[DSL] Domain Specific Languages – Un ejemplo

Intro

Como ya sabemos, un DSL es un lenguaje de programación que se construye para atacar una familia de problemas que se presentan habitualmente en un dominio particular. Aunque su denominación actual (DSL) es relativamente nueva, estos han sido utilizados desde siempre, en particular los que Martin Fowler llama “external DSL” los cuales muchas veces los utilizamos dentro de otro lenguaje huésped. Por ejemplo, es muy común encontrar SQL, XPath, RegEx, Xml entre otros en nuestro código C#.

new SqlCommand("DELETE FROM Customers", connectionString).ExecuteNonQuery();
element.SelectNodes("/books/book[quantity = 0]/text()");
new Regex("b[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,4}b").IsMatch("lontivero@geeks.ms");
XDocument.Parse("<document><Element>Solo un texto.<Element></document>");

Hace ya casi un año que trabajo desarrollando un conjunto de aplicaciones de seguridad pública las cuales presentan ciertos patrones muy comunes y repetitivos que por cierto, también son comunes en otras aplicaciones, sobre todo en las documentales. En estas soluciones de Record management, es muy común que se deban mostrar u ocultar ciertos controles según se cumplan o no un conjunto de condiciones establecidas por configuración. Lo mismo suele suceder con controles que deben ser obligatorios, solo lectura, habilitados o no, etc.

El dominio

En mi caso, que es en el dominio de la seguridad pública, esto es sumamente común, casi cotidiano. La cosa es que el product owner envía estos requerimientos con un formato similar al siguiente:

contact information must not be visible if arrestee is juvenile. Email, PhoneNumber and Address are contact information. arrestee is juvenile if age < age of majority.

En realidad nos llega un documento con algunas decenas de estas especificaciones, muchas veces son así de sencillas mientras que otras veces la implementación de la lógica de la interface de usuario es una tortura. El código que implementa esto muchas veces huele tan pero tan mal, debido al copiar y pegar malos fragmentos de código, que alguien creó una herramienta que, wizard mediante, permite hacer esto por configuración para los casos más sencillos pero que evidentemente, no nos libra de tener que codificar los restantes casos y testearlos una y otra vez.

La idea

¿Cual es la solución? La solución ideal sería crear un compilador capaz de entender inglés pero por suerte esto parece muy lejano, de lo contrario muchos estaríamos sin trabajo, o no? Pero veamos las sentencias anteriores.

arrestee_is_juvenile when age < [age of majority].
Email, PhoneNumber and Address are contactinformation.
contactinformation must not be visible if arrestee_is_ juvenile.

Estas, si bien parecen estar en inglés, son fácilmente analizables por un parser mientras que permanecen fáciles de escribir, leer y entender. Este es un natural, english-like, textual y declarativo lenguaje de especificaciones. La dificultad de construir un lenguaje como este radica en que requiere un gran trabajo de semántica  para no tener que usar estructuras rígidas ni signos de puntación que, si bien facilitaran el análisis sintáctico, vuelven al lenguaje menos natural. En este lenguaje necesitamos que personas no técnicas puedan contar con varias maneras de escribir la lógica de la inteface utilizando la estructura que consideren más conveniente. Por ejemplo, podemos escribir exactamente la misma lógica de las sentencias anteriores en una sola línea a la vez que facilitamos la escritura en su forma mas natural. Por ejemplo:

Email, PhoneNumber and Address should be hidden unless age > [age of majority].

o

Email, PhoneNumber and Address must not be visible if  age < [age of majority].

Obviamente este lenguaje no sería muy útil si las condiciones no pudieran ser mucho más complejas. Por ejemplo, son bastante comunes los requerimientos del tipo:

 Make, Model and VIN should be visible and required  if propertyType = ‘Car’  and (OffenseType != ‘’XX’ or Nationality=’YY’).

El proyecto

En este momento estoy escribiendo una versión pre-alpha de este lenguaje y su interprete como prueba de concepto para mostrarlo y ver que tan factible es de incorporar a la familia de productos en las que trabajo. Introducir algo como esto no es algo demasiado fácil.

Coco/R

Para construir el parser para este lenguaje utilicé Coco/R. Principalmente porque es el más sencillo que conozco, aunque Antlr es en mi opinión bastante superior. El que sea sencillo y amigable para cualquier desarrollador C# pensé que me facilitaría la comunicación y la implementación en un proyecto verdaderamente importante.

La sintaxis

Esta es la definición de la sintaxis en EBNF. Como pude apreciarse es verdaderamente muy sencilla (al menos hasta ahora).

EasyBLS := { Statementes }
Statement := ( DefaultValue | Conditions | Binding | ControlSer ) dot
DefaultValue := ( by default | initially [comma] ) IdentifierList ShouldBe Value
ControlSet := identifier ( is | { comma identifier } and identifier are ) identifier
IdentifierList := identifier[ { comma identifier} and identifier
Binding := IdentifierList ShouldBe ActionList(if |unless ) Expression [ identifier «otherwise» ]
ShouldBe := ( should | must ) [ IF(canHasNot)[ not ]] be
Condition := identifier when Expression
Expression := LogicExpression { LogicOp LogicExpression }
LogicExpression := ( identifier [ ( CompareOp Value | is in ValueList ) ] | «(» Expression «)» | not Expression )
Value := ( string | number | tag )
ValueList := «(» Value { «,» Value } «)»
LogicOp := ( «and» | «or» )
CompareOp := ( «=» | «<» | «>» | «!=» | «<=» | «=>» | «like» )
ActionList := Action [ { comma Action } and Action ]
Action := ( visible | hidden | enabled | disabled | readonly | required )

El AST (Abstract syntax tree)

Siempre me gustó la manera en que SableCC genera el código tanto del parser como del AST automáticamente infiriéndolo de las mismas reglas que definen la sintaxis del lenguaje. Lo único que no me agrada es que genera un tipo de nodo por cada regla y muchas veces no es lo que queremos/necesitamos.

Yo quería utilizar el patrón visitor, el cual además de ser muy simple y conocido ya he explicado varias veces. El problema que tiene este patrón es que genera muchísimas dependencias, es decir, si yo quiero modificar un par de nodos, crear algunos otros y/o eliminar/renombrar alguno ya existente, tengo que modificar tanto las interface IVisitor como todas las clases que la implementan. Además, es siempre lo mismo, todos los nodos se parecen ya que no tienen casi lógica propia ya que está toda en los visitantes (esa es la idea).

Lo que hice fue primero crear una herramienta que me facilitara el mantenimiento de los nodos y los visitantes como así también una vuelta de rosca al patrón para relajar un poco las dependencias. La herramienta de la que hablo no es otra que otros micro lenguaje, muchísimo menos interesante el cual a partir de un pequeño archivito de texto conteniendo la definición de los nodos, crea toda la estructura, los nodos y la jerarquía de visitantes.

Actualmente la definición del AST está en el archivo Tree.ast y que puede verse abajo:

ScriptNode : Node;> Statements:Node*;
StatementNode : Node;
ControlSetNode : Node;> Name:string, Controls:IdentifierListNode;
IdentifierListNode : Node;> Ids:StringValueNode*;
ConditionNode : Node;> Name:string, Expression:Node;
BindingNode : Node;> Target:ControlSetNode, IfValue:ActionListNode, Condition:Node, ElseValue:string;
InitializationNode : Node;>Target:ControlSetNode, Value:Node;
ExprNode : Node;> Left:Node, Right:Node, Operator:LogicalOp;
LogicalExprNode : Node;> Left:Node, Right:Node, Operator:ComparationOp;
PathNode : Node;> Value:string;
StringValueNode : Node;> Value:string;
TagNode : Node;> Value:string;
NumericValueNode : Node;> Value:double;
ValueListNode : Node;> Value:Node*;
NotNode : Node;> Expression:Node;
BindingNameNode : Node;> Name:string;
ActionListNode : Node;> Actions:StringValueNode*;

Luego de correr el ASTGen.exe obtenemos más de 20 clases con una buena cantidad de código. Por ejemplo, veamos como queda el nodo LogicalExprNode:
 
   1: using System;

   2: using System.Diagnostics;

   3: using System.Collections.Generic;

   4: using Temosoft.Presentation;

   5: using Temosoft.Presentation.Language;

   6: using Temosoft.Presentation.Language.EasyEBL.Analysis;

   7:  

   8: namespace Temosoft.Presentation.Language.EasyEBL.Nodes

   9: {

  10:     ///<summary>

  11:     ///The LogicalExprNode node.

  12:     ///</summary>

  13:     public class LogicalExprNode : Node

  14:     {

  15:         #region Public Properties

  16:         

  17:         [DebuggerBrowsable (DebuggerBrowsableState.Never)] 

  18:         private Node _Left;

  19:         

  20:         public Node Left 

  21:         { 

  22:             [DebuggerNonUserCode]

  23:             get

  24:             {

  25:                 return _Left;

  26:             }

  27:             

  28:             [DebuggerNonUserCode]

  29:         set

  30:             {

  31:                 _Left = value;

  32:                 _Left.Parent = this;

  33:             } 

  34:         } 

  35:         [DebuggerBrowsable (DebuggerBrowsableState.Never)] 

  36:         private Node _Right;

  37:         

  38:         public Node Right 

  39:         { 

  40:             [DebuggerNonUserCode]

  41:             get

  42:             {

  43:                 return _Right;

  44:             }

  45:             

  46:             [DebuggerNonUserCode]

  47:         set

  48:             {

  49:                 _Right = value;

  50:                 _Right.Parent = this;

  51:             } 

  52:         } 

  53:         [DebuggerBrowsable (DebuggerBrowsableState.Never)] 

  54:         private ComparationOp _Operator;

  55:         

  56:         public ComparationOp Operator 

  57:         { 

  58:             [DebuggerNonUserCode]

  59:             get

  60:             {

  61:                 return _Operator;

  62:             }

  63:             

  64:             [DebuggerNonUserCode]

  65:         set

  66:             {

  67:                 _Operator = value;

  68:             } 

  69:         } 

  70:                 

  71:         #endregion

  72:         #region Public Constructors

  73:         

  74:         [DebuggerNonUserCode]

  75:         public LogicalExprNode() : base()

  76:         {

  77:              

  78:              

  79:              

  80:         }

  81:         

  82:         [DebuggerNonUserCode]

  83:         public LogicalExprNode(Node _Left, Node _Right, ComparationOp _Operator) : base()

  84:         {

  85:             this.Left = _Left; 

  86:             this.Right = _Right; 

  87:             this.Operator = _Operator; 

  88:         }

  89:         

  90:         #endregion

  91:         

  92:         #region Visitor Methods

  93:         

  94:         public override void Traverse(AbstractVisitor visitor)

  95:         {

  96:             if (visitor == null)

  97:             {

  98:                 throw new ArgumentNullException("visitor");

  99:             }

 100:  

 101:             visitor.Perform(this);

 102:         }

 103:         

 104:         #endregion

 105:  

 106:         #region Overrided Methods

 107:         

 108:         public override string ToString()

 109:         {

 110:             return string.Empty;

 111:             

 112:             /*

 113:             return "LogicalExprNode node: {" +

 114:             "Left: " + typeof(Node).Name + " {" + ((Left != null) ? Left.ToString() : string.Empty) + " } " + 

 115:             "Right: " + typeof(Node).Name + " {" + ((Right != null) ? Right.ToString() : string.Empty) + " } " + 

 116:             "Operator: " + typeof(ComparationOp).Name + " {" + ((Operator.GetType().IsValueType) ? Operator.ToString() : ((Operator != null) ? Operator.ToString() : string.Empty))  + " } " + 

 117:             "}";

 118:             */

 119:         }

 120:         

 121:         #endregion

 122:     }

 123: }

Este es solo un nodo y para agregarlo solo se necesitó agregar una línea al archivo Tree.ast . 1 contra 123 en este caso habría que agregar las que le corresponden en las clases AbstractVisitor, BaseVisitor y DepthFirstAnalysis. Todo el código autogenerado, a excepción del que construye Coco/R, se basa en plantillas usando StringTemplate (altamente recomendable) por lo que modificar todos los nodos para agregarles una característica es tan simple como modificar la plantilla y correr el generador nuevamente.

Por cierto, si 1 a 123+ les parece grosero, no imaginan a cuantas líneas de código se necesitan para implementar lo que en EasyBLS se especifica con una simple línea como la siguiente:
Make, Model and VIN should be visible and required  if propertyType = ‘Car’  and (OffenseType != ‘’XX’ or Nationality=’YY’).

Cuantas pasadas

Por ahora solo 2, una para recolectar info acerca de los controles que se mencionan en los scripts y mapearlos con los controles reales y otra pasada para realmente interpretar o generar el código. Falta una de optimización para eliminar condiciones superfluas como las dobles negaciones y otras duplicidades. La gracia de tener mas de una pasada es que podemos hacer referencia a identificadores que no se han declarado aún, esto permite hacer cosas como esta:

contact_information must not be visible if arrestee is juvenile.
Email, PhoneNumber and Address are contact_information. arrestee is juvenile if age < age of majority.

Declaro contact_information después de haberlo utilizado en una sentencia.

Algunos miedos

Construir un lenguaje y su compilador, o interprete, es bastante sencillo, todos tenemos una idea de como se hacen y hasta se ve en los primeros años de la facultad. Lo que si es difícil es convencer a algunos de los beneficios que este puede traer.

Lo primero es el miedo, hay que atacar el miedo de la gente a lo desconocido. Este no es totalmente infundado ya que existen sobrados motivos para confiar más en un lenguaje liberado por Microsoft que en uno creado por Juancito Pirulo Mendieta. El miedo es a no entender el código del compilador y por lo tanto no poder mantenerlo porque a diferencia de un lenguaje de propósito general, un DSL debe poder mantenerse fácilmente. Si, para algunos puede sonar un tanto extraño pero sí, tenemos que mantener nuestro lenguaje y debemos poder extenderlo fácilmente. Y lamentablemente no creo que un compilador soporte tan estoicamente las miles de líneas de código basura que soportan la mayoría de las aplicaciones (puedo equivocarme).

La compatibilidad hacia atrás en la sintaxis y comportamiento de sus sentencias es delicada y debe meditarse muy bien. Otro miedo es el de probar nuestra propia medicina. Por todo esto, el compilador debe mantenerse lo mas sencillo posible

Interprete o generador de código

Esta discusión es enorme. Como es sabido, podemos generar algún tipo de código a partir de las sentencias del nuevo lenguaje o bien interpretarlas. Si queremos poder embeber nuestro lenguaje en otro, lo que necesitamos es un intérprete (si si, con un generador también se puede pero no es tan natural). Por ejemplo:

new EasyBlsCommand(@“
Make, Model and VIN should be visible and required
if propertyType = ‘Car’ and (OffenseType != ‘’XX’ or Nationality=’YY’).”, wnd, tags
).Execute();

Lo cierto es que generar código es igualmente sencillo así que nada cuesta implementar el generador también.

Lucas Ontivero