用 Rust 从 hyper 手搓一个 HTTP 框架:Router、Extractor、Middleware 背后的设计真相
用 axum 写业务的时候,一直觉得
async fn handler(Json(body): Json<CreateUser>)这种写法理所当然。直到想自己写一个路由,才发现——这个函数签名背后藏了多少编译期魔法。
这篇文章不是教你造一个生产级框架。市面上不需要再多一个 Rust HTTP 框架了。
我想做的是另一件事:从 hyper 开始,一层一层往上搭,搭到 Router、Extractor、Middleware,搞明白 axum 每个设计决策背后的"为什么"。 你会发现,很多你觉得"怪"的写法,其实是在 Rust 的类型系统约束下,唯一的最优解。
先交代背景:hyper 1.x 把一切都改了
如果你看过旧教程,大概率见过这段代码:
// hyper 0.14 时代的写法,现在已经不存在了
let server = Server::bind(&addr).serve(make_service);hyper 1.x 把 Server 这个东西拆掉了。现在你要自己管理连接、自己写 Service、自己处理 HTTP 协议协商。更底层了,但也更灵活了。
第一个坑:别按旧教程写。 Server::bind 在 hyper 1.x 里已经移除了,但网上大量旧教程还在用,官方 examples 才是唯一可信的参考。
现在的 hyper 最小服务器大概长这样:你拿到一个 TcpListener,accept 一个连接,把它交给 http1::Builder::new().serve_connection(io, service)。service 是一个实现了 hyper::service::Service trait 的东西,接收 Request,返回 Response。
就这么简单。但"简单"只是表象。
Router:你以为是字符串匹配,其实是类型体操
HTTP 框架最核心的功能是什么?路由。GET /users/123 找到对应的处理函数。
我一开始的想法很朴素:
// 我的第一版设计:HashMap 存路由
let mut routes: HashMap<String, Box<dyn Fn(Request) -> Response>> = HashMap::new();
routes.insert("GET /users/:id".into(), Box::new(get_user));看起来很合理对吧?但它有三个致命问题。
问题一:dyn Fn(Request) -> Response 不是 async 的。 你的 handler 里要查数据库、要调接口,必须是异步的。改成 dyn Fn(Request) -> Pin<Box<dyn Future<Output = Response>>>> ?可以,但每次调用都要 heap allocate 一个 Future,性能有损耗。
问题二:路径参数怎么提取? GET /users/123 要匹配 GET /users/:id 并且把 123 提取出来。你需要一个路径匹配器,还要决定参数放在哪里传给 handler。放 Request 的 extension 里?还是作为函数参数?
问题三:handler 的参数类型不一样。 有的 handler 需要 Path<String>,有的需要 Json<Body>,有的两个都要。你的 Box<dyn Fn> 怎么统一这些不同的签名?
这三个问题,就是 axum 设计的起点。
axum 的解法:用元组实现"可变参数泛型"
Rust 没有可变参数泛型。你不能写 fn handler(args: ...T)。
但你可以用元组。axum 的 Handler trait 大概是这样:
pub trait Handler<T, S>: Clone + Send + 'static {
type Future: Future<Output = Response> + Send;
fn call(self, state: S, req: Request) -> Self::Future;
}T 是一个元组,比如 (Path<String>, Json<CreateUser>)。axum 为各种长度的元组都实现了 Handler,所以你的函数可以接受任意数量的 extractor 参数。
这就是为什么 axum 的 handler 签名看起来那么"魔法"。 不是语法糖,是 trait 实现。
// 你写:
async fn create_user(Json(body): Json<CreateUser>) -> impl IntoResponse { ... }
// axum 看到的是:
// T = (Json<CreateUser>,)
// 为 (A,) 实现了 Handler,其中 A: FromRequest坑来了:参数顺序不是随意的。 body 类型的 extractor(Json、String、Bytes)会消费 request 的 body,所以必须放在最后。axum 在编译期就能检查这个约束——如果你把 Json 放在 State 前面,编译器会直接报错,而且报错信息非常友好。
看到这个报错的时候,觉得"这也管太多了吧"。自己写了一遍才知道:不管不行,运行时才会发现 body 被读了两次,直接 panic。
Extractor:从请求里"抠"数据的艺术
Extractor 是 axum 最精妙的设计之一。它的核心就一个 trait:
pub trait FromRequest<S>: Sized {
type Rejection: IntoResponse;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection>;
}每个 extractor 实现这个 trait,从 request 里拿自己需要的部分。Json 读 body,Path 解析路径参数,State 从共享状态里 clone 一个 Arc。
看起来简单,但自己实现的时候踩了三个坑。
坑一:body 只能读一次
hyper 的 body 是一个流(Incoming),读完就没了。Json<T> 的实现里会把 body 整个 collect 成 Bytes,然后 serde_json::from_slice。
这意味着:如果你的 handler 里有两个需要读 body 的 extractor,第二个会拿到空 body。
axum 的解决方案很聪明:它在 Request 的 extension 里缓存了已经读取的 body bytes。第一次读的时候从流里读,之后从缓存里读。但这要求 extractor 的执行顺序是确定的——所以 body 类型必须放最后。
坑二:错误类型要统一
FromRequest 的 Rejection 类型必须实现 IntoResponse,这样 extractor 失败时能自动返回合适的 HTTP 响应。
Json 失败返回 400,State 找不到返回 500,认证失败返回 401。每个 extractor 自己决定失败时返回什么状态码,handler 函数不用处理这些。
这个设计的好处是:你的 handler 里全是"快乐路径",错误处理被推到了框架层。坏处是:如果你想自定义错误消息,要写一个自己的 extractor 包一层。
坑三:泛型约束爆炸
自己写 FromRequest 的时候,最容易遇到的问题是泛型约束不够。T: DeserializeOwned 这个约束加了之后,编译器又要求 T: 'static,然后又要求 T: Send。一层一层加下去,你的 trait 定义变得很长。
经验:先写一个具体类型(比如 Json<serde_json::Value>),确认能跑通,再泛化。 不然你会被编译错误淹没。
Middleware:tower::Service 的"洋葱模型"
axum 的 middleware 基于 tower 库。tower 的核心 trait 是 Service:
pub trait Service<Request> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
fn call(&mut self, req: Request) -> Self::Future;
}注意 poll_ready。 这是最容易被忽略的函数。
很多人写 middleware 的时候,只实现 call,不管 poll_ready。功能上能跑,但在高并发下会出问题。poll_ready 是背压机制:当你的 service 处理不过来的时候,poll_ready 返回 Pending,告诉调用方"等一下,我还没准备好"。
不实现 poll_ready 的后果: 请求被无限制地接受,内存持续增长,最后 OOM 被 kill。
写第一个 middleware 的时候很容易犯这个错。一个简单的日志 middleware,记录请求耗时:
// 简化版,省略了 poll_ready
impl<S, Body> Service<Request<Body>> for LoggingMiddleware<S>
where
S: Service<Request<Body>, Response = Response>,
{
// ...
fn call(&mut self, req: Request<Body>) -> Self::Future {
let start = Instant::now();
let fut = self.inner.call(req);
async move {
let res = fut.await?;
println!("took {:?}", start.elapsed());
Ok(res)
}
}
}看起来没问题对吧?但 self.inner.call(req) 需要在 poll_ready 返回 Ready 之后才能调用。 如果你跳过 poll_ready 直接 call,tower 的文档明确说了这是"undefined behavior"(不是内存安全层面的,是逻辑层面的)。
坑的总结:写 middleware 的正确姿势是 poll_ready 转发给 inner service,call 做你的逻辑。 别偷懒。
Middleware 的组合方式
tower 用 Layer trait 来组合 middleware:
pub trait Layer<S> {
type Service;
fn layer(&self, inner: S) -> Self::Service;
}多个 middleware 叠加就是多层 Layer 嵌套。axum 的 Router::layer 方法接受一个 Layer,把它包在最外层。
这里有个坑:layer 的顺序很重要。 如果你先加了 CORS middleware,再加了认证 middleware,那么 CORS preflight 请求(OPTIONS)会先经过认证层——但 preflight 请求通常不带 token,所以会被拒绝。
正确的顺序:CORS 在外层(先处理),认证在内层(后处理)。
Body 处理:最烦但最不能偷懒的部分
hyper 1.x 的 body 类型是 Incoming,它是一个 Stream,产生 Frame<Bytes>。你要自己处理:
大小限制
所有外部输入都必须限大小。 没有例外。
有人写文件上传接口不限 body 大小,结果客户端发了一个 2GB 的请求,服务器内存直接爆了。
实现很简单:在读取 body 的循环里累加字节数,超过阈值就返回 413(Payload Too Large)。但这个逻辑放在哪里?放在每个 handler 里?太重复。放在 middleware 里?body 已经被 extractor 读了。
axum 的解法:DefaultBodyLimit 是一个 middleware,在 body 被读取之前就设置限制。 extractor 读 body 的时候会检查这个限制。这就是为什么 axum 的 body limit 配置是在 Router 层面,而不是在 handler 层面。
超时处理
客户端发了一个 body,发到一半断了(网络不好、用户关了浏览器)。你的 server 端会一直等,永远等。
解决方案:给 body 读取加超时。tokio::time::timeout 包一层就行,但要注意:超时之后要正确清理连接,不能留下半开的 socket。
流式响应
不只是请求 body,响应 body 也有讲究。如果你要返回一个大文件或者 SSE 流,不能把整个 body 构造好再返回,要流式写入。
hyper 1.x 的 body 可以是 Stream<Item = Frame<Bytes>>,你 yield 一块就发一块。但这里有个坑:如果流的某一步 .await 出错了,前面已经发出去的部分收不回来。 所以流式响应的错误处理要比同步响应更小心。
状态共享:Arc 是你唯一的选择
几乎每个 handler 都需要访问共享状态:数据库连接池、配置、缓存。
axum 的方案是 State extractor:
async fn handler(State(db): State<PgPool>) -> impl IntoResponse { ... }底层就是把状态放在 Request 的 extension 里,State 的 FromRequest 实现从 extension 里 clone 出来。
坑:clone 的是什么? 如果你的状态是一个 PgPool,clone 的是连接池的 Arc,不是连接本身,开销很小。但如果你的状态是一个 Vec<User>,clone 的是整个 Vec。状态类型必须是 Clone 的,而且 clone 开销要小。 最安全的做法是把所有状态包在 Arc 里。
写的时候容易一开始把数据库连接池直接放 State,然后发现 State 要求 Clone。好消息是 PgPool 刚好实现了 Clone(内部是 Arc)。但如果你用的是自定义类型,记得手动包 Arc。
回头看 axum:每个"怪"设计都有原因
自己搭了一遍之后,回头看 axum 的设计,突然觉得每一步都是合理的:
| 你觉得"怪"的设计 | 实际原因 |
|---|---|
| Handler 参数用元组 | Rust 没有可变参数泛型,元组是唯一解 |
| body extractor 必须放最后 | body 是流,读完就没了,必须最后消费 |
IntoResponse 而不是直接返回 Response |
允许返回 String、Json、(StatusCode, HeaderMap, Body) 等多种类型 |
| 基于 tower 而不是自己搞 middleware | tower 是 tokio 生态的标准抽象,和其他库互通 |
| 路由是类型级别的 | 编译期检查,运行时零开销 |
State 而不是全局变量 |
类型安全、可测试、避免隐式依赖 |
最后:什么时候该自己写框架?
答案是:几乎永远不需要。 axum、actix-web、poem 都是成熟的选择。
但"不需要"和"不该了解"是两回事。搭完这个 mini 框架之后,对 axum 的使用会上一个台阶:
- 编译错误看得懂了。 看到
the trait Handler<T, _> is not implemented就头大,现在知道是元组长度或者 extractor 顺序的问题。 - middleware 写得对了。 以前不理解
poll_ready,现在知道背压机制的重要性。 - 性能优化有方向了。 知道 body 是流式的,就知道为什么大文件要流式返回而不是读进内存。
- debug 有思路了。 请求卡住了?查 middleware 顺序。body 读取失败?查是不是有多个 extractor 在抢 body。
造轮子的意义不是用你造的轮子,而是理解别人的轮子为什么是圆的。
动手试试:500 行搭一个 mini 框架
整个 mini 框架的核心文件不超过 500 行。建议的顺序:
- 先跑通 hyper 的 echo server(官方 example 改一下就行)
- 加一个
HashMap路由(最朴素的方案,感受它的局限) - 实现
FromRequesttrait(先做Json,再做Path) - 写一个日志 middleware(踩
poll_ready的坑) - 加 body size limit(感受流式 body 的处理方式)
每一步你都会遇到编译错误。别跳过它们,每一个错误背后都是一个设计决策。