Pull to refresh

Сравнение AutoMapper и Mapster

Reading time10 min
Views12K
Original author: Code Maze

Когда мы читаем/записываем/обрабатываем данные в приложении, то часто нужно переместить информацию между разными слоями приложения (прочитать из БД 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 — хорошая альтернатива, о которой полезно знать и уметь с ней работать.

Only registered users can participate in poll. Log in, please.
Какие библиотеки маппинга в dotnet вы используете?
62.2% AutoMapper79
0% AgileMapper0
0.79% ExpressMapper1
0.79% TinyMapper1
14.96% Mapster19
43.31% Ручную реализацию методов конвертации55
127 users voted. 25 users abstained.
Tags:
Hubs:
Total votes 7: ↑6 and ↓1+5
Comments10

Articles