Вы знали, что AutoMapper и MediatR создал один и тот же человек?

Джимми Богард создал две крайне обсуждаемые и спорные темы в .NET разработке. Если с MediatR уже разобрались, то c AutoMapper также хотелось бы расставить все точки над "ё".

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


В интернете часто можно встретить такое мнение:

He got a point!
He got a point!

Или такие восклицания о помощи:

Так начинается типичный тред про AutoMapper на Reddit
Так начинается типичный тред про AutoMapper на Reddit

Начнём с ответа на вопрос:

Что хотел сказать автор?

Стоит уделить внимание тому, в какие времена родилась библиотека. На дворе был конец нулевых. Только начиналась эпоха MVC фреймворков. На свет уже появились:

  • Ruby on Rails;

  • Django;

  • ASP.NET MVC.

При этом, в отличие от первых двух, для детища Microsoft не было ни советов, ни руководств по поводу того, что обозначает буква "M". Разработчики были вольны проектировать модели как угодно, от того было сложно выбирать. Сделать модель сущностью? А может объектом доступа к данным (data access object)? Или DTO подойдёт?

Поэтому, каждая команда разработки самостоятельно определяла правила по созданию моделей. Команда Джимми решила делать модели с привязкой к представлениям, получив некий ViewModel (не путать с МVVM). Поэтому их правила были следующими:

  1. Все View строго типизированы.

  2. Соответствие ViewModel-View - один к одному (для каждого View свой ViewModel).

  3. View определяет структуру ViewModel. Во ViewModel передаётся только то, что должно быть отображено на View.

  4. ViewModel содержит данные и поведение относящиеся только к своему View.

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

То и дело стреляли NRE, было невыносимо писать тесты, а в планах тем временем ещё больше экранов. Возможно, даже до тысячи. Так и появился на свет AutoMapper - как решение задачи по расчистке этого бардака.

Философия Automapper

Появившийся инструмент сделал следующие вещи:

  • Обязал типы, в которые происходит отображение, соблюдать некоторую конвенцию;

  • Спрятал возникновение NRE так сильно, насколько это возможно;

  • Предоставил возможность простого тестирования подобного функционала.

На чём это базируется? Лучше автора библиотеки не скажет никто:

AutoMapper works because it enforces a convention. It assumes that your destination types are a subset of the source type. It assumes that everything on your destination type is meant to be mapped. It assumes that the destination member names follow the exact name of the source type. It assumes that you want to flatten complex models into simple ones.

All of these assumptions come from our original use case - view models for MVC, where all of those assumptions are in line with our view model design. With AutoMapper, we could enforce our view model design philosophy.

And this is why our usage of AutoMapper has stayed so steady over the years - because our design philosophy for view models hasn't changed.

Обобщая вышесказанное, можно сделать вывод, что AutoMapper - это инструмент для решения конкретной задачи при специфичном подходе к разработке, установленном на проекте. При этом, чтобы оно работало как задумано, при проектировании пользовательских типов данных требуется соблюдать стороннее соглашение.

Соответственно, возникает логичный вопрос:

Вашему проекту это подходит?

Мне кажется, что причина большинства негативных постов про библиотеку, заключается в том, что ответом на этот вопрос было "нет". Но его никто не задавал.

Поэтому, происходят такие ситуации, когда, например, в конфигурацию отображения засовывают алгоритм генерации паролей.

Думаю, такой пример кода не стоит предъявлять в текстовом виде. А то, не дай Бог, скопируют
Думаю, такой пример кода не стоит предъявлять в текстовом виде. А то, не дай Бог, скопируют

Есть специальный чек-лист прямиком от Джимми Богарда, с помощью которого можно проверить, правильно ли вы используете библиотеку.

Однако, даже если всё делается по назначению, то, стоит упомянуть, что

AutoMapper не лишён недостатков

Давайте детально, по пунктам, аргументируем этот тезис.

Обманчивый статический анализ

Статический анализатор сообщает только о том, что некоторые поля модели не используются вообще. Можно пометить их атрибутом [UsedImplicitly], но это лишь попытка уйти от проблемы.

Код, который не задействован ни в какой бизнес-логике, а лишь декларирует данные, гоняющиеся туда-сюда, нельзя удалить по подсказке IDE. Мы сломаем приложение и узнаем об этом лишь после запуска.

И статический анализатор, призванный служить блюстителем порядка и качества на проекте, ничем не поможет. Потому что эта сторонняя библиотека снижает уровень доверия к его отчётам.

Беспомощность навигации кода

Невозможно выяснить какое поле сущности отображается в какое-то другое поле DTO. Дело в том, что кнопка "show usages" не покажет ничего, кроме объявления поля.

AutoMapper ведь работает неявно.

Решением проблемы могло бы быть написание явных конфигураций отображения. Однако, нужен ли весь этот комплексный механизм, основанный на рефлексии, в таком случае?

Сложность отладки

Или практически невозможность.

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

А вдруг мы решим явно описать некую конфигурацию отображения? Грубо говоря, это просто жонглирование методами ForMember и MapFrom. Давайте взглянем на сигнатуру одного из них, она будет выглядеть так:

void MapFrom<TMember>(Expression<Func<TSource, TMember>> sourceMember);

То есть, опять же, это не вычисляемый код, а код, описывающий поведение, потому что на вход ожидается выражение, а не делегат. Даже негде поставить точку останова. И исключение не получится отловить тоже. Например, у нас есть две модельки UserEntity и UserDTO:

public class UserEntity
{
    public string FirstName { get; set; }
    
    public string LastName { get; set; }
    
    public Address Address { get; set; }
}

public class Address
{
    public string City { get; set; }
}

public class UserDTO
{
    public string FullName { get; set; }
}

И вот мы предъявляем некую конфигурацию маппинга:

Mapper.Initialize(cfg =>
{
    cfg.CreateMap<UserEntity, UserDTO>().ForMember(
        x => x.FullName,
        opt => opt.MapFrom(x =>
            $"{x.FirstName} {x.LastName} ({x.Address.City})")
    );
});

Тогда при предъявлении объекта, который ну точно должен вызвать NRE:

var userEntity = new UserEntity()
{
    FirstName = "Cezary",
    LastName = "Piątek",
    Address = null
};
var userDto = Mapper.Map<UserDTO>(userEntity);
Console.WriteLine(JsonConvert.SerializeObject(userDto, Formatting.Indented));

мы получим не NRE:

{
    FullName : null
}

Из-за повсеместного использования выражений и рефлексии в библиотеке возникает и другой интересный кейс. Возьмём следующий кусок кода:

using AutoMapper;
using System;

var config = new MapperConfiguration(cfg =>
{
    cfg.CreateMap<UserSource, UserDestination>()
        .ForMember(dst => dst.Name, opt => opt.MapFrom(src => src.Name.ToLower()));
});
var mapper = config.CreateMapper();

var source = new UserSource("VASYA");

var destination = mapper.Map<UserSource, UserDestination>(source);

Console.WriteLine(destination);

public record UserSource(string Name);
public record UserDestination(string Name);

При конструировании объекта destination поле Name смаппится, а функция ToLower применена не будет:

UserDestination { Name = VASYA }

Слом организации кода

Простых коммерческих проектов не существует. Они могут казаться такими поначалу, но рано или поздно их сложность всё равно будет расти.

И если раньше это была пара ручек, выполняющих SELECT'ы без JOIN'ов, то обязательно всё начнётся с реализации чего-то из следующего:

  1. Форматирование;

  2. Композиция одного большого объекта из нескольких маленьких;

  3. Бизнес-логика, влияющая на условия отображения данных;

  4. Ролевая модель.

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

Впрочем, о том, что это неправильно говорил и автор библиотеки, о чём было упомянуто ранее в статье. Но, хаос в кодовых базах всё ещё продолжает появляться.

Низкая производительность

Чтобы не быть голословным, я написал простейший бенчмарк. Код доступен на GitHub.

Я сравнил быстродействие AutoMapper и другого, одного из альтернативных, способов создания конвертации объекта - метода расширения:

public static class UserModelExtensions
{
    public static User ToUser(this UserModel model) =>
        new(model.FirstName, model.LastName, model.BirthDate, model.Address.ToAddress());
}

public static class AddressModelExtensions
{
    public static Address ToAddress(this AddressModel model) =>
        new(model.Latitude, model.Longitude);
}

Сравнивал в двух кейсах:

  1. Маппинг объекта в объект;

  2. Маппинг списка размером в 10 000 элементов в другой список.

Результаты на картинке снизу:

M1, кстати, очень шустрый. На intel i5 8-го поколения (уже с hyperthreading) разрыв между AutoMapper и extension method, в кейсе List, был 5-кратным против 3-кратного здесь.
M1, кстати, очень шустрый. На intel i5 8-го поколения (уже с hyperthreading) разрыв между AutoMapper и extension method, в кейсе List, был 5-кратным против 3-кратного здесь.

Влияние на размер сборки

Не знаю, насколько этот минус существенный, но всё же стоит про него рассказать.

У меня есть pet project, в котором я отказался от AutoMapper. При этом, размер релизной сборки уменьшился почти на мегабайт!

До удаления - 1.7 MB
До удаления - 1.7 MB
После удаления - ~795 KB
После удаления - ~795 KB

Выводы

Не зря говорят: "явное лучше неявного". Вот и рассмотрев AutoMapper под лупой, мы в очередной раз убедились в этом.

Даже при соблюдении всех рекомендаций, библиотеку можно признать устаревшей, потому что её вызовы и назначение не поменялись, а задача, которую нужно было решить, уже ни перед кем не встаёт. За больше чем 10 лет разработка поменялась очень много раз. Мне ли вам об этом говорить?

Оставив AutoMapper позади, как пережиток прошлого, можно обратить внимание на другие способы создания конвертеров. Ими могут быть как старые добрые нативные инструменты языка программирования C# вроде методов расширения, так и новые решения по кодогенерации, ставшие возможными благодаря развитию платформы.

Материалы


Ещё я веду telegram канал StepOne, где оставляю много интересных заметок про коммерческую разработку и мир IT глазами эксперта.

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


  1. Kiel
    22.12.2022 17:57
    +6

    Вашему проекту это подходит?

    Дааа! Безусловно! Ты задолбаешься все эти dto в бизнес модели мапить и обратно! А без dto не будет версионирования API. К тому же dto у какого-нибудь гугла "смотришь и плачешь". Один их универсальный OBJECT чего стоит ;)

    Но бизнес логику пихать в маппинг нельзя, это просто ошибка


  1. GbrtR
    22.12.2022 17:58
    +2

    Ими могут быть как старые добрые нативные инструменты языка программирования C# вроде методов расширения, так и новые решения по кодогенерации, ставшие возможными благодаря развитию платформы.
    Этим способам надо добавить возможность лёгкой миграции с automapper-а.

    т.е. берём конфиг автомаппера и генерируем код по его правилам. Или натравливаем на исходный код конвертор который базируется на Roslyn, если автомаппер сконфигурирован программно, но сложные случаи скорее всего автоматически не разрешить.

    Без подобного инструмента, сложно будет слезть с автомаппера.


    1. IvaYan
      22.12.2022 18:01

      т.е. брём конфиг автомаппера и генерируем код по его правилам.

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


      1. GbrtR
        22.12.2022 18:06

        Много и давно, начиная от T4 и кончая source generators. Буквально пару месяцев назад подобная статья была.


      1. Leg01as
        22.12.2022 21:27
        +1

        Библиотека Mapster поддерживает кодогенерацию. Да и даже без режима кодогенерации все равно работает чуточку быстрее, чем AutoMapper.


      1. Nikita_Danilov
        22.12.2022 21:59
        +1

        Существовал проект cezarypiatek/MappingGenerator, вернее как, существует, только автор превратил его в коммерческий продукт: https://mappinggenerator.net/ , что в принципе намекает на спрос.


    1. mayorovp
      22.12.2022 18:04

      Ничего сложного если слезать не моментально.


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


      1. GbrtR
        22.12.2022 18:10

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


  1. Nikita_Danilov
    22.12.2022 22:04
    +4

    Наблюдал на примере двух систем и десятка компонентов, бизнес-логика всегда (рано или поздно) просачивалась в конфигурацию автомаппера, и потом поддержка становилась ещё болезненнее. Это какое-то невообразимое природное свойство подобных инструментов, притягивать бизнес-логику, как например: маппинг разных енумов, значения по-умолчанию, flatten-unflatten (что вообще заход на поле доменного представления). А в самый неудобный момент это становится внезапным сюрпризом.


  1. GbrtR
    22.12.2022 23:28
    +1

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

    var a = new A();
    var b = new B();
    
    From<IPartA>.To<B>.Merge(a, b);
    
    var bb = From<A>.To<B>.Map(a);
    

    Из чего-то типа:
    public static class From<TS>  
    {
        public static class To<TD> where TD : class
        {
            public static TD Map(TS src)
            {
                TD data  = Activator.CreateInstance<TD>();
                // ...
                return data;
            }
    
            public static TD Merge(TS src, TD dst)
            {
                // ...
                return dst;
            }
        }    
    }
    

    Ещё пара интересных фич было. Задание правил маппинга через аттрибуты и собственный анализатор кода для проверки того что свойства не были случайно забыты при реализации конверторов.

    Типа такого:
    
    public class A : IPartA
    {
        public string P1 { get; set; }
        public string P2 { get; set; }
    
        public static string Convert(A a)
        {
            return a.P1 + a.P2; 
        }
    }
    
    [Map<A, C>()]
    [MustUseAll]
    public class B
    {
        [Map(nameof(A.P1), nameof(C.Pr1))]
        public string Prop1 { get; set; }
    
        [Map(nameof(A.P2))]
        public string Prop2 { get; set; }
    
        [IgnoreMap<C>]
        [Map(nameof(A.Convert))]
        public string Prop3 { get; set; }
    }
    
    public class C
    {
        public string Pr1 { get; set; }
        public string Prop2 { get; set; }
        public string Pr3 { get; set; }
    }
    
    
    public interface IPartA
    {
        public string P2 { get; set; }
    }
    


    И аттрибуты
    [AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = true)]
    public class MapAttribute : System.Attribute
    {
        public MapAttribute(params string[] s)
        {
        }
    }
    
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
    public class MapAttribute<T1> : System.Attribute where T1: class
    {    
    }
    
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
    public class MapAttribute<T1, T2> : System.Attribute where T1 : class where T2 : class
    {    
    }
    
    public class IgnoreMapAttribute<T> : System.Attribute where T : class
    {
    }
    
    [AttributeUsage(AttributeTargets.Class, Inherited = true)]
    public class MustUseAllAttribute : System.Attribute 
    {
    }
    
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
    public class MustUseAllAttribute<T> : System.Attribute where T : class
    {
    }


    1. Stefanio Автор
      23.12.2022 01:07
      +1

      Интересный пример! Кстати про первый снимает статья даже была, Fluent Generics называлась. Не думал, что встречу ещё где-то этот подход

      https://habr.com/ru/post/666244/


  1. kemsky
    23.12.2022 14:59
    +3

    Я не могу понять одного, почему библиотека или ее автор решает за меня как ее использовать? Если есть инструмент для маппинга, то его домен это маппинг и основная задача - предоставить апи для решения задач маппинга. Вместо этого создатели автомаппера гнут свою линию, причем доходит до смешного: в одном ответе на стековерфлоу они советуют юзать какой-то новый метод, а уже в другом говорят что это плохая идея, а метод мы удалили навсегда. И эти парни не знают слов deprecated, obsolete и тп. Мы художники - мы так видим, а вы переписывайте как хотите ваши маппинги - напрасная трата человеко-часов. Мало того, они как бы дают вам возможность вернуть часть функционала, если очень надо, но код примеров еще надо найти в каком-то из обсуждений и самое главное - этот код бажный, какое-то издевательство. Этого достаточно, чтобы начать обходить эту либу стороной, неизвестно что они решат удалить или запретить завтра, а переписывать сотни мапингов нет никакого желания.

    Сама идея неявного маппинга многократно доказала, что это гарантированный источник ошибок, который к тому же скрывает связи внутри приложения. Это означает, что все эти маппинги надо будет покрывать тестами, но зачем, если вполне можно сделать апи, которое максимально ограничит эти ошибки?


  1. MonkAlex
    24.12.2022 14:09

    Сам автомаппер не сильно нравится.

    Но очень помогает в некоторых сценариях автомаппер.коллекшн

    Берешь коллекцию из внешнего источника, из своей БД и мержишь их. Всего пара строчек автомаппера и это работает, здорово! А вот писать самому такое - лень =) Но, может есть инструменты хорошие, а я просто не в курсе?


    1. s207883
      24.12.2022 22:54

      Возможно linq join?


      1. MonkAlex
        24.12.2022 23:04

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

        А для линка придётся описывать каждое конкретное поле с вложенной коллекцией. Муторно, но более правильно, наверное.