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

habr.png

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 это интерфейс automaper’a в котором определены методы ForMember, ForAll ()… все эти методы возвращают возвращают this (Fluent Api).

Мы вернем 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> нельзя, тогда AutoMapper будет сопоставлять все свойства DTO типу object

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

© Habrahabr.ru