跳转到正文
zeno's blog
返回

axum(二):Handler 与 Extractor-编译期请求解析的魔法

专题: axum

Table of contents

Open Table of contents

TL;DR

axum 的 handler 是普通 async fn,参数类型决定如何从 HTTP 请求中提取数据。FromRequestParts 提取头部/路径/query(不消耗 body),FromRequest 提取 body(只能有一个,必须放最后)。所有验证在编译期完成——类型不匹配编译器直接拒绝,不等到运行时 500。这背后是 all_the_tuples! 宏为 0~16 个参数生成的 17 个 Handler trait blanket impl。


Handler trait:async fn 如何变成请求处理器

trait 定义

pub trait Handler<T, S>: Clone + Send + Sized + 'static {
    type Future: Future<Output = Response> + Send + 'static;

    fn call(self, req: Request, state: S) -> Self::Future;
}

类型参数 T 是一个标记类型(marker type),用于解决 Rust 的 trait coherence 限制——没有它,编译器无法区分 Fn(A) -> XFn(A, B) -> Y 的不同 blanket impl。

编译器看到的

当你写:

async fn get_user(
    Path(id): Path<u32>,
    State(db): State<PgPool>,
    Json(body): Json<UpdateUser>,
) -> impl IntoResponse {
    // ...
}

编译器做了什么:

  1. all_the_tuples! 宏生成了 impl Handler<(M, T1, T2, T3), S> 用于 3 参数函数
  2. 对于前 N-1 个参数(Path<u32>, State<PgPool>),要求实现 FromRequestParts<S>
  3. 对于最后一个参数(Json<UpdateUser>),要求实现 FromRequest<S, M>
  4. 返回类型要求实现 IntoResponse
  5. 函数本身要求 Clone + Send + 'static

生成的代码(概念上):

// 编译器为这个 handler 生成的调用路径
fn call(self, req: Request, state: S) -> Self::Future {
    async move {
        let (mut parts, body) = req.into_parts();

        // 逐个提取 FromRequestParts 参数
        let t1 = match Path::<u32>::from_request_parts(&mut parts, &state).await {
            Ok(v) => v,
            Err(rejection) => return rejection.into_response(),
        };
        let t2 = match State::<PgPool>::from_request_parts(&mut parts, &state).await {
            Ok(v) => v,
            Err(rejection) => return rejection.into_response(),
        };

        // 重新组装请求,提取 FromRequest 参数(消耗 body)
        let req = Request::from_parts(parts, body);
        let t3 = match Json::<UpdateUser>::from_request(req, &state).await {
            Ok(v) => v,
            Err(rejection) => return rejection.into_response(),
        };

        // 调用用户函数
        self(t1, t2, t3).await.into_response()
    }
}

全部在编译期单态化——零运行时分发开销。

双 trait 架构:FromRequestParts vs FromRequest

// 不消耗 body,可以在任意位置
pub trait FromRequestParts<S>: Sized {
    type Rejection: IntoResponse;
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection>;
}

// 消耗 body,只能作为最后一个参数
pub trait FromRequest<S, M = private::ViaRequest>: Sized {
    type Rejection: IntoResponse;
    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection>;
}

FromRequest 的第二个类型参数 M 是 marker,用于实现一个 blanket impl:

// 所有 FromRequestParts 自动也是 FromRequest(通过 ViaParts marker)
impl<S, T> FromRequest<S, private::ViaParts> for T
where T: FromRequestParts<S>

这让 FromRequestParts 的类型也能出现在最后一个参数位置。

为什么 body 只能消耗一次

HTTP 请求的 body 是一个异步流(stream),读完就没了。FromRequest 拿到完整的 Request(包含 body),提取后 body 被消耗。如果允许两个 FromRequest 参数,第二个拿到的是空 body。

axum 用类型系统在编译期强制这个约束:前 N-1 个参数必须是 FromRequestParts(只能访问 headers/URI/method,不碰 body),最后一个可以是 FromRequest

内置 Extractor 全表

FromRequestParts(不消耗 body,可以在任意位置)

Extractor提取内容示例
Path<T>URL 路径参数Path(id): Path<u32>
Query<T>查询字符串 ?key=valQuery(params): Query<SearchParams>
HeaderMap所有请求头headers: HeaderMap
MethodHTTP 方法method: Method
Uri完整 URIuri: Uri
VersionHTTP 版本version: Version
State<T>应用状态State(db): State<PgPool>
Extension<T>请求级扩展数据Extension(user): Extension<AuthUser>
ConnectInfo<T>客户端连接信息ConnectInfo(addr): ConnectInfo<SocketAddr>
MatchedPath匹配的路由模式path: MatchedPath"/users/{id}"
NestedPath嵌套路由前缀prefix: NestedPath
OriginalUrinest 剥离前的 URIuri: OriginalUri
RawQuery未解析的查询字符串query: RawQuery
WebSocketUpgradeWebSocket 升级句柄ws: WebSocketUpgrade

