En la primera parte de esta serie, discutimos uno de los desafÃos más difÃciles que enfrentamos al desarrollar software para un conjunto de herramientas de orquestación dentro del Telescopio de Treinta Metros: desarrollar un lenguaje especÃfico del dominio que pudiera manejar la complejidad necesaria del sistema sin imponérsela a los cientÃficos que realmente usarÃan la herramienta.
Ìý
Examinamos nuestro intento de desarrollar un DSL utilizando Scala y por qué nos encontramos con algunos problemas. En esta publicación, explicaremos cómo lo resolvimos con la ayuda de Kotlin.
Ìý
El segundo intento: simplificar las cosas con Kotlin
Ìý
HabÃamos escuchado que Kotlin era un lenguaje diseñado para DSL integrados. TenÃa receptores implÃcitos, declaraciones en la parte superior de los scripts (.kts), marcadores DSL y un enfoque sÃncrono predeterminado (espera) para la programación utilizando funciones de suspensión y corutinas.
Ìý
Las corutinas son hilos livianos con funciones de suspensión; son mecanismos para la programación asÃncrona en Kotlin. Kotlin te permite crear muchas corutinas al tiempo que hace que los programas parezcan sÃncronos con la ayuda de funciones de suspensión, pero se ejecutan de manera asÃncrona en los hilos de JVM. Esto se puede describir simplemente como escribir sincrónico, ejecutar asÃncrono.
Ìý
Kotlin simplificó los scripts eliminando la necesidad de declaraciones async y await, sin perder los beneficios de la programación asÃncrona. También permitió a los usuarios codificar explÃcitamente el comportamiento asÃncrono cuando fuera necesario.
Ìý
Migramos nuestro DSL porque quedamos impresionados por Kotlin. Pero migramos solo lo suficiente para asegurarnos de que nuestro DSL funcionara mientras aún usábamos el motor de scripts que ya habÃamos implementado en Scala.
Ìý
El script en el ejemplo anterior se tradujo al siguiente ejemplo al usar DSL implementado en Kotlin:
// TcsSync.kts
Ìý
script {
ÌýÌýÌýval motorAssembly = Assembly(TCS, "motor1", 5.seconds)
ÌýÌýÌýval timeKey = taiTimeKey("time")
Ìý
ÌýÌýÌýonSetup("observationPrep") { command ->
ÌýÌýÌýÌýÌýÌýÌýval executeAt = command(timeKey).head()
Ìý
ÌýÌýÌýÌýÌýÌýÌýscheduleOnce(executeAt) {
ÌýÌýÌýÌýÌýÌýÌýÌýÌýÌýÌýval moveCommand = Setup(prefix, "move30Degrees", command.obsId)
ÌýÌýÌýÌýÌýÌýÌýÌýÌýÌýÌýmotorAssembly.submit(moveCommand)
ÌýÌýÌýÌýÌýÌýÌý}
ÌýÌýÌý}
}
El DSL utilizó las corutinas y funciones de suspensión de Kotlin para realizar operaciones asÃncronas. Utilizamos un ámbito de corutina con un ejecutor de un solo hilo y soporte DSL para asegurar que todas las corutinas en el script se ejecutaran en el mismo ámbito de corutina. Este enfoque simplificó las operaciones concurrentes y eliminó problemas como condiciones de carrera y corrupción del estado debido a la programación paralela.
Ìý
En el ejemplo a continuación, cada lÃnea es una llamada asÃncrona, pero durante la ejecución, cada llamada se completará antes de ejecutar la siguiente lÃnea, dándole semántica sÃncrona.
motorAssembly1.submit(moveCommand)
publish(currentState())
motorAssembly2.submit(moveCommand)
publish(currentState())
Haciendo que Scala y Kotlin trabajen juntos
Ìý
Elegimos Kotlin con Scala para implementar el DSL porque ambos lenguajes se ejecutan en la plataforma JVM, que ofrece interoperabilidad total con Java. Usamos Java como puente entre Kotlin y Scala.
Ìý
TenÃamos un rasgo CommandHandler (Interfaz de Java) en Scala, donde todos los tipos expuestos eran tipos de Java, por ejemplo, CompletionStage y Void. CommandHandler encapsula la lógica para ejecutar un comando especÃfico:
trait CommandHandler {
ÌýÌýÌýdef handler(command: SetupCommand): CompletionStage[Void]
}
Para ejecutar estos controladores de comandos en Scala, convertimos los tipos de Java en tipos de Scala. Entonces, CompletionFuture se asignó a Future, utilizando funciones proporcionadas por la biblioteca estándar de Scala asScala y Void se asignó al tipo unit como se muestra en el método handleSetupCommand a continuación:
class Script {
ÌýÌýÌývar setupHandler: CommandHandler = _
Ìý
ÌýÌýÌýdef addSetupHandler(handler: CommandHandler): Unit ={
ÌýÌýÌýÌýÌýÌýÌýsetupHandler = handler
ÌýÌýÌý}
ÌýÌýÌýdef handleSetupCommand(command: SetupCommand): Future[Unit] {
ÌýÌýÌýÌýÌýÌýÌýsetupHandler.handler(command).asScala.map(_ => ())
ÌýÌýÌý}
}
En el lado de Kotlin, estos tipos de Java se convirtieron en tipos de Kotlin. Ampliando el ejemplo anterior, el Deferred de Kotlin se asignó a CompletableFuture de Java utilizando utilidades de Kotlin y se pasó al método addSetupHandler:
//DslSupport.kt
class DslSupport {
ÌýÌýÌýval script = Script()
Ìý
ÌýÌýÌýfun addSetupHandler(handler suspend Setup-> Void) = {
ÌýÌýÌýÌýÌýÌýÌýscript.addSetupHandler(CommandHandler { command ->
ÌýÌýÌýÌýÌýÌýÌýÌýÌýÌýÌýval deferred: Deferred<Void> = CoroutineScope.async {handler(command)}
ÌýÌýÌýÌýÌýÌýÌýÌýÌýÌýÌýdeffered.asCompletableFuture()
ÌýÌýÌýÌýÌýÌýÌý})
ÌýÌýÌý}
}
Entonces, la interfaz de Scala que expone el tipo de Java se instanció en Kotlin y se devolvió a Scala.
Ìý
Detalles de implementación
Ìý
El diagrama representa una vista simplificada de nuestro árbol de dependencias. El módulo superior tiene el rasgo CommandHandler y la clase Script declarada en Scala.


El módulo de soporte DSL (a la izquierda) instancia la interfaz en Kotlin para crear el DSL.
El módulo Execution Engine & App (a la derecha) es una implementación del Execution Engine, que ejecuta scripts. El Execution Engine asume que los binarios de los scripts estarán disponibles en el classpath al iniciar la ejecución. Carga el script solicitado utilizando la reflexión de Java, como una instancia de interfaz Script y realiza las ejecuciones necesarias en ella. La clase principal para ejecutar el Secuenciador (Scripts y Execution Engine) está implementada en este módulo.
Ìý
El truco aquà es que el módulo de Scripts (el más inferior) depende de ambos módulos. El de la izquierda proporciona soporte DSL para escribir scripts y el de la derecha tiene el Execution Engine & App.
Este módulo de Script es donde los escritores de scripts pueden escribir scripts. Está ubicado en un repositorio separado, lo que asegura que los escritores de scripts no estarán expuestos a un código complicado. Les proporciona un entorno aislado para escribir scripts.
La aplicación Sequencer se inicia desde el módulo Script, utilizando la clase principal declarada en el módulo Execution Engine & App, para hacer que los scripts estén disponibles en el classpath al ejecutar la aplicación.
Ìý
Un experimento único
Ìý
Esto fue un experimento único en el que logramos que Scala y Kotlin interactuaran entre sÃ. Fue un desafÃo porque no pudimos encontrar ningún otro ejemplo documentado en Internet; no tenÃamos un punto de referencia o guÃa para lo que estábamos tratando de hacer.
Ìý
Para llevar el proyecto a su etapa final, desarrollamos nuestros propios modelos. Por ejemplo, querÃamos construir módulos de Scala y Kotlin en una configuración de compilación de monorepo compartido para simplificar las modificaciones de código. Esto nos llevó a desarrollar una configuración de compilación SBT única para admitir Scala y Kotlin en una configuración de monorepo, ¡que es una historia para otra publicación de blog! Si estás interesado, puedes leer un poco más al respecto
Aviso legal: Las declaraciones y opiniones expresadas en este artÃculo son las del autor/a o autores y no reflejan necesariamente las posiciones de ÷ÈÓ°Ö±²¥.