返回文章列表

用 Rust 从 hyper 手搓一个 HTTP 框架:Router、Extractor、Middleware 背后的设计真相

689·4 分钟阅读
RustHTTPhyperWeb 框架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(JsonStringBytes)会消费 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 类型必须放最后。

坑二:错误类型要统一

FromRequestRejection 类型必须实现 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 里,StateFromRequest 实现从 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 允许返回 StringJson(StatusCode, HeaderMap, Body) 等多种类型
基于 tower 而不是自己搞 middleware tower 是 tokio 生态的标准抽象,和其他库互通
路由是类型级别的 编译期检查,运行时零开销
State 而不是全局变量 类型安全、可测试、避免隐式依赖

最后:什么时候该自己写框架?

答案是:几乎永远不需要。 axum、actix-web、poem 都是成熟的选择。

但"不需要"和"不该了解"是两回事。搭完这个 mini 框架之后,对 axum 的使用会上一个台阶:

  1. 编译错误看得懂了。 看到 the trait Handler<T, _> is not implemented 就头大,现在知道是元组长度或者 extractor 顺序的问题。
  2. middleware 写得对了。 以前不理解 poll_ready,现在知道背压机制的重要性。
  3. 性能优化有方向了。 知道 body 是流式的,就知道为什么大文件要流式返回而不是读进内存。
  4. debug 有思路了。 请求卡住了?查 middleware 顺序。body 读取失败?查是不是有多个 extractor 在抢 body。

造轮子的意义不是用你造的轮子,而是理解别人的轮子为什么是圆的。


动手试试:500 行搭一个 mini 框架

整个 mini 框架的核心文件不超过 500 行。建议的顺序:

  1. 先跑通 hyper 的 echo server(官方 example 改一下就行)
  2. 加一个 HashMap 路由(最朴素的方案,感受它的局限)
  3. 实现 FromRequest trait(先做 Json,再做 Path
  4. 写一个日志 middleware(踩 poll_ready 的坑)
  5. 加 body size limit(感受流式 body 的处理方式)

每一步你都会遇到编译错误。别跳过它们,每一个错误背后都是一个设计决策。