Привет, Хабр! Меня зовут Георгий, я С#-разработчик в 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.