Comprendre l’injection de dépendances

Effectué

Les applications ASP.NET Core doivent souvent accéder aux mêmes services sur plusieurs composants. Par exemple, il est possible que plusieurs composants doivent accéder à un service qui extraie les données d’une base de données. ASP.NET Core utilise un conteneur d’injection de dépendances (DI) intégré pour gérer les services utilisés par une application.

Injection de dépendances et inversion de contrôle (IoC)

Le modèle d’injection de dépendances est une forme d’inversion de contrôle (IoC). Dans le modèle d’injection de dépendances, un composant reçoit ses dépendances à partir de sources externes, au lieu de les créer lui-même. Ce modèle dissocie le code de la dépendance, ce qui facilite ainsi les tests et la gestion de code.

Tenez compte du fichier Program.cs suivant :

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using MyApp.Services;

var builder = WebApplication.CreateBuilder(args);
    
builder.Services.AddSingleton<PersonService>();
var app = builder.Build();

app.MapGet("/", 
    (PersonService personService) => 
    {
        return $"Hello, {personService.GetPersonName()}!";
    }
);
    
app.Run();

Et le fichier PersonService.cs suivant :

namespace MyApp.Services;

public class PersonService
{
    public string GetPersonName()
    {
        return "John Doe";
    }
}

Pour comprendre le code, commencez par le code app.MapGet mis en évidence. Ce code route les requêtes HTTP GET pour l’URL racine (/) vers un délégué qui retourne un message d’accueil. La signature du délégué définit un paramètre PersonService nommé personService. Lorsque l’application s’exécute et qu’un utilisateur demande l’URL racine, le code dans le délégué dépend du PersonService service afin d'obtenir du texte à inclure dans le message d’accueil.

Où le délégué obtient-il le service PersonService ? Il est implicitement fourni par le conteneur de service. La ligne builder.Services.AddSingleton<PersonService>() mise en évidence indique au conteneur de service de créer au démarrage de l’application une instance de la classe PersonService et de la fournir à tout composant qui en a besoin.

Tout composant nécessitant le service PersonService peut déclarer un paramètre de type PersonService dans sa signature de délégué. Le conteneur de service fournit automatiquement une instance de la classe PersonService au moment de la création du composant. Le délégué ne crée pas l’instance PersonService, il utilise simplement l’instance fournie par le conteneur de service.

Interfaces et injection de dépendances

Si vous souhaitez éviter les dépendances sur une implémentation spécifique de service, vous pouvez configurer à la place un service pour une interface distincte, puis dépendre seulement de l’interface. Cette approche vous offre la possibilité de permuter l’implémentation de service, ce qui rend le code plus facile à tester et à gérer.

Prenons l’exemple d’une interface pour la classe PersonService :

public interface IPersonService
{
    string GetPersonName();
}

L’interface définit la méthode unique, GetPersonName, qui renvoie une string. Cette classe PersonService implémente l’interface IPersonService :

internal sealed class PersonService : IPersonService
{
    public string GetPersonName()
    {
        return "John Doe";
    }
}

Au lieu d’inscrire la classe PersonService directement, vous pouvez l’inscrire comme implémentation de l’interface IPersonService :

var builder = WebApplication.CreateBuilder(args);
    
builder.Services.AddSingleton<IPersonService, PersonService>();
var app = builder.Build();

app.MapGet("/", 
    (IPersonService personService) => 
    {
        return $"Hello, {personService.GetPersonName()}!";
    }
);
    
app.Run();

Cet exemple Program.cs diffère de l’exemple précédent de deux façons :

  • L’instance PersonService est inscrite en tant qu’implémentation de l’interface IPersonService (par opposition à l’inscription directe de la PersonService classe).
  • La signature de délégué attend maintenant un paramètre IPersonService au lieu d’un paramètre PersonService.

Quand l’application s’exécute et qu’un client demande l’URL racine, le conteneur de service fournit une instance de la classe PersonService, car elle est inscrite comme implémentation de l’instance IPersonService.

Conseil

Considérez IPersonService comme un contrat. Il définit les méthodes et les propriétés qu’une implémentation doit avoir. Le délégué souhaite avoir une instance de IPersonService. Elle ne s’intéresse pas du tout à l’implémentation sous-jacente, seulement aux méthodes et propriétés de l’instance définies dans le contrat.

Tests avec l’injection de dépendances

L’utilisation des interfaces facilite les tests de composants en isolation. Vous pouvez créer une implémentation simulée de l’interface IPersonService à des fins de test. Lorsque vous inscrivez l’implémentation simulée dans le test, le conteneur de service la fournit au composant testé.

Par exemple, disons qu’au lieu de renvoyer une chaîne codée en dur, la méthode GetPersonName dans la classe PersonService extraie le nom d’une base de données. Pour tester le composant dépendant de l’interface IPersonService, vous pouvez créer une implémentation simulée de l’interface IPersonService qui renvoie une chaîne codée en dur. Le composant testé ne connaît pas la différence entre l’implémentation réelle et celle simulée.

Supposons également que votre application route un point de terminaison d’API qui renvoie un message d’accueil. Le point de terminaison dépendant de l’interface IPersonService pour obtenir le nom de la personne à accueillir. Le code qui inscrit le service IPersonService et route le point de terminaison d’API pourrait ressembler à ce qui suit :

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IPersonService, PersonService>();

var app = builder.Build();

app.MapGet("/", (IPersonService personService) =>
{
    return $"Hello, {personService.GetPersonName()}!";
});

app.Run();

C’est semblable à l’exemple précédent avec IPersonService. Le délégué attend un paramètre IPersonService fourni par le conteneur de service. Comme évoqué plus tôt, supposons que PersonService implémentant l’interface extraie le nom de la personne à accueillir à partir d’une base de données.

Prenons ensuite le test XUnit suivant qui teste le même point de terminaison d’API :

Conseil

Ne vous inquiétez pas si vous n’êtes pas familiarisé avec XUnit ou Moq. L’écriture de tests d’unité n’est pas traitée par ce module. Cet exemple n’est destiné qu’à illustrer la façon dont vous pouvez utiliser l’injection de dépendances dans des tests.

using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using MyWebApp;
using System.Net;

public class GreetingApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public GreetingApiTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetGreeting_ReturnsExpectedGreeting()
    {
        //Arrange
        var mockPersonService = new Mock<IPersonService>();
        mockPersonService.Setup(service => service.GetPersonName()).Returns("Jane Doe");

        var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                services.AddSingleton(mockPersonService.Object);
            });
        }).CreateClient();

        // Act
        var response = await client.GetAsync("/");
        var responseString = await response.Content.ReadAsStringAsync();

        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("Hello, Jane Doe!", responseString);
    }
}

Le test précédent :

  • Permet de créer une implémentation simulée de l’interface IPersonService qui renvoie une chaîne codée en dur.
  • Permet d’inscrire l’implémentation simulée avec le conteneur de service.
  • Permet de créer un client HTTP pour effectuer une requête au point de terminaison d’API.
  • Permet d’affirmer que la réponse du point de terminaison d’API est celle attendue.

Ce test ne s’intéresse pas à la façon dont la classe PersonService obtient le nom de la personne à accueillir. Il s’intéresse uniquement à l’inclusion du nom dans le message d’accueil. Le test utilise une implémentation simulée de l’interface IPersonService pour isoler le composant testé à partir de l’implémentation réelle du service.