En la entrada anterior, FreeMonad en Cats, realicé una descripción de cómo definir un DSL utilizando una FreeMonad pero, en ciertas situaciones, necesitamos utilizar un segundo DSL conjuntamente con el primero. En la entrada de hoy, Composición de FreeMonad, realizaré un ejemplo de una aplicación que utiliza conjuntamente dos DSL para la definición de un programa.
Presentación del problema
Continuando con las entidades de dominio de la entrada anterior, supongamos que necesitamos realizar la creación de un esquema de base de datos, realizar unas inserciones, eliminar y consultar; es decir, necesitamos un programa que realice unas operaciones básicas de tipo CRUD; pero además, necesitamos que las operaciones sean trazadas en un log en la salida estándar; pudiendo ser de dos tipos: mensajes informativos, tipo info; y, mensajes de depuración, tipo debug.
Para definir un programa que cumpla con estos requisitos necesitamos lo siguiente: definir un DSL para las operaciones en base de datos, definir un DSL para las operaciones de log y un intérprete que entienda el lenguaje de los DSL`s.
Definición de los ADT`s
Definiré dos ADT’s para los dos tipos de operaciones: DBOperations, para el ADT’s de las operaciones en base de datos; y, LogOperation. Antes de definir los ADT’s, definiremos el alias a utilizar. Definimos un alias con nombre Response con un tipo genérico, define un contenedor binario de tipo Either de un tipo Exception y un tipo genérico. La definición del alias es el siguiente:
type Response[A] = Either[Exception, A]
El ADT que define las operaciones de base de datos es el mismo que se utiliza en la entrada anterior, mediante la siguiente estructura de clases:
sealed trait DBOperation[A] case class Configure(xa: Aux[IO, Unit]) extends DBOperation[Response[Unit]] case class CreateSchema() extends DBOperation[Response[Boolean]] case class Insert(author: Author) extends DBOperation[Response[Int]] case class Select(key: Int) extends DBOperation[Response[Option[String]]] case class Delete(key: Int) extends DBOperation[Response[Int]]
Las clases definidas definen las siguientes operaciones: clase Configure, define la operación de configuración de Transactor de la base de datos; CreateSchema, creación del esquema de base de datos: Insert, inserción de una entidad Author; Select, seleccionar un autor por clave; Delete, eliminación de un autor por clave.
El ADT que define las operaciones de escritura de log se define mediante la siguiente estructura de clases:
sealed trait LogOperation[A] case class Info(msg: String) extends LogOperation[Response[Unit]] case class Debug(msg: String) extends LogOperation[Response[Unit]]
Las clases definidas definen las siguientes operaciones: Info, define la escritura de un mensaje de tipo informativo; Debug, define la escritura de un mensaje de tipo depuración.
En los dos ADT’s, se define como respuesta de la operación el tipo Response.
Definición de los DSL’s
Una FreeMonad permite a un Functor tener la funcionalidad de una mónada; añadir esta funcionalidad, la proporciona el tipo Free[_]. Además, para poder trabajar con otros DSL, es necesario añadir elementos que permitan la unión de los DSL; para ello se utilizan las siguientes herramientas:
- InjectK[DBOperation, F].- Type class que permite inyectar el tipo DBOperation en el tipo F del contexto de ejecución.
- Free.inject[DBOperation, F].- Función que permite enlazar el tipo DBOperation al tipo F del contexto de ejecución.
La definición de los DSL`s DBOperations y LogOperations se realiza definiendo una clase y su companion object el cual define una referencia implícita a la instancia de la clase. El código de los DSL`s se definen en el siguiente snippet:
class DBOperations[F[_]](implicit I: InjectK[DBOperation, F]){ def configure(xa: Aux[IO, Unit]): Free[F, Response[Unit]] = Free.inject[DBOperation, F](Configure(xa)) def createSchema(): Free[F, Response[Boolean]] = Free.inject[DBOperation, F](CreateSchema()) def insert(elem: Author): Free[F, Response[Int]] = Free.inject[DBOperation, F](Insert(elem)) def delete(key: Int): Free[F, Response[Int]] = Free.inject[DBOperation, F](Delete(key)) def select(key: Int): Free[F, Response[Option[String]]] = Free.inject[DBOperation, F](Select(key)) } object DBOperations{ implicit def dboperations[F[_]](implicit I: InjectK[DBOperation, F]) = new DBOperations[F]() } class LogOperations[F[_]](implicit I: InjectK[LogOperation, F]){ def infoLog(msg: String): Free[F, Response[Unit]] = Free.inject[LogOperation, F](Info(msg)) def debugLog(msg: String): Free[F, Response[Unit]] = Free.inject[LogOperation, F](Debug(msg)) } object LogOperations{ implicit def logopearations[F[_]](implicit I: InjectK[LogOperation, F]) = new LogOperations[F]() }
Definición de los intérpretes
Los intérpretes son los functores de transformación. Realizan la interpretación del DSL, interpretando el lenguaje para el que está definido, ejecuta la funcionalidad requerida y retorna el valor transformado.
El intérprete del DSL DBOperations se define en el objeto DBOperationInterprete el cual es un functor de transformación de un tipo DBOperation a OperationResponse. El tipo OperationResponse es un alias del tipo identidad definido en Cats. El alias se define de la siguiente forma:
type OperationResponse[A] = Id[A]
DBOperationsInterprete define una referencia a un transactor de la librería Doobie con una configuración por defecto, esta configuración es un valor mutable ya que puede ser configurada de forma dinámica. Para todo elemento del ADT, se utiliza el repositorio de Author y se trata el resultado como un tipo OperationResponse[A]. El snippet del código con el intérprete es el siguiente:
object DBOperationsInterpreter extends (DBOperation ~> OperationResponse){ implicit val cs = IO.contextShift(ExecutionContexts.synchronous) // TODO var -> val? private var xa = Transactor.fromDriverManager[IO]( s"com.mysql.jdbc.Driver", s"jdbc:mysql://host:port/doobie", s"user", s"pwd", Blocker.liftExecutionContext(ExecutionContexts.synchronous) // just for testing ) override def apply[A](fa: DBOperation[A]) = fa match { case Configure(xaTransactor) => { this.xa = xaTransactor val result: OperationResponse[Response[Unit]] = Right(Unit) result } case CreateSchema() => { val resultCreateSchema: Response[Boolean] = AuthorRepository.createSchemaIntoMySqlB(xa) val result: OperationResponse[ Response[Boolean]] = resultCreateSchema result } case Insert(author) =>{ val resultTask: Response[Int] = AuthorRepository.insertAuthorIntoMySql(xa, author) val result: OperationResponse[Response[Int]] = resultTask result } case Delete(key) => { val resultTask: Response[Int] = AuthorRepository.deleteAuthorById(xa, key) val result: OperationResponse[Response[Int]] = resultTask result } case Select(key) => { val resultTask: Response[Option[String]] = AuthorRepository.selectAuthorById(xa, key) val result: OperationResponse[Response[Option[String]]] = resultTask result } } }
Para el lector interesado en el código de ejemplo del repositorio Author puede acceder a través del siguiente enlace.
El intérprete del DSL`s de operaciones de Log se define en el objeto LogOperationsInterprete el cual define un functor transformador de un tipo LogOperation a un tipo OperationResponse. El intérprete define las operaciones para los mensajes de tipo info y debug escribiendo mensajes en la salida estándar. El snippet de código con el intérprete es el siguiente:
object LogOperationsInterpreter extends (LogOperation ~> OperationResponse){ override def apply[A](fa: LogOperation[A]) = fa match { case Info(msg) => println(s"[*** INFO] ${msg}") val result: OperationResponse[Response[Unit]] = Right(Unit) result case Debug(msg) => println(s"[*** DEBUG] ${msg}") val result: OperationResponse[Response[Unit]] = Right(Unit) result } }
Un aspecto importante a destacar es que en los dos funtores de transformación, es decir, los intérpretes, tienen el mismo tipo de salida definido en el alias OperationResponse[A].
Llegado a este punto, tenemos definidos los dos intérpretes de forma individual pero, nos falta la definición del intérprete que comprenda los dos lenguajes; para ello, tenemos que definir un tipo que englobe a los dos ADT de los DSL definidos y, posteriormente, define un functor que transforma del tipo anterior al resultado final.
El tipo DoobiePureComposingApp es un alias que define un tipo EitherK con los tipos DBOperation, LogOperation (ADT de los DSL definidos) y el tipo A. Este tipo comprende los tipos de las gramáticas de los DSL.
type DoobiePureComposingApp[A] = EitherK[DBOperation, LogOperation, A]
El tipo que define el resultado es el tipo identidad de los DSL definidos.
type OperationResponse[A] = Id[A]
Así, la definición del intérprete que engloba a los dos DSL se define de la siguiente forma:
val interpreter: DoobiePureComposingApp ~> OperationResponse = DBOperationsInterpreter or LogOperationsInterpreter
Definición del programa de uso de los DSL
Llegado a este punto, estamos en disposición de declarar un programa que realice operaciones en base de datos de la entidad Author y con capacidad de escribir en un log. Como comentamos al inicio de la entrada, el programa declara un conjunto de operaciones representativas con un objetivo pedagógico. El snippet del código es el siguiente:
implicit val cs = IO.contextShift(ExecutionContexts.synchronous) final case class DatabaseConfig( host: String, port: String, user: String, password: Secret[String]) def loadEnvironmentVariables(): DatabaseConfig = { val host: ConfigValue[String] = env("DDBB_HOST").or(prop("ddbb.host")).as[String] val port: ConfigValue[String] = env("DDBB_PORT").or(prop("ddbb.port")).as[String] val user: ConfigValue[String] = env("DDBB_USER").or(prop("ddbb.user")).as[String] val password: ConfigValue[Secret[String]] = env("DDBB_PWD").secret val configureEnv: ConfigValue[DatabaseConfig] = (host, port, user, password).parMapN(DatabaseConfig) configureEnv.load[IO].unsafeRunSync() } val dataConfigure: DatabaseConfig = loadEnvironmentVariables() private val xa = Transactor.fromDriverManager[IO]( "com.mysql.jdbc.Driver", s"jdbc:mysql://${dataConfigure.host}:${dataConfigure.port}/doobie", s"${dataConfigure.user}", s"${dataConfigure.password.value}", Blocker.liftExecutionContext(ExecutionContexts.synchronous) // just for testing ) def programBusiness(xa: Aux[IO, Unit])(implicit DB: DBOperations[DoobiePureComposingApp], L: LogOperations[DoobiePureComposingApp]): Free[DoobiePureComposingApp, Response[Option[String]]] = { import DB._ import L._ for { _ <- debugLog("Configuring database...") _ <- configure(xa) _ <- debugLog("Creating database...") result <- createSchema() numInsert1 <- insert(Author(0, "AuthorTest1")) numInsert2 <- insert(Author(0, "AuthorTest2")) numDelete1 <- delete(key = 1) nameAuthor <- select(key = 2) _ <- infoLog("Created Database.") } yield { nameAuthor } } val result = programBusiness(xa).foldMap(interpreter) println(s"Result=${result}")
El código presentado, realiza la carga de la configuración de base de datos desde variables de entorno utilizando la librería Ciris; esta funcionalidad, queda definida en la función loadEnvironmentVariables la cual retorna una clase DatabaseConfig con la configuración; se define el Transactor de la librería Doobie con la configuración la cual es utilizada posteriormente en la declaración del programa; y, por último, se declara el programa en la función programBusiness el cual define como implícitos las referencias a las instancias de los DSL con el tipo DoobiePureComposingApp y, como retorno, un tipo Free con el tipo DoobiePureComposingApp y el tipo de retorno del programa Response[Option[String]].
La salida por consola es la siguiente:
[*** DEBUG] Configuring database…
[*** DEBUG] Creating database…
[*** INFO] Created Database.
Result=Right(Some(AuthorTest2))
Para el lector interesado en el código puede acceder a través del siguiente enlace y, para la entrada en donde explico el uso de la librería Ciris, puede acceder mediante el siguiente enlace