Maven依赖冲突避坑指北

技术
前言:依赖冲突的由来

Maven是当今Java工程中最流行的构建工具之一,而工程所依赖的库的数量也会随着工程规模和复杂度的上升逐步增加。

足够多的依赖项也会给工程带来一些难以发现的依赖冲突,时刻威胁着系统运行的稳定性,也给工程今后的迭代,架构的升级带来不小的麻烦。

那么,何为依赖冲突?有个最直接的现象,即在实际开发过程中,或多或少要引入一些依赖,若在引入依赖后工程无法启动了,或者之前都正常运行的逻辑却在某些场景下突然报错了等等,依赖冲突可能就是罪魁祸首。

不过不用担心,因为依赖冲突这个问题几乎在任何一个稍具规模的Java工程里都会存在。举个例子,你的工程里引入了spring-boot-starter-redis包,然后又有使用分布式锁的需求,但由于spring-boot官方并未提供成型的类库使用,于是你在度娘上找了个xxx-distribution-lock-redis, 顺手贴进了pom 里。写完代码后启动工程准备秀一波,结果 console输出如下信息:

Cause by: java.lang.NoclassDefFoundError:org/springframework/data/redis/connection/lettuce/LettuceClientConfiguration                 
  at java.base/java.lang.Class.getDeclaredMothods0(Native Method)             
  at java.base/java.lang.Class.privateGetDeclaredMethods(Class.java:3166)          
  at java.base/java.lang.Class.getDeclaredMethods(Class.java:2309)  
  at java.base/java.lang.Class.ReflectionUtils.getDeclaredMethods(ReflectionUtils.java:463)         
  ... 21 common frames omitted
Cause by: java.lang.ClassNotFoundException: org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration
  at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:521)
  ...25 common frames omitted("hello world!");   

这是典型的依赖冲突问题,什么?你说编译没问题?照着文档写的?还能打包?TOO NAIVE...

你以为你引了这个版本的依赖,工程里跑的就是这个版本的吗?

其实,这个场景还相对较简单,因为对于使用方来说,是知道自己引了可能有相同功能的依赖,并且在工程启动的时候便会有对应报错提示。但有时候,你并不知道工程里的依赖有多少交集,而且工程也是正常启动,往往在某个天时地利人和,服务突然就出现了不明所以的错误。

那么,为什么会出现这样的情况?Maven 对于同一个依赖同时引入多种版本是如何处理的?这些问题我们先放一放,本文将会从实践出发,讲解从发现和分析依赖关系到逐步讲解依赖的核心机制,以及最后在开发新老系统的时候给出如何避免依赖冲突的操作建议,先来介绍下在实际开发过程中,如何去分析依赖关系。

依赖可视化

稍具规模的一个 Java Web 工程,依赖的包就多达上百个,所以,你的服务依赖关系应该是呈树状的。通过 Maven 内置命令,或者第三方插件均可以帮助你对工程依赖进行分析。

使用Maven命令展示依赖树

Maven 提供了命令用于查看依赖关系树:

mvn dependency:tree

可以输出如下格式的信息:

picture.image

如上所示,会输出每个模块的子级依赖项,以树状的结构展示。配合终端的字符串查找命令也可快速查询结果。但有时候如果想看图形化的展示,便可以借助 IDE 工具来更直观地展示依赖关系。

使用IDEA内置工具展示

找个工程,在启动模块的pom里,借助IntelliJ IDEA, 我们可以直观地查看依赖树:

picture.image

将图形设置为实际尺寸或放大,可以看到每个红线的指向,即冲突的依赖,但这样的红线,多得数不胜数。

那么,我姑且用"依赖健康度 "来衡量冲突的严重程度吧,虽然业界暂时没有类似手机清理管家那种扫描服务依赖健康度的工具和算法,但很显然,基本可以认为,红线越多,冲突便越严重。

除非你非常清楚每个冲突点到底有没有影响,而不是凭直觉来判断,否则每一个冲突都有可能会演变为 下家公司做兄弟 服务雪崩的导火索。

picture.image

使用IDEA插件分析依赖关系

