Go 包操作之如何拉取私有的Go Module

2023-10-25 12:00:08

Go 包操作之如何拉取私有的Go Module

在前面,我們已經瞭解了GO 專案依賴包管理與Go Module常規操作,Go Module 構建模式已經成為了 Go 語言的依賴管理與構建的標準。

在平時使用Go Module 時候,可能會遇到以下問題:

  • 在某 module 尚未釋出到類似GitHub 或 Gitee 這樣的網站前,如何 import 這個原生的 module?
  • 如何拉取私有 module?

一、匯入本地 module

1.1 依賴本地尚未釋出的 module

如果我們的專案依賴的是本地正在開發、尚未釋出到公共站點上的 Go Module,那麼我們應該如何做呢?

例如:假設有個hello-module的專案,你的main包中依賴了moduleA,程式碼如下:

package main

import "gitee.com/tao-xiaoxin/study-basic-go/hello-module/moduleA"

func main() {
	moduleA.ModuleA()
}

並且,這個專案中的moduleA 依賴 moduleB,此時此刻,module A 和 moduleB 還沒有釋出到gitee公共託管站點上,它的原始碼還在你的開發機器上。也就是說,Go 命令無法在gitee.com/user/上找到並拉取 module A 和 module B,這時,使用go mod tidy命令,就會收到類似下面這樣的報錯資訊:

$go mod tidy
go: finding module for package gitee.com/user/moduleB
go: finding module for package gitee.com/user/moduleA
go: gitee.com/tao-xiaoxin/study-basic-go imports
        gitee.com/user/moduleA: module gitee.com/user: git ls-remote -q origin in /Users/thinkook/go/pkg/mod/cache/vcs/ff424152e6f6be73e07b96e5d8e06c6cd9f86dc9903058919a7b8737718a8418: exit status 128:
        致命錯誤:倉庫 'https://gitee.com/user/' 未找到
go: gitee.com/tao-xiaoxin/study-basic-go/moduleA imports
        gitee.com/user/moduleB: module gitee.com/user: git ls-remote -q origin in /Users/thinkook/go/pkg/mod/cache/vcs/ff424152e6f6be73e07b96e5d8e06c6cd9f86dc9903058919a7b8737718a8418: exit status 128:
        致命錯誤:倉庫 'https://gitee.com/user/' 未找到

所以,Go提供了兩種方式可以匯入本地正在開發的 Go Module

1.2 Go Module 開發中本地匯入兩種方式

1.2.1 使用 replace 指令

介紹: 使用replace指令可以替代遠端依賴模組的路徑,將其指向原生的模組路徑,便於本地開發和測試。

基本使用: 下面是一個範例replace指令的使用方式:

replace example.com/module@版本號 => 你的本地Module路徑(可以使用相對路徑或者絕對路徑)

接著,我們繼續回到上面的舉例中,首先,我們需要在 module a 的 go.mod 中的 require 塊中手工加上這一條並且替換為本地路徑上的module AmoduleB:

replace (
	gitee.com/user/moduleA v1.0.0 => ../moduleA
	gitee.com/user/moduleB v1.0.0 => ../moduleB
)

這裡的v1.0.0版本號是一個「假版本號」,目的是滿足go.modrequire塊的語法要求。

或者使用go mod edit 命令編輯 go.mod 檔案:

go mod edit -replace=gitee.com/user/[email protected]=../moduleA -replace=gitee.com/user/[email protected]=../moduleB

這樣修改之後,Go 命令就會讓module A依賴你本地正在開發、尚未釋出到程式碼託管網站的module B的原始碼了,並且main函數依賴你本地正在開發、尚未釋出到程式碼託管網站的module B的原始碼了。

雖然雖然這個方案可以解決上述問題,但是在平時開發過程中,go.mod 檔案通常需要上傳到程式碼伺服器上,這意味著,另一個開發人員下載了這份程式碼後,很可能無法成功編譯。在這個方法中,require指示符將gitee.com/user/moduleA v1.0.0替換為一個本地路徑下的module A的原始碼版本,但這個本地路徑因開發者環境而異。為了成功編譯module A和主程式,該開發人員必須將replace後面的本地路徑更改為適應自己的環境路徑。

於是,每當開發人員 pull 程式碼後,第一件事就是要修改go.mod中的replace塊。每次上傳程式碼前,可能還要將replace路徑還原,這是一個很繁瑣的事情。於是,Go開發團隊在Go 1.18 版本中加入了 Go 工作區(Go workspace,也譯作 Go 工作空間)輔助構建機制。

上述舉例程式碼倉庫地址:點我進入

1.2.2 使用工作區模式

介紹:Go 工作區模式是 Go 語言 1.18 版本引入的新功能,允許開發者將多個本地路徑放入同一個工作區中,這樣,在這個工作區下各個模組的構建將優先使用工作區下的模組的原始碼。工作區模式具有以下優勢:

  • 可以將多個本地模組放入同一個工作區中,方便開發者管理。
  • 可以解決「偽造 go.mod」方案帶來的那些問題。
  • 可以提高模組構建的效能。

常用命令:

Go 工具提供了以下命令來幫助開發者使用工作區模式:

  • go work edit:提供了用於修改go.work的命令列介面,主要是給工具或指令碼使用。
  • go work init:初始化工作區檔案 go.work
  • go work use:將模組新增到工作區檔案
  • go work sync:把go.work檔案裡的依賴同步到workspace包含的Module的go.mod檔案中。

基本使用:

  1. 首先,我們初始化 Go workspace 使用命令go work init命令如下:
go work init [moddirs]

moddirs是Go Module所在的本地目錄。如果有多個Go Module,就用空格分開。如果go work init後面沒有引數,會建立一個空的workspace。

執行go work init後會生成一個go.work檔案,go.work裡列出了該workspace需要用到的Go Module所在的目錄,workspace目錄不需要包含你當前正在開發的Go Module程式碼。

  1. 如果要給workspace新增Go Module,可以使用如下命令:
go work use [-r] moddir

如果帶有-r引數,會遞迴查詢-r後面的路徑引數下的所有子目錄,把所有包含go.mod檔案的子目錄都新增到go work檔案中。

  1. 如果要同步依賴到workspace包含的Module的go.mod檔案中,可以使用如下命令:

    go work sync
    

介紹完之後,我們回到上面的例子中,現在我們進入 gowork下面,然後通過下面命令初始化一個go.work:

go work init .

我們看到go work init命令建立了一個go.work檔案,使用go env GOWORK命令檢視go.work所在位置

$go env GOWORK
~/workspace/GolandProjects/study-basic-go/syntax/gowork/go.work

接著,我們在 module ago.work 中的 use 塊中替換為本地路徑上的module AmoduleB:

go 1.21.1

use (
	.
	./moduleA
	./moduleB
)

支援replace指示符:go.work還支援replace指示符,使用方法和上面一樣

上面的程式碼地址:點我

二、拉取私有 module 的需求與參考方案

自從 Go 1.11 版本引入 Go Module 構建模式後,通過 Go 命令拉取專案依賴的公共 Go Module,已不再是一個「痛點」。現在,我們只需要在每個開發機上設定環境變數 GOPROXY,設定一個高效且可靠的公共 GOPROXY 服務,就可以輕鬆地拉取所有公共 Go Module 了。

但隨著公司內 Go 使用者和 Go 專案的增多,「重造輪子」的問題就出現了。抽取公共程式碼放入一個獨立的、可被複用的內部私有倉庫成為了必然,這樣我們就有了拉取私有 Go Module 的需求。

一些公司或組織的所有程式碼,都放在公共 vcs 託管服務商那裡(比如 github.com),私有 Go Module 則直接放在對應的公共 vcs 服務的 private repository(私有倉庫)中。如果你的公司也是這樣,那麼拉取託管在公共 vcs 私有倉庫中的私有 Go Module,也很容易,見下圖:

也就是說,只要我們在每個開發機上,設定公共 GOPROXY 服務拉取公共 Go Module,同時將私有倉庫設定到 GOPRIVATE 環境變數,就可以了。這樣,所有私有模組的拉取都將直接連線到程式碼託管伺服器,不會通過 GOPROXY 代理服務,並且不會向 GOSUMDB 伺服器發出 Go 包的雜湊值校驗請求。

