Concepten, Ideeƫn, Strategieƫn
Concepten, Ideeƫn, StrategieƫnHet schema evolueren via veld-versiebeheer

Het schema evolueren via veld-versiebeheer

Naarmate de behoeften van onze applicatie evolueren, zal de GraphQL API die gegevens aanlevert ook moeten evolueren, waarbij wijzigingen in het schema worden doorgevoerd. Wanneer de wijziging niet-breaking is, zoals bij het toevoegen van een nieuw type of veld, kunnen we die direct toepassen zonder bang te zijn voor bijwerkingen. Maar wanneer de wijziging een breaking change is, moeten we ervoor zorgen dat we geen bugs of onverwacht gedrag in de applicatie introduceren.

Breaking changes zijn die wijzigingen waarbij een type, veld of directive wordt verwijderd, of de signatuur van een al bestaand veld (of directive) wordt gewijzigd, zoals:

  • Het hernoemen van een veld
  • Het wijzigen van het type van een bestaand veldargument, of het verplicht maken ervan
  • Het toevoegen van een nieuw verplicht argument aan het veld
  • Het toevoegen van non-nullable aan het responstype van een veld

Om met breaking changes om te gaan, zijn er twee hoofdstrategieën: versiebeheer en evolutie, zoals respectievelijk geïmplementeerd door REST en GraphQL.

REST API's geven de versie van de te gebruiken API aan via de endpoint-URL (zoals https://api.mycompany.com/v1 of https://api-v1.mycompany.com) of via een header (zoals Accept-version: v1). Via versiebeheer worden breaking changes toegevoegd aan een nieuwe versie van de API, en omdat clients expliciet naar de nieuwe versie moeten verwijzen, zijn ze op de hoogte van de wijzigingen.

GraphQL verwerpt het gebruik van versiebeheer niet, maar moedigt het gebruik van evolutie aan. Zoals vermeld op de pagina met GraphQL best practices:

While there's nothing that prevents a GraphQL service from being versioned just like any other REST API, GraphQL takes a strong opinion on avoiding versioning by providing the tools for the continuous evolution of a GraphQL schema.

Evolutie gedraagt zich anders doordat het niet verwacht wordt eens in de paar maanden plaats te vinden, zoals versiebeheer. Het is eerder een continu proces dat, indien nodig, zelfs dagelijks plaatsvindt, waardoor het geschikter is voor snelle iteratie. Deze aanpak is uiteengezet door Principled GraphQL, een reeks best practices om de ontwikkeling van een GraphQL-service te begeleiden, in zijn vijfde principe:

5. Use an Agile Approach to Schema Development: The schema should be built incrementally based on actual requirements and evolve smoothly over time

Het schema evolueren

Via evolutie moeten velden met breaking changes het volgende proces doorlopen:

  1. Het veld opnieuw implementeren met een andere naam.
  2. Het veld als deprecated markeren, waarbij clients worden gevraagd het nieuwe veld te gebruiken.
  3. Wanneer het veld door niemand meer wordt gebruikt, het uit het schema verwijderen.

