После первого знакомства с библиотекой AutoMapper многие испытали вау‑эффект. Круто, можно маппить обьекты, можно писать запросы поверх DTO (проекции) и все магическим образом работает (ну или приходится верить, что работает). Это ли не чудо?

Однако, с опытом, стали очевидны и недостатки использования этой библиотеки, а их достаточное количество:

  • Неявный код — авто‑матический маппинг, маппинг по соглашениям. Иронично, но это является рекомендуемым способом. В этом сценарии маппинг осуществляется банально по именам свойств или полей и дополнительно работают соглашения. Если не очень удачно назвать свойства или дать им ошибочные типы — никакая встроенная валидация не поможет. Фактически, каждый маппинг надо тестировать, что само по себе будет еще тем приключением. Можно, да, можно, написать обвязку, с помощью которой можно провалидировать многие вещи, но все это сопряжено с другими недостатками этой библиотеки, см. далее.

  • Поддержка. Программисты написавшие эту библиотеку не переносят инакомыслия и являются истиной в первой и последней инстанции, да‑да! Систематически ломается API. Просто задумайтесь, тысячи проектов новых и не очень зависят от этой библиотеки, однако авторы не в состоянии даже применить стратегию deprecation. Характерным примером является один из последних релизов, в котором помимо исправления критического бага на платформе.NET 7 содержались еще и обратно‑несовместимые изменения. Последней каплей, побудившей меня по‑размышлять в целом на эту тему, стал вот это баг: https://github.com/AutoMapper/AutoMapper/issues/4205. Ради «косметики» была сломана совместимость с EF6 (практическая ценность этих изменений — около нуля). Сказано было `Well, we target EF Core now.`, но попробуйте найти это,скажем, в каком‑нибудь логе или гайде (когда, да и зачем, почему?). Конечно, ну кто я такой, чтобы говорить им что делать, и я не буду.

  • Наблюдая за развитием этой библиотеки, я замечаю как бессистемно добавляются и исчезают методы, как поля меняют область видимости. При желании, можно найти на стековерфлоу взаимоисключающе советы от авторов. При этом с точки зрения архитектуры в этом нет никакой необходимости, можно было бы просто разрешить некоторые вещи, которые автомаппер и так использует внутри себя. Конкретно в моем немаленьком монолите используются сотни маппингов (так вышло, проекту около 7 лет) и практически любое изменение в автомаппере что‑нибудь да ломает. И чем больше вы попытаетесь его кастомизировать, тем сильнее будете страдать при обновлениях.

  • API это еще один недостаток. За все эти годы, можно было довести эту часть до совершенства, но... Сам по себе маппинг может использоваться просто из кода либо внутри LINQ при запросах в базу либо и там и там, очевидно? А вот и нет, авторы дают только две опции из трех: только LINQ и только код (и может быть LINQ, если не падает). То есть, нет возможности на уровне API даже толком определить, что же мы хотим и соответственно это никак не проверяется, а что‑то и просто нельзя сделать.

  • Я гарантирую, что если вы не покрыли вдоль и поперек все тестами, то ваши маппинги содержат ошибку(и). Это может быть неожиданная работа соглашений, некорректные типы, которые только чудом пока еще работают, может быть, что вы маппите лишнее. Как только вы начнете прописывать явно свои маппинги, то минимум пару открытий вы точно сделаете.

Маппинг можно разделить на две части: проекции и обратные им действия (что я бы и назвал собственно маппингом). Далее я буду писать только про проекции, в принципе, использовать автомаппер без проекций мне кажется нет смысла вообще.

Первый способ для самых бедных заключается в использовании методов‑расширений:

public static Dto ToDto(this Entity entity)
{
    return new Dto
    {
        Id = entity.Id, 
        Name = entity.Name, 
        AnotherDto = new AnotherDto
        {
            Id = entity.Another.Id
        }
    };
}

ОК, но как быть с запросами? Ну можно сделать метод-расширение для IQueryable<>, только переиспользовать его, например, для IEnumerable<> будет проблематично. В примере выше, маппинг `AnotherDto` прописан прямо в теле метода и если он где-то используется еще, то нужно искать способ объявить эту логику в одном месте. В случае обычного метода можно вынести эту часть в еще один метод-расширение, но вот с деревьями выражений этот номер не пройдет (как не будет работать и сам пример), провайдер про наши методы ничего не знает и преобразовать в SQL запрос не сможет. Другими словами, нужна возможность композиции.

Перейдем сразу к делу:

public interface IProjection
{
    LambdaExpression GetProjectToExpression();
}

