本节目标:搞清
spring-boot-starter-parent与spring-boot-dependencies两种引入方式的真实差别,学会用 import scope 的 BOM 把多模块与公司父 POM 的版本收拢到一处,掌握覆盖托管版本的规范做法,并对照 4.x 的依赖大版本变化做升级前的对齐。
适用版本:Spring Boot 4.1.x(Java 21)
2.2 版本对齐与 BOM
上一节解决的是「冲突已经发生怎么修」。这一节要往前一步:让冲突没有机会发生。依赖冲突的根源几乎都是「版本信息散落在各处」——某个模块的 pom.xml 里多写了一个 <version>,或者两个内部 SDK 各自钉死了同一个库。版本对齐的目标只有一句话:同一个坐标,整个工程里只在一个地方出现版本号。
问题:多模块的版本漂移
book-loan 有五个模块。假设没有统一管理,每个模块各自声明依赖版本,会发生什么:
| 模块 | 声明的 spring-webmvc | 后果 |
|---|---|---|
book-loan-web | 7.0.9 | 正常 |
book-loan-infrastructure | 6.2.19(从旧项目抄来) | 运行期与 7.0.9 竞争 |
book-loan-boot | 不写(靠传递) | 结果取决于调解顺序 |
三个模块单独构建都成功,合并成可执行 jar 后 spring-webmvc 只剩一个版本——选中的那个未必是你要的,而且没有任何一步会报错。升级 Spring Boot 时更麻烦:你改了四处版本号,漏掉一处,问题要等到运行时才暴露。
版本对齐要解决的就是这件事:把版本从「每个模块各写一遍」变成「父 POM / BOM 写一遍,子模块不写」。
两种引入方式:parent 与 BOM
Spring Boot 官方提供两条路,很多人只知道第一条。
| 维度 | 继承 spring-boot-starter-parent | import spring-boot-dependencies |
|---|---|---|
| 引入方式 | <parent> 段 | <dependencyManagement> + <scope>import</scope> |
| 得到依赖版本管理 | 是 | 是 |
| 得到插件版本管理 | 是 | 否 |
| 得到构建默认值 | 是(编译级别、资源过滤、UTF-8、-parameters 等) | 否 |
| 是否占用 Maven 唯一的 parent 位 | 是 | 否 |
| 能否与公司父 POM 共存 | 需要公司父 POM 反过来继承它 | 可以并存 |
| 适合场景 | 单模块、个人项目、无公司父 POM | 多模块企业项目 |
两条路都能让子模块「写依赖不写版本」,区别在于它们除了版本还给了你什么。
方式一:继承 spring-boot-starter-parent
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<relativePath/> 空标签表示「不从本地上级目录找,直接去仓库下载」(入门卷已讲过)。这个 parent 本身几乎不含代码,它注入两类东西:
- dependencyManagement:所有 starter 的版本,因此写
spring-boot-starter-webmvc时不用写版本; - pluginManagement 与一批构建默认值:
maven.compiler.release、资源过滤、UTF-8 编码、-parameters编译参数等。注意从 Spring Boot 3.1 起,它改用maven.compiler.release配置 Java 版本,maven.compiler.source/target属性已被移除——网上老教程里那两行在 4.x 里是无效的。
方式二:import spring-boot-dependencies
企业项目通常已经有一个公司级的父 POM(管着私服地址、代码规范插件、发布流程)。Maven 只允许继承一个 parent,这时就不能再继承 spring-boot-starter-parent,改用 BOM:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.birdor</groupId>
<artifactId>birdor-parent</artifactId>
<version>5.2.0</version>
</parent>
<groupId>com.birdor.bookloan</groupId>
<artifactId>book-loan-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<properties>
<spring-boot.version>4.1.1</spring-boot.version>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>
代价是你要自己补回 parent 提供的默认值:maven.compiler.release、project.build.sourceEncoding 必须显式写,否则可能出现编译到旧字节码或中文乱码。
插件版本也一样:用 starter-parent 时 spring-boot-maven-plugin 的版本由 parent 管,不要再写 <version>;用 BOM 时 parent 不管插件,必须写版本。
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</build>
这一处是两种方式最容易混的地方:漏写插件版本,Maven 会用内置的默认版本,可能与框架版本不匹配,表现为 repackage 出的 jar 启动异常。
取舍:什么时候用哪个
把选择整理成一张决策表:
| 你的情况 | 选择 | 理由 |
|---|---|---|
| 单模块、练手项目、没有公司 parent | spring-boot-starter-parent | 零配置拿到全部默认值,最省事 |
| 已有公司 parent、多模块 | spring-boot-dependencies BOM | 不占用 parent 位,可与公司规范共存 |
| 公司 parent 反过来继承 starter-parent | 可行,但要谨慎 | 全公司的构建默认值被 Spring Boot 绑定,升级 Spring 时全体受影响 |
| 内部有多个团队共享的组件 | 自建 BOM(见下文) | 组件版本单独一条发布线,与 Spring 解耦 |
book-loan 采用 BOM 方式,因为它属于一个已有公司父 POM 的多模块工程——这也是绝大多数生产项目的真实处境。
import scope 的四条规则
用 BOM 就必须理解 import 的边界,它和普通依赖的语义完全不同。
<type>pom</type>与<scope>import</scope>必须同时出现,而且只能放在<dependencyManagement>里;放进<dependencies>会被当成一个真的 pom 依赖去解析,达不到版本管理的目的。- 本 POM 里直接写的
<dependencyManagement>条目,优先级高于任何 import 进来的版本。这是官方给你的覆盖通道,也是下一小节「覆盖托管版本」的基础。 - 多个 BOM 都管理同一个坐标时,结果与 import 的顺序相关。不要依赖这种隐式顺序——真出现重叠,就在本 POM 里显式写一条,把结果钉死。
import不改变依赖的存在性,只改版本。它不会把 Spring Boot 的任何库塞进你的 classpath;真正决定「有没有」的仍然是你自己的<dependencies>。
自建 BOM 管理内部组件
上节那个 Guava 冲突,根因之一就是两个内部 SDK 各自钉死了版本。正确做法是建一个内部 BOM,把「内部组件用哪个版本」也收拢到一处:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.birdor.bookloan</groupId>
<artifactId>book-loan-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>book-loan-bom</artifactId>
<packaging>pom</packaging>
<dependencyManagement>
<dependencies>
<!-- 四个内部模块:domain / application / infrastructure / web,写法相同 -->
<dependency>
<groupId>com.birdor.bookloan</groupId>
<artifactId>book-loan-domain</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 内部 SDK:isbn-metadata-sdk 2.3.0、loan-report-sdk 1.4.0 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.0.1-jre</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
关键点是 ${project.version}:在 BOM 里它指的是 BOM 自己的版本,所以内部模块必须与 BOM 同版本发布(release train 模式)。这是企业里最常见的做法——内部组件统一版本号,一起升、一起发,避免「domain 是 1.3、web 是 1.1」的错配。
子模块只要 import 这个 BOM,写依赖时就不带版本:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.birdor.bookloan</groupId>
<artifactId>book-loan-bom</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependency>
<groupId>com.birdor</groupId>
<artifactId>isbn-metadata-sdk</artifactId>
</dependency>
因为内部 BOM 和 spring-boot-dependencies 是并列的 import,要遵守上面的规则 3:尽量让两者管理互不重叠的坐标。内部 BOM 管 com.birdor.*,Spring BOM 管 org.springframework.*,这样顺序就不再重要;一旦重叠(比如 Guava 两边都管),就在本 POM 里显式写一条并加注释说明取舍。
覆盖托管版本
总有需要偏离官方版本的场景:某个 CVE 要求升级 Tomcat、Hibernate 有个 bug 只能在补丁版修。覆盖方式有两种,优先级和风险都不同。
方式一:用 properties(首选)
BOM 里绝大多数版本都是用属性定义的,改属性即可覆盖:
<properties>
<tomcat.version>11.0.24</tomcat.version>
<hibernate.version>7.4.5.Final</hibernate.version>
</properties>
一行改动、语义清晰,而且下游项目还能再覆盖。但它有一个致命前提:属性名必须真的存在。 BOM 里没定义的属性名不会报错,只是静默失效——你以为改掉了版本,其实什么都没发生。这是本主题最常见的翻车点。
所以覆盖前先确认属性名有效:
mvn help:evaluate -Dexpression=tomcat.version -q -DforceStdout
11.0.24
-q 关掉日志、-DforceStdout 把求值结果直接打到标准输出,得到的就是当前生效的版本。如果属性不存在,该命令会报 null object or invalid expression 或打印空值——据此就能判断属性名是否写对。
方式二:显式声明(BOM 没暴露属性时)
有些依赖的版本不是用属性定义的,或者像 Spring Retry 那样在 4.0 里整个移出了依赖管理,这时只能在本 POM 的 <dependencyManagement> 里显式写:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 4.0 起 Spring Retry 的依赖管理被移除,必须显式给版本 -->
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>${spring-retry.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
这里的 spring-retry.version 由公司父 POM(birdor-parent)统一定义,本工程只引用它——版本号依然只写在一处,正符合本节的主张。不要在这里写死一个字面量版本,否则它就成了第二个版本源。
按规则 2,本 POM 直接写的条目优先级最高,一定生效。代价是覆盖面更大——它管住了这个坐标的所有路径,写错会影响全工程,所以要配注释写清「为什么偏离官方版本」。
三种覆盖方式的对比:
| 方式 | 生效条件 | 优点 | 风险 |
|---|---|---|---|
<properties> | BOM 里用该属性定义版本 | 一行改动、可被下游再覆盖 | 属性名写错静默失效 |
显式 dependencyManagement | 总是生效 | 一定能覆盖 | 覆盖面过大,可能改了不该改的 |
直接 <dependency> 带 <version> | 总是生效 | 写法最直观 | 变成直接依赖,改变依赖调解深度 |
4.1.1 的托管版本实况
升级到 4.x 时,版本对齐最需要提前知道的是:一大批依赖的主版本号都变了。这些不是补丁升级,而是可能带 API 变更的大版本,必须逐个确认自己的代码是否受影响。
下面这张表是本机从 ~/.m2/repository/org/springframework/boot/spring-boot-dependencies/4.1.1/spring-boot-dependencies-4.1.1.pom 实测出来的 4.1.1 真实托管版本(4.0 刚引入时是另一套口径,例如 Spring Data 2025.1、Hibernate 7.2,4.1 线已经往前走):
| 组件 | 4.1.1 托管版本 | 说明 |
|---|---|---|
| Spring Framework | 7.0.9 | 全系列基线 |
| Spring Security | 7.1.1 | Spring Authorization Server 已并入 |
| Spring Data | 2026.0.1 | — |
| Spring Batch | 6.0.5 | — |
| Spring Kafka | 4.1.1 | — |
| Spring AMQP | 4.1.1 | — |
| Hibernate ORM | 7.4.5.Final | — |
| Hibernate Validator | 9.1 | — |
| HikariCP | 7.0.2 | 连接池 |
| Tomcat | 11.0.24 | 对应 Servlet 6.1 |
| Testcontainers | 2.0.5 | 集成测试 |
| Flyway | 12.4.0 | — |
| Jackson | 3.1.5 | 包名从 com.fasterxml.jackson 变为 tools.jackson |
| Micrometer | 1.17.1 | — |
对照 3.5.x 那一侧:Spring Framework 是 6.2(本机实测 6.2.19)、Tomcat 10.1、Servlet 6.0 / Jakarta EE 10、Jackson 2(com.fasterxml.jackson)。升级前最实用的动作是跑 mvn -pl book-loan-boot dependency:list -DincludeGroupIds=org.springframework,org.hibernate,com.zaxxer,把当前工程解析后的实际版本列出来和上表逐项核对——它打印的是调解之后的最终版本,比看 POM 里写了什么可靠得多。注意别拿「4.0 起」的版本号去对照 4.1 工程,4.1 已经把不少依赖又推了一档。
4.0 移除的依赖管理
有两处依赖管理在 4.0 里被移除了,迁移时会直接报「版本缺失」:
- Spring Retry:Spring 生态已转向 Spring Framework 7 的内建重试能力,
spring-boot-dependencies不再管理 Spring Retry。若你的代码仍在用,必须像上面那样显式给版本,并考虑迁移到框架内建方案。 - Spring Authorization Server:它已并入 Spring Security 7.0。因此
spring-authorization-server.version属性不再生效,需要覆盖时改用spring-security.version。
这两条都属于「升级后属性/版本突然找不到」的类型,最好在升级清单里提前标注。
常见坑
| 坑 | 表现 | 处理 |
|---|---|---|
| import 了 BOM 却没写 starter | 编译找不到 @SpringBootApplication | import 只管版本,不引入依赖 |
| 属性名拼错 | 覆盖看似生效,版本其实没变 | 用 help:evaluate 确认属性存在 |
| 用 BOM 却漏写插件版本 | repackage 出的 jar 启动异常 | 显式声明 spring-boot-maven-plugin 版本 |
BOM 里用 ${project.version} 但内部模块不同版本发布 | 依赖解析失败 | 内部模块与 BOM 同版本发布 |
| 内部 BOM 与 Spring BOM 管理同一坐标 | 结果依赖 import 顺序,难排查 | 划分管理边界,或在本 POM 显式钉死 |
用 maven.compiler.source/target 设 Java 版本 | 在 4.x 下无效 | 改用 maven.compiler.release |
小结
- 版本对齐的目标是「同一个坐标全工程只写一次版本」,这是从根上避免上节那类冲突的办法。
- 两条引入路径:
spring-boot-starter-parent省事但占用唯一的 parent 位;spring-boot-dependenciesBOM 用import引入,可与公司父 POM 共存,代价是要自己补编译默认值和插件版本。 import的四条规则里最该记住的是:本 POM 直接写的条目优先级最高、import 只改版本不改变存在性。- 自建 BOM 用
${project.version}管理内部模块,采用 release train 模式统一发布;内部 BOM 与 Spring BOM 应划分互不重叠的管理边界。 - 覆盖托管版本优先用
<properties>,并用mvn help:evaluate确认属性名有效;BOM 未暴露属性或已移除管理时,才在dependencyManagement里显式声明。
版本对齐解决的是「声明层面的一致性」。但即使版本都对了,多模块工程每次构建仍然可能慢得让人放弃本地验证——下一节 2.3 构建加速与缓存 讲怎么把构建时间压下来。
阅读导航:上一节:2.1 依赖冲突排查 · 下一节:2.3 构建加速与缓存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。