Magazine
Memo inside

La communication dans une architecture orientée services

Joan Zapata

icon dot

04 décembre 2020

L’architecture orientée service (Service-Oriented Architecture) a fait ses preuves dans pratiquement tous les secteurs. Elle permet de créer des systèmes capables d’évoluer et de grandir pour fonctionner à grande échelle si besoin. Bien que la notion d’architecture orientée service recouvre plusieurs définitions, l’idée selon laquelle une application complexe peut être décomposée en sous-parties autonomes a fait son petit bout de chemin depuis la fin des années 1990.

Chez Memo Bank, nous avons souscrit aux principes de l’architecture orientée service dès le départ, dès nos premiers développements. Trois années plus tard, nos applications backend s’appuient sur une vingtaine de petits services, plus ou moins dépendants les uns des autres. Opter pour une architecture orientée service nous a permis de manipuler des langages qui nous sont chers, comme Elixir et Kotlin. Grâce à ce choix d’architecture, nous avons aussi pu mettre en place les outils avec lesquels nous voulions travailler, comme l’event sourcing (le fait de garder une trace de tous les évènements qui modifient l’état d’une application donnée).

Avoir plusieurs services à notre disposition nous oblige à nous poser plusieurs questions, à commencer par : comment faire pour que nos différents services se parlent, discutent, communiquent entre eux ? Bien sûr, plusieurs personnes ont déjà apporté plusieurs réponses à cette question ; dans les lignes qui suivent, nous allons ajouter notre réponse à la pile de réponses existantes.

Pour commencer, prenons un cas de figure simple, une situation qui ne mobilise que deux services.

REST en HTTPS

Comme beaucoup d’applications backend, nous avons commencé par des requêtes REST en HTTPS.

Image de l'article

Qu’est-ce qui tourne en rond et que nous cherchons à éviter par tous les moyens ? Les dépendances cycliques bien sûr (quand A dépend de B et que B dépend de A). Dans notre exemple, Contracts ne peut pas ordonner à Customers d’activer le compte du client dès que ce dernier a signé son contrat. Au lieu de ça, Customers doit interroger Contracts à intervalles réguliers pour savoir quand la tâche en cours (la signature du contrat par le client) est terminée.

Passer par le HTTPS nous semble être un bon choix : c’est un protocole relativement simple à mettre en place, largement répandu, et qui nous sert aussi à exposer des API à nos applications frontend. Une chose nous dérange cependant dans le HTTPS, c’est son caractère synchrone — pas de quoi nous faire regretter notre choix, cela dit. Dans notre exemple, le côté synchrone du HTTPS signifie que Customers attend (avec une connexion active) pendant que Contracts mouline, tout du long. En généralisant, cela signifie que le système tout entier règle son pas sur le pas du service le plus lent. Dit autrement, il suffit d’un seul service lent pour ralentir tout le système. Cela signifie aussi que si le service Contracts ne répond pas, le service Customers ne répondra pas, lui non plus — on parle alors d’indisponibilités en cascade (cascading failures).

Alors, comment avons-nous contourné le problème ? En utilisant deux techniques différentes : la transmission continue de messages (continuous messaging) et la transmission ponctuelle de messages (discrete messaging).

La transmission discrète

Reprenons notre exemple : le service Customers souhaite que le service Contracts envoie un contrat au client.

Pour ce cas de figure, nous pouvons nous en sortir en utilisant un mécanisme discret de messagerie : la mise en file d’attente (ou queue). Voyez une queue comme une boîte de réception tierce, à part. Même si le service principal tombe, cesse de fonctionner, la boîte de réception continue d’accepter les messages de son côté, elle ne dépend pas du bon fonctionnement de tel ou tel service. Au sein du même service, plusieurs instances (du service) peuvent faire appel à la même queue — auquel cas la queue se comporte alors comme un répartiteur de charge (load balancer), ce qui permet de traiter tous les messages au moins une fois. Et quand un message a été traité, la boîte de réception peut l’oublier, le purger.

Image de l'article

Nous utilisons des files d’attente pour envoyer des commandes d’un service à un autre (SendContract par exemple).

