1. 程式人生 > 程式設計 >Go開發中的十大常見陷阱[譯]

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

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

作者: Teiva Harsanyi

譯者: Simon Ma

我在Go開發中遇到的十大常見錯誤。順序無關緊要。

未知的列舉值

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

type Status uint32

const (
	StatusOpen Status = iota
	StatusClosed
	StatusUnknown
)
複製程式碼

在這裡,我們使用iota建立了一個列舉,其結果如下:

StatusOpen = 0
StatusClosed = 1
StatusUnknown = 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
}
複製程式碼

這裡沒有什麼特別的,狀態會被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,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,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,"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,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,i := range ints {
  go func(i int) {
    fmt.Printf("%v\n",i)
  }(i)
}
複製程式碼

第二種是在for迴圈範圍內建立另一個變數:

ints := []int{1,i := range ints {
  i := i
  go func() {
    fmt.Printf("%v\n",i)
  }()
}
複製程式碼

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

因為處於迴圈中意味著處於另一個作用域內,所以i := i相當於建立了另一個名為i的變數例項。

當然,為了便於閱讀,最好使用不同的變數名稱。

延伸閱讀

Using goroutines on loop iterator variables

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