原文: 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/unmarshalled
。 json
咱們設計瞭如下結構:網絡
type Request struct { ID int `json:"Id"` Timestamp int `json:"Timestamp"` Status Status `json:"Status"` }
而後,接收這樣的請求:數據結構
{ "Id": 1234, "Timestamp": 1563362390, "Status": 0 }
這裏沒有什麼特別的,狀態會被unmarshalled
爲StatusOpen
。閉包
然而,讓咱們以另外一個未設置狀態值的請求爲例:
{ "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
優化成內聯函數。
在函數調用中,按值傳遞的變量將建立該變量的副本,而經過指針傳遞只會傳遞該變量的內存地址。
那麼,指針傳遞會比按值傳遞更快嗎?請看一下這個例子。
我在本地環境上模擬了0.3KB
的數據,而後分別測試了按值傳遞和指針傳遞的速度。
結果顯示:按值傳遞比指針傳遞快4倍以上,這很違背直覺。
測試結果與Go中如何管理內存有關。我雖然不能像威廉·肯尼迪那樣出色地解釋它,但讓我試着總結一下。
譯者注開始
做者沒有說明Go內存的基本存儲方式,譯者補充一下。
一個goroutine會以一個很小的棧開始其生命週期,通常只須要2KB。
一個goroutine的棧,和操做系統線程同樣,會保存其活躍或掛起的函數調用的本地變量,可是和OS線程不太同樣的是,一個goroutine的棧大小並非固定的;棧的大小會根據須要動態地伸縮。
而goroutine的棧的最大值有1GB,比傳統的固定大小的線程棧要大得多,儘管通常狀況下,大多goroutine都不須要這麼大的棧。
譯者本身的理解:
譯者注結束
衆所周知,能夠在堆或棧上分配變量。
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) }
爲何棧如此之快? 主要有兩個緣由:
總之,當建立一個函數時,咱們的默認行爲應該是使用值而不是指針。只有在咱們想要共享變量時才應使用指針。
若是咱們遇到性能問題,可使用go build -gcflags "-m -m"
命令,來顯示編譯器將變量轉義到堆的具體操做。
再次重申,對於大多很多天經常使用例來講,值傳遞是最合適的。
若是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
循環無關。
break
出for/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.DBError
被wrapped
,它將永遠不會執行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
常常被誤用。 根據官方文檔:
A Context carries a deadline, a cancelation signal, and other values across API boundaries.
這種描述很是籠統,以致於讓一些人對使用它感到困惑。
讓咱們試着詳細描述一下。Context
能夠包含:
I/O
請求,等待的channel
輸入,等等)。cancelable
上下文來實現,一旦咱們得到第二個請求,這個上下文就會被取消。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 的最佳特性之一。
我常常看到的一個錯誤是在沒有-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.Reader
和 io.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")))
我見過的最後一個常見錯誤是使用 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
你還想提到其餘常見的錯誤嗎?請隨意分享,繼續討論;)