22"
Optimisation d'une API GraphQL avec du cache serveur

27 Août 2021

Clément Rivaille

Est-ce que j'en ai vraiment besoin ?

Cache HTTP

type Post @cacheControl(maxAge: 120) {
  id: ID!
  title: String!
  author: Author!
  votes: Int
  comments: [Comment!] @cacheControl(maxAge: 60)
  readByCurrentUser: Boolean! @cacheControl(maxAge: 10, scope: PRIVATE)
}
  • Chaque type a par défaut un maxAge 0, scope PUBLIC
  • Les types primitifs héritent du parent
  • Le maxAge de la requête est le plus petit présent dans l'arbre
  • Le cache est PRIVATE si un seul nœud l'est
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 server = new ApolloServer({
  // ...
  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
  ],
  // tracing: true,
  // cacheControl: true
})
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 server = new ApolloServer({
  // ...
  plugins: [
    responseCachePlugin({
      cache
})

Dataloader

Conserver les instances dans le Contexte le temps de la requête

const post = await context.loaders.post.load(postId);
const authors = comments && await context.loaders.comment.loadMany(post.commentIds);
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()
    }
  );
}

Data sources

  • Injectés dans le contexte par Apollo (context.datasources)
  • Cache : stocke les résultats par ids dans redis, avec un TTL
const post = await context.datasources.post.findOneById(postId, { ttl: 180 });
const comments = post && await context.datasources.comment.findManyByIds(
  post.commentIds, { ttl: 60 }
);
context.dataSources.post.deleteFromCacheById(id)
export function buildDataSources() {
  return {
    user: new MongoDataSource<LeanDocument<IUserModel>, GraphQLAPIContext>(MongoUser.collection),
    post: new MongoDataSource<LeanDocument<IPostModel>, GraphQLAPIContext>(MongoPost.collection),
    library: new MongoDataSource<LeanDocument<ILibraryModel>, GraphQLAPIContext>(MongoLibrary.collection),
    // …
  };
}
export type GraphQLContextDataSources = ReturnType<typeof buildDataSources>;
new ApolloServer({
  // …
  cache,
  dataSources: buildDataSources
})

Et pour aller plus loin…

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<GraphQLAPIContext> {
  private cache?: KeyValueCache<string>;

  initialize(config: DataSourceConfig<GraphQLAPIContext>) {
    this.cache = config.cache;
    if (super.initialize) {
      super.initialize(config);
    }
  }

  async getRecentPostsIds(authorId: string): Promise<string[]> {
    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}`
      );
    }
  }
}

Merci !

Plus de détails dans les articles du blog

 

(… et sur Sportall)

22 - Optimisation d'une API GraphQL avec du cache serveur

By lonestone

22 - Optimisation d'une API GraphQL avec du cache serveur

  • 281