昨天的Spring Boot项目用一条命令就能编译、测试和打包。今天把这条命令拆开:Maven依据pom.xml组织构建,并由插件执行具体工作。理解这层分工,才能看懂"IDE里能运行,命令行却失败"究竟是哪一步出了问题。
本文用Maven3.9.9、JDK21和一个完整的小项目讲清坐标、目录、依赖、生命周期、插件与排错。我们让测试真正守住打包入口,再观察产物里到底有什么。
一、先认识项目的三个坐标
一个制品通常用groupId、artifactId、version识别。例如demo:build-lab:1.0.0中,demo是组织命名空间,build-lab是制品名,1.0.0是版本。默认packaging是jar;它决定默认绑定哪些构建任务。
POM并非只是一张下载清单。它同时描述项目身份、依赖和构建配置。dependencies供项目代码使用,build/plugins参与构建过程。JUnit是测试依赖;编译器插件负责调用编译器,Surefire插件运行单元测试,JAR插件打包。
二、建立一个可验证的最小项目
目录如下,测试文件名称以Test结尾,能被本例Surefire默认规则发现:
text
build-lab/
├─ pom.xml
└─ src/
├─ main/java/demo/App.java
└─ test/java/demo/AppTest.java
完整pom.xml:
xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId><artifactId>build-lab</artifactId><version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId>
<version>5.11.4</version><scope>test</scope>
</dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.2</version></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.4.2</version>
<configuration><archive><manifest><mainClass>demo.App</mainClass></manifest></archive></configuration>
</plugin>
</plugins></build>
</project>
release设为21,要求编译环境支持该目标,并约束使用的Java平台API。它不负责替你安装JDK。先运行mvn -v,检查Maven实际使用的Java版本与Java home;IDE项目SDK和终端JAVA_HOME可能不同。
显式固定插件版本,使团队采用相同的插件行为。JUnit只在测试阶段需要,所以配置test作用域。主程序不依赖它,运行时也不需要携带它。
App.java:
java
package demo;
public class App {
public static String label(String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("title is blank");
}
return "TODO: " + title.strip();
}
public static void main(String[] args) {
System.out.println(label(args.length == 0 ? "learn Maven" : args[0]));
}
}
AppTest.java:
java
package demo;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AppTest {
@Test void trimsTitle() {
assertEquals("TODO: learn Maven", App.label(" learn Maven "));
}
@Test void rejectsBlank() {
assertThrows(IllegalArgumentException.class, () -> App.label(" "));
}
@Test void rejectsNull() {
assertThrows(IllegalArgumentException.class, () -> App.label(null));
}
}
测试覆盖正常标题、空白和null三条路径。这个例子虽小,失败条件却明确:如果以后删除输入检查,测试应当阻止构建继续成功。
三、阶段与目标如何配合
运行mvn verify时,Maven沿default生命周期执行到verify;不是只执行一个叫verify的动作。主要阶段可以这样读:
| 阶段 | 关心的结果 |
|---|---|
| compile | 主源码编译成功 |
| test | 单元测试通过 |
| package | 生成JAR等制品 |
| verify | 执行已绑定的后续质量检查 |
| install | 把制品放入本地仓库,供其他本地项目引用 |
| deploy | 将制品发布到远程制品仓库 |
阶段规定顺序,插件目标负责动作。 例如compiler:compile执行编译,surefire:test执行测试。直接调用插件目标不等于自动执行它之前的全部生命周期阶段。verify是否执行集成测试或静态检查,取决于项目是否配置了对应插件和绑定,阶段名称本身不会自动生成这些检查。生命周期说明
clean属于另一条生命周期。mvn clean verify先清理再构建;单独mvn verify不会自动清空旧产物。日常增量构建可以不clean,排除遗留文件或做交付验收时使用clean更直接。

