主题
界面
主题
《Maven 基础》 讲了"能构建起来":一个 pom、几个依赖、mvn test。
本节讲"构建得对、管得住":
能跑: pom.xml 里写几个 <dependency> (S0-003)
管得住: 版本从哪来?冲突怎么解?20 个模块怎么组织?(本节)一个判断你是否需要的信号:
如果你曾经遇到过"
java.lang.NoSuchMethodError"或"ClassNotFoundException,但依赖明明写了" ——那就是本节要解决的问题 ✓
完成本节后,你应该能够:
<dependencyManagement> 与 <dependencies> 的区别,并知道为什么要用它;scope)及各自的可见性;dependency:tree / -Dverbose / help:effective-pom 排查依赖问题 ★。mvn 常用命令吗?(《Maven 基础》)先看一个真实的现象:
你在 pom 里只写了 1 个依赖:
mybatis-plus-spring-boot3-starter
实际打进 jar 的有 23 个:
h2、mybatis、mybatis-spring、spring-boot-autoconfigure、spring-context、
spring-aop、spring-expression、micrometer-observation、micrometer-commons、
jsqlparser、spring-boot-starter-jdbc、spring-boot-starter、logback、log4j-to-slf4j……这 22 个"多出来"的依赖,就是"传递依赖" ✓
它带来两个问题:
| 问题 | 表现 |
|---|---|
| 版本冲突 ✗ | A 依赖 logback 1.4,B 依赖 logback 1.5 → 用哪个? |
| 依赖膨胀 ⚠️ | 你不小心引了一个"全家桶",jar 体积暴涨 |
Maven 的答案是"依赖调解"(5.3)——本节的重点是让你看懂它的规则,以及知道怎么覆盖它 ✓
<dependency>
<groupId>com.baomidou</groupId> <!-- 组织/项目 -->
<artifactId>mybatis-plus-jsqlparser</artifactId> <!-- 模块 -->
<version>3.5.9</version> <!-- 版本 -->
</dependency>三段合起来唯一确定一个 jar ✓
查找顺序(记住这个,能解决"依赖下载不下来"的一半问题):
① 本地仓库 ~/.m2/repository
② 配置的远程仓库(pom / settings.xml)
③ Maven 中央仓库(默认兜底)本项目的做法(《Maven 基础》):labs/.mvn/settings.xml 里配了阿里云镜像,不改动本机用户级配置 ✓
机制:如果你的依赖 A 依赖 B,那么 B 也会自动成为你的依赖 ✓
实测(mvn -pl data-access dependency:tree):
+- com.h2database:h2:jar:2.3.232:runtime
+- com.baomidou:mybatis-plus-spring-boot3-starter:jar:3.5.9:compile
| +- com.baomidou:mybatis-plus:jar:3.5.9:compile
| | +- com.baomidou:mybatis-plus-core:jar:3.5.9:compile
| | +- com.baomidou:mybatis-plus-annotation:jar:3.5.9:compile
| | +- com.baomidou:mybatis-plus-spring:jar:3.5.9:compile
| | \- org.mybatis:mybatis:jar:3.5.16:compile
| +- org.mybatis:mybatis-spring:jar:3.0.4:compile
| +- com.baomidou:mybatis-plus-spring-boot-autoconfigure:jar:3.5.9:compile
| \- org.springframework.boot:spring-boot-autoconfigure:jar:3.4.1:compile
| \- org.springframework.boot:spring-boot:jar:3.4.1:compile
| \- org.springframework:spring-context:jar:6.2.1:compile
| +- org.springframework:spring-aop:jar:6.2.1:compile
| +- org.springframework:spring-expression:jar:6.2.1:compile
| \- io.micrometer:micrometer-observation:jar:1.14.2:compile
| \- io.micrometer:micrometer-commons:jar:1.14.2:compile
+- com.baomidou:mybatis-plus-jsqlparser:jar:3.5.9:compile
| +- com.github.jsqlparser:jsqlparser:jar:5.0:compile
| \- com.baomidou:mybatis-plus-jsqlparser-common:jar:3.5.9:compile
| \- com.baomidou:mybatis-plus-extension:jar:3.5.9:compile
+- org.springframework.boot:spring-boot-starter-jdbc:jar:3.4.1:compile
| +- org.springframework.boot:spring-boot-starter:jar:3.4.1:compile
| | +- org.springframework.boot:spring-boot-starter-logging:jar:3.4.1:compile
| | | +- ch.qos.logback:logback-classic:jar:1.5.12:compile
| | | | \- ch.qos.logback:logback-core:jar:1.5.12:compile
| | | +- org.apache.logging.log4j:log4j-to-slf4j:jar:2.24.3:compile三处值得看:
| 观察 | 说明 |
|---|---|
+- 与 \- | +- 表示还有同级,\- 表示这是最后一个 ✓ |
| 缩进 | 层级越深 = 传递路径越长 |
compile / runtime | 依赖范围(5.5)✓ |
⚠️ 一个实践提醒:传递依赖不是"你声明的"——如果某个传递依赖消失了(上游换了版本),你的代码可能突然编译不过 ✗
规则:
"你的代码直接 import 的类,必须显式声明它的依赖" ✓
不要依赖"传递" ——传递依赖是上游的实现细节,随时可能变 ⚠️
问题:同一个依赖出现了多个版本,用哪个?
Maven 的两条规则(按这个顺序判断):
| 顺序 | 规则 | 说明 |
|---|---|---|
| ① | 最短路径优先 | 传递链越短越优先 |
| ② | 声明优先 ★ | 路径一样长时,先声明的优先 |
举例:
你的项目
├─ A → logback 1.4 (路径长度 2)
└─ B → C → logback 1.5 (路径长度 3)
→ 用 1.4(路径更短)✓
你的项目
├─ A → logback 1.4 (路径长度 2)
└─ B → logback 1.5 (路径长度 2)
→ 用 1.4(A 先声明)✓⚠️ 第二条规则有个反直觉的后果:
改 pom 里依赖的"顺序",可能悄悄改变最终用的版本 ✗
这就是为什么"显式管理版本"如此重要(5.4)✓
<dependencyManagement>:把版本管起来 这是本节最核心的一节 ✓
两种写法的区别:
<dependencies> | <dependencyManagement> | |
|---|---|---|
| 是否真的引入依赖 | ✅ 是 | ❌ 否(只是"声明版本")✓ |
所以:
<!-- 父 pom:只声明版本,不引入 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.9</version>
</dependency>
</dependencies>
</dependencyManagement><!-- 子模块:引入,但**不写版本**(版本由父 pom 管) -->
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<!-- 没有 <version> ✓ -->
</dependency>
</dependencies>四个好处:
| 好处 | 说明 |
|---|---|
| ① 版本只写一处 ★ | 改版本只改父 pom ✓ |
| ② 子模块不会写错版本 | 漏写 <version> 会用父 pom 的 ✓ |
| ③ 能覆盖传递依赖的版本 ★★ | 这是它最被低估的能力(见下) |
| ④ 依赖清单清晰 | 父 pom 就是"版本总表" ✓ |
实测(mvn -pl spring-boot-web dependency:tree -Dverbose):
\- (org.slf4j:slf4j-api:jar:2.0.16:compile - version managed from 2.0.15; omitted for duplicate)
\- (org.springframework:spring-core:jar:6.2.1:compile - version managed from 6.2.1; omitted for duplicate)
\- (com.fasterxml.jackson.core:jackson-databind:jar:2.18.2:compile - version managed from 2.18.2; omitted for duplicate)关键在 version managed from 2.0.15 ✓✓
这句话的意思是:某个依赖本来要 2.0.15,但因为 dependencyManagement(这里是 Spring Boot 的 BOM)声明了 2.0.16,所以最终用了 2.0.16 ✓
而你根本没在自己的 pom 里写过 slf4j ✓ ——是 BOM 替你统一了版本 ✓
这就是"不用写版本"的真相:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- 为什么可以不写版本? -->
</dependency>答案:因为父 pom 是 spring-boot-starter-parent,它用 <dependencyManagement> 导入了 Spring Boot 的 BOM(里面声明了几百个依赖的版本)✓
本项目的做法(因为 labs 有自己的父 pom,不能继承 starter-parent):
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.4.1</version>
<type>pom</type>
<scope>import</scope> <!-- ★ 关键:type=pom + scope=import -->
</dependency>
</dependencies>
</dependencyManagement><scope>import</scope> 的意义:把另一个 pom 的 dependencyManagement "导入"进来 ✓ ——这是"不继承 parent 也能用 BOM"的标准做法 ✓
六种范围(记住这张表,能解决"为什么测试里能跑、运行时报 ClassNotFound")★:
| scope | 编译 | 测试 | 运行 | 打进包 | 典型 |
|---|---|---|---|---|---|
compile(默认) | ✅ | ✅ | ✅ | ✅ | 业务依赖 |
provided | ✅ | ✅ | ❌ | ❌ | servlet-api(容器提供) |
runtime | ❌ | ✅ | ✅ | ✅ | 数据库驱动(h2)★ |
test | ❌ | ✅ | ❌ | ❌ | JUnit / Mockito ✓ |
system | ✅ | ✅ | ❌ | ❌ | ⚠️ 已废弃,不要用 ✗ |
import | — | — | — | — | 只用于 dependencyManagement(5.4)✓ |
三个最需要理解的:
| scope | 为什么这样设计 |
|---|---|
provided | "编译需要它,但运行时由环境提供" —— 如 servlet-api:你写代码要 import 它,但 Tomcat 自带,打进包反而会冲突 ✗ |
runtime | "编译不需要,运行需要" —— 如数据库驱动:你只面向 JDBC 接口编程,驱动是运行时才加载的 ✓(这也正是 JDBC 的设计:面向接口) |
test | 只测试用,不污染生产 classpath ✓ |
本项目的例子(data-access/pom.xml):
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope> <!-- ★ 编译不用它,运行才需要 -->
</dependency>实测:依赖树里显示 com.h2database:h2:jar:2.3.232:runtime ✓
这是本节第二个重点 ✓ ——很多人把这两件事混为一谈。
继承(<parent>) | 聚合(<modules>) | |
|---|---|---|
| 做什么 | 复用配置(依赖版本、插件、属性) | 一起构建(一次命令构建多个模块) |
| 写法 | 子 pom 里 <parent> | 父 pom 里 <modules> |
| 方向 | 子 → 父(子引用父) | 父 → 子(父列出子) |
| 可以只用其一吗 | ✅ 可以 | ✅ 可以 |
本项目的 labs/pom.xml 两者都用了 ✓
实测:18 个模块的一次构建(mvn validate):
[2/18] labs-start
[3/18] labs-oop-basics
[4/18] labs-polymorphism
[5/18] labs-java-syntax
[6/18] labs-common-classes
[7/18] labs-collections
[8/18] labs-functional
[9/18] labs-concurrency
[10/18] labs-version-features
[11/18] labs-testing-unit
[12/18] labs-testing-testng
[13/18] labs-sql-basics
[14/18] labs-sql-advanced
[15/18] labs-spring-core
[16/18] labs-spring-boot-web
[17/18] labs-data-access
[18/18] labs-web-basics([1/18] 是父 pom labs 自己)
关键观察:
| 观察 | 说明 |
|---|---|
| 顺序不是声明顺序 ⚠️ | Maven 的 Reactor 会按依赖关系排序(被依赖的先构建)✓ |
| 一次命令构建全部 | 这就是"聚合"的价值 ✓ |
| 失败的模块会中止后续(默认) | 可用 --fail-at-end 让它跑完再看 ✓ |
多模块的三个实践要点:
| 要点 | 说明 |
|---|---|
父 pom 的 packaging 必须是 pom ★ | 否则无法作为父/聚合 ✓ |
| 依赖版本放父 pom 的 dependencyManagement | 子模块不写版本 ✓ |
不要把所有依赖都放父 pom 的 <dependencies> ⚠️ | 那样每个模块都会继承这些依赖——即使它不需要 ✗(造成依赖膨胀) |
最后一条是最常见的错误:
<!-- ✗ 父 pom:所有模块都会带上 spring-boot-starter-web,哪怕它只是个工具类模块 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>正确做法:父 pom 只放"所有模块都需要的"(如 JUnit、日志),其他放各自的 pom ✓
这是本节最"能立刻用上"的一节 ★
| 命令 | 看什么 |
|---|---|
mvn dependency:tree | 依赖树(谁依赖谁)✓ |
mvn dependency:tree -Dverbose ★ | 看到"被仲裁掉的依赖"(omitted for ...)✓ |
mvn dependency:tree -Dincludes=groupId:artifactId | 只看某个依赖 ✓ |
mvn help:effective-pom ★ | 看到"合并后的最终 pom"(含父 pom 与 BOM 的贡献)✓ |
mvn -pl <module> ... | 只构建某个模块 ✓ |
mvn -am -pl <module> ... | 连带构建它依赖的模块 ✓ |
mvn -o ... | 离线模式(不访问网络)✓ |
实测:-Dverbose 的输出(见 5.4):
\- (org.slf4j:slf4j-api:jar:2.0.16:compile - version managed from 2.0.15; omitted for duplicate)三种 omitted for 的含义:
| 提示 | 含义 |
|---|---|
omitted for duplicate | 重复了(已有一个同版本的)✓ |
omitted for conflict with x.y.z ★ | 版本冲突,输给了 x.y.z ⚠️ |
version managed from x ✓ | 被 dependencyManagement 统一管理了 ✓ |
看到 conflict 时该怎么办:
① 确认"赢的那个版本"是否是你想要的
② 如果不是 → 用 <dependencyManagement> 或 <exclusions> 干预
③ 验证:再跑一次 dependency:tree<exclusions>:排除不需要的传递依赖 <dependency>
<groupId>com.example</groupId>
<artifactId>some-lib</artifactId>
<version>1.0</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>什么时候用:
| 场景 | 说明 |
|---|---|
| 依赖冲突且无法用 BOM 解决 | 排除一个版本 ✓ |
| 引入了不需要的"全家桶" | 减少 jar 体积 ✓ |
| 有安全漏洞的传递依赖 | 先排除(但要确认功能不受影响)⚠️ |
⚠️ 两个风险:
| 风险 | 说明 |
|---|---|
| 排除了实际需要的类 | 运行时 ClassNotFoundException ✗ |
| 排除后没人记得为什么 | 加注释说明原因 ✓ |
实践建议:优先用 dependencyManagement 统一版本,其次才考虑 exclusions ✓ ——排除是"手术",管理是"调理" ✓
<!-- 父 pom -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement><!-- 子模块:不写版本 -->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>见第 7 节(18 个模块的实测)。
<!-- 子模块 ✗ -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.9</version> <!-- ✗ 版本散落在各模块 -->
</dependency>问题:父 pom 升级到 3.6.0 后,这个模块还是 3.5.9 ✗ → "同一个 jar 两个版本"(可能导致诡异的 NoSuchMethodError)
修复:删掉 <version>,让父 pom 管 ✓
一个例外:确实需要不同版本时(如兼容性验证)——但要写注释说明 ⚠️
本仓库 labs 就是完整的实验场:18 个模块、父 pom 管版本、多个模块用 Spring Boot BOM。
① 看多模块的构建顺序:
cd labs
.\mvnw.cmd -B -ntp validate预期输出:
[INFO] Building labs-start 1.0.0 [2/18]
[INFO] Building labs-oop-basics 1.0.0 [3/18]
[INFO] Building labs-polymorphism 1.0.0 [4/18]
[INFO] Building labs-java-syntax 1.0.0 [5/18]
[INFO] Building labs-common-classes 1.0.0 [6/18]
[INFO] Building labs-collections 1.0.0 [7/18]
[INFO] Building labs-functional 1.0.0 [8/18]
[INFO] Building labs-concurrency 1.0.0 [9/18]
[INFO] Building labs-version-features 1.0.0 [10/18]
[INFO] Building labs-testing-unit 1.0.0 [11/18]
[INFO] Building labs-testing-testng 1.0.0 [12/18]
[INFO] Building labs-sql-basics 1.0.0 [13/18]
[INFO] Building labs-sql-advanced 1.0.0 [14/18]
[INFO] Building labs-spring-core 1.0.0 [15/18]
[INFO] Building labs-spring-boot-web 1.0.0 [16/18]
[INFO] Building labs-data-access 1.0.0 [17/18]
[INFO] Building labs-web-basics 1.0.0 [18/18]② 看依赖树(传递依赖):
.\mvnw.cmd -B -ntp -pl data-access dependency:tree预期输出(节选,见 5.2):1 个 starter 带出 23 个依赖 ✓
③ 看"被仲裁掉"的依赖(version managed from):
.\mvnw.cmd -B -ntp -pl spring-boot-web dependency:tree -Dverbose预期输出(节选):
\- (org.slf4j:slf4j-api:jar:2.0.16:compile - version managed from 2.0.15; omitted for duplicate)
\- (org.springframework:spring-core:jar:6.2.1:compile - version managed from 6.2.1; omitted for duplicate)
\- (com.fasterxml.jackson.core:jackson-databind:jar:2.18.2:compile - version managed from 2.18.2; omitted for duplicate)④ 看"合并后的最终 pom":
.\mvnw.cmd -B -ntp -pl data-access help:effective-pom这能回答"这个版本到底从哪来的" ✓
三处实测的结论:
| 实测 | 结论 |
|---|---|
| 18 个模块一次构建 | 聚合(<modules>)的价值 ✓ |
| 1 个 starter → 23 个依赖 | 依赖传递 ✓ |
version managed from 2.0.15 | BOM/dependencyManagement 真的在管版本 ✓✓ |
~/.m2/repository 里找一个 jar:理解"本地仓库"的结构(groupId/artifactId/version/)✓;dependency:tree —— 观察冲突提示(omitted for conflict with)⚠️;help:effective-pom 对比父子 pom:看 BOM 到底贡献了什么 ✓;<dependencies> 与 <dependencyManagement> 互换:观察子模块依赖的变化(理解"是否真的引入")★;<parent> 但不加 <modules>:观察它是否能单独构建(理解"继承"可以独立存在)✓。NoSuchMethodError / ClassNotFoundException,但依赖写了 原因:传递依赖的版本不对(某个旧版本"赢"了)✗
排查:dependency:tree -Dverbose,找那个类所在的包,看最终版本 ✓
修复:用 dependencyManagement 锁定版本 ✓
<dependencies> 里 见 5.6 节。所有模块都被迫带上这些依赖 ✗
见 6.3 节。
scope 用错 | 错误 | 症状 |
|---|---|
该 provided 的用了 compile | 打进包后与容器自带的类冲突 ✗ |
该 runtime 的用了 compile | 代码可能不小心直接依赖了实现类(破坏可替换性)⚠️ |
该 compile 的用了 test | 生产环境 ClassNotFoundException ✗✗ |
该 test 的用了 compile | 测试框架被打进生产包 ✗ |
本项目的 h2 用 runtime 就是正确示范 ✓
mvn clean install 当成默认命令 mvn clean install # ✗ 会把快照安装到本地仓库,可能污染其他项目更好的选择:
| 场景 | 命令 |
|---|---|
| 只想编译+测试 | mvn test ✓ |
| 只想验证能构建 | mvn verify ✓ |
| 多模块且想给别的模块用 | mvn install(这时才需要)✓ |
install 的副作用:把你的快照装进 ~/.m2 ——别人(或别的项目)可能意外用到它 ⚠️
dependency:tree 就猜版本 "我觉得应该是 1.5" ✗ ——用 dependency:tree 看,比猜快 ✓
A 依赖 B,B 又依赖 A ✗Maven 会直接报错 ✓ ——这通常是设计问题(应该抽一个公共模块 C)✓
目标:把"依赖传递、版本仲裁、多模块"亲手验证一遍。
步骤:
version managed from;data-access 里显式加一个 org.slf4j:slf4j-api:2.0.15,再 dependency:tree -Dverbose —— 观察冲突提示 ⚠️;2.0.16,再跑一次 —— 观察冲突消失 ✓;-Dincludes 只看一个依赖:.\mvnw.cmd -B -ntp -pl data-access dependency:tree "-Dincludes=com.baomidou:*"install 与 test:观察 ~/.m2/repository/cn/kb/learn/ 下什么时候会多出东西 ✓;labs 里加一个 labs-demo 模块(只写父 pom + 一个 Java 类),观察它是否出现在 Reactor 里 ✓。验收方式:能说出第 2 步的冲突提示、第 3 步"为什么冲突消失了"。
扩展任务:给 labs 做一次"依赖治理":
dependency:tree 找出所有模块共用的依赖(如 JUnit);<dependencyManagement>(甚至 <dependencies>,如果所有模块都要用);<version> 出现的次数变化;基础题
<dependencyManagement> 里声明的依赖会被自动加入到所有子模块。(对 / 错)runtime 而不是 compile?代码阅读题
<!-- 父 pom -->
<project>
<groupId>cn.kb.learn</groupId>
<artifactId>labs</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>a</module>
<module>b</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.0.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</project><!-- 子模块 a -->
<project>
<parent>
<groupId>cn.kb.learn</groupId>
<artifactId>labs</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>a</artifactId>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
</dependencies>
</project>a 最终有哪些依赖(含范围)?b 也写了 guava 但指定了 32.0.0,会怎样?这带来什么隐患?编程题
jackson 版本不一致(2.13 / 2.15 / 2.18 都有); ② 有个模块引入了 spring-boot-starter-web,但它只是个工具模块; ③ 一次构建要 8 分钟,其中大部分时间在下载依赖;综合任务
core(公共工具)、domain(领域)、web(接口)、batch(批处理)四个模块;-pl / -am);提示 1(方向):第 1 题的方向是"它只是声明,不引入"。
提示 2(关键点):第 5 题——子模块写了版本,会"赢"过父 pom 的声明吗?
提示 3(接近答案):第 7 题的"构建变快"——Maven 的增量构建是"模块级"的,改一个模块只重编它(但依赖它的也要重编)。
<dependencyManagement> 只声明版本(与范围),不会引入依赖 ✓<dependencyManagement>
<dependencies>
<dependency>guava 33.0.0</dependency> <!-- 只是"如果有人用 guava,就用 33.0.0" -->
</dependencies>
</dependencyManagement>子模块必须自己写 <dependency>(不写版本)才会真的引入 ✓
这个区别的实际意义:
| 用法 | 效果 |
|---|---|
<dependencyManagement> | "版本总表" —— 子模块按需引入 ✓ |
<dependencies> | "这些都要" —— 所有子模块无条件继承 ⚠️ |
所以:父 pom 该用 dependencyManagement(而不是 dependencies)来管理版本 ✓
完整表述:
① 路径长度不同 → 短的赢
② 路径长度相同 → 先声明的赢⚠️ 一个容易忘的细节:"先声明"指的是"在你的 pom 里的先后顺序",不是"字母序"也不是"依赖树的顺序" ✓
这也是为什么要显式管理版本 ——依赖顺序是个脆弱的东西(有人调整 pom 的顺序就可能改变结果)✗
// ✓ 面向接口
Connection conn = DriverManager.getConnection(url);
// ✗ 如果驱动是 compile,你可能不小心写出这样的代码
com.mysql.cj.jdbc.Driver driver = new com.mysql.cj.jdbc.Driver(); // 与 MySQL 绑死了 ✗更深层的理由(这也解释了 JDBC 的设计):
"编译期不依赖实现"才能保证"换数据库不用改代码" ✓
如果驱动是
compile,编译器不会阻止你 import 实现类 ——你就会写出与 MySQL 绑定的代码,换库时要改一大片 ✗
runtime是一种"编译期约束" ——它让"用实现类"这件事在编译期就不可能 ✓这与 《HTTP 请求响应与 Servlet》 讲的"面向接口"、《MySQL 性能优化基础》 的"归档优于分库"是同一类思路:用结构上的约束,而不是靠人记得 ✓
第 4 题:子模块 a 有两个依赖:
| 依赖 | 范围 | 从哪来 |
|---|---|---|
guava:33.0.0 | compile(默认) | 自己声明,版本来自父 pom 的 dependencyManagement ✓ |
junit-jupiter:5.11.4 | test | 继承自父 pom 的 <dependencies> ✓ |
注意两者的来源不同 ✓ ——这正是本题的考点。
第 5 题:b 会用 32.0.0(它自己写的赢) ⚠️
规则:子模块显式写的 <version> 会覆盖父 pom 的 <dependencyManagement> ✓
这带来的隐患(正是 6.3 节的反例):
| 隐患 | 说明 |
|---|---|
| 版本漂移 ✗ | 父 pom 升级到 33.1 时,b 仍然是 32.0 ——而且没人会发现 |
| 同一 jar 两个版本 ⚠️ | 如果 a 与 b 最终被打进同一个包,classpath 上会有两个 guava ——用哪个取决于类加载顺序 ✗ |
| 诡异的运行时错误 ✗✗ | 典型表现:NoSuchMethodError ——编译是过的(各自用自己的版本都合法),运行时才炸 |
这就是为什么"运行时才暴露"(对应编程题 6 的最后一问):
编译时:a 用 guava 33 编译 ✓(方法 M 存在)
b 用 guava 32 编译 ✓(它没用到 M)
各自都过 ✓
运行时:classpath 上只有一个 guava 生效(假设是 32)
a 调用 M → NoSuchMethodError ✗✗编译期是"各自为政"的,运行期是"共享 classpath"的 ✓
所以"版本不一致"必然是运行时问题 ——这也正是它难以排查的原因(本地可能没事,生产才炸)✗
怎么防:
| 手段 | 说明 |
|---|---|
子模块不写 <version> ★ | 让父 pom 统一管理 ✓ |
| CI 检查 | 用 mvn dependency:tree 或 versions-maven-plugin 检查版本一致性 ✓ |
mvn enforcer:enforce ★★ | Maven Enforcer 插件能强制"依赖收敛"(DependencyConvergence 规则)——版本不一致直接构建失败 ✓✓ |
Enforcer 的配置示例(很实用):
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>enforce-versions</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<dependencyConvergence/> <!-- ★ 版本必须收敛 -->
<requireMavenVersion><version>[3.8,)</version></requireMavenVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>这就是"把约定变成机制"(再次呼应 《Git Flow 与团队协作》 的分支保护原则)✓
① 统一 jackson 版本
方案:导入 Jackson 的 BOM(最优):
<!-- 父 pom -->
<dependencyManagement>
<dependencies>
<!-- 如果已经用了 Spring Boot 的 BOM,jackson 版本已经管好了 —— 先确认这一点 ✓ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.4.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 如果需要覆盖,可以再显式声明(放在 BOM 之后,后者覆盖前者) -->
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>2.18.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>然后:删掉各模块里 jackson 的 <version> ✓
验证:
mvn dependency:tree -Dverbose | grep jackson
# 所有 jackson 都应该是 2.18.2,"omitted for conflict" 消失 ✓② 发现并处理"工具模块引了 starter-web"
怎么发现:
mvn -pl <工具模块> dependency:tree | grep spring-boot-starter-web或:看它的 jar 体积(引入 starter-web 会让 fat jar 大十几 MB)⚠️
怎么修:
| 情况 | 做法 |
|---|---|
| 它真的不需要 | 删掉这个依赖 ✓ |
| 只需要其中一部分(如 Jackson) | 改成只引 jackson-databind ✓ |
| 只需要注解/工具类 | 只引 spring-web 或 spring-core ✓ |
原则:"引你需要的,而不是引一个方便的" ✓
"全家桶依赖"是依赖膨胀的头号原因 ⚠️
判断方法:看你的代码 import 了什么 ——只声明"你 import 过的" ✓
可以用
mvn dependency:analyze找出"声明了但没用"与"用了但没声明"的依赖 ✓✓
③ 构建加速(三种以上)
| 方案 | 做法 | 适用 |
|---|---|---|
| ① 并行构建 ★ | mvn -T 1C install(每个 CPU 核一个线程) | 多模块 ✓(本项目 18 个模块收益明显) |
| ② 只构建需要的 ★ | mvn -pl <module> -am(只构建该模块及其依赖) | 日常开发 ✓ |
| ③ 跳过不需要的阶段 | -DskipTests(不跑测试)/ -Dmaven.test.skip(连编译都不做) | 快速验证 ⚠️(别养成习惯 ✗) |
| ④ 离线模式 | mvn -o(不检查远程更新) | 依赖已稳定时 ✓ |
| ⑤ 本地仓库/私服 | Nexus / Artifactory 私服 | 团队级(避免每人重复下载)✓✓ |
| ⑥ CI 缓存 | 缓存 ~/.m2/repository | CI 环境 ✓ |
| ⑦ 依赖少而精 | 去掉"全家桶" | 长期 ✓ |
最有效的组合:① + ② + ⑤(并行 + 增量 + 私服)✓
⚠️ 一个提醒:-T 并行构建要求模块间依赖关系清晰 ——有循环依赖或插件不兼容时会失败 ⚠️
为什么"版本不一致"会在运行时才暴露
见基础题第 5 题的答案:编译期各自为政,运行期共享 classpath ✓
补充一个更具体的例子:
模块 A:用了 guava 33 的新方法 Lists.newArrayList(...)
模块 B:只用了 guava 32 就有的方法
编译:都过 ✓
打包:classpath 上只有一份 guava(Maven 会选一个,比如 32)
运行:A 调用新方法 → NoSuchMethodError ✗✗
而且——如果 Maven 恰好选了 33,B 也能跑(33 向下兼容 32 的 API)
→ 这就变成"时好时坏"的问题 ⚠️⚠️ 极难排查这类问题的三个特征(遇到时往这个方向想):
| 特征 | 说明 |
|---|---|
| 编译能过,运行炸 | 典型的版本冲突 ✓ |
| 本地能跑,别人/生产炸 | classpath 顺序或依赖解析结果不同 ✗ |
报的是 NoSuchMethodError / NoClassDefFoundError ★ | 而不是 ClassNotFoundException ——前者是"类在但版本不对",后者是"类根本不在" ✓ |
模块划分与依赖
┌─────────────┐
│ core │ 公共工具(无业务):日期、字符串、常量、异常基类
└──────┬──────┘
│
┌──────▼──────┐
│ domain │ 领域模型与业务逻辑(依赖 core)
└──┬───────┬──┘
│ │
┌───────▼──┐ ┌──▼──────┐
│ web │ │ batch │ 两个"入口/适配器"(都依赖 domain)
└──────────┘ └─────────┘规则:
| 规则 | 说明 |
|---|---|
| 依赖方向单向 ★ | web → domain → core,不能反向 ✓ |
core 不依赖任何业务模块 | 它是"纯工具" ✓ |
domain 不知道 web/batch 的存在 ★ | 业务逻辑不该知道"谁在调用它" ✓ |
| 两个入口互不依赖 | 否则就该抽公共部分到 domain 或 core ✓ |
这与 《Spring MVC 请求映射与参数绑定》 的"分层"(Controller / Service / Repository)是同一套思路的"模块级版本" ✓ ——依赖只能从上往下 ✓
父 pom 该放什么
| 放 | 不放 |
|---|---|
<dependencyManagement>(所有依赖的版本)✓ | 业务依赖(会被所有模块继承)✗ |
插件配置与版本(maven-compiler-plugin 等)✓ | 某个模块特有的插件 ⚠️ |
<properties>(Java 版本、编码、版本号)✓ | — |
| 所有模块都用的依赖(JUnit、日志 API)✓ | — |
<modules> ✓ | — |
一个判断标准:
"这个东西,是否每个模块都需要且版本必须一致?"
- 是 → 父 pom 的
<dependencies>(或至少 dependencyManagement)✓- 否 → 各模块自己声明 ✓
版本管理策略
| 策略 | 说明 | 适用 |
|---|---|---|
| 统一版本(推荐)★ | 所有模块用同一个版本号(如都是 1.2.0)✓ | 一起发布的项目 ✓ |
| 独立版本 | 各模块有自己的版本 | 独立发布的库(如 core 单独发版)⚠️ |
推荐统一版本,理由:
| 理由 | 说明 |
|---|---|
| 简单 | 只有一个版本号要管 ✓ |
| 不容易错配 | 不会出现"core 1.0 + web 2.0"这种组合 ✗ |
| 发布清晰 | "我们发布了 1.2.0" ✓ |
统一管理的方式:
<!-- 父 pom -->
<version>1.2.0</version>
<!-- 子模块 -->
<parent>
<groupId>cn.kb.learn</groupId>
<artifactId>myapp</artifactId>
<version>1.2.0</version> <!-- ★ 与父 pom 一致 -->
</parent>
<artifactId>myapp-web</artifactId>
<!-- 子模块不写 <version>,继承父 pom 的 ✓ -->更简单:子模块完全不写 <version>,直接继承 ✓
拆分后构建会不会变快
会,但要满足条件 ✓
| 机制 | 说明 |
|---|---|
| 模块级增量 ★ | 改 web 只重编 web(core、domain、batch 用缓存)✓ |
| 并行构建 | -T 1C 让四个模块并行 ✓ |
| 只构建需要的 | -pl web -am:只构建 web 及其依赖 ✓ |
前提条件 ⚠️:
| 条件 | 说明 |
|---|---|
| 模块间依赖要清晰 | 否则改一个模块会触发大量重编 ✗ |
| 不要"改 core 就等于改全部" ⚠️ | 如果 core 天天改,增量就没用了 ——这说明 core 的内容不稳定(可能该拆分或下沉)✗ |
量化:如果日常开发只改 1 个模块,构建时间大致是"该模块时间 + 少量依赖检查",而不是全量 ✓
拆分带来的新问题
| # | 问题 | 应对 |
|---|---|---|
| 1 | 循环依赖的风险 | 用 Enforcer 或 ArchUnit 检查(模块间不能有环)✓ |
| 2 | 模块间 API 的边界 ⚠️ | domain 的哪些类可以被 web 用?——用"包可见性 + 显式 API"约束;不要让 web 直接改 domain 的内部状态 ✓ |
| 3 | 部署单元变化 | 原来一个 jar,现在可能要分别部署(web 是服务、batch 是定时任务)✓ |
| 4 | 版本/发布协调 | 统一版本可以缓解 ✓ |
| 5 | 构建配置变复杂 | 父 pom 要设计好,避免每个模块重复配置 ✓ |
| 6 | "拆错了"的代价 ✗ | 模块边界一旦固化就很难改 ——宁可先粗后细 ✓ |
第 2 条值得展开:
"模块边界"本质是"API 契约" ——domain 暴露什么,web 才能用什么 ✓
如果没有约束,web 会"伸手进 domain 的内部"(改个字段、调个内部方法)——这就是"模块化失败" ✗
可以用 T1-018 的思路做"架构测试":用 ArchUnit 写测试,禁止 web 依赖 domain 的 internal 包 ✓✓
如果拆分后构建反而变慢了
| 原因 | 说明 |
|---|---|
| ① 模块粒度太细 ✗ | 模块太多 → Reactor 开销 > 增量收益(几十个模块的协调成本)⚠️ |
| ② 依赖"底层"的模块太多 | 改 core 就要重编所有模块 ——增量失效 ✗ |
| ③ 每个模块都跑全部测试 ⚠️ | 没有按模块划分测试 → 改一个模块也跑全套 ✗ |
| ④ 插件在每个模块重复执行 | 如每个模块都做 shade / 打包 / 静态检查 ⚠️ |
⑤ 没有用 -T 与 -pl -am | 仍然是全量串行 ✓(最容易修) |
| ⑥ 依赖下载变多 | 模块多了,依赖解析次数增加 ⚠️ |
排查方法:
mvn -X ... # 看每个阶段耗时
# 或
mvn ... -Dorg.slf4j.simpleLogger.showDateTime=true # 带时间戳结论:
拆分的收益不是"自动"的 ——它依赖"模块边界稳定"与"增量能被利用" ✓
如果 core 天天变,或者模块切得太碎,拆分反而会拖慢 ✗
这与 《MySQL 性能优化基础》 的"归档优于分库分表"是同一类判断:先做成本低、收益明确的(整理依赖、模块粒度合理、用
-T/-pl),不要一上来就追求"完美的模块划分" ✓
<dependencyManagement> 与 <dependencies> 的区别,并会用它统一版本;<type>pom</type> + <scope>import</scope> 是导入 BOM 的标准写法;provided / runtime / test);-Dverbose 的输出(三种 omitted for 的含义)★;dependency:tree / help:effective-pom / -pl -am 排查问题 ✓;mvn clean install 的副作用。《代码规范与自动校验》:把"风格"交给工具,把 review 时间留给逻辑与安全。