Rust in Everything
基于 tower-http 0.7.1

压缩的内部

主线位置:作用点主线的三个时刻都占。call 时从 Accept-Encoding 选好编码,响应 future 完成时用谓词决定压不压、改头、换 body,body 流动时由 WrapBody 边读边压。这三步各自的行为(协商规则、DefaultPredicate 排除哪些响应、改哪些头、流式 flush)已经在 axum 卷 压缩 跑过一遍,这一页只讲类型。

flowchart TB
  layer["CompressionLayer<P>"] -->|layer| svc["Compression<S, P>:inner、accept、predicate、quality"]
  svc -->|"call:Encoding::from_headers"| fut["ResponseFuture<F, P>:encoding、predicate、quality"]
  fut -->|"poll 完成"| body["CompressionBody<B>"]
  body --> inner["BodyInner<B>:pin_project_cfg! 展开"]
  inner --> v1["Gzip:WrapBody<GzipEncoder>"]
  inner --> v2["Deflate / Brotli / Zstd:按 feature 出现"]
  inner --> v3["Identity:原 body"]

谓词是类型参数

Compression 的第二个类型参数就是谓词:

pub struct Compression<S, P = DefaultPredicate> {
    pub(crate) inner: S,
    pub(crate) accept: AcceptEncoding,
    pub(crate) predicate: P,
    pub(crate) quality: CompressionLevel,
}

compress_when(predicate) 返回的是 Compression<S, C>,一个新类型。谓词随请求被 clone 进 ResponseFuture,响应回来时调一次。Predicate trait 只要求 Clone 和一个 should_compress,再带一个默认方法 and。它有三个泛型实现:

  • 闭包:Fn(StatusCode, Version, &HeaderMap, &Extensions) -> bool + Clone。闭包拿到的是响应的四个部分,拿不到 body,所以闭包谓词没法按 size_hint 判断大小;要按大小,用 SizeAbove。
  • Option<T>:None 表示一律压缩,方便按配置开关某条规则。
  • And<Lhs, Rhs>:两个都说压才压。

默认谓词也是这样拼出来的,DefaultPredicate 是一层新类型:

pub struct DefaultPredicate(
    And<And<And<SizeAbove, NotForContentType>, NotForContentType>, NotForContentType>,
);

包一层新类型而不是直接 type DefaultPredicate = And<...>,换内部规则时公开类型不变。自定义谓词时文档建议从它出发再 .and(...),否则 gRPC、图片、SSE 的排除规则就丢了。

SizeAbove 的阈值在 0.7.0 从 u16 改成了 u64(#704),之前最大只能设到 65535 字节。它先看 body 的 size_hint().exact(),没有再看 Content-Length 头,两样都没有就压:

        let content_size = response.body().size_hint().exact().or_else(|| {
            response
                .headers()
                .get(header::CONTENT_LENGTH)
                .and_then(|h| h.to_str().ok())
                .and_then(|val| val.parse().ok())
        });

        match content_size {
            Some(size) => size >= self.0,
            _ => true,
        }

CompressionBody 与 pin_project_cfg!

CompressionBody<B> 里只有一个字段 inner: BodyInner<B>,变体是按 feature 出现的(第 182 行):

pin_project_cfg! {
    #[project = BodyInnerProj]
    pub(crate) enum BodyInner<B>
    where
        B: Body,
    {
        #[cfg(feature = "compression-gzip")]
        Gzip {
            #[pin]
            inner: GzipBody<B>,
        },
        // Deflate、Brotli、Zstd 同样带 #[cfg]
        Identity {
            #[pin]
            inner: B,
        },
    }
}

body 要 pin 住内层才能 poll,枚举的投影一般交给 pin_project_lite::pin_project!。但 pin-project-lite 是 macro_rules! 写的,不接受变体上的 #[cfg]。pin_project_cfg! 绕过去的办法是穷举:每遇到一个带 #[cfg(pred)] 的变体,就把展开分成两支,一支 cfg(all(..., pred)) 带上这个变体,一支 cfg(all(..., not(pred))) 不带,最后每一支各自交给 pin_project!。四个带条件的变体展开成 2⁴ = 16 份 BodyInner 定义,任意一组 feature 下恰好一份的 cfg 为真。这个宏 2022 年 1 月随 #205(「Clean up some #[cfg]s in compression/body.rs」)进来,文件头注明出自一个 CC0 的 gist。

📝NOTE
冷知识:兄弟模块用了另一个办法

decompression/body.rs 面对同一个问题,没有用这个宏。它让五个变体永远存在,没开的 feature 把变体的类型换成一个不可能有值的占位:

pub(crate) enum Never {}

#[cfg(feature = "decompression-gzip")]
type GzipBody<B> = WrapBody<GzipDecoder<B>>;
#[cfg(not(feature = "decompression-gzip"))]
type GzipBody<B> = (Never, PhantomData<B>);

poll_frame 里对应的分支写成 BodyInnerProj::Gzip { inner } => match inner.0 {},空 match 证明这里不可达。这是 2021 年 11 月把 pin-project 换成 pin-project-lite(#151)时的写法,压缩模块两个月后换成了宏。两种办法效果一样,一个多 16 份展开,一个多四个永远构造不出来的变体。

get_ref、get_mut、into_inner 要从 WrapBody 一路剥回原 body,每个变体都是四连 .get_ref().get_ref().get_ref().get_ref():WrapBody.read(编码器)→ StreamReader → StreamErrorIntoIoError → BodyIntoStream → 原 body。

size_hint 与 is_end_stream

第 273 行:

    fn size_hint(&self) -> http_body::SizeHint {
        if let BodyInner::Identity { inner } = &self.inner {
            inner.size_hint()
        } else {
            http_body::SizeHint::new()
        }
    }

    fn is_end_stream(&self) -> bool {
        if let BodyInner::Identity { inner } = &self.inner {
            inner.is_end_stream()
        } else {
            false
        }
    }

压了的 body 长度未知,只能给一个空的 SizeHint,hyper 就用 chunked 编码发送(压缩时 Content-Length 头也已经被删掉)。没压的原样转发,这两处分别是 0.6.2 的 #531 和 0.6.4 的 #535;在那之前,套上 CompressionLayer 的小响应即使没被压缩,也丢了长度信息。

Default 在 B: Default 时造一个 Identity 变体。这让 CorsLayer、TimeoutLayer 这类短路时要 B::default() 的层可以套在压缩外面,原因见 四件套。

CompressionLevel

CompressionLevel 是 #[non_exhaustive] 的四个变体:Fastest、Best、Default、Precise(i32),一一映射到 async-compression 的 Level。有两处不是直接映射:

  • br 的 Default 是 4(第 338 行):brotli crate 自己的默认级别是 11,也就是最高级,在线压缩太慢。注释说 4 和 NGINX 动态 brotli 的默认值相同。这是 0.4.2 的 #356。
  • zstd 级别 ≥ 17(含 Best)时把窗口限制在 8 MiB(第 369 行):window_log(23),理由是 Chromium 的 zstd 解码器最多只接受 8 MB 的窗口,RFC 8878 也建议编码器不要超过。

级别只影响压缩,解压一侧的 WrapBody::new 收到的 CompressionLevel::default() 会被解码器的 apply 忽略。

一次看三样

同一段 108890 字节的文本,四种算法各用三个级别压一次;再看 size_hint;最后用 u64 阈值和闭包组合一个谓词:

tower-http = { version = "=0.7.1", features = ["compression-full"] }
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"
use bytes::Bytes;
use http::{Request, Response, StatusCode};
use http_body::Body as _;
use http_body_util::{BodyExt, Full};
use std::convert::Infallible;
use tower::{service_fn, Layer, ServiceExt};
use tower_http::compression::{
  predicate::{DefaultPredicate, Predicate, SizeAbove},
  CompressionLayer, CompressionLevel,
};

fn text() -> String {
  (0..2000).map(|i| format!("line {i}: the quick brown fox jumps over the lazy dog\n")).collect()
}

async fn handler(req: Request<Full<Bytes>>) -> Result<Response<Full<Bytes>>, Infallible> {
  let body = match req.uri().path() {
    "/small" => "0123456789".to_owned(),
    "/404" => return Ok(Response::builder().status(StatusCode::NOT_FOUND).body(Full::from(text())).unwrap()),
    _ => text(),
  };
  Ok(Response::new(Full::from(body)))
}

fn get(path: &str, enc: &str) -> Request<Full<Bytes>> {
  Request::get(path).header("accept-encoding", enc).body(Full::default()).unwrap()
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  println!("input {} bytes", text().len());
  for enc in ["gzip", "deflate", "br", "zstd"] {
    let mut line = format!("{enc:<8}");
    for (name, level) in [("Fastest", CompressionLevel::Fastest), ("Default", CompressionLevel::Default), ("Best", CompressionLevel::Best)] {
      let svc = CompressionLayer::new().quality(level).layer(service_fn(handler));
      let res = svc.oneshot(get("/", enc)).await.unwrap();
      let n = res.into_body().collect().await.unwrap().to_bytes().len();
      line += &format!("  {name}={n}");
    }
    println!("{line}");
  }

  // size_hint 与 is_end_stream:没压的转发,压了的未知
  let svc = CompressionLayer::new().layer(service_fn(handler));
  for path in ["/small", "/"] {
    let res = svc.clone().oneshot(get(path, "gzip")).await.unwrap();
    let ce = res.headers().get("content-encoding").cloned();
    let b = res.body();
    println!("{path:<6} content-encoding={ce:?} size_hint.exact={:?} is_end_stream={}", b.size_hint().exact(), b.is_end_stream());
  }

  // 自定义谓词:只压 2xx,且大小超过阈值;200_000 超出了 u16 的范围
  let only_ok = |status: StatusCode, _: http::Version, _: &http::HeaderMap, _: &http::Extensions| status.is_success();
  let big = CompressionLayer::new().compress_when(DefaultPredicate::new().and(SizeAbove::new(200_000)).and(only_ok));
  let low = CompressionLayer::new().compress_when(DefaultPredicate::new().and(SizeAbove::new(1_000)).and(only_ok));
  for (name, layer) in [("min 200000", big), ("min 1000", low)] {
    for path in ["/", "/404"] {
      let res = layer.clone().layer(service_fn(handler)).oneshot(get(path, "gzip")).await.unwrap();
      println!("{name:<10} {path:<4} {} content-encoding={:?}", res.status(), res.headers().get("content-encoding"));
    }
  }
}

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

input 108890 bytes
gzip      Fastest=5580  Default=5147  Best=5149
deflate   Fastest=5568  Default=5135  Best=5137
br        Fastest=8949  Default=2154  Best=2359
zstd      Fastest=2090  Default=2225  Best=2066
/small content-encoding=None size_hint.exact=Some(10) is_end_stream=false
/      content-encoding=Some("gzip") size_hint.exact=None is_end_stream=false
min 200000 /    200 OK content-encoding=None
min 200000 /404 404 Not Found content-encoding=None
min 1000   /    200 OK content-encoding=Some("gzip")
min 1000   /404 404 Not Found content-encoding=None
  • 级别和大小不是单调的:这段文本高度重复,br 的 Best(11)比 Default(4)还大 205 字节,zstd 的 Fastest 比 Default 小。级别换来的是 CPU 时间,压缩率要拿自己的数据测,不要按名字猜。gzip 与 deflate 相差 12 字节,正好是 gzip 头尾比 zlib 头尾多出来的部分。
  • /small 只有 10 字节,被 DefaultPredicate 的 32 字节下限挡住,走 Identity,size_hint 原样转发;/ 压了,size_hint 变成未知。
  • 阈值 200000 超出 u16,0.6 里这样写编译不过;闭包谓词让 404 一律不压。

小结

  • 谓词是 Compression 的类型参数,compress_when 换出一个新类型;Predicate 有闭包、Option<T>、And 三种组合方式,闭包看不到 body;DefaultPredicate 是一层包着 And 链的新类型。
  • SizeAbove 先看 size_hint().exact() 再看 Content-Length,都没有就压;0.7.0 起阈值是 u64(#704)。
  • CompressionBody 的变体按 feature 出现,pin-project-lite 不支持变体上的 #[cfg],pin_project_cfg! 穷举出 16 份定义;解压模块用不可构造的 Never 占位解决同一个问题。
  • 只有 Identity 变体转发 size_hint 和 is_end_stream(#531、#535),压了的 body 长度未知;Default 造的是 Identity。
  • CompressionLevel 默认交给各算法,但 br 的默认被改成 4,zstd 高级别时窗口限制在 8 MiB;实测级别和压缩率不单调。
  • trailers 怎么在压缩后继续转发,见 共享内核 最后一节。

💬 关联讨论

发起讨论
加载中...