18、约定优于配置:自定义约定插件、统一项目结构、版本目录

说实话,在Gradle的世界里摸爬滚打几年后,我最大的感悟就是:约定优于配置这个理念,真的是解放生产力的关键。你想想看,如果每个项目都要从头配置一遍依赖、插件、任务,那得多累?

我个人习惯是,一旦团队超过三个人,就必须上约定插件。为什么?因为人多了,每个人写构建脚本的风格都不一样。有人喜欢把版本号写死,有人喜欢用extra属性,还有人直接复制粘贴上一项目的配置——结果就是,项目一多,维护成本直线上升。

什么是约定优于配置?

说白了,就是框架或工具提供一套默认的、合理的规则。你只要遵循这些规则,就能自动获得正确的行为。不需要你事无巨细地去声明每一件事。

Gradle本身就有很多约定:src/main/java是源码目录,src/test/java是测试目录。你不需要告诉Gradle「我的源码在这里」,它默认就知道。这就是约定。

但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 里,否则无法被其他项目共享。

知识体系:约定插件的核心逻辑

下面这张图,是我自己总结的约定插件核心逻辑。你看完应该就明白整个体系是怎么运转的了。

约定插件核心逻辑 版本目录 (libs.versions.toml) 约定插件 (my-conventions) 统一Java版本 · 统一插件 · 统一依赖 子项目 A app/ 子项目 B libs/common/ 子项目 C libs/data/ 所有子项目通过 plugins { id("my-conventions") } 统一配置 版本目录统一管理依赖版本,避免冲突

你看,整个体系是分层的:最底层是版本目录,管理所有依赖版本;中间层是约定插件,引用版本目录并配置公共行为;最上层是各个子项目,只需要声明使用约定插件即可。

避坑指南:我踩过的几个坑

讲到这里,我得分享几个实战中容易踩的坑。这些都是我用真金白银换来的教训。

  • 版本目录不要放在buildSrc里:我曾经把libs.versions.toml放在buildSrc下,结果其他项目根本引用不到。正确的路径是gradle/libs.versions.toml
  • 约定插件不要写死版本号:我刚开始写约定插件时,直接把Spring Boot版本号写死在插件里。后来升级版本时,发现所有项目都得改。正确的做法是:约定插件引用版本目录,版本目录统一管理版本号。
  • 小心插件顺序:在约定插件中声明插件时,顺序很重要。比如kotlin插件必须在spring-boot之前声明,否则会报错。我因为这个坑浪费了半天时间。
我的小技巧: 在约定插件中,可以用 beforeEvaluateafterEvaluate 来控制配置时机。比如,有些配置必须在所有插件应用之后才能生效。

总结一下

约定优于配置,说白了就是「少写代码,多写规范」。通过自定义约定插件、统一项目结构、使用版本目录,我们可以让构建脚本变得简洁、一致、可维护。

我个人觉得,一个成熟的团队,应该把80%的构建逻辑都封装到约定插件里。剩下的20%,留给各个子项目做个性化配置。这样既保证了统一性,又保留了灵活性。

嗯,这就是我对约定插件的理解。希望对你有所帮助。


公众号:蓝海资料掘金营,微信deep3321