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 функцією.

Серіалізація повинна конфігуруватися:

  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 — сенс наступних виразів повинен вловлювати відразу ж (це така підмножина 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 треба мати на увазі:

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

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

Читайте також  Епізод 0. Hack vs Mac. Xcode build time

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

Можливе створення інтерпретаторів Detach, FindDifferences і т. п.

Чому run-time, а не .cs згенерований до початку компіляції?

Наявність можливості згенерувати .cs це краще, ніж відсутність можливості, але в run-time є свої переваги:

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

Останнє можна вирішити Roslyn’ом, але і це рішення приносить обмеження і новий поріг входження. Втім, якщо потрібні Typescript биндиги (я ж DTO записав функцією, тобто тепер це проблема) — треба витягувати вираження Roslyn’му — і писати интрерпретор DSL Includes в typescript. Тоді “за компанію” можна записати і “ідеальний сериализатор”.cs (а не в Expression Trees).

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

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

При програмуванні Internal DSL за допомогою Expression Tree треба мати на увазі що:

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

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

Можна спробувати сформулювати стратегія оптимізації, покликану враховувати ці та інші моменти кодогенерации (анонімний ассмбли), що я поки не можу, оскільки не маю вичерпного розуміння всіх деталей. Але є практичний вихід: я зупинився на достатніх для мене бенчмарках. І до речі — так — генерація .cs всі перераховані проблеми б зняла.

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

Дані — Об’єкт містить масив з 600 записів на 15 полів простих типів. Потоковим JSON.NET, ServiceStack потрібно два виклику reflection’а GetProperties().

dslComposeFormatter — ComposeFormatter на першому місці, інші подробиці тут .

BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134
Intel Core i5-2500K CPU 3.30 GHz (Sandy Bridge), CPU 1, 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.

Степан Лютий

Обожнюю технології в сучасному світі. Хоча частенько і замислююся над тим, як далеко вони нас заведуть. Не те, щоб я прям і знаюся на ядрах, пікселях, коллайдерах і інших парсеках. Просто приходжу в захват від того, що може в творчому пориві вигадати людський розум.

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *