Rust in Everything
基于 tower-http 0.7.1

从零写一个跨三个时刻的中间件

主线位置:两条主线都自己实现一遍。作用点主线: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 移进 bodyInFlightRequests 的计数守卫同样如此
没有 Default,所以也不能短路短路的层要求 body Default,或者自带一个枚举变体

还可以接着做的:把分类器提成 trait;加一个短路(比如按路径直接回 404),这时会遇到 ResponseFuture 要用枚举同时装「内层 future」和「现成响应」的问题;把守卫的回调换成用户传进来的闭包,看看为什么 tower-http 要把它做成工厂。

小结

  • 三个时刻各一段代码:call 抄下请求里之后要用的东西,future 完成时分类并把守卫移进 body,poll_frame 数字节、判断结束。
  • 结束有三处:None、trailers、交出一帧后 is_end_stream() 为真;漏掉最后一处,长度精确的 body 会被误报成放弃。
  • 放弃靠守卫的 Drop,正常路径要主动关掉它,内层出错也要关;Drop 里不 panic。

💬 关联讨论

发起讨论
加载中...