第 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)GradleMaven 表达多语言构建要靠一堆 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 经验一般、人员流动快MavenXML 的约束是资产。任何人接手都能看懂,出问题好查
追求构建脚本的标准化和可读性Maven所有 Maven 项目的 pom 长得都差不多;Gradle 项目的脚本可以千差万别
依赖简单的中小型服务(< 10 个模块)Maven增量构建和缓存的收益在这个规模上测不出来
CI 环境简单、不想引入缓存服务器MavenGradle 的完整收益需要缓存基础设施,配不好反而更慢
需要和大量传统企业插件/工具集成Maven某些行业工具(代码扫描、合规检查、专有打包)只有 Maven 插件
已经在用 Maven 且没痛点不要迁见下面的成本分析

最后一行值得用加粗再说一遍:已经在用 Maven 且没痛点的项目,别迁。 这不是保守,是算过账的。

迁移成本

gradle init 能自动把 Maven 项目转成 Gradle:

cd order-service
gradle init --type pom        # 读 pom.xml,生成 settings.gradle.kts + 各模块 build.gradle.kts

但生成的是”翻译版”,有几个已知问题:

  1. 所有 compile 都翻成 api(或 implementation,取决于版本),不会帮你做语义区分。 翻译版通常全用 implementation,这不会出错,但你也拿不到 implementation 的全部收益——真正要紧的是把该暴露的改成 api,把剩下的留在 implementation,这一步只能人工判断。
  2. 插件换成 Gradle 版,配置要重写。 maven-shade-plugin → Shadow 插件;maven-enforcer-plugin → 没有直接对应,要靠 resolutionStrategy + 自定义 Task。
  3. profile 逻辑要重写。 Maven profile 没有直接对应物,通常翻成 if (project.hasProperty(...))。
  4. 资源过滤、属性替换要改成 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 min6%小
下载依赖2.5 min31%极大
编译(全量)1.5 min19%中
跑测试2.5 min31%大
打包 + 镜像1.0 min13%小

注意这个分布:真正的”编译”只有 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.55.5
只构建变更模块-3.02.5
并行 -T 1C-0.71.8
测试分层 + 跳过 javadoc-0.61.2 min

8 分钟 → 1.2 分钟,全部用 Maven 现有手段,零学习成本,零迁移风险。

(数字是我按典型情况估的,你的项目要自己测。但这个量级关系是可信的:在这个场景里,构建工具不是瓶颈,依赖下载和”构建了你没改的东西”才是。)


第三步:现在再算迁移 Gradle 的账

收益侧(乐观估计):

收益项乐观估计说明
增量编译-0.3 min编译只有 1.5 分钟,且已用 -pl -am 裁掉了大部分
Build Cache 跨构建复用-0.3 min需要配缓存服务器,且你的模块变更率高的话复用率有限
Daemon0CI 上每次都是新容器,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)声明约束。它有四个已知别扭:

  1. 字符串引用,没有类型。 <version>${jackson.version}</version> —— 拼错了要跑一遍才知道(Maven 会把它当字面量,然后报”找不到这个版本”)。
  2. IDE 补全弱。 在 <properties> 和 <dependencyManagement> 之间跳来跳去,很累。
  3. 只对依赖有效,对插件无效。 插件版本要单独写在 <pluginManagement> 里,另一套机制。
  4. 跨项目共享要发一个 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(性能分析神器)