Go 語言實戰: 編寫可維護 Go 語言代碼建議

介紹

你們好,我在接下來的兩個會議中的目標是向你們提供有關編寫 Go 代碼最佳實踐的建議。html

這是一個研討會形式的演講,不會有幻燈片,而是直接從文檔開始。git

貼士: 在這裏有最新的文章連接
https://dave.cheney.net/practical-go/presentations/qcon-china.html

編者的話

  • 終於翻譯完了 Dave 大神的這一篇《Go 語言最佳實踐
  • 耗時兩週的空閒時間
  • 翻譯的同時也對 Go 語言的開發與實踐有了更深層次的瞭解
  • 有興趣的同窗能夠翻閱 Dave 的另外一篇博文SOLID Go 語言設計(第六章節也會提到)
  • 同時在這裏也推薦一個 Telegram Docker 羣組(分享/交流): https://t.me/dockertutorial

正文

1. 指導原則

若是我要談論任何編程語言的最佳實踐,我須要一些方法來定義「什麼是最佳」。若是你昨天來到個人主題演講,你會看到 Go 團隊負責人 Russ Cox 的這句話:程序員

Software engineering is what happens to programming when you add time and other programmers. (軟件工程就是你和其餘程序員花費時間在編程上所發生的事情。)
— Russ Cox

Russ 做出了軟件編程與軟件工程的區分。 前者是你本身寫的一個程序。 後者是不少人會隨着時間的推移而開發的產品。 工程師們來來去去,團隊會隨着時間增加與縮小,需求會發生變化,功能會被添加,錯誤也會獲得修復。 這是軟件工程的本質。github

我多是這個房間裏 Go 最先的用戶之一,~但要爭辯說個人資歷給個人見解更可能是假的~。相反,今天我要提的建議是基於我認爲的 Go 語言自己的指導原則:golang

  1. 簡單性
  2. 可讀性
  3. 生產力
注意:
你會注意到我沒有說性能或併發。 有些語言比 Go 語言快一點,但它們確定不像 Go 語言那麼簡單。 有些語言使併發成爲他們的最高目標,但它們並不具備可讀性及生產力。
性能和併發是重要的屬性,但不如簡單性,可讀性和生產力那麼重要。

1.1. 簡單性

咱們爲何要追求簡單? 爲何 Go 語言程序的簡單性很重要?sql

咱們都曾遇到過這樣的狀況: 「我不懂這段代碼」,不是嗎? 咱們都作過這樣的項目:你懼怕作出改變,由於你擔憂它會破壞程序的另外一部分; 你不理解的部分,不知道如何修復。docker

這就是複雜性。 複雜性把可靠的軟件中變成不可靠。 複雜性是殺死軟件項目的罪魁禍首。數據庫

簡單性是 Go 語言的最高目標。 不管咱們編寫什麼程序,咱們都應該贊成這一點:它們很簡單。編程

1.2. 可讀性

Readability is essential for maintainability.
(可讀性對於可維護性是相當重要的。)
— Mark Reinhold (2018 JVM 語言高層會議)

爲何 Go 語言的代碼可讀性是很重要的?咱們爲何要爭取可讀性?json

Programs must be written for people to read, and only incidentally for machines to execute. (程序應該被寫來讓人們閱讀,只是順便爲了機器執行。)
— Hal Abelson 與 Gerald Sussman (計算機程序的結構與解釋)

可讀性很重要,由於全部軟件不只僅是 Go 語言程序,都是由人類編寫的,供他人閱讀。執行軟件的計算機則是次要的。

代碼的讀取次數比寫入次數多。一段代碼在其生命週期內會被讀取數百次,甚至數千次。

The most important skill for a programmer is the ability to effectively communicate ideas. (程序員最重要的技能是有效溝通想法的能力。)
— Gastón Jorquera [[1]](( https://gaston.life/books/eff...

可讀性是可以理解程序正在作什麼的關鍵。若是你沒法理解程序正在作什麼,那你但願如何維護它?若是軟件沒法維護,那麼它將被重寫;最後這多是你的公司最後一次投資 Go 語言。

~若是你正在爲本身編寫一個程序,也許它只須要運行一次,或者你是惟一一個曾經看過它的人,而後作任何對你有用的事。~可是,若是是一個不止一我的會貢獻編寫的軟件,或者在很長一段時間內需求、功能或者環境會改變,那麼你的目標必須是你的程序可被維護。

編寫可維護代碼的第一步是確保代碼可讀。

1.3. 生產力

Design is the art of arranging code to work today, and be changeable forever. (設計是安排代碼到工做的藝術,而且永遠可變。)
— Sandi Metz

我要強調的最後一個基本原則是生產力。開發人員的工做效率是一個龐大的主題,但歸結爲此; 你花多少時間作有用的工做,而不是等待你的工具或迷失在一個外國的代碼庫裏。 Go 程序員應該以爲他們能夠經過 Go 語言完成不少工做。

有人開玩笑說, Go 語言是在等待 C++ 語言程序編譯時設計的。快速編譯是 Go 語言的一個關鍵特性,也是吸引新開發人員的關鍵工具。雖然編譯速度仍然是一個持久的戰場,但能夠說,在其餘語言中須要幾分鐘的編譯,在 Go 語言中只需幾秒鐘。這有助於 Go 語言開發人員感覺到與使用動態語言的同行同樣的高效,並且沒有那些語言固有的可靠性問題。

對於開發人員生產力問題更爲基礎的是,Go 程序員意識到編寫代碼是爲了閱讀,所以將讀代碼的行爲置於編寫代碼的行爲之上。Go 語言甚至經過工具和自定義強制執行全部代碼以特定樣式格式化。這就消除了項目中學習特定格式的摩擦,並幫助發現錯誤,由於它們看起來不正確。

Go 程序員不會花費成天的時間來調試難以想象的編譯錯誤。他們也不會將浪費時間在複雜的構建腳本或在生產中部署代碼。最重要的是,他們不用花費時間來試圖瞭解他們的同事所寫的內容。

當他們說語言必須擴展時,Go 團隊會談論生產力。

2. 標識符

咱們要討論的第一個主題是標識符。 標識符是一個用來表示名稱的花哨單詞; 變量的名稱,函數的名稱,方法的名稱,類型的名稱,包的名稱等。

Poor naming is symptomatic of poor design. (命名不佳是設計不佳的症狀。)
— Dave Cheney

鑑於 Go 語言的語法有限,咱們爲程序選擇的名稱對咱們程序的可讀性產生了很是大的影響。 可讀性是良好代碼的定義質量,所以選擇好名稱對於 Go 代碼的可讀性相當重要。

2.1. 選擇標識符是爲了清晰,而不是簡潔

Obvious code is important. What you can do in one line you should do in three.
(清晰的代碼很重要。在一行能夠作的你應當分三行作。( if/else 嗎?))
— Ukiah Smith

Go 語言不是爲了單行而優化的語言。 Go 語言不是爲了最少行程序而優化的語言。咱們沒有優化源代碼的大小,也沒有優化輸入所需的時間。

Good naming is like a good joke. If you have to explain it, it’s not funny.
(好的命名就像一個可笑話。若是你必須解釋它,那就很差笑了。)
— Dave Cheney

清晰的關鍵是在 Go 語言程序中咱們選擇的標識名稱。讓咱們談一談所謂好的名字:

  • 好的名字很簡潔。 好的名字不必定是最短的名字,但好的名字不會浪費在無關的東西上。好名字具備高的信噪比。
  • 好的名字是描述性的。 好的名字會描述變量或常量的應用,而不是它們的內容。好的名字應該描述函數的結果或方法的行爲,而不是它們的操做。好的名字應該描述包的目的而非它的內容。描述東西越準確的名字就越好。
  • 好的名字應該是可預測的。 你可以從名字中推斷出使用方式。~這是選擇描述性名稱的功能,但它也遵循傳統。~這是 Go 程序員在談到習慣用語時所談論的內容。

讓咱們深刻討論如下這些屬性。

2.2. 標識符長度

有時候人們批評 Go 語言推薦短變量名的風格。正如 Rob Pike 所說,「 Go 程序員想要正確的長度的標識符」。 [[1]](https://www.lysator.liu.se/c/...

Andrew Gerrand 建議經過對某些事物使用更長的標識,向讀者代表它們具備更高的重要性。

The greater the distance between a name’s declaration and its uses, the longer the name should be. (名字的聲明與其使用之間的距離越大,名字應該越長。)
— Andrew Gerrand [[2]]( https://talks.golang.org/2014...

由此咱們能夠得出一些指導方針:

  • 短變量名稱在聲明和上次使用之間的距離很短時效果很好。
  • 長變量名稱須要證實本身的合理性; 名稱越長,須要提供的價值越高。冗長的名稱與頁面上的重量相比,信號量較小。
  • 請勿在變量名稱中包含類型名稱。
  • 常量應該描述它們持有的值,而不是該如何使用。
  • 對於循環和分支使用單字母變量,參數和返回值使用單個字,函數和包級別聲明使用多個單詞
  • 方法、接口和包使用單個詞。
  • 請記住,包的名稱是調用者用來引用名稱的一部分,所以要好好利用這一點。

咱們來舉個栗子:

type Person struct {
    Name string
    Age  int
}

// AverageAge returns the average age of people.
func AverageAge(people []Person) int {
    if len(people) == 0 {
        return 0
    }

    var count, sum int
    for _, p := range people {
        sum += p.Age
        count += 1
    }

    return sum / count
}

在此示例中,變量 p 的在第 10 行被聲明而且也只在接下來的一行中被引用。 p 在執行函數期間存在時間很短。若是要了解 p 的做用只需閱讀兩行代碼。

相比之下,people 在函數第 7 行參數中被聲明。sumcount 也是如此,他們用了更長的名字。讀者必須查看更多的行數來定位它們,所以他們名字更爲獨特。

我能夠選擇 s 替代 sum 以及 c(或多是 n)替代 count,可是這樣作會將程序中的全部變量分量下降到一樣的級別。我能夠選擇 p 來代替 people,可是用什麼來調用 for ... range 迭代變量。若是用 person 的話看起來很奇怪,由於循環迭代變量的生命時間很短,其名字的長度超出了它的值。

貼士:
與使用段落分解文檔的方式同樣用空行來分解函數。 在 AverageAge 中,按順序共有三個操做。 第一個是前提條件,檢查 people 是否爲空,第二個是 sumcount 的累積,最後是平均值的計算。

2.2.1. 上下文是關鍵

重要的是要意識到關於命名的大多數建議都是須要考慮上下文的。 我想說這是一個原則,而不是一個規則。

兩個標識符 iindex 之間有什麼區別。 咱們不能判定一個就比另外一個好,例如

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

從根本上說,上面的代碼更具備可讀性

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

我認爲它不是,由於就此事而論, iindex 的範圍很大可能上僅限於 for 循環的主體,後者的額外冗長性(指 index)幾乎沒有增長對於程序的理解。

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

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

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

在此示例中,oidSNMP 對象 ID 的縮寫,所以將其縮短爲 o 意味着程序員必需要將文檔中經常使用符號轉換爲代碼中較短的符號。 相似地將 index 替換成 i,模糊了 i 所表明的含義,由於在 SNMP 消息中,每一個 OID 的子值稱爲索引。

貼士: 在同一聲明中長和短形式的參數不能混搭。

2.3. 不要用變量類型命名你的變量

你不該該用變量的類型來命名你的變量, 就像您不會將寵物命名爲「狗」和「貓」。 出於一樣的緣由,您也不該在變量名字中包含類型的名字。

變量的名稱應描述其內容,而不是內容的類型。 例如:

var usersMap map[string]*User

這個聲明有什麼好處? 咱們能夠看到它是一個 map,它與 *User 類型有關。 可是 usersMap 是一個 map,而 Go 語言是一種靜態類型的語言,若是沒有定義變量,不會讓咱們意外地使用到它,所以 Map 後綴是多餘的。

接下來, 若是咱們像這樣來聲明其餘變量:

var (
    companiesMap map[string]*Company
    productsMap map[string]*Products
)

usersMapcompaniesMapproductsMap 三個 map 類型變量,全部映射字符串都是不一樣的類型。 咱們知道它們是 map,咱們也知道咱們不能使用其中一個來代替另外一個 - 若是咱們在須要 map[string]*User 的地方嘗試使用 companiesMap, 編譯器將拋出錯誤異常。 在這種狀況下,很明顯變量中 Map 後綴並無提升代碼的清晰度,它只是增長了要輸入的額外樣板代碼。

個人建議是避免使用任何相似變量類型的後綴。

貼士:
若是 users 的描述性都不夠用,那麼 usersMap 也不會。

此建議也適用於函數參數。 例如:

type Config struct {
    //
}

func WriteConfig(w io.Writer, config *Config)

命名 *Config 參數 config 是多餘的。 咱們知道它是 *Config 類型,就是這樣。

在這種狀況下,若是變量的生命週期足夠短,請考慮使用 confc

若是有更多的 *Config,那麼將它們稱爲 originalupdatedconf1conf2 會更具描述性,由於前者不太可能被互相誤解。

貼士:
不要讓包名竊取好的變量名。
導入標識符的名稱包括其包名稱。 例如, context 包中的 Context 類型將被稱爲 context.Context。 這使得沒法將 context 用做包中的變量或類型。
func WriteLog(context context.Context, message string)
上面的栗子將會編譯出錯。 這就是爲何 context.Context 類型的一般的本地聲明是 ctx,例如:
func WriteLog(ctx context.Context, message string)

2.4. 使用一致的命名方式

一個好名字的另外一個屬性是它應該是可預測的。 在第一次遇到該名字時讀者就可以理解名字的使用。 當他們遇到常見的名字時,他們應該可以認爲自從他們上次看到它以來它沒有改變意義。

例如,若是您的代碼在處理數據庫請確保每次出現參數時,它都具備相同的名稱。 與其使用 d * sql.DBdbase * sql.DBDB * sql.DBdatabase * sql.DB 的組合,倒不如統一使用:

db *sql.DB

這樣作使讀者更爲熟悉; 若是你看到db,你知道它就是 *sql.DB 而且它已經在本地聲明或者由調用者爲你提供。

相似地,對於方法接收器: 在該類型的每一個方法上使用相同的接收者名稱。 在這種類型的方法內部能夠使讀者更容易使用。

注意:
Go 語言中的短接收者名稱慣例與目前提供的建議不一致。 這只是早期作出的選擇之一,已經成爲首選的風格,就像使用 CamelCase 而不是 snake_case 同樣。

貼士:
Go 語言樣式規定接收器具備單個字母名稱或從其類型派生的首字母縮略詞。 你可能會發現接收器的名稱有時會與方法中參數的名稱衝突。 在這種狀況下,請考慮將參數名稱命名稍長,而且不要忘記一致地使用此新參數名稱。

最後,某些單字母變量傳統上與循環和計數相關聯。 例如,ijk 一般是簡單 for 循環的循環概括變量。n 一般與計數器或累加器相關聯。v 是通用編碼函數中值的經常使用簡寫,k 一般用於 map 的鍵,s 一般用做字符串類型參數的簡寫。

與上面的 db 示例同樣,程序員認爲 i 是一個循環概括變量。 若是確保 i 始終是循環變量,並且不在 for 循環以外的其餘地方中使用。 當讀者遇到一個名爲 ij 的變量時,他們知道循環就在附近。

貼士:
若是你發現本身有如此多的嵌套循環, ijk 變量都沒法知足時,這個時候可能就是須要將函數分解成更小的函數。

2.5. 使用一致的聲明樣式

Go 至少有六種不一樣的方式來聲明變量

  • var x int = 1
  • var x = 1
  • var x int; x = 1
  • var x = int(1)
  • x := 1

我確信還有更多我沒有想到的。 這多是 Go 語言的設計師意識到的一個錯誤,但如今改變它爲時已晚。 經過全部這些不一樣的方式來聲明變量,咱們如何避免每一個 Go 程序員選擇本身的風格?

我想就如何在程序中聲明變量提出建議。 這是我儘量使用的風格。

  • 聲明變量但沒有初始化時,請使用 var 當聲明變量稍後將在函數中初始化時,請使用 var 關鍵字。
var players int    // 0

var things []Thing // an empty slice of Things

var thing Thing    // empty Thing struct
json.Unmarshall(reader, &thing)

var 表示此變量已被聲明爲指定類型的零值。 這也與使用 var 而不是短聲明語法在包級別聲明變量的要求一致 - 儘管我稍後會說你根本不該該使用包級變量。

  • 在聲明和初始化時,使用 := 在同時聲明和初始化變量時,也就是說咱們不會將變量初始化爲零值,我建議使用短變量聲明。 這使得讀者清楚地知道 := 左側的變量是初始化過的。

爲了解釋緣由,讓咱們看看前面的例子,但此次是初始化每一個變量:

var players int = 0

var things []Thing = nil

var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)

在第一個和第三個例子中,由於在 Go 語言中沒有從一種類型到另外一種類型的自動轉換; 賦值運算符左側的類型必須與右側的類型相同。 編譯器能夠從右側的類型推斷出聲明的變量的類型,上面的例子能夠更簡潔地寫爲:

var players = 0

var things []Thing = nil

var thing = new(Thing)
json.Unmarshall(reader, thing)

咱們將 players 初始化爲 0,但這是多餘的,由於 0players 的零值。 所以,要明確地表示使用零值, 咱們將上面例子改寫爲:

var players int

第二個聲明如何? 咱們不能省略類型而寫做:

var things = nil

由於 nil 沒有類型。 [[2]](https://speakerdeck.com/campo...,咱們有一個選擇,若是咱們要使用切片的零值則寫做:

var things []Thing

或者咱們要建立一個有零元素的切片則寫做:

var things = make([]Thing, 0)

若是咱們想要後者那麼這不是切片的零值,因此咱們應該向讀者說明咱們經過使用簡短的聲明形式作出這個選擇:

things := make([]Thing, 0)

這告訴讀者咱們已選擇明確初始化事物。

下面是第三個聲明,

var thing = new(Thing)

既是初始化了變量又引入了一些 Go 程序員不喜歡的 new 關鍵字的罕見用法。 若是咱們用推薦地簡短聲明語法,那麼就變成了:

thing := new(Thing)

這清楚地代表 thing 被初始化爲 new(Thing) 的結果 - 一個指向 Thing 的指針 - 但依舊咱們使用了 new 地罕見用法。 咱們能夠經過使用緊湊的文字結構初始化形式來解決這個問題,

thing := &Thing{}

new(Thing) 相同,這就是爲何一些 Go 程序員對重複感到不滿。 然而,這意味着咱們使用指向 Thing{} 的指針初始化了 thing,也就是 Thing 的零值。

相反,咱們應該認識到 thing 被聲明爲零值,並使用地址運算符將 thing 的地址傳遞給 json.Unmarshall

var thing Thing
json.Unmarshall(reader, &thing)
貼士:
固然,任何經驗法則,都有例外。 例如,有時兩個變量密切相關,這樣寫會很奇怪:
var min int
max := 1000
若是這樣聲明可能更具可讀性
min, max := 0, 1000

綜上所述:

在沒有初始化的狀況下聲明變量時,請使用 var 語法。

聲明並初始化變量時,請使用 :=

貼士:
使複雜的聲明顯而易見。
當事情變得複雜時,它看起來就會很複雜。例如
var length uint32 = 0x80
這裏 length 可能要與特定數字類型的庫一塊兒使用,而且 length 明確選擇爲 uint32 類型而不是短聲明形式:
length := uint32(0x80)
在第一個例子中,我故意違反了規則, 使用 var 聲明帶有初始化變量的。 這個決定與個人經常使用的形式不一樣,這給讀者一個線索,告訴他們一些不尋常的事情將會發生。

2.6. 成爲團隊合做者

我談到了軟件工程的目標,即編寫可讀及可維護的代碼。 所以,您可能會將大部分職業生涯用於你不是惟一做者的項目。 我在這種狀況下的建議是遵循項目自身風格。

在文件中間更改樣式是不和諧的。 即便不是你喜歡的方式,對於維護而言一致性比你的我的偏好更有價值。 個人經驗法則是: 若是它經過了 gofmt,那麼一般不值得再作代碼審查。

貼士:
若是要在代碼庫中進行重命名,請不要將其混合到另外一個更改中。 若是有人使用 git bisect,他們不想經過數千行重命名來查找您更改的代碼。

3. 註釋

在咱們繼續討論更大的項目以前,我想花幾分鐘時間談論一下注釋。

Good code has lots of comments, bad code requires lots of comments.
(好的代碼有不少註釋,壞代碼須要不少註釋。)
— Dave Thomas and Andrew Hunt (The Pragmatic Programmer)

註釋對 Go 語言程序的可讀性很是重要。 註釋應該作的三件事中的一件:

  1. 註釋應該解釋其做用。
  2. 註釋應該解釋其如何作的。
  3. 註釋應該解釋其緣由。

第一種形式是公共符號註釋的理想選擇:

// Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.

第二種形式很是適合在方法中註釋:

// queue all dependant actions
var results []chan error
for _, dep := range a.Deps {
        results = append(results, execute(seen, dep))
}

第三種形式是獨一無二的,由於它不會取代前兩種形式,但與此同時它並不能代替前兩種形式。 此形式的註解用以解釋代碼的外部因素。 這些因素脫離上下文後一般很難理解,此註釋的爲了提供這種上下文。

return &v2.Cluster_CommonLbConfig{
    // Disable HealthyPanicThreshold
        HealthyPanicThreshold: &envoy_type.Percent{
            Value: 0,
        },
}

在此示例中,沒法清楚地明白 HealthyPanicThreshold 設置爲零百分比的效果。 須要註釋 0 值將禁用 panic 閥值。

3.1. 關於變量和常量的註釋應描述其內容而非其目的

我以前談過,變量或常量的名稱應描述其目的。 向變量或常量添加註釋時,該註釋應描述變量內容,而不是變量目的。

const randomNumber = 6 // determined from an unbiased die

在此示例中,註釋描述了爲何 randomNumber 被賦值爲6,以及6來自哪裏。 註釋沒有描述 randomNumber 的使用位置。 還有更多的栗子:

const (
    StatusContinue           = 100 // RFC 7231, 6.2.1
    StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2
    StatusProcessing         = 102 // RFC 2518, 10.1

    StatusOK                 = 200 // RFC 7231, 6.3.1

在HTTP的上下文中,數字 100 被稱爲 StatusContinue,如 RFC 7231 第 6.2.1 節中所定義。

貼士:
對於沒有初始值的變量,註釋應描述誰負責初始化此變量。
// sizeCalculationDisabled indicates whether it is safe
// to calculate Types' widths and alignments. See dowidth.
var sizeCalculationDisabled bool
這裏的註釋讓讀者知道 dowidth 函數負責維護 sizeCalculationDisabled 的狀態。

隱藏在衆目睽睽下
這個提示來自Kate Gregory[[3]](https://www.infoq.com/article...。有時你會發現一個更好的變量名稱隱藏在註釋中。

// registry of SQL drivers
var registry = make(map[string]*sql.Driver)
註釋是由做者添加的,由於 registry 沒有充分解釋其目的 - 它是一個註冊表,但註冊的是什麼?

經過將變量重命名爲 sqlDrivers,如今能夠清楚地知道此變量的目的是保存SQL驅動程序。

var sqlDrivers = make(map[string]*sql.Driver)
以前的註釋就是多餘的,能夠刪除。

3.2. 公共符號始終要註釋

godoc 是包的文檔,因此應該始終爲包中聲明的每一個公共符號 —​ 變量、常量、函數以及方法添加註釋。

如下是 Google Style 指南中的兩條規則:

  • 任何既不明顯也不簡短的公共功能必須予以註釋。
  • 不管長度或複雜程度如何,對庫中的任何函數都必須進行註釋
package ioutil

// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r io.Reader) ([]byte, error)

這條規則有一個例外; 您不須要註釋實現接口的方法。 具體不要像下面這樣作:

// Read implements the io.Reader interface
func (r *FileReader) Read(buf []byte) (int, error)

這個註釋什麼也沒說。 它沒有告訴你這個方法作了什麼,更糟糕是它告訴你去看其餘地方的文檔。 在這種狀況下,我建議徹底刪除該註釋。

這是 io 包中的一個例子

// LimitReader returns a Reader that reads from r
// but stops with EOF after n bytes.
// The underlying implementation is a *LimitedReader.
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }

// A LimitedReader reads from R but limits the amount of
// data returned to just N bytes. Each call to Read
// updates N to reflect the new amount remaining.
// Read returns EOF when N <= 0 or when the underlying R returns EOF.
type LimitedReader struct {
    R Reader // underlying reader
    N int64  // max bytes remaining
}

func (l *LimitedReader) Read(p []byte) (n int, err error) {
    if l.N <= 0 {
        return 0, EOF
    }
    if int64(len(p)) > l.N {
        p = p[0:l.N]
    }
    n, err = l.R.Read(p)
    l.N -= int64(n)
    return
}

請注意,LimitedReader 的聲明就在使用它的函數以前,而 LimitedReader.Read 的聲明遵循 LimitedReader 自己的聲明。 儘管 LimitedReader.Read 自己沒有文檔,但它清楚地代表它是 io.Reader 的一個實現。

貼士:
在編寫函數以前,請編寫描述函數的註釋。 若是你發現很難寫出註釋,那麼這就代表你將要編寫的代碼很難理解。

3.2.1. 不要註釋很差的代碼,將它重寫

Don’t comment bad code — rewrite it
— Brian Kernighan

粗劣的代碼的註釋高亮顯示是不夠的。 若是你遇到其中一條註釋,則應提出問題,以提醒您稍後重構。 只要技術債務數額已知,它是能夠忍受的。

標準庫中的慣例是注意到它的人用 TODO(username) 的樣式來註釋。

// TODO(dfc) this is O(N^2), find a faster way to do this.

註釋 username 不是該人承諾要解決該問題,但在解決問題時他們多是最好的人選。 其餘項目使用 TODO 與日期或問題編號來註釋。

3.2.2. 與其註釋一段代碼,不如重構它

Good code is its own best documentation. As you’re about to add a comment, ask yourself, 'How can I improve the code so that this comment isn’t needed?' Improve the code and then document it to make it even clearer.
好的代碼是最好的文檔。 在即將添加註釋時,請問下本身,「如何改進代碼以便不須要此註釋?' 改進代碼使其更清晰。
— Steve McConnell

函數應該只作一件事。 若是你發現本身在註釋一段與函數的其他部分無關的代碼,請考慮將其提取到它本身的函數中。

除了更容易理解以外,較小的函數更易於隔離測試,將代碼隔離到函數中,其名稱多是所需的全部文檔。

4. 包的設計

Write shy code - modules that don’t reveal anything unnecessary to other modules and that don’t rely on other modules' implementations.
編寫謹慎的代碼 - 不向其餘模塊透露任何沒必要要的模塊,而且不依賴於其餘模塊的實現。
— Dave Thomas

每一個 Go 語言的包實際上都是它一個小小的 Go 語言程序。 正如函數或方法的實現對調用者而言並不重要同樣,包的公共API-其函數、方法以及類型的實現對於調用者來講也並不重要。

一個好的 Go 語言包應該具備低程度的源碼級耦合,這樣,隨着項目的增加,對一個包的更改不會跨代碼庫級聯。 這些世界末日的重構嚴格限制了代碼庫的變化率以及在該代碼庫中工做的成員的生產率。

在本節中,咱們將討論如何設計包,包括包的名稱,命名類型以及編寫方法和函數的技巧。

4.1. 一個好的包從它的名字開始

編寫一個好的 Go 語言包從包的名稱開始。將你的包名用一個詞來描述它。

正如我在上一節中談到變量的名稱同樣,包的名稱也很是重要。我遵循的經驗法則不是「我應該在這個包中放入什麼類型的?」。相反,我要問是「該包提供的服務是什麼?」一般這個問題的答案不是「這個包提供 X 類型」,而是「這個包提供 HTTP」。

貼士:
以包所提供的內容來命名,而不是它包含的內容。

4.1.1. 好的包名應該是惟一的。

在項目中,每一個包名稱應該是惟一的。包的名稱應該描述其目的的建議很容易理解 - 若是你發現有兩個包須要用相同名稱,它多是:

  1. 包的名稱太通用了。
  2. 該包與另外一個相似名稱的包重疊了。在這種狀況下,您應該檢查你的設計,或考慮合併包。

4.2. 避免使用相似 basecommonutil 的包名稱

很差的包名的常見狀況是 utility 包。這些包一般是隨着時間的推移一些幫助程序和工具類的包。因爲這些包包含各類不相關的功能,所以很難根據包提供的內容來描述它們。這一般會致使包的名稱來自包含的內容 - utilities

utilshelper 這樣的包名稱一般出如今較大的項目中,這些項目已經開發了深層次包的結構,而且但願在不遇到導入循環的狀況下共享 helper 函數。經過將 utility 程序函數提取到新的包中,導入循環會被破壞,但因爲該包源於項目中的設計問題,所以其包名稱不反映其目的,僅反映其爲了打破導入循環。

我建議改進 utilshelpers 包的名稱是分析它們的調用位置,若是可能的話,將相關的函數移動到調用者的包中。即便這涉及複製一些 helper 程序代碼,這也比在兩個程序包之間引入導入依賴項更好。

[A little] duplication is far cheaper than the wrong abstraction.
([一點點]重複比錯誤的抽象的性價比高不少。)
— Sandy Metz

在使用 utility 程序的狀況下,最好選多個包,每一個包專一於單個方面,而不是選單一的總體包。

貼士:
使用複數形式命名 utility 包。例如 strings 來處理字符串。

當兩個或多個實現共有的功能或客戶端和服務器的常見類型被重構爲單獨的包時,一般會找到名稱相似於 basecommon 的包。我相信解決方案是減小包的數量,將客戶端,服務器和公共代碼組合到一個以包的功能命名的包中。

例如,net/http 包沒有 clientserver 的分包,而是有一個 client.goserver.go 文件,每一個文件都有各自的類型,還有一個 transport.go 文件,用於公共消息傳輸代碼。

貼士:
標識符的名稱包括其包名稱。
重要的是標識符的名稱包括其包的名稱。

  • 當由另外一個包引用時,net/http 包中的 Get 函數變爲 http.Get
  • 當導入到其餘包中時,strings 包中的 Reader 類型變爲 strings.Reader
  • net 包中的 Error 接口顯然與網絡錯誤有關。

4.3. 儘早 return 而不是深度嵌套

因爲 Go 語言的控制流不使用 exception,所以不須要爲 trycatch 塊提供頂級結構而深度縮進代碼。Go 語言代碼不是成功的路徑愈來愈深地嵌套到右邊,而是以一種風格編寫,其中隨着函數的進行,成功路徑繼續沿着屏幕向下移動。 個人朋友 Mat Ryer 將這種作法稱爲「視線」編碼。[[4]](https://medium.com/@matryer/l...

這是經過使用 guard clauses 來實現的; 在進入函數時是具備斷言前提條件的條件塊。 這是一個來自 bytes 包的例子:

func (b *Buffer) UnreadRune() error {
    if b.lastRead <= opInvalid {
        return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
    }
    if b.off >= int(b.lastRead) {
        b.off -= int(b.lastRead)
    }
    b.lastRead = opInvalid
    return nil
}

進入 UnreadRune 後,將檢查 b.lastRead 的狀態,若是以前的操做不是 ReadRune,則會當即返回錯誤。 以後,函數的其他部分繼續進行 b.lastRead 大於 opInvalid 的斷言。

與沒有 guard clause 的相同函數進行比較,

func (b *Buffer) UnreadRune() error {
    if b.lastRead > opInvalid {
        if b.off >= int(b.lastRead) {
            b.off -= int(b.lastRead)
        }
        b.lastRead = opInvalid
        return nil
    }
    return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune")
}

最多見的執行成功的狀況是嵌套在第一個if條件內,成功的退出條件是 return nil,並且必須經過仔細匹配大括號來發現。 函數的最後一行是返回一個錯誤,而且被調用者必須追溯到匹配的左括號,以瞭解什麼時候執行到此點。

對於讀者和維護程序員來講,這更容易出錯,所以 Go 語言更喜歡使用 guard clauses 並儘早返回錯誤。

4.4. 讓零值更有用

假設變量沒有初始化,每一個變量聲明都會自動初始化爲與零內存的內容相匹配的值。 這就是零值。 值的類型決定了其零值; 對於數字類型,它爲 0,對於指針類型爲 nilslicesmapchannel 一樣是 nil

始終設置變量爲已知默認值的屬性對於程序的安全性和正確性很是重要,而且能夠使 Go 語言程序更簡單、更緊湊。 這就是 Go 程序員所說的「給你的結構一個有用的零值」。

對於 sync.Mutex 類型。sync.Mutex 包含兩個未公開的整數字段,它們用來表示互斥鎖的內部狀態。 每當聲明 sync.Mutex 時,其字段會被設置爲 0 初始值。sync.Mutex 利用此屬性來編寫,使該類型可直接使用而無需初始化。

type MyInt struct {
    mu  sync.Mutex
    val int
}

func main() {
    var i MyInt

    // i.mu is usable without explicit initialisation.
    i.mu.Lock()
    i.val++
    i.mu.Unlock()
}

另外一個利用零值的類型是 bytes.Buffer。您能夠聲明 bytes.Buffer 而後就直接寫入而無需初始化。

func main() {
    var b bytes.Buffer
    b.WriteString("Hello, world!\n")
    io.Copy(os.Stdout, &b)
}

切片的一個有用屬性是它們的零值 nil。若是咱們看一下切片運行時 header 的定義就不難理解:

type slice struct {
        array *[...]T // pointer to the underlying array
        len   int
        cap   int
}

此結構的零值意味着 lencap 的值爲 0,而 array(指向保存切片的內容數組的指針)將爲 nil。這意味着你不須要 make 切片,你只需聲明它便可。

func main() {
    // s := make([]string, 0)
    // s := []string{}
    var s []string

    s = append(s, "Hello")
    s = append(s, "world")
    fmt.Println(strings.Join(s, " "))
}
注意:
var s []string 相似於它上面的兩條註釋行,但並不徹底相同。值爲 nil 的切片與具備零長度的切片就能夠來相互比較。如下代碼將輸出 false
func main() {
    var s1 = []string{}
    var s2 []string
    fmt.Println(reflect.DeepEqual(s1, s2))
}

nil pointers -- 未初始化的指針變量的一個有用屬性是你能夠在具備 nil 值的類型上調用方法。它能夠簡單地用於提供默認值。

type Config struct {
    path string
}

func (c *Config) Path() string {
    if c == nil {
        return "/usr/home"
    }
    return c.path
}

func main() {
    var c1 *Config
    var c2 = &Config{
        path: "/export",
    }
    fmt.Println(c1.Path(), c2.Path())
}

4.5. 避免包級別狀態

編寫可維護程序的關鍵是它們應該是鬆散耦合的 - 對一個程序包的更改應該不多影響另外一個不直接依賴於第一個程序包的程序包。

在 Go 語言中有兩種很好的方法能夠實現鬆散耦合

  1. 使用接口來描述函數或方法所需的行爲。
  2. 避免使用全局狀態。

在 Go 語言中,咱們能夠在函數或方法範圍以及包範圍內聲明變量。當變量是公共的時,給定一個以大寫字母開頭的標識符,那麼它的範圍對於整個程序來講其實是全局的 - 任何包均可以隨時觀察該變量的類型和內容。

可變全局狀態引入程序的獨立部分之間的緊密耦合,由於全局變量成爲程序中每一個函數的不可見參數!若是該變量的類型發生更改,則能夠破壞依賴於全局變量的任何函數。若是程序的另外一部分更改了該變量,則能夠破壞依賴於全局變量狀態的任何函數。

若是要減小全局變量所帶來的耦合,

  1. 將相關變量做爲字段移動到須要它們的結構上。
  2. 使用接口來減小行爲與實現之間的耦合。

5. 項目結構

咱們來談談如何將包組合到項目中。 一般一個項目是一個 git 倉庫,但在將來 Go 語言開發人員會交替地使用 moduleproject

就像一個包,每一個項目都應該有一個明確的目的。 若是你的項目是一個庫,它應該提供一件事,好比 XML 解析或記錄。 您應該避免在一個包實現多個目的,這將有助於避免成爲 common 庫。

貼士:
據個人經驗, common 庫最終會與其最大的調用者緊密相連,在沒有升級該庫與最大調用者的狀況下是很難修復的,還會帶來了許多無關的更改以及API破壞。

若是你的項目是應用程序,如 Web 應用程序,Kubernetes 控制器等,那麼項目中可能有一個或多個 main 程序包。 例如,我編寫的 Kubernetes 控制器有一個 cmd/contour 包,既能夠做爲部署到 Kubernetes 集羣的服務器,也能夠做爲調試目的的客戶端。

5.1. 考慮更少,更大的包

對於從其餘語言過渡到 Go 語言的程序員來講,我傾向於在代碼審查中提到的一件事是他們會過分使用包。

Go 語言沒有提供有關可見性的詳細方法; Java有 publicprotectedprivate 以及隱式 default 的訪問修飾符。 沒有 C++friend 類概念。

在 Go 語言中,咱們只有兩個訪問修飾符,publicprivate,由標識符的第一個字母的大小寫表示。 若是標識符是公共的,則其名稱以大寫字母開頭,該標識符可用於任何其餘 Go 語言包的引用。

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

鑑於包的符號的訪問有限控件,Go 程序員應遵循哪些實踐來避免建立過於複雜的包層次結構?

貼士:
cmd/internal/ 以外的每一個包都應包含一些源代碼。

個人建議是選擇更少,更大的包。 你應該作的是不建立新的程序包。 這將致使太多類型被公開,爲你的包建立一個寬而淺的API。

如下部分將更爲詳細地探討這一建議。

貼士:
來自 Java
若是您來自 JavaC#,請考慮這一經驗法則 -- Java 包至關於單個 .go 源文件。 - Go 語言包至關於整個 Maven 模塊或 .NET 程序集。

5.1.1. 經過 import 語句將代碼排列到文件中

若是你按照包提供的內容來安排你的程序包,是否須要對 Go 包中的文件也執行相同的操做?何時應該將 .go 文件拆分紅多個文件?何時應該考慮整合 .go 文件?

如下是個人經驗法則:

  • 開始時使用一個 .go 文件。爲該文件指定與文件夾名稱相同的名稱。例如: package http 應放在名爲 http 的目錄中名爲 http.go 的文件中。
  • 隨着包的增加,您可能決定將各類職責任務拆分爲不一樣的文件。例如:messages.go 包含 RequestResponse 類型,client.go 包含 Client 類型,server.go包含 Server 類型。
  • 若是你的文件中 import 的聲明相似,請考慮將它們組合起來。或者肯定 import 集之間的差別並移動它們。
  • 不一樣的文件應該負責包的不一樣區域。messages.go 可能負責網絡的 HTTP 請求和響應,http.go 可能包含底層網絡處理邏輯,client.goserver.go 實現 HTTP 業務邏輯請求的實現或路由等等。
貼士: 首選名詞爲源文件命名。

注意:
Go編譯器並行編譯每一個包。 在一個包中,編譯器並行編譯每一個函數(方法只是 Go 語言中函數的另外一種寫法)。 更改包中代碼的佈局不會影響編譯時間。

5.1.2. 優先內部測試再到外部測試

go tool 支持在兩個地方編寫 testing 包測試。假設你的包名爲 http2,您能夠編寫 http2_test.go 文件並使用包 http2 聲明。這樣作會編譯 http2_test.go 中的代碼,就像它是 http2 包的一部分同樣。這就是內部測試。

go tool 還支持一個特殊的包聲明,以 test 爲結尾,即 package http_test。這容許你的測試文件與代碼一塊兒存放在同一個包中,可是當編譯時這些測試不是包的代碼的一部分,它們存在於本身的包中。就像調用另外一個包的代碼同樣來編寫測試。這被稱爲外部測試。

我建議在編寫單元測試時使用內部測試。這樣你就能夠直接測試每一個函數或方法,避免外部測試干擾。

可是,你應該將 Example 測試函數放在外部測試文件中。這確保了在 godoc 中查看時,示例具備適當的包名前綴而且能夠輕鬆地進行復制粘貼。

貼士:
避免複雜的包層次結構,抵制應用分類法
Go 語言包的層次結構對於 go tool 沒有任何意義除了下一節要說的。 例如, net/http 包不是一個子包或者 net 包的子包。

若是在項目中建立了不包含 .go 文件的中間目錄,則可能沒法遵循此建議。

5.1.3. 使用 internal 包來減小公共API

若是項目包含多個包,可能有一些公共的函數,這些函數旨在供項目中的其餘包使用,但不打算成爲項目的公共API的一部分。 若是你發現是這種狀況,那麼 go tool 會識別一個特殊的文件夾名稱 - 而非包名稱 - internal/ 可用於放置對項目公開的代碼,但對其餘項目是私有的。

要建立此類包,請將其放在名爲 internal/ 的目錄中,或者放在名爲 internal/ 的目錄的子目錄中。 當 go 命令在其路徑中看到導入包含 internal 的包時,它會驗證執行導入的包是否位於 internal 目錄。

例如,.../a/b/c/internal/d/e/f 的包只能經過以 .../a/b/c/ 爲根目錄的代碼被導入。 它沒法經過 .../a/b/g 或任何其餘倉庫中的代碼導入。[[5]](https://golang.org/doc/go1.4#...

5.2. 確保 main 包內容儘量的少

main 函數和 main 包的內容應儘量少。 這是由於 main.main 充當單例; 程序中只能有一個 main 函數,包括 tests

由於 main.main 是一個單例,假設 main 函數中須要執行不少事情,main.main 只會在 main.mainmain.init 中調用它們而且只調用一次。 這使得爲 main.main 編寫代碼測試變得很困難,所以你應該將全部業務邏輯從 main 函數中移出,最好是從 main 包中移出。

貼士:
main 應該作解析 flags,開啓數據庫鏈接、開啓日誌等,而後將執行交給更高一級的對象。

6. API 設計

我今天要給出的最後一條建議是設計, 我認爲也是最重要的。

到目前爲止我提出的全部建議都是建議。 這些是我嘗試編寫 Go 語言的方式,但我不打算在代碼審查中拼命推廣。

可是,在審查 API 時, 我就不會那麼寬容了。 這是由於到目前爲止我所談論的全部內容都是能夠修復並且不會破壞向後兼容性; 它們在很大程度上是實現的細節。

當涉及到軟件包的公共 API 時,在初始設計中投入大量精力是值得的,由於稍後更改該設計對於已經使用 API 的人來講會是破壞性的。

6.1. 設計難以被誤用的 API

APIs should be easy to use and hard to misuse.
(API 應該易於使用且難以被誤用)
— Josh Bloch [[3]]( https://www.infoq.com/article...

若是你從這個演講中帶走任何東西,那應該是 Josh Bloch 的建議。 若是一個 API 很難用於簡單的事情,那麼 API 的每次調用都會很複雜。 當 API 的實際調用很複雜時,它就會便得不那麼明顯,並且會更容易被忽視。

6.1.1. 警戒採用幾個相同類型參數的函數

簡單, 但難以正確使用的 API 是採用兩個或更多相同類型參數的 API。 讓咱們比較兩個函數簽名:

func Max(a, b int) int
func CopyFile(to, from string) error

這兩個函數有什麼區別? 顯然,一個返回兩個數字最大的那個,另外一個是複製文件,但這不重要。

Max(8, 10) // 10
Max(10, 8) // 10

Max 是可交換的; 參數的順序可有可無。 不管是 8 比 10 仍是 10 比 8,最大的都是 10。

可是,卻不適用於 CopyFile

CopyFile("/tmp/backup", "presentation.md")
CopyFile("presentation.md", "/tmp/backup")

這些聲明中哪個備份了 presentation.md,哪個用上週的版本覆蓋了 presentation.md? 沒有文檔,你沒法分辨。 若是沒有查閱文檔,代碼審查員也沒法知道你寫對了順序。

一種可能的解決方案是引入一個 helper 類型,它會負責如何正確地調用 CopyFile

type Source string

func (src Source) CopyTo(dest string) error {
    return CopyFile(dest, string(src))
}

func main() {
    var from Source = "presentation.md"
    from.CopyTo("/tmp/backup")
}

經過這種方式,CopyFile 老是能被正確調用 - 還能夠經過單元測試 - 而且能夠被設置爲私有,進一步下降了誤用的可能性。

貼士: 具備多個相同類型參數的API難以正確使用。

6.2. 爲其默認用例設計 API

幾年前,我就對 functional options[[7]](https://commandcenter.blogspo... 進行過討論[[6]](https://dave.cheney.net/2014/...,使 API 更易用於默認用例。

本演講的主旨是你應該爲常見用例設計 API。 另外一方面, API 不該要求調用者提供他們不在意參數。

6.2.1. 不鼓勵使用 nil 做爲參數

本章開始時我建議是不要強迫提供給 API 的調用者他們不在意的參數。 這就是我要說的爲默認用例設計 API。

這是 net/http 包中的一個例子

package http

// ListenAndServe listens on the TCP network address addr and then calls
// Serve with handler to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.
//
// The handler is typically nil, in which case the DefaultServeMux is used.
//
// ListenAndServe always returns a non-nil error.
func ListenAndServe(addr string, handler Handler) error {

ListenAndServe 有兩個參數,一個用於監聽傳入鏈接的 TCP 地址,另外一個用於處理 HTTP 請求的 http.HandlerServe 容許第二個參數爲 nil,須要注意的是調用者一般會傳遞 nil,表示他們想要使用 http.DefaultServeMux 做爲隱含參數。

如今,Serve 的調用者有兩種方式能夠作一樣的事情。

http.ListenAndServe("0.0.0.0:8080", nil)
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)

二者徹底相同。

這種 nil 行爲是病毒式的。 http 包也有一個 http.Serve 幫助類,你能夠合理地想象一下 ListenAndServe 是這樣構建的

func ListenAndServe(addr string, handler Handler) error {
    l, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    defer l.Close()
    return Serve(l, handler)
}

由於 ListenAndServe 容許調用者爲第二個參數傳遞 nil,因此 http.Serve 也支持這種行爲。 事實上,http.Serve 實現了若是 handlernil,使用 DefaultServeMux 的邏輯。 參數可爲 nil 可能會致使調用者認爲他們能夠爲兩個參數都使用 nil。 像下面這樣:

http.Serve(nil, nil)

會致使 panic

貼士:
不要在同一個函數簽名中混合使用可爲 nil 和不能爲 nil 的參數。

http.ListenAndServe 的做者試圖在常見狀況下讓使用 API 的用戶更輕鬆些,但極可能會讓該程序包更難以被安全地使用。

使用 DefaultServeMux 或使用 nil 沒有什麼區別。

const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", nil)

對比

const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)

這種混亂值得拯救嗎?

const root = http.Dir("/htdocs")
mux := http.NewServeMux()
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", mux)
貼士: 認真考慮 helper 函數會節省很多時間。 清晰要比簡潔好。

貼士:
避免公共 API 使用測試參數
避免在公開的 API 上使用僅在測試範圍上不一樣的值。 相反,使用 Public wrappers 隱藏這些參數,使用輔助方式來設置測試範圍中的屬性。

6.2.2. 首選可變參數函數而非 []T 參數

編寫一個帶有切片參數的函數或方法是很常見的。

func ShutdownVMs(ids []string) error

這只是我編的一個例子,但它與我所寫的不少代碼相同。 這裏的問題是他們假設他們會被調用於多個條目。 可是不少時候這些類型的函數只用一個參數調用,爲了知足函數參數的要求,它必須打包到一個切片內。

另外,由於 ids 參數是切片,因此你能夠將一個空切片或 nil 傳遞給該函數,編譯也沒什麼錯誤。 可是這會增長額外的測試負載,由於你應該涵蓋這些狀況在測試中。

舉一個這類 API 的例子,最近我重構了一條邏輯,要求我設置一些額外的字段,若是一組參數中至少有一個非零。 邏輯看起來像這樣:

if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 {
    // apply the non zero parameters
}

因爲 if 語句變得很長,我想將簽出的邏輯拉入其本身的函數中。 這就是我提出的:

// anyPostive indicates if any value is greater than zero.
func anyPositive(values ...int) bool {
    for _, v := range values {
        if v > 0 {
            return true
        }
    }
    return false
}

這就可以向讀者明確內部塊的執行條件:

if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
        // apply the non zero parameters
}

可是 anyPositive 還存在一個問題,有人可能會這樣調用它:

if anyPositive() { ... }

在這種狀況下,anyPositive 將返回 false,由於它不會執行迭代而是當即返回 false。對比起若是 anyPositive 在沒有傳遞參數時返回 true, 這還不算世界上最糟糕的事情。

然而,若是咱們能夠更改 anyPositive 的簽名以強制調用者應該傳遞至少一個參數,那會更好。咱們能夠經過組合正常和可變參數來作到這一點,以下所示:

// anyPostive indicates if any value is greater than zero.
func anyPositive(first int, rest ...int) bool {
    if first > 0 {
        return true
    }
    for _, v := range rest {
        if v > 0 {
            return true
        }
    }
    return false
}

如今不能使用少於一個參數來調用 anyPositive

6.3. 讓函數定義它們所需的行爲

假設我須要編寫一個將 Document 結構保存到磁盤的函數的任務。

// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error

我能夠指定這個函數 Save,它將 *os.File 做爲寫入 Document 的目標。但這樣作會有一些問題

Save 的簽名排除了將數據寫入網絡位置的選項。假設網絡存儲可能在之後成爲需求,則此功能的簽名必須改變,從而影響其全部調用者。

Save 測試起來也很麻煩,由於它直接操做磁盤上的文件。所以,爲了驗證其操做,測試時必須在寫入文件後再讀取該文件的內容。

並且我必須確保 f 被寫入臨時位置而且隨後要將其刪除。

*os.File 還定義了許多與 Save 無關的方法,好比讀取目錄並檢查路徑是不是符號連接。 若是 Save 函數的簽名只用 *os.File 的相關內容,那將會頗有用。

咱們能作什麼 ?

// Save writes the contents of doc to the supplied
// ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error

使用 io.ReadWriteCloser,咱們能夠應用接口隔離原則來從新定義 Save 以獲取更通用文件形式。

經過此更改,任何實現 io.ReadWriteCloser 接口的類型均可以替換之前的 *os.File

這使 Save 在其應用程序中更普遍,並向 Save 的調用者闡明 *os.File 類型的哪些方法與其操做有關。

並且,Save 的做者也不能夠在 *os.File 上調用那些不相關的方法,由於它隱藏在 io.ReadWriteCloser 接口後面。

但咱們能夠進一步採用接口隔離原則

首先,若是 Save 遵循單一功能原則,它不可能讀取它剛剛寫入的文件來驗證其內容 - 這應該是另外一段代碼的功能。

// Save writes the contents of doc to the supplied
// WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error

所以,咱們能夠將咱們傳遞給 Save 的接口的規範縮小到只寫和關閉。

其次,經過向 Save 提供一個關閉其流的機制,使其看起來仍然像一個文件,這就提出了在什麼狀況下關閉 wc 的問題。

可能 Save 會無條件地調用 Close,或者在成功的狀況下調用 Close

這給 Save 的調用者帶來了問題,由於它可能但願在寫入文檔後將其餘數據寫入流。

// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error

一個更好的解決方案是從新定義 Save 僅使用 io.Writer,它只負責將數據寫入流。

接口隔離原則應用於咱們的 Save 功能,同時, 就需求而言, 得出了最具體的一個函數 - 它只須要一個可寫的東西 - 而且它的功能最通用,如今咱們能夠使用 Save 將咱們的數據保存到實現 io.Writer 的任何事物中。

[[譯註: 不理解設計原則部分的同窗能夠閱讀 Dave 大神的另外一篇《Go 語言 SOLID 設計》]](https://www.jianshu.com/p/0ae...

7. 錯誤處理

我已經給出了幾個關於錯誤處理的演示文稿[[8]](https://dave.cheney.net/2016/...,並在個人博客上寫了不少關於錯誤處理的文章。我在昨天的會議上也講了不少關於錯誤處理的內容,因此在這裏再也不贅述。

相反,我想介紹與錯誤處理相關的兩個其餘方面。

7.1. 經過消除錯誤來消除錯誤處理

若是你昨天在個人演講中,我談到了改進錯誤處理的提案。可是你知道有什麼比改進錯誤處理的語法更好嗎?那就是根本不須要處理錯誤。

注意:
我不是說「刪除你的錯誤處理」。個人建議是,修改你的代碼,這樣就不用處理錯誤了。

本節從 John Ousterhout 最近的著做「軟件設計哲學」[[9]](https://www.amazon.com/Philos...。該書的其中一章是「定義不存在的錯誤」。咱們將嘗試將此建議應用於 Go 語言。

7.1.1. 計算行數

讓咱們編寫一個函數來計算文件中的行數。

func CountLines(r io.Reader) (int, error) {
    var (
        br    = bufio.NewReader(r)
        lines int
        err   error
    )

    for {
        _, err = br.ReadString('\n')
        lines++
        if err != nil {
            break
        }
    }

    if err != io.EOF {
        return 0, err
    }
    return lines, nil
}

因爲咱們遵循前面部分的建議,CountLines 須要一個 io.Reader,而不是一個 *File;它的任務是調用者爲咱們想要計算的內容提供 io.Reader

咱們構造一個 bufio.Reader,而後在一個循環中調用 ReadString 方法,遞增計數器直到咱們到達文件的末尾,而後咱們返回讀取的行數。

至少這是咱們想要編寫的代碼,可是這個函數因爲須要錯誤處理而變得更加複雜。 例如,有這樣一個奇怪的結構:

_, err = br.ReadString('\n')
lines++
if err != nil {
    break
}

咱們在檢查錯誤以前增長了行數,這樣作看起來很奇怪。

咱們必須以這種方式編寫它的緣由是,若是在遇到換行符以前就讀到文件結束,則 ReadString 將返回錯誤。若是文件中沒有換行符,一樣會出現這種狀況。

爲了解決這個問題,咱們從新排列邏輯增來加行數,而後查看是否須要退出循環。

注意:
這個邏輯仍然不完美,你能發現錯誤嗎?

可是咱們尚未完成檢查錯誤。當 ReadString 到達文件末尾時,預期它會返回 io.EOFReadString 須要某種方式在沒有什麼可讀時來中止。所以,在咱們將錯誤返回給 CountLine 的調用者以前,咱們須要檢查錯誤是不是 io.EOF,若是不是將其錯誤返回,不然咱們返回 nil 說一切正常。

我認爲這是 Russ Cox 觀察到錯誤處理可能會模​​糊函數操做的一個很好的例子。咱們來看一個改進的版本。

func CountLines(r io.Reader) (int, error) {
    sc := bufio.NewScanner(r)
    lines := 0

    for sc.Scan() {
        lines++
    }
    return lines, sc.Err()
}

這個改進的版本從 bufio.Reader 切換到 bufio.Scanner

bufio.Scanner 內部使用 bufio.Reader,但它添加了一個很好的抽象層,它有助於經過隱藏 CountLines 的操做來消除錯誤處理。

注意:
bufio.Scanner 能夠掃描任何模式,但默認狀況下它會查找換行符。

若是掃描程序匹配了一行文本而且沒有遇到錯誤,則 sc.Scan() 方法返回 true 。所以,只有當掃描儀的緩衝區中有一行文本時,纔會調用 for 循環的主體。這意味着咱們修改後的 CountLines 正確處理沒有換行符的狀況,而且還處理文件爲空的狀況。

其次,當 sc.Scan 在遇到錯誤時返回 false,咱們的 for 循環將在到達文件結尾或遇到錯誤時退出。bufio.Scanner 類型會記住遇到的第一個錯誤,一旦咱們使用 sc.Err() 方法退出循環,咱們就能夠獲取該錯誤。

最後, sc.Err() 負責處理 io.EOF 並在達到文件末尾時將其轉換爲 nil,而不會遇到其餘錯誤。

貼士:
當遇到難以忍受的錯誤處理時,請嘗試將某些操做提取到輔助程序類型中。

7.1.2. WriteResponse

個人第二個例子受到了 Errors are values 博客文章[[10]](https://blog.golang.org/error...

在本章前面咱們已經看過處理打開、寫入和關閉文件的示例。錯誤處理是存在的,可是接收範圍內的,由於操做能夠封裝在諸如 ioutil.ReadFileioutil.WriteFile 之類的輔助程序中。可是,在處理底層網絡協議時,有必要使用 I/O 原始的錯誤處理來直接構建響應,這樣就可能會變得重複。看一下構建 HTTP 響應的 HTTP 服務器的這個片斷。

type Header struct {
    Key, Value string
}

type Status struct {
    Code   int
    Reason string
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
    _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
    if err != nil {
        return err
    }

    for _, h := range headers {
        _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
        if err != nil {
            return err
        }
    }

    if _, err := fmt.Fprint(w, "\r\n"); err != nil {
        return err
    }

    _, err = io.Copy(w, body)
    return err
}

首先,咱們使用 fmt.Fprintf 構造狀態碼並檢查錯誤。 而後對於每一個標題,咱們寫入鍵值對,每次都檢查錯誤。 最後,咱們使用額外的 \r\n 終止標題部分,檢查錯誤以後將響應主體複製到客戶端。 最後,雖然咱們不須要檢查 io.Copy 中的錯誤,但咱們須要將 io.Copy 返回的兩個返回值形式轉換爲 WriteResponse 的單個返回值。

這裏不少重複性的工做。 咱們能夠經過引入一個包裝器類型 errWriter 來使其更容易。

errWriter 實現 io.Writer 接口,所以可用於包裝現有的 io.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 應用於 WriteResponse 能夠顯着提升代碼的清晰度。 每一個操做再也不須要本身作錯誤檢查。 經過檢查 ew.err 字段,將錯誤報告移動到函數末尾,從而避免轉換從 io.Copy 的兩個返回值。

7.2. 錯誤只處理一次

最後,我想提一下你應該只處理錯誤一次。 處理錯誤意味着檢查錯誤值並作出單一決定。

// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
        w.Write(buf)
}

若是你作出的決定少於一個,則忽略該錯誤。 正如咱們在這裏看到的那樣, w.WriteAll 的錯誤被丟棄。

可是,針對單個錯誤作出多個決策也是有問題的。 如下是我常常遇到的代碼。

func WriteAll(w io.Writer, buf []byte) error {
    _, err := w.Write(buf)
    if err != nil {
        log.Println("unable to write:", err) // annotated error goes to log file
        return err                           // unannotated error returned to caller
    }
    return nil
}

在此示例中,若是在 w.Write 期間發生錯誤,則會寫入日誌文件,註明錯誤發生的文件與行數,而且錯誤也會返回給調用者,調用者可能會記錄該錯誤並將其返回到上一級,一直回到程序的頂部。

調用者可能正在作一樣的事情

func WriteConfig(w io.Writer, conf *Config) error {
    buf, err := json.Marshal(conf)
    if err != nil {
        log.Printf("could not marshal config: %v", err)
        return err
    }
    if err := WriteAll(w, buf); err != nil {
        log.Println("could not write config: %v", err)
        return err
    }
    return nil
}

所以你在日誌文件中獲得一堆重複的內容,

unable to write: io.EOF
could not write config: io.EOF

但在程序的頂部,雖然獲得了原始錯誤,但沒有相關內容。

err := WriteConfig(f, &conf)
fmt.Println(err) // io.EOF

我想深刻研究這一點,由於做爲我的偏好, 我並無看到 logging 和返回的問題。

func WriteConfig(w io.Writer, conf *Config) error {
    buf, err := json.Marshal(conf)
    if err != nil {
        log.Printf("could not marshal config: %v", err)
        // oops, forgot to return
    }
    if err := WriteAll(w, buf); err != nil {
        log.Println("could not write config: %v", err)
        return err
    }
    return nil
}

不少問題是程序員忘記從錯誤中返回。正如咱們以前談到的那樣,Go 語言風格是使用 guard clauses 以及檢查前提條件做爲函數進展並提早返回。

在這個例子中,做者檢查了錯誤,記錄了它,但忘了返回。這就引發了一個微妙的錯誤。

Go 語言中的錯誤處理規定,若是出現錯誤,你不能對其餘返回值的內容作出任何假設。因爲 JSON 解析失敗,buf 的內容未知,可能它什麼都沒有,但更糟的是它可能包含解析的 JSON 片斷部分。

因爲程序員在檢查並記錄錯誤後忘記返回,所以損壞的緩衝區將傳遞給 WriteAll,這可能會成功,所以配置文件將被錯誤地寫入。可是,該函數會正常返回,而且發生問題的惟一日誌行是有關 JSON 解析錯誤,而與寫入配置失敗有關。

7.2.1. 爲錯誤添加相關內容

發生錯誤的緣由是做者試圖在錯誤消息中添加 context 。 他們試圖給本身留下一些線索,指出錯誤的根源。

讓咱們看看使用 fmt.Errorf 的另外一種方式。

func WriteConfig(w io.Writer, conf *Config) error {
    buf, err := json.Marshal(conf)
    if err != nil {
        return fmt.Errorf("could not marshal config: %v", err)
    }
    if err := WriteAll(w, buf); err != nil {
        return fmt.Errorf("could not write config: %v", err)
    }
    return nil
}

func WriteAll(w io.Writer, buf []byte) error {
    _, err := w.Write(buf)
    if err != nil {
        return fmt.Errorf("write failed: %v", err)
    }
    return nil
}

經過將註釋與返回的錯誤組合起來,就更難以忘記錯誤的返回來避免意外繼續。

若是寫入文件時發生 I/O 錯誤,則 errorError() 方法會報告如下相似的內容;

could not write config: write failed: input/output error

7.2.2. 使用 github.com/pkg/errors 包裝 errors

fmt.Errorf 模式適用於註釋錯誤 message,但這樣作的代價是模糊了原始錯誤的類型。 我認爲將錯誤視爲不透明值對於鬆散耦合的軟件很是重要,所以若是你使用錯誤值作的惟一事情是原始錯誤的類型應該可有可無的面孔

  1. 檢查它是否爲 nil
  2. 輸出或記錄它。

可是在某些狀況下,我認爲它們並不常見,您須要恢復原始錯誤。 在這種狀況下,使用相似個人 errors 包來註釋這樣的錯誤, 以下

func ReadFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, errors.Wrap(err, "open failed")
    }
    defer f.Close()

    buf, err := ioutil.ReadAll(f)
    if err != nil {
        return nil, errors.Wrap(err, "read failed")
    }
    return buf, nil
}

func ReadConfig() ([]byte, error) {
    home := os.Getenv("HOME")
    config, err := ReadFile(filepath.Join(home, ".settings.xml"))
    return config, errors.WithMessage(err, "could not read config")
}

func main() {
    _, err := ReadConfig()
    if err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

如今報告的錯誤就是 K&D [[11]](http://www.gopl.io/)樣式錯誤

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

而且錯誤值保留對原始緣由的引用。

func main() {
    _, err := ReadConfig()
    if err != nil {
        fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))
        fmt.Printf("stack trace:\n%+v\n", err)
        os.Exit(1)
    }
}

所以,你能夠恢復原始錯誤並打印堆棧跟蹤;

original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory
stack trace:
open /Users/dfc/.settings.xml: no such file or directory
open failed
main.ReadFile
        /Users/dfc/devel/practical-go/src/errors/readfile2.go:16
main.ReadConfig
        /Users/dfc/devel/practical-go/src/errors/readfile2.go:29
main.main
        /Users/dfc/devel/practical-go/src/errors/readfile2.go:35
runtime.main
        /Users/dfc/go/src/runtime/proc.go:201
runtime.goexit
        /Users/dfc/go/src/runtime/asm_amd64.s:1333
could not read config

使用 errors 包,你能夠以人和機器均可檢查的方式向錯誤值添加上下文。 若是昨天你來聽個人演講,你會知道這個庫在被移植到即將發佈的 Go 語言版本的標準庫中。

8. 併發

因爲 Go 語言的併發功能,常常被選做項目編程語言。 Go 語言團隊已經不遺餘力以廉價(在硬件資源方面)和高性能來實現併發,可是 Go 語言的併發功能也能夠被用來編寫性能不高同時也不太可靠的代碼。在結尾,我想留下一些建議,以免 Go 語言的併發功能帶來的一些陷阱。

Go 語言以 channels 以及 selectgo 語句來支持併發。若是你已經從書籍或培訓課程中正式學習了 Go 語言,你可能已經注意到併發部分始終是這些課程的最後一部分。這個研討會也沒有什麼不一樣,我選擇最後覆蓋併發,好像它是 Go 程序員應該掌握的常規技能的額外補充。

這裏有一個二分法; Go 語言的最大特色是簡單、輕量級的併發模型。做爲一種產品,咱們的語言幾乎只推廣這個功能。另外一方面,有一種說法認爲併發使用起來實際上並不容易,不然做者不會把它做爲他們書中的最後一章,咱們也不會遺憾地來回顧其造成過程。

本節討論了 Go 語言的併發功能的「坑」。

8.1. 保持本身忙碌或作本身的工做

這個程序有什麼問題?

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, GopherCon SG")
    })
    go func() {
        if err := http.ListenAndServe(":8080", nil); err != nil {
            log.Fatal(err)
        }
    }()

    for {
    }
}

該程序實現了咱們的預期,它提供簡單的 Web 服務。 然而,它同時也作了其餘事情,它在無限循環中浪費 CPU 資源。 這是由於 main 的最後一行上的 for {} 將阻塞 main goroutine,由於它不執行任何 IO、等待鎖定、發送或接收通道數據或以其餘方式與調度器通訊。

因爲 Go 語言運行時主要是協同調度,該程序將在單個 CPU 上作無效地旋轉,並可能最終實時鎖定。

咱們如何解決這個問題? 這是一個建議。

package main

import (
    "fmt"
    "log"
    "net/http"
    "runtime"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, GopherCon SG")
    })
    go func() {
        if err := http.ListenAndServe(":8080", nil); err != nil {
            log.Fatal(err)
        }
    }()

    for {
        runtime.Gosched()
    }
}

這看起來很愚蠢,但這是我看過的一種常看法決方案。 這是不瞭解潛在問題的症狀。

如今,若是你有更多的經驗,你可能會寫這樣的東西。

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, GopherCon SG")
    })
    go func() {
        if err := http.ListenAndServe(":8080", nil); err != nil {
            log.Fatal(err)
        }
    }()

    select {}
}

空的 select 語句將永遠阻塞。 這是一個有用的屬性,由於如今咱們再也不調用 runtime.GoSched() 而耗費整個 CPU。 可是這也只是治療了症狀,而不是病根。

我想向你提出另外一種你可能在用的解決方案。 與其在 goroutine 中運行 http.ListenAndServe,會給咱們留下處理 main goroutine 的問題,不如在 main goroutine 自己上運行 http.ListenAndServe

貼士:
若是 Go 語言程序的 main.main 函數返回,不管程序在一段時間內啓動的其餘 goroutine 在作什麼, Go 語言程序會無條件地退出。
package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, GopherCon SG")
    })
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

因此這是個人第一條建議:若是你的 goroutine 在獲得另外一個結果以前沒法取得進展,那麼讓本身完成此工做而不是委託給其餘 goroutine 會更簡單。

這一般會消除將結果從 goroutine 返回到其啓動程序所需的大量狀態跟蹤和通道操做。

貼士:
許多 Go 程序員過分使用 goroutine,特別是剛開始時。與生活中的全部事情同樣,適度是成功的關鍵。

8.2. 將併發性留給調用者

如下兩個 API 有什麼區別?

// ListDirectory returns the contents of dir.
func ListDirectory(dir string) ([]string, error)
// ListDirectory returns a channel over which
// directory entries will be published. When the list
// of entries is exhausted, the channel will be closed.
func ListDirectory(dir string) chan string

首先,最明顯的不一樣: 第一個示例將目錄讀入切片而後返回整個切片,若是出錯則返回錯誤。這是同步發生的,ListDirectory 的調用者會阻塞,直到讀取了全部目錄條目。根據目錄的大小,這可能須要很長時間,而且可能會分配大量內存來構建目錄條目。

讓咱們看看第二個例子。 這個示例更像是 Go 語言風格,ListDirectory 返回一個通道,經過該通道傳遞目錄條目。當通道關閉時,代表沒有更多目錄條目。因爲在 ListDirectory 返回後發生了通道的填充,ListDirectory 可能會啓動一個 goroutine 來填充通道。

注意:
第二個版本實際上沒必要使用 Go 協程; 它能夠分配一個足以保存全部目錄條目而不阻塞的通道,填充通道,關閉它,而後將通道返回給調用者。但這樣作不太現實,由於會消耗大量內存來緩衝通道中的全部結果。

通道版本的 ListDirectory 還有兩個問題:

  • 經過使用關閉通道做爲沒有其餘項目要處理的信號,在中途遇到了錯誤時, ListDirectory 沒法告訴調用者經過通道返回的項目集是否完整。調用者沒法區分空目錄和讀取目錄的錯誤。二者都致使從 ListDirectory 返回的通道當即關閉。
  • 調用者必須持續從通道中讀取,直到它被關閉,由於這是調用者知道此通道的是否中止的惟一方式。這是對 ListDirectory 使用的嚴重限制,即便可能已經收到了它想要的答案,調用者也必須花時間從通道中讀取。就中型到大型目錄的內存使用而言,它可能更有效,但這種方法並不比原始的基於切片的方法快。

以上兩種實現所帶來的問題的解決方案是使用回調,該回調是在執行時在每一個目錄條目的上下文中調用函數。

func ListDirectory(dir string, fn func(string))

絕不奇怪,這就是 filepath.WalkDir 函數的工做方式。

貼士:
若是你的函數啓動了 goroutine,你必須爲調用者提供一種明確中止 goroutine 的方法。 把異步執行函數的決定留給該函數的調用者一般會更容易些。

8.3. 永遠不要啓動一箇中止不了的 goroutine。

前面的例子顯示當一個任務時沒有必要時使用 goroutine。但使用 Go 語言的緣由之一是該語言提供的併發功能。實際上,不少狀況下你但願利用硬件中可用的並行性。爲此,你必須使用 goroutines

這個簡單的應用程序在兩個不一樣的端口上提供 http 服務,端口 8080 用於應用程序服務,端口 8001 用於訪問 /debug/pprof 終端。

package main

import (
    "fmt"
    "net/http"
    _ "net/http/pprof"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
        fmt.Fprintln(resp, "Hello, QCon!")
    })
    go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug
    http.ListenAndServe("0.0.0.0:8080", mux)                       // app traffic
}

雖然這個程序不是很複雜,但它表明了真實應用程序的基礎。

該應用程序存在一些問題,由於它隨着應用程序的增加而顯露出來,因此咱們如今來解決其中的一些問題。

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 處理程序分解成爲它們本身的函數,咱們將它們與 main.main 分離。 也遵循了上面的建議,並確保 serveAppserveDebug 將它們的併發性留給調用者。

