Go 語言實(shí)戰(zhàn): 編寫可維護(hù) Go 語言代碼建議
目錄
介紹
大家好,我在接下來的兩個會議中的目標(biāo)是向大家提供有關(guān)編寫 Go 代碼最佳實(shí)踐的建議烈拒。
這是一個研討會形式的演講歌憨,不會有幻燈片,而是直接從文檔開始原在。
貼士: 在這里有最新的文章鏈接
https://dave.cheney.net/practical-go/presentations/qcon-china.html
編者的話
- 終于翻譯完了 Dave 大神的這一篇《Go 語言最佳實(shí)踐》
- 耗時兩周的空閑時間
- 翻譯的同時也對 Go 語言的開發(fā)與實(shí)踐有了更深層次的了解
- 有興趣的同學(xué)可以翻閱 Dave 的另一篇博文《SOLID Go 語言設(shè)計(jì)》(第六章節(jié)也會提到)
- 同時在這里也推薦一個 Telegram Docker 群組(分享/交流): https://t.me/dockertutorial
正文
1. 指導(dǎo)原則
如果我要談?wù)撊魏尉幊陶Z言的最佳實(shí)踐那先,我需要一些方法來定義“什么是最佳”。如果你昨天來到我的主題演講唤蔗,你會看到 Go 團(tuán)隊(duì)負(fù)責(zé)人 Russ Cox 的這句話:
Software engineering is what happens to programming when you add time and other programmers. (軟件工程就是你和其他程序員花費(fèi)時間在編程上所發(fā)生的事情。)
— Russ Cox
Russ 作出了軟件編程與軟件工程的區(qū)分窟赏。 前者是你自己寫的一個程序。 后者是很多人會隨著時間的推移而開發(fā)的產(chǎn)品箱季。 工程師們來來去去涯穷,團(tuán)隊(duì)會隨著時間增長與縮小,需求會發(fā)生變化藏雏,功能會被添加拷况,錯誤也會得到修復(fù)。 這是軟件工程的本質(zhì)掘殴。
我可能是這個房間里 Go 最早的用戶之一赚瘦,但要爭辯說我的資歷給我的看法更多是假的。相反奏寨,今天我要提的建議是基于我認(rèn)為的 Go 語言本身的指導(dǎo)原則:
- 簡單性
- 可讀性
- 生產(chǎn)力
注意:
你會注意到我沒有說性能或并發(fā)起意。 有些語言比 Go 語言快一點(diǎn),但它們肯定不像 Go 語言那么簡單病瞳。 有些語言使并發(fā)成為他們的最高目標(biāo)揽咕,但它們并不具有可讀性及生產(chǎn)力。
性能和并發(fā)是重要的屬性套菜,但不如簡單性亲善,可讀性和生產(chǎn)力那么重要。
1.1. 簡單性
我們?yōu)槭裁匆非蠛唵危?為什么 Go 語言程序的簡單性很重要逗柴?
我們都曾遇到過這樣的情況: “我不懂這段代碼”蛹头,不是嗎? 我們都做過這樣的項(xiàng)目:你害怕做出改變戏溺,因?yàn)槟銚?dān)心它會破壞程序的另一部分; 你不理解的部分渣蜗,不知道如何修復(fù)。
這就是復(fù)雜性旷祸。 復(fù)雜性把可靠的軟件中變成不可靠袍睡。 復(fù)雜性是殺死軟件項(xiàng)目的罪魁禍?zhǔn)住?/p>
簡單性是 Go 語言的最高目標(biāo)。 無論我們編寫什么程序肋僧,我們都應(yīng)該同意這一點(diǎn):它們很簡單斑胜。
1.2. 可讀性
Readability is essential for maintainability.
(可讀性對于可維護(hù)性是至關(guān)重要的控淡。)
— Mark Reinhold (2018 JVM 語言高層會議)
為什么 Go 語言的代碼可讀性是很重要的?我們?yōu)槭裁匆獱幦】勺x性止潘?
Programs must be written for people to read, and only incidentally for machines to execute. (程序應(yīng)該被寫來讓人們閱讀掺炭,只是順便為了機(jī)器執(zhí)行。)
— Hal Abelson 與 Gerald Sussman (計(jì)算機(jī)程序的結(jié)構(gòu)與解釋)
可讀性很重要凭戴,因?yàn)樗熊浖粌H僅是 Go 語言程序涧狮,都是由人類編寫的,供他人閱讀么夫。執(zhí)行軟件的計(jì)算機(jī)則是次要的者冤。
代碼的讀取次數(shù)比寫入次數(shù)多。一段代碼在其生命周期內(nèi)會被讀取數(shù)百次档痪,甚至數(shù)千次涉枫。
The most important skill for a programmer is the ability to effectively communicate ideas. (程序員最重要的技能是有效溝通想法的能力。)
— Gastón Jorquera [1]
可讀性是能夠理解程序正在做什么的關(guān)鍵腐螟。如果你無法理解程序正在做什么愿汰,那你希望如何維護(hù)它?如果軟件無法維護(hù)乐纸,那么它將被重寫;最后這可能是你的公司最后一次投資 Go 語言衬廷。
如果你正在為自己編寫一個程序,也許它只需要運(yùn)行一次汽绢,或者你是唯一一個曾經(jīng)看過它的人吗跋,然后做任何對你有用的事。但是宁昭,如果是一個不止一個人會貢獻(xiàn)編寫的軟件小腊,或者在很長一段時間內(nèi)需求、功能或者環(huán)境會改變久窟,那么你的目標(biāo)必須是你的程序可被維護(hù)秩冈。
編寫可維護(hù)代碼的第一步是確保代碼可讀。
1.3. 生產(chǎn)力
Design is the art of arranging code to work today, and be changeable forever. (設(shè)計(jì)是安排代碼到工作的藝術(shù)斥扛,并且永遠(yuǎn)可變入问。)
— Sandi Metz
我要強(qiáng)調(diào)的最后一個基本原則是生產(chǎn)力。開發(fā)人員的工作效率是一個龐大的主題稀颁,但歸結(jié)為此; 你花多少時間做有用的工作芬失,而不是等待你的工具或迷失在一個外國的代碼庫里。 Go 程序員應(yīng)該覺得他們可以通過 Go 語言完成很多工作匾灶。
有人開玩笑說棱烂, Go 語言是在等待 C++ 語言程序編譯時設(shè)計(jì)的〗着快速編譯是 Go 語言的一個關(guān)鍵特性颊糜,也是吸引新開發(fā)人員的關(guān)鍵工具哩治。雖然編譯速度仍然是一個持久的戰(zhàn)場,但可以說衬鱼,在其他語言中需要幾分鐘的編譯业筏,在 Go 語言中只需幾秒鐘。這有助于 Go 語言開發(fā)人員感受到與使用動態(tài)語言的同行一樣的高效鸟赫,而且沒有那些語言固有的可靠性問題蒜胖。
對于開發(fā)人員生產(chǎn)力問題更為基礎(chǔ)的是,Go 程序員意識到編寫代碼是為了閱讀抛蚤,因此將讀代碼的行為置于編寫代碼的行為之上台谢。Go 語言甚至通過工具和自定義強(qiáng)制執(zhí)行所有代碼以特定樣式格式化。這就消除了項(xiàng)目中學(xué)習(xí)特定格式的摩擦岁经,并幫助發(fā)現(xiàn)錯誤朋沮,因?yàn)樗鼈兛雌饋聿徽_。
Go 程序員不會花費(fèi)整天的時間來調(diào)試不可思議的編譯錯誤蒿偎。他們也不會將浪費(fèi)時間在復(fù)雜的構(gòu)建腳本或在生產(chǎn)中部署代碼。最重要的是怀读,他們不用花費(fèi)時間來試圖了解他們的同事所寫的內(nèi)容诉位。
當(dāng)他們說語言必須擴(kuò)展時,Go 團(tuán)隊(duì)會談?wù)撋a(chǎn)力菜枷。
2. 標(biāo)識符
我們要討論的第一個主題是標(biāo)識符苍糠。 標(biāo)識符是一個用來表示名稱的花哨單詞; 變量的名稱,函數(shù)的名稱啤誊,方法的名稱岳瞭,類型的名稱,包的名稱等蚊锹。
Poor naming is symptomatic of poor design. (命名不佳是設(shè)計(jì)不佳的癥狀瞳筏。)
— Dave Cheney
鑒于 Go 語言的語法有限,我們?yōu)槌绦蜻x擇的名稱對我們程序的可讀性產(chǎn)生了非常大的影響牡昆。 可讀性是良好代碼的定義質(zhì)量姚炕,因此選擇好名稱對于 Go 代碼的可讀性至關(guān)重要。
2.1. 選擇標(biāo)識符是為了清晰丢烘,而不是簡潔
Obvious code is important. What you can do in one line you should do in three.
(清晰的代碼很重要柱宦。在一行可以做的你應(yīng)當(dāng)分三行做。(if/else
嗎?))
— Ukiah Smith
Go 語言不是為了單行而優(yōu)化的語言播瞳。 Go 語言不是為了最少行程序而優(yōu)化的語言掸刊。我們沒有優(yōu)化源代碼的大小,也沒有優(yōu)化輸入所需的時間赢乓。
Good naming is like a good joke. If you have to explain it, it’s not funny.
(好的命名就像一個好笑話忧侧。如果你必須解釋它石窑,那就不好笑了。)
— Dave Cheney
清晰的關(guān)鍵是在 Go 語言程序中我們選擇的標(biāo)識名稱苍柏。讓我們談一談所謂好的名字:
好的名字很簡潔尼斧。 好的名字不一定是最短的名字,但好的名字不會浪費(fèi)在無關(guān)的東西上试吁。好名字具有高的信噪比棺棵。
好的名字是描述性的。 好的名字會描述變量或常量的應(yīng)用熄捍,而不是它們的內(nèi)容烛恤。好的名字應(yīng)該描述函數(shù)的結(jié)果或方法的行為,而不是它們的操作余耽。好的名字應(yīng)該描述包的目的而非它的內(nèi)容缚柏。描述東西越準(zhǔn)確的名字就越好。
好的名字應(yīng)該是可預(yù)測的碟贾。 你能夠從名字中推斷出使用方式币喧。這是選擇描述性名稱的功能,但它也遵循傳統(tǒng)袱耽。這是 Go 程序員在談到習(xí)慣用語時所談?wù)摰膬?nèi)容杀餐。
讓我們深入討論以下這些屬性。
2.2. 標(biāo)識符長度
有時候人們批評 Go 語言推薦短變量名的風(fēng)格朱巨。正如 Rob Pike 所說史翘,“ Go 程序員想要正確的長度的標(biāo)識符”。 [1]
Andrew Gerrand 建議通過對某些事物使用更長的標(biāo)識冀续,向讀者表明它們具有更高的重要性琼讽。
The greater the distance between a name’s declaration and its uses, the longer the name should be. (名字的聲明與其使用之間的距離越大,名字應(yīng)該越長洪唐。)
— Andrew Gerrand [2]
由此我們可以得出一些指導(dǎo)方針:
- 短變量名稱在聲明和上次使用之間的距離很短時效果很好钻蹬。
- 長變量名稱需要證明自己的合理性; 名稱越長,需要提供的價值越高凭需。冗長的名稱與頁面上的重量相比脉让,信號量較小。
- 請勿在變量名稱中包含類型名稱功炮。
- 常量應(yīng)該描述它們持有的值溅潜,而不是該如何使用。
- 對于循環(huán)和分支使用單字母變量薪伏,參數(shù)和返回值使用單個字滚澜,函數(shù)和包級別聲明使用多個單詞
- 方法、接口和包使用單個詞嫁怀。
- 請記住设捐,包的名稱是調(diào)用者用來引用名稱的一部分借浊,因此要好好利用這一點(diǎn)。
我們來舉個栗子:
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
在執(zhí)行函數(shù)期間存在時間很短浊猾。如果要了解 p
的作用只需閱讀兩行代碼。
相比之下曙蒸,people
在函數(shù)第 7
行參數(shù)中被聲明。sum
和 count
也是如此岗钩,他們用了更長的名字纽窟。讀者必須查看更多的行數(shù)來定位它們,因此他們名字更為獨(dú)特兼吓。
我可以選擇 s
替代 sum
以及 c
(或可能是 n
)替代 count
臂港,但是這樣做會將程序中的所有變量份量降低到同樣的級別。我可以選擇 p
來代替 people
视搏,但是用什么來調(diào)用 for ... range
迭代變量审孽。如果用 person
的話看起來很奇怪,因?yàn)檠h(huán)迭代變量的生命時間很短浑娜,其名字的長度超出了它的值佑力。
貼士:
與使用段落分解文檔的方式一樣用空行來分解函數(shù)。 在AverageAge
中棚愤,按順序共有三個操作搓萧。 第一個是前提條件杂数,檢查people
是否為空宛畦,第二個是sum
和count
的累積,最后是平均值的計(jì)算揍移。
2.2.1. 上下文是關(guān)鍵
重要的是要意識到關(guān)于命名的大多數(shù)建議都是需要考慮上下文的次和。 我想說這是一個原則,而不是一個規(guī)則那伐。
兩個標(biāo)識符 i
和 index
之間有什么區(qū)別踏施。 我們不能斷定一個就比另一個好,例如
for index := 0; index < len(s); index++ {
//
}
從根本上說罕邀,上面的代碼更具有可讀性
for i := 0; i < len(s); i++ {
//
}
我認(rèn)為它不是畅形,因?yàn)榫痛耸露? i
和 index
的范圍很大可能上僅限于 for 循環(huán)的主體,后者的額外冗長性(指 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
意味著程序員必須要將文檔中常用符號轉(zhuǎn)換為代碼中較短的符號耘纱。 類似地將 index
替換成 i
,模糊了 i
所代表的含義毕荐,因?yàn)樵?SNMP
消息中束析,每個 OID
的子值稱為索引。
貼士: 在同一聲明中長和短形式的參數(shù)不能混搭憎亚。
2.3. 不要用變量類型命名你的變量
你不應(yīng)該用變量的類型來命名你的變量, 就像您不會將寵物命名為“狗”和“貓”员寇。 出于同樣的原因,您也不應(yīng)在變量名字中包含類型的名字虽填。
變量的名稱應(yīng)描述其內(nèi)容丁恭,而不是內(nèi)容的類型。 例如:
var usersMap map[string]*User
這個聲明有什么好處斋日? 我們可以看到它是一個 map
牲览,它與 *User
類型有關(guān)。 但是 usersMap
是一個 map
恶守,而 Go 語言是一種靜態(tài)類型的語言第献,如果沒有定義變量,不會讓我們意外地使用到它,因此 Map
后綴是多余的兔港。
接下來, 如果我們像這樣來聲明其他變量:
var (
companiesMap map[string]*Company
productsMap map[string]*Products
)
usersMap
庸毫,companiesMap
和 productsMap
三個 map
類型變量,所有映射字符串都是不同的類型衫樊。 我們知道它們是 map
飒赃,我們也知道我們不能使用其中一個來代替另一個 - 如果我們在需要 map[string]*User
的地方嘗試使用 companiesMap
, 編譯器將拋出錯誤異常。 在這種情況下科侈,很明顯變量中 Map
后綴并沒有提高代碼的清晰度载佳,它只是增加了要輸入的額外樣板代碼。
我的建議是避免使用任何類似變量類型的后綴臀栈。
貼士:
如果users
的描述性都不夠用蔫慧,那么usersMap
也不會。
此建議也適用于函數(shù)參數(shù)权薯。 例如:
type Config struct {
//
}
func WriteConfig(w io.Writer, config *Config)
命名 *Config
參數(shù) config
是多余的姑躲。 我們知道它是 *Config
類型,就是這樣盟蚣。
在這種情況下黍析,如果變量的生命周期足夠短,請考慮使用 conf
或 c
屎开。
如果有更多的 *Config
阐枣,那么將它們稱為 original
和 updated
比 conf1
和 conf2
會更具描述性,因?yàn)榍罢卟惶赡鼙换ハ嗾`解。
貼士:
不要讓包名竊取好的變量名侮繁。
導(dǎo)入標(biāo)識符的名稱包括其包名稱虑粥。 例如,context
包中的Context
類型將被稱為context.Context
宪哩。 這使得無法將context
用作包中的變量或類型娩贷。
func WriteLog(context context.Context, message string)
上面的栗子將會編譯出錯。 這就是為什么
context.Context
類型的通常的本地聲明是ctx
锁孟,例如:
func WriteLog(ctx context.Context, message string)
2.4. 使用一致的命名方式
一個好名字的另一個屬性是它應(yīng)該是可預(yù)測的彬祖。 在第一次遇到該名字時讀者就能夠理解名字的使用。 當(dāng)他們遇到常見的名字時品抽,他們應(yīng)該能夠認(rèn)為自從他們上次看到它以來它沒有改變意義储笑。
例如,如果您的代碼在處理數(shù)據(jù)庫請確保每次出現(xiàn)參數(shù)時圆恤,它都具有相同的名稱突倍。 與其使用 d * sql.DB
,dbase * sql.DB
盆昙,DB * sql.DB
和 database * sql.DB
的組合羽历,倒不如統(tǒng)一使用:
db *sql.DB
這樣做使讀者更為熟悉; 如果你看到db
,你知道它就是 *sql.DB
并且它已經(jīng)在本地聲明或者由調(diào)用者為你提供淡喜。
類似地秕磷,對于方法接收器: 在該類型的每個方法上使用相同的接收者名稱。 在這種類型的方法內(nèi)部可以使讀者更容易使用炼团。
注意:
Go 語言中的短接收者名稱慣例與目前提供的建議不一致澎嚣。 這只是早期做出的選擇之一,已經(jīng)成為首選的風(fēng)格瘟芝,就像使用CamelCase
而不是snake_case
一樣易桃。
貼士:
Go 語言樣式規(guī)定接收器具有單個字母名稱或從其類型派生的首字母縮略詞。 你可能會發(fā)現(xiàn)接收器的名稱有時會與方法中參數(shù)的名稱沖突模狭。 在這種情況下颈抚,請考慮將參數(shù)名稱命名稍長踩衩,并且不要忘記一致地使用此新參數(shù)名稱嚼鹉。
最后,某些單字母變量傳統(tǒng)上與循環(huán)和計(jì)數(shù)相關(guān)聯(lián)驱富。 例如锚赤,i
,j
和 k
通常是簡單 for
循環(huán)的循環(huán)歸納變量褐鸥。n
通常與計(jì)數(shù)器或累加器相關(guān)聯(lián)线脚。v
是通用編碼函數(shù)中值的常用簡寫,k
通常用于 map
的鍵,s
通常用作字符串類型參數(shù)的簡寫浑侥。
與上面的 db
示例一樣姊舵,程序員認(rèn)為 i
是一個循環(huán)歸納變量。 如果確保 i
始終是循環(huán)變量寓落,而且不在 for
循環(huán)之外的其他地方中使用括丁。 當(dāng)讀者遇到一個名為 i
或 j
的變量時,他們知道循環(huán)就在附近伶选。
貼士:
如果你發(fā)現(xiàn)自己有如此多的嵌套循環(huán)史飞,i
,j
和k
變量都無法滿足時仰税,這個時候可能就是需要將函數(shù)分解成更小的函數(shù)构资。
2.5. 使用一致的聲明樣式
Go 至少有六種不同的方式來聲明變量
var x int = 1
var x = 1
var x int; x = 1
var x = int(1)
x := 1
我確信還有更多我沒有想到的。 這可能是 Go 語言的設(shè)計(jì)師意識到的一個錯誤陨簇,但現(xiàn)在改變它為時已晚吐绵。 通過所有這些不同的方式來聲明變量,我們?nèi)绾伪苊饷總€ Go 程序員選擇自己的風(fēng)格河绽?
我想就如何在程序中聲明變量提出建議拦赠。 這是我盡可能使用的風(fēng)格。
-
聲明變量但沒有初始化時葵姥,請使用
var
荷鼠。 當(dāng)聲明變量稍后將在函數(shù)中初始化時,請使用var
關(guān)鍵字榔幸。
var players int // 0
var things []Thing // an empty slice of Things
var thing Thing // empty Thing struct
json.Unmarshall(reader, &thing)
var
表示此變量已被聲明為指定類型的零值允乐。 這也與使用 var
而不是短聲明語法在包級別聲明變量的要求一致 - 盡管我稍后會說你根本不應(yīng)該使用包級變量。
-
在聲明和初始化時削咆,使用
:=
牍疏。 在同時聲明和初始化變量時,也就是說我們不會將變量初始化為零值拨齐,我建議使用短變量聲明鳞陨。 這使得讀者清楚地知道:=
左側(cè)的變量是初始化過的。
為了解釋原因瞻惋,讓我們看看前面的例子厦滤,但這次是初始化每個變量:
var players int = 0
var things []Thing = nil
var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)
在第一個和第三個例子中,因?yàn)樵?Go 語言中沒有從一種類型到另一種類型的自動轉(zhuǎn)換; 賦值運(yùn)算符左側(cè)的類型必須與右側(cè)的類型相同歼狼。 編譯器可以從右側(cè)的類型推斷出聲明的變量的類型掏导,上面的例子可以更簡潔地寫為:
var players = 0
var things []Thing = nil
var thing = new(Thing)
json.Unmarshall(reader, thing)
我們將 players
初始化為 0
,但這是多余的羽峰,因?yàn)?0
是 players
的零值趟咆。 因此添瓷,要明確地表示使用零值, 我們將上面例子改寫為:
var players int
第二個聲明如何? 我們不能省略類型而寫作:
var things = nil
因?yàn)?nil
沒有類型值纱。 [2]相反鳞贷,我們有一個選擇,如果我們要使用切片的零值則寫作:
var things []Thing
或者我們要創(chuàng)建一個有零元素的切片則寫作:
var things = make([]Thing, 0)
如果我們想要后者那么這不是切片的零值虐唠,所以我們應(yīng)該向讀者說明我們通過使用簡短的聲明形式做出這個選擇:
things := make([]Thing, 0)
這告訴讀者我們已選擇明確初始化事物悄晃。
下面是第三個聲明,
var thing = new(Thing)
既是初始化了變量又引入了一些 Go 程序員不喜歡的 new
關(guān)鍵字的罕見用法凿滤。 如果我們用推薦地簡短聲明語法妈橄,那么就變成了:
thing := new(Thing)
這清楚地表明 thing
被初始化為 new(Thing)
的結(jié)果 - 一個指向 Thing
的指針 - 但依舊我們使用了 new
地罕見用法。 我們可以通過使用緊湊的文字結(jié)構(gòu)初始化形式來解決這個問題翁脆,
thing := &Thing{}
與 new(Thing)
相同眷蚓,這就是為什么一些 Go 程序員對重復(fù)感到不滿。 然而反番,這意味著我們使用指向 Thing{}
的指針初始化了 thing
沙热,也就是 Thing
的零值。
相反罢缸,我們應(yīng)該認(rèn)識到 thing
被聲明為零值篙贸,并使用地址運(yùn)算符將 thing
的地址傳遞給 json.Unmarshall
var thing Thing
json.Unmarshall(reader, &thing)
貼士:
當(dāng)然,任何經(jīng)驗(yàn)法則枫疆,都有例外爵川。 例如,有時兩個變量密切相關(guān)息楔,這樣寫會很奇怪:
var min int
max := 1000
如果這樣聲明可能更具可讀性
min, max := 0, 1000
綜上所述:
在沒有初始化的情況下聲明變量時寝贡,請使用 var
語法。
聲明并初始化變量時值依,請使用 :=
圃泡。
貼士:
使復(fù)雜的聲明顯而易見。
當(dāng)事情變得復(fù)雜時愿险,它看起來就會很復(fù)雜颇蜡。例如
var length uint32 = 0x80
這里
length
可能要與特定數(shù)字類型的庫一起使用,并且length
明確選擇為uint32
類型而不是短聲明形式:
length := uint32(0x80)
在第一個例子中辆亏,我故意違反了規(guī)則, 使用
var
聲明帶有初始化變量的风秤。 這個決定與我的常用的形式不同,這給讀者一個線索,告訴他們一些不尋常的事情將會發(fā)生褒链。
2.6. 成為團(tuán)隊(duì)合作者
我談到了軟件工程的目標(biāo)唁情,即編寫可讀及可維護(hù)的代碼疑苔。 因此甫匹,您可能會將大部分職業(yè)生涯用于你不是唯一作者的項(xiàng)目。 我在這種情況下的建議是遵循項(xiàng)目自身風(fēng)格。
在文件中間更改樣式是不和諧的兵迅。 即使不是你喜歡的方式抢韭,對于維護(hù)而言一致性比你的個人偏好更有價值。 我的經(jīng)驗(yàn)法則是: 如果它通過了 gofmt
恍箭,那么通常不值得再做代碼審查刻恭。
貼士:
如果要在代碼庫中進(jìn)行重命名,請不要將其混合到另一個更改中扯夭。 如果有人使用git bisect
鳍贾,他們不想通過數(shù)千行重命名來查找您更改的代碼。
3. 注釋
在我們繼續(xù)討論更大的項(xiàng)目之前交洗,我想花幾分鐘時間談?wù)撘幌伦⑨尅?/p>
Good code has lots of comments, bad code requires lots of comments.
(好的代碼有很多注釋骑科,壞代碼需要很多注釋。)
— Dave Thomas and Andrew Hunt (The Pragmatic Programmer)
注釋對 Go 語言程序的可讀性非常重要构拳。 注釋應(yīng)該做的三件事中的一件:
- 注釋應(yīng)該解釋其作用咆爽。
- 注釋應(yīng)該解釋其如何做的。
- 注釋應(yīng)該解釋其原因置森。
第一種形式是公共符號注釋的理想選擇:
// 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))
}
第三種形式是獨(dú)一無二的斗埂,因?yàn)樗粫〈皟煞N形式,但與此同時它并不能代替前兩種形式凫海。 此形式的注解用以解釋代碼的外部因素呛凶。 這些因素脫離上下文后通常很難理解,此注釋的為了提供這種上下文行贪。
return &v2.Cluster_CommonLbConfig{
// Disable HealthyPanicThreshold
HealthyPanicThreshold: &envoy_type.Percent{
Value: 0,
},
}
在此示例中把兔,無法清楚地明白 HealthyPanicThreshold
設(shè)置為零百分比的效果。 需要注釋 0
值將禁用 panic
閥值瓮顽。
3.1. 關(guān)于變量和常量的注釋應(yīng)描述其內(nèi)容而非其目的
我之前談過县好,變量或常量的名稱應(yīng)描述其目的。 向變量或常量添加注釋時暖混,該注釋應(yīng)描述變量內(nèi)容缕贡,而不是變量目的。
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的上下文中谍倦,數(shù)字 100
被稱為 StatusContinue
,如 RFC 7231 第 6.2.1 節(jié)中所定義泪勒。
貼士:
對于沒有初始值的變量昼蛀,注釋應(yīng)描述誰負(fù)責(zé)初始化此變量宴猾。
// sizeCalculationDisabled indicates whether it is safe
// to calculate Types' widths and alignments. See dowidth.
var sizeCalculationDisabled bool
這里的注釋讓讀者知道
dowidth
函數(shù)負(fù)責(zé)維護(hù)sizeCalculationDisabled
的狀態(tài)。隱藏在眾目睽睽下
這個提示來自Kate Gregory[3]叼旋。有時你會發(fā)現(xiàn)一個更好的變量名稱隱藏在注釋中仇哆。
// registry of SQL drivers
var registry = make(map[string]*sql.Driver)
注釋是由作者添加的,因?yàn)?
registry
沒有充分解釋其目的 - 它是一個注冊表夫植,但注冊的是什么讹剔?通過將變量重命名為
sqlDrivers
,現(xiàn)在可以清楚地知道此變量的目的是保存SQL驅(qū)動程序详民。
var sqlDrivers = make(map[string]*sql.Driver)
之前的注釋就是多余的延欠,可以刪除。
3.2. 公共符號始終要注釋
godoc
是包的文檔沈跨,所以應(yīng)該始終為包中聲明的每個公共符號 —? 變量衫冻、常量、函數(shù)以及方法添加注釋谒出。
以下是 Google Style
指南中的兩條規(guī)則:
- 任何既不明顯也不簡短的公共功能必須予以注釋隅俘。
- 無論長度或復(fù)雜程度如何,對庫中的任何函數(shù)都必須進(jìn)行注釋
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)
這條規(guī)則有一個例外; 您不需要注釋實(shí)現(xiàn)接口的方法笤喳。 具體不要像下面這樣做:
// 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
的聲明就在使用它的函數(shù)之前呼寸,而 LimitedReader.Read
的聲明遵循 LimitedReader
本身的聲明控妻。 盡管 LimitedReader.Read
本身沒有文檔鸥印,但它清楚地表明它是 io.Reader
的一個實(shí)現(xiàn)恭陡。
貼士:
在編寫函數(shù)之前蹬音,請編寫描述函數(shù)的注釋。 如果你發(fā)現(xiàn)很難寫出注釋休玩,那么這就表明你將要編寫的代碼很難理解著淆。
3.2.1. 不要注釋不好的代碼,將它重寫
Don’t comment bad code?—?rewrite it
— Brian Kernighan
粗劣的代碼的注釋高亮顯示是不夠的拴疤。 如果你遇到其中一條注釋永部,則應(yīng)提出問題,以提醒您稍后重構(gòu)呐矾。 只要技術(shù)債務(wù)數(shù)額已知苔埋,它是可以忍受的。
標(biāo)準(zhǔn)庫中的慣例是注意到它的人用 TODO(username)
的樣式來注釋蜒犯。
// TODO(dfc) this is O(N^2), find a faster way to do this.
注釋 username
不是該人承諾要解決該問題组橄,但在解決問題時他們可能是最好的人選荞膘。 其他項(xiàng)目使用 TODO
與日期或問題編號來注釋。
3.2.2. 與其注釋一段代碼晨炕,不如重構(gòu)它
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.
好的代碼是最好的文檔衫画。 在即將添加注釋時毫炉,請問下自己瓮栗,“如何改進(jìn)代碼以便不需要此注釋?' 改進(jìn)代碼使其更清晰瞄勾。
— Steve McConnell
函數(shù)應(yīng)該只做一件事费奸。 如果你發(fā)現(xiàn)自己在注釋一段與函數(shù)的其余部分無關(guān)的代碼,請考慮將其提取到它自己的函數(shù)中进陡。
除了更容易理解之外愿阐,較小的函數(shù)更易于隔離測試,將代碼隔離到函數(shù)中趾疚,其名稱可能是所需的所有文檔缨历。
4. 包的設(shè)計(jì)
Write shy code - modules that don’t reveal anything unnecessary to other modules and that don’t rely on other modules' implementations.
編寫謹(jǐn)慎的代碼 - 不向其他模塊透露任何不必要的模塊,并且不依賴于其他模塊的實(shí)現(xiàn)糙麦。
— Dave Thomas
每個 Go 語言的包實(shí)際上都是它一個小小的 Go 語言程序辛孵。 正如函數(shù)或方法的實(shí)現(xiàn)對調(diào)用者而言并不重要一樣,包的公共API-其函數(shù)赡磅、方法以及類型的實(shí)現(xiàn)對于調(diào)用者來說也并不重要魄缚。
一個好的 Go 語言包應(yīng)該具有低程度的源碼級耦合,這樣焚廊,隨著項(xiàng)目的增長冶匹,對一個包的更改不會跨代碼庫級聯(lián)。 這些世界末日的重構(gòu)嚴(yán)格限制了代碼庫的變化率以及在該代碼庫中工作的成員的生產(chǎn)率咆瘟。
在本節(jié)中嚼隘,我們將討論如何設(shè)計(jì)包,包括包的名稱袒餐,命名類型以及編寫方法和函數(shù)的技巧嗓蘑。
4.1. 一個好的包從它的名字開始
編寫一個好的 Go 語言包從包的名稱開始。將你的包名用一個詞來描述它匿乃。
正如我在上一節(jié)中談到變量的名稱一樣桩皿,包的名稱也非常重要。我遵循的經(jīng)驗(yàn)法則不是“我應(yīng)該在這個包中放入什么類型的幢炸?”泄隔。相反,我要問是“該包提供的服務(wù)是什么宛徊?”通常這個問題的答案不是“這個包提供 X
類型”佛嬉,而是“這個包提供 HTTP
”逻澳。
貼士:
以包所提供的內(nèi)容來命名,而不是它包含的內(nèi)容暖呕。
4.1.1. 好的包名應(yīng)該是唯一的斜做。
在項(xiàng)目中,每個包名稱應(yīng)該是唯一的湾揽。包的名稱應(yīng)該描述其目的的建議很容易理解 - 如果你發(fā)現(xiàn)有兩個包需要用相同名稱瓤逼,它可能是:
- 包的名稱太通用了。
- 該包與另一個類似名稱的包重疊了库物。在這種情況下霸旗,您應(yīng)該檢查你的設(shè)計(jì),或考慮合并包戚揭。
4.2. 避免使用類似 base
诱告,common
或 util
的包名稱
不好的包名的常見情況是 utility
包。這些包通常是隨著時間的推移一些幫助程序和工具類的包民晒。由于這些包包含各種不相關(guān)的功能精居,因此很難根據(jù)包提供的內(nèi)容來描述它們。這通常會導(dǎo)致包的名稱來自包含的內(nèi)容 - utilities
潜必。
像 utils
或 helper
這樣的包名稱通常出現(xiàn)在較大的項(xiàng)目中靴姿,這些項(xiàng)目已經(jīng)開發(fā)了深層次包的結(jié)構(gòu),并且希望在不遇到導(dǎo)入循環(huán)的情況下共享 helper
函數(shù)刮便。通過將 utility
程序函數(shù)提取到新的包中空猜,導(dǎo)入循環(huán)會被破壞,但由于該包源于項(xiàng)目中的設(shè)計(jì)問題恨旱,因此其包名稱不反映其目的辈毯,僅反映其為了打破導(dǎo)入循環(huán)。
我建議改進(jìn) utils
或 helpers
包的名稱是分析它們的調(diào)用位置搜贤,如果可能的話谆沃,將相關(guān)的函數(shù)移動到調(diào)用者的包中。即使這涉及復(fù)制一些 helper
程序代碼仪芒,這也比在兩個程序包之間引入導(dǎo)入依賴項(xiàng)更好唁影。
[A little] duplication is far cheaper than the wrong abstraction.
([一點(diǎn)點(diǎn)]重復(fù)比錯誤的抽象的性價比高很多。)
— Sandy Metz
在使用 utility
程序的情況下掂名,最好選多個包据沈,每個包專注于單個方面,而不是選單一的整體包饺蔑。
貼士:
使用復(fù)數(shù)形式命名utility
包锌介。例如strings
來處理字符串。
當(dāng)兩個或多個實(shí)現(xiàn)共有的功能或客戶端和服務(wù)器的常見類型被重構(gòu)為單獨(dú)的包時,通常會找到名稱類似于 base
或 common
的包孔祸。我相信解決方案是減少包的數(shù)量隆敢,將客戶端,服務(wù)器和公共代碼組合到一個以包的功能命名的包中崔慧。
例如拂蝎,net/http
包沒有 client
和 server
的分包,而是有一個 client.go
和 server.go
文件惶室,每個文件都有各自的類型温自,還有一個 transport.go
文件,用于公共消息傳輸代碼拇涤。
貼士:
標(biāo)識符的名稱包括其包名稱捣作。
重要的是標(biāo)識符的名稱包括其包的名稱誉结。
- 當(dāng)由另一個包引用時鹅士,
net/http
包中的 Get 函數(shù)變?yōu)?http.Get
。- 當(dāng)導(dǎo)入到其他包中時惩坑,
strings
包中的Reader
類型變?yōu)?strings.Reader
掉盅。net
包中的Error
接口顯然與網(wǎng)絡(luò)錯誤有關(guān)。
4.3. 盡早 return
而不是深度嵌套
由于 Go 語言的控制流不使用 exception
以舒,因此不需要為 try
和 catch
塊提供頂級結(jié)構(gòu)而深度縮進(jìn)代碼趾痘。Go 語言代碼不是成功的路徑越來越深地嵌套到右邊,而是以一種風(fēng)格編寫蔓钟,其中隨著函數(shù)的進(jìn)行永票,成功路徑繼續(xù)沿著屏幕向下移動。 我的朋友 Mat Ryer 將這種做法稱為“視線”編碼滥沫。[4]
這是通過使用 guard clauses
來實(shí)現(xiàn)的; 在進(jìn)入函數(shù)時是具有斷言前提條件的條件塊侣集。 這是一個來自 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
}
進(jìn)入 UnreadRune
后,將檢查 b.lastRead
的狀態(tài)兰绣,如果之前的操作不是 ReadRune
世分,則會立即返回錯誤。 之后缀辩,函數(shù)的其余部分繼續(xù)進(jìn)行 b.lastRead
大于 opInvalid
的斷言臭埋。
與沒有 guard clause
的相同函數(shù)進(jìn)行比較,
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")
}
最常見的執(zhí)行成功的情況是嵌套在第一個if條件內(nèi)臀玄,成功的退出條件是 return nil
瓢阴,而且必須通過仔細(xì)匹配大括號來發(fā)現(xiàn)。 函數(shù)的最后一行是返回一個錯誤健无,并且被調(diào)用者必須追溯到匹配的左括號荣恐,以了解何時執(zhí)行到此點(diǎn)。
對于讀者和維護(hù)程序員來說睬涧,這更容易出錯募胃,因此 Go 語言更喜歡使用 guard clauses
并盡早返回錯誤旗唁。
4.4. 讓零值更有用
假設(shè)變量沒有初始化,每個變量聲明都會自動初始化為與零內(nèi)存的內(nèi)容相匹配的值痹束。 這就是零值检疫。 值的類型決定了其零值; 對于數(shù)字類型,它為 0
祷嘶,對于指針類型為 nil
屎媳,slices
、map
和 channel
同樣是 nil
论巍。
始終設(shè)置變量為已知默認(rèn)值的屬性對于程序的安全性和正確性非常重要烛谊,并且可以使 Go 語言程序更簡單、更緊湊嘉汰。 這就是 Go 程序員所說的“給你的結(jié)構(gòu)一個有用的零值”丹禀。
對于 sync.Mutex
類型。sync.Mutex
包含兩個未公開的整數(shù)字段鞋怀,它們用來表示互斥鎖的內(nèi)部狀態(tài)双泪。 每當(dāng)聲明 sync.Mutex
時,其字段會被設(shè)置為 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
。如果我們看一下切片運(yùn)行時 header
的定義就不難理解:
type slice struct {
array *[...]T // pointer to the underlying array
len int
cap int
}
此結(jié)構(gòu)的零值意味著 len
和 cap
的值為 0
抛猫,而 array
(指向保存切片的內(nèi)容數(shù)組的指針)將為 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
值的類型上調(diào)用方法。它可以簡單地用于提供默認(rèn)值哎壳。
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. 避免包級別狀態(tài)
編寫可維護(hù)程序的關(guān)鍵是它們應(yīng)該是松散耦合的 - 對一個程序包的更改應(yīng)該很少影響另一個不直接依賴于第一個程序包的程序包毅待。
在 Go 語言中有兩種很好的方法可以實(shí)現(xiàn)松散耦合
- 使用接口來描述函數(shù)或方法所需的行為。
- 避免使用全局狀態(tài)归榕。
在 Go 語言中尸红,我們可以在函數(shù)或方法范圍以及包范圍內(nèi)聲明變量。當(dāng)變量是公共的時,給定一個以大寫字母開頭的標(biāo)識符外里,那么它的范圍對于整個程序來說實(shí)際上是全局的 - 任何包都可以隨時觀察該變量的類型和內(nèi)容怎爵。
可變?nèi)譅顟B(tài)引入程序的獨(dú)立部分之間的緊密耦合,因?yàn)槿肿兞砍蔀槌绦蛑忻總€函數(shù)的不可見參數(shù)盅蝗!如果該變量的類型發(fā)生更改鳖链,則可以破壞依賴于全局變量的任何函數(shù)。如果程序的另一部分更改了該變量墩莫,則可以破壞依賴于全局變量狀態(tài)的任何函數(shù)芙委。
如果要減少全局變量所帶來的耦合,
- 將相關(guān)變量作為字段移動到需要它們的結(jié)構(gòu)上狂秦。
- 使用接口來減少行為與實(shí)現(xiàn)之間的耦合灌侣。
5. 項(xiàng)目結(jié)構(gòu)
我們來談?wù)勅绾螌M合到項(xiàng)目中。 通常一個項(xiàng)目是一個 git
倉庫裂问,但在未來 Go 語言開發(fā)人員會交替地使用 module
和 project
侧啼。
就像一個包,每個項(xiàng)目都應(yīng)該有一個明確的目的愕秫。 如果你的項(xiàng)目是一個庫慨菱,它應(yīng)該提供一件事焰络,比如 XML
解析或記錄戴甩。 您應(yīng)該避免在一個包實(shí)現(xiàn)多個目的,這將有助于避免成為 common
庫闪彼。
貼士:
據(jù)我的經(jīng)驗(yàn)甜孤,common
庫最終會與其最大的調(diào)用者緊密相連,在沒有升級該庫與最大調(diào)用者的情況下是很難修復(fù)的畏腕,還會帶來了許多無關(guān)的更改以及API破壞缴川。
如果你的項(xiàng)目是應(yīng)用程序,如 Web
應(yīng)用程序描馅,Kubernetes
控制器等把夸,那么項(xiàng)目中可能有一個或多個 main
程序包。 例如铭污,我編寫的 Kubernetes
控制器有一個 cmd/contour
包恋日,既可以作為部署到 Kubernetes
集群的服務(wù)器,也可以作為調(diào)試目的的客戶端嘹狞。
5.1. 考慮更少岂膳,更大的包
對于從其他語言過渡到 Go 語言的程序員來說,我傾向于在代碼審查中提到的一件事是他們會過度使用包磅网。
Go 語言沒有提供有關(guān)可見性的詳細(xì)方法; Java有 public
谈截、protected
、private
以及隱式 default
的訪問修飾符。 沒有 C++
的 friend
類概念簸喂。
在 Go 語言中毙死,我們只有兩個訪問修飾符,public
和 private
喻鳄,由標(biāo)識符的第一個字母的大小寫表示规哲。 如果標(biāo)識符是公共的,則其名稱以大寫字母開頭诽表,該標(biāo)識符可用于任何其他 Go 語言包的引用唉锌。
注意:
你可能會聽到人們說exported
與not exported
, 跟public
和private
是同義詞。
鑒于包的符號的訪問有限控件竿奏,Go 程序員應(yīng)遵循哪些實(shí)踐來避免創(chuàng)建過于復(fù)雜的包層次結(jié)構(gòu)袄简?
貼士:
除cmd/
和internal/
之外的每個包都應(yīng)包含一些源代碼。
我的建議是選擇更少泛啸,更大的包绿语。 你應(yīng)該做的是不創(chuàng)建新的程序包。 這將導(dǎo)致太多類型被公開候址,為你的包創(chuàng)建一個寬而淺的API吕粹。
以下部分將更為詳細(xì)地探討這一建議。
貼士:
來自Java
岗仑?
如果您來自Java
或C#
匹耕,請考慮這一經(jīng)驗(yàn)法則 --Java
包相當(dāng)于單個.go
源文件。 - Go 語言包相當(dāng)于整個Maven
模塊或.NET
程序集荠雕。
5.1.1. 通過 import
語句將代碼排列到文件中
如果你按照包提供的內(nèi)容來安排你的程序包稳其,是否需要對 Go 包中的文件也執(zhí)行相同的操作?什么時候應(yīng)該將 .go
文件拆分成多個文件炸卑?什么時候應(yīng)該考慮整合 .go
文件既鞠?
以下是我的經(jīng)驗(yàn)法則:
- 開始時使用一個
.go
文件。為該文件指定與文件夾名稱相同的名稱盖文。例如:package http
應(yīng)放在名為http
的目錄中名為http.go
的文件中嘱蛋。 - 隨著包的增長,您可能決定將各種職責(zé)任務(wù)拆分為不同的文件五续。例如:
messages.go
包含Request
和Response
類型洒敏,client.go
包含Client
類型,server.go
包含Server
類型返帕。 - 如果你的文件中
import
的聲明類似桐玻,請考慮將它們組合起來【S或者確定import
集之間的差異并移動它們镊靴。 - 不同的文件應(yīng)該負(fù)責(zé)包的不同區(qū)域铣卡。
messages.go
可能負(fù)責(zé)網(wǎng)絡(luò)的HTTP
請求和響應(yīng),http.go
可能包含底層網(wǎng)絡(luò)處理邏輯偏竟,client.go
和server.go
實(shí)現(xiàn)HTTP
業(yè)務(wù)邏輯請求的實(shí)現(xiàn)或路由等等煮落。
貼士: 首選名詞為源文件命名。
注意:
Go編譯器并行編譯每個包踊谋。 在一個包中蝉仇,編譯器并行編譯每個函數(shù)(方法只是 Go 語言中函數(shù)的另一種寫法)。 更改包中代碼的布局不會影響編譯時間殖蚕。
5.1.2. 優(yōu)先內(nèi)部測試再到外部測試
go tool
支持在兩個地方編寫 testing
包測試轿衔。假設(shè)你的包名為 http2
,您可以編寫 http2_test.go
文件并使用包 http2
聲明睦疫。這樣做會編譯 http2_test.go
中的代碼甜奄,就像它是 http2
包的一部分一樣弊琴。這就是內(nèi)部測試吱瘩。
go tool
還支持一個特殊的包聲明扶檐,以 test
為結(jié)尾,即 package http_test
瓦糕。這允許你的測試文件與代碼一起存放在同一個包中底洗,但是當(dāng)編譯時這些測試不是包的代碼的一部分,它們存在于自己的包中咕娄。就像調(diào)用另一個包的代碼一樣來編寫測試亥揖。這被稱為外部測試。
我建議在編寫單元測試時使用內(nèi)部測試谭胚。這樣你就可以直接測試每個函數(shù)或方法徐块,避免外部測試干擾。
但是灾而,你應(yīng)該將 Example
測試函數(shù)放在外部測試文件中。這確保了在 godoc
中查看時扳剿,示例具有適當(dāng)?shù)陌熬Y并且可以輕松地進(jìn)行復(fù)制粘貼旁趟。
貼士:
避免復(fù)雜的包層次結(jié)構(gòu),抵制應(yīng)用分類法
Go 語言包的層次結(jié)構(gòu)對于go tool
沒有任何意義除了下一節(jié)要說的庇绽。 例如锡搜,net/http
包不是一個子包或者net
包的子包。如果在項(xiàng)目中創(chuàng)建了不包含
.go
文件的中間目錄瞧掺,則可能無法遵循此建議耕餐。
5.1.3. 使用 internal
包來減少公共API
如果項(xiàng)目包含多個包,可能有一些公共的函數(shù)辟狈,這些函數(shù)旨在供項(xiàng)目中的其他包使用肠缔,但不打算成為項(xiàng)目的公共API的一部分夏跷。 如果你發(fā)現(xiàn)是這種情況,那么 go tool
會識別一個特殊的文件夾名稱 - 而非包名稱 - internal/
可用于放置對項(xiàng)目公開的代碼明未,但對其他項(xiàng)目是私有的槽华。
要創(chuàng)建此類包,請將其放在名為 internal/
的目錄中趟妥,或者放在名為 internal/
的目錄的子目錄中猫态。 當(dāng) go
命令在其路徑中看到導(dǎo)入包含 internal
的包時,它會驗(yàn)證執(zhí)行導(dǎo)入的包是否位于 internal
目錄披摄。
例如亲雪,.../a/b/c/internal/d/e/f
的包只能通過以 .../a/b/c/
為根目錄的代碼被導(dǎo)入。 它無法通過 .../a/b/g
或任何其他倉庫中的代碼導(dǎo)入疚膊。[5]
5.2. 確保 main
包內(nèi)容盡可能的少
main
函數(shù)和 main
包的內(nèi)容應(yīng)盡可能少匆光。 這是因?yàn)?main.main
充當(dāng)單例; 程序中只能有一個 main
函數(shù),包括 tests
酿联。
因?yàn)?main.main
是一個單例终息,假設(shè) main
函數(shù)中需要執(zhí)行很多事情,main.main
只會在 main.main
或 main.init
中調(diào)用它們并且只調(diào)用一次。 這使得為 main.main
編寫代碼測試變得很困難贞让,因此你應(yīng)該將所有業(yè)務(wù)邏輯從 main
函數(shù)中移出周崭,最好是從 main
包中移出。
貼士:
main
應(yīng)該做解析flags
喳张,開啟數(shù)據(jù)庫連接续镇、開啟日志等,然后將執(zhí)行交給更高一級的對象销部。
6. API 設(shè)計(jì)
我今天要給出的最后一條建議是設(shè)計(jì), 我認(rèn)為也是最重要的摸航。
到目前為止我提出的所有建議都是建議。 這些是我嘗試編寫 Go 語言的方式舅桩,但我不打算在代碼審查中拼命推廣酱虎。
但是,在審查 API 時, 我就不會那么寬容了擂涛。 這是因?yàn)榈侥壳盀橹刮宜務(wù)摰乃袃?nèi)容都是可以修復(fù)而且不會破壞向后兼容性; 它們在很大程度上是實(shí)現(xiàn)的細(xì)節(jié)读串。
當(dāng)涉及到軟件包的公共 API 時,在初始設(shè)計(jì)中投入大量精力是值得的撒妈,因?yàn)樯院蟾脑撛O(shè)計(jì)對于已經(jīng)使用 API 的人來說會是破壞性的恢暖。
6.1. 設(shè)計(jì)難以被誤用的 API
APIs should be easy to use and hard to misuse.
(API 應(yīng)該易于使用且難以被誤用)
— Josh Bloch [3]
如果你從這個演講中帶走任何東西,那應(yīng)該是 Josh Bloch 的建議狰右。 如果一個 API 很難用于簡單的事情杰捂,那么 API 的每次調(diào)用都會很復(fù)雜。 當(dāng) API 的實(shí)際調(diào)用很復(fù)雜時棋蚌,它就會便得不那么明顯嫁佳,而且會更容易被忽視挨队。
6.1.1. 警惕采用幾個相同類型參數(shù)的函數(shù)
簡單, 但難以正確使用的 API 是采用兩個或更多相同類型參數(shù)的 API。 讓我們比較兩個函數(shù)簽名:
func Max(a, b int) int
func CopyFile(to, from string) error
這兩個函數(shù)有什么區(qū)別脱拼? 顯然瞒瘸,一個返回兩個數(shù)字最大的那個,另一個是復(fù)制文件熄浓,但這不重要情臭。
Max(8, 10) // 10
Max(10, 8) // 10
Max
是可交換的; 參數(shù)的順序無關(guān)緊要。 無論是 8 比 10 還是 10 比 8赌蔑,最大的都是 10俯在。
但是,卻不適用于 CopyFile
娃惯。
CopyFile("/tmp/backup", "presentation.md")
CopyFile("presentation.md", "/tmp/backup")
這些聲明中哪一個備份了 presentation.md
跷乐,哪一個用上周的版本覆蓋了 presentation.md
? 沒有文檔趾浅,你無法分辨愕提。 如果沒有查閱文檔,代碼審查員也無法知道你寫對了順序皿哨。
一種可能的解決方案是引入一個 helper
類型浅侨,它會負(fù)責(zé)如何正確地調(diào)用 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
總是能被正確調(diào)用 - 還可以通過單元測試 - 并且可以被設(shè)置為私有如输,進(jìn)一步降低了誤用的可能性。
貼士: 具有多個相同類型參數(shù)的API難以正確使用央勒。
6.2. 為其默認(rèn)用例設(shè)計(jì) API
幾年前不见,我就對 functional options
[7] 進(jìn)行過討論[6],使 API 更易用于默認(rèn)用例崔步。
本演講的主旨是你應(yīng)該為常見用例設(shè)計(jì) API稳吮。 另一方面, API 不應(yīng)要求調(diào)用者提供他們不在乎參數(shù)刷晋。
6.2.1. 不鼓勵使用 nil
作為參數(shù)
本章開始時我建議是不要強(qiáng)迫提供給 API 的調(diào)用者他們不在乎的參數(shù)盖高。 這就是我要說的為默認(rèn)用例設(shè)計(jì) 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
有兩個參數(shù)眼虱,一個用于監(jiān)聽傳入連接的 TCP
地址,另一個用于處理 HTTP
請求的 http.Handler
席纽。Serve
允許第二個參數(shù)為 nil
捏悬,需要注意的是調(diào)用者通常會傳遞 nil
,表示他們想要使用 http.DefaultServeMux
作為隱含參數(shù)润梯。
現(xiàn)在过牙,Serve
的調(diào)用者有兩種方式可以做同樣的事情甥厦。
http.ListenAndServe("0.0.0.0:8080", nil)
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
兩者完全相同。
這種 nil
行為是病毒式的寇钉。 http
包也有一個 http.Serve
幫助類刀疙,你可以合理地想象一下 ListenAndServe
是這樣構(gòu)建的
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)
}
因?yàn)?ListenAndServe
允許調(diào)用者為第二個參數(shù)傳遞 nil
,所以 http.Serve
也支持這種行為扫倡。 事實(shí)上谦秧,http.Serve
實(shí)現(xiàn)了如果 handler
是nil
,使用 DefaultServeMux
的邏輯撵溃。 參數(shù)可為 nil
可能會導(dǎo)致調(diào)用者認(rèn)為他們可以為兩個參數(shù)都使用 nil
疚鲤。 像下面這樣:
http.Serve(nil, nil)
會導(dǎo)致 panic
。
貼士:
不要在同一個函數(shù)簽名中混合使用可為nil
和不能為nil
的參數(shù)缘挑。
http.ListenAndServe
的作者試圖在常見情況下讓使用 API 的用戶更輕松些集歇,但很可能會讓該程序包更難以被安全地使用。
使用 DefaultServeMux
或使用 nil
沒有什么區(qū)別语淘。
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)
貼士: 認(rèn)真考慮
helper
函數(shù)會節(jié)省不少時間。 清晰要比簡潔好惶翻。
貼士:
避免公共 API 使用測試參數(shù)
避免在公開的 API 上使用僅在測試范圍上不同的值姑蓝。 相反,使用Public wrappers
隱藏這些參數(shù)维贺,使用輔助方式來設(shè)置測試范圍中的屬性它掂。
6.2.2. 首選可變參數(shù)函數(shù)而非 []T
參數(shù)
編寫一個帶有切片參數(shù)的函數(shù)或方法是很常見的。
func ShutdownVMs(ids []string) error
這只是我編的一個例子溯泣,但它與我所寫的很多代碼相同虐秋。 這里的問題是他們假設(shè)他們會被調(diào)用于多個條目。 但是很多時候這些類型的函數(shù)只用一個參數(shù)調(diào)用垃沦,為了滿足函數(shù)參數(shù)的要求客给,它必須打包到一個切片內(nèi)。
另外肢簿,因?yàn)?ids
參數(shù)是切片靶剑,所以你可以將一個空切片或 nil
傳遞給該函數(shù),編譯也沒什么錯誤池充。 但是這會增加額外的測試負(fù)載桩引,因?yàn)槟銘?yīng)該涵蓋這些情況在測試中。
舉一個這類 API 的例子收夸,最近我重構(gòu)了一條邏輯坑匠,要求我設(shè)置一些額外的字段,如果一組參數(shù)中至少有一個非零卧惜。 邏輯看起來像這樣:
if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 {
// apply the non zero parameters
}
由于 if
語句變得很長厘灼,我想將簽出的邏輯拉入其自己的函數(shù)中夹纫。 這就是我提出的:
// 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
}
這就能夠向讀者明確內(nèi)部塊的執(zhí)行條件:
if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
// apply the non zero parameters
}
但是 anyPositive
還存在一個問題,有人可能會這樣調(diào)用它:
if anyPositive() { ... }
在這種情況下设凹,anyPositive
將返回 false
舰讹,因?yàn)樗粫?zhí)行迭代而是立即返回 false
。對比起如果 anyPositive
在沒有傳遞參數(shù)時返回 true
, 這還不算世界上最糟糕的事情闪朱。
然而月匣,如果我們可以更改 anyPositive
的簽名以強(qiáng)制調(diào)用者應(yīng)該傳遞至少一個參數(shù),那會更好监透。我們可以通過組合正常和可變參數(shù)來做到這一點(diǎn)桶错,如下所示:
// 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
}
現(xiàn)在不能使用少于一個參數(shù)來調(diào)用 anyPositive
。
6.3. 讓函數(shù)定義它們所需的行為
假設(shè)我需要編寫一個將 Document
結(jié)構(gòu)保存到磁盤的函數(shù)的任務(wù)胀蛮。
// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error
我可以指定這個函數(shù) Save
院刁,它將 *os.File
作為寫入 Document
的目標(biāo)。但這樣做會有一些問題
Save
的簽名排除了將數(shù)據(jù)寫入網(wǎng)絡(luò)位置的選項(xiàng)粪狼。假設(shè)網(wǎng)絡(luò)存儲可能在以后成為需求退腥,則此功能的簽名必須改變,從而影響其所有調(diào)用者再榄。
Save
測試起來也很麻煩狡刘,因?yàn)樗苯硬僮鞔疟P上的文件。因此困鸥,為了驗(yàn)證其操作嗅蔬,測試時必須在寫入文件后再讀取該文件的內(nèi)容。
而且我必須確保 f
被寫入臨時位置并且隨后要將其刪除疾就。
*os.File
還定義了許多與 Save
無關(guān)的方法澜术,比如讀取目錄并檢查路徑是否是符號鏈接。 如果 Save
函數(shù)的簽名只用 *os.File
的相關(guān)內(nèi)容猬腰,那將會很有用鸟废。
我們能做什么 ?
// Save writes the contents of doc to the supplied
// ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error
使用 io.ReadWriteCloser
姑荷,我們可以應(yīng)用接口隔離原則來重新定義 Save
以獲取更通用文件形式盒延。
通過此更改,任何實(shí)現(xiàn) io.ReadWriteCloser
接口的類型都可以替換以前的 *os.File
鼠冕。
這使 Save
在其應(yīng)用程序中更廣泛添寺,并向 Save
的調(diào)用者闡明 *os.File
類型的哪些方法與其操作有關(guān)。
而且懈费,Save
的作者也不可以在 *os.File
上調(diào)用那些不相關(guān)的方法畦贸,因?yàn)樗[藏在 io.ReadWriteCloser
接口后面。
但我們可以進(jìn)一步采用接口隔離原則楞捂。
首先薄坏,如果 Save
遵循單一功能原則,它不可能讀取它剛剛寫入的文件來驗(yàn)證其內(nèi)容 - 這應(yīng)該是另一段代碼的功能寨闹。
// Save writes the contents of doc to the supplied
// WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error
因此胶坠,我們可以將我們傳遞給 Save
的接口的規(guī)范縮小到只寫和關(guān)閉。
其次繁堡,通過向 Save
提供一個關(guān)閉其流的機(jī)制沈善,使其看起來仍然像一個文件,這就提出了在什么情況下關(guān)閉 wc
的問題椭蹄。
可能 Save
會無條件地調(diào)用 Close
闻牡,或者在成功的情況下調(diào)用 Close
。
這給 Save
的調(diào)用者帶來了問題绳矩,因?yàn)樗赡芟M趯懭胛臋n后將其他數(shù)據(jù)寫入流罩润。
// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error
一個更好的解決方案是重新定義 Save
僅使用 io.Writer
,它只負(fù)責(zé)將數(shù)據(jù)寫入流翼馆。
將接口隔離原則應(yīng)用于我們的 Save
功能割以,同時, 就需求而言, 得出了最具體的一個函數(shù) - 它只需要一個可寫的東西 - 并且它的功能最通用,現(xiàn)在我們可以使用 Save
將我們的數(shù)據(jù)保存到實(shí)現(xiàn) io.Writer
的任何事物中应媚。
[譯注: 不理解設(shè)計(jì)原則部分的同學(xué)可以閱讀 Dave 大神的另一篇《Go 語言 SOLID 設(shè)計(jì)》]
7. 錯誤處理
我已經(jīng)給出了幾個關(guān)于錯誤處理的演示文稿[8]严沥,并在我的博客上寫了很多關(guān)于錯誤處理的文章。我在昨天的會議上也講了很多關(guān)于錯誤處理的內(nèi)容中姜,所以在這里不再贅述消玄。
- https://dave.cheney.net/2014/12/24/inspecting-errors
- https://dave.cheney.net/2016/04/07/constant-errors
相反,我想介紹與錯誤處理相關(guān)的兩個其他方面丢胚。
7.1. 通過消除錯誤來消除錯誤處理
如果你昨天在我的演講中翩瓜,我談到了改進(jìn)錯誤處理的提案。但是你知道有什么比改進(jìn)錯誤處理的語法更好嗎嗜桌?那就是根本不需要處理錯誤奥溺。
注意:
我不是說“刪除你的錯誤處理”。我的建議是骨宠,修改你的代碼浮定,這樣就不用處理錯誤了。
本節(jié)從 John Ousterhout 最近的著作“軟件設(shè)計(jì)哲學(xué)”[9]中汲取靈感层亿。該書的其中一章是“定義不存在的錯誤”桦卒。我們將嘗試將此建議應(yīng)用于 Go 語言。
7.1.1. 計(jì)算行數(shù)
讓我們編寫一個函數(shù)來計(jì)算文件中的行數(shù)匿又。
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
;它的任務(wù)是調(diào)用者為我們想要計(jì)算的內(nèi)容提供 io.Reader
裕偿。
我們構(gòu)造一個 bufio.Reader
洞慎,然后在一個循環(huán)中調(diào)用 ReadString
方法,遞增計(jì)數(shù)器直到我們到達(dá)文件的末尾嘿棘,然后我們返回讀取的行數(shù)劲腿。
至少這是我們想要編寫的代碼,但是這個函數(shù)由于需要錯誤處理而變得更加復(fù)雜鸟妙。 例如焦人,有這樣一個奇怪的結(jié)構(gòu):
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
我們在檢查錯誤之前增加了行數(shù),這樣做看起來很奇怪重父。
我們必須以這種方式編寫它的原因是花椭,如果在遇到換行符之前就讀到文件結(jié)束,則 ReadString
將返回錯誤房午。如果文件中沒有換行符矿辽,同樣會出現(xiàn)這種情況。
為了解決這個問題歪沃,我們重新排列邏輯增來加行數(shù)嗦锐,然后查看是否需要退出循環(huán)。
注意:
這個邏輯仍然不完美沪曙,你能發(fā)現(xiàn)錯誤嗎奕污?
但是我們還沒有完成檢查錯誤。當(dāng) ReadString
到達(dá)文件末尾時液走,預(yù)期它會返回 io.EOF
碳默。ReadString
需要某種方式在沒有什么可讀時來停止。因此缘眶,在我們將錯誤返回給 CountLine
的調(diào)用者之前嘱根,我們需要檢查錯誤是否是 io.EOF
,如果不是將其錯誤返回巷懈,否則我們返回 nil
說一切正常该抒。
我認(rèn)為這是 Russ Cox 觀察到錯誤處理可能會模??糊函數(shù)操作的一個很好的例子。我們來看一個改進(jìn)的版本顶燕。
func CountLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
lines := 0
for sc.Scan() {
lines++
}
return lines, sc.Err()
}
這個改進(jìn)的版本從 bufio.Reader
切換到 bufio.Scanner
凑保。
在 bufio.Scanner
內(nèi)部使用 bufio.Reader
,但它添加了一個很好的抽象層涌攻,它有助于通過隱藏 CountLines
的操作來消除錯誤處理欧引。
注意:
bufio.Scanner
可以掃描任何模式,但默認(rèn)情況下它會查找換行符恳谎。
如果掃描程序匹配了一行文本并且沒有遇到錯誤芝此,則 sc.Scan()
方法返回 true
。因此,只有當(dāng)掃描儀的緩沖區(qū)中有一行文本時婚苹,才會調(diào)用 for
循環(huán)的主體岸更。這意味著我們修改后的 CountLines
正確處理沒有換行符的情況,并且還處理文件為空的情況租副。
其次坐慰,當(dāng) sc.Scan
在遇到錯誤時返回 false
,我們的 for
循環(huán)將在到達(dá)文件結(jié)尾或遇到錯誤時退出用僧。bufio.Scanner
類型會記住遇到的第一個錯誤,一旦我們使用 sc.Err()
方法退出循環(huán)赞咙,我們就可以獲取該錯誤责循。
最后, sc.Err()
負(fù)責(zé)處理 io.EOF
并在達(dá)到文件末尾時將其轉(zhuǎn)換為 nil
攀操,而不會遇到其他錯誤院仿。
貼士:
當(dāng)遇到難以忍受的錯誤處理時,請嘗試將某些操作提取到輔助程序類型中速和。
7.1.2. WriteResponse
我的第二個例子受到了 Errors are values
博客文章[10]的啟發(fā)歹垫。
在本章前面我們已經(jīng)看過處理打開、寫入和關(guān)閉文件的示例颠放。錯誤處理是存在的排惨,但是接收范圍內(nèi)的,因?yàn)椴僮骺梢苑庋b在諸如 ioutil.ReadFile
和 ioutil.WriteFile
之類的輔助程序中碰凶。但是暮芭,在處理底層網(wǎng)絡(luò)協(xié)議時,有必要使用 I/O
原始的錯誤處理來直接構(gòu)建響應(yīng)欲低,這樣就可能會變得重復(fù)辕宏。看一下構(gòu)建 HTTP
響應(yīng)的 HTTP
服務(wù)器的這個片段砾莱。
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
構(gòu)造狀態(tài)碼并檢查錯誤洪添。 然后對于每個標(biāo)題拭宁,我們寫入鍵值對,每次都檢查錯誤太防。 最后扫步,我們使用額外的 \r\n
終止標(biāo)題部分魔策,檢查錯誤之后將響應(yīng)主體復(fù)制到客戶端。 最后河胎,雖然我們不需要檢查 io.Copy
中的錯誤闯袒,但我們需要將 io.Copy
返回的兩個返回值形式轉(zhuǎn)換為 WriteResponse
的單個返回值。
這里很多重復(fù)性的工作。 我們可以通過引入一個包裝器類型 errWriter
來使其更容易政敢。
errWriter
實(shí)現(xiàn) io.Writer
接口其徙,因此可用于包裝現(xiàn)有的 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
應(yīng)用于 WriteResponse
可以顯著提高代碼的清晰度闹获。 每個操作不再需要自己做錯誤檢查。 通過檢查 ew.err
字段河哑,將錯誤報(bào)告移動到函數(shù)末尾避诽,從而避免轉(zhuǎn)換從 io.Copy
的兩個返回值。
7.2. 錯誤只處理一次
最后璃谨,我想提一下你應(yīng)該只處理錯誤一次沙庐。 處理錯誤意味著檢查錯誤值并做出單一決定。
// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
w.Write(buf)
}
如果你做出的決定少于一個佳吞,則忽略該錯誤拱雏。 正如我們在這里看到的那樣, w.WriteAll
的錯誤被丟棄底扳。
但是铸抑,針對單個錯誤做出多個決策也是有問題的。 以下是我經(jīng)常遇到的代碼花盐。
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
期間發(fā)生錯誤,則會寫入日志文件算芯,注明錯誤發(fā)生的文件與行數(shù)柒昏,并且錯誤也會返回給調(diào)用者,調(diào)用者可能會記錄該錯誤并將其返回到上一級熙揍,一直回到程序的頂部职祷。
調(diào)用者可能正在做同樣的事情
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
}
因此你在日志文件中得到一堆重復(fù)的內(nèi)容,
unable to write: io.EOF
could not write config: io.EOF
但在程序的頂部届囚,雖然得到了原始錯誤有梆,但沒有相關(guān)內(nèi)容。
err := WriteConfig(f, &conf)
fmt.Println(err) // io.EOF
我想深入研究這一點(diǎn)意系,因?yàn)樽鳛閭€人偏好, 我并沒有看到 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 語言風(fēng)格是使用 guard clauses
以及檢查前提條件作為函數(shù)進(jìn)展并提前返回痰催。
在這個例子中兜辞,作者檢查了錯誤,記錄了它夸溶,但忘了返回逸吵。這就引起了一個微妙的錯誤。
Go 語言中的錯誤處理規(guī)定缝裁,如果出現(xiàn)錯誤扫皱,你不能對其他返回值的內(nèi)容做出任何假設(shè)。由于 JSON
解析失敗捷绑,buf
的內(nèi)容未知韩脑,可能它什么都沒有,但更糟的是它可能包含解析的 JSON
片段部分胎食。
由于程序員在檢查并記錄錯誤后忘記返回扰才,因此損壞的緩沖區(qū)將傳遞給 WriteAll
,這可能會成功厕怜,因此配置文件將被錯誤地寫入。但是蕾总,該函數(shù)會正常返回粥航,并且發(fā)生問題的唯一日志行是有關(guān) JSON
解析錯誤,而與寫入配置失敗有關(guān)生百。
7.2.1. 為錯誤添加相關(guān)內(nèi)容
發(fā)生錯誤的原因是作者試圖在錯誤消息中添加 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
}
通過將注釋與返回的錯誤組合起來,就更難以忘記錯誤的返回來避免意外繼續(xù)市俊。
如果寫入文件時發(fā)生 I/O
錯誤杨凑,則 error
的 Error()
方法會報(bào)告以下類似的內(nèi)容;
could not write config: write failed: input/output error
7.2.2. 使用 github.com/pkg/errors
包裝 errors
fmt.Errorf
模式適用于注釋錯誤 message
,但這樣做的代價是模糊了原始錯誤的類型摆昧。 我認(rèn)為將錯誤視為不透明值對于松散耦合的軟件非常重要撩满,因此如果你使用錯誤值做的唯一事情是原始錯誤的類型應(yīng)該無關(guān)緊要的面孔
- 檢查它是否為
nil
。 - 輸出或記錄它绅你。
但是在某些情況下伺帘,我認(rèn)為它們并不常見,您需要恢復(fù)原始錯誤忌锯。 在這種情況下伪嫁,使用類似我的 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)
}
}
現(xiàn)在報(bào)告的錯誤就是 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)
}
}
因此张咳,你可以恢復(fù)原始錯誤并打印堆棧跟蹤;
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
包帝洪,你可以以人和機(jī)器都可檢查的方式向錯誤值添加上下文。 如果昨天你來聽我的演講晶伦,你會知道這個庫在被移植到即將發(fā)布的 Go 語言版本的標(biāo)準(zhǔn)庫中碟狞。
8. 并發(fā)
由于 Go 語言的并發(fā)功能,經(jīng)常被選作項(xiàng)目編程語言婚陪。 Go 語言團(tuán)隊(duì)已經(jīng)竭盡全力以廉價(在硬件資源方面)和高性能來實(shí)現(xiàn)并發(fā)族沃,但是 Go 語言的并發(fā)功能也可以被用來編寫性能不高同時也不太可靠的代碼。在結(jié)尾泌参,我想留下一些建議脆淹,以避免 Go 語言的并發(fā)功能帶來的一些陷阱。
Go 語言以 channels
以及 select
和 go
語句來支持并發(fā)沽一。如果你已經(jīng)從書籍或培訓(xùn)課程中正式學(xué)習(xí)了 Go 語言盖溺,你可能已經(jīng)注意到并發(fā)部分始終是這些課程的最后一部分。這個研討會也沒有什么不同铣缠,我選擇最后覆蓋并發(fā)烘嘱,好像它是 Go 程序員應(yīng)該掌握的常規(guī)技能的額外補(bǔ)充。
這里有一個二分法; Go 語言的最大特點(diǎn)是簡單蝗蛙、輕量級的并發(fā)模型蝇庭。作為一種產(chǎn)品,我們的語言幾乎只推廣這個功能捡硅。另一方面哮内,有一種說法認(rèn)為并發(fā)使用起來實(shí)際上并不容易,否則作者不會把它作為他們書中的最后一章壮韭,我們也不會遺憾地來回顧其形成過程北发。
本節(jié)討論了 Go 語言的并發(fā)功能的“坑”。
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 {
}
}
該程序?qū)崿F(xiàn)了我們的預(yù)期琳拨,它提供簡單的 Web 服務(wù)。 然而逼蒙,它同時也做了其他事情从绘,它在無限循環(huán)中浪費(fèi) CPU 資源。 這是因?yàn)?main
的最后一行上的 for {}
將阻塞 main goroutine
是牢,因?yàn)樗粓?zhí)行任何 IO僵井、等待鎖定、發(fā)送或接收通道數(shù)據(jù)或以其他方式與調(diào)度器通信驳棱。
由于 Go 語言運(yùn)行時主要是協(xié)同調(diào)度批什,該程序?qū)⒃趩蝹€ CPU 上做無效地旋轉(zhuǎn),并可能最終實(shí)時鎖定社搅。
我們?nèi)绾谓鉀Q這個問題驻债? 這是一個建議乳规。
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()
}
}
這看起來很愚蠢,但這是我看過的一種常見解決方案合呐。 這是不了解潛在問題的癥狀暮的。
現(xiàn)在,如果你有更多的經(jīng)驗(yàn)淌实,你可能會寫這樣的東西冻辩。
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
語句將永遠(yuǎn)阻塞。 這是一個有用的屬性拆祈,因?yàn)楝F(xiàn)在我們不再調(diào)用 runtime.GoSched()
而耗費(fèi)整個 CPU恨闪。 但是這也只是治療了癥狀,而不是病根放坏。
我想向你提出另一種你可能在用的解決方案咙咽。 與其在 goroutine
中運(yùn)行 http.ListenAndServe
,會給我們留下處理 main goroutine
的問題淤年,不如在 main goroutine
本身上運(yùn)行 http.ListenAndServe
钧敞。
貼士:
如果 Go 語言程序的main.main
函數(shù)返回,無論程序在一段時間內(nèi)啟動的其他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
在得到另一個結(jié)果之前無法取得進(jìn)展犁享,那么讓自己完成此工作而不是委托給其他 goroutine
會更簡單。
這通常會消除將結(jié)果從 goroutine
返回到其啟動程序所需的大量狀態(tài)跟蹤和通道操作豹休。
貼士:
許多 Go 程序員過度使用goroutine
,特別是剛開始時桨吊。與生活中的所有事情一樣威根,適度是成功的關(guān)鍵。
8.2. 將并發(fā)性留給調(diào)用者
以下兩個 API 有什么區(qū)別视乐?
// 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
首先洛搀,最明顯的不同: 第一個示例將目錄讀入切片然后返回整個切片,如果出錯則返回錯誤佑淀。這是同步發(fā)生的留美,ListDirectory
的調(diào)用者會阻塞,直到讀取了所有目錄條目伸刃。根據(jù)目錄的大小谎砾,這可能需要很長時間,并且可能會分配大量內(nèi)存來構(gòu)建目錄條目捧颅。
讓我們看看第二個例子景图。 這個示例更像是 Go 語言風(fēng)格,ListDirectory
返回一個通道碉哑,通過該通道傳遞目錄條目挚币。當(dāng)通道關(guān)閉時亮蒋,表明沒有更多目錄條目。由于在 ListDirectory
返回后發(fā)生了通道的填充妆毕,ListDirectory
可能會啟動一個 goroutine
來填充通道慎玖。
注意:
第二個版本實(shí)際上不必使用 Go 協(xié)程; 它可以分配一個足以保存所有目錄條目而不阻塞的通道,填充通道笛粘,關(guān)閉它趁怔,然后將通道返回給調(diào)用者。但這樣做不太現(xiàn)實(shí)闰蛔,因?yàn)闀拇罅績?nèi)存來緩沖通道中的所有結(jié)果痕钢。
通道版本的 ListDirectory
還有兩個問題:
- 通過使用關(guān)閉通道作為沒有其他項(xiàng)目要處理的信號,在中途遇到了錯誤時,
ListDirectory
無法告訴調(diào)用者通過通道返回的項(xiàng)目集是否完整序六。調(diào)用者無法區(qū)分空目錄和讀取目錄的錯誤任连。兩者都導(dǎo)致從ListDirectory
返回的通道立即關(guān)閉。 - 調(diào)用者必須持續(xù)從通道中讀取例诀,直到它被關(guān)閉随抠,因?yàn)檫@是調(diào)用者知道此通道的是否停止的唯一方式。這是對
ListDirectory
使用的嚴(yán)重限制繁涂,即使可能已經(jīng)收到了它想要的答案拱她,調(diào)用者也必須花時間從通道中讀取。就中型到大型目錄的內(nèi)存使用而言扔罪,它可能更有效秉沼,但這種方法并不比原始的基于切片的方法快。
以上兩種實(shí)現(xiàn)所帶來的問題的解決方案是使用回調(diào)矿酵,該回調(diào)是在執(zhí)行時在每個目錄條目的上下文中調(diào)用函數(shù)唬复。
func ListDirectory(dir string, fn func(string))
毫不奇怪,這就是 filepath.WalkDir
函數(shù)的工作方式全肮。
貼士:
如果你的函數(shù)啟動了goroutine
敞咧,你必須為調(diào)用者提供一種明確停止goroutine
的方法。 把異步執(zhí)行函數(shù)的決定留給該函數(shù)的調(diào)用者通常會更容易些辜腺。
8.3. 永遠(yuǎn)不要啟動一個停止不了的 goroutine休建。
前面的例子顯示當(dāng)一個任務(wù)時沒有必要時使用 goroutine
。但使用 Go 語言的原因之一是該語言提供的并發(fā)功能评疗。實(shí)際上测砂,很多情況下你希望利用硬件中可用的并行性。為此壤巷,你必須使用 goroutines
邑彪。
這個簡單的應(yīng)用程序在兩個不同的端口上提供 http
服務(wù),端口 8080
用于應(yīng)用程序服務(wù)胧华,端口 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
}
雖然這個程序不是很復(fù)雜宙彪,但它代表了真實(shí)應(yīng)用程序的基礎(chǔ)。
該應(yīng)用程序存在一些問題有巧,因?yàn)樗S著應(yīng)用程序的增長而顯露出來释漆,所以我們現(xiàn)在來解決其中的一些問題。
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
處理程序分解成為它們自己的函數(shù)篮迎,我們將它們與 main.main
分離男图。 也遵循了上面的建議,并確保 serveApp
和 serveDebug
將它們的并發(fā)性留給調(diào)用者甜橱。
但是這個程序存在一些可操作性問題逊笆。 如果 serveApp
返回,那么 main.main
將返回岂傲,導(dǎo)致程序關(guān)閉并由你使用的進(jìn)程管理器來重新啟動难裆。
貼士:
正如 Go 語言中的函數(shù)將并發(fā)性留給調(diào)用者一樣,應(yīng)用程序應(yīng)該將監(jiān)視其狀態(tài)和檢測是否重啟的工作留給另外的程序來做镊掖。 不要讓你的應(yīng)用程序負(fù)責(zé)重新啟動自己乃戈,最好從應(yīng)用程序外部處理該過程。
然而亩进,serveDebug
是在一個單獨(dú)的 goroutine
中運(yùn)行的症虑,返回后該 goroutine
將退出,而程序的其余部分繼續(xù)归薛。 由于 /debug
處理程序已停止工作很久谍憔,因此操作人員不會很高興發(fā)現(xiàn)他們無法在你的應(yīng)用程序中獲取統(tǒng)計(jì)信息。
我們想要確保的是主籍,如果任何負(fù)責(zé)提供此應(yīng)用程序的 goroutine
停止韵卤,我們將關(guān)閉該應(yīng)用程序。
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 {}
}
現(xiàn)在 serverApp
和 serveDebug
檢查從 ListenAndServe
返回的錯誤崇猫,并在需要時調(diào)用 log.Fatal
。因?yàn)閮蓚€處理程序都在 goroutine
中運(yùn)行需忿,所以我們將 main goroutine
停在 select{}
中诅炉。
這種方法存在許多問題:
- 如果
ListenAndServer
返回nil
錯誤,則不會調(diào)用log.Fatal
屋厘,并且該端口上的 HTTP 服務(wù)將在不停止應(yīng)用程序的情況下關(guān)閉涕烧。 -
log.Fatal
調(diào)用os.Exit
,它將無條件地退出程序;defer
不會被調(diào)用汗洒,其他goroutines
也不會被通知關(guān)閉议纯,程序就停止了。 這使得編寫這些函數(shù)的測試變得困難溢谤。
貼士:
只在main.main
或init
函數(shù)中的使用log.Fatal
瞻凤。
我們真正想要的是任何錯誤發(fā)送回 goroutine
的調(diào)用者憨攒,以便它可以知道 goroutine
停止的原因,可以干凈地關(guān)閉程序進(jìn)程阀参。
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
的返回狀態(tài)肝集。通道的大小等于我們想要管理的 goroutine
的數(shù)量,這樣發(fā)送到 done
通道就不會阻塞蛛壳,因?yàn)檫@會阻止 goroutine
的關(guān)閉杏瞻,導(dǎo)致它泄漏。
由于沒有辦法安全地關(guān)閉 done
通道衙荐,我們不能使用 for range
來循環(huán)通道直到獲取所有 goroutine
發(fā)來的報(bào)告捞挥,而是循環(huán)我們開啟的多個 goroutine
,即通道的容量忧吟。
現(xiàn)在我們有辦法等待每個 goroutine
干凈地退出并記錄他們遇到的錯誤砌函。所需要的只是一種從第一個 goroutine
轉(zhuǎn)發(fā)關(guān)閉信號到其他 goroutine
的方法。
事實(shí)證明瀑罗,要求 http.Server
關(guān)閉是有點(diǎn)牽扯的胸嘴,所以我將這個邏輯轉(zhuǎn)給輔助函數(shù)。serve
助手使用一個地址和 http.Handler
斩祭,類似于 http.ListenAndServe
劣像,還有一個 stop
通道,我們用它來觸發(fā) 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)
}
}
}
現(xiàn)在耳奕,每次我們在 done
通道上收到一個值時,我們關(guān)閉 stop
通道诬像,這會導(dǎo)致在該通道上等待的所有 goroutine
關(guān)閉其 http.Server
屋群。 這反過來將導(dǎo)致其余所有的 ListenAndServe
goroutines
返回。 一旦我們開啟的所有 goroutine
都停止了坏挠,main.main
就會返回并且進(jìn)程會干凈地停止芍躏。
貼士:
自己編寫這種邏輯是重復(fù)而微妙的。 參考下這個包: 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
- 如有翻譯有誤或者不理解的地方,請?jiān)u論指正
- 待更新的譯注之后會做進(jìn)一步修改翻譯
- 翻譯:田浩
- 郵箱:llitfkitfk@gmail.com