Nous implémentons AutoMapper en utilisant Roslyn et la génération de code

Dans un article précédent , j'ai décrit un moyen d'organiser la génération de code avec Roslyn. Il s'agissait alors de démontrer une approche commune. Maintenant, je veux réaliser quelque chose qui aura une vraie application.


Et donc, toute personne intéressée à voir comment vous pouvez créer une bibliothèque comme AutoMapper demande chat.


Présentation


Tout d'abord, je pense qu'il vaut la peine de décrire comment mon Ahead of Time Mapper (AOTMapper) fonctionnera. Le point d'entrée de notre mappeur sera la méthode d'extension générique MapTo<> . L'analyseur le recherchera et proposera d'implémenter la MapToUser extension MapToUser , où User est le type transmis à MapTo<> .


Par exemple, prenez les cours suivants:


 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; } } } 

Le MapToUser généré ressemblera à ceci:


 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; } 

Comme vous pouvez le voir dans cet exemple, toutes les propriétés avec les mêmes noms et types sont attribuées automatiquement. À leur tour, ceux pour lesquels aucune correspondance n'a été trouvée continuent de «se bloquer», créant une erreur de compilation et le développeur doit en quelque sorte les gérer.


Par exemple, comme ceci:


 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; } 

Pendant la génération de MapToUser , l' MapTo<User> appel MapTo<User> sera remplacé par MapToUser .


Comment cela fonctionne en mouvement peut être vu ici:



AOTMapper peut également être installé via nuget:


 Install-Package AOTMapper 

Le code de projet complet peut être trouvé ici .


Implémentation


J'ai longtemps réfléchi à la manière de procéder différemment et j'ai finalement conclu que ce n'était pas si mal, car cela résout certains des inconvénients qui me tourmentaient lors de l'utilisation d' AutoMapper .


Premièrement, nous obtenons différentes méthodes d'extension pour différents types, de sorte que, pour certains types d' User abstraits, nous pouvons très facilement utiliser IntelliSense pour savoir quelles cartes sont déjà implémentées sans avoir à rechercher le même fichier dans lequel nos cartes sont enregistrées. Regardez simplement les méthodes d'extension que vous avez déjà.


Deuxièmement, lors de l'exécution, il s'agit simplement d'une méthode d'extension et nous évitons ainsi toute surcharge associée à l'appel de notre mappeur. Je comprends que les développeurs d' AutoMapper ont consacré beaucoup d'efforts à l'optimisation de l'appel, mais il y a encore des coûts supplémentaires. Ma petite référence a montré qu'en moyenne, elle est de 140 à 150 ns par appel, hors temps d'initialisation. Le benchmark lui-même peut être consulté dans le référentiel et les résultats de mesure sont inférieurs.


La méthodeMoyenneErreurStddevGen 0Gen 1Gen 2Alloué
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

De plus, les avantages de ce mappeur incluent le fait qu'il ne nécessite généralement pas de temps pour s'initialiser au démarrage de l'application, ce qui peut être utile dans les grandes applications.


L'analyseur lui-même a la forme suivante (sans le code de liaison):


 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)); } } 

Il ne fait que vérifier s'il s'agit de la méthode dont nous avons besoin, extraire le type de l'entité sur laquelle MapTo<> est appelé ensemble à partir du premier paramètre de la méthode généralisée, et générer un message de diagnostic.


Il sera à son tour traité à l'intérieur de l' AOTMapperCodeFixProvider . Ici, nous obtenons des informations sur les types sur lesquels nous exécuterons la génération de code. Ensuite, nous remplaçons l'appel à MapTo<> par une implémentation spécifique. Ensuite, nous appelons AOTMapperGenerator qui générera un fichier avec la méthode d'extension.


Dans le code, cela ressemble à ceci:


 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 modifie lui-même le projet entrant en créant des fichiers avec des mappages entre les types.
Cela se fait comme suit:


 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; }} }} "; } 

Conclusions


Au total, nous avons un mappeur qui fonctionne juste au moment de l'écriture du code, et puis il ne reste rien de son exécution. Les plans proposent un moyen d'ajouter des capacités de configuration. Par exemple, configurez les modèles pour les noms des méthodes générées et spécifiez le répertoire où enregistrer. Ajoutez également la possibilité de suivre les changements de types. J'ai une idée de la façon dont cela peut être organisé, mais je soupçonne que cela peut être perceptible en termes de consommation de ressources, et jusqu'à présent, il a été décidé de retarder cela.

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


All Articles