你們好,我在接下來的兩個會議中的目標是向你們提供有關編寫 Go 代碼最佳實踐的建議。html
這是一個研討會形式的演講,不會有幻燈片,而是直接從文檔開始。git
貼士: 在這裏有最新的文章連接
https://dave.cheney.net/practical-go/presentations/qcon-china.html
若是我要談論任何編程語言的最佳實踐,我須要一些方法來定義「什麼是最佳」。若是你昨天來到個人主題演講,你會看到 Go 團隊負責人 Russ Cox 的這句話:程序員
Software engineering is what happens to programming when you add time and other programmers. (軟件工程就是你和其餘程序員花費時間在編程上所發生的事情。)
— Russ Cox
Russ 做出了軟件編程與軟件工程的區分。 前者是你本身寫的一個程序。 後者是不少人會隨着時間的推移而開發的產品。 工程師們來來去去,團隊會隨着時間增加與縮小,需求會發生變化,功能會被添加,錯誤也會獲得修復。 這是軟件工程的本質。github
我多是這個房間裏 Go 最先的用戶之一,~但要爭辯說個人資歷給個人見解更可能是假的~。相反,今天我要提的建議是基於我認爲的 Go 語言自己的指導原則:golang
注意:
你會注意到我沒有說性能或併發。 有些語言比 Go 語言快一點,但它們確定不像 Go 語言那麼簡單。 有些語言使併發成爲他們的最高目標,但它們並不具備可讀性及生產力。
性能和併發是重要的屬性,但不如簡單性,可讀性和生產力那麼重要。
咱們爲何要追求簡單? 爲何 Go 語言程序的簡單性很重要?sql
咱們都曾遇到過這樣的狀況: 「我不懂這段代碼」,不是嗎? 咱們都作過這樣的項目:你懼怕作出改變,由於你擔憂它會破壞程序的另外一部分; 你不理解的部分,不知道如何修復。docker
這就是複雜性。 複雜性把可靠的軟件中變成不可靠。 複雜性是殺死軟件項目的罪魁禍首。數據庫
簡單性是 Go 語言的最高目標。 不管咱們編寫什麼程序,咱們都應該贊成這一點:它們很簡單。編程
Readability is essential for maintainability.
(可讀性對於可維護性是相當重要的。)
— Mark Reinhold (2018 JVM 語言高層會議)
爲何 Go 語言的代碼可讀性是很重要的?咱們爲何要爭取可讀性?json
Programs must be written for people to read, and only incidentally for machines to execute. (程序應該被寫來讓人們閱讀,只是順便爲了機器執行。)
— Hal Abelson 與 Gerald Sussman (計算機程序的結構與解釋)
可讀性很重要,由於全部軟件不只僅是 Go 語言程序,都是由人類編寫的,供他人閱讀。執行軟件的計算機則是次要的。
代碼的讀取次數比寫入次數多。一段代碼在其生命週期內會被讀取數百次,甚至數千次。
The most important skill for a programmer is the ability to effectively communicate ideas. (程序員最重要的技能是有效溝通想法的能力。)
— Gastón Jorquera [[1]](( https://gaston.life/books/eff...
可讀性是可以理解程序正在作什麼的關鍵。若是你沒法理解程序正在作什麼,那你但願如何維護它?若是軟件沒法維護,那麼它將被重寫;最後這多是你的公司最後一次投資 Go 語言。
~若是你正在爲本身編寫一個程序,也許它只須要運行一次,或者你是惟一一個曾經看過它的人,而後作任何對你有用的事。~可是,若是是一個不止一我的會貢獻編寫的軟件,或者在很長一段時間內需求、功能或者環境會改變,那麼你的目標必須是你的程序可被維護。
編寫可維護代碼的第一步是確保代碼可讀。
Design is the art of arranging code to work today, and be changeable forever. (設計是安排代碼到工做的藝術,而且永遠可變。)
— Sandi Metz
我要強調的最後一個基本原則是生產力。開發人員的工做效率是一個龐大的主題,但歸結爲此; 你花多少時間作有用的工做,而不是等待你的工具或迷失在一個外國的代碼庫裏。 Go 程序員應該以爲他們能夠經過 Go 語言完成不少工做。
有人開玩笑說, Go 語言是在等待 C++ 語言程序編譯時設計的。快速編譯是 Go 語言的一個關鍵特性,也是吸引新開發人員的關鍵工具。雖然編譯速度仍然是一個持久的戰場,但能夠說,在其餘語言中須要幾分鐘的編譯,在 Go 語言中只需幾秒鐘。這有助於 Go 語言開發人員感覺到與使用動態語言的同行同樣的高效,並且沒有那些語言固有的可靠性問題。
對於開發人員生產力問題更爲基礎的是,Go 程序員意識到編寫代碼是爲了閱讀,所以將讀代碼的行爲置於編寫代碼的行爲之上。Go 語言甚至經過工具和自定義強制執行全部代碼以特定樣式格式化。這就消除了項目中學習特定格式的摩擦,並幫助發現錯誤,由於它們看起來不正確。
Go 程序員不會花費成天的時間來調試難以想象的編譯錯誤。他們也不會將浪費時間在複雜的構建腳本或在生產中部署代碼。最重要的是,他們不用花費時間來試圖瞭解他們的同事所寫的內容。
當他們說語言必須擴展時,Go 團隊會談論生產力。
咱們要討論的第一個主題是標識符。 標識符是一個用來表示名稱的花哨單詞; 變量的名稱,函數的名稱,方法的名稱,類型的名稱,包的名稱等。
Poor naming is symptomatic of poor design. (命名不佳是設計不佳的症狀。)
— Dave Cheney
鑑於 Go 語言的語法有限,咱們爲程序選擇的名稱對咱們程序的可讀性產生了很是大的影響。 可讀性是良好代碼的定義質量,所以選擇好名稱對於 Go 代碼的可讀性相當重要。
Obvious code is important. What you can do in one line you should do in three.
(清晰的代碼很重要。在一行能夠作的你應當分三行作。(if/else
嗎?))
— Ukiah Smith
Go 語言不是爲了單行而優化的語言。 Go 語言不是爲了最少行程序而優化的語言。咱們沒有優化源代碼的大小,也沒有優化輸入所需的時間。
Good naming is like a good joke. If you have to explain it, it’s not funny.
(好的命名就像一個可笑話。若是你必須解釋它,那就很差笑了。)
— Dave Cheney
清晰的關鍵是在 Go 語言程序中咱們選擇的標識名稱。讓咱們談一談所謂好的名字:
讓咱們深刻討論如下這些屬性。
有時候人們批評 Go 語言推薦短變量名的風格。正如 Rob Pike 所說,「 Go 程序員想要正確的長度的標識符」。 [[1]](https://www.lysator.liu.se/c/...
Andrew Gerrand 建議經過對某些事物使用更長的標識,向讀者代表它們具備更高的重要性。
The greater the distance between a name’s declaration and its uses, the longer the name should be. (名字的聲明與其使用之間的距離越大,名字應該越長。)
— Andrew Gerrand [[2]]( https://talks.golang.org/2014...
由此咱們能夠得出一些指導方針:
咱們來舉個栗子:
type Person struct { Name string Age int } // AverageAge returns the average age of people. func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count }
在此示例中,變量 p
的在第 10
行被聲明而且也只在接下來的一行中被引用。 p
在執行函數期間存在時間很短。若是要了解 p
的做用只需閱讀兩行代碼。
相比之下,people
在函數第 7
行參數中被聲明。sum
和 count
也是如此,他們用了更長的名字。讀者必須查看更多的行數來定位它們,所以他們名字更爲獨特。
我能夠選擇 s
替代 sum
以及 c
(或多是 n
)替代 count
,可是這樣作會將程序中的全部變量分量下降到一樣的級別。我能夠選擇 p
來代替 people
,可是用什麼來調用 for ... range
迭代變量。若是用 person
的話看起來很奇怪,由於循環迭代變量的生命時間很短,其名字的長度超出了它的值。
貼士:
與使用段落分解文檔的方式同樣用空行來分解函數。 在AverageAge
中,按順序共有三個操做。 第一個是前提條件,檢查people
是否爲空,第二個是sum
和count
的累積,最後是平均值的計算。
重要的是要意識到關於命名的大多數建議都是須要考慮上下文的。 我想說這是一個原則,而不是一個規則。
兩個標識符 i
和 index
之間有什麼區別。 咱們不能判定一個就比另外一個好,例如
for index := 0; index < len(s); index++ { // }
從根本上說,上面的代碼更具備可讀性
for i := 0; i < len(s); i++ { // }
我認爲它不是,由於就此事而論, i
和 index
的範圍很大可能上僅限於 for 循環的主體,後者的額外冗長性(指 index
)幾乎沒有增長對於程序的理解。
可是,哪些功能更具可讀性?
func (s *SNMP) Fetch(oid []int, index int) (int, error)
或
func (s *SNMP) Fetch(o []int, i int) (int, error)
在此示例中,oid
是 SNMP
對象 ID
的縮寫,所以將其縮短爲 o
意味着程序員必需要將文檔中經常使用符號轉換爲代碼中較短的符號。 相似地將 index
替換成 i
,模糊了 i
所表明的含義,由於在 SNMP
消息中,每一個 OID
的子值稱爲索引。
貼士: 在同一聲明中長和短形式的參數不能混搭。
你不該該用變量的類型來命名你的變量, 就像您不會將寵物命名爲「狗」和「貓」。 出於一樣的緣由,您也不該在變量名字中包含類型的名字。
變量的名稱應描述其內容,而不是內容的類型。 例如:
var usersMap map[string]*User
這個聲明有什麼好處? 咱們能夠看到它是一個 map
,它與 *User
類型有關。 可是 usersMap
是一個 map
,而 Go 語言是一種靜態類型的語言,若是沒有定義變量,不會讓咱們意外地使用到它,所以 Map
後綴是多餘的。
接下來, 若是咱們像這樣來聲明其餘變量:
var ( companiesMap map[string]*Company productsMap map[string]*Products )
usersMap
,companiesMap
和 productsMap
三個 map
類型變量,全部映射字符串都是不一樣的類型。 咱們知道它們是 map
,咱們也知道咱們不能使用其中一個來代替另外一個 - 若是咱們在須要 map[string]*User
的地方嘗試使用 companiesMap
, 編譯器將拋出錯誤異常。 在這種狀況下,很明顯變量中 Map
後綴並無提升代碼的清晰度,它只是增長了要輸入的額外樣板代碼。
個人建議是避免使用任何相似變量類型的後綴。
貼士:
若是users
的描述性都不夠用,那麼usersMap
也不會。
此建議也適用於函數參數。 例如:
type Config struct { // } func WriteConfig(w io.Writer, config *Config)
命名 *Config
參數 config
是多餘的。 咱們知道它是 *Config
類型,就是這樣。
在這種狀況下,若是變量的生命週期足夠短,請考慮使用 conf
或 c
。
若是有更多的 *Config
,那麼將它們稱爲 original
和 updated
比 conf1
和 conf2
會更具描述性,由於前者不太可能被互相誤解。
貼士:
不要讓包名竊取好的變量名。
導入標識符的名稱包括其包名稱。 例如,context
包中的Context
類型將被稱爲context.Context
。 這使得沒法將context
用做包中的變量或類型。
func WriteLog(context context.Context, message string)
上面的栗子將會編譯出錯。 這就是爲何context.Context
類型的一般的本地聲明是ctx
,例如:
func WriteLog(ctx context.Context, message string)
一個好名字的另外一個屬性是它應該是可預測的。 在第一次遇到該名字時讀者就可以理解名字的使用。 當他們遇到常見的名字時,他們應該可以認爲自從他們上次看到它以來它沒有改變意義。
例如,若是您的代碼在處理數據庫請確保每次出現參數時,它都具備相同的名稱。 與其使用 d * sql.DB
,dbase * sql.DB
,DB * sql.DB
和 database * sql.DB
的組合,倒不如統一使用:
db *sql.DB
這樣作使讀者更爲熟悉; 若是你看到db
,你知道它就是 *sql.DB
而且它已經在本地聲明或者由調用者爲你提供。
相似地,對於方法接收器: 在該類型的每一個方法上使用相同的接收者名稱。 在這種類型的方法內部能夠使讀者更容易使用。
注意:
Go 語言中的短接收者名稱慣例與目前提供的建議不一致。 這只是早期作出的選擇之一,已經成爲首選的風格,就像使用CamelCase
而不是snake_case
同樣。貼士:
Go 語言樣式規定接收器具備單個字母名稱或從其類型派生的首字母縮略詞。 你可能會發現接收器的名稱有時會與方法中參數的名稱衝突。 在這種狀況下,請考慮將參數名稱命名稍長,而且不要忘記一致地使用此新參數名稱。
最後,某些單字母變量傳統上與循環和計數相關聯。 例如,i
,j
和 k
一般是簡單 for
循環的循環概括變量。n
一般與計數器或累加器相關聯。v
是通用編碼函數中值的經常使用簡寫,k
一般用於 map
的鍵,s
一般用做字符串類型參數的簡寫。
與上面的 db
示例同樣,程序員認爲 i
是一個循環概括變量。 若是確保 i
始終是循環變量,並且不在 for
循環以外的其餘地方中使用。 當讀者遇到一個名爲 i
或 j
的變量時,他們知道循環就在附近。
貼士:
若是你發現本身有如此多的嵌套循環,i
,j
和k
變量都沒法知足時,這個時候可能就是須要將函數分解成更小的函數。
Go 至少有六種不一樣的方式來聲明變量
var x int = 1
var x = 1
var x int; x = 1
var x = int(1)
x := 1
我確信還有更多我沒有想到的。 這多是 Go 語言的設計師意識到的一個錯誤,但如今改變它爲時已晚。 經過全部這些不一樣的方式來聲明變量,咱們如何避免每一個 Go 程序員選擇本身的風格?
我想就如何在程序中聲明變量提出建議。 這是我儘量使用的風格。
var
。 當聲明變量稍後將在函數中初始化時,請使用 var
關鍵字。var players int // 0 var things []Thing // an empty slice of Things var thing Thing // empty Thing struct json.Unmarshall(reader, &thing)
var
表示此變量已被聲明爲指定類型的零值。 這也與使用 var
而不是短聲明語法在包級別聲明變量的要求一致 - 儘管我稍後會說你根本不該該使用包級變量。
:=
。 在同時聲明和初始化變量時,也就是說咱們不會將變量初始化爲零值,我建議使用短變量聲明。 這使得讀者清楚地知道 :=
左側的變量是初始化過的。爲了解釋緣由,讓咱們看看前面的例子,但此次是初始化每一個變量:
var players int = 0 var things []Thing = nil var thing *Thing = new(Thing) json.Unmarshall(reader, thing)
在第一個和第三個例子中,由於在 Go 語言中沒有從一種類型到另外一種類型的自動轉換; 賦值運算符左側的類型必須與右側的類型相同。 編譯器能夠從右側的類型推斷出聲明的變量的類型,上面的例子能夠更簡潔地寫爲:
var players = 0 var things []Thing = nil var thing = new(Thing) json.Unmarshall(reader, thing)
咱們將 players
初始化爲 0
,但這是多餘的,由於 0
是 players
的零值。 所以,要明確地表示使用零值, 咱們將上面例子改寫爲:
var players int
第二個聲明如何? 咱們不能省略類型而寫做:
var things = nil
由於 nil
沒有類型。 [[2]](https://speakerdeck.com/campo...,咱們有一個選擇,若是咱們要使用切片的零值則寫做:
var things []Thing
或者咱們要建立一個有零元素的切片則寫做:
var things = make([]Thing, 0)
若是咱們想要後者那麼這不是切片的零值,因此咱們應該向讀者說明咱們經過使用簡短的聲明形式作出這個選擇:
things := make([]Thing, 0)
這告訴讀者咱們已選擇明確初始化事物。
下面是第三個聲明,
var thing = new(Thing)
既是初始化了變量又引入了一些 Go 程序員不喜歡的 new
關鍵字的罕見用法。 若是咱們用推薦地簡短聲明語法,那麼就變成了:
thing := new(Thing)
這清楚地代表 thing
被初始化爲 new(Thing)
的結果 - 一個指向 Thing
的指針 - 但依舊咱們使用了 new
地罕見用法。 咱們能夠經過使用緊湊的文字結構初始化形式來解決這個問題,
thing := &Thing{}
與 new(Thing)
相同,這就是爲何一些 Go 程序員對重複感到不滿。 然而,這意味着咱們使用指向 Thing{}
的指針初始化了 thing
,也就是 Thing
的零值。
相反,咱們應該認識到 thing
被聲明爲零值,並使用地址運算符將 thing
的地址傳遞給 json.Unmarshall
var thing Thing json.Unmarshall(reader, &thing)
貼士:
固然,任何經驗法則,都有例外。 例如,有時兩個變量密切相關,這樣寫會很奇怪:
var min int max := 1000
若是這樣聲明可能更具可讀性
min, max := 0, 1000
綜上所述:
在沒有初始化的狀況下聲明變量時,請使用 var
語法。
聲明並初始化變量時,請使用 :=
。
貼士:
使複雜的聲明顯而易見。
當事情變得複雜時,它看起來就會很複雜。例如
var length uint32 = 0x80
這裏length
可能要與特定數字類型的庫一塊兒使用,而且length
明確選擇爲uint32
類型而不是短聲明形式:
length := uint32(0x80)
在第一個例子中,我故意違反了規則, 使用
var
聲明帶有初始化變量的。 這個決定與個人經常使用的形式不一樣,這給讀者一個線索,告訴他們一些不尋常的事情將會發生。
我談到了軟件工程的目標,即編寫可讀及可維護的代碼。 所以,您可能會將大部分職業生涯用於你不是惟一做者的項目。 我在這種狀況下的建議是遵循項目自身風格。
在文件中間更改樣式是不和諧的。 即便不是你喜歡的方式,對於維護而言一致性比你的我的偏好更有價值。 個人經驗法則是: 若是它經過了 gofmt
,那麼一般不值得再作代碼審查。
貼士:
若是要在代碼庫中進行重命名,請不要將其混合到另外一個更改中。 若是有人使用git bisect
,他們不想經過數千行重命名來查找您更改的代碼。
在咱們繼續討論更大的項目以前,我想花幾分鐘時間談論一下注釋。
Good code has lots of comments, bad code requires lots of comments.
(好的代碼有不少註釋,壞代碼須要不少註釋。)
— Dave Thomas and Andrew Hunt (The Pragmatic Programmer)
註釋對 Go 語言程序的可讀性很是重要。 註釋應該作的三件事中的一件:
第一種形式是公共符號註釋的理想選擇:
// Open opens the named file for reading. // If successful, methods on the returned file can be used for reading.
第二種形式很是適合在方法中註釋:
// queue all dependant actions var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) }
第三種形式是獨一無二的,由於它不會取代前兩種形式,但與此同時它並不能代替前兩種形式。 此形式的註解用以解釋代碼的外部因素。 這些因素脫離上下文後一般很難理解,此註釋的爲了提供這種上下文。
return &v2.Cluster_CommonLbConfig{ // Disable HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, }
在此示例中,沒法清楚地明白 HealthyPanicThreshold
設置爲零百分比的效果。 須要註釋 0
值將禁用 panic
閥值。
我以前談過,變量或常量的名稱應描述其目的。 向變量或常量添加註釋時,該註釋應描述變量內容,而不是變量目的。
const randomNumber = 6 // determined from an unbiased die
在此示例中,註釋描述了爲何 randomNumber
被賦值爲6,以及6來自哪裏。 註釋沒有描述 randomNumber
的使用位置。 還有更多的栗子:
const ( StatusContinue = 100 // RFC 7231, 6.2.1 StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2 StatusProcessing = 102 // RFC 2518, 10.1 StatusOK = 200 // RFC 7231, 6.3.1
在HTTP的上下文中,數字 100
被稱爲 StatusContinue
,如 RFC 7231 第 6.2.1 節中所定義。
貼士:
對於沒有初始值的變量,註釋應描述誰負責初始化此變量。
// sizeCalculationDisabled indicates whether it is safe // to calculate Types' widths and alignments. See dowidth. var sizeCalculationDisabled bool
這裏的註釋讓讀者知道dowidth
函數負責維護sizeCalculationDisabled
的狀態。隱藏在衆目睽睽下
這個提示來自Kate Gregory[[3]](https://www.infoq.com/article...。有時你會發現一個更好的變量名稱隱藏在註釋中。
// registry of SQL drivers var registry = make(map[string]*sql.Driver)
註釋是由做者添加的,由於registry
沒有充分解釋其目的 - 它是一個註冊表,但註冊的是什麼?經過將變量重命名爲
sqlDrivers
,如今能夠清楚地知道此變量的目的是保存SQL驅動程序。
var sqlDrivers = make(map[string]*sql.Driver)
以前的註釋就是多餘的,能夠刪除。
godoc
是包的文檔,因此應該始終爲包中聲明的每一個公共符號 — 變量、常量、函數以及方法添加註釋。
如下是 Google Style
指南中的兩條規則:
package ioutil // ReadAll reads from r until an error or EOF and returns the data it read. // A successful call returns err == nil, not err == EOF. Because ReadAll is // defined to read from src until EOF, it does not treat an EOF from Read // as an error to be reported. func ReadAll(r io.Reader) ([]byte, error)
這條規則有一個例外; 您不須要註釋實現接口的方法。 具體不要像下面這樣作:
// Read implements the io.Reader interface func (r *FileReader) Read(buf []byte) (int, error)
這個註釋什麼也沒說。 它沒有告訴你這個方法作了什麼,更糟糕是它告訴你去看其餘地方的文檔。 在這種狀況下,我建議徹底刪除該註釋。
這是 io
包中的一個例子
// LimitReader returns a Reader that reads from r // but stops with EOF after n bytes. // The underlying implementation is a *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // A LimitedReader reads from R but limits the amount of // data returned to just N bytes. Each call to Read // updates N to reflect the new amount remaining. // Read returns EOF when N <= 0 or when the underlying R returns EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if l.N <= 0 { return 0, EOF } if int64(len(p)) > l.N { p = p[0:l.N] } n, err = l.R.Read(p) l.N -= int64(n) return }
請注意,LimitedReader
的聲明就在使用它的函數以前,而 LimitedReader.Read
的聲明遵循 LimitedReader
自己的聲明。 儘管 LimitedReader.Read
自己沒有文檔,但它清楚地代表它是 io.Reader
的一個實現。
貼士:
在編寫函數以前,請編寫描述函數的註釋。 若是你發現很難寫出註釋,那麼這就代表你將要編寫的代碼很難理解。
Don’t comment bad code — rewrite it
— Brian Kernighan
粗劣的代碼的註釋高亮顯示是不夠的。 若是你遇到其中一條註釋,則應提出問題,以提醒您稍後重構。 只要技術債務數額已知,它是能夠忍受的。
標準庫中的慣例是注意到它的人用 TODO(username)
的樣式來註釋。
// TODO(dfc) this is O(N^2), find a faster way to do this.
註釋 username
不是該人承諾要解決該問題,但在解決問題時他們多是最好的人選。 其餘項目使用 TODO
與日期或問題編號來註釋。
Good code is its own best documentation. As you’re about to add a comment, ask yourself, 'How can I improve the code so that this comment isn’t needed?' Improve the code and then document it to make it even clearer.
好的代碼是最好的文檔。 在即將添加註釋時,請問下本身,「如何改進代碼以便不須要此註釋?' 改進代碼使其更清晰。
— Steve McConnell
函數應該只作一件事。 若是你發現本身在註釋一段與函數的其他部分無關的代碼,請考慮將其提取到它本身的函數中。
除了更容易理解以外,較小的函數更易於隔離測試,將代碼隔離到函數中,其名稱多是所需的全部文檔。
Write shy code - modules that don’t reveal anything unnecessary to other modules and that don’t rely on other modules' implementations.
編寫謹慎的代碼 - 不向其餘模塊透露任何沒必要要的模塊,而且不依賴於其餘模塊的實現。
— Dave Thomas
每一個 Go 語言的包實際上都是它一個小小的 Go 語言程序。 正如函數或方法的實現對調用者而言並不重要同樣,包的公共API-其函數、方法以及類型的實現對於調用者來講也並不重要。
一個好的 Go 語言包應該具備低程度的源碼級耦合,這樣,隨着項目的增加,對一個包的更改不會跨代碼庫級聯。 這些世界末日的重構嚴格限制了代碼庫的變化率以及在該代碼庫中工做的成員的生產率。
在本節中,咱們將討論如何設計包,包括包的名稱,命名類型以及編寫方法和函數的技巧。
編寫一個好的 Go 語言包從包的名稱開始。將你的包名用一個詞來描述它。
正如我在上一節中談到變量的名稱同樣,包的名稱也很是重要。我遵循的經驗法則不是「我應該在這個包中放入什麼類型的?」。相反,我要問是「該包提供的服務是什麼?」一般這個問題的答案不是「這個包提供 X
類型」,而是「這個包提供 HTTP
」。
貼士:
以包所提供的內容來命名,而不是它包含的內容。
在項目中,每一個包名稱應該是惟一的。包的名稱應該描述其目的的建議很容易理解 - 若是你發現有兩個包須要用相同名稱,它多是:
base
,common
或 util
的包名稱很差的包名的常見狀況是 utility
包。這些包一般是隨着時間的推移一些幫助程序和工具類的包。因爲這些包包含各類不相關的功能,所以很難根據包提供的內容來描述它們。這一般會致使包的名稱來自包含的內容 - utilities
。
像 utils
或 helper
這樣的包名稱一般出如今較大的項目中,這些項目已經開發了深層次包的結構,而且但願在不遇到導入循環的狀況下共享 helper
函數。經過將 utility
程序函數提取到新的包中,導入循環會被破壞,但因爲該包源於項目中的設計問題,所以其包名稱不反映其目的,僅反映其爲了打破導入循環。
我建議改進 utils
或 helpers
包的名稱是分析它們的調用位置,若是可能的話,將相關的函數移動到調用者的包中。即便這涉及複製一些 helper
程序代碼,這也比在兩個程序包之間引入導入依賴項更好。
[A little] duplication is far cheaper than the wrong abstraction.
([一點點]重複比錯誤的抽象的性價比高不少。)
— Sandy Metz
在使用 utility
程序的狀況下,最好選多個包,每一個包專一於單個方面,而不是選單一的總體包。
貼士:
使用複數形式命名utility
包。例如strings
來處理字符串。
當兩個或多個實現共有的功能或客戶端和服務器的常見類型被重構爲單獨的包時,一般會找到名稱相似於 base
或 common
的包。我相信解決方案是減小包的數量,將客戶端,服務器和公共代碼組合到一個以包的功能命名的包中。
例如,net/http
包沒有 client
和 server
的分包,而是有一個 client.go
和 server.go
文件,每一個文件都有各自的類型,還有一個 transport.go
文件,用於公共消息傳輸代碼。
貼士:
標識符的名稱包括其包名稱。
重要的是標識符的名稱包括其包的名稱。
- 當由另外一個包引用時,
net/http
包中的 Get 函數變爲http.Get
。- 當導入到其餘包中時,
strings
包中的Reader
類型變爲strings.Reader
。net
包中的Error
接口顯然與網絡錯誤有關。
return
而不是深度嵌套因爲 Go 語言的控制流不使用 exception
,所以不須要爲 try
和 catch
塊提供頂級結構而深度縮進代碼。Go 語言代碼不是成功的路徑愈來愈深地嵌套到右邊,而是以一種風格編寫,其中隨着函數的進行,成功路徑繼續沿着屏幕向下移動。 個人朋友 Mat Ryer 將這種作法稱爲「視線」編碼。[[4]](https://medium.com/@matryer/l...
這是經過使用 guard clauses
來實現的; 在進入函數時是具備斷言前提條件的條件塊。 這是一個來自 bytes
包的例子:
func (b *Buffer) UnreadRune() error { if b.lastRead <= opInvalid { return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil }
進入 UnreadRune
後,將檢查 b.lastRead
的狀態,若是以前的操做不是 ReadRune
,則會當即返回錯誤。 以後,函數的其他部分繼續進行 b.lastRead
大於 opInvalid
的斷言。
與沒有 guard clause
的相同函數進行比較,
func (b *Buffer) UnreadRune() error { if b.lastRead > opInvalid { if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") }
最多見的執行成功的狀況是嵌套在第一個if條件內,成功的退出條件是 return nil
,並且必須經過仔細匹配大括號來發現。 函數的最後一行是返回一個錯誤,而且被調用者必須追溯到匹配的左括號,以瞭解什麼時候執行到此點。
對於讀者和維護程序員來講,這更容易出錯,所以 Go 語言更喜歡使用 guard clauses
並儘早返回錯誤。
假設變量沒有初始化,每一個變量聲明都會自動初始化爲與零內存的內容相匹配的值。 這就是零值。 值的類型決定了其零值; 對於數字類型,它爲 0
,對於指針類型爲 nil
,slices
、map
和 channel
一樣是 nil
。
始終設置變量爲已知默認值的屬性對於程序的安全性和正確性很是重要,而且能夠使 Go 語言程序更簡單、更緊湊。 這就是 Go 程序員所說的「給你的結構一個有用的零值」。
對於 sync.Mutex
類型。sync.Mutex
包含兩個未公開的整數字段,它們用來表示互斥鎖的內部狀態。 每當聲明 sync.Mutex
時,其字段會被設置爲 0
初始值。sync.Mutex
利用此屬性來編寫,使該類型可直接使用而無需初始化。
type MyInt struct { mu sync.Mutex val int } func main() { var i MyInt // i.mu is usable without explicit initialisation. i.mu.Lock() i.val++ i.mu.Unlock() }
另外一個利用零值的類型是 bytes.Buffer
。您能夠聲明 bytes.Buffer
而後就直接寫入而無需初始化。
func main() { var b bytes.Buffer b.WriteString("Hello, world!\n") io.Copy(os.Stdout, &b) }
切片的一個有用屬性是它們的零值 nil
。若是咱們看一下切片運行時 header
的定義就不難理解:
type slice struct { array *[...]T // pointer to the underlying array len int cap int }
此結構的零值意味着 len
和 cap
的值爲 0
,而 array
(指向保存切片的內容數組的指針)將爲 nil
。這意味着你不須要 make
切片,你只需聲明它便可。
func main() { // s := make([]string, 0) // s := []string{} var s []string s = append(s, "Hello") s = append(s, "world") fmt.Println(strings.Join(s, " ")) }
注意:
var s []string
相似於它上面的兩條註釋行,但並不徹底相同。值爲nil
的切片與具備零長度的切片就能夠來相互比較。如下代碼將輸出false
。
func main() { var s1 = []string{} var s2 []string fmt.Println(reflect.DeepEqual(s1, s2)) }
nil pointers
-- 未初始化的指針變量的一個有用屬性是你能夠在具備 nil
值的類型上調用方法。它能夠簡單地用於提供默認值。
type Config struct { path string } func (c *Config) Path() string { if c == nil { return "/usr/home" } return c.path } func main() { var c1 *Config var c2 = &Config{ path: "/export", } fmt.Println(c1.Path(), c2.Path()) }
編寫可維護程序的關鍵是它們應該是鬆散耦合的 - 對一個程序包的更改應該不多影響另外一個不直接依賴於第一個程序包的程序包。
在 Go 語言中有兩種很好的方法能夠實現鬆散耦合
在 Go 語言中,咱們能夠在函數或方法範圍以及包範圍內聲明變量。當變量是公共的時,給定一個以大寫字母開頭的標識符,那麼它的範圍對於整個程序來講其實是全局的 - 任何包均可以隨時觀察該變量的類型和內容。
可變全局狀態引入程序的獨立部分之間的緊密耦合,由於全局變量成爲程序中每一個函數的不可見參數!若是該變量的類型發生更改,則能夠破壞依賴於全局變量的任何函數。若是程序的另外一部分更改了該變量,則能夠破壞依賴於全局變量狀態的任何函數。
若是要減小全局變量所帶來的耦合,
咱們來談談如何將包組合到項目中。 一般一個項目是一個 git
倉庫,但在將來 Go 語言開發人員會交替地使用 module
和 project
。
就像一個包,每一個項目都應該有一個明確的目的。 若是你的項目是一個庫,它應該提供一件事,好比 XML
解析或記錄。 您應該避免在一個包實現多個目的,這將有助於避免成爲 common
庫。
貼士:
據個人經驗,common
庫最終會與其最大的調用者緊密相連,在沒有升級該庫與最大調用者的狀況下是很難修復的,還會帶來了許多無關的更改以及API破壞。
若是你的項目是應用程序,如 Web
應用程序,Kubernetes
控制器等,那麼項目中可能有一個或多個 main
程序包。 例如,我編寫的 Kubernetes
控制器有一個 cmd/contour
包,既能夠做爲部署到 Kubernetes
集羣的服務器,也能夠做爲調試目的的客戶端。
對於從其餘語言過渡到 Go 語言的程序員來講,我傾向於在代碼審查中提到的一件事是他們會過分使用包。
Go 語言沒有提供有關可見性的詳細方法; Java有 public
、protected
、private
以及隱式 default
的訪問修飾符。 沒有 C++
的 friend
類概念。
在 Go 語言中,咱們只有兩個訪問修飾符,public
和 private
,由標識符的第一個字母的大小寫表示。 若是標識符是公共的,則其名稱以大寫字母開頭,該標識符可用於任何其餘 Go 語言包的引用。
注意:
你可能會聽到人們說exported
與not exported
, 跟public
和private
是同義詞。
鑑於包的符號的訪問有限控件,Go 程序員應遵循哪些實踐來避免建立過於複雜的包層次結構?
貼士:
除cmd/
和internal/
以外的每一個包都應包含一些源代碼。
個人建議是選擇更少,更大的包。 你應該作的是不建立新的程序包。 這將致使太多類型被公開,爲你的包建立一個寬而淺的API。
如下部分將更爲詳細地探討這一建議。
貼士:
來自Java
?
若是您來自Java
或C#
,請考慮這一經驗法則 --Java
包至關於單個.go
源文件。 - Go 語言包至關於整個Maven
模塊或.NET
程序集。
import
語句將代碼排列到文件中若是你按照包提供的內容來安排你的程序包,是否須要對 Go 包中的文件也執行相同的操做?何時應該將 .go
文件拆分紅多個文件?何時應該考慮整合 .go
文件?
如下是個人經驗法則:
.go
文件。爲該文件指定與文件夾名稱相同的名稱。例如: package http
應放在名爲 http
的目錄中名爲 http.go
的文件中。messages.go
包含 Request
和 Response
類型,client.go
包含 Client
類型,server.go
包含 Server
類型。import
的聲明相似,請考慮將它們組合起來。或者肯定 import
集之間的差別並移動它們。messages.go
可能負責網絡的 HTTP
請求和響應,http.go
可能包含底層網絡處理邏輯,client.go
和 server.go
實現 HTTP
業務邏輯請求的實現或路由等等。貼士: 首選名詞爲源文件命名。注意:
Go編譯器並行編譯每一個包。 在一個包中,編譯器並行編譯每一個函數(方法只是 Go 語言中函數的另外一種寫法)。 更改包中代碼的佈局不會影響編譯時間。
go tool
支持在兩個地方編寫 testing
包測試。假設你的包名爲 http2
,您能夠編寫 http2_test.go
文件並使用包 http2
聲明。這樣作會編譯 http2_test.go
中的代碼,就像它是 http2
包的一部分同樣。這就是內部測試。
go tool
還支持一個特殊的包聲明,以 test
爲結尾,即 package http_test
。這容許你的測試文件與代碼一塊兒存放在同一個包中,可是當編譯時這些測試不是包的代碼的一部分,它們存在於本身的包中。就像調用另外一個包的代碼同樣來編寫測試。這被稱爲外部測試。
我建議在編寫單元測試時使用內部測試。這樣你就能夠直接測試每一個函數或方法,避免外部測試干擾。
可是,你應該將 Example
測試函數放在外部測試文件中。這確保了在 godoc
中查看時,示例具備適當的包名前綴而且能夠輕鬆地進行復制粘貼。
貼士:
避免複雜的包層次結構,抵制應用分類法
Go 語言包的層次結構對於go tool
沒有任何意義除了下一節要說的。 例如,net/http
包不是一個子包或者net
包的子包。若是在項目中建立了不包含
.go
文件的中間目錄,則可能沒法遵循此建議。
internal
包來減小公共API若是項目包含多個包,可能有一些公共的函數,這些函數旨在供項目中的其餘包使用,但不打算成爲項目的公共API的一部分。 若是你發現是這種狀況,那麼 go tool
會識別一個特殊的文件夾名稱 - 而非包名稱 - internal/
可用於放置對項目公開的代碼,但對其餘項目是私有的。
要建立此類包,請將其放在名爲 internal/
的目錄中,或者放在名爲 internal/
的目錄的子目錄中。 當 go
命令在其路徑中看到導入包含 internal
的包時,它會驗證執行導入的包是否位於 internal
目錄。
例如,.../a/b/c/internal/d/e/f
的包只能經過以 .../a/b/c/
爲根目錄的代碼被導入。 它沒法經過 .../a/b/g
或任何其餘倉庫中的代碼導入。[[5]](https://golang.org/doc/go1.4#...
main
包內容儘量的少main
函數和 main
包的內容應儘量少。 這是由於 main.main
充當單例; 程序中只能有一個 main
函數,包括 tests
。
由於 main.main
是一個單例,假設 main
函數中須要執行不少事情,main.main
只會在 main.main
或 main.init
中調用它們而且只調用一次。 這使得爲 main.main
編寫代碼測試變得很困難,所以你應該將全部業務邏輯從 main
函數中移出,最好是從 main
包中移出。
貼士:
main
應該作解析flags
,開啓數據庫鏈接、開啓日誌等,而後將執行交給更高一級的對象。
我今天要給出的最後一條建議是設計, 我認爲也是最重要的。
到目前爲止我提出的全部建議都是建議。 這些是我嘗試編寫 Go 語言的方式,但我不打算在代碼審查中拼命推廣。
可是,在審查 API 時, 我就不會那麼寬容了。 這是由於到目前爲止我所談論的全部內容都是能夠修復並且不會破壞向後兼容性; 它們在很大程度上是實現的細節。
當涉及到軟件包的公共 API 時,在初始設計中投入大量精力是值得的,由於稍後更改該設計對於已經使用 API 的人來講會是破壞性的。
APIs should be easy to use and hard to misuse.
(API 應該易於使用且難以被誤用)
— Josh Bloch [[3]]( https://www.infoq.com/article...
若是你從這個演講中帶走任何東西,那應該是 Josh Bloch 的建議。 若是一個 API 很難用於簡單的事情,那麼 API 的每次調用都會很複雜。 當 API 的實際調用很複雜時,它就會便得不那麼明顯,並且會更容易被忽視。
簡單, 但難以正確使用的 API 是採用兩個或更多相同類型參數的 API。 讓咱們比較兩個函數簽名:
func Max(a, b int) int func CopyFile(to, from string) error
這兩個函數有什麼區別? 顯然,一個返回兩個數字最大的那個,另外一個是複製文件,但這不重要。
Max(8, 10) // 10 Max(10, 8) // 10
Max
是可交換的; 參數的順序可有可無。 不管是 8 比 10 仍是 10 比 8,最大的都是 10。
可是,卻不適用於 CopyFile
。
CopyFile("/tmp/backup", "presentation.md") CopyFile("presentation.md", "/tmp/backup")
這些聲明中哪個備份了 presentation.md
,哪個用上週的版本覆蓋了 presentation.md
? 沒有文檔,你沒法分辨。 若是沒有查閱文檔,代碼審查員也沒法知道你寫對了順序。
一種可能的解決方案是引入一個 helper
類型,它會負責如何正確地調用 CopyFile
。
type Source string func (src Source) CopyTo(dest string) error { return CopyFile(dest, string(src)) } func main() { var from Source = "presentation.md" from.CopyTo("/tmp/backup") }
經過這種方式,CopyFile
老是能被正確調用 - 還能夠經過單元測試 - 而且能夠被設置爲私有,進一步下降了誤用的可能性。
貼士: 具備多個相同類型參數的API難以正確使用。
幾年前,我就對 functional options
[[7]](https://commandcenter.blogspo... 進行過討論[[6]](https://dave.cheney.net/2014/...,使 API 更易用於默認用例。
本演講的主旨是你應該爲常見用例設計 API。 另外一方面, API 不該要求調用者提供他們不在意參數。
nil
做爲參數本章開始時我建議是不要強迫提供給 API 的調用者他們不在意的參數。 這就是我要說的爲默認用例設計 API。
這是 net/http
包中的一個例子
package http // ListenAndServe listens on the TCP network address addr and then calls // Serve with handler to handle requests on incoming connections. // Accepted connections are configured to enable TCP keep-alives. // // The handler is typically nil, in which case the DefaultServeMux is used. // // ListenAndServe always returns a non-nil error. func ListenAndServe(addr string, handler Handler) error {
ListenAndServe
有兩個參數,一個用於監聽傳入鏈接的 TCP
地址,另外一個用於處理 HTTP
請求的 http.Handler
。Serve
容許第二個參數爲 nil
,須要注意的是調用者一般會傳遞 nil
,表示他們想要使用 http.DefaultServeMux
做爲隱含參數。
如今,Serve
的調用者有兩種方式能夠作一樣的事情。
http.ListenAndServe("0.0.0.0:8080", nil) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
二者徹底相同。
這種 nil
行爲是病毒式的。 http
包也有一個 http.Serve
幫助類,你能夠合理地想象一下 ListenAndServe
是這樣構建的
func ListenAndServe(addr string, handler Handler) error { l, err := net.Listen("tcp", addr) if err != nil { return err } defer l.Close() return Serve(l, handler) }
由於 ListenAndServe
容許調用者爲第二個參數傳遞 nil
,因此 http.Serve
也支持這種行爲。 事實上,http.Serve
實現了若是 handler
是nil
,使用 DefaultServeMux
的邏輯。 參數可爲 nil
可能會致使調用者認爲他們能夠爲兩個參數都使用 nil
。 像下面這樣:
http.Serve(nil, nil)
會致使 panic
。
貼士:
不要在同一個函數簽名中混合使用可爲nil
和不能爲nil
的參數。
http.ListenAndServe
的做者試圖在常見狀況下讓使用 API 的用戶更輕鬆些,但極可能會讓該程序包更難以被安全地使用。
使用 DefaultServeMux
或使用 nil
沒有什麼區別。
const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", nil)
對比
const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
這種混亂值得拯救嗎?
const root = http.Dir("/htdocs") mux := http.NewServeMux() http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", mux)
貼士: 認真考慮helper
函數會節省很多時間。 清晰要比簡潔好。貼士:
避免公共 API 使用測試參數
避免在公開的 API 上使用僅在測試範圍上不一樣的值。 相反,使用Public wrappers
隱藏這些參數,使用輔助方式來設置測試範圍中的屬性。
[]T
參數編寫一個帶有切片參數的函數或方法是很常見的。
func ShutdownVMs(ids []string) error
這只是我編的一個例子,但它與我所寫的不少代碼相同。 這裏的問題是他們假設他們會被調用於多個條目。 可是不少時候這些類型的函數只用一個參數調用,爲了知足函數參數的要求,它必須打包到一個切片內。
另外,由於 ids
參數是切片,因此你能夠將一個空切片或 nil
傳遞給該函數,編譯也沒什麼錯誤。 可是這會增長額外的測試負載,由於你應該涵蓋這些狀況在測試中。
舉一個這類 API 的例子,最近我重構了一條邏輯,要求我設置一些額外的字段,若是一組參數中至少有一個非零。 邏輯看起來像這樣:
if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 { // apply the non zero parameters }
因爲 if
語句變得很長,我想將簽出的邏輯拉入其本身的函數中。 這就是我提出的:
// anyPostive indicates if any value is greater than zero. func anyPositive(values ...int) bool { for _, v := range values { if v > 0 { return true } } return false }
這就可以向讀者明確內部塊的執行條件:
if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) { // apply the non zero parameters }
可是 anyPositive
還存在一個問題,有人可能會這樣調用它:
if anyPositive() { ... }
在這種狀況下,anyPositive
將返回 false
,由於它不會執行迭代而是當即返回 false
。對比起若是 anyPositive
在沒有傳遞參數時返回 true
, 這還不算世界上最糟糕的事情。
然而,若是咱們能夠更改 anyPositive
的簽名以強制調用者應該傳遞至少一個參數,那會更好。咱們能夠經過組合正常和可變參數來作到這一點,以下所示:
// anyPostive indicates if any value is greater than zero. func anyPositive(first int, rest ...int) bool { if first > 0 { return true } for _, v := range rest { if v > 0 { return true } } return false }
如今不能使用少於一個參數來調用 anyPositive
。
假設我須要編寫一個將 Document
結構保存到磁盤的函數的任務。
// Save writes the contents of doc to the file f. func Save(f *os.File, doc *Document) error
我能夠指定這個函數 Save
,它將 *os.File
做爲寫入 Document
的目標。但這樣作會有一些問題
Save
的簽名排除了將數據寫入網絡位置的選項。假設網絡存儲可能在之後成爲需求,則此功能的簽名必須改變,從而影響其全部調用者。
Save
測試起來也很麻煩,由於它直接操做磁盤上的文件。所以,爲了驗證其操做,測試時必須在寫入文件後再讀取該文件的內容。
並且我必須確保 f
被寫入臨時位置而且隨後要將其刪除。
*os.File
還定義了許多與 Save
無關的方法,好比讀取目錄並檢查路徑是不是符號連接。 若是 Save
函數的簽名只用 *os.File
的相關內容,那將會頗有用。
咱們能作什麼 ?
// Save writes the contents of doc to the supplied // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error
使用 io.ReadWriteCloser
,咱們能夠應用接口隔離原則來從新定義 Save
以獲取更通用文件形式。
經過此更改,任何實現 io.ReadWriteCloser
接口的類型均可以替換之前的 *os.File
。
這使 Save
在其應用程序中更普遍,並向 Save
的調用者闡明 *os.File
類型的哪些方法與其操做有關。
並且,Save
的做者也不能夠在 *os.File
上調用那些不相關的方法,由於它隱藏在 io.ReadWriteCloser
接口後面。
但咱們能夠進一步採用接口隔離原則。
首先,若是 Save
遵循單一功能原則,它不可能讀取它剛剛寫入的文件來驗證其內容 - 這應該是另外一段代碼的功能。
// Save writes the contents of doc to the supplied // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error
所以,咱們能夠將咱們傳遞給 Save
的接口的規範縮小到只寫和關閉。
其次,經過向 Save
提供一個關閉其流的機制,使其看起來仍然像一個文件,這就提出了在什麼狀況下關閉 wc
的問題。
可能 Save
會無條件地調用 Close
,或者在成功的狀況下調用 Close
。
這給 Save
的調用者帶來了問題,由於它可能但願在寫入文檔後將其餘數據寫入流。
// Save writes the contents of doc to the supplied // Writer. func Save(w io.Writer, doc *Document) error
一個更好的解決方案是從新定義 Save
僅使用 io.Writer
,它只負責將數據寫入流。
將接口隔離原則應用於咱們的 Save
功能,同時, 就需求而言, 得出了最具體的一個函數 - 它只須要一個可寫的東西 - 而且它的功能最通用,如今咱們能夠使用 Save
將咱們的數據保存到實現 io.Writer
的任何事物中。
[[譯註: 不理解設計原則部分的同窗能夠閱讀 Dave 大神的另外一篇《Go 語言 SOLID 設計》]](https://www.jianshu.com/p/0ae...
我已經給出了幾個關於錯誤處理的演示文稿[[8]](https://dave.cheney.net/2016/...,並在個人博客上寫了不少關於錯誤處理的文章。我在昨天的會議上也講了不少關於錯誤處理的內容,因此在這裏再也不贅述。
相反,我想介紹與錯誤處理相關的兩個其餘方面。
若是你昨天在個人演講中,我談到了改進錯誤處理的提案。可是你知道有什麼比改進錯誤處理的語法更好嗎?那就是根本不須要處理錯誤。
注意:
我不是說「刪除你的錯誤處理」。個人建議是,修改你的代碼,這樣就不用處理錯誤了。
本節從 John Ousterhout 最近的著做「軟件設計哲學」[[9]](https://www.amazon.com/Philos...。該書的其中一章是「定義不存在的錯誤」。咱們將嘗試將此建議應用於 Go 語言。
讓咱們編寫一個函數來計算文件中的行數。
func CountLines(r io.Reader) (int, error) { var ( br = bufio.NewReader(r) lines int err error ) for { _, err = br.ReadString('\n') lines++ if err != nil { break } } if err != io.EOF { return 0, err } return lines, nil }
因爲咱們遵循前面部分的建議,CountLines
須要一個 io.Reader
,而不是一個 *File
;它的任務是調用者爲咱們想要計算的內容提供 io.Reader
。
咱們構造一個 bufio.Reader
,而後在一個循環中調用 ReadString
方法,遞增計數器直到咱們到達文件的末尾,而後咱們返回讀取的行數。
至少這是咱們想要編寫的代碼,可是這個函數因爲須要錯誤處理而變得更加複雜。 例如,有這樣一個奇怪的結構:
_, err = br.ReadString('\n') lines++ if err != nil { break }
咱們在檢查錯誤以前增長了行數,這樣作看起來很奇怪。
咱們必須以這種方式編寫它的緣由是,若是在遇到換行符以前就讀到文件結束,則 ReadString
將返回錯誤。若是文件中沒有換行符,一樣會出現這種狀況。
爲了解決這個問題,咱們從新排列邏輯增來加行數,而後查看是否須要退出循環。
注意:
這個邏輯仍然不完美,你能發現錯誤嗎?
可是咱們尚未完成檢查錯誤。當 ReadString
到達文件末尾時,預期它會返回 io.EOF
。ReadString
須要某種方式在沒有什麼可讀時來中止。所以,在咱們將錯誤返回給 CountLine
的調用者以前,咱們須要檢查錯誤是不是 io.EOF
,若是不是將其錯誤返回,不然咱們返回 nil
說一切正常。
我認爲這是 Russ Cox 觀察到錯誤處理可能會模糊函數操做的一個很好的例子。咱們來看一個改進的版本。
func CountLines(r io.Reader) (int, error) { sc := bufio.NewScanner(r) lines := 0 for sc.Scan() { lines++ } return lines, sc.Err() }
這個改進的版本從 bufio.Reader
切換到 bufio.Scanner
。
在 bufio.Scanner
內部使用 bufio.Reader
,但它添加了一個很好的抽象層,它有助於經過隱藏 CountLines
的操做來消除錯誤處理。
注意:
bufio.Scanner
能夠掃描任何模式,但默認狀況下它會查找換行符。
若是掃描程序匹配了一行文本而且沒有遇到錯誤,則 sc.Scan()
方法返回 true
。所以,只有當掃描儀的緩衝區中有一行文本時,纔會調用 for
循環的主體。這意味着咱們修改後的 CountLines
正確處理沒有換行符的狀況,而且還處理文件爲空的狀況。
其次,當 sc.Scan
在遇到錯誤時返回 false
,咱們的 for
循環將在到達文件結尾或遇到錯誤時退出。bufio.Scanner
類型會記住遇到的第一個錯誤,一旦咱們使用 sc.Err()
方法退出循環,咱們就能夠獲取該錯誤。
最後, sc.Err()
負責處理 io.EOF
並在達到文件末尾時將其轉換爲 nil
,而不會遇到其餘錯誤。
貼士:
當遇到難以忍受的錯誤處理時,請嘗試將某些操做提取到輔助程序類型中。
個人第二個例子受到了 Errors are values
博客文章[[10]](https://blog.golang.org/error...。
在本章前面咱們已經看過處理打開、寫入和關閉文件的示例。錯誤處理是存在的,可是接收範圍內的,由於操做能夠封裝在諸如 ioutil.ReadFile
和 ioutil.WriteFile
之類的輔助程序中。可是,在處理底層網絡協議時,有必要使用 I/O
原始的錯誤處理來直接構建響應,這樣就可能會變得重複。看一下構建 HTTP
響應的 HTTP
服務器的這個片斷。
type Header struct { Key, Value string } type Status struct { Code int Reason string } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) if err != nil { return err } for _, h := range headers { _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value) if err != nil { return err } } if _, err := fmt.Fprint(w, "\r\n"); err != nil { return err } _, err = io.Copy(w, body) return err }
首先,咱們使用 fmt.Fprintf
構造狀態碼並檢查錯誤。 而後對於每一個標題,咱們寫入鍵值對,每次都檢查錯誤。 最後,咱們使用額外的 \r\n
終止標題部分,檢查錯誤以後將響應主體複製到客戶端。 最後,雖然咱們不須要檢查 io.Copy
中的錯誤,但咱們須要將 io.Copy
返回的兩個返回值形式轉換爲 WriteResponse
的單個返回值。
這裏不少重複性的工做。 咱們能夠經過引入一個包裝器類型 errWriter
來使其更容易。
errWriter
實現 io.Writer
接口,所以可用於包裝現有的 io.Writer
。 errWriter
寫入傳遞給其底層 writer
,直到檢測到錯誤。 今後時起,它會丟棄任何寫入並返回先前的錯誤。
type errWriter struct { io.Writer err error } func (e *errWriter) Write(buf []byte) (int, error) { if e.err != nil { return 0, e.err } var n int n, e.err = e.Writer.Write(buf) return n, nil } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { ew := &errWriter{Writer: w} fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) for _, h := range headers { fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err }
將 errWriter
應用於 WriteResponse
能夠顯着提升代碼的清晰度。 每一個操做再也不須要本身作錯誤檢查。 經過檢查 ew.err
字段,將錯誤報告移動到函數末尾,從而避免轉換從 io.Copy
的兩個返回值。
最後,我想提一下你應該只處理錯誤一次。 處理錯誤意味着檢查錯誤值並作出單一決定。
// WriteAll writes the contents of buf to the supplied writer. func WriteAll(w io.Writer, buf []byte) { w.Write(buf) }
若是你作出的決定少於一個,則忽略該錯誤。 正如咱們在這裏看到的那樣, w.WriteAll
的錯誤被丟棄。
可是,針對單個錯誤作出多個決策也是有問題的。 如下是我常常遇到的代碼。
func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { log.Println("unable to write:", err) // annotated error goes to log file return err // unannotated error returned to caller } return nil }
在此示例中,若是在 w.Write
期間發生錯誤,則會寫入日誌文件,註明錯誤發生的文件與行數,而且錯誤也會返回給調用者,調用者可能會記錄該錯誤並將其返回到上一級,一直回到程序的頂部。
調用者可能正在作一樣的事情
func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) return err } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil }
所以你在日誌文件中獲得一堆重複的內容,
unable to write: io.EOF could not write config: io.EOF
但在程序的頂部,雖然獲得了原始錯誤,但沒有相關內容。
err := WriteConfig(f, &conf) fmt.Println(err) // io.EOF
我想深刻研究這一點,由於做爲我的偏好, 我並無看到 logging
和返回的問題。
func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) // oops, forgot to return } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil }
不少問題是程序員忘記從錯誤中返回。正如咱們以前談到的那樣,Go 語言風格是使用 guard clauses
以及檢查前提條件做爲函數進展並提早返回。
在這個例子中,做者檢查了錯誤,記錄了它,但忘了返回。這就引發了一個微妙的錯誤。
Go 語言中的錯誤處理規定,若是出現錯誤,你不能對其餘返回值的內容作出任何假設。因爲 JSON
解析失敗,buf
的內容未知,可能它什麼都沒有,但更糟的是它可能包含解析的 JSON
片斷部分。
因爲程序員在檢查並記錄錯誤後忘記返回,所以損壞的緩衝區將傳遞給 WriteAll
,這可能會成功,所以配置文件將被錯誤地寫入。可是,該函數會正常返回,而且發生問題的惟一日誌行是有關 JSON
解析錯誤,而與寫入配置失敗有關。
發生錯誤的緣由是做者試圖在錯誤消息中添加 context
。 他們試圖給本身留下一些線索,指出錯誤的根源。
讓咱們看看使用 fmt.Errorf
的另外一種方式。
func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { return fmt.Errorf("could not marshal config: %v", err) } if err := WriteAll(w, buf); err != nil { return fmt.Errorf("could not write config: %v", err) } return nil } func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { return fmt.Errorf("write failed: %v", err) } return nil }
經過將註釋與返回的錯誤組合起來,就更難以忘記錯誤的返回來避免意外繼續。
若是寫入文件時發生 I/O
錯誤,則 error
的 Error()
方法會報告如下相似的內容;
could not write config: write failed: input/output error
github.com/pkg/errors
包裝 errors
fmt.Errorf
模式適用於註釋錯誤 message
,但這樣作的代價是模糊了原始錯誤的類型。 我認爲將錯誤視爲不透明值對於鬆散耦合的軟件很是重要,所以若是你使用錯誤值作的惟一事情是原始錯誤的類型應該可有可無的面孔
nil
。可是在某些狀況下,我認爲它們並不常見,您須要恢復原始錯誤。 在這種狀況下,使用相似個人 errors
包來註釋這樣的錯誤, 以下
func ReadFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, errors.Wrap(err, "open failed") } defer f.Close() buf, err := ioutil.ReadAll(f) if err != nil { return nil, errors.Wrap(err, "read failed") } return buf, nil } func ReadConfig() ([]byte, error) { home := os.Getenv("HOME") config, err := ReadFile(filepath.Join(home, ".settings.xml")) return config, errors.WithMessage(err, "could not read config") } func main() { _, err := ReadConfig() if err != nil { fmt.Println(err) os.Exit(1) } }
如今報告的錯誤就是 K&D
[[11]](http://www.gopl.io/)樣式錯誤,
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
而且錯誤值保留對原始緣由的引用。
func main() { _, err := ReadConfig() if err != nil { fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err)) fmt.Printf("stack trace:\n%+v\n", err) os.Exit(1) } }
所以,你能夠恢復原始錯誤並打印堆棧跟蹤;
original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory stack trace: open /Users/dfc/.settings.xml: no such file or directory open failed main.ReadFile /Users/dfc/devel/practical-go/src/errors/readfile2.go:16 main.ReadConfig /Users/dfc/devel/practical-go/src/errors/readfile2.go:29 main.main /Users/dfc/devel/practical-go/src/errors/readfile2.go:35 runtime.main /Users/dfc/go/src/runtime/proc.go:201 runtime.goexit /Users/dfc/go/src/runtime/asm_amd64.s:1333 could not read config
使用 errors
包,你能夠以人和機器均可檢查的方式向錯誤值添加上下文。 若是昨天你來聽個人演講,你會知道這個庫在被移植到即將發佈的 Go 語言版本的標準庫中。
因爲 Go 語言的併發功能,常常被選做項目編程語言。 Go 語言團隊已經不遺餘力以廉價(在硬件資源方面)和高性能來實現併發,可是 Go 語言的併發功能也能夠被用來編寫性能不高同時也不太可靠的代碼。在結尾,我想留下一些建議,以免 Go 語言的併發功能帶來的一些陷阱。
Go 語言以 channels
以及 select
和 go
語句來支持併發。若是你已經從書籍或培訓課程中正式學習了 Go 語言,你可能已經注意到併發部分始終是這些課程的最後一部分。這個研討會也沒有什麼不一樣,我選擇最後覆蓋併發,好像它是 Go 程序員應該掌握的常規技能的額外補充。
這裏有一個二分法; Go 語言的最大特色是簡單、輕量級的併發模型。做爲一種產品,咱們的語言幾乎只推廣這個功能。另外一方面,有一種說法認爲併發使用起來實際上並不容易,不然做者不會把它做爲他們書中的最後一章,咱們也不會遺憾地來回顧其造成過程。
本節討論了 Go 語言的併發功能的「坑」。
這個程序有什麼問題?
package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { } }
該程序實現了咱們的預期,它提供簡單的 Web 服務。 然而,它同時也作了其餘事情,它在無限循環中浪費 CPU 資源。 這是由於 main
的最後一行上的 for {}
將阻塞 main goroutine
,由於它不執行任何 IO、等待鎖定、發送或接收通道數據或以其餘方式與調度器通訊。
因爲 Go 語言運行時主要是協同調度,該程序將在單個 CPU 上作無效地旋轉,並可能最終實時鎖定。
咱們如何解決這個問題? 這是一個建議。
package main import ( "fmt" "log" "net/http" "runtime" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { runtime.Gosched() } }
這看起來很愚蠢,但這是我看過的一種常看法決方案。 這是不瞭解潛在問題的症狀。
如今,若是你有更多的經驗,你可能會寫這樣的東西。
package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() select {} }
空的 select
語句將永遠阻塞。 這是一個有用的屬性,由於如今咱們再也不調用 runtime.GoSched()
而耗費整個 CPU。 可是這也只是治療了症狀,而不是病根。
我想向你提出另外一種你可能在用的解決方案。 與其在 goroutine
中運行 http.ListenAndServe
,會給咱們留下處理 main goroutine
的問題,不如在 main goroutine
自己上運行 http.ListenAndServe
。
貼士:
若是 Go 語言程序的main.main
函數返回,不管程序在一段時間內啓動的其餘goroutine
在作什麼, Go 語言程序會無條件地退出。
package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }
因此這是個人第一條建議:若是你的 goroutine
在獲得另外一個結果以前沒法取得進展,那麼讓本身完成此工做而不是委託給其餘 goroutine
會更簡單。
這一般會消除將結果從 goroutine
返回到其啓動程序所需的大量狀態跟蹤和通道操做。
貼士:
許多 Go 程序員過分使用goroutine
,特別是剛開始時。與生活中的全部事情同樣,適度是成功的關鍵。
如下兩個 API 有什麼區別?
// ListDirectory returns the contents of dir. func ListDirectory(dir string) ([]string, error)
// ListDirectory returns a channel over which // directory entries will be published. When the list // of entries is exhausted, the channel will be closed. func ListDirectory(dir string) chan string
首先,最明顯的不一樣: 第一個示例將目錄讀入切片而後返回整個切片,若是出錯則返回錯誤。這是同步發生的,ListDirectory
的調用者會阻塞,直到讀取了全部目錄條目。根據目錄的大小,這可能須要很長時間,而且可能會分配大量內存來構建目錄條目。
讓咱們看看第二個例子。 這個示例更像是 Go 語言風格,ListDirectory
返回一個通道,經過該通道傳遞目錄條目。當通道關閉時,代表沒有更多目錄條目。因爲在 ListDirectory
返回後發生了通道的填充,ListDirectory
可能會啓動一個 goroutine
來填充通道。
注意:
第二個版本實際上沒必要使用 Go 協程; 它能夠分配一個足以保存全部目錄條目而不阻塞的通道,填充通道,關閉它,而後將通道返回給調用者。但這樣作不太現實,由於會消耗大量內存來緩衝通道中的全部結果。
通道版本的 ListDirectory
還有兩個問題:
ListDirectory
沒法告訴調用者經過通道返回的項目集是否完整。調用者沒法區分空目錄和讀取目錄的錯誤。二者都致使從 ListDirectory
返回的通道當即關閉。ListDirectory
使用的嚴重限制,即便可能已經收到了它想要的答案,調用者也必須花時間從通道中讀取。就中型到大型目錄的內存使用而言,它可能更有效,但這種方法並不比原始的基於切片的方法快。以上兩種實現所帶來的問題的解決方案是使用回調,該回調是在執行時在每一個目錄條目的上下文中調用函數。
func ListDirectory(dir string, fn func(string))
絕不奇怪,這就是 filepath.WalkDir
函數的工做方式。
貼士:
若是你的函數啓動了goroutine
,你必須爲調用者提供一種明確中止goroutine
的方法。 把異步執行函數的決定留給該函數的調用者一般會更容易些。
前面的例子顯示當一個任務時沒有必要時使用 goroutine
。但使用 Go 語言的緣由之一是該語言提供的併發功能。實際上,不少狀況下你但願利用硬件中可用的並行性。爲此,你必須使用 goroutines
。
這個簡單的應用程序在兩個不一樣的端口上提供 http
服務,端口 8080
用於應用程序服務,端口 8001
用於訪問 /debug/pprof
終端。
package main import ( "fmt" "net/http" _ "net/http/pprof" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug http.ListenAndServe("0.0.0.0:8080", mux) // app traffic }
雖然這個程序不是很複雜,但它表明了真實應用程序的基礎。
該應用程序存在一些問題,由於它隨着應用程序的增加而顯露出來,因此咱們如今來解決其中的一些問題。
func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() { http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { go serveDebug() serveApp() }
經過將 serveApp
和 serveDebug
處理程序分解成爲它們本身的函數,咱們將它們與 main.main
分離。 也遵循了上面的建議,並確保 serveApp
和 serveDebug
將它們的併發性留給調用者。
可是這個程序存在一些可操做性問題。 若是 serveApp
返回,那麼 main.main
將返回,致使程序關閉並由你使用的進程管理器來從新啓動。
貼士:
正如 Go 語言中的函數將併發性留給調用者同樣,應用程序應該將監視其狀態和檢測是否重啓的工做留給另外的程序來作。 不要讓你的應用程序負責從新啓動本身,最好從應用程序外部處理該過程。
然而,serveDebug
是在一個單獨的 goroutine
中運行的,返回後該 goroutine
將退出,而程序的其他部分繼續。 因爲 /debug
處理程序已中止工做好久,所以操做人員不會很高興發現他們沒法在你的應用程序中獲取統計信息。
咱們想要確保的是,若是任何負責提供此應用程序的 goroutine
中止,咱們將關閉該應用程序。
func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil { log.Fatal(err) } } func serveDebug() { if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil { log.Fatal(err) } } func main() { go serveDebug() go serveApp() select {} }
如今 serverApp
和 serveDebug
檢查從 ListenAndServe
返回的錯誤,並在須要時調用 log.Fatal
。由於兩個處理程序都在 goroutine
中運行,因此咱們將 main goroutine
停在 select{}
中。
這種方法存在許多問題:
ListenAndServer
返回 nil
錯誤,則不會調用 log.Fatal
,而且該端口上的 HTTP 服務將在不中止應用程序的狀況下關閉。log.Fatal
調用 os.Exit
,它將無條件地退出程序; defer
不會被調用,其餘 goroutines
也不會被通知關閉,程序就中止了。 這使得編寫這些函數的測試變得困難。貼士:
只在main.main
或init
函數中的使用log.Fatal
。
咱們真正想要的是任何錯誤發送回 goroutine
的調用者,以便它能夠知道 goroutine
中止的緣由,能夠乾淨地關閉程序進程。
func serveApp() error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() error { return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { done := make(chan error, 2) go func() { done <- serveDebug() }() go func() { done <- serveApp() }() for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } } }
咱們能夠使用通道來收集 goroutine
的返回狀態。通道的大小等於咱們想要管理的 goroutine
的數量,這樣發送到 done
通道就不會阻塞,由於這會阻止 goroutine
的關閉,致使它泄漏。
因爲沒有辦法安全地關閉 done
通道,咱們不能使用 for range
來循環通道直到獲取全部 goroutine
發來的報告,而是循環咱們開啓的多個 goroutine
,即通道的容量。
如今咱們有辦法等待每一個 goroutine
乾淨地退出並記錄他們遇到的錯誤。所須要的只是一種從第一個 goroutine
轉發關閉信號到其餘 goroutine
的方法。
事實證實,要求 http.Server
關閉是有點牽扯的,因此我將這個邏輯轉給輔助函數。serve
助手使用一個地址和 http.Handler
,相似於 http.ListenAndServe
,還有一個 stop
通道,咱們用它來觸發 Shutdown
方法。
func serve(addr string, handler http.Handler, stop <-chan struct{}) error { s := http.Server{ Addr: addr, Handler: handler, } go func() { <-stop // wait for stop signal s.Shutdown(context.Background()) }() return s.ListenAndServe() } func serveApp(stop <-chan struct{}) error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return serve("0.0.0.0:8080", mux, stop) } func serveDebug(stop <-chan struct{}) error { return serve("127.0.0.1:8001", http.DefaultServeMux, stop) } func main() { done := make(chan error, 2) stop := make(chan struct{}) go func() { done <- serveDebug(stop) }() go func() { done <- serveApp(stop) }() var stopped bool for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } if !stopped { stopped = true close(stop) } } }
如今,每次咱們在 done
通道上收到一個值時,咱們關閉 stop
通道,這會致使在該通道上等待的全部 goroutine
關閉其 http.Server
。 這反過來將致使其他全部的 ListenAndServe
goroutines
返回。 一旦咱們開啓的全部 goroutine
都中止了,main.main
就會返回而且進程會乾淨地中止。
貼士:
本身編寫這種邏輯是重複而微妙的。 參考下這個包: https://github.com/heptio/workgroup,它會爲你完成大部分工做。
引用:1. https://gaston.life/books/effective-programming/
2. https://talks.golang.org/2014/names.slide#4
3. https://www.infoq.com/articles/API-Design-Joshua-Bloch
1. https://www.lysator.liu.se/c/pikestyle.html
2. https://speakerdeck.com/campoy/understanding-nil
3. https://www.youtube.com/watch?v=Ic2y6w8lMPA
4. https://medium.com/@matryer/line-of-sight-in-code-186dd7cdea88
5. https://golang.org/doc/go1.4#internalpackages
6. https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
7. https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html
8. https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully
9. https://www.amazon.com/Philosophy-Software-Design-John-Ousterhout/dp/1732102201
原文連接:Practical Go: Real world advice for writing maintainable Go programs