18、约定优于配置:自定义约定插件、统一项目结构、版本目录
说实话,在Gradle的世界里摸爬滚打几年后,我最大的感悟就是:约定优于配置这个理念,真的是解放生产力的关键。你想想看,如果每个项目都要从头配置一遍依赖、插件、任务,那得多累?
我个人习惯是,一旦团队超过三个人,就必须上约定插件。为什么?因为人多了,每个人写构建脚本的风格都不一样。有人喜欢把版本号写死,有人喜欢用extra属性,还有人直接复制粘贴上一项目的配置——结果就是,项目一多,维护成本直线上升。
什么是约定优于配置?
说白了,就是框架或工具提供一套默认的、合理的规则。你只要遵循这些规则,就能自动获得正确的行为。不需要你事无巨细地去声明每一件事。
Gradle本身就有很多约定:src/main/java是源码目录,src/test/java是测试目录。你不需要告诉Gradle「我的源码在这里」,它默认就知道。这就是约定。
但Gradle只提供了最基础的约定。对于企业级项目,我们需要自定义约定——比如统一的模块命名、统一的依赖管理、统一的插件版本。这时候,自定义约定插件就派上用场了。
自定义约定插件:从零开始
我在项目中遇到过这样一个场景:公司有十几个微服务,每个服务都用Spring Boot,但每个项目的build.gradle.kts都长得不一样。有人用io.spring.dependency-management,有人直接写死版本号。后来我写了一个约定插件,把所有公共配置抽出来,问题就解决了。
来看一个简单的约定插件示例:
// buildSrc/src/main/kotlin/my-conventions.gradle.kts
plugins {
id("java")
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.2.0"))
implementation("org.springframework.boot:spring-boot-starter-web")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}
然后在子项目中,只需要一行:
plugins {
id("my-conventions")
}
嗯,就这么简单。所有子项目自动获得Java 17、Spring Boot 3.2.0、JUnit 5的支持。不需要重复写那些又臭又长的配置了。
buildSrc 目录下,或者发布到内部仓库。我个人更推荐发布到仓库,因为这样多个项目可以共享同一个约定插件版本。
统一项目结构:别再各自为政了
你想想看,如果每个项目的目录结构都不一样,新人上手得多痛苦?A项目的测试在test目录,B项目的测试在src/test,C项目干脆没有测试目录——这还怎么协作?
统一项目结构,说白了就是定一套标准。比如:
| 模块 | 目录 | 说明 |
|---|---|---|
| 应用模块 | app/ |
主应用程序入口 |
| 公共库 | libs/common/ |
工具类、通用代码 |
| 数据层 | libs/data/ |
数据库、API客户端 |
| 领域层 | libs/domain/ |
业务逻辑、实体 |
| 测试工具 | libs/test-utils/ |
测试基类、Mock工厂 |
在约定插件里,我们可以强制这些目录存在:
// 在约定插件中
sourceSets {
main {
java.setSrcDirs(listOf("src/main/java"))
resources.setSrcDirs(listOf("src/main/resources"))
}
test {
java.setSrcDirs(listOf("src/test/java"))
resources.setSrcDirs(listOf("src/test/resources"))
}
}
我曾经见过一个项目,有人把配置文件放在config/目录,有人放在resources/,还有人直接放在项目根目录。后来我写了一个约定插件,强制所有配置文件必须放在src/main/resources下,并且用application-{profile}.yml命名。从那以后,再也没人问「配置文件在哪」了。
版本目录:告别版本冲突
版本管理,一直是多模块项目的痛点。我记得有一次,两个子模块依赖了不同版本的Jackson,结果运行时疯狂报错。排查了半天才发现是版本冲突。
Gradle 7.0引入的版本目录(Version Catalog),就是用来解决这个问题的。它把所有的依赖版本集中管理,统一声明,统一引用。
来看一个libs.versions.toml文件:
[versions]
spring-boot = "3.2.0"
kotlin = "1.9.21"
jackson = "2.16.0"
[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" }
jackson-core = { module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jackson" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
然后在build.gradle.kts中引用:
dependencies {
implementation(libs.spring.boot.starter.web)
implementation(libs.jackson.core)
testImplementation(libs.spring.boot.starter.test)
}
plugins {
alias(libs.plugins.spring.boot)
alias(libs.plugins.kotlin.jvm)
}
这样做的好处很明显:
- 版本统一:所有模块引用同一个版本,不会出现冲突
- 升级方便:改一个文件,所有模块都跟着变
- 可读性强:依赖的用途一目了然
gradle/libs.versions.toml 路径下。不要放在 buildSrc 里,否则无法被其他项目共享。
知识体系:约定插件的核心逻辑
下面这张图,是我自己总结的约定插件核心逻辑。你看完应该就明白整个体系是怎么运转的了。
你看,整个体系是分层的:最底层是版本目录,管理所有依赖版本;中间层是约定插件,引用版本目录并配置公共行为;最上层是各个子项目,只需要声明使用约定插件即可。
避坑指南:我踩过的几个坑
讲到这里,我得分享几个实战中容易踩的坑。这些都是我用真金白银换来的教训。
- 版本目录不要放在buildSrc里:我曾经把
libs.versions.toml放在buildSrc下,结果其他项目根本引用不到。正确的路径是gradle/libs.versions.toml。 - 约定插件不要写死版本号:我刚开始写约定插件时,直接把Spring Boot版本号写死在插件里。后来升级版本时,发现所有项目都得改。正确的做法是:约定插件引用版本目录,版本目录统一管理版本号。
- 小心插件顺序:在约定插件中声明插件时,顺序很重要。比如
kotlin插件必须在spring-boot之前声明,否则会报错。我因为这个坑浪费了半天时间。
beforeEvaluate 和 afterEvaluate 来控制配置时机。比如,有些配置必须在所有插件应用之后才能生效。
总结一下
约定优于配置,说白了就是「少写代码,多写规范」。通过自定义约定插件、统一项目结构、使用版本目录,我们可以让构建脚本变得简洁、一致、可维护。
我个人觉得,一个成熟的团队,应该把80%的构建逻辑都封装到约定插件里。剩下的20%,留给各个子项目做个性化配置。这样既保证了统一性,又保留了灵活性。
嗯,这就是我对约定插件的理解。希望对你有所帮助。