HTTP 压缩选择的是施加了内容编码的表示,不是一种新的媒体类型。经过 gzip 的文本,解压后仍然是文本。因此有效验证需要回答三个问题:编码是否可接受、传输内容是否变小、解码后是否恢复了原本要提供的字节。配置里勾选了压缩,或者接口返回成功状态,都不能同时证明这三件事。

区分缺失、排除与偏好

根据 RFC 9110,Accept-Encoding 缺失时,任何内容编码都被认为可接受;显式空值则表示不希望使用内容编码。identity 代表没有内容编码,通常默认可接受,除非被 identity;q=0 或适用的 *;q=0 排除。通配符还可以让没有逐个列出的编码成为可接受候选,因此“只能发送客户端单独点名的编码”并不是完整规则。

质量值为零表示不可接受,正数则用于表达可接受编码之间的偏好。但服务器还必须具有对应编码器,并判断内容是否适合压缩。客户端偏好 Brotli,不会替服务器安装 Brotli。如果 identity 仍然可接受,未编码响应可能就是合理结果。即使所有候选都被排除,也不能假定每个服务器都会实施严格的 406 策略,因为 RFC 9110 还允许忽略协商偏好。应用需要哪种行为,必须单独验证。

坏掉的样子

代理压缩正文却漏写 Content-Encoding,客户端就可能把压缩字节当成文本;反过来,代理解压后保留原字段,则可能诱发第二次解码。Content-Length 如果存在,描述的是编码后内容的字节数,不是原始长度。流式压缩不一定提前知道最终大小,可以不发送这个字段,而不是继续沿用旧长度。消息分帧与内容编码是不同层次,不能混为一谈。

当 Accept-Encoding 参与可复用变体的选择时,Vary 需要描述这个维度,未编码响应也不能漏掉。否则,缓存中的 gzip 副本可能送给明确排除了 gzip 的客户端。缓存新鲜度和存储资格仍是另外的问题。即使选择正确,也不保证压缩值得:很小的正文存在额外开销,已经压缩过的媒体通常收益有限。应测代表性内容和处理成本,而不是声称所有文本都有一个固定的压缩百分比。

快速验证

准备 Python 3.11 或更新版本、支持 gzip 的 curl,以及 PATH 中的 Caddy 2.11.4。从官方发布页取得适合系统的二进制,并核对公布的校验和后再运行。程序使用回环端口 18765;若该端口占用,应在代码里换成空闲端口。它关闭管理端点、自动 HTTPS 和配置持久化,只清理自己创建的临时文件与子进程,不接触正在运行的网站服务。

import gzip
import os
import shutil
import socket
import subprocess
import tempfile
import time
from http.client import HTTPConnection, HTTPException
from pathlib import Path

caddy = shutil.which("caddy")
if caddy is None:
    raise SystemExit("Put Caddy 2.11.4 on PATH first")
print(subprocess.check_output([caddy, "version"], text=True).split()[0])
port = 18765
with socket.socket() as probe:
    probe.bind(("127.0.0.1", port))


def get(name, accept):
    connection = HTTPConnection("127.0.0.1", port, timeout=2)
    try:
        connection.putrequest("GET", "/" + name, skip_accept_encoding=True)
        if accept is not None:
            connection.putheader("Accept-Encoding", accept)
        connection.endheaders()
        response = connection.getresponse()
        body = response.read()
        assert response.status == 200
        return response.headers, body
    finally:
        connection.close()


