golang 1.13 - module VS package 講的很清楚

在寫 《配置 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 的查找範圍如下:

  1. $GOROOT/pkg 查找 內置包
  2. 查找 相對路徑 的包
  3. 項目根目錄下的 vendor 目錄查找 第三方包
  4. $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

我們來設想一下 開發的不同階段 :

  1. 前期,一個人開發原型 。只有 module 內引用,愛放哪放哪。

  2. 繼續前期,原型 新增了一個 mymod2 ,而且 引用原來的 mymod ,有了 module 間引用,此時你有 兩個選擇

    1. 繼續隨便放 ,譬如 ~/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 。

    2. 把 mymod 按照 module path 託管到對應地址 ,mymod2 就會從託管服務下載 mymod 自動存放到 $HOME/go/pkg/mod/github.com/jay/[email protected] 。下載過程是自動的,存放位置自動跟 “託管地址+版本” 映射,並不需要人工干預。

      需要注意 的是,mymod2 引用的是託管的代碼,~/mymod/ 下的最新修改如果沒有push 到託管,是訪問不到的。

      如果 mymod2 後續也要發佈或者跟其他開發者協作,建議一開始就選擇這種方式 提供引用。否則按 2.1 處理,mymod2 在別人的環境無法獲取 mymod 的依賴。

  3. 中期,其他開發者加入 。爲了其他開發者可以正常地訪問依賴,需要把所有用到的 module 按 module path 放到託管服務上 。(同 2.2)

    託管服務可以是公共的,也可以是私有的。如果是私有的,需要配置 ssh 以達到免密訪問。(ssh 配置不展開。)

    考慮到遲早需要發佈到託管,最好初始化時就考慮 把託管地址作爲 module path 。

  4. 後期,持續開發和維護。也許是 公共轉私有(或者反過來,開源),又或者項目改名,或者某個公共託管撂挑子不幹了——總之,有些 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》。

詳細的解釋,大家自己看官方文檔,這裏只強調格式:主版本號.次版本號.修訂號

  1. 主版本號:當你做了 不兼容 的 API 修改 (breaking changes),
  2. 次版本號:當你做了 向下兼容 的功能性新增 (compatible features),
  3. 修訂號:當你做了 向下兼容的問題修正 (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/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章