Не каждый бобер способен разглядеть в себе единорога
Не каждый бобер способен разглядеть в себе единорога

Всем привет, меня зовут Сергей, я системный архитектор в компании Bimeister, и, как вы уже догадались, сегодня мы поговорим про маппинг объектов в .net.

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

Статья ориентирована на младших разработчиков, которые впервые сталкиваются с темой маппинга объектов и на всех неравнодушных. В данной статье мы не будем касаться широкой темы разнообразных ОRМ-ов (Object­Relational 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)


  1. stjimmy54
    19.10.2022 14:41
    +2

    Мне кажется, что в статье не хватает самого главного - работа с проекциями EFCore. Насколько хорошо в Mapster реализован аналог .ProjectTo<SomeDTO>?


    1. sakutylev Автор
      19.10.2022 14:43

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


    1. kenoma
      19.10.2022 15:46
      +1

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

      Пару дней назад на этот вопрос уже ответили.


  1. megazoid007
    19.10.2022 16:26

    А могёт ли этот Mapster через конструктор как это умеет AutoMapper ? Для меня это киллер фича т.к. это особенно важно для маппинга на доменную модель.


    1. sakutylev Автор
      20.10.2022 11:34

      Да, он умеет маппить с использованием конструктора
      Constructor mapping · MapsterMapper/Mapster Wiki (github.com)


  1. ZOXEXIVO
    19.10.2022 21:31

    Было бы круто иметь простой инструмент для Visual Studio / Rider который позволил бы быстро генерировать и обновлять ручные маппинги между объектами (из контекстного меню / по команде)

    Остается только проблема валидации незамапленных значений, но это не критично


    1. andToxa
      20.10.2022 07:32

      MappingGenerator was initially created as a design time alternative to AutoMapper. Есть плагины и для студии и для райдера.


  1. UnclShura
    20.10.2022 17:48

    Automapper просто не работает при количестве объектов в несколько тысяч. Ну как не работает... добавляет 2 минуты к старту системы. Не верю в мепперы как класс.


    1. sakutylev Автор
      20.10.2022 18:33

      А если использовать кодогенерацию или предварительно компилировать expression tree в конфигурациях маппинга, это помогло бы ускорить?