我這樣升級 Go 版本,你呢?
閱讀本文大概需要 5 分鐘。
大家好,我是 polarisxu。
有些人可能注意到,每次 Go 發(fā)布新版本,官方都會提供類似這樣的升級截圖:(比如最新發(fā)布的 Go1.17 Beta1)

這可以說是官方的 Go 多版本管理,也是升級 Go 的方式。今天就一起聊一聊這種多版本管理方式及其實現原理。(我之前介紹過一個第三方多版本管理工具 goup,是我比較推薦的)。
注意,Windows 用戶應該使用 WSL2。
01 為什么需要多個 Go 版本
有些人可能覺得沒有這樣的需求。實際工作中,這樣的需求還是很常見的。以下一些場景,可能會希望有多版本:
一般為了穩(wěn)定,線上版本通常不會激進升級到最新版本,但你本地很可能想試用新版本的功能。這時候就希望能方便的支持多版本; 為了測試或重現特定的問題,希望能夠在特定的版本進行,這是為了避免不同版本干擾。 。。。
多版本并存,讓我們可以更自如的使用 Go。
02 官方多版本的使用方式
根據上面的圖,安裝某個版本的 Go,跟一般 Go 包安裝一樣,執(zhí)行 go get 命令:
$ go get golang.org/dl/go<version> // 其中 <version> 替換為你希望安裝的 Go 版本
這一步,只是安裝了一個特定 Go 版本的包裝器,真正安裝特定的 Go 版本,還需要執(zhí)行如下命令:
$ go<version> download // 和上面一樣,<version> 是具體的版本
因此,如果需要安裝 Go1.16.4,執(zhí)行如下兩個命令即可。
$ go get golang.org/dl/go1.16.4
$ go1.16.4 download
幾個注意的點:
有一個特殊的版本標記:gotip,用來安裝最新的開發(fā)版本; 因為 golang.org 訪問不了,你應該配置 GOPROXY(所以,啟用 Module 是必須的); 跟安裝其他包一樣,go get 之后,go1.16.4 這個命令會被安裝到 $GOBIN目錄下,默認是~/go/bin目錄,所以該目錄應該放入 PATH 環(huán)境變量;沒有執(zhí)行 download 之前,運行 go1.16.4,會提示 go1.16.4: not downloaded. Run 'go1.16.4 download' to install to ~/sdk/go1.16.4;
可見,最后下載下來的 Go 放在了 ~/sdk/go1.16.4 目錄下。
現在你是否有這樣的疑問:沒執(zhí)行 download 之前,直接運行 go1.16.4 會報錯,執(zhí)行之后,它就成了具體的 Go 命令了,怎么做到的?
03 扒一扒原理
golang.org/dl/go<version> 對應的源碼在 https://github.com/golang/dl(這是一個鏡像)。
查看該倉庫代碼,發(fā)現一堆以各個版本命名的目錄:

可見,每次發(fā)布新版本,都需要往這個倉庫增加一個對應的版本文件夾。
隨便打開一個(比如 go1.16.4),看看里面包含什么文件:
就一個 main.go 文件(從 go get 安裝操作,你應該猜到一定有一個 main.go 文件)。
main.go 文件的內容如下:(gotip 的內容不一樣,它調用的是 version.RunTip())
package main
import "golang.org/dl/internal/version"
func main() {
version.Run("go1.16.4")
}
所以,關鍵在于 internal/version 包的 Run 函數(不同版本,version 參數不同)。注意以下代碼我給的注釋:
// Run runs the "go" tool of the provided Go version.
func Run(version string) {
log.SetFlags(0)
// goroot 獲取 go 安裝的目錄,即 ~/sdk/go<version>
root, err := goroot(version)
if err != nil {
log.Fatalf("%s: %v", version, err)
}
// 執(zhí)行下載操作
if len(os.Args) == 2 && os.Args[1] == "download" {
if err := install(root, version); err != nil {
log.Fatalf("%s: download failed: %v", version, err)
}
os.Exit(0)
}
// 怎么驗證是否已經下載好了 Go?在下載的 Go 中會創(chuàng)建一個 .unpacked-success 文件,用來指示下載好了。
if _, err := os.Stat(filepath.Join(root, unpackedOkay)); err != nil {
log.Fatalf("%s: not downloaded. Run '%s download' to install to %v", version, version, root)
}
// 運行下載好的 Go
runGo(root)
}
這里主要是下載和運行 Go。
下載
我們先看下載、安裝 Go。
當執(zhí)行 go1.16.4 download 時,會運行 install 函數,查看該函數發(fā)現,它調用了 versionArchiveURL 函數獲取要下載的 Go 的 URL:
// versionArchiveURL returns the zip or tar.gz URL of the given Go version.
func versionArchiveURL(version string) string {
goos := getOS()
ext := ".tar.gz"
if goos == "windows" {
ext = ".zip"
}
arch := runtime.GOARCH
if goos == "linux" && runtime.GOARCH == "arm" {
arch = "armv6l"
}
return "https://dl.google.com/go/" + version + "." + goos + "-" + arch + ext
}
也就是從 https://dl.google.com 下載 Go 包,最終的包(是一個歸檔文件,Wiindows 下是 .zip,其他系統(tǒng)是 .tar.gz)會放到 ~/sdk/go1.16.4 目錄下。
之后通過 sha256 驗證文件的完整性(因為服務端放了 sha256 校驗文件),最后解壓縮,并創(chuàng)建上面說的 .unpacked-success 空標記文件。這樣這個版本的 Go 就安裝成功了。
注意,gotip 的下載是通過 git 獲取源碼的方式進行的,它會通過源碼構建安裝最新的 gotip 版本。具體邏輯在 internal/version/gotip.go 中。
運行
因為下載的 Go 是預編譯好的,因此可以直接使用。
但是它將 Go 下載到了 ~/sdk/go<version> 目錄下了,我們并沒有將這個目錄的 bin 目錄加入 PATH,因此直接 go 命令運行的還是之前的版本,而不是剛安裝的 go1.16.4。這個問題我們一會再說,先看看為什么這個時候 go1.16.4 命令可以當作 go 命令來使用。
上文說了,go1.16.4 只是一個包裝器。當對應的 Go1.16.4 安裝成功后,再次運行 go1.16.4,會執(zhí)行 internal/version/version.go 中的 runGo(root) 函數。
func runGo(root string) {
gobin := filepath.Join(root, "bin", "go"+exe())
cmd := exec.Command(gobin, os.Args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
newPath := filepath.Join(root, "bin")
if p := os.Getenv("PATH"); p != "" {
newPath += string(filepath.ListSeparator) + p
}
cmd.Env = dedupEnv(caseInsensitiveEnv, append(os.Environ(), "GOROOT="+root, "PATH="+newPath))
handleSignals()
if err := cmd.Run(); err != nil {
// TODO: return the same exit status maybe.
os.Exit(1)
}
os.Exit(0)
}
該函數通過 os/exec 包運行 ~/sdk/go1.16.4/bin/go 命令,并設置好響應的標準輸入輸出流等,同時為新運行的進程設置好相關環(huán)境變量,可以認為,執(zhí)行 go1.16.4,相當于執(zhí)行 ~/sdk/go1.16.4/bin/go。
所以,go1.16.4 這個命令,一直都只是一個包裝器。如果你希望新安裝的 go1.16.4 成為系統(tǒng)默認的 Go 版本,即希望運行 go 運行的是 go1.16.4,方法有很多:
將 ~/sdk/go1.16.4/bin/go加入 PATH 環(huán)境變量(替換原來的);做一個軟連,默認 go 執(zhí)行 go1.16.4(推薦這種方式),不需要頻繁修改 PATH; 移動 go1.16.4 替換之前的 go(不推薦);
03 每次升級版本創(chuàng)建一個包裝器
手動復制粘貼代碼做這件事情肯定是很笨的辦法。在 golang.org/dl 中提供了一個工具,可以快速生成對應版本的包裝器:https://github.com/golang/dl/blob/master/internal/genv/main.go。
$ genv go1.16.4
就可以生成 go1.16.4 包裝器。這里的實現,有一個點提一下,它使用了 go list -m -json 命令:
$ go list -m -json
{
"Path": "golang.org/dl",
"Main": true,
"Dir": "<workspace>/dl",
"GoMod": "<workspace>/dl/go.mod",
"GoVersion": "1.11"
}
可以方便解析相關信息。
04 總結
官方的 Go 多版本管理就介紹完了。總結一下:
官方通過 genv 命令生成對應版本的包裝器; 通過 go get 命令下載安裝對應的包裝器; 運行包裝器,提供 download 這個 flag,下載對應版本的 Go 安裝包并解壓、校驗; 之后,運行包裝器,會執(zhí)行對應版本的 go 命令;
這樣達到了多版本管理的目的。這個設計思路還是可以的。
但這種多版本管理,我認為存在一些問題:
上面說的,讓某個版本成為默認 Go 版本,沒有命令一鍵搞定; 沒法知道有哪些版本,比如無法方便的知曉 1.15.13 是否存在,更無法方便的知曉 1.15.x 系列,x 的最大版本; 刪除某個版本,得手動進行(刪除包裝器和下載的 Go 安裝包);
你喜歡這種方式管理還是類似 goup 這樣的第三方工具呢?你現在是怎么管理多版本的,歡迎交流!
