- PVSM.RU - https://www.pvsm.ru -
В прошлой статье [1] я описал способ организации кодогенераци при помощи Roslyn. Тогдашней задачей было продемонстрировать общий подход. Сейчас я хочу реализовать то, что будет иметь реальное применение.
И так, кому интересно посмотреть на то как можно сделать библиотеку на подобие AutoMapper [2] прошу под кат.
Первым делом, думаю стоит описать то, как будет работать мой Ahead of Time Mapper(AOTMapper). Точкой входа нашего мапера будет служить обобщенный метод расширение(generic extention method) MapTo<>. Анализатор будет искать его и предлагать реализовать метод расширение MapToUser, где User это тип который передан в MapTo<>.
Как пример возьмем следующее классы:
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; }
}
}
Сгенерённый 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
Полный код проекта можно посмотреть тут [3].
Я долго думал как это можно сделать по другому и в конце-концов сошелся на мысли, что это не так уж и плохо, поскольку это решает некоторые неудобства, которые мучили меня при использовании AutoMapper.
Во первых, мы получаем разные методы расширения для разных типов, в следствии чего, для некого абстрактного типа User мы можем очень легко при помощи IntelliSense-а узнать какие мапинги уже реализованы без необходимости искать тот самый файл, где прописаны наши мапинги. Достаточно лишь посмотреть какие методы расширения уже есть.
Во вторых, в рантайме это просто метод расширение и таким образом мы избегаем любых накладных расходов связанных с вызовом нашего мапера. Я понимаю что разработчики AutoMapper потратили много усилий на оптимизацию вызова, но дополнительные затраты всеравно есть. Мой небольшой бенчмарк показал что в среднем это 140-150ns на вызов, без учета времени на инициализацию. Сам бенчмарк можно посмотреть в репозиторий, а результаты замеров ниже.
| 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 |
Кроме того к преимуществам данного мапера можно отнести то, что он вообще не требует времени на инициализацию при запуске приложения, что может быть полезным в больших приложениях.
Сам анализатор имеет следующий вид(упуская обвязочный код):
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. Здесь мы достает информацию о типах над которыми будем запускать кодогенерацию. Потом заменяем вызов MapTo<> на конкретную реализацию. После чего вызываем AOTMapperGenerator который сгенерит нам файл с методом расширением.
В коде это имеет следующий вид:
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;
}}
}}
";
}
Итого, у нас есть мапер, который работает прямо во время написания кода, а потом ничего не остается от его рантайма. В планах придумать способ как добавить возможность конфигурации. Например, конфигурировать шаблоны для имен генерированых методов и указывать директорию куда сохранять. Кроме того, добавить возможность отслеживать изменения в типах. У меня есть идея как это можно организовать, но подозреваю что это может быть заметно в плане потреблении ресурсов и пока было решено по временить с этим.
Автор: byme
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/323730
Ссылки в тексте:
[1] статье: https://habr.com/ru/post/455952
[2] AutoMapper: https://github.com/AutoMapper/AutoMapper
[3] тут: https://github.com/byme8/AOTMapper
[4] Источник: https://habr.com/ru/post/459771/?utm_campaign=459771&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.