Свой mapper или немного про ExpressionTrees

image

Сегодня мы поговорим про то, как написать свой AutoMapper. Да, мне бы очень хотелось рассказать вам об этом, но я не смогу. Дело в том, что подобные решения очень большие, имеют историю проб и ошибок, а также прошли долгий путь применения. Я лишь могу дать понимание того, как это работает, дать отправную точку для тех, кто хотел бы разобраться с самим механизмом работы «мапперов». Можно даже сказать, что мы напишем свой велосипед.

Отказ от ответственности


Я ещё раз напоминаю: мы напишем примитивный mapper. Если вам вдруг вздумается его доработать и использовать в проде — не делайте этого. Возьмите готовое решение, которое знает стек проблем этой предметной области и уже умеет их решать. Есть несколько более-менее весомых причинам писать и использовать свой вело-mapper:

  • Нужна какая-то специальная кастомизация.
  • Нужна максимальная производительность в ваших условиях и вы готовы набивать шишки.
  • Вы хотите разобраться с тем, как работает mapper.
  • Вам просто нравится велоспорт.

Что называют словом «mapper»?


Это подсистема, которая отвечает за то, чтобы взять некий объект и преобразовать (скопировать его значения) его в другой. Типичная задача: преобразовать DTO в объект бизнес слоя. Самый примитивный mapper «бежит» по свойствам (property) источника данных и сопоставляет их со свойствами типа данных, который будет на выходе. После сопоставления происходит извлечение значений из источника и их запись в объект, который будет результатом преобразования. Где-то по пути, скорее всего, нужно будет ещё создать этот самый «результат».

Для потребителя mapper — это сервис, который предоставляет следующий интерфейс:

public interface IMapper<out TOut>
{
     TOut Map(object source);
}

Подчеркиваю: это наиболее примитивный интерфейс, который, с моей точки зрения, удобен для объяснения. В реальности мы, скорее всего, будем иметь дело с более конкретным маппером (IMapper<TIn, TOut>) или с более общим фасадом (IMapper), который сам подберет конкретный mapper под заданные типы объектов входа-выхода.

Наивная реализация


Ремарка: даже наивная реализация mapper'a требует элементарных знаний в области Reflection и ExpressionTrees. Если вы ещё не прошли по ссылкам или ничего не слышали об этих технологиях — сделайте это, прочтите. Обещаю, мир уже никогда не будет прежним.

Впрочем, мы с вами пишем свой mapper. Для начала давайте получим все свойства (PropertyInfo) того типа данных, который будет на выходе (далее я буду называть его TOut). Это сделать достаточно просто: тип мы знаем, так как пишем имплементацию generic-класса, параметризированного типом TOut. Далее, используя экземпляр класса Type, мы получаем все его свойства.

Type outType = typeof(TOut);
PropertyInfo[] outProperties = outType.GetProperties();

При получении свойств я опускаю особенности. Например, некоторые из них могут быть без setter-функции, некоторые могут быть помечены аттрибутом как игнорируемые, некоторые могут быть со специальным доступом. Мы рассматриваем самый простой вариант.

Идём далее. Было бы неплохо уметь создавать экземпляр типа TOut, то есть того самого объекта, в который мы «мапим» входящий объект. В C# это можно сделать несколькими способами. Например, мы можем сделать так: System.Activator.CreateInstance(). Или даже просто new TOut(), но для этого вам нужно создать ограничение для TOut, чего в обобщенном интерфейсе делать не хотелось бы. Впрочем, мы с вами что-то знаем об ExpressionTrees, а значит можем сделать вот так:

ConstructorInfo outConstructor = outType.GetConstructor(Array.Empty<Type>());
Func<TOut> activator = outConstructor == null
   ? throw new Exception($"Default constructor for {outType.Name} not found")
   : Expression.Lambda<Func<TOut>>(Expression.New(outConstructor)).Compile();

Почему именно так? Потому что мы знаем, что экземпляр класса Type может дать информацию о том, какие у него есть конструкторы — это весьма удобно для случаев, когда мы решим развить свой mapper настолько, что будем передавать в конструктор какие-либо данные. Также, мы ещё немного узнали про ExpressionTrees, а именно — они позволяют налету создать и скомпилировать код, который потом можно будет многократно использовать. В данном случае это функция, которая на самом деле выглядит как () => new TOut().

