第 07 章(节选) 依赖治理与实战

本篇来自《Go 程序员的 Java 修炼之路》第 07 章「第 07 章 依赖地狱治理:Jar Hell、ClassLoader 与 Shade(NoSuchMethodError 的三种死法)」。 返回:第 07 章索引

07.6 依赖治理:从”救火”到”防火”

前面五节都是”出问题了怎么办”。这一节讲”怎么让它不出问题”。

组织级 BOM 怎么运营

第 05 章讲过 BOM 怎么写,这里讲怎么写完之后不被玩坏。

事项建议
谁来维护一个明确的 owner(架构组 / 平台组),不是”谁需要谁改”。改动走 PR review
升级流程季度为周期 + CVE 紧急通道。每季度的升级窗口里统一升,而不是各团队随时升
灰度策略BOM 先发 RC 版本 → 挑 1~2 个非核心服务试点两周 → 全量。BOM 是 SNAPSHOT 之外的”全局开关”,改错影响全公司
版本收敛每个 artifact 在 BOM 里只允许一个版本。允许”特殊模块用特殊版本”的口子一开,半年后就有三十个例外
例外管理真需要例外,在 BOM 里加显式注释说明原因和预计移除时间,并设一个 enforcer 规则把例外列白名单

为什么”允许任意版本”终将失控? 因为版本组合是乘性的。10 个常用库各有 3 个版本在公司内部流通,理论组合是 3^10 ≈ 59049 种。你不可能测 59049 种组合,所以必然有组合是”从没人验证过但有人在用”的 —— 那就是定时炸弹。版本收敛的本质是把组合数从指数级压回常数级。

构建期强制检查:让冲突在 CI 失败

这是本节最该带走的东西。核心思想一句话:

让构建在冲突时失败,比让人在运行时发现便宜一百倍。

maven-enforcer-plugin 的规则清单(extra-enforcer-rules 提供带星号的那些):

规则作用什么时候启用
dependencyConvergence同一 artifact 出现多个版本 → 构建失败新项目立刻开;老项目先钉死版本再开
banDuplicateClasses ★同一个全限定名出现在多个 jar 里 → 失败。这是检测 Jar Hell 最直接的手段同上,且建议一直开着
requireUpperBoundDeps确保最终版本 不低于所有传递依赖要求的版本(防止”选到比某个依赖要求的还低”)配合 convergence 一起用
banTransitiveDependencies ★禁止使用传递依赖(强制”直接使用原则”)库项目建议开;应用项目看团队纪律
requireReleaseDeps依赖里有 SNAPSHOT → 失败(发布时必开)发布 profile 里开
bannedDependencies黑名单:禁止某个依赖(如 commons-logging、log4j 1.x)按需

完整配置(注意 banDuplicateClasses 在扩展包里):

<plugin>
    <artifactId>maven-enforcer-plugin</artifactId>
    <version>3.4.1</version>
    <dependencies>
        <!-- banDuplicateClasses / banTransitiveDependencies 在这个扩展包里 -->
        <dependency>
            <groupId>org.codehaus.mojo</groupId>
            <artifactId>extra-enforcer-rules</artifactId>
            <version>1.7.0</version>
        </dependency>
    </dependencies>
    <executions>
        <execution>
            <id>enforce-rules</id>
            <goals><goal>enforce</goal></goals>
            <configuration>
                <rules>
                    <dependencyConvergence/>
                    <banDuplicateClasses>
                        <findAllDuplicates>true</findAllDuplicates>
                        <!-- 已知无害的重复类放这里(比如一些库的 legency 兼容类) -->
                        <ignoredClasses>
                            <ignoredClass>javax.*</ignoredClass>
                        </ignoredClasses>
                    </banDuplicateClasses>
                    <requireUpperBoundDeps/>
                    <requireReleaseDeps>
                        <message>发布版本不允许依赖 SNAPSHOT!</message>
                    </requireReleaseDeps>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

第一次跑大概率红一大片。 一个三五年的项目,依赖树里通常有几十处分歧。处理节奏:

mvn enforcer:display-info              # 先看规模,别一上来就加规则
# 1. 把现有冲突用 dependencyManagement 一个个钉死(输出从 omitted 变成 version managed)
# 2. 冲突清零后加 dependencyConvergence
# 3. 再处理重复类,最后加 banDuplicateClasses
# 4. 治理不动的老项目:至少在 CI 里跑 enforcer 只警告不失败,作为技术债台账

Gradle 侧:

configurations.all {
    resolutionStrategy {
        failOnVersionConflict()              // Gradle 7 起标记废弃,但语义最直白
        failOnNonReproducibleResolution()    // 现代替代:动态版本/变化模块导致不可重现时失败
        force("com.fasterxml.jackson.core:jackson-databind:2.15.3")  // 强制版本
    }
}
 
// 依赖锁定(Gradle 的 go.sum 等价物)
dependencyLocking {
    all { it.enable() }
}

安全与合规

工具形态特点
OWASP dependency-checkMaven/Gradle 插件开源免费,基于 NVD 数据库;扫描慢(首次要下载整个 NVD),误报多
SnykSaaS / CLI商业,误报率低,有修复建议(自动提 PR)
GitHub DependabotGitHub 原生零配置,自动提升级 PR;只覆盖 GitHub 托管项目
墨菲安全(MurphySec)国内 SaaS / CLI国内源的漏洞库更新快,支持供应链投毒检测

最小可用配置(OWASP,接在 CI 里):

mvn org.owasp:dependency-check-maven:check \
    -DfailBuildOnCVSS=7          # CVSS >= 7 就失败
    -DsuppressionFile=owasp-suppressions.xml   # 误报白名单
    -DnvdApiKey=${env.NVD_API_KEY}             # NVD 现在要 API key,不然慢到不可用

依赖健康度指标(拿来当看板)

指标怎么算健康线
冲突数`dependency:tree -Dverbosegrep -c “omitted for conflict”`
重复类数banDuplicateClasses 的失败条目数0
未声明直接使用数dependency:analyze 的 Used undeclared0
最久未升级依赖版本发布时间距今的最大值< 18 个月
依赖总数`dependency:listwc -l`
高危 CVE 数OWASP / Snyk 报告0

【思考】为什么”依赖升级”在 Java 项目里这么痛苦,而在 Go 项目里相对轻松?

至少想出三条,其中一条应该是”反直觉”的。

参考答案

先说三条常规解释,再说那条反直觉的。

原因一:传递依赖的深度和宽度不在一个量级。

Java:你写一个 spring-boot-starter-web,它展开成 30+ 个 jar,其中某些 jar 又带进自己的依赖。一个中型 Spring Boot 应用的 classpath 上有 80150 个 jar,依赖树深度 57 层。升级一个直接依赖,可能牵动几十个间接依赖的版本。

Go:go.mod 里的 indirect 依赖通常也就几十行,且每一行都有确定的版本,升级 A 不会”顺带”改 B —— 除非 A 的新版本真的要求更高的 B。

原因二:MVS 的”单调性”给了 Go 一个 Java 没有的保证。

Go 的 MVS 有一条性质叫单调性:往依赖图里加依赖,只会让版本升高,不会降低。所以你升级一个模块,不可能意外降级另一个模块。

Maven 的 nearest wins 没有这个保证。你加一个新依赖(深度 1),它可能把某个间接依赖从深度 2 顶到深度 3,导致另一个间接依赖的版本反而降了。这就是为什么 Java 项目里”加一个依赖,另一个功能炸了”这种事会发生 —— 你在不知情的情况下降级了别人。

原因三:工具链成熟度。

Go 有 go get -u ./...、go list -m -u all(列出可升级的),Dependabot 对 Go 的支持几乎是零配置。升级结果写进 go.mod,是一个可 review、可回滚的 commit。

Java 有 versions-maven-plugin(mvn versions:display-dependency-updates),但它只告诉你”有新版本”,不负责验证兼容性。而真正决定兼容性的是 BOM,所以 Java 的升级粒度是”升 BOM”而不是”升单个依赖” —— 一次动上百个版本,风险集中爆发。

原因四(反直觉的那条,我认为最重要):Java 的”强向后兼容承诺”反而抑制了升级动力。

这条值得展开。

Java 生态有一条非常强的文化:向后兼容是神圣的。Guava、Jackson、commons-lang3、Spring 都以”老代码不用改就能升”为荣。Spring Boot 2.7 的代码可以几乎不改跑到 3.x(除了 javax.* → jakarta.* 那次大改名)。

听起来这是好事。但它的副作用是:

因为老版本一直能用,所以没有人有动力升级。

于是技术债以”版本落后”的形式悄悄累积:

  • Guava 20(2016 年发布)到今天还能编译、还能跑,所以没人升到 33
  • 等到某天必须升(比如 JDK 21 不兼容、或者爆出 CVE),你要跨越 7 年的变更,一次性承受所有的破坏性变更
  • 而 Go 的生态因为没有强兼容承诺(语义导入版本意味着 v2 就是另一个包,不兼容很正常),反而形成了”小步快跑”的习惯 —— 每次升一点,每次痛一点,但从来不积累成山

这是一个典型的”短期最优导致长期次优”: 强兼容承诺降低了每次升级的成本,却提高了长期技术债的规模,因为取消了升级的紧迫性。

代码锚点 —— 把”落后程度”变成可见的指标:

# Java:列出可升级的依赖(只看不改)
mvn versions:display-dependency-updates | grep -A2 "available"
 
# Go:列出所有可升级的模块
go list -m -u all | grep "\["

两个命令的心智负担完全不同:Go 的那条你敢直接执行升级;Java 的那条你只敢看,因为你知道升了要改几十处。

更深一层:这背后还是那个”复杂度付在哪一层”的老问题。

  • Java 把向后兼容的成本付给了库的维护者(他们要小心翼翼保持兼容,不敢删 API,于是老 API 越积越多,Jackson 的 ObjectMapper 有几十个方法)
  • Go 把升级的成本付给了库的使用者(v2 就是不同的包,你要改 import 路径)

Java 的选择让”不升级”变得可行,Go 的选择让”不升级”变得不可行。 二十年尺度上看,后者更健康 —— 因为”能一直不升级”本身就是一种债务。

对你的实践建议:不要等”必须升”的时候才升。每季度固定一个升级窗口,把 BOM 升到上一个 LTS 版本,用 CI 跑全量回归。把”一次跨越七年”变成”每年跨越一次”。


07.7 实战:两个完整的 Jar Hell 排查案例

案例一:支付宝 SDK 报 NoSuchMethodError

现象

服务上线后,调用支付接口必现:

java.lang.NoSuchMethodError: org.apache.http.client.utils.URLEncodedUtils.parse
    at com.alipay.api.internal.util.AlipaySignature.rsaSign(AlipaySignature.java:126)
    at com.alipay.api.DefaultAlipayClient.execute(DefaultAlipayClient.java:355)
    at com.example.order.PayService.pay(PayService.java:62)

错误尝试(浪费的半天)

第一反应是 SDK 版本问题。把支付宝 SDK 从 4.35 升到 4.38,无效。再降回 4.30,还是无效。

停。这里有个判断错误值得记住:SDK 版本号换了三个,错误一模一样 —— 说明问题不在 SDK 本身的代码里,而在它依赖的环境里。NoSuchMethodError 报的类是 org.apache.http.client.utils.URLEncodedUtils,那不是支付宝的包,是 Apache 的。

关键转折

mvn dependency:tree -Dverbose -Dincludes=org.apache.httpcomponents
[INFO] com.example:order-web:jar:1.0.0-SNAPSHOT
[INFO] +- com.alipay.sdk:alipay-sdk-java:jar:4.38.0.ALL:compile
[INFO] |  \- org.apache.httpcomponents:httpclient:jar:4.5.13:compile
[INFO] |     \- org.apache.httpcomponents:httpcore:jar:4.4.13:compile
[INFO] \- org.springframework.boot:spring-boot-starter-data-redis:jar:2.7.18:compile
[INFO]    \- some-internal-cache-starter:jar:1.2.0:compile
[INFO]       \- (org.apache.httpcomponents:httpclient:jar:4.3.6:compile - omitted for conflict with 4.5.13)

两个关键发现:

  1. 项目自己根本没声明 httpclient —— 它完全是传递进来的
  2. 有两个版本:SDK 通过深度 2 要 4.5.13,某个内部 starter 通过深度 3 要 4.3.6。看起来 4.5.13 赢了(路径更短),那为什么还报错?

问题 5: 4.5.13 明明赢了,为什么报错?

因为这是多模块项目。order-web 模块里 4.5.13 赢了,但报错发生在打包后的 fat jar里 —— 而 order-core(深度更深)引入的那个 starter 在另一个模块里被排到了更前面。或者说得更准确:最终 classpath 是各模块解析结果按某种顺序拼接的,nearest wins 只在单模块内成立。

验证(这一步不能省):

# 看打包产物里到底有几份 httpclient
unzip -l target/order-web.jar | grep httpclient
# BOOT-INF/lib/httpclient-4.5.13.jar
# BOOT-INF/lib/httpclient-4.3.6.jar      ← 两份都在!
 
# 用 javap 证明签名差异
javap -classpath httpclient-4.3.6.jar org.apache.http.client.utils.URLEncodedUtils | grep "parse"
#   public static java.util.List<NameValuePair> parse(java.net.URI, java.lang.String)
 
javap -classpath httpclient-4.5.13.jar org.apache.http.client.utils.URLEncodedUtils | grep "parse"
#   public static java.util.List<NameValuePair> parse(java.net.URI, java.nio.charset.Charset)   ← @since 4.5
#   public static java.util.List<NameValuePair> parse(java.net.URI, java.lang.String)           ← deprecated

根因:SDK 调用的是 parse(URI, Charset),这个重载是 4.5 才引入的(javadoc 里明确标 @since 4.5)。classpath 上先被加载的是 4.3.6(它在 BOOT-INF/lib/ 里排在前面),里面只有 parse(URI, String) → NoSuchMethodError。

修复(两步)

<!-- 第一步:在 dependencyManagement 里锁定(优先级高于仲裁规则) -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.13</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpcore</artifactId>
            <version>4.4.13</version>     <!-- 必须一起锁!httpclient 4.5 需要 httpcore 4.4+ -->
        </dependency>
    </dependencies>
</dependencyManagement>
 
<!-- 第二步:在直接用到的模块里显式声明(直接使用原则) -->
<dependencies>
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
    </dependency>
</dependencies>

注意 httpcore 必须一起锁 —— 这是 httpclient 系列最经典的坑:httpclient 4.5 依赖 httpcore 4.4(新增了 org.apache.http.ssl.SSLContexts、org.apache.http.util.Args 等)。只锁 httpclient 不锁 httpcore,会得到一个”4.5 的 client + 4.3 的 core”,症状同样是 NoSuchMethodError,但报错的类名变成 org.apache.http.* 下的其他类。这类”版本错配”比”版本冲突”更难查,因为 dependency:tree 里两个 artifact 各自都只有一个版本,看起来很干净。

防复发

<rules>
    <dependencyConvergence/>
    <banDuplicateClasses>
        <findAllDuplicates>true</findAllDuplicates>
    </banDuplicateClasses>
