還有半個月go1.12就要發布了。這是首個將go modules納入正式支持的穩定版本。
距離go modules隨着go1.11正式面向廣大開發者進行體驗也已經過去了半年,這段時間go modules也發生了一些變化,借此機會我想再次深入探討go modules的使用,同時對這個新生包管理方案做一些思考。
本文索引
版本控制和語義化版本
包的版本控制總是一個包管理器繞不開的古老話題,自然對於我們的go modules也是這樣。我們將學習一種新的版本指定方式,然后深入地探討一下golang官方推薦的semver
即語義化版本。
控制包版本
在討論go get進行包管理時我們曾經討論過如何對包版本進行控制([文章在此](https://www.cnblogs.com/apocelipes/p/9534885.html)),支持的格式如下: ```text 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個痛點: 1. 目標庫需要打上符合要求的tag,如果tag不符合要求不排除日后出現兼容問題(目前來說只要正確指定tag就行,唯一的特殊情況在下一節介紹) 2. 如果目標庫沒有打上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`,也就是語義化版本。何謂語義化
通俗地說,就是一種清晰可讀的,明確反應版本信息的版本格式,更具體的規范在[這里](https://semver.org/lang/zh-CN/)。如規范所言,形如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
本部分將討論如何發布你的modules到github等開源倉庫以供他人使用,放心這是相對來說最輕松的一部分。go.sum不是鎖文件
也許你知道npm的package-lock.json的作用,它會記錄所有庫的准確版本,來源以及校驗和,從而幫助開發者使用正確版本的包。通常我們發布時不會帶上它,因為package.json已經夠用,而package-lock.json的內容過於詳細反而會對版本控制以及變更記錄等帶來負面影響。如果看到go.sum文件的話,也許你會覺得它和package-lock.json一樣也是一個鎖文件,那就大錯特錯了。go.sum不是鎖文件。
更准確地來說,go.sum是一個構建狀態跟蹤文件。它會記錄當前module所有的頂層和間接依賴,以及這些依賴的校驗和,從而提供一個可以100%復現的構建過程並對構建對象提供安全性的保證。
go.sum同時還會保留過去使用的包的版本信息,以便日后可能的版本回退,這一點也與普通的鎖文件不同。所以go.sum並不是包管理器的鎖文件。
因此我們應該把go.sum和go.mod一同添加進版本控制工具的跟蹤列表,同時需要隨着你的模塊一起發布。如果你發布的模塊中不包含此文件,使用者在構建時會報錯,同時還可能出現安全風險(go.sum提供了安全性的校驗)。
使用vendor目錄
golang一直提供了工具選擇上的自由性,如果你不喜歡go mod的緩存方式,你可以使用`go mod vendor`回到`godep`或`govendor`使用的`vendor`目錄進行包管理的方式。當然這個命令並不能讓你從godep之類的工具遷移到go modules,它只是單純地把go.sum中的所有依賴下載到vendor目錄里,如果你用它遷移godep你會發現vendor目錄里的包回合godep指定的產生相當大的差異,所以請務必不要這樣做。
我們舉第一部分中用到的項目做例子,使用go mod vendor
之后項目結構是這樣的:
tree my-module
my-module
├── go.mod
├── go.sum
├── main.go
└── vendor
├── github.com
│ ├── mattn
│ │ └── go-gtk
│ │ └── glib
│ │ ├── glib.go
│ │ └── glib.go.h
│ └── mqu
│ └── go-notify
│ ├── LICENSE
│ ├── README
│ └── notify.go
└── modules.txt
可以看到依賴被放入了vendor目錄。
接下來使用go build -mod=vendor
來構建項目,因為在go modules模式下go build是屏蔽vendor機制的,所以需要特定參數重新開啟vendor機制:
go build -mod=vendor
./my-module
a notify!
構建成功。當發布時也只需要和使用godep時一樣將vendor目錄帶上即可。
注意包版本
其實這是第一部分的老生常談,當你發布一個v2+版本的庫時,需要進行以下操作: 1. 將`module my-module`改成`module my-module/v2` 2. 將源代碼中使用了v2+版本包的import語句從`import "my-module"`改為`import "my-module/v2"` 3. 仔細檢查你的代碼中所有`my-module`包的版本是否統一,修改那些不兼容的問題 4. 在changelog中仔細列出所有breaking changes 5. 當然,如果你覺得前面四步過於繁瑣,注明你的用戶需要指定`+incompatible`是一個暫時性的解決方案。注意以上幾點的話發布go modules也就是一個輕松的工作了。
小結
相比godep和vendor機制而言,go modules已經是向現代包管理器邁出的堅實一步,雖然還有不少僵硬甚至詭異的地方,但是個人還是推薦在go1.12發布后考慮逐步遷移到go modules,畢竟有官方的支持,相關issues的討論也很活躍,不出意外應該是go包管理方案的最終答案,現在花上一些時間是值得的。當然包管理是一個很大的話題,就算本文也只是講解了其中的一二,以后我也許有時間會介紹更多go modules相關的內容。
總之go modules還是一個新興事物,包管理器是一個需要不斷在實踐中完善的工具,如果你有建設性的想法請盡量向官方反饋。
go modules的官方wiki也上線一段時間了,這篇文件基本上是與其結合的查漏補缺,同時也夾雜了一些個人見解,所以難免有所錯誤疏漏,歡迎指正。