Sélectionner une page

Préambule

Cet article est le troisième d’une liste de 5 chapitres, concernant l’architecture logicielle, afin de vous aider à analyser vos besoins, et à choisir l’architecture la plus adaptée pour réaliser votre projet.

  1. L’architecture Monolithique
  2. L’Architecture Orientée Service ou « Service Oriented Architecture (SOA) »
  3. L’Architecture Orientée Événement ou « Event Oriented Architecture (EOA) »
  4. Architecture Moderne : Les concepts essentiels
  5. Architecture Moderne : Exemple de création d’un nouveau projet

Lire l’article Architecture Logicielle – Chapitre 2 – L’Architecture Orientée Service est recommandé pour une meilleure compréhension du présent article.

Introduction

Cette Architecture, de plus en plus utilisée, vient souvent compléter l’Architecture Orientée Service. Comme cette dernière, l’Architecture Orientée Événements introduit également la notion de service, un service étant un composant/module de notre application, développé séparément.

L’Architecture Orientée Service nécessite la mise en place d’APIs pour que les services puissent communiquer. Les traitements sont donc généralement synchrones. Mais pour une Architecture Orientée Événements, les services communiquent par l’intermédiaire d’un Message Broker. Ici, une nouvelle notion est importante, celle d’événement.

Un événement est un message, qui sera transmis d’un service à un autre. Un message peut même être transmis à plusieurs services. Ces messages sont stockés dans un espace prévu à cet effet. Ensuite, ils sont extraits par d’autres services.

Le but d’un événement est d’attester d’une action passée. Comme cette action est passée, l’événement est immuable. On nomme généralement les événements avec un participe passé (ex: UserRegistered, RegistrationMailSentToUser, etc…), et de la manière la moins sujette à interprétation.

Ce sont à ces événements que les services vont s’abonner. Le but est de définir le traitement à effectuer, lorsqu’un événement se produit.

Message Broker

Un Message Broker est un système permettant d’acheminer des messages d’un service à un autre. Il en existe de nombreux, parmi lesquels ont retrouve généralement RabbitMQ et Kafka. Nous verrons plus tard comment fonctionne RabbitMQ.

Un message broker est indispensable dans le cas d’un traitement asynchrone. En effet, pour permettre un traitement différé, une requête doit être stockée afin de pouvoir être extraite par un/plusieurs services plus tard.

Indispensable est un grand mot, mais on peut au moins dire qu’il est nécessaire pour implémenter un traitement asynchrone performant. En effet, on pourrait reproduire ce fonctionnement “à la main” en stockant un message dans un fichier txt par exemple, ou bien dans une base de données. Mais l’utilisation d’un message broker permet de gérer de nombreux aspects importants tels que la répartition des messages, l’acquittement, etc…

RabbitMQ

RabbitMQ est un message broker basé sur le protocole AMQP.

AMQP est un protocole de communication très intéressant dans le monde du microservice. Contrairement à un protocole comme HTTP, AMQP vient ajouter une étape intermédiaire, permettant de faire du traitement asynchrone.

Pour une application Web classique, HTTP est parfaitement adapté pour afficher une page d’accueil par exemple (le serveur ne mettra pas longtemps à répondre). Mais dans certains cas, le traitement “backend” peut être bien plus coûteux (plusieurs secondes, plusieurs minutes, voir plusieurs jours !). Exemples : résoudre une opération mathématique complexe, envoyer plein d’e-mails, purger la base de données, archiver des logs, etc…

Avec HTTP, le client (celui qui crée la requête) attend la réponse du serveur (celui qui traite la requête). AMQP va ajouter une étape supplémentaire : au lieu de traiter directement la demande, on la stocke dans une file d’attente (une queue). C’est généralement un service qui tourne en arrière plan (Worker/Daemon) qui va traiter la demande. Ainsi, le client est rapidement libéré puisqu’il n’attend pas forcément le résultat de la requête.

