第 06 章(节选) 依赖管理差异
本篇来自《Go 程序员的 Java 修炼之路》第 06 章「第 06 章 Gradle 与构建选型:为什么有人不用 Maven」。 返回:第 06 章索引
06.3 依赖管理:Gradle 跟 Maven 的关键差异
第 05 章讲过的东西(GAV 坐标、仓库三层、依赖树怎么看)这里不重复。这一节只讲差异。
语法对照
同一份依赖,两种写法:
<!-- Maven: pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>// Gradle Kotlin DSL: build.gradle.kts
dependencies {
implementation(platform(libs.spring.boot.dependencies)) // BOM
implementation(libs.spring.boot.starter.web)
runtimeOnly(libs.mysql)
testImplementation(libs.junit.jupiter)
}问题 5: Gradle 版本里那行 platform(...) 是干什么用的?它就是 Maven 的 <dependencyManagement><scope>import</scope></dependencyManagement>——引入一份版本清单,自己不引入任何依赖。下一节细说。
仲裁策略:这是最重要的一点
第 05 章讲过 Maven 的 nearest wins,这里只做对照。
| Maven | Gradle | |
|---|---|---|
| 规则 | 路径最短者优先,深度相同则先声明者胜 | 选最高版本(newest wins) |
| 会不会选到更低版本 | 会,而且常见 | 不会 |
| 结果对声明顺序敏感吗 | 是 | 否 |
| 跟 Go 的 MVS 像吗 | 不像 | 很像(都是”取最高”) |
【思考】同一个依赖图,两种工具分别解析出什么结果?
图一:
你的项目 → A → C 1.0,你的项目 → B → C 2.0图二:你的项目 → A → X → C 1.0,你的项目 → B → C 2.0注意图二里 C 1.0 的路径变成了 3。
参考答案
先给两个图的结论,然后解释为什么这个结论比”谁大谁小”重要得多。
图一(两条路径深度相同,都是 2):
your-project
├── A → C 1.0 (深度 2)
└── B → C 2.0 (深度 2)
- Gradle:C 2.0。规则简单,取最高版本,完事。
- Maven:取决于 A 和 B 谁先声明。 A 在前 → C 1.0;B 在前 → C 2.0。
图二(C 1.0 的深度变成 3):
your-project
├── A → X → C 1.0 (深度 3)
└── B → C 2.0 (深度 2)
- Gradle:C 2.0(最高版本,跟深度无关)。
- Maven:C 2.0(路径 2 比 3 短,nearest wins)。
所以:图一两者可能不同,图二两者恰好相同。
关键结论,比结果本身重要:Gradle 的解析结果是确定的,Maven 的不是。
这句话要展开说清楚。图一里 Maven 的结果是 1.0 还是 2.0,取决于一个没有任何语义的东西——XML 里两行 <dependency> 的先后顺序。而”声明顺序”是什么?它是:
- 某个人某天整理 pom 时随手调整的
- IDE 自动格式化插件排的
- 从别的项目复制粘贴时带过来的
于是这个经典事故成立:有人把 pom 里的依赖按字母序重排了一遍,代码一行没改,生产环境炸了。
代码锚点 —— Gradle 里怎么看到”是谁要求了什么版本”:
./gradlew :order-web:dependencyInsight --configuration runtimeClasspath --dependency jackson-databindcom.fasterxml.jackson.core:jackson-databind:2.15.3
variant "compile" [
...
]
com.fasterxml.jackson.core:jackson-databind:2.15.3
+--- com.thirdparty:report-sdk:1.4.0
| \--- runtimeClasspath
\--- org.springframework.boot:spring-boot-starter-json:3.2.0
\--- org.springframework.boot:spring-boot-starter-web:3.2.0
\--- runtimeClasspath
(com.fasterxml.jackson.core:jackson-databind:2.9.0 -> 2.15.3) // ← 这一行是关键
最后那行 2.9.0 -> 2.15.3 就是 Gradle 版的 omitted for conflict with:某个地方要 2.9.0,最终被选中了 2.15.3。
dependencyInsight 是 Gradle 对应 mvn dependency:tree -Dverbose -Dincludes=xxx 的工具,但用法不同、信息组织也不同:它是”以某个依赖为中心倒查谁引入了它”,而 Maven 那个是”正着打印整棵树”。排查时前者更直接,熟悉成本也更高。
更深一层:为什么”确定性”比”选到最高版本”更值钱?
Gradle 选最高版本,也会出问题——比如 2.15.3 有 breaking change,而某个库只在 2.9.0 上测过。但这类问题是”可诊断的”:你看到 2.9.0 -> 2.15.3,知道发生了什么,可以决定要不要钉版本。
Maven 那类问题是不可诊断的:你看到 tree 里只有 2.9.0,看不到”为什么是它”。你不知道它是被路径规则选中的、还是被声明顺序选中的,也不知道换个环境(不同 Maven 版本、不同依赖顺序)会不会变。问题不可诊断,等于问题不可治理。
这正是 Go 把 MVS 写进规范的原因。 MVS 的三条性质(顺序无关、单调、可重现)不是”算法优雅”,是运维属性:它们保证”任何人在任何机器上跑同一个 go.mod,得到相同结果”。Gradle 拿到了其中两条(顺序无关、单调),Maven 一条都没拿到(它的结果还依赖 pom 里行的顺序)。
顺带补一刀: Gradle 也不是完全确定的——如果你用了动态版本(1.+、latest.release)或 SNAPSHOT,解析结果会随时间变化。所以 Gradle 提供了 failOnDynamicVersions(),第 06.8 节的依赖锁也是为此存在的。任何构建工具的确定性,都需要你主动放弃”可变版本”这个便利去换取。
强制版本:force / strictly / failOnVersionConflict
三种武器,强度递增:
dependencies {
// 1. constraints + strictly:最强,且能说清理由
implementation("com.fasterxml.jackson.core:jackson-databind")
constraints {
implementation("com.fasterxml.jackson.core:jackson-databind") {
version { strictly("2.15.3") }
because("report-sdk 1.4.0 在 2.9.0 上编译,但 2.9.0 有 CVE-2022-42003;" +
"已在 2.15.3 上完成回归测试")
}
}
// 2. force:粗暴版,全局强制
// configurations.all { resolutionStrategy.force("commons-io:commons-io:2.11.0") }
// 3. resolutionStrategy:全局规则
// configurations.all { resolutionStrategy.failOnVersionConflict() }
}| 手段 | 语义 | 强度 | 建议 |
|---|---|---|---|
constraints + strictly | ”这个版本不可协商” | 最强,冲突时构建失败 | 推荐。带 because 能写理由,可审计 |
force | ”强制用这个版本” | 强,静默覆盖 | 慎用。它掩盖问题而不是暴露问题 |
failOnVersionConflict() | ”有冲突就失败” | 治理用 | 老项目第一次开会红一大片,建议先 strictly 清理再开 |
eachDependency | 遍历每个依赖改版本 | 任意 | 灵活但难读,最后手段 |
关于 failOnVersionConflict(): 它是 Gradle 版的 dependencyConvergence(Maven enforcer 那条规则)——把冲突从”运行时事故”提前成”构建期失败”,这是它最大的价值。用法就是上面注释里那行。注意:它的废弃状态在不同 Gradle 版本的 javadoc 里说法不一致,用之前以你手上版本的官方 DSL 文档为准; 更重要的是,即使它可用,它也是”全局开关”,粒度比 strictly 粗,团队里建议优先用 strictly。
./gradlew dependencies --configuration runtimeClasspath > /tmp/deps.txt # 相当于 mvn dependency:list
./gradlew :order-web:dependencyInsight --configuration runtimeClasspath --dependency commons-ioconfiguration vs scope:Gradle 最有价值的改进
Maven 的 scope 解决”classpath 分层”(第 05 章那张 4×4 表)。Gradle 的 configuration 解决的是一个 Maven 根本没解决的问题:传递依赖要不要暴露给下游的编译期。
api vs implementation(由 java-library 插件引入):
plugins {
`java-library` // 注意:必须应用 java-library(不是 java),才有 api / implementation
}
dependencies {
api(libs.jackson.databind) // 下游能"看见"并编译期可用
implementation(libs.guava) // 下游编译时看不见,运行时可用
}语义差别一句话:
| 配置 | 出现在下游的 compileClasspath? | 出现在下游的 runtimeClasspath? |
|---|---|---|
api | 是 | 是 |
implementation | 否 | 是 |
问题 6: 那 implementation 的依赖是不是就”没了”?不是。它照样被打进最终的发布包、照样在运行时可用。 它只是不出现在下游模块的编译 classpath 里。
【思考】为什么要用
implementation?它解决了什么真实问题?这是本章最值得想清楚的一道题。给个抓手:Maven 里改一个最底层模块的一行代码,会发生什么?
参考答案
直接答案:implementation 做了三件事——切断编译耦合、大幅提升增量编译速度、防止下游误用未承诺的 API。第二点是它最大的工程价值。
一、减少编译耦合(最直观的收益)
场景:你的 order-core 内部用 Guava 做集合处理。
// order-core/build.gradle.kts
dependencies {
implementation(libs.guava) // 内部实现细节
}// order-core 内部代码
package com.example.order.core;
import com.google.common.collect.Lists; // 只在 .java 文件内部使用
public class OrderSplitter {
public List<Order> split(Order order) {
return Lists.newArrayList(...); // Guava 只出现在方法体里
}
}现在你把 Guava 换成自己写的工具类(或者换成 Eclipse Collections)。用 implementation,下游模块不需要重新编译——因为它们的编译 classpath 里从来没有 Guava,也就不会有任何代码引用它。
换成 Maven 的 compile scope 呢?Guava 会传递给下游的编译 classpath。于是:
- 下游某个模块的代码里可能直接
import com.google.common.collect.Lists(无意中用了——IDE 自动补全弹出来,顺手就点了) - 你换掉 Guava → 下游编译失败
- 你被迫把”内部实现选型”变成一个需要跨团队协调的 breaking change
implementation 把”我内部用了什么”变成真正的私事。 这是模块化的本质——模块边界的价值就体现在”内部变化不外溢”。
二、大幅提升增量编译速度(这是最值钱的,也是最少被讲清楚的)
这是 implementation 最大的工程价值,很多人只知道第一条。
Maven / api 的世界:
common-utils (改一行代码)
↓ api 传递
order-api
↓
order-core
↓
order-dal
↓
order-web
改 common-utils 一行代码 → 上面五个模块全部重新编译。为什么?因为每个模块的编译 classpath 里都含着前一个模块的所有 api 依赖,javac 无法确定”改了这行会不会影响你”,保守起见只能全编。
implementation 的世界:
common-utils (改一行内部实现)
↓ implementation 不传递编译期
order-core ← 因为 order-core 的编译 classpath 里没有 common-utils 的东西
(只有运行时 classpath 有)
改 common-utils 一行代码 → 只有 common-utils 自己重编译 + 依赖它的模块重新链接(不用重新编译)。
这个差别在 30 个模块的项目上是分钟级的。 Gradle 官方和多个团队的实测都指向同一个量级:把大量 api(或从 Maven 迁来的 compile)改成 implementation 后,典型增量编译时间能下降 50%~90%,取决于依赖图的深度和宽度。
为什么能这么快? 因为 javac 的增量编译是”撤销-重编”模型:一个模块的公开 ABI 变了,所有依赖它的模块要重编。implementation 依赖不出现在下游的编译 classpath,所以:
- 改了
implementation依赖 → 本模块的公开 ABI 没变(只要你没改方法签名)→ 下游不重编 - 改了
api依赖 → 本模块的公开 ABI 有可能变(比如方法签名里用了那个类型)→ 下游必须重编
代码锚点 —— 怎么验证:
# 1. 看看你的模块实际暴露了什么(Gradle 能打印编译 classpath)
./gradlew :order-web:dependencies --configuration compileClasspath
# 2. 改一行内部实现,看有多少模块重编
echo "// touch" >> order-core/src/main/java/com/example/OrderService.java
./gradlew build --console=plain | grep -c "compileJava"
# 3. 对比:把 implementation 全改成 api,再跑一次,数一数 compileJava 的次数这个实验值得做一次。 数字会让你对 implementation 的价值有肌肉记忆,比读十篇文章管用。
三、防止下游误用(“契约清晰”价值)
// 下游代码(如果用了 api 传递,这段代码能编译)
import com.google.common.collect.Lists;
Lists.newArrayList(...); // 你依赖的是 order-core,但你在用 Guava这段代码的问题:你依赖的是 order-core,但你的代码实际依赖的是 Guava。 而 order-core 从来没有承诺过”我会给你 Guava”。
明天 order-core 升级,把 Guava 去掉了 → 你的代码编译失败 → 你会去问 order-core 的维护者”你为什么删了我的 Guava”,而他会一脸茫然:“我从来没给过你 Guava。”
implementation 让这个错误在编译期就发生不了,因为编译器根本看不见 Guava。这是”让正确的用法容易、错误的用法不可能”的经典设计——跟 Go 的未使用 import 直接编译失败是同一类哲学:用编译器强制约束,而不是靠文档和自觉。
// implementation 之后:编译错误
import com.google.common.collect.Lists;
// error: package com.google.common.collect does not exist四、那什么时候必须用 api?
判断标准只有一条:这个依赖的类型有没有出现在你模块的公开签名里(public 方法的参数、返回值、父类、注解)。
// ✅ 必须用 api —— Jackson 的 JsonNode 出现在 public 方法签名里
package com.example.order.api;
import com.fasterxml.jackson.databind.JsonNode;
public interface OrderExportService {
JsonNode exportAsJson(Long orderId); // 下游要调用这个方法,必须看得见 JsonNode
}// ✅ 可以用 implementation —— Guava 只在方法体里
public List<Order> split(Order order) {
return Lists.newArrayList(...); // 没出现在签名里
}实践口诀:默认全用 implementation,编译器报错说”找不到符号”时再改成 api。 从下往上加,比从上往下删容易得多,而且能保证你暴露的每一个 api 都有真实理由。
更深一层:这是 Gradle 对 Maven 的一次实质性改进,不是语法糖。
Maven 的 compile scope 做了一件事:依赖会传递。它没给你”传不传给下游的编译期”这个选项——传递就是全传递。这在 2005 年是合理的简化,但当依赖树从 20 个节点涨到 200 个,这个简化变成了性能问题和耦合问题的共同根源。
Gradle 拆开了一个被 Maven 合并的概念:
| 概念 | Maven | Gradle |
|---|---|---|
| 编译期需要 | compile / provided | implementation / compileOnly |
| 运行时需要 | compile / runtime | implementation / runtimeOnly |
| 下游编译期需要 | 跟”我编译期需要”是同一个 scope | api —— 独立维度 |
最后一行就是那次拆分。 “我需要”和”我需要让别人也看见”是两个不同的问题,Maven 用同一个 scope 回答了它们,Gradle 用两个配置回答。
对照 Go:Go 在这一点上做得比两边都干净。 Go 里根本不存在”传递依赖泄漏到编译期”这个问题——go.mod 里每个直接依赖都写得清清楚楚,你要用一个包,必须在自己的 go.mod 里 require 它。Go 强制”依赖显式声明”,Gradle 用 implementation 达到类似效果,Maven 完全没有这层保护。
顺带说一句,Go 里有个工具叫 go mod tidy,它会删掉你没直接用的 require。它的存在前提是”直接使用的依赖必须自己声明”——这条规则 Java 生态直到 Gradle 的 implementation 才开始逼近。
其他常用 configuration
| Gradle configuration | 编译期 | 运行时 | 传递给下游编译期 | 近似 Maven scope |
|---|---|---|---|---|
implementation | 有 | 有 | 否 | compile |
api | 有 | 有 | 是 | compile(但 Maven 无法区分这两者) |
compileOnly | 有 | 无 | 否 | provided |
compileOnlyApi | 有 | 无 | 是(编译期) | 无对应 |
runtimeOnly | 无 | 有 | 否 | runtime |
testImplementation | 测试 | 测试 | 否 | test |
testRuntimeOnly | 无 | 测试 | 否 | test(部分) |
annotationProcessor | 编译期(APT) | 无 | 否 | 需配 maven-compiler-plugin 的 annotationProcessorPaths |
platform(...) / enforcedPlatform(...) | — | — | — | import scope 的 BOM |
几个补充说明:
compileOnlyApi:你的公开签名里用到了某个类型,但运行时由环境提供(比如 Servlet API 出现在你的接口签名里,而容器会提供)。用得少,但用到时没有替代品。annotationProcessor:Gradle 把注解处理器(Lombok、MapStruct、Dagger)单列成一个配置,Maven 里要配maven-compiler-plugin的annotationProcessorPaths。Gradle 这个设计更清晰,Maven 那边很多人还在往provided里塞 Lombok。enforcedPlatformvsplatform:platform引入的版本会被更高版本覆盖(跟 Maven BOM 行为一致);enforcedPlatform引入的版本强制生效,等同于strictly。默认用platform,除非你明确要强制。
依赖锁:Maven 没有的东西
Maven 要可重现构建,得靠一堆外部手段(第 05 章题 4 讲过:禁 SNAPSHOT、锁插件版本、project.build.outputTimestamp)。Gradle 内置了依赖锁:把解析结果拍平写进文件。
// build.gradle.kts
configurations.all {
resolutionStrategy.activateDependencyLocking()
}
// 需要一个 task 把所有可解析的 configuration 都解析一遍,才能写全锁文件
tasks.register("resolveAndLockAll") {
doFirst {
require(gradle.startParameter.isWriteDependencyLocks) {
"请加 --write-locks 参数运行"
}
}
doLast {
configurations.filter { it.isCanBeResolved }.forEach { it.resolve() }
}
}./gradlew resolveAndLockAll --write-locks # 生成/更新锁文件
./gradlew build # 正常构建,锁定状态下解析结果不变锁文件长这样(gradle.lockfile,每个 project 一份):
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
com.fasterxml.jackson.core:jackson-databind:2.15.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.mysql:mysql-connector-j:8.0.33=runtimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:5.10.1=testCompileClasspath,testRuntimeClasspath
empty=annotationProcessor
注意它的粒度是按 configuration 锁的(每行末尾列出这个版本被哪些 configuration 选中)。这比 go.sum 粗糙,但比 Maven 的”完全没有”强太多。
BOM 支持
dependencies {
// 等价于 Maven 的 import scope:引入版本清单,不引入依赖
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.0"))
// 强制版:版本不可被覆盖,等价于 strictly
implementation(enforcedPlatform("com.fasterxml.jackson:jackson-bom:2.15.3"))
implementation("com.fasterxml.jackson.core:jackson-databind") // 不写版本,BOM 给
// 也可以用于 test 侧
testImplementation(platform("org.junit:junit-bom:5.10.1"))
testImplementation("org.junit.jupiter:junit-jupiter")
}问题 7: Maven 的 BOM 只能写在 dependencyManagement 里,且一个 pom 只能有一个 <parent>。Gradle 有这个限制吗?没有——platform() 就是一个普通的函数调用,你想引几个引几个,而且可以对不同 configuration 引不同 BOM(上面就同时引了 Spring 的和 JUnit 的)。