Skip to content
Published on

用 Rust 构建 Web API:Axum 与 Actix

分享
Authors

引言 — 为什么用 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。这样一来,towertower-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_optionalOption 表达结果可能不存在,多行的情况用 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 后端里同样成立。

参考资料