MUnit I: declaración de test

Inicio una serie de entradas relacionadas MUnit. MUnit es una librería de testing para Scala. En la presente entrada, MUnit 1: declaración de test, realizaré una introducción y una descripción de cómo se declaran test con esta librería.

MUnit tiene una parecido a otra librería de testing como es JUnit. JUnit es una librería del contexto del lenguaje de programación Java. La filosofía de MUnit es seguir la misma línea de JUnit pero en el contenxto del lengua Scala. Las características de MUnit son las siguientes:

  1. MUnit se implementa como un ejecutor de JUnit e intenta construir sobre JUnit siempre que sea posible.
  2. No tiene dependencias externas con otras librerías del mundo Scala.
  3. Es una librería multiplataforma que complica MUnit a JVM a través de JavaScript Scala.js.
  4. Informes de pruebas entendibles para analizar los problemas.

Definición de dependencias

La definición de dependencias de MUnit en un proyecto Scala en donde se utiliza sbt se realiza de la siguiente manera:

val MunitVersion = "0.7.20"
val MunitCatsEffectVersion = "0.12.0"
[...]
lazy val root = (project in file("."))
  .settings(
     scalacOptions += "-Yrangepos",
     libraryDependencies ++= Seq(
        "org.scalameta" %% "munit" % MunitVersion % Test,
        "org.typelevel" %% "munit-cats-effect-2" % MunitCatsEffectVersion % Test
     ),
  testFrameworks += new TestFramework("munit.Framework")
)

Ejemplo básico de test con MUnit

La definición de un test en MUnit y la forma de trabajar es parecida a JUnit. Los test se definen heredando de la clase munit.FunSuite, se declara un test con la sentencia test y, dentro de esta, se define la prueba funcional verificando el resultado con assertEquals. Un ejemplo básico de test es el descrito en el siguiente snippet:

class BasicEjem1 extends munit.FunSuite {
  test("Basic") {
    val obtained = 43
    val expected = 43
    assertEquals(obtained, expected)
  }
}

Declaración de test en MUnit

Declaración de test asíncronos.

MUnit tiene la capacidad de realizar test de tareas asíncronas. Esta tarea es posible gracias a los transformadores de MUnit los cuáles permiten controlar la ejecución de este tipo de tarea con un tiempo límite de espera de 30 segundos.

En el siguiente ejemplo, se muestra la definición de un test asíncrono cuyo tiempo de espera es el valor por defecto.

import scala.concurrent.Future
class BasicAsyncEjem1 extends munit.FunSuite {
  implicit val ec = scala.concurrent.ExecutionContext.global
  test("async") {
    Future {
      println("Hello World")
    }
  }
}

Si deseamos modificar el timeout, debemos de modificar el valor del atributo munitTimeout. Un ejemplo de modificación del campo munitTimeout es el siguiente:

import scala.concurrent.Future
import scala.concurrent.duration.Duration
class BasicAsyncEjem2 extends munit.FunSuite {
  implicit val ec = scala.concurrent.ExecutionContext.global
  override val munitTimeout = Duration(1, "s")
  test("slow-async") {
    Future {
      Thread.sleep(5000)
      println("Hello world, slow-async")
    }
  }
}

La salida por consola es la excepción de Timeout cuya traza es la siguiente:

java.util.concurrent.TimeoutException: Future timed out after [1 second]

MUnit tiene una tratamiento especial para manejar Future, pudiendo definir tareas asíncronas sin necesidad de ser ejecutas con un resultado de éxito del test. En el siguiente ejemplo, se muestra una tarea asíncrona definida en un case class que nunca se ejecuta y el resultado de ejecución es correcto.

class BasicAsyncEjem3 extends munit.FunSuite {
  implicit val ec = scala.concurrent.ExecutionContext.global
  case class LazyFuture[+T](run: () => Future[T])
  object LazyFuture {
    def apply[T](thunk: => T)(implicit ec: ExecutionContext): LazyFuture[T] =
      LazyFuture(() => Future(thunk))
  }
  test("buggy-task") {
    LazyFuture {
      Thread.sleep(10)
      println("Hello world BasicAsyncEjem3")
    }
  }
}

Para poder ejecutar la anterior tarea asíncrona, necesitamos definir un ejecutor que sea capaz de ejecutar la referencia a la función run. En el siguiente ejemplo, definimos dicho ejecutor identificado como munitValueTransform. El código del ejemplo es el siguiente:

import scala.concurrent.{ExecutionContext, Future}
class BasicAsyncEjem4 extends munit.FunSuite {
  case class LazyFuture[+T](run: () => Future[T])
  object LazyFuture {
    def apply[T](thunk: => T)(implicit ec: ExecutionContext): LazyFuture[T] =
      LazyFuture(() => Future(thunk))
  }
  override def munitValueTransforms = super.munitValueTransforms ++ List(
    new ValueTransform(
      "LazyFuture",
      { 
        case LazyFuture(run) => run()
      }
    )
  )
  implicit val ec = ExecutionContext.global
    test("tarea OK") {
      LazyFuture {
         Thread.sleep(5000)
         println("Hello world BasicAsyncEjem4")
      }
    }
}

La salida por consola del código anterior es la siguiente:

Hello world BasicAsyncEjem4

Definir test con una función auxiliar.

Podemos definir una función que defina un test cuyos parámetros pueden ser por valor o por nombre. Un código ejemplo de estos test es el siguiente:

class AuxiliarFunctionEjem1 extends munit.FunSuite {
   def check[T](name: String, original: List[T], expected: Option[T])(implicit loc: munit.Location): Unit = {
      test(name) {
         val obtained = original.headOption
         assertEquals(obtained, expected)
      }
   }
   check("basic", List(1, 2), Some(1))
   def checkByName(name: String, bytes: => Array[Byte]): Unit =
       test(name) {
          assertEquals(bytes.length > 0, true)
       }
   import java.nio.file.{Files, Paths}
   checkByName("file", Files.readAllBytes(Paths.get("build.sbt")))
}

Definir test que siempre falle.

En ocasiones necesitamos que un test falle. Para definir este comportamiento, empleamos la función fail. Un código ejemplo es el siguiente:

class AlwaysFailEjem1 extends munit.FunSuite {
  test("issue-456".fail) {
     assertEquals(1, 1)
  }
}

Modificar el comportamiento de ejecutor munitTest

Si necesitamos modificar el comportamiento de un test empleamos un tag. Un tag es una entidad de Munit la cual se representa con un case class. El comportamiento del test lo definiremos en una clase TestTransform del ejecutor munitTestTransforms, referenciando al tag identificador del comportamiento. En la identificación del test, se define la referencia al tag a ejecutar. Un código ejemplo para ejecutar un test cuatro veces es el siguiente:

case class Rerun(count: Int) extends munit.Tag("Rerun")
class CustomizeEvaluationTestEjem1 extends munit.FunSuite {
   override def munitTestTransforms = super.munitTestTransforms ++ List(
     new TestTransform(
       "Rerun",
       { test =>
           val rerunCount = test.tags.collectFirst { case Rerun(n) => n }.getOrElse(1)
           if (rerunCount == 1) test
           else {
             test.withBody(() => {
                Future.sequence(1.to(rerunCount).map(_ => test.body()).toList)
             })
           }
       }
     )
   )
   test("files".tag(Rerun(4))) {
       println(s"Hello world...")
   }
}

El resultado del anterior código es la escritura en consola del texto “Hello world…” cuatro veces.

Modificar el nombre de un test de forma dinámica.

Si necesitamos ejecutar test cuyo nombre dependa de circunstancias externas, es necesario emplear un TestTransform. Un código ejemplo que modifica el nombre de los test de forma dinámica con la versión de scala es el siguiente:

class CustomizeTestNameEjem1 extends munit.FunSuite {
  val scalaVersion = scala.util.Properties.versionNumberString
  override def munitTestTransforms: List[TestTransform] = super.munitTestTransforms ++ List(
     new TestTransform(
        "append Scala version",
        { test =>
            test.withName(test.name + "-" + scalaVersion)
        }
      )
  )
  test("test-Foo") {
     assert(scalaVersion.startsWith("2.13.4"))
     assertEquals(scalaVersion, "2.13.4")
  }
}

El resultado de la ejecución es el que se muestra en la siguiente imagen:

Saltar un test.

Si necesitamos saltar un test, emplearemos la función flaky para saltar la ejecución. Un código ejemplo es el siguiente:

class FlakyTestEjem1 extends munit.FunSuite {
  test("requests".flaky) {
    val obtained = 42
    val expected = 43
    assertEquals(obtained, expected)
  }
}

Para saltar la ejecución en los test, es necesario ejecutar sbt definiendo la variable MUNIT_FLASK_OK con valor a true. Si no se emplea la variable, los test se ejecutan normalmente. Un ejemplo de ejecución es el siguiente:

MUNIT_FLAKY_OK=true sbt test

Compartir configuración de test.

Si necesitamos definir clases de test con componentes comunes, se define una herencia de clases cuya clase base defina todo aquello que sea común. Si necesitamos modificar el nombre de los test de dos clases diferentes, definimos una clase base con el transformador del nombre de test y, sus clases hijas, heredan de dicha clase base. Un código ejemplo es el siguiente:

class BaseSuite extends munit.FunSuite {
   val scalaVersion = scala.util.Properties.versionNumberString
   override def munitTestTransforms: List[TestTransform] = super.munitTestTransforms ++ List(
      new TestTransform(
        "append Scala version",
        { test =>
          test.withName(test.name + "-" + scalaVersion)
        }
      )
   )
}
class MyFirstTestSuite extends BaseSuite {
   test("FirstTestSuite.test1") {
      println("Hello world ...FirstTestSuite.test1")
   }
   test("FirstTestSuite.test2") {
      println("Hello world FirstTestSuite.test2")
   }
}
class MySecondTestSuite extends BaseSuite {
   test("SecondTestSuite.test1") {
      println("Hello world ...SecondTestSuite.test1")
   }
   test("SecondTestSuite.test2") {
      println("Hello world SecondTestSuite.test2")
   }
}

La salida por consola es la que se muestra en la siguiente imagen:

Definición de una batería de pruebas.

Si necesitamos definir una batería de pruebas, definimos una clase de test que herede de munit.Suite, definimos el tipo de de valor de las pruebas y, para finalizar, definimos la lista de test con la creación de tantas clases Test como queremos. Un código de ejemplo es el siguiente:

import munit.Assertions.assertEquals
import munit.{Location, Tag}
class TestLibrarySuite2 extends munit.Suite {
   override type TestValue = Unit
   override def munitTests(): Seq[Test] = List(
     new Test(
       "example1",
       body = () => {
          val obtained = 43
          val expected = 43
          assertEquals(obtained, expected)
       },
       tags = Set.empty[Tag],
       location = implicitly[Location]
     ),
     new Test(
        "example2",
        body = () => {
           val obtained = "43"
           val expected = "43"
           assertEquals(obtained, expected)
        },
        tags = Set.empty[Tag],
        location = implicitly[Location]
     )
   )
}

En la siguiente entrada, MUnit II: aserciones, describiré en cómo definir aserciones en los test.

Microservicios en Python: plantilla básica.

En la presente entrada, Microservicios en Python: plantilla básica, realizaré una descripción de una plantilla base de un microservicio en Python utilizando la librería Flask.

La arquitectura de microservicios es aquel enfoque que permite definir aplicaciones software mediante un conjunto de servicios desplegables de forma independiente, es decir, una aplicación es un conjunto de pequeñas aplicaciones poco acopladas. La definición de microservicio que realiza Martin Fowler es la siguiente:

“El término ‘Arquitectura de microservicio’ ha surgido en los últimos años para describir una forma particular de diseñar aplicaciones de software como conjuntos de servicios desplegables de forma independiente. Si bien no existe una definición precisa de este estilo arquitectónico, existen ciertas características comunes en torno a la organización en torno a la capacidad empresarial, la implementación automatizada, la inteligencia en los puntos finales y el control descentralizado de idiomas y datos.”

El acoplamiento entre los microservicios, se puede realizar utilizando colas, brokers de mensajería o mediante peticiones HTTP. Un ejemplo de un productor y consumidor de mensajes para el broker de mensjaes que contiene Redis pueden ser los que describo en los siguientes enlaces:

La funcionalidad de la plantilla del microservicio es muy simple, se definirá un punto de entrada de tipo POST al cual se le pasarán los campos nombre, operación y operador y, como resultado, retornará un JSON con el resultado. Se empleará la técnica DDD Domain Driven Design para definir una entidad de dominio la cual será almacenda en un supuesto contenedor de datos, en nuestro caso, en memoria.

Las dependencias de las librerías del proyecto se definen en el fichero requirements.txt y contiene las siguientes referencias: flask, dataclasses y pytest.

La arquitectura está compuesta por tres capas horizontales: capa de presentación, representada por el paquete entrypoints; cada de servicios, representada por el paquete services; y, capa de datos, representada por el paquete repository. Desde un punto de vista vertical, tenemos las siguientes capas: capa de dominio, representada por el paquete domain en donde se define las entidades de dominio y DTO; y, por último,capa de excepciones, representado con el paquete exception en cual contiene las excepciones del
aplicativo.

Descripción arquitectónica por capas

Capa de dominio

La capa de dominio está compuesto por el módulo entity_model.py el cual contiene la entidad de dominio UseCaseEntity y los DTO UseCaseRequest y UseCaseResponse. El snippet de la entidad de dominio es el siguiente:

class UseCaseEntity:
    def __init__(self,
                 uuid: str,
                 name: str,
                 operation: str,
                 operator: int,
                 date_data: Optional[date] = None):
        self.uuid = uuid
        self.name = name
        self.operation = operation
        self.operator = operator
        self.date = date_data
    @property
    def calculate(self) -> int:
        result = 0
        if self.operation == "+":
             result = self.operator + self.operator
        elif self.operation == "*":
             result = self.operator * self.operator
        else:
             result = -1
        return result

Capa de presentación

La capa de presentación está compuesta por el módulo app.py. Los puntos de entrada son: métodos liveness y rediness para conocer el estado del microservicio (en el ejemplo no realizan ninguna operación) y el método para la operación de negocio use_case_example; este método, realiza la obtención de los parámetros de la petición HTTP, creación del DTO de la petición e invocación al método de servicio; para finalizar, retorna el resultado. El snippet de la función es la siguiente:

@app.route("/use_case_example", methods=['POST'])
def do_use_case_example():
    """
    use case example
    curl --header "Content-Type: application/json" --request POST \
         --data '{"name":"xyz1", "operation":"+", "operator":"20"}' \
         http://localhost:5000/use_case_example
    :return: str
    """
    p_name = request.json['name']
    p_operation = request.json['operation']
    p_operator = int(request.json['operator'])
    current_app.logger.info(f"[*] /use_case_example")
    current_app.logger.info(f"[*] Request: Name={p_name} operation={p_operation} operator={p_operator}")
    current_app.logger.info(f"Name={p_name} operation={p_operation} operator={p_operator}")
    data_request = entity_model.UseCaseRequest(uuid=uuid.UUID,
                                               name=p_name,
                                               operation=p_operation,
                                               operator=p_operator)
    repository = use_case_repository.UseCaseRepository()
    response_use_case = use_case_service.do_something(data_request, repository)
    data = jsonify({'result': response_use_case.resul})
    return data, 200

Capa de servicio

La capa de servicio está compuesta por el módulo use_case_service.py el cual contiene la función que realiza la operación de negocio: creación de la entidad de dominio, inserción en el repositorio de datos y retorno del resultado. El snippet de la función es el siguiente:

def do_something(request: entity_model.UseCaseRequest,
                 repository: use_case_repository.AbstractUseCaseRepository) -> entity_model.UseCaseResponse:
    """
    Business operation.
    :param request: entity_model.UseCaseRequest
    :param repository: use_case_repository.AbstractUseCaseRepository

    :return: entity_model.UseCaseResponse
    """
    if request is None:
        raise use_case_exception.UseCaseRequestException()
    logging.info(f"[**] /use_case_service.do_something")
    entity = entity_model.UseCaseEntity(uuid=request.uuid,
                                        name=request.name,
                                        operation=request.operation,
                                        operator=request.operator,
                                        date_data=date.today())
    repository.add(entity)
    return entity_model.UseCaseResponse(str(entity.calculate))

Capa de repositorios

La capa de repositorio define el respositorio en donde se almacenan los datos la cual está compuesta por el módulo use_case_repository.py. El módulo contiene la definición del repositorio UseCaseRepository para la entidad UseCaseEntity y la clase de abstracta con las operaciones de los repositorios. El snippet del repositorio es el siguiente:

class UseCaseRepository(AbstractUseCaseRepository):
    """
    Definition of the operations that connect to database.
    """
    def __init__(self) -> None:
        self.database: [entity_model.UseCaseEntity] = []
    def add(self, entity: entity_model.UseCaseEntity) -> bool:
        result: bool = False
        logging.info(f"[***] /use_case_repository.add")
        if entity is not None:
            result = True
            self.database.append(entity)
        return result
    def get(self, p_uuid: str) -> entity_model.UseCaseEntity:
        index = 0
        enc = False
        result: entity_model.UseCaseEntity = None
        logging.info(f"[***] /use_case_repository.get")
        while (index < len(self.database)) and not enc:
            aux: entity_model.UseCaseEntity = self.database[index]
            if aux.uuid == uuid.UUID(p_uuid):
                result = aux
                enc = True
            index += 1
       return result

Pruebas

La plantilla contiene el directorio tests el cual contiene los test de la plantilla del microservicio. Para ejecutar los test se ejecuta el siguiente comando desde la carpeta raíz del proyecto:

>pytest --setup-show

Docker

Todo microservicio debe de tener la definición de la imagen para que sea ejecutado en un contenedor. Así, existe el fichero Dockerfile para definir dicha imagen. El contenido de la imagen contiene las operaciones de instalación de las herramientas para Python, instalación de las librerías, copiado de código fuente y variables de entorno y ejecutación. El snippet con el contenido del DOckerfile es el siguiente:

FROM python:3.8-alpine
RUN apk add --no-cache --virtual .build-deps gcc musl-dev python3-dev
RUN apk add libpq
COPY requirements.txt /tmp
RUN pip install -r /tmp/requirements.txt
RUN apk del --no-cache .build-deps
RUN mkdir -p /app
COPY . /app/
WORKDIR /app
ENV FLASK_APP=entrypoints/app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1
CMD flask run --host=0.0.0.0 --port=80

Makefile

Para facilitar las operaciones de Docker, se ha definido un fichero de tipo Makefile en el cual se definen las operaciones necesarias para operar con Docker. Las operaciones son las siguientes:

  • Operación build.- Creación de la imagen. Para ejecutar la operación se ejecuta el comando make build desde la raíz del proyecto. El snippet de la definición de la operación build es la siguiente:
build:
     docker image build -t alvaroms/template-microservice:v1.0 .
  • Operación run.- Arranque de un contenedor con la imagen del proyecto. Para ejecutar la operación se ejecuta el comando make run desde la raíz del proyecto. El snippet de la definición de la operación run es la siguiente:
run:
   docker container run -d --name template-microservice -p 6060:80 alvaroms/template-microservice:v1.0
  • Operación exec.- Acceso a la consola del contenedor. Para ejecutar la operación se ejecuta el comando make exec desde la raíz del proyecto. El snippet de la definición de la operación exec es la siguiente:
exec:
    docker container exec -it template-microservice /bin/sh
  • Operación logs.- Visualización de los logs del contenedor. Para ejecutar la operación se ejecuta el comando make logs desde la raíz del proyecto. El snippet de la definición de la operación logs es la siguiente:
logs:
    docker container logs template-microservice
  • Operación test.- Ejecución de los test. Para ejecutar la operación se ejecuta el comando make test desde la raíz del proyecto. El snippet de la definición de la operación test es la siguiente:
test:
    pytest --setup-show
  • Operación all.- Ejecución de los test, construcción de la imagen y arranque del contenedor. Para ejecutar la operación se ejecuta el comando make all desde la raíz del proyecto. El snippet de la definición de la operación test es la siguiente:
all: test build run

Pruebas del API

Los comando curl para realizar las pruebas sobre el microservicio desplegado en el contenedor son los siguientes:

  • Root del microsercicio.
    curl http://localhost:6060/
  • Función rediness
    curl http://localhost:6060/readiness
  • Función liveness
    curl http://localhost:6060/liveness
  • Función de negocio.
    curl --header "Content-Type: application/json" --request POST \
    
    --data '{"name": "xyz1", "operation": "+", "operator": "20"}' \
    
    http://localhost:6060/use_case_example

Integración Contínua

Para finalizar, se define un pipeline de integración contínua definida en el fichero .travis.yml. El snippet con el contenido es el siguiente:

dist: xenial
language: python
python: 3.6
install:
- pip3 install -r requirements.txt

script:
- make test

branches:

 

Para el lector interesado puede acceder al código a través del siguiente enlace.

Redis: consumidor de mensajes

En la entrada anterior, Redis: productor de mensajes, describo cómo definir un productor para la publicación de mensajes en el broker Redis; en la presente entrada, Redis: consumidor de mensajes, describiré cómo consumir mensajes del broker.

El primer paso es crear el broker al cual publicar mensajes; para ello, trabajaré con una imagen Docker con Redis. Para descarga la imagen y arrancar el contenedor es necesario ejecutar los siguientes comandos:

docker pull redis
docker run --name some-redis -d redis

Tras su ejecución, tendremos Redis en una contenedor cuyo puerto de acceso es el 6379.

El segundo paso, es escribir el código del consumidor. Seguiremos los mismos criterios que en la entrada Redis: productor de mensajes.

Para crear la conexión con Redis, creamos un objeto de tipo Redis con los datos de la conexión a Redis. El snippet del código es el siguiente:

import redis
publish_redis = redis.Redis(host=config.HOST_REDIS, port=config.PORT_REDIS, db=0)

Una vez que tenemos la referencia a Redis, necesitamos suscribirnos al topic donde leer los mensajes; una vez suscritos, nos mantenemos a la espera de la recepción del mesaje; al recepcionar el mensaje, obtenemos un mensaje con una estructura de diccionario del cual deberemos de obtener el campo data. El snippet con el código es el siguiente:

consume_client_topic = publish_redis.pubsub()
consume_client_topic.subscribe(config.TOPIC_REDIS)
for message in consume_client_topic.listen():
  if message['data'] != 1:
    data = json.loads(message['data'].decode())
    logging.info(f"mesagge={data['message']} result={data['result']}")

Para el lector interesado, el código del enlace está en el siguiente enlace.

Redis: productor de mensajes

Redis es una herramienta Open source la cual puede ser utilizada como un almacén de estructura de datos en memoria, como una cache, como base de datos y como un broker
de mensajes. En la presente entrada, Redis: productor de mensajes en el broker, me centraré en describir cómo crear un productor de mensajes en el broker de mensajes de Redis. El ejemplo estará definido en lenguaje Python.

El primer paso es crear el broker al cual publicar mensajes; para ello, trabajaré con una imagen Docker con Redis. Para descargar la imagen y arrancar el contenedor es necesario ejecutar los siguientes comandos:

docker pull redis
docker run --name some-redis -d redis

Tras su ejecución, tendremos Redis en una contenedor cuyo puerto de acceso es el 6379.

El segundo paso es crear un proyecto Python en donde definiremos la dependencia del paquete redis y un fichero de tipo Python con el código del productor.

Para crear la conexión con Redis, creamos un objeto de tipo Redis con los datos de la conexión a Redis. El snippet del código es el siguiente:

import redis
publish_redis = redis.Redis(host=config.HOST_REDIS, port=config.PORT_REDIS, db=0)

Una vez creado la referecia a Redis, utilizaremos la función publish para publicar un mensaje en un topic de Redis. El snippet ejemplo es el siguiente:

msg = '{"message": "Test message-%d", "result": "OK"}' % index
publish_redis.publish(config.TOPIC_REDIS, msg)

El valor config.TOPIC_REDIS corresponde con un valor alfanumérico.

Para el lector interesado, el código del enlace está en el siguiente enlace.

En la siguiente entrada, Redis: consumidor de mensajes, realizaré la descripción de un consumidor de mensajes.

Composición de FreeMonad

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

FreeMonad en Cats

Definimos FreeMonad como aquella construcción que permite construir una mónada desde un functor. En la presente entrada, FreeMonad en Cats, describiré cómo definir y utilizar una FreeMonad mediante un ejemplo.

Según Wikipedia, definimos funtor como sigue:

“En teoría de categorías un funtor o functor es una función de una categoría a otra que lleva objetos a objetos y morfismos a morfismos de manera que la composición de morfismos y las identidades se preserven”.

Un functor cumple la propiedad de identidad y de composición.

Según Wikipedia, definimos mónada como sigue:

“En la programación funcional , una mónada (monad en inglés), es una estructura que representa cálculos definidos como una secuencia de pasos”

Una mónada cumple la propiedad de identidad y asociativa.

La FreeMonad permite lo siguiente:

  • Representar cálculos con estado como datos y ejecutarlos.
  • Ejecutar cálculos recursivos.
  • Definir un DSL.
  • Reorientar un cálculo a otro intérprete utilizando transformaciones naturales.

La implementación de FreeMonad que utilizaremos en el ejercicio es aquella que se ha definido en la librería Cats y, el módulo de cats empleado, es el paquete que se define en cats-free. Una FreeMonad se define como Free[_]

La definición de dependencias del módulo sbt en el fichero build.sbt es la siguiente:

lazy val catsEffect = (project in file("cats-effect"))
  .settings(
    name := "example-cats-effect",
    assemblySettings,
    scalacOptions ++= basicScalacOptions,
    libraryDependencies ++=
      catsEffectDependencies ++ Seq(
        scalaTest
      )
  )
lazy val catsEffectDependencies = Seq(
  cats_effect,
  cats_effect_laws
)

La definición de las coordenadas de los paquetes a utilizar en el módulo para la librería cats-free son las siguientes:

lazy val cats_effect_laws = "org.typelevel" %% "cats-effect-laws" % "2.2.0" % "test"
lazy val cats_free = "org.typelevel" %% "cats-free" % "2.2.0"

Presentación del problema

Supongamos que tenemos un aplicativo el cual maneja una entidad Autor y necesitamos persistir los datos de la entidad Autor en base de datos. Para ello, necesitamos realizar operaciones sobre base de datos como son: creación del esquema, insertar, borrar y buscar elementos. La definición de la entidad de dominio del problema se define en la siguiente case class:

case class Author(id:Long, name: String)

Para dar solución a este problema, definiremos un DSL que soporte las funcionalidades requeridas. Evidentemente, es un problema teórico con el objetivo describir la definición de una FreeMonad.

Definición de la gramática

La gramática del DSL queda definida en un ADT (Tipo de dato Algebraico) el cual define las operaciones a realizar. El ADT con las operaciones de base de datos OperationDB[A] queda definido en la siguiente estructura de clases:

sealed trait OperationDB[A]
case class CreateSchema() extends OperationDB[OperationDBResponse[Boolean]]
case class Insert(author: Author) extends OperationDB[OperationDBResponse[Int]]
case class Select(key: Int) extends OperationDB[OperationDBResponseOption[String]]
case class Delete(key: Int) extends OperationDB[OperationDBResponse[Int]]

Las operaciones de base de datos son: CreateSchema, operación para la creación del esquema de la entidad Autor; Insert, operación de inserción de un Autor; Select, búsqueda de un Autor en función de una clave; y, Delete, eliminación de un Autor por clave. El tipo parametrizado es el tipo de retorno de cada operación cuyos tipos son los representados en los siguiente alias:

type OperationDBResponse[A] = Either[Exception, A]
type OperationDBResponseOption[A] = Either[Exception, Option[A]]

Los alias descritos anteriormente define contenedores binarios de Tipo Either cuyo valor Left representa una excepción y, su valor Right, representa el resultado de la operación de tipo A u Option[A]

Definición del DSL

El DSL se define mediante una FreeMonad la cual, utilizando Cats, queda definida con el tipo Free[_]. Así, el alias del tipo de operación del DSL se define utilizando el siguiente alias:

type Operation[A] = Free[OperationDB, A]

El alias Operation[A] define una FreeMonad cuyo valor de entrada es una operación de tipo OperationDB y cuyo resultado es un tipo genérico A.

El DSL debe de definir funciones para las siguientes operaciones: creación de un esquema, inserción, borrado y consulta. Las funciones del DSL son las siguientes:

import cats.free.Free
import cats.free.Free.liftF
[...]
def createSchema(): Operation[OperationDBResponse[Boolean]] =
    liftF[OperationDB, OperationDBResponse[Boolean]](CreateSchema())
def insert(elem: Author): Operation[OperationDBResponse[Int]] =
    liftF[OperationDB, OperationDBResponse[Int]](Insert(elem))
def delete(key: Int): Operation[OperationDBResponse[Int]] =
    liftF[OperationDB, OperationDBResponse[Int]](Delete(key))
def select(key: Int): Operation[OperationDBResponseOption[String]] =
    liftF[OperationDB, OperationDBResponseOption[String]](Select(key))

Las funciones del DSL utilizan la función liftF que permite subir de contexto de una operación OperationDB con un tipo de respuesta a una FreeMonad, Free[_].

Definición del intérprete del DSL

Una vez definido el lenguaje de dominio es necesario definir aquel intérprete que sea capaz de traducir el lenguaje en operaciones de base de datos. En nuestro caso, utilizaremos una entidad repositorio el cual sea capaz de realizar las operaciones de base de datos, consiguiendo desacoplar las operaciones de interpretación del lenguaje con las operaciones directas de base de datos.

El repositorio es un objeto llamado AuthorRepository el cual utiliza la librería funcional Doobie para realizar las operaciones.

Para cada operación del lenguaje, el intérprete gestiona un estado. Los estados son los siguientes: Init, estado inicial que representa el estado previo a la creación del esquema; Created, estado que indica que el esquema ha sido creado; Deleted, estado que representa la eliminación del esquema, en la práctica no se utiliza.

Los estados quedan representados en la siguiente estructura:

sealed trait StateDatabase
case object Init extends StateDatabase
case object Created extends StateDatabase
case object Deleted extends StateDatabase

Dada la existencia de estados, el intérprete utilizará la mónada estado para gestionar el retorno de estado de cada operación.

El intérprete define un functor de transformación, representado por el símbolo ~> el cual transforma entidades de tipo OpearetionDB en OperationState. El functor intérprete recibe como parámetro un Transactor de Doobie de la base de datos con la que se opera. El snippet de código del intérprete es el siguiente:

import cats.~>
[...]
def pureInterpreter(xa: Aux[IO, Unit]): OperationDB ~> OperationState = new(OperationDB ~> OperationState){
override def apply[A](fa: OperationDB[A]): OperationState[A] = fa match {
  case CreateSchema() => {
    val resultCreateSchema: Either[Exception, Boolean] = AuthorRepository.createSchemaIntoMySqlB(xa)
    resultCreateSchema
      .fold( ex => State[StateDatabase, OperationDBResponse[Boolean]]{ state => (Init,  resultCreateSchema) },
        value => State[StateDatabase, OperationDBResponse[Boolean]]{ state => (Created,  resultCreateSchema) } )
  }
  case Insert(author) =>{
    State[StateDatabase, OperationDBResponse[Int]]{ state =>
      (Created,  AuthorRepository.insertAuthorIntoMySql(xa, author))
    }
  }
  case Delete(key) => {
    State[StateDatabase, OperationDBResponse[Int]]{ state =>
      (Created,  AuthorRepository.deleteAuthorById(xa, key))
    }
  }
  case Select(key) => {
    State[StateDatabase, OperationDBResponseOption[String]]{ state =>
      (Created,  AuthorRepository.selectAuthorById(xa, key))
    }
  }
 }
}

Definición del repositorio de la entidad Autor.

El repositorio con las operaciones de base de datos está definiado en un objeto AuthorRepository que utiliza la librería Doobie. El objeto define un conjunto de funciones con las operaciones que utiliza el intérprete del DSL; como ejemplo, la operación de creación del esquema es la siguiente:

val dropAuthor: ConnectionIO[Int] = sql"""DROP TABLE IF EXISTS Author""".update.run
val createAuthor: ConnectionIO[Int] = sql"""CREATE TABLE Author (id SERIAL, name text)""".update.run
def createSchemaIntoMySqlA[A](xa: Aux[IO, Unit]): Either[Exception, Unit] = {
  val creator: ConnectionIO[Unit] = for {
    _ <- dropAuthor
    _ <- createAuthor
  } yield ()
  try{
    val resultDatabase: Unit = creator.transact(xa).unsafeRunSync
    Right(resultDatabase)
  } catch {
    case e: java.sql.SQLException =>{
      println(s"Error create schema: ${e}")
      Left(e)
    }
  }
}

Para el lector interesado en el código del repositorio puede acceder en el siguiente enlace.

Definición de los programas

Una vez definida en un objeto AuthorDSL las funciones del DSL, alias e intérpretes, estamos en disposición de definir las declaraciones de los programas que pueden utilizar dicho lenguaje; para ello, necesitamos importar el DSL y definir el Transactor de base de datos, estas operaciones, se realizan de la siguiente forma:

import doobie.Transactor
import doobie.util.ExecutionContexts
import es.ams.freemonaddoobie.AuthorDSL._
[...]
implicit val cs = IO.contextShift(ExecutionContexts.synchronous)
val xa = Transactor.fromDriverManager[IO](
  "com.mysql.jdbc.Driver",
  "jdbc:mysql://localhost:3306/doobie",
  "root",
  "root",
  Blocker.liftExecutionContext(ExecutionContexts.synchronous) // just for testing
)

Para la correcta ejecución del programa es necesario tener una base de datos levantada en la configuración que se especifica en el Transactor; en mi caso, he utilizado un contenedor Docker con una base de datos MySQL.

La definición de un programa está compuesta por la secuencia de funciones del DSL definidas en un for comprehension y, para su ejecución, se utiliza  la función foldMap con el intérprete definido. Al utilizar la mónada estado, es necesario arrancar la ejecución con un estado.

En los siguientes apartados, se describen ejemplos de las declaraciones de programas del DSL definido

1.- Ejemplo de programa de creación de un esquema.

def createDatabase(): Operation[Either[Exception, Boolean]] = for {
  result <- createSchema()
} yield (result)
val resultCreate = createDatabase().foldMap(pureInterpreter(xa)).run(Init).value
println(s"Create database=${resultCreate}")
println

La salida por consola es la siguiente:

Create database=(Created,Right(true))

2.- Ejemplo de programa de inserción de un Autor en base de datos.

def insertAuthor(): Operation[Either[Exception, Int]] = for {
  num <- insert(Author(0, "Author1"))
} yield (num)
val resultInsertAuthor = insertAuthor().foldMap(pureInterpreter(xa)).run(Created).value
println(s"Insert Author=${resultInsertAuthor}")
Println

La salida por consola es la siguiente:

Insert Author=(Created,Right(1))

3.- Ejemplo de programa de eliminación de un Autor en base de datos.

def deleteAuthor(): Operation[Either[Exception, Int]] = for {
  numInsert10 <- insert(Author(0, "Author10"))
  numInsert11 <- insert(Author(0, "Author11"))
  numInsert12 <- insert(Author(0, "Author12"))
  numDeleted <- delete(2)
} yield (numDeleted)
val resultDeleteAuthor = deleteAuthor().foldMap(pureInterpreter(xa)).run(Created).value
println(s"Delete Author=${resultDeleteAuthor}")
println

La salida por consola es la siguiente:

Delete Author=(Created,Right(1))

4.- Ejemplo de programa de consulta de un autor por clave.

def selectAuthorOK(): Operation[Either[Exception, Option[String]]] = for {
  author <- select(1)
} yield (author)
val resultSelectAuthorOK = selectAuthorOK.foldMap(pureInterpreter(xa)).run(Created).value
println(s"Select Author=${resultSelectAuthorOK}")
println

La salida por consola es la siguiente:

Select Author=(Created,Right(Some(Author1)))

Para el lector interesado puede acceder al código de la entrada en el siguiente enlace.

Ciris

En toda aplicación, es común realizar la carga de configuración desde variables de entorno.En el ecosistema de Scala, la tarea de carga de estos valores los podemos realizar con la solución Ciris. Ciris es una librería funcional que realiza esta funcionalidad. En la presente entrada, Ciris, realizaré una breve descripción de esta librería sencilla.

Definición de dependencias

Ciris está compuesta por un conjunta de paquetes que utilizan otros módulos, como pueden ser: Circe, Refined o Squants; pero, para poder trabajar correctamente, es necesario añadir el paquete refined-cats el cuál no se referencia en la documentación.

La definición de las dependencias necesarias para trabajar con Ciris son las siguientes:

lazy val ciris_ciris = "is.cir" %% "ciris" % "1.2.1"
lazy val ciris_circe = "is.cir" %% "ciris-circe" % "1.2.1"
lazy val ciris_enumeratum = "is.cir" %% "ciris-enumeratum" % "1.2.1"
lazy val ciris_refined = "is.cir" %% "ciris-refined" % "1.2.1"
lazy val ciris_squants = "is.cir" %% "ciris-squants" % "1.2.1"
lazy val ciris_refined_cats = "eu.timepit" %% "refined-cats" % "0.9.18"

Para cargar las dependencias definidas anteriormente en el proyecto, modificamos el fichero build.sbt para realizar la carga del módulo y realizar los ejemplos básicos. El código resultante del fichero de configuración sbt es el siguiente:

import Dependencies._
[...]
lazy val ciris = (project in file("ciris"))
  .settings(
    name := "example-ciris",
    assemblySettings,
    scalacOptions ++= basicScalacOptions,
    libraryDependencies ++=
    cirisDependencies ++ Seq(
      scalaTest
    )
  )
lazy val cirisDependencies = Seq(
  ciris_ciris
  ,ciris_circe
  ,ciris_enumeratum
  ,ciris_refined
  ,ciris_squants
  ,ciris_refined_cats
)

Casos de uso de ejemplo

En el presente apartado, realizaré la presentación de unos ejemplos de prueba de la librería Ciris.

  • Carga de una variable de entorno de tipo entera

Sea la variable de entorno API_PORT=8080 y el siguiente snippet de código el cual realiza lo siguiente: lectura de la varibla API_PORT como entero creando un objeto port de tipo ConfigValue[Int]; el objeto port, es cargado como un tipo IO de cats-effect y la función unsafeRunSync resuelve el valor del tipo IO obteniendo el valor resultado.

def exampleLoadIntENV(): Unit = {
  val port: ConfigValue[Int] = env("API_PORT").or( prop("api.port") ).as[Int]
  val portResult: Int = port.load[IO].unsafeRunSync()
  println(s"Port=${portResult}")
  println()
}

El resultado por pantalla es el siguiente:

Port=8080
  • Carga de variables de entorno en una clase.

Sean las variables de entorno API_PORT=8080 y API_TIMEOUT=100 millis y el siguiente snippet de código el cual realiza lo siguiente: lectura de la varibla API_PORT como entero creando un objeto de tipo ConfigValue[Int]; lectura de la variable API_TIMEOUT de tipo Duration de tipo ConfigValue[Duration]; definición de la clase Config con un campo de tipo entero y otro de tipo Duration; parseo de los dos objetos anteiores para carga los valores de tipo ConfigValue[A] a la clase Config; y, para finalizar, carga del objeto de tipo ConfigValue[Config] como un tipo IO de cats-effect y la función unsafeRunSync resuelve el valor del tipo IO obteniendo el valor resultado.

def exampleLoadPairEnvVar(): Unit = {
  val port: ConfigValue[Int] = env("API_PORT").or( prop("api.port") ).as[Int]
  val timeout: ConfigValue[Option[Duration]] = env("API_TIMEOUT").as[Duration].option
  final case class Config(port: Int, tiemout: Option[Duration])
  val config: ConfigValue[Config] = (port, timeout).parMapN(Config)
  val resultConfig: Config = config.load[IO].unsafeRunSync()
  println(s"Result Config->${resultConfig}")
  println()
}

El siguiente snippet de código es idéntico al anterior pero se utiliza for comprehension.

def exampleLoadWithForCom(): Unit = {
  final case class Config(port: Int, tiemout: Option[Duration])
  val config = for{
    eport <- env("API_PORT").or( prop("api.port") ).as[Int]
    etimeout <- env("API_TIMEOUT").as[Duration].option
  } yield{
    Config(eport, etimeout)
  }
  val result: Config = config.load[IO].unsafeRunSync()
  println(s"Result Config->${result}")
  println()
}

La salida por consola es la siguiente:

Result Config->Config(8080,Some(100 milliseconds))
  • Carga de variable de entorno y valor por defecto

Sean las variables de entorno API_PORT=8080 y API_TIMEOUT=100 millis. Los siguientes snippet de código son idénticos a los anteriores salvo que se utiliza la función default para determinar el valor por defecto: en el primer caso, con un valor de tipo Duration; y, en el segundo, con una clase Config.

def exampleDefaultValue1(): Unit = {
  val timeDefault: ConfigValue[Duration] = env("API_TIME_DEEFAULT").as[Duration].default(10.seconds)
  val result: Duration = timeDefault.load[IO].unsafeRunSync()
  println(s"Result default 1=${result}")
  println()
}
def exampleDefaultValue2(): Unit = {
  final case class Config(port: Int, tiemout: Option[Duration])
  val config = (
    env("API_PORT").or( prop("api.port") ).as[Int],
    env("API_TIMEOUT").as[Duration].option
  ).parMapN(Config).default{
    Config(8082, 20.seconds.some)
  }
  val result: Config = config.load[IO].unsafeRunSync()
  println(s"Result default 2=${result}")
  println()
}

La salida por consola es la siguiente:

Result default 1=10 seconds
Result default 2=Config(8080,Some(100 milliseconds))
  • Carga de variables de entorno con un secreto

Sea la variable de entorno API_KEY=keyRR01234567890123456789. El siguiente snippet realiza la carga de una variable de entorno que representa un secreto; en este caso, se emplea la función secret la cual retorno un objeto de tipo Secret[String]

def exampleSecrets(): Unit = {
  val apiKey: ConfigValue[Secret[String]] = env("API_KEY").secret
  val resultSecret: Secret[String] = apiKey.load[IO].unsafeRunSync()
  println(s"secret=${resultSecret.value}")
  println()
}

La salida por consola es la siguiente:

secret=keyRR01234567890123456789

Para el lector interesado puede acceder al código a través del siguiente enlace.

Scala 3. Dotty II: tipo unión

En la presente entrada, Scala 3. Dotty II: tipo unión, describiré el tipo Unión. El tipo Unión es parecido al tipo intersección descrito en la entrada anterior. El tipo Unión permite que una determinada instancia sea de un tipo determinado o bien de otro. El tipo unión se representa por el símbolo | y cumple la propiedad conmutativa.

En el siguiente ejemplo, se muestra una función con un argumento de tipo unión.

trait TypeA{
  val elemA: String
}
trait TypeB{
  val elemB: String
}
case class ClassTypeA(elemA: String) extends TypeA
case class ClassTypeB(elemB: String) extends TypeB

def printPretty(arg: ClassTypeA | ClassTypeB): Unit = {
  val value = arg match{
    case ClassTypeA(eA) => eA
    case ClassTypeB(eB) => eB
  }
  println(s" Value argument=$value")
}
object Main{
  def main(args: Array[String]): Unit = {
    val a = ClassTypeA("aa")
    val b = ClassTypeB("bb")
    printPretty(a)
    printPretty(b)
  }
}

El código define lo siguiente: se define dos case class de tipo TypeA y TypeB implementadas en las clases ClassTypeA y ClassTypeB; se define una función printPretty cuyo parámetro puede ser del tipo ClassTypeA o bien ClassTypeB; y, para finalizar, se define un objeto Main cuya función main realiza dos llamadas a la función printPretty con dos parámetros con los posibles tipos definidos.

La salida por consola del código es la siguiente:

Value argument=aa
Value argument=bb

Lo más destacado del código anterior es la función printPretty. La función recibe un argumento cuyo tipo puede ser de los tipos definidos ClassTypeA, o bien, ClassTypeB, en función del tipo de entrada, la función escribirá por consola diferentes valores.

En la siguiente entrada, Dotty III: enumeraciones, describiré el tipo enumeración.

AWS Lambda en Scala. Operaciones con AWS S3

Las tres grandes soluciones utilizadas en el mundo empresarial para definir sistemas cloud son Amazon AWS, Microsoft Azure y Google Cloud. Las tres soluciones permiten la posibilidad de desarrollar arquitecturas serverless la cual se implementan con funciones lambda. En la presente entrada, AWS Lambda en Scala. Operaciones con AWS S3, describiré cómo definir una función lambda en Amazon AWS.

 

 

 

 

 

Definimos arquitectura Serverless como aquella arquitectura que define sistemas con aplicaciones y servicios, con capacidad de ejecución, así como, la posibilidad de crear nuevas aplicaciones y servicios, sin necesidad de administrar infraestructura.

Definimos una función Lambda de AWS como “un un servicio informático que permite ejecutar código sin aprovisionar ni administrar servidores. AWS Lambda ejecuta el código sólo cuando es necesario, y se escala de manera automática, pasando de pocas solicitudes al día a miles por segundo”.

Las funciones Lambda pueden ser definidas en diferentes lenguajes como pueden ser: Java, Python, Node, Scala,… en las diferentes plataformas. Dada la diversidad de plataformas y lenguajes, las soluciones son amplias y diversas. Para unificar funcionalidad ante las plataformas y lenguajes existen frameworks que ofrecen operaciones para simplificar la tarea al desarrollador. Un ejemplo de este tipo de tecnología es el framework Serverless.

El framework Serverless es una herramienta Open Source la cual permite el desarrollo y despliegue de aplicaciones serverless en AWS, Azure, Google y otras más.

Instalación en entorno Linux/Mac

La instalación del framework en un entorno Linux o Mac es muy sencilla, simplemente es necesario ejecutar el siguiente comando desde la línea de comando:

curl -o- -L https://slss.io/install | bash

Para la verificación de la instalación, se ejecuta el siguiente comando:

serverless -h

El resultado del comando anterior deberá de mostrar la información de los comandos del framework.

Descripción funcional de la función de ejemplo

Definiremos una función que opere sobre la solución cloud de Amazon. Desde un punto de vista funcional, la función es sencilla, realizará ciertas operaciones con el servicio S3 de AWS descritas en el siguiente listado:

  • Listado de los bucket existentes.
  • Creación de un bucket en S3.
  • Subida de un fichero a S3.
  • Descarga de un fichero a S3.

Creación de la función con Serverless

Para realizar la creación de una función, utilizaremos el comando create del framework Serverless; para ello, en la consola del sistema, crearemos una carpeta (por ejemplo: serverless-scala-aws-s3) y ejecutaremos el comando create de serverless. El snippet copn los comandos son los siguientes:

cd serverless-scala-aws-s3
serverless create --template aws-scala-sbt --path lambda-s3

El comando create emplea la plantilla para un proyecto en Scala con sbt y define el path a la función. Además de la plantilla del lenguaje Scala, se pueden definir funciones en otros lenguajes como Python, Java, kotlin, Go,…

La vista de la estructura creada desde un IDE es el siguiente:

Los componentes del proyecto son los siguientes:

  • build.sbt Fichero sbt para la gestión del ciclo de vida del código de la función. Al tener que operar con S3 se debe de definir la dependencia de la librería AWScala en la referencia libraryDependencies. Las librerías utilizadas en este proyecto son las siguientes:
libraryDependencies ++= Seq(
  "com.amazonaws" % "aws-lambda-java-events" % "2.2.7",
  "com.amazonaws" % "aws-lambda-java-core" % "1.2.0",
  "com.amazonaws" % "aws-lambda-java-log4j2" % "1.1.0",
  "com.github.seratch" %% "awscala" % "0.8.+"
)
  • Componentes de Scala. La plantilla del framework crea automáticamente cuatro componentes, siendo el más importante el Handler de la función.
    • Handler.- El componente handler define dos clases: Handler, para la función lambda a desarrollar; y, ApiGatewayHandler, para definir la clase para el servicio API Gateway; en nuestro caso, nos centraremos en la clase Handler.La clase Handler define un método handleRequest en el cual desarrollaremos la funcionalidad del ejemplo.
import scala.jdk.CollectionConverters._
class Handler extends RequestHandler[Request, Response] {
  val logger: Logger = LogManager.getLogger(getClass)
  def handleRequest(input: Request, context: Context): Response = {
    implicit val region = Region.US_EAST_1
    implicit val s3 = S3()
    val buckets: Seq[Bucket] = s3.buckets
    logger.info(s"\n1 buckets: $buckets \n")
    val bucket: Bucket = s3.createBucket("prueba2fromlambdafunction")
    logger.info(s"\n2 bucket: $bucket \n")
    // Upload operation of the file example1-file.txt with name example1-uploaded-file.txt
    bucket.put("example1-uploaded-file.txt", new java.io.File("example1-file.txt"))
    val s3obj: Option[S3Object] = bucket.getObject("example1-uploaded-file.txt")
    logger.info(s"\n3 Uploaded: ${s3obj.getOrElse("Empty")} \n")
    logger.info(s"Received a request: $input")
    Response("Go Serverless v1.0!!!!! Your function executed successfully!!", input)
  }
}
    • Request.- Define la clase Request con los parámetros del evento de entrada.
import scala.beans.BeanProperty
class Request(@BeanProperty var key1: String, @BeanProperty var key2: String, @BeanProperty var key3: String) {
  def this() = this("", "", "")
  override def toString: String = s"Request($key1, $key2, $key3)"
}
    • Response.- Define la clase respuesta del tipo de retorno del Handler.
import scala.beans.BeanProperty
case class Response(@BeanProperty message: String, @BeanProperty request: Request)
    • ApiGatewayResponse.- Define la clase de respuesta para el caso de APIGateway.
case class ApiGatewayResponse(@BeanProperty statusCode: Integer, @BeanProperty body: String,
@BeanProperty headers: java.util.Map[String, String], @BeanProperty base64Encoded: Boolean = false)
  • Serverless.yml. El fichero serverless.yml es aquel lugar donde se configura la función para que sea desplegada en AWS. El fichero está compuesto por varias secciones en donde se define las variables, la función, o bien, aquellos recursos necesarios de AWS. Este fichero es el que empleará el framework Serverless para definir la plantilla de CloudFormation para su despliegue en AWS. La secciones son:
    • Service.- Definición del nombre del servicio de la función en AWS.
    • Provider.- definición de las variables internas a AWS.
    • Custom.- Definición de las variables específicas para la función como por ejemplo: nombre del proyecto, región,… proporcionada por los valores definidos en Provider, o bien, desde la línea de comando.
    • Environment.- Definición de las variables de entorno globales.
    • Package.- configuración del paquete a crear para realizar la subida a AWS. Se puede definir qué ficheros incluir o excluir, o bien, el nombre del jar con el que se trabaja.
    • Functions.- definición de la función Scala, definición de la referencia del rol, variables de entorno,…
    • Resources.- definición de los recursos empleados por la función en AWS; en nuestro caso, definición del role y las políticas de seguridad.

El contenido del fichero es el siguiente:

service: lambda-s3
provider:
  name: aws
  project: scalaproject
  runtime: java8
  stage: ${opt:stage, 'dev'}
  region: us-east-1
  timeout: 900
  iamRoleStatements:
    - Effect: Allow
      Action:
        - s3:GetObject
        - s3:PutObject
      Resource:
        - "arn:aws:s3:::prueba2fromlambdafunction/*"
custom:
  currentStage: ${opt:stage, self:provider.stage}
  currentProject: ${self:provider.project}
  currentRegion: ${opt:region, self:provider.region}
environment:

package:
  individually: true
  artifact: target/scala-2.13/lambda-s3.jar

functions:
  lambda-s3:
    handler: app.Handler
    role: LambdaRole
    environment:
      ENV: ${self:custom.currentStage}
resources:
  Resources:
    LambdaRole:
      Type: AWS::IAM::Role
      Properties:
        RoleName: ${self:custom.currentProject}-lambda-s3-${self:custom.currentStage}
          AssumeRolePolicyDocument:
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        Policies:
          - PolicyName: ${self:custom.currentProject}-lambda-s3-${self:custom.currentStage}
            PolicyDocument:
              Statement:
                - Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                    - logs:CreateLogStream
                    - logs:PutLogEvents
                    - s3:*
                  # - ec2:DescribeNetworkInterfaces
                  # - ec2:CreateNetworkInterface
                  # - ec2:DeleteNetworkInterface
                  # - ec2:DescribeInstances
                  # - ec2:AttachNetworkInterface
                 Resource: "*"

De snippet anterior resaltar las líneas comentadas en la definición de los permisos; éstas líneas, corresponden a los permisos que se deben de añadir si se desea que la función Lambda se ejecute en una subred de una VPC determinada.

Ciclo de vida

Configuración del profile de AWS. Para trabajar con AWS es necesario instalar el cliente de AWS y definir las credenciales del usuario para poder realizar las operaciones de despliegue en la cuenta de Amazon.

  • Creación del artefacto. Para realizar el despliegue, es necesario construir el artefacto con los componentes Scala y su ensamblado con las librerías necesarias. El comando SBT a ejecutar en la carpeta de la función es el siguiente:
sbt assembly
  • Despliegue de la función en Amazon AWS. El proceso de despliegue consiste en crear o modificar los recursos en AWS o el código de la función utilizando el stack de cloudformation asociado al fichero serverless.yml. El comando a ejecutar en la carpeta de la función es el siguiente:
serverless deploy -r us-east-1
  • Eliminación de la función. Si se desea eliminar la función se puede eliminar la función y sus recursos asociados con el siguiente:
serverless remove

Librería AWScala

La librería AWScala es aquella librería que permite realizar las operaciones con S3 u otros servicios de AWS. En nuestro caso, nos centraremos en definir las operaciones en S3.

  • Instancia del cliente S3. La creación de un cliente para realizar operaciones con S3 se realiza creando un componente de tipo S3. Dado que la función tiene asociado un role con los permisos de acceso, no es necesario asignar las credenciales. El snippet de ejemplo es el siguiente:
import awscala._, s3._
import awscala.s3._
import awscala.Region
implicit val region = Region.US_EAST_1
implicit val s3 = S3()
  • Listado de los buckets existentes. Una vez creado el cliente, se realiza la conexión al servicio S3 y, con la función buckets, obtenemos una lista con los bucket existentes. El snippet de ejemplo es el siguiente:
val buckets: Seq[Bucket] = s3.buckets
  • Creación de un bucket. De la misma manera que el caso anterior, el cliente S3 tiene una función de creación de bucket cuyo nombre es createBucket al cual se le pasa un nombre único del bucket a crear. El snippet de ejemplo es el siguiente:
val bucket: Bucket = s3.createBucket("prueba2fromlambdafunction")
  • Subir un fichero a S3. Para subir un fichero a S3, el cliente S3 emplea la función put al cual, como primer parámetro, se le pasa el nombre que tendrá en el bucket; y, como segundo parámetro, se le pasa un objeto File con la referencia del fichero. En nuestro caso, existe un fichero de texto example1-file.txt en el proyecto. El snippet de ejemplo es el siguiente:
bucket.put("example1-uploaded-file.txt", new java.io.File("example1-file.txt"))
  • Descarga de un fichero de S3. Para descargar un fichero de S3, el cliente emplea la función getObject a la cual se le pasa como parámetro el nombre del elemento a descargar. El snippet de ejemplo es el siguiente:

val s3obj: Option[S3Object] = bucket.getObject(“example1-uploaded-file.txt”)

Ejecución

Una vez desplegada la función con el comando de serverless deploy, hay que entrar en la consola de AWS y navegaremos hasta la consola de funciones lambda;y,
una vez en la consola, tendremos la referencia a la función. Para las pruebas, crearemos un evento con unos datos de pruebas como los siguientes:

{
"key1": "value1",
"key2": "value2",
"key3": "value3"
}

Para ejecutar la función, pulsaremos el botón de Test en la parte superior derecha; tras la ejecución, se reportará el resultado de la función y se crearán en el cloudwatch las trazas de la función. El aspecto de la consola de AWS con la información de la función es el siguiente:

El resultado de la función en S3 es la creación de un fichero en el bucket prueba2fromlambdafunction. La vista de la consola S3 tras la ejecución de la función es la siguiente:

 

Si el lector está interesado en el código puede acceder al siguiente repositorio de GitHub.

Conclusiones

La selección del lenguaje con el que se opera con AWS depende del equipo de desarrollo ya que, en función del conocimiento de los posibles lenguajes, se seleccionará uno u otro. Desde mi experiencia en los equipos en lod que he trabajado, siempre se ha elegido el lenguaje Python por su sencillez de uso utilizando la librería boto3. Con el presente ejemplo, quiero poner de manifiesto la sencillez con el lenguaje Scala y, dado que estamos construyendo funciones lambda sin infraestructura, utilizar un lenguaje con paradigma funcional permite construir componentes software orientados a la funcionalidad a desarrollar.

Scala 3. Dotty I: Tipos intersección

Inicio una serie de entradas sobre las nuevas características del compilador Dotty el cual representará la versión 3 de Scala que será lanzado en los próximos meses.

Para la realización de los ejemplo, utilizaré el editor de código Scatie. Si el lector está interesado en la descripción del editor, puede acceder al siguiente enlace.

En la presente entrada, me centraré en el operador de intersección de tipos. El operador se representa con el carácter &. La intersección de tipos permite determinar que una instancia puede ser de dos los tipos determinados, es decir, sean dos tipos definidos A y B y una instancia cuya definición de tipo es del tipo A & B, determina que dicha instancia es del tipo A y del tipo A. El operador & es conmutativo con los cual A & B es igual a B & A. A continuación, se muestra el siguiente ejemplo descriptivo:

trait TypeA{
  val elemA: String
}
trait TypeB{
  val elemB: String
}
case class ClassTypeA(elemA: String) extends TypeA
case class ClassTypeB(elemB: String) extends TypeB
case class IntersectionAB(elemA:String, elemB:String) extends TypeA with TypeB

def printPretty(arg: TypeA & TypeB): Unit = println("printPretty:" + arg.elemA + " & " + arg.elemB)
def printPretty2(arg: IntersectionAB): Unit = println("printPretty2:" + arg.elemA + " & " + arg.elemB)

object Main{
  def main(args: Array[String]): Unit = {
    val x = IntersectionAB("aa", "bb")
    printPretty(x)
    println(x)
    val x2 = new IntersectionAB("aa", "bb")
    printPretty(x2)
    println(x2)
    val x3 = new IntersectionAB("aa", "bb")
    printPretty2(x3)
    println(x3)
  }
}

En el ejemplo anterior, se definen dos tipos definidos en los trait TypeA y TypeB; se definen dos case clases de los tipos anteriores cuyos nombres respectivos son ClassTypeA y ClassTypeB; se define una case class IntersectionAB que implementa los tipos anteriores; se define una función printPretty cuyo parámetro es un tipo intersección de los tipos TypeA y TypeB; se define una función printPretty2 cuyo parámetro es del tipo IntersectionAB; y, para finalizar, se define un objeto Main con la función main en donde se instancia objetos intersección y se pasan a las dos funciones de impresión. La funcionalidad de las funciones pretty son sencillas, solo imprimen por consola los tipos pasados por parámetro.

La salida por consola del código es la siguiente:

printPretty:aa & bb
IntersectionAB(aa,bb)
printPretty:aa & bb
IntersectionAB(aa,bb)
printPretty2:aa & bb
IntersectionAB(aa,bb)

Lo más destacado del ejemplo es la función printPretty la cual define un argumento cuyo tipo debe de ser del tipo TypeA y TypeB. Si a la función no se le pasa un objeto con dicha condición, el código no compila.

En la siguiente entrada, Scala 3. Dotty II: uniones de tipo, describeré el tipo unión.