从零写一个跨三个时刻的中间件
主线位置:两条主线都自己实现一遍。作用点主线:
call、响应 future、poll_frame三处各有代码;结局主线:失败由一个两态的分类器判断,结束由 body 包装判断,放弃由Drop守卫判断。
stateDiagram-v2 [*] --> Future: call,守卫装进 future Future --> Body: 响应头出来,分类,守卫移进 body Future --> Abandoned: future 被 drop Future --> Done: 内层返回 Err Body --> Done: None、trailers 或 is_end_stream Body --> Abandoned: body 被 drop Done --> [*] Abandoned --> [*]
本页只讲思路和关键代码。下面的片段都摘自同一个文件,约 250 行,不依赖 tower-http,能编译、能跑:
tower-service = "0.3"
tower = { version = "=0.5.3", features = ["util"] } # 只用来在 main 里 service_fn、oneshot
tokio = { version = "=1.54.1", features = ["macros", "rt", "time"] }
http = "1"
http-body = "1"
http-body-util = "0.1"
bytes = "1"
futures = "0.3"
pin-project-lite = "0.2"
用 WrapBody 的思路从零实现一个流式 gzip body、多个中间件组合时的类型问题,以及配套作业,放在付费课程里。
失败:一个两态的分类器
tower-http 的 classify 把「看完响应头能不能下结论」做成两个变体(classify)。最小版本只要两个函数:
enum Classified {
Ready(Verdict),
RequiresEos,
}
// 5xx 当场判失败;带 `x-status: trailer` 的响应要等 trailers
fn classify_head(res: &http::response::Parts) -> Classified {
if res.status.is_server_error() {
Classified::Ready(Verdict::Failed(format!("status {}", res.status.as_u16())))
} else if res.headers.get("x-status").is_some_and(|v| v == "trailer") {
Classified::RequiresEos
} else {
Classified::Ready(Verdict::Ok)
}
}
fn classify_eos(trailers: Option<&HeaderMap>) -> Verdict {
match trailers.and_then(|t| t.get("x-result")) {
Some(v) if v == "0" => Verdict::Ok,
Some(v) => Verdict::Failed(format!("x-result {}", v.to_str().unwrap_or("?"))),
None => Verdict::Failed("no trailers".into()),
}
}
x-status / x-result 是为了演示编的,换成 grpc-status 就是 GrpcErrorsAsFailures 的形状。和 tower-http 比少了两样:没有 MakeClassifier(按请求造分类器,比如按请求方法决定规则),分类器也不是 trait,换规则要改代码。真要做成可替换的,下一步就是把这两个函数提成 trait,再给 Observe 加一个类型参数,这正是 Trace 多出来的那个 M。
放弃:一个 Drop 守卫
struct Guard {
uri: String,
start: Instant,
stage: &'static str,
done: bool,
}
impl Guard {
fn finish(&mut self, what: String) {
self.done = true;
println!(" [{}] {what} ({} ms)", self.uri, self.start.elapsed().as_millis());
}
}
impl Drop for Guard {
fn drop(&mut self) {
if !self.done {
// drop 里不要 panic:可能正在 unwind,再 panic 会 abort
println!(" [{}] abandoned during {}", self.uri, self.stage);
}
}
}
守卫默认「会报告」,正常路径主动调 finish 把它关掉,和 OnEarlyDrop 的 completed() 一个思路。反过来写(默认不报告、放弃时才标记)是做不到的:放弃的那一方根本不会调用你的代码,它只会 drop。stage 记录守卫此刻在哪,于是同一个守卫能区分「future 被丢」和「body 被丢」。
第一个时刻:call
impl<S, B, ResBody> Service<Request<B>> for Observe<S>
where
S: Service<Request<B>, Response = Response<ResBody>>,
S::Error: std::fmt::Display,
ResBody: Body,
ResBody::Error: std::fmt::Display,
{
type Response = Response<ObserveBody<ResBody>>;
type Error = S::Error;
type Future = ObserveFuture<S::Future>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<B>) -> Self::Future {
let uri = req.uri().path().to_owned();
println!(" [{uri}] call {}", req.method());
let guard = Guard { uri, start: Instant::now(), stage: "response future", done: false };
ObserveFuture { inner: self.inner.call(req), guard: Some(guard) }
}
}
和 tower-http 的每个中间件一样:poll_ready 原样转发,Error 就是 S::Error,响应 body 换成自己的包装类型(四件套)。call 里能拿到的只有请求,所以要在这里把之后需要的东西(路径、开始时间)抄下来,请求本身已经交给内层了。
第二个时刻:响应 future 完成
pin_project! {
struct ObserveFuture<F> {
#[pin]
inner: F,
guard: Option<Guard>,
}
}
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
let result = ready!(this.inner.poll(cx));
let Some(mut guard) = this.guard.take() else {
unreachable!("ObserveFuture polled after it returned Ready");
};
let res = match result {
Ok(res) => res,
Err(e) => {
guard.finish(format!("failed: service error {e}"));
return Poll::Ready(Err(e));
}
};
let (parts, body) = res.into_parts();
let eos = match classify_head(&parts) {
Classified::Ready(v) => { /* 打印结论 */ false }
Classified::RequiresEos => { /* 打印「等 trailers」 */ true }
};
guard.stage = "body";
let mut body = ObserveBody { inner: body, bytes: 0, eos, guard };
if body.inner.is_end_stream() {
body.guard.finish(end_msg(0, eos, None));
}
Poll::Ready(Ok(Response::from_parts(parts, body)))
}
三个要点:
- 守卫装在
Option里,take()出来移进 body。poll拿到的是Pin<&mut Self>,不能把字段直接搬走,Option::take是最简单的办法。InFlightRequests的 future 也是这样把计数守卫交给 body。 - 内层返回
Err不算放弃,要主动finish;否则 future 结束时守卫被 drop,会误报。 - 有的 body 一构造就已经结束(空 body,
is_end_stream()为真),调用方可能根本不会 poll 它。这里当场收尾,和OnEarlyDrop的「构造时就空」那一条一样。
第三个时刻:body 流动
fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<B::Data>, B::Error>>> {
let mut this = self.project();
if this.guard.done {
return this.inner.poll_frame(cx);
}
let frame = ready!(this.inner.as_mut().poll_frame(cx));
match &frame {
None => this.guard.finish(end_msg(*this.bytes, *this.eos, None)),
Some(Err(e)) => this.guard.finish(format!("failed: body error {e}")),
Some(Ok(f)) => {
if let Some(t) = f.trailers_ref() {
this.guard.finish(end_msg(*this.bytes, *this.eos, Some(t)));
} else if let Some(d) = f.data_ref() {
*this.bytes += d.remaining();
if this.inner.is_end_stream() {
this.guard.finish(end_msg(*this.bytes, *this.eos, None));
}
}
}
}
Poll::Ready(frame)
}
「什么算结束」有三处,少一处都会出错:
None:流结束了。- trailers:trailers 一定是最后一帧,而且
RequiresEos的结论要靠它下。 - 交出一个数据帧后
is_end_stream()为真:长度精确的 body(Full、带Content-Length的响应)交完最后一帧就报告结束,hyper 这时不会再 poll 它,等不到None。只看None的话,这种 body 读完会被守卫误报成「被丢掉」。tower-http 0.7.0 的 #687 修的正是Trace和OnEarlyDrop在这里的漏判(Trace 的内部 的「什么算结束」)。
is_end_stream 和 size_hint 原样转发给内层。这一层不改字节,没有理由让下游以为长度变了;超时一族 里 TimeoutBody 没有转发,结果 hyper 把 Content-Length 换成了 chunked。
跑一遍
五个请求:正常、503、要看 trailers 的「gRPC 式」响应、读一帧就丢掉 body、handler 要 10 秒而调用方 50 毫秒后放弃。
#[tokio::main(flavor = "current_thread")]
async fn main() {
let svc = Observe { inner: tower::service_fn(handler) };
for path in ["/ok", "/boom", "/grpc"] {
println!("GET {path}");
let res = svc.clone().oneshot(Request::get(path).body(()).unwrap()).await.unwrap();
let _ = res.into_body().collect().await;
}
println!("GET /stream, drop after one frame");
let res = svc.clone().oneshot(Request::get("/stream").body(()).unwrap()).await.unwrap();
let mut body = res.into_body();
let _ = body.frame().await;
drop(body);
println!("GET /slow, give up after 50 ms");
let _ = tokio::time::timeout(Duration::from_millis(50), svc.clone().oneshot(Request::get("/slow").body(()).unwrap())).await;
}
handler 按路径返回:/ok 是 Full("hello"),/boom 是 503,/grpc 是一个数据帧加 x-result: 13 的 trailers 并带 x-status: trailer 头,/stream 是两个数据帧的流,其余路径先睡 10 秒。M2 Max、rustc 1.99.0,跑 3 次输出相同:
GET /ok
[/ok] call GET
[/ok] head 200 -> Ok
[/ok] end of stream, 5 bytes (0 ms)
GET /boom
[/boom] call GET
[/boom] head 503 -> Failed("status 503")
[/boom] end of stream, 4 bytes (0 ms)
GET /grpc
[/grpc] call GET
[/grpc] head 200 -> wait for trailers
[/grpc] end of stream, 3 bytes, Failed("x-result 13") (0 ms)
GET /stream, drop after one frame
[/stream] call GET
[/stream] head 200 -> Ok
[/stream] abandoned during body
GET /slow, give up after 50 ms
[/slow] call GET
[/slow] abandoned during response future
/ok是Full,交出唯一一帧后is_end_stream()就为真,靠第三种条件收尾。/grpc的状态码是 200,结论推迟到 trailers:失败。- 后两个请求是两种放弃:一个守卫已经在 body 里,一个还在 future 里。
tokio::time::timeout到点后丢掉了oneshot的 future,连带丢掉ObserveFuture和里面的守卫。
和 tower-http 的对照
| 这里 | tower-http |
|---|---|
classify_head / classify_eos 两个函数 | MakeClassifier → ClassifyResponse → ClassifyEos 三个 trait |
一个守卫,stage 区分阶段 | OnEarlyDrop 两个回调工厂,响应头出来时再造一级 |
println! | Trace 的六个回调,各是一个类型参数 |
| 守卫从 future 移进 body | InFlightRequests 的计数守卫同样如此 |
没有 Default,所以也不能短路 | 短路的层要求 body Default,或者自带一个枚举变体 |
还可以接着做的:把分类器提成 trait;加一个短路(比如按路径直接回 404),这时会遇到 ResponseFuture 要用枚举同时装「内层 future」和「现成响应」的问题;把守卫的回调换成用户传进来的闭包,看看为什么 tower-http 要把它做成工厂。
小结
- 三个时刻各一段代码:
call抄下请求里之后要用的东西,future 完成时分类并把守卫移进 body,poll_frame数字节、判断结束。 - 结束有三处:
None、trailers、交出一帧后is_end_stream()为真;漏掉最后一处,长度精确的 body 会被误报成放弃。 - 放弃靠守卫的
Drop,正常路径要主动关掉它,内层出错也要关;Drop里不 panic。