Introduction

Les projets informatiques passent par différentes phases au cours de leurs développements et de leur croissance. Du monolithe à l'architecture orientée microservice, nous allons ici nous intéresser aux différentes structures qui permettent à une page frontend de récupérer les informations à afficher.

Prenons l'exemple suivant : une application de recette de cuisine qui permettrait à l'utilisateur, en plus de connaitre la liste des ingrédients, de trouver des producteurs pour acheter des ingrédients frais et locaux. Nous allons discerner 3 services pour cette application :

  • un service produits
  • un service recettes
  • un service producteurs locaux

Le Monolithe

Au commencement d'un projet, la première vision est bien souvent le développement d'un monolithe. Toutes les différentes responsabilités et fonctionnalités de l'application se retrouvent dans une seule et même codebase.

Architecture monolithique

Pour le frontend, la récupération des données se fait assez facilement, une simple requête permet de récupérer toutes les informations nécessaires, puisque ces dernières sont situées au même endroit.

Architecture microservices

Au fil du temps, la codebase monolithique grossit, et la vélocité de développement décroît avec le temps. L'architecture orientée microservices s'impose souvent comme un choix judicieux pour répondre à cette problématique. Ainsi les développeurs backend repensent l'application en découpant et séparant par responsabilité. Chaque microservice est désormais responsable d'une sous-partie.

Pour notre exemple, le découpage sera le suivant : un microservice gérant nos produits, un autre pour les recettes et un dernier pour les producteurs.

Architecture en microservices

Les microservices sont autonomes et ne dépendent pas les uns des autres, cependant cela se complique d'un point de vue frontend. Chaque microservice exposant sa propre API, le code front-end doit appeler séparément les APIs, et faire un travail d'agrégation des données pour pouvoir afficher les informations voulues. Cela crée souvent des allers-retours entre client et serveur provocant des latences à l'affichage.

Backend for frontend (BFF)

C'est là qu’apparaît un nouveau pattern: le Backend for Frontend (BFF). Il s'agit une couche backend supplémentaire qui va agréger les données de plusieurs microservices afin de les renvoyer à la page frontend, qui ne possède plus qu'un seul point de contact et reçoit directement les données prêtes à afficher.

Pour que cela fonctionne, chaque vue front-end se verra attribuer en endpoint géré par la BFF, plus l'application deviendra grande, plus le nombre d'endpoint augmentera... Les changements de version des microservices devront être également réimpacté dans la BFF. Au fil du temps cette BFF deviendra aussi grosse que notre monolithe initiale et sa maintenance sera de plus en plus complexe.

Backend for Frontend

Mais alors... n'existe-t-il pas une solution permettant d’agréger les données des différents microservice, tout en gardant la séparation des responsabilités, et de renvoyer au frontend une API unique ? C'est tout l'enjeu d'Apollo Federation

Apollo Federation

Apollo Federation

Qu'est-ce que GraphQL ?

Redhat définit GraphQL comme :

Un langage de requête et un environnement d'exécution côté serveur pour les interfaces de programmation d'applications (API) qui s'attache à fournir aux clients uniquement les données qu'ils ont demandées, et rien de plus.

Ce langage vient remplacer les API REST classiques en ouvrant tout un nouveau champ de possibilités aux développeurs lors de la création de leurs API. L'un des principaux avantages de GraphQL est d'être WYSIWYG (What you see is what you get). En effet, lors d'une requête, GraphQL permet de choisir la liste exhaustive des champs que le client souhaite recevoir. Les autres champs ne seront pas calculés ni envoyés au client et donc la requête sera optimisée.

Exemple avec REST

GET /product
const result = {
  id: "RaCletTeDePays42",
  name: "Raclette de pays",
  calories: 42,
  origin: "Suisse",
  composition: "Lait de vache",
};

Exemple avec GraphQL

POST /graphql
const query = gql`
  query {
    product {
      name
      origin
    }
  }
`;
const result = {
  name: "Raclette de pays",
  origin: "Suisse",
};

Apollo Federation par l'exemple

Depuis le 13 avril 2022, la version 2 est sortie, dans les exemples qui suivront la syntaxe sera basée sur cette version.

