Когда мы читаем/записываем/обрабатываем данные в приложении, то часто нужно переместить информацию между разными слоями приложения (прочитать из БД entity, преобразовать её в модель для api и отдать пользователю) или преобразовать данные в формат системы (при интеграциях). Всё это сводится к преобразованию объектов одного (исходного) типа в объекты другого (целевого) типа.

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

Использование автоматизированных инструментов преобразования объектов (object-object mapping) может помочь в организации кода и отделении ответственности за преобразования в отдельный изолированный уровень приложения.

AutoMapper — самая популярная библиотека для маппинга объектов в dotnet — NuGet-пакет скачали больше 313 миллионов раз за 11 лет существования библиотеки.

Mapster появился на 4 года позже AutoMapper и имеет 8.2 миллионов загрузок на nuget.org. Популярность отличается больше, чем на порядок, так зачем бы вообще смотреть на альтернативу AutoMapper? Дело в том, что Mapster обещает лучшую производительность и меньший объем памяти по сравнению с другими библиотеками маппинга объектов, поэтому стоит по крайней мере рассмотреть использование этой библиотеки и понять возможности для замены автомаппера на мапстер.

Маппинг простых моделей

Давайте проверим сценарий, когда исходный и целевой типы имеют одинаковый набор свойств с одинаковыми именами, но различаются по типу свойств. Исходный тип User:

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public bool IsActive { get; set; }
    public string Email { get; set; } = null!;
    public DateTime CreatedAt { get; set; }
}

Тип, в который мы будем преобразовывать юзера — UserDto:

public class UserDto
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public bool IsActive { get; set; }
    public string Email { get; set; } = null!;
    public string CreatedAt { get; set; } = null!;
}

Мы видим, что единственное различие типов свойств в том, что CreatedAt для User имеет тип DateTime, а для UserDto тип string. В подобных ситуациях библиотеки маппинга выполняют неявное приведение типов.

Чтобы использовать AutoMapper, нам сначала нужно создать объект IMapper. Существует несколько способов его создания, например, можно использовать класс MapperConfiguration. Для этого достатчно указать исходный и целевой типы в качестве generic-параметров в методе CreateMap<TSource, TDestination>(). В нашем случае это User и UserDto:

var mapper = new MapperConfiguration(cfg => cfg.CreateMap<User, UserDto>())
    .CreateMapper();

В продакшен-коде, скорее всего, вы будете использовать профили Автомаппера чтобы лучше организовать код и разделить ответственности, но для примера нам достаточно упрощенного механизма создания.

Создадим объект исхоного типа и преобразуем его в объект целевого типа с помощью IMapper:

var source = new User
{
    Id = 1,
    Name = "User 1",
    Email = "test@example.com",
    IsActive = true,
    CreatedAt = DateTime.Now
};

UserDto destination = mapper.Map<UserDto>(source);

Чтобы смаппить объект в новый тип в AutoMapper нам нужно передать исходный объект в качестве параметра метода Map, а generic-параметром указать целевой тип для преобразования.

Если мы проверим значения переменной destination, то все значения свойств исходного объекта будут совпадать с значениями свойств объекта целевого типа и значение свойства CreatedAt будет неявно преобразовано к типу string:

{
  "Id": 1,
  "Name": "User 1",
  "IsActive": true,
  "Email": "test@example.com",
  "CreatedAt": "14-10-2022 21:53:57"
}

В Mapster всё ещё проще. Для типов с совпадающими свойствами, где возможно неявное преобразование типов свойств, мы можем напрямую вызвать extension-метод Adapt<TDestination>(this object source) для объекта исходного типа с generic-параметром целевого типа:

var source = new User
{
    Id = 1,
    Name = "User 1",
    Email = "test@example.com",
    IsActive = true,
    CreatedAt = DateTime.Now
};

UserDto destination = source.Adapt<UserDto>();

Ещё с помощью Mapster мы можем заполнить поля существующего объекта целевого типа из объекта исходного типа:

var destination = new UserDto();
source.Adapt(destination);

Mapster предоставляет и другие способы для преобразования: collection.ProjectToType() для преобразования IQueryable коллекций, экземпляр IMapper для DI, source generator для явной реализации мапперов.

В этом примере значения объекта целевого типа будут совпадать с теми, что получены с помощью AutoMapper.

Маппинг сложных моделей

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

public class Address
{
    public string AddressLine1 { get; set; } = null!;
    public string AddressLine2 { get; set; } = null!;
    public string City { get; set; } = null!;
    public string State { get; set; } = null!;
    public string Country { get; set; } = null!;
    public string ZipCode { get; set; } = null!;
}

И добавим свойство с типом Address в тип User:

public class User
{
    public int Id { get; set; }
    public string FirstName { get; set; } = null!;
    public string LastName { get; set; } = null!;
    public string Email { get; set; } = null!;
    public Address Address { get; set; } = null!;
}

Для простоты примера типы UserDto и AddessDto будут содержать аналогичный набор свойств.

Библиотеки маппинга должны преобразовывать все типы на любом уровне на своем пути (в том числе приводить типы неявно, если такое преобразование существует). В нашем случае необходимо сопоставить и User, и Address с соответствующими целевыми типами.

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

var mapper = new MapperConfiguration(cfg =>
    {
        cfg.CreateMap<User, UserDto>();
        cfg.CreateMap<Address, AddressDto>();
    })
    .CreateMapper();

Код самого преобразования останется без изменений:

UserDto destination = mapper.Map<UserDto>(source);

В Mapster предварительная конфигурация всё ещё не нужна — поскольку исходный и целевой типы имеют одинаковые свойства, а свойство Address в целевом типе имеет тип AddressDto, который имеет набор свойств аналогичный (или неявно преобразуемый) типу Address. Поэтому всё ещё достаточно вызывать метод-расширение Adapt:

UserDto destination = source.Adapt<UserDto>();

Маппинг коллекций

Часто нам нужно маппить список или массив одного типа в другой. Для этого нам просто нужно указать в качестве generic-параметра метода маппинга List<TDestination>, TDestination[] или что-то подобное. Каких-то других специальных настроек для работы этой функции не требуется.

В AutoMapper нам все еще нужно указать конфигурацию маппинга для типов элеметов коллекций:

var mapper = new MapperConfiguration(cfg => cfg.CreateMap<User, UserDto>())
    .CreateMapper();

var sourceList = new List<User>() { ... };
List<UserDto> destinationList = mapper.Map<List<UserDto>>(sourceList);

В Mapster мы можем напрямую вызвать метод Adapt, указав в качестве generic-параметра List<TDestination>:

var sourceList = new List<User>() { ... };
List<UserDto> destinationList = sourceList.Adapt<List<UserDto>>();

Настройка маппинга свойств

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

public class User
{
    public string FirstName { get; set; } = null!;
    public string LastName { get; set; } = null!;
}

public class UserDto
{
    public string FullName { get; set; } = null!;
}

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

Для этого в AutoMapper метод CreateMap позволяет с помощью fluent interface задать дополнительные настройки маппинга свойств методом ForMember:

var mapper = new MapperConfiguration(cfg =>
{
    cfg.CreateMap<User, UserDto>()
        .ForMember(
            dest => dest.FullName,
            config => config.MapFrom(src => $"{src.FirstName} {src.LastName}"
            ));
});

В Mapster можно использовать статический класс TypeAdapterConfig для задания правил маппинга свойств. Указываем исходный и целевой типы generic-параметрами, создаем новую конфигурацию с помощью метода NewConfig() и используем похожий на Автомаппер fluent-интерфейс для задания маппинга свойств:

TypeAdapterConfig<User, UserDto>
    .NewConfig()
    .Map(dest => dest.FullName, src => $"{src.FirstName} {src.LastName}");

Разворачиваем сложные модели (object flattening)

