Skip to content

Apache Kafka (2)

Continuando con la serie dedicada a Apache Kafka, en este artículo se revisa el proceso de instalación y algunas de las tareas básicas que pueden realizarse para familiarizarse con el software. La documentación es bastante efectiva a este respecto, por lo que este artículo se limita a seguir paso por paso el guión propuesto en la misma. El objetivo es instalar y verificar el funcionamiento de Kakfa de forma local en una máquina Windows partiendo de cero y sin utilizar Docker.

Descarga

La versión actual de Kafka en el momento de escribir este artículo es la 2.0.0. Puede descargarse desde la propia página de descargas oficial del proyecto. El descargable es un fichero comprimido en formato .tgz que puede descomprimirse en cualquier directorio.

Instalación

El software descomprimido se distribuye en cuatro directorios. El directorio bin contiene scripts para ser ejecutados desde línea de comandos, tanto para Windows en formato .bat como para UNIX en formato .sh. El directorio config contiene los ficheros de configuración en formato .properties. El directorio libs contiene las dependencias en forma de librerías de Java en formato .jar. Y el directorio site-docs contiene toda la documentación comprimida en formato .tgz.

El único requerimiento para ejecutar Kafka es tener instalada una máquina virtual de Java 1.8 o superior. Con tenerla disponible en el PATH de la máquina es suficiente.

Arranque

Kafka utiliza Zookeeper, por lo que es necesario tener una instancia de este software arrancada. El propio Kafka proporciona el ejecutable y un script para arrancar una instancia preconfigurada con valores por defecto:

Una vez arrancado Zookeeper se puede arrancar Kafka con otro script y valores por defecto:

Si todo se ejecuta de forma correcta se mostrará en ambos casos el log de ejecución de los procesos por consola.

Cada fichero .properties pasado como parámetro en la línea de comandos contiene los valores de configuración utilizados por defecto para los servidores. Cada entrada de cada fichero está documentada con bastante detalle y en su conjunto resulta sencillo de entender. La documentación proporciona un listado exhaustivo con todos los parámetros de configuración disponibles.

Uno de los parámetros importantes dentro de la configuración es el que establece el directorio donde se almacena la información. Por defecto su valor es /tmp/kafka-logs. Al arrancar el servidor se puede comprobar cómo se crea el directorio y ficheros dentro del mismo.

Tópico

Para crear tópicos se puede utilizar el script kafka-topics  desde línea de comandos con el parámetro --create. Por ejemplo, para crear un tópico de nombre “prueba” con una partición se puede ejecutar la siguiente línea:

Si todo se ejecuta de forma correcta, se mostrará un mensaje confirmando la creación:

Adicionalmente se puede comprobar con el mismo script y el parámetro --list, que lista los tópicos disponibles:

Tal y como indica la documentación, Kafka se puede configurar para crear los tópicos de forma automática si no existen en vez de tener que crearlos de forma manual.

Para los más curiosos del lugar, comentar que estos scripts en realidad se limitan a invocar una clase Java que se encuentra dentro de libs\kafka_<versión de Scala>-<versión de Kafka>.jar.

Publicación/Suscripción

Para publicar mensajes en un tópico se puede utilizar un script que abre un cliente que captura la entrada de teclado y publica las líneas tecleadas como mensajes:

Para suscribirse a un tópico y recibir los mensajes publicados se puede ejecutar otro cliente que se queda a la espera y muestra por pantalla todos los mensajes recibidos:

Si se ejecuta varias veces el último script se puede comprobar que los mensajes son persistidos y que se pueden volver a procesar tantas veces como se quiera.

Como nota al margen, comentar que si se ejecutan los scripts sin parámetros se muestra un texto de ayuda con todas las opciones disponibles.

Cluster

Un ejercicio un poco más sofisticado es configurar Kafka como un clúster. Para el ejemplo se utilizan tres servidores ejecutándose sobre una misma máquina de manera local.

El primer paso es crear un fichero .properties para cada servidor, tomando como punto de partida el fichero por defecto utilizado para arrancar el primer servidor.

El segundo paso es modificar los ficheros para configurar un identificador, puertos y directorio distintos. Para ello hay que editarlos manualmente y sustituir las siguientes entradas:

  • En config/server-1.properties:

  • En config/server-2.properties:

Y el tercer paso es arrancar dos nuevos servidores utilizando los nuevos ficheros de configuración:

A partir de aquí ya se puede empezar a probar el funcionamiento. Por ejemplo, creando un tópico con un factor de replicación de 3:

Comprobando que efectivamente está replicado:

Publicando mensajes:

Matando el proceso del servidor líder:

Y comprobando que los mensajes siguen disponibles a través de un seguidor que ahora se comporta como lider:

Connectors

El Connector API de Kafka permite importar y exportar datos desde diversas fuentes externas. Por ejemplo, desde un fichero de texto plano, o desde el commit log de una base de datos relacional. Es decir, que se puede configurar Kafka para que monitorice un recurso y a medida que se añada información en él se replique en tiempo real en un tópico. O alimentar en tiempo real un recurso a partir de los mensajes publicados en un tópico.

Para el ejemplo demostrativo de esta funcionalidad se configura el cluster del ejemplo anterior para monitorizar el contenido de un fichero de texto plano de nombre test.txt, publicar su contenido a medida que se le añade información en un tópico de nombre “connect-test”, y generar otro fichero de nombre test.sink.txt con la información publicada en el tópico.

El primer paso es crear el fichero de texto test.txt con un par de líneas en el directorio raíz de la instalación:

El segundo paso es instalar los conectores, que son los componentes software que implementan todo el proceso. Se crean dos. El primero para monitorizar el fichero de entrada, y el segundo para generar el fichero de salida:

Y el tercer y último paso es comprobar que el fichero de salida contiene las líneas del fichero de entrada:

Si se abre el fichero de entrada y se añade otra línea, automáticamente se replicará en el fichero de salida. Opcionalmente se puede comprobar que cada línea es publicada como un mensaje en el tópico:

Notar que disponer de los mensajes publicados en un tópico permite además que puedan ser consumidos por más procesos.

Apache Kafka (1)

Cuando se pulsa una tecla en el teclado de un ordenador se produce una interrupción. Lo que quiere decir que el ordenador debe dejar de hacer lo que estaba haciendo para atender dicha interrupción. El sistema operativo recoge el carácter correspondiente a la tecla pulsada y lo notifica a las aplicaciones. Esto permite por ejemplo que una aplicación web pueda indicarle a un navegador que ejecute un determinado trozo de código JavaScript cuando se produzca la pulsación de una tecla.

El término interrupción se utiliza habitualmente para referirse a la acción que sucede a bajo nivel. En un nivel más alto de abstracción estas acciones se conocen como eventos. La mayoría de aplicaciones procesan eventos tales como la pulsación de una tecla o un botón de un ratón de manera local. Algunas además los reenvían a un servidor remoto. El análisis de estos eventos permite conocer de forma muy precisa como interaccionan los usuarios con una aplicación.

Enviar eventos de teclado o ratón a un servidor remoto implica generar una gran cantidad de tráfico. Aunque no por ello deja de ser una réplica del modelo de más bajo nivel. Una aplicación notifica eventos a un servidor de igual forma que un teclado o ratón los notifica a un ordenador. Lo que se puede generalizar diciendo que todo sistema de información puede entenderse como un conjunto de procesos que se comunican generando y respondiendo a eventos. La tecnología o estrategia utilizada para procesar la información puede variar, pero hay patrones recurrentes. Utilizar algún tipo de almacén intermedio para evitar la pérdida de eventos es uno de ellos. El buffer que utiliza un teclado para almacenar las últimas teclas pulsadas es similar al que utiliza un servidor para almacenar los eventos recibidos. Y en este sentido, Apache Kafka puede verse como uno de estos almacenes de eventos.

Kafka

Apache Kafka es una plataforma altamente escalable, creada originalmente por LinkedIn, que facilita el proceso de ingentes cantidades de información en tiempo real. Su caso de uso más habitual es su utilización como sistema de mensajería. Donde la publicación de un mensaje por parte de un proceso representa un evento que se produce en el sistema, conteniendo el mensaje el detalle concreto del evento generado, y permitiendo que una aplicación lo procese con objeto de consumirlo completamente, o lo transforme con objeto de volver a publicarlo como parte de una cadena de procesamiento mayor.

La versión actual de Kafka es la 2.0.0, una major release liberada a finales de julio de este mismo año.

La introducción de tan bajo nivel de los primeros párrafos es para tratar de responder las preguntas habituales acerca de para qué sirve Kafka y cuando se debe utilizar. Se podría utilizar siempre, para procesar todos los eventos generados por un sistema de cualquier tamaño, pero se debe utilizar preferentemente cuando el volumen de información a gestionar, sobre todo en tiempo real, sea enorme. El rendimiento y la escalabilidad son dos de los factores clave que deberían tenerse en cuenta a la hora de tomar la decisión de utilizarlo.

Persistencia

Kafka está diseñado para almacenar y distribuir grandes volúmenes de eventos de forma muy eficiente. Una característica que lo distingue del resto de productos similares es que almacena los eventos de forma duradera. Una aplicación puede suscribirse para recibir los nuevos eventos que se produzcan, o recibir los eventos que se produjeron en un periodo de tiempo concreto dado. Esta característica permite ejecutar procesos sobre eventos generados en el pasado como si se estuvieran produciendo en tiempo real. Y posibilita la repetición a posteriori en caso de error de un mismo proceso con los mismos eventos.

