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 aquí.
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 Thoughtworks.