Introduction

Qu'allons-nous faire ?

Dans un tutoriel précédent l'astronaute Jonathan vous a présenté comment mettre en place un serveur GraphQL avec une base de données. Ici, nous allons voir comment utiliser Apollo en passant par des APIs REST et surtout les points d'attention pour préserver les performances de votre application.

Nous allons mettre en place un serveur GraphQL et une application front via le framework Apollo.

Pré-requis

Nous allons utiliser https://api.nasa.gov, une API ouverte à tout le monde, mise à disposition par la NASA. Vous pouvez utiliser la clé d'authentification de démo (qui est limitée à 30 appels par heure), mais si vous souhaitez vous pouvez aller sur cette page pour demander une clé d'authentification personnelle.

authentication_key

Le serveur Apollo sera en NodeJS en version 10. L'application front sera faite en React. Je vais utiliser Docker pour ce projet. Le code fourni pour le tutoriel est disponible ici et contient un fichier docker-compose.yml vous permettant d'installer le projet.

Apollo serveur

Mise en place du serveur Apollo

Initialisation du projet

Pour commencer, clonez la branche master de ce projet. Ainsi, vous devriez pouvoir faire :

docker-compose up -d

L'application doit être disponible à l'adresse http://localhost:3000/.

Ajout des dépendances

Maintenant que nous avons une application qui tourne, nous allons ajouter les dépendances pour la librairie Apollo server :

docker-compose exec gateway yarn add apollo-server apollo-server-express graphql --save

Enfin, nous devons créer un serveur ApolloServer et l'ajouter à notre application. Pour ceci, nous allons modifier le fichier src/index.js :

import express from 'express'; import { ApolloServer, gql } from 'apollo-server-express'; const PORT = 3000; const app = express(); const typeDefs = gql` type Query { hello: String } `; const resolvers = { Query: { hello: () => 'Hello, world!' } }; const server = new ApolloServer({ typeDefs, resolvers, }); server.applyMiddleware({ app }); app.listen({ port: PORT }, () => console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`) );

Ça y est, nous avons un serveur GraphQL qui tourne ! 🚀

À cette étape, nous devons pouvoir faire une query et avoir un résultat, comme ceci :

graphql-init

Création du schéma des données

Comme vous pouvez constater ici, lors de la création d'ApolloServer nous devons lui passer nos types & resolvers. Au fur et à mesure que notre application grandit, leur nombre augmente aussi. Par conséquence, nous ne pouvons pas les laisser dans le fichier src/index.js comme ci-dessus, mais nous allons plutôt séparer tout cela dans des fichiers et dossiers afin de structurer notre application. Je vais créer les dossiers suivants à l'intérieur de src :

  • le dossier definitions contiendra les Queries, Mutations et les types que nous allons définir dans l'application
  • le dossier dataSources contiendra les différentes APIs REST que nous allons appeler
  • le dossier resolvers quant à lui nous permettra d'implémenter les resolvers de nos différents types
  • enfin, je vais créer un dossier helpers dans lequel je mettrai notamment un GraphqlHelper qui me permettra de charger les fichiers qui sont dans les dossiers ci-dessus. Le GraphqlHelper parcourt les dossiers de façon reccursive et charge tous les fichiers dans le schéma.

Pour implémenter cette structure de dossier, nous allons avoir besoin de modifier notre code. Pour gagner du temps, je vous mets à disposition ici le helper et tous les fichiers modifiés. J'ai simplement déplacé la Query hello dans le fichier src/definitions/Query.graphql et le resolver dans le fichier src/resolvers/hello.js.

Et nous allons mettre à jour notre fichier src/index.js:

import express from 'express'; import { ApolloServer, makeExecutableSchema } from 'apollo-server-express'; const PORT = 3000; const app = express(); const GraphQLHelper = require('./helpers/graphql'); const server = new ApolloServer({ schema: makeExecutableSchema({ typeDefs: GraphQLHelper.typeDefs, resolvers: GraphQLHelper.resolvers, }), dataSources: () => GraphQLHelper.dataSources, }); server.applyMiddleware({ app }); app.listen({ port: PORT, expressApp: app }, () => console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`) ); app.get('/', (req, res) => res.send('Hello World!'));

Nous utilisons makeExecutableSchema pour construire notre schéma graphql qui est détaillé ici.

Rest data source

Tout d'abord nous allons ajouter la dépendance suivante :

docker-compose exec gateway yarn add apollo-datasource-rest --save

Cette librairie met à disposition une classe RESTDataSource qui permet de faire des appels REST facilement.