En este punto es importante entender la visión que ofrece Kafka respecto a los eventos. Son las secuencias de información que representan la historia del proceso realizado por un sistema. Almacenando el histórico de eventos consumidos y producidos por un sistema se puede reproducir el proceso realizado por el mismo. Los eventos se pueden consumir en tiempo real, por ejemplo para responder de forma inmediata a las peticiones realizadas por un cliente a través de una aplicación, pero también pueden procesarse con una menor prioridad, por ejemplo para agregar información de cara a su posterior análisis con algún tipo de herramienta a modo de cuadro de mandos. Con esta visión en mente, es fácil entender que Kafka se denomine a sí mismo “structured commit log”, es decir, un sistema de log que almacena los mensajes de forma estructurada para facilitar el tratamiento de los mismos, a diferencia de un log tradicional donde los mensajes son cadenas de texto añadidos en un fichero sin ninguna estructura.

Estructura

Kafka escala horizontalmente gracias a su arquitectura distribuida en forma de cluster con una alta tolerancia a fallos basada en Zookeeper. Un cluster de Kafka almacena información organizada en categorías llamadas tópicos e identificadas por un nombre. Donde cada tópico es un almacén de secuencias ordenadas de una unidad mínima de información denominado registro. Y cada registro es un paquete de información compuesto de una clave, un valor y un timestamp.

Los tópicos se organizan internamente en estructuras llamadas particiones. Estas particiones son similares a las existentes en muchas bases de datos relacionales, permitiendo almacenar los registros agrupados por algún criterio determinado con objeto de mejorar los tiempos de acceso. Los clientes que producen registros eligen la partición en la que deben publicarse estos. Cuando se publica un registro en un tópico se almacena en una partición y se le asigna un offset que indica su posición dentro de la partición. Un offset es un número secuencial único que identifica de forma única a un registro. Una característica de Kafka a este respecto es que los clientes que consumen registros son los que llevan el control de los offsets; cuando se subscriben a un tópico indican el offset del primer registro que quieren recibir. El control lo lleva el cliente, no el servidor, lo que permite simplificar al máximo la lógica del servidor. De hecho, Kafka gestiona los offsets de los consumidores con un tópico, lo que le evita tener que mantener estructuras privadas dedicadas y le permite reutilizar toda la infraestructura existente.

Los registros son persistidos en los tópicos en base a una política de retención configurable. Los registros pueden almacenarse durante horas, días, o indefinidamente según cada caso de uso concreto. Kafka garantiza un tiempo de respuesta constante independientemente del número de registros almacenados.

Todas las lecturas y escrituras sobre una determinada partición son realizadas por un único servidor denominado bróker y que actúa como líder. Por cada líder pueden existir otros brokers actuando como seguidores que replican la partición de forma pasiva. Si el líder de una partición cae entonces uno de sus seguidores se promociona automáticamente a líder haciéndose cargo de gestionar la partición. Incluso es posible replicar un mismo cluster en varios datacenters, ya sea como mecanismo de backup, o con el propósito de tener la información distribuida por regiones geográficas distintas.

Grupos de Consumidores

Un concepto importante en Kafka son los denominados grupos de consumidores. Cada grupo de consumidores está ligado a un tópico, y cada consumidor dentro de un grupo está ligado a una partición. Si todos los consumidores de un tópico pertenecen al mismo grupo entonces los registros se distribuyen de forma que se garantiza que un mismo registro es consumido una única vez por un único consumidor del grupo. Si los consumidores pertenecen a varios grupos entonces un mismo registro se distribuye a un único consumidor de cada grupo.

Esto permite configurar Kafka siguiendo los patrones habituales utilizados en los sistemas de mensajería clásicos. Si se utiliza un único grupo entonces el sistema se comporta como una cola, donde un mensaje es consumido por único proceso. Si se utilizan múltiples grupos entonces el sistema se comporta como un sistema de publicación/suscripción, donde un mensaje es consumido por varios procesos. Tener un único consumidor por cada partición garantiza que los registros se consumen en el mismo orden que se publican, pero implica que Kafka sólo garantiza el orden a nivel de partición, no de tópico.

API

Kafka está escrito en Scala y Java, aunque existen clientes para prácticamente todos los lenguajes de programación de uso más habitual. El cliente para Java es desarrollado por el propio proyecto de forma oficial.

