Maven 从零到精通:坐标、依赖调解与构建生命周期

bee2026-10-0860 分钟0 次阅读
手写 settings.xml 镜像加速、GAV 坐标与仓库体系、依赖范围与传递依赖调解、三套生命周期与多模块工程——一次讲透 Maven 的每个核心概念。
1 / 154
小节
〇、30 秒看懂
2 / 154

Maven 干的事只有一件:把你「需要什么」写成一张清单(pom.xml),然后按这张清单自动取货、加工、装箱。你不需要知道 jar 存在硬盘哪个角落,也不需要手工拼一串编译命令——你只要写上坐标(组织名 + 模块名 + 版本号),剩下的交给它。这一篇把四件事讲透:你是谁(坐标)→ 你需要什么(依赖与 scope)→ 从哪取货(仓库与镜像)→ 怎么加工(生命周期与多模块)。

3 / 154
类比

Maven 像快递分拣中心。你在 App 里下单时填的收货信息就是坐标(groupId:artifactId:version),分拣中心扫一眼标签就知道该把哪个包裹放上哪条传送带(哪个阶段),完全不需要知道你家客厅在几楼。而 scope 是包裹上的「仅限拆箱验货 / 必须签收 / 别寄这户」贴纸——贴错贴纸,包裹就会在错误的场合出现或干脆缺席。

4 / 154
架构图
图 · 本篇地图:Maven 只回答四个问题
图 · 本篇地图:Maven 只回答四个问题
5 / 154

学完这一篇,你应该能回答:

6 / 154
  • 为什么换一台机器只要一份 pom.xml 就能构建,而手抄 javac -cp 不行?
  • 同一个库被两条路径带进来两个版本,Maven 选哪个?我能不能强行改判?
  • mvn clean package 这五个词各自触发了什么?测试代码到底有没有被编译?
7 / 154
小节
一、为什么需要构建工具
8 / 154

先看没有构建工具的世界。你写了三个类,编译只要一行:

9 / 154
bash
javac -d out src/main/java/com/example/OrderService.java
10 / 154

javac 会顺着 import 把依赖的类一并编译。但真实项目有两百个源文件、十几个第三方库时,命令会膨胀成这样:

11 / 154
代码对照
代码bash
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 的位置和版本全变了,卡住你的永远是环境而不是代码
12 / 154

这还只是「编译」。接下来「跑测试」「打成 jar」「发布到服务器」每一步都要重复一遍这套手工活。构建工具要解决的正是这件事:把拉依赖、编译、测试、打包、发布固化成一条可重复、可声明、跨机器一致的标准流水线。

13 / 154

Maven 是这条流水线上最经典也最主流的工具。它和手写 javac 的根本区别在于「认什么」:

14 / 154
  • 手写 javac 认的是「本机路径」——lib/xxx.jar 必须真实存在于磁盘
  • Maven 认的是「坐标」——你只声明 org.springframework:spring-core:6.1.0,它自己去仓库把文件找回来
  • 于是换机器只需要一份 pom.xml,不再依赖任何人的本地环境
15 / 154
架构图
图 · Maven 核心概念全景
图 · Maven 核心概念全景
16 / 154

上面这张图是本篇的骨架:左上角是坐标(GAV),用它定位一个构件;左下角是仓库体系,决定去哪取货;右边两列是生命周期与插件,决定取到货之后按什么顺序加工。后面每一节都只是在展开其中一格。

17 / 154
类比

Maven 找 jar 的顺序,和你在大学里找书先看总馆一模一样——先翻自己书架(本地仓库 ~/.m2/repository),没有就去学校总馆借阅系统(私服 Nexus),还没有才去国家图书馆(中央仓库)。找到之后各层都会留一份复印件,所以第二次借同一本书几乎不用等。

18 / 154
说明

把 Maven 记成两句话——「依赖管家」加「标准化流水线」,而 pom.xml 就是它的说明书,声明式地描述项目需要什么、产出什么。

19 / 154
小节
二、安装 Maven 与 settings.xml 的正确姿势
20 / 154

