De cette façon, j'écris ce code est testable, mais y a-t-il quelque chose qui ne va pas?

13

J'ai une interface appelée IContext. Aux fins de cela, peu importe ce qu'il fait, sauf ce qui suit:

T GetService<T>();

Cette méthode consiste à examiner le conteneur DI actuel de l'application et tente de résoudre la dépendance. Assez standard je pense.

Dans mon application ASP.NET MVC, mon constructeur ressemble à ceci.

protected MyControllerBase(IContext ctx)
{
    TheContext = ctx;
    SomeService = ctx.GetService<ISomeService>();
    AnotherService = ctx.GetService<IAnotherService>();
}

Donc, plutôt que d'ajouter plusieurs paramètres dans le constructeur pour chaque service (car cela deviendra vraiment ennuyeux et long pour les développeurs qui étendent l'application), j'utilise cette méthode pour obtenir des services.

Maintenant, ça ne va pas . Cependant, la façon dont je le justifie actuellement dans ma tête est la suivante - je peux me moquer .

Je peux. Il ne serait pas difficile de se moquer IContextpour tester le contrôleur. Je devrais quand même:

public class MyMockContext : IContext
{
    public T GetService<T>()
    {
        if (typeof(T) == typeof(ISomeService))
        {
            // return another mock, or concrete etc etc
        }

        // etc etc
    }
}

Mais comme je l'ai dit, ça ne va pas. Toutes pensées / abus bienvenus.

LiverpoolsNumber9
la source
8
Cela s'appelle Service Locator et je n'aime pas ça. Il y a eu beaucoup d'écrits sur le sujet - voir martinfowler.com/articles/injection.html et blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern pour une entrée.
Benjamin Hodgson
Extrait de l'article de Martin Fowler: "J'ai souvent entendu la plainte selon laquelle ces types de localisateurs de services sont une mauvaise chose car ils ne sont pas testables parce que vous ne pouvez pas leur substituer des implémentations. Vous pouvez certainement les concevoir mal pour y entrer genre de problème, mais vous n'avez pas à le faire. Dans ce cas, l'instance de localisateur de service est juste un simple détenteur de données. Je peux facilement créer le localisateur avec des implémentations de test de mes services. " Pourriez-vous expliquer pourquoi vous ne l'aimez pas? Peut-être dans une réponse?
LiverpoolsNumber9
8
Il a raison, c'est une mauvaise conception. Il est facile: public SomeClass(Context c). Ce code est assez clair, n'est-ce pas? Il déclare, that SomeClassdépend d'un Context. Euh, mais attendez, ce n'est pas le cas! Il ne dépend que de la dépendance Xqu'il obtient du contexte. Cela signifie que chaque fois que vous y apportez une modification, Contextcelle - ci peut casser SomeObject, même si vous ne modifiez que l' Contextart Y. Mais oui, tu sais que tu n'as changé que Ynon X, donc ça SomeClassva. Mais écrire un bon code ne concerne pas ce que vous savez, mais ce que le nouvel employé sait quand il regarde votre code la première fois.
valenterry
@DocBrown Pour moi, c'est exactement ce que j'ai dit - je ne vois pas la différence ici. Pouvez-vous expliquer davantage?
valenterry
1
@DocBrown Je vois votre point maintenant. Oui, si son contexte n'est qu'un ensemble de toutes les dépendances, ce n'est pas une mauvaise conception. Cela peut être une mauvaise dénomination, mais ce n'est qu'une hypothèse. OP devrait clarifier s'il existe plus de méthodes (objets internes) du contexte. En outre, discuter du code est très bien, mais c'est programmers.stackexchange donc pour moi, nous devrions également essayer de voir "derrière" les choses pour améliorer l'OP.
valenterry

Réponses:

5

Avoir un au lieu de plusieurs paramètres dans le constructeur n'est pas la partie problématique de cette conception . Tant que votre IContextclasse n'est rien d'autre qu'une façade de service , spécialement pour fournir les dépendances utilisées dans MyControllerBase, et non un localisateur de service général utilisé dans tout votre code, cette partie de votre code est IMHO ok.

Votre premier exemple peut être changé en

protected MyControllerBase(IContext ctx)
{
    TheContext = ctx;
    SomeService = ctx.GetSomeService();
    AnotherService = ctx.GetAnotherService();
}

ce ne serait pas un changement de conception substantiel de MyControllerBase. Si cette conception est bonne ou mauvaise ne dépend que du fait si vous voulez

  • assurez-vous TheContext, SomeServiceet AnotherServicesont toujours tous initialisés avec des objets fictifs, ou tous avec des objets réels
  • ou, pour permettre leur initialisation avec différentes combinaisons des 3 objets (ce qui signifie, dans ce cas, vous devrez passer les paramètres individuellement)

Donc, utiliser un seul paramètre au lieu de 3 dans le constructeur peut être tout à fait raisonnable.

Ce qui pose problème, c'est d' IContextexposer la GetServiceméthode en public. À mon humble avis, vous devriez éviter cela, mais plutôt garder les "méthodes d'usine" explicites. Est-ce que ce sera ok pour implémenter les méthodes GetSomeServiceet GetAnotherServicede mon exemple en utilisant un localisateur de service? À mon humble avis, cela dépend. Tant que la IContextclasse continue d'être une simple fabrique abstraite dans le but spécifique de fournir une liste explicite d'objets de service, ce qui est acceptable à mon humble avis. Les usines abstraites ne sont généralement que du code «collé», qui n'a pas besoin d'être testé lui-même. Néanmoins, vous devriez vous demander si dans le contexte de méthodes comme GetSomeService, si vous avez vraiment besoin d'un localisateur de service, ou si un appel de constructeur explicite ne serait pas plus simple.

Alors, méfiez-vous, lorsque vous vous en tenez à une conception où l' IContextimplémentation n'est qu'un wrapper autour d'une GetServiceméthode générique publique , permettant de résoudre toutes les dépendances arbitraires par des classes arbitraires, alors tout s'applique à ce que @BenjaminHodgson a écrit dans sa réponse.

Doc Brown
la source
Je suis d'accord avec ça. Le problème avec l'exemple est la GetServiceméthode générique . Le refactoring vers des méthodes explicitement nommées et typées est préférable. Mieux encore serait d'exposer explicitement les dépendances de l' IContextimplémentation dans le constructeur.
Benjamin Hodgson
1
@BenjaminHodgson: c'est peut-être parfois mieux, mais ce n'est pas toujours mieux. Vous notez l'odeur du code lorsque la liste des paramètres ctor s'allonge de plus en plus. Voir mon ancienne réponse ici: programmers.stackexchange.com/questions/190120/…
Doc Brown
@DocBrown "l'odeur de code" de la sur-injection de constructeur est révélatrice d'une violation du SRP, qui est le vrai problème. Envelopper simplement plusieurs services dans une classe Facade, uniquement pour les exposer en tant que propriétés, ne fait rien pour résoudre la source du problème. Ainsi, une façade ne devrait pas être un simple wrapper autour d'autres composants, mais devrait idéalement offrir une API simplifiée pour les consommer (ou devrait les simplifier d'une autre manière) ...
AlexFoxGill
... N'oubliez pas qu'une "odeur de code" comme une sur-injection de constructeur n'est pas un problème en soi. C'est juste un indice qu'il y a un problème plus profond dans le code qui peut généralement être résolu en refactorisant sensiblement une fois le problème identifié
AlexFoxGill
Où étiez-vous lorsque Microsoft a cuit cela IValidatableObject?
RubberDuck
15

Cette conception est connue sous le nom de Service Locator * et je ne l'aime pas. Il y a beaucoup d'arguments contre:

Service Locator vous couple à votre conteneur . En utilisant l'injection de dépendance régulière (où le constructeur énonce explicitement les dépendances), vous pouvez remplacer simplement votre conteneur par un autre, ou revenir à new-expressions. Avec votre, IContextce n'est pas vraiment possible.

Service Locator masque les dépendances . En tant que client, il est très difficile de dire ce dont vous avez besoin pour construire une instance d'une classe. Vous avez besoin d'une sorte de IContext, mais vous devez également définir le contexte pour renvoyer les objets corrects afin de faire le MyControllerBasetravail. Ce n'est pas du tout évident de la signature du constructeur. Avec DI régulier, le compilateur vous dit exactement ce dont vous avez besoin. Si votre classe a beaucoup de dépendances, vous devriez ressentir cette douleur car elle vous incitera à refactoriser. Service Locator cache les problèmes liés aux mauvaises conceptions.

Service Locator provoque des erreurs d'exécution . Si vous appelez GetServiceavec un paramètre de type incorrect, vous obtiendrez une exception. En d'autres termes, votre GetServicefonction n'est pas une fonction totale. (Les fonctions totales sont une idée du monde FP, mais cela signifie essentiellement que les fonctions doivent toujours renvoyer une valeur.) Mieux vaut laisser le compilateur vous aider et vous dire quand vous avez des dépendances erronées.

Service Locator viole le principe de substitution Liskov . Parce que son comportement varie en fonction de l'argument type, Service Locator peut être vu comme s'il avait effectivement un nombre infini de méthodes sur l'interface! Cet argument est expliqué en détail ici .

Le localisateur de service est difficile à tester . Vous avez donné un exemple de faux IContextpour les tests, ce qui est bien, mais il est certainement préférable de ne pas avoir à écrire ce code en premier lieu. Injectez simplement vos fausses dépendances directement, sans passer par votre localisateur de services.

Bref, juste faites pas . Cela semble être une solution séduisante au problème des classes avec beaucoup de dépendances, mais à long terme, vous allez juste rendre votre vie misérable.

* Je définis Service Locator comme un objet avec une Resolve<T>méthode générique qui est capable de résoudre des dépendances arbitraires et est utilisé dans toute la base de code (pas seulement à la racine de composition). Ce n'est pas la même chose que Service Facade (un objet qui regroupe un petit ensemble connu de dépendances) ou Abstract Factory (un objet qui crée des instances d'un seul type - le type d'une Abstract Factory peut être générique mais la méthode ne l'est pas) .

