依赖项修剪工具综述:检测未使用依赖的方案调查Dependency Pruning
文章综述了多种未使用依赖检测工具(如unused-detectors),探讨其在Python项目中的适用性、准确性和效率,帮助开发者优化依赖树以减少冗余和潜在安全风险。
Andrew Nesbitt
三年前修剪依赖树是最佳时机,现在则是次优选择。
你的 lockfile 中每个包都意味着别人掌握着钥匙。CI 凭据下运行的安装脚本、可能被钓鱼的维护者账户或注册表转手给新所有者,下一个补丁版本可能与上一个截然不同。两版重构前已不再调用的依赖,和每次请求都调用的依赖一样面临同等风险,且 CVE 出现时仍会通知你。最廉价的供应链加固就是停止引入未使用的依赖项。
最近我对 Dependabot 的 CVE 警报和常规版本升级的第一反应,是先检查是否仍需该依赖,再查看其变更。几乎不用的库出现 CVE 时,删除比修补更合理——此举能同时关闭当前警报及未来所有相关警报。除警报本身外无需额外工具即可实现。
现有关于精简依赖的论述多聚焦前端打包体积、摇树优化和死代码消除,以将 JavaScript 载荷控制在 KB 级预算内。而对清单文件本身的工具与建议却鲜有涉及——如何从 Gemfile、pyproject.toml 或 Cargo.toml(无论使用何种语言)中直接删除无用条目。
这里有两个问题,需不同工具解决:一是二元的——我的代码从未导入哪些声明的依赖?可能是为废弃功能添加后未清理;二是比例的——实际调用了已导入依赖的多少部分?因调用一个辅助函数引入 6 万行库,与无效清单条目属不同问题,但两者都让数万行他人代码闲置于供应链中。
Mike Fiedler 开发的 unladen 是唯一尝试解决第二问的工具。它构建代码到各依赖的调用图,计算实际激活的库逻辑行比例,并输出“重量比”。若 SCA 扫描器曾用可达性分析判断 CVE 是否影响你,这正是针对整个依赖而非单个缺陷函数的相同机制。
低重量比提示可考虑内联所需代码或寻找更小专用库。这是 Rob Pike “少量复制优于少量依赖” 的量化实践。unladen 仅支持 Python 且尚处早期,据我所知尚无其他包管理器尝试此方法。在此之前,多数语言的实用方案是让代码助手分析仓库,找出可内联的依赖——这往往比预期更有效。
Python
Python 在这一领域表现尤为突出,可能是因为动态导入以及 requirements.txt 与实际安装包之间的差异问题长期困扰开发者,促使多个团队独立开发了扫描工具。deptry 和 creosote 都会对源代码进行静态 AST 遍历,收集导入的依赖项,并与 pyproject.toml 或 requirements.txt 中的声明进行对比;deptry 还会标记间接引入但未被显式声明的情况。
Tweag 开发的 FawltyDeps 采用了类似的方法,但在处理导入名到包名的映射方面更为出色(这些工具通常在此处出错,例如 import PIL 来自 Pillow 包,import sklearn 来自 scikit-learn,等等)。pip-check-reqs 是其中最古老的工具,提供了一个 pip-extra-reqs 命令,用于检查 plain requirements.txt 中声明但未使用的依赖。这四个工具均处于维护状态,因此可以根据项目结构选择最适合的一个。
JavaScript
在查找 package.json 中未使用的条目时,knip 是目前推荐的工具。depcheck 曾是多年来的标准工具,但其仓库于 2025 年初被归档,其 README 文档已指向 knip。knip 从入口点构建完整的模块图,支持一百多种框架和配置文件插件,即使源文件没有直接导入,eslint-plugin-whatever 也会被视为“已使用”,并支持通过 --fix 自动删除发现的未使用内容。
npm、pnpm 或 Yarn 均未原生提供此类功能,尽管供应链安全事件的历史表明这一生态系统中存在大量需求。Christoph Nakazawa 的《Dependency Managers Don’t Manage Your Dependencies》已有五年历史,至今仍是必须手动完成此工作的最佳论据。
Rust
Cargo 没有内置相关功能,但第三方选项表现良好。cargo-machete 通过文本级快速扫描 crate 引用,无需编译任何内容,这使得它在 CI 中每次推送运行的成本足够低,尽管偶尔会出现宏和重新导出的误报。cargo-shear 则正确解析源码以获得更准确的读取结果,同时避免完整构建。cargo-udeps 采取了另一种方式,实际编译项目以查看哪些 crate 被链接,这是最精确的方法,但需要 nightly Rust 版本且耗时与完整构建相当。建议在 CI 中使用 machete,并偶尔手动运行其他工具之一。
Go
Go 是唯一在工具链中彻底解决此问题的语言。go mod tidy 会遍历每个 .go 文件,确定实际的导入集,并重新编写 go.mod 和 go.sum 以匹配,删除任何未引用的内容。由于这是一个标准命令,所有开发者都会运行,Go 项目很少一开始就积累无用依赖,这为每个包管理器提供等效功能的理由提供了有力支持。如果某些依赖项在 tidy 后仍然存在且不确定原因,可以使用 go mod why -m <module> 查看哪个导入路径保留了它。
Java
Maven 的 maven-dependency-plugin 插件早已提供 mvn dependency:analyze 功能。它在编译后的字节码上运行,通过对比引用类与声明的依赖关系,报告“未使用的声明”和“未声明的使用”(即间接引入但应直接声明的依赖)。在 Gradle 生态中,Dependency Analysis Gradle Plugin 已成为标准工具,能生成结构化建议,包含未使用依赖等依赖卫生问题;Netflix 的 Nebula Lint 也有一条 unused-dependency 规则,执行类似的字节码与声明检查。
字节码分析无法识别反射或注解处理器,因此通过类名字符串加载或仅在编译期使用的代码会被误判为未使用——这恰好符合大量企业 Java 项目的特征。若需证明此类操作在安全层面的收益,SAP 的 Ponta 等人曾对一个真实工业级 Java 应用进行去冗余处理,并观测到后续 CVE 漏洞暴露率显著下降。
PHP
composer-unused 通过比对 class 和 namespace 的使用情况与 composer.json 中的自动加载映射,找出未被任何代码引用的包。ShipMonk 的 composer-dependency-analyser 速度更快,还能检测影子依赖以及本应属于 require-dev 而非 require 的包。两者均处于维护状态。
.NET
.NET CLI 没有对应的命令。Visual Studio 在解决方案资源管理器中集成了基于 Roslyn 的“移除未使用引用”功能,而 ReferenceTrimmer 则将相同的 Roslyn 分析嵌入构建流程以支持 CI。snitch 则会找出那些已通过传递性获取、无需显式声明的包,虽相关但不会真正缩小依赖闭包。
Elixir
Mix 内置了 mix deps.unlock --unused,它会清除 mix.exs 中不再需要的 lockfile 条目,还有 --check-unused 可在 CI 中发现此类问题。但这仅针对 lockfile 卫生管理,而非代码级分析——它无法发现仍列于 mix.exs 但实际未被任何模块调用的包。目前我未找到维护良好的第三方工具能实现完整的源码与清单比对,因此在 Elixir 项目中你可能需要手动阅读 mix.exs。
Ruby
Ruby 是我预期工具链最完善却收获最少的环境。Bundler 无内置检查功能,bundle clean 仅删除 lockfile 中未安装的 gem,二者目的不同。degem 是唯一近两年有更新的静态扫描工具,它能根据 Gemfile 中的 require 调用和常量引用进行检测。
除此之外,仅有 2015 年前后的一些过时方案:要么用 grep 匹配 gem 名称,要么在测试覆盖率下运行套件观察加载的文件。由于 Ruby 高度依赖自动加载和元编程,静态方法总会产生大量噪音,而运行时覆盖率的准确性又受限于测试用例完整性。不过带着审慎态度使用 degem 总比没有强。若有开发者能为 Ruby 打造轻量级工具,我愿成为首批用户。
Caveats
静态分析工具无法识别动态导入、通过入口点或字符串名称加载的插件系统、仅提供 CLI(需通过 shell 调用)的包,以及仅被类型检查器使用的类型存根包。因此这些工具会错误地将某些标记为未使用。大多数维护良好的工具都为此提供了忽略列表,你需要手动配置它们。此外也存在假阴性情况:某个包可能被报告为“已使用”,因为某文件导入了它,但该文件本身是死代码(无人调用)。因此在删除依赖前先清理死代码能获得更干净的结果,而 knip 等工具会同时执行这两项操作。
若担心破坏性变更,应加强测试覆盖率。如果扫描工具报告某个依赖未被使用,你将其删除后 CI 通过,但生产环境却出现崩溃,真正的问题其实是存在未被测试的代码路径——无论是否进行依赖修剪,你都需要知晓这一点。你的代码调用第三方库的位置本就是需要测试的关键边界,因为次版本更新时对方的行为变化可能直接导致你的 Bug。能在删除任何内容前就通过修剪过程暴露几个这类漏洞,这样的修剪工作就已经物有所值。
需要完整排版与评论请前往来源站点阅读。