Skip to content

Maven 进阶:依赖管理与多模块 ​

S3-028建议学进阶约 90 分钟JDK 21企业开发基础 · 工程工具与规范

1. 本节定位 ​

《Maven 基础》 讲了"能构建起来":一个 pom、几个依赖、mvn test。

本节讲"构建得对、管得住":

text
能跑:      pom.xml 里写几个 <dependency>          (S0-003)
管得住:    版本从哪来?冲突怎么解?20 个模块怎么组织?(本节)

一个判断你是否需要的信号:

如果你曾经遇到过"java.lang.NoSuchMethodError"或"ClassNotFoundException,但依赖明明写了" ——那就是本节要解决的问题 ✓

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 Maven 坐标(GAV)的三段含义与依赖查找顺序;
  • 说出"依赖传递"的机制,以及它带来的两个问题(版本冲突、依赖膨胀);
  • 说出依赖调解的两条规则(最短路径优先、声明优先);
  • 说出 <dependencyManagement> 与 <dependencies> 的区别,并知道为什么要用它;
  • 说出六种依赖范围(scope)及各自的可见性;
  • 说出"继承"与"聚合"在多模块里各自的职责;
  • 会用 dependency:tree / -Dverbose / help:effective-pom 排查依赖问题 ★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看一个真实的现象:

text
你在 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)——本节的重点是让你看懂它的规则,以及知道怎么覆盖它 ✓

5. 核心概念 ​

5.1 坐标(GAV) ​

xml
<dependency>
  <groupId>com.baomidou</groupId>          <!-- 组织/项目 -->
  <artifactId>mybatis-plus-jsqlparser</artifactId>   <!-- 模块 -->
  <version>3.5.9</version>                 <!-- 版本 -->
</dependency>

三段合起来唯一确定一个 jar ✓

查找顺序(记住这个,能解决"依赖下载不下来"的一半问题):

text
① 本地仓库 ~/.m2/repository
② 配置的远程仓库(pom / settings.xml)
③ Maven 中央仓库(默认兜底)

本项目的做法(《Maven 基础》):labs/.mvn/settings.xml 里配了阿里云镜像,不改动本机用户级配置 ✓

5.2 依赖传递 ​

机制:如果你的依赖 A 依赖 B,那么 B 也会自动成为你的依赖 ✓

实测(mvn -pl data-access dependency:tree):

text
+- 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 的类,必须显式声明它的依赖" ✓

不要依赖"传递" ——传递依赖是上游的实现细节,随时可能变 ⚠️

5.3 依赖调解(Mediation) ​

问题:同一个依赖出现了多个版本,用哪个?

Maven 的两条规则(按这个顺序判断):

顺序规则说明
①最短路径优先传递链越短越优先
②声明优先 ★路径一样长时,先声明的优先

举例:

text
你的项目
  ├─ 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)✓

5.4 <dependencyManagement>:把版本管起来 ​

这是本节最核心的一节 ✓

两种写法的区别:

<dependencies><dependencyManagement>
是否真的引入依赖✅ 是❌ 否(只是"声明版本")✓

所以:

xml
<!-- 父 pom:只声明版本,不引入 -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.baomidou</groupId>
      <artifactId>mybatis-plus-jsqlparser</artifactId>
      <version>3.5.9</version>
    </dependency>
  </dependencies>
</dependencyManagement>
xml
<!-- 子模块:引入,但**不写版本**(版本由父 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):

text
\- (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 替你统一了版本 ✓

这就是"不用写版本"的真相:

xml
<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):

xml
<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"的标准做法 ✓

5.5 依赖范围(scope) ​

六种范围(记住这张表,能解决"为什么测试里能跑、运行时报 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):

xml
<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>runtime</scope>        <!-- ★ 编译不用它,运行才需要 -->
</dependency>

实测:依赖树里显示 com.h2database:h2:jar:2.3.232:runtime ✓

5.6 多模块:继承 vs 聚合 ​

这是本节第二个重点 ✓ ——很多人把这两件事混为一谈。

继承(<parent>)聚合(<modules>)
做什么复用配置(依赖版本、插件、属性)一起构建(一次命令构建多个模块)
写法子 pom 里 <parent>父 pom 里 <modules>
方向子 → 父(子引用父)父 → 子(父列出子)
可以只用其一吗✅ 可以✅ 可以

本项目的 labs/pom.xml 两者都用了 ✓

实测:18 个模块的一次构建(mvn validate):

text
[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> ⚠️那样每个模块都会继承这些依赖——即使它不需要 ✗(造成依赖膨胀)

最后一条是最常见的错误:

xml
<!-- ✗ 父 pom:所有模块都会带上 spring-boot-starter-web,哪怕它只是个工具类模块 -->
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
</dependencies>

正确做法:父 pom 只放"所有模块都需要的"(如 JUnit、日志),其他放各自的 pom ✓

5.7 常用排查命令 ​

这是本节最"能立刻用上"的一节 ★

命令看什么
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):

text
\- (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 时该怎么办:

text
① 确认"赢的那个版本"是否是你想要的
② 如果不是 → 用 <dependencyManagement> 或 <exclusions> 干预
③ 验证:再跑一次 dependency:tree

5.8 <exclusions>:排除不需要的传递依赖 ​

xml
<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 ✓ ——排除是"手术",管理是"调理" ✓

6. 递进示例 ​

6.1 最小示例:父 pom 管版本 ​

xml
<!-- 父 pom -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.11.4</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
xml
<!-- 子模块:不写版本 -->
<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
  </dependency>
</dependencies>

6.2 业务示例:本项目的多模块 ​

见第 7 节(18 个模块的实测)。

6.3 反例:在子模块里写死版本 ​

xml
<!-- 子模块 ✗ -->
<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 管 ✓

一个例外:确实需要不同版本时(如兼容性验证)——但要写注释说明 ⚠️

7. 完整可运行命令与实测输出 ​

本仓库 labs 就是完整的实验场:18 个模块、父 pom 管版本、多个模块用 Spring Boot BOM。

① 看多模块的构建顺序:

powershell
cd labs
.\mvnw.cmd -B -ntp validate

预期输出:

text
[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]

② 看依赖树(传递依赖):

powershell
.\mvnw.cmd -B -ntp -pl data-access dependency:tree

预期输出(节选,见 5.2):1 个 starter 带出 23 个依赖 ✓

③ 看"被仲裁掉"的依赖(version managed from):

powershell
.\mvnw.cmd -B -ntp -pl spring-boot-web dependency:tree -Dverbose

预期输出(节选):

text
\- (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":

powershell
.\mvnw.cmd -B -ntp -pl data-access help:effective-pom

这能回答"这个版本到底从哪来的" ✓

三处实测的结论:

实测结论
18 个模块一次构建聚合(<modules>)的价值 ✓
1 个 starter → 23 个依赖依赖传递 ✓
version managed from 2.0.15BOM/dependencyManagement 真的在管版本 ✓✓

8. Debug 观察点 ​

  1. 在 ~/.m2/repository 里找一个 jar:理解"本地仓库"的结构(groupId/artifactId/version/)✓;
  2. 故意把某个依赖的版本改旧,再 dependency:tree —— 观察冲突提示(omitted for conflict with)⚠️;
  3. help:effective-pom 对比父子 pom:看 BOM 到底贡献了什么 ✓;
  4. 把父 pom 的 <dependencies> 与 <dependencyManagement> 互换:观察子模块依赖的变化(理解"是否真的引入")★;
  5. 给一个模块加 <parent> 但不加 <modules>:观察它是否能单独构建(理解"继承"可以独立存在)✓。

9. 常见错误 ​

错误 1:NoSuchMethodError / ClassNotFoundException,但依赖写了 ​

原因:传递依赖的版本不对(某个旧版本"赢"了)✗

排查:dependency:tree -Dverbose,找那个类所在的包,看最终版本 ✓

修复:用 dependencyManagement 锁定版本 ✓

错误 2:把依赖都写在父 pom 的 <dependencies> 里 ​

见 5.6 节。所有模块都被迫带上这些依赖 ✗

错误 3:子模块写死版本,绕过父 pom 管理 ​

见 6.3 节。

错误 4:scope 用错 ​

错误症状
该 provided 的用了 compile打进包后与容器自带的类冲突 ✗
该 runtime 的用了 compile代码可能不小心直接依赖了实现类(破坏可替换性)⚠️
该 compile 的用了 test生产环境 ClassNotFoundException ✗✗
该 test 的用了 compile测试框架被打进生产包 ✗

本项目的 h2 用 runtime 就是正确示范 ✓

错误 5:mvn clean install 当成默认命令 ​

bash
mvn clean install        # ✗ 会把快照安装到本地仓库,可能污染其他项目

更好的选择:

场景命令
只想编译+测试mvn test ✓
只想验证能构建mvn verify ✓
多模块且想给别的模块用mvn install(这时才需要)✓

install 的副作用:把你的快照装进 ~/.m2 ——别人(或别的项目)可能意外用到它 ⚠️

错误 6:不看 dependency:tree 就猜版本 ​

"我觉得应该是 1.5" ✗ ——用 dependency:tree 看,比猜快 ✓

错误 7:多模块里模块间循环依赖 ​

text
A 依赖 B,B 又依赖 A ✗

Maven 会直接报错 ✓ ——这通常是设计问题(应该抽一个公共模块 C)✓

10. 动手实验 ​

目标:把"依赖传递、版本仲裁、多模块"亲手验证一遍。

步骤:

  1. 按第 7 节跑三条命令,核对 18 个模块与 version managed from;
  2. 制造一次版本冲突:在 data-access 里显式加一个 org.slf4j:slf4j-api:2.0.15,再 dependency:tree -Dverbose —— 观察冲突提示 ⚠️;
  3. 用 dependencyManagement 解决它:在父 pom 里锁定 2.0.16,再跑一次 —— 观察冲突消失 ✓;
  4. 用 -Dincludes 只看一个依赖:
    powershell
    .\mvnw.cmd -B -ntp -pl data-access dependency:tree "-Dincludes=com.baomidou:*"
  5. 对比 install 与 test:观察 ~/.m2/repository/cn/kb/learn/ 下什么时候会多出东西 ✓;
  6. 新建一个模块:在 labs 里加一个 labs-demo 模块(只写父 pom + 一个 Java 类),观察它是否出现在 Reactor 里 ✓。

验收方式:能说出第 2 步的冲突提示、第 3 步"为什么冲突消失了"。

扩展任务:给 labs 做一次"依赖治理":

  • 用 dependency:tree 找出所有模块共用的依赖(如 JUnit);
  • 把它们提到父 pom 的 <dependencyManagement>(甚至 <dependencies>,如果所有模块都要用);
  • 统计:治理前后,各 pom 里 <version> 出现的次数变化;
  • 说明:为什么"版本只写一处"能减少一类 bug?

11. 练习题 ​

基础题

  1. 判断:<dependencyManagement> 里声明的依赖会被自动加入到所有子模块。(对 / 错)
  2. 填空:依赖调解的两条规则是 ______ 与 ______;其中"路径一样长"时用第 ______ 条。
  3. 简答:为什么数据库驱动通常用 runtime 而不是 compile?

代码阅读题

xml
<!-- 父 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>
xml
<!-- 子模块 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>
  1. 子模块 a 最终有哪些依赖(含范围)?
  2. 如果子模块 b 也写了 guava 但指定了 32.0.0,会怎样?这带来什么隐患?

编程题

  1. 设计一个"依赖治理方案"并落地:
    • 场景:一个 12 模块的项目,出现三个问题—— ① 各模块 jackson 版本不一致(2.13 / 2.15 / 2.18 都有); ② 有个模块引入了 spring-boot-starter-web,但它只是个工具模块; ③ 一次构建要 8 分钟,其中大部分时间在下载依赖;
    • 要求:
      • 针对①:用 BOM 或 dependencyManagement 统一(给出具体配置);
      • 针对②:说明怎么发现它、怎么修;
      • 针对③:给出至少三种加速方案并说明各自的适用场景;
    • 回答:为什么"各模块依赖版本不一致"会在运行时才暴露(而不是编译期)?

综合任务

  1. 为一个"从单模块到多模块"的拆分做方案:
    • 现状:一个单体项目,300 个类,构建要 5 分钟,团队 8 人;
      • 想拆成 core(公共工具)、domain(领域)、web(接口)、batch(批处理)四个模块;
    • 要求:
      • 设计模块划分与依赖关系(谁依赖谁,为什么);
      • 说明父 pom 该放什么、不该放什么;
      • 设计版本管理策略(各模块版本号一致还是独立?);
      • 说明拆分后构建会不会真的变快(提示:Reactor 的增量构建、-pl / -am);
      • 说明拆分带来的新问题(提示:循环依赖的风险、模块间 API 的边界、部署单元变化);
    • 额外要求:说明"如果拆分后构建反而变慢了,可能是什么原因?"
分级提示

提示 1(方向):第 1 题的方向是"它只是声明,不引入"。

提示 2(关键点):第 5 题——子模块写了版本,会"赢"过父 pom 的声明吗?

提示 3(接近答案):第 7 题的"构建变快"——Maven 的增量构建是"模块级"的,改一个模块只重编它(但依赖它的也要重编)。

12. 参考答案 ​

基础题
  1. 错。<dependencyManagement> 只声明版本(与范围),不会引入依赖 ✓
xml
<dependencyManagement>
  <dependencies>
    <dependency>guava 33.0.0</dependency>     <!-- 只是"如果有人用 guava,就用 33.0.0" -->
  </dependencies>
</dependencyManagement>

子模块必须自己写 <dependency>(不写版本)才会真的引入 ✓

这个区别的实际意义:

用法效果
<dependencyManagement>"版本总表" —— 子模块按需引入 ✓
<dependencies>"这些都要" —— 所有子模块无条件继承 ⚠️

所以:父 pom 该用 dependencyManagement(而不是 dependencies)来管理版本 ✓

  1. 最短路径优先;声明优先;二(声明优先)。

完整表述:

text
① 路径长度不同 → 短的赢
② 路径长度相同 → 先声明的赢

⚠️ 一个容易忘的细节:"先声明"指的是"在你的 pom 里的先后顺序",不是"字母序"也不是"依赖树的顺序" ✓

这也是为什么要显式管理版本 ——依赖顺序是个脆弱的东西(有人调整 pom 的顺序就可能改变结果)✗

  1. 因为代码只应该依赖 JDBC 接口,不应依赖具体的驱动实现 ✓
java
// ✓ 面向接口
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、5 题)

第 4 题:子模块 a 有两个依赖:

依赖范围从哪来
guava:33.0.0compile(默认)自己声明,版本来自父 pom 的 dependencyManagement ✓
junit-jupiter:5.11.4test继承自父 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 的最后一问):

text
编译时: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 的配置示例(很实用):

xml
<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 与团队协作》 的分支保护原则)✓

编程题(第 6 题)

① 统一 jackson 版本

方案:导入 Jackson 的 BOM(最优):

xml
<!-- 父 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> ✓

验证:

bash
mvn dependency:tree -Dverbose | grep jackson
# 所有 jackson 都应该是 2.18.2,"omitted for conflict" 消失 ✓

② 发现并处理"工具模块引了 starter-web"

怎么发现:

bash
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/repositoryCI 环境 ✓
⑦ 依赖少而精去掉"全家桶"长期 ✓

最有效的组合:① + ② + ⑤(并行 + 增量 + 私服)✓

⚠️ 一个提醒:-T 并行构建要求模块间依赖关系清晰 ——有循环依赖或插件不兼容时会失败 ⚠️

为什么"版本不一致"会在运行时才暴露

见基础题第 5 题的答案:编译期各自为政,运行期共享 classpath ✓

补充一个更具体的例子:

text
模块 A:用了 guava 33 的新方法 Lists.newArrayList(...)
模块 B:只用了 guava 32 就有的方法

编译:都过 ✓
打包:classpath 上只有一份 guava(Maven 会选一个,比如 32)
运行:A 调用新方法 → NoSuchMethodError ✗✗

而且——如果 Maven 恰好选了 33,B 也能跑(33 向下兼容 32 的 API)
→ 这就变成"时好时坏"的问题 ⚠️⚠️ 极难排查

这类问题的三个特征(遇到时往这个方向想):

特征说明
编译能过,运行炸典型的版本冲突 ✓
本地能跑,别人/生产炸classpath 顺序或依赖解析结果不同 ✗
报的是 NoSuchMethodError / NoClassDefFoundError ★而不是 ClassNotFoundException ——前者是"类在但版本不对",后者是"类根本不在" ✓
综合任务(第 7 题)

模块划分与依赖

text
        ┌─────────────┐
        │    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" ✓

统一管理的方式:

xml
<!-- 父 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仍然是全量串行 ✓(最容易修)
⑥ 依赖下载变多模块多了,依赖解析次数增加 ⚠️

排查方法:

bash
mvn -X ...                    # 看每个阶段耗时
# 或
mvn ... -Dorg.slf4j.simpleLogger.showDateTime=true      # 带时间戳

结论:

拆分的收益不是"自动"的 ——它依赖"模块边界稳定"与"增量能被利用" ✓

如果 core 天天变,或者模块切得太碎,拆分反而会拖慢 ✗

这与 《MySQL 性能优化基础》 的"归档优于分库分表"是同一类判断:先做成本低、收益明确的(整理依赖、模块粒度合理、用 -T/-pl),不要一上来就追求"完美的模块划分" ✓

13. 掌握标准 ​

  • [ ] 能说出 GAV 坐标与依赖查找顺序;
  • [ ] 知道"传递依赖"及其两个问题(版本冲突、膨胀);
  • [ ] 能说出依赖调解的两条规则;
  • [ ] 能说出 <dependencyManagement> 与 <dependencies> 的区别,并会用它统一版本;
  • [ ] 知道 <type>pom</type> + <scope>import</scope> 是导入 BOM 的标准写法;
  • [ ] 能说出六种 scope 的可见性(尤其 provided / runtime / test);
  • [ ] 能说出"继承"与"聚合"的区别;
  • [ ] 会读 -Dverbose 的输出(三种 omitted for 的含义)★;
  • [ ] 会用 dependency:tree / help:effective-pom / -pl -am 排查问题 ✓;
  • [ ] 知道 mvn clean install 的副作用。

14. 扩展资源 ​

15. 下一步学习 ​

《代码规范与自动校验》:把"风格"交给工具,把 review 时间留给逻辑与安全。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引