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

    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

    • +11
    • 3,4k
    • 2
    Поделиться публикацией

    Комментарии 2

      +3
      3) Отсутствие типобезопасности

      Просто написать UnitTest, который делает
      Mapper.Configuration.AssertConfigurationIsValid();

      Заодно проверяется ситуация, когда у источника и получателя свойство называлось одинаково и маппинг был не нужен, а потом одно из свойств зарефакторили, но маппинг не построили.
        +4

        А не проще во всех этих примерах было тупо написать? Вообще без автомэппера?

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое