工作了5年你居然不知道版本号有这些规范?

3,772 阅读14分钟

文章顶部.png

zhaoyan.png

前言

所谓语义化版本控制,就是要求你的版本号能按照特定规则及条件来进行约束,以期达到见到版本号即能了解其修改内容的信息或相邻版本间的更新迭代关系。通过阅读本文,你将能够对语义化版本控制规范能够有一个全面的了解,同时也对各平台上依赖版本时的语法有个大体的了解。

背景

在正式开始之前,先问大家几个问题:

我们经常在类似 Github、npm、或者 pub.dev 上看到一些软件或者库的版本号包含如下信息,你是否会疑惑他们之间的区别是什么?分别适用什么场景?

  • alpha
  • beta
  • rc
  • release

再看看下面几组版本号,你是否能弄清楚各个版本号之间谁更新更大?

  • 1.0.0 1.0.1 1.1.0
  • 1.0.0-beta 1.0.0
  • 1.0.0-release 1.0.0
  • 1.0.0-alpha 1.0.0-alpha.1
  • 1.0.0-alpha 1.0.0-rc.1

这次将借着我们在做组件管理平台的机会,像大家介绍一下日常软件开发中的语义化版本控制规范。相信通过下面的学习,上述的问题也能够迎刃而解。

常见先行版本号标识

上面说到 alpha、beta、rc、release 等版本号中常见的一些标识,有一个正式的名称叫做:先行版本号标识。我们可以通过一个生活中的例子来通俗易懂的说明它们之间的差异和联系。

现在假设你是一个蛋糕店的老板,你打算给你的蛋糕店推出一个新品,那么上述所谓的先行版本号就是如下几个阶段的蛋糕:

Alpha 版就是你对于你蛋糕的最终形式还在脑海当中,只有一个蛋糕的基本样子,口味应该是什么味道你心里还没谱,对于装饰如奶油、水果还有蜡烛这些甚至都还没有放在一起(你的软件各功能甚至都没有打通)。由于过于简陋,并且口味还没固定,你还不能将其给你的顾客品尝。你只能自己反复摸索尝试,或者让自己店里的员工对口味、外观以及一些缺陷进行点评。

Alpha版蛋糕

Beta 版就是你的蛋糕已经开始尝试将部分奶油涂抹在蛋糕上,你已经尝试将所有的元素组装起来,这时候的蛋糕还处于不能拿出去卖的阶段,但口味和后续方向已经基本固定。你甚至可以邀请你店里的熟客来参加小规模的试吃活动,并让他们针对你的这款蛋糕进行全方面的点评。

Beta版蛋糕

RC 版就是你的蛋糕已经基本做完了,其最核心的口味和外观已经确定下来,你可以再检查一下蛋糕是否有裂缝、哪些地方需要针对性的进行一些美化或修补。

RC版蛋糕

release 版就是你已经把蛋糕装饰好了,插上蜡烛,撒上曲奇,进行裱花。这时候蛋糕已经完成了,你可以正式的将这块蛋糕摆上橱窗,向大家兜售你的艺术品了。

release版蛋糕

通过上述的蛋糕制作过程,你应该对这些先行版本号标识有了自己的认知。接下来我们再总结下这些先行版本号标识的常见含义:

标识常见含义
alpha(α)内部测试版(有些也叫 A 测版)。α 是希腊字母的第一个,表示最早的版本,一般此类版本包含很多 BUG ,功能也不全,主要面向的是开发人员和测试人员。
beta(β)公开测试版(有些也叫 B 测版)。 β 是希腊字母的第二个,因此这个版本比alpha版发布较晚一些,主要是给参与内部体验的用户测试用,该版本仍然存在很多 BUG ,但是相对 alpha 版要稳定一些。此时,基本功能已经固定,但仍然可能增加新功能。
rc(Release Candidate)rc (候选版本),该版本又较 beta 版更进一步了,该版本功能不再增加,和最终发布版功能一样。
这个版本的作用是提前预览即将发行版本的内容,并且在该版本后,最终发行版的发布也迫在眉睫了。
release稳定版(有些也叫做 stable、GA 版)。在开源软件中,都有正式版,这个就是开源软件的最终发行版,用户可以放心大胆的用了。

相信阅读到这里,上面的第一个问题你已经有了答案。那么明白这些标识的具体含义之后,它到底应该怎么用呢?具体要放在版本号里的哪个位置上呢?接下来我们将通过对语义化版本控制规范的详细介绍,来帮助你解答这些疑惑。

何为语义化版本控制规范

在介绍什么是语义化版本控制规范之前,我们先需要了解为什么需要语义化版本控制规范。

大家先设身处地的设想这样一个开发场景:

你现在的项目现在分别依赖了 foo : 1.0.0bar : 2.0.0baz : 3.0.0

项目example_app的依赖项

同时 foo 组件也依赖了 bar : 2.0.0baz : 3.0.0

组件foo的依赖项

同时 bar 组件也依赖了 baz : 3.0.0

组件bar的依赖项

现在你很幸运,项目可以跑起来。

突然有一天因为要修改一个问题,需要升级你项目中 baz 组件的版本号,需要将它从 3.0.0 升级到 3.0.1。但很不幸的是,baz 组件这个小小的版本升级却发生了破坏性的 API 改动。然后你发现你不仅需要修改主工程 example_app 的版本号,还需要升级 foo 组件的版本号以及 bar 组件的版本号。而在你做完这些之后,发现 foo 依赖的其他组件的版本又和你主工程 example_app 项目中依赖的组件的版本冲突了,于是你崩溃了。

这就是软件管理领域中被称作“依赖地狱”的死亡之谷。即当你的系统规模越大,引入的包越多,你就越可能遇到由于依赖导致的问题:

  • 如果依赖关系过高,可能面临版本控制被锁死的风险(必须对每一个依赖包改版才能完成某次升级)
  • 而如果依赖关系过于松散,又将无法避免版本的混乱(假设兼容于未来的多个版本已超出了合理数量) 当你项目的进展因为版本依赖被锁死或版本混乱变得不够简便和可靠,就意味着你正处于依赖地狱之中。

通过上述的场景我们可以看到,版本号的管理(包括依赖关系的控制以及版本号的命名)并不是一个随心所欲的事情:管理好了,能给你带来极大便利,反之则会给你的开发带来很多没必要的麻烦。那么我们应该如何解决这些事情呢?

基于上述的一些问题,Gravatars 及 Github 的创始人之一的 Tom Preston-Werner 提出了一个名为语义化版本控制规范(Semantic Versioning)的解决方案,它期望用一组简单的规则及条件来约束版本号的配置和增长。这套规则是根据现在各种封闭、开源软件所广泛使用的版本号命名规则所设计。为了让这套理论运作,必须要定义好你的公共 API。一旦定义了公共 API,你就可以透过修改相应的版本号来向大家说明你的修改。考虑使用这样的版本号格式:

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

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

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

以上这套规则或者说系统,就是所谓的”语义化版本控制”,在这套规则之下,版本号以及版本号的更新都潜在包含了代码的修改信息,而这套规则被简称为 semver (Semantic Versioning 简写)。

接下来我将基于 semver 2.01 向大家介绍 这套规则的一些细则。

语义化版本控制规范细则

语义化版本控制规范的细则有很多,有兴趣的同学可以直接到semver 2.01 的官方文档 查看即可,我们这里将其主要内容总结给大家。

X.Y.Z(主版本号.次版本号.修订号)修复问题但不影响 API 时,递增修订号;API 保持向下兼容的新增及修改时,递增次版本号;进行不向下兼容的修改时,递增主版本号。

一个标准的语义化版本号各部分的含义

其实所谓语义化版本控制规范,本来也只是一种约定,它并不能完美适合每一个团队,我们也没必要完全生搬硬套,这里以 Google 官方推出的 mockito2 的版本号为例,可以看到其也没有严格按照细则进行遵守。

组件mockito的一些非正式版本号

所以如果你团队内已经统一认知,了解版本号中每个地方代表的含义,做到“见号知意”:看到 1.0.0-npeFixed.8 就知道这个组件是从 1.0.0 拉出来 为了修复 NPE 的;看到 2.3.0-addFaceIdSupport.1 就知道这个组件是基于 2.3.0 来做 FaceId 支持的;见到 5.0.0-nullSafety.6 就知道这个版本是为了空安全的。那么我们的语义化版本控制规范的目的也就达到了,不是吗?

版本语法

就像人类的烹饪方式从最开始的单纯用火烤到发明陶器之后的烹煮,再到现代社会中基于烤、煮、蒸而演化出来的各类五花八门的烹饪方式一样,语义化版本控制规范在各个平台上也衍生出不同的版本规范和版本语法(Version Syntax),但万变不离其宗。接下来我将大致介绍下常见平台版本语法的异同,期望能对你有所帮助。

由于 PyPI上的版本规范及版本说明符比较特殊且繁琐,这里就不进行比对,有兴趣的同学可以查看PEP 440 – Version Identification and Dependency Specification3 了解更多细节。

和烹饪方式的的演化过程一样, 语义化版本控制规范在不同平台、不同时期也有不同的表现

定义平台格式示例描述
完全匹配目标版本gradleversion

version!!
com.example:foo:5.0.2

com.example:foo:5.0.2!!
这里用5.0.2 和 5.0.2!! 是有区别的。
5.0.2 这种写法是对此版本最低要求,但是可以被引擎升级,这里的5.0.2是被称作必须版本(Required version4), 也就是说它最小版本是5.0.2,并且相信未来的任何升级都是可以正常工作的。

而5.0.2!! 则是严格版本(Strict version5), 即只能使用5.0.2的版本,传递依赖项过来的版本如果没有更高约束或者别的严格版本,会被覆写为此版本,否则会失败。
mavenversion

[version]
5.0.2

[5.0.2]
和gradle类似,这里用5.0.2 和 [5.0.2] 是有区别的。
5.0.2 这种写法是对此版本的软要求(Soft requirement6),如果依赖关系树中较早没有出现其他版本,则使用 5.0.2。

而 [5.0.2] 这种写法是对此版本的硬性要求(Hard requirement6)。使用 5.0.2并且仅使用 5.0.2。
pubversionfoo: 5.0.2
npmversionfoo: 5.0.2
podversion

= version
pod 'foo', '5.0.2'

pod 'foo', '=5.0.2'
兼容版本gradleversion.+com.example:foo:1.+>= 1.0.0 < 2.0.0
maven[version, version+1)[1.0.0, 2.0.0)同上
pub^version

~version
foo: ^1.0.0

foo: ~1.0.0
>= 1.0.0 < 2.0.0

>=1.0.0 < 1.1.0

^version 和 ~version 分别被称作 插入符语法(Caret Syntax7) 和 波形语法(Tilde Syntax8),他们的主要区别在于前者兼容当前版本后及后续所有的 次版本号及修订号,即 ^X.Y.Z 等价于 >=X.Y.Z
<(X+1).0.0;

而后着只兼容当前版本号及后续所有的修订号,即
~X.Y.Z 等价于 >=X.Y.Z <X.(Y+1).0
npm^versionfoo: ^1.0.0同上
pod~> versionpod 'foo', '~> 1'同上
匹配任意版本gradlecom.example:foo任意一个版本,具体约束可能由其他组件依赖了此组件并且存在具体约束,否则默认取最新的
maven[firstVersion,)[0.0.1,)>=0.0.1
pubanyfoo: any任意一个版本,具体约束可能由其他组件依赖了此组件并且存在具体约束,否则默认取最新的
npm*foo: *同上
podpod 'foo'同上
已发布的最新版本gradle+com.example:foo:+任意一个版本,具体约束可能由其他组件依赖了此组件并且存在具体约束,否则默认取最新的
mavenLATEST

LATESTLATEST 在maven 3.x版本被废弃
pubanyfoo: any任意一个版本,具体约束可能由其他组件依赖了此组件并且存在具体约束,否则默认取最新的
npm*foo: *同上
pod> 0.0.1pod 'foo', '>0.0.1'同上
大于当前版本gradle(version, )com.example:foo: (0.0.1, )
maven(version, )(0.0.1, )
pub>versionfoo: >0.0.1
npm> versionfoo: > 0.0.1
pod> versionpod 'foo', '> 0.0.1'
大于等于当前版本gradle[version, )com.example:foo: [0.0.1, )
maven[version, )[0.0.1, )
pub>=versionfoo: >=0.0.1
npm>= versionfoo: >= 0.0.1
pod>= versionpod 'foo', '>= 0.0.1'
小于当前版本gradle(, version)com.example:foo: (, 2.0.0)
maven(, version)(, 2.0.0)
pub<versionfoo: <2.0.0
npm< versionfoo: < 2.0.0
pod< versionpod 'foo', '< 2.0.0'
小于等于当前版本gradle(, version]com.example:foo: (, 2.0.0]
maven(, version](, 2.0.0]
pub<=versionfoo: <=2.0.0
npm<= versionfoo: <= 2.0.0
pod<= versionpod 'foo', '<= 2.0.0'
范围区间gradle[version1, version2]com.example:foo: [1.0.0, 2.0.0]
maven[version1, version2][1.0.0, 2.0.0]
pub'>=version1 <=version2 'foo: '>=1.0.0 <=3.0.0'当存在区间约束的时候,版本号需要通过单引号进行包裹
npmversion1-version2

>=version1 <=version2
foo: 1.0.0-3.0.0


foo: >=1.0.0 <=3.0.0
version1 到 version的任意版本号,包含自身
pod>=version1, <=version2pod 'foo', '>= 1.0.0' , '<= 3.0.0'
范围集合gradle(,version1), [version2,)com.example:foo:
(,1.0.0),[3.0.0,)
< 1.0.0 或者 >= 3.0.0
maven(,version1), [version2,)(,1.0.0),[3.0.0,)同上
pub不支持不支持不支持
npmversion1version2foo: <1.0.0>= 3.0.0< 1.0.0 或者 >= 3.0.0
pod<version1, >=version2pod 'foo', '< 1.0.0' , '>= 3.0.0'同上
排除制定版本gradle(,version), (version,)com.example:foo:
(,1.0.5),(1.0.5,)
不等于 1.0.5
maven(,version), (version,)(,1.0.5),(1.0.5,)同上
pub不支持不支持不支持
npm<version>versionfoo: <1.0.5>1.0.5不等于 1.0.5
pod!= versionpod 'foo', '!= 1.0.5'不等于 1.0.5
特有gradlemaven特殊版本标识: -SNAPSHOTcom.example:foo: 1.0.0-SNAPSHOT这个其实是maven的特殊版本标识,当你发布此带-SNAPSHOT标识版本后,maven自己会根据你的发布时间将版本展开为类似于1.0-yyyyMMdd-HHmmss-1 的格式,所以如果你带了此标识,你可以重复发布此版本,当前前提是你的maven开启了对应的配置。
其他特殊标识
dev

rc\snapshot\final\ga\release\sp
1. dev会被判定低于任何其他非数字部分,如:
1.0.0-dev < 1.0.0-ALPHA < 1.0.0-alpha < 1.0-rc
2. 字符串rc,snapshot,final,ga,release和 sp 被认为高于其他字符串部分(按此顺序排序),如:
1.0-zeta < 1.0-rc < 1.0-snapshot < 1.0-final < 1.0-ga < 1.0-release < 1.0-sp < 1.0

有些平台中还有一些特定的其他语法和规则,如果感兴趣,可以点击平台名称的超链接进入对应平台的官方文档自行查看。

相信你读到了这里,对语义化版本控制规范已经了然于胸。那么开篇的两个问题你是否也有了答案,欢迎在评论区留言。

Q&A 环节

经过上面的分享,相信大家对语义化版本已经有了一个整体的了解,那么我们来检验一下你的学习效果,请尝试回答下面几个问题:

Q:“v1.2.3” 是一个语义化版本号吗?

首先,“v1.2.3” 并不是的一个语义化的版本号。但是,在语义化版本号之前增加前缀 “v” 是用来表示版本号的常用做法。在版本控制系统中,将 “version” 缩写为 “v” 是很常见的。但是我们可以通过 npm-semver 来进行处理并转化成语义化的版本。

npm-semver可以帮你处理和转化语义化版本

Q:这么多规则及要求,我该如何验证我的语义化版本是否符合规范或者比较他们之间的大小关系呢?

这里就推荐 npm 的 github.com/npm/node-se…

node-semver 也可以帮你做到

对于脚本上对版本是否符合要求进行验证,可以使用 semver 2.0 文档中推荐的如下两个正则表达式。

第一个用于支持按组名称提取的语言,PCRE(Perl 兼容正则表达式,比如 Perl、PHP 和 R)、Python 和 Go。参见: regex101.com/r/Ly7O1x/3/

/^(?P<major>0|[1-9]\d*).(?P<minor>0|[1-9]\d*).(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:+(?P<buildmetadata>[0-9a-zA-Z-]+(?:.[0-9a-zA-Z-]+)*))?$/gm

第二个用于支持按编号提取的语言(与第一个对应的提取项按顺序分别为:major、minor、patch、prerelease、buildmetadata)。主要包括 ECMA Script(JavaScript)、PCRE(Perl 兼容正则表达式,比如 Perl、PHP 和 R)、Python 和 Go。 参见: regex101.com/r/vkijKf/1/

/^(0|[1-9]\d ).(0|[1-9]\d).(0|[1-9]\d )(?:-((?:0|[1-9]\d|\d*a-zA-Z-)(?:.(?:0|[1-9]\d |\da-zA-Z- )) ))?(?:+([0-9a-zA-Z-]+(?:.[0-9a-zA-Z-]+)))?$/gm

Q:万一不小心把一个不兼容的改版当成了次版本号发行了,或者在修订等级的发布中,误将重大且不兼容的改变加到代码之中,我能通过重复发布当前版本来解决问题吗?

首先必须强调一点,不管如何都不能去修改已发行的版本(这点在部分平台已经帮你处理掉了,例如 pub 本身已经做了这种限制)。然后最好根据场景升级一个对应级别的版本来回滚逻辑,最后再将你的重大且不兼容的改版升一个主版本号进行发布。记住,语义化的版本控制就是透过版本号的改变来传达意义。

尾声

至此,我们已经了解了语义化版本控制规范的具体细则,常用的先行版本号标识的含义及应用场景,希望能在大家日后的工作生活当中所有帮助。你还见过哪些常见的先行版本号,你们团队又是如何避免包依赖地狱的,欢迎在评论区补充。感谢大家的观看,再见。

参考文档

semver.org/lang/zh-CN/

pub.dev/packages/mo…

peps.python.org/pep-0440/#v…

docs.gradle.org/current/use…

docs.gradle.org/current/use…

maven.apache.org/pom.html#de…

docs.npmjs.com/cli/v6/usin…

docs.npmjs.com/cli/v6/usin…

推荐阅读

线程池 ThreadPoolExecutor 基础介绍

Netty-EventLoop实现原理

Netty内存分配

从单线程到多线程,再到虚拟线程

分布式事务解决方案-seata

招贤纳士

政采云技术团队(Zero),Base 杭州,一个富有激情和技术匠心精神的成长型团队。规模 500 人左右,在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。

如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注 文章顶部.png