Concepts, idées, stratégies
Concepts, idées, stratégiesFaire évoluer le schéma via le versionnage des champs

Faire évoluer le schéma via le versionnage des champs

À mesure que les besoins de notre application évoluent, l'API GraphQL qui lui fournit des données devra également évoluer, en introduisant des changements dans son schéma. Chaque fois que le changement est non disruptif, comme lorsqu'on ajoute un nouveau type ou champ, nous pouvons l'appliquer directement sans craindre d'effets secondaires. Mais lorsque le changement est disruptif, nous devons nous assurer de ne pas introduire de bogues ou de comportements inattendus dans l'application.

Les changements disruptifs sont ceux qui suppriment un type, un champ ou une directive, ou modifient la signature d'un champ (ou d'une directive) déjà existant, comme :

  • Renommer un champ
  • Modifier le type d'un argument de champ existant, ou le rendre obligatoire
  • Ajouter un nouvel argument obligatoire au champ
  • Ajouter non-nullable au type de réponse d'un champ

Pour traiter les changements disruptifs, il existe deux stratégies principales : le versionnage et l'évolution, telles qu'implémentées par REST et GraphQL, respectivement.

Les APIs REST indiquent la version de l'API à utiliser soit dans l'URL du endpoint (comme https://api.mycompany.com/v1 ou https://api-v1.mycompany.com) soit via un en-tête (comme Accept-version: v1). Via le versionnage, les changements disruptifs sont ajoutés à une nouvelle version de l'API, et comme les clients doivent explicitement pointer vers la nouvelle version de l'API, ils seront conscients des changements.

GraphQL ne rejette pas l'utilisation du versionnage, mais encourage l'utilisation de l'évolution. Comme indiqué dans la page des meilleures pratiques GraphQL :

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.

L'évolution se comporte différemment en ce qu'elle n'est pas censée avoir lieu une fois tous les quelques mois, comme c'est le cas pour le versionnage. C'est plutôt un processus continu, qui a lieu même quotidiennement si nécessaire, ce qui la rend plus adaptée à l'itération rapide. Cette approche a été formulée par Principled GraphQL, un ensemble de bonnes pratiques pour guider le développement d'un service GraphQL, dans son cinquième principe :

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

Faire évoluer le schéma

Via l'évolution, les champs avec des changements disruptifs doivent passer par le processus suivant :

  1. Réimplémenter le champ en utilisant un nom différent.
  2. Déprécier le champ, en demandant aux clients d'utiliser le nouveau champ à la place.
  3. Dès que le champ n'est plus utilisé par personne, le supprimer du schéma.

Voyons un exemple. Supposons que nous ayons un type Account, modélisant un compte pour une personne avec un nom et un prénom via ce schéma (en utilisant le SDL de GraphQL - Schema Definition Language) :

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

Dans ce schéma, les champs name et surname sont tous deux obligatoires (c'est le symbole ! ajouté après le type String) car nous nous attendons à ce que toutes les personnes aient à la fois un nom et un prénom.

Éventuellement, nous permettons également aux organisations d'ouvrir des comptes. Les organisations, cependant, n'ont pas de prénom, nous devons donc modifier la signature du champ surname pour le rendre non obligatoire :

type Account {
  id: Int
  name: String!
  surname: String # Ceci a changé
}

Il s'agit d'un changement disruptif car l'application ne s'attend pas à ce que le champ surname retourne null, donc elle peut ne pas vérifier cette condition, comme lors de l'exécution de ce code JavaScript :

// Cela échouera quand account.surname est null
const upperCaseSurname = account.surname.toUpperCase();

Les bogues potentiels résultant de changements disruptifs peuvent être évités en faisant évoluer le schéma :

  • Nous ne modifions pas la signature du champ surname ; au lieu de cela, nous le marquons comme déprécié, en ajoutant un message utile indiquant le nom du champ qui le remplace
  • Nous introduisons un nouveau nom de champ personSurname (ou accountSurname) dans le schéma

Notre type Account ressemble maintenant à ceci :

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

Enfin, en collectant les logs des requêtes de nos clients, nous pouvons analyser s'ils ont effectué la transition vers le nouveau champ. Dès que nous constatons que le champ surname n'est plus utilisé par personne, nous pouvons alors le supprimer du schéma :

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

Problèmes avec l'évolution

L'exemple décrit ci-dessus est très simple, mais il démontre déjà quelques problèmes potentiels liés à l'évolution du schéma :

ProblèmeDescription
Les noms de champ deviennent moins soignésLa première fois que nous nommons le champ, nous trouverons probablement le nom optimal pour lui, comme surname. Quand nous devons le remplacer, cependant, nous devrons créer un nom différent qui peut être sous-optimal (l'optimal est déjà pris !). Tous les remplacements possibles dans l'exemple ci-dessus ont des problèmes :

- personName rend explicite que le compte est pour une personne, donc si, plus tard, nous devons ouvrir un compte pour un non-humain ayant un prénom (je ne sais pas... un Martien ?), nous devrons alors faire évoluer le schéma à nouveau pour maintenir des noms cohérents
- Le terme « account » dans accountName est complètement redondant puisque le type est déjà Account
- Sinon, quel autre nom utiliser ? surname1 ? surnameNew ? Ou encore pire, surnameV2 ?

Par conséquent, le schéma mis à jour sera moins compréhensible et plus verbeux.
Le schéma peut accumuler des champs dépréciésDéprécier des champs est plus sensé comme une circonstance temporaire ; à terme, nous voudrions vraiment supprimer ces champs du schéma pour le nettoyer avant qu'ils ne commencent à s'accumuler.

Cependant, il peut y avoir des clients qui ne révisent pas leurs requêtes et récupèrent toujours des informations depuis le champ déprécié. Dans ce cas, notre schéma deviendra lentement mais sûrement une sorte de cimetière de champs, accumulant plusieurs champs différents pour la même fonctionnalité.

Voyons comment résoudre ces problèmes.

Versionnage des champs

Nous pouvons créer notre champ avec un argument appelé version, via lequel nous spécifions quelle version du champ utiliser.

Dans ce scénario, nous devrons toujours conserver l'implémentation pour le champ déprécié, donc nous ne nous améliorons pas sur ce point. Cependant, son contrat devient caché : le nouveau champ peut maintenant conserver son nom original (il n'est pas nécessaire de le renommer de surname à personSurname), empêchant notre schéma de devenir trop verbeux.

Veuillez noter que ce concept de versionnage est différent de celui de REST :

  • REST établit une situation tout-ou-rien dans laquelle toute l'API interrogée a la même version puisque la version à utiliser fait partie du endpoint
  • Dans cette autre approche, chaque champ est versionné indépendamment

Ainsi, nous pouvons accéder à différentes versions pour différents champs, comme ceci :

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

De plus, en s'appuyant sur le semantic versioning, nous pouvons utiliser les contraintes de version pour choisir la version, en suivant les mêmes règles utilisées par Composer pour déclarer les dépendances de paquets. Nous renommons alors l'argument de champ version en versionConstraint et mettons à jour la requête :

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

En appliquant cette stratégie à notre champ déprécié surname, nous pouvons maintenant étiqueter l'implémentation dépréciée comme version "1.0.0" et la nouvelle implémentation comme version "2.0.0" et accéder aux deux, même dans la même requête :

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

Cette fonctionnalité est disponible dans Gato GraphQL :

Interroger des champs via des contraintes de version

Versionnage des directives

Comme les directives reçoivent également des arguments, nous pouvons implémenter exactement la même méthodologie pour versionner les directives aussi !

Par exemple, lors de l'exécution de cette requête :

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

Elle peut produire une réponse différente pour chaque version de la directive :

Interroger une directive versionnée