# Go 构建模式是怎么演化的?

Go 程序由 Go 包组合而成的,Go 程序的构建过程就是确定包版本、编译包以及将编译后得到的目标文件链接在一起的过程。

一个 Go Module 是一个 Go 包的集合。module 是有版本的,所以 module 下的包也就有了版本属性。这个 module 与这些包会组成一个独立的版本单元,它们一起打版本、发布和分发。

在 Go Module 模式下,通常一个代码仓库对应一个 Go Module。一个 Go Module 的顶层目录下会放置一个 go.mod 文件,每个 go.mod 文件会定义唯一一个 module,也就是说 Go Module 与 go.mod 是一一对应的。

go.mod 文件所在的顶层目录也被称为 module 的根目录,module 根目录以及它子目录下的所有 Go 包均归属于这个 Go Module,这个 module 也被称为 main module。

你可能也意识到了,Go Module 的原理和使用方法其实有点复杂,但“千里之行始于足下”,下面我们先从如何创建一个 Go Module 说起。我们先来将上面的例子改造成为一个基于 Go Module 构建模式的 Go 项目。

# 创建一个 Go Module

将基于当前项目创建一个 Go Module,通常有如下几个步骤:

第一步,通过 go mod init 创建 go.mod 文件,将当前项目变为一个 Go Module;

第二步,通过 go mod tidy 命令自动更新当前 module 的依赖信息;

第三步,执行 go build,执行新 module 的构建。

首先我们看一下 Go Module 的语义导入版本机制。

Go Module 规定:**如果同一个包的新旧版本是兼容的,那么它们的包导入路径应该是相同的。**怎么理解呢?我们来举个简单示例。我们就以 logrus 为例,它有很多发布版本,我们从中选出两个版本 v1.7.0 和 v1.8.1.。按照上面的语义版本规则,这两个版本的主版本号相同,新版本 v1.8.1 是兼容老版本 v1.7.0 的。那么,我们就可以知道,如果一个项目依赖 logrus,无论它使用的是 v1.7.0 版本还是 v1.8.1 版本,它都可以使用下面的包导入语句导入 logrus 包:

import "github.com/sirupsen/logrus"

那么问题又来了,假如在未来的某一天,logrus 的作者发布了 logrus v2.0.0 版本。那么根据语义版本规则,该版本的主版本号为 2,已经与 v1.7.0、v1.8.1 的主版本号不同了,那么 v2.0.0 与 v1.7.0、v1.8.1 就是不兼容的包版本。然后我们再按照 Go Module 的规定,如果一个项目依赖 logrus v2.0.0 版本,那么它的包导入路径就不能再与上面的导入方式相同了。那我们应该使用什么方式导入 logrus v2.0.0 版本呢?

Go Module 创新性地给出了一个方法:将包主版本号引入到包导入路径中,我们可以像下面这样导入 logrus v2.0.0 版本依赖包:

import "github.com/sirupsen/logrus/v2"

这就是 Go 的“语义导入版本”机制,也就是说通过在包导入路径中引入主版本号的方式,来区别同一个包的不兼容版本,这样一来我们甚至可以同时依赖一个包的两个不兼容版本:

import (
    "github.com/sirupsen/logrus"
    logv2 "github.com/sirupsen/logrus/v2"
)

接下来,我们再来看一下 Go Module 的最小版本选择原则。

在前面的例子中,Go 命令都是在项目初始状态分析项目的依赖,并且项目中两个依赖包之间没有共同的依赖,这样的包依赖关系解决起来还是比较容易的。但依赖关系一旦复杂起来,比如像下图中展示的这样,Go 又是如何确定使用依赖包 C 的哪个版本的呢?

在这张图中,myproject 有两个直接依赖 A 和 B,A 和 B 有一个共同的依赖包 C,但 A 依赖 C 的 v1.1.0 版本,而 B 依赖的是 C

在这张图中,myproject 有两个直接依赖 A 和 B,A 和 B 有一个共同的依赖包 C,但 A 依赖 C 的 v1.1.0 版本,而 B 依赖的是 C 的 v1.3.0 版本,并且此时 C 包的最新发布版为 C v1.7.0。这个时候,Go 命令是如何为 myproject 选出间接依赖包 C 的版本呢?选出的究竟是 v1.7.0、v1.1.0 还是 v1.3.0 呢?你可以暂停一两分钟思考一下。

其实,当前存在的主流编程语言,以及 Go Module 出现之前的很多 Go 包依赖管理工具都会选择依赖项的“最新最大 (Latest Greatest) 版本”,对应到图中的例子,这个版本就是 v1.7.0。

当然了,理想状态下,如果语义版本控制被正确应用,并且这种“社会契约”也得到了很好的遵守,那么这种选择算法是有道理的,而且也可以正常工作。在这样的情况下,依赖项的“最新最大版本”应该是最稳定和安全的版本,并且应该有向后兼容性。至少在相同的主版本 (Major Verion) 依赖树中是这样的。

但我们这个问题的答案并不是这样的。Go 设计者另辟蹊径,在诸多兼容性版本间,他们不光要考虑最新最大的稳定与安全,还要尊重各个 module 的述求:A 明明说只要求 C v1.1.0,B 明明说只要求 C v1.3.0。所以 Go 会在该项目依赖项的所有版本中,选出符合项目整体要求的“最小版本”。

这个例子中,C v1.3.0 是符合项目整体要求的版本集合中的版本最小的那个,于是 Go 命令选择了 C v1.3.0,而不是最新最大的 C v1.7.0。并且,Go 团队认为“最小版本选择”为 Go 程序实现持久的和可重现的构建提供了最佳的方案。

了解了语义导入版本与最小版本选择两种机制后,你就可以说你已经掌握了 Go Module 的精髓。

# Go 各版本构建模式机制和切换

我们前面说了,在 Go 1.11 版本中,Go 开发团队引入 Go Modules 构建模式。这个时候,GOPATH 构建模式与 Go Modules 构建模式各自独立工作,我们可以通过设置环境变量 GO111MODULE 的值在两种构建模式间切换。

然后,随着 Go 语言的逐步演进,从 Go 1.11 到 Go 1.16 版本,不同的 Go 版本在 GO111MODULE 为不同值的情况下,开启的构建模式几经变化,直到 Go 1.16 版本,Go Module 构建模式成为了默认模式。

所以,要分析 Go 各版本的具体构建模式的机制和切换,我们只需要找到这几个代表性的版本就好了。

我这里将 Go 1.13 版本之前、Go 1.13 版本以及 Go 1.16 版本,在 GO111MODULE 为不同值的情况下的行为做了一下对比,这样我们可以更好地理解不同版本下、不同构建模式下的行为特性,下面我们就来用表格形式做一下比对:

# 总结

Go 语言最初发布时内置的构建模式为 GOPATH 构建模式。在这种构建模式下,所有构建都离不开 GOPATH 环境变量。在这个模式下,Go 编译器并没有关注依赖包的版本,开发者也无法控制第三方依赖的版本,导致开发者无法实现可重现的构建。

那么,为了支持可重现构建,Go 1.5 版本引入了 vendor 机制,开发者可以在项目目录下缓存项目的所有依赖,实现可重现构建。但 vendor 机制依旧不够完善,开发者还需要手工管理 vendor 下的依赖包,这就给开发者带来了不小的心智负担。

后来,Go 1.11 版本中,Go 核心团队推出了新一代构建模式:Go Module 以及一系列创新机制,包括语义导入版本机制、最小版本选择机制等。语义导入版本机制是 Go Moudle 其他机制的基础,它是通过在包导入路径中引入主版本号的方式,来区别同一个包的不兼容版本。而且,Go 命令使用最小版本选择机制进行包依赖版本选择,这和当前主流编程语言,以及 Go 社区之前的包依赖管理工具使用的算法都有点不同。

此外,Go 命令还可以通过 GO111MODULE 环境变量进行 Go 构建模式的切换。但你要注意,从 Go 1.11 到 Go 1.16,不同的 Go 版本在 GO111MODULE 为不同值的情况下,开启的构建模式以及具体表现行为也几经变化,这里你重点看一下前面总结的表格。