博客 / 詳情

返回

golang中經常會犯的一些錯誤

0.1、索引

https://waterflow.link/articles/1664080524986

1、未知的枚舉值

我們現在定義一個類型是unit32的Status,他可以作為枚舉類型,我們定義了3種狀態

type Status uint32

const (
    StatusOpen Status = iota
    StatusClosed
    StatusUnknown
)

其中我們使用了iota,相關的用法自行google。最終對應的狀態就是:

0-開啓狀態,1-關閉狀態,2-未知狀態

現在我們假設有一個請求參數過來,數據結構如下:

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

可以看到是一個json類型的字符串,其中就包含了Status狀態,我們的請求是希望把狀態修改為關閉狀態。

然後我們在服務端創建一個結構體,方便把這些字段解析出來:

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

好了,我們在main中執行下代碼,看下解析是否正確:

package main

import (
    "encoding/json"
    "fmt"
)

type Status uint32

const (
    StatusOpen Status = iota
    StatusClosed
    StatusUnknown
)

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

func main() {
    js := `{
        "Id": 1234,
        "Timestamp": 1563362390,
        "Status": 1
      }`

    request := &Request{}
    err := json.Unmarshal([]byte(js), request)
    if err != nil {
        fmt.Println(err)
        return
    }
}

執行後的結果如下:

go run main.go
&{1234 1563362390 1}

可以看到解析是沒問題的。

然而,讓我們再提出一個未設置狀態值的請求(無論出於何種原因):

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

在這種情況下,請求結構的狀態字段將被初始化為其零值(對於 uint32 類型:0)。因此,StatusOpen 而不是 StatusUnknown。

最佳實踐是將枚舉的未知值設置為 0:

type Status uint32

const (
    StatusUnknown Status = iota
    StatusOpen
    StatusClosed
)

在這裏,如果狀態不是 JSON 請求的一部分,它將被初始化為 StatusUnknown,正如我們所期望的那樣。

2、指針無處不在?

按值傳遞變量將創建此變量的副本。而通過指針傳遞它只會複製內存地址。

因此,傳遞指針總是會更快,對麼?

如果你相信這一點,請看看這個例子。這是一個 0.3 KB 數據結構的基準測試,我們通過指針和值傳遞和接收。 0.3 KB 並不大,但這與我們每天看到的數據結構類型(對於我們大多數人來説)應該相差不遠。

當我在本地環境中執行這些基準測試時,按值傳遞比按指針傳遞快 4 倍以上。這可能有點違反直覺,對吧?

這其實與 Go 中如何管理內存有關。我們都知道變量可以分配在堆上或棧上,也知道:

  • 棧包含給定 goroutine 的正在進行的變量。一旦函數返回,變量就會從堆棧中彈出。
  • 堆包含共享變量(全局變量等)。

讓我們看下下面這個簡單的例子:

type foo struct{}

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

這裏,一個結果變量由當前的 goroutine 創建。這個變量被壓入當前堆棧。一旦函數返回,客户端將收到此變量的副本。變量本身從堆棧中彈出。它仍然存在於內存中,直到它被另一個變量擦除,但它不能再被訪問。

我們現在修改下上面的例子,使用指針:

type foo struct{}

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

結果變量仍然由當前的 goroutine 創建,但客户端將收到一個指針(變量地址的副本)。如果結果變量從堆棧中彈出,則此函數的客户端無法再訪問它。

在這種情況下,Go 編譯器會將結果變量轉移到可以共享變量的地方:堆。

但是,傳遞指針是另一種情況。例如:

type foo struct{}

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

因為我們在同一個 goroutine 中調用 f,所以 p 變量不需要被轉移。它只是被壓入堆棧,子函數可以訪問它。

比如在 io.Reader 的 Read 方法中接收切片而不是返回切片的直接結果,也不會轉移到堆上。

但是返回一個切片(它是一個指針)會將其轉移到堆中。

為什麼堆棧那麼快?主要原因有兩個:

  • 堆棧不需要垃圾收集器。正如我們所説,一個變量在創建後被簡單地壓入,然後在函數返回時從堆棧中彈出。無需進行復雜的過程來回收未使用的變量等。
  • 堆棧屬於一個 goroutine,因此與將變量存儲在堆上相比,存儲變量不需要同步。這也導致性能增益。

結論就是:

當我們創建一個函數時,我們的默認行為應該是使用值而不是指針。僅當我們想要共享變量時才應使用指針。

最後:

如果我們遇到性能問題,一種可能的優化可能是檢查指針在某些特定情況下是否有幫助。使用以下命令可以知道編譯器何時將變量轉移到堆中:go build -gcflags "-m -m"。(內存逃逸)

3、中斷 for/switch 或 for/select

我們看下下面的代碼會發生什麼:

package main

func f() bool {
    return true
}

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

我們將調用 break 語句。但是,這會破壞 switch 語句,而不是 for 循環。

相同的情況還會出現在fo/select中,像下面這樣:

package main

import (
    "context"
    "time"
)

func main() {
    ch := make(chan struct{})
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    for {
        select {
        case <-ch:
        // Do something
        case <-ctx.Done():
            break
        }
    }
}

雖然調用了break,但是還是會陷入死循環。break 與 select 語句有關,與 for 循環無關。

打破 for/switch 或 for/select 的,一種方案是直接return結束整個函數,下面如果還有代碼不會被執行。

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ch := make(chan struct{})
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    for {
        select {
        case <-ch:
        // Do something
        case <-ctx.Done():
            return
        }
    }

  // 這裏不會執行
    fmt.Println("done")
}

還有一種方案是使用中斷標記

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ch := make(chan struct{})
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
loop:
    for {
        select {
        case <-ch:
        // Do something
        case <-ctx.Done():
            break loop
        }
    }

  // 會繼續往下執行
    fmt.Println("done")
}

4、錯誤管理

一個錯誤應該只處理一次。記錄錯誤就是處理錯誤。因此,應該記錄或傳播錯誤。

我們可能希望為錯誤添加一些上下文並具有某種形式的層次結構。

讓我們看一個接口請求數據庫的例子,我們分為接口層,service層和類庫層。我們希望返回的層次結構像下面這樣:

unable to serve HTTP POST request for id 1
 |_ unable to insert customer
     |_ unable to commit transaction

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

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

func postHandler(id int) string {
    err := insert(id)
    if err != nil {
        fmt.Printf("unable to serve HTTP POST request for id %d\n", id)
        return `{ok: false}`
    }
    return `{ok: true}`
}

func insert(id int) error {
    err := dbQuery(id)
    if err != nil {
        return errors.Wrapf(err, "unable to insert customer")
    }
    return nil
}

func dbQuery(id int) error {
    // Do something then fail
    return errors.New("unable to commit transaction")
}

func main() {
    res := postHandler(1)
    fmt.Println(res)
}

初始錯誤(如果不是由外部庫返回)可以使用 errors.New 創建。service層 insert 通過向其添加更多上下文來包裝此錯誤。然後,接口層通過記錄錯誤來處理錯誤。每個級別都返回或處理錯誤。

例如,我們可能還想檢查錯誤原因本身以實現重試。假設我們有一個來自處理數據庫訪問的外部庫的 db 包。這個庫可能會返回一個名為 db.DBError 的暫時(臨時)錯誤。要確定是否需要重試,我們必須檢查錯誤原因:

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

type DbError struct {
    msg string
}

func (e *DbError) Error() string {
    return e.msg
}

func postHandler(id int) string {
    err := insert(id)
    if err != nil {
        errCause := errors.Cause(err)
        if _, ok := errCause.(*DbError); ok {
            fmt.Println("retry")
        } else {
            fmt.Printf("unable to serve HTTP POST request for id %d\n", id)
            return `{ok: false}`
        }

    }
    return `{ok: true}`
}

func insert(id int) error {
    err := dbQuery(id)
    if err != nil {
        return errors.Wrapf(err, "unable to insert customer")
    }
    return nil
}

func dbQuery(id int) error {
    // Do something then fail
    return &DbError{"unable to commit transaction"}
}

func main() {
    res := postHandler(1)
    fmt.Println(res)
}

這是使用errors.Cause完成的,它也來自pkg/errors。(可以通過errors.Cause檢查。 errors.Cause 將遞歸檢索沒有實現causer 的最頂層錯誤,這被認為是原始原因。)

有時候也會有人這麼用。例如,檢查錯誤是這樣完成的:

package main

import (
    "fmt"

    "github.com/pkg/errors"
)

type DbError struct {
    msg string
}

func (e *DbError) Error() string {
    return e.msg
}

func postHandler(id int) string {
    err := insert(id)
    if err != nil {
        switch err.(type) {
        default:
            fmt.Printf("unable to serve HTTP POST request for id %d\n", id)
            return `{ok: false}`
        case *DbError:
            fmt.Println("retry")

        }
    }
    return `{ok: true}`
}

func insert(id int) error {
    err := dbQuery(id)
    if err != nil {
        return errors.Wrapf(err, "unable to insert customer")
    }
    return nil
}

func dbQuery(id int) error {
    // Do something then fail
    return &DbError{"unable to commit transaction"}
}

func main() {
    res := postHandler(1)
    fmt.Println(res)
}

如果 DBError 被包裝,它永遠不會觸發重試。

5、切片初始化

有時,我們知道切片的最終長度是多少。例如,假設我們要將 Foo 的切片轉換為 Bar 的切片,這意味着這兩個切片將具有相同的長度。

我們有時候經常會這樣初始化切片:

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

我們都知道切片的底層是數組。如果沒有更多可用空間,它會實施增長戰略。在這種情況下,會自動創建一個新數組(容量更大)並複製所有元素。

現在,假設我們需要多次重複這個增長操作,因為我們的 []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 完成的。

6、上下文管理

context.Context對我們來説非常好用,他可以在協程之間傳遞數據、可以控制協程的生命週期等等。但是這也造成了它的濫用。

go官方文檔是這麼定義的:

==一個 Context 攜帶一個截止日期、一個取消信號和其他跨 API 邊界的值。==

這個描述很寬泛,足以讓一些人對為什麼以及如何使用它感到困惑。

讓我們試着詳細説明一下。上下文可以攜帶:

  • 一個截止時間。它意味着一個持續時間(例如 250 毫秒)或日期時間(例如 2022-01-08 01:00:00),我們認為如果達到,我們必須取消正在進行的活動(I/O 請求,等待通道輸入等)。
  • 取消信號(基本上是 <-chan struct{})。 在這裏,行為是相似的。 一旦我們收到信號,我們必須停止正在進行的活動。 例如,假設我們收到兩個請求。 一個插入一些數據,另一個取消第一個請求(因為它不再需要)。 這可以通過在第一次調用中使用可取消上下文來實現,一旦我們收到第二個請求,該上下文將被取消。
  • 鍵/值列表(均基於 interface{} 類型)。

另外需要説明的是。

首先,上下文是可組合的。因此,我們可以有一個包含截止日期和鍵/值列表的上下文。

此外,多個 goroutine 可以共享相同的上下文,因此取消信號可能會停止多個活動。

我們可以看下一個具體的錯誤例子

一個 Go 應用程序是基於 urfave/cli 的(如果你不知道,那是一個在 Go 中創建命令行應用程序的好庫)。一旦開始,開發人員就會繼承某種應用程序上下文。這意味着當應用程序停止時,庫將使用此上下文發送取消信號。

我瞭解的是,這個上下文是在調用 gRPC 端點時直接傳遞的。這不是我們想要做的。

相反,我們想向 gRPC 庫傳遞:請在應用程序停止時或在 100 毫秒後取消請求。

為此,我們可以簡單地創建一個組合上下文。如果 parent 是應用程序上下文的名稱(由 urfave/cli 創建),那麼我們可以簡單地這樣做:

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "time"

    "github.com/urfave/cli/v2"
)

func main() {

    app := &cli.App{
        Name:  "boom",
        Usage: "make an explosive entrance",
        Action: func(parent *cli.Context) error {
      // 父上下文傳進來,給個超時時間
            ctx, cancel := context.WithTimeout(parent.Context, 10*time.Second)
            defer cancel()
            grpcClientSend(ctx)

            return nil
        },
    }

    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }
}

func grpcClientSend(ctx context.Context) {
    for {
        select {
        case <-ctx.Done(): // 達到超時時間就結束
            fmt.Println("cancel!")
            return
        default:
            time.Sleep(2 * time.Second)
            fmt.Println("do something!")
        }
    }
}

7、使用文件名作為函數輸入?

假設我們必須實現一個函數來計算文件中的空行數。一般我們是這樣實現的:

package main

import (
    "bufio"
    "fmt"
    "os"

    "github.com/pkg/errors"
)

func main() {

    cou, err := count("a.txt")
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(cou)
}

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 body?字節緩衝區?這並不重要,因為我們仍將使用相同的 Read 方法。

在我們的例子中,我們甚至可以緩衝輸入以逐行讀取。因此,我們可以使用 bufio.Reader 及其 ReadLine 方法:

我們把讀取文件的部分放到函數外面

package main

import (
    "bufio"
    "fmt"
    "io"
    "os"

    "github.com/pkg/errors"
)

func main() {

    filename := "a.txt"
    file, err := os.Open(filename)
    if err != nil {
        fmt.Println(err, "unable to open ", filename)
        return
    }
    defer file.Close()
    count, err := count(bufio.NewReader(file))
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(count)
}

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++
        }
    }
}

使用第二種實現,無論實際數據源如何,都可以調用該函數。同時,這將有助於我們的單元測試,因為我們可以簡單地從字符串創建一個 bufio.Reader:

package main

import (
    "bufio"
    "fmt"
    "io"
    "strings"

    "github.com/pkg/errors"
)

func main() {

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

    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(count)
}

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++
        }
    }
}

8、Goroutines 和循環變量

我看到一個常見錯誤是使用帶有循環變量的 goroutines。

以下示例的輸出是什麼?

package main

import (
    "fmt"
    "time"
)

func main() {

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

    time.Sleep(time.Second)
}

在這個例子中,每個 goroutine 共享相同的變量實例,所以它會產生 3 3 3。而不是我們認為的1 2 3

有兩種解決方案可以解決這個問題。第一個是將 i 變量的值傳遞給閉包(內部函數):

package main

import (
    "fmt"
    "time"
)

func main() {

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

    time.Sleep(time.Second)
}

第二個是在 for 循環範圍內創建另一個變量:

package main

import (
    "fmt"
    "time"
)

func main() {

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

    time.Sleep(time.Second)
}

調用 i := i 可能看起來有點奇怪,但它完全有效。處於循環中意味着處於另一個範圍內。所以 i := i 創建了另一個名為 i 的變量實例。當然,為了便於閲讀,我們可能想用不同的名稱來稱呼它。

原文
https://itnext.io/the-top-10-most-common-mistakes-ive-seen-in-go-projects-4b79d4f6cd65

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.