還有半個月go1.12就要釋出了。這是首個將go modules納入正式支援的穩定版本。
距離go modules隨著go1.11正式面向廣大開發者進行體驗也已經過去了半年,這段時間go modules也發生了一些變化,藉此機會我想再次深入探討go modules的使用,同時對這個新生包管理方案做一些思考。
本文索引
版本控制和語義化版本
包的版本控制總是一個包管理器繞不開的古老話題,自然對於我們的go modules也是這樣。
我們將學習一種新的版本指定方式,然後深入地探討一下golang官方推薦的semver
即語義化版本。
控制包版本
在討論go get進行包管理時我們曾經討論過如何對包版本進行控制(文章在此),支援的格式如下:
vX.Y.Z-pre.0.yyyymmddhhmmss-abcdefabcdef
vX.0.0-yyyymmddhhmmss-abcdefabcdef
vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdefabcdef
vX.Y.Z
在go.mod檔案中我們也需要這樣指定,否則go mod無法正常工作,這帶來了2個痛點:
- 目標庫需要打上符合要求的tag,如果tag不符合要求不排除日後出現相容問題(目前來說只要正確指定tag就行,唯一的特殊情況在下一節介紹)
- 如果目標庫沒有打上tag,那麼就必須毫無差錯的編寫大串的版本資訊,大大加重了使用者的負擔
基於以上原因,現在可以直接使用commit的hash來指定版本,如下:
# 使用go get時
go get github.com/mqu/go-notify@ef6f6f49
# 在go.mod中指定
module my-module
require (
// other packages
github.com/mqu/go-notify ef6f6f49
)
隨後我們執行go build
或go mod tidy
,這兩條命令會整理並更新go.mod檔案,更新後的檔案會是這樣:
module my-module
require (
github.com/mattn/go-gtk v0.0.0-20181205025739-e9a6766929f6 // indirect
github.com/mqu/go-notify v0.0.0-20130719194048-ef6f6f49d093
)
可以看到hash資訊自動擴充成了符合要求的版本資訊,今後可以依賴這一特性簡化包版本的指定。
對於hash資訊只有兩個要求:
- 指定hash資訊時不要在前面加上
v
,只需要給出commit hash即可 - hash至少需要8位,與git等工具不同,少於8位會導致go mod無法找到包的對應版本,推薦與go mod保持一致給出12位長度的hash
然而這和我們理想中的版本控制方式似乎還是有些出入,是不是覺得。。。有點不直觀?接下來介紹的語義化版本也許能帶來一些改觀。
語義化版本
golang官方推薦的最佳實踐叫做semver
,這是一個簡稱,寫全了就是Semantic Versioning
,也就是語義化版本。
何謂語義化
通俗地說,就是一種清晰可讀的,明確反應版本資訊的版本格式,更具體的規範在這裡。
如規範所言,形如vX.Y.Z
的形式顯然比一串hash更直觀,所以golang的開發者才會把目光集中於此。
為何使用語義化版本
semver
簡化版本指定的作用是顯而易見的,然而僅此一條理由顯然有點缺乏說服力,畢竟改進後的版本指定其實也不是那麼麻煩,對吧?
那麼為何要引入一套新的規範呢?
我想這可能與golang一貫重視工程化的哲學有關:
不要刪除匯出的名稱,鼓勵標記的複合文字等等。如果需要不同的功能,新增 新名稱而不是更改舊名稱。如果需要完整中斷,請建立一個帶有新匯入路徑的新包。 -go modules wiki
通過semver
對版本進行嚴格的約束,可以最大程度地保證向後相容以及避免“breaking changes”,而這些都是golang所追求的。兩者一拍即合,所以go modules提供了語義化版本的支援。
語義化版本帶來的影響
如果你使用和釋出的包沒有版本tag或者處於1.x版本,那麼你可能體會不到什麼區別,因為go mod所支援的格式從始至終是遵循semver
的,主要的區別體現在v2.0.0
以及更高版本的包上。
“如果舊軟體包和新軟體包具有相同的匯入路徑,則新軟體包必須向後相容舊軟體包。” – go modules wiki
正如這句話所說,相同名字的物件應該向後相容,然而按照語義化版本的約定,當出現v2.0.0
的時候一定表示發生了重大變化,很可能無法保證向後相容,這時候應該如何處理呢?
答案很簡單,我們為包的匯入路徑的末尾附加版本資訊即可,例如:
module my-module/v2
require (
some/pkg/v2 v2.0.0
some/pkg/v2/mod1 v2.0.0
my/pkg/v3 v3.0.1
)
格式總結為pkgpath/vN
,其中N
是大於1的主要版本號。在程式碼裡匯入時也需要附帶上這個版本資訊,如import "some/pkg/v2"
。如此一來包的匯入路徑發生了變化,也不用擔心名稱相同的物件需要向後相容的限制了,因為golang認為不同的匯入路徑意味著不同的包。
不過這裡有幾個例外可以不用參照這種寫法:
- 當使用
gopkg.in
格式時可以使用等價的require gopkg.in/some/pkg.v2 v2.0.0
- 在版本資訊後加上
+incompatible
就可以不需要指定/vN
,例如:require some/pkg v2.0.0+incompatible
- 使用go1.11時設定
GO111MODULE=off
將取消這種限制,當然go1.12裡就不能這麼幹了
除此以外的情況如果直接使用v2+版本將會導致go mod報錯。
v2+版本的包允許和其他不同大版本的包同時存在(前提是新增了/vN
),它們將被當做不同的包來處理。
另外/vN
並不會影響你的倉庫,不需要建立一個v2對應的倉庫,這只是go modules新增的一種附加資訊而已。
當然如果你不想遵循這一規範或者需要相容現有程式碼,那麼指定+incompatible
會是一個合理的選擇。不過如其字面意思,go modules不推薦這種行為。
一點思考
眼尖的讀者可能已經發現了,semver
很眼熟。
是的,REST api
是它的最忠實使用者,像xxx.com/api/v2/xxx
的最佳實踐我們恐怕都司空見慣了,所以golang才會要求v2+的包使用pkg/v2
的形式。然而把REST api
的最佳實踐融合進包管理器設計,真的會是又一個最佳實踐嗎?
我覺得未必如此,一個顯而易見的缺點就在於向後相容上,主流的包管理器都只採用semver
的子集,最大的原因在於如果只提供對版本的控制,而把先後相容的責任交由開發者/使用者相對於強行將無關的資訊附加在包名上來說可能會造成一定的迷惑,但是這種做法可以最大限度的相容現有程式碼,而golang則需要修改mod檔案,修改引入路徑,分散的修改往往導致潛在的缺陷,考慮到現有的golang生態這一做法顯得不那麼明智。同時將版本資訊繫結進包名對於習慣了傳統包管理器方案的使用者(npm,pip)來說顯得有些怪異,可能需要花上一些額外時間適應。
不過檢驗真理的標準永遠都是實踐,隨著go1.12的釋出我們最終會見分曉,對於go modules現在是給予耐心提出建議的階段,評判還為時尚早。
replace的限制
go mod edit -replace
無疑是一個十分強大的命令,但強大的同時它的限制也非常多。
本部分你將看到兩個例子,它們分別闡述了本地包替換的方法以及頂層依賴與間接依賴的區別,現在讓我們進入第一個例子。
本地包替換
replace除了可以將遠端的包進行替換外,還可以將本地存在的modules替換成任意指定的名字。
假設我們有如下的專案:
tree my-mod
my-mod
├── go.mod
├── main.go
└── pkg
├── go.mod
└── pkg.go
其中main.go負責呼叫my/example/pkg
中的Hello
函式列印一句“Hello”,my/example/pkg
顯然是個不存在的包,我們將用本地目錄的pkg
包替換它,這是main.go:
package main
import "my/example/pkg"
func main() {
pkg.Hello()
}
我們的pkg.go相對來說很簡單:
package pkg
import "fmt"
func Hello() {
fmt.Println("Hello")
}
重點在於go.mod檔案,雖然不推薦直接編輯mod檔案,但在這個例子中與使用go mod edit
的效果幾乎沒有區別,所以你可以嘗試自己動手修改my-mod/go.mod:
module my-mod
require my/example/pkg v0.0.0
replace my/example/pkg => ./pkg
至於pkg/go.mod,使用go mod init
生成後不用做任何修改,它只是讓我們的pkg成為一個module,因為replace的源和目標都只能是go modules。
因為被replace的包首先需要被require(wiki說本地替換不用指定,然而我試了報錯),所以在my-mod/go.mod中我們需要先指定依賴的包,即使它並不存在。對於一個會被replace的包,如果是用本地的module進行替換,那麼可以指定版本為v0.0.0
(對於沒有使用版本控制的包只能指定這個版本),否則應該和替換包的指定版本一致。
再看replace my/example/pkg => ./pkg
這句,與替換遠端包時一樣,只是將替換用的包名改為了本地module所在的絕對或相對路徑。
一切準備就緒,我們執行go build
,然後專案目錄會變成這樣:
tree my-mod
my-mod
├── go.mod
├── main.go
├── my-mod
└── pkg
├── go.mod
└── pkg.go
那個叫my-mod的檔案就是編譯好的程式,我們執行它:
./my-mod
Hello
執行成功,my/example/pkg
已經替換成了本地的pkg
。
同時我們注意到,使用本地包進行替換時並不會生成go.sum所需的資訊,所以go.sum檔案也沒有生成。
本地替換的價值在於它提供了一種使自動生成的程式碼進入go modules系統的途徑,畢竟不管是go tools還是rpc工具,這些自動生成程式碼也是專案的一部分,如果不能納入包管理器的管理範圍想必會帶來很大的麻煩。
頂層依賴與間接依賴
如果你因為golang.org/x/...
無法獲取而使用replace進行替換,那麼你肯定遇到過問題。明明已經replace的包為何還會去未替換的地址進行搜尋和下載?
解釋這個問題前先看一個go.mod的例子,這個專案使用的第三方模組使用了golang.org/x/...
的包,但專案中沒有直接引用它們:
module schanclient
require (
github.com/PuerkitoBio/goquery v1.4.1
github.com/andybalholm/cascadia v1.0.0 // indirect
github.com/chromedp/chromedp v0.1.2
golang.org/x/net v0.0.0-20180824152047-4bcd98cce591 // indirect
)
注意github.com/andybalholm/cascadia v1.0.0
和golang.org/x/net v0.0.0-20180824152047-4bcd98cce591
後面的// indirect
,它表示這是一個間接依賴。
間接依賴是指在當前module中沒有直接import,而被當前module使用的第三方module引入的包,相對的頂層依賴就是在當前module中被直接import的包。如果二者規則發生衝突,那麼頂層依賴的規則覆蓋間接依賴。
在這裡golang.org/x/net
被github.com/chromedp/chromedp
引入,但當前專案未直接import,所以是一個間接依賴,而github.com/chromedp/chromedp
被直接引入和使用,所以它是一個頂層依賴。
而我們的replace命令只能管理頂層依賴,所以在這裡你使用replace golang.org/x/net => github.com/golang/net
是沒用的,這就是為什麼會出現go build時仍然去下載golang.org/x/net
的原因。
那麼如果我把// indirect
去掉了,那麼不就變成頂層依賴了嗎?答案當然是不行。不管是直接編輯還是go mod edit
修改,我們為go.mod新增的資訊都只是對go mod
的一種提示而已,當執行go build
或是go mod tidy
時golang會自動更新go.mod導致某些修改無效,簡單來說一個包是頂層依賴還是間接依賴,取決於它在本module中是否被直接import,而不是在go.mod檔案中是否包含// indirect
註釋。
限制
replace唯一的限制是它只能處理頂層依賴。
這樣限制的原因也很好理解,因為對於包進行替換後,通常不能保證相容性,對於一些使用了這個包的第三方module來說可能意味著潛在的缺陷,而允許頂層依賴的替換則意味著你對自己的專案有充足的自信不會因為replace引入問題,是可控的。相當符合golang的工程性原則。
也正如此replace的適用範圍受到了相當的限制:
- 可以使用本地包替換將生成程式碼納入go modules的管理
- 對於直接import的頂層依賴,可以替換不能正常訪問的包或是過時的包
- go modules下import不再支援使用相對路徑匯入包,例如
import "./mypkg"
,所以需要考慮replace
除此之外的replace暫時沒有什麼用處,當然以後如果有變動的話說不定可以發揮比現在更大的作用。
釋出go modules