- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 为什么用 Rust 写 Web API
- Axum 入门 — 处理函数与路由
- 提取器 — 从请求中取出想要的东西
- 共享状态 — 分发数据库连接池与配置
- tower 中间件 — 叠加横切关注点
- 用 serde 处理 JSON — 序列化与反序列化
- 用 sqlx 连接数据库 — 编译期验证的查询
- 错误处理 — 把 Result 变成响应
- 与 Actix Web 的比较 — 该选哪一个
- 结语
- 参考资料
引言 — 为什么用 Rust 写 Web API
曾几何时,Web 后端似乎与 Rust 相去甚远。所有权和生命周期这类概念,让人觉得是请求-响应代码的一道准入门槛。但现在情况变了。在此前文章里见过的 async/await 与 Tokio 之上,成熟的 Web 框架相继出现,用 Rust 写 Web API 的体验变得出乎意料地清爽。
Rust Web 后端的魅力很明确:编译期就能捕获的大量 bug、无需垃圾回收也能获得的可预测低延迟,以及类型系统强制带来的健壮性。JSON 字段名拼错一个字,编译就会拒绝;Option 强制你处理空值。
本文以目前最受欢迎的框架 Axum 为中心,梳理从路由到数据库连接、再到错误处理的实战流程。文末会与另一位代表性选手 Actix Web 做比较。Axum 由 Tokio 团队打造,与 tower 生态无缝契合,是一个不错的起点。
Axum 入门 — 处理函数与路由
Axum 中最基础的两个概念是处理函数(handler)与路由器(router)。处理函数是接收请求并返回响应的 async 函数,路由器则定义了哪个路径和方法对应哪个处理函数。
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
// 路由器:把路径和处理函数连接起来
let app = Router::new()
.route("/", get(root))
.route("/health", get(health));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
// 处理函数:就是一个普通的 async 函数
async fn root() -> &'static str {
"Hello, Axum!"
}
async fn health() -> &'static str {
"OK"
}
处理函数只是一个普通的 async 函数,这是 Axum 的一大优点。不需要用宏包裹,也不需要实现特殊的 trait,只要参数写想要的类型、返回能作为响应的值即可。返回类型是 &'static str 也能自动变成 HTTP 响应,是因为 Axum 用一个叫 IntoResponse 的 trait 把多种类型转换成响应——字符串、状态码、JSON、元组等,很多东西都能直接变成响应。
提取器 — 从请求中取出想要的东西
Axum 真正优雅的地方在于提取器(extractor)。在处理函数的参数里写上特定的类型,Axum 就会自动从请求里把那部分取出来传给你。路径参数、查询字符串、JSON 请求体、请求头等,大多数请求元素都有对应的提取器。
use axum::{
extract::{Path, Query, Json},
routing::{get, post},
Router,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct Pagination {
page: u32,
per_page: u32,
}
#[derive(Deserialize)]
struct CreateUser {
name: String,
email: String,
}
// 提取路径参数: /users/42 -> id = 42
async fn get_user(Path(id): Path<u64>) -> String {
format!("user {id}")
}
// 提取查询字符串: /users?page=2&per_page=20
async fn list_users(Query(p): Query<Pagination>) -> String {
format!("page {} ({} per page)", p.page, p.per_page)
}
// 提取 JSON 请求体
async fn create_user(Json(payload): Json<CreateUser>) -> String {
format!("created {} <{}>", payload.name, payload.email)
}
fn app() -> Router {
Router::new()
.route("/users/{id}", get(get_user))
.route("/users", get(list_users).post(create_user))
}
提取器的顺序也有规则。会消费请求体的提取器(例如 Json)会把整个请求体读掉,所以只能有一个,并且必须放在参数列表的最后。像请求头、路径这类不消费请求体的提取器,可以在它之前出现多个。如果提取失败(比如 JSON 解析失败),Axum 会自动返回合适的 4xx 响应,所以处理函数的函数体只需专注于成功路径。
共享状态 — 分发数据库连接池与配置
真实应用的处理函数需要访问数据库连接池、配置值这类共享资源。Axum 把它当作状态(state)来处理,通过 State 提取器注入到处理函数中。
use axum::{extract::State, routing::get, Router};
use std::sync::Arc;
// 整个应用共享的状态
#[derive(Clone)]
struct AppState {
// 实际项目中这里会放 sqlx 连接池、配置等
app_name: Arc<String>,
}
async fn handler(State(state): State<AppState>) -> String {
format!("running: {}", state.app_name)
}
#[tokio::main]
async fn main() {
let state = AppState {
app_name: Arc::new("my-service".to_string()),
};
let app = Router::new()
.route("/", get(handler))
.with_state(state); // 把状态挂到路由器上
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
要求 AppState 实现 Clone 的原因是,每个请求都需要复制一份状态的值。所以惯例是把数据库连接池这类真正的资源用 Arc 包起来,让复制变得廉价。此前智能指针那篇文章里见过的 Arc,在这里自然地派上了用场。连接池本身只存在一份,每个请求共享的都是那个 Arc 的克隆。
tower 中间件 — 叠加横切关注点
日志、鉴权、超时、压缩、CORS 这类要应用到多条路由上的通用逻辑,由中间件来处理。Axum 没有重新发明一套中间件系统,而是直接使用 Rust 生态里标准的服务抽象 tower。这样一来,tower 和 tower-http 提供的海量中间件可以直接拿来用。
use axum::{routing::get, Router};
use tower_http::trace::TraceLayer;
use tower_http::timeout::TimeoutLayer;
use std::time::Duration;
fn app() -> Router {
Router::new()
.route("/", get(|| async { "Hello" }))
// 中间件用 layer 叠加
.layer(TraceLayer::new_for_http()) // 请求日志/追踪
.layer(TimeoutLayer::new(Duration::from_secs(10))) // 请求超时
}
用 layer 叠加中间件,会应用到其下所有路由。叠加多个 layer 时,请求从外向内依次穿过,响应则反方向流出。得益于这种共享 tower 的设计,Axum 的一大优势是可以和其他基于 tower 的工具(比如 gRPC 框架 tonic)复用中间件。
用 serde 处理 JSON — 序列化与反序列化
Web API 的核心是收发 JSON。在 Rust 里,这基本上由 serde crate 来完成。给结构体派生 Serialize/Deserialize,编译器就会生成该类型与 JSON 之间的转换代码。
use axum::Json;
use serde::{Deserialize, Serialize};
// 接收请求体的类型
#[derive(Deserialize)]
struct CreateTodo {
title: String,
#[serde(default)] // 缺省为 false
done: bool,
}
// 发送响应的类型
#[derive(Serialize)]
struct Todo {
id: u64,
title: String,
done: bool,
}
async fn create_todo(Json(input): Json<CreateTodo>) -> Json<Todo> {
let todo = Todo {
id: 1,
title: input.title,
done: input.done,
};
Json(todo) // 用 Json 包起来就自动变成 JSON 响应
}
用 Json 提取器把请求体接收为类型,返回时用 Json 包起来,响应就会以 JSON 形式发出。serde 的属性还能控制更多细节:#[serde(rename_all = "camelCase")] 改变字段命名规则,#[serde(default)] 给缺失的字段一个默认值,Option<T> 字段则表示一个可有可无的值。这些全都以声明式的方式附加在类型上,JSON 契约因此就直接体现在代码里。
用 sqlx 连接数据库 — 编译期验证的查询
要存取数据就需要数据库。在 Rust 异步生态中广泛使用的是 sqlx。sqlx 的独特之处在于,它不是 ORM,而是写原生 SQL,但会在编译期把这条查询和真实的数据库 schema 对照验证。拼错的列名或类型不匹配,不是在运行期,而是在编译期就会被捕获。
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
#[derive(sqlx::FromRow)]
struct User {
id: i64,
name: String,
email: String,
}
async fn setup_pool() -> PgPool {
PgPoolOptions::new()
.max_connections(5)
.connect("postgres://localhost/mydb")
.await
.unwrap()
}
async fn find_user(pool: &PgPool, id: i64) -> Result<Option<User>, sqlx::Error> {
// query_as 把结果映射到结构体
let user = sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(user)
}
sqlx 提供连接池,这个池通过前面见过的 State 分享给应用。参数通过美元符号加编号的占位符(表示第一个、第二个参数)配合 bind 传入,从根源上杜绝了 SQL 注入。结果行会映射到派生了 FromRow 的结构体上。fetch_optional 用 Option 表达结果可能不存在,多行的情况用 fetch_all,预期恰好一行时用 fetch_one。更进一步,还有 query! 宏这种强大的模式,能在编译期连接真实数据库来验证查询本身。
错误处理 — 把 Result 变成响应
到目前为止的示例大多只处理了成功路径,但真实的 API 必须优雅地处理失败。Rust 的惯用做法是让处理函数返回 Result,并让错误类型自己转换成 HTTP 响应——只需给错误类型实现 IntoResponse 即可。
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
// 应用专属的错误类型
enum AppError {
NotFound,
Database(sqlx::Error),
}
// 定义错误如何转换成 HTTP 响应
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not found"),
AppError::Database(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
// 把 sqlx 的错误自动转换成 AppError (供 ? 运算符使用)
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Database(e)
}
}
async fn get_user(
State(pool): State<sqlx::PgPool>,
Path(id): Path<i64>,
) -> Result<Json<String>, AppError> {
let user = find_user(&pool, id).await?; // sqlx::Error 通过 ? 变成 AppError
match user {
Some(_) => Ok(Json(format!("user {id}"))),
None => Err(AppError::NotFound), // 转换成 404
}
}
这个模式的精妙之处在于它与 ? 运算符的配合。只要实现了 From<sqlx::Error>,在数据库调用后面加上 ?,失败就会自动变成 AppError,再进一步转换成合适的 HTTP 状态码。处理函数的函数体可以顺着成功路径自然地写下去,错误则悄悄地向上传播,最终变成形态统一的响应。在实务中,这部分通常会再用 thiserror 之类的 crate 打磨得更简洁。
与 Actix Web 的比较 — 该选哪一个
除了 Axum,另一个代表性框架是 Actix Web。它成熟已久,并凭借在各类基准测试中名列前茅的性能而闻名。这两个框架其实相当相似——都跑在 async/await 之上,都使用提取器风格的处理函数,都用 serde 处理 JSON。
核心差异如下:
- 中间件生态:Axum 直接采用 tower,与 tower/tower-http 的中间件以及 tonic 这类其他基于 tower 的工具共享。Actix 有自己的一套中间件系统。
- 运行时:Axum 直接绑定 Tokio。Actix 有一段历史是运行在自己的 actor 运行时(actix-rt,内部同样基于 Tokio)之上。
- 设计理念:Actix 正如其名,起源于 actor 模型,如今在状态管理上仍留有那种色彩。Axum 追求的是函数与 tower 服务这种更薄的抽象。
- 团队与集成:Axum 由 Tokio 团队维护,因此与 Tokio、hyper、tower 的集成非常顺畅。
为了方便比较,这里也看一下 Actix 的处理函数。可以看到它的形态与 Axum 差别不大。
use actix_web::{get, web, App, HttpServer, Responder};
#[get("/users/{id}")]
async fn get_user(path: web::Path<u64>) -> impl Responder {
let id = path.into_inner();
format!("user {id}")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(get_user))
.bind(("0.0.0.0", 3000))?
.run()
.await
}
选型标准可以归纳如下:如果重视与 tower 生态的集成、与 Tokio 的紧密程度、以及轻薄可预测的抽象,Axum 是当下自然的默认选择。如果追求久经验证的成熟度、丰富的功能,以及顶尖水准的基准测试表现,Actix Web 依然是出色的选择。不过两者在实际性能上的差距,对大多数应用而言可以忽略不计,所以按生态适配度和团队偏好来选,是更现实的做法。
结语
用 Rust 构建 Web API,如今已不再是特别的苦行,反而更接近一种愉快的体验。本文梳理的流程总结如下:
- Axum 的处理函数就是普通的
async函数,提取器以声明式的方式从请求中取出想要的东西。 - 数据库连接池这类共享资源用
Arc包起来,以状态(State)的形式注入。 - 日志、超时这类横切关注点,用 tower 中间件叠加。
- 用 serde 处理 JSON,用 sqlx 处理编译期验证的 SQL。
- 错误通过实现
IntoResponse的类型加上?运算符,优雅地转换成响应。 - Actix Web 是成熟而强大的替代方案,选择往往取决于生态适配度而非性能。
在这样一个由类型系统强制契约、编译器提前捕获大量 bug 的环境里,你的 API 从一开始就是健壮的。Rust 带来的安全性与性能,不只是系统编程的专利,在 Web 后端里同样成立。
参考资料
- Axum 官方文档 (docs.rs): https://docs.rs/axum/latest/axum/
- Axum 示例集 (GitHub): https://github.com/tokio-rs/axum/tree/main/examples
- tower / tower-http: https://docs.rs/tower-http/latest/tower_http/
- serde 官方网站: https://serde.rs/
- sqlx (GitHub): https://github.com/launchbadge/sqlx
- Actix Web 官方网站: https://actix.rs/