web-dev-qa-db-fra.com

Injection Unity avec trop de paramètres constructeur

La question consiste à choisir la conception appropriée pour le scénario décrit ci-dessous. Il s'agit d'une rediffusion de https://stackoverflow.com/questions/51940180/unity-injection-with-too-many-constructor-parameters où il a été suggéré de poser la question ici.

La question semble provoquer une grave controverse, même parmi certains des gourous C # les plus connus. En fait, il est bien au-delà de C # et tombe davantage dans la pure informatique. La question est basée sur la "bataille" bien connue entre un modèle de localisateur de service et un modèle d'injection de dépendance pure: https://martinfowler.com/articles/injection.html vs http: //blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/ et une mise à jour ultérieure pour remédier à la situation lorsque l'injection de dépendance devient trop compliquée: http://blog.ploeh.dk/2010/02/02/RefactoringtoAggregateServices /

J'ai lu tout ce que j'ai pu trouver sur le sujet et j'ai même contacté directement plusieurs gourous C # bien connus, mais on ne sait toujours pas quel serait le meilleur choix.

Configuration et exigences de développement

  1. Nous utilisons Unity pour DI/IOC. En tant que tel, nous ne créons jamais de classes directement et n'injectons que des interfaces via des constructeurs.
  2. Nous utilisons Moq avec MockBehavior.Strict pour tous les tests unitaires afin de garantir que nous obtenons toujours le comportement attendu et aucune surprise cachée.
  3. Nous utilisons une simulation partielle pour les tests d'intégration où tous les services externes sont simulés d'une manière ou d'une autre et tous (ou presque) les services internes sont réels.
  4. Nous utilisons une racine de composition unique où toutes les interfaces sont enregistrées.
  5. Nous voulons simplifier la conception et réduire le temps/coût de maintenance.

Configuration de l'entreprise

Nous avons une grande (50 - 100+) collection de "règles", que j'appelle des micro-services, car chaque règle est enveloppée dans un seul service. Si vous avez un meilleur nom, veuillez "l'appliquer" lors de la lecture. Chacun d'eux fonctionne sur un seul objet, appelons-le quote. Cependant, un Tuple (contexte + citation) semble plus approprié. Le devis est un objet métier, qui est traité et sérialisé dans une base de données et le contexte contient des informations de support, qui sont nécessaires pendant le traitement du devis, mais ne sont pas enregistrées dans la base de données. Certaines de ces informations de support peuvent en fait provenir d'une base de données ou de certains services tiers. Ce n'est pas pertinent. La ligne d'assemblage vient à l'esprit comme un exemple du monde réel: un travailleur de l'assemblage (micro service) reçoit une entrée (instruction (contexte) + pièces (devis)), la traite (fait quelque chose avec des pièces selon l'instruction et/ou modifie l'instruction) et le passe plus loin en cas de succès OR le supprime (soulève une exception) en cas de problème. Les microservices finissent par être regroupés en un petit nombre (environ 5) de services de haut niveau. Cette approche linéarise le traitement d'un objet métier très complexe et permet de tester chaque micro-service séparément de tous les autres: donnez-lui simplement un état d'entrée et testez qu'il produit la sortie attendue.

Voici où cela devient intéressant. En raison du nombre d'étapes impliquées, les services de haut niveau commencent à dépendre de nombreux micro-services: 10-15 + et plus. Cette dépendance est naturelle et reflète simplement la complexité de l'objet métier sous-jacent. En plus de cela, les microservices peuvent être ajoutés/supprimés presque sur une base constante: ce sont essentiellement des règles commerciales, qui sont presque aussi fluides que l'eau. Quelqu'un peut déclarer que ces services de haut niveau violent le principe de responsabilité unique. Eh bien, si je dois appliquer, disons, plus de 15 "règles" pour créer un devis, alors l'implémentation de IQuoteCreateService.CreateQuote doit les appliquer tous, tout en n'effectuant qu'une seule tâche, en créant un devis!

