本文最后更新于 2025-01-12,本文发布时间距今超过 90 天, 文章内容可能已经过时。最新内容请以官方内容为准

sans - IO:Rust 构建网络服务的有效秘诀

原文链接:sans-IO: The secret to effective Rust for network services

在 Firezone,我们使用 Rust 构建可扩展的安全远程访问功能,无论是从安卓手机、MacOS 电脑还是 Linux 服务器都能便捷使用。每个应用的核心都有一个名为connlib的连接库,它负责管理网络连接和 WireGuard 隧道,确保你的网络流量安全。经过多次迭代,我们最终确定了一种非常满意的设计。这种设计让我们能够进行快速全面的测试,实现深度定制,并且我们完全确信它能按预期运行。

connlib由 Rust 编写,我们所说的设计被称为 sans - IO。Rust 在速度和内存安全性方面的优势,使其成为构建网络服务的绝佳选择。我们的 Rust 技术栈中的大部分组件都并不新奇:我们使用tokio运行时处理异步任务,tungstenite用于 WebSocket 通信,boringtun实现 WireGuard 功能,rustls通过 API 对流量进行加密等等。然而,一旦深入探究这个库的底层,你会发现一些不同寻常的地方:几乎没有对tokio::spawn的调用,所有通信都通过单个 UDP 套接字进行多路复用,并且相同的 API 似乎在各个层次反复出现,比如handle_timeoutpoll_transmithandle_input等等。

这些都是 sans - IO 设计的典型特征。我们的协议并非在多个地方通过套接字收发字节,而是被实现为纯粹的状态机。甚至时间也被抽象化了:每个需要获取当前时间的函数,都会接收一个Instant参数,而不是直接调用Instant::now。这种模式并非我们首创,Python 社区甚至有一个专门介绍它的网站。在 Rust 中,以下这些库也采用了这种模式:

  • quinn,一个独立的 QUIC 实现。
  • quiche,Cloudflare 的 QUIC 实现。
  • str0m,一个 sans - IO 的 WebRTC 实现。

在本文中,我们将探讨传统 IO 方式存在的一些问题,接着介绍如何转变为 sans - IO 设计,以及我们认为这种设计优势显著的原因。事实证明,Rust 非常适合这种设计模式。

Rust 的异步模型与“函数染色”之争

如果你在 Rust 领域浸淫已久,很可能遇到过“函数染色”的争论。简而言之,该争论探讨的是异步函数只能从其他异步函数中调用这一限制,这就像是给函数“染上”了异步的“颜色”。对于这个问题,人们有各种看法,但在我看来,函数能够暂停执行并在之后恢复,这是其 API 契约中相当重要的一部分。Rust 在编译时强制实施这一规则,其实是件好事。

这一限制带来的结果是,调用栈深处的异步函数会“迫使”每个调用它的函数也变为异步函数,以便通过.await等待内部函数执行完成。如果要调用的代码并非你自己编写,而是引入的依赖项,这可能会带来问题。

有些人将此视为一个问题,他们希望编写的代码不依赖于其依赖项是否为异步的。这种担忧是合理的。归根结底,在每个异步调用栈的最底层,都有一个Future需要在某些操作上暂停。通常,这些操作是某种形式的 IO,比如向套接字写入数据、从文件读取内容、等待时间流逝等等。然而,大多数异步函数本身实际上并不执行异步工作,它们之所以是异步的,只是因为依赖其他异步函数。围绕这些内部异步函数的代码,在阻塞式上下文中通常也能正常工作,但依赖项的作者恰好选择了异步版本。

让我们通过一个例子来看看这个问题。Firezone 的连接库connlib使用 ICE 进行 NAT 穿透,在此过程中,我们利用 STUN 来发现服务器反射候选地址,即我们的公共地址。STUN 是一种二进制消息格式,STUN 绑定是一个相当简单的协议:向服务器发送一个 UDP 数据包,服务器记录发送套接字的 IP 和端口,然后发送一个包含该地址的 UDP 数据包作为回应。

以下是我们使用tokioUdpSocket实现这一过程的方式(感谢 Cloudflare 提供的公共 STUN 服务器):

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let socket = UdpSocket::bind("0.0.0.0:0").await?;
    socket.connect("stun.cloudflare.com:3478").await?;
    socket.send(&make_binding_request()).await?;

    let mut buf = vec![0u8; 100];
    let num_read = socket.recv(&mut buf).await?;
    let address = parse_binding_response(&buf[..num_read]);

    println!("Our public IP is: {address}");

    Ok(())
}

同样,这也可以使用标准库中的阻塞式 IO 来编写:

fn main() -> anyhow::Result<()> {
    let socket = UdpSocket::bind("0.0.0.0:0")?;
    socket.connect("stun.cloudflare.com:3478")?;
    socket.send(&make_binding_request())?;

    let mut buf = vec![0u8; 100];
    let num_read = socket.recv(&mut buf)?;
    let address = parse_binding_response(&buf[..num_read]);

    println!("Our public IP is: {address}");

    Ok(())
}

你可以在以下仓库中找到这些代码片段对应的可运行程序:https://github.com/firezone/sans - io - blog - example。

请注意,除了async的使用,这两段代码几乎完全相同。如果我们要编写一个允许执行 STUN 操作的库,就必须选择其中一种方式,或者两种都包含。关于解决这种代码重复的“最佳”方法,有很多不同的观点。编写 sans - IO 代码就是其中之一。

认识 sans - IO

sans - IO 的核心思想与面向对象编程(OOP)中的依赖倒置原则相似。尽管有些 OOP 代码在遵循设计模式方面可能有些极端(比如AbstractSingletonProxyFactoryBean),但我发现明确阐述这些原则,对于深入理解特定设计非常有帮助。

依赖倒置原则指出,策略(做什么)不应依赖于实现细节(怎么做)。相反,组件双方都应依赖抽象并通过抽象进行通信。换句话说,决定在网络上发送消息的代码(即策略)不应依赖于实际发送消息的代码(即实现)。

这正是上述示例中的核心问题:在tokio示例中,我们将策略代码构建在 UDP 套接字之上,这就迫使上层的所有代码都必须是异步的;在std示例中,则要求处理阻塞式 IO。策略代码本身是相同的,但无论我们使用阻塞式还是非阻塞式 IO,这都是我们希望测试,并可能通过库与他人共享的部分。

应用依赖倒置原则

那么,我们该如何应用依赖倒置原则呢?答案是引入抽象!当我们调用UdpSocket::send时,实际上传递了哪些数据呢?是负载、SocketAddr,以及隐式的套接字本身。套接字也可以通过SocketAddr来标识,也就是我们在应用程序中早些时候绑定的地址。让我们将这三个元素封装成一个抽象概念,这就是Transmit

pub struct Transmit {
    dst: SocketAddr,
    payload: Vec<u8>
}

在任何需要通过UdpSocket发送数据的地方,我们都应该生成一个Transmit。但这只是解决方案的一半。Transmit要如何处理呢?我们需要在某个地方执行这个Transmit!这是任何 sans - IO 应用程序的另一半。回顾依赖倒置原则的定义:策略不应依赖于实现,相反,两者都应依赖于抽象。Transmit就是我们的抽象,并且我们已经知道需要重写策略代码以使用它。实际的实现细节,即我们的UdpSocket,也需要了解我们的新抽象。

这就是事件循环发挥作用的地方。sans - IO 代码需要被“驱动”,这与 Rust 中的Future需要由运行时进行轮询以推进执行非常相似。

事件循环是我们副作用的实现,它会实际调用UdpSocket::send。这样一来,其余的代码就变成了一个状态机,只表达在特定时刻应该发生什么。

