Spring Boot 多模块项目依赖管理:父工程与子工程的区别与最佳实践
先聊一个我在实际项目里经常遇到的场景。你接手一个维护了一两年的系统,代码全塞在一个Maven工程里,里面Service层几千行,各种工具类堆在同一个包下面,pom.xml里几百个依赖,版本有的是2.3.5.RELEASE,有的是3.1.2,甚至同一个依赖出现了两三次不同的版本。你说要升级一下某个底层库,能把人折腾掉半条命,因为根本不知道哪边依赖了它、哪个版本在生效。这种时候,多模块项目就成了一个绕不开的答案。
这篇文章就围绕Spring Boot项目里的多模块依赖管理这个话题展开,重点拆解父工程和子工程到底有什么区别、各自该干什么活,以及在真实项目里怎么把依赖版本管得清清楚楚。我尽量把每一步怎么做、为什么这么做、踩过什么坑都讲透,适合刚接触Maven多模块的新手,也适合已经在用但想把依赖管理做得更规范的同学。读完你至少能自己搭出一个结构干净、依赖不混乱的多模块Spring Boot工程,并且明白每一步操作背后的逻辑。
1. 多模块项目的整体设计与思路拆解
1.1 为什么非要多模块不可
先把话说透:多模块不是银弹,一个只有三五个接口的小项目硬拆成四五个模块,纯属自己给自己找麻烦。但一旦项目进入持续迭代、多人协作、模块复用的阶段,多模块的价值就会非常明显地体现出来。
我从几个实际痛点来说。
第一是代码复用。没有多模块之前,所有公共代码都在同一个工程里,A功能想用B功能里一个工具类,直接import就完了,看起来很方便,但时间一长,模块之间互相引用、互相纠缠,边界感完全消失。多模块之后,公共的东西放到一个common模块,业务A和业务B各自成模块,谁需要什么、谁依赖谁,在pom.xml里一目了然。
第二是构建效率。单体大工程每次改一行代码都要全量编译、全量打包。模块化之后,改到order-service就只构建这个模块,Maven的增量构建特性会把关联模块也编译,但整体范围已经小了很多,尤其是涉及大型项目的场景,这个效率差距很可观。
第三是职责清晰与团队协作。不同团队维护不同模块,互不干扰,代码冲突率明显降低。模块的边界说清楚之后,大家的注意力自然就集中在自己的领域内,代码评审、权限控制也好做。
第四是依赖管理的可治理性。这个点跟本文主题直接相关。没有统一父工程时,每个模块都想当然地写自己的依赖版本,你的fastjson是1.2.83,我的是1.2.68,一合并就出幺蛾子。有了父工程做dependencyManagement,所有版本统一收口,回归到“一处定义,处处生效”。
1.2 一个合理的模块结构长什么样
我见过很多团队在模块划分上走极端,要么一个模块大到失控,要么几十个模块碎成一地。比较稳的常见做法是切分成这几类模块:
parent:纯POM模块,不写业务代码,只做依赖管理、插件管理和公共属性配置。common:工具类、统一返回对象、常量、枚举、异常定义等。dao或repository:数据访问层,放实体、Mapper、Repository接口。service:业务逻辑层,也是类型最多的模块,可以按业务域继续细分,比如order-service、user-service。web或admin:Controller层、启动类所在的模块,是最终打包部署的入口。
举个我最近在用的工程结构作为参考:
my-project/ ├── pom.xml // 父工程,packaging=pom ├── common/ // 公共模块 │ ├── src/main/java │ └── pom.xml ├── dao/ // 数据访问模块 │ ├── src/main/java │ └── pom.xml ├── service/ // 业务逻辑模块 │ ├── src/main/java │ └── pom.xml └── web/ // Web入口模块 ├── src/main/java ├── src/main/resources └── pom.xml注意,web模块是唯一包含启动类的模块,其他模块都是普通jar。后面我会细说为什么启动类只能有一个,以及这个设计对依赖管理的影响。
1.3 拆分边界把握的两个原则
模块怎么拆,其实没有绝对标准,但有两个原则我一直很推荐。
第一个原则是按业务域拆,而不是按技术层次拆。很多人会把一个Spring Boot项目拆成controller模块、service模块、dao模块,这种做法从技术上看挺合理,但从业务迭代的角度看非常痛苦。因为一次下单操作要改Controller、Service、Mapper三层,如果这三个在三个模块里,你一次改动要跨模块同步了很多次。更好的做法是垂直拆分,也就是按业务域拆,比如订单域、用户域、支付域,每个域内含controller、service、dao。这样每个域本身是完整闭环,跨域依赖用接口隔离,整体架构更接近DDD的思想。
第二个原则是独立部署的模块才真正值得拆出来。如果拆出来的模块只是被别的模块引用、自己从不单独部署,那你要慎重,它可能更适合作为包而不是模块。模块之间如果A要依赖B,B又要依赖A,说明边界本身就没划清,这个情况一定要重构。
2. 父工程与子工程的核心差异解析
2.1 父工程到底是个什么角色
很多人一提到多模块就想到继承,觉得子工程pom.xml里的<parent>一写,父工程的配置就自动全部“传”给子工程了。这个理解方向没错,但里面有非常多的细节需要理清楚,尤其是依赖管理方面的机制。
先说结论。在Maven多模块项目中,父工程的核心角色是管理者,而不是传递者。它统一声明依赖版本、插件版本、公共属性,子工程通过<parent>继承这些配置。但关键是,父工程里声明“版本”和声明“依赖”是两回事,稍不留神就会把依赖全部强塞给每个子项目。
这就引出两个容易混淆的标签:<dependencyManagement>和<dependencies>。
在技术圈最容易被误解的地方就在这里。我从用法和效果两个角度拆:
| 对比维度 | dependencyManagement | dependencies |
|---|---|---|
| 用途 | 统一管理依赖版本,定义“版本字典” | 直接为当前工程引入依赖 |
| 是否被子模块继承 | 子模块默认不继承(只继承版本清单) | 子模块会自动继承(依赖全部传递) |
| 声明位置 | 通常写在父工程POM | 写在父工程POM,会全量传给子模块 |
| 对子模块的影响 | 子模块需要自己显式声明依赖(可以省略版本号) | 子模块无需声明即可使用该依赖 |
| 典型场景 | 父工程统一版本、子工程各取所需 | 父工程强制所有模块都用某个公共依赖 |
用生活化的类比来解释,<dependencyManagement>相当于一份“采购清单”,告诉所有人某样东西的标准型号是什么,但真正要买什么、买多少,由各子模块自己决定;<dependencies>则相当于直接“强制采购”,父工程买的每一样东西,所有子模块都得人手一份。
2.2 继承机制:子工程能从父工程获得什么
子工程通过<parent>继承父工程的POM,具体能继承的东西包括:
- properties属性定义:比如
<java.version>、<spring-boot.version>等变量。 - dependencyManagement:子模块能用自己的groupId/artifactId去省略版本号。
- pluginManagement:跟dependencyManagement一个道理,定义插件版本和配置,子模块按需启用。
- 插件配置:如果父工程直接在
<build><plugins>里声明插件,子工程会继承并执行。 - 仓库配置:
<repositories>、<pluginRepositories>等。
但有几点继承规则要特别注意,我踩过的坑不少,这里一起列出来:
第一,子工程可以覆盖父工程的配置。子工程POM里的配置优先级更高,父工程定义了一个依赖版本,子工程如果自己又写了一个版本号,以子工程为准。这个特性很灵活,但也带来了安全隐患——版本统一失效了。所以在团队规范里一般约定:版本一律不准在子模块里直接指定,父工程统一管理,避免有人偷懒绕过版本中心。
第二,继承不是没有成本的传递。如果父工程直接在<dependencies>里写了依赖,那所有子模块就会无条件继承这份依赖,哪怕它根本用不到。这个机制有时候很方便,比如把lombok、common模块放进父工程的<dependencies>里,省得每个子模块都用重复代码去声明。但滥用的话也会造成依赖爆炸,每个模块都被迫携带一堆跟业务无关的jar包。
第三,父工程构建顺序有讲究。Maven按照模块的依赖关系自动决定构建顺序,父工程先构建,然后按拓扑排序构建子模块。如果模块之间存在循环依赖,构建直接失败。这个在项目初期就要从工程结构上规避。
2.3 两种常见的多模块管理方式
在Spring Boot项目里,父工程的处理方式主要有两种,各有优劣。
方式一:继承Spring Boot官方父工程
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent>这种情况下,Spring Boot官方父工程已经内置了海量第三方依赖的版本管理,而且帮你在maven-compiler-plugin、maven-surefire-plugin、spring-boot-maven-plugin等插件上做了默认配置。你不需要自己维护依赖版本清单,官方已经替你做了一轮。前提是,你的多模块项目必须服从这个版本体系,不能在现代Spring Boot之外的场景自由伸缩。
方式二:自定义父POM,用import方式引入BOM
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>3.2.5</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>这种方式不继承Spring Boot官方父工程,而是把它当成一个BOM清单导入到自己的dependencyManagement里。好处是,你可以完全掌控自己项目父工程的结构,把Spring Boot版本管理和其他框架的BOM(比如Spring Cloud Alibaba BOM、MyBatis Plus BOM)并列放在一起取交集、做定制。坏处是,官方父工程里预置的插件配置没有了,需要自己配置spring-boot-maven-plugin、Java编译参数等。
我在代团队时,生产级项目基本都有自定义父POM+引入多个BOM。这样既满足Spring Boot版本统一管理,又不受官方父工程约束,还能把内部自研公共库的版本一并纳入管理。这个方案后面实操部分会重点演示。
3. 实操:从零搭建Spring Boot多模块工程
3.1 环境准备与版本选择
动手之前先把环境准备好。我用的是:
- JDK 17(Spring Boot 3.x要求至少17)
- Maven 3.9.x
- IDEA 2023.2+
Spring Boot版本选当前稳定版,比如3.2.5或3.3.x。这里多说一句,Spring Boot 3.x和2.x在依赖管理上的核心逻辑是一样的,本文的配置在2.x上也能跑通,只是版本号、部分依赖的groupId可能有变化(比如javax改成jakarta)。
新建父工程时,Maven archetype选普通的maven-archetype-quickstart即可,后面再把pom.xml内容替换成我们的目标配置。父工程不需要任何Java代码,它的src目录可以整个删掉,只保留一个pom.xml。
3.2 父工程pom.xml的完整配置
这是整个多模块依赖管理的核心枢纽。我给出一个经过实战打磨的模板,重点注释放在关键位置:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <!-- 自定义父POM,groupId/artifactId/version 按自己公司规范填写 --> <groupId>com.example</groupId> <artifactId>my-project-parent</artifactId> <version>1.0.0-SNAPSHOT</version> <!-- 父工程只做依赖管理,必须是 pom 类型 --> <packaging>pom</packaging> <name>my-project-parent</name> <description>项目统一依赖管理与构建配置</description> <!-- 子模块列表:按构建顺序排列 --> <modules> <module>common</module> <module>dao</module> <module>service</module> <module>web</module> </modules> <!-- 统一属性:版本集中定义,改版本只改这里 --> <properties> <java.version>17</java.version> <spring.boot.version>3.2.5</spring.boot.version> <mybatis-plus.version>3.5.7</mybatis-plus.version> <hutool.version>5.8.27</hutool.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <!-- 依赖版本字典:父工程只管理版本号,不强制引入 --> <dependencyManagement> <dependencies> <!-- 引入 Spring Boot BOM:官方的版本规则都归它管 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring.boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> <!-- 内部公共模块:定义版本,子模块通过坐标直接引用 --> <dependency> <groupId>com.example</groupId> <artifactId>common</artifactId> <version>${project.version}</version> </dependency> <!-- 第三方常用组件统一收口 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>${hutool.version}</version> </dependency> </dependencies> </dependencyManagement> <!-- 所有子模块统一的构建配置 --> <build> <pluginManagement> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring.boot.version}</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.13.0</version> <configuration> <source>${java.version}</source> <target>${java.version}</target> <encoding>${project.build.sourceEncoding}</encoding> </configuration> </plugin> </plugins> </pluginManagement> </build> </project>注意这里最核心的机制是:spring-boot-dependencies的import方式引入,它是一份巨大的版本清单,包含了Spring Boot官方测试过的所有依赖版本。之后我们在子模块里引入Spring生态组件时,都只需要写<groupId>和<artifactId>,连<version>都不用写,因为父工程已经通过BOM知道了该用什么版本。
3.3 子工程pom.xml配置:减少重复并保持清晰
接下来看子模块怎么写。先说简单但关键的common模块,它一般被多个模块依赖,本身不依赖复杂业务,通常只引入工具类、参数校验、通用返回值等依赖。
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <!-- 继承父工程 --> <parent> <groupId>com.example</groupId> <artifactId>my-project-parent</artifactId> <version>1.0.0-SNAPSHOT</version> <relativePath>../pom.xml</relativePath> </parent> <artifactId>common</artifactId> <packaging>jar</packaging> <dependencies> <!-- 依赖版本由父工程的 dependencyManagement 统一控制 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency> </dependencies> </project>这里我故意不写<version>,让Maven从父工程的dependencyManagement中解析版本。如果你在IDEA里看到版本号没有变红报错,说明父工程BOM里的版本解析成功。这种写法的好处是,父工程升级某个依赖版本时,所有子模块无需改动代码,重新构建即同步生效。
再来看业务模块service,它依赖dao模块。如果service里用到了dao中的类,就需要显式声明依赖:
<dependencies> <!-- 内部模块依赖 --> <dependency> <groupId>com.example</groupId> <artifactId>dao</artifactId> </dependency> <!-- Spring Boot Web Starter(如果业务里需要做远程调用/接口封装) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>因为我们在父工程的dependencyManagement里已经为com.example:dao定义了版本为${project.version},所以这里同样不需要写版本号。${project.version}这个变量在父工程中就等于1.0.0-SNAPSHOT,并且在子模块解析时会被替换成父工程当前版本,这样模块之间同步升版本不会漏改。
3.4 web模块的打包细节与启动类唯一性问题
这个模块是整个项目的入口,我们需要特别关注两个点。
第一,启动类只能放在web模块。因为Spring Boot在打包可执行jar时,是通过spring-boot-maven-plugin的repackage目标重新封装jar,这个操作会定位带有main方法的类作为启动类。如果多个模块添加了这个插件或者定义了启动类,Spring Boot的自动配置扫描范围就会出问题,最常见的就是启动时报找不到Controller或者Mapper。
第二,web模块需要配置可执行打包。父工程里我们在pluginManagement中配置了spring-boot-maven-plugin,但pluginManagement只是定义插件的“可用版本与配置”,并不会自动执行。所以web模块中需要显式声明使用这个插件:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>这里版本号依然省略,因为在父工程的pluginManagement里已经管理了插件版本。而且其他非入口模块不要加这个插件,否则每个jar都会有repackage干扰,启动的时候反而找不到主类。
3.5 在IDEA中运行与验证
项目导入IDEA的时候,以父工程my-project-parent作为Maven项目导一次,IDEA会自动识别<modules>下的子模块。这一步要注意,如果之前是逐个导入子模块的,很可能造成模块重复加载或依赖解析异常,我遇到过好多次。最稳的做法是,删除本地仓库里关于这个项目的旧元数据,然后重新从父pom导入。
导入之后,在web模块的启动类上直接运行main方法。启动成功后,你可以在IDEA的Maven面板看到整个reactor构建列表,从父工程到四个子模块,用mvn clean install命令验证构建:
mvn clean install这条命令会按模块依赖顺序自动构建。如果遇到某个模块编译不过,错误信息会明确显示是哪个子模块失败,这就是模块化之后排查问题的第一个好处。
4. 项目中的版本管理策略与常见问题排查
4.1 多模块项目依赖管理最佳实践清单
实操部分结束,这部分从方法论层面总结一下我在多个项目中沉淀下来的最佳实践。这些点很多人只有在代码仓库变成一锅粥之后才会回头体会:
版本一律收口在父工程,子模块严禁出现<version>标签。如果非要写,也要有充分理由并且通过代码评审。最直接的做法是设置Checkstyle或专门的Maven校验插件(如maven-enforcer-plugin)来禁止子POM直接声明版本,我团队里就启用过<requireReleaseDeps>与自定义规则做行政约束。
内部模块依赖用版本变量,不用固定数字。比如内部common模块,推荐在父工程里用<version>${project.version}</version>来定义,而不是写死1.0.0-SNAPSHOT,这样全项目晋级版本时不需要逐个子模块去找内部依赖的版本号。
区分公共依赖与业务依赖。可以在父工程的<dependencies>里声明一小撮“全项目强制依赖”,比如lombok、common模块,让所有子模块自动带上。但公共依赖的数量要克制,原则是“少而关键”,否则每个模块都扛着无关jar包,最终打包体量膨胀不说,还可能因为相互传递依赖导致意外的版本覆盖。
外部BOM按需引入。很多人一上来就把Spring Cloud全家桶BOM拉进来,结果只用到其中两三个组件,白白引入一堆版本约束,将来还可能跟业务用的组件版本冲突。正确的做法是用到哪个系列的组件,再把对应BOM引进来。BOM的引入顺序也有讲究:Spring Boot自己的BOM放在最前面,其他框架的BOM放后面,后声明的版本覆盖先声明的,这才是利用Maven版本覆盖机制的正确姿势。
保持module列表的构建顺序可预测。父工程的<modules>中列出的顺序尽量遵循依赖关系,虽然Maven会根据实际依赖自动调整,但保持一个清晰的列表,对维护者来说可读性极好。
4.2 高频踩坑:spring-boot-maven-plugin重复打包
这个坑出现频率非常高。症状表现是:构建成功后,普通子模块的jar包被repackage成了Spring Boot可执行jar,体积明显变大,但里面找不到Main-Class;或者启动时报错“Unable to find a single main class”。
原因很清楚:父工程如果直接在一级<build><plugins>里配置了spring-boot-maven-plugin,那么所有子模块都会继承并执行repackage目标。普通模块(common、service)没有main方法,这个插件要么报错,要么把jar处理成一个非预期的可执行包。
解决方案就是我上面实操里采用的:父工程中只放到pluginManagement,真正需要执行的web模块再显式声明。这个原则跟dependencyManagement的设计哲学一脉相承——父工程定义规范,子模块按需启用。
还有一种情况是,web模块引用了本地模块(比如common),spring-boot-maven-plugin默认会把依赖模块的jar也复制进BOOT-INF/lib,如果你发现打出来的包里重复出现内部模块jar,检查一下打包配置,一般不需要特殊处理,这是正常现象。
4.3 依赖冲突的两种表现与排查思路
依赖冲突是多模块项目里最容易出问题的环节。因为模块引用了模块,第三方依赖被层层传递,最后某个类拿到的是一个旧的、不兼容的版本。
冲突的表现方式通常有两种:一是编译错误,提示某个类不存在或方法签名不对;二是运行期异常,比如NoSuchMethodError、NoClassDefFoundError、ClassNotFoundException,这种尤其隐蔽,编译没问题,一启动就炸。
排查方法我建议按这个顺序来:
先查看依赖树,在子模块目录下执行:
mvn dependency:tree或者用IDEA的Maven窗口,打开Dependencies面板,用Ctrl+F搜索冲突的jar包名。依赖树会列出所有传递依赖及其版本,你一眼就能看到是否是同一groupId/artifactId出现了多个版本。
然后是解决冲突:在多模块项目里,优先在父工程的dependencyManagement中直接声明该依赖的目标版本,这样全局统一。如果只想在某个模块内覆盖,就在该子模块的<dependencies>里显式声明一次指定版本,但需要代码评审把关。
最后检查依赖来源。父工程引入的BOM如果先后顺序不对,旧版本可能就会覆盖新版本,这导致一种非常无语的情况:子模块里明明写了一个更新版本的依赖,但实际生效的还是BOM里那个旧版本。遇到这种情况,看看父工程BOM里有没有对应坐标,有的话就调整父工程BOM顺序或版本。这里补充一个规则,子模块里显式声明的依赖版本 > 父工程dependencyManagement里的版本 > BOM import的版本,但前提是子模块真的写了版本号。
4.4 常见问题速查表
把这几年积累的多模块项目常见问题整理成一张速查表,方便大家快速定位:
| 问题现象 | 可能原因 | 推荐处理 |
|---|---|---|
| 编译报错“Cannot resolve symbol” | 依赖未声明或版本解析失败 | 检查子模块pom中是否有依赖坐标,以及父工程dependencyManagement是否正确解析版本 |
| 启动报“No qualifying bean of type” | 类扫描不到,或模块依赖未引入 | 确认相关模块是否在pom中被依赖,启动类是否在web模块,@ComponentScan是否正确配置 |
| 启动报“Unable to find a single main class” | 多个模块配置了spring-boot-maven-plugin | 移除普通子模块的插件配置,只保留web模块 |
| 某个类运行时报NoSuchMethodError | 依赖版本冲突,或本地仓库缓存了旧包 | 用dependency:tree查冲突,在父工程统一指定版本,必要时mvn clean install -U刷新 |
| 子模块构建顺序异常 | 模块间循环依赖或顶层污染 | 检查pom依赖方向,重新梳理模块边界 |
| 版本修改不生效 | 本地仓库缓存旧POM | 对父工程执行mvn clean install -N,强制刷新 |
| 依赖版本被强制覆盖 | BOM导入顺序导致 | 调整父工程dependencyManagement的BOM顺序,后导入的BOM覆盖先导入的版本 |
4.5 多模块项目里的一个隐藏细节:插件版本管理
很多文章把依赖管理讲得很细,但插件管理容易一带而过。实际上,插件的版本混乱也够喝一壶的。最常见的场景是不同模块用了不同版本的maven-compiler-plugin,导致编译行为不一致,或者某个模块编译时JDK版本对不上。
插件的管理思路和依赖一样,在父工程<pluginManagement>里统一声明版本与公共配置。子模块需要某个插件时只声明<groupId>和<artifactId>,版本从父工程里来。这样你升级编译插件版本时,只需要动父工程一处。
再补充一个容易被忽略的配置:在父工程里设置<maven.compiler.source>和<maven.compiler.target>对应的属性,或者直接在pluginManagement的maven-compiler-plugin中配置。Spring Boot官方父工程里会默认设置编译参数,如果你走自定义父POM路线,这块千万别忘了,否则运行时各种class版本错误会让你怀疑人生。
4.6 另一个容易忽略的隐患:传递依赖泄漏
多模块项目中,common模块往往被多个业务模块依赖,如果common里顺手引入了spring-boot-starter-web,那么所有依赖common的模块都会被这个starter污染。轻则每个模块多出几百个class,重则某些模块本不该支持Web功能,却因为传输依赖被迫引入了内嵌Tomcat环境,造成启动端口冲突等诡异问题。
所以公共模块的依赖要“轻”,尽量只放纯工具类、纯POJO、标准库依赖。如果需要把Spring上下文相关的东西(比如通用的JacksonConfig、WebMvcConfigurer)抽到公共模块里,建议单独拆出一个common-web模块,把需要Web场景的依赖隔离在那里,让需要Web能力的业务模块显式依赖它,而不是通过透明传递被动拉进来。
这个原则其实一句话就能说完:传递依赖要克制,显式依赖要清晰。模块之间宁可多写几行pom坐标,也不要依赖隐式传递带来的“便利”,因为代码能跑的时候你不会觉得有什么,但出问题时,隐式依赖的排查成本是最高的。
5. 从依赖管理的层面聊聊这套架构的收益
写到这里,可能有人会觉得,多模块不就是拆几个子目录、pom里多写几行配置嘛,至于说这么多吗?但从我经历过的项目来看,依赖管理的收益恰恰不是“能跑起来”那一刻体现的,而是体现在后续漫长的迭代里。
举一个真实的例子。一个业务系统,Spring Boot 2.7打算升到3.2。单体工程面临的麻烦是:所有第三方依赖兼容性都要重新验证,全部混在一起,升级动作根本没有边界;但如果是多模块,common模块涉及的依赖集合非常小,几乎不影响;业务模块只需对照Spring Boot的迁移文档调整javax到jakarta的导入变更;真正要动大改的只有web入口模块和相关配置。也就是说,多模块天然为技术升级划了一条清晰的边界,升级风险可以被精确隔离在少数模块内部。这就是依赖管理设计得好带来的直接收益,这个账,很多团队是等到大版本升级时才真正算明白的。
再比如团队里来了新人,让他负责一个订单模块,只需要打开service/order这个模块,看它的pom和代码就能搞清楚这个功能域用了哪些依赖、依赖了哪些内部模块,完全不需要走进一大堆无关代码里去“考古”。这种可读性,在多模块做好依赖管理之后几乎免费获得。
我自己的体会是,多模块项目的山不是搭出来的,而是改出来的。第一版搭得规整不难,难的是每次加依赖、调版本、加模块时都守住父工程统一管理的底线。如果哪次图方便在子模块里写了版本号,或者为了让某个功能快速跑通往common里塞了个重量级依赖,架构就会朝着失控的方向滑一步。所以这些原则和执行约束,最好在项目启动第一天就写进团队约定里,并且在代码评审时严格把关。
最后再分享一个小习惯。我一直建议团队里维护一份“依赖升级记录”,放在父工程POM的<description>里或者另一个专门的文档,每次升级BOM、调整版本号都记一句:为什么升、影响面是什么、由谁负责验证。这个文档在平时看起来没什么用,但在处理一年一度的安全漏洞升级或者关键依赖兼容性问题时,价值极高。多模块的版本管理不是把版本号集中在一处就结束了,真正的核心是把变更的历史和原因也一并管理起来,这才是“集中管理”四个字的完整含义。