Go 部落格

向後相容性、Go 1.21 和 Go 2

Russ Cox
2023 年 8 月 14 日

Go 1.21 包含了一些改進相容性的新功能。在您停止閱讀之前,我知道這聽起來很無聊。但無聊有時是好事。在 Go 1 的早期,Go 充滿活力和驚喜。每週我們都會發佈一個新的快照版本,每個人都可以嘗試一下,看看我們改變了什麼,以及他們的程式會如何中斷。我們釋出了 Go 1 及其相容性承諾,就是為了消除這種驚喜,讓新的 Go 版本變得無聊。

無聊是好的。無聊意味著穩定。無聊意味著您可以專注於您的工作,而不是 Go 的不同之處。這篇博文將介紹我們在 Go 1.21 中為保持 Go 的無聊所做的重要工作。

Go 1 相容性

十多年來,我們一直專注於相容性。對於 Go 1,早在 2012 年,我們就釋出了一份題為“Go 1 和 Go 專案的未來”的檔案,其中明確闡述了一個非常清晰的意圖:

按照 Go 1 規範編寫的程式,在其生命週期內都應能夠正確編譯和執行,無需更改。… 即使 Go 1 的未來版本出現,今天能工作的 Go 程式也應該繼續工作。

這裡有幾點說明。首先,相容性意味著原始碼相容性。當您更新到新版本的 Go 時,確實需要重新編譯您的程式碼。其次,我們可以新增新的 API,但不能以破壞現有程式碼的方式新增。

文件末尾警告說,“[我們]不可能保證未來不會有任何更改會破壞任何程式。”然後列舉了程式可能仍然中斷的幾個原因。

例如,如果您的程式依賴於一個有 bug 的行為,而我們修復了這個 bug,那麼您的程式就會中斷,這是可以理解的。但我們非常努力地將中斷降到最低,並保持 Go 的無聊。到目前為止,我們主要使用了兩種方法:API 檢查和測試。

API 檢查

關於相容性,最清楚的一點是,我們不能移除 API,否則使用它的程式就會中斷。

例如,這裡有一個我們不能破壞的程式:

package main

import "os"

func main() {
    os.Stdout.WriteString("hello, world\n")
}

我們不能移除 `os` 包;我們不能移除全域性變數 `os.Stdout`,它是一個 `*os.File`;我們也無法移除 `os.File` 方法 `WriteString`。很明顯,移除其中任何一個都會破壞這個程式。

也許不那麼明顯的是,我們完全不能改變 `os.Stdout` 的型別。假設我們想讓它成為一個具有相同方法的介面。上面看到的程式不會中斷,但這個程式會中斷:

package main

import "os"

func main() {
    greet(os.Stdout)
}

func greet(f *os.File) {
    f.WriteString(“hello, world\n”)
}

這個程式將 `os.Stdout` 傳遞給一個名為 `greet` 的函式,該函式需要一個 `*os.File` 型別的引數。所以將 `os.Stdout` 更改為介面會破壞這個程式。

為了幫助我們開發 Go,我們使用了一個工具,它將每個包的匯出 API 列表儲存在與實際包分開的檔案中。

% cat go/api/go1.21.txt
pkg bytes, func ContainsFunc([]uint8, func(int32) bool) bool #54386
pkg bytes, method (*Buffer) AvailableBuffer() []uint8 #53685
pkg bytes, method (*Buffer) Available() int #53685
pkg cmp, func Compare[$0 Ordered]($0, $0) int #59488
pkg cmp, func Less[$0 Ordered]($0, $0) bool #59488
pkg cmp, type Ordered interface {} #59488
pkg context, func AfterFunc(Context, func()) func() bool #57928
pkg context, func WithDeadlineCause(Context, time.Time, error) (Context, CancelFunc) #56661
pkg context, func WithoutCancel(Context) Context #40221
pkg context, func WithTimeoutCause(Context, time.Duration, error) (Context, CancelFunc) #56661

我們的一個標準測試檢查實際的包 API 是否與這些檔案匹配。如果我們向包中添加了新的 API,測試就會中斷,除非我們將其新增到 API 檔案中。如果我們更改或刪除了 API,測試也會中斷。這有助於我們避免錯誤。然而,這樣的工具只能找到一類問題,即 API 的更改和刪除。還有其他方式可以對 Go 進行不相容的更改。

這就引出了我們用於保持 Go 無聊的第二種方法:測試。

測試

發現意外不相容性的最有效方法是針對下一個 Go 版本開發的版本執行現有測試。我們滾動測試 Google 所有內部 Go 程式碼的 Go 開發版本。當測試透過時,我們將該提交安裝為 Google 的生產 Go 工具鏈。

如果更改破壞了 Google 內部的測試,我們就假定它也會破壞 Google 外部的測試,並尋找減少影響的方法。大多數情況下,我們會完全回滾更改,或者找到一種重寫方式,使其不破壞任何程式。但有時,我們會認為這項更改很重要,並且“相容”,即使它確實破壞了一些程式。在這種情況下,我們仍然努力將影響降到最低,然後在釋出說明中記錄潛在問題。

以下是我們在 Google 內部測試 Go 時發現的兩種微妙相容性問題,它們仍包含在 Go 1.1 中。

結構體字面量和新欄位

下面是一段在 Go 1 中執行良好的程式碼:

package main

import "net"

var myAddr = &net.TCPAddr{
    net.IPv4(18, 26, 4, 9),
    80,
}

`main` 包聲明瞭一個全域性變數 `myAddr`,它是一個 `net.TCPAddr` 型別的複合字面量。在 Go 1 中,`net` 包將 `TCPAddr` 定義為一個具有 `IP` 和 `Port` 兩個欄位的結構體。這些與複合字面量中的欄位匹配,因此程式可以編譯。

在 Go 1.1 中,該程式停止編譯,並出現編譯器錯誤:“結構體字面量中的初始化器不足”。問題是我們向 `net.TCPAddr` 添加了第三個欄位 `Zone`,而該程式缺少第三個欄位的值。修復方法是使用帶標籤的字面量重寫程式,以便在兩個版本的 Go 中都能構建:

var myAddr = &net.TCPAddr{
    IP:   net.IPv4(18, 26, 4, 9),
    Port: 80,
}

由於這個字面量沒有為 `Zone` 指定值,它將使用零值(在這種情況下是空字串)。

使用複合字面量來表示標準庫結構體的要求在 相容性文件 中有明確說明,`go vet` 會報告需要標籤的字面量,以確保與更高版本的 Go 相容。這個問題在 Go 1.1 中足夠新,值得在釋出說明中簡要提及。現在我們只是提及新欄位。

時間精度

我們在測試 Go 1.1 時發現的第二個問題與 API 完全無關。它與時間有關。

Go 1 釋出後不久,有人指出 `time.Now` 返回的時間具有微秒精度,但透過一些額外的程式碼,它可以返回納秒精度的時間。這聽起來不錯,對吧?更高的精度更好。所以我們做了這個更改。

這破壞了 Google 內部的一些測試,這些測試在結構上與這個相似:

func TestSaveTime(t *testing.T) {
    t1 := time.Now()
    save(t1)
    if t2 := load(); t2 != t1 {
        t.Fatalf("load() = %v, want %v", t1, t2)
    }
}

這段程式碼呼叫 `time.Now`,然後透過 `save` 和 `load` 進行往返,並期望得到相同的時間。如果 `save` 和 `load` 使用僅儲存微秒精度的表示,那麼在 Go 1 中會正常工作,但在 Go 1.1 中會失敗。

為了幫助修復此類測試,我們向 `time.Time` 添加了 `Round``Truncate` 方法來丟棄不必要的精度,並在釋出說明中記錄了潛在問題和用於修復它的新方法。

這些例子表明,測試發現的相容性問題型別與 API 檢查不同。當然,測試也不能完全保證相容性,但它比僅進行 API 檢查更全面。我們在測試中發現了很多決定破壞相容性規則並在此次釋出前回滾的問題。時間精度更改是一個有趣的例子,它破壞了程式但我們仍然釋出了它。我們進行了更改,因為提高精度更好,並且在函式記錄的行為允許範圍內。

這個例子表明,儘管付出了巨大的努力並引起了極大的關注,有時更改 Go 意味著破壞 Go 程式。嚴格來說,這些更改在 Go 1 文件的意義上是“相容”的,但它們仍然會破壞程式。這些相容性問題大多可以歸為三類:輸出更改、輸入更改和協議更改。

輸出更改

當一個函式給出與以前不同的輸出時,就會發生輸出更改,但新的輸出與舊的輸出一樣正確,甚至更正確。如果現有程式碼編寫為只期望舊輸出,那麼它就會中斷。我們剛才看到了一個例子,即 `time.Now` 添加了納秒精度。

排序。 另一個例子發生在 Go 1.6 中,當時我們將排序的實現速度提高了約 10%。下面是一個對顏色列表按名稱長度排序的示例程式:

colors := strings.Fields(
    `black white red orange yellow green blue indigo violet`)
sort.Sort(ByLen(colors))
fmt.Println(colors)

Go 1.5:  [red blue green white black yellow orange indigo violet]
Go 1.6:  [red blue white green black orange yellow indigo violet]

更改排序演算法通常會改變相等元素的順序,這裡就發生了這種情況。Go 1.5 按順序返回 green, white, black。Go 1.6 返回 white, green, black。

排序顯然可以按喜歡的任何順序返回相等的結果,並且這次更改使其速度提高了 10%,這非常好。但期望特定輸出的程式會中斷。這很好地說明了相容性為何如此困難。我們不想破壞程式,但也不想被鎖定在未記錄的實現細節中。

Compress/flate。 另一個例子是,在 Go 1.8 中,我們改進了 `compress/flate`,使其產生更小的輸出,而 CPU 和記憶體開銷大致相同。這聽起來像是雙贏,但它破壞了一個需要可重複存檔構建的專案:現在他們無法重現舊的存檔。他們分叉了 `compress/flate` 和 `compress/gzip` 來保留舊演算法的副本。

我們對 Go 編譯器也做了類似的事情,使用 `sort` 包的一個分叉(以及其他),這樣編譯器在構建時就能產生與早期 Go 版本相同的results。

對於此類輸出更改不相容性,最佳答案是編寫接受任何有效輸出的程式和測試,並將這些中斷作為更改測試策略的機會,而不僅僅是更新預期答案。如果您需要真正可重現的輸出,次佳答案是分叉程式碼以使自己免受更改的影響,但請記住,您也使自己免受了錯誤修復的影響。

輸入更改

當一個函式改變它接受的輸入或處理輸入的方式時,就會發生輸入更改。

ParseInt。 例如,Go 1.13 為了提高可讀性,增加了對大數字中下劃線支援。除了語言更改之外,我們還使 `strconv.ParseInt` 接受了新的語法。此更改並未破壞 Google 內部的任何內容,但很久以後我們才從一位外部使用者那裡得知,他們的程式碼確實中斷了。他們的程式使用下劃線分隔的數字作為資料格式。他們首先嚐試 `ParseInt`,只有在 `ParseInt` 失敗時才回退到檢查下劃線。當 `ParseInt` 不再失敗時,下劃線處理程式碼就不再運行了。

ParseIP。 另一個例子是,Go 的 `net.ParseIP` 遵循早期 IP RFC 中的示例,這些示例經常顯示帶有前導零的十進位制 IP 地址。它將 IP 地址 18.032.4.011 讀取為 18.32.4.11,只是多了一些零。很久以後我們才發現,基於 BSD 的 C 庫將 IP 地址中的前導零解釋為八進位制數:在這些庫中,18.032.4.011 表示 18.26.4.9!

