Статья посвящена двойному применению API Expression Trees — для разбора выражений и для генерации кода. Разбор выражений помогает построить структуры представления (они же структуры представления проблемно-ориентированного языка Internal DSL), а кодогенерация позволяет динамически создавать эффективные функции — наборы инструкций задаваемые структурами представления.


Демонстрировать буду динамическое создание итераторов свойств: serialize, copy, clone, equals. На примере serialize покажу как можно оптимизировать сериализацию (по сравнению с потоковыми сериализаторами) в классической ситуации, когда "предварительное" знание используется для улучшения производительности. Идея в том, что вызов потокового сериалайзера всегда проиграет "непотоковой" функции точно знающей какие узлы дерева надо обойти, при этом выписанной "не руками" а динамически, по заданным правилам. Inernal DSL и решает задачу компактного задания правила обхода дерева свойств (в общем случае: вычислений c проименованием узлов) . Бенчмарк сериализатора скромный, но он важен тем, что добавляет подходу, построенному вокруг применения конкретного Internal DSL Includes (диалект того Include/ThenInclude что из EF Core) и применению Internal DSL в целом, необходимой убедительности.


Введение


Сравните:


var p = new Point(){X=-1,Y=1};
// which has better performance ?
var json1 = JsonConvert.SerializeObject(p); 
var json2 = $"{{\"X\":{p.X}, \"Y\":{p.Y}}}";

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


var p = new Point(){X=-1,Y=1};
// which has better performance ?
var json1 = JsonConvert.SerializeObject(p); 
var formatter = JsonManager.ComposeFormatter<Point>();
var json2 = formatter(p);

Здесь JsonManager.ComposeFormatterреальный инструмент. Правило по которому генерируется обход структуры при сериализации не очевидно, но оно звучит так "при параметрах по умолчанию, для пользовательских value type обойди все поля первого уровня". Если же его задавать явно:


// обход задан явно
var formatter2 = JsonManager.ComposeFormatter<Point>(
   chain=>chain   
      .Include(e=>e.X)
      .Include(e=>e.Y)  // DSL Includes
)

Это и есть DSL Includes. Анализу трейдофф (уступок?) описания метаданных DSLом, просвещена работа, но сейчас, не уделяя внимания методу объявления "правил обхода" (т.е. игнорируя форму записи метаданных), акцентирую что C# предоставляет возможность собрать и скомпилировать "идеальный сериализатор" при помощи Expression Trees.


Как он это делает - много кода и гид по кодогенерации Expression Trees...

переход от formatter к serilizer (пока без expression trees):


 Func<StringBuilder, Point, bool>  serializer = ... // later
 string formatter(Point p)
            {
                var stringBuilder = new StringBuilder();
                serializer(stringBuilder, p);
                return  stringBuilder.ToString();
            }

В свою очередь serializer строится такой (если задавать статическим кодом):


Expression<Func<StringBuilder, Point, bool>> serializerExpression = 
    SerializeAssociativeArray(sb, p,
          (sb1, t1) => SerializeValueProperty(sb1, t1, "X", o => o.X, SerializeValueToString),
          (sb4, t4) => SerializeValueProperty(sb1, t1, "Y", o => o.Y,  SerializeValueToString)
    );
Func<StringBuilder, Point, bool> serializer = serializerExpression.Compile();  

