Concepten, Ideeƫn, Strategieƫn
Concepten, Ideeƫn, StrategieƫnCache control via persisted queries

Cache control via persisted queries

GraphQL werkt doorgaans via POST, waarbij alle queries worden uitgevoerd tegen ƩƩn enkel endpoint en parameters via de body van het verzoek worden meegegeven. De URL van dat ene endpoint levert verschillende antwoorden op, wat betekent dat het niet gecached kan worden (tenminste niet wanneer de URL als identifier wordt gebruikt).

De standaardmanier om caching in GraphQL te ondersteunen is dan ook op de client-laag, via de Apollo-client en vergelijkbare bibliotheken, die de geretourneerde objecten onafhankelijk van elkaar cachen en ze identificeren aan de hand van hun unieke globale ID.

(Als je daarentegen op de server cachet, gebruik je normaal gesproken de URL als identifier en sla je de gegevens van alle entiteiten in de respons gezamenlijk op.)

Maar deze oplossing heeft een aantal nadelen:

  • De applicatie krijgt meer JavaScript te draaien aan de client-kant. Het bezoeken van de website via een goedkope mobiele telefoon gaat ten koste van de prestaties
  • De applicatie wordt complexer en heeft meer bewegende onderdelen, want nu moeten we ons ook bezighouden met de implementatie van de cache-laag
  • Niet iedereen begrijpt JavaScript (de website kan bijvoorbeeld in PHP zijn geschreven), maar het omgaan met JS wordt nu ook een verantwoordelijkheid

Een veel betere oplossing is het gebruik van HTTP-caching. Laten we bekijken aan welke voorwaarden moet worden voldaan om dit te laten werken.

GraphQL benaderen via GET

HTTP-caching gebruiken betekent dat we de GraphQL-respons cachen met de URL als identifier. Dit heeft 2 gevolgen:

  1. We moeten het enkele GraphQL-endpoint via GET benaderen
  2. We moeten de query en variabelen als URL-parameters meegeven

Als het enkele endpoint /graphql is, kan de GET-operatie worden uitgevoerd tegen de URL /graphql?query=...&variables=....

Dit geldt voor het ophalen van gegevens van de server (via de query-operatie). Voor het muteren van gegevens (via de mutation-operatie) moeten we nog steeds POST gebruiken. Dat is geen probleem, want mutations worden altijd vers uitgevoerd; we kunnen de resultaten van een mutation toch niet cachen, dus we zouden HTTP-caching er sowieso niet voor gebruiken.

Deze aanpak werkt (en wordt zelfs aanbevolen op de officiƫle site), maar er zijn bepaalde aandachtspunten.

GraphQL-queries coderen via URL-parameter

Een GraphQL-query beslaat doorgaans meerdere regels. Bijvoorbeeld:

{
  posts {
    id
    title
  }
}

We kunnen deze meerdere regels echter niet rechtstreeks in de URL-parameter invoeren.

De oplossing is om het te encoderen. De GraphiQL-client codeert de bovenstaande query bijvoorbeeld als volgt:

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

OkƩ, dat werkt. Maar het ziet er niet erg mooi uit, toch? Wie kan nog iets opmaken uit die query?

Een van de voordelen van GraphQL is dat zijn queries zo makkelijk te begrijpen zijn. Na wat oefening, zodra je de query ziet, snap je hem meteen. Maar zodra hij gecodeerd is, is dat allemaal weg en kunnen alleen machines hem nog begrijpen; de mens valt buiten de vergelijking.

Een andere oplossing zou zijn om alle regeleindes in de query te vervangen door een spatie. Dat werkt omdat regeleindes geen semantische betekenis toevoegen aan de query. De bovenstaande query kan dan worden weergegeven als:

?query={ posts { id title } }

Dit werkt goed voor eenvoudige queries. Maar als je een echt lange query hebt met veel { } die openen en sluiten, en met veldargumenten en directives, wordt het steeds moeilijker te begrijpen.

Neem bijvoorbeeld deze 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 wordt dan deze enkele regel:

{ 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 } } } 

Opnieuw, de query uitvoeren werkt, maar we weten niet meer wat we aan het uitvoeren zijn.

En als de query ook fragmenten bevat, vergeet het dan maar helemaal — er is geen manier om er nog wijs uit te worden.

Persisted queries te hulp

Als het meegeven van de query in de URL niet bevredigend is, welke andere optie hebben we dan? Nou, de query niet meegeven in de URL!

Dit is de aanpak die een "persisted query" wordt genoemd: we slaan de query op de server op en gebruiken een identifier (zoals een numeriek ID of een unieke string die wordt gegenereerd door een hash-algoritme toe te passen op de query als invoer) om hem op te halen. Ten slotte geven we deze identifier als URL-parameter mee in plaats van de query.

De query kan bijvoorbeeld worden geĆÆdentificeerd met ID 2908 (of een hash zoals "50ac3e81"), en dan voeren we de GET-operatie uit tegen de URL /graphql?id=2908. De GraphQL-server haalt vervolgens de query op die overeenkomt met dit ID, voert hem uit en retourneert de resultaten.

Gato GraphQL maakt het nog eenvoudiger: een persisted query is geĆÆmplementeerd als een aangepast berichttype, zodat je er een kunt aanmaken en publiceren zoals elk gewoon bericht. De slug die je kiest (die standaard gebaseerd is op de ingevoerde titel) wordt de identifier. Persisted queries maken de implementatie van HTTP-caching triviaal.

De max-age-waarde berekenen

HTTP-caching werkt door de Cache-Control-header in de respons te sturen, met een max-age-waarde die aangeeft hoe lang de respons gecached moet worden, of no-store om aan te geven dat hij niet gecached mag worden.

Hoe berekent de GraphQL-server de max-age-waarde voor de query, als verschillende velden verschillende max-age-waarden kunnen hebben?

Het antwoord is: haal de max-age-waarde op voor alle velden die in de query worden opgevraagd en zoek uit welke de laagste is. Dat wordt de max-age van de respons.

Stel dat we een entiteit van het type User hebben. Op basis van het gedrag dat aan deze entiteit is toegewezen, kunnen we bepalen hoe lang het bijbehorende veld gecached mag worden:

šŸ›  Het ID zal nooit veranderen ⇒ We geven veld id een max-age van 1 jaar

šŸ›  De URL wordt zeer zelden bijgewerkt (als überhaupt) ⇒ We geven veld url een max-age van 1 dag

šŸ›  De naam van de persoon kan af en toe veranderen (bijv. om een status toe te voegen, of om te zeggen "Milton (draagt een masker)") ⇒ We geven veld name een max-age van 1 uur

šŸ›  De karma van de gebruiker op de site kan op elk moment veranderen (bijv. nadat iemand hun reactie een upvote geeft) ⇒ We geven veld karma een max-age van 1 minuut

šŸ›  Als we de gegevens van de ingelogde gebruiker opvragen, kan de respons helemaal niet gecached worden (ongeacht welk veld we opvragen) ⇒ De max-age moet no-store zijn

Als gevolg hiervan heeft de respons op de volgende GraphQL-queries de volgende max-age-waarden (in dit voorbeeld negeren we de max-age voor veld Root.users, maar in de praktijk wordt dit ook meegenomen):

QueryWaarde max-age
{
  users {
    id
  }
}
1 jaar
{
  users {
    id
    url
  }
}
1 dag
{
  users {
    id
    url
    name
  }
}
1 uur
{
  users {
    id
    url
    name
    karma
  }
}
1 minuut
{
  me {
    id
    url
    name
    karma
  }
}
no-store (niet cachen)

De Cache Control List aanmaken

Nadat we de max-age voor elk veld hebben bepaald, voeren we deze informatie in via een Cache Control List:

Een cache control-beleid definiƫren

Gato GraphQL berekent vervolgens automatisch de max-age-waarde van de respons en stuurt deze terug als de Cache-Control HTTP-header.