Conception : composants et communication
Motivation et principes
Qu'est-ce que la conception ?
Une fois qu'on a défini ce qu'on veut faire (Analyse), il reste encore à déterminer comment faire (Conception). Il existe en effet une infinité de manière d'implémenter les fonctionnalités définies dans l'analyse. Il faudra donc choisir la plus pertinente au regard des besoins et moyens actuels et futurs.
Une première des choses est de définir la structure de notre projet, i.e. son architecture. Cette étape, très importante, ne doit pas être négligée car impacte fortement la facilité avec laquelle on pourra ensuite écrire le code, le comprendre, le déboguer, l'étendre, le refactorer, le maintenir, etc.
Pourquoi la conception ?
Une bonne conception fait gagner en rapidité et confort, rentabilisant rapidement le temps passé sur la conception. À l'inverse, une mauvaise conception impacte le projet sur le long terme, et constitue une dette technique qui sera d'autant plus coûteuse à "rembourser" que le projet est avancé.
Par exemple, modifier la signature d'une fonction nécessite d'adapter le code l'appelant. En cas de mauvaise conception, la modification du code appelant peut à son tour nécessiter d'adapter d'autres parties du code, engendrant des modifications en cascade. Cela sera d'autant plus coûteux que le projet est avancé, i.e. que le code potentiellement impacté sera grand, et le risque de régressions élevé.
Dans certains (vieux) projets ayant accumulé beaucoup de dettes techniques, la moindre modification est extrêmement coûteuse, nécessitant de larges réécritures. On se retrouve alors à ne pas effectuer certaines modifications (dont corrections de bug) car trop coûteuses, ou à les réaliser avec des bricolages successifs, accroissant toujours plus la dette technique.
Le code est alors si instable (i.e. fréquemment modifié), si illisible et incompréhensible, qu'il faudrait jeter le code existant, revenir à l'étape de conception, puis tout réimplementer à partir de 0 afin de repartir sur des bases saines. Cependant, une réécriture complète du code est souvent si coûteuse en ressources et en temps, qu'elle est bien souvent inenvisageable. De plus, reprendre la conception n'empêche pas de faire de nouvelles erreurs, et de repartir avec de nouvelles dettes techniques, engendrant un besoin perpétuel de recommencer le projet, sans jamais le finir, et toujours repartant de 0.
Bons et mauvais principes
Une bonne conception est ainsi vitale à un projet. Malheureusement, les erreurs de conception sont très faciles à commettre, et ne sont bien souvent détectées qu'au moment où leur impact devient critique, à un stade où le projet est déjà bien avancé.
Bien souvent, c'est l'expérience qui va nous permettre de reconnaître et d'éviter les erreurs de conception. Notre propre expérience, mais aussi celle des autres. En effet, de nombreux développeurs qui nous ont précédés, se sont plantés parfois méchamment, parfois à répétition, mais ont tiré leçons de leurs expériences, aussi bien au niveau de la conception, que de l'implémentation, des tests, etc.
Ces expériences ont permis de constituer des listes dont nous verrons certains éléments par la suite :
Cependant, il ne faut pas perdre de vu que l'objectif principal est que le projet soit fonctionnel. Les recommandations ne sont pas des règles absolues, et ne sont que des moyens pour faciliter le développement et la maintenance du projet. Moyens qu'il faut bien évidemment mettre en regard avec les besoins et contraintes du projet.
En effet, un code parfait n'existe pas. Un perfectionniste peut se retrouver à continuellement réécrire son code afin de le rendre plus "propre", plus "joli", sans jamais réussir à se satisfaire, n'avançant alors plus sur le projet. De plus, certains principes appliqués trop doctement ont tendance à complexifier inutilement le code sans être nécessaires. Il faut donc avoir une application raisonnée de ces différents principes. Trouver le juste milieu est difficile, et nécessite encore une fois de l'expérience.
Les composants
La Programmation Orientée Composant (POC) consiste à découper le projet en un ensemble de blocs indépendants appelés composants. Ce découpage en composants permet de réduire la complexité globale du projet, transformant un problème complexe, en plusieurs sous-problèmes plus simples à résoudre individuellement. Leur indépendance fait que, en théorie, la modification d'un composant ne se propage pas aux autres, limitant ainsi le coût des modifications.
Attention à ne pas confontre POC (Programmation Orienté Composant) avec PoC (preuves de concepts).
UML
Usage vs communication (i.e. fort couplage).
Il est possible de représenter les différents composants via un diagramme de déploiement, décrivant aussi les matériels sur lesquels les composants seront déployés.
💡 Il est possible de préciser les caractéristiques (mémoire, processeur, etc.) des matériels.
💡 Il est aussi possible d'utiliser des diagrammes de composants ou des diagrammes de package. En pratique, le diagramme de déploiement suffit.
Comment bien découper en composants ?
On souhaite découper le projet en composants qui soient le plus indépendants possibles, i.e. qui aient un faible couplage. C'est à dire qu'un composant doit pouvoir évoluer sans impacter les autres composants. On va ainsi tenter de regrouper "ce qui évolue ensemble" au sein d'un même composant.
En couches d'abstractions
L'abstraction part du principe que le code doit communiquer l'intention, sans dévoiler les détails et la complexité technique. Le code est alors bien plus intuitif, lisible et compréhensible.
Par exemple, indique qu'on souhaite lire une source, sans dévoiler la nature de la source (fichier, flux, socket, etc), ou la complexité technique (vérification des permissions, ouverture d'un descripteur de fichier, décodage, etc).
Un autre exemple, indique qu'on souhaite réagir un à événement . Nous n'avons ainsi pas besoin de savoir qu'un tel événement est déclenché par ou . En centralisant la définition de l'événement, on garanti ainsi son uniformité au sein du projet, et facilite ses modifications futures.
Un exemple d'abstraction : le DP itérateur
Le DP
itérateur permet d'abstraire le parcours d'une collection, simplement en définissant deux opérations :
- récupérer le prochain élément.
- vérifier si le parcours est terminé.
On peut alors écrire des algorithmes génériques (e.g. filter, map, copy, foreach, find, sort, etc) acceptant tous types de collections, indépendamment de leur nature (liste, tableau, ensemble, arbre, etc) ou de leur implémentation. Ces algorithmes n'ont besoin d'être implémenté qu'une seule fois, au lieu de devoir les réécrire pour chaque collection différente. Cela implique aussi qu'on peut changer la structure d'une collection (e.g. transformer un tableau en liste), sans impacter le code l'utilisant.
Un générateur est un type d'itérateur créant lui-même les éléments, au fur et à mesure de leur parcours. Ils sont utilisés lorsqu'on souhaite éviter de devoir créer l'ensemble des éléments avant de pouvoir les parcourir, e.g. liste infinie.
Couches d'abstraction
Au niveau de la conception, on utilise des couches d'abstraction, i.e. l'application du principe d'abstraction à l'échelle des composants. Les couches les plus basses (de bas niveau) sont les couches les plus techniques, quand les couches les plus hautes (de haut niveau) sont les plus abstraites :
Les DAO (Data Access Object) abstraient l'accès à des données persistantes, indépendamment de leur support de stockage (e.g. fichiers, base SQL, etc.). On manipule alors les données par le biais de classes qui se chargent d'effectuer les opérations demandées.
Il convient de ne pas mélanger les différents niveaux d'abstraction. Chaque couche N ne doit déprendre que de la couche N-1.
Le découpage en couches évite de s'enfermer dans une solution ou un choix technique. Par exemple, si on change la structure de la base de donnée, les adaptations à effectuer seront localisées et restreintes à la couche DAO. Ainsi, les autres couches ne seront pas impactées, et le code à modifier sera aisément identifié, réduisant significativement le coût des modifications.
Les couches les plus hautes communiquent l'intention. Elles peuvent être rédigées en réutilisant la logique et le langage métier, permettant potentiellement au client d'en comprendre le code et d'en modifier certains éléments à la marge.
Par responsabilités
Il est aussi d'attribuer à chaque composant ses propres responsabilités. C'est par exemple le cas dans l'architecture 3-tiers :
La couche présentation défini l'interface utilisateur. Son rôle est d'afficher les données, ainsi que de convertir les événements bruts (e.g. clavier, souris) en intentions (e.g. supprimer un produit) qu'elle transmet à la couche inférieure.
La couche métier (business) modélise les données et processus métiers. Elle ne connaît pas la couche présentation, en revanche, elle émet des notifications que la couche présentation peut écouter afin de se mettre à jour.
La couche infrastructure abstrait les différents supports de stockages et moyens de communication. Ainsi la couche métier accéder aux services de l'infrastructure sans dépendre de leur nature (e.g. BDD SQL vs fichiers JSON, etc).
Chacune de ces couches peut évoluer sans impacter les autres couches. La couche métier doit même pouvoir être exécutée et instrumentée sans la couche de présentation. Ainsi, en cas d'indisponibilité de la couche de présentation (e.g. bug au cours d'une refonte), le système reste fonctionnel et disponible, e.g. via des scripts ad hoc, lignes de commande, requêtes REST, etc. La couche métier peut alors être testée de manière isolée, en retirant la couche présentation, et remplaçant l'infrastructure par des modules simulés ou maîtrisés (e.g. avec des données de tests).
Par fonctionnalités
Il est aussi possible de découper le projet en modules fournissant chacune un service, e.g. gestion utilisateurs, gestion produits, planificateur, etc. Il est ainsi aisé d'ajouter/retirer des fonctionnalités au projet, en ajoutant/retirant des modules.
Bien évidemment ces différentes manières de découper un projet ne sont pas mutuellement exclusives. Il est ainsi possible d'avoir une architecture 3-tiers où les couches présentation et métier sont réparties dans différents modules, avec des bibliothèques partagées fournissant des couches d'abstractions, pour des besoins partagés entre les différents modules, e.g. l'accès à la couche infrastructure.
Ces modules peuvent être implémentés sous la forme d'un dossier (package) dont le contenu pourra être déployé indépendamment des autres modules. Certains modules peuvent dépendre d'un autre, bien qu'on cherche à les rendre le plus indépendant possible.
Attention à bien éviter les dépendances circulaires entre modules/composants.
Les interfaces
Les composants interagissent entre eux par l'échange de messages, qui peuvent être implémentés sous la forme d'appels de fonctions, d'événements, de requêtes REST, etc. Lors de la conception, ils sont usuellement représentés sous la forme d'un appel de fonction, sans en préjuger la nature.
Principe
Afin que les composants puissent se comprendre, il convient d'en spécifier les différents messages. Une interface est alors un contrat décrivant les messages qu'un composant s'engage à accepter. Le respect de ce contrat permet le développement parallèle des composants via différentes équipes de développement, tout en limitant les conflits lors de l'intégration. L'interface sert de frontière entre l'implémentation du composant, et son utilisation.
Abstraction
Ainsi le composant doit être vu comme une boîte noire exposant une (ou plusieurs) interface. L'interface ne doit communiquer que l'intention, sans révéler l'implémentation interne (abstraction). Pour cela elle doit être définie en ne révélant que le minimum d'informations nécessaires (principe de moindre connaissance). Ainsi, l'interface doit pouvoir être utilisée sans nécessiter la connaissance ce qu'elle fait en interne (ni de comment elle le fait).
Cela n'est pas entièrement vrai. Sans avoir besoin de comprendre les détails techniques, il est tout de même nécessaire de connaître, dans les grandes lignes, le comportement interne de certains composants (e.g. SQL ou Git) afin de comprendre ce qu'on fait, et ainsi de les utiliser correctement.
Agnostisme
De même, une interface ne doit pas dépendre de l'usage concret qui en est fait, i.e. elle ne doit pas évoluer lorsque les besoins de ses clients évoluent. Par exemple, un client souhaite filtrer et afficher une liste de produits :
En effet, l'affichage et le filtrage des produits ne sont pas de la responsabilité du store. Ces deux opérations peuvent avoir plusieurs variations, e.g. afficher les produits sous forme d'une liste compacte, sous la forme de fiches détaillée, etc. Ce qui reviendrait à devoir modifier le store pour chaque nouvel usage.
Il est cependant possible de fournir, en dehors du store, des helpers permettant d'expliciter le code, mais aussi de factoriser les opérations récurrentes. Le store peut aussi éventuellement accepter des paramètres génériques lorsque cela se justifie.
Attention cependant à ne pas anticiper des besoins futurs qui ne se manifesteront peut-être pas, ou qui finalement trouveront une meilleure alternative lorsque le projet sera plus avancé. Ajouter de la généricité ne doit se faire que s'il y a un besoin actuel.
Conseils
Stabilité
Un composant ne dépend ainsi pas d'implémentations, mais d'interfaces tierces. L'interface se doit d'être stable, mais cela ne préjuge rien quant à l'implémentation qu'il y a derrière. Il est ainsi possible d'isoler une implémentation "sale" et instable derrière une interface propre et stable. En revanche, un composant stable/difficile à modifier ne doit pas dépendre d'une interface instable. En effet, tout changement de l'interface nécessite d'adapter l'implémentation et les usages.
Nommage
Les noms doivent révéler l'intention et être explicites, sans pour autant être trop longs. Certains termes proches se distinguent par des nuances implicites, qui sont cruciales à la bonne comprehension intuitive des concepts sous-jacent, e.g.:
- changed: a été modifié.
- stale: n'est plus à jour.
- invalidated: invalidée.
- expired: a expiré.
Il est aussi important d'uniformiser les noms en adoptant des conventions et en s'y tenant. Par exemple, pour ajouter des éléments à une collection, il est possible d'utiliser plusieurs termes. Il convient ainsi d'en sélectionner un, et de l'utiliser pour l'ensemble de nos collections :
Il est aussi possible de définir des redirections, e.g. :
- prepend/append: ajout en début/fin de collection.
- insert: ajout à une place précise dans la collection.
- add: ajout par défaut, commune à toutes les collections, redirige vers les méthodes précédentes.
Les conventions peuvent aussi introduire de nouvelles nuances qu'il conviendra d'expliciter, e.g. :
- listen : écouter.
- once : n'écouter que le premier événement.
- on : observation d'événements riches.
- when : once + promesse.
- observe : listen + appel initial.
- monitor : listen + collecte des événements.
- watch : listen + vérification des valeurs.
- bind : déclaration d'un watch auprès d'un système.
Exportation
Je vous recommande d'exporter vos interfaces par le biais d'un dossier séparé de l'implémentation. Chaque interface est alors exportée dans un sous-dossier qui se contente de réexporter des symboles de l'implémentation. Cela permet ainsi d'avoir des références stables, indépendantes des restructurations qui peuvent être effectuées dans l'implémentation.
Cette séparation claire entre l'export de l'interface et l'implémentation permet d'aisément visualiser ce qui est exporté. De plus, on peut alors facilement détecter lorsqu'un tiers contourne les interfaces en important directement des éléments de l'implémentation.
Si l'interface est spécifique au composant, elle peut être documentée par le biais de fichiers à la racine de son dossier d'exportation.
Protocole de communication
Les interfaces décrivent ce que les composants sont capables de faire, mais pas la manière dont ils échangent entre eux dans le cadre d'un scénario donné. Par exemple, pour acheter un produit, quels messages sont envoyés à qui, dans quel ordre ?
Pour cela on utilise principalement 2 diagrammes UML :
Diagramme de séquence : si beaucoup de messages.
Diagramme de communication : si beaucoup d'acteurs.
Stateless + DP orchestrator
Les appels de fonctions peuvent être
synchrone (
j'attends la réponse) ou
asynchrone (
je continue l'exécution sans attendre la réponse). Les appels asynchrones peuvent être implémentés par :
- un callback : fonction passée en paramètre et appelée lorsque l'action est finie (e.g. ).
- une fonction asynchrone : dépend du langage de programmation utilisé (e.g. ).
Diagramme de séquence
XXX
Le diagramme de séquence permet de montrer, dans un ordre chronologique, les interactions entre divers entités dans le cadre d'un scénario donné. Ils sont notamment très utilisés pour décrire des protocoles réseaux.
msg : fct / REST query / etc.
Ref + Alt + par (parallel) opt (p 635 ) + crit + break + loop.
Diagramme de Communication
Lorsqu'il y a trop d'entités, et peut de messages entre deux entités, il est possible d'utiliser un diagramme de communication pour plus de lisibilité :
Autres diagrammes
- Global d'interaction (er gros un diagramme d'activité dont les noeuds sont d'autres diagrammes).
- Temps.
Communication entre composants
Obtenir une référence vers le destinataire
Pour pouvoir envoyer un message, le composant expéditeur doit pouvoir joindre le composant destinataire, i.e. qu'il ai une référence vers le composant ciblé.
Glue code
Le plus simple est d'utiliser un glue code qui va initialiser les différents composants en leur transmettant les dépendances dont ils ont besoin (dependencies injection):
Module
Lorsque plusieurs interfaces "vont ensemble", on peut souhaiter les regrouper au sein d'une même interface/objet (un
module), afin de s'assurer d'utiliser la même implémentation. Cela permet aussi de regrouper les dépendances au sein d'un même objet (
Dependency Bundle), évitant ainsi la multiplication des paramètres (
Parameter Object) :
Lorsque l'interface sert à créer différents types d'instances, on parle alors de DP Abstract Factory.
Service Locator
Une autre méthode, plus flexible, est d'utiliser un DP
Service Locator, afin d'enregistrer des
interfaces, puis de les récupérer en les initialisant sur demande. Cela permet de charger dynamiquement les composants tout en résolvant l'ordre des dépendances.
Il convient toutefois de continuer d'utiliser l'injection de dépendances afin de faciliter les tests, et d'éviter de coupler l'implémentation de l'interface avec le Service Locator.
Event Bus
Dans les systèmes réactifs, on peut utiliser un
Event Bus global, permettant de s'abonner à un événement, sans à en connaître l'origine. Bien évidemment cela ne fonctionne que pour des objets/événements "globaux" du système.
Transférer le message
Idéalement, les composants sont indépendants du moyen de communication utilisé (e.g. appel de fonction, requête REST, requête SQL, etc). Pour cela on peut utiliser un
broker qui reçoit les requêtes, puis les transmet. Il abstrait ainsi la communication entre les composants :
Le broker peut alors fournir diverses fonctionnalités additionnelles, e.g. :
- queue : gestion de fils d'attentes de messages à traiter.
- load balancing : répartition des messages vers différents clones du même composant.
- Data Transfert Object : agrégations de messages lors de l'envoi sur le réseau.
Proxy
Bien évidemment, il est possible d'implémenter des couches d'abstractions par-dessus le broker, e.g. :
On peut même fournir un
proxy, i.e. une interface qui se fait passer pour le composant, et qui transmet les requêtes tout en fournissant divers services (ici communication à travers le broker) :
Diag de communication.
Si on se contente simplement d'appeler les fonctions du destinataire, un proxy + broker est complètement inutile, et complexifie le code pour rien. Dans ce cas, autant utiliser directement l'interface du destinataire, qu'on pourra ensuite remplacer par un proxy + broker le jour où le besoin se manifestera réellement.
Interceptions de messages
Le broker et le proxy sont aussi régulièrement utilisés pour intercepter les messages afin de fournir diverses fonctionnalités :
- access control : rejeter la requête si l'expéditeur ne dispose pas des droits nécessaires.
- cache : conserver les résultats des requêtes précédentes.
- logs : enregistrer les requêtes (et éventuellement leurs réponses) pour faciliter les diagnostics futurs (e.g. debug).
- monitoring : e.g. suivre la charge du système, lancer des alertes, etc.
Compatibilité
Pour que des composants puissent échanger entre eux, ils doivent utiliser les mêmes interfaces. Cependant, le fait que les interfaces doivent être stables n'empêche pas des évolutions ponctuelles. Or, on souhaite éviter de casser l'ensemble du projet tant que les composants n'auront pas été adaptés à la nouvelle interface.
Pour cela on utilise le DP
Adapter, qui va servir de traducteur entre les deux versions de l'interface :
diag
Une autre solution est d'anticiper en créant une interface "Gateway" locale, i.e. :
- regrouper les dépendances du composant derrière une interface unique (DP façade).
- découpler l'interface locale, des interfaces dont elle dépend (DP Bridge).
- éventuellement coordonner les appels aux différentes dépendances (DP mediator).
Ainsi, il est possible de simplifier l'implémentation du composant tout en le rendant plus explicite. Le fait d'isoler les dépendances au même endroit permet à la fois d'identifier ce que le composant a réellement besoin, et de limiter les modifications en cas de modifications des dépendances.
Architectures
Architecture monolithique
Dans une architecture monolithique les composants sont regroupés au sein d'un même exécutable. Les messages sont usuellement implémentés sous la forme d'appels de fonctions.
Service Oriented Architecture (SOA)
Dans une architecture SOA, les services (composants) sont distribués au sein de différents exécutables/processus. Il est alors nécessaire d'utiliser alors des techniques de
communications inter-processus (IPC) :
- Fichiers FIFO : pipes, sockets.
- Événements : message queue, signaux unix.
- Données : sémaphores, mémoire partagée.
Le message doit alors être converti en données binaires afin de pouvoir être transmis (e.g. JSON). Pour transmettre un objet, il faudra alors :
- le sérialiser, i.e. le transformer en données binaires.
- puis le désérialiser à la réception, i.e. le récréer à partir des données binaires.
Micro-services
Dans une architecture de micro-services, chaque service a son propre exécutable/processus. Son principal intérêt réside dans leur déploiement indépendant, et leur réplication sur plusieurs serveurs en fonction de la charge. Cette architecture est ainsi utilisée lorsqu'on traite un volume très massif de requêtes et de données.
Les services pouvant se trouver sur différents serveurs, les sockets sont alors utilisées avec un protocole réseau adapté aux besoins du système. Par défaut, on utilisera généralement une API REST.
Envoyer une requête réseau est bien plus coûteux qu'un simple appel de fonction. Le découpage en services doit ainsi éviter des communications excessives, en regroupant ensemble les composants qui communiquent fréquemment entre eux.
KISS
Ces différents outils répondent à des besoins spécifiques, et ne sont pas nécessairement adaptés à tous les projets. Il est ainsi important de ne pas complexifier inutilement son projet en utilisant certains outils alors qu'ils ne sont pas nécessaires/utiles dans le cadre du projet. La règle générale est, autant que possible, de garder les choses le plus simple possible (KISS - Keep it Simple, Stupide).
Par exemple, pour système où les composants communiquent à travers le réseau, commencer d'abord par une version où ils communiquent directement entre eux. Ce qui en facilite le développement et les tests. Puis, lorsqu'on implémente le réseau, faire en sorte de rester simple en isolant cette complexité (e.g. via un proxy + broker). Il est ainsi nécessaire d'anticiper, sans complexifier.
Idéalement, on fera en sorte de pouvoir construire différentes versions (e.g. sans communications réseau, avec communications textuelles, avec communication compressées et chiffrées, etc.). Pour cela, il faut que le composant en charge des communications puisse être soit facilement configuré (e.g. en (des)activant certaines fonctionnalités), soit aisément substitué par une autre implémentation (e.g. pour le remplacer par une implémentation triviale).