Maven 依赖仲裁实战:pom 里写的 2.0,为什么打出的 WAR 包是 1.5?

老炮踩坑录 · 番外篇 · 翻车现场系列

基于「企业融合评估平台」真实 pom 与打包产物,复盘 Maven 冲突时怎么决定用哪个版本

关键词:依赖冲突 · 传递依赖 · dependencyManagement · NoSuchMethodError

👋 欢迎阅读

🏠个人主页: 知守观

📘我的专栏: 老炮踩坑录

💻当前内容:Maven 依赖冲突

前言

离职后复盘这个项目,有一天我解压了当年打的 war 包,想核对几个 jar 的版本。

翻到 WEB-INF/lib 目录,一个文件名让我停住了:

text 复制代码
jsqlparser-1.0.jar

1.0?项目里的 mybatis-plus 是 3.3.0,它那一版的发布说明里白纸黑字写着 "jsqlparser up 3.1"。也就是说 mybatis-plus 要带进来的是 jsqlparser 3.1。

lib 目录里只有一个 jsqlparser,是 1.0,3.1 不见了。

谁换的?没人换呀。pom.xml 里从头到尾没出现过 jsqlparser 这个词------它是传递依赖,两个框架各自依赖了不同的版本,Maven 按规则保留了其中一个。

选择的结果写在 lib 目录里,只是从来没人去看。

案发现场:pom 里写的和 lib 里跑的不一致

pom.xml 里的两段声明(真实行号):

xml 复制代码
<!-- pom.xml L94 -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.3.0</version>
</dependency>

<!-- pom.xml L102 -->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.2.5</version>
</dependency>

两个都是分页相关的框架,同时存在于一个项目里。它们各自拖着一条传递依赖链,终点是同一个 artifact:

同一个 com.github.jsqlparser:jsqlparser,一个依赖 1.0,一个依赖 3.1。Maven 的处理方式是自己选一个,不报错------选一个保留,另一个不打进 classpath,也不给任何提示。

最终保留下来的,是 pagehelper 那条链上的 1.0。

注意一个细节:pom 里 mybatis-plus 声明在第 94 行,pagehelper 在第 102 行,mybatis-plus 靠前。很多人以为"先声明的赢",mybatis-plus 明明先声明,还是输了。因为 Maven 比的是依赖路径的深度,3 比 4 小,pagehelper 那条链更近,声明顺序在这里不起作用。

版本是谁定的:Maven 的三条选择规则

这次复盘我把 pom.xml 和 lib 目录从头到尾对了一遍,版本不一致的情况一共三类,对应 Maven 的三条规则。

规则一:dependencyManagement 说了算

pom.xml 里这个依赖,一个版本号都没写:

xml 复制代码
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>

lib 里躺的是 mysql-connector-java-8.0.13.jar。版本号从哪来的?从 parent 来------spring-boot-starter-parent 2.1.0 背后的 spring-boot-dependencies 这份 BOM,把几百个常用依赖的版本全管了。

再看配置文件里的痕迹:

com.mysql.cj.jdbc.Driver 是 8.x 驱动才有的类名,serverTimezone 是 8.x 驱动要求的参数。这个项目跑的是 8.0.13 的驱动,而决定这件事的人,是写 spring-boot-dependencies 的人。

不写版本本身没错,BOM 就是干这个的。但要清楚一点:把 parent 从 2.1.0 升到 2.7.18,pom 一个字不用改,驱动、Jackson、Netty、Logback 的版本全跟着变。 升级 parent 从来不只是升一个框架,是换掉整张依赖清单。

规则二:路径最近者胜(nearest-wins)

jsqlparser 这一局就是这条规则的实例。两条链,深度 3 的赢,深度 4 的被丢弃。

被丢弃的那个不是消失了,Maven 只是不打进 classpath,在依赖树里会留一行记录。这是排查的关键证据,后面讲怎么查到这条记录。

规则三:同深度,先声明的胜;重复声明,只打一行警告

这份 pom 里,commons-lang:commons-lang:2.6 声明了两遍(L243 和 L265),一个字不差的两遍。commons-codec 也声明了两遍:第一次不写版本(被 BOM 管成 1.11),第二次显式写 1.11,注释写着"华为云使用"。

