通过为每个文档发送稳定的验证器、检查客户端保存的验证器,并返回当前文档或不带正文的 304 Not Modified,实现条件 GET。本 Mac 教程使用一个合成文本页面演示这一过程。

任务是让对公共文档的重复请求仅在需要时传输其内容。你将运行一个小型本地 Python 服务器,从 Terminal 测试它,并模拟文档更新。示例仅在本地运行;发布它是另一项任务。

服务器应如何在 200 和 304 之间作出选择?

初始请求会收到 200 OK、文档正文及其验证器。之后的条件请求会发送保存的验证器。如果文档未更改,服务器会返回 304 Not Modified,客户端便可复用已存储的正文。内容发生更改时,则会收到新的 200 响应。HTTP 条件请求。

对于本应返回 200 的现有文档,请按以下顺序作出判断。If-None-Match 的优先级高于 If-Modified-Since;它们不是两个独立的测试。RFC 9110,条件请求字段。

请求条件 响应
没有条件请求标头 200,包含文档
If-None-Match 与当前标签匹配 304,不带正文
存在 If-None-Match,但不匹配 200,即使提供的日期表明内容未更改也是如此
仅提供有效的 If-Modified-Since,且文档没有更新 304,不带正文
仅提供有效的 If-Modified-Since,且文档已更新 200,包含文档

选择文档并确定其正常响应后,再评估这些条件。文档不存在时,仍必须返回其正常的资源不存在响应。304 不能包含内容或尾部字段。它必须携带适用的缓存元数据,包括本应随其 200 响应一同返回的 Date、ETag、Vary、Cache-Control、Expires 和 Content-Location 字段。RFC 9110,前置条件评估 和 304 响应要求。

选择能够描述已发布文档的验证器

ETag 是带引号的版本标识符,例如 "doc-v1"。其生成方式由实现自行决定:修订标识符或内容哈希都可以。对于相同的表示形式,应保持其稳定;表示形式发生变化时,则应更改它。此示例使用两个内容固定的显式修订版本。ETag 标头。

强标签标识特定的表示形式;弱标签带有前缀 W/,可描述语义等价但字节不同的内容。使用 If-None-Match 进行条件 GET 比较时采用弱比较,因此 W/"doc-v1" 与 "doc-v1" 匹配。请求也可以包含标签列表,或单独的通配符 *。RFC 9110,If-None-Match。

Last-Modified 表示文档发生更改的时间。应使用一致确定的修改时间,并纳入相关文档组件,而不是每次请求的时间。该时间不得晚于响应的 Date。RFC 9110,Last-Modified 的生成。

示例为两个合成修订版本指定了特意选取的说明性发布时间:2024 年 1 月 1 日和 1 月 2 日 GMT 中午。这些日期是测试数据。在实际发布流程中,应使用维护的发布时间元数据。Last-Modified 在检测内容差异方面不如 ETag 精确,但可作为有用的回退方案。Last-Modified 标头。

单独选择缓存策略

验证器用于判断已存储的副本是否仍然匹配。新鲜度策略决定缓存何时需要发起询问。本教程发送 Cache-Control: no-cache,允许存储,但要求在复用前成功验证。这样可以轻松观察重复请求的行为。no-store 禁止存储,而 max-age 允许在响应仍然新鲜时复用。RFC 9111,响应缓存指令。

对于可容忍更新延迟一小段时间才显示的公共文档,max-age=60 之类的策略可能合适。这只是说明性的编辑选择:应根据文档的更新需求来确定时间间隔。新鲜的缓存响应可以直接复用,无需联系服务器,因此并非每次重复访问都会产生 304。RFC 9111,新鲜度。

在 Mac 上运行完整的本地示例

打开 Terminal,使用以下命令检查前置条件。请使用 Python 3.9 或更高版本,因为程序使用了 str.removeprefix。如果没有 python3,请先安装 Python 3。无需安装第三方 Python 软件包。

python3 --version
curl --version

在新的工作文件夹中,使用纯文本代码编辑器将以下完整程序保存为 server.py。它仅提供 /page.txt,实现 GET 和 HEAD,并根据命令行参数选择文档修订版本。Python 的 BaseHTTPRequestHandler 会将请求分派给相应的方法,并提供用于写入响应的辅助函数。Python HTTP 服务器文档。

import argparse
import re
from datetime import datetime, timezone
from email.utils import format_datetime, parsedate_to_datetime
from http.server import BaseHTTPRequestHandler, HTTPServer

parser = argparse.ArgumentParser()
parser.add_argument("version", type=int, choices=(1, 2))
args = parser.parse_args()

BODY = {
    1: b"Paper Shapes\nA circle and a square.\n",
    2: b"Paper Shapes\nA circle, a square, and a triangle.\n",
}[args.version]
ETAG = '"doc-v%d"' % args.version
MODIFIED = datetime(2024, 1, args.version, 12, tzinfo=timezone.utc)
LAST_MODIFIED = format_datetime(MODIFIED, usegmt=True)
TOKEN = r'(?:W/)?"[\x21\x23-\x7e\x80-\xff]*"'
TAG_LIST = re.compile(rf'[ \t]*(?:{TOKEN})?(?:[ \t]*,[ \t]*(?:{TOKEN})?)*[ \t]*')

