Hold the Framework: exploración de patrones de inyección de dependencia

Publicado: 2022-03-11

Los puntos de vista tradicionales sobre la inversión de control (IoC) parecen trazar una línea divisoria entre dos enfoques diferentes: el localizador de servicios y los patrones de inyección de dependencia (DI).

Prácticamente todos los proyectos que conozco incluyen un marco DI. Las personas se sienten atraídas por ellos porque promueven un acoplamiento flexible entre los clientes y sus dependencias (generalmente a través de la inyección del constructor) con un código repetitivo mínimo o nulo. Si bien esto es excelente para un desarrollo rápido, algunas personas encuentran que puede hacer que el código sea difícil de rastrear y depurar. La “magia entre bastidores” suele lograrse a través de la reflexión, lo que puede traer toda una serie de nuevos problemas.

En este artículo, exploraremos un patrón alternativo que se adapta bien a las bases de código Java 8+ y Kotlin. Conserva la mayoría de los beneficios de un marco DI y es tan sencillo como un localizador de servicios, sin necesidad de herramientas externas.

Motivación

  • Evita las dependencias externas
  • Evita la reflexión
  • Promover la inyección de constructor
  • Minimizar el comportamiento del tiempo de ejecución

Un ejemplo

En el siguiente ejemplo, modelaremos una implementación de TV, donde se pueden usar diferentes fuentes para obtener contenido. Necesitamos construir un dispositivo que pueda recibir señales de varias fuentes (por ejemplo, terrestre, cable, satélite, etc.). Construiremos la siguiente jerarquía de clases:

Jerarquía de clases de un dispositivo de TV que implementa una fuente de señal arbitraria

Ahora, comencemos con una implementación DI tradicional, una en la que un marco como Spring conecta todo para nosotros:

 public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }

Notamos algunas cosas:

  • La clase TV expresa una dependencia de un TvSource. Un marco externo verá esto e inyectará una instancia de una implementación concreta (terrestre o por cable).
  • El patrón de inyección del constructor permite pruebas sencillas porque puede crear fácilmente instancias de TV con implementaciones alternativas.

Hemos tenido un buen comienzo, pero nos damos cuenta de que traer un marco DI para esto podría ser un poco exagerado. Algunos desarrolladores informaron problemas al depurar problemas de construcción (rastros de pila largos, dependencias imposibles de rastrear). Nuestro cliente también ha expresado que los tiempos de fabricación son un poco más largos de lo esperado, y nuestro perfilador muestra ralentizaciones en las llamadas reflexivas.

Una alternativa sería aplicar el patrón Service Locator. Es sencillo, no usa reflexión y podría ser suficiente para nuestro pequeño código base. Otra alternativa es dejar las clases en paz y escribir el código de ubicación de dependencia alrededor de ellas.

Después de evaluar muchas alternativas, optamos por implementarlo como una jerarquía de interfaces de proveedores. Cada dependencia tendrá un proveedor asociado que tendrá la responsabilidad exclusiva de localizar las dependencias de una clase y construir una instancia inyectada. También haremos que el proveedor sea una interfaz interna para facilitar su uso. Lo llamaremos inyección Mixin porque cada proveedor se mezcla con otros proveedores para ubicar sus dependencias.

Los detalles de por qué me decidí por esta estructura se elaboran en Detalles y justificación, pero aquí está la versión corta:

  • Segrega el comportamiento de ubicación de dependencia.
  • La extensión de las interfaces no cae en el problema del diamante.
  • Las interfaces tienen implementaciones predeterminadas.
  • Las dependencias faltantes impiden la compilación (¡puntos de bonificación!).

El siguiente diagrama muestra cómo interactúan las dependencias y los proveedores, y la implementación se ilustra a continuación. También agregamos un método principal para demostrar cómo podemos componer nuestras dependencias y construir un objeto de TV. También se puede encontrar una versión más larga de este ejemplo en este GitHub.

Interacciones entre proveedores y dependencias

 public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }

Algunas notas sobre este ejemplo:

  • La clase TV depende de un TvSource, pero no conoce ninguna implementación.
  • El TV.Provider extiende el TvSource.Provider porque necesita el método tvSource() para construir un TvSource, y puede usarlo incluso si no está implementado allí.
  • Las fuentes Terrestre y Cable pueden ser utilizadas indistintamente por el televisor.
  • Las interfaces Terrestrial.Provider y Cable.Provider proporcionan implementaciones concretas de TvSource.
  • El método principal tiene una implementación concreta MainContext de TV.Provider que se usa para obtener una instancia de TV.
  • El programa requiere una implementación de TvSource.Provider en tiempo de compilación para instanciar un televisor, por lo que incluimos Cable.Provider como ejemplo.

Detalles y justificación

Hemos visto el patrón en acción y parte del razonamiento detrás de él. Puede que no estés convencido de que deberías usarlo a estas alturas, y tendrías razón; no es exactamente una bala de plata. Personalmente, creo que es superior al patrón del localizador de servicios en la mayoría de los aspectos. Sin embargo, en comparación con los marcos DI, uno tiene que evaluar si las ventajas superan la sobrecarga de agregar código repetitivo.

Los proveedores extienden a otros proveedores para localizar sus dependencias

Cuando un proveedor extiende a otro, las dependencias se unen. Esto proporciona la base básica para la validación estática que evita la creación de contextos no válidos.

Uno de los principales puntos débiles del patrón del localizador de servicios es que debe llamar a un método genérico GetService<T>() que de alguna manera resolverá su dependencia. En el momento de la compilación, no tiene garantías de que la dependencia se registre alguna vez en el localizador, y su programa podría fallar en el tiempo de ejecución.

El patrón DI tampoco aborda esto. La resolución de dependencias generalmente se realiza a través de la reflexión de una herramienta externa que en su mayoría está oculta para el usuario, que también falla en el tiempo de ejecución si no se cumplen las dependencias. Herramientas como el CDI de IntelliJ (solo disponible en la versión paga) brindan cierto nivel de verificación estática, pero solo Dagger con su preprocesador de anotaciones parece abordar este problema por diseño.

Las clases mantienen la inyección de constructor típica del patrón DI

Esto no es obligatorio, pero definitivamente lo desea la comunidad de desarrolladores. Por un lado, puede simplemente mirar el constructor e inmediatamente ver las dependencias de la clase. Por otro lado, permite el tipo de prueba unitaria a la que se adhieren muchas personas, que consiste en construir el sujeto bajo prueba con simulacros de sus dependencias.

Esto no quiere decir que no se admitan otros patrones. De hecho, uno podría incluso encontrar que Mixin Injection simplifica la construcción de gráficos de dependencia complejos para la prueba porque solo necesita implementar una clase de contexto que amplíe el proveedor de su sujeto. El MainContext anterior es un ejemplo perfecto donde todas las interfaces tienen implementaciones predeterminadas, por lo que puede tener una implementación vacía. Reemplazar una dependencia solo requiere anular su método de proveedor.

Veamos la siguiente prueba para la clase de TV. Necesita instanciar un televisor, pero en lugar de llamar al constructor de la clase, usa la interfaz TV.Provider. El TvSource.Provider no tiene una implementación predeterminada, por lo que debemos escribirlo nosotros mismos.

 public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }

Ahora agreguemos otra dependencia a la clase TV. La dependencia de CathodeRayTube hace la magia para hacer que una imagen aparezca en la pantalla del televisor. Está desacoplado de la implementación de TV porque es posible que queramos cambiar a LCD o LED en el futuro.

 public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

Si hace esto, notará que la prueba que acabamos de escribir aún se compila y pasa como se esperaba. Agregamos una nueva dependencia al televisor, pero también proporcionamos una implementación predeterminada. Esto significa que no tenemos que simular si solo queremos usar la implementación real, y nuestras pruebas pueden crear objetos complejos con cualquier nivel de granularidad simulada que queramos.

Esto es útil cuando desea burlarse de algo específico en una jerarquía de clases compleja (por ejemplo, solo la capa de acceso a la base de datos). El patrón permite configurar fácilmente el tipo de pruebas sociables que a veces se prefieren a las pruebas solitarias.

Independientemente de su preferencia, puede estar seguro de que puede recurrir a cualquier forma de prueba que mejor se adapte a sus necesidades en cada situación.

Evite las dependencias externas

Como puede ver, no hay referencias o menciones a componentes externos. Esto es clave para muchos proyectos que tienen limitaciones de tamaño o incluso de seguridad. También ayuda con la interoperabilidad porque los marcos no tienen que comprometerse con un marco DI específico. En Java, ha habido esfuerzos como JSR-330 Dependency Injection for Java Standard que mitigan los problemas de compatibilidad.

Evite la reflexión

