C# : L’injection de dépendances (DI) et l’inversion de contrôle (Ioc)

Par Thibault Herviou, consultant d’IVIDATA Group

Lorsque l’on a compris les fondamentaux de la programmation orientée objet (POO), il faut rapidement comprendre et assimiler plusieurs patrons de conceptions.

En effet, cela permet de faire face à des problèmes connus et ainsi d’imposer de bonnes pratiques qui s’appuient sur l’expérience de concepteurs de logiciels.

Pour moi, et sûrement pour beaucoup d’autres concepteurs de logiciels, l’un des premiers objectifs de la conception est de garder une interface claire, facilement maintenable et surtout évolutive. Car tout développeur le sait, les technologies évoluent très très vite.

C’est pourquoi, dans cet article je vais vous présenter l’injection de dépendances (DI) et l’inversion de contrôle (Ioc).

Mais pourquoi l’injection de dépendances ?

En C# il est simple d’instancier un nouvel objet via l’opérateur «new ». L’instanciation d’un nouvel objet dans une classe impose un couplage (c’est-à-dire une connexion étroitement liée), voir même une référence à une assembly.

On pourrait s’en sortir si le projet n’était pas trop complexe avec des objets simples et très peu de couplage. Mais, imaginons que vous vouliez créer un service, comment allez-vous faire pour l’implémenter rapidement dans une nouvelle classe/assembly ? Et dans le pire des cas, le jour où vous voulez changer de type de service dans votre projet simple, qui au fil du temps est devenu complexe, comment allez-vous procéder ?

Vous l’avez compris, ce patron est pour tout développeur qui se soucie de la qualité de son logiciel et cherche à rendre son application la plus maintenable possible.

En effet, l’injection permet d’établir de façon dynamique la relation entre plusieurs classes. Elle consiste à découper les responsabilités entres les différents modules, les différentes parties et facilite même la modification ultérieure des classes.

Pour mieux comprendre ce patron, il faut d’abord se poser la question : qu’est-ce qu’une dépendance en POO ?

Comprendre

Comme je vous l’ai dit, il est trivial de créer des dépendances en C# , comme le montre ce premier exemple:

##################################################################################

public class CalculateurService

{

public ParamètresUtilisateur ObtenirParamètres(int utilisateurId)

{

var monteur = new ParamètresMonteur();

monteur.SetUtilisateur(utilisateurId);

return monteur.MonterParamètres();

}

}

##################################################################################

Comme vous pouvez le constater, on est bien dans un cas typique de dépendance, car notre service est obligé de créer la classe « ParamètresMonteur » et ne peut pas se passer d’elle pour les calculs.

Aussi, étant donné que ces deux classes sont fortement liées, si le constructeur de la classe « ParamètresMonteur » est modifié, nous serons dans l’obligation de modifier la méthode « ObtenirParamètres ».

De plus, chaque classe qui souhaiterait utiliser « ParamètresMonteur » se retrouverait avec la même implémentation. Ce qui va rendre les futures modifications de la classe plus coûteuses en temps et ainsi, d’augmenter les risques d’erreur au fur et à mesure que notre application va évoluer.

C’est là que l’injection de dépendances est utile dans un objet. Elle permet de ne pas se soucier de l’instanciation d’autres objets /modules dont il dépend et par conséquent de pouvoir le réutiliser dans d’autre classes.

Mettre en place

Pour éviter au mieux les dépendances, il y a trois types d’injections :

  • Injection par mutateur/champs ;
  • Injection par constructeur ;
  • Injection par interface ;

Injection par mutateur/champs

Pour ce faire, il faut ajouter une propriété dans notre classe qui aura pour rôle, d’injecter la dépendance dans cette dernière.

Ce qui se traduirait au niveau de notre service par :

##################################################################################

public class CalculateurService

{

private ParamètresMonteur _paramètresMonteur;

public ParamètresMonteur ParamètresMonteur

{

set { this._paramètresMonteur = value; }

}

public ParamètresUtilisateur ObtenirParamètres(int utilisateurId)

{

_paramètresMonteur.SetUtilisateur(utilisateurId);

return _paramètresMonteur.MonterParamètres();

}

}

##################################################################################

Comme vous pouvez le voir, nous injectons l’objet « paramètresMonteur » dans notre service par le biais d’un mutateur. Cette façon de procéder permet à la classe de ne plus être responsable de l’instanciation de la dépendance (ici « ParamètresMonteur ») et ainsi diminue les risques de dépendances cycliques.

A ce niveau, le plus difficile est de savoir qu’elle classe doit se faire injecter dans l’autre ?

La meilleure façon de faire est d’injecter la classe la moins utilisée dans l’autre classe.

