Главная » Хабрахабр » Internal DSL & Expression Trees — динамическое создание функций serialize, copy, clone, equals (Часть I)

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

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

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

Введение

Сравните:

var p = new Point();
// 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);

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

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

Анализу трейдофф (уступок?) описания метаданных DSLом, просвещена работа, но сейчас, не уделяя внимания методу объявления "правил обхода" (т.е. Это и есть DSL Includes. игнорируя форму записи метаданных), акцентирую что 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 будут задаваться уже совсем по другому — динамически (ниже).

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

Если бы 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; }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

string DATEFORMAT= "YYYY";
var formatter3 = JsonManager.ComposeFormatter<Record>( 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<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);

узлу обхода (внутренее представление DSL Includes, еще будет сказано) свойства (MemberInfo) для участия в сериализации, напр. Тут rule -правило, которое может отбирать по ChainNode т.е. форма объединения деревьев). только 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<User>(cachedFormatter1, dbContext, chain=>chain .IncludeAll(e => e.Roles) .ThenIncludeAll(e => e.Privileges)); } }

public read/write property), интересуется у модели — где string/json чтобы вставить as is, использует форматтеры полей по умолчанию (byte[] в строку, datetime в ISO и т.п). Интерпретатору ToJsonEf принимает навигационную последовательность, при сериализации использует ее же (отбирает листья правилом "по умолчанию для EF Core", т.е. Поэтому он должен выполнять 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 MVC — заслуживает отдельного исследования. Необходимо предупредить: хотя казалось бы в ASP и предварительное знание всегда в наличии, и потоковый сериалайзер не слишком нужная штука в мире веба, где даже базы данных отдают данные в json, но применение DSL Includes в ASP MVC история не самая простая.

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

Еще больше DSL Includes

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

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

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

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

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

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

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
// .Include(e => e.NextPoint.Value) // now this can throw expression
// .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, Equeals

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

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. Copy Что было схоже с семантикой ObjectExtensions.

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. Copy (там разделение вынуждено — в include то как идентифицируем, в supportedLeafsRule — то что копируем). Что было схоже с объявление ObjectExtensions.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

10. BenchmarkDotNet=v0. 0. 14, OS=Windows 10. 30GHz (Sandy Bridge), 1 CPU, 4 logical and 4 physical cores
. 17134
Intel Core i5-2500K CPU 3. 1. NET Core SDK=2. 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.

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

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

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

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

Заключение

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

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Пишем торговых роботов с помощью графического фреймворка StockSharp. Часть 1

Один из них – бесплатная платформа StockSharp, которую можно использовать для профессиональной разработки торговых терминалов и торговых роботов на языке C#. В нашем блоге мы много пишем о технологиях и полезных инструментах, связанных с биржевой торговлей. API, с целью создания ...

[Перевод] Сверхинтеллект: идея, не дающая покоя умным людям

Расшифровка выступления на конференции Web Camp Zagreb Мачея Цегловского, американского веб-разработчика, предпринимателя, докладчика и социального критика польского происхождения. В 1945 году, когда американские физики готовились к испытанию атомной бомбы, кому-то пришло в голову спросить, не может ли такое испытание зажечь ...