Pull to refresh
85.78
SimbirSoft
Лидер в разработке современных ИТ-решений на заказ

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

Level of difficultyMedium
Reading time13 min
Views20K

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

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

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

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

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

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

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

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

  3. Практика

    Mapster как аналог Automapper

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

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

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

    UPD: Усложняем примеры

  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"
        }
    };
} 

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

Но класс бенчмарков будет выглядеть примерно так
[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

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

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

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

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

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

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

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 модельки домов.

  1. Попроще:

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; }
    }
  1. И поинтереснее:

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.

Tags:
Hubs:
Total votes 8: ↑6 and ↓2+6
Comments24

Articles

Information

Website
www.simbirsoft.com
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия