跳转到内容

Rust 网络编程:TCP 服务器与 HTTP 客户端

你用的几乎每个程序都在和另一台计算机通信:聊天软件、游戏、天气小组件。它们在底层做的事情都一样:打开一个连接,发送一些字节,再读取回应。Rust 很擅长这件事:速度快,占用内存少,而且编译器能拦住很多会让网络代码在凌晨 3 点崩溃的 bug。

在这一页,你会写一个小小的 TCP 服务器和客户端,让服务器同时服务多个客户端(先用线程,再用异步的 tokio),然后用 reqwest 调用真实的 HTTP API。TCP 示例可以直接在 Rust Playground 里运行。reqwest 示例需要联网,所以要在你自己的计算机上用 cargo run 运行。每个示例我们都实际编译运行过,展示的输出都是真实的。

  • 网络服务和代理:Cloudflare 用 Rust 编写了 Pingora(英文),这是它开源的代理框架。服务网格 Linkerd(英文)用的也是 Rust 编写的代理。
  • Web API 和后端,用基于 tokio 的 axum 来写。见项目:构建 Web API。
  • API 客户端和工具:调用 GitHub 的命令行工具、聊天机器人、检查网站是否在线的脚本。
  • 自定义协议:游戏服务器、聊天服务器、通过 UDP 发送传感器读数的设备。

也有比较难的地方:异步 Rust 的学习曲线确实不平缓,而且第一次编译网络相关的 crate 要花一两分钟。如果只是写一个调用某个 API 的 10 行脚本,Python 可能写得更快。当程序必须跑得快、连续运行好几个月,或者要处理大量连接时,Rust 的优势才真正体现出来。

crate 用途 风格 适合的场景
std::net TCP 和 UDP 套接字,Rust 自带 阻塞 学习、小工具、连接数不多。没有任何依赖。
tokio 异步运行时,带异步 TCP、UDP 和定时器 异步 同时有大量连接。大多数网络 crate 都基于它。
reqwest 好用的 HTTP 客户端(JSON、超时、TLS) 异步(可选阻塞) 调用 Web API。通常的首选。
ureq 小巧简单的 HTTP 客户端 阻塞 完全不想用异步或 tokio。
hyper reqwest 和 axum 底层的 HTTP 引擎 异步 要自己写框架,或者需要完全掌控细节。
axum 用来写 HTTP 服务器的 Web 框架 异步 编写 Web API。见 axum 项目。

这些 crate 都很成熟,用的人也很多。比较好的学习路线:先用 std::net 理解基本概念,再转向 tokio、reqwest 和 axum。

一个程序里的 TCP 服务器和客户端

Section titled “一个程序里的 TCP 服务器和客户端”

TCP 在两个程序之间建立一条字节流(stream),就像打电话一样。服务器在某个地址和端口上监听(listen),客户端连接(connect)上去,之后双方都可以读和写。

通常服务器和客户端是两个程序。这里我们把它们放进同一个程序,好让它能在 Playground 里运行:服务器跑在一个线程里,main 则是客户端。

use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn main() -> std::io::Result<()> {
// 端口 0 表示“随便一个空闲端口”。然后再问系统分到了哪个。
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
// 服务器:接受一个客户端,读一行,回一行。
let server = thread::spawn(move || -> std::io::Result<()> {
let (stream, _) = listener.accept()?;
let mut name = String::new();
BufReader::new(&stream).read_line(&mut name)?;
writeln!(&stream, "你好,{}!", name.trim())?;
Ok(())
});
// 客户端:连接,发送一行,读取回应。
let stream = TcpStream::connect(addr)?;
writeln!(&stream, "Ferris")?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply)?;
print!("客户端收到:{reply}");
server.join().unwrap()?;
println!("服务器已结束");
Ok(())
}

输出:

客户端收到:你好,Ferris!
服务器已结束
▶ 动手试一试

