【譯】Go 專案開發裡最常犯的 10 個錯誤

kuibatian發表於2019-12-25

以下是一篇譯文,適當加入了我的一些觀點,如有出入之處歡迎與我溝通。
@劉付強

原文:https://itnext.io/the-top-10-most-common-m...

這個文章是列舉了下我目前在go專案看到的最常犯錯的10個錯誤,以下的順序並不重要哦。

1、未知型別的列舉值

我們來看一個簡單的例子:

type Status uint32

const (
    StatusOpen Status = iota
    StatusClosed
    StatusUnknown
)

這裡我們使用iota定義了一組列舉變數表示結果的狀態:


StatusOpen = 0
StatusClosed = 1
StatusUnknown = 2

現在,我們假設這個Status型別是一個JSON請求的一部分,它會被執行 marshall/ummarshall 操作。 我們可以設計如下的一個結構體:


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

之後,收到的請求像這樣:

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

到這裡還沒有什麼特殊的地方,status會被unmarshall到StatusOpen上,對嗎?

好了,我們在來看一個另外的請求資料,這次請求裡status變數沒有設定(先不糾結是什麼原因了):

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

在這個情況下,Request結構體裡的Status欄位就會被初始化為他的零值(是一個uint32型別的:0)。 因此,StatusOpen 代替了 StatusUnknow。(按道理來說,如果請求裡不傳遞status應該代表狀態未知才對哦)

最佳的實踐應該是給未知的值設定為列舉變數的0值:

type Status uint32

const (
    StatusUnknown Status = iota
    StatusOpen
    StatusClosed
)

這樣的話,如果JSON請求裡不傳遞status欄位,它就會別初始化為 StatusUnknow,這樣就符合我們的預期了。

2、基準化分析(Benchmarking)

要完成一個準確無誤的基礎測試是比較難的,有非常多的因素會影響測試結果。

一個比較常見的錯誤是會被編譯器優化給愚弄。 讓我們來看一個具體的示例吧,例子源於:teivah/bitvector庫

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

這個函式清除指定範圍的bit位,我們可能會這樣對它做基準測試:


func BenchmarkWrong(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        clear(1221892080809121, 10, 63)
    }
}

在這個基準測試裡,編譯器會注意到clear函式是一個葉子函式(即它沒有呼叫任何其他函式),因此編譯器會把這個函式作為行內函數。 一旦這個函式被內聯處理了,編譯器會發現這裡也沒有任何副作用。所以,clear函式呼叫就會被簡單的移除掉了,這會導致不準確的基準測試結果。

一個方法是可以設定一個全域性變數來儲存計算結果,如下程式碼所示:

var result uint64

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

這樣的話,編譯器就不太確定這個函式呼叫是否會有副作用,因此它就不會做內聯優化處理,基準測試得到的結果會比較正確了。

在我的環境下沒有復現出來編譯器內聯優化的差別 :( , 在介紹一種可以編碼編譯器對函式做內聯的黑科技吧:
在函式上方可以加上 //go:noinline 即可,詳細內容可以參考這個文章Go’s hidden #pragmas

3、指標!到處都是指標

使用值傳遞來傳遞一個變數,會建立一個變數的副本(拷貝變數的值),而使用指標來傳遞變數的話,只會拷貝一個記憶體地址。

因此,使用指標做為值傳遞總是更快的,是這樣嗎?

如果你是這麼認為的,請看下這個例子 pointer_test.go 為了方便大家直觀的閱讀,我把程式碼放過來吧:


package main

import (
    "encoding/json"
    "testing"
)

type foo struct {
    ID            string  `json:"_id"`
    Index         int     `json:"index"`
    GUID          string  `json:"guid"`
    IsActive      bool    `json:"isActive"`
    Balance       string  `json:"balance"`
    Picture       string  `json:"picture"`
    Age           int     `json:"age"`
    EyeColor      string  `json:"eyeColor"`
    Name          string  `json:"name"`
    Gender        string  `json:"gender"`
    Company       string  `json:"company"`
    Email         string  `json:"email"`
    Phone         string  `json:"phone"`
    Address       string  `json:"address"`
    About         string  `json:"about"`
    Registered    string  `json:"registered"`
    Latitude      float64 `json:"latitude"`
    Longitude     float64 `json:"longitude"`
    Greeting      string  `json:"greeting"`
    FavoriteFruit string  `json:"favoriteFruit"`
}

type bar struct {
    ID            string
    Index         int
    GUID          string
    IsActive      bool
    Balance       string
    Picture       string
    Age           int
    EyeColor      string
    Name          string
    Gender        string
    Company       string
    Email         string
    Phone         string
    Address       string
    About         string
    Registered    string
    Latitude      float64
    Longitude     float64
    Greeting      string
    FavoriteFruit string
}

var input foo

func init() {
    err := json.Unmarshal([]byte(`{
    "_id": "5d2f4fcf76c35513af00d47e",
    "index": 1,
    "guid": "ed687a14-590b-4d81-b0cb-ddaa857874ee",
    "isActive": true,
    "balance": "$3,837.19",
    "picture": "http://placehold.it/32x32",
    "age": 28,
    "eyeColor": "green",
    "name": "Rochelle Espinoza",
    "gender": "female",
    "company": "PARLEYNET",
    "email": "rochelleespinoza@parleynet.com",
    "phone": "+1 (969) 445-3766",
    "address": "956 Little Street, Jugtown, District Of Columbia, 6396",
    "about": "Excepteur exercitation labore ut cupidatat laboris mollit ad qui minim aliquip nostrud anim adipisicing est. Nisi sunt duis occaecat aliquip est irure Lorem irure nulla tempor sit sunt. Eiusmod laboris ex est velit minim ut cillum sunt laborum labore ad sunt.\r\n",
    "registered": "2016-03-20T12:07:25 -00:00",
    "latitude": 61.471517,
    "longitude": 54.01596,
    "greeting": "Hello, Rochelle Espinoza!You have 9 unread messages.",
    "favoriteFruit": "banana"
  }`), &input)
    if err != nil {
        panic(err)
    }
}

func byPointer(in *foo) *bar {
    return &bar{
        ID:            in.ID,
        Address:       in.Address,
        Email:         in.Email,
        Index:         in.Index,
        Name:          in.Name,
        About:         in.About,
        Age:           in.Age,
        Balance:       in.Balance,
        Company:       in.Company,
        EyeColor:      in.EyeColor,
        FavoriteFruit: in.FavoriteFruit,
        Gender:        in.Gender,
        Greeting:      in.Greeting,
        GUID:          in.GUID,
        IsActive:      in.IsActive,
        Latitude:      in.Latitude,
        Longitude:     in.Longitude,
        Phone:         in.Phone,
        Picture:       in.Picture,
        Registered:    in.Registered,
    }
}

func byValue(in foo) bar {
    return bar{
        ID:            in.ID,
        Address:       in.Address,
        Email:         in.Email,
        Index:         in.Index,
        Name:          in.Name,
        About:         in.About,
        Age:           in.Age,
        Balance:       in.Balance,
        Company:       in.Company,
        EyeColor:      in.EyeColor,
        FavoriteFruit: in.FavoriteFruit,
        Gender:        in.Gender,
        Greeting:      in.Greeting,
        GUID:          in.GUID,
        IsActive:      in.IsActive,
        Latitude:      in.Latitude,
        Longitude:     in.Longitude,
        Phone:         in.Phone,
        Picture:       in.Picture,
        Registered:    in.Registered,
    }
}

var pointerResult *bar

func BenchmarkByPointer(b *testing.B) {
    var r *bar
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        r = byPointer(&input)
    }
    pointerResult = r
}

var valueResult bar

func BenchmarkByValue(b *testing.B) {
    var r bar
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        r = byValue(input)
    }
    valueResult = r
}

在這個基準測試例子裡,對比了通過指標傳遞和值傳遞的方式來傳遞0.3KB大小的資料的區別。 0.3KB並不算太大的資料,但是這個和我們日常用到的資料結構是差不多的了(比較有代表性了)。

當我在我本地執行這個基準測試的時候,值傳遞比指標傳遞要快4倍。這個和結果是非常違反我們直覺的,是不是呀?

要解釋這個結果,就涉及到了Go語言是如何進行記憶體管理的了。我不能像 William Kennedy 解釋的那麼精彩和詳盡,但我來做下簡述吧。

一個變數可以在堆(heap)和棧(stack)上進行空間分配:

棧空間上包含了依託在一個goroutine是的 ongoing 的變數,一旦函式返回後,變數就會從棧裡彈出。
堆空間包含的是共享的變數(如全域性變數等)
讓我們用一個例子來檢查下我們是在哪裡返回的值:

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

如上程式碼所示, 一個 result的變數在當前的goroutine裡被建立出來,這個變數被壓入到當前的棧裡,一旦這個函式返回後,呼叫端可以收到一個該變數的一個拷貝,而這個變數本身會從棧裡彈出。而這個被彈出的變數直到它被其他的變數擦除之前仍然在記憶體裡存在,但是它不能被再次訪問的到了。

現在再來看一個使用指標的例子:

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

從上邊程式碼可以看到 result變數還是在當前的goroutine裡被建立,但是呼叫者會收到一個指標(這個變數地址的拷貝),如果result變數被棧彈出掉了,呼叫這個函式的呼叫者就再也不能訪問到它了。 在這種情景下,Go的編譯器會把這個變數逃逸到一個可以共享的記憶體空間,即堆(heap)。 這裡可以去了解下逃逸分析

另一種指標傳遞的情況,如下:


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

因為我們是在相同的goroutine裡呼叫f函式,變數 p 不需要被逃逸,它會被壓入到棧裡,子函式可以正常的訪問到它。

例如, 這是io.Reader的Read方法接受一個切片而不是返回一個切片的一個重要原因。如果是返回一個切片(這裡是個指標)就會造成這個變數會逃逸到堆空間。

type Reader interface {
        Read(p []byte) (n int, err error)
}

現在的問題是,為什麼棧是更快的呢? 這裡有2個原因:

在棧空間上的變數是自動回收的,因此它不需要垃圾回收(GC),如我們前邊說的,一個變數一旦它被建立時候會簡單的壓棧,而一旦它所在的函式返回的時候就會從棧裡彈出,這裡就不需要複雜的過程來進行未使用變數的回收等工作了
由於棧是隸屬於一個goroutine的,因此和堆上儲存的變數相對比,棧上儲存的變數就不需要去做同步處理了,這一點也是棧在效能上提升的很重要的。
總結來說,當我們建立了一個函式,我們預設的行為應該是使用值代替指標。 指標應該僅僅在我們希望去共享一個變數的時候才使用。

再補充一點,如果我們遇到了效能問題, 一個優化點是檢查一下是否有指標誤用的情況,通過以下的命令我們可以知道編譯器是否對變數進行了逃逸處理:

go build -gcflag "-m -m"

最後說一句,在我們日常的開發中,值傳遞是最好的選擇。

這裡是Jacob Walker 在GopherConn 2019的一個topic,想更多瞭解stack和heap的可以看一下。

4、中斷一個 for/switch 或 for/select

大家來猜一下,下面程式碼裡的f函式如果返回true的話,會發生什麼情況?

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

我們會走到break語句上,但是,這裡的break只會中斷switch語句,而不是for迴圈。

相同的問題,如下:


for {
  select {
  case <-ch:
  // Do something
  case <-ctx.Done():
    break
  }
}

這裡的break也只會中斷select語句,而不是for迴圈。

如果要去中斷for/switch或for/select 的迴圈,一個可行的方案是使用標籤break,比如這樣:

loop:
    for {
        select {
        case <-ch:
        // Do something
        case <-ctx.Done():
            break loop
        }
    }

5、錯誤管理

Go語言在對待錯誤處理上還是有些年輕,如果說這個是Go 2裡最期待的特性之一的話,這並不是個恰合。

當前的標準庫(Go1.13之前)僅僅提供了一些構造錯誤的函式,你可以到pkg/errors來看一下.

這個庫是一個遵守如下經驗法則的好方法,但它其實並不總是可以被遵守的:

一個錯誤應該只被處理一次,日誌記錄一個錯誤就是在處理一個錯誤,殷超一個錯誤要麼被日誌記錄要麼被傳播到下個階段去。

使用當前的標準庫,我們是很難遵守這個原則的,因為我們希望去增加一些上下文到錯誤上,並且可能是具有某種層級關係的形式。

讓我們看一個期望通過REST呼叫導致資料庫問題的示例:


 unable to server HTTP POST request for customer 1234
 |_ unable to insert customer contract abcd
     |_ unable to commit transaction
如果我們使用 pkg/erros庫,我們可能會這麼做:

func postHandler(customer Customer) Status {
    err := insert(customer.Contract)
    if err != nil {
        log.WithError(err).Errorf("unable to server 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")
}

最初的error(如果不是從第三方庫裡返回的)可以是用errors.New方法來建立。中間層 insert 方法包裹了這個錯誤,並且追加了更多的上下文到這個error上,最後,上層的呼叫方通過記錄日誌來處理了這個錯誤,每個層級要麼返回要麼處理了這個錯誤。

我們可能想知道引起錯誤的原因從而做進一步的處理,比如說去做一次重試操作。比如這樣的情況,我們有一個從外部第三方庫裡引入的db包,用來處理資料庫的訪問,這個庫可能返回一個臨時的error,叫做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 server 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
}

以上程式碼裡使用的errors.Cause 是由第三方包提供的 github.com/pkg/errors.

我看到一個常見錯誤是部分的使用了pkg/errors包,例如用以下的方式進行錯誤檢查:


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