Benjamin Hodgson
la source
1
Vous vous plaignez du modèle Service Locator (que j'accepte). Mais en réalité, dans l'exemple de l'OP, il MyControllerBasen'est ni couplé à un conteneur DI spécifique, ni "vraiment" un exemple de l'anti-modèle Service Locator.
Doc Brown
@DocBrown, je suis d'accord. Non pas parce que cela me facilite la vie, mais parce que la plupart des exemples donnés ci-dessus ne sont pas pertinents pour mon code.
LiverpoolsNumber9
2
Pour moi, la caractéristique de l'anti-modèle Service Locator est la GetService<T>méthode générique . La résolution des dépendances arbitraires est l'odeur réelle, qui est présente et correcte dans l'exemple de l'OP.
Benjamin Hodgson
1
Un autre problème lié à l'utilisation d'un localisateur de service est qu'il réduit la flexibilité: vous ne pouvez avoir qu'une seule implémentation de chaque interface de service. Si vous construisez deux classes qui reposent sur un IFrobnicator, mais décidez plus tard que l'une doit utiliser votre implémentation DefaultFrobnicator d'origine, mais que l'autre devrait vraiment utiliser un décorateur CacheingFrobnicator autour d'elle, vous devez changer le code existant, alors que si vous êtes injecter des dépendances directement tout ce que vous avez à faire est de changer votre code de configuration (ou le fichier de configuration si vous utilisez un framework DI). Il s'agit donc d'une violation OCP.
Jules
1
@DocBrown La GetService<T>()méthode permet de demander des classes arbitraires: "Ce que cette méthode fait, c'est regarder le conteneur DI actuel de l'application et tente de résoudre la dépendance. Assez standard je pense." . Je répondais à votre commentaire en haut de cette réponse. Ceci est 100% un localisateur de services
AlexFoxGill
5

Les meilleurs arguments contre l'anti-modèle Service Locator sont clairement énoncés par Mark Seemann, donc je ne vais pas trop expliquer pourquoi c'est une mauvaise idée - c'est un voyage d'apprentissage que vous devez prendre le temps de comprendre par vous-même (je recommande également le livre de Mark ).

OK donc pour répondre à la question - reformulons votre problème réel :

Donc, plutôt que d'ajouter plusieurs paramètres dans le constructeur pour chaque service (car cela deviendra vraiment ennuyeux et long pour les développeurs qui étendent l'application), j'utilise cette méthode pour obtenir des services.

