
Hoje falaremos sobre como escrever seu
AutoMapper . Sim, eu realmente gostaria de falar sobre isso, mas não posso. O fato é que essas soluções são muito grandes, têm um histórico de tentativa e erro e também percorreram um longo caminho até o aplicativo. Só posso entender como isso funciona, dar um ponto de partida para aqueles que gostariam de entender o mecanismo de trabalho dos “mapeadores”. Você pode até dizer que escreveremos nossa bicicleta.
Isenção de responsabilidade
Recordo mais uma vez: escreveremos um mapeador primitivo. Se você de repente decidir modificá-lo e usá-lo no produto - não faça isso. Tome uma solução pronta que conheça a pilha de problemas nessa área e
já saiba como resolvê-los. Existem vários motivos mais ou menos significativos para escrever e usar o seu mapeador de bicicletas:
- Precisa de alguma personalização especial.
- Você precisa de desempenho máximo em suas condições e está pronto para encher cones.
- Você quer entender como o mapeador funciona.
- Você gosta de andar de bicicleta.
O que é chamado a palavra "mapeador"?
Esse é o subsistema responsável por pegar um objeto e convertê-lo (copiar seus valores) em outro. Uma tarefa típica é converter um DTO em um objeto da camada de negócios. O mapeador mais primitivo "executa" as propriedades da fonte de dados e as compara com as propriedades do tipo de dados que será produzido. Após a correspondência, os valores são extraídos da fonte e gravados no objeto, que será o resultado da conversão. Em algum lugar ao longo do caminho, provavelmente, ainda será necessário criar esse "resultado".
Para o consumidor, o mapeador é um serviço que fornece a seguinte interface:
public interface IMapper<out TOut> { TOut Map(object source); }
Enfatizo: esta é a interface mais primitiva que, do meu ponto de vista, é conveniente para explicação. Na realidade, provavelmente estaremos lidando com um mapeador mais específico (IMapper <TIn, TOut>) ou com uma fachada mais geral (IMapper), que selecionará um mapeador específico para os tipos especificados de objetos de entrada e saída.
Implementação ingênua
Nota: mesmo a implementação ingênua do mapeador requer conhecimentos básicos de
Reflexão e
Expressão . Se você não seguiu os links ou ouviu alguma coisa sobre essas tecnologias - faça-o, leia-o. Prometo que o mundo nunca mais será o mesmo.
No entanto, estamos escrevendo seu próprio mapeador. Para começar, vamos obter todas as propriedades (
PropertyInfo ) do tipo de dados que serão
exibidas (daqui em diante vou chamá-lo de
TOut ). Isso é bem simples: conhecemos o tipo, pois estamos escrevendo a implementação de uma classe genérica parametrizada com o tipo TOut. Em seguida, usando uma instância da classe Type, obtemos todas as suas propriedades.
Type outType = typeof(TOut); PropertyInfo[] outProperties = outType.GetProperties();
Ao obter propriedades, omito os recursos. Por exemplo, alguns deles podem estar sem uma função setter, alguns podem ser marcados como ignorados pelo atributo, outros podem ter acesso especial. Estamos considerando a opção mais simples.
Nós vamos além. Seria bom poder criar uma instância do tipo TOut, ou seja, o próprio objeto no qual "mapeamos" o objeto recebido. No C #, existem várias maneiras de fazer isso. Por exemplo, podemos fazer isso: System.Activator.CreateInstance (). Ou mesmo apenas o novo TOut (), mas para isso você precisa criar uma restrição para o TOut, o que você não gostaria de fazer na interface generalizada. No entanto, nós dois sabemos algo sobre ExpressionTrees, o que significa que podemos fazê-lo assim:
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();
Porque assim? Como sabemos que uma instância da classe Type pode fornecer informações sobre quais construtores ela possui - isso é muito conveniente para casos em que decidimos desenvolver nosso mapeador para que passemos quaisquer dados ao construtor. Além disso, aprendemos um pouco mais sobre o ExpressionTrees, ou seja, eles permitem que a placa crie e compile código, que pode ser reutilizado. Nesse caso, é uma função que realmente se parece com () => new TOut ().
Agora você precisa escrever o método principal do mapeador, que copiará os valores. Iremos pela maneira mais simples: percorrer as propriedades do objeto que nos chegou na entrada e procurar as propriedades com o mesmo nome entre as propriedades do objeto de saída. Se encontrado - copie, se não - siga em frente.
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;
Assim, formamos completamente a classe
BasicMapper . Você pode se familiarizar com os testes dele
aqui . Observe que a fonte pode ser um objeto de qualquer tipo específico ou um objeto anônimo.
Desempenho e boxe
A reflexão é ótima, mas lenta. Além disso, seu uso frequente aumenta o tráfego de memória, o que significa que ele carrega o GC, o que significa que diminui ainda mais o aplicativo. Por exemplo, usamos apenas os métodos
PropertyInfo.SetValue e
PropertyInfo.GetValue . O método GetValue retorna um objeto no qual um determinado valor é quebrado (boxe). Isso significa que recebemos uma alocação do zero.
Os mapeadores geralmente estão localizados onde você precisa transformar um objeto em outro ... Não, não um, mas muitos objetos. Por exemplo, quando pegamos algo do banco de dados. Neste local, eu gostaria de ver o desempenho normal e não perder memória em uma operação elementar.
O que podemos fazer?
O ExpressionTrees nos ajudará novamente. O fato é que o .NET permite criar e compilar código "on the fly": nós o descrevemos na representação do objeto, dizemos o que e onde vamos usá-lo ... e compilamos. Quase nenhuma mágica.
Mapeador compilado
De fato, tudo é relativamente simples: já fizemos novas com Expression.New (ConstructorInfo). Você provavelmente notou que o método New estático é chamado exatamente o mesmo que o operador. O fato é que quase toda a sintaxe C # é refletida na forma de métodos estáticos da classe Expression. Se algo estiver faltando, significa que você está procurando o chamado "Açúcar sintático".
Aqui estão algumas operações que usaremos em nosso mapeador:
- Declaração de variável - Expression.Variable (Type, string). O argumento Type diz que tipo de variável será criada e string é o nome da variável.
- Atribuição - Expression.Assign (Expressão, Expressão). O primeiro argumento é o que atribuímos, e o segundo argumento é o que atribuímos.
- O acesso à propriedade de um objeto é Expression.Property (Expression, PropertyInfo). Expression é o proprietário da propriedade e PropertyInfo é a representação do objeto da propriedade obtida por meio de Reflection.
Com esse conhecimento, podemos criar variáveis, acessar propriedades de objetos e atribuir valores a propriedades de objetos. Provavelmente, também entendemos que o ExpressionTree precisa ser compilado em um delegado no formato
Func <object, TOut> . O plano é este: obtemos uma variável que contém os dados de entrada, criamos uma instância do tipo TOut e criamos expressões que atribuem uma propriedade a outra.
Infelizmente, o código não é muito compacto, então sugiro que você dê uma olhada na implementação do
CompiledMapper imediatamente. Eu trouxe aqui apenas pontos-chave.
Primeiro, criamos uma representação de objeto do parâmetro de nossa função. Como ele recebe um objeto como entrada, o objeto será um parâmetro.
var parameter = Expression.Parameter(typeof(object), "source");
Em seguida, criamos duas variáveis e uma lista de Expressões na qual adicionaremos sequencialmente expressões de atribuição. A ordem é importante, porque é assim que os comandos serão executados quando chamamos o método compilado. Por exemplo, não podemos atribuir um valor a uma variável que ainda não foi declarada.
Além disso, da mesma maneira que no caso de implementação ingênua, examinamos a lista de propriedades de tipo e tentamos combiná-las por nome. No entanto, em vez de atribuir valores imediatamente, criamos expressões para extrair valores e atribuir valores para cada propriedade associada.
Expression sourceValue = Expression.Property(sourceInstance, sourceProperty); Expression outValue = Expression.Property(outInstance, outProperty); expressions.Add(Expression.Assign(outValue, sourceValue));
Um ponto importante: depois de termos criado todas as operações de atribuição, precisamos retornar o resultado da função. Para fazer isso, a última expressão na lista deve ser Expression, contendo a instância da classe que criamos. Deixei um comentário ao lado desta linha. Por que o comportamento correspondente à palavra-chave return no ExpressionTree se parece com isso? Receio que este seja um problema separado. Agora eu sugiro que seja fácil de lembrar.
Bem, no final, temos que compilar todas as expressões que construímos. Em que estamos interessados aqui? A variável body contém o "corpo" da função. “Funções normais” têm um corpo, certo? Bem, que colocamos entre chaves. Então, Expression.Block é exatamente isso. Como os chavetas também são um escopo, precisamos passar as variáveis que serão usadas lá - no nosso caso, sourceInstance e outInstance.
var body = Expression.Block(new[] {sourceInstance, outInstance}, expressions); return Expression.Lambda<Func<object, TOut>>(body, parameter).Compile();
Na saída, obtemos Func <object, TOut>, ou seja, uma função que pode converter dados de um objeto para outro. Por que essas dificuldades, você pergunta? Lembro que, em primeiro lugar, queríamos evitar o boxe ao copiar valores ValueType e, em segundo lugar, queríamos abandonar os métodos PropertyInfo.GetValue e PropertyInfo.SetValue, porque eles são um pouco lentos.
Por que não boxe? Como o ExpressionTree compilado é uma IL real e, para o tempo de execução, ele se parece com (quase) com o seu código. Por que o "mapeador compilado" é mais rápido? Novamente: porque é simplesmente IL. A propósito, podemos confirmar facilmente a velocidade usando a biblioteca
BenchmarkDotNet , e a própria referência pode ser visualizada
aqui .
Na coluna Ratio, "CompiledMapper" (CompiledMapper) mostrou um resultado muito bom, mesmo comparado ao AutoMapper (é a linha de base, ou seja, 1). No entanto, não vamos nos alegrar: o AutoMapper possui recursos significativamente maiores em comparação com nossa bicicleta. Com esta placa, eu só queria mostrar que o ExpressionTrees é muito mais rápido que a "abordagem clássica de reflexão".
Sumário
Espero ter conseguido mostrar que escrever o seu mapeador é bastante simples. Reflection e ExpressionTrees são ferramentas muito poderosas que os desenvolvedores usam para resolver muitas tarefas diferentes. Injeção de dependência, serialização / desserialização, repositórios CRUD, criação de consultas SQL, usando outras linguagens como scripts para aplicativos .NET - tudo isso é feito usando Reflection, Reflection.Emit e ExpressionTrees.
E o mapper? O Mapper é um ótimo exemplo em que você pode aprender tudo isso.
PS: Se você quiser um pouco mais de ExpressionTrees, sugiro ler sobre como
fazer o seu conversor JSON usando essa tecnologia.