话不多说,直接进入正题。这次想聊的是 Spring Boot 多模块项目里,父工程和子工程在依赖管理上的分工问题。我见过太多团队把多模块搭起来之后,依赖管理全靠“互相抄”和“试错”,父 pom 里堆了一堆 dependencies 和 distributionManagement,子工程里也堆了一堆重复的版本号,看起来跑得通,实际每次升级一次 Spring Boot 或者某个中间件版本,全项目都要伤筋动骨。
这篇文章我会结合自己做多模块项目时的实际经验,把父工程和子工程到底该怎么划分职责、dependencyManagement 和 dependencies 为什么不能混用、以及版本仲裁和独占依赖这些最容易翻车的地方一次讲清楚。
1. 为什么要搞父工程和子工程:从单模块到多模块的核心动机
先把最基础的问题回答掉:为什么 Java 项目到了中大型规模之后,一定要拆成多模块,而不是继续坚持“一个 Spring Boot 应用包到底”的单模块结构。
单模块项目的管理方式其实是把编译单元、部署单元、代码组织单元这三件事绑死在了一起。比如你做一个商城系统,里面有用户、订单、商品、支付四块业务,如果全部塞进同一个 Maven 项目里,最直接的问题是:订单模块的同事改了一行代码,整个商城都要重新编译、重新测试、重新打包。而支付这个模块可能根本不依赖订单,但因为你上班打卡和发版用的是同一个仓库、同一个 pom,每次全量构建的时间和风险都被白等了一遍。
拆模块就是为了把"编译边界"和"代码边界"重新对齐。订单子工程只依赖它真正需要的公共模块,支付子工程不需要受订单模块的类变动影响。这样一来,你在本地开发时只需要 mvn install 公共的部分,其余引用它的模块会自动用到你本地最新快照,构建速度显著提升。
拆模块之后出现父工程,是因为这些子模块之间还存在需要共同遵守的东西,具体说就是三类:
- 统一的第三方依赖版本。Spring Boot 版本、MyBatis 版本、Redis 客户端版本,这些不应该由各子工程自己说了算。
- 统一的插件配置。比如 spring-boot-maven-plugin、maven-compiler-plugin 的 Java 版本、maven-surefire-plugin 的测试策略。
- 统一的仓库和发布坐标。子模块要用哪个 groupId 和 version,这个规矩也要在父工程里定好。
父工程的存在不是为了把代码放进去,而是为了让子模块在“根”这个层面达成一致,避免条块分割变成各写各的草台班子。
2. 父工程与子工程的分工边界:dependencyManagement 和 dependencies 的根本区别
这一节是核心中的核心。多模块依赖管理最常见的误用,就是把 dependencyManagement 当成 dependencies 的平替甚至加强版,导致依赖到底会不会传递到子模块这个关键问题成为黑箱。
先下一个准确的定义:
dependencies 是真实的依赖声明。它写在父 pom 里时,意味着所有继承这个父工程的子模块都会无条件获得这些依赖的坐标。任何子模块的 classpath 里都会有这些 jar 包。
dependencyManagement 是版本管理器,它不是依赖声明。它内部配的 dependency 只负责锁定版本,不会把依赖传递给任何子模块。子模块必须显式声明自己要用的依赖坐标,只是这时候可以省略 version——因为版本号由父工程的 dependencyManagement 统一提供。
我用一个生活例子解释一下。假设父工程是“学校食堂的菜单管理制度”,里面规定了哪个菜放多少盐,这个制度对应 dependencyManagement。但是学生每次去打饭时,必须自己说“我要一份红烧肉”,对应子模块里写 dependencies 中间的坐标。不能说食堂的制度里写了红烧肉的盐度,所有学生就自动吃到了红烧肉。
这个区别之所以重要,是因为如果你理解反了,很容易出现以下两种事故:
- 父工程用 dependencyManagement 管理了 spring-boot-starter-web,但子工程竟然没有自己声明这个依赖,然后满项目找 ClassNotFoundException 找半天。
- 父工程直接用了 dependencies 声明了公司内部的 user-common,但某个子模块根本不需要用户体系,却被迫在 classpath 里带上这个包,既增加了包体积,还有可能因为包里的 AOP 切面被自动扫描而出现莫名其妙的行为问题。
正确姿势是父工程只用 dependencyManagement 把所以要统一的内容列出来,然后真正需要 Apache Commons 或 Spring Web 的子模块各自在自己的 dependencies 里引入坐标。这样,子模块间的依赖“暴露度”最小,维护成本也最低。
3. 多模块项目依赖管理的完整实操链路:从空账号到拿到一个能跑的骨架
搞清楚了原则,接下来要落地。下面我给出一个完整的实操流程,这套流程我从零项目的多模块架构到底包到运行的完整链路,读者可以直接参考。
3.1 父 pom 的基本骨架
先在任意目录下创建父 pom.xml。它的类型必须设为 pom,也就是它是一个纯粹的聚合与编排项目,不产出任何 jar 或 war。
<?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> <groupId>com.example</groupId> <artifactId>shop-root</artifactId> <version>1.0.0-SNAPSHOT</version> <packaging>pom</packaging> <name>shop-root</name> <description>商城多模块父工程</description> </project>这里有几个小细节要说明。
- 必须写 packaging 为 pom,不然不管父工程里放没放代码,它都会被当成一个普通的 jar 模块来解析,后续子模块发布和父子继承都会问题重重。
- version 我这里写的是 1.0.0-SNAPSHOT。在正式管理依赖版本时建议使用统一的 release 版本号策略,开发期间 SNAPSHOT 才能支持 mvn install 本地构建。
3.2 引入 Spring Boot 父级依赖
多模块项目的最优继承方案不是直接继承 spring-boot-starter-parent,而是让自家的父 pom 再继承一层。这样做的原因是,Spring Boot 的父 pom 并不是神,它的很多依赖版本管理规则是死的,需要自己项目里对某些依赖做定制覆盖。
食品的 Top-level 继承方案如下:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> <!-- lookup parent from repository --> </parent>这样做,你的 shop-root 就能享有 Spring Boot 的统一版本管理,比如它会把 spring-boot-starter-web、spring-boot-starter-test 这些常见的版本全部锁好。你无需再指定 Spring Boot 里各组件的版本号。但是,如果想对某个 Spring Boot 管理的依赖做版本覆盖,比如将 fastjson 换成更高版本,你在自家父工程里只需声明 dependencyManagement 时指定版本。
需要特别提醒的是,relativePath这个标签。默认情况下 Maven 会从当前 pom 的路径往上找 parent 的 pom 文件。这里留空并加上注释,是为了告诉 Maven:不要在本机文件系统找那个 Spring Boot 的 pom 文件,直接从 Maven 仓库拉取。多人协作时如果不写这个标签,有时候 Maven 会从某个本地的临时目录错误地找一个不存在的父 pom,然后报各种 Invalid parent 的错误。
3.3 在父 pom 中统一管理私有模块依赖
如果项目是真正的多团队协作,一般还会随着时间沉淀出自己的公共模块,比如统一的响应体封装、统一的异常处理、统一的序列化配置。对这类私有依赖,推荐用<dependencyManagement>在父 pom 中锁定版本,而子工程引不引由自己决定。
<dependencyManagement> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>shop-common</artifactId> <version>${project.version}</version> </dependency> <dependency> <groupId>com.example</groupId> <artifactId>shop-order-api</artifactId> <version>${project.version}</version> </dependency> </dependencies> </dependencyManagement>这里project.version会取当前父工程版本 1.0.0-SNAPSHOT。如果所有子模块都沿用这个版本,这个写法可以保证联动升级时不会逐个改一堆版本号。不过要注意,${project.version}在父工程和子工程中的解析值可能不同,所以在 dependencyManagement 里引用${project.version}时,最终以父工程 pom 解析结果为准,子模块要理解这一点。
3.4 创建两个不同类型的子模块:普通 jar 模块 vs 可启动的 Spring Boot 应用模块
多模块里的子工程大体可以分成两类:被封装的业务组件和最终启动的入口应用。
假设我们拆出来 shop-common、shop-order、shop-user 这几个模块。其中 shop-common 是 jar 模块,供其他模块复用。shop-order 和 shop-user 是最终要对外提供 HTTP 接口的服务模块,它们内部真正需要 Spring Boot 的 Web 容器。
对普通 jar 模块,spring-boot-maven-plugin 不需要重复配置,也不必单独把它当 Spring Boot 应用打包。对可启动的服务模块,必须在它的 pom 中显式带上 spring-boot-starter-web 依赖并配置打包插件。
以 shop-order 为例:
<parent> <groupId>com.example</groupId> <artifactId>shop-root</artifactId> <version>1.0.0-SNAPSHOT</version> </parent> <artifactId>shop-order</artifactId> <packaging>jar</packaging> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>shop-common</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>注意这里 shop-common 的依赖没有写 version,这就是父工程里dependencyManagement起作用的直观体现。
那么问题来了,如果 shop-order 没有配置spring-boot-maven-plugin的 repackage,只加了 spring-boot-starter-web,它打包出来的是一个普通 jar,而不是一个 Fat Jar。启动时你会在java -jar时报错,提示找不到主清单属性。这是很多新人的第一个坑,这里先立个 Flag。
4. 依赖仲裁和版本冲突:为什么父 pom 锁了版本还是会被覆盖
很多团队以为在父工程中锁好版本就天下太平了,但实际上 Maven 的版本仲裁策略并不是“父工程说了算”。了解清楚仲裁顺序,才能在排查冲突时快速定位问题,不至于瞎猜。
4.1 仲裁规则一:子工程先得、就近优先
Maven 仲裁依赖版本有几个原则,最关键的一条是基于依赖树的“就近原则”。
假设父工程的 dependencyManagement 声明了 Jackson 2.13.5,而 shop-order 自己直接依赖了 Jackson 2.12.6,那么最终生效的是 2.12.6。原理是,在依赖树中,子模块自己声明的依赖离当前模块最近,具有最高优先权。
这一点经常被误解:很多人以为父 pom 指定的版本是最终不可动摇的,实际情况却相反。子模块想覆盖某个依赖版本,只要在自己的 dependencies 里声明相同坐标并指定新版本即可。知道这一点,排查“我明明在父工程里锁过版本,为什么依赖树里还是旧版”这类问题时,第一反应应该是去搜子模块里有没有重复声明。
4.2 仲裁规则二:路径最短优先,先声明先赢
如果没有直接声明,而是通过传递依赖引入了相同坐标但不同版本,那么依赖树中路径更短的版本获胜。如果路径长短也一样,只保留第一个在 pom 中声明的版本,后面的被丢弃。
比如 shop-order 同时依赖了 A 和 B,A 传递依赖了 C:1.0,B 传递依赖了 C:2.0,且路径深度都是 3。此时 pom 里先声明的 A 决定了 C 的实际版本为 1.0。这类隐性问题即使是资深开发也经常被坑到,因为两个传递依赖都能编译,但最终跑起来的行为可能会因为 C 版本变化而不同。
4.3 实战排查:一条 mvn 命令看懂依赖树
排查依赖冲突最直接的方式,是用mvn dependency:tree命令查看当前模块的真实解析结果。
mvn -pl shop-order dependency:tree这个命令只会解析 shop-order 项目。输出中可以看到传递依赖树,直接看冲突版本附近的节点,观察哪些路径被忽略。
如果你怀疑父工程的 dependencyManagement 没有生效,可以使用:
mvn help:effective-pom -pl shop-order这条命令会打印出 shop-order “修正”之后完整的 pom 内容,把父工程里的锁版本逻辑和子模块自身覆盖逻辑全部展开。它会清清楚楚地显示最终每个依赖的版本是多少。凡是依赖冲突抓不到头绪时,先跑一遍 effective-pom,比肉眼对着两层 pom 文件猜效率高得多。
4.4 谁在偷偷引入同一个 jar:全局排除并不是好主意
依赖冲突排查到最后,往往要处理“两方都引了同一个库但版本不同”的问题。很多人第一反应是在 dependency 里加 exclusions 把传递依赖排除掉。
我也用过 exclusions,但它并不适合做全局策略。因为 exclusions 是以“当前依赖声明”为单位的,一旦新增了一条引用了那个传递依赖的路径,就必须再去新地方排除一次,维护成本极高,还容易漏。
更合适的处理方式是:先确认冲突库的业界最佳兼容版本,然后在父工程 dependencyManagement 中声明该版本。这样一来所有子模块解析该依赖时都会落到同一个版本,就不需要到处排除。比如你的项目里同时出现了 commons-io 2.6 和 2.11,与其在每条依赖链上排除一遍,不如在父 pom 中锁一个 2.11.0,一劳永逸。
5. 多模块构建过程中的依赖隔离:哪些依赖必须“局部独占”,哪些依赖千万别塞进父 pom
父 pom 用 dependencies 直接声明依赖的做法,只在极少数场景下适用。比如整个团队的统一基础校验工具、统一日志框架的老版本桥接包,这类覆盖面极大的依赖可以用这种方式传递下去。但你要顶着非常大的风险,因为这意味着任何子模块都被迫携带了这些依赖。
下面说的“局部独占”更常见也更关键,需要逐条记住:
- Spring Boot 服务模块专属的 web 相关依赖。比如 spring-boot-starter-tomcat、spring-boot-starter-web,如果放父 pom 的 dependencies 里,那些只做内部公共类的 jar 模块也会具备 Servlet 容器能力。轻则 fat jar 体积变大,重则服务启动时出现重复的 Servlet 容器初始化逻辑,直接报错。
- 数据库连接池和 ORM 相关依赖。不同服务可能用 MySQL、PostgreSQL 甚至只操作 Redis,把 HikariCP、MyBatis 这种强绑定 DB 的依赖统一塞给全部子模块,等于逼着所有模块写死一套数据层选型。
- 测试相关的独占版本。比如 Mockito 的 inline mock maker 和 spring-boot-starter-test 在某些版本组合下有兼容问题,你只需要在某个模块单独适配时做定制,而不是全局锁死。
- 跨服务 API 接口定义模块之间的循环依赖。如果 shop-order-api 编译时依赖了 shop-user-api,而 shop-user-api 又依赖了 shop-order-api,父工程也无法救你,唯一能做的是抽象出第三方的 common 模块来解环。
还有一类依赖是我强烈建议放开由子模块自己管理的:公司内部实验性组件、各组件的 alpha 版本,或者准备替换掉的过渡依赖。它们不适合写进父工程的依赖管理里,否则全局强制升级时容易造成连锁反应。真正的经验法则是,父工程锁版本时只锁“所有人、所有模块、所有场景都必须保持一致”的内容,包括 Spring Boot 基线、公司通用公共库、基础加密组件和序列化库。其余留给孩子自我自治。
6. 一个完整的可运行示例:采用正确依赖管理策略的商城多模块骨架
光说理论不够,这里我贴一份我实际用过很多次的多模块骨架目录结构和核心 pom 写法,覆盖聚合、继承、依赖管理、启动模块打包,可以直接作为参考来搭自己的项目。
6.1 目录结构
shop-root/ pom.xml shop-common/ pom.xml src/main/java/com/example/common/ shop-order-api/ pom.xml src/main/java/com/example/order/api/ shop-order/ pom.xml src/main/java/com/example/order/ shop-user/ pom.xml src/main/java/com/example/user/在 shop-root 的 pom.xml 里,需要显式声明<modules>列表来聚合这些子模块:
<modules> <module>shop-common</module> <module>shop-order-api</module> <module>shop-order</module> <module>shop-user</module> </modules>模块顺序在构建时会根据相互依赖关系自动计算,但写清楚顺序有助于可读性。shop-order-api是一个暴露订单服务接口和 DTO 的模块,它被打包成普通 jar,供外部或者内部其他模块用 Feign 或 WebClient 调用。
6.2 顶层父 pom 的完整配置要点
聚合和继承在同一份 pom 里生效,具体结构如下:
<groupId>com.example</groupId> <artifactId>shop-root</artifactId> <version>1.0.0-SNAPSHOT</version> <packaging>pom</packaging> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>8</java.version> <shop.version>1.0.0-SNAPSHOT</shop.version> <mybatis-plus.version>3.5.3.1</mybatis-plus.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>shop-common</artifactId> <version>${shop.version}</version> </dependency> <dependency> <groupId>com.example</groupId> <artifactId>shop-order-api</artifactId> <version>${shop.version}</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> </dependencies> </dependencyManagement> <build> <pluginManagement> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </pluginManagement> </build>注意我这里把spring-boot-maven-plugin放进了 pluginManagement。这意味着它只统一管理插件的坐标和版本,而不是让每个子模块都默认继承它。子模块需要时,在自身 build 里声明,就会自动获得版本。
6.3 子模块之间的依赖用法示例
shop-order 模块需要依赖 shop-common 和 shop-order-api,并且自己作为 Spring Boot 应用启动。它的 pom 可以这样写:
<parent> <groupId>com.example</groupId> <artifactId>shop-root</artifactId> <version>1.0.0-SNAPSHOT</version> </parent> <artifactId>shop-order</artifactId> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>shop-common</artifactId> </dependency> <dependency> <groupId>com.example</groupId> <artifactId>shop-order-api</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> </plugins> </build>这样,shop-order 自身的依赖管理完全透明。它能在父工程配置中省掉版本号,但同时只加载自己需要的依赖,不会把 DB 池等与自身无关的东西带入 classpath。
如果你需要单独构建某个子模块及其依赖的子模块,可以这样执行:
mvn -pl shop-order -am clean package-am参数表示同时构建依赖的其他模块,比如 shop-common 和 shop-order-api。这条命令比全量mvn clean package快很多,尤其是公共模块已经安装到本地 Maven 仓库后,可以并行只编译需要的东西。
6.4 千万注意踩坑:子模块打包找不到符号问题
多模块构建常见的另一个坑是子模块之间的编译顺序问题。如果 shop-order 依赖了 shop-order-api,而你只构建 shop-order,本地仓库里还没有 shop-order-api 的 jar 包,那你就会碰到 "package com.example.order.api does not exist" 类的错误。解决办法是先在父工程目录下执行:
mvn -pl shop-order-api -am install把公共模块安装到本地仓库。之后再去单独构建启动模块就能顺利解析。团队协作时,也可以配置 CI 平台在流水线里先构建并安装所有内部依赖,再构建应用可运行包。
6.5 构建产出的可运行 jar 中包含了哪些子模块?
最终shop-order/target下生成的 fat jar 里,会自动打入所有被引用的子模块 class 文件和相关资源。这是 Spring Boot repackage 的成果。但如果你的子模块需要被其他 Java 项目(不是 Spring Boot 启动模块)使用,那个项目直接依赖 shop-order-api 时,会从 Maven 仓库拉到普通 jar 版本,而不是 fat jar。这个差异很容易被忽略,却非常关键。所以在发布公共 jar 模块时,要确保它们的 packaging 和打包插件不会错误套用 Spring Boot 的 repackage。
7. 依赖管理最佳实践汇总:我的建议和几个必须避开的习惯
讲了这么多,最后总结一些我真实踩过坑之后沉淀下来的关键经验。这里不按套路聊大道理,只讲实战里能直接改代码的习惯。
经验一:父 pom 的 dependencies 尽量留空。
父 pom 里的<dependencies>是要传给每个子模块的“血脉”,一旦写进去,想想所有子项目都会被强制传一个库,你愿意吗?只有在非常确定“所有子模块必须用且只有这一个版本”时才放进去。我自己会放的是 Lombok 可选的时候都会犹豫,因为很多纯 DTO 模块其实不需要 Lombok,可它进父 pom dependencies 后就成了全部子模块的强制依赖。所以我的默认策略是:所有子模块主动声明自己需要的东西,父工程只锁版本不强制传。
经验二:自定义 parent 而不是直接继承 spring-boot-starter-parent,留着扩展空间。
直接继承 Spring Boot 官方父工程固然省事,但一旦项目里要引入公司统一的 parent 配置、统一的异常码规范、统一的代码检查插件,官方父工程继承链就无法插入了。解决方法是让顶层父 pom 继承 spring-boot-starter-parent,再让子模块继承顶层父 pom,形成一条 Java 侧的唯一继承链。这条链上任何位置都可以加插件、加 profile、加仓库配置,后续迁移到 Spring Boot 3 时,只需要在顶层父 pom 修改官方 parent 版本即可,子模块不受影响。这一点在多模块项目中是真正的收益:升级 Spring Boot 变成一条命令的事,而不是全项目逐模块改 pom。
经验三:版本号不要在子模块中重复出现,用 property 统一管理。
在上面的顶层父 pom 里,我定义过<mybatis-plus.version>,之后在 dependencyManagement 中可以引用${mybatis-plus.version}。这样所有子模块中用到 MyBatis Plus 的版本号都指向同一个 property,后期统一升版本时只需要改父 pom 中的一处。如果子模块自己声明了版本,那父 pom 的 version 会完全失效,这也就是为什么强调“子模块最好不要写版本号”的原因。但假设业务需要个别子模块用不同版本,那就在该子模块中显式覆盖,这是允许的,但这种例外的情况越少越好。
经验四:模块划分并非越细越好。
多模块的粒度应该粗到“模块可独立演进”,细到“依赖关系清楚”。比如 shop-order 和 shop-order-api 分开是合理的,因为订单系统对外提供 API 接口时,外部团队不应该引入整个订单服务的实现类;而同时独立出一个 shop-common 供各业务模块共用。但如果把“工具类”和“工具类常量”都拆成独立模块,那就是过度设计,构建链路会变成面条形状,每次改动都触发一串模块重编译,最终大家就会怨声载道。粒度上我建议按“对外 API、实现模块、公共基础模块”三层来切,尽量不要超过四层,除非有独立部署和独立扩展的强制要求。
经验五:在构建环境里,父工程和子模块的版本号要保持一致。
多模块项目经常出现一个团队维护多个 release 分支,父工程版本号与子模块版本号不一致。比如父 pom 版本是 2.0.0-SNAPSHOT,某个子模块还停在 1.33.0-SNAPSHOT。这时候你到 IT 环境部署,可能因为 Maven 仓库找不到匹配版本而失败或者引用了完全意外的旧版本。所以我的习惯是:用统一版本变量定义所有模块的 version,项目发布时一次性替换这个变量。同时,在 CI 流水线里设置mvn versions:set -DnewVersion=xxx批量更新所有模块版本,保持全链路一致性。
经验六:小心 spring-boot-maven-plugin 的 repackage 污染普通 jar 模块。
如果你在子工程中只写了 spring-boot-maven-plugin 而未配合<executions><execution><goals><goal>repackage</goal></goals></execution></executions>,那么这个插件默认不会执行什么特殊处理。一旦你一顺手加了 repackage,它就会把依赖的一部分打成一个不可再被其他模块引用的 fat jar。这个坑我在公共 API 模块上踩过:外部服务引入 shop-order-api 之后,发现依赖里的 MyBatis 相关类全报 ClassNotFound,最后查出来是 API 模块被 Spring Boot 插件重打包成了 fat jar,而 fat jar 里嵌套的 lib 不被外部 Spring Boot 工程识别。正确做法:普通 jar 模块不要配置 repackage。
8. 升级到 Spring Boot 3 和 Java 17 时,父工程与子工程的改动策略
目前我们一直在用 Spring Boot 2.7 举例,但其实这套多模块依赖管理经验在 Spring Boot 3.x 上同样适用,只要理解底层规则一致:Maven 的父子继承机制不随 Boot 大版本变化。
升级到 Spring Boot 3 时,最核心的改动集中在三个地方:
- 顶层父 pom 中 Spring Boot 版本由 2.7.18 改为 3.x,同时 Java 编译版本从 8 或 11 提升到 17 或 21。
- 检查 dependencyManagement 中管理的第三方库,是否与 Spring Boot 3 的 Jakarta EE 命名空间兼容。Spring Boot 3 把包名从 javax 迁移到了 jakarta,所以只要哪个依赖用了旧的 javax API,它大概率在 Spring Boot 3 下编译直接工业级爆炸。此时需要把该依赖版本升级到兼容 jakarta 的版本。
- 所有子模块中如果有自定义的
javax.annotation.Resource、javax.servlet.*等引用,全部替换为jakarta.*。这一步虽然和父工程 pom 无关,但却是升级是否顺利的隐形决定因素。
这套升级流程之所以能高效推进,正是因为在多模块架构下,版本和依赖的“扳机”集中在顶层父 pom 中,而不需要每个开发都翻自己模块去更新上百个依赖版本号。
另外,Spring Boot 3.x 中spring-boot-maven-plugin的重新打包逻辑和旧版没有大变化,因此在上面提到的 repackage 注意点依然适用。
值得注意的还有,Spring Boot 3.2 后官方 Maven 插件对 Maven 版本的要求从 Maven 3.6.3 提高到了 Maven 3.6.3 以上,其实只要你用的是 Maven 3.8+,一般都不会是瓶颈。真正容易遇到的是 JDK 版本:本地开发环境如果保持在 JDK 11 而在 pom 中声明 Java 17,那么多模块编译时会出现大量语法不支持错误。遇到时先看 Maven 的java.version属性是否真的指向了 17,再看 IDE 导入项目时用的是哪套 JDK。去年我遇到一次很诡异的现象:命令行构建没问题,IDE 每跑必挂,最后发现 IDE 的 "Gradle/JVM" 设置用了默认的 JDK 1.8。
这类小问题在单体应用里影响不大,但在多模块项目里,因为所有子模块跨编译,某个模块编译错误会迅速传导,导致无从下手。所以我后来在父 pom 中干脆强制放一个 maven-enforcer-plugin,统一检查 JDK 版本和 Maven 版本:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-enforcer-plugin</artifactId> <executions> <execution> <id>enforce-java-version</id> <goals> <goal>enforce</goal> </goals> <configuration> <rules> <requireJavaVersion> <version>[17,)</version> </requireJavaVersion> <requireMavenVersion> <version>[3.6.3,)</version> </requireMavenVersion> </rules> </configuration> </execution> </executions> </plugin>在多模块团队协作里,这种“环境一致性检查”依赖很容易被忽略,但它其实比某个具体依赖的版本冲突更防不胜防。统一决策、统一打包、统一环境,才是多模块分而不乱的本质。
最后分享一个小小的经验:别把父工程当成垃圾桶,所有团队里没人管的东西都往里塞。依赖管理要有明确的所有者,父工程的改动必须有 release note,子模块的依赖声明要尽量准确到“这里需要才写”。这样项目积累几年之后,依赖不会变成一团解不开的乱麻。这次的父工程与子工程依赖管理实践,我就先分享到这里,如果你在搭多模块项目时也碰到什么奇怪的坑,欢迎在评论区一起复盘。