行业惯例
语义化版本控制规范(SemVer)是一种常见的软件版本命名方式。
SemVer的主要规则为:
版本格式:主版本号.次版本号.修订号,版本号递增规则如下:
主版本号:当你做了不兼容的 API 修改,
次版本号:当你做了向下兼容的功能性新增,
修订号:当你做了向下兼容的问题修正。
先行版本号及版本编译信息可以加到“主版本号.次版本号.修订号”的后面,作为延伸。
这段话里面有2个关键概念:向下兼容、API。怎么理解“向下兼容”?我认为不能将“向下兼容”理解为“任何可能的输入对应的输出都不会变”,这样的话就没有软件更新的必要了。因此,原文将主版本号的“不兼容”限定在了“API修改”范围内,次版本号和修订号只需要保证API层面的兼容性。
以依赖包为例,从兼容性角度看,个人理解的SemVer规则是:
- 修订号变更:只包含bug修复,无功能新增或修改,绝大多数调用方可以无感升级;
- 次版本号变更:包含功能新增或修改,但已有接口的签名不变;绝大多数调用方的程序在升级后依然可以正常编译,但运行时的逻辑可能存在变化;
- 主版本号变更:包含已有接口的删除或修改,相当一部分调用方的程序在升级后编译报错。
实践经验
修订版本可以无感升级吗?
一种常见的看法是,修订版本不存在破坏性变更,因此可以无感升级。例如,从x.y.2
版本升级到x.y.12
版本,虽然升了10个小版本,但因为只是修订版本的变更,所以也不需要专门测试。
在软件包严格遵循SemVer规则的前提下,上述看法是合理的。但是,很多软件包往往自定义一套版本号规则,或者干脆不按规则发版,导致“修订”版本也可能引入破坏性变更。
以JDK为例,从JDK 10开始,JDK版本号遵循一套自定义的版本号规则。这套规则的格式为$FEATURE.$INTERIM.$UPDATE.$PATCH.
,具体来说:
- $FEATURE:功能版本号,可能包含不兼容变更。JDK每6个月发布一个功能更新,因此功能号每6个月递增一次。
- $INTERIM:中期版本号。在JDK现行的每6个月发布一个功能版本的模式下,不存在中期版本,因此中期版本号总是0。
- $UPDATE:更新版本号,包含安全问题修复、回退(regression)、bug修复等。每3个月递增一次。
- $PATCH:补丁版本号,包含紧急发布补丁。
例如,自2021年9月发布至今,JDK 17一共发布了14个更新版本(包含2个补丁版本),最新版本号为17.0.12
。
基于JDK版本号的格式,我们可以得出一个貌似正确的结论:JDK将SemVer定义的“次版本号”并入“主版本号”并称为“功能版本号”,并且在每个功能版本内只发布较小的修订版本。因此,将JDK由17.0.2
升级到17.0.12
,似乎不需要经过全面测试就可以被发布上线。
为了弄清楚“JDK修订版本能否无感升级”,我们来看下JDK的修订版本具体更新了哪些内容。JDK从17.0.2
到17.0.12
发生的变更很多,这里只举三方依赖库的例子:
三方依赖库 | JDK 17.0.2 | JDK 17.0.12 |
---|---|---|
FreeType | 2.10.4 | 2.13.2 |
HarfBuzz | 2.8 | 8.2.2 |
libpng | 1.6.37 | 1.6.40 |
LCMS | 2.12 | 2.16 |
ICU4J | 67.1 | 67.1 |
Unicode | 13.0.0 | 13.0.0 |
可见JDK的“修订”版本会更新三方依赖库的版本,并且三方依赖库的更新包含了次版本号更新甚至主版本号更新(例如HarfBuzz)。那么如果三方依赖库在中间某个版本引入了破坏性变更,JDK的“修订”版本自然也会包含这个变更,导致线上故障。
有同学会说,FreeType、HarfBuzz这些库都是在java.desktop路径下,服务端程序不会被这些依赖库的更新所影响,其实不然。比如在生成报表时我们往往需要在服务端渲染PDF,而itextpdf渲染PDF的时候就会调用FreeType实现文字的渲染。因此,FreeType的版本更新可能会影响报表的渲染效果,造成线上故障。
总结:修订版本能否无感升级,取决于具体每个软件包的版本号规则和规则执行情况。以JDK为例,它的修订版本是可能引入破坏性变更的,显然不能无感升级。比较稳妥的做法是,根据软件包的提交记录而非版本号来判断能否无感升级;任何依赖包版本的变更(包括修订版本变更),都需要经过完整的测试才能上线。
修复安全漏洞一定要升级到最新版吗?
观察到这样两种现象:
- 在修复依赖包安全漏洞时,安全部门的同事往往会指定一个特定的并且较高的软件版本,要求所有线上服务升级到这个版本。例如,为了解决fastjson的安全漏洞,将线上服务的fastjson版本从
1.2.4x
升级到1.2.83
,过程中需要解决很多额外的代码兼容性问题,影响漏洞修复速度和服务稳定性。 - 随日常迭代滚动更新依赖包,使得依赖包始终保持最新。这种做法在业务开发团队应该很少见😂,但JDK的确是这么做的。例如,JDK 17.0.9将FreeType升级到2.13.0,JDK 17.0.11又将FreeType升级到2.13.2,尽管FreeType的
2.13.x
版本并未修复任何安全漏洞或者增加大的功能。我理解JDK这么做可能是为了避免在遇到安全漏洞时跨版本升级。
不评价上述两种做法的好坏,只介绍另一种解决方案:backport补丁。
举例,在不同版本的Rocky Linux系统上执行命令yum list installed | grep freetype
,得到的版本号是不一样的:
三方依赖库 | Rocky Linux 8.5 | Rocky Linux 8.10 |
---|---|---|
FreeType | 2.9.1-4.el8_3.1 | 2.9.1-9.el8 |
FreeType本身的版本号都是2.9.1
,但后缀不同,后缀代表的就是backport补丁版本。补丁版本是由RHEL引入的,Rocky Linux作为RHEL的下游自然包含相同的软件包。对于RHEL 8.5,FreeType包的补丁版本为4
;对于RHEL 8.10,FreeType包的补丁版本为9
。
补丁版本是RPM包管理器的一项功能,具体可以参考官方文档。简单来说,RPM支持在原始版本代码的基础上,通过git diff的方式引入代码变更,从而修复漏洞或bug。
通过下载RPM包的源码,我们可以很清楚地看到补丁功能的原理。
在Rocky Linux 8.10上执行以下命令,下载并解压RPM源码包:
yum install yum-utils
yumdownloader --source freetype.x86_64
rpm2cpio freetype-2.9.1-9.el8.src.rpm | cpio -ivdm
当前目录下会新增这些文件:
-rw-r--r-- 1 root root 618 Sep 30 2022 freetype-2.2.1-enable-valid.patch
-rw-r--r-- 1 root root 618 Sep 30 2022 freetype-2.3.0-enable-spr.patch
-rw-r--r-- 1 root root 460 Sep 30 2022 freetype-2.5.2-more-demos.patch
-rw-r--r-- 1 root root 340 Sep 30 2022 freetype-2.6.5-libtool.patch
-rw-r--r-- 1 root root 2.0K Sep 30 2022 freetype-2.8-multilib.patch
-rw-r--r-- 1 root root 876 Sep 30 2022 freetype-2.9.1-avoid-invalid-face-index.patch
-rw-r--r-- 1 root root 5.1K Sep 30 2022 freetype-2.9.1-covscan.patch
-rw-r--r-- 1 root root 754 Sep 30 2022 freetype-2.9.1-guard-face-size.patch
-rw-r--r-- 1 root root 1.4K Sep 30 2022 freetype-2.9.1-png-bitmap-size.patch
-rw-r--r-- 1 root root 789 Sep 30 2022 freetype-2.9.1-png-memory-leak.patch
-rw-r--r-- 1 root root 1.2K Sep 30 2022 freetype-2.9.1-properly-guard-face-index.patch
-rw-rw-r-- 1 root root 1.9M Sep 30 2022 freetype-2.9.1.tar.bz2
-rw-r--r-- 1 root root 516 Sep 30 2022 freetype-2.9.1-windres.patch
-rw-r--r-- 1 root root 2.9K Sep 30 2022 freetype-2.9-ftsmooth.patch
-rw-rw-r-- 1 root root 2.1M Sep 30 2022 freetype-doc-2.9.1.tar.bz2
-rw-r--r-- 1 root root 35K Sep 30 2022 freetype.spec
-rw-rw-r-- 1 root root 228K Sep 30 2022 ft2demos-2.9.1.tar.bz2
-rw-r--r-- 1 root root 257 Sep 30 2022 ftconfig.h
可见RPM源码包freetype-2.9.1-9.el8.src.rpm
主要包含3个部分:
- FreeType 2.9.1官方发布的源码
freetype-2.9.1.tar.bz2
- 若干后缀为
.patch
的补丁文件 - 配置文件
freetype.spec
对于补丁文件,以freetype-2.9.1-guard-face-size.patch
为例,它的内容为:
From 0c2bdb01a2e1d24a3e592377a6d0822856e10df2 Mon Sep 17 00:00:00 2001
From: Werner Lemberg <wl@gnu.org>
Date: Sat, 19 Mar 2022 09:37:28 +0100
Subject: [PATCH] * src/base/ftobjs.c (FT_Request_Size): Guard `face->size`.
Fixes #1140.
---
src/base/ftobjs.c | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/base/ftobjs.c b/src/base/ftobjs.c
index 6492a1517..282c9121a 100644
--- a/src/base/ftobjs.c
+++ b/src/base/ftobjs.c
@@ -3409,6 +3409,9 @@
if ( !face )
return FT_THROW( Invalid_Face_Handle );
+ if ( !face->size )
+ return FT_THROW( Invalid_Size_Handle );
+
if ( !req || req->width < 0 || req->height < 0 ||
req->type >= FT_SIZE_REQUEST_TYPE_MAX )
return FT_THROW( Invalid_Argument );
--
2.35.1
通过补丁文件内容可以发现:
- RPM补丁文件以git diff格式保存代码变更,变更内容就是一个git commit,甚至在GitHub上能找到原始提交;
- RPM补丁功能能够将新提交合入老版本。FreeType
2.9.1
发布于2018年5月,而这个补丁提交于2022年3月19日。如果没有补丁功能,用户只能通过将FreeType升级到2.12.0
才能获得这个bugfix;得益于补丁功能,用户只需要升级到2.9.1-9
这个补丁版本,避免其它功能受到影响。
此外,freetype.spec文件也包含很多信息,例如所有补丁版本的发布说明,这里不赘述。
回过头来看RHEL,可以发现RHEL保证了在同一个大版本(例如RHEL 8,或者说CentOS 8、Rocky Linux 8)内的三方依赖库版本的一致性。例如从RHEL 8.5到8.10,FreeType一直是2.9.1版没有变,只是通过补丁机制将新版本中重要的bugfix合入2.9.1版的代码。这或许是一种值得借鉴的做法。
Java生态内其实也有类似的做法。例如在2022年,fastjson就发布了很多老版本的noneautotype包,在修复漏洞的同时保证了代码逻辑的兼容性。
总结:修复安全漏洞一定要升级到最新版吗?不一定,通过backport修复代码到历史版本也同样能解决问题。