Go 語言 “十誡”[譯]

本文翻譯自 John Arundel 的《Ten commandments of Go》[1]。全文如下:

作爲一名全職的 Go 語言作家 [2] 和老師 [3],我花了很多時間和學生們一起,幫助他們寫出更清晰、更好、更有用的 Go 程序。我發現,我給他們的建議可以歸納總結爲一套通用原則,在這裏我將這些原則分享給大家。

1. 你應該是無聊的

Go 社區喜歡共識 (consensus)。比如:Go 源代碼有一個由 gofmt 強制執行的統一的代碼格式規範。同樣,無論你要解決什麼問題,通常都有一個標準的、類似於 Go 行事風格的方法來解決。有時它是標準的方式,因爲它是最好的方式,但通常它只是最好的方式,因爲它是標準的方式

要抵制住創意、時尚或(最糟糕的是)聰明的誘惑,這些不是 Go 的行事風格。Go 行事風格的代碼簡單、無聊,通常相當囉嗦,而且最重要的是顯式的風格 (由於這個原因,有些人把 Go 稱爲面向顯式(obviousness-oriented) 風格的編程語言)。

當有疑問時,請遵循最小驚喜原則 [4]。爭取做到一目瞭然 [5]。要直截了當,要簡單,要顯式,要無聊。

這並不是說在軟件工程層面沒有展示令人歎爲觀止的優雅和風格的空間了;當然有。但那是在設計層面上,而不是單個代碼行。代碼並不重要,它應該以被隨時替換。重要的是程序。

2. 你應該以測試爲先

在 Go 中,一個常見的錯誤是先寫了一些函數 (比如:GetDataFromAPI),然後在考慮如何測試它時不知所措。函數通過網絡進行了真正的 API 調用,它向終端打印東西,它寫磁盤文件了,這是一個可怕的的不可測試性的坑。

不要先寫那個函數,而是先寫一個測試 (比如:TestGetDataFromAPI)。如何寫這樣一個測試呢?它必須爲函數的調用提供一個本地的 TLS 測試服務器,所以你需要一種方法來注入這種依賴。它要寫數據到 io.Writer,你同樣需要爲此注入一個模擬外部世界的本地依賴,比如:bytes.Buffer。

現在,當你開始編寫 GetDataFromAPI 函數時,一切都將變得很容易了。它的所有依賴關係都被注入,所以它的業務邏輯與它與外部世界的交互和監聽方式完全脫鉤。

HTTP handler 也是如此。一個 HTTP handler 的唯一工作是解析請求中的數據,將其傳遞給某個業務邏輯函數來計算結果,並將結果格式化到 ResponseWriter。這幾乎不需要測試,所以你的大部分測試將在業務邏輯函數本身,而不是 handler。我們知道 HTTP 的工作原理。

3. 你應該測試行爲,而不是函數

如果你想知道如何在不實際調用 API 的情況下測試這個函數,那麼答案很簡單:"不要測試這個函數"。

你需要測試的不是一些函數,而是一些行爲。例如,一個可能是 "給定一些用戶輸入,我可以正確地組合 URL 並以正確的參數調用 API。" 另一個可能是 "給定 API 返回的一些 JSON 數據,我可以正確地將其解包到某個 Go 結構體中。"

當你沿着這樣的思路考量問題的解決方法的時候,寫測試就容易多了:你可以想象一些這類函數,它們每個函數都會接受一些輸入,併產生一些輸出,並且很容易給它們編寫單元測試。有些事情它們是不會做的,例如進行任何 HTTP 調用。

同樣,當你試圖實現 "數據可以持久地存儲在數據庫中並從數據庫中檢索" 這樣的行爲時,你可以將其分解成更小的、更可測試的行爲。例如,"給定一個 Go 結構體,我可以正確地生成 SQL 查詢,並將其內容存儲到 Postgres 表中",或者 "給定一個對象,我可以正確地將結果解析到 Go 結構體切片中"。不需要 mock 數據庫,不需要真正的數據庫!

4. 你不應制造文書工作

所有的程序都會在某一點上涉及到一些繁瑣的、不可避免的數據倒換重組活動;我們可以把所有這類活動歸入文書工作的範疇。對程序員來說,唯一的問題是,這些文書工作在 API 邊界的哪一邊?

如果是放在用戶側,那就意味着用戶必須編寫大量的代碼來爲你的庫準備文書工作,然後再編寫大量的代碼來將結果解壓成有用的格式。

