L'inversion de dépendance facile (et pas chère)
par Sylvainpublié leAh, l'inversion de dépendance, voilà une pratique qui est parfois difficile à faire passer dans les équipes. En particulier quand il s'agit d'applications mobiles. Les arguments sont toujours les mêmes :
- Ça ajoute trop de complexité
- Ça bouffe des ressources et plombe les performances
- On n'en a pas besoin de toute façon on n'utilise qu'une seule implémentation
- Les frameworks c'est le mal absolu
- Tu peux m'expliquer l'inversion de dépendance ?
- Fastoche, tu ouvres le gestionnaire de package nuget et tu installes AutoFac
Et si on trouvait un moyen simple, efficace et sans framework d'inverser les dépendances ?
Inversion et injection de dépendances
Le but ici n'est pas de proposer un cours complet sur les principes d'inversion et d'injection de dépendances mais juste de présenter une astuce rapide, facile et pas chère pour effectuer une inversion de dépendance sans avoir recours à aucun framework.
Si vous ne connaissez pas ces principes, je vous invite à vous rendre sur votre moteur de recherche préféré et d'y saisir les mots clés suivants (pas tous en même temps, hein !) : inversion de dépendance / injection de dépendance / dependency inversion / dependency injection / constructor injection / ioc di.
Vous verrez qu'assez souvent cela vous ramène à l'un ou l'autre des frameworks à la mode. Et dans les cas les plus complexes, c'est pertinent de ne pas réinventer la roue. Mais dans une application mobile, on a souvent besoin de quelque chose de beaucoup plus simple sans s'encombrer d'un framework ou d'une bibliothèque supplémentaire.
Pourquoi une inversion de contrôle ?
Sans m'étendre sur le sujet, le but principal de l'inversion de contrôle est de ne pas laisser une classe dépendre d'autres classes. Elle ne doit dépendre que de leurs abstractions.
L'application de ce principe a des conséquences assez variées mais qui vont toutes dans le sens d'un code plus propre et mieux structuré :
- Respect du principe de responsabilité unique : la classe ne voit que des abstractions, la responsabilité de ce qu'il s'y passe concrètement ne la regarde pas
- Découplage : les classes ne sont pas liées entre elles
- Modularité : une classe n'étant pas liée à une implémentation particulière de ses dépendances, celle-ci est facilement interchangeable. Une même classe pourra, par exemple, lire les données dans une base Sql, un fichier json ou xml en fonction de l'implémentation du service d'accès aux données injecté.
- Testabilité : puisqu'on ne dépend pas d'une implémentation concrète, on peut facilement utiliser des simulacres pour effectuer des tests unitaires.
Pour l'astuce que je vais vous présenter très bientôt (je vous tiens en haleine !) nous allons passer les dépendances via le constructeur de la classe.
L'intérêt est double :
- Le code est lisible car la liste des dépendances d'une classe est directement dans la signature de son constructeur : pas de surprise cachée dans le code de la classe
- On ne peut pas oublier de passer une dépendance à la classe puisqu'on en a besoin pour l'instancier
Ici, les puristes auront sans doute les poils qui se dressent car ce que je vais vous montrer n'est pas au sens strict de l'injection de dépendance, mais pour les cas simples, ça en présente tous les avantages.
Ce que nous allons chercher à faire c'est avoir pour une classe :
- Une implémentation par défaut de ses dépendances
- La possibilité d'injecter d'autres implémentations en cas de besoin (notamment pour les tests unitaires)
L'inversion de contrôle pas à pas
Partons de pas grand-chose
Partons d'une classe ne suivant aucun pattern particulier, la dépendance est directement instanciée dans le corps de la classe. Beurk !
public class MaClasse
{
private readonly MaDepdendance _maDependance;
MaClasse()
{
_maDependance = new MaDependance();
}
}
Imaginez juste un instant que la classe MaDependance
dépende elle-même d'une autre classe, bienvenue au code spaghetti et aux crottes de nez dans le code !
Dans un premier temps, nous allons simplement chercher à passer la dépendance depuis l'extérieur. De cette façon, notre classe n'aura plus la responsabilité de l'instancier. Rien de bien compliqué.
public class MaClasse
{
private readonly MaDepdendance _maDependance;
// dépendance injectée via la constructeur
MaClasse(MaDependance maDependance)
{
_maDependance = maDependance ?? throw new ArgumentNullException("Un message bien senti !");
}
}
Mieux mais pas terrible. Nous n'avons en réalité fait que déplacer le problème. Pour l'instant, toujours pas de découplage ou de modularité dans le code.
Dépendons de l'abstraction au lieu de l'implémentation
Pour faire mieux, nous allons appliquer stricto-sensu le principe d'inversion de contrôle : dépendre d'abstraction au lieu d'implémentation.
public class MaClasse
{
private readonly IMaDependance _maDependance;
// On injecte une abstraction (interface) au lieu d'une implémentation concrète
MaClasse(IMaDependance maDependance)
{
_maDependance = maDependance ?? throw new ArgumentNullException("Un message bien senti !");
}
}
Vous avez vu ? Le "I" pour interface
?
Ce n'est bien entendu qu'une convention de nommage, mais en réalité il s'est passé ceci :
// interface décrivant les membres à implémenter
public interface IMaDependance
{
MaMethode();
}
// MaDependance implémente désormais l'interface IMaDependance
public class MaDependance : IMaDependance
{
public void MaMethode()
{
// implémentation de l'interface
}
}
Au lieu de passer directement l'implémentation concrète de la dépendance, nous avons passé son abstraction. Chouette !
Reste un souci. Sans l'aide d'un framework qui ferait ça dynamiquement, il reste nécessaire de passer explicitement les dépendances au constructeur de notre classe lors de son instanciation.
Double problème :
- Cela peut vite devenir très lourd si la classe a plusieurs dépendances
- Que se passe t'il si l'on souhaite changer d'implémentation alors que celle-ci est passée en dur partout dans le code ?
Ajoutons une implémentation par défaut
Pour ajouter une implémentation pour défaut, nous allons simplement tirer parti du mot clé this
appliqué au constructeur de la classe. this
permet, en effet, d'appeler un constructeur à partir d'un autre constructeur.
public class MaClasse
{
private readonly IMaDependance _maDependance;
// Constructeur acceptant un argument
MaClasse(IMaDependance maDependance)
{
_maDependance = maDependance ?? throw new ArgumentNullException("Un message bien senti !");
}
// Constructeur vide, qui par appelle l'autre constucteur avec 'this'
MaClasse() : this(new MaDepdendance()){}
}
De cette manière, le constructeur vide appellera systématiquement le constructeur ayant la dépendance en argument en lui passant une implémentation concrète.
Nous obtenons donc deux manières d'instancier notre classe :
- Avec le constructeur vide : la dépendance sera implémentée par défaut
- Avec l'autre constructeur : la dépendance sera implémentée manuellement par le développeur
Nous avons donc atteint notre objectif premier avec un simple mot clé this
du langage C#
C'est bien, mais il subsiste un problème : l'implémentation par défaut est instanciée en dur dans le code :
- Si on suit les principes SOLID, une classe doit être évolutive sans modification. Ici, ce n'est pas le cas : le jour où on aura besoin de changer d'implémentation par défaut, il sera nécessaire de modifier le code au niveau du constructeur.
- Si plusieurs classes dépendent du même service, on risque simplement d'oublier de modifier le constructeur de l'une ou l'autre des classes qui utilisent
MaDependance
.
Trouvons donc un moyen de globaliser la correspondance entre une abstraction et son implémentation par défaut. C'est-à-dire, qu'en pratique, pour une interface donnée on obtienne systématiquement la même implémentation.
Prenons une bonne résolution
Avec Xamarin.Forms nul besoin de chercher très loin, le DependencyService
jouera ce rôle à merveille.
A strictement parler, le DependencyService
sert surtout à la résolution d'implémentations natives (dans les projets iOS, Android...) pour les utiliser dans le projet commun Xamarin.Forms.
En pratique, cela fonctionne très bien pour résoudre n'importe quel type.
Le DependencyService
expose principalement trois méthodes : Register
, Get
et Resolve
.
Register
ne souffre pas d’ambiguïté, c'est ici que nous enregistrerons notre implémentation par défaut.
Pour la simplicité de la démonstration, et parce qu'en pratique ce sera souvent le cas, je prends la classe App.xaml.cs
comme point d'entrée pour enregistrer mes dépendances.
public partial class App : Application
{
public App()
{
InitializeComponent();
// On enregistre le type MaDependance pour l'interface IMaDependance
DependencyService.Register<IMaDependance, MaDependance>();
MainPage = new MainPage();
}
//[...]
}
Faites comme vous voulez, ce qui est important c'est de bien enregistrer les types avant toute utilisation du DependencyService
dans le code, cela va de soi.
Get
et Resolve
semblent fonctionner de manière identique, cependant la documentation affichée dans Visual Studio nuance quelque peu leur usage :
Resolve : The method to use to resolve dependencies by type
Get : Returns the platform-specific implementation of type T
Resolve
semble mieux correspondre à notre besoin.
public class MaClasse
{
private readonly IMaDependance _maDependance;
MaClasse(IMaDependance maDependance)
{
_maDependance = maDependance ?? throw new ArgumentNullException("Un message bien senti !");
}
// On résoud le type avec le DependencyService
MaClasse() : this(DependencyService.Resolve<IMaDependance>()){}
}
Désormais, notre classe ne dépend plus d'aucune implémentation concrète, elle n'est plus qu'abstraction !
Imaginons que de nombreuses classes dépendent de IMaDependance
et qu'on ait besoin de changer l'implémentation de la dépendance partout dans le code, il suffira de remplacer l'implémentation enregistrée dans DependencyService.Register
.
Pour le reste, ça n'a pas changé : pour injecter une implémentation différente il est nécessaire d'instancier la classe en passant explicitement la dépendance dans le constructeur. Ce sera notamment le cas pour les simulacres créés à fin de tests unitaires.
Conclusion
Si l'inversion de contrôle est souvent confondue avec le framework qui la met en oeuvre, il s'agit en réalité d'un principe à l'énoncé plutôt simple : une classe doit dépendre d'abstractions et non d'implémentations.
Et dans les cas simples, on peut l'appliquer sans framework et profiter de ses avantages à moindre frais : découplage, modularité, testabilité.
Je vous en ai présenté ici une façon fort simple à base d'interface
, d'appel à un constructeur par défaut avec le mot clé this
et du DependencyService
de Xamarin.Forms.
Pour un projet mobile, c'est souvent largement suffisant !
Le code source
Comme toujours, un petit projet d'exemple sur mon GitHub. Celui-ci est minimaliste : un service, un ViewModel et un test unitaire. Son seul intérêt est de démontrer le fonctionnement de tout cela de façon un peu moins théorique.
Et chez vous l'inversion de contrôle, ça se passe comment ?