public readonly struct Projection<TSource, TResult> : IProjection
{
    public Expression<Func<TSource, TResult>> ProjectToExpression => LazyExpression.Value;

    public Func<TSource, TResult> ProjectTo => LazyDelegate.Value;

    private Lazy<Func<TSource, TResult>> LazyDelegate { get; }

    private Lazy<Expression<Func<TSource, TResult>>> LazyExpression { get; }

    public Projection(Expression<Func<TSource, TResult>> expression)
    {
        // visitor и остальное приведу в гисте пожалуй
        LazyExpression = new Lazy<Expression<Func<TSource, TResult>>>(() => (Expression<Func<TSource, TResult>>) new ProjectionSingleVisitor().Visit(expression), LazyThreadSafetyMode.PublicationOnly);

        var lazyExpression = LazyExpression;

        // тут можно использовать на свой страх и риск FastExpressionCompiler
        LazyDelegate = new Lazy<Func<TSource, TResult>>(() => lazyExpression.Value.Compile(), LazyThreadSafetyMode.PublicationOnly);
    }

    internal Projection(Expression<Func<TSource, TResult>> expressionFunc, Func<TSource, TResult> delegateFunc, LazyThreadSafetyMode.PublicationOnly)
    {
        LazyExpression = new Lazy<Expression<Func<TSource, TResult>>>(() => (Expression<Func<TSource, TResult>>) new ProjectionSingleVisitor().Visit(expressionFunc), LazyThreadSafetyMode.PublicationOnly);
        LazyDelegate = new Lazy<Func<TSource, TResult>>(() => delegateFunc);
    }

    LambdaExpression IProjection.GetProjectToExpression()
    {
        return ProjectToExpression;
    }

    public static implicit operator Func<TSource, TResult>(Projection<TSource, TResult> f)
    {
        return f.ProjectTo;
    }

    public static implicit operator Expression<Func<TSource, TResult>>(Projection<TSource, TResult> f)
    {
        return f.ProjectToExpression;
    }
}

public static class ProjectionExtensions
{
    public static IQueryable<TDestination> Projection<TSource, TDestination>(this IQueryable<TSource> queryable, Projection<TSource, TDestination> projection)
    {
        return queryable.Select(projection.ProjectToExpression);
    }

    public static IEnumerable<TDestination> Projection<TSource, TDestination>(this IEnumerable<TSource> enumerable, Projection<TSource, TDestination> projection)
    {
        return enumerable.Select(projection.ProjectTo);
    }
}

Можно обьявить проекции следующим образом:

    public static readonly Projection<Category, LookupDetails> CategoryLookupDetails = new(x => new LookupDetails
    {
        Id = x.Id,
        Name = x.Name,
    });
    
    public static readonly Projection<SubCategory, SubCategoryDetails> SubCategoryDetails = new(x => new SubCategoryDetails
    {
        Id = x.Id,
        Active = x.Active,
        Category = CategoryLookupDetails.ProjectTo(x.Category),
        Name = x.Name,
        Description = x.Description,
        CreatedDate = x.CreatedDate,
        ModificationDate = x.ModificationDate
    });

И использовать в запросах:

    [Retryable]
    public virtual Task<Option<SubCategoryDetails>> GetAsync(long id)
    {
        return Repository
            .Queryable()
            .Projection(SubCategoryDetails)
            .SingleOptionalAsync(x => x.Id == id);
    }

Никто не мешает и просто вызвать SubCategoryDetails.ProjectTo(entity), если на руках есть сущность. Как видите, композиция работает, смешно, но даже сгенерированный SQL практически идентичный по сравнению с автомаппером, отличается только порядок.

Идея, достаточно простая, выражения переписываются и вместо ProjectTo происходит подстановка тела ProjectToExpression.

Способен ли этот код заменить автомаппер целиком? Нет конечно, однако он простой, он ваш и при желании поддается полной кастомизации и добавлению фич.

Важно: эта версия не оптимизирована и даже не проверена до конца.

Ссылка на остальной код: gist.

