Optimiser une API GraphQL à l’aide d’un cache serveur — Partie 2

Dans la précédente partie de cette série d’articles sur l’optimisation d’une API GraphQL, nous avons mis en place un cache HTTP se configurant sur les schémas eux-même. À présent, on souhaiterait avoir un cache plus fin sur des morceaux de requête. Pour cela, nous aurons recours aux Dataloaders.

À quoi sert un Dataloader ?

Les dataloaders sont des outils GraphQL permettant de mémoriser dans une requête une entité déjà récupérée depuis la base de données. Exemple : supposons une liste d’entités A, B, C, etc, ayant chacune un resolver attachedTo pointant sur un même objet X. Sans dataloader, ce resolver sera exécuté pour chaque entité, faisant ainsi plusieurs fois le même appel vers la base de données !

https://cdn-images-1.medium.com/max/800/1*jHZHRvI7KGSZ1vuyBARteQ.png

Et les resolvers ne sont que l’exemple le plus basique. Sur mon projet actuel on a sans cesse besoin d’accéder à certaines entités parentes afin de déterminer l’accès à certaines informations, voire calculer certaines propriétés (« L’élément dont fait partie cet item est-il enfant d’un événement en cours ? », « Quel est l’organisme propriétaire et l’utilisateur en fait-il partie ? », etc). Si à chaque fois que l’on a besoin d’une ressource, on va la chercher en base de données, on fait vite grimper le nombre de requêtes.

Les dataloaders répondent à cette problématique. Un dataloader est associé à une collection (en Mongo, une table pour SQL) et permet de récupérer une ou plusieurs entités depuis leurs ids. Il conserve le résultat tout au cours de la requête. Si la même ressource est demandée, il pourra la retourner sans la chercher en base de donnée. De cette manière, le dataloader garantit qu’une requête BDD n’est pas exécutée plus de fois que nécessaire.

Mise en place

Pour construire un dataloader, on utilise la classe Dataloader dont le constructeur a essentiellement besoin de deux éléments :

  • Une fonction de batch retournant, pour une liste de clés (de type K), les valeurs correspondantes. La raison pour laquelle cette fonction prend et retourne une liste est pour permettre au dataloader de faire du batching.
  • Dans les options, une fonction de transformation de clé (K) en chaîne de caractère. Si vos clés sont déjà des strings, cette fonction n’est pas requise. Mais elle est indispensable pour Mongo où l’on manipule des ObjectId.

Voici un exemple de fonction générique construisant un dataloader sur une collection Mongo (en utilisant mongoose). Le type de clé est ici ObjectId.

import DataLoader from 'dataloader';function createLoader<T extends mongoose.Document>(model: mongoose.Model<T>) {
 return new DataLoader(
   async (ids: readonly ObjectId[]) => {
     const documents = await model.find({
       _id: {
         $in: ids
       }
     } as FilterQuery<T>);
     return ids.map(
       id =>
         documents.find(
           doc => doc._id && doc._id.toString() === id.toString()
         ) || null
     );
   },
   {
     cacheKeyFn: key => key.toString()
   }
 );
}

Cette fonction permet de créer une liste de plusieurs loaders pour les données les plus demandées. L’idéal ensuite est d’instancier ces dataloaders depuis le context GraphQL. Dans le constructeur de ma classe GraphQLAPIContext, j’ai ainsi ajouté :

this.loaders = buildLoaders();

De cette façon je peux accéder à l’instance des loaders depuis n’importe quel resolver. Un loader propose deux fonctions principales : load, pour récupérer un donnée par une clé, et loadMany pour en récupérer plusieurs.

const post = await loader.posts.load(id);
const authors = post &&
 await loader.authors.loadMany(post.authorsIds);

Ainsi dans une requête, dès qu’un document est chargé depuis un nœud GraphQL, il sera accessible pour tous les autres sans avoir besoin de refaire une requête Mongo.

Toutefois, cela n’est valable que pour une seule requête. Les dataloaders sont réinitialisés à chaque appel. Donc si on a énormément d’appels simultanés (et dans ma situation, c’est le cas), on conservera un nombre conséquent de requêtes à la BDD. Y a t-il moyen de conserver le résultat d’une requête BDD sur plusieurs appels API ? Nous le découvrirons dans la dernière partie, consacrée aux data sources.