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 重复选择与字节检查,因为中间层可能新增、移除或重复施加压缩。局部压缩率正常,不代表公网收到的表示也正确。
延伸阅读
来源
RFC 9110:主动协商,无法满足偏好时可以返回 406 或忽略该协商字段,已核验 2026-10-15。
RFC 9110:Accept-Encoding,字段缺失、identity、排除条件与内容编码,已核验 2026-09-07。
Caddy encode 指令,编码选择与默认大小门槛,已核验 2026-09-07。
Caddy 2.11.4 发布,实际验证的二进制与公布的校验和,已核验 2026-09-07。
curl 压缩模式,自动解压及保存响应头不变的行为,已核验 2026-09-07。