2. 多模块项目结构

2.1 为什么需要多模块?

刚开始接触 Gradle 时,我习惯把所有代码塞进一个 module 里。后来项目越来越大,光是编译就要等几分钟,改一行公共代码还要担心影响到其他模块。

多模块项目说白了就是“分而治之”。把一个大项目拆成几个小模块,每个模块各司其职。这样做的好处很明显:

维度 单模块 多模块
编译速度 改一行触发全量编译 只编译变更模块及其依赖
代码隔离 所有依赖混在一起 依赖可见性可控制
团队协作 冲突频繁 不同团队负责不同模块
重用性 耦合严重 模块可独立发布

我记得去年接手一个遗留系统,一个单体项目里有二十万行代码,业务逻辑和工具类全混在一起。重构时我花了三周把它拆成 8 个模块,之后每次改 bug 只影响一个模块,测试覆盖率也上去了。

我的习惯:在项目初期就搭建好多模块骨架。哪怕一开始只有一个模块有业务代码,也要把公共模块、API 模块分离出来。这样后续扩展时不会手忙脚乱。

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?因为根项目本身通常不需要这些配置。你想想看,根项目只是一个容器,它自己一般不写业务代码。

创建目录结构的步骤大致是这样:

  1. 新建一个空目录作为根项目(比如 my-app)。
  2. 在里面创建 settings.gradle.kts 和 build.gradle.kts。
  3. 按照 include 的模块名创建子目录,每个子目录里放自己的 build.gradle.kts 和 src 源码。
  4. 在根目录执行 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 统一管理所有依赖版本。

我们来看一张图,它展示了一个典型多模块项目的结构以及依赖关系:

多模块项目结构与依赖关系 根项目 (my-app) common 模块 api 模块 service 模块 web 模块 图例说明: 实线箭头:子模块归属于根项目 虚线箭头:模块间的依赖关系(service 依赖 common 和 api;web 依赖 service) 注意:common 和 api 之间没有直接依赖,保持解耦。

从图中可以看到,web 模块依赖于 service,而 service 又依赖于 common 和 api。这种层级依赖在实际项目中非常常见。我一般把 common 放工具类和常量,api 放接口定义(比如 Feign 接口),service 放业务实现,web 放控制器和前端暴露。

2.4 依赖管理的避坑指南

  • 循环依赖:A 依赖 B,B 依赖 A,编译直接报错。我曾经在重构时不小心引入循环依赖,排查了半天。解决方案是拆分出公共部分放到第三个模块。
  • 依赖版本不一致:两个子模块分别引入了不同版本的 Jackson,导致运行时序列化异常。解决办法:在根项目用 version catalog 统一声明。
  • 过度暴露内部依赖:用 api 代替 implementation 会让模块边界模糊。我一直坚持“最小依赖原则”。
注意:如果子模块之间需要共享某个依赖的类,你应该把这个类定义在一个公共模块中(比如 common),然后让其他模块依赖 common,而不是直接依赖那个包含类的模块。这样可以保持依赖关系的单向、清晰。

总结一下这一节的核心:多模块不仅仅是目录结构上的拆分,更是依赖关系的精心设计。每次添加一个新模块时,先想清楚它应该依赖哪些模块、它的 API 应该暴露给谁。嗯,这里的权衡决定了后期项目维护的难易程度。