Les files d’attente ont plusieurs avantages :

  • Le service à l’origine de la commande ne se préoccupe pas de l’état du service à qui la commande est destinée. Expéditeur et destinataire font chambre à part. Une fois que le service expéditeur a transmis la commande à la file d’attente, ce dernier peut se remettre au travail, quel que soit l’état du service destinataire.
  • Le service qui reçoit les commandes peut les traiter à son rythme, selon sa propre cadence. De notre côté, nous pouvons allouer plus ou moins de ressources au service destinataire, selon le nombre de commandes qu’il doit traiter.
  • Petit plus : nous pouvons isoler facilement les messages en échec, ce qui nous permet ensuite de les traiter à la main (nous y reviendrons sans doute dans un prochain article).

La transmission continue

Considérons maintenant notre second cas de figure : une fois que le client a signé son contrat, le service Contracts doit faire signe au service Customers pour que ce dernier active le compte du client qui vient de signer.

Nous pourrions utiliser une file d’attente ici aussi, mais comme nous en avons déjà parlé, nous tenons à éviter les dépendances récursives. Par conséquent, Contracts ne sait pas ce qui se passe chez CustomersContracts ne peut donc pas envoyer la commande ActivateCustomer à Customers, car Contracts ignore tout de cette commande et de son destinataire.

La piste de la file d’attente étant exclue, nous avons résolu ce second problème en instaurant un mécanisme de messagerie continue : des flux (streams). Qu’est-ce qu’un flux ? Voyez un flux comme une liste (ordonnée) d’évènements qui peuvent être consommés en temps-réel, mais qui sont aussi enregistrés quelques part, de sorte qu’ils sont disponibles à tout moment, sur un registre. Contrairement aux files d’attentes, qui contiennent des commandes éphémères et disparates, les flux racontent une histoire cohérente.

Image de l'article

Chacun des services que nous utilisons chez Memo Bank diffuse un flux d’évènements. Les flux d’évènements diffusés par nos services permettent de retracer le cycle de vie de chacun de nos services. Que nos évènements soient d’emblée utiles ou non, qu’ils nous servent tout de suite ou pas, nous les intégrons dans tous nos développements. C’est pour nous une routine, au même titre que les tests et le suivi des indicateurs.

Ainsi datés, enregistrés et consignés, nos évènements forment un journal de bord immuable, inaltérable, une source de vérité. Et comme ils font aussi partie de l’API du service qui émet les commandes, nos évènements peuvent être consommés par d’autres services, que ce soit en temps-réel (un par un), ou à une date ultérieure (pour remonter le fil… des évènements). À quoi servent les évènements ainsi consignés dans un registre ? À garder une trace des différents états de nos différents services et à en extraire des données que nous pouvons ensuite analyser.

En résumé :

  • les files d’attente nous servent à envoyer des commandes à des services spécifiques ;
  • les flux d’évènement nous permettent d’exposer les différents états par lesquels passe un service en particulier.

Tout dépend des dépendances

En regardant bien les flèches du diagramme précédent, vous vous demandez peut-être si nous n’avons pas introduit une dépendance cyclique entre le service Customers et celui de Contracts. La réponse est non. Ouf. Une dépendance n’est pas déterminée par le sens de circulation des données, mais par la connaissance que les différents services ont les uns des autres. Ici, le service Customers voit ce qui se passe dans le service Contracts. Mais la réciproque n’est pas vraie. Contracts ignore ce qui se passe chez Customers. Et pour cause : Contracts n’a pas besoin de savoir qui lui envoie des commandes, pas plus qu’il n’a besoin de connaître les services qui peuvent voir ce qui se passe chez lui.

Image de l'article

L’API de Contracts contient à la fois la file d’attente et le flux d’évènements. Le service Customers dépend de cette API. En matière d’API, les noms donnés aux commandes et aux évènements ont leur importance. Pour être cohérents d’une API à une autre, nous utilisons toujours :

  • L’impératif pour les commandes, les ordres. Par exemple : SendContract ;
  • Le participe passé pour les évènements, les faits. Par exemple : ContractSent.

Cette nomenclature, cette convention dans l’attribution des noms, s’accorde bien avec l’architecture de notre Core Banking System, une architecture basée sur CQRS/ES : les commandes restent les mêmes, et les évènements sont les faits.

Comment déterminer la direction de la dépendance

Tout en respectant les principes énoncés plus haut, nous aurions pu opter pour la solution suivante — au moins aussi valide que la nôtre.

Image de l'article

Si plusieurs solutions existent, sans que l’une d’entre elles soit préférable aux autres, laquelle choisir ? C’est vous qui voyez. Tout dépend de la direction, du sens de la dépendance que vous souhaitez instaurer, instituer.

Image de l'article

Voici les questions que nous nous posons généralement dans ce genre de cas :

  • Est-ce que l’un de nos services peut être agnostique vis-à-vis des autres services ? Dans notre exemple précédent, du moment que le service Contracts reçoit des commandes, il n’a pas à s’intéresser aux services qui font appel à lui, il peut être agnostique. En revanche, Customers ne peut pas se permettre d’être agnostique, indifférent, car Customers ne peut pas se désintéresser des contrats. Ici, notre préférence va donc à la solution A.
  • Que se passe-t-il si l’un des services est un service tiers, externe ? Toujours dans notre exemple, Memo Bank n’aurait aucun intérêt à externaliser le service Customers. En revanche, nous pourrions décider d’externaliser le service Contracts. Là encore, A l’emporte.
  • Est-ce que l’un des services pilote d’autres services ? Ici, nous tenons à éviter le phénomène d’orchestration implicite — explicité dans cette présentation de Bernd Ruecker. Ajouter un client est une procédure complexe qui implique plusieurs services : envoyer des e-mails, des notifications, créer un IBAN, et ainsi de suite. Par conséquent, le service Customers joue le rôle de pilote ici, celui de chef d’orchestre. Faire en sorte que le service pilote dépende des services qu’il orchestre — et pas l’inverse — facilite la lecture du code ici, car la procédure toute entière se trouve à un seul et même endroit. Ici aussi, avantage à A.
  • Est-ce que la solution envisagée crée un cycle au sein de l’architecture ? Même s’il n’y a pas de lien apparent entre les deux services, ils dépendent sans doute tous les deux d’autres services. Admettons que le service Customers dépende du service Users, qui dépend quant à lui du service Contracts. Si nous optons pour la solution B, nous nous retrouvons avec un cycle entre les 3 services mentionnés. Une fois de plus, A semble préférable.

Conclusion

Quand on se lance dans la conception d’une architecture orientée service, mieux vaut penser très tôt à la circulation des messages entre les différents services qui composent l’architecture. Utiliser HTTPS et REST semble être la solution la plus simple pour commencer, mais elle n’est pas dénuée de limitations. C’est la raison pour laquelle nous avons ajouté les files d’attente (queues) et les flux (d’évènements) à notre boîte à outils. Pour le reste, nous nous en tenons à deux principes simples.

Premier principe : chaque service doit diffuser des évènements dès que son état change, même si les évènements en question ne sont pas utiles au moment où ils sont introduits. À la manière d’une boîte noire, les évènements enregistrés doivent permettre de remonter le fil du temps, pour reconstituer les différents états au travers desquels chaque service est passé, comme ContractSent ou ContractSigned. Ces flux contribuent à renforcer la traçabilité de nos services — une obligation que nous avons en tant que banque — mais ils permettent aussi de consolider l’API de chaque service, ce qui rend le système plus facile à manipuler pour toutes les équipes.

Second principe : tout dépend des dépendances, tout repose sur elles. Les dépendances donnent forme au système, influencent son fonctionnement, mais elles peuvent aussi aller jusqu’à causer sa perte — dans le cas des dépendances cycliques. Une fois que les dépendances sont en place, bien en place, les outils à utiliser s’imposent d’eux même pour faire circuler l’information dans les directions qui conviennent.

Image de Joan Zapata

Joan Zapata

Rédacteur

Logo MemoBank
Logo Memo Bank