В сегодняшней заметке я хотел бы поведать вам о коротком приключении по написанию своего маппера для .NET Standard 2.0. Ссылка на github и результаты benchmark'ов прилагаются.


Думаю ни для кого из вас не секрет, что такое mapper и для чего он нужен. Буквально на каждом шагу в процессе работы мы сталкиваемся с теми или иными примерами маппингов (или трансформаций) данных из одного вида в другой. К ним можно отнести маппинг записей из хранилища в domain model, маппинг response удалённого сервиса в view model и уже затем в domain model и т.д. Зачастую, на границе уровня абстракции существуют входной и выходной форматы данных и именно в моменты взаимодействия абстракций такая вещь, как маппер, может показать себя во всей красе, привнося с собой существенную экономию времени и effort'ов для разработчика и, как следствие, забирая на себя долю от общей производительности системы.


Исходя из этого можно описать и MVP требования:


  1. Скорость работы (less performance & memory impact);
  2. Простота использования (clean & easy to use API).

Что касается первого пункта, то в этом нам поможет BenchmarkDotNet и вдумчивая реализация, не лишённая и оптимизаций. Для второго же я написал простой unit test, который, в некотором роде, выступает документацией API нашего маппера:


[TestMethod]
public void WhenMappingExist_Then_Map()
{
    var dto = new CustomerDto
    {
        Id = 42,
        Title = "Test",
        CreatedAtUtc = new DateTime(2017, 9, 3),
        IsDeleted = true
    };

    mapper.Register<CustomerDto, Customer>();

    var customer = mapper.Map<CustomerDto, Customer>(dto);

    Assert.AreEqual(42, customer.Id);
    Assert.AreEqual("Test", customer.Title);
    Assert.AreEqual(new DateTime(2017, 9, 3), customer.CreatedAtUtc);
    Assert.AreEqual(true, customer.IsDeleted);
}

Итого, нам потребуется реализовать лишь 2 простых метода:


  1. void Register<TSource, TDest>();
  2. TDest Map<TSource, TDest>(TSource source).

Регистрация


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


  1. Для верификации- в случае отсутствия конструктора по умолчанию (или невозможности осуществить маппинг итогового типа) на мой взгляд, сообщить об этом следует как можно раньше на этапе конфигурации, соблюдая тем самым принцип Fail fast. В противном случае ошибка невозможности создания экземпляра типа может настигнуть нас уже на этапе выполнения инфраструктурного кода или бизнес-логики;
  2. Для расширения- на данный момент API предельно прост и under the hood подразумевает маппинг опирающийся на naming conventions, однако, вполне вероятно что уже очень скоро мы захотим вводить правила осуществления маппинга тех или иных полей, значением для присваивания которых может и вовсе явиться результат выполнения метода. В этом случае, дабы так же соблюсти и принцип Single responsible, такое разделение мне кажется вполне закономерным.

Если метод Map в любом маппере является основным и именно на него приходится львиная доля времени выполнения, то метод Register наоборот, для каждой пары типов вызывается лишь единожды на этапе конфигурации. Именно поэтому он является отличным кандидатом для совершения всех необходимых "тяжеловесных" манипуляций: генерации оптимального плана выполнения маппинга и как следствие, дальнейшего кеширования полученных результатов.


Таким образом, его реализация должна включать:


  1. Построение плана выполнения создания и инициализации экземпляра требуемого типа;
  2. Кеширование результатов.

План выполнения


В C# нам доступно не так много способов создать и проинициализировать экземпляр типа в runtime и чем выше уровень абстракции того или иного метода, тем менее оптимальным с точки зрения времени выполнения он является. Ранее я уже сталкивался с подобным выбором в другом своём небольшом проекте под названием FsContainer и потому следующие результаты не стали для меня удивительными.


