摘要:這導致了指針的過度使用,因爲 return nil, err 比返回具有零值的空結構,和諸如 return User{}, err 之類的錯誤要容易得多,也更乾淨。第二個問題是,由於我們的 Gitlab 實例是私有的,並且 Go 嘗試通過 https 下載 git 存儲庫,因此當我們嘗試下載未經任何身份驗證的 Go 模塊時,會收到 401 錯誤。

本文作者最近開始在工作中將 Go 作爲主力編程語言來使用,這是一種有趣的語言,帶有豐富的標準庫,但在標準庫中交付一個生產就緒的 HTTP 服務器並非易事。因此,作者寫下了這篇文章,提到了 Go 語言的一些問題。

本文最初發佈於 sbstp 博客,經原作者授權由 InfoQ 中文站翻譯並分享,未經許可禁止一切形式的轉載

在這篇文章中,我將討論在使用 Go 語言的過程中遇到的一些問題和怪癖。我會有意略過那些經常被提到的問題,例如缺少泛型和 err != nil 錯誤處理模式等,因爲關於它們的討論已經夠多了,並且 Go 團隊準備在 Go 2 中解決它們。

問題目錄

  • 零初始化
  • 過度 linting
  • 返回錯誤
  • nil 切片和 JSON
  • Go 模塊和 Gitlab
  • 日期格式 API
  • 非類型化常量
  • 總結

零初始化

Go 允許變量和 struct 字段不使用一個值也能顯式初始化。在這種情況下,它將爲變量或字段賦予一個零值,我認爲這可能成爲錯誤和意外行爲的潛在源頭。

我第一次遇到這方面的問題,是一個微服務開始用盡文件描述符,並因此出現虛假錯誤的時候。以下是導致問題出現的代碼:

複製代碼

client :=&http.Client{
Transport:&http.Transport{
TLSClientConfig:&tls.Config{InsecureSkipVerify: true},
}
}

乍一看代碼沒什麼問題,但實際上它包含一個導致 TCP 套接字泄漏的錯誤。這裏發生的事情是,我們創建了一個新的 http.Client,其中所有傳輸超時都未指定。由於它們未被指定,因此 Go 會將它們初始化爲零值,這就是問題所在。

複製代碼

// IdleConnTimeout is the maximum amount of time an idle
// (keep-alive) connection will remain idle before closing
// itself.
// Zero means no limit.
IdleConnTimeouttime.Duration// Go 1.7

在上面的 http.Transport 文檔中,可以看到零值表示超時是無限的,因此連接永遠不會關閉。

隨着時間的流逝,套接字不斷積累,最終導致文件描述符用盡。這一過程所需的時間取決於你的服務獲得多少活動,以及文件描述符的 ulimit 設置。

解決這個問題的方法很簡單:初始化 http.Transport 時提供非零超時。Stack Overflow 網站上有這個問題的 答案 ,演示瞭如何從 http 庫複製默認值。

但這仍然是一個容易掉進去的陷阱,據我所知目前沒有 lint 可以幫助解決這類問題。

這還會帶來其他副作用。例如,未導出字段將始終被初始化爲零值,因爲字段無法從包外部進行初始化。

下面是一個示例包:

複製代碼

package utils

type Collection struct {
items map[string]string
}

func (c*Collection)Set(key, val string) {
c.items[key] = val
}

下面是這個包的用法示例:

複製代碼

packagemain

funcmain(){
col := utils.Collection{}
col.Set("name","val")// panic: assignment to nil map
}

解決這個問題的方法沒那麼優雅。這是防禦性編程,在訪問映射前,包作者必須檢查它是否已經被初始化:

複製代碼

func(c *Collection)Set(key, valstring){
ifc.items ==nil{
c.items =make(map[string]string)
}
c.items[key] = val
}

如果 struct 具有多個字段,代碼很快會變得臃腫。一種解決方案是爲類型提供構造函數,例如 utils.NewCollection(),其會始終初始化字段,即便有了這個構造函數,也無法阻止用戶使用 utils.Collections{}初始化其結構,結果就是帶來一堆問題。

過度 linting

我認爲編譯器對未使用的變量過於嚴格。我經常遇到的麻煩是,註釋了一個函數調用後還得在調用上方修改多行代碼。

我有一個 API 客戶端,可以在其上發送請求和接收響應,如下:

複製代碼

client,err:= NewClient()
iferr!= nil {
returnerr
}
defer client.Close()

resp,err:= client.GetSomething()
iferr!= nil {
returnerr
}

process(resp)

假如我想調試代碼,並註釋掉對 process 函數的調用:

複製代碼

client,err:= NewClient()
iferr!= nil {
returnerr
}
defer client.Close()

resp,err:= client.GetSomething()
iferr!= nil {
returnerr
}

//process(resp)

現在,編譯器會出現:resp declared and not used(resp 已聲明但未使用)。好的,我使用 _ 代替 resp:

複製代碼

client,err:= NewClient()
iferr!= nil {
returnerr
}
defer client.Close()

_,err:= client.GetSomething()

// process(resp)

現在編譯器將提示:no new variables on left side of :=(:= 左側沒有新變量)。!之前已聲明瞭 err,我將使用 = 代替:=

複製代碼

client,err:= NewClient()
iferr!= nil {
returnerr
}
defer client.Close()

_,err= client.GetSomething()

// process(resp)

終於通過編譯,但是爲了註釋掉一行代碼還得更改代碼兩次纔行。我經常要做更多的編輯工作才能讓程序通過編譯。

我希望編譯器有一種開發模式,其中未使用的變量只會給出警告,而不會阻止編譯,這樣編輯 - 編譯 - 調試的週期不會像現在這樣麻煩。

返回錯誤

在 Go 語言社區中有很多關於錯誤管理的討論。我個人不介意 if err != nil { return err }這種模式。它可以再做改進,並且有人已經在 Go 2 中提出了對其改進的提案。

最讓我感到困擾的是元組樣式返回。當一個函數可能產生錯誤時,你仍然必須在發生錯誤時提供有效僞值。比如,函數返回 (int, error),那麼必須 return 0, err,也就是說就算一切正常,也還是要爲返回的 int 提供一個值。

我覺得這從根本上就是錯的。首先,當出現錯誤時,我用不着找出一些僞值也應該能返回纔是。這導致了指針的過度使用,因爲 return nil, err 比返回具有零值的空結構,和諸如 return User{}, err 之類的錯誤要容易得多,也更乾淨。

其次,提供有效僞值後,我們很容易假設僞值就是正確的,然後在調用側略過錯誤而繼續下去。

複製代碼

// The fact that err is declaredandused here makes it so
// there's no warnings about it being unused below.
err:= hello()
if err != nil {
returnerr
}
x, err:= strconv.ParseInt("not a number", 10, 32)
// Forget tocheckerr, no warning
doSomething(x)

相比起簡單返回 nil 來說,這種錯誤更難找到。因爲如果我們返回了 nil,我們應該會在後面代碼行的某處出現 nil 指針 panic。

我認爲支持求和類型的語言(例如 Rust、Haskell 或 OCaml)可以更優雅地解決這個問題。發生錯誤時,它們無需爲非錯誤返回值提供一個值。

複製代碼

enumResult<T,E> {
Ok(T),
Err(E),
}

結果要麼是 Ok(T),要麼是 Err(E),而不會兩者都是。

複製代碼

fnconnect(portu32) ->Result<Socket, Error> {
ifport >65536{
// note that I don't have to provide a value for Socket
returnErr(Error::InvalidPort);
}
// ...
}

nil 切片和 JSON

在 Go 中創建切片的推薦方法是使用 var 聲明,例如 var vals []int。這個語句會創建一個 nil 切片,這意味着沒有數組支持此切片:它只是一個 nil 指針。append 函數支持附加到一個 nil 切片,這就是爲什麼可以使用模式 vals = append(vals, x) 的原因所在。len 函數也支持 nil 切片,當切片爲 nil 時返回 0。在實踐中,大多數情況下這用起來挺不錯的,但它也會導致奇怪的行爲。

例如,假設正在構建一個 JSON API。我們從一個數據庫查詢事務並將它們轉換爲對象,以便可以將它們序列化爲 JSON。服務層如下所示:

複製代碼

packagemodels

import"sql"

typeCustomerstruct{
Namestring`json:"name"`
Emailstring`json:"email"`
}

funcGetCustomers(db *sql.DB)([]*Customer, error){
rows, err := db.Query("SELECT name, email FROM customers")
iferr !=nil{
returnnil, err
}

varcustomers []*Customer
for_, row :=rangerows {
customers =append(customers, &User {
Name: row[0].(string)
Email: row[1].(string)
})
}

returncustomers,nil
}

這是相當簡單的,使用這個服務的 HTTP 控制器如下所示:

複製代碼

packagecontrollers

import"http"
import"encoding/json"
import"github.com/me/myapp/models"

funcGetCustomers(req *http.Request, resp http.ResponseWriter){
...
customers, err := models.GetCustomers(db)
iferr !=nil{
...
}
resp.WriteHeader(200)
iferr := json.NewEncoder(resp).Encode(customers); err !=nil{
...
}
}

這些都是基礎,但這裏實際上有問題,它可能會在這個 API 的消費者中觸發錯誤。當數據庫中沒有客戶時,SQL 查詢將不返回任何行。因此,附加到 customers 切片的循環將永遠不會處理任何項目。於是,custormers 切片將作爲 nil 返回。

當 JSON 編碼器看到一個 nil 切片時,它將對響應寫入 null,而不是寫入 [],可是沒有結果的情況下本來應該寫入的是後者,這勢必會給 API 消費者帶來一些問題,因爲在沒有項目的情況下它們本來預期的是一個空列表。

解決方案很簡單,要麼使用一個切片字面量 customers := []*Customer{},要麼使用 customers := make([]*Customer, 0) 這樣的調用。請注意,某些 Go linters 會警告你不要使用空切片字面量,並建議使用 var customers []*Customer 來代替,但後者的語義是不一樣的。

在其他地方也可能出現麻煩。對於 len 函數,一個空映射和一個 nil 映射是相同的。他們有 0 個元素。但是對於其他函數,例如 reflect.DeepEqual 來說,這些映射並不相同。我認爲考慮到 len 的行爲方式,如果一個函數會檢查這兩個映射是否相同,那麼可以預期檢查的結果是一樣的。但是 reflect.DeepEqual 表示不同意,這可能因爲它使用了反射來對比兩個對象,這種比法不是很好的辦法,但卻是 Go 目前唯一可用的選項。

Go 模塊和 Gitlab

一開始,依靠 Git 存儲庫下載模塊可能是一個好主意,但是一旦出現更復雜的用例,Go 模塊就會徹底瓦解。我的團隊在搭配使用 Go 模塊和私有 Gitlab 實例時遇到了很多問題。其中有兩大問題最爲突出。

第一個問題是 Gitlab 允許用戶擁有遞歸項目組。例如,你可以在 gitlab.whatever.com/group/tools/tool-1 上擁有一個 git 存儲庫。Go 模塊並沒有對此提供開箱即用的支持。Go 模塊將嘗試下載 gitlab.whatever.com/group/tools.git,因爲它假定該網站使用類似於 GitHub 的模式,也就是說裏面只有兩個級別的嵌套。我們必須在 go.mod 文件中使用一個 replace 來將 Go 模塊指向正確的位置。

還有一種解決問題的方法是使用 HTML

標籤,讓它指向正確的 git 存儲庫,但這需要 Git 平臺來支持它。要求 Git 平臺爲 Go 模塊添加這種特殊用例的支持並不是一個好的設計決策。它不僅需要在 Git 平臺中進行上游更改,而且還需要將已部署的軟件升級到最新版本,後者在企業部署流程中並不會一直那麼迅速。

第二個問題是,由於我們的 Gitlab 實例是私有的,並且 Go 嘗試通過 https 下載 git 存儲庫,因此當我們嘗試下載未經任何身份驗證的 Go 模塊時,會收到 401 錯誤。使用我們的 Gitlab 密碼進行身份驗證是不切實際的選擇,尤其是在涉及 CI/CD 的情況下。我們找到的解決方案是在使用這個.gitconfig 發出 https 請求時,強制 git 使用 ssh。

複製代碼

[url"[email protected]:"]
insteadOf = https://gitlab.whatever.com

這個方案在實踐中效果很好,但是在初次遇到這個問題時要修復它沒那麼容易。它還假定 SSH 公鑰已在 Gitlab 中註冊,並且私鑰未使用密碼加密。如果你在 GNOME Keyring 或 KDE Wallet 之類的 keyring 代理中註冊了密碼,並且 git 集成了它,那麼倒可能使用一個加密的私鑰,但是我沒有嘗試過這種辦法,所以也不知道是否真的可行。