Kafka expone su funcionalidad a través de un protocolo sobre TCP mediante el que oferta servicios agrupados en base a su cometido. Cada grupo de servicios es un API distinto. Cinco de ellos constituyen el núcleo del sistema. Producer API permite publicar registros en los tópicos. Consumer API permite suscribirse a los tópicos y consumir registros. Streams API permite consumir registros de tópicos para volver a publicarlos transformados en otros tópicos. Connector API permite construir pasarelas entre Kafka y otros sistemas, como por ejemplo una base de datos relacional. Y AdminClient API que permite gestionar los distintos componentes que conforman una instancia o cluster de Kafka.

Para el caso de uso de Kafka como un sistema de procesamiento de streams, además del API, existe un proyecto llamado KSQL que se liberó este mismo año y permite escribir procesos que consuman streams con un lenguaje similar a SQL.

Resumiendo, Kafka es una plataforma que aúna distintas características que permiten utilizarlo como un sistema de almacenamiento, un sistema de mensajería, y un sistema de procesamiento de streams muy eficiente y altamente escalable.

dartis (y 2)

En este artículo se continúa la revisión de las características ofrecidas por dartis, un cliente para Redis escrito en Dart que publiqué hace unos días.

Pub/Sub

Redis permite que los clientes entren en un modo de funcionamiento especial en el que se implementa el patrón Publish/Subscribe. En este modo los clientes pueden suscribirse a canales y recibir mensajes a través de ellos. Es decir, que Redis se comporta como un sistema de mensajería. En este modo los clientes sólo pueden ejecutar los comandos SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PING y QUIT.

El servidor envía, tanto las respuestas a las suscripciones a canales, como los propios mensajes publicados en dichos canales, en un stream de eventos con una estructura de mensajes distinta a la que se recibe cuando el cliente se encuentra operando en el modo normal, por lo que se debe conocer en todo momento en que modo se encuentra trabajando el cliente para poder procesar correctamente las respuestas recibidas del servidor.

La conexión a este modo de funcionamiento se realiza de forma explícita en dartis a través de una factoría estática. Y los eventos recibidos se publican a través de un Stream.

Tener una clase especializada para implementar este modo permite reducir la complejidad del código. Máxime cuando Redis sólo permite a los clientes en este modo ejecutar un conjunto de comandos muy concretos y salir terminando la conexión con el servidor. Aunque impide que el cliente ejecute otros comandos antes de entrar en este modo, como por ejemplo validarse contra el servidor utilizando una clave.

Monitor

Redis permite también que los clientes trabajen en modo monitor. En este modo no pueden ejecutar ningún comando, se limitan a recibir mensajes del servidor. Los mensajes recibidos contienen un texto con detalles acerca de todos los comandos ejecutados por todos los clientes contra el servidor. Es decir, que cada vez que un cliente ejecuta un comando, el servidor envía un mensaje informando de tal acción a todos los clientes que se encuentran en modo monitor. Esto resulta de utilidad para depurar aplicaciones de forma no intrusiva.

La conexión a este modo de funcionamiento se realiza de forma explícita en dartis a través de una factoría estática. Y los eventos recibidos se publican a través de un Stream.

De igual forma que en el modo del apartado anterior, disponer de una clase especializada reduce la complejidad de la implementación, pero no permite ejecutar ningún comando previo a la entrada en este modo.

Inline Commands

Redis permite la ejecución de comandos sin tener que implementar el protocolo RESP. En este modo los comandos se envían como cadenas de texto plano al servidor. Es similar a como se haría a través de una sesión Telnet, escribiendo directamente en una consola de texto.

Las respuestas recibidas desde el servidor en este modo están formateadas siguiendo el protocolo RESP, por lo que este modo es adecuado para comprobar la respuesta exacta del servidor ante un determinado comando, evitando cualquier tipo de agente intermedio que la procese o altere de alguna forma.

Transacciones

Redis permite ejecutar varios comandos dentro del contexto de una transacción. Aunque no es una acción de “todo o nada” como habitualmente se sobreentiende que funciona una transacción con la mayoría de software existente.

Una transacción empieza en Redis ejecutando el comando MULTI y termina ejecutando el comando EXEC. Todos los comandos que se ejecutan después de MULTI se encolan y se ejecutan cuando se llama a EXEC. Si se produce un error encolando un comando la transacción se aborta en el momento que se llama a EXEC, no en el momento que se produce el error encolando el comando. Y si se produce un error durante la ejecución de un comando encolado se continúa con el siguiente comando encolado, no se produce un rollback de lo ejecutado hasta el momento.

EXEC retorna el resultado de la ejecución de todos los comandos ejecutados en el contexto de la transacción, tanto los terminados con éxito como los terminados con error, para dar la oportunidad al cliente de actuar en función del resultado individual de cada comando.