BenchmarkDotNet=v0.10.9, OS=Windows 8.1 (6.3.9600)
Processor=Intel Core i5-5200U CPU 2.20GHz (Broadwell), ProcessorCount=4
Frequency=2143473 Hz, Resolution=466.5326 ns, Timer=TSC
.NET Core SDK=2.0.0
  [Host]     : .NET Core 2.0.0 (Framework 4.6.00001.0), 64bit RyuJIT
  DefaultJob : .NET Core 2.0.0 (Framework 4.6.00001.0), 64bit RyuJIT

 |                      Method |       Mean |     Error |    StdDev |     Median |
 |---------------------------- |-----------:|----------:|----------:|-----------:|
 | ExpressionCtorObjectBuilder |   8.548 ns | 0.2764 ns | 0.4541 ns |   8.608 ns |
 |     ActivatorCreateInstance |  79.379 ns | 1.6812 ns | 3.1987 ns |  78.890 ns |
 |       ConstructorInfoInvoke | 164.445 ns | 3.3355 ns | 4.3371 ns | 164.016 ns |
 |    DynamicMethodILGenerator |   5.859 ns | 0.2455 ns | 0.3015 ns |   5.819 ns |
 |                     NewCtor |   6.989 ns | 0.2615 ns | 0.5741 ns |   6.756 ns |

Несмотря на то, что использовать ConstructorInfo.Invoke и Activator.CreateInstance довольно легко, в данном списке с большим отрывом они являются явными аутсайдерами ввиду того, что в деталях своих реализаций они используют RuntimeType и System.Reflection. Это вполне приемлимо в повседневных задачах, но совершенно неуместно в рамках наших требований, где создание экземпляра типа является наиболее узким bottle neck'ом с точки зрения performance.


Что касается использования Expression и DynamicMethod, то здесь без сюрпризов- результатом выполнения являются указатели на скомпилированные функции, которые останется лишь вызвать, передав соответствующие аргументы.


Хотя Delegate, скомпилированный посредством генерации IL code налету, отрабатывает несколько быстрее, он не включает в себя код инициализации экземпляра типа. Более того, лично для меня воспроизведение IL инструкций посредством ilgen.Emit является весьма нетривиальным занятием.


var dynamicMethod = new DynamicMethod("Create_" + ctorInfo.Name, ctorInfo.DeclaringType, new[] { typeof(object[]) });       
var ilgen = dynamicMethod.GetILGenerator();     
ilgen.Emit(OpCodes.Newobj, ctorInfo);       
ilgen.Emit(OpCodes.Ret);        
return dynamicMethod.CreateDelegate(typeof(Func<TDest>));

Именно поэтому я остановился на реализации с использованием Expression:


var body = Expression.MemberInit(
    Expression.New(typeof(TDest)), props
);

return Expression.Lambda<Func<TSource, TDest>>(body, orig).Compile();

Кеширование


Для кеширования скомпилированного делегата, который в дальнейшем будет использоваться для выполнения маппинга, я выбирал между Dictionary и Hashtable. Забегая вперёд, хотелось бы отметить, что ключевые роли играют не только тип коллекции, но и тип ключа, по которому будет осуществляться выборка. Для проверки этого утверждения был написан отдельный benchmark и получены следующие результаты:


BenchmarkDotNet=v0.10.9, OS=Windows 8.1 (6.3.9600)
Processor=Intel Core i5-5200U CPU 2.20GHz (Broadwell), ProcessorCount=4
Frequency=2143473 Hz, Resolution=466.5326 ns, Timer=TSC
.NET Core SDK=2.0.0
  [Host]     : .NET Core 2.0.0 (Framework 4.6.00001.0), 64bit RyuJIT
  DefaultJob : .NET Core 2.0.0 (Framework 4.6.00001.0), 64bit RyuJIT

 |              Method |      Mean |     Error |    StdDev |
 |-------------------- |----------:|----------:|----------:|
 |     DictionaryTuple |  80.37 ns | 1.6473 ns | 1.6179 ns |
 | DictionaryTypeTuple |  49.35 ns | 0.6235 ns | 0.5832 ns |
 |      HashtableTuple | 103.07 ns | 2.6081 ns | 2.4397 ns |
 |  HashtableTypeTuple |  71.51 ns | 0.8679 ns | 0.7694 ns |

Принимая это во внимание, можно сделать следующие заключения:


  1. Использование типа Dictionary предпочтительнее Hashtable с точки зрения временных затрат на получение элемента коллекции;
  2. Использование в качестве ключа типа TypeTuple (src) предпочтительнее Tuple<Type, Type> с точки зрения временных затрат на Equals & GetHashCode;

Маппинг


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


public TDest Map<TSource, TDest>(TSource source)
{
    var key = new TypeTuple(typeof(TSource), typeof(TDest));
    var activator = GetMap(key);
    return ((Func<TSource, TDest>)activator)(source);
}

Результаты


В качестве результатов хотелось бы привести итоги финальных замеров существующих (и находящихся в актуальном состоянии) на данный момент мапперов:


BenchmarkDotNet=v0.10.9, OS=Windows 8.1 (6.3.9600)
Processor=Intel Core i5-5200U CPU 2.20GHz (Broadwell), ProcessorCount=4
Frequency=2143473 Hz, Resolution=466.5326 ns, Timer=TSC
.NET Core SDK=2.0.0
  [Host]     : .NET Core 2.0.0 (Framework 4.6.00001.0), 64bit RyuJIT
  DefaultJob : .NET Core 2.0.0 (Framework 4.6.00001.0), 64bit RyuJIT

 |                 Method |       Mean |     Error |    StdDev |
 |----------------------- |-----------:|----------:|----------:|
 |      FsMapperBenchmark |  84.492 ns | 1.6972 ns | 1.6669 ns |
 | ExpressMapperBenchmark | 251.161 ns | 4.6736 ns | 4.3717 ns |
 |    AutoMapperBenchmark | 204.142 ns | 4.2002 ns | 9.1309 ns |
 |       MapsterBenchmark |  90.949 ns | 1.6393 ns | 1.4532 ns |
 |   AgileMapperBenchmark | 218.021 ns | 3.0921 ns | 2.7410 ns |
 |    CtorMapperBenchmark |   7.806 ns | 0.2472 ns | 0.2312 ns |

Исходный код проекта доступен на github: https://github.com/FSou1/FsMapper.


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

Комментарии (17)


  1. Bonart
    02.11.2017 02:46

    У данного маппера есть один недостаток (весьма широко распространенный), которого очень хотелось бы избежать — требование наличия конструктора по умолчанию и свойств, доступных для записи.
    Для DTO это нормально, а для бизнес-объекта — хуже маппинга вручную.


    1. fsou11 Автор
      02.11.2017 11:02

      Вне всякого сомнения, Кирилл. Это исключительно POC для сравнения базовой функциональности. Если говорить об инициализации экземпляров, у которого отсутствует конструктор по умолчанию, то тогда это или дополнительные конфигурации или Resolve.


    1. marshinov
      02.11.2017 22:17

      Можно обойти это ограничение с помощью protected-конструктора и protected setter'ов. Не совсем элегантно, но mapper сможет дотянуться. Поправьте, если я ошибаюсь и через expression такой делегат не скомпилируешь.


    1. vba
      03.11.2017 11:13

      Я бы сказал что это ненормально даже для DTO, я например использую записи в F# для определения DTO, кратко и без геммора с поддержкой Equals и HashCode. В решениях с max immutability такие подходы c mapper увы, не годятся.


  1. ARad
    02.11.2017 05:22

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


        var key = new TypeTuple(typeof(TSource), typeof(TDest));
        var activator = GetMap(key);

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


        mapper.Register<CustomerDto, Customer>();
        var map = mapper.GetMap<CustomerDto, Customer>();
    
        var customer = map(dto);


    1. fsou11 Автор
      02.11.2017 11:06

      А хранить `map` вы где будете и с каким ключём? :)

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

      mapper.Map<List<CustomerDTO>, List<Customer>>()


      1. mayorovp
        02.11.2017 11:09

        В поле класса который этот маппер использует.


        1. fsou11 Автор
          02.11.2017 11:24

          В таком случае «маппер» для каждой пары типов у вас будет свой, а не хватать им будет ровно одной единственной функциональности-

          ObjectBuilder.GetActivator<TSource, TDest>()
          Ничего не имею против такого подхода, но в таком случае вам в качестве зависимостей нужен не столько маппер, сколько библиотека по компиляции плана создания экземпляра требуемого типа.


  1. Bonart
    02.11.2017 08:53
    +1

    Еще пара замечаний по дизайну.


    1. Сейчас маппер имеет изменяемое состояние.
      Логично разделить его на билдер (где можно делать регистрации) и маппер (потокобезопасный объект без состояния, можно только использовать ранее зарегистрированное).
    2. Сейчас маппер имеет неопределенный интерфейс (эквивалент локатора сервисов) — результат маппинга определяется не контрактом, а ранее сделанными регистрациями.
      Логично сделать не единый маппер с множеством регистраций и обобщенными методами, а обобщенный маппер (каждый на одну пару типов). Такой дизайн позволит полностью избавиться от изменяемого состояния (все что сейчас делается при регистрации можно будет реализовать прямо в конструкторе) и сделать зависмосоти от маппинга прозрачными (сразу видны требуемые типы).


    1. fsou11 Автор
      02.11.2017 16:01

      1. Другими словами вынести состояние в отдельный тип, например, конфигурации?

      2. Не совсем понял. Можно пояснения в виде кода?


  1. gnaeus
    02.11.2017 10:51

    Я правильно понимаю, что Ваш маппер НЕ поддерживает:


    • маппинг вложенных свойств
    • маппинг коллекций
    • маппинг на существующий объект (обновление)
    • генерацию проекций для IQueryable<T>

    И при этом (судя по Вашим бенчмаркам) всего на 10% бытрее того же Mapster, который все это умеет?


    Еще было бы неплохо сравнить с EmitMapper.


    А для ускорения маппинга вместо Expression.Compile можно использовать FastExpressionCompiler.


    Суть проекта в компиляции expression tree напрямую, без создания анонимной assembly для запуска в песочнице (как это делает Expression.Compile).


    1. fsou11 Автор
      02.11.2017 11:09

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

      В первую очередь было интересно реализовать совершенно базовый функционал наиболее оптимальным способом.

      Добавлю EmitMapper в сравнение и попробую FastExpressionCompiler. Спасибо.


    1. vitaliy91
      02.11.2017 14:32

      FastExpressionCompiler по крайней мере выполняет компиляцию конструкторов точно также как DynamicMethodILGenerator у автора.


    1. fsou11 Автор
      02.11.2017 20:48

      К сожалению заиспользовать EmitMapper для .NET Core 2.0 не удалось по следующей причине:

      System.MissingMethodException: Method not found: 'System.Reflection.Emit.AssemblyBuilder System.AppDomain.DefineDynamicAssembly(System.Reflection.AssemblyName, System.Reflection.Emit.AssemblyBuilderAccess)'


  1. Imbecile
    02.11.2017 11:09

    А при чём тут .NET Standard? Вы же только одну его реализацию, .NET Core используете.
    Ну и необходимость лезть в исходники, чтобы понять, что скрывается за именами в бенчмарках — за гранью бобра и козла.


  1. KIVan
    02.11.2017 19:59

    А почему бы не написать генератор мапинговых методов на Roslyn (для нас ведь главное уйти от написания рутинного кода)? Это сэкономит время старта, добавит гибкость (расширять мапинг можно будет обычным C# кодом) и можно даже сделать проверку вызова незарегистрированных мапингов при компиляции.


    1. fsou11 Автор
      02.11.2017 20:04

      Интересная мысль, похоже это заявка на статью от вас :)