Go team 關于如何保持 Go Modules 兼容性的一些實踐

Go team 在其官方 blog 上討論了如何讓你的 Go Modules 保持兼容性的話題,并給出了一些建議,這些建議都是該團隊在實際開發(fā)中不斷踩坑總結(jié)出來的精華,可以說是最佳實踐。我們站在巨人的肩膀上,可以寫出更優(yōu)雅,更具有兼容性的代碼,下面讓我們深入逐條解讀這些建議。
隨著新功能的添加,或者重構 Go Module 的某些公共部分,Go Module 將隨著時間的推移而不斷發(fā)生變化。
但是,發(fā)布新的 Go Module 版本對使用者來是一個噩耗。他們必須找到新版本,學習新的API,并更改其代碼。而且某些用戶可能永遠不會更新,這意味著您必須永遠為代碼維護兩個版本。因此,通常最好以兼容的方式更改現(xiàn)有的 Go Module。
在本文中,我們將探討一些代碼技巧,能夠讓你保持 Go Module 的兼容性。核心的思想就是是:添加,但是不要更改或刪除你的 Go Module 代碼。我們還將從宏觀角度討論如何設計具備高度兼容性的 API 。
新增函數(shù)
通常來說,改變函數(shù)的形參是破壞代碼兼容性最常見的情況。我們講過討論幾個解決這種問題的方式,但讓我們首先看一個不好的實踐。
有這么一個函數(shù):
func?Run(name?string)
當我們出于某個情況要擴展這個函數(shù),為這個函數(shù)添加一個形參 size :
func?Run(name?string,?size?...int)
假如你在其他代碼中,或者 Go Module 的使用者使用了最新版本,那么像下面的代碼就會出現(xiàn)問題:
package?mypkg
var?runner?func(string)?=?yourpkg.Run
原來的 Run 函數(shù)的類型是 func(string),但是新的 Run 函數(shù)的類型變成了 func(string, ...int),所以在編譯階段就會報錯。必須要根據(jù)新的函數(shù)類型修改調(diào)用方式,這給使用 Go Module 的開發(fā)者造成很多不便,甚至出現(xiàn) bug。
針對這種情況,我們可以新增一個函數(shù)來解決這個問題,而不是修改函數(shù)簽名。我們都知道,context 包是 Golang 1.7 版本之后才引入的,通常 ctx 會做為函數(shù)的第一個參數(shù)傳入。但是現(xiàn)有的已經(jīng)很穩(wěn)定的 API 的可導出函數(shù)不可能去修改函數(shù)簽名,在其函數(shù)第一個入?yún)⑻砑?context.Context,這樣會影響所有函數(shù)調(diào)用方,尤其在一些底層代碼庫中,這是非常危險的操作。
Go team 使用新增函數(shù) 的方法解決了這個問題。舉個栗子,database/sql 這個 package 的 Query 方法的簽名一直是:
func?(db?*DB)?Query(query?string,?args?...interface{})?(*Rows,?error)
當 context package 引入的時候,Go team 新增了這樣一個函數(shù):
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...)
}
通過這種方式,Go team 能夠在平滑地升級一個 package 的同時不對代碼的可讀性、兼容性造成影響。類似的代碼在 golang 源碼中隨處可見。
可選參數(shù)(optional arguments)
如果你在實現(xiàn) package 之前就確定這個函數(shù)后面可能會需要添加參數(shù)來擴展某些功能,那么你可以提前在函數(shù)簽名使用可選參數(shù)(optional arguments)。最簡單的方法是在函數(shù)簽名中使用結(jié)構體參數(shù),下面是 golang 源碼中 crypto/tls.Dial 的一段代碼:
func?Dial(network,?addr?string,?config?*Config)?(*Conn,?error)
Dial 函數(shù)實現(xiàn) TLS 的握手操作,這個過程中需要其他很多參數(shù),同時還支持默認值。當給 config 傳遞 nil 的時候就是使用默認值;當傳遞 Config struct 的時候?qū)采w默認值。假如以后出現(xiàn)了新的 TLS 配置參數(shù),可以很輕松地通過在 Config struct 中添加新字段來實現(xiàn),這種方式是向后兼容的。
有些情況下,新增函數(shù)和使用可選參數(shù)的方式可以結(jié)合起來,通過把可選參數(shù)的結(jié)構體變成一個方法的接收者(receiver)。比如,在 Go 1.11 之前,net package 中的Listen 方法的簽名是:
func?Listen(network,?address?string)?(Listener,?error)
但是在 Go 1.11 中,Go team 新增加了兩個 feature :
傳遞了 context 參數(shù); 增加了 control function,允許調(diào)用者在網(wǎng)絡連接還沒有bind的時候調(diào)整原始連接的參數(shù)。
這看起來是相當大的調(diào)整了,如果是一般開發(fā)者,最多也就會新增一個函數(shù),參數(shù)中添加 context, control function。但是 Go team 的開發(fā)者非等閑之輩,net package 的作者想到未來的某一天這個函數(shù)是不是會有調(diào)整,或者需要更多的參數(shù)?于是就預留了一個 ListenConfig 的結(jié)構體,為這個 strcut 實現(xiàn)了 Listen 方法,從而也不用再新增一個函數(shù)才能解決問題。
type?ListenConfig?struct?{
????Control?func(network,?address?string,?c?syscall.RawConn)?error
}
func?(*ListenConfig)?Listen(ctx?context.Context,?network,?address?string)?(Listener,?error)
還有一種叫做可選類型的設計模式,是把可選的函數(shù)作為函數(shù)形參,每一個可選函數(shù)都可以通過參數(shù)來調(diào)整其狀態(tài)。在 Rob Pike 的 blog (https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html) 里對這種模式進行了詳細的解讀。這種設計模式在 grpc 的源碼中大量使用。
option types 與函數(shù)參數(shù)中的 option struct具有相同的作用:它們是傳遞行為,修改配置的可擴展方式。決定選擇哪個很大程度上取決于具體場景。來看一下 gRPC 的 DialOption 選項類型的用法:
grpc.Dial("some-target",
??grpc.WithAuthority("some-authority"),
??grpc.WithMaxDelay(time.Second),
??grpc.WithBlock())
當然你也可以作為 struct 選項實現(xiàn):
notgrpc.Dial("some-target",?¬grpc.Options{
??Authority:?"some-authority",
??MaxDelay:??time.Minute,
??Block:?????true,
})
以上,任一種方式都是能夠維持 Go Module 兼容性的方法,可以根據(jù)不同的場景選擇合理的實現(xiàn)。
保證 interfaces 的兼容性
有時候,新特性的支持需要更改對外暴露的(public)的接口: ?需要使用新的方法來擴展接口。直接向接口添加方法是不合適的,這會導致接口的實現(xiàn)方都需要修改代碼。那么,我們?nèi)绾尾拍茉诠_的接口上支持新方法呢?
Go team 給出的建議是:使用新方法定義一個新接口,然后在使用舊接口的任何地方動態(tài)檢查提供的類型是舊類型還是新類型。
讓我們以 golang ?源碼中 archive/tar package 來詳細說明一下。tar.NewReader 以 io.Reader 作為參數(shù),但是后來 Go team 覺得應該提供一種更加高效的方式,就是當調(diào)用 Seek 方法的時候可以跳過一個文件的 header。但是又不能直接在 io.Reader 中新增 Seek 方法,這會影響所有實現(xiàn)了 io.Reader 的方法(如果你有看過 golang 源碼,就會知道 io.Reader 接口的應用有多廣泛了)。
另外一種方法是將 tar.NeaReader 的入?yún)⒏某?io.ReaderSeeker interface,因為該 interface 同時支持 io.Reader 和 Seek 。但是正如前面所講,改變一個函數(shù)的簽名,不是一種好的方式。
所以 Go team 決定維持 tar.NewReader 的簽名不變,在 Read 方法中進行類型檢查:
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.
}
如果遇到要向現(xiàn)有接口添加方法的情況,則可以遵循此策略。首先使用新方法創(chuàng)建新接口,或者使用新方法標識現(xiàn)有接口。接下來,確定需要添加的相關代碼啊,對第二個接口進行類型檢查,并添加使用它的代碼。
在可能的情況下,最好避免這種問題。例如,在設計構造函數(shù)時,最好返回具體類型。與接口不同,使用具體類型可以讓你將來在不中斷用戶使用的情況下添加新方法,同時將來可以更輕松地擴展你的 Go Module。
Tip: 如果你用到了一個 interface,但是你不想用戶去實現(xiàn)它,你可以為 interface 添加 unexported 的方法。
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?避免用戶去實現(xiàn)它
????private()
}
新增配置方法
到目前為止,我們討論了修改函數(shù)簽名或者為 interface 添加方法,會影響到用戶的代碼導致編譯失敗。實際上,函數(shù)行為的改變會造成同樣的問題。例如,很多開發(fā)者希望 json.Decoder 可以忽略 struct 中沒有的 json 字段。但是當 Go team 想要在這種情況下返回一些錯誤的時候,就必須要小心,因為這樣做會導致很多使用該方法的用戶突然收到以前從未遇到的錯誤。
因此,他們沒有更改所有用戶的行為,而是向Decoder結(jié)構添加了一種配置方法:Decoder.DisallowUnknownFields 。調(diào)用此方法會使用戶選擇新行為,同時會為現(xiàn)有用戶保留舊的方法。
保持 struct 的兼容性
通過上面的內(nèi)容我們了解到,對函數(shù)簽名的任何更改都是一種破壞性的改動。但是如果使用 struct 就會讓你的代碼靈活很多, 如果具有可導出的結(jié)構體類型,則幾乎可以隨時添加一個字段或刪除一個未導出的字段而不會破壞兼容性。添加字段時,請確保其零值有意義并保留舊的行為,以便未設置該字段的現(xiàn)有代碼繼續(xù)起作用。
還記得上面講到的 net package 的作者在 Go 1.11 的時候添加的 ListenConfig struct 嗎?事實證明他的設計是對的。在 Go 1.13 中,新增了 KeepAlive 字段,允許取消或使用 keep-alive 的功能。有了之前的設計,這個字段的加入就容易多了。
關于 struct 的使用,有一個細節(jié)如果你沒有注意到的話,也會對用戶造成很大的影響。如果 struct 中所有的字段都是可判等的(意思是可用通過 == or !=來比較,或者可以作為 map 的 key),那么這個 struct 就是可判等的。這種情況下,如果你為 struct 添加了一個不可判等的類型,將會導致這個 struct 也變成不可判等的。如果用戶在代碼中使用了你的 struct 進行判等操作,那么就會遇到代碼錯誤。
如果你要保持結(jié)構體可判等,就不要向其添加不可比較的字段。可以為此編寫測試用例來避免遺忘導致bug。
Conclusion
從頭開始規(guī)劃 API 的時候,請仔細考慮 API 在將來的可擴展性。而且當你確實需要添加新功能時,請記住以下規(guī)則:添加,不要更改或刪除。請牢記,添加接口方法,函數(shù)參數(shù)和返回值都會導致 Go Module 不能向后兼容。
如果你確實需要大規(guī)模更改 API,或者要添加更多新特性,那么使用新的 API 版本會是更好的方式。但是大多數(shù)時候,進行向后兼容的更改應該是你的首選,能夠避免給用戶帶來麻煩。
推薦閱讀
站長 polarisxu
自己的原創(chuàng)文章
不限于 Go 技術
職場和創(chuàng)業(yè)經(jīng)驗
Go語言中文網(wǎng)
每天為你
分享 Go 知識
Go愛好者值得關注