可是這個程序存在一些可操做性問題。 若是 serveApp 返回,那麼 main.main 將返回,致使程序關閉並由你使用的進程管理器來從新啓動。

貼士:
正如 Go 語言中的函數將併發性留給調用者同樣,應用程序應該將監視其狀態和檢測是否重啓的工做留給另外的程序來作。 不要讓你的應用程序負責從新啓動本身,最好從應用程序外部處理該過程。

然而,serveDebug 是在一個單獨的 goroutine 中運行的,返回後該 goroutine 將退出,而程序的其他部分繼續。 因爲 /debug 處理程序已中止工做好久,所以操做人員不會很高興發現他們沒法在你的應用程序中獲取統計信息。

咱們想要確保的是,若是任何負責提供此應用程序的 goroutine 中止,咱們將關閉該應用程序。

func serveApp() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
        fmt.Fprintln(resp, "Hello, QCon!")
    })
    if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil {
        log.Fatal(err)
    }
}

func serveDebug() {
    if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil {
        log.Fatal(err)
    }
}

func main() {
    go serveDebug()
    go serveApp()
    select {}
}

如今 serverAppserveDebug 檢查從 ListenAndServe 返回的錯誤,並在須要時調用 log.Fatal。由於兩個處理程序都在 goroutine 中運行,因此咱們將 main goroutine 停在 select{} 中。

這種方法存在許多問題:

  1. 若是 ListenAndServer 返回 nil 錯誤,則不會調用 log.Fatal,而且該端口上的 HTTP 服務將在不中止應用程序的狀況下關閉。
  2. log.Fatal 調用 os.Exit,它將無條件地退出程序; defer 不會被調用,其餘 goroutines 也不會被通知關閉,程序就中止了。 這使得編寫這些函數的測試變得困難。
貼士:
只在 main.maininit 函數中的使用 log.Fatal

咱們真正想要的是任何錯誤發送回 goroutine 的調用者,以便它能夠知道 goroutine 中止的緣由,能夠乾淨地關閉程序進程。

func serveApp() error {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
        fmt.Fprintln(resp, "Hello, QCon!")
    })
    return http.ListenAndServe("0.0.0.0:8080", mux)
}

func serveDebug() error {
    return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux)
}

func main() {
    done := make(chan error, 2)
    go func() {
        done <- serveDebug()
    }()
    go func() {
        done <- serveApp()
    }()

    for i := 0; i < cap(done); i++ {
        if err := <-done; err != nil {
            fmt.Println("error: %v", err)
        }
    }
}

咱們能夠使用通道來收集 goroutine 的返回狀態。通道的大小等於咱們想要管理的 goroutine 的數量,這樣發送到 done 通道就不會阻塞,由於這會阻止 goroutine 的關閉,致使它泄漏。

