Хватит маппить все руками, используй Mapster

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Данная статья подойдет и тем, кто только собирается открыть для себя мир автомаппинга, и тем, кто хочет найти для себя альтернативу используемой библиотеки. Для полного понимания, что тут будет происходить, желательно обладать базовым пониманием C#, знать о существовании DI и подозревать, что рефлексия не так проста, как кажется. Ну и LINQ с EF.Core, куда же без них (хотя про них достаточно просто когда-то слышать и примерно представлять, зачем они нужны).

Сразу оговорюсь, что в этой статье я собрал не только свой опыт, но и опыт других специалистов — авторов статей и видео, и привел наглядные примеры, как с этим работать. Все ссылки на источники вы найдете внутри.

Если вы пресытились хвалебными отзывами и различными сравнениями мапперов по производительности – сразу пропускайте вводную часть и прыгайте к примерам. Там я подробно расскажу, как этим всем пользоваться.

  1. Вводная часть

  2. Mapster — это имя вам что-нибудь говорит?

    Для тех, кто любит цифры

    Коротко об особенностях Mapster

  3. Практика

    Mapster как аналог Automapper

    «Одноклеточный» Mapster

    Mapster и работа с базой

    Mapster и фокусы кодогенерации

  4. Выводы

Вводная часть

Поскольку каждый уважающий себя разработчик хочет, чтобы его проект был удобным, красивым и масштабируемым с точки зрения архитектуры, он старается разделять слои приложения, чтобы в дальнейшем упростить поддержку и развитие. В целом звучит круто, но достаточно сложно, поскольку правильное разделение слоев часто приводит к неконтролируемому росту числа моделей данных, которыми слои между собой обмениваются.

Пример:

Допустим, у нас есть класс репозиторий, который ходит в базу и возвращает нам данные. Мы не хотим, чтобы эта полная модель данных уходила куда-то дальше, поэтому мы преобразуем полученную модель в 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 – явное использование кодогенерации.

Немного об окружении:

  • Процессор: AMD Ryzen 7 3700x;

  • Среда разработки: Visual Studio 2022;

  • Версия .NET 7. 

Пускаем тесты, и видим результат:

Mapperly оказался самым быстрым и наименее прожорливым. Всё, господа, расходимся, тема закрыта. 

Если же говорить серьезно, то мапстер показал достойный результат, но его главная фишка раскроется уже непосредственно в применении, о котором речь пойдет в практическом разделе. 

Коротко об особенностях Mapster

Если говорить об основных отличительных особенностях этой библиотеки, то можно выделить следующее: 

  1. Кодогенерация

Mapster использует подход кодогенерации, что позволяет ему достаточно быстро работать. Не сказать, что это какой-то уникальный подход, но тот же AutoMapper использует рефлексию, что сказывается на его производительности.

  1. Возможность примитивного использования мапстера из коробки

Для маппинга примитивных моделей с совпадающими полями, мапстер действительно просто и удобно использовать. Даже DI контейнер не нужен. Пример будет в практическом разделе.

  1. Гибкая возможность настройки

В данном пункте я имею ввиду не только возможность задавать кастомный маппинг сложных моделей данных в конфиге, но и настройку билда проекта, при которой не получится запустить приложение, если не будет задан маппинг всех полей для моделей. Подробнее об этом будет описано в практике.

Дополнительно оставлю ссылку на статью, где более подробно описано сравнение мапстера и автомаппера с конкретными примерами реализации. 

Теперь предлагаю перейти непосредственно к практическим примерам.

Практика 

Дальнейшие примеры будут рассмотрены для .NET 7 в Visual Studio 2022 (среда разработки не принципиальна).

Для приложения будем использовать следующие модельки:

  1. User – модель данных в базе

public class User
{
    public Guid Id { get; set; }

    public string Name { get; set; }

    public string Email { get; set; }

    public string Password { get; set; }
}
  1. UserDto – моделька, которую мы хотим отдавать

public class UserDto
{
    public Guid Id { get; set; }

    public string Name { get; set; }

    public string Email { get; set; }
}
  1. 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 пакеты:

По опыту использования, для себя я смог выделить четыре типа использования мапстера:

  1. Mapster как аналог Automapper;

  2. «Одноклеточный» Mapster;

  3. Mapster и работа с базой;

  4. 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 &quot;$(TargetDir)$(ProjectName).dll&quot;" />
		<Exec WorkingDirectory="$(ProjectDir)" Command="dotnet mapster extension -a &quot;$(TargetDir)$(ProjectName).dll&quot;" />
		<Exec WorkingDirectory="$(ProjectDir)" Command="dotnet mapster mapper -a &quot;$(TargetDir)$(ProjectName).dll&quot;" />
	</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). 

Кстати, так выглядит сгенерированный класс:

Генерировать можно не только маппинги по интерфейсам, но и модельки и методы расширений, но это уже к документации.

Данный подход я тоже пока не встречал в реальных проектах, но выглядит он весьма интересно и перспективно. Более подробно об этих фичах рассказывалось тут. 

Что касается применения, то можно рассмотреть опять же высоконагруженные системы, где мы гонимся за минимизацией расходов памяти и времени, которые использует приложение. Также данный подход позволяет сократить затрачиваемое время разработчиков на внедрение новой функциональности и поиск ошибок, связанных с некорректными возвращаемыми данными.

Выводы

Хотелось бы сказать, что Mapster действительно способен упростить разработку и избавить от части рутины. Им удобно и приятно пользоваться, а «одноклеточный» сценарий так просто находка для любителей не заморачиваться по мелочам. Данная статья показывает основные, но далеко не полные возможности этой библиотеки. Пускай и круг решаемых задач глобально ограничивается работой с преобразованием одних данных в другие, но гибкость этой библиотеки позволяет не только упростить процесс самой разработки, но и улучшить качество поддержки, что положительно повлияет на скорость нахождения багов или доработки приложения.

Спасибо за внимание!

Больше авторских материалов для backend-разработчиков от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.

Источник: https://habr.com/ru/companies/simbirsoft/articles/797073/


Интересные статьи

Интересные статьи

13 января 2024 года компания-разработчик космического аппарата Astrorobotic сообщила, что Peregrine пролетел расстояние до орбиты Луны (384 тыс. км), но прилуниться не сможет. Утечка топлива на борту ...
Если вы не специалист по REST, то, вероятно, в своих ответах постоянно используете одни и те же HTTP-коды, в основном 200, 404 и 500. Если применяется аутентификация, то, возможно, добавляются 401 и 4...
В этой статье речь пойдет о том, почему использовать метод unwrap() для типов Result в продакшн коде Rust крайне нежелательно.
От браузерного движка, который изначально был малоизвестной альтернативой, применявшейся в непопулярном браузере, до чемпиона, захватившего весь рынок. Вероятно, иногда вы встреч...
В недавнем исследовании, опубликованном в техническом издании «The Information» приводится расчет, согласно которому различные компании потратили около 16 миллиардов долларов на проекты, связ...