
Всем привет, меня зовут Сергей, я системный архитектор в компании 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-кода и снизить архитектуру в чистоте.
ссылка на оригинал статьи https://habr.com/ru/company/bimeister/blog/693722/
Добавить комментарий