Rust 网络编程:TCP 服务器与 HTTP 客户端
你用的几乎每个程序都在和另一台计算机通信:聊天软件、游戏、天气小组件。它们在底层做的事情都一样:打开一个连接,发送一些字节,再读取回应。Rust 很擅长这件事:速度快,占用内存少,而且编译器能拦住很多会让网络代码在凌晨 3 点崩溃的 bug。
在这一页,你会写一个小小的 TCP 服务器和客户端,让服务器同时服务多个客户端(先用线程,再用异步的 tokio),然后用 reqwest 调用真实的 HTTP API。TCP 示例可以直接在 Rust Playground 里运行。reqwest 示例需要联网,所以要在你自己的计算机上用 cargo run 运行。每个示例我们都实际编译运行过,展示的输出都是真实的。
能用它做什么
Section titled “能用它做什么”- 网络服务和代理:Cloudflare 用 Rust 编写了 Pingora(英文),这是它开源的代理框架。服务网格 Linkerd(英文)用的也是 Rust 编写的代理。
- Web API 和后端,用基于 tokio 的 axum 来写。见项目:构建 Web API。
- API 客户端和工具:调用 GitHub 的命令行工具、聊天机器人、检查网站是否在线的脚本。
- 自定义协议:游戏服务器、聊天服务器、通过 UDP 发送传感器读数的设备。
也有比较难的地方:异步 Rust 的学习曲线确实不平缓,而且第一次编译网络相关的 crate 要花一两分钟。如果只是写一个调用某个 API 的 10 行脚本,Python 可能写得更快。当程序必须跑得快、连续运行好几个月,或者要处理大量连接时,Rust 的优势才真正体现出来。
选哪个 crate?
Section titled “选哪个 crate?”| 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。
用线程服务多个客户端
Section titled “用线程服务多个客户端”真正的服务器不能让 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 写同一个服务器
Section titled “用 tokio 写同一个服务器”用 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 wordsinto_split() 把流拆成读的一半和写的一半,这样读和写就可以放在不同的变量里。和前面一样,接受连接的循环会一直运行。main 返回时,tokio 会停止所有任务。
一分钟了解 UDP
Section titled “一分钟了解 UDP”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。
超时和网络错误
Section titled “超时和网络错误”在网络上,出错是家常便饭:没有人在监听、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)用 reqwest 处理 HTTP 和 JSON
Section titled “用 reqwest 处理 HTTP 和 JSON”HTTP 是 Web 的语言,它运行在 TCP 之上。你可以在 TcpStream 上手写 HTTP,但客户端 crate 会帮你处理各种细节:HTTPS、重定向、请求头、压缩。下面这些示例需要联网,还需要 reqwest crate,所以不能在 Playground 里运行。请在你的计算机上运行:
cargo new api-clientcd api-clientcargo add reqwest --features jsoncargo add tokio --features fullcargo add serde --features derive[dependencies]reqwest = { version = "0.13", features = ["json"] }serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }我们会用 JSONPlaceholder(英文),一个专门用来练习的免费假 API。第一次编译要下载并编译不少 crate,请耐心等一分钟左右。
GET 和 POST JSON
Section titled “GET 和 POST JSON”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 的超时和错误
Section titled “reqwest 的超时和错误”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 bytesserver said 404 Not Foundcould not connect: error sending request for url (http://127.0.0.1:1/)gave up after 2 secondsis_status()、is_connect() 和 is_timeout() 会告诉你发生的是哪一类失败,这样你就能决定怎么办:稍后重试、给用户显示一条消息,或者干脆放弃。在更大的程序里,用 anyhow 可以很方便地给这些错误加上上下文。
- 双方都在等对方:客户端调用了
read_line,但服务器也在读,因为客户端忘了发送\n。谁都不发送,程序就卡住了。双方要约定好一条消息在哪里结束(换行符是最简单的规则)。 - 过早地“读到末尾”:
read_to_string会一直等到对方关闭连接。如果连接一直开着,它就永远不会返回。改成一行一行地读。 - 在异步代码里阻塞:在 tokio 任务里使用
std::net或std::thread::sleep,会阻塞一整个工作线程。在异步代码里,要用tokio::net和tokio::time::sleep。 - 忘了设置超时:在又快又稳的本地网络上,测试都能通过。然后某一个慢吞吞的服务器就让一切都卡住了。从第一天起就设置超时。
1. 计算器服务器
Section titled “1. 计算器服务器”写一个服务器,读取像 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 = 5mul 4 5 = 20add 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 completedfirst open one: delectus aut autemJSON 数组对应 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
- std::net — 标准库文档(英文):
TcpListener、TcpStream、UdpSocket和地址类型,附示例。 - 构建单线程 Web 服务器 — Rust 程序设计语言:官方教程的最后一个项目基于
std::net构建了一个 HTTP 服务器,然后加上线程池。 - Tokio tutorial(英文):一步步讲解异步 TCP,从创建任务到消息分帧。
- reqwest — docs.rs(英文):客户端、请求、JSON、超时,以及
blocking模块。 - Web clients — Rust Cookbook(英文):HTTP 请求、调用 API 和下载文件的简短示例。