响应解压
主线位置:作用点主线的三个时刻都占,站在客户端一侧。
call时给请求补一个Accept-Encoding,响应 future 完成时看Content-Encoding决定换不换 body,body 流动时由WrapBody套着解码器边读边解。reqwest 的gzip()、brotli()等开关就是它,行为(只补不改、GZIP和x-gzip原样交出、删掉哪两个头)在 reqwest 卷 响应体 已经实测过。
flowchart TB
req["请求"] --> hasAE{"已有 Accept-Encoding"}
hasAE -->|否| addAE["补上 to_header_value()"]
hasAE -->|是| keep[原样]
addAE --> inner[内层服务]
keep --> inner
inner --> resp["响应头"]
resp --> m{"Content-Encoding 的字节"}
m -->|"gzip / deflate / br / zstd 且已开启"| wrap["删 Content-Encoding、Content-Length,套 WrapBody"]
m -->|"其他,或没有这个头"| ident["Identity:原样"]
wrap --> eos["解码器读完:再 poll 内层取 trailers"]
call:只补不改
fn call(&mut self, mut req: Request<ReqBody>) -> Self::Future {
if let header::Entry::Vacant(entry) = req.headers_mut().entry(ACCEPT_ENCODING) {
if let Some(accept) = self.accept.to_header_value() {
entry.insert(accept);
}
}
ResponseFuture {
inner: self.inner.call(req),
accept: self.accept,
}
}
补上的值来自 AcceptEncoding::to_header_value,一个写死的字符串,没有 q 值。请求里已经有 Accept-Encoding(哪怕是 identity)就不碰,但响应回来照样按配置解压:解不解压只看本层配置,不看请求里写了什么。
响应 future:逐字节匹配
ResponseFuture::poll 用 HeaderMap::entry 拿到 Content-Encoding,match entry.get().as_bytes():b"gzip"、b"deflate"、b"br"、b"zstd" 四个字面量,每个还要 self.accept.xxx() 为真。匹配上了才:
entry.remove();
parts.headers.remove(header::CONTENT_LENGTH);
匹配不上(大写、x-gzip、没开的算法、gzip, br 这种叠了两层的)就原样返回,头和字节都不动。Content-Encoding 只看第一个值,同名头出现两次时第二个不管。
DecompressionBody 的 size_hint 同样只在 Identity 时转发,解了压的长度未知;它没有覆盖 is_end_stream,用的是默认的 false。
多成员:zstd 开,gzip 关
gzip 和 zstd 都允许把多段压缩数据首尾相接:gzip 叫 member,zstd 叫 frame。解码器读完第一段后要不要接着读,是 async-compression 的 multiple_members 开关,默认关。tower-http 的两个解码器设得不一样(decompression/body.rs):
fn apply(input: Self::Input, _quality: CompressionLevel) -> Self::Output {
GzipDecoder::new(input)
}
// ...
fn apply(input: Self::Input, _quality: CompressionLevel) -> Self::Output {
let mut decoder = ZstdDecoder::new(input);
decoder.multiple_members(true);
decoder
}
- zstd:0.6.4 的 #548 打开,支持「由多个 zstd frame 组成的响应」。
- gzip:0.4.1 的 #354 打开过,0.6.8 的 #621 又关掉了,CHANGELOG 的理由是「HTTP context only uses one member」。
关掉以后,第一段之后的字节去哪了,取决于它们和第一段是不是在同一个帧里。这一点下面的实验会看到。
结尾:三次改动
编解码器报告读完以后,WrapBody 继续 poll 内层 body(代码见 共享内核 最后一节)。对解压来说,这时内层还交出数据帧,意味着「压缩流说结束了,HTTP body 还没完」。这段逻辑在三个版本里各不相同,按 git 历史整理:
| 解码器读完后,内层交出 | 0.6.11 | 0.7.0(#685) | 0.7.1(#712、#722) |
|---|---|---|---|
| trailers | 转发 | 转发 | 转发 |
| 空数据帧 | 结束 body(None),后面的 trailers 丢失 | 转发 | 转发 |
| 非空数据帧 | 报错 there are extra bytes after body has been decompressed | 原样转发,不报错 | 报错,同 0.6.11 |
- #685 本意是压缩方向的 gRPC trailers(「forward trailers from inner body after compression finishes」),顺手把「非 trailers 帧」的分支简化成原样转发,解压方向的多余字节检查跟着没了。
- #712 把检查加回来,提交说明写明这是 #685 引入的回归。但它把空数据帧也归到了「结束」那一支。
- #722 让空数据帧继续转发:
Body的约定是返回None之后不能再 poll,提前结束就再也拿不到后面的 trailers。
0.7.0 这一列来自源码对照,本地没有 0.7.0 可跑,未实测;0.6.11 和 0.7.1 两列下面都跑了。
空 body
Content-Encoding: gzip 加一个空 body,gzip 解码器会报「意外的 EOF」。0.6.7 的 #618 在 StreamErrorIntoIoError 里加了 read_some_data:内层一个数据帧都没交过时,解码器的错误被吞掉、当作空 body(见 共享内核 的「错误怎么穿过 AsyncRead」)。HEAD 请求的响应、204 都属于这种情况。
实验
用 CompressionLayer 造出压缩字节(同一个 WrapBody,换成编码器),再让上游以指定的 Content-Encoding 和帧序列返回,经 DecompressionLayer 读出。上游一律带 content-length: 999,看它有没有被删:
tower-http = { version = "=0.7.1", features = ["compression-gzip", "compression-zstd", "decompression-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"
futures = "0.3"
type Body = UnsyncBoxBody<Bytes, BoxError>;
// 借 CompressionLayer 造压缩字节;谓词恒真,免得被 32 字节下限挡住
async fn compress(enc: &str, text: &'static str) -> Bytes {
let svc = CompressionLayer::new().compress_when(|_, _, _: &_, _: &_| true)
.layer(service_fn(move |_: Request<Full<Bytes>>| async move { Ok::<_, Infallible>(Response::new(Full::<Bytes>::from(text))) }));
let req = Request::get("/").header("accept-encoding", enc).body(Full::default()).unwrap();
svc.oneshot(req).await.unwrap().into_body().collect().await.unwrap().to_bytes()
}
fn frames(v: Vec<Frame<Bytes>>) -> Body {
StreamBody::new(stream::iter(v.into_iter().map(Ok::<_, BoxError>))).boxed_unsync()
}
// run(label, content_encoding, body):上游回这个头和这些帧(另加 content-length: 999),
// 经 DecompressionLayer 后 collect,打印剩下的两个头、字节和 trailers,或者错误
#[tokio::main(flavor = "current_thread")]
async fn main() {
// call 时:默认、no_br()、请求已带 accept-encoding: identity 三种,让上游回显它看到的头
// ...
let g1 = compress("gzip", "hello ").await;
let g2 = compress("gzip", "world").await;
let z1 = compress("zstd", "hello ").await;
let z2 = compress("zstd", "world").await;
let cat = |a: &Bytes, b: &Bytes| Bytes::from([a.as_ref(), b.as_ref()].concat());
run("gzip one member", "gzip", frames(vec![Frame::data(g1.clone())])).await;
run("gzip two members, one frame", "gzip", frames(vec![Frame::data(cat(&g1, &g2))])).await;
run("gzip two members, two frames", "gzip", frames(vec![Frame::data(g1.clone()), Frame::data(g2.clone())])).await;
run("zstd two frames, one frame", "zstd", frames(vec![Frame::data(cat(&z1, &z2))])).await;
run("gzip + empty frame + trailers", "gzip", {
let mut t = HeaderMap::new();
t.insert("x-checksum", "abc".parse().unwrap());
frames(vec![Frame::data(g1.clone()), Frame::data(Bytes::new()), Frame::trailers(t)])
}).await;
run("empty body", "gzip", frames(vec![])).await;
run("gzip, br", "gzip, br", frames(vec![Frame::data(g1.clone())])).await;
}
tower-http 0.7.1 的输出(M2 Max、rustc 1.99.0,跑 3 次相同):
default upstream saw accept-encoding Some("zstd,gzip,deflate,br")
no_br upstream saw accept-encoding Some("zstd,gzip,deflate")
given upstream saw accept-encoding Some("identity")
gzip one member ce=None cl=None -> b"hello " trailers=None
gzip two members, one frame ce=None cl=None -> b"hello " trailers=None
gzip two members, two frames ce=None cl=None -> error: there are extra bytes after body has been decompressed
zstd two frames, one frame ce=None cl=None -> b"hello world" trailers=None
gzip + empty frame + trailers ce=None cl=None -> b"hello " trailers=Some({"x-checksum": "abc"})
empty body ce=None cl=None -> b"" trailers=None
gzip, br ce=Some("gzip, br") cl=Some("999") -> b"\x1f\x8b\x08\0\0\0\0\0\0\xff\xcbH\xcd\xc9\xc9W\0\0\xf6\xf9\x81\xed\x06\0\0\0" trailers=None
同一份代码只把版本换成 =0.6.11,只有一行不同(跑 3 次相同):
gzip + empty frame + trailers ce=None cl=None -> b"hello " trailers=None
逐行看:
- 两段 gzip,放在一帧里:只得到第一段
hello,world没了,没有任何错误。第二段的字节在第一段结束时已经被StreamReader读进缓冲区,解码器不开multiple_members,读完第一段就报告结束;WrapBody接着 poll 的是内层 body,缓冲区里剩下的字节没人检查。 - 两段 gzip,分两帧:第二帧到达时解码器已经结束,它是「解码器读完后的非空数据帧」,报错。同样的字节,分帧方式不同,一个静默截断,一个报错;网络上一个 body 被切成几帧,调用方控制不了。
- 两段 zstd,放在一帧里:开了
multiple_members,两段都解出来。 - 空数据帧加 trailers:0.7.1 的 #722 让 trailers 留了下来;0.6.11 遇到空帧就结束了 body,
x-checksum丢失。 - 空 body:不报错,#618。
gzip, br:不认,Content-Encoding和Content-Length都留着,交出的是原始的 gzip 字节(1f 8b开头)。
问: 真实服务器会发多段 gzip 吗?
答: 少见。gzip 格式允许把几段直接拼起来,命令行的 gunzip 会把拼接的几段都解出来;#621 选择和「HTTP 只用一个 member」的常见情况对齐。如果你的上游可能这样发,在 0.7.1 里拿到的是被截断的 body,而且只有在第二段恰好落在单独一帧时才会看到错误。要可靠地发现,只能自己比对长度或校验和。
小结
call时只在请求没有Accept-Encoding时补一个写死的、不带 q 值的列表;解不解压只看本层配置。- 响应的
Content-Encoding按字节和四个小写字面量比较,匹配上才删Content-Encoding、Content-Length并套WrapBody,否则头和字节都不动;解了压的 body 不转发size_hint。 - zstd 开
multiple_members(#548),gzip 关(#621):两段 gzip 在同一帧里时第二段被静默丢掉,分在两帧时报there are extra bytes after body has been decompressed。 - 解码器读完后的结尾逻辑:0.6.11 遇空帧提前结束、丢 trailers;0.7.0(#685)不再检查多余字节;0.7.1 恢复检查(#712)并转发空帧(#722)。
- 带编码头的空 body 不报错(#618)。