本地跑得好好的,一上测试环境就 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 从来没给过这个保证。