第 06 章(节选) 心智模型与三层架构
本篇来自《Go 程序员的 Java 修炼之路》第 06 章「第 06 章 Gradle 与构建选型:为什么有人不用 Maven」。 返回:第 06 章索引
06.1 心智模型:Gradle 跟 Maven 根本不是一类东西
第 05 章讲 Maven 的时候,反复出现一个词:lifecycle。它是线性的、固定的、写死在 Maven 里的:
validate → compile → test → package → verify → install → deploy
你说”我要到 package 这个状态”,Maven 就把前面所有 phase 依次跑一遍。你想在某个环节插一步自定义逻辑,唯一办法是把插件的 goal 绑定到某个 phase,比如把 shade 绑到 package。这个模型服务了二十年,因为它的好处很实在:你拿到任何一个陌生 Maven 项目,不看构建脚本就知道 mvn install 会发生什么。
Gradle 换了个地基。
Maven:声明式 + 约定优于配置。 你在 XML 里说”我要什么”(我要一个依赖、我要编译器用 17),Maven 按固定 lifecycle 执行。XML 不是程序,是一份数据。
Gradle:命令式 + 可编程。 你在脚本里写”怎么构建”。build.gradle.kts 是一段真的会被编译执行的 Kotlin 代码。构建过程不是一条线,是一张有向无环图(DAG)——节点是 Task,边是依赖关系。
// build.gradle.kts —— 注意这是代码,不是配置
tasks.register("generateVersion") {
val out = layout.buildDirectory.file("generated/version.txt")
inputs.property("version", project.version.toString())
outputs.file(out) // 声明输出
doLast { // doLast = 这个 Task 的 action
out.get().asFile.apply { parentFile.mkdirs() }.writeText(project.version.toString())
}
}
// 让 processResources 依赖它:这就是往 DAG 里加一条边
tasks.named<ProcessResources>("processResources") {
from(tasks.named("generateVersion"))
}问题 1: 上面这段代码里,generateVersion 被绑到哪个”阶段”了?
答案是没有阶段。它只声明了一件事:processResources 需要我的输出。运行 ./gradlew test 时 Gradle 会顺着 DAG 判断是否需要它;运行 ./gradlew clean 时它根本不会被提起。
【思考】为什么”构建是 DAG”比”构建是线性 phase 序列”更灵活?代价是什么?
先自己想两个具体场景:①一个项目里同时有 Java 和 TypeScript,前端要先
npm run build再把产物塞进 jar;②要根据 git 分支名决定打哪个环境的包。
参考答案
直接答案:DAG 表达的是”谁需要谁”,线性序列表达的是”先后顺序”。前者是本质信息,后者是它的一种退化形式。
先看 Maven 那两种场景有多别扭。
场景一(多语言混合):没有现成的位置。你只能挑一个 phase 把 exec-maven-plugin 绑上去,于是你要开始纠结”绑 generate-resources 还是 prepare-package”——你在用一个跟你的意图无关的坐标轴去表达意图。你的真实意图只是”打 jar 之前必须有前端产物”。
场景二(按分支决定环境):更麻烦。XML 里没有 if。你要么用 profile + 属性激活(可激活条件有限),要么写自定义插件,要么靠 exec-maven-plugin 跑一段脚本把结果塞进属性。每种办法都是在往声明式模型上打补丁。
Gradle 里这两件事都是一等的:
// 场景一:给 jar 加一条依赖边,语义直接
tasks.named<Jar>("jar") {
from(tasks.named("buildFrontend")) // buildFrontend 是个 Exec 类型的 Task
}
// 场景二:就是 Kotlin 的 if
val branch = System.getenv("GIT_BRANCH") ?: "dev"
if (branch == "main") {
tasks.named<Test>("test") { enabled = false }
}再看代价 —— 这才是重点,而且它比收益更值得记住。
| 维度 | Maven(线性 phase) | Gradle(Task DAG) |
|---|---|---|
| 表达”顺序” | 天然 | 天然 |
| 表达”依赖” | 只能靠顺序近似 | 天然 |
| 插入自定义步骤 | 绑定 goal 到 phase,位置受限 | 定义 Task + 加边 |
| 条件逻辑 | 靠 profile,能力很弱 | 就是 if,随便写 |
| 可预测性 | 极高:不看脚本也知道会发生什么 | 低:必须读脚本才知道 |
| 脚本可读性 | 差异小,都是同一套 XML | 差异极大,取决于作者水平 |
| 犯错的代价 | 通常写不出太离谱的东西 | 一个熟练的人能写出没人看得懂的构建 |
核心代价一句话:灵活性的账单由”后来读代码的人”支付。
Maven 的 XML 让你写不出花活,这被很多人吐槽,但它同时保证了任何两个 Maven 项目的构建脚本差异是可控的。Gradle 取消了这层保护:你可以把构建逻辑抽成函数、放进 buildSrc、写成插件、在 configuration 阶段遍历文件系统动态注册 Task——这些都是真实需求,也都是真实灾难来源。
// 真实项目见过:configuration 阶段遍历目录动态建 Task,
// 于是 ./gradlew tasks 的输出取决于你工作区里有哪些目录
fileTree("modules").visit {
if (it.isDirectory && it.name.startsWith("svc-")) {
tasks.register("deploy-${it.name}") { doLast { println("deploying ${it.name}") } }
}
}更深一层:这是”配置”和”程序”的分野,不是”好工具”和”坏工具”的分野。
Maven 把构建脚本当作数据——规整、受限、可被任意工具可靠解析。Gradle 把构建脚本当作程序——要能表达任意逻辑。这个分野在 Go 世界里也发生过一次:go.mod 是纯数据格式,Go 团队拒绝给它加逻辑,理由跟 Maven 一样——数据格式才能被工具安全重写(go mod tidy 能安全重写 go.mod 就是因为它结构确定;图灵完备的 Gradle 脚本没法被工具安全改写)。
所以判断标准不是”哪个更强”,是”你需不需要构建脚本是图灵完备的”。 90% 的项目不需要。剩下 10%(Android、多语言、monorepo、代码生成密集)需要得非常强烈。这就是两套工具共存的根因。
Task 是怎么工作的:inputs / outputs / actions
每个 Task 有三样东西:
| 组成 | 是什么 | 作用 |
|---|---|---|
| inputs | 输入文件、目录、或任意可序列化的属性 | 计算”我是否过期” |
| outputs | 输出文件或目录 | 计算”我是否过期”,也是缓存的搬运单元 |
| actions | 真正干活的闭包(doLast / doFirst) | 执行 |
Gradle 在执行前对 inputs/outputs 做一次指纹比对:
> Task :order-core:compileJava UP-TO-DATE
> Task :order-web:test FROM-CACHE
> Task :order-api:jar
三种状态必须分清:
| 标记 | 含义 |
|---|---|
| 无标记 | 真的执行了 |
UP-TO-DATE | inputs/outputs 没变,跳过了 |
FROM-CACHE | 本地或远程 Build Cache 命中,产物直接拷过来,跳过了 |
问题 2: 如果一个插件作者忘了声明某个 input(比如把”当前时间戳”当作隐式的输入),会发生什么?
答案是:Gradle 认为没变化,该重跑的 Task 被跳过,产出一个陈旧的结果。反过来,如果把”每次都变的东西”(比如进程 PID)声明成 input,就会每次全量重跑,增量构建完全失效。
这就是 Gradle 增量构建的阿喀琉斯之踵:它的正确性依赖人(插件作者)的声明正确,而不是由系统保证。
【思考】Go 的
go build也有增量编译,基于文件内容哈希 +GOCACHE构建缓存。它跟 Gradle 的增量构建有什么本质不同?提示:想想”谁负责判断要不要重跑”,以及”判断错了会怎样”。
参考答案
直接答案:Go 的增量是编译器内建且不可配置的,Gradle 的是构建系统层面且需要人工声明的。这是”内建” vs “可配置”的典型取舍。
Go 的做法:
go build 的缓存键由这些东西算出来:编译器版本 + 编译参数 + 源文件内容的哈希 + 所有依赖包的哈希 + 目标平台(GOOS/GOARCH)+ 一些环境变量。整个过程由 cmd/go 自己算,你没有接口去改它。
go env GOCACHE # 缓存目录,Linux 上 ~/.cache/go-build,Windows 上 %LocalAppData%\go-build
go build -x -a ./... # -a 强制忽略缓存全量重编,用来对比验证
go clean -cache # 清掉整个构建缓存它的性质是:
- 不可能”声明错” —— 因为根本没有声明环节。编译器比谁都清楚编译一个包需要什么。
- 完全透明 —— 你不需要知道它存在,它就在那儿。
- 粒度是”包” —— Go 的编译单元就是包,缓存键天然对应包。
- 跨项目共享 —— 同一个
GOCACHE下,A 项目和 B 项目用到同一个版本的golang.org/x/net,编译产物共用。
Gradle 的做法:
缓存键 = Task 声明的 inputs/outputs 指纹 + classpath + Task 实现类的哈希。注意最后一项:Gradle 连”你这个 Task 类的字节码有没有变”都算进去了,这是对的(改了 Task 逻辑当然要重跑)。
问题在前一项:inputs 是插件作者一行一行写出来的。
tasks.register<MyCodegenTask>("genCode") {
// 忘了写这一行 → 模板文件改了,Task 不会重跑 → 产出陈旧代码
// inputs.dir("src/main/templates")
// 或者写错了:把整个项目目录当 input
// inputs.dir(".") → 任何文件动一下都全量重跑
}| 维度 | Go go build | Gradle |
|---|---|---|
| 谁判断”要不要重跑” | 编译器自己 | 构建系统,依据插件作者的声明 |
| 判断依据 | 内容哈希(自动收集) | inputs/outputs 声明(人工) |
| 会不会判断错 | 不会(无声明环节) | 会,且这是 Gradle 最常见的正确性 bug |
| 可配置性 | 几乎为零(GOFLAGS、-a) | 极高(可以完全自定义缓存键) |
| 跨项目共享 | 天然共享(同一个 GOCACHE) | 需要显式开 Build Cache,默认只在本项目 |
| 出问题怎么查 | go build -x | --info 看 Task 输出、--scan 看 Build Scan |
代码锚点 —— Gradle 里怎么自己验证声明对了没:
./gradlew build --info | grep -i "up-to-date\|from-cache" # 看哪些被跳过了
# 关键手法:改一个不该影响 Task 的文件,看它是否被跳过;再改一个该影响的,看它是否重跑
touch src/main/resources/application.yml && ./gradlew compileJava # 应该 UP-TO-DATE
echo "// x" >> src/main/java/Foo.java && ./gradlew compileJava # 应该真的执行Gradle 还有一个更硬的工具:--rerun-tasks(强制全量重跑,绕过所有 up-to-date 判断)。在怀疑”缓存骗了我”的时候,用它做对照实验。 这跟 Go 里用 go build -a 验证是同一个套路。
更深一层:这是”封闭系统”和”开放系统”的经典差异。
Go 的编译模型是封闭的:编译一个包需要什么,编译器全知道(源码 + 依赖包的导出数据)。没有插件、没有代码生成钩子、没有”资源过滤”这种步骤。封闭系统可以把优化做到极致且保证正确,代价是系统边界外的事情它做不了(所以 Go 项目经常还得配一个 Makefile 干那些脏活)。
Gradle 的模型是开放的:任何插件都能注册任意 Task,Task 里能干任何事(跑 python、调 HTTP、改字节码)。开放系统无法自动推断”这个 Task 依赖什么”,只能把责任交给声明者。Gradle 用 TaskInputs/TaskOutputs 这套 API 设计了一套”自愿申报”制度,配合 Configuration Cache 的严格校验来兜底,但它本质上仍然是靠约定而非强制。
这个取舍的现实例子: Go 项目里几乎没人讨论”构建缓存不干净导致诡异 bug”,因为不可能发生;Java 项目里 ./gradlew clean 是排查问题的标准第一步动作——这个动作本身就是”我不信任缓存”的体现。当一个系统的用户把”清空缓存重来”当作常规操作,说明它的正确性保证是有条件的。
06.2 三层架构:为什么 Gradle”看起来很慢”
本节要解释开场那个现象:为什么它跑八分钟,而且前几十秒什么输出都没有。
Daemon
Gradle 启动一个 JVM 是要成本的(加载几千个类、初始化、JIT 预热)。所以 Gradle 默认起一个常驻后台进程 Daemon,构建结束后它不死,下次构建直接复用。
./gradlew --status # 看当前有几个 daemon 在跑
./gradlew --stop # 全部杀掉
./gradlew build --no-daemon # 单次构建不用 daemon(CI 里有时这么干)问题 3: 那第一次为什么慢?因为第一次要启动 daemon(或者版本不匹配要重启一个)。Daemon 按 JVM 参数复用——你改了 org.gradle.jvmargs,Gradle 会另起一个 daemon,老的还占着内存。这是”我明明开着 daemon 为什么还是慢”的常见原因之一。
Initialization / Configuration / Execution
这是理解 Gradle 性能问题的关键,必须记住:
| 阶段 | 干什么 | 能否增量 |
|---|---|---|
| Initialization | 解析 settings.gradle.kts,确定这次构建有哪些 project | 否(但很快) |
| Configuration | 执行所有 project 的 build.gradle.kts,建出完整的 Task 图 | 否 —— 这是重点 |
| Execution | 按 DAG 执行被需要的那些 Task | 是(up-to-date + build cache) |
关键的不对称性:Execution 是增量的,Configuration 不是。
你只想跑 :order-web:test?对不起,所有 project 的构建脚本都会被执行一遍,Task 图要完整建出来,然后才挑出需要的那几个 Task 跑。
【思考】为什么改一行 Java 代码,Gradle 构建还是要好几秒?明明只有一个 Task 需要重跑。
想一下:一个 30 个模块的项目,改
order-core里的一个.java文件,跑./gradlew :order-core:test。这 5 秒花在哪了?
参考答案
直接答案:绝大部分时间花在 Configuration 阶段,而这个阶段做不了一次完整的增量(直到 Configuration Cache 出现)。
时间都去哪了(一次典型的 5 秒”只改一行”):
| 耗时 | 阶段 | 干什么 |
|---|---|---|
| ~0.3s | 启动 | 跟 daemon 握手 |
| ~0.2s | Initialization | 读 settings |
| ~3s | Configuration | 执行 30 个 build.gradle.kts:Kotlin 脚本编译检查、插件 apply、依赖版本目录解析、subprojects{} 注入、Task 图构建 |
| ~1s | Execution | 真的编译那一个类 + 跑测试 |
| ~0.5s | 收尾 | 文件监听、daemon 收尾 |
Configuration 阶段为什么慢? 因为它是”执行脚本”,脚本里什么都可能发生。常见的时间杀手:
// 反面教材:这些都在 configuration 阶段执行,每次构建都跑一遍
val gitHash = Runtime.getRuntime().exec("git rev-parse HEAD").inputStream.bufferedReader().readText() // 起进程
val versionFile = File("version.txt").readText() // 读文件(且没声明为 input)
val remoteConfig = URL("https://cfg.example.com/x").readText() // 发网络请求
val allModules = File(".").walkTopDown().filter { it.isDirectory }.toList() // 遍历整个目录树四条规则(写构建脚本时背下来):
- configuration 阶段不碰 IO。 要读文件、跑命令、发请求,一律放进 Task 的
doLast或Provider(惰性求值)。 - 用
register不用create。tasks.register("x")是懒注册(用到才配置),tasks.create("x")是急切注册(configuration 阶段就实例化)。 - 用
configureEach不用all。tasks.withType<Test>().configureEach {}只在需要时配置;.all {}会强制配置所有已存在的 Task。 - 开 Configuration Cache。
Configuration Cache(Gradle 6.6 引入实验特性,8.1 转 stable):
原理很直白:把 Configuration 阶段的产物(Task 图)序列化到磁盘,下次构建如果构建脚本没变,直接反序列化,跳过整个 Configuration 阶段。
# gradle.properties
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn # 迁移期先只警告不失败第一次跑你会看到 Calculating task graph as no configuration cache is available for tasks,之后就是 Reusing configuration cache。这个提示就是你的收益凭证——看到它,说明 Configuration 时间被省掉了。
代价是它给构建脚本立了一堆规矩:不能在 Task 里捕获 Project 对象、不能在 configuration 阶段之后访问项目模型、不能用某些 API。老插件可能不兼容,所以有了 problems=warn 这个渐进开关。Gradle 9.0(2025 年 7 月发布)已经把 Configuration Cache 设为推荐执行模式,方向很明确:未来它会变成唯一模式。
关于 configuration-on-demand(按需要只配置相关 project):
它确实存在(org.gradle.configureondemand=true),但别把希望寄托在它身上。它长期处于孵化状态,且有一个致命前提:要求项目之间完全解耦。只要你在根项目用了 allprojects {} / subprojects {} 做配置注入(几乎所有项目都这么干),它就可能配置错项目、算出错误结果。Gradle 官方现在的方向是 Configuration Cache + Isolated Projects(Gradle 9 起孵化,目标是让配置阶段可以并行且增量)。新项目按”开 Configuration Cache + 少用 subprojects 注入”来优化,不要指望 configuration-on-demand。
代码锚点 —— 怎么测出 Configuration 到底花了多久:
./gradlew --dry-run :order-core:test # 只走 Initialization+Configuration,不执行任何 Task
# 用它测出的时间 ≈ 你的 Configuration 开销
./gradlew build --scan # 生成 Build Scan,里面有 phase 级别的耗时拆解
./gradlew build --profile # 本地版,产出 build/reports/profile/*.html--dry-run 是被严重低估的诊断命令。 它的输出时间就是”你什么都没干也要付的固定成本”。如果这个数字大于 2 秒,先优化配置脚本,别去折腾缓存。
更深一层:为什么 Maven 没有这个问题?
因为 Maven 没有”执行构建脚本”这个阶段。它只是解析 XML + 按固定 lifecycle 找插件,这个过程的成本跟项目规模线性相关、且常数很小。Maven 的慢是”每次都真干活”(跑完所有 phase),Gradle 的慢是”每次都要先想一遍要干什么”。
这是两种模型的固有成本,不是实现质量问题。 声明式模型的配置成本天然低(解析数据),命令式模型的配置成本天然高(执行程序)。Gradle 用 Configuration Cache 把这个”执行”变成了”一次执行 + N 次反序列化”,这是对的方向,但你要主动去开它。
Build Cache
up-to-date 检查只在同一个工作目录内有效(它比对的是上次执行的输出)。Build Cache 更进一步:把 Task 的输出打包,按 inputs 哈希索引,跨目录、跨机器复用。
# gradle.properties
org.gradle.caching=true本地缓存默认在 ~/.gradle/caches/build-cache-1。远程缓存要配:
// settings.gradle.kts
buildCache {
local { isEnabled = true }
remote<HttpBuildCache> {
url = uri("https://gradle-cache.internal.example.com/cache/")
// 关键:只有 CI 才 push,开发者机器只 pull
// 否则每个人的机器都往里塞,缓存迅速膨胀且互相污染
isPush = System.getenv("CI") == "true"
credentials {
username = System.getenv("CACHE_USER")
password = System.getenv("CACHE_PWD")
}
}
}问题 4: clean 之后再构建,为什么第二次能快很多?因为 clean 只删了 build/ 目录,没删 Build Cache。Task 的 outputs 从缓存里直接拷回来,标记是 FROM-CACHE。
【思考】远程 Build Cache 在团队里值不值得搞?
假设 20 人团队、10 个服务、CI 每天跑 50 次构建。想清楚收益来自哪种”复用”,以及要付什么运维成本。
参考答案
直接答案:CI 上收益巨大,本地开发收益有限。值得搞,但前提是你已经有构建耗时的痛点,且有人愿意长期维护那台缓存服务器。
收益来自两种复用,第二种才是远程缓存独有的:
复用一:跨分支 / 跨 commit 复用(本地缓存也能做到)。 你在 feature 分支改了代码,切回 main 再构建——main 上那些没变的模块,outputs 还在本地缓存里,直接 FROM-CACHE。这个收益本地缓存就能给,不需要服务器。
复用二(真正的大头):跨机器复用。 场景长这样:
上午 10:00 小王 push,CI 全量构建,编译产物进远程缓存
上午 10:05 小李 push,CI 构建 —— order-api / order-core 没变,直接从缓存拿
→ 只有 order-web 需要真编译
20 人团队、10 个服务,每个人每天 push 2~3 次,一次 push 触发多个服务的构建。 如果每次都全量编译 10 个模块,90% 的 CPU 时间在重复编译跟上次一模一样的代码。远程缓存把这个数字压到接近”只有真正变了的模块才编译”。
量化一下: 假设一次全量 CI 8 分钟,其中编译 3 分钟。开了远程缓存后,典型情况下 70% 的模块没变 → 编译时间降到 1 分钟 → 总时长 6 分钟。一天 50 次构建,省 100 分钟 CI 机时,更重要的是每次 push 到拿到反馈的时间从 8 分钟降到 6 分钟以下——开发者的等待时间是有复利效应的,它对迭代速度的影响远大于那点机时费用。
代价这一侧,必须诚实:
| 代价 | 具体是什么 |
|---|---|
| 服务器运维 | 一个 HTTP 缓存节点(Gradle 官方有 Docker 镜像,也可以用 Nginx + WebDAV,或者上 Develocity)。要管容量、备份、权限 |
| 缓存污染排查 | 某个 Task 的 inputs 声明不全(漏了一个隐式输入),会把错误产物缓存起来,然后所有机器都拿到错的。这是远程缓存最可怕的失败模式,而且症状是”在我机器上是对的” |
| 可缓存性声明 | 不是所有 Task 都能缓存。Task 要标注 @CacheableTask,且 inputs/outputs 声明必须完整。自定义 Task 默认不可缓存 |
| 网络依赖 | 缓存服务器挂了,构建变慢(不会失败,但要能 fallback)。CI 对它的可用性有依赖 |
决策建议:
| 团队规模 / 场景 | 建议 |
|---|---|
| 1-5 人,单仓库 | 不搞。开本地缓存就够了 |
| 5-20 人,多服务 | 值得。先上一个最简 HTTP 缓存节点,只让 CI push |
| 20+ 人 / monorepo | 必搞,且考虑 Develocity(商业方案,带 Build Scan 和缓存分析) |
| 每次 CI < 3 分钟 | 不搞。优化空间不值得运维成本 |
代码锚点 —— 最简的”够用”配置思路(伪配置,按你的实际基础设施调整):
// settings.gradle.kts
buildCache {
remote<HttpBuildCache> {
url = uri(System.getenv("GRADLE_CACHE_URL") ?: "https://gradle-cache.internal/cache/")
// 只有 CI 写,开发者一律只读 —— 这一行能避免 90% 的污染问题
isPush = System.getenv("CI") == "true"
// 网络异常时不让构建失败,降级为本地缓存
isEnabled = System.getenv("GRADLE_CACHE_URL") != null
}
}更深一层:远程 Build Cache 本质上是”把团队的编译算力池化”。
Go 世界里有没有对应物?有,而且 Go 做得更早更简单:每个开发者的 GOCACHE 是本地的,但 Go 通过公共的 module proxy(proxy.golang.org)缓存了依赖的源码,编译产物虽然不共享,但因为 Go 编译极快,跨机器共享编译产物的收益不值得那个复杂度。
这里有个很关键的设计判断: 远程构建缓存的收益 ∝ 单次编译成本 × 复用率。Go 的单次编译成本很低(编译快),所以这个乘积小,不值得;Java(尤其 Kotlin/Scala)单次编译成本高,所以值得。工具的价值取决于它解决的问题有多贵,工具本身多先进是次要的。
顺带一个反直觉的提醒: 远程缓存对”每个人都在改同一批文件”的场景几乎没用(复用率低),对”大家改不同模块”的场景收益极大。所以 monorepo + 远程缓存是绝配,小仓库 + 远程缓存是浪费。