Go Module 浅析

Golang 在 1.13 版本后的包管理器功能已经逐步完善,现在越来越多第三方开源项目支持官方的依赖管理方式。Go Module 是 Golang 管理依赖关系的包管理器,本文将浅析 Go Module 的使用和原理,希望大家阅读本文后能对 Go Module 有更进一步地了解。

本文将围绕着以下的几个问题出发,并在讲解过程中回答这些问题。

  1. 如何使用 Go Module 引入第三方库和发布自己的库?
  2. 如果依赖了同一个库的多个版本,Go Module 如何进行版本选择?
  3. 当我们维护一个 Go Module 时,如果进行兼容的、不兼容的更新?
  4. Go 是如何托管和下载 Go Module 依赖的?

Go Module 介绍#

Go Module 是 Golang 管理依赖关系的包管理器,为 Golang 项目的第三方依赖提供管理能力,类似 NodeJS 的 NPM、Python 的 PIP 等。

在 Go Module 的实现中,一个 Go 模块 (module) 是一个包管理器的基础单元,由一个或许多个目录前缀相同的包 (package) 组成。模块可以是一个项目、一个依赖库,项目或依赖库将以整个模块进行发布、版本控制、分发。

当我们需要发布一个工具库的时候,我们首先将整个工具库声明为模块,然后进行版本标记、发布。当我们需要在项目中引用该工具库时,也需要先将项目声明为模块,然后导入并使用指定特定版本的工具库模块。

Go Module 使用#

创建模块#

当我们编写一个 Go 项目的时候,我们可以为该项目创建一个模块。Go 模块的名称是该模块的所在路径,也是模块下的所有包路径的统一前缀,通常为其对应 Git 仓库地址路径。下面我们在一个空的 Go 项目下,通过 go mod init 命令创建一个模块,模块名称可以自行修改为各位的 GitHub 仓库地址。

1
go mod init github.com/megashow/golang-demo

上面命令将会在空项目文件夹下创建一个 go.mod 文件,文件的内容如下,module 声明了该模块的名称,go 声明了该模块依赖的 Golang 最低版本。

1
2
3
4
// go.mod
module github.com/megashow/golang-demo

go 1.21.0

空项目文件夹中定义的包均属于该模块,在模块内的包之间可以直接相互引用,比如我们定义一个 utils 包,在根目录下使用它。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// utils/author.go
package utils

func Author() string {
	return "MegaShow"
}

// main.go
package main

import (
	"fmt"

	"github.com/megashow/golang-demo/utils" // utils 具体的包路径
)

func main() {
	fmt.Println("Hello,", utils.Author()) // => Hello, MegaShow
}

使用第三方库#

当我们需要引入第三方库依赖时,可以使用 Go Module 管理依赖。使用 go get 命令引入第三方库,前提是当前项目已初始化为 Go 模块。

1
go get github.com/gin-gonic/gin@v1.9.0

通过以上命令引入了 Golang Web 库 gin,未指定版本时将默认使用最新版本,效果同 latest

修改 main 函数,使用 gin 创建一个 Web 服务器,响应请求并返回消息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// main.go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/megashow/golang-demo/utils"
)

func main() {
	router := gin.Default()
	router.GET("/ping", func(ctx *gin.Context) {
		ctx.String(http.StatusOK, "Hello, "+utils.Author())
	})
	http.ListenAndServe(":8080", router)
}

此时,我们再观察 go.mod 文件,文件中记录了不少第三方库的地址以及当前依赖的版本,这些信息被记录在 require 模块中。虽然我们仅依赖了 gin,但是有不少库是 gin 依赖的,或者是 gin 依赖的库依赖的。

1
2
3
4
5
6
7
require (
	github.com/gin-gonic/gin v1.9.0 // indirect
	github.com/go-playground/validator/v10 v10.11.2 // indirect
	golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
	...more...
)

每个依赖行末有注释标注是否是本项目直接依赖,备注 indirect 的依赖即为间接依赖。

那为什么我们此前使用了 gin,还是被标注为间接依赖呢?这是因为我们通过 go get 引入依赖后,并没有直接使用 gin,后续也没有修改 go.mod 文件,因此 go.mod 文件并没有更新。Go 模块提供 go mod tidy 命令更新当前模块的配置文件。

1
go mod tidy

执行上述命令后,go.mod 文件的依赖信息被修改。另外,当我们手动修改 go.mod 文件后,也可以通过 go mod download 下载 go.mod 文件最新依赖的库。

1
go mod download

模块版本说明#

go.mod 文件中记录了不少第三方库的地址以及当前依赖的版本,我们可以发现这些版本有几种不同的形式。一个已经发布正式版本的 Go Module 将使用 SemVer 的形式声明其版本号。

version

版本形式示例说明
主要版本v1.x.x模块主版本,该版本变更时可发生不兼容的API变化
次要版本v1.2.x模块次要版本,该版本变更时应做出向后兼容的保障
补丁版本v1.2.3一般为次要版本的补丁,该版本变更时应做出向后兼容的保障
预发布版本v1.2.3-beta.4模块预发布版本,该版本不一定具备稳定性保障

Go Module 要求开发人员根据 SemVer 命名版本号,但没有要求一定要根据这套规范约束模块的兼容性和稳定性。为了统一版本规范和降低版本升级的成本,通常建议模块的开发人员和使用人员统一使用通用的版本号和兼容性约束规范。

在上述的示例中我们使用的 gin 版本为 v1.9.0,这是一个主版本为 1 和次版本为 9 的正式版本。而 gin 的依赖 validator 版本为 v10.11.2,这是一个主版本为 10 的正式版本。validator 的模块路径后面带有 /v10 的后缀,这是因为不同主版本的模块可能是不兼容的,通过指定特定主要版本后缀,可以在同一个项目里面同时使用不同主版本的模块。在使用过程中引用的 package 路径也需要添加下主版本后缀。

1
2
3
require (
	github.com/go-playground/validator/v10 v10.11.2 // indirect
)
1
2
// 使用 v10 版本的 validator
import "github.com/go-playground/validator/v10/xxx"

除了上述的版本说明外,Go Module 还有一些关于开发中的模块版本规范。

  1. 当主版本为 0 时,表示该版本还处于开发中,可能无法保障兼容性和稳定性,比如 v0.1.5
  2. 当模块代码并未打上版本标记时,Go 将自动生成一个伪版本号,比如 v0.0.0-20210923205945-b76863e36670

伪版本号的格式为 baseVersion + timestamp + revisionIdentifier,即前一版本号 (如果没有就取 v0.0.0) + 当前模块代码提交时间 + 代码提交的哈希标识前 12 位。

发布模块#

当一个 Go 模块被提交并推送到 Git 远程仓库中的时候,使用人员能同通过 Go 将模块下载到本地,即相当于 Go 模块已被发布。

不过为了方便使用和标记,通常我们需要为 Go 模块标记上一个新版本,并在文档中描述新版本和旧版本的区别。如果没有标记版本号,Go 也会自动生成一个伪版本号,该版本的模块依然可以被使用。

Go 模块使用 Git tag 系统标记新版本,tag 的名称即为 Go 模块的版本号。

1
2
3
git commit -m "更新版本 v0.1.0"  # 提交代码
git tag v0.1.0                  # 为当前代码打上 tag
git push origin v0.1.0          # 将 tag 推送到远程仓库

如果使用代码托管平台托管 Git 仓库,比如 GitHub,也可以直接在 GitHub 平台上手动创建 tag 或 release。

Go Module 版本选择#

最小版本选择#

当我们使用 Go Module 引入第三方依赖时,必然会出现存在同一个模块多个依赖版本的问题。比如我们的模块同时依赖了 gin@v1.9.0validator@v10.15.3,而 gin@v1.9.0 依赖了 validator@v10.11.2,那么相当于我们的模块同时依赖了 validator 的两个版本。在构建包的时候,是选择其中一个版本,还是同时引入两个版本呢?

