Potenciando la Arquitectura de Microservicios Reactivos

Daniel Bustamante Ospina
Bancolombia Tech
Published in
12 min readOct 28, 2020

Con el paso del tiempo, va impulsándose aún más la adopción de la tecnología y la transformación digital en distintos ámbitos de la vida moderna. Debido a esto, nos encontramos con mayores retos en la construcción de sistemas que soporten estos procesos a una mayor escala para poder generar satisfacción en nuestros clientes a través de una experiencia digital superior. Algunas de esas necesidades fueron plasmadas en lo que se conoce como el manifiesto de los sistemas reactivos: https://www.reactivemanifesto.org/es

Uno de los grandes retos que afrontamos con el crecimiento exponencial del uso de los sistemas, es el hecho de mantenerlos “responsivos” de cara al usuario, a pesar de que puedan ocurrir situaciones inesperadas, aumento en la carga o fallas en el hardware y/o en el software. Todo esto, a pesar de que dichos sistemas crecen cada vez más en complejidad, número de partes y componentes, las cuales pueden fallar de formas inesperadas.

Dicho de otra forma necesitamos poder construir sistemas tolerantes a fallos (resilientes). A su vez, estos sistemas deben ser elásticos para que puedan adaptarse al aumento o disminución en la carga. Por último, los sistemas deben ser mantenibles y extensibles, logrando un costo de evolución y mantenimiento que sea sostenible en el tiempo.

La solución a esto no es simple. Y tampoco, tenemos un sólo aspecto que solucione todas estas necesidades. Sólo es posible lograr dichas características a través del descubrimiento ó re-descubrimiento y aplicación de distintas prácticas y principios de ingeniería y ciencias de la computación que nos lleven a la construcción de mejores sistemas; más robustos, más flexibles y mejor preparados para lograr hacer frente a las demandas modernas cada vez más exigentes. Es a esto a lo que llamamos el enfoque a los sistemas reactivos.

No podríamos tratar en este artículo todos éstos principios y prácticas, ya que esto tomaría un libro entero o varios. Sin embargo, podemos hacer énfasis en uno de los principios fundamentales de éste enfoque: los sistemas dirigidos por mensajes (la orientación a mensajes asíncronos) y un estilo de programación que se complementa muy bien con dichos sistemas, la programación reactiva y funcional.

La orientación a mensajes: La gran idea

La gran idea, “The big idea” así fue cómo llamó Alan Key (Inventor de la orientación a objetos) al paso de mensajes, dicha idea era lo que él realmente quería enfatizar con el concepto de orientación a objetos. Un enfoque de tener unidades de software independientes que se comunicarán exclusivamente a través del envío de mensajes, encapsulando y protegiendo su propio estado, aislándose del resto de objetos o procesos en el sistema…

Como podemos ver ahora, muchos años después, los actuales lenguajes orientados a objetos no parecen ser la realización de lo que el autor del término pensaba cuando lo imaginó.

“I made up the term ‘object-oriented’, and I can tell you I didn’t have C++ in mind.” ~ Alan Kay, ‘97

Un ejemplo de la orientación a mensajes puesta en práctica, donde podemos resaltar todos sus beneficios, fue la realización temprana de un modelo de actores, Erlang/OTP.

Este es un lenguaje y framework para la construcción de sistemas distribuidos altamente tolerantes fallos construido por Ericsson para sus switches de telecomunicaciones, con el cual logró construir su sistema AXD301, alcanzando una disponibilidad de 99.9999999%. Convirtiéndose en importante pilar de la intercomunicación global; para darnos una idea, el 90% de todo el tráfico de internet pasa a través de nodos controlados por Erlang (según reportado por Cisco, CodeBeamSTO 2018).

Ahora sabemos que la orientación a mensajes no es nueva, pero es la base sobre la cual podemos construir sistemas increíblemente robustos, sistemas cuyas partes y componentes estén desacoplados, tanto en tiempo como en espacio (localización). Esto quiere decir que un componente puede enviar un mensaje a otro componente cuya ubicación desconoce, o incluso, que no esté disponible en el momento (en ese caso recibirá el mensaje cuando vuelva a estarlo, sin afectar al remitente del mensaje).

