Refactoriser un monolithe en microservices

Ce guide de référence est le deuxième d'une série en quatre parties sur la conception, la création et le déploiement de microservices. Cette série décrit les différents éléments d'une architecture de microservices. La série inclut des informations sur les avantages et les inconvénients du modèle d'architecture de microservices, et sur la façon d'appliquer ce modèle.

  1. Présentation des microservices
  2. Refactoriser un monolithe en microservices (le présent document)
  3. Communication interservices dans une configuration à microservices
  4. Traçage distribué dans une application à microservices

Cette série est destinée aux développeurs et aux architectes d'applications qui conçoivent et mettent en œuvre la migration pour refactoriser une application monolithique en application à microservices.

Le processus de transformation d'une application monolithique en microservices est une forme de modernisation des applications. Pour moderniser une application, nous vous recommandons de ne pas refactoriser l'ensemble de votre code en même temps. Nous vous recommandons plutôt de redimensionner de manière incrémentielle votre application monolithique. Lorsque vous refactorisez une application de manière incrémentielle, vous créez progressivement une nouvelle application de type à microservices et vous l'exécutez en parallèle de votre application monolithique. Cette approche est également appelée modèle de "figuier étrangleur". Au fil du temps, les fonctionnalités mises en œuvre par l'application monolithique diminuent jusqu'à ce que l'application monolithique disparaisse complètement ou devienne un autre microservice.

Pour dissocier des fonctionnalités d'un monolithe, vous devez extraire soigneusement les données, la logique et les composants destinés aux utilisateurs de la fonctionnalité, puis les rediriger vers le nouveau service. Il est important de bien maîtriser l'espace du problème avant de vous attaquer à l'espace de la solution.

Lorsque vous comprenez l'espace du problème, vous comprenez les limites naturelles du domaine qui offrent le bon niveau d'isolation. Nous vous recommandons de créer des services volumineux plutôt que des petits services tant que vous n'avez pas parfaitement compris le domaine.

La définition des limites des services est un processus itératif. Comme ce processus est une tâche qui implique un travail conséquent, vous devez évaluer en permanence le coût de la dissociation par rapport aux avantages que vous obtenez. Vous trouverez ci-dessous des facteurs qui vous aideront à mieux appréhender la dissociation d'un monolithe :

  • Évitez de refactoriser tout d'un seul coup. Pour prioriser la dissociation de certains services, vous devez évaluer le coût de la dissociation en comparaison aux avantages qu'elle vous offre.
  • Les services d'une architecture de microservices sont organisés autour de préoccupations commerciales et non pas techniques.
  • Lorsque vous migrez des services de manière incrémentielle, configurez la communication entre les services et le monolithe pour passer par des contrats d'API bien définis.
  • Les microservices nécessitent beaucoup plus d'automatisation : pensez à l'avance à l'intégration continue (CI), au déploiement continu (CD), à la journalisation centralisée et à la surveillance.

Les sections suivantes décrivent différentes stratégies pour dissocier les services et migrer de manière incrémentielle votre application monolithique.

Dissociation par conception basée sur le domaine

Les microservices doivent être conçus autour des fonctions commerciales et non sur des couches horizontales telles que l'accès aux données ou la messagerie. Les microservices doivent également avoir un couplage souple et une cohésion fonctionnelle élevée. Le couplage souple signifie que vous pouvez modifier un service sans exiger que les autres services soient mis à jour en même temps. Un microservice est cohérent s'il a une fonction unique et bien définie, par exemple la gestion des comptes utilisateur ou le traitement des paiements.

La conception basée sur le domaine (DDD) nécessite une bonne compréhension du domaine pour lequel l'application est écrite. Les connaissances nécessaires sur le domaine pour créer l'application sont détenues par les personnes qui le comprennent, c'est-à-dire les experts du domaine.

Vous pouvez appliquer l'approche DDD rétroactivement à une application existante comme suit :

  1. Identifiez un langage omniprésent, un vocabulaire commun partagé par toutes les parties prenantes. En tant que développeur, il est important d'utiliser dans votre code des termes qu'une personne non-technique pourrait comprendre. Le résultat de votre code doit refléter les processus de votre entreprise.
  2. Identifiez les modules pertinents dans l'application monolithique, puis appliquez le vocabulaire commun à ces modules.
  3. Définissez des contextes clairement définis dans lesquels vous appliquez des limites explicites aux modules identifiés, avec des responsabilités clairement définies. Les contextes clairement définis que vous identifiez sont des candidats à une refactorisation en microservices plus petits.

Le schéma suivant montre comment appliquer des contextes clairement définis à une application e-commerce existante :

Contextes clairement définis appliqués à une application.

Figure 1 : Les fonctionnalités de l'application sont séparées en contextes limités qui migrent vers des services.

Dans la figure 1, les fonctionnalités de l'application e-commerce sont séparées en contextes limités et migrées vers des services comme suit :

  • Les fonctionnalités de gestion des commandes et de traitement sont liées aux catégories suivantes :
    • La fonctionnalité de gestion des commandes migre vers le service de commande.
    • La fonctionnalité de gestion de la livraison logistique migre vers le service de livraison.
    • La fonctionnalité d'inventaire migre vers le service d'inventaire.
  • Les fonctionnalités de comptabilité sont restreintes à une seule catégorie :
    • Les fonctionnalités pour les clients, les vendeurs et les tiers sont migrées ensemble vers le service de comptabilité.

Donner la priorité aux services pour la migration

Un point de départ idéal pour dissocier les services consiste à identifier les modules faiblement couplés dans votre application monolithique. Vous pouvez choisir un module faiblement couplé comme l'un des premiers candidats pour la conversion en microservice. Pour effectuer une analyse des dépendances de chaque module, examinez les points suivants :

  • Le type de dépendance : dépendances par rapport à des données ou à d'autres modules.
  • L'échelle de dépendance : l'impact d'une modification du module identifié sur d'autres modules.

La migration d'un module avec de fortes dépendances de données n'est généralement pas une tâche aisée. Si vous commencez par migrer les fonctionnalités avant de migrer les données associées, vous risquez de lire et d'écrire temporairement des données dans plusieurs bases de données. Par conséquent, vous devez tenir compte des défis liés à l'intégrité des données et à la synchronisation.

Nous vous recommandons d'extraire des modules qui ont des besoins en ressources différents par rapport au reste du monolithe. Par exemple, si un module comporte une base de données en mémoire, vous pouvez le convertir en un service que vous pourrez ensuite déployer sur des hôtes disposant d'une mémoire plus élevée. Lorsque vous transformez en services des modules qui ont des exigences de ressources particulières, vous pouvez rendre votre application beaucoup plus facile à faire évoluer.

Du point de vue des opérations, la refactorisation d'un module dans son propre service implique également d'ajuster les structures d'équipe existantes. La méthode la plus efficace consiste à créer des équipes plus petites pour leur confier la responsabilité de l'intégralité d'un service.

D'autres facteurs pouvant affecter la priorité des services pour la migration incluent la criticité métier, la couverture complète des tests, la stratégie de sécurité de l'application et le niveau d'adhésion de l'entreprise. En fonction de vos évaluations, vous pouvez classer les services comme décrit dans le premier document de cette série, c'est-à-dire en fonction des avantages que vous pouvez tirer de la refactorisation.

Extraire un service d'un monolithe

Après avoir identifié le service candidat idéal, vous devez identifier un moyen de faire coexister les modules monolithiques et à microservices. Pour gérer cette coexistence, vous pouvez introduire un adaptateur de communication entre processus (IPC) qui facilite la collaboration entre les modules. Au fil du temps, le microservice assumera toute la charge de travail afin d'éliminer le composant monolithique. Ce processus incrémentiel réduit les risques associés à la transition d'une application monolithique à un nouveau microservice, car vous pouvez détecter les bugs ou les problèmes de performances de manière progressive.

Le schéma suivant montre comment mettre en œuvre l'approche IPC :

Une approche IPC mise en œuvre pour aider les modules à fonctionner ensemble.

Figure 2. Un adaptateur IPC coordonne la communication entre l'application monolithique et un module à microservices.

Dans la figure 2, le module Z est le service candidat que vous souhaitez extraire de l'application monolithique. Les modules X et Y dépendent du module Z. Les modules à microservices X et Y utilisent un adaptateur IPC dans l'application monolithique pour communiquer avec le module Z par l'intermédiaire d'une API REST.

Le document suivant de cette série, Communication entre services dans une configuration à microservices, décrit le modèle du "figuier étrangleur" et explique comment déconstruire un service à partir du monolithe.

Gérer une base de données monolithique

En règle générale, les applications monolithiques possèdent leurs propres bases de données monolithiques. L'un des principes de l'architecture à microservices consiste à avoir une base de données pour chaque service. Par conséquent, lorsque vous modernisez votre application monolithique en microservices, vous devez diviser la base de données monolithique en fonction des limites de service que vous identifiez.

