Skip to content

两行依赖对调了顺序,运行时少了一个方法 ​

整理 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 里的依赖声明不同。全部构建成功。

二、选哪个版本 ​

应用lib-autil 1.0第 2 层:进入类路径midlib-b按 util 2.0 编译util 2.0第 3 层:被略去运行时实际用到的是 1.0Text.title 不存在把 lib-a 与 lib-b 都放在第 2 层:谁在 pom.xml 里写在前面,谁带来的版本生效dependencyManagement 里写明版本:不论路径远近都用它;依赖声明里的显式版本又优先于它导入多个 BOM 时,先导入的那个生效
图 1 · 应用没有直接依赖 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.0NoSuchMethodError
lib-a、lib-b(都在第 2 层,lib-a 写在前面)1.0NoSuchMethodError
lib-b、lib-a(顺序对调)2.0正常
lib-a、mid,再直接声明 util:2.02.0正常

规则有两条:

  1. 最近者胜出。 从应用到这个库,经过的依赖层数最少的那条路径上的版本生效。第一行里,2.0 更新,但它远一层,落选了。
  2. 一样近时,先声明者胜出。 第二、三行只差两个 <dependency> 的先后顺序,结果一个出错一个正常。

版本号的大小不在规则里。Maven 不会因为 2.0 比 1.0 新就选它,也不会检查选出来的版本是否满足其他依赖的需要。

第四行是这条规则的直接推论:在应用自己的 pom.xml 里声明这个库,它就在第 1 层,一定胜出。这是最简单的修复,但它让应用多了一个「自己并不使用」的直接依赖,过一阵子就没人记得它为什么在那里。更好的办法在下一节。

落选的版本可以看到。普通的 mvn dependency:tree 只显示最后生效的结果;加上 -Dverbose 会把被略去的也列出来:

text
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.0lib-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.01.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> 里为这个库写明版本,把选择从「碰巧」变成「决定」。

六、几条做法 ​

  1. 版本只写在一个地方:父 POM 或 BOM 的 <dependencyManagement>。各模块的 <dependency> 不带版本号。
  2. 导入多个 BOM 时,顺序是有含义的;需要覆盖时在自己的管理条目里显式写出,不靠调整顺序。
  3. 至少打开 requireUpperBoundDeps。
  4. 升级某个库之后看一眼 dependency:tree -Dverbose 的差异,确认生效的版本确实变了。
  5. <exclusion> 用来去掉不需要的依赖,不用来「选版本」;选版本用依赖管理。
  6. 不要假设换了构建工具结果相同:Gradle 默认在冲突时选最高的版本,同一组依赖在两种工具下可能得到不同的类路径。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权