首页/文章/Rust
Rust

Axum 构建 REST API

用 Axum 的 Router、State、Extractor 和 IntoResponse 组织一个可运行的 Rust API 骨架,再把存储与错误边界推进到生产设计。

Axum 建立在 Tokio、Hyper 与 Tower 生态上,handler 负责把 HTTP 输入转换为领域操作和响应。它强调类型化提取器与组合式路由:路径 ID、JSON、共享状态和错误响应都通过函数签名表达。本文实现一个内存版任务 API,重点展示 API 的边界;生产环境需要把内存 map 换成数据库 repository,并补上认证、授权、迁移和可观测性。

请求经过哪些层

flowchart LR
  client[HTTP 客户端] --> router[Axum Router]
  router --> middleware[Tower 中间件:trace / timeout / auth]
  middleware --> extractor[Path / Query / Json / State 提取]
  extractor --> handler[Handler:参数校验与响应映射]
  handler --> service[应用服务 / 领域规则]
  service --> repo[Repository Trait]
  repo --> db[(PostgreSQL / Redis)]

Route 负责把 method + path 映射到 handler;extractor 负责解析请求部件;应用服务执行业务规则;repository 隔离持久化。这个拆分让 handler 不至于同时处理 HTTP、SQL 和业务事务。

最小依赖

[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync"] }
uuid = { version = "1", features = ["serde", "v4"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }

Axum 0.8 使用 /{id} 形式的路径参数。若项目仍在 Axum 0.7,需要按该版本路由语法调整,不要把不同大版本的示例混在同一份代码里。

用 State 共享依赖,用 extractor 声明输入

这个示例用 Tokio RwLock<HashMap<...>> 代替数据库,代码可聚焦在 handler 组合上:

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
use uuid::Uuid;

#[derive(Clone, Default)]
struct AppState {
    tasks: Arc<RwLock<HashMap<Uuid, Task>>>,
}

#[derive(Clone, Serialize)]
struct Task {
    id: Uuid,
    title: String,
    completed: bool,
}

#[derive(Deserialize)]
struct CreateTask {
    title: String,
}

enum ApiError {
    NotFound,
    InvalidInput(&'static str),
    Internal,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "task not found"),
            ApiError::InvalidInput(message) => (StatusCode::BAD_REQUEST, message),
            ApiError::Internal => {
                tracing::error!("unexpected internal API error");
                (StatusCode::INTERNAL_SERVER_ERROR, "internal server error")
            }
        };
        (status, Json(serde_json::json!({ "error": message }))).into_response()
    }
}

async fn list_tasks(State(state): State<AppState>) -> Json<Vec<Task>> {
    let tasks = state.tasks.read().await;
    Json(tasks.values().cloned().collect())
}

async fn create_task(
    State(state): State<AppState>,
    Json(input): Json<CreateTask>,
) -> Result<(StatusCode, Json<Task>), ApiError> {
    let title = input.title.trim();
    if title.is_empty() || title.len() > 160 {
        return Err(ApiError::InvalidInput("title must contain 1 to 160 bytes"));
    }

    let task = Task { id: Uuid::new_v4(), title: title.to_owned(), completed: false };
    state.tasks.write().await.insert(task.id, task.clone());
    Ok((StatusCode::CREATED, Json(task)))
}

async fn get_task(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<Task>, ApiError> {
    state.tasks.read().await.get(&id).cloned().map(Json).ok_or(ApiError::NotFound)
}

async fn delete_task(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
    state.tasks.write().await.remove(&id).map(|_| StatusCode::NO_CONTENT).ok_or(ApiError::NotFound)
}

fn app(state: AppState) -> Router {
    Router::new()
        .route("/health", get(|| async { StatusCode::NO_CONTENT }))
        .route("/api/tasks", get(list_tasks).post(create_task))
        .route("/api/tasks/{id}", get(get_task).delete(delete_task))
        .with_state(state)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt().init();
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app(AppState::default()))
        .with_graceful_shutdown(async { let _ = tokio::signal::ctrl_c().await; })
        .await?;
    Ok(())
}

输入长度检查用字节数,若产品规则按 Unicode 字符计数,应改用 chars().count() 或在领域层统一验证。

提取器的参数顺序有约束:读取路径、query、state 等 request parts 的 extractor 放前面;消费 body 的 Json<T> 放最后,因为请求 body 只能消费一次。Path<Uuid> 无法解析时会由 extractor 返回拒绝响应,handler 不会运行。

错误响应要稳定,内部详情留在日志

对外返回稳定的错误码和消息,不要把 SQL 错误、连接字符串或堆栈放进 JSON。把数据库错误映射到领域错误,再由 IntoResponse 转为 HTTP 状态;日志应携带 request ID,并避免记录令牌、密码或完整敏感 payload。

生产接口还应显式定义分页(limit + cursor)、幂等键、请求体大小限制、速率限制、超时、认证授权和 API 版本策略。即使类型安全能拒绝格式错误的 UUID,也不能替代权限检查:知道某个 ID 不代表有权读取它。

从内存 map 迁移到数据库

AppState 常保存连接池和配置,而不是为每个请求新建连接。把 handler 中的 HashMap 操作移到 TaskRepository trait,再注入 PostgreSQL 实现:

enum RepoError { Unavailable }

trait TaskRepository: Send + Sync {
    async fn list(&self, limit: u32, after: Option<Uuid>) -> Result<Vec<Task>, RepoError>;
    async fn find(&self, id: Uuid) -> Result<Option<Task>, RepoError>;
    async fn insert(&self, title: String) -> Result<Task, RepoError>;
    async fn delete(&self, id: Uuid) -> Result<bool, RepoError>;
}

接口的 Send + Sync 约束有助于在 Tokio 多线程 runtime 中安全共享状态。真实项目可用 sqlx::PgPool,在 repository 层显式处理唯一约束、事务、分页和连接池超时。不要把 std::sync::Mutex 持有过 .await;如果临界区只做很短的内存操作,同步锁可以合适,但数据库/网络等待应用 async 连接池和事务。

常见踩坑

  1. 在 handler 里直接写所有 SQL 和规则:短期省文件,后面测试和事务边界难维护。提取应用服务与 repository。
  2. Router 状态类型拼不起来:组合子 router 时统一 Router<AppState>,在最外层 .with_state(state);Router<S> 表示还缺少 S 状态。
  3. 把 body extractor 放到 Path 前面:body 是单次流,消费 body 的 extractor 应放最后。
  4. 将锁 guard 带过 .await:会阻塞 runtime worker 或导致 Send 约束失败;缩短临界区,把 I/O 放到锁外。
  5. 把任意内部错误返回给客户端:既泄漏实现细节,也让 API 客户端绑定数据库文案。定义稳定错误映射。
  6. 用内存 map 当生产存储:多进程各自一份数据,重启就清空;示例替换成持久化 repository。
  7. 误以为 Result 自动成为 500:Axum handler 的错误类型须转换为响应(如 IntoResponse),且应区分 4xx 业务拒绝和 5xx 服务失败。

参考资料

文章反馈

如果这篇文章对你有帮助,欢迎发邮件交流。

通过邮箱联系 ↗继续阅读 →