L’injection de dépendances dans une application ASP.NET Core

Contrairement à ASP.NET MVC, ASP.NET Core possède nativement un container d’injection de dépendances. Ce pattern est particulièrement utile pour facilement architecturer une application. Le but de cet article est, d’abord, de montrer comment configurer l’injection de dépendances dans une application ASP.NET Core avec le container natif. Il est possible de remplacer ce container natif par un autre container comme Autofac ou StructureMap. Dans un 2e temps, on va montrer comment configurer un container Autofac et enfin, indiquer quelques fonctionnalités intéressantes d’Autofac par rapport au container natif.


L’injection de dépendances en théorie

L’injection de dépendances est un design pattern permettant d’injecter des objets dans des classes à l’exécution. Le principal objectif est d’éviter à la classe d’instancier elle-même les objets dont elle pourrait avoir besoin. Quand une classe utilise un autre objet, elle devient dépendante de cet objet et possède une dépendance vers cet objet. Le fait d’instancier cette dépendance dans le classe qui la consomme introduit un fort couplage entre:

  • La classe consommatrice de l’objet et
  • L’objet instancié

Ce couplage peut devenir complexe si l’instanciation de l’objet nécessite l’instanciation d’autres objets ou s’il y a beaucoup d’objets à instancier. La classe consommatrice devient, ainsi, dépendante de tous les autres objets ce qui augmente encore le couplage entre tous ces objets. Par exemple, si on modifie le constructeur d’une classe, il faudra répercuter cette modification partout où ce constructeur est utilisé.

Pour éviter ces problèmes, le pattern injection de dépendances (cf. dependency injection) préconise d’injecter les dépendances d’une classe de façon à ce qu’elle ne les instancie pas elle-même.

Ainsi l’intérêt de l’injection de dépendances est:

  • De permettre un faible couplage puisqu’on réduit le nombre de dépendances entre les objets. Comme les dépendances sont injectées, si on modifie le constructeur d’une classe injectée, il ne sera pas nécessaire de répercuter la modification dans les classes qui l’utilise.
  • D’avoir une implémentation plus flexible puisqu’on peut plus facilement modifier les dépendances d’une classe.
  • D’être plus extensible puisqu’on peut ajouter de nouvelles fonctionnalité en fournissant des implémentations différentes à une classe.
  • De faciliter les tests unitaires de la classe puisqu’on peut plus facilement y injecter des mocks ou des stubs.
  • De faciliter la maintenabilité d’une application puisque les dépendances d’une classe apparaissent de façon plus évidente.

Quelques méthodes pour déléguer l’instanciation d’objets

Plusieurs approches permettent de déléguer l’instanciation à d’autres classes différentes de celles qui les consomment. Les patterns “Factory” et le pattern “Service Locator” sont quelques unes de ces approches.

Pattern “Factory” simple

Ce pattern introduit une factory pour déléguer la création de l’objet. La classe consommatrice crée donc la factory et crée l’objet consommé au moyen de cette factory.

Inconvénients

  • La complexité de ce modèle peut augmenter rapidement si la classe consommatrice doit créer beaucoup d’objets.
  • Si on doit créer des objets de type différent, il faudra créer les factory correspondantes. La logique de choix des factory reste dans la classe consommatrice. L’utilisation de plusieurs factories correspond au pattern “fabrique abstraite”.

Dans l’exemple suivant, on instancie une factory dans le constructeur pour qu’elle fournisse l’objet IDataService:

public class ConsumingController : Controller 
{ 
  private readonly IDataService dataService; 
 
  public ConsumingController() 
  { 
    var dependencyFactory = new DependencyFactory(); 
    this.dataService = dependencyFactory.CreateDataService(); 
  } 
 
  public ActionResult Index() 
  { 
    var model = new ConsumingControllerViewData<IEnumerable>(this.dataService.GetValues()) 
    { 
      Title = "Dependency values" 
    }; 
    return this.View(model); 
  } 
 
  // ... 
}

Pattern “Service Locator”

Le Service Locator se comporte comme un registre à qui on demande des objets. C’est le Service Locator qui va enregistrer l’objet et l’instancier. Pour obtenir l’instance, la classe consommatrice doit fournir le nom de l’objet et le type voulu en retour. En utilisant ce pattern, le Service Locator devient le responsable de la durée de vie de l’objet.

Inconvénients:
Les contraintes de ce pattern sont:

  • De maintenir une référence vers le Service Locator dans la classe consommatrice de façon y faire appel pour créer les objets.
  • De rendre implicite la dépendance vers l’objet consommée puisque la dépendance n’apparaît que dans le corps du constructeur.
  • Instancier les dépendances de cette façon complique les tests unitaires puisqu’il est plus compliqué d’injecter un mock de l’objet consommé.

Dans l’exemple suivant, le Service Locator est assuré par l’objet IServiceProvider:

public class ConsumingController : Controller 
{ 
  private readonly IDataService dataService; 
 
  public ConsumingController(IServiceProvider serviceProvider) 
  { 
    this.dataService = serviceProvider.GetRequiredService<IDataService>(); 
  } 
 
  public ActionResult Index() 
  { 
    var model = new ConsumingControllerViewData<IEnumerable>(this.dataService.GetValues()) 
    { 
      Title = "Dependency values" 
    }; 
    return this.View(model); 
  } 
 
  // ... 
}

Dans le cas où on utilise le Service Locator il faut privilégier l’instanciation des objets dans le constructeur de façon à rendre plus explicite les dépendances de la classe consommatrice.

Implémentations de l’injection de dépendances

L’implémentation la plus courante de l’injection de dépendances est par le constructeur. Ainsi on ne garde pas une référence vers un service locator ou vers une factory. De plus la gestion de la durée de vie de l’objet n’est pas effectuée par la classe consommatrice. On a une inversion de contrôle puisque l’objet est poussé vers la classe consommatrice sans que celle-ci ne l’instancie explicitement.

Un autre intérêt est que la classe consommatrice n’a pas de connaissances sur l’objet à créer puisque l’objet n’est manipulé qu’au moyen de son interface.

Dans l’exemple suivant, on injecte IDataService par le constructeur:

public class ConsumingController : Controller 
{ 
  private readonly IDataService dataService; 
 
  public ConsumingController(IDataService dataService) 
  { 
    this.dataService = dataService; 
  } 
 
  public ActionResult Index() 
  { 
    var model = new ConsumingControllerViewData<IEnumerable>(this.dataService.GetValues()) 
    { 
      Title = "Dependency values" 
    }; 
    return this.View(model); 
  } 
 
  // ... 
}

Dans le cas de l’injection de dépendances par le constructeur, une bonne pratique est d’enregistrer l’objet injecté dans un membre en lecture seule (i.e. avec le mot clé readonly).

Type d’injection

D’un point de vue général, il existe plusieurs types d’injection:

  • Injection par le constructeur: comme dans l’exemple précédent.
  • Injection en utilisant un accesseur: à utiliser dans le cas où la dépendance est optionnelle sinon privilégier l’injection par le constructeur.
  • Injection par appel à une méthode: cete méthode peut être utile quand il est nécessaire d’avoir un paramètre supplémentaire qui ne peut être passé dans le constructeur.

Container

Les frameworks d’injection de dépendances utilisent des containers pour ranger les informations concernant tous les objets de l’application. En fonction de ces informations, le container connaît les dépendances entre tous les objets.

Au lancement de l’application, il faut enregistrer les informations concernant les objet dans ce container. Par la suite, en fonction des besoins, ce container sera sollicité pour instancier les différents objets et les injecter dans les classes consommatrices.

C’est aussi le container qui, en fonction de l’enregistrement des objets et de leur utilisation, est capable de gèrer la durée de vie des objets qu’il a instancié.

Durée de vie des objets

Comme indiqué précédemment, la durée de vie des objets est gêré par le container en fonction de la façon dont les objets ont été enregistrés dans ce container:

  • Transient (i.e. éphémère): les objets enregistrés de cette façon sont instanciés à chaque fois qu’ils sont injectés.
  • Scoped: la même instance de l’objet sera utilisée dans le cadre d’une même requête HTTP. Ainsi une nouvelle instance est créée pour chaque requête web.
  • Singleton: les objets de ce type sont créés une seule fois et la même instance est utilisée pendant toute la durée de vie.

Le container garde une trace de tous les objets qu’il a créé, ainsi ces objets sont disposés quand leur durée de vie se termine:

  • Ainsi si l’objet disposé possède des dépendances, elles sont automatiquement disposées.
  • Si l’objet disposé implémente IDisposable alors la méthode IDisposable.Dispose() est exécutée.

Injection de dependances avec ASP.NET Core

Un exemple dans la branche dependency_injection du repository GitHub webapi_example permet d’illustrer les différents éléments de cet article.

Utiliser le container natif

Contrairement à ASP.NET MVC (reposant sur le framework .NET), ASP.NET Core (utilisant .NET Core) propose nativement la fonctionnalité d’injection de dépendances:

  • Le container natif d’ASP.NET Core propose les fonctionnalités de base pour l’enregistrement des objets et la résolution de dépendances.
  • Toutes les dépendances du framework comme le routage, les loggers ou la configuration sont déjà enregistrées dans le container. On peut ainsi y accéder sans les avoir explicitement enregistrés.
  • Seulement l’injection par le constructeur est supportée.

