Pull to refresh

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

Reading time 19 min
Views 8.3K


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


Демонстрировать буду динамическое создание итераторов свойств: serialize, copy, clone, equals. На примере serialize покажу как можно оптимизировать сериализацию (по сравнению с потоковыми сериализаторами) в классической ситуации, когда "предварительное" знание используется для улучшения производительности. Идея в том, что вызов потокового сериалайзера всегда проиграет "непотоковой" функции точно знающей какие узлы дерева надо обойти. При этом такой сериализатор создается "не руками" а динамически, но по заранее заданным правилам обхода. Предложенный Inernal DSL решает задачу компактного описания правил обхода древовидных структур объектов по их свойствам/properties (а в общем случае: обхода дерева вычислений 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 это просто запись метаданных — как интерпретировать эту запись зависит от интерпертатора. Он может интерпретировать метаданные как ему хочется вплоть до игнорирования. Одни интерпертаторы будут сами выполнять действие, другие строить функцию готовую их выполнять (через Expression Tree, или даже Reflection.Emit). Хороший Internal DSL рассчитан на универсальное использование и существования многих интерпертаторов, каждый из которых имеет свою специфику, свои утечки абстракции.
Код с использованием 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. Гарантированное отсутствие изменений в сгенерированном коде облегчает его обновление.
  4. Избегаем проблемы "яйца и курицы". Кодогенерация 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. Это сильно увеличивает код, поскольку "передай лямбду/функцию" — самая популярная операция в функциональном программировании, которого и требуют Expression Trees.


  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.


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


С другой стороны использование Expression Trees для разбора выражений не является признаком выделяющим Internal DSL из всего класса Fluent API. Таким признаком является использование грамматики С# для выражения отношений в проблемной области.


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


Expression Trees внутри DSL Includes играют роль весьма не большую (достать имена узлов, если они не указаны в ручную), и наоборот для создания эффективного интерпертатора/сериалайзера компилируемого run-time — решающую (run-time компиляция).


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


Заключение


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


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


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

Tags:
Hubs:
+33
Comments 20
Comments Comments 20

Articles