楼层: 首页/ 软件技术/ Java 基础/ 构建工具 Gradle:把依赖、编译、测试、打包串成一条线
14

构建工具 Gradle:把依赖、编译、测试、打包串成一条线

Gradle · Kotlin DSL · Version Catalog · Dependency Locking

前面 13 章你写的都是"单个 .java 文件 + javac"。可一旦项目里出现 十几个 jar 依赖、三个模块、还要跑测试和打可执行包,"手敲 javac"这件事就彻底不可行了。这一章讲 Java 世界的两条主流路线之一——Gradle(另一条是 Maven)。本章基线:Gradle 9.x(当前稳定线 9.7.x)+ JDK 21 toolchain。

为什么需要构建工具:依赖是棵树,不是一张清单

是什么:构建工具是一条可重复、可复现的流水线:解析依赖 → 编译 → 跑测试 → 打包 → 发布。javac 只覆盖了其中"编译"的第一步。

为什么必需:因为 Java 的依赖是树状的——你写了 guava,guava 又依赖 failureaccess 和 checker-qual,那几个又各自有依赖。版本冲突时谁赢、同一个类在两个 jar 里出现怎么办,靠人肉管理必然出错。构建工具还解决另外两个问题:可复现(你用 21 编译、同事用 17,行为就不一样,所以要用 toolchain 钉死 JDK 版本)和一次性产出全部工件(jar、测试报告、覆盖率、Docker 镜像)。

怎么用:先建立一个最小可跑的 Gradle 项目,再逐个理解它的组成部分。

标准 Gradle Java 项目的目录结构(约定优于配置,不要改)

demo/
├── settings.gradle.kts              # 项目名、包含哪些子模块(构建的"入口")
├── build.gradle.kts                 # 构建脚本本身(Kotlin DSL)
├── gradle/
│   ├── libs.versions.toml           # 版本目录:所有依赖版本集中在这里
│   └── wrapper/
│       ├── gradle-wrapper.jar       # wrapper 的启动器,要提交到版本库
│       └── gradle-wrapper.properties # 钉死 Gradle 版本
├── gradlew                          # Linux/macOS 启动脚本
├── gradlew.bat                      # Windows 启动脚本
└── src/
    ├── main/java/com/example/App.java        # 主代码(这个路径是约定,别改)
    ├── main/resources/application.properties # 资源文件(会打进 jar)
    └── test/java/com/example/AppTest.java    # 测试代码
坑:不用 wrapper,直接用系统装的 gradle

这是团队协作里最容易出问题的一步。gradlew 是"自带 Gradle 版本"的启动器——它读 gradle-wrapper.properties 里的版本号,没有就自动下载,保证所有人、所有 CI 机器用的是同一个 Gradle 版本。而"系统装的 gradle"在你的机器上是 8.2,在新同事机器上是 9.7,"我这儿能构建、CI 上失败"往往就源于此。纪律:永远用 ./gradlew,永远把 gradlew、gradlew.bat、gradle/wrapper/ 提交进版本库。升级版本也用命令做:./gradlew wrapper --gradle-version=9.7.1——它会顺手把启动脚本一起更新。顺带一个安全实践:在 properties 里加上 distributionSha256Sum,配合 CI 的 wrapper 校验动作,可以防住"构建工具分发包被替换"这类供应链攻击。

三个构建阶段与常用命令

是什么:每次执行 ./gradlew xxx,Gradle 都会按固定顺序走三个阶段:① 初始化(读 settings.gradle.kts 确定有哪些项目)→ ② 配置(执行所有 build.gradle.kts,算出这一轮要跑的任务图)→ ③ 执行(真正跑被选中的任务)。

为什么必须知道这三个阶段:因为构建慢的绝大多数原因出在"配置阶段",而不是编译或测试本身。项目里有 50 个模块时,每个模块的 build.gradle.kts 每次都要被执行一遍——哪怕你只想跑一个测试。Gradle 后续版本的重点优化(配置缓存、Isolated Projects)全都冲着这个阶段去。知道了这一点,你就知道该往哪里找性能问题的根因。

日常够用的一组命令(全部用 ./gradlew)

./gradlew tasks                    # 列出所有可执行任务(不记得命令时先看这个)
./gradlew build                    # 编译 + 测试 + 打包(最常用的总入口)
./gradlew compileJava              # 只编译,不跑测试(改代码后快速验证语法)
./gradlew test                     # 只跑测试
./gradlew test --tests "*AppTest"   # 只跑匹配的测试类(大数据集项目里非常省时间)
./gradlew run                      # 跑 application 插件配置的 mainClass
./gradlew jar                      # 只打 jar 包,产物在 build/libs/
./gradlew dependencies             # 打印依赖树(排查"这个 jar 是哪来的"第一名)
./gradlew dependencyInsight --dependency guava --configuration runtimeClasspath
# ↑ 只查某个依赖"为什么被引入、最后解析成哪个版本"--排查版本冲突的利器
./gradlew clean                    # 删掉 build/ 目录(会丢掉增量构建的成果,别习惯性加它)
./gradlew --status                 # 看有哪些守护进程(daemon)在跑
./gradlew --stop                   # 停掉所有 daemon(改了内存参数后需要)
./gradlew build --scan             # 生成构建扫描报告,用它分析"到底哪一步慢"
让构建变快的几个开关:先加这三个,再谈换工具
开关作用
--configuration-cache缓存"配置阶段"的结果:第二次跑同样的任务时直接跳过配置。大项目上这一项常常就是几倍的差距。可以在 gradle.properties 里常开。
--build-cache跨机器复用任务产物(本地 + CI 都能共享)。改了无关模块时不必重编。
--parallel多模块项目并行构建。单模块项目没用,多模块效果明显。
org.gradle.jvmargs=-Xmx4g写在 gradle.properties 里。构建本身是个 JVM,默认堆常常偏小,大项目会频繁 GC 甚至 OOM。
--offline网络不好时跳过远程检查,用本地缓存构建。

译Kotlin DSL 和 Groovy DSL 该选哪个

Gradle 同时支持两种脚本语言:老项目常见 build.gradle(Groovy),新项目默认 build.gradle.kts(Kotlin DSL)。

新项目一律用 Kotlin DSL,理由有三:① IDE 补全和跳转能用——写错的任务名、拼错的配置项当场变红,不用等到执行才报错;② 类型安全,配置项是有类型的,不是"随便塞个字符串,运行时才发现写错";③ 官方文档现在都以 Kotlin DSL 为主,抄例子不用翻译。

代价是它对 Kotlin 语法有基本要求(tasks.test { } 这种 lambda 写法、tasks.register("x") { } 的惰性配置)。但你要用的其实就那么几个固定模板,抄几次就熟了。

build.gradle.kts 实战:从空目录到能跑能测能打包

是什么:build.gradle.kts 就是构建脚本本体。它的内容大体分五块:插件 → 项目信息 → 仓库 → 依赖 → 任务配置。

为什么这么组织:因为 Gradle 的插件机制把"能力"和"配置"分开了:plugins { java } 引入 Java 支持(带来 compileJava、test、jar 等任务),然后你再去配置这些任务的参数。不引入插件,就没有那些任务。

settings.gradle.kts(项目的入口,哪怕只有一行)

rootProject.name = "demo"

// 多模块项目在这里声明子模块
// include("app", "core", "web")
// 用类型安全的写法可以避免手写字符串:
// includeBuild("build-logic")

build.gradle.kts:一个完整、可直接抄的 Java 应用

// ── ① 插件:引入能力(java 负责编译/测试/打包,application 负责 run 和启动脚本) ──
plugins {
    java
    application
}

// ── ② 项目信息 ──
group = "com.example"
version = "0.1.0"

// ── ③ Java 版本:用 toolchain 钉死,不依赖你机器上的默认 JDK ──
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

// ── ④ 仓库:从哪找依赖 ──
repositories {
    mavenCentral()
    // mavenLocal() // 一般不要加,原因见后文"坑"
}

// ── ⑤ 依赖 ──
dependencies {
    implementation("com.google.guava:guava:33.4.0-jre")

    testImplementation(platform("org.junit:junit-bom:5.11.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
    // ↑ 版本号以 Maven Central 当前最新为准;写进版本目录统一管理更省心(见下一节)
}

// ── ⑥ 任务配置:JUnit 5 必须显式开 useJUnitPlatform ──
tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "skipped", "failed")
    }
}

// ── ⑦ application 插件:指定入口类,run 任务和启动脚本都靠它 ──
application {
    mainClass = "com.example.App"
}

// ── ⑧ 让 jar 可以直接 java -jar 运行 ──
tasks.jar {
    manifest {
        attributes("Main-Class" to application.mainClass.get())
    }
}

gradle.properties:项目级配置(不用每次敲命令行参数)

