Questionner l'un des arguments pour les frameworks d'injection de dépendances: Pourquoi la création d'un graphe d'objets est-elle difficile?

13

Les frameworks d'injection de dépendances comme Google Guice donnent la motivation suivante pour leur utilisation ( source ):

Pour construire un objet, vous construisez d'abord ses dépendances. Mais pour construire chaque dépendance, vous avez besoin de ses dépendances, etc. Ainsi, lorsque vous créez un objet, vous devez vraiment créer un graphique d'objet.

Construire des graphiques d'objets à la main demande beaucoup de travail (...) et rend les tests difficiles.

Mais je n'achète pas cet argument: même sans framework d'injection de dépendances, je peux écrire des classes qui sont à la fois faciles à instancier et pratiques à tester. Par exemple, l'exemple de la page de motivation de Guice pourrait être réécrit de la manière suivante:

class BillingService
{
    private final CreditCardProcessor processor;
    private final TransactionLog transactionLog;

    // constructor for tests, taking all collaborators as parameters
    BillingService(CreditCardProcessor processor, TransactionLog transactionLog)
    {
        this.processor = processor;
        this.transactionLog = transactionLog;
    }

    // constructor for production, calling the (productive) constructors of the collaborators
    public BillingService()
    {
        this(new PaypalCreditCardProcessor(), new DatabaseTransactionLog());
    }

    public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard)
    {
        ...
    }
}

Il peut donc y avoir d'autres arguments pour les cadres d'injection de dépendances ( qui sont hors de portée pour cette question !), Mais la création facile de graphiques d'objets testables n'en fait pas partie, n'est-ce pas?

oberlies
la source
1
Je pense qu'un autre avantage de l'injection de dépendances qui est souvent ignoré est qu'il vous oblige à savoir de quoi dépendent les objets . Rien n'apparaît comme par magie dans les objets, vous injectez soit des attributs explicitement marqués, soit directement via le constructeur. Sans oublier comment cela rend votre code plus testable.
Benjamin Gruenbaum
Notez que je ne cherche pas d'autres avantages de l'injection de dépendance - je ne suis intéressé que par comprendre l'argument selon lequel «l'instanciation d'objet est difficile sans injection de dépendance»
oberlies
Cette réponse semble fournir un très bon exemple pour expliquer pourquoi vous voudriez une sorte de conteneur pour vos graphiques d'objets (en bref, vous ne pensez pas assez grand, en utilisant seulement 3 objets).
Izkata
@Izkata Non, ce n'est pas une question de taille. Avec l'approche décrite, le code de ce message serait juste new ShippingService().
oberlies
@oberlies l'est toujours; vous devrez ensuite fouiller dans 9 définitions de classe pour déterminer quelles classes sont utilisées pour ce service d'expédition, plutôt qu'un seul emplacement
Izkata

Réponses:

12

Il y a un vieux, vieux débat en cours sur la meilleure façon de faire l'injection de dépendance.

  • La coupe originale de Spring a instancié un objet simple, puis injecté des dépendances via des méthodes de définition.

  • Mais alors, une grande contingence de personnes a insisté sur le fait que l'injection de dépendances via les paramètres du constructeur était la bonne façon de le faire.

  • Puis, ces derniers temps, alors que l'utilisation de la réflexion est devenue plus courante, la fixation directe des valeurs des membres privés, sans arguments ni constructeurs, est devenue à la mode.

Votre premier constructeur est donc cohérent avec la deuxième approche de l'injection de dépendances. Il vous permet de faire de belles choses comme injecter des simulations pour les tests.

Mais le constructeur sans argument a ce problème. Puisqu'il instancie les classes d' implémentation pour PaypalCreditCardProcessoret DatabaseTransactionLog, il crée une dépendance difficile et à la compilation avec PayPal et la base de données. Il prend la responsabilité de construire et de configurer correctement tout cet arbre de dépendances.

  • Imaginez que le processeur PayPay est un sous-système vraiment compliqué et tire en outre de nombreuses bibliothèques de support. En créant une dépendance au moment de la compilation sur cette classe d'implémentation, vous créez un lien incassable vers cet arbre de dépendance entier. La complexité de votre graphique d'objet vient de bondir d'un ordre de grandeur, peut-être deux.

  • Un grand nombre de ces éléments dans l'arborescence des dépendances seront transparents, mais beaucoup d'entre eux devront également être instanciés. Il y a de fortes chances que vous ne puissiez pas simplement instancier a PaypalCreditCardProcessor.

  • En plus de l'instanciation, chacun des objets aura besoin de propriétés appliquées à partir de la configuration.

Si vous n'avez qu'une dépendance sur l'interface, et autorisez une fabrique externe à construire et à injecter la dépendance, ils vous coupent tout l'arborescence des dépendances PayPal, et la complexité de votre code s'arrête à l'interface.

Il y a d'autres avantages, comme la spécification des classes d'implémentation dans la configuration (c'est-à-dire au moment de l'exécution plutôt que lors de la compilation), ou la spécification de dépendances plus dynamique qui varie, par exemple, selon l'environnement (test, intégration, production).

Par exemple, disons que PayPalProcessor avait 3 objets dépendants, et chacune de ces dépendances en avait deux autres. Et tous ces objets doivent extraire les propriétés de la configuration. Le code en l'état assumerait la responsabilité de construire tout cela, de définir les propriétés à partir de la configuration, etc. etc. - toutes les préoccupations dont le cadre DI prendra soin.

Il peut ne pas sembler évident au premier abord de quoi vous vous protégez en utilisant un cadre DI, mais il s'additionne et devient douloureusement évident au fil du temps. (lol je parle de l'expérience d'avoir essayé de le faire à la dure)

...

Dans la pratique, même pour un programme vraiment minuscule, je trouve que je finis par écrire dans un style DI, et divise les classes en paires implémentation / usine. Autrement dit, si je n'utilise pas un framework DI comme Spring, je jette juste ensemble quelques classes d'usine simples.

Cela permet de séparer les préoccupations afin que ma classe puisse tout faire, et la classe d'usine assume la responsabilité de construire et de configurer les choses.

Pas une approche requise, mais FWIW

...

Plus généralement, le modèle DI / interface réduit la complexité de votre code en faisant deux choses:

  • abstraction des dépendances en aval dans les interfaces

  • «lever» les dépendances en amont hors de votre code et dans une sorte de conteneur

En plus de cela, puisque l'instanciation et la configuration d'objets sont une tâche assez familière, le framework DI peut réaliser de nombreuses économies d'échelle grâce à une notation standardisée et à des astuces comme la réflexion. La diffusion de ces mêmes préoccupations autour des cours finit par ajouter beaucoup plus d'encombrement qu'on ne le pense.

Rob
la source
Le code assumerait la responsabilité de construire tout cela - Une classe ne prend que la responsabilité de savoir quelles sont les implémentations de ses collaborateurs. Il n'a pas besoin de savoir à son tour ce dont ces collaborateurs ont besoin. Ce n'est donc pas tant que ça.
oberlies
1
Mais si le constructeur PayPalProcessor avait 2 arguments, d'où viendraient-ils?
Rob
Ce n'est pas le cas. Tout comme BillingService, PayPalProcessor possède un constructeur à zéro argument qui crée lui-même les collaborateurs dont il a besoin.
oberlies
en faisant abstraction des dépendances en aval dans les interfaces - l'exemple de code le fait également Le champ processeur est de type CreditCardProcessor et non de type d'implémentation.
oberlies
2
@oberlies: votre exemple de code est à cheval entre deux styles, le style d'argument constructeur et le style constructible zéro argument. L'erreur est que constructible à zéro argument n'est pas toujours possible. La beauté de DI ou Factory est qu'ils permettent en quelque sorte aux objets d'être construits avec ce qui ressemble à un constructeur zéro-args.
rwong
4
  1. Lorsque vous nagez à l'extrémité peu profonde de la piscine, tout est "facile et pratique". Une fois passé une dizaine d'objets, ce n'est plus pratique.
  2. Dans votre exemple, vous avez lié votre processus de facturation pour toujours et un jour à PayPal. Supposons que vous souhaitiez utiliser un autre processeur de carte de crédit? Supposons que vous souhaitiez créer un processeur de carte de crédit spécialisé contraint sur le réseau? Ou vous devez tester la gestion des numéros de carte de crédit? Vous avez créé du code non portable: "écrivez une fois, utilisez-le une seule fois car cela dépend du graphe d'objet spécifique pour lequel il a été conçu."

En liant votre graphe d'objet au début du processus, c'est-à-dire en le câblant dans le code, vous avez besoin que le contrat et l'implémentation soient présents. Si quelqu'un d'autre (peut-être même vous) veut utiliser ce code dans un but légèrement différent, il doit recalculer le graphique d'objet entier et le réimplémenter.

Les frameworks DI vous permettent de prendre un tas de composants et de les connecter ensemble lors de l'exécution. Cela rend le système "modulaire", composé d'un certain nombre de modules qui fonctionnent sur les interfaces des uns et des autres plutôt que sur les implémentations des uns et des autres.

BobDalgleish
la source
Quand je suis votre argument, le raisonnement pour DI aurait dû être "créer un graphique objet à la main est facile, mais conduit à du code qu'il est difficile à maintenir". Cependant, l'affirmation était que "créer un graphique d'objet à la main est difficile" et je ne cherche que des arguments à l'appui de cette affirmation. Votre message ne répond donc pas à ma question.
oberlies
1
Je suppose que si vous réduisez la question à "créer un graphique d'objet ...", alors c'est simple. Mon point est que DI résout le problème que vous ne traitez jamais avec un seul graphique d'objet; vous traitez avec une famille d'entre eux.
BobDalgleish
En fait, la création d'un seul graphique d'objet peut déjà être délicate si les collaborateurs sont partagés .
oberlies
4

L'approche «instancier mes propres collaborateurs» peut fonctionner pour les arbres de dépendance , mais elle ne fonctionnera certainement pas bien pour les graphiques de dépendance qui sont des graphiques acycliques dirigés généraux (DAG). Dans un DAG de dépendance, plusieurs nœuds peuvent pointer vers le même nœud, ce qui signifie que deux objets utilisent le même objet comme collaborateur. Ce cas ne peut en effet pas être construit avec l'approche décrite dans la question.

Si certains de mes collaborateurs (ou collaborateurs du collaborateur) devaient partager un certain objet, je devrais instancier cet objet et le transmettre à mes collaborateurs. Donc, en fait, j'aurais besoin d'en savoir plus que mes collaborateurs directs, et cela n'est évidemment pas à l'échelle.

oberlies
la source
1
Mais l'arbre de dépendance doit être un arbre, au sens de la théorie des graphes. Sinon, vous avez un cycle, ce qui est tout simplement insatisfaisant.
Xion
1
@Xion Le graphe de dépendances doit être un graphe acyclique dirigé. Il n'est pas nécessaire qu'il n'y ait qu'un seul chemin entre deux nœuds comme dans les arbres.
oberlies
@Xion: Pas nécessairement. Considérez un UnitOfWorkqui a un DbContextréférentiel unique et multiple. Tous ces référentiels doivent utiliser le même DbContextobjet. Avec «l'auto-instanciation» proposée par OP, cela devient impossible à faire.
Flater
1

Je n'ai pas utilisé Google Guice, mais j'ai pris beaucoup de temps à migrer d'anciennes applications N-tier héritées dans .Net vers des architectures IoC comme Onion Layer qui dépendent de l'injection de dépendance pour découpler les choses.

Pourquoi l'injection de dépendance?

Le but de l'injection de dépendance n'est pas réellement pour la testabilité, c'est en fait de prendre des applications étroitement couplées et de desserrer le couplage autant que possible. (Ce qui a pour sous-produit souhaitable de rendre votre code beaucoup plus facile à adapter pour des tests unitaires appropriés)

Pourquoi devrais-je m'inquiéter du couplage?

Le couplage ou les dépendances étroites peuvent être une chose très dangereuse. (Surtout dans les langues compilées) Dans ces cas, vous pourriez avoir une bibliothèque, une DLL, etc. qui est très rarement utilisée et qui a un problème qui met efficacement l'application entière hors ligne. (Votre application entière meurt parce qu'une pièce sans importance a un problème ... c'est mauvais ... VRAIMENT mauvais) Maintenant, lorsque vous dissociez des éléments, vous pouvez réellement configurer votre application afin qu'elle puisse s'exécuter même si cette DLL ou cette bibliothèque est entièrement manquante! Bien sûr, une seule pièce qui a besoin de cette bibliothèque ou DLL ne fonctionnera pas, mais le reste de l'application est aussi heureux que possible.

Pourquoi ai-je besoin d'une injection de dépendance pour effectuer des tests appropriés

Vraiment, vous voulez juste du code à couplage lâche, l'injection de dépendances le permet. Vous pouvez coupler librement des choses sans IoC, mais généralement c'est plus de travail et moins adaptable (je suis sûr que quelqu'un a une exception)

Dans le cas que vous avez donné, je pense qu'il serait beaucoup plus facile de simplement configurer l'injection de dépendance afin que je puisse me moquer du code que je ne suis pas intéressé à compter dans le cadre de ce test. Dites-nous simplement la méthode "Hé, je sais que je vous ai dit d'appeler le référentiel, mais à la place, voici les données qu'il" devrait "renvoyer des clins d'œil " maintenant parce que ces données ne changent jamais, vous savez que vous testez uniquement la partie qui utilise ces données, pas la récupération effective des données.

Fondamentalement, lorsque vous testez, vous voulez des tests d'intégration (fonctionnalité) qui testent un élément de fonctionnalité du début à la fin, et des tests unitaires complets qui testent chaque élément de code (généralement au niveau de la méthode ou de la fonction) indépendamment.

L'idée est que vous voulez vous assurer que toute la fonctionnalité fonctionne, sinon vous voulez connaître la partie exacte du code qui ne fonctionne pas.

Cette CAN se faire sans injection de dépendance, mais le plus souvent que votre projet se développe , il devient de plus en plus lourd à le faire sans injection de dépendances en place. (TOUJOURS supposer que votre projet va grandir! Il vaut mieux avoir une pratique inutile de compétences utiles que de trouver un projet qui monte rapidement et nécessite une refactorisation et une réingénierie sérieuses après que les choses décollent déjà.)

RualStorge
la source
1
L'écriture de tests utilisant une grande partie mais pas la totalité du graphe de dépendances est en fait un bon argument pour DI.
oberlies
1
Il est également intéressant de noter qu'avec DI, vous pouvez facilement remplacer par programme des morceaux entiers de votre code. J'ai vu des cas où un système effacerait et réinjecterait une interface avec une classe entièrement différente pour réagir aux pannes et aux problèmes de performances des services distants. Il y avait peut-être une meilleure façon de le gérer, mais cela a étonnamment bien fonctionné.
RualStorge
0

Comme je le mentionne dans une autre réponse , le problème ici est que vous voulez que la classe Adépende d' une classeB sans coder en dur quelle classe Best utilisée dans le code source de A. C'est impossible en Java et en C # car la seule façon d'importer une classe est de s'y référer par un nom unique au monde.

En utilisant une interface, vous pouvez contourner la dépendance de classe codée en dur, mais vous devez toujours mettre la main sur une instance de l'interface, et vous ne pouvez pas appeler de constructeurs ou vous êtes de retour au carré 1. Alors maintenant, codez qui pourrait autrement créer ses dépendances repousse cette responsabilité envers quelqu'un d'autre. Et ses dépendances font la même chose. Alors maintenant, chaque fois que vous avez besoin d'une instance d'une classe, vous finissez par construire manuellement l'arborescence de dépendances entière, alors que dans le cas où la classe A dépend directement de B, vous pouvez simplement appeler new A()et avoir cet appel constructeur new B(), et ainsi de suite.

Un framework d'injection de dépendances tente de contourner cela en vous permettant de spécifier les mappages entre les classes et de construire l'arborescence de dépendances pour vous. Le problème est que lorsque vous bousiller les mappages, vous découvrirez au moment de l'exécution, pas au moment de la compilation comme vous le feriez dans les langages qui prennent en charge les modules de mappage en tant que concept de première classe.

Doval
la source
0

Je pense que c'est un gros malentendu ici.

Guice est un framework d' injection de dépendances . Cela rend DI automatique . Le point qu'ils ont soulevé dans l'extrait que vous avez cité concerne le fait que Guice puisse supprimer le besoin de créer manuellement le "constructeur testable" que vous avez présenté dans votre exemple. Cela n'a absolument rien à voir avec l'injection de dépendance elle-même.

Ce constructeur:

BillingService(CreditCardProcessor processor, TransactionLog transactionLog)
{
    this.processor = processor;
    this.transactionLog = transactionLog;
}

utilise déjà l'injection de dépendance. Vous venez de dire que l'utilisation de la DI est facile.

Le problème que Guice résout est que pour utiliser ce constructeur, vous devez maintenant avoir un code de constructeur de graphique d'objet quelque part , en passant manuellement les objets déjà instanciés comme arguments de ce constructeur. Guice vous permet d'avoir un seul endroit où vous pouvez configurer quelles classes d'implémentation réelles correspondent à celles-ci CreditCardProcessoret aux TransactionLoginterfaces. Après cette configuration, chaque fois que vous créez à l' BillingServiceaide de Guice, ces classes sont transmises automatiquement au constructeur.

C'est ce que fait le cadre d' injection de dépendance . Mais le constructeur lui-même que vous avez présenté est déjà une implémentation du principe d' injection de dépendance . Les conteneurs IoC et les frameworks DI sont des moyens d'automatiser les principes correspondants mais rien ne vous empêche de tout faire à la main, c'était tout.

hijarien
la source