Как стать автором
Обновить

DotNet быстрый маппинг моделей с Mapster Tool

Время на прочтение23 мин
Количество просмотров10K

Предисловие

Современные разработчики все чаще стараются создавать удобные с точки зрения архитектуры системы. Монолит заменяется на микро-сервисы, функционал классов и библиотек больше не выходит за границы своей ответственности (но это не точно), разработчики все больше беспокоятся о правильной архитектуре, нежели о внутренней реализации сервисов и их оптимизации

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

В чем проблема

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

При написании кода Вы применили стандартное разделение приложения на 3 слоя: [Api (доступ к системе), Service (анализатор транзакций) и Db (доступ к БД)]. К Базе данных Банк Вам предоставил доступ к следующим таблицам: [Пользователь, Транзакция, ЖурналТранзакций]

Как Вы понимаете, уровень Service должен получить данные из уровня Db для анализа, но полностью привязываться к моделям таблиц не всегда хорошая идея, т.к. это приводит к размытию границ между слоями приложения. Это довольно стандартная проблема и решается она простым маппингом полей между 2-мя соответствующими моделями слоя Db и слоя Service. Те, кто сталкивался с данной ситуацией, уже понимают к чему я клоню. Если в нашем приложении несколько слоев и несколько таблиц в БД, то проблем нет. Проблемы возникают, когда приложение начинает расти и количество связей выходит за разумные рамки. Писать преобразования моделей слоя Db в модели слоя Service и наоборот становится все затратнее по времени, что плодит дополнительные издержки в поддержке проекта (а время - это деньги!). Я уже не говорю о новых слоях, которые скорее всего появятся в Вашем проекте. В таких случаях, как правило, начинают применять авто-мапперы (библиотеки для автоматического маппинга моделей между собой). Однако преобразования моделей в runtime всегда будут выполняться медленнее, чем прямое преобразование по заранее определенным полям, даже с учетом всех оптимизаций. Данные задержки при разовом преобразовании через авто-маппер обычный человек никогда не заметит, но при большом количестве итераций производительность системы может существенно снизиться

Маппинг моделей

Единого решения всех проблем не существует - это утопия. Каждая ситуация уникальна и требует анализа исходных данных. При решении проблемы преобразования моделей из вышеописанной задачи мы можем выделить 3 возможных сценария:

  1. Издержки на авто-маппинг не вызывают серьезных временных издержек в проекте и гнева заказчика;

  2. Производительность системы удовлетворительна, но издержки на маппинг существенны;

  3. Производительности системы не хватает и нужно срочно что-то делать.

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

Нам остается либо оптимизировать другие части проекта для приведения общей производительности к допустимой норме, либо оптимизировать сам авто-маппинг. Далее попробуем решить проблему именно вторым способом

Кодогенерация маппинг сервисов

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

Количество библиотек, реализующих авто-маппинг моделей, довольно большое. Самая популярная dotnet библиотека авто-маппинга на 2022 год остается AutoMapper (>350 млн. скачиваний из nuget). Эта библиотека заняла рынок авто-маппинга и уверенно на нем держится. Спустя несколько лет после создания AutoMapper было разработано новое решение под названием Mapster. Из описания данной библиотеки можно можно предположить, что основной курс был сделан на производительность. Можно было бы остановиться на данном варианте и провести замеры в бенчмарке, но при более глубоком изучении возможностей библиотеки оказалось, что с недавних пор она стала поддерживать кодогенерацию классов-мапперов. По сути, мы получаем авто-маппинг, т.к. нам не нужно больше прописывать все преобразования вручную, но при этом данные преобразования будут создаваться сами при сборки проекта, а не в runtime. Данный инструмент имеет название Mapster.Tool

Tool to generate object mapping using Mapster

Демонстрация работы Mapster.Tool

Для демонстрации работы инструмента был создан проект Mapster.Tool.HowTo

Для примера работы инструмента начнем реализовывать анализатор транзакций из начала статьи. Создадим условный проект для работы с Базой данных (Для удобства не будем создавать саму БД, а создадим только имитацию работы с ней)

Структура Db проекта
Структура Db проекта

В директории PersistModels содержатся модели соответствующих таблиц Базы данных. Класс DbContext содержит логику получения списков сущностей Базы данных

Реализация классов проекта Db
namespace DbLoader.PersistModels
{
    /// <summary>
    /// БД модель с данными транцакции.
    /// </summary>
    public class Transaction
    {
        public int Id { get; set; }
        
        /// <summary>
        /// Дата и время транзакции.
        /// </summary>
        public DateTimeOffset TransactDate { get; set; }

        /// <summary>
        /// Сумма транзакции.
        /// </summary>
        public decimal Amount { get; set; }

