Architecture modulaire, microservices : on en est où ?

Les principes de l’architecture modulaire ne sont pas vraiment nouveaux. Par contre, l’implémentation de cette architecture dans les SI devient monnaie courante. En effet on voit aujourd’hui les fameux microservices fleurir un peu partout. Ce qui était un buzzword il y a quelques années est devenu la norme sur beaucoup de nouveaux développements. Ce billet dresse un constat sur les pratiques rencontrées dans les équipes, du côté métier et du côté technique.

Adoption des microservices

Cet article m’a été inspiré au départ par un sondage réalisé par O’Reilly en Juillet 2018 : The State of Microservices Maturity. Le sondage porte sur des entreprises ayant déjà implémenté des microservices dans leurs développements. Le rapport rend compte à la fois des aspects techniques liés aux microservices, du succès des projets, ainsi que des contraintes pour le métier. On y apprend notamment que pour les entreprises interrogées :

  • Les architectures microservices sont largement adoptées sur les nouveaux projets, ainsi que les technologies nécessaires : conteneurs, intégration continue, tests automatisés…
  • Les microservices sont considérés en majorité comme des succès
  • Le découpage des microservices selon le périmètre métier n’est pas fait correctement.

Il est assez étonnant de constater qu’un élément primordial, la gestion des bounded contexts, n’est pas abouti alors même que l’adoption est massive et couronnée de succès !

 DDD, Bounded context ?

La notion de bounded context (contexte délimité ?) vient du Domain-Driven Design (DDD).

Il s’agit de définir clairement les contours d’un domaine métier. Un domaine important sera séparé en différents sous-domaines. Ils peuvent parfois interagir les uns avec les autres, permettant l’aboutissement de processus métier impliquant plusieurs domaines.

Appliqué aux microservices, il s’agit « simplement » de spécifier un périmètre métier cohérent et unifié pour chaque service : pose de la problématique métier, objets et vocabulaire, comportements, interactions. Ce travail permet de mettre du sens sur les mots : un terme générique comme « client » peut avoir une signification très différente selon le contexte. Voici un exemple simpliste de 3 bounded contexts pour illustrer le propos :

Ce travail est fondamental : la conception des microservices découle des bounded contexts. C’est ce qui garantit que les services conçus sont autonomes et indépendants. Si les contextes définis sont trop fins et trop couplés, il faudra livrer régulièrement plusieurs services en même temps, ce qui est très coûteux. On parle alors de nanoservices.

Le métier d’abord !

On l’aura compris, le point de départ d’une architecture microservices est un travail métier et non technique ! On doit avoir une isolation fonctionnelle claire pour chaque composant. Il semble que cet élément est souvent trop peu travaillé.

Il est nécessaire de faire des ateliers impliquant des personnes de différentes équipes, des experts métier et des développeurs. Il faut installer de nouvelles pratiques comme par exemple les ateliers d’event storming.

N’oublions pas que ce qui tourne en production est le code écrit par les développeurs. Le code doit donc correspondre au maximum avec le métier que l’on cherche à aider. En bref, les concepts du DDD (Domain-Driven Design) devraient être adoptés plus massivement.

 Cohérence oui, Dépendance non

Un des gros risques en modélisant de nouveaux systèmes modulaires, est la tentation de grouper les services dans une roadmap globale et de faire dépendre fortement les services entre eux. Certes, il y a forcément des interactions entre les composants. Mais chaque équipe doit pouvoir avancer sans dépendre du travail des autres. Il faut préserver l’autonomie de chaque composant et des équipes associées. Pour cela on pourra :

  • Concevoir chaque service comme pouvant fonctionner en mode seul au monde (le cas nominal peut être rendu sans dépendance externe)
  • Utiliser des mocks (bouchons) pour les services externes non développés.
  • Limiter les échanges de données en prenant en compte le contexte. Par exemple, un client dans le service panier n’a probablement pas besoin de tous les champs du référentiel.
  • Eviter l’utilisation de librairies partagées.

La cohérence de l’ensemble est garantie par le respect des interfaces d’entrée/sortie de chaque service.

Alternative aux microservices : le monolithe modulaire

Une piste assez peu explorée est celle du monolithe modulaire. Mais c’est quoi exactement ? Pour produire un monolithe modulaire, il faut isoler les domaines métier comme on le ferait pour des microservices. Les deux différences notables sont que tout est dans la même base de code, et qu’on a une seule unité de déploiement. Mais la tâche est bien plus ardue qu’il n’y paraît. Pour implémenter un monolithe modulaire, il faut appliquer les principes suivants :

  • Au préalable, délimiter les domaines métier « sur le papier » (bounded contexts)
  • Délimiter les domaines métier dans le code : utiliser l’approche package by feature. Tout le code lié à un domaine est isolé.
  • Définir pour chaque domaine des interfaces publiques pour interagir avec les autres : API (au sens interface, pas HTTP)
  • Interdire les dépendances entre les domaines. On interagit uniquement avec les interfaces publiques ; on ne mélange pas les objets de différents domaines.

