Consumer-Driven Contracts en Arquitecturas de Microservicios con Spring Cloud Contract

Con la expansión de las nuevas arquitecturas distribuidas (como la de microservicios) nos encontramos con una serie de problemáticas de mayor complejidad y que requieren de otro tipo de soluciones.

En el caso de las arquitecturas de microservicios uno de los nuevos retos que se nos presentan es probar la integración entre los diferentes microservicios que componen nuestro ecosistema.

Además, la existencia de nuevas tendencias y patrones en el mundo del testing, como Consumer Driven Contracts (CDC), se entrelaza con las nuevas arquitecturas para proporcionarnos un nuevo marco de trabajo en lo que a testeo se refiere.

En este post analizaremos las situaciones que nos han llevado hasta CDC y cómo este patrón nos permite llevar a cabo pruebas de integración entre microservicios basándonos en la extensibilidad y mantenibilidad de las interfaces.

Antiguamente, en las arquitecturas monolíticas, nuestra aplicación estaba formada por un único componente, que era testeado a través de pruebas unitarias así como de pruebas de integración, que comprobaban las comunicaciones entre las diferentes capas (MVC) y los elementos que conformaban el monolito.

En muchas ocasiones existían comunicaciones con sistemas de terceros, situación que siempre se ha resuelto con una especie de acuerdo entre productor y consumidor en el que se detallan las características del mismo (protocolo de comunicación, dónde consumirlo, parámetros de entrada, respuesta esperada, comportamiento, códigos de error…).

Por ejemplo, en las arquitecturas SOA el formato habitual de este acuerdo estaba representado por un WSDL. En muchos otros casos se quedaba en unas cuantas páginas redactadas en un documento con el formato de la consultora de turno.

Otro de los problemas habituales inherentes a estos formatos era la necesidad de flexibilidad en la definición de los contratos. Pensemos, por ejemplo, en una situación en la que sabemos de antemano que va a haber modificaciones en los parámetros de entrada o en la estructura del tipo de retorno de los contratos a lo largo de todo su ciclo de vida, situación que será habitual en muchas comunicaciones.

Típicamente esta situación se abordaba utilizando un formato abierto para la comunicación; utilizando, por ejemplo, un tipo Map en Java, permitiendo así la entrada y retorno de cualquier cantidad, tipología y nombrado de parámetros sin que se rompiese el acuerdo de la interfaz.

Esto mismo, que de por sí representa una mala práctica, además impide hacer una validacion efectiva del cumplimiento de interfaz, ya que la misma es demasiado abierta.

Todas estas prácticas y formatos, en la forma en la que eran utilizados, cuentan con una serie de limitaciones como son:

  • El versionado del acuerdo de interfaz.
  • La extensibilidad del acuerdo de interfaz sin suponer nuevos desarrollos en todas las demás partes implicadas.
  • La notificación de cambios en el acuerdo a las partes implicadas.
  • La imposibilidad de definir el comportamiento de la interfaz en un WSDL.
  • La validación automática del cumplimiento del contrato en el servidor.
  • Reproducción del comportamiento de la interfaz de forma automática en el consumidor.
  • La validación de forma efectiva del acuerdo de interfaz.

En el caso de las arquitecturas de microservicios nuestra aplicación va a estar conformada por diversos componentes, mientras que en las arquitecturas monolíticas solo era necesario realizar test unitarios así como test de integración (más allá de los tests end-to-end con terceros sistemas).

Sin embargo, en las arquitecturas de microservicios nos encontramos que es necesario realizar pruebas de integración entre los diferentes componentes que conforman el ecosistema, es decir, nuestra aplicación. Esto supone un nuevo reto que, ante la falta de soluciones mejores, muchas veces se ha solventado de forma incorrecta.

Entre las distintas formas de afrontar esta situación, una solución habitual era levantar en nuestra máquina local los microservicios cuya integración fuese a ser testeada (además de otros servicios transversales, como podrían ser el registro o servidor de configuración).

Esta solución, si bien cuenta con sus limitaciones, podría ser válida mientras hablemos de un número relativamente pequeño de microservicios como podría ser 4 o 5, dependiendo de las necesidades de memoria de cada uno y de la capacidad de nuestra máquina.

Pero eventualmente nos encontraremos que nuestra máquina no tiene capacidad para levantar todos los servicios necesarios. Por ejemplo, para pruebas que suponen un flujo largo de comunicaciones entre las diferentes piezas.

Si analizamos cómo eran solventadas las integraciones con terceros en las arquitecturas monolíticas nos encontramos que el desarrollo era probado de forma local conforme al acuerdo de interfaz, utilizando para ello algún framework de mocks que reprodujese el comportamiento del productor.

Sin embargo, la integración real no se producía hasta que el artefacto era desplegado en un entorno de integración y se ejecutaban las pruebas pertinentes.

En las arquitecturas de microservicios el procedimiento debe ser similar, si bien habitualmente es un único equipo el que gestiona el ecosistema entero de microservicios, aunque no tiene por que ser así, también es cierto que cada servicio debe ser tratado como un proyecto independiente y, por lo tanto, su integración debe ser llevada a cabo no de forma local, sino en un entorno destinado a dicho objetivo.

Por lo tanto, el ámbito de las pruebas que se deben realizar de forma local corresponde a la validación del cumplimiento de la interfaz de servicio, no siendo pertinente una integración real con el mismo.

Esto no es motivo para que en el proceso de integración continua (o el que corresponda) se realicen pruebas automáticas en un entorno con servicios reales que determine si el artefacto es apto para producción.

Otro de los puntos que se ven afectados por las nuevas arquitecturas son el formato y protocolo de comunicación (por ejemplo JSON sobre HTTP como solución más habitual en arquitecturas de MS en comparación con XML en arquitecturas SOA).

En este caso JSON es el formato más adecuado para la mantenibilidad de los acuerdos de interfaz, ya que no nos restringe a una estructura tan rígida como suele ser habitual en un formato más clásico como es XML.

Utilizando JSON podemos incluir nuevos elementos en la estructura de entrada o de salida sin que esto provoque problemas en el productor o en los consumidores respectivamente.

Otro punto clave que conviene destacar, y que va más allá de la parte técnica, es la funcionalidad que estas interfaces nos van a aportar. ¿De qué nos sirve ofertar una funcionalidad que nadie va a utilizar?

En este sentido es necesario que quienes proporcionen las directivas de evolución de nuestra interfaz sean sus propios consumidores. Serán ellos quien en base a unas necesidades de negocio nos trasladen los puntos clave del comportamiento de nuestra interfaz.

En este sentido la definición y comportamiento de la interfaz deberían venir dirigidos por las necesidades reales de negocio. Esto está muy relacionado con la filosofía Behaviour-driven Development.

Consumer-driven Contracts

En general todos estos problemas representan solo la punta del iceberg. Estas situaciones que detallamos suponen un ejemplo a pequeña escala de lo que es un problema mayor, que afecta a las comunicaciones entre las diversas aplicaciones que interactúan a nivel corporativo.

Los microservicios desarrollados para una solución concreta no serán solo consumidos por los otros microservicios que componen esa solución, sino que podrán llegar a ser consumidos por cualquier otra aplicación corporativa, pudiendo llegar a tener cientos de consumidores con sus correspondientes integraciones y pudiendo provocar esto que un pequeño cambio en una interfaz termine en una caída de servicio que afecte a toda la compañía.

Aquí es donde entra en juego el patrón Consumer-driven Contracts (CDC), espoleado por el creciente número de APIs que es necesario administrar derivado del boom de arquitecturas distribuidas como las de microservicios.

Este tipo de patrones y situaciones conjugan también con el creciente interés por las herramientas de API-management.