Laten we een voorbeeld bekijken. Stel dat we een type Account hebben dat een account modelleert als een persoon met een naam en een achternaam via dit schema (met behulp van GraphQL's SDL — Schema Definition Language):

type Account {
  id: Int
  name: String!
  surname: String!
}

In dit schema zijn zowel het veld name als het veld surname verplicht (dat is het !-symbool dat na het type String wordt toegevoegd), omdat we verwachten dat alle personen zowel een voornaam als een achternaam hebben.

Uiteindelijk staan we ook organisaties toe om accounts te openen. Organisaties hebben echter geen achternaam, dus moeten we de signatuur van het veld surname wijzigen om het niet-verplicht te maken:

type Account {
  id: Int
  name: String!
  surname: String # Dit is gewijzigd
}

Dit is een breaking change omdat de applicatie niet verwacht dat het veld surname null retourneert, zodat deze mogelijk niet op deze conditie controleert, zoals bij het uitvoeren van deze JavaScript-code:

// Dit mislukt wanneer account.surname null is
const upperCaseSurname = account.surname.toUpperCase();

De potentiƫle bugs die voortkomen uit breaking changes kunnen worden vermeden door het schema te evolueren:

  • We wijzigen de signatuur van het veld surname niet; in plaats daarvan markeren we het als deprecated, met een nuttig bericht dat de naam van het vervangende veld aangeeft
  • We introduceren een nieuwe veldnaam personSurname (of accountSurname) in het schema

Ons type Account ziet er nu zo uit:

type Account {
  id: Int
  name: String!
  surname: String! @deprecated(reason: "Use `personSurname`")
  personSurname: String
}

Tot slot kunnen we door het verzamelen van logs van de queries van onze clients analyseren of ze de overstap naar het nieuwe veld hebben gemaakt. Wanneer we merken dat het veld surname door niemand meer wordt gebruikt, kunnen we het dan uit het schema verwijderen:

type Account {
  id: Int
  name: String!
  personSurname: String
}

Problemen met evolutie

Het hierboven beschreven voorbeeld is heel eenvoudig, maar toont al een paar potentiƫle problemen bij het evolueren van het schema:

ProbleemBeschrijving
Veldnamen worden minder netjesDe eerste keer dat we een veld een naam geven, vinden we er waarschijnlijk de optimale naam voor, zoals surname. Wanneer we het echter moeten vervangen, moeten we er een andere naam voor bedenken die mogelijk suboptimaal is (de optimale is al bezet!). Alle mogelijke vervangingen in het bovenstaande voorbeeld hebben problemen:

- personName maakt expliciet dat het account voor een persoon is, dus als we later een account moeten openen voor een niet-persoon met een achternaam (ik weet het niet... een Marsbewoner?), moeten we het schema opnieuw evolueren om consistente namen te behouden
- Het "account"-gedeelte in accountName is volledig overbodig omdat het type al Account is
- Anders, welke andere naam te gebruiken? surname1? surnameNew? Of nog erger, surnameV2?

Als gevolg hiervan zal het bijgewerkte schema minder begrijpelijk en uitgebreider zijn.
Het schema kan deprecated velden ophopenHet markeren van velden als deprecated heeft het meeste zin als een tijdelijke omstandigheid; uiteindelijk willen we die velden echt uit het schema verwijderen om het op te schonen voordat ze zich beginnen op te hopen.

Er kunnen echter clients zijn die hun queries niet herzien en nog steeds informatie ophalen uit het deprecated veld. In dat geval zal ons schema langzaam maar gestaag een soort veldenbegraafplaats worden, waarbij meerdere verschillende velden voor dezelfde functionaliteit worden opgehoopt.

Laten we eens kijken hoe we deze problemen kunnen oplossen.

Velden van versiebeheer voorzien

We kunnen ons veld aanmaken met een argument genaamd version, waarmee we specificeren welke versie van het veld we willen gebruiken.

In dit scenario moeten we de implementatie voor het deprecated veld toch behouden, dus verbeteren we op dat punt niets. Het contract wordt echter verborgen: het nieuwe veld kan nu zijn oorspronkelijke naam behouden (er is geen noodzaak om het van surname naar personSurname te hernoemen), wat voorkomt dat ons schema te uitgebreid wordt.

Merk op dat dit concept van versiebeheer verschilt van dat in REST:

  • REST legt een alles-of-niets-situatie vast waarbij de hele bevraagde API dezelfde versie heeft, omdat de te gebruiken versie deel uitmaakt van het endpoint
  • Bij deze andere aanpak wordt elk veld onafhankelijk van versiebeheer voorzien

We kunnen dus toegang krijgen tot verschillende versies voor verschillende velden, zoals dit:

query GetPosts {
  posts(version: "1.0.0") {
    id
    title(version: "2.1.1")
    url
    author {
      id
      name(version: "1.5.3")
    }
  }
}

Bovendien kunnen we door te vertrouwen op semantic versioning de versiebeperkingen gebruiken om de versie te kiezen, volgens dezelfde regels die door Composer worden gebruikt voor het declareren van pakketafhankelijkheden. Vervolgens hernoemen we het veldargument version naar versionConstraint en updaten we de query:

query GetPosts {
  posts(versionConstraint: "^1.0") {
    id
    title(versionConstraint: ">=2.1")
    url
    author {
      id
      name(versionConstraint: "~1.5.3")
    }
  }
}

Door deze strategie toe te passen op ons deprecated veld surname, kunnen we de deprecated implementatie nu taggen als versie "1.0.0" en de nieuwe implementatie als versie "2.0.0" en beide benaderen, zelfs in dezelfde query:

query GetSurname {
  account(id: 1) {
    oldVersion: surname(versionConstraint: "^1.0")
    newVersion: surname(versionConstraint: "^2.0")
  }
}

Deze functie is beschikbaar in Gato GraphQL:

Velden bevragen via versiebeperkingen

Directives van versiebeheer voorzien

Omdat directives ook argumenten ontvangen, kunnen we precies dezelfde methodologie toepassen om ook directives van versiebeheer te voorzien!

Wanneer je bijvoorbeeld deze query uitvoert:

query {
  post(by: { id: 1 }) {
    oldVersion: title @strTitleCase(versionConstraint: "^0.1")
    newVersion: title @strTitleCase(versionConstraint: "^0.2")
  }
}

Kan het een andere respons produceren voor elke versie van de directive:

Een directive met versiebeheer bevragen