《Spring Boot 实战》1.2 父子 POM 与依赖管理

讲清父 POM 的 parent 继承与 dependencyManagement 版本管理的分工,演示导入 spring-boot-dependencies BOM、子模块声明依赖、用 Enforcer 预防版本冲突,以及可复用库模块的打包与发布方式。

本节目标:把 1.1 定下的三模块骨架写成能构建的 Maven 工程,分清 parent 继承与 dependencyManagement 各自的职责,掌握子模块声明依赖、预防版本冲突、发布可复用库的完整做法。
适用版本:Spring Boot 4.1.x(Java 21)

1.2 父子 POM 与依赖管理

1.1 定了 book-loan-api / book-loan-core / book-loan-web 三个模块和一条直线依赖。本节把这条直线变成真的能 mvn package 的工程。多模块工程里最容易出错的不是 Java 代码,而是 pom.xml:版本散落各处、子模块不知道该不该写 <version>、库模块被打成了不可复用的 fat jar。这些问题的根源都是没分清 Maven 的两套机制——继承(parent) 与 依赖管理(dependencyManagement)。

1.2.1 parent 与 dependencyManagement 的分工

这两个概念经常被混为一谈,但职责完全不同。

机制作用影响范围
<parent> 继承子模块继承父 POM 的 properties、build 配置、插件配置、dependencyManagement所有继承者,是结构性继承
<dependencyManagement>只声明版本与坐标,不真正引入依赖;子模块声明依赖时省略 <version>只影响版本解析,不改变依赖图

关键区别一句话:<parent> 会真的把配置带给子模块,<dependencyManagement> 只提供「版本字典」。 父 POM 里写了 <dependencyManagement>,子模块并不会因此多出任何依赖——它只是让子模块在声明依赖时可以省略版本号。

这带来一个常见误解的澄清:父 POM 的 <dependencies> 会被所有子模块无条件继承并真正引入。 所以父 POM 里不要直接写 <dependencies>,除非你真的希望每个模块都依赖它(例如统一的 spring-boot-starter-test 在某些团队里会这么做,但它会污染库模块的依赖图)。稳妥做法是:父 POM 只放 <dependencyManagement>,<dependencies> 留给各子模块自己声明。

1.2.2 聚合与继承是两件事

<modules> 和 <parent> 也常被混为一谈。

  • 聚合(aggregation):父 POM 用 <modules> 列出子模块,作用是「一次命令构建全部」,构建顺序由 Maven 根据依赖自动推导。
  • 继承(inheritance):子模块用 <parent> 指向父 POM,作用是「继承配置」。

两者可以同时用,也可以分开。本章的工程同时用了两者:根 book-loan/pom.xml 既聚合三个子模块,又作为它们的父 POM。但要注意——聚合不要求父 POM 是子模块的 parent,继承也不要求子模块被列在 <modules> 里。 只有当你确实想「统一构建 + 统一配置」时才两者都做。

根 POM 长这样:

<?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>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>book-loan</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>
    <name>book-loan</name>

    <modules>
        <module>book-loan-api</module>
        <module>book-loan-core</module>
        <module>book-loan-web</module>
    </modules>

    <properties>
        <java.version>21</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
</project>

三个细节值得停一下:

  • <packaging>pom</packaging> 是聚合父 POM 的必需项——它本身不产出 jar。
  • <relativePath/> 显式置空,告诉 Maven「父 POM 去仓库找,不要在上层目录找」,避免多模块工程里相对路径解析出意外。
  • spring-boot-starter-parent 的 <version> 决定了整个工程的 Spring Boot 版本,这就是 4.1.1 的唯一出处。

1.2.3 用 spring-boot-dependencies BOM 替代 parent

上例直接继承了 spring-boot-starter-parent,这是最省事的做法。但如果你的公司有一套统一的公司级父 POM(管代码规范、私服地址、插件版本),你就不能再继承 Spring 的 parent 了——Java 里一个 POM 只能有一个 <parent>。

此时改用「导入 BOM」的方式。Spring Boot 把全部依赖的版本管理单独发布成一个 BOM:spring-boot-dependencies。在父 POM 的 <dependencyManagement> 里 import 它,效果与继承 parent 几乎一致:

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>4.1.1</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

<type>pom</type> 加 <scope>import</scope> 是 BOM 导入的固定写法,缺一不可。它把 spring-boot-dependencies 里 dependencyManagement 声明的所有版本「灌」进当前 POM 的版本字典。

两种方式怎么选?

方式适用场景代价
继承 spring-boot-starter-parent纯 Spring Boot 项目,没有公司父 POM失去了自己的父 POM 位置
导入 spring-boot-dependencies BOM已有公司父 POM,或多工程统一版本需自己配 spring-boot-maven-plugin 的版本与 java.version

注意第二行的代价:spring-boot-starter-parent 除了依赖版本,还替你配置了 maven-compiler-plugin、maven-surefire-plugin、资源过滤、java.version 默认值等。改用 BOM 导入后,这些不再自动生效,需要自己补。这就是「parent 是结构继承、BOM 只是版本字典」的直接后果。

1.2.4 子模块如何声明依赖

子模块的 POM 只做两件事:指向父 POM、声明自己用到的依赖(省略版本)。

book-loan-api 是最底层的契约模块,只放 DTO 与错误码,依赖极轻:

<?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>book-loan</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>book-loan-api</artifactId>
    <name>book-loan-api</name>

    <dependencies>
        <dependency>
            <groupId>jakarta.validation</groupId>
            <artifactId>jakarta.validation-api</artifactId>
        </dependency>
    </dependencies>
</project>

jakarta.validation-api 没有写 <version>——版本来自父 POM 继承的 BOM。这是本节最重要的一条纪律:除了模块间互相引用的依赖,其他第三方依赖一律不写版本。

book-loan-core 依赖 book-loan-api,并引入 JPA:

    <artifactId>book-loan-core</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>book-loan-api</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
    </dependencies>

模块间引用用 ${project.version},保证三个模块永远同版本。spring-boot-starter-flyway 是 4.x 的要点:Flyway 从 4.0 起必须显式引入这个 starter,只加 flyway-core 不再触发自动配置。

book-loan-web 依赖 book-loan-core,加 Web MVC starter,并且只有它配置 spring-boot-maven-plugin:

    <artifactId>book-loan-web</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>book-loan-core</artifactId>
            <version>${project.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

spring-boot-starter-webmvc 是 4.x 的新名,旧名 spring-boot-starter-web 已废弃——正文一律用新名。

1.2.5 为什么只有 web 模块配置 spring-boot-maven-plugin

这是一个高频错误,值得单独讲。spring-boot-maven-plugin 的 repackage 目标会把普通 jar 改造成可执行 fat jar——把所有依赖解包塞进 BOOT-INF/lib,并改写 MANIFEST.MF 指向 JarLauncher。

问题在于:这样的 jar 不能被别的模块当普通依赖引用。 如果把 book-loan-core 也 repackage 了,那么 book-loan-web 引用它时会拿到一个「里面套着 BOOT-INF/lib」的 jar,类路径根本找不到 Book 实体,编译直接失败。

所以规则是:

模块类型是否配 spring-boot-maven-plugin产物
可执行应用(book-loan-web)配,默认绑定 repackage可执行 fat jar
可复用库(book-loan-api、book-loan-core)不配普通 jar

父 POM 里也不要用 <pluginManagement> 把这个插件预置给所有子模块——那会让每个模块都继承 repackage 行为。正确做法是让每个子模块显式声明自己需要的插件,应用的归应用,库的归库。

1.2.6 版本冲突的预防

多模块工程里,版本冲突的来源通常是「某个子模块显式写了一个版本,覆盖了 BOM 的管理版本」。预防手段有两层。

第一层:纪律。 除模块间引用外,第三方依赖一律不写 <version>。这条纪律能消掉绝大多数冲突。

第二层:自动化校验。 用 Maven Enforcer 插件在构建时强制版本收敛——如果依赖树里同一个 groupId:artifactId 出现多个版本,直接构建失败:

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-enforcer-plugin</artifactId>
                <executions>
                    <execution>
                        <id>enforce-convergence</id>
                        <goals>
                            <goal>enforce</goal>
                        </goals>
                        <configuration>
                            <rules>
                                <dependencyConvergence/>
                                <banDuplicatePomDependencyVersions/>
                            </rules>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

这两条规则的分工:

  • dependencyConvergence:要求依赖树中每个 artifact 的版本唯一,否则失败。
  • banDuplicatePomDependencyVersions:禁止同一个 POM 里重复声明同一依赖。

排查冲突的常用命令:

# 打印依赖树,找出同一 artifact 的多版本来源
mvn dependency:tree -Dverbose

# 找出「声明了但没用」和「用了但没声明」的依赖
mvn dependency:analyze

dependency:tree -Dverbose 会标出被 omitted for duplicate(版本冲突被裁掉)的节点,这是定位冲突最快的手段。dependency:analyze 则能发现「代码里 import 了某个类,但它其实是靠传递依赖碰巧进来的」——这种依赖必须显式声明,否则上游一升级就断。

1.2.7 可复用库模块的打包与发布

book-loan-api 与 book-loan-core 的定位是「可复用库」,它们要能被别的工程引用。发布路径分两级:

本地开发:mvn install 把库装进本机 ~/.m2/repository,同机的其他工程就能引用了。多模块工程在本地联调时,install 一次即可让依赖它的工程拿到最新版。

团队共享:mvn deploy 推到私服(Nexus / Artifactory)。需要在父 POM 声明发布地址:

    <distributionManagement>
        <repository>
            <id>company-releases</id>
            <url>https://nexus.example.com/repository/maven-releases/</url>
        </repository>
        <snapshotRepository>
            <id>company-snapshots</id>
            <url>https://nexus.example.com/repository/maven-snapshots/</url>
        </snapshotRepository>
    </distributionManagement>

Maven 按版本后缀自动选择目标仓库:1.0.0-SNAPSHOT 走 snapshotRepository,1.0.0 走 repository。

版本命名纪律:开发期用 -SNAPSHOT,可被反复覆盖;一旦要给外部消费,发布不带后缀的正式版本号,且发布后不再改动同一版本号(改动意味着 1.0.1 或 1.1.0)。私服上的正式版本被覆盖,会让所有依赖方的构建结果变得不可复现,这是比编译错误更难查的事故。

库模块的兼容性还有一条实务建议:库的公开 API 尽量只暴露稳定契约(book-loan-api 放 DTO 与接口,book-loan-core 的内部实现类用包私有收窄)。库一旦被多个工程依赖,改公开 API 的成本会随消费者数量线性上升。

1.2.8 常见坑

坑一:父 POM 写了 <dependencies>。 所有子模块无条件继承,连 book-loan-api 都被迫依赖 spring-boot-starter-webmvc。结果是最底层的契约模块拖进一整套 Web 栈。父 POM 只写 <dependencyManagement>。

坑二:子模块又写了一遍版本。 有人「保险起见」在子模块补 <version>4.1.1</version>,这会覆盖 BOM 管理值,日后升级 Spring Boot 时漏改一处就出现两个版本共存。让 Enforcer 的 dependencyConvergence 把它挡住。

坑三:库模块被 repackage 成 fat jar。 症状是引用方编译报「找不到类」。原因见 1.2.5——spring-boot-maven-plugin 只能配在可执行模块上。

坑四:忘记 <relativePath/>。 在多模块工程里,父 POM 的查找会先按相对路径找,可能解析到意料之外的目录。显式置空是零成本保险。

小结

  • <parent> 是结构继承,会真的把 properties、build、插件配置带给子模块;<dependencyManagement> 只是版本字典,不引入依赖。
  • 父 POM 只放 <dependencyManagement>,不放 <dependencies>;否则所有子模块被迫继承。
  • 聚合(<modules>)与继承(<parent>)是两件事,可同时用也可分开。
  • 没有公司父 POM 时继承 spring-boot-starter-parent;有公司父 POM 时改为 import 导入 spring-boot-dependencies BOM,但要自己补编译器、测试插件的配置。
  • 子模块的第三方依赖一律不写版本(由 BOM 管理),模块间引用用 ${project.version}。
  • 只有可执行模块配 spring-boot-maven-plugin;库模块保持普通 jar,否则无法被引用。
  • 用 Enforcer 的 dependencyConvergence 把版本冲突变成构建失败,用 dependency:tree -Dverbose 定位来源。
  • 库发布用 mvn install(本地)/ mvn deploy(私服),正式版本发布后不再覆盖。

骨架能构建之后,下一节处理一个更隐蔽的问题:包怎么分、依赖方向怎么守。1.3 会用包可见性和 ArchUnit 把架构约束变成可执行的测试。

阅读导航:上一节:1.1 何时该拆模块 · 下一节:1.3 分层与包结构约定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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