Rust in Everything
基于 tower-http 0.7.1

请求解压

主线位置:作用点主线的第一个时刻,站在服务端一侧。call 时看请求的 Content-Encoding,要么把请求 body 换成解码中的 body 交给内层,要么短路回 415;响应 future 完成时只做一件事:把响应 body 装箱。解码本身发生在 handler 读请求 body 的时候,用的是 共享内核 里的 WrapBody。

flowchart TB
  req["请求"] --> ce{"Content-Encoding"}
  ce -->|没有| id1["Identity"]
  ce -->|"identity(字节完全相同)"| id2["Identity,头保留"]
  ce -->|"gzip / deflate / br / zstd 且已开启"| dec["删 Content-Encoding、Content-Length,body 套 WrapBody"]
  ce -->|其他| pass{"pass_through_unaccepted"}
  pass -->|true| id3["Identity,头保留"]
  pass -->|false| r415["短路:415 + Accept-Encoding"]
  id1 --> inner["内层服务:Request<DecompressionBody<B>>"]
  id2 --> inner
  id3 --> inner
  dec --> inner
  inner --> boxed["响应 body 装箱成 UnsyncBoxBody"]
  r415 --> boxed

同一套零件,换个方向

RequestDecompression 的字段和响应解压一样,多一个开关:

pub struct RequestDecompression<S> {
    pub(super) inner: S,
    pub(super) accept: AcceptEncoding,
    pub(super) pass_through_unaccepted: bool,
}

内层看到的请求 body 类型是 DecompressionBody<ReqBody>(Service 实现的约束 S: Service<Request<DecompressionBody<ReqBody>>, ...>),和响应解压交给调用方的是同一个类型,里面是同样的 BodyInner 和四个解码器,multiple_members 的设置、结尾逻辑、空 body 的处理都和 响应解压 一致。

call 也是逐字节匹配,只多了两条:

                match entry.get().as_bytes() {
                    #[cfg(feature = "decompression-gzip")]
                    b"gzip" if self.accept.gzip() => {
                        entry.remove();
                        parts.headers.remove(header::CONTENT_LENGTH);
                        BodyInner::gzip(crate::compression_utils::WrapBody::new(
                            body,
                            CompressionLevel::default(),
                        ))
                    }
                    // deflate、br、zstd 同样
                    b"identity" => BodyInner::identity(body),
                    _ if self.pass_through_unaccepted => BodyInner::identity(body),
                    _ => return ResponseFuture::unsupported_encoding(self.accept),
                }
  • identity 放行,但 Content-Encoding: identity 这个头留在请求里。
  • 其余一律视为「不支持」:大写的 GZIP、x-gzip、关掉的算法、gzip, br。响应解压遇到不认识的会原样放过,请求解压默认拒绝;pass_through_unaccepted(true) 改成原样放过,头和字节都不动,交给 handler 自己处理。

415

不支持时走 Kind::Unsupported,这是 四件套 里的短路枚举。poll 造响应:

            StateProj::Unsupported { accept } => {
                let res = Response::builder()
                    .header(
                        header::ACCEPT_ENCODING,
                        accept
                            .to_header_value()
                            .unwrap_or(HeaderValue::from_static("identity")),
                    )
                    .status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
                    .body(UnsyncBoxBody::from_inner(
                        Empty::new().map_err(Into::into).boxed_unsync(),
                    ))
                    .unwrap();
                Poll::Ready(Ok(res))
            }

响应头里的 Accept-Encoding 告诉客户端服务端认哪些编码(RFC 7694 的用法),值同样来自 to_header_value;四种都关掉时写 identity。body 是空的。

响应一律装箱

短路的响应要和内层的响应放进同一个类型。别的短路模块要求 ResBody: Default,这里换了个办法,把两边都装进 tower-http 自己的 UnsyncBoxBody:

    type Response = Response<UnsyncBoxBody<D, BoxError>>;

于是内层响应 body 要 Send + 'static,错误要能转成 BoxError,每个正常响应都多一次装箱。好处是外层的 CorsLayer、TimeoutLayer 拿到的 body 实现了 Default,不受内层影响;坏处是加不加这一层,响应类型不同,按条件加层时会碰到类型不一致,原因见 body 模块。CatchPanic 也是同样的取舍。

没有解压后上限

RequestDecompression 自己不限制解压出来多少字节。上限要靠 RequestBodyLimitLayer,而两层谁在外面,决定了限的是哪一边:

flowchart LR
  subgraph a["Limit 在外"]
    a1["Limit:看到压缩后的 Content-Length,额度套在压缩字节上"] --> a2["解压:删掉 Content-Length"] --> a3["handler:读到全部解压字节"]
  end
  subgraph b["Limit 在里"]
    b1["解压:删掉 Content-Length"] --> b2["Limit:没有 Content-Length,额度套在解压后的字节上"] --> b3["handler:读到上限就报错"]
  end

ServiceBuilder 里先写的在外层。下面先跑几种编码,再用 10 MiB 的零压成的 gzip 对比两种顺序(上限 1 MiB):

tower-http = { version = "=0.7.1", features = ["decompression-full", "limit"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
http = "1"
http-body = "1"
http-body-util = "0.1"
bytes = "1"
flate2 = "1"
use bytes::Bytes;
use http::{header, Request, Response};
use http_body_util::{BodyExt, Full};
use std::{convert::Infallible, io::Write};
use tower::{ServiceBuilder, ServiceExt};
use tower_http::{decompression::RequestDecompressionLayer, limit::RequestBodyLimitLayer};

// handler 读完请求 body,报告读到多少字节,以及它看到的两个头
async fn handler<B>(req: Request<B>) -> Result<Response<Full<Bytes>>, Infallible>
where
  B: http_body::Body,
  B::Error: std::fmt::Display,
{
  let heads = format!("ce={:?} cl={:?}", req.headers().get(header::CONTENT_ENCODING), req.headers().get(header::CONTENT_LENGTH));
  let msg = match req.into_body().collect().await {
    Ok(c) => format!("{heads} read {} bytes", c.to_bytes().len()),
    Err(e) => format!("{heads} error: {e}"),
  };
  Ok(Response::new(Full::from(msg)))
}

fn gzip(data: &[u8]) -> Bytes {
  let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
  e.write_all(data).unwrap();
  Bytes::from(e.finish().unwrap())
}

fn post(ce: &str, body: Bytes) -> Request<Full<Bytes>> {
  Request::post("/").header(header::CONTENT_ENCODING, ce).header(header::CONTENT_LENGTH, body.len()).body(Full::new(body)).unwrap()
}

// show(label, svc, req):发请求,打印状态码、响应的 Accept-Encoding 和响应正文

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let hello = gzip(b"hello world");
  let dec = || ServiceBuilder::new().layer(RequestDecompressionLayer::new()).service_fn(handler);
  show("gzip", dec(), post("gzip", hello.clone())).await;
  show("identity", dec(), post("identity", Bytes::from("plain"))).await;
  show("GZIP", dec(), post("GZIP", hello.clone())).await;
  let no_br = ServiceBuilder::new().layer(RequestDecompressionLayer::new().no_br()).service_fn(handler);
  show("br, no_br()", no_br, post("br", Bytes::from("whatever"))).await;
  let pass = ServiceBuilder::new().layer(RequestDecompressionLayer::new().pass_through_unaccepted(true)).service_fn(handler);
  show("GZIP, pass_through", pass, post("GZIP", hello.clone())).await;

  let bomb = gzip(&vec![0u8; 10 * 1024 * 1024]);
  println!("bomb: {} bytes compressed", bomb.len());
  let outside = ServiceBuilder::new()
    .layer(RequestBodyLimitLayer::new(1 << 20))
    .layer(RequestDecompressionLayer::new())
    .service_fn(handler);
  show("limit outside decompression", outside, post("gzip", bomb.clone())).await;
  let inside = ServiceBuilder::new()
    .layer(RequestDecompressionLayer::new())
    .layer(RequestBodyLimitLayer::new(1 << 20))
    .service_fn(handler);
  show("limit inside decompression", inside, post("gzip", bomb.clone())).await;
}

输出(M2 Max、rustc 1.99.0,flate2 1.1.10,跑 3 次相同):

gzip                         200 OK accept-encoding=None "ce=None cl=None read 11 bytes"
identity                     200 OK accept-encoding=None "ce=Some(\"identity\") cl=Some(\"5\") read 5 bytes"
GZIP                         415 Unsupported Media Type accept-encoding=Some("zstd,gzip,deflate,br") ""
br, no_br()                  415 Unsupported Media Type accept-encoding=Some("zstd,gzip,deflate") ""
GZIP, pass_through           200 OK accept-encoding=None "ce=Some(\"GZIP\") cl=Some(\"31\") read 31 bytes"
bomb: 10208 bytes compressed
limit outside decompression  200 OK accept-encoding=None "ce=None cl=None read 10485760 bytes"
limit inside decompression   200 OK accept-encoding=None "ce=None cl=None error: length limit exceeded"
  • gzip:handler 看不到 Content-Encoding 和 Content-Length,读到解压后的 11 字节。
  • identity:两个头都还在。
  • GZIP:大写不认,handler 没被调用,415 里列出四种编码;关掉 br 后列表少了 br。
  • pass_through:handler 拿到原样的 31 字节 gzip 数据和原来的头。
  • 10 MiB 的零压成 10208 字节,压缩比约一千比一。Limit 在外:RequestBodyLimit 看到 Content-Length: 10208,小于 1 MiB,放行并把额度收紧到 10208(额度取上限和 Content-Length 中较小的那个,见 axum 卷 其余常用中间件);这个额度套在压缩字节上,解压层在它里面,handler 读到了全部 10485760 字节。Limit 在里:解压层先删掉了 Content-Length,RequestBodyLimit 只能按 1 MiB 的上限给解压后的流套 Limited,handler 读到第 1 MiB 之后拿到 length limit exceeded。

这里的 200 是 handler 自己把错误写进了正文;在 axum 里,Bytes、Json 这类提取器会把 length limit exceeded 变成 413。

📝NOTE
没有蠢问题

问: 既然 Limit 在里才有用,为什么不两层都放?

答: 可以,两层限的是两件事:外层限网络上收多少压缩字节,Content-Length 超限时当场回 413,handler 和解码器都不会运行;里层限解压后展开多少。只放里层时,声明了一个巨大 Content-Length 的请求也会先进到 handler,直到读出的解压字节超限才失败。

小结

  • RequestDecompression 复用 DecompressionBody 和 WrapBody,内层看到的请求 body 类型是 DecompressionBody<B>。
  • Content-Encoding 按字节匹配:四种已开启的编码解压并删掉 Content-Encoding、Content-Length;identity 放行且保留头;其余默认短路 415,响应带 Accept-Encoding 列出支持的编码,pass_through_unaccepted(true) 改成原样放行。
  • 短路和正常响应都装箱成 UnsyncBoxBody<D, BoxError>,不要求 Default,但要求内层 body Send + 'static。
  • 没有解压后大小上限。RequestBodyLimitLayer 在外层时限的是压缩字节,10208 字节的 gzip 让 handler 读到 10 MiB;放在里层才限住解压后的字节。

💬 关联讨论

发起讨论
加载中...