
Este artículo se centra en el uso dual de la API de árboles de expresión : para analizar expresiones y generar código. El análisis de expresiones ayuda a construir estructuras de presentación (también son estructuras de presentación del lenguaje interno orientado a problemas DSL interno ), y la generación de código le permite crear dinámicamente funciones efectivas: conjuntos de instrucciones especificadas por estructuras de presentación.
Demostraré la creación dinámica de iteradores de propiedades: serializar, copiar, clonar, iguales . Usando serializar como ejemplo, le mostraré cómo optimizar la serialización (en comparación con los serializadores de transmisión) en la situación clásica en la que se utiliza el conocimiento "preliminar" para mejorar el rendimiento. La idea es que llamar al serializador de transmisión siempre perderá la función de "no transmisión", sabiendo exactamente qué nodos de árbol deben moverse. Al mismo tiempo, dicho serializador se crea "no a mano" sino de forma dinámica, sino de acuerdo con las reglas de omisión predefinidas. El DSL Inernal propuesto resuelve el problema de una descripción compacta de las reglas para atravesar las estructuras de árbol de los objetos por sus propiedades / propiedades (y en el caso general: atravesar el árbol de cómputo con el nombre de los nodos) . El punto de referencia del serializador es modesto, pero es importante porque agrega al enfoque construido alrededor del uso de un DSL interno específico incluye (un dialecto de Incluir / EntoncesIncluir de EF Core ) y el uso del DSL interno en su conjunto, la persuasión necesaria.
Introduccion
Compara:
var p = new Point(){X=-1,Y=1};
El segundo método es obviamente más rápido (los nodos son conocidos y están "abarrotados de código"), mientras que el método es, por supuesto, más complicado. Pero cuando obtiene este código como una función (generada y compilada dinámicamente), la complejidad está oculta (incluso lo que no está claro está oculto
dónde está la reflexión y dónde está el tiempo de ejecución de generación de código).
var p = new Point(){X=-1,Y=1};
Aquí JsonManager.ComposeFormatter
es la herramienta real . La regla por la cual se genera la omisión de estructura durante la serialización no es obvia, pero suena así "con los parámetros predeterminados, para los tipos de valores personalizados rodean todos los campos del primer nivel". Si lo configura explícitamente:
Esta es la descripción de los metadatos a través de DSL Incluye. DSL ha iluminado el análisis de los pros y los contras de describir metadatos, pero ahora ignorando la forma de grabar metadatos, enfatizo que C # proporciona la capacidad de compilar y compilar el "serializador ideal" usando Expression Trees.
Cómo lo hace: una gran cantidad de código y una guía de generación de código de Expression Trees ...transición del formatter
al serilizer
(hasta ahora sin árboles de expresión):
Func<StringBuilder, Point, bool> serializer = ...
A su vez, el serializer
se construye así (si está configurado con código estático):
Expression<Func<StringBuilder, Point, bool>> serializerExpression = SerializeAssociativeArray(sb, p, (sb1, t1) => SerializeValueProperty(sb1, t1, "X", o => oX, SerializeValueToString), (sb4, t4) => SerializeValueProperty(sb1, t1, "Y", o => oY, SerializeValueToString) ); Func<StringBuilder, Point, bool> serializer = serializerExpression.Compile();
¿Por qué es tan "funcional", por qué no puedes serializar dos campos a través de un punto y coma? En resumen: porque esta expresión se puede asignar a una variable del tipo Expression<Func<StringBuilder, Box, bool>>
, pero no se permite el punto y coma.
¿Por qué no podría escribir directamente Func<StringBuilder, Point, bool> serializer = (sb,p)=>SerializeAssociativeArray(sb,p,...
? Es posible, pero no estoy demostrando la creación de un delegado, sino un ensamblado (en este caso, código estático) árbol de expresión, con una compilación para el delegado en el futuro, en uso práctico, serializerExpression
se establecerá de una manera completamente diferente, dinámicamente (a continuación).
Pero lo que es importante en la solución en sí: SerializeAssociativeArray
acepta una matriz de params Func<..> propertySerializers
acuerdo con el número de nodos que se omitirán. Eludir algunos de ellos puede establecerlo SerializeValueProperty serializadores de hojas (aceptando el formateador SerializeValueToString
), y otros nuevamente por SerializeAssociativeArray
(es decir, ramas), y así se construye un iterador (árbol) del recorrido.
Si Point contenía la propiedad NextPoint:
var @delegate = SerializeAssociativeArray(sb, p, (sb1, t1) => SerializeValueProperty(sb1, t1, "X", o => oX, SerializeValueToString), (sb4, t4) => SerializeValueProperty(sb1, t1, "Y", o => oY, SerializeValueToString), (sb4, t4) => SerializeValueProperty(sb1, t1, "NextPoint", o => o.NextPoint, (sb4, t4) =>SerializeAssociativeArray(sb1, p1, (sb1, t1) => SerializeValueProperty(sb2, t2, "X", o => oX, SerializeValueToString), (sb4, t4) => SerializeValueProperty(sb2, t2, "Y", o => oY, SerializeValueToString) ) ) );
El dispositivo de las tres funciones SerializeAssociativeArray
, SerializeValueProperty
, SerializeValueToString
no SerializeValueToString
complicado:
Serializar ... 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; }
Aquí no se dan muchos detalles (soporte de lista, tipo de referencia y anulable). Y sin embargo, está claro que realmente obtengo json en la salida, y todo lo demás es aún más que las funciones estándar SerializeArray
, SerializeNullable
, SerializeRef
.
Era un árbol de expresión estático, no dinámico, no evaluado en C # .
Puede ver cómo Expression Tree se construye dinámicamente en dos pasos:
Paso 1: el descompilador observa el código asignado por Expression<T>

Por supuesto, esto te sorprenderá la primera vez. Nada está claro, pero puedes ver cómo las primeras cuatro líneas forman algo como:
("sb","t") .. SerializeAssociativeArray..
Luego se captura la conexión con el código fuente. Y debe quedar claro que si domina un registro de este tipo (combinando 'Expression.Const', 'Expression.Parameter', 'Expression.Call', 'Expression.Lambda', etc. ) realmente puede componer dinámicamente, cualquier bypass de nodos (basado en metadatos). Esto es eval en C # .
El mismo código de descompilador, pero compilado por man.
Solo el autor del intérprete está obligado a participar en este bordado de cuentas. Todas estas artes permanecen dentro de la biblioteca de serialización . Es importante conocer la idea de que puede proporcionar bibliotecas que generen dinámicamente funciones compiladas eficientes en C # (y .NET Standard).
Sin embargo, un serializador de transmisión superará una función generada dinámicamente si llama a la compilación cada vez antes de la serialización (la compilación dentro de ComposeFormatter
es una operación costosa), pero puede guardar el enlace y reutilizarlo:
static Func<Point, string> formatter = JsonManager.ComposeFormatter<Point>(); public string Get(Point p){
Si necesita crear y guardar un serializador de tipo anónimo para su reutilización, necesita infraestructura adicional:
static CachedFormatter cachedFormatter = new CachedFormatter(); public string Get(List<Point> list){
Después de eso, tenemos en cuenta con confianza la primera microoptimización para nosotros y acumulamos, acumulamos, acumulamos ... Quién es el chiste, quién no, pero antes de pasar a la pregunta de que el nuevo serializador es nuevo, soluciono la ventaja obvia: será más rápido.
¿Qué a cambio?
El DSL incluye intérprete en serilize (y de la misma manera es posible en iteradores equals, copy, clone, y esto también se tratará) requirió los siguientes costos:
1 - costos de la infraestructura para almacenar enlaces a código compilado.
Estos costos generalmente no son necesarios, como lo es el uso de Expression Trees con compilación: el intérprete puede crear un serializador en reflejos e incluso lamerlo tanto que se acercará a la velocidad en términos de serializadores de flujo (por cierto, copie, clone y los iguales no se recopilan a través de árboles de expresión, ni se lamen, no existe tal tarea, a diferencia de superar a ServiceStack y Json.NET en el marco de la tarea bien entendida de optimizar la serialización en json, una condición necesaria para presentar una nueva solución).
2 : debe tener en cuenta las fugas de abstracciones y un problema similar: cambios en la semántica en comparación con las soluciones existentes.
Por ejemplo, Point e IEnumerable necesitan dos serializadores diferentes para serializar.
var formatter1 = JsonManager.ComposeFormatter<Point>(); var formatter2 = JsonManager.ComposeEnumerableFormatter<Point>();
O: "¿funciona el cierre?". Funciona, solo el nodo necesita establecer un nombre (único):
string DATEFORMAT= "YYYY"; var formatter3 = JsonManager.ComposeFormatter<Record>( chain => chain .Include(i => i.RecordId) .Include(i => i.CreatedAt.ToString(DATEFORMAT) , "CreatedAt"); );
Este comportamiento lo dicta el dispositivo interno del intérprete ComposeFormatter
.
Los costos de este tipo son el mal inevitable. Además, se descubre que al expandir la funcionalidad y expandir el alcance del DSL interno, también aumentan las fugas de abstracción. Ciertamente oprimirá al desarrollador de DSL interno, aquí debe abastecerse de un estado de ánimo filosófico.
Para el usuario, las fugas de abstracción se superan mediante el conocimiento de los detalles técnicos de DSL interno ( ¿qué esperar? ) Y la riqueza de la funcionalidad de un DSL particular y sus intérpretes ( ¿qué a cambio? ). Por lo tanto, la respuesta a la pregunta: "¿vale la pena crear y usar DSL interno?", Solo puede haber una historia sobre la funcionalidad de un DSL particular, sobre todos sus detalles y comodidades, y sus posibles aplicaciones (intérpretes), es decir. Una historia sobre la superación de los costos.
Con todo esto en mente, vuelvo a la efectividad de un DSL en particular.
Se logra una eficiencia significativamente mayor cuando el objetivo es reemplazar el triple (DTO, transformar en DTO, serializar DTO) con una función de serialización generada y detallada localmente. Al final, el objeto de función dualismo le permite decir "DTO es una función" y establecer un objetivo: aprender cómo establecer una función DTO.
La serialización debe configurarse:
- Desvíe el árbol (para describir los nodos a través de los cuales tendrá lugar la serialización, por cierto, esto resuelve el problema de los enlaces circulares), en el caso de las hojas, asigne un formateador (por tipo).
- La regla para incluir hojas (si no se especifican): ¿propiedad frente a campos? solo lectura?
- Para poder especificar tanto una rama (un nodo con navegación) como una hoja no es solo MemberExpression (
e=>e.Name
), sino generalmente cualquier función (`e => e.Name.ToUpper ()," MyMemberName ") - establece el formateador en un formato específico nodo.
Otras opciones para aumentar la flexibilidad:
- serializar una hoja que contiene una cadena json "tal cual" (formateador de cadena especial);
- establecer formateadores en grupos, es decir ramas enteras, en esta rama como esta, en otra de una manera diferente (por ejemplo, fechas aquí con tiempo y en esta sin tiempo).
En todas partes, las construcciones involucradas: omiten el árbol, la rama, la hoja y todo esto se puede escribir usando DSL Incluye.
DSL incluye
Como todos están familiarizados con EF Core, el significado de las siguientes expresiones debe capturarse de inmediato (este es un subconjunto de xpath).
Aquí están los nodos "con navegación" - "ramas".
La respuesta a la pregunta de qué nodos "hojas" (campos / propiedades) se incluyen en el árbol así definido: ninguno. Para incluir hojas, debe enumerarlas explícitamente:
Include<User> include2 = chain=> chain .Include(e => e.UserName)
O agregue dinámicamente por la regla, a través de un intérprete especializado:
Aquí la regla es una regla que puede seleccionarse mediante ChainNode. por tipo de expresión devuelta por el nodo (ChainNode - representación interna de DSL incluye, que se discutirá más adelante) propiedades (MemberInfo) para participar en la serialización, p. solo la propiedad, o solo la propiedad de lectura / escritura, o solo aquellas para las que hay un formateador, puede seleccionar de una lista de tipos, e incluso la expresión de inclusión en sí misma puede establecer una regla (si enumera nodos de hoja, es decir, la forma de unión de árbol) .
O ... déjelo a discreción del intérprete de usuario, quien decide qué hacer con los nodos. DSL incluye es solo un registro de metadatos; la forma de interpretar este registro depende del intérprete. Puede interpretar los metadatos como quiera, hasta ignorarlos. Algunos intérpretes realizarán la acción ellos mismos, mientras que otros construirán una función lista para realizarlos (a través de Expression Tree, o incluso Reflection.Emit). Un buen DSL interno está diseñado para uso universal y la existencia de muchos intérpretes, cada uno de los cuales tiene sus propios detalles, sus propias filtraciones de abstracción.
El código que usa DSL interno puede ser muy diferente de lo que era antes.
Fuera de la caja
Integración con EF Core.
La tarea en ejecución es "cortar los enlaces cíclicos", para iniciar solo lo que se especifica en la expresión de inclusión:
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
acepta la secuencia de navegación, cuando se serializa, la usa (selecciona las hojas según la regla "predeterminada para EF Core", es decir, la propiedad de lectura / escritura pública), está interesado en el modelo, donde string / json usa formateadores de campo para insertar como está por defecto (byte [] por cadena, fecha y hora en ISO, etc.). Por lo tanto, debe realizar IQuardable por debajo de sí mismo.
En el caso de que el resultado se transforme, las reglas cambian: no hay necesidad de usar DSL Incluye para especificar la navegación (si no hay reutilización de la regla), se usa otro intérprete y la configuración se realiza localmente:
static CachedFormatter cachedFormatter1 = new CachedFormatter(); string GetJson() { using (var dbContext = GetEfCoreContext()) { var json = dbContext.ParentRecords
Está claro que todos estos detalles, todo esto es "por defecto", pueden tenerse en cuenta solo si realmente lo necesita y / o si este es su propio intérprete. Por otro lado, volvemos nuevamente a las ventajas: el DTO no está manchado por el código, está especificado por una función específica, los intérpretes son universales. El código se está volviendo más pequeño, eso es bueno.
Es necesario advertir : aunque parece que el conocimiento preliminar siempre está disponible en ASP, y un serializador de transmisión no es algo muy necesario en el mundo de la web, donde incluso las bases de datos transmiten datos en json, pero el uso de DSL incluye en ASP MVC no es la historia más fácil . Cómo combinar la programación funcional con ASP MVC merece un estudio aparte.
En este artículo me limitaré a las complejidades de DSL Incluye, mostraré tanto la nueva funcionalidad como la fuga de abstracciones para mostrar que el problema del análisis de "costos y adquisiciones" es realmente agotable.
Más DSL incluye
Include<Point> include = chain => chain.Include(e=>eX).Include(e=>eY);
Esto difiere de EF Core Incluye funciones estáticas integradas que no pueden asignarse a variables y pasarse como parámetros. DSL incluye a sí mismo nació de la necesidad de pasar "incluir" en mi implementación de la plantilla de repositorio sin degradar la información de tipo que habría aparecido cuando se estandarizaron en cadenas.
La diferencia más dramática aún está en la cita. EF Core incluye - inclusión de propiedades de navegación (nodos de ramas), DSL incluye - registro de recorrido de un árbol de cálculos, asignando un nombre (ruta) al resultado de cada cálculo.
La representación interna de EF Core incluye es una lista de cadenas recibidas por MemberExpression.Member (La expresión especificada por e=>User.Name
solo puede ser [MemberExpression] ( https://msdn.microsoft.com/en-us/library/system.linq.expressions. memberexpression (v = vs. 110) .aspx y en vistas internas solo se guarda la línea de Name
).
En DSL Incluye, la representación interna son las clases ChainNode y ChainMemberNode que guardan la expresión completa (por ejemplo, e=>User.Name
), que, tal como está, está integrada en el Árbol de expresiones. Es precisamente a partir de esto que DSL incluye admite campos y tipos de valores de usuario y llamadas a funciones:
Ejecución de funciones:
Include<User> include = chain => chain .Include(i => i.UserName) .Include(i => i.Email.ToUpper(),"EAddress");
Qué hacer con esto depende del intérprete. CreateFormatter- devolverá {"UserName": "John", "EAddress": "JOHN@MAIL.COM"}
La ejecución también puede ser útil para establecer un recorrido sobre estructuras anulables.
Include<StrangePointF> include = chain => chain .Include(e => e.NextPoint)
DSL incluye también tiene una entrada corta para la solución de varios niveles ThenIncluding.
Include<User> include = chain => chain .Include(i => i.UserName) .IncludeAll(i => i.Groups)
comparar con
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);
Y aquí también hay una fuga de abstracción. Si escribiera la navegación en este formulario, sabría cómo funciona un intérprete que llamará QuaryableExtensions. Y traduce las llamadas a Incluir y luego a Incluir para incluir "cadena". Qué puede importar (debes tenerlo en cuenta).
El álgebra incluye expresiones .
Incluir expresiones puede ser:
Comparar var b1 = InlcudeExtensions.IsEqualTo(include1, include2); var b2 = InlcudeExtensions.IsSubTreeOf(include1, include2); var b3 = InlcudeExtensions.IsSuperTreeOf(include1, include2);
Clon var include2 = InlcudeExtensions.Clone(include1);
Fusionar var include3 = InlcudeExtensions.Merge(include1, include2);
Convertir a listas XPath - Todos los caminos a las hojas IReadOnlyCollection<string> paths1 = InlcudeExtensions.ListLeafXPaths(include);
etc.
La buena noticia es: no hay fugas de abstracciones, el nivel de abstracción pura se alcanza aquí. Hay metadatos y trabajo con metadatos.
Dialéctica
DSL incluye le permite alcanzar un nuevo nivel de abstracción, pero en el momento del logro, se forma una necesidad de pasar al siguiente nivel: generar expresiones de inclusión ellos mismos.
En este caso, no es necesario generar DSL como una cadena fluida, solo necesita crear estructuras de representación internas.
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);
También puede pasar estructuras de presentación a intérpretes. ¿Por qué, entonces, incluye el registro DSL fluido? Esta es una pregunta puramente especulativa, la respuesta a la cual: porque en la práctica, para desarrollar una representación interna (y también se desarrolla) solo se obtiene con el desarrollo de DSL (es decir, un breve registro expresivo conveniente para el código estático). Una vez más, esto se dirá más cerca de la conclusión.
Copiar, clonar, igual
Todo lo anterior es cierto para los intérpretes de inclusión de expresiones que implementan copia , clonación , iteradores iguales .
Igual aComparación solo en hojas de la expresión Incluir.
Problema semántico oculto: evaluar o no ordenar en la lista
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);
ClonPase a través de los nodos de expresión. Se copian las propiedades que coinciden con la regla.
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);
Puede haber un intérprete que seleccionará la hoja de incluye. ¿Por qué se hace a través de una regla separada? Lo que era similar a la semántica de ObjectExtensions.Copy
CopiaPase a través de nodos: una rama de expresión e identificación por nodos hoja. Se copian las propiedades que coinciden con la regla (similar a Clonar).
Include<User> include = chain=>chain.IncludeAll(e=>e.Groups); ObjectExtensions.Copy(user1, user2, include, supportedLeafsRule); ObjectExtensions.CopyAll(userList1, userList2, include, supportedLeafsRule);
Puede haber un intérprete que seleccionará la hoja de incluye. ¿Por qué se hace a través de una regla separada? 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. , " /" — , Expression Trees.
ssmbly , ( 10 ) ( assembly , — ). , , , — .
, ( ), , . : . — — .cs .
— 600 15 . JSON.NET, ServiceStack reflection' GetProperties().
dslComposeFormatter — ComposeFormatter , .
BenchmarkDotNet =v0.10.14, OS=Windows 10.0.17134
Intel Core i5-2500K CPU 3.30GHz (Sandy Bridge), 1 CPU, 4 logical and 4 physical cores
.NET Core SDK=2.1.300
Method | Mean | Error | StdDev | Min | Max | Median | Allocated |
---|
dslComposeFormatter | 2.208 ms | 0.0093 ms | 0.0078 ms | 2.193 ms | 2.220 ms | 2.211 ms | 849.47 KB |
JsonNet_Default | 2.902 ms | 0.0160 ms | 0.0150 ms | 2.883 ms | 2.934 ms | 2.899 ms | 658.63 KB |
JsonNet_NullIgnore | 2.944 ms | 0.0089 ms | 0.0079 ms | 2.932 ms | 2.960 ms | 2.942 ms | 564.97 KB |
JsonNet_DateFormatFF | 3.480 ms | 0.0121 ms | 0.0113 ms | 3.458 ms | 3.497 ms | 3.479 ms | 757.41 KB |
JsonNet_DateFormatSS | 3.880 ms | 0.0139 ms | 0.0130 ms | 3.854 ms | 3.899 ms | 3.877 ms | 785.53 KB |
ServiceStack_SerializeToString | 4.225 ms | 0.0120 ms | 0.0106 ms | 4.201 ms | 4.243 ms | 4.226 ms | 805.13 KB |
fake_expressionManuallyConstruted | 54.396 ms | 0.1758 ms | 0.1644 ms | 54.104 ms | 54.629 ms | 54.383 ms | 7401.58 KB |
fake_expressionManuallyConstruted — expression ( ).
DSL : DSL ; Internal DSL run-time .
Expression Tree .NET Standard .
Expression Trees Internal DSL Fluent API. # .
fluent ( Expression Trees), Internal DSL # fluent, "" Expression Trees.
Expression Trees DSL Includes ( , ), / run-time — (run-time ).
Internal DSL : - serialize , copy , clone , equals "" . , " ", . : includes ( ) , ( , ).
Conclusión
DSL Includes DTO — ( json). , , , " ", . = .
Internal DSL , DSL, Internal DSL ( Expression) ( Expression Tree).
DSL Includes json ComposeFormatter DashboardCodes.Routines nuget GitHub.