『每周譯Go』那些年我使用Go語言犯的錯
在這篇文章里,我主要分享六年來用Go編程過程中所犯過的一些錯誤。
init 函數(shù)
在 Go 中,你可以在單個package或文件中定義多個特殊的函數(shù) init,從 Effective Go 中,我們知道
init函數(shù)是在包中所有變量初始化之后才會調(diào)用,而這些變量又是在所有導(dǎo)入的包初始化之后才初始化。
盡管在大多數(shù)情況下使用 init 函數(shù)是一種確定的行為,但你還是想減少使用它的次數(shù)。首先,init 函數(shù)最典型的應(yīng)用就是初始化全局變量,那么你可以通過減少全局變量的數(shù)量來達(dá)到減少使用 init 函數(shù)的目的。
使用 init函數(shù)之前最好要三思而行,主要的原因就是你不能從代碼中調(diào)用 init 函數(shù)。另外一個原因是,雖然具有確定性,但是很難預(yù)測 init 函數(shù)的執(zhí)行順序。這個順序依賴你導(dǎo)入的包和源碼的文件名。移除一個package或者重命名一個文件都會影響這個順序。
使用 init函數(shù)最好的例子就是初始化一個計算成本很大的查找表。
**我在哪里后悔使用了 init 函數(shù)呢?**曾經(jīng)我用 cobra 去構(gòu)建一個CLI工具時,用 init 函數(shù)去定義命令行的數(shù)據(jù)結(jié)構(gòu)和 flags?,F(xiàn)在我知道時完全沒必要了。順便說一句,Go 1.16昨天發(fā)布了,帶來了一些好消息。
設(shè)置
GODEBUG環(huán)境變量為 inittrace=1,現(xiàn)在會導(dǎo)致運(yùn)行時會為每個包的init打印一行標(biāo)準(zhǔn)錯誤信息,總結(jié)其執(zhí)行時間和內(nèi)存分配。
常量
下面這個變量的值在代碼庫任何位置都沒有更改過:
// Version of the application
var Version = "master"
因此我想當(dāng)然地把它看作一個常量。突然把 var改成 const。就這樣我不知不覺地破壞了我應(yīng)用程序的更新通知系統(tǒng),因為我在構(gòu)建時修改了這個常量。
$ go build \
-ldflags="-X 'github.com/henvic/wedeploycli/defaults.Version=$NEW_RELEASE_VERSION' \
-X 'github.com/henvic/wedeploycli/defaults.Build=$BUILD_COMMIT' \
-X 'github.com/henvic/wedeploycli/defaults.BuildTime=$BUILD_TIME'"
原來...常量是不能修改的。
空指針引用
破壞了更新通知系統(tǒng)還不算完,我忘記檢查指針是否為空,導(dǎo)致破壞了 update 命令。還好我在發(fā)布后的測試中發(fā)現(xiàn)了這個問題,花了幾分鐘就修復(fù)了,所以影響也不大。
注釋和文檔
有一次我讀到一句話,當(dāng)代碼不能表達(dá)你的意圖的時候,你就要寫注釋了,因為代碼時間長了會過時和不同步。你以為通過恰當(dāng)命名的變量、結(jié)構(gòu)體、接口、和函數(shù)的代碼來表達(dá)你的推理和想法,這就夠了。但請你不要低估注釋的力量!我以前就是這種心態(tài),下意識地低估了注釋的力量。還好,后來寫了幾年的 Go 。領(lǐng)略到良好注釋的價值。如果你對什么是合理的注釋或者要不要寫注釋存在疑問。我建議你讀一讀標(biāo)準(zhǔn)庫的源碼,看看它們是怎么寫的。Go代碼評審中的注釋部分 也是一個不錯的參考。
文檔同樣也很關(guān)鍵。你如果想要用 godoc 給公共 API生成文檔。你只要遵循最小化和不張揚(yáng)評論模式 。該模式使用起來相對簡單,即使不是你喜歡的風(fēng)格,你也能獲得一致性的體驗。
**記?。?*代碼只寫一次,但是會被讀多次。最好多花點時間寫代碼注釋,讓別人和未來的自己都能理解,而不是急著寫代碼,幾個月或者幾年后要花更多的時間去理解它。
創(chuàng)建的包太多了
I didn't need to write 133 packages.
I didn't need to write 133 packages.
I didn't need to write 133 packages.
I didn't need to write 133 packages.
現(xiàn)在想想有點羞恥,我在我的 CLI 工具代碼里給需要的 58個命令每一個都創(chuàng)建了一個包。這58個目錄每個目錄都至少有一個文件(每個文件都是一個包)。我本可以用一個包包含10-15個中等大小的文件。其中 command/list 包 犯的錯誤最嚴(yán)重。
./command/list
├── instances
│ └── instances.go (85 lines of code)
├── list.go (121 lines of code)
├── projects
│ └── projects.go (73 lines of code)
└── services
└── services.go (109 lines of code)
Total: 388 lines of code
下面這些指標(biāo)可能會過多地分解你的代碼
只有幾十行代碼的小包或者文件 由于存在多個包同名,導(dǎo)包的時候使用自定義標(biāo)志符
那我們應(yīng)該怎么解決呢?command 包里面地list.go 文件最好少于350行代碼。作為代碼組織的優(yōu)秀范例,我建議看一下 net/http 包的代碼。在包含十幾個文件的單個包中,實現(xiàn)了 Go HTTP客戶端和服務(wù)端。
導(dǎo)出名
在 Go 中,如果一個名字是以大寫字母開頭,那么這個名稱就稱作導(dǎo)出名。換句話說,就是其他包的代碼可以直接訪問。
下面是 fmt 包的函數(shù):
func Printf(format string, a ...interface{}) (n int, err error)
func Println(a ...interface{}) (n int, err error)
func newPrinter() *pp
只有前兩個函數(shù)可以被 fmt 包外面的調(diào)用,如果你嘗試在外面調(diào)用 newPrinter ,你會得到一個編譯時錯誤
./prog.go:8:2: cannot refer to unexported name fmt.newPrinter
./prog.go:8:2: undefined: fmt.newPrinter
正如我在上一節(jié)中所提到的,如果我有更少的包,我將避免需要導(dǎo)出很多外部變量,從而大大簡化依賴關(guān)系圖。
內(nèi)部包
現(xiàn)在,Go 有另外一個很棒的特性來劃分你的代碼:內(nèi)部包
./a/b/c/internal/d/e/f 現(xiàn)在只要用 ./a/b/c 就可以導(dǎo)入了
使用內(nèi)部包可以保護(hù)你的代碼,除非你需要公共的 API。這對于那些不打算公開發(fā)布的私人項目非常有用。設(shè)定清晰的界限和期望,只暴露你打算支持的用例。如果你的 API 用戶面很小的話,你可以很自由地進(jìn)行內(nèi)部代碼變更,而不用擔(dān)心升級了一個大版本后,導(dǎo)致后端不兼容和一堆bug。
你可能對該觀點有所顧慮,因為你的代碼在整個團(tuán)隊項目中至關(guān)重要。那么是時候來回顧一下下面這句 Go 諺語:
一點點復(fù)制總比一點點依賴好——Rob Pike,Go諺語 ,2015.11.18 于 Gopherfest 。
你可以使用 apidiff (一種檢測API變更后兼容性的工具)來檢測你的 API 連接。也可以看看2019年 GopherCon 上 Jonathan Amsterdam的 檢測 API 變更后兼容性 的演講(文字版:https://about.sourcegraph.com/go/gophercon-2019-detecting-incompatible-api-changes/)
全局變量和配置信息
你還沒開始閱讀之前,可以看看我以前的關(guān)于環(huán)境變量、配置、密鑰和全局變量的博客。
你希望你的代碼看起來簡潔,但是配置信息可能會妨礙你。一種可能的快速解決辦法就是使用全局變量全局傳遞,對嗎?當(dāng)然如果你不介意引入并發(fā)安全或者不介意無法進(jìn)行并行測試的話。這會讓事情變得糟糕,而且重構(gòu)的成本越來越大。注意如果你一次性把事情做對,后面就不會發(fā)生問題了。例如,初始化對象時,可以顯示地傳遞配置信息。
如果你需要在你不需要了解的層之間傳遞參數(shù)的話,使用 context 非常有用。
// Params for the metrics system.
type Params struct {
// Hostname of your service.
Hostname string
// Verbose flag.
Verbose bool
// ...
}
// paramsContextKey is the key for the params context.
// Using struct{} here to guarantee there are no conflicts with keys defined outside of this package.
type paramsContextKey struct{}
// Context returns a copy of the parent context with the given params.
func (p *Params) Context(ctx context.Context) context.Context {
return context.WithValue(ctx, paramsContextKey{}, p)
}
// FromContext gets params from context.
func FromContext(ctx context.Context) (*Params, error) {
if p, ok := ctx.Value(paramsContextKey{}).(*Params); ok {
return p, nil
}
return nil, errors.New("metrics system params not found")
}
可以看看 Go Playground 里面另外一個例子,注意避坑。不要使用 context 直接傳遞具體配置信息。只有通過上下層傳遞一些一目了然東西,才可以使用 context 。所以使用 context 之前 最好好好思考下使用的最佳方式。
導(dǎo)入和生成大小
將人類可讀的源代碼轉(zhuǎn)換成一系列機(jī)器操作指令需要大量的工作?,F(xiàn)在有很多策略來解決這個問題,一些語言(例如 Javascript、Python、Tcl)是解釋型語言。也就是說代碼在解釋器(比如瀏覽器或者運(yùn)行時)執(zhí)行期間就被翻譯成機(jī)器碼。Go 是一種靜態(tài)編譯語言,意味著代碼要提前編譯。靜態(tài)編譯語言的優(yōu)點就是執(zhí)行速度快。像及時編譯這樣的東西結(jié)合了解釋型和編譯型的概念,說到這兒就有點離題了。無論何時運(yùn)行 go build,經(jīng)過代碼解析,編譯成機(jī)器碼,鏈接之后,都會創(chuàng)建一個包含你所有程序和依賴,可以被機(jī)器執(zhí)行的文件。
該構(gòu)建過程很容易輸入幾K文本文件,然后經(jīng)過機(jī)器碼轉(zhuǎn)換和鏈接依賴就變成了幾M的二進(jìn)制文件。盡管 Go 是非常高效和簡潔,但除非 Gopher 會魔法,不然也少不了這個消耗。
回到我的例子,我在開發(fā)一個 CLI 工具 有一個 deploy 命令涉及到多次調(diào)用 git。我們使用了 git 作為傳輸層和智能緩存層。缺點就是我們工具需要系統(tǒng)范圍的依賴。遇到幾個 git bug 后,我決定用 Go 實現(xiàn)一個 git (go-git)作為替代。我增加了一個 experimental flag后,就開始用純 Go 庫實現(xiàn)了。但是,我忘記評估后面這個實現(xiàn)帶來的影響。它讓文件的大小成倍增長,從不到 4M 增加到了 9M 。雖然影響不是很大,但也大幅度增加了文件的大小。我后悔沒有意識到這一點,如果我注意到了,我會用構(gòu)建標(biāo)簽來隱藏后面這種實現(xiàn)方法,一直到準(zhǔn)備好進(jìn)行A/B測試。
并發(fā)和流
我沒有學(xué)習(xí)過 Go的并發(fā)編程,導(dǎo)致我犯了很多新手錯誤:往多個 goroutines或者 線程中寫入導(dǎo)致文本混亂。
我甚至在終端中給文本動畫寫包的時候也犯過這個錯誤。如果你發(fā)現(xiàn)你也存在這個問題,你可以用 互斥鎖來設(shè)置 哪個 goroutine 什么時候可以寫,更復(fù)雜的情況可以使用 channel 來打印單個通道。另外,注意下如果輸出順序很重要,你可能需要同步打印標(biāo)準(zhǔn)錯誤 (os.Stderr) 或者標(biāo)準(zhǔn)輸出(os.Stdout)。
Tip:Go 有一個很棒的數(shù)據(jù)競爭檢測器,你可以通過 在 go test或者 go build 命令加上 -race 標(biāo)志來運(yùn)行你的測試用例或程序。
Sockets 和 WebSocket
曾經(jīng)我解決了一個有意思的問題,就是實現(xiàn) 基于 WebSocket 的 SSH 功能時候,我們已經(jīng)在后端系統(tǒng)上使用了 socket.io 協(xié)議。為了簡單起見,我們希望將該功能也用于此協(xié)議,為此,我拉取并大量修改了現(xiàn)有的 Go socket.io 庫代碼。我花了繁重的工作去理解這個協(xié)議的實現(xiàn),然后再讓其能滿足我們的需求。由于沒有正式的 socket.io 的規(guī)范,我甚至不得不對協(xié)議進(jìn)行逆向工程。
最后看起來能夠按預(yù)期運(yùn)行的時候,我們向服務(wù)器發(fā)送了一個文件,并從連接中運(yùn)行一個簡單的測試用例:$ cat hamlet.txt 并比較輸出。令人意外的時候,有些短語出現(xiàn)了亂序。錯誤的原因是,每接受到一條消息,庫代碼就創(chuàng)建一個新的 goroutine。
解決辦法很簡單:當(dāng)前循環(huán)中刪除 go 關(guān)鍵字,讓其在同一線程內(nèi)調(diào)用。你通常希望在最后一刻創(chuàng)建 goroutine。如果考慮到性能,你可能會馬上創(chuàng)建一個。對我來說,這是一個典型的例子,里面有帶案例的文檔可以向用戶解釋他們需要什么。另外,里面還有不同的方式來定義阻塞和非阻塞程序。
感謝閱讀。
