Go 部落格

保持模組相容性

Jean Barkhuysen 和 Jonathan Amsterdam
2020 年 7 月 7 日

引言

本文是本系列文章的第 5 部分。

注意:有關開發模組的文件,請參閱 開發和釋出模組

您的模組會隨著時間的推移而演進,您會新增新功能、改變行為以及重新考慮模組公共介面的一部分。正如 Go 模組:v2 及更高版本 中所討論的,v1+ 模組的破壞性更改必須作為主要版本升級的一部分進行(或者透過採用新的模組路徑)。

然而,釋出新的主要版本對您的使用者來說很麻煩。他們必須找到新版本、學習新的 API 並修改他們的程式碼。而且有些使用者可能永遠不會更新,這意味著您必須永遠維護兩個版本的程式碼。因此,通常更好的做法是以相容的方式更改現有的軟體包。

在本文中,我們將探討一些引入非破壞性更改的技術。共同的主題是:只增加,不修改或刪除。我們還將討論如何從一開始就設計相容的 API。

向函式新增內容

通常,破壞性更改以向函式新增新引數的形式出現。我們將介紹一些處理此類更改的方法,但首先讓我們看看一種不起作用的技術。

當新增具有合理預設值的新引數時,很容易想將它們新增為可變引數。為了擴充套件函式

func Run(name string)

`Run(string)`

func Run(name string, size ...int)

再新增一個預設值為零的 size 引數,有人可能會建議使用

package mypkg
var runner func(string) = yourpkg.Run

理由是所有現有的呼叫 site 都可以繼續工作。雖然這是事實,但 Run 的其他用法可能會被破壞,例如這個用法

原始的 Run 函式在這裡起作用是因為它的型別是 func(string),而新的 Run 函式的型別是 func(string, ...int),因此在編譯時賦值失敗。

這個例子說明呼叫相容性不足以實現向後相容。實際上,您無法對函式的簽名進行任何向後相容的更改。

不要改變函式的簽名,而是新增一個新函式。例如,在引入 context 包後,將 context.Context 作為第一個引數傳遞給函式成為一種常見做法。然而,穩定的 API 無法改變匯出的函式來接受 context.Context,因為這會破壞該函式的所有用法。

func (db *DB) Query(query string, args ...interface{}) (*Rows, error)

取而代之的是添加了新函式。例如,database/sql 包的 Query 方法的簽名是(並且仍然是)

func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)

`Query(query string, args ...interface{}) (*Rows, error)`

func (db *DB) Query(query string, args ...interface{}) (*Rows, error) {
    return db.QueryContext(context.Background(), query, args...)
}

建立 context 包時,Go 團隊向 database/sql 添加了一個新方法

`QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)`

func Dial(network, addr string, config *Config) (*Conn, error)

為了避免複製程式碼,舊方法呼叫新方法

`func (db *DB) Query(query string, args ...interface{}) (*Rows, error) { return db.QueryContext(context.Background(), query, args...) }`

func Listen(network, address string) (Listener, error)

新增方法允許使用者按照自己的節奏遷移到新的 API。由於這些方法的讀取方式相似且排在一起,並且新方法的名稱中包含 Context,因此 database/sql API 的此擴充套件並未降低包的可讀性或理解性。

type ListenConfig struct {
    Control func(network, address string, c syscall.RawConn) error
}

func (*ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)

如果您預計將來函式可能需要更多引數,可以透過將可選引數作為函式簽名的一部分來提前計劃。最簡單的方法是新增單個結構體引數,就像 crypto/tls.Dial 函式所做的那樣

`Dial(network, address string, config *Config) (Conn, error)`

grpc.Dial("some-target",
  grpc.WithAuthority("some-authority"),
  grpc.WithMaxDelay(time.Second),
  grpc.WithBlock())

Dial 執行的 TLS 握手需要網路和地址,但它還有許多其他具有合理預設值的引數。為 config 傳遞 nil 將使用這些預設值;傳遞一個設定了某些欄位的 Config 結構體將覆蓋這些欄位的預設值。將來,新增新的 TLS 配置引數只需在 Config 結構體上新增一個新欄位,這是一個向後相容的更改(幾乎總是如此——參見下面的“維護結構體相容性”)。

notgrpc.Dial("some-target", &notgrpc.Options{
  Authority: "some-authority",
  MaxDelay:  time.Second,
  Block:     true,
})

有時,可以透過將選項結構體作為方法接收者來結合新增新函式和新增選項的技術。考慮 net 包在網路地址監聽能力方面的演變。在 Go 1.11 之前,net 包只提供了一個簽名如下的 Listen 函式

`Listen(network, address string) (Listener, error)`

對於 Go 1.11,net 監聽功能添加了兩個特性:傳遞上下文,以及允許呼叫者提供一個“控制函式”以便在連線建立後但在繫結之前調整原始連線。結果本可以是一個接受上下文、網路、地址和控制函式的新函式。相反,包的作者添加了一個 ListenConfig 結構體,預料到將來可能需要更多選項。他們沒有定義一個名稱笨重的新頂層函式,而是向 ListenConfig 添加了一個 Listen 方法

`func (lc *ListenConfig) Listen(ctx context.Context, network, address string) (Listener, error)`

將來提供新選項的另一種方法是“Option types”模式,其中選項作為可變引數傳遞,每個選項都是一個改變正在構建的值狀態的函式。Rob Pike 的文章 自引用函式和選項設計 更詳細地描述了它們。一個廣泛使用的例子是 google.golang.org/grpcDialOption

Option types 履行了與函式引數中的結構體選項相同的作用:它們是一種可擴充套件的方式來傳遞修改行為的配置。選擇哪種方式很大程度上取決於風格。考慮 gRPC 的 DialOption option type 的這個簡單用法

`grpc.Dial("some.address", grpc.WithBlock())`

這也可以透過結構體選項來實現

package tar

type Reader struct {
  r io.Reader
}

func NewReader(r io.Reader) *Reader {
  return &Reader{r: r}
}

func (r *Reader) Read(b []byte) (int, error) {
  if rs, ok := r.r.(io.Seeker); ok {
    // Use more efficient rs.Seek.
  }
  // Use less efficient r.r.Read.
}

`type DialConfig struct { Block bool ... } grpc.Dial("some.address", &DialConfig{Block: true})`

函式式選項有一些缺點:每次呼叫時都需要在選項前寫包名;它們增加了包名稱空間的規模;而且如果多次提供相同的選項,其行為不明確。另一方面,接受選項結構體的函式需要一個可能幾乎總是為 nil 的引數,這讓一些人覺得不太美觀。當型別的零值具有有效含義時,指定選項應具有其預設值會很笨拙,通常需要指標或額外的布林欄位。

兩者都是確保模組公共 API 未來可擴充套件性的合理選擇。

使用介面

有時,新功能需要更改公開的介面:例如,介面需要透過新方法進行擴充套件。然而,直接向介面新增方法是破壞性更改——那麼,我們如何支援公開介面上的新方法呢?

// TB is the interface common to T and B.
type TB interface {
    Error(args ...interface{})
    Errorf(format string, args ...interface{})
    // ...

    // A private method to prevent users implementing the
    // interface and so future additions to it will not
    // violate Go 1 compatibility.
    private()
}

基本思想是定義一個包含新方法的新介面,然後在所有使用舊介面的地方,動態檢查提供的型別是舊型別還是新型別。

讓我們透過 archive/tar 包中的一個例子來說明這一點。tar.NewReader 接受 io.Reader,但隨著時間的推移,Go 團隊意識到,如果能夠呼叫 Seek,則從一個檔案頭跳到下一個檔案頭會更高效。但是,他們不能向 io.Reader 新增 Seek 方法:那會破壞所有實現 io.Reader 的型別。

另一個排除的選項是將 tar.NewReader 改為接受 io.ReadSeeker 而不是 io.Reader,因為它同時支援 io.Reader 方法和 Seek(透過 io.Seeker)。但是,正如我們前面看到的,更改函式簽名也是一種破壞性更改。

因此,他們決定保持 tar.NewReader 簽名不變,但在 tar.Reader 方法中進行型別檢查(並支援)io.Seeker

`func (r *Reader) next() (rawhd *Header, err error) { ... seeker, ok := r.r.(io.Seeker) if ok { ... use seeker ... } ... }`

(參閱 reader.go 檢視實際程式碼。)

當您遇到想要向現有介面新增方法的情況時,您可以遵循此策略。首先建立一個包含新方法的新介面,或識別一個包含新方法的現有介面。接下來,確定需要支援它的相關函式,對第二個介面進行型別檢查,並新增使用它的程式碼。

此策略僅在仍可支援不含新方法的舊介面時有效,從而限制了模組未來的可擴充套件性。

在可能的情況下,最好完全避免這類問題。例如,在設計建構函式時,優先返回具體型別。與介面不同,使用具體型別允許您將來新增方法而不會破壞使用者。這個特性使得您的模組將來更容易擴充套件。

提示:如果您確實需要使用介面但不希望使用者實現它,可以新增一個未匯出的方法。這可以防止在您的包之外定義的型別在不嵌入的情況下滿足您的介面,使您以後可以新增方法而不會破壞使用者的實現。例如,請參閱 testing.TBprivate() 函式

type Point struct {
        _ [0]func()
        X int
        Y int
}

Jonathan Amsterdam 的“Detecting Incompatible API Changes”(檢測不相容的 API 更改)演講也更詳細地探討了此主題(影片幻燈片)。

type doNotCompare [0]func()

type Point struct {
        doNotCompare
        X int
        Y int
}

新增配置方法

到目前為止,我們討論了明顯的破壞性更改,即更改型別或函式會導致使用者程式碼停止編譯。然而,行為更改也可能破壞使用者,即使使用者程式碼繼續編譯。例如,許多使用者期望 json.Decoder 忽略 JSON 中不在引數結構體中的欄位。當 Go 團隊想在這種情況下返回錯誤時,他們必須非常謹慎。如果沒有選擇加入(opt-in)機制,許多依賴這些方法的使用者可能會開始收到以前沒有的錯誤。

因此,他們沒有改變所有使用者的行為,而是在 Decoder 結構體中添加了一個配置方法:Decoder.DisallowUnknownFields。呼叫此方法使使用者選擇加入新行為,但不呼叫則保留現有使用者的舊行為。

維護結構體相容性

我們前面看到,對函式簽名的任何更改都是破壞性更改。結構體的情況要好得多。如果您的結構體型別是匯出的,您幾乎總是可以新增欄位或刪除未匯出的欄位而不會破壞相容性。新增欄位時,請確保其零值是有意義的並保留舊行為,以便現有未設定該欄位的程式碼能夠繼續工作。

回想一下,net 包的作者在 Go 1.11 中添加了 ListenConfig,因為他們認為可能會有更多選項出現。事實證明他們是對的。在 Go 1.13 中,添加了 KeepAlive 欄位,以允許停用 keep-alive 或更改其週期。預設值零保留了啟用預設週期 keep-alive 的原始行為。
新欄位有一種微妙的方式可能意外地破壞使用者程式碼。如果結構體中的所有欄位型別都是可比較的——意味著這些型別的值可以使用 ==!= 進行比較並用作 map 鍵——那麼整個結構體型別也是可比較的。在這種情況下,新增一個不可比較型別的新欄位將使整個結構體型別變得不可比較,從而破壞任何比較該結構體型別值的程式碼。
為了保持結構體可比較,不要向其中新增不可比較的欄位。您可以為此編寫測試,或者依靠即將推出的 gorelease 工具來捕獲它。