def tag_matches(value):
    if value.strip() == "*":
        return True
    if not TAG_LIST.fullmatch(value):
        return False
    return any(tag.removeprefix("W/") == ETAG
               for tag in re.findall(TOKEN, value))

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.serve_document()

    def do_HEAD(self):
        self.serve_document()

    def serve_document(self):
        if self.path != "/page.txt":
            self.send_error(404)
            return
        not_modified = False
        tags = self.headers.get_all("If-None-Match")
        if tags is not None:
            not_modified = tag_matches(",".join(tags))
        else:
            dates = self.headers.get_all("If-Modified-Since", [])
            if len(dates) == 1:
                try:
                    since = parsedate_to_datetime(dates[0])
                    if since.tzinfo is None:
                        since = since.replace(tzinfo=timezone.utc)
                    not_modified = MODIFIED <= since
                except (TypeError, ValueError, OverflowError):
                    pass
        self.send_response(304 if not_modified else 200)
        self.send_header("ETag", ETAG)
        self.send_header("Cache-Control", "no-cache")
        if not not_modified:
            self.send_header("Last-Modified", LAST_MODIFIED)
            self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(BODY)))
        self.end_headers()
        if not not_modified and self.command == "GET":
            self.wfile.write(BODY)

if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

这是对两个指定验证器的本地演示,并非完整的 HTTP 服务栈。日期转换使用了 Python 宽松的电子邮件日期解析器;部署时应使用针对 HTTP 的日期验证和条件请求处理。Python 文档说明了该解析器接受的输入及其异常。Python email 日期实用工具。

在 Terminal 中,切换到包含 server.py 的文件夹并启动修订版本 1。保持该窗口运行。如果端口 8000 已被占用,请在程序中选择另一个可用端口,并在每个测试 URL 中使用相同端口。

python3 server.py 1

测试内容未更改、内容已更改和优先级

打开另一个 Terminal 窗口。以下请求会显示响应标头以及任何返回的正文。-i 选项会包含标头,-H 用于提供请求标头,--max-time 用于限制每次传输的时长。curl 手册。

获取初始响应

curl --max-time 5 -sS -i http://127.0.0.1:8000/page.txt

预期结果为 200 OK、合成页面文本、ETag: "doc-v1" 和 Last-Modified: Mon, 01 Jan 2024 12:00:00 GMT。将此流程用于其他文档时,记录实际返回的值;验证器只适用于对应的响应。

使用各个验证器重新验证

curl --max-time 5 -sS -i \
  -H 'If-None-Match: "doc-v1"' \
  http://127.0.0.1:8000/page.txt

curl --max-time 5 -sS -i \
  -H 'If-Modified-Since: Mon, 01 Jan 2024 12:00:00 GMT' \
  http://127.0.0.1:8000/page.txt

两个请求都应返回 304 Not Modified,且不带页面文本。响应会保留 ETag 和 Cache-Control;Python 的响应辅助函数会添加 Date。使用 W/"doc-v1" 重复第一个请求,也应返回 304。示例会解析带引号的标签,而不是简单地按逗号拆分,因为标签中可能包含逗号。

验证 ETag 的优先级

curl --max-time 5 -sS -i \
  -H 'If-None-Match: "older-revision"' \
  -H 'If-Modified-Since: Wed, 03 Jan 2024 12:00:00 GMT' \
  http://127.0.0.1:8000/page.txt

预期结果为 200 OK,并包含文档。标签不匹配,因此后面的日期不得触发 304。这个有意设置为互相冲突的请求可以检测出错误地组合了两项测试的实现。

模拟发布下一个修订版本

在服务器窗口中按 Control-C,然后启动修订版本 2。保持请求 URL 不变。

python3 server.py 2

使用修订版本 1 的验证器,重复之前的两个重新验证请求。此时两个请求都应收到 200、提及三角形的更新后句子、ETag: "doc-v2" 和 1 月 2 日的修改日期。使用 If-None-Match: "doc-v2" 重复请求,则应返回 304。

这些结果构成一套验收流程:初始下载、内容未更改时的验证、验证器冲突、文档已更改,以及针对新修订版本的内容未更改验证。该示例已针对两个修订版本进行了 24 项本地检查,包括弱标签、标签列表、通配符匹配、HEAD 和缺失路径。

为什么浏览器和 Google 请求可能看起来不同

浏览器通常通过其缓存管理 HTTP 验证器。WHATWG Fetch 算法会在重新验证时添加已保存的验证器,并在成功收到 304 后更新已存储响应的标头并返回该存储响应。因此,即使网络重新验证收到的是 304,应用程序 JavaScript 仍可收到可用的 200 响应和文档正文。Fetch 标准,HTTP 网络或缓存获取。

诊断服务器行为时,请先从上面的显式 curl 请求开始。在浏览器 JavaScript 中手动提供条件请求标头会改变测试方式:对于包含这些标头的默认缓存请求,Fetch 会切换到 no-store。测试浏览器的常规重新验证行为时,应让浏览器自行管理缓存。Fetch 标准,请求缓存模式。

Google 的抓取基础设施支持这两对验证器,并建议使用 ETag,因为它可以避免日期格式问题。其文档还指出,该基础设施不支持其他 HTTP 缓存指令。请将浏览器缓存策略与对 Google 重新抓取行为的预期分开选择。Google 抓取工具和获取工具概览。

Google 并不会在每次抓取尝试时都发送条件请求标头。其 Search 文档说明,304 允许复用先前抓取的内容,而较新的内容可以收到带有更新后正文的 200。请确保普通的无条件请求仍能正常工作;抓取工具发出的请求不带验证器,并不能证明你的实现失败。排查 Google Search 抓取错误。

将此模式应用于已发布的文档之前,请针对其实际服务 URL 重复验收流程。检查未更改的修订版本是否保留其验证器,编辑是否会更新验证器,以及 304 是否不带文档正文。理想结果是正确复用文档,同时仍能获取已更改的内容。

来源