本节目标:能读懂
mvn dependency:tree,判断一个依赖该用哪种 scope,遇到版本冲突时知道 Maven 选了谁、为什么,以及怎样把版本收敛到一处。
适用版本:JDK 25 LTS(Temurin 25.0.5)、Maven 3.9.12。
从第 16 章留下的测试依赖说起
第 16 章给 library 加了 JUnit 5 测试。当时只写了三行坐标,测试就能跑;但把项目交给同事时,问题会变成另一副样子:他拉下代码,mvn test 下载了一组他没有声明过的 jar,版本还和你机器上的不完全一样。测试通过与否,开始取决于「谁的本地仓库里恰好有什么」。
这不是 Maven 的缺陷,而是依赖管理的常态。一个项目真正运行的类,往往只有一部分是自己写的;其余来自 JDK、构建插件和第三方库。依赖管理要回答三个问题:
- 需要哪些 jar:坐标是什么,版本从哪里来。
- 它们分别在什么阶段可见:编译、测试、运行、打包,classpath 并不相同。
- 同一份依赖出现多个版本时听谁的:冲突由谁裁决,怎么固定。
library 卷一只有 JUnit 一个第三方依赖,正好适合把这三件事看清楚,而不被几十个传递依赖淹没。
坐标、传递依赖与依赖树
Maven 用三段坐标定位一个构件:groupId:artifactId:version。library 自己的坐标是:
com.plume:library:0.1.0
JUnit 的坐标是 org.junit.jupiter:junit-jupiter:5.11.4。只写这一条,Maven 实际会解析出一整棵树,因为 junit-jupiter 自己还依赖 junit-jupiter-api、junit-jupiter-engine、junit-platform-commons 等构件。这些叫传递依赖(transitive dependency)。
在 library 根目录执行:
mvn dependency:tree
本机 JDK 25 + Maven 3.9.12 输出(节选):
[INFO] com.plume:library:jar:0.1.0
[INFO] \- org.junit.jupiter:junit-jupiter:jar:5.11.4:test
[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:5.11.4:test
[INFO] | +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] | +- org.junit.platform:junit-platform-commons:jar:1.11.4:test
[INFO] | \- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] +- org.junit.jupiter:junit-jupiter-params:jar:5.11.4:test
[INFO] \- org.junit.jupiter:junit-jupiter-engine:jar:5.11.4:test
[INFO] \- org.junit.platform:junit-platform-engine:jar:1.11.4:test
树里每一行末尾的 :test 是作用域。它告诉我们:这七个 jar 只在测试阶段出现,不会进入最终的 library.jar。稍后会验证这一点。
树的结构也值得注意。junit-jupiter 是直接依赖,缩进一级的是它的直接依赖,再缩进一级的是传递依赖。你只声明了一个坐标,Maven 却解析出七个构件,这就是依赖治理要从树开始看的原因。
六种作用域,实际常用四种
Maven 的作用域(scope)决定一个依赖在哪些 classpath 上可见、是否参与打包、是否继续传递。常用的是下面四种:
| 作用域 | 编译主代码 | 编译测试 | 运行 | 打进 jar | 传递给下游 |
|---|---|---|---|---|---|
compile | 是 | 是 | 是 | 是 | 是 |
provided | 是 | 是 | 否 | 否 | 否 |
runtime | 否 | 是 | 是 | 是 | 是 |
test | 否 | 是 | 否 | 否 | 否 |
另外两个作用域在现代项目里很少用:system 需要写死本地路径,已被废弃;import 只出现在 dependencyManagement 里,用来导入一个 BOM,本身不引入 jar。
怎么选,可以用一句话判断:主代码编译时要不要直接 import 这个库的类?运行时环境提不提供它?
- 主代码直接 import,运行时要带着走:
compile。卷二的 JSON 库、HTTP 客户端会落在这里。 - 主代码直接 import,但运行时由容器提供:
provided。典型是 Servlet API,应用服务器自己有,打成 jar 反而会冲突。 - 主代码不 import,运行时才需要:
runtime。典型是 JDBC 驱动,代码里只出现java.sql接口,驱动类由DriverManager反射加载。 - 只有测试代码 import:
test。JUnit、AssertJ、Testcontainers 都属于这一类。
library 的 JUnit 必须是 test。把它写成 compile,测试框架就会被打进发布产物,既增大体积,也可能和运行环境里的版本打架。
把版本收敛到一处
library 的 pom.xml 没有直接给 JUnit 写版本,而是先导入 junit-bom:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.11.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
junit-bom 是一份「版本清单」,它把 junit-jupiter、junit-jupiter-api、junit-platform-commons 等构件的版本统一写成 5.11.4。<scope>import</scope> 只在这个清单里生效:它不会把 BOM 变成项目的依赖,只是把清单内容合并进 dependencyManagement。
这样做的好处是版本只有一个来源。以后把 BOM 从 5.11.4 升到 5.12.x,JUnit 全家桶一起升级,不会出现「jupiter 是 5.12、platform 还是 5.11」的错配。即使某个传递依赖自己想拉旧版本,dependencyManagement 也会把它压回清单里的版本。
如果没有 BOM,直接把版本写在 <dependency> 里也能工作,但每多加一个 JUnit 构件就要重复一次版本号。项目越大,重复越容易漂移。
冲突从哪里来,Maven 怎么选
冲突的根源是「同一个坐标出现了多个版本」。比如 A 依赖 commons-lang3:3.12.0,B 依赖 commons-lang3:3.14.0,你的项目同时依赖 A 和 B,classpath 上只能留一个。
Maven 的默认裁决规则有两条:
- 路径最短者优先:直接写在
<dependencies>里的版本,胜过任何传递依赖带来的版本。 - 路径一样长时,先声明者优先:同在第二层,
pom.xml里写在前面的那个依赖带来的版本胜出。
dependencyManagement 的优先级高于这两条:只要清单里写了版本,传递依赖就得服从。
为了看清这个过程,用一个最小实验项目 dep-demo,先引入 junit-jupiter:5.11.4,再直接声明旧版 junit-platform-commons:1.6.3:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-commons</artifactId>
<version>1.6.3</version>
<scope>test</scope>
</dependency>
执行:
mvn dependency:tree -Dverbose
本机输出(节选):
[INFO] +- org.junit.jupiter:junit-jupiter:jar:5.11.4:test
[INFO] | +- org.junit.jupiter:junit-jupiter-api:jar:5.11.4:test
[INFO] | | +- (org.junit.platform:junit-platform-commons:jar:1.11.4:test - omitted for conflict with 1.6.3)
[INFO] | | \- (org.apiguardian:apiguardian-api:jar:1.1.2:test - omitted for conflict with 1.1.0)
[INFO] \- org.junit.platform:junit-platform-commons:jar:1.6.3:test (scope not updated to test)
omitted for conflict with 1.6.3 的意思是:1.11.4 被 1.6.3 顶掉了。原因不是 1.6.3 更新,而是它离根更近——它是直接依赖,1.11.4 只是传递依赖。Maven 不会比较版本高低,只比较路径远近。
这份 classpath 能编译,却不一定能运行。junit-jupiter 5.11.4 的引擎会调用 junit-platform-commons 1.11.4 才有的类。本机执行 mvn test 的真实报错(节选):
[ERROR] TestEngine with ID 'junit-jupiter' failed to discover tests
...
Caused by: java.lang.NoClassDefFoundError: org/junit/platform/commons/util/UnrecoverableExceptions
...
Caused by: java.lang.ClassNotFoundException: org.junit.platform.commons.util.UnrecoverableExceptions
这就是依赖冲突最典型的表现:编译期一切正常,运行期才炸,报错还指向一个你没直接写过的类。 遇到 NoClassDefFoundError 或 NoSuchMethodError,第一反应应该是 mvn dependency:tree -Dverbose,而不是怀疑 JDK。
作用域写错的两种真实后果
作用域不是注释,它会直接改变 classpath。下面两个错误在项目里很常见。
把 test 依赖写进主代码。 新建一个 Main,import org.junit.jupiter.api.Test,但 JUnit 仍是 test scope。执行 mvn compile:
[ERROR] /private/tmp/java_agent_f/dep-demo/src/main/java/demo/Main.java:[3,29] 程序包org.junit.jupiter.api不存在
[ERROR] /private/tmp/java_agent_f/dep-demo/src/main/java/demo/Main.java:[7,28] 找不到符号
[ERROR] 符号: 类 Test
test scope 的依赖不在主代码的编译 classpath 上,所以连符号都看不见。解决办法不是把 scope 改成 compile,而是问:这段代码为什么要在主代码里用测试框架?通常答案是「用错了地方」。
把 provided 当成 compile 的替代品。 本地编译通过,是因为 Maven 把 provided 放进了编译 classpath;运行时容器没提供这个库,就会在启动或第一次调用时抛 NoClassDefFoundError。provided 的潜台词是「运行环境保证有」,不是「我不想打包」。
排查依赖问题的顺序
遇到「本地能跑、CI 报错」或运行期 NoClassDefFoundError,按下面顺序排查,通常三步内能定位:
- 先确认是不是依赖问题。 看报错里的类名是否属于你直接写的包。如果是
org.junit...、com.fasterxml...这种第三方包名,基本可以确定。 - 再看解析出的版本。 跑
mvn dependency:tree -Dverbose,找omitted for conflict和omitted for duplicate。没有 verbose 时,被省略的分支不会显示。 - 最后看 classpath 的实际来源。
mvn dependency:build-classpath -Dmdep.outputFile=cp.txt会把完整 classpath 写到文件里,比在 IDE 的依赖列表里猜更可靠。
三步之后仍然找不到,再怀疑 scope、插件自己的 classpath、或容器里另一份同名 jar。顺序不要反过来:先怀疑 JDK 往往会浪费最多时间。
在 library 里落地
library 的依赖策略可以写成三句话:
- 运行时零第三方依赖。 主代码只 import
java.base和jdk.httpserver,发布会更简单。 - 测试依赖用
test。 JUnit 全家桶通过junit-bom统一版本,不进入 jar。 - 版本只写在 BOM 或
dependencyManagement。 子依赖不重复写版本。
验证测试依赖没有被带进产物:
unzip -l target/library.jar | grep -ci junit
0
grep -ci 统计包含 junit 的行数,结果是 0。jar 里只有 com/plume/library/**、META-INF/MANIFEST.MF 和 Maven 写入的 pom.xml/pom.properties。
再看一份完整的依赖清单:
mvn dependency:list
[INFO] org.junit.jupiter:junit-jupiter:jar:5.11.4:test -- module org.junit.jupiter
[INFO] org.junit.jupiter:junit-jupiter-api:jar:5.11.4:test -- module org.junit.jupiter.api
[INFO] org.opentest4j:opentest4j:jar:1.3.0:test -- module org.opentest4j
[INFO] org.junit.platform:junit-platform-commons:jar:1.11.4:test -- module org.junit.platform.commons
[INFO] org.apiguardian:apiguardian-api:jar:1.1.2:test -- module org.apiguardian.api
每一行都带 :test,说明它们只属于测试 classpath。-- module ... 是 Maven 对模块名的推断,卷二的模块化打包会再用到。
如果将来真的需要排除某个传递依赖,可以在直接依赖上写 <exclusions>:
<dependency>
<groupId>com.example</groupId>
<artifactId>some-lib</artifactId>
<version>1.0.0</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
这段配置属于卷二的场景,library 当前没有这类依赖,所以不在本机构建里执行。它的语法与上文的冲突规则是配套的:先看树,再决定排除谁;不要凭印象排除。
常见错误与边界
「版本号越高越优先。」 错。Maven 默认看路径远近,不看版本高低。直接声明的 1.6.3 会打败传递来的 1.11.4,哪怕后者更新。
「dependency:tree 没标红就没问题。」 树只展示解析结果,不判断兼容性。1.6.3 和 5.11.4 在树里都是合法坐标,运行期才暴露 NoClassDefFoundError。
「把所有依赖都改成 compile 最省事。」 这样测试框架、编译期注解处理器、容器提供的 API 都会被打进产物,jar 变大,类冲突概率上升。scope 的意义就是让不同阶段看到不同的世界。
「BOM 会引入依赖。」 不会。<scope>import</scope> 只导入版本清单,不把 BOM 里的构件变成项目的直接依赖。
小结
- Maven 坐标是
groupId:artifactId:version;一条直接依赖会解析出一棵传递依赖树。 compile、provided、runtime、test的区别体现在编译、运行、打包、传递四个维度。- JUnit 必须是
test;它不会进入library.jar,unzip -l | grep -ci junit的结果是 0。 - 冲突默认按「路径最短优先、同深度先声明优先」裁决,不比较版本高低。
dependencyManagement和 BOM 把版本收敛到一处,优先级高于传递依赖的版本。- 遇到
NoClassDefFoundError、NoSuchMethodError,先跑mvn dependency:tree -Dverbose。
依赖解析清楚之后,下一个问题是:这些 class 文件怎样变成一个别人能直接运行的文件?下一节从 library.jar 的结构开始,讲可执行 jar、shade 和模块化 jar。
阅读导航:上一节:16.3 可测试性设计 · 下一节:17.2 可执行 jar 与模块化 jar 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。