读完这篇,你能做什么
能区分三种项目关系,并用有方向的模块依赖完成选择性构建。
学习理由、准备要求与知识点
为什么学它
多模块项目需要清楚的依赖方向和版本管理,才能支持局部构建与长期维护。
先读这些内容
知识点清单
- 聚合 modules
- parent 继承
- dependencies 引用
dependencyManagement- BOM import
pluginManagement- reactor 与循环依赖
- -pl
/-am局部构建
运行前先确认Java 17;Apache Maven 3.9.9。算法把模块视为静态任务 DAG,假设依赖完成后模块可运行且并行资源无限;不是 Maven 实际线程调度器。
01先弄懂它是什么
Maven 多模块中,有三种关系要分开:modules 说明哪些项目一起构建,parent 说明继承谁的配置,dependencies 说明真正使用哪个模块或库。它们可以由同一个父 POM 组织,但不是一回事。某模块出现在 modules 列表里,不会自动让其他模块能 import 它的类,还要声明依赖。
dependencyManagement 用来统一版本和默认属性,不会自动把库加进每个子模块;子模块仍要声明自己确实使用的依赖。BOM 是用 pom 类型和 import 范围引入的一组版本约定。pluginManagement 类似地保存插件默认安排,某个 goal 是否真的执行,还要看插件声明、默认绑定和 execution。
模块拆分应反映稳定职责和变化方向。common 可以承载稳定通用类型,应用可以依赖它;若 common 又调用应用专属 Service,就产生反向依赖。用调整 modules 顺序解决环是无效的,因为排序无法满足互相先于对方。模块不是越多越好,过度拆分会扩大版本与构建协调成本。
文中这几个词,先认一认
- reactor
- Maven 在一次构建中收集多个模块、按依赖确定先后并执行它们的机制。
- 聚合
- 把多个项目列为本次一起构建的对象,和它们继承哪个父 POM 是两种关系。
- 关键路径
- 依赖关系中累计耗时最长的一串任务;就算充分并行,也不能比这条路径完成得更早。
02看看它怎么工作
Maven 先收集这次要构建的模块,再确定先后顺序并执行;负责这些工作的机制叫 reactor。模块 A 依赖本次构建中的模块 B 时,要先准备好 B,再构建 A。各模块还必须有能区分彼此的项目坐标,不能重复冲突。外部依赖由仓库解析,不在这里跟着编译。找不到兄弟模块时,先检查它是否被本次构建选中、依赖坐标是否写对,再决定怎么处理。
在聚合根执行 mvn -pl app -am verify,意图是选择 app 并同时构建它所需的 reactor 项目。-pl 决定选择范围,-am 按依赖补充需要的项目。它不会自动把未收集到的任意磁盘目录纳入当前构建。单独进入子模块可能依赖已安装的兄弟产物,与同一 reactor 构建使用的来源不同。
多开构建线程,只能让互不等待的部分同时做。若 app 等 service、service 又等 common,这条链仍得按顺序完成。把每一步的耗时沿依赖链相加,最长的那条就是关键路径,可以帮助找到不能靠加线程消除的等待。实际构建还要下载依赖、运行测试,也会争用机器资源,并受插件能否安全并行影响;根据模块耗时算出的理想下界,不是实际速度承诺。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart LR
P["聚合根"] --> C["common"]
P --> S["service"]
P --> A["app"]
C -->|"构建先完成"| S
S -->|"构建先完成"| A
M["版本管理"] --> D["子模块显式依赖声明"]
D --> S按顺序看,每一步做了什么
- 分别画出 modules 聚合、parent 继承和 dependencies 引用。
- 核对每个模块坐标以及依赖声明的版本来源。
- 在根目录比较全量 verify 与 -pl app -am verify 的 reactor 列表。
- 查看 effective-pom 和 dependency:tree,解释管理版本与实际选择。
- 对依赖环调整职责方向,再讨论并行优化。
03对照实现代码
Apache Maven maven-3.9.9 · ProjectSorter#ProjectSorter、#addEdge
原创教学图构造,非源码节选。仅保留 reactor 内显式依赖边;真实实现还处理 parent、构建插件及扩展、版本匹配和特殊边规则。
import java.util.*;
class SourceModel {
static Map<String,List<String>> localEdges(Set<String> reactor,
Map<String,List<String>> declared) {
Map<String,List<String>> graph = new LinkedHashMap<>();
for (String module : new TreeSet<>(reactor)) {
List<String> dependencies = new ArrayList<>();
for (String dep : declared.getOrDefault(module,List.of())) {
// 外部依赖由仓库解析,不成为本模型的 reactor 节点。
if (reactor.contains(dep)) dependencies.add(dep);
}
// 边从使用者指向被依赖模块,排序器需按依赖先完成处理。
graph.put(module,List.copyOf(dependencies));
}
// 此处只是建图,循环检查属于后续排序阶段。
return Collections.unmodifiableMap(graph);
}
}真实构造器先收集项目坐标和顶点,并检测重复身份;图需要有清楚的项目集合边界。
只有依赖坐标匹配本次 reactor 里的某个项目,才会把它算作内部模块依赖。依赖了一个外部库,不表示 Maven 这次也会重新构建那个库。
源码不只读取 dependencies,还处理 parent、插件与扩展等关系;简图只能解释主要方向,不能当作完整 Maven 排序规范。
最后进行拓扑排序并映射回项目列表。不可满足的依赖环无法靠调换模块书写位置解决,应该回到关系设计。
代码先调用谁,再调用谁
- 收集
MavenProject - 按坐标建立顶点
- 添加依赖及其他顺序边
TopologicalSorter.sort- 按排序结果执行 reactor 项目
04动手写一个例子
Kahn 拓扑推进与模块构建关键路径
每个模块有非负耗时和直接依赖,计算无限并行资源下全部完成的最早时间。缺失模块、重复依赖、环和耗时溢出应失败;返回值是理论下界。
- 输入 1
common=2,service=3依赖common,app=1依赖service- 输出 1
6
- 输入 2
a=3、b=2独立,c=4依赖a和b- 输出 2
7
把依赖边反转为从先行模块指向使用者,并统计每个节点尚未完成的依赖数。初始只有入度为零的模块可运行,处理完成后解除后继的一项限制。
节点最早开始时间等于全部直接依赖完成时间的最大值。每完成一个依赖,就以 max 更新后继开始时间;入度降为零时,该最大值已汇总完毕,加自身耗时得到完成时间。
若队列耗尽而已处理节点数少于总数,则剩余节点被环阻塞。加法使用精确溢出检测,避免把超大耗时绕成负数后给出错误加速结论。
为什么这样写是对的节点进入就绪队列时,其所有依赖均已完成,start 等于这些依赖 finish 的最大值。
每个节点入队出队一次,每条依赖用于建图和更新各一次;图、入度和时间数组占 V+E。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Task(long duration, List<String> deps) {}
static long earliest(Map<String,Task> tasks) {
Map<String,Integer> left = new HashMap<>();
Map<String,List<String>> next = new HashMap<>();
Map<String,Long> start = new HashMap<>();
for (String name : tasks.keySet()) next.put(name,new ArrayList<>());
for (var entry : tasks.entrySet()) {
Task t = entry.getValue();
if (t.duration() < 0) throw new IllegalArgumentException("duration");
Set<String> seen = new HashSet<>();
for (String d : t.deps()) {
if (!tasks.containsKey(d) || !seen.add(d))
throw new IllegalArgumentException("dependency");
// 反向邻接表用于通知所有等待当前模块的使用者。
next.get(d).add(entry.getKey());
}
left.put(entry.getKey(),seen.size());
start.put(entry.getKey(),0L);
}
Deque<String> ready = new ArrayDeque<>();
for (String n : tasks.keySet()) if (left.get(n) == 0) ready.add(n);
int done = 0;
long answer = 0;
while (!ready.isEmpty()) {
String n = ready.remove();
long finish = Math.addExact(start.get(n),tasks.get(n).duration());
done++;
answer = Math.max(answer,finish);
for (String child : next.get(n)) {
// 需等待全部依赖,开始时间取完成时间的最大值。
start.put(child,Math.max(start.get(child),finish));
int remaining = left.merge(child,-1,Integer::sum);
if (remaining == 0) ready.add(child);
}
}
// 未处理节点代表环,不返回不完整任务的局部时间。
if (done != tasks.size()) throw new IllegalArgumentException("cycle");
return answer;
}
static Task t(long duration, String... deps) { return new Task(duration,List.of(deps)); }
static void eq(long expected, long actual) {
if (expected != actual) throw new AssertionError(actual);
}
static void reject(Runnable r) {
try { r.run(); throw new AssertionError("accepted"); }
catch (IllegalArgumentException expected) {}
}
public static void main(String[] args) {
eq(0,earliest(Map.of()));
eq(6,earliest(Map.of("common",t(2),"service",t(3,"common"),"app",t(1,"service"))));
eq(7,earliest(Map.of("a",t(3),"b",t(2),"c",t(4,"a","b"))));
eq(4,earliest(Map.of("a",t(0),"b",t(4,"a"))));
reject(() -> earliest(Map.of("a",t(1,"b"))));
reject(() -> earliest(Map.of("a",t(1,"b"),"b",t(1,"a"))));
try {
earliest(Map.of("a",t(Long.MAX_VALUE),"b",t(1,"a")));
throw new AssertionError("overflow accepted");
} catch (ArithmeticException expected) {}
System.out.println("PASS 7 reactor scheduling tests");
}
}这些特殊情况也要试一试
- 空图耗时为零,零耗时模块仍要解除依赖。
- 缺失依赖、重复依赖与循环分别拒绝,不能依赖队列顺序碰巧通过。
- 使用
Math.addExact拒绝累计时间溢出。
进阶选读 · 有限构建线程下的调度优化
场景机器只有四个构建线程,长测试模块和短模块混合,实际工期高于关键路径。
做法基于 DAG 剩余关键路径长度为就绪任务排序,再用有限工作槽模拟执行;同时加入资源互斥或数据库测试并发约束。
复杂度启发式队列通常 O((V+E) log V);一般有限资源最优调度问题难以高效精确求解。
好处与代价优先长路径可能改善工期,但插件线程安全和共享测试资源会限制并行,最终需可比较测量验证。
05项目里怎么用
拆模块先写职责,再建立依赖方向。公共模块不应为了方便引入 Web 启动框架和应用配置,否则本来轻量的消费者也会承担额外依赖。遇到环可提取稳定接口或上移业务编排,但应防止把所有类型堆入一个不断增长的 common。
父 POM、聚合根和 BOM 可以分别承担工作:父 POM 统一构建设置,BOM 统一依赖版本,聚合根决定这次一起构建哪些项目。分开更灵活,也更难理解,因此应画出实际关系,并说明子模块单独构建时,父 POM 和兄弟模块的产物从哪里来。
优化并行或缓存前,先保证构建测试范围正确。只测 app,却漏了已修改的上游模块或受影响下游,快速通过也没有意义。CI 选择增量模块时,要沿依赖关系找全相关部分,并保留周期性全量验证;比较耗时时说明是否包含下载、缓存和测试。
容易踩的坑
dependencyManagement声明了库,就以为子模块已经真实依赖它。- 只调整 modules 顺序来试图消除依赖环。
- 把 -am 理解为自动发现磁盘上所有相关项目。
- 把理想关键路径时间当成增加线程后的实际承诺。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 单模块 | 构建与版本管理简单。 | 大型项目边界可能难约束。 | 规模较小、统一发布的应用。 |
| 同仓多模块 | 共享变更可在同一 reactor 验证。 | 需维护方向和构建范围。 | 边界稳定且紧密协作的组件。 |
| 独立发布模块 | 版本与消费者可独立演进。 | 兼容性和发布协调更复杂。 | 有独立生命周期的公共能力。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01聚合父与继承 parent 必须相同吗?
展开参考答案与推导
不必须,前者组织构建成员,后者提供配置。
02版本管理会创建真实依赖边吗?
展开参考答案与推导
单独管理项不会,子模块需声明实际使用的依赖。
03关键路径为什么只能给出下界?
展开参考答案与推导
它忽略有限线程、下载、共享资源和运行波动等限制。
换个情况,还会处理吗
01app 单独构建找不到 common,但根构建成功怎么办?
展开参考答案与推导
检查 reactor 是否提供 common,以及独立构建时该坐标是否已在仓库可解析。
02common 依赖 app 导致环怎么改?
展开参考答案与推导
重新划分职责,提取稳定契约或上移编排,去掉反向依赖。
03开启并行后测试随机失败怎么办?
展开参考答案与推导
检查插件线程安全、共享数据库和固定端口等资源,再限制相关并发。
写代码试一试
01为何汇总依赖耗时取最大值而非求和?
- 输入 / 条件
两个独立依赖分别耗时3和2- 输出 / 预期
使用者最早在3开始
提示 本题假设无限并行。
展开参考答案与推导
两个依赖可同时运行,等待时间为较晚完成者;串行资源约束属于扩展模型。
02如何确认检测到了环而非忽略节点?
- 输入 / 条件
a与b互依赖,另有独立c- 输出 / 预期
整个计算拒绝
提示 比较处理数和节点数。
展开参考答案与推导
即使 c 完成,只要 done 小于总数仍报告环。
03如何返回关键路径本身?
- 输入 / 条件
链 common→service→app- 输出 / 预期
路径列表与总耗时
提示 更新最大值时记录前驱。
展开参考答案与推导
记录使 start 最大的依赖,在最大 finish 节点向前回溯;相同耗时可约定稳定选择。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能区分三种项目关系,并用有方向的模块依赖完成选择性构建。
本篇重点加练
重点练版本管理与局部构建的真实范围,防止模块独立通过却缺少必要依赖。
dependencyManagement- -pl
/-am局部构建
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01设计评审
工程含 api、service、app 三个模块,另有统一公司 parent。画出聚合关系与继承关系,说明两类关系分别影响构建入口和配置来源。
- 提交内容
- 目录
/POM草案及两张关系图。 - 怎样算完成
- 聚合与继承独立表达
- 模块路径可解析
练到的知识点聚合 modules · parent 继承
- 作业 02原理问答
父 POM 管理工具库版本,service 需要使用工具类而 api 不需要。写出三个模块各自的依赖声明责任,并说明通过哪些产物确认真实依赖是否存在。
- 提交内容
- 声明对照和有效依赖验证计划。
- 怎样算完成
- 管理版本与引入依赖分开
- 不通过无关模块传递偷带依赖
练到的知识点dependencies 引用 · dependencyManagement
综合应用
- 作业 03排错实验
在最小多模块工程导入 BOM 并配置
pluginManagement,比较声明前后的有效 POM 与实际插件执行;只引入测试所需的小范围依赖。- 提交内容
- POM、effective-pom 摘录、自写构建检查及运行证据。
- 怎样算完成
- 版本管理来源可定位
- 插件管理与实际执行分开
练到的知识点BOM import · pluginManagement
- 作业 04编程实践
创建 common、service、app 三模块练习工程,app 依赖 service、service 依赖 common;从根构建和定向构建 app 两种方式执行行为测试,并记录参与模块。
- 提交内容
- 完整工程、自写模块调用测试和 reactor 日志。
- 怎样算完成
- 定向构建包含需要的模块
- 不靠事先手工安装残留产物通过
练到的知识点reactor 与循环依赖 · -pl/-am 局部构建
- 作业 05面试场景
common 为复用一个 DTO 开始依赖 app,构建报循环。根据业务职责重新安排共享类型,给出保留行为的迁移顺序和编译验证。
- 提交内容
- 依赖图、迁移步骤与验收用例。
- 怎样算完成
- 循环通过职责调整解决
- 不通过跳过模块掩盖问题
练到的知识点parent 继承 · dependencies 引用 · reactor 与循环依赖
- 作业 06排错实验
在三模块练习工程中管理一个共享库版本,定向构建其中应用模块并记录实际参与模块;改变被管理版本后检查各模块有效依赖。
- 提交内容
- POM、自写版本/行为检查及 reactor 证据。
- 怎样算完成
- 管理值与最终版本对应
- 局部构建范围可核验
练到的知识点dependencyManagement · -pl/-am 局部构建
- 作业 07设计评审
根工程与某子模块都声明同一库版本,团队希望统一升级。列出版本来源及允许例外的合同,设计能发现局部覆盖的核验。
- 提交内容
- 版本责任表和差异检查计划。
- 怎样算完成
- 有效版本优先于文件表面文本
- 例外有明确业务或兼容依据
练到的知识点dependencyManagement
- 作业 08面试场景
局部构建通过但组合运行失败,共享模块近期修改了接口。设计干净环境下的验证顺序,说明本地已安装产物会影响哪些判断。
- 提交内容
- 构建/安装/运行证据图。
- 怎样算完成
- 产物来源可追溯到本轮源码
- 局部与整体验证边界明确
练到的知识点-pl/-am 局部构建
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成依赖兼容性基础后:团队升级 BOM,部分模块私自锁定旧版本。设计版本差异审计和分模块验证,注明哪些测试需要整套集成运行。
- 提交内容
- 版本来源矩阵与升级验证计划。
- 怎样算完成
- 有效版本与声明来源可追溯
- 局部构建证据的边界明确
练到的知识点dependencyManagement · BOM import · pluginManagement · -pl/-am 局部构建
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
原来同版本发布的模块改为独立发布,版本管理与 -pl
/-am工作方式各能保证哪些事?提出跨版本兼容矩阵。- 提交内容
- 发布关系图和兼容性测试范围。
- 怎样算完成
- 构建顺序与版本兼容分开
- 独立产物的依赖合同明确
练到的知识点dependencyManagement · -pl/-am 局部构建
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
Maven:多模块构建 (新标签页)↗
阅读 reactor 排序、-pl、-am,理解聚合、依赖关系与局部构建。
- 实践参考文档英文
Maven:依赖机制 (新标签页)↗
重点阅读依赖调解、scope、optional
/exclusion与dependencyManagement的区别。 - 深入教程英文
Maven:构建生命周期 (新标签页)↗
区分阶段与插件目标,理解 test、package、verify、install 各自执行到哪里。
- 实践实践指南中文
提交后自动跑 Java 测试:GitHub Actions 入门 (新标签页)↗
先读指定 Java 版本与构建测试部分。小练习:在自己的练习仓库配置与本地相同的 JDK 和 Maven 命令,再让一个测试故意失败,找到日志中的失败位置。 文档公开;运行需要自己的
GitHub仓库、账号权限及可用 Actions 配额,费用取决于账号和使用情况。 - 深入参考文档英文
ProjectSorter 3.9.9 源码 (新标签页)↗
查看 reactor 项目收集、内部边和拓扑排序。