第 06 章(节选) 选型与实战重写
本篇来自《Go 程序员的 Java 修炼之路》第 06 章「第 06 章 Gradle 与构建选型:为什么有人不用 Maven」。 返回:第 06 章索引
06.7 什么时候该选 Gradle,什么时候该用 Maven
这是本章的落点。前面所有技术细节,都是为了让你能做这个判断。
决策表
| 场景 | 建议 | 理由 |
|---|---|---|
| Android(含任何 Android 模块) | Gradle,没得选 | AGP(Android Gradle Plugin)官方只支持 Gradle。这不是偏好问题 |
| 多语言混合构建(Java + Kotlin/Scala/Groovy + 前端 + native) | Gradle | Maven 表达多语言构建要靠一堆 exec 插件,非常别扭 |
| 需要高度定制构建流程(代码生成、字节码增强、条件打包、多产物) | Gradle | 这是 Gradle 的核心优势区,Maven 的 XML 表达不了 |
| 构建耗时成为瓶颈(全量 > 10 分钟、增量 > 30 秒) | Gradle(但先优化 Maven) | 增量 + Build Cache + implementation 的收益在大型项目上是数量级的 |
| Spring Framework / Spring Boot 源码本身 | Gradle(事实) | Spring 团队 2020 年前后迁到了 Gradle(Spring Boot 2.3 起)。但你用 Spring Boot 写应用不需要跟着用 Gradle |
| 超大单体仓库(monorepo,几十上百模块) | Gradle | 并行 Task 执行 + 远程 Build Cache + 约定插件,在这个规模上差距明显 |
| 团队 Java 经验一般、人员流动快 | Maven | XML 的约束是资产。任何人接手都能看懂,出问题好查 |
| 追求构建脚本的标准化和可读性 | Maven | 所有 Maven 项目的 pom 长得都差不多;Gradle 项目的脚本可以千差万别 |
| 依赖简单的中小型服务(< 10 个模块) | Maven | 增量构建和缓存的收益在这个规模上测不出来 |
| CI 环境简单、不想引入缓存服务器 | Maven | Gradle 的完整收益需要缓存基础设施,配不好反而更慢 |
| 需要和大量传统企业插件/工具集成 | Maven | 某些行业工具(代码扫描、合规检查、专有打包)只有 Maven 插件 |
| 已经在用 Maven 且没痛点 | 不要迁 | 见下面的成本分析 |
最后一行值得用加粗再说一遍:已经在用 Maven 且没痛点的项目,别迁。 这不是保守,是算过账的。
迁移成本
gradle init 能自动把 Maven 项目转成 Gradle:
cd order-service
gradle init --type pom # 读 pom.xml,生成 settings.gradle.kts + 各模块 build.gradle.kts但生成的是”翻译版”,有几个已知问题:
- 所有
compile都翻成api(或implementation,取决于版本),不会帮你做语义区分。 翻译版通常全用implementation,这不会出错,但你也拿不到implementation的全部收益——真正要紧的是把该暴露的改成api,把剩下的留在implementation,这一步只能人工判断。 - 插件换成 Gradle 版,配置要重写。
maven-shade-plugin→ Shadow 插件;maven-enforcer-plugin→ 没有直接对应,要靠resolutionStrategy+ 自定义 Task。 - profile 逻辑要重写。 Maven profile 没有直接对应物,通常翻成
if (project.hasProperty(...))。 - 资源过滤、属性替换要改成
ProcessResources的配置。
真实的成本清单:
| 成本项 | 说明 | 量级 |
|---|---|---|
| 依赖树变化 | 仲裁规则不同,解析结果会变,必须逐条验证 | 高危,必须做 |
| 脚本重写 | gradle init 只是起点,人工重构才能用好 | 每人周级 |
| CI/CD 改造 | 流水线脚本、缓存策略、产物路径、Dockerfile 全要改 | 周级 |
| 团队学习 | Kotlin DSL、Task 模型、增量构建的心智模型 | 月级 |
| 过渡期双维护 | 大项目往往要同时保持两套构建一段时间 | 最贵的一项 |
| 插件生态差异 | 小众插件可能没有 Gradle 版 | 看运气 |
迁移时最该做的一件事,没有任何替代品:
# 1. 导出两边的依赖树
mvn dependency:list -DoutputFile=/tmp/maven-deps.txt -DincludeScope=runtime
./gradlew :order-web:dependencies --configuration runtimeClasspath > /tmp/gradle-deps.txt
# 2. 归一化后 diff
diff <(sort /tmp/maven-deps.txt) <(sort /tmp/gradle-deps.txt)这个 diff 里的每一行差异都是一个潜在的生产事故。 构建通过只说明编译能过,运行时的版本可能对不上。第 05 章那个 NoSuchMethodError 的故事,在这个环节会重演。
【思考】一个真实决策:团队 20 人,10 个 Spring Boot 微服务,当前用 Maven,CI 构建要 8 分钟。要不要迁 Gradle?
不要凭感觉。给一个量化的分析框架:先把 8 分钟拆开,再看每一块能被什么手段优化,最后算迁移要付多少。
参考答案
直接结论:先优化 Maven。大概率 8 分钟能降到 3-4 分钟,那时候再算迁移的账,结论通常变成”不值”。
下面把推理过程完整走一遍,这个框架本身比结论值钱。
第一步:把 8 分钟拆开(这是所有优化的前提,很多人跳过这步)
不要猜。去 CI 日志里读时间戳,或者本地跑一次带时间输出:
mvn clean install -DskipTests -T 1C 2>&1 | ts '[%H:%M:%S]' # ts 命令给每行加时间戳
# 或者看 CI 流水线各 step 的耗时一个 10 个微服务、每个 3-5 个模块的典型拆解:
| 环节 | 耗时 | 占全量 | 可优化空间 |
|---|---|---|---|
| 拉代码 + 环境准备 | 0.5 min | 6% | 小 |
| 下载依赖 | 2.5 min | 31% | 极大 |
| 编译(全量) | 1.5 min | 19% | 中 |
| 跑测试 | 2.5 min | 31% | 大 |
| 打包 + 镜像 | 1.0 min | 13% | 小 |
注意这个分布:真正的”编译”只有 1.5 分钟,占 19%。 而 Gradle 最擅长优化的恰恰是编译(增量 + 缓存)。
这意味着:即使 Gradle 把编译时间降到 0,你最多省 19%。 而剩下的 81%(依赖下载、测试、打包)Gradle 帮不上太多忙——依赖下载两边都要下,测试两边都要跑。
这就是”先拆开”的价值:它立刻告诉你,这个问题可能根本不是构建工具的问题。
第二步:先用 Maven 自己的手段优化(按顺序做,每步都测)
优化一:缓存依赖(省 2.5 分钟,收益最大,成本最低)
# GitLab CI 示例(GitHub Actions / Jenkins 同理)
cache:
key:
files:
- "**/pom.xml" # pom 变了才失效
paths:
- .m2/repository
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"关键是把本地仓库放到项目目录内(CI 的 cache 通常只能缓存工作区内的路径),并以 pom.xml 的内容做 cache key。这一项通常能省 2-3 分钟。
优化二:并行构建(省 30-40%)
mvn clean install -T 1C # 每核一个线程
mvn clean install -T 4 # 或者指定固定线程数注意:-T 会放大部分插件的线程安全 bug。 第一次用先跑两遍看结果是否稳定,尤其注意自定义插件和老版本的 surefire。
优化三:只构建变更的模块(大杀器,但要看场景)
# 用 git diff 算出哪些模块变了,只构建它们及其依赖
CHANGED=$(git diff --name-only origin/main...HEAD | grep -oP '^[^/]+' | sort -u | tr '\n' ',')
mvn install -pl "$CHANGED" -am -T 1C对 10 个独立微服务来说,一次 commit 通常只改 1-2 个服务 → 构建量直接降到 10-20%。这一项的收益可能超过前面所有项加起来。
优化四:测试分层(省 1-2 分钟)
# 单测(快)每次都跑;集成测试(慢)只在特定条件下跑
mvn test # surefire:只跑 *Test
mvn verify -P integration # failsafe:跑 *IT,绑定在 integration-test phase把 Testcontainers 这类需要起容器的测试挪到独立的 profile,PR 流水线只跑快测试,合并到 main 才跑全量。
优化五:跳过不必要的东西
mvn install -DskipTests # 跳过测试执行(仍编译测试代码)
mvn install -Dmaven.javadoc.skip=true # 跳过 javadoc(很慢)
mvn install -Dspotless.check.skip=true # 格式检查放到单独的 lint job
mvn install -Dmaven.source.skip=true # 不生成 sources jar(发布时才需要)算一下总账:
| 手段 | 省时 | 累计剩余 |
|---|---|---|
| 起点 | — | 8.0 min |
| 缓存依赖 | -2.5 | 5.5 |
| 只构建变更模块 | -3.0 | 2.5 |
| 并行 -T 1C | -0.7 | 1.8 |
| 测试分层 + 跳过 javadoc | -0.6 | 1.2 min |
8 分钟 → 1.2 分钟,全部用 Maven 现有手段,零学习成本,零迁移风险。
(数字是我按典型情况估的,你的项目要自己测。但这个量级关系是可信的:在这个场景里,构建工具不是瓶颈,依赖下载和”构建了你没改的东西”才是。)
第三步:现在再算迁移 Gradle 的账
收益侧(乐观估计):
| 收益项 | 乐观估计 | 说明 |
|---|---|---|
| 增量编译 | -0.3 min | 编译只有 1.5 分钟,且已用 -pl -am 裁掉了大部分 |
| Build Cache 跨构建复用 | -0.3 min | 需要配缓存服务器,且你的模块变更率高的话复用率有限 |
| Daemon | 0 | CI 上每次都是新容器,daemon 几乎无收益 |
implementation 减少重编 | 长期收益 | 本地开发体验改善明显,CI 上有限 |
乐观估计:CI 时间再降 0.5-1 分钟。
成本侧:
| 成本项 | 估算 |
|---|---|
| 10 个服务的脚本重写 + 人工重构 | 2-3 人周 |
| CI/CD 改造(流水线、缓存策略、Dockerfile) | 1-2 人周 |
| 依赖树 diff 验证(10 个服务 × 逐条核对) | 1 人周,且这是必须做的 |
| 20 人团队的学习曲线(每人 1-2 周效率下降) | 20-40 人周(最贵的一项) |
| 过渡期双维护 | 1-2 个月 |
| 踩坑(插件不兼容、缓存污染、行为差异) | 不可预估 |
总成本:至少 5 人周的直接投入 + 20-40 人周的机会成本 + 1-2 个月的过渡期风险。
收益:CI 从 1.2 分钟降到 0.5 分钟。
第四步:结论
不迁。 理由不是”Gradle 不好”,是这个场景下的瓶颈不在构建工具。
按每天 50 次构建算,省 0.7 分钟 × 50 = 每天 35 分钟机时。一年按 250 工作日 = 约 146 小时的等待时间,摊到 20 个人身上是每人每年 7 小时。而迁移成本是每人 1-2 周的效率损失。 这笔账要十多年才能回本——而那时候技术栈早就换了。
什么情况下结论会翻转:
| 如果…… | 那么 |
|---|---|
| 优化完 Maven 仍然是 8 分钟 | 重做拆解,看瓶颈到底在哪(可能是测试,不是构建) |
| 编译占了 5 分钟以上(比如大量 Kotlin/Scala) | 值得评估 Gradle,增量编译收益会非常明显 |
| 团队已经在用 Gradle 做别的项目(比如有 Android) | 统一工具有价值,可以迁 |
| 需要 Maven 表达不了的构建逻辑(代码生成密集、多产物) | 这是刚性需求,跟性能无关,该迁 |
| 单体仓库 30+ 模块,互相依赖复杂 | 值得评估 Gradle,implementation + 并行 + 远程缓存在这个规模上差距大 |
代码锚点 —— 这个分析框架可以固化成一个 checklist:
#!/bin/bash
# ci-build-profile.sh —— 先测量,再优化
set -e
echo "=== 1. 依赖下载耗时 ==="
rm -rf /tmp/m2probe && time mvn -Dmaven.repo.local=/tmp/m2probe dependency:go-offline -q
echo "=== 2. 全量编译耗时(不跑测试) ==="
time mvn clean compile -T 1C -o # -o 离线,排除下载干扰
echo "=== 3. 测试耗时 ==="
time mvn surefire:test
echo "=== 4. 打包耗时 ==="
time mvn package -DskipTests -o跑完这四步,你就知道该优化什么。大部分团队从来没做过这一步,然后凭直觉决定”换个构建工具试试”。
更深一层:这个案例的真正教训不是”别迁 Gradle”,是”先测量再决策”。
工程师的一个常见认知偏误:对”换个工具”的收益估计过高,对”用现有工具做优化”的收益估计过低。 因为换工具有明确叙事(“Gradle 更快”),而优化现有工具是琐碎的脏活(配缓存、拆流水线、分层测试)。前者性感,后者有效。
Go 社区在这件事上有句朴素的话:先 profile,再优化。 pprof 出来之前不要猜热点在哪里。构建优化一模一样:先 profile 你的 CI,再决定换不换工具。 上面的四个 time 命令就是你的 pprof。
06.8 实战:把第 05 章那个多模块项目用 Gradle 重写
第 05 章 05.10 节用 Maven 搭了 order-api / order-core / order-web。这里用 Gradle 重写,并对齐第 00 章那个四模块结构(多一个 order-dal)。
目录结构:
order-service/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── gradle/
│ ├── libs.versions.toml # Version Catalog(Gradle 7.4+ stable,8.0 起默认启用)
│ └── wrapper/gradle-wrapper.properties
├── buildSrc/ # 可选,约定插件
├── order-api/ ├── build.gradle.kts └── src/main/java/com/example/order/api/
├── order-core/ ├── build.gradle.kts └── src/main/java/com/example/order/core/
├── order-dal/ ├── build.gradle.kts └── src/main/java/com/example/order/dal/
└── order-web/ ├── build.gradle.kts └── src/main/java/com/example/order/web/
settings.gradle.kts
rootProject.name = "order-service"
include("order-api")
include("order-core")
include("order-dal")
include("order-web")
dependencyResolutionManagement {
// 禁止子模块各自声明 repositories —— 统一在这里管
// 这一条能防止"某个模块偷偷加了个仓库,然后只有它能构建"
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
// maven { url = uri("https://nexus.internal.example.com/repository/maven-public/") }
}
// Gradle 8+ 会自动读取 gradle/libs.versions.toml,下面这段只在改名/改路径时才需要
// versionCatalogs {
// create("libs") { from(files("gradle/libs.versions.toml")) }
// }
}gradle/libs.versions.toml(Version Catalog)
[versions]
java = "17"
spring-boot = "3.2.0"
jackson = "2.15.3"
mybatis = "3.5.13"
junit = "5.10.1"
mockito = "5.8.1"
mysql = "8.0.33"
guava = "32.1.3-jre"
[libraries]
# BOM(platform):只管版本,不引入依赖
spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
# 业务依赖:version.ref 指向 [versions] 里的条目
spring-context = { module = "org.springframework:spring-context" }
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
mybatis = { module = "org.mybatis:mybatis", version.ref = "mybatis" }
mysql = { module = "com.mysql:mysql-connector-j", version.ref = "mysql" }
guava = { module = "com.google.guava:guava", version.ref = "guava" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
[bundles]
# 一组常一起用的依赖,一次引入
jackson = ["jackson-databind"]
test = ["junit-jupiter", "mockito-core"]
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.4" }问题 8: spring-context 没写版本?对——版本由 Spring Boot BOM 提供(在根 build.gradle.kts 里用 platform() 引入)。Catalog 只管坐标,版本可以留给 BOM,也可以自己钉死。两种策略可以混用,这正是 Catalog 比 Maven BOM 灵活的地方。
Version Catalog:它为什么是 Gradle 对”BOM + properties”的改进
这一小节单独讲,因为它值得。Maven 里做版本统一要两个东西拼起来:<properties> 存版本字符串 + <dependencyManagement>(或 BOM)声明约束。它有四个已知别扭:
- 字符串引用,没有类型。
<version>${jackson.version}</version>—— 拼错了要跑一遍才知道(Maven 会把它当字面量,然后报”找不到这个版本”)。 - IDE 补全弱。 在
<properties>和<dependencyManagement>之间跳来跳去,很累。 - 只对依赖有效,对插件无效。 插件版本要单独写在
<pluginManagement>里,另一套机制。 - 跨项目共享要发一个 BOM 到私服。
Version Catalog 一次解决四个:
// 类型安全的访问器:libs.<name>,IDE 能补全,能跳转
dependencies {
implementation(libs.jackson.databind) // Cmd+B 直接跳到 TOML 里那一行
implementation(libs.bundles.test) // bundle:一组依赖
}
plugins {
alias(libs.plugins.spring.boot) // 插件版本也统一管了
}| 能力 | Maven(properties + BOM) | Gradle(Version Catalog) |
|---|---|---|
| 版本集中声明 | <properties> | [versions] 段 |
| 依赖坐标集中声明 | 散落在各 pom | [libraries] 段 |
| 类型安全 | 无(字符串替换) | 有(生成的访问器) |
| IDE 补全/跳转 | 弱 | 强 |
| 插件版本统一管理 | 需要 <pluginManagement>(另一套) | [plugins] 段,同一套机制 |
| 依赖分组 | 无 | [bundles] |
| 跨项目共享 | 发布 BOM 到私服 | 可以发布 TOML 文件,也能 from(...) 引用 |
| 改动的失效范围 | 改 BOM 要发版 | 改 TOML 不触发 buildSrc 重编译(这就是为什么它被推荐) |
实践建议:把”一周改三次”的依赖版本放 TOML,把”一年改一次”的构建配置放 buildSrc 的约定插件。 这是 Gradle 官方的建议,理由就是 06.6 节那个思考题——改 buildSrc 会让整个配置层作废,改 TOML 不会。
注意版本要求: Version Catalog 在 Gradle 7.0 引入(需要 enableFeaturePreview("VERSION_CATALOGS")),7.4 转 stable,Gradle 8.0 起 gradle/libs.versions.toml 默认启用无需配置。如果你的项目还在 Gradle 6.x,用不了。
根 build.gradle.kts
plugins {
// apply false:只在根项目声明插件版本,不实际应用
// 这样子模块用 alias(libs.plugins.spring.boot) 时就不用写版本号了
alias(libs.plugins.spring.boot) apply false
}
allprojects {
group = "com.example"
version = "1.0.0-SNAPSHOT"
}
subprojects {
apply(plugin = "java")
java {
// toolchain:Gradle 会自动找/下载匹配的 JDK,比 sourceCompatibility 更可靠
toolchain { languageVersion.set(JavaLanguageVersion.of(17)) }
}
dependencies {
// platform() = Maven 的 import scope:引入版本清单,不引入依赖
implementation(platform(libs.spring.boot.dependencies))
testImplementation(platform(libs.junit.bom))
// 所有模块通用的测试依赖
testImplementation(libs.bundles.test)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
showStandardStreams = false
}
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters") // Spring 的参数名绑定需要
}
}关于 subprojects {} 的警告(重要): 这种”从根项目往子项目注入配置”的写法是 Gradle 的经典模式,但它制造了项目间的隐式耦合——子模块的配置依赖于根项目的执行时机。这会:
- 破坏
configuration-on-demand - 让 Configuration Cache 更难命中(根项目一变,所有子项目配置都变)
- 跟 Gradle 9 的 Isolated Projects 方向冲突
更好的做法是约定插件(buildSrc 或独立复合构建里的 java-conventions.gradle.kts),每个模块显式 id("java-conventions")。上面的写法保留是因为它直观、适合教学和小项目;上规模的项目请直接用约定插件。
order-api/build.gradle.kts(库模块,暴露 API)
plugins {
`java-library` // 库模块用 java-library,才有 api / implementation 之分
}
dependencies {
// api:JsonNode 出现在公开接口签名里,下游必须看得见
api(libs.jackson.databind)
// implementation:Guava 只在方法体里用,不暴露给下游编译期
implementation(libs.guava)
}order-core/build.gradle.kts(库模块,内部实现)
plugins {
`java-library`
}
dependencies {
api(project(":order-api")) // 公开方法返回 OrderDTO,下游必须看见
implementation(project(":order-dal"))
implementation(libs.spring.context)
implementation(libs.mybatis)
implementation(libs.guava)
}order-dal/build.gradle.kts
plugins {
`java-library`
}
dependencies {
api(project(":order-api"))
implementation(libs.mybatis)
// 编译期只需要 JDBC 接口(JDK 自带),运行时才需要驱动实现
runtimeOnly(libs.mysql)
}order-web/build.gradle.kts(应用模块)
plugins {
java // 应用模块:不需要对外暴露 API
alias(libs.plugins.spring.boot) // Spring Boot 插件会自动应用 java 插件
}
dependencies {
implementation(project(":order-core"))
implementation(libs.spring.boot.starter.web)
implementation(libs.jackson.databind)
testImplementation(libs.mockito.core)
}
// Spring Boot 插件会生成 bootJar(fat jar)和 bootRun
tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
archiveFileName.set("order-service.jar")
}gradle.properties
# JVM 参数:改这个会导致 daemon 重启,一次配好别老动
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
org.gradle.parallel=true # Task 并行执行
org.gradle.caching=true # 开 Build Cache
org.gradle.configuration-cache=true # 开 Configuration Cache(Gradle 8.1+ stable)
org.gradle.configuration-cache.problems=warn # 迁移期先警告不失败
kotlin.code.style=official # 如果用了 Kotlin常用命令
./gradlew build # 全量:编译 + 测试 + 打包
./gradlew :order-core:test # 只跑某个模块的测试
./gradlew :order-core:test --tests '*OrderTest' # 只跑某个测试类
./gradlew build -x test # 排除某个 Task(≈ -DskipTests)
./gradlew bootRun # 跑 Spring Boot 应用(≈ mvn spring-boot:run)
./gradlew clean build --rerun-tasks # 忽略所有 up-to-date,全量重跑
./gradlew projects # 列出所有 project(≈ mvn validate)
./gradlew :order-web:tasks # 列出某个 project 的所有 Task
./gradlew :order-web:dependencies --configuration runtimeClasspath
./gradlew :order-web:dependencyInsight --configuration runtimeClasspath --dependency jackson-databind
./gradlew build --dry-run # 只走配置阶段,测 Configuration 耗时
./gradlew build --scan # 生成 Build Scan(性能分析神器)