Pour utiliser le container, il faut ajouter la ligne services.AddMvc() dans le fichier Startup.cs d’un projet ASP.NET Core:

public class Startup 
{ 
    public Startup(IConfiguration configuration) 
    { 
        this.Configuration = configuration; 
    } 

    public IConfiguration Configuration { get; } 
    
    public void ConfigureServices(IServiceCollection services) 
    { 
        services.AddMvc(); 
    } 
}

services.AddMvc() permet d’enregistrer dans le container les services du framework.

Enregistrer explicitement des dépendances

L’enregistrement des objets dans le container se fait en fonction de la durée de vie souhaitée pour ces objets. L’enregistrement se fait dans ConfigureServices() de la classe Startup.cs:

public void ConfigureServices(IServiceCollection services) 
{ 
    services.AddMvc(); 

    services.AddTransient<IDataService, DataService>(); 
}

Dans le cadre de cet exemple, la classe DataService satisfait l’interface IDataService:

public class DataService : IDataService 
{ 
    // ... 
}

L’enregistrement se fait de cette façon:

  • Transient: pour enregistrer un objet pour qu’il soit instancié de façon éphémère quand il est injecté, il faut utiliser une des surchages suivantes:
    • La méthode la plus courante en utilisant le type de l’objet à enregistrer:
      services.AddTransient<IDataService, DataService>(); 
      services.AddTransient<typeof(IDataService), typeof(DataService)>();
      
    • En instanciant explicitement l’objet:
      services.AddTransient<IDataService>(s => new DataService());
      
  • Scope: pour enregistrer un objet pour que sa durée de vie corresponde à la durée de vie d’une requête HTTP:
    services.AddScope<IDataService, DataService>();
    
  • Singleton: pour enregistrer un objet et que la même instance soit utilisée tout au long de l’exécution de l’application:
    services.AddSingleton<IDataService, DataService>();

Dans la pratique:

  • Il faut privilégier l’enregistrement des objets avec AddTransient() de façon à ce qu’une nouvelle instance soit créée à chaque utilisation de ces objets. Ce type d’enregistrement permet d’éviter de gérer les problèmes d’accès concurrents et les fuites mémoires qui pourraient subvenir pour des objets qui ne sont pas correctement libérés.
  • Eviter d’utiliser des singletons car ils nécessitent de se préoccuper des problèmes d’accès concurrents s’ils sont utilisés au même moment par des objets différents. Ils peuvent être en outre à l’origine de fuites mémoires.
  • Un singleton ne doit pas dépendre d’objets enregistrés avec AddTransient() ou AddScoped(). Dans ce cas, les dépendances du singleton deviennent elles aussi des singletons. Le container natif d’ASP.NET Core lance une exception dans ce cas.

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, 2 services sont injectés avec la fonction IServiceCollection.AddSingleton() dans la méthode ConfigureServices() de la classe Startup:

public void ConfigureServices(IServiceCollection services) 
{ 
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); 

    services.AddSingleton<IPizzaFlavourRepositoryService>(new PizzaFlavourRepositoryService()); 
    services.AddSingleton<IPizzaOrderRepositoryService, PizzaOrderRepositoryService>();

    // ... 
}
Accès concurrents

Dans l’exemple, les objets IPizzaFlavourRepositoryService et IPizzaOrderRepositoryService sont enregistrés en tant que singleton. Leur implémentation actuelle a été faite au plus simple mais ne permet pas de gérer des accès concurrents.

Pattern ServiceLocator

Avec ASP.NET Core, le pattern Service Locator peut être utilisé avec le service System.IServiceProvider. Il suffit d’injecter ce service dans un constructeur pour pouvoir l’utiliser.

Par exemple:

public class PizzaFlavourController : ControllerBase 
{ 
    private readonly IPizzaFlavourRepositoryService flavourService; 

    public PizzaFlavourController(IServiceProvider serviceProvider) 
    { 
        this.flavourService = (IPizzaFlavourRepositoryService)serviceProvider
            .GetService(typeof(IPizzaFlavourRepositoryService)); 
    } 
}

En rajoutant le namespace Microsoft.Extensions.DependencyInjection, on peut utiliser des méthodes d’extensions plus pratiques:

using Microsoft.Extensions.DependencyInjection; 

public class PizzaFlavourController : ControllerBase 
{ 
    private readonly IPizzaFlavourRepositoryService flavourService; 

    public PizzaFlavourController(IServiceProvider serviceProvider) 
    { 
        this.flavourService = serviceProvider
            .GetRequiredService<IPizzaFlavourRepositoryService>(); 
    } 
}

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, on instancie le service de cette façon dans le controller PizzaFlavourController.

Résoudre des objets dans une méthode

Si on utilise le pattern Service Locator, il n’est pas obligatoire d’instancier une dépendance dans le constructeur. On peut aussi instancier ses dépendances dans le corps d’une méthode, toutefois dans ce cas, il est préférable d’utiliser un scope enfant (i.e. portée) et observer certaines règles:

  • L’utilisation du scope permet de garantir qu’en dehors de ce scope, les objets résolus seront correctement libérés.
  • Sachant que la durée de vie de ces objets est lié à celle du scope, il ne faut pas garder de références de ces objets en dehors du scope.

La création d’un scope se fait par l’intermédiaire du service System.IServiceProvider et des méthodes d’extensions se trouvant dans Microsoft.Extensions.DependencyInjection.

Par exemple:

using Microsoft.Extensions.DependencyInjection; 

public class ConsumingService : IConsumingService 
{ 
    private readonly IServiceProvider serviceProvider; 
    
    public PizzaFlavourRepositoryService(IServiceProvider serviceProvider) 
    { 
        this.serviceProvider = serviceProvider; 
    } 

    private IEnumerable<string> UseDependency() 
    { 
        using (var scope = this.serviceProvider.CreateScope())
        { 
            var dataService = scope.ServiceProvider.GetRequiredService<IDataService>(); 
            return dataService.GetValues(); 
        } 
    } 
}

Au préalable, la classe PizzaFlavourGeneratorService a été enregistrée dans Startup.ConfigureServices():

public class Startup 
{ 
    public void ConfigureServices(IServiceCollection services) 
    { 
        services.AddTransient<IDataService, DataService>();

        // ... 
    } 
}

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, on utilise un scope dans la fonction PizzaFlavourRepositoryService.GetExistingFlavours() pour instancier un objet satisfaisant IPizzaFlavourGeneratorService.

L’injection de dépendances avec Autofac

Autofac est un container d’injection de dépendances qui permet d’apporter plus de fonctionnalités que le container de base d’ASP.NET Core. D’autres types de container sont compatibles avec ASP.NET Core comme par exemple StructureMap (qui n’est pas traité dans cet article).

Parmi les fonctionnalités proposées par les containers comme Autofac, celles qui sont les plus intéressantes sont:

  • L’enregistrement d’objets par convention (i.e. registration by convention ou convention-based registration): cette fonctionnalité permet d’enregistrer des objets automatiquement en associant une interface nommée, par exemple ICustomService avec la classe CustomService. Ces enregistrements peuvent se faire pour les objets définis dans un namespace particulier, dans une assembly particulière ou suivant leur nommage. L’intérêt principale de cette fonctionnalité est d’éviter d’enregistrer tous les objets à injecter un à un.
  • L’interception: cette fonctionnalité avancée permet de rajouter des compétences à des objets enregistrés par décoration.

Après avoir indiqué comment configurer Autofac dans une application ASP.NET Core, on va expliciter ces différentes fonctionnalités.

Installer et configurer Autofac

Pour utiliser Autofac dans une application ASP.NET Core, il faut:

  1. Ajouter le package NuGet Autofac.Extensions.DependencyInjection en exécutant dans le répertoire du projet la commande:
    dotnet add <chemin du fichier projet .csproj> package Autofac.Extensions.DependencyInjection
    

    Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, il faut exécuter:

    user@debian:~/% dotnet add webapi_example/WebApi.csproj package Autofac.Extensions.DependencyInjection
    
  2. Ajouter le service permettant d’utiliser Autofac au moment de créer l’hôte du serveur Web dans le fichier Program.cs:
    WebHost.CreateDefaultBuilder(args).ConfigureServices(services => services.AddAutofac())
    

    Le contenu du fichier se présente, ainsi, de cette façon:

    using Autofac.Extensions.DependencyInjection; 
    // ...
    
    public static void Main(string[] args) 
    { 
        CreateWebHostBuilder(args).Build().Run(); 
    } 
    
    public static IWebHostBuilder CreateWebHostBuilder(string[] args) => 
        WebHost.CreateDefaultBuilder(args) 
            .ConfigureServices(services => services.AddAutofac())
            .UseStartup<Startup>();
    
  3. Il faut modifier la classe Startup pour qu’elle permette de configurer Autofac. Dans un premier temps, il faut modifier le constructeur pour injecter Microsoft.AspNetCore.IHostingEnvironment:
    using Autofac; 
    // ... 
    
    public Startup(IHostingEnvironment hostingEnvironment) 
    { 
        this.hostingEnvironment = hostingEnvironment; 
    
        var builder = new ConfigurationBuilder(); 
        this.Configuration = builder.Build(); 
    }

    On crée la méthode ConfigureContainer() pour ajouter le module AutofacModule permettant d’enregistrer les objets à injecter:

    using Autofac; 
    // ...
    
    public void ConfigureContainer(ContainerBuilder builder) 
    { 
        builder.RegisterModule(new AutofacModule()); 
    }
  4. On ajoute, enfin, un fichier nommé AutofacModule.cs contenant une classe du même nom permettant d’enregistrer les objets à injecter. La classe AutofacModule doit dériver de la classe Autofac.Module. Pour pour enregistrer les classes, il faut surcharger la méthode Load():
    using Autofac; 
    // ... 
    
    public class AutofacModule : Module 
    { 
        protected override void Load(ContainerBuilder builder) 
        { 
            // ... 
        } 
    }