Cela contredit sévèrement la recommandation de Mark ci-dessus: si j'ai plus de 15 règles effectivement indépendantes appliquées à une citation dans un service de haut niveau, alors, selon le troisième blog, je devrais les regrouper en quelques groupes logiques de, disons pas plus de 3-4 au lieu d'injecter tous les 15+ via le constructeur. Mais il n'y a pas de groupes logiques! Bien que certaines règles soient peu dépendantes, la plupart ne le sont pas et donc les regrouper artificiellement fera plus de mal que de bien.

Ajoutez à cela que les règles changent fréquemment, et cela devient un cauchemar de maintenance: tous les appels réels/simulés doivent être mis à jour chaque fois que les règles changent.

Et je n'ai même pas mentionné que les règles dépendent de l'État américain et donc, en théorie, il y a environ 50 collections de règles avec une collection par chaque État et par chaque flux de travail. Et bien que certaines des règles soient partagées entre tous les États (comme "enregistrer le devis dans la base de données"), il existe de nombreuses règles spécifiques à chaque État.

Voici un exemple très simplifié.

Devis - objet métier, qui est enregistré dans la base de données.

public class Quote
{
    public string SomeQuoteData { get; set; }
    // ...
}

Micro services. Chacun d'eux effectue quelques petites mises à jour pour citer. Des services de niveau supérieur peuvent également être créés à partir de certains micro-services de niveau inférieur.

public interface IService_1
{
    Quote DoSomething_1(Quote quote);
}
// ...

public interface IService_N
{
    Quote DoSomething_N(Quote quote);
}

Tous les micro-services de haut niveau héritent de cette interface. C'est pratique parce que l'implémentation de bas niveau: QuoteProcessor fournit certaines tâches courantes, comme appeler pour la validation des données, effectuer des tâches de finalisation (si nécessaire), etc ... Ceci n'est pas pertinent pour la question mais explique pourquoi les micro services héritent également de cette interface.

public interface IQuoteProcessor
{
    List<Func<Quote, Quote>> QuotePipeline { get; }
    Quote ProcessQuote(Quote quote = null);
}

// Low level quote processor. It does all workflow related work.
public abstract class QuoteProcessor : IQuoteProcessor
{
    public abstract List<Func<Quote, Quote>> QuotePipeline { get; }

    public Quote ProcessQuote(Quote quote = null)
    {
        // The real code performs Aggregate over QuotePipeline.
        // That applies each step from workflow to a quote.
        return quote;
    }
}

Un des services de "workflow" de haut niveau:

public interface IQuoteCreateService
{
    Quote CreateQuote(Quote quote = null);
}

et sa mise en œuvre réelle où nous utilisons de nombreux micro-services de bas niveau.

public class QuoteCreateService : QuoteProcessor, IQuoteCreateService
{
    protected IService_1 Service_1;
    // ...
    protected IService_N Service_N;

    public override List<Func<Quote, Quote>> QuotePipeline =>
        new List<Func<Quote, Quote>>
        {
            Service_1.DoSomething_1,
            // ...
            Service_N.DoSomething_N
        };

    public Quote CreateQuote(Quote quote = null) => 
        ProcessQuote(quote);
}

Problèmes

Il existe deux façons principales d'atteindre l'ID:

L'approche standard consiste à injecter toutes les dépendances via le constructeur:

    public QuoteCreateService(
        IService_1 service_1,
        // ...
        IService_N service_N
        )
    {
        Service_1 = service_1;
        // ...
        Service_N = service_N;
    }

Et puis enregistrez tous les types avec Unity:

public static class UnityHelper
{
    public static void RegisterTypes(this IUnityContainer container)
    {
        container.RegisterType<IService_1, Service_1>(
            new ContainerControlledLifetimeManager());
        // ...
        container.RegisterType<IService_N, Service_N>(
            new ContainerControlledLifetimeManager());

        container.RegisterType<IQuoteCreateService, QuoteCreateService>(
            new ContainerControlledLifetimeManager());
    }
}

Ensuite, Unity fera sa "magie" et résoudra tous les services au moment de l'exécution. Le problème est que nous avons actuellement environ 50 à 100 de ces micro-services et leur nombre devrait augmenter. Par la suite, certains constructeurs reçoivent déjà 10 à 15 + services injectés. Ce n'est pas pratique à entretenir, à se moquer, etc ...

Bien sûr, il est possible d'utiliser l'idée à partir d'ici: http://blog.ploeh.dk/2010/02/02/RefactoringtoAggregateServices/ Cependant, les micro services ne sont pas vraiment liés les uns aux autres et les regrouper est donc un processus artificiel sans aucune justification. De plus, cela va également à l'encontre de l'objectif de rendre l'ensemble du flux de travail linéaire et indépendant (un micro service prend un "état" actuel, puis exécute une action avec un devis, puis passe simplement à autre chose). Aucun d'eux ne se soucie des autres microservices avant ou après eux.

Une idée alternative semble créer un seul "référentiel de services" ou localisateur de services:

public interface IServiceRepository
{
    IService_1 Service_1 { get; set; }
    // ...
    IService_N Service_N { get; set; }

    IQuoteCreateService QuoteCreateService { get; set; }
}

public class ServiceRepository : IServiceRepository
{
    protected IUnityContainer Container { get; }

    public ServiceRepository(IUnityContainer container)
    {
        Container = container;
    }

    private IService_1 _service_1;

    public IService_1 Service_1
    {
        get => _service_1 ?? (_service_1 = Container.Resolve<IService_1>());
        set => _service_1 = value;
    }
    // ...
}

Enregistrez-le ensuite avec Unity et changez le constructeur de tous les services pertinents en quelque chose comme ceci:

    public QuoteCreateService(IServiceRepository repo)
    {
        Service_1 = repo.Service_1;
        // ...
        Service_N = repo.Service_N;
    }

Les avantages de cette approche (par rapport à la précédente) sont les suivants:

Tous les microservices et les services de niveau supérieur peuvent être créés sous une forme unifiée: de nouveaux microservices peuvent être facilement ajoutés/supprimés sans qu'il soit nécessaire de corriger l'appel du constructeur pour les services et tous les tests unitaires. Par la suite, la maintenance et la complexité diminuent.

En raison de l'interface IServiceRepository, il est facile de créer un test unitaire automatisé, qui itérera sur toutes les propriétés et validera que tous les services peuvent être instanciés, ce qui signifie qu'il n'y aura pas de mauvaises surprises d'exécution.

Le problème avec cette approche est qu'elle ressemble beaucoup à un localisateur de services, ce que certaines personnes considèrent comme un anti-modèle: http://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern / puis les gens commencent à argumenter que toutes les dépendances doivent être explicites et non cachées comme dans ServiceRepository.

Pourtant, quelques idées supplémentaires ont été fournies dans les réponses à la question initiale (dans le lien ci-dessus). Ils étaient tous centrés sur l'idée d'utiliser IEnumerable pour passer en tant que tableau de paramètres ou en tant que collection de règles à appliquer. Personnellement, je pense que l'utilisation d'un tableau de paramètres pour transmettre des services au constructeur fera plus de mal que de bien en raison de la nécessité de maintenir chaque appel de constructeur en synchronisation et sans compilateur à la rescousse.

Question

Je veux réduire la complexité associée à l'injection de trop de paramètres via le constructeur. Les règles (ou paramètres) sont assez indépendantes, donc je n'ai pas de raisonnement logique pour les regrouper afin de diminuer le nombre de paramètres pour les classes supérieures. De l'autre côté, la configuration métier nécessite que de nombreuses règles presque indépendantes soient appliquées au niveau du ou des objets métier supérieurs (devis, par exemple).

5

Ce morceau de code a attiré mon attention:

public override List<Func<Quote, Quote>> QuotePipeline =>
    new List<Func<Quote, Quote>>
    {
        Service_1.DoSomething_1,
        // ...
        Service_N.DoSomething_N
    };

Le fait que vous preniez une liste d'éléments discrets et que vous les mettiez immédiatement dans une liste suggère qu'il existe ici un concept commercial implicite qui n'est pas incorporé dans une classe. Si c'était moi, j'aurais une classe pour représenter le pipline et injecter le pipeline lui-même ou une fabrique qui permettra à l'appelant d'en créer un.

Si vous écrivez une usine, il est "OK" de passer dans le conteneur Unity lui-même, car la gestion de la dépendance et de la durée de vie fait partie de la responsabilité unique d'une usine. De cette façon, vous bénéficiez des avantages du modèle de localisateur de services sans trop enfreindre les règles pour l'IoC.

Lorsque vous devez effectuer un test unitaire, vous pouvez simplement remplacer une autre fabrique de pipelines qui renvoie des simulations.

Donc, nous définissons d'abord le pipeline:

public class QuotePipeline : List<Func<Quote,Quote>>
{
    public Quote Execute(Quote quote)
    {
        foreach (var f in this) quote = f(quote);
        return quote;
    }
}

Écrivez maintenant une usine. Unity s'injectera toujours automatiquement si votre classe a IUnityContainer comme argument constructeur.

class QuotePipelineFactory : IQuotePipelineFactory
{
    protected readonly IUnityContainer _container;

    public QuotePipelineFactory(IUnityContainer container)
    {
        _container = container;
    }

    public QuotePipeline GetPipeline()
    {
        var p = new QuotePipeline();

        var d1 = _container.Resolve<Service_1>();
        p.Add( q => d1.DoSomething_1(q) );

        var d2 = _container.Resolve<Service_2>();
        p.Add( q => d2.DoSomething_2(q) );

        return p;
    }
}

Injectez ensuite l'usine et récupérez le pipeline lui-même:

public class QuoteCreateService : IQuoteProcessor
{
    protected readonly IQuotePipelineFactory _quotePipelineFactory;

    public QuoteCreateService(IQuotePipelineFactory quotePipelineFactory)
    {
        _quotePipelineFactory = quotePipelineFactory;
    }

    public Quote CreateQuote(Quote quote)
    {
        var p = _quotePipelineFactory.GetPipeline();
        return p.Execute(quote);
    }
}

Notez maintenant qu'il n'y a qu'une seule dépendance et qu'elle peut prendre en charge un nombre quelconque de processeurs de devis.

Si vos pipelines de devis sont toujours les mêmes, vous pouvez bien sûr les stocker en tant qu'instance unique dans l'usine, qui sera adaptée à la durée de vie par Unity.

Si les pipelines diffèrent, vous pouvez toujours ajouter des arguments d'entrée à GetPipeline(), puis éventuellement utiliser un modèle de stratégie pour choisir et choisir les processeurs à inclure dans le pipeline.

1
John Wu

Personnellement, je n'aime pas du tout l'injection via les constructeurs. C'est parce que les classes auront besoin de classes injectées que seules elles et personne d'autre ne se soucie, mais maintenant, toute personne créant une instance de la classe doit effectuer ces injections, donc elles ont également besoin qu'elles soient injectées à leurs constructeurs, et ainsi de suite et ainsi de suite.

C'est bien mieux si chaque classe s'occupe d'elle-même. Ayez une usine ou des singletons qui retournent un remplacement si nécessaire, et tout le monde recueille tous les objets injectés dont ils ont besoin. Sérieusement, pourquoi voudriez-vous une classe injectée dans votre constructeur qui est utilisée par une autre instance à sept niveaux de distance?

1
gnasher729