Comment Coinbase utilise Relay et GraphQL pour activer l’hypercroissance
De Chris Erickson et Terence Bezman

Il y a un peu plus d’un an, Coinbase a achevé la migration de notre application mobile principale vers React Native. Au cours de la migration, nous avons réalisé que notre approche existante des données (points de terminaison REST et bibliothèque de récupération de données REST maison) n’allait pas suivre l’hypercroissance que nous connaissions en tant qu’entreprise.
« Hypercroissance » est un mot à la mode surutilisé, alors clarifions ce que nous entendons dans ce contexte. Au cours des 12 mois qui ont suivi la migration vers l’application React Native, le trafic de notre API a été multiplié par 10 et nous avons multiplié par 5 le nombre d’actifs pris en charge. Dans le même laps de temps, le nombre de contributeurs mensuels sur nos applications principales a triplé pour atteindre environ 300. Ces ajouts ont entraîné une augmentation correspondante des nouvelles fonctionnalités et expériences, et nous ne voyons pas cette croissance ralentir de sitôt (nous envisageons d’embaucher 2 000 personnes supplémentaires dans les domaines des produits, de l’ingénierie et de la conception cette année seulement).
Pour gérer cette croissance, nous avons décidé de migrer nos applications vers GraphQL et Relay. Ce changement nous a permis de résoudre de manière globale certains des plus grands défis auxquels nous étions confrontés liés à l’évolution des API, à la pagination imbriquée et à l’architecture des applications.
GraphQL a été initialement proposé comme une approche pour aider à l’évolution de l’API et à l’agrégation des demandes.
Auparavant, afin de limiter les demandes simultanées, nous créions divers points de terminaison pour agréger les données d’une vue particulière (par exemple, le tableau de bord). Cependant, à mesure que les fonctionnalités changeaient, ces points de terminaison continuaient de croître et les champs qui n’étaient plus utilisés ne pouvaient pas être supprimés en toute sécurité, car il était impossible de savoir si un ancien client les utilisait encore.
Dans son état final, nous étions limités par un système inefficace, comme l’illustrent quelques anecdotes :
- Un point de terminaison de tableau de bord Web existant a été réutilisé pour un nouvel écran d’accueil. Ce point de terminaison était responsable de 14 % de notre charge backend totale. Malheureusement, le nouveau tableau de bord n’utilisait ce point de terminaison que pour un seul champ booléen.
- Notre point de terminaison utilisateur était devenu si gonflé qu’il s’agissait d’une réponse de près de 8 Mo – mais aucun client n’avait réellement besoin de toutes ces données.
- L’application mobile devait effectuer 25 appels d’API parallèles au démarrage, mais à l’époque, React Native nous limitait à 4 appels parallèles, provoquant une chute d’eau irréversible.
Chacun de ces problèmes pourrait être résolu isolément à l’aide de diverses techniques (meilleur processus, gestion des versions d’API, etc.), qui sont difficiles à mettre en œuvre alors que l’entreprise se développe à un rythme aussi rapide.
Heureusement, c’est exactement pour cela que GraphQL a été créé. Avec GraphQL, le client peut faire une seule requête, récupérer seul les données dont il a besoin pour la vue qu’il affiche. (En fait, avec Relay nous pouvons exiger elles ou ils seul demander les données dont ils ont besoin – nous y reviendrons plus tard.) Cela conduit à des demandes plus rapides, à un trafic réseau réduit, à une charge moindre sur nos services backend et à une application globalement plus rapide.
Lorsque Coinbase a pris en charge 5 actifs, l’application a pu faire quelques requêtes : une pour obtenir les actifs (5), et une autre pour obtenir les adresses de portefeuille (jusqu’à 10) pour ces actifs, et les assembler sur le client. Cependant, ce modèle ne fonctionne pas bien lorsqu’un jeu de données devient suffisamment volumineux pour nécessiter une pagination. Soit vous avez une taille de page inacceptable (ce qui réduit les performances de votre API), soit vous vous retrouvez avec des API encombrantes et des requêtes en cascade.
Si vous n’êtes pas familier, une chute d’eau dans ce contexte se produit lorsque le client doit d’abord demander une page d’actifs (donnez-moi les 10 premiers actifs pris en charge), puis doit demander les portefeuilles pour ces actifs (donnez-moi des portefeuilles pour ‘BTC’, ‘ETH’, ‘LTC’, ‘DOGE’, ‘SOL’, …). Étant donné que la deuxième requête dépend de la première, elle crée une cascade de requêtes. Lorsque ces requêtes dépendantes sont effectuées à partir du client, leur latence combinée peut entraîner des performances catastrophiques.
C’est un autre problème que GraphQL résout : il permet d’imbriquer les données associées dans la requête, en déplaçant cette cascade vers le serveur principal qui peut combiner ces requêtes avec une latence beaucoup plus faible.
Nous avons choisi Relay comme bibliothèque client GraphQL, ce qui a apporté un certain nombre d’avantages inattendus. La migration a été difficile dans la mesure où l’évolution de notre code pour suivre les pratiques de relais idiomatiques a pris plus de temps que prévu. Cependant, les avantages de Relay (colocation, découplage, élimination des cascades de clients, performances et malléabilité) ont eu un impact beaucoup plus positif que nous ne l’avions jamais prévu.
En termes simples, Relay est unique parmi les bibliothèques clientes GraphQL dans la mesure où il permet à une application de s’adapter à davantage de contributeurs tout en restant malléable et performante.
Ces avantages découlent du modèle d’utilisation de fragments de Relay pour colocaliser les dépendances de données dans les composants qui restituent les données. Si un composant a besoin de données, il doit être transmis via un type spécial de prop. Ces props sont opaques (le composant parent sait seulement qu’il doit passer un {ChildComponentName}Fragment sans savoir ce qu’il contient), ce qui limite le couplage inter-composants. Les fragments garantissent également qu’un composant ne lit que les champs qu’il a explicitement demandés, ce qui réduit le couplage avec les données sous-jacentes. Cela augmente la malléabilité, la sécurité et les performances. Le compilateur relais est à son tour capable d’agréger des fragments en une seule requête, ce qui évite à la fois les chutes d’eau client et la demande répétée des mêmes données.
Tout cela est assez abstrait, alors considérez un simple composant React qui récupère les données d’une API REST et affiche une liste (ceci est similaire à ce que vous construiriez en utilisant React Query, SWR ou même Apollo):
Quelques remarques :
- Le composant AssetList va provoquer une requête réseau, mais cela est opaque pour le composant qui le rend. Cela rend presque impossible le préchargement de ces données à l’aide d’une analyse statique.
- De même, AssetPriceAndBalance provoque un autre appel réseau, mais provoquera également une chute d’eau, car la requête ne sera lancée que lorsque les composants parents auront fini de récupérer ses données et de rendre les éléments de la liste. (L’équipe React en parle lorsqu’elle parle de « récupération sur rendu »)
- AssetList et AssetListItem sont étroitement couplés – l’AssetList doit fournir un objet d’actif contenant tous les champs requis par la sous-arborescence. De plus, AssetHeader nécessite la transmission d’un actif entier, tout en n’utilisant qu’un seul champ.
- Chaque fois que les données d’un seul élément changent, la liste entière sera restituée.
Bien qu’il s’agisse d’un exemple trivial, on peut imaginer comment quelques dizaines de composants comme celui-ci sur un écran pourraient interagir pour créer un grand nombre de cascades de récupération de données de chargement de composants. Certaines approches tentent de résoudre ce problème en déplaçant tous les appels de récupération de données vers le haut de l’arborescence des composants (par exemple, en les associant à la route). Cependant, ce processus est manuel et sujet aux erreurs, les dépendances de données étant dupliquées et susceptibles de se désynchroniser. Cela ne résout pas non plus les problèmes de couplage et de performances.
Relay résout ces types de problèmes par conception.
Regardons la même chose écrite avec Relay :
Comment s’en sortent nos observations antérieures ?
- AssetList n’a plus de dépendances de données cachées : il expose clairement le fait qu’il nécessite des données via ses accessoires.
- Étant donné que le composant est transparent quant à ses besoins en données, toutes les données requises pour une page peuvent être regroupées et demandées avant même que le rendu ne commence. Cela élimine les cascades de clients sans que les ingénieurs aient à y penser.
- Tout en exigeant que les données soient transmises à travers l’arborescence en tant qu’accessoires, Relay permet de le faire d’une manière qui ne ne pas créer un couplage supplémentaire (car les champs sont seul accessible par le composant enfant). L’AssetList sait qu’il doit passer l’AssetListItem à un AssetListItemFragmentRef, sans le savoir quelle cela contient. (Comparez cela au chargement de données basé sur l’itinéraire, où les exigences en matière de données sont dupliquées sur les composants et l’itinéraire, et doivent être synchronisées.)
- Cela rend notre code plus malléable et facile à faire évoluer – un élément de la liste peut être modifié isolément sans toucher à aucune autre partie de l’application. S’il a besoin de nouveaux champs, il les ajoute à son fragment. Lorsqu’il n’a plus besoin d’un champ, il le supprime sans avoir à craindre qu’il casse une autre partie de l’application. Tout cela est appliqué via la vérification de type et les règles de charpie. Cela résout également le problème d’évolution de l’API mentionné au début de cet article : les clients arrêtent de demander des données lorsqu’elles ne sont plus utilisées, et éventuellement les champs peuvent être supprimés du schéma.
- Parce que les dépendances de données sont déclarées localement, React et Relay sont capables d’optimiser le rendu : si le prix d’un actif change, SEULS les composants qui affichent réellement ce prix devront être restitués.
Bien que sur une application triviale, ces avantages ne soient pas énormes, il est difficile d’exagérer leur impact sur une grande base de code avec des centaines de contributeurs hebdomadaires. C’est peut-être mieux capturé par cette phrase de la récente conférence ReactConf Relay : Relay vous permet de « penser localement et d’optimiser globalement ».
La migration de nos applications vers GraphQL et Relay n’est que le début. Nous avons encore beaucoup de travail à faire pour continuer à étoffer GraphQL chez Coinbase. Voici quelques éléments sur la feuille de route :
L’API GraphQL de Coinbase dépend de nombreux services en amont, dont certains sont plus lents que d’autres. Par défaut, GraphQL n’enverra pas sa réponse tant que toutes les données ne seront pas prêtes, ce qui signifie qu’une requête sera aussi lente que le service en amont le plus lent. Cela peut nuire aux performances de l’application : un élément d’interface utilisateur de faible priorité qui a un backend lent peut dégrader les performances d’une page entière.
Pour résoudre ce problème, la communauté GraphQL a normalisé une nouvelle directive appelée @defer. Cela permet à des sections d’une requête d’être marquées comme « faible priorité ». Le serveur GraphQL enverra le premier bloc dès que toutes les données requises seront prêtes et diffusera les parties différées dès qu’elles seront disponibles.
Les applications Coinbase ont tendance à avoir beaucoup de données qui changent rapidement (par exemple, les prix et les soldes des cryptos). Traditionnellement, nous avons utilisé des choses comme Pusher ou d’autres solutions propriétaires pour maintenir les données à jour. Avec GraphQL, nous pouvons utiliser des abonnements pour fournir des mises à jour en direct. Cependant, nous pensons que les abonnements ne sont pas un outil idéal pour nos besoins et prévoyons d’explorer l’utilisation des requêtes en direct (plus à ce sujet dans un article de blog à venir).
Coinbase se consacre à accroître la liberté économique mondiale. À cette fin, nous nous efforçons de rendre nos produits performants, quel que soit l’endroit où vous vivez, y compris dans les zones où les connexions de données sont lentes. Pour aider à faire de cela une réalité, nous aimerions créer et déployer une couche de mise en cache périphérique globale, sécurisée, fiable et cohérente afin de réduire le temps total d’aller-retour pour toutes les requêtes.
L’équipe Relay a fait un travail formidable et nous sommes extrêmement reconnaissants du travail supplémentaire qu’ils ont fait pour permettre au monde de profiter de leurs apprentissages chez Meta. À l’avenir, nous aimerions transformer cette relation à sens unique en une relation à double sens. À partir du deuxième trimestre, Coinbase prêtera des ressources pour aider à travailler sur Relay OSS. Nous sommes très heureux d’aider à faire avancer le Relais !
Êtes-vous intéressé à résoudre de gros problèmes à une échelle toujours croissante? Venez nous rejoindre!