Read this article in English: Creating a Dependency Injection in C#
De acuerdo con la Wikipedia, la inyección de dependencias es un patrón de diseño de software que nos permite seguir el principio de inversión de dependencias mediante inversión de control, es decir, definir las dependencias de una clase desde fuera de la misma. En el caso de C# tenemos a nuestra disposición varios proyectos que generan todas las dependencias necesarias para nuestros objetos. Uno de estos es Ninject, un motor open-source con una sintaxis simple y plugins para ASP.net MVC y SignalR.
En este artículo veremos cómo funciona un motor de inyección de dependencias creando uno propio, basado en la sintaxis y la filosofía de Ninject.
Introducción
La inyección de dependencias nos ayuda a separar las responsabilidades entre las diferentes capas de nuestro código, así como mejorar la encapsulación y la facilidad de crear pruebas en nuestro código. Comencemos por un ejemplo simple, una lista de notas que nos permiten agregar una nota y guardarla llamando a _dataStorage, un objeto inicializado en el constructor:
public class NoteList { private DataStorage _dataStorage; public NoteList() { _dataStorage = new DataStorage(); } public void Add(string note) { if (string.IsNullOrEmpty(note)) { throw new ArgumentException("Note cannot be empty"); } _dataStorage.Save(note); } }
DataStorage es una clase que escribe el contenido de una nota en disco, y que tiene el siguiente código para el método Save:
internal void Save(string note) { using (StreamWriter writer = new StreamWriter("db.txt")) { writer.WriteLine(note); } }
El código de test se muestra a continuación:
//Arrange var noteList = new NoteList(); var noteText = "myCustomNote"; //Act noteList.Add(noteText); //Assert using (StreamReader reader = new StreamReader("db.txt")) { var all = reader.ReadToEnd(); Assert.IsTrue(all.Contains(noteText)); }
Como se puede ver, generamos un acoplamiento con DataStorage, esto significa que no podemos probar NoteList a menos que también probemos el comportamiento de DataStorage.
Eliminando el acoplamiento
El primer cambio que podemos hacer es abstraer DataStorage en una interfaz llamada IDataStorage. Seguidamente pasamos dicha interfaz como argumento en el constructor, en vez de inicializarla dentro de nuestra clase:
... private IDataStorage _dataStorage; public NoteList(IDataStorage dataStorage) { _dataStorage = dataStorage; } ...
El código de test cambia también, ya que pasamos a definir las dependencias fuera de la clase:
... var dataStorage = new DataStorage(); var noteList = new NoteList(dataStorage); var noteText = "myCustomNote"; ...
Construyendo nuestro inyector
Una vez que tenemos una clase que recibe los parámetros como argumentos. Estos parámetros deben ser definidos e inicializados manualmente antes de llamar a nuestro constructor, con lo cual, el primer paso es generar dichos parámetros solicitando un tipo específico a nuestro Iniector. La primera versión es una clase estática con dos métodos y un diccionario interno para almacenar los mapeos, que tiene este aspecto:
private static Dictionary<Type, object> mappings = new Dictionary<Type, object>(); public static T Get<T>() { return (T)mappings[typeof(T)]; } public static void Map<T>(object o) { mappings.Add(typeof(T), o); }
Ahora podemos solicitar un objeto para la interfaz de nuestro test:
Injector.Map<IDataStorage>(new DataStorage()); var dataStorage = Injector.Get<IDataStorage>();
Este código presenta un problema, y es que siempre devolveremos el mismo objeto, y seguimos especificando el inyector en el constructor. Sería mucho más sencillo si el inyector devolviera una nueva copia de nuestro objeto, y si adicionalmente pudiéramos pedir nuestro objeto directamente desde el inyector:
Injector.Map<IDataStorage>(new DataStorage()); var noteList = Injector.Get<NoteList>();
Para poder llegar a estos resultados necesitamos una serie de mejoras importantes en nuestro inyector. El primer paso consiste en dividir el método Get en un método no genérico, de tal manera que podamos realizar llamadas recursivas como veremos más adelante:
public static T Get<T>() { var type = typeof(T); return (T)Get(type); }
El segundo paso consiste en cambiar la manera de almacenar los objetos, ya que en vez de una instancia, almacenaremos un tipo. De esta manera cambiamos el método Map para asegurarnos que no estamos almacenando un objeto específico, y que el tipo a almacenar hereda o implementa el tipo que se usará de clave:
public static void Map<T, V>() where V : T { mappings.Add(typeof(T), typeof(V)); }
Finalmente, cambiamos nuestro método Get para invocar al constructor del tipo requerido, tras algunas comprobaciones adicionales:
private static object Get(Type type) { var target = ResolveType(type); var constructor = target.GetConstructors()[0]; var parameters = constructor.GetParameters(); List<object> resolvedParameters = new List<object>(); foreach (var item in parameters) { resolvedParameters.Add(Get(item.ParameterType)); } return constructor.Invoke(resolvedParameters.ToArray()); }
Veamos este código paso a paso: En primer lugar se resuelve el tipo, es decir, se comprueba si hay algún mapping disponible para el tipo especificado, en caso contrario se devuelve el mismo tipo:
private static Type ResolveType(Type type) { if (mappings.Keys.Contains(type)) { return mappings[type]; } return type; }
Una vez nos hemos asegurado de estar usando el tipo de dato correcto, el siguiente paso es recuperar el constructor, y su lista de parámetros (si existe).
... var target = ResolveType(type); var constructor = target.GetConstructors()[0]; var parameters = constructor.GetParameters(); ...
Si el constructor tiene parámetros, para cada uno de ellos se intentará resolver (es decir, se repetirá el proceso de manera recursiva), y si la resolución tiene éxito, se agregará a una lista de parámetros resueltos. En este caso el orden es muy importante ya que debe coincidir con el orden en el que se especifican los parámetros en el constructor**.
foreach (var item in parameters) { resolvedParameters.Add(Get(item.ParameterType)); }
Finalmente se invoca el constructor con la lista de parámetros y se devuelve el objeto que se acaba de crear.
return constructor.Invoke(resolvedParameters.ToArray());
La sintaxis final para obtener un nuevo objeto de nuestro inyector de dependencias es la siguiente:
Injector.Map<IDataStorage, DataStorage>(); var noteList = Injector.Get<NoteList>();
El resultado es una manera muy conveniente de generar un objeto sin preocuparnos de sus dependencias, que pueden estar definidas a un nivel mucho mayor.
Resumen
La inyección de dependencias no es un término oscuro ni mucho menos, nos permite crear arquitecturas desacopladas, y motores como el que acabamos de crear nos permiten crear la infraestructura necesaria a un nivel más alto, con lo cual el resto de las clases pueden tener solamente la lógica relacionada con su propio comportamiento (principio de responsabilidad simple) con un conjunto mínimo de dependencias. Por otro lado no es una pieza de software muy complicada, aunque en este caso es un ejemplo y el conjunto de casos cubiertos es mínimo.
Finalmente, las características de C# nos permiten hacer algo de metaprogramación al obtener tipos y parámetros de una función en tiempo de ejecución, así como crear objetos de manera dinámica, lo cual no está mal para un lenguaje fuertemente tipado. Sería interesante comprobar cómo podríamos crear una versión de este inyector en un entorno más dinámico y débilmente tipado.
Descarga el código!
- Disponible en GitHub: YetAnotherDependencyInjector
Un artículo cojonudo. Muy bien explicado, con la suficiente sencillez y rigor para resultar ameno e instructivo. Además logra desmitificar totalmente esas librerías que usamos habitualmente y que parecen actuar de forma mágica.
Gracias Manuel, me alegro que te resulte tan interesante como me resultó a mí escribirlo.
Genial artículo Rober!
Muy didáctico si señor :D
PD: Te he dejado un Pull-Request, a ver que te parece ;)
Gran artículo.
De todo lo que he estado leyendo sobre la inyección de dependencias este es el que más que me ha permitido aclararme con este concepto y entenderlo más claramente y como usarlo de una forma adecuada. Ahora que lo tengo más claro podré empezar a tenerlo en cuenta para mis desarrollos.
Muchas gracias.