在寫 《配置 1.13+ 的 golang 環境》時,花了大量篇幅解釋 module 的概念,還有 module 與 package 之間的聯繫。眼看字數翻了一番,乾脆把這部分另起一篇。
module 與 package
0x0 module 不是 package
是的,他們不是同一個概念!!module(模塊)是新引入的概念,一個 module 是 零到多個 package(包)的組合,不要把他們混爲一談。
package | module | |
---|---|---|
本質 | 一個目錄下所有 go 源碼的集合(不包括子目錄,那是另一個 package) | 同一個根目錄下所有包的集合(包括子目錄) |
共享 | 代碼 共享命名空間(包名),包內可以 直接互相調用(包括小寫開頭的 unexported members) | 同一個 module 下的 package 共享 module path 作爲 package path 的前綴,module 內可以 直接互相 import |
單位 | (代碼開頭)import 的單位 | (go.mod)require 的單位 |
package 具體體現爲一個目錄下所有 go 源碼的集合(不包括子目錄,那是另一個 package),它們 共享命名空間(包名),包內可以 直接互相調用(包括小寫開頭的 unexported members)。package 是 import 的單位 ,import 語句寫在每一個 go 源碼文件的開頭。
包名跟目錄名 可以一樣也可以不一樣。雖然允許不一樣,但是大家習慣性認爲目錄就是包名;爲了避免大家還要去查包名, 沒什麼特別理由建議保持一致。
例如,import path/to/pkg_dir
中的 pkg_dir 是目錄名,package pkg
和 pkg.MyFunc()
裏的 pkg 是包名。
module 則是同一個根目錄下所有包的集合(包括子目錄),它們 共享 module path 作爲 package path 的前綴,module 內可以 直接互相 import。module 是 require 的單位 ,require 語句在 go.mod 裏。
0x1 GOPATH + vendor 時代
這段解釋 GOPATH 的機制,是爲了對比,加深理解。
如果你不想了解已經被拋棄的 GOPATH ,可以直接跳過看 0x2 部分。
在依賴 GOPATH 的時候,import 的查找範圍如下:
$GOROOT/pkg
查找 內置包查找 相對路徑 的包- 項目根目錄下的 vendor 目錄查找 第三方包
$GOPATH/src
查找下載的 第三方包 和 本地包,如果不存在,嘗試go get
重點解釋 2 和 4。
相對路徑 import?別用!
假定有項目 A ,底下有兩個包,分別爲 A/alpha 和 A/beta。
爲了方便,A/alpha 包使用相對路徑引入 A/beta:
1
|
import "../beta"
|
如果 A 不在 GOPATH 裏開發,換言之 A 不會被別的項目引用,那麼是可以正常編譯執行的。
可是如果 A 在 GOPATH 裏開發,那麼編譯時會報錯:
1
|
can't load package: local import "../beta" in non-local package
|
這是因爲 go 使用全局遞歸 import,來確保每個用到的包都只 import 一次。(題外話,也因此,go 不允許循環 import,會死循環。)
假定有另一個項目 B,底下的 main 包引入了 A/alpha,那麼就會觸發以下 import 順序:
- import “A/alpha”,遞歸 import “A/alpha” import 的包
- import “../beta”,(到這裏會出錯,因爲 B 項目下找不到 “../beta”)
- 運行 “A/alpha” 的 init(),然後 import 完成
如果你覺得解釋太囉嗦,記住 別用相對路徑 就完了。
一切靠 GOPATH
既然相對路徑會有各種問題,那麼本地包的導入,就只剩下第 4 種 - GOPATH 一條路了。
這就導致了包管理高度依賴 GOPATH:
- 爲了本地開發的包 能被其它包引用,開發得在 GOPATH 下進行。
- 不僅引用其他項目包要經 GOPATH,連項目內的包互相引用 ,也得經過 GOPATH。(實際上這時不存在 項目 的概念,即使共享一個項目根目錄,還是不同的包。)
- 項目目錄不能改名,一改,項目內外的引用全得改。(事實上,如果你要把項目託管到源碼倉庫,或者更換託管地址,項目目錄是一定會改的。)
這麼打個比方,李明 爸爸叫 李雷,媽媽叫 韓梅梅,他們一家住 廣東省廣州市黃埔區。但是很奇怪,他們家互相稱呼都得叫全名,而且是帶地址那種。譬如 媽媽 喊老伴和兒子喫飯,就得喊『廣東省 / 廣州市 / 黃埔區 / VK花園10-204 / 李雷』和『廣東省 / 廣州市 / 黃埔區 / VK花園10-204 / 李明』。更詭異的是,如果他們過年回老家了,譬如說 長沙,然後媽媽忘記了改稱呼,還按前面叫,明明都在一屋(項目)裏,但他們倆都不知道在喊自己了。
根本原因,在於 import 中只有全局,沒有本地(項目 / 模塊)概念。全局以下就直接是包,包和包之間沒有聯繫,哪怕我們在一個項目裏,目錄相鄰。
如果你寫過 Java,對比一下就發現,Java 的 classpath 默認爲 當前目錄;這個當前目錄,是以執行 javac 的位置算的,其實就是項目的根目錄。所以同一個項目下的包,用相對根目錄的路徑 就能 import,不管項目整體放哪、項目目錄有沒有改名。
0x2 引入 module
module 模式設爲 on,背後主要是兩個變化:引入 module (和 module path),放棄 GOPATH (和 vendor) 。
這個 module 就是介於 global 和 package 之間的概念,是 一系列的 package 集合。這個概念讓在一個 module 裏的 package 們產生了聯繫:整體管理, 互相可見。
module path 和 package path
package path 具體來說,就是 import 後面那串路徑;module path 則對應 require。
在使用上,package path 似乎沒有任何變化,其實它的組成有了重要的變化:
GOPATH 模式
從 $GOPATH/src
起完整的路徑。
例如 $GOPATH/src/github.com/jay/mymod/midware/router
的 package path 是 github.com/jay/mymod/midware/router
,其它包(包括同一個項目github.com/jay/mymod
下的其它包)需要 import 這個路徑。
路徑上的 任何變化 都要體現在 import 路徑裏,如果移出 GOPATH 則 直接找不到 。(是的,明明引用的包就在旁邊目錄都找不到。)
module 模式
module path + module 內的相對路徑。(如果 package 在 module 根目錄,也就是跟 go.mod 一個目錄,當且僅當這種情況 module path 等於 package path。)
例如 module path 是 github.com/jay/mymod
,module 內的 midware/router
的 package path 是 github.com/jay/mymod/midware/router
,其它包(包括同一個module github.com/jay/mymod
下的其它包)需要 import 這個路徑。
是不是感覺其實沒啥差別,只是把路徑截成了兩段,把前面那段叫 module path。[苦笑]
差別在於:
- module path 是一個在 go.mod 內的聲明,不需要是真實的路徑。你的 module 可以放在任何地方開發,不需要放在 GOPATH 地下,路徑裏也不須包含
github.com/jay/mymod
! - 基於這點,只要 go.mod 聲明不改,挪位置,根目錄重名,都不影響 module 內 package 互相引用!
module 間引用
等等,這些便利都只是 module 內而已,那 module 之間的引用呢?
再來對比一下:
GOPATH 模式
項目託管地址、本地存放路徑、import 路徑 (的開頭) 三者一致。
仍然以上面的項目爲例,三個都是 github.com/jay/mymod
。
具體到 託管地址是 https://github.com/jay/mymod
,
本地存放地址(無論手動新建項目,還是 go get 自動放)是 $GOPATH/src/github.com/jay/mymod
,
import 則是 import "github.com/jay/mymod/midware/router"
(mymod 下面其中一個 package)。
module 模式
在上述三者基礎上,加上 go.mod 聲明的 module path 一致。
也就是在 module 初始化時,執行 go mod init github.com/jay/mymod
,生成的 go.mod 裏第一行就是
1
|
module github.com/jay/mymod
|
託管地址、import 路徑都跟 GOPATH 一樣。差別是本地存放路徑:$HOME/go/pkg/mod/github.com/jay/mymod
。($HOME/go/pkg/mod
叫 mod cache 目錄)
看了這個對比,module 模式多了一個 go.mod 的聲明要保持一致,存放路徑還變長了,是不是又感覺根本沒簡化,還變複雜了。[苦笑]x2
關鍵在於,go.mod 裏提供了一個關鍵字 replace
。
go.mod 裏的 replace
我們來設想一下 開發的不同階段 :
-
前期,一個人開發原型 。只有 module 內引用,愛放哪放哪。
-
繼續前期,原型 新增了一個 mymod2 ,而且 引用原來的 mymod ,有了 module 間引用,此時你有 兩個選擇:
-
繼續隨便放 ,譬如
~/mymod/
,然後在 mymod2 根目錄執行go mod edit -replace=github.com/jay/mymod@v=~/mymod@v
,@v
是可選的。go mod edit -replace=github.com/jay/mymod=~/mymod
就是所有版本都替換。你也可以指定版本如@v1.0.1
。 -
把 mymod 按照 module path 託管到對應地址 ,mymod2 就會從託管服務下載 mymod 自動存放到
$HOME/go/pkg/mod/github.com/jay/[email protected]
。下載過程是自動的,存放位置自動跟 “託管地址+版本” 映射,並不需要人工干預。需要注意 的是,mymod2 引用的是託管的代碼,
~/mymod/
下的最新修改如果沒有push 到託管,是訪問不到的。如果 mymod2 後續也要發佈或者跟其他開發者協作,建議一開始就選擇這種方式 提供引用。否則按 2.1 處理,mymod2 在別人的環境無法獲取 mymod 的依賴。
-
-
中期,其他開發者加入 。爲了其他開發者可以正常地訪問依賴,需要把所有用到的 module 按 module path 放到託管服務上 。(同 2.2)
託管服務可以是公共的,也可以是私有的。如果是私有的,需要配置 ssh 以達到免密訪問。(ssh 配置不展開。)
考慮到遲早需要發佈到託管,最好初始化時就考慮 把託管地址作爲 module path 。
-
後期,持續開發和維護。也許是 公共轉私有(或者反過來,開源),又或者項目改名,或者某個公共託管撂挑子不幹了——總之,有些 module 挪位置了。譬如說
https://github.com/jay/mymod
挪到https://bitbucket.com/jay/mymod
。這時 replace 再次發揮作用,在所有引用這個 module 的 module 的根目錄執行
go mod edit -replace=github.com/jay/mymod=bitbucket.com/jay/mymod
,那些 import 語句就不用一個一個修改了。 (原理同 2.1 ,只是映射的是 託管地址,不是本地,所以這個修改寫入 go.mod 並提交之後, 對其他開發者也能生效 。)不過 mymod 本身,除非你只挪託管不修改 go.mod 的 module path 聲明(意味着 mymod 只作爲依賴存在,自身沒有 main 包需要編譯執行),否則 mymod 內部的 import 語句還是得改爲新的 module path。
需要注意 的是,replace 只對當前 module 直接引用的依賴起作用 ,對於間接引用不起作用。如果 mod1 引用 mod2,然後 mod2 引用 mod3;當 mod3 改動地址時,在 mod1 裏 replace mod3 的地址,只會對 mod1 直接引用 mod3 起作用; mod2 對 mod3 的引用必須在 mod2 裏改。
如果 mod2 是第三方的 module,而它引用的同樣是第三方的 mod3 挪了位置之後,mod2 沒有及時更新,那麼可能你只能 fork 一個 mod2 自行修改了。
這個問題據說可以通過 自建 goproxy 來指定重定向解決。我還沒到需要用到的時候,將來踩了自建 goproxy 的坑再回來寫。
0x3 semver 語義化版本
要理解 go modules 的運作,還有一個是不得不提的,就是 Semantic Version - 語義化版本,縮寫 semver。
關於 semver 是什麼,請看 《語義化版本 2.0》。
詳細的解釋,大家自己看官方文檔,這裏只強調格式:主版本號.次版本號.修訂號
- 主版本號:當你做了 不兼容 的 API 修改 (breaking changes),
- 次版本號:當你做了 向下兼容 的功能性新增 (compatible features),
- 修訂號:當你做了 向下兼容的問題修正 (compatible hotfixs)。
譬如說當前版本號是 v1.2.3 ,在此基礎上:
- fix 了個 bug,沒有影響兼容性,v1.2.4
- 新增 / 改善了功能,依然沒有影響兼容性,v1.3.0
- 任何影響兼容性的修改,無論是 fix bug (這 bug 得多嚴重),還是 API 簽名(名字 or 參數)改動,或者乾脆的刪掉了 deprecated API,反正調用方會出錯,必須跟着修改,v2.0.0
一個特例是,主版本號爲 0 的版本,被認爲是初步開發的 不穩定版本 ,可以不遵循兼容性的原則。
理解了這些,下面的一些做法就比較好理解了。
導入兼容性原則
一個 module 一定是 向下兼容 的。(又叫向後兼容 backwards compatible,指 newer 的版本兼容 older 的版本)反過來說,如果不兼容,會被視作 不同的 module 。
具體操作上,就是 2 以上的主版本號,會加入 module path,使得 module 聲明、導入路徑(包括 require 和 import)、緩存路徑 都發生變化,從而被識別爲不同的 module。唯獨不變的是 託管地址,靠 tag 就可以區分,沒有必要每個主版本新建一個項目。還是以 github.com/jay/mymod
爲例:
主版本號 | 0 或 1 | 2 (3 或以上以此類推) |
---|---|---|
module 聲明 | module github.com/jay/mymod | module github.com/jay/mymod/v2 |
require 列表 | github.com/jay/mymod v1.0.1 | github.com/jay/mymod/v2 v2.0.2 |
import 語句 | import “github.com/jay/mymod/midware/router” | import “github.com/jay/mymod/v2/midware/router” |
選擇最新版本
在同一個主版本下,如果在添加依賴時你沒有指定版本(也就是你沒有手動 go get github.com/jay/[email protected]
,或者只是指定了大版本 go get github.com/jay/mymod@v1
沒有指定次版本),那麼第一次獲取依賴時,go 會自動 獲取最新的版本 並將版本信息寫入 go.mod。
在這之後,除非你手動更新,否則 go 會一直使用 go.mod 記錄的版本,不會自動更新。
最小版本選擇
依賴包括 直接依賴 和 間接依賴。mod1 依賴了 mod2,然後 mod2 又依賴了 mod3, mod2 是直接依賴, mod3 是間接依賴。間接依賴在 go.mod 裏以 //indirect
結尾。
執行 go mod graph
可以輸出所有 module 之間的依賴關係。如果項目稍大,內容會很長,長到超出 bash / cmd 的緩衝那種,建議重定向一個文件再搜索。或者 go mod why <package path>
查詢某個 package 被誰依賴。
因爲有 直接依賴 和 間接依賴,而且對某個 module 的間接依賴可能不止一處,就有可能出現依賴的版本不一致。這種不一致又分兩種情況:
-
主版本號不同:這個好辦,參見上一個小節,主版本號不同直接被認爲是不同 module,你依賴你的,我依賴我的,並行不悖。
-
主版本號相同:選擇所有依賴裏,最大的版本號。
例如 同時依賴 v1.0.1、v1.0.2、v1.1.3,那麼選擇 v1.1.3。因爲同一個主版本下是向下兼容的,依賴 v1.0.1 和 v1.0.2 的代碼,調用 v1.1.3 也是可以的;反過來說,v1.1.3 裏可能增加了新功能,依賴它的地方再去調用老版本,很有可能會報錯。
僞版本
go module 允許通過 commit-hash 指定版本 (可以通過 hash 前綴指定,有規定最小長度,但我忘了,這是不推薦的做法),但在獲取時會自動跟 tag 比對,一旦命中會自動轉換成 semver。
如果 module 完全沒有打 tag,或者指定的 hash 不命中 tag,go 會生成一個僞版本號來記錄,格式是 vX.0.0-yyyymmddhhmmss-12位hash
。
+incompatible
在 go.mod 裏可以看見有些依賴後面帶着一個 +incompatible
。這個標記的意思是,依賴的版本在 2 以上,但是這個 module 自身沒有使用 module 模式(也就是根目錄沒有 go.mod),所以無法通過在路徑添加版本來區分主版本。
更多版本選擇的原理,請參考 《Minimal Version Selection》。
延伸:可重現構建
Java 從 ant、Maven 到 gradle,Python distutils、setuptools 到 pip,js 的 npm 和 yarn,go 經歷了 vgo、glide、dep 到 內置 modules,再加上一系列 VCS 和 託管服務(目前是 git 和 github 統一了江湖),各種構建物倉庫。
大家做了那麼多工作,設計這麼複雜的機制,本質上都是爲了一個目的:構建過程可重複,構建產物可重現 。
在軟件個人英雄主義的時代,這不成問題的,代碼是大牛一個人開發的,構建所需要的代碼和工具,都在大牛的電腦上。稍往後,多幾個人加入,也是在一個公司、一個研究機構裏,ftp 共享一下就完事了,最多搭建一個內部的 VCS 服務。
但是在軟件開發網絡大協作的年代,這就變成了一個工程難題。分佈在世界各地,素未謀面的一羣人一起開發,很多問題就會湧現。特別是開源的年代,即使是小公司的項目,一個學生的作業,也極少會從零開始開發,你不可避免地會引用其他人的工作成果。
哪怕只是和別人合作過一個簡單的項目,你都大概率遇到過『你的代碼在我這裏 編譯不過 / 運行報錯。』『不可能,我本地一點問題都沒有,是測試過才提交的。』這種對話。
上述那麼多的 工具 和 機制,是爲了保證分散各處的開發者(可能還有測試、運維團隊),能夠做到共享 一致的環境、一致的配置、一致的代碼版本,一致的依賴,一致的構建腳本,重現一致的構建過程,得到一致的構建產物 。
話題很大,不是三言兩語能夠說清的,就到此爲止。提那麼一下,是希望幫助理解,爲什麼把事情搞得那麼複雜。
參考:https://jaycechant.info/2020/golang-1-13-module-VS-package/