也许 Go 开发可以更简单!Go += Package Versioning

830 阅读22分钟
原文链接: zhuanlan.zhihu.com
简评:对于Go来说一直以来依赖包的版本控制上没有一个好的方案,尽管社区诞生了不下十余个解决该问题的工具,但一直以来没有一个官方的支持。这个提案有望在Go 的下个版本中看到官方的包版本控制了,去除了GOPATH依赖,同时还引入了module 的概念,真正意义上实现了重编译,可谓一次大的变更

是时候为 Go 添加包版本控制了!

更确切地说,我们需要把包版本的概念普及到Go 开发者和工具常用词汇中,以便在后续的相互沟通时能准确一致的表达哪些程序代码需要编译、运行和解析。同样,go 命令也需要准确的告诉开发者在编译中使用了哪个包的哪个版本。

版本控制可以让我们能够实现重编译。当我让你试用我程序最新版本时,我清楚的知道你不仅仅获取到的是我最新程序的代码,还包括我代码所依赖的相同版本的包,这样才能编译出完全一样的二进制包。

版本控制还能让我们不同阶段保持同样的编译方式,即使我们的依赖包可能有新版本了,只要我们的配置未允许使用,go 命令也不会使用新版本的包。

尽管添加版本控制是必须的功能,但同时我们也不能失去go 命令行现有的优秀特性:简单、高效、易懂。目前来看,很多程序员还是不太重视版本控制,主要是大部分情况下也都没出现什么问题。试想如果我们有一个合理模式的设计和默认配置,让开发者不需要太多关注版本控制的东西,程序仍然能很好的工作,并且对现有工程影响较小,发布新版本足够简单,甚至日常开发中可以忽略掉版本控制的工作,这样的版本控制模式才是我们想要的。

简而言之,版本控制是必须的,但是应该足够透明不能破坏掉go get 本身功能。这篇文章探讨了一个能完全实现这些的提案,并且提供了一个现在可用的原型demo,希望这能为集成到go命令奠定基础。我打算通过这篇文章讨论下在产品化过程中哪些是需要做的,哪些是不需要做的,基于这个讨论,我将对提案和原型做进一步调整,并且提交一个官方提案,作为可选功能集成到Go 1.11版本中。

这个提案保留了go get的精华部分,增加了重复构建,采用了语义化的版本控制,弃用了vendor,废弃了基础工程创建时依赖GOPATH,并且提供了老项目平滑迁移的方式,目前这个提案还处于初级阶段,如果细节上有问题,我们会在Go主版本发布前修复掉

背景

在我们讨论这个提案之前,先了解下当下的现状。讲起来可能会很长,但历史的教训对现在有很重要的参考意义,并且让我们能够清楚的知道这个提案改变了什么。如果你觉得没意思,那么可以直接跳到提案,或者去看原型demo

Makefiles, goinstall, 和 go get

2009年11月,Go 发布了带有编译器、链接器和一些内置库的初代版本,在当时你必须通过运行6g6l 来编译和链接你的程序,还包含一些简单的makefiles。大多数情况下,通过简单包装的gobuild可以编译一个单包程序并且生成对应的makefile。在当时,也没有一个合适的方式把代码分享给其他人。尽管还有很多功能没提供,但是仍然发布了,并且Go 计划将一些剩余的功能放到社区去做。

2010年2月,goinstall 出现了,一个新的零配置的命令行,主要用来从源码管理库(像 Bitbucket 和 GitHub)下载packages。Goinstall引入了今天在Go开发者中已经普及的路径约定,因为在当时没有代码遵循这个约定,goinstall 起初只能用在标准库的导入上,但是开发者很快就把他们自己的命名约定迁移到今天我们所知的统一约定上,这些发布的Go packages 逐渐形成了一个连贯的生态系统。

Goinstall 同时还弃用了makefiles,消除了用户在构建配置上的复杂性。尽管对 package 作者来说每次构建时不能生成代码偶尔会觉得不方便,但是对于 package 使用者来说这个简化却非常重要:使用者不必担心安装的 package 里工具集编译时和 package 作者编译时不一致了,简化对于一个工具来说很有意义。对于分步编译 package 来说 makefile 有一定的必要性;逆向工程中如何让同一个 package 使用不同的工具(像go vet或者代码完成)makefile在这方面就很难去做了。即便正确的维护好了编译依赖关系,对于任何一个makefiles来说在必要时再进行重编译也很困难。尽管一些人认为去掉makefiles失去了灵活性,但是回头来看,所获得的好处远大于这些不便。

2011年12月,作为Go 1 的预发版本的一部分,我们介绍了go命令中使用 go get 来替换goinstall

总的来说,go get 是变革性的,它让 Go 开发者能够共享代码和相互构建,并且通过工具隔离了 go 命令编译系统中的细节,但是,go get 缺少版本控制的概念,实际上在goinstall第一次讨论中就清楚的意识到需要版本控制相关的功能。不幸的是,至少在当时我们的 Go 团队中还不清楚应该怎么去做。当go get需要一个包时,总是从像 Git 或者 Mercurial 这样的远程版本控制系统中下载最新的副本,包版本管理上的缺失至少导致了两个重大的缺陷。

Versioning 和 API 的稳定性

首先在没有版本控制时go get 的一个重大缺陷是对于给定的更新无法知道是否是用户所期望的。

在2013年11月,Go 1.2 添加了一个发关于包版本控制基本建议的FAQ:

公共发布的包应该保持向后兼容性。Go 1 compatibility guidelines 提供了一个好的参考:不要删除导出的命名,鼓励使用符合语义(composite literals)tag命名版本等等。如果需要不同的功能请添加新的命名而不是修改旧的命名,如果需要完全独立的功能,在新的导入路径下创建新 package

2014年3月,Gustavo Niemeyer 创建了gopkg.in,倡导“Go语言稳定性API”。这个网站其实是个感知 GitHub 版本变化的重定向器,你可以通过gopkg.in/yaml.v1gopkg.in/yaml.v2 的这样的导入方式来指向Git库的不同提交版本(也可能在不同分支)。按照这个思路,依赖库的功能有了重大改变后你可以把之前的 v1 版本导入路径作为备用,然后创建一个新版本 v2,通过v2 导入路径引入完全不同的API。

2015年8月,Dave Cheney 提出了语义化版本管理的提案(a proposal to adopt semantic versioning),在接下来几个月里引发了一场有趣的讨论,每个人都认为语义化版本管理是个好主意,但是没有人知道下一步怎么做:语义化版本应该使用什么样的工具呢?

任何关于语义化版本控制的的讨论都不可避免的会有人用海勒姆法则的反驳:

当一个 API 有足够的用户的时候,在约定中你承诺的什么都无所谓,所有在你系统里面被观察到的行为都会被一些用户直接依赖。

虽然海勒姆法则在经验上来讲是正确的,但是语义化版本控制仍然是不同发布版本间建立一个合理期望关系的有效方式。一般来说,从1.2.3 升级到1.2.4 不应该破坏你的原有代码,然而从1.2.3 升级到2.0.2 有可能会破坏原来的代码。如果你的版本升级到1.2.4 出现了问题,那么作者一般会根据 bug 的报告在1.2.5 修复掉,如果你的代码升级到2.0.0 出现了问题,那么有可能是一次大升级的故意为之。

Vendoring 和 可重复构建

go get 没有版本控制概念的第二个重大缺陷是你很有可能无法实现重编译的想法。你没有办法确认你的程序的使用者编译的时候和你的编译时依赖于相同版本的包,在2013年11月,Go 1.2 FAQ 中也增加了以下基础建议:

如果你使用的是外部提供的包担心它会发生意想不到的改变时,最简单的方式是拷贝到你本地的库中(这是Google内部采用的方法)把它标记为本地库放到一个新的导入路径下,比如把"original.com/pkg"拷贝到" you.com/external/or…"。Keith Rarick的goven 是一个自动化实现该功能的工具

Goven 是 Keith Rarick 在2012年3月发布的,它将你依赖的包都拷贝到你本地资源库并且更新所有的导入路径指向新的本地路径,用这种方式修改源代码的依赖对于编译来说是有效的,但也存在一些问题,这种修改让本地的包很难和新的副本比较变化来合并需要的更新。

在2013年9月,Keith 发布了godep,“一个冻结包依赖的新工具”。godep最重要的提升是添加了我们现在都知道的Go vendoring ——无需修改源文件将依赖拷贝到项目中——无需工具链支撑,通过某种方式设置GOPATH实现

2014年10月,Keith 建议在Go 工具链中增加“外部包”的概念支持,以便于工具可以根据约定更好的分析工程。在那个时候,也出现了很多类似于godep的工具,Matt Farina 写了一篇文章“Glide in the Sea of Go Package Managers” 比较了godep和很多后来出现的工具,其中最为突出的是glide

2015年4月,Dave Cheney 介绍了gb,一个“基于工程的构建工具...通过vendor中的源码实现可重复构建”,而且无需重新导入,(gb的另一个目的是避免将需要的代码放在特殊指定的GOPATH目录下,这对很多开发者来说都不是一个好的开发流程)。

那年春天,Jason Buberel 对Go 包管理工具进行了调查以便了解通过什么样的方式来整合下这些不同人努力的成果,避免出现重复和浪费的工作。他的调查让 Go 团队清楚的认识到go命令需要在包不重新导入情况下直接支持vendoring。与此同时,Daniel Theophanes 开始制定了一个文件格式规范来描述vendor目录下代码的准确源和版本信息。在2015年6月,我们接受了Keith 的提案vendor作为Go 1.5 实验特性,在Go 1.5 中是可选的,在Go 1.6 中是默认的特性。我们鼓励所有vendoring工具的作者和Daniel 一起努力制定一个统一元数据格式的文件规范。

vendoring概念融入到Go 的工具链中可以让像go vet这样的程序分析工具更好的理解工程。到现在为止已经有数十个Go 包管理工具或者vendoring工具来管理 vendor 目录。从另一方面来说,因为这些工具使用了不同元数据的文件格式规范,他们之间也无法轻易的共享依赖的相关信息。

更重要的是,vendoring并不是一个完整的解决方案,它只是提供了可重编译的实现而没有解决包版本控制的问题,它并没有帮助工程去理解包的版本来决定使用哪个版本的包。像glidedep这样的包管理器是通过对vendor目录的某种设置隐式的将版本控制的概念引入到Go编译中,也不需要直接的工具链支撑。事实上,在Go 生态系统中很多工具都不能识别版本,很显然,Go需要对包版本提供直接的工具链支持。

一次官方包管理的实验

在2016年GopherCon上,一群对Go 感兴趣的gophers 聚集在Hack Day 上围绕Go包管理进行了一场讨论,其中一个成果是成立一个委员会和包管理咨询小组,目标就是创建一个新的Go 包管理工具,愿景是统一现有的一些工具,实现方式上仍然通过vendor 目录的方式。由Peter Bourgon 发起以及委员会成员Andrew Gerrand, Ed Muller, Jessie Frazelle, and Sam Boyer 共同起草了一份规范,在Sam 主导下实现了dep,相关背景信息请查看Sam 在2016年2月发布的文章“So you want to write a package manager” 和2016年12月的文章 “The Saga of Go Dependency Management” 以及2017年7月GopherCon 的分享“The New Era of Go Package Management”。

Dep 有多种用途:它是基于当前最佳实践的一次改进,也是迈向成功解决方案的重要一步,同时也是一次“官方实验”,这帮助我们更加深入的了解到对于Go 开发者来说我们哪些该做以及哪些没有做好。但是dep并不是go 命令集成包版本控制的最终原型。它通过一种强大灵活性的方式探索设计的空间,可以说在我们编译 Go 程序时扮演着makefiles的功能,一旦我们深入的理解了它的设计思路以及聚焦到几个必须支持的关键功能的时候,你会发现这样的设计将会移除掉Go 生态中很多其他功能降低了表达力成本,同时,强制的约定让Go 代码看起来更统一和易懂,对于构建工具来说也变得更简单了。

我们接下来看下dep下一阶段的目标:完成go 命令集成最终原型的初稿,类似于goinstall 的包管理,这个原型是一个独立的命令我们称之为vgo,你可以认为它就是个支持包版本控制的go 命令。这是一个新的实验特性,和当时引入goinstall 一样,一些代码和工程已经支持vgo了,其他一些工程需要做些适配。跟移除 makefiles 时一样去掉了一些控制和表现层相关的东西,简化系统降低用户使用成本。

vgo 的实验并不意味着我们将停止对dep的支持,我们会持续保证dep的可用直到go 命令集成的路径确定、实现并且可用。当然我们也尽可能的保证能从dep平滑过渡到go 命令集成的方式,如果工程没有使用dep(注意godepglide已经停止更新,建议迁移到dep)那么可以直接迁移到vgo

提案

提案中关于在 go 命令中添加版本控制共分四个步骤。首先是要兼容Go FAQ 和gopkg.in 中的导入规则,也就是说建立一个预期,新版本的包导入路径应该向后兼容于老版本。第二,采用一个简单的新算法(称为最小版本选择)来筛选出哪个包版本在编译时使用。第三,引入Go 模块的概念,Go 模块是一组包含单个版本的包并且声明了它们所需的最低版本的依赖。第四,定义如何将这些改变集成到go 命令,因此从现在开始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"

创建了v2.0.0 版本,在语义化版本控制中意味着一次重大变更,按照导入兼容原则要创建一个新导入路径的包。由于每个主要版本有不同的导入路径,因此给定的Go 可执行程序中可能包含主版本中任意一个,这正是我们预期想要的。

包作者遵循导入兼容性原则可以让我们减少适配工作,让系统更简单的同时也让包生态减少碎片化。当然,实际上尽管作者尽最大努力去做了,更新时也难免会出现破坏用户使用的情况。因此,使用一个不频繁升级的升级机制很重要,这也是接下来我们要讲的。

最小版本选择

几乎现在所有的包管理包括depcargo都在构建时使用最新的包版本,基于两方面的重要因素,我认为这是个错误的约定。首先,“最新可用版本”有可能因为外部事件导致变更,像新版本发布。也许今晚你依赖的包中有人会发布个新版本,第二天早上你再编译有可能就产生不同的结果了。第二,为了覆盖这个默认约定,开发者花费大量的时间告诉包管理器不使用哪个版本的包。

提案中我们使用了不同的方式,称之为最小版本选择。构建时每个包默认使用的是最老的可用版本,这个方式让昨天和今天的编译不会有变化,因为你总不会在今天发布一个更老版本吧。更好的是,开发者只需告诉包管理器最小可用的那个版本,包管理器就可以很快的决定哪个版本可用。我们称它为最小版本选择一方面是因为我们选择的是最小版本,另一方面是因为对整个系统来说是最小化的,避免了现有系统的复杂性。

最小版本选择为模块指定了其依赖模块的最低版本需求,这为后续升级和降级操作提供了一个很好的选择。同时,它还可以通过排除指定版本的依赖或者指定特殊版本依赖完成编译。

最小版本选择在不锁定文件情况下默认就完成了可重复构建。

最小版本选择是导入兼容的关键。用户不会再说:“不,版本太新了”,更多情况是面临“不,版本太旧了”,这种情况下解决方案很明确:升级新版本就可以了。

Go Modules

Go 模块是共享一个导入路径前缀的包集合,也就是我们所说的模块路径。模块是版本控制的单元,模块的版本通过语义化的版本字符串表示,当开发中使用Git 时,开发者通过给模块的Git 资源库添加一个新tag的方式来定义一个新的语义化版本。尽管强烈推荐使用语义化版本的方式,但也支持指向特定commit。

模块定义在一个叫go.mod的新文件里,里面包含了模块所依赖包的最小版本。下面就是个简单的go.mod文件:

// My hello, world.

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/textrsc.io/quote ,这个模块自身编译的时候使用的是 go.mod 文件中指定的依赖列表的版本。对于更上一层的编译,其他导入这个模块的地方将使用它较新的版本编译。

包发布者最好使用语义化的 tag 发布版本,vgo 也鼓励通过打tag的版本号方式,而不是任意的提交版本。 rsc.io/quote 模块使用的是 tag 版本的方式,而 golang.org/x/text 模块没有提供一个tag版本。对于未命名的提交,v0.0.0-yyyymmddhhmmss-commit 表示一个指定日期的提交,在语义化版本控制中,字符串v0.0.0表示预发版本号,yyyymmddhhmmss-commit 表示预发版本的标识符。

除了指定必须的依赖版本,go.mod 文件还可以实现前面章节中提到的排除和替换的版本,但是这些只有当直接编译该模块的时候起作用,在模块作为整体工程一部分编译时就不行了,详细可查看这个例子(examples)。

Goinstall 和旧的 go get 通过像git 和hg 这样的版本控制工具直接下载代码,这种方式存在很多问题,其中包括碎片化严重:用户如果没有bzr 就没法下载托管在Bazaar 资源库的代码。相比之下,模块则是通过HTTP 下载zip 包的方式。之前,遇到特殊需求的包 go get 通过版本控制的命令行工具去主流的代码托管网站下载,现在vgo 直接通过网站提供的 API 下载需要的包。

模块统一通过zip包的形式提供可以让下载协议更简单,公司或者个人可以处于任何原因考虑(安全或者想要缓存副本防止源被删除)自己做下载代理,使用代理来确保可用性并且通过go.mod定义了哪些代码需要用到,vendor 目录也就不再需要了。

go 命令

go 命令必须更新才能使用模块功能。一个重要的变化就是常用的构建命令,像 gobuild, go install, go run, 和 go test 将需要按指定需求解析对应的依赖关系了,在新模块中使用golang.org/x/text 时只需在Go 源码中导入编译就可以了,无需单独关注版本问题。

但是,最重要的变化还是终结了GOPATH作为Go 代码工作空间的设置,由于go.mod文件包含了完整的模块路径并且还定义了每个使用的依赖的版本,因此包含go.mod文件的目录就可以被认为是一个目录树的根目录了,该目录树作用于自身的工作空间,并且和其他类似的目录彼此隔离。现在你只需git clone然后cd就可以直接撸代码了,不再需要GOPATH

下一步计划?

我还发布了一篇文章“A Tour of Versioned Go”,主要讲了使用vgo的一些感受。通过这篇文章可以了解到现在如何下载和体验vgo,我会在这一周发布更多的一些文章来补充下这篇文章跳过的一些内容。希望大家能针对这篇文章提出些建议,我也会尽量去看下 Go subreddit 和 golang-nuts 邮件反馈。在周五我会发布一篇FAQ的终结篇章,下周我将提交一个正式的Go 提案。

请尝试下vgo(vgo)吧,开始在你的版本库中通过tag标记版本吧,创建并检入go.mod文件。注意如果你的资源库有一个空的go.mod但是存在dep, glide, glock, godep, godeps, govend,govendor 或者 gvt配置文件的话,vgo将会使用它们填充go.mod文件。

原文:Go += Package Versioning
编译:缪斯的情人