Aller au contenu

Fondamentaux • Injection de dépendances

Concepts

L’injection de dépendances (Dependency Injection en anglais) est un modèle de conception (Design Pattern en anglais). Il permet, comme son nom l’indique, d’injecter une dépendance (au lieu de l’instancier directement).

Arrêtons-nous un moment sur plusieurs concepts clefs.

Dépendance

Qu’est-ce qu’une dépendance ?

Imaginons que nous développons un jeu de course automobile. Schéma des dépendances nécessaires pour le moteur de jeu de course

Le moteur de jeu de course a besoin pour bien fonctionner :

  • d’un gestionnaire d’entrées (manette, clavier, souris, écran tactile, volant, pédale, etc.) ;
  • d’une persistence (sauvegarder la progression, les classements, les meilleurs temps de course, etc.) ;
  • d’un moteur de physique ;
  • d’un média pour l’affichage (écran de PC, écran de mobile, navigateur web) ;
  • d’un média pour le son ;
  • etc.

Cette liste représente les dépendances du moteur de jeu. Le moteur en a besoin pour bien fonctionner et couvrir tous les besoins et fonctionnalités.

Instantiation

Nous pourrions directement instancier les dépendances (via new()) dans la classe du moteur de jeu.
Instantiation des dépendances directement dans le moteur de jeu de course

Cette approche fonctionne, bien entendu. Mais elle impose un couplage fort entre le moteur de jeu de course et toutes ses dépendances.

Qu’est-ce un couplage fort ? C’est un lien étroit entre deux classes. Les deux classes sont très dépendantes l’une de l’autre. Elles sont imbriquées. Tout changement apporté à une classe aura potentiellement des impacts sur l’autre.

C’est vraiment une situation que nous souhaitons éviter à tout prix. Nous préférons des classes autonomes, isolées, indépendantes.

Pourtant, le moteur de jeu a besoin de toutes les dépendances que nous avons citées. Alors comment faire ?

Contrat d’interface

Concentrons-nous sur le gestionnaire d’entrées. Il a pour rôle de capturer les actions du joueur. Ces actions vont lui permettre de piloter la voiture sur le circuit de course :

  • accélérer ;
  • freiner ;
  • tourner le volant.

Si nous devions définir un contrat pour le gestionnaire d’entrées, il pourrait être :

  • niveau d’accélération : un nombre décimal entre 0 (aucune accéleration) et 1 (accélération maximale) ;
  • niveau de freinage : un nombre décimal entre 0 (aucun freinage) et 1 (freinage maximal) ;
  • direction : un angle entre -45º (volant tourné complètement à gauche) et +45º (volant tourné complètement à droite). Interface du gestionnaire d'entrées utilisée par le moteur de jeu

Le moteur de jeu va utiliser ces 3 valeurs renvoyées par le gestionnaire d’entrées afin de modifier le comportement de la voiture.

Le moteur de jeu a-t-il besoin de savoir comment sont obtenues ces 3 valeurs ? Le joueur peut utiliser un système de volant/pédalier, une manette, un clavier, un écran tactile, etc. Mais le moteur de jeu a uniquement besoin des 3 valeurs, quel que soit le dispositif employé par le joueur.

Nous avons défini un contrat d’interface : les propriétés et méthodes que s’engage à fournir la dépendance.

Le moteur de jeu aura besoin d’une implémentation concrète qui respecte ce contrat, rien de plus, rien de moins. Il n’aura pas besoin de connaitre les détails de l’implémentation, comment est effectivement codé le gestionnaire d’entrées.

Injection de dépendances

Le moteur de jeu a défini ce dont il avait besoin. Nous devons lui fournir maintenant une classe qui fait le boulot.

C’est là que l’injection de dépendances intervient. Elle va fournir au moteur de jeu une classe concrète qui respecte le contrat d’interface. Il pourra utiliser alors les méthodes et propriétés définies par ce contrat.

Injection du gestionnaire de manette de jeu

Il y a plusieurs manières d’injecter la dépendance :

  • via un paramètre du constructeur
  • via une méthode
  • via une propriété décorée

Quelle que soit la façon de faire, le mécanisme d’injection de dépendance va fournir une classe correspondante à l’interface souhaitée.

Pour y arriver, nous allons devoir paramétrer l’injection de dépendance afin de lui définir les liens entre interface et implémentation. Paramétrage de l'injection de dépendances

Avantages

Le mécanisme d’injection de dépendances permet de fournir une implémentation concrète correspondant à un contrat d’interface.

Cela nous apporte un certain nombre d’avantages :

  • découplage entre un besoin (l’interface) et les moyens de répondre à ce besoin (la classe concrète) ;
  • mise à disposition d’un ensemble de dépendances que nous pourrons utiliser selon nos besoins ;
  • résolution de dépendances en cascade : une dépendance qui a besoin d’une dépendance, qui en a besoin d’une autre, etc.

L’injection de dépendances avec .NET

Afin de gérer l’injection de dépendances, .NET fournit nativement un mécanisme de gestion de dépendances via le namespace Microsoft.Extensions.DependencyInjection.

Comment cela fonctionne-t-il ?

  • les dépendances utilisées dans notre code sont représentées par des interfaces ;
  • nous codons des implémentations concrètes pour ces interfaces ou nous utilisons des implémentations proposées par des paquets NuGet ;
  • .NET propose le conteneur de dépendances (des services pour .NET) IServiceProvider ;
  • nous ajoutons les dépendances dans IServiceCollection ;
  • le conteneur de dépendances est construit via BuildServiceProvider() ;
  • .NET résout les dépendances en injectant les implémentations concrètes dans les constructeurs des classes où elles sont utilisées ;
  • nous pouvons également récupérer nous même des dépendances via GetService() ou GetRequiredService().

Pour nous accompagner et mieux comprendre le fonctionnement, commençons par créer un nouveau projet console.

Terminal window
dotnet new console --name=DependencyInjection

Ajoutons le paquet Nuget : Microsoft.Extensions.Hosting.

Terminal window
cd DependencyInjection
dotnet add package Microsoft.Extensions.Hosting

Nous pouvons maintenant ouvrir le projet DependencyInjection dans notre IDE favori.

Tout d’abord, créons un conteneur de services IServiceCollection.

Program.cs
using Microsoft.Extensions.DependencyInjection;
// Création du conteneur de services
IServiceCollection services = new ServiceCollection();

Nous avons maintenant une base de travail.

Ajout de dépendances dans le conteneur

.NET propose d’ajouter des dépendances dans le conteneur avec 3 durées de vie :

  • Singleton : une instance de la dépendance est créée au démarrage de l’application et .NET fournit toujours cette instance ;
  • Scoped : une instance de la dépendance est créée en même temps que le scope, .NET fournit cette instance depuis ce scope et pendant sa durée de vie, puis l’instance est détruite en même temps que le scope ;
  • Transient : une nouvelle instance de la dépendance est créée systématiquement dès qu’elle est requise.

Rentrons un peu plus dans le détail.

Durée de vie Singleton

Un Singleton est un design pattern qui nous garantie qu’une seule instance d’une classe est créée.

En faisant un parallèle avec le monde réél, le tableau de La Joconde est un Singleton. Il est unique au monde et le musée du Louvre en est garant. Le visage de Mona Lisa peut être admiré par tous et chacun peut profiter de son sourire.

Un Singleton a donc les propriétés suivantes :

  • l’instance est unique, pas de doublon possible ;
  • l’instance peut héberger des données qui pourront être partagées et réutilisées ;
  • comme l’instance n’est créée qu’une seule fois, elle peut (mais ne doit pas obligatoirement) être complexe.

Nous pouvons ajouter une dépendance de type Singleton en utilisant la méthode AddSingleton(). Il s’agit d’une méthode d’extension de IServiceCollection.

Un cache mémoire est un exemple d’utilisation d’un Singleton. Les données du cache doivent être partagées et identiques quel que soit le consommateur du cache.

Program.cs
using Microsoft.Extensions.DependencyInjection;
// Création du conteneur de services
IServiceCollection services = new ServiceCollection();
// Ajout de la dépendance IMemoryCache au conteneur en tant que Singleton
services.AddSingleton<IMemoryCache, MemoryCache>();
// Contrat d'interface du cache mémoire
internal interface IMemoryCache
{
object Get(string key);
void Set(string key, object value);
}
// Implémentation concrète du cache mémoire
internal sealed class MemoryCache : IMemoryCache
{
private readonly Dictionary<string, object> _cache = new();
public object Get(string key) => _cache[key];
public void Set(string key, object value) => _cache[key] = value;
}

Nous avons :

  • déclaré une interface IMemoryCache : il s’agit du contrat à respecter ;
  • déclaré une classe MemoryCache implémentant cette interface et utilisant un dictionnaire comme cache ;
  • ajouté la dépendance IMemoryCacheMemoryCache au conteneur en tant que Singleton.

Durée de vie Transient

À l’opposé de Singleton, une dépendance avec la durée de vie Transient est éphémère. Elle est instanciée à la demande, n’est utilisée qu’une fois, puis détruite.

Pour faire de nouveau un parallèle avec le mode réel, un mouchoir jetable est Transient. Nous prenons un mouchoir depuis un paquet, nous nous mouchons avec, puis nous le jetons à la poubelle. Un mouchoir a les mêmes propriétés que n’importe quel autre mouchoir du paquet : la taille, la couleur, la texture, les capacités d’absorption, etc.

Nous voyons avec cet exemple que la dépendance de type Transient a les propriétés suivantes :

  • elle répond à un besoin ciblé, c’est donc une dépendance plutôt légère avec des fonctionnalités réduites ;
  • elle est facile à instancier, c’est-à-dire que c’est peu coûteux de la créer ;
  • elle ne possède pas de données persistantes, elle est stateless.

Nous pouvons ajouter une dépendance de type Transient en utilisant la méthode AddTransient(). Il s’agit d’une méthode d’extension de IServiceCollection.

Program.cs
using Microsoft.Extensions.DependencyInjection;
// Création du conteneur de services
IServiceCollection services = new ServiceCollection();
// Ajout de la dépendance IMemoryCache au conteneur en tant que Singleton
services.AddSingleton<IMemoryCache, MemoryCache>();
// Ajout de la dépendance ILogger au conteneur en tant que Transient
services.AddTransient<ILogger, ConsoleLogger>();
// ...
// Contrat d'interface du service de journalisation
internal interface ILogger
{
void LogInfo(string message);
void LogError(string message);
}
// Implémentation concrète du service de journalisation via la console
internal sealed class ConsoleLogger : ILogger
{
public void LogInfo(string message) => Console.WriteLine($"{DateTime.Now} - {message}");
public void LogError(string message) => Console.WriteLine($"{DateTime.Now} - ERROR: {message}");
}

Nous avons :

  • déclaré une interface ILogger ;
  • déclaré une classe ConsoleLogger implémentant cette interface et utilisant la classe statique Console ;
  • ajouté la dépendance ILoggerConsoleLogger au conteneur en tant que Transient.

La classe ConsoleLogger a les caractéristiques suivantes :

  • elle est légère, facile à instancier ;
  • aucune donnée persistante n’est présente ;
  • elle répond au besoin de journaliser des messages ;
  • puisque nous avons une application console, elle utilise la classe statique Console pour afficher les messages ;
  • dans une autre situation, les messages auraient pu être consignés dans un fichier texte, une table de base de données ou bien un gestionnaire d’événements.

Durée de vie Scoped

Définissons d’abord ce qu’est un scope. Il s’agit d’un sous-conteneur. Dans une application web, un nouveau scope est utilisé à chaque requête client. Il est créé à la réception de la requête et détruit une fois que le traitement de la requête est terminé. Toutes les dépendances sont récupérées à partir de ce scope.

S’il est créé automatiquement par ASP.NET, nous pouvons également en créer un nous même via les méthodes d’extension de IServiceProvider :

  • CreateScope() en mode synchrone ;
  • CreateAsyncScope() en mode asynchrone.

À partir d’un scope, il est possible d’obtenir une dépendance via la méthode GetService() ou GetRequiredService().

Mais à quoi ça sert exactement ?

Pour les dépendances enregistrées avec une durée de vie Singleton ou Transient, le scope n’apporte rien de particulier. Par contre, les dépendances avec une durée de vie Scoped seront instanciées une seule fois par scope. Si une dépendance est injectée plusieurs fois pour le même scope alors l’instance sera toujours la même. Si un autre scope est créé, une nouvelle instance de la dépendance sera ainsi créée pour ce nouveau scope. Nous sommes donc à mi-chemin entre Singleton et Transient.

Faisons de nouveau une analogie avec un événement de la vie réelle. Nous nous rendons dans un parc d’attraction. Nous achetons un billet d’entrée pour ce parc. Ce billet n’est valable que pour la journée et uniquement pour nous. Par contre, ce billet nous permet d’accéder à toutes les attractions du parc, autant de fois que nous le souhaitons.

Qu’apporte donc la durée de vie Scoped ?

  • pendant la durée de vie du scope, les dépendances ne sont pas recréées ce qui évite une charge de travail ;
  • les dépendances restent stateless puisque recréés à chaque nouveau scope ;
  • mais des données peuvent être partagées pendant la durée de vie du scope.

Limitations

Certaines dépendances peuvent dépendre les unes des autres. Il faut dans ce cas être attentif aux durées de vie, en particulier Scoped.

Récupération des dépendances

Le moyen le plus simple d’obtenir une dépendance est d’utiliser le constructeur de la classe. Nous utiliserons cette façon de faire quasiment tout le temps. Chaque dépendance est un paramètre du constructeur. Le fournisseur de services IserviceProvider va alors pour chaque paramètre du constructeur :

  • lire le type du paramètre ;
  • chercher le type correspondant dans le conteneur de services IservicesCollection ;
  • instancier la classe correspondant au type ;
  • si cette classe a également des dépendances alors il essaie de les résoudre ;
  • une fois la classe instanciée, elle est utilisée comme valeur du paramètre.

Nous pouvons également obtenir manuellement une dépendance en appelant la méthode d’extension GetService() ou GetRequiredService() de l’interface IServiceProvider.

Program.cs
using Microsoft.Extensions.DependencyInjection;
// Création du conteneur de services
IServiceCollection services = new ServiceCollection();
// Ajout de la dépendance IMemoryCache au conteneur en tant que Singleton
services.AddSingleton<IMemoryCache, MemoryCache>();
// Ajout de la dépendance ILogger au conteneur en tant que Transient
services.AddTransient<ILogger, ConsoleLogger>();
// Construction du fournisseur de services
IServiceProvider serviceProvider = services.BuildServiceProvider();
// Récupération du service de type IMemoryCache
IMemoryCache memoryCache = serviceProvider.GetRequiredService<IMemoryCache>();
// Utilisation du cache
memoryCache.Set("cache-key", "cache-value-123");
object cacheValue = memoryCache.Get("cache-key");
// Contrat d'interface du cache mémoire
internal interface IMemoryCache
{
object Get(string key);
void Set(string key, object value);
}
// Implémentation concrète du cache mémoire
internal sealed class MemoryCache : IMemoryCache
internal sealed class MemoryCache(ILogger logger) : IMemoryCache
{
private readonly Dictionary<string, object> _cache = new();
public object Get(string key) => _cache[key];
public object Get(string key)
{
if (!_cache.TryGetValue(key, out object? value))
{
logger.LogError($"Key '{key}' not found");
throw new InvalidOperationException();
}
logger.LogInfo($"Read from cache '{key}' → '{value}'");
return value;
}
public void Set(string key, object value) => _cache[key] = value;
public void Set(string key, object value)
{
_cache[key] = value;
logger.LogInfo($"Write '{value}' to cache '{key}'");
}
}
// Contrat d'interface du logger de console
internal interface ILogger
{
void LogInfo(string message);
void LogError(string message);
}
// Implémentation concrète du logger console
internal sealed class ConsoleLogger : ILogger
{
public void LogInfo(string message) => Console.WriteLine($"{DateTime.Now} - {message}");
public void LogError(string message) => Console.WriteLine($"{DateTime.Now} - ERROR: {message}");
}

Si nous exécutons le code, nous obtenons ceci dans la console :

17/03/2024 12:46:42 - Write 'cache-value-123' to cache 'cache-key'
17/03/2024 12:46:42 - Read from cache 'cache-key' → 'cache-value-123'

Nous avons apporté beaucoup de modifications dans le code ci-dessus. Tout d’abord, nous avons :

  • construit le fournisseur de service via la méthode d’extension BuildServiceProvider() ;
  • récupéré une implémentation concrète de IMemoryCache via la méthode d’extension GetRequiredService() ;
  • appelé le cache mémoire afin de tester son comportement.

Nous avons ensuite modifié la classe MemoryCache en :

  • ajoutant un constructeur avec le paramètre ILogger permettant d’injecter cette dépendance ;
  • utilisant ILogger pour journaliser les actions de chaque méthode.

Ainsi, nous avons pu voir comment récupérer une dépendance :

  • le cache mémoire via la méthode GetRequiredService() ;
  • le service de journalisation via le constructeur du cache mémoire.

Pour aller plus loin

Nous avons vu ensemble comment fonctionne le design pattern d’injection de dépendances et la façon dont il est implémenté nativement dans .NET.

Ce design pattern est primordial pour coder une application de qualité. Il est donc important de bien comprendre comment il fonctionne et comment l’employer.

Microsoft propose une documentation complète sur ce sujet (et bien d’autres). N’hésitez pas à la consulter pour approfondir ou éclaircir certains points.

Avant que l’injection de dépendances ne soit proposé nativement dans .NET Core, il existait des paquets NuGet qui permettaient (et permettent encore) de le faire :

Ces alternatives peuvent être utiles au cas où le mécanisme natif de .NET ne serait pas suffisant ou bien pour le code legacy écrit en .NET Framework.