第 06 章(节选) 核心结论与深度思考题
来自第 06 章「第 06 章 Gradle 与构建选型:为什么有人不用 Maven」。 返回:第 06 章索引
06.9 Go ↔ Java 构建体系综合对照表
| 维度 | Go | Maven | Gradle |
|---|---|---|---|
| 配置文件 | go.mod(纯数据,无逻辑) | pom.xml(XML,声明式) | build.gradle.kts(Kotlin 代码,命令式) |
| 构建模型 | 包依赖图,由编译器解析 | 固定 lifecycle(线性 phase 序列) | Task DAG(任意图) |
| 增量构建 | 内建,内容哈希,无配置 | 模块级,能力有限(增量插件) | Task 级 + 文件级,依赖 inputs/outputs 声明 |
| 缓存 | GOCACHE,内建,跨项目天然共享 | 无内置 | 本地 ~/.gradle/caches/build-cache-1 + 可选远程节点 |
| 缓存正确性 | 由编译器保证,不可能配错 | — | 依赖插件作者声明正确,可能配错 |
| 依赖仲裁 | MVS:最高版本,顺序无关 | nearest wins:路径最短,深度相同看声明顺序 | 最高版本,顺序无关 |
| 锁定文件 | go.sum(默认强制,内置) | 无内置(要第三方插件 + 一堆配置) | gradle.lockfile(内置,需主动开启) |
| 间接依赖是否入文件 | 是(// indirect 全拍平) | 否,每次构建现场算 | 否(除非开了依赖锁) |
| 多模块 | 多个 go.mod,无继承,宁可重复 | <modules> 聚合 + <parent> 继承(两个独立机制) | include + 约定插件(buildSrc / includeBuild) |
| 配置复用粒度 | 无(不共享) | 整体继承(单继承) | 插件级,可组合 |
| 本地联调依赖 | replace 指令(一行) | mvn install 装到本地仓库 | includeBuild + 依赖替换 |
| 工具链版本管理 | go.mod 的 go/toolchain 指令,go 命令内建切换 | mvnw(用得少) | Wrapper(强制,每个项目必带) |
| 可定制性 | 极低(无插件机制;复杂需求靠 Makefile) | 中(插件 + 绑定 goal 到 phase) | 极高(图灵完备的脚本) |
| 学习曲线 | 一小时 | 一天能用,一个月理解依赖仲裁 | 一周能用,半年写出好脚本 |
| 构建脚本的”可读性下限” | 高(格式固定) | 高(XML 约束,写不出花活) | 低(取决于作者) |
为什么 Go 能只用一个 go build 打天下,而 Java 生态需要两套复杂工具?
这是本章最后、也是最值得想清楚的问题。
先看 Go 的构建要处理什么:
- 解析
go.mod,算出依赖版本集合 - 下载模块源码到
$GOPATH/pkg/mod - 按包依赖拓扑序编译,缓存到
GOCACHE - 链接成一个静态二进制
就这些。没有资源处理、没有代码生成钩子、没有多产物、没有字节码后处理。 Go 的部署单元是自包含的二进制,构建产物就是它。
再看 Java 的构建要处理什么:
| 环节 | 具体内容 | Go 里有吗 |
|---|---|---|
| 资源处理 | src/main/resources 拷贝到 classpath、占位符替换(filtering)、二进制文件保护 | 无(go:embed 极简) |
| 代码生成 | 注解处理器(Lombok、MapStruct、Dagger)、protobuf/Thrift、OpenAPI 生成客户端 | 无(go generate 是外部约定) |
| 字节码增强 | AspectJ 织入、JaCoCo 插桩、Spring 的 CGLIB 代理、Quarkus 的构建期增强 | 无 |
| 多产物类型 | jar / war / ear / fat jar / native image / javadoc jar / sources jar / 平台专用包 | 只有二进制 |
| 多语言 | Java / Kotlin / Scala / Groovy / 前端资源 / native(JNI) | 只有 Go(+ 少量汇编/cgo) |
| 部署目标 | 应用服务器(Tomcat/WildFly)、容器镜像、云平台、OSGi 容器、Android | 只有二进制 + Docker |
| 测试分层 | 单测 / 集成测试 / 契约测试 / 性能测试,各自 classpath 不同 | go test 一种(有 -short) |
| 质量门禁 | Checkstyle / PMD / SpotBugs / JaCoCo 覆盖率 / 依赖漏洞扫描 | go vet + golangci-lint |
| 发布 | 发布到 Maven 私服、签名、生成 checksum、推 Docker | 打 git tag |
| 环境差异 | 不同环境不同打包(第 05 章讲过为什么该避免,但现实中存在) | 无(一个二进制到处跑) |
这张表就是答案:复杂度来自问题域,不来自工具设计。
Go 的构建模型是”包 + 单一产物”,所以 go build 能覆盖 100% 的需求,剩下的边角料用 Makefile 糊一下就行。Java 的构建模型是”多产物 + 多语言 + 可插拔的处理链”,这个复杂度必须有地方承载——Maven 选择把它放进插件 + 生命周期约定,Gradle 选择把它放进可编程的脚本。
问题 9: 那是不是 Java 就应该统一成一套工具?
不可能,因为这两套方案各自优化了一个不同的目标:
- Maven 优化的是可预测性和治理:任何项目你都看得懂,任何插件行为都绑在固定位置,适合”人多、项目多、要管控”的场景。
- Gradle 优化的是表达力和性能:构建逻辑可以是任意程序,增量和缓存做到极致,适合”构建本身就是复杂工程”的场景。
这两个目标的冲突是根本性的。 你不能既让脚本图灵完备,又保证所有脚本都长得一样。所以两套会长期共存。
那 Go 的”简单”是不是更优越? 在这个特定维度上,是的——但它是用”不支持上面那张表里的大部分场景”换来的。 你让 Go 去构建 Android APK、去织入 AspectJ、去同时编译 Kotlin 和 Scala 并产出带 javadoc 的签名 jar,go build 立刻就不够用了,你会在 Makefile 里重新发明一个 Gradle——而且是个更烂的 Gradle。
这就是”问题域决定工具复杂度”的完整含义:Go 的简单不是设计天才,是作用域受限;Java 的复杂不是设计失败,是作用域宽泛。
06.10 本章核心结论
-
Maven 是声明式 + 线性 lifecycle,Gradle 是命令式 + Task DAG。 这不是”两个工具”,是两种心智模型。DAG 更灵活,代价是构建脚本的可读性下限低得多。
-
Gradle 的三阶段里,Configuration 阶段做不了增量,这是”改一行代码还是要几秒”的根因。解法是 Configuration Cache(6.6 引入,8.1 stable)+ 不在配置阶段做 IO + 用
register/configureEach。 -
Gradle 的增量构建依赖插件作者正确声明 inputs/outputs,配错了就会”该重跑没重跑”或”不该重跑也重跑”。 Go 的增量是编译器内建的、不可能配错。这是”可配置” vs “内建”的取舍。
-
依赖仲裁:Maven 是 nearest wins(结果对声明顺序敏感),Gradle 是 highest version(结果确定)。 迁移时必须 diff 两边的依赖树,每一行差异都是潜在的生产事故。
-
implementationvsapi是 Gradle 相对 Maven 最有价值的改进:它把”我需要”和”我需要让别人也看见”拆成两个维度,带来编译解耦、增量编译提速 50%~90%、防止下游误用三个收益。默认全用implementation,编译报错了再改api。 -
依赖锁(
gradle.lockfile)是 Gradle 内置的、Maven 没有的能力,配合strictly和failOnVersionConflict(),能把冲突从运行时事故提前成构建期失败。 -
新项目一律用 Kotlin DSL + Wrapper + Version Catalog。 Groovy 的 typo 只能靠跑一遍构建发现,Kotlin DSL 的 typo IDE 当场画红线——构建脚本是低频修改、高破坏性的代码,最需要工具帮助。
-
选型的核心判断不是”Gradle 更快”,是”你的瓶颈在哪”。 先 profile 你的 CI(依赖下载 / 编译 / 测试 / 打包各占多少),大多数情况下瓶颈不在构建工具。已经在用 Maven 且没痛点的项目,别迁。
-
Gradle 值得上的场景:Android(强制)、多语言混合、构建逻辑复杂到 XML 表达不了、30+ 模块的 monorepo、编译本身是大头(大量 Kotlin/Scala)。
06.11 深度思考题
题 1:implementation 依赖泄漏的经典案例
你在 order-api 里用了 Guava(implementation(libs.guava))。下游 order-web 的代码里写 import com.google.common.collect.Lists,会怎样?如果 order-web 自己也声明了 Guava 呢?这跟 Maven 的 compile scope 对比如何?
参考答案
直接答案:编译不过。这就是 implementation 的核心价值——让”误用传递依赖”这件事在编译期就不可能发生。
场景一:order-web 没有声明 Guava
// order-api/build.gradle.kts
dependencies {
implementation(libs.guava) // 不暴露给下游编译期
}// order-web/src/main/java/.../ReportController.java
import com.google.common.collect.Lists; // ← 编译错误error: package com.google.common.collect does not exist
注意运行时是没问题的 —— Guava 照样在 order-web 的 runtimeClasspath 里(因为 implementation 会传递到运行时)。所以如果你用反射绕过编译期检查:
Class<?> listsClass = Class.forName("com.google.common.collect.Lists"); // 能成功这恰恰说明 implementation 的边界是编译期可见性,不是”依赖是否存在”。
场景二:order-web 自己声明了 Guava
// order-web/build.gradle.kts
dependencies {
implementation(project(":order-api"))
implementation(libs.guava) // 自己也声明了一份
}现在编译能过了。但这里有个陷阱:两个模块声明的 Guava 版本可能不同。
order-api → guava 32.1.3-jre
order-web → guava 31.0-jre
Gradle 会仲裁成 32.1.3-jre(最高版本)。order-web 的编译 classpath 里是 32.1.3(它自己声明的那个被升上去了),运行时也是 32.1.3。两个模块看到的是同一个版本,不会出问题。
对比 Maven 的 compile scope:
<!-- Maven: 传递依赖泄漏 -->
<!-- order-api/pom.xml -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
<!-- scope 默认 compile,会传递给下游编译期 -->
</dependency>
<!-- order-web/pom.xml:什么都没声明,但能用 Guava -->// order-web 里的代码,编译通过 —— IDE 的自动补全把 Guava 弹出来了,顺手就用
import com.google.common.collect.Lists; // ✅ 能编译问题在于这段代码的”依赖声明”和”实际依赖”不一致: order-web/pom.xml 里没有 Guava,但它的代码依赖 Guava。
明天 order-api 的维护者把 Guava 换成 Eclipse Collections(他完全有权这么做——order-api 从来没承诺过提供 Guava):
[ERROR] order-web/src/main/java/.../ReportController.java:[3,38]
package com.google.common.collect does not exist
order-web 的构建挂了,而 order-web 一行代码都没改。 接下来发生的是跨团队扯皮:“你为什么删了我的 Guava?” / “我什么时候给过你 Guava?”
代码锚点 —— 怎么发现这类”隐式依赖”:
# Maven:找出"用了但没声明"的依赖(靠传递进来的)
mvn dependency:analyze
# [WARNING] Used undeclared dependencies found:
# [WARNING] com.google.guava:guava:jar:32.1.3-jre:compile ← 就是这个
# Gradle 没有直接等价的内置 task,但可以用这个思路:
# 1. 把所有 implementation 改成 compileOnly,看哪些模块编译失败
# 2. 或者用第三方插件(dependency-analysis-gradle-plugin)顺带一个实战判断: 如果 order-web 确实需要用 Guava(不是误用,是真的要用),正确做法是显式声明它。这跟 Go 的哲学完全一致——你 import 什么,就要在 go.mod 里 require 什么,不靠别人捎带。 implementation 的作用是帮你发现这件事,而不是让你绕开它。
更深一层:这是”显式依赖原则”(Explicit Dependencies Principle)的一次工程落地。
Go 把这条原则做到了极致:未使用的 import 编译失败,使用了但没 require 的包 go mod tidy 会自动补上。编译器和工具链共同保证”声明”和”使用”永远一致。
Maven 完全没有这层保护——compile scope 让传递依赖自动进入下游编译期,于是”声明”和”使用”可以长期不一致,直到某天崩塌。
Gradle 用 implementation 把这条原则做成了默认行为:你想用就得自己声明。它没有像 Go 那样做成语言级强制(因为 JVM 的 classpath 模型不支持),但它把这个选择暴露给了库作者——你有权决定你的实现细节不外泄。
最后一个观察: 从 Maven 迁到 Gradle 的项目,第一次把 compile 全量换成 implementation 后,通常会编译失败一大片。这些失败全是”依赖泄漏”的证据——每一个错误都在告诉你”这个模块在用别人没承诺过的东西”。所以迁 Gradle 时不要急着把这些错误改成 api 糊过去,先看看每个错误对应的是”真的需要暴露”还是”本来就该自己声明”。
题 2:为什么 Gradle 理论上可以比 Maven 快很多,但很多人实际用起来反而更慢?
给一个排查清单。
参考答案
直接答案:因为 Gradle 的速度优势全都是”需要正确配置才能拿到”的,而 Maven 的慢是”默认就那样、不会更糟”。Gradle 给的是上限,不是默认值。
先说清楚一件事:Gradle 的快不是免费的,它是抵押了”配置复杂度”换来的。 下面是”抵押品没兑现”的六种典型情况,按出现频率排序。
一、Configuration 阶段做了重活(最常见)
// ❌ 这些都在 configuration 阶段跑,每次构建都执行
val gitHash = "git rev-parse HEAD".runCommand()
val buildTime = LocalDateTime.now() // 更狠:每次都不一样,Configuration Cache 永远不命中
val props = File("build-config.properties").readText()
File("modules").listFiles()?.forEach { ... } // 遍历目录树排查方法:
./gradlew build --dry-run # 只走配置阶段
# 如果这个数字 > 2 秒,先优化脚本,别折腾缓存修复: 全部改成 Provider(惰性求值)或放进 Task 的 doLast:
val gitHash: Provider<String> = providers.exec {
commandLine("git", "rev-parse", "HEAD")
}.standardOutput.asText.map { it.trim() }
// 只有真正被 Task 用到时才执行二、没开 daemon / daemon 老重启
./gradlew --status # 看有几个 daemon,各是什么版本、什么 JVM 参数老重启的两个原因:
- 改了
org.gradle.jvmargs—— 不同 JVM 参数对应不同 daemon,改一次起一个新的,老的还占内存 - 内存不够被系统杀 ——
-Xmx配太小,daemon OOM 后重启,然后你看到”第一次构建特别慢”反复出现
# 一次性配好,别老动
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m大型项目(30+ 模块)通常需要 4g 或更多。 配小了的症状是构建到一半变慢、或者报 GC overhead limit exceeded。
三、没开并行 / 没开缓存
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true这三个不开,你用的是”打了折扣的 Gradle”——只拿到了 Task DAG 的灵活性,没拿到性能。
注意 CI 上的特殊性: daemon 在 CI 上收益有限(每次新容器),但 Build Cache 和 Configuration Cache 在 CI 上收益很大,前提是你把 ~/.gradle/caches 挂到 CI 缓存里:
cache:
paths:
- .gradle/caches
- .gradle/wrapper四、插件的 inputs/outputs 声明不正确(最难查,后果最严重)
两种失效方向:
| 失效方向 | 现象 | 后果 |
|---|---|---|
| 漏声明 input | Task 一直 UP-TO-DATE,但产出是陈旧的 | 错误的构建结果,且被缓存传播到所有机器 |
| 多声明 input | Task 每次都重跑,永远不 UP-TO-DATE | 增量构建完全失效,比 Maven 还慢 |
排查方法:
# 手工对照实验
touch src/main/resources/config.yml && ./gradlew compileJava # 期望 UP-TO-DATE
echo "// x" >> src/main/java/Foo.java && ./gradlew compileJava # 期望真的执行
./gradlew build --scan # Build Scan 里能看到每个 Task 为什么被判定为 out-of-date--scan 给出的 Build Scan 是排查这类问题的核武器,它会告诉你”Task X 重跑是因为 input 属性 Y 变了”。
五、用了动态版本或 SNAPSHOT(隐蔽且致命)
dependencies {
implementation("com.example:order-api:1.+") // ❌ 动态版本
implementation("com.example:order-sdk:latest.release") // ❌ 更糟
implementation("com.example:order-core:1.0-SNAPSHOT") // ❌ 可变版本
}后果:
- 动态版本默认缓存 24 小时(
cacheDynamicVersionsFor),过期后每次构建都要联网查询元数据 - SNAPSHOT 同理(
cacheChangingModulesFor,默认 24 小时) - 依赖解析发生在 configuration 阶段或 Task 执行前,网络往返会把几秒钟变成几十秒
- 最糟的是:它同时破坏了可重现性和 Configuration Cache 的命中率
修复:
configurations.all {
resolutionStrategy {
cacheDynamicVersionsFor(0, "seconds") // 或者干脆禁用动态版本
failOnDynamicVersions() // 有动态版本直接失败
failOnChangingVersions() // 有 SNAPSHOT 直接失败
activateDependencyLocking() // 上依赖锁
}
}这是本章最该记住的排查项之一。 我见过太多”Gradle 很慢”的项目,根因就是某个模块里躺着一行 1.+。
六、脚本写成了”反模式”
| 反模式 | 问题 | 正确写法 |
|---|---|---|
tasks.create("x") | 急切注册,configuration 阶段就实例化 | tasks.register("x") |
tasks.all { } | 强制配置所有 Task | tasks.withType<T>().configureEach { } |
subprojects { } 里塞一大堆逻辑 | 项目间耦合,破坏缓存和并行 | 约定插件 |
afterEvaluate { } | 延迟到配置末尾执行,破坏 Configuration Cache | 用 Provider 惰性求值 |
在 buildSrc 里放频繁变动的东西 | 改一行 → 整个配置层作废 | 频繁变动的放 Version Catalog |
在 configuration 阶段 configurations.xxx.resolve() | 强制提前解析依赖,且结果可能不是最终的 | 让 Task 自己解析 |
完整排查清单(按顺序走):
# 1. 测 Configuration 开销(> 2 秒说明脚本有问题)
./gradlew build --dry-run
# 2. 看 daemon 状态(多个 daemon / 频繁重启 = 配置问题)
./gradlew --status
# 3. 检查 gradle.properties(三个开关开了没)
cat gradle.properties | grep -E "parallel|caching|configuration-cache|jvmargs"
# 4. 搜动态版本和 SNAPSHOT(高危)
grep -rn "\.+\|latest\.\|SNAPSHOT" --include="*.gradle*" --include="*.toml" .
# 5. 看哪些 Task 被跳过、哪些真跑了
./gradlew build --console=plain | grep -E "UP-TO-DATE|FROM-CACHE"
# 6. 生成 Build Scan,看 phase 级耗时和 Task 失效原因
./gradlew build --scan
# 7. 怀疑缓存骗了你:强制全量做对照
./gradlew clean build --rerun-tasks更深一层:这是”可配置性”的账单,跟 06.1 节的对照是同一件事。
Go 的构建速度是默认值,你没法让它变慢(除了把 GOCACHE 挪到很慢的磁盘上)。Gradle 的构建速度是一个区间,下限很低(配置错了比 Maven 还慢),上限很高(配置对了快一个数量级)。
这个区间的存在,正是”可编程构建”的代价。 你获得了表达任意构建逻辑的能力,同时获得了”把构建写得很慢”的能力。工具不会拦你。
所以给团队的实操建议是:Gradle 项目必须有”构建规范”和”构建性能的 CI 卡点”。 比如:
- 禁止动态版本和 SNAPSHOT 发布(用
failOnDynamicVersions()强制) --dry-run时间纳入 CI 监控,超过阈值报警- 每个新插件引入前,确认它的 inputs/outputs 声明是完整的(看它有没有
@CacheableTask)
没有这套规范,Gradle 项目的构建性能会随着人员流动而缓慢劣化——每个人都加一点点”配置阶段的重活”,两年后没人记得它为什么这么慢。 Maven 不会有这个问题,因为它从设计上就不给你加这些的机会。
题 3:依赖锁(lockfile)该不该提交到 Git?为什么 Go 的 go.sum 一定要提交,而 Java 生态对此不那么执着?
参考答案
直接答案:该提交。Java 生态”不执着”不是因为不需要,是因为历史上没有内置能力 + Maven 的可重现性欠账太多,大家默认接受了”差不多就行”。这个默认值是错的。
先说 Go 为什么是强制的。
go.sum 记录每个模块 zip 的内容哈希(dirhash):
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cV7A1jEEdVzd2AwG+h+Sq41aF9+UsCw0=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrbhFX/dtASuQDlWy0/kUBqiFLYHSOhTZgPrBpNzs4=
它的强制性体现在:go build 每次都会校验,不匹配直接失败,没有开关可以关掉(除非设 GOFLAGS=-mod=mod 加 GONOSUMDB,或者 GOPRIVATE/GONOSUMCHECK,但那都是针对私有模块的例外,不是常规做法)。
这个”强制”带来的性质:
- 依赖投毒不可行。 有人劫持了
GOPROXY或者改了 tag 指向的内容,哈希对不上,构建失败。 - 构建可重现。 同一份代码 + 同一份 go.sum,任何时间任何机器拿到相同的依赖内容。
- 没有”忘记提交”这个选项。 它必须提交,否则队友构建失败——工具链用失败逼你做对。
Java 侧为什么松?三个历史原因,都不是好理由。
原因一:Maven 长期没有内置锁机制。 go.sum 是 2018 年(Go 1.11 模块系统)就有的;Gradle 的依赖锁要到 2019 年(Gradle 5.0)才有,且默认关闭;Maven 到现在都没有内置的(要靠 maven-enforcer-plugin + 一堆配置,或者第三方插件)。
“没有能力”被误读成了”不需要”。
原因二:Maven 生态默认接受可变版本。 SNAPSHOT 是官方一等公民,1.0-SNAPSHOT 每天被覆盖几十次。在一个”版本本来就可变”的文化里,锁文件的价值被稀释了——你锁了版本号,但那个版本号指向的内容还是可变的。
Go 从设计上就不允许可变版本(伪版本号里带 commit hash),所以 go.sum 能锁死一切。这是前置条件,不是决心问题。
原因三(最真实):可重现构建的成本被低估。 让一个五年的 Maven 项目达到逐字节可重现,要做的事第 05 章题 4 列过六层:禁 SNAPSHOT、锁依赖、锁插件、锁 Maven 版本、project.build.outputTimestamp、固定 Docker 镜像摘要。很多团队算了算,觉得”我们没出过事”就跳过了。
那 gradle.lockfile 该不该提交?
该。 理由三条:
1. 它防的是”上游偷偷改了东西”。 没有锁文件,你依赖 spring-boot-starter-web:3.2.0,Gradle 每次都会去解析它的传递依赖。某天 Jackson 发了个 2.15.4(可能是个有问题的发布),你的下次构建就自动用上了——你的代码一行没改,行为变了。
./gradlew resolveAndLockAll --write-locks
git add gradle.lockfile */gradle.lockfile
git commit -m "lock: 锁定依赖版本"提交后,CI 上 ./gradlew build 会用锁定的版本。要升级必须显式 --write-locks,这是一次有记录的、可 review 的变更。
2. 它让”依赖升级”变成一个显式动作。 这是流程价值,不是技术价值。没有锁文件,依赖升级是”悄悄发生的”;有锁文件,升级必须走一个 PR,PR 的 diff 就是版本变更清单。
3. 它跟 Configuration Cache / Build Cache 协同。 依赖版本稳定 → 缓存命中率高 → 构建快。这是个额外红利。
代价和注意事项:
| 注意点 | 说明 |
|---|---|
| 每个 project 一份 | 不是根目录一个文件,是 order-api/gradle.lockfile、order-core/gradle.lockfile……都要提交 |
| 粒度比 go.sum 粗 | 锁的是 group:artifact:version,不锁内容哈希。要内容校验得另开 dependency verification(verification-metadata.xml) |
| 要配 CI 卡点 | 否则大家本地跑 --write-locks,锁文件会被无声更新 |
| 多平台锁 | 如果项目跨平台(mac 开发 / Linux CI),不同平台的传递依赖可能不同,要锁多个 configuration |
# CI 上应该这样跑:不允许隐式更新锁文件
./gradlew build --lock-mode=STRICT
# 或者用锁文件做校验,不一致就失败内容校验那一层(dependency verification):
./gradlew --write-verification-metadata sha256 help # 生成 verification-metadata.xml<!-- gradle/verification-metadata.xml -->
<verification-metadata>
<components>
<component group="com.fasterxml.jackson.core" name="jackson-databind" version="2.15.3">
<artifact name="jackson-databind-2.15.3.jar">
<sha256 value="..." origin="Generated by Gradle"/>
</artifact>
</component>
</components>
</verification-metadata>这才是 Go 的 go.sum 在 Gradle 里的真正对应物 —— 锁文件锁版本,verification 锁内容。两个都开,你才有 Go 级别的保障。
更深一层:这个差异的根源是”默认值”的设计哲学。
Go 的哲学是:安全性应该是默认值,想不安全要主动付出努力(设环境变量、加 flag)。 所以 go.sum 强制、模块内容不可变、校验默认开。
Java 生态的哲学是:兼容性应该是默认值,想安全要主动配置。 所以锁文件默认关、SNAPSHOT 允许、校验默认无。
两个哲学各自服务自己的历史约束: Go 是 2012 年的新语言,可以从零设计;Maven 是 2004 年的工具,那时”构建可重现”还不是被普遍认识到的需求,而向后兼容压倒一切。
但对今天的你来说,判断标准很清楚: 如果你的系统需要回答”生产环境跑的到底是什么代码”(金融、医疗、政务、任何需要审计的系统),或者你的团队超过 10 个人(人一多,上游依赖的意外变更就会变成事故),锁文件 + 内容校验就是必需品,不是可选项。
最后一句实操建议:不要追求一步到位。 按这个顺序做,每一步都有独立收益:
1. 禁 SNAPSHOT 发布(enforcer 的 requireReleaseDeps / failOnChangingVersions)
2. 禁用动态版本(failOnDynamicVersions)
3. 上依赖锁(gradle.lockfile,提交到 Git)
4. 上依赖内容校验(verification-metadata.xml)
5. 锁构建工具版本(wrapper,已经在做)
6. 逐字节可重现(project.build.outputTimestamp 的 Gradle 等价物 + 固定基础镜像)
前两步几乎没有成本,今天就该做。
题 4(开放题):如果让你给团队制定构建工具规范,你会怎么写?
【思考】
假设你是那个 20 人团队的构建负责人,要给 10 个服务定规范。几个必须回答的点:
- 新项目用 Gradle 还是 Maven?老项目要不要迁?
- 项目模板(脚手架)里该预置什么?
- 禁止事项清单怎么列?怎么用工具强制而不是靠自觉?
- CI 上有哪些卡点?
- 依赖升级流程怎么走?
想清楚”哪些靠工具强制、哪些靠 code review、哪些靠文档”,一份规范才真的能落地。
参考答案
先给框架:一份能落地的规范,核心不是”写什么规则”,是”每条规则由谁来执行”。
我把规范分成三层:
| 层 | 执行者 | 特点 | 覆盖比例 |
|---|---|---|---|
| 强制层 | 工具(构建失败 / CI 红) | 不可能违反 | 能覆盖的都放这层,目标 70% |
| 模板层 | 脚手架(新项目自动带上) | 默认正确 | 能模板化的都放这层,目标 25% |
| 约定层 | Code review + 文档 | 靠人 | 只放真的没法自动化的,最多 5% |
大多数人写规范失败,是因为 90% 的内容都放在了约定层。 人会忘、会赶工期、会抄错。规则不在构建里卡死,就等于没有。
一、工具选型(约定层,但一旦定下来就是硬约束)
新项目:Gradle + Kotlin DSL + Version Catalog + Wrapper(默认)
例外:Android 模块(Gradle,强制)、纯库项目且团队不熟悉 Gradle(Maven)
老项目:默认不迁。触发迁移评估的条件(满足任意一条):
- 全量 CI > 10 分钟,且 profile 显示编译占 50% 以上
- 需要 Maven 无法表达的构建逻辑
- 模块数 > 30 且互相依赖复杂
迁移必须由一个人端到端负责,包含依赖树 diff 验证。不接受"边写业务边迁"。
为什么新项目默认 Gradle? 不是因为它快,是因为 Kotlin DSL + Version Catalog + implementation 这三个东西加一起,长期维护成本更低。而新项目没有历史包袱,学习成本最低。
为什么老项目默认不迁? 见 06.7 那个量化分析——大多数情况下瓶颈不在构建工具。
二、项目模板(模板层)
脚手架里必须预置:
gradle/
├── libs.versions.toml # Version Catalog,预置公司标准依赖集
└── wrapper/gradle-wrapper.properties # distributionUrl 指向内网镜像 + sha256
buildSrc/src/main/kotlin/
├── java-conventions.gradle.kts # Java 版本、编码、编译参数
├── test-conventions.gradle.kts # JUnit 5、测试日志、覆盖率
└── publish-conventions.gradle.kts # 发布配置、POM 元数据
gradle.properties # daemon/parallel/caching/configuration-cache
.detekt.yml 或 checkstyle.xml # 静态检查
关键:模板要带一个”健康检查” task,新项目第一天就能跑:
// buildSrc 里的约定插件
tasks.register("buildHealthCheck") {
doLast {
val problems = mutableListOf<String>()
if (System.getenv("CI") == null && !File(".gradle/caches").exists()) {
problems += "建议开启 Build Cache"
}
// 检查有没有动态版本
configurations.filter { it.isCanBeResolved }.forEach { conf ->
conf.dependencies.forEach { dep ->
if (dep.version?.endsWith("+") == true || dep.version?.contains("latest.") == true) {
problems += "动态版本: ${dep.group}:${dep.name}:${dep.version}"
}
}
}
if (problems.isNotEmpty()) {
throw GradleException("构建健康检查失败:\n" + problems.joinToString("\n"))
}
}
}三、禁止事项(强制层 —— 这是全文最重要的一段)
每一条都必须有”让构建失败”的 enforce 手段,否则不写进规范。
// buildSrc/src/main/kotlin/dependency-rules.gradle.kts
configurations.all {
resolutionStrategy {
// 1. 禁止动态版本
failOnDynamicVersions()
// 2. 禁止 SNAPSHOT(发布时)
if (project.hasProperty("release")) {
failOnChangingVersions()
}
// 3. 依赖锁
activateDependencyLocking()
}
}
// 4. 禁止模块自己声明仓库(统一在 settings.gradle.kts)
// → RepositoriesMode.FAIL_ON_PROJECT_REPOS 已经在 settings 里配了
// 5. 禁止在子模块硬编码版本号(必须走 Version Catalog 或 platform)
// → 靠 buildHealthCheck task 扫描
// 6. 禁止 api 依赖泛滥:api 数量超过 implementation 的 30% 就报警| 禁止项 | 强制手段 | 理由 |
|---|---|---|
动态版本 1.+ / latest.release | failOnDynamicVersions() | 破坏可重现性 + 每次联网解析拖慢构建 |
| 生产发布含 SNAPSHOT | failOnChangingVersions()(release profile) | 可变版本 = 不可重现 |
| 子模块自己声明 repositories | FAIL_ON_PROJECT_REPOS | 防止”只有某台机器能构建” |
| 硬编码依赖版本 | 模板 + health check 扫描 | 版本散落 = 冲突温床 |
| configuration 阶段做 IO | Code review + --dry-run 卡点 | 拖慢每次构建 |
subprojects {} 里塞复杂逻辑 | Code review + 约定插件模板 | 项目耦合,破坏缓存 |
提交 gradle.lockfile 之外改了依赖却没更新锁文件 | CI 用 --lock-mode=STRICT | 锁文件形同虚设 |
用 api 暴露实现细节 | Code review(约定层) | 这个没法自动判断,靠 review |
最后一条诚实地标了”靠 review” —— 不是所有事都能自动化,但要明确标注哪些不能,而不是假装都卡住了。
四、CI 卡点(强制层)
# 流水线阶段
stages:
- lint # 静态检查 + 构建健康检查
- build # 编译 + 单测
- verify # 集成测试 + 覆盖率 + 依赖漏洞扫描
- publish # 发布
# 关键配置
build:
script:
- ./gradlew buildHealthCheck
- ./gradlew build --lock-mode=STRICT # 锁文件不一致就失败
- ./gradlew dependencyCheckAnalyze # OWASP 依赖漏洞扫描
cache:
key:
files: ["gradle/libs.versions.toml", "**/build.gradle.kts"]
paths: [".gradle/caches", ".gradle/wrapper"]
# 性能监控卡点(这条很多团队没有,但很有价值)
perf-gate:
script:
- ./gradlew help --dry-run # 测 Configuration 时间
- ./gradlew build --scan
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"' # 每周跑一次,结果发到群里性能监控卡点是最容易被忽略、长期收益最高的一条。 构建性能是缓慢劣化的:每人每次加一点点,半年后发现”怎么这么慢”,但没人知道是哪次提交开始的。每周跑一次 --scan,把 Configuration 时间和全量构建时间画成趋势图,能让你在劣化到不可接受之前就发现它。
五、依赖升级流程(约定层 + 工具辅助)
# 1. 自动发现可用更新(工具做)
./gradlew dependencyUpdates # Gradle Versions Plugin
# 2. 人工决策升级哪些(人做,这是唯一需要判断的环节)
# - 安全补丁:立即升
# - patch 版本:批量升,跑测试
# - minor 版本:逐个升,看 changelog
# - major 版本:单独 PR,要评估
# 3. 改 Version Catalog(唯一入口)
vim gradle/libs.versions.toml
# 4. 更新锁文件 + 验证
./gradlew resolveAndLockAll --write-locks
./gradlew build
git diff gradle.lockfile # diff 就是这次升级的完整清单,review 看这个
# 5. 提交 PR,标题格式:[deps] 升级 jackson 2.15.3 → 2.16.0流程设计的关键点:让 PR 的 diff 就是变更清单。 因为改的是 TOML + lockfile,reviewer 扫一眼 diff 就知道升了什么、影响了哪些 configuration。这比在 20 个 pom.xml 里找版本号强太多。
频率建议: 每月一次常规升级 + 安全补丁随时升。不要攒半年——攒得越久,升级的风险和成本越高(这是所有依赖管理的第一定律)。
六、一份规范好不好,用一个标准检验
新同事第一天入职,clone 项目,不看任何文档,能否在 30 分钟内成功构建并跑通测试?
如果答案是”能”,说明你的模板层和强制层做得够好。
如果需要他先读 20 页 wiki、配三个环境变量、手动改两个配置文件——那你的规范是文档,不是工程。 文档会被绕过,工程不会。
更深一层:构建规范的终极目的不是”统一风格”,是”降低认知负担”。
Go 生态在这件事上给了我们最好的示范:go build、go test、go vet、gofmt 四个命令覆盖 95% 的场景,所有 Go 项目都一样,新人不看文档就能上手。这不是因为 Go 程序员更守规矩,是因为 Go 把规范做进了工具。
Java 生态做不到这么极致(问题域太宽,见 06.9 那张表),但方向是一样的:能塞进工具的规则,就不要留在文档里;能塞进模板的配置,就不要让人手写。 每一条”靠自觉”的规则,都是在给未来的某次生产事故留门。
下一章预告
这一章回答了”为什么这个世界需要第二套构建工具”,也给了选型清单。但有个东西从头到尾被反复提及、却一直没正面处理:
classpath 里真的出现了两个版本的同名类,会发生什么?
第 05 章埋的那个 NoSuchMethodError 已经解释过根因(运行时按全限定名寻址),但那是”选到了错的版本”。还有一种更难的情况:两个版本的类同时在 classpath 里,JVM 加载哪个取决于 jar 的物理顺序,而这个顺序由依赖树遍历、打包插件实现、甚至文件系统目录遍历顺序共同决定——于是”这台机器对,那台机器错”。
第 07 章正面处理它:Jar Hell 的完整图景、ClassLoader 的双亲委派与隔离、Shade 重定位的原理和代价、OSGi 和 JPMS 为什么没能普及、banDuplicateClasses 怎么用。
在那之前,做一件事:打开你手边的 Java 项目,如果是 Maven 就跑 mvn dependency:tree -Dverbose,如果是 Gradle 就跑 ./gradlew dependencies --configuration runtimeClasspath,然后数一数有多少处版本冲突标记。 那个数字,就是你跟依赖地狱之间的距离。