都 2021 了,你還忘記關閉 http body?

看了看日曆,現在已經是 2021 年了,偶爾還是能看到有人在發諸如 《http body 未關閉導致線上事故》,或者 《sql.Rows 未關閉半夜驚魂》類的文章,令人有一種夢迴 2015 的感覺。

在這個 Go 的靜態分析工具已經強到爛大街的時代,寫這些文章除了暴露這些人所在的公司基礎設施比較差,代碼質量低以外,並不能體現出什麼其它的意思了。畢竟哪怕是不懂怎麼讀源碼,這樣的問題你 Google 搜一下也知道是怎麼回事了。

特別是有些人還掛着大公司的 title,讓人更加不能理解了。下面是簡單的靜態分析工具的科普,希望給那些還在水深火熱的 Gopher 們送點解藥。

何謂靜態分析

靜態分析是通過掃描並解析用戶代碼,尋找代碼中的潛在 bug 的一種手段。

靜態分析一般會集成在項目上線的 CI 流程中,如果分析過程找到了 bug,會直接阻斷上線,避免有問題的代碼被部署到線上系統。從而在部署早期發現並修正潛在的問題。

社區常見 linter

時至今日,社區已經有了豐富的 linter 資源供我們使用,本文會挑出一些常見 linter 進行說明。

go lint

go lint 是官方出的 linter,是 Go 語言最早期的 linter 了,其可以檢查:

但這幾年社區的 linter 蓬勃發展,所以這個項目也被官方 deprecated 掉了。其主要功能被另外一個 linter:revive[^1] 完全繼承了。

go vet

go vet 也是官方提供的靜態分析工具,其內置了鎖拷貝檢查、循環變量捕獲問題、printf 參數不匹配等工具。

比如新手老手都很容易犯的 loop capture 錯誤:

package main

func main() {
 var a = map[int]int {1 : 1, 2: 3}
 var b = map[int]*int{}
 for k, r := range a {
  go func() {
   b[k] = &r
  }()
 }
}

go vet 會直接把你罵醒:

~/test git:master ❯❯❯ go vet ./clo.go
# command-line-arguments
./clo.go:8:6: loop variable k captured by func literal
./clo.go:8:12: loop variable r captured by func literal

執行 go tool vet help 可以看到 go vet 已經內置的一些 linter。

~ ❯❯❯ go tool vet help
vet is a tool for static analysis of Go programs.

vet examines Go source code and reports suspicious constructs,
such as Printf calls whose arguments do not align with the format
string. It uses heuristics that do not guarantee all reports are
genuine problems, but it can find errors not caught by the compilers.

Registered analyzers:

    asmdecl      report mismatches between assembly files and Go declarations
    assign       check for useless assignments
    atomic       check for common mistakes using the sync/atomic package
    bools        check for common mistakes involving boolean operators
    buildtag     check that +build tags are well-formed and correctly located
    cgocall      detect some violations of the cgo pointer passing rules
    composites   check for unkeyed composite literals
    copylocks    check for locks erroneously passed by value
    errorsas     report passing non-pointer or non-error values to errors.As
    httpresponse check for mistakes using HTTP responses
    loopclosure  check references to loop variables from within nested functions
    lostcancel   check cancel func returned by context.WithCancel is called
    nilfunc      check for useless comparisons between functions and nil
    printf       check consistency of Printf format strings and arguments
    shift        check for shifts that equal or exceed the width of the integer
    stdmethods   check signature of methods of well-known interfaces
    structtag    check that struct field tags conform to reflect.StructTag.Get
    tests        check for common mistaken usages of tests and examples
    unmarshal    report passing non-pointer or non-interface values to unmarshal
    unreachable  check for unreachable code
    unsafeptr    check for invalid conversions of uintptr to unsafe.Pointer
    unusedresult check for unused results of calls to some functions

默認情況下這些 linter 都是會跑的,當前很多 IDE 在代碼修改時會自動執行 go vet,所以我們在寫代碼的時候一般就能發現這些錯了。

但 go vet 還是應該集成到線上流程中,因爲有些程序員的下限實在太低。

errcheck

Go 語言中的大多數函數返回字段中都是有 error 的:

func sayhello(wr http.ResponseWriter, r *http.Request) {
 io.WriteString(wr, "hello")
}

func main() {
 http.HandleFunc("/", sayhello)
 http.ListenAndServe(":1314", nil) // 這裏返回的 err 沒有處理
}

這個例子中,我們沒有處理 http.ListenAndServe 函數返回的 error 信息,這會導致我們的程序在啓動時發生靜默失敗。

程序員往往會基於過往經驗,對當前的場景產生過度自信,從而忽略掉一些常見函數的返回錯誤,這樣的編程習慣經常爲我們帶來意外的線上事故。例如,規矩的寫法是下面這樣的:

data, err := getDataFromRPC()
if err != nil {
 return nil, err
}

// do business logic
age := data.age

而自信的程序員可能會寫成這樣:

data, _ := getDataFromRPC()

// do business logic
age := data.age

如果底層 RPC 邏輯出錯,上層的 data 是個空指針也是很正常的,如果底層函數返回的 err 非空時,我們不應該對其它字段做任何的假設。這裏 data 完全有可能是個空指針,造成用戶程序 panic。

errcheck 會強制我們在代碼中檢查並處理 err。

gocyclo

gocyclo 主要用來檢查函數的圈複雜度。圈複雜度可以參考下面的定義:

圈複雜度 (Cyclomatic complexity) 是一種代碼複雜度的衡量標準,在 1976 年由 Thomas J. McCabe, Sr. 提出。在軟件測試的概念裏,圈複雜度用來衡量一個模塊判定結構的複雜程度,數量上表現爲線性無關的路徑條數,即合理的預防錯誤所需測試的最少路徑條數。圈複雜度大說明程序代碼可能質量低且難於測試和維護,根據經驗,程序的可能錯誤和高的圈複雜度有着很大關係。

看定義較爲複雜但計算還是比較簡單的,我們可以認爲:

在大多數語言中,若函數的圈複雜度超過了 10,那麼我們就認爲該函數較爲複雜,需要做拆解或重構。部分場景可以使用表驅動的方式進行重構。

由於在 Go 語言中,我們使用 if err != nil 來處理錯誤,所以在一個函數中出現多個 if err != nil 是比較正常的,因此 Go 中函數複雜度的閾值可以稍微調高一些,15 是較爲合適的值。

下面是在個人項目 elasticsql 中執行 gocyclo 的結果,輸出 top 10 複雜的函數:

~/g/s/g/c/elasticsql git:master ❯❯❯ gocyclo -top 10  ./
23 elasticsql handleSelectWhere select_handler.go:289:1
16 elasticsql handleSelectWhereComparisonExpr select_handler.go:220:1
16 elasticsql handleSelect select_handler.go:11:1
9 elasticsql handleGroupByFuncExprDateHisto select_agg_handler.go:82:1
9 elasticsql handleGroupByFuncExprDateRange select_agg_handler.go:154:1
8 elasticsql buildComparisonExprRightStr select_handler.go:188:1
7 elasticsql TestSupported select_test.go:80:1
7 elasticsql Convert main.go:28:1
7 elasticsql handleGroupByFuncExpr select_agg_handler.go:215:1
6 elasticsql handleSelectWhereOrExpr select_handler.go:157:1

bodyclose

使用 bodyclose[^2] 可以幫我們檢查在使用 HTTP 標準庫時忘記關閉 http body 導致連接一直被佔用的問題。

resp, err := http.Get("http://example.com/") // Wrong case
if err != nil {
 // handle error
}
body, err := ioutil.ReadAll(resp.Body)

像上面這樣的例子是不對的,使用標準庫很容易犯這樣的錯。bodyclose 可以直接檢查出這個問題:

# command-line-arguments
./httpclient.go:10:23: response body must be closed

所以必須要把 Body 關閉:

resp, err := http.Get("http://example.com/")
if err != nil {
 // handle error
}
defer resp.Body.Close() // OK
body, err := ioutil.ReadAll(resp.Body)

HTTP 標準庫的 API 設計的不太好,這個問題更好的避免方法是公司內部將 HTTP client 封裝爲 SDK,防止用戶寫出這樣不 Close HTTP body 的代碼。

sqlrows

與 HTTP 庫設計類似,我們在面向數據庫編程時,也會碰到 sql.Rows 忘記關閉的問題,導致連接大量被佔用。sqlrows[^3] 這個 linter 能幫我們避免這個問題,先來看看錯誤的寫法:

rows, err := db.QueryContext(ctx, "SELECT * FROM users")
if err != nil {
    return nil, err
}

for rows.Next() {
 err = rows.Scan(...)
 if err != nil {
  return nil, err // NG: this return will not release a connection.
 }
}

正確的寫法需要在使用完後關閉 sql.Rows:

rows, err := db.QueryContext(ctx, "SELECT * FROM users")
defer rows.Close() // NG: using rows before checking for errors
if err != nil {
    return nil, err
}

與 HTTP 同理,公司內也應該將 DB 查詢封裝爲合理的 SDK,不要讓業務使用標準庫中的 API,避免上述錯誤發生。

funlen

funlen[^4] 和 gocyclo 類似,但是這兩個 linter 對代碼複雜度的視角不太相同,gocyclo 更多關注函數中的邏輯分支,而 funlen 則重點關注函數的長度。默認函數超過 60 行和 40 條語句時,該 linter 即會報警。

linter 集成工具

一個一個去社區裏找 linter 來拼搭效率太低,當前社區裏已經有了較好的集成工具,早期是 gometalinter,後來性能更好,功能更全的 golangci-lint 逐漸取而代之。目前 golangci-lint 是 Go 社區的絕對主流 linter。

golangci-lint

golangci-lint[^5] 能夠通過配置來 enable 很多 linter,基本主流的都包含在內了。

在本節開頭講到的所有 linter 都可以在 golangci-lint 中進行配置,

使用也較爲簡單,只要在項目目錄執行 golangci-lint run . 即可。

~/g/s/g/c/elasticsql git:master ❯❯❯ golangci-lint run .
main.go:36:9: S1034: assigning the result of this type assertion to a variable (switch stmt := stmt.(type)) could eliminate type assertions in switch cases (gosimple)
 switch stmt.(type) {
        ^
main.go:38:34: S1034(related information): could eliminate this type assertion (gosimple)
  dsl, table, err = handleSelect(stmt.(*sqlparser.Select))
                                 ^
main.go:40:23: S1034(related information): could eliminate this type assertion (gosimple)
  return handleUpdate(stmt.(*sqlparser.Update))
                      ^
main.go:42:23: S1034(related information): could eliminate this type assertion (gosimple)
  return handleInsert(stmt.(*sqlparser.Insert))
                      ^
select_handler.go:192:9: S1034: assigning the result of this type assertion to a variable (switch expr := expr.(type)) could eliminate type assertions in switch cases (gosimple)
 switch expr.(type) {

參考資料

[1] https://revive.run/

[2] https://github.com/timakin/bodyclose

[3] https://github.com/gostaticanalysis/sqlrows

[4] https://github.com/ultraware/funlen

[5] https://github.com/golangci/golangci-lint

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