Overblog
Editer l'article Suivre ce blog Administration + Créer mon blog
Laurent COCAULT

Structures logicielles

Aucun projet de développement logiciel d’une certaine envergure ne peut faire l'économie de la question de la structure logicielle. L'adoption de plus en plus courante, voire systématique, des approches agiles sur ces projets peut laisser penser que la structure doit être purement émergente et ne doit faire l'objet d'aucune anticipation. Il s'agit là d'une erreur qui, à moyen terme, se paie au prix fort pour les équipes qui s'en laissent convaincre.

La question de la structure se pose à différents niveaux : au niveau du système et au niveau de ses composants. L’objet de cet article est de proposer un panorama des structures qu’on peut trouver au niveau des composants. Ainsi, certains styles d’architectures de niveau système tels que les « micro-services » ne seront pas évoqués ici. On notera néanmoins que les motifs de structure proposés dans cet article restent applicables individuellement à un micro-service dans une architecture de ce type.

Structures logicielles

Commençons avec la structure la plus simple qu’on peut qualifier de monolithe. Ce terme est généralement opposé à celui de micro-service ; on retiendra par exemple le conseil de Simon Brown qui nous invite à savoir concevoir des monolithes modulaires avant de concevoir des architectures à base de micro-services. Le terme de monolithe évoqué ici fait référence aux modalités de déploiement de la solution en suggérant qu’un système complet puisse être déployé comme un tout, indépendamment de sa structure interne. Dans cet article, le concept de monolithe fait référence à la structure interne des composants. D’un point de vue technique on pourra concevoir le monolithe comme un composant constitué d’un seul module (ou paquetage), d’une seule classe, voire d’une seule méthode. Une telle structure, ou plus précisément une telle absence de structure, est tout à fait acceptable, voire souhaitable, tant que la complexité du composant reste élémentaire. On trouve de nombreux exemples de code (tutoriels ou « hello world ») qui présentent cette structure pour des raisons de simplicité et de concision. Cet argument vaut pour le développement de solutions opérationnelles ; si on suit le principe KISS (Keep It Simple Studid), il est contreproductif de venir introduire des éléments de structure dans un composant qui propose un comportement trivial.

Pourquoi structurer un composant logiciel ?

Dès que le comportement visé suppose un volume de code plus conséquent il convient d’envisager une approche plus structurée. En effet, rester sur une approche monolithique avec un volume de code qui approche le millier de lignes de code pose plusieurs problèmes :

  • Lisibilité. En tant que développeurs, nous sommes lecteurs de code avant d’en être des écrivains. Il est donc essentiel de faciliter la lecture du code source. Or, cette lecture commence en général par la localisation d’une portion de code responsable d’un comportement du logiciel qu’on souhaite corriger ou étendre. Un monolithe de grande taille expose le développeur à un effort de lecture fastidieux pour cibler les quelques lignes qui l’intéressent dans un contexte donné. Au contraire, la structure doit proposer une grille de lecture de haut niveau qui dispense d’une lecture complète de la base de code pour identifier ces quelques lignes.
  • Représentation mentale. Idéalement, le développeur ne devrait pas avoir à lire quoi que ce soit pour identifier les lignes de code qu’il doit modifier, compléter ou supprimer. Il devrait lui suffire de faire appel à sa mémoire. Mais il est évident qu’on ne peut pas demander à un cerveau « standard » de mémoriser une base de code d’un millier de lignes de code, d’autant plus que la volumétrie d’applications professionnelles s’exprime plutôt en millions de lignes. Pour permettre à ce cerveau « standard » d’identifier la localisation de certaines lignes de code sans avoir à lire, il est nécessaire que ce code suive des conventions d’organisation, c’est-à-dire un ensemble de règles qui indiquent « où ranger » un élément selon sa nature et son rôle.
  • Testabilité. Le premier niveau de test que l’on considère quand on développe un logiciel est celui du test unitaire. Mais de quelle « unité » parle-t-on lorsqu’on fait référence aux tests unitaires. Cette référence à la notion d’unité suggère que le logiciel est structuré en constituants élémentaires qu’il est possible de tester individuellement. Si le composant n’est composé que d’une seule unité, la notion de test unitaire n’a plus de sens et on ne peut plus tester le logiciel que dans sa globalité.
  • Isolation des changements. Cette dernière problématique est certainement l’une des plus importantes et celle qui guide les principes d’élaboration des structures logicielles abordés dans cet article. En décomposant le logiciel en modules, on ne cherche pas seulement à rendre sa structure plus lisible mais également à circonscrire les modifications qu’on va apporter au logiciel. Autrement dit, si on doit changer un comportement du logiciel, les changements porteront idéalement sur un seul module. Cette problématique devient particulièrement prégnante dans le cadre d’un travail collaboratif où l’efficacité des développeurs dépend de leur capacité à intervenir sur une base de code commune sans interférer avec les réalisations des uns et des autres.
