
Всем привет, меня зовут Сергей, я системный архитектор в компании 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/
Пару дней назад на этот вопрос уже ответили.