Хабрахабр

Реализуем AutoMapper при помощи Roslyn и кодогенерации

Тогдашней задачей было продемонстрировать общий подход. В прошлой статье я описал способ организации кодогенераци при помощи Roslyn. Сейчас я хочу реализовать то, что будет иметь реальное применение.

И так, кому интересно посмотреть на то как можно сделать библиотеку на подобие AutoMapper прошу под кат.

Точкой входа нашего мапера будет служить обобщенный метод расширение(generic extention method) MapTo<>. Первым делом, думаю стоит описать то, как будет работать мой Ahead of Time Mapper(AOTMapper). Анализатор будет искать его и предлагать реализовать метод расширение MapToUser, где User это тип который передан в MapTo<>.

Как пример возьмем следующее классы:

namespace AOTMapper.Benchmark.Data
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; } }
}

Сгенерённый MapToUser будет иметь следующий вид:

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

В свою очередь, те для которых совпадения не обнаружены продолжают "висеть" создавая ошибку компиляции и разработчик должен как-то их обработать. Как видно из этого примера, все свойства с одинаковыми именами и типами присваиваются автоматически.

Например, вот так:

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

Во время генерации MapToUser место вызова MapTo<User> будет заменено на MapToUser.

Как это работает в движении можно посмотреть тут:

Также AOTMapper можно установить через nuget:

Install-Package AOTMapper

Полный код проекта можно посмотреть тут.

Я долго думал как это можно сделать по другому и в конце-концов сошелся на мысли, что это не так уж и плохо, поскольку это решает некоторые неудобства, которые мучили меня при использовании AutoMapper.

Достаточно лишь посмотреть какие методы расширения уже есть. Во первых, мы получаем разные методы расширения для разных типов, в следствии чего, для некого абстрактного типа User мы можем очень легко при помощи IntelliSense-а узнать какие мапинги уже реализованы без необходимости искать тот самый файл, где прописаны наши мапинги.

Я понимаю что разработчики AutoMapper потратили много усилий на оптимизацию вызова, но дополнительные затраты всеравно есть. Во вторых, в рантайме это просто метод расширение и таким образом мы избегаем любых накладных расходов связанных с вызовом нашего мапера. Сам бенчмарк можно посмотреть в репозиторий, а результаты замеров ниже. Мой небольшой бенчмарк показал что в среднем это 140-150ns на вызов, без учета времени на инициализацию.

Кроме того к преимуществам данного мапера можно отнести то, что он вообще не требует времени на инициализацию при запуске приложения, что может быть полезным в больших приложениях.

Сам анализатор имеет следующий вид(упуская обвязочный код):

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

Все что он делает это проверяет тот ли это метод который нам нужен, извлекает тип из сущности на которой вызван MapTo<> вместе из первым параметром обобщенного метода и генерит диагностическое сообщение.

Здесь мы достает информацию о типах над которыми будем запускать кодогенерацию. Оно уже в свою очередь будет обработано внутри AOTMapperCodeFixProvider. После чего вызываем AOTMapperGenerator который сгенерит нам файл с методом расширением. Потом заменяем вызов MapTo<> на конкретную реализацию.

В коде это имеет следующий вид:

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 изменяет входящий проект создавая файлы с мапингами между типами.
Сделано это следующим образом:

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

В планах придумать способ как добавить возможность конфигурации. Итого, у нас есть мапер, который работает прямо во время написания кода, а потом ничего не остается от его рантайма. Кроме того, добавить возможность отслеживать изменения в типах. Например, конфигурировать шаблоны для имен генерированых методов и указывать директорию куда сохранять. У меня есть идея как это можно организовать, но подозреваю что это может быть заметно в плане потреблении ресурсов и пока было решено по временить с этим.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть