paint-brush
Cómo desarrollar un DSL en Kotlinpor@yaf
2,105 lecturas
2,105 lecturas

Cómo desarrollar un DSL en Kotlin

por Fedor Yaremenko11m2023/12/11
Read on Terminal Reader

Demasiado Largo; Para Leer

Los lenguajes de dominio específico son más fáciles, convenientes y expresivos para describir las tareas del área temática. Kotlin es un lenguaje de programación moderno con muchas características y azúcar sintáctica, por lo que es excelente como lenguaje anfitrión para DSL interno. El artículo describe cómo utilizar las diversas funciones de Kotlin para crear un DSL para definir procesos comerciales.
featured image - Cómo desarrollar un DSL en Kotlin
Fedor Yaremenko HackerNoon profile picture


Los programadores discuten constantemente sobre qué lenguaje es el mejor. Una vez comparamos C y Pascal, pero pasó el tiempo. Las batallas de Python / Ruby y Java /C# ya quedaron atrás. Cada idioma tiene sus pros y sus contras, por eso los comparamos. Lo ideal sería ampliar los idiomas para adaptarlos a nuestras propias necesidades. Los programadores han tenido esta oportunidad durante mucho tiempo. Conocemos diferentes formas de metaprogramar, es decir, crear programas para crear programas. Incluso las macros triviales en C le permiten generar grandes fragmentos de código a partir de pequeñas descripciones. Sin embargo, estas macros son poco fiables, limitadas y poco expresivas. Las lenguas modernas tienen medios de extensión mucho más expresivos. Uno de estos lenguajes es Kotlin.


La definición de un lenguaje de dominio específico.

Un lenguaje de dominio específico (DSL) es un lenguaje desarrollado específicamente para un área temática específica, a diferencia de los lenguajes de propósito general como Java, C#, C++ y otros. Esto significa que es más fácil, más conveniente y más expresivo describir las tareas del área temática, pero al mismo tiempo es inconveniente y poco práctico para resolver tareas cotidianas, es decir, no es un lenguaje universal. Como ejemplo de DSL, puede utilizar el lenguaje de expresión regular. El área temática de las expresiones regulares es el formato de las cadenas.


Para comprobar que la cadena cumple con el formato, basta con utilizar una biblioteca que implemente soporte para expresiones regulares:

 private boolean isIdentifierOrInteger(String s) { return s.matches("^\\s*(\\w+\\d*|\\d+)$"); }


Si verifica que la cadena cumpla con el formato especificado en un lenguaje universal, por ejemplo, Java, obtendrá el siguiente código:

 private boolean isIdentifierOrInteger(String s) { int index = 0; while (index < s.length() && isSpaceChar(s.charAt(index))) { index++; } if (index == s.length()) { return false; } if (isLetter(s.charAt(index))) { index++; while (index < s.length() && isLetter(s.charAt(index))) index++; while (index < s.length() && isDigit(s.charAt(index))) index++; } else if (Character.isDigit(s.charAt(index))) { while (index < s.length() && isDigit(s.charAt(index))) index++; } return index == s.length(); }


El código anterior es más difícil de leer que las expresiones regulares, es más fácil cometer errores y más complicado realizar cambios.


Otros ejemplos comunes de DSL son HTML, CSS, SQL, UML y BPMN (los dos últimos usan notación gráfica). Los DSL no solo los utilizan los desarrolladores, sino también los evaluadores y los especialistas no informáticos.


Tipos de ADSL

Los DSL se dividen en dos tipos: externos e internos. Los lenguajes DSL externos tienen su propia sintaxis y no dependen del lenguaje de programación universal en el que se implementa su soporte.


Pros y contras de los DSL externos:

🟢 Generación de código en diferentes idiomas / bibliotecas listas para usar

🟢 Más opciones para configurar su sintaxis

🔴 Uso de herramientas especializadas: ANTLR, yacc, lex

🔴 A veces es difícil describir la gramática.

🔴 No hay soporte para IDE, necesitas escribir tus complementos


Los DSL internos se basan en un lenguaje de programación universal específico (lenguaje anfitrión). Es decir, con la ayuda de herramientas estándar del idioma anfitrión, se crean bibliotecas que permiten escribir de forma más compacta. Como ejemplo, considere el enfoque de Fluent API.


Pros y contras de los DSL internos:

🟢 Utiliza las expresiones del idioma anfitrión como base.

🟢 Es fácil incorporar DSL en el código en los idiomas anfitriones y viceversa

🟢 No requiere generación de código

🟢 Se puede depurar como una subrutina en el idioma anfitrión

🔴 Posibilidades limitadas a la hora de configurar la sintaxis.


Un ejemplo de la vida real

Recientemente, en la empresa nos enfrentamos a la necesidad de crear nuestro DSL. Nuestro producto tiene implementada la funcionalidad de aceptación de compra. Este módulo es un minimotor de BPM (Business Process Management). Los procesos de negocio suelen representarse gráficamente. Por ejemplo, la notación BPMN a continuación muestra un proceso que consiste en ejecutar la Tarea 1 y luego ejecutar la Tarea 2 y la Tarea 3 en paralelo.


Para nosotros era importante poder crear procesos de negocio mediante programación, incluida la construcción dinámica de una ruta, la configuración de los ejecutantes para las etapas de aprobación, el establecimiento de la fecha límite para la ejecución de la etapa, etc. Para hacer esto, primero intentamos resolver este problema usando la API Fluent. acercarse.


Luego llegamos a la conclusión de que establecer rutas de aceptación utilizando Fluent API todavía resulta engorroso y nuestro equipo consideró la opción de crear su propio DSL. Investigamos cómo se vería la ruta de aceptación en un DSL externo y un DSL interno basado en Kotlin (porque nuestro código de producto está escrito en Java y Kotlin ).


ADSL externo:

 acceptance addStep executor: HEAD_OF_DEPARTMENT duration: 7 days protocol should be formed parallel addStep executor: FINANCE_DEPARTMENT or CTO or CEO condition: ${!request.isInternal} duration: 7 work days after start date addStep executor: CTO dueDate: 2022-12-08 08:00 PST can change addStep executor: SECRETARY protocol should be signed


ADSL interno:

 acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe formed } parallel { addStep { executor = FINANCE_DEPARTMENT or CTO or CEO condition = !request.isInternal duration = startDate() + workDays(7) } addStep { executor = CTO dueDate = "2022-12-08 08:00" timezone PST +canChange } } addStep { executor = SECRETARY protocol shouldBe signed } }


A excepción de las llaves, ambas opciones son casi iguales. Por lo tanto, se decidió no perder tiempo y esfuerzo en desarrollar un DSL externo, sino crear un DSL interno.


Implementación de la estructura básica del DSL.

Comencemos a desarrollar un modelo de objetos.

 interface AcceptanceElement class StepContext : AcceptanceElement { lateinit var executor: ExecutorCondition var duration: Duration? = null var dueDate: ZonedDateTime? = null val protocol = Protocol() var condition = true var canChange = ChangePermission() } class AcceptanceContext : AcceptanceElement { val elements = mutableListOf<AcceptanceElement>() fun addStep(init: StepContext.() -> Unit) { elements += StepContext().apply(init) } fun parallel(init: AcceptanceContext.() -> Unit) { elements += AcceptanceContext().apply(init) } } object acceptance { operator fun invoke(init: AcceptanceContext.() -> Unit): AcceptanceContext { val acceptanceContext = AcceptanceContext() acceptanceContext.init() return acceptanceContext } }


lambdas

Primero, veamos la clase AcceptanceContext . Está diseñado para almacenar una colección de elementos de ruta y se utiliza para representar el diagrama completo, así como bloques parallel .

Los métodos addStep y parallel toman una lambda con un receptor como parámetro.


Una lambda con un receptor es una forma de definir una expresión lambda que tiene acceso a un objeto receptor específico. Dentro del cuerpo del literal de función, el objeto receptor pasado a una llamada se convierte en un this implícito, de modo que puede acceder a los miembros de ese objeto receptor sin ningún calificador adicional, o acceder al objeto receptor usando una expresión this .


Además, si el último argumento de una llamada a un método es lambda, entonces lambda se puede colocar fuera del paréntesis. Es por eso que en nuestro DSL podemos escribir un código como el siguiente:

 parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } }


Esto equivale a un código sin azúcar sintáctico:

 parallel({ this.addStep({ this.executor = FINANCE_DEPARTMENT ... }) this.addStep({ this.executor = CTO ... }) })


Lambdas con receptores y Lambda fuera del paréntesis son características de Kotlin que son particularmente útiles cuando se trabaja con DSL.


Declaración de objeto

Ahora veamos la acceptance de la entidad. acceptance es un objeto. En Kotlin, una declaración de objeto es una forma de definir un singleton, una clase con una sola instancia. Entonces, la declaración de objeto define tanto la clase como su instancia única al mismo tiempo.


"invocar" la sobrecarga del operador

Además, el operador invoke está sobrecargado para el objeto accreditation . El operador invoke es una función especial que puedes definir en tus clases. Cuando invocas una instancia de una clase como si fuera una función, se llama a la función del operador invoke . Esto le permite tratar objetos como funciones y llamarlos de manera similar a una función.


Tenga en cuenta que el parámetro del método invoke también es una lambda con un receptor. Ahora podemos definir una ruta de aceptación…

 val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT ... } parallel { addStep { executor = FINANCE_DEPARTMENT ... } addStep { executor = CTO ... } } addStep { executor = SECRETARY ... } }


...y recorrerlo

 val headOfDepartmentStep = acceptanceRoute.elements[0] as StepContext val parallelBlock = acceptanceRoute.elements[1] as AcceptanceContext val ctoStep = parallelBlock.elements[1] as StepContext


Agregar detalles

Funciones infijas

Echa un vistazo a este código

 addStep { executor = FINANCE_DEPARTMENT or CTO or CEO ... }


Podemos implementar esto de la siguiente manera:

 enum class ExecutorConditionType { EQUALS, OR } data class ExecutorCondition( private val name: String, private val values: Set<ExecutorCondition>, private val type: ExecutorConditionType, ) { infix fun or(another: ExecutorCondition) = ExecutorCondition("or", setOf(this, another), ExecutorConditionType.OR) } val HEAD_OF_DEPARTMENT = ExecutorCondition("HEAD_OF_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val FINANCE_DEPARTMENT = ExecutorCondition("FINANCE_DEPARTMENT", setOf(), ExecutorConditionType.EQUALS) val CHIEF = ExecutorCondition("CHIEF", setOf(), ExecutorConditionType.EQUALS) val CTO = ExecutorCondition("CTO", setOf(), ExecutorConditionType.EQUALS) val SECRETARY = ExecutorCondition("SECRETARY", setOf(), ExecutorConditionType.EQUALS)


La clase ExecutorCondition nos permite configurar varios posibles ejecutores de tareas. En ExecutorCondition definimos la función infija or . Una función infija es un tipo especial de función que le permite llamarla usando una notación infija más natural.


Sin utilizar esta característica del lenguaje, tendríamos que escribir así:

 addStep { executor = FINANCE_DEPARTMENT.or(CTO).or(CEO) ... }


Las funciones infijas también se utilizan para establecer el estado requerido del protocolo y la hora con una zona horaria.

 enum class ProtocolState { formed, signed } class Protocol { var state: ProtocolState? = null infix fun shouldBe(state: ProtocolState) { this.state = state } } enum class TimeZone { ... PST, ... } infix fun String.timezone(tz: TimeZone): ZonedDateTime { val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z") return ZonedDateTime.parse("$this $tz", format) }


Funciones de extensión

String.timezone es una función de extensión. En Kotlin, las funciones de extensión le permiten agregar nuevas funciones a clases existentes sin modificar su código fuente. Esta característica es particularmente útil cuando desea aumentar la funcionalidad de clases sobre las que no tiene control, como clases de bibliotecas estándar o externas.


Uso en el DSL:

 addStep { ... protocol shouldBe formed dueDate = "2022-12-08 08:00" timezone PST ... }


Aquí "2022-12-08 08:00" es un objeto receptor, en el que se llama a la función de extensión timezone , y PST es el parámetro. Se accede al objeto receptor mediante this palabra clave.

Sobrecarga del operador

La siguiente característica de Kotlin que utilizamos en nuestro DSL es la sobrecarga de operadores. Ya hemos considerado la sobrecarga del operador invoke . En Kotlin, puedes sobrecargar otros operadores, incluidos los aritméticos.

 addStep { ... +canChange }


Aquí el operador unario + está sobrecargado. A continuación se muestra el código que implementa esto:

 class StepContext : AcceptanceElement { ... var canChange = ChangePermission() } data class ChangePermission( var canChange: Boolean = true, ) { operator fun unaryPlus() { canChange = true } operator fun unaryMinus() { canChange = false } }


Toque final

Ahora podemos describir rutas de aceptación en nuestro DSL. Sin embargo, los usuarios de DSL deben estar protegidos de posibles errores. Por ejemplo, en la versión actual, el siguiente código es aceptable:

 val acceptanceRoute = acceptance { addStep { executor = HEAD_OF_DEPARTMENT duration = days(7) protocol shouldBe signed addStep { executor = FINANCE_DEPARTMENT } } }


addStep dentro de addStep parece extraño, ¿no? Averigüemos por qué este código se compila correctamente sin errores. Como se mencionó anteriormente, los métodos acceptance#invoke y AcceptanceContext#addStep toman una lambda con un receptor como parámetro, y se puede acceder a un objeto receptor mediante this palabra clave. Entonces podemos reescribir el código anterior así:

 val acceptanceRoute = acceptance { [email protected] { [email protected] = HEAD_OF_DEPARTMENT [email protected] = days(7) [email protected] shouldBe signed [email protected] { executor = FINANCE_DEPARTMENT } } }


Ahora puede ver que se llama [email protected] en ambas ocasiones. Especialmente para estos casos, Kotlin tiene una anotación DslMarker . Puede utilizar @DslMarker para definir anotaciones personalizadas. No se puede acceder a los receptores marcados con la misma anotación uno dentro del otro.

 @DslMarker annotation class AcceptanceDslMarker @AcceptanceDslMarker class AcceptanceContext : AcceptanceElement { ... } @AcceptanceDslMarker class StepContext : AcceptanceElement { ... }


ahora el codigo

 val acceptanceRoute = acceptance { addStep { ... addStep { ... } } }


no se compilará debido a un error 'fun addStep(init: StepContext.() -> Unit): Unit' can't be called in this context by implicit receiver. Use the explicit one if necessary

Enlaces

A continuación se muestran enlaces a la documentación oficial de Kotlin sobre las características del lenguaje que se consideraron en este artículo:



Conclusión

Los lenguajes de dominio específico ofrecen un medio poderoso para mejorar la productividad, reducir errores y mejorar la colaboración al proporcionar una forma especializada y expresiva de modelar y resolver problemas dentro de un dominio específico. Kotlin es un lenguaje de programación moderno con muchas características y azúcar sintáctica, por lo que es excelente como lenguaje anfitrión para DSL interno.