這是 Go 和世界其他地區之間的嚴重不匹配,但改變一個 Go 版本到下一個版本對前導零的含義也會造成嚴重的不匹配。這將是一個巨大的不相容性。最後,我們決定在 Go 1.17 中更改 `net.ParseIP`,完全拒絕前導零。這種更嚴格的解析確保了當 Go 和 C 都成功解析 IP 地址時,或者當舊版本和新版本 Go 解析 IP 地址時,它們都對 IP 地址的含義達成一致。

此更改並未破壞 Google 內部的任何內容,但 Kubernetes 團隊擔心已儲存的配置在之前可以解析,但在 Go 1.17 下將停止解析。帶有前導零的地址可能應該從這些配置中刪除,因為 Go 的解釋與幾乎所有其他語言都不同,但這應該發生在 Kubernetes 的時間線上,而不是 Go 的時間線上。為了避免語義更改,Kubernetes 開始使用自己的 `net.ParseIP` 分叉版本。

處理輸入更改的最佳方法是先驗證您要接受的語法,然後再解析值,但有時您需要分叉程式碼。

協議更改

最後一種常見的相容性問題是協議更改。協議更改是指對一個包進行的更改,該更改在程式用於與外部世界通訊的協議中是外部可見的。幾乎任何更改在某些程式中都可能變得外部可見,正如我們在 `ParseInt` 和 `ParseIP` 中所看到的,但協議更改在幾乎所有程式中都是外部可見的。

HTTP/2。 協議更改的一個明顯例子是 Go 1.6 添加了對 HTTP/2 的自動支援。假設一個 Go 1.5 客戶端正在連線到一個支援 HTTP/2 的伺服器,並且網路中有中介軟體碰巧會破壞 HTTP/2。由於 Go 1.5 只使用 HTTP/1.1,程式可以正常工作。但隨後更新到 Go 1.6 會破壞程式,因為 Go 1.6 開始使用 HTTP/2,而在這種情況下,HTTP/2 不起作用。

Go 旨在預設支援現代協議,但這個例子表明,啟用 HTTP/2 可能會破壞程式,而這並非程式本身的錯(也不是 Go 的錯)。在這種情況下,開發人員可以退回到使用 Go 1.5,但這並不令人滿意。相反,Go 1.6 在釋出說明中記錄了此更改,並使停用 HTTP/2 變得簡單。

實際上,Go 1.6 記錄了兩種停用 HTTP/2 的方法:使用包 API 顯式配置 `TLSNextProto` 欄位,或設定 GODEBUG 環境變數。

GODEBUG=http2client=0 ./myprog
GODEBUG=http2server=0 ./myprog
GODEBUG=http2client=0,http2server=0 ./myprog

稍後我們將看到,Go 1.21 將 GODEBUG 機制通用化,使其成為所有可能破壞性更改的標準。

SHA1。 這是一個更微妙的協議更改示例。沒有人應該再使用基於 SHA1 的證書進行 HTTPS 了。證書頒發機構於 2015 年停止簽發它們,所有主要瀏覽器於 2017 年停止接受它們。2020 年初,Go 1.18 預設停用了對它們的廣泛支援,並提供了一個 GODEBUG 設定來覆蓋此更改。我們還宣佈了在 Go 1.19 中移除 GODEBUG 設定的意圖。

Kubernetes 團隊告訴我們,一些安裝仍然使用私有的 SHA1 證書。撇開安全問題不談,強迫這些企業升級其證書基礎設施並不是 Kubernetes 的職責,並且分叉 `crypto/tls` 和 `net/http` 來保留 SHA1 支援將極其痛苦。相反,我們同意將覆蓋保留時間比我們計劃的更長,以創造更多時間進行有序過渡。畢竟,我們希望儘量少地破壞程式。

Go 1.21 中擴充套件的 GODEBUG 支援

為了改進向後相容性,即使在這些微妙的情況下,Go 1.21 也擴充套件和正式化了 GODEBUG 的使用。

