Avancé

[ASP.NET Core] Injection de dépendances

ASP.NET logoL’injection de dépendances a plusieurs intérêts dans la conception d’une application, notamment si celle-ci est fondée sur une technologie telle que ASP.NET MVC : c’est grâce à ces principes qu’il est possible de remplacer et de surcharger les comportements natifs du framework. Mais c’est aussi grâce à ces principes qu’il est possible d’écrire une application à l’architecture plus aboutie et plus robuste, en respectant au mieux le découpage des responsabilités entre les différents modules qui la composent.

Le concept d’injection de dépendances

Pour pouvoir mettre en place des tests unitaires, il est primordial d’utiliser une conception basée sur l’inversion de contrôle.

Un programme ne respectant pas les principes de l’inversion de contrôle est totalement maitre des éléments nécessaires à son fonctionnement. Cela signifie qu’il est capable d’assurer lui-même les interactions avec des éléments extérieurs (récupérer des saisies extérieures ou émettre des messages par exemple).

A l’inverse, un programme qui respecte les principes de l’inversion de contrôle n’est plus totalement maitre de ces éléments mais se place plutôt comme une brique faisant partie d’un tout. Cela signifie qu’il dépend d’un orchestrateur global qui va lui fournir des APIs pour communiquer avec l’extérieur. Ainsi, pour récupérer des saisies utilisateurs ou émettre des messages, il ne choisirait plus lui-même une solution technique spécifique mais se contenterait plutôt d’utiliser celle fournie par l’orchestrateur global.

Cette notion d’orchestrateur global est généralement appelée conteneur IoC.
Généralement il s’agit d’un gros dictionnaire Type / Instance. Des usages plus avancés permettent de définir des associations Type / Factory par exemple. Un client du conteneur peut alors lui demander la résolution d’un type. Charge au conteneur de trouver la meilleure instance pour le type demandé.

L’injection de dépendances est une sous approche faisant partie des concepts globaux de l’inversion de contrôle. Son principe est tel qu’un sous-programme doit exprimer les dépendances dont il a besoin pour fonctionner. Le conteneur IoC est alors utilisé pour instancier le sous-programme ; en prenant en compte ces dépendances. Concrètement l’injection de dépendances peut prendre plusieurs formes : via un constructeur, via une propriété, via un champ privé, etc. Cela dépend de la librairie utilisée et des choix conceptuels utilisés dans le projet.

Le système ci-dessous est présenté dans une forme incompatible avec les principes de l’IoC et notamment de l’injection de dépendances.

public class SousProgramme
{
    private readonly IOutputService _outputService;

    public SousProgramme()
    {
        _outputService = new ConsoleOutputService();
    }

    public void Do()
    {
        _outputService.Print("hello world");
    }
}

Plusieurs points problématiques sont facilement soulevés par cette approche

  • Il est complexe pour un module extérieur d’analyser les dépendances sur lesquelles repose le sous-programme. Il est même impossible de comprendre que celui-ci va utiliser la console comme périphérique de sortie sans lire son code source.
  • Il est difficile de remplacer le périphérique de sortie par un autre (réseau par exemple) car il y a un couplage fort entre le sous-programme et l’API qui utilise la console.
  • Le code n’est donc pas testable puisqu’il il est difficilement vérifiable que la console est utilisée pour une impression spécifique.

L’écriture ci-dessous est une approche compatible avec l’IoC. Le sous-programme n’est plus maitre de l’instanciation de l’API dont il dépend. De plus il exprime clairement via son constructeur qu’il ne peut pas fonctionner sans une implémentation valide de IOutputService.

public class SousProgramme
{
    private readonly IOutputService _outputService;

    public SousProgramme(IOutputService outputService)
    {
        _outputService = outputService;
    }

    public void Do()
    {
        _outputService.Print("hello world");
    }
}

L’exemple ci-dessous présente une instanciation du conteneur IoC de la librairie Unity (Microsoft). On y notre l’enregistrement d’un service puis la résolution d’une instance du sous programme. Les dépendances de ce dernier sont analysées et résolues depuis les services enregistrés dans le conteneur.

static void Main(string[] args)
{
    var unityContainer = new UnityContainer();

    unityContainer.RegisterType<IOutputService, ConsoleOutputService>();

    var instance = unityContainer.Resolve<SousProgramme>();
}

Dans le cadre d’un projet ASP.NET MVC, il est possible de transposer facilement le principe d’injection de dépendances de la façon suivante.

Le contrôleur exprime ici ses dépendances via son constructeur.

public class MyController : Controller
{
    private readonly IProductService _productService;

    public MyController(IProductService productService)
    {
        _productService = productService;
    }
}

Avant ASP.NET Core…

L’exemple précédent est un peu particulier. En effet, si vous le lancez tel quel, à l’exécution, le programme ne fonctionne pas.

En effet, le comportement par défaut de la fabrique du contrôleur est de s’attendre à ce qu’on constructeur par défaut (sans arguments) soit présent sur les contrôleurs à instancier.

En coulisse, la fabrique utilise en fait la classe Activator du Framework .NET et sa méthode CreateInstance.

Avec ASP.NET MVC 4 et 5, la solution utilisée consiste généralement à remplacer le mécanisme de résolution de dépendances de la librairie (la classe DependencyResolver) par une implémentation branchée sur le conteneur IoC.

Avec ASP.NET Web API 1 et 2, il est également nécessaire de remplacer un service de la librairie par une implémentation branchée sur le conteneur IoC. Cependant, les différences de fonctionnement interne entre les deux librairies font que le service à remplacer n’est pas exactement le même.

En utilisant une librairie telle que Unity, il est possible de récupérer un paquet Nuget adapté à ASP.NET MVC qui télécharge automatiquement la classe Bootstrapper ci-dessous. Notez l’appel à la méthode SetResolver de la classe DependencyResolver. C’est cet appel qui permet de remplacer le mécanisme de résolution et d’instanciation des services pour toute la partie ASP.NET MVC d’une application.

public static class Bootstrapper
{
  public static IUnityContainer Initialise()
  {
    var container = BuildUnityContainer();

    DependencyResolver.SetResolver(new UnityDependencyResolver(container));

    return container;
  }

  private static IUnityContainer BuildUnityContainer()
  {
    var container = new UnityContainer();

    RegisterTypes(container);

    return container;
  }

  public static void RegisterTypes(IUnityContainer container)
  {
      container.RegisterType<IServiceUtilisateur, ServiceUtilisateur>();
  }
}

L’instance passée à SetResolver doit simplement implémenter une interface IDependencyResolver, présente dans l’espace de nom System.Web.Mvc.

public interface IDependencyResolver
{
    object GetService(Type serviceType);
    IEnumerable<object> GetServices(Type serviceType);
}

Et c’est à ce niveau que l’implémentation naïve présente par défaut et faisant appel à la classe Activator est définie. C’est cette classe que nous remplaçons quand nous utilisons la métode SetResolver.

private class DefaultDependencyResolver : IDependencyResolver
{
    public object GetService(Type serviceType)
    {
        if (serviceType.IsInterface || serviceType.IsAbstract)
        {
            return null;
        }
        try
        {
            return Activator.CreateInstance(serviceType);
        }
        catch
        {
            return null;
        }
    }

    public IEnumerable<object> GetServices(Type serviceType)
    {
        return Enumerable.Empty<object>();
    }
}

Avec ASP.NET Web API 2, l’implémentation passe par la création d’une classe qui respecte le contrat IDependencyResolver. L’espace de nom de ce dernier étant System.Web.Http.Dependencies.

public interface IDependencyResolver : IDependencyScope, IDisposable
{
    IDependencyScope BeginScope();
}

public interface IDependencyScope : IDisposable
{
    object GetService(Type serviceType);
    IEnumerable<object> GetServices(Type serviceType);
}

L’instanciation et la définition du Resolver pour être utilisable avec les contrôleurs Web API se fait donc au final d’une manière totalement différente.

public static void Register(HttpConfiguration config)
{
    var container = new UnityContainer();
    container.RegisterType<IProductService, ProductService>();

    config.DependencyResolver = new UnityResolver(unityContainer);

    // Web API routes
    config.MapHttpAttributeRoutes();

    config.Routes.MapHttpRoute(
        name: "DefaultApi",
        routeTemplate: "api/{controller}/{id}",
        defaults: new { id = RouteParameter.Optional }
    );
}

Notez que les deux technologies, ASP.NET MVC et ASP.NET Web API ont les fondations pour permettre l’injection de dépendances. Le mécanisme de DependencyResolver est présent de base. Cependant, par défaut, il n’est pas branché sur un conteneur IoC. De plus, chaque technologie utilise son propre mécanisme. Pour une application qui mêle des contrôleurs MVC et Web API, il est donc nécessaire de créer deux DependencyResolver maisons et de les brancher sur le même conteneur IoC.

A noter que SignalR, une autre brique ASP.NET dispose lui aussi de son propre mécanisme, également différent de ceux présentés ci-dessus.

Avec ASP.NET Core, ce fonctionnement est totalement modifié.

ASP.NET Core et l’injection de dépendances

Première nouveauté avec ASP.NET Core, l’injection de dépendances est considérée directement comme un module principal d’ASP.NET. Cela signifie qu’il ne faut plus considérer un DependencyResolver propre uniquement à ASP.NET MVC mais bel et bien un ensemble de classes et d’interfaces présentes au cœur d’ASP.NET et utilisables de base dans n’importe quel code fonctionnant avec ASP.NET (MVC, SignalR, etc.).

Autre différence de taille, le mécanisme maintenant présent de base ne joue pas un simple rôle d’activateur de classe comme c’était le cas auparavant, mais il est un conteneur IoC à part entière. Certes il ne supporte pas toutes les fonctionnalités offertes par les conteneurs les plus complexes disponibles dans le monde de .NET, mais il suffira aux besoins de la plupart des développeurs.

A noter que de base, tout ce mécanisme d’injection de dépendances est compatible avec .NET Core, et il est bien disponible dans la version allégée du Framework .NET.

Tout le cœur du mécanisme tourne autour des paquets Microsoft.Framework.DependencyInjection et Microsoft.Framework.DependencyInjection.Abstractions. Ceux-ci sont presque automatiquement présents dans votre application car ils sont une dépendance de Microsoft.AspNet.Hosting.

Pour utiliser ce conteneur, rien de plus simple. Si vous avez ouvert une application ASP.NET Core, vous avez surement remarqué la présence d’une méthode ConfigureServices dans la classe Startup. Le paramètre de type IServiceCollection représente simplement le conteneur IoC. C’est auprès de cette collection qu’il va falloir enregistrer vos services.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
    }

    public void Configure(IApplicationBuilder app)
    {
        app.Run(async (context) =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    }
}

Pour enregistrer un service dans la collection, il convient de définir qu’elle est la stratégie de gestion de son cycle de vie à adopter. Ce principe est généralement commun à tous les conteneurs Ioc. Les stratégies suivantes sont disponibles avec le conteneur ASP.NET 5.

  • Instance. Vous fournissez une instance au conteneur et celui-ci la réutilisera tout le temps. Il n’en créera jamais d’autre.
  • Transient. C’est le comportement le plus courant. Une nouvelle instance est simplement créée à chaque fois qu’un composant fait appel au conteneur.
  • Singleton. Le conteneur va créer une première instance et la conservera tout le temps que l’application s’exécutera.
  • Scoped. Une instance est créée et réutilisée pendant toute la durée de vie d’une portée bien spécifique.

Ce dernier cas est particulièrement intéressant. Dans une application Web, la portée la plus couramment utilisée est celle de la requête HTTP. Cela signifie que dans ce mode, il est possible d’avoir une seule et même instance d’un service qui sera partagée pendant toute la durée de traitement d’une requête HTTP.

Dit autrement, tous vos services qui reposent sur un même composant pourront récupérer ce dernier sous la forme d’un singleton pendant le traitement d’une requête. Dès la prochaine requête sur votre application, c’est un nouveau singleton qui sera instancié.

Sachant cela, voici quelques exemples d’utilisation du conteneur.

Ci-dessous le service est enregistré de telle manière que chaque demande auprès du conteneur doit instancier la classe ProductService.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddTransient<IProductService, ProductService>();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.Run(async (context) =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    }
}

Ci-dessous un autre exemple ou j’enregistre mon service en tant que singleton et où je suis responsable de fournir l’instance à utiliser.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddInstance<IProductService>(new ProductService());
    }

    public void Configure(IApplicationBuilder app)
    {
        app.Run(async (context) =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    }
}

Notez que, lorsque vous écrivez services.AddMvc pour rendre votre application ASP.NET MVC 6 fonctionnelle, vous enregistrez simplement les différents composants nécessaires au fonctionnement d’ASP.NET MVC. Ci-dessous, un extrait du code de la méthode AddMvc. Notez l’enchaînement en cascade des différentes méthodes. Notez également que dans cette version d’ASP.NET MVC, tout est vraiment considéré en tant que composants enregistrés dans le conteneur IoC.

public static IServiceCollection AddMvc([NotNull] this IServiceCollection services)
{
    var builder = services.AddMvcCore();

    builder.AddApiExplorer();
    builder.AddAuthorization();
    builder.AddCors();
    builder.AddDataAnnotations();
    builder.AddFormatterMappings();
    builder.AddJsonFormatters();
    builder.AddViews();
    builder.AddRazorViewEngine();

    return services;
}

public static IMvcBuilder AddMvcCore(
    [NotNull] this IServiceCollection services,
    [NotNull] Action<MvcOptions> setupAction)
{
    ConfigureDefaultServices(services);

    AddMvcCoreServices(services);

    services.Configure(setupAction);

    return new MvcBuilder() { Services = services, };
}

internal static void AddMvcCoreServices(IServiceCollection services)
{
    // ...

    //
    // Controller Factory
    //
    // This has a cache, so it needs to be a singleton
    services.TryAddSingleton<IControllerFactory, DefaultControllerFactory>();

    // Will be cached by the DefaultControllerFactory
    services.TryAddTransient<IControllerActivator, DefaultControllerActivator>();
    services.TryAddEnumerable(
        ServiceDescriptor.Transient<IControllerPropertyActivator, DefaultControllerPropertyActivator>());

    // ...
}

Bring You Own Container

Le mécanisme d’IoC d’ASP.NET NET Core est basé sur un ensemble d’interfaces. Ce niveau d’abstraction est tel qu’il est facilement possible de remplacer le conteneur IoC présent par défaut par une autre implémentation. Il devient alors possible de faire tourner l’intégralité de la stack ASP.NET sur le conteneur de votre choix (Unity, StructureMap, Autofac, etc.), vous permettant d’accéder aux fonctionnalités propres à ces conteneurs ou vous permettant simplement de respecter les standards de votre entreprise.

Pour que ce scénario soit possible, il est tout de même nécessaire de disposer d’une implémentation de ces abstractions adaptée au conteneur de votre choix. Généralement, ce sont les développeurs d’un conteneur qui fournissent ce genre d’implémentation.

Il faut également savoir qu’il est possible d’écrire la méthode ConfigureServices de la classe Startup sous une autre forme. C’est cette autre forme qui permet l’utilisation d’un conteneur maison. Notez que cette fois ci le type de retour a changé puisqu’il est devenu un IServiceProvider. Il s’agit de l’abstraction qui représente le conteneur IoC.

public class Startup
{
    public IServiceProvider ConfigureServices(IServiceCollection services)
    {
        services.AddInstance<IProductService>(new ProductService());

        throw new NotImplementedException();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.Run(async (context) =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    }
}

Ci-dessous une implémentation possible en me basant sur Autofac (attention les paquets sont encore en alpha). Notez que la méthode Populate est dans un paquet spécifique (Autofac.Dnx). C’est elle qui contient toute la méthode de faire le lien entre le fonctionnement classique d’Autofac et les couches d’abstractions d’ASP.NET Core.

public class Startup
{
    public IServiceProvider ConfigureServices(IServiceCollection services)
    {
        services.AddInstance<IProductService>(new ProductService());

        var builder = new ContainerBuilder();

        builder.Populate(services);

        var container = builder.Build();

        return container.Resolve<IServiceProvider>();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.Run(async (context) =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    }
}

Conclusion

L’intégration de l’injection de dépendances comme une brique de base d’ASP.NET est une vraie bonne nouvelle pour les développeurs. Elle permet aux néophytes de s’initier facilement au concept, et ce, sans même ajouter de librairies supplémentaires. Et pour les développeurs habitués au concept, elle permet d’avoir une solution cohérente et élégante avec tous les niveaux d’abstraction nécessaires pour remplacer le fonctionnement par défaut par un conteneur spécifique.

Si vous n’utilisez pas encore l’injection de dépendances, avec ASP.NET Core, c’est le moment de faire le grand saut. La qualité et la robustesse de vos applications n’en seront que grandies.

A bientôt 🙂

Nombre de vue : 1659

AJOUTER UN COMMENTAIRE