Au niveau des communications entre services, on n’a plus besoin de la couche réseau/protocole. Celle-ci est nécessaire dans une architecture microservices afin de transporter les données entre les services, sur le réseau. Dans le monolithe, les services communiquent directement entre eux, par exemple au sein de la même machine virtuelle Java. Cela simplifie grandement l’implémentation des interactions. En voici une illustration : monolithe modulaire versus microservices

C’est une approche très intéressante mais qui demande une grande rigueur de la part de l’équipe de développement. Voici le retour d’expérience de Shopify sur le sujet.

 Point de vue technique

 Communication entre composants : HTTP m’a tuer

L’API REST HTTP est devenue la star des systèmes d’information. Les approches API first et open API sont aujourd’hui largement répandues. A tel point que de nombreuses personnes utilisent les termes « microservices » et « API » indifféremment pour parler de la même chose.

Or, le protocole HTTP ne devrait pas être le moyen de communication par défaut entre les composants d’une architecture modulaire. Les API HTTP sont un bon moyen d’exposer des données ou des commandes vers l’extérieur. Cependant, quand il s’agit de faire communiquer des services entre eux au sein d’un process, c’est une technologie relativement peu adaptée. Le protocole HTTP est synchrone et point-à-point ; il introduit un couplage fort entre les composants. Il nécessite des éléments techniques supplémentaires : API management, service discovery, gestion des authentifications… Enfin, quand on cumule des services trop couplés, et une communication omniprésente par HTTP : on arrive au syndrôme du plat de spaghetti !

ça a l’air bon, mais pas pour mon architecture !

Cela arrive quand un geste métier, comme un clic sur un site web, a pour conséquence de nombreux appels HTTP envoyés vers différents composants. Mécaniquement, la disponibilité de l’application diminue. Il faut intégrer des mécaniques de retry, circuit breaker

Dans la mesure du possible, on privilégiera une approche événementielle pour implémenter les process : l’architecture orientée événements.

 Gestion des contrats d’événements

Dans une architecture modulaire, si on a opté pour une approche événementielle, il faut prendre un soin particulier sur les échanges de données. Chaque message émis doit avoir un contrat clairement défini. Les contrats peuvent être spécifiés de différentes façons et sérialisés en différents formats. Par exemple, Apache AVRO permet une sérialisation binaire de taille très légère, et un versionning du contrat. Le JSON schema aura l’avantage de permettre la production d’échanges au format JSON, ce format étant omniprésent aujourd’hui.

Quelque soit l’outil choisi, on veillera à implémenter le principe de robustesse :

  • Le consommateur d’un message lit uniquement les données qui l’intéressent, et ignore ce qu’il ne connaît pas
  • tolerant reader : le consommateur doit accepter une donnée même si le format n’est pas celui attendu, tant qu’il est compréhensible
  • l’émetteur d’un message respecte toujours le contrat : pas de suppression de champ par exemple.

 Les développeurs et le dogmatisme

Quelques petites phrases entendues sur le terrain :

« C’est pas un microservice… il est trop gros ! »

Le terme « microservice » est mal choisi, à mon sens. Il sous-entend que le service doit être petit (voire très petit). En réalité, la taille de la base de code n’a pas d’importance. La seule chose essentielle est que le service ait une responsabilité métier unique, clairement définie. Ensuite, selon la complexité du domaine, on peut avoir des services développés avec très peu de lignes de code et d’autres beaucoup plus volumineux.

« Ah non ! Ton batch ne peut pas interroger la base directement : elle appartient au microservice machin… »

Certes, une des premières choses qu’on apprend sur le sujet est qu’un microservice dispose de sa propre base de données, et lui seul peut y accéder. Cependant, à l’intérieur d’un domaine, on fera parfois émerger un composant technique supplémentaire pour pouvoir le scaler de façon indépendante. Par exemple, un service de notification, ou un batch de traitements de données. Si les composants sont fonctionnellement dans le même domaine, et maintenus par la même équipe, il n’y a pas de contre-indication justifiée au fait qu’ils partagent la même base de données.

« On fait une nouvelle brique ? »

C’est le développeur qui trouve ça génial et qui veut faire un nouveau composant, pour la moindre nouvelle fonctionnalité… 😀

 Conclusion

L’architecture modulaire permet à des équipes différentes de travailler en autonomie sur des composants distincts. Même si la mode est aux microservices, le monolithe modulaire est une alternative à étudier quand on n’a pas des besoins particuliers de mise à l’échelle.

La plus grosse difficulté est de faire correspondre les problématiques métier avec l’implémentation concrète dans les développements. On peut s’appuyer sur les principes du domain-driven design pour améliorer cela. L’event-storming permet par exemple de définir les domaines métier, ainsi que d’aligner les développeurs avec les experts du métier. Il facilite également l’adoption des architectures orientées événements, et du coup l’indépendance des briques applicatives.

RSocket, le protocole réactif

rsocket logo

RSocket (pour Reactive Socket) est un nouveau protocole de communication. Il spécifie des façons d’échanger des messages au format binaire entre applications. C’est un protocole de niveau applicatif qui permet des communications correspondant aux besoins modernes : push de données, échanges bi-directionnels, reprise de connexion, asynchronisme…

Il est conçu pour être utilisé autant pour de la communication de serveur à serveur, que serveur à périphérique (smartphone, navigateur web etc.).

Le protocole est open-source. Créé au départ par Netflix, il est désormais supporté par Facebook, Pivotal et Netifi. Il doit être intégré prochainement dans le framework Spring (cf issue). La spécification du protocole est actuellement en version 0.2 mais la release est proche, cette version étant considérée comme une release candidate 1.0. Rentrons un peu dans le détail !

 Les points clé de RSocket

Reactive streams – le contrôle des flux

La conception de RSocket s’appuie sur le manifeste réactif et la spécification Reactive Streams. Il s’agit d’implémenter des systèmes asynchrones et non bloquants, mais pas uniquement.

Un des apports fondamentaux apporté par le dogme réactif est la backpressure. Ce paradigme permet à un consommateur de données dans un système, d’informer les autres applications qu’il est surchargé. Les producteurs de cette donnée doivent assimiler cela et ne pas surcharger le flux. Le consommateur peut également choisir de consommer les données au rythme qu’il veut. Le but est de produire un sytème résilient sans devoir implémenter des mécanismes complexes de type circuit breaker.

RSocket introduit ces éléments dans sa spécification, le rendant indiqué pour implémenter des applications réactives.

Autres caractéristiques

  • RSocket est indépendant du transport sous-jacent. Actuellement il peut fonctionner avec TCP, WebSockets, Aeron, ou HTTP/2. Typiquement on choisira TCP pour des échanges de serveur à serveur, et Websocket pour navigateur à serveur.
  • Reprise de connexion. Si une connexion est coupée entre les 2 participants, cela peut être problématique pour les communications de type « longue durée » (abonnement à un flux de données). Le protocole fournit les moyens de reprendre la discussion au même endroit dans une nouvelle connexion, grâce à une notion de position implicite.
  • Rsocket est un protocole de haut niveau. Le but recherché par les créateurs est de fournir des impémentations directement utilisables au sein des applications. Ces librairies sont disponibles dans différents langages de programmation.
  • Les échanges sont au format binaire, afin de maximiser les performances et d’optimiser les resources. Cela peut rendre plus difficile le debug des messages. Cependant, c’est totalement cohérent quand on pense que l’immense majorité des échanges se font entre 2 machines et ne sont pas lus par un humain. Les applications devront donc implémenter la sérialisation et désérialisation de leur format natif vers du binaire.

 Modes d’interaction

La base du protocole tient dans les différents modes d’interaction proposés. RSocket fournit 4 modes distincts :

  1. Fire-and-Forget : requête unique, pas de réponse envoyée par le serveur
  2. Request/Response : « HTTP-like » : 1 requête, 1 réponse.
  3. Request/Stream : requête simple, réponse multiple. Le serveur renvoie une collection (stream) en réponse. Ce n’est pas une liste figée mais bien un flux qui arrive au fil de l’eau.
  4. Channel : échanges bi-directionnels. Les 2 participants s’envoient des flux de messages l’un à l’autre.

Ces modes d’interaction ont été pensés pour répondre aux besoins actuels des applications. Ainsi, le push de données est supporté par le mode request/stream. Cela permet par exemple, de gérer un flux d’informations à recevoir continuellement, sans avoir besoin de requêter plusieurs fois le serveur. Le mode fire-and-forget, avec sa requête unique sans réponse, permet d’optimiser dans des cas où la réponse peut être ignorée. Le mode channel implémente un dialogue complet entre deux composants.

Ces différents modes ainsi que les points clés listés ci-dessus sont le socle de RSocket.

 Les implémentations

A ce jour le protocole a des implémentations en Java, Javascript, C++ et Kotlin. Voyons un peu comment cela marche en Java dans la section suivante.

Exemples en java