Différence de fonctionnement entre HTTP et AMQP

Le fonctionnement d’AMQP est simple : il se base sur un système de queues. Des Producers vont créer des messages (ex: “L’utilisateur X a supprimé l’article Y”, ou “Envoyer le message X à tous les utilisateurs Y par e-mail”), et les stocker dans des queues. Ces messages vont ensuite être consommés/extraits par des Consumers.

C’est un peu comme une équipe de développement : le chef de projet crée de nouvelles tâches, et il peut en ajouter sans se préoccuper de celles existantes; ils les ajoutent les unes à la suite des autres. Dès qu’un développeur termine une tâche, il prend la suivante dans la liste des tâches. Tous les développeurs peuvent travailler en même temps sur une même liste de tâches, et plus ils sont nombreux, plus vite la liste de tâche se videra. Mais ils peuvent également avoir leur propre liste de tâches attitrées.

Dans cette analogie, le chef de projet est un Producer, les développeurs sont des Consumers, et la liste de tâches est une queue.

Fonctionnement basé sur une liste de tâches avec plusieurs Consumers

Exemple

Prenons maintenant un exemple concret d’application qui utiliserait ce protocole. On utilisera le même exemple que dans le chapitre précédent (interface de création d’e-mail “MailBox”, et service d’envoi d’e-mail “MailSender”).

  1. Notre service “MailSender” est un daemon (il tourne non-stop en arrière plan). Ici, on pourrait déployer autant d’instances qu’on le souhaite (de manière à pouvoir envoyer plusieurs e-mails simultanément).
  2. Le service “MailSender”, lorsqu’il démarre, va faire de l’attente active. C’est-à-dire qu’il va attendre qu’un message arrive dans la queue “email” pour faire quelque chose. Exemple :
// ...
while (1) {
    $message = json_decode($this->getMessage('mail'), true);
    // return string|null depending if there's a message or not

    if (null !== $message) {
        $this->sendMail(
            $message['receiver'],
            $message['subject'],
            $message['content']
        );
        // Do whatever you want with the mail
    }
}
// ...
  1. Le service “MailSender” est lancé (ou plusieurs)
  2. Notre service “MailBox” est une application Web classique. Lorsque l’utilisateur saisit les données via le formulaire, et qu’il valide ce dernier, le service va envoyer les informations via AMQP. Exemple :
$this->sendAmqpMessage('mail', json_encode($_POST));
  1. Un utilisateur accède à “MailBox” via son navigateur. Il valide le formulaire.
  2. Le navigateur envoie une requête HTTP au serveur, qui est traitée par l’application/service “MailBox”.
  3. Le service “MailBox” envoi les informations (destinataire, objet, contenu du mail) au format JSON via AMQP dans la queue “email”.
  4. Dès que le service “MailSender” passera par la ligne “$this->getMessage(…)”, le message sera “extrait” de la queue “email”.
  5. Le service “MailSender” pourra ensuite traiter la demande, et envoyer le contenu du message par e-mail.
Exemple d’une application d’envoi d’e-mail basée sur AMQP

Avec AMQP, le service “Mail Box” pourrait répondre automatiquement “le mail va être envoyé” au lieu de “le mail a été envoyé”, et l’utilisateur pourrait continuer à utiliser l’application, et écrire d’autres e-mails par exemple. Ensuite, lorsqu’une instance du service “MailSender” sera disponible, il pourra prendre en charge la demande d’envoi d’e-mail.

Cet exemple est basique, et permet aux différents Workers de se répartir les tâches. Mais des services différents peuvent avoir leur propre copie d’un même message. En effet, un Producer envoie un message une seule fois, mais les Producers peuvent être de nature différente et pour certains, travailler en collaboration, ou chacun de son côté. Ainsi, 2 Consumers peuvent dépiler les messages d’une même queue, tandis qu’un troisième Consumer de nature différente peut dépiler les mêmes messages de manière indépendante des autres Consumers, et ce afin de générer des logs, des statistiques, etc… (cf. Tutoriel sur RabbitMQ pour voir les différents types d’exchange).

Avantages

  • Optimisation de l’expérience utilisateur : les traitements longs sont non bloquants
  • Évolutivité et Flexibilité : ajouter un nouveau service n’impacte pas les services existants

Inconvénients

  • Plus complexe : nécessite d’apprendre à utiliser AMQP et à travailler en asynchrone

Conclusion

AMQP est très intéressant. Il permet de concevoir une architecture orientée événement (Event Oriented Architecture), et donc pallier à de nombreux problèmes engendrés par une architecture orientée service.

Il existe des alternatives au protocole AMQP, permettant également de concevoir une architecture orientée événement, avec des solutions telles que Kafka. Mais ces alternatives ne seront pas détaillées ici.

CQRS (Command Query Responsibility Segregation)

Ce pattern définit la notion de “Command” et de “Query”. Une “Command” est une action visant à effectuer des modification, alors qu’une Query est une action visant à obtenir des données. D’un point de vue CRUD, cela revient à :

  • Query = Read
  • Command = Create + Update + Delete (mais aussi tout le reste. Ex: envoyer e-mail)

Implémentation de la couche “Command”

Les services implémentant la couche “Command” résident dans la partie “Domaine” de l’application. Ce sont ces services qui connaissent la business logic. En gros, c’est le cœur de votre application. Pour que CQRS soit respecté, un service de la couche “Command” ne doit en aucun cas permettre d’effectuer une action telle que “getArticleContent()”, ou “listCities()”, mais uniquement des actions telles que “orderArticle(1)”, ou “createNews(…)”.

Ces deux derniers exemples montrent qu’une action, sur la couche “Command”, est abstraite, et peut avoir un effet conséquent sur l’application. La création d’une news par exemple va certes enregistrer la news en base de données, mais elle peut également entraîner l’envoi de la news par e-mail aux inscrits, s’ajouter dans un flux RSS, et bien d’autres.

Bien sûr, un service “Command” a le droit (et le besoin) d’effectuer des requêtes de type “Read”. Mais il n’expose pas ces requêtes. Celles-ci doivent faire partie d’un processus de type “Command”. En effet, la “Command” possède la source de l’information. Quoi que disent les Queries, les Commands sont les seules sources fiables à 100%. Les Queries ne peuvent posséder que des copies, potentiellement fausses (le temps qu’une Command transmette les informations aux Queries).

Ainsi, un service “Command” pourra mettre en place une API pour permettre d’effectuer des actions telles que :

  • POST /user
  • POST /send_mail

Mais elle peut aussi, en plus ou à la place, se baser sur des événements pour déclencher ce type de traitement. Exemple :

  • onOrderCreated -> bookArticle
  • onPaymentCreated -> setOrderStatusAsPaid

Implémentation de la couche “Query”

Cette couche est plus simple. Elle doit répondre au cas d’utilisation “Retourner les informations demandées, de manière optimisée”. Son but principal est de fournir aux IHM une API permettant d’accéder aux données. Ces services ignorent tout de la business logic, et ont comme simple rôle de récupérer, agréger, et retourner les informations demandées.

Pour réaliser cette demande, nous avons 3 options :

  • Ces services pourraient effectuer des requêtes HTTP vers les services “Command”. Mais dans ce cas, cela signifierait que les services “Command” violerait le principe CQRS puisqu’ils répondraient à un besoin de “Query”.
  • Ces services pourraient partager leur base de données avec les services “Command”. Mais ça ne permettrait pas de respecter le “Database Per Service”.
  • Ces services pourraient posséder leur propre base de données, qu’ils alimenteraient eux-même en consommant des messages issues des services Command.

Bien sûr, la bonne solution à retenir est la dernière. Les messages que les services consomment sont issues de queues (cf. AMQP). Ainsi, un service “Query” peut posséder une base de données qui n’est que le reflet des données issues d’un ou de plusieurs services “Command”. Ces données sont redondantes, mais aussi et surtout optimisées.

Orchestration

Les Commands sont indispensables pour que le Back puisse fonctionner. Les Queries, elles, sont optionnelles pour le Back, mais indispensables pour le Front.

Une IHM, ou un service de type API, doit avoir accès aux données. Néanmoins, très souvent, elles agrègent ces données. Une page visant à lister toutes les commandes d’un utilisateur pourrait avoir besoin des informations suivantes : nom et prénom de l’utilisateur, date et articles des commandes, état des paiements, statuts des livraisons. On voit ici que ces informations sont probablement issues de différents services (ex: “Order”, “Payment”, “Delivery”, “User”).

Pour répondre à ce besoin, on va donc créer un service de type Query. Ce service possèdera sa propre base de données, contenant toutes les informations nécessaires. Ces données sont des copies des données réelles, lesquelles proviennent des Commands. Pour alimenter sa base de données, un service Query utilise généralement un Consumer, qui consomme des messages tels que “OrderCreated”, “PaymentCreated”, “OrderDelivered”, etc… De cette manière, ce service peut ajouter/modifier les données de sa propre base de données. Ainsi, l’API proposée par ce service pourra permettre une lecture directe des informations, adaptée à chaque cas d’utilisation.

Les services Commands, eux, ne doivent jamais faire appel aux Queries, pour la simple raison que leurs données sont basées sur des événements. Il s’agit d’une copie des données, laquelle n’est pas forcément à jour. Généralement, la mise-à-jour des données est extrêmement rapide (quelques ms) par rapport aux données d’une Command. Néanmoins, cette différence est réelle. De plus, une Command pourrait avoir modifié des données, avant d’émettre un événement. Cette modification ne serait donc pas encore connue par les Queries.

Avantages

Il est possible de créer une infinité de services “Query”, sans modifier les services “Command”. Chaque service “Query” permet d’optimiser les traitements de type “Read” en possédant une version optimisée des données pour répondre à la problématique. Un service “Statistics” et un service “History” pourraient tous deux posséder des données calculées, basées sur des événements émis par un service “Authentication”. Dans cet exemple, “History” consommerait tous les messages de type “user_created” pour alimenter sa base de données, et pourrait, au lieu de stocker la date de l’inscription d’un utilisateur, posséder uniquement une table avec les attributs “jour” et “nombre de nouveaux utilisateurs”. Le service “Statistics” lui pourrait consommer ces même messages, et alimenter une table “nouveaux utilisateurs par heure”, mais aussi une autre table “nouveaux utilisateurs par jour férié”, ou n’importe quoi d’autre.

Grâce à cette séparation, la performance de lecture de données n’est plus impactée par la complexité du domaine. Chaque service “Query” peut être indépendant des autres services “Query”, et fonctionner avec une structure plus adéquate pour répondre à la demande.

De plus, chaque service “Command” peut se concentrer sur l’implémentation du domaine, sans être impacté par les besoins des services des IHM.

Inconvénients

Cette solution est bien évidemment plus compliquée à mettre en place, et plus longue sur le court termes puisqu’elle nécessite de créer des service supplémentaires. Néanmoins, pour mettre en place CQRS, il est possible de créer un seul et unique service “Query” pour commencer, et le séparer en plusieurs parties plus tard.

Il faut être capable de s’interroger lors du développement sur les données “non synchronisées”. En effet, dans un tel modèle, les services “Query” ne sont pas exactement le reflet du business à un instant T. Le temps que les événements se propagent, et qu’ils soient traités, un service peut posséder une information qui n’a pas encore été répercutée sur un autre service. C’est le problème engendré par la redondance de données.

Event Sourcing

L’Event Sourcing est un autre patron d’architecture basé sur l’Architecture Orientée Événements. Celui-ci va plus loin encore, en définissant l’état d’une entité ou d’un agrégat comme étant le résultat d’une séquence d’événements.

Chaque modification d’une entité doit être accompagnée d’un événement. Cet événement, souvent nommé avec un participe passé (ex: CommentCreated, MailSent), doit représenter une modification de l’état d’un objet, ou un traitement venant d’être effectué.

Tous ces événements sont enregistrés dans une base de données appelée Event Store. Grâce à l’Event Store, on est capable de déterminer l’état actuel, ou passé, d’un objet.

L’analogie la plus fréquente utilisée est celle d’un système de transactions bancaires. Vous, en tant que client d’une banque, disposez d’un compte. Sur ce compte, vous pouvez visualiser l’historique de toutes les transactions effectuées. La somme présente sur votre compte n’est que la somme de toutes les transactions. De plus, si vous souhaitez connaître votre solde à une date précise, vous pouvez faire la somme des transactions, depuis le début, jusqu’à cette date.

Ces événements sont produits par des traitements, lesquels sont la conséquence d’une action, ou commande. Une commande est un type de message, destiné à décrire un ordre (ex: CreateUser). On les nommes de ce fait, toujours à l’impératif. Le but d’une commande est de déclencher une action, un traitement, ou la création/modification/suppression d’un ou de plusieurs objets.

Une fois la commande exécutée, ce sont des événements qui sont produits, et diffusés aux autres services.

Avantages

  • Sécurité : Primordial dans certains domaine, tels que la finance, ou médical. Dans ces cas là, il est indispensable de conserver un historique le plus complet possible.
  • Débogage : Si un problème se pose, on peut retracer les événements qui ont conduit à cette situation, et revenir à un état précis en rejouant les événements depuis le début jusqu’à un moment donné.
  • Reprise de données : Si une panne a eu lieu, on peut reprendre là où on s’était arrêté.

Inconvénients

  • Connaître l’état actuel d’une entité : on doit rejouer l’ensemble des événements pour calculer l’état actuel ou passé d’une entité, ou bien conserver l’état actuel des objets dans des bases de données ou en mémoire.
  • Style de programmation plus complexe à prendre en main.

Conclusion

Les événements, dans le cadre de l’architecture logicielle, apporte une complexité non négligeable au système. Néanmoins, ils apportent de véritables avantages tels que l’optimisation, ou une plus grande flexibilité.

Les concepts exposés ici n’ont pas pour vocation d’être implémentés à l’exactitude, mais plutôt de servir de base à une réflexion concernant l’architecture finale de votre projet. Vous n’êtes en aucun cas obligé d’utiliser CQRS ou l’Event Sourcing. Il s’agit simplement de principes de base pouvant vous aider à résoudre certaines problématiques. Dans tous les cas, vous devrez évidemment les adapter à votre projet.

Résumé

Architecture Orientée Événement

Application divisée en services, lesquels communiquent via un Message Broker, en produisant et en consommant des événements (messages).

Message Broker

Système permettant d’acheminer des messages d’un service à un autre, en les stockant dans un endroit prévu à cette effet, en vu d’être extrait par le service concerné à un moment donné.

AMQP (Advanced Message Queuing Protocol)

Protocol de communication reposant sur un système de queues, auxquelles les services vont pouvoir “s’abonner”, et sur lesquels ils pourront “publier” des messages.

RabbitMQ

Message Broker basé sur le protocole AMQP.

CQRS (Command Query Responsibility Segregation)

Création de « vues/agrégats » regroupant les données (potentiellement calculées) essentielles. Il s’agit d’une base de données optimisée et adaptée à une application précise.

Event Sourcing

Principe d’architecture reposant sur un « historique » des événements afin de gérer la communication entre services.

Aller au Chapitre 4 – Architecture Moderne : Les concepts essentiels