Хватит маппить все руками, используй Mapster
Привет, Хабр! Меня зовут Георгий, я С#-разработчик в SimbirSoft. Хочу рассказать об опыте использования библиотеки Mapster: как она может упростить разработку, сэкономить силы и частично избавиться от рутины маппинга.
Данная статья подойдет и тем, кто только собирается открыть для себя мир автомаппинга, и тем, кто хочет найти для себя альтернативу используемой библиотеки. Для полного понимания, что тут будет происходить, желательно обладать базовым пониманием C#, знать о существовании DI и подозревать, что рефлексия не так проста, как кажется. Ну и LINQ с EF.Core, куда же без них хотя про них достаточно просто когда-то слышать и примерно представлять, зачем они нужны.
Сразу оговорюсь, что в этой статье я собрал не только свой опыт, но и опыт других специалистов — авторов статей и видео, и привел наглядные примеры, как с этим работать. Все ссылки на источники вы найдете внутри.
Если вы пресытились хвалебными отзывами и различными сравнениями мапперов по производительности — сразу пропускайте вводную часть и прыгайте к примерам. Там я подробно расскажу, как этим всем пользоваться.
Вводная часть
Mapster — это имя вам что-нибудь говорит?
Для тех, кто любит цифры
Коротко об особенностях Mapster
Практика
Mapster как аналог Automapper
«Одноклеточный» Mapster
Mapster и работа с базой
Mapster и фокусы кодогенерации
Выводы
Вводная часть
Поскольку каждый уважающий себя разработчик хочет, чтобы его проект был удобным, красивым и масштабируемым с точки зрения архитектуры, он старается разделять слои приложения, чтобы в дальнейшем упростить поддержку и развитие. В целом звучит круто, но достаточно сложно, поскольку правильное разделение слоев часто приводит к неконтролируемому росту числа моделей данных, которыми слои между собой обмениваются.
Пример:
Допустим, у нас есть класс репозиторий, который ходит в базу и возвращает нам данные. Мы не хотим, чтобы эта полная модель данных уходила куда-то дальше, поэтому мы преобразуем полученную модель в 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"
}
};
}
Как создавать, конфигурировать и прокидывать тестируемые мапперы, я рассказывать не буду, поскольку это другая тема (кроме мапстера, о нем речь пойдет позже), но класс бенчмарков будет выглядеть примерно так:
Сразу отмечу, что Mapster в данном случае — это как раз «одноклеточное» использование, а MapsterMapper — явное использование кодогенерации.
Немного об окружении:
Пускаем тесты, и видим результат:
Mapperly оказался самым быстрым и наименее прожорливым. Всё, господа, расходимся, тема закрыта.
Если же говорить серьезно, то мапстер показал достойный результат, но его главная фишка раскроется уже непосредственно в применении, о котором речь пойдет в практическом разделе.
Коротко об особенностях 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()
.RequireDestinationMemberSource(true);
}
}
IRegister находится в пространстве имен Mapster.
RequireDestinationMemberSource указывает, что проект не соберется в случае, если невозможно автоматически преобразовать одну модель в другую (в таком случае, нужно руками прописать маппинг свойств), но в нашем случае названия свойств у User и UserDto совпадают, поэтому не требуется больше никаких дополнительных настроек, только зарегистрировать конфиг и сам маппер в DI:
builder.Services.AddSingleton(() => //Добавляем конфиг
{
var config = new TypeAdapterConfig();
new RegisterMapper().Register(config);
return config;
});
builder.Services.AddScoped(); //Добавляем сам маппер
К достоинствам такого подхода можно отнести то, что мы можем использовать Mapster там, где он нам необходим и проделывать интересные фокусы, о которых мы поговорим позже.
«Одноклеточный» Mapster
Из названия понятно, что это максимально простой способ использования этой библиотеки. Собственно, с ним я и сталкивался чаще всего. Дело в том, что для маппинга моделей нам нужно всего лишь вызывать метод .Adapt у исходных моделей.
var user = new User //Создаем сущность User
{
Id = Guid.NewGuid(),
Name = "Tom",
Email = "Tom@mail.com",
Password = "123"
};
var userDto = user.Adapt(); //Маппим user в userDto
Всё, конец. Действительно просто и лаконично, а самое прекрасное то, что если у целевой модели и модели источника названия полей совпадают, то мапстер сам смаппит все их значения. И даже если у нас сложная модель, он все сделает сам, обходя объект рекурсивно. Согласитесь, экономия кучи времени в случае, если у нас модель в модели в модели с кучей полей с совпадающими названиями.
Однако если поля будут иметь различные названия, тут уже придется поработать руками, иначе в итоге мы получим дефолтные значения.
Чтобы все работало как надо, создаем конфиг, где указываем все правила маппинга для всех моделей:
public class MapsterConfig
{
public MapsterConfig()
{
TypeAdapterConfig.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();
На этом наши манипуляции заканчиваются, и мы снова радуемся простоте и лаконичности.
Mapster и работа с базой
В случае, если у нас в базе лежит модель с кучей ненужных для DTO полей, а тянуть кучу лишних данных не хочется, можно обратиться к мапстеру и попросить его сразу возвращать DTOшки из базы. Он будет настолько любезен, что даже запрос будет сформирован с учетом только необходимых нам полей. Примером же будет служить наш бедный на поля юзер.
Так выглядит классический запрос на получение всех юзеров из базы:
var users = await _context.Users.ToListAsync();
var result = users.Select(x => x.Map());
А так будет выглядеть запрос, если мы воспользуемся мапстером:
var users = _mapper.From(_context.Users).ProjectToType();
var result = await users.ToListAsync();
_mapper в рассматриваемом случае — маппер из первого сценария использования, где мы прокидываем его через DI.
Разница не сказать что большая, но самое интересное происходит в запросах, которые собирает Ef.Core, обращаясь к базе данных:
Сверху показан запрос, который генерируется в случае использования мапстера, а снизу — обычный. Таким образом, если у нас хранятся действительно тяжелые модели, которые затратно тянуть в память, это очень даже рабочий вариант использования.
На практике такое встречается крайне редко (настолько, что я никогда такого не видел в проектах), но возможность все равно крайне интересная.
Рассмотренный способ применения можно использовать в высоконагруженных приложениях, где идет большая нагрузка на базу данных, а кеширование по той или иной причине не завезли. Такое применение позволит избежать лишней нагрузки на базу, сократит трафик, который идет к ней и от нее, что в целом положительно скажется на скорости работы приложения.
Mapster и фокусы кодогенерации
Помимо всего прочего Mapster, а если быть точнее Mapster.Tool, умеет в кодогенерацию при сборке проекта. Это позволяет автоматически генерировать классы мапперов во время сборки проекта. Инструкция по установке находится на страничке в гитхабе.
После установки, производим следующие операции:
Добавляем в файл проекта (.csproj) следующий код для генерации:
И для очистки:
Очистку можно выполнять из командной строки с помощью команды
dotnet msbuild -t:CleanGenerated
Но это необходимо в том случае, если мы меняем интерфейсы маппера.
Далее нам необходимо создать интерфейс маппера, по которому будет генерироваться сама реализация маппинга.
В нем указываем, все, что хотим маппить, и накидываем на сам интерфейс атрибут [Mapper]:
[Mapper]
public interface IUserMapper
{
UserDto MapTo(User user);
User MapTo(UserDto userDto);
}
и засовываем это все в DI.
builder.Services.AddScoped();
В целом всё готово. Просто вызываем в нужном нам месте маппинг:
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).
Кстати, так выглядит сгенерированный класс:
Генерировать можно не только маппинги по интерфейсам, но и модельки и методы расширений, но это уже к документации.
Данный подход я тоже пока не встречал в реальных проектах, но выглядит он весьма интересно и перспективно. Более подробно об этих фичах рассказывалось тут.
Что касается применения, то можно рассмотреть опять же высоконагруженные системы, где мы гонимся за минимизацией расходов памяти и времени, которые использует приложение. Также данный подход позволяет сократить затрачиваемое время разработчиков на внедрение новой функциональности и поиск ошибок, связанных с некорректными возвращаемыми данными.
Выводы
Хотелось бы сказать, что Mapster действительно способен упростить разработку и избавить от части рутины. Им удобно и приятно пользоваться, а «одноклеточный» сценарий так просто находка для любителей не заморачиваться по мелочам. Данная статья показывает основные, но далеко не полные возможности этой библиотеки. Пускай и круг решаемых задач глобально ограничивается работой с преобразованием одних данных в другие, но гибкость этой библиотеки позволяет не только упростить процесс самой разработки, но и улучшить качество поддержки, что положительно повлияет на скорость нахождения багов или доработки приложения.
Спасибо за внимание!
Больше авторских материалов для backend-разработчиков от моих коллег читайте в соцсетях SimbirSoft — ВКонтакте и Telegram.