研究!rsc公司

关于编程的想法和链接,通过

RSS(RSS)

围棋中的版本控制原则
发布于2019年12月3日星期二。PDF格式

这篇博客文章是关于我们如何以Go模块的形式将包版本控制添加到Go中,以及我们做出选择的原因。对其进行了调整和更新来自2018年我在新加坡GopherCon的演讲.

为什么选择版本?

首先,让我们看看这些方法,确保我们都在同一页上基于GOPATH的 得到打破。

假设我们有一个全新的Go安装,我们想编写一个导入D的程序。我们跑了 得到 D类.记住,我们使用的是原始的基于GOPATH的 得到,不是Go模块。

$去拿D

这将查找并下载最新版本的D,目前是D 1.0。它在建造。我们很高兴。

现在假设几个月后我们需要C。我们跑了 得到 C类.这将查找并下载最新版本的C,即C1.8。

$去拿C

C输入D,但 得到发现它已经下载了D的副本,所以它会重复使用该副本。不幸的是,该副本仍然是D1.0。C的最新副本是使用D 1.4编写的,包含C需要的功能或错误修复D 1.0中缺少的。所以C被破坏了,因为依赖项D太旧了。

由于构建失败,我们再次尝试 得到 -u个 C类.

$go获取-u C

不幸的是,一小时前D的作者发布了D1.6。因为 得到 -u个使用每个依赖项的最新版本,包括D,原来C还是破的。C的作者使用了D 1.4,效果很好,但D1.6引入了一个错误,使C无法正常工作。以前,C被打破是因为D太老了。现在,C坏了,因为D太新了。

这是两种方式 得到使用GOPATH时失败。有时它使用的依赖关系太旧。其他时候,它使用的依赖关系太新了。在这种情况下,我们真正想要的是D的版本C的作者使用并测试的。但基于GOPATH 得到不能那样做,因为它根本不知道包的版本。

围棋程序员开始要求更好的处理我们一发布包版本的甲状腺肿,的原始名称 得到.多年来编写了各种工具,与Go发行版分离,以帮助简化特定版本的安装。但由于这些工具在单一方法上不一致,它们不能作为创建其他版本软件工具的基础,例如版本软件godoc或版本软件漏洞检查器。

我们需要在Go中添加包版本的概念有很多原因。最紧迫的原因是帮助 得到停止使用太旧或太新的代码,但在围棋开发人员和工具词汇表使整个Go生态系统成为版本软件。这个执行模块镜像和校验和数据库,可以安全地加速Go包下载,和新的版本软件Go包发现站点都是通过全生态系统的理解实现的版本是什么。

软件工程版本

在过去的两年里,我们为Go本身添加了对包版本的支持,以Go模块的形式,内置于命令。Go模块引入了一个新的导入路径语法,称为语义导入版本化,以及用于选择要使用的版本的新算法,称为最小版本选择。

你可能会想:为什么不做其他语言所做的呢?Java有Maven,Node有NPM,Ruby有Bundler,Rust有Cargo。这个问题怎么还没有解决?

您可能还想知道:我们在2018年初推出了一款名为Dep的新的实验性围棋工具实施了Bundler and Cargo公司开创的通用方法。为什么Go模块没有重用Dep的设计?

答案是,我们从Dep了解到,一般Bundler/Cargo/Dep该方法包括一些决策,这些决策使软件工程更复杂、更具挑战性。由于了解到戴的设计中存在的问题,围棋模块的设计做出了不同的决定,从而使软件工程变得更简单、更容易。

但什么是软件工程?软件工程与编程有何不同?我喜欢以下定义:

软件工程就是编程的过程
当你添加时间和其他程序员时。

编程意味着让程序正常工作。你有一个问题要解决,你写一些Go代码,你运行它,你得到答案,你就完成了。这就是编程,这本身就足够困难了。

但是,如果代码必须日复一日地继续工作呢?如果另外五个程序员也需要编写代码,该怎么办?如果代码必须随着需求的变化而优雅地进行调整,该怎么办?然后你开始考虑版本控制系统,跟踪代码如何随时间变化并与其他程序员协调。添加单元测试,确保您修复的错误不会随着时间的推移再次出现,六个月后就不会了,而不是那个不熟悉代码的新团队成员。你考虑模块化和设计模式,将程序划分为团队成员需要的部分可以独立工作。您可以使用工具来帮助您尽早发现错误。你在寻找使程序尽可能清晰的方法,这样就不太可能出现错误。您要确保可以快速测试微小的更改,即使在大型程序中也是如此。你这么做是因为你的编程已转变为软件工程。

(软件工程的定义和解释是我的谷歌同事Titus Winters对一个原创主题的即兴表演,他的首选措辞是“软件工程是随着时间的推移而集成的编程。”值得你花七分钟时间去看他在2017年CppCon上提出了这一想法,视频中从8:17到15:00。)

几乎所有Go独特的设计决策出于对软件工程的关注。例如,大多数人认为我们使用戈夫特使代码看起来更好或结束团队成员了解程序布局。在某种程度上,我们做到了。但更重要的原因是gofmt公司如果一个算法定义了Go源代码的格式,然后是程序,比如甲状腺肿戈雷纳 修理,可以更轻松地编辑源代码。这有助于您长期维护代码。

另一个例子是,Go导入路径是URL。如果代码已导入“uuid”,你必须问哪个乌伊德包裹。正在搜索乌伊德pkg.go.dev软件找到了几十个同名的包。如果改为代码导入“github.com/google/uuid”,现在我们很清楚我们指的是哪种包装。使用URL可避免歧义并且还重用了现有的命名机制,使它更简单、更容易与其他程序员协调。继续这个例子,Go导入路径写入Go源文件中,不在单独的构建配置文件中。这使得Go源文件是独立的,这样更容易理解、修改和复制它们。这些决定都是为了简化软件工程。

原则

从Dep的设计到Go模块的改变有三大原则,所有这些都是为了简化软件工程。这些是兼容性、重复性和合作的原则。这篇文章的其余部分解释了每个原理,显示了它是如何引导我们对围棋模块做出与Dep不同的决定的,然后尽我所能公平地回应反对做出这种改变的反对意见。

原则1:兼容性

程序中名称的含义不应随时间而改变。

第一个原则是兼容性。兼容性(或者,如果您愿意,也可以说是稳定性)是指在程序中,名字的含义不应随时间而改变。如果一个名字去年意味着一件事,今年和明年的含义应该是一样的。

例如,程序员有时会感到困惑通过以下细节串。拆分.我们都期望这种分裂”你好 世界生成两个字符串“你好“和”世界.”但如果输入有前导空格、尾随空格或重复空格,结果也包含空字符串。

示例:字符串。拆分(x,“”)“hello world”=>{“hello”,“world”}“hello world”=>{“hello”,“”,“world”}“hello world”=>{“”,“hello”,“world”}“hello world”=>{“hello”,“world”,“}”

假设我们决定总体上会更好改变的行为串。拆分省略那些空字符串。我们能做到吗?

不。

我们已经付出了串。拆分特定的含义。文件和实施都同意这一含义。程序取决于这个含义。改变意思会破坏这些程序。这将破坏兼容性原则。

我们可以落实新含义;我们只需要给它取个新名字。事实上,多年前,为了解决这个问题,我们介绍了串。领域,这是为空间分隔领域量身定制的并且从不返回空字符串。

示例:字符串。字段(x)“hello world”=>{“hello”,“world”}“hello world”=>{“hello”,“world”}“hello world”=>{“hello”,“world”}“hello world”=>{“hello”,“world”}

我们没有重新定义串。拆分,因为我们关心兼容性。

遵循兼容性原则简化了软件工程,因为它让您在试图理解编程时忽略时间。人们不必想,“好吧,这个包是在2015年写的,回来的时候串。拆分返回空字符串,但此其他包是上周写的,所以它期望串。拆分把他们排除在外。”不仅仅是人。工具也不必担心时间。例如,重构工具始终可以将串。拆分呼叫从一个包裹到另一个包裹不用担心它会改变其含义。

事实上,Go 1最重要的功能不是语言更改或新的库功能。这是兼容性声明:

旨在按照Go 1规范编写的程序将继续正确编译和运行,保持不变,在该规范的生命周期内。今天有效的围棋项目应该继续有效即使Go 1的未来“点”版本出现(Go 1.1、Go 1.2等)。

golang.org/doc/go1compat

我们承诺不再改变名字的含义在标准库中,因此使用Go 1.1的程序可以预计将继续在Go 1.2中工作,依此类推。这种持续的承诺使用户能够轻松地编写代码并保持其正常工作即使他们升级到更新的Go版本更快的实施和新功能。

兼容性与版本控制有什么关系?考虑兼容性很重要因为当今最流行的版本控制方法-语义版本控制-相反,鼓励不相容.也就是说,语义版本化有一个不幸的影响,即使不兼容的更改看起来很容易。

每个语义版本都采用vMAJOR的形式。轻微。补丁。如果两个版本的主版本号相同,更高版本(如果你愿意的话,更高版本)有望向后兼容与较早(较少)的一个。但如果两个版本的主要数字不同,它们没有预期的兼容性关系。

语义版本化似乎表明,“可以使不兼容对包进行更改。通过增加主版本号来告诉用户有关它们的信息。一切都会好起来的。”但这是一个空洞的承诺。增加主版本号是不够的。一切都不好。如果串。拆分今天有一个意思,明天有不同的意思,现在,简单地阅读代码就是软件工程,不是编程,因为你需要考虑时间。

情况越来越糟。

假设B写成expect串。拆分v1,而C是按预期编写的串。拆分第2版。如果你自己构建每一个就好了。

但是当包A同时导入B和C时会发生什么?如果串。拆分必须只有一个含义,没有办法建立一个有效的程序。

对于Go模块的设计,我们认识到兼容性对于简化软件工程是绝对必要的必须得到支持、鼓励和遵循。Go FAQ自2013年11月Go 1.2以来一直鼓励兼容性:

面向公共使用的软件包应在发展过程中尽量保持向后兼容性。这个Go 1兼容性指南在这里是一个很好的参考:不要删除导出的名称,鼓励使用带标记的复合文字,等等。如果需要不同的功能,请添加新名称,而不是更改旧名称。如果需要完全中断,请使用新的导入路径创建新包。

对于Go模块,我们给这个旧建议取了一个新名称导入兼容性规则:

如果旧包和新包具有相同的导入路径,
新包必须与旧包向后兼容。

但是,我们该如何处理语义版本控制呢?如果我们仍然想使用语义版本控制,正如许多用户所期望的那样,那么导入兼容性规则需要不同的语义主要版本,定义上没有兼容性关系,必须使用不同的导入路径。在Go模块中执行此操作的方法是将主版本放在导入路径中。我们称之为语义导入版本控制.

在这个例子中,我的/东西/v2标识特定模块的语义版本2。版本1只是我的/东西,模块路径中没有显式版本。但当引入主版本2或更高版本时,必须在模块名称后添加版本,以区别于版本1和其他主要版本,所以版本2是我的/东西/v2,版本3是我的/东西/v3等等。

如果包是它自己的模块,如果出于某种原因,我们真的需要重新定义拆分而不是添加新函数领域,然后我们可以创造(主要版本1)和字符串/v2(主要版本2),具有不同的拆分功能。然后可以构建以前无法构建的程序:B说进口 “字符串”而C说进口 “字符串/v2”.这些是不同的包装,所以可以将两者都纳入程序中。现在B和C可以各自拥有拆分他们期望的功能。

因为字符串/v2有不同的自动导入路径、人员和工具了解他们命名不同的包,正如人们已经理解的那样加密/随机数学/兰德命名不同的包。没有人需要学习新的消歧规则。

让我们回到不可构建的程序,而不是使用语义导入版本控制。如果我们更换在本例中,使用任意包D,然后我们有一个经典的“钻石依赖问题”B和C都可以自己建造,但对D的要求不同,相互冲突。如果我们试图在a的构建中使用这两者,那么没有一个D选项是有效的。

语义导入版本控制减少了菱形依赖。不存在D的冲突要求。D版本1.3必须向后兼容D版本1.2,D版本2.0具有不同的导入路径D/v2。

使用两个主要版本的程序会将它们与其他版本分开两个包具有不同的导入路径,构建良好。

反对意见:美学

语义导入版本化最常见的反对意见人们不喜欢在导入路径中看到主要版本。简言之,它们很丑陋。当然,这实际上意味着人们没有被利用查看导入路径中的主要版本。

我能想到Go代码中两个主要美学变化的例子这在当时看起来很丑陋,但被采纳是因为它们简化了软件工程现在看起来很自然。

第一个例子是导出语法。早在2009年初,Go就使用了出口关键字将函数标记为导出。我们知道我们需要更轻量级的东西来标记各个结构字段,我们四处寻找想法,考虑到“前导下划线表示未报告”或“声明中的前导加号表示导出。”最后,我们想到了“出口上限”的想法。用大写字母作为出口信号对我们来说很奇怪,但这是我们能找到的唯一缺点。否则,这个想法很好,它满足了我们的目标,这比我们一直在考虑的其他选择更具吸引力。所以我们采用了它。我记得当时想改变一下fmt.printf打印fmt公司。打印在我的代码中很难看,或者至少很刺耳:对我来说,fmt公司。打印看起来不像围棋,至少不是我写的围棋。但我没有很好的理由反对它,所以我同意(并实施)这一改变。几周后,我习惯了,现在是fmt.printf打印看起来不像,去找我。更重要的是,我开始欣赏关于读取代码时导出和不导出的内容。当我现在回到C++或Java代码时,我看到一个调用,如x.危险()我很想一眼就能看出危险的方法是任何人都可以调用的公共方法。

第二个示例是导入路径,我在前面简要提到过。在Go的早期,之前甲状腺肿 得到,导入路径不是完整的URL。开发人员必须手动下载并安装名为乌伊德然后写进口 “uuid”.更改导入路径的URL(进口 “github.com/google/uuid”)消除了这种模糊性,提高了精确度 得到可能。起初人们确实在抱怨,但现在,更长的路是我们的第二天性。我们依赖并赞赏它们的精确性,因为它使我们的软件工程工作更简单。

这两个变化都是出口的大写字母和导入路径的完整URL的动机是好的软件工程论据反对意见是视觉美学。随着时间的推移,我们开始意识到这些好处,我们的审美判断也得到了调整。我希望导入路径中的主要版本也会发生同样的情况。我们会习惯的,我们会重视它们带来的精确性和简单性。

反对意见:更新导入路径

另一个常见的反对意见是从(比方说)升级吗模块的v2同一模块的v3需要更改引用该模块的所有导入路径,即使客户端代码不需要任何其他更改。

确实,升级需要重写导入路径,但编写工具也很容易全局搜索和替换。我们打算使处理此类升级成为可能 修理,虽然我们还没有实现。

前一个反对意见和这个反对意见含蓄地建议保留主版本信息仅存在于单独的版本元数据文件中。如果我们这样做,那么导入路径不够精确,无法识别语义,就像以前那样导入“uuid”可能意味着几十个不同的包中的任何一个。所有程序员和工具都必须查看元数据文件以回答以下问题:这是哪个主要版本?哪个串。拆分我在打电话吗?将文件从一个模块复制到另一个模块时会发生什么忘记检查元数据文件?相反,如果我们保持导入路径的语义精确,那么就不需要教授程序员和工具了将包的不同主要版本分开的新方法。

在导入路径中使用主版本的另一个好处是指当您将包从v2更新到v3时,你可以逐步更新程序,分阶段,可能一次一个包裹,而且总是很清楚哪些代码被转换了,哪些没有。

反对意见:一个构建中有多个主要版本

另一个常见的反对意见是在同一个版本中有D v1和D v2吗应该完全禁止。这样,D的作者就不会思考这种情况下产生的复杂性。例如,可能包D定义了一个命令行标志或注册HTTP处理程序,这样就可以同时构建D v1和D v2如果没有明确的协调,单个程序将失败在这些版本之间。

Dep严格执行此限制,有些人说这更简单。但这只对D的作者来说很简单。这对D的用户来说并不简单,通常用户数量超过作者。如果D v1和D v2不能在单个构建中共存,然后钻石依赖又回来了。你无法将大型程序从D v1逐渐转换为D v2,我刚才解释的方式。在互联网规模的项目中,这会将Go包生态系统分割为不兼容的包组:使用Dv1的包组和使用Dv2的包组。有关详细示例,请参阅我2018年的博客帖子,“语义导入版本化.”

Dep被迫禁止在一个构建中使用多个主要版本因为Go构建系统需要每个导入路径命名一个唯一的包(Dep没有考虑语义导入版本控制)。相比之下,Cargo和其他系统确实允许在一个构建中使用多个主要版本。据我所知,这些系统允许多个版本的原因与Go模块执行以下操作的原因相同:如果不允许它们,就很难在大型程序上工作。

反对意见:实验太难

最后一个反对意见是,导入路径中的版本是不必要的开销刚开始设计包装,您没有用户,你经常做出落后的、不兼容的改变。这绝对是真的。

语义版本控制正是这种情况的例外。在主版本0中,完全没有兼容性预期,以便在刚开始时可以快速迭代不用担心兼容性。例如,v0.3.4不需要向后兼容任何其他内容:不是v0.3.3,不是v0.0.1,也不是v1.0.0。

语义导入版本控制也有类似的例外:导入路径中未提及主版本0。

在这两种情况下,理由都是时间没有进入画面。你还没有在做软件工程。你只是在编程。当然,这意味着如果您使用其他人的软件包的v0版本,那么您就接受了这些包的新版本可能包括中断API更改而不更改相应的导入路径,你承担责任在发生这种情况时更新代码。

原则2:可重复性

包的给定版本的生成结果不应随时间而更改。

第二个原则是程序构建的可重复性。我指的是重复性当您构建包的特定版本时,构建应该决定哪些依赖项版本以可重复的方式使用,这不会随时间而改变。我今天的体形明天应该会匹配我的代码构建以及明年任何其他程序员的构建。大多数软件包管理系统并不能保证这一点。

我们前面看到了如何基于GOPATH 得到不提供重复性。弗斯特 得到使用的D版本太旧:

然后 得到 -u个使用的D版本太新:

你可能会想,“当然 得到犯了这个错误:它对版本一无所知。”但大多数其他系统也会犯同样的错误。我将以Dep为例,但至少Bundler和Cargo的工作方式是一样的。

Dep要求每个包都包含一个称为manifest的元数据文件,其中列出了依赖版本的要求。当Dep下载C时,它读取C的清单并得知C需要D1.4或更高版本。然后,Dep下载满足该约束的最新版本的D。昨天,这意味着D 1.5:

今天,这意味着D 1.6:

该决定取决于时间。它每天都在变化。生成不可重复。

Dep(以及Bundler和Cargo等)的开发者了解重复性的重要性,所以他们引入了第二个元数据文件,称为锁文件。如果C是一个完整的程序,Go会调用什么包裹 主要的,然后锁文件会列出要使用的确切版本对于C的每个依赖项,Dep让锁文件推翻它通常会做出的决定。锁定这些决策确保它们不再随时间变化并使构建可重复。

但锁定文件仅适用于整个程序包裹 主要的.如果C是一个库,作为更大程序的一部分构建会怎么样?然后是只用于构建C的锁文件可能无法满足较大程序中的附加约束。所以Dep和其他人必须忽略锁定文件与库关联并返回到默认的基于时间的决策。当您将C 1.8添加到更大的构建中时,你得到的确切包裹取决于今天是星期几。

总之,Dep从基于时间的决策开始关于要使用的D版本。然后添加一个锁文件,为了重复性,覆盖基于时间的决策,但该锁定文件只能应用于整个程序。

在Go模块中相反,命令会做出决定关于以不随时间变化的方式使用D的哪个版本。然后构建可以一直重复,而不会增加锁文件覆盖的复杂性,这种重复性适用于库,而不仅仅是整个程序。

Go模块使用的算法非常简单,尽管有一个令人印象深刻的名字“最小版本选择”它的工作原理是这样的。每个包指定每个依赖项的最低版本。例如,假设B1.3请求D1.3或更高版本,并且C 1.8请求D 1.4或更高版本。在Go模块中command更喜欢使用这些精确版本,而不是最新版本。如果我们单独构建B,我们将使用D1.3。如果我们单独构建C,我们将使用D1.4。这些库的构建是可重复的。

如图所示,如果生成请求的不同部分不同最低版本命令使用请求的最新版本。A的构建看到了对D 1.3和D 1.4的请求,1.4晚于1.3,因此构建选择D1.4。该决定并不取决于D 1.5和D 1.6是否存在,所以它不会随时间而改变。

我之所以称之为最小版本选择,有两个原因。首先,它为每个包选择满足请求的最小版本(相当于请求的最大版本)。其次,这似乎只是可能奏效的最简单的方法。

最小版本选择提供重复性,对于整个程序和库,总是,没有任何锁定文件。它消除了时间的考虑。每个选择的版本总是其中一个版本已选择的某个包明确提到用于构建。

反对意见:使用最新版本是一项功能

优先考虑重复性的常见第一个反对意见就是声称更喜欢最新版本依赖关系是一个特性,而不是一个bug。这种说法是程序员要么不想或者懒得定期更新依赖项,所以像Dep这样的工具应该使用最新的依赖项自动。理由是拥有最新版本超过了重复性的损失。

但这一论点经不起仔细审查。Dep等工具提供锁文件,这就要求程序员自己更新依赖项,正是因为可重复构建比使用最新版本更重要。当您部署单行错误修复时,你想确保你的错误修复是唯一的改变,你也没有捡到依赖项的不同更新版本。

你想推迟升级,直到你提出要求,以便您可以准备运行所有单元测试,所有的集成测试,甚至可能生产金丝雀,开始使用之前这些升级了生产中的依赖关系。每个人都同意这一点。锁定文件之所以存在,是因为每个人都同意这一点:可重复性比自动升级更重要。

反对意见:构建库时使用最新版本是一项功能

你可以提出更微妙的论点根据最小版本选择生成就是承认重复性对整个程序构建至关重要,但随后认为,对于图书馆来说,平衡是不同的,并且拥有最新的依赖关系比可重复构建。

我不同意。随着编程越来越意味着连接大型图书馆,那些大型图书馆的组织越来越多作为较小图书馆的集合,选择全过程构建可重复性的所有原因对于库构建来说变得同样重要。

这一趋势的极限是最近的动向从云计算到“无服务器”托管,比如亚马逊Lambda、谷歌云功能、,或Microsoft Azure函数。我们上传到这些系统的代码是一个库,而不是一个完整的程序。我们当然希望生产建立在使用相同版本的服务器依赖于我们的开发机器。

当然,不管怎样,重要的是要让它更容易程序员定期更新他们的依赖项。我们还需要工具来报告哪些版本在给定的构建或给定的二进制中,包括更新可用时的报告以及在正在使用的版本。

原则3:合作

为了维护Go软件包生态系统,我们必须共同努力。
工具无法解决缺乏合作的问题。

第三个原则是合作。我们经常谈论“围棋社区”和“Go开源生态系统”社区和生态系统强调我们所有的工作都是相互关联的,我们依靠彼此的贡献来建设。目标是一个统一的系统,作为一个连贯的整体运行。相反,我们想要避免的是,是一个支离破碎的生态系统,分成无法相互协作的包组。

合作原则承认保持生态系统健康繁荣就是让我们大家一起工作。如果我们不这样做,那么无论我们的工具技术多么复杂,Go开源生态系统注定会支离破碎,最终会失败。那么,含蓄地说,如果修复不兼容性需要合作,那也没关系。无论如何,我们都无法避免合作。

例如,我们再次使用C 1.8,需要D 1.4或更高版本。由于可重复性,C 1.8本身的构建将使用D1.4。如果我们将C构建为需要D1.5的更大构建的一部分,那也没关系。

然后发布了D1.6,以及一些更大的构建,也许是连续集成测试,发现C 1.8不适用于D 1.6。

无论如何,解决方案是C的作者和D的作者合作并发布修复程序。确切的修复取决于到底出了什么问题。

也许C依赖于D 1.6中修复的错误行为,或者C取决于D 1.6中更改的未指定行为。那么解决方案是C的作者发布一个新的C版本1.9,与D的进化合作。

或者可能D1.6只是有一个错误。然后解决方案是D的作者发布一个固定的D 1.7,尊重兼容性原则进行合作,此时C的作者可以发布C版本1.9指定它需要D 1.7。

花点时间看看刚才发生了什么。最新的C和最新的D不能一起工作。这在Go软件包生态系统中引入了一个小裂缝。C的作者或D的作者致力于修复该错误,相互合作以及生态系统的其他部分修复骨折。这种合作对保持生态系统健康至关重要。没有足够的技术替代品。

Go模块中的可重复构建意味着有缺陷的D1.6在用户明确要求升级之前不会被选中。这为C的作者和D的作者创造了合作的时间真正的解决方案。Go模块系统没有其他尝试这些暂时的不相容。

反对意见:使用声明的不兼容和SAT解决方案

这种方法最常见的反对意见是依赖于合作是指期望开发人员合作是不合理的。开发人员需要一些单独解决问题的方法。理由是:他们只能真正依靠自己,不是其他人。包管理器提供的解决方案如捆扎机、货物和折旧允许开发人员声明他们的包与其他包之间的不兼容性然后使用SAT解算器找到不被约束排除的包组合。

这个论点因几个原因而失败。

首先Go模块使用的算法选择版本已经为特定模块的开发人员提供了完整的控制为该模块选择的版本,实际上比SAT约束更具控制力。开发人员可以强制使用任何依赖项的任何特定版本,说“无论别人怎么说,都要用这个确切的版本。”但这种能力仅限于特定模块的构建,以避免让其他开发人员对您的构建拥有相同的控制权。

第二,Go模块中库构建的可重复性意味着依赖项的新的不兼容版本的发布正如我们在上一节中看到的那样,对构建没有直接影响。只有当有人采取一些步骤添加时,才会出现破损该版本转换为他们自己的版本,此时他们可以再次后退。

第三,如果版本选择是SAT求解器的问题,通常有许多可能的令人满意的选择:SAT求解器必须在两者之间进行选择,而且这样做没有明确的标准。正如我们之前看到的,基于SAT的软件包管理器在多个有效的可能选择中进行选择通过选择更新的版本。如果使用最新版本的一切满足约束条件,这是明确的“最佳”答案。但如果这两个可能的选择是“最新的B,较旧的C”和“较旧的B,最新的C”?应该优先选择哪一个?开发者如何预测结果?由此产生的系统很难理解。

第四,SAT解算器的输出与输入一样好:如果省略了任何不兼容性,则SAT解算器很可能会得到一个仍然不稳定的组合,只是没有这样宣布。不兼容信息可能特别涉及依赖项的组合不完整年龄差异很大之前已经组装好了。事实上,2018年Rust’s Cargo生态系统分析发现Cargo对最新版本的偏好是掩盖许多缺少的约束货物清单中。如果最新版本不起作用,探索旧版本似乎会产生“尚不知道会被打破”的组合因为它是为了生产一个工作的。

总的来说,一旦你走上了选择每个依赖项的最新版本,基于SAT解决方案的包管理器可能选择工作配置Go模块是。如果说有什么区别的话,SAT解题者可能不太可能以找到工作配置。

示例:围棋模块与SAT解决方案

上一节中给出的反驳有点抽象。让我们继续我们的例子,使其具体化使用SAT解算器并查看其结果,就像在Dep。我用Dep来表示具体性,因为它是Go模块的直接前身,但这里的行为并不是Dep特有的我不想把它单挑出来。在本例中,戴普的工作方式与其他许多人一样包管理器,他们都分享这里详述的问题。

要设置舞台,请记住C 1.8运行良好D 1.4和D 1.5,但C 1.8和D 1.6的组合被破坏。

这可能会通过持续集成测试引起注意,问题是接下来会发生什么。

当C的作者发现C 1.8不适用于D 1.6,Dep允许并鼓励发布新版本C1.9。C 1.9记录了在1.4之后1.6之前需要D。其想法是记录不兼容性帮助Dep在将来的构建中避免这种情况。

在Dep中,避免不兼容非常重要,甚至是紧迫的-因为库构建中缺乏可重复性意味着D 1.6一发布,未来所有新版本的C都将使用D 1.6和break。这是一个构建紧急情况:所有C的新用户都被破坏了。如果D的作者不在,或者C的作者没有时间修复实际的错误,理由是C的作者必须能够采取一些措施保护用户免受损坏。这一步是释放C 1.9,记录与D1.6的不兼容性。这通过阻止使用D1.6修复了新的C版本。

使用Go模块时不会发生这种紧急情况,因为最小的版本选择和可重复的构建。使用Go模块,D 1.6的发布不会影响C的用户,因为还没有明确请求D1.6。用户继续使用他们已经使用的旧版本的D。无需记录不兼容性,因为什么都没有破。有时间合作解决实际问题。

再看看Dep记录不兼容性的方法,发布C 1.9并不是一个很好的解决方案。首先,前提是D的作者通过发布D1.6创建了一个构建紧急事件然后无法发布修复程序,所以给C的作者一个解决问题的方法是很重要的,通过释放C 1.9。但如果D的作者可能不在,如果C的作者也不在,会发生什么?然后是自动升级引起的紧急情况C的所有新用户都处于崩溃状态。Go模块中的可重复构建完全避免了紧急情况。

此外,假设错误在D中,D的作者发布了一个固定的D 1.7。变通方法C 1.9需要在1.6之前使用D,所以它不会使用固定的D 1.7。C的作者必须发布C 1.10允许使用D 1.7。

相反,如果我们使用Go模块,C的作者不必发布C 1.9然后也不必通过发出C1.10来撤销它。

在这个简单的例子中,Go模块最终对用户来说比Dep更顺畅。它们会自动避免构建中断,为真正解决问题的合作创造时间。理想情况下,C或D在任何C用户注意到之前就被修复了。

但更复杂的例子呢?也许是Dep记录不兼容的方法在更复杂的情况下更好,或者它可以让事情继续下去即使真正的修复需要很长时间才能实现。

让我们看看。要做到这一点,让我们把时钟倒回去一点,到发布buggy D 1.6之前,并比较Dep和Go模块所做的决策。此图显示了所有相关软件包版本,以及Dep和Go模块的方式将构建最新的C和最新的A。

Dep正在使用D 1.5当Go模块系统使用D1.4时,但这两种工具都找到了有效的构建。每个人都很高兴。

但是现在假设发布了buggy D1.6。

Dep builds自动拾取D1.6并中断。Go模块构建继续使用D1.4并继续工作。这是我们刚才看到的简单情况。

不过,在继续之前,让我们先修复Dep构建。我们发布了C 1.9,其中记录了与D 1.6的不兼容性:

现在Dep builds自动拾取C1.9,构建再次开始工作。Go模块不能以这种方式记录不兼容性,但Go模块构建也没有中断,所以不需要修复。

现在,让我们创建一个足以分解Go模块的构建复杂度。我们可以分两步完成这项工作。首先,我们将发布一个需要D1.6的新B。其次,我们将发布一个需要新B的新a,在这一点上,A的构建将使用具有D 1.6和break的C。

我们首先发布需要D1.6的新B1.4。

由于可重复性,Go模块构建迄今未受影响。但是看!现在自动创建A拾取B 1.4它们又坏了。怎么搞的?

Dep倾向于使用最新的B和最新的C构建A,但这是不可能的:最新的B想要D 1.6,最新的C想要D 1.6之前。但戴放弃了吗?不。它寻找B和C的替代版本同意接受D。

在这种情况下,戴决定保留最新的B,这意味着使用D 1.6,这意味着使用C 1.9。由于Dep不能使用最新的C语言,它尝试使用旧版本的C语言。C 1.8看起来不错:它说它需要D 1.4或更高版本,并且这允许D 1.6。所以Dep使用了C1.8,它就坏了。

我们知道C 1.8和D 1.6不兼容,但Dep没有。Dep不知道,因为C 1.8是在D 1.6之前发布的:C的作者不可能预测到D1.6会是一个问题。所有包管理系统都同意包内容必须一次性不可变它们已发布,这意味着C的作者没有出路追溯性地证明C 1.8不适用于D 1.6。(如果有办法改变C 1.8的要求追溯到过去,这将违反可重复性。)发布带有更新需求的C1.9是一个修复。

因为Dep更喜欢使用最新的C,大多数情况下,它将使用C 1.9,并知道要避免D 1.6。但如果戴不能使用最新的东西,它将开始尝试某些东西的早期版本,可能包括C 1.8。使用C1.8使其看起来像是D1.6,尽管我们知道更好,而且构建中断。

或者它可能不会断裂。严格地说,德普不必做出那个决定。当Dep意识到它不能同时使用最新的B和最新的C时,对于如何进行,它有很多选择。我们认为戴普保留了最新的B。但如果戴保留了最新的C,则需要使用旧的D,然后使用旧的B,生成一个工作构建,如图的第三列所示。

所以,也许戴的构建被破坏了,或者没有,取决于它在其基于SAT解决方案的版本选择.(我上次检查时,在一个包的较新版本之间进行了选择与之相比,Dep优先选择按字母顺序排列的较早导入路径,至少在小型测试用例中。)

这个示例演示了Dep和类似系统的另一种方式(除Go模块外,几乎所有包管理器)可以产生令人惊讶的结果:最喜欢的答案(使用最新的答案)不适用,通常有很多选择没有明确他们之间的偏好。确切答案取决于SAT求解算法的细节,启发式算法,以及软件包的输入顺序通常会呈现给求解器。他们解决方案中的这种不明确和不确定性这是这些系统需要锁文件的另一个原因。

无论如何,为了Dep用户的利益,让我们假设Dep幸运成为保持构建工作的选择。毕竟,我们仍在尝试打破Go模块用户的构建。

为了打破Go模块构建,让我们发布一个新版本的a,版本1.21,需要最新的B,这反过来需要最新的D。现在,当命令生成最新的A,它被迫使用最新的B和最新的D。在Go模块中,没有C 1.9,所以命令使用C 1.8,C 1.8和D 1.6的组合不起作用。最后,我们打破了Go模块构建!

但是看!Dep构建也使用了C 1.8和D 1.6,所以它们也坏了。之前,戴必须在最新的B和最新的C之间做出选择。如果它选择了最新的B,那么构建就会失败。如果它选择了最新的C语言,那么构建成功了。A中的新要求迫使Dep选择最新的B和最新的D,取代了Dep选择的最新C。所以Dep使用了旧的C 1.8,和以前一样,构建也会中断。

我们应该从这一切中得出什么结论?首先,记录Dep的不兼容性不能保证避免这种不兼容性。第二,类似Go模块中的可重复构建也不能保证避免不兼容。这两种工具最终都会构建不兼容的包对。但正如我们所见,它需要多个有意的步骤将Go模块引入一个损坏的构建,导致德普陷入同样的破碎构建的步骤。在这个过程中,基于Dep的构建又破了两次而Go模块构建没有。

我在这些例子中使用了Dep,因为它是Go模块的直接前身,但我并不是要单挑德普。在这方面,它的工作方式与几乎所有其他语言的其他包管理器。他们都有这个问题。它们甚至没有像不幸的设计那样真的坏掉或行为不端。他们的目的是试图解决缺乏合作的问题在各种软件包维护人员中,工具无法解决缺乏合作的问题.

C与D不兼容的唯一真正解决方案是发布一个新的C或D的固定版本。尝试避免不兼容性是有用的只是因为它为C的作者和D的作者合作修复。与首选最新版本的Dep方法相比并记录不兼容性,可重复构建的Go模块方法具有最小版本选择没有记录的不兼容性自动创造合作时间,没有建造紧急情况,没有宣布的不兼容,用户没有明确的工作。

然后我们可以依靠合作来解决实际问题。

结论

以下是Go中版本控制的三个原则,Go模块设计的原因背离了Dep、Cargo、Bundler等的设计。

这些原理的动机是对软件工程的关注,编程会发生什么当你添加时间和其他程序员时。兼容性消除了时间的影响关于程序的意义。重复性消除了时间的影响构建的结果。合作是一种明确的认识,无论我们的工具多么先进,我们必须与其他程序员合作。我们无法绕过他们。

这三个原则也在良性循环中相互加强。

兼容性支持新的版本选择算法,这提供了可重复性。可重复性确保有缺陷的新版本在明确请求之前被忽略,这就创造了更多的时间来进行修复合作。这种合作反过来又重建了兼容性。循环不断。

自Go 1.13起,Go模块已准备好投入生产使用,包括谷歌在内的许多公司都采用了它们。Go 1.14和Go 1.15版本将带来更多人体工程学的改进,最终走向弃用并删除对GOPATH的支持。有关采用模块的更多信息,请参阅博客文章Go博客上的系列,从使用Go模块.”