HTTP 模块与压缩
主线位置:请求主线上两处。请求阶段,
process_request在early_request_filter和request_filter之间调下游模块的request_header_filter;响应阶段,response_filter之后、写给下游之前,每个响应头和每块响应体都过一遍下游模块。压缩模块排在最后。
flowchart TB init["init_downstream_modules"] --> mods["HttpModules"] mods --> ctx["build_ctx"] ctx --> erf["early_request_filter"] erf --> rhf["模块: 请求头"] rhf --> rf["request_filter"] rf --> up["上游"] up --> resf["response_filter"] resf --> mhf["模块: 响应头"] mhf --> mbf["模块: 响应体"] mbf --> down["写给下游"]
代码:pingora-core/src/modules/http/mod.rs(369 行,trait 和容器)、modules/http/compression.rs(109 行,压缩模块本身只是一层壳)、modules/http/grpc_web.rs(80 行,另一个现成的模块),真正的压缩逻辑在 protocols/http/compression/:mod.rs 1472 行,gzip.rs、brotli.rs、zstd.rs 各 160 到 317 行。0.9.0 新加的上游模块(upstream_modules feature)用的是同一个 trait,见上游模块。
实验在 exp-pingora/proto-main(M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2):18306 在 early_request_filter 里把压缩级别调成 6,18308 做同样的事但放在 request_filter 里,18300 不动。上游 18301 对 /big 回 4200 字节的 text/html,带 ETag: "v1"、Accept-Ranges: bytes 和 Content-Length;对 /small 回 10 字节。
HttpModule:一组过滤器
HttpModule 的方法都有空的默认实现:
pub trait HttpModule {
async fn request_header_filter(&mut self, _req: &mut RequestHeader) -> Result<()> {
Ok(())
}
async fn request_body_filter(
&mut self,
_body: &mut Option<Bytes>,
_end_of_stream: bool,
) -> Result<()> {
Ok(())
}
async fn response_header_filter(
&mut self,
_resp: &mut ResponseHeader,
_end_of_stream: bool,
) -> Result<()> {
Ok(())
}
fn response_body_filter(
&mut self,
_body: &mut Option<Bytes>,
_end_of_stream: bool,
) -> Result<()> {
Ok(())
}
fn response_trailer_filter(
&mut self,
_trailers: &mut Option<Box<HeaderMap>>,
) -> Result<Option<Bytes>> {
Ok(None)
}
fn response_done_filter(&mut self) -> Result<Option<Bytes>> {
Ok(None)
}
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
}
请求头、请求体、响应头三个方法是 async,响应体、trailer、结束三个是同步的,响应体那个每块数据调一次。方法拿的是 &mut self,模块的状态就存在它自己身上,所以一个模块对象只服务一个请求。
谁来造这个对象,由 HttpModuleBuilder 回答:init() 每个请求调一次,order() 决定先后。HttpModules 只是 builder 的列表:
pub fn add_module(&mut self, builder: ModuleBuilder) {
if self.module_index.get().is_some() {
// We use a shared module_index the index would be out of sync if we
// add more modules.
panic!("cannot add module after ctx is already built")
}
self.modules.push(builder);
// not the most efficient way but should be fine
// largest order first
self.modules.sort_by_key(|m| -m.order());
}
build_ctx 对每个 builder 调 init(),得到这个请求的 HttpModuleCtx。第一次调用时还会建一张 TypeId → 下标 的表,存进 OnceCell 给之后所有请求共用;同一个类型出现两次就 panic!("duplicated filters found")。有了这张表,get_mut::<T>() 能按类型找到某个模块,钩子里调整压缩级别就是这么找到压缩模块的。HttpModuleCtx 的每个过滤方法都是按顺序对每个模块调一遍,出错就停。
代理服务建起来时调 ProxyHttp::init_downstream_modules,默认实现只有一行:modules.add_module(ResponseCompressionBuilder::enable(0)),加一个级别为 0、即关着的压缩模块。所以默认情况下,每个请求都会建一个压缩模块对象,只是什么也不做。Session 上的 write_response_header 和转发路径上的 downstream_response_task_filter 都会先过模块再写,用户在钩子里直接调 session.write_response_header 发的响应也经过模块。
和 tower 比:tower 的中间件是一层套一层的 Service,每层自己决定什么时候调内层、返回值怎么变换,见 Layer 与 Stack;pingora 的模块不包裹任何东西,只是在几个固定的位置被调用,不能拦下请求、也不能自己去调上游。
ResponseCompression
ResponseCompression 是一个 newtype,Deref 到 ResponseCompressionCtx,HttpModule 实现把四个方法转给它。ResponseCompressionBuilder 的 order() 是 i16::MIN / 2,注释说「run the response filter later than most others filters」:别的模块看到、改的都是没压缩的响应。init() 建的是 ResponseCompressionCtx::new(level, false, false),后两个参数是「允许解压」和「保留 ETag」,都关着。
CtxInner 有两个阶段:
enum CtxInner {
HeaderPhase {
// Store the preferred list to compare with content-encoding
accept_encoding: Vec<Algorithm>,
encoding_levels: [u32; Algorithm::COUNT],
decompress_enable: [bool; Algorithm::COUNT],
preserve_etag: [bool; Algorithm::COUNT],
// Optional dictionary for dcz compression (RFC 9842).
dictionary: Option<DictionaryData>,
},
BodyPhase(Option<Box<dyn Encode + Send + Sync>>),
}
请求头阶段只做一件事:request_filter 在压缩打开时解析 Accept-Encoding,存进 accept_encoding。响应头到达时,response_header_filter 做决定,从 HeaderPhase 换到 BodyPhase:里面要么是一个编码器(Encode,encode(input, end) -> Bytes),要么是 None(不处理)。之后每块响应体交给 response_body_filter 压一下;编码出错就打 warn、换成 BodyPhase(None),之后的数据原样通过。
怎么决定
decide_action 只有三种结果:
if let Some(ce) = content_encoding {
if accept_encoding.contains(&ce) {
// downstream can accept this encoding, nothing to do
Action::Noop
} else {
// always decompress because uncompressed is always acceptable
// ...
Action::Decompress(ce)
}
} else if accept_encoding.is_empty() // both CE and AE are empty
|| !compressible(resp) // the type is not compressible
|| accept_encoding[0] == Algorithm::Any
{
Action::Noop
} else {
// try to compress with the first AC
// TODO: support to configure preferred encoding
Action::Compress(accept_encoding[0])
}
- 响应已经有
Content-Encoding:客户端接受就不动,不接受就解压。但解压默认关着(上面那个false),要adjust_decompression(true)才会真的解;能解的只有 gzip 和 br(decompressor_impl),zstd 不行。 - 没有
Content-Encoding:用Accept-Encoding里排第一个的算法压。compressor认 gzip、br、zstd 三种;dcz要先set_dictionary。 compressible先看长度,Content-Length小于 20 不压;再看类型,MIME_CHECK只放行text/、application/、font/、几种图标和 SVG、binary/octet-stream,类型里带zip的不压,没有Content-Type的也不压。
parse_accept_encoding 把值当作结构化字段列表(sfv)解析,按出现顺序收集认得的算法,循环里写着 // TODO: support q value。q 值被解析了,但没人看。* 是一个合法的 token,但 Algorithm::from("*") 得到的是 Other(只有空串才是 Any),于是被过滤掉,Accept-Encoding: * 的效果和没有这个头一样。
决定压缩或解压后,adjust_response_header 改响应头:设 Content-Encoding;删 Content-Length(边读边压,不知道压完多长);删 Accept-Ranges(字节范围对不上了);加 Transfer-Encoding: chunked(注释写着「TODO: chunked is for h1 only」,h2 的 write_response_header 会把它删掉);强 ETag 前面加 W/ 变弱,不是合法引号形式的 ETag 直接删,注释说是照 nginx gzip 模块的做法。只要响应「可能」因 Accept-Encoding 而不同,就加 Vary: Accept-Encoding,哪怕这次没压。
实验
curl -D - 发不同的 Accept-Encoding,Content-Type、Date、实验代码加的 x-l4 头省略:
| 端口与路径 | Accept-Encoding | 响应头 | 线上 body 字节 |
|---|---|---|---|
18306 /big | gzip | etag: W/"v1"、vary: accept-encoding、Content-Encoding: gzip、Transfer-Encoding: chunked;没有 Content-Length 和 Accept-Ranges | 80 |
18306 /big | br | 同上,Content-Encoding: br | 26 |
18306 /big | zstd | 同上,Content-Encoding: zstd | 31 |
18306 /big | br, gzip | Content-Encoding: br | 26 |
18306 /big | gzip, br | Content-Encoding: gzip | 80 |
18306 /big | identity | 原样,ETag: "v1"、Content-Length: 4200,多了 vary: accept-encoding | 4200 |
18306 /big | * | 同上 | 4200 |
18306 /small | gzip | 原样,没有 vary | 10 |
18300 /big | gzip | 原样,没有 vary | 4200 |
18308 /big | gzip | 原样,但多了 vary: accept-encoding | 4200 |
curl --compressed 取 18306 的 /big,解出来是 4200 字节。4200 字节的 body 是一个短语重复 300 次,所以压得这么小。
几行要解释:
- 改过的头变成了小写。
etag、vary是insert_header用http的常量名插入的,上游原来的ETag拼写没有保留,见 HTTP/1 服务端里大小写的那一节。 /small没有vary。 10 字节小于 20,compressible为假,depends_on_accept_encoding也为假,所以连Vary都不加。identity那一行不压,但响应确实可能因Accept-Encoding而变,所以加了。- 18308 的开关开晚了。
request_filter在下游模块的request_header_filter之后才运行(见 process_request)。压缩模块看请求头时级别还是 0,request_filter直接返回,Accept-Encoding没有被记下来;等request_filter里把级别调成 6,响应头阶段is_enabled()为真,Vary照加,但accept_encoding是空的,decide_action走Noop。结果是一个永远不压、却声明自己随Accept-Encoding变化的响应。要在钩子里打开压缩,得放在early_request_filter里,或者在init_downstream_modules里直接用一个非 0 的级别。
Accept-Encoding: gzip;q=0, br,意思是「不要 gzip,br 可以」。18306 回的 Content-Encoding 是什么?Session 上还有一个 pub upstream_compression: ResponseCompressionCtx(lib.rs),默认级别 0。它不是模块,由 proxy_h1 / proxy_h2 在处理上游响应时直接调,位置在 h1_response_filter 之前,缓存的处理和 response_filter 钩子都在这个函数里,所以写进缓存的是它处理过的内容。下游模块在缓存之后,处理的是真正写给这个客户端的响应。0.9.0 的上游模块排在 upstream_compression 前面,看到的是上游的原始字节,见上游模块。
response_done_filter 是为了压缩模块的最后几个字节加的HttpModule 原来只有请求头、请求体、响应头、响应体、trailer 五个过滤方法。响应结束时,有些路径(比如从缓存出响应)不发一个带结束标记的 body 块,而是发一个单独的 HttpTask::Done。下游模块收不到这个信号,压缩器里还没吐出来的字节就留在那里。f5bbb47(「Allow modules to receive HttpTask::Done, flush response compression」,首次进入 0.5.0;CHANGELOG 里链的是同一说明的提交 c82fb6b)的说明:「In the ResponseCompression module this could manifest as un-flushed body bytes. This change duplicates the response_filter logic currently used for upstream response compression.」它加了 response_done_filter;压缩模块的实现是 self.0.response_body_filter(None, true),用一块空数据加结束标记把编码器冲干净,代理再把返回的字节作为最后一块 body 写出去。
tower-http 的 CompressionLayer 按 q 值选编码、q=0 的先剔除,见 压缩;pingora 0.9.0 只看顺序。
动脑筋答案
===== port 18306 /big AE=[gzip;q=0, br]
HTTP/1.1 200 OK
etag: W/"v1"
vary: accept-encoding
Content-Encoding: gzip
Transfer-Encoding: chunked
Connection: keep-alive
body bytes on wire: 80
- 回的是 gzip,正好是客户端说不要的那一个。
parse_accept_encoding用sfv解析出两个条目:gzip(带参数q=0)和br。它只取每个条目的 token,参数一律不看,列表是[Gzip, Brotli]。decide_action用列表的第一个,于是Compress(Gzip)。RFC 9110 12.4.2 规定 q 值为 0 表示「not acceptable」,按 RFC 应该回 br。源码在那一行留了// TODO: support q value。
小结
HttpModule是请求头、请求体、响应头、响应体、trailer、结束六个过滤方法的集合,每个请求由 builder 新建一份,按order()从大到小依次调用;它不包裹下一层,只在固定位置被调用。- 代理默认就装了一个级别为 0 的
ResponseCompression,排在所有模块最后;钩子里用get_mut::<ResponseCompression>()找到它再adjust_level。 - 压缩只用
Accept-Encoding里排第一的算法,q 值不看,*等于没写;长度小于 20、类型不在白名单里的不压。 - 压缩时删
Content-Length和Accept-Ranges、改成 chunked、强 ETag 变弱、加Vary;解压默认关着,只支持 gzip 和 br。 - 在
request_filter里才打开压缩为时已晚:Accept-Encoding已经错过,响应带着Vary却从不压缩。 upstream_compression是另一份上下文,在缓存之前处理上游响应;response_done_filter(0.5.0)让压缩器在HttpTask::Done时也能冲出最后的字节。