Enregistrement des objets

Comme pour le container intégré à ASP.NET Core, l’enregistrement des objets dans Autofac se fait en fonction de leur durée de vie. Autofac fournit davantages de fonctionnalités que le container intégré, on peut:

  • Enregistrer les objets un à un comme pour le container intégré ou
  • Enregistrer les objets par convention (i.e. convention-based registration).

Durée de vie des objets

La durée de vie des objets est gêré par le container en fonction de la façon dont les objets ont été enregistrés dans le container. L’injection des objets se configure avec un syntaxe de type “fluent”:

Dans les exemples suivants:

  • builder correspond au container de type Autofac.ContainerBuilder.
  • CustomClass correspond au type de la classe à injecter
  • ICustomClass est l’interface que la classe CustomClass satisfait.

Les types d’enregistrement principaux sont:

  • Une instance à chaque injection (i.e. instance per dependency ou transient): une nouvelle instance sera créée à chaque fois que l’objet est injecté:
    builder.RegisterType<CustomClass>().InstancePerDependency(); 
    

    Enregistrer une classe et injecter l’interface correspondante:

    builder.Register(container => new CustomClass())
        .As<ICustomClass>().InstancePerDependency();
    

    Si le constructeur de CustomClass contient un paramètre de type IDependency (qui est enregistré dans le container):

    builder.Register(container => new CustomClass(container.Resolve<IDependency>())).As<ICustomClass>().InstancePerDependency();
    
  • Un singleton: la même instance est injectée partout:
    builder.RegisterType<CustomClass>().SingleInstance();  // dans le cas l'objet sera instancié directement par le container
    

    On peut enregistrer une instance spécifique en exécutant:

    var customClassInstance = new CustomClass(); 
    builder.RegisterInstance<ICustomClass>(customClassInstance);
    

    Ou

    builder.Register(container => customClassInstance).As<ICustomClass>().SingleInstance();
    
  • Une instance par scope (i.e. Instance Per Lifetime scope): dans le cas où on crée des scopes, les objets enregistrés de cette façon seront injectés sous forme d’une instance par scope:
    builder.RegisterType<CustomClass>().InstancePerLifetimeScope(); 
    

    Pour enregistrer une classe et injecter l’interface correspondante:

    builder.Register(container => new CustomClass())
        .As<ICustomClass>().InstancePerLifetimeScope(); 
    

    L’utilisation dans un scope se fait de cette façon:

    using (var scope = container.BeginLifetimeScope()) 
    { 
        var customClassInstance = scope.Resolve<CustomClass>(); 
    }
  • Une instance par scope nommé (i.e. Instance Per Matching Lifetime scope): même utilisation que précédemment mais pour des scopes nommés. L’enregistrement de ce type permet de garantir que l’instance sera unique pour chaque scope nommé.

    L’enregistrement se fait de cette façon en ajoutant le nom du scope (par exemple "ScopeName"):

    builder.RegisterType<CustomClass>().InstancePerMatchingLifetimeScope("ScopeName");
    

    L’objet sera injecté seulement dans le scope avec le nom configuré:

    using (var scope = container.BeginLifetimeScope("ScopeName")) 
    { 
        var customClassInstance = scope.Resolve<CustomClass>(); 
    }
  • Une instance par requête HTTP (i.e. Intance Per Request): une même instance sera utilisée pour chaque requête HTTP. L’enregistrement se fait de cette façon:
    builder.RegisterType<CustomClass>().InstancePerRequest(); 
    

    Si l’objet est injecté sous la forme de l’interface qu’il satisfait:

    builder.RegisterType<CustomClass>().As<ICustomClass>().InstancePerRequest();
    

D’autres types d’enregistrements existent (cf. Instance Scope).

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, on enregistre les services injectés au niveau des controllers dans la classe AutofacModule de cette façon:

protected override void Load(ContainerBuilder builder) 
{ 
    builder.RegisterType<PizzaFlavourGeneratorService>() 
        .As<IPizzaFlavourGeneratorService>() 
        .SingleInstance(); 

    builder.Register(c => new PizzaFlavourRepositoryService(c.Resolve<IServiceProvider>())) 
        .As<IPizzaFlavourRepositoryService>() 
        .SingleInstance(); 

    builder.Register(c => new PizzaOrderRepositoryService(c.Resolve<IPizzaFlavourRepositoryService>())) 
        .As<IPizzaOrderRepositoryService>() 
        .SingleInstance(); 
}