        /// <summary>
        /// Валюта. 
        /// См. список валют https://somesite.ru/currency-list
        /// </summary>
        public char Currency { get; set; }
    }
}
namespace DbLoader.PersistModels
{
    /// <summary>
    /// Бд модель с данными пользователя.
    /// </summary>
    public class User
    {
        public int Id { get; set; }

        /// <summary>
        /// Имя пользователя.
        /// </summary>
        public string FName { get; set; }

        /// <summary>
        /// Фамилия пользователя.
        /// </summary>
        public string LName { get; set; }

        /// <summary>
        /// Роль пользователя.
        /// А - администратор, U - пользователь.
        /// </summary>
        public char Role { get; set; }
    }
}
namespace DbLoader.PersistModels
{
    /// <summary>
    /// БД модель журнала совершенных транзакций.
    /// </summary>
    public class UserTransaction
    {
        public int Id { get; set; }

        /// <summary>
        /// Номер отправителя (User).
        /// </summary>
        public int SenderId { get; set; }

        /// <summary>
        /// Номер получателя (User).
        /// </summary>
        public int ReceiverId { get; set; }

        /// <summary>
        /// Номер данных транзакции (Transaction).
        /// </summary>
        public int TransactId { get; set; }
    }
}
using DbLoader.PersistModels;
namespace DbLoader
{
    public class DbContext
    {
        // Количество пользователей
        private const int UserCount = 10;
        
        // Количество транзакций
        private const int TransactionCount = 5;

        public List<User> Users { get; set; }
        public List<Transaction> Transactions { get; set; }
        public List<UserTransaction> UserTransactions { get; set; }

        public DbContext()
        {
            Init();
        }

        public void Init()
        {
            Users = Enumerable.Range(1, UserCount).Select(usrId => new User
            {
                Id = usrId,
                FName = $"Name_{usrId}",
                LName = "LastName",
                Role = usrId % 3 == 0 ? 'A' : 'U'
            }).ToList();

            Transactions = Enumerable.Range(1, TransactionCount).Select(tranId => new Transaction
            {
                Id = tranId,
                Amount = Random.Shared.Next(1, 99999),
                Currency = 'R',
                TransactDate = DateTimeOffset.UtcNow.AddDays(-Random.Shared.Next(0, 3))
            }).ToList();

            UserTransactions = Transactions.Select(tran => new UserTransaction
            {
                Id = tran.Id,
                TransactId = tran.Id,
                ReceiverId = Random.Shared.Next(1, UserCount),
                SenderId = Random.Shared.Next(1, UserCount)
            }).ToList();
        }
    }
}

Далее создадим проект Service слоя

Структура Service проекта
Структура Service проекта

В директории DomainModels содержатся модели для работы сервиса. Они похожи на модели Db слоя, но имеют небольшие отличия. Класс TransactionAnalyzer выполняет получение данных с Db проекта, их преобразование в Domain модели и непосредственно сам анализ

Реализация классов проекта Service
namespace TransactionService.DomainModels.Enums
{
    /// <summary>
    /// Доступные валюты.
    /// </summary>
    public enum CurrencyType
    {
        /// <summary>
        /// USD
        /// </summary>
        U = 'U',

        /// <summary>
        /// RUR
        /// </summary>
        R = 'R'
    }
}
namespace TransactionService.DomainModels.Enums
{
    /// <summary>
    /// Роль пользователя.
    /// </summary>
    public enum RoleType
    {
        /// <summary>
        /// Администратор
        /// </summary>
        A = 'A',

        /// <summary>
        /// Пользователь
        /// </summary>
        U = 'U'
    }
}
using TransactionService.DomainModels.Enums;

namespace TransactionService.DomainModels
{
    /// <summary>
    /// Domain модель с данными транцакции.
    /// </summary>
    public class Transaction
    {
        public int Id { get; set; }

        /// <summary>
        /// Дата и время транзакции.
        /// </summary>
        public DateTimeOffset Date { get; set; }

        /// <summary>
        /// Сумма транзакции.
        /// </summary>
        public decimal TransactionSum { get; set; }

        /// <summary>
        /// Валюта. 
        /// </summary>
        public CurrencyType Currency { get; set; }
    }
}
using TransactionService.DomainModels.Enums;

namespace TransactionService.DomainModels
{
    /// <summary>
    /// Domain модель с данными пользователя.
    /// </summary>
    public class User
    {
        public int Id { get; set; }

        /// <summary>
        /// ФИ пользователя.
        /// </summary>
        public string Name { get; set; }

        /// <summary>
        /// Роль пользователя.
        /// </summary>
        public RoleType Role { get; set; }
    }
}
namespace TransactionService.DomainModels
{
    /// <summary>
    /// Domain модель журнала совершенных транзакций.
    /// </summary>
    public class UserTransaction
    {
        public int Id { get; set; }

        /// <summary>
        /// Номер отправителя (User).
        /// </summary>
        public int SenderId { get; set; }

