Адаптируем AutoMapper под себя
AutoMapper один из основных инструментов применяемых в разработке Enterprise приложений, поэтому хочется писать как можно меньше кода определяя маппинг сущностей.
Мне не нравится дублирование в MapFrom при широких проекциях.
CreateMap()
.ForMember(x => x.Name, s => s.MapFrom(x => x.Identity.Passport.Name))
.ForMember(x => x.Surname, s => s.MapFrom(x => x.Identity.Passport.Surname))
.ForMember(x => x.Age, s => s.MapFrom(x => x.Identity.Passport.Age))
.ForMember(x => x.Number, s => s.MapFrom(x => x.Identity.Passport.Number))
Я бы хотел переписать так:
CreateMap()
.From(x=>x.IdentityCard.Passport).To()
ProjectTo
AutoMapper умеет строить маппинг как в памяти, так и транслировать в SQL, он дописывает Expression, делая проекцию в DTO по правилам, которые вы описали в профайлах.
EntityQueryable.Select(dtoPupil => new PupilDto() {
Name = dtoPupil.Identity.Passport,
Surname = dtoPupil.Identity.Passport.Surname})
80% процентов маппинга, который приходится писать мне — маппинг который достраивает Expression из IQueryble.
Это очень удобно:
public ActionResult> GetAdultPupils(){
var result = _context.Pupils
.Where(x=>x.Identity.Passport.Age >= 18 && ...)
.ProjectTo().ToList();
return result;
}
В декларативном стиле мы сформировали запрос к таблице Pupils, добавили фильтрацию, спроецировали в нужный DTO и вернули клиенту, так можно записать все read методы простого CRUD интерфейса.И все это будет выполнено в на уровне базы данных.
Правда, в серьезных приложениях такие action’ы вряд-ли будут удовлетворять клиентов.
Минусы AutoMapper’a
1) Он очень многословен, при «широком» маппинге приходится писать правила, которые не умещаются на одной строчке кода.
Профайлы разрастаются и превращаются в архивы кода, который один раз написан и изменяется только при рефакторинге наименований.
2) Если использовать маппинг по конвенции, теряется лаконичность наименования
свойств в DTO:
public class PupilDto
{
// Сущность Pupil связана один к одному с сущностью IdentityCard
// IdentityCard один к одному с Passport
public string IdentityCardPassportName { get; set; }
public string IdentityCardPassportSurname { get; set; }
}
3) Отсутствие типобезопасности
1 и 2 — неприятные моменты, но с ними можно смириться, а вот с отсутствием типобезопасности при регистрации смириться уже сложнее, это не должно компилироваться:
// Name - string
// Age - int
ForMember(x => x.Age, s => s.MapFrom(x => x.Identity.Passport.Name)
О таких ошибках мы хотим получать информацию на этапе компиляции, а не в run-time.
С помощью extention оберток устраним эти моменты.
Пишем обертку
Почему регистрация должна быть написана таким образом?
CreateMap()
.ForMember(x => x.Name, s => s.MapFrom(x => x.Identity.Passport.Name))
.ForMember(x => x.Surname, s => s.MapFrom(x => x.Identity.Passport.Surname))
.ForMember(x => x.Age, s => s.MapFrom(x => x.Identity.Passport.Age))
.ForMember(x => x.House, s => s.MapFrom(x => x.Address.House))
.ForMember(x => x.Street, s => s.MapFrom(x => x.Address.Street))
.ForMember(x => x.Country, s => s.MapFrom(x => x.Address.Country))
.ForMember(x => x.Surname, s => s.MapFrom(x => x.Identity.Passport.Age))
.ForMember(x => x.Group, s => s.MapFrom(x=>x.EducationCard.StudyGroup.Number))
Вот так намного лаконичнее:
CreateMap()
// маппинг по конвенции
// PassportName = Passport.Name, PassportSurname = Passport.Surname
.From(x => x.IdentityCard.Passport).To()
// House,Street,Country - по конвенции
.From(x => x.Address).To()
// первый параметр кортежа - свойство DTO, второй - сущности
.From(x => x.EducationCard.Group).To((x => x.Group,x => x.Number));
Метод To будет принимать кортежи, если понадобится указать правила маппинга
IMapping
Мы вернем wrapper чтобы запомнить Expression из метода From
public static MapperExpressionWrapper
From
(this IMappingExpression mapping,
Expression> expression) =>
new MapperExpressionWrapper(mapping, expression);
Теперь программист написав метод From сразу увидит перегрузку метода To, тем самым мы подскажем ему API, в таких случаях мы можем осознать все прелести extension методов, мы расширили поведение, не имея write доступ к исходникам автомаппера
Типизируем
Реализация типизированного метода To сложнее.
Попробуем спроектировать этот метод, нам нужно максимально разбить его на части и вынести всю логику в другие методы. Сразу условимся, что мы ограничим количество параметров-кортежей десятью.
Когда в моей практике встречается подобная задача, я сразу смотрю в сторону Roslyn, не хочется писать множество однотипных методов и заниматься Copy Paste, их проще сгенерировать.
В этом нам помогут generic’и. Нужно сгенерировать 10 методов c различным числом generic’ов и параметров
Первый подход к снаряду был немного другой, я хотел ограничить возвращаемые типы лямбд (int, string, boolean, DateTime) и не использовать универсальные типы.
Сложность в том, что даже для 3 параметров нам придется генерировать 64 различные перегрузки, а при использовании generic всего 1:
IMappingExpression To(
this MapperExpressionWrapper mapperExpressionWrapper,
(Expression>, Expression>) arg0,
(Expression>, Expression>) arg1,
(Expression>, Expression>) arg2,
(Expression>, Expression>) arg3)
{
...
}
Но это не главная проблема, мы же генерируем код, это займет определенное время и мы получим весь набор необходимых методов.
Проблема в другом, ReSharper не подхватит столько перегрузок и просто откажется работать, вы лишитесь Intellisience и подгрузите IDE.
Реализуем метод принимающий один кортеж:
public static IMappingExpression To
(this
MapperExpressionWrapper mapperExpressionWrapper,
(Expression>, Expression>) arg0)
{ // регистрация по конвенции
RegisterByConvention(mapperExpressionWrapper);
// регистрация по заданному expreession
RegisterRule(mapperExpressionWrapper, arg0);
// вернем IMappingExpression,чтобы далее можно было применить
// любые другие extension методы
return mapperExpressionWrapper.MappingExpression;
}
Сначала проверим для каких свойств можно найти маппинг по конвенции, это довольно простой метод, для каждого свойства в DTO ищем путь в исходной сущности. Методы придется вызывать рефлексивно, потому что нужно получить типизированную лямбду, а ее тип зависит от prop.
Регистрировать лямбду типа Expression
private static void RegisterByConvention(
MapperExpressionWrapper mapperExpressionWrapper)
{
var properties = typeof(TDest).GetProperties().ToList();
properties.ForEach(prop =>
{
// mapperExpressionWrapper.FromExpression = x=>x.Identity.Passport
// prop.Name = Name
// ruleByConvention Expression> x=>x.Identity.Passport.Name
var ruleByConvention = _cachedMethodInfo
.GetMethod(nameof(HelpersMethod.GetRuleByConvention))
.MakeGenericMethod(typeof(TSource), typeof(TProjection), prop.PropertyType)
.Invoke(null, new object[] {prop, mapperExpressionWrapper.FromExpression});
if (ruleByConvention == null) return;
//регистрируем
mapperExpressionWrapper.MappingExpression.ForMember(prop.Name,
s => s.MapFrom((dynamic) ruleByConvention));
});
}
RegisterRule получает кортеж, который задает правила маппинга, в нем нужно «соединить»
FromExpression и expression, переданный в кортеж.
В этом нам поможет Expression.Invoke, EF Core 2.0 не поддерживал его, более поздние версии начали поддерживать. Он позволит сделать «композицию лямбд»:
Expression> from = x=>x.EducationCard.StudyGroup;
Expression> @for = x=>x.Number;
//invoke = x=>x.EducationCard.StudyGroup.Number;
var composition = Expression.Lambda>(
Expression.Invoke(@for,from.Body),from.Parameters.First())
Метод RegisterRule:
private static void RegisterRule mapperExpressionWrapper,
(Expression>, Expression>) rule)
{
//rule = (x=>x.Group,x=>x.Number)
var (from, @for) = rule;
// заменяем интерполяцию на конкатенацию строк
@for = (Expression>) _interpolationReplacer.Visit(@for);
//mapperExpressionWrapper.FromExpression = (x=>x.EducationCard.StudyGroup)
var result = Expression.Lambda>(
Expression.Invoke(@for, mapperExpressionWrapper.FromExpression.Body),
mapperExpressionWrapper.FromExpression.Parameters.First());
var destPropertyName = from.PropertiesStr().First();
// result = x => Invoke(x => x.Number, x.EducationCard.StudyGroup)
// можно читать, как result = x=>x.EducationCard.StudyCard.Number
mapperExpressionWrapper.MappingExpression
.ForMember(destPropertyName, s => s.MapFrom(result));
}
Метод To спроектирован так, чтобы его легко было расширять при добавлении параметров-кортежей. При добавлении в параметры еще одного кортежа, нужно добавить еще один generic, параметр, и вызов метода RegisterRule для нового параметра.
Пример для двух параметров:
IMappingExpression To
(this MapperExpressionWrappermapperExpressionWrapper,
(Expression>, Expression>) arg0,
(Expression>, Expression>) arg1)
{
RegisterByConvention(mapperExpressionWrapper);
RegisterRule(mapperExpressionWrapper, arg0);
RegisterRule(mapperExpressionWrapper, arg1);
return mapperExpressionWrapper.MappingExpression;
}
Используем CSharpSyntaxRewriter, это визитор который проходится по узлам синтаксического дерева. За основу возьмем метод с To с одним аргументом и в процессе обхода добавим generic, параметр и вызов RegisterRule;
public override SyntaxNode VisitMethodDeclaration(MethodDeclarationSyntax node)
{
// Если это не метод To
if (node.Identifier.Value.ToString() != "To")
return base.VisitMethodDeclaration(node);
// returnStatement = return mapperExpressionWrapper.MappingExpression;
var returnStatement = node.Body.Statements.Last();
//beforeReturnStatements:
//[RegisterByConvention(mapperExpressionWrapper),
// RegisterRule(mapperExpressionWrapper, arg0)]
var beforeReturnStatements = node.Body.Statements.SkipLast(1);
//добавляем вызов метода RegisterRule перед returStatement
var newBody = SyntaxFactory.Block(
beforeReturnStatements.Concat(ReWriteMethodInfo.Block.Statements)
.Concat(new[] {returnStatement}));
// возвращаем перезаписанный узел дерева
return node.Update(
node.AttributeLists, node.Modifiers,
node.ReturnType,
node.ExplicitInterfaceSpecifier,
node.Identifier,
node.TypeParameterList.AddParameters
(ReWriteMethodInfo.Generics.Parameters.ToArray()),
node.ParameterList.AddParameters
(ReWriteMethodInfo.AddedParameters.Parameters.ToArray()),
node.ConstraintClauses,
newBody,
node.SemicolonToken);
}
В ReWriteMethodInfo лежат сгенерированные синтаксические узлы дерева, которые необходимо добавить. После этого мы получим список, состоящий их 10 объектов с типом MethodDeclarationSyntax (синтаксическое дерево, представляющее метод).
На следующем шаге возьмем класс, в котором лежит шаблонный метод To и запишем в него все новые методы используя другой Visitor, в котором переопределим VisitClassDeclatation.
Метод Update метод позволяет редактировать существующий узел дерева, он под капотом перебирает все переданные аргументы, и если хотя бы один отличается от исходного создает новый узел.
public override SyntaxNode VisitClassDeclaration(ClassDeclarationSyntax node)
{
//todo refactoring it
return node.Update(
node.AttributeLists,
node.Modifiers,
node.Keyword,
node.Identifier,
node.TypeParameterList,
node.BaseList,
node.ConstraintClauses,
node.OpenBraceToken,
new SyntaxList(ReWriteMethods),
node.CloseBraceToken,
node.SemicolonToken);
}
В конце концов мы получим SyntaxNode — класс с добавленными методами, запишем узел в новый файл.Теперь у нас появились перегрузки метода To принимающие от 1 до 10 кортежей и намного более лаконичный маппинг.
Точка расширения
Посмотрим на AutoMapper, как на нечто большее. Queryable Provider не может разобрать достаточно много запросов, и определенную часть этих запросов можно выполнить переписав по-другому. Вот тут в игру вступает AutoMapper, extension’ы это точка расширения, куда мы можем добавить свои правила.
Применим visitor из предыдущей статьи заменяющий интерполяцию строк конкатенацией в методе RegusterRule.В итоге все expression’ы, определяющие маппинг из сущности, пройдут через этот visitor, тем самым мы избавимся от необходимости каждый раз вызывать ReWrite.Это не панацея, единственное, чем мы можем управлять — проекция, но это все-равно облегчает жизнь.
Также мы можем дописать некоторые удобные extention’ы, например, для маппинга по условию:
CreateMap()
.ToIf(x => x.Age, x => x < 18, x => $"{x.Age}", x => "Adult")
Главное не заиграться с этим и не начать переносить сложную логику на уровень отображения
Github