2022-01-19
我们假设某个 Maven 工程,结构如下:
- Parent Project (named mvn-demo)
- Submodule A (Dependent on B) (named my-main-app)
- Submodule B (named my-dependency)
如果我们想达成这样一个目标:
- 执行 A 的 mainClass
- 每次在 B 的代码更新后,不需要重新整项目 mvn install,执行 A 的 mainClass 后即可看到变化
【其实就是达到和 IDE 中运行(子)模块一样的效果】
首先我们清理下 ~/.m2/repository/com/mvndemo 目录下已构建的包,再运行下 mvn clean,防止出问题。
我们尝试运用 exec-maven-plugin:
mvn exec:java -Dexec.mainClass=com.mvndemo.MyMainApp
提示:
java.lang.ClassNotFoundException: com.mvndemo.MyMainApp
...
[ERROR] Failed to execute goal org.codehaus.mojo:exec-maven-plugin:3.0.0:java (default-cli) on project mvn-demo: An exception occured while
executing the Java class. com.mvndemo.MyMainApp
父工程没有依赖子模块,当然找不到类。
于是尝试用 -pl 指定要运行的子模块:
mvn -pl my-main-app exec:java -Dexec.mainClass=com.mvndemo.MyMainApp
提示:
[WARNING] The POM for com.mvndemo:my-dependency:jar:0.0.1-SNAPSHOT is missing, no dependency information available
...
[ERROR] Failed to execute goal on project my-main-app: Could not resolve dependencies for project com.mvndemo:my-main-app:jar:0.0.1-SNAPSHOT
: Could not find artifact com.mvndemo:my-dependency:jar:0.0.1-SNAPSHOT
也就是说找不到依赖 B。这个时候如果在父工程中 mvn install 下,就能正常运行了,但这有不好的地方:每次对 B 的代码进行修改都需要重新 mvn clean install 后再运行,一来耗时,二来代码复杂,总之就是很麻烦。我们看 IDE(IntelliJ IDEA 或者 VSCode Java Extension)都可以在修改某个依赖的代码后一键运行主模块,自动应用依赖里的改动,那 mvn 为什么就不行呢?
沿着刚才的思路再想一想,其实我们只要让 mvn exec:java 能够 a) 及时编译所有依赖的代码,再 b) 将 B 的 class 文件(通常在类似 target/classes 的目录下)加到 classpath,使其能优先被加载,这就可以了。于是我们可想到这条指令:
mvn -pl my-main-app --also-make compile exec:java -Dexec.mainClass=com.mvndemo.MyMainApp
执行后错误:
[ERROR] Failed to execute goal org.codehaus.mojo:exec-maven-plugin:3.0.0:java (default-cli) on project mvn-demo: An exception occured while
executing the Java class. com.mvndemo.MyMainApp
注意该错误是对父工程 mvn-demo 发生的,因为父工程确实引用不到 com.mvndemo.MyMainApp 这个类。若我们手动指定依赖 B,排除掉父工程呢?
mvn -pl my-main-app,my-dependency compile exec:java -Dexec.mainClass=com.mvndemo.MyMainApp
同样的错误还会发生,只是对象变成了依赖 B:
[ERROR] Failed to execute goal org.codehaus.mojo:exec-maven-plugin:3.0.0:java (default-cli) on project my-dependency: An exception occured w
hile executing the Java class. com.mvndemo.MyMainApp
此时终于能想到理想的目标:我们能不能对依赖 B 和模块 A 执行 compile,之后只对模块 A 执行 exec:java 呢?
如果想要在一条指令里解决,那可能不行了。
在多个子模块的 Maven 工程里,执行 phase 或者 goal 时,会对所有子模块也同样执行它们:
The same command can be used in a multi-module scenario (i.e. a project with one or more subprojects). Maven traverses into every subproject and executes clean, then executes deploy (including all of the prior build phase steps). 来源
那分成两条指令呢?
mvn -pl my-main-app -am compile; mvn -pl my-main-app exec:java -Dexec.mainClass=com.mvndemo.MyMainApp
首先这有违我们的初衷,其次该指令仍然会出错:
[ERROR] Failed to execute goal on project my-main-app: Could not resolve dependencies for project com.mvndemo:my-main-app:jar:0.0.1-SNAPSHOT
: Could not find artifact com.mvndemo:my-dependency:jar:0.0.1-SNAPSHOT
(在没有安装过的情况下)模块 A 还是找不到依赖 B。很容易想到这是因为刚才我们期望的条件 b) 没有满足:“将 B 的 class 文件(通常在类似 target/classes 的目录下)加到 classpath,使其能优先被加载”。
怎么满足这个条件呢?查阅 SO 后,发现还是得在一个命令里解决问题,因为 Maven 的依赖解析过程有个“会话”(Session)的概念:
Maven can reference only output generated in current Session (during currently executing shell command). It uses the most "mature" place to look for the "output":
If compile is run - the classes end up in the target/classes dir, thus other modules can reference that
If package is run - then target/*.jar is created and this jar file ends up in the classpath instead
If install is run - then jar file ends up in the local repository - which is what ends up on the classpath So there are 3 factors that impede your task:
maven-exec-plugin requires dependency resolution (as pointed out by @mondaka)
Your module1 references module2
generate-sources is run before the compilation. Thus module2 is not yet prepared to be used as a dependency.
So if you want to do it your way - you'll have to run at least compile phase each time you use anything from the Default Lifecycle. Or you could write your own plugin that doesn't require dependency resolution.
因此得转向另外一条思路:让 exec:java 在除了模块 A 的其他模块/工程里不执行。
在父工程的 pom.xml 中添加 <pluginManagement> 标签,管理所有继承模块的配置,使其跳过 exec:java 的执行(<skip>true</skip>):
<project ...>
...
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<goals><goal>java</goal></goals>
</execution>
</executions>
<configuration>
<mainClass>com.mvndemo.MyMainApp</mainClass>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
在模块 A 中则覆盖此配置,不跳过 exec:java 的执行:
<project>
...
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<goals><goal>java</goal></goals>
</execution>
</executions>
<configuration>
<skip>false</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>
此时再执行这条指令,便可以达成我们的目标了:
mvn -pl my-main-app --also-make compile exec:java
又或者更简短地,可以直接省略 -pl 和 --also-make,对父工程的所有子模块都执行 mvn 的 phase/goal:
mvn compile exec:java
但这可能会把一些无关依赖也连带着构建了。
Spring Boot 的 Maven 插件也提供了 <skip> 这个配置项,因此应用类似的方法可以达到在父工程执行 mvn spring-boot:run 即可执行包含 Spring Boot Application 的子模块,并且使对其他依赖子模块的改动生效。
此处有一个问题:为何 spring-boot:run 这个 goal 不需要在前面加 compile 这个 phase?
经过翻阅源码,发现原因:spring-boot:run 这个 Mojo 使用了 @Execute 注解:
@Execute(phase = LifecyclePhase.TEST_COMPILE)
@execute phase="" lifecycle=""
...
When this goal is invoked, it will first invoke a parallel lifecycle, ending at the given phase. If a goal is provided instead of a phase, that goal will be executed in isolation. The execution of either will not affect the current project, but instead make available the ${executedProject} expression if required. An alternate lifecycle can also be provided: for more information see the documentation on the build lifecycle.
也就是说执行这个 Mojo 前,Maven 会先 fork 出去,执行 a) 某个 lifecycle 直到某个 phase,或者 b) 某个 goal。在之后再执行本 Mojo。
那我们也试着给 exec-maven-plugin 的 ExecJavaMojo 也加上这个注解:
@Execute(phase = LifecyclePhase.COMPILE)
再试着使用修改后的插件,在最开始的工程中执行 mvn exec:java,成功达到效果。
这个注解还是非常强力的,但也带来了很多复杂性。
这个注解的作用应该是无法用 pom.xml 里的配置项代替的,因此如果使用原版插件的话还是乖乖 mvn compile exec:java 吧。
2022-01-20
突然想到,只要允许 Maven 在依赖 B 上的 exec:java 目标以失败告终,之后不停止,接着执行其他工程/模块的 exec:java 目标,应该也能达成目的。尝试之后发现下列指令确实可以成功:
mvn -fn -pl my-main-app -am compile exec:java -Dexec.mainClass=com.mvndemo.MyMainApp
(-fae 选项不成功)
(虽然会打印出很多错误信息)
这样就不再需要修改 pom.xml 了。
对 Spring Boot 采用这个方法,需要指定 Spring Boot Maven 插件的全名:
mvn -fn -pl <...> -am org.springframework.boot:spring-boot-maven-plugin:run
- https://stackoverflow.com/a/26448447
- https://stackoverflow.com/questions/11091311/maven-execjava-goal-on-a-multi-module-project
- https://stackoverflow.com/questions/50288587/how-does-maven-decide-when-to-use-the-target-folder-for-classpath
- https://maven.apache.org/developers/mojo-api-specification.html