Cuestionando uno de los argumentos para los marcos de inyección de dependencia: ¿Por qué es difícil crear un gráfico de objeto?


13

Los marcos de inyección de dependencia como Google Guice dan la siguiente motivación para su uso ( fuente ):

Para construir un objeto, primero construye sus dependencias. Pero para construir cada dependencia, necesita sus dependencias, y así sucesivamente. Entonces, cuando construyes un objeto, realmente necesitas construir un gráfico de objeto.

Construir gráficas de objetos a mano requiere mucha mano de obra (...) y dificulta las pruebas.

Pero no compro este argumento: incluso sin un marco de inyección de dependencias, puedo escribir clases que sean fáciles de instanciar y convenientes para probar. Por ejemplo, el ejemplo de la página de motivación de Guice podría reescribirse de la siguiente manera:

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)
    {
        ...
    }
}

Por lo tanto, puede haber otros argumentos para los marcos de inyección de dependencia ( que están fuera del alcance de esta pregunta ), pero la creación sencilla de gráficos de objetos comprobables no es uno de ellos, ¿verdad?


1
Creo que otra ventaja de la inyección de dependencia que a menudo se ignora es que te obliga a saber de qué objetos dependen . Nada aparece mágicamente en los objetos, ya sea inyectando atributos marcados explícitamente o directamente a través del constructor. Sin mencionar cómo hace que su código sea más verificable.
Benjamin Gruenbaum

Tenga en cuenta que no estoy buscando otras ventajas de la inyección de dependencia: solo estoy interesado en comprender el argumento de que "la
creación de

Esta respuesta parecería proporcionar un muy buen ejemplo de por qué querrías algún tipo de contenedor para tus gráficos de objetos (en resumen, no estás pensando lo suficientemente grande, usando solo 3 objetos).
Izkata

@Izkata No, esto no es una cuestión de tamaño. Con el enfoque descrito, el código de esa publicación sería simplemente new ShippingService().
Oberlies

@oberlies todavía lo es; entonces tendrías que cavar en 9 definiciones de clase para descubrir qué clases se están utilizando para ese Servicio de envío, en lugar de una ubicación
Izkata

Respuestas:


12

Hay un viejo, viejo debate en curso sobre la mejor manera de hacer la inyección de dependencia.

  • El corte original del resorte instanciaba un objeto plano y luego inyectaba dependencias a través de métodos de establecimiento.

  • Pero luego una gran contingencia de personas insistió en que inyectar dependencias a través de parámetros de constructor era la forma correcta de hacerlo.

  • Luego, últimamente, a medida que el uso de la reflexión se hizo más común, establecer los valores de los miembros privados directamente, sin establecedores ni argumentos de constructor, se convirtió en la rabia.

Entonces, su primer constructor es consistente con el segundo enfoque para la inyección de dependencia. Le permite hacer cosas buenas como inyectar simulacros para probar.

Pero el constructor sin argumentos tiene este problema. Dado que está creando instancias de las clases de implementación para PaypalCreditCardProcessory DatabaseTransactionLog, crea una dependencia difícil en tiempo de compilación de PayPal y la Base de datos. Asume la responsabilidad de construir y configurar todo ese árbol de dependencias correctamente.

  • Imagine que el procesador PayPay es un subsistema realmente complicado, y además incorpora muchas bibliotecas de soporte. Al crear una dependencia en tiempo de compilación en esa clase de implementación, está creando un enlace irrompible a todo ese árbol de dependencias. La complejidad de su gráfico de objeto acaba de aumentar en un orden de magnitud, tal vez dos.

  • Muchos de esos elementos en el árbol de dependencias serán transparentes, pero muchos de ellos también deberán ser instanciados. Lo más probable es que no puedas crear una instancia de a PaypalCreditCardProcessor.

  • Además de la creación de instancias, cada uno de los objetos necesitará propiedades aplicadas desde la configuración.

Si solo tiene una dependencia en la interfaz y permite que una fábrica externa construya e inyecte la dependencia, se corta todo el árbol de dependencias de PayPal y la complejidad de su código se detiene en la interfaz.

Hay otros beneficios, como especificar clases de implementaciones en la configuración (es decir, en tiempo de ejecución en lugar de tiempo de compilación), o tener una especificación de dependencia más dinámica que varía, por ejemplo, por entorno (prueba, integración, producción).

Por ejemplo, supongamos que PayPalProcessor tenía 3 objetos dependientes, y cada una de esas dependencias tenía dos más. Y todos esos objetos tienen que extraer propiedades de la configuración. El código tal como está asumiría la responsabilidad de construir todo eso, establecer propiedades a partir de la configuración, etc. etc., todas las preocupaciones de las que se ocupará el marco DI.

Al principio puede no parecer obvio de qué se está protegiendo al usar un marco DI, pero se acumula y se vuelve dolorosamente obvio con el tiempo. (jaja, hablo desde la experiencia de haber intentado hacerlo de la manera difícil)

...

En la práctica, incluso para un programa realmente pequeño, encuentro que termino escribiendo en un estilo DI y divido las clases en pares de implementación / fábrica. Es decir, si no estoy usando un marco DI como Spring, solo agrego algunas clases simples de fábrica.

Eso proporciona la separación de las preocupaciones para que mi clase pueda hacer lo suyo, y la clase de fábrica asume la responsabilidad de construir y configurar cosas.

No es un enfoque obligatorio, pero FWIW

...

En términos más generales, el patrón DI / interfaz reduce la complejidad de su código al hacer dos cosas:

  • abstraer dependencias posteriores en interfaces

  • "levantar" dependencias ascendentes de su código y colocarlas en algún tipo de contenedor

Además de eso, dado que la creación de instancias y configuración de objetos es una tarea bastante familiar, el marco DI puede lograr muchas economías de escala a través de la notación estandarizada y el uso de trucos como la reflexión. Dispersar esas mismas preocupaciones alrededor de las clases termina agregando mucho más desorden de lo que uno pensaría.


El código asumiría la responsabilidad de construir todo eso : una clase solo se responsabiliza de saber cuáles son las implementaciones de sus colaboradores. No necesita saber qué necesitan estos colaboradores a su vez. Entonces esto no es tanto.
Oberlies

1
Pero si el constructor de PayPalProcessor tuviera 2 argumentos, ¿de dónde vendrían?
Rob

No lo hace. Al igual que BillingService, PayPalProcessor tiene un constructor de cero argumentos que crea los colaboradores que necesita.
Oberlies

abstraer dependencias posteriores en interfaces : el código de ejemplo también hace esto. El campo del procesador es del tipo CreditCardProcessor, y no del tipo de implementación.
Oberlies

2
@oberlies: Su ejemplo de código se extiende entre dos estilos, el estilo de argumento del constructor y el estilo constructivo de cero args. La falacia es que la construcción de cero args no siempre es posible. La belleza de DI o Factory es que de alguna manera permiten que los objetos se construyan con lo que parece un constructor de cero args.
rwong

4
  1. Cuando nadas en el extremo poco profundo de la piscina, todo es "fácil y conveniente". Una vez que pasas una docena de objetos, ya no es conveniente.
  2. En su ejemplo, ha vinculado su proceso de facturación para siempre y un día a PayPal. ¿Suponga que quiere usar un procesador de tarjeta de crédito diferente? ¿Suponga que desea crear un procesador de tarjeta de crédito especializado que esté restringido en la red? ¿O necesita probar el manejo del número de tarjeta de crédito? Ha creado un código no portátil: "escriba una vez, use solo una vez porque depende del gráfico de objeto específico para el que fue diseñado".

Al vincular su gráfico de objeto al principio del proceso, es decir, conectarlo al código, requiere que tanto el contrato como la implementación estén presentes. Si alguien más (tal vez incluso usted) quiere usar ese código para un propósito ligeramente diferente, tiene que volver a calcular todo el gráfico del objeto y reimplementarlo.

Los marcos DI le permiten tomar un montón de componentes y conectarlos en tiempo de ejecución. Esto hace que el sistema sea "modular", compuesto por una serie de módulos que funcionan para las interfaces de los demás en lugar de las implementaciones de los demás.