        /// <summary>
        /// Номер получателя (User).
        /// </summary>
        public int ReceiverId { get; set; }

        /// <summary>
        /// Номер данных транзакции (Transaction).
        /// </summary>
        public int TransactId { get; set; }
    }
}

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

В проект Service слоя для начала нам необходимо добавить инструмент Mapster.Tool

dotnet new tool-manifest
dotnet tool install Mapster.Tool

И добавить саму библиотеку Mapster и Mapster.Core

Install-Package Mapster
Install-Package Mapster.Core

Mapster.Tool умеет генерировать 3 вида кода:

  1. Модели, на которые будет выполняться маппинг;

  2. Классы с Extension методами, которые преобразуют исходную модель в указанную;

  3. Обычные классы-сервисы для маппинга моделей.

Первый пункт в данном примере нас не интересует, т.к. все модели мы создали вручную, поэтому добавим в файл проекта настройки для генерации 2 и 3 пунктов. Получим следующую конфигурацию

Конфигурация проекта Service
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <Folder Include="Mapster\Extensions\Generated\" />
    <Folder Include="Mapster\MapServices\Generated\" />
  </ItemGroup>

	<!-- Добавление библиотек Mapster -->
	<ItemGroup>
		<PackageReference Include="Mapster" Version="7.3.0" />
		<PackageReference Include="Mapster.Core" Version="1.2.0" />
	</ItemGroup>

	<!-- Добавление команд для генерации мапперов -->
	<Target Name="Mapster" AfterTargets="AfterBuild">
		<Exec WorkingDirectory="$(ProjectDir)" Command="dotnet tool restore" />
		<Exec WorkingDirectory="$(ProjectDir)" Command="dotnet mapster mapper -a &quot;$(TargetDir)$(ProjectName).dll&quot; -o ./Mapster/MapServices/Generated -p 1" />
		<Exec WorkingDirectory="$(ProjectDir)" Command="dotnet mapster extension -a &quot;$(TargetDir)$(ProjectName).dll&quot; -o ./Mapster/Extensions/Generated -p 1" />
	</Target>

	<!-- Для очистки сгенерированных файлов командой dotnet msbuild -t:CleanGenerated -->
	<ItemGroup>
		<Generated Include="**\*.g.cs" />
	</ItemGroup>
	<Target Name="CleanGenerated">
		<Delete Files="@(Generated)" />
	</Target>

	<!-- Ссылка на Db проект -->
	<ItemGroup>
		<ProjectReference Include="..\DbLoader\DbLoader.csproj" />
	</ItemGroup>

</Project>

Задержимся немного на изучении конфигурации проекта и рассмотрим подробнее блок с добавлением команд генерации. Mapster.Tool команды принимают 1 обязательный параметр для генерации кода, этим параметром является путь к сборке проекта под тегом -a. Также я указал тег -o для указания директории, куда будут генерироваться созданные файлы, и тег -p для генерации полных имен классов. Генерация мапперов будет выполняться при сборке проекта с помощью параметра AfterTargets="AfterBuild"

Для Extension методов маппинга и для обычных мапперов можно указать следующие теги для генерации кода

Параметры генерации обычных и Extension мапперов
  1. a - путь к сборке проекта, в котором происходит генерация (string);

  2. o - путь проекта, куда необходимо генерировать файлы мапперы (если директории нет, то инструмент сам создаст его) (string);

  3. n - пространство имен сгенерированных классов (string);

  4. p - генерация с указанием полного имени класса (bool);

  5. b - базовое пространство имен для сгенерированных классов и пространств имен (string).

Сервисы маппинга

Обыкновенные классы маппинга можно сгенерировать при помощи добавления в проект интерфейса с атрибутом [Mapper]. Для указания дополнительной конфигурации генерации необходимо создать класс от интерфейса IRegister

Структура проекта для генерации мапперов
Структура проекта для генерации мапперов

В интерфейсе ITransactionMapper укажем нужные нам преобразования. Для примера я добавил маппинг Persist моделей в Domain, а также добавил создание UserTransaction из данных транзакции и данных отправителя и получателя. В классе ConfigRegister я добавил особые правила маппинга, без которых явно нельзя преобразовать модели

Реализация настройки генерации
using Mapster;
using TransactionService.DomainModels;

namespace TransactionService.Mapster.MapServices.Abstraction
{
    [Mapper]
    public interface ITransactionMapper
    {
        /// <summary>
        /// Метод маппинга UserTransaction модели из Persist данных пользователей и транзакции 
        /// в Domain модель UserTransaction
        /// </summary>
        /// <param name="data">Persist Данные транзакции, получателя и отправителя</param>
        /// <returns>Domain UserTransaction</returns>
        public UserTransaction MapTo(
            (
                DbLoader.PersistModels.Transaction transaction, 
               (DbLoader.PersistModels.User sender, DbLoader.PersistModels.User reciever) users
            ) data);

