Back to Home

We implement AutoMapper using Roslyn and code generation

roslyn · .net · code generation

We implement AutoMapper using Roslyn and code generation

    In a previous article, I described a way to organize code generation with Roslyn. The then task was to demonstrate a common approach. Now I want to realize something that will have a real application.


    And so, who are interested to see how you can make a library like AutoMapper, please, under cat.


    Introduction


    First of all, I think it's worth describing how my Ahead of Time Mapper (AOTMapper) will work. The entry point of our mapper will be the generic extention method MapTo<>. The analyzer will search for it and offer to implement the extension method MapToUser, where Useris 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; }
        }
    }

    Generated MapToUserwill have the following form:


    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 names and types are assigned automatically. In turn, those for which no matches were found continue to “hang” creating a compilation error and the developer must somehow handle 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 generation, the MapToUserplace of the call will be replaced by .MapToMapToUser


    How it works in motion can be seen here:



    AOTMapper can also be installed via nuget:


    Install-Package AOTMapper

    The full project code can be found here .


    Implementation


    I thought for a long time how this can be done differently and in the end I came to the conclusion that this is not so bad, as this solves some of the inconvenience that tormented me when using AutoMapper.


    Firstly, we get different extension methods for different types, as a result of which, for some abstract type, Userwe can very easily use IntelliSense to find out which maps are already implemented without having to look for the very file where our maps are written. Just look at what extension methods you already have.


    Secondly, in runtime it’s just an extension method and thus we avoid any overhead associated with calling our mapper. I understand that the developers AutoMapperspent a lot of effort on optimizing the call, but there are still some additional costs. My small benchmark showed that on average it is 140-150ns per call, excluding initialization time. The benchmark itself can be viewed in the repository, and the measurement results are lower.


    MethodMeanErrorStddevGen 0Gen 1Gen 2Allocated
    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

    In addition, the advantages of this mapper include the fact that it generally does not require time to initialize when the application starts, which can be useful in large applications.


    The analyzer itself has the following form (missing 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().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.Empty
                .Add("fromType", fromTypeName)
                .Add("toType", toTypeName);
            context.ReportDiagnostic(Diagnostic.Create(AOTMapperIsNotReadyDescriptor, genericNameSyntax.GetLocation(), properties));
        }
    }

    All he does is check whether it is the method that we need, extracts the type from the entity on which it is called MapTo<>together from the first parameter of the generalized method, and generates a diagnostic message.


    It will in turn be processed internally AOTMapperCodeFixProvider. Here we get information about the types over which we will run code generation. Then we replace the call MapTo<>with a specific implementation. Then we call AOTMapperGeneratorwhich will generate a file with the extension method.


    In the code, it looks like this:


    private async Task 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.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);
    }

    Itself AOTMapperGeneratormodifies the incoming project by creating files with mappings between the types.
    This is done as follows:


    public void GenerateMappers(ImmutableDictionary 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()
            .Where(o => (o.DeclaredAccessibility & Accessibility.Public) > 0)
            .ToDictionary(o => o.Name, o => o.Type);
        var toProperties = toSymbol.GetAllMembers()
            .OfType()
            .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


    In total, we have a mapper that works right at the time of writing the code, and then nothing remains of its runtime. The plans come up with a way to add configuration capabilities. For example, configure the templates for the names of the generated methods and specify the directory where to save. In addition, add the ability to track changes in types. I have an idea how this can be organized, but I suspect that this may be noticeable in terms of resource consumption, and so far it has been decided to delay this.

    Read Next