《Java 编程入门》3.3 Maven 生命周期:compile、test、package

把 Maven 生命周期还原成一条可执行的流水线:validate、compile、test、package、verify、install、deploy 各自做什么,为什么 mvn package 会顺带跑测试,target 下每个目录从哪来;再用 library 工程实跑 compile、test、package,检查 jar 内容与 No tests to run 的真实输出。

本节目标:能说清 mvn compile、mvn test、mvn package 各自触发哪些阶段,知道 target 下每个目录由谁生成,并能判断一次构建为什么成功或失败。
适用版本:JDK 25 LTS(Temurin 25.0.5);Maven 3.9.12。

生命周期是一条流水线

Maven 的核心概念是「生命周期」(lifecycle)。它不是一堆可以随便调用的命令,而是一条有顺序的流水线:你指定一个阶段,Maven 从这条流水线的开头一路执行到这个阶段,前面的阶段不会跳过。

默认生命周期叫 default,常用阶段按顺序是:

阶段做什么典型产物
validate检查项目配置是否完整无
compile编译主源码target/classes
test编译并运行测试target/test-classes、测试报告
package把主产物打成 jar/wartarget/*.jar
verify运行集成检查校验报告
install把产物安装到本地仓库~/.m2/repository
deploy发布到远程仓库远程仓库

所以 mvn package 不是「只打包」,它实际执行 validate → compile → test → package。mvn install 又会在此基础上多跑 verify 和 install。理解这一点,就不会问「为什么我只想打包,它却跑了测试」。

执行阶段用 mvn <phase>。mvn 不带参数时只打印帮助,不会构建。mvn clean 是另一条生命周期(clean 生命周期)的阶段,和 default 生命周期独立,所以 mvn clean package 会先清理再打包。

compile:从源码到 target/classes

mvn compile 触发到 compile 阶段。它做两件事:复制 src/main/resources 到 target/classes,调用 javac 编译 src/main/java。

本机在 library 上执行:

mvn -B compile

输出(节选):

[INFO] --- resources:3.3.1:resources (default-resources) @ library ---
[INFO] skip non existing resourceDirectory /Users/bingrong.yan/code/library/src/main/resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ library ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 3 source files with javac [debug release 21] to target/classes
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

三行关键信息:

  • resources 插件先跑,没有资源目录就跳过。
  • compiler 插件调用 javac,参数里有 release 21。
  • 编译了 3 个源文件,产物在 target/classes。

compile 不会运行测试,也不会打 jar。它只保证「主源码能编译成 class 文件」。这是最快的反馈阶段,改完代码先跑它,比直接 package 省时间。

test:编译测试并运行

mvn test 触发到 test 阶段,包含 compile、test-compile、test 三个动作:

  1. 编译主源码到 target/classes。
  2. 编译 src/test/java 到 target/test-classes。
  3. 用 Surefire 插件运行测试类,生成报告到 target/surefire-reports。

library 目前还没有测试,所以输出里会出现:

[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ library ---
[INFO] No sources to compile
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ library ---
[INFO] No tests to run.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

No tests to run. 不是错误,它只表示「测试源码目录为空,或者没有匹配到测试类」。Surefire 默认匹配 **/Test*.java、**/*Test.java、**/*Tests.java、**/*TestCase.java 这几类命名。第 16 章引入 JUnit 5 后,测试类会按这个规则被发现。

测试失败时,mvn test 会停在 test 阶段,不会继续到 package。这是有意的:测试不通过,产物就不应该被发布。 如果确实要跳过测试,可以加 -DskipTests(编译测试但不运行)或 -Dmaven.test.skip=true(连测试编译都跳过),但这两个参数只应该用于临时排查,不应该写进日常构建脚本。

package:把 class 文件打成 jar

mvn package 触发到 package 阶段,在 test 之后多执行 jar 插件:

[INFO] --- jar:3.4.1:jar (default-jar) @ library ---
[INFO] Building jar: /Users/bingrong.yan/code/library/target/library-0.1.0-SNAPSHOT.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

jar 文件名由 artifactId 和 version 拼成:library-0.1.0-SNAPSHOT.jar。如果 packaging 是 war,这里会调用 war 插件;如果是 pom,不会生成 jar。卷一用 jar。

target 目录在三个阶段之后长这样:

target/
├── classes/
│   └── com/plume/library/...
├── test-classes/
├── surefire-reports/
├── maven-status/
└── library-0.1.0-SNAPSHOT.jar

classes 是主产物,test-classes 是测试产物,surefire-reports 是测试报告,maven-status 是增量编译的缓存。这些目录都可以删,下一次构建会重新生成。target 不应该提交到 Git。

插件与默认绑定

生命周期本身只定义「阶段」,不定义「怎么做」。每个阶段由插件目标(plugin goal)实现,Maven 通过「默认绑定」把它们挂上去:

阶段默认插件目标
process-resourcesmaven-resources-plugin:resources
compilemaven-compiler-plugin:compile
test-compilemaven-compiler-plugin:testCompile
testmaven-surefire-plugin:test
packagemaven-jar-plugin:jar
installmaven-install-plugin:install

这些插件版本由 Maven 的超级 POM 决定,不是项目 POM 里写的。本机 Maven 3.9.12 绑定的是 compiler 3.13.0、surefire 3.2.5、jar 3.4.1。不同 Maven 版本可能绑定不同插件版本,构建结果可能有细微差异。正式项目会把关键插件版本写进 pom.xml,第 17 章会展开。

也可以直接调用插件目标,绕过生命周期:

mvn compiler:compile

它只执行 compiler 插件的 compile 目标,不会先跑 resources,也不会触发后续阶段。这种用法适合调试插件参数,不适合日常构建。日常构建用阶段,阶段保证顺序和完整性。

在 library 里跑一遍

library 当前的 pom.xml 只需要坐标、打包方式和编码:

<groupId>com.plume</groupId>
<artifactId>library</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<properties>
  <maven.compiler.release>21</maven.compiler.release>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

主源码是第 3.2 节拆好的两个类。入口类:

package com.plume.library;

import com.plume.library.cli.Usage;

public final class LibraryApp {
    public static void main(String[] args) {
        System.out.println("Hello, Library");
        if (args.length == 0) {
            Usage.print();
            return;
        }
        System.out.println("command = " + args[0]);
    }
}

被入口调用的用法类:

package com.plume.library.cli;

public final class Usage {
    private Usage() {
    }

    public static void print() {
        System.out.println("usage: library <"
                + String.join("|", CommandNames.ADD, CommandNames.LIST,
                        CommandNames.BORROW, CommandNames.RETURN,
                        CommandNames.SEARCH, CommandNames.QUIT)
                + ">");
    }
}

两个类加上包私有的 CommandNames,一共 3 个源文件。这正好对应构建日志里的 Compiling 3 source files。

完整跑一遍:

mvn -B clean package

本机输出(节选;路径按示例项目目录调整):

[INFO] --- clean:3.2.0:clean (default-clean) @ library ---
[INFO] Deleting /Users/bingrong.yan/code/library/target
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ library ---
[INFO] Compiling 3 source files with javac [debug release 21] to target/classes
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ library ---
[INFO] No sources to compile
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ library ---
[INFO] No tests to run.
[INFO]
[INFO] --- jar:3.4.1:jar (default-jar) @ library ---
[INFO] Building jar: /Users/bingrong.yan/code/library/target/library-0.1.0-SNAPSHOT.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

看 jar 里有什么:

jar tf target/library-0.1.0-SNAPSHOT.jar
META-INF/
META-INF/MANIFEST.MF
com/
com/plume/
com/plume/library/
com/plume/library/cli/
com/plume/library/LibraryApp.class
com/plume/library/cli/CommandNames.class
com/plume/library/cli/Usage.class

三个 class 文件都在,包路径变成了 jar 内的目录。META-INF/MANIFEST.MF 是 jar 的清单文件,目前只有最基本的版本信息。

直接运行这个 jar:

java -jar target/library-0.1.0-SNAPSHOT.jar

会失败:

target/library-0.1.0-SNAPSHOT.jar中没有主清单属性

因为 Maven 默认生成的清单里没有 Main-Class。jar 知道自己包含哪些 class,但不知道从哪个类的 main 启动。第 17 章会用 maven-jar-plugin 的 manifest 配置或打包插件解决这个问题。现在先用类路径运行:

java -cp target/library-0.1.0-SNAPSHOT.jar com.plume.library.LibraryApp list
Hello, Library
command = list

-cp 可以指向目录,也可以指向 jar。指向 jar 时,JVM 会把 jar 里的 class 当成类路径的一部分。

常见错误与边界

「只想打包,结果测试失败卡住了。」 package 在 test 之后,测试失败就不会打包。这是设计行为,不是 bug。要临时跳过用 -DskipTests,但不要把它写进 pom.xml 或 CI 默认命令。

「改了代码,mvn package 还是旧结果。」 先确认是不是在错误的目录执行,或者 target 里残留了上次的 class。用 mvn clean package 清理后重建。增量编译偶尔会因为时间戳或缓存判断失误,clean 是最直接的排除手段。

「mvn install 和 mvn deploy 混用。」 install 只把产物放进本机 ~/.m2/repository,不会上传到远程;deploy 才会发布到远端仓库,而且需要在 pom.xml 的 distributionManagement 里配置地址。日常开发用 install,发布才用 deploy。

「jar 里没有依赖。」 Maven 默认的 jar 只包含本项目编译出的 class,不包含第三方依赖。卷一没有第三方依赖,所以现在看不出来;卷二引入 HTTP 客户端或 JSON 库后,直接 java -jar 会因为找不到依赖类而失败。第 17 章会讲可执行 jar 的几种打包方式。

小结

  • Maven 生命周期是一条有顺序的流水线,执行某个阶段会先执行它前面的所有阶段。
  • compile 编译主源码到 target/classes,test 编译并运行测试,package 在测试通过后生成 jar。
  • clean 属于另一条生命周期,mvn clean package 先清理再打包;排查增量编译问题时先 clean。
  • 每个阶段由插件目标实现,默认绑定来自 Maven 超级 POM;mvn compiler:compile 这类直接目标调用绕过生命周期。
  • No tests to run. 表示没有测试类,不是失败;测试失败会阻止 package,这是有意的质量门禁。
  • jar 默认没有 Main-Class,java -jar 会失败,需要显式配置或改用 -cp。
  • target 是生成目录,可以随时删除,不应该提交;~/.m2/repository 是机器级缓存,也不属于项目。
  • 改完主代码先跑 mvn compile,它最快;mvn package 要求测试全部通过。
  • 直接调用插件目标(如 mvn compiler:compile)不会触发前置阶段,日常构建仍然用阶段。

到这里,工具链、目录、包和构建流水线都已经跑通。下一章开始写真正的领域代码:从 Book 的字段出发,讨论基本类型、包装类型和数值边界,把「一本书在程序里长什么样」定下来。

阅读导航:上一节:3.2 包、模块与可见性 · 下一节:4.1 基本类型与包装类型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发