當然,這個方案有一個前提,那就是每個開發人員都需要具有存取公共 vcs 服務上的私有 Go Module 倉庫的許可權,憑證的形式不限,可以是 basic auth 的 user 和 password,也可以是 personal access token(類似 GitHub 那種),只要按照公共 vcs 的身份認證要求提供就可以了。

不過,更多的公司 / 組織,可能會將私有 Go Module 放在公司 / 組織內部的 vcs(程式碼版本控制)伺服器上,就像下面圖中所示:

那麼這種情況,我們該如何讓 Go 命令,自動拉取內部伺服器上的私有 Go Module 呢?這裡給出兩個參考方案。

2.1 方案一:通過直連組織公司內部的私有 Go Module 伺服器拉取

在這個方案中,我們看到,公司內部會搭建一個內部 goproxy 服務(也就是上圖中的 in-house goproxy)。這樣做有兩個目的,一是為那些無法直接存取外網的開發機器,以及 ci 機器提供拉取外部 Go Module 的途徑,二來,由於 in-house goproxy 的 cache 的存在,這樣做還可以加速公共 Go Module 的拉取效率。

另外,對於私有 Go Module,開發機只需要將它設定到 GOPRIVATE 環境變數中就可以了,這樣,Go 命令在拉取私有 Go Module 時,就不會再走 GOPROXY,而會採用直接存取 vcs(如上圖中的 git.yourcompany.com)的方式拉取私有 Go Module。

這個方案十分適合內部有完備 IT 基礎設施的公司。這型別的公司內部的 vcs 伺服器都可以通過域名存取(比如 git.yourcompany.com/user/repo),因此,公司內部員工可以像存取公共 vcs 服務那樣,存取內部 vcs 伺服器上的私有 Go Module。

2.2 方案二:將外部 Go Module 與私有 Go Module 都交給內部統一的 GOPROXY 服務去處理:

在這種方案中,開發者只需要把 GOPROXY 設定為 in-house goproxy,就可以統一拉取外部 Go Module 與私有 Go Module。

但由於 go 命令預設會對所有通過 goproxy 拉取的 Go Module,進行 sum 校驗(預設到 sum.golang.org),而我們的私有 Go Module 在公共 sum 驗證 server 中又沒有資料記錄。因此,開發者需要將私有 Go Module 填到 GONOSUMDB 環境變數中,這樣,go 命令就不會對其進行 sum 校驗了。

不過這種方案有一處要注意:in-house goproxy 需要擁有對所有 private module 所在 repo 的存取許可權,才能保證每個私有 Go Module 都拉取成功。

在平時開發中,更推薦第二個方案。在第二個方案中,我們可以將所有複雜性都交給 in-house goproxy 這個節點,開發人員可以無差別地拉取公共 module 與私有 module,心智負擔降到最低。

三、統一 Goproxy 方案的實現思路與步驟

3.1 goproxy 服務搭建

Go module proxy 協定規範釋出後,Go 社群出現了很多成熟的 Goproxy 開源實現,比如最初的 Athens,還有國內的兩個優秀的開源實現:goproxy.cngoproxy.io 等。其中,goproxy.io 在官方站點給出了企業內部部署的方法,所以今天我們將基於 goproxy.io 來實現我們的方案。

我們在上圖中的 in-house goproxy 節點上執行這幾個步驟安裝 goproxy:

$mkdir ~/.bin/goproxy
$cd ~/.bin/goproxy
$git clone https://github.com/goproxyio/goproxy.git
$cd goproxy
$make

編譯後,我們會在當前的 bin 目錄(~/.bin/goproxy/goproxy/bin)下看到名為 goproxy 的可執行檔案。

然後,我們建立 goproxy cache 目錄:

$mkdir /root/.bin/goproxy/goproxy/bin/cache

再啟動 goproxy:

$./goproxy -listen=0.0.0.0:8081 -cacheDir=/root/.bin/goproxy/goproxy/bin/cache -proxy https://goproxy.io
goproxy.io: ProxyHost https://goproxy.io

啟動後,goproxy 會在 8081 埠上監聽(即便不指定,goproxy 的預設埠也是 8081),指定的上游 goproxy 服務為 goproxy.io。

不過要注意下:goproxy 的這個啟動引數並不是最終版本的,這裡我僅僅想驗證一下 goproxy 是否能按預期工作。我們現在就來實際驗證一下。

