Статья посвящена двойному применению API Expression Trees — для разбора выражений и для генерации кода. Разбор выражений помогает построить структуры представления (они же структуры представления проблемно-ориентированного языка Internal DSL), а кодогенерация позволяет динамически создавать эффективные функции — наборы инструкций задаваемые структурами представления.
Демонстрировать буду динамическое создание итераторов свойств: serialize, copy, clone, equals. На примере serialize покажу как можно оптимизировать сериализацию (по сравнению с потоковыми сериализаторами) в классической ситуации, когда "предварительное" знание используется для улучшения производительности. Идея в том, что вызов потокового сериалайзера всегда проиграет "непотоковой" функции точно знающей какие узлы дерева надо обойти, при этом выписанной "не руками" а динамически, по заданным правилам. Inernal DSL и решает задачу компактного задания правила обхода дерева свойств (в общем случае: вычислений c проименованием узлов) . Бенчмарк сериализатора скромный, но он важен тем, что добавляет подходу, построенному вокруг применения конкретного Internal DSL Includes (диалект того Include/ThenInclude что из EF Core) и применению Internal DSL в целом, необходимой убедительности.
Введение
Сравните:
var p = new Point(){X=-1,Y=1};
// which has better performance ?
var json1 = JsonConvert.SerializeObject(p);
var json2 = $"{{\"X\":{p.X}, \"Y\":{p.Y}}}";
Второй способ — очевидно быстрей (узлы известны и "забиты в код"), при этом способ конечно же сложней. Но когда вы получите этот код как функцию (динамически сгенерированную и скомпилированную) — сложность скрывается (скрывается даже то что становится не понятно
где рефлексия, а где кодогенерация рантайм).
var p = new Point(){X=-1,Y=1};
// which has better performance ?
var json1 = JsonConvert.SerializeObject(p);
var formatter = JsonManager.ComposeFormatter<Point>();
var json2 = formatter(p);
Здесь JsonManager.ComposeFormatter
— реальный инструмент. Правило по которому генерируется обход структуры при сериализации не очевидно, но оно звучит так "при параметрах по умолчанию, для пользовательских value type обойди все поля первого уровня". Если же его задавать явно:
// обход задан явно
var formatter2 = JsonManager.ComposeFormatter<Point>(
chain=>chain
.Include(e=>e.X)
.Include(e=>e.Y) // DSL Includes
)
Это и есть DSL Includes. Анализу трейдофф (уступок?) описания метаданных DSLом, просвещена работа, но сейчас, не уделяя внимания методу объявления "правил обхода" (т.е. игнорируя форму записи метаданных), акцентирую что C# предоставляет возможность собрать и скомпилировать "идеальный сериализатор" при помощи Expression Trees.
переход от 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
не сложное:
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 функцией.
Сериализация должна конфигурироваться:
- Деревом обхода (описать узлы по которым будет проходить сериализация, кстати это решает проблему циркулярных ссылок), в случае листьев — присвоить форматтер (по типу).
- Правилом включения листьев (если они не заданы) — property vs fields? readonly?
- Иметь возможность задать как ветку (узел с навигацией) так и лист не просто MemberExpression (
e=>e.Name
), а вообще любой функцией (`e=>e.Name.ToUpper(), "MyMemberName") — задать форматтер конкретному узлу.
Другие возможности служащие увелечению гибкости:
- сериализовать лист содержащую стрку json "as is" (специальный форматтер строк);
- задавать форматтеры группам, т.е. целым веткам, в этой ветке так — в другой по другому (например тут даты со временем, а в этой без времени).
Везде участвовуют такие конструкции как: дерево обхода, ветка, лист, и это все может быть записано используя DSL Includes.
DSL Includes
Поскольку все знакомы с EF Core — cмысл последующих выражений должен улавливаться сразу же (это такое подмножество xpath).
// DSL Includes
Include<User> include1 = chain=> chain
.IncludeAll(e => e.Groups)
.IncludeAll(e => e.Roles)
.ThenIncludeAll(e => e.Privileges)
// EF Core syntax
// https://docs.microsoft.com/en-us/ef/core/querying/related-data
var users = context.Users
.Include(blog => blog.Groups)
.Include(blog => blog.Roles)
.ThenInclude(blog => blog.Privileges);
Тут перечислены узлы "с навигацией" — "ветки".
Ответ на вопрос какие узлы "листья" (поля/свойства) включаются в так заданное дерево — никакие. Чтобы включить листья их надо либо перечислить явно:
Include<User> include2 = chain=> chain
.Include(e => e.UserName) // leaf member
.IncludeAll(e => e.Groups)
.ThenInclude(e => e.GroupName) // leaf member
.IncludeAll(e => e.Roles)
.ThenInclude(e => e.RoleName) // leaf member
.IncludeAll(e => e.Roles)
.ThenIncludeAll(e => e.Privileges)
.ThenInclude(e => e.PrivilegeName) // leaf member
Либо добавить динамически по правилу, через специализированный интрепретатор:
// Func<ChainNode, MemberInfo> rule = ...
var include2 = IncludeExtensions.AppendLeafs(include1, rule);
Тут rule -правило, которое может отбирать по ChainNode.Type т.е. по тип выражения возвращаемого узлом (ChainNode — внутренее представление DSL Includes, о чем еще будет сказано) свойства (MemberInfo) для участия в сериализации, напр. только property, или только read/write property, или только, те для которых есть форматер, можно отбирать по списку типов, и даже само include выражение может задавать правило (если в нем перечислены узлы-листья — т.е. форма объединения деревьев).
Либо… оставить на усмотрение пользовательскому итерпретатору, который сам решает что делать с узлами. DSL Includes это просто запись метаданных — как интерпретировать эту запись зависит от интерпертатора. Он может интерпретировать метаданные как ему хочется вплоть до игнорирования. Хороший Internal DSL рассчитан на универсальное использование и существования различных интерпертаторов, каждый из которых имеет свои детали реализации.
Одни интерпертаторы будут сами выполнять действие, другие строить функцию готовую их выполнять (через Expression Tree, или даже Reflection.Emit). Код с использованием Internal DSL будет сильно отличаться от того что было до него.
Out of the box
Интеграция с EF Core.
Ходовая задача "отрубить циклические ссылки", в сериализацию пускать только то что задано в include-выражении:
static CachedFormatter cachedFormatter1 = new CachedFormatter();
string GetJson()
{
using (var dbContext = GetEfCoreContext())
{
string json =
EfCoreExtensions.ToJsonEf<User>(cachedFormatter1, dbContext, chain=>chain
.IncludeAll(e => e.Roles)
.ThenIncludeAll(e => e.Privileges));
}
}
Интерпретатору ToJsonEf
принимает навигационную последовательность, при сериализации использует ее же (отбирает листья правилом "по умолчанию для EF Core", т.е. public read/write property), интересуется у модели — где string/json чтобы вставить as is, использует форматтеры полей по умолчанию (byte[] в строку, datetime в ISO и т.п). Поэтому он должен выполнять IQuaryable из под себя.
В случае когда происходит трансформация результата правила меняются — нет никакой необходимости использовать DSL Includes для задания навигации (если нет переиспользования правила), используется другой интерпретатор, а конфигурация происходит по месту:
static CachedFormatter cachedFormatter1 = new CachedFormatter();
string GetJson()
{
using (var dbContext = GetEfCoreContext())
{
var json = dbContext.ParentRecords
// back to EF core includes
// but .Include(include1) also possible
.IncludeAll(e => e.Roles)
.ThenIncludeAll(e => e.Privileges)
.Select(e => new { FieldA: e.FieldA, FieldJson:"[1,2,3]", Role: e.Roles().First() })
.ToJson(cachedFormatter1,
chain => chain.Include(e => e.Role),
LeafRuleManager.DefaultEfCore,
config: rules => rules
.AddRule<string[]>(GetStringArrayFormatter)
.SubTree(
chain => chain.Include(e => e.FieldJson),
stringAsJsonLiteral: true) // json as is
.SubTree(
chain => chain.Include(e => e.Role),
subRules => subRules
.AddRule<DateTime>(
dateTimeFormat: "YYYMMDD",
floatingPointFormat: "N2"
)
),
),
useToString: false, // no default ToString for unknown leaf type (throw exception)
dateTimeFormat: "YYMMDD",
floatingPointFormat: "N2"
}
}
Понятно, все эти детали, все это "по умолчанию", можно держать в голове только если очень надо и/или если это твой собственный интерпертатор. С другой стороны еще раз возвращаемся к трейдофф: DTO не размазан по коду, задан конкретной функцией, интерпретаторы увниверсальны. Кода становится меньше — это уже хорошо.
Необходимо предупредить: хотя казалось бы в ASP и предварительное знание всегда в наличии, и потоковый сериалайзер не слишком нужная штука в мире веба, где даже базы данных отдают данные в json, но применение DSL Includes в ASP MVC история не самая простая. Как комбинировать функциональное программирование с ASP MVC — заслуживает отдельного исследования.
В этой статье я ограничусь тонкостями именно DSL Includes, буду показывать и новую функциональность, и утечку абстракций, для того чтобы показать что проблема анализа "трейдофф" вообще-то исчерпаема.
Еще больше DSL Includes
Include<Point> include = chain => chain.Include(e=>e.X).Include(e=>e.Y);
Это отличается от EF Сore Includes построенного на статических функциях, которые невозможно присваивать переменным и передавать в качестве параметров. Сам DSL Includes родился от потребности передавать "include" в мою реализацию шаблона Repository без деградации информации о типах которая бы появилась при стандартном переводе их в строки.
Самое кардинальное отличие все же в назначении. EF Core Includes — включение свойств навигации (узлов веток), DSL Includes — запись обхода дерева вычислений, присваивание имени (path) результату каждого вычисления.
Внутреннее представление EF Core Includes — список строк полученных MemberExpression.Member (Expression задаваемая e=>User.Name
может быть только [MemberExpression](https://msdn.microsoft.com/en-us/library/system.linq.expressions.memberexpression(v=vs.110).aspx а во внутренних представлениях сохраняется только строчка Name
).
В DSL Includes внутреннее представление — классы ChainNode и ChainMemberNode сохраняющее expression (e.g. e=>User.Name
)целиком, которое, может быть как есть встроено в Expression Tree. Именно из этого следует и то что DSL Includes поддерживает и поля и пользовательские value types и вызовы функции:
Исполнение функций :
Include<User> include = chain => chain
.Include(i => i.UserName)
.Include(i => i.Email.ToUpper(),"EAddress");
Что с этим делать зависит от интерпретатора. CreateFormatter- выдаст {"UserName":"John", "EAddress":"JOHN@MAIL.COM"}
Исполнение так же может быть полезным для задания обхода по nullable структурам
Include<StrangePointF> include
= chain => chain
.Include(e => e.NextPoint) // NextPoint is nullable struct
.ThenIncluding(e => e.Value.X)
.ThenInclude(e => e.Value.Y);
// but not this way (abstraction leak)
// Include<StrangePointF> include
// = chain => chain
// now this can throw an exception
// .Include(e => e.NextPoint.Value)
// .ThenIncluding(e => e.X)
// .ThenInclude(e => e.Y);
В DSL Includes так же существует короткая запись многоуровнего обхода ThenIncluding .
Include<User> include = chain => chain
.Include(i => i.UserName)
.IncludeAll(i => i.Groups)
// ING-form - doesn't change current node
.ThenIncluding(e => e.GroupName) // leaf
.ThenIncluding(e => e.GroupDescription) // leaf
.ThenInclude(e => e.AdGroup); // leaf
сравните с
Include<User> include = chain => chain
.Include(i => i.UserName)
.IncludeAll(i => i.Groups)
.ThenInclude(e => e.GroupName)
.IncludeAll(i => i.Groups)
.ThenInclude(e => e.GroupDescription)
.IncludeAll(i => i.Groups)
.ThenInclude(e => e.AdGroup);
И тут тоже есть утечка абстракции. Если я записал подобной формой навигацию, я должен знать как работает интерпетатор который будет вызывать QuaryableExtensions. А он переводит вызовы Include и ThenInclude в Include "строковый". Что может иметь значение (надо иметь ввиду).
Алгебра Include выражений.
Include-выражения можно:
var b1 = InlcudeExtensions.IsEqualTo(include1, include2);
var b2 = InlcudeExtensions.IsSubTreeOf(include1, include2);
var b3 = InlcudeExtensions.IsSuperTreeOf(include1, include2);
var include2 = InlcudeExtensions.Clone(include1);
var include3 = InlcudeExtensions.Merge(include1, include2);
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.
Сравнение только по листьям из 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);
Проход по узлам выражения. Копируются свойства подходящие под правило.
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
Проход по узлам-ветка выражения и идентификация по узлам-листьям. Копируются свойства подходящие под правило (схоже с 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 надо иметь ввиду:
- Невозможность копировать readonly свойства, причем это популярные типы Tuple<,> и Anonymous Type. Аналогичная проблема с клонированием, но несколько под другим углом.
- Абстрактный тип (напр. IEnumerable реализован приватным типом) — каким public типом его заменить.
- Все expression из include-выражений, которые не выражают свойства и поля — будут отброшены.
- "копирование в массив" не понятно что такое.
Автор DSL должен полагаться на то что такие неопределенные ситуации вытекающие из конфликта семантики и способа записи метаданных пользователь может предвидеть, т.е. предположит что они будут приводить к неопределенному результату и не будет рассчитывать на существующие интерпретаторы. Кстати, сериализация свойств анонимных и Tuple<,>
, т.е. типов c readonly свойствами, или копирование ValueTuple<,>
c writabale полями не являются неопределенными ситуациями (и реализованы как и можно было ожидать).
Хорошая новость, здесь в том что вообще написать свой интерпретатор (не претендуя на компиляцию Expression Trees) Includes выражений — достаточно просто. Вся алгебра работы с Include DSL уже реализована.
Возможно создание интерпретаторов Detach, FindDifferences и т.п.
Почему run-time, а не .cs сгенерированный до начала компиляции?
Наличие возможности сгенерировать .cs это лучше, чем отсутствие возможности, но у run-time есть свои преимущества:
- Избегаем затратной возни со сгенерированными исходниками (настройки каталогов, имен файлов, source control).
- Избегаем привязки к среде программирования, плагинам, перехватам событий, языкам скриптов — все что повышает порог вхождения.
- Избегаем проблемы "яйца и курицы". Кодогенерация dev time требует планирования очередности, иначе можно попасть в ситуацию: "А" не может скомпилироваться потому что "Б" еще не сгенерирован, а "Б" не может быть сгенерирован, потому, что "А" еще не скомпилирован.
Последнее решаемо Roslyn'ом, но и это решение приносит ограничения и увеличивает порог вхождения. Впрочем если нужны Typescript биндиги (я же DTO записал функцией, т.е. теперь это проблема) — надо вытаскивать DSL Includes выражения Roslyn'ом (сложная часть) — и писать интрерпретор их в typescript (простая часть). Тогда "за компанию" можно записать и "идеальный сериализатор" в .cs (а не в Expression Trees).
Подытожу: кодогенерация же run time — почти чистая кодогенерация, минимум инфраструктуры. Просто надо запомнить что следует избегать многократного пересоздания функций которые можно переиспользовать (и соглашаться на дикую по объему знаков запись Expression Trees).
Проблемы с эффективностью скомпилированных функций Expression Trees
При программировании Internal DSL при помощи Expression Tree надо иметь ввиду что:
LambdaExpression.Compile
компилирует только верхнюю Lambda. При этом выражение остается рабочим, но медленным. Компилировать надо каждую лямбда, по ходу "склейки" expression tree, передавая в CallExpression в качестве параметров — не LambdaExpression, а делегат (т.е откомпилированный LambdaExpression) завернутый в константу ConstantExpression.
Компиляция происходит в динамически создаваемый анонимный аssеmbly, и вызов методов проходит (в 10 наносекунд в моих тестах) проверку на безопасность (мои assembly не подписаны, возможно когда подписаны — будет дольше). Оно, конечно, не много, но если сильно дробить код — может накапливаться.
Можно попытаться сформулировать стратегию оптимизации, призванную учитывать эти и другие моменты кодогенерации (в анонимный ассембли), что я пока не могу, поскольку не имею исчерпывающего понимания всех деталей. Но есть практический выход: я остановился на достаточных для меня бенчмарках. И кстати — да — генерация в .cs все перечисленные проблемы бы сняла.
Бенчмарк сериализации
Данные — Объект содержащий массив из 600 записей на 15 полей простых типов. Потоковым JSON.NET, ServiceStack нужно два вызова reflection'а GetProperties().
dslComposeFormatter — ComposeFormatter на первом месте, остальные подробности здесь .
BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134
Intel Core i5-2500K CPU 3.30GHz (Sandy Bridge), 1 CPU, 4 logical and 4 physical cores
.NET Core SDK=2.1.300
Method | Mean | Error | StdDev | Min | Max | Median | Allocated |
---|---|---|---|---|---|---|---|
dslComposeFormatter | 2.208 ms | 0.0093 ms | 0.0078 ms | 2.193 ms | 2.220 ms | 2.211 ms | 849.47 KB |
JsonNet_Default | 2.902 ms | 0.0160 ms | 0.0150 ms | 2.883 ms | 2.934 ms | 2.899 ms | 658.63 KB |
JsonNet_NullIgnore | 2.944 ms | 0.0089 ms | 0.0079 ms | 2.932 ms | 2.960 ms | 2.942 ms | 564.97 KB |
JsonNet_DateFormatFF | 3.480 ms | 0.0121 ms | 0.0113 ms | 3.458 ms | 3.497 ms | 3.479 ms | 757.41 KB |
JsonNet_DateFormatSS | 3.880 ms | 0.0139 ms | 0.0130 ms | 3.854 ms | 3.899 ms | 3.877 ms | 785.53 KB |
ServiceStack_SerializeToString | 4.225 ms | 0.0120 ms | 0.0106 ms | 4.201 ms | 4.243 ms | 4.226 ms | 805.13 KB |
fake_expressionManuallyConstruted | 54.396 ms | 0.1758 ms | 0.1644 ms | 54.104 ms | 54.629 ms | 54.383 ms | 7401.58 KB |
fake_expressionManuallyConstruted — expression где только верхняя лямбда скомпилирована (цена ошибки).
Формализация
Кодогенерация и DSL связаны следующим образом: для создания эффективного DSL необходима кодогенерация в язык среды исполнения; для создания эффективного Internal DSL необходима кодогенерация run-time.
Следствием из закона "эффективности DSL" является то что Expression Tree — является инструментом, который мы используем только потому что это безальтернативный способ иметь кодогенерацию находясь в .NET Standard фреймворке.
С другой стороны использование Expression Trees для разбора выражений не является признаком выделяющим Internal DSL из всего класса fluent API. Таким признаком является использование грамматики С# для выражения отношений в проблемной области, а построение структур представления может идти путем простого исполнения fluent выражений кода (без разбора посредством Expression Trees, при этом наиболее характерным для Internal DSL в С# является комбинирование исполнения цепочек fluent, в каждой из которых есть "немножко" разбора посредством Expression Trees).
Expression Trees внутри DSL Includes играют роль весьма не большую (достать имена узлов, если они не указаны в ручную), и наоборот для создания эффективного интерпертатора/сериалайзера — решающую. При этом DSL Includes имеет гораздо большее значение для самого творческого процесса: созданные библиотечные функции- итераторы свойств serialize, copy, clone, equals являются производными по отношению к найденному способу записать процесс итерации и эффективно упростить запись "обхода". На это утверждение никак не влияет, что после перехода на новый, более высокий уровень абстракции — генерируется уже сам "Internal DSL", через создание его "структур представления", т.е. без записи fluent C#.
Когда теоретически можно решить, что стоит ограничится изобретением только "структур представления", практически, творческий процесс таким путем не идет. Удобная символьная запись необходима: алгебра includes гораздо более выразительна (а значит помогает мышлению) чем, те же операции записанные со структурами (хотя они конечно необходимы, по скольку эффективны).
Заключение
При помощи DSL Includes появилась возможность записать DTO наконец тем чем оно и является в значительном числе случаев — функцией сериализации (в json). Удалось выйти на новый уровень абстракции не потеряв, а приобретя в производительности, как в скорости вычислений, так и "меньше кода", но все же за счет увеличения прикладной сложности. Рост абстракции = рост утечек абстракции.
Ответом этой проблеме со стороны разработчика Internal DSL является обращение внимания пользователя на семантику операций, реализуемых интерпретаторами DSL, на необходимость знания структур представления Internal DSL (в каком виде сохраняются Expression) и на важность знания о внутреннем устройстве интерпретатора (используют или не используют компиляцию Expression Tree).
И DSL Includes и json сериализатор ComposeFormatter лежат в библиотеке DashboardCodes.Routines доступной через nuget и GitHub.
Комментарии (20)
mayorovp
10.08.2018 13:24LambdaExpression.Compile компилирует только верхнюю Lambda.
Это вообще как? А куда деваются остальные?
RomanPokrovskij Автор
10.08.2018 14:01не компилируются в IL (не создаются в динамическом assembly) т.е. исполняются в каком-то режиме интерпретации.
хорошая тема для нового исследования — запустить из windbg и посмотреть что происходит точно.mayorovp
10.08.2018 14:22Не верю…
RomanPokrovskij Автор
10.08.2018 14:40что режим интерпретации не возможен?
вот косвенное доказательтво: у LambdaExpression.Compile есть параметр preferInterpretation
mayorovp
10.08.2018 14:43Не верю что он автоматически используется для вложенных замыканий.
RomanPokrovskij Автор
11.08.2018 12:52-1А верите ли, что некомпилируемые вложенные LambdaExpression не создаются в динамическом ассембли по крайней мере отдельными динамическими функциями? А если каждую вложенную компилировать (для последующего заворачивания в константу) — то создаются и все начинает усорятся? Это как раз просто доказать (без поисков «неизвестного» в WinDbg — это весть день портатить надо).
RomanPokrovskij Автор
11.08.2018 19:44Возможно надо попасть в какую-то тютельку, подсовывая в параметры вызова то что надо, и компилировать внутренюю лямбду будут. Например если параметры «не дженерик» делегаты. Инетересно что FastCompile получив полное выражение без оптимизации «скомпилированная внутреннея лямбда» — тоже не смог большего чем простой Compile — хотя казалось бы- разобрал — скомпилировал — собрал — но есть какие-то сложности.
А разница по факту — ровно на порядок. Компилируешь внутренние лямбды — 2 ms, не компилируешь — 20 ms (и никакой FastExpressionCompiler, как я сказал — не исправляет — в случае моих выражений, по крайней мере)
gnaeus
А Вы не могли бы добавить в бенчмарк JSON-сериализации еще и библиотеку Jil, которая также строит сериализаторы с помощью генерации IL-кода в рантайме ?
По поводу быстрой компиляции Expression: есть такая библиотека FastExpressionCompiler. Она компилирует выражения в IL без создания динамической assembly.
RomanPokrovskij Автор
Спасибо, ради таких дополнений и писал статью.
RomanPokrovskij Автор
Померил Jil — Jil в два раза быстрее.
В статью добавлять пока не буду — потому что не понимаю трейдофф от компиляции через Reflection.Emit. Кроме того github.com/dotnet/corefx/issues/29365. Jil/Sigil на windows в Core запускается (монстры), но что дальше вопрос. И что-то там все равно не так — asterix ами накрыло ветку Dependencies Benchmark проекта в VS/SolExp после добавления Jil (хотя запускается). В общем надо подумать. А так — да — ставлю в очередь — сесть и написать интерпертатор DSL Includes компилирующий сериализатор через Reflection.Emit (точнее искать компилятор Expression Tree — Reflection Emit), чтобы всё понять.
Меня больше заинтриговал вопрос как они кэшируют скомпилированные функции? По типу? По месту вызова — рассматривая стек? Я на такое не решился.
RomanPokrovskij Автор
А, вот FastExpressionCompiler — и есть такой компилятор. Надо пробовать.
RomanPokrovskij Автор
Сериалайзер построенный с FastCompile — несущественно добавил к перформансу (0.2 ms), Jil не обогнал (еще 0.8 ms не хватило) а в целом jil у ComposeFormatter выигрывает 1ms. Все лямбды скомпилированы при помощи FastCompile. Рабочая гипотеза — Jil строит компилятор с минимум работы со стеком, тут и выигрыш.
компилятор выведен как параметр
```
var composeFormatterFastCompileDelegate = JsonManager.ComposeFormatter(includeWithLeafs, compile: (ex)=>ex.CompileFast());
```
вообще, надо брать windbg и смотреть код.
не буду обещать что сделаю, но было бы интересно.
RomanPokrovskij Автор
«Jil строит компилятор» читать как «Jil строит сериализатор»
gnaeus
На сколько я знаю, кешируют по типу при первой (де)сериализации объекта
force
Быстрее. Как пример, клонятор использует Reflection.Emit, когда возможно и заваливается на ExpressionTree, если не получается. Код по идее идентичен, но на экспрешшенах тупо медленнее. Или же где-то есть ошибка, или же особенность задачи приводит к разным операциям, но в целом голый il код получается производительнее.
PS: А вообще, Jil делает очень упоротые оптимизации в плане сериализации данных в строку, так что он ещё там может выигрывать.
RomanPokrovskij Автор
Да, трейдофф «быстрее» за ограниченность платформы (не Standard).
В прочем моей удачей было бы не перегнать клонятор или Jil по скорости, а им о Include DSL рассказать. И убедить поддерживать.
nomoreload
Там степень оптимизаций настолько упорота, что даже порядок свойств определённый.
RomanPokrovskij Автор
Это то ладно, но Jil меня больше удивил тем, что нет и не надо четкого разделения между потоковым сериализатором и компилирующим функцию. И надо было и мне (покрайней мере для статьи) смело идти путем кэширования по ключу: тип + [CallerMemberName] + [CallerName]. Статья вообще построена на противопоставлении потоковых клонеров, сериалайзерах — компилирующим. А оказалось компилирующие просто имитируют потоковость — и противопоставление преодолено. Опять же DSL Include — удобная запись метаданных и может быть использована везде где нужно задать дерево обхода (вычислений) с проименованием узлов, но статья должна строится немного по другому.
RomanPokrovskij Автор
Я сверил сериализацию jil c просто кодом записанным `.Append(«I1:»).Append(e.I1).Append(",")` и т.п. 12 атрибутов, массив 600 элеменов. Jil победил но с разницей в 30 **нано**секуд. Т.е. если сгенерировать по мете сериализатор в .cs и избавится от имитации потоковости а вызывать сериализатор в контроллере — эффективность jil будет фактически повторена. Это не считая тех случаев когда «тонкой сериализацией» получается убрать DTO — в таком случае и сейчас ComposeFormatter — эффективней. А идеал лежит в другой плоскости заменить пару Select + ToList одним ToJson(cache, IQueryable, IModel). Правда Include DSL тут уже не будет (`IQueryable` не`Include`).