HTTP Response Header Last-Modified und ETag mit PHP für Caching setzen

Bei statischen Dateien braucht man sich um das Thema Caching in der Regel wenig Sorgen machen. Natürlich können auch statische Dateien den Server ganz ordentlich beschäftigen, insbesondere wenn eine Webseite gut besucht wird und man den schwer gewichtigen Apache als Frontend-Webserver eingerichtet hat. In diesem Fall und natürlich auch für Projekte in denen PHP zum Einsatz kommt, kann ich Varnish Cache sehr empfehlen.

Jedoch hat nicht jeder die Möglichkeit so weit ins System einzugreifen und manchmal möchte oder muss man auch auf PHP-Ebene entscheiden, ob eine vom Client (Browser) gecachte Datei noch aktuell ist oder nicht. Und hier kommen diverse HTTP Response Header ins Spiel, die man mit PHP setzen kann.

So kann man am Anfang eines Programms zum Beispiel abfragen, wann ein Artikel aus einem Shop das letzte Mal geändert wurde. Oder will einfach nur schauen, ob sich eine spezielle Datei seit der letzten Anfrage des Clients geändert hat.

Diesen serverseitig errechneten „zuletzt modifiziert“ Zeitstempel vergleicht man dann mit dem Zeitstempel, den der Client an den Server mitgeschickt hat. Ist die gecachte Version noch aktuell, kann das Programm sofort abgebrochen werden – man sagt dem Client einfach: „Deine Version ist noch aktuell, es hat sich hier nix geändert.“

Komplexe und eventuell Ressourcen fressende Routinen müssen damit nicht mehr ausgeführt werden, der Server wird entlastet und hat wieder Luft für die nächste Anfrage.

Damit der Einbau möglichst einfach gestaltet werden kann, habe ich hier einmal die von mir geschriebene Funktion gepostet (die jeder gerne frei nutzen kann):

/**
 * Sets http response headers "last-modified" and "etag"
 *
 * This function also checks if the client cached version of the requested site
 * is still up to date by comparing last-modified and/or etag headers of server
 * and client (in stict mode both have to match) for a given last modification
 * timestamp and identifier (optional). If this client cached version is up to
 * date the status header 304 (not modified) will be set and the program will be
 * terminated.
 *
 * @author Ansas Meyer
 * @param int $timestamp late modification timestamp
 * @param string $identifier additional identifier (optional, default: "")
 * @param bool $strict use strict mode (optional, default: false)
 * @return bool true if headers could be set, otherwise false
 */
