Go 部落格
保持模組相容性
引言
本文是系列文章的第 5 部分。
- 第 1 部分 — 使用 Go 模組
- 第二部分 — 遷移到 Go 模組
- 第三部分 — 釋出 Go 模組
- 第四部分 — Go 模組:v2 及更高版本
- 第 5 部分 — 保持模組相容性(本文)
注意: 有關開發模組的文件,請參閱 開發和釋出模組。
隨著時間的推移,您的模組會不斷演進,您會新增新功能、修改行為並重新考慮模組公共介面的某些部分。正如Go 模組:v2 及更高版本中所討論的,對 v1+ 模組的破壞性更改必須作為主要版本升級的一部分(或透過採用新的模組路徑)進行。
然而,釋出新的主要版本對您的使用者來說是很困難的。他們必須找到新版本,學習新的 API,並修改他們的程式碼。而且有些使用者可能永遠不會更新,這意味著您必須永遠維護兩個版本的程式碼。所以,通常最好以相容的方式更改現有包。
在這篇文章中,我們將探討一些引入非破壞性更改的技術。共同的主題是:新增,不要更改或移除。我們還將討論如何從一開始就設計您的 API 以實現相容性。
向函式新增
通常,破壞性更改以函式新引數的形式出現。我們將介紹一些處理這種更改的方法,但首先讓我們看看一種無效的技術。
當新增帶有合理預設值的新引數時,很容易將它們作為可變引數新增。為了擴充套件函式
func Run(name string)
使用一個附加的 size
引數,預設為零,有人可能會建議
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 握手需要網路和地址,但它有許多其他引數,這些引數具有合理的預設值。為 config
傳遞 nil
會使用這些預設值;傳遞一個設定了某些欄位的 Config
結構體將覆蓋這些欄位的預設值。將來,新增新的 TLS 配置引數只需要在 Config
結構體上新增一個新欄位,這是一個向後相容的更改(幾乎總是如此——參見下面的“維護結構體相容性”)。
有時,新增新函式和新增選項的技術可以透過將選項結構體作為方法接收器來結合使用。考慮 net
包在網路地址上監聽能力的發展。在 Go 1.11 之前,net
包只提供了一個 Listen
函式,其簽名如下
func Listen(network, address string) (Listener, error)
在 Go 1.11 中,net
監聽功能增加了兩個特性:傳遞上下文,以及允許呼叫者提供一個“控制函式”以在建立後但在繫結之前調整原始連線。結果可能是一個新函式,它接受上下文、網路、地址和控制函式。相反,包作者添加了一個ListenConfig
結構體,以預測將來可能需要更多選項。而且,他們沒有定義一個笨拙的頂級函式,而是向 ListenConfig
添加了一個 Listen
方法
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/grpc 的DialOption。
選項型別在函式引數中扮演著與結構體選項相同的角色:它們是傳遞行為修改配置的可擴充套件方式。選擇哪種方式主要取決於風格。考慮 gRPC 的 DialOption
選項型別的簡單用法
grpc.Dial("some-target",
grpc.WithAuthority("some-authority"),
grpc.WithMaxDelay(time.Second),
grpc.WithBlock())
這也可以透過結構體選項來實現
notgrpc.Dial("some-target", ¬grpc.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.TB
的private()
函式。
// 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()
}
Jonathan Amsterdam 在“檢測不相容的 API 更改”講座中也更詳細地探討了此主題(影片,幻燈片)。
新增配置方法
到目前為止,我們已經討論了明顯的破壞性更改,即更改型別或函式會導致使用者程式碼停止編譯。然而,行為更改也可能破壞使用者,即使使用者程式碼繼續編譯。例如,許多使用者期望json.Decoder
忽略 JSON 中不在引數結構體中的欄位。當 Go 團隊希望在這種情況下返回錯誤時,他們必須小心。如果沒有選擇加入機制,則許多依賴這些方法的現有使用者可能會開始收到以前沒有的錯誤。
因此,他們沒有更改所有使用者的行為,而是向 Decoder
結構體添加了一個配置方法:Decoder.DisallowUnknownFields
。呼叫此方法會使某個使用者選擇新行為,而不呼叫此方法則會為現有使用者保留舊行為。
維護結構體相容性
我們上面看到,對函式簽名的任何更改都是破壞性更改。結構體的情況要好得多。如果您有一個匯出的結構體型別,您幾乎總是可以新增欄位或刪除未匯出欄位而不會破壞相容性。新增欄位時,請確保其零值有意義並保留舊行為,以便不設定該欄位的現有程式碼繼續工作。
回想一下,net
包的作者在 Go 1.11 中添加了 ListenConfig
,因為他們認為將來可能會有更多選項。事實證明他們是對的。在 Go 1.13 中,添加了KeepAlive
欄位,以允許停用保持活動或更改其週期。預設值零保留了啟用預設週期的保持活動的原始行為。
新欄位有一種微妙的方式可能會意外地破壞使用者程式碼。如果結構體中的所有欄位型別都可比較——這意味著這些型別的值可以用 ==
和 !=
進行比較,並用作 map 鍵——那麼整個結構體型別也都是可比較的。在這種情況下,新增一個不可比較型別的新欄位將使整個結構體型別變得不可比較,從而破壞任何比較該結構體型別值的程式碼。
為了保持結構體可比較,不要向其中新增不可比較的欄位。您可以為此編寫一個測試,或者依賴即將推出的 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 釋出
上一篇文章:泛型的下一步
部落格索引