Go 部落格

後向相容性、Go 1.21 和 Go 2

Russ Cox
2023 年 8 月 14 日

Go 1.21 包含新的功能來改善相容性。在您停止閱讀之前,我知道這聽起來很無趣。但是無趣是好的。回到 Go 1 早期,Go 是令人興奮且充滿驚喜的。我們每週都會發行新的快照版本,而每個人都會擲骰子,看看我們做了哪些變更,以及他們的程式會有什麼樣的影響。我們發布了 Go 1 及其保持相容性,就是為了消除這些令人興奮的事,所以 Go 的新版本會很無聊。

無聊是好的。無聊是穩定的。無聊表示您可以專注於您的工作,而不是 Go 有什麼不同。這篇文章探討了我們在 Go 1.12 中發布的重大工作,以維持 Go 的無聊。

Go 1 相容性

我們已經專注於相容性超過十年。早在 2012 年的 Go 1,我們就發布了一份標題為「Go 1 和 Go 程式未來的展望」的文件,其中闡述了非常明確的意圖

撰寫為符合 Go 1 規格的程式,在該規格的生命週期內會持續編譯且正確執行,而且不需要任何變動。…即使未來 Go 1 發布新版本,目前正常運作的 Go 程式應會持續正常運作。

在此有幾個條件。首先,相容性是指原始碼相容性。當你更新 Go 到新版本時,你必須重新編譯你的程式碼。第二,我們可以新增 API,但不能以破壞既有程式碼的方式進行。

文件的結尾警告,「[無法] 保證未來的任何變更都不會破壞任何程式。」然後列出促使程式可能仍然會破壞的許多原因。

例如,如果你的程式依賴於錯誤的行為,而我們修正了錯誤的行為,你的程式就可能會破壞。但是我們盡全力盡量減少破壞,且維持 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傳遞到需要類型為*os.File參數的函式greet。因此將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 發行的開發版本上執行既有測試。我們逐步測試 Go 的開發版本,並對應所有 Google 的內部 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 中,程式會停止編譯,而且編譯器錯誤會說「在結構文字中有過少的初始化程式」。問題在於我們加入了第三個欄位 `Zone` 至 net.TCPAddr,而且這個程式缺少第三個欄位的數值。修正方法是使用標記文字來重新編寫程式,讓它可以在兩個版本的 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,然後透過 saveload 來回處理結果,並預期會取得相同的時間。如果 saveload 使用僅儲存微秒精度的表示法,這將在 Go 1 中正常運作,但在 Go 1.1 中會失敗。

為了解決此類測試,我們已新增 RoundTruncate 方法來移除不需要的精度,而且我們在版本說明文件中記載可能的相關問題,以及可用來解決此類問題的新方法。

這些範例顯示測試如何找出 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 回傳綠色、白色、黑色(按此順序)。Go 1.6 回傳白色、綠色、黑色。

排序在任何順序回傳相同結果都明顯是允許的,而且此變更讓排序速度提升 10%,非常棒。但預期特定輸出的程式會損壞。這是相容性難以實現的一個好範例。我們不希望損壞程式,但也不希望受限於未記載的實作詳細資料。

壓縮/flate。舉另一個範例來說,在 Go 1.8 中,我們改良了 compress/flate 以產生更小的輸出,而 CPU 和記憶體開銷大致相同。這聽起來好像是一個雙贏局面,但它中斷了 Google 內部一個需要可復現的封存建置的專案:現在他們無法復現他們舊有的封存。他們分岔了 compress/flatecompress/gzip 以保留舊演算法的副本。

我們在 Go 編譯器中也做了類似的事情,使用 sort 套件的分岔版本 (以及其他),以便編譯器即使使用早期版本的 Go 建置,也能產生相同的結果。

針對這些類型的輸出變更不相容,最佳的解答是撰寫會接受任何有效輸出的程式和測試,並將這類的中斷當成一個機會來調整您的測試策略,而不仅仅是更新預期的解答。如果您真的需要可復現的輸出,那麼接下來最好的解答就是分岔原始碼以將自己與變更隔離,但請記住,您也將自己與錯誤修正隔離。

輸入變更

當一個函式變更其接受哪些輸入或如何處理輸入時,就會發生輸入變更。

ParseInt。舉例來說,Go 1.13 新增了支援在大型數字中使用底線以提升可讀性。隨著語言的變更,我們讓 strconv.ParseInt 接受新的語法。這個變更並沒有對 Google 內部的任何東西造成中斷,但很久以後,我們才得知一個外部使用者的程式碼真的發生了中斷。他們的程式將數字以底線分隔,作為資料格式。它先嘗試使用 ParseInt,如果 ParseInt 失敗,只會回退來檢查底線。當 ParseInt 停止失敗時,處理底線的程式碼就停止執行。

