跳转到内容

用 Rust 和 Axum 编写 Web API

在这个项目里,你会做一个真正的 Web 服务器:一个小小的待办事项 API(to-do API)。其他程序(一个网站、一个手机 App,或者只是终端里的 curl)向它发送 HTTP 请求,它用 JSON 来回答。你平时用的大多数 App,“后端”跑的就是这类东西。

我们会用 axum,这是 Tokio 团队做的一个流行又好上手的 Web 框架。它建立在异步 Rust 和 serde 之上,所以这个项目会把你学过的很多内容串起来:结构体、Result、智能指针,以及线程间共享状态。如果你做过待办事项命令行工具,会觉得思路很眼熟。只不过这一次,待办清单住在一个网址后面。

完成之后,你的服务器能回答这些请求:

方法和路径 作用 成功时的状态码
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:

Terminal window
cargo new todo-api
cd todo-api
cargo add [email protected] tokio --features tokio/full
cargo add serde --features derive
cargo 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 在那里是另一个命令。)

把 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

让它继续运行,打开第二个终端,给它发一个请求:

Terminal window
curl http://127.0.0.1:3000/
Hello, world!

服务器不会在文本后面加换行,所以你的终端提示符可能会紧跟在 Hello, world! 后面出现。这很正常。本页把每个响应都单独显示在一行。你也可以在浏览器里打开 http://127.0.0.1:3000/。在第一个终端里按 Ctrl + C 可以停止服务器。每次修改代码之后,都需要停止并重新启动它。

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 同时显示状态行和响应头:

Terminal window
curl -i http://127.0.0.1:3000/example
HTTP/1.1 200 OK
content-type: application/json
content-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 列)”。

在 /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 交给你。有两个参数时,你拿到的是一个元组。

Terminal window
curl http://127.0.0.1:3000/hello/Ana
curl http://127.0.0.1:3000/add/2/3
Hello, Ana!
2 + 3 = 5

这里有个惊喜。你声明了 a 和 b 是 i32,所以 axum 会替你检查。发一个不是数字的值试试:

Terminal window
curl -i http://127.0.0.1:3000/add/2/x
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 68
Invalid URL: Cannot parse value at index 1 with value `x` to a `i32`

你的处理函数根本没有运行。函数签名里的类型本身就是校验。

要创建一条待办事项,客户端会在请求正文(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 发送正文:

Terminal window
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:

Terminal window
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 Entity
content-type: text/plain; charset=utf-8
content-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 /todos
async 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 /todos
async 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 })
None
deleted 1? true
deleted 1 again? false
left: 1
▶ 动手试一试

通过 second_handle 创建的待办事项,用 db 读取时也能看到,因为两个 Arc 指向的是同一个列表。

用 cargo run 启动服务器,然后在第二个终端里把每个端点都试一遍。一开始列表是空的:

Terminal window
curl http://127.0.0.1:3000/todos
[]

创建一条待办事项。注意状态码是 201 Created:

Terminal window
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Buy milk"}'
HTTP/1.1 201 Created
content-type: application/json
content-length: 40
{"id":1,"title":"Buy milk","done":false}

再创建一条,然后列出全部:

Terminal window
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 获取一条,再请求一条不存在的:

Terminal window
curl http://127.0.0.1:3000/todos/2
curl -i http://127.0.0.1:3000/todos/99
{"id":2,"title":"Learn axum","done":false}
HTTP/1.1 404 Not Found
content-length: 0

删除待办事项 1,然后再删一次:

Terminal window
curl -i -X DELETE http://127.0.0.1:3000/todos/1
curl -i -X DELETE http://127.0.0.1:3000/todos/1
HTTP/1.1 204 No Content
HTTP/1.1 404 Not Found
content-length: 0
Terminal window
curl http://127.0.0.1:3000/todos
[{"id":2,"title":"Learn axum","done":false}]

有些错误 axum 也会替你处理。JSON 里的类型不对,会得到一个解释问题所在的 422;对某个路径使用了你没设置的方法,会得到 405 Method Not Allowed,并附带一个列出可用方法的响应头:

Terminal window
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/2
HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 130
Failed to deserialize the JSON body into the target type: title: invalid type: integer `42`, expected a string at line 1 column 12
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD,DELETE
content-length: 0

422 的消息是说:“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