人类文明从石器时代到青铜、铁器时代再到现代文明,与人类善于使用好的生产工具这一特点息息相关,同样,区分程序猿来自哪个时代,也可看平时解决问题用的什么方法。

IDEA的插件市场里有众多好用的生产力工具,对于Maven的依赖关系的分析与排查的需求,推荐使用Maven Helper插件来实现。

步骤1:插件安装

打开IDEA的Preferences,(Mac 快捷键为"⌘+,") 点击左侧Plugins,搜索maven helper如下图,点击Install, 等待下载完毕后重启IDEA即可。

picture.image

步骤2:使用插件分析依赖

进入任意的pom文件,IDEA编辑框底部tab会多出Dependency Analyzer选项卡。

picture.image

点击此选项卡,会出现如下UI,All Dependencies as Tree会将所有依赖以树形结构展示。

picture.image

Conflicts会展示当前模块有冲突的依赖:

picture.image

当然也是支持搜索的。

picture.image

而当手动修改了pom.xml,顶部会提示"⚠️ Refresh UI"来刷新依赖图。

步骤3:使用插件解决冲突

当发现有冲突的依赖项,可以右键Jump to Source[META DOWN]快捷跳转到pom.xml相应位置:

picture.image

如果点击Exclude,则会将这个依赖排除。

以上图为例,当点击Exclude后,当前pom的变化如下:

排除前:

<dependency>                 
        <groupId>com.shizhuang-inc</groupId>        
        <artifactId>instrument-core</artifactId>
</dependency>

排除后:

<dependency>                 
  <groupId>com.shizhuang-inc</groupId>        
  <artifactId>instrument-core</artifactId>
  <exclusions>
    <exclusion>      
      <artifactId>utils</artifactId>     
      <groupId>com.poizon</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.poizon</groupId>        
  <artifactId>B</artifactId>
  <version>2.0</version>
</dependency>

何谓就近原则?

随着工程逐步迭代,依赖管理成本也会逐步增加。为了避免各个库不可避免地声明使用了相同的库所带来的歧义,Maven又额外引入了一种机制,也就是"就近原则"。

就近原则保证了在工程的依赖树中,同个依赖有多个版本存在时,应选择哪一个版本使用。

在 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 使用此项                 

A -> B -> C -> D路径1

A -> E -> D路径2

A -> D路径3 (使用此项最短路径)

依赖的管理与控制

Maven作为管理依赖的一把手,对依赖的控制也灵活多变。官方提供了依赖管理机制,而为了控制依赖的引入时机,也规定了依赖的作用域,以及可选依赖项。最后,有时候不得不使用人工干预的方式,来解决依赖冲突,即依赖排除。

1)依赖管理

依赖管理即dependencyManagement, 主要用来声明依赖库的版本,常用于父子类型的工程中。一个最基本的做法是,在父工程里声明dependencyManagement标签,里面声明子模块需要的依赖库版本,在子模块中引入对应的不带版本声明的依赖库。

父模块声明依赖管理:

<properties>                 
  ...        
  <!-- 声明依赖库版本 -->                
  <fastjson.version>1.2.76</fastjson.version>
  ...
</properties>       

<dependencyManagement>          
  <dependencies>
                      
    ...
                    
    <!-- 声明依赖库 -->              
    <dependency>
      <groupId>com.alibaba</groupId>    
      <artifactId>fastjson</artifactId>
      <version>${fastjson.version}</version>
    </dependency>
                    
    ...
  </dependencies>             
</dependencyManagement>

子模块中就无需指定版本了:

<dependencies>                 
  ...      
  <dependency>               
    <groupId>com.alibaba</groupId>      
    <artifactId>fastjson</artifactId>
  </dependency>
  ...     
</dependencies>           

这样做的好处是在同一个工程内部,即便有个依赖在各个模块中声明了不同版本,但在实际使用过程中,如果其他模块引入了包含这个依赖的模块,那么版本号依然是以你在父模块中声明的版本为准。

举个实际的例子,在XNIO Parent POM中,dependencyManagement节点有声明以下依赖:

...                 
<version.org.wildfly.common>1.5.2.Final</version.org.wildfly.common>                 
...  
...                 
<dependency>                 
  <groupId>org.wildfly.common</groupId>        
  <artifactId>wildfly-common</artifactId>
  <version>${version.org.wildfly.common}</version>
</dependency>
<dependency>       
  <groupId>org.wildfly.client</groupId>        
  <artifactId>wildfly-client-config</artifactId>
  <version>${version.org.wildfly.client-config}</version>
</dependency>          
...   

而在wildfly-client-config中,也同样声明了 wildfly-common,但版本与XNIO Parent POM 中的不一样:

...                    
<version.org.wildfly.common>1.2.0.Final</version.org.wildfly.common>                 
...  
<dependency>                 
  <groupId>org.wildfly.common</groupId>        
  <artifactId>wildfly-common</artifactId>
  <version>${version.org.wildfly.common}</version>
</dependency>

现在有个xnio-api模块,它是XNIO Parent的子模块,这个模块同时依赖了上面俩模块:

<dependency>                 
  <groupId>org.wildfly.common</groupId>        
  <artifactId>wildfly-common</artifactId>
</dependency>
<dependency>       
  <groupId>org.wildfly.client</groupId>        
  <artifactId>wildfly-client-config</artifactId>
</dependency>

此时,即便wildfly-client-config中已经规定了版本1.2.0.Final,xnio-api模块中wildfly-common 这个依赖的版本仍然为父模块中声明的版本 1.5.2.Final。

使用以下来描述上述的关系:

XNIO Parent POM {                 
  revision: 1.0   
  依赖管理项 {     
        wildfly-common:1.5.2.Final,        
        wildfly-client-config: 1.0-SNAPSHOT {
          依赖声明 {
            wildfly-common:1.2.0.Final 
          }
        }         
        ...           
    },         
  子模块 {              
    xnio-api: ${revision} {           
      依赖声明 {
        wildfly-common, //版本为 1.5.2.Final     
        wildfly-client-config
      }
    }             
  }               
}             

2)依赖的作用域

依赖作用域可以更好控制依赖什么时候会被引入,官方文档中也介绍了6种作用域:

  • compile : 默认作用域,编译打包工程后该作用域的依赖一并会打包进去。
  • provided :编译和运行均会使用到,一般这种依赖最终由外部来提供,例如工程打包为 war 包后部署至 Tomcat 容器,而 Tomcat 容器是提供了 servlet-api 依赖的, 所以工程里的这个依赖作用域是 provided, 这是为了避免打包的时候将此类型的库打包进类目录中,造成重复引入而引起的依赖冲突。
  • runtime : 只在运行期使用,例如某个具体的数据库连接驱动,在实际代码开发过程中是面向底层接口来使用,直接使用具体某个驱动也是采用反射或者 SPI 的方式。其实就是为了避免干扰动态加载相关依赖的逻辑。
  • test : 测试期间才会使用的依赖
  • system : 声明为此作用域的依赖必须显式指定 jar 包路径。
  • import : 此作用域只支持类型为 pom 的依赖且只能在 dependencyManagement 中使用。声明为此作用域的依赖将会被这个依赖里声明的 dependencyManagement 里的依赖列表所替换。

3)依赖排除

依赖排除通常用于解决那些由于客观原因造成的依赖冲突,例如有如下模块依赖关系:

  A                 
  ├── B                 
  │   └── C             
  │       └── D 2.0         
  └── E 
      └── D 1.0         

按就近原则,此时构建A模块时,依赖的D模块的版本为1.0,这可能会违背初衷,想使用高版本的依赖。如果B和E模块均为第三方模块,自己无权更改 pom文件,则需要使用依赖排除:

<dependency>                 
  <groupId>com.shizhuang-inc</groupId>        
  <artifactId>E</artifactId>
  <exclusions>
    <exclusion>      
      <groupId>com.shizhuang-inc</groupId>     
      <artifactId>D</artifactId>
    </exclusion>
  </exclusions>    
</dependency> 

4)可选依赖

可选依赖即依赖声明里,标签 "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的依赖控制权交给了使用方。

依赖陷阱

正是由于这些核心的依赖机制,作为开发者其实还是会难免会采坑的。

以下例举出一些情形,以防采坑:

1)单个模块中多次引入同个依赖,且均声明了版本号。

笔者之前在整理某个服务的依赖时发现有的模块中居然对一个依赖声明了多次,而这个依赖未使用父模块依赖管理,版本号也不一样。

且先不论规范,在这样的情形下,按定义顺序,后定义的依赖生效。

  A                 
  ├── C(2.0)                        
  └── C(1.0) 此项生效

2)工程内父pom中声明了多个bom依赖。

这样难免会出现这些多个bom里存在相同的依赖但版本不一样的情况:

A BOM:

<dependencyManagement>                 
  <dependencies>
    ...    
    <dependency>             
      <groupId>cglib</groupId>    
      <artifactId>cglib</artifactId>
      <version>2.1</version>
    </dependency>
    ...   
  </dependencies>             
</dependencyManagement>

B BOM:

<dependencyManagement>                 
  <dependencies> 
    ...   
    <dependency>             
      <groupId>cglib</groupId>    
      <artifactId>cglib</artifactId>
      <version>3.3.0</version>
    </dependency>
    ...   
  </dependencies>             
</dependencyManagement>

父模块parent BOM:

...                 
<dependencyManagement>                 
  <dependencies>
    <dependency>    
      <groupId>com.shizhuang-inc</groupId>    
      <artifactId>A</artifactId>
      <version>1.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
                    
    <dependency>   
      <groupId>com.shizhuang-inc</groupId>    
      <artifactId>B</artifactId>
      <version>1.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    ...   
  </dependencies>             
</dependencyManagement>

子模块 C:

<parent>                 
  <artifactId>com.shizhuang</artifactId>            
  <groupId>parent</groupId>
  <version>${revision}</version>
</parent>
                    
<dependencies>
  <dependency>      
   <groupId>cglib</groupId>      
   <artifactId>cglib</artifactId>
  </dependency>
</dependencies> 

此时,生效的版本为A BOM中定义的cglib版本 2.1。所以,多个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。

了解了核心机制后,其实也很好回答了开头问题了。将依赖的选择以流程图形式表示如下:

picture.image

如何避免依赖冲突

了解现有的服务依赖

对于一个技术债务堆积得比较深的服务来说,了解每个冲突需要耗费大量的精力,这里我可以提供以下几个方面重点操作的建议:

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接口做封装的依赖库了。

④动态代理,和要进行动态代理的目标对象所在的包,字节码增强相关依赖库。

此外,特别要注意动态代理所作用的对象,极容易出现因依赖冲突导致要代理的目标方法找不到的错误,以及ASM版本因依赖冲突导致JVM操作码兼容性问题:

  • asm 5.0.4
public ClassVisitor(final int api, final ClassVisitor cv) {                 
        if (api != Opcodes.ASM4 && api != Opcodes.ASM5) {  
            throw new IllegalArgumentException();     
        }
        this.api = api;           
        this.cv = cv;
}
  • asm 7.1
public ClassVisitor(final int api, final ClassVisitor classVisitor) {                 
    if (api != Opcodes.ASM7 && api != Opcodes.ASM6 && api != Opcodes.ASM5 && api != Opcodes.ASM4) {
        throw new IllegalArgumentException("Unsupported api " + api);
    }               
    this.api = api;               
    this.cv = classVisitor; 
}

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加载“相同的类”时,由于包名不一样,这些相同类名的类也均会被加载使用而互不影响。(关于该插件的使用方法具体可参考后文链接。)

参考资料

Maven - Introduction to the Dependency Mechanism

https://maven.apache.org/guides/introduction/introduction-to-dependency-mechanism.html

Flatten Maven Plugin

https://www.mojohaus.org/flatten-maven-plugin/index.html

Apache Maven Shade Plugin - Introduction

https://maven.apache.org/plugins/maven-shade-plugin/

*文|栉枫忻垣

0
0
0
0
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论