AutoMapper один из основных инструментов применяемых в разработке Enterprise приложений, поэтому хочется писать как можно меньше кода определяя маппинг сущностей.
Мне не нравится дублирование в MapFrom при широких проекциях.
CreateMap<Pupil, PupilDto>() .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<Pupil, PupilDto>() .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<IEnumerable<PupilDto>> GetAdultPupils(){ var result = _context.Pupils .Where(x=>x.Identity.Passport.Age >= 18 && ...) .ProjectTo<PupilDto>().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<Pupil, PupilDto>() .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<Pupil,PupilDto>() // маппинг по конвенции // 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<TSource,TDest> это интерфейс automaper'a в котором определены методы ForMember,ForAll()… все эти методы возвращают возвращают this (Fluent Api).
Мы вернем wrapper чтобы запомнить Expression из метода From
public static MapperExpressionWrapper<TSource, TDest, TProjection> From<TSource, TDest, TProjection> (this IMappingExpression<TSource, TDest> mapping, Expression<Func<TSource, TProjection>> expression) => new MapperExpressionWrapper<TSource, TDest, TProjection>(mapping, expression);
Теперь программист написав метод From сразу увидит перегрузку метода To, тем самым мы подскажем ему API, в таких случаях мы можем осознать все прелести extension методов, мы расширили поведение, не имея write доступ к исходникам автомаппера
Типизируем
Реализация типизированного метода To сложнее.
Попробуем спроектировать этот метод, нам нужно максимально разбить его на части и вынести всю логику в другие методы. Сразу условимся, что мы ограничим количество параметров-кортежей десятью.
Когда в моей практике встречается подобная задача, я сразу смотрю в сторону Roslyn, не хочется писать множество однотипных методов и заниматься Copy Paste, их проще сгенерировать.
В этом нам помогут generic'и. Нужно сгенерировать 10 методов c различным числом generic'ов и параметров
Первый подход к снаряду был немного другой, я хотел ограничить возвращаемые типы лямбд (int,string,boolean,DateTime) и не использовать универсальные типы.
Сложность в том, что даже для 3 параметров нам придется генерировать 64 различные перегрузки, а при использовании generic всего 1:
IMappingExpression<TSource, TDest> To<TSource, TDest, TProjection,T,T1, T2, T3>( this MapperExpressionWrapper<TSource,TDest,TProjection> mapperExpressionWrapper, (Expression<Func<TDest, T>>, Expression<Func<TProjection, T>>) arg0, (Expression<Func<TDest, T1>>, Expression<Func<TProjection, T1>>) arg1, (Expression<Func<TDest, T2>>, Expression<Func<TProjection, T2>>) arg2, (Expression<Func<TDest, T3>>, Expression<Func<TProjection, T3>>) arg3) { ... }
Но это не главная проблема, мы же генерируем код, это займет определенное время и мы получим весь набор необходимых методов.
Проблема в другом, ReSharper не подхватит столько перегрузок и просто откажется работать, вы лишитесь Intellisience и подгрузите IDE.
Реализуем метод принимающий один кортеж:
public static IMappingExpression<TSource, TDest> To <TSource, TDest, TProjection, T>(this MapperExpressionWrapper<TSource,TDest,TProjection> mapperExpressionWrapper, (Expression<Func<TDest, T>>, Expression<Func<TProjection, T>>) arg0) { // регистрация по конвенции RegisterByConvention(mapperExpressionWrapper); // регистрация по заданному expreession RegisterRule(mapperExpressionWrapper, arg0); // вернем IMappingExpression,чтобы далее можно было применить // любые другие extension методы return mapperExpressionWrapper.MappingExpression; }
Сначала проверим для каких свойств можно найти маппинг по конвенции, это довольно простой метод, для каждого свойства в DTO ищем путь в исходной сущности. Методы придется вызывать рефлексивно, потому что нужно получить типизированную лямбду, а ее тип зависит от prop.
Регистрировать лямбду типа Expression<Func<TSource,object>> нельзя, тогда AutoMapper будет сопоставлять все свойства DTO типу object
private static void RegisterByConvention<TSource, TDest, TProjection>( MapperExpressionWrapper<TSource, TDest, TProjection> mapperExpressionWrapper) { var properties = typeof(TDest).GetProperties().ToList(); properties.ForEach(prop => { // mapperExpressionWrapper.FromExpression = x=>x.Identity.Passport // prop.Name = Name // ruleByConvention Expression<Func<Pupil,string>> 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<Func<Pupil,StudyGroup>> from = x=>x.EducationCard.StudyGroup; Expression<Func<StudyGroup,int>> @for = x=>x.Number; //invoke = x=>x.EducationCard.StudyGroup.Number; var composition = Expression.Lambda<Func<Pupil, string>>( Expression.Invoke(@for,from.Body),from.Parameters.First())
Метод RegisterRule:
private static void RegisterRule<TSource, TDest, TProjection, T (MapperExpressionWrapper<TSource,TDest,TProjection> mapperExpressionWrapper, (Expression<Func<TDest, T>>, Expression<Func<TProjection, T>>) rule) { //rule = (x=>x.Group,x=>x.Number) var (from, @for) = rule; // заменяем интерполяцию на конкатенацию строк @for = (Expression<Func<TProjection, T>>) _interpolationReplacer.Visit(@for); //mapperExpressionWrapper.FromExpression = (x=>x.EducationCard.StudyGroup) var result = Expression.Lambda<Func<TSource, T>>( 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<TSource, TDest> To<TSource, TDest, TProjection, T, T1> (this MapperExpressionWrapper<TSource,TDest,TProjection>mapperExpressionWrapper, (Expression<Func<TDest, T>>, Expression<Func<TProjection, T>>) arg0, (Expression<Func<TDest, T1>>, Expression<Func<TProjection, T1>>) 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<MemberDeclarationSyntax>(ReWriteMethods), node.CloseBraceToken, node.SemicolonToken); }
В конце концов мы получим SyntaxNode — класс с добавленными методами, запишем узел в новый файл.Теперь у нас появились перегрузки метода To принимающие от 1 до 10 кортежей и намного более лаконичный маппинг.
Точка расширения
Посмотрим на AutoMapper, как на нечто большее. Queryable Provider не может разобрать достаточно много запросов, и определенную часть этих запросов можно выполнить переписав по-другому. Вот тут в игру вступает AutoMapper, extension'ы это точка расширения, куда мы можем добавить свои правила.
Применим visitor из предыдущей статьи заменяющий интерполяцию строк конкатенацией в методе RegusterRule.В итоге все expression'ы, определяющие маппинг из сущности, пройдут через этот visitor, тем самым мы избавимся от необходимости каждый раз вызывать ReWrite.Это не панацея, единственное, чем мы можем управлять — проекция, но это все-равно облегчает жизнь.
Также мы можем дописать некоторые удобные extention'ы, например, для маппинга по условию:
CreateMap<Passport,PassportDto>() .ToIf(x => x.Age, x => x < 18, x => $"{x.Age}", x => "Adult")
Главное не заиграться с этим и не начать переносить сложную логику на уровень отображения
Github
