Concepten, Ideeƫn, Strategieƫn
Concepten, Ideeƫn, StrategieƫnDe applicatie ontwerpen om met verschillende GraphQL-servers te werken

De applicatie ontwerpen om met verschillende GraphQL-servers te werken

"Programmeren tegen interfaces, niet implementaties" is de praktijk van het aanroepen van functionaliteit niet rechtstreeks, maar via een contract dat beschrijft welke invoer vereist is en wat de verwachte uitvoer is, terwijl de implementatiedetails verborgen blijven. Deze strategie helpt de applicatie los te koppelen van een specifieke implementatie, provider of stack, zodat je tussen hen kunt wisselen zonder de applicatiecode te hoeven aanpassen.

We kunnen deze strategie ook toepassen met GraphQL. GraphQL kan fungeren als tussenpersoon tussen de applicatie en de server, zodat we alle benodigde wijzigingen alleen in de GraphQL-queries hoeven door te voeren, terwijl de bedrijfslogica onaangeroerd blijft.

Een GraphQL-query fungeert als interface tussen de client en de server. Bij het uitvoeren van een query verwerkt de GraphQL-server deze en geeft de vereiste gegevens terug aan de client. Waar komen de gegevens vandaan? Hoe zijn ze verkregen? De client weet het niet en geeft er ook niet om.

De GraphQL-query fungeert als interface tussen client en server

Het antwoord op de query heeft dezelfde vorm als de query. Voor deze GraphQL-query:

{
  post(by: { id: 1 }) {
    id
    title
  }
}

...zal het antwoord zijn:

{
  "data": {
    "post": {
      "id": 1,
      "title": "Hello world!"
    }
  }
}

Bij dezelfde query met andere parameters zullen de geretourneerde gegevens verschillend zijn, maar de vorm blijft constant. Dit betekent dat zolang de query niet verandert, de applicatie zijn logica voor het lezen en verwerken van gegevens niet hoeft te wijzigen, en het evenmin uitmaakt welke GraphQL-server de query uitvoert.

Zo kunnen we naadloos de ene GraphQL-server door de andere vervangen.

Queries zijn afhankelijk van het GraphQL-schema

De laatste alinea is echter wat te optimistisch, want de GraphQL-query moet mogelijk worden aangepast afhankelijk van de GraphQL-server. Om preciezer te zijn: de query is gebaseerd op het GraphQL-schema, en als verschillende servers verschillende schema's blootstellen, zal de query ook anders zijn.

Een GraphQL-server die de Cursor Connections Specification gebruikt, kan bijvoorbeeld de volgende query uitvoeren:

{
  categories(first: 10000) {
    edges {
      node {
        categoryId
        description
        id
        name
        slug
      }
    }
  }
}

En een andere server die paginering in WordPress-stijl gebruikt (zoals Gato GraphQL) voert dezelfde query als volgt uit:

{
  postCategories(pagination: { limit: 10000 }) {
    id
    description
    globalID
    name
    slug
  }
}

We kunnen de verschillen tussen de twee queries zien:

KenmerkServer #1Server #2
Veld voor postcategorieƫncategoriespostCategories
Veldargument om het aantal resultaten te beperkenfirstpagination.limit
Het veld id van een object vertegenwoordigtzijn uniek globaal IDzijn uniek ID voor zijn type
Vorm van de querydieper vanwege edges.nodevlakker

Alleen de query van de eerste server vervangen door de equivalente query van de tweede server in de applicatie werkt niet. Dat komt doordat de logica de gegevens uit het antwoord nog steeds leest op basis van de vorm en velden van de originele query.

Een mogelijke oplossing is ook de logica voor het ophalen van gegevens aan de clientzijde te vervangen. Bijvoorbeeld de volgende logica:

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

...kan als volgt worden vervangen:

const categories = data?.data.postCategories;

Maar dat is precies wat we willen vermijden. We willen de wijzigingen tot een minimum beperken: alleen de interface (de GraphQL-query) aanpassen en de bedrijfslogica ongewijzigd laten.

Gelukkig is het mogelijk de verschillen te overbruggen door alleen de GraphQL-queries aan te passen, via de volgende stappen:

  1. De GraphQL-queries losgekoppeld houden van de applicatie
  2. De veldnamen aanpassen via aliassen
  3. De vorm van het antwoord aanpassen via een self-veld

Laten we bekijken hoe we via deze 3 stappen een applicatie kunnen aanpassen om naar een andere GraphQL-server te wijzen.

De GraphQL-queries losgekoppeld houden van de applicatie

