De todos modos, ¡no puedes hacerlo! - Uso de interfaces e inyección de dependencias para un diseño a largo plazo.

Hola a todos!

Finalmente tenemos un contrato para actualizar el libro de Mark Siman " Dependency Injection in .NET ". Lo principal es que lo termine lo antes posible. También tenemos un libro en el editor del respetado Dinesh Rajput sobre patrones de diseño en la primavera 5, donde uno de los capítulos también está dedicado a la implementación de dependencias.

Durante mucho tiempo hemos estado buscando material interesante que recuerde las fortalezas del paradigma DI y aclare nuestro interés en él, y ahora se ha encontrado. Es cierto que el autor prefirió dar ejemplos en Go. Esperamos que esto no le impida seguir sus pensamientos y le ayude a comprender los principios generales de inversión de control y trabajar con interfaces, si este tema está cerca de usted.

La coloración emocional del original es un poco más tranquila, el número de signos de exclamación en la traducción se reduce. Que tengas una buena lectura!

El uso de interfaces es una técnica comprensible que le permite crear código que es fácil de probar y fácilmente extensible. En repetidas ocasiones me he convencido de que esta es la herramienta de diseño de arquitectura más poderosa de todas.

El propósito de este artículo es explicar qué son las interfaces, cómo se usan y cómo proporcionan extensibilidad y capacidad de prueba del código. Finalmente, el artículo debe mostrar cómo las interfaces pueden ayudar a optimizar la gestión de entrega de software y simplificar la planificación.

Interfaces

La interfaz describe el contrato. Dependiendo del lenguaje o marco, el uso de interfaces puede ser dictado explícita o implícitamente. Entonces, en el lenguaje Go, las interfaces se dictan explícitamente . Si intenta utilizar una entidad como interfaz, pero no será totalmente coherente con las reglas de esta interfaz, se producirá un error en tiempo de compilación. Por ejemplo, al ejecutar el ejemplo anterior, obtenemos el siguiente error:

prog.go:22:85: cannot use BadPricer literal (type BadPricer) as type StockPricer in argument to isPricerHigherThan100: BadPricer does not implement StockPricer (missing CurrentPrice method) Program exited. 

Interfaces es una herramienta para ayudar a separar a la persona que llama de la persona que llama, esto se hace mediante un contrato.

Vamos a concretar este problema usando un ejemplo de un programa para el intercambio automático de divisas. Se llamará al programa de comerciantes con un precio de compra establecido y un símbolo de cotización. Luego, el programa irá al intercambio para averiguar la cotización actual de este ticker. Además, si el precio de compra de este ticker no excede el precio establecido, el programa realizará una compra.



De forma simplificada, la arquitectura de este programa se puede representar de la siguiente manera. Del ejemplo anterior está claro que la operación de obtener el precio actual depende directamente del protocolo HTTP, mediante el cual el programa contacta al servicio de intercambio.

El estado de la Action también depende directamente de HTTP. Por lo tanto, ambos estados deben comprender completamente cómo usar HTTP para extraer datos de intercambio y / o completar transacciones.

Así es como se vería la implementación:

 func analyze(ticker string, maxTradePrice float64) (bool, err) { resp, err := http.Get( "http://stock-service.com/currentprice/" + ticker ) if err != nil { //   } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) // ... currentPrice := parsePriceFromBody(body) var hasTraded bool var err error if currentPrice <= maximumTradePrice { err = doTrade(ticker, currentPrice) if err == nil { hasTraded = true } } return hasTraded, err } 

Aquí, la persona que llama ( analyze ) depende directamente de HTTP. Ella necesita saber cómo se formulan las solicitudes HTTP. ¿Cómo se hace su análisis? Cómo manejar reintentos, tiempos de espera, autenticación, etc. Ella tiene un buen control sobre http . Siempre que llamemos a análisis, también debemos llamar a la biblioteca http .

¿Cómo puede ayudarnos la interfaz aquí? En el contrato proporcionado por la interfaz, puede describir el comportamiento , en lugar de la implementación específica.

 type StockExchange interface { CurrentPrice(ticker string) float64 } 

Lo anterior define el concepto de StockExchange . Aquí dice que StockExchange admite llamar a la única función CurrentPrice . Estas tres líneas me parecen la técnica arquitectónica más poderosa de todas. Nos ayudan a controlar las dependencias de las aplicaciones con mucha más confianza. Proporcionar pruebas. Proporcionar extensibilidad.

Inyección de dependencia

Para comprender completamente el valor de las interfaces, debe dominar la técnica llamada "inyección de dependencia".

La inyección de dependencia significa que la persona que llama proporciona algo que necesita. Por lo general, se ve así: la persona que llama configura el objeto y luego lo pasa a la persona que llama. Luego, la parte llamada extrae de la configuración y la implementación. En este caso, hay una mediación conocida. Considere una solicitud al servicio HTTP Rest. Para implementar el cliente, necesitamos usar una biblioteca HTTP que pueda formular, enviar y recibir solicitudes HTTP.

Si colocamos la solicitud HTTP detrás de la interfaz, la persona que llama podría separarse y ella "no se daría cuenta" de que la solicitud HTTP realmente tuvo lugar.

La persona que llama solo debe realizar una llamada de función genérica. Puede ser una llamada local, una llamada remota, una llamada HTTP, una llamada RPC, etc. La persona que llama no está al tanto de lo que está sucediendo y, por lo general, le conviene perfectamente, siempre que obtenga los resultados esperados. A continuación se muestra cómo se vería la inyección de dependencia en nuestro método de analyze .

 func analyze(se StockExchange, ticker string, maxTradePrice float64) (bool, error) { currentPrice := se.CurrentPrice(ticker) var hasTraded bool var err error if currentPrice <= maximumTradePrice { err = doTrade(ticker, currentPrice) if err == nil { hasTraded = true } } return hasTraded, err } 

Nunca dejo de sorprenderme de lo que está sucediendo aquí. Revertimos completamente nuestro árbol de dependencias y comenzamos a controlar mejor todo el programa. Además, incluso visualmente, toda la implementación se ha vuelto más limpia y comprensible. Vemos claramente que el método de análisis debe elegir el precio actual, verificar si este precio es adecuado para nosotros y, de ser así, hacer un trato.

Lo más importante, en este caso separamos a la persona que llama de la persona que llama. Dado que la persona que llama y toda la implementación están separadas de la llamada usando la interfaz, puede extender la interfaz creando muchas implementaciones diferentes. ¡Las interfaces le permiten crear muchas implementaciones específicas diferentes sin necesidad de cambiar el código de la parte llamada!



El estado de "obtener el precio actual" en este programa depende solo de la interfaz de StockExchange . Esta implementación no sabe nada sobre cómo contactar al servicio de intercambio, cómo se almacenan los precios o cómo se realizan las solicitudes. La verdadera ignorancia dichosa. Por otra parte, bilateral. La implementación HTTPStockExchange tampoco sabe nada sobre el análisis. Sobre el contexto en el que se llevará a cabo el análisis, cuando se lleve a cabo, porque los desafíos ocurren indirectamente.

Dado que los fragmentos de programa (aquellos que dependen de interfaces) no necesitan cambiarse al cambiar / agregar / eliminar implementaciones específicas, dicho diseño resulta ser duradero . Supongamos que encontramos que StockService menudo no StockService disponible.

¿En qué se diferencia el ejemplo anterior de llamar a una función? Al aplicar una llamada de función, la implementación también será más limpia. La diferencia es que cuando llama a la función, todavía tenemos que recurrir a HTTP. El método de analyze simplemente delegará la tarea de la función, que debería llamar a http , en lugar de llamar a http directamente. Toda la fuerza de esta técnica radica en la "inyección", es decir, en que la persona que llama proporciona la interfaz a la persona que llama. Así es exactamente como se produce la inversión de dependencia, donde los precios dependen solo de la interfaz y no de la implementación.

Múltiples implementaciones listas para usar

En esta etapa, tenemos la función de analyze y la interfaz StockExchange , pero en realidad no podemos hacer nada útil. Acabo de anunciar nuestro programa. Por el momento, es imposible llamarlo, ya que todavía no tenemos una única implementación específica que cumpla con los requisitos de nuestra interfaz.

El énfasis principal en el siguiente diagrama se hace en el estado de "obtener el precio actual" y su dependencia de la interfaz de StockExchange . A continuación se muestra cómo coexisten dos implementaciones completamente diferentes y no se conoce el precio actual. Además, ambas implementaciones no están relacionadas entre sí, cada una de ellas depende solo de la interfaz de StockExchange .



Producción

La implementación HTTP original ya existe en la implementación de analyze primaria; todo lo que nos queda es extraerlo y encapsularlo detrás de una implementación concreta de la interfaz.

 type HTTPStockExchange struct {} func (se HTTPStockExchange) CurrentPrice(ticker string) float64 { resp, err := http.Get( "http://stock-service.com/currentprice/" + ticker ) if err != nil { //   } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) // ... return parsePriceFromBody(body) } 

El código que anteriormente StockExchange a la función de análisis ahora es autónomo y satisface la interfaz de StockExchange , es decir, ahora podemos pasarlo a analyze . Como recordará de los diagramas anteriores, el análisis ya no está asociado con la dependencia HTTP. Al usar la interfaz, el analyze no "imagina" lo que sucede detrás de escena. Solo sabe que se le garantizará un objeto con el que puede llamar a CurrentPrice .

También aquí aprovechamos las virtudes típicas de la encapsulación. Antes, cuando las solicitudes http estaban vinculadas al análisis, la única forma de comunicarse con el intercambio a través de http era indirecta, a través del método de analyze . Sí, podríamos encapsular estas llamadas en funciones y ejecutar la función de forma independiente, pero las interfaces nos obligan a separar a la persona que llama de la persona que llama. Ahora podemos probar HTTPStockExchange independientemente de la persona que llama. Esto afecta fundamentalmente el alcance de nuestras pruebas y cómo entendemos y respondemos a las fallas de las pruebas.

Prueba

En el código existente, tenemos la estructura HTTPStockService , que nos permite asegurarnos por separado de que puede comunicarse con el servicio de intercambio y analizar las respuestas recibidas de él. Pero ahora StockExchange que el análisis pueda manejar correctamente la respuesta de la interfaz de StockExchange , además, que esta operación es confiable y reproducible.

 currentPrice := se.CurrentPrice(ticker) if currentPrice <= maxTradePrice { err := doTrade(ticker, currentPrice) } 

PODRÍAMOS usar la implementación con HTTP, pero tendría muchas desventajas. Hacer llamadas de red en pruebas unitarias puede ser lento, especialmente para servicios externos. Debido a demoras y una conexión de red inestable, las pruebas podrían resultar poco confiables. Además, si necesitáramos pruebas con la declaración de que podemos completar la transacción, y pruebas con la declaración de que podemos filtrar los casos en los que la transacción NO debe concluirse, sería difícil encontrar datos de producción reales que satisfagan de manera confiable ambos condiciones Se podría elegir maxTradePrice , imitando artificialmente cada una de las condiciones de esta manera, por ejemplo, con maxTradePrice := -100 transacción no debe completarse, y maxTradePrice := 10000000 obviamente debe terminar con la transacción.

Pero, ¿qué sucede si se nos asigna una determinada cuota en el servicio de intercambio? ¿O si tenemos que pagar el acceso? ¿Realmente (y deberíamos) pagar o gastar nuestra cuota cuando se trata de pruebas unitarias? Idealmente, las pruebas deben ejecutarse con la mayor frecuencia posible, por lo que deben ser rápidas, baratas y confiables. ¡Creo que de este párrafo está claro por qué usar una versión con HTTP puro es irracional en términos de prueba!

¡Hay una mejor manera, e implica el uso de interfaces!

Con una interfaz, puede fabricar cuidadosamente la implementación de StockExchange , que nos permitirá analyze rápida, segura y confiable.

 type StubExchange struct { Price float64 } func (se StubExchange) CurrentPrice(ticker string) float64 { return se.Price } func TestAnalyze_MakeTrade(t *testing.T) { se := StubExchange{Price: 10} maxTradePrice := 11 traded, err := analyze(se, "TSLA", maxTradePrice) if err != nil { t.Errorf("expected err == nil received: %s", err) } if !traded { t.Error("expected traded == true") } } func TestAnalyze_DontTrade(t *testing.T) { se := StubExchange{Price: 10} maxTradePrice := 9 traded, err := analyze(se, "TSLA", maxTradePrice) //  } 

El trozo del servicio de intercambio se utiliza anteriormente, gracias al cual se lanza la rama de interés para nosotros en el analyze . Luego, se hacen declaraciones en cada una de las pruebas para asegurarse de que el análisis haga lo que se necesita. Aunque este es un programa de prueba, mi experiencia sugiere que los componentes / arquitectura, donde las interfaces se usan aproximadamente de esta manera, también se prueban de esta manera para garantizar la durabilidad en el código de batalla. Gracias a las interfaces, podemos usar el StockExchange controlado en la memoria, que proporciona pruebas confiables, fácilmente configurables, fáciles de entender, reproducibles y rápidas.

Desanclar: configuración del llamante

Ahora que hemos discutido cómo usar las interfaces para separar a la persona que llama de la persona que llama, y ​​cómo hacer múltiples implementaciones, todavía no hemos tocado un aspecto crítico. ¿Cómo configurar y proporcionar una implementación específica en un momento estrictamente definido? Puede llamar directamente a la función de análisis, pero ¿qué hacer en la configuración de producción?

Aquí es donde la implementación de dependencias es útil.

 func main() { var ticker = flag.String("ticker", "", "stock ticker symbol to trade for") var maxTradePrice = flag.Float64("maxtradeprice", "", "max price to pay for a share of the ticker symbol." se := HTTPStockExchange{} analyze(se, *ticker, *maxTradePrice) } 

Al igual que en nuestro caso de prueba, la implementación concreta específica de StockExchange que se utilizará con el analyze configura la persona que llama fuera del análisis. Luego se pasa (inyecta) para analyze . Esto garantiza que se analice NADA NADA sobre cómo HTTPStockExchange configura HTTPStockExchange . Quizás nos gustaría proporcionar el dominio http que vamos a utilizar en forma de un indicador de línea de comando, y luego analizar no tendrá que cambiar. ¿O qué hacer si necesitáramos proporcionar algún tipo de autenticación o token para acceder a HTTPStockExchange , que se extraerá del entorno? Nuevamente, analizar no debe cambiar.

La configuración se lleva a cabo a un nivel fuera del analyze , liberando así al análisis de la necesidad de configurar sus propias dependencias. Por lo tanto, se logra una estricta separación de funciones.



Decisiones de estantería

Quizás los ejemplos anteriores son suficientes, pero todavía hay muchas otras ventajas para las interfaces y la inyección de dependencias. Las interfaces permiten diferir decisiones sobre implementaciones específicas. Aunque las decisiones nos obligan a decidir qué comportamiento apoyaremos, aún nos permiten tomar decisiones sobre implementaciones específicas más adelante. Supongamos que sabíamos que queríamos realizar transacciones automatizadas, pero aún no estábamos seguros de qué proveedor de cotizaciones usaríamos. Una clase similar de soluciones se trata constantemente cuando se trabaja con almacenes de datos. ¿Qué debe usar nuestro programa: mysql, postgres, redis, sistema de archivos, cassandra? En definitiva, todo esto son detalles de implementación, y las interfaces nos permiten diferir las decisiones finales sobre estos temas. ¡Nos permiten desarrollar la lógica de negocios de nuestros programas y cambiar a soluciones tecnológicas específicas en el último momento!

A pesar de que esta técnica por sí sola deja muchas posibilidades, algo mágico sucede a nivel de planificación de proyectos. Imagine lo que sucederá si agregamos una dependencia más a la interfaz de intercambio.



Aquí reconfiguraremos nuestra arquitectura en forma de un gráfico acíclico dirigido, de modo que tan pronto como estemos de acuerdo con los detalles de la interfaz de intercambio, podamos COMPETIBLEMENTE continuar trabajando con la tubería utilizando HTTPStockExchange . Creamos una situación en la que la incorporación de una nueva persona al proyecto nos ayuda a avanzar más rápido. Al ajustar nuestra arquitectura de esta manera, vemos mejor dónde, cuándo y durante cuánto tiempo podemos involucrar a más personas en el proyecto para acelerar la entrega de todo el proyecto. Además, dado que la conexión entre nuestras interfaces es débil, generalmente es fácil involucrarse en el trabajo, comenzando con las interfaces de implementación. ¡Puede desarrollar, probar y probar HTTPStockExchange completamente independientemente de nuestro programa!

El análisis de las dependencias arquitectónicas y la planificación de acuerdo con estas dependencias pueden acelerar drásticamente los proyectos. Usando esta técnica en particular, pude completar muy rápidamente proyectos para los cuales se asignaron varios meses.

Por delante

Ahora debería ser más claro cómo las interfaces y la implementación de dependencias aseguran la durabilidad del programa diseñado. Supongamos que cambiamos nuestro proveedor de cotizaciones, o comenzamos a transmitir cuotas y las guardamos en tiempo real; Hay tantas otras posibilidades como quieras. El método de análisis en su forma actual admitirá cualquier implementación adecuada para la integración con la interfaz de StockExchange .

 se.CurrentPrice(ticker) 

Por lo tanto, en muchos casos, puede hacerlo sin cambios. No en todos, pero en esos casos predecibles que podemos encontrar. No solo somos inmunes a la necesidad de cambiar el código de analyze y verificar dos veces su funcionalidad clave, sino que podemos ofrecer fácilmente nuevas implementaciones o cambiar entre proveedores. ¡También podemos expandir o actualizar sin problemas las implementaciones específicas que ya tenemos sin la necesidad de cambiar o verificar el analyze !

Espero que los ejemplos anteriores demuestren de manera convincente cómo el debilitamiento de la comunicación entre entidades en el programa mediante el uso de interfaces reorienta completamente las dependencias y separa a la persona que llama de la persona que llama. Gracias a este desapego, el programa no depende de una implementación específica, sino que depende de un comportamiento específico. Este comportamiento puede ser proporcionado por una amplia variedad de implementaciones. Este principio de diseño crítico también se llama tipificación de pato .

El concepto de interfaces y la dependencia del comportamiento, y no de la implementación, es tan poderoso que considero las interfaces como un lenguaje primitivo, sí, esto es bastante radical. Espero que los ejemplos discutidos anteriormente resulten bastante convincentes, y usted estará de acuerdo en que las interfaces y la inyección de dependencia se deben usar desde el comienzo del proyecto. En casi todos los proyectos en los que trabajé, se requería no una, sino al menos dos implementaciones: para producción y para pruebas.

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


All Articles