Всем привет, меня зовут Сергей, я системный архитектор в компании Bimeister, и, как вы уже догадались, сегодня мы поговорим про маппинг объектов в .net.
Мы сравним несколько популярных подходов и библиотек для маппинга, дадим общее представление и посмотрим на различия, которые стоит учитывать при выборе инструментов.
Статья ориентирована на младших разработчиков, которые впервые сталкиваются с темой маппинга объектов и на всех неравнодушных. В данной статье мы не будем касаться широкой темы разнообразных ОRМ-ов (ObjectRelational Mapping), а также темы сериализации/десериализации данных, которую тоже часто называют маппингом. Мы рассмотрим сопоставление объектов между различными слоями нашего приложения, например DТО (Data Transfer Object) и объектом из базы данных, с которым оперирует Entity Framework.
Итак, начнём!
Начнем со всем известных понятий...
Уровень данных (Data Layer) — предоставляет источники данных остальной части приложения, содержит локальные и удаленные источники данных, мапперы и репозитории.
Уровень домена (Domain Layer) — содержит бизнес-логику, оперирует кейсами и моделями предметной области и репозитории.
Уровень представления (Presentation Lауег) — содержит действия, фрагменты модели представлений и адаптеры.
Зачем это нужно?
С увеличением кодовой базы проекта мы, как правило, задумываемся о выстраивании удобной для разработки и поддержки кода архитектуры... и приходим к проблеме маппинга объектов с одного слоя на другой.
Внедрение такой архитектуры уменьшает связывание кода и повышает его тестируемость, вследствие чего проект становится проще обслуживать, но при этом добавляет много boilerplate-кода. Чтобы уменьшить количество такого кода, нужно правильно выбрать подход для маппинга объектов.
У нас несколько классов, которые представляют собой сущности уровней данных и представления:
public class ClientDto
{
public long Id { get; set; }
public string Email { get; set; }
public string? FullName { get; set; }
}
public class EntityObject
{
public Guid Id { get; set; }
public string Type { get; set; }
public string Name { get; set; }
public string? Description { get; set; }
public long ClientId { get; set; }
public DateTime StartDate { get; set; }
public DateTime UpdateDate { get; set; }
}
public class EntityDto
{
public Guid Id { get; set; }
public string Type { get; set; }
public string Name { get; set; }
public string? Description { get; set; }
public DateTimeOffset StartDate { get; set; }
public DateTimeOffset UpdateDate { get; set; }
public ClientDto Client { get; set; }
}
Давайте же посмотрим, какие есть способы смаппить объекты.
Способ №1. Ручной маппинг объектов.
Никаких пререквизитов нет. Самый простой способ сделать это — создать инстанс объекта вручную в том месте, где вам нужен целевой объект:
EntityObject sourceObject = await _someService.GetObjectAsync();
EntityDto dest = new()
{
Id = sourceObject.Id,
Type = sourceObject.Type,
Name = sourceObject.Name,
Description = sourceObject.Description,
StartDate = sourceObject.StartDate,
UpdateDate = sourceObject.UpdateDate,
Client = new ClientDto
{
Id = sourceObject.ClientId
}
};
Для того чтобы снизить дублирование, можно вынести эту часть в отдельный метод или сделать extension:
public static class MappingExtensions
{
public static EntityDto MapToDto(this EntityObject? mappingObject)
{
if (mappingObject == null)
throw new ArgumentNullException(nameof(mappingObject));
EntityDto dest = new()
{
Id = mappingObject.Id,
Type = mappingObject.Type,
Name = mappingObject.Name,
Description = mappingObject.Description,
StartDate = mappingObject.StartDate,
UpdateDate = mappingObject.UpdateDate,
Client = new ClientDto
{
Id = mappingObject.ClientId
}
};
return dest;
}
}
Плюсы:
вы сами управляете всем и можете реализовать любой по сложности маппинг;
не нужны дополнительные зависимости и их конфигурация.
Минусы:
вам приходится самим управлять своим маппингом, даже в простых конфигурациях;
в некоторых случаях не получится полностью избежать дублирования инфраструктурного кода.
Способ №2. С использованием AutoMapper
Нам нужен NuGet-пакет, для использования в ASP.NET проще всего использовать этот:
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection
Чтобы зарегистрировать mapper и конфигурацию в DI, достаточно добавить в ConfigureServices следующую строчку:
services.AddAutoMapper(assembly1, assembly2 /*, ...*/);
// or
services.AddAutoMapper(type1, type2 /*, ...*/);
Это зарегистрирует конфигурацию MapperConfiguration как Singleton и реализацию IMapper как transient, а также добавит дополнительно различные converters, resolvers и т.д.
И, собственно, сам маппинг:
EntityObject sourceObject = await _someService.GetObjectAsync();
// IMapper mapper достается из DI
mapper.Map<EntityDto>(_sourceObject);
// Также есть non-generic версия этого метода,
// когда вы не знаете какой у вас тип во время компиляции
Для простых сценариев это подойдет, но если вы хотите управлять маппингом, есть возможность настроить профиль:
public class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<EntityObject, EntityDto>()
.ForMember(dest => dest.Id, o => o.MapFrom(src => src.Id))
.ForMember(dest => dest.Type, o => o.MapFrom(src => src.Type))
.ForMember(dest => dest.Name, o => o.MapFrom(src => src.Name))
.ForMember(dest => dest.Description, o => o.MapFrom(src => src.Description))
.ForMember(dest => dest.StartDate, o => o.MapFrom(src => src.StartDate))
.ForMember(dest => dest.UpdateDate, o => o.MapFrom(src => src.UpdateDate))
.ForMember(dest => dest.Client, o => o.MapFrom(src => new ClientDto {Id = src.ClientId}));
}
}
Когда нам нужна гибкость и мы хотим взять под контроль преобразование одного типа в другой, можно использовать механизм TypeConverters:
public class DateTimeTypeConverter : ITypeConverter<string, DateTime>
{
public DateTime Convert(string source, DateTime destination, ResolutionContext context)
{
return System.Convert.ToDateTime(source);
}
}
И использовать его в профиле:
.ForMember(dest => dest.UpdateDate, o => o.ConvertUsing<DateTimeTypeConverter>(src => src.UpdateDate));
Для работы с вычисляемыми значениями есть IValueResolver и IMemberValueResolver.
Automapper имеет также много дополнительных настроек в виде управления NamingConventions, замены символов в членах класса, определения prefix/postfix при маппинге членов класса, фильтрации полей, включения компиляцию конфигураций маппингов (по умолчанию компиляция ленивая) и т.д.
Плюсы:
сокращается большое количество инфраструктурного кода;
не нужны дополнительные зависимости и их конфигурация.
Минусы:
механизм с профилями сложен в поддержке, так как сложно понять, какие профили используются;
появляются дополнительные зависимости в проектах, нужно актуализировать список assembly, в котором лежат профили, чтобы исключить ошибки при маппинге;
при сложных маппингах или маппинге больших коллекций наблюдается деградация производительности.
AutoMapper хорошо использовать в небольших проектах или в ситуации когда профили меняются редко и когда мы не "выжимаем" максимум возможностей гонясь за каждыми миллисекундами.
Информация о библиотеке:
Исходный код: Github
Документация: Docs
Популярность: Stars 9K/Forks 1.7K, постоянные релизы раз в пару месяцев
Последний релиз: октябрь 2022
Количество загрузок в nuget: 300 миллионов
Способ 3. С использованием Mapster
Для работы с библиотекой нам также нужен дополнительный NuGet-пакет:
dotnet add package Mapster.DependencyInjection
Добавляем в DI через ConfigureServices и конфигуриуем:
...
services.AddSingleton(TypeAdapterConfig.GlobalSettings);
services.AddScoped<IMapper, ServiceMapper>();
...
Mapster имеет разные режимы работы, в том числе с использованием кодогенерации. Это позволяет получить большую производительность и меньшее потребление памяти. Видеть использование ваших моделей и отлаживать код, который отвечает за маппинг:
EntityObject sourceObject = await _someService.GetObjectAsync();
// маппинг в новый объект
EntityDto destObject = sourceObject.AdaptTo<EntityDto>();
// или в существующий объект
sourceObject.AdaptTo<EntityDto>(destObject);
Настройка маппинга осуществляется через TypeAdapterConfig:
public class MappingConfig : TypeAdapterConfig
{
public MappingConfig()
{
ForType<EntityObject, EntityDto>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.Name, src => src.Name);
}
}
Библиотека может почти все то же, что и AutoMapper, касательно Naming Conventions и Custom Converters.
Mapster — также гибко настраиваемый, умеет маппинг private-членов класса, условный и с нескольких источников.
Дополнительно хочу отметить возможность маппить отдельные свойства и null propagation:
TypeAdapterConfig<EntityObject, EntityDto>.NewConfig()
.Map(dest => dest.Client.Id, src => src.ClientId);
TypeAdapterConfig<EntityDto, EntityObject>.NewConfig()
.Map(dest => dest.ClientId, src => src.Client.Id);
Кодогенерация предполагает, что Mapster сам сможет сформировать целевой DTO и реализовать маппинги для него. Для ее работы нужен пакет Mapster.Tools:
dotnet add package Mapster.Tools
И интерфейс для маппера:
[Mapper]
public interface IEntityObjectMapper
{
EntityDto MapTo(EntityObject student);
}
Вот и все, в результате сборки мы получим сгенерированный файл *.g.cs, в котором будет реализован маппинг. Такие файлы я рекомендую исключить из репозитория, это позволит избежать проблем при совместной работе, например, когда кто-то из разработчиков менял свойства исходного объекта и не пересобрал проект.
Плюсы:
Есть кодогенерация, которая меняет подход к маппингу объектов.
Во многих случаях производительность выше чем в AutoMapper, и ниже потребление памяти.
Минусы:
Варианты с кодогенерацией не совсем привычны, особенно если использовал раньше AutoMapper.
Меньше возможностей кастомизации — я не нашел аналогов IMemberValueResolver.
Mapster был разработан, чтобы быть эффективным решением по скорости и памяти, если вы пишите высокопроизводительный продукт — этот маппер окажется хорошим выбором.
Информация о библиотеке:
Исходный код: Github
Документация: Docs
Популярность: Stars 3K/Forks 237, релизы несколько раз в год
Последний релиз: Февраль 2022
Количество загрузок в nuget: 8 миллионов
Производительность
Тесты выполнялись с помощью BenchmarkDotNet.
Первая колонка говорит нам про название теста, я протестировал простой маппинг и маппинг коллекций различных размеров. Все примеры можно увидеть по ссылке на Github. Остальные колонки: среднее значение работы теста, ошибка, стандартное отклонение и медиана. Вся техническая информация о запуске и результаты приведены в таблице:
BenchmarkDotNet=v0.13.2, OS=Windows 10 (10.0.19044.2006/21H2/November2021Update)
Intel Core i5-10210U CPU 1.60GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK=6.0.305
[Host] : .NET 6.0.10 (6.0.1022.47605), X64 RyuJIT AVX2
Job-MNIIYB : .NET 6.0.10 (6.0.1022.47605), X64 RyuJIT AVX2
Runtime=.NET 6.0 RunStrategy=Throughput
Method |
Mean |
Error |
StdDev |
Median |
---|---|---|---|---|
Manual |
101.6 ns |
2.12 ns |
5.43 ns |
99.89 ns |
AutoMapper |
190.0 ns |
1.76 ns |
1.56 ns |
190.17 ns |
Mapster |
135.4 ns |
2.63 ns |
6.54 ns |
132.66 ns |
ManualCollection100 |
3,435.8 ns |
62.04 ns |
100.19 ns |
3,424.57 ns |
AutoMapperCollection100 |
4,725.9 ns |
81.95 ns |
72.64 ns |
4,702.34 ns |
MapsterCollection100 |
3,452.8 ns |
57.91 ns |
91.85 ns |
3,448.84 ns |
ManualCollection1000 |
38,786.8 ns |
752.18 ns |
772.43 ns |
38,648.30 ns |
AutoMapperCollection1000 |
55,647.5 ns |
514.10 ns |
429.30 ns |
55,668.73 ns |
MapsterCollection1000 |
38,467.2 ns |
425.09 ns |
397.63 ns |
38,333.86 ns |
ManualCollection10000 |
590,107.0 ns |
11,356.83 ns |
13,947.21 ns |
585,938.77 ns |
AutoMapperCollection10000 |
2,158,687.2 ns |
35,665.54 ns |
36,625.88 ns |
2,169,171.48 ns |
MapsterCollection10000 |
647,230.3 ns |
6,786.75 ns |
5,667.24 ns |
646,317.58 ns |
Вместо выводов:
Я рассмотрел лишь самые популярные варианты. Были еще интересные кандидаты, но я не стал их рассматривать по причине или редких релизов или очень давней последней версии, но это не повод вовсе не упомянуть их (ExpressMapper и его форк для net standard, а также TinyMapper).
Также есть интересное решение, позволяющее генерировать маппинг через расширение среды разработки Visual Studio - Mapping Generator, но за некоторые возможности придется заплатить.
Все выводы я рекомендую делать вам самим, потому что, как бы нам не хотелось, «серебряной пули» не существует, нужно выбирать решение, которое подходит вам, исходя из ваших задач, размера проекта, команды разработки и принятых практик.
И все-таки, автоматический маппинг — полезная и удобная вещь, которая в больших проектах позволяет снизить количество boilerplate-кода и снизить архитектуру в чистоте.
Комментарии (9)
megazoid007
19.10.2022 16:26А могёт ли этот Mapster через конструктор как это умеет AutoMapper ? Для меня это киллер фича т.к. это особенно важно для маппинга на доменную модель.
sakutylev Автор
20.10.2022 11:34Да, он умеет маппить с использованием конструктора
Constructor mapping · MapsterMapper/Mapster Wiki (github.com)
ZOXEXIVO
19.10.2022 21:31Было бы круто иметь простой инструмент для Visual Studio / Rider который позволил бы быстро генерировать и обновлять ручные маппинги между объектами (из контекстного меню / по команде)
Остается только проблема валидации незамапленных значений, но это не критично
andToxa
20.10.2022 07:32MappingGenerator was initially created as a design time alternative to AutoMapper. Есть плагины и для студии и для райдера.
UnclShura
20.10.2022 17:48Automapper просто не работает при количестве объектов в несколько тысяч. Ну как не работает... добавляет 2 минуты к старту системы. Не верю в мепперы как класс.
sakutylev Автор
20.10.2022 18:33А если использовать кодогенерацию или предварительно компилировать expression tree в конфигурациях маппинга, это помогло бы ускорить?
stjimmy54
Мне кажется, что в статье не хватает самого главного - работа с проекциями EFCore. Насколько хорошо в Mapster реализован аналог .ProjectTo<SomeDTO>?
sakutylev Автор
Спасибо за комментарий, соглашусь и попробую вынести это в отдельную статью.
kenoma
https://habr.com/ru/post/693828/
Пару дней назад на этот вопрос уже ответили.