Aller au contenu
BackJavaquarkusGraphQL

La Taverne GraphQL : Un royaume de tavernes

Dans cette nouvelle escale à The Falling Whale, la taverne s'agrandit en un véritable royaume. Découvrez comment la fédération GraphQL permet à plusieurs services indépendants de parler d'une seule voix, sans révéler les frontières qui les séparent.

Comment unifier plusieurs microservices Quarkus grâce à la fédération GraphQL ?

Depuis l'article précédent, un seul tavernier tenait tout le comptoir : le registre des aventuriers, leurs quêtes, leurs curseurs de pagination, leurs sceaux de confidentialité. Ça a tenu bon un moment. Mais la réputation de la taverne a fini par dépasser les murs d'une seule salle.

Aujourd'hui, deux guildes distinctes se partagent le travail. L'une tient le grand registre des aventuriers : qui ils sont, leur classe, leur niveau, l'état de leur bourse. L'autre tient le tableau des quêtes : qui a pris quoi, pour quelle récompense. Deux bâtiments, deux équipes, deux services Quarkus qui tournent chacun de leur côté.

Le problème, c'est que le voyageur qui pousse la porte ne veut pas connaître cette organisation interne. Il veut une seule question, une seule réponse : « donne-moi la liste des aventuriers, avec leurs quêtes ». Peu lui importe que la réponse vienne de deux bâtiments différents.

C'est exactement ce que la fédération GraphQL est censée résoudre : donner à plusieurs services indépendants l'apparence d'un seul grand comptoir.

Pourquoi séparer, au juste ?

Une question mérite d'être posée avant même d'ouvrir un IDE : pourquoi ne pas simplement garder un seul service, comme à l'épisode 4, et ajouter une table de plus ? Rien n'empêche techniquement une taverne unique de tenir indéfiniment plus de registres.

La réponse tient moins à la technique qu'à l'organisation. Dans une vraie guilde, ce n'est pas la même équipe qui gère le recrutement des aventuriers et le suivi des quêtes. Chacune a son propre rythme de mise à jour, ses propres urgences, parfois son propre langage. Les forcer à partager un seul dépôt de code et un seul cycle de déploiement, c'est leur imposer une dépendance qu'elles n'ont pas demandée : une modification urgente du tableau des quêtes attend que le registre des aventuriers passe ses propres tests, alors que rien ne les lie vraiment.

La fédération répond à ce problème d'équipe, pas à un problème de performance ou d'échelle. Elle permet à chaque service de vivre, de se déployer et d'évoluer seul, tout en laissant le voyageur ignorer complètement cette frontière.
C'est un compromis : on gagne en autonomie d'équipe, on perd en simplicité d'infrastructure. Une seule taverne reste plus simple à faire tourner que deux tavernes plus un routeur. Le choix ne se justifie que si la séparation organisationnelle existe déjà, ou est sur le point d'exister.

Un comptoir, deux guildes

Avant de coder quoi que ce soit, il faut être clair sur ce que Quarkus fait, et surtout sur ce qu'il ne fait pas. SmallRye GraphQL, l'extension qui porte GraphQL dans Quarkus, sait transformer un service en subgraph : un comptoir capable de dire au reste du royaume quels types il possède, lesquels il complète chez les autres, et comment le prouver. En revanche, rien dans Quarkus n'assemble ces comptoirs entre eux.
Cette tâche revient à un composant à part entière, extérieur à l'écosystème Quarkus : l'Apollo Router. C'est lui, le voyageur qui frappe à la bonne porte, interroge les bons registres, et recompose une réponse unique avant de la tendre au client.

Concrètement, ça donne trois pièces distinctes :

  • le service aventurier
  • le service quete
  • le routeur qui les relie.
    Chacun des deux services garde son indépendance totale, son propre code, son propre registre en mémoire, son propre port. Le routeur, lui, ne connaît qu'un plan du royaume, le schéma composé des deux, et sait à qui poser quelle question.

Découper le domaine : qui possède quoi

Le service aventurier reste propriétaire du type Aventurier tel qu'on l'a construit à l'épisode 4 : identifiant, nom, classe, niveau, solde d'or.
C'est lui qui décide de la forme de cette entité, et c'est lui qui expose la clé qui permettra à d'autres services de la reconnaître.

@Key(fields = @FieldSet("id"))
@Description("Un aventurier de la taverne")
public class Aventurier {

    @Id
    private Long id;

    private String nom;
    private String classe;
    private Integer niveau;
    private Integer soldeOr;

    // constructeurs, getters, setters
}

Aventurier, tel que défini dans aventurier-service

Le service quete, lui, ne connaît strictement rien d'un aventurier, à part sa clé. Il ne le définit pas, il l'étend. C'est la différence entre posséder une entité et simplement savoir y accrocher quelque chose :

@Extends
@Key(fields = @FieldSet("id"))
public class Aventurier {

    @Id
    @External
    private Long id;

    // getters, setters
}

Aventurier, version fantôme dans quete-service

Ce deuxième Aventurier n'a qu'un seul champ, marqué @External pour signaler qu'il ne fait qu'emprunter cette donnée: celle-ci appartient à l'autre guilde. Sur ce squelette, le service peut ensuite accrocher ce qu'il possède réellement : la liste des quêtes.

@Query
public Aventurier aventurier(@Id @NonNull Long id) {
    return new Aventurier(id);
}

@Description("Liste des quêtes en cours ou terminées de l'aventurier")
public List<List<QueteResponse>> quetes(@Source List<Aventurier> aventuriers) {
    Map<Long, List<Quete>> quetesParAventurier = queteService.getQuetesForAventuriers(
            aventuriers.stream().map(Aventurier::getId).toList());

    return aventuriers.stream()
            .map(a -> quetesParAventurier.getOrDefault(a.getId(), List.of()).stream()
                    .map(QueteResponse::fromDomain)
                    .toList())
            .toList();
}

QueteResource, côté quete-service

La première méthode mérite un mot d'explication : elle ne sert à aucun client GraphQL humain. C'est un resolver de référence. Quand le routeur a besoin de savoir ce que quete-service peut dire d'un aventurier, il n'envoie pas tout l'objet, juste sa clé. Le service reconstruit un Aventurier minimal à partir de cet identifiant, suffisant pour que le second resolver, quetes, sache à qui répondre.

Et ce second resolver, c'est exactement le même principe que le batching de l'épisode 4, sauf qu'il traverse maintenant le réseau plutôt qu'une simple frontière de méthode. Au lieu d'un appel par aventurier, le routeur regroupe tous les aventuriers concernés par une requête et interroge quete-service une seule fois avec la liste complète. Il suffit d'activer cette option côté configuration :

quarkus.smallrye-graphql.federation.enabled=true
quarkus.smallrye-graphql.federation.batch-resolving-enabled=true

Sans cette dernière ligne, le service se retrouverait à recevoir un appel par aventurier, exactement le problème que l'épisode 4 avait pris soin d'éviter à l'intérieur d'un seul service. La fédération n'annule pas ce risque, elle le déplace simplement à l'échelle du réseau, avec les mêmes conséquences si on l'ignore.

Sous le capot : comment le routeur retrouve un aventurier

Le resolver de référence de tout à l'heure, celui qui reconstruit un Aventurier à partir d'un simple identifiant, n'est pas une convention arbitraire de SmallRye. Il répond à un contrat précis défini par la spécification de fédération elle-même : tout subgraph qui étend un type porteur de @key doit exposer une requête spéciale, _entities, capable de retrouver n'importe laquelle de ses entités à partir d'une simple représentation de sa clé.

Quand le voyageur demande les quêtes de huit aventuriers, le routeur ne pose pas huit questions séparées à quete-service. Il envoie une seule requête, qui ressemble à ceci une fois traduite en clair :

query($representations: [_Any!]!) {
  _entities(representations: $representations) {
    ... on Aventurier {
      quetes {
        titre
        recompenseOr
      }
    }
  }
}

ce que le routeur envoie vraiment à quete-service

Avec, en variables, la liste des clés à résoudre :

{
  "representations": [
    { "__typename": "Aventurier", "id": "1" },
    { "__typename": "Aventurier", "id": "2" },
    { "__typename": "Aventurier", "id": "3" }
  ]
}

C'est cette requête _entities, générée automatiquement par SmallRye à partir de nos annotations, qui appelle en coulisses la méthode aventurier(@Id Long id) pour reconstruire chaque stub, puis le resolver batché quetes pour les compléter en un seul passage.
Le tavernier n'écrit jamais cette requête lui-même, il se contente de fournir les deux briques, le résolveur de référence et le resolver batché, et le mécanisme de fédération les assemble pour lui à chaque appel.

Ça vaut la peine de le savoir, ne serait-ce que pour lire un plan de requête (query plan) le jour où l'un des deux services deviendra lent : ce que le routeur affiche alors, ce sont précisément des appels à _entities, pas des noms de méthodes Java.

Assembler le royaume

Une fois les deux comptoirs prêts, il reste à les relier. Le rôle du CLI rover est de lire le schéma que chaque service expose et de les composer en un seul document, le supergraphe, qui décrit le royaume entier vu de l'extérieur : quels types existent, qui les possède, qui les étend.
Ce document composé est ensuite ce que l'Apollo Router charge et sert.

