压缩的内部
主线位置:作用点主线的三个时刻都占。
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。
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
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 怎么在压缩后继续转发,见 共享内核 最后一节。