Créer un code véritablement modulaire sans dépendances

Publié: 2022-03-11

Développer des logiciels, c'est bien, mais… je pense que nous pouvons tous convenir que cela peut être un peu une montagne russe émotionnelle. Au début, tout est super. Vous ajoutez de nouvelles fonctionnalités les unes après les autres en quelques jours, voire quelques heures. Vous êtes sur une lancée !

Avance rapide de quelques mois, et votre vitesse de développement diminue. Est-ce parce que vous ne travaillez pas aussi dur qu'avant ? Pas vraiment. Avançons rapidement de quelques mois de plus, et votre vitesse de développement diminue encore. Travailler sur ce projet n'est plus amusant et est devenu une corvée.

Ça s'empire. Vous commencez à découvrir plusieurs bogues dans votre application. Souvent, la résolution d'un bogue en crée deux nouveaux. À ce stade, vous pouvez commencer à chanter :

99 petits bogues dans le code. 99 petits insectes. Prenez-en un, corrigez-le autour,

…127 petits bugs dans le code.

Comment vous sentez-vous à l'idée de travailler sur ce projet maintenant ? Si vous êtes comme moi, vous commencez probablement à perdre votre motivation. C'est juste pénible de développer cette application, car chaque modification du code existant peut avoir des conséquences imprévisibles.

Cette expérience est courante dans le monde du logiciel et peut expliquer pourquoi tant de programmeurs veulent jeter leur code source et tout réécrire.

Raisons pour lesquelles le développement de logiciels ralentit avec le temps

Alors, quelle est la raison de ce problème ?

La principale cause est la complexité croissante. D'après mon expérience, le plus grand contributeur à la complexité globale est le fait que, dans la grande majorité des projets logiciels, tout est connecté. En raison des dépendances de chaque classe, si vous modifiez du code dans la classe qui envoie des e-mails, vos utilisateurs ne peuvent soudainement pas s'inscrire. Pourquoi donc? Parce que votre code d'enregistrement dépend du code qui envoie les e-mails. Maintenant, vous ne pouvez rien changer sans introduire de bugs. Il n'est tout simplement pas possible de tracer toutes les dépendances.

Alors voilà; la véritable cause de nos problèmes est la complexité croissante provenant de toutes les dépendances de notre code.

Grosse boule de boue et comment la réduire

Ce qui est drôle, c'est que ce problème est connu depuis des années maintenant. C'est un anti-modèle courant appelé la "grosse boule de boue". J'ai vu ce type d'architecture dans presque tous les projets sur lesquels j'ai travaillé au fil des ans dans plusieurs entreprises différentes.

Quel est donc cet anti-modèle exactement ? En termes simples, vous obtenez une grosse boule de boue lorsque chaque élément a une dépendance avec d'autres éléments. Ci-dessous, vous pouvez voir un graphique des dépendances du projet open source bien connu Apache Hadoop. Afin de visualiser la grosse pelote de boue (ou plutôt la grosse pelote de laine), vous dessinez un cercle et placez uniformément les classes du projet dessus. Tracez simplement une ligne entre chaque paire de classes qui dépendent les unes des autres. Vous pouvez maintenant voir la source de vos problèmes.

Une visualisation de la "grosse boule de boue" d'Apache Hadoop, avec quelques dizaines de nœuds et des centaines de lignes les reliant les uns aux autres.

La "grosse boule de boue" d'Apache Hadoop

Une solution avec code modulaire

Alors je me suis posé une question : Serait-il possible de réduire la complexité tout en s'amusant comme au début du projet ? À vrai dire, vous ne pouvez pas éliminer toute la complexité. Si vous souhaitez ajouter de nouvelles fonctionnalités, vous devrez toujours augmenter la complexité du code. Néanmoins, la complexité peut être déplacée et séparée.

Comment les autres industries résolvent ce problème

Pensez à l'industrie mécanique. Lorsqu'un petit atelier mécanique crée des machines, il achète un ensemble d'éléments standard, en crée quelques-uns personnalisés et les assemble. Ils peuvent fabriquer ces composants complètement séparément et tout assembler à la fin, en faisant seulement quelques ajustements. Comment est-ce possible? Ils savent comment chaque élément s'emboîtera grâce aux normes de l'industrie telles que la taille des boulons et aux décisions préalables telles que la taille des trous de montage et la distance entre eux.

Un schéma technique d'un mécanisme physique et comment ses pièces s'emboîtent. Les pièces sont numérotées dans l'ordre de celles à attacher ensuite, mais cet ordre de gauche à droite va de 5, 3, 4, 1, 2.

Chaque élément de l'assemblage ci-dessus peut être fourni par une société distincte qui n'a aucune connaissance du produit final ou de ses autres pièces. Tant que chaque élément modulaire est fabriqué selon les spécifications, vous pourrez créer le dispositif final comme prévu.

Pouvons-nous reproduire cela dans l'industrie du logiciel ?

Bien sûr que nous pouvons! En utilisant les interfaces et l'inversion du principe de contrôle ; la meilleure partie est le fait que cette approche peut être utilisée dans n'importe quel langage orienté objet : Java, C#, Swift, TypeScript, JavaScript, PHP - la liste est longue. Vous n'avez pas besoin d'un framework sophistiqué pour appliquer cette méthode. Il vous suffit de vous en tenir à quelques règles simples et de rester discipliné.

L'inversion du contrôle est votre ami

Lorsque j'ai entendu parler pour la première fois d'inversion de contrôle, j'ai immédiatement réalisé que j'avais trouvé une solution. C'est un concept qui consiste à prendre des dépendances existantes et à les inverser en utilisant des interfaces. Les interfaces sont de simples déclarations de méthodes. Ils ne fournissent aucune mise en œuvre concrète. En conséquence, ils peuvent être utilisés comme un accord entre deux éléments sur la façon de les connecter. Ils peuvent être utilisés comme connecteurs modulaires, si vous voulez. Tant qu'un élément fournit l'interface et qu'un autre élément en fournit l'implémentation, ils peuvent travailler ensemble sans rien savoir l'un de l'autre. C'est brilliant.

Voyons sur un exemple simple comment découpler notre système pour créer du code modulaire. Les diagrammes ci-dessous ont été implémentés comme de simples applications Java. Vous pouvez les trouver sur ce dépôt GitHub.

Problème

Supposons que nous ayons une application très simple composée uniquement d'une classe Main , de trois services et d'une seule classe Util . Ces éléments dépendent les uns des autres de multiples façons. Ci-dessous, vous pouvez voir une implémentation utilisant l'approche "grosse boule de boue". Les classes s'appellent simplement. Ils sont étroitement couplés et vous ne pouvez pas simplement supprimer un élément sans toucher les autres. Les applications créées à l'aide de ce style vous permettent initialement de vous développer rapidement. Je pense que ce style est approprié pour les projets de preuve de concept, car vous pouvez facilement jouer avec les choses. Néanmoins, il n'est pas approprié pour les solutions prêtes pour la production car même la maintenance peut être dangereuse et tout changement peut créer des bogues imprévisibles. Le schéma ci-dessous montre cette grosse boule d'architecture en boue.

Main utilise les services A, B et C, qui utilisent chacun Util. Le service C utilise également le service A.

Pourquoi l'injection de dépendance a tout faux

Dans la recherche d'une meilleure approche, nous pouvons utiliser une technique appelée injection de dépendance. Cette méthode suppose que tous les composants doivent être utilisés via des interfaces. J'ai lu des affirmations selon lesquelles il découple les éléments, mais est-ce vraiment le cas? Non. Jetez un œil au schéma ci-dessous.

L'architecture précédente mais avec injection de dépendances. Désormais, Main utilise les services d'interface A, B et C, qui sont implémentés par leurs services correspondants. Les services A et C utilisent tous deux Interface Service B et Interface Util, qui est implémenté par Util. Le service C utilise également le service d'interface A. Chaque service avec son interface est considéré comme un élément.

La seule différence entre la situation actuelle et une grosse boule de boue est le fait que maintenant, au lieu d'appeler les classes directement, nous les appelons via leurs interfaces. Il améliore légèrement la séparation des éléments les uns des autres. Si, par exemple, vous souhaitez réutiliser le Service A dans un autre projet, vous pouvez le faire en supprimant le Service A lui-même, ainsi que Interface A , ainsi que l' Interface B et Interface Util . Comme vous pouvez le voir, Service A dépend toujours d'autres éléments. En conséquence, nous rencontrons toujours des problèmes de changement de code à un endroit et de comportement erroné à un autre. Cela crée toujours le problème que si vous modifiez Service B et Interface B , vous devrez modifier tous les éléments qui en dépendent. Cette approche ne résout rien; à mon avis, cela ajoute simplement une couche d'interface au-dessus des éléments. Vous ne devez jamais injecter de dépendances, mais vous devez plutôt vous en débarrasser une fois pour toutes. Vive l'indépendance !

La solution pour le code modulaire

L'approche, je crois, résout tous les principaux maux de tête des dépendances en n'utilisant pas du tout de dépendances. Vous créez un composant et son écouteur. Un écouteur est une interface simple. Chaque fois que vous avez besoin d'appeler une méthode depuis l'extérieur de l'élément actuel, vous ajoutez simplement une méthode à l'écouteur et vous l'appelez à la place. L'élément est uniquement autorisé à utiliser des fichiers, à appeler des méthodes dans son package et à utiliser des classes fournies par le framework principal ou d'autres bibliothèques utilisées. Ci-dessous, vous pouvez voir un schéma de l'application modifiée pour utiliser l'architecture des éléments.

Un diagramme de l'application modifiée pour utiliser l'architecture des éléments. Principales utilisations Util et les trois services. Main implémente également un écouteur pour chaque service, qui est utilisé par ce service. Un écouteur et un service ensemble sont considérés comme un élément.

Veuillez noter que, dans cette architecture, seule la classe Main a plusieurs dépendances. Il relie tous les éléments ensemble et encapsule la logique métier de l'application.

Les services, en revanche, sont des éléments totalement indépendants. Maintenant, vous pouvez retirer chaque service de cette application et les réutiliser ailleurs. Ils ne dépendent de rien d'autre. Mais attendez, ça va mieux : vous n'avez plus jamais besoin de modifier ces services, tant que vous ne changez pas leur comportement. Tant que ces services font ce qu'ils sont censés faire, ils peuvent rester intacts jusqu'à la fin des temps. Ils peuvent être créés par un ingénieur logiciel professionnel, ou un codeur pour la première fois compromis avec le pire code spaghetti jamais cuisiné avec des goto mélangées. Cela n'a pas d'importance, car leur logique est encapsulée. Aussi horrible que cela puisse être, cela ne se répercutera jamais sur les autres classes. Cela vous donne également le pouvoir de répartir le travail dans un projet entre plusieurs développeurs, où chaque développeur peut travailler sur son propre composant indépendamment sans avoir besoin d'en interrompre un autre ou même de connaître l'existence d'autres développeurs.

Enfin, vous pouvez recommencer à écrire du code indépendant, comme au début de votre dernier projet.

Modèle d'élément

Définissons le modèle d'élément structurel afin de pouvoir le créer de manière reproductible.

La version la plus simple de l'élément se compose de deux éléments : une classe d'élément principal et un écouteur. Si vous souhaitez utiliser un élément, vous devez implémenter l'écouteur et appeler la classe principale. Voici un schéma de la configuration la plus simple :

Diagramme d'un élément unique et de son écouteur dans une application. Comme auparavant, l'application utilise l'élément, qui utilise son écouteur, qui est implémenté par l'application.

Évidemment, vous devrez éventuellement ajouter plus de complexité à l'élément, mais vous pouvez le faire facilement. Assurez-vous simplement qu'aucune de vos classes logiques ne dépend d'autres fichiers du projet. Ils ne peuvent utiliser que le framework principal, les bibliothèques importées et les autres fichiers de cet élément. En ce qui concerne les fichiers de ressources tels que les images, les vues, les sons, etc., ils doivent également être encapsulés dans des éléments afin qu'ils soient faciles à réutiliser à l'avenir. Vous pouvez simplement copier le dossier entier dans un autre projet et le tour est joué !

Ci-dessous, vous pouvez voir un exemple de graphique montrant un élément plus avancé. Notez qu'il s'agit d'une vue qu'il utilise et qu'il ne dépend d'aucun autre fichier d'application. Si vous souhaitez connaître une méthode simple de vérification des dépendances, consultez simplement la section d'importation. Existe-t-il des fichiers extérieurs à l'élément actuel ? Si tel est le cas, vous devez supprimer ces dépendances en les déplaçant dans l'élément ou en ajoutant un appel approprié à l'écouteur.

Un schéma simple d'un élément plus complexe. Ici, le sens large du mot « élément » se compose de six parties : Vue ; logiques A, B et C ; Élément; et Element Listener. Les relations entre ces deux derniers et l'application sont les mêmes qu'auparavant, mais l'élément interne utilise également les logiques A et C. La logique C utilise les logiques A et B. La logique A utilise la logique B et la vue.

Jetons également un coup d'œil à un exemple simple "Hello World" créé en Java.

 public class Main { interface ElementListener { void printOutput(String message); } static class Element { private ElementListener listener; public Element(ElementListener listener) { this.listener = listener; } public void sayHello() { String message = "Hello World of Elements!"; this.listener.printOutput(message); } } static class App { public App() { } public void start() { // Build listener ElementListener elementListener = message -> System.out.println(message); // Assemble element Element element = new Element(elementListener); element.sayHello(); } } public static void main(String[] args) { App app = new App(); app.start(); } }

Initialement, nous définissons ElementListener pour spécifier la méthode qui imprime la sortie. L'élément lui-même est défini ci-dessous. En appelant sayHello sur l'élément, il imprime simplement un message en utilisant ElementListener . Notez que l'élément est complètement indépendant de l'implémentation de la méthode printOutput . Il peut être imprimé dans la console, une imprimante physique ou une interface utilisateur sophistiquée. L'élément ne dépend pas de cette implémentation. En raison de cette abstraction, cet élément peut être réutilisé facilement dans différentes applications.

Jetez maintenant un œil à la classe App principale. Il implémente l'auditeur et assemble l'élément avec une implémentation concrète. Nous pouvons maintenant commencer à l'utiliser.

Vous pouvez également exécuter cet exemple en JavaScript ici

Architecture des éléments

Voyons comment utiliser le modèle d'élément dans des applications à grande échelle. C'est une chose de le montrer dans un petit projet, c'en est une autre de l'appliquer au monde réel.

La structure d'une application Web full-stack que j'aime utiliser se présente comme suit :

 src ├── client │ ├── app │ └── elements │ └── server ├── app └── elements

Dans un dossier de code source, nous divisons initialement les fichiers client et serveur. C'est une chose raisonnable à faire, car ils s'exécutent dans deux environnements différents : le navigateur et le serveur principal.

Ensuite, nous divisons le code de chaque couche en dossiers appelés app et elements. Les éléments se composent de dossiers avec des composants indépendants, tandis que le dossier de l'application relie tous les éléments ensemble et stocke toute la logique métier.

De cette façon, les éléments peuvent être réutilisés entre différents projets, tandis que toute la complexité spécifique à l'application est encapsulée dans un seul dossier et assez souvent réduite à de simples appels d'éléments.

Exemple pratique

Croyant que la pratique l'emporte toujours sur la théorie, examinons un exemple concret créé dans Node.js et TypeScript.

Exemple réel

Il s'agit d'une application Web très simple qui peut être utilisée comme point de départ pour des solutions plus avancées. Il suit l'architecture des éléments et utilise un modèle d'élément largement structurel.

À partir des surbrillances, vous pouvez voir que la page principale a été distinguée en tant qu'élément. Cette page inclut sa propre vue. Ainsi, lorsque, par exemple, vous souhaitez le réutiliser, vous pouvez simplement copier l'intégralité du dossier et le déposer dans un autre projet. Câblez simplement tout ensemble et vous êtes prêt.

Il s'agit d'un exemple de base qui démontre que vous pouvez commencer à introduire des éléments dans votre propre application dès aujourd'hui. Vous pouvez commencer à distinguer des composants indépendants et à séparer leur logique. Peu importe à quel point le code sur lequel vous travaillez actuellement est désordonné.

Développez plus vite, réutilisez plus souvent !

J'espère qu'avec ce nouvel ensemble d'outils, vous pourrez développer plus facilement du code plus maintenable. Avant de vous lancer dans l'utilisation du modèle d'élément dans la pratique, récapitulons rapidement tous les points principaux :

  • Beaucoup de problèmes dans les logiciels surviennent à cause des dépendances entre plusieurs composants.

  • En effectuant un changement à un endroit, vous pouvez introduire un comportement imprévisible ailleurs.

Trois approches architecturales courantes sont :

  • La grosse boule de boue. C'est génial pour un développement rapide, mais pas si génial pour des objectifs de production stables.

  • Injection de dépendance. C'est une solution à moitié cuite que vous devriez éviter.

  • Architecture des éléments. Cette solution permet de créer des composants indépendants et de les réutiliser dans d'autres projets. Il est maintenable et brillant pour les versions de production stables.

Le modèle d'élément de base consiste en une classe principale qui contient toutes les méthodes principales ainsi qu'un écouteur qui est une interface simple qui permet la communication avec le monde extérieur.

Afin d'obtenir une architecture d'éléments à pile complète, vous devez d'abord séparer votre code frontal du code principal. Ensuite, vous créez un dossier dans chacun pour une application et des éléments. Le dossier des éléments se compose de tous les éléments indépendants, tandis que le dossier de l'application relie tout ensemble.

Vous pouvez maintenant créer et partager vos propres éléments. À long terme, cela vous aidera à créer des produits facilement maintenables. Bonne chance et faites-moi savoir ce que vous avez créé!

De plus, si vous vous retrouvez à optimiser prématurément votre code, lisez Comment éviter la malédiction de l'optimisation prématurée par son collègue Toptaler Kevin Bloch.

En relation: Meilleures pratiques JS : créer un bot Discord avec TypeScript et injection de dépendances