第 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-DATEinputs/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         # 清掉整个构建缓存

它的性质是:

  1. 不可能”声明错” —— 因为根本没有声明环节。编译器比谁都清楚编译一个包需要什么。
  2. 完全透明 —— 你不需要知道它存在,它就在那儿。
  3. 粒度是”包” —— Go 的编译单元就是包,缓存键天然对应包。
  4. 跨项目共享 —— 同一个 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 buildGradle
谁判断”要不要重跑”编译器自己构建系统,依据插件作者的声明
判断依据内容哈希(自动收集)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.2sInitialization读 settings
~3sConfiguration执行 30 个 build.gradle.kts:Kotlin 脚本编译检查、插件 apply、依赖版本目录解析、subprojects{} 注入、Task 图构建
~1sExecution真的编译那一个类 + 跑测试
~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()  // 遍历整个目录树

四条规则(写构建脚本时背下来):

  1. configuration 阶段不碰 IO。 要读文件、跑命令、发请求,一律放进 Task 的 doLast 或 Provider(惰性求值)。
  2. 用 register 不用 create。 tasks.register("x") 是懒注册(用到才配置),tasks.create("x") 是急切注册(configuration 阶段就实例化)。
  3. 用 configureEach 不用 all。 tasks.withType<Test>().configureEach {} 只在需要时配置;.all {} 会强制配置所有已存在的 Task。
  4. 开 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 + 远程缓存是绝配,小仓库 + 远程缓存是浪费。