第 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,这里只做对照。

MavenGradle
规则路径最短者优先,深度相同则先声明者胜选最高版本(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-databind
com.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-io

configuration 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。于是:

  1. 下游某个模块的代码里可能直接 import com.google.common.collect.Lists(无意中用了——IDE 自动补全弹出来,顺手就点了)
  2. 你换掉 Guava → 下游编译失败
  3. 你被迫把”内部实现选型”变成一个需要跨团队协调的 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 合并的概念:

概念MavenGradle
编译期需要compile / providedimplementation / compileOnly
运行时需要compile / runtimeimplementation / runtimeOnly
下游编译期需要跟”我编译期需要”是同一个 scopeapi —— 独立维度

最后一行就是那次拆分。 “我需要”和”我需要让别人也看见”是两个不同的问题,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。
  • enforcedPlatform vs platform: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 的)。