《Spring Boot 实战》2.2 版本对齐与 BOM

本节讲 Spring Boot 多模块工程的版本对齐:对比 spring-boot-starter-parent 与 spring-boot-dependencies BOM 的引入方式与取舍,说明 import scope 的生效规则、如何自建 BOM 统一内部组件版本、如何用 properties 或显式声明覆盖托管版本,并汇总 4.x 各依赖的大版本变化与 4.0 移除的依赖管理。

本节目标:搞清 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-web7.0.9正常
book-loan-infrastructure6.2.19(从旧项目抄来)运行期与 7.0.9 竞争
book-loan-boot不写(靠传递)结果取决于调解顺序

三个模块单独构建都成功,合并成可执行 jar 后 spring-webmvc 只剩一个版本——选中的那个未必是你要的,而且没有任何一步会报错。升级 Spring Boot 时更麻烦:你改了四处版本号,漏掉一处,问题要等到运行时才暴露。

版本对齐要解决的就是这件事:把版本从「每个模块各写一遍」变成「父 POM / BOM 写一遍,子模块不写」。

两种引入方式:parent 与 BOM

Spring Boot 官方提供两条路,很多人只知道第一条。

维度继承 spring-boot-starter-parentimport 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 启动异常。

取舍:什么时候用哪个

把选择整理成一张决策表:

你的情况选择理由
单模块、练手项目、没有公司 parentspring-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 的边界,它和普通依赖的语义完全不同。

  1. <type>pom</type> 与 <scope>import</scope> 必须同时出现,而且只能放在 <dependencyManagement> 里;放进 <dependencies> 会被当成一个真的 pom 依赖去解析,达不到版本管理的目的。
  2. 本 POM 里直接写的 <dependencyManagement> 条目,优先级高于任何 import 进来的版本。这是官方给你的覆盖通道,也是下一小节「覆盖托管版本」的基础。
  3. 多个 BOM 都管理同一个坐标时,结果与 import 的顺序相关。不要依赖这种隐式顺序——真出现重叠,就在本 POM 里显式写一条,把结果钉死。
  4. 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 Framework7.0.9全系列基线
Spring Security7.1.1Spring Authorization Server 已并入
Spring Data2026.0.1—
Spring Batch6.0.5—
Spring Kafka4.1.1—
Spring AMQP4.1.1—
Hibernate ORM7.4.5.Final—
Hibernate Validator9.1—
HikariCP7.0.2连接池
Tomcat11.0.24对应 Servlet 6.1
Testcontainers2.0.5集成测试
Flyway12.4.0—
Jackson3.1.5包名从 com.fasterxml.jackson 变为 tools.jackson
Micrometer1.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编译找不到 @SpringBootApplicationimport 只管版本,不引入依赖
属性名拼错覆盖看似生效,版本其实没变用 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-dependencies BOM 用 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 构建加速与缓存 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计