Con este patrón no se puede utilizar await  sobre cada comando, ya que el Future de cada comando no se completa hasta que se obtiene la respuesta del servidor, algo que no ocurre hasta ejecutar EXEC.

Una transacción iniciada con MULTI puede abortarse ejecutando el command DISCARD antes de llamar a EXEC. El comando DISCARD termina la transacción en curso descartando todos los comandos encolados hasta el momento.

Por último, comentar que en el contexto de una transacción no se debe utilizar el comando CLIENT REPLY, ya que en determinados casos el cliente puede perder la sincronía con el servidor. Es por ello que las transacciones en Redis están documentadas como una funcionalidad a ser deprecada, prefiriéndose el uso de scripts en Lua en el servidor.

Scripts Lua

Redis permite ejecutar scripts en Lua en el servidor. Esta característica es ofrecida a través de comandos, por lo que no requiere ninguna implementación especial por parte del cliente.

La única característica particular a tener en cuenta con esta funcionalidad es que la salida de la ejecución de un script puede ser de cualquier tipo. Lo mismo puede resultar en una primitiva, como una cadena de texto o un entero, o un array de valores heterogéneos. Para lidiar con esta situación, dartis admite un parámetro que permite mapear la respuesta del servidor en cualquier tipo que se quiera.

En el ejemplo del código anterior, el mapper convierte la respuesta del servidor en una lista de cadenas de texto utilizando las facilidades que ofrece dartis para crear serializadores y deserializadores personalizados.

Serializadores/Deserializadores

dartis tiene un sistema de conversores dinámico que permite añadir conversores nuevos o sobreescribir los existentes. Un conversor es una clase que convierte una primitiva en una lista de bytes de cara a ser enviado al servidor, y convierte una lista de respuestas del servidor o bytes en una primitiva de cara a que un cliente pueda utilizarla en una aplicación.

Por defecto hay registrados conversores para los tipos de uso más habitual como enteros, decimales, cadenas de texto y listas. Utilizando UTF-8 por defecto para las cadenas de texto.

Los conversores que transforman primitivas en listas de bytes se denominan codificadores, y son clases que se crean extendiendo de Converter o Encoder. Por ejemplo, el siguiente código muestra un conversor que transforma instancias del tipo DateTime de Dart en listas de bytes.

Por su parte, los conversores que transforman listas de respuestas del servidor o bytes en una primitiva se denominan decodificadores, y son clases que se crean extendiendo de Converter o Decoder. Por ejemplo, el siguiente código muestra un conversor que transforma una respuesta del servidor en una instancia del tipo DateTime de Dart.

Todos los codificadores se registran utilizando el atributo codec del cliente.

Permitir definir tipos personalizados facilita trabajar de forma sencilla con los comandos propios que pueda definir cualquier módulo para Redis. Siendo un módulo una extensión que se puede añadir a un servidor Redis, a modo de plugin, y que dartis soporta de manera natural.

Y estas son básicamente las principales características de la librería. En el propio repositorio del proyecto puede encontrarse más documentación y ejemplos de uso.

Para terminar, comentar que el proyecto está desarrollado utilizando Visual Studio Code como IDE con tan sólo un par de extensiones. EditorConfig para garantizar la uniformidad del formato de los ficheros, y Dart Code para la integración de las herramientas del SDK, en particular el formateador de código y el analizador de código estático. Travis como servidor de integración continua y Coveralls para el análisis de la cobertura del código. El proyecto tiene implementadas unos trescientos casos de prueba, incluyendo tanto pruebas unitarias como de integración, con un noventa por ciento de cobertura.

dartis (1)

Hace un par de días liberé dartis, un cliente para Redis escrito en Dart. Empecé a desarrollarlo para probar algunas de las novedades de la versión 2 de Dart y al final he acabado con un cliente sencillo pero bastante completo.

RESP

REdis Serialization Protocol (RESP) es el protocolo que implementa Redis para comunicar los clientes con el servidor. Es un protocolo muy básico sobre TCP, con tan sólo cinco tipos de paquetes distintos. Los mensajes son todos de tipo texto, con un primer carácter identificador del tipo de paquete, seguido de una longitud en algunos casos, a continuación el contenido del mensaje en sí mismo, y un par de caracteres de terminación.

El hecho de que sea un protocolo de tipo texto tiene la ventaja de que resulta fácil de implementar y depurar, pero tiene el inconveniente de que hay que convertir entre binario y texto continuamente, en particular las longitudes de los mensajes. En principio he optado por utilizar las facilidades del propio lenguaje para las conversiones, sin realizar ninguna medición del rendimiento, pero podría estudiarse la posibilidad de hacer una implementación más específica.

Por otra parte, debido a que los paquetes no contienen ningún tipo de identificador, es necesario guardar la secuencia de cada comando enviado de forma ordenada y casarla contra las respuestas recibidas en el mismo orden. Con el hándicap de que es posible deshabilitar las respuestas del servidor, por lo que en algunos casos los paquetes han de enviarse sin esperar respuesta. En determinadas circunstancias muy concretas el protocolo no impide que se produzcan pérdidas de sincronía entre cliente y servidor.

Conexión

La conexión al servidor se realiza siguiendo el típico patrón consistente en utilizar un método estático en la clase del cliente a modo de factoría.

Como parámetro se admite una cadena de conexión siguiendo el formato clásico de protocolo, host y puerto. Lo que posibilita una configuración muy sencilla y ampliable.
Redis permite trabajar con varias bases de datos dentro de una misma instancia, pero esta forma de trabajo no está totalmente soportada cuando se utiliza una instalación de Redis en cluster, por lo que es mejor no utilizar esta característica si no es absolutamente necesaria. Resulta más sencillo levantar más instancias.

Redis permite proteger de forma opcional con una clave los accesos al servidor, pero normalmente las instancias de Redis no se encuentran abiertas al mundo exterior, sino como dentro de una DMZ, por lo que no suele utilizarse, aunque ello depende en gran medida de las políticas de seguridad de cada cual.

Algunos puntos abiertos en la implementación de las conexiones con el servidor son la posibilidad de indicar un timeout, realizar reconexiones automáticas en caso de pérdida de conexión, y un pool de conexiones.

Comandos

Un cliente le comunica a un servidor Redis lo que quiere hacer a través de comandos. Y existen más de 200 comandos distintos. dartis implementa una interface genérica que permite ejecutar cualquier tipo de comando, a la vez que ofrece una interface más específica que expone todos los comandos ofrecidos por Redis de forma fuertemente tipada.

dartis funciona de forma totalmente asíncrona, por lo que todos los comandos retornan un Future que se completa cuando el servidor retorna el resultado.

Además, el conjunto de comandos está expuesto utilizando Generics, lo que permite que cada aplicación que utilice la librería pueda decidir el tipo concreto de datos que quiere utilizar, en vez de limitarse a utilizar String como hacen otras librerías.

Redis almacena secuencias de bytes, no cadenas de textos, por lo que puede utilizarse para gestionar cualquier tipo de información.

Los comandos están expuestos en forma de vista sobre el cliente, es decir, es la misma instancia pero exponiendo sólo una interface sobre la misma, por lo que pueden obtenerse distintas vistas del mismo cliente para trabajar con distintos tipos de datos.

Es seguro trabajar con varias vistas a un mismo tiempo, ya que todas utilizan el mismo cliente.

Pipelining

Redis permite enviar una al servidor una serie de comandos a un mismo tiempo, en vez de uno a uno. Aunque en la práctica esta es más una característica propia de las comunicaciones que de Redis. El cliente puede enviar más de un comando dentro de un mismo paquete TCP. Y el servidor puede procesar paquetes TCP que contengan más de un comando.
Por defecto dartis sólo envía un comando en cada paquete TCP, deshabilitando de forma explícita el algoritmo de Nagle sobre el socket, para garantizar que cada comando se envía de forma inmediata al servidor, pero permite activar el pipelining llamando al método pipeline:

En buena lógica, con este patrón no se puede utilizar await sobre cada comando, ya que el Future de cada comando no se completa hasta que se obtiene respuesta del servidor, y los comandos no se envían al servidor hasta que se vacía el pipeline con la llamada al método flush.

Otra opción es utilizar la lista de Futures retornada por el método flush para esperar hasta que se completen todos los comandos.

Esta forma de trabajo se utiliza sobre todo para realizar cargas masivas de información o clientes con casos de uso concretos que requieren un mayor rendimiento.

Fire And Forget

Redis permite que los clientes indiquen al servidor que no quieren recibir respuestas del servidor. Es decir, se pueden enviar comandos para su ejecución y desentenderse del resultado (fire and forget).

Esta es una característica que Redis ofrece a través del comando CLIENT REPLY. El comando necesita un parámetro que admite el valor OFF para deshabilitar todas las respuestas, SKIP para deshabilitar sólo la respuesta del comando siguiente, y ON para habilitar todas las respuestas.

En este modo los comandos se completan de forma inmediata con el valor null cuando las respuestas del servidor están deshabilitadas.

La implementación de esta característica no es compleja, pero requiere que el cliente detecte de manera explícita que se está ejecutando el comando CLIENT REPLY, y llevar la cuenta de para que comandos se debe esperar respuesta y para cuáles no. Si el propio comando CLIENT REPLY es el que falla entonces puede llegar a perderse la sincronía entre servidor y cliente, aunque se presupone una casuística con una probabilidad muy baja de que suceda.

MongoDB: Hands on!

MongoDB es una base de datos NoSQL distribuida y escalable que gestiona objetos almacenados en un formato llamado BSON. Un formato inspirado en el formato JSON, pero que almacena la información de forma binaria. Es habitual pensar en los objetos almacenados en MongoDB, llamados documentos, como objetos en formato JSON.

Una de las características más significativas de MongoDB es que es una base de datos de esquema dinámico, es decir, que no fuerza un esquema fijo predefinido como las base de datos relacionales. Cada documento (objeto JSON) puede tener los campos y tipos que se quieran. Aunque si realmente se necesita, es posible forzar unas reglas de validación siguiendo la especificación JSON Schema.

MongoDB permite almacenar los documentos agrupados en colecciones, equivalente a las tablas del modelo relacional, y realizar consultas sobre estas colecciones. Estas consultas pueden realizarse sobre cualquier campo de los documentos de una manera eficiente, ya que es posible crear índices, tanto sobre campos de primer nivel, como campos de objetos JSON anidados, e incluso arrays. Además, es posible realizar consultas agregadas sobre cualquier campo, a la manera de una cláusula GROUP BY en SQL. MongoDB da una gran importancia a las agregaciones, permitiendo incluso ejecutar varias agregaciones en paralelo con una única consulta.

Instalación

La instalación de MongoDB puede realizarse en local descargando un fichero comprimido, o alternativamente usando una imagen de Docker.

En Windows el ejecutable del servidor es mongod.exe, y para arrancarlo hay que pasarle el parámetro --dbpath con el nombre de un directorio existente. El cliente de línea de comandos es mongo.exe, y al arrancarlo se conectará automáticamente al servidor local por defecto.

El cliente GUI oficial de MongoDB se llama Compass, disponible a través de suscripción o de una versión Community que requiere registro. Como alternativa pueden encontrarse distintos clientes no oficiales, desarrollados tanto por empresas como particulares en régimen de código abierto. Uno de los clientes más populares es Robomongo, que fue adquirido por una empresa llamada 3T Software Labs, y le ha cambiado el nombre a la herramienta por el de Robo 3T. Su versión gratuita portable para Windows no requiere instalación, basta con descomprimir el fichero descargado en un directorio y lanzar el ejecutable.

En lo que sigue se entenderá que los ejemplos se ejecutan directamente sobre el cliente de línea de comandos mongo.exe contra una base de datos local. En la práctica se puede utilizar cualquier cliente contra cualquier servidor, ya sea local o proporcionado por un tercero. La propia MongoDB proporciona instancias en la nube a través de su servicio Atlas.

Comandos

MongoDB ofrece una extensa lista de comandos organizados en los siguientes grupos:

  • Conexiones
  • Bases de Datos
  • Gestión de Usuarios
  • Gestión de Roles
  • Colecciones
  • Cursores
  • Construcción de Objetos
  • Actualización Masiva de Datos
  • Planes de Ejecución de Consultas
  • Réplica
  • Balanceado de Carga
  • Monitorización
  • Invocación de Comandos Nativos

La lista de comandos es demasiado extensa como para examinarlos todos, por lo que es imprescindible revisar la documentación oficial que constituye la referencia más actualizada que se puede encontrar al respecto.

Como es habitual, los comandos que más se usan son los de acceso de datos, es decir, los de lectura y escritura sobre colecciones. En cualquier caso, la secuencia de creación de una base de datos y una colección es tan sencilla como la ejecución de los siguientes comandos:

La sintaxis del shell por defecto es JavaScript y resulta sencilla de entender. En las dos líneas anteriores lo único que resulta conveniente aclarar es que el objeto global db del segundo comando referencia automáticamente a la base de datos seleccionada en el primer comando. Si la base de datos no existe se instancia en el momento que se crea una colección.

CRUD

Los documentos (objetos JSON) se insertan y recuperan en la colecciones siguiendo la sintaxis de JSON:

Como se observa, MongoDB añade automáticamente a los documentos el atributo _id  con un UUID autogenerado a modo de clave primaria. Opcionalmente se puede suministrar este valor desde el cliente, pero en la mayoría de casos esta opción no se utiliza.

Donde MongoDB se sale un poco del tiesto con la nomenclatura es en las modificaciones. Por defecto la base de datos trabaja con documentos completos, no parciales, por lo que para modificar o eliminar un atributo es necesario reescribir el documento completo. Para evitar dicho trasiego de información, MongoDB permite indicar lo que denomina operadores de actualización (update operators) en los comandos de modificación de documentos:

Como se observa, los operadores empiezan por el símbolo del dolar, e indican la operación concreta a realizar sobre los atributos. $set añade un nuevo atributo al documento, $incr incrementa el valor de un atributo, y existen otros como  $unset que elimina un atributo. El mismo concepto se utiliza para realizar filtros con condiciones lógicas, aplicando operadores con nombres bastantes descriptivos como $and y $or por citar un par de ellos. Leer la documentación para hacerse una idea de las opciones disponibles es imprescindible.

El borrado de documentos es trivial utilizando un filtro:

Índices

MongoDB crea automáticamente un índice por el campo _id de todas las colecciones, y permite además definir índices sobre otros campos para mejorar el rendimiento de las consultas más habituales:

El valor 1 pasado como parámetro en la operación indica que se quiere que el orden del índice sea ascendente. Un valor -1 indicaría que se quiere que sea descendente.

En la práctica MongoDB permite crear una gran variedad de índices en función de los elementos sobre los que se definan:

  • Sobre un único campo
  • Sobre múltiples campos
  • Sobre los elementos de un campo de tipo array
  • Sobre campos de coordenadas geoespaciales
  • Sobre campos de tipo string para búsquedas de texto
  • Sobre colecciones distribuidas por varios servidores
  • Sobre un conjunto filtrado de documentos de una colección
  • Sobre documentos que tengan un determinado campo
  • Sobre documentos que expiran transcurrido un tiempo

Interesante comentar en este punto que MongoDB permite obtener el plan de ejecución de una consulta, incluyendo los índices utilizados:

Joins

Para recuperar dos documentos relacionados, que se encuentren en dos colecciones distintas, es necesario indicar el nombre de las dos colecciones y el nombre de los dos campos que las relacionan:

Como se observa, MongoDB trata esta operación como una agregación en vez de una consulta simple, y es en el parámetro $lookup donde se indican los nombres y colecciones sobre los que realizar el join, así como el nombre del objeto sobre el que proyectar el resultado.

MongoDB trata todos los joins como “left outer joins“, por lo que las consultas siempre retornan resultado incluso cuando no puede satisfacerse el join.

Agregaciones

Las agregaciones en MongoDB son un tipo de consultas que van más allá de la simple lectura de uno o varios documentos de una colección. De hecho, MongoDB las considera un framework en si mismo, una alternativa al patrón map-reduce.

Las agregaciones se construyen como una secuencia de operaciones, denominada pipeline, que se ejecuta sobre una colección. Cada una de las operaciones, denominadas stages, generan un resultado o modifican el resultado de la operación anterior.

Como se observa, el parámetro de entrada es un array de objetos (pipeline). Cada objeto es una operación (stage). Y MongoDB las ejecuta de forma secuencial, por lo que el orden es importante. Se puede empezar con una operación e ir añadiendo operaciones hasta que se obtenga el resultado deseado. El tipo de operaciones que se puede realizar es bastante extenso, por lo que es recomendable consultar la documentación de referencia para hacerse una idea de todas las opciones disponibles.

Las operaciones más comunes son $match para filtrar, $sort para ordenar, $group para agrupar, $count para contar ocurrencias, $skip para ignorar elementos, y $limit  para limitar el tamaño del resultado. Otras operaciones interesantes son por ejemplo $unwind para extraer elementos de los arrays embebidos en los documentos, $facet para calcular varias agregaciones en paralelo, y $graphLookup para hacer búsquedas recursivas.

Otros

Para terminar, comentar que MongoDB permite definir colecciones con un tamaño máximo prefijado que se caracterizan por ofrecer un rendimiento muy eficiente. Cuando el número de documentos insertados en dichas colecciones supera dicho máximo los documentos más antiguos se sobreescriben por los nuevos. Si se realiza una consulta sin especificar ningún orden se garantiza que los documentos se retornarán en el mismo orden en que se insertaron, pudiéndose indicar también que se retornen en el orden inverso. Este tipo de colecciones permiten además crear cursores sobre los últimos N elementos de los mismos, como el comando tail de UNIX, por lo que pueden utilizarse para almacenar documentos como si fueran entradas en un log.

Además, ofrece la posiblidad de definir un tiempo de expiración para los documentos, de forma que cuando se supere dicho tiempo el documento será automáticamente eliminado de la base de datos. Una característica muy popular en otra base de datos NoSQL como es Redis.

Y por supuesto, es una base de datos altamente escalable que posibilita implantarla utilizando distintas configuraciones de réplica y alta disponibilidad, incluyendo la posibilidad de distribuir colecciones sobre diversos servidores.

En definitiva, el servidor de base de datos orientado a documentos más popular hoy en día por el tiempo que lleva en el mercado y las posibilidades que ofrece.