Plus nous avançons dans la digitalisation, plus les systèmes sont complexes, répartis sur plusieurs microservices. Une simple application de commerce électronique peut avoir des services de catalogue de produits, de commandes, d’expédition et de gestion des stocks. Cette complexité croissante des applications s’accompagne d’une augmentation des dépendances entre les services.
Les applications décomposées en différentes parties autonomes peuvent être versionnées et déployées indépendamment les unes des autres mais doivent toujours pouvoir interagir entre elles.
Lorsque les classes sont enchevêtrées, le moindre changement pour l’une des classes va nécessiter de modifier le code de chacune des classes qui dépendent de cette dernière.
Notion de dépendance entre classes
Lorsqu’une classe (A) a besoin d’une autre classe (B) pour fonctionner, on dit que (A) a une dépendance vers (B) et que (B) est une dépendance pour (A).
Si la classe (B) a elle-même une dépendance vers une autre classe (C), la classe (C) devient aussi une dépendance de (A).
Dans un système complexe, il est alors courant d’avoir un enchevêtrement de dépendances qui peut amener à des situations complexes lorsqu’il devient nécessaire de modifier une des classes. En effet avec le jeu des dépendances il peut être alors nécessaire de modifier tout ou partie des classes qui dépendent de la classe modifiée.
Il est donc important de maintenir une ‘saine’ relation entre les différents objets pour faciliter l’ajout de fonctionnalités ou la correction de bugs et pour cela nous pouvons utiliser le principe d’inversion des dépendances.
Inversion des dépendances
Le principe d’inversion des dépendances (Dependency Inversion Principle ou DIP en anglais), est l’un des 5 principes de conception SOLID permettant de produire des architectures logicielles évolutives et maintenables :
Selon ce principe, il faut
- Que les modules de haut niveau ne dépendent plus des modules de bas niveau, mais que l’ensemble des modules dépendent d’abstractions.
- Que les abstractions ne dépendent pas des détails, mais que les détails dépendent des abstractions
Il existe plusieurs solutions pour appliquer ce principe à votre code et l’une d’elle est l’injection des dépendances (Dependency Injection ou DI en anglais). Cette implémentation est aussi appelée inversion de contrôle (Inversion of Control ou IoC en anglais)
Injection de dépendances
L’injection de dépendances consiste, pour une classe, à déléguer la création de ses dépendances au code appelant qui va ensuite les injecter dans la classe correspondante. De ce fait, la création d’une instance de la dépendance est effectuée à l’extérieur de la classe dépendante et injectée dans la classe (le plus souvent dans le constructeur mais il existe d’autres possibilités).
Comme l’explique Thomas, consultant senior et Scrum Master
Dans une architecture « classique » qui ne fait pas appel à l’inversion de dépendance, un module de haut niveau, appelons le « contrôleur » va avoir une dépendance vers un module de plus bas niveau, appelons le « logique métier ». Lorsque le programme maître instancie le contrôleur suite à une action utilisateur, le contrôleur instancie lui-même le module de logique métier. Il devient alors complexe de séparer la logique métier du contrôleur. On parle de couplage fort.
Pour mettre en place l’injection de dépendance, il faut donner la possibilité au programme maître d’instancier lui-même la logique métier lorsque c’est nécessaire. Ainsi, au moment de l’action utilisateur, lorsque que le programme maître souhaite instancier le contrôleur, il commence par instancier la dépendance et l’injecte dans le contrôleur (injection par le constructeur, il existe d’autres possibilités moins répandues).
Pour prendre une métaphore du monde réel, supposons que vous souhaitiez faire peindre votre salon par un peintre professionnel en rose poudré. Pour s’acquitter de sa tâche ce dernier va probablement venir avec ses propres outils, disons pour simplifier un simple pinceau et un seau de peinture. C’est « l’approche classique ».
Si vous souhaitez faire de l’injection de dépendances, cela signifie que vous vous mettez d’accord avec votre artisan pour lui fournir vous-même le pinceau et la peinture lorsqu’il arrive. Cela peut sembler normal pour la peinture mais ça l’est moins pour l’outillage.
Pour résumer, l’injection de dépendances a pour but de séparer la création d’une dépendance de son usage. Toutefois cette manière d’agir semble moins naturelle et plus complexe alors pourquoi est-elle autant en vogue dans l’informatique moderne ?
Pourquoi opter pour l’injection de dépendances ?
Il y a deux raisons principales : l’utilisation d’abstractions et les tests unitaires.
Les abstractions
L’abstraction est un des fondamentaux de la programmation orientée objet. En suivant ce processus d’abstraction, le développeur masque toutes les informations non pertinentes d’un objet à des fins de simplification et d’efficacité.
Les abstractions sont généralement implémentées en tant que classes ou interfaces abstraites et permettent d’introduire la notion de contrat, dans la syntaxe C# ce sont les interfaces. Ce sont elles qui vont permettre la mise en œuvre du principe d’inversion de dépendances (« les modules dépendent d’abstractions »).
Comme l’explique Thomas :
Lorsque les modules de bas niveau implémentent des interfaces et que ce sont ces interfaces qui sont injectées dans les modules de haut niveau, on ne dépend plus des implémentations (des « détails ») et on peut dès lors « remplacer » une implémentation par une autre tant que la nouvelle implémentation respecte l’abstraction (c’est-à-dire qu’elle implémente l’interface).
Pour reprendre notre exemple du monde réel, intéressons nous à l’outil de l’artisan, son pinceau. Et nous allons passer un contrat avec lui disant que nous allons lui fournir non pas un pinceau mais un « outil pour peindre » : un IPaintingTool.
Au moment du démarrage du chantier, c’est à vous de décider si vous lui fournissez un pinceau, un rouleau à peinture ou un pistolet à peinture. Si ces trois outils respectent bien l’interface IPaintingTool, votre artisan doit savoir les manipuler. Je rappelle que dans le merveilleux monde de l’informatique et de la programmation orientée objet, on peut donner un comportement à un outil 😊.
L’avantage étant que, si demain quelqu’un invente un nouvel « outil pour peindre » qui implémente la même interface, vous n’aurez pas besoin de changer d’artisan, il pourra utiliser votre outil directement.
On voit que grâce aux abstractions nous avons introduit un découplage des modules (on peut aussi parler de « couplage faible ») et que grâce à cela la modification ou le remplacement d’un module de bas niveau n’a pas d’impact sur les modules qui en dépendent (du moins tant que l’abstraction, le contrat, ne change pas).
Les tests unitaires
Le deuxième bénéfice principal de l’injection de dépendances réside dans la facilité de rédiger des tests unitaires.
Les tests unitaires sont menés par le développeur lui-même, et consistent à vérifier la bonne exécution des fonctions dont il a la charge. Ces fonctions sont testées de manière indépendante, souvent avec des données réduites. Il est souhaitable de définir très tôt des jeux de tests représentatifs, car le développeur pourra réaliser les tests unitaires sur cette base et la qualité en sera améliorée.
L’objectif d’un test unitaire est donc de tester une « unité de code » (d’où son nom) dans une situation donnée. Un test unitaire n’a surtout pas pour vocation de tester toute une chaine d’implémentations et notamment les différentes implémentations des dépendances de la classe testée.
En l’absence d’un mécanisme d’injection de dépendances, une des principales difficultés pour écrire des tests unitaires corrects va être de s’affranchir des dépendances de la classe puisqu’elle les instancie elle-même.
Comme l’explique Thomas
Par exemple si parmi les dépendances d’un module on trouve un module de bas niveau permettant d’accéder à une base de données, en l’absence d’un mécanisme d’injection de dépendances, lorsque vous testez votre module il faudra par exemple s’assurer de la disponibilité d’une base de données avec des données valides. Et que se passe-t-il si votre test implique de créer des données dans la base, est-ce que vous devez vous préoccuper de supprimer ces données à la fin du test ou est-ce que vous laisserez ces données dans la base ? Dans le second cas, est-ce que la présence de ces données va avoir un impact sur le résultat du test la prochaine fois que vous le lancerez ?
Alors que si les dépendances sont injectées, vous avez la possibilité de fournir des « simulacres » (ou Mock). Ces simulacres sont tout simplement des implémentations dédiées uniquement à vos tests dont vous pouvez contrôler le comportement. Dans l’exemple précédent le simulacre du module d’accès à la base de données n’a pas besoin qu’une base de données existe réellement et, évidemment, si elle existe il n’écrira jamais de données dans votre base de développement ou de recette. En revanche vous pouvez très bien définir le retour d’une méthode donnée. Cela permet aussi de tester plus simplement des cas aux limites (par exemple une coupure réseau).
Pour reprendre notre métaphore du monde réel, si vous voulez vérifier que votre peintre connait son métier, vous pouvez lui fournir un « simulacre de peinture » qui a la même apparence qu’une peinture réelle mais n’a aucun effet sur la surface sur laquelle il est appliqué et lui demander de vous montrer ce qu’il en ferait. Ainsi il ne va pas repeindre accidentellement un morceau de votre mur pendant le test.
Grâce à l’injection de dépendances, il devient beaucoup plus facile d’écrire des tests unitaires fiables et compartimentés. Vous pouvez ainsi vous construire une batterie de tests unitaires vous permettant de vous prémunir notamment des régressions lors des évolutions de vos applications.
Les principes de l’injection de dépendances étant exposés, cela ne veut pas forcément dire qu’il faut systématiser son utilisation au risque de complexifier dans les cas où la dépendance n’est pas gênante. Comme souvent dans l’univers du code, le plus difficile est de tirer parti de la technologie à bon escient, sans excès.
Implémentation .Net
La plupart des Frameworks de développement existants dans NET 6.0 (ASP.Net Core, Blazor, MAUI, etc.) proposent nativement un mécanisme d’injection de dépendances grâce au package Microsoft.Extensions.DependencyInjection. Lorsqu’il n’est pas directement accessible (dans le cas d’une application console par exemple) il est, en général, facile à ajouter. Ce mécanisme permet notamment de gérer le cycle de vie des dépendances et inclut par défaut certains modules de bas niveau tels que la journalisation (logging), le diagnostic et la configuration de l’application.
En outre, il est possible de remplacer le mécanisme natif par d’autres implémentations qui apportent des fonctionnalités plus avancées :