L’implémentation en Java est basée sur la librairie Reactor. Au niveau du transport nous allons utiliser ici le transport TCP via le framework Netty. Les 2 dépendances suivantes sont suffisantes pour commencer à implémenter RSocket dans une application : io.rsocket:rsocket-core et io.rsocket:rsocket-transport-netty.

Démarrons un serveur en local :

RSocketFactory.receive()
    .acceptor((setup, socket) -> Mono.just(new AbstractRSocket() {})) // ne fait rien
    .transport(TcpServerTransport.create("localhost", 7000))
    .start()
    .subscribe();

Ce serveur ne va rien faire car on n’a pas spécifié de comportement concret sur la méthode acceptor. Il faut fournir une implémentation des interfaces SocketAcceptor et RSocket afin de déterminer ce que fait le serveur quand il reçoit un message. Il est intéressant de regarder l’interface RSocket pour constater qu’elle demande l’implémentation des 4 modes d’interaction évoqués plus haut :

public interface RSocket extends Availability, Closeable {
  // [...]
  Mono<Void> fireAndForget(Payload payload);
  Mono<Payload> requestResponse(Payload payload);
  Flux<Payload> requestStream(Payload payload);
  Flux<Payload> requestChannel(Publisher<Payload> payloads);
  // [...]
}

Prenons l’exemple d’un service de streaming de « news ». Lorsque le serveur reçoit une requête d’un client, il va envoyer un flux continu d’actualités qui se met à jour sans nouvelle requête. Nous allons devoir implémenter la méthode requestStream pour gérer cette interaction. La classe Payload est la classe qui représente un message binaire qui transite sur la connexion ; il faut donc effectuer les transformations entre les objets métier et ce type. Voici donc à quoi peut ressembler une implémentation du flux côté serveur :

SocketAcceptor socketAcceptor = (setup, sendingSocket) -> {
    return Mono.just(new AbstractRSocket() {
        @Override
        public Flux<Payload> requestStream(Payload payload) {
            return newsProducer.streamNews(payload) // service métier qui fournit le flux de données en fonction de la requête
                    .map(NewsMapper::toByte)        // sérialisation de l'objet métier
                    .map(DefaultPayload::create)    // creation du payload (methode fournie par l'implémentation rsocket-java)
                    ;
        }
    });
};

Sur le même exemple, créons la socket et utilisons là pour que le client puisse interroger le serveur et récupérer les news :

RSocket clientSocket = RSocketFactory.connect()
        .transport(TcpClientTransport.create("localhost", 7000))
        .start()
        .block();
        
clientSocket
    .requestStream(DefaultPayload.create("Donne moi les news s'il te plait"))
    .map(Payload::getData)            
    .map(NewsMapper::fromByteBuffer)  // désérialisation du message vers l'objet métier
    .doOnNext(newsConsumer::readNews) // appel du service métier de lecture des news reçues
    .doFinally(signalType -> clientSocket.dispose())
    .then()
    .block();

Ces quelques exemples démontrent que l’on peut utiliser RSocket dans une application Java très simplement. Cela nécessite en amont l’adoption de la programmation réactive.

Netifi proteus

Proteus est une plateforme basée sur RSocket. Elle fournit un broker auquel les applications vont se connecter, le broker se chargeant des échanges entre les applications. Il gère le routage entre les services, la sécurité, le load balancing. Une console web permet de visualiser et d’administrer la plateforme. Comme souvent, on dispose d’une version communautaire open-source avec les fonctionnalités de base, et la version enterprise contient des fonctionnalités avancées (connecteurs, métriques, alerting, …)

Les échanges sont encodés à l’aide de Protobuf. Ceci permet de spécifier les contrat d’échanges : mode d’interaction, types d’entrée/sortie, etc. Les interfaces client/serveur sont ensuite générées, et il suffit de les implémenter pour écrire notre logique métier. Proteus se charge de la sérialisation des objets et de la communication via RSocket.

Afin d’illustrer Proteus, voici quelques copies d’écran de la console web.

Gestion des brokers :
console web proteus

Statut des services connectés :
console web proteus

L’outil est intéressant mais semble encore un peu limité. Il est par exemple impossible d’envoyer un message à partir de la console, ce qui serait très pratique en développement. Je l’ai trouvé également assez lourd à démarrer via docker, sachant que c’est uniquement un « passe-plat » et qu’il ne stocke pas les messages. Les librairies sont pour l’instant disponibles pour Java, Javascript et Spring Framework mais d’autres devraient arriver prochainement. A suivre donc !

Pour conclure

Dans des systèmes de plus en plus distribués et découplés, les échanges de message asynchrones deviennent un standard de communication entre les applications. RSocket s’inscrit dans cette logique mais se démarque en apportant les principes réactifs au niveau du protocole de communication.

Supporté par des grandes entreprises du numérique, son adoption en sera peut-être facilitée. A suivre…