[譯] Go 語言實(shí)戰(zhàn): 編寫可維護(hù) Go 語言代碼建議

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)原則:

  1. 簡單性
  2. 可讀性
  3. 生產(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ù)中被聲明。sumcount 也是如此岗钩,他們用了更長的名字纽窟。讀者必須查看更多的行數(shù)來定位它們,因此他們名字更為獨(dú)特兼吓。

我可以選擇 s 替代 sum 以及 c(或可能是 n)替代 count臂港,但是這樣做會將程序中的所有變量份量降低到同樣的級別。我可以選擇 p 來代替 people视搏,但是用什么來調(diào)用 for ... range 迭代變量审孽。如果用 person 的話看起來很奇怪,因?yàn)檠h(huán)迭代變量的生命時間很短浑娜,其名字的長度超出了它的值佑力。

貼士:
與使用段落分解文檔的方式一樣用空行來分解函數(shù)。 在 AverageAge 中棚愤,按順序共有三個操作搓萧。 第一個是前提條件杂数,檢查 people 是否為空宛畦,第二個是 sumcount 的累積,最后是平均值的計(jì)算揍移。

2.2.1. 上下文是關(guān)鍵

重要的是要意識到關(guān)于命名的大多數(shù)建議都是需要考慮上下文的次和。 我想說這是一個原則,而不是一個規(guī)則那伐。

兩個標(biāo)識符 iindex 之間有什么區(qū)別踏施。 我們不能斷定一個就比另一個好,例如

for index := 0; index < len(s); index++ {
    //
}

從根本上說罕邀,上面的代碼更具有可讀性

for i := 0; i < len(s); i++ {
    //
}

我認(rèn)為它不是畅形,因?yàn)榫痛耸露? iindex 的范圍很大可能上僅限于 for 循環(huán)的主體,后者的額外冗長性(指 index)幾乎沒有增加對于程序的理解诉探。

但是日熬,哪些功能更具可讀性?

func (s *SNMP) Fetch(oid []int, index int) (int, error)

func (s *SNMP) Fetch(o []int, i int) (int, error)

在此示例中肾胯,oidSNMP 對象 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庸毫,companiesMapproductsMap 三個 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 類型,就是這樣盟蚣。

在這種情況下黍析,如果變量的生命周期足夠短,請考慮使用 confc屎开。

如果有更多的 *Config阐枣,那么將它們稱為 originalupdatedconf1conf2 會更具描述性,因?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.DBdbase * sql.DB盆昙,DB * sql.DBdatabase * 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)驱富。 例如锚赤,ijk 通常是簡單 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)讀者遇到一個名為 ij 的變量時,他們知道循環(huán)就在附近伶选。

貼士:
如果你發(fā)現(xiàn)自己有如此多的嵌套循環(huán)史飞,ijk 變量都無法滿足時仰税,這個時候可能就是需要將函數(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)?0players 的零值趟咆。 因此添瓷,要明確地表示使用零值, 我們將上面例子改寫為:

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)該做的三件事中的一件:

  1. 注釋應(yīng)該解釋其作用咆爽。
  2. 注釋應(yīng)該解釋其如何做的。
  3. 注釋應(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)有兩個包需要用相同名稱瓤逼,它可能是:

  1. 包的名稱太通用了。
  2. 該包與另一個類似名稱的包重疊了库物。在這種情況下霸旗,您應(yīng)該檢查你的設(shè)計(jì),或考慮合并包戚揭。

4.2. 避免使用類似 base诱告,commonutil 的包名稱

不好的包名的常見情況是 utility 包。這些包通常是隨著時間的推移一些幫助程序和工具類的包民晒。由于這些包包含各種不相關(guān)的功能精居,因此很難根據(jù)包提供的內(nèi)容來描述它們。這通常會導(dǎo)致包的名稱來自包含的內(nèi)容 - utilities潜必。

utilshelper 這樣的包名稱通常出現(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) utilshelpers 包的名稱是分析它們的調(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ú)的包時,通常會找到名稱類似于 basecommon 的包孔祸。我相信解決方案是減少包的數(shù)量隆敢,將客戶端,服務(wù)器和公共代碼組合到一個以包的功能命名的包中崔慧。

例如拂蝎,net/http 包沒有 clientserver 的分包,而是有一個 client.goserver.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以舒,因此不需要為 trycatch 塊提供頂級結(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屎媳,slicesmapchannel 同樣是 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)的零值意味著 lencap 的值為 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)松散耦合

  1. 使用接口來描述函數(shù)或方法所需的行為。
  2. 避免使用全局狀態(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ù)芙委。

如果要減少全局變量所帶來的耦合,

  1. 將相關(guān)變量作為字段移動到需要它們的結(jié)構(gòu)上狂秦。
  2. 使用接口來減少行為與實(shí)現(xiàn)之間的耦合灌侣。

5. 項(xiàng)目結(jié)構(gòu)

我們來談?wù)勅绾螌M合到項(xiàng)目中。 通常一個項(xiàng)目是一個 git 倉庫裂问,但在未來 Go 語言開發(fā)人員會交替地使用 moduleproject侧啼。

