如何处理 Rust 依赖项
我很抱歉成为第一个告诉你这个消息的人,但 Rust 项目往往有很多依赖项。免责声明:今天,我将介绍一些公开可用的 Rust crates。请不要骚扰他们的作者、维护者或用户。我不是在开玩笑。让我们来看看 ripgrep
,这是有史以来最受欢迎的 Rust 程序之一。我们可以通过克隆项目,然后通过一个令人头疼的 sed
调用运行 cargo tree
,非常轻松地检查依赖项的数量。
$ git clone https://github.com/BurntSushi/ripgrep
$ cargo tree -e no-dev --prefix none |
sed -e 's/(*)//g' -e 's/^[ t]*//;s/[ t]*$//' |
sort -u | wc -l
33
让我们来分解一下 shell 命令的每个步骤:
cargo tree
以“树”格式打印当前 Cargo 包的依赖项,让你可以看到哪些依赖项是由其他依赖项引入的。-e no-dev
跳过开发依赖项,这些依赖项仅用于测试。默认情况下,cargo
使用树格式,我们可以使用选项--prefix none
跳过它。sed
是一个简单的核心工具,允许你在命令输出中的每一行上运行正则表达式。每个表达式由-e
表示。- 第一个表达式
s/(*)//g
是一个简单的替换操作,表示“将每个(*)
替换为空字符串,全局替换”。cargo tree
会将此操作应用于重复的依赖项,而我希望移除此操作。
- 第一个表达式
- 第二个表达式
s/^[ t]*//;s/[ t]*$//
用于删除每行开头和结尾的空白字符。此操作仅用于清理输出,确保后续唯一性测试不会受到影响。 sort -u
用于对每行进行排序并删除重复行。wc -l
打印总行数。
对于一个高度先进的文本匹配程序来说,33 个依赖项并不是那么糟糕。大多数依赖项都是像 Rust
regex
引擎或 高度优化的文本匹配库 这样的东西。坦白说,我能找到这些依赖项的合理理由。不过,这毕竟是一个小型命令行工具,所以我也不指望它会有成千上万个依赖项。干得不错!那么,让我们尝试一些更复杂的东西吧。具体来说,让我们尝试一个网络应用程序,因为这些应用程序以拥有不必要的依赖项而著称。我将从 awesome list 中挑选一个……啊,miniserve
。它应该是一个相对较小的应用程序,对吗?那么,让我们来检查一下依赖项的数量。
$ git clone https://github.com/svenstaro/miniserve
$ cargo tree --no-default-features -e no-dev --prefix none |
sed -e 's/(*)//g' -e 's/^[ t]*//;s/[ t]*$//' |
sort -u | wc -l
281
我特意添加了 --no-default-features
来删除不必要的依赖项,但我们仍然有 281 个依赖项。数量相当多!查看依赖项列表,我们可以看到:
- 一个 QR 码生成器。
rand
crate,包括 v0.8.5 版本和 v0.9.1 版本,以及fastrand
。actix-web
,它引入了所有tokio
和各种用于处理互联网边缘情况的 crates。base64
、hashbrown
、syn
和zerocopy
的进一步重复。
我可以为其中许多依赖项辩护。互联网是一个复杂的地方,任何需要面对公共网络并响应 99% 的网络客户端的系统都必须处理这些问题。然而,这意味着维护者需要审核 281 个代码片段。想象一下,如果其中一个依赖项被入侵,或者 simply 变得无人维护。更不用说这些代码片段有两份副本被编译到每个 crates 中。我并不是来这里盯着依赖关系图看的。任何人都可以做到这一点。我想确定问题的范围,看看我们能否找到一些解决方案。
依赖关系拖累
需要明确的是,这个问题并不是随随便便就能抱怨的。我曾经历过这个问题双方的情况。我参加过安全审计,在审计中,“引入的代码量”是将 Rust 集成到项目中的一个明显缺点。我也曾是库的作者,降低依赖项数量是一个大问题。我还想明确的是,这不是 Rust 独特的问题。自 JavaScript 和 Python 引入包管理器以来,人们就一直在抱怨多余的包。JavaScript 以“leftpad”事件闻名,甚至简单的代码片段也需要两百个依赖项。即使是 C++,一旦引入 Boost,也会遇到依赖问题。在我看来,所有这些语言中的依赖项可以分为两类。
- 完成你不想自己做的事情的依赖项。你不想实现一个 HTTP 服务器,所以你引入了
axum
。
作为某些系统设施或硬件设备的规范接口的依赖项。想想用于进行系统调用的 C 库1,或者用于通过 GPU 将内容显示在屏幕上的 OpenGL 库。
需要明确的是,引入第一类依赖项有非常充分的理由。对于密码学或网络通信等领域,自行编写底层操作无异于自寻死路,直奔安全漏洞的深渊。即使对于不太关键的操作,通常也应优先使用经过验证的成熟库,而非自行编写。这里还有一点需要说明的是代码重用;在 Rust 社区中,没有理由对某些算法进行多次实现,从而需要专家们分头工作。为什么让一组人审查一段代码,而另一组人审查另一段几乎相同的代码,而让两组人只审查一个 crate 不是更好呢?这个想法是创建 x11rb-protocol
crate 的核心原因。话虽如此,有些事情太微不足道了,为它们使用单独的 crates 显然是过犹不及。我不得不处理一些依赖于 regex
和 nom
等重量级工具的 crates,这些工具用于字节处理操作,而这些操作实际上可以用基本的切片来实现。这个问题最严重的罪魁祸首是 scopeguard
。几乎没有使用场景能证明这个 crate 比几行简单的 Rust 代码更经济。以下是一个快速的多重填充:
// Before:
scopeguard::defer! {
do_the_thing();
}
// After:
struct CallOnDrop<F: FnMut()>(F);
impl<F: FnMut()> Drop for CallOnDrop<F> {
fn drop() {
(self.0)();
}
}
let _bomb = CallOnDrop(|| do_the_thing());
安全分拆
回到我之前提到的观点。这两种类型的依赖关系几乎存在于所有语言中。我认为 Rust 还有第三种类型:
- “安全隔离” crates,将一些不安全功能包裹在安全包装器中。
还有更简单的,比如 bytemuck
,它只是将简单的数据转换包装起来,而标准库尚未暴露这些数据转换。还有 C 库包装器,比如 zstd
,它将 C 库包装起来,使其变得安全。我在这里主要谈的是 bytemuck
类型的包装器。当有效使用时,这种“安全隔离”crates 可以将不安全的代码隔离到依赖图的特定部分,从而确保其余部分的安全。如果依赖树中的其他 crates 使用 #![forbid(unsafe_code)]
,您可以确信任何行为问题只会来自那些“隔离”的不安全代码 crates。在 smol
的依赖树中,我们使用这种策略取得了很好的效果。如果没有一定程度的不安全代码,就无法创建高性能的执行器。因此,我们将所有不安全的代码隔离到 async-task
中,这样 async-executor
几乎就不存在不安全的代码了。同样,高效的无锁通道在某种程度上也需要使用不安全代码来实现。因此,我们将所有不安全代码隔离到 concurrent-queue
中,以便 async-channel
crate 可以使用 #![forbid(unsafe_code)]
。虽然 smol
可能有相当多的依赖项,但其中许多依赖项的存在完全消除了不安全代码。我认为,如果更多的 crates 都能做到这一点,那就更好了。eframe
是市场上比较流行的 GUI crates 之一,它有大约 120 个依赖项。如果这些依赖项中更多的是用完全安全的代码编写的,那会更令人满意吧。
该怎么办?
尽管如此,许多 crates 最终还是会包含一些不安全的代码,尽管这是有道理的。更不用说,安全的代码仍然需要审计。因此,虽然一些微型 crates 各自都能很好地完成某项任务,但我们仍然应该寻找一种方法来减少依赖树中的依赖项数量。对于应用程序和库开发人员来说,我们可以采取的第一项措施显而易见。我们可以尽量减少功能,这通常可以减少依赖项。您可以使用 --no-default-features
标志运行 cargo add
,如下所示:
$ cargo add nom --no-default-features
在我自己的应用程序中,我通常先添加没有默认功能的依赖项,然后在需要时逐一添加功能。因此,我通常可以将依赖树保持在最低限度。如果这不起作用,不要害怕切换到另一个并行的 crate。对于许多依赖性较强的 crates,通常会有一个依赖性较少的替代 crates,同时保留你所依赖的核心功能。例如,与其使用 futures
,不如考虑使用 futures-lite
。除了 actix-web
之外,也许可以看看 axum
是否能满足你的用例。我发现,通过这两种策略,你可以最大限度地减少 crate 或应用程序的依赖性。通常情况下,可以将其降低到更易于管理的水平。
本网站的源代码托管于Codeberg以上所有观点均属个人意见,不代表我过去、现在或未来的任何雇主。