版本的故事(四)版本号有多重要

上一篇谈了怎样给版本取个好名字,也就是版本号。说明了“语义化版本”的命名规范,也说明了这一种命名规范在依赖管理中发挥的重要作用。今天继续谈语义化版本号,说明一下这种命名方式的重要性,以及对研发和运维过程的影响。

开发人员每一次向下游工序交付一个版本,都必须为这个版本编一个号码,这就是版本号(也称作版本 ID)。如果每次交付都是同样的版本号,随着时间的推进就会产生很多版本号相同、但是功能不一样的二进制包,在这种情况下部署,你可以想象会遇到多少惊奇。对于每一次生成二进制包,都应该分配一个唯一标识,对于审计来说,这是非常重要的。最显而易见的方法,是使用二进制包的散列值作为唯一标识,以便可以验证生成二进制包的源代码是否正确。当你不确定某个环境到底部署了哪个版本的时候,可以使用文件的 MD5 码找出版本库中对应的版本。一些二进制产物管理平台可以自动提取散列码。但是以散列码作为标识有一些缺点,很明显的一个缺点是散列码太长,与他人沟通的时候很难记得住(你很难告诉同事“把 customer 组件从 ea3304ea2fff21dd1e501795c43c48ff 版本替换成 81b134b598b16d1605e6f76189f1c018 版本就可以解决你遇到的问题”,他也很难记住)。更重要的是散列码只能标识两个版本是否相同,却无法体现版本之间的时间关系(哪一个版本是新的)和兼容性(新版本是否完全包含老版本的功能)。所以我们应该使用规范化的版本号作为二进制包的标识符。

所以我们必须采用语义化版本规范,使用三段式版本号,如下:

主版本号.次版本号.修订号

版本号递增规则如下:

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

使用这种方式,版本号就不再是一个随意的命名。任何一个以 API 方式对外提供服务的程序(无论是 Web API、消息处理、函数 API),都应该遵循语义化版本规范。从 API 规范的意义上说,语义化版本实际上是对软件接口规格的描述。例如一个软件模块对外提供 Web API 服务,模块版本是 2.3.1,可以理解成这样的规格描述:

为了准确描述程序的接口规范,我们在设计和开发的时候要尽量把接口规范和实现代码分离,这样就可以更准确的控制 API 规格。以 Web API 为例,我们可以把与服务接口相关的代码放在单独的目录里,比如把控制器代码全部放在 controller 目录,输入输出数据结构全部在 vo 目录。这样就可以在发布版本的时候根据变更的范围准确确定版本号。

当以下变更发生时,接口的调用方法发生了变化,需要升级主版本号:

  • controller 删除了原有接口;
  • controller 在原有接口上添加了参数,并且参数是必须的;
  • vo 删除了输出数据的属性;
  • vo 添加了输出数据的属性,并且属性是必须的。

当以下变更发生时,原有的接口仍然可以工作,需要升级次版本号:

  • controller 添加了新接口;
  • controller 在原有加快上添加了参数,但是参数可以不输入;
  • vo 添加了输出数据的属性;
  • vo 添加了输入数据的属性,但是属性可以不输入。

当以下变更发生时,只需要升级修订号:

  • controller 和 vo 的代码都没有改动,只改动了程序其他的部分。

用这样的方法,版本号就可以描述程序内部的变更范围。 

下面说一下版本号对依赖管理的作用。在构建和运行软件时,软件的一部分要依赖于另一部分,就产生了依赖关系。在任何应用程序(甚至是最小的应用程序)中也会有一些依赖关系。至少,大多数软件应用都对其运行的操作系统环境有依赖,Java 应用程序依赖于 JVM,它提供了 JavaSE API 的一个实现。网络服务之间也存在依赖关系。在大型软件中,从组件中选择好用的版本,组成一个完整的系统是一个极具难度的事。为了做好依赖管理,我们必须做下面几件事:

  1. 为每一个二进制包制定唯一的版本号,禁止一物多码,更要禁止一码多物。必须标识版本,才能管理依赖;
  2. 发布版本时描述依赖关系;
  3. 使用语义化版本号,只要确定了一个版本是可用的,就可以确定一个区间的版本都可用。

我们以 Linux 操作系统为例看一下依赖管理的过程。Linux 是一个非常复杂的体系,它本身由很多二进制包组成,使用者也需要在操作系统上安装自己需要的程序。如果没有一个依赖管理机制,要在 Linux 上安装一个软件,将会是一件困难的任务。幸运的是各种 Linux 发行版都提供了完善的包管理机制,还附带了包管理工具。比如 Debian 操作系统,提供了 dpkg 工具,以下是使用 dpkg 查看 wget 信息:

$ dpkg -s wget
Package: wget
Section: web
Maintainer: Noël Köthe <noel@debian.org>
Architecture: amd64
Version: 1.18-5+deb9u3
Depends: libc6 (>= 2.17), libgnutls30 (>= 3.5.6), libidn11 (>= 1.13), libnettle6, libpcre3, libpsl5 (>= 0.13.0), libuuid1 (>= 2.16), zlib1g (>= 1:1.1.4)

这里列出了主要信息,有两个信息非常重要:

  • wget 本身的版本号:1.18-5+deb9u3
  • wget 依赖的其他组件版本(Depends 行)

有了这些信息,就可以在安装 wget 的时候检查 Debian 上已经安装的库,判断是否满足依赖条件,包管理工具可以级联安装所有的依赖项。也可以检查 wget 与已经安装的程序是否存在依赖冲突,提示用户进行处理。如果没有这一套包管理机制,在 Linux 上安装一个包是非常冒险的事情。

最后我们再来看看语义化版本是怎样帮助我们做好老版本维护的。有时候正在生产环境运行的老版本忽然发现一个缺陷,或者需要添加一个功能,都需要对老版本进行维护。这种事情在 To B 业务非常多见,To B 业务部署在很多现场,每个现场项目实施的时期不一样,所以版本都有一些差异,对老版本进行维护是一件不可避免的事情。

如果不使用语义化版本号,比如用一个不断增长的序号来标识版本号,连续发布多个版本就会形成这样的版本路径:

 

 

随着时间的发展,有一些老版本会在部署在不同的现场。现在 1002 版本上发现一个缺陷,需要紧急修复。这时候该怎么办呢?1002 版本已经经过了 2 次升级,直接替换成 1004 版本行不行,很难判断,所以只能基于 1002 版本升级替换。这个好办,使用 Git 做一个分支,修改后重新发布一个版本就可以了。缺陷修改后,形成下面这样的版本路径:

 

 

 

如果以后需要维护 1001、1003 版本,继续发展下去,版本路径就会越来越复杂:

 

 

维护版本分支越来越多,基本上要为每一个老版本创建一个维护分支,工作量随着项目发展越来越大。开发团队要把大量的精力放在老项目维护上,产品开发的工作受到越来越多的牵制。语义化版本号能怎样改变这种局面呢?如果每一次发布都按照语义化版本编号,那么最初的版本路径就是下面这样:

 

 

用这种方式,我们就能准确判断版本之间的兼容关系,根据版本之间的替换关系确定最佳维护位置。当我们在 1.0.6 版本上发现一个缺陷,需要紧急修复,1.2.1 版本可以完全兼容 1.0.6 版本的功能。如果这个缺陷已经在 1.2.1 版本得到修复,那么升级现场的版本即可。如果必须修改代码,也只需要在 1.2.1 的基础上修改,再发布一个版本即可,不增加维护分支:

 

 

 

如果已经发生了主版本升级的情况,我们也只需要为每一个主版本创建一个维护分支,就能同时满足多个项目的维护工作,降低维护工作量。

 

 

如上图,对于所有 1.x 主版本,只需要基于 1.2.2 版本建立一个维护分支即可。

posted on 2020-09-13 18:45  小陆  阅读(1317)  评论(0编辑  收藏  举报