就像一個包,每個項(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谈截、protectedprivate 以及隱式 default 的訪問修飾符。 沒有 C++friend 類概念簸喂。

在 Go 語言中毙死,我們只有兩個訪問修飾符,publicprivate喻鳄,由標(biāo)識符的第一個字母的大小寫表示规哲。 如果標(biāo)識符是公共的,則其名稱以大寫字母開頭诽表,該標(biāo)識符可用于任何其他 Go 語言包的引用唉锌。

注意:
你可能會聽到人們說 exportednot exported, 跟 publicprivate 是同義詞。

鑒于包的符號的訪問有限控件竿奏,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岗仑?
如果您來自 JavaC#匹耕,請考慮這一經(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 包含 RequestResponse 類型洒敏,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.goserver.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.mainmain.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)了如果 handlernil,使用 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)容中姜,所以在這里不再贅述消玄。

相反,我想介紹與錯誤處理相關(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.ReadFileioutil.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.WritererrWriter 寫入傳遞給其底層 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 錯誤杨凑,則 errorError() 方法會報(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)緊要的面孔

  1. 檢查它是否為 nil
  2. 輸出或記錄它绅你。

但是在某些情況下伺帘,我認(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 以及 selectgo 語句來支持并發(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()
}

通過將 serveAppserveDebug 處理程序分解成為它們自己的函數(shù)篮迎,我們將它們與 main.main 分離男图。 也遵循了上面的建議,并確保 serveAppserveDebug 將它們的并發(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)在 serverAppserveDebug 檢查從 ListenAndServe 返回的錯誤崇猫,并在需要時調(diào)用 log.Fatal。因?yàn)閮蓚€處理程序都在 goroutine 中運(yùn)行需忿,所以我們將 main goroutine 停在 select{} 中诅炉。

這種方法存在許多問題:

  1. 如果 ListenAndServer 返回 nil 錯誤,則不會調(diào)用 log.Fatal屋厘,并且該端口上的 HTTP 服務(wù)將在不停止應(yīng)用程序的情況下關(guān)閉涕烧。
  2. log.Fatal 調(diào)用 os.Exit,它將無條件地退出程序; defer 不會被調(diào)用汗洒,其他 goroutines 也不會被通知關(guān)閉议纯,程序就停止了。 這使得編寫這些函數(shù)的測試變得困難溢谤。

貼士:
只在 main.maininit 函數(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

10. https://blog.golang.org/errors-are-values

11. http://www.gopl.io/


原文鏈接:Practical Go: Real world advice for writing maintainable Go programs

  • 如有翻譯有誤或者不理解的地方,請?jiān)u論指正
  • 待更新的譯注之后會做進(jìn)一步修改翻譯
  • 翻譯:田浩
  • 郵箱:llitfkitfk@gmail.com
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末榜配,一起剝皮案震驚了整個濱河市否纬,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌蛋褥,老刑警劉巖临燃,帶你破解...
    沈念sama閱讀 206,839評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡膜廊,警方通過查閱死者的電腦和手機(jī)乏沸,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,543評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來溃论,“玉大人屎蜓,你說我怎么就攤上這事≡垦” “怎么了炬转?”我有些...
    開封第一講書人閱讀 153,116評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長算灸。 經(jīng)常有香客問我扼劈,道長,這世上最難降的妖魔是什么菲驴? 我笑而不...
    開封第一講書人閱讀 55,371評論 1 279
  • 正文 為了忘掉前任荐吵,我火速辦了婚禮,結(jié)果婚禮上赊瞬,老公的妹妹穿的比我還像新娘先煎。我一直安慰自己,他們只是感情好巧涧,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,384評論 5 374
  • 文/花漫 我一把揭開白布薯蝎。 她就那樣靜靜地躺著,像睡著了一般谤绳。 火紅的嫁衣襯著肌膚如雪占锯。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,111評論 1 285
  • 那天缩筛,我揣著相機(jī)與錄音消略,去河邊找鬼。 笑死瞎抛,一個胖子當(dāng)著我的面吹牛艺演,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播桐臊,決...
    沈念sama閱讀 38,416評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼钞艇,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了豪硅?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,053評論 0 259
  • 序言:老撾萬榮一對情侶失蹤挺物,失蹤者是張志新(化名)和其女友劉穎懒浮,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,558評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡砚著,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,007評論 2 325
  • 正文 我和宋清朗相戀三年次伶,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片稽穆。...
    茶點(diǎn)故事閱讀 38,117評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡瑞凑,死狀恐怖入蛆,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤劲阎,帶...
    沈念sama閱讀 33,756評論 4 324
  • 正文 年R本政府宣布,位于F島的核電站咽筋,受9級特大地震影響堕扶,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜否灾,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,324評論 3 307
  • 文/蒙蒙 一卖擅、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧墨技,春花似錦惩阶、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,315評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至私痹,卻和暖如春脐嫂,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背紊遵。 一陣腳步聲響...
    開封第一講書人閱讀 31,539評論 1 262
  • 我被黑心中介騙來泰國打工账千, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人暗膜。 一個月前我還...
    沈念sama閱讀 45,578評論 2 355
  • 正文 我出身青樓匀奏,卻偏偏與公主長得像,于是被迫代替她去往敵國和親学搜。 傳聞我的和親對象是個殘疾皇子娃善,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,877評論 2 345

推薦閱讀更多精彩內(nèi)容