首先,我們在開發機上設定 GOPROXY 環境變數指向 10.10.20.20:8081:

// .bashrc
export GOPROXY=http://10.10.20.20:8081

生效環境變數後,執行下面命令:

$go get github.com/pkg/errors

結果和我們預期的一致,開發機順利下載了 github.com/pkg/errors 包。我們可以在 goproxy 側,看到了相應的紀錄檔:

goproxy.io: ------ --- /github.com/pkg/@v/list [proxy]
goproxy.io: ------ --- /github.com/pkg/errors/@v/list [proxy]
goproxy.io: ------ --- /github.com/@v/list [proxy]
goproxy.io: 0.146s 404 /github.com/@v/list
goproxy.io: 0.156s 404 /github.com/pkg/@v/list
goproxy.io: 0.157s 200 /github.com/pkg/errors/@v/list

在 goproxy 的 cache 目錄下,我們也看到了下載並快取的 github.com/pkg/errors 包:

$cd /root/.bin/goproxy/goproxy/bin/cache
$tree
.
└── pkg
    └── mod
        └── cache
            └── download
                └── github.com
                    └── pkg
                        └── errors
                            └── @v
                                └── list

8 directories, 1 file

這就標誌著我們的 goproxy 服務搭建成功,並可以正常運作了。

3.2 自定義包匯入路徑並將其對映到內部的 vcs 倉庫

一般公司可能沒有為 VCS 伺服器分配域名,我們也不能在 Go 私有包的匯入路徑中放入 IP 地址,因此我們需要給我們的私有 Go Module 自定義一個路徑,比如:mycompany.com/go/module1。我們統一將私有 Go Module 放在 mycompany.com/go 下面的程式碼倉庫中。

那麼,接下來的問題就是,當 goproxy 去拉取 mycompany.com/go/module1 時,應該得到 mycompany.com/go/module1 對應的內部 VCS 上 module1 倉庫的地址,這樣,goproxy 才能從內部 VCS 程式碼伺服器上下載 module1 對應的程式碼,具體的過程如下:

那麼我們如何實現為私有 module 自定義包匯入路徑,並將它對映到內部的 vcs 倉庫呢?

其實方案不止一種,這裡我使用了 Google 雲開源的一個名為 govanityurls 的工具,來為私有 module 自定義包匯入路徑。然後,結合 govanityurls 和 Nginx,我們就可以將私有 Go Module 的匯入路徑對映為其在 VCS 上的程式碼倉庫的真實地址。具體原理你可以看一下這張圖:

首先,goproxy 要想不把收到的拉取私有 Go Module(mycompany.com/go/module1)的請求轉發給公共代理,需要在其啟動引數上做一些手腳,比如下面這個就是修改後的 goproxy 啟動命令:

$./goproxy -listen=0.0.0.0:8081 -cacheDir=/root/.bin/goproxy/goproxy/bin/cache -proxy https://goproxy.io -exclude "mycompany.com/go"

這樣,凡是與 -exclude 後面的值匹配的 Go Module 拉取請求,goproxy 都不會將其轉發給 goproxy.io,而是直接請求 Go Module 的「源站」。

而上面這張圖中要做的,就是將這個「源站」的地址,轉換為企業內部 VCS 服務中的一個倉庫地址。然後我們假設 mycompany.com 這個域名並不存在(很多小公司沒有內部域名解析能力),從圖中我們可以看到,我們會在 goproxy 所在節點的 /etc/hosts 中新增這樣一條記錄:

127.0.0.1 mycompany.com

這樣做了後,goproxy 發出的到 mycompany.com 的請求實際上是發向了本機。而上面這圖中顯示,監聽本機 80 埠的正是 nginxnginx 關於 mycompany.com 這一主機的設定如下:

// /etc/nginx/conf.d/gomodule.conf

server {
        listen 80;
        server_name mycompany.com;

        location /go {
                proxy_pass http://127.0.0.1:8080;
                proxy_redirect off;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

                proxy_http_version 1.1;
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection "upgrade";
        }
}

我們看到,對於路徑為 mycompany.com/go/xxx 的請求,nginx 將請求轉發給了 127.0.0.1:8080,而這個服務地址恰恰就是 govanityurls 工具監聽的地址。

