3.1 传递依赖:一棵树的蔓延方式 本节摘要:传递依赖指依赖的依赖,Maven 会沿依赖树递归解析直至叶子,声明 5 个依赖膨胀出两百多行树是常态而非异常。scope 与 optional 是控制蔓延的两道闸门。理解蔓延规则与拦截机制,是进入冲突定位与版本仲裁之前的第一课。 一、先看一次实测膨胀 给 risk-engine 数了数:POM 里 dependencies 段只有 5 个声明——spring-boot-starter-web、mybatis-plus、rule-sdk、commons-lang3、junit。跑一次依赖树: 5 个声明带来 146 个实际入场的 compile 构件。每个传递进来的构件又自带它的依赖清单,Maven 递归解析直到叶子节点,这个过程叫传递依赖解析。
本节摘要:传递依赖指依赖的依赖,Maven 会沿依赖树递归解析直至叶子,声明 5 个依赖膨胀出两百多行树是常态而非异常。scope 与 optional 是控制蔓延的两道闸门。理解蔓延规则与拦截机制,是进入冲突定位与版本仲裁之前的第一课。
给 risk-engine 数了数:POM 里 dependencies 段只有 5 个声明——spring-boot-starter-web、mybatis-plus、rule-sdk、commons-lang3、junit。跑一次依赖树:
mvn dependency:tree | wc -l # 输出:217 行 mvn dependency:tree -Dscope=compile | wc -l # 输出:146 行 compile 作用域的传递依赖
5 个声明带来 146 个实际入场的 compile 构件。每个传递进来的构件又自带它的依赖清单,Maven 递归解析直到叶子节点,这个过程叫传递依赖解析。作战室视角必须先建立一个直觉:你声明的不是 5 个库,是 5 张社会关系网。任何一张网里的一次升级,都可能改变你 classpath 的构成——这就是"我没改代码但构建结果变了"的第一来源。
传递不是无条件的,两道闸门控制着蔓延的范围。第一道是 scope(作用域),它同时决定依赖在哪个阶段可见、以及以什么身份向下传递:
| 你的 scope | 对下游的传递身份 | 战术含义 |
|---|---|---|
| compile | compile | 全兵种通行 默认最泛滥 |
| provided | 不传递 | 编译要用 运行环境自带 如 Servlet API |
| runtime | runtime | 编译不需要 运行要用 如 JDBC 驱动 |
| test | 不传递 | 只在测试阶段出现 |
| system | 不传递 | 本地路径引入 高危不推荐 |
| import | 特殊 只用于 BOM | 不引依赖只导入版本管理 第 3.3 节主角 |
关键结论有一条:provided 与 test 是传染阻断剂。一个库若把自己的某个依赖标为 provided,它就不会把这个依赖强加给你;反过来说,若你发现某构件"应该被传递却没有传下来",先查上游是不是标了 provided 或 optional。
第二道闸门是 optional(可选依赖),它声明"这个依赖是我的可选增强,不随我传递":
<!-- rule-sdk 的 POM:日志实现声明为可选 --> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.2.11</version> <!-- 可选:用我的人想要日志就得自己声明 --> <optional>true</optional> </dependency>
效果实测:risk-engine 引入 rule-sdk 后,依赖树里找不到 logback——它被 optional 闸门拦下了;直到 risk-engine 自己显式声明日志实现,它才入场。库作者用 optional 表达"插件式能力",应用侧则获得了选择权。经典例子是各种数据库驱动与连接池实现。

被动接受膨胀与主动管理膨胀,是两个工程段位的分界。三件事立刻能做。
第一件,定期数树。 把 dependency:tree 的行数纳入构建观测,突然的增长意味着某个依赖做了一次"大扩军",值得在 code review 里问一句。
第二件,限制深度暴露。 公司内部的公共库应克制传递面:能标 optional 的标 optional,能收窄 scope 的收窄,工具类库尤其不要把整个日志实现、整个 HTTP 客户端强塞给使用方。
第三件,警惕"树很深"本身。 深度不只是数量问题,它与第 3.2 节的调解规则直接相关——路径深度是版本裁决的第一依据,一条意外变深的路径会改变裁决结果。实战中"升级了 A,B 的行为变了"的灵异事件,多半是 A 的新版本改变了某条传递路径的深度,让版本裁决换了胜者。
空目录里建一个只有 POM 的项目,做一组对照实验,十分钟就能把蔓延机制内化:
<!-- 实验项目:先只引入一个 starter 级别的聚合包 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.7.14</version> </dependency> </dependencies>
mvn dependency:tree # 数一数行数 记下来 作为基线 # 然后再加上第二个依赖 重跑 再数 # 你会观察到:新增行数远大于 1 # 因为第二个包的传递依赖里 有一部分与第一个的传递依赖同名同版 被合并 # 也有另一部分是全新的 继续向下蔓延伸展
再做一个 optional 实验:给自己的项目写一个依赖声明、标记 optional,然后让另一个项目依赖你——观察 optional 的依赖没有出现在对方的树里。第 4.2 节讲 BOM 时,这些手感会直接复用。
实验做完顺手把 scope 的传播矩阵做成速查卡贴在作战室墙上(下表合并了两道闸门的全部组合,排查"该传的没传下来"时按行查):
| 上游声明的组合 | 会传给下游吗 | 典型例子 |
|---|---|---|
| compile 且非 optional | 传 且以 compile 身份 | 工具库的核心依赖 |
| runtime 且非 optional | 传 以 runtime 身份 | JDBC 驱动 |
| compile 且 optional | 不传 | 可选日志实现 |
| provided 无论 optional | 不传 | 容器提供的 API |
| test | 不传 | 测试框架 |
⚠️ 常见坑:为了"治住"膨胀,把大量依赖声明成 provided 或加一堆 exclusion。闸门是给库作者用的表达能力,不是应用侧的减肥药——provided 意味着运行环境必须真的提供,乱标会导致运行期缺类;乱 exclude 则会把别人需要的东西一并清场,第 3.2 节会给出排除的纪律。
蔓延的机制清楚了,当多条路径把同一构件的不同版本同时送进战场,裁决规则如何运转、爆炸如何定位——下一节正面开打。