Metafísica de la inyección de dependencia

imagen


La inyección de dependencia es una técnica de uso común en la programación orientada a objetos diseñada para reducir la conectividad de componentes. Cuando se usa correctamente, además de lograr este objetivo, puede aportar cualidades verdaderamente mágicas a sus aplicaciones. Como cualquier magia, esta técnica se percibe como un conjunto de hechizos, y no como un tratado científico riguroso. Esto lleva a una mala interpretación de los fenómenos y, como consecuencia, al mal uso de los artefactos. En el material de mi autor, sugiero que el lector paso a paso, breve y esencialmente, siga el camino lógico desde los fundamentos apropiados del diseño orientado a objetos hasta la magia de la inyección automática de dependencia.

El material se basa en el desarrollo del contenedor Hypo IoC , que mencioné en un artículo anterior . En los ejemplos de código en miniatura, usaré Ruby como uno de los lenguajes orientados a objetos más concisos para escribir ejemplos cortos. Esto no debería causar problemas para que los desarrolladores en otros idiomas lo entiendan.

Nivel 1: Principio de inversión de dependencia


Los desarrolladores en el paradigma orientado a objetos se enfrentan a diario con la creación de objetos, que, a su vez, pueden depender de otros objetos. Esto lleva a un gráfico de dependencia. Supongamos que estamos tratando con un modelo de objetos de la forma:
imagen

- algún servicio de facturación (InvoiceProcessor) y un servicio de notificación (NotificationService). El servicio de procesamiento de facturas envía notificaciones cuando se cumplen ciertas condiciones, sacaremos esta lógica del alcance. En principio, este modelo ya es bueno porque los componentes individuales son responsables de diferentes responsabilidades. El problema radica en cómo implementamos estas dependencias. Un error común es inicializar una dependencia donde se usa esta dependencia:

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

Esto es un error en vista del hecho de que obtenemos una alta conectividad de objetos lógicamente independientes (High Coupling). Esto lleva a una violación del Principio de Responsabilidad Única: un objeto dependiente, además de sus responsabilidades inmediatas, debe inicializar sus dependencias; y también "conocer" la interfaz del constructor de dependencias, lo que dará lugar a una razón adicional para el cambio ( "razón para cambiar", R. Martin ). Es más correcto pasar este tipo de dependencia, inicializada fuera del objeto dependiente:

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

Este enfoque es consistente con el Principio de Inversión de Dependencia. Ahora estamos transfiriendo un objeto con una interfaz de envío de mensajes; ya no es necesario que el servicio de facturación "sepa" cómo construir el objeto del servicio de notificación. Al escribir pruebas unitarias para un servicio de procesamiento de facturas, el desarrollador no tiene que preguntarse cómo reemplazar la implementación de la interfaz del servicio de notificaciones con un código auxiliar. En idiomas con escritura dinámica, como Ruby, puede sustituir cualquier objeto que cumpla con el método de notificación; con la escritura estática, como C # / Java, puede usar la interfaz INotificationService, para lo cual es fácil crear un Mock. ¡El tema de la inversión de dependencia fue revelado en detalle por Alexander Byndyu en un artículo que recientemente celebró su décimo aniversario!

Nivel 2: registro de objetos relacionados


Usar el principio de inversión de dependencia no parece una práctica complicada. Pero con el tiempo, debido a un aumento en el número de objetos y relaciones, aparecen nuevos desafíos. NotificationService puede ser utilizado por otros servicios que no sean InvoiceProcessor. Además, él mismo puede depender de otros servicios, que, a su vez, dependen de terceros, etc. Además, algunos componentes no siempre se pueden usar en una sola copia. La tarea principal es encontrar la respuesta a la pregunta: "¿cuándo crear dependencias?".
Para resolver este problema, puede intentar crear una solución basada en una matriz asociativa de dependencias. Un ejemplo de interfaz de su trabajo podría verse así:

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

No es difícil de implementar en la práctica:

imagen

Cada vez que se llama a container.resolve (), nos dirigiremos a la fábrica, que creará instancias de dependencia, evitando recursivamente el gráfico de dependencia descrito en el registro. En el caso de `container.resolve (InvoiceProcessor)`, se ejecutará lo siguiente:

  1. factory.resolve (InvoiceProcessor): la fábrica solicita las dependencias de InvoiceProcessor en el registro, recibe un NotificationService, que también debe ensamblarse.
  2. factory.resolve (NotificationService): la fábrica solicita las dependencias de NotificationService en el registro, recibe ServiceX, que también debe ensamblarse.
  3. factory.resolve (ServiceX): no tiene dependencias, crea, regresa a lo largo de la pila de llamadas al paso 1, obtiene un objeto ensamblado de tipo InvoiceProcessor.

Cada componente puede depender de varios otros, por lo que la pregunta obvia es "¿cómo hacer coincidir correctamente los parámetros del diseñador con las instancias de dependencia resultantes?". Un ejemplo:

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

En idiomas con escritura estática, el tipo de parámetro puede servir como selector:

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

Dentro de Ruby, puede usar la convención: solo use el nombre del tipo en el formato snake_case, este será el nombre del parámetro esperado.

Nivel 3: gestión de dependencia de por vida


Ya tenemos una buena solución de gestión de dependencias. Su única limitación es la necesidad de crear una nueva instancia de la dependencia con cada llamada. Pero, ¿qué pasa si no podemos crear más de una instancia de un componente? Por ejemplo, un grupo de conexiones a la base de datos. ¿Profundizar más y si necesitamos proporcionar una vida controlada de dependencias? Por ejemplo, cierre la conexión a la base de datos después de completar la solicitud HTTP.
Se hace evidente que el candidato para el reemplazo en la solución original es InstanceFactory. Cuadro actualizado:

imagen

Y la solución lógica es utilizar un conjunto de estrategias ( Estrategia, GoF ) para obtener instancias de componentes. Ahora no siempre creamos nuevas instancias cuando llamamos a Container :: resolve, por lo que es apropiado cambiar el nombre de Factory a Resolver. Tenga en cuenta que el método Container :: register tiene un nuevo parámetro: life_time (curso de la vida). Este parámetro es opcional; de forma predeterminada, su valor es "transitorio" (transitorio), que corresponde al comportamiento implementado previamente. La estrategia singleton también es obvia: con su uso solo se crea una instancia del componente, que se devolverá cada vez.
El alcance es una estrategia un poco más compleja. En lugar de "caminos transitorios" y "solitarios", a menudo se requiere usar algo intermedio: un componente que existe a lo largo de la vida de otro componente. Un ejemplo similar puede ser un objeto de solicitud de aplicación web, que es el contexto de la existencia de objetos tales como, por ejemplo, parámetros HTTP, conexión de base de datos, agregados de modelos. A lo largo de la vida de la solicitud, recopilamos y usamos estas dependencias, y después de su destrucción, esperamos que todas ellas también sean destruidas. Para implementar dicha funcionalidad, será necesario desarrollar una estructura de objeto cerrada bastante compleja:

imagen

El diagrama muestra un fragmento que refleja los cambios en las clases Component y LifetimeStrategy en el contexto de la implementación de la vida útil de Scoped. El resultado fue una especie de "doble puente" (similar a la plantilla Bridge, GoF ). Utilizando las complejidades de las técnicas de herencia y agregación, Component se convierte en el núcleo del contenedor. Por cierto, el diagrama tiene herencia múltiple. Donde el lenguaje de programación y la conciencia lo permitan, puede dejarlo así. En Ruby utilizo impurezas, en otros idiomas puedes reemplazar la herencia con otro puente:
imagen

El diagrama de secuencia muestra el ciclo de vida del componente de sesión, que está vinculado a la vida útil del componente de solicitud:

imagen

Como puede ver en el diagrama, en un momento determinado, cuando el componente de solicitud completa su misión, se llama al método de liberación, que inicia el proceso de destrucción del alcance.

Nivel 4: Inyección de dependencia


Hasta ahora, hablé sobre cómo determinar el registro de dependencias, y luego cómo crear y destruir componentes de acuerdo con el gráfico de las relaciones formadas. ¿Y para qué sirve? Supongamos que usamos esto como parte 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 

El código que se escribirá de esta manera no será más legible, comprobable o flexible. No podemos "forzar" a Rails a inyectar dependencias de controlador a través de su constructor, esto no lo proporciona el marco. Pero, por ejemplo, en ASP.NET MVC esto se implementa en un nivel básico. Para aprovechar al máximo el mecanismo de resolución automática de dependencias, debe implementar la técnica de Inversión de control (IoC, inversión de control). Este es un enfoque en el que la responsabilidad de resolver dependencias va más allá del alcance del código de la aplicación y recae en el marco. Considera un ejemplo.
Imagine que estamos diseñando algo como Rails desde cero. Implementamos el siguiente esquema:

imagen

La aplicación recibe la solicitud, el enrutador recupera los parámetros e indica al controlador apropiado que procese esta solicitud. Tal esquema copia condicionalmente el comportamiento de un marco web típico con solo una pequeña diferencia: el contenedor de IoC está involucrado en la creación e implementación de dependencias. Pero aquí surge la pregunta, ¿dónde se crea el contenedor? Para cubrir tantos objetos de la aplicación futura como sea posible, nuestro marco debe crear un contenedor en la etapa inicial de su operación. Obviamente, no hay un lugar más adecuado que la aplicación de creación de aplicaciones. También es el lugar más adecuado para configurar todas las dependencias:

 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 

Cualquier aplicación tiene un punto de entrada, por ejemplo, el método principal. En este ejemplo, el punto de entrada es el método de llamada. El objetivo de este método es llamar al enrutador para procesar las solicitudes entrantes. El punto de entrada debe ser el único lugar para llamar al contenedor directamente; desde ese momento, el contenedor debe dejarse de lado, toda la magia posterior debe ocurrir "debajo del capó". La implementación del controlador dentro de dicha arquitectura realmente parece inusual. A pesar de que no lo instanciamos explícitamente, tiene un constructor con parámetros:

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

El entorno "comprende" cómo crear instancias de controlador. Esto es posible gracias al mecanismo de inyección de dependencia proporcionado por el contenedor IoC integrado en el corazón de la aplicación web. En el constructor del controlador, ahora puede enumerar todo lo que se requiere para su funcionamiento. Lo principal es que los componentes correspondientes están registrados en el contenedor. Ahora pasemos a la implementación del enrutador:

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

Tenga en cuenta que el enrutador depende del controlador. Si recordamos la configuración de dependencia, entonces Controller es un componente de corta duración y Router es un solitario constante. ¿Cómo puede ser esto? La respuesta es que los componentes no son instancias de las clases correspondientes, como se ve externamente. De hecho, estos son objetos proxy ( Proxy, GoF ) con la instancia del método de fábrica ( Método de fábrica, GoF ); devuelven una instancia del componente de acuerdo con la estrategia asignada. Como el controlador está registrado como "transitorio", el enrutador siempre se ocupará de su nueva instancia cuando se acceda a él. El diagrama de secuencia muestra un mecanismo aproximado de trabajo:

imagen

Es decir Además de la gestión de dependencias, un buen marco basado en un contenedor de IoC también se responsabiliza de la gestión correcta de la vida útil de los componentes.

Conclusión


La técnica de inyección de dependencia puede tener una implementación interna bastante sofisticada. Este es el precio de transferir la complejidad de implementar aplicaciones flexibles al núcleo del marco. El usuario de dichos marcos no puede preocuparse por los aspectos puramente técnicos, sino dedicar más tiempo al desarrollo cómodo de la lógica empresarial de los programas de aplicación. Usando una implementación DI de alta calidad, un programador de aplicaciones inicialmente escribe código comprobable y bien soportado. Un buen ejemplo de la implementación de la inyección de dependencia es el marco Dandy descrito en mi artículo anterior Orthodox Backend .

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


All Articles