axum 自定义 Extractor Rejection 的三种实现方案:WithRejection、FromRequest 派生宏与手动实现
【免费下载链接】axumHTTP routing and request-handling library for Rust that focuses on ergonomics and modularity项目地址: https://gitcode.com/GitHub_Trending/ax/axum
在 axum 中,内置提取器(如Json、Path、Query)失败时会返回框架预设的 rejection 类型(例如JsonRejection),这些 rejection 只能渲染成固定的错误文本,无法直接返回结构化的 JSON 错误响应。本指南以官方示例 examples/customize-extractor-error 为骨架,系统讲解三种为已有提取器定制 rejection 的方案:基于axum_extra::extract::WithRejection的包装方案、基于FromRequest派生宏的方案、以及完全手动实现FromRequest的方案。读完本文,你将掌握每种方案的代码写法、依赖配置、适用场景与取舍,并能在自己的 axum 服务中直接落地统一的错误响应格式。
背景:为什么需要自定义 Rejection
axum 的每个提取器都关联一个 rejection 类型,例如Json提取器的失败类型是JsonRejection(定义见 axum/src/extract/rejection.rs),它是一个由composite_rejection!宏生成的组合枚举:
pub enum JsonRejection { JsonDataError, // JSON 数据不符合目标结构体 JsonSyntaxError, // JSON 语法错误 MissingJsonContentType, // 请求缺少 application/json 头 BytesRejection, // 读取 body 字节失败 }这类 rejection 虽然能直接用作响应(实现了IntoResponse),但它的输出格式固定、可读性一般。在实际 API 服务中,我们通常希望所有错误都返回统一结构的 JSON 载荷(例如{"message": "...", "origin": "..."}),并附带正确的 HTTP 状态码。这便需要对"已有提取器"的 rejection 进行定制——这正是示例 examples/customize-extractor-error 要解决的问题。
该示例在同一个应用中注册了三个路由,分别演示三种方案(见 examples/customize-extractor-error/src/main.rs):
let app = Router::new() .route("/with-rejection", post(with_rejection::handler)) .route("/custom-extractor", post(custom_extractor::handler)) .route("/derive-from-request", post(derive_from_request::handler));其依赖配置(见 examples/customize-extractor-error/Cargo.toml)明确揭示了三条技术路线所需的基础设施:
[dependencies] axum = { path = "../../axum", features = ["macros"] } # 方案二需要 macros 特性(FromRequest 派生宏) axum-extra = { path = "../../axum-extra", features = ["with-rejection"] } # 方案一需要 with-rejection 特性 serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2" # 方案一依赖 thiserror 生成 From 转换 tokio = { version = "1.20", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] }方案一:WithRejection包装提取器(推荐入门)
WithRejection<E, R>是axum-extra提供的一个通用提取器包装类型,核心思路是:原样执行被包裹的提取器E,若失败则将 rejection 通过From转换为你指定的类型R,再由R的IntoResponse实现渲染响应。
源码层面的工作机制
从 axum-extra/src/extract/with_rejection.rs 可以看到其类型签名与约束:
pub struct WithRejection<E, R>(pub E, pub PhantomData<R>); impl<E, R, S> FromRequest<S> for WithRejection<E, R> where S: Send + Sync, E: FromRequest<S>, R: From<E::Rejection> + IntoResponse, { type Rejection = R; async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { let extractor = E::from_request(req, state).await?; Ok(Self(extractor, PhantomData)) } }要点如下:
E是任意实现了FromRequest的提取器(例如Json<Value>);R必须同时满足From<E::Rejection>(把原始 rejection 转成自定义类型)与IntoResponse(能够作为响应返回);- 第二个泛型参数
R仅用于类型层面的标记,PhantomData占位,不携带运行时数据; WithRejection还实现了Deref/DerefMut(委托给内部的E)、FromRequestParts(用于仅消费请求头部的提取器)以及into_inner()方法,便于取出被包装的提取器;- 仓库自带测试
extractor_rejection_is_transformed(axum-extra/src/extract/with_rejection.rs)验证了当内部提取器返回Err时,WithRejection确实返回转换后的自定义 rejection。
完整写法
示例代码见 examples/customize-extractor-error/src/with_rejection.rs:
use axum::{extract::rejection::JsonRejection, response::IntoResponse, Json}; use axum_extra::extract::WithRejection; use serde_json::{json, Value}; use thiserror::Error; pub async fn handler( // 正常时取出 Json<Value>;失败时 JsonRejection 会被转换为 ApiError 返回给客户端 // 第二个构造参数没有实际意义,可以安全忽略 WithRejection(Json(value), _): WithRejection<Json<Value>, ApiError>, ) -> impl IntoResponse { Json(dbg!(value)) } // 借助 thiserror 的 #[from] 属性自动生成 From<JsonRejection> for ApiError #[derive(Debug, Error)] pub enum ApiError { #[error(transparent)] JsonExtractorRejection(#[from] JsonRejection), } // 实现 IntoResponse,让 ApiError 能够渲染为统一结构的 JSON 响应 impl IntoResponse for ApiError { fn into_response(self) -> axum::response::Response { let (status, message) = match self { ApiError::JsonExtractorRejection(json_rejection) => { (json_rejection.status(), json_rejection.body_text()) } }; let payload = json!({ "message": message, "origin": "with_rejection" }); (status, Json(payload)).into_response() } }优缺点
优点:
- 学习曲线平缓:
WithRejection只是对已有提取器的包装,无需理解FromRequest的内部实现细节,也不需要为每个自定义 rejection 新建提取器; - 转换开销极小:只需在原始 rejection 类型与目标 rejection 之间提供一个
From实现,thiserror的#[from]派生属性可以自动生成该实现(见 with_rejection.rs 第 33-39 行); - 保留了原提取器的所有能力(
Deref委托),如MatchedPath等头部提取器同样可用。
缺点:
- 类型冗长:
WithRejection<Json<Value>, ApiError>这类嵌套类型会让函数签名变长、可读性下降; - 无法解构类型别名:由于当前 Rust 对类型别名的限制,无法直接对
type JsonWithApiError = WithRejection<Json<Value>, ApiError>这类别名做模式解构(let WithRejection(json, _) = ...在别名上不可用),详见 axum issue #1116 的讨论(该限制记录在 with_rejection.rs 的注释中)。
方案二:FromRequest派生宏(样板最少)
axum 的macros特性提供了#[derive(FromRequest)]派生宏,可以声明式地为自定义类型生成FromRequest实现。核心属性是:
#[from_request(via(...))]:指定内部委托的提取器,例如via(axum::Json),表示当前类型通过axum::Json来提取;#[from_request(rejection(...))]:指定自定义 rejection 类型,例如rejection(ApiError),框架会通过From把内部提取器的 rejection 转换过去。
从 axum-macros/src/from_request/mod.rs 的源码可以确认:派生宏在生成代码时会将提取结果通过into_inner解包,并把失败映射为<#rejection as From<_>>::from(...),即"原 rejection 必须能From转换到自定义 rejection 类型"。同时该宏存在已知限制(详见FromRequest派生宏文档的 Known Limitations 一节,如不支持解引用泛型、对无via的复杂泛型有限制等),使用前建议查阅。
完整写法
示例代码见 examples/customize-extractor-error/src/derive_from_request.rs:
use axum::{ extract::rejection::JsonRejection, extract::FromRequest, http::StatusCode, response::IntoResponse, }; use serde::Serialize; use serde_json::{json, Value}; pub async fn handler(Json(value): Json<Value>) -> impl IntoResponse { Json(dbg!(value)) } // 创建一个内部使用 axum::Json、但 rejection 完全自定义的提取器 #[derive(FromRequest)] #[from_request(via(axum::Json), rejection(ApiError))] pub struct Json<T>(T); // 让自定义提取器也能作为响应使用 impl<T: Serialize> IntoResponse for Json<T> { fn into_response(self) -> axum::response::Response { let Self(value) = self; axum::Json(value).into_response() } } // 自定义 rejection 类型 #[derive(Debug)] pub struct ApiError { status: StatusCode, message: String, } // 手动提供 From<JsonRejection> for ApiError impl From<JsonRejection> for ApiError { fn from(rejection: JsonRejection) -> Self { Self { status: rejection.status(), message: rejection.body_text(), } } } impl IntoResponse for ApiError { fn into_response(self) -> axum::response::Response { let payload = json!({ "message": self.message, "origin": "derive_from_request" }); (self.status, axum::Json(payload)).into_response() } }优缺点
优点:
- 样板代码最少:只要一行
#[derive(FromRequest)]加两行属性,就能生成完整的FromRequest实现,无需手写from_request函数体; - 转换规则集中:与方案一相同,只需提供
From<原Rejection> for 自定义Rejection,thiserror的#[from]同样可以自动生成; - 提取器本身可复用:自定义的
Json<T>同时实现了FromRequest与IntoResponse,可以在路由与处理器中反复使用。
缺点:
- 每个自定义 rejection 都要派生一次:如果有很多提取器需要定制错误,需要为每个类型都加
#[derive(FromRequest)],存在重复样板; - 存在已知限制:派生宏对泛型、解引用、以及某些复杂结构有约束,遇到特殊场景可能无法满足(这些限制在 axum-macros 的派生宏文档 Known Limitations 中有详细说明);
- 响应式的
IntoResponse仍需手动实现(宏只负责FromRequest部分)。
方案三:手动实现FromRequest(最强大、最灵活)
当需要完全掌控提取流程、在提取失败时附加额外上下文(比如当前匹配的路由路径),或者需要组合多个提取器时,可以绕开一切宏,直接为自定义类型手写FromRequest实现。
核心机制
axum 的FromRequesttrait 签名如下:
pub trait FromRequest<S>: Sized { type Rejection: IntoResponse; async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection>; }手动实现时,你可以:
- 将
Request拆分为(Parts, Body)(req.into_parts()),在提取 body 之前先消费Parts中的元数据(例如MatchedPath、Method、Extension等); - 通过
RequestPartsExt的extract方法使用其他提取器来丰富错误信息; - 用
Request::from_parts(parts, body)重组请求后再交给内部提取器; - 自由决定
Rejection的具体类型——甚至可以直接用元组(StatusCode, Json<Value>),因为它实现了IntoResponse,省去自定义 rejection 类型。
完整写法
示例代码见 examples/customize-extractor-error/src/custom_extractor.rs:
use axum::{ extract::{rejection::JsonRejection, FromRequest, MatchedPath, Request}, http::StatusCode, response::IntoResponse, RequestPartsExt, }; use serde_json::{json, Value}; pub async fn handler(Json(value): Json<Value>) -> impl IntoResponse { Json(dbg!(value)); } // 自定义 Json 提取器,定制 axum::Json 的错误输出 pub struct Json<T>(pub T); impl<S, T> FromRequest<S> for Json<T> where axum::Json<T>: FromRequest<S, Rejection = JsonRejection>, S: Send + Sync, { // 直接使用 (StatusCode, Json<Value>) 作为 rejection,它本身实现了 IntoResponse type Rejection = (StatusCode, axum::Json<Value>); async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { let (mut parts, body) = req.into_parts(); // 在消费 body 之前,先从 parts 中取出 MatchedPath,用于生成更友好的错误信息 // 注意:必须先执行这一步,因为 Json 提取会消耗掉请求 let path = parts .extract::<MatchedPath>() .await .map(|path| path.as_str().to_owned()) .ok(); let req = Request::from_parts(parts, body); match axum::Json::<T>::from_request(req, state).await { Ok(value) => Ok(Self(value.0)), // 把 axum::Json 的 rejection 转换成我们想要的任何格式 Err(rejection) => { let payload = json!({ "message": rejection.body_text(), "origin": "custom_extractor", "path": path, }); Err((rejection.status(), axum::Json(payload))) } } } }这个例子很好地展示了手动实现相比前两种方案的优势:在失败响应中携带了path字段(当前匹配到的路由路径),这是仅靠From转换无法轻易做到的——因为转换发生在Json提取失败之后,而MatchedPath必须在消费 body 前从Parts中读取。代码注释中的"Have to run that first sinceJsonextraction consumes the request"正是这一时序约束的说明。
优缺点
优点:
- API 最强大:直接访问
RequestParts与async/await,可以在提取流程中做任意前置/后置处理,构造更丰富的 rejection(如携带路径、方法、请求 ID 等上下文); - Rejection 类型自由:可以用元组类型、自定义类型,甚至直接返回
(StatusCode, Json<Value>),无需单独定义 rejection 结构体; - 完全可控:不依赖宏,不受派生宏限制,适用于复杂业务场景。
缺点:
- 样板代码多:每个需要定制错误的提取器都要手写一份
impl FromRequest; - 复杂度高:需要理解
Request/Parts/Body的生命周期与消费顺序(如"先提取 parts 元数据、再重组请求"的时序),出错时排查成本更高。
三种方案横向对比
| 维度 | WithRejection | FromRequest派生宏 | 手动实现FromRequest |
|---|---|---|---|
| 所需依赖/特性 | axum-extra的with-rejection特性 | axum的macros特性 | 无额外依赖 |
| 学习曲线 | 低:包装即用 | 低:声明式属性 | 高:需理解 trait 与请求消费时序 |
| 样板代码 | 少(只需From+IntoResponse) | 最少(宏生成提取实现) | 多(每个提取器手写) |
| 可定制程度 | 中:仅能转换 rejection 类型 | 中:可定制 rejection,受宏限制约束 | 高:可访问RequestParts、组合任意提取器、附加上下文 |
| 错误信息丰富度 | 限于原 rejection 的status()/body_text() | 同上 | 可附加MatchedPath等额外上下文 |
| 典型场景 | 快速为内置提取器统一错误格式 | 需要可复用的自定义提取器 + 定制错误 | 复杂业务错误响应、需要额外上下文 |
三个路由的完整示例分别位于:
- examples/customize-extractor-error/src/with_rejection.rs(
/with-rejection) - examples/customize-extractor-error/src/derive_from_request.rs(
/derive-from-request) - examples/customize-extractor-error/src/custom_extractor.rs(
/custom-extractor)
三种方案输出的错误响应都保持统一结构(message+origin字段),区别仅在origin的值,这正说明了"定制 rejection"的最终目标——无论采用哪种实现,对外部客户端而言,错误响应格式应当一致。
运行与验证
在仓库根目录执行(见 examples/customize-extractor-error/README.md):
cargo run -p example-customize-extractor-error服务默认监听127.0.0.1:3000。你可以分别向三个端点发送非法 JSON 来观察三种方案产生的错误响应,例如:
curl -X POST http://127.0.0.1:3000/with-rejection \ -H "content-type: application/json" \ -d "not-valid-json"对比之下,方案三的响应中会额外包含path字段(值为/custom-extractor),直观展示了手动实现方案在错误上下文丰富度上的优势。三个端点的正常请求则会回显解析后的 JSON 值(处理器通过dbg!输出)。
结语
axum 的提取器体系以FromRequesttrait 为中心,而三种定制 rejection 的方式恰好对应了"声明式包装、声明式派生、命令式手写"三个抽象层级:
- 追求快速落地、统一错误格式:优先选择
WithRejection(axum-extra); - 需要可复用的自定义提取器、且结构不复杂:使用
#[derive(FromRequest)](axum的macros特性); - 需要最大灵活度、携带额外错误上下文:手动实现
FromRequest。
三者共用同一套底层机制——From<E::Rejection>转换与IntoResponse渲染——这也意味着你可以在不同模块中混合使用三种方案,只要最终输出保持一致的响应契约即可。当你的 API 需要对外提供稳定、可机器解析的错误结构时,本文的三种模式将是最直接的落地参考。
【免费下载链接】axumHTTP routing and request-handling library for Rust that focuses on ergonomics and modularity项目地址: https://gitcode.com/GitHub_Trending/ax/axum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考