Refactoriza una aplicación monolítica en microservicios

Esta guía de referencia es la segunda de una serie de cuatro partes sobre el diseño, la compilación y la implementación de microservicios. En esta serie, se describen los diversos elementos de una arquitectura de microservicios. En ella, se incluye información sobre los beneficios y las desventajas del patrón de arquitectura de microservicios, y cómo aplicarlo.

  1. Introducción a los microservicios
  2. Refactoriza una aplicación monolítica en microservicios (este documento)
  3. Comunicación entre servicios en una configuración de microservicios
  4. Seguimiento distribuido en una aplicación de microservicios

Esta serie está dirigida a desarrolladores y arquitectos de aplicaciones que diseñan y, luego, implementan la migración para refactorizar una aplicación monolítica en una aplicación de microservicios.

El proceso de transformación de una aplicación monolítica en microservicios es una forma de modernizar la aplicación. Para lograr la modernización de la aplicación, te recomendamos que no refactorices todo el código al mismo tiempo. En su lugar, recomendamos que refactorices de forma incremental la aplicación monolítica. Cuando refactorizas una aplicación de forma incremental, compilas gradualmente una aplicación nueva que consta de microservicios y la ejecutas junto con tu aplicación monolítica. Este enfoque también se conoce como patrón estrangulador. Con el tiempo, la cantidad de funcionalidad que implementa la aplicación monolítica se reduce hasta que desaparece por completo o se convierte en otro microservicio.

Para separar las capacidades de una aplicación monolítica, debes extraer con cuidado los datos, la lógica y los componentes orientados al usuario de la capacidad y redireccionarlos al nuevo servicio. Es importante que comprendas bien el espacio del problema antes de pasar al espacio de la solución.

Cuando comprendas el espacio del problema, comprenderás los límites naturales del dominio que proporcionan el nivel correcto de aislamiento. Te recomendamos que crees servicios más grandes en lugar de servicios más pequeños hasta que comprendas el dominio a fondo.

La definición de límites del servicio es un proceso iterativo. Debido a que este proceso es una cantidad de trabajo no trivial, debes evaluar continuamente el costo de la separación en comparación con los beneficios que obtienes. A continuación, se presentan factores que te ayudarán a evaluar cómo abordar la separación de una aplicación monolítica:

  • Evita refactorizar todo de una vez. Para priorizar la separación del servicio, evalúa el costo frente a los beneficios.
  • Los servicios en una arquitectura de microservicios se organizan en torno a las inquietudes comerciales y no las inquietudes técnicas.
  • Cuando migres los servicios de forma incremental, configura la comunicación entre los servicios y la aplicación monolítica para que pase por contratos de API bien definidos.
  • Los microservicios requieren mucho más automatización: piensa con anticipación en la integración continua (CI), la implementación continua (CD), el registro central y la supervisión.

En las siguientes secciones, se analizan varias estrategias para separar los servicios y migrar de forma incremental tu aplicación monolítica.

Separa por diseño impulsado por dominios

Los microservicios deben diseñarse alrededor de las capacidades empresariales, no de las capas horizontales, como el acceso a los datos o la mensajería. Los microservicios también deben tener acoplamiento bajo y cohesión funcional alta. Los microservicios se vinculan de manera flexible si puedes cambiar un servicio sin necesidad de actualizar otros al mismo tiempo. Un microservicio es coherente si tiene un solo propósito bien definido, como administrar cuentas de usuario o procesar pagos.

El diseño impulsado por dominios (DDD) requiere comprender bien el dominio para el que se escribe la aplicación. El conocimiento necesario del dominio para crear la aplicación reside dentro de las personas que lo conocen: los expertos en el dominio.

Puedes aplicar el enfoque de DDD de forma retroactiva a una aplicación existente de la siguiente manera:

  1. Identifica un lenguaje ubicuo, un vocabulario común que se comparta entre todas las partes interesadas. Como desarrollador, es importante usar términos en tu código que una persona no técnica pueda comprender. Lo que tu código intenta lograr debe ser un reflejo de los procesos de tu empresa.
  2. Identifica los módulos relevantes en la aplicación monolítica y, luego, aplica el vocabulario común a esos módulos.
  3. Define contextos delimitados, en los que apliques límites explícitos a los módulos identificados con responsabilidades definidas claramente. Los contextos delimitados que identificas son candidatos para que se refactoricen en microservicios más pequeños.

En el siguiente diagrama, se muestra cómo puedes aplicar contextos delimitados a una aplicación existente de comercio electrónico:

Se aplican contextos delimitados a una aplicación.

