Wir implementieren AutoMapper mithilfe von Roslyn und Codegenerierung

In einem früheren Artikel habe ich eine Möglichkeit beschrieben, die Codegenerierung mit Roslyn zu organisieren. Die damalige Aufgabe bestand darin, einen gemeinsamen Ansatz zu demonstrieren. Jetzt möchte ich etwas realisieren, das eine echte Anwendung haben wird.


Und wer interessiert sich dafür, wie Sie eine Bibliothek wie AutoMapper erstellen können, bitte unter cat.


Einführung


Zunächst denke ich, dass es sich lohnt zu beschreiben, wie mein Ahead of Time Mapper (AOTMapper) funktionieren wird. Der Einstiegspunkt unseres Mappers wird die generische Erweiterungsmethode MapTo<> . Der Analysator sucht danach und bietet an, die MapToUser Erweiterungsmethode zu implementieren, wobei User der Typ ist, der an MapTo<> .


Nehmen Sie als Beispiel die folgenden Klassen:


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

Der generierte MapToUser sieht folgendermaßen aus:


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

Wie Sie in diesem Beispiel sehen können, werden alle Eigenschaften mit denselben Namen und Typen automatisch zugewiesen. Im Gegenzug hängen diejenigen, für die keine Übereinstimmungen gefunden wurden, weiterhin hängen und verursachen einen Kompilierungsfehler, und der Entwickler muss sie irgendwie behandeln.


Zum Beispiel so:


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

Während der MapToUser Generierung wird der MapTo<User> MapToUser durch MapToUser .


Wie es in Bewegung funktioniert, sehen Sie hier:



AOTMapper kann auch über Nuget installiert werden:


 Install-Package AOTMapper 

Den vollständigen Projektcode finden Sie hier .


Implementierung


Ich habe lange darüber nachgedacht, wie dies anders gemacht werden kann, und bin am Ende zu dem Schluss gekommen, dass dies nicht so schlimm ist, da dies einige der Unannehmlichkeiten löst, die mich bei der Verwendung von AutoMapper gequält haben.


Erstens erhalten wir verschiedene Erweiterungsmethoden für verschiedene Typen, wodurch wir für einige abstrakte User sehr einfach mit IntelliSense herausfinden können, welche Karten bereits implementiert sind, ohne nach derselben Datei suchen zu müssen, in der unsere Karten registriert sind. Schauen Sie sich einfach an, welche Erweiterungsmethoden Sie bereits haben.


Zweitens handelt es sich zur Laufzeit nur um eine Erweiterungsmethode, sodass wir keinen Aufwand für den Aufruf unseres Mappers verursachen. Ich verstehe, dass AutoMapper Entwickler viel Aufwand in die Optimierung des Anrufs AutoMapper , aber es gibt immer noch einige zusätzliche Kosten. Mein kleiner Benchmark zeigte, dass er durchschnittlich 140-150 ns pro Anruf beträgt, ohne Initialisierungszeit. Der Benchmark selbst kann im Repository angezeigt werden, und die Messergebnisse sind niedriger.


MethodeMittelwertFehlerStddevGen 0Gen 1Gen 2Zugewiesen
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.

Zu den Vorteilen dieses Mappers gehört außerdem die Tatsache, dass die Initialisierung beim Start der Anwendung im Allgemeinen keine Zeit erfordert, was in großen Anwendungen hilfreich sein kann.


Der Analysator selbst hat die folgende Form (ohne Bindungscode):


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

Er prüft MapTo<> , ob es sich um die von uns benötigte Methode handelt, extrahiert den Typ aus der Entität, für die MapTo<> aufgerufen wird, aus dem ersten Parameter der verallgemeinerten Methode und generiert eine Diagnosemeldung.


Es wird wiederum im AOTMapperCodeFixProvider . Hier erhalten wir Informationen zu den Typen, über die wir die Codegenerierung ausführen werden. Dann ersetzen wir den Aufruf von MapTo<> durch eine bestimmte Implementierung. Dann rufen wir AOTMapperGenerator der eine Datei mit der Erweiterungsmethode generiert.


Im Code sieht es so aus:


 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 selbst ändert das eingehende Projekt, indem Dateien mit Zuordnungen zwischen den Typen erstellt werden.
Dies geschieht wie folgt:


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

Schlussfolgerungen


Insgesamt haben wir einen Mapper, der zum Zeitpunkt des Schreibens des Codes funktioniert, und dann bleibt nichts von seiner Laufzeit übrig. Die Pläne sehen eine Möglichkeit vor, Konfigurationsfunktionen hinzuzufügen. Konfigurieren Sie beispielsweise die Vorlagen für die Namen der generierten Methoden und geben Sie das Verzeichnis an, in dem gespeichert werden soll. Fügen Sie auch die Möglichkeit hinzu, Änderungen an Typen zu verfolgen. Ich habe eine Idee, wie dies organisiert werden kann, aber ich vermute, dass dies im Hinblick auf den Ressourcenverbrauch spürbar ist, und bisher wurde beschlossen, dies zu verzögern.

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


All Articles