Rust in Everything
基于 tower-http 0.7.1

响应解压

主线位置:作用点主线的三个时刻都占,站在客户端一侧。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:只补不改

Decompression::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.110.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 开头)。
📝NOTE
没有蠢问题

问: 真实服务器会发多段 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)。

💬 关联讨论

发起讨论
加载中...