Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save shunf4/fa9c3eeaef7c6a7cb70aaadad7d64be1 to your computer and use it in GitHub Desktop.

Select an option

Save shunf4/fa9c3eeaef7c6a7cb70aaadad7d64be1 to your computer and use it in GitHub Desktop.

有关 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). 来源

那分成两条指令呢?

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

部分参考资料

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment