Loupe

Générer automatiquement une Factory avec .NET Core et Castle DynamicProxy

Injection de dépendances : le bon et le moins bon

L'injection de dépendances est un excellent pattern, qui peut vraiment aider à avoir du code propre, bien découplé et testable. Il existe de nombreuses bibliothèques d'injection de dépendances (DI), comme Autofac, Lamar (le successeur de StructureMap), ou encore Castle Windsor, mais ces dernières années j'utilise surtout celle fournie par Microsoft avec .NET Core : Microsoft.Extensions.DependencyInjection. C'est loin d'être la plus complète (en fait elle est même assez minimaliste), mais elle suffit généralement à mes besoins.

Mais même si j'aime beaucoup l'injection de dépendances, il y a des scénarios où ça peut rendre les choses plus compliquées… Par exemple, quand on a une classe instanciée manuellement avec des paramètres explicites, mais qui dépend aussi d'un ou plusieurs services, comme dans le code suivant :

public class FooViewModel
{
    private readonly IMyService _myService;

    public FooViewModel(int id, string name, IMyService myService)
    {
        Id = id;
        Name = name;
        _myService = myService;
    }

    public int Id { get; }
    public string Name { get; }
}

Pour créer une instance de FooViewModel, on doit passer des valeurs pour id et name, et une instance de IMyService. Cela implique d'avoir une instance de IMyService sous la main, il faut donc l'injecter dans la classe qui crée une instance de FooViewModel, bien qu'elle n'en ait pas besoin elle-même. Quand c'est juste une dépendance comme dans cet exemple, ça va encore, mais s'il y en avait plusieurs, ça deviendrait vite un peu lourd.

Le pattern Factory

Heureusement, c'est un problème connu, et devinez quoi ? Il a une solution connue ! On règle habituellement ce problème en introduisant une factory ("usine" ou "fabrique" selon l'académie française…). Par exemple, dans notre scénario, puisqu'on crée des ViewModels, on va créer une interface IViewModelFactory :

public interface IViewModelFactory
{
    FooViewModel CreateFooViewModel(int id, string name);
}

Remarquez que la méthode CreateFooViewModel a les mêmes paramètres de "données" que le constructeur de FooViewModel, mais pas son paramètre de "service". C'est la factory qui se chargera de fournir ce dernier. Ainsi la classe qui veut créer une instance de FooViewModel a juste besoin d'appeler la méthode CreateFooViewModel de la factory, sans avoir à se préoccuper des dépendances de FooViewModel.

Une implémentation de cette factory pourrait ressembler à ceci :

public class ViewModelFactory : IViewModelFactory
{
    private readonly IMyService _myService;

    public ViewModelFactory(IMyService myService)
    {
        _myService = myService;
    }

    public FooViewModel CreateFoo(int id, string name)
    {
        return new FooViewModel(id, name, _myService);
    }
}

En gros, la factory permet de faire abstraction des dépendances des objets qu'on veut créer, de façon à ce que le reste du code n'ait pas à s'en préoccuper.

Bon, jusqu'ici, rien de fracassant… le pattern factory est bien connu et largement utilisé. C'est un pattern très utile, mais qu'est-ce que ça donnerait si on avait des dizaines de ViewModels à créer ? On devrait implémenter autant de méthodes qu'il y a de ViewModels, et injecter dans la factory les dépendances de tous ces objets. Ca peut rapidement devenir le bazar dans une application non triviale.

Bien sûr, on pourrait scinder la factory en plusieurs factories plus petites, qui se chargeraient de créer chacune un ou quelques ViewModels. Mais on se retrouverait alors avec une flopée de factories, ce qui n'est pas très pratique non plus…

Résoudre automatiquement les dépendances

Peut-être qu'il est temps de prendre un peu de recul et d'attaquer le problème sous un autre angle…

Comment en est-on arrivé là ? Les conteneurs d'injection de dépendance sont supposés savoir créer des instances de classe avec des dépendances (c'est leur boulot, après tout !), mais dès qu'on ajoute des paramètres de "données" en plus des dépendances de service, tout s'écroule, et on se retrouve à devoir injecter les dépendances à la main. Ce qu'on voudrait vraiment, c'est fournir juste les paramètres de données, et que le conteneur DI s'occupe des dépendances.

Heureusement pour nous, il y a dans Microsoft.Extensions.DependencyInjection une classe pas très connue qui pourrait nous aider : ActivatorUtilities. C'est une classe statique qui contient toute la logique pour construire un objet en résolvant les dépendances depuis un container, et qui permet aussi de spécifier explicitement des paramètres supplémentaires qui ne viennent pas du container. Dans le cas de notre classe FooViewModel, on pourrait l'utiliser comme ceci :

ActivatorUtilities.CreateInstance(
    serviceProvider,
    typeof(FooViewModel),
    123,      // id
    "test");  // name

Pas besoin de se demander quelles sont les dépendances de FooViewModel : ActivatorUtilities examine les paramètres du constructeur, résout les services depuis le container (service provider), et les injecte en plus des paramètres explicites.

Évidemment, sous cette forme, ce n'est pas très pratique à utiliser, et ce n'est pas fortement typé… Pour rendre ça utilisable, il faudrait donc "l'emballer" dans une factory. Mais l'implémentation de la factory risque d'être encore plus pénible qu'avant ! Ce n'est pas du code très compliqué, du coup, est-ce qu'on ne pourrait pas l'automatiser ?

Implémenter la factory automatiquement

Castle DynamicProxy est un composant de la vénérable bibliothèque Castle Core. Grosso modo, il permet d'implémenter automatiquement des classes ou interfaces, en délégant éventuellement à un autre objet, et en utilisant des intercepteurs pour contrôler le comportement des méthodes. Cela en fait un outil idéal pour gérer des problématiques transverses (logging, retry…), ou encore pour implémenter des bibliothèques de mocking (FakeItEasy, Moq et NSubstitute utilisent toutes Castle DynamicProxy).

En quoi cela peut-il nous aider à résoudre notre problème ? Tout simplement en permettant d'implémenter "automatiquement" notre interface IViewModelFactory !

Tout d'abord, on va devoir écrire un intercepteur. Comme son nom l'indique, il va intercepter les appels aux méthodes de notre interface, pour en fournir l'implémentation. Voilà ce que ça donne :

class FactoryInterceptor : IInterceptor
{
    private readonly IServiceProvider _serviceProvider;

    public FactoryInterceptor(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void Intercept(IInvocation invocation)
    {
        invocation.ReturnValue =
            ActivatorUtilities.CreateInstance(
                serviceProvider,
                invocation.Method.ReturnType,
                invocation.Arguments);
    }
}

L'objet IInvocation représente l'appel. On peut en examiner la méthode et les arguments, et spécifier la valeur de retour. Ici on utilise le type de retour de la méthode pour savoir quel type d'objet créer, et on utilise ActivatorUtilities pour en créer une instance, en passant les arguments de l'appel intercepté.

Il nous reste juste à créer un objet proxy qui implémente effectivement IViewModelFactory :

var generator = new ProxyGenerator();
var interceptor = new FactoryInterceptor(serviceProvider);
var factory = generator.
    CreateInterfaceProxyWithoutTarget<IViewModelFactory>(
        interceptor);

Et voilà ! Plus besoin d'implémenter nous même les méthodes de la factory, il suffit de les déclarer, et notre intercepteur fait le reste. Il faut juste s'assurer que les paramètres des méthodes de la factory correspondent bien à ceux des constructeurs des ViewModels (hors services), sinon on aura une erreur à l'exécution.

Pour finir

OK, on a quelque chose qui fonctionne. Pour faciliter l'utilisation, on va empaqueter tout ça dans une méthode d'extension :

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddAutoFactory<TFactory>(this IServiceCollection services)
        where TFactory : class
    {
        services.AddSingleton<TFactory>(CreateFactory<TFactory>);
        return services;
    }

    private static TFactory CreateFactory<TFactory>(IServiceProvider serviceProvider)
        where TFactory : class
    {
        var generator = new ProxyGenerator();
        return generator.CreateInterfaceProxyWithoutTarget<TFactory>(
            new FactoryInterceptor(serviceProvider));
    }

    private class FactoryInterceptor : IInterceptor
    {
        private readonly IServiceProvider _serviceProvider;
        private readonly ConcurrentDictionary<MethodInfo, ObjectFactory> _factories;

        public FactoryInterceptor(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
            _factories = new ConcurrentDictionary<MethodInfo, ObjectFactory>();
        }

        public void Intercept(IInvocation invocation)
        {
            var factory = _factories.GetOrAdd(invocation.Method, CreateFactory);
            invocation.ReturnValue = factory(_serviceProvider, invocation.Arguments);
        }

        private ObjectFactory CreateFactory(MethodInfo method)
        {
            return ActivatorUtilities.CreateFactory(
                method.ReturnType,
                method.GetParameters().Select(p => p.ParameterType).ToArray());
        }
    }
}

Notez que j'ai un peu modifié l'intercepteur par rapport à la version précédente. J'ai inclus une optimisation: au lieu d'appeler ActivatorUtilities.CreateInstance à chaque appel, j'ai utilisé ActivatorUtilities.CreateFactory pour créer et mettre en cache un delegate réutilisable pour chaque méthode.

On peut maintenant utiliser cette méthode de la façon suivante pour enregistrer une factory dans le container :

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddAutoFactory<IViewModelFactory>();
    ...
}

Et voilà, on peut maintenant injecter notre factory automatique partout où on en a besoin !

Remarque: Cette solution a une limitation importante : les méthodes de la factory doivent déclarer un type de retour concret, pas une interface ou une classe abstraite, sinon ActivatorUtilities ne saura pas quel type concret créer. Cette approche ne convient donc pas à tous les scénarios.

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus