.NET Framework
Inyección de dependencia
Buscar..
Observaciones
Problemas resueltos por inyección de dependencia
Si no utilizáramos la inyección de dependencia, la clase Greeter
podría verse más como esto:
public class ControlFreakGreeter
{
public void Greet()
{
var greetingProvider = new SqlGreetingProvider(
ConfigurationManager.ConnectionStrings["myConnectionString"].ConnectionString);
var greeting = greetingProvider.GetGreeting();
Console.WriteLine(greeting);
}
}
Es un "control freak" porque controla la creación de la clase que proporciona el saludo, controla de dónde proviene la cadena de conexión SQL y controla la salida.
Al usar la inyección de dependencia, la clase Greeter
renuncia a esas responsabilidades en favor de una sola responsabilidad, escribiendo un saludo que se le proporciona.
El principio de inversión de dependencia sugiere que las clases deberían depender de abstracciones (como interfaces) en lugar de otras clases concretas. Las dependencias directas (acoplamiento) entre clases pueden dificultar progresivamente el mantenimiento. Dependiendo de las abstracciones se puede reducir ese acoplamiento.
La inyección de dependencia nos ayuda a lograr esa inversión de dependencia porque lleva a escribir clases que dependen de abstracciones. La clase Greeter
"no sabe" nada en absoluto sobre los detalles de implementación de IGreetingProvider
e IGreetingWriter
. Solo se sabe que las dependencias inyectadas implementan esas interfaces. Eso significa que los cambios en las clases concretas que implementan IGreetingProvider
e IGreetingWriter
no afectarán a Greeter
. Tampoco los reemplazará con implementaciones completamente diferentes. Solo los cambios en las interfaces lo harán. Greeter
está desacoplado.
ControlFreakGreeter
es imposible realizar una prueba de unidad correctamente. Queremos probar una pequeña unidad de código, pero en lugar de eso, nuestra prueba incluiría conectarse a SQL y ejecutar un procedimiento almacenado. También incluiría probar la salida de la consola. Debido a que ControlFreakGreeter hace tanto, es imposible realizar pruebas aisladas de otras clases.
Greeter
es fácil de realizar una prueba unitaria porque podemos inyectar implementaciones simuladas de sus dependencias que son más fáciles de ejecutar y verificar que llamar a un procedimiento almacenado o leer la salida de la consola. No requiere una cadena de conexión en app.config.
Las implementaciones concretas de IGreetingProvider
y IGreetingWriter
pueden ser más complejas. Ellos, a su vez, pueden tener sus propias dependencias que se inyectan en ellos. (Por ejemplo, inyectaríamos la cadena de conexión SQL en SqlGreetingProvider
). Pero esa complejidad está "oculta" de otras clases que solo dependen de las interfaces. Eso hace que sea más fácil modificar una clase sin un "efecto dominó" que requiere que realicemos los cambios correspondientes a otras clases.
Inyección de dependencia - Ejemplo simple
Esta clase se llama Greeter
. Su responsabilidad es dar un saludo. Tiene dos dependencias . Necesita algo que le dé el saludo de salida, y luego necesita una forma de emitir ese saludo. Esas dependencias se describen como interfaces, IGreetingProvider
e IGreetingWriter
. En este ejemplo, esas dos dependencias se "inyectan" en Greeter
. (Explicación adicional siguiendo el ejemplo).
public class Greeter
{
private readonly IGreetingProvider _greetingProvider;
private readonly IGreetingWriter _greetingWriter;
public Greeter(IGreetingProvider greetingProvider, IGreetingWriter greetingWriter)
{
_greetingProvider = greetingProvider;
_greetingWriter = greetingWriter;
}
public void Greet()
{
var greeting = _greetingProvider.GetGreeting();
_greetingWriter.WriteGreeting(greeting);
}
}
public interface IGreetingProvider
{
string GetGreeting();
}
public interface IGreetingWriter
{
void WriteGreeting(string greeting);
}
La clase de Greeting
depende tanto de IGreetingProvider
como de IGreetingWriter
, pero no es responsable de crear instancias de ninguno de los dos. En su lugar los requiere en su constructor. Cualquier cosa que cree una instancia de Greeting
debe proporcionar esas dos dependencias. Podemos llamar a eso "inyectar" las dependencias.
Debido a que las dependencias se proporcionan a la clase en su constructor, esto también se denomina "inyección de constructor".
Algunas convenciones comunes:
- El constructor guarda las dependencias como campos
private
. Tan pronto como se crea una instancia de la clase, esas dependencias están disponibles para todos los otros métodos no estáticos de la clase. - Los campos
private
son dereadonly
. Una vez que se establecen en el constructor, no se pueden cambiar. Esto indica que esos campos no deben (y no pueden) ser modificados fuera del constructor. Eso asegura aún más que esas dependencias estarán disponibles durante toda la vida de la clase. - Las dependencias son interfaces. Esto no es estrictamente necesario, pero es común porque facilita la sustitución de una implementación de la dependencia por otra. También permite proporcionar una versión simulada de la interfaz para propósitos de prueba unitaria.
Cómo la inyección de dependencia hace que las pruebas unitarias sean más fáciles
Esto se basa en el ejemplo anterior de la clase Greeter
que tiene dos dependencias, IGreetingProvider
e IGreetingWriter
.
La implementación real de IGreetingProvider
podría recuperar una cadena de una llamada a la API o una base de datos. La implementación de IGreetingWriter
puede mostrar el saludo en la consola. Pero como Greeter
tiene sus dependencias inyectadas en su constructor, es fácil escribir una prueba de unidad que inyecta versiones simuladas de esas interfaces. En la vida real podríamos usar un marco como Moq , pero en este caso escribiré esas implementaciones burladas.
public class TestGreetingProvider : IGreetingProvider
{
public const string TestGreeting = "Hello!";
public string GetGreeting()
{
return TestGreeting;
}
}
public class TestGreetingWriter : List<string>, IGreetingWriter
{
public void WriteGreeting(string greeting)
{
Add(greeting);
}
}
[TestClass]
public class GreeterTests
{
[TestMethod]
public void Greeter_WritesGreeting()
{
var greetingProvider = new TestGreetingProvider();
var greetingWriter = new TestGreetingWriter();
var greeter = new Greeter(greetingProvider, greetingWriter);
greeter.Greet();
Assert.AreEqual(greetingWriter[0], TestGreetingProvider.TestGreeting);
}
}
El comportamiento de IGreetingProvider
y IGreetingWriter
no son relevantes para esta prueba. Queremos probar que Greeter
recibe un saludo y lo escribe. El diseño de Greeter
(mediante inyección de dependencia) nos permite inyectar dependencias simuladas sin partes móviles complicadas. Todo lo que estamos probando es que Greeter
interactúa con esas dependencias como esperamos.
Por qué usamos contenedores de inyección de dependencia (contenedores IoC)
Inyección de dependencia significa clases de escritura para que no controlen sus dependencias; en cambio, se les proporcionan sus dependencias ("inyectadas").
Esto no es lo mismo que usar un marco de inyección de dependencias (a menudo denominado "contenedor DI", "contenedor IoC" o simplemente "contenedor") como Castle Windsor, Autofac, SimpleInjector, Ninject, Unity u otros.
Un contenedor simplemente facilita la inyección de dependencia. Por ejemplo, suponga que escribe una cantidad de clases que dependen de la inyección de dependencia. Una clase depende de varias interfaces, las clases que implementan esas interfaces dependen de otras interfaces, etc. Algunos dependen de valores específicos. Y solo por diversión, algunas de esas clases implementan IDisposable
y deben eliminarse.
Cada clase individual está bien escrita y es fácil de evaluar. Pero ahora hay un problema diferente: crear una instancia de una clase se ha vuelto mucho más complicado. Supongamos que estamos creando una instancia de una clase CustomerService
. Tiene dependencias y sus dependencias tienen dependencias. Construir una instancia puede verse algo como esto:
public CustomerData GetCustomerData(string customerNumber)
{
var customerApiEndpoint = ConfigurationManager.AppSettings["customerApi:customerApiEndpoint"];
var logFilePath = ConfigurationManager.AppSettings["logwriter:logFilePath"];
var authConnectionString = ConfigurationManager.ConnectionStrings["authorization"].ConnectionString;
using(var logWriter = new LogWriter(logFilePath ))
{
using(var customerApiClient = new CustomerApiClient(customerApiEndpoint))
{
var customerService = new CustomerService(
new SqlAuthorizationRepository(authorizationConnectionString, logWriter),
new CustomerDataRepository(customerApiClient, logWriter),
logWriter
);
// All this just to create an instance of CustomerService!
return customerService.GetCustomerData(string customerNumber);
}
}
}
Podría preguntarse, ¿por qué no poner toda la construcción gigante en una función separada que simplemente devuelve CustomerService
? Una razón es que debido a que las dependencias para cada clase se inyectan en ella, una clase no es responsable de saber si esas dependencias son IDisposable
o desecharlas. Simplemente los usa. Entonces, si tuviéramos una función GetCustomerService()
que devolviera un CustomerService
completamente construido, esa clase podría contener una cantidad de recursos desechables y ninguna forma de acceder o desecharlos.
Y aparte de desechar IDisposable
, ¿quién quiere llamar a una serie de constructores anidados como ese, alguna vez? Ese es un pequeño ejemplo. Podría ser mucho, mucho peor. Nuevamente, eso no significa que escribimos las clases de manera incorrecta. Las clases pueden ser individualmente perfectas. El reto es componerlos juntos.
Un contenedor de inyección de dependencia simplifica eso. Nos permite especificar qué clase o valor debe usarse para cumplir con cada dependencia. Este ejemplo ligeramente simplificado utiliza Castle Windsor:
var container = new WindsorContainer()
container.Register(
Component.For<CustomerService>(),
Component.For<ILogWriter, LogWriter>()
.DependsOn(Dependency.OnAppSettingsValue("logFilePath", "logWriter:logFilePath")),
Component.For<IAuthorizationRepository, SqlAuthorizationRepository>()
.DependsOn(Dependency.OnValue(connectionString, ConfigurationManager.ConnectionStrings["authorization"].ConnectionString)),
Component.For<ICustomerDataProvider, CustomerApiClient>()
.DependsOn(Dependency.OnAppSettingsValue("apiEndpoint", "customerApi:customerApiEndpoint"))
);
A esto lo llamamos "registrar dependencias" o "configurar el contenedor". Traducido, esto le dice a nuestro WindsorContainer
:
- Si una clase requiere
ILogWriter
, cree una instancia deLogWriter
.LogWriter
requiere una ruta de archivo. Utilice este valor deAppSettings
. - Si una clase requiere
IAuthorizationRepository
, cree una instancia deSqlAuthorizationRepository
. Requiere una cadena de conexión. Utilice este valor de la secciónConnectionStrings
. - Si una clase requiere
ICustomerDataProvider
, cree unCustomerApiClient
y proporcione la cadena que necesita desdeAppSettings
.
Cuando solicitamos una dependencia del contenedor, llamamos a eso "resolver" una dependencia. Es una mala práctica hacer eso directamente usando el contenedor, pero esa es una historia diferente. Para propósitos de demostración, ahora podríamos hacer esto:
var customerService = container.Resolve<CustomerService>();
var data = customerService.GetCustomerData(customerNumber);
container.Release(customerService);
El contenedor sabe que CustomerService
depende de IAuthorizationRepository
y ICustomerDataProvider
. Sabe qué clases necesita crear para cumplir con esos requisitos. Esas clases, a su vez, tienen más dependencias, y el contenedor sabe cómo cumplirlas. Creará todas las clases que necesite hasta que pueda devolver una instancia de CustomerService
.
Si llega a un punto en el que una clase requiere una dependencia que no hemos registrado, como IDoesSomethingElse
, cuando intentemos resolver el servicio al CustomerService
se producirá una excepción clara que nos indica que no hemos registrado nada para cumplir con ese requisito.
Cada marco DI se comporta de manera un poco diferente, pero generalmente nos dan cierto control sobre cómo se ejemplifican ciertas clases. Por ejemplo, ¿queremos que cree una instancia de LogWriter
y la proporcione a todas las clases que dependen de ILogWriter
, o queremos que cree una nueva cada vez? La mayoría de los contenedores tienen una manera de especificar eso.
¿Qué pasa con las clases que implementan IDisposable
? Por eso llamamos container.Release(customerService);
al final. La mayoría de los contenedores (incluyendo Windsor), avanzará hacia atrás a través de todas las dependencias creadas y Dispose
los que necesitan de desecharlas. Si CustomerService
es IDisposable
también lo eliminará.
Registrar dependencias como se ve arriba puede parecer más código para escribir. Pero cuando tenemos muchas clases con muchas dependencias, entonces realmente vale la pena. Y si tuviéramos que escribir esas mismas clases sin usar la inyección de dependencia, entonces esa misma aplicación con muchas clases sería difícil de mantener y probar.
Esto araña la superficie de por qué usamos contenedores de inyección de dependencia. La forma en que configuramos nuestra aplicación para usar una (y usarla correctamente) no es solo un tema, sino una serie de temas, ya que las instrucciones y los ejemplos varían de un contenedor a otro.