有关 Maven 的依赖解析机制和插件执行行为 === 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). > [来源](https://maven.apache.org/guides/introduction/introduction-to-the-lifecycle.html) 那分成两条指令呢? ``` 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. > > [来源](https://stackoverflow.com/questions/50288587/how-does-maven-decide-when-to-use-the-target-folder-for-classpath) 因此得转向另外一条思路:让 exec:java 在除了模块 A 的其他模块/工程里不执行。 在父工程的 `pom.xml` 中添加 `` 标签,管理所有继承模块的配置,使其跳过 exec:java 的执行(`true`): ``` ... org.codehaus.mojo exec-maven-plugin 3.0.0 java com.mvndemo.MyMainApp true ``` 在模块 A 中则覆盖此配置,不跳过 exec:java 的执行: ``` ... org.codehaus.mojo exec-maven-plugin 3.0.0 java false ``` 此时再执行这条指令,便可以达成我们的目标了: ``` mvn -pl my-main-app --also-make compile exec:java ``` 又或者更简短地,可以直接省略 `-pl` 和 `--also-make`,对父工程的所有子模块都执行 mvn 的 phase/goal: ``` mvn compile exec:java ``` 但这可能会把一些无关依赖也连带着构建了。 --- Spring Boot 的 Maven 插件也提供了 `` 这个配置项,因此应用类似的方法可以达到在父工程执行 `mvn spring-boot:run` 即可执行包含 Spring Boot Application 的子模块,并且使对其他依赖子模块的改动生效。 此处有一个问题:为何 `spring-boot:run` 这个 goal 不需要在前面加 `compile` 这个 phase? 经过翻阅源码,发现原因:[spring-boot:run 这个 Mojo](https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/RunMojo.java) 使用了 `@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. > > [来源](https://maven.apache.org/developers/mojo-api-specification.html) 也就是说执行这个 Mojo 前,Maven 会先 fork 出去,执行 **a)** 某个 lifecycle 直到某个 phase,或者 **b)** 某个 goal。在之后再执行本 Mojo。 那我们也试着给 exec-maven-plugin 的 [ExecJavaMojo](https://github.com/mojohaus/exec-maven-plugin/blob/master/src/main/java/org/codehaus/mojo/exec/ExecJavaMojo.java) 也加上这个注解: ``` @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