L'API de la NASA met à disposition plein de ressources. Imaginons que nous avons une page sur notre site où nous allons afficher sur la homepage de notre application :

  • la photo du jour APOD
  • une image aléatoire qui correspond à une recherche que nous effectuons

Nous allons donc utiliser les endpoints suivants :

Dans cet exercice nous allons créer 2 DataSource différents pour ces 2 besoins pour plusieurs raisons :

  • leurs URLs sont différentes
  • on peut imaginer implémenter un certain nombre de méthodes pour chacune des ces APIs
  • afin d'éviter de nous retrouver avec une classe de plusieurs centaines de lignes

Rappelez-vous, certaines APIs de la NASA demandent une authentification via un paramètre dans l'URL. Pour faire cela pour toutes les URLs que nous allons appeler, nous pouvons surcharger la méthode willSendRequest de la classe RESTDataSource. Dans le cas où on aurait plusieurs classes avec le même comportement, pour éviter de dupliquer du code, je peux créer la classe suivante :

// src/dataSources/NASARESTDataSource.js const { RESTDataSource } = require('apollo-datasource-rest'); const API_KEY = 'DEMO_KEY'; class NASARESTDataSource extends RESTDataSource { willSendRequest(request) { request.params.append('api_key', API_KEY); } } module.exports = NASARESTDataSource;

Ainsi, voici ma classe data source pour récupérer les APODs :

// src/dataSources/apod.js import NASARESTDataSource from './NASARESTDataSource'; class APODRESTDataSource extends NASARESTDataSource { constructor() { super(); this.baseURL = 'https://api.nasa.gov/'; } getDailyImage() { return this.get('planetary/apod'); } } module.exports = APODRESTDataSource;

Sachez que les méthodes get, put, post, etc. sont toutes disponibles dans la classe RESTDataSource et retournent des Promises.

Je vous invite désormais à mettre en place le data source pour envoyer des requêtes à la bibliothèque d'images. Pour information, cette API ne demande pas d'authentification. Vous devriez avoir une classe comme ceci :

// src/dataSources/imageLibrary.js const { RESTDataSource } = require('apollo-datasource-rest'); class ImageLibraryRESTDataSource extends RESTDataSource { constructor() { super(); this.baseURL = 'https://images-api.nasa.gov/'; } search(searchString) { return this.get(`search?q=${searchString}`); } } module.exports = ImageLibraryRESTDataSource;

Types de données

La prochaine étape est de définir les types des données que nous allons avoir. Bien sûr, cela dépend des APIs que vous avez à disposition, et surtout de la façon dont vous allez afficher les données sur le front.

Ainsi, notre type pourrait ressembler à ceci :

// src/definitions/APOD.graphql type APOD { title: String! url: String! date: String explanation: String type: String }

Et pour l'image aléatoire :

// src/definitions/NASAImage.graphql type NASAImage { title: String! description: String! url: String! }

Notez que les champs suivis d'un ! après leur type sont des champs que l'on définit comme obligatoires (non nulls) dans notre schéma, nous devons donc nous assurer que les APIs retournent toujours ces champs pour éviter des exceptions.

Pour voir tous les types possibles, référez-vous à la documentation Apollo.

Maintenant, nous pouvons déclarer nos nouvelles requêtes disponibles. Avec GraphQL, cela se fait dans le fichier suivant :

// src/definitions/Query.graphql type Query { apod: APOD randomImage(search: String!): NASAImage }

Ici, nous déclarons toutes les requêtes qui seront disponibles dans l'application, avec les paramètres qu'elles recoivent et ce qu'elles retournent.

Aussi, dans ce tutoriel nous allons parler des Queries uniquement, car il a pour but de vous présenter comment améliorer les performances de votre application. Néanmoins, je vous invite à lire la documentation suivante pour voir comment fonctionnent les Mutations.

Si vous testez les APIs de la NASA, vous allez remarquer que nous avons nommé nos champs de la façon dont nous allons les utiliser sur le front, ce qui ne correspond pas forcément à ce que retourne l'API. C'est donc dans nos resolvers que nous allons mapper les champs.

Resolvers

Les resolvers font le lien entre les Query et les DataSource. Chaque type et chaque query doit avoir son resolver.

Commençons par les APODs. Dans le dossier src/resolvers je vais créer un fichier apod.js :

'use strict'; const resolvers = { Query: { apod: (parent, args, context, info) => { return context.dataSources.APODRESTDataSource.getDailyImage(); } }, }; module.exports = resolvers;

