共享内核: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:顺序即优先级
// 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 和空数据帧,非空数据帧报错。