避免在Rust库中重复编写同步和异步代码。

3

最近我发现了一个提供同步和异步接口的库。可以通过 async 特性标志启用异步,而异步/同步函数则通过编译器指令进行区分。

例如,这是一个同步函数的样子:

#[cfg(not(feature = "async"))]
fn perform_query<A: ToSocketAddrs>(&self, payload: &[u8], addr: A) -> Result<Vec<u8>>
{
    // More than 100 lines of code with occasional calls to sync UdpSocket::send_to and recv.
}

这就是一个异步函数的样子:

#[cfg(feature = "async")]
async fn perform_query<A: ToSocketAddrs>(&self, payload: &[u8], addr: A) -> Result<Vec<u8>>
{
    // More than 100 lines of code with occasional calls to async UdpSocket::send_to and recv.
    // Apart from 3-4 await lines, it does mostly the same thing as its sync counterpart.
}

我在同步代码中发现并修复了一些错误,现在我准备将修复方案实施到异步代码中。但是我注意到由于这个大函数完全重复,我需要将我的修复补丁应用到异步函数中,然后我开始思考,为什么这个函数的大部分内容首先被复制呢?长期来看,维护这段代码似乎很困难,所以我想通过去重这个函数来帮忙... 然后我遇到了问题,让我意识到这并不像我想象的那么简单。我可以使用编译器指令区分这些行,并且我甚至可以编写一个宏,根据是否启用“async”功能来插入“UdpSocket”调用的同步/异步版本。但是我意识到我无法通过编译器指令选择函数头,因为“#[cfg...]”将应用于整个函数,所以如果我做这样的事情,我会得到大量的语法错误:
#[cfg(not(feature = "async"))]
fn perform_query<A: ToSocketAddrs>(&self, payload: &[u8], addr: A) -> Result<Vec<u8>>
#[cfg(feature = "async")]
async fn perform_query<A: ToSocketAddrs>(&self, payload: &[u8], addr: A) -> Result<Vec<u8>>
{
    // Deduplicated code with occasional differentiation of sync / async UdpSocket calls.
}

我也考虑过只使用核心的异步函数,然后使用异步和同步包装器函数来调用它,无论库是编译为同步还是异步,但是我不能从同步函数中调用异步函数,或者至少需要使用异步运行时进行一些丑陋的魔法来await/poll函数,然后将结果作为同步传递,但是库的同步构建也必须导入异步运行时,最好避免这种情况。

我的当前想法是将数据包的处理移动到单独的同步函数中,这些函数将从同步和异步包装器中调用,这些函数仅处理实际的UdpSocket调用,但我不确定这是否是正确的方法。我的意思是,难道没有更平滑、更优雅的方法吗?对于这个问题,一般的方法是什么?或者复制庞大的函数以供同步和异步构建使用是正常的吗?正如你所猜测的那样,我没有异步编程的经验。


4
附注:这不是控制异步和同步功能的好方法。由于它们在其依赖项之间合并,因此功能应该是可添加的。因此,如果您想支持两者(即perform_queryperform_query_async),通常应具有单独命名的函数。 - kmdreko
4
将函数的异步性条件化是 Rust 团队当前的关注点;请参阅 Rust 内部博客:关键字泛型进展报告,以了解这对未来可能意味着什么。 - kmdreko
5
@kmdreko 更好的是,使用一个完全不同的并行命名空间。mylib::sync::xmylib::async::x。这样你就可以导入你想要的那个,避免一直添加 _async 的麻烦。 - tadman
1
你可能会对maybe_async crate感兴趣。 - cdhowie
1个回答

5
我也考虑过只有核心的异步函数,然后再使用异步和同步包装器函数来调用它...这样库的同步构建也必须导入异步运行时...
这就是reqwest提供其阻塞接口的方式。如果库足够大,异步运行时不会增加太多编译成本,那么我认为这是一个完美的做法。它的优点是在所有情况下,您的IO都以完全相同的方式工作,从而降低了出现微妙错误的机会。
我的当前想法是将数据包的处理移动到单独的同步函数中,这些函数将从同步和异步包装器中调用,这些函数仅处理实际的UdpSocket调用。
我建议您选择这个选项——将算法与IO分离。它具有超出您目前正在追求的代码去重的优点:
  • 如果您可以将数据包算法表达为简单的函数调用,特别是处理IO边缘情况的函数调用,那么编写单元测试可能会更容易,而不必设置对等UDP套接字来测试任何内容。

  • 如果您将算法公开,它们就可以在不寻常的情况下使用,例如与操作系统的网络堆栈不交互的情况:

    • no_std环境中,其中网络是自定义的,不为Rust std或异步IO库所知
    • 流量捕获分析(非实时)
    • 重新实现特殊要求的IO侧(例如向操作系统传递特定标志),同时仍然能够使用库的算法
  • 错误处理是IO的必要部分,如果它与算法相互交织在一起,可能会更加清晰,因此需要进行拆分。

这种库设计风格有时被称为“sans I/O”(至少由Python程序员如此称呼)。例如,在Rust中,您可以看到这种风格的设计,比如http库提供HTTP解析算法,但完全没有IO。


谢谢,我没有找到如此完整的答案来解决这个问题,因此我不得不提交我的问题。这是一个非常小的库,所以使用自己的异步运行时编译它并不值得。但是将算法与IO分离的观点是合理的,并且基本上可以解决我的重复问题。 - MegaBrutal

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接