[譯] Go 語言實戰: 編寫可維護 Go 語言程式碼建議

DukeAnn發表於2020-01-13

目錄

介紹

大家好,我在接下來的兩個會議中的目標是向大家提供有關編寫 Go 程式碼最佳實踐的建議。

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

貼士: 在這裡有最新的文章連結
https://dave.cheney.net/practical-go/prese...

編者的話

  • 終於翻譯完了 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 作出了軟體程式設計與軟體工程的區分。 前者是你自己寫的一個程式。 後者是很多人會隨著時間的推移而開發的產品。 工程師們來來去去,團隊會隨著時間增長與縮小,需求會發生變化,功能會被新增,錯誤也會得到修復。 這是軟體工程的本質。

我可能是這個房間裡 Go 最早的使用者之一,~但要爭辯說我的資歷給我的看法更多是假的~。相反,今天我要提的建議是基於我認為的 Go 語言本身的指導原則:

  1. 簡單性
  2. 可讀性
  3. 生產力

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

1.1. 簡單性

我們為什麼要追求簡單? 為什麼 Go 語言程式的簡單性很重要?

我們都曾遇到過這樣的情況: “我不懂這段程式碼”,不是嗎? 我們都做過這樣的專案:你害怕做出改變,因為你擔心它會破壞程式的另一部分; 你不理解的部分,不知道如何修復。

這就是複雜性。 複雜性把可靠的軟體中變成不可靠。 複雜性是殺死軟體專案的罪魁禍首。

簡單性是 Go 語言的最高目標。 無論我們編寫什麼程式,我們都應該同意這一點:它們很簡單。

1.2. 可讀性

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

為什麼 Go 語言的程式碼可讀性是很重要的?我們為什麼要爭取可讀性?

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

可讀性很重要,因為所有軟體不僅僅是 Go 語言程式,都是由人類編寫的,供他人閱讀。執行軟體的計算機則是次要的。

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

The most important skill for a programmer is the ability to effectively communicate ideas. (程式設計師最重要的技能是有效溝通想法的能力。)
— Gastón Jorquera [1]

可讀性是能夠理解程式正在做什麼的關鍵。如果你無法理解程式正在做什麼,那你希望如何維護它?如果軟體無法維護,那麼它將被重寫;最後這可能是你的公司最後一次投資 Go 語言。

~如果你正在為自己編寫一個程式,也許它只需要執行一次,或者你是唯一一個曾經看過它的人,然後做任何對你有用的事。~但是,如果是一個不止一個人會貢獻編寫的軟體,或者在很長一段時間內需求、功能或者環境會改變,那麼你的目標必須是你的程式可被維護。

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

1.3. 生產力

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

我要強調的最後一個基本原則是生產力。開發人員的工作效率是一個龐大的主題,但歸結為此; 你花多少時間做有用的工作,而不是等待你的工具或迷失在一個外國的程式碼庫裡。 Go 程式設計師應該覺得他們可以通過 Go 語言完成很多工作。

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

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

Go 程式設計師不會花費整天的時間來除錯不可思議的編譯錯誤。他們也不會將浪費時間在複雜的構建指令碼或在生產中部署程式碼。最重要的是,他們不用花費時間來試圖瞭解他們的同事所寫的內容。

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

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]

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

The greater the distance between a name’s declaration and its uses, the longer the name should be. (名字的宣告與其使用之間的距離越大,名字應該越長。)
— Andrew Gerrand [2]

由此我們可以得出一些指導方針:

  • 短變數名稱在宣告和上次使用之間的距離很短時效果很好。
  • 長變數名稱需要證明自己的合理性; 名稱越長,需要提供的價值越高。冗長的名稱與頁面上的重量相比,訊號量較小。
  • 請勿在變數名稱中包含型別名稱。
  • 常量應該描述它們持有的值,而不是該如何使用。
  • 對於迴圈和分支使用單字母變數,引數和返回值使用單個字,函式和包級別宣告使用多個單詞
  • 方法、介面和包使用單個詞。
  • 請記住,包的名稱是呼叫者用來引用名稱的一部分,因此要好好利用這一點。

我們來舉個栗子:

type Person struct {
    Name string
    Age  int
}

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

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

    return sum / count
}

在此示例中,變數 p 的在第 10 行被宣告並且也只在接下來的一行中被引用。 p 在執行函式期間存在時間很短。如果要了解 p 的作用只需閱讀兩行程式碼。