Figura 1. Las capacidades de las aplicaciones se separan en contextos delimitados que se migran a los servicios.

En la figura 1, las capacidades de la aplicación de comercio electrónico se separan en contextos delimitados y se migran a los servicios de la siguiente manera:

  • Las capacidades de administración y entrega de pedidos están vinculadas a las siguientes categorías:
    • La capacidad de administración de pedidos migra al servicio de pedidos.
    • La capacidad de administración de la entrega de logística migra al servicio de entrega.
    • La capacidad del inventario migra al servicio de inventario.
  • Las capacidades de contabilidad se unen en una sola categoría:
    • Las capacidades de consumidor, vendedor y de terceros están vinculadas y migran al servicio de cuentas.

Prioriza los servicios para la migración

Un punto de partida ideal para separar los servicios es identificar los módulos vinculados de manera flexible en tu aplicación monolítica. Puedes elegir un módulo con acoplamiento bajo como uno de los primeros candidatos para convertirlo en un microservicio. Para completar un análisis de dependencias de cada módulo, ten en cuenta lo siguiente:

  • El tipo de dependencia: dependencias de datos o de otros módulos.
  • La escala de la dependencia: cómo un cambio en el módulo identificado puede afectar a otros módulos.

Migrar un módulo con muchas dependencias de datos suele ser una tarea no trivial. Si migras funciones primero y migras los datos relacionados más adelante, es posible que leas y escribas datos en varias bases de datos de forma temporal. Por lo tanto, debes tener en cuenta los desafíos de sincronización e integridad de los datos.

Recomendamos extraer módulos que tengan diferentes requisitos de recursos en comparación con el resto de la aplicación monolítica. Por ejemplo, si un módulo tiene una base de datos en la memoria, puedes convertirlo en un servicio, que luego se puede implementar en hosts con mayor memoria. Cuando conviertes módulos con requisitos de recursos específicos en servicios, puedes hacer que tu aplicación sea mucho más fácil de escalar.

Desde el punto de vista de las operaciones, la refactorización de un módulo en su propio servicio también implica ajustar las estructuras de equipos existentes. La mejor manera de rendir cuentas de forma clara es capacitar a los equipos pequeños que poseen un servicio completo.

Entre los factores adicionales que pueden afectar la forma en que priorizas los servicios para la migración, se incluyen la importancia empresarial, la cobertura de pruebas integral, la postura de seguridad de la aplicación y la aprobación de la organización. Según las evaluaciones, puedes clasificar los servicios como se describe en el primer documento de esta serie, por el beneficio que recibes de la refactorización.

Extrae un servicio de una aplicación monolítica

Después de identificar el candidato de servicio ideal, debes identificar una forma de que coexistan los microservicios y los módulos monolíticos. Una forma de administrar esta coexistencia es ingresar un adaptador de comunicación entre procesos (IPC), que puede ayudar a que los módulos funcionen en conjunto. Con el tiempo, el microservicio toma la carga y elimina el componente monolítico. Este proceso incremental reduce el riesgo de pasar de la aplicación monolítica al microservicio nuevo porque puedes detectar errores o problemas de rendimiento de forma gradual.

En el siguiente diagrama, se muestra cómo implementar el enfoque de IPC:

Se implementa un enfoque de IPC para ayudar a que los módulos funcionen en conjunto.

Figura 2. Un adaptador de IPC coordina la comunicación entre la aplicación monolítica y un módulo de microservicios.

En la figura 2, el módulo Z es el candidato de servicio que deseas extraer de la aplicación monolítica. Los módulos X e Y dependen del módulo Z. Los módulos de microservicios X y Y usan un adaptador de IPC en la aplicación monolítica para comunicarse con el módulo Z a través de una API de REST.

En el siguiente documento de esta serie, Comunicación entre servicios en una configuración de microservicios, se describe el patrón estrangulador y cómo deconstruir un servicio desde la aplicación monolítica.

Administra una base de datos de aplicación monolítica

Normalmente, las aplicaciones monolíticas tienen sus propias bases de datos monolíticas. Uno de los principios de una arquitectura de microservicios es tener una base de datos para cada microservicio. Por lo tanto, cuando modernices tu aplicación monolítica en microservicios, debes dividir la base de datos monolítica según los límites del servicio que identifiques.