状态机

我们的 STUN 绑定请求的状态机图如下所示:

一个用于 STUN 绑定请求的 UML 状态图。

stateDiagram-v2
    [*] --> Sent : Send request
    Sent --> Received : Receive response
    note right of Received
      addr: SocketAddr
    end note
    Received --> [*]

由于我们不再直接执行发送消息的副作用,因此需要重写代码,使其与实际的状态机一致。从图中可以看出,我们有两个状态(不包括进入和退出状态):SentReceived。这两个状态是互斥的,因此我们可以将它们建模为一个枚举:

enum State {
    Sent,
    Received { address: SocketAddr },
}

现在,我们已经定义了数据结构,接下来为其添加一些功能!

struct StunBinding {
    state: State,
    buffered_transmits: VecDeque<Transmit>,
}

impl StunBinding {
    fn new(server: SocketAddr) -> Self {
        Self {
            state: State::Sent,
            buffered_transmits: VecDeque::from([Transmit {
                dst: server,
                payload: make_binding_request(),
            }]),
        }
    }

    fn handle_input(&mut self, packet: &[u8]) {
        // 错误处理留给读者作为练习...
        let address = parse_binding_response(packet);

        self.state = State::Received { address };
    }

    fn poll_transmit(&mut self) -> Option<Transmit> {
        self.buffered_transmits.pop_front()
    }

    fn public_address(&self) -> Option<SocketAddr> {
        match self.state {
            State::Sent => None,
            State::Received { address } => Some(address),
        }
    }
}

handle_input函数与Transmit相反。我们将用它把传入的数据,即UdpSocket::recv的结果,提供给我们的状态机。我们还添加了一些辅助函数,用于实际构造状态机的新实例,并从中查询信息。通过这些,我们现在拥有了一个无需执行任何 IO 操作,就能对程序行为进行建模的状态机。

事件循环

没有事件循环,这个状态机将无法运行。对于这个示例,我们可以使用一个相当简单的事件循环:

fn main() -> anyhow::Result<()> {
    let socket = UdpSocket::bind("0.0.0.0:0")?;
    let server = "stun.cloudflare.com:3478"
        .to_socket_addrs()?
        .next()
        .context("Failed to resolve hostname")?;
    let mut binding = StunBinding::new(server);

    let address = loop {
        if let Some(transmit) = binding.poll_transmit() {
            socket.send_to(&transmit.payload, transmit.dst)?;
            continue;
        }

        let mut buf = vec![0u8; 100];
        let num_read = socket.recv(&mut buf)?;

        binding.handle_input(&buf[..num_read]);

        if let Some(address) = binding.public_address() {
            break address;
        }
    };

    println!("Our public IP is: {address}");

    Ok(())
}

请注意,与之前的版本相比,这个事件循环更加通用。事件循环并不假设 STUN 绑定协议的具体细节,例如,它并不知道这是一个请求 - 响应协议!从事件循环的角度来看,在确定我们的公共地址之前,可能需要处理多个消息。

UDP 是一种不可靠的协议,这意味着我们的数据包可能在传输过程中丢失。为了缓解这个问题,STUN 规定了重传定时器。事实证明,在这个事件循环中添加时间相关的功能相当简单。

抽象时间

我们所说的抽象时间是什么意思呢?在大多数情况下,特别是在网络协议中,需要获取当前时间来检查是否已经过了一段时间。例如,自从我们发送请求以来,是否已经超过了 5 秒?另一个常见的例子是心跳消息:自从我们发送上一个心跳消息以来,是否已经超过了 30 秒?

在所有这些情况下,我们实际上并不需要知道当前的实际时间,只需要知道相对于之前某个时间点的Duration。Rust 为我们提供了一个非常方便的抽象:InstantInstant并不暴露当前时间,但它允许我们测量两个Instant之间的Duration。我们可以通过两个足够通用的 API 来扩展我们的状态机,以满足所有与时间相关的需求:poll_timeouthandle_timeout

