第 05 章(节选) 核心结论与深度思考题

来自第 05 章「第 05 章 Maven 深水区:坐标、仓库与依赖仲裁(依赖到底从哪来)」。 返回:第 05 章索引

05.12 Go ↔ Java 依赖管理综合对照表

维度Go (go.mod)Maven (pom.xml)
版本选择算法MVS:选最高版本,与声明顺序无关nearest wins:选路径最短的,深度相同则先声明者赢
可能选到低版本吗不会会(常见)
间接依赖是否入文件是,Go 1.17+ 全部拍平写进 go.mod(// indirect)否,每次构建现场算
文件本质解析结果的快照解析规则的描述
校验机制go.sum(dirhash,默认强制,不匹配直接失败)无内置;可选 enforcer / 私服扫描
可变版本不存在(伪版本含 commit hash)SNAPSHOT
私有仓库方案GOPROXY 代理 + GOPRIVATE 绕过(只代理,不托管)私服 Nexus/Artifactory(代理 + 托管 + 治理)
发布打 git tag 即发布mvn deploy 到 distributionManagement
多模块一个 repo 多个 go.mod,配置不继承聚合(<modules>)+ 继承(<parent>),两个独立机制
作用域无六种 scope,有传递规则矩阵
版本统一机制无需要(go.mod 已拍平)dependencyManagement / BOM / import scope
是否支持”排除”不支持支持 <exclusions>
是否支持”强制某版本”replace 指令(影响可重现性,慎用)dependencyManagement 或 Gradle 的 force()
依赖分析工具go mod why、go mod graph、go list -m alldependency:tree、dependency:analyze、help:effective-pom
确定性/可重现构建默认就是(go.mod + go.sum + 不可变模块)需额外工作(禁 SNAPSHOT、锁插件版本、可重现打包)
冲突暴露时机go mod tidy / 编译期运行时(NoSuchMethodError)

05.13 本章核心结论

  1. 坐标是五元组:groupId:artifactId:packaging:classifier:version,classifier 区分同一个 GAV 的多种产物。
  2. SNAPSHOT 是可变版本,可变意味着构建不可重现。生产发布禁用,用 enforcer 的 requireReleaseDeps 卡住。
  3. 仓库三层:本地 → 私服 → 中央。 mirrorOf=* 会劫持所有仓库请求(包括你配的第三方仓库),正确写法是 *,!my-repo。
  4. 仲裁只有两条规则:路径最短者优先;深度相同时先声明者优先。 这会选到更低版本,且结果对声明顺序敏感 —— 这是 Maven 不如 Go MVS 的根本之处。
  5. 两种标记必须分清:omitted for conflict with X 是仲裁输了(要处理),version managed from X 是被 dependencyManagement 改成新版(治理生效)。
  6. scope 是”classpath 分区”机制。provided 和 test 不传递给下游 —— 前者避免污染运行环境,后者避免把 mock 框架打进生产包。
  7. dependencyManagement 只声明版本不引入依赖;BOM 是只有它的 pom;import scope 在 pom 解析期原地展开,解决”parent 单继承不够用”。
  8. 多模块治理的正解是 BOM + dependencyConvergence,把冲突从运行时事故提前成构建期失败。
  9. NoSuchMethodError 的根因是运行时按全限定名寻址,全限定名没有版本概念;Go 在编译期链接,从设计上杜绝了它。
  10. phase 是线性序列不是 DAG:执行后面的 phase 会先跑完前面所有 phase,所以 mvn package 一定会跑测试。

05.14 深度思考题

题 1:Maven 的 dependencyManagement 和 Go 的 go.mod 里的 require,本质区别是什么?

参考答案

直接答案:dependencyManagement 是”约束规则”,go.mod 的 require 是”结果快照”。

<!-- Maven:一条规则 -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.3</version>
</dependency>
<!-- 语义:如果有人要用它,用 2.15.3。但此刻项目里没有这个依赖。 -->
// Go:一条结果
require github.com/example/json v1.9.1
// 语义:我确实依赖它,且它已经在依赖图里了。

四个连锁差异:

一、谁负责计算。 Maven 每次 mvn 现场遍历依赖图算出结果 —— 规则静态、结果动态。Go 由 go mod tidy 算一次写回文件,之后 go build 只验证不协商。

二、“没写版本”意味着什么。 Maven 意味着”往上翻几级查 dependencyManagement” —— 一种间接性。Go 不存在没写版本的依赖。

三、改动的影响面。 Maven 改 BOM 一处,所有模块下次构建全变,不需要提交任何东西到业务仓库。Go 升级间接依赖要跑 go mod tidy 并提交一个改了几十行的 commit。前者升级快但”惊喜”多,后者升级慢但每次变更都在 git 历史里,可审计可回滚。

四、失败模式。 Maven 规则冲突时静默选一个,问题推迟到运行时;Go 快照不自洽时 go build 直接报错。

代码锚点 —— 如果 Go 采用 Maven 的模型:

// 假想的"规则式 go.mod"(Go 实际不是这样)
constraint (
    github.com/example/json >= v1.9.0   // 只登记,不引入
)
require (
    github.com/gin-gonic/gin   // 没写版本 → 查上面的 constraint
)

真实的 Go 是全拍平、无协商的,每个 require 都有版本。

更深一层:这是”声明式 vs 命令式”的经典分野。 Maven 的 pom 是声明式意图(我想要 jackson,版本听组织的),go.mod 是命令式结果(我就要这个版本,已经算好了)。声明式灵活可集中治理,代价是要理解求解器的行为;命令式透明可重现,代价是丧失集中协调能力。

Go 选命令式,目标是”任何人在任何机器上构建同一个 commit 必须得到完全相同的结果”;Maven 选声明式,因为它诞生在企业环境,需要”架构组统一管控几百个项目”。想在 Java 项目里拿到 Go 级别的确定性,方向是”在声明式系统上叠一层快照” —— 见题 4。


题 2:为什么”依赖冲突”在 Java 世界是个持续二十年的话题,而 Go 社区几乎不讨论它?

参考答案

直接答案:不是 Go 程序员更聪明,是 Go 在设计上从三个层面同时消灭了”冲突”这个可能性。

层面一:算法消除了”选错”的可能。 Maven 的 nearest wins 可能选到低版本 —— 依赖图里存在一个”更好”的版本但你没用上,这是所有讨论的源头。MVS 选最高版本,给定版本集合结果唯一确定,没有”选错了”这个概念。

层面二(最关键):编译期链接消除了”运行时才发现”的可能。 即使 Go 的 MVS 选了不兼容的版本,编译时就失败了,你拿不到二进制。Java 的运行时寻址意味着选错了也能编译、能启动、能通过测试,直到生产环境某个请求走到那行代码。

这才是”话题持续二十年”的真正原因 —— 不是冲突多,是暴露太晚、定位太难。 一个编译期就报错的问题不值得写文章讨论;一个上线三个月后才炸、且炸的时候没人知道为什么的问题值得。

层面三:静态链接消除了”classpath 顺序”这个变量。 Java 的 classpath 是有序列表,同名类加载哪个取决于 jar 在列表里的物理位置,而位置由依赖树遍历顺序、打包插件实现细节、甚至文件系统目录遍历顺序决定。于是排查时你常常要回答”为什么这台机器对、那台机器错”。Go 的二进制里没有”顺序”这个概念。

代码锚点 —— Go 的版本信息内建可查:

go version -m ./order-service
# ./order-service: go1.21.5
#         dep     github.com/gin-gonic/gin      v1.9.1  h1:...
#         dep     github.com/go-sql-driver/mysql v1.7.1 h1:...

Java 要拿到等价信息得靠构建时额外生成(Spring Boot 的 META-INF/build-info.properties),默认没有。出事故时 Go 工程师花 10 秒确认线上用的是哪个版本,Java 工程师得登机器、找 jar、解压、看 MANIFEST,还不一定有。

更深一层:这是”设计约束”的胜利,不是”工程努力”的胜利。 Go 的三个设计决定 —— ①单一部署单元(静态二进制)②编译期链接 ③内容寻址的版本 —— 让这个问题无法存在。Java 在三点上做了相反选择(多 jar 部署、运行时链接、可变版本号),每个都有充分理由(模块化、热部署、企业级治理),代价就是 Jar Hell。

对 Go 程序员的启示:你不需要记住处理依赖冲突的每一个细节,你需要理解”为什么 Java 必须有一整套工具来处理它”。 理解了这一点,BOM、enforcer、shade、dependencyManagement 就不再是零散知识点,而是一张有逻辑的地图。


题 3:你的项目该不该上 Gradle?给出决策清单。

参考答案

先给结论:已经在用 Maven 且没痛点的项目,别迁。新项目按下面的清单判断。

第一步:有没有硬性约束?

条件结论
项目包含 Android 模块必须 Gradle
团队没人用过 Gradle,且没有学习时间别上。构建工具出问题会阻塞所有人
上游/母公司强制 Maven别上
有统一的 Maven 私服 + 发布流水线,改造成本未知先算成本,流水线改造往往比想象中贵

第二步:Maven 的哪些痛点你真的遇到了? 逐条打勾:

  • 全量构建超过 10 分钟,增量构建没帮上忙
  • 本地”改一行跑一次”超过 30 秒
  • 需要自定义构建逻辑(代码生成、多产物、条件打包),XML 表达不了
  • 依赖冲突频繁,nearest wins 反复踩坑
  • 超过 20 个模块,构建调度复杂

打勾少于 2 个 → 别迁。打勾 3 个以上、尤其含第 3 条 → 值得评估。

第三步:迁移的真实成本。

成本项说明
依赖树变化仲裁策略不同,解析结果会变,必须重新验证
插件生态差异自定义插件要重写,小众插件可能没有 Gradle 版
CI/CD 改造流水线脚本、缓存策略、产物路径全要改
团队学习Kotlin/Groovy DSL 调试比 XML 难
过渡期双维护大项目往往要同时保持两套构建一段时间

代码锚点 —— 迁移时最该做的一件事:对比两边的依赖树

mvn dependency:list -DoutputFile=maven-deps.txt
./gradlew dependencies --configuration runtimeClasspath > gradle-deps.txt
diff <(sort maven-deps.txt) <(sort gradle-deps.txt)

这个 diff 里的每一行都是一个潜在的生产事故。 不要假设”构建通过就没事”。

更深一层:构建工具选型,本质是选”约束”还是”自由”。 Maven 给约束(固定生命周期、固定目录、XML 声明式),好处是任何 Maven 项目你都能上手,坏处是约束外的情况很痛苦。Gradle 给自由(任意任务图、可编程),好处是能做任何事,坏处是每个 Gradle 项目都是独特的。

判断标准不是”哪个更好”,是”你的项目更需要哪个”:团队大、人员流动快、项目多 → 要约束;构建逻辑复杂、团队稳定、追求极致速度 → 要自由。不确定就选约束 —— 约束的代价是”偶尔难受”,自由的代价是”偶尔灾难”。

最后一句反直觉的观察: 对简单的标准项目,Gradle 相比 Maven 的速度优势并不明显(增量构建和 daemon 的收益被配置时间抵消)。真正拉开差距的是几十个模块以上的大型项目和频繁增量构建的场景。5 个模块别为了”更快”迁 Gradle,你大概率测不出差别。


题 4:怎么保证一个 Java 项目的构建是可重现的(今天构建和一年后构建产物一致)?

参考答案

标准:同一份源码,在不同时间、不同机器上构建,产出的 jar 逐字节相同。

按这个标准,默认配置的 Maven 项目百分百不可重现。四个杀手:jar 里的文件时间戳、entry 的顺序(取决于文件系统遍历)、依赖版本没锁、插件版本没锁(最隐蔽,也最常被忽略)。

第一层:锁死依赖

<!-- 禁 SNAPSHOT -->
<rules><requireReleaseDeps/></rules>

所有版本走 dependencyManagement,不允许子模块写死版本。

第二层:锁死插件版本(最容易漏)

不写插件版本,Maven 会用内置默认版本,而这个默认版取决于你装的 Maven 版本。同一份代码用 Maven 3.6 和 3.9 构建,可能产出不同的 jar。

<pluginManagement>
    <plugins>
        <!-- 每一个用到的插件都要写死版本,一个都不能漏 -->
        <plugin><artifactId>maven-compiler-plugin</artifactId><version>3.11.0</version></plugin>
        <plugin><artifactId>maven-jar-plugin</artifactId><version>3.3.0</version></plugin>
        <plugin><artifactId>maven-resources-plugin</artifactId><version>3.3.1</version></plugin>
        <plugin><artifactId>maven-surefire-plugin</artifactId><version>3.2.5</version></plugin>
    </plugins>
</pluginManagement>

看到这个 WARNING 就补上:'build.plugins.plugin.version' for org.apache.maven.plugins:maven-jar-plugin is missing.

第三层:锁死 Maven 版本本身(Wrapper)

mvn wrapper:wrapper -Dmaven=3.9.6    # 生成 mvnw
./mvnw clean install                 # 之后所有人用这个

CI 里必须用 ./mvnw 而不是 mvn,否则”我本地能构建,CI 不行”会反复出现。

第四层:可重现打包配置(Maven 3.6.1+)

<properties>
    <!-- 让 jar 里所有时间戳统一、entry 按名称排序 -->
    <project.build.outputTimestamp>2026-09-02T11:30:42Z</project.build.outputTimestamp>
</properties>

CI 里动态生成(用最后一次 commit 时间):

export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
mvn clean package -Dproject.build.outputTimestamp=$(date -u -d @$SOURCE_DATE_EPOCH +%Y-%m-%dT%H:%M:%SZ)

第五层:Docker 固定基础镜像(用摘要,别用 tag)

# tag 是可变的,sha256 才是不可变的
FROM eclipse-temurin:17.0.9_9-jre-alpine@sha256:... AS builder
RUN ./mvnw clean package -DskipTests
 
FROM eclipse-temurin:17.0.9_9-jre-alpine@sha256:...
COPY --from=builder /build/target/*.jar /app/order-service.jar

第六层:验证

mvn clean package && sha256sum target/order-service.jar > /tmp/h1
# 换机器 / 清本地仓库 / 改系统时间,再构建一次
mvn clean package && sha256sum target/order-service.jar > /tmp/h2
diff /tmp/h1 /tmp/h2        # 无输出 = 可重现
diffoscope /tmp/jar1.jar /tmp/jar2.jar    # 有差异时找出差在哪

更深一层:对照 Go,为什么它默认就可重现? 因为是那三点 —— 依赖不可变(伪版本 + go.sum)、编译期链接(无运行时变量)、单一产物(无打包步骤带来的时间戳/顺序问题)。Java 要把可重现性”做出来”,本质是在逐个补上 Go 天然就有的性质。

务实的判断: 严格逐字节可重现,值得做的场景是金融、政务、开源发布(Apache 基金会要求)、需要审计的系统。普通业务系统把前三层做扎实就够了 —— 投入产出比最高,能做到”一年后重新构建,功能一致、依赖一致”。最怕的是啥都没锁,还用”一直都这么构建的,没问题”当理由。


题 5(开放题,无标准答案):如果让你重新设计 Java 的依赖管理系统,你会保留 Maven 的哪些设计,改掉哪些?

【思考】

假设有机会重新设计(不考虑向后兼容),从零给 Java 造一套依赖管理。几个方向:

  • 坐标系统(GAV + classifier)要不要保留?groupId 的反向域名约定是不是包袱?
  • nearest wins 要不要换成 MVS 那样的”最高版本优先”?换成 MVS 之后会丢掉什么?
  • dependencyManagement / BOM 这套间接层,如果换成 Go 的”拍平写死”,代价是什么?
  • scope 有六种,是不是太多了?能砍掉几个?
  • 如果允许运行时存在多个版本的同名类(像 Node 的 node_modules 那样每个依赖有自己的目录),会不会更好?为什么 JVM 没这么做?

想清楚这几条,你再看 Maven 那些”奇怪的设计”,感觉会不一样 —— 它们大多是在当时的约束下能做出的最好选择。


下一章预告

这一章把 Maven 讲透了,但留了个明显的悬念:既然 nearest wins 有这么多问题,为什么还有那么多人选 Gradle?

第 06 章展开 Gradle:Kotlin DSL 怎么写、build.gradle.kts 的结构、增量构建和构建缓存到底快在哪、版本目录(Version Catalogs),以及从 Maven 迁移的完整流程和验证清单(本章题 3 里那个依赖树 diff 只是其中一步)。

再往后的第 07 章,我们把本章埋的雷挖出来:当 classpath 里真的出现两个版本的同名类,会发生什么? Shade 重定位、ClassLoader 隔离、OSGi、banDuplicateClasses —— 那是 Jar Hell 的正面对决。

在那之前,先做一件事:打开你手边的 Java 项目,跑一遍 mvn dependency:tree -Dverbose,数一数有多少个 omitted for conflict with。那个数字,就是你项目的定时炸弹数量。