La programación reactiva: ¿Una nueva idea?

Aunque el término esté siendo usado recientemente por nuevos frameworks y librerías, las bases en las que se apoya el concepto no son nuevas, de hecho son fundamentos teóricos sólidos provenientes de las ciencias de la computación:

  • Programación declarativa y funcional (Abstracciones y composición funcional)
  • Asincronismo y IO no bloqueante
  • Evaluación perezosa (Lazy)
  • Patrón observador e iterador

Al mezclar de una forma bastante ingeniosa dichos conceptos, surge la programación reactiva, la cual es un enfoque que trata el sistema como un conjunto de flujos de datos asíncronos, es decir, como una secuencia de señales o eventos.

La programación reactiva, da al programador la capacidad de transformar, manipular, mezclar y reaccionar a dicho flujo de datos de forma declarativa a través de abstracciones y patrones de programación funcional. Comúnmente las librerías reactivas poseen una gran cantidad de operadores (funciones de orden superior) que permiten especificar de forma declarativa y funcional las modificaciones sobre el flujo de datos.

En resumen, podemos decir que la programación reactiva abstrae un gran complejidad y la presenta al programador a través de una API limpia y sencilla permitiendo hacer manipulaciones sobre flujos de datos que de otra forma serían bastante complejas de lograr. A su vez, aporta de forma muy significativa en la escalabilidad y eficiencia del sistema al apalancarse en el asincronismo y el IO no bloqueante.

Para darnos una idea rápida de qué tanto aporta la programación reactiva a la escalabilidad y eficiencia de un sistema. En el equipo de Ingeniería de Software de Bancolombia hicimos una comparación entre dos servicios que realizan la misma tarea. Uno de ellos fue construido con IO no bloqueante a través de programación reactiva con Project Reactor y el otro usando el enfoque tradicional bloqueante (modelo Servlet tradicional). Los resultados fueron los siguientes:

En la imagen podemos ver claramente una diferencia bastante marcada en la escalabilidad de ambos sistemas (Eje x: Concurrencia — Eje y: Requests/Seg). La explicación de lo anterior se lo debemos a la ley universal de la escalabilidad (USL).

Para no confundirnos mucho con las ecuaciones, la ley nos explica que entre más carga concurrente tenga un sistema, menos trabajo efectivo podrá realizar (decaimiento en el rendimiento), pero dicho decaimiento está dado por algo conocido como la penalidad de coherencia.

Por lo anterior, podemos argumentar que, si encontramos la forma de disminuir la penalidad de coherencia, entonces aumentaremos la escalabilidad del sistema y esto es precisamente lo que logra la programación reactiva.

La programación reactiva disminuye la penalidad de coherencia al disminuir la cantidad de hilos de ejecución necesarios para procesar un alto número de peticiones concurrentes, gracias al uso de IO asíncrono obtenemos esto de forma “transparente” al hacer uso de una librería reactiva y de los drivers adecuados; sin necesidad de enredarnos con el manejo a bajo nivel del IO asíncrono (el cual es realmente muy complicado cuando no se tienen las abstracciones adecuadas).

Dado lo anterior, podemos darnos una idea de las ventajas que obtendremos al hacer uso de la programación reactiva al interior de los componentes de un sistema orientado a mensajería asíncrona. Dichas elecciones nos acercan a lograr las características que esperamos de un sistema reactivo.

Reactive Commons = Mensajería asíncrona + Programación reactiva + Alto nivel de abstracción

El propósito de Reactive Commons es proporcionar un conjunto de abstracciones e implementaciones de diversos patrones y prácticas que apoyen la base para una arquitectura de microservicios reactivos.

Reactive commons nos permite usar de forma efectiva la programación reactiva y funcional haciendo énfasis en poder lograr distintos patrones de comunicación asíncrona entre los distintos microservicios o componentes que hacen parte de un sistema al habilitar de forma bastante simple y sólo con un par de lineas de código, patrones de comunicación asíncrona potencialmente complejos de implementar.

Características base:

  • Modelo 100% declarativo
  • Conversión automática de mensajes a objetos
  • Autoconfiguración de la topología de mensajería
  • Estrategias de reintentos controlados
  • Balanceo de carga y escalabilidad horizontal
  • Non Blocking reactive programming model (Reactor)
  • Fault tolerance
  • Transparent Location

Cuando hablamos de mensajes, pueden existir diferentes semánticas o significados asociados a éstos. Es decir, que un mensaje que se emite con el propósito de informar a posibles interesados (0 ó n) de que algo ocurrió, es diferente a un mensaje que se envía para un destinatario concreto pidiéndole realizar algún tipo de acción, sin necesitar respuesta sino sólo la certeza de que el mensaje será entregado al destinatario y, a su vez, es diferente un mensaje que necesita ser respondido al remitente dentro de un rango de tiempo. Es por eso que reactive-commons modela inicialmente 3 tipos de mensajes:

  • Eventos (DomainEvent)
  • Comandos (Command)
  • Consultas (AsyncQuery)

En el fondo los tres tipos son mensajes emitidos a un agente de mensajería (broker, Ej: RabbitMQ), pero su diferencia fundamental está en el significado que tienen y el rol que juegan en el sistema, es decir los Eventos podrán ser escuchados por distintos sistemas/componentes interesados o por ninguno, mientras que los Comandos y Consultas sólo serán entregados al destinatario especificado.

Cómo se ve en la imagen, el diseño de Reactive commons cuenta con una fuerte separación entre el API y las implementaciones. Esto permite generar una capa de abstracción sobre diferentes brokers de mensajería de forma transparente al desarrollador, es decir, podríamos eventualmente cambiar la implementación para no usar el broker X sino el broker Y o Z, sin necesidad de cambiar el código del proyecto donde lo usemos (Al momento de escribir este artículo existe la implementación para rabbitMQ y se está terminando de construir la de SQS/SNS).

El API que expone reactive commons es bastante sencilla y se compone sólo de una par de clases e interfaces principales, por lo que abstrae toda la complejidad que se pueda encontrar a más bajos niveles de abstracción.

Para trabajar con Reactive commons es suficiente con recordar 2 interfaces y una clase.

  • HandlerRegistry: Es el punto de entrada para escuchar los distintos tipos de mensajes. Sólo es necesario indicar de forma declarativa qué tipo de mensaje queremos escuchar, cual es el nombre del mensaje (del comando, evento o consulta), la función que será invocada cuando llegue dicho mensaje y el tipo de dato esperado. Reactive commons realiza todo lo demás por nosotros, es decir se encarga de crear la configuración en el broker de forma transparente, de interpretar el mensaje y pasarnos el dato ya convertido a la clase que indiquemos.

Cuando se usa el starter de spring boot sólo necesitamos exponer una instancia del HandlerRegistry como un bean de aplicación.

Adicionalmente, necesitamos indicarle al starter que estamos interesados en habilitar el subsistema de recepción de mensajes, esto se puede hacer fácilmente con una anotación:

Con esto reactive commons creará de forma automática la configuración en el broker para crear los distintos elementos de la topología como colas y subscripciones al momento de iniciar la aplicación spring boot.

  • DirectAsyncGateway: Como su nombre lo indica es la principal puerta de salida de mensajes directos de reactive-commons. Esta es la interfaz mediante la cual enviaremos mensajes asíncronos dirigidos a un destinatario (comandos) ó mensajes asíncronos de los cuales esperamos un mensaje de vuelta para continuar (consultas); dicho lo anterior DirectAsyncGateway sólo tiene dos métodos: sendCommand y requestReply.

Ejemplo:

Cuando se usa el starter de spring, sólo debemos inyectar el bean antes de usarlo, a través de cualquier método de inyección de spring (constructor, autowired, o setter).

