Métaphysique de l'injection de dépendance

image


L'injection de dépendance est une technique couramment utilisée dans la programmation orientée objet conçue pour réduire la connectivité des composants. Lorsqu'il est utilisé correctement, en plus d'atteindre cet objectif, il peut apporter des qualités vraiment magiques à vos applications. Comme toute magie, cette technique est perçue comme un ensemble de sorts et non comme un traité scientifique rigoureux. Cela conduit à une mauvaise interprétation des phénomènes et, par conséquent, à une mauvaise utilisation des artefacts. Dans mon matériel d'auteur, je suggère que le lecteur étape par étape, brièvement et essentiellement, emprunte le chemin logique des fondements appropriés de la conception orientée objet à la magie même de l'injection automatique de dépendances.

Le matériel est basé sur le développement du conteneur Hypo IoC , que j'ai mentionné dans un article précédent . Dans des exemples de code miniatures, j'utiliserai Ruby comme l'un des langages orientés objet les plus concis pour écrire de courts exemples. Cela ne devrait pas poser de problèmes aux développeurs dans d'autres langues.

Niveau 1: Principe d'inversion de dépendance


Les développeurs du paradigme orienté objet sont quotidiennement confrontés à la création d'objets qui, à leur tour, peuvent dépendre d'autres objets. Cela conduit à un graphique de dépendance. Supposons que nous ayons affaire à un modèle objet de la forme:
image

- un service de facturation (InvoiceProcessor) et un service de notification (NotificationService). Le service de traitement des factures envoie des notifications lorsque certaines conditions sont remplies, nous sortirons cette logique du champ d'application. En principe, ce modèle est déjà bon en ce sens que les composants individuels sont responsables de différentes responsabilités. Le problème réside dans la façon dont nous implémentons ces dépendances. Une erreur courante consiste à initialiser une dépendance où cette dépendance est utilisée:

class InvoiceProcessor def process(invoice) #      notificationService = NotificationService.new notificationService.notify(invoice.owner) end end 

C'est une erreur compte tenu du fait que nous obtenons une connectivité élevée d'objets logiquement indépendants (High Coupling). Cela conduit à une violation du principe de responsabilité unique - un objet dépendant, en plus de ses responsabilités immédiates, doit initialiser ses dépendances; et aussi «connaître» l'interface du constructeur de dépendances, ce qui conduira à une raison supplémentaire de changement ( «raison de changer», R. Martin ). Il est plus correct de passer ce type de dépendance, initialisé en dehors de l'objet dépendant:

 class InvoiceProcessor def initialize(notificationService) @notificationService = notificationService end def process(invoice) @notificationService.notify(invoice.owner) end end notificationService = NotificationService.new invoiceProcessor = InvoiceProcessor.new(notificationService) 

Cette approche est conforme au principe d'inversion de dépendance. Nous transférons maintenant un objet avec une interface d'envoi de messages - il n'est plus nécessaire que le service de facturation «sache» comment construire l'objet de service de notification. Lors de l'écriture de tests unitaires pour un service de traitement des factures, le développeur n'a pas à se demander comment remplacer l'implémentation de l'interface du service de notification par un talon. Dans les langues avec typage dynamique, telles que Ruby, vous pouvez remplacer tout objet répondant à la méthode de notification; avec le typage statique, tel que C # / Java, vous pouvez utiliser l'interface INotificationService, pour laquelle il est facile de créer un Mock. La question de l'inversion de dépendance a été révélée en détail par Alexander Byndyu dans un article qui a récemment célébré son 10e anniversaire!

Niveau 2: registre d'objets liés


L'utilisation du principe de l'inversion de dépendance ne semble pas une pratique compliquée. Mais au fil du temps, en raison d'une augmentation du nombre d'objets et de relations, de nouveaux défis apparaissent. NotificationService peut être utilisé par des services autres que InvoiceProcessor. En outre, il peut lui-même dépendre d'autres services qui, à leur tour, dépendent de tiers, etc. De plus, certains composants peuvent ne pas toujours être utilisés en une seule copie. La tâche principale est de trouver la réponse à la question - «quand créer des dépendances?».
Pour résoudre ce problème, vous pouvez essayer de créer une solution basée sur un tableau associatif de dépendances. Un exemple d'interface de son travail pourrait ressembler à ceci:

 registry.add(InvoiceProcessor) .depends_on(NotificationService) registry.add(NotificationService) .depends_on(ServiceX) invoiceProcessor = registry.resolve(InvoiceProcessor) invoiceProcessor.process(invoice) 

Il n'est pas difficile à mettre en œuvre dans la pratique:

image

Chaque fois que container.resolve () est appelé, nous nous tournons vers la fabrique, qui créera des instances de dépendance, contournant récursivement le graphe de dépendance décrit dans le registre. Dans le cas de `container.resolve (InvoiceProcessor)`, ce qui suit sera exécuté:

  1. factory.resolve (InvoiceProcessor) - l'usine demande les dépendances InvoiceProcessor dans le registre, reçoit un NotificationService, qui doit également être assemblé.
  2. factory.resolve (NotificationService) - l'usine demande les dépendances NotificationService dans le registre, reçoit ServiceX, qui doit également être assemblé.
  3. factory.resolve (ServiceX) - n'a pas de dépendances, créez, retournez sur la pile d'appels à l'étape 1, obtenez un objet assemblé de type InvoiceProcessor.

Chaque composant peut dépendre de plusieurs autres, donc la question évidente est "comment faire correspondre correctement les paramètres du concepteur avec les instances de dépendance résultantes?". Un exemple:

 class InvoiceProcessor def initialize(notificationService, paymentService) # ... end end 

Dans les langues avec typage statique, le type de paramètre peut servir de sélecteur:

 class InvoiceProcessor { constructor(notificationService: NotificationService, paymentService: PaymentService) { // ... } } 

Dans Ruby, vous pouvez utiliser la convention - utilisez simplement le nom du type au format snake_case, ce sera le nom du paramètre attendu.

Niveau 3: gestion de la durée de vie des dépendances


Nous avons déjà une bonne solution de gestion des dépendances. Sa seule limitation est la nécessité de créer une nouvelle instance de la dépendance à chaque appel. Mais que faire si nous ne pouvons pas créer plus d'une instance d'un composant? Par exemple, un pool de connexions à la base de données. Creusez plus profondément et si nous devons fournir une durée de vie contrôlée des dépendances? Par exemple, fermez la connexion à la base de données une fois la requête HTTP terminée.
Il devient évident que le candidat au remplacement dans la solution d'origine est InstanceFactory. Graphique mis à jour:

image

Et la solution logique est d'utiliser un ensemble de stratégies ( Stratégie, GoF ) pour obtenir des instances de composants. Maintenant, nous ne créons pas toujours de nouvelles instances lorsque nous appelons Container :: resolver, il est donc approprié de renommer Factory en Resolver. Veuillez noter que la méthode Container :: register a un nouveau paramètre - life_time (lifetime). Ce paramètre est facultatif - par défaut, sa valeur est «transitoire» (transitoire), ce qui correspond au comportement précédemment implémenté. La stratégie de singleton est également évidente - avec son utilisation, une seule instance du composant est créée, qui sera renvoyée à chaque fois.
La portée est une stratégie légèrement plus complexe. Au lieu de «chemins transitoires» et de «solitaires», il est souvent nécessaire d'utiliser quelque chose entre les deux - un composant qui existe tout au long de la vie d'un autre composant. Un exemple similaire peut être un objet de demande d'application Web, qui est le contexte de l'existence d'objets tels que, par exemple, les paramètres HTTP, la connexion à la base de données, les agrégats de modèle. Tout au long de la durée de vie de la demande, nous collectons et utilisons ces dépendances, et après sa destruction, nous nous attendons à ce que toutes soient également détruites. Pour implémenter une telle fonctionnalité, il sera nécessaire de développer une structure d'objet assez complexe et fermée:

image

Le diagramme montre un fragment reflétant les changements dans les classes Component et LifetimeStrategy dans le contexte de l'implémentation de la durée de vie Scoped. Le résultat a été une sorte de «double pont» (similaire au Bridge, modèle GoF ). En utilisant les subtilités des techniques d'héritage et d'agrégation, Component devient le cœur du conteneur. Soit dit en passant, le diagramme a un héritage multiple. Là où le langage de programmation et la conscience le permettent, vous pouvez le laisser ainsi. Dans Ruby, j'utilise des impuretés, dans d'autres langues, vous pouvez remplacer l'héritage par un autre pont:
image

Le diagramme de séquence montre le cycle de vie du composant de session, qui est lié à la durée de vie du composant de demande:

image

Comme vous pouvez le voir sur le diagramme, à un certain moment, lorsque le composant de demande termine sa mission, la méthode de libération est appelée, ce qui lance le processus de destruction de la portée.

Niveau 4: Injection de dépendance


Jusqu'à présent, j'ai expliqué comment déterminer le registre des dépendances, puis comment créer et détruire des composants conformément au graphique des relations formées. Et à quoi ça sert? Supposons que nous l'utilisions dans le cadre de Ruby on Rails:

 class InvoiceController < ApplicationController def pay(params) invoice_repository = registry.resolve(InvoiceRepository) invoice_processor = registry.resolve(InvoiceProcessor) invoice = invoice_repository.find(params[:id]) invoice_processor.pay(invoice) end end 

Le code qui sera écrit de cette façon ne sera pas plus lisible, testable ou flexible. Nous ne pouvons pas «forcer» Rails à injecter des dépendances de contrôleur via son constructeur, ce qui n'est pas fourni par le framework. Mais, par exemple, dans ASP.NET MVC, cela est implémenté au niveau de base. Pour tirer le meilleur parti de l'utilisation du mécanisme de résolution automatique des dépendances, vous devez implémenter la technique d'inversion de contrôle (IoC, inversion de contrôle). Il s'agit d'une approche dans laquelle la responsabilité de la résolution des dépendances dépasse la portée du code d'application et incombe au cadre. Prenons un exemple.
Imaginez que nous concevons quelque chose comme Rails à partir de zéro. Nous implémentons le schéma suivant:

image

L'application reçoit la demande, le routeur récupère les paramètres et demande au contrôleur approprié de traiter cette demande. Un tel schéma copie conditionnellement le comportement d'un framework web typique avec seulement une petite différence - le conteneur IoC est impliqué dans la création et l'implémentation de dépendances. Mais ici, la question se pose, où est le conteneur lui-même créé? Afin de couvrir autant d'objets de la future application que possible, notre structure doit créer un conteneur au tout début de son fonctionnement. De toute évidence, il n'y a pas de lieu plus approprié que l'application App Builder. C'est également l'endroit le plus approprié pour configurer toutes les dépendances:

 class App #   - ,      . def initialize @container = Container.new @container .register(Controller) .using_lifetime(:transient) # ,     @container .register(InvoiceService) .using_lifetime(:singleton) # ,     @container .register(Router) .using_lifetime(:singleton) #  end #     -     , #      . def call(env) router = @container.resolve(Router) router.handle(env.path, env.method, env.params) end end 

Toute application possède un point d'entrée, par exemple, la méthode principale. Dans cet exemple, le point d'entrée est la méthode d'appel. L'objectif de cette méthode est d'appeler le routeur pour traiter les requêtes entrantes. Le point d'entrée devrait être le seul endroit pour appeler directement le conteneur - à partir de ce moment, le conteneur devrait passer par le bord du chemin, toute magie subséquente devrait se produire «sous le capot». L'implémentation du contrôleur dans une telle architecture semble vraiment inhabituelle. Malgré le fait que nous ne l'instancions pas explicitement, il a un constructeur avec des paramètres:

 class Controller #   . #    . def initialize(invoice_service) @invoice_service = invoice_service end def create_invoice(params) @invoice_service.create(params) end end 

L'environnement "comprend" comment créer des instances de contrôleur. Cela est possible grâce au mécanisme d'injection de dépendances fourni par le conteneur IoC intégré au cœur de l'application Web. Dans le constructeur du contrôleur, vous pouvez désormais répertorier tout ce qui est nécessaire à son fonctionnement. L'essentiel est que les composants correspondants soient enregistrés dans le conteneur. Passons maintenant à l'implémentation du routeur:

 class Router #         -  #      #     . def initialize(controller) @controller = controller end def handle(path, method, params) #  ""- if path == '/invoices' && method == 'POST' @controller.create(params) end end end 

Notez que le routeur dépend du contrôleur. Si nous rappelons les paramètres de dépendance, alors Controller est un composant de courte durée et Router est un solitaire constant. Comment est-ce possible? La réponse est que les composants ne sont pas des instances des classes correspondantes, car ils semblent externes. En fait, ce sont des objets proxy ( Proxy, GoF ) avec l'instance de méthode d'usine ( Factory Method, GoF ); ils retournent une instance du composant conformément à la stratégie assignée. Étant donné que le contrôleur est enregistré comme "transitoire", le routeur traitera toujours sa nouvelle instance lors de son accès. Le diagramme de séquence montre un mécanisme approximatif de travail:

image

C'est-à-dire En plus de la gestion des dépendances, un bon framework basé sur un conteneur IoC prend également la responsabilité de la bonne gestion des durées de vie des composants.

Conclusion


La technique d'injection de dépendance peut avoir une implémentation interne assez sophistiquée. C'est le prix du transfert de la complexité de la mise en œuvre d'applications flexibles au cœur du cadre. L'utilisateur de tels frameworks ne peut pas se soucier des aspects purement techniques, mais consacrer plus de temps au développement confortable de la logique métier des programmes d'application. À l'aide d'une implémentation DI de haute qualité, un programmeur d'application écrit initialement du code testable et bien pris en charge. Un bon exemple de la mise en œuvre de l'injection de dépendance est le cadre Dandy décrit dans mon article précédent Orthodox Backend .

Source: https://habr.com/ru/post/fr480364/


All Articles