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

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

19b1ac0389dc0c61c7de9692143c339c.jpg

Данная статья подойдет и тем, кто только собирается открыть для себя мир автомаппинга, и тем, кто хочет найти для себя альтернативу используемой библиотеки. Для полного понимания, что тут будет происходить, желательно обладать базовым пониманием 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"
        }
    };
} 

Как создавать, конфигурировать и прокидывать тестируемые мапперы, я рассказывать не буду, поскольку это другая тема (кроме мапстера, о нем речь пойдет позже), но класс бенчмарков будет выглядеть примерно так:

278162e52692ff56479f36bce3e95bd9.png

Сразу отмечу, что Mapster в данном случае — это как раз «одноклеточное» использование, а MapsterMapper — явное использование кодогенерации.

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

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

d5103f8d8c3d08918823c919193e94cb.png

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 пакеты:

06fccb5f415a9fa4c8d37bbf4dde9223.png

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

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

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

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

  4. 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, обращаясь к базе данных:

2a8cf2833c9c0b2ac5421794d346b6dc.png

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

На практике такое встречается крайне редко (настолько, что я никогда такого не видел в проектах), но возможность все равно крайне интересная.

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

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). 

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

e425f6bfa9d950cb9d105de217431199.png

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

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

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

Выводы

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

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

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

© Habrahabr.ru