with tempfile.TemporaryDirectory(prefix="compression-lab-") as temporary:
    directory = Path(temporary)
    site = directory / "site"
    site.mkdir()
    plain = b"HTTP content coding changes bytes, not the media type.\n" * 200
    (site / "large.txt").write_bytes(plain)
    (site / "tiny.txt").write_bytes(b"small\n")
    config = directory / "Caddyfile"
    config.write_text(
        "{\n admin off\n auto_https off\n persist_config off\n}\n"
        f"http://127.0.0.1:{port} {{\n root * {site}\n encode gzip\n file_server\n}}\n"
    )
    environment = dict(os.environ, XDG_DATA_HOME=str(directory / "data"),
                       XDG_CONFIG_HOME=str(directory / "config"))
    with (directory / "server.log").open("w+") as log:
        process = subprocess.Popen(
            [caddy, "run", "--config", str(config), "--adapter", "caddyfile"],
            stdout=log, stderr=log, env=environment,
        )
        try:
            deadline = time.monotonic() + 5
            while True:
                if process.poll() is not None or time.monotonic() > deadline:
                    log.seek(0)
                    raise RuntimeError(log.read() or "Caddy startup timed out")
                try:
                    _, body = get("large.txt", "identity")
                    assert body == plain
                    break
                except (OSError, HTTPException):
                    time.sleep(0.05)
            cases = [(None, None), ("", None), ("gzip", "gzip"),
                     ("gzip;q=0", None), ("br", None),
                     ("identity;q=0, gzip;q=1", "gzip"), ("*;q=0", None)]
            for accept, expected in cases:
                headers, body = get("large.txt", accept)
                coding = headers.get("Content-Encoding")
                assert coding == expected
                assert headers.get_content_type() == "text/plain"
                assert headers["Vary"] == "Accept-Encoding"
                decoded = gzip.decompress(body) if coding == "gzip" else body
                assert decoded == plain
                if coding == "gzip":
                    assert len(body) < len(plain)
                if headers.get("Content-Length") is not None:
                    assert int(headers["Content-Length"]) == len(body)
                print(repr(accept), coding, len(body), headers.get("Vary"))
            headers, body = get("tiny.txt", "gzip")
            assert body == b"small\n" and headers.get("Content-Encoding") is None
            print("tiny", len(body), headers.get("Vary"))
            decoded = subprocess.check_output([
                "curl", "-q", "--noproxy", "*", "-fsS", "--max-time", "3",
                "--compressed", "-H", "Accept-Encoding: gzip",
                f"http://127.0.0.1:{port}/large.txt",
            ])
            assert decoded == plain
            print("curl decoded:", len(decoded))
        finally:
            process.terminate()
            try:
                process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                process.kill()
                process.wait(timeout=5)

重复构造的样本共有一万一千字节。在本次验证中,gzip 内容为一百二十二字节,解压后完整恢复原始正文。比较不包括 HTTP 头与传输分帧,因此不是带宽或延迟基准。skip_accept_encoding=True 很关键:Python 否则会自动添加 identity 偏好,把本来想验证的“字段缺失”变成了另一个请求条件,造成实验输入与口头描述不一致。

本配置对字段缺失、空值、排除 gzip 和不受支持的 Brotli 请求都返回未编码正文。缺失允许压缩,但不强制服务器必须压缩。两个接受 gzip 的案例则收到 gzip 表示。六字节小文件即使接受 gzip 也不压缩,因为它低于 Caddy 默认的五百一十二字节门槛。大文件测试同时检查 Vary、媒体类型和解码后的字节完全相同,不是只确认某个头里出现了 gzip。

*;q=0 在这份经过验证的 Caddy 配置里仍得到 200 与未编码正文,尽管 identity 已经被排除。这说明不能从“启用了压缩”推导出严格拒绝契约。程序断言是在记录实现行为,并不是认定客户端的排除条件已被满足。若应用要求没有可接受表示就拒绝,应在负责该契约的端点验证或实现相应策略,不要依赖一个未经测试的配置想象。

最后,curl 的 --compressed 返回已经解码的一万一千字节。单独保存的响应头仍可能包含 Content-Encoding,不能因此再把输出解压一次。实验显式绕过 curl 配置文件和代理,避免本机设置影响回环请求。生产验收仍需沿真实代理或 CDN 重复选择与字节检查,因为中间层可能新增、移除或重复施加压缩。局部压缩率正常,不代表公网收到的表示也正确。

延伸阅读

来源