# 构建 JVM 的内存(构建自己也要内存,大项目默认值常常不够)
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g

# 常开这两个,日常构建体感提升最明显
org.gradle.caching=true
org.gradle.configuration-cache=true

# 多模块项目可以再打开并行
# org.gradle.parallel=true

# 指定 Gradle 自己用哪个 JDK(注意:这和 toolchain 是两件事,见下方"坑")
# org.gradle.java.home=/path/to/jdk-21
坑:Gradle 自己的 JVM 和编译用的 JDK,不是同一个东西

很多人把这个搞混,然后对着"Unsupported class file major version"发呆。要分清两个概念:① 运行 Gradle 的 JVM——Gradle 9 要求 JDK 17 及以上才能启动,这个由 JAVA_HOME 或 org.gradle.java.home 决定;② 编译/运行你代码的 JDK——由 java { toolchain { languageVersion = ... } } 决定,Gradle 会自己去下载或查找对应的 JDK。

正确做法:写 toolchain,并把 build.gradle.kts 提交上去。这样哪怕同事机器上装的是 JDK 25,编译产物依然是 21 的目标版本,团队行为一致。反过来,没有 toolchain 时,你的构建结果取决于"当前机器 PATH 里是哪个 java"——这是"我这儿能跑 CI 上不能"的经典成因。

版本目录 libs.versions.toml:把版本号收进一个文件

是什么:版本目录(Version Catalog)是 Gradle 官方的依赖集中管理机制:所有依赖的坐标和版本写在 gradle/libs.versions.toml 里,构建脚本里用 libs.xxx 引用。

为什么需要它:解决多模块项目里三个真实的痛点:① 版本漂移——同一个 guava 在 8 个模块里写了 8 个不同版本;② 升级要全局搜替换——想从 JUnit 5.11 升到 5.12,得在十几个文件里改;③ 拼写错误只能等到运行时报错——写进 TOML 之后,IDE 会给你 补全和跳转,libs.junit.jupitr 这种拼错当场变红。

gradle/libs.versions.toml:四个区段各司其职

# ── [versions] 只放"版本号",方便一处升级 ──
[versions]
guava = "33.4.0-jre"
junit = "5.11.4"
springBoot = "3.4.1"

