抛砖引玉:Java中依赖冲突的解决方法

依赖冲突的解决方法

早在去年的一次面试中,我就曾被问及依赖冲突如何解决,当时确实没有遇到过相关的问题,所以似乎并没有给出很满意的答案。因为在通常情况下,构建工具帮你解决了这些问题,好像一切都没有发生过,万事大吉。正所谓,“出来混,总是要还的”——最近在升级Spring版本时又遇到了这个问题,并且断断续续地困扰了我一段时间,到现在为止总算是完整地解决了。故有此文,以告慰我逝去的青春。


由来已久的问题

引入依赖作为程序员解决“重复造轮子”这件事上的伟大发明,可以说是“前无古人,后无来者”了。不论是C/C++中的静态库、动态库,又或者是Java中的jar包,从某种意义上,都是一种依赖,让程序员可以开心地用着先辈们写好的代码,而不用关注其实现细节。作为Java程序员,我们的包管理工具Ant/Maven/Gradle们,也为我们使用这些依赖带来了极大的便利,但依然不能完全避免一个全新的问题——依赖冲突。

The dependency issue arises around shared packages or libraries on which several other packages have dependencies but where they depend on different and incompatible versions of the shared packages. If the shared package or library can only be installed in a single version, the user may need to address the problem by obtaining newer or older versions of the dependent packages. This, in turn, may break other dependencies and push the problem to another set of packages.
[ Wikipedia: Dependency hell ]

简而言之,依赖关系是一个类树状的结构,如果忽略其中的某些依赖可能是指向同一份代码,那依赖树就是以你的项目为根节点的多叉树。

【figure 1】

如果某些节点指向同一份代码,依然不会有问题,大家一起用这份代码便是;最麻烦的是,指向的这份代码偏偏是同一个工程的不同版本,那依赖冲突就产生了——如果这两个版本相互兼容,便没什么问题,随便选一个都不会有什么事;如果不兼容,比如某个版本多一个方法,真不巧你的代码还用了这个方法,那问题就大了,你必须得做出选择。当你做出选择后,问题接踵而至,你不可避免地改变了另一些工程的依赖版本,它仍然能很好地工作吗(事实上它遇到了与你一样的困扰)?这里需要画一个大大问号。

【figure 2】

  • -

使用不同的版本

有依赖冲突,同一个依赖,版本不同,但两个版本之间又不兼容,那为什么各自使用各自的依赖的版本,大家不就各取所需、相安无事了吗?答案是肯定的,参考阅读

在Java中,对于同一个类加载器(Class Loader)而言,一个类有且只有一个完全限定名。而默认情况下,所有的类都由同一个加载器进行加载。因而同一个类的不同版本代码,我们只能取其一。那么如果有两个类加载器呢?是的,我们就可以完成我们所期待的功能:使用不同依赖的版本。这不是我们今天要研究的主题,有时间再细细研究。

使用其中一个版本

现今的构建工具,都对于依赖冲突这个问题给出了解决方案。以Gradle为例,默认情况下,Gradle会选择最新的版本1。这给我们带来了很大的方便。于是我们的项目在升级spring-boot的时候(由1.4.2升级到1.5.10),由于横跨次版本号,很多依赖版本发生了改变,详细的可以点击前面的POM文件链接。今天的主角是它:

<version>1.4.2.RELEASE</version>
...
<properties>
  <jedis.version>2.8.2</jedis.version>
<properties>
...
<dependency>
  <groupId>redis.clients</groupId>
  <artifactId>jedis</artifactId>
  <version>${jedis.version}</version>
</dependency>

在1.5.10版本中,上面这个jedis版本变成了2.9.0。很显然,新版的SpringBoot引入的jedis:2.9.0出尘绝伦,被Gradle推举为老大,大家都使用这个版本:

localhost:proj root$ gradle dependencyInsight --dependency jedis --configuration compile

redis.clients:jedis:2.9.0 (selected by rule)

redis.clients:jedis:2.8.2 -> 2.9.0
\--- io.codis.jodis:jodis:0.3.1

PS: 这个Gradle命令会列出所有compile阶段对jedis的依赖关系,依赖树在排查依赖问题中很重要

上面的“selected by rule”指的就是被Spring指定的版本。关于Spring dependency-management-plugin,可以参看Sterling Greene
的回答
。而io.codis.jodis:jodis是今天的另一个主角,代码实际中调用了jedis:2.8.2版本的构造方法:

public JedisPool(final GenericObjectPoolConfig poolConfig, final String host, int port,
      final int connectionTimeout, final int soTimeout, final String password, final int database,
      final String clientName);

而在jedis:2.9.0中,这个构造方法被修改了(新增了两个参数,相当于是删除了):

public JedisPool(final GenericObjectPoolConfig poolConfig, final String host, int port,
      final int connectionTimeout, final int soTimeout, final String password, final int database,
      final String clientName, final boolean ssl, final SSLSocketFactory sslSocketFactory,
      final SSLParameters sslParameters, final HostnameVerifier hostnameVerifier);

再梳理一下,引用关系大致如下,即我的项目引了一个公共组件commons,里面的spring-boot和jodis版本不兼容,而我的项目又用到了commons封装的jodis代理:

myproject(它将spring升到了1.5.10,为了配合commons)
      \--commons(编译并没有报错)
             |--spring-boot (引用了1.5.10)
                 \--redis   (引用了2.9.0)
             |--jodis      (引用了0.3.1)
                  \--redis  (同样引用了2.8.2)   

于是在我们的服务启动后,初始化codis连接池的时候发生了错误:

[WARN][2018-06-27T11:56:14.414+0800][io.codis.jodis.RoundRobinJedisPool:193] java.lang.NoSuchMethodError: redis.clients.jedis.JedisPool. <init> < i n i t > <script type="math/tex" id="MathJax-Element-1"> </script>(Lorg/apache/commons/pool2/impl/GenericObjectPoolConfig;Ljava/lang/String;IIILjava/lang/String;ILjava/lang/String;)V

问题的大致原因至此已经很明白了,使用jedis:2.8.0jodis:0.3.1,闯进了被spring-boot引入的jedis:2.9.0的字节码,调用了一个不存在的构造方法,于是JVM直接抛出了一个NoSuchMethodError。到这里可以发现,通过指定一个版本,依赖冲突确实是被Gradle解决了,但这种解决形式是我们不可接受的,它直接导致了codis无法使用。留给我们的是两个问题:

  • 为什么编译阶段没有直接报错?
  • 我们怎么做?

What’s going WRONG?

我们不妨来简单地思考一下:编译型语言,以C/C++为例,程序员编写的源码,经过编译链接成一份可执行文件,其编译的终点是汇编代码(Assembler Code),是最接近计算机底层的语言形式。编译阶段,源码中的方法也好,成员变量也罢,变成了一系列符号(也被称为object file);链接阶段,符号被从不同的object file中关联起来,于是计算机才知道执行某个方法时要去哪里执行哪些指令。C/C++在编译阶段有链接过程,这个过程会把代码中引用的外部方法与其实现关联起来。

关于C/C++与Java的编译差异,可以参看这个回答。如果有编译器链接阶段,那上面说的NoSuchMethodError就会在编译阶段就暴露出来。

那么,Java是一种什么语言呢?通常我们会说Java是编译型语言,很明显,程序员每天和.class、.jar打交道,这个就是编译器帮我们生成的。然而,它并不是严格意义的编译型语言:

Java is the first substantial language which is neither truly interpreted nor compiled; instead, a combination of the two forms is used. This method has advantages which were not present in earlier languages.
[ CS.CMU.EDU: How Java Works ]

编译器(javac)编译成字节码,而解释器(Java Virtual Machine, JVM)来执行字节码。javac实际上是JVM的编译器。javac是否有链接过程呢?答案是没有,但是会做简单的检查:

import com.google.gson.Gson;

public class Test {
    public static void main(String[] args) {
        new Gson().fromJson("{}", "");
    }
}
localhost:test root$ javac -cp /root/.m2/repository/com/google/code/gson/gson/2.8.0/gson-2.8.0.jar src/main/java/Test.java
src/main/java/Test.java:9: 错误: 对于fromJson(String,String), 找不到合适的方法
        new Gson().fromJson("{}", "");
                  ^

本次遇到的问题,仔细一想,在io.codis.jodis:jodis:0.3.1编译时没有任何问题,我引用的redis:2.8.2是存在这个方法的。只不过打成.jar包交付给用户后,这个依赖被构建工具改写了,编译器管辖不了。所以说,javac在符号的链接上,并不完备,它也不可能产生可执行文件——它只能生成字节码,交给JVM去解释运行,NoSuchMethodError被推迟到Runtime来解决。

真正充当Java语言链接器(链接“字节码级别”的符号)的是运行时的类装载器,但是比起C/C++的链接器,它并不会在装载的时候检查这些“符号”是否存在,它把装载哪个类的选择权交给了程序员。redis:2.9.0io.codis.jodis:jodis:0.3.1被装载,JVM执行字节码时,走到io.codis.jodis:jodis:0.3.1的redis构造函数处,发现方法不存在(“链接”失败了),NoSuchMethodError诞生。

问题解决

仔细查看异常堆栈,可以发现问题根源。io.codis.jodis:jodis:0.3.1引用了不存在的方法:

private void resetPools() {
    ...
    pool = new PooledObject(addr,
                            new JedisPool(poolConfig, host, port, connectionTimeoutMs, soTimeoutMs,
                                    password, database, clientName));
}

我们已经知道,Gradle会强制指定jedis:2.9.0,那么我们可以迎合Gradle,去找一个使用jedis:2.9.0jodis版本。MavenRepository上查找jodis,刚好可以发现0.4.1引用的是jedis:2.9.0,引用它便可。引用后,我们查看上面这个方法的实现:

private void resetPools() {
    ...
    pool = new PooledObject(addr,
                            new JedisPool(poolConfig, host, port, connectionTimeoutMs, soTimeoutMs,
                                    password, database, clientName, false, null, null, null));
}

新版jedis在方法末尾追加的四个参数,在jodis:0.4.1中作了最简单的兼容(直接给了null)。至此Spring升级带来的jedis依赖冲突问题完美地解决了。


  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值