Categories
程式開發

這是一篇實踐者對Go語言的微吐槽


本文作者最近開始在工作中將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.
IdleConnTimeout time.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
}

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

package main

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

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

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

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

過度linting

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

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

client, err := NewClient()
if err != nil {
    return err
}
defer client.Close()

resp, err := client.GetSomething()
if err != nil {
    return err
}

process(resp)

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

client, err := NewClient()
if err != nil {
    return err
}
defer client.Close()

resp, err := client.GetSomething()
if err != nil {
    return err
}

//process(resp)

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

client, err := NewClient()
if err != nil {
    return err
}
defer client.Close()

_, err := client.GetSomething()

// process(resp)

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

client, err := NewClient()
if err != nil {
    return err
}
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 declared and used here makes it so
// there's no warnings about it being unused below.
err := hello()
if err != nil {
    return err
}
x, err := strconv.ParseInt("not a number", 10, 32)
// Forget to check err, no warning
doSomething(x)

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

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

enum Result {
    Ok(T),
    Err(E),
}

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

fn connect(port u32) -> Result {
    if port > 65536 {
        // note that I don't have to provide a value for Socket
        return Err(Error::InvalidPort);
    }
    // ...
}

nil切片和JSON

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

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

package models

import "sql"

type Customer struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func GetCustomers(db *sql.DB) ([]*Customer, error) {
    rows, err := db.Query("SELECT name, email FROM customers")
    if err != nil {
        return nil, err
    }

    var customers []*Customer
    for _, row := range rows {
        customers = append(customers, &User {
            Name: row[0].(string)
            Email: row[1].(string)
        })
    }

    return customers, nil
}

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

package controllers

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

func GetCustomers(req *http.Request, resp http.ResponseWriter) {
    ...
    customers, err := models.GetCustomers(db)
    if err != nil {
        ...
    }
    resp.WriteHeader(200)
    if err := 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://...")
if err != nil {
    return err
}

defer mongo.Disconnect(sess)

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

if err := sess.Ping(ctx, nil) {
    return err
}

看起來人畜無害。我們連接到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/