# ── [libraries] 依赖坐标:module 是 GAV 的前两段,version 引自上面的版本 ──
[libraries]
guava = { module = "com.google.guava:guava", version.ref = "guava" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" }

# ── [bundles] 把几个依赖打包成一组,一次引用 ──
[bundles]
junit = ["junit-jupiter", "junit-platform-launcher"]

# ── [plugins] 插件也可以集中管理 ──
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" }

在 build.gradle.kts 里使用(注意访问器的命名转换规则)

plugins {
    java
    alias(libs.plugins.spring.boot)     // [plugins] 里叫 spring-boot,这里写 libs.plugins.spring.boot
}

dependencies {
    implementation(libs.guava)                    // guava            -> libs.guava
    testImplementation(platform(libs.junit.bom))  // junit-bom        -> libs.junit.bom
    testImplementation(libs.bundles.junit)        // [bundles] 一次引一组
}

// 想读版本号本身(比如打进度日志)?versions 区的键是"原样"的:
// println(libs.versions.springBoot.get())
命名转换规则:TOML 里的短横线,到 Kotlin 里变成点
TOML 里的名字Kotlin 里的访问器说明
guavalibs.guava一级名字直接用。
junit-bomlibs.junit.bom短横线被解析成层级分隔,这是新手最容易卡住的一点。
commons-lang3libs.commons.lang3同理,不要写成 libs.commons-lang3(编译不过)。
[plugins] spring-bootlibs.plugins.spring.boot插件多一层 plugins 前缀。
[versions] springBootlibs.versions.springBoot驼峰保留,读出来是 Provider<String>,要 .get()。
坑:版本目录不生效 / 访问器找不到

三种常见原因:① 文件路径不对——必须是 gradle/libs.versions.toml(在根项目的 gradle/ 目录下),放到别处 Gradle 不会自动识别;② 多模块项目中没在 settings 里声明——正常情况下根项目的目录会被所有子模块继承,但如果你用了 includeBuild 等复杂结构,需要在 settings.gradle.kts 里显式声明;③ 改完 TOML 没重新同步——IDE 需要一次 Gradle Sync 才会生成访问器,终端里 ./gradlew build 则会自动重新生成。另外注意:TOML 里的 library 名不要和 [versions] 里的 key 完全重名(比如都叫 guava)——虽然通常能解析,但会让你在阅读时产生歧义,建议 versions 里的名字和 library 名区分开。

依赖锁定与可复现构建:让"同一个版本"真的是同一个

是什么:依赖锁定(dependency locking)会把本次构建实际解析出来的所有依赖版本写进一个 gradle.lockfile;下次构建必须严格按这个清单来,任何偏离都会报错。

为什么需要它:因为"Maven 里写着 33.4.0"并不等于"每次都拿到同一个 jar"。三类漂移来源:① 动态版本(1.0.+、latest.release)——今天解析到 A、明天解析到 B;② -SNAPSHOT 依赖——同一个版本号的内容每天都在变;③ 传递依赖的版本仲裁——你加了一个新库,它悄悄把你原有的 guava 从 33 顶到了 34,于是某个 API 不见了。锁定的价值在于:把"能复现"从愿望变成强制。

① 打开锁定(写在根项目的 build.gradle.kts,多模块时对所有模块生效)

allprojects {
    dependencyLocking {
        lockAllConfigurations()          // 锁定所有可解析的配置
    }
}

// 单个模块的话也可以直接写:
// dependencyLocking { lockAllConfigurations() }

② 生成/更新锁文件(生成后提交进版本库)

./gradlew dependencies --write-locks      # 解析依赖并写出所有 gradle.lockfile
./gradlew build --write-locks             # 也可以顺手在构建时更新

# 只想升级某一个依赖(其余保持不动)
./gradlew build --update-locks com.google.guava:guava

# 生成出来的 gradle.lockfile 长这样(这是要被提交进 git 的):
# com.google.guava:guava:33.4.0-jre=compileClasspath,runtimeClasspath,testCompileClasspath
# com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,...

③ 再进一步:校验依赖的校验和(防"同一个版本号,内容被换掉")

./gradlew --write-verification-metadata sha256 help
# 生成 gradle/verification-metadata.xml 和 .sha256 文件,提交进版本库
# 之后任何 jar 的哈希对不上,构建直接失败 -- 这是供应链安全的底线
坑:加了 mavenLocal() 之后,构建就永远不可复现了

repositories { mavenLocal() } 看起来很方便(能用到你本地 ~/.m2 里自己 install 的包),但它有个致命副作用:构建结果开始依赖"你这台机器上恰好有什么"。你本地装了某个自制的 com.example:core:1.0,于是构建通过;CI 上没装,于是失败——而且报的错往往是"找不到符号"这种让人完全想不到本地仓库的错。正确做法:需要共享的包就发到私服(Nexus / Artifactory)或者用 includeBuild 做复合构建,别用 mavenLocal()。另外还有两个可复现性杀手:① 依赖里用 + 或 latest.release;② 没锁定的 -SNAPSHOT。锁定机制能把它们管住,但最好的办法是从一开始就别用。

依赖配置:implementation / api / compileOnly 到底怎么选

是什么:在 dependencies { } 里,每条依赖前面那个词叫配置(Configuration),它决定"这个依赖在哪个阶段可见、对别人可不可见"。写错配置不会报错,只会造成依赖泄漏或者运行时 ClassNotFound。

六个最常用的配置:先记住 implementation 是默认答案
配置可见范围什么时候用
implementation编译 + 运行,不泄漏给下游默认答案。你内部用的库,别人不需要知道。
api编译 + 运行,泄漏给下游只在库模块用:你的公开 API 签名里出现了这个库的类型(比如返回 com.google.common.collect.ImmutableList)。应用模块几乎不需要它。
compileOnly只有编译期运行时由容器提供的 API,典型是 jakarta.servlet-api(Tomcat 会提供),以及 Lombok 这类注解库。
runtimeOnly只有运行期只有实现、代码里不直接引用:JDBC 驱动(mysql-connector-j)、日志实现(logback)。
annotationProcessor只在编译期跑处理器MapStruct、Lombok(第 17 章详述)。放错到 implementation 会污染运行时类路径。
testImplementation只在测试编译+运行JUnit、AssertJ、Mockito 等测试库。
坑:把 JDBC 驱动写成 compileOnly,运行时报 ClassNotFound

这是新手最经典的一次"编译全绿、启动就崩"。原因是代码里从来没有 import com.mysql.cj.jdbc.Driver——驱动是靠字符串类名或 SPI 机制在运行时被加载的,所以编译器根本不会检查它是否存在,而 compileOnly 又不把它放进运行期类路径。规律:凡是"只有实现、没有编译期引用"的依赖,一律用 runtimeOnly。(这类问题现在也能被更早发现:./gradlew dependencies --configuration runtimeClasspath 看一下运行期到底带了哪些 jar,一眼就能看出驱动不在。)

坑:在应用模块里到处用 api,把依赖泄漏成一张大网

api 的语义是"我要用它,用我的人也要用它"。在 应用(war / 可执行 jar)里用 api 没有任何好处,因为应用没有下游;在库里滥用 api 则会让下游的编译类路径无限膨胀——A 用了 api guava,B 依赖 A,于是 B 也莫名其妙能看见 guava,然后 B 的代码里开始直接用 guava,于是 guava 的版本升级变成牵一发动全身的事。判断标准很简单:"如果我把这个依赖换掉,下游的代码会不会编译不过?"会,才用 api;不会,一律 implementation。

Gradle vs Maven:到底该用哪个

是什么:Maven 是 Java 界最早的依赖管理 + 构建标准,用 XML 描述;Gradle 是后起之秀,用代码(Kotlin/Groovy)描述,强调增量和性能。两者都用同一个中央仓库(Maven Central),发布产物互相兼容。

不是"谁更先进",而是"谁更适合你和你的团队"
维度MavenGradle
描述方式pom.xml,声明式 XML。生命周期(compile/test/package/install)固定。build.gradle.kts,本质是可执行脚本,任务图可编程。
构建性能无守护进程、无跨机器构建缓存,大项目全量构建偏慢。守护进程 + 增量构建 + 构建缓存 + 配置缓存,多次构建的场景优势明显。
灵活度想干"标准生命周期之外"的事要写插件,成本较高。构建脚本就是代码,自定义任务、条件逻辑很直接。
学习曲线低且稳定:会 Java 就能读懂大多数 pom。要懂一点 Kotlin DSL、任务图、配置阶段;也更容易写出"只有作者能维护"的构建脚本。
生态插件多、寿命长、企业存量巨大,几乎所有 Java 工具都提供 Maven 插件。现代生态强:Android 只能用 Gradle;Kotlin Multiplatform、Spring Boot 插件体验很好。
私服/CINexus、Artifactory、各类 CI 天然支持。同样支持;构建扫描(--scan)让性能分析更方便。

命令对照:从 Maven 切过来先记这张表

# Maven                                   Gradle(都是 ./gradlew)
mvn clean install                    =>  ./gradlew build
mvn compile                          =>  ./gradlew compileJava
mvn test                             =>  ./gradlew test
mvn package -DskipTests              =>  ./gradlew assemble
mvn spring-boot:run                  =>  ./gradlew bootRun
mvn dependency:tree                  =>  ./gradlew dependencies
mvn dependency:tree -Dincludes=g:a    =>  ./gradlew dependencyInsight --dependency a
mvn -pl web -am install              =>  ./gradlew :web:build
mvn -T 4 clean install               =>  ./gradlew build --parallel
mvn -o                               =>  ./gradlew build --offline
mvn versions:display-dependency-updates => ./gradlew dependencyUpdates   # 需要第三方插件

选一句话选型建议

选 Gradle:新项目、多模块、用 Kotlin、做 Android、对构建速度敏感、团队里有人愿意维护构建脚本。

选 Maven:传统企业项目、团队以"业务开发"为主不想折腾构建、需要复用大量现成的 Maven 插件、或者项目本来就跑得好好的。存量项目为了"用新技术"去迁移,收益往往远小于风险。

最后一条,也是最实在的一条:构建脚本是要长期维护的代码,选那个"团队里所有人都看得懂"的。一个只有原作者能改的 Gradle 脚本,比一个平平无奇但谁都敢动的 pom.xml 危险得多。

记
本章小结

① 永远用 ./gradlew,并把 wrapper 提交进版本库——它保证所有人用同一个 Gradle 版本;升级用 ./gradlew wrapper --gradle-version=x.y.z。

② 版本集中到 gradle/libs.versions.toml,脚本里用 libs.xxx;记住短横线变点的规则(junit-bom → libs.junit.bom)。依赖配置默认写 implementation,只有真正要泄漏给下游的才用 api。

③ 要可复现就上锁定:dependencyLocking { lockAllConfigurations() } + --write-locks,把 gradle.lockfile 提交进库;别加 mavenLocal()。构建慢先看"配置阶段",--configuration-cache 和 --build-cache 是最划算的两个开关。