别再乱加exclusion了!Maven依赖冲突有妙解
线上事故引发的思考
前几天,我遇到了一个令人头疼的线上问题。系统突然报错,大量的 SQL 解析直接超时,日志里满是 JSQLParserException: Time out occurred。这可把我急坏了,赶紧着手排查。
一番艰难的排查后,发现问题出在 JSqlParser 4.6 版本上,这是一个已知的 bug,在解析复杂 SQL 时,会陷入回溯地狱,CPU 直接被打满。找到问题就好办了,解决方案也很简单,升级到 4.9 版本就可以解决。
但事情远没有这么简单。当我准备升级版本时,发现我们项目里有好几个库都依赖 JSqlParser:mybatis-plus-core 依赖 4.6 ,pagehelper 依赖 4.6 ,而我们自己的 flcloud-jdbc-cipher 要用 4.9 。这就尴尬了,同一个 jar 包,三个地方要三个版本,Maven 该怎么处理呢?相信不少小伙伴在日常开发中也遇到过类似的依赖冲突问题,今天咱们就来好好聊聊如何正确处理 Maven 依赖冲突,别再到处写 exclusion 了。
Maven 依赖仲裁机制揭秘
要解决依赖冲突,首先得了解 Maven 的依赖仲裁机制。Maven 在处理依赖时,主要遵循两个核心规则:路径最短优先和最先声明优先 。
深度决定版本?路径最短优先
路径最短优先,简单来说,就是 Maven 会选择依赖树中路径最短的那个版本。比如说,我们有一个项目Project,它依赖了A和B两个库。A库又依赖了C库的 1.0 版本,而B库依赖了D库,D库又依赖了C库的 2.0 版本 。这时候,Project对C库就有了两条依赖路径:Project -> A -> C:1.0和Project -> B -> D -> C:2.0 。很明显,第一条路径的长度是 2,第二条路径的长度是 3 。根据路径最短优先原则,Maven 最终会选择C库的 1.0 版本。
用依赖树的形式表示就是:
Plain
Project
├── A:1.0
│ └── C:1.0
└── B:1.0
└── D:1.0
└── C:2.0
在这个例子中,C库的 1.0 版本路径更短,所以它会被 Maven 选中。这就好比你要去一个地方,有两条路可以走,一条路更近,你肯定会选择更近的那条路,Maven 也是这么想的。
声明顺序也关键?最先声明优先
当依赖路径长度相同时,Maven 就会启用第二个规则:最先声明优先。这个规则是说,在pom.xml文件中,哪个依赖声明在前面,就优先使用哪个依赖的版本。比如说,还是上面那个Project,现在A库和B库都直接依赖C库,A库依赖C库的 1.0 版本,B库依赖C库的 2.0 版本 ,并且在pom.xml中,A库的依赖声明在B库之前。这时候,虽然两条依赖路径长度都是 1,但是由于A库的依赖声明在前,Maven 就会选择C库的 1.0 版本。
依赖树表示如下:
Plain
Project
├── A:1.0
│ └── C:1.0
└── B:1.0
└── C:2.0
在pom.xml中的声明顺序如下:
xml
<dependencies>
<!-- 先声明A -->
<dependency>
<groupId>com.example</groupId>
<artifactId>A</artifactId>
<version>1.0</version>
</dependency>
<!-- 后声明B -->
<dependency>
<groupId>com.example</groupId>
<artifactId>B</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
这样,Maven 就会根据声明顺序,选择C库的 1.0 版本。这就像是排队买票,谁排在前面谁先买,Maven 在选择依赖版本时也是这个道理。
最初的 "简单粗暴" 方案
大量使用 exclusion 的操作
当时遇到JSqlParser版本冲突问题,我第一时间想到的就是在pom.xml文件中大量使用exclusion标签来排除不需要的版本。在mybatis-plus-boot-starter的依赖中添加exclusion:
xml
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
<exclusions>
<exclusion>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
</exclusion>
</exclusions>
</dependency>
同样,在pagehelper-spring-boot-starter的依赖中也进行类似的操作:
xml
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.0</version>
<exclusions>
<exclusion>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
</exclusion>
</exclusions>
</dependency>
这样做的目的很明确,就是想把mybatis-plus-core和pagehelper引入的JSqlParser 4.6 版本排除掉,让项目最终使用我们自己flcloud-jdbc-cipher需要的 4.9 版本。
该方案存在的问题
这种方法虽然看似解决了当下的问题,但很快我就发现它存在诸多弊端。项目中有多个依赖都需要处理,这意味着我要在多个地方添加exclusion标签 ,整个pom.xml文件变得冗长繁琐,到处都是exclusion的配置,可读性大大降低。而且这种手动添加exclusion的方式非常容易遗漏,只要有一个地方忘记排除,就可能导致依赖冲突再次出现 ,就像一颗隐藏的定时炸弹,随时可能引爆项目。
对于新加入项目的同事来说,他们很难理解这些exclusion的含义和作用。如果他们在不知情的情况下,添加了一个新的依赖,而这个依赖又引入了旧版本的JSqlParser ,那么之前好不容易解决的依赖冲突问题就会再次出现,整个项目又会陷入混乱。所以,这种大量使用exclusion的方案并不是一个可持续的、优雅的解决方案,我们需要寻找更好的方法来处理依赖冲突。
更优雅的解决方案
dependencyManagement 锁版本
后来我发现了一种更优雅的解决方案,那就是在父pom的dependencyManagement中锁定版本。只需要在父pom.xml中添加如下配置:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>4.9</version>
</dependency>
</dependencies>
</dependencyManagement>
就这么简单的几行配置,加上之后,不管子模块的依赖树里jsqlparser出现多少次、原本写的是什么版本,最终都会被统一成 4.9 版本。这就像是给所有子模块的jsqlparser依赖定了一个 "规矩",大家都得按照这个版本来。
用依赖树图示表示就是:
Plain
Parent Pom
└── dependencyManagement
└── com.github.jsqlparser:jsqlparser:4.9
Child Module 1 (mybatis - plus - core)
├── mybatis - plus - core
│ └── com.github.jsqlparser:jsqlparser:4.6 (被覆盖为4.9)
Child Module 2 (pagehelper)
├── pagehelper
│ └── com.github.jsqlparser:jsqlparser:4.6 (被覆盖为4.9)
Child Module 3 (flcloud - jdbc - cipher)
├── flcloud - jdbc - cipher
│ └── com.github.jsqlparser:jsqlparser:4.9
从这个依赖树中可以清晰地看到,即使mybatis - plus - core和pagehelper原本依赖的是 4.6 版本,但在dependencyManagement的作用下,最终都被统一成了 4.9 版本。
优先级背后的原理
可能有些小伙伴会好奇,为什么dependencyManagement的优先级最高呢?我之前也没太搞明白,后来翻了下 Maven 的文档,才弄清楚其中的原理。dependencyManagement的作用就是 "预定义" 版本号,它不会真的引入依赖,但一旦这个依赖在依赖树中出现,就会强制使用预定义的版本 。所以它的优先级比什么路径深度、声明顺序都高,直接一锤定音 。
根据 Maven 的官方文档描述,在依赖解析过程中,Maven 会优先检查dependencyManagement中定义的版本。如果在dependencyManagement中找到了对应的依赖版本,就会使用这个版本,而忽略其他地方声明的版本 。这就好比在一个团队中,dependencyManagement就像是一个 "权威的领导者",它制定的规则(版本号),其他成员(依赖)都必须遵守。哪怕其他依赖在路径深度上有优势,或者声明顺序在前,在dependencyManagement面前,都得按照它规定的版本来。所以,通过dependencyManagement来锁定版本,能够有效地避免依赖冲突,让项目的依赖管理更加可控、更加优雅。
解决冲突后的验证工作
验证各库在新版本下的兼容性
在通过dependencyManagement锁定版本解决依赖冲突后,可不能掉以轻心,还有很重要的一步 ------ 验证各库在新版本下的兼容性 。不同版本的库,其 API 和功能可能会有变化,如果不进行验证,很可能会引入新的问题。
就拿之前的JSqlParser版本升级来说,从 4.6 升级到 4.9 ,虽然解决了 SQL 解析超时的问题,但也可能带来其他问题。因为新版本的JSqlParser在 API 上可能有一些改动 ,如果我们的代码中使用了一些在 4.9 版本中被废弃或者改动的 API,那么项目在运行时就可能会报错。比如,4.6 版本中解析 SQL 的某个方法签名是parseSql(String sql) ,到了 4.9 版本可能变成了parse(String sql, SqlParserConfiguration config) ,如果我们的代码没有及时更新,就会出现方法找不到的错误。
为了避免这种情况,我们需要进行充分的兼容性验证。最好的办法就是跑单测,通过编写一系列的单元测试用例,覆盖各种可能的场景,来验证新版本的库是否能正常工作。对于JSqlParser,我们可以编写测试用例来测试不同类型的 SQL 语句(如SELECT、INSERT、UPDATE、DELETE等)是否能正确解析,以及在复杂 SQL 场景下是否还会出现超时问题。如果单测通过,那么说明新版本的库在当前项目中基本是兼容的;如果单测失败,就需要仔细检查错误信息,看看是哪里出了问题,可能需要调整代码或者寻找其他解决方案。
查看依赖树的实用命令
在解决依赖冲突的过程中,查看依赖树是非常重要的一步。通过查看依赖树,我们可以清楚地了解项目中各个依赖之间的关系,以及每个依赖的版本,从而更好地定位和解决冲突。Maven 提供了一些非常实用的命令来查看依赖树。
mvn dependency:tree:这个命令会输出项目的完整依赖树,显示所有依赖的层级关系和版本信息。在解决JSqlParser依赖冲突时,使用这个命令可以看到mybatis-plus-core、pagehelper和flcloud-jdbc-cipher对JSqlParser的依赖路径和版本,如下所示:
Plain
[INFO] com.example:my-project:jar:1.0.0
[INFO] +- com.baomidou:mybatis-plus-boot-starter:jar:3.5.3.1:compile
[INFO] | \- com.baomidou:mybatis-plus-core:jar:3.5.3.1:compile
[INFO] | \- com.github.jsqlparser:jsqlparser:jar:4.6:compile
[INFO] +- com.github.pagehelper:pagehelper-spring-boot-starter:jar:2.1.0:compile
[INFO] | \- com.github.pagehelper:pagehelper:jar:5.3.2:compile
[INFO] | \- com.github.jsqlparser:jsqlparser:jar:4.6:compile
[INFO] \- com.example:flcloud-jdbc-cipher:jar:1.0.0:compile
[INFO] \- com.github.jsqlparser:jsqlparser:jar:4.9:compile
从这个依赖树中,我们可以清晰地看到各个依赖之间的关系,以及JSqlParser不同版本的依赖路径。
mvn dependency:tree -Dincludes=groupId:artifactId:这个命令可以用来查看特定依赖的引入路径。比如,我们想查看JSqlParser是被哪些依赖引入的,就可以使用mvn dependency:tree -Dincludes=com.github.jsqlparser:jsqlparser ,这样就能更专注地查看与JSqlParser相关的依赖信息,而不会被其他无关的依赖干扰。
mvn dependency:tree -Dverbose:这个命令会输出更详细的依赖信息,包括依赖冲突、重复依赖、被排除的依赖等。在排查复杂的依赖冲突问题时,这个命令非常有用,它可以帮助我们发现一些隐藏的问题,比如某个依赖被意外排除导致的功能异常。
IDEA 可视化查看依赖冲突
除了使用 Maven 命令行工具查看依赖树和冲突,在 IDEA 中也有非常直观的方式来进行依赖分析。在 IDEA 中打开项目的pom.xml文件,然后在底部的标签栏中找到 "Dependency Analyzer" 标签页 。
点击进入 "Dependency Analyzer" 标签页后,IDEA 会以图形化的方式展示项目的依赖树,并且会用不同的颜色和标识来突出显示依赖冲突的部分。比如,当存在版本冲突的依赖时,冲突的依赖会被用红色字体标注出来,并且会显示出不同版本的依赖路径 。通过这种可视化的方式,我们可以非常直观地看到哪些依赖存在冲突,以及冲突的具体情况 ,相比命令行工具,更加方便快捷,尤其是对于复杂的项目依赖关系,能大大提高我们排查和解决问题的效率。而且,在这个界面中,我们还可以直接右键点击依赖,选择排除依赖或者查看依赖详情等操作,进一步简化了依赖管理的流程。
总结与互动
处理 Maven 依赖冲突时,到处写exclusion虽然能解决一时之需,但从长远来看,并不是一个好的选择。而使用dependencyManagement来统一管理依赖版本,不仅能让pom.xml文件更加简洁易读,还能从根本上避免依赖冲突的发生,让项目的依赖管理更加稳定和可靠。
希望今天分享的内容能对大家有所帮助。如果你在处理 Maven 依赖冲突时,有过哪些难忘的经历,或者有自己独特的处理方法,欢迎在评论区留言分享,咱们一起交流学习,让开发之路更加顺畅 。