Мы можем сопоставить свойства вложенных объектов со свойствами верхнего уровня, используя простое соглашение об именовании. Например, вложенное свойство Address.ZipCode из исходного типа User может быть отображено на свойство AddressZipCode в целевом типе UserDto. Это позволит сделать плоскую модель из сложного исходного объекта.

public class User
{
    public Address Address { get; set; } = null!;
}

public class Address
{
    public string ZipCode { get; set; } = null!;
}

public class UserDto
{
    public string AddressZipCode { get; set; } = null!;
}

Такой же результат можно получить, если в исходном типе есть метод с именем Get(DestinationPropertyName). Например, метод GetFullName() будет сопоставлен со свойством FullName:

public class User
{
    public string FirstName { get; set; } = null!;
    public string LastName { get; set; } = null!;
    public string GetFullName() => $"{FirstName} {LastName}";
}

public class UserDto
{
    public string FullName { get; set; } = null!;
}

Такие соглашения об именовании по-умолчанию работают и в AutoMapper, и в Mapster.

Двухсторонний маппинг в сложные модели (unflattening)

Библиотеки маппинга имеют функциональность настройки двухстороннего маппинга, когда из объекта целевого типа мы хотим получить объект исходного. В случае прямого преобразования из сложного объекта в плоский (flattening), обратное преобразование (Reverse Mapping) подразумевает получение сложной модели из плоского представления.

В AutoMapper это можно сделать с помощью метода ReverseMap() в конфигурации маппера:

var mapper = new MapperConfiguration(cfg => cfg
        .CreateMap<User, UserDto>()
        .ReverseMap())
    .CreateMapper();

В Mapster из-за конфигурации по-умолчанию при совпадении свойств типов задавать возможность обратного преобразования не нужно. Если свойства типов отличаются и нужно задать двухстороннее преобразование явно, то можно использовать в конфигурации метод TwoWay(). Этот метод так же включает преобразование плоских моделей в сложные, которое не работает по-умолчанию:

TypeAdapterConfig<User, UserDto>
    .NewConfig()
    .TwoWays()
    .Map(dest => dest.EmailAddress, src => src.Email);

Конфигурация с помощью атрибутов

До сих пор мы рассматривали fluent-конфигурацию в коде для разных сценариев. Чаще всего используется именно этот способ, он позволяет отделить логику маппинга между типами от самих типов. Но AutoMapper и Mapster предоставляют ещё один способ конфигурирования преобразований — атрибуты для задания параметров маппинга.

В AutoMapper есть целый набор атрибутов для разных сценариев:  AutoMap,  Ignore,  ReverseMap,  SourceMember и другие:

public class User
{
    public DateTime CreatedAt { get; set; }
}

[AutoMap(typeof(User))]
public class UserDto
{
    [SourceMember("CreatedAt")]
    public string CreatedDate { get; set; } = null!;
}

Кроме разметки типов атрибутами AutoMapper требует явно добавить маппинги из атрибутов с помощью метода AddMaps(), который принимает на вход сборку, в которой будет идти поиск типов с атрибутами для маппинга:

var mapper = new MapperConfiguration(cfg => cfg.AddMaps(typeof(User).Assembly))
    .CreateMapper();

Mapster предоставляет свой набор аналогичных атрибутов: AdaptTo,  AdaptFrom,  AdaptTwoWays,  AdaptMember и другие. Аналогичный предыдущему примеру код будет выгядеть так:

public class User
{
    public DateTime CreatedAt { get; set; }
}

public class UserDto
{
    [AdaptMember("CreatedAt")]
    public string CreatedDate { get; set; } = null!;
}

Dependency Injection

AutoMapper предоставляет NuGet-пакет AutoMapper.Extensions.Microsoft.DependencyInjection, который позволяет добавить IMapper в IServiceCollection и сконфигурировать его с помощью метода AddAutoMapper. Есть много перегрузок, позволяющих добавить конфигурацию явно или подтянуть все настройки по сборкам.