首先,對於 Go 1 相容性允許但仍可能破壞現有程式的任何更改,我們會盡一切努力來理解潛在的相容性問題,並設計更改以儘可能多地保持現有程式正常工作。對於剩餘的程式,新方法是:

  1. 我們將定義一個新的 GODEBUG 設定,允許各個程式選擇退出新行為。如果無法實現 GODEBUG 設定,則可能不會新增它,但這應該極為罕見。

  2. 為相容性新增的 GODEBUG 設定將維護至少兩年(四個 Go 版本)。一些設定,如 `http2client` 和 `http2server`,將維護更長時間,甚至無限期。

  3. 在可能的情況下,每個 GODEBUG 設定都有一個關聯的 `runtime/metrics` 計數器,名為 `/godebug/non-default-behavior/:events`,它計算特定程式行為因該設定的非預設值而更改的次數。例如,當設定 `GODEBUG=http2client=0` 時,`/godebug/non-default-behavior/http2client:events` 計算程式配置為不支援 HTTP/2 的 HTTP 傳輸次數。

  4. 程式的 GODEBUG 設定是根據主包 `go.mod` 檔案中列出的 Go 版本配置的。如果您的程式的 `go.mod` 檔案顯示 `go 1.20` 並且您更新到 Go 1.21 工具鏈,那麼在您將 `go.mod` 更改為 `go 1.21` 之前,所有由 GODEBUG 控制的在 Go 1.21 中更改的行為都將保留其舊的 Go 1.20 行為。

  5. 程式可以透過在 `main` 包中使用 `//go:debug` 行來更改單個 GODEBUG 設定。

  6. 所有 GODEBUG 設定都記錄在一個 單一的中央列表 中,方便參考。

這種方法意味著每個新版本的 Go 都應該是舊版本 Go 的最佳實現,即使在編譯舊程式碼時保留了在後續版本中以相容但破壞性方式更改的行為。

例如,在 Go 1.21 中,`panic(nil)` 現在會導致一個(非零值)執行時 panic,這樣 `recover` 的結果現在可以可靠地報告當前 goroutine 是否在 panic。這種新行為由 GODEBUG 設定控制,因此取決於主包 `go.mod` 的 `go` 行:如果它顯示 `go 1.20` 或更早版本,則仍然允許 `panic(nil)`。如果它顯示 `go 1.21` 或更高版本,則 `panic(nil)` 會變成一個帶有 `runtime.PanicNilError` 的 panic。並且版本化的預設值可以透過在主包中新增類似這樣的行來顯式覆蓋:

//go:debug panicnil=1

這種功能組合意味著程式可以在更新到更新的工具鏈的同時保留它們所使用的早期工具鏈的行為,可以根據需要對特定設定進行更細粒度的控制,並且可以使用生產環境監控來了解哪些作業實際上使用了這些非預設行為。總而言之,這些應該能使推出新工具鏈比以往更加順利。

有關更多詳細資訊,請參閱“Go、向後相容性與 GODEBUG”。

關於 Go 2 的更新

在本文頂部引用的“Go 1 和 Go 專案的未來”的文字中,省略號隱藏了以下限定條件:

在某個不確定的時間點,可能會出現 Go 2 規範,但在此之前,[…所有相容性細節… ]。

這引發了一個顯而易見的問題:我們何時可以預期會有一個破壞舊 Go 1 程式的 Go 2 規範?

答案是永遠不會。Go 2,從打破過去、不再編譯舊程式的意義上講,將永遠不會發生。Go 2,從 2017 年我們開始的 Go 1 主要修訂版的意義上講,已經發生。

不會有破壞 Go 1 程式的 Go 2。相反,我們將加倍努力實現相容性,這比任何可能的打破過去的舉動都更有價值。事實上,我們認為優先考慮相容性是我們為 Go 1 做出的最重要的設計決策。

因此,在接下來的幾年裡,您將看到大量令人興奮的新工作,但這些工作將以一種謹慎、相容的方式完成,以便我們可以讓您從一個工具鏈升級到下一個工具鏈的過程儘可能地無聊。

下一篇文章: Go 1.21 中的向前相容性和工具鏈管理
上一篇文章: Go 1.21 釋出!
部落格索引