Комментарии (19)


  1. Chuvi
    00.00.0000 00:00
    +2

    Всё конечно здорово. Статья до последнего держит в напряжении и не раскрывает главную загадку - о какой собственно библиотеке речь?


    1. LeshaRB
      00.00.0000 00:00
      +2

      Я так понимаю AutoMapper


    1. superangrypinguinus
      00.00.0000 00:00
      +1

      Об AutoMapper же


    1. MrNutz
      00.00.0000 00:00
      +1

      Поддерживаю. Хорошим тоном было бы вкратце описать что за библиотека, что умеет или для чего применяется. Чтобы не догадываться и не гуглить.


    1. kemsky Автор
      00.00.0000 00:00

      Почему-то мне показалось это очевидным из названия, добавил ссылку.


  1. alexdesyatnik
    00.00.0000 00:00
    -1

    Не совсем понятен смысл изобретения велосипеда, когда полно альтернатив автомапперу - и через рефлексию, и через кодогенерацию. Тот же Mapster.


    1. musisimaru
      00.00.0000 00:00

      Производительность. Соотношение производительности к геморою использования у этой либы очень неплох


  1. zerg903
    00.00.0000 00:00
    +1

    Я тоже не использую AutoMapper предпочитая явное неявному. Но пример автора, как мне кажется, неудачный, стоило упростить код, оставив только то, что относиться к заявленной теме.

    Было бы достаточно следующего:

    public class Projection<TSource, TResult>
    {
        private readonly Lazy<Func<TSource, TResult>> _lazyDelegate;
    
        public Projection(Expression<Func<TSource, TResult>> expression)
        {
            Expression = expression;
            _lazyDelegate = new Lazy<Func<TSource, TResult>>(Expression.Compile, LazyThreadSafetyMode.PublicationOnly);
        }
    
        internal Expression<Func<TSource, TResult>> Expression { get; }
    
        internal Func<TSource, TResult> Delegate => _lazyDelegate.Value;
    
        public TResult Map(TSource source) => Delegate(source);
    }
    
    public static class ProjectionExtensions
    {
        public static IQueryable<TDestination> Projection<TSource, TDestination>(
          this IQueryable<TSource> queryable, Projection<TSource, TDestination> projection)
        {
            return queryable.Select(projection.Expression);
        }
    
        public static IEnumerable<TDestination> Projection<TSource, TDestination>(
          this IEnumerable<TSource> enumerable, Projection<TSource, TDestination> projection)
        {
            return enumerable.Select(projection.Delegate);
        }
    }
    

    Полный код с рабочим примером:
    https://gist.github.com/Zerg903/007967724a9d37f19856b083a1b6bf6e


    1. kemsky Автор
      00.00.0000 00:00
      +1

      Такой подход не даст использовать проекцию в проекции, мне такая возможность нужна, кому-то конечно будет и без нее хорошо (в этом и есть плюс самого подхода).

          public static readonly Projection<SubCategory, SubCategoryDetails> SubCategoryDetails = new(x => new SubCategoryDetails
          {
              Id = x.Id,
              Active = x.Active,
              // тут вложенная проекция:
              Category = CategoryLookupDetails.ProjectTo(x.Category),
              Name = x.Name,
              Description = x.Description,
              CreatedDate = x.CreatedDate,
              ModificationDate = x.ModificationDate
          });
      

      Кроме того, есть случаи когда хотелось бы задать отдельное поведение для делегата, например, при использовании DbFunctions.


      1. tov-vl
        00.00.0000 00:00

        хм, проверил код без использования ProjectionSingleVisitor, как предложил @zerg903, и с вложенными проекциями маппится нормально. ЧЯДНТ?


        1. kemsky Автор
          00.00.0000 00:00
          +1

          EF Core может тихонько свалится в client side evaluation и даже как-то работать.


  1. Dansoid
    00.00.0000 00:00

    Вот я отвечал на StackOverflow: Can I reuse code for selecting a custom DTO object for a child property with EF Core?. Как на меня это самый удобный способ работать без Automapper. DelegateDecompiler так вообще упрощает это дело в разы.


    1. kemsky Автор
      00.00.0000 00:00

      Я как-то забыл даже про DelegateDecompiler, тоже вариант, но как по мне более магический, потенциально может затруднить обновление. Надо попробовать.


  1. amadonus
    00.00.0000 00:00

    Как-то упускается супер полезная фича у автомаппера как валидация конфигурации. В процессе активной разработки мне юнит тесты часто сыпят ошибки что новые поля не замаплены


    1. kemsky Автор
      00.00.0000 00:00
      +1

      Валидация автомаппера проверяет только то, что каждое поле как-то замаплено, она не гарантирует, что маппинг будет работать. Такую валидацию можно сделать и для предложенного подхода.


      1. amadonus
        00.00.0000 00:00

        Хотя бы иметь ассерт уже полезно, остальное руками доделать можно


  1. Vanirn
    00.00.0000 00:00
    +1

    После недолгого использования AutoMapper и отпыта более опытных людей, также пришли в выводу что "ручномаппер" удобнее во всех отношениях (:


    1. hVostt
      00.00.0000 00:00

      Ручной маппер это полный контроль, что не может не радовать. Чем меньше магии, тем лучше. Но чем больше кода пишешь, тем чаще ловишь себя на мысли, что довольно много времени занимаешь тупым обезьяньим трудом. Трудом, который способна сделать за нас машина. Т.е. с одной стороны, мы автоматизируем работу людей с помощью разработки ПО. С другой стороны, как сапожник без сапог, жмём много кнопок там, где можно было бы их не жать.

      Мы проверили как в старой рекламе на свеженьком небольшом проекте, just for fan. Примерно половину проекта писали на ручном маппинге. Вторую половину на Mapster-е в режиме "маппинг на пределе". Результаты. На второй половине проекта, разработка ведётся ощутимо быстрее. Меньше ошибок, ощутимо! Особенно при развитии модели данных. Больше времени посвящается интересным задачам, просто дышится легче.

      Т.е., резюмируя, контроль это здорово! Каждый байт под учётом, каждая буква. И да, по опыту работы с AutoMapper иногда случалась необходимость решить сложный маппинг, который "на ручнике" просто бы писался как есть, и такие мысли возникали, может ну его? Но на длительной дистанции, обезьяний труд зло.


      1. kemsky Автор
        00.00.0000 00:00

        А что означает "Mapster-е в режиме "маппинг на пределе""?