Para determinar dónde dividir una base de datos monolítica, primero analiza las asignaciones de la base de datos. Como parte del análisis de extracción de servicios, recopilaste algunas estadísticas sobre los microservicios que necesitas crear. Puedes usar el mismo enfoque para analizar el uso de la base de datos y asignar tablas u otros objetos de base de datos a los microservicios nuevos. Las herramientas como SchemaCrawler, SchemaSpy y ERBuilder pueden ayudarte a realizar ese análisis. La asignación de tablas y otros objetos te ayuda a comprender la vinculación entre los objetos de la base de datos que abarca tus posibles límites de microservicios.

Sin embargo, dividir una base de datos monolítica es complejo, ya que puede que no haya una separación clara entre los objetos de la base de datos. También debes considerar otros problemas, como la sincronización de datos, la integridad transaccional, las uniones y la latencia. En la sección siguiente, se describen los patrones que pueden ayudarte a responder a estos problemas cuando divides la base de datos monolítica.

Tablas de referencia

En las aplicaciones monolíticas, es común que los módulos accedan a los datos requeridos desde un módulo diferente a través de una unión de SQL a la tabla del otro módulo. En el siguiente diagrama, se usa el ejemplo anterior de la aplicación de comercio electrónico para mostrar este proceso de acceso con una unión de SQL:

Un módulo usa una unión de SQL para acceder a los datos desde otro módulo.

Figura 3. Un módulo une los datos en una tabla de un módulo diferente.

En la figura 3, para obtener información de un producto, un módulo de pedido usa una clave externa product_id para unir un pedido a la tabla de productos.

Sin embargo, si deconstruyes módulos como servicios individuales, te recomendamos no hacer que el servicio de pedidos llame directamente a la base de datos del servicio de productos para ejecutar una operación de unión. En las siguientes secciones, se describen las opciones que puedes considerar para segregar los objetos de la base de datos.

Comparte datos a través de una API

Cuando separas los módulos o las funciones principales en microservicios, por lo general, usas API para compartir y exponer datos. El servicio al que se hace referencia expone los datos como una API que el servicio que llama necesita, como se muestra en el siguiente diagrama:

Los datos se exponen a través de una API.

Figura 4. Un servicio usa una llamada a la API para obtener datos de otro servicio.

En la figura 4, un módulo de pedido usa una llamada a la API para obtener datos de un módulo de producto. Esta implementación tiene problemas de rendimiento obvios debido a llamadas adicionales de red y base de datos. Sin embargo, compartir datos a través de una API funciona bien cuando el tamaño de los datos es limitado. Además, si el servicio al que se llama muestra datos que tienen una frecuencia de cambio conocida, puedes implementar una caché de TTL local en el emisor para reducir las solicitudes de red al servicio llamado.

Replica datos

Otra forma de compartir datos entre dos microservicios separados es replicar los datos en la base de datos del servicio dependiente. La replicación de datos es de solo lectura y se puede volver a compilar en cualquier momento. Este patrón permite que el servicio sea más coherente. En el diagrama siguiente, se muestra cómo funciona la replicación de datos entre dos microservicios:

Los datos se replican entre los microservicios.

Figura 5. Los datos de un servicio se replican en una base de datos de un servicio dependiente.

En la figura 5, la base de datos del servicio de productos se replica en la base de datos del servicio de pedidos. Esta implementación permite que el servicio de pedidos obtenga datos de productos sin llamadas repetidas al servicio de productos.

Para compilar la replicación de datos, puedes usar técnicas como vistas materializadas, captura de datos modificados (CDC) y notificaciones de eventos. Los datos replicados tienen coherencia eventual, pero puede haber un retraso en la replicación de los datos, por lo que existe el riesgo de entregar datos inactivos.

Datos estáticos como configuración

Los datos estáticos, como los códigos de país y las monedas compatibles, cambian lentamente. Puedes inyectar esos datos estáticos como una configuración en un microservicio. Los microservicios modernos y los frameworks en la nube proporcionan funciones para administrar esos datos de configuración mediante servidores de configuración, almacenes clave-valor y bóvedas. Puedes incluir estas funciones de forma declarativa.

Datos mutables compartidos

Las aplicaciones monolíticas tienen un patrón común conocido como estado mutable compartido. En una configuración de estado mutable compartido, varios módulos usan una sola tabla, como se muestra en el siguiente diagrama:

Una configuración de estado mutable compartido hace que una sola tabla esté disponible para varios módulos.

Figura 6. Varios módulos usan una sola tabla.

En la figura 6, las funciones de pedido, pago y envío de la aplicación de comercio electrónico usan la misma tabla ShoppingStatus para mantener el estado del pedido del cliente durante el proceso de compra.

Para migrar una aplicación monolítica de estado mutable compartido, puedes desarrollar un microservicio de ShoppingStatus independiente para administrar la tabla de la base de datos ShoppingStatus. Este microservicio expone las API para administrar el estado de compra de un cliente, como se muestra en el siguiente diagrama:

Las API están expuestas a otros servicios.

Figura 7. Un microservicio expone las API a otros servicios.

En la figura 7, los microservicios de pago, pedido y envío usan las API de microservicios de ShoppingStatus. Si la tabla de la base de datos está muy relacionada con uno de los servicios, te recomendamos que muevas los datos a ese servicio. Luego, puedes exponer los datos a través de una API para que otros servicios los consuman. Esta implementación te ayuda a garantizar que no tengas demasiados servicios detallados que se llamen entre sí con frecuencia. Si divides los servicios de forma incorrecta, debes volver a definir los límites del servicio.

Transacciones distribuidas

Después de aislar el servicio de la aplicación monolítica, es posible que una transacción local en el sistema monolítico original se distribuya entre varios servicios. Una transacción que abarca varios servicios se considera una transacción distribuida. En la aplicación monolítica, el sistema de bases de datos garantiza que las transacciones sean atómicas. Para manejar transacciones entre varios servicios de un sistema basado en microservicios, debes crear un coordinador de transacciones global. El coordinador de transacciones controla la reversión, las acciones de compensación y otras transacciones que se describen en el siguiente documento de esta serie, Comunicación entre servicios en una configuración de microservicios.

Coherencia de los datos

Las transacciones distribuidas presentan el desafío de mantener la coherencia de los datos entre los servicios. Todas las actualizaciones se deben realizar de forma atómica. En una aplicación monolítica, las propiedades de las transacciones garantizan que una consulta muestre una vista coherente de la base de datos según su nivel de aislamiento.

Por el contrario, considera una transacción de varios pasos en una arquitectura basada en microservicios. Si alguna transacción de servicio falla, los datos deben conciliarse mediante la reversión de los pasos que se realizaron de forma correcta en los otros servicios. De lo contrario, la vista global de los datos de la aplicación es incoherente entre los servicios.

Puede ser difícil determinar cuándo falló un paso que implementa la coherencia eventual. Por ejemplo, es posible que un paso no falle de inmediato, sino que se bloquee o se agote el tiempo de espera. Por lo tanto, es posible que debas implementar un tipo de mecanismo de tiempo de espera. Si los datos duplicados están inactivos cuando el servicio llamado accede a él, el almacenamiento en caché o la replicación de datos entre servicios para reducir la latencia de red también pueden generar datos incoherentes.

En el siguiente documento de la serie, Comunicación entre servicios en una configuración de microservicios, se proporciona un ejemplo de un patrón para controlar transacciones distribuidas entre microservicios.

Diseña la comunicación entre servicios

En una aplicación monolítica, los componentes (o módulos de aplicación) se invocan directamente mediante llamadas a funciones. Por el contrario, una aplicación basada en microservicios consta de varios servicios que interactúan entre sí en la red.

Cuando diseñes la comunicación entre servicios, primero piensa en cómo se espera que los servicios interactúen entre sí. Las interacciones de servicio pueden ser una de las siguientes opciones:

  • Interacción uno a uno: cada solicitud de cliente es procesada por exactamente un servicio.
  • Interacciones de uno a varios: cada solicitud es procesada por varios servicios.

También considera si la interacción es síncrona o asíncrona:

  • Síncrona: el cliente espera una respuesta oportuna del servicio y puede bloquearse mientras espera.
  • Asíncrona: el cliente no se bloquea mientras espera una respuesta. La respuesta, si la hay, no se envía de inmediato.

En la siguiente tabla, se muestran combinaciones de estilos de interacción:

Uno a uno Uno a varios
Síncrono Solicitud y respuesta: envía una solicitud a un servicio y espera una respuesta.
Asíncrono Notificación: envía una solicitud a un servicio, pero no se espera ni se envía ninguna respuesta. Publicar y suscribirse: El cliente publica un mensaje de notificación, y cero o más servicios interesados consumen el mensaje.
Solicitud y respuesta asíncrona: envía una solicitud a un servicio, que responde de forma asíncrona. El cliente no se bloquea. Publicación y respuestas asíncronas: el cliente publica una solicitud y espera las respuestas de los servicios interesados.

Cada servicio suele usar una combinación de estos estilos de interacción.

Implementa la comunicación entre servicios

Para implementar la comunicación entre servicios, puedes elegir entre diferentes tecnologías de IPC. Por ejemplo, los servicios pueden usar mecanismos de comunicación síncronos basados en solicitudes y respuestas, como REST basado en HTTP, gRPC o Thrift. Como alternativa, los servicios pueden usar mecanismos de comunicación asíncronos basados en mensajes, como AMQP o STOMP. También puedes elegir entre varios formatos de mensajes diferentes. Por ejemplo, los servicios pueden usar formatos legibles basados en texto, como JSON o XML. Como alternativa, los servicios pueden usar un formato binario, como Avro o búferes de protocolo.

Configurar servicios para llamar directamente a otros servicios genera una alta vinculación entre los servicios. En su lugar, recomendamos usar la mensajería o la comunicación basada en eventos:

  • Mensajería: Cuando implementas la mensajería, quitas la necesidad de que los servicios se llamen directamente. En cambio, todos los servicios conocen un agente de mensajes y envían mensajes a ese agente. El agente de mensajes los guarda en una cola de mensajes. Otros servicios pueden suscribirse a los mensajes que les interesan.
  • Comunicación basada en eventos: cuando implementas el procesamiento impulsado por eventos, la comunicación entre los servicios se realiza a través de eventos que producen los servicios individuales. Los servicios individuales escriben sus eventos en un agente de mensajes. Los servicios pueden escuchar los eventos de interés. Este patrón mantiene los servicios con acoplamiento bajo porque los eventos no incluyen cargas útiles.

En una aplicación de microservicios, recomendamos usar la comunicación asíncrona entre servicios en lugar de la comunicación síncrona. La solicitud de respuesta es un patrón de arquitectura bien comprendido, por lo que diseñar una API síncrona puede parecer más natural que diseñar un sistema asíncrono. La comunicación asíncrona entre servicios se puede implementar mediante la mensajería o la comunicación impulsada por eventos. El uso de la comunicación asíncrona proporciona las siguientes ventajas:

  • Acoplamiento bajo: Un modelo asíncrono divide la interacción de solicitud y respuesta en dos mensajes separados, uno para la solicitud y otro para la respuesta. El consumidor de un servicio inicia el mensaje de solicitud y espera la respuesta, y el proveedor de servicios espera los mensajes de solicitud a los que responde con mensajes de respuesta. Esta configuración significa que el emisor no tiene que esperar el mensaje de respuesta.
  • Aislamiento de falla: El remitente puede seguir enviando mensajes, incluso si el consumidor posterior falla. El consumidor recoge las tareas pendientes cada vez que se recupera. Esta capacidad es especialmente útil en una arquitectura de microservicios, porque cada servicio tiene su propio ciclo de vida. Sin embargo, las API síncronas requieren que el servicio posterior esté disponible o que la operación falle.
  • Capacidad de respuesta: un servicio ascendente puede responder más rápido si no espera servicios descendentes. Si hay una cadena de dependencias de servicios (servicio A llama a B, que llama a C, etc.), la espera de llamadas síncronas puede agregar cantidades de latencia inaceptables.
  • Control de flujo: Una cola de mensajes actúa como un búfer, de modo que los receptores pueden procesar mensajes con su propia frecuencia.

Sin embargo, los siguientes son algunos de los desafíos de usar los mensajes asíncronos de manera eficaz:

  • Latencia: Si el agente de mensajes se convierte en un cuello de botella, la latencia de extremo a extremo puede ser alta.
  • Sobrecarga en el desarrollo y las pruebas: Según la elección de la infraestructura de mensajería o de eventos, podrían existir mensajes duplicados, lo que dificulta lograr que las operaciones sean idempotentes. También puede ser difícil implementar y probar la semántica de solicitud y respuesta mediante mensajería asíncrona. Necesitas una forma de correlacionar los mensajes de solicitud y respuesta.
  • Capacidad de procesamiento: El manejo asíncrono de mensajes, ya sea mediante el uso de una cola central o de otro mecanismo, puede convertirse en un cuello de botella del sistema. Los sistemas de backend, como las colas y los consumidores descendentes, deben escalar para coincidir con los requisitos de capacidad de procesamiento del sistema.
  • Complica el manejo de errores: En un sistema asíncrono, el emisor no sabe si una solicitud se realizó de forma correcta o no, por lo que el control de errores debe manejarse fuera de banda. Este tipo de sistema puede dificultar la implementación de lógica como los reintentos o las retiradas exponenciales. El control de errores es más complicado si hay varias llamadas asíncronas en cadena que deben tener éxito o fallar.

En el siguiente documento de la serie, Comunicación entre servicios en una configuración de microservicios, se proporciona una implementación de referencia para abordar algunos de los desafíos mencionados en la lista anterior.

¿Qué sigue?