两行依赖对调了顺序,运行时少了一个方法
整理
pom.xml时把两个依赖的顺序换了一下,没有改任何版本号。构建通过,测试通过。上线后某个接口抛出NoSuchMethodError,说一个明明存在的方法找不到。
应用依赖的两个库各自依赖同一个第三方库,版本不同。类路径上每个库只能有一个版本,Maven 必须替你选一个。它的选法和直觉不一样:不比较版本号的大小,而是看哪个离得近;一样近的时候,看谁在 pom.xml 里写在前面。
本文用一组最小的工程把选择的规则逐条跑出来,再说明为什么构建发现不了这类问题,以及怎么提前拦住它。实验在 Maven 3.10.0 上运行,见文末。
一、实验的设置
util有两个版本:1.0 只有Text.upper;2.0 多了一个方法Text.title。lib-a依赖util:1.0;lib-b依赖util:2.0,并且调用了title。mid依赖lib-b,用来把lib-b再推远一层。- 应用的代码只有两行:调用一次
lib-a,调用一次lib-b。应用自己不直接使用util。
十个应用的代码完全相同,只有 pom.xml 里的依赖声明不同。全部构建成功。
二、选哪个版本
util,它的两个依赖各自带进来一个版本。Maven 不比较版本号的大小:离应用近的那个胜出,一样近时在 pom.xml 里先声明的那个胜出。这里 1.0 在第 2 层、2.0 在第 3 层,进入类路径的是 1.0。lib-b 是按 2.0 编译的,它调用的方法在 1.0 里不存在,构建照样成功,运行到那一行才抛 NoSuchMethodError。| 应用的依赖声明 | 类路径上的 util | 运行时调用 lib-b |
|---|---|---|
lib-a、mid(1.0 在第 2 层,2.0 在第 3 层) | 1.0 | NoSuchMethodError |
lib-a、lib-b(都在第 2 层,lib-a 写在前面) | 1.0 | NoSuchMethodError |
lib-b、lib-a(顺序对调) | 2.0 | 正常 |
lib-a、mid,再直接声明 util:2.0 | 2.0 | 正常 |
规则有两条:
- 最近者胜出。 从应用到这个库,经过的依赖层数最少的那条路径上的版本生效。第一行里,2.0 更新,但它远一层,落选了。
- 一样近时,先声明者胜出。 第二、三行只差两个
<dependency>的先后顺序,结果一个出错一个正常。
版本号的大小不在规则里。Maven 不会因为 2.0 比 1.0 新就选它,也不会检查选出来的版本是否满足其他依赖的需要。
第四行是这条规则的直接推论:在应用自己的 pom.xml 里声明这个库,它就在第 1 层,一定胜出。这是最简单的修复,但它让应用多了一个「自己并不使用」的直接依赖,过一阵子就没人记得它为什么在那里。更好的办法在下一节。
落选的版本可以看到。普通的 mvn dependency:tree 只显示最后生效的结果;加上 -Dverbose 会把被略去的也列出来:
labs.mediation:app-nearest:jar:1.0
+- labs.mediation:lib-a:jar:1.0:compile
| \- labs.mediation:util:jar:1.0:compile
\- labs.mediation:mid:jar:1.0:compile
\- labs.mediation:lib-b:jar:1.0:compile
\- (labs.mediation:util:jar:2.0:compile - omitted for conflict with 1.0)三、用依赖管理把版本定下来
<dependencyManagement> 不引入依赖,它只规定「如果依赖树里出现了这个库,用哪个版本」。它对传递依赖同样有效,而且不看路径远近:
| 依赖管理 | 依赖声明 | 类路径上的 util |
|---|---|---|
util:2.0 | lib-a、lib-b(lib-a 在前) | 2.0 |
依次导入 bom-x(管理 1.0)、bom-y(管理 2.0) | 同上 | 1.0 |
依次导入 bom-y、bom-x | 同上 | 2.0 |
自己写的条目 util:2.0,再导入 bom-x | 同上 | 2.0 |
util:2.0 | 同上,另外显式声明 util:1.0 | 1.0 |
util:2.0 | 没有任何依赖声明 | 不在类路径上 |
从这张表可以读出依赖管理自己的优先顺序:
- 多个 BOM 管理同一个库时,先导入的生效。 第二、三行又是一个「只换顺序、结果不同」的例子。同时引入框架的 BOM 和公司内部的 BOM 时,两者的先后决定了几十个库的版本。
- 自己写的条目优先于导入的 BOM。 想覆盖 BOM 里的某个版本,在本项目的
<dependencyManagement>里单独写一条。 - 依赖声明里显式写的版本优先于依赖管理。 第五行里依赖管理要 2.0,但
<dependency>上写了<version>1.0</version>,生效的是 1.0。用了 BOM 之后,依赖声明里就不应该再写版本号;某个模块里残留的一个显式版本会悄悄地绕开统一管理。 - 只有管理条目不会带来依赖。 最后一行:没有人依赖
util,它就不在类路径上。
四、为什么构建是绿的
十个应用全部 BUILD SUCCESS,其中四个一运行就出错。原因是编译和运行检查的东西不同:
- 应用的代码只调用了
lib-a和lib-b的方法,这些方法都存在,编译通过。 lib-b早就编译好了,它当时面对的是util:2.0。它的字节码里记着「调用Text.title」,但没有人在应用构建时重新检查这个调用。- 到运行时 JVM 第一次执行到这一行,才去类路径上的
util里找title,找不到,抛出NoSuchMethodError。
所以这类问题的特点是:只在执行到那条具体路径时才暴露。如果那是一个不常走的分支(错误处理、某种特殊的输入、定时任务),它可以在生产环境里潜伏很久。同一家族的错误还有 NoClassDefFoundError、ClassNotFoundException(类在落选的版本里不存在)、AbstractMethodError(接口多了方法)。
测试能发现它,前提是测试真的走到了那条路径,并且测试时的类路径与打包后的一致。类路径在不同 scope 下的差别见 Maven Scope。
五、在构建阶段拦住它
Maven Enforcer 有两条针对版本冲突的规则。在第一个应用(1.0 胜出、2.0 落选)上分别打开:
| 规则 | 构建结果 | 报错 |
|---|---|---|
dependencyConvergence | 失败 | Dependency convergence error for labs.mediation:util:jar:1.0 |
requireUpperBoundDeps | 失败 | Require upper bound dependencies error for labs.mediation:util:1.0 |
dependencyConvergence要求依赖树里同一个库只出现一个版本。最严格,在依赖多的项目里一打开会报出一大批,需要逐个用依赖管理定版本。requireUpperBoundDeps只在「生效的版本低于某个依赖要求的版本」时失败,也就是本文这种最危险的情形(低版本胜出,高版本的调用方找不到新方法)。它更容易在已有的项目里落地。
两条规则报错之后的处理是同一件事:在 <dependencyManagement> 里为这个库写明版本,把选择从「碰巧」变成「决定」。
六、几条做法
- 版本只写在一个地方:父 POM 或 BOM 的
<dependencyManagement>。各模块的<dependency>不带版本号。 - 导入多个 BOM 时,顺序是有含义的;需要覆盖时在自己的管理条目里显式写出,不靠调整顺序。
- 至少打开
requireUpperBoundDeps。 - 升级某个库之后看一眼
dependency:tree -Dverbose的差异,确认生效的版本确实变了。 <exclusion>用来去掉不需要的依赖,不用来「选版本」;选版本用依赖管理。- 不要假设换了构建工具结果相同:Gradle 默认在冲突时选最高的版本,同一组依赖在两种工具下可能得到不同的类路径。
配套实验
- codesphere-labs/java/maven-dependency-mediation:Maven 3.10.0(JDK 25.0.4.1)上十个只有依赖声明不同的应用:构建结果、类路径上的版本与运行输出;带
-Dverbose的依赖树;Enforcer 两条规则的结果(验证记录)
参考资料