Зачем так "функционально", почему нельзя задать сериализацию двух полей через "точку с запятой"? Коротко: потому что вот это выражение можно присвоить переменной типа Expression<Func<StringBuilder, Box, bool>>, а "точку с запятой" нельзя.
Почему нельзя было прямо написать Func<StringBuilder, Point, bool> serializer = (sb,p)=>SerializeAssociativeArray(sb,p,...? Можно, но я демонстрирую не создание делегата, а сборку (в данном случае статическим кодом) expression tree, с полседующей компиляцией в делегат, в практическом использовании serializerExpression будут задаваться уже совсем по другому — динамически (ниже).


Но что важно в самом решении: SerializeAssociativeArray принимает массив params Func<..> propertySerializers по числу узлов которые надо обойти. Обход одних из них может быть задан сериалайзерами "листьев" SerializeValueProperty(принимающим форматер SerializeValueToString), а других опять SerializeAssociativeArray (т.е. веток) и таким образом строится итератор (дерево) обхода.


Если бы Point содержал свойство NextPoint:


var @delegate = 
    SerializeAssociativeArray(sb, p,
          (sb1, t1) => SerializeValueProperty(sb1, t1, "X", o => o.X, SerializeValueToString),
          (sb4, t4) => SerializeValueProperty(sb1, t1, "Y", o => o.Y,  SerializeValueToString),
          (sb4, t4) => SerializeValueProperty(sb1, t1, "NextPoint", o => o.NextPoint,  
               (sb4, t4) =>SerializeAssociativeArray(sb1, p1,
                    (sb1, t1) => SerializeValueProperty(sb2, t2, "X", o => o.X, SerializeValueToString),
                    (sb4, t4) => SerializeValueProperty(sb2, t2, "Y", o => o.Y,  SerializeValueToString)
                )
           )
    );

Устройство трех функций SerializeAssociativeArray, SerializeValueProperty, SerializeValueToString не сложное:


Serialize...
public static bool SerializeAssociativeArray<T>(StringBuilder stringBuilder, T t, params Func<StringBuilder, T, bool>[] propertySerializers)
        {
            var @value = false;
            stringBuilder.Append('{');
            foreach (var propertySerializer in propertySerializers)
            {
                var notEmpty = propertySerializer(stringBuilder, t);
                if (notEmpty)
                {
                    if (!@value)
                        @value = true;
                    stringBuilder.Append(',');
                }
            };
            stringBuilder.Length--;
            if (@value)
                stringBuilder.Append('}');
            return @value;
        }

        public static bool SerializeValueProperty<T, TProp>(StringBuilder stringBuilder, T t, string propertyName,
            Func<T, TProp> getter, Func<StringBuilder, TProp, bool> serializer) where TProp : struct
        {
            stringBuilder.Append('"').Append(propertyName).Append('"').Append(':');
            var value = getter(t);
            var notEmpty = serializer(stringBuilder, value);
            if (!notEmpty)
                stringBuilder.Length -= (propertyName.Length + 3);
            return notEmpty;
        }

        public static bool SerializeValueToString<T>(StringBuilder stringBuilder, T t) where T : struct
        {
            stringBuilder.Append(t);
            return true;
        }

Многие детали тут не приведены (поддержка списков, ссылочного типа и nullable). И все же видно, что я действительно получу json на выходе, а все остальное это еще больше типовых функций SerializeArray, SerializeNullable, SerializeRef.


Это было статическое Expression Tree, не динамиеческое, не eval в C#.


Увидеть как Expression Tree строится динамически можно в два шага:


Шаг 1 — decompiler'ом посмотреть на код присвоенный Expression<T>



Это конечно удивит по первому разу. Ничего не понятно но можно заметить как четырьмя первыми строчками скомпоновано что-то вроде:


("sb","t") .. SerializeAssociativeArray..

Тогда связь с исходным кодом улавливается. И должно стать понятно что если освоить такую запись (комбинируя 'Expression.Const', 'Expression.Parameter', 'Expression.Call', 'Expression.Lambda' etc ...) можно действительно компоновать динамически — любой обход узлов (исходя из метаданных). Это и есть eval в С#.


Шаг 2 — сходить по этой ссылке,


Тот же код декомпилера, но составленный человеком.


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


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


static Func<Point, string> formatter = JsonManager.ComposeFormatter<Point>();
public string Get(Point p){
   // which has better performance ?
   var json1 = JsonConvert.SerializeObject(p); 
   var json2 = formatter(p);
   return json2;
} 

Если же нужно построить и сохранять для переиспользования сериализатор анонимных типов, то необходима дополнительная инфраструктура:


static CachedFormatter cachedFormatter  = new CachedFormatter();
public string Get(List<Point> list){
   // there json formatter will be build only for first call 
   // and assigned to cachedFormatter.Formatter
   // in all next calls cachedFormatter.Formatter will be used.
   // since building of formatter is determenistic it is lock free 
   var json3 = list.Select(e=> {X:e.X, Sum:e.X+E.Y})
                         .ToJson(cachedFormatter, e=>e.Sum); 
   return json3;
} 

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


Что взамен?


Интерпретотор DSL Includes в serilize (а точно так же можно в итераторы equals, copy, clone — и об этом тоже будет) потребовал следующих трейдофф:


1ый трейдофф — нужна инфраструктура сохранения ссылок на скомпилированный код.


Этот трейдофф вообще-то не обязательный как и и использование Expression Trees с компиляцией — интерпретатор может создавать сериалайзер и на "рефлекшнах" и даже вылизать его на столько что он приблизится по скорости к потоковым сериалайзерам (кстати, демонстрируемые в конце статьи copy, clone и equals и не собираются через expression trees, да и не вылизывались, задачи такой нет, в отличии от "обогнать" ServiceStack и Json.NET в рамках всеми понимаемой задачи оптимизации сериализации в json — необходимое условие представления нового решения).


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


Например, для сериализации Point и IEnumerable нужны два разных сериализатора

var formatter1 = JsonManager.ComposeFormatter<Point>();
var formatter2 = JsonManager.ComposeEnumerableFormatter<Point>();
// but not
// var formatter2 = JsonManager.ComposeEnumerableFormatter<List<Point>>();

Или: "а работает ли замыкание/closure?". Работает, только узлу нужно задать имя (уникальное):


string DATEFORMAT= "YYYY";
var formatter3 = JsonManager.ComposeFormatter<Record>(
          chain => chain
                    .Include(i => i.RecordId)
                    .Include(i => i.CreatedAt.ToString(DATEFORMAT) , "CreatedAt");
);

Такое поведение диктуется внутренним устройством конкретно интерпретатора ComposeFormatter.


Этот трейдофф является неизбежным злом. Более того, обнаруживается, что наращивая функционал и расширяя сферы применения Internal DSL, увеличиваются и утечки абстракции. Разработчика Internal DSL это конечно будет угнетать, тут надо запастись философским настроением.


Для пользователя утечки абстракции преодолеваются знанием технических деталей Internal DSL (чего ожидать?) и богатством функционала конкретного DSL и его инерпретаторов (что взамен?). Поэтому ответом на вопрос: "стоит ли создавать и использовать Internal DSL?", может быть только рассказ о функциональности конкретного DSL — о всех его мелочах и удобствах, и возможностях применения (интерперетаторах), т.е. рассказ о преодолении трейдофф.


Имея все это ввиду, возвращаюсь к эффективности конкретного DSL Includes.


Значительно большая эффективность достигается, когда целью становится замена тройки (DTO, трансформация в DTO, сериализация DTO) одной по месту подробно проинструктированной и сгенерированной функцией сериализации. В конце-концев дуализм функция-объект позволяет утверждать "DTO это такая функция" и ставить цель: научиться задавать DTO функцией.


Сериализация должна конфигурироваться:


  1. Деревом обхода (описать узлы по которым будет проходить сериализация, кстати это решает проблему циркулярных ссылок), в случае листьев — присвоить форматтер (по типу).
  2. Правилом включения листьев (если они не заданы) — property vs fields? readonly?
  3. Иметь возможность задать как ветку (узел с навигацией) так и лист не просто MemberExpression (e=>e.Name), а вообще любой функцией (`e=>e.Name.ToUpper(), "MyMemberName") — задать форматтер конкретному узлу.

Другие возможности служащие увелечению гибкости:


  1. сериализовать лист содержащую стрку json "as is" (специальный форматтер строк);
  2. задавать форматтеры группам, т.е. целым веткам, в этой ветке так — в другой по другому (например тут даты со временем, а в этой без времени).

Везде участвовуют такие конструкции как: дерево обхода, ветка, лист, и это все может быть записано используя DSL Includes.


DSL Includes


Поскольку все знакомы с EF Core — cмысл последующих выражений должен улавливаться сразу же (это такое подмножество xpath).


 // DSL Includes
Include<User> include1 = chain=> chain
   .IncludeAll(e => e.Groups)
   .IncludeAll(e => e.Roles)
       .ThenIncludeAll(e => e.Privileges)

// EF Core syntax
// https://docs.microsoft.com/en-us/ef/core/querying/related-data
var users = context.Users
   .Include(blog => blog.Groups)
   .Include(blog => blog.Roles)
      .ThenInclude(blog => blog.Privileges);

Тут перечислены узлы "с навигацией" — "ветки".
Ответ на вопрос какие узлы "листья" (поля/свойства) включаются в так заданное дерево — никакие. Чтобы включить листья их надо либо перечислить явно:


Include<User> include2 = chain=> chain
   .Include(e => e.UserName) // leaf member
   .IncludeAll(e => e.Groups)
      .ThenInclude(e => e.GroupName) // leaf member
   .IncludeAll(e => e.Roles)
      .ThenInclude(e => e.RoleName) // leaf member
   .IncludeAll(e => e.Roles)
      .ThenIncludeAll(e => e.Privileges)
           .ThenInclude(e => e.PrivilegeName) // leaf member

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


// Func<ChainNode, MemberInfo> rule = ...
var include2 = IncludeExtensions.AppendLeafs(include1, rule); 

Тут rule -правило, которое может отбирать по ChainNode.Type т.е. по тип выражения возвращаемого узлом (ChainNode — внутренее представление DSL Includes, о чем еще будет сказано) свойства (MemberInfo) для участия в сериализации, напр. только property, или только read/write property, или только, те для которых есть форматер, можно отбирать по списку типов, и даже само include выражение может задавать правило (если в нем перечислены узлы-листья — т.е. форма объединения деревьев).


Либо… оставить на усмотрение пользовательскому итерпретатору, который сам решает что делать с узлами. DSL Includes это просто запись метаданных — как интерпретировать эту запись зависит от интерпертатора. Он может интерпретировать метаданные как ему хочется вплоть до игнорирования. Хороший Internal DSL рассчитан на универсальное использование и существования различных интерпертаторов, каждый из которых имеет свои детали реализации.
Одни интерпертаторы будут сами выполнять действие, другие строить функцию готовую их выполнять (через Expression Tree, или даже Reflection.Emit). Код с использованием Internal DSL будет сильно отличаться от того что было до него.


Out of the box


Интеграция с EF Core.
Ходовая задача "отрубить циклические ссылки", в сериализацию пускать только то что задано в include-выражении:


static CachedFormatter cachedFormatter1 = new CachedFormatter();
    string GetJson()
    {
           using (var dbContext = GetEfCoreContext())
           {
                 string json = 
                 EfCoreExtensions.ToJsonEf<User>(cachedFormatter1, dbContext, chain=>chain
                              .IncludeAll(e => e.Roles)
                              .ThenIncludeAll(e => e.Privileges));
           }
    }

Интерпретатору ToJsonEf принимает навигационную последовательность, при сериализации использует ее же (отбирает листья правилом "по умолчанию для EF Core", т.е. public read/write property), интересуется у модели — где string/json чтобы вставить as is, использует форматтеры полей по умолчанию (byte[] в строку, datetime в ISO и т.п). Поэтому он должен выполнять IQuaryable из под себя.


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


static CachedFormatter cachedFormatter1 = new CachedFormatter();
string GetJson()
{
           using (var dbContext = GetEfCoreContext())
           {
                 var json = dbContext.ParentRecords
                           // back to EF core includes
                           // but .Include(include1) also possible
                              .IncludeAll(e => e.Roles)
                              .ThenIncludeAll(e => e.Privileges) 
                   .Select(e => new { FieldA: e.FieldA, FieldJson:"[1,2,3]", Role: e.Roles().First() })
                   .ToJson(cachedFormatter1, 
                          chain => chain.Include(e => e.Role),
                          LeafRuleManager.DefaultEfCore,
                          config:  rules => rules
                               .AddRule<string[]>(GetStringArrayFormatter)
                               .SubTree(
                                      chain  => chain.Include(e => e.FieldJson),
                                      stringAsJsonLiteral: true) // json as is
                               .SubTree(
                                      chain  => chain.Include(e => e.Role),
                                      subRules => subRules
                                                   .AddRule<DateTime>(
                                                         dateTimeFormat: "YYYMMDD",
                                                          floatingPointFormat: "N2"
                                        )
                    ),
                    ),
                useToString: false, // no default ToString for unknown leaf type (throw exception)
                dateTimeFormat: "YYMMDD", 
                floatingPointFormat: "N2"
           }
}

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


Необходимо предупредить: хотя казалось бы в ASP и предварительное знание всегда в наличии, и потоковый сериалайзер не слишком нужная штука в мире веба, где даже базы данных отдают данные в json, но применение DSL Includes в ASP MVC история не самая простая. Как комбинировать функциональное программирование с ASP MVC — заслуживает отдельного исследования.


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


Еще больше DSL Includes


Include<Point> include = chain => chain.Include(e=>e.X).Include(e=>e.Y);

Это отличается от EF Сore Includes построенного на статических функциях, которые невозможно присваивать переменным и передавать в качестве параметров. Сам DSL Includes родился от потребности передавать "include" в мою реализацию шаблона Repository без деградации информации о типах которая бы появилась при стандартном переводе их в строки.


Самое кардинальное отличие все же в назначении. EF Core Includes — включение свойств навигации (узлов веток), DSL Includes — запись обхода дерева вычислений, присваивание имени (path) результату каждого вычисления.


Внутреннее представление EF Core Includes — список строк полученных MemberExpression.Member (Expression задаваемая e=>User.Name может быть только [MemberExpression](https://msdn.microsoft.com/en-us/library/system.linq.expressions.memberexpression(v=vs.110).aspx а во внутренних представлениях сохраняется только строчка Name).


В DSL Includes внутреннее представление — классы ChainNode и ChainMemberNode сохраняющее expression (e.g. e=>User.Name)целиком, которое, может быть как есть встроено в Expression Tree. Именно из этого следует и то что DSL Includes поддерживает и поля и пользовательские value types и вызовы функции:


Исполнение функций :


Include<User> include = chain => chain
                    .Include(i => i.UserName)
                    .Include(i => i.Email.ToUpper(),"EAddress");

Что с этим делать зависит от интерпретатора. CreateFormatter- выдаст {"UserName":"John", "EAddress":"JOHN@MAIL.COM"}


Исполнение так же может быть полезным для задания обхода по nullable структурам


Include<StrangePointF> include
    = chain => chain
         .Include(e => e.NextPoint) // NextPoint is nullable struct
             .ThenIncluding(e => e.Value.X)
             .ThenInclude(e => e.Value.Y);

// but not this way (abstraction leak)
//            Include<StrangePointF> include
//                = chain => chain
                       // now this can throw an exception
//                    .Include(e => e.NextPoint.Value)  
//                        .ThenIncluding(e => e.X) 
//                        .ThenInclude(e => e.Y);

В DSL Includes так же существует короткая запись многоуровнего обхода ThenIncluding .


Include<User> include = chain => chain
    .Include(i => i.UserName)
    .IncludeAll(i => i.Groups)
           //  ING-form - doesn't change current node
          .ThenIncluding(e => e.GroupName)   // leaf
          .ThenIncluding(e => e.GroupDescription)  // leaf
          .ThenInclude(e => e.AdGroup); // leaf

сравните с


Include<User> include = chain => chain
      .Include(i => i.UserName)
      .IncludeAll(i => i.Groups)
            .ThenInclude(e => e.GroupName) 
      .IncludeAll(i => i.Groups)
            .ThenInclude(e => e.GroupDescription) 
      .IncludeAll(i => i.Groups)
            .ThenInclude(e => e.AdGroup);

И тут тоже есть утечка абстракции. Если я записал подобной формой навигацию, я должен знать как работает интерпетатор который будет вызывать QuaryableExtensions. А он переводит вызовы Include и ThenInclude в Include "строковый". Что может иметь значение (надо иметь ввиду).


Алгебра Include выражений.


Include-выражения можно:


Cравнивать
var b1 = InlcudeExtensions.IsEqualTo(include1, include2);
var b2 = InlcudeExtensions.IsSubTreeOf(include1, include2);
var b3 = InlcudeExtensions.IsSuperTreeOf(include1, include2);

Клонировать
var include2 = InlcudeExtensions.Clone(include1);

Объединять (merge)
var include3 = InlcudeExtensions.Merge(include1, include2);

Преобразовать в списки XPath - все пути до листьев
IReadOnlyCollection<string> paths1 = InlcudeExtensions.ListLeafXPaths(include); // as xpaths
IReadOnlyCollection<string[]> paths2 = InlcudeExtensions.ListLeafKeyPaths(include); //  as string[]

и т.п.


Хорошая новость: тут нет утечек абстракций, тут достигнут уровень чистой абстракции. Есть метаданные и работа с метаданными.


Диалектика


DSL Includes позволяет достичь новый уровень абстракции но в момент достижения формируется потребность выходить на следующий уровень: генерировать сами Include выражения.


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


var root = new ChainNode(typeof(Point));
var child = new ChainPropertyNode(
         typeof(int),
         expression: typeof(Point).CreatePropertyLambda("X"),
         memberName:"X", isEnumerable:false, parent:root
);
root.Children.Add("X", child);
// or there is number of extension methods e.g.: var child = root.AddChild("X");

Include<Point> include = ChainNodeExtensions.ComposeInclude<Point>(root);

В интерпретаторы тоже можно передавать структуры представления. Зачем же тогда fluent запись DSL includes вообще? Это чисто умозрительный вопрос, ответ на который: потому что на практике — развивать внутренне представление (а оно тоже развивается) получается только вместе с развитием DSL (т.е. краткой выразительной записью удобной для статического кода). Еще раз об этом будет сказано ближе к заключению.


Copy, Clone, Equals


Все сказанное верно и про интерпретаторы include-выражений реализующие итераторы copy, clone, equals.


Equals

Сравнение только по листьям из Include-выражения.
Скрытая семантическая проблема: оценивать или нет порядок в списке


Include<User> include = chain=>chain.Include(e=>e.UserId).IncludeAll(e=>e.Groups).ThenInclude(e=>e.GroupId)

bool b1 = ObjectExtensions.Equals(user1, user2, include);
bool b2 = ObjectExtensions.EqualsAll(userList1, userList2, include);

Clone

Проход по узлам выражения. Копируются свойства подходящие под правило.


Include<User> include = chain=>chain.Include(e=>e.UserId).IncludeAll(e=>e.Groups).ThenInclude(e=>e.GroupId)

var newUser = ObjectExtensions.Clone(user1, include, leafRule1);
var newUserList = ObjectExtensions.CloneAll(userList1, leafRule1);

Может существовать интрепретатор который будет отбирать leaf из includes. Почему сделано — через отдельное правило? Что было схоже с семантикой ObjectExtensions.Copy


Copy

Проход по узлам-ветка выражения и идентификация по узлам-листьям. Копируются свойства подходящие под правило (схоже с Clone).


Include<User> include = chain=>chain.IncludeAll(e=>e.Groups);

ObjectExtensions.Copy(user1, user2, include, supportedLeafsRule);  
ObjectExtensions.CopyAll(userList1, userList2, include, supportedLeafsRule);

Может существовать интерпретатор который будет отбирать leaf из includes. Почему сделано — через отдельное правило? Что было схоже с объявление ObjectExtensions.Copy (там разделение вынуждено — в include то как идентифицируем, в supportedLeafsRule — то что копируем).


Для copy / clone надо иметь ввиду:


  1. Невозможность копировать readonly свойства, причем это популярные типы Tuple<,> и Anonymous Type. Аналогичная проблема с клонированием, но несколько под другим углом.
  2. Абстрактный тип (напр. IEnumerable реализован приватным типом) — каким public типом его заменить.
  3. Все expression из include-выражений, которые не выражают свойства и поля — будут отброшены.
  4. "копирование в массив" не понятно что такое.

Автор DSL должен полагаться на то что такие неопределенные ситуации вытекающие из конфликта семантики и способа записи метаданных пользователь может предвидеть, т.е. предположит что они будут приводить к неопределенному результату и не будет рассчитывать на существующие интерпретаторы. Кстати, сериализация свойств анонимных и Tuple<,>, т.е. типов c readonly свойствами, или копирование ValueTuple<,> c writabale полями не являются неопределенными ситуациями (и реализованы как и можно было ожидать).


Хорошая новость, здесь в том что вообще написать свой интерпретатор (не претендуя на компиляцию Expression Trees) Includes выражений — достаточно просто. Вся алгебра работы с Include DSL уже реализована.


Возможно создание интерпретаторов Detach, FindDifferences и т.п.


Почему run-time, а не .cs сгенерированный до начала компиляции?


Наличие возможности сгенерировать .cs это лучше, чем отсутствие возможности, но у run-time есть свои преимущества:


  1. Избегаем затратной возни со сгенерированными исходниками (настройки каталогов, имен файлов, source control).
  2. Избегаем привязки к среде программирования, плагинам, перехватам событий, языкам скриптов — все что повышает порог вхождения.
  3. Избегаем проблемы "яйца и курицы". Кодогенерация dev time требует планирования очередности, иначе можно попасть в ситуацию: "А" не может скомпилироваться потому что "Б" еще не сгенерирован, а "Б" не может быть сгенерирован, потому, что "А" еще не скомпилирован.

Последнее решаемо Roslyn'ом, но и это решение приносит ограничения и увеличивает порог вхождения. Впрочем если нужны Typescript биндиги (я же DTO записал функцией, т.е. теперь это проблема) — надо вытаскивать DSL Includes выражения Roslyn'ом (сложная часть) — и писать интрерпретор их в typescript (простая часть). Тогда "за компанию" можно записать и "идеальный сериализатор" в .cs (а не в Expression Trees).


Подытожу: кодогенерация же run time — почти чистая кодогенерация, минимум инфраструктуры. Просто надо запомнить что следует избегать многократного пересоздания функций которые можно переиспользовать (и соглашаться на дикую по объему знаков запись Expression Trees).


Проблемы с эффективностью скомпилированных функций Expression Trees


При программировании Internal DSL при помощи Expression Tree надо иметь ввиду что:


  1. LambdaExpression.Compile компилирует только верхнюю Lambda. При этом выражение остается рабочим, но медленным. Компилировать надо каждую лямбда, по ходу "склейки" expression tree, передавая в CallExpression в качестве параметров — не LambdaExpression, а делегат (т.е откомпилированный LambdaExpression) завернутый в константу ConstantExpression.


  2. Компиляция происходит в динамически создаваемый анонимный аssеmbly, и вызов методов проходит (в 10 наносекунд в моих тестах) проверку на безопасность (мои assembly не подписаны, возможно когда подписаны — будет дольше). Оно, конечно, не много, но если сильно дробить код — может накапливаться.



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


Бенчмарк сериализации


Данные — Объект содержащий массив из 600 записей на 15 полей простых типов. Потоковым JSON.NET, ServiceStack нужно два вызова reflection'а GetProperties().


dslComposeFormatter — ComposeFormatter на первом месте, остальные подробности здесь .


BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134
Intel Core i5-2500K CPU 3.30GHz (Sandy Bridge), 1 CPU, 4 logical and 4 physical cores
.NET Core SDK=2.1.300


Method Mean Error StdDev Min Max Median Allocated
dslComposeFormatter 2.208 ms 0.0093 ms 0.0078 ms 2.193 ms 2.220 ms 2.211 ms 849.47 KB
JsonNet_Default 2.902 ms 0.0160 ms 0.0150 ms 2.883 ms 2.934 ms 2.899 ms 658.63 KB
JsonNet_NullIgnore 2.944 ms 0.0089 ms 0.0079 ms 2.932 ms 2.960 ms 2.942 ms 564.97 KB
JsonNet_DateFormatFF 3.480 ms 0.0121 ms 0.0113 ms 3.458 ms 3.497 ms 3.479 ms 757.41 KB
JsonNet_DateFormatSS 3.880 ms 0.0139 ms 0.0130 ms 3.854 ms 3.899 ms 3.877 ms 785.53 KB
ServiceStack_SerializeToString 4.225 ms 0.0120 ms 0.0106 ms 4.201 ms 4.243 ms 4.226 ms 805.13 KB
fake_expressionManuallyConstruted 54.396 ms 0.1758 ms 0.1644 ms 54.104 ms 54.629 ms 54.383 ms 7401.58 KB

fake_expressionManuallyConstruted — expression где только верхняя лямбда скомпилирована (цена ошибки).


Формализация


Кодогенерация и DSL связаны следующим образом: для создания эффективного DSL необходима кодогенерация в язык среды исполнения; для создания эффективного Internal DSL необходима кодогенерация run-time.


Следствием из закона "эффективности DSL" является то что Expression Tree — является инструментом, который мы используем только потому что это безальтернативный способ иметь кодогенерацию находясь в .NET Standard фреймворке.


С другой стороны использование Expression Trees для разбора выражений не является признаком выделяющим Internal DSL из всего класса fluent API. Таким признаком является использование грамматики С# для выражения отношений в проблемной области, а построение структур представления может идти путем простого исполнения fluent выражений кода (без разбора посредством Expression Trees, при этом наиболее характерным для Internal DSL в С# является комбинирование исполнения цепочек fluent, в каждой из которых есть "немножко" разбора посредством Expression Trees).


Expression Trees внутри DSL Includes играют роль весьма не большую (достать имена узлов, если они не указаны в ручную), и наоборот для создания эффективного интерпертатора/сериалайзера — решающую. При этом DSL Includes имеет гораздо большее значение для самого творческого процесса: созданные библиотечные функции- итераторы свойств serialize, copy, clone, equals являются производными по отношению к найденному способу записать процесс итерации и эффективно упростить запись "обхода". На это утверждение никак не влияет, что после перехода на новый, более высокий уровень абстракции — генерируется уже сам "Internal DSL", через создание его "структур представления", т.е. без записи fluent C#.


Когда теоретически можно решить, что стоит ограничится изобретением только "структур представления", практически, творческий процесс таким путем не идет. Удобная символьная запись необходима: алгебра includes гораздо более выразительна (а значит помогает мышлению) чем, те же операции записанные со структурами (хотя они конечно необходимы, по скольку эффективны).


Заключение


При помощи DSL Includes появилась возможность записать DTO наконец тем чем оно и является в значительном числе случаев — функцией сериализации (в json). Удалось выйти на новый уровень абстракции не потеряв, а приобретя в производительности, как в скорости вычислений, так и "меньше кода", но все же за счет увеличения прикладной сложности. Рост абстракции = рост утечек абстракции.


Ответом этой проблеме со стороны разработчика Internal DSL является обращение внимания пользователя на семантику операций, реализуемых интерпретаторами DSL, на необходимость знания структур представления Internal DSL (в каком виде сохраняются Expression) и на важность знания о внутреннем устройстве интерпретатора (используют или не используют компиляцию Expression Tree).


И DSL Includes и json сериализатор ComposeFormatter лежат в библиотеке DashboardCodes.Routines доступной через nuget и GitHub.

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


  1. gnaeus
    10.08.2018 10:16

    А Вы не могли бы добавить в бенчмарк JSON-сериализации еще и библиотеку Jil, которая также строит сериализаторы с помощью генерации IL-кода в рантайме ?


    По поводу быстрой компиляции Expression: есть такая библиотека FastExpressionCompiler. Она компилирует выражения в IL без создания динамической assembly.


    1. RomanPokrovskij Автор
      10.08.2018 10:39

      Спасибо, ради таких дополнений и писал статью.


    1. RomanPokrovskij Автор
      10.08.2018 11:44

      Померил Jil — Jil в два раза быстрее.
      В статью добавлять пока не буду — потому что не понимаю трейдофф от компиляции через Reflection.Emit. Кроме того github.com/dotnet/corefx/issues/29365. Jil/Sigil на windows в Core запускается (монстры), но что дальше вопрос. И что-то там все равно не так — asterix ами накрыло ветку Dependencies Benchmark проекта в VS/SolExp после добавления Jil (хотя запускается). В общем надо подумать. А так — да — ставлю в очередь — сесть и написать интерпертатор DSL Includes компилирующий сериализатор через Reflection.Emit (точнее искать компилятор Expression Tree — Reflection Emit), чтобы всё понять.

      Меня больше заинтриговал вопрос как они кэшируют скомпилированные функции? По типу? По месту вызова — рассматривая стек? Я на такое не решился.


      1. RomanPokrovskij Автор
        10.08.2018 11:57

        А, вот FastExpressionCompiler — и есть такой компилятор. Надо пробовать.


        1. RomanPokrovskij Автор
          10.08.2018 13:53

          Сериалайзер построенный с FastCompile — несущественно добавил к перформансу (0.2 ms), Jil не обогнал (еще 0.8 ms не хватило) а в целом jil у ComposeFormatter выигрывает 1ms. Все лямбды скомпилированы при помощи FastCompile. Рабочая гипотеза — Jil строит компилятор с минимум работы со стеком, тут и выигрыш.

          компилятор выведен как параметр
          ```
          var composeFormatterFastCompileDelegate = JsonManager.ComposeFormatter(includeWithLeafs, compile: (ex)=>ex.CompileFast());
          ```

          вообще, надо брать windbg и смотреть код.
          не буду обещать что сделаю, но было бы интересно.


          1. RomanPokrovskij Автор
            10.08.2018 14:10

            «Jil строит компилятор» читать как «Jil строит сериализатор»


      1. gnaeus
        10.08.2018 15:53
        +1

        На сколько я знаю, кешируют по типу при первой (де)сериализации объекта


      1. force
        10.08.2018 19:10

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

        Быстрее. Как пример, клонятор использует Reflection.Emit, когда возможно и заваливается на ExpressionTree, если не получается. Код по идее идентичен, но на экспрешшенах тупо медленнее. Или же где-то есть ошибка, или же особенность задачи приводит к разным операциям, но в целом голый il код получается производительнее.

        PS: А вообще, Jil делает очень упоротые оптимизации в плане сериализации данных в строку, так что он ещё там может выигрывать.


        1. RomanPokrovskij Автор
          10.08.2018 19:48

          Да, трейдофф «быстрее» за ограниченность платформы (не Standard).
          В прочем моей удачей было бы не перегнать клонятор или Jil по скорости, а им о Include DSL рассказать. И убедить поддерживать.


          1. nomoreload
            11.08.2018 12:00

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


            1. RomanPokrovskij Автор
              11.08.2018 12:49
              -1

              Это то ладно, но Jil меня больше удивил тем, что нет и не надо четкого разделения между потоковым сериализатором и компилирующим функцию. И надо было и мне (покрайней мере для статьи) смело идти путем кэширования по ключу: тип + [CallerMemberName] + [CallerName]. Статья вообще построена на противопоставлении потоковых клонеров, сериалайзерах — компилирующим. А оказалось компилирующие просто имитируют потоковость — и противопоставление преодолено. Опять же DSL Include — удобная запись метаданных и может быть использована везде где нужно задать дерево обхода (вычислений) с проименованием узлов, но статья должна строится немного по другому.


            1. RomanPokrovskij Автор
              11.08.2018 15:58

              Я сверил сериализацию jil c просто кодом записанным `.Append(«I1:»).Append(e.I1).Append(",")` и т.п. 12 атрибутов, массив 600 элеменов. Jil победил но с разницей в 30 **нано**секуд. Т.е. если сгенерировать по мете сериализатор в .cs и избавится от имитации потоковости а вызывать сериализатор в контроллере — эффективность jil будет фактически повторена. Это не считая тех случаев когда «тонкой сериализацией» получается убрать DTO — в таком случае и сейчас ComposeFormatter — эффективней. А идеал лежит в другой плоскости заменить пару Select + ToList одним ToJson(cache, IQueryable, IModel). Правда Include DSL тут уже не будет (`IQueryable` не`Include`).


  1. dadhi
    10.08.2018 10:38

    Для быстрой компиляции и быстрого итогового делегата: FastExpressionCompiler


  1. mayorovp
    10.08.2018 13:24

    LambdaExpression.Compile компилирует только верхнюю Lambda.

    Это вообще как? А куда деваются остальные?


    1. RomanPokrovskij Автор
      10.08.2018 14:01

      не компилируются в IL (не создаются в динамическом assembly) т.е. исполняются в каком-то режиме интерпретации.

      хорошая тема для нового исследования — запустить из windbg и посмотреть что происходит точно.


      1. mayorovp
        10.08.2018 14:22

        Не верю…


        1. RomanPokrovskij Автор
          10.08.2018 14:40

          что режим интерпретации не возможен?

          вот косвенное доказательтво: у LambdaExpression.Compile есть параметр preferInterpretation


          1. mayorovp
            10.08.2018 14:43

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


            1. RomanPokrovskij Автор
              11.08.2018 12:52
              -1

              А верите ли, что некомпилируемые вложенные LambdaExpression не создаются в динамическом ассембли по крайней мере отдельными динамическими функциями? А если каждую вложенную компилировать (для последующего заворачивания в константу) — то создаются и все начинает усорятся? Это как раз просто доказать (без поисков «неизвестного» в WinDbg — это весть день портатить надо).


            1. RomanPokrovskij Автор
              11.08.2018 19:44

              Возможно надо попасть в какую-то тютельку, подсовывая в параметры вызова то что надо, и компилировать внутренюю лямбду будут. Например если параметры «не дженерик» делегаты. Инетересно что FastCompile получив полное выражение без оптимизации «скомпилированная внутреннея лямбда» — тоже не смог большего чем простой Compile — хотя казалось бы- разобрал — скомпилировал — собрал — но есть какие-то сложности.

              А разница по факту — ровно на порядок. Компилируешь внутренние лямбды — 2 ms, не компилируешь — 20 ms (и никакой FastExpressionCompiler, как я сказал — не исправляет — в случае моих выражений, по крайней мере)