Les injections de dépendances
Introduction
L'injection de dépendances (Dependency Injection ou DI) est un principe de conception qui consiste à fournir à une classe les objets dont elle dépend, plutôt que de les créer elle-même. Cette approche réduit le couplage entre les composants, facilite les tests unitaires et améliore la maintenabilité des applications. Dorénavant, elle est aujourd'hui au cœur de tous les composants modernes de l'écosystème Microsoft .NET.
Problématique
Imaginons un service chargé d'envoyer des notifications via des SMS :
public class NotificationService
{
private readonly EmailSender _emailSender = new();
public void Notify(string recipient, string message)
{
_emailSender.Send(recipient, message);
}
}
Le problème est que la classe NotificationService crée elle-même l'instance de la classe EmailSender dont elle a besoin pour envoyer des notifications. Cette implémentation présente plusieurs inconvénients :
- La classe
EmailSenderest fortement couplée à la classeNotificationService. - Il est plus facile de remplacer l'envoi de notification actuel par un autre moyen tels que SMS ou Push.
- Les tests unitaires sont plus compliqués à implémenter car il est difficile de substituer facilement cette dépendance.
Principe
Avec l'injection de dépendances, la classe ne crée plus elle-même les objets dont elle dépend. Leur création est prise en charge par un conteneur d'injection de dépendances, qui les injecte généralement via le constructeur. On commence par définir une abstraction :
Puis nous implémentons la classe EmailSender de la manière suivante :
public class EmailSender : INotificationSender
{
public void Send(string recipient, string message)
{
Console.WriteLine($"Message [{message}] envoyé à [{recipient}] par email");
}
}
L'implémentation de la classe NotificationService devient :
public class NotificationService
{
private readonly INotificationSender _notificationSender;
public NotificationService(INotificationSender notificationSender)
{
_notificationSender = notificationSender;
}
public void Notify(string recipient, string message)
{
_notificationSender.Send(recipient, message);
}
}
Ainsi, la classe ne connaît plus l'implémentation concrète. Elle dépend uniquement d'une abstraction.
Puis nous enregistrons le service injecté dans le conteneur d'injections de dépendance, (dans le fichier program.cs) :
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddScoped<INotificationSender, EmailSender>();
builder.Services.AddScoped<NotificationService>();
var app = builder.Build();
var notificationService = builder.Services.BuildServiceProvider().GetRequiredService<NotificationService>();
notificationService.Notify("James", "Hello :)");
Console.Read();
await app.RunAsync();
Enregistrement au travers de la même interface
En ajoutant cette classe :
public class SmsSender : INotificationSender
{
public void Send(string recipient, string message)
{
Console.WriteLine($"Message [{message}] envoyé à [{recipient}] par SMS");
}
}
Nous pouvons enregistrer les deux implémentations de notification au travers de la même interface :
builder.Services.AddScoped<INotificationSender, EmailSender>();
builder.Services.AddScoped<INotificationSender, SmsSender>();
Si nous écrivons :
public class NotificationService
{
private readonly INotificationSender _notificationSender;
public NotificationService(INotificationSender notificationSender)
{
_notificationSender = notificationSender;
}
}
La résolution d'un seul INotificationSender retourne l'instance de la dernière classe enregistrée dans le conteneur d'injection de dépendance. Ainsi, dans notre cas, il injectera une instance de la classe SmsSender.
Pour obtenir l'ensemble des notifications, nous devons déclarer en paramètre un IEnumerable<INotificationSender> :
public class NotificationService
{
private readonly IEnumerable<INotificationSender> _notificationSenders;
public NotificationService(IEnumerable<INotificationSender> notificationSenders)
{
_notificationSenders = notificationSenders;
}
}
Les cycles de vie
Le conteneur d'injection de dépendances propose trois durées de vie principales : Singleton, Scoped et Transient.
Singleton
Une seule instance est créée pour toute la durée de vie de l'application :
Scoped
Une instance est créée par portée (scope). Dans une application ASP.NET Core, cela correspond généralement à une requête HTTP.
Transient
Une nouvelle instance est créée à chaque demande.
Le choix du cycle de vie est important pour garantir les performances et éviter des problèmes de partage de données.
Injections de dépendance et principes SOLID
L'injection de dépendances est une technique permettant de mettre en œuvre le principe SOLID DIP (Dependency Inversion Principle). Ce principe décrit une manière de concevoir les dépendances entre les composants. L'injection de dépendances est une technique qui facilite son application en confiant la création et l'assemblage des objets à un conteneur.
Avantages
Les exemples de code ci-dessus nous montre que l'injection de dépendances présente de nombreux avantages :
- La réduction du couplage entre les composants.
- La meilleure séparation des responsabilités.
- Le remplacement facilité des implémentations.
- La simplification des tests unitaires grâce aux mocks.
- Le code plus évolutif et plus maintenable.
Relations entre les niveaux de dépendance
Voici une règle importante : un service ne doit pas avoir une dépendance dont la durée de vie est plus courte que la sienne. Autrement dit, nous ne pouvons injecter directement un service Scoped dans un service Singleton. Plus généralement, il faut rester vigilant lorsqu'un service ayant une longue durée de vie conserve une dépendance ayant une durée de vie plus courte. Ainsi, si nous implémentons :
builder.Services.AddScoped<INotificationSender, EmailSender>();
builder.Services.AddSingleton<NotificationService>();
public class NotificationService
{
private readonly INotificationSender _notificationSender;
public NotificationService(INotificationSender notificationSender)
{
_notificationSender = notificationSender;
}
}
L'instance de la classe NotificationService est un singleton. Elle existe pendant toute la durée de vie de l'application, tandis que l'instance de la classe EmailSender a une espérance de vie associée à une portée (scope). Une exception est-elle toujours levée ? Non, pas systématiquement. Cela dépend de l’activation de la validation des portées, via l’option ValidateScopes. Par exemple, avec la configuration suivante :
var builder = Host.CreateApplicationBuilder(args);
builder.ConfigureContainer(
new DefaultServiceProviderFactory(
new ServiceProviderOptions
{
ValidateScopes = true
}));
Le conteneur d'injection de dépendances lève une exception de type InvalidOperationException dont le message est :
Dans ASP.NET Core, cette validation est activée par défaut dans l’environnement Development.
Toutefois, lorsqu'un service Singleton doit utiliser ponctuellement un service Scoped ou Transient, il doit créer un scope (une portée) dans lequel il peut obtenir et utiliser le service. Voici un exemple dans la méthode Notify :
public class NotificationService
{
private readonly IServiceProvider _serviceProvider ;
public NotificationService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void Notify(string recipient, string message)
{
using IServiceScope scope = _serviceProvider.CreateScope();
var notificationSender = scope.ServiceProvider
.GetRequiredService<INotificationSender>();
notificationSender.Send(recipient, message);
}
}
Faut-il injecter IServiceProvider pour obtenir les injections de dépendance ?
Injecter l'interface IServiceProvider peut s'avérer pratique pour obtenir des services à partir du conteneur d'injections de dépendance, mais cela doit être réalisé avec précaution et uniquement lorsque c'est justifié :
public class OrderService
{
private readonly IServiceProvider _provider;
public OrderService(IServiceProvider provider)
{
_provider = provider;
}
}
puis
L'injection de IServiceProvider peut être utile dans certains scénarios particuliers (création dynamique d'objets, résolution différée, usines de services, etc.), mais son utilisation systématique conduit à reproduire le pattern Service Locator, généralement considéré comme un anti-pattern car il masque l'ensemble des dépendances de la classe.
Pourquoi injecter une classe au travers d'une interface plutôt qu'une classe sans interface ?
Nous pouvons injecter une classe sans l'exposer au travers d'une interface :
Puis implémenter le constructeur :
Attention : les interfaces sont utiles lorsque nous souhaitons disposer de plusieurs implémentations d'un même service, faciliter les tests unitaires et réduire le couplage entre les composants. Cependant lorsqu'un service ne possède qu'une seule implémentation et qu'aucune abstraction n'est nécessaire, l'injection d'une classe sans interface est peut être acceptée.
Les dépendances circulaires
Dans une application .NET, les dépendances circulaires sont détectées par le conteneur lorsqu'il tente de construire le graphe des dépendances. Si une dépendance circulaire est rencontrée, une exception de type InvalidOperationException est levée lors du démarrage de l'application avec le message :
Que se passe-t-il lors de la surcharge de constructeurs ?
Le principe
Lorsque plusieurs constructeurs sont présents dans une classe, le conteneur tente de choisir celui qu'il est capable de satisfaire avec les services enregistrés.
Par exemple :
public class OrderService
{
public OrderService()
{
}
public OrderService(ILogger<OrderService> logger)
{
}
}
Comme l'interface ILogger<T> est enregistrée par défaut, le conteneur utilisera le second constructeur, car cette dépendance peut être résolue.
Le constructeur le plus "complet"
Prenons un autre exemple :
public class OrderService
{
public OrderService()
{
}
public OrderService(IRepository repository, ILogger<OrderService> logger)
{
}
}
Si les interfaces IRepository et ILogger<OrderService> sont toutes les deux enregistrées dans le conteneur d'injections de dépendance, le conteneur choisira ce constructeur.
Cas d'ambiguïté
En revanche, si plusieurs constructeurs sont tous entièrement "résolvables" par des injections de dépendance, et qu'aucun ne peut être considéré comme meilleur, le conteneur lève une exception de type InvalidOperationException. Voici un exemple :
public class OrderService
{
public OrderService(IRepository repository)
{
}
public OrderService(ILogger<OrderService> logger)
{
}
}
Si les interfaces IRepository et ILogger<OrderService> sont enregistrées, le conteneur ne sait pas lequel choisir. Nous obtiendrons donc une exception avec le message : "Unable to activate type 'ConsoleApp1.OrderService'. The following constructors are ambiguous ...".
Si un constructeur n'est pas résolvable
public class OrderService
{
public OrderService()
{
}
public OrderService(IRepository repository)
{
}
}
Dans le cas où l'interface IRepository n'est pas enregistrée dans le conteneur d'injections de dépendance, alors le conteneur utilisera le constructeur sans paramètre.