在软件管理的领域里存在着被称作“依赖地狱”的死亡之谷,系统规模越大,加入的套件越多,你就越有可能在未来的某一天发现自己已深陷绝望之中。 —— GitHub 共同创办者 Tom Preston-Werner

依赖地狱

在现代软件开发过程中,相较于“重复造轮子”,开发者往往会利用一些已有的组件(如库、程序、多媒体文件)进行开发。程序开发者根据特定版本的组件来设计自己的软件。这种方式使得代码重复利用,减少了开发的工作量,降低了开发门槛。但是该软件要正确运行,必须安装了指定版本的某些组件。

打个比方:你组装一台游戏电脑,想要配置高配的显卡。由于主板和 cpu 都需要和显卡适配,因此,你不得不先定好显卡型号,然后以该显卡作为标准,来选择主板和 cpu。你组装的电脑,必须依赖于显卡的型号

training

这便是”相依性“的产生过程。

而随着产品的迭代,软件的集成度越来越高,随之依赖组件也越来越多。若只有简单的相依性,则比较容易解决。如 A 软件依赖 B、C 软件包,而 B、C 软件包没有依赖,只需要安装 B、C 软件包,再安装 A 软件即可。而当依赖性过多,且具有多级结构,形成错综复杂的网络,依赖性的解析就会变得异常困难,甚至出现无法解析的致命错误。

我们将在操作系统中由于软件之间的依赖性不能被满足而引发的问题称为依赖地狱

training

依赖地狱主要有以下表现:

依赖过多

一个软件包可能依赖于众多的库,因此安装一个软件包的同时要安装几个甚至几十个库包。

多重依赖

指从所需软件包到最底层软件包之间的层级数过多。这会导致依赖性解析过于复杂,并且容易产生依赖冲突和环形依赖。

依赖冲突

即两个软件包无法共存的情况。除两个软件包包含内容直接冲突外,也可能因为其依赖的低层软件包互相冲突。因此,两个看似毫无关联的软件包也可能因为依赖性冲突而无法安装。

依赖循环

即依赖性关系形成一个闭合环路,最终导致:在安装 A 软件包之前,必须要安装 A、B、C、D 软件包,然而这是不可能的。

语义化版本

为了解决依赖地狱,Tom Preston-Werner 提议用一组简单的规则及条件来约束版本号的配置和增长。这些规则是根据(但不局限于)已经被各种封闭、开放源码软件所广泛使用的惯例所设计。为了让这套理论运作,你必须先有定义好的公共 API 。这可以透过文件定义或代码强制要求来实现。无论如何,这套 API 的清楚明了是十分重要的。一旦你定义了公共 API,你就可以透过修改相应的版本号来向大家说明你的修改。考虑使用这样的版本号格式:X.Y.Z (主版本号.次版本号.修订号)修复问题但不影响 API 时,递增修订号;API 保持向下兼容的新增及修改时,递增次版本号;进行不向下兼容的修改时,递增主版本号。Tom Preston-Werner 称这套系统为语义化版本控制

版本格式

主版本号.次版本号.修订号,版本号递增规则如下:

  • 主版本号:当你做了不兼容的 API 修改,
  • 次版本号:当你做了向下兼容的功能性新增,
  • 修订号:当你做了向下兼容的问题修正。

先行版本号及版本编译元数据可以加到“主版本号.次版本号.修订号”的后面,作为延伸。

training

规范

以下关键词 MUST、MUST NOT、REQUIRED、SHALL、SHALL NOT、SHOULD、SHOULD NOT、 RECOMMENDED、MAY、OPTIONAL 依照 RFC 2119 的叙述解读。

  • 使用语义化版本控制的软件必须(MUST)定义公共 API。该 API 可以在代码中被定义或出现于严谨的文件内。无论何种形式都应该力求精确且完整

  • 标准的版本号必须(MUST)采用 X.Y.Z 的格式,其中 X、Y 和 Z 为非负的整数,且禁止(MUST NOT)在数字前方补零。X 是主版本号、Y 是次版本号、而 Z 为修订号。每个元素必须(MUST)以数值来递增。例如:1.9.1 -> 1.10.0 -> 1.11.0。

  • 标记版本号的软件发行后,禁止(MUST NOT)改变该版本软件的内容。任何修改都必须(MUST)以新版本发行

  • 主版本号为零(0.y.z)的软件处于开发初始阶段,一切都可能随时被改变。这样的公共 API 不应该被视为稳定版。

  • 1.0.0 的版本号用于界定公共 API 的形成。这一版本之后所有的版本号更新都基于公共 API 及其修改内容。

  • 修订号 Z 必须(MUST)在只做了向下兼容的修正时才递增。这里的修正指的是针对不正确结果而进行的内部修改。

  • 次版本号 Y 必须(MUST)在有向下兼容的新功能出现时递增。在任何公共 API 的功能被标记为弃用时也必须(MUST)递增。也可以(MAY)在内部程序有大量新功能或改进被加入时递增,其中可以(MAY)包括修订级别的改变。每当次版本号递增时,修订号必须(MUST)归零

  • 主版本号 X 必须(MUST)在有任何不兼容的修改被加入公共 API 时递增。其中可以(MAY)包括次版本号及修订级别的改变。每当主版本号递增时,次版本号和修订号必须(MUST)归零

  • 先行版本号可以(MAY)被标注在修订版之后,先加上一个连接号再加上一连串以句点分隔的标识符来修饰。标识符必须(MUST)由 ASCII 字母数字和连接号 [0-9A-Za-z-] 组成,且禁止(MUST NOT)留白。数字型的标识符禁止(MUST NOT)在前方补零。先行版的优先级低于相关联的标准版本。被标上先行版本号则表示这个版本并非稳定而且可能无法满足预期的兼容性需求。范例:1.0.0-alpha、1.0.0-alpha.1、1.0.0-0.3.7、1.0.0-x.7.z.92。

  • 版本编译元数据可以(MAY)被标注在修订版或先行版本号之后,先加上一个加号再加上一连串以句点分隔的标识符来修饰。标识符必须(MUST)由 ASCII 字母数字和连接号 [0-9A-Za-z-] 组成,且禁止(MUST NOT)留白。当判断版本的优先层级时,版本编译元数据可(SHOULD)被忽略。因此当两个版本只有在版本编译元数据有差别时,属于相同的优先层级。范例:1.0.0-alpha+001、1.0.0+20130313144700、1.0.0-beta+exp.sha.5114f85。

  • 版本的优先层级指的是不同版本在排序时如何比较。判断优先层级时,必须(MUST)把版本依序拆分为主版本号、次版本号、修订号及先行版本号后进行比较(版本编译元数据不在这份比较的列表中)。由左到右依序比较每个标识符,第一个差异值用来决定优先层级:主版本号、次版本号及修订号以数值比较,例如:1.0.0 < 2.0.0 < 2.1.0 < 2.1.1。当主版本号、次版本号及修订号都相同时,改以优先层级比较低的先行版本号决定。例如:1.0.0-alpha < 1.0.0。有相同主版本号、次版本号及修订号的两个先行版本号,其优先层级必须(MUST)透过由左到右的每个被句点分隔的标识符来比较,直到找到一个差异值后决定:只有数字的标识符以数值高低比较,有字母或连接号时则逐字以 ASCII 的排序来比较。数字的标识符比非数字的标识符优先层级低。若开头的标识符都相同时,栏位比较多的先行版本号优先层级比较高。范例:1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0。

FAQ

  • 在 0.y.z 初始开发阶段,我该如何进行版本控制?

    最简单的做法是以 0.1.0 作为你的初始化开发版本,并在后续的每次发行时递增次版本号。

  • 如何判断发布 1.0.0 版本的时机?

    当你的软件被用于正式环境,它应该已经达到了 1.0.0 版。如果你已经有个稳定的 API 被使用者依赖,也会是 1.0.0 版。如果你很担心向下兼容的问题,也应该算是 1.0.0 版了。

  • 这不会阻碍快速开发和迭代吗?

    主版本号为零的时候就是为了做快速开发。如果你每天都在改变 API,那么你应该仍在主版本号为零的阶段(0.y.z),或是正在下个主版本的独立开发分支中。

  • 对于公共 API,若即使是最小但不向下兼容的改变都需要产生新的主版本号,岂不是很快就达到 42.0.0 版?

    这是开发的责任感和前瞻性的问题。不兼容的改变不应该轻易被加入到有许多依赖代码的软件中。升级所付出的代价可能是巨大的。要递增主版本号来发行不兼容的改版,意味着你必须为这些改变所带来的影响深思熟虑,并且评估所涉及的成本及效益比。

  • 为整个公共 API 写文件太费事了!

    为供他人使用的软件编写适当的文件,是你作为一名专业开发者应尽的职责。保持专案高效一个非常重要的部份是掌控软件的复杂度,如果没有人知道如何使用你的软件或不知道哪些函数的调用是可靠的,要掌控复杂度会是困难的。长远来看,使用语义化版本控制以及对于公共 API 有良好规范的坚持,可以让每个人及每件事都运行顺畅。

  • 万一不小心把一个不兼容的改版当成了次版本号发行了该怎么办?

    一旦发现自己破坏了语义化版本控制的规范,就要修正这个问题,并发行一个新的次版本号来更正这个问题并且恢复向下兼容。即使是这种情况,也不能去修改已发行的版本。可以的话,将有问题的版本号记录到文件中,告诉使用者问题所在,让他们能够意识到这是有问题的版本。

  • 如果我更新了自己的依赖但没有改变公共 API 该怎么办?

    由于没有影响到公共 API,这可以被认定是兼容的。若某个软件和你的包有共同依赖,则它会有自己的依赖规范,作者也会告知可能的冲突。要判断改版是属于修订等级或是次版等级,是依据你更新的依赖关系是为了修复问题或是加入新功能。对于后者,我经常会预期伴随着更多的代码,这显然会是一个次版本号级别的递增。

  • 如果我变更了公共 API 但无意中未遵循版本号的改动怎么办呢?(意即在修订等级的发布中,误将重大且不兼容的改变加到代码之中)

    自行做最佳的判断。如果你有庞大的使用者群在依照公共 API 的意图而变更行为后会大受影响,那么最好做一次主版本的发布,即使严格来说这个修复仅是修订等级的发布。记住, 语义化的版本控制就是透过版本号的改变来传达意义。若这些改变对你的使用者是重要的,那就透过版本号来向他们说明。

  • 我该如何处理即将弃用的功能?

    弃用现存的功能是软件开发中的家常便饭,也通常是向前发展所必须的。当你弃用部份公共 API 时,你应该做两件事:(1)更新你的文件让使用者知道这个改变,(2)在适当的时机将弃用的功能透过新的次版本号发布。在新的主版本完全移除弃用功能前,至少要有一个次版本包含这个弃用信息,这样使用者才能平顺地转移到新版 API。

  • 语义化版本对于版本的字串长度是否有限制呢?

    没有,请自行做适当的判断。举例来说,长到 255 个字元的版本已过度夸张。再者,特定的系统对于字串长度可能会有他们自己的限制。

实践

任何理论不付诸实践都是没有实际价值的。

软件开发流程

软件开发流程与版本相关的包括开发、测试和发布等

  • 开发:本次迭代是增加新功能、修改问题还是其他,这都需要“反应”到版本上去。
  • 测试:开发完成后会进行提测,通常地提测不会只有一次,因为提测后可能还有问题,需要修复完再次提测。
  • 发布:测试完成后会进行发布,包括:内测发布外测发布正式发布等。

版本的认知

如果你分别问开发、测试、产品人员什么是版本的话,你可能会得到不同的答案

假设有一个产品 A,已正式发布的版本是 1.0.0,现在进入下一次迭代开发

  • 产品:这次就是要发布 1.1.0 版本(产品其实关心的是正式版本
  • 开发:严格遵循语义化版本,只要是改动并发布了(不管是内测、外测、正式发布),就改变版本号,因此这次版本可能是 1.1.2
  • 测试:只要是提测了,就需要改下版本号,因为可能会发内测、外测,不然会出现同一个版本号不同源码的问题,对定位问题带来不便。

团队的人员都会以自身的角度去理解版本,所以要做好版本管理,需要让团队对版本有一个一致的认知,类似于领域驱动开发(DDD)的共同语言

建议

笔者经过多次的实践,建议如下:

  • 产品可以先定义一个大致的版本,但是正式发布的时候不一定需要严格遵循。
    • 软件版本对于用户没有什么价值,除了上报问题的时候,需要告知你版本。
    • 1.1.01.1.2 只是数字,没有实际区别,没必要一定要 1.1.0
  • 开发只要提测(不管是内测、外测试、正式发布)就遵循语义化版本修改版本。
  • 测试检查每次收到的测试包版本都应该是是不一样的,并检查是否符合语义化版本规范
  • 团队淡化内测、外测、正式发布的区别,都视为是“正式”发布(区别只是面向的对象)
  • 团队把只要用软件的人都视为用户(包括内测、外测试人员)。

小结

在软件管理中,一定要重视版本控制。否则,一不留神依赖地狱就向你招手。 语义化版本提供了一套简单有效的方案帮助我们尽量避免依赖地狱的出现。但是,语义化版本只是一个规范,规范都是需要人去执行才能有效。作为一位负责任的开发者,你理当确保每次包升级的运作与版本号的表述一致。现实世界是复杂的,我们除了提高警觉外能做的不多。你所能做的就是让语义化的版本控制为你提供一个健全的方式来发行以及升级包,而无需推出新的依赖包,节省你的时间及烦恼。

在实践中,大家可以参考 react 项目。下面是遵从了语义化版本规范的 react 依赖图:

training

注:npm 的依赖关系图,可以通过http://npm.broofa.com/?q=react@16.3.1 查看

另外,推荐大家使用 git 的 tag&release,将版本“持久化”,这有助于避免一些规范的问题,如下图所示:

training

training

参考


CatchZeng
Written by CatchZeng Follow
AI (Machine Learning) and DevOps enthusiast.