0%

Caching und bedingte Anfragen · Übung

Ein Client, der sich erinnert

Dein Server versieht jetzt jeden Lesezugriff mit einem Tag, sagt, wie lange er wiederverwendet werden darf, und beantwortet ein passendes If-None-Match mit einer bodylosen 304. Diese Lektion gibt wire.py einen kleinen Cache: cached_get(url, cache, *, now, make_connection, timeout), ein GET, das sich erinnert.

Um zu entscheiden, ob eine gespeicherte Antwort noch frisch ist, braucht es die aktuelle Zeit, ein Problem, das du schon einmal gelöst hast. Im Abschlussprojekt von Python in der Praxis, Kapitel 13, Lektion 2, hat cache_is_stale now_epoch als genommen, statt time.time() aufzurufen, damit ein Test ihm jeden beliebigen Zeitpunkt übergeben konnte. cached_get tut dasselbe: Sein Aufrufer übergibt now als Anzahl von Sekunden, und nichts in wire.py liest eine Uhr.

Frisch, veraltet, weg

cache ist ein , das dem Aufrufer gehört und das er bei jedem Aufruf übergibt, anfangs leer. cached_get führt einen Eintrag pro , unter der URL:

cache[url] = {"etag": tag, "expires": now + max_age, "record": record}

record ist der Datensatz, den request_once für die 200 zurückgegeben hat (Kapitel 6, Lektion 4): "status", "reason", "headers", "body" und "url". etag ist das Tag der Antwort, aufbewahrt, um später fragen zu können. expires ist der Zeitpunkt, an dem die Kopie veraltet: die Zeit, zu der sie gespeichert wurde, plus das max-age der Antwort.

Jeder Aufruf ist in einer von drei Situationen, und der Datensatz, den cached_get zurückgibt, nennt sie in einem neuen Schlüssel, "source":

  • Nichts gespeichert: Sende ein einfaches GET, speichere die Antwort, wenn sie gespeichert werden kann, und gib sie mit "source": "network" zurück.

  • Frisch: Sende nichts. Gib eine Kopie des gespeicherten Datensatzes mit "source": "cache" zurück.

  • Veraltet: Frag den Server mit If-None-Match und dem gespeicherten Tag. Eine 304 bedeutet, dass die Kopie noch stimmt: Gib sie mit "source": "revalidated" zurück. Eine 200 bedeutet, dass sie sich geändert hat: Speichere die neue und gib sie mit "source": "network" zurück.

Gegen deinen Server, dessen Antworten max-age=60 tragen, laufen drei Aufrufe so ab:

Bei 0 wird die Antwort geholt und gespeichert, bei 30 kommt sie aus dem Cache, bei 61 erneuert eine 304 sie.

Bei 0 wird die 200 mit dem Tag T und max-age=60 gespeichert, veraltet ab 60. Bei 30 wird nichts gesendet. Bei 61 bekommt If-None-Match: T eine 304 mit max-age=60, frisch bis 121. Drei Aufrufe, zwei Anfragen, ein Body auf der Leitung.

Die Grenze gehört HTTP

Wann genau veraltet eine Kopie? RFC 9111 (Abschnitt 4.2) gibt eine Formel: Eine Antwort ist frisch, wenn ihre Frischedauer größer als ihr aktuelles Alter ist. Hier ist die Dauer max-age und das Alter now minus die Zeit, zu der sie gespeichert wurde. In dem Moment, in dem das Alter max-age erreicht, ist „größer als“ falsch, also ist die Kopie veraltet. Wenn expires die Speicherzeit plus max-age enthält, ist der Test now < entry["expires"]: frisch vor diesem Zeitpunkt, veraltet ab diesem Zeitpunkt.

Das ist nicht die Grenze, die Python in der Praxis gezogen hat. Sein Datei-Cache war ein eigener Vertrag, und der sagte, dass ein Alter gleich der Grenze noch als frisch zählt, also verglich er age > max_age. Beides sind vernünftige Entscheidungen für einen Cache, den du selbst entwirfst. Hier sind die Regeln die von , weil der Server, der max-age=60 sendet, sie im Sinne von HTTP meint, also ist eine bei 0 gespeicherte Kopie genau bei 60 veraltet.

Eine bei 100 gespeicherte Antwort trägt max-age=60. Was gibt das aus?

stored_at = 100
max_age = 60
for now in [130, 160]:
    print(now < stored_at + max_age)

Eine bei 100 gespeicherte Antwort hat max-age=60. cached_get wird mit now=160 aufgerufen. Was tut es?

Revalidieren

Ein veralteter Eintrag wird nicht weggeworfen, weil sein Tag den Download noch ersparen kann. cached_get sendet GET mit einem Header, {"If-None-Match": entry["etag"]}, über request_once und gibt dabei das make_connection und den timeout weiter, die es bekommen hat, wie es follow getan hat. Dann liest es das cache-control der Antwort mit cache_rules.

Bei einer 304 ist der gespeicherte Datensatz die Antwort, und die 304 entscheidet, wie lange er frisch bleibt: Das expires des Eintrags wird zu now plus das eigene max-age der 304. Deshalb wiederholt dein Server cache-control auf einer 304. Eine 304 ohne cache-control ergäbe max_age 0, also fragt dieser Client beim nächsten Mal einfach wieder. Der Body einer 304 ist leer, also würde der Aufrufer nichts bekommen, wenn der eigene Datensatz der 304 zurückgegeben würde; gib stattdessen eine Kopie des gespeicherten Datensatzes zurück.

Bei einer 200 ersetzt die neue Version den Eintrag wie jede Antwort aus dem Netzwerk.

Eine Antwort wird nur gespeichert, wenn sie eine 200 mit genau einem etag ist, also hat ein gespeicherter Eintrag immer ein Tag, mit dem er fragen kann. Eine 404 oder eine 200 ohne Tag wird zurückgegeben und nicht gespeichert. Das ist eine Vereinfachung dieses Clients: HTTP würde einem Cache auch erlauben, eine 200 mit max-age und ohne Tag zu behalten und sie wiederzuverwenden, solange sie frisch ist.

Gib Kopien zurück: "source" oder einen anderen Schlüssel der obersten Ebene auf entry["record"].copy() zu setzen, lässt den gespeicherten Datensatz, wie er war. Die Kopie ist allerdings flach: Ihr verschachteltes headers-Dictionary wird weiterhin mit dem Cache geteilt.

Bei 160 kommt eine 304 mit cache-control: max-age=30 an. Wann sendet cached_get seine nächste Anfrage für diese URL?

Echte Caches verfolgen mehr: Age, wie lange eine Antwort schon in einem anderen Cache lag; Vary, für Antworten, die sich je nach unterscheiden; und eine geschätzte Frische, wenn es kein max-age gibt. Dieser Cache lebt für einen Lauf in einem Dictionary; das Abschlussprojekt bewahrt einen in einer Datei auf.

Deine Aufgabe

Schreib in wire.py zwei .

cache_rules(values) nimmt die der cache-control- einer Antwort und gibt {"store": True, "max_age": seconds} zurück. Teile jeden Wert an Kommas, entferne bei jeder Direktive die Leerzeichen am Rand, schreib sie klein, und wenn eine mit max-age= beginnt, lies die Zahl danach. Nur ASCII-Ziffern zählen, wie in read_body: Prüf isascii() und isdigit() vor int(). Ohne brauchbares max-age ist max_age 0. store ist vorerst immer True; Lektion 6 fügt die Direktiven hinzu, die das ändern.

cached_get(url, cache, *, now, make_connection=http.client.HTTPConnection, timeout=TIMEOUT) folgt den drei Situationen von oben, ohne Uhr.

