读完这篇,你能做什么
能解释构建阶段与依赖 scope,并定位实际使用的依赖版本。
学习理由、准备要求与知识点
为什么学它
依赖和构建阶段决定项目能否在 IDE 之外复现,是框架学习的基础环境能力。
先读这些内容
知识点清单
groupId/artifactId/version- packaging 与标准目录
- 生命周期阶段
- phase 与 plugin goal
- clean 独立生命周期
- compile
/test/provided/runtime - 传递依赖和调解
- dependency:tree 证据
运行前先确认Java 17;Maven 3.9.9 源码。阶段示例只展示前缀选择;算法只讨论直接依赖范围,不实现传递依赖、模块路径或打包插件特例。
01先弄懂它是什么
项目文件和依赖多起来后,不能再只靠一条 javac 命令或手动复制 jar。Maven 用 POM 说明源码、依赖和生成物。groupId、artifactId、version 共同标识常见依赖,type 和 classifier 还能区分不同附件。依赖身份由这些坐标确定,不是随意起的目录名;把 jar 扔进源码目录,也没有说明它的版本、来源和依赖关系。
Maven 把构建分成按顺序进行的阶段,插件 goal 则负责具体工作。执行 package,会运行同一默认生命周期中此前阶段绑定的动作,例如编译和测试;clean 属于另一条生命周期,所以 package 不会自动先清空 target。直接执行某个插件 goal,也不一定自动完成之前所需的准备。
scope 说明编译、测试和运行时分别能找到哪些依赖。直接声明的 compile 依赖通常这几处都可见;runtime 用于运行和测试,不进入生产编译类路径;test 用于测试;provided 则供编译和测试使用,运行时预期由 JDK 或容器提供。这里的类路径就是 Java 查找类和库的位置。这些范围不是权限隔离,也不能单独决定打包结果:最终包含哪些 jar,还要核对打包插件和部署方式。
文中这几个词,先认一认
- phase
- 构建流程里的一个阶段,例如 test、package;运行到它时会执行此前阶段绑定的工作。
- goal
- 插件提供的一项具体动作,例如编译 Java 或运行测试。
- 依赖调解
- 不同依赖带来了同一个库的多个版本时,Maven 按规则选定实际采用哪一个。
02看看它怎么工作
Maven 真正使用的配置,不只来自眼前这一份 POM。它还会结合父 POM、已激活配置和默认规则,形成 effective POM,也就是合并后的有效配置。项目的 packaging 会默认安排一些插件动作,自己的 execution 还能补充安排。所以判断 package 等阶段实际做了什么,要一起看有效 POM 和执行日志;某个阶段若没有绑定动作,经过它时可能没有可见工作。
项目直接声明的库叫直接依赖,这些库继续引入的库叫传递依赖。同一个库沿不同路径带来多个版本时,Maven 通常优先选离当前项目更近的版本;路径一样长时,声明先后也会影响选择,dependencyManagement 还可以管理版本。用 dependency:tree 看清实际从哪条路径引入。排除某条路径上的库后,要继续检查别的路径是否仍带着它,以及替代版本是否提供调用方需要的方法和类型。
测试类路径包含生产代码和测试所需依赖,不代表测试库也进入生产编译。把 JUnit 设为 test 可以表达边界,仍应查看生产产物。package 生成产物,install 还会安装到本地仓库,deploy 则涉及远程发布。三者的交付范围不同,不能把 BUILD SUCCESS 当成相同证据。
先跑通一个最小例子
在空目录按文件名建立目录结构。先运行 mvn -version,确认 Maven 使用 JDK 17 或更高版本。POM 固定编译插件版本并声明 release=17;首次构建需要解析插件,下面暂不引入第三方业务依赖。
<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>study.qinghuan</groupId>
<artifactId>first-maven</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build><plugins><plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin></plugins></build>
</project>package study.qinghuan;
public class App {
public static void main(String[] args) {
System.out.println("Hello Java"); // 包名与源文件目录保持一致。
}
}mvn -version
mvn package
java -cp target/classes study.qinghuan.App
mvn help:effective-pom
mvn dependency:tree核对运行结果package 成功后存在 target/classes/study/qinghuan/App.class 和 target/first-maven-1.0.0.jar;java -cp 输出 Hello Java。这个普通 JAR 未配置 Main-Class,所以此处明确使用类路径启动,不直接 java -jar。没有测试源码时,构建成功不表示已做业务测试。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["POM 与父配置"] --> B["有效模型"]
B --> C["选择生命周期与目标阶段"]
C --> D["收集阶段前缀的绑定目标"]
D --> E["运行插件"]
B --> F["依赖图与范围"]
F --> G["编译测试运行类路径"]
G --> E
E --> H["产物与测试报告"]按顺序看,每一步做了什么
- 确认坐标、packaging、生产源码和测试目录。
- 选择所需验收阶段,区分阶段请求与直接 goal。
- 查看有效 POM 中插件版本、编译目标及 execution。
- 通过依赖树解释版本及 scope,检查部署产物。
- 检查测试数量与报告,区分跳过、零测试和实际通过。
03对照实现代码
Apache Maven maven-3.9.9 · DefaultLifecycleMappingDelegate#calculateLifecycleMappings
原创教学实现,非源码节选。仅展示显式阶段绑定的前缀选择,省略 packaging 注入、插件默认 phase 和 execution 优先级。
import java.util.*;
class SourceModel {
static Map<String,List<String>> prefix(List<String> phases, String target,
Map<String,List<String>> bindings) {
int end = phases.indexOf(target);
// 未知阶段直接拒绝,不能默默执行全部阶段。
if (end < 0) throw new IllegalArgumentException("unknown phase");
Map<String,List<String>> plan = new LinkedHashMap<>();
for (int i = 0; i <= end; i++) {
String phase = phases.get(i);
// 保留阶段次序以及没有绑定动作的阶段。
plan.put(phase, List.copyOf(bindings.getOrDefault(phase,List.of())));
}
// 计划不是执行结果;插件运行仍可能失败。
return Collections.unmodifiableMap(plan);
}
}真实方法先构造截至目标阶段的有序映射,解释了阶段请求包含前序阶段的原因。
读取的是有效模型中的插件执行,其中已经可能包含 packaging 带来的配置。
绑定阶段有两个来源:POM 的 execution.phase 可以明确指定,插件 goal 自己也可能声明默认 phase。因此原 POM 没写 phase,不表示这个 goal 一定没绑定到阶段。
映射形成以后仍需实际执行;依赖解析、编译和测试分别存在失败点。
代码先调用谁,再调用谁
- 命令行任务
- 生命周期计划计算
calculateLifecycleMappings- 生成
MojoExecution列表 - 插件执行器
04动手写一个例子
计算直接依赖的可见类路径
为给定库名和 scope,计算生产编译、生产运行、测试编译用途下的直接依赖集合,按名称排序;重复库名拒绝。限定 compile、provided、runtime、test 四种范围。
- 输入 1
api=compile,driver=runtime,junit=test,servlet=provided;生产运行- 输出 1
[api, driver]
- 输入 2
重复声明 api- 输出 2
拒绝重复
把 scope 转成明确用途集合,再按用途过滤。枚举区分 test 范围与 test 生命周期阶段,避免名称相同造成概念混淆。
输入先验证,确保一个库只有一项声明。重复输入即使范围相同也拒绝,避免隐含覆盖和未来变更时的歧义。
测试编译还需要运行实现,因此示例 runtime 会进入测试编译类路径。结果按名称排序仅用于稳定展示,不表示真实类加载优先级。
为什么这样写是对的已处理输入中,结果恰好包含允许当前用途的库,且每个库最多出现一次。
验证过滤均摊线性,最坏所有库入选后排序需要 N log N;集合与结果各最多 N 项。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
enum Scope { COMPILE, PROVIDED, RUNTIME, TEST }
enum Use { MAIN_COMPILE, MAIN_RUNTIME, TEST_COMPILE }
record Dependency(String name, Scope scope) {}
static List<String> visible(List<Dependency> deps, Use use) {
Set<String> seen = new HashSet<>();
List<String> result = new ArrayList<>();
for (Dependency d : deps) {
if (d.name() == null || d.name().isBlank() || d.scope() == null)
throw new IllegalArgumentException("invalid");
// 重复声明直接拒绝,不覆盖已有配置。
if (!seen.add(d.name())) throw new IllegalArgumentException("duplicate");
boolean allowed = switch (use) {
case MAIN_COMPILE -> d.scope() == Scope.COMPILE || d.scope() == Scope.PROVIDED;
case MAIN_RUNTIME -> d.scope() == Scope.COMPILE || d.scope() == Scope.RUNTIME;
// 这四种直接依赖均参与测试编译类路径。
case TEST_COMPILE -> true;
};
if (allowed) result.add(d.name());
}
// 只排序新结果,保持输入不变。
Collections.sort(result);
return List.copyOf(result);
}
static void eq(Object expected, Object actual) {
if (!Objects.equals(expected, actual)) throw new AssertionError(actual);
}
public static void main(String[] args) {
List<Dependency> deps = List.of(new Dependency("api",Scope.COMPILE),
new Dependency("driver",Scope.RUNTIME),new Dependency("junit",Scope.TEST),
new Dependency("servlet",Scope.PROVIDED));
eq(List.of("api","servlet"),visible(deps,Use.MAIN_COMPILE));
eq(List.of("api","driver"),visible(deps,Use.MAIN_RUNTIME));
eq(List.of("api","driver","junit","servlet"),visible(deps,Use.TEST_COMPILE));
eq(List.of(),visible(List.of(),Use.MAIN_RUNTIME));
try {
visible(List.of(deps.get(0),deps.get(0)),Use.MAIN_COMPILE);
throw new AssertionError("duplicate");
} catch (IllegalArgumentException expected) {}
System.out.println("PASS 5 dependency scope tests");
}
}这些特殊情况也要试一试
- 空列表返回空结果;provided 在生产编译有而普通生产运行无。
- runtime 不供生产源码编译,但供测试编译使用。
- 算法不处理 import、system、传递 scope 或打包插件特例。
进阶选读 · 依赖图的范围传播与版本解释
场景多个上游引入同一库的不同版本和范围,需要解释最终选择。
做法在依赖边记录范围、版本和声明位置,按官方传递范围矩阵传播,在管理版本约束下记录最近路径;生成保留关键前驱的解释报告。
复杂度简单图遍历 O(V+E),保存全部路径可能指数增长,应限制解释规模。
好处与代价自写实现很容易偏离 Resolver 的规则,实际诊断优先使用官方依赖树;算法用来理解机制。
05项目里怎么用
稳定构建需要固定相关插件版本,并由项目或父配置统一管理。依赖版本和编译 release 一样都是构建输入;SNAPSHOT 适合开发协作,但内容可能变化,发布记录应绑定明确产物与来源。构建日志需保留执行模块与测试数量,让验证范围可追溯。
选择 test、package 或 verify 应基于实际门槛。集成测试常使用 Failsafe 在 integration-test 与 verify 配合,直接停在 integration-test 可能跳过验证或清理阶段。具体动作取决于项目插件配置,不能因阶段名称里有 test 就断定全部测试已运行。
provided 表示部署容器需要提供兼容库;runtime 则要求运行时能找到实现库。IDE 能运行,可能是它自动配好了类路径,不代表打出的包也能独立运行。最后要拿实际产物按目标部署方式启动,才能确认构建结果真的可用。
容易踩的坑
- 把 phase 与 goal 混淆,直接执行 goal 后声称完整构建已验证。
- 认为 package 隐含 clean 或已经 install。
- 把测试库用于生产源码,依赖 IDE 设置掩盖范围问题。
- 把 provided 机械理解为所有打包插件一定排除该库。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| compile | 生产编译与运行均可使用。 | 可能扩大传递依赖和交付体积。 | 直接调用其 API 的生产依赖。 |
| runtime | 分离编译 API 与运行实现。 | 缺少实现可能延迟到运行才暴露。 | 通过标准接口调用的驱动实现。 |
| provided | 表达容器负责实现的责任。 | 需约束目标环境版本和部署方式。 | 由指定运行环境提供的组件。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01package 后其他本地项目一定能解析该产物吗?
展开参考答案与推导
不一定,install 才负责安装到本地仓库,package 主要生成产物。
02阶段无日志一定是失败吗?
展开参考答案与推导
不一定,该阶段可能没有绑定动作。
03test 范围与 test 阶段有何区别?
展开参考答案与推导
范围控制依赖可见性,阶段描述生命周期执行位置。
换个情况,还会处理吗
01编译成功但运行缺少驱动类怎么办?
展开参考答案与推导
核对接口依赖与运行实现,检查 runtime 配置及部署类路径。
02依赖树出现多个版本怎么办?
展开参考答案与推导
追踪实际选中版本与引入路径,再结合管理版本和兼容性处理。
03package 后保留旧文件怎么办?
展开参考答案与推导
先确认文件来源;需要干净验证时显式执行 clean,package 本身不隐含清理。
写代码试一试
01加入测试库应怎样影响生产运行集合?
- 输入 / 条件
新增 junit=test- 输出 / 预期
生产运行集合不变
提示 比较增加前后结果。
展开参考答案与推导
断言生产集合不变,同时测试编译集合包含 junit。
02怎样避免排序修改调用方数据?
- 输入 / 条件
不可变依赖列表- 输出 / 预期
调用成功且输入不变
提示 排序新建结果。
展开参考答案与推导
过滤到新的 ArrayList,排序后返回不可变副本。
03怎样验证 provided 的边界?
- 输入 / 条件
servlet=provided- 输出 / 预期
生产编译有、生产运行无、测试编译有
提示 覆盖三种用途。
展开参考答案与推导
对同一依赖分别断言三个用途的集合,不只验证一个分支。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能解释构建阶段与依赖 scope,并定位实际使用的依赖版本。
本篇重点加练
重点加练插件执行与依赖调解,检查声明配置最终是否进入真正的构建行为。
- phase 与 plugin goal
- 传递依赖和调解
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为一个课程工具库和使用它的应用设计 Maven 坐标、packaging 与标准目录;说明源码包名与
artifactId哪些部分有关、哪些只是项目约定。- 提交内容
- 两个最小 POM 和目录图。
- 怎样算完成
- 坐标可唯一定位版本
- 产物类型与工程目标匹配
练到的知识点groupId/artifactId/version · packaging 与标准目录
- 作业 02代码推演
列出执行 test、package、install 与 clean 时需要观察的阶段和产物;再把一个插件 goal 与其绑定 phase 分开说明,使用本地日志核验。
- 提交内容
- 阶段/目标对照表、运行日志和产物记录。
- 怎样算完成
- 独立生命周期不混淆
- 阶段与插件执行关系可验证
练到的知识点生命周期阶段 · phase 与 plugin goal · clean 独立生命周期
- 作业 03原理问答
同一个 plugin goal 可以由命令直接执行也可绑定 phase。为两种入口列出前置产物、配置读取和日志证明,设计一个缺少前置产物的检查案例。
- 提交内容
- 执行入口对照和验证计划。
- 怎样算完成
- 阶段与目标关系精确
- 运行入口的前提可核验
练到的知识点phase 与 plugin goal
综合应用
- 作业 04编程实践
创建只有一个主类和一个测试类的工程,为测试库、运行驱动和编译所需 API 设计 scope;用编译、测试和运行三种入口验证类路径。
- 提交内容
- POM、自写测试、类路径记录及运行证据。
- 怎样算完成
- 每项依赖用途明确
- 测试依赖不靠偶然 IDE 类路径
练到的知识点compile/test/provided/runtime
- 作业 05排错实验
在隔离项目构造两个直接依赖引入同一库不同版本的情况,保留 dependency:tree 和最终解析版本,再改变一条显式管理配置比较差异。
- 提交内容
- 最小 POM、自写版本或行为核验、前后依赖树。
- 怎样算完成
- 区分声明版本与选中版本
- 变更只有一个变量
练到的知识点传递依赖和调解 · dependency:tree 证据
- 作业 06面试场景
同学复制 jar 后应用可运行,干净机器通过 Maven 却缺类。建立从坐标、仓库、scope、传递链到打包内容的排查证据表。
- 提交内容
- 诊断流程与各层检查命令。
- 怎样算完成
- 不以本机缓存成功证明可复现
- 编译类路径与运行产物分开
练到的知识点groupId/artifactId/version · 生命周期阶段 · compile/test/provided/runtime · dependency:tree 证据
- 作业 07排错实验
在隔离 Maven 工程加入一个明确版本的插件执行,并记录依赖树;只改变执行绑定或依赖声明之一,比较实际日志和有效配置。
- 提交内容
- POM、自写产物/版本检查及两次构建证据。
- 怎样算完成
- 插件行为与库依赖分别观察
- 差异可归于单项变更
练到的知识点phase 与 plugin goal · 传递依赖和调解
- 作业 08面试场景
两个依赖树路径引入同库不同版本,源码能编译而运行出现方法缺失。构建从选中版本、打包内容到实际类来源的证据链。
- 提交内容
- 版本追踪表与最小验证步骤。
- 怎样算完成
- 编译与运行版本分别确认
- 不只看最顶层依赖声明
练到的知识点传递依赖和调解
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成构建插件基础后:测试被某个属性跳过但 CI 仍返回成功。设计构建验收契约,核对执行数量、报告、产物与解析依赖,不要求增加全套 CI 服务。
- 提交内容
- 验收脚本草案与故障模拟清单。
- 怎样算完成
- 成功码不替代必要行为证据
- 依赖与插件版本可追溯
练到的知识点生命周期阶段 · phase 与 plugin goal · 传递依赖和调解 · dependency:tree 证据
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
项目增加离线构建要求,工具链、插件与依赖都不能临时下载。分析“以前成功构建过一次”的证据是否足够,制定可重复验收。
- 提交内容
- 资源依赖清单和离线验证方案。
- 怎样算完成
- 插件与库解析都纳入
- 本机偶然缓存与受控材料区分
练到的知识点phase 与 plugin goal · 传递依赖和调解
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读教程英文
Maven:入门指南 (新标签页)↗
阅读标准目录、POM 与第一个构建命令,建立可复现的项目起点。
- 实践教程英文
Maven:构建生命周期 (新标签页)↗
区分阶段与插件目标,理解 test、package、verify、install 各自执行到哪里。
- 深入参考文档英文
Maven:依赖机制 (新标签页)↗
重点阅读依赖调解、scope、optional
/exclusion与dependencyManagement的区别。 - 深入参考文档英文
Java 17:javac 命令 (新标签页)↗
查阅 -encoding、-d、--release 和 classpath,用于解释编译器实际输入与输出。
- 深入参考文档英文
阶段映射源码 (新标签页)↗
定位阶段前缀与插件 execution 的真实计划生成过程。
- 深入参考文档英文
Maven Compiler Plugin 3.13.0 (新标签页)↗
核对入门 POM 固定的插件版本、compile 与
testCompile的生命周期绑定,以及 --release 配置入口。