Go modules基礎精進,六大核心概念全解析

語言: CN / TW / HK

點選一鍵訂閱《雲薦大咖》專欄,獲取官方推薦精品內容,學技術不迷路!

 

               


Go 語言做開發時,路徑是如何定義的?Go Mudules又為此帶來了哪些改變?本文將會全面介紹Go modules六大核心概念,包括了設計理念與相容性原則等,掌握這些技術點對於管理和維護Go 模組有重要價值。

上一篇文章中,筆者介紹瞭如何以經典的 hello world 為例建立一個 Go module 模組,需要說明的是一個模組中是可以包含多個包(package)的,它們是可以被一起釋出、打包、版本化的。同時,Go Modules 也可以通過版本管理系統(githubgitlab)或者 goproxy 代理進行下載。在使用 Go Modules 之前,建議大家弄清楚息息相關的六大核心概念,以方便大家在後期的開發、使用過程中理解更加深入。

 

我們在使用 Go 語言做開發時經常會遇到像 example.com/test 或者 example.com/test/pkg/log這樣的路徑,這些路徑到底是怎麼定義的,兩者中存在什麼關係,在 Go Modules 中又扮演著怎樣的角色呢?Go Modules 的引入對已有的包又引入了哪些新的概念,它們是如何協作的?對相容性提出了哪些新的要求呢?讓我們一起來看一下。

 

 

一:模組路徑 (Module Path)


Go 使用 “module path” 來區分不同的 module 模組,它在 go.mod 檔案中被定義,這個檔案中還包含了這個模組編譯所需的其他依賴。如果一個目錄中包含了 go.mod 檔案,那麼這個目錄就是這個 Go 模組的根目錄了。

 