Cuando sigo su argumento, el razonamiento para DI debería haber sido "crear un gráfico de objetos a mano es fácil, pero conduce a un código que es difícil de mantener". Sin embargo, la afirmación era que "crear un gráfico de objetos a mano es difícil" y solo estoy buscando argumentos que respalden esa afirmación. Entonces tu publicación no responde mi pregunta.
Oberlies

1
Supongo que si reduce la pregunta a "crear un gráfico de objeto ...", entonces es sencillo. Mi punto es que DI aborda el problema de que nunca se trata con un solo gráfico de objeto; tratas con una familia de ellos.
BobDalgleish

En realidad, solo crear un gráfico de objeto ya puede ser complicado si se comparten colaboradores .
Oberlies

4

El enfoque de "instanciar a mis propios colaboradores" puede funcionar para los árboles de dependencia , pero ciertamente no funcionará bien para los gráficos de dependencia que son gráficos acíclicos dirigidos en general (DAG). En un DAG de dependencia, varios nodos pueden apuntar al mismo nodo, lo que significa que dos objetos usan el mismo objeto como colaborador. De hecho, este caso no puede construirse con el enfoque descrito en la pregunta.

Si algunos de mis colaboradores (o los colaboradores de los colaboradores) compartieran un determinado objeto, necesitaría crear una instancia de este objeto y pasarlo a mis colaboradores. Entonces, de hecho, necesitaría saber más que mis colaboradores directos, y esto obviamente no escala.


1
Pero el árbol de dependencia debe ser un árbol, en el sentido de la teoría de grafos. De lo contrario, tienes un ciclo, que es simplemente insatisfactorio.
Xion

1
@Xion El gráfico de dependencia debe ser un gráfico acíclico dirigido. No hay requisito de que solo haya exactamente una ruta entre dos nodos como en los árboles.
Oberlies

@ Xion: No necesariamente. Considere un UnitOfWorkque tiene un DbContextrepositorio singular y múltiple. Todos estos repositorios deberían usar el mismo DbContextobjeto. Con la "instanciación propia" propuesta por OP, eso se vuelve imposible de hacer.
Flater

1

No he usado Google Guice, pero me he tomado una gran cantidad de tiempo migrando viejas aplicaciones de nivel N heredadas en arquitecturas .Net a IoC como Onion Layer que dependen de la Inyección de dependencia para desacoplar cosas.

¿Por qué la inyección de dependencia?

El propósito de la Inyección de dependencias no es en realidad la capacidad de prueba, sino tomar aplicaciones estrechamente acopladas y aflojar el acoplamiento tanto como sea posible. (Lo que tiene el deseable producto de hacer que su código sea mucho más fácil de adaptar para una prueba de unidad adecuada)

¿Por qué debería preocuparme por el acoplamiento?

El acoplamiento o las dependencias estrechas pueden ser algo muy peligroso. (Especialmente en lenguajes compilados) En estos casos, podría tener una biblioteca, dll, etc. que se usa muy raramente y que tiene un problema que efectivamente desconecta toda la aplicación. (Toda su aplicación muere porque una pieza sin importancia tiene un problema ... esto es malo ... REALMENTE malo) Ahora, cuando desacopla las cosas, puede configurar su aplicación para que pueda ejecutarse incluso si falta esa DLL o biblioteca por completo. Seguro que una pieza que necesita esa biblioteca o DLL no funcionará, pero el resto de la aplicación funciona felizmente.

¿Por qué necesito la inyección de dependencia para una prueba adecuada?

Realmente solo quieres un código débilmente acoplado, la Inyección de dependencias solo lo habilita. Puede acoplar cosas libremente sin IoC, pero generalmente es más trabajo y menos adaptable (estoy seguro de que alguien tiene una excepción)

