Maven 从零到精通:坐标、依赖调解与构建生命周期
Maven 干的事只有一件:把你「需要什么」写成一张清单(pom.xml),然后按这张清单自动取货、加工、装箱。你不需要知道 jar 存在硬盘哪个角落,也不需要手工拼一串编译命令——你只要写上坐标(组织名 + 模块名 + 版本号),剩下的交给它。这一篇把四件事讲透:你是谁(坐标)→ 你需要什么(依赖与 scope)→ 从哪取货(仓库与镜像)→ 怎么加工(生命周期与多模块)。
Maven 像快递分拣中心。你在 App 里下单时填的收货信息就是坐标(groupId:artifactId:version),分拣中心扫一眼标签就知道该把哪个包裹放上哪条传送带(哪个阶段),完全不需要知道你家客厅在几楼。而 scope 是包裹上的「仅限拆箱验货 / 必须签收 / 别寄这户」贴纸——贴错贴纸,包裹就会在错误的场合出现或干脆缺席。

学完这一篇,你应该能回答:
- 为什么换一台机器只要一份
pom.xml就能构建,而手抄javac -cp不行? - 同一个库被两条路径带进来两个版本,Maven 选哪个?我能不能强行改判?
mvn clean package这五个词各自触发了什么?测试代码到底有没有被编译?
先看没有构建工具的世界。你写了三个类,编译只要一行:
javac -d out src/main/java/com/example/OrderService.javajavac 会顺着 import 把依赖的类一并编译。但真实项目有两百个源文件、十几个第三方库时,命令会膨胀成这样:
javac -d out \ -cp "lib/spring-core.jar:lib/spring-context.jar:lib/jackson-databind.jar:lib/mysql-connector-j.jar" \ $(find src/main/java -name "*.java")- 每引入一个依赖,都要手工往
-cp里补一个 jar 路径,漏一个就编译失败 - 依赖还有它自己的依赖(传递依赖),全靠人肉记忆逐个补齐几乎不可能
- 换一台机器,jar 的位置和版本全变了,卡住你的永远是环境而不是代码
这还只是「编译」。接下来「跑测试」「打成 jar」「发布到服务器」每一步都要重复一遍这套手工活。构建工具要解决的正是这件事:把拉依赖、编译、测试、打包、发布固化成一条可重复、可声明、跨机器一致的标准流水线。
Maven 是这条流水线上最经典也最主流的工具。它和手写 javac 的根本区别在于「认什么」:
- 手写 javac 认的是「本机路径」——
lib/xxx.jar必须真实存在于磁盘 - Maven 认的是「坐标」——你只声明
org.springframework:spring-core:6.1.0,它自己去仓库把文件找回来 - 于是换机器只需要一份
pom.xml,不再依赖任何人的本地环境

