Skip to content

Maven Scope:依赖在哪个阶段可见,以及为什么打包后类找不到 ​

「本地跑得好好的,打包上线就 NoClassDefFoundError」——这类问题十有八九是 scope 写错了。scope 决定的是依赖在编译主代码、编译测试代码、运行时、传递给使用方这四个场景里是否可见。

本文用一个只有四个依赖的实验项目,实测各个 scope 在不同类路径上的表现(Maven 3.9.9,JDK 21)。

一、先说结论 ​

  • 五种 scope 的差别,可以用四个问题概括:编译主代码能不能用、编译测试代码能不能用、运行时在不在类路径上、会不会传递给依赖本项目的人。
  • provided 最容易出问题:编译期可见、运行期不在类路径上。它假设「运行环境会提供这个依赖」,假设不成立就是 NoClassDefFoundError。
  • 实测验证:dependency:list -DincludeScope=compile 包含 provided 的依赖,而 -DincludeScope=runtime 不包含。
  • runtime 用于「只在运行时需要实现」的依赖,典型是 JDBC 驱动——代码只依赖 java.sql 接口,不应该编译期依赖具体驱动。
  • scope 会影响传递性:依赖的依赖以什么 scope 出现在你的项目里,由两者的组合决定。

二、五种 scope ​

scope编译主代码编译测试运行时传递给使用方compile✓✓✓✓provided✓✓✗✗runtime✗✓✓✓test✗✓✗✗system✓✓✗✗典型用法:provided 给容器或注解处理器提供;runtime 给驱动类实现;test 给测试框架
图 1 · 实测 dependency:list 的结果:compile 类路径包含 provided,runtime 类路径不包含;test 类路径包含全部
scope编译主代码编译测试运行时传递给使用方典型用途
compile(默认)✓✓✓✓业务库:Guava、Jackson
provided✓✓✗✗Servlet API、Lombok、注解处理器、容器已提供的库
runtime✗✓✓✓JDBC 驱动、SLF4J 的具体实现
test✗✓✗✗JUnit、Mockito、测试容器
system✓✓✗✗指向本地 jar 文件,已废弃,不要用

三、实测:三种类路径分别包含什么 ​

实验项目的四个依赖分别用不同 scope 声明:

xml
<dependency><groupId>commons-logging</groupId><artifactId>commons-logging</artifactId>
           <version>1.3.6</version><scope>compile</scope></dependency>
<dependency><groupId>org.mapstruct</groupId><artifactId>mapstruct</artifactId>
           <version>1.6.3</version><scope>provided</scope></dependency>
<dependency><groupId>org.javassist</groupId><artifactId>javassist</artifactId>
           <version>3.29.2-GA</version><scope>runtime</scope></dependency>
<dependency><groupId>org.ow2.asm</groupId><artifactId>asm</artifactId>
           <version>9.9.1</version><scope>test</scope></dependency>

用 dependency:list 查看各个类路径的内容:

text
mvn dependency:list -DincludeScope=compile
  commons-logging:jar:1.3.6:compile
  mapstruct:jar:1.6.3:provided          ← provided 在编译类路径上

mvn dependency:list -DincludeScope=runtime
  commons-logging:jar:1.3.6:compile
  javassist:jar:3.29.2-GA:runtime       ← provided 不在运行时类路径上

mvn dependency:list -DincludeScope=test
  commons-logging:jar:1.3.6:compile
  javassist:jar:3.29.2-GA:runtime
  mapstruct:jar:1.6.3:provided
  asm:jar:9.9.1:test                    ← 测试类路径包含全部

这三段输出就是 scope 的全部含义:provided 在编译时在、运行时不在;runtime 反过来;test 只在测试时存在。

四、常见故障与原因 ​

4.1 打包后 NoClassDefFoundError ​

最常见的原因是把本该 compile 的依赖写成了 provided。典型场景:从 Spring Boot 的 spring-boot-starter-tomcat 抄了 provided,但自己的应用不是部署到外部容器,而是用可执行 jar 运行——没有人「提供」这个依赖。

实测主代码引用 provided 的 MapStruct:mvn compile 顺利通过;只用运行时类路径启动,第一次用到它就抛出:

text
Exception in thread "main" java.lang.NoClassDefFoundError: org/mapstruct/factory/Mappers

编译能过、启动才报错,这就是 provided 写错的典型表现。判断方法:

bash
mvn dependency:list -DincludeScope=runtime | grep 你的依赖

不在这个列表里,运行时就找不到。

4.2 NoSuchMethodError 与版本冲突 ​

不同依赖引入了同一个库的不同版本,Maven 按「最短路径优先、路径相同时声明靠前优先」仲裁,结果可能是某个库拿到了它不兼容的版本。

排查与固定:

bash
mvn dependency:tree -Dverbose -Dincludes=有冲突的:artifact   # 看谁引入了它、被谁覆盖了
xml
<!-- 用 dependencyManagement 统一版本,而不是到处写 exclusion -->
<dependencyManagement>
  <dependencies>
    <dependency><groupId>com.fasterxml.jackson</groupId><artifactId>jackson-bom</artifactId>
               <version>2.18.2</version><type>pom</type><scope>import</scope></dependency>
  </dependencies>
</dependencyManagement>

4.3 测试能跑、生产报错 ​

依赖写成了 test,但主代码通过反射或 SPI 在运行时用到了它。反射调用不会在编译期暴露问题,所以只有上线才会发现。

4.4 Lombok 与注解处理器 ​

Lombok 只在编译期生成代码,运行时不需要,所以用 provided(或者更精确地放进 annotationProcessorPaths)。MapStruct 类似:mapstruct 本身是 provided(只用到注解),mapstruct-processor 放进注解处理器路径。

xml
<plugin>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <annotationProcessorPaths>
      <path><groupId>org.mapstruct</groupId><artifactId>mapstruct-processor</artifactId><version>1.6.3</version></path>
    </annotationProcessorPaths>
  </configuration>
</plugin>

这样处理器不会进入运行时类路径,也不会传递给依赖你的项目。

五、传递依赖的 scope 怎么算 ​

依赖 A 的 scope 是 X,A 依赖 B 的 scope 是 Y,那么 B 在你的项目里的 scope 由 (X, Y) 决定:

你对 A 的 scope ↓ / A 对 B 的 scope →compileruntimeprovidedtest
compilecompileruntime不传递不传递
providedprovidedprovided不传递不传递
runtimeruntimeruntime不传递不传递
testtesttest不传递不传递

两个要点:provided 和 test 的依赖不会传递(所以用到就要自己显式声明);compile 依赖的 runtime 依赖会变成 runtime(合理:你编译时不需要它)。

六、几条实践 ​

  1. 默认用 compile,有明确理由才改。 大多数误用来自「复制粘贴了别人的 scope」。
  2. JDBC 驱动、日志实现用 runtime:让编译期无法误用具体实现的 API。
  3. 注解处理器用 annotationProcessorPaths,比 provided 更精确。
  4. 不要用 system:它把本地路径写进构建,换台机器就失败,官方已标记废弃。
  5. 发布前检查运行时类路径:mvn dependency:list -DincludeScope=runtime 或直接看 target 下的 lib 目录。
  6. 用 dependencyManagement 或 BOM 统一版本,把 exclusion 当作例外手段。

七、常见误区 ​

  • 「provided 就是不打包」:更准确的说法是「编译期需要,运行期由环境提供」。环境不提供就会失败。
  • 「runtime 的依赖编译不了就是配错了」:编译不到才是它的目的——防止代码直接依赖具体实现。
  • 「scope 只影响打包」:它同时影响编译、测试、运行和传递性四件事。
  • 「加 exclusion 就能解决版本冲突」:能解决单点冲突,统一版本应该用 dependencyManagement。

小结 ​

scope 回答的是同一个问题的四个侧面:这个依赖在编译主代码、编译测试、运行时、传递给下游时,分别在不在类路径上。记住 provided(编译在、运行不在)和 runtime(编译不在、运行在)这两个互补的例外,剩下的靠 dependency:list -DincludeScope=runtime 验证——这条命令能在发布前挡住绝大多数 NoClassDefFoundError。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权