En el caso que ha dado, creo que sería mucho más fácil configurar la inyección de dependencia para que pueda simular el código que no estoy interesado en contar como parte de esta prueba. Simplemente diga su método "Oye, sé que te dije que llamaras al repositorio, pero aquí están los datos que" deberían "devolver guiños " ahora porque esos datos nunca cambian, sabes que solo estás probando la parte que usa esos datos, no el recuperación real de los datos.

Básicamente, cuando realiza las pruebas, desea Pruebas de integración (funcionalidad) que prueban una pieza de funcionalidad de principio a fin, y pruebas de unidad completa que prueban cada pieza de código (generalmente a nivel de método o función) de forma independiente.

La idea es que quiere asegurarse de que toda la funcionalidad esté funcionando, si no quiere saber la parte exacta del código que no funciona.

Esto PUEDE hacerse sin la inyección de dependencia, pero generalmente a medida que su proyecto crece, se vuelve cada vez más engorroso sin la inyección de dependencia en su lugar. (¡SIEMPRE asuma que su proyecto crecerá! Es mejor tener una práctica innecesaria de habilidades útiles que encontrar un proyecto que se acelere rápidamente y requiera una refactorización y reingeniería seria después de que las cosas ya estén despegando).


1
Escribir pruebas usando una gran parte pero no todo el gráfico de dependencia es, de hecho, un buen argumento para DI.
Oberlies

1
También vale la pena señalar que con DI puede cambiar programáticamente fragmentos enteros de su código fácilmente. He visto casos en los que un sistema borra y reinyecta una interfaz con una clase completamente diferente para reaccionar a interrupciones y problemas de rendimiento por servicios remotos. Puede haber tenido una mejor manera de manejarlo, pero funcionó sorprendentemente bien.
RualStorge

0

Como mencioné en otra respuesta , el problema aquí es que desea que la clase Adependa de alguna claseB sin codificar qué clase Bse usa en el código fuente de A. Esto es imposible en Java y C # porque la única forma de importar una clase es referirse a él por un nombre único globalmente.

Al usar una interfaz, puede solucionar la dependencia de clase codificada, pero aún necesita tener en sus manos una instancia de la interfaz, y no puede llamar a los constructores o volverá al cuadrado 1. Así que ahora codifique que de otro modo podría crear sus dependencias, le quita esa responsabilidad a otra persona. Y sus dependencias están haciendo lo mismo. Entonces, cada vez que necesita una instancia de una clase, termina construyendo todo el árbol de dependencias manualmente, mientras que en el caso en que la clase A depende de B directamente, puede llamar new A()y hacer que esa llamada de constructor new B(), y así sucesivamente.

Un marco de inyección de dependencias intenta evitarlo al permitirle especificar las asignaciones entre clases y construir el árbol de dependencias por usted. El problema es que cuando arruinas las asignaciones, lo descubrirás en tiempo de ejecución, no en tiempo de compilación como lo harías en los lenguajes que admiten módulos de asignación como un concepto de primera clase.


0

Creo que este es un gran malentendido aquí.

Guice es un marco de inyección de dependencia . Hace DI automática . El punto que destacaron en el extracto que citó es acerca de que Guice puede eliminar la necesidad de crear manualmente ese "constructor comprobable" que presentó en su ejemplo. No tiene absolutamente nada que ver con la inyección de dependencia en sí.

Este constructor:

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

ya usa inyección de dependencia. Básicamente, acabas de decir que usar DI es fácil.

El problema que Guice resuelve es que para usar ese constructor ahora debe tener un código de constructor de gráfico de objeto en alguna parte , pasando manualmente los objetos ya instanciados como argumentos de ese constructor. Guice le permite tener un lugar único donde puede configurar qué clases de implementación reales corresponden a esas CreditCardProcessore TransactionLoginterfaces. Después de esa configuración, cada vez que crea BillingServiceusando Guice, esas clases se pasarán al constructor automáticamente.

Esto es lo que hace el marco de inyección de dependencias . Pero el propio constructor que usted presentó ya es una implementación del principio de inyección de dependencia . Los contenedores IoC y los marcos DI son medios para automatizar los principios correspondientes, pero no hay nada que lo detenga para hacer todo a mano, ese fue el punto central.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.