因爲沒有辦法安全地關閉 done 通道,咱們不能使用 for range 來循環通道直到獲取全部 goroutine 發來的報告,而是循環咱們開啓的多個 goroutine,即通道的容量。

如今咱們有辦法等待每一個 goroutine 乾淨地退出並記錄他們遇到的錯誤。所須要的只是一種從第一個 goroutine 轉發關閉信號到其餘 goroutine 的方法。

事實證實,要求 http.Server 關閉是有點牽扯的,因此我將這個邏輯轉給輔助函數。serve 助手使用一個地址和 http.Handler,相似於 http.ListenAndServe,還有一個 stop 通道,咱們用它來觸發 Shutdown 方法。

func serve(addr string, handler http.Handler, stop <-chan struct{}) error {
    s := http.Server{
        Addr:    addr,
        Handler: handler,
    }

    go func() {
        <-stop // wait for stop signal
        s.Shutdown(context.Background())
    }()

    return s.ListenAndServe()
}

func serveApp(stop <-chan struct{}) error {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) {
        fmt.Fprintln(resp, "Hello, QCon!")
    })
    return serve("0.0.0.0:8080", mux, stop)
}

func serveDebug(stop <-chan struct{}) error {
    return serve("127.0.0.1:8001", http.DefaultServeMux, stop)
}

func main() {
    done := make(chan error, 2)
    stop := make(chan struct{})
    go func() {
        done <- serveDebug(stop)
    }()
    go func() {
        done <- serveApp(stop)
    }()

    var stopped bool
    for i := 0; i < cap(done); i++ {
        if err := <-done; err != nil {
            fmt.Println("error: %v", err)
        }
        if !stopped {
            stopped = true
            close(stop)
        }
    }
}

如今,每次咱們在 done 通道上收到一個值時,咱們關閉 stop 通道,這會致使在該通道上等待的全部 goroutine 關閉其 http.Server。 這反過來將致使其他全部的 ListenAndServe goroutines 返回。 一旦咱們開啓的全部 goroutine 都中止了,main.main 就會返回而且進程會乾淨地中止。

貼士:
本身編寫這種邏輯是重複而微妙的。 參考下這個包: https://github.com/heptio/workgroup,它會爲你完成大部分工做。

引用:

1. https://gaston.life/books/effective-programming/

2. https://talks.golang.org/2014/names.slide#4

3. https://www.infoq.com/articles/API-Design-Joshua-Bloch

1. https://www.lysator.liu.se/c/pikestyle.html

2. https://speakerdeck.com/campoy/understanding-nil

3. https://www.youtube.com/watch?v=Ic2y6w8lMPA

4. https://medium.com/@matryer/line-of-sight-in-code-186dd7cdea88

5. https://golang.org/doc/go1.4#internalpackages

6. https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis

7. https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html

8. https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully

9. https://www.amazon.com/Philosophy-Software-Design-John-Ousterhout/dp/1732102201

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

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


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

  • 若有翻譯有誤或者不理解的地方,請評論指正
  • 待更新的譯註以後會作進一步修改翻譯
  • 翻譯:田浩
  • 郵箱:<llitfkitfk@gmail.com>
相關文章
相關標籤/搜索