Pull to refresh

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

Level of difficultyMedium
Reading time6 min
Views7.8K

После первого знакомства с библиотекой 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.

Tags:
Hubs:
Total votes 3: ↑2 and ↓1+1
Comments21

Articles