用 Rust 和 Axum 编写 Web API
在这个项目里,你会做一个真正的 Web 服务器:一个小小的待办事项 API(to-do API)。其他程序(一个网站、一个手机 App,或者只是终端里的 curl)向它发送 HTTP 请求,它用 JSON 来回答。你平时用的大多数 App,“后端”跑的就是这类东西。
我们会用 axum,这是 Tokio 团队做的一个流行又好上手的 Web 框架。它建立在异步 Rust 和 serde 之上,所以这个项目会把你学过的很多内容串起来:结构体、Result、智能指针,以及线程间共享状态。如果你做过待办事项命令行工具,会觉得思路很眼熟。只不过这一次,待办清单住在一个网址后面。
你将做出什么
Section titled “你将做出什么”完成之后,你的服务器能回答这些请求:
| 方法和路径 | 作用 | 成功时的状态码 |
|---|---|---|
GET /todos |
列出所有待办事项 | 200 OK |
GET /todos/{id} |
获取一条待办事项 | 200 OK,或 404 Not Found |
POST /todos |
用 JSON 创建一条待办事项 | 201 Created |
DELETE /todos/{id} |
删除一条待办事项 | 204 No Content,或 404 Not Found |
一条待办事项的 JSON 长这样:
{"id":1,"title":"Buy milk","done":false}有一点很重要:Web 服务器会一直等待请求,而且需要一个网络端口,所以它没法在 Rust Playground 里运行。本页的服务器代码是让你在自己的电脑上用 cargo run 运行的。下面的每一步都真实编译、运行过,显示的响应也都是真实的。程序里能在 Playground 运行的部分,会配有“自己试试”按钮。
新建一个项目,然后添加四个 crate:
cargo new todo-apicd todo-apicargo add serde --features derivecargo add serde_json现在你的 Cargo.toml 里大概是这样(具体的版本号可能更新):
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }它们各自的作用:
- axum 负责 HTTP:读取请求、发送响应。
- tokio 是运行这一切的异步运行时。参见 Async/Await。
- serde 和 serde_json 负责在结构体和 JSON 之间互相转换。参见用 Serde 处理 JSON。
你还需要 curl 来发送请求。macOS、Linux 和 Windows 10 及以上版本都自带它。(在 Windows 的 PowerShell 里要输入 curl.exe,因为单独的 curl 在那里是另一个命令。)
第 1 步:Hello, world!
Section titled “第 1 步:Hello, world!”把 src/main.rs 替换成下面的代码:
use axum::{routing::get, Router};
#[tokio::main]async fn main() { let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn hello() -> &'static str { "Hello, world!"}这里有三个部分在协同工作:
- 处理函数(handler)是一个负责回答请求的
async fn。hello返回一段文本,axum 把它作为响应发回去。 - 路由器(router)把路径和处理函数连起来。
.route("/", get(hello))的意思是“有人向/发送GET请求时,调用hello”。 - 监听器(listener)在你自己的电脑(
127.0.0.1)上打开 3000 端口,axum::serve开始回答请求。它会一直运行,直到你停止它。
用 cargo run 运行它。程序会打印一行,然后开始等待:
Listening on http://127.0.0.1:3000让它继续运行,打开第二个终端,给它发一个请求:
curl http://127.0.0.1:3000/Hello, world!服务器不会在文本后面加换行,所以你的终端提示符可能会紧跟在 Hello, world! 后面出现。这很正常。本页把每个响应都单独显示在一行。你也可以在浏览器里打开 http://127.0.0.1:3000/。在第一个终端里按 Ctrl + C 可以停止服务器。每次修改代码之后,都需要停止并重新启动它。
第 2 步:从结构体返回 JSON
Section titled “第 2 步:从结构体返回 JSON”API 通常用 JSON 来回答。在 axum 里,只要把任何实现了 serde 的 Serialize 的值包进 Json(...),axum 就会把它转成 JSON,并设置正确的 content-type 头:
use axum::{routing::get, Json, Router};use serde::Serialize;
#[derive(Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[tokio::main]async fn main() { let app = Router::new() .route("/", get(hello)) .route("/example", get(example_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn hello() -> &'static str { "Hello, world!"}
async fn example_todo() -> Json<Todo> { let todo = Todo { id: 1, title: String::from("Learn axum"), done: false, }; Json(todo)}-i 参数让 curl 同时显示状态行和响应头:
curl -i http://127.0.0.1:3000/exampleHTTP/1.1 200 OKcontent-type: application/jsoncontent-length: 42
{"id":1,"title":"Learn axum","done":false}(curl 还会打印一个 date 头,本页把它省略了。)JSON 的键和结构体的字段名完全一样。这个 JSON 的结构可以在 Playground 里检查,不需要服务器,因为这只是普通的 serde。下面也包含了第 4 步接收数据时要用的 NewTodo 结构体:
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[derive(Debug, Deserialize)]struct NewTodo { title: String,}
fn main() { let todo = Todo { id: 1, title: String::from("Learn axum"), done: false }; println!("{}", serde_json::to_string(&todo).unwrap());
let list = vec![todo.clone(), Todo { id: 2, title: String::from("Buy milk"), done: true }]; println!("{}", serde_json::to_string(&list).unwrap());
let input: NewTodo = serde_json::from_str(r#"{"title":"Walk the dog"}"#).unwrap(); println!("{:?}", input);
let bad = serde_json::from_str::<NewTodo>(r#"{"name":"Walk the dog"}"#); println!("{}", bad.unwrap_err());}输出:
{"id":1,"title":"Learn axum","done":false}[{"id":1,"title":"Learn axum","done":false},{"id":2,"title":"Buy milk","done":true}]NewTodo { title: "Walk the dog" }missing field `title` at line 1 column 23一个 Vec<Todo> 会变成一个 JSON 数组,这正是 GET /todos 要返回的东西。最后一行是 serde 的错误信息:“缺少字段 title(第 1 行第 23 列)”。
第 3 步:路径参数
Section titled “第 3 步:路径参数”在 /todos/2 这样的路径里,2 每次请求都可能不一样。在 axum 0.8 里,用花括号 {id} 标出路由里的这一部分,然后在处理函数里用 Path 提取器(extractor)读取它:
use axum::{extract::Path, routing::get, Router};
#[tokio::main]async fn main() { let app = Router::new() .route("/hello/{name}", get(hello_name)) .route("/add/{a}/{b}", get(add));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn hello_name(Path(name): Path<String>) -> String { format!("Hello, {name}!")}
async fn add(Path((a, b)): Path<(i32, i32)>) -> String { format!("{a} + {b} = {}", a + b)}提取器是处理函数的一个参数,负责从请求里取出某一部分。Path(name): Path<String> 是一个模式:它拆开 Path,把里面的 String 交给你。有两个参数时,你拿到的是一个元组。
curl http://127.0.0.1:3000/hello/Anacurl http://127.0.0.1:3000/add/2/3Hello, Ana!2 + 3 = 5这里有个惊喜。你声明了 a 和 b 是 i32,所以 axum 会替你检查。发一个不是数字的值试试:
curl -i http://127.0.0.1:3000/add/2/xHTTP/1.1 400 Bad Requestcontent-type: text/plain; charset=utf-8content-length: 68
Invalid URL: Cannot parse value at index 1 with value `x` to a `i32`你的处理函数根本没有运行。函数签名里的类型本身就是校验。
第 4 步:用 POST 接收 JSON
Section titled “第 4 步:用 POST 接收 JSON”要创建一条待办事项,客户端会在请求正文(body)里发送 JSON。Json 提取器读取正文,把它反序列化成你的结构体。客户端只发送标题,所以我们为输入单独准备一个 NewTodo 结构体:
use axum::{routing::post, Json, Router};use serde::{Deserialize, Serialize};
#[derive(Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[derive(Deserialize)]struct NewTodo { title: String,}
#[tokio::main]async fn main() { let app = Router::new().route("/echo", post(echo_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn echo_todo(Json(input): Json<NewTodo>) -> Json<Todo> { let todo = Todo { id: 1, title: input.title, done: false, }; Json(todo)}在路由里用 post(...) 代替 get(...)。在 curl 里,-X POST 设置请求方法,-H 添加请求头,-d 发送正文:
curl -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"title":"Buy milk"}'{"id":1,"title":"Buy milk","done":false}同样,axum 会在你的代码运行之前检查输入。如果 JSON 里缺少 title,你会得到 422 Unprocessable Entity:
curl -i -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"name":"Buy milk"}'HTTP/1.1 422 Unprocessable Entitycontent-type: text/plain; charset=utf-8content-length: 99
Failed to deserialize the JSON body into the target type: missing field `title` at line 1 column 19如果忘了 Content-Type 头,你会得到 415 Unsupported Media Type,消息是 Expected request with `Content-Type: application/json` 。用 curl 测试时,最容易忘的就是这个请求头。
第 5 步:用共享状态做一个真正的待办事项 API
Section titled “第 5 步:用共享状态做一个真正的待办事项 API”到目前为止,每个处理函数都是自己编出数据。真正的 API 需要在请求之间记住待办事项。请求可能同时到达,并在不同的线程上处理,所以这个列表必须能安全地共享。这正是你在并发那一页见过的这对组合的工作:
Mutex<Vec<Todo>>保证同一时间只有一个请求在修改列表。Arc<...>让许多处理函数都能拥有指向同一个列表的指针(参见智能指针)。
我们用 type Db = Arc<Mutex<Vec<Todo>>>; 给这个类型起个短名字,用 .with_state(db) 把它交给路由器,再在处理函数里用 State 提取器读取它。axum 会为每个请求克隆一次 Arc(开销很小:只是复制一个指针)。
下面是完整的程序。把 src/main.rs 替换成它:
use std::sync::{Arc, Mutex};
use axum::{ extract::{Path, State}, http::StatusCode, routing::get, Json, Router,};use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[derive(Deserialize)]struct NewTodo { title: String,}
// “数据库”:一个所有请求都能共享的待办事项列表。type Db = Arc<Mutex<Vec<Todo>>>;
#[tokio::main]async fn main() { let db: Db = Arc::new(Mutex::new(Vec::new()));
let app = Router::new() .route("/todos", get(list_todos).post(create_todo)) .route("/todos/{id}", get(get_todo).delete(delete_todo)) .with_state(db);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
// GET /todosasync fn list_todos(State(db): State<Db>) -> Json<Vec<Todo>> { let todos = db.lock().unwrap(); Json(todos.clone())}
// GET /todos/{id}async fn get_todo(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Todo>, StatusCode> { let todos = db.lock().unwrap(); match todos.iter().find(|todo| todo.id == id) { Some(todo) => Ok(Json(todo.clone())), None => Err(StatusCode::NOT_FOUND), }}
// POST /todosasync fn create_todo(State(db): State<Db>, Json(input): Json<NewTodo>) -> (StatusCode, Json<Todo>) { let mut todos = db.lock().unwrap(); let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1; let todo = Todo { id, title: input.title, done: false, }; todos.push(todo.clone()); (StatusCode::CREATED, Json(todo))}
// DELETE /todos/{id}async fn delete_todo(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode { let mut todos = db.lock().unwrap(); let before = todos.len(); todos.retain(|todo| todo.id != id); if todos.len() < before { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND }}我们来逐个看看新概念:
- 一个路径,多种方法。
get(list_todos).post(create_todo)把GET /todos和POST /todos分别交给不同的处理函数。 - 状态码就是返回值。
StatusCode来自 axum 的http模块。处理函数可以返回一个Result:Ok(Json(todo))会变成带 JSON 的 200,Err(StatusCode::NOT_FOUND)会变成没有正文的 404。元组(StatusCode::CREATED, Json(todo))同时设置状态码和正文。只返回StatusCode::NO_CONTENT(204)表示“完成了,没有内容要返回”。 lock()和守卫。db.lock().unwrap()会一直等到没有其他请求在使用这个列表,然后给你一个守卫(guard),你可以像操作普通的Vec一样读取和修改它。函数结束、守卫离开作用域时,锁就会释放。- 新的 id。 下一个 id 等于列表中最大的 id 加 1;列表为空时就是 1。
- 删除。
retain保留所有 id 不匹配的待办事项。如果长度没有变,说明什么都没删掉,于是我们返回 404。
为什么用 std::sync::Mutex,而不是异步版本的锁?只要不在持有锁的同时跨过 .await,用它就没问题(而且更快)。我们的处理函数都没有这样做:它们加锁、操作列表、然后返回。
列表和加锁的逻辑不需要 Web 服务器,所以可以在 Playground 里试试。下面这些函数做的事和处理函数一样,只是不用 axum:
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]struct Todo { id: u32, title: String, done: bool,}
type Db = Arc<Mutex<Vec<Todo>>>;
fn create(db: &Db, title: &str) -> Todo { let mut todos = db.lock().unwrap(); let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1; let todo = Todo { id, title: title.to_string(), done: false }; todos.push(todo.clone()); todo}
fn get(db: &Db, id: u32) -> Option<Todo> { let todos = db.lock().unwrap(); todos.iter().find(|todo| todo.id == id).cloned()}
fn delete(db: &Db, id: u32) -> bool { let mut todos = db.lock().unwrap(); let before = todos.len(); todos.retain(|todo| todo.id != id); todos.len() < before}
fn main() { let db: Db = Arc::new(Mutex::new(Vec::new())); let second_handle = Arc::clone(&db); // axum 为每个请求做的就是这件事
println!("{:?}", create(&db, "Buy milk")); println!("{:?}", create(&second_handle, "Learn axum")); println!("{:?}", get(&db, 2)); println!("{:?}", get(&db, 99)); println!("deleted 1? {}", delete(&db, 1)); println!("deleted 1 again? {}", delete(&db, 1)); println!("left: {}", db.lock().unwrap().len());}输出:
Todo { id: 1, title: "Buy milk", done: false }Todo { id: 2, title: "Learn axum", done: false }Some(Todo { id: 2, title: "Learn axum", done: false })Nonedeleted 1? truedeleted 1 again? falseleft: 1通过 second_handle 创建的待办事项,用 db 读取时也能看到,因为两个 Arc 指向的是同一个列表。
第 6 步:用 curl 测试
Section titled “第 6 步:用 curl 测试”用 cargo run 启动服务器,然后在第二个终端里把每个端点都试一遍。一开始列表是空的:
curl http://127.0.0.1:3000/todos[]创建一条待办事项。注意状态码是 201 Created:
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Buy milk"}'HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 40
{"id":1,"title":"Buy milk","done":false}再创建一条,然后列出全部:
curl -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Learn axum"}'curl http://127.0.0.1:3000/todos{"id":2,"title":"Learn axum","done":false}[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Learn axum","done":false}]按 id 获取一条,再请求一条不存在的:
curl http://127.0.0.1:3000/todos/2curl -i http://127.0.0.1:3000/todos/99{"id":2,"title":"Learn axum","done":false}HTTP/1.1 404 Not Foundcontent-length: 0删除待办事项 1,然后再删一次:
curl -i -X DELETE http://127.0.0.1:3000/todos/1curl -i -X DELETE http://127.0.0.1:3000/todos/1HTTP/1.1 204 No Content
HTTP/1.1 404 Not Foundcontent-length: 0curl http://127.0.0.1:3000/todos[{"id":2,"title":"Learn axum","done":false}]有些错误 axum 也会替你处理。JSON 里的类型不对,会得到一个解释问题所在的 422;对某个路径使用了你没设置的方法,会得到 405 Method Not Allowed,并附带一个列出可用方法的响应头:
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title": 42}'curl -i -X PUT http://127.0.0.1:3000/todos/2HTTP/1.1 422 Unprocessable Entitycontent-type: text/plain; charset=utf-8content-length: 130
Failed to deserialize the JSON body into the target type: title: invalid type: integer `42`, expected a string at line 1 column 12HTTP/1.1 405 Method Not Allowedallow: GET,HEAD,DELETEcontent-length: 0422 的消息是说:“title 的类型不对:收到的是整数 42,期望的是字符串”。用 Ctrl + C 停止服务器,再重新启动:列表又空了。数据只保存在内存里。把它保存下来,就是下面的第一个扩展思路。
使用旧的 :id 路由写法。 网上很多教程是为 axum 0.7 写的,那时用的是 /todos/:id。在 axum 0.8 里这样写能编译通过,但程序一启动就会 panic:
thread 'main' (12345) panicked at src/main.rs:32:10:Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.意思是:路径段不能以 : 开头,捕获参数要用 {capture}。修复方法:写成 /todos/{id}。
把 Json 放在其他提取器前面。 请求正文只能读取一次,所以读取正文的提取器(Json)必须是最后一个参数。如果你写成 create_todo(Json(input): Json<NewTodo>, State(db): State<Db>),错误信息并不会直接这么说。关键的几行是:
error[E0277]: the trait bound `fn(Json<NewTodo>, State<...>) -> ... {create_todo}: Handler<_, ...>` is not satisfied |31 | .route("/todos", get(list_todos).post(create_todo)) | ---- ^^^^^^^^^^^ unsatisfied trait bound | = note: Consider using `#[axum::debug_handler]` to improve the error message看到“Handler is not satisfied”时,先检查参数的顺序,再检查每个返回类型是不是 axum 能转换成响应的东西。
curl 里忘了 -H "Content-Type: application/json"。 服务器会回复 415。Json 提取器要求必须有这个请求头。
忘了重启。 正在运行的服务器还是旧程序。每次修改之后,都要用 Ctrl + C 停止它,再重新 cargo run。
基本的 API 跑通之后,下面是一些不错的下一步。一次试一个。
- 把待办事项标记为完成。 在第二条路由上用
.patch(...)添加PATCH /todos/{id}。用iter_mut()找到那条待办事项,设置done = true,然后把它返回。 - 输入校验。 标题不应该允许为空。处理函数可以返回
Result<(StatusCode, Json<Todo>), (StatusCode, String)>,并发回Err((StatusCode::BAD_REQUEST, String::from("title must not be empty")))。用title.trim().is_empty()来检查。 - 永不重复的 id。 用“最大 id 加 1”的话,删掉最新的待办事项后,它的 id 会被再次使用。改为在状态里单独保存一个计数器,比如在
Mutex里放一个包含todos: Vec<Todo>和next_id: u32的结构体。 - 用 JSON 文件持久化。 给
Todo也派生Deserialize。启动时用std::fs::read_to_string和serde_json::from_str读取todos.json(文件不存在就从空列表开始)。每次修改之后,用serde_json::to_string_pretty和std::fs::write把列表写回去。文件读写和用 Serde 处理 JSON 这两页有你需要的一切。以后还可以把文件换成 SQLite 这样真正的数据库。 - 用 tower-http 记录日志。 运行
cargo add tower-http --features trace和cargo add tracing-subscriber,然后添加一个日志层。这样服务器会为每个请求打印一行,包含方法、路径、状态码和耗时:
use tower_http::trace::TraceLayer;
// 在 main 的开头:tracing_subscriber::fmt() .with_max_level(tracing_subscriber::filter::LevelFilter::DEBUG) .init();
// 构建路由器时:let app = Router::new() .route("/todos", get(list_todos).post(create_todo)) .route("/todos/{id}", get(get_todo).delete(delete_todo)) .layer(TraceLayer::new_for_http()) .with_state(db);- 部署须知。
127.0.0.1只接受来自你自己电脑的请求。要接受来自其他机器的请求(比如在 Docker 里或服务器上),改为绑定0.0.0.0。很多托管服务会通过PORT环境变量告诉程序该用哪个端口,你可以用std::env::var读取它(参见环境变量与进程)。用cargo build --release构建,服务器会快很多。构建结果是target/release/里的一个文件,你可以把它复制到 Linux 服务器上,或者放进一个小小的 Docker 镜像。如果是公开的网站,就在它前面放一个反向代理(比如 Caddy 或 nginx)来处理 HTTPS。
- 处理函数是一个
async fn。路由器把方法和路径映射到处理函数。axum::serve在一个tokio的TcpListener上运行它。 - 提取器从请求里取出数据:
Path取 URL 的一部分(/todos/{id}),Json取正文(永远放在最后),State取共享数据。 - 返回
Json(value)得到 JSON,返回StatusCode设置状态码,返回元组同时设置两者,返回Result则在成功和错误状态码之间选择。 Arc<Mutex<Vec<Todo>>>让所有请求安全地共享同一个列表。加锁、使用,然后让守卫自动丢弃。不要在持有锁的同时跨过.await。- 用
curl测试你的 API:-i显示状态码,-X设置方法,-H添加请求头,-d发送正文。
小测验
1. 在 axum 0.8 里,路径中带 id 的路由怎么写?
2. 处理函数返回 (StatusCode::CREATED, Json(todo))。客户端会收到什么?
3. 为什么共享的列表要包在 Arc<Mutex<...>> 里?
4. 哪个提取器必须放在处理函数参数的最后?
5. 你用 curl -X POST 发送了一些 JSON,但忘了 Content-Type: application/json 请求头。axum 会怎么回复?
得分: 0 / 5
- axum 文档 — docs.rs(英文)——官方指南,包含路由、处理函数和响应等章节。
- axum 提取器 — docs.rs(英文)——所有内置提取器,以及它们的顺序规则。
- Tokio 教程(英文)——学习驱动 axum 的异步运行时,包括共享状态。
- tower-http 文档 — docs.rs(英文)——现成的日志、CORS、压缩和超时等中间件层。
- HTTP 响应状态码 — MDN——200、201、204、404、422 等状态码分别是什么意思。