相比之下,people 在函式第 7 行引數中被宣告。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` 而不是短宣告語法在包級別宣告變數的要求一致 - 儘管我稍後會說你根本不應該使用包級變數。

* **在宣告和初始化時,使用 `:=`。** 在同時宣告和初始化變數時,也就是說我們不會將變數初始化為零值,我建議使用短變數宣告。 這使得讀者清楚地知道 `:=` 左側的變數是初始化過的。

為了解釋原因,讓我們看看前面的例子,但這次是初始化每個變數:
```golang
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]相反,我們有一個選擇,如果我們要使用切片的零值則寫作:

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]。有時你會發現一個更好的變數名稱隱藏在註釋中。

// 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)

這條規則有一個例外; 您不需要註釋實現介面的方法。 具體不要像下面這樣做:
```golang
// 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]

這是通過使用 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]

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]

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

6.1.1. 警惕採用幾個相同型別引數的函式

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

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

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

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

Max 是可交換的; 引數的順序無關緊要。 無論是 8 比 10 還是 10 比 8,最大的都是 10。

但是,卻不適用於 CopyFile

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

這些宣告中哪一個備份了 presentation.md,哪一個用上週的版本覆蓋了 presentation.md? 沒有文件,你無法分辨。 如果沒有查閱文件,程式碼審查員也無法知道你寫對了順序。

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

type Source string

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

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

通過這種方式,CopyFile 總是能被正確呼叫 - 還可以通過單元測試 - 並且可以被設定為私有,進一步降低了誤用的可能性。

貼士: 具有多個相同型別引數的API難以正確使用。

6.2. 為其預設用例設計 API

幾年前,我就對 functional options[7] 進行過討論[6],使 API 更易用於預設用例。

本演講的主旨是你應該為常見用例設計 API。 另一方面, API 不應要求呼叫者提供他們不在乎引數。

6.2.1. 不鼓勵使用 nil 作為引數

本章開始時我建議是不要強迫提供給 API 的呼叫者他們不在乎的引數。 這就是我要說的為預設用例設計 API。

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

package http

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

ListenAndServe 有兩個引數,一個用於監聽傳入連線的 TCP 地址,另一個用於處理 HTTP 請求的 http.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 設計》]

7. 錯誤處理

我已經給出了幾個關於錯誤處理的簡報[8],並在我的部落格上寫了很多關於錯誤處理的文章。我在昨天的會議上也講了很多關於錯誤處理的內容,所以在這裡不再贅述。

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

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

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

注意:
我不是說“刪除你的錯誤處理”。我的建議是,修改你的程式碼,這樣就不用處理錯誤了。

本節從 John Ousterhout 最近的著作“軟體設計哲學”[9]中汲取靈感。該書的其中一章是“定義不存在的錯誤”。我們將嘗試將此建議應用於 Go 語言。

7.1.1. 計算行數

讓我們編寫一個函式來計算檔案中的行數。

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

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

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

由於我們遵循前面部分的建議,CountLines 需要一個 io.Reader,而不是一個 *File;它的任務是呼叫者為我們想要計算的內容提供 io.Reader

我們構造一個 bufio.Reader,然後在一個迴圈中呼叫 ReadString 方法,遞增計數器直到我們到達檔案的末尾,然後我們返回讀取的行數。

至少這是我們想要編寫的程式碼,但是這個函式由於需要錯誤處理而變得更加複雜。 例如,有這樣一個奇怪的結構:

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

我們在檢查錯誤之前增加了行數,這樣做看起來很奇怪。

我們必須以這種方式編寫它的原因是,如果在遇到換行符之前就讀到檔案結束,則 ReadString 將返回錯誤。如果檔案中沒有換行符,同樣會出現這種情況。

為了解決這個問題,我們重新排列邏輯增來加行數,然後檢視是否需要退出迴圈。

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

但是我們還沒有完成檢查錯誤。當 ReadString 到達檔案末尾時,預期它會返回 io.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]的啟發。

在本章前面我們已經看過處理開啟、寫入和關閉檔案的示例。錯誤處理是存在的,但是接收範圍內的,因為操作可以封裝在諸如 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]樣式錯誤,

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,它會為你完成大部分工作。


引用:

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

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

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

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

2https://speakerdeck.com/campoy/understandi...

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

4https://medium.com/@matryer/line-of-sight-...

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

6https://dave.cheney.net/2014/10/17/functio...

7https://commandcenter.blogspot.com/2014/01...

8https://dave.cheney.net/2016/04/27/dont-ju...

9https://www.amazon.com/Philosophy-Software...

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

11http://www.gopl.io/


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

  • 如有翻譯有誤或者不理解的地方,請評論指正
  • 待更新的譯註之後會做進一步修改翻譯
  • 翻譯:田浩
  • 郵箱:llitfkitfk@gmail.com
本作品採用《CC 協議》,轉載必須註明作者和本文連結

做自己

相關文章