9、多模块项目:多项目结构、跨项目配置、依赖替换

说实话,多模块项目是我在实战中遇到最多坑的地方。刚开始用 Gradle 时,我习惯把所有代码塞进一个模块里,觉得这样简单。直到项目膨胀到几万行代码,每次编译都要等五分钟……嗯,那感觉真不好受。

多模块不是炫技,而是工程化的必然选择。今天我们就来聊聊,怎么用 Kotlin DSL 把多模块项目组织得既清晰又灵活。

9.1 多项目结构:从单模块到多模块

先看一个典型的多模块项目长什么样。我个人习惯用 settings.gradle.kts 来声明所有子模块:

// settings.gradle.kts
rootProject.name = "my-app"

include(":core")
include(":service:user")
include(":service:order")
include(":web:api")
include(":web:admin")

注意这里的命名方式。冒号代表路径分隔符,:service:user 对应的是 service/user 目录。我见过有人把所有模块都平铺在根目录下,结果项目一多就乱成一锅粥。

目录结构建议这样组织:

my-app/
├── settings.gradle.kts
├── build.gradle.kts          // 根项目构建脚本
├── core/
│   └── build.gradle.kts
├── service/
│   ├── user/
│   │   └── build.gradle.kts
│   └── order/
│       └── build.gradle.kts
└── web/
    ├── api/
    │   └── build.gradle.kts
    └── admin/
        └── build.gradle.kts

为什么要分层?说白了,就是让模块之间的依赖关系一目了然。你想想看,如果所有模块都平铺,新人接手项目时根本搞不清哪个模块是核心,哪个是业务层。

9.2 跨项目配置:别再复制粘贴了

多模块项目最烦人的是什么?是每个子模块都要写一遍 kotlin("jvm")application 插件,还要重复配置依赖版本。我曾经在一个 20 多个模块的项目里,看到每个 build.gradle.kts 都有一模一样的 30 行配置……

解决方案是用 allprojectssubprojects 做统一配置。但这里有个坑:allprojects 会把根项目也算进去,而根项目通常不需要应用业务插件。

我推荐的做法是:

// 根项目的 build.gradle.kts
subprojects {
    apply(plugin = "kotlin")
    apply(plugin = "java-library")

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

    repositories {
        mavenCentral()
    }

    dependencies {
        "implementation"(kotlin("stdlib"))
        "testImplementation"(kotlin("test"))
    }
}

这样所有子模块自动获得 Kotlin 支持和标准库依赖。但注意,subprojects 里的配置是共享的,如果某个模块需要特殊配置,可以在它自己的 build.gradle.kts 里覆盖。

小技巧: 我习惯在根项目里定义一个版本目录(version catalog),然后在子模块里统一引用。这样版本号只需要改一处,所有模块同步更新。

9.3 依赖替换:解决冲突的终极武器

依赖冲突是每个多模块项目都会遇到的噩梦。比如模块 A 依赖了 okhttp:4.9.0,模块 B 依赖了 okhttp:4.10.0,Gradle 默认会选择最高版本。但有时候,高版本不兼容低版本的 API,这时候就需要手动干预。

依赖替换(Dependency Substitution)就是干这个的。它允许你在解析依赖时,把某个依赖换成另一个。我曾经在一个大型微服务项目里,用这个功能把多个模块的日志框架统一成了 Logback,避免了 SLF4J 绑定冲突。

看个例子:

// 在 settings.gradle.kts 中
dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files("gradle/libs.versions.toml"))
        }
    }
}

// 或者在 build.gradle.kts 中
configurations.all {
    resolutionStrategy {
        dependencySubstitution {
            substitute(module("com.google.guava:guava"))
                .using(module("com.google.guava:guava:30.1-jre"))
                .because("统一 Guava 版本,避免冲突")
        }
    }
}

这里要注意,dependencySubstitution 是在配置阶段执行的,所以它会影响所有依赖解析。如果你只想替换某个特定配置的依赖,可以用 configurations["compileClasspath"] 来限定范围。

警告: 依赖替换要慎用。我曾经因为替换了一个传递依赖,导致某个库的内部行为发生变化,排查了整整两天。建议只在明确知道替换后不会破坏兼容性的情况下使用。

9.4 跨项目依赖:模块间的正确引用方式

多模块之间互相依赖是常态。比如 :web:api 依赖 :service:user:service:user 又依赖 :core。正确的做法是用 project() 函数:

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

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

这里有个容易被忽略的点:project() 的参数是模块路径,必须和 settings.gradle.ktsinclude 的路径完全一致。大小写、冒号数量都不能错。

我建议在根项目里定义一个依赖映射,避免硬编码路径:

// 根项目的 build.gradle.kts
val modules = mapOf(
    "core" to project(":core"),
    "userService" to project(":service:user"),
    "orderService" to project(":service:order")
)

// 子模块引用时
dependencies {
    implementation(modules["core"])
}

这样如果模块路径变了,只需要改根项目一处即可。

9.5 知识体系总览

下面这张图总结了多模块项目的核心逻辑:

多模块项目核心知识体系 根项目 (Root) 跨项目配置 (subprojects / allprojects) :core (核心模块) :service:user (用户服务) :web:api (API 层) 依赖替换 (Dependency Substitution) 核心原则:统一配置 + 模块隔离 + 依赖可控

9.6 实战中的避坑指南

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

  • 不要滥用 allprojects:根项目通常不需要应用业务插件,用 subprojects 更安全。
  • 版本号统一管理:我习惯用 gradle.properties 或版本目录文件,而不是在每个模块里硬编码版本号。
  • 依赖替换要加注释:替换的原因一定要写清楚,不然三个月后你自己都看不懂为什么这么配。
  • 模块间依赖不要循环:Gradle 会检测循环依赖并报错,但有时候间接循环很难发现。建议用 gradle dependencies 命令定期检查。

核心总结:多模块项目的本质是「分而治之」。通过合理的模块划分、统一的跨项目配置、以及灵活的依赖替换,你可以让大型项目保持可维护性。记住,配置是手段,不是目的——别为了多模块而多模块。


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