上面这张图是本篇的骨架:左上角是坐标(GAV),用它定位一个构件;左下角是仓库体系,决定去哪取货;右边两列是生命周期与插件,决定取到货之后按什么顺序加工。后面每一节都只是在展开其中一格。
Maven 找 jar 的顺序,和你在大学里找书先看总馆一模一样——先翻自己书架(本地仓库 ~/.m2/repository),没有就去学校总馆借阅系统(私服 Nexus),还没有才去国家图书馆(中央仓库)。找到之后各层都会留一份复印件,所以第二次借同一本书几乎不用等。
把 Maven 记成两句话——「依赖管家」加「标准化流水线」,而 pom.xml 就是它的说明书,声明式地描述项目需要什么、产出什么。
Maven 本身是一个压缩包,解压后把 bin 加入 PATH 即可;它还需要一个 JAVA_HOME 指向 JDK:
# Windows:解压到 D:\dev\apache-maven-3.9.6,然后配置环境变量MAVEN_HOME = D:\dev\apache-maven-3.9.6PATH += %MAVEN_HOME%\binmvn -v# Apache Maven 3.9.6 ... Java version: 17.0.10 ... OS name: "windows 11"mvn -v 会同时打印 Maven 版本、它实际使用的 JDK、操作系统——排查「Maven 用错了 JDK」时,第一个要看的就是它。
Maven 默认从海外的中央仓库(repo.maven.apache.org)下载依赖,国内经常慢到超时。解决办法是配置镜像。这里有个初学者最容易混淆的点:settings.xml 有两个位置,作用范围完全不同。
| 位置 | 作用范围 | 是否随机器走 | 建议 |
|---|---|---|---|
MAVEN_HOME/conf/settings.xml | 所有用户 | 否(重装即丢) | 用来看默认值,别改 |
~/.m2/settings.xml(用户级) | 当前用户 | 是(跟人走) | 改这里 |
下面是完整的用户级 ~/.m2/settings.xml,直接照抄即可使用:
<?xml version="1.0" encoding="UTF-8"?><settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd"> <!-- 1. 本地仓库位置:依赖下载后存放的目录 --> <localRepository>D:/dev/.m2/repository</localRepository> <mirrors> <!-- 2. 阿里云镜像:镜像中央仓库,下载速度提升明显 --> <mirror> <id>aliyun-central</id> <name>Aliyun Central</name> <url>https://maven.aliyun.com/repository/public</url> <mirrorOf>central</mirrorOf> </mirror> </mirrors> <profiles> <profile> <id>jdk-17</id> <activation> <activeByDefault>true</activeByDefault> </activation> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> </profile> </profiles></settings><localRepository>:告诉 Maven 依赖缓存在哪,默认是~/.m2/repository;换个独立盘符更利于备份<mirror>:把对central的请求改写成阿里云地址,mirrorOf是匹配规则mirrorOf写central只镜像中央仓库;写会镜像所有仓库(含私服)——*用私服时千万别写*
坑:mirrorOf 写成 会把公司私服的请求也劫持到阿里云,导致私服里的内部包永远拉不到,报 Could not find artifact com.yourcorp:xxx。正确做法是把私服配在 <repositories> 里,镜像只写 central,或写成 ,!your-private-repo。
依赖拉过一次就缓存在本地仓库,断网也能构建。三个高频开关值得记住:
-o(offline):强制离线,只用本地仓库,适合无网环境或验证缓存是否完整-U(update-snapshots):强制检查 SNAPSHOT 依赖的更新,多人协作必备-X(debug):打印完整调试日志,报错时打开它能看到 Maven 到底在请求哪个地址
依赖「明明刚发布却拉不到」时,先试 mvn -U clean package。SNAPSHOT 版本默认每天只更新一次,-U 会强制立刻拉取最新快照。
这个「每天一次」是唯一能被你拖动的数字,也是多人协作时最常见的隐形时差。把它当成一个可调项拖一遍——左边改的是仓库的 <updatePolicy>(换算成分钟),右边给的是这一档在项目里的真实症状:
- 每天第一次构建去比对时间戳,之后全天吃缓存
- 「昨天发布的包今天怎么还拉不到」大多就是卡在这一档
- 这是 Maven 的出厂设置,也是绝大多数单模块项目的合理选择
- 排查时先想清楚:你的缓存上次刷新是几点
-U 与 <updatePolicy> 是同一件事的两个入口——前者只对当次构建生效,后者写进 settings.xml 或仓库定义里长期生效。CI 脚本里常见的 mvn -U clean deploy 就是在临时压过 daily。
Maven 世界里,每个构件(artifact)都由一组坐标唯一定位,简称 GAV:
<dependency> <groupId>org.springframework</groupId> <!-- 组织 / 命名空间 --> <artifactId>spring-context</artifactId> <!-- 模块名 --> <version>6.1.0</version> <!-- 版本号 --> <packaging>jar</packaging> <!-- 打包类型(可省,默认 jar) --></dependency>groupId:通常是反写的组织域名,如org.springframeworkartifactId:模块名,如spring-contextversion:版本号,SNAPSHOT结尾表示开发中的快照- 三者共同映射到仓库里的一个路径:
org/springframework/spring-context/6.1.0/spring-context-6.1.0.jar
| 仓库类型 | 位置 | 作用 | 谁维护 |
|---|---|---|---|
| 本地仓库 | 本机磁盘 ~/.m2/repository | 缓存已下载的依赖,构建时优先查这里 | 你(Maven 自动填充) |
| 中央仓库 | 公网 repo.maven.apache.org | 开源公共库的唯一官方源 | Maven 社区 |
| 私服 | 公司内网(Nexus / Artifactory) | 代理公网加存放内部私有包 | 公司运维 |
依赖解析的顺序是:本地仓库 → 私服(若配置)→ 中央仓库。找到后会逐层回填缓存:私服把公网包缓存一份,本地仓库再把私服包缓存一份。这样第二次构建几乎不碰网络。
私服存在的意义有两个——加速(内网代理公网,全员共享一份缓存)和隔离(内部包不出内网、外网包可审计)。面试问「为什么要私服」,答这两点即可。
依赖声明里最容易用错、也最容易埋雷的字段是 scope。它决定一个依赖在「编译主代码 / 编译测试 / 运行时」三个场景下是否可见。
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.30</version> <scope>provided</scope> <!-- 只在编译期使用,不打包 --></dependency>六种 scope 的完整对照如下,建议收藏:
| scope | 主代码能用 | 测试能用 | 运行时打包 | 典型场景 |
|---|---|---|---|---|
compile(默认) | 能 | 能 | 会打包 | Spring、Jackson 等常规依赖 |
provided | 能 | 能 | 不打包 | servlet-api、lombok(由容器 / JDK 提供) |
runtime | 不能 | 能 | 会打包 | JDBC 驱动(编译期不需要实现) |
test | 不能 | 能 | 不打包 | JUnit、Mockito |
system | 能 | 能 | 不打包 | 本地路径 jar(已废弃,别用) |
import | — | — | — | 仅用于 dependencyManagement 导入 BOM |
下面这个沙盘把三个维度即时联动,切换 scope 就能看到差异:
主代码能否使用:能测试代码能否使用:能运行时是否打包:会打进最终产物
把 JUnit 的 scope 写成 compile(或干脆省略)会把测试框架打进生产包;把 JDBC 驱动写成 provided 会导致上线后报 ClassNotFoundException: com.mysql.cj.jdbc.Driver。scope 用错,本地百分之百跑得通,生产百分之百出事。
上面那张表有六行,抄配置时最容易的一件事就是「忘了写 scope」。别抄——勾一遍:这个生成器每勾一项,都会按它的真实归属生成对应的 <scope>,并在下面告诉你为什么是这一档:
<?xml version="1.0" encoding="UTF-8"?>
<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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>你直接声明的叫直接依赖;它自己声明的依赖会「传递」给你,叫传递依赖。麻烦在于:多条依赖路径可能引入同一个库的不同版本,Maven 必须选出一个——这就是依赖调解(mediation)。