Het loskoppelen van de GraphQL-queries van de applicatielogica omvat:

  • Elke GraphQL-query (of een groep queries) opslaan in een apart bestand, allemaal in een specifieke map
  • De queries exporteren en importeren in de applicatie

We kunnen bijvoorbeeld elke GraphQL-query in een apart bestand onder src/data plaatsen en exporteren:

// file `src/data/categories.js`
export const QUERY_ALL_CATEGORIES = gql`
  {
    categories(first: 10000) {
      edges {
        node {
          databaseId
          description
          id
          name
          slug
        }
      }
    }
  }
`;

De applicatie kan de GraphQL-query vervolgens importeren en gebruiken:

import { QUERY_ALL_CATEGORIES } from 'data/categories';
 
export async function getAllCategories() {
  const apolloClient = getApolloClient();
 
  const data = await apolloClient.query({
    query: QUERY_ALL_CATEGORIES,
  });
 
  const categories = data?.data.categories.edges.map(({ node = {} }) => node);
 
  return {
    categories,
  };
}

Dankzij deze opzet hoeven alle wijzigingen alleen te worden doorgevoerd in de bestanden onder src/data.

De veldnamen aanpassen via aliassen

Een veldalias kan worden gebruikt om een veld in het antwoord van de tweede GraphQL-server te hernoemen naar de naam van dat veld in de eerste server.

Op deze manier kunnen de velden postCategories, id en globalID worden opgehaald met de namen die de applicatie verwacht: respectievelijk categories, categoryId en id:

{
  categories: postCategories(pagination: { limit: 10000 }) {
    categoryId: id
    description
    id: globalID
    name
    slug
  }
}

Let op: het veld categories heeft het argument first, terwijl het bijbehorende veld postCategories het argument pagination.limit gebruikt. Omdat de veldargumenten echter niet worden weerspiegeld in de naam van het veld in het antwoord, hoeven we ons daar geen zorgen over te maken.

De vorm van het antwoord aanpassen via een self-veld

De laatste uitdaging is iets lastiger: we moeten de vorm van het antwoord aanpassen door de extra niveaus voor edges en node toe te voegen, afkomstig van de Cursor Connections-specificatie.

Om dit te bereiken introduceren we een self-veld voor alle typen in het GraphQL-schema, dat hetzelfde object teruggeeft waarop het wordt toegepast:

type QueryRoot {
  self: QueryRoot!
}
 
type Post {
  self: Post!
}
 
type User {
  self: User!
}

Het self-veld maakt het mogelijk extra niveaus aan de query toe te voegen zonder het bevraagde object te verlaten. Het uitvoeren van deze query:

{
  __typename
  self {
    __typename
  }
  
  post(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
  
  user(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
}

...levert dit antwoord op:

{
  "data": {
    "__typename": "QueryRoot",
    "self": {
      "__typename": "QueryRoot"
    },
    "post": {
      "self": {
        "id": 1,
        "__typename": "Post"
      }
    },
    "user": {
      "self": {
        "id": 1,
        "__typename": "User"
      }
    }
  }
}

Nu kunnen we self gebruiken om de niveaus nodes en edge kunstmatig toe te voegen:

{
  categories: self {
    edges: postCategories(pagination: { limit: 10000 }) {
      node: self {
        categoryId: id
        description
        id: globalID
        name
        slug
      }
    }
  }
}

Het type van het object in het GraphQL-schema voor edges en voor self is uiteraard verschillend. Maar dat maakt de applicatie niets uit, omdat die niet interageert met het daadwerkelijke object dat in de GraphQL-server is gemodelleerd. In plaats daarvan ontvangt de applicatie de gegevens als een JSON-object, en die gegevens voor een veld afkomstig van een PostConnection- of Post-object zullen hetzelfde zijn.

Let op: het veld categories wordt opgelost via self en edges wordt opgelost via postCategories, niet andersom. Dit is om de kardinaliteit van de geretourneerde elementen te laten overeenkomen met die gedefinieerd door de velden die de Cursor Connections-specificatie gebruiken:

type RootQuery {
  categories: RootQueryToCategoryConnection
}
 
type RootQueryToCategoryConnection {
  edges: [RootQueryToCategoryConnectionEdge]
}
 
type RootQueryToCategoryConnectionEdge {
  node: Category
}

Als de aangepaste GraphQL-query omgekeerd was (d.w.z. categories: postCategories en edges: self bevragen), zou toegang tot de gegevens mislukken, omdat data.categories een array zou zijn en data.categories.edges dan een fout zou gooien bij het uitvoeren van:

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

Alle queries aanpassen

Na het toepassen van dezelfde strategie op alle GraphQL-queries in src/data kan de applicatie eenvoudig wisselen van de ene GraphQL-server naar de andere.