Maven 本身是一个压缩包,解压后把 bin 加入 PATH 即可;它还需要一个 JAVA_HOME 指向 JDK:

21 / 154
powershell
# 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"
22 / 154

mvn -v 会同时打印 Maven 版本、它实际使用的 JDK、操作系统——排查「Maven 用错了 JDK」时,第一个要看的就是它。

23 / 154
小节
2.1 settings.xml:镜像加速的关键
24 / 154

Maven 默认从海外的中央仓库(repo.maven.apache.org)下载依赖,国内经常慢到超时。解决办法是配置镜像。这里有个初学者最容易混淆的点:settings.xml 有两个位置,作用范围完全不同。

25 / 154
对照表
位置作用范围是否随机器走建议
MAVEN_HOME/conf/settings.xml所有用户否(重装即丢)用来看默认值,别改
~/.m2/settings.xml(用户级)当前用户是(跟人走)改这里
26 / 154

下面是完整的用户级 ~/.m2/settings.xml,直接照抄即可使用:

27 / 154
代码对照
代码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。

28 / 154
小节
2.2 离线模式与强制刷新
29 / 154

依赖拉过一次就缓存在本地仓库,断网也能构建。三个高频开关值得记住:

30 / 154
  • -o(offline):强制离线,只用本地仓库,适合无网环境或验证缓存是否完整
  • -U(update-snapshots):强制检查 SNAPSHOT 依赖的更新,多人协作必备
  • -X(debug):打印完整调试日志,报错时打开它能看到 Maven 到底在请求哪个地址
31 / 154
提示

依赖「明明刚发布却拉不到」时,先试 mvn -U clean package。SNAPSHOT 版本默认每天只更新一次,-U 会强制立刻拉取最新快照。

32 / 154

这个「每天一次」是唯一能被你拖动的数字,也是多人协作时最常见的隐形时差。把它当成一个可调项拖一遍——左边改的是仓库的 <updatePolicy>(换算成分钟),右边给的是这一档在项目里的真实症状:

33 / 154
参数调节台
调节台多久去远程仓库问一次「有没有新快照」
0 分钟 = never,1440 分钟 = daily(Maven 的默认档),60 = interval:60。先拖回默认的 1440 建立基准,再往两头拖
<updatePolicy> · SNAPSHOT 更新检查间隔
1440分钟当前 0 – 2880
daily(默认):一天问一次
  • 每天第一次构建去比对时间戳,之后全天吃缓存
  • 「昨天发布的包今天怎么还拉不到」大多就是卡在这一档
  • 这是 Maven 的出厂设置,也是绝大多数单模块项目的合理选择
  • 排查时先想清楚:你的缓存上次刷新是几点
构建速度80%
拿到新快照的概率55%
快照要新就调短间隔,构建要稳就锁 release 版本——别指望一个又慢又不确定的中间态。
34 / 154
说明

-U 与 <updatePolicy> 是同一件事的两个入口——前者只对当次构建生效,后者写进 settings.xml 或仓库定义里长期生效。CI 脚本里常见的 mvn -U clean deploy 就是在临时压过 daily。

35 / 154
小节
三、坐标 GAV 与仓库体系
36 / 154

Maven 世界里,每个构件(artifact)都由一组坐标唯一定位,简称 GAV:

37 / 154
代码对照
代码xml
<dependency>    <groupId>org.springframework</groupId>       <!-- 组织 / 命名空间 -->    <artifactId>spring-context</artifactId>       <!-- 模块名 -->    <version>6.1.0</version>                      <!-- 版本号 -->    <packaging>jar</packaging>                    <!-- 打包类型(可省,默认 jar) --></dependency>
解读
  • groupId:通常是反写的组织域名,如 org.springframework
  • artifactId:模块名,如 spring-context
  • version:版本号,SNAPSHOT 结尾表示开发中的快照
  • 三者共同映射到仓库里的一个路径:org/springframework/spring-context/6.1.0/spring-context-6.1.0.jar
