pom.xml 里自己写下的依赖三十一个,打进 BOOT-INF/lib 的 jar 两百四十多个。问是谁带进来的,三个人给出三个答案;更关键的问题没人能答:我要删掉其中一个,谁会因此编译失败。
难点不是"看不见",而是"没有归属"。工具负责把树打出来,制度负责回答"删了谁负责"。这篇只讨论后者。
1. 三个问题,三种暴露方式
| 问题 | 暴露方式 | 引见到暴露的延迟 | 处置窗口 |
|---|---|---|---|
| 安全漏洞 | 扫描报告或公告 | 引进时已存在,扫描跑起来才可见 | 小时到天 |
| 体积膨胀 | 镜像大小、启动耗时 | 每次发版看得见,但没人归因 | 月级,缓慢恶化 |
| 版本冲突 | NoSuchMethodError 等运行期报错 |
引入即潜伏,特定路径才触发 | 小时级,定位贵 |
共同点是没有一条告警叫"你多了一个依赖":体积是连续量,冲突是条件触发,漏洞要外部输入。治理不能等信号,只能定期盘。
2. 谁引进来的:两种成本结构
//─── 一次性看树,人肉追;输出长度随项目规模变化,按各版本核对
$ mvn -q dependency:tree -Dincludes=org.apache.commons:commons-text
[INFO] com.yg:clarity-web:jar:0.0.1
[INFO] \- ...spring-boot-starter-web:jar:3.3.6:compile
[INFO] \- ...jackson-databind:jar:2.17.2:compile
[INFO] \- (commons-text:jar:1.10.0 - omitted for conflict with 1.11.0)
//─── 把归属做成机器可读产物,进 CI 比对
$ mvn -q dependency:list | grep -E ':(compile|runtime)' | sort > deps-flat.txt
$ wc -l deps-flat.txt // 与 pom 条目数之差,就是治理缺口
| 做法 | 单次成本 | 可重复性 | 能回答"删了谁负责"吗 |
|---|---|---|---|
| 打一次树肉眼看 | 十几分钟,输出几千行 | 低,结论随人而异 | 不能 |
| 生成 DOT/SVG 图 | 半小时搭一次 | 中,图会大到看不清 | 不能,图不标归属 |
| 扁平清单 + 差集比对 | 一次性脚本,之后 CI 跑 | 高 | 能,前提是清单进仓库 |
| 依赖解析平台 | 按周计接入 | 高 | 能,需对齐仓库权限 |
判据不是"树有多长",而是"从发现问题到知道归属的耗时"。这个耗时超过半小时,实际结果就是没人查。
<!-- pom.xml:把关键传递依赖显式登记,让归属可被搜索 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
<!-- 来源:spring-boot-starter-json 传递,此处仅用于钉版本 -->
<exclusions>
<exclusion>
<groupId>org.apache.commons</groupId>
<artifactId>commons-text</artifactId>
</exclusion>
</exclusions>
</dependency>
显式登记的代价:它看起来像直接依赖,清点"我们用了哪些库"时会被算进去,导致清单虚高——要么写注释标来源,要么在清单里单独分组。
3. 调解规则与它们的隐蔽后果
| 规则 | 生效方 | 结果 | 隐蔽后果 |
|---|---|---|---|
| 就近优先 | Maven 默认 | 声明层级浅的赢,与版本号无关 | 在 pom 里加一层 dependencyManagement 或调整声明顺序,就能悄悄换版本,diff 里看不出来 |
| 最高版本 | Gradle 默认 | 版本号大的赢 | 被升级的一方可能引入 breaking change,调用方代码没变 |
| 显式强制 | 两者的管理块 / resolutionStrategy |
一律用指定版本 | 与传递方期望不符时,运行时才炸 |
Maven 的"就近优先"最反直觉:谁在文件里写得更靠前,谁就赢。同一个库被两个 starter 传递进来,把顺序对调,实际版本可能就变了,而代码 diff 是零行。
// build.gradle.kts:把版本决定权收回仓库,并让冲突在构建期暴露
configurations.all {
resolutionStrategy {
force("org.apache.commons:commons-text:1.11.0")
failOnVersionConflict()
}
}
代价很实在:开开关那周会有一批"原本能跑"的构建失败,要备好清理冲突的人力,不是加一行就完事。
4. 收敛、强制与禁止:三种手段的取舍
| 策略 | 做法 | 收益 | 维护成本 | 适用前提 |
|---|---|---|---|---|
| 收敛 | parent BOM 统一版本 | 升级变单点动作 | 中:BOM 随上游更新 | 多模块、同生态库多 |
| 强制 | 忽略传递方期望,写死版本 | 立刻可预测 | 高:每项都是一条例外 | 已知传递版本有缺陷 |
| 排除 | <exclusions> |
挡掉指定坐标 | 高:上游换实现后要重排 | 明确的双实现冲突 |
| provided / 管理块 | 不打包 / 只定版本 | 减少进包 | 中 / 低 | 环境确定提供 / 多模块统一 |
判据是例外项的数目:强制清单超过十个条目,说明收敛机制没建立,强制只是在逐个打补丁——这些补丁半年后没人敢删。
<!-- 收敛的落点:import 只声明版本,不引入依赖,不会增加进包 jar 数 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.3.6</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
provided 最容易被当轻量化手段误用:编译期可见、运行期由环境提供,环境缺它时报错发生在启动而不是构建——构建是绿的。
// 禁止某类坐标被传递引入:正当理由不是体积,而是同一能力有两个实现
configurations.all {
exclude(group = "commons-logging", module = "commons-logging")
exclude(group = "log4j", module = "log4j")
}
commons-logging 这类排除解决的不是"多一个 jar",而是"行为由类路径顺序决定"——排查方向不同:前者看体积报告,后者看运行期实际加载了哪个实现。
5. 上游不受控,与"没人维护"的判据
传递依赖的版本通常由别人决定,可选动作只有四件:排除后自己引、接受并监控、替换上游、fork。
| 处境 | 可选动作 | 代价 |
|---|---|---|
| 上游漏洞未修 | 显式覆盖 + 回归测试 | 与上游期望不符,可能运行期异常 |
| 上游一年未更新 | 换实现或进风险清单 | 换实现要改调用点 |
| 上游已归档 | 进替换排期 | 排期长度取决于调用面 |
| 判据 | 观察点 | 参考阈值(按项目实际核对) |
|---|---|---|
| 提交活跃度 | 最近一次非机器人提交 | 12–18 个月无实质提交进观察 |
| 安全公告处理 | 有 CVE 后多久出修复版 | 无修复版本是硬伤 |
| 官方替代声明 | README 是否点名替代品 | 明示即判 |
最后一项最关键:看"有无被官方点名的替代品",不看 star 数。活跃的库不用操心,归档的库会自己说明。
6. 扇出比、体检输出与度量
//─── 扇出比:两个数分别从构建产物与 pom 取,不从依赖树取
$ unzip -l target/app.jar | grep -c 'BOOT-INF/lib/.*\.jar$'
| 扇出比(传递/直接) | 典型形态 | 治理建议 |
|---|---|---|
| < 2 | 手写过依赖的脚本型服务 | 看扫描报告 |
| 3–6 | 常规 Spring Boot 服务 | 收敛 + 归属清单 |
| 6–15 | 依赖多生态(Web、消息、搜索) | 上 BOM 与冲突门禁 |
| > 15 | 引入重型框架或厂商 SDK | 先查 SDK 是否自带整套运行时 |
扇出比高不是罪。Spring Boot web 服务直接依赖十几个、进包两百多个是正常形态;真正的问题是这两百多个里有多少能说出归属。扇出比只用来判断要不要上正式治理,不用来判断"依赖太多"。
体检报告最容易做成"列表":三百行坐标、版本、许可证,没人读第二遍。能推动事情的是带决策字段的台账:
//─── 依赖体检台账:一行动作,不是一行坐标
- id: DEP-0142
coord: org.apache.commons:commons-text
origin: spring-boot-starter-json -> jackson-databind
action: pin // pin / exclude / keep
target: 1.11.0
owner: platform
verify: mvn -pl web test -Dtest=*Json*
| 指标 | 计算方式 | 读数含义 |
|---|---|---|
| 直接/传递比 | 进包数 ÷ 声明数 | 要不要上 BOM 与门禁 |
| 冲突数 | 构建期调解次数 | 高且稳定 = 长期无人收敛 |
| 过期数 | 落后 N 个次版本的依赖数 | 集中过期 = 无人负责升级 |
| 归属不明数 | 台账里 origin 为空的条目 |
唯一必须清零的指标 |
前三个允许有数,第四个不允许:归属不明意味着连"能否删"都无从判断,漏洞处置必然卡在这一步。
7. 排错:四种"查不下去"
| 现象 | 常见原因 | 处理顺序 |
|---|---|---|
编译通过,运行报 NoSuchMethodError |
调解后实际版本比编译期低 | 打印运行期类路径,比对 jar 版本 |
| 树里看不到某个类 | 被 provided / optional 遮挡 |
加 -Dverbose 看省略原因 |
| 两处声明同版本,进包是第三个 | dependencyManagement 优先 |
从管理块往上找 |
| 排除后编译失败 | 那条路径上除了该 jar 还带了 API | 改为显式引入,而不是整条砍掉 |
-Dverbose 把 omitted for conflict 与 omitted for duplicate 分开打印,两者的处置完全不同:前者改版本,后者改声明位置。
8. 边界:收益低于成本的场景
| 场景 | 建议 | 理由 |
|---|---|---|
| 脚本级小项目,依赖少于二十个 | 只用扫描报告 | 扇出比低,归属成本高于收益 |
| 内部私有仓库、依赖都是自研 | 用版本基线代替归属台账 | 归属天然明确 |
| 一次性交付 | 锁一次,不做定期体检 | 体检价值来自时间维度 |
| 单模块单团队、发布低于月度 | 台账压成 README 表 | 流程成本超过问题本身 |
| 产物不对外分发 | 放下体积与许可证 | 只保留漏洞这条线 |
正式治理的成本不在工具,而在每次体检都要有人对着台账做决策。这个人力不存在时,BOM、门禁、台账只会变成没人更新的三份文档——比没有更糟,它给出"已在治理"的错觉。
9. 自测
- 直接依赖三十个、进包两百多个,先算哪个比值判断要不要上正式治理?
- "就近优先"在什么情况下会因一次无意义的声明顺序调整而改变实际版本?
- 台账里哪个字段必须清零,为什么是它而不是过期数?
- 排除某条传递依赖后编译报错,说明被排除的东西除了实现还带了什么?
- 判断一个库"没人维护",哪条判据比提交活跃度更硬?
- 什么情况下完整的依赖治理流程,反而比不做更糟?