Go 部落格

讓模組維持相容性

Jean de Klerk 和 Jonathan Amsterdam
2020 年 7 月 7 日

簡介

此文章為一系列文章的第 5 篇。

備註:有關開發模組的文件,請參閱 開發和發佈模組

隨著您新增功能、變更行為並重新考量模組公開介面的部分,您的模組將隨著時間而演進。如 Go 模組:第 2 版及以後 中所述,對 v1+ 模組進行重大變更必須作為主要版本變更的一部分(或透過採用新的模組路徑)。

但是,對使用者來說,發佈新的主要版本很困難。他們必須找到新版本、學習新的 API 並變更他們的程式碼。有些使用者可能永遠不會更新,這意味著您必須永久維護兩個版本。因此,通常建議用相容性的方式變更現有套件。

在此文章中,我們將探討一些導入非重大變更的技巧。它們的共同點是:新增內容,而不要進行變更或移除。我們還會探討為相容性自始至終設計 API 的方式。

新增函式

經常,重大變更會以函數的新參數形式出現。我們會說明一些處理這種變更的方法,但首先來看一種不可行的技巧。

在加入有合理預設值的新參數時,很有可能會將它們加入為可變參數。要擴充函數

func Run(name string)

並加入預設值為零的額外大小參數,有人可能會提出

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

理由是這樣所有現有的呼叫網站都能持續運作。雖然沒錯,但其他使用 Run 的情況可能會中斷,例如

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

原始的 Run 函數在這裡能運作是因為它的類型是 func(string),但新的 Run 函數類型是 func(string, ...int),所以這項指派會在編譯階段失敗。

這個範例說明呼叫相容性還不足以達到後向相容性。事實上,函數簽章並沒有方法能進行後向相容性的變更。

不要變更函數簽章,要新增函數。舉例來說,context 套件推出後,將 context.Context 作為函數的第一個參數變得很常見。然而,穩定的 API 不能將已匯出的函數變更為接受 context.Context,因為那樣會中斷所有使用該函數的地方。

所以改新增函數。例如,database/sql 套件的 Query 函數簽章是(現在還是)

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

context 套件建立時,Go 團隊就在 database/sql 新增一個新的函數

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

為了避免複製程式碼,舊有的函數呼叫新的函數

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

加入方法可以讓使用者按照自己的步調遷移到新的 API。由於方法的寫法類似且排序在一起,而且新方法的名稱裡有 Context,所以這個擴充的 database/sql API 沒有降低套件的可讀性和理解度。

如果你預期函數在未來可能需要更多參數,你可以事先規畫,讓選用參數成為函數簽章的一部分。最簡單的方法是新增單一結構參數,就像 crypto/tls.Dial 函數那樣

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

Dial 執行的 TLS 交握需要一個網路和地址,但也有許多具有合理預設值的參數。傳入 nilconfig 會使用這些預設值;傳入設定了一些欄位的 Config 結構會改寫這些欄位的預設值。未來,如果要新增新的 TLS 設定參數,只需要在 Config 結構新增欄位,這項變更會是後向相容的(幾乎總是如此,詳情請見下方的「維持結構相容性」)。

有時候,新增函數和新增選項的技巧可以合併,方法是將選項結構設為方法接收器。考量 net 套件在網路位址傾聽能力的演進。在 Go 1.11 之前,net 套件只提供簽章為

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

在 Go 1.11 中,net 監聽新增了兩個功能:傳遞 context 以及允許呼叫者提供一個「控制函式」來調整建立後的原始連線,但在繫結之前。結果可能會是一個新的函式,它接收 context、網路、位址和控制函式。但套件作者反而新增了 ListenConfig 結構,預期未來可能需要更多選項。而且他們新增一個 Listen 方法到 ListenConfig,而不是定義一個具有繁瑣名稱的新頂層函式

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

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

提供新選項的其他方法是「選項類型」模式,其中選項傳遞為可變引數,而每個選項都是一個會變更正在建構值狀態的函式。它們由 Rob Pike 在文章 自我參照函式和選項設計 中有更詳細的說明。一個廣泛使用的範例是 google.golang.org/grpcDialOption

選項類型扮演與函式引數中的結構選項相同的作用:它們是一種可擴充方式,用來傳遞修改行為的組態。要選擇哪一個,基本上取決於習慣。考慮這個使用 gRPC 的 DialOption 選項類型的簡單範例

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

這也可以實作為一個結構選項

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

函式選項有一些缺點:它們需要在每次呼叫之前撰寫套件名稱在選項前面;它們會增加套件命名空間的大小;而且如果兩次提供相同的選項,則行為不明確。另一方面,接收選項結構的函式需要一個可能是 nil 的參數,有些人不喜歡這樣。而且,當一種型的零值有有效意義時,若要指定選項應具有其預設值會很麻煩,通常需要指標或額外的布林值欄位。

對這兩個都有道理的選擇,可以確保模組公開 API 未來的可擴充性。

使用介面

有些時候,新功能需要變更公開的介面:例如,必須擴充介面以新增新的方法。直接新增到介面是一個重大變更,但是,我們該如何支援公開介面上的新方法呢?

基本構想是定義一個具有新方法的新介面,然後在每一個使用舊式介面的地方動態檢查提供的型別是舊式或新式。

我們用來自 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

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.
}

(請參閱 reader.go 了解實際程式碼。)

當你碰到想為現有介面加入一個方法時,也許可以採用這個策略。從建立一個具有新方法的新介面開始,或是找一個具有新方法的現有介面。接著,找出需要支援它的相關函式,檢查第二個介面的型別,並加入使用它的程式碼。

此策略僅適用於本身仍支援新方法之前的舊介面,可能會限制模組的未來可擴充性。

如果可以,最好完全避免這類問題。例如,在設計建構函式時,優先傳回具體型別。使用具體型別可以讓你在未來加入方法而不影響使用者,這與介面不同。這個特性可讓你的模組在未來更能輕鬆擴充。

提示:如果你確實需要使用介面,但不想使用者實作,則可以加入一個未外銷的方法。這可防止套件外的型別在未內嵌的情況下滿足你的介面,讓你能夠在不影響使用者實作的情況下稍後加入方法。例如請參閱 testing.TBprivate() 函式

// 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()
}

喬納森·阿姆斯特丹在「檢測不相容的 API 變更」演講中對此主題也有更深入的探討(影片投影片)。

新增設定方法

到目前為止,我們討論過明顯的重大變更,即變更型別或函式會導致使用者的程式碼停止編譯。不過,行為變更也會造成使用者的重大問題,即使使用者程式碼繼續編譯。舉例來說,許多使用者預期 json.Decoder 會略過 JSON 中不在引數結構中的欄位。當 Go 團隊想要在此情況傳回錯誤時,他們必須謹慎小心。如果沒有選擇加入機制,許多仰賴這些方法的使用者可能會開始收到之前未出現過的錯誤。

因此,他們並非對所有使用者變更行為,而是在 Decoder 結構中新增了一個設定方法:Decoder.DisallowUnknownFields。呼叫此方法表示使用者選擇加入新的行為,但未這樣做不會影響現有使用者的舊有行為。

維護結構相容性

上文提到,任何對函式簽章的變更都是重大變更。使用結構則好得多。如果您有匯出的結構型別,您幾乎隨時都可以新增一個欄位或移除一個未匯出的欄位而不影響相容性。新增欄位時,請確保其零值有意義,且保留舊有行為,使未設定該欄位的既有程式碼仍然運作。

請回想 net 套件的作者在 Go 1.11 中新增了 ListenConfig,因為他們認為可能有更多的選項。事實證明他們是對的。在 Go 1.13,KeepAlive 欄位 被新增,用於停用保活或變更其週期。預設值零保留了以預設週期啟用保活的原始行為。

有一個微妙的方式可能導致新欄位意外地中斷使用者程式碼。如果結構中的所有欄位型別都是可比較的,意即這些型別的值可以用 ==!= 進行比較,並用作字典金鑰,則整體結構型別也是可比較的。在此情況中,新增一個不可比較型別的新欄位會讓整體結構型別變為不可比較,中斷任何比較該結構型別值的程式碼。

若要維持結構可比較的,請勿新增不可比較欄位。您可以為此撰寫一個測試,或倚賴即將推出的 gorelease 工具來察覺。

為從一開始就避免比較,請確保該結構有一個不可比較欄位。它可能已經有一個欄位(任何切片、字典或函式型別都是不可比較的),但如果不是,可以這樣新增

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

func() 型態不可比較,而且長度為零的陣列不佔空間。我們可以定義一個型態來釐清我們的意圖

type doNotCompare [0]func()

type Point struct {
        doNotCompare
        X int
        Y int
}

您應該在結構中使用 doNotCompare 嗎?如果您已定義結構以便作為指標來使用,換言之,它的指標方法,以及有可能會傳回指標的 NewXXX 建構函式。那麼,加入 doNotCompare 欄位可能會有些過頭。指標型態的使用者會瞭解,此型態的每個值都是不同的:如果他們想要比較兩個值,他們應該會比較指標。

如果您定義的結構打算直接作為值使用,例如我們的 Point 範例,那麼,通常您會想要讓它可以比較。在您有不想讓他比較的值結構時,這是個罕見的案例,那麼,加入 doNotCompare 欄位會讓您有自由可以稍後變更結構,而不必擔心會破壞比較。缺點是,這個型態無法用作 map 鍵。

結論

在從頭計畫 API 時,請仔細考量 API 將如何延伸適應未來的新變更。當您確實需要加入新功能時,請記住這個規則:添加,不要變更或移除,並銘記這些例外情況:介面、函式引數和傳回值不能以向後相容的方式加入。

如果您需要大幅變更 API,或者隨著更多功能加入後,API 開始失去焦點,那麼,可能就是該推出新版本的時候了。但是,大部分的時間,會輕鬆地進行向後相容的變更,並避免為您的使用者造成困擾。

下一篇:Go 1.15 已發布
上一篇:泛型的下一步
部落格索引