本节目标:把 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-dependenciesBOM,但要自己补编译器、测试插件的配置。 - 子模块的第三方依赖一律不写版本(由 BOM 管理),模块间引用用
${project.version}。 - 只有可执行模块配
spring-boot-maven-plugin;库模块保持普通 jar,否则无法被引用。 - 用 Enforcer 的
dependencyConvergence把版本冲突变成构建失败,用dependency:tree -Dverbose定位来源。 - 库发布用
mvn install(本地)/mvn deploy(私服),正式版本发布后不再覆盖。
骨架能构建之后,下一节处理一个更隐蔽的问题:包怎么分、依赖方向怎么守。1.3 会用包可见性和 ArchUnit 把架构约束变成可执行的测试。
阅读导航:上一节:1.1 何时该拆模块 · 下一节:1.3 分层与包结构约定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。