敲下 mvn package,满屏日志过后 BUILD SUCCESS。可 target/ 里为什么多出 jar、测试为什么自己跑起来、第三方包是谁塞进去的——说不清这三件事,遇到"本地能跑、同事拉下来 ClassNotFound"就只能删 .m2 重下碰运气。
这篇只拆机制:阶段怎么串、谁在阶段上干活、依赖怎么被选中、仓库按什么顺序找。
1. mvn package 其实是"走到某个阶段"
Maven 有三套互相独立的生命周期,一条命令只在其中一套里走:
| 生命周期 | 干什么 | 常走的终点 |
|---|---|---|
clean |
删 target/ |
clean |
default |
编译、测试、打包、安装、发布 | package / install / deploy |
site |
生成文档站点 | site / site-deploy |
mvn clean package 是两套生命周期串联,不是一条链子。所以 mvn package 不会清掉上次的 class 文件——旧的 .class 留在 target/classes 里,改了类名之后照样能打进 jar,这是本地"看起来正常"的一大来源。
default 生命周期的阶段顺序(常用的一段):
validate → initialize → generate-sources → process-sources
→ generate-resources → process-resources → compile
→ process-classes → generate-test-sources → process-test-sources
→ generate-test-resources → process-test-resources
→ test-compile → process-test-classes → test
→ prepare-package → package → pre-integration-test → integration-test
→ post-integration-test → verify → install → deploy
关键规则:指定一个阶段,它前面的所有阶段都会被执行。所以 mvn package 必然跑过 compile 和 test;mvn test 不会打包。
2. 阶段是空壳,插件才干活
阶段本身没有逻辑,只是一串"钩子位置"。真正执行的是绑定在这些位置上的插件目标(goal)。
| 阶段 | 默认绑定的 goal | 产物 |
|---|---|---|
process-resources |
maven-resources-plugin:resources |
target/classes 下的配置/静态资源 |
compile |
maven-compiler-plugin:compile |
target/classes/*.class |
test |
maven-surefire-plugin:test |
target/surefire-reports/ |
package |
maven-jar-plugin:jar(jar 打包时) |
target/*.jar |
install |
maven-install-plugin:install |
本地仓库副本 |
deploy |
maven-deploy-plugin:deploy |
远程仓库副本 |
package 绑什么取决于 <packaging>:jar 出 jar,war 出 war,pom 只用于聚合与父工程。
自己往阶段上挂东西,靠 <executions> 的 <phase>:
<plugin>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>echo-after-resources</id>
<phase>process-resources</phase>
<goals><goal>run</goal></goals>
<configuration>
<target><echo message="resources 已完成,准备编译"/></target>
</configuration>
</execution>
</executions>
</plugin>
<phase> 决定"什么时候",<goals> 决定"做什么",<id> 改名等于新增一条绑定。
3. pom.xml 的四层结构
一份能跑的最小 pom.xml 只有四类内容:
| 层 | 元素 | 回答的问题 | 写错的表现 |
|---|---|---|---|
| 坐标 | groupId / artifactId / version / packaging |
我是谁 | 装进仓库后别人引不到 |
| 依赖 | <dependencies> |
我需要谁 | 编译期找不到符号 |
| 构建 | <build> |
怎么编译、打什么包 | 插件版本漂移、产物不对 |
| 父工程 | <parent> |
我从谁继承默认值 | 版本号被父工程覆盖,改本地不生效 |
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>top.example</groupId>
<artifactId>demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
</dependencies>
</project>
<packaging> 写在坐标层,却决定了 package 阶段绑哪个 goal。
4. 依赖解析:谁被选中
解析是递归的:先取直接依赖,再取它们的依赖,直到没有新坐标。同一坐标出现多次时按两条规则裁决:
- 最近优先:路径短的赢,深度 1 永远赢深度 2;
- 同深度先声明者赢:
pom.xml里写在前面那条生效。
$ mvn dependency:tree -Dverbose
[INFO] top.example:demo:jar:1.0.0-SNAPSHOT
[INFO] +- org.springframework:spring-web:jar:6.1.4:compile
[INFO] | \- org.springframework:spring-core:jar:6.1.4:compile
[INFO] \- com.example:legacy-lib:jar:2.0.0:compile
[INFO] \- (org.springframework:spring-core:jar:5.3.0:compile - omitted for conflict with 6.1.4)
读法:
| 记号 | 含义 |
|---|---|
+- / \- |
同级依赖,\- 是最后一个 |
| 缩进层级 | 依赖深度,第一层是直接依赖 |
omitted for conflict |
被调解规则淘汰,括号里是被淘汰的版本 |
omitted for duplicate |
同坐标同版本重复出现,只保留第一次 |
managed from X |
版本由 dependencyManagement 统一指定 |
树里赢了不代表运行时赢:容器或父类加载器可能换成别的版本,最终 classpath 用 mvn dependency:build-classpath 核对。
5. scope 五种,传播性各不同
| scope | 编译期可见 | 运行期可见 | 打包进产物 | 传递给下游 |
|---|---|---|---|---|
compile(默认) |
是 | 是 | 是 | 是 |
provided |
是 | 否(由容器提供) | 否 | 否 |
runtime |
否 | 是 | 是 | 是 |
test |
仅测试 | 仅测试 | 否 | 否 |
import |
— | — | — | 仅用于 dependencyManagement |
provided 是最容易出事的:本地 IDE 里有 servlet-api,编译通过;打成 war 后容器提供的是另一个版本,方法签名一变就是运行期 NoSuchMethodError。import 不是普通依赖,它只能出现在 <dependencyManagement> 里,用来把一份 BOM 的版本清单导入进来:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.3.6</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
6. SNAPSHOT 与 release 的行为差异
| 维度 | -SNAPSHOT |
release |
|---|---|---|
| 版本可变性 | 同一版本号可反复覆盖 | 按约定不可变 |
| 本地仓库更新 | 按 updatePolicy 检查远程 metadata |
存在即复用 |
| 依赖方拿到什么 | 可能每天不同 | 固定 |
| 适用场景 | 团队内部联调 | 对外发布、上线制品 |
-U 强制刷新只对 SNAPSHOT 有意义(以及清理"上次下载失败留下的 .lastUpdated 标记")。release 版本重复 deploy 到同一仓库,多数仓库管理器会拒绝覆盖。
7. 多模块里的三个不同概念
| 概念 | 写法 | 作用 | 常见误解 |
|---|---|---|---|
| 聚合(aggregation) | <modules> |
一条命令构建多个子模块 | 以为聚合等于继承 |
| 继承(inheritance) | <parent> |
子模块继承配置与版本 | 以为必须同目录 |
| 依赖(dependency) | <dependencies> |
模块间引用代码 | 以为子模块自动互相可见 |
root/pom.xml <modules> 里列 a、b ← 只负责"一起构建"
root/pom.xml <dependencyManagement> ← 只负责"统一版本"
a/pom.xml <parent> 指向 root ← 继承默认值
b/pom.xml 依赖 a 的坐标 ← 才产生编译期可见
聚合不产生依赖:b 能用 a,是因为 a 装进了本地仓库,不是因为同在一个 <modules> 里。
8. 仓库优先级与"下载了但不是它"
查找顺序是固定的三层:
~/.m2/repository → settings.xml 中的 mirror(覆盖原仓库地址) → 远程仓库
<mirrorOf>*</mirrorOf> 会把包括 central 在内的所有仓库请求都改写到镜像,于是"我明明在 pom 里配了私服"却仍然从镜像拿包。判断实际来源有三个办法:
$ mvn -X dependency:resolve 2>&1 | grep -i "download"
$ cat ~/.m2/repository/top/example/demo/1.0.0/_remote.repositories
$ mvn help:effective-settings
_remote.repositories 记录构件来自哪个仓库 id,effective-settings 展示镜像合并后的真实结果。清 .m2 后行为突变,多因这个标记被一起删掉。
9. 最小可跑示例
$ mkdir -p demo/src/main/java/top/example && cd demo
$ # 写入上面那份 pom.xml,再写一个 Hello.java
$ mvn -q package
$ mvn dependency:tree
$ mvn -o package # 离线:只读本地仓库,验证依赖是否齐
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
上线后 NoSuchMethodError |
依赖调解选中了低版本,或容器覆盖 | dependency:tree 定位,用 dependencyManagement 钉版本 |
mvn package 报测试失败 |
test 阶段先于 package |
修测试;-DskipTests 只跳过执行,-Dmaven.test.skip=true 连编译都跳过 |
| 本地能跑、别人拉下来报错 | 用了未 deploy 的 SNAPSHOT | 该模块先 mvn install/deploy,或统一走私服 |
install 后行为没变 |
本地仓库有更早的同版本构件 | 确认版本号是否递增,SNAPSHOT 加 -U |
| 多模块版本不同步 | 子模块各自写死版本 | 版本提到父工程 <properties>,子模块引用 ${revision} 或父版本 |
清了 .m2 才正常 |
旧构件残留 / _remote.repositories 指向旧仓库 |
只删对应坐标目录,不整仓删除 |
11. 边界
| 需求 | 更合适的做法 | 理由 |
|---|---|---|
| 构建缓存与增量 | Gradle build cache | Maven 只做产物级跳过,无跨机缓存 |
| 依赖漏洞扫描 | dependency-check 或平台 SCA |
生命周期本身不检查 CVE |
| 环境隔离的构建 | 容器内构建 + 固定基础镜像 | 本机 JDK/Maven 版本漂移无法复现 |
| 发布流水线 | CI 里 deploy + 制品库 |
人手 install 会把污染带进本地仓库 |
12. 自测
mvn package会不会执行test阶段?依据是哪条规则?process-resources阶段默认绑定的 goal 是哪个,产物落在哪个目录?- 一个依赖在深度 2 是
2.0,深度 3 是3.0,最终选哪个?为什么? provided与compile在打包和传递上差在哪?- 聚合(
<modules>)为什么不能让模块之间直接引用代码? - 明明配了私服却从镜像下载,该用哪两个命令确认?