构建越来越慢、没人敢动构建脚本——这两件事通常同时出现。一个项目的 pom.xml 或 build.gradle.kts 三五年没重构过,里面躺着"当年为修 CI 加上的"配置和 if 分支,谁也说不清删掉哪一行会让发布断掉。此时构建系统已不是工具,而是没人敢碰的单点。
选型常在比功能表:并行、语法、生态。真正决定三年后你还愿不愿意维护它的,是另外四件事:依赖解析的确定性、任务模型的表达力、增量的可信度、插件生态的存活率。此处只谈这四件事的收益边界与账单到期时点。按 Maven 3.9 / Gradle 8.x 行为核对。
1. 先把能力拆到四个格子
| 能力 | 含义 | 失效症状 | 最难搬的部分 |
|---|---|---|---|
| 依赖解析 | 版本调解、冲突消解、仓库策略 | "本地能跑、CI 编译不过" | 调解规则(最近优先 vs 最新优先) |
| 任务编排 | 任务图、并行度、生命周期 | 顺序不定,产物时好时坏 | 生命周期语义(Maven 有、Gradle 无) |
| 增量 | 输入输出指纹、跳过已最新 | 改了没重编、全量重跑 | 自定义任务输入声明 |
| 插件生态 | 编译、打包、检查、发布 | 缺能力只能自己写 | 公司内部私有插件 |
# 先量化再决策:把一次构建切成配置、依赖、执行三段
$ /usr/bin/time -v mvn -o -q package 2>&1 | tail -5 # 离线,去掉网络变量
$ ./gradlew --profile build # 报告里分配置时间/执行时间
$ ./gradlew --stop && ./gradlew clean build --profile # 冷启动对照
最容易被忽略的是配置时间:任务开跑之前,光解析构建脚本就可能吃掉总时长一半。这段时间不产出任何编译结果,却随模块数线性增长,是 Gradle 项目"越加模块越慢"的主因。
2. Maven:约定的收益边界
Maven 的核心资产不是 XML,而是生命周期:validate → compile → test → package → verify → install → deploy 是全局共识,插件挂在确定阶段上。被低估的好处是——脚本可以互不相似,流程彼此可比:看陌生项目不必读 pom.xml 就知道 mvn verify 会做什么。
<!-- 父 POM 只做两件事:锁版本、锁插件版本 -->
<dependencyManagement><dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.3.6</version><type>pom</type><scope>import</scope>
</dependency>
</dependencies></dependencyManagement>
<!-- 插件版本必须显式锁,否则随 Maven 默认值漂移 -->
<plugin><artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version><configuration><release>17</release></configuration></plugin>
| 约定的收益 | 到期的代价 |
|---|---|
| 生命周期统一,CI 脚本可模板化 | 非标准流程只能靠 exec 插件硬塞,塞完没人看得懂 |
| 单继承:一个模块一个 parent | 想同时继承两条基线,只能靠 import BOM |
| 声明式,脚本无逻辑 | 需要逻辑只能用 profile,且不可组合 |
| 调解规则简单(最近优先) | 结果与声明位置强相关,靠 exclusion 修冲突越修越乱 |
收益边界很清楚:流程稳定、模块数中等、发布动作标准的项目它最省心。痛点两处:需要条件化逻辑时(多平台产物、可选模块),以及模块过百后串行构建的墙钟时间。
3. Gradle:灵活性从哪里来,账单在哪到期
Gradle 把构建变成一段在 JVM 上执行的程序,任务图是自己组装的 DAG。这是能力,也是负债源头。
| 灵活性 | 直接后果 | 账单到期形式 |
|---|---|---|
| 任务图任意组装 | 能表达"只在这种情况打包" | 凭命令名预测不了会跑什么 |
| 脚本即代码 | 可抽函数、循环、复用 | 无测试、无评审 |
| Kotlin DSL | 编译期检查与补全 | 改一行脚本要等脚本编译 |
| 配置缓存 | 大项目提速明显 | 配置期碰 Project 即退化 |
| 插件可改写任务 | 补生态缺口容易 | 升级从"报错"变"静默不同" |
// buildSrc/src/main/kotlin/clarity.java-conventions.gradle.kts
plugins { java; id("org.springframework.boot") }
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
tasks.withType<Test>().configureEach {
useJUnitPlatform()
systemProperty("spring.profiles.active", "test") // 唯一的业务判断,必须写清原因
}
分界线是"构建逻辑是否被当成代码管"。放进 buildSrc、有评审、有 TestKit 用例,灵活性是资产;散在各 build.gradle.kts 里就是复利机器——每处 if (project.hasProperty(...)) 都是只在某台机器上成立的分支。
4. 增量与缓存:命中率取决于五个变量
"改了没重编"不是 bug,而是输入声明不完整的必然结果。增量、构建缓存、配置缓存共用一套指纹:任务声明读什么写什么,才能判断可否跳过。Maven 侧粒度粗得多,只跳过"产物已存在"的模块,跳不过"输入没变"的任务——差别不是速度,是指纹粒度。
| 变量 | 影响 | 自查 |
|---|---|---|
| 输入声明完整性 | 漏一个系统属性或环境变量 → 该重跑时跳过 | 看 @Input 是否齐全 |
| 输出声明准确性 | 写到声明目录外 → 不命中或产物缺失 | 看 --info 的缓存键 |
| 时钟与 mtime | 时间戳回退(git checkout、镜像)→ 假失效 |
内容哈希而非时间 |
| 绝对路径 | 键含绝对路径 → 换机器不命中 | 本地与 CI 同挂载路径 |
| 环境差异 | JDK、OS、locale 进键 → 跨平台失效 | 只在同平台共享远端缓存 |
$ ./gradlew build --info | grep -E 'FROM-CACHE|not up-to-date|Caching disabled'
$ ./gradlew clean build --no-build-cache # 对照组:确认真实耗时
$ ./gradlew --stop # 冷热分离,别用常驻守护进程比
"第一次很快、后来变慢"是冷热误读:"第一次"可能是空工程。判断缓存是否有用必须固定对照组——同一提交、同一机器,清空与不清空各跑一次看差值。
5. 多模块与版本统一
| 方式 | 机制 | 适合 | 代价 |
|---|---|---|---|
| 父 POM 继承 | <parent> 单继承 |
模块数 < 30、逻辑统一 | 只能一个父;父改全公司重跑 |
BOM(import) |
只导版本约束,不继承构建配置 | 多产品线共享版本基线 | 只锁版本,不锁插件与编译参数 |
| Convention plugin | 构建逻辑打成插件按需 apply |
模块多、逻辑有分支 | 插件本身要维护、测试、发版 |
# gradle/libs.versions.toml —— 单一事实来源,IDE 有补全
[versions]
springBoot = "3.3.6"
[libraries]
starterWeb = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springBoot" }
[plugins]
boot = { id = "org.springframework.boot", version.ref = "springBoot" }
判断只有一条:版本号出现在几个地方。出现在三个以上 pom.xml 里必然漂移,之后一次"顺手升级"就变成跨模块排查。
6. 速度优化的顺序:先测量
顺序是:配置阶段占比 → 是否全量重跑 → 依赖下载 → 测试耗时 → 并行度 → 编译器与 JVM 参数,越靠前收益越大,最后一项只是边际。判据来自实测拆分:配置占一半的工程,调堆毫无意义。
顺序错了的代价是把优化变成配置堆积:并行、缓存、堆一起加,最后没人知道哪项真起作用,出问题也回退不了。基准要跑多轮取中位:hyperfine --runs 3 './gradlew assemble --offline'。
7. 构建脚本也是生产代码
五条最低要求:进评审(与代码同权)、可测试(Gradle 用 TestKit,Maven 用 maven-invoker-plugin)、拆分(逻辑进 plugin)、写原因(每处分支注明触发场景与失效条件)、定版本(wrapper 固定工具版本)。缺哪条,三年后都以"只有一个人能发版"收账。
8. 迁移触发条件与回退成本
正当理由只有三类:配置时间已成瓶颈、需要表达力做条件化产物、需要细粒度增量与远端缓存。其余(语法短、社区热、想学)不构成依据。
| 触发条件 | 收益 | 回退成本 |
|---|---|---|
| 配置时间占比高 | 高(配置缓存) | 中:翻译不可逆,须留旧 pom.xml |
| 需要条件化产物 | 高 | 低:Maven 本就无此能力 |
| 需要远端缓存 | 中高 | 中:键设计与 CI 改造 |
| 只想统一风格 | 低 | 高:纯翻译工作 |
| 内部插件只有 Maven 版 | 负 | 极高:自研或放弃能力 |
回退成本的关键变量是是否双语并存。保留可用的 pom.xml 直到 Gradle 侧跑满两个发布周期,是唯一现实的保险;删掉之后,回退从"改回去"变成"重建发布流程"。
9. 排错:五种典型现象
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| 本地过、CI 失败 | 依赖来自本地仓库或 SNAPSHOT |
固定版本、CI 干净仓库 |
| 改了代码没重编 | 输入声明漏项、mtime 回退、键过粗 | --info 看跳过原因,关缓存 |
| 构建忽快忽慢 | 守护进程冷热、机器争抢 | 固定 worker 数,记中位 |
| 升级后行为变了 | 默认值变更、废弃 API 静默降级 | 升小版本、读 changelog、对照构建 |
| 只有一个人能发版 | 逻辑含本机路径、环境变量、手工步 | 变量搬进脚本并加注释与测试 |
10. 边界:这些情况不要折腾
| 场景 | 判断 | 理由 |
|---|---|---|
| 单模块小项目 | 留在 Maven 或直接脚本 | 总时长十秒级,改造不划算 |
| 团队只有一两人 | 不引入 convention plugin | 维护成本摊到人头上 |
| 构建本来就是十秒级 | 不测、不缓存、不并行 | 测量本身比节省更耗时 |
| 发布流程还在变 | 先定流程再定工具 | 固化变动中的流程=双重返工 |
| 内部插件生态单一 | 跟随生态 | 缺插件=自研=长期维护 |
一句话:Maven 用一致性换表达力,Gradle 用表达力换一致性成本。选哪个不是技术判断,而是"你更怕哪一种失控"。
11. 自测
- 多模块项目构建慢,你怎么在不改任何配置的前提下判断瓶颈在配置阶段还是执行阶段?
- Maven 的生命周期给你的最大收益和最大限制,各是什么?
- Gradle 的"脚本即代码"在什么条件下是资产、什么条件下是负债?
- 缓存开着却不命中,你会先查哪三个变量?
- 有人提议从 Maven 迁到 Gradle,你要他先拿出哪两项证据?
- 什么情况下"构建脚本进评审"不值得推行?