17 novembre 2021

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

Clément Rivaille - Lead Developer

Nous arrivons à la dernière partie de notre série d’articles sur l’optimisation d’API GraphQL. Précédemment, nous nous sommes servi d’un cache HTTP pour mettre en cache des requêtes entières, puis de dataloaders pour ne récupérer qu’une seule fois chaque entité dans une même requête. À présent nous allons utiliser les data sources pour mettre en cache des entités entre plusieurs requêtes.

Les data sources sont des classes d’Apollo qui servent de connecteur entre graphQL et une base de données ou une API Rest. Ici ce qui nous intéresse, c’est leur capacité de mettre en cache leur ressources. On est donc au niveau de cache le plus bas : cache sur les entités stockées en base de données !

Utilisation

Ils sont un peu plus complexes à mettre en place que les dataloaders. Tout d’abord, il faut utiliser une classe appropriée au type de base de donnée avec laquelle on souhaite interagir (SQL, Mongo, REST…). Dans les exemples qui suivent, j’utiliserai MongoDataSource, mais les logiques avec d’autres sont équivalentes.

Comme pour les dataloaders, je suggère de créer une classe générique héritant ou encapsulant (battez-vous) la data source. Cela permet de faire facilement le lien avec mongoose, et de donner une TTL (temps de cache, en seconde) différente selon la collection, ainsi que de l’hydratation de donnée. En effet la MongoDataSource passe directement par Mongo, et non les fonctions de mongoose. Ce qui est bien plus optimal, mais en contrepartie enlève certains raccourcis (comme la propriété id pour obtenir l’id sous forme de string).

	
import { DataSourceConfig } from 'apollo-datasource';
import {
 Fields,
 ModelOrCollection,
 MongoDataSource
} from 'apollo-datasource-mongodb';
import { Model, Document, LeanDocument } from 'mongoose';function hydrateDocument(doc: LeanDocument): LeanDocument {
 return {...doc, id: doc._id.toString() };
}export class APIDataSource extends MongoDataSource<
 LeanDocument,
 GraphQLAPIContext
> {
 constructor(
   model: Model,
   private ttl: number
 ) {
   super(model.collection as ModelOrCollection>);
 }  async getOneById(id: string) {
   const doc = await this.findOneById(id, { ttl: this.ttl });
   return (doc && hydrateDocument(doc)) || null;
 }  async getManyById(ids: readonly string[]) {
   const documents = await this.findManyByIds([...ids], { ttl: this.ttl });
   return documents.filter(isNotNullOrUndefined).map(hydrateDocument);
 }  // Warning: Cette fonction ne prend que des clé-valeurs simples (string, number ou boolean) n'accepte ni les opérations complexes ($or, $and), ni les ObjectIds
 async findDocuments(fields: Fields, ttl?: number) {
   const documents = await this.findByFields(fields, {
     ttl: isNotNullOrUndefined(ttl) ? ttl : this.ttl
   });
   return documents.filter(isNotNullOrUndefined).map(hydrateDocument);
 }  // Cette fonction est appelée automatiquement par Apollo au début de la requête, en lui passant entre autre le contexte (dans config.context). Utile donc pour effectuer certains réglages éventuels.
 initialize(config: DataSourceConfig) {
   if (super.initialize) {
     super.initialize(config);
   }
 }
}
	

Note : Ne vous souciez pas des LeanDocument, c’est une interface propre à mongoose

Comme vous pouvez le voir, on retrouve les mêmes fonctionnalités que les dataloaders : récupérer un objet par id, ou une liste. Ces deux fonctions vont se charger de vérifier si la donnée est présente dans le cache et n’est pas expirée. Si oui, elle la retourne, si non, elle fait la requête. Sur plusieurs appels d’API, on a donc un seul qui va peupler le cache pour tous les autres ! La troisième fonction, find, peut être parfois utile, mais on verra ensuite une manière plus efficace de mettre en cache des requêtes complexes.

