Internal DSL & Expression Trees — динамічне створення функцій serialize, copy, clone, equals (Частина I)
Стаття присвячена подвійному застосуванню API Expression Trees — для розбору виразів і для генерації коду. Розбір виразів допомагає побудувати структури представлення (вони ж структури подання проблемно-орієнтованої мови Internal DSL), а кодогенерация дозволяє динамічно створювати ефективні функції — набори інструкцій задаються структурами подання.
Демонструвати буду динамічне створення ітераторів властивостей: serialize, copy, clone, equals. На прикладі serialize покажу як можна оптимізувати серіалізацію (порівняно з потоковими сериализаторами) у класичній ситуації, коли “попереднє” знання використовується для поліпшення продуктивності. Ідея в тому, що виклик потокового сериалайзера завжди програє “непотоковой” функції точно знає які вузли дерева треба обійти, при цьому виписаної “не руками” а динамічно, за правилами. Inernal DSL вирішує завдання компактного завдання правила обходу дерева властивостей (обчислень) . Бенчмарк сериализатора скромний, але він важливий тим, що додає підходу, побудованого навколо застосування конкретного Internal DSL Includes (діалект того Include/ThenInclude що з EF Core) та застосування Internal DSL в цілому, необхідної переконливості.
Введення
Порівняйте:
var p = new Point(){X=-1,Y=1};
// which has better performance ?
var json1 = JsonConvert.SerializeObject(p);
var json2 = $"{{"X":{p.X}, "Y":{p.Y}}}";
Другий спосіб — очевидно швидше (вузли відомі і “забиті в код”), при цьому спосіб звичайно ж складніше. Але коли ви отримаєте цей код як функцію (динамічно згенеровану і скомпилированную) — складність приховується (скривається навіть те що стає не зрозуміло
де рефлексія, а де кодогенерация рантайм).
var p = new Point(){X=-1,Y=1};
// which has better performance ?
var json1 = JsonConvert.SerializeObject(p);
var formatter = JsonManager.ComposeFormatter<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) де 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 — сходити за посиланням,
Той же код декомпилера, але складений людиною.
Втягуватись в це вишивання бісером обов’язково тільки автору інтерпретатора. Всі ці витівки залишаються всередині бібліотеки серіалізації. Важливо засвоїти ідею, що можна надавати бібліотеки динамічно генеруючі скомпільовані ефективні функції в С#.
Однак, потоковий сериалайзер буде обганяти динамічно згенеровану функцію якщо компіляцію викликати кожен раз перед серіалізацією (компіляція знаходиться всередині 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>>();
або чому працює такий код?
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 функцією.
Серіалізація повинна конфігуруватися:
- Деревом обходу (описати вузли за якими буде проходити серіалізація, до речі, це вирішує проблему циркулярних посилань), у разі листя — присвоїти форматтер (за типом).
- Правилом включення листя (якщо вони не задані) — property vs fields? readonly?
- Мати можливість задати як гілку (вузол з навігацією) так і лист не просто MemberExpression (
e=>e.Name
), а взагалі будь-функцією (`e=>e.Name.ToUpper(), “MyMemberName”) .
Інші можливості службовці захопленню гнучкості:
- сериализации лист містить стрку json “as is” (спеціальний форматтер рядків);
- задавати форматтеры одного і теж типу, різними у різних гілках (наприклад, дати з часом, і без часу) — від (3) відрізняється груповим завданням.
Скрізь в описі брали участь такі конструкції як: дерево обходу, гілка, аркуш, і все це може бути записано використовуючи DSL Includes.
DSL Includes
Оскільки всі знайомі з EF Core — сенс наступних виразів повинен вловлювати відразу ж (це така підмножина 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 тобто вузла обходу (внутрішнє подання DSL Includes, ще буде сказано) властивості (MemberInfo) для участі в серіалізації, напр. тільки property, або лише read/write property, або тільки ті, для яких є форматер, можна відбирати за списком типів, і навіть саме include вираз може задавати правило (якщо в ньому перераховані вузли-листя — тобто форма об’єднання дерев).
… Залишити на розсуд користувача итерпретатору, який сам вирішує, що робити з вузлами. DSL Includes це просто запис метаданих — як інтерпретувати цю запис залежить від интерпертатора. Він може інтерпретувати метадані як йому хочеться, аж до ігнорування. Хороший Internal DSL розрахований на універсальне використання та існування різних интерпертаторов, кожен з яких має свої деталі реалізації.
Одні интерпертаторы будуть самі виконувати дію, інші будувати функцію готову їх виконувати (через Expression Tree). Код з використанням Internal DSL буде сильно відрізнятися від того, що було до нього.
Out of the box
Інтеграція з EF Core.
Ходова завдання “відрубати циклічні посилання”, в серіалізацію пускати тільки те що задано в include-вираженні:
static CachedFormatter cachedFormatter1 = new CachedFormatter();
string GetJson()
{
using (var dbContext = GetEfCoreContext())
{
string json =
EfCoreExtensions.ToJsonEf<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 Соге 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
// .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-вирази:
Порівнювати
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. Чому зроблено — через окреме правило? Що було схоже з семантикою 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 треба мати на увазі:
- Неможливість копіювати readonly властивості, причому це популярні типи Tuple<> Anonymous Type. Аналогічна проблема з клонуванням, але трохи під іншим кутом.
- Абстрактний тип (напр. IEnumerable реалізований приватним типом) — яким public типом його замінити.
- Всі expression з include-виразів, які не виражають властивості і поля — будуть відкинуті.
- “копіювання в масив” не зрозуміло що таке.
Автор DSL повинен покладатися на те, що такі невизначені ситуації випливають з конфлікту семантики і способу запису метаданих користувач може передбачати, тобто припустить що вони будуть приводити до невизначеного результату і не буде розраховувати на існуючі інтерпретатори. До речі, сериализация властивостей анонімних типів, копіювання або ValueTuple<,> не є невизначеною ситуацією (і реалізовані як і можна було очікувати).
Хороша новина, тут в тому що взагалі написати свій інтерпретатор (не претендуючи на компіляцію Expression Trees) Includes виразів — досить просто. Вся алгебра роботи з Include DSL вже реалізована.
Можливе створення інтерпретаторів Detach, FindDifferences і т. п.
Чому run-time, а не .cs згенерований до початку компіляції?
Наявність можливості згенерувати .cs це краще, ніж відсутність можливості, але в run-time є свої переваги:
- Уникаємо витратною метушні з згенерованими джерела (установки каталогів, файлів, source control).
- Уникаємо прив’язки до середовища програмування, плагінів, перехоплення подій, мов скриптів — все це підвищує поріг входження.
- Уникаємо проблеми “яйця і курки”. Кодогенерация dev time вимагає планування черговості, інакше можна потрапити в ситуацію: “А” не може скомпилироваться бо “Б” ще не створений, а “Б” не може бути згенерований, тому, що “А” ще не скомпільовано.
Останнє можна вирішити Roslyn’ом, але і це рішення приносить обмеження і новий поріг входження. Втім, якщо потрібні Typescript биндиги (я ж DTO записав функцією, тобто тепер це проблема) — треба витягувати вираження Roslyn’му — і писати интрерпретор DSL Includes в typescript. Тоді “за компанію” можна записати і “ідеальний сериализатор”.cs (а не в Expression Trees).
Підсумую: кодогенерация ж run time — майже чистий кодогенерация, мінімум інфраструктури. Просто треба запам’ятати що слід уникати багаторазового перетворення функцій які можна переиспользовать.
Проблеми з ефективністю скомпільованих функцій Expression Trees
При програмуванні Internal DSL за допомогою Expression Tree треба мати на увазі що:
-
LambdaExpression.Compile
компілює тільки верхню Lambda. При цьому вираз залишається робочим, але повільним. Компілювати треба кожну лямбда, по ходу “склеювання” expression tree, передаючи функцій приймаючим функції як параметри — делегат (откомпилированная лямбда) загорнутий в константуExpression.Constant
. -
Компіляція відбувається динамічно створюваний анонімний аssеmbly, і виклик методів відбувається (у 10 наносекунд в моїх тестах) перевірку на безпеку. Воно звичайно не багато, але якщо сильно дрібнити код — може накопичуватися.
Можна спробувати сформулювати стратегія оптимізації, покликану враховувати ці та інші моменти кодогенерации (анонімний ассмбли), що я поки не можу, оскільки не маю вичерпного розуміння всіх деталей. Але є практичний вихід: я зупинився на достатніх для мене бенчмарках. І до речі — так — генерація .cs всі перераховані проблеми б зняла.
Бенчмарк серіалізації
Дані — Об’єкт містить масив з 600 записів на 15 полів простих типів. Потоковим JSON.NET, ServiceStack потрібно два виклику reflection’а GetProperties().
dslComposeFormatter — ComposeFormatter на першому місці, інші подробиці тут .
BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134
Intel Core i5-2500K CPU 3.30 GHz (Sandy Bridge), CPU 1, 4 logical and 4 physical cores
.NET Core SDK=2.1.300
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.