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