Il y a une question qui résout ce problème sur StackOverflow . Comme mentionné dans l'un des commentaires:

La meilleure remarque: "L'un des merveilleux avantages de l'injection de constructeur est qu'il rend les violations du principe de responsabilité unique manifestement évidentes."

Vous cherchez au mauvais endroit la solution à votre problème. Il est important de savoir quand une classe en fait trop. Dans votre cas, je soupçonne fortement qu'il n'y a pas besoin d'un "contrôleur de base". En fait, dans la POO, il y a presque toujours pas besoin d'héritage du tout . Les variations de comportement et de fonctionnalité partagée peuvent être entièrement obtenues grâce à une utilisation appropriée des interfaces, ce qui se traduit généralement par un code mieux factorisé et encapsulé - et pas besoin de transmettre des dépendances aux constructeurs de superclasses.

Dans tous les projets sur lesquels j'ai travaillé où il y a un contrôleur de base, cela a été fait uniquement dans le but de partager des propriétés et des méthodes pratiques, telles que IsUserLoggedIn()et GetCurrentUserId(). ARRÊTEZ . C'est une horrible mauvaise utilisation de l'héritage. Au lieu de cela, créez un composant qui expose ces méthodes et prenez une dépendance là où vous en avez besoin. De cette façon, vos composants resteront testables et leurs dépendances seront évidentes.

Mis à part toute autre chose, lors de l'utilisation du modèle MVC, je recommanderais toujours des contrôleurs maigres . Vous pouvez en savoir plus à ce sujet ici, mais l'essence du modèle est simple, les contrôleurs dans MVC ne devraient faire qu'une seule chose: gérer les arguments passés par le framework MVC, déléguer d'autres préoccupations à un autre composant. Il s'agit là encore du principe de responsabilité unique à l'œuvre.

Il serait vraiment utile de connaître votre cas d'utilisation pour porter un jugement plus précis, mais honnêtement, je ne peux penser à aucun scénario où une classe de base est préférable à des dépendances bien factorisées.

AlexFoxGill
la source
+1 - ceci est une nouvelle vision de la question qu'aucune des autres réponses n'a vraiment abordée
Benjamin Hodgson
1
"L'un des merveilleux avantages de Constructor Injection est qu'il rend les violations du principe de responsabilité unique manifestement évidentes" J'adore ça. Vraiment une bonne réponse. Je ne suis pas d'accord avec tout ça. Mon cas d'utilisation est, curieusement, de ne pas avoir à dupliquer de code dans un système qui aura plus de 100 contrôleurs (au moins). Cependant, concernant le SRP - chaque service injecté a une seule responsabilité, tout comme le contrôleur qui les utilise tous - comment un réfracteur serait-il ??!
LiverpoolsNumber9
1
@ LiverpoolsNumber9 La clé est de déplacer les fonctionnalités de BaseController vers les dépendances, jusqu'à ce qu'il ne reste plus rien dans BaseController, à l'exception protecteddes délégations unilignes vers les nouveaux composants. Ensuite, vous pouvez perdre BaseController et remplacer ces protectedméthodes par des appels directs aux dépendances - cela simplifiera votre modèle et rendra toutes vos dépendances explicites (ce qui est une très bonne chose dans un projet avec des centaines de contrôleurs!)
AlexFoxGill
@ LiverpoolsNumber9 - si vous pouviez copier une partie de votre BaseController dans une boîte à pâte, je pourrais faire des suggestions concrètes
AlexFoxGill
-2

J'ajoute une réponse à cela sur la base des contributions de tous les autres. Merci beaucoup à tous. Voici d'abord ma réponse: "Non, il n'y a rien de mal à cela".

Réponse de Doc Brown "Service Facade" J'ai accepté cette réponse parce que ce que je cherchais (si la réponse était "non") était quelques exemples ou une extension de ce que je faisais. Il a fourni ceci en suggérant que A) il a un nom, et B) il y a probablement de meilleures façons de le faire.

Réponse de Benjamin Hodgson «Service Locator» Autant que j'apprécie les connaissances que j'ai acquises ici, ce que j'ai n'est pas un «Service Locator». Il s'agit d'une "façade de service". Tout dans cette réponse est correct mais pas pour mes circonstances.

