Administrator
发布于 2018-08-08 / 690 阅读
10

Maven 依赖冲突排查:NoSuchMethodError 背后的 Jar 包战争

本地跑得好好的,一上测试环境就 NoSuchMethodError

那次是给项目加了个 HTTP 工具类,本地单元测试全绿,提交完心情不错。结果测试环境部署完,一调用就炸:

java.lang.NoSuchMethodError: org.apache.http.impl.client.CloseableHttpClient.execute(
    Lorg/apache/http/client/methods/HttpUriRequest;)Lorg/apache/http/client/methods/CloseableHttpResponse;
        at com.xxx.util.HttpClientUtil.get(HttpClientUtil.java:47)
        at com.xxx.service.ThirdPartyService.query(ThirdPartyService.java:88)

我的第一反应是"不可能,本地都过了"。师傅过来看了一眼就说:"你本地和测试环境打出来的包,依赖版本不一样。先跑 tree。"

mvn dependency:tree 找出真凶

$ mvn dependency:tree -Dverbose -Dincludes=org.apache.httpcomponents

输出(节选):

[INFO] com.xxx:order-service:jar:1.0.0
[INFO] +- org.apache.httpcomponents:httpclient:jar:4.5.5:compile
[INFO] |  \- org.apache.httpcomponents:httpcore:jar:4.4.9:compile
[INFO] +- com.aliyun:aliyun-java-sdk-core:jar:3.5.0:compile
[INFO] |  \- (org.apache.httpcomponents:httpclient:jar:4.5.5:compile - omitted for duplicate)
[INFO] \- com.xxx:common-lib:jar:2.1.0:compile
[INFO]    \- org.apache.httpcomponents:httpclient:jar:4.2.1:compile

关键在最后一行。common-lib(公司内部封装的公共库)里传来了 httpclient 4.2.1,而我直接依赖的是 4.5.5。最后打进包的只有一个版本,而 Maven 选的不是我以为的那个。

我本地没报错,是因为本地 ~/.m2 里的 common-lib 是 2.0.3 快照版,那个版本没引 httpclient;测试环境Ubuntu 服务器上拉的是 2.1.0 正式版。本地和测试环境的依赖树根本不是同一棵,这就是"我这儿能跑"的真相。

两条仲裁规则,必须背下来

Maven 遇到同一个 artifact 的多个版本,按下面两条决定留谁:

规则一:最短路径优先

看依赖到根节点的层数,层数少的赢。上面那个例子:

order-service -> httpclient:4.5.5                      深度 1
order-service -> common-lib -> httpclient:4.2.1        深度 2

理论上 4.5.5 应该赢。但我的实际情况比这复杂,项目里还有一条更长的路径,加上 pom 里声明顺序的影响,最终进包的是 4.2.1(这个坑后面说)。先用个干净的例子说明规则:

A -> B -> C -> X:1.0     (深度 3)
A -> D -> X:2.0          (深度 2)
结果:X:2.0 胜出

规则二:路径长度相同,先声明的优先

A -> B -> X:1.0          (深度 2,B 在 pom 里先声明)
A -> D -> X:2.0          (深度 2,D 后声明)
结果:X:1.0 胜出

这条最坑。我这次就是栽在这:common-lib 在 pom 里写在 httpclient 前面,两条路径深度都是"直接依赖"级别,先声明的 common-lib 传来的 4.2.1 赢了。改 pom 里依赖的书写顺序会改变打包结果,这件事我以前完全不知道。

验证到底哪个进了包

tree 看得还不够直观,我习惯再确认一次实际产物:

$ mvn dependency:list -DincludeScope=runtime | grep httpclient
[INFO]    org.apache.httpcomponents:httpclient:jar:4.2.1:compile

$ ls target/order-service/WEB-INF/lib/ | grep http
httpclient-4.2.1.jar
httpcore-4.2.1.jar

$ unzip -p target/order-service/WEB-INF/lib/httpclient-4.2.1.jar \
    META-INF/MANIFEST.MF | head -5

确认是 4.2.1 无疑。CloseableHttpClient 在 4.2.1 里还没有我用的那个重载方法,所以运行期报 NoSuchMethodError——注意这是运行期错误,编译期是过了的,因为编译时用的是 4.5.5 的 API。

三种解决方式,我全用了一遍

方式一:exclusions 排除传递依赖(临时止血)

<dependency>
    <groupId>com.xxx</groupId>
    <artifactId>common-lib</artifactId>
    <version>2.1.0</version>
    <exclusions>
        <exclusion>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
        </exclusion>
    </exclusions>
</dependency>

这是最直接的,但有个大问题:项目大了以后,每个引入 common-lib 的模块都得写一遍 exclusion,漏一个就复发。我们项目当时有 8 个模块,我改到第 3 个的时候师傅让我停手了。

