2855 字
约 9 分钟
14
S06-A11 · 传递依赖的治理:谁引进来的、谁能删掉

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. 自测

  1. 直接依赖三十个、进包两百多个,先算哪个比值判断要不要上正式治理?
  2. "就近优先"在什么情况下会因一次无意义的声明顺序调整而改变实际版本?
  3. 台账里哪个字段必须清零,为什么是它而不是过期数?
  4. 排除某条传递依赖后编译报错,说明被排除的东西除了实现还带了什么?
  5. 判断一个库"没人维护",哪条判据比提交活跃度更硬?
  6. 什么情况下完整的依赖治理流程,反而比不做更糟?
S06-A11 · 传递依赖的治理:谁引进来的、谁能删掉
https://www.clxhxhhr.top/posts/6320/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。