Rust in Everything
基于 pingora-core 0.9.0

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 /biggzipetag: W/"v1"、vary: accept-encoding、Content-Encoding: gzip、Transfer-Encoding: chunked;没有 Content-Length 和 Accept-Ranges80
18306 /bigbr同上,Content-Encoding: br26
18306 /bigzstd同上,Content-Encoding: zstd31
18306 /bigbr, gzipContent-Encoding: br26
18306 /biggzip, brContent-Encoding: gzip80
18306 /bigidentity原样,ETag: "v1"、Content-Length: 4200,多了 vary: accept-encoding4200
18306 /big*同上4200
18306 /smallgzip原样,没有 vary10
18300 /biggzip原样,没有 vary4200
18308 /biggzip原样,但多了 vary: accept-encoding4200

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 的级别。
❗IMPORTANT
动脑筋:客户端发 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 前面,看到的是上游的原始字节,见上游模块。

📝NOTE
冷知识: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 时也能冲出最后的字节。

💬 关联讨论

发起讨论
加载中...