Pour initialiser ces data sources, nous avons besoin d’une factory qui va retourner un objet dont les clés sont associées à des data sources pour les collections respectives.

	
export function buildDataSources() {
 return {
   user: new APIDataSource(MongoUser, 300),
   post: new APIDataSource(MongoPost, 60),
   library: new APIDataSource(MongoLibrary, 60),
   // …
 };
}
export type GraphQLContextDataSources = ReturnType;
	

Cette fonction va ensuite être passée à l’initialisation du serveur Apollo, avec le cache redis (le même que celui créé pour le cache HTTP). Notez que l’on renseigne la fonction sans l’appeler.

	
const apolloServer = new ApolloServer({
 // …
 cache,
 dataSources: buildDataSources
});
	

Apollo se chargera lui-même de construire et initialiser les data sources, et les ajouter au contexte. Chaque resolver pourra ainsi y accéder.

	
const post = await context.dataSources.post.findById(id);
	

Léger piège : pour peu que vous utilisiez Typescript, vous vous heurterez à un problème à la construction du contexte graphQL. En effet vous ne pourrez pas renseigner la propriété dataSources dans le constructeur, car c’est Apollo qui l’ajoute après ! C’est pourquoi vous avez besoin de deux classes de contexte : une sans dataSources, utilisée que pour la construction, et une avec, accessible depuis les resolvers.

	
interface GraphQLAPIContext extends GraphQLAPIContextBase {
 dataSources: GraphQLContextDataSources;
}
	

Il ne manque plus qu’une dernière chose. Maintenant que nous avons des entités mises en cache, avec des durées d’une minute environ, que risque t-il de se passer à l’édition ou à la suppression ? Oui, les utilisateurs vont récupérer l’ancienne version toujours en cache, ce qui n’est pas bon du tout ! Pour nettoyer le cache, il faut penser à faire dans les mutations d’édition ou suppression :

	
context.dataSources.post.deleteFromCacheById(id)
	

context.dataSources.post.deleteFromCacheById(id)

Afin qu’il soit à jour au prochain appel.

Qu’en est-il des dataloaders ?

Vous l’aurez peut-être deviné, les data sources remplissent la même fonction que les dataloaders, mais sur plusieurs requêtes. Ces derniers deviennent donc pratiquement obsolètes. Il est tout à fait possible de choisir l’un ou l’autre.

Il est malgré tout possible de les combiner. Notamment si vous souhaitez limiter les appels au cache dans une seule requête. La logique à suivre, c’est de faire en sorte que les dataloaders utilisent les datasources. Les resolvers se contentent ainsi d’appeler les dataloaders, qui derrière appelleront les data sources. Pour cela, l’ordre dans lequel j’ai procédé est :

  • Construire d’abord les dataloaders dans le contexte, sans datasource
  • Construire les datasource
  • Dans la fonction initialize d’un datasource, surcharger le dataloader correspondant (trouvable dans le contexte) pour qu’il fasse appel à l’instance du data source

Mettre en cache des requêtes complexes

Mettre en cache des entités par id, c’est pratique. Mais c’est loin de constituer les requêtes les plus lourdes, ni les plus fréquentes. On aimerait pouvoir mettre en cache des recherches plus complexes. C’est encore possible avec les data sources ! Pour cela, il va falloir mettre un peu la main à la pâte, et construire notre propre Data Source. On procède en héritant de la classe d’Apollo DataSource. Dans celle-ci, nous pouvons surcharger la méthode initialize pour récupérer le cache qui servira dans des fonctions personnalisées. Ces fonctions suivent toutes la même logique : on vérifie si la clé existe dans le cache, si oui on la retourne, si non on va faire l’appel en base de données et on écrit le résultat en cache avec un TTL adéquat.

	
import { DataSource, DataSourceConfig } from 'apollo-datasource';
import { KeyValueCache } from 'apollo-server-caching';
import GraphQLAPIContext from 'GraphqlAPI/context';const RECENT_POSTS_KEY = 'recent_posts_';export default class CustomDataSource extends DataSource {
 private cache?: KeyValueCache;  initialize(config: DataSourceConfig) {
   this.cache = config.cache;
   if (super.initialize) {
     super.initialize(config);
   }
 }  async getRecentPostsIds(authorId: string): Promise {
   const postsJson = this.cache
     ? await this.cache.get(`${RECENT_POSTS_KEY}${authorId}`)
     : undefined;
   const storedPostsIds: string[] | undefined =
     (postsJson && isValidJSON(postsJson) && JSON.parse(postsJson)) || undefined;    if (storedPostsIds) {
     return storedPostsIds;
   } else {
     // look in database
     const posts = await searchRecentPosts(authorId);      const postsIds = posts.map(post => post.id);
     if (this.cache) {
       this.cache.set(
         `${RECENT_POSTS_KEY}${authorId}`,
         JSON.stringify(postsIds),
         { ttl: 180 }
       );
     }      return postsIds;
   }
 }  async clearRecentPostsIds(authorId: string) {
   if (this.cache) {
     await this.cache.delete(
       `${RECENT_POSTS_KEY}${authorId}`
     );
   }
 }
}
	

Note : Dans cet exemple, on ne retourne pas les entités entières, mais leurs ids. En effet, vu qu’on a des data sources qui permettent déjà de récupérer les entités depuis le cache par id, cela ne sert à rien de les stocker une deuxième fois.

Il ne reste plus qu’à ajouter ce data source fait maison à notre factory.

	
export function buildDataSources() {
 return {
   // …
  custom: new CustomDatasource()
 };
}
	

Il est ensuite accessible dans les Query GraphQL depuis le contexte.

	
context.dataSources.custom.getRecentPosts(authorId);
	

Avec ces trois outils, Cache HTTP, Dataloaders, et Data sources, vous disposez de leviers puissants pour améliorer les performances d’un serveur graphQL. Toujours dans l’optique de limiter les appels faits à la base de donnée. Pour résumer chaque niveau :

  • Le Cache HTTP met en cache le résultat de requêtes entières sur des laps de temps variables
  • Les dataloaders garantissent que les recherches par ID sur la BDD ne sont exécutées qu’une seule fois lors d’un appel de l’API
  • Les data sources mettent en cache des entités de la BDD ou des résultats de recherche spécifiques pendant des laps de temps variables

Cependant le nombre de requêtes vers la base de données n’est pas l’unique charge qui pèse sur un serveur !

Il y a d’autres pistes d’amélioration qui sont aussi à envisager :

  • Utilisez un ORM à jour. Cela peut vraiment faire la différence.
  • Désactivez les hydratation de votre ORM que vous n’utilisez pas. Les fameux LeanDocument que vous pouvez voir dans ces exemples sont une classe de mongoose, qui possède une option pour désactiver les nombreuses fonctionnalités de sa classe de Document par défaut. Une charge non négligeable qui se retire « facilement » (quand c’est fait en amont)
  • Alternativement, se passer de l’ORM et faire directement des appels en base de donnée peut s’avérer efficace. C’est ce que font les data sources !
  • Utilisez des indexes dans votre base de données là où c’est pertinent.
  • Évitez les validations de donnée superflues. C’est tentant d’être rigoureux dans chaque route, et de vouloir vérifier que la donnée est absolument cohérente. Mais si cela coûte plusieurs requêtes BDD appelées fréquemment, ce n’est peut-être pas l’idéal. Il peut être parfois plus avantageux de laisser un peu de flexibilité, et partir du principe qu’une fonction n’est appelée que dans un contexte qui a déjà été validé en amont.

Mettre en place du cache est une première étape pour l’optimisation des performances du serveur. Il convient ensuite aux développeurs de l’utiliser judicieusement pour faire le maximum d’économie sur les appels les plus utilisés.