38 / 154
小节
3.1 本地仓库 / 远程仓库 / 私服的关系
39 / 154
对照表
仓库类型位置作用谁维护
本地仓库本机磁盘 ~/.m2/repository缓存已下载的依赖,构建时优先查这里你(Maven 自动填充)
中央仓库公网 repo.maven.apache.org开源公共库的唯一官方源Maven 社区
私服公司内网(Nexus / Artifactory)代理公网加存放内部私有包公司运维
40 / 154

依赖解析的顺序是:本地仓库 → 私服(若配置)→ 中央仓库。找到后会逐层回填缓存:私服把公网包缓存一份,本地仓库再把私服包缓存一份。这样第二次构建几乎不碰网络。

41 / 154
要点

私服存在的意义有两个——加速(内网代理公网,全员共享一份缓存)和隔离(内部包不出内网、外网包可审计)。面试问「为什么要私服」,答这两点即可。

42 / 154
小节
四、依赖范围 scope 全解
43 / 154

依赖声明里最容易用错、也最容易埋雷的字段是 scope。它决定一个依赖在「编译主代码 / 编译测试 / 运行时」三个场景下是否可见。

44 / 154
xml
<dependency>    <groupId>org.projectlombok</groupId>    <artifactId>lombok</artifactId>    <version>1.18.30</version>    <scope>provided</scope>   <!-- 只在编译期使用,不打包 --></dependency>
45 / 154

六种 scope 的完整对照如下,建议收藏:

46 / 154
对照表
scope主代码能用测试能用运行时打包典型场景
compile(默认)能能会打包Spring、Jackson 等常规依赖
provided能能不打包servlet-api、lombok(由容器 / JDK 提供)
runtime不能能会打包JDBC 驱动(编译期不需要实现)
test不能能不打包JUnit、Mockito
system能能不打包本地路径 jar(已废弃,别用)
import———仅用于 dependencyManagement 导入 BOM
47 / 154

下面这个沙盘把三个维度即时联动,切换 scope 就能看到差异:

48 / 154
沙盘
沙盘依赖范围沙盘
运行结果
主代码能否使用:能
测试代码能否使用:能
运行时是否打包:会打进最终产物
默认范围,编译、测试、运行三态全部可见。
49 / 154
警告

把 JUnit 的 scope 写成 compile(或干脆省略)会把测试框架打进生产包;把 JDBC 驱动写成 provided 会导致上线后报 ClassNotFoundException: com.mysql.cj.jdbc.Driver。scope 用错,本地百分之百跑得通,生产百分之百出事。

50 / 154

上面那张表有六行,抄配置时最容易的一件事就是「忘了写 scope」。别抄——勾一遍:这个生成器每勾一项,都会按它的真实归属生成对应的 <scope>,并在下面告诉你为什么是这一档:

51 / 154
生成器
生成器勾一遍,看每个依赖该带哪个 scopepom.xml2 / 6
先只勾 Web(它没有 scope,因为默认 compile),再依次加上 MySQL、Lombok、Test:生成物里会分别出现 runtime、provided、test 三行——这三行就是本节那张表的落地形态
产物
<?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>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
Testscope=test;@SpringBootTest、MockMvc、AssertJ 都在里面,漏了就找不到 @Test。
52 / 154
小节
五、传递依赖与依赖调解
53 / 154

你直接声明的叫直接依赖;它自己声明的依赖会「传递」给你,叫传递依赖。麻烦在于:多条依赖路径可能引入同一个库的不同版本,Maven 必须选出一个——这就是依赖调解(mediation)。

54 / 154
原理动画
动图 · 依赖调解:版本冲突怎么定
动图 · 依赖调解:版本冲突怎么定
55 / 154
类比

同名同学要写全学号。一个班里有三个「张伟」,老师点名时必须说全「学院-班级-学号」才有人应答;Maven 的坐标 groupId:artifactId:version 就是这套全学号制度。麻烦在于——同一个「学院 + 姓名」(同一个 groupId:artifactId)同时出现两个不同学号(1.0 和 1.1),而 JVM 只认「学院 + 姓名」这一层,所以只能留一个。留哪个,就是下面这条调解规则。

