NodeRED II: nodos principales

En la entrada anterior, NodeRED I: Instalación y ejemplo base, realice una breve descripción de NodeRED, cómo levantar NoderRED en un contenedor Docker y un ejemplo básico. En la presente entrada, NodeRED2: nodos principales, describiré cómo usar ciertos nodos en un flujo de trabajo.

Los ejercicios que presento en la entrada son ejercicios sencillos cuyo objetivo es mostrar la funcionalidad de los nodos empleados. Lógicamente, existirán nodos que aparezcan en todos los ejemplos; como por ejemplo: el nodo inyector, para insertar valores e iniciar el flujo; y, el nodo debug, para escribir por consola un valor determinado y así poder depurar un flujo.

Los objetivos de los ejemplos son los siguientes:

  • Ejemplo 1: profundizar en el uso del nodo de inyección.
  • Ejemplo 2: definición de un flujo de trabajo el cual realiza una petición HTTP, trabaja con un fichero CSV y filtra valores para mostrar un mensaje.
  • Ejemplo 3: definición de un flujo de trabajo el cual realiza una bifurcación (nodo switch).

Ejemplo 1: inyector programable.

Para la realización de una inyección de datos, se emplea el nodo de inyección en el cual se define en el objeto msg el valor que se quiere inyectar al flujo; y, para definir el intervalo de repetición, en el campo «Repeat» ubicado en la parte inferior del cuadro, definimos la frecuencia de repetición. En el ejemplo, hemos definido un intervalo de 3 segundos en el cual se inyecta el valor timestamp. El nodo siguiente es un nodo de tipo debug el el cual escribirá en consola en valor timestamp. Desde un punto de vista gráfico, la definición del flujo de trabajo queda representado en la siguiente imagen con la captura de pantalla de NodeRED:

Figura 1: inyección periódica.

Ejemplo 2: tratamiento de CSV

En el siguiente ejemplo, complicamos la funcionalidad y el número de nodos. El objetivo del flujo es el siguiente: realiza la descarga de un fichero CSV mediante una petición HTTP; escribir cada línea del fichero en consola; filtrar los valores del contenido del fichero; modificar el valor filtrado; y, por último, escribir en consola el valor modificado.

Los nodos empleados para realizar el flujo de trabajo son los siguientes:

  • Nodo de red HTTP Request.- Dicho nodo realiza la invocación al servicio HTTP mediante una petición GET a una URL determinada y realiza la definición del tipo de salida.
  • Nodo de parseo CSV.- Dicho nodo realiza el parseo del fichero definiendo el carácter separador de las columnas, el tipo de salida y qué salida define el nodo.
  • Nodo función filter.- Dicho nodo realiza la lectura de un valor de entrada especificado en el objeto msg.paylod.mag y su valor de filtro, en nuestro caso, verifica todos los valores mayores o iguales a 7.
  • Nodo función cambio.- Dicho nodo realiza la modificación de un valor, sustituyendo el valor de entrada correspondiente al campo msg.payload por el string «PANIC!».
  • Nodo función debug.- Dicho nodo realiza la escritura de las trazas de depuración.

Desde un punto de vista gráfico, el flujo de trabajo descrito queda representado en la siguiente captura de pantalla con los elementos y la salida por la consola.

Figura 2: flujo de trabajo de lectura de un CSV con mensaje de error

Ejemplo 3: bifurcaciones en flujos de trabajo

Para finalizar, definimos un flujo de trabajo compuesto de dos flujos. El primer flujo es aquel que trabaja con el nodo función plantilla el cual aplica una plantilla de texto al valor inicial insertado; dicha plantilla con el parseo del valor de entrada, se escribe en la consola de debug. El segundo flujo, define un flujo de trabajo con una bifurcación.

Los nodos empleados para realizar el flujo de trabajo son los siguientes:

  • Nodo función plantilla.- Dicho nodo aplicará una plantilla de texto a los datos de entrada para tener una salida de un tipo determinado.
  • Nodo función switch.- Dicho nodo define las N posibles salida en función de la lógica definida.
  • Nodo función cambio.- Dicho nodo realiza una operación de suma incrementando en 10 el valor de entrada.

Desde un punto de vista gráfico, el flujo de trabajo descrito queda representado en la siguiente captura de pantalla con los elementos y la salida por la consola:

Figura 3: Definición y uso de nodo switch y plantillas.

Con los ejemplos descritos nos permiten realizar una extrapolación del tipo de flujos de trabajo que podemos realizar con NodeRED para interconectar dispositivos y definir flujos de trabajo con una secuencia lógica de operaciones compleja. En las siguiente entradas, seguiré mostrando más ejemplos.

En la siguiente entrada, NodoRed III: nodo función, describiré mediante ejemplos la funcionalidad del nodo función.

NodeRED III: nodo función

En la entrada anterior, NodeRED II: nodos principales, presente el funcionamiento de unos nodos mediante ejemplos. En la presente entrada, NodeRED III: nodo función, me centraré en el nodo función con el cual podremos definir una función definida en código Java Script empleando el nodo Function.

La descripción funcional del conjunto de flujos de trabajo definidos en la entrada son los siguientes:

  • Ejemplo 1: función básica.- Definición de un flujo de trabajo con una función básica.
  • Ejemplo 2: función y sentencias condicionales.- Definición de un flujo de trabajo con una función en donde se emplean sentencias condicionales.
  • Ejemplo 3: función y salidas múltiples.- Definición de un flujo de trabajo con una función de salida múltiple.
  • Ejemplo 4: función y bucles.- Definición de un flujo de trabajo con una función en donde se aplican bucles.
  • Ejemplo 5: función variables de entorno y trazas.- Definición de un flujo de trabajo con una función en donde se trabaja variables de contexto y trazas.
  • Ejemplo 6: función variables de entorno.- Definición de un flujo de trabajo con una función en donde se trabaja con variables de contexto.

Ejemplo 1: función básica.

En el flujo de trabajo del ejemplo 1, se define un nodo inyección en donde se inicia el arranque del flujo; se define un nodo función; y, por último, se define un nodo debug para mostrar por consola el resultado y la trazabilidad.

La función del nodo función flujo define una funcionalidad en el evento «On Message», la funcionalidad definida en este evento es muy sencilla: definición de una variable xyx, definición de una variable newMsg, la cual almacena la longitud del string pasado en el objeto msg, y el retorno de la variable msg. La función realiza la escritura de dos trazas en la consola de log: la primera de tipo warning y la segunda de tipo log.

Desde un punto de vista gráfico, el ejemplo con el código de la función y una ejecución en la consola de NodeRed queda representada en la siguiente imagen de la captura de pantalla del interfaz visual:

Figura 1: definición del flujo y código del ejemplo 1.

Ejemplo 2: función y sentencias condicionales.

En el flujo de trabajo del ejemplo 2, se define un nodo inyección en donde se inicia el arranque del flujo; se define un nodo función; dos nodos template para procesar la salida; y, por último, dos nodos debug para mostrar por consola el resultado y la trazabilidad de la ejecución.

La funcionalidad del nodo función define una funcionalidad en el evento «On Message», la funcionalidad definida en este evento es la siguiente: definición de una variable de tipo lista; una sentencia condicional la cual asigna un valor para cada condición; dos nodos template para parsear la salida; y, por último, los nodos debug para mostrar por consola el resultado.

Desde un punto de vista gráfico, el ejemplo con el código de la función y una ejecución en la consola de NodeRed queda representada en la siguiente imagen de la captura de pantalla del interfaz visual:

Figura 2: definición del flujo y código del ejemplo 2.

Ejemplo 3: función y salidas múltiples.

En el flujo de trabajo del ejemplo 3, se define un nodo inyección en donde se inicia el arranque del flujo; se define un nodo función; dos nodos template para procesar la salida; y, por último, dos nodos debug para mostrar por consola el resultado y la trazabilidad de la ejecución.

La funcionalidad del nodo función define una funcionalidad en el evento «On Message», la funcionalidad definida en este evento es la siguiente: definición de cuatro variables de tipo diccionario y, como salida, retorno una estructura de tipo lista con las variables definidas.

Desde un punto de vista gráfico, el ejemplo con el código de la función y una ejecución en la consola de NodeRed queda representada en la siguiente imagen de la captura de pantalla del interfaz visual:

Figura 3: definición del flujo y código del ejemplo 3.

Ejemplo 4: función y bucles.

En el flujo de trabajo del ejemplo 4, se define un nodo inyección en donde se inicia el arranque del flujo; se define un nodo función; un nodo template para procesar la salida; y, por último, dos nodos debug para mostrar por consola el resultado y la trazabilidad de la ejecución.

La funcionalidad del nodo función define una funcionalidad en el evento «On Message», la funcionalidad definida en este evento es la siguiente: definición de un variable de tipo lista, definición de una variable lista con el contenido del string pasado por parámetro, un bucle que recorre las palabras string y, como salida, retorna la estructura con las palabras.

Desde un punto de vista gráfico, el ejemplo con el código de la función y una ejecución en la consola de NodeRed queda representada en la siguiente imagen de la captura de pantalla del interfaz visual:

Figura 4: definición de flujo y código del ejemplo 4

Ejemplo 5: función variables de entorno y trazas.

En el flujo de trabajo del ejemplo 5, se define un nodo inyección en donde se inicia el arranque del flujo; se define un nodo función; un nodo template para procesar la salida; y, por último, dos nodos debug para mostrar por consola el resultado y la trazabilidad de la ejecución.

La funcionalidad del nodo función define una funcionalidad en el evento «On Message», la funcionalidad definida en este evento es la siguiente: definición de un variable de tipo lista, definición de una variable lista con el contenido del string pasado por parámetro, un bucle que recorre las palabras string y, como salida, retorna la estructura con las palabras. Además, se muestra por consola el valor de la variable de contexto «counter» y se crea una variable global con nombre «varGlobal» la cual se emplea en el flujo del ejemplo 6. En el evento «On Start», se define la variable de contexto «counter» utilizada en la funcionalidad «On Message».

Desde un punto de vista gráfico, el ejemplo con el código de la función y una ejecución en la consola de NodeRed queda representada en la siguiente imagen de la captura de pantalla del interfaz visual:

Figura: definición del evento On Start
Figura 5: definición del flujo y código del ejemplo 5.

Ejemplo 6: función variables de entorno.

En el flujo de trabajo del ejemplo 6, se define un nodo inyección en donde se inicia el arranque del flujo; se define un par de nodos función; un nodo template para procesar la salida; y, por último, un nodo debug para mostrar por consola el resultado y la trazabilidad de la ejecución.

El primer nodo función es prácticamente igual que el nodo del ejemplo 5 salvo que se muestra la variable de contexto «counter», se definen variables múltiples con nombre values y se define la variable «varFlow». En el segundo nodo función, se muestra por consola las trazas de nivel warning, las variables de contexto y de flujo. Por último, el nodo template aplica una plantilla para retornos al siguiente nodo unstring con el valor del atributo payload del objeto msg y la variable varFlow definida en el flujo.

Desde un punto de vista gráfico, el ejemplo con el código de la función y una ejecución en la consola de NodeRed queda representada en la siguiente imagen de la captura de pantalla del interfaz visual:

Figura 6: definición del flujo y el código de la función 6-1.
Figura 7: definición del flujo y el código de la función 6-2.

Los ejemplos mostrados en la entrada permiten mostrar cómo usar nodos función, algunas características básicas del lenguaje Java Script para la definición de funcionalidad en los nodos función y, además, cómo trabajar con diferentes tipos de entrada y salida con nodos tipo función.

En la siguientes entrada, NodeRED IV: mensajes y secuencias, me centraré en la utilización de mensajes y secuencias de mensajes.

NodeRED I: Instalación y ejemplo base

En la presente entrada, NodeRED I: Instalación, realizaré una descripción de la herramienta NodeRed y explicaré los primeros pasos para utilizar NodeRED mediante la definición de un programa básico representado en un flujo NodeRED.

Node-RED es aquella herramienta de programación que permite la conexión de dispositivos hardware, API y servicios online.

El editor de Node-RED está basado en una herramienta gráfica que se accede desde el navegador web La estructura de la interfaz gráfica está compuesta en la parte central por un panel de trabajo; en la parte izquierda, se muestra los diferentes nodos funcionales categorizados por funcionalidades; y, en la parte derecha, un conjunto de pantallas en donde se puede visualizar diferentes paneles de información.

Un programa de NodeRED es aquella estructura visual de un flujo de operaciones compuesta por la unión de nodos la cual realiza una funcionalidad determinada a partir de los datos de entrada definidos en los nodos para tal fin y, como salida, un resultado determinado en los nodos de salida.

Instalación en Docker

NodeRED puede ser instalado en varias plataformas: en una máquina local, en un contenedor docker, en un dispositivo como puede ser una placa Raspberry o bien en un servicio en la nube como AWS o Azure. En el caso que presento, describiré los pasos que he seguido para utilizar NodeRED en un contenedor Docker.

Para realizar la descarga de la imagen de Docker con la instalación de NodeRED, ejecuté el siguiente comando:

docker pull nodered/node-red

Para realizar el arranque de NodeRED con la imagen descargada, ejecutaré el siguiente comando desde la línea de comandos:

docker run -it -p 1880:1880 -v node_red_data:/data --name mynodered nodered/node-red

Una segunda forma para arrancar NodeRED en Docker es identificando el volumen externo del contenedor con una carpeta del sistema de ficheros de la máquina en donde levanta. Para realizar este arranque, identificamos el path de la carpeta de la siguiente forma:

docker run -it -p 1880:1880 -v /path/to/folder:/data --name mynodered nodered/node-red

La descripción de los parámetros del comando anterior son las siguientes:

  • -it.- Modo de arranque del contenedor.
  • -p 1880:1880.- Acceso al interfaz gráfico del contenedor por el puerto 1880.
  • -v node_red_data:/data.- Creación de un volumen de datos a la carpeta de configuración de datos de NodeRED con el nombre /data.
  • –name mynodered.- Asignación del nombre del contenedor con nombre mynodered.
  • nodered/node-red.- Nombre de la imagen docker que se emplea en el contenedor.

Para verificar si el contenedor se ha levantado correctamente, se ejecuta el siguiente comando desde la línea de comandos:

docker container ps -a

Para acceder a la interfaz gráfica del editor de NodeRED una vez arrancado el contenedor, se abre una ventana de un navegador y se utiliza la siguiente URL: localhost:1880. El aspecto visual del editor queda representado en la siguiente imagen:

Figura1: NodeRED interfaz
Figura1: NodeRED interfaz

Ejemplo básico

Para definir un programa en NodeRED, se realiza la definición de un flujo de nodos en el panel de trabajo central con los nodos existentes en la paleta de nodos existentes en la parte izquierda del interfaz. El proceso se realiza seleccionando y arrastrando los nodos y enlazando dichos nodos.

Las categorías de los nodos en NodeRED son las siguientes:

  • Categoría common.- Nodos en donde definen funcionalidades comunes como; por ejemplo: inyección de un valor, operación de debug.
  • Categoría function.- Nodos en donde definen funciones de usuario, funciones de control de flujo,…
  • Categoría network.- Nodos en donde se definen operaciones de red; por ejemplo: peticiones MQTT o bien HTTP, entre otras.
  • Categoría sequences.- Nodos en donde se definen operaciones sobre secuencias de flujo.
  • Categoría parser.- Nodos en donde se definen operaciones de parseo de tipos de datos.
  • Categoría storage.- Nodos en donde se definen operaciones de almacenamiento.

Para desplegar y verificar el correcta definición, es necesario pulsar al botón «Deploy» ubicado en la parte superior derecha.

Un ejemplo básico tipo «Hola Mundo» consiste en inyectar un dato, como por ejemplo, el valor de una fecha determinada; procesar dicho valor en una función y, por último, mostrarlo por la consola debug.

El flujo se compondrá por los siguiente nodos: el primero, un nodo de tipo common de tipo inject el cual inyecta un valor; el segundo, un nodo tipo función el cual, el valor que recibe como parámetro de entrada, lo procesará y retornará para el siguiente nodo; y, por último, un nodo de tipo debug el cual realiza la escritura del valor inyectado.

Nodo inyector

El nodo inyector es aquel nodo que está compuesto por un objeto msg compuesto de dos atributos: payload, con el valor timestamp que se inyecta; y, topic, con el mensaje adicional a inyectar. El aspecto visual del componente es el siguiente:

Figura2: nodo inyector

Nodo Function

El nodo función es aquel nodo el cual gestiona distintos eventos los cuáles pueden ser: evento On Start, funcionalidad que se realiza al inicio del nodo; evento On Message, funcionalidad que se ejecuta al recibir un mensaje; y, evento On Stop, fncionalidad que se realiza al parar el nodo. Todo la funcionalidad se define en Java Script.

En nuestro ejemplo, se define la funcionalidad de evento al recibir un mensaje la cual realiza el tratamiento del valor timestamp recibido y su transformación en tipo String. El aspecto visual del componente es el siguiente:

Figura3: nodo function

Nodo Debug

El nodo debug es aquel que escribe en la consola el campo del mensaje especificado. El aspecto visual del componente es el siguiente:

Figura 4: nodo debug.

Para ejecutar el flujo de trabajo es necesario pulsar en el cuadro izquierdo ubicado en el nodo inject. El aspecto de la salida en la consola del ejemplo descrito es el siguiente:

Figura 5: nodo debug con resultado.

Obtención de la configuración y flujos de trabajo.

La definición de los flujos de trabajo que se definen de forma visual, representados en el fichero flows.json, así como la configuración de NodeRED, representado en el fichero settings.js, se encuentra en la carpeta data del contenedor y en el volumen de Docker. Una forma sencilla para realizar una copia de la carpeta data, es utilizando el comando docker de copiado. Así, para realizar una copia de la carpeta data del contenedor utilizado mynodered a una carpeta determinada, se puede emplear el siguiente comando:

docker cp  mynodered:/data  /your/backup/directory

En las siguiente entrada, NodeRED II: nodos principales, iré profundizando en la definición de flujos y usos de NodeRED.

Broker Mosquitto con MQTTS

Hace un tiempo publiqué una entrada cuyo tema principal era el protocolo MQTT mediante el broker Eclipse Mosquitto. En la entrada de hoy, Broker Mosquitto con MQTTS, describiré cómo utilizar el protocolo MQTT de forma segura, es decir, cómo utilizar el protocolo MQTTS.

El protocolo MQTT es un protocolo muy utilizado en los sistemas IoT y, para conseguir un nivel de seguridad adecuado, es necesario utilizar el protocolo con comunicaciones seguras.

1.-Componentes de seguridad

Para la creación de los certificados, claves privadas y los certificados de solicitud de firma utilizaremos OpenSSL. En los siguientes apartados, realizaré la descripción de los elementos necesarios para nuestro ejemplo de prueba.

Para facilitar la comprensión de los elementos a crear y la configuración del broker, utilizaré una carpeta, con nombre certs, en la cual se ubicarán las siguientes carpetas: carpeta ca, carpeta para almacenar los componentes de seguridad de la autoridad certificadora; carpeta broker, carpeta para almacenar los componentes de seguridad para el broker MQTT; y, por último, la carpeta client, carpeta para almacenar los componentes de seguridad para los clientes, en nuestro caso, los agentes con funcionalidad de publicadores y suscriptores.

La herramienta a utilizar para la creación de los diferentes componentes es OpenSSL la cual se encuentra instalada al menos en los sistemas Linux o Mac de forma predeterminada. Para utilizarla simplemente invocamos el comando openssl desde una consola de comandos.

1.1.- Creación de certificados

La creación de la autoridad de certificados la realizamos con el comando openssl y, para ello, nos ubicamos en la carpeta ~/certs2/ca en una consola de comandos. El comando a utilizar es el siguiente:

openssl req -new -x509 -days 365 -extensions v3_ca -keyout ca.key -out ca.crt

El comando anterior realiza la creación de un certificado definido en el fichero ca.crt y realiza la creación de una clave privada en el fichero ca.key. La validez del certificado es de un año (365 días) desde la fecha de creación. El comando requiere la introducción de unos campos necesarios como son, entre otros: password del certificado, país, ciudad, nombre de la compañía,…

1.2.- Creación de clave privada y certificados para el broker

La creación de las claves y certificados necesarios para el broker la realizamos con el comando openssl y, como en el casa anterior, nos ubicamos en la carpeta creada para ello, carpeta ~/certs2/broker. En este caso, debemos de realizar más operaciones las cuáles son:

  • Generación de una clave privada.

Para la creación de una clave privada RSA para el broker ejecutamos el siguiente comando:

openssl genrsa -out broker.key 2048

El comando anterior realiza la creación de una clave RSA en el fichero broker.key

  • Generación del fichero de solicitud de firmas csr.

Para la creación del fichero para realizar la solicitud de firmas para los certificados se emplea el siguiente comando openssl el cual utiliza la clave privada creada previamente, el comando es el siguiente:

openssl req -out broker.csr -key broker.key -new

La ejecución del comando anterior requiere de la inserción de un conjunto de datos como son, entre otros: país, ciudad y otros datos de identificación; de todos ellos, requiere una especial atención el campo CN Command Name el cual identifica el nombre del dominio en donde se ejecuta el broker, en nuestro caso, el campo CN tiene el valor ‘localhost’.

  • Generación del fichero certificado.

Por último, realizamos la creación del certificado con todos los elementos creados previamente. El comando es el siguiente:

openssl x509 -req -in broker.csr -CA ../ca/ca.crt -CAkey ../ca/ca.key -CAcreateserial -out broker.crt -days 100

El comando anterior realiza un certificado en el fichero broker.crt con una validez de 100 días utilizando el fichero de solicitud de firmas broker.csr y los ficheros de la autoridad certificadora ca.crt y ca.key.

1.3.- Creación de clave privada y certificados para los clientes

La creación de las claves y certificados necesarios para el broker lo realizamos con el comando openssl y, como en el casa anterior, nos ubicamos en la carpeta creada para ello, carpeta ~/certs2/client. En este caso, debemos de realizar más operaciones las cuáles son:

  • Generación de una clave privada.

Para la creación de una clave privada RSA para el cliente ejecutamos el siguiente comando:

openssl genrsa -out client.key 2048

El comando anterior realiza la creación de una clave RSA en el fichero client.key

  • Generación del fichero de solicitud de firmas csr.

Para la creación del fichero para realizar la solicitud de firmas para los certificados se emplea el siguiente comando openssl el cual utiliza la clave privada creada previamente, el comando es el siguiente:

openssl req -out client.csr -key client.key -new

La ejecución del comando anterior requiere de la inserción de un conjunto de datos como son, entre otros: país, ciudad y otros datos de identificación; de todos ellos, requiere una especial atención el campo CN Command Name el cual identifica el nombre del dominio en donde se ejecuta el broker, en nuestro caso, el campo CN tiene el valor ‘localhost’.

  • Generación del fichero certificado.

Por último, realizamos la creación del certificado con todos los elementos creados previamente. El comando es el siguiente:

openssl x509 -req -in client.csr -CA ../ca/ca.crt -CAkey ../ca/ca.key -CAcreateserial -out client.crt -days 100

El comando anterior realiza un certificado en el fichero client.crt con una validez de 100 días utilizando el fichero de solicitud de firmas client.csr y los ficheros de la autoridad certificadora ca.crt y ca.key.

2.- Configuración del broker Mosquitto

La configuración del broker Mosquitto se realiza en el fichero mosquitto.conf ubicado en la carpeta de instalación del mismo. Para poder operar con el broker es necesario que la configuración sea como mínimo la siguiente:

  listener 8883
  certfile /Users/usuario/mosquitto/certs2/broker/broker.crt
  keyfile /Users/usuario/mosquitto/certs2/broker/broker.key
  require_certificate true
  cafile /Users/usuario/mosquitto/certs2/ca/ca.crt
  use_identity_as_username true

3.- Ejemplo de uso

3.1.- Arranque del broker

Para arrancar Mosquitto con la configuración, en un entorno Linux/Mac, el comando a ejecutar es el siguiente:

/usr/local/opt/mosquitto/sbin/mosquitto -c /usr/local/etc/mosquitto/mosquitto.conf

La salida por consola del broker es la siguiente:

Consola del broker Mosquitto

3.2.- Arranque del suscriptor.

Pare simular la recepción de un mensaje utilizaremos la herramienta mosquitto_sub el cual simula la suscripción de un elemento. El comando a utilizar para ejecutar un suscriptor desde línea de comando es el siguiente:

mosquitto_sub -h localhost -t "casa/habitaciones/hab1/luz" --cafile "/Users/E050690/mosquitto/certs2/ca/ca.crt"  --cert "/Users/E050690/mosquitto/certs2/client/client.crt" --key "/Users/E050690/mosquitto/certs2/client/client.key" -p 8883

Los parámetros del comando anterior tienen la siguiente descripción:

  • -h.- Nombre del host donde está funcionando Mosquitto. Es el mismo nombre que el campo CN de los certificados.
  • -t.- Topic del broker donde se conecta el suscriptor.
  • –cafile.- Localización del certificado de la entidad certificadora.
  • –cert.- Localización del certificado del cliente, en nuestro caso, el componente suscriptor.
  • –key.- Localización de la clave privada del cliente, en nuestro caso, el componente suscriptor.
  • -p.- Puerto del protocolo MQTTS.

La consola del suscriptor queda vacía a la espera de los mensajes que realice el publicador/productor.

3.3.- Arranque del publicador.

Para simular el envío de un mensaje utilizaremos la herramienta mosquitto_pub el cual realiza la producción de mensajes a un topic del broker. El comando a utilizar para ejecutar una publicación de un mensaje desde la línea de comandos el el siguiente:

mosquitto_pub -h localhost -t "casa/habitaciones/hab1/luz" --cafile "/Users/E050690/mosquitto/certs2/ca/ca.crt"  --cert "/Users/E050690/mosquitto/certs2/client/client.crt" --key "/Users/E050690/mosquitto/certs2/client/client.key" -p 8883  -m "ON1"

Los parámetros del comando anterior tienen la siguiente descripción:

  • -h.- Nombre del host donde está funcionando Mosquitto. Es el mismo nombre que el campo CN de los certificados.
  • -t.- Topic del broker donde se publica el mensaje.
  • –cafile.- Localización del certificado de la entidad certificadora.
  • –cert.- Localización del certificado del cliente, en nuestro caso, el componente publicador.
  • –key.- Localización de la clave privada del productor, en nuestro caso, el componente publicador.
  • -p.- Puerto del protocolo MQTTS.
  • -m.- Mensaje que se publica en el topic -t

La salida de la consola del broker Mosquitto es la siguiente:

Consola del broker Mosquitto

La salida de la consola del simulador de del suscriptor es la siguiente:

4.- Conclusiones

La complejidad del ejercicio planteado es la creación de los certificados, claves privadas y certificados de petición de firma ya que la configuración y uso del suscriptor, publicador y broker, si se sabe su uso sin certificados, es relativamente sencilla. Un detalle importante es el valor asignado al campo Command Name (CN) de los certificados de firma ya que un valor erróneo puede originar errores de protocolo en las pruebas.

Liquibase

Una de las tareas en todo desarrollo es tener versionado las diferentes versiones de los esquemas de las bases de datos asociados a las versiones del código y su migración de forma automática. Para realizar esta labor, hay varias herramientas; en la presente entrada, me centraré en la herramienta Liquibase.

Liquibase es una solución madura open source para la migración de esquemas de base de datos para desarrollos de aplicaciones. No voy a descubrir una herramienta nueva e innovadora. Mi objetivo en la entrada es realizar unas notas básicas para poder trabajar con ella. Una de las alternativas de Liquibase es Flyway.

Liquibase tiene dos versiones: la versión de la comunidad y la versión enterprise. La versión sobre la que he realizado los ejemplos es con la versión de la comunidad.

Instalación

El proceso de instalación que he realizado es sencillo: he descargado la versión para el sistema de la máquina con la que trabajo (hay versiones para Windows, Linux y Mac), he verificado la versión de la máquina virtual de Java (recomendado la versión 11); y, para finalizar, he descomprimido el fichero; una vez realizado, ya estás en disposición para trabajar con Liquibase.

La estructura de carpetas de Liquibase no es muy amplia. La estructura es la siguiente:

  • Carpeta lib.- Contiene las librería y driver preconfigurados.
  • Carpeta licenses.- Contiene las licencias de Liquibase.
  • Carpeta examples.- Contiene los ejemplos de la documentación.

Además, en la raíz de la carpeta Liquibase, contiene un conjunto de ficheros que van desde ficheros ejecutables a ficheros de texto.

Conceptos

La forma de trabajar con Liquibase consiste en realizar lo siguiente:

  1. Creación de los ficheros con las operaciones de base de datos.
  2. Ejecutar Liquibase con el fichero de la operaciones de base de datos. Evidentemente, con la configuración de una base de datos determinada.
  3. Comprobación en base de datos de los cambios definidos en las operaciones.

El fichero con las operaciones de base de datos se denomina fichero de changelog en el cual se definen las operaciones de base de datos; estas operaciones, representan un changeset. Así, un fichero changelog está compuesto por un conjunto de changeset las cuales representan las operaciones.

Los ficheros changelog pueden estar definidos en varios formatos: JSON, XML, YAML o bien SQL. Los ejemplos de los siguientes apartados los realizaré en formato SQL.

Desde un punto de vista visual los elementos de Liquibase quedan descritos en la siguiente imagen:

Liquibase necesita conocer la base de datos a la que se tiene que conectar así como las credenciales de la misma. Para almacenar esta información, se define un fichero de properties con los datos necesarios para la conexión y la configuración. La ubicación del fichero de propiedades está en la carpeta raíz de Liquibase.

Ejemplos prácticos

Liquibase puede trabajar con varios tipos de base de datos; por ejemplo: H2, MySQL, PostgreSQL, Microsoft SQL Server,… Los ejemplos de los siguientes apartados los realizaré en una base de datos Microsoft SQL Server.

Para trabajar en local, he descargado una imagen Docker con Microsoft SQL Server, he arrancado un contenedor con la imagen descargada y he creado una base de datos de prueba.

El driver de Microsoft SQL Server no está en la instalación de Liquibase con lo cual, es necesario realizar la descarga del driver y el copiado del fichero jar en la carpeta de Liquibase.

Una vez arrancada la base de datos e instalado el driver de la base de datos en Liquibase, necesitamos configurar Liquibase para que se pueda ejecutar los changelog de los ejemplos en la base datos. Para realizar la configuración, crearemos un fichero de propiedades de la carpeta raíz de Liquibase con nombre liquibase.properties. El contenido del fichero es el siguiente:

changeLogFile:<PATH_AL_FICHERO_CHANGELOG>
liquibase.command.url:  jdbc:sqlserver://localhost:1433;databaseName=<NOMBRE_BASE_DE_DATOS>
liquibase.command.username:  sa
liquibase.command.password:  <PASSWORD>
classpath:  mssql-jdbc-10.2.0.jre11.jar

liquibase.hub.mode=off

Ejemplo1: changelog con un fichero

El primer ejemplo consistirá en definir un changelog sencillo formado por un conjunto de changeset en los cuales se define un par de tablas y unas modificaciones sobre las mismas.

El fichero changelog definido es el siguiente:

--liquibase formatted sql

--changeset ams.caso_uso_1:1
create table person (
    id int primary key,
    name varchar(50) not null,
    address1 varchar(50),
    address2 varchar(50),
    city varchar(30)
)
--rollback DROP TABLE person;

--changeset your.name:2
create table company (
    id int primary key,
    name varchar(50) not null,
    address1 varchar(50),
    address2 varchar(50),
    city varchar(30)
)
--rollback DROP TABLE company;

--changeset other.dev:3
alter table person add country varchar(2)
--rollback ALTER TABLE person DROP COLUMN country;


--changeset ams.caso_uso2:1
alter table person add other_field varchar(2)
--rollback ALTER TABLE person DROP COLUMN country;

La primera línea del fichero changelog define el formato del fichero. En nuestro case definimos que el changelog está definido en SQL.

Las siguientes partes del fichero están compuestas por changeset. Los changeset deben de tener definidos un autor y un número de orden, normalmente, un número secuencial para cada autor. Para el primer changeset, el nombre del autor es ams.caso_uso_1 y, el número de orden, lo representa el número uno.

Los changeset, en formato SQL, finalizan con el elemento rollback el cual define la política de rollback para el changelog definido. Este tema se abordará en el apartado de rollback.

Una vez definido el changelog, podemos ejecutar Liquibase para que se realizan los cambios en base de datos. El comando a ejecutar si el fichero changelog está en la carpeta raíz de Liquibase es el siguiente.

./liquibase update

El resultado por consola de comandos es un listado de los changeset ejecutados. Si existe algún error Liquibase no se ejecuta. El resultado en base de datos es el siguiente:

  1. Creación de la tablas DATABASECHANGELOG y DATABASECHANGELOGLOCK. Estas tablas son aquellas tablas en donde Liquibase va almacenando la información de los despliegues. En la tabla DATABASECHANGELOG se almacena el campo autor e id, respectivamente, valores definidos en cada changeset; nombre del fichero del changelog ejecutado, fecha ejecución y otros campos de ejecución.
  2. Creación de las tablas en base de datos.

Una segunda forma de ejecutar Liquibase es pasándole los parámetros de configuración de base de datos desde la línea de comandos.

Ejemplo 2: changelog con N ficheros

Supongamos que tenemos definidos varios ficheros changelog y queremos que estén definidos en nuestra migración porque representan N operativas. La solución consiste en definir un changelog que indique la ubicación de los ficheros de changelog; estos changelog, deben de estar almacenados en una carpeta.

Supongamos que tengamos dos ficheros de changelog en una carpeta sql dentro de Liquibase. Los ficheros son ejemplo2-db-changelog.sqlserver.sql y ejemplo3-db-changelog.sqlserver.sql. El contenido
de los ficheros es análogo al ejemplo anterior.

El fichero changelog necesario con la indicación del conjunto de ficheros sql debe de ser un fichero changelog definido en XML. El contenido del fichero xml con nombre multiple-file-db-changelog-sqlserver.xml es el siguiente:

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
  xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:pro="http://www.liquibase.org/xml/ns/pro"
  xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.4.xsd
      http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-4.5.xsd">

  <includeAll path="sql" />

</databaseChangeLog>

En el snippet anterior se define una etiqueta raíz con nombre databaseChangeLog y, dentro de esta, la etiqueta includeAll la cual indica que en este changelog debe de incluir todo aquello que se encuentra ubicado en el path definido.

En el atributo changeLogFile del fichero liquibase.properties se debe de definir el fichero multiple-file-db-changelog-sqlserver.xml

De la misma manera que el ejemplo anterior , ejecutamos el comando liquibase update para realizar los cambios en base de datos. El resultado por consola y en base de datos sigue el mismo criterio.

Ejemplo 3: changelog con procedimientos almacenados y vistas

Supongamos que queremos definir un procedimiento almacenado o una vista. Con Liquibase, podemos realizarlo si definimos el procedimiento y la vista en un changeset. Un ejemplo de un changeset con un procedimiento y una vista en un changelog determinado es el siguiente:

[...]
-- changeset ams:5
CREATE PROCEDURE prueba.sp_insert_denominador
AS
BEGIN

	INSERT INTO prueba.person
    (id, name, address1, address2, city)
  VALUES
    (1, 'name_test1', 'address_test1', 'address_test1', 'city_test1')

END
--rollback DROP PROCEDURE IF EXISTS prueba.person

-- changeset ams:6
CREATE view prueba.v_person
AS
SELECT
  id as 'Identificador',
  name as 'Nombre',
  address1 as 'Dirección 1',
  address2 as 'Dirección 2',
  city as 'Ciudad'
FROM prueba.person
--rollback DROP VIEW IF EXISTS prueba.v_person
[...]

Para ejecutar los changeset del snippet anterior hay que seguir pasos de ejecución realizados en los ejemplos anteriores.

Creación de tags

Los tags son etiquetas que se definen en un momento determinado para etiquetar qué despliegues se han realizado; por ejemplo, cuando hemos terminado una versión, se puede crear una etiqueta para identificar el punto de la vista de base de datos correspondiente para esa versión.

En Liquibase la creación de una etiqueta es muy sencilla, simplemente hay que ejecutar el comando tag con el número de etiquetado. Un ejemplo puede ser el siguiente:

./liquibase tag --tag=1.0

El resultado en base de datos es la inserción en el campo tag de la tabla DATABASECHANGELOG el valor definido en el atributo tag, en nuestro caso 1.0, correspondiente al registro del último despliegue.

Rollbacks

Un rollback en base de datos consiste en deshacer lo que se ha definido. En una herramienta como Liquibase definir las operaciones de rollback y tener claro las políticas de rollback es un tarea importante y fundamental.

¿Dónde se definen los rollback? Como se ha mostrado en los ejemplos anteriores, los rollback se definen en los changeset de los changelog en la sección –rollback.

¿Hasta qué punto se ejecuta un rollback? Hay dos estrategias, ejecutar el rollback de la última ejecución o bien ejecutar hasta un instante pasado en el tiempo identificado por etiquetas tag.

El comando de ejecución de un rollback hasta un tag, por ejemplo con valor 1.0, es el siguiente:

./liquibase rollback --tag=1.0

El comando de ejecución de un rollback del último despliegue es el siguiente:

./liquibase rollbackCount 1

El resultado del rollback en la tabla DATABASECHANGELOG de la base de datos es la eliminación de los despliegues hasta el tag o bien el último despliegue realizado.

Comandos

Liquibase proporciona otras herramientas que ofrecen información de los despliegues. Estas herramientas lo representan comandos como el comando update. Unas posibles herramientas comunes para ser utilizadas son:

  • history.- Permite visualizar el históricos de changeset ejecutados. Para ejecutar este comando se realiza de la siguiente manera: ./liquibase history.
  • status.- Permite visualizar el estado de los despliegue realizados. Para ejecutar este comando se realiza de la siguiente manera: ./liquibase status.

Liquibase es una herramienta que permite automatizar las migraciones de esquemas de base de datos permitiendo a los desarrolladores tener controlado los esquemas y datos de las base de datos. Además, de ser una herramienta que puede ser integrada en los procesos de integración continua. La elección de Liquibase o herramientas alternativas como Flyway será en función del equipo, del problema a resolver y las tecnologías empleadas.

Decoradores en Python

En la anterior entrada, Closures , defino los closures en Python y muestro ejemplos; en esta entrada, Decoradores en Python, presentaré los decoradores con dos ejemplos.

Según Wikipedia, definimos el patrón Decorator como sigue:

El patrón Decorator responde a la necesidad de añadir dinámicamente funcionalidad a un Objeto. Esto nos permite no tener que crear sucesivas clases que hereden de la primera incorporando la nueva funcionalidad, sino otras que la implementan y se asocian a la primera.

En Python podemos definir clases decoradoras las cuáles añaden funcionalidad a otras clases; pero, cuando hablamos de decoradores en Python, nos referimos a funciones decoradoras que son utilizadas por otras funciones mediante el empleo de anotaciones.

Para comprender el concepto de decorador es necesario entender el concepto de Closures ya que un decorador es un closure al cual se le pasa la referencia de la función sobre la que se ejecuta y, si fuere necesario, parámetros de entrada.

En los siguientes apartados, muestro ejemplos de decoradores: el primero, un decorador básico con un registro de las funciones clientes; y, en el segundo, un decorador al que se le pasan parámetros.

Un decorador está formado por una función que en su interior puede tener la definición de otras funciones. En tiempos de ejecución, al cargar el módulo donde se encuentra la función cliente con la anotación del decorador, se ejecuta el contenido de la primera función del decorador, ejecutándose la función del decorador con la referencia a la función cliente cuando se produce la invocación de la función cliente con el decorador.

Ejemplo básico

Supongamos que queremos cuantificar las funciones decoradoras que son ejecutadas. Para ello, definimos un módulo con una función decoradora y una lista para que almacene las funciones que son ejecutadas. El resultado es el siguiente: módulo my_decorator_ej1.py en el cuál definimos la función decoradora:

#my_decorators_ej1.py
func_registry = []

def my_decorator_ej1(func):

    print(f'Entramos en my_decorator_ej1')

    def inner():
        print('Ejecutamos función (%s)' % func)
        result = func()
        func_registry.append(func)

        return result

    return inner

Supongamos el siguiente módulo cliente que hace uso de la función decoradora definida anteriormente:

#example1.py
from my_decorators_ej1 import my_decorator_ej1, func_registry

@my_decorator_ej1
def function2() -> None:
    print('Entramos en función 2')

@my_decorator_ej1
def function1() -> None:
    print('Entramos en función 1')
    function2()

def run() -> None:
    function1()
    print(f'Funciones ejecutadas...{len(func_registry)}')
    for fun in func_registry:
        print(fun)

Al ejecutar el snippet de código anterior, lo primero que se realizar es la carga del módulo de los decoradores; y, al cargarlos, se escribe por consola el mensaje ‘Entramos en my_decorator_ej1’ de la primera función del decorador; cuando se ejecutan las funciones clientes, se ejecutan las funciones decoradoras.

La salida por consola del ejemplo anterior es la siguiente:

Entramos en my_decorator_ej1
Entramos en my_decorator_ej1
Ejecutamos función (<function function1 at 0x10d40cee0>)
Entramos en función 1
Ejecutamos función (<function function2 at 0x10d40c940>)
Entramos en función 2
Funciones ejecutadas...2
<function function2 at 0x10d40c940>
<function function1 at 0x10d40cee0>

Función decoradora con parámetros

En el siguiente ejemplo vamos a suponer que deseamos mostrar por consola las funciones que son llamadas, los parámetros y su tiempo de ejecución. El patrón del mensaje por consola es un valor por defecto pero este puede ser diferente en función del patrón definido en la anotación del decorador.

La definición del decorador del ejemplo es la siguiente:

#my_decorators_ej1.py
DEFAULT_FMT = '[{end:0.8f}s] {name}({args}) Resultado={result}'
def my_decorator_ej2(fmt=DEFAULT_FMT):
    print(f'Entramos en my_decorator_ej2')

    def decorator(func):

        def print_console(*_args):
            start = time.time()
            _result = func(*_args)
            end = time.time() - start

            name = func.__name__
            result = repr(_result)
            args = ', '.join(repr(arg) for arg in _args)
            print(fmt.format(**locals()))

            return _result

        return print_console

    return decorator

La definición de la clase cliente que usa el módulo de la función decoradora es la siguiente:

from my_decorators_ej1 import my_decorator_ej2

@my_decorator_ej2()
def function1(param: str) -> int:
    print('Ejecuto function1()...')
    return 33

@my_decorator_ej2()
def function2(param: str) -> None:
    print('Ejecuto function2()...')

@my_decorator_ej2('{name}({args}) => {result}')
def function3(param: str) -> int:
    print('Ejecuto function3()...')
    return 66


def run() -> None:
    function1('param1')

    function2('param1')

    function3('param1')

La salida por consola de la ejecución del snippet anterior es la siguiente:

Entramos en my_decorator_ej2
Entramos en my_decorator_ej2
Entramos en my_decorator_ej2
Ejecuto function1()...
[0.00003290s] function1('param1') Resultado=33
Ejecuto function2()...
[0.00002384s] function2('param1') Resultado=None
Ejecuto function3()...
function3('param1') => 66

De la ejecución anterior los primeros mensajes que se muestran en la consola son los correspondientes a los mensajes que se escriben en la primera función del decorador; los siguientes mensajes, son los correspondientes a la ejecución de cada función cliente en función del patrón definido en la anotación del decorador.

Closures

Los closures es un concepto de programación que se encuentra en diferentes lenguajes de programación, como pueden ser: Java, Groovy, Scala, Python,… En la entrada de hoy, Closures, definiré el concepto de closures y mostraré unos ejemplos en lenguaje Python.

Un Closure es aquella función que hereda el contexto de otra función con lo cual permite heredar las variables utilizadas en la primera. No hay que confundir un closures con el concepto de función anónima.

Las funciones closures pueden ser definidas en una clase o bien en funciones pertenecientes a un módulo. El ejemplo típico que mostraré es el cálculo de una media. En los siguientes apartados, mostraré ejemplos con diferentes escenarios.

Closures en una clase

En el presente ejemplo, definiré una clase Averager que define la función __call__ en la cual se define la funcionalidad para el cálculo de la media a partir de los valores que se le pasan por parámetro; y, el almacén de los valores insertados, será una lista que será atributo de la clase. El snippet del código de ejemplo es el siguiente:

class Averager():
    def __init__(self):
        self.series = []

    def __call__(self, new_value):
        self.series.append(new_value)
        total = sum(self.series)
        return total/len(self.series)
        
def __ejemplo1() -> None:
    print(f'-*- Ejemplo de Clouser: cálculo de la media utilizando una clase con método call -*-')
    avg = Averager()
    print(f'avg(10)={avg(10)}')
    print(f'avg(11)={avg(11)}')
    print(f'avg(12)={avg(12)}')

La salida por consola del snippet mostrado es el siguiente:

    -*- Ejemplo de Clouser: cálculo de la media utilizando una clase con método call -*-
    avg(10)=10.0
    avg(11)=10.5
    avg(12)=11.0 

Closures en una función

El cálculo de un closure se puede definir en una función de un módulo con estructura parecida a la de una clase; la diferencia reside en dónde se encuentra el almacén de valores, los cuáles estarán en una función cuyos valores serán heredados por otra función ubicada en un nivel superior, es decir, una función se define dentro de otra función. El snippet del código de ejemplo es el siguiente:

def make_averager():
    series = []

    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total/len(series)

    return averager

def __ejemplo2() -> None:
    print(f'-*- Ejemplo de Clouser: cálculo de la media utilizando un clouser  -*-')
    avg = make_averager()
    print(f'avg(10)={avg(10)}')
    print(f'avg(11)={avg(11)}')
    print(f'avg(12)={avg(12)}')

La salida por consola del snippet mostrado es el siguiente:

-*- Ejemplo de Clouser: cálculo de la media utilizando un clouser  -*-
avg(10)=10.0
avg(11)=10.5
avg(12)=11.0  

Para el cálculo de la media se utiliza las funciones sum y len; y, además, se utiliza una lista con los valores que son insertados en el conjunto de cálculo.

Closures en una función usando el operador nonlocal

El ejemplo anterior puede ser definido de una forma más eficiente, si se va realizando la suma y el conteo de elementos de una forma directa conforme se van añadiendo los elementos. Para realizar este enfoque, hay que definir dos variables count y total en la función de primer nivel; y, en la función de segundo nivel, utilizar el operador nonlocal para identificar que no son variables locales y son variables de la función del nivel superior. El cálculo de la media se realiza con los valores de estas variables eliminando el uso de funciones y el almacén de valores. El snippet del código es el siguiente:

def make_averager2() -> None:
    count = 0
    total = 0

    def averager(new_value):
        nonlocal count, total
        count += 1
        total += new_value
        return total/count

    return averager


def __ejemplo3() -> None:
    print(f'-*- Ejemplo de Couser: utilizando una función con la clausula nonlocal -*-')
    avg = make_averager2()
    print(f'avg(10)={avg(10)}')
    print(f'avg(11)={avg(11)}')
    print(f'avg(12)={avg(12)}')

La salida por consola del snippet mostrado es el siguiente:

-*- Ejemplo de Couser: utilizando una función con la clausula nonlocal -*-
avg(10)=10.0
avg(11)=10.5
avg(12)=11.0 

Como se puede comprobar en los ejemplos, un closure es una definición de un función la cual contiene otra función; y, en está última, se pueden utilizar el contexto de ejecución de la primera.

Este concepto de closures es la base de los decoradores en Python los cuales son aquellas funciones que se ejecutan al invocar a una función.

ZIO IV: modulación por capas

Finalizamos la serie de entradas de ZIO con la presente entrada, ZIO IV: modulación por capas, en la cual presentaré cómo la librería ZIO permite definir módulos funcionales conectados horizontal o verticalmente. Las entradas publicadas hasta la fecha son las siguientes:

El ejemplo práctico ha realizar consistirá en resolver un problema básico de ingeniero de datos del ámbito de BigData. Todo ingeniero de datos debe de dar solución a una ingesta de datos, transformar los datos conforme a unas reglas de negocio y, para finalizar, almacenar o cargar los datos transformados en un data lake o cualquier tipo de almacén de datos; este proceso, se denomina ETL (Extract, Transform and Load). Así, el caso de uso consiste en realizar un proceso de extracción, transformación y carga de datos solicitado por un actor el cual puede ser un sistema o una persona física.

La solución está compuesta por cuatro elementos: el primero, un módulo con la funcionalidad encargada de la extracción de datos; el segundo, el módulo transformador, encargado de transformar los datos en función de unas reglas de negocio; el tercero, el módulo cargador, encargado de realizar la carga de los datos transformados al almacén de datos; y, para finalizar, el elemento coordinador de las operaciones de los módulos el cual contendrá la definición de las secuencias del programa ETL. En el ejemplo, los procesos de extracción y carga son tareas simbólicas ya que el objetivo del ejercicio reside en el desarrollo de los módulos. Desde un punto de vista gráfico la vista de elementos y los intercambios de mensajes entre ellos queda definido en el siguiente diagrama de secuencia UML siguiente:

El diagrama de secuencia anterior define los elementos que intervienen desde un punto de vista del intercambio de mensajes los cuales son: Extractor, el cual realiza las operaciones de extracción con la función extractData(); Transformed, el cual realiza la transformación de los datos con la función doTransformer(); Loader, el cual realiza las operaciones de carga con la función doLoader(); ModuleLayer, el cual contiene el programa que define las operaciones de coordinación del resto de elementos utilizados en la función run(); y, por último, un actor que activa el inicio de las operaciones.

Una vez identificados las entidades abstractas y el intercambio de mensajes, estamos en disposición de profundizar en los elementos físicos que intervienen en la solución y, para ello, emplearemos un diagrama de clases UML para definir la vista estática de la solución. Así, el diagrama de clases con la arquitectura software es la siguiente:

Comenzando en la parte superior del diagrama, tenemos los elementos que definen el extractor. Se define un objeto con nombre Extractor el cual tiene una relación de composición por valor con un trait llamado Service el cual define la operación de extracción con la función extractData; esta función, retorna un elemento de la librería ZIO de tipo IO el cual tiene los siguientes tipos: como valor erróneo, retorna una excepción de tipo ExtractException; y, como retorno de éxito, retorna un ADT de tipo ExtractDataResult. El objeto Extractor tiene un atributo con nombre live el cual define el módulo de ZIO ZLayer asociado a la clase que implementa el servicio ExtractorImpl. Para finalizar, se define un objeto de paquete para enlazar las funciones del objeto Extractor. El snippet del código es el siguiente:

  type Extractor = Has[Extractor.Service]
  object Extractor {
    trait Service {
      def extractData(): IO[ExtractorException, ExtractDataResult]
    }

    case class ExtractorImpl() extends Extractor.Service {
      override def extractData(): IO[ExtractorException, ExtractDataResult] =
        ZIO.succeed(OkExtract(id = 1, name = "Test1", result = true))
    }

    val live: ZLayer[Any, Nothing, Extractor] = ZLayer.succeed(ExtractorImpl())
  }
[...]
 import ModuleLayerExample4Module.Extractor
 package object extractor {
   def extractData = ZIO.accessM[Extractor](_.get.extractData())
 }

Continuando en la parte media del diagrama tenemos los elementos que definen el transformador Transformer. Se define un objeto con nombre Transformer el cual tiene una relación de composición por valor con un trait llamado Service el cual define la operación de transformación con la función doTransformer; esta función, retorna un elemento de la librería ZIO de tipo IO el cual tiene los siguientes tipos: como valor erróneo, retorna una excepción de tipo TransformerException; y, como retorno de éxito, retorna un ADT de tipo TransformedResult. El objeto Transformer tiene un atributo con nombre live el cual define el módulo de ZIO ZLayer asociado a la clase que implementa el servicio TransformedImpl. Para finalizar, se define un objeto para enlazar las funciones del objeto Transformer. El snippet del código es el siguiente:

  type Transformer = Has[Transformer.Service]
  object Transformer {
    trait Service {
      def doTransformer(data: ExtractDataResult): IO[TransformedException, TransformedResult]
    }

    case class TransformerImpl() extends Transformer.Service {
      override def doTransformer(data: ExtractDataResult): IO[TransformedException, TransformedResult] =
        data match {
          case dataIn: OkExtract => ZIO.succeed(OkTransformed(id = dataIn.id, name = dataIn.name, result = true))
          case _                 => ZIO.fail(BasicTransformedException())
        }
    }

    val live: ZLayer[Any, Nothing, Transformer] = ZLayer.succeed(TransformerImpl())
  }
[...]
import ModuleLayerExample4Module.Transformer
package object transformer {
  def transformer(data: ExtractDataResult) = ZIO.accessM[Transformer](_.get.doTransformer(data))
}

En la parte inferior del diagrama tenemos los elementos que definen el cargador Loader. Se define un objeto con nombre Loader el cual tiene una relación de composición por valor con un trait llamado Service el cual define la operación de carga con la función doLoader; esta función, retorna un elemento de la librería ZIO de tipo Task con un ADT de tipo LoaderResult. El tipo Task de ZIO es aquel tipo definido para tareas asíncronas. El objeto Loader tiene un atributo con nombre live el cual define el módulo de ZIO ZLayer asocoado a la clase que implementa el servicio LoaderImpl. Para finalizar, se define un objeto de paquete para enlazar las funciones del objeto Transformer. El snippet del código es el siguiente:

  type Loader = Has[Loader.Service]
  object Loader {
    trait Service {
      def doLoader(data: TransformedResult): Task[LoaderResult]
    }

    case class LoaderImpl() extends Loader.Service {
      override def doLoader(data: TransformedResult): Task[LoaderResult] =
        data match {
          case dataIn: OkTransformed =>
            ZIO.fromFuture(implicit ec => loaderData(dataIn)).mapError(msg => new ErrorLoaderException())
          case _ => ZIO.fail(BasicLoaderException())
        }
    }

    val live: ZLayer[Any, Nothing, Loader] = ZLayer.succeed(LoaderImpl())
  }
[...]
import ModuleLayerExample4Module.Loader
package object loader {
  def loader(data: TransformedResult) = ZIO.accessM[Loader](_.get.doLoader(data))
}

En la parte izquierda del diagrama, se define el módulo controlador ModuleLayerExample4 con el cual declaramos el programa con las definiciones de las operaciones del proceso ETL. El snippet del módulo es el siguiente:

  type Services = Extractor with Transformer with Loader with Logging

  // Log layer
  val envLog =
    Logging.console(
      logLevel = LogLevel.Info,
      format = LogFormat.ColoredLogFormat()
    ) >>> Logging.withRootLoggerName("ModuleLayerExample4")

  val appEnvironment = envLog >+> Extractor.live >+> Transformer.live >+> Loader.live

  def program(): ZIO[Services, Throwable, Boolean] = {
    (for {
      _               <- log.info("[START]")
      dataExtracted   <- extractData
      _               <- log.info(s"[extrated done] data = ${dataExtracted}")
      dataTransformed <- transformer(dataExtracted)
      _               <- log.info(s"[transformed done] data = ${dataTransformed}")
      dataLoaded      <- loader(dataTransformed).catchAllCause(cause => log.info(s"Exception Loader=${cause.prettyPrint}"))
      _               <- log.info(s"[loaded done] data = ${dataLoaded}")
      _               <- log.info(s"[END]")

    } yield { true }) orElse ZIO.succeed(false)

  }

  override def run(args: List[String]): URIO[ZEnv, ExitCode] = {
    (program()
      .catchAllCause(cause => putStrLn(s"Exception=${cause.prettyPrint}"))
      .exitCode)
      .provideCustomLayer(appEnvironment)

  }

Lo primero que se define es el tipo Services el cual contiene las funciones a utilizar; en nuestro caso, definimos un tipo con un conjunto de tipos: Extractor, Transforamer, Loader y Logging definidos previamente. El objetivo de este tipo es definir todas aquellas funciones que estarán disponibles en el programa a declarar, en nuestro caso, el programa que declara la funcionalidad del proceso ETL, así, podremos «inyectar» al programa las funciones que necesitemos.

A continuación, se define la referencia al log y al entorno de ejecución del programa, es decir, define aquellos elementos que contienen las implementaciones de las funciones a utilizar.

Para finalizar se define la función que contiene el programa con las operaciones de la ETL. La función retorna un tipo ZIO con la siguiente composición: como entorno de ejecución, tiene un tipo de tipo Services; como tipo de retorno de error define un tipo Throwable; y, como tipo de resultado de éxito, retorna un tipo Boolean.

Dado que el módulo ModuleLayerExample4 es un objeto de la clase zio.App se debe de definir e implementar la función run() la cual realiza la invocación del la función del programa ETL suministrando las capas de los módulos definidas en el elemento appEnvironment.

Al lector interesado puede acceder al código en el siguiente enlace.

La utilización de la librería ZIO permite tener programas modulares, declarativos y seguros. No tenemos que preocuparnos de realizar una inyección de dependencias sino que hay que definir conjunto de tipos con la funcionalidad necesarias la cual utilizaremos en los programas; y, sobre todo, aclarar el proceso de diseño y desarrollo ya que permite definir los componentes o módulos que intervienen en la solución y sus relaciones. Una vez que se tienen claros los módulos y las firmas de los métodos nos permite sin haber desarrollado cada función una estructura de la solución final.

ZIO III: testing

Continuamos con al serie de la librería ZIO. En la entrada que estamos tratando, ZIO III: testing, me centraré en la definición de test. Las entradas publicadas hasta la fechas son las siguientes:

Los ejemplos mostrados en las entradas anteriores, se han realizado utilizando aserciones de test de prueba o bien mediante código no definido en un test. Para definir test y aserciones claras y concisas, definiremos unos patrones y ejemplos en los siguientes apartados, lo cuáles son:

  • Ejemplo de plantillas.
  • Generación de propiedades en los test.
  • Ejemplo de aserciones.

1.- Ejemplo de plantillas

Las pruebas unitarias tienen que ser categorizadas por funcionalidad y, para conseguir categorias funciones de test, empleamos la función suite. La función suite permite definir pruebas agrupados por una funcionalidad a probar. El conjunto de todas las agrupaciones forman las pruebas de una entidad.

Un requerimiento para la definición de test es que cada clase de test debe de heredar de la clase DefaultRunnableSpec la cual proporciona todos los módulos de ZIO; como por ejemplo: Clock o Random. Un ejemplo de test es el descrito en la siguiente entrada:

import zio.test._
import zio.clock.nanoTime
import Assertion._

import zio.test.DefaultRunnableSpec

object TemplateZioTest extends DefaultRunnableSpec {

    val suite1 = suite("suite1")(
      testM("s1.t1") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) },
      testM("s1.t2") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) }
    )

    val suite2 = suite("suite2")(
      testM("s2.t1") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) },
      testM("s2.t2") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) },
      testM("s2.t3") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) }
    )

    val suite3 = suite("suite3")(
      testM("s3.t1") { assertM(nanoTime)(isGreaterThanEqualTo(0L)) }
    )

    def spec = suite("All test")(suite1, suite2, suite3)

}

2.- Generación de propiedades en los test.

En cierto tipo de test requerimos de datos para ejecutar las pruebas. Los datos pueden ser generados de forma automática por generadores los cuales pueden generar datos primitivos, case class o bien objetos. La entidad para la generación de datos es la entidad Gen definida en zio.test.Gen.

Un requisito fundamental es la necesidad de utilizar el módulo Random con Sized en la definición de los generadores.
Las dependencias de los módulos de los ejemplos es el siguiente:

import zio.test.Assertion.{equalTo, isTrue}
import zio.test.{DefaultRunnableSpec, Gen, Sized, assert, check, suite, testM}
import zio.random.Random
import zio.test.magnolia._
  • Ejemplo de generación de tipos primitivos.

Para la generación de tipos primitivos invocaremos a la función anyXXX, siendo XXX un tipo primitivo, en la definición de test. Un ejemplo de uso de generadores primitivos es el que se define en el siguiente snippet.

testM("Gen Int") {
   check(Gen.anyInt, Gen.anyInt, Gen.anyInt) { (x, y, z) =>
     assert((x + y) + z)(equalTo(x + (y + z)))
   }
},
  • Ejemplo de generación de una case class.

Sea una case class que represente una entidad con nombre Point. Para poder definir una generador de la clase Point, utilizamos la entidad DeriveGen cuyo tipo sea la case class Point. La definición de la clase y el generador de la clase Point es la siguiente:

final case class Point(x: Double, y: Double) {
   def isValid(): Boolean = true
}
val genPoint: Gen[Random with Sized, Point] = DeriveGen[Point]

Para definir el test de la entidad Point con su generador utilizaremos la función check como se muestra en el siguiente ejemplo:

testM("Gen Point") {
  check(genPoint) { (point) =>
     assert(point.isValid())(equalTo(true))
   }
},
  • Ejemplo de generación de objetos.

De la misma manera que el caso anterior para definir un generador de unos objetos a partir de un trait, se realiza de la misma manera. En el siguiente ejemplo, se define el test en donde se utiliza un generador de objetos basados en la definición de un trait:

sealed trait Color {
  def isValid(): Boolean = true
}
case object Red   extends Color
case object Green extends Color
case object Blue  extends Color
val genColor: Gen[Random with Sized, Color] = DeriveGen[Color]

testM("Gen Color") {
   check(genColor) { (color: Color) =>
      assert(color.isValid())(isTrue)
   }
}

3.- Ejemplo de aserciones.

La capacidad de poder verificar todo tipo de dato en una prueba permite definir con más exactitud la ejecución de una prueba. En ZIO empleamos las funciones definidas en la entidad zio.test.Assertion; como pueden ser: equalTo, hasField, isRight,etc…

A continuación, muestro unos ejemplos de pruebas con diferentes tipos de aserciones:

  • Ejemplo de un String.

Supongamos que necesitamos verificar el resultado de un efecto cuyo resultado es un String y, del valor del resultado,
necesitamos verificar que contenga un determinado valor y finalice con otro. La aserción la realizamos empleando la función assert y las funciones containsString y endsWithString de la siguiente manera:

testM("Assertion examples: string") {
  for {
    word <- IO.succeed("The StringTest")
  } yield {
    assert(word)(
       Assertion.containsString("StringTest") &&
          Assertion.endsWithString("Test")
     )
   }
},
  • Ejemplo de un Either.

Supongamos que necesitamos verificar el resultado de un efecto cuyo resultado es un Either. El esquema del test es parecido al anterior pero empleando funciones específicas para el contenedor binario. El ejemplo del snippet es el siguiente:

testM("Assertion examples: either") {
  for {
     either <- IO.succeed(Right(Some(2)))
  } yield {
     assert(either)(isRight(isSome(equalTo(2))))
  }
},
  • Ejemplo de una case class.

Supongamos que necesitamos verificar el resultado de un efecto que retorna una entidad definida en una case class. El esquema del test es como los anteriores pero utilizando la función hasField para acceder a los atributos de la entidad. El ejemplo del snippet es el siguiente:

testM("Assertion examples: case class") {
   final case class Address(country: String, city: String)
   final case class User(name: String, age: Int, address: Address)

   for {
      test <- IO.succeed(User("Nat", 25, Address("France", "Paris")))
   } yield {
      assert(test)(
        hasField("age", (u: User) => u.age, isGreaterThanEqualTo(18)) &&
          hasField("country", (u: User) => u.address.country, not(equalTo("USA")))
      )
   }
},

En la siguiente entrada, ZIO IV: modularización, me centraré en la definición de módulos funcionales.

ZIO II: manejo de errores y recursos

En la entrada anterior, ZIO I: presentación, presenté la librería ZIO y ejemplos con la creación de efectos y operaciones básicas. En la presente entrada, ZIO II: manejo de errores y recursos, describiré cómo podemos manejar errores en la ejecución de efectos con ZIO y el manejo de recursos.

