go包依赖管理
# 6. Go包依赖管理
# 6.1 go构建模式的演化
Go 程序由 Go 包组合而成。
Go 程序的构建过程就是确定包版本、编译包以及将编译后得到的目标文件链接在一起的过程。
# 6.1.1 GOPATH构建模式
在这种构建模式下,Go 编译器可以在本地 GOPATH 环境变量配置的路径下,搜寻 Go 程序依赖的第三方包。如果存在,就使用这个本地包进行编译;如果不存在,就会报编译错误。
Go 编译器在 GOPATH 构建模式下,搜寻第三方依赖包的规则:
- 在已配置的GOPATH 环境变量的路径下
- 在项目路径下
假定 Go 程序导入了 github.com/user/repo 这个包,假定当前 GOPATH 环境变量配置的值为:
export GOPATH=/usr/local/goprojects:/home/tonybai/go
那么在 GOPATH 构建模式下,Go 编译器在编译 Go 程序时,就会在下面两个路径下搜索第三方依赖包是否存在:
/usr/local/goprojects/src/github.com/user/repo
/home/tonybai/go/src/github.com/user/repo
2
如果未配置GOPATH路径,默认值为 $HOME/go。
如果没有在本地找到程序的第三方依赖包的情况下,通过 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 项目所依赖的第三方包版本能受到自己的控制,而不是随意变化。
# 6.1.2 Vendor构建模式
Go 在 1.5 版本中引入 vendor 机制。
vendor 机制本质上就是在 Go 项目的某个特定目录下,将项目的所有依赖包缓存起来,这个特定目录名就是 vendor。
.
├── main.go
└── vendor/
├── github.com/
│ └── sirupsen/
│ └── logrus/
└── golang.org/
└── x/
└── sys/
└── unix/
2
3
4
5
6
7
8
9
10
要想开启 vendor 机制,你的 Go 项目必须位于 GOPATH 环境变量配置的某个路径的 src 目录下面。如果不满足这一路径要求,那么 Go 编译器是不会理会 Go 项目目录下的 vendor 目录的。
不过 vendor 机制虽然一定程度解决了 Go 程序可重现构建的问题,但对开发者来说,它的体验却不那么好:
- Go 项目必须放在 GOPATH 环境变量配置的路径下,庞大的 vendor 目录需要提交到代码仓库,不仅占用代码仓库空间,减慢仓库下载和更新的速度,而且还会干扰代码评审,对实施代码统计等开发者效能工具也有比较大影响。
- 还需要手工管理 vendor 下面的 Go 依赖包,包括项目依赖包的分析、版本的记录、依赖包获取和存放,等等,最让开发者头疼的就是这一点。
为了解决这个问题,Go 核心团队与社区将 Go 构建的重点转移到如何解决包依赖管理上。
Go 社区先后开发了诸如 gb、glide、dep 等工具,来帮助 Go 开发者对 vendor 下的第三方包进行自动依赖分析和管理,但这些工具也都有自身的问题。
# 6.1.3 Go Module 构建模式
Go在1.11 版本开始增加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。
# 6.2 创建一个 Go Module
创建一个Go Module,通常有如下几个步骤:
第一步,通过 go mod init 创建 go.mod 文件,将当前项目变为一个 Go Module;
第二步,通过 go mod tidy 命令自动更新当前 module 的依赖信息;
第三步,执行 go build,执行新 module 的构建。
建立一个新项目 module-mode 用来演示 Go Module 的创建,注意:可以在任意路径下创建这个项目,不必非要在 GOPATH 环境变量的配置路径下。
mkdir github.com/bigwhite/module-mode
cd module-mode
2
创建main.go文件:
package main
import "github.com/sirupsen/logrus"
func main() {
logrus.Println("hello, go module mode")
}
2
3
4
5
6
7
添加go module支持:
# 第一步:go mod init 创建go.mod文件
通过 go mod init 命令为这个项目创建一个 Go Module
$go mod init github.com/bigwhite/module-mode
go: creating new go.mod: module github.com/bigwhite/module-mode
go: to add module requirements and sums:
go mod tidy
2
3
4
现在,go mod init 在当前项目目录下创建了一个 go.mod 文件,这个 go.mod 文件将当前项目变为了一个 Go Module,项目根目录变成了 module 根目录。go.mod 的内容是这样的:
module github.com/bigwhite/module-mode //声明 module 路径 (module path)
go 1.16 //Go 版本指示符,用于表示这个 module 是在某个特定的 Go 版本的 module 语义的基础上编写的
2
3
这个 go.mod 文件现在处于初始状态。
# 第二步:go mod tidy更新依赖信息
go mod init 命令还输出了两行日志,提示我们可以使用 go mod tidy 命令,添加 module 依赖以及校验和。
go mod tidy 命令会扫描 Go 源码,并自动找出项目依赖的外部 Go Module 以及版本,下载这些依赖并更新本地的 go.mod 文件。执行 go mod tidy 命令:
$go mod tidy
go: finding module for package github.com/sirupsen/logrus
go: downloading github.com/sirupsen/logrus v1.8.1
go: found github.com/sirupsen/logrus in github.com/sirupsen/logrus v1.8.1
go: downloading golang.org/x/sys v0.0.0-20191026070338-33540a1f6037
go: downloading github.com/stretchr/testify v1.2.2
2
3
4
5
6
go mod tidy 分析当前 main module 的所有源文件,找出了当前 main module 的所有第三方依赖,确定第三方依赖的版本,还下载了当前 main module 的直接依赖包(比如 logrus),以及相关间接依赖包(直接依赖包的依赖,比如上面的 golang.org/x/sys 等)。
下载依赖包:可以通过代理服务加速第三方依赖的下载。使用 GOPROXY 环境变量,默认值为:https: // proxy.golang.org,direct,可以配置更适合于中国大陆地区的 Go Module 代理服务:go env -w GOPROXY=https://goproxy.cn,direct
执行 go mod tidy 后,我们示例 go.mod 的内容更新如下:
module github.com/bigwhite/module-mode
go 1.16
require github.com/sirupsen/logrus v1.8.1
2
3
4
5
另外,还多了一个新文件 go.sum,内容:
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
2
3
4
5
6
7
8
9
10
这是 Go Module 的一个安全措施,由 go mod 相关命令维护的一个文件,它存放了特定版本 module 内容的哈希值。确保你的项目所依赖的 module 内容,不会被恶意或意外篡改。
# 第三步:go build构建项目
go build 命令会读取 go.mod 中的依赖及版本信息,并在本地 module 缓存路径下找到对应版本的依赖 module,执行编译和链接。
$go build
$$ls
go.mod go.sum main.go module-mode*
$./module-mode
INFO[0000] hello, go module mode
2
3
4
5
# 6.3 深入go module 构建模式
项目所依赖的包有很多版本,Go Module 是如何选出最适合的那个版本的呢?
- 语义导入版本 (Semantic Import Versioning)
- 最小版本选择 (Minimal Version Selection)
# 6.3.1 语义导入版本机制
go.mod 的 require 段中依赖的版本号,都符合 vX.Y.Z 的格式。
在 Go Module 构建模式下,一个符合 Go Module 要求的版本号,由前缀 v 和一个满足语义版本规范 (opens new window)的版本号组成。
Go 命令和 go.mod 文件都使用上面这种符合语义版本规范的版本号,作为描述 Go Module 版本的标准形式。借助于语义版本规范,Go 命令可以确定同一 module 的两个版本发布的先后次序,而且可以确定它们是否兼容。
按照语义版本规范,主版本号不同的两个版本是相互不兼容的。而且,在主版本号相同的情况下,次版本号大都是向后兼容次版本号小的版本。补丁版本号也不影响兼容性。
Go Module 规定:如果同一个包的新旧版本是兼容的,那么它们的包导入路径应该是相同的。
以 logrus 三个版本为例:
v1.7.0
v1.8.1
v2.0.0
v1.8.1、 v1.7.0 主版本号相同,新版本是兼容老版本的,无论这两个使用哪个版本,都可以使用以下语句导入:
import "github.com/sirupsen/logrus"
v2.0.0 与 v1.7.0、v1.8.1主版本号不同,是不兼容的包版本,如果使用v2.0.0版本,Go Module 创新性地给出了一个方法:将包主版本号引入到包导入路径中,可以使用以下语句导入:
import "github.com/sirupsen/logrus/v2"
通过在包导入路径中引入主版本号的方式,来区别同一个包的不兼容版本,这样一来我们甚至可以同时依赖一个包的两个不兼容版本:
import (
"github.com/sirupsen/logrus"
logv2 "github.com/sirupsen/logrus/v2"
)
2
3
4
对v0.y.z版本的导入:
按照语义版本规范的说法,v0.y.z 这样的版本号是用于项目初始开发阶段的版本号。在这个阶段任何事情都有可能发生,其 API 也不应该被认为是稳定的。Go Module 将这样的版本 (v0) 与主版本号 v1 做同等对待,也就是采用不带主版本号的包导入路径,这样一定程度降低了 Go 开发人员使用这样版本号包时的心智负担。
# 6.3.2 最小版本选择原则
复杂依赖关系如何确定依赖包的版本,如下图:
myproject 有两个直接依赖 A 和 B,A 和 B 有一个共同的依赖包 C,但 A 依赖 C 的 v1.1.0 版本,而 B 依赖的是 C 的 v1.3.0 版本,并且此时 C 包的最新发布版为 C v1.7.0。这个时候,Go 命令是如何为 myproject 选出间接依赖包 C 的版本呢?
其实,当前存在的主流编程语言,以及 Go Module 出现之前的很多 Go 包依赖管理工具都会选择依赖项的“最新最大 (Latest Greatest) 版本”,对应到图中的例子,这个版本就是 v1.7.0。
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 程序实现持久的和可重现的构建 提供了最佳的方案。
# 6.4 Go 各版本构建模式机制和切换
Go 1.13 版本之前、Go 1.13 版本以及 Go 1.16 版本,在 GO111MODULE 为不同值的情况下的行为做以下对比,更好地理解不同版本下、不同构建模式下的行为特性