qinghuan
← Java Web / 全部笔记

单元 35 / Java Web

P0 · 必会主线L1 · 基础

Maven 主线:坐标、生命周期与依赖范围

用 POM 写清依赖和构建步骤,分清 package、clean 与插件动作,再判断依赖在哪些阶段可用。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能解释构建阶段与依赖 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;首次构建需要解析插件,下面暂不引入第三方业务依赖。

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>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>
src/main/java/study/qinghuan/App.java · Java
package study.qinghuan;

public class App {
    public static void main(String[] args) {
        System.out.println("Hello Java"); // 包名与源文件目录保持一致。
    }
}
在 pom.xml 所在目录执行 · PowerShell
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["产物与测试报告"]

按顺序看,每一步做了什么

  1. 确认坐标、packaging、生产源码和测试目录。
  2. 选择所需验收阶段,区分阶段请求与直接 goal。
  3. 查看有效 POM 中插件版本、编译目标及 execution。
  4. 通过依赖树解释版本及 scope,检查部署产物。
  5. 检查测试数量与报告,区分跳过、零测试和实际通过。

03对照实现代码

生命周期映射中的阶段前缀 (新标签页)↗

Apache Maven maven-3.9.9 · DefaultLifecycleMappingDelegate#calculateLifecycleMappings

原创教学实现,非源码节选。仅展示显式阶段绑定的前缀选择,省略 packaging 注入、插件默认 phase 和 execution 优先级。

核心机制代码 · java
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 一定没绑定到阶段。

映射形成以后仍需实际执行;依赖解析、编译和测试分别存在失败点。

代码先调用谁,再调用谁

  1. 命令行任务
  2. 生命周期计划计算
  3. calculateLifecycleMappings
  4. 生成 MojoExecution 列表
  5. 插件执行器

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 会进入测试编译类路径。结果按名称排序仅用于稳定展示,不表示真实类加载优先级。

为什么这样写是对的已处理输入中,结果恰好包含允许当前用途的库,且每个库最多出现一次。

时间 O(N log N)额外空间 O(N)

验证过滤均摊线性,最坏所有库入选后排序需要 N log N;集合与结果各最多 N 项。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 43 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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
  • 传递依赖和调解

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为一个课程工具库和使用它的应用设计 Maven 坐标、packaging 与标准目录;说明源码包名与 artifactId 哪些部分有关、哪些只是项目约定。

    提交内容
    两个最小 POM 和目录图。
    怎样算完成
    • 坐标可唯一定位版本
    • 产物类型与工程目标匹配

    练到的知识点groupId/artifactId/version · packaging 与标准目录

  2. 作业 02代码推演

    列出执行 test、package、install 与 clean 时需要观察的阶段和产物;再把一个插件 goal 与其绑定 phase 分开说明,使用本地日志核验。

    提交内容
    阶段/目标对照表、运行日志和产物记录。
    怎样算完成
    • 独立生命周期不混淆
    • 阶段与插件执行关系可验证

    练到的知识点生命周期阶段 · phase 与 plugin goal · clean 独立生命周期

  3. 作业 03原理问答

    同一个 plugin goal 可以由命令直接执行也可绑定 phase。为两种入口列出前置产物、配置读取和日志证明,设计一个缺少前置产物的检查案例。

    提交内容
    执行入口对照和验证计划。
    怎样算完成
    • 阶段与目标关系精确
    • 运行入口的前提可核验

    练到的知识点phase 与 plugin goal

综合应用

  1. 作业 04编程实践

    创建只有一个主类和一个测试类的工程,为测试库、运行驱动和编译所需 API 设计 scope;用编译、测试和运行三种入口验证类路径。

    提交内容
    POM、自写测试、类路径记录及运行证据。
    怎样算完成
    • 每项依赖用途明确
    • 测试依赖不靠偶然 IDE 类路径

    练到的知识点compile/test/provided/runtime

  2. 作业 05排错实验

    在隔离项目构造两个直接依赖引入同一库不同版本的情况,保留 dependency:tree 和最终解析版本,再改变一条显式管理配置比较差异。

    提交内容
    最小 POM、自写版本或行为核验、前后依赖树。
    怎样算完成
    • 区分声明版本与选中版本
    • 变更只有一个变量

    练到的知识点传递依赖和调解 · dependency:tree 证据

  3. 作业 06面试场景

    同学复制 jar 后应用可运行,干净机器通过 Maven 却缺类。建立从坐标、仓库、scope、传递链到打包内容的排查证据表。

    提交内容
    诊断流程与各层检查命令。
    怎样算完成
    • 不以本机缓存成功证明可复现
    • 编译类路径与运行产物分开

    练到的知识点groupId/artifactId/version · 生命周期阶段 · compile/test/provided/runtime · dependency:tree 证据

  4. 作业 07排错实验

    在隔离 Maven 工程加入一个明确版本的插件执行,并记录依赖树;只改变执行绑定或依赖声明之一,比较实际日志和有效配置。

    提交内容
    POM、自写产物/版本检查及两次构建证据。
    怎样算完成
    • 插件行为与库依赖分别观察
    • 差异可归于单项变更

    练到的知识点phase 与 plugin goal · 传递依赖和调解

  5. 作业 08面试场景

    两个依赖树路径引入同库不同版本,源码能编译而运行出现方法缺失。构建从选中版本、打包内容到实际类来源的证据链。

    提交内容
    版本追踪表与最小验证步骤。
    怎样算完成
    • 编译与运行版本分别确认
    • 不只看最顶层依赖声明

    练到的知识点传递依赖和调解

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成构建插件基础后:测试被某个属性跳过但 CI 仍返回成功。设计构建验收契约,核对执行数量、报告、产物与解析依赖,不要求增加全套 CI 服务。

    提交内容
    验收脚本草案与故障模拟清单。
    怎样算完成
    • 成功码不替代必要行为证据
    • 依赖与插件版本可追溯

    练到的知识点生命周期阶段 · phase 与 plugin goal · 传递依赖和调解 · dependency:tree 证据

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    项目增加离线构建要求,工具链、插件与依赖都不能临时下载。分析“以前成功构建过一次”的证据是否足够,制定可重复验收。

    提交内容
    资源依赖清单和离线验证方案。
    怎样算完成
    • 插件与库解析都纳入
    • 本机偶然缓存与受控材料区分

    练到的知识点phase 与 plugin goal · 传递依赖和调解

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