Para que la inyección funcione debemos indicar a reactive commons que queremos habilitar el subsistema correspondiente:

  • DomainEventBus: Es la interfaz más simple de reactive commons y la que menos dependencias externas tiene. Esta depende de ReactiveStreams directamente y no de Reactor Project. Su único método emit, nos permite, como su nombre y firma lo indican, emitir eventos de dominio a cualquier posible interesado, sin necesidad ni posibilidad de especificar los destinatarios del mensaje (ya que es la implementación de patrón pub/sub).

Para activar el bean:

En este punto conocemos las capacidades funcionales base de reactive-commons (para ver otros 2 usos más avanzados “notifications y pattern subscriptions” por favor ir a la documentación en github).

Ahora revisemos algunas características core que nos pueden apoyar en diversos que se presentan en sistemas reactivos.

¿Cómo regular la carga del sistema?

Reactive commons permite especificar de forma opcional la concurrencia máxima usada para procesar los mensajes. Es importante comprender que la concurrencia máxima no hace referencia al número de hilos de procesamiento usados, sino a la cantidad de mensajes que se procesan al tiempo en un momento dado y que, al tratarse de flujos reactivos dicha concurrencia puede ser atendida en la práctica por un número mucho menor de hilos.

app.async.maxConcurrency=20

Al especificar esta propiedad en el archivo de configuración de spring boot (ya sea .properties o en formato yaml) le estamos indicando a reactive commons que el microservicio sólo va a procesar máximo 20 mensajes de cualquier tipo a la vez y que se va a usar el message broker como buffer para no recibir más carga que la indicada (sin perdida de mensajes). Esto es útil cuando deseamos regular la carga máxima que va a recibir un sistema, garantizando que se va a mantener disponible a pesar de condiciones de carga que sepamos que no puede manejar, garantizando el procesamiento de todos los mensajes.

¿Cómo generar estrategias de tolerancia a fallos en el procesamiento de mensajes?

Por defecto reactive commons sólo indica al broker de mensajería retirar un mensaje cuando que se tiene certeza que la aplicación lo pudo procesar correctamente. Esto genera una garantía de entrega a través del proceso de redelivery, pero, ¿qué ocurre cuando sabemos que hay mensajes que por alguna razón no pueden ser procesados por el componente actual y que deben ser tratados con un estrategia diferente?

Por ejemplo, después de N reintentos podemos asumir que así intentemos procesar el mensaje otras N veces, es posible que la falla esté fuera del alcance del componente y necesitemos informar a un componente de más alto nivel para hacer un tratamiento diferente en este caso, no ignorar la falla sino reaccionar a ésta.

Reactive commons nos permite especificar el máximo número de reintentos por mensaje que un microservicio intentará, así como también el tiempo entre reintentos y, al cumplirse el número máximo de reintentos de dicho mensaje, reactive commons convertirá dicho mensaje en un evento que podrá ser escuchado por cualquier otro componente/microservicio permitiéndonos generar estrategias específicas de tolerancia a fallas para el tratamiento de dichos mensajes.

app.async.withDLQRetry=true
app.async.retryDelay=1000
app.async.maxRetries=10

Con las propiedades anteriores se habilita dicha estrategia, con un máximo de reintentos de 10 y un tiempo entre reintentos de 1 segundo.

Al superarse el máximo número de reintentos del comando con nombre “app1.ejemplo.comando1”, se descartará el mensaje no sin antes emitirse un evento con el nombre “app1.ejemplo.comando1.dlq”.

Para finalizar esta breve introducción a 2 conceptos fundamentales en la construcción de sistemas reactivos y cómo usar reactive commons para lograr de forma simple diversos patrones de comunicación asíncrona bajo en enfoque declarativo y funcional.

Los invitamos a dejarnos sus comentarios y a visitar el sitio del proyecto en github, donde son bienvenidos los aportes a la iniciativa:

Para continuar conociendo algunas de nuestras prácticas de ingeniería puede revisar el siguiente artículo:

Conoce más sobre nuestros proyectos Open Source en Bancolombia visita dando clic en github.

--

--