同名同学要写全学号。一个班里有三个「张伟」,老师点名时必须说全「学院-班级-学号」才有人应答;Maven 的坐标 groupId:artifactId:version 就是这套全学号制度。麻烦在于——同一个「学院 + 姓名」(同一个 groupId:artifactId)同时出现两个不同学号(1.0 和 1.1),而 JVM 只认「学院 + 姓名」这一层,所以只能留一个。留哪个,就是下面这条调解规则。
上面那句「只能留一个」在真实项目里最容易出事的是深度相同的情形。切到「版本冲突」,你可以亲手调换两条依赖在 pom.xml 里的书写顺序,观察胜出版本如何翻转——这也解释了为什么「加个依赖没改代码却突然报错」:
举个例子。项目声明了 A 和 C:
my-app├── A:1.0│ └── commons:1.0 ← 路径:my-app → A → commons(长度 2)└── C:1.0 └── B:1.0 └── commons:1.1 ← 路径:my-app → C → B → commons(长度 3)commons出现了两个版本:1.0 和 1.1- 规则一「最短路径优先」:1.0 的路径长度为 2,比 1.1 的 3 短,所以选 1.0
- 若两条路径长度相同,触发规则二「先声明优先」:在
pom.xml里写在前面的依赖胜出
这就是 Maven 依赖调解的全部核心。理解它,你就能提前预测 mvn dependency:tree 的结果。
想让「传递进来的版本」出局,用 <exclusions>:
<dependency> <groupId>com.example</groupId> <artifactId>C</artifactId> <version>1.0</version> <exclusions> <!-- 排除 C 传递进来的 commons,改用我们自己声明的版本 --> <exclusion> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> </exclusion> </exclusions></dependency><exclusion>只写groupId加artifactId,不写版本号(它的语义是「整条子树都不要」)- 排除后,可以再由你直接声明期望的版本,做到「我的版本我做主」
反过来,作者想阻止自己的某个依赖继续向外传递,用 <optional>true</optional>:
<dependency> <groupId>com.example</groupId> <artifactId>redis-client</artifactId> <version>2.0</version> <optional>true</optional> <!-- 用完即止,不再向下游传递 --></dependency>说明:optional 是「依赖作者」的工具,exclusions 是「依赖使用者」的工具。前者写在库自己的 pom 里,后者写在你的项目 pom 里——别搞反。
版本调错了,代码往往要到运行期才炸。两个命令是排查主力:
# 1. 打印完整依赖树,定位「谁把过期版本带进来了」mvn dependency:tree# 2. 分析「声明了但没被用到 / 被覆盖」的依赖,给出优化建议mvn dependency:analyzedependency:tree 的典型输出,用缩进表示路径深度:
[INFO] com.example:my-app:jar:1.0-SNAPSHOT[INFO] +- org.springframework:spring-webmvc:jar:6.1.0:compile[INFO] \- com.fasterxml.jackson.core:jackson-databind:jar:2.16.0:compile[INFO] \- com.fasterxml.jackson.core:jackson-core:jar:2.15.0:compile[INFO] (version managed from 2.16.0) # ← 被某个 dependencyManagement 降级了下面是一个真实的 NoSuchMethodError 现场:你用了 Jackson 2.16 才有的 writeValueAsBytes 重载,但运行时 classpath 里其实是 2.15:
java.lang.NoSuchMethodError: 'byte[] com.fasterxml.jackson.databind.ObjectMapper.writeValueAsBytes(java.lang.Object)' at com.example.api.OrderController.toJson(OrderController.java:42)NoSuchMethodError/NoSuchMethodException几乎都是「编译用 A 版本、运行用 B 版本」导致的- 第一步永远是
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core,看最终选中的是哪个版本 - 锁版本的正确姿势是在父 pom 的
<dependencyManagement>里统一声明(见第八节)
坑:只盯着类名是找不到根因的——NoSuchMethodError 说的是「这个类没有这个方法」,而方法缺失的真相往往是版本不一致,不是「代码写错了」。改代码之前,先 dependency:tree 确认版本。
那句「先 dependency:tree 确认版本」太容易变成一句口号。把上面那棵树摊成一次单步执行:左边是 Maven 收集到的候选,右边同步刷新「此刻的深度表」和「谁把谁带进来的」。连点下一步,重点看第 4 步——它选的是最近的,不是最新的:
<dependency>A:1.0</dependency> # 你的 pom 里只有两条直接依赖<dependency>C:1.0</dependency> # commons 一个都没写读 A 的 pom → 发现 commons:1.0 # 深度 2:my-app → A → commons读 C 的 pom → 发现 B → commons:1.1 # 深度 3:my-app → C → B → commons调解:同一 groupId:artifactId 只留一个编译 classpath 里是 commons:1.0,1.1 从头到尾没被下载| 直接依赖数 | 2 |
| commons 的声明 | 无 |
| 已收集候选 | 0 个 |
读取 pom.xmlMaven 有三套互相独立的生命周期,每套由若干阶段(phase)按顺序组成,执行靠后的阶段会自动执行它前面的:
| 生命周期 | 主要阶段(顺序执行) | 用途 |
|---|---|---|
| clean | pre-clean → clean → post-clean | 清理上一次构建产物 |
| default | validate → compile → test → package → verify → install → deploy | 编译、测试、打包、安装、发布 |
| site | pre-site → site → post-site → site-deploy | 生成项目文档站点 |
阶段本身是「空壳」,真正干活的是绑定到阶段的插件目标(goal),这是 Maven 最核心的设计:
| 阶段 | 默认绑定的插件目标 | 产出 |
|---|---|---|
| compile | maven-compiler-plugin:compile | target/classes 下的 .class |
| test | maven-surefire-plugin:test | 测试报告 |
| package | maven-jar-plugin:jar | target/*.jar |
| install | maven-install-plugin:install | 装进本地仓库 |
| deploy | maven-deploy-plugin:deploy | 推到私服 / 远程仓库 |

上面两张表一共十一行,但「读」和「会」之间差一步:得点。这张图把一次 mvn package 拆成五格,一格一格点下去,每格都说清「这一步是谁在干活、跳过去会怎样」:
这两件事——「阶段自动带上前面的」和「直接调 goal 会跳步」——是 Maven 最容易被问倒的地方。下面这个实验就是专治它的,四档按顺序切:
常用命令其实是「阶段组合」,逐个拆开看就懂了:
mvn clean package -DskipTests -U -Xclean:先跑 clean 生命周期,删掉targetpackage:跑 default 到 package 为止,产出 jar/war-DskipTests:跳过测试执行(仍编译测试代码);-Dmaven.test.skip=true才连测试代码都不编译-U:强制更新 SNAPSHOT;-X:打印 debug 日志
提示:-DskipTests 与 -Dmaven.test.skip=true 常被混用,但前者编译不执行、后者不编译也不执行。CI 上想省时间选后者,想保留编译校验选前者。
当项目长大,单个 pom 会变成几千行。Maven 用两个正交的概念拆分它:聚合(aggregation)负责「一起构建」,继承(inheritance)负责「共享配置」。
spring-demo/ ← 父 POM(父 pom 只写 packaging=pom)├── pom.xml├── demo-common/ ← 公共模块│ └── pom.xml├── demo-service/ ← 业务模块,依赖 common│ └── pom.xml└── demo-web/ ← Web 模块,依赖 service └── pom.xml父 POM 同时做聚合与继承两件事:
<project> <groupId>com.example</groupId> <artifactId>spring-demo</artifactId> <version>1.0.0</version> <packaging>pom</packaging> <!-- 父 POM 必须是 pom,不能是 jar --> <!-- 聚合:声明子模块,mvn install 会按依赖顺序一起构建 --> <modules> <module>demo-common</module> <module>demo-service</module> <module>demo-web</module> </modules> <!-- 继承:版本与配置在这里统一,子模块只声明 groupId 加 artifactId --> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>3.2.0</version> <type>pom</type> <scope>import</scope> <!-- import 导入 BOM --> </dependency> </dependencies> </dependencyManagement></project>子模块只需继承父 POM,并声明依赖(版本交给父级管):
<project> <parent> <groupId>com.example</groupId> <artifactId>spring-demo</artifactId> <version>1.0.0</version> </parent> <artifactId>demo-service</artifactId> <!-- groupId / version 继承自父 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <!-- 不写 version:由父 POM 的 dependencyManagement 决定 --> </dependency> </dependencies></project><modules>是聚合:让一次mvn install构建全部模块,Maven 会自动做拓扑排序<dependencyManagement>是继承:它只「管版本」,不真正引入依赖,子模块声明时省略 versionscope=import配合type=pom:把一整个 BOM(如 Spring Boot 的依赖清单)搬进你的版本管理中心
要点:dependencyManagement 与 dependencies 的区别是面试高频——前者是「版本报价单」,只在子模块显式声明后才生效;后者是「直接下单」,立刻引入依赖。
聚合最容易被当成「按目录顺序构建」,实际是按依赖图排序。这段动画把一次 mvn install 拆成六帧,注意第 5、6 帧:下游模块读的是本地仓库里刚 install 的那份,所以「改了 common 却没 install,web 里还是旧行为」从来不是缓存玄学,是它压根没读到你的新 class:

| 报错 / 现象 | 根因 | 解决 |
|---|---|---|
Could not resolve dependencies 或下载超时 | 网络慢或未配镜像 | 配阿里云镜像;mvn -U 重试 |
Could not find artifact com.corp:xxx | mirrorOf 写成 * 劫持了私服 | 镜像改为 central,私服单独配 <repositories> |
NoSuchMethodError / NoClassDefFoundError | 版本被调解成了别的版本 | mvn dependency:tree 定位,用 dependencyManagement 锁版本 |
运行期 ClassNotFoundException: 驱动类 | JDBC 驱动 scope 写成 provided/test | 改为 runtime(或默认 compile) |
| 测试框架被打进生产包 | JUnit 未声明 test scope | 显式写 <scope>test</scope> |
本地能跑、CI 报 JAVA_HOME invalid | 机器 JDK 不一致 | 用 toolchains 或 maven.compiler.release 锁定 |
SNAPSHOT 改动不生效 | 快照默认每天只更新一次 | mvn -U 强制刷新 |
| 改了代码却还是旧行为 | target 残留未清理 | mvn clean 后再构建 |
| 多模块改动没被下游看到 | 未 install 到本地仓库 | 根目录执行 mvn clean install |
出现 duplicate dependency 警告 | 同一模块被声明两次 | 合并重复声明,用 dependency:analyze 清理 |
把报错分成两类会高效很多——「拉不到」(网络、镜像、私服问题)和「用不对」(版本调解、scope、缓存问题)。前者看 settings.xml,后者看 dependency:tree。
第七节讲了三套生命周期,但「阶段」是抽象的。下面这段动画把一个依赖从写进 pom 到进入运行时 classpath 的全程走了一遍,注意第 4 步(调解)和第 6 步(按 scope 决定打不打包)——它们分别对应本篇第五节和第四节:

本节的第二个实验专治「我改了 scope 到底影响谁」。切四个按钮,你会看到同一个 jar 在「编译主代码 / 编译测试 / 打包」三个场景里的可见性如何变化,以及为什么 JDBC 驱动写成 provided 之后本地一切正常、上线立刻崩:
本节的第三个实验回答一个新手常见的疑问:mvn package 产出的那个瘦 jar 为什么 java -jar 跑不起来。fatjar(可执行胖包)把依赖塞进 BOOT-INF/lib,再用一个自定义类加载器读它们——这正好接上第一篇的 classpath 那节课:
第四个实验回到第七节那两条最坑人的规则。直接调 goal 会跳过前面的阶段(于是你把旧 class 打了新包),同一阶段上多个插件目标则按 pom 里的书写顺序排队。四档都点一遍,尤其是第三档:
最后补一个跨篇的坑:Maven 用的 JDK 和你终端里的 java 未必是同一个。mvn -v 那行 Java version 才是构建真正用的版本——这一档实验专门演了一遍它在多 JDK 机器上是怎么被选出来的:
实验做到这里可以换成自己敲命令。下面这台控制台连着浏览器里的同一个内核,回显全部是算出来的——先敲 lab mvnlife phases,再按顺序把下面几条打完:
lab mvnlife direct 和 lab mvnlife bind 要连着敲——前者少掉的执行行,正好就是后者清单里那些绑定的 goal。看懂这一对照,「为什么我只说了 package 它却跑了测试」这个问题就永久消失了。
规则你已经知道了(就近优先、等长看先后)。真正难的是判定之后怎么改。左边选冲突形态,右边选你的处置方案,面板会给出改完之后 dependency:tree 的结果和风险评级:
无匹配结果
沙盘里最反直觉的一格是「BOM 压过 + 直接声明」——你以为写了 version 就定了,实际它被 dependencyManagement 悄悄改写。判断方法只有一个:跑 mvn dependency:tree,看有没有 (version managed from ...) 那句话。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
'mvn' 不是内部或外部命令,也不是可运行的程序或批处理文件。 / 'mvn' is not recognized as an internal or external command | Maven 的 bin 不在 PATH,或改完环境变量没重开终端 | 加 %MAVEN_HOME%\bin 到 PATH,新开窗口再 mvn -v | #1 环境变量 |
The JAVA_HOME environment variable is not defined correctly | Maven 找不到 JDK(本篇讲的是 Maven,但它必须有 JAVA_HOME) | 指向 JDK 根目录,别带 \bin | #1 第三节 |
Could not resolve dependencies for project ...: Failed to collect dependencies at com.example:A:jar:1.0 | 仓库里没有这个坐标,或网络/镜像不通 | 先在仓库网页搜一下坐标是否存在,再 mvn -U | 本篇第二节 |
Could not find artifact com.yourcorp:xxx:jar:1.0 in aliyun-central | mirrorOf 写成 *,把私服请求也劫持走了 | 镜像只写 central,或 *,!your-private-repo | 本篇 2.1 节 |
java.lang.NoSuchMethodError: 'byte[] com.fasterxml.jackson.databind.ObjectMapper.writeValueAsBytes(java.lang.Object)' | 编译用 A 版本、运行用 B 版本——典型调解事故 | mvn dependency:tree -Dincludes=com.fasterxml.jackson.core | 本篇第六节 |
java.lang.NoClassDefFoundError: com/mysql/cj/jdbc/Driver 或运行期 ClassNotFoundException | JDBC 驱动被写成 provided/test,没进运行时 classpath | 改成 runtime(或默认 compile) | 本篇第四节 scope 沙盘 |
[WARNING] The POM for xxx is missing, no dependency information available / 反复报 same artifact 找不到 | 上一次下载失败留下了 .lastUpdated 占位文件 | 删掉本地仓库里该构件目录,或 mvn -U 重试 | 本篇 2.2 节 |
duplicate dependency 警告 / 同一 GA 出现两个版本 | 同一依赖被父子 pom 或同 pom 声明两次 | mvn dependency:analyze 清理,统一到 dependencyManagement | 本篇第八节 |
离线机器上最常遇到的是「明明有缓存却仍报找不到」。原因是本地仓库里那次失败的下载留下了 *.lastUpdated 文件,Maven 会认为「这个坐标我已经试过了」而不再联网。删掉那个构件目录重下,或用 -U 强制刷新。
上面那行 NoSuchMethodError 是本篇最典型的「Maven 没出错,但你出事」的现场。这段堆栈是真的,先别看答案——点出你认为的凶手行:
本地一切正常,CI 打出 fatjar 上线后,第一个导出接口就 500,日志里只有两行。
目标:亲手制造一次版本冲突,再用三种办法各解一次,感受它们的差别。
<?xml version="1.0" encoding="UTF-8"?><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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>dep-lab</artifactId> <version>1.0.0</version> <properties> <maven.compiler.release>17</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- 它会把 commons-logging 1.2 带进来 --> <dependency> <groupId>commons-cli</groupId> <artifactId>commons-cli</artifactId> <version>1.5.0</version> </dependency> <!-- 它会把 commons-logging 1.0 带进来 --> <dependency> <groupId>commons-attributes</groupId> <artifactId>commons-attributes-compiler</artifactId> <version>2.1</version> </dependency> </dependencies></project>执行并记录输出:
mvn -q dependency:tree -Dincludes=commons-logging预期结果形如(缩进即深度,胜者不带 omitted 标记):
[INFO] com.example:dep-lab:jar:1.0.0[INFO] +- commons-cli:commons-cli:jar:1.5.0:compile[INFO] | \- commons-logging:commons-logging:jar:1.2:compile[INFO] \- commons-attributes:commons-attributes-compiler:jar:2.1:compile[INFO] \- (commons-logging:commons-logging:jar:1.0:compile - omitted for duplicate)再加一段用到 1.2 才有的 API 的代码,观察编译期与运行期的表现,然后依次尝试:直接声明 1.0、给第一个依赖加 exclusion、在 dependencyManagement 里钉版本——每次都用同一条 dependency:tree 验证结果。
验收清单:你能用自己的话解释这三招分别「改变了什么」,以及为什么第三种最适合团队。
- 把两个依赖在
<dependencies>里的书写顺序调换,且故意让它们深度相同(可以再造一个中间层依赖)。你会观察到胜出版本跟着顺序翻转——这就是「靠行序决定运行行为」的脆弱之处。 - 把
commons-logging的 scope 从默认compile改成provided,再mvn package后用java -jar跑。你会观察到启动时抛NoClassDefFoundError,而构建全程绿灯。 - 打开你的
~/.m2/settings.xml,把mirrorOf从central改成*,然后构建一个依赖私服的工程。你会观察到Could not find artifact ...(若你没有私服,可用一个只存在于中央仓库的冷门坐标复现下载路径变化)。
提示:做完第 1 条再回来看第十二节沙盘里的「等深先后」那一列,结论会非常直观。
搭一个三模块工程,把本篇所有概念都落在里面:
multi-lab/ ← 父 pom:packaging=pom + <modules> + <dependencyManagement>├── lab-common/ ← 放一个工具类,无第三方依赖├── lab-service/ ← 依赖 lab-common,并引入一个含冲突传递的库└── lab-web/ ← 依赖 lab-service,打成可执行 fatjar要求:① 父 pom 用 dependencyManagement 统一管版本,子模块一律不写 <version>;② 至少一个子模块用到 <exclusions> 并在注释里写明为什么排除;③ 根目录 mvn clean install 全绿;④ mvn dependency:tree 里没有任何 omitted for conflict;⑤ 在 lab-web 里配 spring-boot-maven-plugin,产出能被 java -jar 直接跑的 fatjar。
验收清单:换一台机器(或删掉整个 ~/.m2/repository)后,只需一句 mvn clean install 就能重建全部结果——这才叫「可复现的构建」。
groupId:artifactId:version 三段各自代表什么?为什么它们能替代「本机 jar 路径」?
Maven 依赖调解的两条规则按什么顺序生效?它会不会优先考虑「版本更新」?
compile / provided / runtime / test 四种 scope,哪两种最容易导致「本地能跑、线上崩」?分别崩在哪一刻?
dependencyManagement 和 dependencies 的区别是什么?为什么前者被称为「报价单」?
mvn clean package -DskipTests 里每个词分别触发了哪个生命周期阶段?
坐标认人、仓库取货、就近定版、scope 定场、阶段绑 goal——GAV 是唯一身份证,本地→私服→中央依次取货,路径近的赢,可见性看场景,干活的全是插件目标。
从手动 javac 到 Maven,你换来的不是「更短的命令」,而是「声明式的、可复现的构建」。这篇文章的每个概念——坐标、仓库、scope、调解、生命周期、聚合继承——最终都为了同一个目标:换任何一台机器,mvn clean package 都能得到一模一样的结果。