Las implementaciones del localizador de servicios no suelen depender de la reflexión, pero las implementaciones DI sí (con la notable excepción de Dagger 2). Esto tiene las principales desventajas de ralentizar el inicio de la aplicación porque el marco necesita escanear sus módulos, resolver el gráfico de dependencia, construir reflexivamente sus objetos, etc.

Mixin Injection requiere que escriba el código para instanciar sus servicios, similar al paso de registro en el patrón del localizador de servicios. Este pequeño trabajo adicional elimina por completo las llamadas reflexivas, lo que hace que su código sea más rápido y sencillo.

Dos proyectos que me llamaron la atención recientemente y se benefician de evitar la reflexión son Substrate VM de Graal y Kotlin/Native. Ambos se compilan en código de bytes nativo, y esto requiere que el compilador sepa con anticipación cualquier llamada reflexiva que realice. En el caso de Graal, se especifica en un archivo JSON que es difícil de escribir, no se puede verificar estáticamente, no se puede refactorizar fácilmente con sus herramientas favoritas. Usar Mixin Injection para evitar la reflexión en primer lugar es una excelente manera de obtener los beneficios de la compilación nativa.

Minimizar el comportamiento del tiempo de ejecución

Al implementar y ampliar las interfaces requeridas, construye el gráfico de dependencia de una pieza a la vez. Cada proveedor se encuentra junto a la implementación concreta, lo que aporta orden y lógica a su programa. Este tipo de capas te resultará familiar si has usado el patrón Mixin o el patrón Cake anteriormente.

En este punto, podría valer la pena hablar sobre la clase MainContext. Es la raíz del gráfico de dependencia y conoce el panorama general. Esta clase incluye todas las interfaces de proveedores y es clave para habilitar las comprobaciones estáticas. Si volvemos al ejemplo y eliminamos Cable.Provider de su lista de implementos lo veremos claramente:

 static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider

Lo que sucedió aquí es que la aplicación no especificó el TvSource concreto que se usaría y el compilador detectó el error. Con el localizador de servicios y la DI basada en reflejos, este error podría haber pasado desapercibido hasta que el programa fallara en el tiempo de ejecución, ¡incluso si se aprobaron todas las pruebas unitarias! Creo que estos y los otros beneficios que hemos mostrado superan la desventaja de escribir el texto estándar necesario para que el patrón funcione.

Capturar dependencias circulares

Volvamos al ejemplo de CathodeRayTube y agreguemos una dependencia circular. Digamos que queremos que se inyecte en una instancia de TV, entonces extendemos TV.Provider:

 public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

El compilador no permite la herencia cíclica y no podemos definir este tipo de relación. La mayoría de los marcos fallan en el tiempo de ejecución cuando esto sucede, y los desarrolladores tienden a solucionarlo solo para que el programa se ejecute. Aunque este antipatrón se puede encontrar en el mundo real, suele ser una señal de un mal diseño. Cuando el código no se compila, debemos animarnos a buscar mejores soluciones antes de que sea demasiado tarde para cambiar.

Mantenga la simplicidad en la construcción de objetos

Uno de los argumentos a favor de SL sobre DI es que es sencillo y fácil de depurar. Está claro a partir de los ejemplos que instanciar una dependencia será solo una cadena de llamadas al método del proveedor. Rastrear el origen de una dependencia es tan simple como entrar en la llamada al método y ver dónde termina. La depuración es más simple que ambas alternativas porque puede navegar exactamente donde se instancian las dependencias, directamente desde el proveedor.

Vida útil del servicio

Un lector atento podría haber notado que esta implementación no aborda el problema de la vida útil del servicio. Todas las llamadas a los métodos de proveedor crearán instancias de nuevos objetos, lo que lo hace similar al alcance de Prototype de Spring.

Esta y otras consideraciones están un poco fuera del alcance de este artículo, ya que simplemente quería presentar la esencia del patrón sin distraer los detalles. Sin embargo, el uso completo y la implementación en un producto deberían tener en cuenta la solución completa con soporte de por vida.

Conclusión

Ya sea que esté acostumbrado a los marcos de inyección de dependencia o a escribir sus propios localizadores de servicios, es posible que desee explorar esta alternativa. Considere usar el patrón mixin que acabamos de ver y vea si puede hacer que su código sea más seguro y más fácil de razonar.

Relacionado: Prácticas recomendadas de JS: Cree un bot de Discord con TypeScript e inyección de dependencia