Привет, Хабр! Меня зовут Георгий, я С#-разработчик в SimbirSoft. Хочу рассказать об опыте использования библиотеки Mapster: как она может упростить разработку, сэкономить силы и частично избавиться от рутины маппинга.

Данная статья подойдет и тем, кто только собирается открыть для себя мир автомаппинга, и тем, кто хочет найти для себя альтернативу используемой библиотеки. Для полного понимания, что тут будет происходить, желательно обладать базовым пониманием C#, знать о существовании DI и подозревать, что рефлексия не так проста, как кажется. Ну и LINQ с EF.Core, куда же без них (хотя про них достаточно просто когда-то слышать и примерно представлять, зачем они нужны).
Сразу оговорюсь, что в этой статье я собрал не только свой опыт, но и опыт других специалистов — авторов статей и видео, и привел наглядные примеры, как с этим работать. Все ссылки на источники вы найдете внутри.
Если вы пресытились хвалебными отзывами и различными сравнениями мапперов по производительности – сразу пропускайте вводную часть и прыгайте к примерам. Там я подробно расскажу, как этим всем пользоваться.
Вводная часть
Поскольку каждый уважающий себя разработчик хочет, чтобы его проект был удобным, красивым и масштабируемым с точки зрения архитектуры, он старается разделять слои приложения, чтобы в дальнейшем упростить поддержку и развитие. В целом звучит круто, но достаточно сложно, поскольку правильное разделение слоев часто приводит к неконтролируемому росту числа моделей данных, которыми слои между собой обмениваются.
Пример:
Допустим, у нас есть класс репозиторий, который ходит в базу и возвращает нам данные. Мы не хотим, чтобы эта полная модель данных уходила куда-то дальше, поэтому мы преобразуем полученную модель в DTO (Data Transfer Object) и уже возвращаем ее.
В принципе маппинг модели данных в DTO можно сделать и руками, и зачастую это достаточно тривиальная задача. Но насколько же она унылая и рутинная! Для каждой сущности в базе писать свой маппинг, плодить дополнительные классы, где этот маппинг будет жить (мы же не хотим маппить данные прямо в репозитории). Таким образом, у проекта разрастается кодовая база, ее сложнее поддерживать, и в целом, будем честны, не особо хочется каждый раз руками прописывать преобразования объектов. Чуть более подробный пример можно найти в этой статье, вместе с другими фокусами мапстера.
Тут как раз и приходит спасение в виде библиотек автомаппинга, которые позволяют избавиться от написания этого веселья ручками.
Mapster — это имя вам что-нибудь говорит?
Собственно, Mapster – это библиотека, упрощающая маппинг моделей для разработчика, благодаря которой кидаться модельками между слоями приложения становится куда проще. Можно сконцентрироваться на разработке логики приложения, а не на преобразовании этих моделей данных друг в друга.
На данный момент существует много различных автомапперов, и их уже не раз сравнивали между собой. Например, этот прекрасный человек в своем видео сравнил их по производительности.
Для тех, кто любит цифры
Как я уже говорил ранее, только ленивый не сравнивал по производительности популярные инструменты маппинга, ну а я человек не ленивый (почти), поэтому тоже пришел поделиться своими наблюдениями.
Сравнивать будем AutoMapper, Mapster в его «одноклеточной» вариации, Mapster в режиме кодогенерации, Mapperly (поскольку он нравится многим) и ручной маппинг.
Маппить будем следующие модельки:
public class DocumentDto { public Guid Id { get; set; } public string Name { get; set; } public string Description { get; set; } public string Author { get; set; } public int PagesCount { get; set; } public bool IsPublic { get; set; } public string[] Tags { get; set; } public DateTime Created { get; set; } public DateTime Modified { get; set; } public DateTime? Deleted { get; set; } }
и
public class Document { public Guid Id { get; set; } public string Name { get; set; } public string Description { get; set; } public string Author { get; set; } public int PagesCount { get; set; } public bool IsPublic { get; set; } public string[] Tags { get; set; } public DateTime Created { get; set; } public DateTime Modified { get; set; } public DateTime? Deleted { get; set; } }
Ничего сложного и необычного в них нет, они достаточно простые и наглядные.
Заполним входную модельку в отдельном статическом классе, чтобы удобно было ее прокидывать в бенчмарки:
public static class TestData { public static DocumentDto TestDocument = new() { Id = Guid.NewGuid(), Name = "DocumentName", Author = "DocumentAuthor", Description = "DocumentDescription", IsPublic = true, PagesCount = 100, Created = DateTime.Now, Modified = DateTime.Now, Deleted = null, Tags = new string[] { "Book", "Horror", "Adventure" } }; }
Как создавать, конфигурировать и прокидывать тестируемые мапперы, я рассказывать не буду, поскольку это другая тема (кроме мапстера, о нем речь пойдет позже).
Но класс бенчмарков будет выглядеть примерно так
[MemoryDiagnoser(false)] [Orderer(BenchmarkDotNet.Order.SummaryOrderPolicy.FastestToSlowest)] public class Benchmarks { private readonly DocumentDto _documentDto = TestData.TestDocument; private readonly IMapper _autoMapper; private readonly MapperlyMapper _mapperlyMapper; private readonly MapsterMapper _mapsterMapper; public Benchmarks() { var mapperConfig = new MapperConfiguration(cfg => { cfg.CreateMap<DocumentDto, Document>(); }); _autoMapper = mapperConfig.CreateMapper(); _mapperlyMapper = new MapperlyMapper(); _mapsterMapper = new MapsterMapperImpl(); } [Benchmark] public Document AutoMapper() { return _autoMapper.Map<Document>(_documentDto); } [Benchmark] public Document Mapster() { return _documentDto.Adapt<Document>(); } [Benchmark] public Document Mapperly() { return _mapperlyMapper.Map(_documentDto); } [Benchmark] public Document MapsterMapper() { return _mapsterMapper.Map(_documentDto); } [Benchmark] public Document ManualMapping() { return _documentDto.Map(); } }
Сразу отмечу, что Mapster в данном случае – это как раз «одноклеточное» использование, а MapsterMapper – явное использование кодогенерации.
Немного об окружении:
-
Процессор: AMD Ryzen 7 3700x;
-
Среда разработки: Visual Studio 2022;
-
Версия .NET 7.
Пускаем тесты, и видим результат:

Mapperly оказался самым быстрым и наименее прожорливым среди всех автомапперов. Всё, господа, расходимся, тема закрыта.
Как и ожидалось, ручной маппинг оказался самым быстрым решением, однако, Mapperly был очень близок к нему (по памяти так вообще идентичны). С отставанием примерно в 2 раза от Mapperly расположился Mapster в режиме кодогенерации, «простейший» мапстер и замыкающим выступает Automapper. В целом, мапстер показал достойный результат, но его главная фишка раскроется уже непосредственно в применении, о котором речь пойдет в практическом разделе.
Коротко об особенностях Mapster
Если говорить об основных отличительных особенностях этой библиотеки, то можно выделить следующее:
-
Кодогенерация
Mapster использует подход кодогенерации, что позволяет ему достаточно быстро работать. Не сказать, что это какой-то уникальный подход, но тот же AutoMapper использует рефлексию, что сказывается на его производительности.
-
Возможность примитивного использования мапстера из коробки
Для маппинга примитивных моделей с совпадающими полями, мапстер действительно просто и удобно использовать. Даже DI контейнер не нужен. Пример будет в практическом разделе.
-
Гибкая возможность настройки
В данном пункте я имею ввиду не только возможность задавать кастомный маппинг сложных моделей данных в конфиге, но и настройку билда проекта, при которой не получится запустить приложение, если не будет задан маппинг всех полей для моделей. Подробнее об этом будет описано в практике.
Дополнительно оставлю ссылку на статью, где более подробно описано сравнение мапстера и автомаппера с конкретными примерами реализации.
Теперь предлагаю перейти непосредственно к практическим примерам.
Практика
Дальнейшие примеры будут рассмотрены для .NET 7 в Visual Studio 2022 (среда разработки не принципиальна).
Для приложения будем использовать следующие модельки:
-
User – модель данных в базе
public class User { public Guid Id { get; set; } public string Name { get; set; } public string Email { get; set; } public string Password { get; set; } }
-
UserDto – моделька, которую мы хотим отдавать
public class UserDto { public Guid Id { get; set; } public string Name { get; set; } public string Email { get; set; } }
-
UserDomainModel — моделька, которую мы хотим отдавать, но с подвохом
public class UserDomainModel { public Guid UserId { get; set; } public string UserName { get; set; } public string UserEmail { get; set; } }
Непосредственно с ними мы и будем развлекаться, но сначала напишем простой маппинг для User и UserDto.
public static UserDto Map(this User user) { return new UserDto { Id = user.Id, Name = user.Name, Email = user.Email }; }
и подтянем необходимые NuGet пакеты:

По опыту использования, для себя я смог выделить четыре типа использования мапстера:
-
Mapster как аналог Automapper;
-
«Одноклеточный» Mapster;
-
Mapster и работа с базой;
-
Mapster и фокусы кодогенерации.
По такому порядку и пойдем.
Mapster как аналог Automapper
Для тех, кто пользовался AutoMapper, достаточно привычный сценарий использования, где мы создаем класс маппера, закидываем его в DI-контейнер и используем по необходимости.
Создадим файл конфигурации:
public class RegisterMapper : IRegister { public void Register(TypeAdapterConfig config) { config.NewConfig<User, UserDto>() .RequireDestinationMemberSource(true); } }
IRegister находится в пространстве имен Mapster.
RequireDestinationMemberSource указывает, что проект не соберется в случае, если невозможно автоматически преобразовать одну модель в другую (в таком случае, нужно руками прописать маппинг свойств), но в нашем случае названия свойств у User и UserDto совпадают, поэтому не требуется больше никаких дополнительных настроек, только зарегистрировать конфиг и сам маппер в DI:
builder.Services.AddSingleton(() => //Добавляем конфиг { var config = new TypeAdapterConfig(); new RegisterMapper().Register(config); return config; }); builder.Services.AddScoped<IMapper, ServiceMapper>(); //Добавляем сам маппер
К достоинствам такого подхода можно отнести то, что мы можем использовать Mapster там, где он нам необходим и проделывать интересные фокусы, о которых мы поговорим позже.
«Одноклеточный» Mapster
Из названия понятно, что это максимально простой способ использования этой библиотеки. Собственно, с ним я и сталкивался чаще всего. Дело в том, что для маппинга моделей нам нужно всего лишь вызывать метод .Adapt у исходных моделей.
var user = new User //Создаем сущность User { Id = Guid.NewGuid(), Name = "Tom", Email = "Tom@mail.com", Password = "123" }; var userDto = user.Adapt<UserDto>(); //Маппим user в userDto
Всё, конец. Действительно просто и лаконично, а самое прекрасное то, что если у целевой модели и модели источника названия полей совпадают, то мапстер сам смаппит все их значения. И даже если у нас сложная модель, он всё сделает сам, обходя объект рекурсивно. Согласитесь, экономия кучи времени в случае, если у нас модель в модели в модели с кучей полей с совпадающими названиями.
Однако если поля будут иметь различные названия, тут уже придется поработать руками, иначе в итоге мы получим дефолтные значения.
Чтобы всё работало как надо, создаем конфиг, где указываем все правила маппинга для всех моделей:
public class MapsterConfig { public MapsterConfig() { TypeAdapterConfig<User, UserDomainModel>.NewConfig() .Map(dest => dest.UserId, src => src.Id) .Map(dest => dest.UserName, src => src.Name) .Map(dest => dest.UserEmail, src => src.Email); } }
после чего регистрируем его в DI.
var mapsterConfig = new MapsterConfig(); builder.Services.AddSingleton<MapsterConfig>();
На этом наши манипуляции заканчиваются, и мы снова радуемся простоте и лаконичности.
Mapster и работа с базой
В случае, если у нас в базе лежит модель с кучей ненужных для DTO полей, а тянуть кучу лишних данных не хочется, можно обратиться к мапстеру и попросить его сразу возвращать DTOшки из базы. Он будет настолько любезен, что даже запрос будет сформирован с учетом только необходимых нам полей. Примером же будет служить наш бедный на поля юзер.
Так выглядит классический запрос на получение всех юзеров из базы:
var users = await _context.Users.ToListAsync(); var result = users.Select(x => x.Map());
А так будет выглядеть запрос, если мы воспользуемся мапстером:
var users = _mapper.From(_context.Users).ProjectToType<UserDto>(); var result = await users.ToListAsync();
_mapper в рассматриваемом случае – маппер из первого сценария использования, где мы прокидываем его через DI.
Разница не сказать что большая, но самое интересное происходит в запросах, которые собирает Ef.Core, обращаясь к базе данных:

Сверху показан запрос, который генерируется в случае использования мапстера, а снизу – обычный. Таким образом, если у нас хранятся действительно тяжелые модели, которые затратно тянуть в память, это очень даже рабочий вариант использования.
На практике такое встречается крайне редко (настолько, что я никогда такого не видел в проектах), но возможность всё равно крайне интересная.
Рассмотренный способ применения можно использовать в высоконагруженных приложениях, где идет большая нагрузка на базу данных, а кеширование по той или иной причине не завезли. Такое применение позволит избежать лишней нагрузки на базу, сократит трафик, который идет к ней и от нее, что в целом положительно скажется на скорости работы приложения.
Mapster и фокусы кодогенерации
Помимо всего прочего Mapster, а если быть точнее Mapster.Tool, умеет в кодогенерацию при сборке проекта. Это позволяет автоматически генерировать классы мапперов во время сборки проекта. Инструкция по установке находится на страничке в гитхабе.
После установки, производим следующие операции:
Добавляем в файл проекта (.csproj) следующий код для генерации:
<Target Name="Mapster" AfterTargets="AfterBuild"> <Exec WorkingDirectory="$(ProjectDir)" Command="dotnet tool restore" /> <Exec WorkingDirectory="$(ProjectDir)" Command="dotnet mapster model -a "$(TargetDir)$(ProjectName).dll"" /> <Exec WorkingDirectory="$(ProjectDir)" Command="dotnet mapster extension -a "$(TargetDir)$(ProjectName).dll"" /> <Exec WorkingDirectory="$(ProjectDir)" Command="dotnet mapster mapper -a "$(TargetDir)$(ProjectName).dll"" /> </Target> <ItemGroup> <Generated Include="**\*.g.cs" /> </ItemGroup> <ItemGroup> <Folder Include="Mappers\" /> </ItemGroup> <Target Name="CleanGenerated"> <Delete Files="@(Generated)" /> </Target>
И для очистки:
<ItemGroup> <Generated Include="**\*.g.cs" /> </ItemGroup> <Target Name="CleanGenerated"> <Delete Files="@(Generated)" /> </Target>
Очистку можно выполнять из командной строки с помощью команды
dotnet msbuild -t:CleanGenerated
Но это необходимо в том случае, если мы меняем интерфейсы маппера.
Далее нам необходимо создать интерфейс маппера, по которому будет генерироваться сама реализация маппинга.
В нем указываем, все, что хотим маппить, и накидываем на сам интерфейс атрибут [Mapper]:
[Mapper] public interface IUserMapper { UserDto MapTo(User user); User MapTo(UserDto userDto); }
И засовываем это всё в DI.
builder.Services.AddScoped<IUserMapper, UserMapper>();
В целом всё готово. Просто вызываем в нужном нам месте маппинг:
var userDto = _userMapper.MapTo(user);
И собираем проект. После чего можно посмотреть на сгенерировавшийся класс:
public partial class UserMapper : IUserMapper { public UserDto MapTo(User p1) { return p1 == null ? null : new UserDto() { Id = p1.Id, Name = p1.Name, Email = p1.Email }; } public User MapTo(UserDto p2) { return p2 == null ? null : new User() { Id = p2.Id, Name = p2.Name, Email = p2.Email }; } }
А помните в самом начале раздела код маппинга, который я писал сам руками? Мапстер сгенерировал его даже лучше меня (привет, проверка на null).
Кстати, так выглядит сгенерированный класс:

Генерировать можно не только маппинги по интерфейсам, но и модельки и методы расширений, но это уже к документации.
Данный подход я тоже пока не встречал в реальных проектах, но выглядит он весьма интересно и перспективно. Более подробно об этих фичах рассказывалось тут.
Что касается применения, то можно рассмотреть опять же высоконагруженные системы, где мы гонимся за минимизацией расходов памяти и времени, которые использует приложение. Также данный подход позволяет сократить затрачиваемое время разработчиков на внедрение новой функциональности и поиск ошибок, связанных с некорректными возвращаемыми данными.
UPD: Усложняем примеры
С простым юзером в целом и так всё понятно, теперь поэкспериментируем с типами данных. Для этого возьмем новые модельки:
Модель здания
public class House { public Guid Id { get; set; } public string Name { get; set; } public string Number { get; set; } public DateTime BuildStarDate { get; set; } public string BuildFinishDate { get; set; } public BuildingType BuildingType { get; set; } public Guid HouseTypeId { get; set; } public HouseType HouseType { get; set; } }
Модель типа здания
public class HouseType { public Guid Id { get; set; } public string TypeName { get; set; } }
Enun для типа здания (не такой, как прошлый тип)
public enum BuildingType { Hospital = 0, Theater = 1 }
Да, топорный пример, но будет вполне показательным.
А мапить это будем в 2 модельки домов.
-
Попроще:
public class HouseDto { public Guid Id { get; set; } public string Name { get; set; } public int Number { get; set; } public string BuildStarDate { get; set; } public DateTime BuildFinishDate { get; set; } public BuildingType BuildingType { get; set; } public HouseTypeDto HouseType { get; set; } }
К ней вдогонку добавим DTOшку для типа:
public class HouseTypeDto { public Guid Id { get; set; } public string TypeName { get; set; } }
-
И поинтереснее:
public class FlatHouseDto { public Guid Id { get; set; } public string Name { get; set; } public int Number { get; set; } public string BuildStarDate { get; set; } public DateTime BuildFinishDate { get; set; } public BuildingType BuildingType { get; set; } public string HouseTypeName { get; set; } }
На этом подготовка закончена. Обращаю внимание, что у House и (Flat)HouseDto типы данных для дат поменяны местами, и поле Number отличается (int vs string).
Пойдем итеративно, начиная с самого ленивого способа.
Для вывода списка всех домов из базы проведем следующую манипуляцию через .Adapt:
var houses = await _houseRepository.GetAllAsync(); var result = houses.Select(x => x.Adapt<HouseDto>()).ToList();
Вызовем метод, получим данные:

Всё как и ожидалось: мапстер рекурсивно смаппил всё, что видел, привел даты к нужному типу (тут прошу обратить внимание, что есть строка, а что дата).
Теперь пускаем метод для FlatHouseDto

houseTypeName вернулся пустой, поскольку мапстер не смог понять, что с этим делать. Добавляем правило для одного свойства в конфиг, который создавали еще для юзера в простых примерах:
TypeAdapterConfig<House, FlatHouseDto>.NewConfig() .Map(dest => dest.HouseTypeName, src => src.HouseType.TypeName);
И получаем следующий результат:

Радуемся добавленным двум строчкам кода.
Теперь посмотрим в сторону автогенерации. Создадим интерфейс:
[Mapper] public interface IHouseMapper { HouseDto MapTo(House house); FlatHouseDto MapToFlat(House house); }
И запускаем билд проекта (при условии, что мы проделали операции из простых примеров).
Иии… тут мапстер уже не очень молодец, но старается. Вот что он смог сгенерировать:
public partial class HouseMapper : IHouseMapper { public HouseDto MapTo(House p1) { return p1 == null ? null : new HouseDto() { Id = p1.Id, Name = p1.Name, Number = p1.Number == null ? 0 : int.Parse(p1.Number), BuildStarDate = p1.BuildStarDate.ToString(), BuildFinishDate = p1.BuildFinishDate == null ? default(DateTime) : DateTime.Parse(p1.BuildFinishDate), BuildingType = p1.BuildingType, HouseType = p1.HouseType == null ? null : new HouseTypeDto() { Id = p1.HouseType.Id, TypeName = p1.HouseType.TypeName } }; } public FlatHouseDto MapToFlat(House p2) { return p2 == null ? null : new FlatHouseDto() { Id = p2.Id, Name = p2.Name, Number = p2.Number == null ? 0 : int.Parse(p2.Number), BuildStarDate = p2.BuildStarDate.ToString(), BuildFinishDate = p2.BuildFinishDate == null ? default(DateTime) : DateTime.Parse(p2.BuildFinishDate), BuildingType = p2.BuildingType }; } }
Всё круто, всё здорово, но HouseTypeName опять потерялся. Тут опять придется добавлять маппинг руками. Однако, замечу, что мы не потеряем все, что дописали. Генерация заново происходит только в том случае, если у нас в целом нет реализации интерфейса. Во всех остальных случаях — всё ожидаемо, никаких случайных выстрелов с удалением важного функционала не происходит (до тех пор, пока мы сами не почистим проект).
Выводы
Хотелось бы сказать, что Mapster действительно способен упростить разработку и избавить от части рутины. Им удобно и приятно пользоваться, а «одноклеточный» сценарий так просто находка для любителей не заморачиваться по мелочам. Данная статья показывает основные, но далеко не полные возможности этой библиотеки. Пускай и круг решаемых задач глобально ограничивается работой с преобразованием одних данных в другие, но гибкость этой библиотеки позволяет не только упростить процесс самой разработки, но и улучшить качество поддержки, что положительно повлияет на скорость нахождения багов или доработки приложения.
Спасибо за внимание!
Больше авторских материалов для backend-разработчиков от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.
ссылка на оригинал статьи https://habr.com/ru/articles/797073/
Добавить комментарий