2. 多模块项目结构
2.1 为什么需要多模块?
刚开始接触 Gradle 时,我习惯把所有代码塞进一个 module 里。后来项目越来越大,光是编译就要等几分钟,改一行公共代码还要担心影响到其他模块。
多模块项目说白了就是“分而治之”。把一个大项目拆成几个小模块,每个模块各司其职。这样做的好处很明显:
| 维度 | 单模块 | 多模块 |
|---|---|---|
| 编译速度 | 改一行触发全量编译 | 只编译变更模块及其依赖 |
| 代码隔离 | 所有依赖混在一起 | 依赖可见性可控制 |
| 团队协作 | 冲突频繁 | 不同团队负责不同模块 |
| 重用性 | 耦合严重 | 模块可独立发布 |
我记得去年接手一个遗留系统,一个单体项目里有二十万行代码,业务逻辑和工具类全混在一起。重构时我花了三周把它拆成 8 个模块,之后每次改 bug 只影响一个模块,测试覆盖率也上去了。
2.2 创建多模块项目
Gradle 创建多模块项目很简单。根项目只需要一个 settings.gradle.kts 文件来声明子模块。来看一个标准的例子:
// settings.gradle.kts
rootProject.name = "my-app"
include(
"common",
"api",
"service",
"web"
)
每个子模块要有一个自己的 build.gradle.kts。如果子模块之间共享一些配置,比如 Java 版本、仓库地址,我通常会在根项目的 build.gradle.kts 里用 allprojects 或 subprojects 来统一管理。
嗯,这里有个坑。当初我写共享配置时,直接在 allprojects 里加了很多公共依赖,结果所有模块都引入了那些依赖,包括不需要的模块。后来改成了只在 subprojects 里放必要的插件和通用配置,具体的依赖放到各自模块里。
// 根项目 build.gradle.kts
plugins {
java
id("org.springframework.boot") version "3.2.0" apply false
}
subprojects {
apply(plugin = "java")
group = "com.example"
version = "1.0.0"
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
}
为什么不用 allprojects?因为根项目本身通常不需要这些配置。你想想看,根项目只是一个容器,它自己一般不写业务代码。
创建目录结构的步骤大致是这样:
- 新建一个空目录作为根项目(比如
my-app)。 - 在里面创建
settings.gradle.kts和build.gradle.kts。 - 按照
include的模块名创建子目录,每个子目录里放自己的build.gradle.kts和src源码。 - 在根目录执行
gradle projects验证模块是否被正确识别。
2.3 项目间依赖关系
多模块的核心价值在于模块间的依赖管理。Gradle 里声明模块依赖非常直观。
// service/build.gradle.kts
dependencies {
implementation(project(":common"))
implementation(project(":api"))
}
你可能会问:project(":common") 这个写法是什么?: 表示从根项目开始的路径。如果模块嵌套更深,比如 sub-module 里的 inner,就写成 project(":sub-module:inner")。
implementation 而不是 api 来声明模块依赖,可以防止依赖泄漏。即:A 依赖 B,B 又依赖 C,如果 B 把 C 声明为 implementation,那么 A 不会间接依赖 C。这样能有效控制模块的可见性。
我曾在一个微服务项目中,因为用 api 声明了内部模块依赖,导致上层模块无意中引用了底层实现,后来升级底层库时牵一发而动全身。从那以后,我严格遵循:模块对外暴露的接口用 api,内部实现用 implementation。
依赖传递还会带来版本冲突。比如两个模块都依赖了不同版本的 Guava。这时可以用 dependencyConstraint 或者 force 来统一版本。不过更优雅的做法是在根项目里建一个 build-logic 模块,用 version catalog 统一管理所有依赖版本。
我们来看一张图,它展示了一个典型多模块项目的结构以及依赖关系:
从图中可以看到,web 模块依赖于 service,而 service 又依赖于 common 和 api。这种层级依赖在实际项目中非常常见。我一般把 common 放工具类和常量,api 放接口定义(比如 Feign 接口),service 放业务实现,web 放控制器和前端暴露。
2.4 依赖管理的避坑指南
- 循环依赖:A 依赖 B,B 依赖 A,编译直接报错。我曾经在重构时不小心引入循环依赖,排查了半天。解决方案是拆分出公共部分放到第三个模块。
- 依赖版本不一致:两个子模块分别引入了不同版本的 Jackson,导致运行时序列化异常。解决办法:在根项目用 version catalog 统一声明。
- 过度暴露内部依赖:用
api代替implementation会让模块边界模糊。我一直坚持“最小依赖原则”。
总结一下这一节的核心:多模块不仅仅是目录结构上的拆分,更是依赖关系的精心设计。每次添加一个新模块时,先想清楚它应该依赖哪些模块、它的 API 应该暴露给谁。嗯,这里的权衡决定了后期项目维护的难易程度。