第 05 章(节选) 坐标仓库与依赖仲裁

本篇来自《Go 程序员的 Java 修炼之路》第 05 章「第 05 章 Maven 深水区:坐标、仓库与依赖仲裁(依赖到底从哪来)」。 返回:第 05 章索引

05.1 坐标 GAV:一个 jar 的身份证

五个要素

Go 里引一个包写的是模块路径,它就是代码仓库地址,天然全局唯一。Maven 没这个便宜可占 —— jar 是从一个扁平的文件服务器拉的,所以必须自己造全局唯一命名。

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.3</version>
    <type>jar</type>                  <!-- 可选,默认 jar,对应生产侧的 packaging -->
    <classifier>sources</classifier>  <!-- 可选,默认无 -->
</dependency>

日常说 GAV 指前三个,但真正唯一定位一个文件的是五个:groupId:artifactId:packaging:classifier:version。

问题 1: type 和 packaging 是一个东西吗?是同一个概念的两个视角 —— packaging 是生产者视角(我构建出什么格式),type 是消费者视角(我要的东西什么格式)。常见值:jar(默认)、pom(BOM 就是这种)、war、maven-plugin、test-jar。

命名是约定,不是强制

groupId 用反向域名,artifactId 小写连字符。但这不是语法要求,你写 <groupId>abc</groupId> Maven 也不会拦你。中央仓库的发布流程有域名所有权验证(你声称 com.fasterxml,得证明你拥有 fasterxml.com),但那是仓库方的准入规则,不是 Maven 协议的一部分。你自己的私服里想写什么都行。

Go 的模块路径不只是名字,它就是下载地址,名字和位置绑定。Maven 的 groupId 只是一个字符串命名空间,名字和位置分离 —— 去哪找由 05.2 的仓库配置决定。

classifier:同一个 GAV 的多种产物

一份源码能构建出几个产物?至少四个:主 jar、sources.jar、javadoc.jar、tests.jar。它们的 GAV 完全一样,靠 classifier 区分。

mvn dependency:get -Dartifact=com.fasterxml.jackson.core:jackson-databind:2.15.3:jar:sources

第二类用途更硬核:同一份代码针对不同平台/JDK 的产物。

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-transport-native-epoll</artifactId>
    <version>4.1.100.Final</version>
    <classifier>linux-x86_64</classifier>
</dependency>

Go 怎么处理这类事?build tag(//go:build linux && amd64)做条件编译,或者 GOOS=linux GOARCH=amd64 go build 交叉编译。Go 把平台差异解决在编译期,Maven 把平台差异解决在坐标里。

SNAPSHOT vs RELEASE

名字里带 -SNAPSHOT 的版本语义是”这个坐标指向的东西随时会变”。每次 deploy,仓库里存的是带时间戳的文件:

~/.m2/repository/com/example/order-core/1.0.0-SNAPSHOT/
├── order-core-1.0.0-20260902.113042-17.jar
├── order-core-1.0.0-SNAPSHOT.jar      # 指向最新时间戳的副本
└── maven-metadata.xml                  # 记录"最新的是哪个时间戳"
<metadata>
  <groupId>com.example</groupId>
  <artifactId>order-core</artifactId>
  <version>1.0.0-SNAPSHOT</version>
  <versioning>
    <snapshot>
      <timestamp>20260902.113042</timestamp>
      <buildNumber>17</buildNumber>
    </snapshot>
    <lastUpdated>20260902113042</lastUpdated>
  </versioning>
</metadata>

Maven 靠这个文件判断远端有没有更新的快照,默认每天只检查一次(updatePolicy=daily),所以经常出现”同事刚 deploy 了,我这边还是旧的”。想立刻拿到:

mvn clean install -U   # -U = --update-snapshots,强制检查更新

【思考】为什么生产发布绝对不能用 SNAPSHOT 依赖?

换个问法:如果今天构建出的 jar 和明天构建出的 jar 内容不一样,但版本号一模一样,会发生什么?

参考答案

直接答案:SNAPSHOT 是”可变版本”,可变版本意味着构建不可重现。

三条代价:

一、你无法回答”线上跑的到底是什么代码”。 发布用 order-core:1.0.0-SNAPSHOT,三个月后出事故,你 checkout 那个 tag 重新构建,复现不了 —— 因为这个版本三个月被 deploy 过一百多次,你拿到的已经不是当时那个了。

二、SNAPSHOT 可以被覆盖,也可能被删除。 很多私服配了自动清理(保留最近 N 个或 30 天)。一年前构建成功的产物,今天可能连依赖都拉不到了。

三、下游被拖着一起变。 你改一行代码 deploy 上去,依赖你的 order-web 啥都没动,下次构建行为就变了。他人的构建结果取决于你的提交时机。

Go 的对照设计:Go 从设计上不允许可变的版本。 版本号要么是语义化 tag,要么是伪版本号:

require github.com/example/order-core v0.0.0-20260902113042-8a3f2c1d9e77

格式是 v0.0.0-<UTC 时间戳>-<commit hash 前 12 位> —— 时间和内容哈希被写进了版本字符串本身,且永远指向那个 commit。再叠一层 go.sum:

github.com/example/order-core v0.0.0-20260902113042-8a3f2c1d9e77 h1:9fX...

h1: 后面是整个模块 zip 的 dirhash。内容变了 hash 就变,go build 直接失败。

代码锚点 —— Maven 里怎么补这一层:

<plugin>
    <artifactId>maven-enforcer-plugin</artifactId>
    <version>3.4.1</version>
    <executions>
        <execution>
            <id>ban-snapshot</id>
            <goals><goal>enforce</goal></goals>
            <configuration>
                <rules>
                    <!-- 发布时若依赖里还有 SNAPSHOT,直接让构建失败 -->
                    <requireReleaseDeps>
                        <message>发布版本不允许依赖 SNAPSHOT!</message>
                    </requireReleaseDeps>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

更深一层:两种”版本”哲学。 Go 认为版本是内容的指纹,可变版本不配叫版本。Maven 认为版本是可协商的标签,仓库管理者可以决定 1.0.0-SNAPSHOT 现在指向什么。2004 年这是合理的(那时没有不可变基础设施,“持续集成”就意味着共用一个不断前进的最新版)。二十年后答案已经明确:发布物必须不可变。所以现代实践是开发期用 SNAPSHOT,发布时由 maven-release-plugin 或 CI 脚本把 SNAPSHOT 改写成正式号、打 tag、再 deploy。

一句话:SNAPSHOT 是开发期的便利,不是发布期的选项。

Go 对照一:命名与寻址

维度GoMaven
唯一标识模块路径坐标 GAV(+packaging+classifier)
名字是否即地址是否,名字和仓库位置分离
唯一性由谁保证代码托管平台的 namespace约定 + 中央仓库的域名验证
平台差异build tag / GOOS+GOARCHclassifier
可变版本不存在(伪版本含 commit hash)SNAPSHOT
内容校验go.sum(默认强制)无内置,需自己配

05.2 仓库体系:三层结构

本地仓库

~/.m2/repository。坐标到路径的映射极其直白 —— groupId 的点转斜杠,再摞上 artifactId 和 version:

com.fasterxml.jackson.core:jackson-databind:2.15.3
        ↓
~/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.15.3/
├── jackson-databind-2.15.3.jar
├── jackson-databind-2.15.3.pom
├── jackson-databind-2.15.3.jar.sha1
└── _remote.repositories     # 记录这个 jar 是从哪个仓库 id 下载的

_remote.repositories 容易被忽略但很关键:它记录了这个 artifact 是从哪个仓库 id 下载的。换私服后 id 不匹配,Maven 会拒绝用本地缓存重新下载 —— 这是”我明明本地有,为什么还要联网”的常见原因。

问题 2: 本地仓库损坏了怎么办?(真事:机器断电,一个 jar 下载了一半)

Maven 的 .sha1 校验只在下载时做,之后不复查。半截 jar 会一直留着,直到报 zip file is empty 或 invalid CEN header。

rm -rf ~/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.15.3
# 清理所有下载失败的标记
find ~/.m2/repository -name "*.lastUpdated" -delete

.lastUpdated 是”下载失败”的标记,它让 Maven 一段时间内不再重试。这是另一个经典坑:网络抖一下,之后几天 Maven 都坚持”这个依赖拉不到”,直到你删掉 .lastUpdated。

中央仓库、镜像、私服

层级位置谁维护作用
本地仓库~/.m2/repository你自己缓存
私服公司内网 Nexus/Artifactory公司运维代理缓存 + 内部构件 + 治理
中央仓库repo1.maven.orgSonatype公共构件最终来源

查找顺序:本地 → 私服 → 中央。配了镜像后,指向中央的请求会被改写地址。国内配阿里云镜像的原因很朴素:地理位置决定延迟,且中央仓库对单 IP 有限速。

<!-- ~/.m2/settings.xml -->
<mirrors>
  <mirror>
    <id>aliyun-public</id>
    <url>https://maven.aliyun.com/repository/public</url>
    <mirrorOf>central</mirrorOf>   <!-- 注意不是 * -->
  </mirror>
</mirrors>

settings.xml 的层次

层次位置说明
全局${maven.home}/conf/settings.xml影响这台机器上的所有人
用户~/.m2/settings.xml最常用,同名元素覆盖全局
命令行mvn -s /path/to/settings.xml优先级最高,CI 里隔离环境很好用
项目.mvn/maven.config、.mvn/jvm.config、.mvn/extensions.xml严格说不是 settings 的第三层,是项目级 CLI 参数

合并规则:不是整体替换,是同名元素覆盖;列表类元素(<mirrors>、<profiles>)追加后按 id 去重。

注意:Maven 3.x 没有官方的”项目级 settings.xml”。Maven Wrapper(mvnw,第 00 章那个项目里就有)锁的是 Maven 版本,不是 settings —— 别指望它统一仓库配置。

【思考】为什么公司里几乎都必须配私服?只说”快”肯定不够。

至少想出三个”除了速度之外”的理由。

参考答案

直接答案:私服的核心价值不是加速,是在你的团队和公共互联网之间插入一个可控的中间层。

一、发布内部构件(刚需)。 com.example.order:order-core:1.0.0 不可能发到中央仓库,下游只能从私服拉。

<distributionManagement>
    <repository>
        <id>nexus-releases</id>       <!-- id 必须和 settings.xml 的 <server> 对上 -->
        <url>https://nexus.example.com/repository/maven-releases/</url>
    </repository>
</distributionManagement>

这个 id 对应关系是新手最常配错的地方。

二、依赖治理:在入口处拦截。 Log4j 2.x 漏洞爆发时,配了私服的公司可以直接在 Nexus 上把 log4j-core 的问题版本拉黑,所有项目立刻拉不到;没配的只能挨个项目改 pom 重新发版。那两周里,有没有私服决定了修复是一小时还是两周。

三、断网也能构建。 私服缓存过的构件,外网断了本地照样能跑。CI 机器的稳定性靠这个。

四、审计与合规。 金融、医疗行业经常要回答”生产系统里用了哪些开源组件、什么版本、有没有已知漏洞”。私服是唯一能系统性回答这个问题的地方。

五、抵御外部不可用。 中央仓库被墙、限速、构件被作者删掉(npm 的 left-pad 事件,Maven 侧也有类似)。有缓存就安全。

代码锚点 —— 最小可用的私服配置:

<settings>
  <servers>
    <server>
      <id>nexus-releases</id>
      <username>deployer</username>
      <password>${env.NEXUS_PWD}</password>
    </server>
  </servers>
  <mirrors>
    <mirror>
      <id>nexus-public</id>
      <url>https://nexus.example.com/repository/maven-public/</url>
      <mirrorOf>central</mirrorOf>
    </mirror>
  </mirrors>
</settings>

更深一层:私服是”依赖的供应链治理点”。 Go 世界里这个位置由 GOPROXY 承担(Athens、goproxy.cn 都干这个)。但两者有根本差别:Maven 私服同时承担代理(拉)和仓库(推),你们公司的构件也往那儿放;Go 的 GOPROXY 只做代理,私有模块靠 GOPRIVATE 绕过代理直连 git。

Go 的选择更简单(不需要”发布构件”的概念,因为依赖就是源码仓库),代价是丧失了对依赖版本的集中治理能力。Maven 更重,但换来了发布、权限、扫描、拦截的一整套企业级能力。

mirrorOf 的坑:一个 * 引发的血案

<mirrorOf>*</mirrorOf>   <!-- 危险:所有仓库请求都被劫持到这个镜像 -->
值含义
central只镜像 id 为 central 的仓库
*镜像所有仓库
external:*所有非 localhost、非 file:// 的仓库
*,!my-repo所有仓库,除了 id 为 my-repo 的

问题 3: pom 里配了一个第三方仓库(某厂商只提供自己的 repo),同时 settings.xml 写了 mirrorOf=*。会怎样?第三方仓库的请求也被发到镜像,镜像上没有那个构件,报 Could not find artifact。然后你会去搜”为什么加了仓库还是拉不到”,浪费一下午。

<mirrorOf>*,!vendor-repo</mirrorOf>   <!-- 正确写法 -->

Go 对照二:仓库与代理

维度GoMaven
本地缓存$GOPATH/pkg/mod~/.m2/repository
换源GOPROXY=https://goproxy.cn,directsettings.xml 里配 <mirror>
私有模块GOPRIVATE=github.com/mycorp/* 绕过代理直连 git私服(同一套 URL 承担推和拉)
校验开关GOSUMDB=off / GOFLAGS无对应机制(默认就不校验)
发布无 —— 打 tag 即发布mvn deploy 到 distributionManagement

必须承认:在”换个源”这件事上,Go 的设计比 Maven 清晰得多。 一行环境变量,语义明确、可覆盖、可读。Maven 的 mirrorOf 通配符是那种”当年能解决问题就行”的设计,二十年后的坑当年没人预料到。


05.3 依赖传递与仲裁:本章的心脏

传递性依赖:87 行是怎么来的

A → B → C,你只声明了 A,B 和 C 都进来。你在 order-web 里写了 spring-boot-starter-web 一个依赖,它带进 spring-boot-starter-json,后者带进 jackson-databind,再带进 jackson-core 和 jackson-annotations。一个 starter 展开成三十几个 jar,全靠传递。

Go 也有传递,但有本质差别:Go 把传递依赖的最终版本拍平写进 go.mod,Maven 不会。

// go.mod —— 间接依赖的版本也被显式记录
require (
    github.com/gin-gonic/gin v1.9.1
    golang.org/x/net v0.17.0 // indirect
    // ... 几十行 indirect
)

Maven 的 pom 里只有你写的那 9 行,剩下 78 行是每次构建时重新算出来的。

仲裁规则:nearest wins

只有两条:

规则一:路径最短者优先(nearest wins)。与版本号大小无关。 规则二:路径深度相同时,第一声明者优先。

order-web
├── report-sdk:1.4.0                    (深度 1)
│   └── jackson-databind:2.9.0          (深度 2)  ← 赢,路径短
└── spring-boot-starter-web:2.7.18      (深度 1)
    └── spring-boot-starter-json:2.7.18 (深度 2)
        └── jackson-databind:2.15.3     (深度 3)  ← 输,路径长

最终进 classpath 的是 2.9.0。 版本号更小,但它赢了。

【思考】Maven 选到了更低的版本,这不是 bug 吗?

先想想:如果让你设计,你选”路径最短”还是”版本最高”?各自的理由是什么?

参考答案

直接答案:不是 bug,是 2005 年做出的设计选择,逻辑是”路径越短,声明越直接,越应该被尊重”。

这个逻辑站得住。 你在 pom 里显式写 jackson-databind:2.15.3(深度 1),引入的 SDK 传递依赖 2.9.0(深度 2),你当然希望 2.15.3 赢 —— 那个版本号是你亲手敲的,表达了明确意图。nearest wins 精确实现了这一点:显式声明永远击败传递依赖。

坑在”两个传递依赖深度相同”的时候:

order-web
├── sdk-a:1.0 → jackson-databind:2.15.3   (深度 2)
└── sdk-b:2.0 → jackson-databind:2.9.0    (深度 2)

深度相同,先声明者赢。某天有人整理 pom,把 sdk-b 挪到前面 —— 什么代码都没改,进 classpath 的 jackson 从 2.15.3 变成 2.9.0。

这才是真正的问题:Maven 的依赖解析结果对声明顺序敏感,而”声明顺序”是一个没人刻意维护、也没有语义的东西。

对照 Go 的 MVS: 结果只取决于版本集合,与声明顺序完全无关。MVS 有三条性质 —— 顺序无关、单调(加依赖只会让版本升高,不会降低)、可重现(同一个 go.mod 永远算出同一结果,再叠 go.sum 保证内容一致)。Maven 一条都不满足。

代码锚点 —— 验证”顺序敏感”:

<!-- 顺序 A:sdk-a 在前,2.15.3 赢 -->
<dependencies>
    <dependency><groupId>com.example</groupId><artifactId>sdk-a</artifactId><version>1.0</version></dependency>
    <dependency><groupId>com.example</groupId><artifactId>sdk-b</artifactId><version>2.0</version></dependency>
</dependencies>
 
<!-- 顺序 B:两行换个位置,2.9.0 赢 -->
<dependencies>
    <dependency><groupId>com.example</groupId><artifactId>sdk-b</artifactId><version>2.0</version></dependency>
    <dependency><groupId>com.example</groupId><artifactId>sdk-a</artifactId><version>1.0</version></dependency>
</dependencies>

跑两次 mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind 看输出变化。做完这个实验,你对”为什么 Java 需要依赖治理”的理解会和只读文章的人不在一个层次。

更深一层: Maven 2(2005)面对的问题是”如何让显式声明赢过传递依赖”,它用”路径深度”这个统一指标解决了 —— 优雅、简单、覆盖 90% 场景。Go 面对的是另一个问题:在任何人都能发布模块的世界里,如何让解析结果可预测、可重现、不受发布顺序影响。Go 选”最高版本”,因为那是唯一不依赖”谁先声明”的选择。

两个答案都解决了自己时代的问题。只是后来依赖树从二十个节点涨到两百个,深度冲突从罕见变成日常,nearest wins 的代价就显出来了。这也是 Gradle 改选”最高版本优先”、以及 Maven 社区承认需要 dependencyConvergence 的原因。

怎么读 mvn dependency:tree 的输出

mvn dependency:tree                                    # 基础
mvn dependency:tree -Dverbose                          # 显示被仲裁丢弃的版本(关键)
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core           # 整个 groupId
mvn org.apache.maven.plugins:maven-dependency-plugin:3.6.1:tree -Dverbose   # 锁插件版本

-Dincludes 格式是 groupId:artifactId:type:classifier,支持 * 通配,多个条件逗号分隔。

版本提示:maven-dependency-plugin 从 3.x 起 verbose 的行为有过调整(部分版本冲突信息默认输出、参数被移除)。发现 -Dverbose 没效果时,显式锁定插件版本最稳,就是上面最后那条。

一段真实输出,逐行读:

[INFO] --- maven-dependency-plugin:3.6.1:tree (default-cli) @ order-web ---
[INFO] com.example:order-web:jar:1.0.0-SNAPSHOT
[INFO] +- com.thirdparty:report-sdk:jar:1.4.0:compile
[INFO] |  \- com.fasterxml.jackson.core:jackson-databind:jar:2.9.0:compile
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:2.7.18:compile
[INFO] |  +- org.springframework.boot:spring-boot-starter-json:jar:2.7.18:compile
[INFO] |  |  \- (com.fasterxml.jackson.core:jackson-databind:jar:2.15.3:compile - omitted for conflict with 2.9.0)
[INFO] \- org.mybatis:mybatis:jar:3.5.13:compile
  • +- / \- 是树的分支符号,| 是竖线,纯 ASCII
  • 行尾的 :compile 是最终生效的 scope(仲裁后的,不是你声明的)

-Dverbose 下有两种关键标记:

(omitted for conflict with X) —— 这个节点在依赖图里存在,但仲裁输了,被丢弃,不在 classpath 里。2.9.0 是赢家。这是依赖冲突的铁证。

(version managed from X) —— 原本这里应该是 X 版本,但被 dependencyManagement 改成了新版本。

标记谁改的版本方向严重性
omitted for conflict with X仲裁规则这个版本输了,不进 classpath高 —— 这就是冲突
version managed from XdependencyManagement / BOM被覆盖成新的低 —— 治理生效了

一句话记法:“omitted” 是被动挨打,“managed” 是主动管控。

配套命令:

mvn dependency:list          # 拍平列表,适合 grep
mvn dependency:analyze       # 找出"声明了没用"/"用了没声明"
mvn help:effective-pom       # 看 Maven 最终看到的完整 pom(含继承 + BOM 展开)
mvn dependency:sources       # 下载源码包
mvn dependency:copy-dependencies -DoutputDirectory=target/libs

help:effective-pom 是被严重低估的命令。搞不清一个没写版本号的依赖最终版本从哪来时,它就是答案。

Maven 3.x 的遗留问题

nearest wins 在复杂依赖图下产生了大量反直觉结果,社区应对分两路:

  1. Maven 侧:把解析引擎抽成独立的 Maven Resolver(原 Aether),改进图遍历和冲突检测,但仲裁规则本身没变(改了会破坏全世界的项目)。真正解法是外挂治理 —— enforcer 的 dependencyConvergence 让冲突暴露成构建失败。
  2. Gradle 侧:另起炉灶,选”最高版本优先”(05.9)。