In the last article, I described a method for organizing code generation with Roslyn. The task then was to demonstrate a general approach. Now I want to realize what will be of real use.
And so, who is interested in looking at how you can make a library like AutoMapper, please under the cat.
First of all, I think it's worth describing how my Ahead of Time Mapper (AOTMapper) will work. The entry point of our maper will be the generic MapTo<>
generic extention method. The analyzer will search for it and offer to implement the MapToUser
extension MapToUser
, where User
is the type that is passed to MapTo<>
.
As an example, take the following 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; } } }
The generated MapToUser
will look like this:
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; }
As you can see from this example, all properties with the same name and type are assigned automatically. In turn, those for which matches are not found continue to "hang" creating a compilation error and the developer must somehow process them.
For example, like this:
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; }
During the generation of MapToUser
place to call MapTo<User>
will be replaced by MapToUser
.
How it works in motion can be found here:
You can also install AOTMapper via nuget:
Install-Package AOTMapper
The full code of the project can be found here .
I thought for a long time how it could be done differently and eventually I thought that it was not so bad, because it resolves some inconveniences that tormented me when using AutoMapper
.
First, we get different extension methods for different types, and as a result, for a certain abstract User
type, we can very easily find out which mappings have already been implemented with the help of IntelliSense, without needing to search for the same file where our mapping is registered. Just look at what extension methods are already there.
Secondly, in runtime, this is just an extension method and thus we avoid any overhead costs associated with calling our mapera. I understand that the AutoMapper
developers AutoMapper
spent a lot of effort on optimizing the call, but there are additional costs. My small benchmark showed that, on average, it is 140-150ns per call, excluding time for initialization. The benchmark itself can be viewed in the repository, and the measurement results below.
Method | Mean | Error | Stddev | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|
AutoMapperToUserEntity | 151.84 ns | 1.9952 ns | 1.8663 ns | 0.0253 | - | - | 80 B |
AOTMapperToUserEntity | 10.41 ns | 0.2009 ns | 0.1879 ns | 0.0152 | - | - | 48 B |
AutoMapperToUser | 197.51 ns | 2.9225 ns | 2.5907 ns | 0.0787 | - | - | 248 B |
AOTMapperToUser | 46.46 ns | 0.3530 ns | 0.3129 ns | 0.0686 | - | - | 216 B |
In addition, the advantages of this mapper can be attributed to the fact that it generally does not take time to initialize when the application starts, which can be useful in large applications.
The analyzer itself has the following form (omitting the binding code):
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)); } }
All he does is check whether this is the method we need, extracts the type from the entity on which MapTo<>
is called together from the first parameter of the generalized method and generates a diagnostic message.
It will in turn be processed inside the AOTMapperCodeFixProvider
. Here we have information about the types over which we will run code generation. Then we replace the MapTo<>
call with a specific implementation. Then we call AOTMapperGenerator
which will generate a file with the extension method.
In the code, it has the following form:
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
itself changes the incoming project by creating files with maps between types.
This is done as follows:
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; }} }} "; }
So, we have a maper that works right while writing the code, and then nothing remains of its runtime. Plans to come up with a way to add configuration capability. For example, configure patterns for the names of generated methods and specify the directory where to save. Also add the ability to track changes in types. I have an idea how it can be organized, but I suspect that this may be noticeable in terms of resource consumption, and so far it has been decided on time with this.
Source: https://habr.com/ru/post/459771/
All Articles