前言(maven依赖特性)
依赖机制是
Maven
最为用户熟知的特性之一,同时也是Maven
所擅长的领域之一。单个项目的依赖管理并不难,但是当你面对包含数百个模块的多模块项目和应用时,Maven
能帮你保证项目的高度控制力和稳定性。
依赖传递性
传递性依赖是Maven2.0
的新特性。假设你的项目依赖于一个库,而这个库又依赖于其他库。你不必自己去找出所有这些依赖,你只需要加上你直接依赖的库,Maven
会隐式的把这些库间接依赖的库也加入到你的项目中。这个特性是靠解析从远程仓库中获取的依赖库的项目文件实现的。一般的,这些项目的所有依赖都会加入到项目中,或者从父项目继承,或者通过传递性依赖。
传递性依赖的嵌套深度没有任何限制,只是在出现循环依赖时会报错。
依赖调解
当项目中出现多个版本构件依赖的情形,依赖调解决定最终应该使用哪个版本。目前,Maven 2.0
只支持“短路径优先”原则,意思是项目会选择依赖关系树中路径最短的版本作为依赖。当然,你也可以在项目POM
文件中显示指定使用哪个版本。值得注意的是,在Maven2.0.8
及之前的版本中,当两个版本的依赖路径长度一致时,哪个依赖会被使用是不确定的。不过从Maven 2.0.9
开始,POM
中依赖声明的顺序决定了哪个版本会被使用,也叫作 第一声明原则。
短路径优先意味着项目依赖关系树中路径最短的版本会被使用。例如,假设A、B、C之间的依赖关系是A->B->C->D(2.0)和A->E->(D1.0),那么D(1.0)会被使用,因为A通过E到D的路径更短。但如果你想要强制使用D(2.0),那你也可以在A中显式声明对D(2.0)的依赖。
依赖管理
在出现传递性依赖或者没有指定版本时,项目作者可以通过依赖管理直接指定模块版本。之前的章节说过,由于传递性依赖,尽管某个依赖没有被A直接指定,但也会被引入。相反的,A也可以将D加入<dependencyManagement>
元素中,并在D可能被引用时决定D的版本号,在本文末尾会赋予依赖范围的详细说明,可以继续往下看。
依赖范围
你可以指定只在当前编译范围内包含合适的依赖。 下面会介绍更多相关的细节。
排除依赖
如果项目X依赖于项目Y,项目Y又依赖项目Z,项目X的所有者可以使用exclusion
元素来显式排除项目Z。
依赖冲突的来源
Maven
是如今Java
工程中最流行的构建工具之一,而工程所依赖的库的数量也会随着工程规模和复杂度的上升逐步增加。
足够多的依赖项也会给工程带来一些难以发现的依赖冲突,时刻威胁着系统运行的稳定性,也给工程今后的迭代,架构的升级带来不小的麻烦。
那么,何为依赖冲突?有个最直接的现象,即在实际开发过程中,或多或少要引入一些依赖,若在引入依赖后工程无法启动了,或者之前都正常运行的逻辑却在某些场景下突然报错了等等,依赖冲突可能就是罪魁祸首。
有时候我们在开发过程中需要引入某个依赖pom包的时候,原本项目启动是正常的,引入后启动console
控制台就打印一些类似:java.lang.NoclassDefFoundError
的报错,这就是典型的依赖冲突场景,举个例子,创建新的maven project
,在你的项目工程中引入mybatis-plus-boot-starter
,版本为:3.5.3
,
代码生成依赖:mybatis-plus-generator
,版本为3.4.1
,本来想直接运行生成项目对应代码,结果发现console报错输出了下面的信息:
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
2024-01-05T14:09:58.052+08:00 ERROR 25584 --- [ main] o.s.boot.SpringApplication : Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'sqlSessionFactory' defined in class path resource [com/baomidou/mybatisplus/autoconfigure/MybatisPlusAutoConfiguration.class]: Failed to instantiate [org.apache.ibatis.session.SqlSessionFactory]: Factory method 'sqlSessionFactory' threw exception with message: org/springframework/core/NestedIOException
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:655) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:643) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1334) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1164) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:561) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:521) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:325) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:323) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:975) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:960) ~[spring-context-6.1.2.jar:6.1.2]
at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:625) ~[spring-context-6.1.2.jar:6.1.2]
at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) ~[spring-boot-3.2.1.jar:3.2.1]
at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:762) ~[spring-boot-3.2.1.jar:3.2.1]
at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:464) ~[spring-boot-3.2.1.jar:3.2.1]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:334) ~[spring-boot-3.2.1.jar:3.2.1]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1358) ~[spring-boot-3.2.1.jar:3.2.1]
at org.springframework.boot.SpringApplication.run(SpringApplication.java:1347) ~[spring-boot-3.2.1.jar:3.2.1]
at com.austin.mvnproject.MavenProjectApplication.main(MavenProjectApplication.java:13) ~[classes/:na]
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.apache.ibatis.session.SqlSessionFactory]: Factory method 'sqlSessionFactory' threw exception with message: org/springframework/core/NestedIOException
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:177) ~[spring-beans-6.1.2.jar:6.1.2]
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:651) ~[spring-beans-6.1.2.jar:6.1.2]
... 19 common frames omitted
Caused by: java.lang.NoClassDefFoundError: org/springframework/core/NestedIOException
at com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration.sqlSessionFactory(MybatisPlusAutoConfiguration.java:167) ~[mybatis-plus-boot-starter-3.5.3.jar:3.5.3]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:140) ~[spring-beans-6.1.2.jar:6.1.2]
... 20 common frames omitted
Caused by: java.lang.ClassNotFoundException: org.springframework.core.NestedIOException
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641) ~[na:na]
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188) ~[na:na]
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:520) ~[na:na]
... 26 common frames omitted
Process finished with exit code 1
Caused by: java.lang.ClassNotFoundException: org.springframework.core.NestedIOException
,这是典型的依赖冲突问题,什么?你说编译没问题?照着文档写的?还能打包?TOO NAIVE...
你以为你引了这个版本的依赖,工程里跑的就是这个版本的吗?
其实,这个场景还相对较简单,因为对于使用方来说,是知道自己引了可能有相同功能的依赖,并且在工程启动的时候便会有对应报错提示。但有时候,你并不知道工程里的依赖有多少交集,而且工程也是正常启动,往往在某个天时地利人和,服务突然就出现了不明所以的错误。
那么,为什么会出现这样的情况?Maven
对于同一个依赖同时引入多种版本是如何处理的?这些问题我们先放一放,本文将会从实践出发,讲解从发现和分析依赖关系到逐步讲解依赖的核心机制,以及最后在开发新老系统的时候给出如何避免依赖冲突的操作建议,先来介绍下在实际开发过程中,如何去分析依赖关系。
Maven依赖可视化
使用IDEA内置工具展示
稍具规模的一个Java Web
工程,依赖的包就多达上百个,所以,你的服务依赖关系应该是呈树状的。通过 Maven
内置命令,或者第三方插件均可以帮助你对工程依赖进行分析。
打开pom.xml文件,右键选择maven -> Show Dependencies 查看依赖关系图,可以看到:
将图形设置为实际尺寸或放大,可以看到每个红线的指向,即冲突的依赖,但这样的红线,表示存在依赖冲突的包,项目分别引入了3.9版本的apache-poi
包,3.2.1版本的easyexcel
,然后easyexcel
本身是默认包含poi
包的,他的版本为4.1.2,这就是所谓的依赖冲突。
那么,我姑且用"依赖健康度"来衡量冲突的严重程度吧,虽然业界暂时没有类似手机清理管家那种扫描服务依赖健康度的工具和算法,但很显然,基本可以认为,红线越多,冲突便越严重。
除非你非常清楚每个冲突点到底有没有影响,而不是凭直觉来判断,否则每一个冲突都有可能会演变为下家公司做兄弟服务雪崩的导火索。
使用IDEA插件分析依赖关系
IDEA的插件市场里有众多好用的生产力工具,对于Maven
的依赖关系的分析与排查的需求,推荐使用Maven Helper
插件来实现。
第一步:搜索Maven Helper
插件并安装,打开IDEA的Settings
,搜索Plugins
导航栏,搜索maven helper插件,如下图,点击install
即可完成安装,安装下载完重启一下IDEA即可。
第二步:使用插件分析依赖
进入任意的pom文件,IDEA编辑框底部tab会多出Dependency Analyzer
选项卡。
点击次Tab选项卡,IDEA会出现如下UI,All Dependencies as Tree
会将所有依赖以树形结构展示。
Conflicts会展示当前模块有冲突的依赖:
而当手动修改了pom.xml,顶部会提示"⚠️ Refresh UI"来刷新依赖图。
第三步:使用插件解决冲突
当发现有冲突的依赖项,可以右键Jump to Source
快捷跳转到pom.xml相应位置:
如果点击Exclude
,则会将这个依赖排除。以上图为例,当点击Exclude
后,当前pom的变化如下:
排除前:
<!-- EasyExcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.2.1</version>
</dependency>
排除后:
<!-- EasyExcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.2.1</version>
<exclusions>
<exclusion>
<artifactId>poi</artifactId>
<groupId>org.apache.poi</groupId>
</exclusion>
</exclusions>
</dependency>
需要注意的是,这种方式解决依赖冲突是下下策。不到万不得已,不太建议使用此方式去排除。这不仅会让混乱的依赖关系雪上加霜,而且也违背了依赖提供者本身的用意。在后文我们会介绍如何最大限度避免依赖冲突,从源头解决问题。
依赖的核心机制
依赖传递性,Maven通过自动包含可传递的依赖关系,避免了发现和指定您自己的依赖关系所需的库的需要。比如你的项目工程A依赖了B,而B有依赖C,它们的关系图如下:
A -> B -> C
在早期没有诸如Maven
这种构建工具之前,你需要手动找到B,C两个依赖的jar包,然后放到工程目录中,就像这样:
A
├── lib
│ ├── B.jar
│ └── C.jar
└── src
└── main
└── java
而有了Maven ,只需要创建个pom, 声明依赖关系即可:
A
├── pom.xml
└── src
└── main
└── java
然后在A项目工程的pom.xml中声明对B的依赖(假定B已声明依赖了C):
<dependency>
<groupId>com.austin</groupId>
<artifactId>B</artifactId>
<version>1.0.0</version>
</dependency>
此外,Maven
为了解决复杂的依赖冲突关系,他秉承的重要特性就是:就近原则(nearest definition),意味着所使用的版本将是依赖关系树中与你的项目最接近的版本。就近原则保证了工程的依赖树中,同一个依赖存在多个不同版本的时候,应该选择哪一个版本使用。
何为就近原则?
在Maven
的官方文档中,给出了如下依赖树的例子作为解释:
A
├── B
│ └── C
│ └── D 2.0
└── E
└── D 1.0
ABCD四个模块的依赖关系是:A -> B -> C -> D(2.0) 和 A -> E -> D(1.0),显然后者A到D模块的依赖路径是最短的,所以当构建A模块时,会使用1.0版本的D模块而不是2.0。
但如果想使用2.0模块的D怎么办?可以显式地在A模块中声明D模块的版本:
A
├── B
│ └── C
│ └── D 2.0
├── E
│ └── D 1.0
│
└── D 2.0 最终使用次版本
路径1:A -> B -> C -> D
路径2:A -> E -> D
路径3:A -> D(使用此项最短路径)
依赖的管理与控制
Maven
作为管理依赖的一把手,对依赖的控制也灵活多变。官方提供了依赖管理机制,而为了控制依赖的引入时机,也规定了依赖的作用域,以及可选依赖项。最后,有时候不得不使用人工干预的方式,来解决依赖冲突,即依赖排除。
(a)依赖管理
依赖管理即dependencyManagement
, 主要用来声明依赖库的版本,常用于父子类型的工程中。一个最基本的做法是,在父工程里声明dependencyManagement
标签,里面声明子模块需要的依赖库版本,在子模块中引入对应的不带版本声明的依赖库。
父模块声明依赖管理:
<name>pangu</name>
<description>
盘古是中国神话中开天辟地的创世神,是一种可以破碎虚空、撕裂混沌的力量,借用盘古之名寓意给项目赋予永存的生命力!
</description>
<properties>
<!-- 声明依赖版本 -->
<pangu.version>0.0.1-SNAPSHOT</pangu.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<pangu.version>0.0.1-SNAPSHOT</pangu.version>
<spring-boot.version>2.6.8</spring-boot.version>
<spring-cloud.version>2021.0.1</spring-cloud.version>
<spring-cloud-alibaba.version>2021.0.1.0</spring-cloud-alibaba.version>
<commons-lang3.version>3.12.0</commons-lang3.version>
<lombok.version>1.18.30</lombok.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<rocketmq.version>5.1.4</rocketmq.version>
<kafka.version>3.6.0</kafka.version>
<mybatis.version>2.3.1</mybatis.version>
<mybatis-plus.version>3.5.4</mybatis-plus.version>
<jackson.version>2.14.2</jackson.version>
</properties>
<modules>
<module>pangu-start</module>
<module>pangu-infrastructure</module>
<module>pangu-interfaces</module>
<module>pangu-domain</module>
<module>pangu-application</module>
<module>pangu-message</module>
<module>pangu-dependencies</module>
</modules>
<dependencyManagement>
<dependencies>
<!-- 依赖库 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
子模块中就无需指定版本了:
<!-- openfeign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
这样做的好处是在同一个工程内部,即便有个依赖在各个模块中声明了不同版本,但在实际使用过程中,如果其他模块引入了包含这个依赖的模块,那么版本号依然是以你在父模块中声明的版本为准。
(b)依赖的作用域
依赖作用域可以更好控制依赖什么时候会被引入,官方文档中也介绍了6种作用域:
-
compile: 默认作用域,编译打包工程后该作用域的依赖一并会打包进去。
-
provided:编译和运行均会使用到,一般这种依赖最终由外部来提供,例如工程打包为 war 包后部署至 Tomcat 容器,而 Tomcat 容器是提供了 servlet-api 依赖的, 所以工程里的这个依赖作用域是 provided, 这是为了避免打包的时候将此类型的库打包进类目录中,造成重复引入而引起的依赖冲突。
-
runtime: 只在运行期使用,例如某个具体的数据库连接驱动,在实际代码开发过程中是面向底层接口来使用,直接使用具体某个驱动也是采用反射或者 SPI 的方式。其实就是为了避免干扰动态加载相关依赖的逻辑。
-
test: 测试期间才会使用的依赖
-
system: 声明为此作用域的依赖必须显式指定 jar 包路径。
-
import: 此作用域只支持类型为 pom 的依赖且只能在 dependencyManagement 中使用。声明为此作用域的依赖将会被这个依赖里声明的 dependencyManagement 里的依赖列表所替换。
(c)依赖排除
依赖排除通常用于解决那些由于客观原因造成的依赖冲突,例如有如下模块依赖关系:
A
├── B
│ └── C
│ └── D 2.0
└── E
└── D 1.0
按就近原则,此时构建A模块时,依赖的D模块的版本为1.0,这可能会违背初衷,想使用高版本的依赖。如果B和E模块均为第三方模块,自己无权更改 pom文件,则需要使用依赖排除:
<dependency>
<groupId>com.austin.rpc</groupId>
<artifactId>E</artifactId>
<exclusions>
<exclusion>
<groupId>com.austin.domain</groupId>
<artifactId>D</artifactId>
</exclusion>
</exclusions>
</dependency>
(d)可选依赖
可选依赖即依赖声明里,标签 "optional" 为 "true" 依赖。
考虑有这样的依赖关系:A -> B -> C(optional)。
A依赖B, B又依赖C, 但C在B中被声明为可选依赖,则A模块的依赖仅包含B模块,不会包含B模块里的可选依赖C。此时, A模块可显式指定依赖C模块,以保证A能正常工作。
A
├── B
│ └── C(optional)
└── C 显式依赖 C
对于B的开发者来说,对C的依赖控制权交给了使用方。
(e)依赖陷阱
正是由于这些核心的依赖机制,作为开发者其实还是会难免会采坑的。
以下例举出一些情形,以防采坑:
(1)单个模块中多次引入同个依赖,且均声明了版本号。
笔者之前在整理某个服务的依赖时发现有的模块中居然对一个依赖声明了多次,而这个依赖未使用父模块依赖管理,版本号也不一样。
且先不论规范,在这样的情形下,按定义顺序,后定义的依赖生效。
A
├── C(2.0)
└── C(1.0) 此项生效
(2)工程内父pom中声明了多个bom依赖。
这样难免会出现这些多个bom里存在相同的依赖但版本不一样的情况:
A BOM:
<dependencyManagement>
<dependencies>
...
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
<version>1.18.30</version>
</dependency>
...
</dependencies>
</dependencyManagement>
B BOM:
<dependencyManagement>
<dependencies>
...
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
<version>1.18.20</version>
</dependency>
...
</dependencies>
</dependencyManagement>
父模块parent BOM:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.austin.rpc</groupId>
<artifactId>A</artifactId>
<version>1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.ausitn.domain</groupId>
<artifactId>B</artifactId>
<version>1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
...
</dependencies>
</dependencyManagement>
子模块C:
...
<parent>
<groupId>com.pangu</groupId>
<artifactId>pangu</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
</project>
此时,生效的版本为A BOM
中定义的lombok
版本为:1.18.30,所以,多个bom
中定义了相同依赖的不同版本,最先声明的bom
会覆盖后续声明的。
(3)多个模块引入同个依赖。
现有如下依赖关系:
A
├── B
│ └── C(1.0) 此项生效
└── D
└── C(2.0)
A模块声明了对B和D两个模块的依赖,而B,D两个模块又同时依赖了C模块,但版本不一样。
此时A对C的间接依赖最短路径有两条,那么C的依赖版本取决于B,D模块的引入顺序,由于B的声明顺序优先于D,所以会采用B模块里面的1.0版本的 C。
如何避免依赖冲突?
了解现有的服务依赖
对于一个技术债务堆积得比较深的服务来说,了解每个冲突需要耗费大量的精力,这里我可以提供以下几个方面重点操作的建议:
(1)重点关注核心链路所在的模块
核心链路的重要性不容置疑,分析这些链路所在的模块的依赖,将有助于提升核心链路的稳定性。
(2)重点关注网络、序列化相关的依赖库
从经验上看,很多依赖冲突都源自于以下几类:
- ①本地序列化/反序列化高度相关的依赖。
如:jackson,Gson,fastjson
这种依赖库一般会被高频调用,可能有些时候引入的版本并不是预期内但也能正常跑通,这只能表示这个依赖库的版本兼容性做的很优秀,并不能说明没有冲突。
例如,在jackson-core
这个库的JsonGenerator
这个抽象类中,于2020 年 4 月发布的2.11系列的版本增加了writeNumber(char[],int,int)
方法:
public void writeNumber(char[] encodedValueBuffer, int offset, int length) throws IOException {
writeNumber(new String(encodedValueBuffer, offset, length));
}
而在此之前是没有的。
假如项目工程是2019年底创建,并且依赖了版本为 2.9.10的jackson-core
, 到2021年,由于需求的迭代,增加了很多新的依赖,这些新的依赖如果使用的jackson-core是2.11之后的版本,并且使用了诸如上面这个只在后续版本中存在的方法,则很有可能因为依赖冲突,因为工程真正还是使用的 2.9.10版本的库。
这样会出现如下错误:
java.lang.AbstractMethodError: com/fasterxml/jackson/core/JsonGenerator.writeNumber([CII)V
②和网络调用序列化反序列化相关的如 Protobuf,Thrift,Hessian等等。
这些依赖库在分布式系统中也是会被高频调用的,不容忽视的点在于,分布式系统的网络调用普遍具有天生的复杂度,参数边界一般更广,所以也很难规避掉因依赖冲突导致的运行时异常。
③RPC,Data类如Feign,Dubbo,gRPC,JDBC,Redis相关的依赖库。
举个实际场景,在实际Web项目工程中,我们一般会使用Redis
,而且基本上都使用的spring-boot-starter-data-redis
。整个2019年,Spring Cloud
生态最新稳定的都还是G系列的版本,对应的Spring Boot
版本是2.1.x,其中使用的lettuce-core
版本最高为5.1.8.RELEASE
(注:在2018年3月发布springboot
2.x之后,默认的连接客户端已经由Jedis
替换为了Lettuce
)。
然而到了2019年底,随着Spring Cloud Hoxton
第一个正式的RELEASE
版本发布,SpringBoot
2.2.x系列也普及了起来,依赖的 lettuce-core
版本也到了5.2以上,相对于之前的版本,Lettuce
研发团队来了个一键三连:加了很多新功能,修了很多BUG
, 增强了很多老特性。但是,他并不向后兼容,或者说并不完全向后兼容。例如,在5.1版本中新增的Tracing
接口用来监控跟踪Redis
命令的执行, 而在5.2的版本中又增加了一个方法:
/**
* Returns {@code true} if tags for {@link Tracer.Span}s should include the command arguments.
*
* @return {@code true} if tags for {@link Tracer.Span}s should include the command arguments.
* @since 5.2
*/
boolean includeCommandArgsInSpanTags();
这个接口增加的方法并不是默认方法(在 Java8中使用default关键字声明的接口方法),这也就意味着实现Tracing接口的类必须要实现该方法。但如果不实现呢?我引的别人的库,它不实现我咋办?那么等待着你的一定会是如下错误,且这个错误会在运行时出现!
java.lang.AbstractMethodError: com.xx.xx.monitor.instrument.redis.lettuce5x.LettuceTracing.includeCommandArgsInSpanTags()Z
除非,降级回到之前兼容的版本,否则不能用基于此版本的Tracing接口做封装的依赖库了。
(3)养成依赖管理好习惯
当P0出现的时候,团队内没有一个人是无辜的,避免依赖冲突,应管理、技术两手抓。技术人员需要有基本的职业素养,不能图一时快活而给线上稳定性埋下罪恶的种子,管理者也需严格落地并执行相关服务依赖治理相关措施。
前面章节也提到过,Maven提供了很好的依赖管理机制,借助这个机制,形成一种规范,能极大避免因依赖问题引起的冲突。这个基本原则是,在父模块中声明工程所需要的依赖项groupId,artifactId和version, 在子模块中只需要声明groupId和artifactId就可以了。 具体例子可以参考上一章节中依赖管理介绍。
(4)定期对工程依赖进行分析
Maven也提供了命令行工具来对工程进行依赖分析,从而适当调整依赖的关系,尽可能避免后续迭代过程中依赖逻辑混乱和冲突的问题。
mvn dependency:analyze
这个命令会列出使用了但是未定义的依赖和未使用但是已定义的依赖。
[WARNING] Used undeclared dependencies found:
[WARNING] javax.annotation:javax.annotation-api:jar:1.3.2:compile
[WARNING] org.springframework.boot:spring-boot:jar:2.1.13.RELEASE:compile
[WARNING] org.springframework.boot:spring-boot-autoconfigure:jar:2.1.13.RELEASE:compile
[WARNING] org.springframework:spring-web:jar:5.1.14.RELEASE:compile
[WARNING] org.springframework:spring-context:jar:5.1.14.RELEASE:compile
[WARNING] org.slf4j:slf4j-api:jar:1.7.29:compile
[WARNING] Unused declared dependencies found:
[WARNING] org.springframework.boot:spring-boot-starter-actuator:jar:2.1.13.RELEASE:compile
[WARNING] org.springframework.boot:spring-boot-starter-web:jar:2.1.13.RELEASE:compile
[WARNING] org.springframework.boot:spring-boot-starter-undertow:jar:2.1.13.RELEASE:compile
Used undeclared dependencies:已经使用了但是未定义的依赖。此类依赖一般是由依赖传递机制引入进来,在代码中也直接使用过。
这些依赖可能会因客观因素的变更而变更,包括依赖版本的变更甚至直接被删除。例如存在以下依赖关系:
A -> B -> C
此时,由于传递依赖机制, A模块会同时包含B和C两个依赖项,在A模块中使用C模块的ClassA
是没有问题的。之后,B模块由于安全升级,将C模块版本也进行了升级。
A -> B1 -> C1
若C模块的Class A
在这次升级中发生了变更,例如类访问标识符不再是public,或者某个方法,字段被移除。当然这种情况一般会在编译期间便会有错误发生,但如果某个类或方法是基于动态代理,反射的方式来调用,在编译期有可能不会出错,只有在实际运行期间才会出现运行时异常。
Unused declared dependencies
:未使用但引入的依赖。此类依赖并未直接在代码中使用,也不代表运行期间没有使用。仅作为删除未使用的依赖的一项参考。
给模块开发者的建议
作为一个合格的开发者,要时刻牢记,你开发的模块有可能被全世界的开发者所使用,并承担着亿级流量中的某个环节的重要职责。
这里并不介绍架构应该如何去设计,因为也没有办法抛开体量或受众人群来去衡量架构的好坏。这里只从避免依赖冲突的角度上,给出如下建议:
1)树立清晰的依赖边界。
要想好当前这个模块主要负责解决什么问题,弄清楚要做什么,避免单个模块包含多种功能。对于开发过程中使用了BOM作为parent
的子模块,尽可能在发布模块时使用flatten
插件将parent
的声明去除,避免无关的依赖通过依赖传递机制被引入到使用方的模块。具体插件使用文档参考后文链接。
2)适当扩大自己的格局,分清主次关系。
在引入一个依赖库时,要仔细考虑这个依赖的使用范围,以及使用方使用你的模块时按照标准是否也一定会直接依赖你引入的依赖。有个典型的例子就是:
假设你开发了一个基于lettuce-redis
类库的增强版,固然这个模块要引入lettuce-core
这个官方依赖,但这个依赖有没有必要参与到依赖传递机制呢?
很显然,你开发的模块并不是使用方使用redis而引入的主要依赖,但使用方和你的模块均需要这个主要的redis
相关依赖,而lettuce-core
正是这个主要依赖,所以它依赖控制权要交给使用方,因此自己开发的增强版模块,需要声明此依赖为可选依赖。
需要注意的是,将那些使用方与自己均会使用的主要模块声明为可选依赖项,是否就能避免因依赖而引的事故呢?
显然,答案是否定的。
由于将依赖控制权交给了使用方,这就不可避免地会造成使用方引入的依赖版本与开发者使用的版本不一致问题。因此,还需要考虑大部分使用方的框架环境大致是在什么范围,尽可能减少因版本差异带来的问题。
3)移花接木
当你开发的模块不得不引入某个具体版本的依赖,但同时也考虑到使用方也会极大可能引入这个依赖时,也可以采取“移花接木”之术。具体表现在对引入依赖库的所有类的包名进行重命名,然后将这些修改了包名的类与模块本身的代码一起打包,最后在打包后的模块的类目录中会存在已经对包名重命名的依赖库字节码文件,相当于依赖库的代码移植到了自身模块中。
借助maven-shade-plugin
这个插件可以很好地完成这一个需求,这样,在JVM
加载“相同的类”时,由于包名不一样,这些相同类名的类也均会被加载使用而互不影响