        /// <summary>
        /// Метод маппинга UserTransaction модели из Persist в Domain
        /// </summary>
        /// <param name="userTransaction">Persist UserTransaction</param>
        /// <returns>Domain UserTransaction</returns>
        public UserTransaction MapTo(DbLoader.PersistModels.UserTransaction userTransaction);

        /// <summary>
        /// Метод маппинга User модели из Persist в Domain
        /// </summary>
        /// <param name="user">Persist User</param>
        /// <returns>Domain User</returns>
        public User MapTo(DbLoader.PersistModels.User user);

        /// <summary>
        /// Метод маппинга Transaction модели из Persist в Domain
        /// </summary>
        /// <param name="transaction">Persist Transaction</param>
        /// <returns>Domain Transaction</returns>
        public Transaction MapTo(DbLoader.PersistModels.Transaction transaction);
    }
}
using Mapster;
using TransactionService.DomainModels;
using TransactionService.DomainModels.Enums;

namespace TransactionService.Mapster.MapServices.Configuration
{
    public class ConfigRegister : IRegister
    {
        public void Register(TypeAdapterConfig config)
        {
            config
                .NewConfig<(
                        DbLoader.PersistModels.Transaction transaction,
                       (DbLoader.PersistModels.User sender, DbLoader.PersistModels.User reciever) users
                    ), UserTransaction>()
                .Map(d => d.TransactId, s => s.transaction.Id)
                .Map(d => d.SenderId, s => s.users.sender.Id)
                .Map(d => d.ReceiverId, s => s.users.reciever.Id);

            config
                .NewConfig<DbLoader.PersistModels.UserTransaction, UserTransaction>()
                .TwoWays();

            config
                .NewConfig<DbLoader.PersistModels.User, User>()
                .Map(d => d.Role, s => (RoleType)s.Role)
                .Map(d => d.Name, s => $"{s.FName} {s.LName}")
                .TwoWays();

            config
                .NewConfig<DbLoader.PersistModels.Transaction, Transaction>()
                .Map(d => d.Currency, s => (CurrencyType)s.Currency)
                .Map(d => d.Date, s => s.TransactDate)
                .Map(d => d.TransactionSum, s => s.Amount)
                .TwoWays();
        }
    }
}

При сборке проекта в папке Generated появятся файлы мапперов

Сгенерированный маппер
namespace TransactionService.Mapster.MapServices.Abstraction
{
    public partial class TransactionMapper : TransactionService.Mapster.MapServices.Abstraction.ITransactionMapper
    {
        public TransactionService.DomainModels.UserTransaction MapTo(System.ValueTuple<DbLoader.PersistModels.Transaction, System.ValueTuple<DbLoader.PersistModels.User, DbLoader.PersistModels.User>> p1)
        {
            return new TransactionService.DomainModels.UserTransaction()
            {
                SenderId = p1.Item2.Item1.Id,
                ReceiverId = p1.Item2.Item2.Id,
                TransactId = p1.Item1.Id
            };
        }
        public TransactionService.DomainModels.UserTransaction MapTo(DbLoader.PersistModels.UserTransaction p2)
        {
            return p2 == null ? null : new TransactionService.DomainModels.UserTransaction()
            {
                Id = p2.Id,
                SenderId = p2.SenderId,
                ReceiverId = p2.ReceiverId,
                TransactId = p2.TransactId
            };
        }
        public TransactionService.DomainModels.User MapTo(DbLoader.PersistModels.User p3)
        {
            return p3 == null ? null : new TransactionService.DomainModels.User()
            {
                Id = p3.Id,
                Name = string.Format("{0} {1}", p3.FName, p3.LName),
                Role = (TransactionService.DomainModels.Enums.RoleType)p3.Role
            };
        }
        public TransactionService.DomainModels.Transaction MapTo(DbLoader.PersistModels.Transaction p4)
        {
            return p4 == null ? null : new TransactionService.DomainModels.Transaction()
            {
                Id = p4.Id,
                Date = p4.TransactDate,
                TransactionSum = p4.Amount,
                Currency = (TransactionService.DomainModels.Enums.CurrencyType)p4.Currency
            };
        }
    }
}

Интерфейс ITransactionMapper можно зарегистрировать в DI для удобного использования в сервисах, но в рамках данной статьи мы этого делать не будем

Немного дополнительной информации

Метод MapTo в интерфейсе можно назвать и другим именем, но структура метода должна быть сохранена

Помимо обычных мапперов можно генерировать методы маппинга с указанием существующего объекта. Для этого необходимо добавить в интерфейс ITransactionMapper перегрузку метода MapTo. Также можно сгенерировать Expression преобразование для его использования в маппинге c IQueryable