ParseIP。舉另一個範例來說,Go 的 net.ParseIP 遵循早期 IP RFC 中的範例,這些 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 版本也會造成嚴重的問題。這將是一個巨大的不相容性。最後,我們決定在 Go 1.17 中變更 net.ParseIP,以完全拒絕前導零。這個更加嚴格的剖析可確保當 Go 和 C 都成功剖析一個 IP 位址,或舊版與新版 Go 都成功剖析時,對其意義的詮釋全都一致。

此變更並未損毀 Google 內部的任何東西,但 Kubernetes 團隊擔心已儲存的組態檔可能過往可以剖析,但會因 Go 1.17 而無法剖析。帶有前導 0 的位址可能需要從這些組態檔移除,因為 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 不應強迫這些企業升級其憑證基礎架構,而 fork crypto/tlsnet/http 來維持 SHA1 支援會是非常痛苦的。相反地,我們同意延長覆寫時間,以創造更多時間進行有條不紊的轉換。畢竟,我們希望破壞的程式盡可能少。

擴充 Go 1.21 中的 GODEBUG 支援

為了改善向後相容性,即使是在我們一直在探討的這些細微情況,Go 1.21 也擴充並正式化 GODEBUG 的使用。

首先,對於任何 Go 1 相容性允許,但仍可能破壞現有程式的變更,我們會執行剛才看到的所有作業來了解潛在相容性問題,並設計變更以維持盡可能多現有的程式執行。對於其餘的程式,新的方法是:

  1. 我們會定義新的 GODEBUG 設定,容許個別程式退出新的行為。無法新增 GODEBUG 設定時,可能不會新增這個設定,但這應該極為罕見。

  2. 為相容性新增的 GODEBUG 設定會至少維持兩年(四個 Go 版本)。有些設定(例如 http2clienthttp2server)會維持更長的時間,甚至無限期。

  3. 在可能的情況下,每個 GODEBUG 設定都有相關聯的 runtime/metrics 計數器,名為 /godebug/non-default-behavior/<name>: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 1.21 中變更的任何 GODEBUG 控制行為都會保留其舊的 Go 1.20 行為,直到您將 go.mod 變更為 go 1.21 為止。

  5. 程式可以使用套件 main 中的 //go:debug 行變更個別 GODEBUG 設定。

  6. 所有 GODEBUG 設定都會記載在 單一、中央清單 中,以方便參考。

此方法表示每個新版本的 Go 應為舊版 Go 的最佳實作,甚至保留在後來的版本中編譯舊程式碼時,以相容但中斷的方式變更的行為。

例如,在 Go 1.21 中,panic(nil) 現在會導致(非 nil)執行時期的緊急錯誤,所以 recover 的結果現在可以可靠地報告目前程序片段是否出現緊急錯誤發生。此一新行為由 GODEBUG 設定控制,因此會依賴主套件的 go.modgo 行:如果寫成 go 1.20 或更早的版本,panic(nil) 仍然被允許。如果寫成 go 1.21 或更新的版本,panic(nil) 會變成附有 runtime.PanicNilError 的緊急錯誤。而基於版本的預設值可以透過將類似下列內容的行加入主套件中,來明確定義

//go:debug panicnil=1

這些功能結合在一起表示,程式可以更新到較新的工具鏈,同時保留先前所使用早期的工具鏈的行為,可以適時地對特定設定套用更精細的控制,並且可以使用生產監控來了解哪些工作實際上會使用這些非預設行為。這些結合起來應該可以比過去更順利地推廣新的工具鏈。

如需詳細資料,請參閱〈Go、向後相容性和 GODEBUG〉。

有關 Go 2 的更新

在本篇文章開頭引用的文字「Go 1 和 Go 程式未來」中,省略號隱藏了下列限定詞

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

這產生一個明顯的問題:我們應該何時能看到會破壞舊的 Go 1 程式的 Go 2 規範?

答案是永遠不會。Go 2 這個從過去的慣例中跳脫出來而不再編譯舊程式的概念永遠不會發生。我們從 2017 年開始邁向的 Go 1 主要修訂版本,在 Go 2 的大概念中,已經發生了。

不會出現會破壞 Go 1 程式的 Go 2。反之,我們將加倍重視相容性,因為它遠比任何可能與過去的決裂更有價值。事實上,我們深信優先考慮相容性是我們為 Go 1 作出的最重要設計決策。

因此,在未來幾年中,你會看到大量令人興奮的新作,但會以一種謹慎、相容的方式執行,這樣才能讓你在工具鏈間的升級盡可能地單調乏味。

下一篇:Go 1.21 中的前向相容性和工具鏈管理
前一篇:Go 1.21 已推出!
網誌索引