federation_version: =2.7.3
subgraphs:
  aventurier:
    routing_url: http://host.docker.internal:8081/graphql
    schema:
      subgraph_url: http://localhost:8081/graphql
  quete:
    routing_url: http://host.docker.internal:8082/graphql
    schema:
      subgraph_url: http://localhost:8082/graphql

Ce que rover doit savoir pour composer le royaume

Le routeur, lui, tourne à côté dans un conteneur, avec le supergraphe composé en entrée :

services:
  router:
    image: ghcr.io/apollographql/router:v1.55.0
    ports:
      - "4000:4000"
    volumes:
      - ./router.yaml:/dist/config/router.yaml:ro
      - ./supergraph.graphql:/dist/schema/supergraph.graphql:ro
    command:
      - --config
      - /dist/config/router.yaml
      - --supergraph
      - /dist/schema/supergraph.graphql

docker-compose.yml, la porte d'entrée du royaume

Un détail à ne pas négliger : ce supergraph.graphql est un instantané. Le jour où un champ change de guilde, où quete-service gagne un nouveau champ, ou même où sa version évolue légèrement, il faut recomposer le supergraphe avec rover et relancer le routeur pour qu'il en tienne compte.
Rien ne se met à jour tout seul en local. C'est précisément ce que la fédération managée d'Apollo (GraphOS) automatise en production, en surveillant les subgraphs et en recomposant à chaque publication, mais c'est un service payant, distinct de ce qu'on met en place ici à la main. Pour un projet personnel ou une démonstration, recomposer soi-même reste largement suffisant, et surtout, ça oblige à comprendre ce que fait réellement cette étape plutôt que de la déléguer aveuglément.

À partir de là, le voyageur qui interroge le port 4000 n'a plus besoin de savoir qu'il existe deux guildes séparées :

query {
  aventuriers {
    nom
    niveau
    quetes {
      titre
      recompenseOr
    }
  }
}

Le routeur découpe cette question en deux appels internes, un vers chaque service, recompose la réponse, et la renvoie comme si elle avait toujours été servie par un seul comptoir :

{
  "nom": "Odran",
  "quetes": []
}

Odran n'a aucune quête, et ça se voit : une liste vide, pas un trou dans la réponse.

Ce dernier détail n'a rien d'anodin. Il confirme que le batching côté quete-service traite bien chaque aventurier de la requête, y compris ceux qui n'ont rien à offrir, plutôt que de les faire silencieusement disparaître de la réponse.

Quand une guilde ferme boutique

Reste une question que la démonstration précédente ne pose pas, tant que tout fonctionne : que se passe-t-il si l'une des deux guildes est fermée au moment où le voyageur pousse la porte, si quete-service plante ou n'a pas encore démarré ?

Le routeur ne fait pas échouer toute la requête pour autant. GraphQL a été pensé dès le départ pour tolérer des réponses partielles : si quetes ne peut pas être résolu, le routeur renvoie quand même nom et niveau, accompagnés d'un tableau errors qui explique précisément quel champ a échoué et pourquoi. Le voyageur reçoit une réponse incomplète plutôt qu'un silence total.

Cette tolérance a une limite, et elle tient à un détail qu'on a pris soin de respecter sans le souligner : dans notre schéma, quetes renvoie une liste simple, pas une liste non-nullable ([QueteResponse!], pas [QueteResponse!]!).
Si le champ avait été déclaré non-nullable, une panne de quete-service n'aurait pas seulement vidé ce champ : l'absence de valeur aurait remonté au premier ancêtre nullable du schéma, potentiellement jusqu'à faire disparaître l'aventurier entier de la réponse, alors que aventurier-service, lui, se portait très bien.
C'est une règle du cœur de la spécification GraphQL, pas une particularité de la fédération, mais la fédération en fait une conséquence bien plus concrète : la nullabilité d'un champ devient littéralement une décision de tolérance de panne inter-services.

Conclusion

La taverne a changé de nature dans cet épisode, sans que le voyageur s'en rende compte. Elle n'est plus un comptoir, elle est un royaume de comptoirs, chacun responsable d'un seul domaine, chacun capable de dire aux autres ce qu'il possède et ce qu'il emprunte. Le batching, déjà central à l'épisode 4, s'est simplement étendu au-delà des murs d'un seul service, avec les mêmes règles et la même vigilance sur les cas vides.

Ce que cet épisode ne dit pas encore, c'est comment ce royaume réagit quand une nouvelle quête apparaît pendant qu'un voyageur regarde. Ça, ce sera pour une autre veillée.


Tout le code relatif à cet article peut être consulté ici

GitHub - ErwanLT/quarkus-demo: Demo project for quarkus possibility
Demo project for quarkus possibility. Contribute to ErwanLT/quarkus-demo development by creating an account on GitHub.

Dernier