Führ dann aus:

python pl_http.py cache

Das Hilfsprogramm ruft dein cached_get gegen deinen eigenen Server bei 0, 30 und 61 für /books auf, dann zweimal für /me, und gibt die Quelle jedes Datensatzes aus und wie viele Anfragen den Server erreicht haben. Bei 30 solltest du Folgendes sehen:

--- your cached_get() at now 30 for /books ---
source: 'cache'
status: 200

und weiter unten requests: 0 für diesen Aufruf. Bei 61 geht eine Anfrage mit dem Tag hinaus:

request 1  GET /books  (If-None-Match: "ba811c01801a2c40d5e507c3c439e7e966380095a4001999178055355cebbec1")  ->  304 Not Modified
--- your cached_get() at now 61 for /books ---
source: 'revalidated'

/me wird zweimal gefragt, weil seine Antwort kein Tag hat. Drück dann Check my work.

Dein Client würde auch eine Antwort aufbewahren, die nennt, wer angemeldet ist, wenn sie mit einem Tag käme. Lektion 6 sorgt dafür, dass das nie passiert.

Aufgabe

Schreib cache_rules(values) und cached_get(url, cache, *, now, make_connection, timeout) in wire.py. Importiere nirgends time und lies nirgends eine Uhr: Jede Zeit kommt aus now.

  • cache_rules(values) gibt {"store": True, "max_age": seconds} zurück. Teile jeden an Kommas, entferne bei jeder Direktive die Leerzeichen am Rand und schreib sie klein; bei max-age=N, wobei N aus ASCII-Ziffern besteht, ist max_age N. Andernfalls ist es 0.

  • cached_get führt einen Eintrag pro in cache: {"etag": tag, "expires": now + max_age, "record": record}.

    • Frisch (now ist kleiner als das expires des Eintrags): Sende nichts; gib eine Kopie des gespeicherten Datensatzes mit "source": "cache" zurück.

    • Veraltet: Sende GET mit If-None-Match, auf das gespeicherte Tag gesetzt, über request_once. Bei einer 304 setz das expires des Eintrags auf now plus das max-age der 304 und gib eine Kopie des gespeicherten Datensatzes mit "source": "revalidated" zurück.

    • Andernfalls, und wenn nichts gespeichert ist, bekommt der Datensatz der Antwort "source": "network". Speichere ihn, wenn er eine 200 mit genau einem etag ist; andernfalls speichere nichts.

    • Gib make_connection und timeout an request_once weiter.

Führ python pl_http.py cache im aus, um deinen Cache gegen deinen eigenen Server zu beobachten.

Drück dann Check my work. Es ruft cache_rules mit Werten auf, die du noch nicht gesehen hast, darunter Max-Age, zwei Zeilen, max-age=abc und max-age=². Es ruft cached_get mit einem Ersatz für make_connection auf, dessen Verbindungen mit vorbereiteten Antworten antworten, zu den Zeiten 1000 und 86400 mit max-age 45 und 7, auf Hosts und , die du noch nicht gesehen hast. Es prüft die Anfrage, die jeder Aufruf sendet (keine, wenn frisch, ein einfaches GET bei einem Fehltreffer, If-None-Match mit dem gespeicherten Tag, wenn veraltet), den Datensatz und sein "source", dass der Eintrag genau zur Speicherzeit plus max-age veraltet ist, dass eine 304 den Timer mit ihrem eigenen max-age neu startet und den gespeicherten Body zurückgibt, dass eine 200 den Eintrag ersetzt, dass eine 404, eine 200 ohne Tag und eine 200 mit zwei Tags nicht gespeichert werden, dass die Änderung eines zurückgegebenen Datensatzes den Cache nicht ändert, dass jede Verbindung geschlossen und der Timeout weitergegeben wird und dass wire.py kein time importiert. Nichts aus deinem Terminal wird bewertet.