Dans cet article nous allons découvrir les bases de GraphQL afin d’en comprendre l’utilité et de savoir si sa mise en place dans notre SI lui en sera bénéfique. Nous n’aborderons pas ici la mise en place pas à pas ou la consommation d’une API GraphQL. Nous réservons cela pour de prochains articles où nous verrons comment consommer une API GraphQL en C# et sa mise en place avec notamment Entity Framework Core.
Introduction
GraphQL est une spécification qui a été initialement développée par Facebook en 2012. Lors de la refonte de leur application mobile, les ingénieurs de Facebook ont été frustrés par la différence entre les données requises par leurs applications et les différents appels APIs nécessaires. Les appels n’étaient pas optimisés aux besoins d’affichages, ce qui engendrait de gros problèmes d’expérience utilisateur. De multiples appels successifs à des APIs afin d’obtenir une seule information, donc une application lente.
Ils ont donc décidé de développer la spécification GraphQL. Elle décrit un langage de requêtage accompagné d’un runtime serveur. Ce langage de requêtage est complètement agnostique d’un quelconque moteur de base de données, c’est le runtime serveur qui se charge de fournir la donnée (en utilisant un ou plusieurs moteurs de base de données différents ou même d’autres APIs en arrière plan).
La composante principale est la définition d’un schéma représentant les données exposées sous la forme d’une arborescence hiérarchique. La donnée exposée au client est totalement décorrélée de la donnée stockée. Nous ne sommes pas obligés de reproduire un pour un notre schéma de base de données dans notre schéma GraphQL. Un point clé d’une API GraphQL est que la donnée est exposée via un point d’entrée unique (accessible via une requête POST), et c’est au client, dans sa requête, de définir les données qu’il souhaite recevoir en retour.
Ci-dessous une requête typique en GraphQL suivi de sa réponse. On voit très clairement que le champ « data » de notre réponse colle parfaitement au format de notre requête. En GraphQL on peut récupérer une grappe d’objets en un seul appel. Comme dans notre exemple où nous récupérons un tweet spécifique avec son auteur.
{ tweet(id: 101) { body, date, author { username, full_name } } }
{ "data": { "tweet": { "body": "Je découvre GraphQL" "date": "2023-01-25T13:45:30" "author": { "username": "etiennep" "full_name": "Etienne Pommier" } } }
GraphQL vs. REST
Comparée à une API REST, une API GraphQL va posséder des avantages mais aussi certaines limitations. Nous allons parcourir, ci-dessous, une liste des différences que nous trouvons les plus notables entre ces deux types d’APIs.
- Gestion du cache : En REST, le cache fait partie de la spécification HTTP. Il est intégré nativement et trivial à implémenter. Contrairement à GraphQL, c’est une des conséquences de n’avoir qu’un point d’entrée. Cependant il existe des librairies pour l’implémenter, telles que FlacheQL ou encore Relay.
- Chargement de fichier : GraphQL ne supporte pas le chargement de fichier contrairement à REST.
- Gestion d’erreur : Sur une API GraphQL le code retour d’un appel en erreur est toujours 200 OK. Il faut analyser le JSON de retour pour avoir le détail d’une/des erreur(s). Ce qui déporte un peu de complexité côté client pour interpréter ces erreurs et faire un affichage correspondant. Par exemple sur des erreurs d’authentification ou d’autorisation, gérées bien plus facilement avec des APIs REST et les codes erreurs HTTP correspondant (401, 403, etc…).
- Récupération de données : En REST la donnée doit être récupérée en accédant à de multiples endpoints serveur. Une route par ressource est nécessaire quand nous récupérons de la donnée via une API REST. Ce qui peut engendrer plusieurs appels successifs entre le serveur et le client pour récupérer toutes les données à afficher sur un écran. Ce n’est pas le cas en GraphQL, un appel suffit pour récupérer une grappe complexe d’objets.
- Versioning : Avec GraphQL la notion de version d’APIs n’existe pas. C’est un choix délibéré de la part de l’équipe qui a écrit la spécification. Il faut étendre le schéma plutôt que de le modifier (on peut cependant marquer des champs comme dépréciés sur le schéma).
- Auto-documentation : Un avantage d’une API GraphQL vient du fait que la documentation est toujours à jour. Le schéma étant exposé de manière standardisé, de multiples outils permettent de générer une documentation interactive telle que GraphiQL. Il en existe bien d’autres et la grande majorité des librairies implémentant GraphQL intègrent un outil de ce type.
- Orienté client : Sur des APIs REST, c’est le serveur qui définit le format de retour. Ce qui peut parfois engendrer de multiples allers-retours entre les équipes back et front afin d’avoir un format de réponse qui correspond au besoin d’affichage. Si différents clients utilisent notre API le besoin peut être très différent et engendrer des conflits ou des réponses avec des données inutiles pour certains clients. GraphQL fait fi de tout cela car c’est le client qui décide de la donnée à récupérer.
Exploration d’un schéma GraphQL
Un serveur GraphQL utilise un schéma pour décrire les données disponibles sur l’API. Il définit une hiérarchie d’objets et de champs ainsi qu’une liste d’opérations disponibles. Ci-dessous un exemple de schéma GraphQL représentant une API pour récupérer des tweets et leurs informations liées, telles que le créateur ou les données statistiques d’un tweet.
type Tweet { id: ID! # The tweet text. No more than 140 characters! body: String # When the tweet was published date: Date # Who published the tweet Author: User # Views, retweets, likes, etc Stats: Stat } type User { id: ID! username: String first_name: String last_name: String full_name: String name: String @deprecated(reason: "Use `username` instead.") avatar_url: Url } type Stat { views: Int likes: Int retweets: Int responses: Int } type Notification { id: ID date: Date type: NotificationType } type Meta { count: Int } enum NotificationType { UserFollow, Like, Retweet } scalar Url scalar Date type Query { tweet(id: ID!): Tweet tweets(limit: Int, skip: Int, sort_field: String, sort_order: String): [Tweet] tweetsMeta: Meta user(id: ID!): User notifications(limit: Int): [Notification] notificationsMeta: Meta } type Mutation { createTweet ( body: String ): Tweet deleteTweet(id: ID!): Tweet markTweetRead(id: ID!): Boolean }
Le schéma est écrit en utilisant le Schema Definition Language (SDL). Ce langage définit un certain nombre de types et la syntaxe qui permet de créer une hiérarchie d’objets. Ce langage est très similaire au JSON ou encore à du YAML. Ce langage prend en charge un certain nombre de types pour décrire notre arborescence de données.
Typiquement, une API GraphQL va exposer une route qui retourne le schéma au complet. Ce qui peut ensuite être exploité par des outils de développement comme GraphiQL, qui est un explorateur de graph interactif. Cet outil permet de requêter simplement notre API avec de l’auto-complétion, facilitant ainsi l’intégration des APIs GraphQL côté client. Il existe bien entendu de nombreux autres outils similaires mais celui-ci est maintenu par la fondation GraphQL, en charge de la spécification.
Les types
- Le type Objet : la grande majorité des types définis dans un schéma GraphQL sont des objets (Tweet, User, etc…). Un objet définit une liste de champs. À noter que les opérations définies dans le schéma sont aussi des types, nous les aborderons en détail un peu plus loin dans cet article.
- Les types Scalaire
- Int : un entier.
- Float : une valeur à virgule flottante.
- String : une chaîne de charactère (en UTF-8).
- Boolean : true ou false.
- ID : Un identifiant unique (sérialisé en String dans le retour de l’API). Cet identifiant peut être utilisé comme clé unique dans un mécanisme de cache par exemple.
- Le type Enum : il correspond à une liste prédéfinie de valeurs qui seront sérialisées en String dans la réponse de l’API. Dans notre schéma nous avons défini un seul type énumération : NotificationType
- Le type List : n’importe lequel des types précédents peut être contenu dans une liste, il suffit de l’englober entre crochets. Par exemple sur notre type Query, le champ tweets est défini comme étant une liste de Tweet.
Vous aurez sans doute remarqué deux types particuliers dans notre graph, Url et Date, déclarés comme étant des scalar. Il est possible de définir des types scalaires personnalisés. Ces types sont associés côté serveur à une logique qui leur est propre et que nous devrons implémenter. Cette logique définit comment ils doivent être sérialisés et désérialisés en un type compatible JSON, et comment notre valeur est représentée dans nos services back. Un des cas d’usage typique des types scalaires personnalisés est la gestion d’un format Date, car ce n’est pas un type géré par la spécification GraphQL.
Il existe aussi deux autres types un peu particulier les Unions et les Interfaces, qui ne sont pas présents dans notre schéma. Ils permettent respectivement de définir des regroupements de types et des champs en commun sur des types.
Les opérations
Comme vu précédemment les opérations sont aussi des types objets. Ils sont donc déclarés avec la même syntaxe. C’est le point d’entrée pour les clients. Les opérations permettent d’interagir avec les données décrites par notre hiérarchie d’objets.
- Query : ce type définit les opération en lecture disponible sur l’API.
- Mutation : ce type définit les opérations en écriture disponible sur l’API.
- Subscription : ce type représente des opérations qui envoient des données au fil du temps, comme pourrait le faire du SignalR par exemple. Ces opérations utilisent des WebSockets plutôt que le classique HTTP pour les précédentes.
Dans notre schéma actuel nous avons défini six opérations de lecture, sous le type query, et trois opérations de modification, sous le type mutation. Les opérations sont définies comme des champs objets classiques et peuvent donc retourner n’importe lequel des types que nous avons listé précédemment. Vous noterez tout de même que certaines opérations prennent en entrée des paramètres (définis entre parenthèses). Ces paramètres pourront ensuite être exploités dans notre backend. En GraphQL, un resolver est associé à chaque opération, c’est le bout de code qui sera chargé de résoudre la requête.
input Filter {
count: Int!
searchInput: String
}
Un resolver est tout simplement une fonction chargée de retourner la donnée pour un unique champ dans notre schéma. La définition et la gestion de ces resolvers peut varier en fonction de l’implémentation GraphQL choisie. L’exécution des resolvers peut s’enchainer en cascade. Si notre champ dans le type query est notre point d’entrée initial il peut être nécessaire d’appeler d’autres resolvers pour résoudre les champs du niveau inférieur.
Notre schéma défini par exemple la query tweets(limit: Int, skip: Int, sort_field: String, sort_order: String): [Tweet]
qui retourne une liste de tweets et sur chaque tweet est défini un champ de type Stat. Dans notre serveur GraphQL si nous avons défini un resolver pour le type Stat, il sera appelé. L’exécution de resolver s’enchaine donc en fonction des données demandées par le client. Dans notre cas par contre on va rencontrer un souci d’optimisation. En effet le resolver sera appelé n fois, n étant le nombre de tweets retournés par notre resolver racine. C’est un problème connu sous le nom de « N+1 Problem« .
Cela n’est pas rédhibitoire en terme de performances dans la mesure ou les ingénieurs de Facebook ont introduit le concept de Data Loaders. Quand on utilise les Data Loaders, une fois que les données à un niveau n sont récupérées, une seule requête est faite pour récupérer les données imbriquées, au niveau n + 1. Il faut cependant prendre en compte que la définition de Data Loaders peut varier en fonction de la librairie utilisée, mais le concept est le même pour toutes. Une version officielle de l’implémentation des Data Loaders à été développé en JavaScript.
Les directives
Il nous reste une dernière notion sur notre schéma que nous n’avons pas encore abordé, les directives. Ce sont des décorateurs qui permettent de configurer des types, champs ou arguments. Toutes les directives sont précédées par le charactère « @« , comme la seule directive présente dans notre schéma : deprecated (sur le champ name dans notre type User). Les directives peuvent prendre en entrée des arguments, comme la reason dans notre cas. Les directives ne peuvent s’appliquer qu’à certains endroits du schéma, spécifiés lors de la création de la directive. Il existe dans la spécification GraphQL trois directives par défaut :
- @deprecated(reason: String) : Marque un champ ou une énumération dans notre schéma comme déprécié. Le paramètre reason est optionnel.
- @skip(if: Boolean!) : Si la condition en entrée (paramètre if) est vraie, le champ décoré sur notre opération ne sera pas résolu par le serveur GraphQL. Le resolver associé à ce champ ne sera donc pas appelé.
- @include(if: Boolean!) : Si la condition est vraie alors le champ décoré sera retourné, donc le resolver associé appelé.
Il est bien entendu possible de créer des directives personnalisées pour transformer le comportement de notre schéma. On pourrait par exemple définir des directives sur des champs String prenant en entrée une culture afin de retourner la valeur décorée traduite. Cependant la définition des directives personnalisées peut varier en fonction de la librairie. Si vous avez des besoins particuliers sur les directives il sera important de vérifier comment est implémentée cette partie de la spécification dans la librairie choisie.
Cas d’usages
Au vu de notre découverte de GraphQL on peut se poser la question de quels sont les cas d’usages préférentiels pour mettre en place une API GraphQL. Tout d’abord GraphQL n’a absolument pas la prétention et surtout la volonté de remplacer les APIs REST. C’est tout simplement une solution qui a été développée pour palier aux limites de REST dans certains contextes. L’un des principaux étant la connexion de multiples clients différents (mobile, web, application bureau, etc…), donc de besoins différents, sur une seule et même API.
- Back end for frontend (BFF) : le BFF est un pattern qui a été mis en place afin de répondre aux besoins spécifiques de chaque client (c’est un variant du pattern API Gateway). Entre une app mobile et un site web le besoin d’affichage varie grandement. Typiquement on peut afficher plus de données sur une page web que sur un écran mobile donc les données à charger vont forcément différer. Ce qui nous mène à des problèmes d’optimisation si nos deux clients utilisent la même API. Le pattern BFF introduit donc une couche entre les clients spécifiques et l’API pour répondre spécifiquement au besoin de chaque type de client. C’est là que GraphQL peut jouer un rôle car en GraphQL c’est le client qui définit les données à charger, du coup plus besoin de développer des proxys différents pour chaque client.
- Agrégation de services : dans un contexte de micro-services une API en GraphQL peut très bien servir de point d’entrée unique pour nos micro-services. GraphQL peut servir d’agrégateur d’API, en effet un resolver n’est pas obligé de requêter une base de données mais peut très bien faire des requêtes HTTP pour retourner la donnée. On peut aussi imaginer un cas d’usage où GraphQL nous permettrait d’exposer assez rapidement des APIs legacy sous un point d’entrée unique et de migrer nos clients sur ces nouvelles routes. Puis par la suite de décommissionner progressivement ces anciennes APIs et/ou les migrer sur des technologies plus récentes ou encore les intégrer directement sur notre API GraphQL.
- Simplification des échanges client/serveur : un cas classique d’utilisation d’une API GraphQL intervient dans un contexte d’optimisation. En effet sur une API REST à chaque route est associé une ressource, au début d’un projet ces ressources sont en général assez peu nombreuses. Mais au fur et à mesure de l’évolution du besoin et de l’ajout de fonctionnalités ces ressources peuvent augmenter de manière exponentielle. Ce qui peut nous mener à devoir faire de multiples aller-retours client/serveur pour récupérer la donnée désirée. GraphQL résout ce problème en exposant les ressources sous la forme d’un graph que l’on peut parcourir en une seule requête.
Conclusion
GraphQL est un outil très puissant et extrêmement flexible pour les consommateurs d’APIs. Il convient de bien analyser ses besoins car GraphQL ne colle pas à tous les contextes. Il est cependant parfaitement adapté dans le cas où plusieurs clients différents (mobiles/web) utilisent notre API. Il faut par contre prendre en compte la courbe d’apprentissage tant au niveau du client que côté serveur. Un des points forts de GraphQL est son support, et ce quel que soit le langage utilisé. Il existe de multiples librairies pour chaque langage, par exemple Appolo pour TypeScript ou encore Hot Chocolate pour C#. Une liste exhaustive de toutes les implémentations de GraphQL en fonction des langages est disponible ici.
Il ne reste plus qu’à choisir votre librairie et vous lancer dans la création de votre première API GraphQL ! Nous verrons dans de prochains articles comment consommer et mettre en place une API GraphQL en C#. Les librairies utilisées seront Hot Chocolate, pour la partie serveur, et Strawberry Chake, pour la partie client.
0 commentaires