function last_modified($timestamp, $identifier = "", $strict = false)
{
    // check: are we still allowed to send headers?
    if (headers_sent()) {
        return false;
    }

    // get: header values from client request
    $client_etag =
        !empty($_SERVER['HTTP_IF_NONE_MATCH'])
        ?   trim($_SERVER['HTTP_IF_NONE_MATCH'])
        :   null
    ;
    $client_last_modified =
        !empty($_SERVER['HTTP_IF_MODIFIED_SINCE'])
        ?   trim($_SERVER['HTTP_IF_MODIFIED_SINCE'])
        :   null
    ;
    $client_accept_encoding =
        isset($_SERVER['HTTP_ACCEPT_ENCODING'])
        ?   $_SERVER['HTTP_ACCEPT_ENCODING']
        :   ''
    ;

    /**
     * Notes
     *
     * HTTP requires that the ETags for different responses associated with the 
     * same URI are different (this is the case in compressed vs. non-compressed
     * results) to help caches and other receivers disambiguate them.
     *
     * Further we cannot trust the client to always enclose the ETag in normal
     * quotation marks (") so we create a "raw" server sided ETag and only
     * compare if our ETag is found in the ETag sent from the client
     */

    // calculate: current/new header values
    $server_last_modified = gmdate('D, d M Y H:i:s', $timestamp) . ' GMT';
    $server_etag_raw = md5($timestamp . $client_accept_encoding . $identifier);
    $server_etag = '"' . $server_etag_raw . '"';

    // calculate: do client and server tags match?
    $matching_last_modified = $client_last_modified == $server_last_modified;
    $matching_etag = $client_etag && strpos($client_etag, $server_etag_raw) !== false;

    // set: new headers for cache recognition
    header('Last-Modified: ' . $server_last_modified);
    header('ETag: ' . $server_etag);

    // check: are client and server headers identical ("no changes")?
    if (
        ($client_last_modified && $client_etag) || $strict
        ?   $matching_last_modified && $matching_etag
        :   $matching_last_modified || $matching_etag
    ) {
        header($_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified');
        exit(304);
    }

    return true;
}

Und so könnte der Einbau aussehen:

// get: timestamp of last modification of current php file
// note: of course you can do any other wild calculation here
$timestamp = getlastmod();

// set: last modified header
// note: script exits if client cached version is still up to date
last_modified($timestamp);

Manchmal hat man aber nicht nur einen Zeitstempel $timestamp auf den man sich beziehen möchte, sondern auch noch einen oder mehrere andere Faktoren. Als Beispiel könnte es sein, dass für die verschlüsselte Version (https) eine andere Seite ausgeben werden soll als für die unverschlüsselte Version (http). Vielleicht soll es auch pro Sprache, Land der IP und/oder diverser anderer Eigenschaften jeweils eine eigene gecachte Version geben. Hier kommt der zweite Parameter $identifier der Funktion ins Spiel:

// get: timestamp of last modification of current php file
// note: of course you can do any other wild calculation here
$timestamp = getlastmod();

// get: additional unique identifier for cached version
// note: of course you can concat many identifiers here
$identifier = isset($_SERVER['HTTPS']);

// set: last modified header
// note: script exits if client cached version is still up to date
last_modified($timestamp, $identifier);

Noch ein paar Hinweise zum Schluss:

  • Standardmäßig werden vom Client beide Request Header HTTP_IF_NONE_MATCH (Gegenstück zu, Server Response Header ETag) und HTTP_IF_MODIFIED_SINCE (Gegenstück zu, Server Response Header Last-Modified) gesendet. Dieses muss aber nicht zwingend der Fall sein.
  • Von der Funktion werden standardmäßig nur dann beide Werte verglichen, wenn auch beide Request Header gesendet werden, ansonsten reicht standardmäßig auch eine Übereinstimmung.
  • Man kann die Funktion zwingen immer nur dann 304 Not Modified (alles noch aktuell) zurückzumelden, wenn beide Werte gesetzt sind und auch beide Werte übereinstimmen. In diesem Fall muss der dritte Parameter $strict auf true gesetzt werden.

Es gibt eine sehr gute Seite, auf der man das Ergebnis prüfen kann: REDbot

Das könnte dich auch interessieren …

2 Antworten

  1. Max Steinweber sagt:

    Also eigentlich mag ich es nicht Kommentare auf Blogs zu schreiben. Die Anzahl der Kommentare in den letzten 10 Jahren kann man wohl an 2 Händen abzählen. Aber der Beitrag ist einer von denen, die es verdient haben ;)

    Gefällt mir wirklich gut und schön erklärt mit gutem Beispiel. Lediglich ein kleiner Fehler ist in deinem Script.

    $client_etag sollte mit str_replace(‚"‘,'“‚,trim($_SERVER[‚HTTP_IF_NONE_MATCH‘])) ermittelt werden. Es ist nicht immer garantiert, dass es ein “ ist.
    Alternativ könnte man natürlich auch alles andere raus filtern und beide ETags „blank“ vergleichen. Dann würde ich aber eher zu strpos( ) raten um aktuellen tag mit dem übermittelten client-etag zu prüfen.

    • Ansas sagt:

      Hi Max,

      danke für Deinen Kommentar. Ich habe die Funktion direkt einmal angepasst (sicher ist sicher), so dass die Anführungszeichen oder was auch immer der Client ggf. daraus macht keine Rolle mehr spielt.

      In dem Zuge ist mir aufgefallen, dass REDbot scheinbar einen neuen Check eingebaut hat. Entsprechend berücksichtige ich nun beim generieren des ETags auch noch die vom Client akzeptierten Encodings. Diese sollten sich pro Client eigentlich nicht ändern, so dass sich für den einzelnen Client nichts ändert.

      LG Ansas

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Bitte beachte die Hinweise zum Datenschutz