有几点值得注意:

  • 127.0.0.1 的意思是“只限本机”。如果要让其他机器访问你的服务器,用 0.0.0.0。
  • TCP 不知道一条消息在哪里结束。我们用一个简单的规则:一条消息 = 一行。writeln! 写入流的方式,和 println! 写到屏幕上一样:它会在末尾加上 \n。read_line 则一直读到这个换行符为止。
  • 每个网络调用都可能失败,所以它们都返回 Result,我们用 ? 来处理。
  • &stream 既能读也能写,因为 &TcpStream 同时实现了 Read 和 Write。

真正的服务器不能让 2 号客户端一直等到 1 号客户端结束。经典的做法是:每个连接一个线程。循环接受一个客户端,把它交给一个新线程,然后马上回到 accept。

这个服务器会统计收到的每一行有多少个单词。三个客户端同时连接上来:

use std::io::{BufRead, BufReader, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::thread;
// 服务器端:对收到的每一行,回复它的单词数。
fn handle_client(stream: TcpStream) -> std::io::Result<()> {
for line in BufReader::new(&stream).lines() {
let words = line?.split_whitespace().count();
writeln!(&stream, "{words} words")?;
}
Ok(()) // 客户端关闭了连接
}
// 客户端:发送一行,返回收到的回应。
fn ask(addr: SocketAddr, msg: &str) -> std::io::Result<String> {
let stream = TcpStream::connect(addr)?;
writeln!(&stream, "{msg}")?;
let mut reply = String::new();
BufReader::new(&stream).read_line(&mut reply)?;
Ok(format!("{msg:?} -> {}", reply.trim()))
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
thread::spawn(move || {
for stream in listener.incoming() {
let stream = stream.unwrap();
thread::spawn(move || handle_client(stream)); // 每个客户端一个线程
}
});
// 三个客户端同时运行,各自在自己的线程里。
let clients = ["hello there", "rust is fun to learn", "see you soon"]
.map(|msg| thread::spawn(move || ask(addr, msg)));
// 按顺序等待它们,这样输出的顺序永远不变。
for c in clients {
println!("{}", c.join().unwrap()?);
}
Ok(())
}

输出:

"hello there" -> 2 words
"rust is fun to learn" -> 5 words
"see you soon" -> 3 words
▶ 动手试一试

这里的消息保留了英文,因为 split_whitespace() 是按空格切分单词的,而中文句子的词语之间没有空格。

客户端丢弃它的 TcpStream 时,连接就关闭了,lines() 随之结束,这个客户端的线程也就执行完了。接受连接的循环永远不会结束,这对服务器来说很正常。在这个例子里,main 一返回,程序就结束了。这种模式很简单,服务几十、几百个客户端都没问题。但如果有几千个,这么多线程会占用大量内存。这时就该异步出场了。

用 tokio 时,每个客户端是一个任务(task),而不是一个线程。任务非常轻量,所以一个服务器可以同时容纳成千上万个连接。和上面的线程版本对比一下:std::net 换成了 tokio::net,thread::spawn 换成了 tokio::spawn,凡是程序要等待网络的地方,都加上 .await。

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
// 同样是统计单词数的服务器,但每个客户端是一个轻量的任务,而不是线程。
async fn handle_client(stream: TcpStream) -> std::io::Result<()> {
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
while let Some(line) = lines.next_line().await? {
let words = line.split_whitespace().count();
write_half.write_all(format!("{words} words\n").as_bytes()).await?;
}
Ok(())
}
async fn ask(addr: std::net::SocketAddr, msg: &str) -> std::io::Result<String> {
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(format!("{msg}\n").as_bytes()).await?;
let mut reply = String::new();
BufReader::new(stream).read_line(&mut reply).await?;
Ok(format!("{msg:?} -> {}", reply.trim()))
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(handle_client(stream)); // 每个客户端一个任务
}
});
// 同时启动三个客户端,再按顺序逐个等待。
let clients = ["hello there", "rust is fun to learn", "see you soon"]
.map(|msg| tokio::spawn(ask(addr, msg)));
for c in clients {
println!("{}", c.await.unwrap()?);
}
Ok(())
}

输出:

"hello there" -> 2 words
"rust is fun to learn" -> 5 words
"see you soon" -> 3 words
▶ 动手试一试

into_split() 把流拆成读的一半和写的一半,这样读和写就可以放在不同的变量里。和前面一样,接受连接的循环会一直运行。main 返回时,tokio 会停止所有任务。

UDP 没有连接。你把一条条独立的消息(数据报,datagram)发到某个地址,每条消息要么完整到达,要么根本不到。顺序和送达都没有保证。它用在游戏、视频通话、DNS 和传感器这些场景里,在这些地方,速度比偶尔丢一个包更重要。在 std::net 里,它只有一个类型:UdpSocket。

let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // 一条消息,发到一个地址
let mut buf = [0u8; 1500];
let (n, from) = socket.recv_from(&mut buf)?; // 等待任何人发来的一条消息

tokio::net::UdpSocket 的用法完全一样,只是要加 .await。

在网络上,出错是家常便饭:没有人在监听、Wi-Fi 断了、服务器卡住了。有两条规则能让你的程序保持健康:处理每一个错误,以及永远不要无限等待。默认情况下,读取操作会一直等下去。set_read_timeout 可以解决这个问题:

use std::io::{ErrorKind, Read};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;
fn main() -> std::io::Result<()> {
// 1. 没有人在监听:连接马上失败。
let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // 被丢弃 = 已关闭
match TcpStream::connect(free_port) {
Ok(_) => println!("居然连上了?!"),
Err(e) => println!("连接失败:{:?}", e.kind()),
}
// 2. 一个接受连接、却从不回应的服务器。
let silent = TcpListener::bind("127.0.0.1:0")?;
let mut stream = TcpStream::connect(silent.local_addr()?)?;
stream.set_read_timeout(Some(Duration::from_millis(200)))?;
let mut buf = [0u8; 64];
match stream.read(&mut buf) {
Ok(n) => println!("读到 {n} 个字节"),
// Linux 报告 WouldBlock,Windows 报告 TimedOut。
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {
println!("等了 200 ms,读取超时")
}
Err(e) => println!("其他错误:{e}"),
}
Ok(())
}

输出:

连接失败:ConnectionRefused
等了 200 ms,读取超时
▶ 动手试一试

e.kind() 返回一个 ErrorKind(英文),你可以对它使用 match。连接很慢的时候,还有 TcpStream::connect_timeout 可用。在 tokio 里,可以用 tokio::time::timeout 包住任意一个 future:

let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;
// Err(_) = 花的时间太长;Ok(inner) = 读取完成了(inner 是读取本身的 Result)

HTTP 是 Web 的语言,它运行在 TCP 之上。你可以在 TcpStream 上手写 HTTP,但客户端 crate 会帮你处理各种细节:HTTPS、重定向、请求头、压缩。下面这些示例需要联网,还需要 reqwest crate,所以不能在 Playground 里运行。请在你的计算机上运行:

Terminal window
cargo new api-client
cd api-client
cargo add reqwest --features json
cargo add tokio --features full
cargo add serde --features derive
[dependencies]
reqwest = { version = "0.13", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

我们会用 JSONPlaceholder(英文),一个专门用来练习的免费假 API。第一次编译要下载并编译不少 crate,请耐心等一分钟左右。

reqwest::get(url).await?.text().await? 会把响应体作为 String 返回。不过对于 JSON,还有更好的办法:打开 json 这个 feature 后,reqwest 会借助 serde 把响应直接转换成你的结构体,也能把你的结构体转换成请求体:

use serde::{Deserialize, Serialize};
// 只读取你列出的字段。多出来的 JSON 字段(比如 "userId")会被忽略。
#[derive(Deserialize)]
struct Todo {
id: u32,
title: String,
completed: bool,
}
#[derive(Serialize)]
struct NewTodo {
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
// GET:JSON -> 你的结构体
let todo: Todo = client
.get("https://jsonplaceholder.typicode.com/todos/1")
.send()
.await?
.json()
.await?;
println!("#{}: {} (done: {})", todo.id, todo.title, todo.completed);
// POST:你的结构体 -> JSON(这个测试 API 会回应,但不会真的保存)
let new = NewTodo { title: "Learn reqwest".to_string(), completed: false };
let response = client
.post("https://jsonplaceholder.typicode.com/todos")
.json(&new)
.send()
.await?;
println!("status: {}", response.status());
println!("{}", response.text().await?);
Ok(())
}
#1: delectus aut autem (done: false)
status: 201 Created
{
"title": "Learn reqwest",
"completed": false,
"id": 201
}

只创建一个 Client,然后重复使用它。它会保持连接并复用,让后续的请求更快。.json(&new) 还会顺便帮你设置 Content-Type: application/json 请求头。

不想用异步?ureq 是一个小巧的阻塞式客户端:ureq::get(url).call()?.body_mut().read_to_string()? 就能把响应体作为 String 拿到,完全不需要 tokio。

reqwest 默认没有超时,所以一个卡住的服务器就能让你的程序永远等下去。请在客户端上设置超时。还要注意:对 reqwest 来说,404 Not Found 不是错误。请求本身成功了,只是对方的回答是“没有”。调用 error_for_status(),就能把 4xx 和 5xx 的响应变成错误:

use std::time::Duration;
// 不只是网络问题,4xx/5xx 的响应也会变成错误。
async fn fetch(client: &reqwest::Client, url: &str) -> Result<String, reqwest::Error> {
let response = client.get(url).send().await?.error_for_status()?;
response.text().await
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
// 没有超时的话,向卡住的服务器发请求可能会永远等下去。
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()?;
// 一个本地服务器:接受连接,但从不回应。
let silent = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let stuck = format!("http://{}/", silent.local_addr().unwrap());
let urls = [
"https://jsonplaceholder.typicode.com/todos/1",
"https://jsonplaceholder.typicode.com/todos/999999",
"http://127.0.0.1:1/",
&stuck,
];
for url in urls {
match fetch(&client, url).await {
Ok(body) => println!("ok, {} bytes", body.len()),
Err(e) if e.is_status() => println!("server said {}", e.status().unwrap()),
Err(e) if e.is_connect() => println!("could not connect: {e}"),
Err(e) if e.is_timeout() => println!("gave up after 2 seconds"),
Err(e) => println!("other error: {e}"),
}
}
Ok(())
}
ok, 83 bytes
server said 404 Not Found
could not connect: error sending request for url (http://127.0.0.1:1/)
gave up after 2 seconds

is_status()、is_connect() 和 is_timeout() 会告诉你发生的是哪一类失败,这样你就能决定怎么办:稍后重试、给用户显示一条消息,或者干脆放弃。在更大的程序里,用 anyhow 可以很方便地给这些错误加上上下文。

  • 双方都在等对方:客户端调用了 read_line,但服务器也在读,因为客户端忘了发送 \n。谁都不发送,程序就卡住了。双方要约定好一条消息在哪里结束(换行符是最简单的规则)。
  • 过早地“读到末尾”:read_to_string 会一直等到对方关闭连接。如果连接一直开着,它就永远不会返回。改成一行一行地读。
  • 在异步代码里阻塞:在 tokio 任务里使用 std::net 或 std::thread::sleep,会阻塞一整个工作线程。在异步代码里,要用 tokio::net 和 tokio::time::sleep。
  • 忘了设置超时:在又快又稳的本地网络上,测试都能通过。然后某一个慢吞吞的服务器就让一切都卡住了。从第一天起就设置超时。

写一个服务器,读取像 add 2 3 或 mul 4 5 这样的行,并回复计算结果。遇到无效的行,就回复一条以 错误: 开头的简短消息。在 main 里用一个连接、四个问题来测试它。提示:用 split_whitespace() 切分这一行,用 parse::<i64>() 解析数字,再用 writeln! 回复。

查看答案
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn calculate(line: &str) -> String {
let parts: Vec<&str> = line.split_whitespace().collect();
let [op, a, b] = parts[..] else {
return "错误:用法是 <op> <a> <b>".to_string();
};
let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else {
return "错误:不是数字".to_string();
};
match op {
"add" => (a + b).to_string(),
"mul" => (a * b).to_string(),
_ => format!("错误:未知运算 {op}"),
}
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
let server = thread::spawn(move || -> std::io::Result<()> {
let (stream, _) = listener.accept()?;
for line in BufReader::new(&stream).lines() {
writeln!(&stream, "{}", calculate(&line?))?;
}
Ok(())
});
let stream = TcpStream::connect(addr)?;
let mut replies = BufReader::new(&stream);
for question in ["add 2 3", "mul 4 5", "add 2 x", "div 8 2"] {
writeln!(&stream, "{question}")?;
let mut answer = String::new();
replies.read_line(&mut answer)?;
println!("{question} = {}", answer.trim());
}
drop(stream); // 关闭连接,服务器的循环就结束了
server.join().unwrap()
}

输出:

add 2 3 = 5
mul 4 5 = 20
add 2 x = 错误:不是数字
div 8 2 = 错误:未知运算 div
▶ 动手试一试

把逻辑放在一个普通的 calculate 函数里,意味着完全不用网络就能测试它。输入的形状不对时,那几行 let ... else 会提前返回。

2. 统计已完成的待办事项(在你的计算机上运行)

Section titled “2. 统计已完成的待办事项(在你的计算机上运行)”

用 reqwest 获取 https://jsonplaceholder.typicode.com/todos?userId=1,它会返回一个由待办事项组成的 JSON 数组。打印一共有多少条、其中多少条已完成,以及第一条未完成事项的标题。提示:反序列化成 Vec<Todo>,然后使用 filter 和 find 这样的迭代器方法。

查看答案
use serde::Deserialize;
#[derive(Deserialize)]
struct Todo {
title: String,
completed: bool,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let todos: Vec<Todo> = reqwest::get("https://jsonplaceholder.typicode.com/todos?userId=1")
.await?
.error_for_status()?
.json()
.await?;
let done = todos.iter().filter(|t| t.completed).count();
println!("user 1 has {} to-dos, {done} completed", todos.len());
let first_open = todos.iter().find(|t| !t.completed).unwrap();
println!("first open one: {}", first_open.title);
Ok(())
}
user 1 has 20 to-dos, 11 completed
first open one: delectus aut autem

JSON 数组对应 Vec,serde 会帮你填好其中的每个元素。你的结构体只需要列出用得到的字段。

  • std::net 提供阻塞式的 TCP(TcpListener、TcpStream)和 UDP(UdpSocket),不需要额外的 crate。
  • 绑定到端口 0 可以拿到任意一个空闲端口,再用 local_addr() 读出具体是哪个。
  • TCP 是字节流:要约定好一条消息在哪里结束,比如一行一条消息。
  • 要服务多个客户端,可以每个连接一个线程;需要成千上万个连接时,用 tokio 任务。
  • 用 reqwest(异步)或 ureq(阻塞)调用 HTTP API,serde 会把 JSON 转换成你的结构体。
  • 一定要设置超时,并使用 error_for_status(),免得把 404 当成成功。

小测验

1. 把 TcpListener 绑定到 127.0.0.1:0 会怎样?

2. 为什么这一页的示例在每条消息末尾都发送一个 \n?

3. reqwest 收到了 404 Not Found 响应。send().await 返回什么?

4. 你的 tokio 服务器用 std::net 和 std::thread::sleep 来处理每个客户端。问题在哪里?

得分: 0 / 4