Maven 实现直接获取内部模块类路径

📅 2026/7/20 22:13:40 👁️ 阅读次数 📝 编程学习
Maven 实现直接获取内部模块类路径

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/maven2during a previous attempt.This failure was cached in the local repository and resolution is not reattempteduntil the update interval of central has elapsed or updates are forced.

报错的关键信息有两层:第一层是"找不到 platform-bom",第二层是"这次失败被缓存了,不会重试"。

报错根因:脱离了 Reactor

当你 cd 进子模块目录单独执行 Maven 命令时,Maven 只能看到当前这一个 pom.xml它完全不知道 platform-commonplatform-bom 是"内部模块"。于是它按照普通第三方依赖的流程去解析:先查本地仓库 ~/.m2/repository,没有就去远程中央仓库找。

显然,你的内部 BOM 不可能发布到中央仓库,于是解析失败。失败之后,Maven 还会在本地仓库留下一个 *.lastUpdated 标记文件,把这次失败缓存起来,导致后续即便你修正了命令,也可能因为缓存而继续失败。

换句话说,问题不是"必须 install",而是你压根没让 Reactor 启动


核心原理:Reactor 与"短路解析"

要理解"不 install 也能跑"的魔法,必须先搞懂 Maven Reactor 的工作机制。

Reactor 是什么

Reactor(反应堆)是 Maven 在多模块构建时的"调度大脑"。当你在项目根目录执行 Maven 命令时,Reactor 会做三件事:

  1. 扫描所有子模块的 pom.xml,建立模块清单。
  2. 根据模块间的 <dependency> 关系,计算依赖图。
  3. 拓扑排序,决定构建顺序(被依赖的模块先构建)。

完成这三步后,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:javaexec: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

这条命令做了两件事:

  1. compile:触发 Maven 生命周期,Reactor 确保上游模块(platform-common)被编译,生成了 target/classes
  2. 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 正确工作,必须同时满足三个条件:

  1. 在根目录运行:激活 Reactor,让 Maven 拥有全局视野。
  2. -pl + -am 锁定范围-pl 指定目标模块,-am(also-make)把它在 Reactor 中依赖的上游模块一起纳入构建。
  3. 绑定一个生命周期阶段:这是最容易被忽略的一点,下面单独讲。

最容易踩的坑:必须绑定生命周期阶段

dependency:build-classpath 只是一个 Goal,它默认不属于任何生命周期阶段。如果你只跑:

mvn dependency:build-classpath -pl platform-service -am

会发生什么?Reactor 虽然被激活了,上游模块也被纳入了,但没有任何阶段被触发。也就是说,process-classes / compile 都没执行,target/classes 没有生成。下游模块解析上游依赖时,发现 artifact 文件不可用,就会回退到本地仓库解析——结果撞上 *.lastUpdated 失败缓存,报出和第一节一模一样的错。

这就是为什么很多人"明明加了 -pl-am 还是失败"的原因:少了生命周期阶段这一步

完整命令模板

正确的命令必须在前缀加一个会触发 process-classes 的阶段,比如 compiletest-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 在以下场景里依然不可替代:

  1. 脱离 Reactor 的脚本/CI 步骤里单独运行某个模块:比如 CI 流水线里有一个独立的步骤只构建并运行 platform-service,不带上 platform-common,那就必须先把 platform-common install 到本地仓库。
  2. 让非 m2e 的 IDE 或第三方工具从本地仓库取依赖:有些老旧的 IDE 插件或第三方工具不识别 Reactor,只能从本地仓库取 JAR。
  3. 发布到团队共享的 Nexus/私服:这是 install 之外还需要 deploy 的场景,但 install 是前置步骤。

如果你只是想在本地调试、跑一下主类、生成一份类路径给脚本用,那么用本文的"三部曲"命令就够了,完全不需要 install


总结:三部曲口诀

回到最初的问题:Maven Exec 插件怎么实现不 install 内部模块直接运行?怎么通过 mvn 命令获取内部模块类路径?

答案可以浓缩成一句话:Exec 插件搭乘了 Reactor 的顺风车,而 Reactor 通过短路解析把 target/classes 当作内部模块的依赖路径。要手动复刻这一行为,记住三部曲口诀:

  1. 找根目录:始终在项目根目录运行 Maven 命令,确保 Reactor 激活,拥有全局视野。
  2. 定目标:用 -pl <module> -am 锁定构建范围,-pl 指定目标模块,-am 自动带上上游依赖。
  3. 加编译:务必在命令前加上 compiletest-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 的各种"奇怪行为"时,多一份从容。