FromRequest(消耗 body,必须在最后)

Extractor提取内容Content-Type
Json<T>JSON body → 反序列化application/json
Form<T>URL-encoded bodyapplication/x-www-form-urlencoded
Stringbody → UTF-8 字符串任意
Bytesbody → 原始字节任意
Multipart多部分表单/文件上传multipart/form-data
Request完整请求任意

body 大小限制

body 提取器默认拒绝超过 2MB 的请求体:

use axum::extract::DefaultBodyLimit;

let app = Router::new()
    .route("/upload", post(upload))
    .layer(DefaultBodyLimit::max(50 * 1024 * 1024))  // 50MB
    // 或者完全禁用限制(危险)
    // .layer(DefaultBodyLimit::disable())

自定义 Extractor

只读元数据(FromRequestParts)

struct ExtractUserAgent(HeaderValue);

impl<S> FromRequestParts<S> for ExtractUserAgent
where
    S: Send + Sync,
{
    type Rejection = (StatusCode, &'static str);

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        parts.headers
            .get(USER_AGENT)
            .map(|v| ExtractUserAgent(v.clone()))
            .ok_or((StatusCode::BAD_REQUEST, "Missing User-Agent"))
    }
}

消耗 body(FromRequest)

struct ValidatedJson<T>(pub T);

impl<S, T> FromRequest<S> for ValidatedJson<T>
where
    T: DeserializeOwned + Validate,
    S: Send + Sync,
{
    type Rejection = (StatusCode, String);

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req, state)
            .await
            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;

        value.validate()
            .map_err(|e| (StatusCode::UNPROCESSABLE_ENTITY, e.to_string()))?;

        Ok(ValidatedJson(value))
    }
}

也可以用派生宏:#[derive(FromRequest)]#[derive(FromRequestParts)]

Rejection:提取失败如何变成 HTTP 响应

每个 extractor 有一个关联的 Rejection 类型,实现了 IntoResponse。提取失败时 rejection 自动转换为 HTTP 响应。

显式处理 rejection

Result 包装 extractor 来拦截失败:

async fn handler(payload: Result<Json<Value>, JsonRejection>) -> impl IntoResponse {
    match payload {
        Ok(Json(value)) => (StatusCode::OK, Json(value)).into_response(),
        Err(JsonRejection::MissingJsonContentType(_)) => {
            (StatusCode::UNSUPPORTED_MEDIA_TYPE, "Expected application/json").into_response()
        }
        Err(JsonRejection::JsonSyntaxError(e)) => {
            (StatusCode::BAD_REQUEST, format!("Invalid JSON: {e}")).into_response()
        }
        Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
    }
}

Option 包装使提取可选(失败返回 None)。

调试 rejection:RUST_LOG=axum::rejection=trace

响应系统:IntoResponse

pub trait IntoResponse {
    fn into_response(self) -> Response<Body>;
}

内置实现

类型状态码Content-Type
()200空 body
&'static str200text/plain; charset=utf-8
String200text/plain; charset=utf-8
Vec<u8>200application/octet-stream
Json<T>200application/json
Html<T>200text/html
StatusCode给定值空 body
Redirect3xxLocation header
NoContent204空 body(0.8 新增)

Tuple 组合

axum 为元组实现了 IntoResponse(最多 17 元素):

// 状态码 + body
async fn created() -> (StatusCode, String) {
    (StatusCode::CREATED, "created".to_string())
}

// 状态码 + 自定义头 + body
async fn with_headers() -> (StatusCode, [(HeaderName, &'static str); 1], Json<Value>) {
    (
        StatusCode::OK,
        [(CONTENT_TYPE, "application/json")],
        Json(json!({"ok": true})),
    )
}

规则:第一个元素可以是 StatusCode;中间元素实现 IntoResponseParts(headers、extensions);最后一个元素实现 IntoResponse(body)。

自定义响应类型

struct ApiResponse<T: Serialize> {
    status: StatusCode,
    data: T,
}

impl<T: Serialize> IntoResponse for ApiResponse<T> {
    fn into_response(self) -> Response<Body> {
        (self.status, Json(json!({ "data": self.data }))).into_response()
    }
}

Result 作为响应

Result<T, E>T: IntoResponseE: IntoResponse 时自动实现 IntoResponse

async fn get_user(Path(id): Path<i64>) -> Result<Json<User>, AppError> {
    let user = find_user(id).await.map_err(AppError::Database)?;
    Ok(Json(user))
}

分享这篇文章:

上一篇
可观测性(一):三大支柱-Logs、Traces、Metrics
下一篇
axum(一):设计哲学-类型驱动的零成本 Web 框架