第 06 章(节选) 核心结论与深度思考题

来自第 06 章「第 06 章 Gradle 与构建选型:为什么有人不用 Maven」。 返回:第 06 章索引

06.9 Go ↔ Java 构建体系综合对照表

维度GoMavenGradle
配置文件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 的构建要处理什么:

  1. 解析 go.mod,算出依赖版本集合
  2. 下载模块源码到 $GOPATH/pkg/mod
  3. 按包依赖拓扑序编译,缓存到 GOCACHE
  4. 链接成一个静态二进制

就这些。没有资源处理、没有代码生成钩子、没有多产物、没有字节码后处理。 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 本章核心结论

  1. Maven 是声明式 + 线性 lifecycle,Gradle 是命令式 + Task DAG。 这不是”两个工具”,是两种心智模型。DAG 更灵活,代价是构建脚本的可读性下限低得多。

  2. Gradle 的三阶段里,Configuration 阶段做不了增量,这是”改一行代码还是要几秒”的根因。解法是 Configuration Cache(6.6 引入,8.1 stable)+ 不在配置阶段做 IO + 用 register/configureEach。

  3. Gradle 的增量构建依赖插件作者正确声明 inputs/outputs,配错了就会”该重跑没重跑”或”不该重跑也重跑”。 Go 的增量是编译器内建的、不可能配错。这是”可配置” vs “内建”的取舍。

  4. 依赖仲裁:Maven 是 nearest wins(结果对声明顺序敏感),Gradle 是 highest version(结果确定)。 迁移时必须 diff 两边的依赖树,每一行差异都是潜在的生产事故。

  5. implementation vs api 是 Gradle 相对 Maven 最有价值的改进:它把”我需要”和”我需要让别人也看见”拆成两个维度,带来编译解耦、增量编译提速 50%~90%、防止下游误用三个收益。默认全用 implementation,编译报错了再改 api。

  6. 依赖锁(gradle.lockfile)是 Gradle 内置的、Maven 没有的能力,配合 strictly 和 failOnVersionConflict(),能把冲突从运行时事故提前成构建期失败。

  7. 新项目一律用 Kotlin DSL + Wrapper + Version Catalog。 Groovy 的 typo 只能靠跑一遍构建发现,Kotlin DSL 的 typo IDE 当场画红线——构建脚本是低频修改、高破坏性的代码,最需要工具帮助。

  8. 选型的核心判断不是”Gradle 更快”,是”你的瓶颈在哪”。 先 profile 你的 CI(依赖下载 / 编译 / 测试 / 打包各占多少),大多数情况下瓶颈不在构建工具。已经在用 Maven 且没痛点的项目,别迁。

  9. 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 参数

老重启的两个原因:

  1. 改了 org.gradle.jvmargs —— 不同 JVM 参数对应不同 daemon,改一次起一个新的,老的还占内存
  2. 内存不够被系统杀 —— -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 声明不正确(最难查,后果最严重)

两种失效方向:

失效方向现象后果
漏声明 inputTask 一直 UP-TO-DATE,但产出是陈旧的错误的构建结果,且被缓存传播到所有机器
多声明 inputTask 每次都重跑,永远不 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 { }强制配置所有 Tasktasks.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,但那都是针对私有模块的例外,不是常规做法)。

这个”强制”带来的性质:

  1. 依赖投毒不可行。 有人劫持了 GOPROXY 或者改了 tag 指向的内容,哈希对不上,构建失败。
  2. 构建可重现。 同一份代码 + 同一份 go.sum,任何时间任何机器拿到相同的依赖内容。
  3. 没有”忘记提交”这个选项。 它必须提交,否则队友构建失败——工具链用失败逼你做对。

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.releasefailOnDynamicVersions()破坏可重现性 + 每次联网解析拖慢构建
生产发布含 SNAPSHOTfailOnChangingVersions()(release profile)可变版本 = 不可重现
子模块自己声明 repositoriesFAIL_ON_PROJECT_REPOS防止”只有某台机器能构建”
硬编码依赖版本模板 + health check 扫描版本散落 = 冲突温床
configuration 阶段做 IOCode 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,然后数一数有多少处版本冲突标记。 那个数字,就是你跟依赖地狱之间的距离。