前面我们提到,对于主要版本不一致的同一个模块依赖,使用时需要加上主要版本的后缀,比如 /v9 /v10。此时,它们被视为两个完全独立的模块,因此在使用的时候会同时编译两个版本的模块依赖。

而对于主要版本一致的同一个模块依赖,Go 在构建包的时候使用了一种名为 “最小版本选择(MVS)” 的算法选择合适的模块版本。

Go 会将模块之间的依赖关系视为一个有向图,在构建的时候会从主模块开始,遍历有向图上的每一个点,得到每个模块的最高版本,这个最高版本即是构建所需要的最低版本。

如下图所示,主模块依赖了 A 1.2 和 B 1.2,此时我们需要 A 和 B 的版本为 1.2 或更高版本。而 A 1.2 依赖于 C 1.3,B 1.2 依赖于 C 1.4,因此当前依赖 C 的最高版本为 1.4,即需要 C 的版本为 1.4 或更高版本。不像 NPM 等版本管理系统,MVS 的版本选择是确定性的,会选择相应依赖所满足条件的最小版本,虽然 B 版本最新为 1.3 但是最终依然选择 B 1.2。

mvs

根据上图依赖关系,Go 最终选择构建的依赖版本分别为主模块、A 1.2、B 1.2、C 1.4、D 1.2。

版本替换和升级#

只依赖于 Go 的最小版本选择不能满足所有版本管理的需求,假设当前 D 1.2 存在一些安全问题,需要升级到 D 1.3,而我们并没有直接依赖于 D,这就需要先由 C 先升级依赖,然后 A 或 B 升级依赖,我们再升级依赖,但很多时候我们没法确保每一环依赖都能很迅速更新解决安全问题。

Go 模块允许直接修改间接依赖的版本,但要自行确保新的依赖关系图能正常编译和运行。执行 go get 命令可以将间接依赖升级到最新版本或特定版本,并且升级后可以发现 go.mod 文件多了一行带 indirect 注释的依赖项。

1
go get D_Package_Path

假设 C 1.4 存在一些安全问题或不兼容问题,我们也可以将 C 回滚为 1.3 版本,但同样需要我们自行确保新的依赖关系图能正常编译和运行。Go 模块通过 replace 语句将某个依赖限制为固定版本。

1
replace C_Package_Path => C_Package_Path v1.3.0

Go 版本特性选择#

前面我们都提到的是模块依赖的版本,而实际上我们构建的时候,也需要关注 Go 本身的版本,不同 Go 版本会存在特性差异的情况。

从 Go 1.21 开始,在 go.mod 文件内指定 Go 版本成为一个强制约束,并且所指定的 Go 版本还需满足以下条件。

  1. 该 Go 版本被认为是使用该模块所需要的最低 Go 版本;
  2. 该 Go 版本必须大于等于所有依赖项的所需的 Go 版本。

同时,还衍生出一些兼容性问题。

  1. 不再向前兼容,低于该版本的 Go 工具链将无法用于构建该项目,而是替换为自动下载的新版本工具链用于构建;
  2. 向后兼容规范化,根据该 Go 版本决定 GODEBUG 变量的取值。

Go Module 维护#

如果我们需要维护一个 Go 模块,那需要我们了解如何创建、更新 Go 模块。在前面讲解 Go 模块使用的过程中,我们已经了解了如何创建 Go 模块,也了解到如何为模块发布一个版本。

但是,模块是需要更新迭代的,接下来我们来了解下 Go 模块如何进行兼容的、不兼容的更新。

小版本更新#

前面发布模块的时候,我们通过 Git tag 系统标记新版本。如果我们需要在主版本保持一致的情况下进行更新,只需要重新标记一个新的 tag,即可得到一个新版本。

在保持主版本不变的情况下,新版本代码应该保证对旧版本兼容,且新版本应该比旧版本大,比如 v1.1.2 可以更新为 v1.1.3

需要注意,当主版本为 0 时,我们视为不稳定的版本,可以不保证兼容性。不过是否兼容取决于模块的维护人,通常为了确保更新不影响依赖方,最好尽可能保障兼容性。

主版本更新#

