-
指導原則
如果我要談論任何程式設計語言的最佳實踐,我需要一些方法來定義“什麼是最佳”。 如果你昨天來到我的主題演講,你會看到Go團隊負責人Russ Cox的這句話:
Software engineering is what happens to programming when you add time and other programmers. (軟體工程就是你和其他程式員花費時間在程式設計上所發生的事情。) — Russ Cox
Russ作出了軟體程式設計與軟體工程的區分。 前者是你自己寫的一個程式。 後者是很多人會随着時間的推移而開發的産品。 工程師們來來去去,團隊會随着時間增長與縮小,需求會發生變化,功能會被添加,錯誤也會得到修複。 這是軟體工程的本質。
我可能是這個房間裡Go最早的使用者之一,但要争辯說我的資曆給我的看法更多是假的。 相反,今天我要提的建議是基于我認為的Go語言本身的指導原則:
● 簡單性
● 可讀性
● 生産力
注意: 你會注意到我沒有說性能或并發。 有些語言比Go語言快一點,但它們肯定不像Go語言那麼簡單。 有些語言使并發成為他們的最高目标,但它們并不具有可讀性及生産力。 性能和并發是重要的屬性,但不如簡單性,可讀性和生産力那麼重要。
1.1. 簡單性
我們為什麼要追求簡單? 為什麼Go語言程式的簡單性很重要?
我們都曾遇到過這樣的情況: “我不懂這段代碼”,不是嗎? 我們都做過這樣的項目:你害怕做出改變,因為你擔心它會破壞程式的另一部分; 你不了解的部分,不知道如何修複。
這就是複雜性。 複雜性把可靠的軟體中變成不可靠。 複雜性是殺死軟體項目的罪魁禍首。
簡單性是Go語言的最高目标。 無論我們編寫什麼程式,我們都應該同意這一點:它們很簡單。
1.2. 可讀性
Readability is essential for maintainability. (可讀性對于可維護性是至關重要的。) — Mark Reinhold (2018 JVM語言高層會議)
為什麼Go語言的代碼可讀性是很重要的?我們為什麼要争取可讀性?
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]
可讀性是能夠了解程式正在做什麼的關鍵。如果你無法了解程式正在做什麼,那你希望如何維護它?如果軟體無法維護,那麼它将被重寫;最後這可能是你的公司最後一次投資Go語言。
~如果你正在為自己編寫一個程式,也許它隻需要運作一次,或者你是唯一一個曾經看過它的人,然後做任何對你有用的事。~但是,如果是一個不止一個人會貢獻編寫的軟體,或者在很長一段時間内需求、功能或者環境會改變,那麼你的目标必須是你的程式可被維護。
編寫可維護代碼的第一步是確定代碼可讀。
1.3. 生産力
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代碼的可讀性至關重要。
2.1. 選擇辨別符是為了清晰,而不是簡潔
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程式員在談到習慣用語時所談論的内容。
讓我們深入讨論以下這些屬性。
2.2. 辨別符長度
有時候人們批評Go語言推薦短變量名的風格。正如Rob Pike所說,“Go程式員想要正确的長度的辨別符”。 [1]
Andrew Gerrand建議通過對某些事物使用更長的辨別,向讀者表明它們具有更高的重要性。
The greater the distance between a name’s declaration and its uses, the longer the name should be. (名字的聲明與其使用之間的距離越大,名字應該越長。) — Andrew Gerrand [2]
由此我們可以得出一些指導方針:
● 短變量名稱在聲明和上次使用之間的距離很短時效果很好。
● 長變量名稱需要證明自己的合理性; 名稱越長,需要提供的價值越高。冗長的名稱與頁面上的重量相比,信号量較小。
● 請勿在變量名稱中包含類型名稱。
● 常量應該描述它們持有的值,而不是該如何使用。
● 對于循環和分支使用單字母變量,參數和傳回值使用單個字,函數和包級别聲明使用多個單詞
● 方法、接口和包使用單個詞。
● 請記住,包的名稱是調用者用來引用名稱的一部分,是以要好好利用這一點。
我們來舉個栗子:
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的累積,最後是平均值的計算。
2.2.1. 上下文是關鍵
重要的是要意識到關于命名的大多數建議都是需要考慮上下文的。 我想說這是一個原則,而不是一個規則。
兩個辨別符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的子值稱為索引。
貼士: 在同一聲明中長和短形式的參數不能混搭。
2.3. 不要用變量類型命名你的變量
你不應該用變量的類型來命名你的變量, 就像您不會将寵物命名為“狗”和“貓”。 出于同樣的原因,您也不應在變量名字中包含類型的名字。
變量的名稱應描述其内容,而不是内容的類型。 例如:
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)
2.4. 使用一緻的命名方式
一個好名字的另一個屬性是它應該是可預測的。 在第一次遇到該名字時讀者就能夠了解名字的使用。 當他們遇到常見的名字時,他們應該能夠認為自從他們上次看到它以來它沒有改變意義。
例如,如果您的代碼在處理資料庫請確定每次出現參數時,它都具有相同的名稱。 與其使用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變量都無法滿足時,這個時候可能就是需要将函數分解成更小的函數。
2.5. 使用一緻的聲明樣式
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]相反,我們有一個選擇,如果我們要使用切片的零值則寫作:
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聲明帶有初始化變量的。 這個決定與我的常用的形式不同,這給讀者一個線索,告訴他們一些不尋常的事情将會發生。
2.6. 成為團隊合作者
我談到了軟體工程的目标,即編寫可讀及可維護的代碼。 是以,您可能會将大部分職業生涯用于你不是唯一作者的項目。 我在這種情況下的建議是遵循項目自身風格。
在檔案中間更改樣式是不和諧的。 即使不是你喜歡的方式,對于維護而言一緻性比你的個人偏好更有價值。 我的經驗法則是: 如果它通過了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閥值。
3.1. 關于變量和常量的注釋應描述其内容而非其目的
我之前談過,變量或常量的名稱應描述其目的。 向變量或常量添加注釋時,該注釋應描述變量内容,而不是變量目的。
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]。有時你會發現一個更好的變量名稱隐藏在注釋中。
// registry of SQL drivers
var registry = make(map[string]*sql.Driver)
注釋是由作者添加的,因為registry沒有充分解釋其目的 - 它是一個系統資料庫,但注冊的是什麼?
通過将變量重命名為sqlDrivers,現在可以清楚地知道此變量的目的是儲存SQL驅動程式。
var sqlDrivers = make(map[string]*sql.Driver)
之前的注釋就是多餘的,可以删除。
3.2. 公共符号始終要注釋
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的一個實作。
貼士: 在編寫函數之前,請編寫描述函數的注釋。 如果你發現很難寫出注釋,那麼這就表明你将要編寫的代碼很難了解。
3.2.1. 不要注釋不好的代碼,将它重寫
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與日期或問題編号來注釋。
3.2.2. 與其注釋一段代碼,不如重構它
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語言包應該具有低程度的源碼級耦合,這樣,随着項目的增長,對一個包的更改不會跨代碼庫級聯。 這些世界末日的重構嚴格限制了代碼庫的變化率以及在該代碼庫中工作的成員的生産率。
在本節中,我們将讨論如何設計包,包括包的名稱,命名類型以及編寫方法和函數的技巧。
4.1. 一個好的包從它的名字開始
編寫一個好的Go語言包從包的名稱開始。将你的包名用一個詞來描述它。
正如我在上一節中談到變量的名稱一樣,包的名稱也非常重要。我遵循的經驗法則不是“我應該在這個包中放入什麼類型的?”。相反,我要問是“該包提供的服務是什麼?”通常這個問題的答案不是“這個包提供X類型”,而是“這個包提供HTTP”。
貼士: 以包所提供的内容來命名,而不是它包含的内容。
4.1.1. 好的包名應該是唯一的。
在項目中,每個包名稱應該是唯一的。包的名稱應該描述其目的的建議很容易了解 - 如果你發現有兩個包需要用相同名稱,它可能是:
● 包的名稱太通用了。
● 該包與另一個類似名稱的包重疊了。在這種情況下,您應該檢查你的設計,或考慮合并包。
4.2. 避免使用類似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接口顯然與網絡錯誤有關。
4.3. 盡早return而不是深度嵌套
由于Go語言的控制流不使用exception,是以不需要為try和catch塊提供頂級結構而深度縮進代碼。 Go語言代碼不是成功的路徑越來越深地嵌套到右邊,而是以一種風格編寫,其中随着函數的進行,成功路徑繼續沿着螢幕向下移動。 我的朋友Mat Ryer将這種做法稱為“視線”編碼。[4]
這是通過使用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并盡早傳回錯誤。
4.4. 讓零值更有用
假設變量沒有初始化,每個變量聲明都會自動初始化為與零記憶體的内容相比對的值。 這就是零值。 值的類型決定了其零值; 對于數字類型,它為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())
}
4.5. 避免包級别狀态
編寫可維護程式的關鍵是它們應該是松散耦合的 - 對一個程式包的更改應該很少影響另一個不直接依賴于第一個程式包的程式包。
在Go語言中有兩種很好的方法可以實作松散耦合
● 使用接口來描述函數或方法所需的行為。
● 避免使用全局狀态。
在Go語言中,我們可以在函數或方法範圍以及包範圍内聲明變量。當變量是公共的時,給定一個以大寫字母開頭的辨別符,那麼它的範圍對于整個程式來說實際上是全局的 - 任何包都可以随時觀察該變量的類型和内容。
可變全局狀态引入程式的獨立部分之間的緊密耦合,因為全局變量成為程式中每個函數的不可見參數!如果該變量的類型發生更改,則可以破壞依賴于全局變量的任何函數。如果程式的另一部分更改了該變量,則可以破壞依賴于全局變量狀态的任何函數。
如果要減少全局變量所帶來的耦合,
● 将相關變量作為字段移動到需要它們的結構上。
● 使用接口來減少行為與實作之間的耦合。
-
項目結構
我們來談談如何将包組合到項目中。 通常一個項目是一個git倉庫,但在未來Go語言開發人員會交替地使用module和project。
就像一個包,每個項目都應該有一個明确的目的。 如果你的項目是一個庫,它應該提供一件事,比如XML解析或記錄。 您應該避免在一個包實作多個目的,這将有助于避免成為common庫。
貼士: 據我的經驗,common庫最終會與其最大的調用者緊密相連,在沒有更新該庫與最大調用者的情況下是很難修複的,還會帶來了許多無關的更改以及API破壞。
如果你的項目是應用程式,如Web應用程式,Kubernetes控制器等,那麼項目中可能有一個或多個main程式包。 例如,我編寫的Kubernetes控制器有一個cmd / contour包,既可以作為部署到Kubernetes叢集的伺服器,也可以作為調試目的的用戶端。
5.1. 考慮更少,更大的包
對于從其他語言過渡到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程式集。
5.1.1. 通過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語言中函數的另一種寫法)。 更改包中代碼的布局不會影響編譯時間。
5.1.2. 優先内部測試再到外部測試
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檔案的中間目錄,則可能無法遵循此建議。
5.1.3. 使用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]
5.2. 確定main包内容盡可能的少
main函數和main包的内容應盡可能少。 這是因為main.main充當單例; 程式中隻能有一個main函數,包括tests。
因為main.main是一個單例,假設main函數中需要執行很多事情, main.main隻會在main.main或main.init中調用它們并且隻調用一次。 這使得為main.main編寫代碼測試變得很困難,是以你應該将所有業務邏輯從main函數中移出,最好是從main包中移出。
貼士: main應該做解析flags,開啟資料庫連接配接、開啟日志等,然後将執行交給更高一級的對象。
-
API設計
我今天要給出的最後一條建議是設計, 我認為也是最重要的。
到目前為止我提出的所有建議都是建議。 這些是我嘗試編寫Go語言的方式,但我不打算在代碼審查中拼命推廣。
但是,在審查API時, 我就不會那麼寬容了。 這是因為到目前為止我所談論的所有内容都是可以修複而且不會破壞向後相容性; 它們在很大程度上是實作的細節。
當涉及到軟體包的公共API時,在初始設計中投入大量精力是值得的,因為稍後更改該設計對于已經使用API的人來說會是破壞性的。
6.1. 設計難以被誤用的API。
APIs should be easy to use and hard to misuse. (API應該易于使用且難以被誤用) — Josh Bloch [3]
如果你從這個演講中帶走任何東西,那應該是Josh Bloch的建議。 如果一個API很難用于簡單的事情,那麼API的每次調用都會很複雜。 當API的實際調用很複雜時,它就會便得不那麼明顯,而且會更容易被忽視。
6.1.1. 警惕采用幾個相同類型參數的函數
簡單, 但難以正确使用的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難以正确使用。
6.2. 為其預設用例設計API
幾年前,我就對functional options[7]進行過讨論[6],使API更易用于預設用例。
本演講的主旨是你應該為常見用例設計API。 另一方面,API不應要求調用者提供他們不在乎參數。
6.2.1. 不鼓勵使用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隐藏這些參數,使用輔助方式來設定測試範圍中的屬性。
6.2.2. 首選可變參數函數而非[]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。
6.3. 讓函數定義它們所需的行為
假設我需要編寫一個将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大神的另一篇]
-
錯誤處理
我已經給出了幾個關于錯誤處理的示範文稿[8],并在我的部落格上寫了很多關于錯誤處理的文章。我在昨天的會議上也講了很多關于錯誤處理的内容,是以在這裡不再贅述。
●
https://dave.cheney.net/2014/12/24/inspecting-errors https://dave.cheney.net/2016/04/07/constant-errors相反,我想介紹與錯誤處理相關的兩個其他方面。
7.1. 通過消除錯誤來消除錯誤處理
如果你昨天在我的演講中,我談到了改進錯誤處理的提案。但是你知道有什麼比改進錯誤處理的文法更好嗎?那就是根本不需要處理錯誤。
注意: 我不是說“删除你的錯誤處理”。我的建議是,修改你的代碼,這樣就不用處理錯誤了。
本節從John Ousterhout最近的著作“軟體設計哲學”[9]中汲取靈感。該書的其中一章是“定義不存在的錯誤”。我們将嘗試将此建議應用于Go語言。
7.1.1. 計算行數
讓我們編寫一個函數來計算檔案中的行數。
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,而不會遇到其他錯誤。
貼士: 當遇到難以忍受的錯誤處理時,請嘗試将某些操作提取到輔助程式類型中。
7.1.2. WriteResponse
我的第二個例子受到了Errors are values部落格文章[10]的啟發。
在本章前面我們已經看過處理打開、寫入和關閉檔案的示例。錯誤處理是存在的,但是接收範圍内的,因為操作可以封裝在諸如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構造狀态碼并檢查錯誤。 然後對于每個标題,我們寫入鍵值對,每次都檢查錯誤。 最後,我們使用額外的rn終止标題部分,檢查錯誤之後将響應主體複制到用戶端。 最後,雖然我們不需要檢查io.Copy中的錯誤,但我們需要将io.Copy傳回的兩個傳回值形式轉換為WriteResponse的單個傳回值。
這裡很多重複性的工作。 我們可以通過引入一個包裝器類型errWriter來使其更容易。
errWriter實作io.Writer接口,是以可用于包裝現有的io.Writer。 errWriter寫入傳遞給其底層writer,直到檢測到錯誤。 從此時起,它會丢棄任何寫入并傳回先前的錯誤。
将errWriter應用于WriteResponse可以顯着提高代碼的清晰度。 每個操作不再需要自己做錯誤檢查。 通過檢查ew.err字段,将錯誤報告移動到函數末尾,進而避免轉換從io.Copy的兩個傳回值。
7.2. 錯誤隻處理一次
最後,我想提一下你應該隻處理錯誤一次。 處理錯誤意味着檢查錯誤值并做出單一決定。
// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
w.Write(buf)
}
如果你做出的決定少于一個,則忽略該錯誤。 正如我們在這裡看到的那樣,w.WriteAll的錯誤被丢棄。
但是,針對單個錯誤做出多個決策也是有問題的。 以下是我經常遇到的代碼。
在此示例中,如果在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解析錯誤,而與寫入配置失敗有關。
7.2.1. 為錯誤添加相關内容
發生錯誤的原因是作者試圖在錯誤消息中添加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
7.2.2. 使用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]樣式錯誤,
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語言的并發功能的“坑”。
8.1. 保持自己忙碌或做自己的工作
這個程式有什麼問題?
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,特别是剛開始時。 與生活中的所有事情一樣,适度是成功的關鍵。
8.2. 将并發性留給調用者
以下兩個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的方法。 把異步執行函數的決定留給該函數的調用者通常會更容易些。
8.3. 永遠不要啟動一個停止不了的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
}
雖然這個程式不是很複雜,但它代表了真實應用程式的基礎。
// 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
該應用程式存在一些問題,因為它随着應用程式的增長而顯露出來,是以我們現在來解決其中的一些問題。
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就會傳回并且程序會幹淨地停止。