各ドキュメントとともに安定したバリデーターを送信し、クライアントが保存したバリデーターを確認して、現在のドキュメントまたは本文のない304 Not Modifiedを返すことで、条件付きGETを実装します。このMac向けチュートリアルでは、合成テキストページを使って手順を説明します。

このタスクでは、公開ドキュメントへの再リクエスト時に、必要な場合に限って内容を転送するようにします。小さなローカルPythonサーバーを起動し、Terminalからテストして、ドキュメントの更新をシミュレートします。この例はローカルで完結します。公開は別の作業です。

サーバーは200と304をどのように選べばよいですか?

最初のリクエストには、ドキュメント本文とバリデーターを含む200 OKが返ります。後続の条件付きリクエストでは、保存済みのバリデーターを送り返します。ドキュメントが変更されていなければ、サーバーは304 Not Modifiedを返し、クライアントは保存済みの本文を再利用できます。内容が変更されていれば、新しい200レスポンスが返ります。HTTP条件付きリクエスト。

通常なら200を返す既存のドキュメントには、次の判定順序を適用します。If-None-MatchはIf-Modified-Sinceより優先されます。この2つは独立した判定ではありません。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"のような引用符付きのバージョン識別子です。生成方法は実装によって異なり、リビジョン識別子でもコンテンツハッシュでもかまいません。同じ表現には同じ値を維持し、表現が変わったら値も変更します。この例では、内容が固定された2つの明示的なリビジョンを使います。ETagヘッダー。

強いタグは特定の表現を識別します。弱いタグにはW/という接頭辞が付き、バイト列が異なっても意味的には同等なコンテンツを示せます。If-None-Matchを使った条件付きGETの比較には弱い比較が使われるため、W/"doc-v1"は"doc-v1"と一致します。リクエストにはタグのリストや、単独のワイルドカード*を含めることもできます。RFC 9110、If-None-Match。

Last-Modifiedは、ドキュメントが変更された日時を示します。リクエストのたびに現在時刻を使うのではなく、関連するドキュメント構成要素を反映した、一貫した方法で決めた更新日時を使います。この値はレスポンスのDateより後になってはいけません。RFC 9110、Last-Modifiedの生成。

このサンプルでは、合成リビジョン2つに例示用の公開時刻を割り当てています。2024年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を開き、次のコマンドで前提条件を確認します。このプログラムではstr.removeprefixを使うため、Python 3.9以降が必要です。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()

これは、指定した2つのバリデーターを扱うローカルのデモであり、HTTP配信スタック全体を実装したものではありません。日付変換にはPythonの寛容なメール日付パーサーを使っています。実運用環境では、HTTP固有の日付検証と条件付きリクエスト処理を使ってください。Pythonのドキュメントでは、このパーサーが受け付ける入力と発生する例外が説明されています。Pythonのメール日付ユーティリティ。

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が発生してはいけません。この意図的に矛盾させたリクエストで、2つの判定を誤って組み合わせる実装を見つけられます。

次のリビジョンの公開をシミュレートする

サーバーのウィンドウでControl-Cを押し、リビジョン2を起動します。リクエストURLはそのままにします。

python3 server.py 2

リビジョン1のバリデーターを含む先ほどの2つの再検証リクエストを繰り返します。どちらにも、三角形に言及する更新済みの文、ETag: "doc-v2"、1月2日の更新日とともに、200が返るはずです。If-None-Match: "doc-v2"を指定したリクエストを繰り返すと、304が返るはずです。

これらの結果で、初回ダウンロード、変更なしの検証、矛盾するバリデーター、変更後のドキュメント、新しいリビジョンに対する変更なしの検証、という受け入れ確認の手順を実行できます。このサンプルは、弱いタグ、タグのリスト、ワイルドカードの一致、HEAD、存在しないパスなどを含む、両方のリビジョンにわたる24件のローカルチェックで検証済みです。

ブラウザーやGoogleのリクエストの見え方が異なる理由

ブラウザーは通常、キャッシュを通じてHTTPバリデーターを管理します。WHATWG Fetchアルゴリズムは再検証時に保存済みのバリデーターを追加し、304が成功すると保存済みレスポンスのヘッダーを更新して、保存済みのレスポンスを返します。そのため、ネットワーク上の再検証で304を受け取っていても、アプリケーションのJavaScriptは使用可能な200レスポンスとドキュメント本文を受け取れます。Fetch Standard、HTTP-network-or-cache fetch。

サーバーの動作を診断するときは、まず上記の明示的なcurlリクエストを使ってください。ブラウザーのJavaScriptで条件付きヘッダーを手動指定するとテストの内容が変わります。Fetchは、そうしたヘッダーを含むデフォルトキャッシュのリクエストをno-storeに切り替えます。通常のブラウザー再検証をテストするときは、キャッシュ管理をブラウザーに任せてください。Fetch Standard、リクエストのキャッシュモード。

Googleのクロール基盤は、どちらのバリデーターの組み合わせにも対応しており、日付形式に関する問題を避けられるためETagを推奨しています。また、同基盤では他のHTTPキャッシュディレクティブはサポートされていないと、Googleのドキュメントに記載されています。ブラウザーのキャッシュポリシーは、Googleの再クロール動作に対する予想とは別に選んでください。Googleのクローラーとフェッチャーの概要。

Googleは、すべてのクロール試行で条件付きヘッダーを送るわけではありません。Google検索のドキュメントによると、304があれば以前にクロールしたコンテンツを再利用でき、更新されたコンテンツには更新後の本文を含む200が返される場合があります。通常の無条件リクエストも引き続き処理できるようにしてください。クローラーのリクエストにバリデーターがないからといって、実装が失敗した証拠にはなりません。Google検索のクロールエラーのトラブルシューティング。

このパターンを公開済みドキュメントに適用する前に、実際の配信URLに対して受け入れ確認の手順をもう一度実行してください。変更されていないリビジョンではバリデーターが維持され、編集時には更新され、304にはドキュメント本文が含まれないことを確認します。ドキュメントを正しく再利用でき、変更された内容を取得できることが期待される結果です。

出典