FlashMapper — альтернатива автомапперу

в 8:50, , рубрики: .net, automapper, C#, flashmapper, Программирование

Я даже не знаю, что такое автомаппер. Зачем мне его альтернатива?

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

FlashMapper, как и AutoMapper, это .net-библиотека, которая избавляет вас от написания рутинного кода в процессе преобразования. Он автоматически сопоставляет все одинаковые свойства классов, оставляя вам только необходимость разрешить различия.

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

Если уже есть автомаппер, то зачем было делать еще одну подобную библиотеку?

В автомаппере мне никогда не нравился его монструозный синтаксис. Просто, чтобы связать два свойства в разных классах нужно прописать три лямбды в конфигурации, чтобы проигнорировать свойство — две. Также на последнем месте работы у меня часто стала возникать потребность ‘смаппить’ данные из двух классов в один. Я не нашел простого решения этой проблемы стандартными средствами автомаппера, поэтому начал писать для него библиотеку-расширение, позволяющую создавать маппинги из двух и более исходных классов в один, и даже почти дописал ее. Но потом мне вдруг пришла в голову идея более простого синтаксиса, и я решил все бросить, и написать с нуля свою библиотеку.

Собственно, FlashMapper имеет более простой и дружественный к интелисенсу синтаксис, предоставляет возможность ‘маппить’ из нескольких объектов в один, а также предоставляет API для вынесения каждой отдельной отдельной конфигурации маппинга в свой собственный класс, в который можно пробрасывать какую-нибудь логику как зависимость через конструктор. Также по задумке он должен был иметь лучшую производительность, но по некоторым причинам, о которых ниже, производительность оказалась на уровне автомаппера.

Синтаксис

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

Модель с пользовательской формы

public class UserForm
{
    public Guid? Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Town { get; set; }
    public string State { get; set; }
    public DateTime BirthDate { get; set; }
    public string Login { get; set; }
    public string Password { get; set; }
}

Модель базы данных
public class UserDb
{
    public Guid Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Town { get; set; }
    public string State { get; set; }
    public DateTime BirthDate { get; set; }
    public string Login { get; set; }
    public string PasswordHash { get; set; }
    public DateTime RegistrationTime { get; set; }
    public byte[] Timestamp { get; set; }
    public bool IsDeleted { get; set; }
}

Тогда вот как будет выглядеть конфигурация маппинга из первого класса во второй:

public class FlashMapperInitializer : IInitializer // Не является частью FlashMapper'а
{
    private readonly IMappingConfiguration mappingConfiguration; // Синглтон объект, который хранит в себе все конфигурации
    private readonly IPasswordHashCalculator passwordHashCalculator;

    public FlashMapperInitializer(IMappingConfiguration mappingConfiguration, IPasswordHashCalculator passwordHashCalculator)
    {
        this.mappingConfiguration = mappingConfiguration;
    }

    public void Init() // Код, который запускается во время инициализации приложения
    {
        mappingConfiguration.CreateMapping<UserForm, UserDb>(u => new UserDb
        {
            Id = u.Id ?? Guid.NewGuid(),
            Login = u.Id.HasValue ? MappingOptions.Ignore() : u.Login,
            RegistrationTime = u.Id.HasValue ? MappingOptions.Ignore() : DateTime.Now,
            Timestamp = MappingOptions.Ignore<byte[]>(),
            IsDeleted = false,
            PasswordHash = passwordHashCalculator.Calculate(u.Password)
        });
        //mappingConfiguration.CreateMapping <...> (...); // Прочие конфигурации
    }
}

Синтаксис напоминает создание нового объекта и инициализацию его свойств на основе объекта-источника. На деле же параметр метода CreateMapping — это лямбда-выражение, которое затем дополняется сопоставлением оставшихся свойств класса UserDb с аналогичными свойствами из класса UserForm. Также на основе этого выражения создается еще одно, которое не создает новый объект типа UserDb, но просто копирует данные в уже существующий объект. Оба выражения компилируются и сохраняются в mappingConfiguration для дальнейшего использования.

Вот как затем можно использовать созданный маппинг:

public class UserController : Controller
{
    private readonly IMappingConfiguration mappingConfiguration;
    private readonly IRepository<UserDb> usersRepository;

    public UserController(IMappingConfiguration mappingConfiguration, IRepository<UserDb> usersRepository)
    {
        this.mappingConfiguration = mappingConfiguration;
        this.usersRepository = usersRepository;
    }

    [HttpPost]
    public ActionResult Edit(UserForm model)
    {
        if (!ModelState.IsValid)
            return View(model);
        var existingUser = usersRepository.Find(model.Id);
        if (existingUser == null)
        {
            var newUser = mappingConfiguration.Convert(model).To<UserDb>();
            usersRepository.Add(newUser);
        }
        else
        {
            mappingConfiguration.MapData(model, existingUser);
            usersRepository.Update(existingUser);
        }
        return View(model);
    }
}

Маппинг из нескольких источников

FlashMapper позволяет создавать маппинги с несколькими источниками (до 15):

mappingConfiguration.CreateMapping<Source1, Source2, Source3, Destination>((s1, s2, s3) => new Destination { ... });

В этом случае при автоматическом сопоставлении свойств, подходящие будут искаться в каждом источнике. Если при этом подходящее свойство будет найдено в нескольких источниках FlashMapper бросит исключение о том, что произошла коллизия. Чтобы такого не произошло, нужно либо вручную указать из какого источника брать нужное свойство, либо указать в настройках маппинга CollisionBehavior = ChooseAny.

mappingConfiguration.CreateMapping<Source1, Source2, Source3, Destination>((s1, s2, s3) => new Destination { ... }, o => o.CollisionBehavior(SelectSourceCollisionBehavior.ChooseAny));

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

API для вынесения каждого маппинга в отдельный сервис

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

Вот как при этом изменится конфигурация маппинга из примера выше:

public interface IUserDbBuilder : IBuilder<UserForm, UserDb> { }

public class UserDbBuilder : FlashMapperBuilder<UserForm, UserDb, UserDbBuilder>, IUserDbBuilder
{
    private readonly IPasswordHashCalculator passwordHashCalculator;

    public UserDbBuilder(IMappingConfiguration mappingConfiguration, IPasswordHashCalculator passwordHashCalculator) : base(mappingConfiguration)
    {
        this.passwordHashCalculator = passwordHashCalculator;
    }

    protected override void ConfigureMapping(IFlashMapperBuilderConfigurator<UserForm, UserDb> configurator)
    {
        configurator.CreateMapping(u => new UserDb
        {
            Id = u.Id ?? Guid.NewGuid(),
            Login = u.Id.HasValue ? MappingOptions.Ignore() : u.Login,
            RegistrationTime = u.Id.HasValue ? MappingOptions.Ignore() : DateTime.Now,
            Timestamp = MappingOptions.Ignore<byte[]>(),
            IsDeleted = false,
            PasswordHash = passwordHashCalculator.Calculate(u.Password)
        });
    }
}

Интерфейс IUserDbBuilder имеет два метода — UserDb Build(UserForm source) и void MapData(UserForm source, UserDb destination). Базовый класс FlashMapperBuilder<UserForm, UserDb, UserDbBuilder> их реализует, но оставляет для реализации метод void ConfigureMapping(IFlashMapperBuilderConfigurator<UserForm, UserDb> configurator). Данный метод вызывается во время инициализации и создает маппинг в переданном mappingConfiguration. Заметьте, что последним generic-параметром абстрактного класса FlashMapperBuilder должна быть его реализация, в данном случае UserDbBuilder.

Рассмотрим процесс настройки подробнее. Базовый класс FlashMapperBuilder<> также реализует интерфейс IFlashMapperBuilder, который в свою очередь предоставляет метод RegisterMapping, который должен быть вызван во время инициализации приложения. В итоге реализация UserDbBuilder должна быть зарегистрирована в IoC-контейнере для двух интерфейсов – IUserDbBuilder для дальнейшего использования, и IFlashMapperBuilder для регистрации маппинга.

Как-то так для Ninject’а

Kernel.Bind<IUserDbBuilder, IFlashMapperBuilder>().To<UserDbBuilder>();

Для упрощения этого синтаксиса, я использую небольшой extension-метод

public static IBindingWhenInNamedWithOrOnSyntax<T> AsFlashMapperBuilder<T>(
    this IBindingWhenInNamedWithOrOnSyntax<T> builder) where T : IFlashMapperBuilder
{
    builder.Kernel.AddBinding(new Binding(typeof(IFlashMapperBuilder), builder.BindingConfiguration));
    return builder;
} 

В результате регистрацию билдера в контейнере можно переписать так:

Kernel.Bind<IUserDbBuilder>().To<UserDbBuilder>().AsFlashMapperBuilder();

В библиотеке имеется сервис IFlashMapperBuildersRegistrationService, с методом RegisterAllBuilders, этот метод должен быть вызван во время инициализации приложения. Сервис просто берет все реализации интерфейса IFlashMapperBuilder, которые переданы ему в конструктор, и вызывает у них метод RegisterMappings.

Вот пример использования сервиса IUserDbBuilder

public class UserController : Controller
{
    private readonly IUserDbBuilder userDbBuilder;
    private readonly IRepository<UserDb> usersRepository;

    public UserController(IUserDbBuilder userDbBuilder, IRepository<UserDb> usersRepository)
    {
        this.userDbBuilder = userDbBuilder;
        this.usersRepository = usersRepository;
    }

    [HttpPost]
    public ActionResult Edit(UserForm model)
    {
        if (!ModelState.IsValid)
            return View(model);
        var existingUser = usersRepository.Find(model.Id);
        if (existingUser == null)
        {
            var newUser = userDbBuilder.Build(model);
            usersRepository.Add(newUser);
        }
        else
        {
            userDbBuilder.MapData(model, existingUser);
            usersRepository.Update(existingUser);
        }
        return View(model);
    }
}

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

Чтобы этого не произошло, выражение, переданное в метод CreateMapping, модифицируется. Ему добавляется еще один параметр – сам билдер, и все обращения к текущему экземпляру билдера заменяются на обращение к этому параметру. В итоге получается маппинг от двух параметров, первый это собственно данные, второй – билдер. Когда у билдера в дальнейшем вызывается метод Build, он берет этот маппинг и передает данные и себя в качестве параметров.

Именно из-за этого последним generic-параметром класса FlashMapperBuilder должен быть сам билдер. Конечно можно было бы и во время рантайма вычислить этот тип, но мне хотелось избежать любого late binding’а при использовании уже готовых скомпилированных маппингов.

Данный подход также позволяет использовать несколько источников, максимум – 14.

Производительность

Меня особо никогда не беспокоила производительность автомаппера, но от коллег я слышал, что работает он раз в 10 медленнее аналогичного ручного маппинга. Я тогда решил, что умнее всех, и у меня-то уж получится производительность сопоставимая с ручным маппингом. Казалось бы, при использовании готового маппинга у меня уже есть скомпилированный метод, IL-код которого должен совпадать с IL-кодом метода, выполняющего аналогичную работу, но написанного руками, никакого late binding’а при использовании маппингов нет, нужно только получить указатель на метод и вызвать его. Но на деле производительность моего маппера получилась сопоставимой с производительностью автомаппера.

Я был очень удивлен, начал профайлить код, мой код не вызывал никаких тормозов, основное время выполнения отжирал скомпилированный метод маппинга. Я скачал символы дотнета, и запустил профайлер еще раз. Оказалось, что 72% процессорного времени занимает вызов некоего метода JIT_MethodAccessCheck.

Я начал гуглить, что вообще делает этот метод. В результатах поиска был некий файл jithelpers.h из сорцов дотнета на гихабе, а также вопросы на stackoverflow от людей, также обеспокоенных производительностью скомпилированных лямбда-выражений. Со stackoverflow я узнал, что этот метод как-то связан с CAS (code access security), а также нашел другой способ компиляции лямбда-выражений. Я решил просто слепо воспользоваться этим способом, посмотреть какая получится производительность. И, о чудо, мой скомпилированный из выражения метод стал работать с такой же скоростью, как и аналогичный ручной метод. Но, к сожалению, после этого у меня свалились два теста.

Сломались тесты на dependency injection. У скомпилированных новым способом выражений пропал доступ к приватным полям класса, в которых они были созданы. Причем, компилировались они без ошибок, ошибка выскакивала уже в момент конвертации из одного класса в другой. Если честно, я даже не подозревал до этого момента, что дотнет во время выполнения каждый раз проверяет, имеет ли данный кусок скомпилированного кода доступ к тому или иному члену класса.

Я стал разбираться, что к чему. Способ компиляции лямбда-выражений, который посоветовали на stackoverflow использует более старый и низкоуровневый API дотнета для генерации IL-кода. Это классы MethodBuilder, TypeBuilder и прочие. Нужно было руками их создать, определить параметры, передать экземпляр MethodBuilder’а в метод CompileToMethod лямбда-выражения. Данный метод записывал IL-код непосредственно того, что было в выражении, поэтому результирующий метод и работал быстро. После этого надо было создать динамический тип из инстанса класса TypeBuilder, и выдернуть из него нужный метод рефлексией. Понятное дело, что раз я создаю новый тип и метод определяется уже в нем, то у метода пропадает доступ к приватным полям класса, в котором было определено лямбда-выражение. Стандартный же способ компиляции лямбда-выражения – это простой вызов метода Compile, данный метод появился в более поздних версиях дотнета, и использует более новый API. Это класс DynamicMethod. Он не требует руками создавать динамические типы и прочее, а просто предоставляет методы для записи в него IL-кода и получения результата. Хотя внутри он точно также создает новый тип, но он умеет каким-то образом отключать проверку доступности членов класса во время выполнения, у него даже есть специальный флаг для этого в конструкторе. И, судя по всему, он и добавляет вызов JIT_ MethodAccessCheck в скомпилированный метод.

Я покопался еще во внутренностях дотнета, попытался различными способами обойти проверку доступности полей. Например, я пытался сделать, чтобы класс созданный TypeBuilder'ом был вложенным классом того, доступ к полям которого мне нужно получить, пытался также получить доступ к полям через рефлексию, но там возникли другие проблемы. В общем пока у меня не получилось заставить заработать способ со stackoverflow, и я откатил все до стандартного способа компиляции.

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

image

Хотя обидно, конечно, что результаты не соответствуют названию библиотеки, поэтому я поковыряюсь еще немного, может и получится что-то.

Заключение

На данный момент FlashMapper имеет некий базовый функционал автомаппера, помимо этого имеет более компактный синтаксис конфигурации, позволяет маппить из нескольких объектов и имеет API для вынесения конфигураций в отдельные сервисы.

Основная сборка библиотеки

Сборка с расширениями для маппинга из нескольких источников

Сборка с АПИ для вынесения конфигураций в сервисы

Исходники на гитхабе

Документация

Автор: Birbone

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js