
sans - IO:Rust 构建网络服务的有效秘诀
本文最后更新于 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_timeout
、poll_transmit
、handle_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 数据包作为回应。
以下是我们使用tokio
的UdpSocket
实现这一过程的方式(感谢 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 --> [*]
由于我们不再直接执行发送消息的副作用,因此需要重写代码,使其与实际的状态机一致。从图中可以看出,我们有两个状态(不包括进入和退出状态):Sent
和Received
。这两个状态是互斥的,因此我们可以将它们建模为一个枚举:
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 为我们提供了一个非常方便的抽象:Instant
。Instant
并不暴露当前时间,但它允许我们测量两个Instant
之间的Duration
。我们可以通过两个足够通用的 API 来扩展我们的状态机,以满足所有与时间相关的需求:poll_timeout
和handle_timeout
:
impl StunBinding {
// ...
/// 通知`StunBinding`时间已经推进到`now`。
fn handle_timeout(&mut self, now: Instant) {}
/// 返回我们预计下一次调用`handle_timeout`的时间戳。
fn poll_timeout(&self) -> Option<Instant> {
None
}
// ...
}
与handle_input
和poll_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 决策的影响。像str0m
或quinn - proto
这样采用 sans - IO 方式编写的库则不会如此。相反,它们是纯粹的状态机,因此关于使用异步还是阻塞式 IO,以及使用哪个异步运行时的决策,被推迟到应用程序层面。
能够自由选择阻塞式或非阻塞式 IO 并非其唯一优势。sans - IO 设计还具有良好的组合性,通常拥有非常灵活的 API,易于测试,并且与 Rust 的特性配合得很好。让我们逐一探讨这些额外的优势。
易于组合
再看一下StunBinding
的 API。暴露给事件循环的主要函数有:handle_timeout
、handle_input
、poll_transmit
和poll_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_input
和poll_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,它让我们得以构建出稳健、可测试且高性能的网络库,支撑起整个产品的网络连接需求。如果你正在构建网络相关应用,或是处理复杂网络协议,不妨考虑下这种设计模式,说不定它能帮你解决不少头疼问题。