Автомаппер для бедных

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

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

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

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

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.

@kemsky
08.02.2023 05:26 UTC
Первоисточник

Комментарии

@Chuvi
08.02.2023 04:39 UTC
+2

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

@LeshaRB
08.02.2023 06:24 UTC
+2

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

@superangrypinguinus
08.02.2023 07:00 UTC
+1

Об AutoMapper же

@MrNutz
08.02.2023 09:07 UTC
+1

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

@kemsky
08.02.2023 11:27 UTC
0

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

@alexdesyatnik
08.02.2023 09:04 UTC
-1

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

@musisimaru
08.02.2023 12:41 UTC
0

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

06.04.2023 10:39 UTC
0

Из бенчмарков что я смотрел mapperly самый производительный, и по скорости + аллокациям практически идентичен мапингу ручками. AutoMapper значительно уступал.

06.04.2023 18:22 UTC
0

Все зависит от фич, у мапперов не большой выбор: либо собирать выражения и компилировать в рантайме (дает тормоза на первом использовании), либо использовать сурс-генераторы (производительность сразу на уровне). Конкретно mapperly не умеет проекции из IQueryable, только в следующем релизе добавят.

@zerg903
08.02.2023 14:04 UTC
+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

@kemsky
08.02.2023 14:12 UTC
+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.

09.02.2023 05:05 UTC
0

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

09.02.2023 05:30 UTC
+1

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

@Dansoid
08.02.2023 14:48 UTC
0

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

@kemsky
08.02.2023 16:23 UTC
0

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

@amadonus
09.02.2023 05:31 UTC
0

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

@kemsky
09.02.2023 05:34 UTC
+1

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

10.02.2023 05:07 UTC
0

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

@Vanirn
10.02.2023 08:42 UTC
+1

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

@posledam
25.02.2023 04:26 UTC
0

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

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

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

25.02.2023 15:18 UTC
0

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