Quels principes en tirer pour concevoir des structures logicielles ?

La nécessité de structurer le logiciel s’impose à partir d’une certaine taille, mais toutes les structures ne se valent pas. Et modulariser un logiciel reste une vaine incantation si on ne suit pas certains principes qui permettent de répondre aux enjeux posés ci-dessus.

  • Structure répétitive. Pour être lisible et être appréhendable humainement, la structure logicielle doit proposer des motifs réguliers ; autrement dit elle doit suivre des conventions qui permettent de projeter une même démarche d’analyse d’un domaine fonctionnel à un autre. Ces conventions se traduisent à la fois dans l’organisation du code, c’est-à-dire la façon de séparer les responsabilités, mais également dans la façon de nommer les artefacts logiciels (interfaces, classes, méthodes…) On retrouve ces principes lorsqu’on applique des patrons de conception : on sépare ainsi les responsabilités de manière « classique », mais on adopte surtout une terminologie récurrente qui véhicule efficacement une sémantique claire.
  • Couplages faibles. Le principe clé de la conception des structures logicielles est celui-ci : couplages faibles et cohérence forte. En d’autres termes, les éléments du code qui sont fortement dépendant les uns des autres doivent être placés dans le même module (cohérence du module) et ceux qui ne sont pas, ou peu, liés doivent être placés dans des modules différents. Au-delà de la simple application de principes de connexité, la constitution de modules répond également à une logique de collaboration des classes dans un but commun. Nommer le module est généralement un bon moyen de s’assurer qu’on respecte ce principe ; si on hésite pour un module entre deux noms distincts, c’est que le module assume plusieurs responsabilités et que les classes ne coopèrent pas toutes de manière étroite. A l’inverse si on est tenté d’attribuer deux noms sémantiquement proches à deux modules, c’est qu’ils servent peut-être un même but et devraient alors constituer un unique module.
  • Interfaces stables. Une valeur dérivée du couplage faible est celle de la stabilité des interfaces. Si les responsabilités sont proprement attribuées aux modules, un changement de comportement aura des chances d’être porté par un seul module. Il n’est alors pas nécessaire de changer les règles de coopération entre modules et de modifier leurs interfaces en conséquence. Naturellement, certains changements impacteront plusieurs modules et induiront des changements d’interfaces ; mais la conception de la solution doit viser à faire de cette situation une exception. La stabilité des interfaces répond non seulement aux objectifs d’isolation des changements mais également à la testabilité de la solution ; la mise en place de tests unitaires s’appuie massivement sur les contrats d’interfaces, à la fois en tant que spécification des éléments testés mais également comme contrat à respecter par les doublures de test. Une instabilité des interfaces a donc des impacts qui concernent le code applicatif et le code de test ; elle s’avère ainsi très coûteuse et peut même convaincre à tort certains développeurs que « les tests unitaires, ça coûte cher à développer et plus encore à maintenir ».
Structures logicielles

Au début des années 2000, lorsque j’ai commencé à travailler, le style architectural « en couches » était le plus communément adopté. Robert Martin explique assez bien dans son ouvrage « Clean Architecture », et mieux encore dans son article « No DB », comment les architectes logiciels s’étaient alors laissés convaincre de construire leurs solutions autour de systèmes de gestion de bases de données relationnelles. D’un autre côté, il était nécessaire d’exposer l’application à ses utilisateurs, ce qui supposait d’avoir des éléments IHM dont la migration progressive vers des contenus Web s’amorçait. En complément de la persistance et de l’interface utilisateur, une application porte également une logique métier qui se veut la plus indépendante des externalités techniques induites par la base de données et la technologie des IHM. Il en résulte trois tiers assez distincts qui ont guidé la mise en place des architectures en couches pendant de nombreuses années : présentation, application, données.

Structures logicielles

Le principe de ce style d’architecture est de limiter les dépendances en imposant que chaque couche de l’architecture ne dépend directement que de la couche inférieure et qu’il n’existe aucune dépendance, même indirecte, vers une couche supérieure. Autrement dit, la couche de donnée ne dépend d’aucune autre couche du logiciel développé ; la couche applicative ne dépend que de la couche de donnée ; la couche de présentation s’appuie sur la couche applicative sans avoir de dépendance directe avec la couche de données. Dans cette approche, le flux de contrôle peut suivre les dépendances logicielles ; autrement dit, il n’est pas nécessaire de recourir à un mécanisme d’inversion de dépendance.

D’une certaine façon, ce style d’architecture s’inspire du modèle OSI qui a fait ses preuves dans le domaine réseau depuis les années 1970. Les principes de séparation des responsabilités et de communication entre couches au travers d’interfaces standardisées ont été repris par les ingénieurs logiciel qui ont mis en œuvre ces architectures en couches.

Il est ici important de souligner le concept d’interface standardisée. S’il est tout à fait possible d’établir des dépendances directes entre les implémentations d’une couche et celles de la couche inférieure, il est préférable que ces dépendances passent par des interfaces abstraites. Il ne s’agit pas là seulement d’une lubie d’architecte mais bien d’une nécessité pour les tests unitaires. En effet, si on souhaite par exemple tester la couche applicative sans s’appuyer sur la couche de données, et notamment sur la base de données, il est nécessaire de pouvoir injecter une dépendance vers une doublure de test.

Structures logicielles

Le silotage de l’architecture en couche ne répond pas toujours aux enjeux de complexité d’un composant logiciel. Dans certains cas, il est en effet nécessaire d’isoler certains domaines fonctionnels ; or l’organisation traditionnelle d’une architecture en couche est plutôt orientée selon une décomposition technique (base de données, métier, IHM). Introduire un axe de découpage supplémentaire permet de répondre à cette nécessité de décomposer fonctionnellement. En prenant l’exemple d’un catalogue de produits en ligne à partir duquel un utilisateur peut passer des commandes on peut produire le diagramme suivant :

Structures logicielles

Dans ce cas, on respecte une isolation stricte des domaines fonctionnels. Une telle approche est généralement celle adoptée par les concepteurs qui visent une architecture orientée services, à la différence près que les silos sont déployés de manière indépendante.

Structures logicielles

L’introduction d’un axe fonctionnel dans l’architecture matricielle préserve un bon niveau de découplage. Néanmoins, il est assez rare que de telles organisations soient retenues : d’une part les domaines fonctionnels sont rarement isolés et la tentation est grande venir coupler par les données, en particulier si le système s’adosse à une base de données relationnelle dont le but est, comme son nom l’indique, de gérer des relations ; d’autre part, le silotage interdit les mutualisations sur les éléments techniques (par exemple un système de gestion des paramètres de configuration, ou bien un mécanisme de journalisation). De nombreuses structures logicielles s’appuient ainsi sur une couche d’éléments « communs » et il n’est pas rare de voir un module « commons » dans une solution logicielle.

En abandonnant la logique du silo pour les domaines fonctionnels, il est assez naturel de jouer la carte de la convergence jusqu’à la couche IHM où il est nécessaire de proposer à l’utilisateur une vision agrégée des domaines fonctionnels plutôt qu’une segmentation qui n’a de sens qu’au niveau technique. Dans les architectures qui préservent le silotage des domaines fonctionnels, comme les micro-services, on retrouve d’ailleurs souvent un composant dont le rôle est de réagréger les données de manière à la présenter à l’utilisateur final. Ainsi, l’extension de l’architecture en couches à plusieurs domaines fonctionnels va plutôt tendre vers une architecture pyramidale que vers une architecture matricielle.

Structures logicielles

Le diagramme ci-dessus illustre la nature des couplages qu’on peut trouver dans une architecture pyramidale : un couplage technologique par les éléments communs, et un couplage par les données (le rôle central de la « commande » qui fait le lien entre les produits du catalogue et les consommateurs y est manifeste). Ces couplages restent des compromis vis-à-vis de la mutualisation du code et la consistance du modèle de données. Tant que ces compromis sont conscients, la mise en place d’une telle structure est tout à fait pertinente.