Enregistrement des objets par convention

Avec Autofac, il est possible d’injecter des objets par convention (i.e. convention-based registration ou registration by convention). Ainsi, on peut enregistrer des objets automatiquement parce-qu’ils sont définis dans un namespace particulier, dans une assembly particulière ou suivant leur nommage.

Par exemple, pour enregistrer toutes les classes se trouvant dans l’assembly nommée CustomAssembly et les injecter sous la forme de l’interface qu’elles satisfont, il faut exécuter les lignes suivantes dans la classe AutofacModule:

protected override void Load(ContainerBuilder builder) 
{ 
    var assemblies = AppDomain.CurrentDomain.GetAssemblies() 
        .Where(x => x.FullName.StartsWith("CustomAssembly")).ToArray(); 

    builder.RegisterAssemblyTypes(assemblies) 
        .Where(t => t.IsClass) 
        .AsImplementedInterfaces() 
        .InstancePerRequest(); 
}

Dans cet exemple, les objets seront injectés sous la forme de l’interface qu’ils satisfont (à cause du paramétrage AsImplementInterfaces()) et chaque instance sera spécifique pour chaque requête HTTP (à cause du paramètrage InstancePerRequest()).

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, pour enregistrer tous les services sous forme de singleton, on peut exécuter le code suivant dans la classe AutofacModule:

protected override void Load(ContainerBuilder builder) 
{ 
    var assemblies = AppDomain.CurrentDomain.GetAssemblies() 
        .Where(x => x.FullName.StartsWith("WebApi")).ToArray(); 

    builder.RegisterAssemblyTypes(assemblies) 
        .Where(t => t.IsClass && t.FullName.EndsWith("Service")) 
        .AsImplementedInterfaces() 
        .SingleInstance(); 
}

Ainsi on enregistre toutes les classes dont le nom se termine par "Service" se trouvant dans l’assembly dont le nom commence par "WebApi".

Interception

L’interception est une fonctionnalité avancée de l’injection de dépendances. Elle permet d’ajouter des compétences à une classe sans modifier l’implémentation de la classe. Ce pattern est un peu similaire au pattern Decorator. Si on ajoute une compétence à une classe, à chaque fois qu’elle est injectée, elle est interceptée pour lui rajouter cette compétence. L’instance injectée bénéficie de cette compétence de façon transparente sans que l’implémentation de la classe ne soit modifiée.

L’intérêt de cette fonctionnalité est de pouvoir rajouter des compétences sur un grand nombre de classes sans en modifier l’implémentation. Dans la pratique, l’interception permet de rajouter des fonctionnalités de logging, de gestion d’exceptions, d’authentification, de gestion de cache etc…

Techniquement, lorsqu’une classe est configurée pour être interceptée par une autre classe, le container crée un objet proxy qu’il va utiliser suivant 2 méthodes:

  • Si l’interception se fait par l’interface: dans le cas où la classe interceptée satisfait une interface, le container va créer un objet proxy satisfaisant la même interface et containant l’implémentation de la classe interceptée et les compétences à ajouter.
  • Si l’interception se fait par la classe: la classe interceptée doit comporter des méthodes virtuelles. Le container va créer un objet proxy surchargeant les méthodes virtuelles de la classe interceptée pour lui ajouter des compétences. A chaque fois qu’une méthode virtuelle de la classe interceptée est invoquée, la méthode virtuelle du proxy sera exécutée.

Le code complet de cet exemple se trouve dans la branche dependency_injection_autofac du repository GitHub webapi_example.

Configurer l’interception

Pour configurer l’interception avec Autofac, il faut effectuer les étapes suivantes:

  1. Ajouter le package NuGet Autofac.Extras.DynamicProxy en exécutant dans le répertoire du projet la commande:
    dotnet add <chemin du fichier projet CSPROJ> package Autofac.Extras.DynamicProxy 
    

    Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, il faut exécuter:

    user@debian:~/% dotnet add webapi_example/WebApi.csproj package Autofac.Extras.DynamicProxy
    
  2. Créer une classe qui va effectuer l’interception. Cette classe doit satisfaire l’interface Castle.DynamuicProxy.IInterceptor:
    using Castle.DynamicProxy; 
    // ... 
    
    public class InterceptingClass : IInterceptor 
    { 
        public LoggerInterceptor() 
        { 
                
        } 
    
        public void Intercept(IInvocation invocation) 
        { 
            invocation.Proceed(); 
        } 
    }

    La méthode Intercept() sera exécutée à chaque fois qu’une méthode de la classe interceptée est exécutée. La ligne invocation.Proceed() permet d’exécuter la méthode dans la classe interceptée.

  3. Il faut indiquer quelle est la classe à intercepter et quel objet doit intercepter. Ces indications se font au moment de l’enregistrement dans le container dans la classe AutofacModule.

    Par exemple, on enregistre d’abord l’objet interceptant (i.e. la classe InterceptingClass) dans le container:

    using Autofac; 
    using Autofac.Extras.DynamicProxy; 
    // ... 
    
    public class AutofacModule : Module 
    { 
        protected override void Load(ContainerBuilder builder) 
        { 
            builder.Register(c => new InterceptingClass()); 
        } 
    }
    

    Ensuite, on enregistre la classe interceptée (i.e InterceptedClass) en précisant la classe interceptant (i.e InterceptingClass):

    using Autofac; 
    using Autofac.Extras.DynamicProxy; 
    // ... 
    
    public class AutofacModule : Module 
    { 
        protected override void Load(ContainerBuilder builder) 
        { 
            builder.Register(c => new InterceptingClass()); 
            // ... 
    
            builder.Register(c => new InterceptedClass()) 
            .As<IInterceptedClass>() 
            .SingleInstance() 
            .EnableInterfaceInterceptors() 
            .InterceptedBy(typeof(InterceptingClass)); 
        } 
    }
    

    Il y a 2 types d’interceptions:

    • Interception par interface: il faut indiquer ce type d’interception avec la ligne EnableInterfaceInterceptors(). La classe interceptée doit satisfaire une interface, c’est l’interface qui sera injectée avec le proxy.
    • Interception par classe: il faut indiquer ce type d’interception avec la ligne EnableClassInterceptors(). La classe interceptée doit comporter des méthodes virtuelles. Lorsque ces méthodes sont invoquées, la méthode du proxy est exécutée.

D’autres implémentations de l’interception existent avec Autofac notamment (cf. Type interceptors):

  • En enregistrant les classes interceptants par nom (i.e. named registration).
  • En déclenchant l’interception en utilisant des attributs.

Exemple d’implémentation de l’interception

Dans le cas de l’exemple dans le repository GitHub webapi_example/dependency_injection, on se propose de configurer 2 intercepteurs:

  • Un intercepteur nommé LoggingInterceptor permettant de logger des informations sur la fonction exécutée avec log4net.
  • Un intercepteur nommé TimingInterceptor permettant de logger le temps d’exécution des fonctions.