另外,還要介紹下包(package) 這個概念,它在 Go Modules 出現之前就已經存在了。Go 模組中的  (package)”是處於同一目錄中的一些原始碼檔案的集合,這些檔案將被編譯在一起。包路徑(package path是模組路徑和子目錄(模組根目錄的相對路徑)的組合。舉個例子,在模組“golang.org/x/net”下的 html 目錄中有個包,這個包的路徑是 “golang.org/x/net/html”

 

總結下來就是: 一個程式碼倉庫可以包含多個 Go 模組,一個 Go 模組可以包含多個 Go 包。

 

模組路徑是一個 Go 模組的規範名稱,用於區分不通的模組。同時他還是該模組下 Go 包的路徑字首。理論上,模組路徑應該至少包含兩個關鍵資訊:

模組的作用 哪裡獲取該模組

 

二:版本號與相容性原則


版本號相當於是一個模組的只讀快照,它可以是正式的釋出版本,也可以是預釋出版本。 每個版本都以字母 v 開頭,後跟一個語義版本,例如 v1.0.0

 

總而言之,語義版本由三個由點分隔的非負整數(主要版本、次要版本和補丁版本,從左到右)組成。 補丁版本後可以跟一個以連字元開頭的可選預釋出字串。 預釋出字串或補丁版本後可以跟一個以加號開頭的構建元資料字串。 例如,v0.0.0v1.12.134v8.0.5-prev2.0.9+meta 等都是有效版本。

 

版本號中的資訊代表了這個版本是否是一個穩定版,是否保持了與之前版本的相容性。

 

  • 當維護的模組發生了一些不相容變更,比如修改了外部可呼叫的介面或者函式時,需要對主版本號進行遞增,並且將次版本號和補丁版本號置為零。比如在模組中移除了一個包。
  • 在模組中新增一些新的函式或者介面,並沒有影響模組的相容性時,需要對次版本號進行遞增,並且將補丁版本號置為零。
  • 當修復了一些 bug 或者進行了一些優化時,只需要對補丁版本號進行遞增就可以了,因為這些變更不會對已經公開的介面進行變更。
  • 預釋出字尾代表了這個版本號是一個預釋出版本。預釋出版本號的排序會在正式版本號的前面。舉個例子,v1.2.3-pre 會排列在 v1.2.3 前面。
  • 元資料字尾會在版本比對中被忽略,版本控制中的程式碼庫會忽略帶有構建元資料的標籤,但在 go.mod 檔案中指定的版本中會保留構建元資料。如果一個模組還沒有遷移到 Go Modules 並且主版本號是 2 或者更高,+incompatible 字尾會被新增到版本號上。

 

如果一個版本的主版本號是 0 或者它有一個預釋出版本字尾,那麼這個版本被認為是一個不穩定版本。通常,不穩定版本不受相容性限制的,舉個例子,v0.2.0 可能和 v0.1.0 是不相容的,v1.5.0-beta 可能和 v1.5.0 也是不相容的。

 

Go 可以通過 tags、分支、和 commit 雜湊值來獲取模組,即使這些命名沒有遵循這些規則。在主模組中,go 命令會自動的將這些 revision 轉化為符合標準的版本號,其被稱為偽版本號(pseudo-version)。舉個例子,當執行下面的命令時:

go get -d golang.org/x/net@daa7c041

Go 會講指定的 hash daa7c041 轉化為一個偽版本號 v0.0.0-20191109021931-daa7c04131f5。在主模組之外需要規範版本,如果 go.mod 檔案中出現像 master 這樣的非規範版本,go 命令會報錯。

 

 

 

三:偽版本號


偽版本號是一種預釋出版本號的格式,其中包含了指定的 commit hash 值。另外,對於沒有打標籤的程式碼庫,也可以使用偽版本號來表明某個版本,它可以在正式釋出某個版本之前方便的進行測試。舉個例子,每個偽版本號都有三部分組成:

 

  1. 基本版本字首(vX.0.0 或 vX.Y.Z-0),它要麼源自修訂版之前的語義版本標籤,要麼源自 vX.0.0(如果沒有此類標籤)。
  2. 時間戳 (yyyymmddhhmmss),這是建立 commit 的 UTC 時間。 在 Git 中,這是 commit 提交時間。
  3. commit 識別符號 (abcdefabcdef),它是提交 commit 雜湊的 12 個字元的字首,或者在 Subversion 中,是一個用零填充的修訂號。

 

在這三個部分之下,又分為以下多種情況

 

  • 如果之前沒有基版本,那麼諸如 vX.0.0-yyyymmddhhmmss-abcdefabcdef 這樣的偽版本號將被啟用。主版本號 X 需要匹配模組的主版本號字尾。
  • 如果之前的基版本號是一個像 vX.Y.Z-pre 這樣的預釋出版本,那麼 vX.Y.Z-pre.0.yyyymmddhhmmss-abcdefabcdef 將被採用。
  • 如果之前的基版本號是一個像 vX.Y.Z 這樣的正式版本,那麼 vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdefabcdef 將被採用,舉個例子,如果基版本號是 v1.2.3,偽版本號可能是 v1.2.4-0.20191109021931-daa7c04131f5。
  • 基於不同的基礎版本號,多個偽版本號是有可能指向同一個 commit hash 的,在對一個低於已經存在的偽版本號打標籤時,這種情況就會發生。

 

上面介紹的這種偽版本號攜帶了兩個非常有用的資訊:

1. 偽版本號會高於這些已經存在的基礎版本號,但是會低於後面生成的其他偽版本號。

2. 有相同基礎版本字首的偽版本按時間順序排序。

 

偽版本號不需要手動指定。很多 Go 命令可以接受一個 commit hash 或者分支名,然後自動將其轉化為一個偽版本號(或者一個標籤,如果存在的話)。例如:

go get -d example.com/mod@master
go list -m -json example.com/mod@abcd1234

 

四:主版本號字尾


 

從主版本號 2 開始,模組路徑中必須新增一個像 /v2 這樣的一個和主版本號匹配的字尾。舉個例子如果一個模組在版本 v1.0.0 是的路徑為 example.com/test,那麼它在 v2.0.0 時的路徑將是 example.com/test/v2。

 

主版本號字尾遵循匯入相容規則:

 

如果一個新程式碼包和老程式碼包擁有同樣的匯入路徑,那麼新包必須保證對老程式碼包的向後相容。

 

  • 根據定義,模組的新主版本中的包與先前主版本中的相應包不向後相容。 因此,從 v2 開始,包需要新的匯入路徑。 這是通過向模組路徑新增主版本字尾來實現的。 由於模組路徑是模組內每個包的匯入路徑的字首,因此將主版本字尾新增到模組路徑可為每個不相容的版本提供不同的匯入路徑。
  • 主版本 v0 或 v1 不允許使用主版本字尾。 v0 和 v1 之間的模組路徑不需要更改,因為 v0 版本為不穩定,沒有相容性保證。 此外,對於大多數模組,v1 向後相容最新的 v0 版本, v1 版本才開始作為對相容性的承諾。
  • 這裡有一個特例,以 gopkg.in/ 開頭的模組路徑必須始終具有主版本字尾,即使是 v0 和 v1 版本。 字尾必須以點而不是斜線開頭(例如,gopkg.in/yaml.v2)。因為在 Go Modules 推出之前,gopkg.in 就沿用了這個規則,為了能讓引入 gopkg.in 包的程式碼能繼續匯入編譯, Go 做了一些相容性工作。
  • 主版本字尾可以讓一個模組的多個主版本共存於同一個構建中。 這可以很好的解決鑽石依賴性問題(diamond dependency conflict) https://jlbp.dev/what-is-a-diamond-dependency-conflict。 通常,如果傳遞依賴項在兩個不同版本中需要一個模組,則將使用更高的版本。 但是,如果兩個版本不相容,則任何一個版本都不會滿足所有的呼叫者。 由於不相容的版本必須具有不同的主版本號,因此主版本字尾具有不同的模組路徑,這樣就不存在衝突了:具有不同字尾的模組被視為單獨的模組,並且它們的包的匯入路徑也是不同的。
  • 因為很多 Go 專案在遷移到 Go 模組之前就釋出了 v2 或更高版本的版本,所以沒有使用主要版本字尾。對於這些版本,Go 使用 +incompatible 構建標記來進行註釋(例如,v2.0.0+incompatible)。

 

五:解析包路徑到模組路徑的流程


通常在使用“go get”時可能是指定到一個包路徑,而非模組路徑,Go 是如何找到模組路徑的呢?

 

go 命令會在主模組(當前模組)的 build list 中搜索有哪些模組路徑匹配這個包路徑的字首。舉個例子,如果匯入的包路徑是 example.com/a/b,發現 example.com/a 是一個模組路徑,那麼就會去檢查 example.com/a 在 b 目錄中是否包含這個包,在這個目錄中要至少存在一個 go 原始碼檔案才會被認為是一個有效的包。編譯約束(Build Constraints)在這一過程中不會被應用。 如果確實在 build list 中找到了一個模組包含這個包,那麼這個模組將被使用。如果沒有發現模組能提供這個包或者發現兩個及兩個以上的模組提供了這個包,那麼 go 命令會提示報錯。但是你可以指定 -mod=mod 來使 go 命令嘗試下載本地找不到的包,並且更新 go.mod 和 go.sum。go get 和 go mod tidy 這兩個命令會自動的做這些工作。

 

當 go 命令試圖下載一個新的程式碼包時,它回去檢查 GOPROXY 環境變數,這是一個使用逗號分隔的 URL 列表,當然也支援像 direct 和 off 這樣的關鍵字。代理 URL 代表 go 將使用 GOPROXY 協議拉取模組,direct 表示 go 需要和版本控制系統直接互動,off 不需要和外界做任何互動。另外,GOPRIVATE 和 GONOPROXY 環境變數也可以精細的控制 go 下載程式碼包的策略。

 

對於 GOPROXY 列表中的每一項, go 命令回去請求模組路徑的每一個字首。對於請求成功的模組,go 命令回去下載最新模組並且檢查這個某塊是否包含請求的包。如果多個模組包含了請求的包,擁有最長路徑的將被選擇。如果發現的模組中沒有包含這個包,會報錯。如果沒有模組被發現,go 命令會嘗試 GOPROXY 列表中的下一個配置項,如果最終都嘗試過沒有發現則會報錯。舉個例子,假設使用者想要去獲取 golang.org/x/net/html 這個包,之前配置的 GOPROXY 為 https://corp.example.com,https://goproxy.io。go 命令會遵循下面的請求順序:

向 https://corp.example.com/ 發起請求 (並行):
Request for latest version of golang.org/x/net/html
Request for latest version of golang.org/x/net
Request for latest version of golang.org/x
Request for latest version of golang.org

如果 https://corp.example.com/ 上面都失敗了返回 410 或者 404 狀態碼,向 https://proxy.golang.org/ 發起請求:

Request for latest version of golang.org/x/net/html
Request for latest version of golang.org/x/net
Request for latest version of golang.org/x
Request for latest version of golang.org

當一個需要的模組被發現後,go 命令會將這個依賴模組的路徑和對應版本新增到主模組的 go.mod 檔案中。這樣就確保了以後在編譯該模組時,同樣的模組版本將被使用,保證了編譯的可重複性。如果解析的程式碼包沒有被主模組直接引用,在 go.mod 檔案中新增的新依賴後會有 // indirect 註釋。

六:go.mod 檔案


就像前面提到過的,模組的定義是由一個 UTF-8 編碼的名為 go.mod 文字檔案定義的。 這個檔案是按照“行”進行組織的(line-oriented)。每一行都有一個獨立的指令,有一個預留關鍵字和一些引數組成。比如:

module example.com/my/thing
go 1.17
require example.com/other/thing v1.0.2
require example.com/new/thing/v2 v2.3.4
exclude example.com/old/thing v1.2.3
replace example.com/bad/thing v1.4.5 => example.com/good/thing v1.4.5
retract [v1.9.0, v1.9.5]

開頭的關鍵詞可以以行的形式被歸總為塊,就像日常所用的 imports 一樣,所以可以改成下面這樣:

require (
 example.com/new/thing/v2 v2.3.4
 example.com/old/thing v1.2.3
)

go.mod 檔案的設計兼顧了開發者的可讀性和機器的易寫性。go 命令也提供了幾個子命令來幫組開發者修改 go.mod 檔案。舉個例子,go get 命令可以在需要的時候更新 go.mod 檔案。go mod edit 命令可以對檔案做一些底層的修改操作。如果我們也有類似的需求,可以使用 golang.org/x/mod/modfile 包以程式設計方式進行同樣的更改。通過這個包,也可以一窺底層 go.mod 的 struct 結構:

// go.mod 檔案的組成形式
type File struct {
 Module *Module  // 模組路徑
 Go *Go  // Go 版本
 Require []*Require // 依賴模組
 Exclude []*Exclude // 排除模組
 Replace []*Replace // 替換模組
 Retract []*Retract // 撤回模組
}
// A Module is the module statement.
type Module struct {
 Mod module.Version
 Deprecated string
}
// A Go is the go statement.
type Go struct {
 Version string // "1.23"
}
// An Exclude is a single exclude statement.
type Exclude struct {
 Mod module.Version
}
// A Replace is a single replace statement.
type Replace struct {
 Old module.Version
 New module.Version
}
// A Retract is a single retract statement.
type Retract struct {
 VersionInterval
 Rationale string
}

從上面的 Module 的 struct 中可以看到 “Deprecated”這一結構,在 Go Modules 推出的早期是沒有這個設計的,那麼這個欄位是做什麼用的呢? 估計很多人都不知道,如果我們維護的一個模組主版本從 v1 演進到了 v2,而不再維護 v1 版本了,希望使用者儘可能使用 v2,通過上面的介紹知道v1 和 v2 是不同的 import path,“Retract”也無能為力,這時候這個 “Deprecated”就起作用了,看下面的例子:

// Deprecated: in example.com/a/[email protected], the latest supported version is example.com/a/b/v2.
module example.com/a/b
go 1.17

當用戶再去獲取 example.com/a/b 這個版本時,go 命令可以感知到這個版本已經不再維護了,會報告給使用者:

go get -d example.com/a/b@v1.9.0
go: warning: module example.com/deprecated/a is deprecated: in example.com/a/b@v1.9.0, the latest supported version is example.com/a/b/v2

使用者就可以根據提示進行 v2 程式碼拉取了。

《Go modules基礎精進,六大核心概念全解析》一文全面介紹了 Go Modules 中的模組、模組路徑、包、包路徑、如何通過包路徑尋找模組路徑,還介紹了版本號和偽版本號,最後簡單介紹了 go.mod 檔案,以及其中不為人知的“Deprecated”功能,瞭解這些概念、設計理念和相容性原則,將對管理和維護自己的 Go 模組大有幫助。

以上這些概念都是平常使用 Go 語言會高頻接觸到的內容,理解版本號和偽版本號的區別和設計原則,可以幫助我們清楚按照 semver 的標準定義自己的 tag 是多麼重要。同時,遵循Go Modules 定義的相容性原則,上下游開發者在社群協同時將會變得更加友好和高效。接下來的系列文章將會開始具體來了解 Go Modules 中的設計細節,例如 go.mod 檔案詳解以及配套的 go mod 子命令等,敬請期待。另外,騰訊雲 goproxy 企業版已經產品化,需要了解的同學可以點選這裡

                       

李保坤往期精彩文章推薦:Go語言重新開始,Go Modules 的前世今生與基本使用

《雲薦大咖》是騰訊雲加社群精品內容專欄。雲薦官特邀行業佼者,聚焦於前沿技術的落地及理論實踐之上,持續為您解讀雲時代熱點技術、探索行業發展新機。點選一鍵訂閱,我們將為你定期推送精品內容。