Internal DSL & Expression Trees — динамическое создание функций serialize, copy, clone, equals (Часть I)

_cm3grewmg9yewpcch1nqkl2ho8.png

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

Демонстрировать буду динамическое создание итераторов свойств: serialize, copy, clone, equals. На примере serialize покажу как можно оптимизировать сериализацию (по сравнению с потоковыми сериализаторами) в классической ситуации, когда «предварительное» знание используется для улучшения производительности. Идея в том, что вызов потокового сериалайзера всегда проиграет «непотоковой» функции точно знающей какие узлы дерева надо обойти, при этом выписанной «не руками», а динамически, по правилам. Inernals DSL решает задачу компактного задания правила обхода дерева свойств (вычислений) . Бенчмарк сериализатора скромный, но он важен тем, что добавляет подходу, построенному вокруг применения конкретного 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();
var json2 = formatter(p);

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

// обход задан явно
var formatter2 = JsonManager.ComposeFormatter(
   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  serializer = ... // later
 string formatter(Point p)
            {
                var stringBuilder = new StringBuilder();
                serializer(stringBuilder, p);
                return  stringBuilder.ToString();
            }

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

Expression> 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 serializer = serializerExpression.Compile();  

Зачем так «функционально», почему нельзя задать сериализацию двух полей через «точку с запятой»? Коротко: потому что вот это выражение можно присвоить переменной типа Expression>, а «точку с запятой» нельзя.
Почему нельзя было прямо написать Func 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(StringBuilder stringBuilder, T t, params Func[] 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(StringBuilder stringBuilder, T t, string propertyName,
            Func getter, Func 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(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

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

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

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


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

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

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

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

static Func formatter = JsonManager.ComposeFormatter();
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 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();
var formatter2 = JsonManager.ComposeEnumerableFormatter();
// but not
// var formatter2 = JsonManager.ComposeEnumerableFormatter>();

или почему работает такой код?

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

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

Этот трейдофф является неизбежным злом. При этом этом обнаруживается, что наращивая функционал и расширяя сферы применения 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. задавать форматтеры одного и тоже типа, разными в разных ветках (например даты со временем, и без времени) — от (3) отличается групповым заданием.

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


DSL Includes

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

 // DSL Includes
Include 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 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 rule = ...
var include2 = IncludeExtensions.AppendLeafs(include1, rule); 

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

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


Out of the box

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

static CachedFormatter cachedFormatter1 = new CachedFormatter();
    string GetJson()
    {
           using (var dbContext = GetEfCoreContext())
           {
                 string json = 
                 EfCoreExtensions.ToJsonEf(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(GetStringArrayFormatter)
                               .SubTree(
                                      chain  => chain.Include(e => e.FieldJson),
                                      stringAsJsonLiteral: true) // json as is
                               .SubTree(
                                      chain  => chain.Include(e => e.Role),
                                      subRules => subRules
                                                   .AddRule(
                                                         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 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 include = chain => chain
                    .Include(i => i.UserName)
                    .Include(i => i.Email.ToUpper(),"EAddress");

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

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

Include 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 include
//                = chain => chain
//                    .Include(e => e.NextPoint.Value)  // now this can throw expression
//                        .ThenIncluding(e => e.X) 
//                        .ThenInclude(e => e.Y);

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

Include 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 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.Equals(include1, include2);


Преобразовать в списки XPath — все пути до листьев
IReadOnlyCollection paths1 = InlcudeExtensions.ListLeafXPaths(include);

и т.п.

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


Диалектика

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 include = ChainNodeExtensions.ComposeInclude(root);

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


Copy, Clone, Equeals

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


Equals

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

Include 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 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 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 должен полагаться на то что такие неопределенные ситуации вытекающие из конфликта семантики и способа записи метаданных пользователь может предвидеть, т.е. предположит что они будут приводить к неопределенному результату и не будет рассчитывать на существующие интерпретаторы. Кстати, сериализация свойств анонимных типов, или копирование ValueTuple<,> не является неопределенной ситуацией (и реализованы как и можно было ожидать).

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

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


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

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


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

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

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


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

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


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


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


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

© Habrahabr.ru