方式二:dependencyManagement 统一版本(推荐)

在父 pom 里锁版本:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.5</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpcore</artifactId>
            <version>4.4.9</version>
        </dependency>
    </dependencies>
</dependencyManagement>

dependencyManagement 里的声明不会真的引入依赖,只起到"版本仲裁"的作用。一旦在这里声明了,所有传递依赖过来的 httpclient 都会被强制成 4.5.5,exclusions 可以删掉。子模块引用时连 version 都不用写:

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
</dependency>

改完之后重新看树,全项目只剩一个版本:

$ mvn dependency:tree -Dverbose -Dincludes=org.apache.httpcomponents
[INFO] +- org.apache.httpcomponents:httpclient:jar:4.5.5:compile
[INFO] |  \- org.apache.httpcomponents:httpcore:jar:4.4.9:compile
[INFO] \- com.xxx:common-lib:jar:2.1.0:compile
[INFO]    \- (org.apache.httpcomponents:httpclient:jar:4.2.1:compile - omitted for conflict with 4.5.5)

注意最后那句提示从 omitted for duplicate 变成了 omitted for conflict with 4.5.5,说明是仲裁掉了而不是巧合重复。

方式三:maven-enforcer-plugin 卡在 CI 上

治本的做法是让问题在编译期就暴露。师傅帮我在父 pom 里加了这个插件:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-enforcer-plugin</artifactId>
    <version>3.0.0-M2</version>
    <executions>
        <execution>
            <id>enforce</id>
            <goals><goal>enforce</goal></goals>
            <configuration>
                <rules>
                    <dependencyConvergence/>
                    <bannedDependencies>
                        <excludes>
                            <exclude>commons-logging:commons-logging</exclude>
                        </excludes>
                    </bannedDependencies>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

dependencyConvergence 规则会在存在版本冲突时直接让构建失败,逼着你在 dependencyManagement 里显式指定版本。加上之后第一次跑,我们项目报出 11 处冲突,其中 commons-logging 是日志框架的经典坑,被 bannedDependencies 一起挡了。

几个排查时帮了大忙的命令

# 查某个类是从哪个 jar 加载的
$ mvn dependency:build-classpath -Dmdep.outputFile=cp.txt
$ grep -o '[^:]*httpclient[^:]*' cp.txt

# 看某个依赖是谁引进来的(反向追踪)
$ mvn dependency:tree -Dverbose -Dincludes=com.xxx:common-lib

# 只看某个 scope
$ mvn dependency:tree -Dscope=compile

# 找出未声明却被使用的依赖(漏声明的)
$ mvn dependency:analyze

dependency:analyze 那次还帮我发现了另一类问题:代码里直接 import 了传递依赖的类(比如用了 httpcore 的类但没声明 httpcore 依赖)。这种"搭便车"的依赖一旦上游换了版本就会突然消失,报 NoClassDefFoundError。

那次还顺带搞明白的几件事

releases 和 snapshots 的更新策略不同。 我用 -U 参数强制更新快照时,才第一次认真看了这块配置:

<repositories>
    <repository>
        <id>my-snapshots</id>
        <url>http://nexus.xxx.com/repository/snapshots/</url>
        <snapshots>
            <enabled>true</enabled>
            <updatePolicy>always</updatePolicy>     <!-- 每次构建都检查 -->
        </snapshots>
        <releases>
            <enabled>false</enabled>
        </releases>
    </repository>
</repositories>

release 版本默认下载过就不再检查,所以如果你的 ~/.m2 里已经有一个 2.1.0,远端更新了同名版本(正常流程不允许,但小公司确实发生过),本地是拉不到的。这也是"本地和服务器不一致"的一个来源。

把依赖树固化下来。 我们后来把所有模块的依赖树输出成一个文件提交到 git,CI 里做 diff。一旦有人改了 pom 导致依赖变化,对比结果会直接出现在构建日志里,review 的时候一眼能看到。

$ mvn dependency:tree -DoutputFile=deps.txt -DoutputType=text

IDE 的依赖视图不可信。 我一开始是在 IDEA 里看的依赖,它显示的是 4.5.5,跟命令行跑出来的 4.2.1 不一致。原因是 IDEA 有自己的一套解析逻辑,而且缓存了旧结果。后来我遇到依赖问题一律用命令行验证,IDE 只用来写代码。

这次学到的

三类报错要能对上号:NoSuchMethodError / NoSuchFieldError 是版本冲突;NoClassDefFoundError 多为漏依赖或依赖被排除;ClassNotFoundException 常见于 ClassLoader 隔离问题。

还有一条纪律我现在一直在守:本地能跑不算数,提交前一定跑一次 mvn clean dependency:tree 并把输出和本地环境对比。那次事故本质上是我默认了"本地的 classpath 等于服务器的 classpath",而 Maven 从来没给过这个保证。

参考