四、运行、检查产物,再故意让测试失败
在pom.xml所在目录执行:
bash
mvn -v
mvn clean verify
java -jar target/build-lab-1.0.0.jar
jar tf target/build-lab-1.0.0.jar
本次实际验收:3个测试全部通过,JAR启动输出TODO: learn Maven;故意改错断言后构建失败且未生成JAR,恢复后重新构建成功。JAR目录中包含App.class,不包含测试类和JUnit。
本例构建通过后,测试报告在target/surefire-reports,主类在target/classes,JAR在target/build-lab-1.0.0.jar。我们配置Main-Class清单,因此可以用java -jar启动。
普通JAR插件不会自动把所有第三方依赖打进JAR。这里主程序只用JDK,所以能独立运行。若加入运行时库,需要明确classpath、分发依赖,或选择适合项目的打包插件;Spring Boot可执行包也有自己的打包方式。
将测试期望临时改为错误字符串,再执行mvn clean verify:测试阶段失败,命令返回非零退出码,后续打包不会执行。恢复期望后再次构建。通过一次可控失败,可以确认CI执行的确实是测试,而非只编译源码或跳过测试。
不要把跳过测试当作修复。 -DskipTests通常跳过测试执行但仍编译测试代码;maven.test.skip会被相关插件用于跳过测试编译和执行。交付流水线应保留真实验收步骤。
五、依赖怎样进入classpath
| 作用域 | 主要用途 |
|---|---|
| compile | 默认值,主编译与运行都需要 |
| provided | 编译需要,运行环境提供 |
| runtime | 主编译不需要,运行需要 |
| test | 测试编译和执行需要 |
传递依赖把依赖的依赖带进解析图,实际传播还受作用域、optional和exclusions影响。同一个库出现多个版本时,Maven通常选择依赖路径更近的定义;同深度时先声明者优先。不要把"声明过一个新版本"理解为依赖图里所有关联库都自动升级。依赖机制
用依赖树找来源,比直接往pom.xml反复加版本更有效:
bash
mvn dependency:tree
mvn help:effective-pom
dependencyManagement集中管理依赖版本和相关属性,本身不等于引入依赖。子项目仍在dependencies声明需要的库。pluginManagement也用于管理插件配置,是否执行仍要看插件实际声明和生命周期绑定;不要把管理区当作执行清单。
六、仓库、重复构建与排错顺序
本地仓库存放已下载依赖和本地install产物,远程仓库提供可下载制品。install成功只说明当前机器的其他项目可以解析这个版本;让同事和CI使用,应通过团队远程制品仓库发布,不能依靠复制个人仓库目录。
项目配置提交到版本库,仓库凭据放在受控的settings.xml或CI凭据系统中。固定依赖与插件版本、使用Maven Wrapper统一Maven版本,再统一JDK环境,才能减少"同一份代码,不同机器构建不同"的情况。SNAPSHOT内容可以变化,缓存与更新策略也会影响解析结果。
遇到失败按日志定位:先看mvn -v确认环境,再看第一个失败插件;编译错误查源码与release,测试错误查surefire-reports,解析失败查坐标、网络和仓库配置。只有明确是某个缓存制品损坏,才针对它处理;删除整个本地仓库会造成大量无关重新下载。
七、🧠 思维导图
#mermaid-svg-Dzay11U1G9ILZ7Cu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Dzay11U1G9ILZ7Cu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Dzay11U1G9ILZ7Cu .error-icon{fill:#552222;}#mermaid-svg-Dzay11U1G9ILZ7Cu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Dzay11U1G9ILZ7Cu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Dzay11U1G9ILZ7Cu .marker.cross{stroke:#333333;}#mermaid-svg-Dzay11U1G9ILZ7Cu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Dzay11U1G9ILZ7Cu p{margin:0;}#mermaid-svg-Dzay11U1G9ILZ7Cu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Dzay11U1G9ILZ7Cu .cluster-label text{fill:#333;}#mermaid-svg-Dzay11U1G9ILZ7Cu .cluster-label span{color:#333;}#mermaid-svg-Dzay11U1G9ILZ7Cu .cluster-label span p{background-color:transparent;}#mermaid-svg-Dzay11U1G9ILZ7Cu .label text,#mermaid-svg-Dzay11U1G9ILZ7Cu span{fill:#333;color:#333;}#mermaid-svg-Dzay11U1G9ILZ7Cu .node rect,#mermaid-svg-Dzay11U1G9ILZ7Cu .node circle,#mermaid-svg-Dzay11U1G9ILZ7Cu .node ellipse,#mermaid-svg-Dzay11U1G9ILZ7Cu .node polygon,#mermaid-svg-Dzay11U1G9ILZ7Cu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Dzay11U1G9ILZ7Cu .rough-node .label text,#mermaid-svg-Dzay11U1G9ILZ7Cu .node .label text,#mermaid-svg-Dzay11U1G9ILZ7Cu .image-shape .label,#mermaid-svg-Dzay11U1G9ILZ7Cu .icon-shape .label{text-anchor:middle;}#mermaid-svg-Dzay11U1G9ILZ7Cu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Dzay11U1G9ILZ7Cu .rough-node .label,#mermaid-svg-Dzay11U1G9ILZ7Cu .node .label,#mermaid-svg-Dzay11U1G9ILZ7Cu .image-shape .label,#mermaid-svg-Dzay11U1G9ILZ7Cu .icon-shape .label{text-align:center;}#mermaid-svg-Dzay11U1G9ILZ7Cu .node.clickable{cursor:pointer;}#mermaid-svg-Dzay11U1G9ILZ7Cu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Dzay11U1G9ILZ7Cu .arrowheadPath{fill:#333333;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Dzay11U1G9ILZ7Cu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Dzay11U1G9ILZ7Cu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Dzay11U1G9ILZ7Cu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Dzay11U1G9ILZ7Cu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Dzay11U1G9ILZ7Cu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Dzay11U1G9ILZ7Cu .cluster text{fill:#333;}#mermaid-svg-Dzay11U1G9ILZ7Cu .cluster span{color:#333;}#mermaid-svg-Dzay11U1G9ILZ7Cu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Dzay11U1G9ILZ7Cu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Dzay11U1G9ILZ7Cu rect.text{fill:none;stroke-width:0;}#mermaid-svg-Dzay11U1G9ILZ7Cu .icon-shape,#mermaid-svg-Dzay11U1G9ILZ7Cu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Dzay11U1G9ILZ7Cu .icon-shape p,#mermaid-svg-Dzay11U1G9ILZ7Cu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Dzay11U1G9ILZ7Cu .icon-shape .label rect,#mermaid-svg-Dzay11U1G9ILZ7Cu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Dzay11U1G9ILZ7Cu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Dzay11U1G9ILZ7Cu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Dzay11U1G9ILZ7Cu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Maven构建
POM与坐标
生命周期
依赖
验收
项目与插件配置
compile test package verify
install与deploy
作用域与版本仲裁
测试失败阻止打包
依赖树与有效POM
八、总结
总结要点
POM把构建要求写成共享约定。 坐标说明项目是谁,依赖说明代码需要什么,插件说明构建如何执行。
生命周期让步骤有序衔接。 选择需要到达的阶段,让测试与检查成为产物生成的前置条件,再用一次失败实验验证它们真的生效。
排错要看实际环境和解析结果。 mvn -v、依赖树、有效POM和测试报告,比反复点IDE重试更能解释问题。
下一篇继续工具篇,使用Gradle重建同一个小项目,对比任务图、依赖配置与Wrapper。
👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