
Hoy hablaremos sobre cómo escribir su
AutoMapper . Sí, realmente me gustaría contarte sobre esto, pero no puedo. El hecho es que tales soluciones son muy grandes, tienen un historial de prueba y error, y también han recorrido un largo camino hacia la aplicación. Solo puedo dar una comprensión de cómo funciona esto, dar un punto de partida para aquellos que deseen comprender el mecanismo de trabajo de los "mapeadores". Incluso se podría decir que escribiremos nuestra bicicleta.
Descargo de responsabilidad
Te recuerdo una vez más: escribiremos un mapeador primitivo. Si de repente decide modificarlo y usarlo en el producto, no lo haga. Tome una solución preparada que conozca la pila de problemas en esta área temática y
ya sepa cómo resolverlos. Hay varias razones más o menos significativas para escribir y usar su mapeador de bicicletas:
- Necesita alguna personalización especial.
- Necesita el máximo rendimiento en sus condiciones y está listo para llenar conos.
- Desea comprender cómo funciona el mapeador.
- Simplemente te gusta andar en bicicleta.
¿Qué se llama la palabra "mapeador"?
Este es el subsistema responsable de tomar un objeto y convertirlo (copiar sus valores) a otro. Una tarea típica es convertir un DTO en un objeto de capa empresarial. El mapeador más primitivo "corre" a través de las propiedades del origen de datos y las compara con las propiedades del tipo de datos que se generarán. Después de la coincidencia, los valores se extraen de la fuente y se escriben en el objeto, que será el resultado de la conversión. En algún punto del camino, lo más probable es que aún sea necesario crear este mismo "resultado".
Para el consumidor, mapper es un servicio que proporciona la siguiente interfaz:
public interface IMapper<out TOut> { TOut Map(object source); }
Destaco: esta es la interfaz más primitiva, que, desde mi punto de vista, es conveniente para la explicación. En realidad, probablemente trataremos con un mapeador más específico (IMapper <TIn, TOut>) o con una fachada más general (IMapper), que seleccionará un mapeador específico para los tipos especificados de objetos de entrada-salida.
Implementación ingenua
Nota: incluso la implementación ingenua de mapper requiere conocimientos básicos de
Reflection and
ExpressionTrees . Si no ha seguido los enlaces o no ha escuchado nada sobre estas tecnologías, hágalo, léalo. Prometo que el mundo nunca será igual.
Sin embargo, estamos escribiendo su propio mapeador. Para comenzar, obtengamos todas las propiedades (
PropertyInfo ) del tipo de datos que se generarán (en adelante, lo llamaré
TOut ). Esto es bastante simple: conocemos el tipo, ya que estamos escribiendo la implementación de una clase genérica parametrizada con el tipo TOut. Luego, usando una instancia de la clase Type, obtenemos todas sus propiedades.
Type outType = typeof(TOut); PropertyInfo[] outProperties = outType.GetProperties();
Al obtener propiedades, omito las características. Por ejemplo, algunos de ellos pueden estar sin una función de establecimiento, algunos pueden estar marcados como ignorados por el atributo, algunos pueden tener acceso especial. Estamos considerando la opción más simple.
Vamos más allá Sería bueno poder crear una instancia de tipo TOut, es decir, el mismo objeto en el que "mapeamos" el objeto entrante. En C #, hay varias formas de hacer esto. Por ejemplo, podemos hacer esto: System.Activator.CreateInstance (). O incluso solo un nuevo TOut (), pero para esto necesita crear una restricción para TOut, que no querría hacer en la interfaz generalizada. Sin embargo, ambos sabemos algo sobre ExpressionTrees, lo que significa que podemos hacerlo así:
ConstructorInfo outConstructor = outType.GetConstructor(Array.Empty<Type>()); Func<TOut> activator = outConstructor == null ? throw new Exception($"Default constructor for {outType.Name} not found") : Expression.Lambda<Func<TOut>>(Expression.New(outConstructor)).Compile();
Por qué Debido a que sabemos que una instancia de la clase Type puede proporcionar información sobre los constructores que tiene, esto es muy conveniente para los casos en que decidimos desarrollar nuestro mapeador para pasarle cualquier dato al constructor. Además, aprendimos un poco más sobre ExpressionTrees, es decir, permiten que la placa cree y compile código, que luego se puede reutilizar. En este caso, es una función que realmente se parece a () => new TOut ().
Ahora necesita escribir el método principal del mapeador, que copiará los valores. Seguiremos el camino más simple: revise las propiedades del objeto que nos llegó en la entrada y busque las propiedades con el mismo nombre entre las propiedades del objeto saliente. Si se encuentra, copie, si no, continúe.
TOut outInstance = _activator(); PropertyInfo[] sourceProperties = source.GetType().GetProperties(); for (var i = 0; i < sourceProperties.Length; i++) { PropertyInfo sourceProperty = sourceProperties[i]; string propertyName = sourceProperty.Name; if (_outProperties.TryGetValue(propertyName, out PropertyInfo outProperty)) { object sourceValue = sourceProperty.GetValue(source); outProperty.SetValue(outInstance, sourceValue); } } return outInstance;
Por lo tanto, hemos formado completamente la clase
BasicMapper . Puedes familiarizarte con sus pruebas
aquí . Tenga en cuenta que la fuente puede ser un objeto de cualquier tipo en particular o un objeto anónimo.
Rendimiento y boxeo
La reflexión es genial, pero lenta. Además, su uso frecuente aumenta el tráfico de memoria, lo que significa que carga el GC, lo que significa que ralentiza la aplicación aún más. Por ejemplo, acabamos de utilizar los métodos
PropertyInfo.SetValue y
PropertyInfo.GetValue . El método GetValue devuelve un objeto en el que se ajusta un cierto valor (boxeo). Esto significa que recibimos una asignación desde cero.
Los mapeadores generalmente se encuentran donde necesitas convertir un objeto en otro ... No, no uno, sino muchos objetos. Por ejemplo, cuando tomamos algo de la base de datos. En este lugar, me gustaría ver un rendimiento normal y no perder memoria en una operación primaria.
Que podemos hacer
ExpressionTrees nos ayudará de nuevo. El hecho es que .NET le permite crear y compilar código "sobre la marcha": lo describimos en la representación de objetos, decimos qué y dónde lo usaremos ... y lo compilamos. Casi no hay magia.
Mapeador compilado
De hecho, todo es relativamente simple: ya hicimos nuevo con Expression.New (ConstructorInfo). Probablemente haya notado que el nuevo método estático se llama exactamente igual que el operador. El hecho es que casi toda la sintaxis de C # se refleja en forma de métodos estáticos de la clase Expression. Si falta algo, significa que está buscando el llamado "Azúcar sintáctico".
Aquí hay algunas operaciones que usaremos en nuestro mapeador:
- Declaración de variables: expresión.Variable (tipo, cadena). El argumento Tipo indica qué tipo de variable se creará, y cadena es el nombre de la variable.
- Asignación - Expression.Assign (Expression, Expression). El primer argumento es lo que asignamos, y el segundo argumento es lo que asignamos.
- El acceso a la propiedad de un objeto es Expression.Property (Expression, PropertyInfo). Expression es el propietario de la propiedad, y PropertyInfo es la representación del objeto de la propiedad obtenida a través de Reflection.
Con este conocimiento, podemos crear variables, acceder a las propiedades de los objetos y asignar valores a las propiedades de los objetos. Lo más probable es que también comprendamos que ExpressionTree debe compilarse en un delegado de la forma
Func <object, TOut> . El plan es este: obtenemos una variable que contiene los datos de entrada, creamos una instancia de tipo TOut y creamos expresiones que asignan una propiedad a otra.
Desafortunadamente, el código no es muy compacto, por lo que le sugiero que eche un vistazo a la implementación de
CompiledMapper de inmediato. Traje aquí solo puntos clave.
Primero, creamos una representación de objeto del parámetro de nuestra función. Como toma un objeto como entrada, el objeto será un parámetro.
var parameter = Expression.Parameter(typeof(object), "source");
A continuación, creamos dos variables y una lista de expresiones en la que agregaremos secuencialmente expresiones de asignación. El orden es importante, porque así es como se ejecutarán los comandos cuando llamamos al método compilado. Por ejemplo, no podemos asignar un valor a una variable que aún no se ha declarado.
Además, de la misma manera que en el caso de la implementación ingenua, revisamos la lista de propiedades de tipo e intentamos unirlas por nombre. Sin embargo, en lugar de asignar valores inmediatamente, creamos expresiones para extraer valores y asignar valores para cada propiedad asociada.
Expression sourceValue = Expression.Property(sourceInstance, sourceProperty); Expression outValue = Expression.Property(outInstance, outProperty); expressions.Add(Expression.Assign(outValue, sourceValue));
Un punto importante: después de haber creado todas las operaciones de asignación, debemos devolver el resultado de la función. Para hacer esto, la última expresión en la lista debe ser Expresión, que contiene la instancia de la clase que creamos. Dejé un comentario al lado de esta línea. ¿Por qué el comportamiento correspondiente a la palabra clave return en ExpressionTree se ve así? Me temo que este es un tema aparte. Ahora sugiero que sea fácil de recordar.
Bueno, al final, tenemos que compilar todas las expresiones que construimos. ¿Qué nos interesa aquí? La variable cuerpo contiene el "cuerpo" de la función. Las "funciones normales" tienen un cuerpo, ¿verdad? Bueno, lo encerramos entre llaves. Entonces, Expression.Block es exactamente eso. Dado que las llaves también son un alcance, debemos pasar allí las variables que se utilizarán allí, en nuestro caso sourceInstance y outInstance.
var body = Expression.Block(new[] {sourceInstance, outInstance}, expressions); return Expression.Lambda<Func<object, TOut>>(body, parameter).Compile();
En la salida, obtenemos Func <objeto, TOut>, es decir Una función que puede convertir datos de un objeto a otro. ¿Por qué tantas dificultades, preguntas? Le recuerdo que, en primer lugar, queríamos evitar el boxeo al copiar valores ValueType, y en segundo lugar, queríamos abandonar los métodos PropertyInfo.GetValue y PropertyInfo.SetValue, ya que son algo lentos.
¿Por qué no boxear? Debido a que el ExpressionTree compilado es un IL real, y para el tiempo de ejecución, se parece (casi) a su código. ¿Por qué el "mapeador compilado" es más rápido? De nuevo: porque es simplemente IL. Por cierto, podemos confirmar fácilmente la velocidad utilizando la biblioteca
BenchmarkDotNet , y el punto de referencia en sí se puede ver
aquí .
En la columna Ratio, el "mapeador compilado" (CompiledMapper) mostró un resultado muy bueno, incluso en comparación con AutoMapper (es la línea de base, es decir, 1). Sin embargo, no nos regocijemos: AutoMapper tiene capacidades significativamente mayores en comparación con nuestra bicicleta. Con esta placa solo quería mostrar que ExpressionTrees es mucho más rápido que el "enfoque clásico de reflexión".
Resumen
Espero haber podido demostrar que escribir su mapeador es bastante simple. Reflection and ExpressionTrees son herramientas muy poderosas que los desarrolladores usan para resolver muchas tareas diferentes. Inyección de dependencias, serialización / deserialización, repositorios CRUD, creación de consultas SQL, uso de otros lenguajes como scripts para aplicaciones .NET: todo esto se realiza mediante Reflection, Reflection.Emit y ExpressionTrees.
¿Qué pasa con el mapeador? Mapper es un gran ejemplo donde puedes aprender todo esto.
PD: Si quisieras más ExpressionTrees, te sugiero leer sobre cómo
hacer tu convertidor JSON usando esta tecnología.