Maven 实现直接获取内部模块类路径
背景
在多模块 Maven 项目中,你是否遇到过这样的场景:只是修改了某个内部的公共模块里的一行代码,为了在另一个业务模块里通过 exec:java 跑一下主类做验证,却不得不先执行一遍耗时的 mvn install,把公共模块重新装进本地仓库?又或者,你想用脚本拿到一个模块的完整类路径,结果 Maven 报错说在中央仓库找不到你自己的内部模块?
这两个看似无关的痛点,背后其实是同一个机制:Maven Reactor(反应堆)对内部模块依赖的"短路解析"。理解了它,你就能解释为什么 exec:java 可以不 install 直接跑,也能手动复刻这一行为,用一行命令拿到包含内部模块 target/classes 的完整类路径。
本文会从一个真实的报错出发,层层递进地拆解原理,最后给出一份可直接套用的命令模板。文中所有模块名、包名均已抽象化,与任何具体项目无关。
问题复现:为什么内部模块"找不到"?
一个典型的多模块结构
假设我们有一个标准的多模块工程,根项目叫 demo-platform,下面挂着几个子模块:
platform-bom:BOM 模块,packaging=pom,统一管理依赖版本。platform-common:公共模块,提供工具类和核心抽象。platform-service:业务模块,依赖platform-common。
这是一个再普通不过的微服务或中台项目骨架。
错误示范:在子模块目录里直接跑
某天,你想给 platform-service 写一个启动脚本,需要先拿到它的完整类路径。你顺手 cd 进了 platform-service 目录,敲下:
cd platform-service
mvn dependency:build-classpath
然后控制台甩给你一长串报错:
[ERROR] Failed to execute goal on project platform-service:
Could not resolve dependencies for project com.example:platform-service:jar:1.0.0:
Failed to collect dependencies at com.example:platform-common:jar:1.0.0:
Failed to read artifact descriptor for com.example:platform-common:jar:1.0.0:
com.example:platform-bom:pom:1.0.0 was not found in https://repo.maven.apache.org/maven2
during a previous attempt.
This failure was cached in the local repository and resolution is not reattempted
until the update interval of central has elapsed or updates are forced.
报错的关键信息有两层:第一层是"找不到 platform-bom",第二层是"这次失败被缓存了,不会重试"。
报错根因:脱离了 Reactor
当你 cd 进子模块目录单独执行 Maven 命令时,Maven 只能看到当前这一个 pom.xml,它完全不知道 platform-common、platform-bom 是"内部模块"。于是它按照普通第三方依赖的流程去解析:先查本地仓库 ~/.m2/repository,没有就去远程中央仓库找。
显然,你的内部 BOM 不可能发布到中央仓库,于是解析失败。失败之后,Maven 还会在本地仓库留下一个 *.lastUpdated 标记文件,把这次失败缓存起来,导致后续即便你修正了命令,也可能因为缓存而继续失败。
换句话说,问题不是"必须 install",而是你压根没让 Reactor 启动。
核心原理:Reactor 与"短路解析"
要理解"不 install 也能跑"的魔法,必须先搞懂 Maven Reactor 的工作机制。
Reactor 是什么
Reactor(反应堆)是 Maven 在多模块构建时的"调度大脑"。当你在项目根目录执行 Maven 命令时,Reactor 会做三件事:
- 扫描所有子模块的
pom.xml,建立模块清单。 - 根据模块间的
<dependency>关系,计算依赖图。 - 拓扑排序,决定构建顺序(被依赖的模块先构建)。
完成这三步后,Reactor 在内存里就已经清楚地知道"模块 B 依赖模块 A,且 A 正在本次构建中"。这个"知道"非常关键,它直接决定了下一步的解析策略。
两种依赖解析模式
Maven 对依赖的解析有两种截然不同的模式,区别在于"依赖路径指向哪里":
| 运行场景 | 解析策略 | 依赖路径指向 |
|---|---|---|
| 脱离 Reactor(单独构建子模块) | 常规解析 | ~/.m2/repository/.../platform-common-1.0.0.jar |
| Reactor 内部(聚合构建) | 短路解析 | /workspace/platform-common/target/classes |
第一种模式下,Maven 把内部模块当成普通第三方库,去本地仓库找 JAR 包。第二种模式下,Maven 会直接把内部模块的编译输出目录 target/classes 当作依赖路径。
短路解析的本质
为什么 Maven 要这么做?因为在一个聚合构建里,内部模块的代码可能刚刚被你改过,本地仓库里的 JAR 是旧的。如果还按常规模式去取 JAR,你改的代码根本不会生效。所以 Maven 干脆绕过 JAR,直接用实时编译出来的 target/classes 目录——这样既保证了代码是最新的,又省去了 install 这一步。
这就是"不 install 也能跑"的理论基础:Maven 并不依赖 JAR 包来连接内部模块,而是直接利用编译后的 class 文件目录。
Exec 插件为何"不 install 也能跑"?
理解了短路解析,再来看 exec:java 的行为就豁然开朗了。
Exec 插件的 ClassLoader 构建
exec:java 或 exec:exec 在运行你的 Main 类时,会通过 MavenSession 拿到 Reactor 中所有已构建模块的 MavenProject 对象,直接读取它们的 getOutputDirectory()(也就是 target/classes),把这些目录和外部 JAR 包路径拼接起来,构建一个 URLClassLoader,然后用这个 ClassLoader 启动你的应用。
注意这里的关键:Exec 插件拿到的内部模块路径,是 target/classes 这样的文件目录路径,而不是本地仓库里的 JAR 路径。这一切都建立在"Reactor 已经把内部模块识别为反应堆项目"的前提之上。
生命周期阶段的隐式保障
但 Exec 插件本身并不神奇,它有一个隐含前提:target/classes 必须已经存在。这个前提通常由 Maven 生命周期阶段来保证。
我们日常用的 mvn exec:java 命令,往往不是孤立执行的,而是配合阶段一起跑,比如:
mvn compile exec:java -pl platform-service -am
这条命令做了两件事:
compile:触发 Maven 生命周期,Reactor 确保上游模块(platform-common)被编译,生成了target/classes。exec:java:插件启动时,Reactor 直接把target/classes路径交给插件。
所以 Exec 插件"不 install 也能跑"的真相是:它搭乘了 Reactor 生命周期的顺风车,而 compile 阶段保证了 target/classes 已经就绪。如果你把 compile 去掉,直接跑 mvn exec:java -pl platform-service -am,同样会因为 target/classes 不存在而失败。
手动获取类路径的正确姿势
理解了原理,我们就能手动复刻 Exec 插件的行为,用 dependency:build-classpath 拿到一份完整的类路径。这个插件是 Maven 官方 maven-dependency-plugin 提供的一个 goal,专门用来输出当前模块的完整依赖类路径。
三个关键要素
要让 build-classpath 正确工作,必须同时满足三个条件:
- 在根目录运行:激活 Reactor,让 Maven 拥有全局视野。
- 用
-pl+-am锁定范围:-pl指定目标模块,-am(also-make)把它在 Reactor 中依赖的上游模块一起纳入构建。 - 绑定一个生命周期阶段:这是最容易被忽略的一点,下面单独讲。
最容易踩的坑:必须绑定生命周期阶段
dependency:build-classpath 只是一个 Goal,它默认不属于任何生命周期阶段。如果你只跑:
mvn dependency:build-classpath -pl platform-service -am
会发生什么?Reactor 虽然被激活了,上游模块也被纳入了,但没有任何阶段被触发。也就是说,process-classes / compile 都没执行,target/classes 没有生成。下游模块解析上游依赖时,发现 artifact 文件不可用,就会回退到本地仓库解析——结果撞上 *.lastUpdated 失败缓存,报出和第一节一模一样的错。
这就是为什么很多人"明明加了 -pl 和 -am 还是失败"的原因:少了生命周期阶段这一步。
完整命令模板
正确的命令必须在前缀加一个会触发 process-classes 的阶段,比如 compile 或 test-compile:
mvn compile dependency:build-classpath \
-pl platform-service -am \
-Dmdep.outputFile=classpath.txt
参数解释:
compile:触发编译,确保上游模块产出target/classes。-pl platform-service:只在platform-service模块上执行 goal。-am:自动构建它依赖的上游模块。-Dmdep.outputFile=classpath.txt:把类路径输出到文件,避免控制台日志太长不好找。
执行流程会变成:每个上游模块先跑 compile(生成 target/classes),再跑 build-classpath。下游模块解析上游依赖时,artifact 文件就是 target/classes 目录,不再回退到仓库,也就不会触发 *.lastUpdated 缓存。
几个常用变体
只看控制台输出(适合快速验证):
mvn compile dependency:build-classpath -pl platform-service -am
控制台会输出一大段日志,其中包含 Dependencies classpath: 字样,后面跟着的就是完整类路径。
只取 runtime 范围(适合打包运行场景):
mvn compile dependency:build-classpath -pl platform-service -am \
-Dmdep.includeScope=runtime -Dmdep.outputFile=cp.txt
includeScope 可以过滤依赖范围,runtime 通常对应运行时需要的依赖。
包含测试类(适合跑测试场景):
mvn test-compile dependency:build-classpath -pl platform-service -am \
-Dmdep.outputFile=cp.txt
test-compile 会同时编译主代码和测试代码,target/test-classes 也会被生成。
验证与结果分析
命令跑完之后,怎么判断 Reactor 短路解析真的生效了?打开生成的 classpath.txt,看里面内部模块对应的路径形态。
成功的标志:路径是目录
如果类路径里出现形如下面的内容,说明 Reactor 解析成功,用的是编译输出目录:
D:\workspace\platform-common\target\classes;
D:\workspace\platform-service\target\classes;
C:\Users\Admin\.m2\repository\org\springframework\boot\spring-boot\3.0.0\spring-boot-3.0.0.jar;
...
可以清楚看到两类截然不同的路径:
- 内部模块:表现为本地工程目录(
.../target/classes),证明 Reactor 跳过了本地仓库。 - 第三方库:表现为本地仓库中的 JAR 包(
.../.m2/repository/...),走的是常规解析。
失败的标志:路径是 JAR
如果类路径里内部模块对应的路径变成了这样:
C:\Users\Admin\.m2\repository\com\example\platform-common\1.0.0\platform-common-1.0.0.jar
说明 Reactor 没有短路解析,走的是本地仓库 JAR。这通常意味着两种情况:要么你没在根目录运行,要么你没加 compile 阶段,要么你之前 install 过这个模块,本地仓库里恰好有 JAR。
拿到类路径之后怎么用
有了 classpath.txt,你就可以直接用 java -cp 启动应用,完全绕过 mvn install:
java -cp "target/classes;$(cat classpath.txt)" com.example.MainClass
注意把当前模块自己的 target/classes 也加进去(因为 build-classpath 输出的是依赖路径,不包含当前模块自身)。Windows 用分号 ; 分隔,Linux/macOS 用冒号 : 分隔。
避坑指南:失败缓存的"幽灵"
如果你修正了命令之后依然报错,提示 This failure was cached in the local repository and resolution is not reattempted,那是 Maven 的失败缓存在作祟。
缓存是怎么产生的
Maven 在尝试从远程仓库下载依赖失败后,会在本地仓库对应目录下生成一个以 .lastUpdated 结尾的标记文件,里面记录了失败时间。后续再次解析这个依赖时,Maven 会先检查这个标记文件,如果距离上次失败还没超过更新间隔(默认 24 小时),就直接拒绝重试,连远程仓库都不去问。
这个机制本意是避免频繁请求不存在的依赖,但在我们的场景里却成了"幽灵"——你明明已经修正了命令,让 Reactor 来解析内部模块了,但缓存还在那里挡路。
两种清除方式
方式一:加 -U 强制更新
最简单的办法是在命令里加 -U 参数,强制 Maven 忽略失败缓存重新解析:
mvn compile dependency:build-classpath -pl platform-service -am -U
-U 会强制 Maven 忽略之前缓存的失败记录,重新去仓库(包括 Reactor)解析。
方式二:手动删除 .lastUpdated 文件
如果 -U 还不行,可以手动删除本地仓库里对应的 .lastUpdated 文件。找到 ~/.m2/repository/com/example/platform-bom/ 目录,删掉里面的 *.lastUpdated 文件即可。也可以用一行命令批量清理:
# Linux/macOS
find ~/.m2/repository -name "*.lastUpdated" -delete
# Windows PowerShell
Get-ChildItem -Path "$env:USERPROFILE\.m2\repository" -Recurse -Filter "*.lastUpdated" | Remove-Item
清理完之后再跑正确的命令,就不会再被缓存干扰了。
场景对照表
把前面讲过的几种命令放在一起对比,会更直观:
| 命令 | 是否触发 Reactor | 是否触发编译 | 上游 target/classes | 内部模块解析 | 结果 |
|---|---|---|---|---|---|
cd platform-service && mvn dependency:build-classpath | 否 | 否 | 未生成 | 回退仓库,命中缓存 | 失败 |
mvn dependency:build-classpath -pl platform-service -am | 是 | 否 | 未生成 | 回退仓库,命中缓存 | 失败 |
mvn compile dependency:build-classpath -pl platform-service -am | 是 | 是 | 已生成 | 直接用 target/classes | 成功,类路径含目录 |
mvn test-compile dependency:build-classpath -pl platform-service -am | 是 | 是 | 已生成 | 直接用 target/classes | 成功 |
先 mvn install -pl platform-common 再单跑 | 否 | 否 | 未生成但本地仓库有 JAR | 用仓库 JAR | 成功,但类路径是 JAR 而非目录 |
这张表能解释一个常见困惑:为什么"先 install 再单跑"也能成功,但拿到的类路径形态不一样?因为 install 之后本地仓库里有了 JAR,单跑时 Maven 走常规解析,直接取 JAR 路径。这和 Reactor 短路解析拿到 target/classes 目录是两种完全不同的行为。
何时才真正需要 install?
讲到这里,可能有人会问:那是不是以后都不用 install 了?并不是。install 在以下场景里依然不可替代:
- 脱离 Reactor 的脚本/CI 步骤里单独运行某个模块:比如 CI 流水线里有一个独立的步骤只构建并运行
platform-service,不带上platform-common,那就必须先把platform-commoninstall 到本地仓库。 - 让非 m2e 的 IDE 或第三方工具从本地仓库取依赖:有些老旧的 IDE 插件或第三方工具不识别 Reactor,只能从本地仓库取 JAR。
- 发布到团队共享的 Nexus/私服:这是
install之外还需要deploy的场景,但install是前置步骤。
如果你只是想在本地调试、跑一下主类、生成一份类路径给脚本用,那么用本文的"三部曲"命令就够了,完全不需要 install。
总结:三部曲口诀
回到最初的问题:Maven Exec 插件怎么实现不 install 内部模块直接运行?怎么通过 mvn 命令获取内部模块类路径?
答案可以浓缩成一句话:Exec 插件搭乘了 Reactor 的顺风车,而 Reactor 通过短路解析把 target/classes 当作内部模块的依赖路径。要手动复刻这一行为,记住三部曲口诀:
- 找根目录:始终在项目根目录运行 Maven 命令,确保 Reactor 激活,拥有全局视野。
- 定目标:用
-pl <module> -am锁定构建范围,-pl指定目标模块,-am自动带上上游依赖。 - 加编译:务必在命令前加上
compile或test-compile阶段,确保内部模块有产物输出。
最终命令模板:
mvn compile dependency:build-classpath \
-pl <target-module> -am \
-Dmdep.outputFile=classpath.txt
如果之前有失败缓存,加 -U 强制更新:
mvn compile dependency:build-classpath \
-pl <target-module> -am -U \
-Dmdep.outputFile=classpath.txt
掌握了这一点,你就能在多模块项目中游刃有余地进行调试和脚本编写,彻底告别繁琐的重复 install。更重要的是,你理解了 Maven Reactor 的设计哲学——构建时优先用实时产物,而非仓库里的旧 JAR——这会让你在面对 Maven 的各种"奇怪行为"时,多一份从容。