Теперь нужно написать основной метод mapper'a, который будет копировать значения. Мы пойдем по самому простому пути: идём по свойствам объекта, который пришёл к нам на вход, и ищем среди свойств исходящего объекта свойство с таким же названием. Если нашли — копируем, если нет — идём дальше.

TOut outInstance = _activator();
PropertyInfo[] sourceProperties = source.GetType().GetProperties();

for (var i = 0; i < sourceProperties.Length; i++)
{
     PropertyInfo sourceProperty = sourceProperties[i];
     string propertyName = sourceProperty.Name;
     if (_outProperties.TryGetValue(propertyName, out PropertyInfo outProperty))
     {
          object sourceValue = sourceProperty.GetValue(source);
          outProperty.SetValue(outInstance, sourceValue);
     }
}

return outInstance;

Таким образом у нас полностью сформировался класс BasicMapper. С его тестами можно ознакомиться вот тут. Обратите внимание, что источником может быть как объект какого-то конкретного типа, так и анонимный объект.

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


Reflection отличная, но медленная штука. Более того, её частое использование увеличивает memory traffic, а значит нагружает GC, а значит ещё больше замедляет работу приложения. Например, только что мы использовали методы PropertyInfo.SetValue и PropertyInfo.GetValue. Метод GetValue возвращает object, в которой завернуто (boxing) некое значение. Это значит, что мы получили аллокацию на пустом месте.

Mapper'ы обычно находятся там, где нужно превратить один объект в другой… Нет, не один, а множество объектов. Например, когда мы забираем что-то из базы данных. В этом месте хотелось бы видеть нормальную производительность и не терять память на элементарной операции.

Что мы можем сделать? Нам снова поможет ExpressionTrees. Дело в том, что .NET позволяет создавать и компилировать код «на лету»: мы описываем его в объектном представлении, говорим что и где будем использовать… и компилируем. Почти никакой магии.

Компилируемый mapper


На самом деле, всё относительно просто: мы уже делали new с помощью Expression.New(ConstructorInfo). Наверное вы заметили, что статический метод New называется точно так же, как и оператор. Дело в том, что почти у всего синтаксиса C# есть отражение в виде статических методов класса Expression. Если чего-то нет, то это значит, что вы ищите т.н. «синтаксический сахар».

Вот несколько операций, которые мы будем использовать в нашем mapper'e:

  • Объявление переменной — Expression.Variable(Type, string). Аргумент Type говорит о том, какого типа переменная будет создана, а string — название переменной.
  • Присваивание — Expression.Assign(Expression, Expression). Первый аргумент — это то, чему мы присваиваем, а второй аргумент — что присваиваем.
  • Доступ к свойству объекта — Expression.Property(Expression, PropertyInfo). Expression — владелец свойства, а PropertyInfo — полученное через Reflection объектное представление свойства.

Имея эти знания, мы можем создавать переменные, получать доступ к свойствам объектов и присваивать значения свойствам объектов. Скорее всего, мы также понимаем, что ExpressionTree нужно скомпилировать в делегат вида Func<object, TOut>. План такой: получаем переменную, которая содержит входные данные, создаем экземпляр типа TOut и создаем выражения (expression), которые присваивают одно свойство в другое.

К сожалению, код получается не очень компактный, поэтому предлагаю сразу взглянуть на имплементацию CompiledMapper. Я вынес сюда лишь узловые моменты.

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

var parameter = Expression.Parameter(typeof(object), "source");

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

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

Expression sourceValue = Expression.Property(sourceInstance, sourceProperty);
Expression outValue = Expression.Property(outInstance, outProperty);
                    
expressions.Add(Expression.Assign(outValue, sourceValue));

Важный момент: после того, как мы создали все операции присваивания нам нужно вернуть результат из функции. Для этого последним выражением в списке должно быть Expression, содержащее экземпляр класса, который мы создали. Я оставил комментарий рядом с этой строчкой. Почему поведение, соответствующее ключевому слову return в ExpressionTree выглядит именно так? Боюсь, что это отдельная тема. Сейчас я предлагаю это просто запомнить.

Ну и в самом конце мы должны скомпилировать все выражения, которые мы построили. Что нам тут интересно? Переменная body содержит «тело» функции. У «обычных функций» ведь есть тело, верно? Ну, которое мы заключаем в фигурные скобки. Так вот, Expression.Block — это именно оно. Так как фигурные скобки — это ещё и область видимости, то мы должны передать туда переменные, которые там будут использоваться — в нашем случае sourceInstance и outInstance.

var body = Expression.Block(new[] {sourceInstance, outInstance}, expressions);
return Expression.Lambda<Func<object, TOut>>(body, parameter).Compile();

На выходе мы получим Func<object, TOut>, т.е. функцию, которая может сконвертировать данные из одного объекта в другой. К чему такие сложности, спросите вы? Я напомню, что во-первых, мы хотели избежать boxing'a при копировании ValueType-значений, а во-вторых, мы хотели отказаться от методов PropertyInfo.GetValue и PropertyInfo.SetValue, так как они несколько медленные.

Почему не будет boxing? Потому что скомпилированный ExpressionTree это настоящий IL и для runtime он выглядит также (почти), как и ваш код. Почему «скомпилированный mapper» работает быстрее? Снова: потому что это просто обычный IL. Кстати, скорость мы можем легко подтвердить с помощью библиотеки BenchmarkDotNet, а сам бенчмарк можно посмотреть тут.
Method Mean Error StdDev Ratio Allocated
AutoMapper 1,291.6 us 3.3173 us 3.1030 us 1.00 312.5 KB
Velo_BasicMapper 11,987.0 us 33.8389 us 28.2570 us 9.28 3437.5 KB
Velo_CompiledMapper 341.3 us 2.8230 us 2.6407 us 0.26 312.5 KB

В колонке Ratio «скомпилированный mapper» (CompiledMapper) показал очень неплохой результат, даже по сравнению с AutoMapper (он baseline, т.е. 1). Впрочем, давайте не будем радоваться: AutoMapper обладает значительно большими возможностями по сравнению с нашим велосипедом. Этой табличкой я лишь хотел показать, что ExpressionTrees значительно быстрее, чем «подход классического Reflection».

Резюме


Надеюсь, мне удалось показать, что написать свой mapper достаточно просто. Reflection и ExpressionTrees — очень мощные средства, которые разработчики используют для решения множества различных задач. Dependency injection, Serialization/Deserialization, CRUD-репозитории, построение SQL запросов, использование других языков в качестве скриптов для .NET-приложений — всё это делается с использованием Reflection, Reflection.Emit и ExpressionTrees.

А что mapper? Mapper — отличный пример, на котором всему этому можно научиться.

P.S.: Если вам захотелось ещё немного ExpressionTrees, то предлагаю прочитать о том, как сделать свой конвертер JSON с помощью этой технологии.

Средняя зарплата в IT

120 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 7 453 анкет, за 1-ое пол. 2021 года Узнать свою зарплату
Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 16

  • НЛО прилетело и опубликовало эту надпись здесь
      0
      Да, всё верно. Кодогенерация — тоже способ решения подобных задач. Впрочем, у этого есть и минусы: захламление кодовой базы и необходимость её поддержки, так как модели могут (и будут) меняться.

      Кстати, нет ли у вас под рукой хорошей статьи по использованию Roslyn для кодогенерации? Чтоб, так сказать, «на пальцах». Давно хотел освоить, но видел только какие-то сложные примеры.
      • НЛО прилетело и опубликовало эту надпись здесь
          0
          Спасибо большое, интересная статья.

          Однако, в комментариях правильно заметили: «суть автомаппера не банальное перекладывание из одного в другое, в общем-то проекции, возможность инъекции...».

          В своей статье я дал лишь самый простой пример — копирование свойства в свойство. Проекции я не создавал, типы не изменял, данные в конструктор не пробрасывал и т.п. Как вы думаете, с помощью кодогенерации возможно будет задать какие-то дополнительные правила преобразования из одного типа в другое? Или организовать взаимосвязь мапперов, когда в одном объекте есть другой, на который тоже существует схема маппинга?

          Мне кажется, что это сложная задача для кодогенерации. Впрочем, я в ней не специалист.

          Да, я понял, что вы поддерживаете использование AutoMapper :)

          • НЛО прилетело и опубликовало эту надпись здесь
            0
            А в автомаппере, поле удалилось, ну и хрен с ним, тихонько игнорируем.

            configuration.AssertConfigurationIsValid(); позволяет не игнорировать тихонько, а получить явный exception при старте приложения.
            • НЛО прилетело и опубликовало эту надпись здесь
        0
        Ну что же. Добавьте в свой велосипед еще скорости FastExpressionCompiler .
        И все-таки странно что Automapper слил, есть им еще куда стремиться, хотя думал по той тропинке столько зверей потопталось, что дальше уже некуда.
          0

          Автомапер, как мне кажется, сложнее внутри, а так идея одна и та же. Следущий шаг — генерация байткода, но это потребует куда больших усилий.

            0
            А зачем? Этот велосипед, как я писал, нужен для демонстрации работы с ExpressionTrees. AutoMapper значительно мощнее. Если у меня когда-нибудь (по объективным замерам) возникнут проблемы с производительностью там, где происходит маппинг — возможно я вернусь к этому велосипеду.

            FastExpressionCompiler посмотрю, спасибо. Есть у меня мысль написать про то, как я парсил JS и компилировал его с помощью ExpressionTrees — думаю там это может пригодиться.
            0
            teoadal почему вы решили строить выражение как функцию с отдельным созданием экземпляра и последовательным присвоений значений из сущности в ДТО, а не построение просто лямбда-выражения? Вы ведь обычно пишите запрос через ORM как .Select(x => new T { Field = x.Field }), а не .Select(x => { var y = new T(); y.Field = x.Field; }). Мне кажется такое вариант куда ближе к ОРМ-ным и проще им парсится

            То же делали маппер на проекте, но через Expression.MemberInit + набор Expression.Bind
            public Expression<Func<TEntity, TDto>> GetMapExpr<TEntity, TDto>()
            {
                var entityType = typeof(TEntity);
                var entityParam = Expression.Parameter(entityType, "x");
                var entityProps = entityType.GetProperties();
            
                var dtoType = typeof(TDto);
                var dtoProps = dtoType.GetProperties();
            
                var memberExpressions = ... сличение dtoProps и entityProps и построение Expresion.Bind ...
            
                var newDTO = Expression.MemberInit(Expression.New(dtoType), memberExpressions);
                var selector = (Expression<Func<TEntity, TDto>>)Expression.Lambda(newDTO, entityParam);
                return selector;
            }
              0
              Я сделал так, чтобы было больше похоже на то, что делалось с помощью Reflection (PropertyInfo.GetValue/SetValue). Текст больше для тех, кто не очень разбирается в ExpressionTrees, поэтому хотелось, чтобы переход был плавный.

              Также, у меня немного другая ситуация: аргумент функции типа object, а не TEntity. То есть мне в самом начале нужно сделать приведение типа и сохранить результат в переменную, а значит без тела (Expression.Body) как мне кажется не получится.

              При этом вы правы, чтобы сократить количество действий можно было формировать набор Expression.Bind и в самом конце сделать Expression.MemberInit. Т.е. скомпилированный код будет вида:
              x => 
              { 
                   var source = (TIn) x; 
                   return new TOut 
                   {
                       Property = source.Property;
                       ...
                   } 
              }
              

              А код построения функции:
              private Func<object, TOut> BuildConverter(Type sourceType)
              {
                  var parameter = Expression.Parameter(typeof(object), "x");
                  var source = Expression.Variable(sourceType, "source");
              
                  var bindings = sourceType.GetProperties()
                      .Where(p => _outProperties.ContainsKey(p.Name))
                      .Select(p => Expression.Bind(_outProperties[p.Name], Expression.Property(source, p)));
              
                  var body = Expression.Block(new[] {source}, 
                      Expression.Assign(source, Expression.Convert(parameter, sourceType)),
                      Expression.MemberInit(Expression.New(_outConstructor), bindings));
                  
                  return Expression.Lambda<Func<object, TOut>>(body, parameter).Compile();
              }
              

              0

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

                0
                Эх, хорошая штука эти ExpressionTrees. Я, правда, маппер не писал, но атрибутные валидацию данных и правовые фильтры, а также кастомные фильтры/сортировки для IQueriable прикручивал.
                  0
                  Да, мне тоже очень нравится, но только для простых задач. Например, парсить и создавать в функции из JavaScript — ад. Там возникает сразу целая куча проблем вроде того, что ExpressionTrees иногда сложно составлять «по ходу» кода. Иногда нужно заглянуть на пару шагов вперед, чтобы выражение получилось правильное.
                    0
                    Да, в сложных случаях получается как с регулярками — оно работает, но чтобы понять как — надо сделать реверс-инжиринг :). Но в отличие от регулярок тут хоть код можно хорошо оформить, комментарии оставить.

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

                Самое читаемое