Maven 对重复声明的处理是 :用其中一个,然后在构建日志里打一行 [WARNING] duplicate declaration。

一行 warning。构建日志几千行,没人看。这两处重复声明从进仓库那天起就一直在,到项目停更也没人发现。

为什么没炸,以及为什么难发现

先说没炸的原因,如实讲。

我搜了全项目,分页代码 10 个文件,清一色 PageHelper.startPage:

java 复制代码
PageHelper.startPage(pageNum, pageSize);

mybatis-plus 在这个项目里只被当 CRUD 框架用------没配 PaginationInterceptor,没有任何地方调 selectPage,IPage 一个引用都没有。jsqlparser 3.1 的 API 主要被 mybatis-plus 的分页 count 优化用到,而那段代码在这个项目里从来没被执行过。

pagehelper 自己做分页用 1.0,正好 classpath 上就是 1.0,严丝合缝。需要 3.1 的代码路径,一步都没踩上去。

网上搜 "The following method did not exist: net.sf.jsqlparser",能搜到一大把 pagehelper 和 mybatis-plus 共存的事故记录,症状都差不多 :日常功能全好,某天一条复杂 SQL 触发分页 count 优化,抛 NoSuchMethodError。具体是哪个方法不存在,取决于那条 SQL 解析出来什么结构。

这类问题难发现,原因有四个:

  • 编译期不校验。 编译只看你 import 的类在不在,不检查运行时会加载哪个版本。jsqlparser 1.0 和 3.1 包名一样,编译两边都过。
  • 启动期不校验。 Spring 容器起来,一切正常。版本不对不会阻止启动,只会在执行到那行代码时炸。
  • 测试走不到。 测试环境的 SQL 简单,分页 count 不走优化分支,或者干脆没测那条路径。
  • 没人看产物。 所有人核对依赖都看 pom.xml,没人解压 war 包看 WEB-INF/lib。而 pom 里写的,本来就不是最终结果。

正确写法:四条铁律

铁律一:看依赖树,看打包产物,别只看 pom

bash 复制代码
mvn dependency:tree -Dverbose -Dincludes=com.github.jsqlparser

-Dverbose 是关键,被丢弃的版本会显示出来:

omitted for conflict with 1.0------谁要它、输给了谁、赢的是哪个版本,一行全交代了。每一行 omitted 背后都是一次版本冲突的选择,挨个搞清楚原因,是接手老项目的第一课。

war 包项目再加一步:解压产物,直接翻 WEB-INF/lib。那是上线时类路径上的全部真相。

铁律二:关键版本,用 dependencyManagement 锁定

xml 复制代码
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.github.jsqlparser</groupId>
            <artifactId>jsqlparser</artifactId>
            <version>3.1</version>
        </dependency>
    </dependencies>
</dependencyManagement>

dependencyManagement 优先级高于传递依赖的选择结果,写进去,所有路径上的 jsqlparser 统一成 3.1。

但锁定之前先回答一个问题:两个使用者都兼容你锁的那个版本吗?jsqlparser 1.0 到 3.1 跨了大版本,pagehelper 5.1.4 配 3.1 能不能正常工作,我没有验证过,不敢替它打包票。锁完版本,两个框架的分页功能都要回归一遍。

更根本的问题其实是:一个项目为什么同时需要 mybatis、mybatis-plus、pagehelper 三个持久层相关的 starter?这是选型层面的事,我在 Java 技术选型三条铁律 文章里写过,这里不展开。

有个细节我没完全确认 :文章里我提到,把 jsqlparser 强行锁定到 3.1 后,pagehelper 5.1.4 能不能完美兼容,我没有实际测过。有没有兄弟在项目里踩过这俩组合的坑?或者你们干脆把 pagehelper 换成 mybatis-plus 自带的分页了?评论区指点一下。

铁律三:把检查交给构建工具,别交给人的眼睛

xml 复制代码
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-enforcer-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>enforce</goal>
            </goals>
            <configuration>
                <rules>
                    <dependencyConvergence/>
                    <banDuplicatePomDependencyVersions/>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

dependencyConvergence 是最狠的一条:同一个 artifact 从多条链进来,版本必须收敛成一个,否则构建直接失败。banDuplicatePomDependencyVersions 治的是 commons-lang 写两遍那种问题,重复声明从 warning 升级成 error。

丑话说在前面:这份 pom 加上 dependencyConvergence,第一次构建肯定过不了,jsqlparser、httpclient 那一串 omitted 全会变成报错。这正是目的------把运行时才暴露的版本问题,提前到构建期暴露。修的方式就是铁律二,在dependencyManagement里逐个锁定。

铁律四:同族依赖,版本对齐

pom.xml L131 还有一处:

xml 复制代码
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-guava</artifactId>
    <version>2.9.8</version>
</dependency>

Jackson 这一族,Boot 2.1.0 的 BOM 统一管在 2.9.7。这里单独给家族里的一个成员指定了 2.9.8。lib 目录里现在的局面是:jackson-databind-2.9.7.jar 和 jackson-datatype-guava-2.9.8.jar 并存,一个家族混着两个补丁版本。

如实说,这次混版本没造成过已知的事故,Jackson 2.9.x 补丁版本之间基本兼容。但写法本身就是隐患------下次有人照这个思路给 databind 也单独指个版本,撞上一组真不兼容的组合,就是另一个 jsqlparser 故事。

同族依赖的处理只有两种:整个家族交给 BOM 管,一个版本都别写;或者整个家族显式指定同一个版本。只给家族里一个成员开小灶,是最差的选择。

自查清单

自查打卡 :在你的项目里跑一下这个命令: mvn dependency:tree -Dverbose | grep "omitted for conflict" 看看有多少个被 Maven 静默丢弃的版本?打在评论区,我看看谁的"地雷"最多。

老炮点评

pom.xml 里写的版本号,只是 Maven 选择版本时的输入之一,不是最终结果。真正决定类路径的是那三条规则:BOM、路径深度、声明顺序。

编译通过、启动成功,什么都证明不了。类路径上躺的是哪个版本,只有打包产物和依赖树说了算。

这个项目的 jsqlparser 没出事,靠的是那段代码从来没被执行。ThreadLocal + @Async 导致用户数据串号 文章里那句话放到这里一样成立:你依赖的是碰巧没触发,而不是代码本身是安全的。

最后问一句 :你们平时排查 NoSuchMethodError 或依赖冲突,是老老实实跑 mvn dependency:tree 捋,还是等线上真炸了,才被迫去解压 jar 包看版本? 评论区聊聊,我看看有多少项目是"盲盒式"上线。

下期预告

核对版本的过程中我还发现了另一件事:这个 pom 声明了 60 多个依赖,lib 目录里却躺了 150 多个 jar。

多出来的那些是哪来的?翻了一下,一个叫 swagger2markup 的依赖贡献了一大串------rhino、pegdown、json-schema-validator、libphonenumber,全跟着进了 WEB-INF/lib。一个只在本地生成离线文档的工具,凭什么上生产?

下期讲:《一个生成文档的依赖,让生产 war 包多了 30 个 jar》。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。

相关推荐
鬼手点金1 小时前
上下文:Agent 的眼睛
java·前端·人工智能·git·深度学习
171320330计算机毕设编程1 小时前
计算机毕业设计 基于Hadoop的新能源汽车发展前景分析 LW PPT
java·大数据·hadoop·python·算法·随机森林·线性回归
技术小结-李爽2 小时前
【工具】怎么在终端中启动springboot项目?
java·spring boot·后端·maven
Shaoshing2 小时前
消息发送
java·开发语言
Code_Solitude2 小时前
c语言:指针(3)+冒泡排序
java·c语言·算法
乌暮2 小时前
从 `println` 到生产级日志:Spring Boot 日志体系完全指南
java·开发语言·jvm·spring boot·后端·学习·面试
脉动数据行情2 小时前
Java OkHttp 对接国际期货批量行情 SpringBoot 定时落库实践
java·spring boot·okhttp
鶴哥只手遮天2 小时前
Spring Boot 4.1.1 深度源码解析-第 29 章 · 测试体系:`@SpringBootTest`、切片测试、Testcontainers
java·后端·开源
yunwei372 小时前
eBPF 实例教程:BPF 动态指针处理可变长度数据
linux·后端·性能优化
tltwuyulw2 小时前
Java的函数式编程(五)
java·后端