En un mundo en el que la tendencia es “APIficar” todos los servicios, el incremento de APIs y las complejidades asociadas a su gestión hacen que este tipo de herramientas y patrones sean imprescindibles a nivel organizacional.

¿Qué es CDC?

Centrándonos de nuevo en CDC ¿qué es CDC? CDC es un patrón que analiza todas estas situaciones, necesidades y sus posibles implicaciones, proporcionandonos una serie de guías que nos permitirán formar un marco de trabajo adecuado para el desarrollo de comunicaciones entre aplicaciones.

En concreto, CDC gira en torno a una serie de objetivos:

  1. Fomentar un modelo de desarrollo más ágil en el que no sea necesario que una de las partes espere a que la otra finalice su implementación para poder comenzar la suya.
  2. Que las necesidades reales de negocio sean las que guíen el desarrollo de funcionalidades, previniendo el desarrollo de funcionalidades innecesarias.
  3. Proporcionar protección a los consumidores, esto es que se prevengan las rupturas de los acuerdos de interfaz, de forma que si estos se producen nos enteremos lo antes posible y no en entornos productivos.
  4. Fomentar la fácil evolución de la funcionalidad en el servidor, de forma que un pequeño cambio no suponga un nuevo desarrollo en el consumidor.
  5. Que exista un versionado de los acuerdos de interfaz.
  6. Que la definición de los acuerdos se realice de forma colaborativa tomando mayor importancia las necesidades del consumidor, que serán las que guíen las necesidades de negocio.
  7. Que se pueda comprobar de forma automática si un proveedor cumple una serie de acuerdos de interfaz.
  8. Que los consumidores dispongan de una forma sencilla de un sistema que reproduzca el comportamiento del productor para sus tests de integración.

Actualmente en el mercado existen varias soluciones para implementar el patrón CDC. En este post nos centraremos en la solución proporcionada por Spring que es Spring Cloud Contract, analizando a alto nivel su funcionamiento y cómo nos permite garantizar cada uno de los diversos puntos previamente mencionados.

Spring Cloud Contract

Spring Cloud Contract es un framework que nos permite utilizar el patrón CDC basándose en la definición de contratos utilizando Groovy como lenguaje. Aquí podemos ver la estructura de un contrato de ejemplo:

org.springframework.cloud.contract.spec.Contract.make {
	request {
		method 'PUT'
		url '/fraudcheck'
		body([
			   "client.id": $(regex('[0-9]{10}')),
			   loanAmount: 99999
		])
		headers {
			contentType('application/json')
		}
	}
	response {
		status 200
		body([
			   fraudCheckStatus: "FRAUD",
			   "rejection.reason": "Amount too high"
		])
		headers {
			contentType('application/json')
		}
	}
}

En la misma podemos ver cómo se definen dos elementos: la petición y la respuesta. En la petición se detalla el endpoint y la operación en la cual se encontrarán la implementación y en la respuesta el código HTTP devuelto.

En ambos casos se detallan las cabeceras y el cuerpo que se incluirá, en el caso de la petición en el cuerpo se indica que cierto campo deberá tener un formato que siga la expresión regular indicada.

Gestión del Contrato

Este contrato será un reflejo de las necesidades de un consumidor sobre el proveedor que vaya a implementar dicho servicio. Por tanto, será desarrollado en primera instancia por el consumidor.

La especificación de este contrato en lenguaje Groovy será almacenado dentro del proyecto software del proveedor (SCC define una ruta por defecto) o en su defecto en un repositorio compartido destinado a almacenar contratos.

Las diferentes iteraciones, a través de las cuales tanto consumidor como productor propondrán sus cambios al contrato, se pueden gestionar a través de pull-requests en el repositorio en el que se encuentra el contrato.

El servicio proporcionado por el productor puede ser invocado por diversos consumidores aunque cada uno de los mismos requiera unas necesidades diferentes de dicha interfaz, pudiendo por tanto existir diversos contratos para una misma interfaz, representando cada uno las necesidades de un consumidor diferente.