Réponses USR

Je vais aborder cela plus en détail:

Vous abandonnez beaucoup d'informations statiques de cette façon. Vous reportez les décisions à l'exécution comme le font de nombreux langages dynamiques. De cette façon, vous perdez la vérification statique (sécurité), la documentation et le support d'outillage (auto-complétion, refactorings, recherche d'usages, flux de données).

Je ne perds aucun outillage et je ne perds pas de frappe "statique". La façade de service retournera ce que j'ai configuré dans mon conteneur DI, ou default(T). Et ce qu'il retourne est "tapé". La réflexion est encapsulée.

Je ne vois pas pourquoi l'ajout de services supplémentaires comme arguments du constructeur est un gros fardeau.

Ce n'est certainement pas "rare". Comme j'utilise un contrôleur de base , chaque fois que je dois changer de constructeur, je devrai peut-être changer 10, 100, 1000 autres contrôleurs.

Si vous utilisez un framework d'injection de dépendances, vous n'aurez même pas à passer manuellement les valeurs des paramètres. Là encore, vous perdez certains avantages statiques, mais pas autant.

J'utilise l'injection de dépendance. C'est le but.

Et enfin, le commentaire de Jules sur la réponse de Benjamin, je ne perds aucune flexibilité. C'est ma façade de service. Je peux ajouter autant de paramètres GetService<T>que je veux pour distinguer les différentes implémentations, tout comme on le ferait lors de la configuration d'un conteneur DI. Ainsi, par exemple, je pourrais changer GetService<T>()pour GetService<T>(string extraInfo = null)contourner ce "problème potentiel".


Quoi qu'il en soit, merci encore à tout le monde. Ça a été vraiment utile. À votre santé.

LiverpoolsNumber9
la source
4
Je ne suis pas d'accord que vous ayez une façade de service ici. La GetService<T>()méthode est capable de (tenter de) résoudre des dépendances arbitraires . Cela en fait un localisateur de services, pas une façade de service, comme je l'ai expliqué dans la note de bas de page de ma réponse. Si vous deviez le remplacer par un petit ensemble de méthodes GetServiceX()/ GetServiceY(), comme le suggère @DocBrown, ce serait une façade.
Benjamin Hodgson
2
Vous devez lire et porter une attention particulière à la dernière section de cet article sur le localisateur de service abstrait , qui est essentiellement ce que vous faites. J'ai vu cet anti-modèle corrompre un projet entier - portez une attention particulière à la citation "cela vous rendra la vie en tant que développeur de maintenance pire car vous devrez utiliser des quantités considérables de puissance cérébrale pour comprendre les implications de chaque changement que vous apportez. "
AlexFoxGill
3
Sur la frappe statique - vous manquez le point. Si j'essaie d'écrire du code qui ne fournit pas une classe utilisant DI avec un paramètre constructeur dont il a besoin, il ne sera pas compilé. C'est une sécurité statique à la compilation contre les problèmes. Si vous écrivez votre code, en fournissant à votre constructeur un IContext qui n'a pas été correctement configuré pour fournir l'argument dont il a réellement besoin, alors il se compilera et échouera au moment de l'exécution
Ben Aaronson
3
@ LiverpoolsNumber9 Eh bien, alors pourquoi avoir une langue fortement typée? Les erreurs de compilation sont une première ligne de défense contre les bogues. Les tests unitaires sont le deuxième. Le vôtre ne serait pas non plus détecté par les tests unitaires, car il s'agit d'une interaction entre une classe et sa dépendance, vous seriez donc dans la troisième ligne de défense: les tests d'intégration. Je ne sais pas à quelle fréquence vous les exécutez, mais vous parlez maintenant de commentaires de l'ordre de quelques minutes ou plus, plutôt que des millisecondes qu'il faut à votre IDE pour souligner une erreur de compilation.
Ben Aaronson
2
@ LiverpoolsNumber9 erroné: vos tests doivent maintenant remplir IContextet injecter cela, et surtout: si vous ajoutez une nouvelle dépendance, le code sera toujours compilé - même si les tests échoueront au moment de l'exécution
AlexFoxGill