Arquitecturas modulares

[English version]Este enlace se abrirá en una ventana nueva

Punto de partida

Cuando diseñamos la arquitectura de un sistema, debemos ser extremadamente cautos con su complejidad y como el sistema se mantendrá a futuro. Siempre deberíamos tener en la cabeza que el mantenimiento no es tanto entender como funciona el sistema, o como atender a los usuarios finales, sino que, fundamentalmente, se relaciona con añadir nuevas funcionalidades que complemente o, incluso, modifiquen el comportamiento previamente diseñado.

De esta forma, nuestro foco debería estar en buscar diferentes aproximaciones que nos permitieran conseguir este objetivo. Siendo la principal como abordar la modularidad y la intercomunicación entre los distintos subsistemas involucrados. Aunque los sistemas puedan ser construidos de forma iterativa, no deberíamos despreciar, ni ignorar, una adecuada definición de la arquitectura que, lógicamente, estará basada en nuestro conocimiento y experiencia previa.

En este post me voy a enfocar en un ejemplo en el que hemos trabajado recientemente, en como hemos implementado una arquitectura modular, teniendo en mente desde el principio no los detalles de la implementación ni el camino completo que queríamos seguir, pero si el objetivo que queríamos lograr y, más o menos, como lo queríamos construir.

El problema

La verdad es que el tipo de aplicación que os voy a contar no es de las, aparentemente, más atractivas. Una aplicación de gestión, pero con ciertas peculiaridades relacionadas con la forma en la que los usuarios finales pueden interactuar con el sistema. De hecho, la aplicación, aunque orientada a usuarios internos también permitía la interacción con el usuario final.

Esta interacción consistía en distintos canales de entrada y salida, como, por ejemplo, correo electrónico, WhatsApp, Telegram, un cliente Web o una APP. Estos requerimientos son, así, a bote pronto, difusos… Podríamos platearnos crear un monolito que, pensaríamos, podría cubrir las necesidades del cliente. Pero si lo analizamos con cuidado nos encontraremos con una serie de aspectos importantes a abordar.

  • Seguridad… ¿tengo que dar acceso a toda la aplicación a todos los usuarios?, ¿aunque tenga permisos y seguridad, no es demasiado?
  • Relacionados con la interacción con el usuario… si mañana aparece un nuevo canal, ¿cómo lo puedo añadir al sistema?
  • Identificación del usuario final… ¿tengo que crear los usuarios en mi sistema?, ¿no estaría añadiendo así una capa adicional de extraordinaria complejidad?

Contestar a estas preguntas, requiere que diseñemos el sistema con cuidado, en concreto:

  • Deberíamos crear un API responsable de la interacción con el usuario final.
  • Nuestro sistema de gestión no es, en realidad, responsable de la identificación del usuario final. Necesitaremos, por tanto, otro sistema que lo haga. Será su responsabilidad.
  • Deberíamos crear tantos componentes independientes y reusables como pudiéramos.
  • Debemos incluir mecanismos de notificación tanto para usuarios internos como para usuarios finales.

Más allá de esta visión funcional, deberíamos tener en cuenta una serie de aspectos técnicos y operativos, ¿cómo conseguir una independencia real de varios equipos de desarrollo en paralelo? Y, a la vez, como conseguir no reinventar la rueda una y otra vez.

En la siguiente imagen os muestro la visión global de la arquitectura del sistema, de hecho bastante esquemática, pero espero que suficiente para tener una mejor comprensión de lo que estoy hablando.

00_esquema_general00_esquema_general

Como podéis ver, he representado distintos módulos, o subsistemas, que se orientan a diferentes tareas, que espero os den una orientación de como hemos logrado tanto una arquitectura funcional como técnica adecuada.

  • Hemos considerado un API REST común que es empleado por los diferentes sistemas de interacción. En el esquema los he denominado agentes.
  • Pero, a la vez, el objetivo que perseguía era el de aislar funcionalidad, no es que tengamos un agente de WhatsApp o de Telegram sino que ambos comparten un núcleo común de funcionalidades. Lo mismo podríamos decir del agente Web y APP.
  • Hay también algunos elementos interesantes relacionados con la modularidad como pueden ser el despliegue en caliente, un motor de Plug-and-Play, soporte de una arquitectura de microfrontends y un sistema de eventos.

En los siguientes apartados intentare daros una explicación más detallada de lo que estoy hablando.

El subsistema del API común

La idea detrás de este API común es proporcionar a los diferentes agentes un sistema común de entrada/salida que evite que esto tengan acceso al sistema completo. Misión de este API sería la identificación, no de los usuarios, sino del agente y la posibilidad de que los agentes sean desplegados en distintos servidores.

01_common_rest01_common_rest


Este sistema nos proporciona, por tanto, la entrada/salida y la seguridad a nivel de agente. Teniendo en cuenta que:

  • Podemos crear nuevos agentes.
  • Los agentes comparten un secreto con el sistema, que se utiliza para enviar y recibir información y que puede ser cambiado en cualquier momento.
  • El servidor puede intercambiar con los agentes información relacionada con la configuración de los mismos.
  • El API también será responsable de controlar el número máximo de peticiones por agente, sólo como una capa de seguridad adicional.
  • De hecho, el que cada agente sea un servicio independiente (notad que no he utilizado la palabra microservicio sino servicio), nos permite tratarlos de forma independiente pudiendo parar uno de ellos si su comportamiento fuera errático o estuviera siendo atacado, todo ello sin comprometer la seguridad global del sistema, su confianza y su operativa.

El subsistema de APP y Web

Aunque pueda parecer obvio, ni el cliente Web ni la App deberían almacenar tokens de seguridad para intercambiar información con el servidor, las implicaciones de seguridad son elevadas así que nunca hagáis eso. Entonces, ¿quién lo tiene? La respuesta, debemos crear un middleware que será responsable no sólo de almacenar los tokens sino de identificar los usuarios.

02_webs_apps02_webs_apps


Más allá de la responsabilidad de almacenar los tokens e intercambiar la seguridad, tenemos también

  • Compartimos un código base común para la app y la web. Aquí la verdad he de reconocer que las promesas de escribe una vez y ejecuta en cualquier sitio son un tanto optimistas… ha tocado hacer un esfuerzo importante para hacer esa promesa realidad.
  • El middleware se responsabiliza de la seguridad y la identificación del usuario. No hablo tanto de diferentes mecanismos de seguridad, sino sobre todo de controlar quien accede a que y cómo, debemos tener en cuenta que el sistema de gestión delega esa capa de "identificación" en este sistema.
  • Lógicamente utilizamos aquellas capacidades del dispositivo que sean adecuadas y necesarias: service workers, notfiicaciones, geolocalización

BOTS: WhatsApp & Telegram

No se debe reinventar la rueda. Los patrones de diseño son importantísimos cuando planificamos una arquitectura adecuada. Relacionado con los BOTS, tenemos en realidad un sistema común y compartido y, en el momento actual, dos implementaciones distintas.

03_bots03_bots


Como podéis ver, los sistemas involucrados tratan de generalizar lo más posible, en concreto:

  • El API común de los BOTs es un sistema conversacional, basado en una máquina de estados finita.
  • Se apoya en acciones (lo que el usuario puede hacer) y respuestas (lo que yo, como robot, puedo responder)
  • El API común es el único responsable de la interacción con el backend.
  • Los detalles específicos de la integración de WhatsApp y Telegram se restringen, por tanto, a las comunicaciones de red y a detalles específicos de los bots, fundamentalmente el tamaño máximo de los mensajes y la disposición de los botones.

Si os dais cuenta, de lo que hablo, en realidad, es de típicos patrones de ingeniería de software. Desacoplamiento, aislamiento, responsabilidad única, …

Eventos y colas

Me resulta difícil imaginar un sistema que no tenga necesidad de eventos. Tu sistema dispara eventos cuando algo ocurre, y hay otros sistemas (subsistemas) que se suscriben a dichos eventos para hacer algo. No hay ni magia ni necesitas una compleja cola Kafka para soportar eventos. Un simple patron productor-subscriptor

04_queues04_queues

Pese a la simplicidad, si lo que queremos es soportar un sistema auténticamente modular, necesitamos que ciertos aspectos complejos funcionen. Por ejemplo:

  • Si añado nuevos listeners quiero que se descubran en caliente.
  • Mis listeners deberían tener sus propias librerías, no deberíamos terminar con un monolito gigantesco.
  • Eso significa, que tenemos que utilizar algunas características avanzadas como, por ejemplo, ClassLoaders personalizados y configuraciones de integración que lo soporten. Ni demasiado complejo, ni demasiado fácil

En resumen, cuando tengáis que plantear un sistema probablemente tengáis que utilizar eventos, y eso no implica una cola Kafka, eso es, en realidad, un detalle de implementación. Si diseñáis una arquitectura adecuada, deberías poder reemplazar este elemento por cualquier otra alternativa que se pudiera necesitar en el futuro.

La aplicación de gestión

Esto ya lo he comentado en posts anteriores, quizá uno de los problemas más destacados cuando desarrolláis una aplicación compleja es que un flujo de trabajo típico no es suficiente. No deberíamos terminar con un monolito en frontend, puesto que son complejos de mantener, complejos de desarrollar y complejos de probar.

La propuesta que os he comentado otras veces, dividir el frontend en una serie de micro-frontends cada uno de los cuales tenga, además, su capa de micro-backend y sus niveles de seguridad adecuados.

05_microfrontends05_microfrontends

Tal y como os comenté antes, cuando os hablaba de las colas y la subscripción, soportar estos microfrontends requiere descubrimientos en caliente, classloaders personalizados y una configuración de construcción de la aplicación compleja que nos permita empaquetar todo el código en un único archivo desplegable. Afortunadamente, no tenéis que hacerlo más que una vez aplicando, por ejemplo, una adecuada configuración multi-proyecto en Gradle.

Más allá del empaquetamiento, hay otra serie de aspectos a considerar,

  • Soportar traducciones a nivel de servicio
  • Soportar hojas de estilo a nivel de servicio.
  • Orquestar servicios, aunque usamos REACT para el frontend y orquestamos mediante la definición proporcionada por el servidor, el "pintado" final de que se representa lo hacemos mediante WebComponents.

En resumen

No necesitas crear arquitecturas extremadamente complejas, sólo usa aquello que necesites. Pero ten siempre en mente que no debes buscar la simplicidad por el mero hecho de que sea simple. Algunas cosas son complejas por naturaleza y, tu misión como ingeniero, es hacer que la complejidad sea operativa.