You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

25 KiB

06构建模式Go是怎么解决包依赖管理问题的

你好我是Tony Bai。

通过前面的讲解我们已经初步了解了Go程序的结构以及Go项目的典型布局了。那么接下来我们是时候来系统学习一下Go应用的构建了它们都是我们继续Go语言学习的前提。

所以在这一节课我们就来了解Go构建模式演化的前世今生。理解了这个发展史后我们会重点来探讨现在被广泛采用的构建模式Go Module的基本概念和应用构建方式。 接着知道了怎么做后我们会再深一层继续分析Go Module的工作原理。这样层层深入地分析完后你就能彻底、透彻地掌握Go Module构建模式了。

好了我们直接开始吧。我们先来了解一下Go构建模式的演化过程弄清楚Go核心开发团队为什么要引入Go module构建模式。

Go构建模式是怎么演化的

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

Go语言的构建模式历经了三个迭代和演化过程分别是最初期的GOPATH、1.5版本的Vendor机制以及现在的Go Module。这里我们就先来介绍一下前面这两个。

首先我们来看GOPATH。

Go语言在首次开源时就内置了一种名为GOPATH的构建模式。在这种构建模式下Go编译器可以在本地GOPATH环境变量配置的路径下搜寻Go程序依赖的第三方包。如果存在就使用这个本地包进行编译如果不存在就会报编译错误。

我这里给出了一段在GOPATH构建模式下编写的代码你先来感受一下

package main

import "github.com/sirupsen/logrus"

func main() {
    logrus.Println("hello, gopath mode")
}

你可以看到这段代码依赖了第三方包logruslogrus是Go社区使用最为广泛的第三方log包

接下来这个构建过程演示了Go编译器这里使用Go 1.10.8在GOPATH环境变量所配置的目录下这里为/Users/tonybai/Go无法找到程序依赖的logrus包而报错的情况

$go build main.go
main.go:3:8: cannot find package "github.com/sirupsen/logrus" in any of:
	/Users/tonybai/.bin/go1.10.8/src/github.com/sirupsen/logrus (from $GOROOT)
	/Users/tonybai/Go/src/github.com/sirupsen/logrus (from $GOPATH)

那么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

这里注意一下如果你没有显式设置GOPATH环境变量Go会将GOPATH设置为默认值不同操作系统下默认值的路径不同在macOS或Linux上它的默认值是$HOME/go。

那么,当遇到像上面例子一样,没有在本地找到程序的第三方依赖包的情况,我们该如何解决这个问题呢?

这个时候就要让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在1.5版本中引入vendor机制。vendor机制本质上就是在Go项目的某个特定目录下将项目的所有依赖包缓存起来这个特定目录名就是vendor。

Go编译器会优先感知和使用vendor目录下缓存的第三方包版本而不是GOPATH环境变量所配置的路径下的第三方包版本。这样无论第三方依赖包自己如何变化无论GOPATH环境变量所配置的路径下的第三方包是否存在、版本是什么都不会影响到Go程序的构建。

如果你将vendor目录和项目源码一样提交到代码仓库那么其他开发者下载你的项目后就可以实现可重现的构建。因此如果使用vendor机制管理第三方依赖包最佳实践就是将vendor一并提交到代码仓库中。

下面这个目录结构就是为上面的代码示例添加vendor目录后的结果

.
├── main.go
└── vendor/
    ├── github.com/
    │   └── sirupsen/
    │       └── logrus/
    └── golang.org/
        └── x/
            └── sys/
                └── unix/

在添加完vendor后我们重新编译main.go这个时候Go编译器就会在vendor目录下搜索程序依赖的logrus包以及后者依赖的golang.org/x/sys/unix包了。

这里你还要注意一点要想开启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构建模式。

04讲我们曾基于Go Module构建模式编写过一个“hello, world”程序当时是为了讲解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 Module说起。我们先来将上面的例子改造成为一个基于Go Module构建模式的Go项目。

创建一个Go Module

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

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

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

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

我们一步一步来详细看一下。

我们先建立一个新项目module-mode用来演示Go Module的创建注意我们可以在任意路径下创建这个项目不必非要在GOPATH环境变量的配置路径下。

这个项目的main.go修改自上面的例子修改后的main.go的代码是这样的我们依旧依赖外部包logrus

package main

import "github.com/sirupsen/logrus"

func main() {
	logrus.Println("hello, go module mode")
}

你可以看到这个项目目录下只有main.go一个源文件现在我们就来为这个项目添加Go Module支持。我们通过go mod init命令为这个项目创建一个Go Module这里我们使用的是Go版本为1.16.5Go 1.16版本默认采用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

现在go mod init在当前项目目录下创建了一个go.mod文件这个go.mod文件将当前项目变为了一个Go Module项目根目录变成了module根目录。go.mod的内容是这样的

module github.com/bigwhite/module-mode

go 1.16

这个go.mod文件现在处于初始状态它的第一行内容用于声明module路径(module path)最后一行是一个Go版本指示符用于表示这个module是在某个特定的Go版本的module语义的基础上编写的。

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

我们看到对于一个处于初始状态的module而言go mod tidy分析了当前main module的所有源文件找出了当前main module的所有第三方依赖确定第三方依赖的版本还下载了当前main module的直接依赖包比如logrus以及相关间接依赖包直接依赖包的依赖比如上面的golang.org/x/sys等

Go Module还支持通过Go Module代理服务加速第三方依赖的下载。在03讲我们讲解Go环境安装时就提到过GOPROXY环境变量这个环境变量的默认值为“https: // proxy.golang.org,direct不过我们可以配置更适合于中国大陆地区的Go Module代理服务。

由go mod tidy下载的依赖module会被放置在本地的module缓存路径下默认值为$GOPATH[0]/pkg/modGo 1.15及以后版本可以通过GOMODCACHE环境变量自定义本地module的缓存路径。

执行go mod tidy后我们示例go.mod的内容更新如下

module github.com/bigwhite/module-mode

go 1.16

require github.com/sirupsen/logrus v1.8.1

你可以看到当前module的直接依赖logrus还有它的版本信息都被写到了go.mod文件的require段中。

而且执行完go mod tidy后当前项目除了go.mod文件外还多了一个新文件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=

这同样是由go mod相关命令维护的一个文件它存放了特定版本module内容的哈希值。

这是Go Module的一个安全措施。当将来这里的某个module的特定版本被再次下载的时候go命令会使用go.sum文件中对应的哈希值和新下载的内容的哈希值进行比对只有哈希值比对一致才是合法的这样可以确保你的项目所依赖的module内容不会被恶意或意外篡改。因此我推荐你把go.mod和go.sum两个文件与源码一并提交到代码版本控制服务器上。

现在go mod init和go mod tidy已经为我们当前Go Module的构建铺平了道路接下来我们只需在当前module的根路径下执行go build就可以完成module的构建了

go build命令会读取go.mod中的依赖及版本信息并在本地module缓存路径下找到对应版本的依赖module执行编译和链接。如果顺利的话我们会在当前目录下看到一个新生成的可执行文件module-mode执行这个文件我们就能得到正确结果了。

整个过程的执行步骤是这样的:

$go build
$$ls
go.mod		go.sum		main.go		module-mode*
$./module-mode 
INFO[0000] hello, go module mode

好了到这里我们已经完成了一个有着多个第三方依赖的项目的构建了。但关于Go Module的操作还远不止这些。随着Go项目的演进我们会在代码中导入新的第三方包删除一些旧的依赖包更新一些依赖包的版本等。关于这些内容我会在下一节课再给你详细讲解。

那么在看到我们的Go Module机制会自动分析项目的依赖包并选出最适合的版本后不知道你会不会有这样的疑惑**项目所依赖的包有很多版本Go Module是如何选出最适合的那个版本的呢**要想回答这个问题我们就需要深入到Go Module构建模式的工作原理中去。

深入Go Module构建模式

Go语言设计者在设计Go Module构建模式来解决“包依赖管理”的问题时进行了几项创新这其中就包括语义导入版本(Semantic Import Versioning),以及和其他主流语言不同的**最小版本选择(Minimal Version Selection)**等机制。只要你深入理解了这些机制你就能真正掌握Go Module构建模式。

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

在上面的例子中我们看到go.mod的require段中依赖的版本号都符合vX.Y.Z的格式。在Go Module构建模式下一个符合Go Module要求的版本号由前缀v和一个满足语义版本规范的版本号组成。

你可以看看下面这张图语义版本号分成3部分主版本号(major)、次版本号(minor)和补丁版本号(patch)。例如上面的logrus module的版本号是v1.8.1这就表示它的主版本号为1次版本号为8补丁版本号为1。

Go命令和go.mod文件都使用上面这种符合语义版本规范的版本号作为描述Go 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"
)

不过到这里你可能会问v0.y.z版本应该使用哪种导入路径呢

按照语义版本规范的说法v0.y.z这样的版本号是用于项目初始开发阶段的版本号。在这个阶段任何事情都有可能发生其API也不应该被认为是稳定的。Go Module将这样的版本(v0)与主版本号v1做同等对待也就是采用不带主版本号的包导入路径这样一定程度降低了Go开发人员使用这样版本号包时的心智负担。

Go语义导入版本机制是Go Module机制的基础规则同样它也是Go Module其他规则的基础。

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

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

在这张图中myproject有两个直接依赖A和BA和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.0B明明说只要求C v1.3.0。所以Go会在该项目依赖项的所有版本中选出符合项目整体要求的“最小版本”。

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

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

但很多Go开发人员的起点并非是默认开启Go Module构建模式的Go 1.16版本多数Go开发人使用的环境中都存在着多套Go版本有用于体验最新功能特性的Go版本也有某些遗留项目所使用的老版本Go编译器。

它们工作时采用的构建模式是不一样的并且即便是引入Go Module的Go 1.11版本它的Go Module机制和后续进化后的Go版本的Go Module构建机制在表现行为上也有所不同。因此Go开发人员可能需要经常在各个Go版本间切换。而明确具体版本下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版本间切换了不用再担心切换后模式变化导致构建失败了。

小结

好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。

在这一讲中我们初步了解了Go语言构建模式的演化历史。

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为不同值的情况下开启的构建模式以及具体表现行为也几经变化这里你重点看一下前面总结的表格。

现在Go核心团队已经考虑在后续版本中彻底移除GOPATH构建模式Go Module构建模式将成为Go语言唯一的标准构建模式。所以学完这一课之后我建议你从现在开始就彻底抛弃GOPATH构建模式全面使用Go Module构建模式

思考题

今天我们的思考题是如何将基于GOPATH构建模式的现有项目迁移为使用Go Module构建模式欢迎在留言区和我分享你的答案。

感谢你和我一起学习也欢迎你把这节课分享给更多对Go构建模式感兴趣的朋友。我是Tony Bai我们下节课见。