Structures logicielles

Dans les exemples précédents, la présentation est présentée comme un unique module. Une telle approche « monolithique » est tout à fait acceptable pour des IHM assez simples. Pour des interfaces utilisateurs plus complexes, il est généralement nécessaire d’introduire des éléments de structure intermédiaires, c’est-à-dire d’autres modules. Et une approche classique en la matière consiste à adopter le patron de conception MVC proposant une séparation du modèle de données IHM, des vues, des contrôleurs. Selon cette approche, la vue a la charge de présenter les informations à l’utilisateur et d’intégrer les widgets permettant à l’utilisateur d’interagir avec l’interface ; la vue dépend donc à la fois du modèle qu’elle représente et des contrôleurs qui prennent en charge les actions des utilisateurs. Le contrôleur dépend également du modèle qui expose les opérations métier que l’utilisateur souhaite atteindre via les widgets de la vue. De son côté, le modèle ne dépend ni des vues ni des contrôleurs mais est orienté vers le cœur applicatif, par exemple la couche « Application » dans l’exemple de l’architecture en couche traditionnelle. Les modifications du modèle qui doivent être répercutées au niveau de la vue passent en général par un mécanisme de notification selon le patron de conception « Observateur » qui évite le couplage du modèle vers la vue.

Structures logicielles

Il est intéressant de noter que le patron MVC définisse un modèle alors même que l’architecture en couche situe la définition des données beaucoup plus bas dans la pile de dépendances. Ce point suggère que plusieurs modèles de données coexistent dans une application complète. De fait, la modélisation d’une donnée à des fins de présentation, de traitement métier et de persistance peut nécessiter des ajustements locaux qu’il est important de ne pas propager. Par exemple, dans la couche de présentation, on pourra imaginer qu’un produit du catalogue soit sélectionné par l’utilisateur ; associer un état de sélection à une commande à du sens pour le modèle de l’IHM mais n’a pas de sens pour les traitements métier. De même, attribuer un identifiant numérique aux produits du catalogue pour optimiser la persistance des relations entre ces produits et les commandes n’a pas de sens au niveau métier et encore moins au niveau de la présentation.

Du haut de ces pyramides…

Les différents modèles d’architecture présentés ci-dessus ont une caractéristique en commun : elles sont construites verticalement. Et même si chaque couche de la structure n’est directement dépendante que de la couche inférieure, les dépendances transitives ne sont pas à négliger. Ces architectures débouchent ainsi souvent sur un empilement de couches non interchangeables qui contraignent la testabilité et le déploiement : en effet, il est assez difficile de venir intégrer des doublures de test ou des implémentations alternatives dans une structure aussi verticalisée.

Après l’âge d’or des architectures en couches, des modèles d’architecture favorisant l’horizontalité de la structure ont été de plus en plus fréquemment adoptées et sont aujourd’hui assez souvent recommandées. C’est le cas des architecture orientées événements et des architectures hexagonales discutées ci-dessous. Naturellement, l’émergence de ces modèles d’architecture ne doit pas remettre en cause le bien fondé des architectures historiques. Tout comme il est précisé qu’un monolithe est acceptable dans le cadre d’une application simple, une architecture en couches peut tout à fait répondre aux besoins de simplicité, de modularité et d’évolutivité d’une application. Rappeler ici la nécessité de rechercher la simplicité est essentiel dans la mesure où les deux modèles suivants présentent quelques éléments de complexité technique à ne pas sous-estimer.

Structures logicielles

La déclinaison typique de l’architecture en couche proposait trois tiers : un tiers pour la persistance, un tiers pour la logique métier et un tiers pour la présentation. Dans une application qui requiert ces trois couches, la dynamique de la plupart des cas d’utilisation répond à une sollicitation de l’utilisateur qui est prise en charge par la couche de présentation, déléguée à la couche applicative qui s’appuie à son tour sur la couche de donnée pour insérer, récupérer, modifier ou supprimer des données (logique CRUD – Create, Request, Update, Delete). Dans cette architecture, le flux de contrôle et les dépendances structurelles sont orientées dans le même sens. C’est ce qui conduit à la verticalité de solution. Pour orienter la solution selon un axe horizontal, il faut rompre avec l’alignement des liens de dépendances et du flux de contrôle en recourant à l’inversion de dépendance (le « D » des principes SOLID).

La place centrale de la logique métier est renforcée dans une architecture hexagonale ce qui en fait un style d’architecture particulièrement utilisée pour les équipes suivant le Domain Driven Design qui est centré sur le modèle métier. Une architecture hexagonale place en son cœur le modèle de données et la logique du métier. Les interactions avec la présentation ou la persistance des données sont alors contractualisées au niveau noyau métier. Prenons l’exemple d’un scénario de création d’une commande depuis un catalogue : l’interaction utilisateur gérée par la présentation se conclut par une validation du panier qui crée la commande. Ce scénario dont le point de départ se situe dans le module de présentation se traduit par une délégation de la création effective de la commande vers le noyau applicatif. Dans le ce cas, le flux de contrôle suit la dépendance de la présentation vers le module applicatif. Ce n’est plus le cas lorsqu’il s’agit de persister la commande ; en effet la logique métier spécifie le contrat que le module de persistance doit implémenter, contrat qui comporte une opération de création de commande. Ce contrat est défini dans le cœur applicatif et l’implémentation est apportée par le module de persistance pour être injectée dans le module métier. Ainsi la dépendance va du module de persistance vers le module applicatif alors que le flux de contrôle va dans l’autre sens.

Structures logicielles

Les avantages d’une telle approche sont nombreux ; en gérant le couplage de cette manière, les modifications qui surviennent sur un module périphérique (ici les modules de présentation et de persistance) n’impactent pas structurellement le module métier (il faut préciser ici « structurellement » parce que les changements de comportement vont nécessairement avoir un impact sur le fonctionnement global du composant). On peut même aller jusqu’à remplacer une de ces modules périphériques sans remettre en cause le code de la logique métier, sous réserve que le contrat d’interface est respecté. Cet avantage est particulièrement intéressant pour gérer l’obsolescence des modules qui sont technologiquement adhérents, sans remettre la logique métier qui s’avère généralement plus stable. On peut aussi profiter de cet avantage pour intégrer des doublures de test qui permettent de tester logique métier dans les meilleures conditions : remplacer une base de données par une doublure de stockage en mémoire évite bien des soucis de performance ou de répétitivité des tests unitaires.

Quand on regarde la représentation classique d’une architecture hexagonale, on pourrait douter qu’il s’agit d’une architecture horizontale. Pourtant, il s’agit bien d’une architecture à plat où toutes les modules sont dépendants du module métier et d’aucun autre. La représentation canonique de l’architecture hexagonale consiste simplement à replier l’architecture, en suivant généralement une logique de traversée du flux de contrôle de la gauche vers la droite. Sur la partie gauche, on trouvera ainsi le module de présentation, mais on pourrait également y trouver un frontal de services REST.

Structures logicielles

L’architecture hexagonale propose un très bon niveau d’évolutivité, ce qui en fait un style de plus en plus adopté. Mais rappelons qu’il n’existe pas de balle en argent et que l’architecture hexagonale ne doit pas être retenue les yeux fermés. En effet, l’inversion de dépendance qu’elle suppose pour réduire les couplages présente un coût : l’introduction d’abstractions (les contrats d’interfaces), le recours à des fabriques (pour ne pas créer des couplages directs vers des implémentations de ces contrats) et la mise en place de DTO - Data Transfer Object (pour dissocier proprement les modèles de données). Si le besoin à adresser est simple, que le modèle de données est anémique, que la logique se limite à du CRUD, ou que le composant est voué à une grande stabilité, une architecture en couches peut répondre plus efficacement.

Structures logicielles

L’organisation horizontale qui ne semblait pas manifeste pour une architecture hexagonale, l’est davantage dans une architecture orientée événement. Ce style d’architecture qui vient clore notre bestiaire présente la même « hauteur » que l’architecture hexagonale mais elle introduit un concept supplémentaire qui vient encore réduire les couplages entre modules. En effet, le flux de contrôle dans une architecture hexagonale reste assez classique dans le sens où les transferts de contrôle entre modules sont explicites et généralement synchrones (même si ce n’est pas une nécessité absolue). Autrement dit, pour reprendre l’exemple précédent, quand le module de présentation délègue la création d’une commande au module applicative, il le fait avec un appel direct. De la même façon le module métier sait qu’une implémentation se cache derrière son interface de persistance et l’appelle explicitement. Cette hypothèse de l’existence d’un, et un seul, module cible lors du transfert du flux de contrôle est le couplage que les architectures orientées événements viennent lever.

A la place d’un appel explicite, le module qui souhaite déléguer une action à un autre module le fait en émettant un événement. Si un module est conçu pour réagir à cet événement, il prendra sa part dans le scénario système, déléguant à son tour le flux de contrôle en émettant un ou plusieurs événements. L’émetteur d’un événement ne sait pas quel module le prendra en charge, ni même si un module le prendra ou si plusieurs modules le traiteront. Le seul couplage qui perdure entre ces modules est celui du modèle de données des événements échangés et du médium de communication, c’est-à-dire du bus d’événements.

L’évolutivité apportée par une telle architecture est plus importante encore que celle de l’architecture hexagonale. Par exemple, imaginons que nous ayons réalisé une première version de notre système de commandes dans lequel les données sont stockées par le module de persistance dans une base de données relationnelle. Le client sollicite une évolution pour que les commandes soient également transmises vers un autre système de stockage de l’un de ses partenaires. Dans l’architecture hexagonale, il était simple de remplacer un support de persistance par un autre mais supposait un changement de la logique métier pour en adresser deux. Avec une architecture orientée événement, il suffit d’introduire un nouveau module s’abonnant aux événements de stockage de la commande pour prendre en compte le besoin, sans qu’il soit nécessaire de venir modifier la logique métier qui émet l’événement.

Structures logicielles

Tout comme pour l’architecture hexagonale, le gain en matière de couplage vient avec une contrepartie. En effet, avec une architecture orientée événements, la dynamique de l’application est plus souple mais aussi moins facilement lisible. Pour comprendre les implications de l’émission d’un événement, il faut en effet connaître les modules qui vont y réagir. De plus, la logique est fragmentée par les échanges d’événements davantage qu’elle ne l’est pour des appels directs : à l’appel, il n’y a pas de grande différence puisque les arguments d’un appel direct peuvent se concevoir comme un la charge utile (ou « payload ») d’un événement. Mais le traitement du retour est plus complexe : là où un appel direct et synchrone est mis en attente et dispose de son contexte de travail lorsqu’il reçoit des données en retour, le traitement d’un retour asynchrone suppose de préalablement restaurer un contexte de traitement, entraînant une lourdeur algorithmique. D’ailleurs, lorsqu’on projette ce style d’architecture (qui, je le rappelle, est abordé ici dans le cadre d’un composant) sur une architecture système, les architectes adoptent souvent le patron CQRS (Command Query Responsibility Segregation) qui permet justement de dissocier les commandes et requêtes.

Choisir son style d’architecture

Les sections précédentes mettent en évidence plusieurs critères qui permettent de cartographier les styles d’architecture. Le diagramme suivant s’attache à deux aspects essentiels que sont la verticalité de la solution et la force des couplages :

Structures logicielles

Face à cette diversité de styles d’architecture, il est essentiel de garder en tête que le choix doit se faire en contexte. Ces styles viennent en effet avec leurs qualités et leurs limites. La pondération des avantages et des inconvénients ne peut faire l’économie d’une mise en perspective avec le contexte du projet. Le diagramme de décision suivant synthétise les questions à se poser pour choisir le style le mieux adapté à votre contexte.

Structures logicielles

Cet arbre de décision reste une illustration des critères à considérer pour choisir son style d’architecture ; d’autres critères peuvent être intégrés à la prise de décision et des variantes des styles d’architecture (comme le MVC est une variante des architectures en couches adaptée aux IHM) peuvent être appliquées en conséquence. Il reste également important de rappeler pour conclure cet article que les styles abordés ici sont ceux des constituants d’un système plus vaste dont la structure doit elle-même être définie. Certainement le sujet d’un prochain article.

Partager cet article
Repost0
Pour être informé des derniers articles, inscrivez vous :
Commenter cet article