Модификация интерфейса ITransactionMapper и полученные новые методы
public User MapToExisting(DbLoader.PersistModels.User user, User existingUser);
public Expression<Func<DbLoader.PersistModels.User, User>> UserToDomain { get; }
public System.Linq.Expressions.Expression<System.Func<DbLoader.PersistModels.User, TransactionService.DomainModels.User>> UserToDomain => p1 => new TransactionService.DomainModels.User()
        {
            Id = p1.Id,
            Name = string.Format("{0} {1}", p1.FName, p1.LName),
            Role = (TransactionService.DomainModels.Enums.RoleType)p1.Role
        };

public TransactionService.DomainModels.User MapToExisting(DbLoader.PersistModels.User p5, TransactionService.DomainModels.User p6)
        {
            if (p5 == null)
            {
                return null;
            }
            TransactionService.DomainModels.User result = p6 ?? new TransactionService.DomainModels.User();
            
            result.Id = p5.Id;
            result.Name = string.Format("{0} {1}", p5.FName, p5.LName);
            result.Role = (TransactionService.DomainModels.Enums.RoleType)p5.Role;
            return result;
            
        }

В методе ConfigRegister прописана конфигурация маппинга. Вы можете добавлять свои правила при необходимости. Привожу некоторые из них:

  • AfterMapping и BeforeMapping используются для указания действий до и после маппинга

  • MapToConstructor позволяет маппить модели с использованием конструктора

  • MaxDepth указывает максимальную глубину маппинга моделей

  • TwoWays указывает на необходимость создания обратного маппинга

  • Ignore* методы позволяют игнорировать поля при маппинге

Метод Map имеет одну из удобных перегрузок с указанием условия маппинга

config
      .NewConfig<(
              DbLoader.PersistModels.Transaction transaction,
              (DbLoader.PersistModels.User sender, DbLoader.PersistModels.User reciever) users
          ), UserTransaction>()
      .Map(d => d.TransactId, s => s.transaction.Id)
      .Map(d => d.SenderId, s => s.users.sender.Id, cond => cond.users.sender != null)
      .Map(d => d.ReceiverId, s => s.users.reciever.Id, cond => cond.users.reciever != null);

На текущий момент мы автоматизировали генерацию классов-мапперов. Однако зачастую бывает более удобно работать с Extension методами маппинга. Попробуем настроить генерацию Extension методов

Extension мапперы

Логика генерации Extension мапперов в текущей версии Mapster.Tool схожа с логикой генерацией обычных мапперов, однако определять интерфейс маппера не нужно. Генерация вызывается с помощью метода GenerateMapper в классе конфига генерации

Метод GenerateMapper принимает 3 возможных типа параметра генерации

  • MapType.MapToTarget - сгенерированный метод дополнительно принимает в себя существующую сущность, на которую маппятся значения

  • MapType.Map - сгенерированный метод ничего не принимает и возвращает целевую модель

  • MapType.Projection - сгенерированный Expression может использоваться для маппинга Quaryable

В отличии от генерации обычных мапперов в текущей версии Mapster.Tool я заметил ряд проблем:

  1. Генерация моделей из других сборок без выполнения дополнительных действий скорее всего не сработает

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

Разберем первую проблему и ее решение. Когда модели для маппинга находятся в другом проекте, сгенерировать Extension методы на них сразу не получится. Мапперы скорее всего просто не появятся в директории сгенерированных файлов. Эта особенность заключается в том, что для Extension методов искомая модель ищется из типов текущей сборки (она указывается в команде в конфигурации проекта под тегом -a), но ее там не будет. Однако если все модели находятся в проекте генератора, то проблем возникнуть не должно. В нашем случае Db модели не лежат в сборке генератора, поэтому я выделил на текущий момент 2 способа, как можно сгенерировать Extension методы, когда модели находятся в отдельном проекте

  1. Генерация с добавлением атрибута [AdaptFrom] на модель

    В данном случае мы должны на все классы Domain слоя добавить атрибут [AdaptFrom] с указанием типа Db модели. Тогда мапперы должны без проблем сгенерироваться. Также Mapster поддерживает атрибуты [AdaptTo] и [AdaptTwoWays]

Пример с добавлением атрибута
using Mapster;

namespace TransactionService.DomainModels
{
    [AdaptFrom(typeof(DbLoader.PersistModels.UserTransaction))]
    /// <summary>
    /// Domain модель журнала совершенных транзакций.
    /// </summary>
    public class UserTransaction
    {
        public int Id { get; set; }

        /// <summary>
        /// Номер отправителя (User).
        /// </summary>
        public int SenderId { get; set; }

        /// <summary>
        /// Номер получателя (User).
        /// </summary>
        public int ReceiverId { get; set; }

        /// <summary>
        /// Номер данных транзакции (Transaction).
        /// </summary>
        public int TransactId { get; set; }
    }
}

  1. Генерация при явном добавлении сборки через класс CodeGenerationRegister

    Иногда добавить атрибут на модель может быть невозможно. Например, если генератор мапперов находится в отдельном от Domain и Persist моделей проекте, то добавление атрибута скорее всего не поможет. Или, например, если сами модели Persist слоя генерируются от Базы данных с помощью Code First подхода. В подобных случаях можно воспользоваться следующим решением

    Mapster Tool до генерации Extension методов добавляет в список сборок те сборки, типы которых добавлены в список TypeSettings из AdaptAttribureBuilder. По сути, это такое же выставление атрибутов моделям, но сделанное через AdaptAttribureBuilder

Структура проекта для генерации Extension мапперов
Структура проекта для генерации Extension мапперов

Класс ConfigRegister, аналогично примеру с генерацией обычных мапперов, содержит логику маппинга моделей, но к каждому конфигу добавляется вызов метода GenerateMapper. Класс CodeGenerationRegister позволяет произвести добавление тех типов, которых нет в текущей сборке

Реализация настройки генерации
using Mapster;
using TransactionService.DomainModels.Enums;

namespace TransactionService.Mapster.Extensions.Configuration
{
    public class ConfigRegister : IRegister
    {
        public void Register(TypeAdapterConfig config)
        {
             config
                .NewConfig<DbLoader.PersistModels.User, DomainModels.User>()
                .Map(d => d.Role, s => (RoleType)s.Role)
                .Map(d => d.Name, s => $"{s.FName} {s.LName}")
                .TwoWays()
                .GenerateMapper(MapType.MapToTarget | MapType.Map | MapType.Projection);

            config
                .NewConfig<DbLoader.PersistModels.Transaction, DomainModels.Transaction>()
                .Map(d => d.Currency, s => (CurrencyType)s.Currency)
                .Map(d => d.Date, s => s.TransactDate)
                .Map(d => d.TransactionSum, s => s.Amount)
                .TwoWays()
                .GenerateMapper(MapType.MapToTarget | MapType.Map);

            config
                .NewConfig<DbLoader.PersistModels.UserTransaction, DomainModels.UserTransaction>()
                .TwoWays()
                .GenerateMapper(MapType.MapToTarget | MapType.Map);

        }
    }
}
using DbLoader.PersistModels;
using Mapster;

namespace TransactionService.Mapster.Extensions.Configuration
{
    public class CodeGenerationRegister : ICodeGenerationRegister
    {
        public void Register(CodeGenerationConfig config)
        {
            var userAttribute = new AdaptFromAttribute(typeof(DomainModels.User));
            var userAttrBuilder = new AdaptAttributeBuilder(userAttribute);
            userAttrBuilder.ForType<User>();

            var transactionAttribute = new AdaptFromAttribute(typeof(DomainModels.Transaction));
            var transactionAttrBuilder = new AdaptAttributeBuilder(transactionAttribute);
            transactionAttrBuilder.ForType<Transaction>();

            var userTransactionAttribute = new AdaptFromAttribute(typeof(DomainModels.UserTransaction));
            var userTransactionAttrBuilder = new AdaptAttributeBuilder(userTransactionAttribute);
            userTransactionAttrBuilder.ForType<UserTransaction>();

            config.AdaptAttributeBuilders.Add(userAttrBuilder);
            config.AdaptAttributeBuilders.Add(transactionAttrBuilder);
            config.AdaptAttributeBuilders.Add(userTransactionAttrBuilder);
        }
    }
}

После сборки проекта в директории сгенеренных мапперов появятся 3 файла. Можно заметить, что в некоторых методах часть атрибутов не была добавлена. Это связано с тем, что мы не прописали явного обратного преобразования из Domain в Persist. Метод или атрибут TwoWays необходимо использовать после определения конфига преобразования в обе стороны, если поля в моделях различаются

Сгенеренные Extension мапперы
namespace DbLoader.PersistModels
{
    public static partial class TransactionMapper
    {
        public static DbLoader.PersistModels.Transaction AdaptToTransaction(this TransactionService.DomainModels.Transaction p1)
        {
            return p1 == null ? null : new DbLoader.PersistModels.Transaction()
            {
                Id = p1.Id,
                Currency = (char)p1.Currency
            };
        }
        public static DbLoader.PersistModels.Transaction AdaptTo(this TransactionService.DomainModels.Transaction p2, DbLoader.PersistModels.Transaction p3)
        {
            if (p2 == null)
            {
                return null;
            }
            DbLoader.PersistModels.Transaction result = p3 ?? new DbLoader.PersistModels.Transaction();
            
            result.Id = p2.Id;
            result.Currency = (char)p2.Currency;
            return result;
            
        }
        public static TransactionService.DomainModels.Transaction AdaptToTransaction(this DbLoader.PersistModels.Transaction p4)
        {
            return p4 == null ? null : new TransactionService.DomainModels.Transaction()
            {
                Id = p4.Id,
                Date = p4.TransactDate,
                TransactionSum = p4.Amount,
                Currency = (TransactionService.DomainModels.Enums.CurrencyType)p4.Currency
            };
        }
        public static TransactionService.DomainModels.Transaction AdaptTo(this DbLoader.PersistModels.Transaction p5, TransactionService.DomainModels.Transaction p6)
        {
            if (p5 == null)
            {
                return null;
            }
            TransactionService.DomainModels.Transaction result = p6 ?? new TransactionService.DomainModels.Transaction();
            
            result.Id = p5.Id;
            result.Date = p5.TransactDate;
            result.TransactionSum = p5.Amount;
            result.Currency = (TransactionService.DomainModels.Enums.CurrencyType)p5.Currency;
            return result;
            
        }
    }
}
namespace DbLoader.PersistModels
{
    public static partial class UserMapper
    {
       public static DbLoader.PersistModels.User AdaptToUser(this TransactionService.DomainModels.User p1)
        {
            return p1 == null ? null : new DbLoader.PersistModels.User()
            {
                Id = p1.Id,
                Role = (char)p1.Role
            };
        }
        public static DbLoader.PersistModels.User AdaptTo(this TransactionService.DomainModels.User p2, DbLoader.PersistModels.User p3)
        {
            if (p2 == null)
            {
                return null;
            }
            DbLoader.PersistModels.User result = p3 ?? new DbLoader.PersistModels.User();
            
            result.Id = p2.Id;
            result.Role = (char)p2.Role;
            return result;
            
        }
        public static TransactionService.DomainModels.User AdaptToUser(this DbLoader.PersistModels.User p4)
        {
            return p4 == null ? null : new TransactionService.DomainModels.User()
            {
                Id = p4.Id,
                Name = string.Format("{0} {1}", p4.FName, p4.LName),
                Role = (TransactionService.DomainModels.Enums.RoleType)p4.Role
            };
        }
        public static TransactionService.DomainModels.User AdaptTo(this DbLoader.PersistModels.User p5, TransactionService.DomainModels.User p6)
        {
            if (p5 == null)
            {
                return null;
            }
            TransactionService.DomainModels.User result = p6 ?? new TransactionService.DomainModels.User();
            
            result.Id = p5.Id;
            result.Name = string.Format("{0} {1}", p5.FName, p5.LName);
            result.Role = (TransactionService.DomainModels.Enums.RoleType)p5.Role;
            return result;
            
        }
        public static System.Linq.Expressions.Expression<System.Func<DbLoader.PersistModels.User, TransactionService.DomainModels.User>> ProjectToUser => p7 => new TransactionService.DomainModels.User()
        {
            Id = p7.Id,
            Name = string.Format("{0} {1}", p7.FName, p7.LName),
            Role = (TransactionService.DomainModels.Enums.RoleType)p7.Role
        };
    }
}
namespace DbLoader.PersistModels
{
    public static partial class UserTransactionMapper
    {
        public static DbLoader.PersistModels.UserTransaction AdaptToUserTransaction(this TransactionService.DomainModels.UserTransaction p1)
        {
            return p1 == null ? null : new DbLoader.PersistModels.UserTransaction()
            {
                Id = p1.Id,
                SenderId = p1.SenderId,
                ReceiverId = p1.ReceiverId,
                TransactId = p1.TransactId
            };
        }
        public static DbLoader.PersistModels.UserTransaction AdaptTo(this TransactionService.DomainModels.UserTransaction p2, DbLoader.PersistModels.UserTransaction p3)
        {
            if (p2 == null)
            {
                return null;
            }
            DbLoader.PersistModels.UserTransaction result = p3 ?? new DbLoader.PersistModels.UserTransaction();
            
            result.Id = p2.Id;
            result.SenderId = p2.SenderId;
            result.ReceiverId = p2.ReceiverId;
            result.TransactId = p2.TransactId;
            return result;
            
        }
        public static TransactionService.DomainModels.UserTransaction AdaptToUserTransaction(this DbLoader.PersistModels.UserTransaction p4)
        {
            return p4 == null ? null : new TransactionService.DomainModels.UserTransaction()
            {
                Id = p4.Id,
                SenderId = p4.SenderId,
                ReceiverId = p4.ReceiverId,
                TransactId = p4.TransactId
            };
        }
        public static TransactionService.DomainModels.UserTransaction AdaptTo(this DbLoader.PersistModels.UserTransaction p5, TransactionService.DomainModels.UserTransaction p6)
        {
            if (p5 == null)
            {
                return null;
            }
            TransactionService.DomainModels.UserTransaction result = p6 ?? new TransactionService.DomainModels.UserTransaction();
            
            result.Id = p5.Id;
            result.SenderId = p5.SenderId;
            result.ReceiverId = p5.ReceiverId;
            result.TransactId = p5.TransactId;
            return result;
            
        }
    }
}

Стоит отметить, что Extension методы могут быть более удобными для разработки, но при этом в отличии от обычных мапперов они обладают некоторыми ограничениями. Например, сгенерировать модель из нескольких других моделей в данном случае не получится. Также сгенерировать преобразование списковых структур скорее всего также не сработает (однако можно работать с Quaryable преобразованием). При этом таких проблем с генерацией обычных мапперов я не заметил

Итог

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

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

Инструмент был разработан не так давно и может быть доработан в будущем. На момент написания статьи инструмент имеет ряд проблем

В данной статье продемонстрирована лишь часть возможностей данного инструмента, т.к. описание всех его сценариев потребовало бы слишком много времени и текста. Однако описанный функционал должен решать большинство проблем. Если Вы столкнулись с проблемой, которая не описана в статье, то код Mapster.Tool можно забрать с github и попробовать проанализировать его

Инструмент позволяет решить проблему генерации большого количества мапперов при этом особо не влияя на производительность системы (далее мы в этом убедимся). При этом не стоит путать Mapster и Mapster Tool, это не совсем одно и то же. Сам генератор Mapster Tool появился не так давно и может быть доработан в будущем

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

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

Тестирование в Benchmark

Тестирование производительности маппинга выполнялось на 3000 сущностях класса User. Данная модель является довольно простой, но в большинстве случаев именно подобные модели приходится маппить в большом количестве итераций. Тестирование проводилось при ручном маппинге, маппинге AutoMapper, маппинге Mapster и маппинге Mapster.Tool двумя подходами

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

Спасибо за просмотр!

Замеры производительности в Benchmark
Замеры производительности в Benchmark
Код теста
using AutoMapper;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using Bogus;
using DbLoader.PersistModels;
using Mapster;

namespace TransactionService
{
    [MemoryDiagnoser, Orderer(summaryOrderPolicy: SummaryOrderPolicy.FastestToSlowest)]
    public class TransactionAnalyzer
    {
        private static readonly int userCount = 3000;
        private static readonly List<User> usersPersist;
        private static readonly IQueryable<User> usersPersistQueryable;
        private static readonly IMapper mapper;
        private static readonly Func<User, DomainModels.User> ProjToUser;

        static TransactionAnalyzer()
        {
            TypeAdapterConfig<User, DomainModels.User>.NewConfig()
                .Map(d => d.Role, s => (DomainModels.Enums.RoleType)s.Role)
                .Map(d => d.Name, s => $"{s.FName} {s.LName}");

            mapper = new Mapper(new MapperConfiguration(x => x
               .CreateMap<User, DomainModels.User>()
               .ForMember(dest => dest.Name, act => act.MapFrom(src => $"{src.FName} {src.LName}"))
               .ForMember(dest => dest.Role, act => act.MapFrom(src => (DomainModels.Enums.RoleType)src.Role))));

            usersPersist = new Faker<User>().Rules((f, o) =>
            {
                o.Id = f.Random.Number();
                o.FName = f.Name.FirstName();
                o.LName = f.Name.LastName();
                o.Role = 'A';
            }).Generate(userCount);

            usersPersistQueryable = usersPersist.AsQueryable();
            ProjToUser = UserMapper.ProjectToUser.Compile();
        }

        [Benchmark]
        public void ManualMapping_MapUserToDomain()
        {
            var userDomains = usersPersist.Select(x => x == null ? null : new DomainModels.User()
            {
                Id = x.Id,
                Name = $"{x.FName} {x.LName}",
                Role = (DomainModels.Enums.RoleType)x.Role
            }).ToList();
        }

        [Benchmark]
        public void MapsterTool_MapUserToDomain()
        {
            var userDomains = usersPersist.Select(x => x.AdaptToUser()).ToList();
        }

        [Benchmark]
        public void AutoMapper_MapUserToDomain()
        {
            var userDomains = usersPersist.Select(x => mapper.Map<DomainModels.User>(x)).ToList();
        }

        [Benchmark]
        public void Mapster_MapUserToDomain()
        {
            var userDomains = usersPersist.Select(x => x.Adapt<DomainModels.User>()).ToList();
        }

        [Benchmark]
        public void MapsterTool_MapUserToDomainByQueryable()
        {
            var userDomains = usersPersistQueryable.Select(ProjToUser).ToList();
        }
    }
}

Пыжьянов И. Л.

Газпромбанк Разработчик C#. Департамент информационных технологий инвестиционного бизнеса, Отдел развития трейдинговых систем

Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+7
Комментарии12

Публикации

Истории

Работа

Ближайшие события