12、多模块项目:多模块项目结构、模块间依赖、统一版本管理、聚合与继承

说实话,我见过太多人把多模块项目搞砸了。

明明是个小项目,硬拆成七八个模块,结果依赖关系乱成一锅粥。或者反过来,一个巨型单体项目,几万行代码塞在一个模块里,每次编译都要喝杯咖啡等它跑完。

多模块项目,说白了就是「分而治之」的思想。但怎么分、怎么治,这里头有门道。

12.1 多模块项目结构:怎么拆才合理?

我个人习惯,先看业务边界。一个模块应该对应一个清晰的业务领域或技术职责。比如一个电商系统,可以拆成:

  • common:公共工具类、常量、异常定义
  • dal:数据访问层,放 MyBatis Mapper、实体类
  • service:业务逻辑层
  • web:Controller 层,对外暴露 API
  • api:对外提供的 RPC 接口定义

嗯,这里要注意:不是拆得越细越好。模块多了,构建时间反而可能变长,因为 Gradle 要处理更多的配置和依赖解析。

典型的目录结构长这样:

my-project/
├── settings.gradle.kts
├── build.gradle.kts          # 根项目构建文件
├── common/
│   ├── build.gradle.kts
│   └── src/
├── dal/
│   ├── build.gradle.kts
│   └── src/
├── service/
│   ├── build.gradle.kts
│   └── src/
└── web/
    ├── build.gradle.kts
    └── src/

根目录的 settings.gradle.kts 里,要声明所有子模块:

rootProject.name = "my-project"

include(
    "common",
    "dal",
    "service",
    "web"
)

我在项目中遇到过有人把模块名写成 :common 带冒号的,结果 Gradle 报错半天找不到原因。记住,include 里直接写模块名就行,Gradle 会自动拼接路径。

12.2 模块间依赖:别搞成循环引用

模块之间怎么依赖?很简单,在子模块的 build.gradle.kts 里声明:

// service/build.gradle.kts
dependencies {
    implementation(project(":common"))
    implementation(project(":dal"))
}

// web/build.gradle.kts
dependencies {
    implementation(project(":service"))
}

这里有个铁律:绝对不能出现循环依赖。比如 A 依赖 B,B 又依赖 A。Gradle 会直接报错,而且这种错误在大型项目里特别难排查。

我曾经在一个老项目里见过 A → B → C → A 的循环依赖,三个模块互相引用。最后花了整整一个下午才理清关系,重构了接口才解决。所以,设计阶段就要画好依赖图,确保是单向的。

另外,注意 implementationapi 的区别:

  • implementation:依赖只对当前模块可见,不会传递到下游
  • api:依赖会透传给依赖当前模块的其他模块

我建议默认用 implementation,除非你明确需要暴露依赖。这样可以减少不必要的编译开销。

12.3 统一版本管理:别再手动改版本号了

多模块项目最头疼的问题之一:版本号散落在各个模块里。升级一个依赖版本,要改十几个文件,漏一个就出问题。

Gradle 提供了几种统一管理版本的方式,我推荐用 Version Catalog(版本目录),这是 Gradle 7.0+ 官方推荐的做法。

在根目录创建 gradle/libs.versions.toml

[versions]
spring-boot = "3.2.0"
mybatis = "3.5.16"
lombok = "1.18.30"

[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
mybatis-spring-boot-starter = { module = "org.mybatis.spring.boot:mybatis-spring-boot-starter", version.ref = "mybatis" }
lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }

然后在子模块里引用:

dependencies {
    implementation(libs.spring.boot.starter.web)
    implementation(libs.mybatis.spring.boot.starter)
    compileOnly(libs.lombok)
}

这样,所有版本号都集中在 libs.versions.toml 里。改一个地方,所有模块同步更新。

小技巧:我习惯在 libs.versions.toml 里也定义插件版本,比如 Kotlin、Spring Boot 插件。这样整个项目的版本管理就完全统一了。

12.4 聚合与继承:Gradle 的两种复用机制

这两个概念容易混淆,我简单解释一下。

聚合(Aggregation):根项目把子模块聚在一起,统一构建。你执行 ./gradlew build,所有子模块都会构建。这就是聚合。

继承(Inheritance):子模块可以继承根项目的配置。比如所有子模块都用同样的仓库、插件、依赖版本。

在 Gradle 里,继承通过 allprojectssubprojects 实现:

// 根项目的 build.gradle.kts
subprojects {
    apply(plugin = "java")
    apply(plugin = "io.spring.dependency-management")

    group = "com.example"
    version = "1.0.0"

    repositories {
        mavenCentral()
    }

    java {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

这样,所有子模块自动继承 Java 版本、仓库配置。你不需要在每个子模块里重复写。

但要注意:不要滥用继承。我见过有人把业务逻辑也写在根项目的 subprojects 里,结果子模块之间耦合严重。继承只适合放基础设施配置,比如插件、仓库、编译选项。

12.5 一张图看懂多模块项目

下面这张 SVG 图,展示了多模块项目的核心结构:

根项目 (Root) settings.gradle.kts + build.gradle.kts 聚合 (Aggregation) — 统一构建 common 工具类、常量 dal 数据访问层 service 业务逻辑层 web API 层 统一版本管理:gradle/libs.versions.toml 所有依赖版本集中管理,一处修改,全局生效 继承:插件、仓库、编译选项

从这张图可以清楚看到:根项目负责继承配置(插件、仓库、编译选项),子模块被聚合在一起统一构建,模块之间通过 project() 建立依赖关系,版本号统一由 libs.versions.toml 管理。

12.6 避坑指南

最后,分享几个我踩过的坑:

  • 模块命名不要用驼峰:Gradle 默认用目录名作为模块名,驼峰命名在跨平台时可能出问题。我习惯用短横线,比如 user-service
  • 不要忽略 settings.gradle.kts:有次我忘了在 settings.gradle.ktsinclude 新模块,结果构建时 Gradle 完全无视它,找了半小时才发现。
  • 慎用 compileruntime:这两个配置在 Gradle 7.0 以后已经废弃了。用 implementationapicompileOnly 替代。
  • 版本目录文件位置别放错libs.versions.toml 必须放在 gradle/ 目录下,不是根目录。放错了 Gradle 不会报错,但版本目录不会生效。

核心要点总结

  • 按业务边界拆分模块,避免过细或过粗
  • 模块间依赖保持单向,杜绝循环引用
  • 用 Version Catalog 统一管理版本号
  • 根项目负责继承配置,子模块负责业务实现
  • 聚合让所有模块可以一键构建

多模块项目,说白了就是「分而治之」再加「统一管理」。分得好,项目结构清晰、构建高效;分得不好,那就是给自己挖坑。希望这些经验能帮你少走弯路。

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