Contenidos
Autenticación - Identity Server 5
Test de integración y unitarios 7
Integración y Despliegue (CI/CD) 7
Control de calidad del código. 8
CI/CD completos e independientes 11
Monitorización y traza del sistema. 11
Comunicación entre microservicios 14
Tipos de comunicación con mensajes 14
Cancelar la operación completa. 17
Separación de contextos con DMS. 20
Escenarios de Concurrencia. 22
Separación canónica de contextos 22
Interfaz de comunicación entre contextos mediante servicios 22
Framework .Net Core 3.1
El backend se implementará utilizando .Net Core en su última versión estable (actualmente 3.1) porque es multi plataforma, modular, de código abierto, y ofrece un alto rendimiento.
Desarrollar las aplicaciones en .Net Core nos permite ampliar nuestra estrategia DevOps, pudieron elegir agentes de compilación basados en sistemas Unix, así como plataformas de despliegue.
Como decimos la tecnología utilizada para el desarrollo es .Net Core 3.1, por las siguientes razones:
- Multiplataforma. La API será multi plataforma, soportando Windows o Linux.
- Open Source. El Código Fuente de .NET Core es disponible en GitHub bajo licencia MIT y Apache 2. Microsoft da soporte completo y realiza evolución continua del framework.
- Framework modular. .NET Core ha sido diseñado para ser modular, dando al desarrollador la posibilidad de incluir sólo las librerías necesarias y requeridas por la aplicación.
- Consumo de memoria y rendimiento. El consume de memoria es de los más bajos que existen en el mercado y su rendimiento es mucho más alto que Full Framework.
API Rest en ASP .Net Core
Para exponer el negocio usaremos una interfaz de aplicación o API (Application Programming Interface) creadas con ASP .Net Core.
La API será un servicio de tipo REST (Representational State Transfer) que nos establece un conjunto de restricciones, reglas y especificaciones con las que definimos un estilo de arquitectura software, que usaremos para crear aplicaciones web respetando HTTP.
Con esta definición creamos un mecanismo que nos permite conectar aplicaciones entre sí, no solo con las diferentes interfaces de usuario que disponga nuestro sistema, sino también con otros servicios o aplicaciones con las que debamos integrarnos.
De este modo, disponer de una API Rest que represente nuestro negocio nos permite ofrecer datos y exponer nuestros servicios a otros sistemas, independientemente de los lenguajes de programación empleados. El intercambio de datos lo realizaremos empleando formatos de datos estándar, principalmente en JSON o llegado el caso en XML.
La API Rest se caracteriza por diferentes aspectos que la convierte en una herramienta útil para nuestra solución técnica:
- Protocolo Cliente/Servidor sin estado, cada petición contiene toda la información necesaria para ser ejecutada.
- Operaciones definidas. Las acciones que se realizan con los datos a través con la API se basan en la especificación HTTP, (Get, Post, Put, Delete, Patch, etc)
- Manejo de recursos con la URI.
- Interfaz Uniforme
- Estructura jerárquica
La definición de esta se expondrá a través de ficheros de OpenApi, y se expondrá empleando Swagger que nos creará una descripción de nuestra API Rest.
Adicionalmente, apoyado en esto, se generará de manera automática, utilizando NSwag los modelos de comunicación entre backend y frontend, siendo capaces de aceptar los cambios que se realizarán en el transcurso de este proyecto.
Arquitectura Backend
A nivel de arquitectura lógica, usaremos una arquitectura hexagonal donde el dominio de la aplicación de cada servicio estará bien definido y aislado de integraciones con otras partes de la aplicación.
La arquitectura hexagonal nos ayuda a definir un entorno donde nuestra lógica de negocio no esté acoplada a ninguna tecnología o framework, considerándose la parte más vital de la aplicación, y llevando a las capas más externas de la arquitectura estas integraciones.
Facilita también estar desacoplado del método de entrega, haciendo que sea más sencillo que un caso de uso funcione para una aplicación móvil, un API Rest, una web tradicional, una web SPA por API Rest, etc
Otro beneficio que nos ofrece la arquitectura hexagonal, como cualquier arquitectura basada en la inversión de dependencias, es facilitar que los componentes se puedan probar a través de pruebas unitarias.
Dentro del dominio se expondrán interfaces para definir como se deben integrar las diferentes capas que van a construir la aplicación sobre nuestro dominio. Por un lado, se van a definir los diferentes casos de uso en la capa de aplicación que usarán las interfaces del dominio para su implementación a través de diferentes tipos de servicios. En la capa de infraestructura, se implementarán estos servicios cumpliendo con las interfaces marcadas por el propio dominio, quedando de este modo aislado el dominio de las capas de integración más externas.
En las capas de infraestructura, la persistencia de los datos en las BBDD se utilizará la última versión estable de Entity Framework Core o cualquier ORM (dapper, nhibernate) que permita trabajar de forma fluida con MySQL y con .Net Core, pero para las acciones de lectura de la información se usará un micro-ORM, como puede ser Dapper, pudiendo llegar a utilizar un modelo de lecturas completamente diferente del usado para la persistencia de datos, obteniendo de este modo una segregación entre escrituras y lecturas, más conocido como modelo CQS.
Este enfoque garantiza el rendimiento de las consultas para recuperar los datos, pero también para el uso de diferentes modelos para persistir y recuperar la información, garantizando la responsabilidad de cada tipo de acción y permitiendo crear un modelo que represente correctamente las reglas de negocio.
Servicio de mensajería
Para la comunicación entre microservicios se podrán utilizar servicios de mensajería.
Los servicios de mensajería pueden ser de varios tipos dependiendo de las necesidades que se requieran. Por ejemplo, se podrían utilizar alguno de los siguientes tipos:
- Las colas FIFO (First-In-First-Out).
Las colas FIFO (First-In-First-Out) están diseñadas para mejorar la mensajería entre aplicaciones cuando el orden de las operaciones y eventos es crítico, o cuando no se pueden tolerar duplicados. A continuación, se describen algunos ejemplos del uso de mensajería:
- Para asegurar que los comandos introducidos por el usuario se ejecuten en el orden correcto.
- Mostrar el precio correcto del producto enviando las modificaciones de precio en el orden correcto.
- Evitar que un estudiante se inscriba en un curso antes de registrarse en una cuenta.
La comunicación no se hará directamente entre las distintas partes, sino que se publicarán los mensajes al topic que aplique, pudiendo suscribirse aquellas partes interesadas y leer los mensajes de estas suscripciones de manera independizada.
Nota: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/FIFO-queues.html
Autenticación - Identity Server
El proceso de autenticación de una aplicación es aquel que nos garantiza que la identidad de un usuario es la que dice ser. Esta comprobación suele realizarse mediante el uso de credenciales, aunque la podemos completar con mecanismos más complejos que garanticen la identificación de la identidad, como puede ser el MFA (Multi Factor Authentication), donde empleamos mecanismos adicionales para garantizar que el usuario que intenta autenticarse es quien dice ser, y nos indica si tiene permisos para acceder a la aplicación que solicita. Este proceso no va más allá, y no debemos mezclarlo con el proceso de autorización, que nos dirá que puede hacer un usuario en una determinada aplicación una vez ha accedido.
Identity Server 4 es un servicio que funciona como conector OpenId y OAuth2.0 para ASP.Net Core que nos sirve como servicio de autenticación y control de identidades, gestionando las identidades de los usuarios y además teniendo la posibilidad de integrarse con otros proveedores de identidad.
Integrando Identity Server en nuestra solución, nos permite incluir dentro de nuestro proyecto características como:
- Disponer de un servicio de autenticación centralizado, teniendo controlado en un mismo punto el proceso de login y el flujo de autenticación para todas a las aplicaciones (web, móviles, servicios)
- Single Sign-on / Sign-out para multiples aplicaciones.
- Control de acceso a nuestras API, podemos emitir tokens para diferentes tipos de clientes y así integrar de forma segura las aplicaciones SPA, servicios o aplicaciones de movilidad.
- Soporta integraciones con proveedores externos de seguridad por lo que podemos usarlo como puerta de enlace con estos proveedores para autenticar a nuestros usuarios.
Autorización
Los procesos de autorización de las aplicaciones están basados en roles, estos roles otorgan permisos a los usuarios para realizar diferentes acciones en las aplicaciones. Como hemos comentado, el mecanismo de autenticación simplemente nos confirma que un usuario es quien dice ser, pero el mecanismo de autorización nos dice que puede hacer ese usuario, una vez que sabemos quién es, en una aplicación en concreto. Por esta misma razón es importante mantener separada la autorización de la autenticación, y no emplear los mecanismos de autenticación (como tokens) para incluir información de autorización.
La información de autorización forma parte de propio negocio de la aplicación. Distintas aplicaciones manejarán diferentes tipos de roles, que concederán determinados permisos para ejecutar acciones concretas de cada aplicación.
Por esta razón, nuestra propuesta técnica quiere proveer de un mecanismo de autorización que mantenga separadas estas dos responsabilidades, y para la autorización nos basaremos en Balea, un Framework de autorización para Asp.Net que nos permite configurar y dotar de mecanismos de autorización a nuestro sistema y que nos permite basarnos en la gestión de autorización de Asp.Net para realizar la gestión de roles de nuestra aplicación.
Además, Balea nos permite evolucionar esta solución hacia un ecosistema empresarial utilizando ediciones superiores de este Framework, que nos permitirá gestionar de manera centralizada los roles de una determinada identidad para diferentes aplicaciones con su propio portal de administración, lo cual nos permite liberarnos de ciertos desarrollos repetitivos en las aplicaciones que gestionan roles, además de tener una visión global de los roles que tiene un usuario.
Test de integración y unitarios
Durante el desarrollo de la funcionalidad se generarán un conjunto de pruebas, tanto de integración como unitarias, que pruebe de forma automática el código generado.
Las pruebas que se realizarán serán tanto pruebas unitarias como de integración.
- Las pruebas unitarias se crearán sobre determinados componentes de la aplicación de una forma aislada y sin tener en cuenta las dependencias que este tiene sobre otros componentes de la aplicación ni su interacción con estos, se centrarán en la funcionalidad del propio componente.
- Para probar como los diferentes componentes interactúan entre sí y como ejecutan las diferentes reglas de negocio se crearán pruebas de integración. Estas pruebas cubrirán mayor cobertura de código, definiendo casos de uso y replicando escenarios de negocio en los entornos donde se ejecuten las pruebas. Estas pruebas se realizan en las capas superiores de la aplicación, como puede ser directamente las API y buscan siempre probar de una forma más sistemática la aplicación como un conjunto.
- En determinadas ocasiones, se puede valorar la creación de pruebas automáticas, estas buscan reproducir las interacciones de los usuarios con la aplicación. No siempre se decide la creación de este tipo de pruebas ya que se consideran muy débiles ante cambios de las aplicaciones y tienen un alto coste de desarrollo.
Es importante tener en cuenta que cuanto mayor sea el nivel de pruebas que una solución disponga más fácil es asegurar su calidad, mantenimiento y evolución. Pero hacer todas estas pruebas tiene un coste elevado, en muchos casos pudiendo ser mayor que el coste que supone la propia implementación funcional.
Todos los test que se definan se ejecutarán en el proceso de integración continua, verificando que estas pruebas se cumplen antes de poder desplegar, evitando los posibles problemas ocasionados en este despliegue.
Integración y Despliegue (CI/CD)
Los procesos de integración serán los mismos procesos usados por el cliente.
Para los procesos de despliegue se utilizarán contenedores Docker y serán ubicados en el sistema que el cliente decida, concediendo la autorización necesaria para poder llevar a cabo dichos procesos.
En este punto, sería necesario contar con un repositorio Docker hub.
Control de calidad del código
De manera análoga al análisis de seguridad, es importante detectar otros problemas que pueden residir en el código y establecer unos controles de calidad del código.
Para ello, Plain Concepts recomienda el uso de la herramienta SonarCloud, enfocada a realizar análisis del código existente en el repositorio para señalar errores o vulnerabilidades antes incluso de desplegarlo.
Se puede integrar fácilmente en el flujo de integración y despliegue continuo y establecerse como interruptor para permitir o no despliegues basados en criterios de calidad.
Adicionalmente, permite ofrecer la información en una serie de paneles y reportes, fácilmente accesibles y que permiten la colaboración de los elementos detectados para trabajar en su resolución.
Esta herramienta estará disponible durante la ejecución del proyecto por parte de Plain Concepts, una vez esta termine la cuenta asociada al proyecto será deshabilitada.
No obstante, por las características especiales de este proyecto, habría que ver si es aplicable.
Reporting
Para el desarrollo de los diferentes reportes que se contemplan en la aplicación, nos apoyaremos en tres tipos de informes.
- Informes personalizados integrados en la aplicación. Serán informes generados internamente por la plataforma y que serán desarrollados principalmente usando librerías de terceros, variarán dependiendo del formato del informe, complejidad y volumen.
- Paneles integrados en la solución. Aquellos paneles de información que deben estar integrados en la propia aplicación, inicialmente se considerarán como desarrollos a medida pudiéndonos apoyar en librerías que nos ayuden a representar gráficamente los datos, pero serán un desarrollo interno de la propia aplicación.
- Paneles analíticos. Para los diferentes tipos de reportes que se contemplen en la aplicación que requieran capacidades analíticas sobre los diferentes datos almacenados por la solución.
- Paneles de análisis de estado del sistema, se podrán crear paneles de control de la plataforma. La explotación de esta información la podremos realizar con soluciones basadas en Grafana, Prometheus o Amazon CloudWatch Application Insights.
Entrega de documentación
Junto con la solución, se entregará la documentación asociada para la puesta en marcha del proyecto, así como todo lo necesario para su mantenimiento, configuración y despliegue.
Se asume que las personas que deben acceder al proyecto están familiarizadas con las tecnologías que él se emplea. No será objeto de dicha documentación explicar conceptos básicos de desarrollo ni relativos a las tecnologías utilizadas.
La documentación se presentará en ficheros en formato markdown que se entregará en los propios repositorios del proyecto.
La documentación funcional estará reflejada en el propio backlog del proyecto, representada a través de épicas, características y elementos de producto (PBI o Product Backlog Items).
El responsable de la definición del backlog será siempre el “Product Owner” designado por el cliente. Aunque se pueda apoyar en otros miembros del equipo, como los “Delivery Lead” o alguien que asuma el rol de analista funcional. Pero, el último responsable de validar el contenido funcional será el “Product Owner” ya que los desarrollos se ajustarán a estos tanto para su implementación como para su validación en dichos elementos de trabajo.
Command Query Segregation
Su idea principal es separar los métodos en dos categorías distintas:
- Queries o consultas: devuelven un resultado y no cambia el estado del sistema, sin efectos colaterales.
- Commands o comandos: cambian el estado del sistema sin retornar valor.
Al afrontar un simple CRUD, el enfoque tradicional presenta una serie de problemáticas:
- Poca precisión al usar la misma entidad objeto en la lectura y escritura de la base de datos: modificar el objeto completo cuando solo queremos cambiar una propiedad, o consultar todo el objeto cuando desde presentación solo necesitamos una proyección más pequeña.
- Posibles problemas de bloqueos y competencia por los recursos: cuando escribimos, necesitamos bloquear, mientras que al leer quizás podamos consultar información sucia. Esto puede impactar significativamente en el escalado del sistema.
- Accesos síncronos de gran cantidad de información puede provocar problemas de cuello de botella en el rendimiento.
Con la separación entre consultas y comando conseguimos solventar estas problemáticas y conseguir los siguientes beneficios:
- División clara del trabajo, cada parte tendrá un responsable distinto.
- Al separar las responsabilidades entre comandos y consultas se puede mejorar el rendimiento, escalabilidad y seguridad del sistema.
- La lógica es más clara, distinguiéndose con mayor facilidad aquellos comportamientos u operaciones del sistema que provocan cambios en el estado del sistema.
Microservicios
En proyectos grandes, donde existe gran cantidad de procesos y equipos de desarrollo independientes para las distintas partes del sistema, solemos recomendar partir esta solución en n soluciones más pequeñas, aisladas lo más posibles entre ellas. Dependiendo de la naturaleza de la parte en concreto, por entre otras cosas su tamaño, se puede considerar un microlito (por ejemplo, para la facturación) o un microservicio para tareas más concretas (por ejemplo, para transformar un html en un pdf).
No obstante, obviando la diferencia en su nomenclatura, van a compartir muchas características, como pueden ser la especialización en el conocimiento de negocio o despliegues independientes.
Esta separación de la solución monolítica en partes más pequeñas es más compleja de lo que puede parecer. Como punto de partida, en dicha separación partiremos de un enfoque DDD, y respecto a la organización en cómo se comunican, tal y como enuncia la ley de Conway, tenderemos a la que se asemeje a la estructura de comunicación de la organización donde se implantará la solución. Teniendo esta primera organización en cuenta, será necesario trabajar en ello siguiendo directivas desarrolladas en DDD.
Compartición de código
Para conseguir que los microlitos o microservicios sean independientes, habrá ocasiones donde tengamos que duplicar el código, o tener una versión adaptada a las necesidades de ese microservicio.
Para aquellas partes de código que queremos no obstante compartir entre múltiples microlitos, este código deberá estar empaquetado y versionado, de tal manera que cada microservicio pueda asociarse a una versión distinta del paquete, y no siendo por tanto necesario desplegar n microservicios al lanzar una nueva versión del paquete.
Domain Driven Design
Domain Driven design o diseño guiado por el dominio es un enfoque de desarrollo para proyecto con necesidades complejas en las que se prima la relación entre los conceptos del negocio y su implementación.
DDD identifica los problemas como dominios, esto es, el comportamiento de nuestra aplicación, independiente de la tecnología con la que el proyecto sea desarrollado. Un dominio es identificado como una isla de conocimiento común, o un conjunto de reglas acordes a una misma temática y que deben trabajar en común en pos de que nuestro sistema se comporte como es debido. Bajando a un punto más tecnológico, un dominio es el conjunto de clases que colaboran entre sí otorgando funcionalidades a través del código.
Dentro de estas islas del conocimiento en DDD también se identifica lo que sería denominado Contexto Delimitado o Bounded Context, donde en cada dominio o dominios se pueden identificar grupos de clases que colaboran entre sí para llevar a cabo una tarea específica, pero además en otro Bounded Context estas mismas clases o entidades pueden existir pero para cumplir con otro objetivo, por lo que aunque en ambos contextos existan puede que su tarea sea tal que no adquieran tanta relevancia dentro del dominio y se modelen de forma diferente.
Repositorios de código
Por practicidad, únicamente cuando existen equipos específicos para el desarrollo de cada una de las partes nos plantearemos el uso de repositorios de códigos separados. Normalmente, partimos de un único repositorio de arranque (monorepo). Eso sí, internamente separaremos físicamente cada una de las partes, y siempre con integraciones y despliegues completamente independientes. Tras un periodo de trabajo, analizaremos la separación en distintos repositorios (multirepo). Si hemos separado nuestro código, esta operación no debería ser costosa.
CI/CD completos e independientes
Al tener mayor cantidad de piezas de software a desplegar, se hace imprescindible automatizar completamente todo el ciclo de despliegue de cada una de ellas.
Dentro de este incluiremos, entre otras cosas:
- ejecución de pruebas unitarias y funcionales
- ejecución de proceso de verificación de la calidad del código subido
- despliegue de la infraestructura consumida: este despliegue debe ser idempotente, de tal manera que no cambia nada si no se ha cambiado nada en la configuración deseada.
Es muy importante que el despliegue de cada una de las partes sea independiente de las demás. Una de las mayores ventajas de los microservicios es poder estar seguros de que si subimos una nueva versión de un microservicio, no va a impactar en el resto, reduciendo así el área de pruebas y de riesgo. Aquí existen varias técnicas para ayudarnos en esta transición, en el caso de microservicios desde tener distintas versiones de un microservicio desplegado, o en el caso de microlitos tener versionado en la API que sustente consumos de una versión previa.
Monitorización y traza del sistema
Con la separación del monolito en múltiples partes, una única operación de usuario puede involucrar múltiples procesos en distintas de estas partes. Esto puede dificultar y emborronar la información que obtenemos en las trazas y logs de nuestro sistema.
Por ello, hay que buscar en nuestras herramientas de trazas que podamos obtener la información conjunta por cada operación, viendo el impacto de un posible error en otras partes de nuestro sistema. Para ello, podemos utilizar id’s de correlación y/o sistemas de logs como Graylog.
Más allá de tener trazas específicas, es muy importante tener una visión global del sistema. Para poder llegar a esta visión, es necesario que el formato de los datos recogidos pueda ser agregable y uniforme para todas las partes. Esto nos permitirá, a nivel global, saber en cada periodo de tiempo, por ejemplo, cual es la duración media de las peticiones, o cual es el número de errores de autorización fallida que estamos teniendo en todo en el sistema, y su desglose por cada pieza. Para facilitarnos en una parte de esta normalización de formatos utilizaremos librerías para que nuestras salidas de error sean uniformes. En .NetCore, utilizaremos ProblemDetails para capturar nuestras excepciones y devolverlas siguiendo un estándar ISO.
En cuanto a la monitorización, el hecho de multiplicar el número de piezas de nuestro sistema hace más imprescindible que nunca tener una visión rápida y global del estado de cada una de ellas.
Dentro de su alcance de observación, solemos encontrar interesante monitorizar:
- Operacionales:
- Bases de datos: evolución en consumos de CPU’s, DTU’s, memoria, disco,tiempo de vida de páginas en memoria…
- Reliability (por cada pieza): evolución en la disponibilidad, duración de las peticiones, agrupadas por petición o por unidades de negocio (país, fábrico o colegio).
- Procesos: especialmente aquellos procesos en background de nuestro sistema. Evolución del estado, última ejecución, evolución de la duración…
- Mensajes: pieza clave para conocer el estado real de nuestro sistema. Evolución en el número de mensajes recibidos, número de mensajes entregados, número de mensajes fallidos, peticiones.
- KPI’s: enfocados al negocio: número de ventas realizadas, tamaño medio del carrito de la compra, usuarios con GDPR aceptado, etc…
Existen múltiples herramientas para realizar esta monitorización. Por su flexibilidad, solemos utilizar Grafana, alimentada por distintas fuentes de datos.
Resiliencia
Al aumentar el número de piezas, la probabilidad combinada de que alguna de las partes falle aumenta también, por lo que debemos asegurarnos de que el sistema en su completitud siga funcionando aunque alguna pieza falle o el sistema sufra condiciones anómalas.
Entre las distintas técnicas con las que aseguramos esta resiliencia, podemos destacar:
- Timeouts: Considerar que el mensaje has fallado si la respuesta no se ha entregado en un periodo determinado de precio, previniendo el consumo de recursos del cliente. Esta configuración no tiene porqué ser fija, pudiendo ser valores dinámicos como por ejemplo tres veces la desviación típica de la media de tiempo de respuesta observada en un periodo previo.
- Circuit breaker: si se detecta que el problema es persistente, debemos considerarlo no operativos. Esta técnica evita el consumo innecesario de recursos y la degradación innecesaria del rendimiento global. Incrementa no obstante el riesgo de sobrecargar la salud de las máquinas sanas redirigiéndoles demasiado tráfico.
- Reintentos: si el fallo en la ejecución de una tarea tiene un coste, y hay tolerancia a este retraso, puede tener más sentido reintentar el mensaje intentándolo de nuevo. Estos reintentos potencialmente pueden ir mal, pudiernod llegar a ser un ataque de denegación de servicio autoinflingido si el volumen es muy elevado. Para evitar esto, debemos limitar el número de reintentos y utilizando un tiempo exponencial (y a ser posible, con un factor de aleatoriedad), antes de mandarlo, dando así una mayor oportunidad al sistema de recuperarse en el mismo.
En .Net, nos apoyaremos en la librería Polly para implementar estas tres técnicas.
Adicionalmente, se priorizará las comunicaciones a través de mensajes usando un bus. A través de las características del bus utilizado, obtendremos también:
- Salvar picos en el sistema: al realizarse la comunicación a través de un sistema con capacidad de persistencia (según el tiempo de vida definido en la cola o topic), evitaremos que si el generador de mensajes produce un volumen superior a la capacidad del consumidor, estos se vayan encolando parar ser procesados por el consumidor con la capacidad que éste pueda soportar.
- Servicio de Deadletters: un microservicio puede generar mensajes envenenados que disparen errores en oros microservicios. Para ello, los mensajes problemáticos son reenviados a un almacenaje para su posterior diagnóstico. Además, nos permite monitorizar la salud de los mensajes que viajan por el sistema.
Comunicación entre microservicios
Tipos de comunicación con mensajes
Identificamos cuatro tipos de comunicación:
- Request/response: el mensaje es síncrono. El microservicio que envía espera respuesta inmediata. El mensaje es consumido por un único microservicio que genera la respuesta. Un ejemplo de esta comunicación sería enviar un mensaje “Command”.
- Sidewinder: el mensaje es síncrono, con al menos una respuesta inmediata esperada. Sin embargo, este mensaje se consulta, no se consume, pudiendo recibir respuesta de un subconjunto de los destinatarios, no solo de uno.
- Winner-take-all: el mensaje es asíncrono, por lo que no espera una respuesta inmediata. El microservicio emisor simplemente no se preocupa después de que el mensaje sea emitido. En este caso, el mensaje es consumido, por lo que será entregado como máximo a un único microservicio receptor. Por ejemplo, redimensionar imágenes hacia un cluster de procesos.
- Fire-and-forget: Los emisores emiten mensajes sin destinario, y los receptores aceptan todos y cada uno de los mensajes, pero solo actúan con aquellos que les interesan. Los mensajes son necesariamente asíncronos y son observados por múltiples receptores (no consumidos). En la práctica, una capa de enrutado asegura una eficiente distribución y enrutado de la información del mensaje sobre la red. Por ejemplo, en un proceso de checkout, cada uno de los workflows: confirmación, facturación, entrega…
Como recomendación general, hay que intentar tender a que las comunicaciones entre microservicios sean asíncronas, siendo una opción más resiliente (entre otras ventajas).
No obstante, esto no siempre es sencillo. Para comunicación entre microservicios, adicionalmente nos apoyamos en algunos patrones para resolver la problemática asociada:
Gateway Aggregation
Es muy frecuente el escenario en que necesitemos que un microservicio solicite información de otro microservicio. Esta comunicación en muchos casos se realizará a través de una llamada directa al Rest Api del microservicio de destino. Entre otras cosas, al estar securizadas, necesitamos facilitarle además el token de autorización.
Pese a que existen múltiples opciones para solventar este escenario como, por ejemplo crear un nuevo token a través de un flujo client credential del microservicio, tiene problemas como estar atados a conocer la dirección del microservicio distinto o una mayor complejidad.
En este escenario, intentamos utilizar el patrón Gateway aggregation. Así, será una pieza previa, el agregador el encargado de comunicar a ambos microservicios. Así, llamaría al segundo microservicio y posteriormente al primer microservicio facilitándole la información requerida obtenida anteriormente.
Request-reply async
Un escenario que nos encontramos mucho son peticiones (queries o commands) que, además de por beneficios comentados anteriormente, por el tiempo de procesamiento que requieren, queremos encolar la solicitud y continuar con el resto del flujo, hasta que de manera reactiva seamos notificados que dicho proceso ha terminado y podemos recibir su respuesta. Para este escenario, normalmente utilizamos el patrón Request-reply async utilizando canales de petición y respuesta en nuestro sistema de bus.
Con este patrón, el solicitante crea un mensaje de solicitud, indicando normalmente algún id de correlación o sesión, y lo envío al canal de solicitudes, recibiendo confirmación si se ha registrado dicha solicitud correctamente.
El receptor, recibirá dicho mensaje (según su capacidad en ese momento) y lo procesará, tras lo cual escribirá en el canal de respuestas.
El solicitante reaccionará a la inserción de dicho mensaje de respuesta, y a través del id de correlación y, apoyándose en un sistema de persistencia si fuese necesario donde recuperar el estado de la ejecución previa, continuará con la misma.
Según el sistema de bus utilizado, existen distintos enfoques de implementar dicho patrón. De hecho, en algunos de ellos utilizando el concepto de sesión y sesión con estado, nos facilitaría retomar la información de estado de dicha sesión y continuar con estado que tenía la ejecución en el momento de mandar la solicitud.
Transacciones
Utilizamos transacciones continuamente. Una transacción nos permite agrupar distintas actividades para llevar nuestro sistema de un estado consistente en otro: o bien todo funciona, o nada cambia.
Las transacciones no solo aplican a las bases de datos, pudiendo usarse, por ejemplo también, con los sistemas de mensajería.
En una solución monolítica, todas nuestras creaciones y actualizaciones probablemente caerán dentro de un único ámbito de transacción. Cuando partimos nuestras bases de datos u otros elementos en varios, perdemos la seguridad de esta única transacción.
Estas son los enfoques habituales a utilizar:
Prueba más tarde
Si una de las partes falla, podemos encolar esta parte de la operación en una cola o fichero de log, y probar de neuvo más tarde. Para algunos tipos de operaciones esto tiene sentido (errores transient, por ejemplo), pero hay que asumir que este reintento arreglará el problema.
Esto es otra forma de lo que se llama consistencia eventual. En lugar de utilizar un ámbito de transacción para asegurarnos que el sistema está en un estado consistente cuando la transacción se completa, aceptamos que el sistema llegará a ser consistente en algún momento en el futuro. Esto es especialmente útil en operaciones de negocio con una vida muy larga.
Cancelar la operación completa
Otra opción es rechazar la operación completa. En este caso, tenemos que retornar el sistema a un estado consistente. Así, habría que revertir no solo la parte que ha fallado, sino la parte de la operación previa ocurrida. Para ello, usaremos una compensación de transacción, esto es, disparando una nueva transacción para revertir lo que ha ocurrido. Esta transacción a su vez también puede fallar. Por ello, necesitaremos o bien reintentar esta transacción de compensación, o tener algún proceso backend para limpiar a posteriori esa inconsistencia
No obstante, cuando no manejamos una o dos operaciones a mantener consistentes, sino un número más alto, manejar estas transacciones de compensación por cada modo de fallo se vuelve cada vez más complicado y enrevesado.
Transacciones distribuidas
Una alternativa a manejar manualmente transacciones de compensación es el uso de transacciones distribuidas. Con este enfoque, se utiliza un gestor de transacción para orquestar las otras transacciones que se hacen en los subsistemas. Como en una transacción normal, una transacción distribuida trata de asegurar que todo se mantiene en un estado consistente, solo que a través de distintos sistemas en los que correr los distintos procesos.
El algoritmo más común para manejar transacciones distribuidas es usar un commit en dos fases. En él, primero hay una fase de votación, donde cada participante le dice al gestor de transacción si cree que la transacción local va a poder realizarse. Si el gestor recibe un voto favorable unánime, notifica a todos los participantes a hacer su commit. Un único voto supone la notificación de rollback a todas las partes.
Para la mayoría de escenarios, intentamos evitar las transacciones distribuidas por los problemas que nos terminan causando. Así, priorizamos una política definida de reintentos para errores transients (o para aquellos procesos o errores que podemos considerar puntuales y en los que tiene sentido hacer una espera para intentar nuevamente). Para escenarios más complejos, utilizamos el enfoque de cancelar la operación completa, gestionándolo a través de sagas.
Sagas
Una saga es una secuencia de transacciones locales que hay que coordinar. Para cada una de estas transacciones se debe definir una acción compensatoria que deshaga el cambio que ha hecho la transacción (lo que ya hemos visto en el apartado Cancelar la operación completa).
Existen dos patrones de implementación
Con coreografía
Cada servicio debe conocer e implementar cómo responder a ciertos eventos de la saga. Así, el inconveniente de este enfoque es distribuir la lógica en varios sitios, con el riesgo adicional de introducir dependencias cíclicas.
Con orquestación
Habrá un proceso o servicio encargado de la coordinación de los pasos de la saga al que llamaremos gestor o coordinador de la saga. Este gestor aglutina toda la lógica de la saga simplificándola de esta manera y evitando dependencias cíclicas entre servicios.
Utilizamos ambos enfoques, según el escenario de aplicación. Normalmente, solemos utilizar coreografía para procesos más pequeños y con una definición más cerrada, y pasar a una estrategia de orquestación cuando las operaciones se vuelven más complejas.
Separación de monolito
Partimos de una base de datos central (BDC) de tipo monolítico desplegada en MySQL sobre AWS RDS, compuesta por 637 tablas en el entorno de PRE, con tablas en desuso y sobreindexadas, que requiere de separación por contextos de aplicaciones. La base de datos está centralizada bajo el esquema ‘uno’ a la que distintas aplicaciones desplegadas y mantenidas por distintos proveedores acceden.
Mediante la separación del monolito en bases de datos independientes comunicadas por servicios se busca solucionar problemas de acoplamiento, estabilidad y resiliencia, dispersando el consumo de CPU evitando así bloqueos y timeouts mientras se aumenta la resiliencia y se libera la sobrecarga sobre un servidor centralizado. Para conseguir esta segregación de la base de datos en contextos delimitados o bounded context intercomunicados por servicios dedicados, se realizará una transición a una arquitectura orientada a servicios (SOA) para la que se hará uso del servicio de migración de datos (DMS) de AWS. En esta fase intermedia, se extraerán tablas acotadas al uso de aplicaciones específicas, como lms o Módulos Administrativos. La extracción de estas tablas de DBC uno a un nuevo esquema desplegado sobre un servidor dedicado y dimensionado permitirá aligerar la carga del monolito original y aliviar el uso intensivo sobre él. Una vez la migración de las tablas se realice mediante tareas de migración en batch, particionadas para las tablas que superen el millón de registros, se crearán las indexaciones secundarias y foreign keys para mantener la coherencia del modelo. Posteriormente, se eliminarán en DBC uno las tablas no requeridas, liberando espacio de memoria, y se mantendrán las tablas a las que aplicaciones externas al bounded context sí requieran acceder en modo lectura; finalmente, se establecerá una ongoing replication que habilite la actualización de las transacciones en origen y destino, haciendo uso de una replicación mediante mecanismos de CDC (Change Data Capture).
Por otro lado, en el contexto actual nos encontramos además con que la lógica de negocio se sustenta en el uso de procedimientos almacenados que a su vez producen la creación de un exceso de tablas temporales evitables que consumen recursos valiosos en cada transacción y comprometen la funcionalidad de la plataforma. Mediante el despliegue de servicios se busca, además del mencionado incremento de resiliencia y optimización de recursos y mejora del rendimiento, la extracción de la carga de modelo lógico a las aplicaciones y servicios, lo que supone la reducción de interdependencias a nivel de procedimientos almacenados que bloquean nuevos despliegues y mejoras, además de un aumento de seguridad y control de procesos.
Separación de contextos con DMS
Con el objetivo de ganar tiempos y acelerar la comunicación mediante servicios, se separarán los contextos duplicando las bases de datos, pero configurando las réplicas solo en aquellas tablas donde se ejecuten procesos de escritura en un contexto aislado, de manera que en el contexto de lectura, se sincronicen los valores actualizados, manteniendo la responsabilidad de lectura y escritura separadas, dando pie a una contextualización de CQRS y facilitando la separación del coste de CPU entre servidores distribuyendo los procesos según operación.
Para la distribución de las tablas en servidores y esquemas diferentes, se debe primero tener una clara visión de qué aplicaciones acceden a qué tablas específicas y si lo hacen en calidad de lectura o escritura.
La separación de las tablas y cohesión de los datos no puede hacerse de forma bidireccional, porque ante un escenario de concurrencia de aplicaciones sobre la misma tabla incurre en pérdida de datos. Por tanto, se requiere una replicación unidireccional, lo cual exige una labor de documentación y análisis detallado que respete la lógica funcional de la plataforma.
Puesto que distintos proveedores han ido desarrollando sobre el esquema uno, no hay una imagen y documentación centralizadas que ofrezcan una visibilidad detallada de qué se ha desplegado, con lo que la segregación de las tablas podría afectar a la integridad de la plataforma si no se hace respetando la gobernanza de los datos.
Una vez compilada la documentación por parte de todos los responsables de los distintos componentes de la plataforma, y tras la fase de análisis de los campos críticos, se pasará a la separación propiamente dicha. Esta separación consistirá en aislar, en primera instancia, las tablas de Módulos Administrativos o del LMS, dependiendo del alcance y coste/beneficio que implique la separación de uno u otro contexto.
El proceso de separación se hará empleando Data Migration Service (DMS) de AWS. DMS es la herramienta de migración y replicación CDC de AWS. Para poder usarlo, se requiere tener un servidor para cada esquema a sincronizar, y el despliegue de los componentes del DMS que son la instancia de replicación, la asignación de los endpoints y la creación de tareas de migración en batch y la ongoing replication que registra los cambios del esquema de origen en el esquema de destino.
Requisitos DMS
Crear un servidor de replicación
Crear endpoints de origen y destino con información de conexión a los data stores
Crear una o más tareas de migración que muevan las tablas especificadas
Una vez migradas las tablas a separar por contextos, se eliminarán las tablas de DBC que sean de uso exclusivo del nuevo contexto y se establecerá la ongoing replication entre las tablas que requieran de acceso por parte de aplicaciones de MA y de LMS. A su vez, es necesario recrear las indexaciones secundarias y key-constrains en las tablas de destino.
- Una tarea consiste de tres fases principales:
Carga completa de datos
Aplicación de cambios cacheados
Replicación contínua
- AWS DMS crea:
Tablas
Claves Primarias
En determinados contextos, indexación primaria
- AWS DMS nocrea:
Indexación secundaria
Non-primary key constraints
Data defaults
Escenarios de Concurrencia
Ante el escenario de concurrencia de escritura de aplicaciones sobre tablas, se valorará la separación de la(s) tabla(s) en cuestión en caso de que cada aplicación haga uso de campos diferentes. Ante la imposibilidad de separación de dichas tablas, se crearán servicios que coordinen la comunicación de las aplicaciones con las tablas.
Separación canónica de contextos
Una vez creados los servicios necesarios para intercomunicar todas las aplicaciones que así lo requieran, con las piezas de negocio separadas contextualmente, se quitarán todas las entidades que no pertenezcan al conjunto dejando un bounded context representativo de esa pieza de negocio.
Interfaz de comunicación entre contextos mediante servicios
Una vez identificados los servicios relativos al contexto que se haya separado, cubriendo todas las necesidades de CRUD, se eliminará el DMS como sistema de sincronización de datos, obligando que toda gestión del dato de dicho contexto, sea manejada a través de esta capa de servicios. Esto nos permitirá reducir a un ámbito más concreto los datos, segmentando un bounded context representativo del negocio concreto (*).
El detalle técnico de los servicios creados por Plain Concepts se contempla en el inicio de este documento.
Para esta arquitectura orientada a servicios se utilizarán los servicios existentes y la creación de nuevos servicios se evaluará en base a los requerimientos de datos por cada aplicación.
Beneficios
Con esta solución conseguiremos aligerar la carga de procesamiento y memoria de un solo servidor que hospeda toda la información para todas las aplicaciones, que además obliga a adoptar múltiples estrategias de failover para mantener la prestación de servicios en caso de caída, es vulnerable a picos de procesamiento de una aplicación en concreto afectando a todas, etc. Con la separación de contextos ganamos un desacoplamiento en los datos, un manejo contextual de configuraciones de fallos, escalados y monitorización concreta que nos permite tener una visibilidad centrada en casos de negocio, permitiendo compartir la información contextual con todos aquellos que la necesiten a través de servicios y no acoplando soluciones o adaptando entidades de modelos para cubrir necesidades de negocio con propósitos muy concretos. Es un primer reto que luego nos permitirá adaptarnos a situaciones de comunicación con otras aplicaciones más complejas.
(*) Para conseguir estos objetivos, necesitamos la documentación y colaboración de los distintos actores, con el propósito de identificar las dependencias y poder separar los contextos de datos