3 novembre 2021

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

Clément Rivaille - Lead Developer

En début d’année, sur le projet sur lequel je travaille, nous nous sommes confronté à des problèmes de performance sur notre API. Nous n’avions pas d’énorme dette technique, et employions de bonnes pratiques de développement, mais la base d’utilisateurs grossissait à un point où « bien coder » ne suffisait plus à faire tenir le serveur. Il a fallu s’attaquer sérieusement à cette problématique très souvent mise de côté : comment alléger la charge du serveur ?

Pourquoi c’est pas facile et quelles sont les solutions ?

Notre API est une API GraphQL basée sur Express, utilisant Mongo comme base de données (mais cet article est valable pour n’importe quel type de base de données). Le service est lié à des événements en direct, ce qui signifie que l’on fait face à d’énormes pics de requêtes simultanées. De plus, les entités en base ont beaucoup de dépendances entre elles, liées aux permissions des utilisateurs. De fait, pour récupérer un élément, il est souvent nécessaire d’aller chercher ses parents ou d’autres données liées à l’utilisateur pour calculer chaque resolver. Et je ne vous parle pas de la page d’accueil qui affiche du contenu dynamique en fonction de ce qui est publié, les dates des événements, et les préférences de chaque utilisateur ! Bref, de très nombreux appels vers la base de données pour chaque requête, tous absolument nécessaires. Ça passe pour 100 utilisateurs, mais à partir de 600, tout s’effondre, même avec un cluster de 5 nœuds.

Optimiser ce genre d’API n’est pas une tâche facile. Car GraphQL n’a pas la même logique que REST : une Query est un arbre composé de plusieurs petits nœuds, que l’on ne peut pas calculer à l’avance. Chacun de ces nœuds est un resolver, avec sa propre logique, qui va faire ses propres micro-appels en base de données. Impossible donc de faire un seul énorme appel parfaitement optimisé en tout début de requête ! Il faut forcément que ce soit modulable.

Heureusement, il existe un outil puissant pour résoudre ce problème : le cache serveur. L’optique est d’éviter de faire des appels base de donnée (BDD), pour récupérer le résultat dans Redis à la place. C’est un gain de performance non négligeable ! Et Apollo propose des outils bien pratiques pour cela. On trouve ainsi trois niveaux de cache : Cache HTTP, Dataloaders, et Datasources. Cette première partie sera consacrée au premier.

Server-side caching avec Redis

Le cache HTTP est le plus haut niveau de cache côté serveur. L’idée ici est, pour deux appels identiques au serveur, de conserver le résultat du premier et le renvoyer au deuxième. On économise littéralement l’intégralité de la requête !

C’est plaisant sur le papier, mais bien sûr il y a un peu de complexité derrière. Les données doivent être maintenues en cache pour un certain temps seulement. Certaines expirent plus rapidement que d’autres. Certaines dépendent de l’utilisateur qui fait la requête. Et certaines ne doivent même surtout pas être mises en cache ! Comment configurer cela ?

Apollo offre une élégante solution de server-side caching pour cela. Celle-ci utilise directement le schéma GraphQL pour spécifier la durée et l’étendue du cache, pour chaque type de donnée. Simple à écrire, et tout autant à lire.

	
type Post @cacheControl(maxAge: 60) {
 id: ID!
 title: String!
 author: Author!
 votes: Int
 comments: [Comment!]
 readByCurrentUser: Boolean! @cacheControl(maxAge: 10, scope: PRIVATE)
}
	

Voyons comment mettre cela en place. Le plugin responseCachePlugin doit être initialisé à la construction de l’ApolloServer, en paramétrant la ressource de cache à utiliser. Ici, ça sera un cache Redis.

	
import { BaseRedisCache } from 'apollo-server-cache-redis';
import responseCachePlugin from 'apollo-server-plugin-response-cache';
import Redis from 'ioredis';const cache = new BaseRedisCache({
 client: new Redis({ ...config.redis })
});const apolloServer = new ApolloServer({
 // […]
 plugins: [
   responseCachePlugin({
     cache
   })
 ]
});
	

Une fois ceci mis en place, il suffit de configurer le schéma directement dans les fichiers .graphql, en utilisant le tag cacheControl, pour mettre en cache les requêtes entières. Le fonctionnement est celui-ci :

  • maxAge indique la durée de validité du cache en secondes. Par défaut, elle vaut 0.
  • scope prend la valeur PUBLIC ou PRIVATE pour indiquer si un cache est commun à tous les utilisateurs, ou s’il est unique à chacun. Par défaut, il vaut PUBLIC
  • Le cache prend effet sur toute la requête (et non les resolvers, nous verrons dans les parties suivantes comment le faire). La valeur maxAge pour la requête est celle du plus petit maxAge présent dans l’arbre, et le scope est PRIVATE si une seule des propriété l’est.
  • Les types primitifs héritent de la configuration de leur parent direct. Les types complexes quant à eux prennent les valeurs par défaut (0, PUBLIC) ou celles écrites dans le schéma. Il vaut donc mieux spécifier le cache sur tous les types existants.

En prenant bien le temps de passer sur tout le schéma, on a déjà un cache bien performant ! Dans le cas du projet où je l’ai mis en place, je suis parti sur une durée de 60 secondes par défaut, 20 ou 30 pour les données ayant besoin d’être rafraîchies rapidement, 240 voire 300 pour les éléments rarement mis à jour. Ce n’est pas nécessaire d’avoir un cache trop long, et quand il y a beaucoup d’appels ça fait déjà une différence.

Il reste quelques configurations à ajouter au plugin (toujours dans les options de la fonction d’initialisation) :

  • sessionId: Fonction prenant en paramètre la requête, et retournant l’id unique de l’utilisateur. Nécessaire pour identifier l’utilisateur et ainsi faire fonctionner le scope PRIVATE. L’id peut être un token JWT dans les header, ou même un id présent dans le context graphQL.
  • shouldReadFromCache et shouldWriteToCache : Fonctions asynchrone prenant en paramètre la requête et le contexte, et retournant un booléen indiquant respectivement si la requête peut être lue depuis le cache, et conservée dans le cache. Certains cas d’usage en effet peuvent nécessiter de contourner le cache, comme un backoffice par exemple (où l’on veut la donnée la plus à jour possible). Idem pour certains utilisateurs, comme les admins, qui ont accès à des données “non publiées” qui ne doivent pas être intégrées dans le cache public. Ces fonctions permettent donc de se baser sur un header, ou des infos de l’utilisateur, pour ignorer le cache.

Voici un exemple de configuration du cache avec ces paramètres :

	
plugins: [
 responseCachePlugin({
   cache,
   sessionId: request =>
     (request.context as GraphQLAPIContext).uid || null,
   shouldReadFromCache: async ({ request, context }) => {
     if (request.http && request.http.headers.get('Cache-Control') === 'no-cache') {
       return false;
     }
     const apiContext = context as GraphQLAPIContext;
     if (await context.isUserAdmin()) {
       return false;
     }
     return true;
   },
  shouldWriteToCache: async ({ request, context }) => {
    // [...] same
  }
 }) as ApolloServerPlugin
],
// Ces deux options sont optionelles ; elles permettent d'avoir un log du cache dans les réponses, très utile pour le dev
// tracing: true,
// cacheControl: true
	

Règles dynamiques de cache

Enfin, on peut affiner encore plus loin en spécifiant des règles de cache dynamiques à l’intérieur des resolvers. Par exemple, supposons que pour une même entité, on souhaite avoir un cache public par défaut, mais privé si elle remplie certaines conditions ? Ou que si elle est dans un certain état, elle doit être en cache moins longtemps ?

Pour mettre en place ce genre de logique, On utilise cacheControl depuis les GraphQLResolveInfo, fournies en paramètre dans chaque resolver.

	
// Post.resolver.ts
// […]post: async (
 _parent,
 args
 context,
 { cacheControl }: GraphQLResolveInfo
) => {
 const post = await getPost(args.id);
 cacheControl.setCacheHint({
     scope: post.private ? CacheScope.Private : CacheScope.Public,
     maxAge: post.draft ? 20 : 120
 });
 return post;
}
	

On a à présent un cache sur des requêtes entières !

Mais il a ses limites. Dès que la requête est privée, elle est exécutée par chaque utilisateur. Et pour certaines données critiques, il n’est pas très long. Or, avec ce cache, c’est tout ou rien : soit la requête entière est en cache, soit non. Ce qui signifie que si une requête lourde inclut ne serait-ce qu’une propriété à faible cache, tout l’arbre de resolver est exécuté. Il faudrait pouvoir alléger les requêtes elle-mêmes, morceaux par morceaux.

C’est la solution que l’on mettra en place dans la deuxième partie, via les dataloaders. Stay tuned…