La implementación llevada a cabo en el servidor deberá cumplir los contratos proporcionados por cada uno de los consumidores.

SCC nos permitirá generar un artefacto que incluya los contratos definidos, de forma que este artefacto podrá ser puesto a disponibilidad de todos los consumidores a través de un repositorio y lo más importante: podrá ser versionado.

Hasta este punto, tenemos un contrato, que representa un acuerdo de interfaz definido entre ambas partes y que está disponible y versionado en un repositorio para su fácil acceso.

Con esto cumplimos los siguientes puntos de los 8 previamente definidos:

1. Fomentamos un modelo de desarrollo ágil: con la definición de un contrato, ya sea una versión inicial o una más avanzada, cualquiera de las dos partes puede comenzar el desarrollo sin depender de la otra, ya que gracias a este conoce las restricciones y necesidades existentes.

2. Las necesidades reales de negocio son las que han guiado el desarrollo, ya que siendo el consumidor el que detalla inicialmente sus necesidades en el contrato estas irán enfocadas al conjunto mínimo de funcionalidad necesaria por el consumidor.

5. Versionamos los acuerdos de interfaz ya que al generarse un artefacto que los incluye podemos versionar dicho artefacto. Así mismo, al ser almacenados los contratos en repositorios de código se puede acceder al histórico de los mismos así como etiquetarlos con la versión correspondiente.

6. Los contratos se han definido de forma colaborativa y disponemos de mecanismos como los pull-requests, que nos permiten realizar solicitudes de cambios en la interfaz, establecer una discusión sobre dichos cambios y finalmente aprobarlos o rechazarlos.

Una vez disponemos de un contrato, no necesariamente en su definición final, veremos cómo este puede ser utilizado tanto por consumidor como por productor.

En el productor

En el lado productor, SCC nos permitirá utilizar el contrato definido para generar de forma automática un test de integración que reproduzca el comportamiento de dicho contrato. Utilizando el plugin proporcionado por SCC, durante la fase de test se generará el código fuente de un test por cada contrato existente.

De esta forma nuestra aplicación servidor no pasará los tests a no ser que cumpla la definición del contrato. Esto nos garantiza que ningún desarrollo llevado a cabo en el productor vaya a romper la comunicación con algún consumidor cuando este sea desplegado en entornos de integración o productivos.

En caso de romper algún contrato sabremos qué consumidor es el dueño de ese contrato pudiendo negociar con el mismo, en caso de ser necesario, los cambios correspondientes.

Hasta este punto disponemos de un contrato y de un test de integración que verifica el cumplimiento del mismo sin la necesidad de haber desarrollado ni una sola línea de código, ni de tests ni de funcionalidad, siendo así que CDC está fuertemente basado en TDD.

En este punto sería donde empezaríamos a desarrollar la funcionalidad que permitiera pasar dicho test de rojo a verde.

En el consumidor

En el lado consumidor SCC nos permitirá utilizar el contrato definido para levantar un stub (utilizando wiremock) que reproduzca el comportamiento del servidor. De esta forma podremos implementar los correspondientes tests de integración sin necesidad de incluir código que mockee el comportamiento del servidor.

A efectos prácticos se levantará un stub que se comportará como si fuera el servidor, mientras a través del test de integración podremos realizar la llamada al mismo.

Al utilizar dicho stub garantizamos que el comportamiento que obtendremos será únicamente el acordado en el contrato, de forma que no haremos asunciones falsas sobre el comportamiento del servidor.

Al igual que en el lado productor, la filosofía de trabajo está fuertemente basada en TDD, ya que hasta este punto habremos desarrollado nuestro test de integración que se comunicará con el stub, pero todavía no hemos desarrollado ni una línea de funcionalidad. Con esto podremos desarrollar la funcionalidad que pase nuestro test a verde.

