接手了一套比较有年代感的系统,计划把重构及遇到的问题写成系列文章,老树发新枝,重温一些实战技术,分享给大家。【重构02篇】:Maven项目Jar包管理机制、冲突解决。
知识背景
Jar包冲突在软件开发过程中是不可避免的,因此,如何快速定位冲突源,理解冲突导致的过程及底层原理,是每个程序员的必修课。也是提升工作效率、应对面试、在团队中脱颖而出的机会。
实践中能够直观感受到的Jar包冲突表现往往有这几种:
程序抛出java.lang.ClassNotFoundException异常;
程序抛出java.lang.NoSuchMethodError异常;
程序抛出java.lang.NoClassDefFoundError异常;
程序抛出java.lang.LinkageError异常等;
这是能够直观呈现的,当然还有隐性的异常,比如程序执行结果与预期不符等。下面,我们就分析一下Maven项目中Jar包的处理机制及引起冲突的原因。
Maven Jar包管理机制
在Maven项目中,想要了解Jar冲突必须得先了解一下Maven是如何管理的Jar包的。这涉及到Maven的一些特性,比如依赖传递、最短路径优先原则、最先声明原则等。
依赖传递原则
当在Maven项目中引入A的依赖,A的依赖通常又会引入B的jar包,B可能还会引入C的jar包。这样,当你在pom.xml文件中添加了A的依赖,Maven会自动的帮你把所有相关的依赖都添加进来。
比如,在Spring Boot项中,当引入了spring-boot-starter-web:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
1
2
3
4
此时Maven的依赖结构可能是这样的:
上面这种关系,我们就可以理解为依赖的传递性。即当一个依赖需要另外一个依赖支撑时,Maven会帮我们把相应的依赖依次添加到项目当中。
这样的好处是,使用起来就非常方便,不用自己挨个去找依赖Jar包了。坏处是会引起Jar包冲突,我们后面会讲到。
最短路径优先原则
依赖链路一:主要根据依赖的路径长短来决定引入哪个依赖(两个冲突的依赖)。
举例说明:
依赖链路一:A -> X -> Y -> Z(21.0)
依赖链路二:B -> Q -> Z(20.0)
1
2
项目中同时引入了A和B两个依赖,它们间接都引入了Z依赖,但由于B的依赖链路比较短,因此最终生效的是Z(20.0)版本。这就是最短路径优先原则。
此时如果Z的21.0版本和20.0版本区别较大,那么就会发生Jar包冲突的表现。
最先声明优先原则
如果两个依赖的路径一样,最短路径优先原则是无法进行判断的,此时需要使用最先声明优先原则,也就是说,谁的声明在前则优先选择。
举例说明:
依赖链路一:A -> X -> Z(21.0)
依赖链路二:B -> Q -> Z(20.0)
1
2
A和B最终都依赖Z,此时A的声明(pom中引入的顺序)优先于B,则针对冲突的Z会优先引入Z(21.0)。
如果Z(21.0)向下兼容Z(20.0),则不会出现Jar包冲突问题。但如果将B声明放前面,则有可能会发生Jar包冲突。
Jar包冲突产生的原因
上面讲了Maven维护Jar包的三个原则,其实每个原则会发生什么样的Jar包冲突,已经大概了解了。这里再来一个综合示例。
举例说明:
依赖链路一:A -> B -> C -> G21(guava 21.0)
依赖链路二:D -> F -> G20(guava 20.0)
1
2
假设项目中同时引入了A和D的依赖,按照依赖传递机制和默认依赖调节机制(第一:路径最近者优先;第二:第一声明优先),默认会引入G20版本的Jar包,而G21的Jar包不会被引用。
如果C中的方法使用了G21版本中的某个新方法(或类),由于Maven的处理,导致G21并未被引入。此时,程序在调用对应类时便会抛出ClassNotFoundException异常,调用对应方法时便会抛出NoSuchMethodError异常。
排查定位Jar包冲突
在高版本的IDEA中已经自带了Maven依赖管理插件,依次执行:打开pom.xml文件,在文件内右击,选择Maven,选择Show Dependencies即可查看Maven的依赖层级结构:
执行之后展示的效果便是最开始的spring-boot-web那样效果,在图中可以清楚的看到都使用了哪些依赖,它们的层级,是否有冲突的jar包等。冲突部分会用红色标出,同时标出Maven默认选择了哪个版本。
如果你的IDEA版本中默认没有Maven管理插件,也可安装Maven Helper,通过这块插件来帮你分析Jar包冲突。
安装完插件,重启之后,打开pom.xml文件,在文件下面的Dependency Analyzer视图中便可以看到Jar包冲突的结果分析:
此时,关于哪些Jar包冲突了,便一目了然。同时,可以右击冲突的Jar包,执行”Exclude“进行排除,在pom.xml中便会自动添加排除jar包的属性。
对于本地环境可以利用Maven Helper等插件来解决,但在预生产或生成环境中就没那么方便了。此时可以通过mvn命令来定位突出的细节。
执行如下mvn命令:
mvn dependency:tree -Dverbose
1
注意不要省略-Dverbose,要不然不会显示被忽略的包。
执行结果如下:
通过这种形式,也可以清晰的看出哪些Jar包发生了冲突。
如何统一Jar包依赖
像上面截图所示,如果一个项目有N多个子项目构成,项目之间可能还有依赖关系,Jar包冲突不可避免,此时可采用父pom,统一对版本进行管理,一劳永逸。
通常做法,是在parent模块的pom文件中尽可能地声明所有相关依赖Jar包的版本,并在子pom中简单引用(不再指定版本)该构件即可。
比如在父pom.xml中定义Lombok的版本:
<dependencyManagement> <dependencies> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.10</version> </dependency> </dependencies> </dependencyManagement>
在子module中便可定义如下:
<dependencies> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>
通过这种方式,所有的子module中都采用了统一的版本。
解决Jar包冲突的方法
这里基于Maven项目介绍几种场景下解决Jar冲突的方法:
Maven默认处理:采用此种方法,要牢记Maven依赖调节机制的基本原则,路径最近者优先和第一声明优先;
排除法:上面Maven Helper的实例中已经讲到,可以将冲突的Jar包在pom.xml中通过exclude来进行排除;
版本锁定法:如果项目中依赖同一Jar包的很多版本,一个个排除非常麻烦,此时可用版本锁定法,即直接明确引入指定版本的依赖。根据前面介绍Maven处理Jar包基本原则,此种方式的优先级最高。这种方法一般采用上面我们讲到的如何统一Jar包依赖的方式。
Jar包冲突的本质
上面讲了Maven对项目中Jar包冲突的解决原则和实战层面的解决方案,但并未涉及到Jar包冲突的本质。关于Jar包冲突的本质在《从Jar包冲突搞到类加载机制,就是这么霸气》一文中已经进行详细的讲解了。这里再针对其中的几个关键点进行概述一下。
Jar包冲突的本质:Java应用程序因某种因素,加载不到正确的类而导致其行为跟预期不一致。
具体分两种情况:
情况一:项目依赖了同一Jar包的多个版本,并且选错了版本;
情况二:同样的类在不同的Jar包中出现,导致JVM加载了错误的类;
情况一,也是本文重点讨论的场景,也就是说引入了多个Jar包版本,不同的Jar包版本有不同的类和方法。由于(不懂)Maven依赖树的仲裁机制导致Maven加载了错误的Jar包,从而导致Jar包冲突;
情况二,同一类在不同的Jar包中出现(上篇文章中有详细描述)。这种情况是由于JVM的同一个类加载器对于同一个类只会加载一次,现在加载一个类之后,同全限定名的类便不会进行加载,从而出现Jar包冲突的问题。
针对第二种情况,如果不是类冲突抛出了异常,你可能根本意识不到,所以就显得更为棘手。这种情况就可以采用前文所述的通过分析不同类加载器的优先级及加载路径、文件系统的文件加载顺序等进行调整来解决。
小结
除了上述的方法,还很多小技巧来排查类冲突,比如通过IDE提供的搜索功能,直接搜索抛异常的类,看看是否存在多个,是否使用的是预期的版本等。这些技巧需要在实践的过程中不断的摸索和积累。
总之,无论项目多么庞大,依赖多么复杂,只要牢记导致冲突的原因,及解决冲突的几个方式,细心分析,总会有迹可循的。看完这篇文章,实践一下,你可能就会在团队中脱颖而出,成为Jar包冲突终结者。