Reprenons notre exemple d'application de recettes. Nos 3 services séparés vont exposer leurs schémas GraphQL unitairement et Apollo Federation va venir agréger ces schémas pour générer en sortie un schéma unique.

Schéma des microservices

Voici comment peuvent se décomposer les schémas de données des différents microservices

Microservice produit

type Product @key(fields: "id") {
  id: ID!
  name: String
  calories: Int
}

Le service des produits exposera le type Product principal, avec des informations simples. la directive @key(fields: "id") permet de donner une clé qui sera utilisée pour récupérer les informations du produit depuis les autres microservices

GraphQL se base sur un principe de resolver afin de calculer les données à afficher. Dans notre cas, comme le champ id est utilisé comme clé, il sera utilisé dans un resolver afin de trouver parmi la liste de produits en base de données le produit correspondant à cet id. Pour cela, Apollo Federation propose un resolver appelé __resolveReference. L'objet référence en paramètre de la fonction ne contiendra que l'id du produit à retrouver. Il est ainsi très simple de retrouver ce produit grâce à un ProductService.

resolvers: {
  Product: {
    __resolveReference(reference) {
      return ProductService.getById(reference.id);
    },
  },
},

Microservice recette

type Recipe {
  id: ID!
  forHowManyPersons: Int
  name: String
  ingredients: [Ingredient!]
}
 
type Ingredient {
  product: Product
  quantity: Int
}
 
type Product @key(fields: "id") {
  id: ID!
}
 
type Query {
  recipe(id: ID!): Recipe
}

Le service des recettes exposera un nouveau type Recipe avec des informations de la recette, notamment la liste des ingrédients. Et c'est ici que la puissance de Apollo Federation rentre en jeu.

Ce microservice va pouvoir étendre la notion de Product, en spécifiant la clé qui a été définie dans le microservice de produit (ici "id").

En version 1, le mot-clé @external permettait de préciser que le champ id était défini dans un autre service. Cette notion est devenue implicite avec l'arrivée d'Apollo Federation v2. Il en va de même pour le mot clé extend qui permettait de dire qu'un type étendait un autre type défini dans un autre service de la fédération.

Il suffira ensuite de définir le resolver expliquant à GraphQL quoi faire pour résoudre la query.

resolvers: {
  Query: {
    recipe: (_: any, { id }) => {
      return RecipeService.findOne(id);
    },
  },
},

Et c'est tout ! Apollo Federation s'occupe du reste ! Une fois le setup de notre Federation terminé, il suffira d'appeler la requête suivante pour récupérer les informations d'une recette avec pour chaque ingrédient, les informations de celui-ci provenant du service de produits !

query Recipe($recipeId: ID!) {
  recipe(id: $recipeId) {
    name
    forHowManyPersons
    ingredients {
      quantity
      product {
        name
        calories
      }
    }
  }
}

Microservice de recette

Le service Producteur va pouvoir ajouter de la donnée dont il est propriétaire à l'entité Produit, alors même que cette entité est définie dans un autre service. Pour cela, il va suffire d'étendre le type Product et de lui définir la propriété producers qui correspondra à la liste des producteurs qui produisent ce produit. Le schéma de ce service est le suivant:

type Producer @key(fields: "id") {
  id: ID!
  products: [Product!]
  address: String
  name: String
}
 
type Product @key(fields: "id") {
  id: ID!
  producers: [Producer]
}

On ajoute, enfin ce resolver :

resolvers: {
  Product: {
    producers(product) {
      return ProducerService.getProducersHavingProducts(product.id);
    },
  },
},

Création d'une Gateway

Une fois nos différents schémas créés indépendamment les uns des autres, il est maintenant nécessaire de les agréger dans un seul schéma. C'est le rôle de Apollo Gateway.

const gateway = new ApolloGateway({
  supergraphSdl: new IntrospectAndCompose({
    subgraphs: [
      { name: "product", url: "http://localhost:3001" },
      { name: "recipe", url: "http://localhost:3002" },
      { name: "producer", url: "http://localhost:3003" },
    ],
  }),
});

Ici nous utilisons directement le champs subgraphs pour spécifier les schémas à agréger. Cependant cette méthode necessite un redémarage du serveur pour chaque modification de schéma. En production deux solutions s'offre à vous. Vous pouvez soit utiliser la schema registry fournie par Apollo. ou implémenter votre propre stratégie en utilisant le SupergraphManager.

Cette gateway sera finalement exposée dans un nouvel Apollo Server

const server = new ApolloServer({
  gateway,
});

Notre Apollo Federation est maintenant prête !

Nous pouvons maintenant charger l'ensemble des données de notre application de recette en un seul appel, en utilisant la query suivante :

query Recipe($recipeId: ID!) {
  recipe(id: $recipeId) {
    name
    forHowManyPersons
    ingredients {
      quantity
      product {
        name
        calories
        producers {
          name
          address
        }
      }
    }
  }
}

Query Plan

Comment est-ce que Apollo Federation fait pour agréger toutes ces données et reconstruire la réponse ? Pour comprendre cela, il faut analyser le Query Plan de la requête. En effet, Apollo va décomposer notre requête en plusieurs plus petites. Analysons le query plan de notre requête :

QueryPlan {
  Sequence {
    Fetch(service: "recipe") {
      {
        recipe(id: "1") {
          name
          forHowManyPersons
          ingredients {
            quantity
            product {
              __typename
              id
            }
          }
        }
      }
    },
    Parallel {
      Flatten(path: "recipe.ingredients.@.product") {
        Fetch(service: "product") {
          {
            ... on Product {
              __typename
              id
            }
          } =>
          {
            ... on Product {
              name
              calories
            }
          }
        },
      },
      Flatten(path: "recipe.ingredients.@.product") {
        Fetch(service: "farmer") {
          {
            ... on Product {
              __typename
              id
            }
          } =>
          {
            ... on Product {
              producers {
                name
                address
              }
            }
          }
        },
      },
    },
  },
}
  1. la requête est faite pour récupérer la recette avec l'id "1". Les données de la recette sont récupérées. Pour les produits, seuls les ids sont récupérés car il s'agit de la seule donnée accessible dans le service de recette.
  2. Sur le service des produits, un premier appel est fait avec seulement l'id en paramètre. Cela va déclencher le resolver __resolveReference afin de récupérer le produit associé à cet id. Une fois cela effectué, il ne reste plus qu'à récupérer le nom et les calories de chaque produit.
  3. Le même principe s'applique pour les producteurs. Pour chaque ingrédient, le produit est résolu et ensuite les producteurs sont résolus en fonction de leur production de l'ingrédient en question.

Allons plus loin...

Dans l'objectif de développer notre application avec le réseau de producteurs locaux, nous souhaitons que les producteurs disposent d'un système de livraison de leurs marchandises. La logique du calcul du prix de livraison doit être détenue par le microservice des producteurs mais nécessite des données du produit, comme par exemple le poids et la taille du produit, informations qui doivent être détenues par le microservice de produits.

Nous allons modifier le schéma du microservice de produits comme tel :

type Product @key(fields: "id") {
  id: ID!
  name: String
  calories: Int
  dimensions: Dimensions
}
 
type Dimensions {
  size: Int
  weight: Int
}

De même, le schéma du microservice de producteur devient :

type Producer {
  id: ID!
  products: [Product!]
  address: String
  name: String
}
 
type Dimensions {
  size: Int
  weight: Int
}
 
type Product @key(fields: "id") {
  id: ID!
  producers: [Producer]
  dimensions: Dimensions
  shippingCost: Int @requires(fields: "dimensions { size weight }")
}
resolvers: {
  Product: {
    producers(product) {
      return ProducerService.getProducersHavingProducts(product.id);
    },
    shippingCost: (product) => {
      return ProducerService.computeShippingCost(product.dimensions);
    },
  },
},

Le @requires sur le champs shippingCost spécifie que le resolver va nécessiter des données supplémentaires que la clé du produit (id) afin de résoudre le champ.

Conclusion

À travers GraphQL, Apollo Federation permet de garantir un fort découplage backend. Chaque microservice est autonome et ne dépend pas d'un service tiers. Et de garantir une unique API à destination du frontend.