Адаптируем AutoMapper под себя

в 9:33, , рубрики: .net, automapper, C#, roslyn, Программирование

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

Автор: brager17

Источник

* - обязательные к заполнению поля