Cargo 的未来愿景:改进依赖管理与构建性能
根据 Stack Overflow 的调查,cargo 是最受开发者青睐的开发工具。许多人选择 Rust 正是因为它拥有 Cargo。我常听人说,Cargo 已经很棒了,想不出还能怎么改进。但我想把目光放得更远一些。
我想听听大家的想法:理想的工作流程应该是什么样的?我们又该如何实现?这仅仅是我个人的一些思考。我知道自己无法全面了解或轻易说清所有工作流程,而其他人或许有更出色的解决方案。
实现这些目标也需要大家的帮助。这些工作流程不仅局限于 Cargo,还涉及 Rust、crates.io 等多个方面。有很多机会可以深入参与并贡献力量。
有待改进的工作流程
我想从工作流程的宏观视角来展开讨论。具体的问题领域(例如构建脚本)将作为改进方案的一部分来阐述。
#[non_exhaustive]
此外,还有关于智能体开发的说明
依赖管理
“永远押注生态系统”
我坚信 crates.io 生态系统是 Rust 的核心优势之一,它让开发者能够快速创建功能更丰富、打磨更精致的应用,这是没有该生态系统时难以实现的。
应用程序不仅能利用现有功能,还能受益于未来的任何功能更新和修复。例如,ripgrep 正在针对大型单体仓库进行优化,而 typos 由于复用了 ignore,自动获得了同样的优化效果。如果重新实现或分叉代码,就无法获得这样的好处。这也使得共享审计成为可能。在你的应用中,可以只使用一两个别人也在用的序列化框架,也可以审计十个散布在配置系统、网络通信等不同模块中的定制方案。
依赖并非没有成本和风险,我们需要努力降低这些成本和风险。
**如何发现高质量的依赖?**
有一些"公认"的依赖可供使用,前提是你了解内情。但高效工作不应该被内部知识所限制。
当没有"公认"的依赖时,你就需要自行寻找候选方案,这并不总是容易的事。为了比较它们,你需要收集一系列信号来判断其安全性、是否适合你的用途、以及能否长期依赖。这些信号可能包括下载量、依赖方、审查记录等,以及作者其他软件包带来的信任度。了解并汇总这些信息需要大量工作。
在理想情况下,所有依赖都应该经过审查。我们并不生活在理想世界中,但如何能更接近这个目标?在 Rust 中,我们有 unsafe 作为隔离需要进一步检查的代码的方式。我们需要更多类型的审计点,并提高它们的可发现性。如果这些审计点是可选的,并且在不需要审计的情况下以性能或功能为代价提供回退方案,那就更好了。不过,有时我们仍然想走捷径,信任他人的审查,因此对自己或他人审查的追踪应该与依赖的使用相结合。
**如何最小化升级开销?**
首先,我们需要改善维护者的 lint 检查和测试状况,以减少 bug 和意外变更。
假设我们现在讨论的是有意的变更,简单的答案就是阻止破坏性变更,但这会让维护者付出代价——无法清除某些技术债务,也会让用户付出代价——在功能、修复和构建性能上受到限制。
我们应该鼓励记录变更,并让这些信息更容易被看到。然而,这只是权宜之计,治标不治本。我们怎样才能做得更好?
一个灵感来源是 Rust 语言,它也通过版本(Editions)提供了一种选择加入的破坏性变更形式。Rust 项目通过不稳定的特性来准备版本,并为用户提供迁移方案。这应该被视为我们为包(packages)的维护者和用户提供的最低体验。更进一步,我们可以将这些变更更直接地呈现给用户,嵌入到他们的工作流程中,并针对他们的具体情况。当迁移不可行时,这一点尤其重要。
作为对现有能力的一次实验,在 clap 中,我采用了这样的做法:尽可能将破坏性变更作为并行特性引入,同时弃用旧行为,并附上迁移说明。弃用功能被放在 deprecated 特性标志后面,因为很多人会把警告(包括弃用警告)当作错误处理,如果默认开启这些警告,升级时就必须同时解决弃用问题,这会增加成本和风险。这极大地简化了迁移指南,使其变成“启用一个特性,然后按提示操作”。当无法实现并行特性时,我们会使用 unstable-vX 特性,这样既不会阻碍最终破坏性变更的开发,也更容易收集反馈。
我看到的几个问题:
- 用户对
deprecated特性的知晓度 - 迁移指南的可发现性
- 迁移仍然需要手动完成
unstable-vX特性可能产生意想不到的效果,需要更多护栏来扩大其使用范围- 这些只是我制定的惯例;要围绕这些想法形成一种文化,需要一条铺好的路来提高认知并鼓励使用
构建性能
提升构建性能,目标不是让速度快上 2%,而是改变用户的工作方式,让 Rust 对更多用户更具吸引力。用户拿来比较的对象,既有 C++(如果不考虑预构建框架,两者颇为相似),也有脚本语言(后者根本无需构建)。
我们还需要把目光从 rustc 的性能,扩展到用户的整个工作流程。
用户在修改底层代码时,渴望获得即时反馈。但不幸的是,这往往会导致整个依赖栈重新构建,即使执行 cargo check 也需要一些时间。
他们在调试测试失败时,会反复迭代直到行为正确。要么循环运行整个 cargo test --workspace(“因为省事”),要么为每次失败手动拼凑出类似 cargo test -p application-library --test area -- some::test::name 的命令,以更快的迭代速度换取操作上的中断。
也许在上述任一过程中,他们基于 main 分支进行了变基,结果因为某个依赖更新,整个依赖栈又得重新构建。又或者磁盘空间耗尽,不得不执行 rm -rf target。再或者,构建莫名其妙地就重来了。
又或者,在上述任一过程中,Cargo 被并发的调用阻塞了——无论是来自 rust-analyzer、另一个希望共享缓存的工作树,还是系统上另一个共享缓存的项目。
有些用户会在等待 CI 完成时切换任务,以免在未合并的代码上堆积过多改动,增加代价高昂的合并冲突风险。CI 系统虽然有缓存,但缓存过多会因网络传输和解压时间而抵消所有好处。适合本地调试的默认配置,会增加缓存大小并拖慢构建速度。虽然构建结果可以缓存,但测试结果不行,整个测试套件每次都得重新运行。
另请参阅我在 RustNL 2025 上的演讲: 视频 / 幻灯片
适应性
Cargo 的设计初衷是带有明确主张,但在这份主张与向后兼容性之间,往往难以灵活适应不同需求——无论是改进 Docker 缓存、复杂应用需求,还是融入大型企业环境。 在 Git 中,操作被划分为底层命令和上层命令。而 Cargo 几乎全是上层命令,可编程性非常有限。增加底层命令并扩展上层命令的程序化输出,将极大帮助人们根据自身需求定制 Cargo。Cargo 维护
任何改进 Cargo 的讨论都离不开一个前提:Cargo 团队需要有足够的人力来支撑这些工作,同时还要维持日常运转。此前,Cargo 曾因团队人手不足而经历了长达数年的功能冻结。我担心我们离重蹈覆辙还有多远。 在那次功能冻结期间及之后的一段时间里,我们专注于改进流程和工具,以提高维护 Cargo 的效率。例如,我们通过开发并迁移到符合需求的快照测试库(snapbox,参见 #14039),使得进行大规模用户体验变更变得更加容易。 如今,我认为最拖慢我们脚步、限制我们做出改变的因素是 Cargo 的架构。Cargo 年代久远,拥有定制化的框架、对并发操作的限制、使用 C 库(而 Rust 库本可替代它们),并且缺乏清晰的边界,导致在做出或审查变更时需要全局知识。并非所有人都能资助这样的工作,我们培养新的设计和代码审查人员也需要时间。外部贡献者最能帮助 Cargo 团队的地方不在于代码,而在于思考。促进思考的一个关键因素是,将线程中的所有相关信息汇集到一处,并加以总结,以便轻松消化。例如,我在另一个项目上有一个进展缓慢的 PR,当我回头查看时,必须重新阅读整个 Issue 线程,才能回忆起该 PR 旨在实现的行为。这工作量很大,导致我需要专门的时间来审查,从而让它永远停留在“某天”的类别里。这听起来像是代理的完美工作。不过,核实信息来源并思考其分析结果仍需时间。另请参阅 D-SUMMARIZE。
关于代理式开发的一点看法
Rust 成功的很大一部分原因在于努力像对待人类一样与人交流。这无意中也让 Rust 在代理式开发中受益,因为许多需求是重叠的。我认为,长期来看,继续专注于人类将是最佳策略,同时假设代理会不断改进。审视代理式开发的需求之所以有价值,是因为它让我们看到人类面临的相同问题,但规模之大足以打破我们对“足够好”的 10% 改进的安于现状。
在我看来,人类和代理重叠的三大支柱是:
信号质量: Rust 适合代理式开发的一个原因是它提供的反馈。我们需要通过避免过度冗长来澄清 Cargo 现有的信号。我们还需要通过 lint、审计点(例如:unsafe)等工具提供更多信号。
依赖管理: 我们还需要考虑用于选择和审计依赖的信号,以及降低拥有依赖的成本。
构建性能: 尽管大量智能体开发工作是在后台完成的,但智能体运行的命令性能仍然至关重要,尤其是当你希望获得更紧密的反馈循环时(例如,bun 在 Rust 移植的第一版中跳过了 cargo check)。操作速度越快,就越能在内循环中使用它们,从而获得更高质量的结果。
迈向这一未来的潜在步骤
虽然这份清单很长,但并非详尽无遗。我写累了。而且,我对其中一些主题的了解不够深入,或者经验不足,不便过多评论。
我经常看到一种观点,认为如今 Rust 中没有什么合并进来的东西值得提升 MSRV。但纵观这份清单,我觉得有很多值得期待的内容。
#[non_exhaustive]
- 可维护性
- 底层命令
- 结构化日志
- 开放命名空间
- 注册表命名空间
- 公开依赖
- 功能包
- 审计
- API 演进
- [功能生命周期](#feature-lifecycle)
- [API 生命周期](#api-lifecycle)
- [源码内联](#source-inlining)
- [审计 API 变更](#auditing-api-changes)
- 性能
- [改进的解耦](#improved-decomposition)
- [消除不必要的重构建](#removing-unnecessary-rebuilds)
- [共享缓存](#shared-caches)
- [构建缓存 GC](#build-cache-gc)
- [细粒度缓存锁定](#fine-grained-cache-locking)
- [替代编译模型](#compilation-model)
- [不透明依赖](#opaque-dependencies)
- [测试结果缓存](#test-caching)
- [测试失败迭代](#test-failures)
- [Cargo 维护](#cargo-maintenance)
- [构建性能](#build-performance)
- [公共依赖](#public-dependencies)
- [依赖管理](#dependency-management)
- 仓库
- 包
- crate
- 模块
为 Cargo 引入 `async`
Cargo 诞生于 `async` / `await` 之前,拥有自己定制的异步框架,并使用 `curl` 作为执行器。 这意味着 Cargo 无法利用现有的异步执行器工具链,代码库对贡献者来说也不遵循熟悉的模式,并且需要大量工作才能扩展代码库以支持并行处理更多任务,例如解析数千个清单文件。 我们还希望从 `curl` 切换到 Rust 原生网络库,但这是阻碍我们前进的少数几个因素之一。 涉及领域:迁移脱离 `libgit2`
在 Cargo 诞生之初,`git` 尚未普及,但 Cargo 团队仍选择将其作为注册表索引的传输机制。`libgit2` 填补了这一空白,并使得自定义进度体验成为可能。同时,为了应对用户遇到的功能缺口,也提供了使用 `git` 的备用方案。 十多年后的今天,`git` 已无处不在,并且大多数 Cargo 注册表已不再使用它。那些有 git 依赖的项目,其开发者大概率也已安装了 `git`。因此,现在是时候将 `git` 备用方案设为默认方案,以提升获取性能和网络兼容性。也许我们最终会完全移除 libgit2 的网络传输层。这将消除对 curl 的另一个依赖,让 Cargo 能够改用 Rust 原生的网络库和常规的异步执行器。
或许有一天我们甚至能彻底移除 libgit2,用 gix 或 git 替代它,从而从 Cargo 的构建中移除又一个 C 语言组件。这也会减少 Cargo 的启动开销。
涉及领域
跟踪问题:#17227
使用 PubGrub 进行解析
Cargo 的依赖解析器存在缺陷、错误信息不佳以及功能缺失的问题。然而,人们缺乏信心去修改或审查相关代码,而且很难知道如何在差异测试套件中更新参考的 SAT 实现。
PubGrub 是一个通用化的依赖解析器。我们可以将 Cargo 的逻辑与算法解耦,使其更容易修改,并消除进行差异测试的需求。它的输出信息更丰富,能提供更好的错误提示。
Astral 已经在 uv 中验证了 PubGrub 的可行性,包括像更改预发布版本的工作方式这类修改的便捷性。
涉及领域
跟踪问题:#5284
生成 Shell 补全
Cargo 提供了 bash 和 zsh 的补全功能,但这些是手动编写的,期望任何 CLI 变更都能同步反映到这些补全中,却没有良好的测试方案。贡献者和审查者不应该被要求是 bash 和 zsh 补全方面的专家,也不需要在他们的系统上安装所有 shell。
Cargo 使用了 clap,它提供了生成的补全功能,但 Cargo 有一些动态生成的补全,而 clap 仅通过一个正在开发中的特性来支持这些。稳定该特性并将 Cargo 的补全切换到新系统,将使它们更易于维护且功能更丰富,因为新系统提供动态补全要容易得多。
涉及领域:
跟踪问题:#14520
底层命令
我们希望将 cargo check 拆解成尽可能小的工作单元,并通过一系列底层命令的管道将其作为 API 暴露出来。这样,调用方就能掌控 Cargo 的行为,无论是修改步骤间的数据,还是直接用自定义实现替换某些步骤。
由于这些操作之间需要清晰的输入/输出接口,我们必然要对 Cargo 代码库做同样的改造。例如,特性解析将成为一个独立的、从输入到输出的纯转换过程。这样一来,贡献者和审查者只需关注有限的代码范围,从而降低贡献门槛,也让审查者更放心地接受改动。
或许有一天,这种重构会促使 Cargo 拆分成更小的库供他人使用。不过这也存在权衡:它会限制 cargo 内部的设计,可能使抽象出 CLI 特定假设变得更加复杂,并将调用方锁定在某个 cargo 版本上。我曾维护过调用 cargo 的第三方工具,用户经常遇到“未知键”警告或“此功能不稳定”错误(尽管在其工具链中已是稳定功能)的困扰。因此,底层命令仍应作为通用扩展的推荐路径。
涉及领域:
项目目标:原型化一套新的 Cargo“底层”命令
结构化日志
cargo check --timings 只能告诉你本次构建耗时多久。同样,cargo check -v 也只能告诉你本次构建中某个东西为何被重新编译。很多时候,你只有在构建进行时才发现问题。有了结构化日志,你可以获取之前构建的报告,从而更好地诊断问题。
我们对结构化日志的需求与面向用户的命令的程序化输出有很大重叠。在迭代结构化日志的过程中,我们也在探索如何改进程序化输出。
涉及领域:
跟踪问题:#15844
开放命名空间
在其他语言中,命名空间是显式且可扩展的。在 Rust 中,它们与模块系统耦合,且不可扩展。
此特性旨在让 Rust 的命名空间部分开放,使得一个包可以表现得像是另一个包中的模块。
以 Clap 为例:
| 现有名称 | 潜在名称 | 说明 |
|---|---|---|
clap | clap | |
clap_derive | clap::derive | |
clap_complete | clap::complete | |
clap_mangen | clap::man | |
clap_lex | clap_lex | 第一方,但属于 clap 的私有依赖 |
clap-cargo | clap-cargo | 第三方,但扩展了 clap 的 API |
这更多是一个影响 API 设计的语言特性。它涉及依赖管理,提供了一种替代方案来组合内聚的 API,而无需像 feature 那样存在缺陷,也无需将所有组件的主版本号耦合在一起,从而允许更小范围的破坏性变更。
如果我们在 crates.io 和 docs.rs 等地方扩展包的用户体验,使它们之间可以导航,那么这能进一步帮助依赖管理,将内聚性延伸到用户体验层面。
涉及领域
跟踪问题:#13576
注册表命名空间
人们可能出于多种原因需要这个功能。在本文档范围内,最相关的是:知道一个包来自可信来源,并找到来自该来源的其他包,例如来自同一公司的不同 API。
涉及领域
参考:Cargo 和 Crates.io 的组织所有权与注册表命名空间设计调查
公开依赖
能够标记某个依赖(及其公开依赖)属于公共 API 的一部分,这感觉像是一个小而渐进的改进。
最基本的作用是,它有助于防止在公共 API 中意外泄露依赖。
最典型的例子是 impl From<dep::Error> for Error。
像 cargo-semver-checks 这样的工具可以在此基础上,通过升级依赖来帮助识别无意的破坏性变更。
犯错是可以理解的,而工具能帮助发现问题。但这个问题从"你的失误"变成"系统性失败"的转折点在于
workspace.dependencies。
修改它们时,很容易忽略它们被使用的地方。
在准备发布一个使用了继承依赖的库时,你无法仅通过查看该库来判断自上次发布以来发生了哪些变化。
在解决这个问题之前,我对于通过 cargo add(#10608)等方式进一步固化 workspace.dependencies 持保留态度,因为我担心这会让人陷入困境。
这也会影响性能。
如果你想在开发时生成文档,最简单的方法是 cargo doc,但它会为所有依赖生成文档,包括像 windows-sys 这样构建缓慢的依赖。
如果你留意的话,可能会发现 cargo doc --no-deps,也许这对你来说已经足够,但理想路径不应该依赖于知识储备。
有了公开依赖,Cargo 知道哪些依赖可以被你的项目访问,从而可以只生成这些依赖的文档,加快文档构建速度。
相关领域
跟踪问题:#44663
包分发
有些包需要同步升级,但目前缺乏相应的工具支持。我们可以通过允许用户指定某个依赖的来源来自另一个依赖来改善这一点,例如: ```toml [package] name = "some-cli" [dependencies] clap = { from = ["clap_complete", "clap_mangen"] } clap_complete = "4.6.0" clap_mangen = "4.6.0" ``` 这种声明方式有点反直觉,可以通过维护者提供"分发"包来改进: ```toml [package] name = "some-cli" [dependencies] clap_distribution = "4.6.0" clap.from = "clap_distribution" clap_complete.from = "clap_distribution" clap_mangen.from = "clap_distribution" ``` 这看起来与 [battery packs](https://battery-pack-rs.github.io/battery-pack/) 类似,或许可以作为将这一概念引入 Cargo 的一种方式。 **依赖项:**模块模板
`cargo new` 目前只能从固定模板生成整个包。 但实际上存在多种模板类型:
$ cargo new --bin --from rust_cli:async_main
$ cargo new --mod --from rust_cli:args
$ cargo new --mod --from rust_cli:watch
$ cargo new --test --from rust_cli:cli_tests
$ fd
Cargo.toml
src/main.rs
src/args.rs
src/watch.rs
tests/cli_tests.rs
注:这只是一个粗略的草图,用于突出想法
你得到的是:
- 包元数据从工作区继承(如果存在的话,目前已有此功能)
- 依赖项从给定的示例中填充
- 从
main示例生成一个可运行的src/main.rs - 需要编辑并添加到
src/main.rs中的额外模块
你仍然需要编辑各个模块,并将它们通过 mod 引入 src/main.rs
相关领域
包来源追溯
xz 后门 事件凸显了(除其他问题外)发布版本与版本控制系统(VCS)不一致的风险。我们需要一个审计点,用于检测以下情况:发布的 .crate 文件没有关联的 git sha、与对应 sha 的源代码不一致,以及该 sha 是一个冒名提交。
相关领域
内置依赖
识别不纯的包(即那些可以访问文件、环境、网络、随机数生成器等资源的包)可以作为另一个审计点。我们可以通过 build-std 项目的努力来实现这一点,该项目致力于将对 core、alloc 和 std 的依赖集成到 Cargo 的 [dependencies] 中。
这一点对于过程宏尤其重要,因为在你审查一个包时,你的环境可能会运行这些宏。这样 Cargo 就能知道运行某个过程宏是否安全,从而决定是否可以缓存其结果——特别是在存在缓存系统的情况下。