Una vez hemos utilizado el contrato definido para generar los tests de integración en el productor y los stubs en el consumidor, podemos evaluar el cumplimiento de los puntos restantes de los 8 inicialmente definidos:

3. Proporcionamos protección a los consumidores ya que ante cualquier cambio en el productor, el test de integración generado evaluará que se siga cumpliendo el contrato, previniendo así situaciones en las que nuevas funcionalidades del productor rompan la comunicación cuando lleguen a entornos productivos. En caso de romper algún o varios contratos sabemos a qué consumidores estamos afectando.

4. Fomentamos la fácil evolución de la funcionalidad en el servidor ya que se pueden desarrollar diferentes funcionalidades de forma transparente a los consumidores. Para cada nueva funcionalidad desarrollada el test de integración evaluará que se siga cumpliendo el contrato, de forma que estos cambios se pueden realizar de forma transparente para los consumidores con la garantía de que no romperemos nada. Es más, se pueden añadir nuevos parámetros a la petición, nuevas cabeceras de la interfaz mientras se siga respetando el contrato.

7. Se puede comprobar de forma automática si un proveedor cumple una serie de acuerdos de interfaz gracias al test de integración generado.

8. Los consumidores disponen de un sistema que reproduce el comportamiento del servidor gracias a la utilización de stubs que ejecutan la definición del contrato.

Respecto al punto 4 hay que resaltar que es especialmente importante de cara a la retrocompatibilidad de las APIs. Como sabemos, una de las grandes problemáticas en la gestión de APIs es la mantenibilidad. Es decir, la necesidad de mantener versiones anteriores o compatibilidad con las mismas, a pesar del surgimiento de nuevas funcionalidades, mientras los correspondientes clientes se actualizan a la última versión.

En este caso poder realizar cambios menores al API sin que los consumidores se vean afectados y teniendo además, gracias a los contratos, la garantía de que así será, supone una clara mejora sobre modelos anteriores.

Hasta aquí hemos detallado el proceso de lo que podría ser una primera iteración en la definición del contrato, con ambas partes trabajando de forma independiente.

Eventualmente irán surgiendo nuevas necesidades, ya sea por parte de los consumidores o del proveedor que provocarán que se tengan que hacer modificaciones en el contrato. Con cada una de estas iteraciones se generará una nueva versión del contrato, que a su vez provocará cambios tanto en el stub en el lado consumidor, como en el test de integración en el lado productor.

Ambas partes deberán forzosamente adaptar su código para ajustarse a la nueva definición del contrato; si no es así no podrán pasarse los tests. En el servidor por el propio test generado y en el consumidor porque el stub no reproducirá el comportamiento anterior.

Camino a seguir

Como reflexión debemos pensar cuál es el camino a seguir para este tipo de prácticas y patrones. Un posible ejemplo son los SLAs (Service Level Agreement) que, para quien no los conozca, son una serie de acuerdos en los que se detalla la calidad del servicio que se va a comprometer por ejemplo en términos de disponibilidad, tiempos de respuesta…

En este sentido CDC no representa más que a otro tipo de acuerdo donde se detalla el formato de una interfaz.

De la misma forma, se podrían establecer acuerdos que identificaran el número de peticiones concurrentes que se deben aceptar, sus tiempos de respuesta, su disponibilidad… Aunque en el fondo lo que estamos acordando no dejan de ser las condiciones que los consumidores esperan/requieren de un servicio, sean estas del carácter o tipología que sean.

Fuentes

Abraham Rodríguez actualmente desarrolla funciones de ingeniero backend J2EE en Paradigma donde ya ha realizado diversos proyectos enfocados a arquitecturas de microservicios. Especializado en sistemas Cloud, ha trabajado con AWS y Openshift y es Certified Google Cloud Platform Developer. Cuenta con experiencia en diversos sectores como banca, telefonía, puntocom... Y es un gran defensor de las metodologías ágiles y el software libre."

Ver toda la actividad de Abraham Rodríguez

Escribe un comentario