Par conséquent, cette implémentation nous oblige à faire des vérifications supplémentaires, étant donné que notre classe « paramètresMonteur » peut-être null, et cela arrive plus souvent que ce que l’on imagine. En effet, ce type d’injection n’est pas très explicite pour une personne qui veut utiliser le service.

Vous me direz qu’il n’y qu’à aller voir comment fonctionne le service. Malheureusement, on ne peut pas toujours prendre le temps d’analyser toutes les mécaniques des classe/services que l‘on utilise. Aussi, quand le service est encapsulé dans une DLL, comment fait-on?

Pour éviter ce risque, les développeurs préfèrent utiliser l’injection par constructeur.

Injection par constructeur

Comme je vous l’ai dit, ce type d’injection est la plus courante et, est tout aussi simple à mettre en place que la précédente.

Ce qui donnerait dans notre exemple :

##################################################################################

public class CalculateurService

{

private ParamètresMonteur _paramètresMonteur;

public CalculateurService(ParamètresMonteur paramètresMonteur)

{

this._paramètresMonteur = paramètresMonteur;

}

public ParamètresUtilisateur ObtenirParamètres(int utilisateurId)

{

_paramètresMonteur.SetUtilisateur(utilisateurId);

return _paramètresMonteur.MonterParamètres();

}

}

##################################################################################

Cette implémentation permet d’informer, l’utilisateur de la classe, des dépendances à utiliser et de s’assurer que celles-ci soient instanciées en même temps que la création de l’objet.

Par contre, même si l’on s’est déchargé de l’instanciation des dépendances, notre classe dépend toujours fortement de « ParamètresMonteur ». Le jour où l’on va changer de classe pour créer les paramètres utilisateur, il nous faudra modifier toutes les classes qui y font références. Effectivement, ce n’est pas envisageable et cela risque de poser de gros problèmes de régression et d’effets de bord.

Pour pallier à ce problème, on utilise en parallèle l’injection par interface

L’injection par interface

Pour ce type d’injection, nous allons définir un contrat entre les différents objets (interfaces) qui permettra de réduire les dépendances. Il faut savoir que ce type d’injection est toujours utilisée avec une injection par constructeur ou par mutateur/champs et inversement.

Ce qui nous donne dans notre cas d’injection par constructeur :

##################################################################################

public class CalculateurService

{

private IParamètresMonteur _paramètresMonteur;

public CalculateurService(IParamètresMonteur paramètresMonteur)

{

this._paramètresMonteur = paramètresMonteur;

}

public IParamètresUtilisateur ObtenirParamètres(int utilisateurId)

{

_paramètresMonteur.SetUtilisateur(utilisateurId);

return _paramètresMonteur.MonterParamètres();

}

}

#################################################################################

Lorsque le service fait référence à l’assembly abstraite, aucune référence à l’implémentation directe ne sera nécessaire. Cela va faciliter la migration/changement de type de classe le jour J.

Même si nous respectons à la lettre les principes de l’injection de dépendance, nous aurons toujours des classes qui devrons se charger de l’instanciation des interfaces. Et par conséquent, nous aurons encore un faible couplage au niveau de notre application.

Pour encore réduire ces dépendances, nous allons utiliser l’inversion de contrôle (IOC).

Conteneur Ioc basique

Une fois que l’on a assimilé le principe de l’injection de dépendances, nous pouvons nous attaquer à un autre concept de plus haut niveau : l’inversion de contrôle.

En effet, si l’on a peu ou beaucoup de classes faisant appel à notre service, nous créerons autant de fois que nécessaire des instances « IparamètresMonteur » en amont. Nous nous retrouverons donc avec plusieurs instances, ce qui ne devait pas se produire. Aussi, le fait d’instancier notre interface à plusieurs endroits, nous nous retrouverons face à autant de duplication de code que d’instanciations, ce qui rendra notre application difficilement maintenable.

Par conséquent, la première idée qui nous vient à l’esprit lorsque l’on maîtrise mal l’inversion de dépendances est de rendre notre classe « ParamètresMonteur» statique. Ce qui est une très mauvaise idée. On n’a plus de problème d’instances par contre, à présent, il nous faudra rendre notre classe thread-safe et bien gérer le multi-threading. Sans parler des problèmes auxquels nous seront confrontés lorsqu’on voudra faire des tests unitaires.

D’autres aurons pensé au singleton. Il n’en est rien, il y a bien souvent trop de problèmes de fuite de mémoire.

Ensuite, lorsque l’on cherche dans ses fondamentaux, on pense tout naturellement à un des patrons de conception décrit par le « Gof » : la Factory.

En effet, une factory avec un singleton pourrait être la solution, malheureusement, il resterait toujours un couplage faible entre la classe et l’appel à chaque factory. Dans notre exemple, nous n’en aurions qu’une, on n’en voit donc pas l’intérêt. Mais, dans une application il y a largement plus de factory et il y aurait donc autant de couplages que de factory. Sans compter sur les instanciations qui peuvent être plus ou moins longues. Or dans certaines conditions nous pourrions en voir immédiatement sans devoir attendre le temps d’instanciation.

Pour pouvoir encore plus externaliser du code, comme la création de notre objet, et ne pas ralentir notre programme s’il y a des instanciations longues, nous allons devoir mapper les contrats avec leurs implémentations au démarrage de l’application.

Pour ce faire, nous allons utiliser un conteneur IOC.

L’inversion de contrôle

L’IOC ce défini comme un conteneur qui détermine ce qui doit être instancié et retourné au client pour éviter que ce dernier appel explicitement le constructeur avec l’opérateur « new ».

En résumé, c’est un objet qui agit comme un cache pour les instances dont nous avons besoin dans diverses parties de nos applications.

L’exemple

Ce premier exemple montre l’instanciation de nos classes au démarrage de notre application :

##################################################################################

public Programe()

{

conteneur = new ConteneurIoc();

var param = new ParamètresMonteur() ;

conteneur.Enregistrer<IParamètresMonteur>(param);

conteneur.Enregistrer<ICalculateurService>(new CalculateurService(param));

}

##################################################################################

Ainsi, au moment où l’on voudra utiliser notre classe, il suffira de faire appel à notre conteneur :

##################################################################################

private void UneMéthode()

{

var service = conteneur.Résoudre<ICalculateurService>();

var paramUtilisateur = service.ObtenirParamètres(1);

// la suite

}

##################################################################################

Pour aller plus loin

Depuis les premiers jours de la programmation orientée objet, les développeurs ont fait face à la question de la création et de la récupération des instances de classes dans les applications ainsi que dans les bibliothèques.

C’est pourquoi, il n’est pas utile de créer son propre conteneur, il y a de très bon conteneur disponible sur la toile qui s’en chargeront pour nous. Comme par exemple Uniy de Microsoft, Spring.Net ,Ninject, Google Guice et bien d’autres encore.

Conclusion

L’injection de dépendances est un principe très important en programmation, il permet une architecture modulable, fiable et simple, même sur des applications complexes.

Couplée avec l’inversion de contrôle, nous nous retrouvons dans une méthode de pensée permettant d’éviter de brider le comportement de l’application, tout en simplifiant son extensibilité sans code supplémentaire, et facilitant sa testabilité.

Au niveau maintenance, il permet de faciliter les tests unitaires de son code par l’injection de fausses implémentations (Mocks). Nous pouvons donc aussi changer l’implémentation d’une classe voire même d’un Framework rapidement et sans changer ne nombreuses lignes de code.

Le faible couplage rend l’application très maintenable, réduit le temps de maintenance en cas de changement et permet plusieurs configurations avec le même code.

Au niveau développement, il permet de se concentrer uniquement sur sa ou ses tâches principales, et de ne pas avoir d’effet de bord tant que l’interface est respectée ainsi que d’inciter les développeurs à créer de petits modules réutilisables dans le code.

L’une des contraintes si vous utiliser un conteneur IOC tel que Unity ou Google Guice est qu’ils sont plus ou moins compliqués à mette en place et ne répondent pas du tout aux mêmes besoins. Cependant, on en entend beaucoup parler avec le .NET Core qui dispose d’une infrastructure de service IOC. Aussi, il y a des articles qui bourgeonnent partout avec de très bon tutoriel.

Par conséquent, un projet sans un minimum d’injection de dépendances, c’est une future application qui connaîtra de très gros problèmes de maintenance et d’évolutivité. Surtout que le surcoût de sa mise-en-place avec l’inversion de contrôle est proche de 0, ou en tout cas bien inférieur au surcoût s’ils ne sont pas utilisées.

Pour conclure cet article, l’IoC ne peut que rendre vos projets plus robustes et évolutifs. Il faut donc l’utiliser sans modération ;).

A propos de l’auteur :

Thibault Herviou : Architect logiciel / développeur d’application, je suis avant tout technophile / bidouilleur en tous genres, du raspberry pi aux imprimantes 3D en passant par le brassage de bières amateur. De nature curieuse, je suis adepte du « DIY », en effet, j’aime avoir la satisfaction de faire les choses par moi-même, mais surtout, pouvoir comprendre le fonctionnement des objets/technologies qui m’entourent.