Konzepte, Ideen, Strategien
Konzepte, Ideen, StrategienCache Control via Persisted Queries

Cache Control via Persisted Queries

GraphQL arbeitet in der Regel über POST, indem alle queries gegen einen einzigen Endpoint ausgeführt werden und die Parameter im Request-Body übergeben werden. Die URL dieses einzigen Endpoints liefert unterschiedliche Antworten, was bedeutet, dass sie nicht gecacht werden kann (zumindest nicht, wenn die URL als Identifier verwendet wird).

Die Standardmethode für Caching in GraphQL ist daher auf der Client-Ebene, über den Apollo-Client und ähnliche Bibliotheken, die die zurückgegebenen Objekte unabhängig voneinander cachen und sie über ihre eindeutige globale ID identifizieren.

(Im Gegensatz dazu verwenden wir beim serverseitigen Caching normalerweise die URL als Identifier und cachen die Daten aller Entities in der Antwort gemeinsam.)

Diese Lösung hat jedoch mehrere Nachteile:

  • Die Anwendung muss mehr JavaScript clientseitig ausführen. Der Zugriff auf die Website über ein günstiges Mobiltelefon führt zu Leistungseinbußen
  • Die Anwendung wird komplexer und hat mehr Einzelteile, da wir uns nun auch um die Implementierung des Caching-Layers kümmern müssen
  • Nicht jeder versteht JavaScript (z. B. könnte die Website in PHP geschrieben sein), aber der Umgang mit JS wird nun ebenfalls zur Pflicht

Eine viel bessere Lösung ist die Verwendung von HTTP-Caching. Schauen wir uns die dafür notwendigen Voraussetzungen an.

Zugriff auf GraphQL via GET

HTTP-Caching bedeutet, dass wir die GraphQL-Antwort unter Verwendung der URL als Identifier cachen. Das hat 2 Konsequenzen:

  1. Wir müssen auf den einzelnen Endpoint von GraphQL via GET zugreifen
  2. Wir müssen die Query und die Variablen als URL-Parameter übergeben

Wenn der einzelne Endpoint also /graphql ist, kann die GET-Operation gegen die URL /graphql?query=...&variables=... ausgeführt werden.

Dies gilt für das Abrufen von Daten vom Server (via query-Operation). Für das Verändern von Daten (via mutation-Operation) müssen wir weiterhin POST verwenden. Das ist kein Problem, da Mutations immer frisch ausgeführt werden; wir können die Ergebnisse einer Mutation nicht cachen, also würden wir HTTP-Caching damit ohnehin nicht einsetzen.

Dieser Ansatz funktioniert (und wird sogar auf der offiziellen Website vorgeschlagen), aber es gibt bestimmte Punkte, auf die wir achten müssen.

GraphQL-Queries als URL-Parameter kodieren

Eine GraphQL-Query erstreckt sich normalerweise über mehrere Zeilen. Zum Beispiel:

{
  posts {
    id
    title
  }
}

Wir können diesen mehrzeiligen String jedoch nicht direkt als URL-Parameter eingeben.

Die Lösung besteht darin, ihn zu kodieren. Zum Beispiel kodiert der GraphiQL-Client die obige Query so:

%7B%0A%20%20posts%20%7B%0A%20%20%20%20id%0A%20%20%20%20title%0A%20%20%7D%0A%7D

Gut, das funktioniert. Aber es sieht nicht sehr gut aus, oder? Wer kann diese Query noch verstehen?

Eine der Tugenden von GraphQL ist, dass seine queries so leicht zu erfassen sind. Mit etwas Übung verstehen wir eine Query sofort, wenn wir sie sehen. Aber sobald sie kodiert wurde, ist das alles weg, und nur noch Maschinen können sie verstehen; der Mensch ist aus der Gleichung heraus.

Eine andere Lösung könnte darin bestehen, alle Zeilenumbrüche in der Query durch ein Leerzeichen zu ersetzen, was funktioniert, weil Zeilenumbrüche der Query keine semantische Bedeutung hinzufügen. Die obige Query lässt sich dann so darstellen:

?query={ posts { id title } }

Das funktioniert gut für einfache queries. Aber wenn du eine wirklich lange Query hast, mit vielen öffnenden und schließenden { } sowie Feldargumenten und Direktiven, wird es immer schwieriger zu verstehen.

Zum Beispiel wird diese Query:

{
  posts(limit:5) {
    id
    title @titleCase
    excerpt @default(
      value:"No title",
      condition:IS_EMPTY
    )
    author {
      name
    }
    tags {
      id
      name
    }
    comments(
      limit:3,
      order:"date|DESC"
    ) {
      id
      date(format:"d/m/Y")
      author {
        name
      }
      content
    }
  }
}

Zu dieser einzeiligen Query:

{ posts(limit:5) { id title @titleCase excerpt @default(value:"No title", condition:IS_EMPTY) author { name } tags { id name } comments(limit:3, order:"date|DESC") { id date(format:"d/m/Y") author { name } content } } } 

Die Ausführung der Query funktioniert zwar nach wie vor, aber wir wissen nicht mehr, was wir da eigentlich ausführen.

Und wenn die Query auch noch Fragments enthält, dann kann man sie vollends vergessen – es gibt keine Möglichkeit mehr, den Überblick zu behalten.

Persisted Queries kommen zur Rettung

Wenn das Übergeben der Query in der URL nicht zufriedenstellend ist, welche andere Möglichkeit haben wir? Nun, die Query einfach nicht in der URL übergeben!

Das ist der Ansatz, der als „Persisted Query" bezeichnet wird: Wir speichern die Query auf dem Server und verwenden einen Identifier (z. B. eine numerische ID oder einen eindeutigen String, der durch einen Hashing-Algorithmus mit der Query als Eingabe erzeugt wird), um sie abzurufen. Schließlich übergeben wir diesen Identifier als URL-Parameter, anstelle der Query.

Zum Beispiel könnte die Query mit der ID 2908 identifiziert werden (oder einem Hash wie "50ac3e81"), und dann führen wir die GET-Operation gegen die URL /graphql?id=2908 aus. Der GraphQL-Server ruft dann die dieser ID entsprechende Query ab, führt sie aus und gibt die Ergebnisse zurück.

Gato GraphQL macht das noch einfacher: Eine Persisted Query ist als benutzerdefinierter Beitragstyp implementiert, sodass wir eine erstellen und wie jeden normalen Beitrag veröffentlichen können, und der Slug, den wir wählen (der standardmäßig auf dem eingegebenen Titel basiert), wird ihr Identifier. Persisted Queries machen die Implementierung von HTTP-Caching trivial.

Berechnung des max-age-Werts

HTTP-Caching funktioniert, indem der Cache-Control-Header in der Antwort gesendet wird, mit einem max-age-Wert, der angibt, wie lange die Antwort gecacht werden soll, oder no-store, um anzugeben, dass sie nicht gecacht werden soll.

Wie wird der GraphQL-Server den max-age-Wert für die Query berechnen, wenn verschiedene Felder unterschiedliche max-age-Werte haben können?

Die Antwort lautet: Den max-age-Wert aller in der Query angeforderten Felder ermitteln und herausfinden, welcher der niedrigste ist. Das wird der max-age der Antwort sein.

Nehmen wir zum Beispiel an, wir haben eine Entity vom Typ User. Entsprechend dem dieser Entity zugewiesenen Verhalten können wir festlegen, wie lange das entsprechende Feld gecacht werden kann:

🛠 Seine ID wird sich nie ändern ⇒ Wir geben dem Feld id ein max-age von 1 Jahr

🛠 Seine URL wird sehr selten aktualisiert (falls überhaupt) ⇒ Wir geben dem Feld url ein max-age von 1 Tag

🛠 Der Name der Person kann sich hin und wieder ändern (z. B. um einen Status hinzuzufügen oder um zu sagen „Milton (trägt eine Maske)") ⇒ Wir geben dem Feld name ein max-age von 1 Stunde

🛠 Das Karma des Benutzers auf der Website kann sich jederzeit ändern (z. B. nachdem jemand seinen Kommentar hochgestimmt hat) ⇒ Wir geben dem Feld karma ein max-age von 1 Minute

🛠 Wenn wir die Daten des angemeldeten Benutzers abfragen, kann die Antwort überhaupt nicht gecacht werden (unabhängig davon, welches Feld wir abrufen) ⇒ Das max-age muss no-store sein

Als Ergebnis werden die Antworten auf die folgenden GraphQL-queries die folgenden max-age-Werte haben (für dieses Beispiel ignorieren wir das max-age für das Feld Root.users, aber in der Praxis wird es ebenfalls berücksichtigt):

Querymax-age-Wert
{
  users {
    id
  }
}
1 Jahr
{
  users {
    id
    url
  }
}
1 Tag
{
  users {
    id
    url
    name
  }
}
1 Stunde
{
  users {
    id
    url
    name
    karma
  }
}
1 Minute
{
  me {
    id
    url
    name
    karma
  }
}
no-store (nicht cachen)

Erstellen der Cache Control List

Sobald wir das max-age für jedes Feld identifiziert haben, geben wir diese Informationen über eine Cache Control List ein:

Definieren einer Cache Control Policy

Gato GraphQL berechnet dann automatisch den max-age-Wert der Antwort und sendet ihn als Cache-Control-HTTP-Header zurück.