go构建模式及演化
构建模式演化
GOPATH
Go 语言在首次开源时,就内置了一种名为 GOPATH 的构建模式。在这种构建模式下,Go 编译器可以在本地 GOPATH 环境变量配置的路径下,搜寻 Go 程序依赖的第三方包。如果存在,就使用这个本地包进行编译;如果不存在,就会报编译错误。
我们先假定 Go 程序导入了 github.com/user/repo 这个包,我们也同时假定当前 GOPATH 环境变量配置的值为:
export GOPATH=/usr/local/goprojects:/home/wang/go
那么在 GOPATH 构建模式下,Go 编译器在编译 Go 程序时,就会在下面两个路径下搜索第三方依赖包是否存在:
/usr/local/goprojects/src/github.com/user/repo
/home/wang/go/src/github.com/user/repo
如果没有显式设置 GOPATH 环境变量,Go 会将 GOPATH 设置为默认值,不同操作系统下默认值的路径不同,在 macOS 或 Linux 上,它的默认值是 $HOME/go。
如果没有在本地找到程序的第三方依赖包的情况,会通过 go get 来解决这个问题。
go get
目的:下载依赖包,且自动判断是否下载依赖包所关联的包。
缺陷:不同时刻下载的依赖包会存在版本不同的情况,继而导致程序加载依赖包兼容不好。
我们可以通过 go get 命令将本地缺失的第三方依赖包下载到本地,比如:
go get github.com/sirupsen/logrus
go get不仅能将 logrus 包下载到 GOPATH 环境变量配置的目录下,它还会检查 logrus 的依赖包在本地是否存在,如果不存在,go get 也会一并将它们下载到本地。
不过,go get 下载的包只是那个时刻各个依赖包的最新主线版本,这样会给后续 Go 程序的构建带来一些问题。比如,依赖包持续演进,可能会导致不同开发者在不同时间获取和编译同一个 Go 包时,得到不同的结果,也就是不能保证可重现的构建(Reproduceable Build)。又比如,如果依赖包引入了不兼容代码,程序将无法通过编译。
最后还有一点,如果依赖包因引入新代码而无法正常通过编译,并且该依赖包的作者又没用及时修复这个问题,这种错误也会传导到你的程序,导致你的程序无法通过编译。也就是说,在 GOPATH 构建模式下,Go 编译器实质上并没有关注 Go 项目所依赖的第三方包的版本。但 Go 开发者希望自己的 Go 项目所依赖的第三方包版本能受到自己的控制,而不是随意变化。于是 Go 核心开发团队引入了 Vendor 机制试图解决上面的问题。
Vendor
目的:将项目依赖缓存起来,解决版本不一致带来的问题。
缺陷:必须在位于go path配置目录下,占用项目代码空间;开发者手动管理依赖包带来的额外工作;
Go 在 1.5 版本中引入 vendor 机制。vendor 机制本质上就是在 Go 项目的某个特定目录下,将项目的所有依赖包缓存起来,这个特定目录名就是 vendor。Go 编译器会优先感知和使用 vendor 目录下缓存的第三方包版本,而不是 GOPATH 环境变量所配置的路径下的第三方包版本。这样,无论第三方依赖包自己如何变化,无论 GOPATH 环境变量所配置的路径下的第三方包是否存在、版本是什么,都不会影响到 Go 程序的构建。如果你将 vendor 目录和项目源码一样提交到代码仓库,那么其他开发者下载你的项目后,就可以实现可重现的构建。因此,如果使用 vendor 机制管理第三方依赖包,最佳实践就是将 vendor 一并提交到代码仓库中。
要想开启 vendor 机制,你的 Go 项目必须位于 GOPATH 环境变量配置的某个路径的 src 目录下面。如果不满足这一路径要求,那么 Go 编译器是不会理会 Go 项目目录下的 vendor 目录的。
不过 vendor 机制虽然一定程度解决了 Go 程序可重现构建的问题,但对开发者来说,它的体验却不那么好。
一方面,Go 项目必须放在 GOPATH 环境变量配置的路径下,庞大的 vendor 目录需要提交到代码仓库,不仅占用代码仓库空间,减慢仓库下载和更新的速度,而且还会干扰代码评审,对实施代码统计等开发者效能工具也有比较大影响。
另外,你还需要手工管理 vendor 下面的 Go 依赖包,包括项目依赖包的分析、版本的记录、依赖包获取和存放,等等,最让开发者头疼的就是这一点。
为了解决这个问题,Go 核心团队与社区将 Go 构建的重点转移到如何解决包依赖管理上。Go 社区先后开发了诸如 gb、glide、dep 等工具,来帮助 Go 开发者对 vendor 下的第三方包进行自动依赖分析和管理,但这些工具也都有自身的问题。就在 Go 社区为包依赖管理焦虑并抱怨没有官方工具的时候,Go 核心团队基于社区实践的经验和教训,推出了 Go 官方的解决方案:Go Module。
Go Module
目的:更好的解决项目版本依赖
从 Go 1.11 版本开始,除了 GOPATH 构建模式外,Go 又增加了一种 Go Module 构建模式。
一个 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 mod init
创建go.mod文件,将当前项目变为一个 Go Module
go mod tidy
自动更新当前 module 的依赖信息,写入go.mod信息,创建go.sum文件。
由 go mod tidy 下载的依赖 module 会被放置在本地的 module 缓存路径下,默认值为 $GOPATH[0]/pkg/mod,Go 1.15 及以后版本可以通过 GOMODCACHE 环境变量,自定义本地 module 的缓存路径。
如果当前 module 的直接依赖 A,还有它的版本信息都被写到了 go.mod 文件的 require 段中。而且,执行完 go mod tidy 后,当前项目除了 go.mod 文件外,还多了一个新文件 go.sum,这是 Go Module 的一个安全措施。当将来这里的某个 module 的特定版本被再次下载的时候,go 命令会使用 go.sum 文件中对应的哈希值,和新下载的内容的哈希值进行比对,只有哈希值比对一致才是合法的,这样可以确保你的项目所依赖的 module 内容,不会被恶意或意外篡改。
因此,我推荐你把 go.mod 和 go.sum 两个文件与源码,一并提交到代码版本控制服务器上。
go build
执行新 module 的构建
读取 go.mod 中的依赖及版本信息,并在本地 module 缓存路径下找到对应版本的依赖 module,执行编译和链接。如果顺利的话,我们会在当前目录下看到一个新生成的可执行文件 module-mode,执行这个文件我们就能得到正确结果了。
深入 Go Module 构建模式
Go Module 的语义导入版本机制
go.mod 的 require 段中依赖的版本号,都符合 vX.Y.Z 的格式。在 Go Module 构建模式下,一个符合 Go Module 要求的版本号,由前缀 v 和一个满足语义版本规范的版本号组成。
语义版本号分成 3 部分:主版本号 (major)、次版本号 (minor) 和补丁版本号 (patch)。eg: v1.18.3,即主版本号为 1,次版本号为 18,补丁版本号为 3。
按照语义版本规范,主版本号不同的两个版本是相互不兼容的。而且,在主版本号相同的情况下,次版本号大都是向后兼容次版本号小的版本。补丁版本号也不影响兼容性。
而且,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" )
不过到这里,你可能会问,v0.y.z 版本应该使用哪种导入路径呢?按照语义版本规范的说法,v0.y.z 这样的版本号是用于项目初始开发阶段的版本号。在这个阶段任何事情都有可能发生,其 API 也不应该被认为是稳定的。Go Module 将这样的版本 (v0) 与主版本号 v1 做同等对待,也就是采用不带主版本号的包导入路径,这样一定程度降低了 Go 开发人员使用这样版本号包时的心智负担。Go 语义导入版本机制是 Go Module 机制的基础规则,同样它也是 Go Module 其他规则的基础。
Go Module 的最小版本选择原则
Go 会在该项目依赖项的所有版本中,选出符合项目整体要求的“最小版本”。
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 版本间切换了,不用再担心切换后模式变化,导致构建失败了。