impl StunBinding {
    // ...

    /// 通知`StunBinding`时间已经推进到`now`。
    fn handle_timeout(&mut self, now: Instant) {}

    /// 返回我们预计下一次调用`handle_timeout`的时间戳。
    fn poll_timeout(&self) -> Option<Instant> {
        None
    }

    // ...
}

handle_inputpoll_timeout类似,这些 API 是我们的协议代码与事件循环之间的抽象:

  • poll_timeout:由事件循环用于安排唤醒定时器。
  • handle_timeout:由事件循环用于通知状态机定时器已过期。

为了演示,假设我们希望在收到上一个绑定请求的响应后,每隔 5 秒发送一个新的绑定请求。以下是实现方法:

impl StunBinding {
    // ...

    /// 通知`StunBinding`时间已经推进到`now`。
    fn handle_timeout(&mut self, now: Instant) {
        let last_received_at = match self.state {
            State::Sent => return,
            State::Received { at, .. } => at,
        };

        if now.duration_since(last_received_at) < Duration::from_secs(5) {
            return;
        }

        self.buffered_transmits.push_front(Transmit {
            dst: self.server,
            payload: make_binding_request(),
        });
        self.state = State::Sent;
    }

    /// 返回我们预计下一次调用`handle_timeout`的时间戳。
    fn poll_timeout(&self) -> Option<Instant> {
        match self.state {
            State::Sent => None,
            State::Received { at, .. } => Some(at + Duration::from_secs(5)),
        }
    }

    // ...
}

我所做的唯一其他更改是在State::Received变体中添加了一个at字段,该字段在handle_input被调用时设置为当前时间:

impl StunBinding {
    fn handle_input(&mut self, packet: &[u8], now: Instant) {
        let address = parse_binding_response(packet);

        self.state = State::Received { address, at: now };
    }
}

这是更新后的状态图:

一个每隔 5 秒刷新一次的 STUN 绑定请求的 UML 状态图。

stateDiagram-v2
    [*] --> Sent: Send request
    Sent --> Received: Receive response
    note right of Received
      addr: SocketAddr
      at: Instant
    end note
    Received --> Sent: Send request After 5s

事件循环也有了一些小的变化。现在我们不再在知道公共 IP 后就退出,而是会一直循环,直到用户退出程序:

loop {
    if let Some(transmit) = binding.poll_transmit() {
        socket.send_to(&transmit.payload, transmit.dst).await?;
        continue;
    }

    let mut buf = vec![0u8; 100];

    tokio::select! {
        Some(time) = &mut timer => {
            binding.handle_timeout(time);
        },
        res = socket.recv(&mut buf) => {
            let num_read = res?;
            binding.handle_input(&buf[..num_read], Instant::now());

        }
    }

    timer.reset_to(binding.poll_timeout());

    if let Some(address) = binding.public_address() {
        println!("Our public IP is: {address}");
    }
}

sans - IO 的优势

到目前为止,对于来回发送几个 UDP 数据包而言,这一切似乎带来了过多的开销。当然,一开始介绍的 10 行代码示例,看起来比这个状态机和事件循环更简洁!但请回想一下关于函数染色的争论。在像上述示例那样没有依赖的代码片段中,使用async似乎是理所当然且非常简单的。然而,一旦引入依赖,问题就出现了。在这些依赖之上构建功能(即策略),会让你受到它们关于异步与阻塞式 IO 决策的影响。像str0mquinn - proto这样采用 sans - IO 方式编写的库则不会如此。相反,它们是纯粹的状态机,因此关于使用异步还是阻塞式 IO,以及使用哪个异步运行时的决策,被推迟到应用程序层面。

能够自由选择阻塞式或非阻塞式 IO 并非其唯一优势。sans - IO 设计还具有良好的组合性,通常拥有非常灵活的 API,易于测试,并且与 Rust 的特性配合得很好。让我们逐一探讨这些额外的优势。

易于组合

再看一下StunBinding的 API。暴露给事件循环的主要函数有:handle_timeouthandle_inputpoll_transmitpoll_timeout。这些函数都并非特定于 STUN 领域!大多数网络协议都可以使用这些函数或其变体来实现。因此,将这些状态机组合在一起非常容易:想要查询 5 个 STUN 服务器以获取你的公共 IP 地址?没问题,只需创建 5 个StunBinding实例,并按顺序调用它们。

在 Firezone 的例子中,snownet库就是一个很好的体现。snownet库结合了 ICE 和 WireGuard,为应用程序的其他部分提供了在任何网络设置下都能工作的“神奇”IP 隧道。snownet构建在str0m(一个 sans - IO 的 WebRTC 库)和boringtun(一个实现 WireGuard 协议的库)之上。由于这些库都是 sans -IO 设计,把它们组合起来就相对简单,我们可以专注于高层次的策略,像是“怎样按顺序调用这些库来建立隧道”,而非被底层实现细节所困扰。

灵活的 API

因为 sans -IO 库把策略和实现分离,其 API 往往极为灵活。拿StunBinding来说,只要遵循既定接口,你就能轻易替换其底层传输机制。想把 UDP 换成 TCP?没问题,只要新的传输实现遵循Transmit抽象就行。在更大型的项目里,这种灵活性非常关键。例如,你可能要为不同平台适配网络库,或是基于性能测试结果切换传输协议,sans -IO 设计让这类调整变得轻松。

易测试性

测试网络代码向来棘手,毕竟搭建真实网络环境既复杂又耗时。不过,sans -IO 库把网络协议逻辑抽象成了纯状态机,这让测试容易许多。对于StunBinding,无需启动 UDP 套接字、连接真实服务器,就能测试其逻辑。只需模拟handle_inputpoll_transmit的输入,验证public_address输出是否正确就行。在 Firezone,我们大量使用这种测试策略,编写测试用例既快速又可靠,极大提升了代码质量。

与 Rust 特性契合

Rust 的设计理念围绕安全性、性能与并发性。sans -IO 设计与这些理念高度契合:

  • 安全性:通过把协议逻辑和易出错的 I/O 操作分离,减少了因复杂 I/O 引发的漏洞风险,让代码审查聚焦于核心逻辑,提升安全性。
  • 性能:状态机本质上很适合优化。在编译期,Rust 能对这类代码执行各种优化,像是内联函数、去除不必要的分支等,提升运行效率。
  • 并发性:虽然 sans -IO 库本身并不强制使用异步或并发,但它与异步运行时配合得很好。例如,在事件循环里,很容易引入 Tokio 的async/await机制,实现高效并发处理,无需重写整个协议逻辑。

缺点

当然,sans -IO 设计并非十全十美,也有一些缺点:

  • 学习曲线:初次接触这种设计,尤其是习惯传统网络编程的开发者,会觉得很不习惯。理解状态机、抽象传输以及事件循环如何协同工作,需要花费一些时间。
  • 样板代码:对比简单直接的网络代码示例,sans -IO 实现往往有更多样板代码。例如,定义状态机枚举、编写事件循环,这些额外工作一开始可能让人望而却步。
  • 生态整合挑战:在 Rust 生态里,并非所有库都采用 sans -IO 设计。整合这类库时,可能要写不少胶水代码,把传统 I/O 风格的库适配进 sans -IO 架构。

总结

尽管有缺点,sans -IO 设计仍为网络编程带来显著提升。在 Firezone,它让我们得以构建出稳健、可测试且高性能的网络库,支撑起整个产品的网络连接需求。如果你正在构建网络相关应用,或是处理复杂网络协议,不妨考虑下这种设计模式,说不定它能帮你解决不少头疼问题。