serviceCollection.AddAutoMapper(c =>
        {
            c.AddProfile(typeof(UserProfile));
            c.AddProfile(typeof(AddressProfile));
            c.CreateMap<Car, CarDto>();
        });
// или так
serviceCollection.AddAutoMapper(typeof(User).Assembly);

У Mapster похожий способ подключения — нужно добавить NuGet-пакет Mapster.DependencyInjection и зарегистрировать в контейнере типы TypeAdapterConfig и ServiceMapper:

var config = new TypeAdapterConfig();
// или
// var config = TypeAdapterConfig.GlobalSettings;
// ...
services.AddSingleton(config);
services.AddScoped<IMapper, ServiceMapper>();

После этого мы можем использовать интерфейс IMapper в качестве зависимости:

public class SampleService 
{
    private readonly IMapper mapper;
    public SampleService(IMapper mapper) 
    {
        this.mapper = mapper;
    }
}

Сравнение производительности AutoMapper и Mapster

Для тестов производительности будем использовать BenchmarkDotNet. Исходный код бенчмарка и результаты есть на github.

Для подготовки данных используется NuGet-пакет Bogus, который умеет генерить данные подходящих типов:

var faker = new Faker<SimpleUser>()
	.Rules((f, o) =>
	{
		o.Id = f.Random.Number();
		o.Name = f.Name.FullName();
		o.Email = f.Person.Email;
		o.IsActive = f.Random.Bool();
		o.CreatedAt = DateTime.Now;
	});
return faker.Generate(count);

Почти все бенчмарки устроены одинаково — для подготовленного списка из 1000 элементов исходного типа они поэлементно маппят объекты на целевой тип:

[Benchmark(Description = "AutoMapper_SimpleMapping")]
public void AutoMapperSimpleObjectMapping()
{
	for (var i = 0; i < Size; i++)
	{
		var destination = Mapper.Map<SimpleUserDto>(Source[i]);
	}
}
	
[Benchmark(Description = "Mapster_SimpleMapping")]
public void MapsterSimpleObjectMapping()
{
	for (var i = 0; i < Size; i++)
	{
		var destination = Source[i].Adapt<SimpleUserDto>();
	}
}

Есть тесты на простые модели, маппинг списков, сложные модели с вложенностью, flattening и unflattening, модели с настройкой маппера для определенных свойств, модели с разметкой атрибутами. Результаты бенчмарка для dotnet 7:

|                           Method |      Mean |    Error |    StdDev |  Ratio | Allocated |
|--------------------------------- |----------:|---------:|----------:|-------:|----------:|
|         AutoMapper_SimpleMapping | 304.29 us | 0.855 us | 11.594 us |  1.290 | 109.38 KB |
|            Mapster_SimpleMapping | 235.87 us | 0.628 us |  8.492 us |  1.000 | 109.38 KB |
|           AutoMapper_ListMapping | 208.90 us | 0.234 us |  2.949 us |  1.021 | 125.59 KB |
|              Mapster_ListMapping | 204.54 us | 0.357 us |  4.846 us |  1.000 | 117.24 KB |
|         AutoMapper_NestedMapping | 128.83 us | 0.285 us |  6.069 us |  2.079 | 117.19 KB |
|            Mapster_NestedMapping |  61.94 us | 0.262 us |  2.914 us |  1.000 | 117.19 KB |
|      AutoMapper_FlattenedMapping |  88.15 us | 0.197 us |  2.666 us |  2.862 |  23.44 KB |
|         Mapster_FlattenedMapping |  30.79 us | 0.054 us |  0.735 us |  1.000 |  23.44 KB |
| AutoMapper_CustomPropertyMapping | 175.77 us | 0.517 us |  6.859 us |  1.551 |  73.96 KB |
|    Mapster_CustomPropertyMapping | 113.29 us | 0.291 us |  3.951 us |  1.000 |  73.78 KB |
|        AutoMapper_ReverseMapping | 118.53 us | 0.244 us |  3.495 us |  2.576 |  46.88 KB |
|           Mapster_ReverseMapping |  46.00 us | 0.140 us |  1.595 us |  1.000 |  46.88 KB |
|      AutoMapper_AttributeMapping | 301.95 us | 0.698 us |  9.207 us |  1.296 |  85.94 KB |
|         Mapster_AttributeMapping | 232.81 us | 0.543 us |  7.349 us |  1.000 |  85.94 KB |

Mapster превосходит AutoMapper по скорости работы во всех сценариях, на большинстве сценариев на 30-50%, а в некоторых сценариях больше, чем в 2 раза. При этом потребление памяти или не изменяется, или уменьшается.

Выводы

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

Это не значит, что нужно обязательно заменять AutoMapper на Mapster во всех своих проектах. Mapster — хорошая альтернатива, о которой полезно знать и уметь с ней работать.

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


  1. tralalablblbl
    17.10.2022 23:46
    +2

    Было бы хорошо ещё сравнить интеграцию в ef core. Automapper умеет генерировать правильные sql селекты с использованием ProjectTo


    1. Vadimyan Автор
      17.10.2022 23:59
      +1

      У Mapster есть аналогичная ProjectTo поддержка EF какр раз через вскольз упомянутый ProjectToType, который работает с IQueryable. Можно попробовать сравнить. Тут вопрос, будет ли, например, хорошим сравнением использованием InMemory или хочется именно данных на реальной "железной" БД? И нужно, конечно, сравнивать не "очевидные" сценарии, где маппинг явный.


      1. tralalablblbl
        18.10.2022 00:35
        +1

        Для меня и просто наличие поддержки хватает, спасибо.

        Все равно все тонкости реализации всплывут только во время активного использования


  1. Veikedo
    18.10.2022 01:21
    +1

    В первой части статьи вы так расписывали удобство Mapster'a что в нём не надо явно конфигурацию прописывать. Но ведь в продакшене в любом случае эти правила должны быть явно прописаны, чтобы иметь возможность вызвать AssertConfigurationIsValid.
    Да и про киллер-фичу с генерацией моделек у мапстера ни слова.


  1. kuber
    18.10.2022 03:33
    +2

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


    1. Vadimyan Автор
      18.10.2022 09:29

      На самом деле в стороннем бенчмарке из статьи есть сравнение как раз с ручным маппингом в методе. Мне, правда, это сравнение кажется не совсем честным — например, там повсеместно используется LINQ, что очевидно будет замедлять код. Собственно, ручной маппинг там на 2-3 месте обычно. Без LINQ и на моих модельках результаты немного другие, Mapster вдвое медленнее на комплексных типах и чуть медленнее на простых.


      1. kuber
        18.10.2022 14:54
        +1

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


        1. Vadimyan Автор
          18.10.2022 15:22

          Да, мапперы это история скорее про организацию кода. И иногда про валидацию, например, благодаря AssertConfigurationIsValid. То есть, ручная конвертация будет в любом случае быстрее (или сопоставима по скорости в случае с кодогенерируемым маппингом).

          Я для себя вижу такие потенциальные недостатки или опасности ручного маппинга:

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

          • (В немикросервисах) через пару лет поддержки можно обнаружить 3-4 метода конвертации одной и той же модельки в разных местах прото от того, что разработчик не нашёл существующий метод конвертвции.

          • Сценарий "добавил свойство, не протащил в конвертер" — это как раз то, что ловится в библиотеках маппинга валидацией.

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


          1. kuber
            18.10.2022 17:29
            +1

            Да, именно так. Мэпперы это про упрощение разработки и поддержки проекта.


  1. JeanneD
    18.10.2022 08:58
    +1

    Mapster с кодогенерацией даёт такую же скорость как и при ручном маппинге.

    Единственное что проекции не будут работать, но тут понятно по каким причинам.