相反 (將文書工作放在 API 實現側),寫零文書工作的庫,可以在一行中調用:

game.Run()

不要讓用戶調用一個構造函數來獲取某個對象,然後再基於這個對象進行方法調用。那就是文書工作。只要讓一切在他們直接調用時發生就可以了。如果有可配置的設置,請設置合理的默認值,這樣用戶根本不用考慮,除非他們因爲某些原因需要覆蓋默認值。功能選項 (functional option)[6] 是一個很好的模式。

這是另一個先寫測試的好理由,如果你寫的 API 中創造了文書工作,那麼在測試時你將不得不自己做所有的文書工作,以便使用你自己的庫。如果這被證明是笨拙、囉嗦和耗時的,可以考慮將這些文書工作移到 API 邊界內。

5. 你不應該殺死程序

你的庫沒有權利終止用戶的程序。不要在你的包中調用像 os.Exit、log.Fatal、panic 這樣的函數,這不是你能決定的。相反,如果你遇到了不可恢復 (recover) 的錯誤,將它們返回給調用者。

爲什麼不呢?因爲它迫使任何想使用你的庫的人去寫代碼,不管 panic 是否真的被觸發。出於同樣的原因,你永遠不應該使用會引起 panic 的第三方庫,因爲一旦你用了,你就需要 recover 它們。

所以你千萬不要顯式調用 (這些可以殺死程序的函數),但是隱式調用呢?你所做的任何操作,在某些情況下可能會 panic(比如:索引一個空的片斷,寫入一個空 map,類型斷言失敗)都應該先檢查一下是否正常,如果不正常就返回一個錯誤。

6. 你不要泄露資源

對於一個打算永遠運行而不崩潰或出錯的程序來說,對其的要求要比對單次命令行工具要嚴格一些。例如,想想太空探測器:在關鍵時刻意外重啓制導系統,可能會讓價值數十億美元的飛行器駛向星系間的虛空。對於負責的軟件工程師來說,這很可能會導致一場沒有咖啡的面談,讓人有些不舒服。

我們不是都在爲太空器寫軟件,但我們應該像太空工程師一樣思考。自然,我們的程序應該永遠不會崩潰(最壞的情況下,它們應該優雅地退化,並提出退出過程的詳實信息),但它們也需要是可持續的。這意味着不能泄露內存、goroutines、文件句柄或任何其他稀缺資源。

每當你有一些可泄漏的資源時,當你知道你已經成功獲得它的那一刻,你應該想着釋放它。無論函數如何退出或何時退出,保證將其清理掉,我們可以用 Go 帶給我們的禮物:defer[7]。

任何時候啓動一個 goroutine,你都應該知道它是如何結束的。啓動它的同一個函數應該負責停止它。使用 waitgroups 或者 errgroups,並且總是向一個可能被取消的函數傳遞一個 context.Context。

7. 你不應該限制用戶的選擇

我們如何編寫友好、靈活、強大、易用的庫呢?一種方法是避免不必要地限制用戶對庫的操作。一個常見的 Gopherism(Go 主義) 是 "接受接口,返回結構"。但爲什麼這是個好建議呢?

假設你有一個函數,接受類似於一個 * os.File 的參數 ,並向其寫入數據。也許被寫入的東西是一個文件並不重要,具體來說,它只需要是一個 "你可以寫入的東西"(這個想法由標準庫接口,如 io.Writer 表達)。有很多這樣的東西:網絡連接、HTTP response writer、bytes.Buffer 等等。

通過強迫用戶傳遞給你一個文件,你限制了他們對你的庫的使用。通過接受一個接口 (如 io.Writer) 來代替,你將打開新的可能性,包括尚未被創造的類型,後續它們仍然可以滿足(接口) ,可以與你的代碼 io.Writer 一起工作。

爲什麼要 "返回結構體"?好吧,假設你返回一些接口類型。這極大地限制了用戶對該值的操作(他們能做的就是調用其上的方法)。即使他們事實上可以用底層的具體類型做他們需要做的事情,他們也必須先用類型斷言來解包它。換句話說,這就是額外的文書工作 (應該避免)。

另一種避免限制用戶選擇的方法是不要使用只有當前 Go 版本纔有的功能。相反,考慮至少支持最近兩個主要的 Go 版本:有些人不能立即升級。

8. 你應該設定邊界

讓每一個軟件組件在自己的內部是完整的、有能力的;不要讓它的內部關注點暴露出來,越過它的邊界滲入到其他組件中。這一點對於與其他人的代碼的邊界來說,是雙倍的。

