来,过路人,请坐到我身边来,听老衲讲一讲我对 Rust 过分要求的故事。
想象一下,你打算用Rust创建一个新库。这个库的唯一功能就是封装一个你需要的公共API, 比如 Spotify API或者 ArangoDB 之类的数据库。这并不是造火箭,你也不是在发明什么新东西或者处理复杂的算法,所以你认为这应该相对简单直接。
你决定用异步方式实现这个库。你的库中大部分工作都涉及执行HTTP请求,主要是I/O操作,所以使用异步是有道理的(而且,这也是Rust圈里现在的潮流)。你开始编码,几天后就准备好了v0.1.0版本。当 cargo publish
成功完成并将你的作品上传到 crates.io
时,你暗自得意地想: "不错嘛"。
几天过去了,你在GitHub上收到了一个新通知。有人提了一个问题:
我如何同步使用这个库?
我的项目不使用异步,因为对我的需求来说太复杂了。我想尝试你的新库,但不确定怎么轻松地使用它。我不想在代码中到处使用
block_on(endpoint())
。。我见过像reqwest
这样的 crate导出一个blocking
模块,提供完全相同的功能,你能不能也这么做?
从底层来看,这听起来是个很复杂的任务。为异步代码(需要像 tokio
这样的运行时、awaiting future、pinning等)和普通的同步代码提供一个通用接口?好吧,既然他们提出请求的态度很好,也许我们可以试试。毕竟,代码中唯一的区别就是 async
和 await
关键字的出现,因为你没有做什么花哨的事情。
好吧,这或多或少就是crate 发生的事情 rspotify
,我曾经和它的创建者 Ramsay 一起维护它。对于那些不知道的人来说,它是 Spotify Web API 的一个包装器。对不了解的人来说,这是一个Spotify Web API的封装。说明一下,我最终确实实现了这个功能,尽管不如我希望的那么干净利落;我会在Rspotify系列的这篇新文章中试图解释这个情况。
为了提供更多背景信息,Rspotify 的客户端大致如下:
|
|
本质上,我们需要让 some_endpoint
同时支持异步和阻塞两种使用方式。这里的关键问题是,当你有几十个端点时,你该如何实现这一点?而且,你怎样才能让用户在异步和同步之间轻松切换呢?
这是最初实现的方法。它相当简单,而且确实能用。你只需要把常规的客户端代码复制到 Rspotify 的一个新的 blocking
模块里。reqwest
(我们用的 HTTP 客户端)和 reqwest::blocking
共用一个接口,所以我们可以在新模块里手动删掉 async
或 .await
这样的关键字,然后把 reqwest
的导入改成 reqwest::blocking
。
这样一来,Rspotify 的用户只需要用 rspotify::blocking::Client
替代 rspotify::Client
,瞧!他们的代码就变成阻塞式的了。这会让只用异步的用户的二进制文件变大,所以我们可以把它放在一个叫 blocking
的特性开关后面,大功告成。
不过,问题后来就变得明显了。整个 crate 的一半代码都被复制了一遍。添加或修改一个端点就意味着要写两遍或删两遍所有东西。
除非你把所有东西都测试一遍,否则没法确保两种实现是等效的。这主意倒也不坏,但说不定你连测试都复制粘贴错了呢!那可怎么办?可怜的代码审查员得把同样的代码读两遍,确保两边都没问题 —— 这听起来简直就是人为错误的温床。
根据我们的经验,这确实大大拖慢了 Rspotify 的开发进度,尤其是对于不习惯这种折腾的新贡献者来说。作为 Rspotify 的一个新晋且热情的维护者,我开始研究其他可能的解决方案。
block_on
第二种方法是把所有东西都在异步那边实现。然后,你只需为阻塞接口做个包装,在内部调用 block_on
。block_on
会运行 future 直到完成,本质上就是把它变成同步的。你仍然需要复制方法的定义,但实现只需写一次:
|
|
请注意,为了调用block_on
,您首先必须在端点方法中创建某种运行时。例如,使用tokio
:
|
|
这就引出了一个问题:我们是应该在每次调用端点时都初始化运行时,还是有办法共享它呢?我们可以把它保存为一个全局变量(呃,真恶心),或者更好的方法是,我们可以把运行时保存在 Spotify
结构体中。但是由于它需要对运行时的可变引用,你就得用 Arc<Mutex<T>>
把它包起来,这样一来就完全扼杀了客户端的并发性。正确的做法是使用 Tokio 的 Handle
,大概是这样的:
|
|
虽然使用 handle 确实让我们的阻塞客户端更快了^1,但还有一种性能更高的方法。如果你感兴趣的话,这正是 reqwest
自己采用的方法。简单来说,它会生成一个线程,这个线程调用 block_on
来等待一个装有任务的通道 [^2] (https://nullderef.com/blog/rust-async-sync/#block-on-channels) [^3] (https://nullderef.com/blog/rust-async-sync/#block-on-reqwest)。
不幸的是,这个解决方案仍然有相当大的开销。你需要引入像 futures
或 tokio
这样的大型依赖,并将它们包含在你的二进制文件中。所有这些,就是为了...最后还是写出阻塞代码。所以这不仅在运行时有成本,在编译时也是如此。这在我看来就是不对劲。
而且你仍然有不少重复代码,即使只是定义,积少成多也是个问题。reqwest
是一个巨大的项目,可能负担得起他们的 blocking
模块的开销。但对于像 rspotify
这样不那么流行的 crate 来说,这就难以实现了。
另一种可能的解决方法是,正如 features 文档所建议的那样,创建独立的 crate。我们可以有 rspotify-sync
和 rspotify-async
,用户可以根据需要选择其中一个作为依赖,甚至如果需要的话可以两个都用。问题是 —— 又来了 —— 我们究竟该如何生成这两个版本的 crate 呢?即使使用 Cargo 的一些技巧,比如为每个 crate 准备一个 Cargo.toml
文件(这种方法本身就很不方便),我也无法在不复制粘贴整个 crate 的情况下做到这一点。
采用这种方法,我们甚至无法使用过程宏,因为你不能在宏中凭空创建一个新的 crate。我们可以定义一种文件格式来编写 Rust 代码的模板,以便替换代码中的某些部分,比如 async
/.await
。但这听起来完全超出了我们的范畴。
maybe_async
crate第三次尝试基于一个名为 maybe_async
的 crate。我记得当初发现它时,天真地以为这就是完美的解决方案。
总之,这个 crate 的思路是,你可以用一个过程宏自动移除代码中的 async
和 .await
,本质上就是把复制粘贴的方法自动化了。举个例子:
|
|
生成以下代码:
|
|
你可以通过在编译 crate 时切换 maybe_async/is_sync
特性来配置是要异步还是阻塞代码。这个宏适用于函数、trait 和 impl
块。如果某个转换不像简单地移除 async
和 .await
那么容易,你可以用 async_impl
和 sync_impl
过程宏来指定自定义实现。它处理得非常好,我们在 Rspotify 中已经使用它一段时间了。
事实上,它效果如此之好,以至于我让 Rspotify 变成了HTTP 客户端无关的,这比异步/同步无关更加灵活。这使我们能够支持多种 HTTP 客户端,比如 reqwest
和 ureq
,而不用管客户端是异步的还是同步的。
如果你有 maybe_async
,实现HTTP 客户端无关并不是很难。你只需要为 HTTP 客户端定义一个 trait,然后为你想支持的每个客户端实现它:
一段代码胜过千言万语。(你可以在这里找到 Rspotify 的 reqwest
客户端的完整源代码, ureq
也可以在这里找到 )
|
|
然后,我们可以进一步扩展,让用户通过在他们的 Cargo.toml
中设置特性标志来选择他们想要使用的客户端。比如,如果启用了 client-ureq
,由于 ureq
是同步的,它就会启用 maybe_async/is_sync
。这样一来,就会移除 async
/.await
和 #[async_impl]
块,Rspotify 客户端内部就会使用 ureq
的实现。
这个解决方案避免了我之前提到的所有缺点:
ureq
,这样就不会引入 tokio
及其相关依赖Cargo.toml
中配置一个标志不过,先停下来想几分钟,试试看你能不能找出为什么不应该这么做。实际上,我给你9个月时间,这就是我花了多长时间才意识到问题所在...
嗯,问题在于 Rust 中的特性必须是叠加的:"启用一个特性不应该禁用功能,而且通常应该可以安全地启用任意组合的特性"。当依赖树中出现重复的 crate 时,Cargo 可能会合并该 crate 的特性,以避免多次编译同一个 crate。如果您想了解更多详细信息,参考资料对此进行了很好的解释。
这种优化意味着互斥的特性可能会破坏依赖树。在我们的情况下,maybe_async/is_sync
是一个由 client-ureq
启用的 切换特性。所以如果你试图同时启用 client-reqwest
来编译,它就会失败,因为 maybe_async
将被配置为生成同步函数签名。不可能有一个 crate 直接或间接地同时依赖于同步和异步的 Rspotify,而且根据 Cargo 参考文档,maybe_async
的整个概念目前是错误的。
一个常见的误解是,这个问题可以通过"特性解析器v2"来修复,参考文档也对此进行了很好的解释。从2021版本开始,这个新版本已经默认启用了,但你也可以在之前的版本的 Cargo.toml
中指定使用它。这个新版本除了其他改进,还在一些特殊情况下避免了特性的统一,但不包括我们的情况:
为了以防万一,我自己尝试复现了这个问题,结果确实如我所料。这个代码库是一个特性冲突的例子,在任何特性解析器下都会出错。
有一些 crate也存在这个问题:
arangors
和 aragog
:ArangoDB 的包装器。两者都用于 maybe_async
在异步和同步之间切换(arangors
事实上,的作者是同一个人)^5 [^6] (https://nullderef.com/blog/rust-async-sync/#aragog-error)。
k8s-openapi
:Kubernetes 的包装器,与 inkwell
^8存在同样的问题。
maybe_async
随着这个 crate 开始变得流行起来,有人在 maybe_async
中提出了这个问题,解释了情况并展示了一个修复方案:
async 和 sync 在同一程序中 fMeow/maybe-async-rs #6
maybe_async
现在会有两个特性标志:is_sync
和 is_async
。这个 crate 会以同样的方式生成函数,但会在标识符后面添加 _sync
或 _async
后缀,这样就不会冲突了。例如:
|
|
现在将生成以下代码:
|
|
然而,这些后缀会引入噪音,所以我在想是否有可能以更符合人体工程学的方式来实现。我fork了maybe_async
并尝试了一下,你可以在这一系列评论中读到更多相关内容。总的来说,这太复杂了,我最终放弃了。
修复这个边缘情况的唯一方法就是让Rspotify对所有人的可用性变差。但我认为,同时依赖异步和同步版本的人可能很少;实际上我们还没有收到任何人的抱怨。与reqwest
不同,rspotify
是一个"高级"库,所以很难想象它会在一个依赖树中出现多次。
也许我们可以向Cargo的开发者寻求帮助?
虽然不是官方的,但 Rust 中可以进一步探索的另一种有趣方法是“Sans I/O”。这是一个 Python 协议,它抽象了网络协议(如 HTTP)的使用,从而最大限度地提高了可重用性。Rust 中现有的一个示例是 tame-oidc
。
Rspotify 远不是第一个遇到这个问题的项目,所以阅读之前的相关讨论可能会很有趣:
oneof
配置谓词(类似 #[cfg(any(…))]
)来支持互斥特性。这只是让在别无选择的情况下拥有冲突特性变得更容易,但特性仍应该是严格叠加的。cargo test --all-features
将涵盖所有情况。但如果不是,用户就必须用多个特性标志组合运行命令,这相当麻烦。非官方的 cargo-hack
已经可以实现这一点。根据这条旧评论,这不是 Rust 团队已经否决的东西;它仍在讨论中。
虽然是非官方的,但另一个可以在 Rust 中进一步探索的另一种有趣方法是 “Sans I/O”。这是一种 Python 协议,它在我们的案例中抽象了 HTTP 等网络协议的使用,从而最大化了可重用性。Rust 中现有的一个例子是 tame-oidc
。
我们目前面临以下选择:
maybe_async
并为我们库中的每个端点添加 _async
和 _sync
后缀。ncspot
或 spotifyd
是阻塞的,而其他如 spotify-tui
使用异步,所以我不确定他们会怎么想。我知道这是我给自己强加的问题。我们可以直接说"不。我们只支持异步"或"不。我们只支持同步"。虽然有用户对能够使用两者感兴趣,但有时你就是得说不。如果这样一个特性变得如此复杂,以至于你的整个代码库变成一团糟,而你没有足够的工程能力来维护它,那这就是你唯一的选择。如果有人真的很在意,他们可以直接 fork 这个 crate 并将其转换为同步版本供自己使用。
毕竟,大多数 API 封装库等只支持异步或阻塞代码中的一种。例如,serenity
(Discord API)、sqlx
(SQL 工具包)和 teloxide
(Telegram API)是仅异步的,而且它们非常流行。。
尽管有时候很沮丧,但我并不后悔花了这么多时间兜圈子试图让异步和同步都能工作。我最初为 Rspotify 做贡献就是为了_学习。我没有截止日期,也没有压力,我只是想在空闲时间尝试改进 Rust 中的一个库。而且我确实学到了_很多;希望在读完这篇文章后,你也是如此。
也许今天的教训是,我们应该记住 Rust 毕竟是一种低级语言,有些事情如果不引入大量复杂性是不可能实现的。无论如何,我期待 Rust 团队将来如何解决这个问题。
那么你怎么看?如果你是 Rspotify 的维护者,你会怎么做?如果你愿意,可以在下面留言。