传统Go构建以及包依赖管理
- Go在构建设计方面深受Google内部开发实践的影响,比如go get的设计就深受 Google内部单一代码仓库(single monorepo)和基于主干(trunk/mainline based)的开发模型 的影响:只获取Trunk/mainline代码和版本无感知
- 我们知道go get获取的代码会放在GOROOT/src下面,而go build会在GOROOT/src和GOPATH/src下面按照import path去搜索package,由于go get 获取的都是各个package repo的trunk/mainline的代码,因此,Go 1.5之前的Go compiler都是基于目标Go程序依赖包的trunk/mainline代码去编译的。这样的机制带来的问题是显而易见的,至少包括:
- 因依赖包的trunk的变化,导致不同人获取和编译你的包/程序时得到的结果实质是不同的,即不能实现reproduceable build
- 因依赖包的trunk的变化,引入不兼容的实现,导致你的包/程序无法通过编译
- 因依赖包演进而无法通过编译,导致你的包/程序无法通过编译
- 为了实现reporduceable build,Go 1.5引入了Vendor机制,Go编译器会优先在vendor下搜索依赖的第三方包,这样如果开发者将特定版本的依赖包存放在vendor下面并提交到code repo,那么所有人理论上都会得到同样的编译结果,从而实现reporduceable build
Go语言版本控制
在没有版本控制时,go get 的一个重大缺陷是对于给定的更新无法知道是否是用户所期望的。
Go 将在下个版本(1.11)中看到官方的包版本控制,去除了 GOPATH 依赖,同时还引入了 module(模块) 的概念。
版本控制可以让我们能够实现重编译。当我让你试用我程序最新版本时,我清楚的知道你不仅仅获取到的是我最新程序的代码,还包括我代码所依赖的相同版本的包,这样才能编译出完全一样的二进制包。
版本控制还能让我们不同阶段保持同样的编译方式,即使我们的依赖包可能有新版本了,只要我们的配置未允许使用,go 命令也不会使用新版本的包。
尽管添加版本控制是必须的功能,但同时也不能失去go 命令行现有的优秀特性:简单、高效、易懂,所以它应该足够透明不能破坏掉go get 本身功能。
Go新版本中保留了go get的精华部分,增加了重复构建,采用了语义化的版本控制,弃用了 vendor,废弃了基础工程创建时依赖GOPATH,并且提供了老项目平滑迁移的方式。
Go添加版本控制共分四个步骤
导入兼容规则
-
包管理系统中最大的痛苦在于解决兼容性问题
比如,大多数系统中包B 声明需要的包D 版本是6或者更高版本,然后包C声明所需的包D 版本是2,3和4,但不能高于版本5。如果你正在编写包A ,你想同时引入包B 和C ,那么你不走运了:没有一个独立的D 版本可以供B 和C 同时选择编译进A。B 和C 做的都是合理的,你也没办法改变它,所以你就被卡住了。
-
为了避免主导者设计一个导致现有的大型程序无法编译的系统,提案要求包作者遵循以下导入兼容性原则:
如果一个旧包和新包有相同的导入路径,新包必须向后兼容旧包 这条规则是对前面 Go FAQ 的重申,引用 FAQ 中最后讲的:“如果需要完全变更,那么就创建个新导入路径的包”。开发者希望能通过语义化的版本来表达这样一个变更,因此我们把语义化版本控制也加入到我们提案中。具体点说,主版本2 和更新的版本可以通过在路径中包含版本信息来区分,比如:
import "github.com/go-yaml/yaml/v2"
- 包作者遵循导入兼容性原则可以让我们减少适配工作,让系统更简单的同时也让包生态减少碎片化
当然,实际上尽管作者尽最大努力去做了,更新时也难免会出现破坏用户使用的情况。因此,使用一个不频繁升级的升级机制很重要,这也是接下来我们要讲的。
最小版本规则
-
几乎现在所有的包管理包括dep和cargo都在构建时使用最新的包版本,基于两方面的重要因素,被认为这是个错误的约定:
首先,“最新可用版本”有可能因为外部事件导致变更,像新版本发布。也许今晚你依赖的包中有人会发布个新版本,第二天早上你再编译有可能就产生不同的结果了;
第二,为了覆盖这个默认约定,开发者花费大量的时间告诉包管理器不使用哪个版本的包。
-
提案中我们使用了不同的方式,称之为最小版本选择。
构建时每个包默认使用的是最老的可用版本,这个方式让昨天和今天的编译不会有变化,因为你总不会在今天发布一个更老版本吧。更好的是,开发者只需告诉包管理器最小可用的那个版本,包管理器就可以很快的决定哪个版本可用。我们称它为最小版本选择一方面是因为我们选择的是最小版本,另一方面是因为对整个系统来说是最小化的,避免了现有系统的复杂性。
最小版本选择为模块指定了其依赖模块的最低版本需求,这为后续升级和降级操作提供了一个很好的选择。同时,它还可以通过排除指定版本的依赖或者指定特殊版本依赖完成编译。
最小版本选择在不锁定文件情况下默认就完成了可重复构建。
最小版本选择是导入兼容的关键。用户不会再说:“不,版本太新了”,更多情况是面临“不,版本太旧了”,这种情况下解决方案很明确:升级新版本就可以了。
Go Module
-
Go Module是共享一个导入路径前缀的包集合,也就是我们所说的模块路径
Module是版本控制的单元,Module的版本通过语义化的版本字符串表示,当开发中使用Git 时,开发者通过给模块的Git 资源库添加一个新tag的方式来定义一个新的语义化版本。尽管强烈推荐使用语义化版本的方式,但也支持指向特定commit。
-
模块定义在一个叫go.mod的新文件里,里面包含了模块所依赖包的最小版本
下面就是个简单的go.mod文件:
module "rsc.io/hello"
require (
"golang.org/x/text" v0.0.0-20180208041248-4e4a3210bb54
"rsc.io/quote" v1.5.2
)
这个文件通过路径标识 rsc.io/hello 定义了一个模块,它本身还依赖于两个其他模块:golang.org/x/text 和 rsc.io/quote ,这个模块自身编译的时候使用的是 go.mod 文件中指定的依赖列表的版本。对于更上一层的编译,其他导入这个模块的地方将使用它较新的版本编译。
包发布者最好使用语义化的 tag 发布版本,vgo 也鼓励通过打tag的版本号方式,而不是任意的提交版本。
除了指定必须的依赖版本,go.mod 文件还可以实现前面章节中提到的排除和替换的版本,但是这些只有当直接编译该模块的时候起作用,在模块作为整体工程一部分编译时就不行了
Goinstall 和旧的 go get 通过像git 和hg 这样的版本控制工具直接下载代码,这种方式存在很多问题,其中包括碎片化严重:用户如果没有bzr 就没法下载托管在Bazaar 资源库的代码。相比之下,Go Module则是通过HTTP 下载zip 包的方式。
Module统一通过zip包的形式提供可以让下载协议更简单,公司或者个人可以处于任何原因考虑(安全或者想要缓存副本防止源被删除)自己做下载代理,使用代理来确保可用性并且通过go.mod定义了哪些代码需要用到
Go 命令
go 命令必须更新才能使用模块功能。一个重要的变化就是常用的构建命令,像 gobuild, go install, go run, 和 go test 将需要按指定需求解析对应的依赖关系了
最重要的变化还是终结了GOPATH作为Go 代码工作空间的设置,由于go.mod文件包含了完整的模块路径并且还定义了每个使用的依赖的版本,因此包含go.mod文件的目录就可以被认为是一个目录树的根目录了,该目录树作用于自身的工作空间,并且和其他类似的目录彼此隔离。现在你只需git clone然后cd就可以直接撸代码了,不再需要GOPATH了