与小版本更新相同,主版本更新也是需要 Git tag 标记一个新的 tag,新版本的主版本号 +1,并重置次要版本号、补丁版本号。

与小版本更新不同的是,由于主版本变化可能带来不兼容的变更,那后续新版本和老版本可能在开发迭代过程中分叉出两条分支,如何同时维护新老版本成为一个问题。老版本可能不再添加新功能,但是需要对安全问题、Bug 等进行修复处理。

主版本的更新有两种实现方案,其中一种是在原有的主分支维护新的主版本代码,而老版本代码则分叉出新的分支进行维护。当需要在老版本上进行打补丁时,可以在新的分支上修改代码,然后打上老的主版本的 tag。而新版本的更新则在主分支上进行,按小版本更新的方式继续打 tag。

假设我们当前版本为 v1.4.8,我们需要更新版本为 v2.0.0,这可能是一个不兼容的更新。首先,我们将 go.mod 中的模块信息修改为 v2 版本。

1
2
3
module github.com/megashow/golang-demo
// 修改为
module github.com/megashow/golang-demo/v2

然后将所有代码中引用关系全都修改为 v2 版本,注意是所有代码,如果不修改的话,相当于 v2 版本又引用了 v1 版本。

1
2
3
import "github.com/megashow/golang-demo/xxxxxx"
// 修改为
import "github.com/megashow/golang-demo/v2/xxxxxx"

最后我们为 v2 添加 Git 标签,并推送到远端仓库,这样 v2 版本就发布成功了。

1
2
git tag v2.0.0
git push origin v2.0.0

后续,用户使用我们的模块新版本的时候,只需要按照模块的使用方式引入带 v2 路径的模块即可。

以上这种方案只在主分支中保留一个主版本的代码,这种方法能兼容使用 Go 模块的仓库,而不能兼容未使用 Go 模块的仓库。比如有一个应用是 Go 1.12,并未使用 Go Module,此时按这种方法只能引用 v2 的代码,而不能使用 v1 的代码。对这种场景的兼容也很简单,但是需要在主分支里面保留两个主版本的代码。

首先,我们创建一个 v2 文件夹,然后将所有代码都拷贝一份到 v2 文件夹中。然后按照上面的方法修改 go.mod 和 import 引用信息。

1
2
3
mkdir v2
cp *.go v2/  # 这里简单举例, 实际可以自行拷贝
cp go.mod v2/

这种方案相当于在仓库里面同时维护新老版本的代码,也就不需要涉及到开新分支的操作了,不过缺点也显而易见,v2 版本的代码是维护在 v2 文件夹中的,根目录维护的是 v1 版本的代码,部分人可能不太习惯也不太喜欢这种维护方式(比如我)。

Go Module 下载#

前面介绍了许多关于 Go Module 相关的知识点,但是一直没有深入源码去分析 Go Module 是如何工作的。比如 go get 的命令到底执行了什么操作?代码依赖是如何被下载下来的?这里我们简单分析下源码的实现。

注意,以下源码来源 Golang 1.21.0,不同版本 Golang 实现可能存在差异。

下载 Golang 的源码并分析后,我们可以发现 Golang 开发工具的实现在 src/cmd/go/ 文件夹中。首先我们查阅 go get 命令的实现,go get 命令被封装在 modget.CmdGet 中,具体实现会调用 runGet 函数。以下代码仅保留关键步骤。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func init() {
	base.Go.Commands = []*base.Command{
		modget.CmdGet,
	}
}

func runGet(ctx context.Context, cmd *base.Command, args []string) {
	/* more-code: 各种前置判断 */

	// 解析参数得到需要下载的模块具体路径以及版本
	dropToolchain, queries := parseArgs(ctx, args)

	// 创建解析器
	r := newResolver(ctx, queries)
	r.performLocalQueries(ctx) // 加载本地文件的依赖
	r.performPathQueries(ctx) // 加载指定路径的依赖

	for {
		r.performWildcardQueries(ctx) // 加载带通配符的依赖
		r.performPatternAllQueries(ctx) // 如果指定 all, 则加载所有依赖

		/* more-code: 处理一些存在歧义的依赖和缺失的依赖 */
		break
	}

	/* more-code: 各种后置判断和处理, 比如写入新的信息到 go.mod */
	oldReqs := reqsFromGoMod(modload.ModFile())
	modload.WriteGoMod(ctx, opts)
	newReqs := reqsFromGoMod(modload.ModFile())
	r.reportChanges(oldReqs, newReqs)
}

接下来我们分析下我们通常使用的第三方模块是如何被下载下来的。翻了一层又一层的源码,终于在 modload.QueryPattern 找到了相关代码。可以看到这里使用 modfetch.TryProxies 函数进行下载尝试,简单的伪代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// -- 伪代码 --
modfetch.TryProxies(func(proxy string) error {
	// 首先获取模块的具体仓库地址
	repo := modfetch.Lookup(ctx, proxy, path)
	// 然后获取模块的版本列表
	versions, err := repo.Versions(ctx, qm.prefix)
	// 得到模块信息
	mod := filterAndLookup(versions)
	// 下载模块
	modfetch.Download(ctx, mod)
})

如果当前代理为 direct 的时候,Go 首先会判断需要下载的模块中是否有预设的前缀,如果存在预设的前缀,则按预设的规则生成模块的仓库地址。比如 github.com、bitbucket.org 等知名的代码托管平台都有预设的配置。当需要下载的模块带有 github.com 前缀时,Golang 则标记这个模块为 Git 仓库,并记录仓库地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var vcsPaths = []*vcsPath{
	// GitHub 等平台
	{
		pathPrefix: "github.com",
		regexp:     lazyregexp.New(`^(?P<root>github\.com/[\w.\-]+/[\w.\-]+)(/[\w.\-]+)*$`),
		vcs:        "git",
		repo:       "https://{root}",
		check:      noVCSSuffix,
	},
}

如果所下载的模块地址并不在这些知名平台,则执行动态加载逻辑。Go 首先会向该地址发起一个 HTTP GET 请求,并带上请求参数 go-get=1。比如我个人发布的模块 go.icytown.com/hugo-ice,下载时将首先访问该地址。

1
web.Get("https://go.icytown.com/hugo-ice?go-get=1")

然后解析访问得到的 HTML,从 head 中获取到相应的模块信息,如下。

1
2
3
4
5
6
7
<!-- 格式如下 -->
<meta name="go-import" content="[prefix] [vcs] [repo]">
<meta name="go-source" content="[prefix] [home] [directory] [file]">

<!-- 具体示例 -->
<meta name="go-import" content="go.icytown.com/hugo-ice git https://github.com/megashow/hugo-ice">
<meta name="go-source" content="go.icytown.com/hugo-ice https://github.com/megashow/hugo-ice https://github.com/megashow/hugo-ice/tree/master{/dir} https://github.com/megashow/hugo-ice/blob/master{/dir}/{file}#L{line}">

其中 go-import 记录了模块的具体信息,包括模块路径前缀、使用的 VCS 版本控制工具、真实的仓库地址,用于区别如何下载、从哪里下载这个模块。而 go-source 记录了模块的代码映射关系,可以使用 {/dir}{dir}{file}{line} 等变量,用于 godoc.org 托管模块文档时可跳转到相应的代码文件或具体某一行代码。

根据这种约束,我们可以使用我们自己的域名去声明一个模块的地址,将模块仓库托管在 GitHub,然后通过 HTML 指向 GitHub,而不需要直接使用 GitHub 的路径作为模块的地址。这样如果后续需要更换托管平台时,也不需要修改模块的地址和源代码。

如果我们需要自行搭建代码托管平台并在上面维护模块时,也需要通过这种 HTML head 的约束去声明模块的具体地址。

结语#

Go Module 的出现让 Golang 的依赖管理得到了完善,本文简单介绍了如何使用、维护 Go 模块,也简单介绍了 Go 模块的一些原理和实现。当然还有更多细节的东西本文并没有提及,如果感兴趣不妨去翻一翻 Golang 的源码。