Pour déterminer comment diviser une base de données monolithique, commencez par analyser les mappages de la base de données. Dans le cadre de l'analyse d'extraction de services, vous avez recueilli des informations sur les microservices que vous devez créer. Vous pouvez utiliser la même approche pour analyser l'utilisation de la base de données et mapper des tables ou d'autres objets de base de données aux nouveaux microservices. Des outils tels que SchemaRobot, SchemaSpy et ERBuilder peuvent vous aider à effectuer ce type d'analyse. Mapper les tables et les autres objets vous aide à comprendre le couplage entre les objets de base de données qui peut traverser les limites potentielles de microservices.

Cependant, la division d'une base de données monolithique est complexe car il peut ne pas y avoir de séparation claire entre les objets de base de données. Vous devez également tenir compte d'autres problèmes tels que la synchronisation des données, l'intégrité transactionnelle, les jointures et la latence. La section suivante décrit des modèles qui peuvent vous aider à résoudre ces problèmes lorsque vous divisez votre base de données monolithique.

Tables de référence

Dans les applications monolithiques, il est courant que les modules accèdent aux données requises à partir d'un module différent, grâce à une opération de jointure SQL avec la table de l'autre module. Le schéma suivant utilise l'exemple d'application d'e-commerce précédent pour illustrer ce processus d'accès par jointure SQL :

Un module utilise une jointure SQL pour accéder aux données d'un autre module.

Figure 3. Un module joint les données à la table d'un autre module.

Dans la figure 3, pour obtenir des informations sur les produits, un module de commande utilise une clé étrangère product_id pour joindre une commande à la table des produits.

Toutefois, si vous décompilez des modules en tant que services individuels, nous vous recommandons de ne pas faire en sorte que le service de commande appelle directement la base de données du service de produit pour exécuter une opération de jointure. Les sections suivantes décrivent les options que vous pouvez envisager pour séparer les objets de base de données.

Partager des données via une API

Lorsque vous séparez les fonctionnalités ou les modules de base en microservices, vous utilisez généralement des API pour partager et exposer des données. Le service référencé expose les données sous la forme d'une API dont le service appelant a besoin, comme illustré dans le schéma suivant :

Données exposées via une API.

Figure 4. Un service utilise un appel d'API pour récupérer les données d'un autre service.

Dans la figure 4, un module de commande utilise un appel d'API pour obtenir les données d'un module de produit. Cette mise en œuvre présente des problèmes de performances évidents en raison d'appels réseau et de base de données supplémentaires. Toutefois, le partage de données via une API fonctionne bien lorsque la taille des données est limitée. En outre, si le service appelé renvoie des données avec un taux de changement bien connu, vous pouvez mettre en œuvre un cache TTL local sur l'appelant pour réduire le nombre de requêtes réseau adressées au service appelé.

Répliquer des données

Une autre façon de partager des données entre deux microservices distincts consiste à répliquer des données dans la base de données du service dépendant. La réplication de données est en lecture seule et peut être reconstruite à tout moment. Ce modèle permet au service d'être plus cohérent. Le schéma suivant illustre le fonctionnement de la réplication de données entre deux microservices :

Des données sont répliquées entre les microservices.

Figure 5. Les données d'un service sont répliquées dans une base de données de service dépendante.

Dans la figure 5, la base de données du service de produit est répliquée dans la base de données du service de commande. Cette implémentation permet au service de commande d'obtenir des données produit sans appels répétés au service de produit.

Pour créer une réplication de données, vous pouvez utiliser des techniques telles que des vues matérialisées, la capture de données modifiées (CDC ou "Change Data Capture") et des notifications d'événement. Les données répliquées sont cohérentes à terme, mais il peut y avoir un retard dans la réplication des données. Il existe donc un risque de diffusion de données obsolètes.

Données statiques en tant que configuration

Les données statiques comme les codes pays et les devises acceptées ne changent que très rarement. Vous pouvez injecter ces données statiques en tant que configuration dans un microservice. Les microservices et les frameworks cloud modernes fournissent des fonctionnalités permettant de gérer ces données de configuration à l'aide de serveurs de configuration, de magasins de paires valeur/clé et de coffres-forts. Vous pouvez inclure ces fonctionnalités de manière déclarative.

Données modifiables partagées

Les applications monolithiques ont un modèle commun appelé état modifiable partagé. Dans une configuration avec état modifiable partagé, plusieurs modules utilisent une même table, comme indiqué dans le schéma suivant :

Une configuration d'état modifiable partagé permet de mettre une table à la disposition de plusieurs modules.

Figure 6. Plusieurs modules utilisant une même table.

Dans la figure 6, les fonctionnalités de commande, de paiement et d'expédition de l'application e-commerce utilisent la même table ShoppingStatus pour conserver l'état de la commande du client tout au long du parcours d'achat.

Pour migrer un monolithe d'état modifiable partagé, vous pouvez développer un microservice ShoppingStatus distinct pour gérer la table de base de données ShoppingStatus. Ce microservice expose des API pour gérer l'état des achats d'un client, comme indiqué dans le schéma suivant :

API exposées à d'autres services.

Figure 7. Un microservice expose des API à plusieurs autres services.

Dans la figure 7, les microservices de paiement, de commande et d'expédition utilisent les API du microservice ShoppingStatus. Si la table de base de données est étroitement liée à l'un des services, nous vous recommandons de déplacer les données vers ce service. Vous pouvez ensuite exposer les données via une API pour que d'autres services puissent les utiliser. Cette implémentation vous permet de vous assurer que vous n'avez pas trop de services spécifiques qui s'appellent fréquemment. Si vous avez réparti vos services de manière incorrecte, vous devez revoir la définition des limites de service.

Transactions distribuées

Une fois que vous avez isolé le service du monolithe, une transaction locale dans le système monolithique d'origine peut être distribuée entre plusieurs services. Une transaction qui couvre plusieurs services est considérée comme une transaction distribuée. Dans l'application monolithique, le système de base de données garantit que les transactions sont atomiques. Pour gérer les transactions entre divers services dans un système basé sur des microservices, vous devez créer un coordinateur de transactions global. Le coordinateur des transactions gère les rollbacks et les actions de compensation ainsi que d'autres transactions décrites dans le document suivant de cette série : Communication entre services dans une configuration à microservices.

Cohérence des données

Les transactions distribuées présentent le défi de maintenir la cohérence des données entre les services. Toutes les mises à jour doivent être effectuées de manière atomique. Dans une application monolithique, les propriétés des transactions garantissent qu'une requête renvoie une vue cohérente de la base de données en fonction de son niveau d'isolation.

Prenons l'exemple d'une transaction en plusieurs étapes dans une architecture basée sur des microservices. Si une transaction de service échoue, les données doivent être réconciliées en effectuant un rollback des étapes qui ont réussi entre les autres services. Sinon, la vue globale des données de l'application sera incohérente entre les services.

Il peut être difficile de déterminer quand une étape qui met en œuvre la cohérence à terme a échoué. Par exemple, une étape peut ne pas échouer immédiatement et plutôt être bloquée ou expirer. Par conséquent, vous devrez peut-être mettre en œuvre un mécanisme de délai d'inactivité. Si les données dupliquées sont obsolètes lorsque le service appelé y accède, la mise en cache ou la réplication des données entre les services afin de réduire la latence du réseau peuvent également entraîner des incohérences de données.

Le document suivant de la série, Communication entre services dans une configuration à microservices, fournit un exemple de modèle pour gérer les transactions distribuées sur plusieurs microservices.

Conception de la communication interservice

Dans une application monolithique, les composants (ou les modules d'application) s'appellent directement via des appels de fonction. En revanche, une application basée sur des microservices se compose de plusieurs services qui interagissent entre eux sur le réseau.

Lorsque vous concevez une communication interservice, réfléchissez d'abord à la manière dont les services sont censés interagir entre eux. Les interactions avec les services peuvent être les suivantes :

  • Interaction un à un : chaque requête client est traitée par un seul service.
  • Interactions un à plusieurs : chaque requête est traitée par plusieurs services.

Déterminez également si l'interaction est synchrone ou asynchrone :

  • Synchrone : le client attend une réponse en temps opportun du service et peut se bloquer pendant qu'il attend.
  • Asynchrone : le client ne se bloque pas pendant l'attente d'une réponse. La réponse, s'il y en a une, n'est pas nécessairement envoyée immédiatement.

Le tableau suivant présente des combinaisons de styles d'interaction :

Un à un Un à plusieurs
Synchrone Requête et réponse : envoi d'une requête à un service et attente d'une réponse.
Asynchrone Notification : envoi d'une requête à un service sans attente ou envoi d'une réponse. Publier et s'abonner : le client publie un message de notification et le ou les services intéressés consomment le message.
Requête et réponse asynchrone : envoi d'une requête à un service, qui répond de manière asynchrone. Le client ne bloque pas l'accès. Publication et réponses asynchrones : le client publie une requête et attend les réponses des services concernés.

Chaque service utilise généralement une combinaison de ces styles d'interaction.

Mettre en œuvre la communication interservices

Pour mettre en œuvre la communication interservice, vous pouvez choisir parmi différentes technologies IPC. Par exemple, les services peuvent utiliser des mécanismes de communication basés sur les requêtes/réponses synchrones, tels que REST, gRPC ou Thrift. Les services peuvent également utiliser des mécanismes de communication asynchrones basés sur des messages, tels que AMQP ou STOMP. Vous pouvez également choisir parmi différents formats de messages. Par exemple, les services peuvent utiliser des formats en texte clair, tels que JSON ou XML. Les services peuvent également utiliser un format binaire tel que Avro ou Protocol Buffers.

La configuration de services pour appeler directement d'autres services entraîne un couplage élevé. Nous vous recommandons plutôt d'utiliser la messagerie ou la communication basée sur des événements :

  • Messagerie : lorsque vous mettez en œuvre la messagerie, les services n'ont plus besoin de s'appeler directement entre eux. Au lieu de cela, tous les services connaissent un agent de messagerie et transfèrent les messages à cet agent. L'agent de messagerie enregistre ces messages dans une file d'attente de messages. D'autres services peuvent s'abonner aux messages qui les intéressent.
  • Communication basée sur les événements : la mise en œuvre du traitement basé sur des événements permet la communication entre les services via les événements produits par des services individuels. Les services individuels écrivent leurs événements dans un agent de messagerie. Les services peuvent écouter les événements qui les intéressent. Ce modèle maintient un faible couplage des services car les événements n'incluent pas de charge utile.

Dans une application de microservices, nous vous recommandons d'utiliser une communication interservices asynchrone plutôt qu'une communication synchrone. La requête-réponse étant un modèle architectural bien compris, la conception d'une API synchrone peut sembler plus naturelle que la conception d'un système asynchrone. La communication asynchrone entre les services peut être mise en œuvre à l'aide de messages ou d'une communication basée sur des événements. L'utilisation de la communication asynchrone présente les avantages suivants :

  • Couplage faible : un modèle asynchrone divise l'interaction requête/réponse en deux messages distincts, l'un pour la requête et l'autre pour la réponse. Le consommateur d'un service initie le message de requête et attend la réponse, tandis que le fournisseur de services attend les messages de requête auxquels il répond en renvoyant des messages de réponse. Cette configuration évite à l'appelant d'attendre le message de réponse.
  • Isolation en cas d'échec : l'expéditeur peut continuer à envoyer des messages, même en cas de défaillance du consommateur en aval. Le consommateur récupère les messages en attente à chaque récupération. Cette fonctionnalité est particulièrement utile dans une architecture à microservices, car chaque service possède son propre cycle de vie. Cependant, les API synchrones nécessitent que le service en aval soit disponible, sans quoi l'opération échoue.
  • Réactivité : un service en amont peut répondre plus rapidement s'il n'attend pas les services en aval. S'il existe une chaîne de dépendances de services (le service A appelle B, qui appelle C et ainsi de suite), l'attente des appels synchrones peut augmenter la latence au-delà des limites acceptables.
  • Contrôle de flux : une file d'attente de messages agit comme un tampon, de telle sorte que les destinataires peuvent traiter les messages à leur propre rythme.

Cependant, voici quelques problèmes liés à l'utilisation efficace de la messagerie asynchrone :

  • Latence : si l'agent de messagerie devient un goulot d'étranglement, la latence de bout en bout peut devenir élevée.
  • Frais de développement et de test : en fonction du choix d'infrastructure de messagerie ou d'événements, il peut arriver d'avoir des messages en doublon, auquel cas il peut s'avérer difficile de rendre les opérations idempotentes. Il peut également être difficile de mettre en œuvre et de tester la sémantique des requêtes et des réponses à l'aide de la messagerie asynchrone. Vous avez besoin d'un moyen de mettre en corrélation les messages de requête et de réponse.
  • Débit : la gestion asynchrone des messages via une file d'attente centrale ou un autre mécanisme peut devenir un goulot d'étranglement dans le système. Les systèmes de backend, tels que les files d'attente et les consommateurs en aval, doivent évoluer pour répondre aux exigences de débit du système.
  • Simplification de la gestion des erreurs : dans un système asynchrone, l'appelant ne sait pas si une requête a réussi ou échoué. La gestion des erreurs doit donc être traitée hors bande. Ce type de système peut compliquer la mise en œuvre des logiques de nouvelles tentatives ou d'intervalles exponentiels entre les tentatives. La gestion des erreurs est d'autant plus compliquée si plusieurs appels asynchrones dépendants doivent réussir ou échouer.

Le document suivant de la série, Communication entre services dans une configuration à microservices, fournit une mise en œuvre de référence pour résoudre certains des problèmes mentionnés dans la liste précédente.

Étape suivante