日期格式 API

Go 的日期格式實在讓人摸不着頭腦。Go 沒有使用常用的 strftime %Y-%m-%d 格式或 yyyy-mm-dd 格式,而是使用了佔位符數字和具有特殊含義的單詞。如果要在 Go 中使用 yyyy-mm-dd 格式設置日期,則必須使用“2006-01-02”格式的字符串。2006 是年份的佔位符,01 是月份的佔位符,而 02 是日期的佔位符。Jan 一詞代表月份,各個月份以三個字母的縮寫表示,如 Jan、Feb、Mar……以此類推。

我覺得這毫無必要。不查看文檔是很難記住它的,實在太亂了,並且沒有充分理由就拋棄了已經有半個世紀歷史的 strftime 標準格式。

我還發現 time 包的官方文檔在解釋這部分內容時一團糟。它基本沒講明白工作機制,結果是你必須去找那些以清晰易懂的方式解釋清楚這個問題的第三方資源纔行。

非類型化的常量

看下這段代碼:

複製代碼

sess, err := mongo.Connect("mongodb://...")
iferr !=nil{
returnerr
}

defermongo.Disconnect(sess)

ctx, cancel := context.WithTimeout(context.Background(),15)
defercancel()

iferr := sess.Ping(ctx,nil) {
returnerr
}

看起來人畜無害。我們連接到 MongoDB 數據庫,在函數退出時 defer 斷開連接,然後創建一個具有 15 秒超時的上下文,並使用此上下文運行一個 ping 命令,對數據庫運行狀況檢查。這應該能順利運行,但可惜不行,每次運行都會返回一個 context deadline exceeded 錯誤。

因爲我們創建的上下文沒有 15 秒的超時,它的超時時間是 15 納秒。這叫超時嗎?這是瞬間失敗。

context.WithTimeout 函數接受一個 context.Context 和一個 time.Duration。time.Duration 是一個新類型,定義爲 type Duration int64。由於 Go 的非類型化常量的緣故,我們能夠將一個 int 傳遞給這個函數。也就是說,在常量被賦予類型之前是沒有類型的。因此,15 不是一個整數字面量或整數常數。當我們將其作爲 time.Duration 傳遞時,它將被類型化爲 time.Duration。

所有這一切意味着,沒有類型錯誤或 lint 告訴我們,我們沒有給這個函數一個適當的 time.Duration。正常來說你要把這個函數 time.Second * x 傳遞給 timeout,單位是 x 秒。time.Second 的類型是 time.Duration,它與 x 相乘後會進行類型化,讓這裏的類型保持安全。但現在並不是這回事,一個沒有類型的常量與真實的 time.Duration 一樣有效,於是就搞出來上面那攤子麻煩。

總結

Go 是一種有趣且非常有用的語言。簡潔是它的宗旨,而且它在大部分時候都做到了這一點。但是,簡單性不應該高於正確性。如果你選擇簡單性而不是正確性,那麼到頭來你會偷工減料,並交付有問題的解決方案。

我認爲 Go 模塊與 Gitlab 的交互就是一個很好的例子。Go 決定採用一種“簡單”的解決方案,不像其他那些語言那樣做一個包存儲中心,而是從 git 服務器中獲取內容。結果不僅在對 git 服務器進行身份驗證時會出現嚴重錯誤。當 git 服務器的命名 / 分組約定與 GitHub 不同時,它也會出錯。最後,你浪費了一整天的時間來研究 stackoverflow,試圖解決這個“簡單”的軟件包系統的問題。

我一直在關注 Go 2 的提案,並且很高興看到 Go 團隊在這方面投入了很大努力。他們正在收集很多社區反饋,這是很好的做法。用戶經常會提供非常有趣的反饋。Go 2 是修復本文中提到的某些問題的絕好機會,或者至少可以允許用戶創建自己的數據結構和類型集,從而解決其中的一些問題。

我可以斷定,當 Go 2 到來時,我將編寫大量實用程序和數據結構來讓程序更安全,更加人性化。

原文鏈接: https://blog.sbstp.ca/go-quirks/

相關文章