Rust in Everything
基于 tower-http 0.7.1

共享内核:content_encoding 与 compression_utils

主线位置:作用点主线的第一和第三个时刻的公共底座。call 时的编码协商在 content_encoding.rs,body 流动时的编解码管道在 compression_utils.rs。压缩、响应解压、请求解压三个模块各自只写「什么时候用、用哪个」,真正搬字节的是这里。

flowchart TB
  subgraph ce["content_encoding.rs:协商"]
    enc["Encoding 枚举"]
    se["SupportedEncodings trait"]
    neg["encodings / from_headers"]
  end
  subgraph cu["compression_utils.rs:搬字节"]
    ae["AcceptEncoding:四个布尔值"]
    wb["WrapBody<M: DecorateAsyncRead>"]
    lvl["CompressionLevel"]
  end
  comp[压缩] --> neg
  comp --> wb
  dec[响应解压] --> ae
  dec --> wb
  rdec[请求解压] --> ae
  rdec --> wb
  sd["ServeDir 预压缩"] --> neg
  ae -->|实现| se

两个文件,四个用户

这两个文件都不是公开模块(lib.rs 里是 mod content_encoding;、mod compression_utils;),只有 CompressionLevel 被 pub use 出来。它们的编译条件不一样(lib.rs):

文件编译条件谁用
content_encoding.rs任一 compression-*、decompression-*,或 fs压缩(from_headers)、ServeDir(encodings)、两个解压(只用 trait)
compression_utils.rs任一 compression-* 或 decompression-*压缩、响应解压、请求解压

ServeDir 只需要「从 Accept-Encoding 里挑一个预压缩文件」,不需要边读边压,所以 fs 只拉进前一个文件。它的协商结果怎么变成文件扩展名,见 axum 卷 ServeDir 与 ServeFile 的「预压缩文件」一节;协商规则本身(q 值、同分时的顺序、*、406)见 axum 卷 压缩。这一页不重复这些规则,只看它们背后的类型。

Encoding:顺序即优先级

Encoding:

// This enum's variants are ordered from least to most preferred.
#[derive(Copy, Clone, Debug, Ord, PartialOrd, PartialEq, Eq)]
pub(crate) enum Encoding {
    #[allow(dead_code)]
    Identity,
    #[cfg(any(feature = "fs", feature = "compression-deflate"))]
    Deflate,
    #[cfg(any(feature = "fs", feature = "compression-gzip"))]
    Gzip,
    #[cfg(any(feature = "fs", feature = "compression-br"))]
    Brotli,
    #[cfg(any(feature = "fs", feature = "compression-zstd"))]
    Zstd,
}

两件事值得注意。

第一,优先级不是一张表,而是派生出来的 Ord:preferred_encoding 按 (q 值, 编码) 取最大,q 值相同时比枚举顺序。改变优先级等于调换变体的位置。

第二,每个变体的条件是 any(feature = "fs", feature = "compression-xxx")。只要开了 fs,四个变体全都存在,哪怕压缩 feature 只开了 gzip。于是压缩的 ResponseFuture 在 match (should_compress, encoding) 时,编译器会看到 Encoding::Brotli 这类「存在、但没有对应压缩器」的变体,第 115 行 只好补一个分支:

            #[cfg(feature = "fs")]
            #[allow(unreachable_patterns)]
            (true, _) => {
                // This should never happen because the `AcceptEncoding` struct which is used to determine
                // `self.encoding` will only enable the different compression algorithms if the
                // corresponding crate feature has been enabled.
                // ...
                // To safeguard against refactors that changes this relationship or other bugs the
                // server will return an uncompressed response instead of panicking since that could
                // become a ddos attack vector.
                return Poll::Ready(Ok(Response::from_parts(
                    parts,
                    CompressionBody::new(BodyInner::identity(body)),
                )));
            }

两个解压模块根本不用这个枚举。它们拿响应或请求的 Content-Encoding 直接和 b"gzip"、b"br" 这些字节串比较。所以同一个 crate 里,协商一侧的 Encoding::parse 用 eq_ignore_ascii_case、认 x-gzip,解码一侧大小写敏感、不认 x-gzip。后者的后果见 reqwest 卷 响应体 的「解压」一节。

SupportedEncodings:一个 trait,两个实现

SupportedEncodings 只有四个返回 bool 的方法,协商函数用它过滤掉服务端不支持的编码。tag 里非测试代码的实现只有两个:

  • AcceptEncoding:压缩、响应解压、请求解压三个模块都拿它存配置,.gzip(false)、.no_br() 改的就是它的字段。每个方法在对应 feature 没开时直接返回 false,所以「支持哪些编码」由编译时的 feature 和运行时的开关一起决定。
  • PrecompressedVariants:ServeDir 的 precompressed_gzip() 等方法打开的那几个。

AcceptEncoding 还有一个方向相反的用途:解压模块要发出一个 Accept-Encoding。to_header_value 是一张 16 行的 match,把四个布尔值映射成写死的字符串,比如 (true, true, true, true) => "zstd,gzip,deflate,br",全关时返回 None。字符串里没有 q 值,列出的顺序对服务端没有约束力。响应解压在 call 时补的就是它,请求解压回 415 时带在响应里的也是它。

WrapBody:一条管道,两个方向

压缩和解压的 body 是同一个类型 WrapBody<M>,区别只在类型参数 M 是编码器还是解码器。new 把管道一节一节接起来:

        // convert `Body` into a `Stream`
        let stream = BodyIntoStream::new(body);

        // an adapter that converts the error type into `io::Error` while storing the actual error
        // `StreamReader` requires the error type is `io::Error`
        let stream = StreamErrorIntoIoError::<_, B::Error>::new(stream);

        // convert `Stream` into an `AsyncRead`
        let read = StreamReader::new(stream);

        // apply decorator to `AsyncRead` yielding another `AsyncRead`
        let read = M::apply(read, quality);
flowchart LR
  inner["内层 Body"] --> bis["BodyIntoStream:数据帧变成 Stream 项,遇到 trailers 先存着"]
  bis --> seio["StreamErrorIntoIoError:错误存起来,换成哨兵 io::Error"]
  seio --> sr["tokio-util StreamReader:AsyncBufRead"]
  sr --> codec["async-compression 编码器或解码器"]
  codec --> wbody["WrapBody::poll_frame:每次读到的字节交成一帧"]

M 要实现 DecorateAsyncRead:apply 套上编解码器,get_pin_mut 把它拆回去,好让 WrapBody 在编解码器读完以后直接 poll 内层 body 取 trailers。实现一共 8 个,compression/body.rs 里 4 个编码器(GzipEncoder、ZlibEncoder、BrotliEncoder、ZstdEncoder),decompression/body.rs 里 4 个解码器。注意 deflate 用的是 Zlib*:HTTP 的 deflate 指的是 zlib 封装格式。

poll_frame 用一块初始 4096 字节的 BytesMut 调 poll_read_buf,读到多少就 split().freeze() 成一帧交出去。输出帧的大小由编解码器决定,和输入帧无关;Data 统一是 Bytes,Error 统一是 BoxError。

错误怎么穿过 AsyncRead

StreamReader 要求流的错误类型是 io::Error,而内层 body 的错误可以是任何类型。直接 io::Error::new(Other, e) 会把原错误埋进一层包装里。StreamErrorIntoIoError 的做法是把原错误存在自己的字段里,往外只交一个带特殊 errno 的 io::Error:

            Some(Err(err)) => {
                *this.error = Some(err);
                Poll::Ready(Some(Err(io::Error::from_raw_os_error(SENTINEL_ERROR_CODE))))
            }

SENTINEL_ERROR_CODE 是 -837459418,一个不会和真实 errno 撞上的负数。错误从编解码器里冒出来以后,WrapBody::poll_frame 的 Err 分支 沿 get_pin_mut 链走回 StreamErrorIntoIoError,把存着的原错误取出来:

                    if let Some(body_error) = body_error {
                        return Poll::Ready(Some(Err(body_error.into())));
                    } else if err.raw_os_error() == Some(SENTINEL_ERROR_CODE) {
                        // SENTINEL_ERROR_CODE only gets used when storing
                        // an underlying body error
                        unreachable!()
                    } else if *read_some_data {
                        return Poll::Ready(Some(Err(err.into())));
                    }

三种情况:

  • 内层 body 出错:取回原错误,转成 BoxError 交出去,调用方可以 downcast 回原来的类型。
  • 编解码器自己出错(比如数据不是合法的 gzip),而且内层已经交过数据:交出这个 io::Error。
  • 编解码器出错,但内层一个数据帧都没交过:什么也不交,往下走到「poll 剩余帧」。这是 0.6.7 的 #618:带着 Content-Encoding: gzip 的空 body(HEAD 的响应、204)不再报 EOF 错误。

下面把压缩层和解压层背靠背接起来,等于一次不经网络的 HTTP 往返。/ok 回两个数据帧加 trailers,/err 第二帧是内层自己的错误 DiskGone,/bad 是一段冒充 gzip 的明文:

tower-http = { version = "=0.7.1", features = ["compression-gzip", "decompression-gzip"] }
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"
futures = "0.3"
#[derive(Debug)]
struct DiskGone;
// impl Display("disk gone")与 std::error::Error 略

type Body = UnsyncBoxBody<Bytes, BoxError>;

fn origin_body(path: &str) -> Body {
  let data = |s: &'static str| Ok::<_, BoxError>(Frame::data(Bytes::from(s.repeat(20))));
  match path {
    "/ok" => {
      let mut t = HeaderMap::new();
      t.insert("grpc-status", "0".parse().unwrap());
      StreamBody::new(stream::iter(vec![data("hello "), data("world "), Ok(Frame::trailers(t))])).boxed_unsync()
    }
    _ => StreamBody::new(stream::iter(vec![data("hello "), Err(BoxError::from(DiskGone))])).boxed_unsync(),
  }
}

// read_all:逐帧读,记下每个数据帧的长度;出错时用 e.is::<DiskGone>() / e.is::<io::Error>() 分辨类型

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let server = ServiceBuilder::new()
    .layer(CompressionLayer::new())
    .service(service_fn(|req: Request<Full<Bytes>>| async move {
      Ok::<_, Infallible>(Response::new(origin_body(req.uri().path())))
    }));
  // 客户端:解压层包在「服务端」外面
  let client = ServiceBuilder::new().layer(DecompressionLayer::new()).service(server.clone());
  // 对 /ok、/err:先带 accept-encoding: gzip 直接问 server,再经 client 问一次
  // /bad:上游回 content-encoding: gzip 和明文 "not gzip at all",经 DecompressionLayer 读
  // 最后用 accept-encoding: GZIP 问一次 server
  // ...
}

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

/ok
  compressed: content-encoding Some("gzip")
  compressed: data frames [48], trailers Some({"grpc-status": "0"}), end
  round trip: data frames [240], trailers Some({"grpc-status": "0"}), end
/err
  compressed: content-encoding Some("gzip")
  compressed: data frames [], then error (DiskGone): disk gone
  round trip: data frames [], then error (DiskGone): disk gone
/bad
  decompressed: data frames [], then error (io::Error): Invalid gzip header
accept-encoding: GZIP -> content-encoding Some("gzip")
  • /ok:两个 120 字节的输入帧,压缩一侧只交出一个 48 字节的帧,帧的边界由编码器决定。trailers 在编码器读完以后由 WrapBody 直接从内层取出、原样转发,解压一侧同样。
  • /err:DiskGone 穿过了 StreamReader、gzip 编码器,又穿过了 gzip 解码器,两次都能 downcast 回原类型。第一帧的 120 字节还压在编码器里没吐出来,错误就先到了,所以压缩一侧一个数据帧都没有。
  • /bad:解码器自己的错误,类型是 io::Error。
  • GZIP:协商一侧不区分大小写,照样选了 gzip;换成响应头 Content-Encoding: GZIP,解压一侧就不认了。

编解码器读完以后

poll_read_buf 返回 Ok(0),说明编解码器认为流结束了。WrapBody 记下 read_all_data = true,此后每次 poll_frame 都直接 poll 内层 body(第 238 行):

        // poll any remaining frames, such as trailers
        let body = M::get_pin_mut(this.read).get_pin_mut().get_pin_mut();
        match ready!(body.poll_frame(cx)) {
            Some(Ok(frame)) if frame.is_trailers() => /* 原样转发 */,
            Some(Ok(frame)) => match frame.into_data() {
                Ok(data) if data.has_remaining() => Poll::Ready(Some(Err(
                    "there are extra bytes after body has been decompressed".into(),
                ))),
                Ok(mut data) => /* 空数据帧:转发,不结束 */,
                Err(frame) => /* 其他帧:转发 */,
            },
            Some(Err(err)) => Poll::Ready(Some(Err(err.into()))),
            None => Poll::Ready(None),
        }

这里 poll 的是 BodyIntoStream:它在 poll_next 时遇到第一个非数据帧就把它存起来、停止产出数据,这时把存着的 trailers 交出来。这段代码在 0.7.0、0.7.1 改了三次(#685、#712、#722),对解压的影响和实测放在 响应解压。它在压缩方向的副作用是 body 结束后还会多 poll 一次内层,没有 fuse 的流会 panic,见 axum 卷 压缩 小结里的 #732。

小结

  • content_encoding.rs 管协商,开 fs 也会编译;compression_utils.rs 管搬字节,只在压缩、解压 feature 下编译。两者都不公开,只导出 CompressionLevel。
  • Encoding 的变体顺序就是同分时的优先级;开了 fs 时四个变体全在,所以压缩的 future 要多一个兜底分支。解压不用这个枚举,按字节比较 Content-Encoding,和协商一侧的大小写规则不一致。
  • SupportedEncodings 两个实现:AcceptEncoding(压缩、两个解压共用)和 ServeDir 的 PrecompressedVariants;AcceptEncoding::to_header_value 生成解压一侧发出的 Accept-Encoding,不带 q 值。
  • WrapBody<M> 一个类型两个方向:Body → Stream → AsyncRead → 编解码器 → Body,8 个 DecorateAsyncRead 实现。
  • 内层错误存进 StreamErrorIntoIoError、对外换成 errno 为 -837459418 的哨兵,出来时取回原错误,可以 downcast;编解码器在没读到任何数据时的错误被当成空 body(#618)。
  • 编解码器读完后继续 poll 内层:转发 trailers 和空数据帧,非空数据帧报错。

💬 关联讨论

发起讨论
加载中...