Vous pouvez voir dans cet exemple que depuis une Query nous avons accès au parent, aux arguments de la requête, au contexte (qui nous donne accès aux data sources, aux extensions éventuelles, etc.) et aux informations de notre appel (qui contient le type de retour attendu, le type de parent, le cacheControl, etc.).

Néanmoins, pour une meilleure lisibilité, le plus souvent nous allons écrire nos resolvers sous cette forme :

Query: { apod: (_, __, { dataSources: { APODRESTDataSource } }) => APODRESTDataSource.getDailyImage(), },

Nous pouvons enfin tester notre code sur l'URL http://localhost:3000/graphql :

query apod { apod { title } }

À ce stade vous devriez avoir un résultat :

graphql-response1

Et que se passe-t-il si je demande par exemple le champ type ? Il est null, car l'API ne retourne pas de champ avec ce nom, ce champ correspond à media_type dans le retour de l'API. Pour mapper notre entité, nous pouvons faire ceci :

'use strict'; const resolvers = { APOD: { type: ({ media_type }) => media_type, // équivalent de "type: data => data.media_type," }, Query: { apod: (_, __, { dataSources: { APODRESTDataSource } }) => APODRESTDataSource.getDailyImage(), }, }; module.exports = resolvers;

Et maintenant, essayez de mettre en place le resolver pour l'API de la bibliothèque d'images. Le résultat devrait être proche de celui-ci :

'use strict'; const resolvers = { NASAImage: { title: ({ data}) => data[0].title, description: ({ data}) => data[0].description, url: ({ links}) => links[0].href, }, Query: { randomImage: (_, { search }, { dataSources: { ImageLibraryRESTDataSource } }) => ImageLibraryRESTDataSource.search(search).then(result => { if (result.collection.metadata.total_hits === 0) { return null; } return result.collection.items[Math.floor(Math.random()*result.collection.metadata.total_hits)]; }), }, }; module.exports = resolvers;

graphql-response2

Exercice : pour pratiquer davantage ce que nous venons de mettre en place, je vous invite à écrire le type & resolver pour cette API - la liste des astéroïdes près de la terre.

Apollo client

Mise en place d'Apollo client

Initialisation du projet front

Pour commencer, clonez la branche "step-2" de ce projet, mettez-vous sur le commit suivant. Nous avons besoin d'éxécuter la commande suivante pour prendre en compte les modifications du fichier docker-compose.yml :

docker-compose up -d

L'application front en React est disponible sur http://localhost:3001/.

Initialisation d'Apollo client

Afin de démarrer, nous allons ajouter de nouvelles dépendances dans l'application :

docker-compose exec front-app yarn add apollo-client apollo-cache-inmemory apollo-link apollo-link-http apollo-link-error react-apollo graphql graphql-tag --save

apollo-client, react-apollo, graphql et graphql-tag sont le minimum dont nous allons avoir besoin. Nous avons également ajouté d'autres librairies qui vont nous permettre de mettre en place la gestion des erreurs et la connexion à notre serveur Apollo.

Je vous invite à lire en détail la documentation d'Apollo sur la configuration de plusieurs links et la page sur le fonctionnement de link.

Ainsi, je vais commencer la configuration de mon client. Dans le dossier src/ je vais créer un nouveau dossier graphql/helpers qui contiendra la configuration.

Voici le code pour créer un httpLink :

// src/graphql/helpers/httpLink.js import { createHttpLink } from 'apollo-link-http'; const httpLink = createHttpLink({ uri: 'http://localhost:3000/graphql', }); export default httpLink;

Ensuite, je vais créer le fichier errorLink :

// src/graphql/helpers/errorLink.js import { onError } from 'apollo-link-error'; const errorLink = onError(({ networkError, graphQLErrors }) => { if (graphQLErrors) { graphQLErrors.forEach(({ message, locations, path }) => { console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`); }); } if (networkError) { console.log(`[Network error]: ${networkError}`); } }); export default errorLink;

Vous remarquerez que nous avons également installé apollo-cache-inmemory. En effet, ApolloClient demande d'avoir un système de cache obligatoirement lors de l'initialisation. InMemoryCache est la solution recommandée par Apollo.

Maintenant, j'ai besoin d'initialiser un client Apollo avec les links et le cache ci-dessus. Je vais faire ceci dans un fichier à part pour bien séparer mes différents besoins :

// src/graphql/helpers/client.js import { ApolloLink } from 'apollo-link'; import { ApolloClient } from 'apollo-client'; import { InMemoryCache } from 'apollo-cache-inmemory'; import errorLink from './errorLink'; import httpLink from './httpLink'; const createGraphQLClient = () => { return new ApolloClient({ link: ApolloLink.from([errorLink, httpLink]), cache: new InMemoryCache(), }); }; export default createGraphQLClient;

Enfin, passons le client Apollo à l'application :

// index.js import React from 'react'; import ReactDOM from 'react-dom'; import { ApolloProvider } from 'react-apollo'; import './index.css'; import App from './App'; import createClientGraphQL from './graphql/helpers/client'; ReactDOM.render(( <ApolloProvider client={createClientGraphQL()}> <App /> </ApolloProvider> ), document.getElementById('root'));

Récupération des données depuis le serveur

Nous sommes enfin prêts pour faire notre première query ! Je vais placer toutes les requêtes dans le dossier graphql/queries.

// graphql/queries/apod.js import gql from 'graphql-tag'; export const APOD = gql` query APOD { apod { title url date explanation type } } `;

Nous écrivons la même requête que dans le playground du serveur.

Apollo client fournit un composant Query :

import React from 'react'; import { Query } from 'react-apollo'; import { APOD } from './graphql/queries/apod'; const App = () => { return ( <div> <Query query={APOD}> {({ loading, error, data }) => { if (loading) return "Loading..."; if (error) return `Error! ${error.message}`; return ( <div> <p>{data.apod.title}</p> <p>{data.apod.explanation}</p> <p>{data.apod.date}</p> <p><img src={data.apod.url} alt={data.apod.title}/></p> </div> ); }} </Query> </div> ); }; export default App;

Le composant Query est un observer, il se met donc à jour lorsqu'il obtient une réponse du serveur.

Maintenant que nous avons l'image du jour, nous souhaitons ajouter une image aléatoire qui vient de la bibliothèque d'images de la NASA. Je vais donc ajouter la query randonImage :

// graphql/queries/randomImage.js import gql from 'graphql-tag'; export const RANDOM_NASA_IMAGE = gql` query RANDOM_NASA_IMAGE($search: String!) { randomImage(search: $search) { title url description } } `;

Et voici notre composant App :

import React from 'react'; import { Query } from 'react-apollo'; import { APOD } from './graphql/queries/apod'; import { RANDOM_NASA_IMAGE } from './graphql/queries/randomImage'; const App = () => { return ( <div> <Query query={APOD}> {({ loading, error, data }) => { if (loading) return "Loading..."; if (error) return `Error! ${error.message}`; return ( <div className={'apod'}> <p>{data.apod.title}</p> <p>{data.apod.explanation}</p> <p>{data.apod.date}</p> <p><img src={data.apod.url} alt={data.apod.title}/></p> </div> ); }} </Query> <Query query={RANDOM_NASA_IMAGE} variables={{ search: 'raccoon' }}> {({ loading, error, data }) => { if (loading) return "Loading..."; if (error) return `Error! ${error.message}`; return ( <div className={'random'}> <p>{data.randomImage.title}</p> <p>{data.randomImage.description}</p> <p><img src={data.randomImage.url} alt={data.randomImage.title}/></p> </div> ); }} </Query> </div> ); }; export default App;

Pour information, avec GraphQL, nous pouvons faire plusieurs requêtes en un appel réseau au serveur, et c'est le serveur qui se chargera d'aggréger les données pour nous renvoyer une réponse.

Et voilà ! Bravo à vous :) Nous avons un client / serveur Apollo avec les résultats attendus. Dans le chapitre suivant nous allons voir comment améliorer les performances de notre application.

Options de cache

Mise en place du cache

Nos back & front étant prêts, nous allons enfin passer à la mise en place du cache. Le but de l'exercice est d'arriver à mettre en place le schéma suivant :

cache-schema

Nous allons couvrir ces points un par un.

In-memory

Il est temps de voir comment fonctionne le cache InMemory d'Apollo. En effet, par défaut il va cacher les données avec leur champ id ou _id et __typename (qui correspond à leur type défini dans le schéma du serveur).

Ainsi, si je re-demande une même query, il n'y aura pas de deuxième appel au serveur, mais j'obtiendrai le résultat du premier appel.

Pour illustrer ce cas, je vais apporter des modifications à notre application. Pour gagner du temps, vous pouvez cloner la branche "step-3" de notre projet, ce commit en particulier. Pour information, j'ai ajouté un Router et 2 pages - Home page et Random page. La home page a le même comportement que précédemment, et la Random page affiche uniquement le résultat de la query randomImage.

Je vous invite à tester l'application. Vous allez constater que lorsqu'on change de page pour aller sur random page ou revenir sur la Home, les résultats ne changent pas.

Maintenant, imaginons que nous avons un site e-commerce et que nous sommes dans le tunnel d'achat. Évidemment, dans un cas pareil nous souhaitons toujours récupérer les données à jour depuis nos APIs, et non les résultats cachés côté client. Pour faire cela, nous pouvons configurer une Query pour avoir toujours la response depuis le réseau (plutôt que le cache client) via la props fetchPolicy :

// front-app/src/pages/Random.js <Query query={RANDOM_NASA_IMAGE} variables={{ search: 'raccoon' }} fetchPolicy={"network-only"}>

Notez que ceci va également mettre à jour le cache. Ainsi, si je reviens sur la Home, je verrai la photo récupérée sur la page Random. Si je souhaite avoir toujours une photo aléatoire, je dois changer tous les endoits où j'appelle les requêtes concernées.

Redis

Nous avons donc mis en place du cache côté client pour limiter le nombre d'appels inutiles au serveur. Nous pouvons maintenant nous concentrer sur le serveur.

Jusque là, nous n'avons fait aucune gestion de cache côté serveur. Pourtant, la plupart du temps les réponses des APIs (surtout publiques comme la nôtre) peuvent être cachées pour une durée définie dans les headers. Et la bonne nouvelle est que les datasources Apollo sont compatibles avec Redis et Memcached.

Pour cet exemple, nous allons utiliser Redis pour mettre les réponses en cache. J'ai donc modifié le fichier docker-compose.yml pour ajouter un container redis :

redis: image: bitnami/redis ports: - 6379:6379 environment: ALLOW_EMPTY_PASSWORD: 'yes'

Et maintenant je vais ajouter une nouvelle dépendance à mon Apollo serveur :

docker-compose exec gateway yarn add apollo-server-cache-redis --save

Ensuite, je vais dire à mon serveur de stocker les réponses des data sources dans le cache Redis :

const { RedisCache } = require('apollo-server-cache-redis'); const redisCache = new RedisCache({ host: 'redis', password: 'password', }); const server = new ApolloServer({ schema: makeExecutableSchema({ typeDefs: GraphQLHelper.typeDefs, resolvers: GraphQLHelper.resolvers, }), dataSources: () => GraphQLHelper.dataSources, cache: redisCache, });

Et c'est tout. Désormais les réponses de nos APIs sont bien cachées. Si vous avez Redis Desktop Manager par exemple, vous pouvez facilement vérifier le bon fonctionnement de cette étape.

graphql-redis-cache

Automatic Persisted Queries

Un autre moyen d'améliorer les performances est d'utiliser les persisted queries. Cela permet de faire des appels en GET au serveur au lieu de POST, cela réduit la taille de la requête envoyée et bypass l'étape de validation du schéma.

Voici un schéma qui explique le fonctionnement :

graphql-persisted-queries

Pour activer les persisted queries côté serveur :

const server = new ApolloServer({ schema: makeExecutableSchema({ typeDefs: GraphQLHelper.typeDefs, resolvers: GraphQLHelper.resolvers, }), dataSources: () => GraphQLHelper.dataSources, cache: redisCache, persistedQueries: { cache: redisCache, }, });

Et côté client je vais créer un nouveau link :

docker-compose exec front-app yarn add apollo-link-persisted-queries --save
// src/graphql/helpers/persistedQueryLink.js import { createPersistedQueryLink } from 'apollo-link-persisted-queries'; const persistedQueryLink = createPersistedQueryLink({ useGETForHashedQueries: true, }); export default persistedQueryLink;
// src/graphql/helpers/client.js import persistedQueryLink from './persistedQueryLink'; const createGraphQLClient = () => { return new ApolloClient({ link: ApolloLink.from([persistedQueryLink, errorLink, httpLink]), cache: new InMemoryCache(), }); };

Désormais dans notre navigateur les appels se font en GET quand cela est possible :

graphql-persisted-queries-result

Maintenant que nous avons des appels en GET, nous pouvons même mettre en place un Varnish pour encore plus améliorer les performances.

Suivre les performances

Pour aller encore plus loin, vous pouvez analyser chacun de vos appels réseau entre le Gateway et les APIs en passant pas les extensions. Je vous invite à lire cet article sur notre blog pour en savoir plus.

Mot de la fin

Merci à tous ceux qui ont suivi ce tutoriel jusqu'à la fin. J'espère qu'il vous a été utile.