ZIO

La estructura de la entrada está compuesta de los siguientes apartados:

  1. Manejo de errores.
  2. Manejo de recursos.

1.- Manejo de errores

Dada la definición de un efecto en ZIO, sabemos cómo proporcionar el entorno y ejecutar dicho efecto; pero, tenemos que dar respuesta a la siguiente pregunta: ¿cómo podemos realizar el control de la ejecución si se produce un error en la ejecución del efecto? La respuesta es sencilla, el control del efecto se realiza capturando y controlando las excepciones que se puedan originar, así como, si se produce un error tener la posibilidad de poder volver a ejecutar el efecto.

  • Tratamiento de error con el contenedor binario Either.

La primera estrategia es empleando un contenedor binario Either mediante la función either en la cual podemos tener los siguientes valores: en Left, el valor de error; o bien en right, el resultado correcto. Este primer ejemplo es el más sencillo porque se asemeja al control de errores de una función.

El ejemplo más básico de la definición de un ejemplo es el siguiente:

val zeither: UIO[Either[String, Int]] = IO.fail("Boom!").either
val result: Either[String, Int]       = Runtime.default.unsafeRun(zeither)
assertResult(Left("Boom!"))(result)
  • Tratamiento de error en un efecto con el tipo explícito.

Supongamos que tenemos un efecto cuyo posible error lo conocemos; supongamos que el efecto, es la lectura de un fichero y, como conocemos, el error en el tratamiento de un fichero es la generación de una excepción de tipo IOException. La solución consiste en la definición de un efecto en el que definamos el tipo de error y su resultado; en concreto, la solución consiste en definir un efecto cuyo tipo de error es una excepción de tipo IOException y su resultado es un tipo List[String] definiendo un tipo UIO[IOException, List[String]].

En el siguiente ejemplo, se define una función que realiza la lectura de un fichero mediante un efecto de tipo UIO, su ejecución y verificación de tratamiento.

def readFile(nameFile: String): UIO[List[String]] = {
  IO.succeed(Source.fromFile(nameFile).getLines().toList)
}
val readFileResult: IO[IOException, List[String]] = readFile(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String]                = Runtime.default.unsafeRun(readFileResult)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)
  • Tratamiento de errores con la función catchAll.

Supongamos que realizamos la lectura de un fichero y queremos capturar todas las posibles excepciones que se puedan producir; para este escenario, utilizamos la función catchAll definida en ZIO. En el siguiente ejemplo, realizamos la lectura de las líneas de un fichero cuyo nombre es pasado por parámetro y, con la función catchAll, capturamos todas las excepciones. Si se produce una excepción entonces realizamos la lectura de un fichero cuyos datos son valores por defecto. El snippet de ejemplo es el siguiente:

 def readFileCatchAll(nameFile: String): Task[List[String]] = {
   ZIO(Source.fromFile(nameFile).getLines().toList).catchAll {
     case _ => {
       val uriFile = this.getClass.getClassLoader.getResource("default.data").toURI
       readFile(uriFile.getPath)
     }
   }
 }

 val readFileOK: Task[List[String]] = readFileCatchAll(getURIFileTest(nameFile).getPath)
 val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
 assert(resultReadFileOK.isEmpty === false)
 assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

 val readFileKO: Task[List[String]] = readFileCatchAll("errorFile.data")
 val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
 assert(resultReadFileKO.isEmpty === false)
 assertResult(List("OK"))(resultReadFileKO)
  • Tratamiento de un error con la función catchSome.

Supongamos que queremos capturar un tipo determinado de excepción, en este supuesto utilizamos la función catchSome. En el siguiente ejemplo, se muestra el mismo ejemplo del apartado anterior pero realizando el tratamiento para la excepción FileNotFoundException. El snippet del ejemplo es el siguiente:

 def readFileOrDefault(nameFile: String): Task[List[String]] = {
   ZIO(Source.fromFile(nameFile).getLines().toList).catchSome {
     case _: FileNotFoundException => {
       val uriFile = this.getClass.getClassLoader.getResource("default.data").toURI
       readFile(uriFile.getPath)
     }
   }
 }

val readFileOK: Task[List[String]] = readFileOrDefault(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: Task[List[String]] = readFileOrDefault("errorFile.data")
val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)
  • Ejecución de un efecto alternativo con la función orElse.

Supongamos que queremos ejecutar un efecto y, suponiendo que se produzca un error en el efecto, deseamos que se ejecute un efecto secundario; para este supuesto, utilizamos la función orElse. En el siguiente snippet de código se muestra el ejemplo con la función orElse.

def readFileFallback(nameFile: String): Task[List[String]] = {
   ZIO(Source.fromFile(nameFile).getLines().toList).orElse {
     val uriFile = this.getClass.getClassLoader.getResource("default.data").toURI
     readFile(uriFile.getPath)
    }
}

val readFileOK: Task[List[String]] = readFileFallback(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: Task[List[String]] = readFileFallback("errorFile.data")
val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)
  • Tratamiento de un efecto de forma no pura.

Supongamos que queremos retornar el resultado y no realizar un tratamiento específico, es decir, si el efecto se ejecuta sin problemas retornamos el resultado; pero, si se produce un error retornamos un resultado del tipo esperado; para este supuesto, utilizamos la función fold. En el siguiente snippet de código se muestra el ejemplo con la función fold:

def readFileFold(nameFile: String): Task[List[String]] = {
  ZIO(Source.fromFile(nameFile).getLines().toList).fold(_ => List("OK"), data => data)
}

val readFileOK: Task[List[String]] = readFileFold(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: Task[List[String]] = readFileFold("errorFile.data")
val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)
  • Tratamiento de un efecto de forma pura.

El caso contrario al ejemplo anterior es definir un efecto para el caso de éxito y caso de error mediante la función foldM. En el siguiente snippet de código se muestra el ejemplo con la función foldM.

def readFileFoldM(nameFile: String): Task[List[String]] = {
  ZIO(Source.fromFile(nameFile).getLines().toList)
    .foldM(_ => ZIO.succeed(List("OK")), data => ZIO.succeed(data))
}

val readFileOK: Task[List[String]] = readFileFoldM(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String] = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: Task[List[String]] = readFileFoldM("errorFile.data")
val resultReadFileKO: List[String] = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)
  • Tratamiento con reintento de ejecución.

Supongamos que queremos reintentar ejecutar un efecto si se produce un error un número determinado de veces y,
si dado ese número de reintentos no tenemos éxito, capturar la excepción y retornar un efecto con un resultado por defecto; para este caso, utilizamos la función retry para definir un número de reintentos con un Schedule y la función catchAll. El snippet de código con el ejemplo es el siguiente:

import zio.clock.Clock
  [...]
  def readFileRetrying(nameFile: String): ZIO[Clock, Throwable, List[String]] = {
    ZIO(Source.fromFile(nameFile).getLines().toList)
      .retry(Schedule.recurs(5))
      .catchAll { case _ =>
        ZIO.succeed(List("OK"))
      }
}

val readFileOK: ZIO[Clock, Throwable, List[String]] = readFileRetrying(getURIFileTest(nameFile).getPath)
val resultReadFileOK: List[String]                  = Runtime.default.unsafeRun(readFileOK)
assert(resultReadFileOK.isEmpty === false)
assertResult(List("1 2 3", "4 5 6"))(resultReadFileOK)

val readFileKO: ZIO[Clock, Throwable, List[String]] = readFileRetrying("errorFile.data")
val resultReadFileKO: List[String]                  = Runtime.default.unsafeRun(readFileKO)
assert(resultReadFileKO.isEmpty === false)
assertResult(List("OK"))(resultReadFileKO)

Para el lector interesado en el código de los ejemplos, puede acceder al mismo a través del siguiente enlace.

2.- Manejo de recursos

Para el manejo de recursos es necesario definir un patrón estructural basado en la estructura try/finally. Supongamos que definimos un efecto y, una vez que finaliza su ejecución, queremos ejecutar un segundo efecto de finalización; para ello, utilizamos la función ensuring. Un ejemplo de patron try/finally con efectos en ZIO es el siguiente:

val finalizer2: UIO[Unit] = UIO.effectTotal(println("finally"))
val operation: UIO[Unit] = IO.succeed(println("Finalizing 2!")).ensuring(finalizer2)
val resultOperation      = Runtime.default.unsafeRun(operation)
assertResult(())(resultOperation)

Otra forma de aplicar el patrón try/finally es utilizando la función bracket en la cual se realiza una adquisición de un recurso, una tratamiento y un cierre de recurso. Un ejemplo de utilización de función bracket con un fichero es el siguiente:

def readFileBracket(nameFile: String): Task[List[String]] =
  UIO(Source.fromFile(nameFile)).bracket(bufferedSource => UIO(bufferedSource.close())) { file =>
    UIO(file.getLines().toList)
  }

val file: Task[List[String]] = readFileBracket(getURIFileTest(nameFile).getPath)
val resultFile               = Runtime.default.unsafeRun(file)
assertResult(List("1 2 3", "4 5 6"))(resultFile)

Para el lector interesado en el código de los ejemplos, puede acceder al mismo a través del siguiente enlace.

En el siguiente ejemplo, ZIO III: testing, describiré unos patrones para la realización de test con ZIO.