压缩
主线位置:请求主线上的一层中间件,用
Router::layer套上(装配时只是一次Layer::layer)。它在请求进来时只读一个头Accept-Encoding,然后原样交给内层;真正的工作在响应回来时:决定压不压、改响应头、把 body 换成一个边读边压的流。
flowchart TB
req["请求"] --> neg["读 Accept-Encoding,选编码"]
neg --> inner["调用内层服务"]
inner --> none{"选不出编码"}
none -- 是 --> r406["改成 406,body 原样"]
none -- 否 --> hard{"已编码或是 Range"}
hard -- 是 --> pass["原样返回"]
hard -- 否 --> pred{"谓词:大小、类型"}
pred -- 不压 --> pass
pred -- 压 --> vary["加 Vary"]
vary --> idn{"选中 identity"}
idn -- 是 --> pass2["body 不变"]
idn -- 否 --> wrap["包成压缩 body,改头"]
本页源码在 tower-http/src/compression/、compression_utils.rs 和 content_encoding.rs。示例用这一组依赖(compression-full 打开 gzip、deflate、br、zstd 四个 feature;fs 只有最后一节的 ServeDir 需要;test-util 给流式那一节的暂停时钟用):
axum = "=0.8.9"
tower-http = { version = "=0.7.1", features = ["compression-full", "fs"] }
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "0.1"
futures = "0.3"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "test-util"] }
所有输出都在 M2 Max、rustc 1.99.0、--release 下跑 3 次,结果相同。
结构
CompressionLayer 和它生成的 Compression 都只有三样配置:
pub struct Compression<S, P = DefaultPredicate> {
pub(crate) inner: S,
pub(crate) accept: AcceptEncoding,
pub(crate) predicate: P,
pub(crate) quality: CompressionLevel,
}
accept是四个布尔值,默认全开。但编码名的解析是用#[cfg(feature = ...)]包着的:只开了compression-gzip时,br在解析阶段就被当成未知编码丢掉。所以「支持哪些编码」由编译时的 feature 和运行时的.gzip(false)/.no_br()一起决定。predicate决定一个响应值不值得压,默认是DefaultPredicate,可以用compress_when换掉。quality是CompressionLevel,默认Default,即各算法自己的默认级别,交给 async-compression 0.4.51。
call 只做一件事:在把请求交给内层之前算好编码,存进 future。
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
let encoding = Encoding::from_headers(req.headers(), self.accept);
ResponseFuture {
inner: self.inner.call(req),
encoding,
predicate: self.predicate.clone(),
quality: self.quality,
}
}
请求一进来就要算,是因为请求在 self.inner.call(req) 里被移走了,响应回来时已经看不到 Accept-Encoding。
协商:q 值、优先级和通配符
encodings 把所有 Accept-Encoding 头按逗号拆开,每一项再按 ; 拆成编码名和 q 值:
- 编码名不区分大小写,
x-gzip等同于gzip;不认识的(compress)或没开 feature 的直接丢掉,*也在这里被丢掉,由下面单独处理。 - q 值由
QValue::parse解析成 0–1000 的整数,只接受0、1开头、最多 3 位小数;格式不对的,这一项整个丢掉(不是当成 q=1)。没写 q 值就是 1000。
选择由 Encoding::preferred_encoding 完成:
accepted_encodings
.filter(|(_, qvalue)| qvalue.0 > 0)
.max_by_key(|&(encoding, qvalue)| (qvalue, encoding))
.map(|(encoding, _)| encoding)
先去掉 q=0 的,再按 (q 值, 编码) 取最大。第二个分量是 Encoding 枚举本身,它派生了 Ord,注释写着「This enum's variants are ordered from least to most preferred」:Identity < Deflate < Gzip < Brotli < Zstd。q 值相同时,排在后面的赢。
通配符和「拒绝不压缩」由 preferred_encoding_with_wildcard 处理:
let wildcard_q = match wildcard_q {
Some(q) => q,
None => {
let identity_rejected = explicit
.iter()
.any(|(enc, q)| *enc == Encoding::Identity && q.0 == 0);
return match Encoding::preferred_encoding(explicit.into_iter()) {
Some(enc) => Some(enc),
None => {
if identity_rejected {
None
} else {
Some(Encoding::Identity)
}
}
};
}
};
// Build the effective set of (encoding, qvalue) for all supported encodings.
// For each supported encoding, use its explicit q-value if listed, otherwise the wildcard
// q-value.
let all_supported = all_supported_encodings(supported_encoding);
// ...
Encoding::preferred_encoding(effective)
- 没有
*:从列出来的里面选;一个都选不出时,除非客户端写了identity;q=0,否则退回 identity(不压缩)。没有Accept-Encoding头也走这条路,结果是 identity。 - 有
*:所有支持的编码(包括 identity)都拿到通配符的 q 值,显式写出的编码用自己的 q 值。*的 q 为 1 时,所有编码同分,按枚举顺序选 zstd。 - 返回
None表示没有可接受的编码。这时ResponseFuture::poll等内层返回后,只把状态码改成 406,body 和其他头原样保留(注释:「the inner service has already been called, so its response body and headers are passed through. Only the status code is overwritten」)。
use axum::{Router, body::Body, http::Request, routing::get};
use http_body_util::BodyExt;
use tower::ServiceExt;
use tower_http::compression::CompressionLayer;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(|| async { "hello ".repeat(100) }))
.layer(CompressionLayer::new());
for accept in [
"",
"gzip",
"gzip, deflate, br, zstd",
"gzip, br;q=0.9",
"br;q=0, gzip;q=0.5",
"x-gzip",
"GZIP",
"gzip;q=0.0001",
"compress",
"*",
"*;q=0, gzip",
"identity;q=0",
"*;q=0",
] {
let mut req = Request::get("/");
if !accept.is_empty() {
req = req.header("accept-encoding", accept);
}
let res = app.clone().oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
let status = res.status().as_u16();
let enc = res.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned()).unwrap_or("-".into());
let vary = res.headers().get("vary").map(|v| v.to_str().unwrap().to_owned()).unwrap_or("-".into());
let len = res.into_body().collect().await.unwrap().to_bytes().len();
println!("{:<26} {status} enc={enc:<5} vary={vary:<16} body={len}", format!("{accept:?}"));
}
}
"" 200 enc=- vary=accept-encoding body=600
"gzip" 200 enc=gzip vary=accept-encoding body=44
"gzip, deflate, br, zstd" 200 enc=zstd vary=accept-encoding body=22
"gzip, br;q=0.9" 200 enc=gzip vary=accept-encoding body=44
"br;q=0, gzip;q=0.5" 200 enc=gzip vary=accept-encoding body=44
"x-gzip" 200 enc=gzip vary=accept-encoding body=44
"GZIP" 200 enc=gzip vary=accept-encoding body=44
"gzip;q=0.0001" 200 enc=- vary=accept-encoding body=600
"compress" 200 enc=- vary=accept-encoding body=600
"*" 200 enc=zstd vary=accept-encoding body=22
"*;q=0, gzip" 200 enc=gzip vary=accept-encoding body=44
"identity;q=0" 406 enc=- vary=accept-encoding body=600
"*;q=0" 406 enc=- vary=accept-encoding body=600
- 600 字节的重复文本,gzip 压成 44 字节,zstd 22 字节。
gzip, deflate, br, zstd是常见浏览器发的那种写法,四个都是 q=1,选 zstd。x-gzip选中后,响应头写的是规范名gzip。gzip;q=0.0001:q 值四位小数,这一项被丢掉,结果和compress、没有头一样,不压缩。- 406 的两行 body 还是 600 字节的原文。客户端说「什么都不接受」,中间件仍然调用了 handler,只是把状态码换掉。
- 每一行都带
Vary,包括没压缩的,原因在下一节。
ServeDir 挑预压缩文件也用 encodings 和 preferred_encoding,但不走 preferred_encoding_with_wildcard,所以它不认 *,也不会回 406(见 ServeDir 与 ServeFile)。
* 和 406 是 0.7.0 才有的0.7.0 之前,Encoding::from_headers 直接返回一个 Encoding,通配符被当成未知编码忽略。#693「Fix: Handle wildcard * in Accept-Encoding per RFC 9110 §12.5.3」的说明:「The compression middleware silently ignores the * wildcard in Accept-Encoding headers. This means requests like *;q=0,gzip (which per RFC 9110 §12.5.3 should select gzip and reject everything else) fall back to identity encoding instead.」
改法是让 from_headers 返回 Option<Encoding>,None 表示无法满足;为了不改中间件的类型签名,「The ResponseFuture handles the None case by overwriting the response status to 406 while passing through the inner service's body unchanged. This avoids introducing a new error type or changing the middleware's Service output type.」这就是上面「handler 照样执行、只改状态码」的来历。PR 也明说了 fs 不受影响,「since it uses preferred_encoding() directly with its own encoding negotiation logic」。CHANGELOG 把它列在 0.7.0 的 breaking 下:之前 identity;q=0 会静默拿到不压缩的响应,现在是 406。
压不压:两条硬规则和 DefaultPredicate
ResponseFuture::poll 拿到内层的响应之后:
// never recompress responses that are already compressed
let should_compress = !res.headers().contains_key(header::CONTENT_ENCODING)
// never compress responses that are ranges
&& !res.headers().contains_key(header::CONTENT_RANGE)
&& self.predicate.should_compress(&res);
前两条写死在 future 里,和谓词无关,换了谓词也绕不过:已经有 Content-Encoding 的不再压(比如 ServeDir 给出的 .gz 文件),有 Content-Range 的不压(206 的那一段字节是原文件的某个区间,压缩后偏移就对不上了)。
剩下的交给谓词。DefaultPredicate 是四个谓词的 And:
let inner = SizeAbove::new(SizeAbove::DEFAULT_MIN_SIZE)
.and(NotForContentType::GRPC)
.and(NotForContentType::IMAGES)
.and(NotForContentType::SSE);
SizeAbove:DEFAULT_MIN_SIZE是 32。大小先取 body 的size_hint().exact(),没有再读Content-Length头;size >= 32才压。两者都没有(流式 body)时一律压。NotForContentType用starts_with比较Content-Type,可以带一个例外前缀:GRPC是application/grpc,例外application/grpc-web;IMAGES是image/,例外image/svg+xml(SVG 是文本);SSE是text/event-stream,没有例外。比较区分大小写。
gRPC 有自己的消息级压缩(文档原话「which has its own protocol specific compression scheme」),常见的图片格式本身已经压缩过。SSE 是 0.5.2 加进默认谓词的(#465),PR 的理由是「If not careful or properly handled, general compression with SSE will result in missing some or all events.」(axum 的 Sse 怎么产生事件见 SSE 与重定向。)
use axum::{
Router,
body::Body,
http::{Request, header},
response::IntoResponse,
routing::get,
};
use http_body_util::BodyExt;
use tower::ServiceExt;
use tower_http::compression::CompressionLayer;
fn text(n: usize) -> String {
"a".repeat(n)
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/31", get(|| async { text(31) }))
.route("/32", get(|| async { text(32) }))
.route("/html", get(|| async { ([(header::CONTENT_TYPE, "text/html")], text(2000)) }))
.route("/png", get(|| async { ([(header::CONTENT_TYPE, "image/png")], text(2000)) }))
.route("/svg", get(|| async { ([(header::CONTENT_TYPE, "image/svg+xml")], text(2000)) }))
.route("/sse", get(|| async { ([(header::CONTENT_TYPE, "text/event-stream")], text(2000)) }))
.route("/grpc", get(|| async { ([(header::CONTENT_TYPE, "application/grpc")], text(2000)) }))
.route("/grpc-web", get(|| async { ([(header::CONTENT_TYPE, "application/grpc-web")], text(2000)) }))
.route("/encoded", get(|| async { ([(header::CONTENT_ENCODING, "gzip")], text(2000)) }))
.route("/ranged", get(|| async { ([(header::CONTENT_RANGE, "bytes 0-1999/5000")], text(2000)) }))
.route(
"/stream",
get(|| async {
let chunks = futures::stream::iter([Ok::<_, std::io::Error>("tiny")]);
Body::from_stream(chunks).into_response()
}),
)
.layer(CompressionLayer::new());
println!("{:<10} {:<5} {:<5} {:<5} {}", "path", "enc", "len", "body", "vary");
for path in ["/31", "/32", "/html", "/png", "/svg", "/sse", "/grpc", "/grpc-web", "/encoded", "/ranged", "/stream"] {
let req = Request::get(path).header("accept-encoding", "gzip").body(Body::empty()).unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let h = |name: &str| res.headers().get(name).map(|v| v.to_str().unwrap().to_owned()).unwrap_or("-".into());
let (enc, len, vary) = (h("content-encoding"), h("content-length"), h("vary"));
let body = res.into_body().collect().await.unwrap().to_bytes();
println!("{path:<10} {enc:<5} {len:<5} {:<5} {vary}", body.len());
}
}
path enc len body vary
/31 - 31 31 -
/32 gzip - 23 accept-encoding
/html gzip - 38 accept-encoding
/png - 2000 2000 -
/svg gzip - 38 accept-encoding
/sse - 2000 2000 -
/grpc - 2000 2000 -
/grpc-web gzip - 38 accept-encoding
/encoded gzip 2000 2000 -
/ranged - 2000 2000 -
/stream gzip - 24 accept-encoding
- 31 字节不压,32 字节压,边界是
>=。32 个a压完是 23 字节,省得不多;/stream只有 4 字节,因为大小未知照样被压,结果是 24 字节,比原文还大。 /encoded是 handler 自己声称gzip的 2000 字节明文,中间件信了它,原样放过。- 没被压的响应都还带着
Content-Length。这个头不是 handler 写的,是 axum 的RouteFuture按 body 的size_hint补的(见 Route 与类型擦除)。
想改规则,用 compress_when 组合自己的谓词,比如 SizeAbove::new(256).and(NotForContentType::GRPC).and(NotForContentType::IMAGES)。谓词也可以是闭包 Fn(StatusCode, Version, &HeaderMap, &Extensions) -> bool。不管怎么换,前面两条硬规则都还在。
改了哪些响应头
决定压缩之后(should_compress 为真),future 先补 Vary,再看选中的编码:
if should_compress
&& !parts.headers.get_all(header::VARY).iter().any(|value| {
contains_ignore_ascii_case(
value.as_bytes(),
header::ACCEPT_ENCODING.as_str().as_bytes(),
)
})
{
parts
.headers
.append(header::VARY, header::ACCEPT_ENCODING.into());
}
let body = match (should_compress, encoding) {
// if compression is _not_ supported or the client doesn't accept it
(false, _) | (_, Encoding::Identity) => {
return Poll::Ready(Ok(Response::from_parts(
parts,
CompressionBody::new(BodyInner::identity(body)),
)))
}
// ... gzip、deflate、br、zstd 各一个分支,用 WrapBody 包住 body
};
parts.headers.remove(header::ACCEPT_RANGES);
parts.headers.remove(header::CONTENT_LENGTH);
parts
.headers
.insert(header::CONTENT_ENCODING, encoding.into_header_value());
Vary: accept-encoding只取决于「这个响应值得压」,与这次选没选中编码无关。所以上一节没带Accept-Encoding的请求也拿到了Vary:同一个 URL,换个请求头就会得到不同的字节,缓存必须知道这一点。已经有Vary且包含accept-encoding(不区分大小写的子串匹配)时不重复加,用的是append,原有的Vary值不受影响。- 真正压缩时,去掉
Content-Length(压缩后的长度事先不知道,HTTP/1.1 下 hyper 会改用 chunked 编码)和Accept-Ranges(压缩后的字节流不能按原文件的偏移取区间),加上Content-Encoding。
把 ServeDir 放在 CompressionLayer 里面,能同时看到这几条:
use axum::{Router, body::Body, http::Request};
use tower::ServiceExt;
use tower_http::{compression::CompressionLayer, services::ServeDir};
#[tokio::main]
async fn main() {
std::fs::create_dir_all("tmp/cmp").unwrap();
std::fs::write("tmp/cmp/a.txt", "hello ".repeat(100)).unwrap();
std::fs::write("tmp/cmp/a.txt.gz", "pretend this is gzip").unwrap();
let plain = Router::new().fallback_service(ServeDir::new("tmp/cmp"));
let pre = Router::new().fallback_service(ServeDir::new("tmp/cmp").precompressed_gzip());
let cases = [
("ServeDir", plain.clone(), None),
("ServeDir + Compression", plain.clone().layer(CompressionLayer::new()), None),
("ServeDir + Compression", plain.layer(CompressionLayer::new()), Some("bytes=0-9")),
("precompressed + Compression", pre.layer(CompressionLayer::new()), None),
];
for (name, app, range) in cases {
let mut req = Request::get("/a.txt").header("accept-encoding", "gzip");
if let Some(r) = range {
req = req.header("range", r);
}
let res = app.oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
let h = |name: &str| res.headers().get(name).map(|v| v.to_str().unwrap().to_owned()).unwrap_or("-".into());
println!(
"{name:<28} range={:<11} {} enc={:<4} len={:<3} accept-ranges={:<5} vary={}",
range.unwrap_or("-"),
res.status().as_u16(),
h("content-encoding"),
h("content-length"),
h("accept-ranges"),
h("vary"),
);
}
}
ServeDir range=- 200 enc=- len=600 accept-ranges=bytes vary=-
ServeDir + Compression range=- 200 enc=gzip len=- accept-ranges=- vary=accept-encoding
ServeDir + Compression range=bytes=0-9 206 enc=- len=10 accept-ranges=bytes vary=-
precompressed + Compression range=- 200 enc=gzip len=20 accept-ranges=bytes vary=accept-encoding
- 第二行:压缩后
Content-Length和Accept-Ranges都没了,客户端从这个响应里看不出可以发Range。 - 第三行:客户端照样发了
Range,ServeDir回 206 带Content-Range,硬规则让它原样通过。 - 第四行:
ServeDir选了.gz文件并写了Content-Encoding,中间件不再压,Content-Length和Accept-Ranges都保留;Vary是ServeDir自己加的。静态文件提前压好再配precompressed_gzip(),就省掉了每个请求的 CPU。
use axum::{Router, body::Body, http::Request, routing::get};
use http_body_util::BodyExt;
use tower::ServiceExt;
use tower_http::compression::CompressionLayer;
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(|| async { "hello ".repeat(100) }))
.layer(CompressionLayer::new());
let req = Request::get("/")
.header("accept-encoding", "gzip;q=0.8, identity")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
let enc = res.headers().get("content-encoding").cloned();
let len = res.into_body().collect().await.unwrap().to_bytes().len();
println!("content-encoding: {enc:?}, {len} bytes");
}
客户端接受 gzip,但给它的 q 值比 identity 低。打印的是 Some("gzip") 还是 None,body 多少字节?
流式压缩
压缩 body 是 WrapBody:内层 body 先变成 Stream(BodyIntoStream),再变成 AsyncRead(tokio-util 的 StreamReader),套上 async-compression 的编码器(比如 GzipEncoder),最后在 poll_frame 里用一块初始容量 4096 字节(INTERNAL_BUF_CAPACITY)的缓冲读编码器的输出,读到多少就作为一帧交出去。整个 body 不会被攒在内存里。
编码器什么时候吐数据,决定了流式响应的延迟。async-compression 0.4.51 的 bufread 编码器在上游返回 Pending、而这之前已经读进过数据时,会对压缩器做一次 flush(generic/bufread/encoder.rs 里 State::Encoding 遇到 None 输入转成 State::Flushing)。也就是说:内层一时没有新数据,已经收到的部分就先压出来交给客户端。下面的 body 每 100 毫秒产生一块 100 字节(暂停时钟,毫秒数是虚拟时间):
use axum::{Router, body::Body, http::Request, routing::get};
use futures::StreamExt;
use http_body_util::BodyExt;
use std::time::Duration;
use tokio::time::Instant;
use tower::ServiceExt;
use tower_http::compression::CompressionLayer;
// one 100-byte chunk every 100 ms, three times
fn ticks() -> impl futures::Stream<Item = Result<String, std::io::Error>> + Send {
futures::stream::unfold(0, |i| async move {
if i == 3 {
return None;
}
tokio::time::sleep(Duration::from_millis(100)).await;
Some((Ok(format!("{i}").repeat(100)), i + 1))
})
}
async fn run(accept: &'static str, fused: bool) {
let app = Router::new()
.route(
"/",
get(move || async move {
if fused { Body::from_stream(ticks().fuse()) } else { Body::from_stream(ticks()) }
}),
)
.layer(CompressionLayer::new());
let req = Request::get("/").header("accept-encoding", accept).body(Body::empty()).unwrap();
let start = Instant::now();
let mut body = app.oneshot(req).await.unwrap().into_body();
while let Some(frame) = body.frame().await {
let len = frame.unwrap().into_data().unwrap().len();
println!("{:>4} ms {len} bytes", start.elapsed().as_millis());
}
println!("{:>4} ms end", start.elapsed().as_millis());
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
std::panic::set_hook(Box::new(|_| {}));
println!("-- identity");
run("identity", false).await;
println!("-- gzip, unfold().fuse()");
run("gzip", true).await;
println!("-- gzip, unfold()");
let err = tokio::spawn(run("gzip", false)).await.unwrap_err();
let msg = err.into_panic();
println!("panicked: {}", msg.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| msg.downcast_ref::<String>().cloned()).unwrap());
}
-- identity
100 ms 100 bytes
200 ms 100 bytes
300 ms 100 bytes
300 ms end
-- gzip, unfold().fuse()
100 ms 30 bytes
200 ms 20 bytes
300 ms 24 bytes
300 ms end
-- gzip, unfold()
100 ms 30 bytes
200 ms 20 bytes
300 ms 24 bytes
panicked: Unfold must not be polled after it returned `Poll::Ready(None)`
- 压缩后每一块仍在它产生的那一刻交出,没有攒到结尾。第一帧 30 字节里有 gzip 的 10 字节头;最后一帧 24 字节里有 8 字节的 gzip 尾(CRC32 和长度)。代价是每次 flush 都让压缩率变差,小块越多越明显。
- 第三段是一个已知问题:body 结束时程序 panic 了。
WrapBody读完编码器的输出后,还要再 poll 一次内层 body,取可能跟在数据后面的 trailers;这一次落到BodyIntoStream::poll_frame最后的this.body.poll_frame(cx),而内层 body 之前已经返回过None。futures::stream::unfold被 poll 过结束之后会 panic,Body::from_stream原样把这次 poll 转给它。stream::iter这类结束后能一直返回None的流不受影响(上一节的/stream就没事);给流加.fuse()可以绕开。这个问题已经报告为 issue #732「CompressionLayer re-polls non-fused streaming bodies after EOF and panics」,报告者在 0.7.0 和 0.7.1 上复现,截至 0.7.1 仍未修复。在真实服务器上,这个 panic 发生在 hyper 驱动连接的任务里。
动脑筋答案
content-encoding: None, 600 bytes
跑 3 次相同。没有压缩。identity 没写 q 值,按 1000 算;gzip 是 800。preferred_encoding 比较的是 (q 值, 编码),q 值先比,(1000, Identity) 大于 (800, Gzip),枚举里 identity 排最低这件事只在 q 值相同时才起作用。选中 identity 后,响应走 (_, Encoding::Identity) 分支原样返回,但因为谓词认为它值得压,仍然带着 Vary: accept-encoding。
小结
CompressionLayer在call时就从Accept-Encoding选好编码,响应回来再决定压不压。- 选编码:去掉 q=0,按
(q 值, 编码)取最大,同分时 zstd > br > gzip > deflate > identity;q 值写错的项被丢掉;0.7.0 起认*,拒绝了一切(identity;q=0、*;q=0)就把状态码改成 406,body 原样。 - 不压的情况:已有
Content-Encoding、有Content-Range(这两条写死);DefaultPredicate再排除小于 32 字节、image/(SVG 除外)、application/grpc(gRPC-Web 除外)、text/event-stream。大小未知的流式 body 一律压。 - 值得压就加
Vary: accept-encoding;真的压了就去掉Content-Length、Accept-Ranges,写上Content-Encoding。 - 压缩是流式的,上游暂时没数据时编码器会 flush;0.7.1 的
WrapBody会在 body 结束后多 poll 一次内层,未 fuse 的unfold流会 panic(#732)。
压缩、解压和 ServeDir 预压缩共用的编码内核见 tower-http 卷 共享内核,CompressionBody 的内部见 压缩的内部;条件加层时 CompressionBody 带来的类型问题见其中的 body 模块。