例如,假設你的庫調用了某個 API。這個 API 會有自己的模式和自己的詞彙,反映自己的關注點和自己的領域語言。

一旦你讓一點外來數據在你的程序內部自由運行,它很快就會到處亂跑。你的其他包都需要導入這些外來類型,這很煩人,而且代碼將會有一股糟糕的味道。

相反,你的 airlock 函數應該做兩件事:它應該將外來數據轉化爲你自己的內部格式,而且應該確保數據是有效的。現在,你的所有其他代碼只需要處理你的內部類型,它不需要擔心數據是否會出錯、丟失或不完整。

另一種執行良好邊界的方法是始終檢查錯誤。如果你不這樣做,無效的數據可能會泄露進來。

9. 你不應該在內部使用接口

一個接口值說:"我不知道這個東西到底是什麼,但也許我知道有些事情我可以用它來做。" 這在 Go 程序中是一種超級不方便的值,因爲我們不能做任何沒有被接口指定的事情。

對於空接口 (interface{}) 來說,這也是雙倍的,因爲我們對它一無所知。因此,根據定義,如果你有一個空的接口值,你需要把它類型化爲具體的東西才能使用它。

在處理任意數據(也就是在運行時類型或模式未知的數據)時,不得不使用它們是很常見的,比如無處不在的 map[string]interface{}[8]。但是,我們應該儘快使用 airlock 將這一團無知轉化爲某種具體類型的有用的 Go 值。

特別是,不要用 interface{} 類型值來模擬泛型(Go 有泛型 [9])。不要寫一個函數,接受一些可以是七種具體類型之一的值,然後對其進行類型轉換,爲該類型找到合適的操作。相反,寫七個函數,每個具體類型一個。

不要僅因爲你可以在測試中注入 mock,就創建一個公共的接口,這是一個錯誤。創建一個真正的用戶在調用你的函數之前必須實現的接口,這違反了 “無文書工作原則”。不要在一般情況下寫 mock;Go 不適合這種風格的測試。(當 Go 中的某些東西很困難時,這通常是你做錯事的標誌。)

10. 你不要盲目地遵從誡命,而要自己思考

人們說:"告訴我們什麼是最佳做法",彷彿有一本小祕籍,裏面有任何技術或組織問題的正確答案。(是有的,但不要說出去。我們不希望每個人都成爲顧問)。

小心任何看似清楚、明確、簡單地告訴你在某種情況下該怎麼做的建議。它不會適用於每一種情況,在適用的地方,它都需要告誡,需要細微的差別,需要澄清。

每個人都希望得到的是不需要真正理解就能應用的建議。但這樣的建議比它能帶來的幫助更危險:它能讓你走到橋的一半,然後你會發現橋是紙做的,而且剛開始下雨。

非常感謝比爾 - 肯尼迪(Bill Kennedy)[10] 和伊南克 - 古姆斯(Inanc Gumus)[11] 對這篇文章的有益評論。

參考資料

[1]  《Ten commandments of Go》: https://bitfieldconsulting.com/golang/commandments

[2]  作家: https://bitfieldconsulting.com/books

[3]  老師: https://bitfieldconsulting.com/golang/learn

[4]  最小驚喜原則: https://en.wikipedia.org/wiki/Principle_of_least_astonishment

[5]  一目瞭然: https://www.youtube.com/watch?v=8TLiGHJTlig

[6]  功能選項 (functional option): https://www.imooc.com/read/87/article/2424

[7]  Go 帶給我們的禮物:defer: https://www.imooc.com/read/87/article/2421

[8]  map[string]interface{}: https://bitfieldconsulting.com/golang/map-string-interface

[9]  Go 有泛型: https://bitfieldconsulting.com/golang/map-string-interface

[10]  比爾 - 肯尼迪(Bill Kennedy): https://www.ardanlabs.com/

[11]  伊南克 - 古姆斯(Inanc Gumus): https://medium.com/@inanc

[12]  改善 Go 語⾔編程質量的 50 個有效實踐: https://www.imooc.com/read/87

[13]  Kubernetes 實戰:高可用集羣搭建、配置、運維與應用: https://coding.imooc.com/class/284.html

[14]  我愛發短信: https://51smspush.com/

[15]  鏈接地址: https://m.do.co/c/bff6eed92687

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/YcmNN6DwQiI7b0Pj0Co-vg