</rules>

加进 CI。下次有人引入冲突依赖,构建直接红。

教训(两条都值得背)

  1. NoSuchMethodError 99% 是依赖冲突,不是代码问题。 看到它的第一反应应该是 dependency:tree -Dverbose,不是改代码
  2. 显式声明你直接用到的所有依赖,即使它是传递进来的 —— 这是 Maven 的直接使用原则(对应 dependency:analyze 的 “Used undeclared” 警告)。你依赖的是”能力”,不是”某个库恰好带进来了它”

案例二:SPI 文件被覆盖,自动配置静默失效

现象

把 Spring Boot 应用用 shade 打成 uber-jar 之后,本地 mvn spring-boot:run 一切正常,打成 jar 跑起来报:

java.lang.IllegalArgumentException: No auto-configuration classes found in
META-INF/spring.factories. If you are using a custom packaging, make sure that file is correct.

或者更隐蔽的版本:应用能启动,但某几个自动配置类没生效(比如 Redis 的自动配置消失,RedisTemplate Bean 不存在)。

排查

# 看 jar 里 spring.factories 有几份、内容是什么
unzip -l target/app.jar | grep spring.factories
# 只有一行:META-INF/spring.factories
 
unzip -p target/app.jar META-INF/spring.factories | grep EnableAutoConfiguration
# 只看到 spring-boot 自己的那批,业务的 starter 全没了

根因

Shade 打平了所有 jar,而 META-INF/spring.factories 是同名文件。多个 jar 里都有它,打平时只有一个能活下来(后处理的覆盖先处理的)—— 其他的自动配置类就这样静默消失了。

这里有个反直觉的知识点值得讲清楚:在普通 classpath 上,SPI 文件是不会被覆盖的。

// ClassLoader.getResource()    → 返回第一个(会被"覆盖")
// ClassLoader.getResources()   → 返回所有匹配的(枚举)
// ServiceLoader 用的是 getResources() ← 所以普通 classpath 上所有 SPI 文件都会被读到

所以”SPI 文件覆盖”只在两种情况下发生:① shade/uber-jar 打平(物理上只有一个文件了)② 某些容器的 ClassLoader 实现只取第一个。这也是为什么这个问题在 shade 之后才暴露。

修复

<transformers>
    <!-- SPI 文件:合并 + 重定位里面的类名 -->
    <transformer implementation=
        "org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
    <!-- 同名资源:追加合并,不是覆盖 -->
    <transformer implementation=
        "org.apache.maven.plugins.shade.resource.AppendingTransformer">
        <resource>META-INF/spring.factories</resource>
    </transformer>
    <transformer implementation=
        "org.apache.maven.plugins.shade.resource.AppendingTransformer">
        <resource>META-INF/spring.schemas</resource>
    </transformer>
    <transformer implementation=
        "org.apache.maven.plugins.shade.resource.AppendingTransformer">
        <resource>META-INF/spring.handlers</resource>
    </transformer>
</transformers>

更好的修复:不要用 shade 打 Spring Boot 应用。 用 spring-boot-maven-plugin(07.5)—— 它的嵌套 jar 方案里,每个依赖的 META-INF/spring.factories 都还在各自 jar 里,getResources() 能全部枚举到,这个问题根本不存在。

教训

  1. Shade 之后必须做全量回归,尤其是启动路径和自动配置。Shade 的问题不会在编译期暴露,也不会在启动第一行暴露
  2. “能启动”不等于”配置正确”。自动配置静默失效是 Spring Boot 最阴险的失败模式之一 —— 用 /actuator/conditions 和 /actuator/beans 对比 shade 前后的差异
  3. 打包方式会改变依赖的可见性。 本地跑(mvn spring-boot:run,classpath 是目录列表)和生产跑(fat jar)的 classpath 结构上就不一样 —— 这也是”本地好使线上炸”的一大来源