Dans un premier temps, on installe log4net:

  1. On installe le package NuGet de log4net en exécutant la ligne suivante:
    user@debian:~/% dotnet add webapi_example/WebApi.csproj package log4net
    
  2. On ajoute un fichier de configuration log4net au projet en ajoutant un fichier nommé log4net.config avec le contenu suivant:
    <log4net> 
        <appender name="Console" type="log4net.Appender.ConsoleAppender"> 
            <layout type="log4net.Layout.PatternLayout"> 
                <!-- Pattern to output the caller's file name and line number --> 
                <conversionPattern value="%5level [%thread] - %message%newline" /> 
            </layout> 
        </appender> 
    
        <appender name="RollingFile" type="log4net.Appender.RollingFileAppender"> 
            <file value="Logs/webapi.log" /> 
            <appendToFile value="true" /> 
            <maximumFileSize value="100KB" /> 
            <maxSizeRollBackups value="2" /> 
    
            <layout type="log4net.Layout.PatternLayout"> 
                <conversionPattern value="%level %thread %logger - %message%newline" /> 
            </layout> 
        </appender> 
    
        <root> 
            <level value="DEBUG" /> 
            <appender-ref ref="Console" /> 
            <appender-ref ref="RollingFile" /> 
        </root> 
    </log4net> 
    
  3. On configure le projet pour qu’à l’exécution, la configuration de log4net soit lue dans le fichier. Dans la classe Startup, on modifie la fonction Configure() en y ajoutant le code suivant:
    using System.IO; 
    using Autofac; 
    using log4net; 
    using log4net.Config; 
    using log4net.Repository; 
    using System.Reflection; 
    // ... 
    
    public class Startup 
    { 
        public void Configure(IApplicationBuilder app, IHostingEnvironment env) 
        { 
            var configFile = Path.Combine(env.ContentRootPath, "log4net.config"); 
            var repository = log4net.LogManager.GetRepository(Assembly.GetEntryAssembly()); 
            XmlConfigurator.Configure(repository, new FileInfo(configFile)); 
    
            // ... 
        } 
    }
  4. On crée la classe LoggingInterceptor permettant de logguer les appels aux fonctions:
    using Autofac; 
    using Castle.DynamicProxy; 
    using System.Linq; 
    using System.IO; 
    using log4net; 
    // ... 
    
    public class LoggingInterceptor : IInterceptor 
    { 
        private ILog logger; 
    
        public LoggingInterceptor() 
        { 
            this.logger = LogManager.GetLogger(typeof(LoggingInterceptor)); 
        } 
    
        public void Intercept(IInvocation invocation) 
        { 
            this.logger.InfoFormat("Calling method {0} with parameters {1}... ", 
                invocation.Method.Name, 
                string.Join(", ", invocation.Arguments.Select(a => (a ?? "").ToString()).ToArray())); 
    
            invocation.Proceed();   
    
            this.logger.InfoFormat("Done: result was {0}.", invocation.ReturnValue); 
        } 
    }
  5. On crée ensuite la classe TimingInterceptor permettant de logguer le temps d’exécution des fonctions:
    using System.Diagnostics; 
    
    public class TimingInterceptor : IInterceptor 
    { 
        private ILog logger; 
    
        public TimingInterceptor() 
        { 
            this.logger = LogManager.GetLogger(typeof(TimingInterceptor)); 
        } 
    
        public void Intercept(IInvocation invocation) 
        { 
            this.logger.InfoFormat("Calling method {0} with parameters {1}... ", 
                invocation.Method.Name, 
                string.Join(", ", invocation.Arguments.Select(a => (a ?? "").ToString()).ToArray())); 
    
            var stopWatch = new Stopwatch(); 
            stopWatch.Start(); 
    
            invocation.Proceed(); 
    
            stopWatch.Start(); 
    
            this.logger.InfoFormat($"Function {invocation.Method.Name}: execution time {stopWatch.Elapsed}"); 
        } 
    }
  6. On configure l’interception pour la classe PizzaOrderRepositoryService dans la classe AutofacModule:
    using Autofac; 
    using Autofac.Extras.DynamicProxy; 
    // ... 
    
    public class AutofacModule : Module 
    { 
        protected override void Load(ContainerBuilder builder) 
        { 
            builder.Register(c => new LoggingInterceptor()); 
            builder.Register(c => new TimingInterceptor()); 
    
            builder.Register(c => new PizzaOrderRepositoryService(c.Resolve<IPizzaFlavourRepositoryService>())) 
            .As<IPizzaOrderRepositoryService>() 
            .SingleInstance() 
            .EnableInterfaceInterceptors() 
            .InterceptedBy(typeof(LoggingInterceptor), typeof(TimingInterceptor)); 
    
            // ... 
        } 
    }
  7. Pour tester, il suffit de compiler puis d’exécuter le code en exécutant successivement:
    user@debian:~/webapi_example% dotnet build
    user@debian:~/webapi_example% dotnet run
    

    Il faut aller à l’adresse http://localhost:5000/swagger/index.html avec un browser.
    Si on exécute des méthodes du controller PizzaOrder (qui utilise la classe PizzaOrderlRepositoryService) comme par la fonction GET /api/PizzaOrder, on peut voir les messages logs correspondant aux exécutions de la méthode Intercept() des classes LoggingInterceptor et TimingInterceptor (dans la console et dans le fichier Logs/webapi.log):

    INFO 12 WebApiExample.Interceptors.LoggingInterceptor - Calling method GetOrders with parameters ...  
    INFO 12 WebApiExample.Interceptors.TimingInterceptor - Calling method GetOrders with parameters ...  
    INFO 12 WebApiExample.Interceptors.TimingInterceptor - Function GetOrders: execution time 00:00:00.0009593 
    INFO 12 WebApiExample.Interceptors.LoggingInterceptor - Done: result was System.Collections.Generic.List`1[WebApiExample.Services.PizzaOrder]. 
    

Le code complet de cet exemple se trouve dans la branche dependency_injection_autofac du repository GitHub webapi_example.

Pour résumer

Quelques soit le container utilisé (container natif ou Autofac), il faut privilégier l’injection par le constructeur de façon à ce que les dépendances d’une classe soient facilement visibles.

La plupart du temps, la durée de vie Transient (i.e. éphémère) convient pour la plupart des objets, elle permet d’éviter de se préoccuper des accès concurrents à un même objet. Dans le cas de singletons, en revanche, les accès concurrents provenant de requêtes différentes sur un même objet peuvent occasionner des comportements inattendus. Une attention particulière doit être portée quant à l’implémentation des singletons pour éviter ces comportements inattendus.

Enfin, Autofac ou d’autres containers tiers possèdent des fonctionnalités supplémentaires intéressantes comme l’enregistrement d’objets par convention ou l’interception. En outre, ils ont l’avantage d’être facilement configurable pour remplacer le container natif d’ASP.NET Core.

Références

Leave a Reply