Implementamos o AutoMapper usando Roslyn e geração de código

Em um artigo anterior , descrevi uma maneira de organizar a geração de código com Roslyn. A tarefa então era demonstrar uma abordagem comum. Agora quero realizar algo que terá uma aplicação real.


E então, quem está interessado em ver como você pode criar uma biblioteca como o AutoMapper, por favor, em cat.


1. Introdução


Primeiro de tudo, acho que vale a pena descrever como meu Ahead of Time Mapper (AOTMapper) funcionará. O ponto de entrada do nosso mapeador será o método de extensão genérico MapTo<> . O analisador procurará por ele e oferecerá a implementação do MapToUser extensão MapToUser , em que User é o tipo que é passado para MapTo<> .


Como exemplo, tome as seguintes classes:


 namespace AOTMapper.Benchmark.Data { public class UserEntity { public UserEntity() { } public UserEntity(Guid id, string firstName, string lastName) { this.Id = id; this.FirstName = firstName; this.LastName = lastName; } public Guid Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } } public class User { public string FirstName { get; set; } public string LastName { get; set; } public string Name { get; set; } } } 

O MapToUser gerado terá a seguinte aparência:


 public static AOTMapper.Benchmark.Data.User MapToUser(this AOTMapper.Benchmark.Data.UserEntity input) { var output = new AOTMapper.Benchmark.Data.User(); output.FirstName = input.FirstName; output.LastName = input.LastName; output.Name = ; // missing property return output; } 

Como você pode ver neste exemplo, todas as propriedades com os mesmos nomes e tipos são atribuídas automaticamente. Por sua vez, aqueles para os quais não foram encontradas correspondências continuam "travando", criando um erro de compilação e o desenvolvedor deve, de alguma forma, lidar com eles.


Por exemplo, assim:


 public static AOTMapper.Benchmark.Data.User MapToUser(this AOTMapper.Benchmark.Data.UserEntity input) { var output = new AOTMapper.Benchmark.Data.User(); output.FirstName = input.FirstName; output.LastName = input.LastName; output.Name = $"{input.FirstName} {input.LastName}"; return output; } 

Durante a geração do MapTo<User> , o MapTo<User> chamada MapTo<User> será substituído pelo MapToUser .


Como funciona em movimento pode ser visto aqui:



O AOTMapper também pode ser instalado via nuget:


 Install-Package AOTMapper 

O código completo do projeto pode ser encontrado aqui .


Implementação


Pensei por um longo tempo como isso pode ser feito de maneira diferente e, no final, cheguei à conclusão de que isso não é tão ruim, pois isso resolve alguns dos inconvenientes que me atormentaram ao usar o AutoMapper .


Em primeiro lugar, obtemos diferentes métodos de extensão para diferentes tipos, como resultado, para algum tipo abstrato de User , podemos usar muito facilmente o IntelliSense para descobrir quais mapas já foram implementados sem precisar procurar o mesmo arquivo em que nossos mapas estão registrados. Veja quais métodos de extensão você já possui.


Em segundo lugar, em tempo de execução, é apenas um método de extensão e, portanto, evitamos qualquer sobrecarga associada à chamada de nosso mapeador. Entendo que os desenvolvedores do AutoMapper gastaram muito esforço para otimizar a chamada, mas ainda existem alguns custos adicionais. Meu pequeno benchmark mostrou que, em média, é 140-150ns por chamada, excluindo o tempo de inicialização. O próprio benchmark pode ser visualizado no repositório e os resultados da medição são mais baixos.


MétodoMeanErroStddevGen 0Gen 1Gen 2Alocado
AutoMapperToUserEntity151,84 ns1.9952 ns1.8663 ns0,0253--80 B
AOTMapperToUserEntity10,41 ns0.2009 ns0,1879 ns0,0152--48 B
AutoMapperToUser197,51 ns2,9225 ns2.5907 ns0,0787--248 B
AOTMapperToUser46,46 ns0,3530 ns0,3129 ns0,0686--216 B

Além disso, as vantagens desse mapeador incluem o fato de que geralmente não é necessário tempo para inicializar quando o aplicativo é iniciado, o que pode ser útil em aplicativos grandes.


O analisador em si tem o seguinte formato (sem o código de ligação):


 private void Handle(OperationAnalysisContext context) { var syntax = context.Operation.Syntax; if (syntax is InvocationExpressionSyntax invocationSytax && invocationSytax.Expression is MemberAccessExpressionSyntax memberAccessSyntax && syntax.DescendantNodes().OfType<GenericNameSyntax>().FirstOrDefault() is GenericNameSyntax genericNameSyntax && genericNameSyntax.Identifier.ValueText == "MapTo") { var semanticModel = context.Compilation.GetSemanticModel(syntax.SyntaxTree); var methodInformation = semanticModel.GetSymbolInfo(genericNameSyntax); if (methodInformation.Symbol.ContainingAssembly.Name != CoreAssemblyName) { return; } var fromTypeInfo = semanticModel.GetTypeInfo(memberAccessSyntax.Expression); var fromTypeName = fromTypeInfo.Type.ToDisplayString(); var typeSyntax = genericNameSyntax.TypeArgumentList.Arguments.First(); var toTypeInfo = semanticModel.GetTypeInfo(typeSyntax); var toTypeName = toTypeInfo.Type.ToDisplayString(); var properties = ImmutableDictionary<string, string>.Empty .Add("fromType", fromTypeName) .Add("toType", toTypeName); context.ReportDiagnostic(Diagnostic.Create(AOTMapperIsNotReadyDescriptor, genericNameSyntax.GetLocation(), properties)); } } 

Tudo o que ele faz é verificar se é o método que precisamos, extrai o tipo da entidade na qual MapTo<> é chamado junto do primeiro parâmetro do método generalizado e gera uma mensagem de diagnóstico.


Por sua vez, será processado dentro do AOTMapperCodeFixProvider . Aqui, obtemos informações sobre os tipos sobre os quais executaremos a geração de código. Em seguida, substituímos a chamada para MapTo<> por uma implementação específica. Em seguida, chamamos AOTMapperGenerator que irá gerar um arquivo com o método de extensão.


No código, fica assim:


 private async Task<Document> Handle(Diagnostic diagnostic, CodeFixContext context) { var fromTypeName = diagnostic.Properties["fromType"]; var toTypeName = diagnostic.Properties["toType"]; var document = context.Document; var semanticModel = await document.GetSemanticModelAsync(); var root = await diagnostic.Location.SourceTree.GetRootAsync(); var call = root.FindNode(diagnostic.Location.SourceSpan); root = root.ReplaceNode(call, SyntaxFactory.IdentifierName($"MapTo{toTypeName.Split('.').Last()}")); var pairs = ImmutableDictionary<string, string>.Empty .Add(fromTypeName, toTypeName); var generator = new AOTMapperGenerator(document.Project, semanticModel.Compilation); generator.GenerateMappers(pairs, new[] { "AOTMapper", "Mappers" }); var newProject = generator.Project; var documentInNewProject = newProject.GetDocument(document.Id); return documentInNewProject.WithSyntaxRoot(root); } 

AOTMapperGenerator próprio AOTMapperGenerator modifica o projeto recebido criando arquivos com mapeamentos entre os tipos.
Isso é feito da seguinte maneira:


 public void GenerateMappers(ImmutableDictionary<string, string> values, string[] outputNamespace) { foreach (var value in values) { var fromSymbol = this.Compilation.GetTypeByMetadataName(value.Key); var toSymbol = this.Compilation.GetTypeByMetadataName(value.Value); var fromSymbolName = fromSymbol.ToDisplayString().Replace(".", ""); var toSymbolName = toSymbol.ToDisplayString().Replace(".", ""); var fileName = $"{fromSymbolName}_To_{toSymbolName}"; var source = this.GenerateMapper(fromSymbol, toSymbol, fileName); this.Project = this.Project .AddDocument($"{fileName}.cs", source) .WithFolders(outputNamespace) .Project; } } private string GenerateMapper(INamedTypeSymbol fromSymbol, INamedTypeSymbol toSymbol, string fileName) { var fromProperties = fromSymbol.GetAllMembers() .OfType<IPropertySymbol>() .Where(o => (o.DeclaredAccessibility & Accessibility.Public) > 0) .ToDictionary(o => o.Name, o => o.Type); var toProperties = toSymbol.GetAllMembers() .OfType<IPropertySymbol>() .Where(o => (o.DeclaredAccessibility & Accessibility.Public) > 0) .ToDictionary(o => o.Name, o => o.Type); return $@" public static class {fileName}Extentions {{ public static {toSymbol.ToDisplayString()} MapTo{toSymbol.ToDisplayString().Split('.').Last()}(this {fromSymbol.ToDisplayString()} input) {{ var output = new {toSymbol.ToDisplayString()}(); { toProperties .Where(o => fromProperties.TryGetValue(o.Key, out var type) && type == o.Value) .Select(o => $" output.{o.Key} = input.{o.Key};" ) .JoinWithNewLine() } { toProperties .Where(o => !fromProperties.TryGetValue(o.Key, out var type) || type != o.Value) .Select(o => $" output.{o.Key} = ; // missing property") .JoinWithNewLine() } return output; }} }} "; } 

Conclusões


No total, temos um mapeador que funciona corretamente no momento da escrita do código e, em seguida, nada resta do seu tempo de execução. Os planos apresentam uma maneira de adicionar recursos de configuração. Por exemplo, configure os modelos para os nomes dos métodos gerados e especifique o diretório onde salvar. Adicione também a capacidade de rastrear alterações nos tipos. Tenho uma ideia de como isso pode ser organizado, mas suspeito que isso possa ser notado em termos de consumo de recursos e, até agora, foi decidido adiar isso.

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


All Articles