透過為每份文件傳送穩定的驗證器、檢查用戶端儲存的驗證器,並回傳目前文件或不含本文的 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/ 為前綴,可描述語意等價但位元組有所不同的內容。條件式 GET 的 If-None-Match 比對採用弱比較,因此 W/"doc-v1" 會符合 "doc-v1"。請求也可以包含標籤清單,或單獨使用萬用字元 *。RFC 9110,If-None-Match。
Last-Modified 描述文件變更的時間。請使用一致判定的修改時間,並納入相關文件組成部分,而不是每次收到請求時的時間。此時間不得晚於回應的 Date。RFC 9110,Last-Modified 的產生方式。
範例為兩個合成修訂版本指定了刻意用於說明的發布時間:2024 年 1 月 1 日和 1 月 2 日格林威治標準時間正午。這些日期是測試資料。實際發布流程應使用持續維護的發布中繼資料。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 電子郵件日期工具。
在 Terminal 中,切換到包含 server.py 的資料夾,並啟動修訂版本 1。讓該視窗保持執行。如果連接埠 8000 已被占用,請在程式中選擇其他可用連接埠,並在每個測試網址中使用相同連接埠。
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。請保持請求網址不變。
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-network-or-cache fetch。
診斷伺服器行為時,請先執行上述明確的 curl 請求。在瀏覽器 JavaScript 中手動提供條件式標頭會改變測試方式:Fetch 會將含有這些標頭的預設快取請求切換為 no-store。測試瀏覽器的一般重新驗證行為時,請讓瀏覽器自行管理快取。Fetch 標準,請求快取模式。
Google 的爬取基礎架構支援這兩組驗證器,並建議使用 ETag,因為它能避免日期格式問題。其說明文件也指出,該基礎架構不支援其他 HTTP 快取指示詞。請將瀏覽器快取政策與對 Google 重新爬取行為的預期分開選擇。Google 爬蟲與擷取工具概覽。
Google 不會在每次爬取嘗試時都傳送條件式標頭。其搜尋說明文件指出,304 允許重用先前爬取的內容;較新的內容則可能收到包含更新本文的 200。請確保一般的非條件式請求仍可正常運作;爬蟲請求未帶驗證器,並不能證明你的實作失敗。排解 Google 搜尋爬取錯誤。
將此模式套用到已發布文件之前,請針對該文件實際提供服務的網址重複驗收流程。確認未變更的修訂版本會保留驗證器、編輯會更新驗證器,且 304 不帶文件本文。這樣才能正確重用文件,同時確保變更後的內容仍可擷取。
來源
- RFC 9110:HTTP 語意 — IETF / RFC 編輯者。
- RFC 9111:HTTP 快取 — IETF / RFC 編輯者。
- Fetch 標準 — WHATWG。
- HTTP 條件式請求 — MDN Web Docs。
- ETag 標頭 — MDN Web Docs。
- Last-Modified 標頭 — MDN Web Docs。
- http.server — HTTP 伺服器 — Python 軟體基金會。
- email.utils:各種工具 — Python 軟體基金會。
- curl — 使用方式 — curl 專案。
- Google 爬蟲與擷取工具(使用者代理程式)概覽 — Google。
- 排解 Google 搜尋爬取錯誤 — Google 搜尋中心。