本节目标:能说清
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/war | target/*.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 三个动作:
- 编译主源码到
target/classes。 - 编译
src/test/java到target/test-classes。 - 用 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-resources | maven-resources-plugin:resources |
compile | maven-compiler-plugin:compile |
test-compile | maven-compiler-plugin:testCompile |
test | maven-surefire-plugin:test |
package | maven-jar-plugin:jar |
install | maven-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 基本类型与包装类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。