在這個例子裡,如果**db.DBError是被包裹的,這裡的switch永遠不會進入此分支,即retry永遠不會被觸發。

6、切片初始化

有時候我們知道切片的最終長度是多少,比如這個場景,假如我們想要把一個切片Foo轉換為另一個切片Bar,這就意味著著2個切片將會是同樣的長度。

我經常看到用這樣的方式進行切片的初始化:


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

切片並不是一個多麼神奇的資料結構,在底層,切片實現了一個增長策略,當在當前切片沒有足夠的空間的時候會自動增長,在這個情況發生時,一個新的切片變數會被自動建立出來(這個陣列將會有更大的容量),之後原切片的所有資料會被拷貝到新的切片上。

現在,讓我們想象一下,如果我們需要多次的對我們包含成千上萬元素的切片變數Foo執行重複的增長操作,插入操作的時間複雜度仍然是O(1),但實際上這個會嚴重的影響程式的效能。

因此,如果我們知道最終的長度,我們可以這麼來搞:

  • 初始化時候指定一個預定義的長度 golang func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars }
  • 或者初始化它時候給指定0長度和預定義的容量 golang 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可以包含:

  • deadline(最終期限), 它可以指一個期限(如250ms)或是一個日期(如2019-01-08 01:00:00),這期限或日期是表示當它到達的時候我們必須取消一個正在執行的活動(一個I/O請求,等待一個channel的輸入等)
  • cancelation signal(取消訊號,基本上是 <-chan struct{} ),這裡的行為和之前的是相似的,一旦我們收到一個訊號,我們必須停止正在執行的活動。比如,假如我們收到2個請求,一個是插入一些資料,另一個是取消第一個請求(2個請求是不相關的),這個可以通過在第一個請求呼叫裡使用一個可以取消的context,一旦我們收到第二個請求的時候就可以呼叫這個context傳送訊號,進而讓第一個請求停止執行。

一個key/value對的列表(都是 interface{}型別)
有2點需要補充下:

  • 第一,context是可以組合的,因此我們可以有一個即包含了deadline,也包含了一個key/value列表的context。
  • 第二,多個goroutine可以共享一個相同的context,因此,一個取消訊號可能會導致多個活動停止執行。
  • 言歸正傳,這裡是一個我見過的具體錯誤示例。

一個Go應用是基於 urfave/cli (這是go語言裡一個非常好用的建立命令列應用的第三方庫),一旦啟動,開發人員就會繼承一種應用的context,這就意味著當應用停止的時候,這個庫會傳送一個取消訊號。

我遇到的情況是,在我呼叫一個gRPC服務的時候,這個context被直接傳遞過去了,這並不是我們所期望的。(因為這個context從cli庫裡繼承來的,裡邊有不符合預期的內容,會引起其他問題的) 相反,我們希望指示gRPC庫: 請在程式停止或者100ms以後取消這個請求。為此,我們可以簡單的建立一個組合的context,如果parent(父級)是cli庫應用的名字,我們可以簡單的這樣做:

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

上下文的理解並不複雜,在我看來,context是go語言的最佳特性之一。

延伸閱讀:

Understanding the context package in golang

gRPC and Deadlines

8、不使用 -race 選項

我經常見到的一個錯誤是在測試go應用的時候沒有帶 -race 選項。

正如這篇報告所描述的,雖然Go是“旨在使併發程式設計變得更容易,更不易出錯”,但實際上我們仍然會遭遇很多併發的問題。

顯然,Go的競爭檢查(race detector)無法解決每一個併發問題,然而它依然是一個有價值的工具,我們應當確保在做測試的時候(go test)始終使用它。

延伸閱讀

Does the Go race detector catch all data race bugs?

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的呼叫端了(client),如:


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、goroutines和迴圈變數

最後一個常見錯誤是使用迴圈變數的方式建立goroutine。

先來猜測下一些程式碼的輸出結果是什麼?


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

無論如何都會按序輸出:1,2,3? 大錯特錯了

在這個例子裡,每個goroutine都會共享相同的迴圈變數,所以它會輸出3,3,3(大概率會這樣)

有2種方案來解決這個問題,第一種是把變數傳遞到閉包裡(內部的函式):


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

第二種方式是在for迴圈裡(作用域)建立另一個變數:


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

i := i 這樣的賦值看起來有些奇怪,但這個真的是非常有效的。 進入迴圈體力就意味著進入了一個新的作用域,因此 i:=i建立了一個新的變數例項,名字也是i. 當然為了提高可讀性,我們也可以使用其他的名稱。

延伸閱讀

CommonMistakes

相關文章