govanityurls 這個工具,是前 Go 核心開發團隊成員 Jaana B. Dogan 開源的一個工具,這個工具可以幫助 Gopher 快速實現自定義 Go 包的 go get 匯入路徑。

govanityurls 本身,就好比一個「導航」伺服器。當 go 命令向自定義包地址發起請求時,實際上是將請求傳送給了 govanityurls 服務,之後,govanityurls 會將請求中的包所在倉庫的真實地址(從 vanity.yaml 組態檔中讀取)返回給 go 命令,後續 go 命令再從真實的倉庫地址獲取包資料。

注:govanityurls 的安裝方法很簡單,直接 go install/go get github.com/GoogleCloudPlatform/govanityurls 就可以了。在我們的範例中,vanity.yaml 的設定如下:

host: mycompany.com

paths:
  /go/module1:
      repo: ssh://[email protected]/module1
      vcs: git

也就是說,當 govanityurls 收到 nginx 轉發的請求後,會將請求與 vanity.yaml 中設定的 module 路徑相匹配,如果匹配 OK,就會將該 module 的真實 repo 地址,通過 go 命令期望的應答格式返回。在這裡我們看到,module1 對應的真實 VCS 上的倉庫地址為:ssh://[email protected]/module1

所以,goproxy 會收到這個地址,並再次向這個真實地址發起請求,並最終將 module1 快取到本地 cache 並返回給使用者端。

3.3 開發機 (使用者端) 的設定

前面範例中,我們已經將開發機的 GOPROXY 環境變數,設定為 goproxy 的服務地址。但我們說過,凡是通過 GOPROXY 拉取的 Go Module,go 命令都會預設把它的 sum 值放到公共 GOSUM 伺服器上去校驗。

但我們實質上拉取的是私有 Go Module,GOSUM 伺服器上並沒有我們的 Go Module 的 sum 資料。這樣就會導致 go build 命令報錯,無法繼續構建過程。

因此,開發機使用者端還需要將 mycompany.com/go,作為一個值設定到 GONOSUMDB 環境變數中:

export GONOSUMDB=mycompany.com/go

這個環境變數設定一旦生效,就相當於告訴 go 命令,凡是與 mycompany.com/go 匹配的 Go Module,都不需要再做 sum 校驗了。

到這裡,我們就實現了拉取私有 Go Module 的方案。

3.4 方案的「不足」

3.4.1 第一點:開發者還是需要額外設定 GONOSUMDB 變數

由於 Go 命令預設會對從 GOPROXY 拉取的 Go Module 進行 sum 校驗,因此我們需要將私有 Go Module 設定到 GONOSUMDB 環境變數中,這就給開發者帶來了一個小小的「負擔」。

對於這個問題,我的解決建議是:公司內部可以將私有 Go 專案都放在一個特定域名下,這樣就不需要為每個 Go 私有專案單獨增加 GONOSUMDB 設定了,只需要設定一次就可以了。

3.4.2 第二點:新增私有 Go Module,vanity.yaml 需要手工同步更新

這是這個方案最不靈活的地方了,由於目前 govanityurls 功能有限,針對每個私有 Go Module,我們可能都需要單獨設定它對應的 VCS 倉庫地址,以及獲取方式(git、svn 或 hg)。

關於這一點,我的建議是:在一個 VCS 倉庫中管理多個私有 Go Module。相比於最初 Go 官方建議的一個 repo 只管理一個 module,新版本的 Go 在一個 repo 下管理多個 Go Module 方面,已經有了長足的進步,我們已經可以通過 repo 的 tag 來區別同一個 repo 下的不同 Go Module。

不過對於一個公司或組織來說,這點額外工作與得到的收益相比,應該也不算什麼!

3.4.3 第三點:無法劃分許可權

在講解上面的方案的時候,我們也提到過,goproxy 所在節點需要具備存取所有私有 Go Module 所在 VCS repo 的許可權,但又無法對 Go 開發者端做出有差別授權。這樣,只要是 goproxy 能拉取到的私有 Go Module,Go 開發者都能拉取到。

不過對於多數公司而言,內部所有原始碼原則上都是企業內部公開的,這個問題似乎也不大。如果覺得這是個問題,那麼只能使用前面提到的第一個方案,也就是直連私有 Go Module 的原始碼伺服器的方案了。

參考連結: