语义化版本号
一般来说,版本号推荐采用 Semantic Versioning 中推荐的格式(major.minor.patch ),通过语义化的版本格式,清晰地传达库作者对于各个版本之间的兼容性保证语义。以 Akka 为例,在 2.3.x 版本以前,使用的是 epoch.major.minor 的格式,而从 2.4.0 版本开始,版本号的格式也切换为了更通用、更易理解的 major.minor.patch 格式。库的使用者,通过版本号则能够清晰的识别出 Akka 在各个版本之间提供的向后兼容性保障,比如:
当前版本 |
目标版本 |
向后兼容 |
2.4.x |
2.5.x |
OK |
2.5.x |
2.6.x |
OK |
2.x.y |
3.x.y |
NOT OK |
同一个 major 版本下各个 minor 和 patch 版本之间,我们保持了向后二进制兼容性(backwards binary compatibility),除非特别说明,本文后续所有的兼容性,都指向后兼容性。向后的二进制兼容性指:一个具备向后兼容性的新版本Jar,可以直接替换掉其对应的旧版本,而不引发任何问题,这里主要指对公开编程接口的二进制兼容性保障。
保障 minor 和 patch 版本的二进制兼容性也不一定就意味着开发迭代就束缚了手脚。我们可以通过显式的标注:ApiMayChange 、 Internal 来清晰的传达语义。
有意义的版本号清晰的传达了语义,简化了日常开发工作中的包管理工作。在版本进行升级和收敛过程中,希望可以提供清晰的变更历史(changelog)。而在两个二进制不兼容版本之间,希望可以提供清晰的迁移指引(migration guide)。
什么是二进制兼容性
得益于完善的自动化发布工具链,我们进行库的发布也是非常方便的。而方便快捷的开发支持并不意味着我们可以快速迭代而忽略库其他方面的要素,比如在编写和发布二方包时对二进制兼容性的考虑。接下来将会讨论:
二进制兼容性为何会导致应用运行时错误
如何通过良好的设计、流程以及通过工具来尽可能地保持版本间的二进制兼容性
如何评估评估潜在的二进制兼容性问题
受限于本文的篇幅,本文将不会讨论:
序列化的兼容性
网络协议的兼容性
JVM上的同一种编程语言的不同版本之间的二进制兼容性
JVM上的不同编程语言不同版本之间的互操作性
推荐阅读:
感兴趣的同学可以扩展学习 Java 各版本之间的兼容性相关资料
Groovy、Kotlin 、Scala 等 JVM 编程在不同的 Java 版本上的编译产物差异
▐ JVM 的执行流程
JVM 上的编程语言在通过编译器编译后,会将我们的代码转换为平台无关的 JVM 字节码,并存储在 .class 文件中, .class 文件打包在 Jar 文件中进行分发。
推荐:可以通过 jd-gui 或则 recaf 等工具来解读 .class 文件,进一步加深理解。
当我们的代码依赖于某个库时,在编译产物中,结果字节码 引用 (references) 了对应库类/成员的字节码。在应用运行并引用到了对应的类/成员 被时,JVM 将会使用 Classloader 结合 类/成员的签名,从而加载对应的字节码。这时如果没有找到对应的类/成员,就会报错,最直观的感受就是应用启动失败,或则在运行过程中出现错误。一般来说,在升级相关依赖时,都需要仔细阅读对应版本的变更历史和兼容性声明,并通过人工和自动化回归测试等方式来进行保障。
为了避免这些错误,应用执行过程中所需字节码的相关库都需要正确提供。如果确定不会用到某些执行路径上的相关字节码,则可以通过通过排除相关依赖,对最终产物的大小进行缩减。因为 JVM 对字节码进行懒加载的缘故,经常在应用运行过程中我们才陆续发现相关的二进制兼容性问题,对应的异常基本都是 java.lang.LinkageError 及其子类,比如: AbstractMethodError 、 NoSuchMethodError 、 NoSuchFieldError 、 NoClassDefFoundError 等。
GraalVM、Scala-Native、Scala-JS 等可静态链接,报错的时机不一样,这里不展开讨论。但本质上也是链接错误。
感兴趣的读者可以搜索:Native Image Compatibility and Optimization Guide
▐ 构建工具的依赖管理和仲裁
在应用的过程中如果用到了某个类,JVM 将通过 Classloader 从 classpath 加载对应的类,并使用其第一个匹配的类。如果匹配到的和我们实际代码正确执行需要有出入,就会导致各种运行时错误或则行为改变。为了避免上面的问题,业界早已有各种基于 Classloader 的隔离方案,比如 OSGI 标准、蚂蚁的 sofa 容器等。构建工具无论是 Gradle 、Maven 还是 SBT 都提供了默认的版本驱逐/仲裁规则。其中Gradle和SBT的默认规则是使用最新的版本,而Maven的仲裁规则稍微复杂一些,简单认为是:版本管理声明优先、先声明优先、最短路径优先。
推荐阅读:
可以通过 arthas 查看对应的类被加载的版本等信息
目前使用最广泛的还是 Maven,如果遇到了版本冲突等问题,大家可能最简单的想法就是:升级到相关依赖库的最新版本。然而,很多情况下,因为二进制不兼容,导致并没有这样简单直接的解法,下面将会展开讲一下为什么可能会陷入这样的困境。
▐ 源码兼容性和二进制兼容性
源码兼容性
源码兼容的两个库可以相互替换,不会新增加任何的编译错误和行为改变(不兼容的行为改变可能会引发对应的源码修改)。比如从 akka-actor 的 2.6.10 版本 升级到 2.6.11 版本 不会引入任何的编译错误、也不会引起语义改变,那么我们可以说 akka-actor 的 2.6.11 版本对其 2.6.10 版本是源码兼容的。
说明:这里的两个版本都使用Java 1.8 进行编译, 并且使用了相同的 Scala版本。
二进制兼容性
二进制兼容的两个库可以相互替换,而不会引发任何的链接错误(LinkageError)。比如 某个人对内提供的包在 1.3.13 到 1.3.14 的两个版本之间只是修改了某个方法的内部实现(逻辑优化)。此时我们说 1.3.13 和 1.3.14 这两个版本之间是二进制兼容的。
源码兼容和二进制兼容的关系
通常来说,破坏源码兼容性也会破坏二进制兼容性,而破坏二进制兼容性,则不一定破坏了破坏源码兼容性。比如在原来的某个方法基础上增加了一个参数,那么这个时候我同时破坏了源码兼容性和二进制兼容性。而如果我在不修改方法签名的情况下,在之前的版本使用Java 6编译,在新的版本实现中,我在内部实现利用了部分Java 8的特性和语法并使用Java 8 进行编译和发布。那么这个时候新的版本没有破坏源码兼容性,却破坏了二进制兼容性;此时在JVM 6 上运行会报错。
▐ 向前和向后兼容性
广义的兼容性分为向前兼容(Forward Compatible)和向后兼容(Backward Compatible),分别是是站在同一个库的不同版本来说的。
向后兼容:在需要用到某个库时,可以使用新版本的某个库替换其旧版本,而不会引发问题,这也是我们通常的关注的。比如 guava-jre 的 20.0 版本可以替换 guava-jre 的 19.0 版本(没有使用非兼容方法)。我们在讨论某个库的新版本不兼容的时候,通常指其没有提供向后兼容性。例如:Guava 的向后兼容性说明
向前兼容:在需要使用某个库时,可以使用旧版本的某个库替换其新版本,而不会引发问题。通常我们需要使用SchemaRegistery等来达到目的(比如旧版本还可以读取新版本写入的数据),向前兼容使我们提供的客户端库可以独立服务器端进行多版本演进。还有一些则是为了生态平滑升级做出的努力:Forward Compatibility for the Scala 3 Transition
在上图中,产物 A 依赖了库 B ;如果可以使用 B V1 代替 B V2 ,则说 B V1 是向前兼容的;如果可以使用 B V3 代替 B V2 ,则说 B V3 是向后兼容的。
二进制兼容性的重要性
正常的情况下,如果应用升级/引入一个库在启动、回归测试中都没问题,那么这次升级是简单的,如果启动失败或则运行错误,则就需要开发同学进一步介入,开始漫长的排包、修复、测试过程。
针对二进制不兼容的库,开发同学需要把不兼容的版本进行剔除;这个过程比较耗时、容易出错。借助于MavenHelper 等依赖分析工具可以节省一部分心力。
而某些二进制不兼容问题比较复杂,处理起来需要相关依赖上下游进行整体升级,耗时耗力。
上图中,应用 A 同时依赖了库 B 和 库 C;而库 B V1 依赖库 D V1 , 但是这些方法在 D V2 中被删除了,而在 C V2 中又开始使用了仅存在于 D V2 的一些方法。这个时候, D V2 相对于 D V1 不具备向后二进制兼容性。作为应用 A来说,就陷入了困境,无论排除掉库的 V1 还是 V2 版本,都会引发错误。唯一的办法是让 B 迁移到 依赖 D V2 ;或 B 和 C 都升级到的另一个共同兼容版本进行编译发布,然后 A 再升级。
有时候,新功能开发需要引入一个新的依赖,但是新的依赖又会引入二进制不兼容性,这个时候开发新功能,就会破坏旧功能;保留旧功能,就不好开发新功能。通常把这种情况叫做“依赖地狱”(dependency hell)。如上图,如果我们需要引入一个新的功能需要用到库 F ,而 F 依赖了库 D V3 , 如果此时 D V3 相对于 D V2 不是二进制兼容的,那么我们就必须要 升级 B V2 和 C V2 ,使它们依赖于 D V3 进行开发,从而才可以安全地引入 F 。
由上可见,保持一个二方包在各个 minor 和 patch 版本之间的兼容性对于一个普通的开发同学的幸福感来说是多么的重要。通过尽可能地保持二进制兼容性,库的消费者在遇到依赖冲突时,可以排除掉旧版本的包,直接使用新版本的包,而不会陷入进退维谷的境地。
如何保持二进制兼容性
保持二进制兼容性,除了依赖工具来进行检查,还有一些明显可以推断出会引发二进制兼容性问题的。对于在 JVM 上的一些语言,可能会在保持了源码兼容性的情况下损坏二进制兼容性,这里不作为展开,下面都描述的是使用 Java 时需要注意的点。
1. 保持较低的 Java 目标版本
在同一个大版本的多个小版本之间,尽可能地使用相同的 source 或则 target 进行发布库。比如针对于 Java 1.6 或则 Java 1.8 发布,在整个过程中不要轻易地修改。通过克制和显式地声明库编写时使用的语言特性和目标版本,降低库消费者的使用成本。如果需要使用更新的语言特性,则需要使用新的 major 版本进行重新发布,或则多针对多个版本进行差异化编译。
2. 完整的方法废弃过程
保持兼容性的多个版本间,对于废弃的方法或字段不要轻易地进行删除或修改,使用 @Deprecated 显式地标注废弃的原因和替代,给出清晰的指引,并在变更日志中指出。保留2个 minor 版本之后,则可以通过流程卡点、公告等方式来告知相关方,版本的收敛情况则可以通过各种工具来查看。
3. 不要轻易修改成员可见性
对于公开提供的 API 方法、字段需要尽可能地克制,保持兼容性的多个版本间,默认内部实现的方法不要对外进行公开,如果需要对可见性进行封闭,则需要使用上面描述的废弃过程来进行逐渐收敛。改变可见性后将会破坏源码兼容性和二进制兼容性。如果不得不公开,则可以通过显式的标注该成员仅供内部使用来进行声明。
4. 不要轻易修改方法的签名
在保持兼容性的多个版本间,尽可能地使用复合参数对象,比如 QueryMessageRequest 来代替多个参数的方法重载,从而方便地保持向后兼容性。如果已经采用了多参数的方式,则可以迁移到新的复合参数的方法上,比如都代理到 private queryMessage0(final QueryMessageRequest request) 。并通过 2 中描述的过程来对以前已经暴露的方法进行废弃。直接修改方法签名将会破坏二进制兼容性和源码兼容性。
5. 不要轻易删除类的公开成员
在保持兼容性的多个版本间,不要删除一个类的方法和字段,如果要删除则需要经过 2 中描述的过程进行废弃。直接删除方法签名将会破坏二进制兼容性和源码兼容行。
6. 修改二进制兼容性后可以选择升级包名
在 major 的版本升级中,我们可以修改包名,比如从 org.apache.commons.lang2 改为 org.apache.commons.lang3 ,通过这样的方式可以很好的避免 2个版本之间的二进制兼容性问题。不过包名看起来相对来说会怪一点。与此同时,RxJava 3 在版本发布的过程中也采用了类似的 package 路径隔离方式。
7. 其他会引发兼容性问题的修改
修改 |
二进制兼容性 |
源码兼容性 |
删除类 |
false |
false |
将类改为抽象类 |
false |
false |
将类改为final |
false |
false |
将类改为非公开 |
false |
false |
新增加异常 |
false |
true |
类型改变 |
false |
false |
删除父类 |
false |
false |
父类修改后不兼容 |
false |
false |
删除成员 |
false |
false |
成员可见性降低 |
false |
false |
成员改为静态 |
false |
false |
修改成员的(返回)类型 |
false |
false |
方法改为抽象方法 |
false |
false |
成员改为final |
false |
false |
静态字段/方法改为成员字段/方法 |
false |
false |
接口增加方法、父类增加抽象方法,默认方法 |
true |
false |
方法不再抛出异常 |
true |
false |
接口新增加默认方法 |
false |
false |
抽象方法改为默认实现 |
false |
false |
构造方法删除 |
false |
false |
构造方法可见性变低 |
false |
false |
8. (推荐)使用工具来进行检查
在发布二方包的过程中,推荐使用各种编译插件来检查二进制兼容性,Java 、Kotlin 和 Scala 生态都有对应的二进制兼容性检查工具。以最近测试过的:japicmp 插件为例,如果我进行了不安全的修改,在打包的时候会非常清晰地给出指引,并生成对应的报告。
配置好当前的版本,以及旧版本之后,japicmp 会在打包过程中给出清晰的指示。例如:
推荐阅读
Revapi is a tool for API analysis and change tracking.
japicmp is a tool to compare two versions of a jar archive:
japi-compliance-checker — a tool for checking backward binary and source-level compatibility of a Java library API.
binary-compatibility-validator ——The tool allows to dump binary API of a Kotlin library
小结
在本文中,我们分享了什么是二进制兼容性,不保持二进制兼容性可能带来的问题是什么、保持二进制兼容性对于用户侧的来说可以带来什么好处,以及如何通过工具来避免常见的二进制兼容性破坏。如果所有的库作者都尽可能地保持二进制兼容性,通过版本号来显化的传递潜在的二进制兼容性问题,并在库的变更记录和升级指引中显式给出指导,那么作为库的使用方将会更少、更加容易的处理依赖相关的问题。