Go開發中的十大常見陷阱[譯]

原文: The Top 10 Most Common Mistakes I’ve Seen in Go Projectshtml

做者: Teiva Harsanyigit

譯者: Simon Magithub

我在Go開發中遇到的十大常見錯誤。順序可有可無。golang

未知的枚舉值

讓咱們看一個簡單的例子:數據庫

type Status uint32

const (
    StatusOpen Status = iota
    StatusClosed
    StatusUnknown
)

在這裏,咱們使用iota建立了一個枚舉,其結果以下:編程

StatusOpen = 0
StatusClosed = 1
StatusUnknown = 2

如今,讓咱們假設這個Status類型是JSON請求的一部分,將被marshalled/unmarshalledjson

咱們設計瞭如下結構:網絡

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

而後,接收這樣的請求:數據結構

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

這裏沒有什麼特別的,狀態會被unmarshalledStatusOpen閉包

然而,讓咱們以另外一個未設置狀態值的請求爲例:

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

在這種狀況下,請求結構的Status字段將初始化爲它的零值(對於uint32類型:0),所以結果將是StatusOpen而不是StatusUnknown

那麼最好的作法是將枚舉的未知值設置爲0

type Status uint32

const (
    StatusUnknown Status = iota
    StatusOpen
    StatusClosed
)

若是狀態不是JSON請求的一部分,它將被初始化爲StatusUnknown,這才符合咱們的指望。

自動優化的基準測試

基準測試須要考慮不少因素的,才能獲得正確的測試結果。

一個常見的錯誤是測試代碼無形間被編譯器所優化

下面是teivah/bitvector庫中的一個例子:

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

此函數清除給定範圍內的位。爲了測試它,可能以下這樣作:

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

在這個基準測試中,clear不調用任何其餘函數,沒有反作用。因此編譯器將會把clear優化成內聯函數。一旦內聯,將會致使不許確的測試結果。

一個解決方案是將函數結果設置爲全局變量,以下所示:

var result uint64

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

如此一來,編譯器將不知道clear是否會產生反作用。

所以,不會將clear優化成內聯函數。

延伸閱讀

High Performance Go Workshop

被轉移的指針

在函數調用中,按值傳遞的變量將建立該變量的副本,而經過指針傳遞只會傳遞該變量的內存地址。

那麼,指針傳遞會比按值傳遞更快嗎?請看一下這個例子

我在本地環境上模擬了0.3KB的數據,而後分別測試了按值傳遞和指針傳遞的速度。

結果顯示:按值傳遞比指針傳遞快4倍以上,這很違背直覺。

測試結果與Go中如何管理內存有關。我雖然不能像威廉·肯尼迪那樣出色地解釋它,但讓我試着總結一下。

譯者注開始

做者沒有說明Go內存的基本存儲方式,譯者補充一下。

  1. 下面是來自Go語言聖經的介紹:

    一個goroutine會以一個很小的棧開始其生命週期,通常只須要2KB。

    一個goroutine的棧,和操做系統線程同樣,會保存其活躍或掛起的函數調用的本地變量,可是和OS線程不太同樣的是,一個goroutine的棧大小並非固定的;棧的大小會根據須要動態地伸縮。

    而goroutine的棧的最大值有1GB,比傳統的固定大小的線程棧要大得多,儘管通常狀況下,大多goroutine都不須要這麼大的棧。

  2. 譯者本身的理解:

    • 棧:每一個Goruntine開始的時候都有獨立的棧來存儲數據。(Goruntine分爲主Goruntine和其餘Goruntine,差別就在於起始棧的大小
    • 堆: 而須要被多個Goruntine共享的數據,存儲在堆上面。

譯者注結束

衆所周知,能夠在上分配變量。

  • 棧儲存當前Goroutine的正在使用的變量(譯者注: 可理解爲局部變量)。一旦函數返回,變量就會從棧中彈出。
  • 堆儲存共享變量(全局變量等)。

讓咱們看一個簡單的例子,返回單一的值:

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

當調用函數時,result變量會在當前Goruntine棧建立,當函數返回時,會傳遞給接收者一份值的拷貝。而result變量自身會從當前Goruntine棧出棧。

雖然它仍然存在於內存中,但它不能再被訪問。而且還有可能被其餘數據變量所擦除。

如今,在看一個返回指針的例子:

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

當調用函數時,result變量會在當前Goruntine棧建立,當函數返回時,會傳遞給接收者一個指針(變量地址的副本)。若是result變量從當前Goruntine棧出棧,則接收者將沒法再訪問它。(譯者注:此狀況稱爲「內存逃逸」)

在這個場景中,Go編譯器將把result變量轉義到一個能夠共享變量的地方:

不過,傳遞指針是另外一種狀況。例如:

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

由於咱們在同一個Goroutine中調用f,因此p變量不須要轉義。它只是被推送到堆棧,子功能能夠訪問它。(譯者注:不須要其餘Goruntine共享的變量就存儲在棧上便可)

好比,io.Reader中的Read方法簽名,接收切片參數,將內容讀取到切片中,返回讀取的字節數。而不是返回讀取後的切片。(譯者注:若是返回切片,會將切片轉義到堆中。)

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

爲何棧如此之快? 主要有兩個緣由:

  1. 堆棧不須要垃圾收集器。就像咱們說的,變量一旦建立就會被入棧,一旦函數返回就會從出棧。不須要一個複雜的進程來回收未使用的變量。
  2. 儲存變量不須要考慮同步。堆屬於一個Goroutine,所以與在堆上存儲變量相比,存儲變量不須要同步。

總之,當建立一個函數時,咱們的默認行爲應該是使用值而不是指針。只有在咱們想要共享變量時才應使用指針。

若是咱們遇到性能問題,可使用go build -gcflags "-m -m"命令,來顯示編譯器將變量轉義到堆的具體操做。

再次重申,對於大多很多天經常使用例來講,值傳遞是最合適的。

延伸閱讀

  1. Language Mechanics On Stacks And Pointers
  2. Understanding Allocations: the Stack and the Heap - GopherCon SG 2019

出乎意料的break

若是f返回true,下面的例子中會發生什麼?

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

咱們將調用break語句。然而,將會breakswitch語句,而不是for循環。

一樣的問題:

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

breakselect語句有關,與for循環無關。

breakfor/switch或for/select的一種解決方案是使用帶標籤的break,以下所示:

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

缺失上下文的錯誤

Go在錯誤處理方面仍然有待提升,以致於如今錯誤處理是Go2中最使人期待的需求。

當前的標準庫(在Go 1.13以前)只提供error的構造函數,天然而然就會缺失其餘信息。

讓咱們看一下pkg/errors庫中錯誤處理的思想:

An error should be handled only once. Logging an error is handling an error. So an error should either be logged or propagated.

(譯:錯誤應該只處理一次。記錄log 錯誤就是在處理錯誤。因此,錯誤應該記錄或者傳播)

對於當前的標準庫,很難作到這一點,由於咱們但願向錯誤中添加一些上下文信息,使其具備層次結構。

例如: 所指望的REST調用致使數據庫問題的示例:

unable to server 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 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可使用error.New建立。中間層insert對此錯誤添加更多上下文信息。最終經過log錯誤來處理錯誤。每一個級別要麼返回錯誤,要麼處理錯誤。

咱們可能還想檢查錯誤緣由來判讀是否應該重試。假設咱們有一個來自外部庫的db包來處理數據庫訪問。 該庫可能會返回一個名爲db.DBError的臨時錯誤。要肯定是否須要重試,咱們必須檢查錯誤緣由:

使用pkg/errors中提供的errors.Cause能夠判斷錯誤緣由。

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
}

我見過的一個常見錯誤是部分使用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.DBErrorwrapped,它將永遠不會執行retry

延伸閱讀

Don’t just check errors, handle them gracefully

正在擴容的切片

有時,咱們知道切片的最終長度。假設咱們想把Foo切片轉換成Bar切片,這意味着這兩個切片的長度是同樣的。

我常常看到切片如下面的方式初始化:

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

切片不是一個神奇的數據結構,若是沒有更多可用空間,它會進行雙倍擴容。在這種狀況下,會自動建立一個切片(容量更大),並複製其中的元素。

若是想容納上千個元素,想象一下,咱們須要擴容多少次。雖然插入的時間複雜度是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
    }

毫無規範的Context

context.Context 常常被誤用。 根據官方文檔:

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

這種描述很是籠統,以致於讓一些人對使用它感到困惑。

讓咱們試着詳細描述一下。Context能夠包含:

  • A deadline(最後期限)。它意味着到期以後(250ms以後或者一個指定的日期),咱們必須中止正在進行的操做(I/O請求,等待的channel輸入,等等)。
  • A cancelation signal(取消信號)。一旦咱們收到信號,咱們必須中止正在進行的活動。例如,假設咱們收到兩個請求:一個用來插入一些數據,另外一個用來取消第一個請求。這能夠經過在第一個調用中使用cancelable上下文來實現,一旦咱們得到第二個請求,這個上下文就會被取消。
  • A list of key/value (鍵/值列表)均基於interface{}類型。

值得一提的是,Context是能夠組合的。例如,咱們能夠繼承一個帶有截止日期和鍵/值列表的Context。此外,多個goroutines能夠共享相同的Context,取消一個Context可能會中止多個活動。

回到咱們的主題,舉一個我經歷的例子。

一個基於urfave/cli若是您不知道,這是一個很好的庫,能夠在Go中建立命令行應用程序)建立的Go應用。一旦開始,程序就會繼承父級的Context。這意味着當應用程序中止時,將使用此Context發送取消信號。

我經歷的是,這個Context是在調用gRPC時直接傳遞的,這不是我想作的。相反,我想當應用程序中止時或無操做100毫秒後,發送取消請求。

爲此,能夠簡單地建立一個組合的Context。若是parent是父級的Context的名稱(由urfave/cli建立),那麼組合操做以下:

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

Context並不複雜,在我看來,可謂是 Go 的最佳特性之一。

延伸閱讀

  1. Understanding the context package in golang
  2. gRPC and Deadlines

被遺忘的-race參數

我常常看到的一個錯誤是在沒有-race參數的狀況下測試 Go 應用程序。

正如本報告所述,雖然Go「旨在使併發編程更容易,更不容易出錯」,但咱們仍然遇到不少併發問題。

顯然,Go 競爭檢測器沒法解決每個併發問題。可是,它仍有很大價值,咱們應該在測試應用程序時始終啓用它。

延伸閱讀

Does the Go race detector catch all data race bugs?

更完美的封裝

另外一個常見錯誤是將文件名傳遞給函數。

假設咱們實現一個函數來計算文件中的空行數。最初的實現是這樣的:

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
}

filename 做爲給定的參數,而後咱們打開該文件,再實現讀空白行的邏輯,嗯,沒有問題。

假設咱們但願在此函數之上實現單元測試,並使用普通文件,空文件,具備不一樣編碼類型的文件等進行測試。代碼很容易變得很是難以維護。

此外,若是咱們想對於HTTP Body實現相同的邏輯,將不得不爲此建立另外一個函數。

Go 設計了兩個很棒的接口:io.Readerio.Writer (譯者注:常見IO 命令行,文件,網絡等)

因此能夠傳遞一個抽象數據源的io.Reader,而不是傳遞文件名。

仔細想想統計的只是文件嗎?一個HTTP正文?字節緩衝區?

答案並不重要,重要的是不管Reader讀取的是什麼類型的數據,咱們都會使用相同的Read方法。

在咱們的例子中,甚至能夠緩衝輸入以逐行讀取它(使用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))

不管數據源如何,均可以調用count。而且,還將促進單元測試,由於能夠從字符串建立一個bufio.Reader,這大大提升了效率。

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

Goruntines與循環變量

我見過的最後一個常見錯誤是使用 Goroutines 和循環變量。

如下示例將會輸出什麼?

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循環範圍內建立另外一個變量:

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

i := i可能看起來有點奇怪,但它徹底有效。

由於處於循環中意味着處於另外一個做用域內,因此i := i至關於建立了另外一個名爲i的變量實例。

固然,爲了便於閱讀,最好使用不一樣的變量名稱。

延伸閱讀

Using goroutines on loop iterator variables

你還想提到其餘常見的錯誤嗎?請隨意分享,繼續討論;)

相關文章
相關標籤/搜索