构建工具与协作:Maven 依赖调解、BOM、门禁与可重复构建
同样一份源码,两个同事各自 mvn package,一个跑通一个在运行时报 NoSuchMethodError;同一个版本号打出来的 jar,两次构建的哈希却不一样,回滚时无法证明回到的是哪一份;合并冲突解完能编译,但线上配置被"顺手"改回了旧值。这三个事故分别属于依赖模型、可重复构建和 Git 协作——构建工具的三块地基,也是面试里"你了解 Maven 吗"背后真正要问的东西。本页用真实 Maven 3.9.9、Gradle 9.8.2 与 git 2.55.0 把每一块跑一遍。
本页是 U62(#99)的 C12/E04/F15 权威正文:Maven 生命周期/依赖/BOM/冲突/插件/多模块、Gradle 最小对照、Git 协作与制品恢复。Boot 插件与可执行 jar 布局见 Spring Boot 启动;AOT/Native 的构建入口差异见 AOT/Native/CRaC;测试分层、数据库迁移与流水线门禁(含门禁失败注入与制品回滚)归 U63《交付门禁》;治理与审批经济学归架构治理与交付效能。本页只讲构建工具与协作本身。
构建要回答的三个问题
| 问题 | 机制 | 答错的症状 | 本页章节 |
|---|---|---|---|
| classpath 上到底是哪个版本 | 依赖调解 + dependencyManagement/BOM | 编译通过、运行 NoSuchMethodError/ClassNotFoundException | Maven 依赖模型、Gradle 对照 |
| 这个版本号对应哪一份字节 | 可重复构建(固定时间戳与顺序)+ 制品哈希 | 回滚后行为不一致、"同版本"无法复现 | 可重复构建 |
| 协作后代码到底是什么意图 | 分支/合并语义 + 冲突解决责任 + rebase 边界 | 冲突解错、配置被覆盖、历史被重写 | Git 协作 |
三者的共同点是:声明不等于事实。pom.xml 里写 2.21.7 不代表运行时就加载 2.21.7;version=1.0.0 不代表两份 jar 字节相同;"冲突已解决"不代表两边意图都保留。本页的每个结论都会落到"去哪看事实"上:dependency:tree、build-classpath、jar 的 SHA-256、git log --graph。
版本、工程与观察入口
固定 Apache Maven 3.9.9(内置 Maven Resolver 1.9.22)、Gradle 9.8.2(官方发行版,缓存在仓库外 ~/.cache/software-engineering-study/tools)、git 2.55.0、BellSoft Liberica JDK 21.0.11+11-LTS、macOS 27.2 arm64。查询与运行日期 2026-10-28。
公开入口:
python3 labs/build-toolchain/run.py脚本把 labs/build-toolchain 复制到临时目录执行,不污染源码树;运行结束删除临时树,只保留日志。Maven 部分在副本上切换 BOM/无 BOM/直接声明三种状态,Gradle 部分在同一依赖图上做对照,Git 部分在独立临时仓库里制造真实冲突与 rebase。证据写入 labs/build-toolchain/evidence/u62-2026-10-28.json——完整命令、退出码与 17 条观察行见运行证据,工程边界见实验说明。
工程结构:
labs/build-toolchain/
pom.xml 父 pom:插件管理、jackson BOM、outputTimestamp
platform/pom.xml 独立 BOM 模块(import scope 对照)
legacy/ 声明旧版 jackson,制造传递依赖冲突
lib/ 依赖 BOM 管理的 jackson
app/ 依赖 lib + legacy,带 enforcer 门禁与测试
gradle-project/ Gradle 最小对照(lib + app,platform BOM)Maven 依赖模型
生命周期与阶段
Maven 的三套生命周期里最常用的是 default:validate → compile → test → package → verify → install → deploy。插件目标绑定到阶段,mvn test 会把 validate、compile 一起跑完;mvn package 不会跑 verify 之后的东西。这个语义决定了门禁应该绑在哪个阶段:越早越便宜,enforcer 绑 validate、测试绑 test、集成测试绑 verify、制品写入 install(本地仓库)。
一个常见误解是"mvn install 才检查依赖"。事实是依赖解析发生在任何需要 classpath 的阶段之前,validate 阶段就可能因为依赖缺失或门禁失败而中断——实验里 mvn -pl app validate -Djackson.version=2.17.1 在 validate 就失败了。
依赖调解的三条规则
同一份 classpath 上,同一个 groupId:artifactId 只能有一个版本。选谁由调解决定:
- 最近优先(nearest wins):依赖树里深度更浅的版本胜出。
- 同深度第一声明者胜:深度相同时,pom 里先声明的那条路径胜出。
- 直接声明优先:本模块
<dependencies>里直接写的版本,压过任何传递依赖。
Maven Resolver 的判定就在一段很短的方法里:
private boolean isNearer(ConflictItem item1, ConflictItem item2) {
if (item1.isSibling(item2)) {
return item1.getNode().getVersion().compareTo(item2.getNode().getVersion()) > 0;
} else {
return item1.getDepth() < item2.getDepth();
}
}注意两个分支:同父(sibling)时比版本号取高,否则比深度取浅。这正是"最近优先 + 同深度第一声明"的实现;而它取代的 winner 又会在遍历中不断被更近的候选替换:
} else if (group.winner == null || isNearer(item, group.winner)) {
group.winner = item;
}
} else if (backtrack) {
backtrack(group, context);
}
}
context.setWinner(group.winner);
}这解释了为什么依赖调解的结果依赖声明顺序:实验里 legacy 模块直接声明 jackson 2.17.1、lib 传递 2.21.7,去掉 BOM 后 app 解析到的是 2.17.1——因为 legacy 是 app 的直接依赖,深度 1,比 lib → jackson 的深度 2 更浅。换成 app 自己直接声明 2.17.1,结果一样。没有 BOM 时,"谁的版本生效"是 pom 写法的副产物,不是任何人想要的契约。
dependencyManagement 与 BOM
dependencyManagement 是版本约束的声明段:子模块依赖不写 <version> 时从这里取。它有两种用法:
| 用法 | 机制 | 继承什么 | 不继承什么 |
|---|---|---|---|
| 继承 parent 的 dependencyManagement | <parent> 指向父 pom | 版本约束 + 插件管理 + 属性 | — |
| import 一个 BOM | <dependency><type>pom</type><scope>import</scope></dependency> | 只有版本约束 | 插件、属性、构建配置 |
本实验两条都跑过:父 pom 里的 dependencyManagement 把 jackson 钉在 2.21.7;platform 模块是一个独立 BOM,app 用 import 引入它。两种方式下 dependency:tree 都解析到 2.21.7,但 import 方式不会把父 pom 的 enforcer 配置带进来——这正是"企业 parent 与公共 BOM 冲突时只 import BOM"的机制依据。Boot 的 spring-boot-dependencies 也是 BOM,正文见 Spring Boot 启动。
BOM 不是依赖。导入 BOM 不会把 jackson 放进 classpath,它只提供"如果你用到,用这个版本"的约束。实验里 app 只通过 lib 才真的拿到 jackson。
多模块反应堆
多模块构建的顺序由模块之间的依赖关系推导,不是 pom 里 <modules> 的声明顺序。本实验的模块依赖是 app → lib → (jackson)、app → legacy → (jackson 2.17.1)、platform 只被 import,所以实际构建顺序是 platform → legacy/lib → app(legacy 与 lib 无相互依赖,可并行)。
-pl app -am 表示"只构建 app 以及它依赖的模块",-pl app 单独用则要求依赖模块已经在本地仓库里——这就是为什么实验先 install 一次。CI 里如果只写 mvn -pl app package,会拿到本地仓库里的旧版本依赖,是"本地能跑、CI 失败"的常见来源。
插件门禁:enforcer
依赖约束写在文档里没人执行,就等于没有。enforcer 的 bannedDependencies 把"不许出现 2.18 以下的 jackson"变成构建失败:
<execution>
<id>ban-old-jackson</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<bannedDependencies>
<excludes><exclude>com.fasterxml.jackson.core:jackson-databind:[2.0,2.18)</exclude></excludes>
<message>拒绝 jackson-databind 2.18 以下版本(教学门禁)</message>
</bannedDependencies>
</rules>
</configuration>
</execution>实验两条路径都跑了:-Djackson.version=2.17.1 时在 validate 阶段失败,报出被禁的依赖坐标与路径;恢复 2.21.7 后通过。门禁的价值不是"阻止一次错误",而是让这类错误在每个分支、每台机器上以同样方式暴露,不依赖 reviewer 记不记得看 dependency:tree。
可重复构建
为什么同一个版本号会打出不同的字节
ZIP(jar 是 ZIP)的每个条目带修改时间,manifest 也可以带构建时间。如果不固定,两次构建的 jar 字节不同、SHA-256 不同——"回滚到 1.0.0"就变成"回滚到某一次 1.0.0 的构建",无法证明回滚到了什么。
Maven 的解法是 project.build.outputTimestamp 属性:
<properties>
<project.build.outputTimestamp>2026-10-01T00:00:00Z</project.build.outputTimestamp>
</properties>它让归档插件(jar/war/source)把条目时间与 manifest 时间固定到该值。实验里固定后两次 clean package 的 SHA-256 完全一致:
U62 mavenReproducible=PASS sha256=8cf35840601c9edf…去掉该属性后,跨过 ZIP 时间戳 2 秒精度的窗口再构建,哈希不同:
U62 mavenNotReproducibleWithoutTimestamp=PASS hashes differ注意粒度:ZIP 的 DOS 时间戳精度是 2 秒,两次构建若落在同一时间片可能碰巧相同。实验专门 sleep 3 秒规避这个陷阱——这不是"不可重复构建不成立",而是观察方法必须跨过时间片。
版本号作为制品契约
同版本号重建必须字节一致——这条要求的上游是版本号本身就是契约。制品库里的 app-1.0.0.jar 只能对应一份内容;如果每次构建都能产出不同的 1.0.0,"回滚到 1.0.0"就无法证明回滚到了什么。生产里通常把"版本号 + 构建工具链 + 依赖锁文件"一起冻结,并在发布记录里保存制品 SHA-256。
SNAPSHOT 版本是例外:它语义上就允许多次覆盖,因此不能作为回滚依据。发布版本必须是唯一且不可变的。
可重复构建的边界
同一台机器、同一个 JDK、同一份源码两次构建一致,只证明构建过程没有引入随机性。跨 JDK(不同编译器产出的字节码可能不同)、跨 OS(换行符、文件系统顺序)、跨 Maven 版本(插件实现变化)都可能不一致。本实验未运行跨 JDK/跨 OS 的可重复构建;生产里通常用 源提交 + 工具链镜像 + 依赖锁定 三者一起固定。
Gradle 的对照
Gradle 的可重复归档靠两个开关:
tasks.withType<AbstractArchiveTask>().configureEach {
isPreserveFileTimestamps = false
isReproducibleFileOrder = true
}实验两次构建哈希一致(U62 gradleReproducibleJar=PASS)。Gradle 没有 Maven 的 outputTimestamp 属性,但 isPreserveFileTimestamps=false 会把条目时间归零,效果等价。
Gradle 最小对照
本页不教 Gradle 全套,只对照三个会影响结论的机制。
插件、配置与可见性
Gradle 用插件组织构建:java-library 提供 api/implementation 配置,application 提供 run 与发行包。api 的依赖对下游可见,implementation 不可见——这是 Maven 没有的区分(Maven 里所有 compile 依赖都传递)。本实验 lib 用 api 暴露 jackson,app 用 implementation 引入 lib。
平台 BOM
dependencies {
api(platform("com.fasterxml.jackson:jackson-bom:2.21.7"))
api("com.fasterxml.jackson.core:jackson-databind")
}platform(...) 等价于 Maven 的 import scope:只提供约束,不引入依赖本身。实验 :app:dependencies 显示 jackson-bom:2.21.7 与被约束的 jackson-databind -> 2.21.7。
冲突调解:最高版本胜出
这里有一个面试高频、但两个工具结论相反的点:
| 工具 | 默认调解 | 实验证据 |
|---|---|---|
| Maven(无 BOM) | 最近优先;同深度第一声明 | mavenNearestWins=PASS resolved=2.17.1 |
| Gradle | 最高版本胜出 | gradleHighestVersionWins=PASS 2.17.1 -> 2.21.7 |
同一份"legacy 要 2.17.1、lib 要 2.21.7"的依赖图,Maven 在无 BOM 时可能选 2.17.1,Gradle 一定选 2.21.7。"升级构建工具"可能悄悄改变运行时版本,这是迁移时必须检查的点。Gradle 也支持 force、strictly 与 resolutionStrategy 覆盖默认行为,本实验未运行这些覆盖。
Git 协作
分支、合并与冲突的本质
git merge 做的是三方合并:以共同祖先 base、双方各自的最新提交为三个输入。冲突只在同一段内容的双方都改过时产生。实验里:
- base 的
config.txt是mode=stable / replicas=2 - feature 改成
mode=canary - main 改成
replicas=3 - 合并时 Git 把两个改动放在同一文件同一区域,产生冲突标记
<<<<<<< HEAD
mode=stable
replicas=3
=======
mode=canary
replicas=2
>>>>>>> feature冲突不是"文件坏了",是 Git 明确告诉你"这两处我无法替你决定"。实验的解决方式是两边意图都保留(mode=canary + replicas=3),然后提交合并。真实的坑是:解决者只留下一边,编译通过、测试通过,但丢掉了另一边想要的变更——比如这里只留 mode=stable,canary 发布就悄悄没生效。
冲突解决的责任
解决冲突的人必须能回答三个问题:
- 两边各自想做什么?(读 commit message 与 diff,不是只看冲突标记)
- 两个意图是否兼容?不兼容时谁的需求优先?(业务判断,不是 Git 判断)
- 解决后如何验证?(构建 + 测试 + 必要时手动验证配置)
把冲突标记提交进仓库、或者用 -X ours/-X theirs 全局偏向一边,都是把"我不知道"伪装成"已解决"。
rebase 与提交身份
rebase 把一串提交"重放"到新基底上:内容可以相同,但提交哈希会变(父提交变了)。实验:
- 从 main 拉出
rebase-src,加一个提交; git rebase main;- 回到 main,
git merge --ff-only rebase-src快进合并。
--ff-only 的意义是拒绝隐式创建合并提交:如果分支已经分叉,快进失败,强迫你显式决定是 merge 还是 rebase。两条规则:
- 公共分支(已推送、别人可能基于它工作)不要 rebase,否则别人的历史会断。
- 本地未推送的提交可以 rebase,用来整理提交粒度与信息。
工作区状态作为发布门禁
git status --porcelain 为空,表示没有未提交改动、没有未跟踪文件。实验在合并与 rebase 结束后检查它为空(U62 gitStatusClean=PASS)。生产里这条应该写进发布脚本:工作区不干净时拒绝打包,避免"构建产物来自未提交代码"这种无法追溯的情况。
构建产物本身不进版本库:target/、build/、.gradle/ 都在 .gitignore 里。需要追溯时用制品哈希(jar 的 SHA-256),而不是把 jar 提交进去。
观测、排障与恢复顺序
构建事故按固定顺序排查,能少走很多弯路:
- classpath 事实:
mvn dependency:tree(Maven)或:app:dependencies --configuration compileClasspath(Gradle),看实际解析版本,不看 pom 里写的。 - 管理段是否生效:
mvn help:effective-pom,确认 dependencyManagement 真的被继承/导入。 - 绑定到哪个阶段:
mvn help:describe -Dplugin=...,确认插件 goal 绑在 validate 还是 verify。 - 字节是否一致:两次构建比较 SHA-256;不一致先查
outputTimestamp/isPreserveFileTimestamps。 - 协作历史:
git log --graph --oneline,看合并与 rebase 后的真实拓扑;git status --porcelain确认工作区干净。 - 门禁为何放行:检查 enforcer 规则是否覆盖了实际坐标,
bannedDependencies的区间是否写对([2.0,2.18)开区间不含 2.18)。
恢复顺序:先修 classpath(否则测试跑的是错版本),再修门禁(否则同类问题会再次发生),最后修制品与历史。反过来做等于用新错误掩盖旧错误。
实验读法
python3 labs/build-toolchain/run.py 输出 17 条观察行:
| 观察行 | 含义 |
|---|---|
U62 mavenManagedBom=PASS jackson=2.21.7 | BOM 管理下实际解析 2.21.7 |
U62 mavenNearestWins=PASS resolved=2.17.1 | 去掉 BOM 后最近优先选中 2.17.1 |
U62 mavenDirectWins=PASS jackson=2.17.1 | 直接声明压过传递依赖 |
U62 mavenEnforcerReject=PASS exit=1 | 门禁在 validate 阶段拒绝旧版 |
U62 mavenEnforcerPass=PASS exit=0 | 修复版本后门禁通过 |
U62 mavenReproducible=PASS | 固定时间戳后两次构建哈希一致 |
U62 mavenNotReproducibleWithoutTimestamp=PASS | 去掉时间戳后哈希不同 |
U62 mavenRunManagedClasspath=PASS | 真实运行主类,classpath 中 jackson 为 2.21.7 |
U62 gradlePlatformBom=PASS | Gradle platform 引入 BOM |
U62 gradleHighestVersionWins=PASS | Gradle 选最高版本 2.21.7 |
U62 gradleReproducibleJar=PASS | Gradle 可重复 jar |
U62 gitMergeConflict=PASS / gitMergeResolved=PASS | 真实冲突与解决 |
U62 gitRebaseAndFastForward=PASS / gitStatusClean=PASS | rebase 与工作区门禁 |
U62 cleanup=PASS | 临时工作树已删除 |
闭卷验收
本页 24 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
未运行与不能由本页证明的
- 企业制品库(Nexus/Artifactory)、
mvn deploy、Maven Central 发布流程、GPG 签名未运行。 - CI 平台(GitHub Actions/Jenkins)与流水线编排未运行;本页只在本机执行等价命令。
- 依赖漏洞扫描(OWASP dependency-check 等)、license 合规、SBOM 生成未运行。
- 跨 JDK、跨 OS、跨 Maven 版本的可重复构建未运行;只验证同一环境两次构建。
- Gradle 的 configuration cache、build cache、
--offline、版本目录(version catalog)与插件开发未运行。 - Boot 的 Gradle 插件 repackage 与 Gradle 版 Native 构建未运行(AOT/Native 归 U28)。
- Git 远程托管、代码评审平台、保护分支规则、签名提交未运行;只验证本机仓库的合并/rebase/状态检查。
- 教材完成不等于学习者掌握;未运行条件不得由"命令写好"或"配置正确"推出。