56 / 154
内核实验
TeaVM依赖树怎么长出来:就近原则与 exclusion 现场未启动
依次点四个按钮;最后一步看 exclusion 如何把整棵子树剪掉
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
57 / 154

上面那句「只能留一个」在真实项目里最容易出事的是深度相同的情形。切到「版本冲突」,你可以亲手调换两条依赖在 pom.xml 里的书写顺序,观察胜出版本如何翻转——这也解释了为什么「加个依赖没改代码却突然报错」:

58 / 154
内核实验
TeaVM两个版本抢一个坑:谁赢?未启动
调换声明顺序再跑一次,注意「先声明优先」只在路径等长时生效
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
59 / 154

举个例子。项目声明了 A 和 C:

60 / 154
代码对照
代码text
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 里写在前面的依赖胜出
61 / 154

这就是 Maven 依赖调解的全部核心。理解它,你就能提前预测 mvn dependency:tree 的结果。

62 / 154
小节
5.1 改变调解结果:exclusions 与 optional
63 / 154

想让「传递进来的版本」出局,用 <exclusions>:

64 / 154
代码对照
代码xml
<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,不写版本号(它的语义是「整条子树都不要」)
  • 排除后,可以再由你直接声明期望的版本,做到「我的版本我做主」
65 / 154

反过来,作者想阻止自己的某个依赖继续向外传递,用 <optional>true</optional>:

66 / 154
代码对照
代码xml
<dependency>    <groupId>com.example</groupId>    <artifactId>redis-client</artifactId>    <version>2.0</version>    <optional>true</optional>   <!-- 用完即止,不再向下游传递 --></dependency>
解读

说明:optional 是「依赖作者」的工具,exclusions 是「依赖使用者」的工具。前者写在库自己的 pom 里,后者写在你的项目 pom 里——别搞反。

67 / 154
小节
六、依赖冲突排查实战
68 / 154

版本调错了,代码往往要到运行期才炸。两个命令是排查主力:

69 / 154
bash
# 1. 打印完整依赖树,定位「谁把过期版本带进来了」mvn dependency:tree# 2. 分析「声明了但没被用到 / 被覆盖」的依赖,给出优化建议mvn dependency:analyze
70 / 154

dependency:tree 的典型输出,用缩进表示路径深度:

71 / 154
text
[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 降级了
72 / 154

下面是一个真实的 NoSuchMethodError 现场:你用了 Jackson 2.16 才有的 writeValueAsBytes 重载,但运行时 classpath 里其实是 2.15:

73 / 154
代码对照
代码text
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 确认版本。

74 / 154

那句「先 dependency:tree 确认版本」太容易变成一句口号。把上面那棵树摊成一次单步执行:左边是 Maven 收集到的候选,右边同步刷新「此刻的深度表」和「谁把谁带进来的」。连点下一步,重点看第 4 步——它选的是最近的,不是最新的:

75 / 154
单步调试台
单步台跟着 Maven 走一遍调解:1.0 是怎么赢过 1.1 的1 / 6
六步走完一棵树,第 3 步起盯住右边那张深度表,它决定胜负
被调试的代码
1<dependency>A:1.0</dependency> # 你的 pom 里只有两条直接依赖
2<dependency>C:1.0</dependency> # commons 一个都没写
3读 A 的 pom → 发现 commons:1.0 # 深度 2:my-app → A → commons
4读 C 的 pom → 发现 B → commons:1.1 # 深度 3:my-app → C → B → commons
5调解:同一 groupId:artifactId 只留一个
6编译 classpath 里是 commons:1.0,1.1 从头到尾没被下载
此刻的变量
直接依赖数2
commons 的声明无
已收集候选0 个
调用栈
1读取 pom.xml
1调解的输入不是「你以为的版本」,而是这张依赖图。你的 pom 里一个 commons 都没写,但它照样会出现在 classpath 上——这就是传递依赖。
76 / 154
小节
七、三套生命周期与阶段
77 / 154

Maven 有三套互相独立的生命周期,每套由若干阶段(phase)按顺序组成,执行靠后的阶段会自动执行它前面的:

78 / 154
对照表
生命周期主要阶段(顺序执行)用途
cleanpre-clean → clean → post-clean清理上一次构建产物
defaultvalidate → compile → test → package → verify → install → deploy编译、测试、打包、安装、发布
sitepre-site → site → post-site → site-deploy生成项目文档站点
79 / 154

阶段本身是「空壳」,真正干活的是绑定到阶段的插件目标(goal),这是 Maven 最核心的设计:

80 / 154
对照表
阶段默认绑定的插件目标产出
compilemaven-compiler-plugin:compiletarget/classes 下的 .class
testmaven-surefire-plugin:test测试报告
packagemaven-jar-plugin:jartarget/*.jar
installmaven-install-plugin:install装进本地仓库
deploymaven-deploy-plugin:deploy推到私服 / 远程仓库
81 / 154
架构图
图 · 三条生命周期,与阶段背后干活的 goal
图 · 三条生命周期,与阶段背后干活的 goal
82 / 154

上面两张表一共十一行,但「读」和「会」之间差一步:得点。这张图把一次 mvn package 拆成五格,一格一格点下去,每格都说清「这一步是谁在干活、跳过去会怎样」:

83 / 154
交互图解
流程一次 mvn clean package 点着看(谁在干活)1 / 5
从 ① 点到 ⑤,重点在第 ② 格:clean 和 package 属于两条互不相干的链
→
→
→
→
① 命令被拆成阶段
mvn clean package 不是一个动作,而是「走 clean 链 + 走 default 链到 package」。两条生命周期彼此独立,写在一行里只是顺序执行。
全部看懂了一句话:阶段是排班表,goal 才是干活的人;你调的是阶段,做的是 goal。
84 / 154

这两件事——「阶段自动带上前面的」和「直接调 goal 会跳步」——是 Maven 最容易被问倒的地方。下面这个实验就是专治它的,四档按顺序切:

85 / 154
内核实验
TeaVM三条生命周期:谁带谁、谁跳过谁未启动
先点「三条生命周期」看清 clean 与 default 互不相干,再点「默认绑定」看每个阶段上挂着哪个 goal
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
86 / 154

常用命令其实是「阶段组合」,逐个拆开看就懂了:

87 / 154
代码对照
代码bash
mvn clean package -DskipTests -U -X
解读
  • clean:先跑 clean 生命周期,删掉 target
  • package:跑 default 到 package 为止,产出 jar/war
  • -DskipTests:跳过测试执行(仍编译测试代码);-Dmaven.test.skip=true 才连测试代码都不编译
  • -U:强制更新 SNAPSHOT;-X:打印 debug 日志

提示:-DskipTests 与 -Dmaven.test.skip=true 常被混用,但前者编译不执行、后者不编译也不执行。CI 上想省时间选后者,想保留编译校验选前者。

88 / 154
小节
八、多模块工程:聚合与继承
89 / 154

当项目长大,单个 pom 会变成几千行。Maven 用两个正交的概念拆分它:聚合(aggregation)负责「一起构建」,继承(inheritance)负责「共享配置」。

90 / 154
text
spring-demo/                 ← 父 POM(父 pom 只写 packaging=pom)├── pom.xml├── demo-common/             ← 公共模块│   └── pom.xml├── demo-service/            ← 业务模块,依赖 common│   └── pom.xml└── demo-web/                ← Web 模块,依赖 service    └── pom.xml
91 / 154

父 POM 同时做聚合与继承两件事:

92 / 154
xml
<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>
93 / 154

子模块只需继承父 POM,并声明依赖(版本交给父级管):

94 / 154
代码对照
代码xml
<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> 是继承:它只「管版本」,不真正引入依赖,子模块声明时省略 version
  • scope=import 配合 type=pom:把一整个 BOM(如 Spring Boot 的依赖清单)搬进你的版本管理中心

要点:dependencyManagement 与 dependencies 的区别是面试高频——前者是「版本报价单」,只在子模块显式声明后才生效;后者是「直接下单」,立刻引入依赖。

95 / 154

聚合最容易被当成「按目录顺序构建」,实际是按依赖图排序。这段动画把一次 mvn install 拆成六帧,注意第 5、6 帧:下游模块读的是本地仓库里刚 install 的那份,所以「改了 common 却没 install,web 里还是旧行为」从来不是缓存玄学,是它压根没读到你的新 class:

96 / 154
原理动画
动图 · 多模块:一次 mvn install 的构建顺序
动图 · 多模块:一次 mvn install 的构建顺序
97 / 154
小节
九、十个高频报错与坑
98 / 154
对照表
报错 / 现象根因解决
Could not resolve dependencies 或下载超时网络慢或未配镜像配阿里云镜像;mvn -U 重试
Could not find artifact com.corp:xxxmirrorOf 写成 * 劫持了私服镜像改为 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 清理
99 / 154
要点

把报错分成两类会高效很多——「拉不到」(网络、镜像、私服问题)和「用不对」(版本调解、scope、缓存问题)。前者看 settings.xml,后者看 dependency:tree。

100 / 154
小节
十、决策卡与总结
101 / 154
决策
决策同事的电脑上有个「祖传」依赖只能通过本地文件路径引入,于是他写了 `<scope>system</scope>` 加 `<systemPath>`,本地恰好有这个 jar,所以一切正常;上线后打包却缺了这个 jar。最正确的做法是什么?
102 / 154
小节
十一、动手实验:scope 与打包产物
103 / 154

第七节讲了三套生命周期,但「阶段」是抽象的。下面这段动画把一个依赖从写进 pom 到进入运行时 classpath 的全程走了一遍,注意第 4 步(调解)和第 6 步(按 scope 决定打不打包)——它们分别对应本篇第五节和第四节:

104 / 154
原理动画
动图 · 一个依赖的一生
动图 · 一个依赖的一生
105 / 154

本节的第二个实验专治「我改了 scope 到底影响谁」。切四个按钮,你会看到同一个 jar 在「编译主代码 / 编译测试 / 打包」三个场景里的可见性如何变化,以及为什么 JDBC 驱动写成 provided 之后本地一切正常、上线立刻崩:

106 / 154
内核实验
TeaVMscope 决定谁可见:一次改动三处联动未启动
先点 compile 建立基准,再逐个换成 provided / runtime / test 对比差异
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
107 / 154

本节的第三个实验回答一个新手常见的疑问:mvn package 产出的那个瘦 jar 为什么 java -jar 跑不起来。fatjar(可执行胖包)把依赖塞进 BOOT-INF/lib,再用一个自定义类加载器读它们——这正好接上第一篇的 classpath 那节课:

108 / 154
内核实验
TeaVM可执行 Jar 的内部结构:为什么瘦 jar 跑不起来未启动
先看 layout 认清目录,再看 loader 理解 BOOT-INF/lib 里的 jar 是怎么被加载的
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
109 / 154

第四个实验回到第七节那两条最坑人的规则。直接调 goal 会跳过前面的阶段(于是你把旧 class 打了新包),同一阶段上多个插件目标则按 pom 里的书写顺序排队。四档都点一遍,尤其是第三档:

110 / 154
内核实验
TeaVM直接调某个阶段 / goal:哪些步骤被跳掉了未启动
对比 mvn package 与 mvn jar:jar 的执行清单,少掉的那几行就是事故现场
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
111 / 154
内核实验
TeaVM一个阶段上绑了多个 goal:谁先跑未启动
注意 pom 里 <plugin> 的书写顺序如何决定执行顺序,这条规则解释了大量「我加了插件之后构建变了」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
112 / 154

最后补一个跨篇的坑:Maven 用的 JDK 和你终端里的 java 未必是同一个。mvn -v 那行 Java version 才是构建真正用的版本——这一档实验专门演了一遍它在多 JDK 机器上是怎么被选出来的:

113 / 154
内核实验
TeaVMMaven 到底挑了哪个 JDK未启动
看完回第二节开头那句:mvn -v 打印的 Java version 与 java -version 不同,就该改哪里
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
114 / 154

实验做到这里可以换成自己敲命令。下面这台控制台连着浏览器里的同一个内核,回显全部是算出来的——先敲 lab mvnlife phases,再按顺序把下面几条打完:

115 / 154
内核控制台
116 / 154
坑

lab mvnlife direct 和 lab mvnlife bind 要连着敲——前者少掉的执行行,正好就是后者清单里那些绑定的 goal。看懂这一对照,「为什么我只说了 package 它却跑了测试」这个问题就永久消失了。

117 / 154
小节
十二、沙盘:依赖冲突时选哪个版本
118 / 154

规则你已经知道了(就近优先、等长看先后)。真正难的是判定之后怎么改。左边选冲突形态,右边选你的处置方案,面板会给出改完之后 dependency:tree 的结果和风险评级:

119 / 154
沙盘
沙盘依赖冲突时选哪个版本
运行结果
无匹配结果
120 / 154
提示

沙盘里最反直觉的一格是「BOM 压过 + 直接声明」——你以为写了 version 就定了,实际它被 dependencyManagement 悄悄改写。判断方法只有一个:跑 mvn dependency:tree,看有没有 (version managed from ...) 那句话。

121 / 154
小节
十三、常见报错速查
122 / 154
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
'mvn' 不是内部或外部命令,也不是可运行的程序或批处理文件。 / 'mvn' is not recognized as an internal or external commandMaven 的 bin 不在 PATH,或改完环境变量没重开终端加 %MAVEN_HOME%\bin 到 PATH,新开窗口再 mvn -v#1 环境变量
The JAVA_HOME environment variable is not defined correctlyMaven 找不到 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-centralmirrorOf 写成 *,把私服请求也劫持走了镜像只写 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 或运行期 ClassNotFoundExceptionJDBC 驱动被写成 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本篇第八节
123 / 154
坑

离线机器上最常遇到的是「明明有缓存却仍报找不到」。原因是本地仓库里那次失败的下载留下了 *.lastUpdated 文件,Maven 会认为「这个坐标我已经试过了」而不再联网。删掉那个构件目录重下,或用 -U 强制刷新。

124 / 154

上面那行 NoSuchMethodError 是本篇最典型的「Maven 没出错,但你出事」的现场。这段堆栈是真的,先别看答案——点出你认为的凶手行:

125 / 154
报错急救
报错急救NoSuchMethodError: ObjectMapper.writeValueAsBytes(Object)
编译用的是 2.16,跑起来却是 2.15:调用点在业务代码里

本地一切正常,CI 打出 fatjar 上线后,第一个导出接口就 500,日志里只有两行。

Exception in thread "http-nio-8080-exec-5" java.lang.NoSuchMethodError: 'byte[] com.fasterxml.jackson.databind.ObjectMapper.writeValueAsBytes(java.lang.Object)'
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:897)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255)
at com.example.api.OrderController.list(OrderController.java:29)
at com.example.api.OrderController.toJson(OrderController.java:42)
at java.base/java.lang.invoke.DirectMethodHandle$Holder.invokeVirtual(DirectMethodHandle$Holder.java)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
126 / 154
小节
十四、随堂自测
127 / 154
随堂自测
随堂自测项目里 A 传递引入了 commons:1.0(路径深度 2),B 传递引入了 commons:1.1(深度 3),你在 pom 里没有任何一处直接写 commons。最终 classpath 上是哪个版本?
先自己选一个,选中立刻告诉你对不对
128 / 154
随堂自测
随堂自测`mvn clean package -DskipTests` 和 `mvn clean package -Dmaven.test.skip=true` 的区别是什么?
先自己选一个,选中立刻告诉你对不对
129 / 154
小节
十五、动手练习
130 / 154
小节
第一档 · 照做
131 / 154

目标:亲手制造一次版本冲突,再用三种办法各解一次,感受它们的差别。

132 / 154
xml
<?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>
133 / 154

执行并记录输出:

134 / 154
bash
mvn -q dependency:tree -Dincludes=commons-logging
135 / 154

预期结果形如(缩进即深度,胜者不带 omitted 标记):

136 / 154
text
[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)
137 / 154

再加一段用到 1.2 才有的 API 的代码,观察编译期与运行期的表现,然后依次尝试:直接声明 1.0、给第一个依赖加 exclusion、在 dependencyManagement 里钉版本——每次都用同一条 dependency:tree 验证结果。

138 / 154

验收清单:你能用自己的话解释这三招分别「改变了什么」,以及为什么第三种最适合团队。

139 / 154
小节
第二档 · 变体
140 / 154
  1. 把两个依赖在 <dependencies> 里的书写顺序调换,且故意让它们深度相同(可以再造一个中间层依赖)。你会观察到胜出版本跟着顺序翻转——这就是「靠行序决定运行行为」的脆弱之处。
  2. 把 commons-logging 的 scope 从默认 compile 改成 provided,再 mvn package 后用 java -jar 跑。你会观察到启动时抛 NoClassDefFoundError,而构建全程绿灯。
  3. 打开你的 ~/.m2/settings.xml,把 mirrorOf 从 central 改成 *,然后构建一个依赖私服的工程。你会观察到 Could not find artifact ...(若你没有私服,可用一个只存在于中央仓库的冷门坐标复现下载路径变化)。
141 / 154

提示:做完第 1 条再回来看第十二节沙盘里的「等深先后」那一列,结论会非常直观。

142 / 154
小节
第三档 · 造一个
143 / 154

搭一个三模块工程,把本篇所有概念都落在里面:

144 / 154
text
multi-lab/            ← 父 pom:packaging=pom + <modules> + <dependencyManagement>├── lab-common/       ← 放一个工具类,无第三方依赖├── lab-service/      ← 依赖 lab-common,并引入一个含冲突传递的库└── lab-web/          ← 依赖 lab-service,打成可执行 fatjar
145 / 154

要求:① 父 pom 用 dependencyManagement 统一管版本,子模块一律不写 <version>;② 至少一个子模块用到 <exclusions> 并在注释里写明为什么排除;③ 根目录 mvn clean install 全绿;④ mvn dependency:tree 里没有任何 omitted for conflict;⑤ 在 lab-web 里配 spring-boot-maven-plugin,产出能被 java -jar 直接跑的 fatjar。

146 / 154

验收清单:换一台机器(或删掉整个 ~/.m2/repository)后,只需一句 mvn clean install 就能重建全部结果——这才叫「可复现的构建」。

147 / 154
小节
十六、要点自查
148 / 154
自检

groupId:artifactId:version 三段各自代表什么?为什么它们能替代「本机 jar 路径」?

149 / 154
自检

Maven 依赖调解的两条规则按什么顺序生效?它会不会优先考虑「版本更新」?

150 / 154
自检

compile / provided / runtime / test 四种 scope,哪两种最容易导致「本地能跑、线上崩」?分别崩在哪一刻?

151 / 154
自检

dependencyManagement 和 dependencies 的区别是什么?为什么前者被称为「报价单」?

152 / 154
自检

mvn clean package -DskipTests 里每个词分别触发了哪个生命周期阶段?

153 / 154
口诀

坐标认人、仓库取货、就近定版、scope 定场、阶段绑 goal——GAV 是唯一身份证,本地→私服→中央依次取货,路径近的赢,可见性看场景,干活的全是插件目标。

154 / 154
总结

从手动 javac 到 Maven,你换来的不是「更短的命令」,而是「声明式的、可复现的构建」。这篇文章的每个概念——坐标、仓库、scope、调解、生命周期、聚合继承——最终都为了同一个目标:换任何一台机器,mvn clean package 都能得到一模一样的结果。