天天看點

fread讀結構體傳回值是0無錯誤_Go 項目開發中 10 個最常見的錯誤

『就要學習 Go 語言』系列 -- 第 33 篇分享好文

四哥翻譯水準有限,如有翻譯或了解錯誤,煩請幫忙指出,感謝!

原文如下:

這篇文章列舉了我在 Go 項目中遇見的最常見錯誤,排序先後不重要。

1.未知的枚舉值

來看個簡單的例子:

type Status uint32const (    StatusOpen Status = iota    StatusClosed    StatusUnknown)
           

我們使用 iota 建立了枚舉值,結果如下:

StatusOpen = 0StatusClosed = 1StatusUnknown = 2
           

現在,我們假設 Status 類型是 JSON 請求的一部分,将會被 marshalled/unmarshalled。設計的結構體如下:

type Request struct {    ID        int    `json:"Id"`    Timestamp int    `json:"Timestamp"`    Status    Status `json:"Status"`}
           

接收到請求的結果如下:

{  "Id": 1234,  "Timestamp": 1563362390,  "Status": 0}
           

這沒什麼特殊的,Status 會被解析成 StatusOpen,沒錯吧?

好,我們再請求一次,得到的結果沒有設定 Status(不管什麼原因):

{  "Id": 1235,  "Timestamp": 1563362390}
           

這個例子中,Request 結構體的 Status 字段會初始化為零值(對于 uint32 類型來說是 0)。是以,對應的是 StatusOpen 而不是 StatusUnknown。

最好的做法就是将未知的值設定為枚舉值 0:

type Status uint32const (    StatusUnknown Status = iota    StatusOpen    StatusClosed)
           

這樣,即使 Status 不是請求 JSON 的一部分,就像我們所期望的那樣,它也會初始化為 StatusUnknown。

2.基準測試

想要準确無誤地進行基準測試很難,因為有太多因素會對結果産生影響。

最常見的錯誤之一就是代碼會被編譯器優化,我們來看一個具體的例子,這個例子選自 teivah/bitvector[1] 庫:

func clear(n uint64, i, j uint8) uint64 {    return (math.MaxUint64<}
           

在這個基準測試中,編譯器注意到 clear() 函數是一個葉子函數(沒有調用其他函數),會将它優化成内聯函數。一旦 clear() 函數被内聯,編譯器注意到它沒有任何的副作用,是以編譯器會将它移除,進而導緻錯誤的結果。

一種可行的方案是将結果作為全局變量,就像下面這樣:

var result uint64func BenchmarkCorrect(b *testing.B) {    var r uint64    for i := 0; i < b.N; i++ {        r = clear(1221892080809121, 10, 63)    }    result = r}
           

這樣,編譯器不知道調用是否會産生副作用。是以,基準測試結果是正确的。

拓展閱讀:High Performance Go Workshop[2]

3.指針!到處都是指針!

變量傳值會建立變量的副本;如果傳指針,複制的則是記憶體位址。

是以,傳指針通常會更快,是這樣嗎?

如果你相信這是真的,請看下這個例子[3]。這是一個基準測試,對一個 0.3 KB 的結構體分别做傳值和傳指針的對比。0.3 KB 不算大,與我們每天接觸的結構體的大小相差不遠。

當在我本地環境執行基準測試,傳值要比傳指針快 4 倍,是不是有點違反直覺?

這個答案的解釋與 Go 的記憶體管理有關。我無法像 William Kennedy 一樣出色地解釋,但不妨礙我們來總結一下:

一個變量可以配置設定在堆上或者棧上:

•棧中存儲目前 goroutine 正在使用的變量,一旦函數傳回,變量便會從棧中彈出;•堆存儲共享變量(全局變量等);

我們來看一個傳回值的例子:

func getFooValue() foo {    var result foo    // Do something    return result}
           

示例中,目前 goroutine 建立了變量 result,被推入目前的棧中。函數傳回的時候,函數調用者将會接收到變量的副本。而變量 result 本身被目前 goroutine 彈出棧。但它依然存儲在記憶體裡(但再也不能通路),直到被其他變量覆寫。

來看一個傳指針的例子:

func getFooPointer() *foo {    var result foo    // Do something    return &result}
           

目前 goroutine 建立了變量 result,但函數調用者将接收到一個指針(變量位址的副本),如果變量 result 被彈出棧,函數調用者将再也不能通路它。

在這種情況下,Go 編譯器會将變量 result 逃逸到一個變量可以共享的地方:堆。

來看下傳指針的另外一種情況:

func main()  {    p := &foo{}    f(p)}
           

因為我們在同一個 goroutine 裡調用 f() 函數,變量 p 不會被逃逸。隻是被推入棧中,子函數任然可以通路它。

這也是 io.Reader 接口中 Read 方法接收切片而不是傳回切片的直接原因,如果傳回一個切片,它将會逃逸到堆中。

為什麼棧會如此之快?有兩個主要的原因:

•棧不需要垃圾回收機制。我們說過,變量建立的時候便會被推入棧中,函數傳回時會被彈出棧。不需要一個複雜的過程回收未使用的變量。•棧屬于一個 goroutine,與堆相比,将變量存儲在棧中不需要同步機制。這也可以提高性能。

總之,當我們建立函數時,預設應該是使用值而不是指針。隻有在我們想要共享變量時才應使用指針。

當我們遭遇性能問題時,在一些特定情況下,可能的優化政策是檢查下指針,這擷取對我們有所幫助。通過使用下面這條指令,可以知道編譯器何時将變量轉義到堆:

go build -gcflags "-m -m"
           

再次強調下,對于大多數日常用例來說,值傳遞是最合适的。

拓展閱讀:Language Mechanics On Stacks And Pointers[4]

4.使用 break 跳出 for/switch 或者 for/select

下面的例子中,如果函數 f() 傳回 true 将會發生什麼:

for {  switch f() {  case true:    break  case false:    // Do something  }}
           

将會走 break 語句,跳出 switch 語句而不是終止 for 循環。

下面是同樣的問題:

for {  select {  case   // Do something  case     break  }}
           

break 隻會跳出 select 語句,而不是終止 for 循環。

上述問題的一種可行方法是使用标記,就像下面這樣:

loop:    for {        select {        case         // Do something        case             break loop        }    }
           

5.錯誤管理

Go 在錯誤處理方面仍然有待提高,這也是 Go2.0 最令人期待的特性之一。

目前标準庫(Go1.13 之前)隻提供建構錯誤的功能,如果檢視 pkg/errors[5] 包,會發現對于錯誤處理的規則,它并不完全遵守:

一個錯誤隻被處理一次,記錄錯誤就是在處理錯誤。是以,錯誤要麼被記錄要麼被收集。

使用目前的标準庫,很難遵守以上規則,因為總是希望對錯誤加一些上下文資訊或者讓其具有某種層次結構。

讓我們來看一個例子,希望能通過 REST 調用檢視一個資料庫的問題:

unable to serve HTTP POST request for customer 1234 |_ unable to insert customer contract abcd     |_ unable to commit transaction
           

如果我們使用 pkg/errors 包,可以這樣做:

func postHandler(customer Customer) Status {    err := insert(customer.Contract)    if err != nil {        log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID)        return Status{ok: false}    }    return Status{ok: true}}func insert(contract Contract) error {    err := dbQuery(contract)    if err != nil {        return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)    }    return nil}func dbQuery(contract Contract) error {    // Do something then fail    return errors.New("unable to commit transaction")}
           

剛開始的時候,錯誤由 errors.New 函數建立;并在函數 insert() 中,通過 errors.Wrapf() 添加一些上下文資訊包裝次錯誤;最後,在父級函數中通過 log 包來記錄錯誤。每個層級都會傳回或處理錯誤。

有時候我們想通過檢查錯誤原因來判斷是否應該重試。假設有一個來自外部庫的 db 包,用來處理資料庫通路。該庫可能會傳回一個名為 db.DBError 的臨時性錯誤。我們需要檢查錯誤原因來決定是否需要重試:

func postHandler(customer Customer) Status {    err := insert(customer.Contract)    if err != nil {        switch errors.Cause(err).(type) {        default:            log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID)            return Status{ok: false}        case *db.DBError:            return retry(customer)        }    }    return Status{ok: true}}func insert(contract Contract) error {    err := db.dbQuery(contract)    if err != nil {        return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID)    }    return nil}
           

這樣,通過使用 pkg/errors 包的 errors.Cause() 函數實作重試。

我見過的使用 pkg/errors 包處理錯誤的常見錯誤方式如下:

switch err.(type) {default:  log.WithError(err).Errorf("unable to serve HTTP POST request for customer %s", customer.ID)  return Status{ok: false}case *db.DBError:  return retry(customer)}
           

在這個例子中,如果 db.DBError 被包裝過,将永遠不會觸發 retry() 函數。

拓展閱讀:Don’t just check errors, handle them gracefully[6]

6.初始化切片

一些情況下,我們知道一個切片的最終長度。例如,假設我們将一個切片 轉化成一個切片 Bar,很明顯我們知道這兩個切片有相同的長度。

我經常看到使用下面這種方式對切片進行初始化:

var bars []Barbars := make([]Bar, 0)
           

切片并不是一個神奇的結構。在底層,切片實作了自動增長政策,如果目前切片沒有足夠容量可用時候會自動增長。這種情況下,會自動建立一個新的更大長度[7]的數組,原有的數組元素會拷貝到新數組。

現在,讓我們想象下,當 []Foo 有成千上萬個元素時,是不是需要多次重複自增長操作?插入操作的時間複雜度仍然是 O(1),但實際上這個會嚴重的影響程式的性能。

是以,如果我們知道切片的最終長度,可以選擇下面兩種做法之一:

•使用預定義長度初始化;

func convert(foos []Foo) []Bar {    bars := make([]Bar, len(foos))    for i, foo := range foos {        bars[i] = fooToBar(foo)    }    return bars}
           

•使用 0 長度和預定義的容量對其進行初始化;

func convert(foos []Foo) []Bar {    bars := make([]Bar, 0, len(foos))    for _, foo := range foos {        bars = append(bars, fooToBar(foo))    }    return bars}
           

到底哪種才是最佳的方式呢?第一種會稍稍快一點。然而,你可以更喜歡第二種,因為它更加有一緻性:不管我們是否知道初始大小,都可以使用 append 在切片的末尾添加元素。

7.Context 管理

context.Context 經常被開發者誤解,根據官方文檔描述:

A Context carries a deadline, a cancelation signal, and other values across API boundaries.

這個描述非常通用,通用的足以讓很多人對為什麼要使用和如何去使用 context 感到非常疑惑。

讓我們較長的描述下,一個 context 可以包含:

•一個截止時間。意味着,一個時間段(例如 250 ms)結束之後或者到了某一個時間點(例如 2019-01-08 01:00:00),我們必須取消正在進行的操作(例如:I/O 請求、等待一個協程輸入等)。•一個取消信号(基本上都是 •一個 key/value 對的清單(都是 interface{}類型)。

補充兩點:第一,上下文是可組合的。是以,我們可以有一個包含截止時間和 key/value 清單的上下文;第二,多個 goroutine 可以共享同一 context,是以取消信号可能會終止多個活動。

言歸正傳,下面是我見過的一個具體錯誤。

一個 Go 應用基于 urfave/cli[8](一個非常好用的可建立指令行引用的第三方包)。一旦引用啟動,開發人員将繼承一種應用的 context,這意味着當應用停止之後,庫将使用次 context 發送取消信号。

我遇到的情況是,在我調用 gRPC 服務的時候,這個 context 被直接傳遞過去了。這并不是我們想要的。

相反,我們希望訓示 gRPC庫:請在程式停止或者 100ms 以後取消這個請求。為此,我們可以簡單的建立一個組合的 context,如果 parent 是應用 context 的名字,我們可以簡單的這樣做:

ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond)response, err := grpcClient.Send(ctx, request)
           

context 了解起來并不複雜,而且我認為它是 Go 語言的最佳特性之一。

拓展閱讀:

1 Understanding the context package in golang[9] 

2 gRPC and Deadlines[10]

8.不使用 -race 選項

測試 Go 應用的時候,我經常碰到的一個錯誤是沒有 -race 選項。正如在這份報告[11]裡描述的一樣,盡管 Go 語言的設計旨在“使并發程式設計變得更容易且不容易出錯”,我們依然會遇到各種并發問題。

顯然,Go 的競争檢查(race detector)無法解決每一個并發問題。不過,它依然是有使用價值的工具,在測試 Go 應用的時候,應當開啟它。

拓展閱讀:Does the Go race detector catch all data race bugs?[12]

9.使用檔案名作為輸入參數

另一個常見的錯誤是将一個檔案名作為參數傳遞。

假設我們要編寫一個函數,實作計算檔案裡空行的數目的功能。最常見的做法就是像下面這樣:

func count(filename string) (int, error) {    file, err := os.Open(filename)    if err != nil {        return 0, errors.Wrapf(err, "unable to open %s", filename)    }    defer file.Close()    scanner := bufio.NewScanner(file)    count := 0    for scanner.Scan() {        if scanner.Text() == "" {            count++        }    }    return count, nil}
           

檔案名作為參數傳遞,然後在函數裡打開它,接着實作其他的邏輯,應當是這樣嗎?

現在,假設我們想要基于這個函數實作單元測試,測試普通檔案、空檔案或者不同編碼類型的檔案等。這會變得難以管理。

再比如,如果是從一個 HTTP Body 接收内容去實作相同的邏輯(計算空行數目), 那就不得不為此再建立一個函數。

Go 語言裡面有兩個非常棒的抽象函數:io.Reader 和 io.Writer。我們可以傳遞一個抽象方法 io.Reader 代替傳檔案名,這就可以使得函數更具通用性了。

不管輸入源是檔案、HTTP 包體還是一個位元組 Buffer,都不重要,我們都可以使用同一個讀取方法實作相同功能。

在我們的例子中,我們甚至可以通過逐行讀取的方式将輸入緩存起來,是以,我們可以使用 bufio.Reader 和 ReadLine 方法:

func count(reader *bufio.Reader) (int, error) {    count := 0    for {        line, _, err := reader.ReadLine()        if err != nil {            switch err {            default:                return 0, errors.Wrapf(err, "unable to read")            case io.EOF:                return count, nil            }        }        if len(line) == 0 {            count++        }    }}
           

至于打開檔案的操作就可以交給 count() 函數調用者去實作:

file, err := os.Open(filename)if err != nil {  return errors.Wrapf(err, "unable to open %s", filename)}defer file.Close()count, err := count(bufio.NewReader(file))
           

通過第二種方法,不管實際的資料來源是什麼,都可以通過調用該函數來實作相同功能。與此同時,這也有助于我們的單元測試,因為我們可以簡單地從字元串建立 bufio.Reader 即可:

count, err := count(bufio.NewReader(strings.NewReader("input")))
           

10.協程和循環變量

最後一個常見的錯誤是使用循環變量的方式建立 goroutine。

下面的例子輸出什麼?

ints := []int{1, 2, 3}for _, i := range ints {  go func() {    fmt.Printf("%v\n", i)  }()}
           

正如所希望的一樣,按順序輸出 1 2 3,不過真的是這樣嗎?

在這個例子中,每一個 goroutine 共享相同的變量,是以會輸出 3 3 3(最有可能)。

這個問題有兩個解決辦法,第一種是将變量 i 的值傳遞給閉包(内部的函數):

ints := []int{1, 2, 3}for _, i := range ints {  go func(i int) {    fmt.Printf("%v\n", i)  }(i)}
           

第二種解決辦法是在 for-range 循環體内建立一個臨時變量:

ints := []int{1, 2, 3}for _, i := range ints {  i := i  go func() {    fmt.Printf("%v\n", i)  }()}
           

這行代碼 i := i 看起來有些奇怪,但完全是有效的。進入循環體裡就是進入另一個變量作用域,是以 i:=i 建立了一個新的、名稱相同的變量 i。當然為了提高可讀性,我們也可以使用其他的變量名。

拓展閱讀:Common Mistakes:Using goroutines on loop iterator variables[13]

References

[1]

 teivah/bitvector: https://github.com/teivah/bitvector/

[2]

 High Performance Go Workshop: https://dave.cheney.net/high-performance-go-workshop/dotgo-paris.html?source=post_page-----4b79d4f6cd65----------------------#watch_out_for_compiler_optimisations

[3]

 例子: https://gist.github.com/teivah/a32a8e9039314a48f03538f3f9535537

[4]

 Language Mechanics On Stacks And Pointers: https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-stacks-and-pointers.html

[5]

 pkg/errors: https://github.com/pkg/errors

[6]

 Don’t just check errors, handle them gracefully: https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully?source=post_page-----4b79d4f6cd65----------------------

[7]

 更大長度: https://www.darkcoding.net/software/go-how-slices-grow/

[8]

 urfave/cli: https://github.com/urfave/cli

[9]

 Understanding the context package in golang: http://p.agnihotry.com/post/understanding_the_context_package_in_golang/?source=post_page-----4b79d4f6cd65----------------------

[10]

 gRPC and Deadlines: https://grpc.io/blog/deadlines/?source=post_page-----4b79d4f6cd65----------------------

[11]

 這份報告: https://blog.acolyer.org/2019/05/17/understanding-real-world-concurrency-bugs-in-go/

[12]

 Does the Go race detector catch all data race bugs?: https://medium.com/@val_deleplace/does-the-race-detector-catch-all-data-races-1afed51d57fb

[13]

 Common Mistakes:Using goroutines on loop iterator variables: https://github.com/golang/go/wiki/CommonMistakes?source=post_page-----4b79d4f6cd65----------------------#using-goroutines-on-loop-iterator-variables

推薦閱讀:

阿裡雲進階技術專家探讨 Go 的錯誤處理

Gin架構系列 - 自定義錯誤處理

如果我的文章對你有所幫助,點贊、轉發都是一種支援!

fread讀結構體傳回值是0無錯誤_Go 項目開發中 10 個最常見的錯誤
fread讀結構體傳回值是0無錯誤_Go 項目開發中 10 個最常見的錯誤
給個[在看],是對我最大的支援