Construyendo aplicaciones desde un contenedor de integración continua (CI)

Otro beneficio de Docker es que puede construir su aplicación desde un contenedor preconfigurado, como se muestra en la Figura 6-13, por lo que no necesita crear una máquina o máquina virtual para construir su aplicación. Puede usar o probar ese contenedor ejecutándolo en su máquina de desarrollo. Pero lo que es aún más interesante es que puede usar el mismo contenedor de compilación desde su proceso de CI.

Image

Figura 6-13. Compilando la aplicación .NET en un contenedor de construcción

Para este escenario, proporcionamos la imagen microsoft/aspnetcore-build, que puede usar para compilar y crear sus aplicaciones ASP.NET Core. El resultado se coloca en una imagen basada en la imagen microsoft/aspnetcore, que es una imagen optimizada del tiempo de ejecución, como se indicó anteriormente.

La imagen aspnetcore-build contiene todo lo que necesita para compilar una aplicación ASP.NET Core, que incluye .NET Core, ASP.NET SDK, npm, Bower, Gulp, etc.

Necesitamos estas dependencias en tiempo de compilación. Pero no queremos llevar esto en tiempo de ejecución, porque haría innecesariamente grande la imagen. En la aplicación eShopOnContainers, puede construir la aplicación desde un contenedor simplemente ejecutando el siguiente comando docker-compose.

docker-compose -f docker-compose.ci.build.yml up

 

La Figura 6-14 muestra este comando corriendo en la línea de comando.

Image

Figura 6-14. Contruyendo la aplicación .NET desde un contenedor

Como puede ver, el contenedor que está corriendo es ci-build_1. Este se basa en la imagen aspnetcore-build para que pueda compilar y construir toda su aplicación desde ese contenedor en lugar de desde su PC. Es por eso que en realidad está compilando y construyendo los proyectos .NET Core en Linux, porque ese contenedor se está ejecutando en el host Docker Linux por defecto.

El fichero docker-compose.ci.build.yml para esa imagen (parte de eShopOnContainers) contiene lo siguiente. Puede ver que inicia un contenedor de compilación utilizando la imagen microsoft/aspnetcore-build.

version: '3'

 

services:

 

ci-build:

 

image: microsoft/aspnetcore-build:2.0

 

volumes:

- .:/src

 

working_dir: /src

 

command: /bin/bash -c "pushd ./src/Web/WebSPA && npm rebuild node-sass && popd && dotnet restore ./eShopOnContainers-ServicesAndWebApps.sln && dotnet publish ./eShopOnContainers-ServicesAndWebApps.sln -c Release -o ./obj/Docker/publish"

 

 

Una vez que el contenedor de compilación está funcionando, ejecuta los comandos del .NET Core SDK dotnet restore y dotnet publish para compilar todos los proyectos en la solución y generar los ejecutables de .NET. En este caso, debido a que eShopOnContainers también tiene un SPA basado en TypeScript y Angular para el lado del cliente, también necesita verificar las dependencias de JavaScript con npm, pero esa acción no está relacionada con los ejecutables de .NET.

El comando dotnet publish construye y publica el resultado dentro de cada proyecto en la carpeta ../obj/Docker/publish, como se muestra en la Figura 6-15.

Image

Figura 6-15. Ficheros binarios generados por el comando dotnet publish

Creando las imágenes Docker desde la CLI

Una vez que el resultado de la compilación se publica en las carpetas relacionadas (dentro de cada proyecto), el siguiente paso es construir realmente las imágenes Docker. Para hacer esto, use los comandos docker-compose build y docker-compose up, como se muestra en la Figura 6-16.

Image

Figura 6-16. Construyendo las imágenes Docker y ejecutando los contendores

En la Figura 6-17, puede ver cómo se ejecuta el comando docker-compose build.

Image

Figura 6-17. Construyendo las imágenes Docker con el comando docker-compose build

La diferencia entre los comandos docker-compose build y docker-compose up es que docker-compose up construye las imágenes e inicia los contenedores.

Cuando usa Visual Studio, todos estos pasos se realizan por debajo. Visual Studio compila su aplicación .NET, crea las imágenes Docker y despliega los contenedores en el host Docker. Visual Studio ofrece funciones adicionales, como la capacidad de depurar los contenedores que se ejecutan en Docker, directamente desde Visual Studio.

El aprendizaje general aquí es que puede construir su aplicación de la misma forma como lo haría su proceso de CI/CD, desde un contenedor en lugar de hacerlo desde una máquina local. Después de crear las imágenes, sólo necesita ejecutar las imágenes Docker con el comando docker-compose up.

Recursos adicionales

Usando una base de datos corriendo en un contenedor

Puede tener sus bases de datos (SQL Server, PostgreSQL, MySQL, etc.) en servidores autónomos regulares, en clusters locales o en servicios PaaS en la nube como Azure SQL DB. Sin embargo, para entornos de desarrollo y pruebas, es conveniente que las bases de datos se ejecuten como contenedores, ya que no tiene ninguna dependencia externa y con simplemente ejecutar el comando docker-compose se inicia toda la aplicación. Tener esas bases de datos como contenedores también es excelente para las pruebas de integración, porque las bases de datos se inician en el contenedor y siempre se inicializan con los mismos datos de ejemplo, por lo que las pruebas pueden ser más predecibles.

SQL Server corriendo como contenedor con una base de datos de los microservicios

En eShopOnContainers, hay un contenedor llamado sql.data definido en el fichero docker-compose.yml que ejecuta SQL Server para Linux con las bases de datos necesarias para todos los microservicios. (También podría tener un contenedor SQL Server para cada base de datos, pero eso requeriría más memoria asignada a Docker.) El punto importante en microservicios es que cada microservicio posee sus datos relacionados, por lo tanto, su base de datos SQL relacionada en este caso. Pero las bases de datos pueden estar en cualquier sitio.

El contenedor de SQL Server en la aplicación de referencia se configura con el siguiente código YAML en el fichero docker-compose.yml, que se ejecuta al correr docker-compose up. Tenga en cuenta que el código YAML tiene información de configuración consolidada del fichero genérico docker-compose.yml y el fichero docker-compose.override.yml. (Aunque, en general, debería separar la configuración del entorno y la configuración de la base o estática, relacionada con la imagen de SQL Server, como mencionamos anteriormente).

sql.data:

image: microsoft/mssql-server-linux

environment:

- MSSQL_SA_PASSWORD=Pass@word

- ACCEPT_EULA=Y

- MSSQL_PID=Developer

ports:

- "5434:1433"

 

De manera similar, en lugar de utilizar docker-compose, el siguiente comando docker run puede ejecutar ese contenedor:

docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD= your@password' -p 1433:1433 -d microsoft/mssql-server-linux

 

Sin embargo, si está desplegando una aplicación de multi-contenedor como eShopOnContainers, es más conveniente usar el comando docker-compose up, para que despliegue todos los contenedores necesarios para la aplicación.

Cuando inicia este contenedor de SQL Server por primera vez, el contenedor inicializa SQL Server con la contraseña que proporciona. Una vez que SQL Server se ejecuta como un contenedor, puede actualizar la base de datos conectándose a través de cualquier conexión regular de SQL, como SQL Server Management Studio, Visual Studio o desde el código en C#.

La aplicación eShopOnContainers inicializa cada base de datos de microservicio, cargándola con datos de ejemplo al inicio, como se explica en la siguiente sección.

Tener SQL Server ejecutándose como un contenedor no solo es útil para una demostración donde es posible que no tenga acceso a una instancia de SQL Server. Como se indicó, también es ideal para entornos de desarrollo y prueba, de forma que pueda ejecutar fácilmente pruebas de integración a partir de una imagen limpia de SQL Server con datos iniciales conocidos.

Recursos adicionales

Inicializando con datos de prueba al arrancar la aplicación Web

Para cargar datos a la base de datos cuando se inicia la aplicación, puede agregar código como el siguiente al método Configure en la clase Startup del proyecto de la API Web:

public class Startup

{

// Other Startup code...

 

public void Configure(IApplicationBuilder app,

IHostingEnvironment env,

ILoggerFactory loggerFactory)

{

// Other Configure code...

 

// Seed data through our custom class

CatalogContextSeed.SeedAsync(app)

.Wait();

 

// Other Configure code...

}

}

El siguiente código en la clase CatalogContextSeed carga los datos iniciales.

public class CatalogContextSeed

{

public static async Task SeedAsync(IApplicationBuilder applicationBuilder)

{

var context = (CatalogContext)applicationBuilder

.ApplicationServices.GetService(typeof(CatalogContext));

using (context)

{

context.Database.Migrate();

 

if (!context.CatalogBrands.Any())

{

context.CatalogBrands.AddRange(

GetPreconfiguredCatalogBrands());

 

await context.SaveChangesAsync();

}

if (!context.CatalogTypes.Any())

{

context.CatalogTypes.AddRange(

GetPreconfiguredCatalogTypes());

 

await context.SaveChangesAsync();

}

}

}

static IEnumerable<CatalogBrand> GetPreconfiguredCatalogBrands()

{

return new List<CatalogBrand>()

{

new CatalogBrand() { Brand = "Azure"},

new CatalogBrand() { Brand = ".NET" },

new CatalogBrand() { Brand = "Visual Studio" },

new CatalogBrand() { Brand = "SQL Server" }

};

}

 

static IEnumerable<CatalogType> GetPreconfiguredCatalogTypes()

{

return new List<CatalogType>()

{

new CatalogType() { Type = "Mug"},

new CatalogType() { Type = "T-Shirt" },

new CatalogType() { Type = "Backpack" },

new CatalogType() { Type = "USB Memory Stick" }

};

}

}

Para realizar pruebas de integración, es necesario tener una manera de generar datos consistentes con las pruebas. Es ideal poder crear todo desde cero, incluida una instancia de SQL Server ejecutándose en un contenedor.

La base de datos EF Core InMemory versus SQL Server en un contenedor

Otra buena opción al ejecutar pruebas es usar el proveedor de la base de datos InMemory de Entity Framework. Puede especificar esa configuración en el método ConfigureServices de la clase Startup en su proyecto de API web:

public class Startup

{

// Other Startup code ...

public void ConfigureServices(IServiceCollection services)

{

services.AddSingleton<IConfiguration>(Configuration);

 

// DbContext using an InMemory database provider

services.AddDbContext<CatalogContext>(opt => opt.UseInMemoryDatabase());

 

//(Alternative: DbContext using a SQL Server provider

//services.AddDbContext<CatalogContext>(c =>

//{

// c.UseSqlServer(Configuration["ConnectionString"]);

//

//});

}

// Other Startup code ...

}

Sin embargo, hay un detalle importante. La base de datos in-memory no soporta muchas restricciones que son específicas de una base de datos en particular. Por ejemplo, puede agregar un índice único en una columna de su modelo EF Core y escribir una prueba en su base de datos in-memory para verificar que no le permite agregar un valor duplicado. Pero la base de datos in-memory no soporta índices únicos en una columna. Por lo tanto, este tipo de base de datos en memoria no se comporta exactamente igual que una base de datos real de SQL Server.

Aun así, una base de datos in-memory todavía es útil para pruebas y creación de prototipos. Pero si desea crear pruebas de integración precisas, que tengan en cuenta el comportamiento de una implementación específica de base de datos, debe usar una base de datos real como SQL Server. Para ello, ejecutar SQL Server en un contenedor es una muy buena opción y más precisa que el proveedor de base de datos EF Core in-memory.

Usando un servicio de cache Redis corriendo en un contenedor

Puede ejecutar Redis en un contenedor, especialmente para desarrollo y pruebas y para escenarios de prueba de concepto. Este escenario es conveniente, porque puede tener todas sus dependencias ejecutándose en contenedores, no sólo para sus máquinas de desarrollo local, sino también para sus entornos de prueba en sus procesos de CI/CD.

Sin embargo, cuando ejecuta Redis en producción, es mejor buscar una solución de alta disponibilidad como Redis Microsoft Azure, que se ejecuta como una PaaS (Plataforma como servicio). En su código, solo necesita cambiar las cadenas de conexión.

Redis Labs proporciona una imagen Docker con Redis. Esa imagen está disponible desde Docker Hub en esta URL:

https://hub.docker.com/_/redis/

Puede ejecutar directamente un contenedor Docker Redis ejecutando el siguiente comando Docker CLI desde la interfaz de comandos del sistema:

docker run --name some-redis -d redis

 

La imagen de Redis incluye expose:6379 (el puerto utilizado por Redis), por lo que la vinculación de contenedores estándar lo hará disponible automáticamente para los contenedores vinculados.

En eShopOnContainers, el microservicio basket.api usa un caché de Redis que se ejecuta como un contenedor. Ese contenedor basket.data se define como parte del fichero docker-compose.yml multi-contenedor, como se muestra en el siguiente ejemplo:

//docker-compose.yml file

//...

basket.data:

image: redis

expose:

- "6379"

 

Este código en docker-compose.yml define un contenedor llamado basket.data basado en la imagen redis y publica internamente el puerto 6379, lo que significa que sólo será accesible desde otros contenedores que se ejecutan dentro del host Docker.

Finalmente, en el fichero docker-compose.override.yml, el microservicio basket.api para nuestra aplicación eShopOnContainers, define la cadena de conexión que se utilizará para ese contenedor Redis:

basket.api:

environment:

// Other data ...

- ConnectionString=basket.data

- EventBusConnection=rabbitmq

Implementando comunicación entre microservicios basada en eventos (eventos de integración)

Como se mencionó anteriormente, cuando utiliza la comunicación basada en eventos, un microservicio publica un evento cuando sucede algo notable, como cuando actualiza una entidad del negocio. Otros microservicios se suscriben a esos eventos y cuando reciben, o se enteran de un evento, pueden actualizar sus propias entidades del negocio, lo que puede llevar a la publicación de más eventos. Esta es, precisamente, la base de la consistencia eventual. Este sistema de publicación/suscripción generalmente se realiza mediante el uso de un bus de eventos. El bus de eventos se puede diseñar como una interfaz con las APIs necesarias para publicar eventos y suscribirse a ellos o anular la suscripción. También puede tener una o más implementaciones basadas en cualquier comunicación entre procesos o mensajes, como una cola de mensajería o un bus de servicio que admite comunicación asíncrona y un modelo de publicación/suscripción.

Puede usar eventos para implementar transacciones del negocio que abarcan múltiples servicios, lo que le brinda consistencia eventual entre esos servicios. Una transacción eventualmente consistente está formada por una serie de acciones distribuidas. En cada acción, el microservicio actualiza una entidad del negocio y publica otro evento que desencadena la siguiente acción.

Image

Figura 6-18. Comunicaciones asíncronas basadas en eventos, a través de un event bus

Esta sección describe cómo puede implementar este tipo de comunicación con .NET utilizando una interfaz de bus de eventos genérica, como se muestra en la Figura 6-18. Existen múltiples implementaciones potenciales, cada una de las cuales utiliza una tecnología o infraestructura diferente, como RabbitMQ, Azure Service Bus o cualquier otro servicio open source de terceros o un service bus comercial.

Usando brokers de mensajes y buses de servicios para sistemas de producción

Como se señaló en la sección de arquitectura, puede elegir entre varias tecnologías de mensajería para implementar su bus de eventos abstractos. Pero estas tecnologías están en diferentes niveles de abstracción. Por ejemplo, RabbitMQ, un transporte basado en un broker de mensajería, está en un nivel más bajo que los productos comerciales como Azure Service Bus, NServiceBus, MassTransit o Brighter. La mayoría de estos productos pueden funcionar sobre RabbitMQ o Azure Service Bus. La elección del producto depende de la cantidad de funciones y de la escalabilidad que ofrecen de entrada y lo que necesita para su aplicación.

Para implementar sólo una prueba de concepto de bus de evento para su entorno de desarrollo, como en el ejemplo de eShopOnContainers, una implementación simple sobre RabbitMQ ejecutándose como un contenedor podría ser suficiente. Pero para los sistemas de misión crítica y de producción que requieran alta escalabilidad, probablemente deba evaluar y usar Azure Service Bus.

Si necesita abstracciones de alto nivel y funciones más completas como Sagas para procesos de larga duración que faciliten el desarrollo distribuido, vale la pena evaluar otros buses de servicio comerciales y open source como NServiceBus, MassTransit y Brighter. En este caso, las abstracciones y las API que se usarían normalmente, serían las proporcionadas directamente por esos buses en lugar de sus propias abstracciones (como las abstracciones simples de bus de eventos provistas en eShopOnContainers). En ese caso, puede investigar el fork de eShopOnContainers usando NServiceBus (ejemplo implementado por Particular Software).

Por supuesto, siempre puede construir sus propias características particulares sobre tecnologías de nivel inferior como RabbitMQ y Docker, pero el trabajo necesario para "reinventar la rueda" puede ser demasiado costoso para una aplicación empresarial personalizada.

Para insistir en el punto: las abstracciones e implementación del bus de eventos mostradas como ejemplo en la muestra de eShopOnContainers están destinadas a ser utilizadas sólo como una prueba de concepto. Una vez que haya decidido que desea tener una comunicación asíncrona y controlada por eventos, como se explica en esta sección, debe elegir el producto del bus de servicio que mejor se adapte a sus necesidades de producción.

Eventos de integración

Los eventos de integración se utilizan para sincronizar el estado del dominio entre múltiples microservicios o sistemas externos. Esto se hace mediante la publicación de eventos de integración fuera del microservicio. Cuando se publica un evento para varios microservicios receptores (tantos como estén suscritos al evento de integración), el manejador de eventos apropiado en cada microservicio receptor se encarga de procesar el evento.

Un evento de integración es básicamente una clase de transporte de datos, como en el siguiente ejemplo:

public class ProductPriceChangedIntegrationEvent : IntegrationEvent

{

public int ProductId { get; private set; }

public decimal NewPrice { get; private set; }

public decimal OldPrice { get; private set; }

 

public ProductPriceChangedIntegrationEvent(int productId, decimal newPrice,

decimal oldPrice)

{

ProductId = productId;

NewPrice = newPrice;

OldPrice = oldPrice;

}

}

 

Los eventos de integración se pueden definir en el nivel de aplicación de cada microservicio, para que estén desacoplados de otros microservicios, de forma similar a como se definen ViewModels en el servidor y el cliente. No se recomienda compartir una librería común de eventos de integración entre múltiples microservicios. Hacerlo sería acoplar esos microservicios con una sola librería de definición de eventos. Eso no es recomendable por las mismas razones por las que no desea compartir un modelo de dominio común en varios microservicios: los microservicios deben ser completamente autónomos.

Hay sólo algunos tipos de librerías que se deberían compartir entre de microservicios. Uno son las que conforman los bloques finales de la aplicación, como la API del cliente del Bus de Eventos, como en eShopOnContainers. Otros son las librerías que constituyen herramientas que también podrían compartirse como componentes NuGet, como los serializadores JSON, por ejemplo.

El bus de eventos

Un bus de eventos permite la comunicación de tipo publicación/suscripción entre microservicios sin que los componentes se tengan en cuenta explícitamente entre sí, como se muestra en la Figura 6-19.

Image

Figura 6-19. Aspectos básicos de publicación/suscripción con un bus de eventos

El bus de eventos está relacionado con el patrón Observador y el patrón Publicación/Suscripción.

Patrón Observador

En el patrón Observador, el objeto primario (conocido como Observable) notifica a otros objetos interesados (conocidos como Observadores) con información relevante (eventos).

Patrón Publicación-Suscripción (Pub/Sub)

El propósito del patrón Publicación-Suscripción es el mismo que el patrón Observer: desea notificar a otros servicios cuando ocurren ciertos eventos. Pero hay una diferencia importante entre los patrones Observer y Pub/Sub. En el patrón del Observer, la transmisión se realiza directamente desde el observable al observador, así que ambos se "conocen". Pero cuando se usa un patrón Pub/Sub, hay un tercer componente, llamado broker o message broker o event bus, que es conocido tanto por el editor como por el suscriptor. Por lo tanto, cuando se utiliza el patrón Pub/Sub, el editor y los suscriptores se desacoplan, precisamente, gracias al mencionado bus de eventos o message broker.

El intermediario o bus de eventos

¿Cómo se logra el anonimato entre el que publica y el suscriptor? Una manera fácil es dejar que un intermediario se ocupe de todas las comunicaciones. Un bus de eventos es uno de esos intermediarios.

Un bus de eventos generalmente se compone de dos partes:

En la Figura 6-19, puede ver cómo, desde el punto de vista de la aplicación, el bus de eventos no es más que un canal Pub/Sub. La forma de implementar esta comunicación asíncrona puede variar. Puede tener varias implementaciones para que pueda intercambiarlas, según los requisitos del entorno (por ejemplo, entornos de producción o desarrollo).

En la Figura 6-20 puede ver una abstracción de un bus de eventos con implementaciones múltiples, basadas en distintas tecnologías de infraestructura de mensajería, como RabbitMQ, Azure Service Bus u otro intermediario de eventos/mensajes.

Image

Figura 6- 20. Múltiples implementaciones de un bus de eventos

Sin embargo, como se mencionó anteriormente, usar sus propias abstracciones (la interfaz del bus de eventos) es bueno sólo si necesita funciones básicas soportadas por sus abstracciones. Si necesita funciones más complejas, probablemente debería utilizar la API y abstracciones proporcionadas por bus de eventos comercial, en lugar de sus propias abstracciones.

Definiendo la interfaz de un bus de eventos

Comencemos con una implementación de la interfaz del bus de eventos y otras posibles implementaciones, sólo para explorar un poco. La interfaz debe ser genérica y sencilla, como la siguiente.

public interface IEventBus

{

void Publish(IntegrationEvent @event);

 

void Subscribe<T, TH>()

where T : IntegrationEvent

where TH : IIntegrationEventHandler<T>;

 

void SubscribeDynamic<TH>(string eventName)

where TH : IDynamicIntegrationEventHandler;

 

void UnsubscribeDynamic<TH>(string eventName)

where TH : IDynamicIntegrationEventHandler;

 

void Unsubscribe<T, TH>()

where TH : IIntegrationEventHandler<T>

where T : IntegrationEvent;

}

 

El método Publish es sencillo. El bus de eventos transmitirá el evento de integración que le haya sido transferido a cualquier aplicación de microservicios, o incluso una aplicación externa, suscrita a ese evento. Este método es utilizado por el microservicio que está publicando el evento.

Los métodos Subscribe (puede tener varias implementaciones según los argumentos) son utilizados por los microservicios que desean recibir eventos. Este método tiene dos argumentos. El primero es el evento de integración al que se quiere suscribir (IntegrationEvent). El segundo argumento es el manejador de eventos de integración (o método callback), de tipo IIntegrationEventHandler<T>, para ser ejecutado cuando el microservicio receptor reciba ese mensaje de evento de integración.

Implementando un bus de eventos con RabbitMQ para entornos de desarrollo o pruebas

Deberíamos comenzar diciendo que, si crea su bus de eventos personalizado basado en RabbitMQ ejecutándose en un contenedor, como lo hace la aplicación eShopOnContainers, debería usarlo sólo para los entornos de desarrollo y pruebas. No debe usarlo para su entorno de producción, a menos que lo esté construyendo como parte de un bus de servicio orientado a producción. En un bus de eventos personalizado simple le pueden faltar muchas características críticas, que ya están listas en un bus de servicio comercial.

Una de las implementaciones personalizadas del bus de eventos en eShopOnContainers es básicamente una librería que utiliza la API de RabbitMQ (hay otra implementación basada en el bus de servicio de Azure).

La implementación del bus de eventos permite a los microservicios suscribirse a eventos, publicarlos y recibirlos, como se muestra en la Figura 6-21.

Image

Figura 6-21. Implementación de un bus de eventos con RabbitMQ

En el código, la clase EventBusRabbitMQ implementa la interfaz genérica IEventBus. Esto se basa en Inyección de Dependencia para que pueda cambiar de esta versión de desarrollo/pruebas a una versión de producción.

public class EventBusRabbitMQ : IEventBus, IDisposable

{

// Implementation using RabbitMQ API

//...

 

La implementación con RabbitMQ de un bus de eventos para desarrollo/pruebas tiene un código muy básico. Tiene que gestionar la conexión con el servidor RabbitMQ y proporcionar un código para publicar un evento en las colas de mensajes. También tiene que implementar un diccionario de colecciones de manejadores de eventos de integración para cada tipo de evento; estos tipos de eventos pueden tener constructores y métodos de suscripción diferentes para cada microservicio receptor, como se muestra en la Figura 6-21.

Implementando un método Publish simple con RabbitMQ

El siguiente código es parte de una implementación simplificada de bus de eventos para RabbitMQ, aunque está mejorada en el código real de eShopOnContainers. Por lo general, no necesita cambiarlo a menos que esté realizando mejoras. El código obtiene una conexión y un canal a RabbitMQ, crea un mensaje y luego lo publica en la cola.

public class EventBusRabbitMQ : IEventBus, IDisposable

{

// Member objects and other methods ...

// ...

public void Publish(IntegrationEvent @event)

{

var eventName = @event.GetType().Name;

var factory = new ConnectionFactory() { HostName = _connectionString };

using (var connection = factory.CreateConnection())

using (var channel = connection.CreateModel())

{

channel.ExchangeDeclare(exchange: _brokerName,

type: "direct");

 

string message = JsonConvert.SerializeObject(@event);

var body = Encoding.UTF8.GetBytes(message);

 

channel.BasicPublish(exchange: _brokerName,

routingKey: eventName,

basicProperties: null,

body: body);

}

}

}

El código real del método Publish en la aplicación eShopOnContainers se mejora usando una política de reintentos con Polly, que reintenta la tarea una cierta cantidad de veces en caso de que el contenedor RabbitMQ no esté listo. Esto puede ocurrir cuando docker-compose está iniciando los contenedores y, por ejemplo, el contenedor RabbitMQ arranque más lentamente que los otros contenedores.

Como se mencionó anteriormente, hay muchas configuraciones posibles en RabbitMQ, por lo que este código se debe usar sólo para entornos de desarrollo/pruebas.

Implementando las suscripciones con el API de RabbitMQ

Al igual que con el código de publicación, el siguiente código es una simplificación de una parte de la implementación del bus de eventos para RabbitMQ. De nuevo, generalmente no necesita cambiarlo a menos que lo esté mejorando.

public class EventBusRabbitMQ : IEventBus, IDisposable

{

// Member objects and other methods ...

// ...

 

public void Subscribe<T, TH>()

where T : IntegrationEvent

where TH : IIntegrationEventHandler<T>

{

var eventName = _subsManager.GetEventKey<T>();

var containsKey = _subsManager.HasSubscriptionsForEvent(eventName);

if (!containsKey)

{

if (!_persistentConnection.IsConnected)

{

_persistentConnection.TryConnect();

}

 

using (var channel = _persistentConnection.CreateModel())

{

channel.QueueBind(queue: _queueName,

exchange: BROKER_NAME,

routingKey: eventName);

}

 

}

 

_subsManager.AddSubscription<T, TH>();

}

 

}

Cada tipo de evento tiene un canal relacionado para obtener eventos de RabbitMQ. Puede tener tantos manejadores de eventos por canal y tipo de evento como sea necesario.

El método Subscribe acepta un objeto IIntegrationEventHandler, que es como un método callback en el microservicio actual, más su objeto relacionado IntegrationEvent. En el código se agrega ese manejador de eventos a la lista de manejadores que cada tipo de evento puede tener por cada microservicio cliente. Si el cliente no se ha suscrito al evento, se crea un canal para el tipo de evento para que pueda recibir eventos en un estilo push de RabbitMQ cuando ese evento se publica desde cualquier otro servicio.

Suscribiéndose a eventos

El primer paso para usar el bus de eventos es suscribir los microservicios a los eventos que desean recibir. Eso debería hacerse en los microservicios receptores.

El siguiente código sencillo muestra lo que cada microservicio receptor necesita implementar al iniciar el servicio (es decir, en la clase de Startup) para suscribirse a los eventos que necesite. En este caso, el microservicio basket.api necesita suscribirse a ProductPriceChangedIntegrationEvent y a los mensajes OrderStartedIntegrationEvent.

Por ejemplo, al suscribirse al evento ProductPriceChangedIntegrationEvent, esto hace que el microservicio del carrito de compras tenga conocimiento de cualquier cambio en el precio del producto y le permite advertir al usuario sobre el cambio, si ese producto está en su carrito de compras.

var eventBus = app.ApplicationServices.GetRequiredService<IEventBus>();

 

eventBus.Subscribe<ProductPriceChangedIntegrationEvent,

ProductPriceChangedIntegrationEventHandler>();

eventBus.Subscribe<OrderStartedIntegrationEvent,

OrderStartedIntegrationEventHandler>();

 

Después de ejecutar este código, el microservicio suscriptor estará escuchando a través de los canales de RabbitMQ. Cuando llega un mensaje de tipo ProductPriceChangedIntegrationEvent, el código invoca al manejador de eventos registrado, para que lo procese.

Publicando eventos a través del bus de eventos

Finalmente, el remitente del mensaje (el microservicio de origen) publica los eventos de integración con un código similar al del siguiente ejemplo. (Este es un ejemplo simplificado que no tiene en cuenta la atomicidad de la transacción). Implementaría un código similar cada vez que un evento se deba propagar a través de múltiples microservicios, generalmente justo después de confirmar datos o transacciones desde el microservicio de origen.

Primero, el objeto que implementa el bus de eventos (basado en RabbitMQ o en un bus de servicio) se inyectaría en el constructor del controlador, como en el siguiente código:

[Route("api/v1/[controller]")]

public class CatalogController : ControllerBase

{

private readonly CatalogContext _context;

private readonly IOptionsSnapshot<Settings> _settings;

private readonly IEventBus _eventBus;

 

public CatalogController(CatalogContext context,

IOptionsSnapshot<Settings> settings,

IEventBus eventBus)

{

_context = context;

_settings = settings;

_eventBus = eventBus;

// ...

}

Luego lo usaría en el método del controlador, como en el método UpdateProduct:

[Route("update")]

[HttpPost]

public async Task<IActionResult> UpdateProduct([FromBody]CatalogItem product)

{

var item = await _context.CatalogItems.SingleOrDefaultAsync(

i => i.Id == product.Id);

// ...

if (item.Price != product.Price)

{

var oldPrice = item.Price;

item.Price = product.Price;

_context.CatalogItems.Update(item);

 

var @event = new ProductPriceChangedIntegrationEvent(item.Id,

item.Price,

oldPrice);

// Commit changes in original transaction

await _context.SaveChangesAsync();

// Publish integration event to the event bus

// (RabbitMQ or a service bus underneath)

_eventBus.Publish(@event);

// ...

En este caso, dado que el microservicio de origen es un CRUD simple, ese código se coloca directamente en un controlador de API Web.

En microservicios más avanzados, como cuando se utilizan enfoques CQRS, se puede implementar en la clase CommandHandler, en el método Handle().

Diseñando la atomicidad y resiliencia al publicar en el bus de eventos

Cuando publica eventos de integración a través de un sistema de mensajería distribuida como un bus de eventos, existe el problema de actualizar de forma atómica la base de datos original y publicar un evento (es decir, que ocurran ambas operaciones o no ocurra ninguna). Asi, en el ejemplo simplificado que mostramos anteriormente, el código envía datos a la base de datos cuando se cambia el precio del producto y luego publica un mensaje ProductPriceChangedIntegrationEvent. Inicialmente, podría parecer esencial que estas dos operaciones se realizaran de forma atómica. Sin embargo, si está utilizando una transacción distribuida que involucra la base de datos y el intermediario de mensajes, como lo hace en sistemas más antiguos como Microsoft Message Queuing (MSMQ), esto no se recomienda por las razones descritas por el teorema CAP.

Básicamente, usamos microservicios para construir sistemas escalables y altamente disponibles. Simplificando algo el teorema de CAP, dice que no se puede construir una base de datos distribuida (o un microservicio con su modelo) que esté continuamente disponible, sea consistente y tolerante con cualquier partición. Debe elegir dos de estas tres propiedades.

En las arquitecturas basadas en microservicios, debe elegir la disponibilidad y la tolerancia y debe restarle importancia a la consistencia. Por lo tanto, en la mayoría de las aplicaciones modernas basadas en microservicios, generalmente se utilizan transacciones distribuidas en mensajes, como se hace al implementar transacciones distribuidas basadas en el Windows Distributed Transaction Coordinator (DTC) con MSMQ.

Volvamos al punto inicial y su ejemplo. Si el servicio falla después de que se actualiza la base de datos (en este caso, justo después de la línea de código con _context.SaveChangesAsync()), pero antes de que se publique el evento de integración, el sistema general podría volverse inconsistente. Esto podría ser crítico para el negocio, dependiendo de la operación específica con la que se encuentre.

Como se mencionó anteriormente en la sección de arquitectura, puede tener varios enfoques para manejar este problema:

Para este escenario, usar el patrón completo de Event Sourcing (ES) es uno de los mejores enfoques, sino el mejor. Sin embargo, en muchos escenarios, es posible que no pueda implementar un sistema ES completo. ES significa almacenar sólo eventos de dominio en su base de datos transaccional, en lugar de almacenar datos de estado actuales. Almacenar sólo eventos de dominio puede tener grandes beneficios, como tener disponible el historial de su sistema y poder determinar el estado de su sistema en cualquier momento del pasado. Sin embargo, la implementación de un sistema completo de ES requiere que rediseñar la arquitectura del sistema y presenta muchas otras complejidades y requisitos. Por ejemplo, desearía utilizar una base de datos creada específicamente para el ES, como Event Store o una base de datos orientada a documentos como Azure Cosmos DB, MongoDB, Cassandra, CouchDB o RavenDB. Event Sourcing es un gran enfoque para este problema, pero no es la solución más fácil, a menos que ya esté familiarizado con el patrón.

La opción de usar transaction log mining inicialmente parece muy transparente. Sin embargo, para usar este enfoque, el microservicio debe estar acoplado al registro de transacciones de la RDBMS, como el registro de transacciones de SQL Server. Esto probablemente no es algo que quiera hacer. Otro inconveniente es que las actualizaciones de bajo nivel registradas en el registro de transacciones pueden no estar en el mismo nivel que sus eventos de integración de alto nivel. Si es así, el proceso de ingeniería inversa de esas operaciones de registro de transacciones puede ser difícil.

Un enfoque balanceado es la combinación de una tabla de base de datos transaccional y un patrón ES simplificado. Puede usar un estado como “listo para publicar el evento”, que se establece en el evento original cuando lo guarda en la tabla de eventos de integración. A continuación, intenta publicar el evento en el bus de eventos. Si la acción de publicar evento tiene éxito, inicie otra transacción en el servicio de origen y mueva el estado de "listo para publicar el evento" a "evento ya publicado".

Si falla la acción publicar-evento en el bus de eventos, los datos aún serán consistentes dentro del microservicio de origen, porque los eventos siguen marcados como "listo para publicar el evento" y, con respecto al resto de los servicios, eventualmente serán consistentes, cuando se reintente la publicación del evento. Para esto puede tener trabajos en segundo plano para verificar el estado de las transacciones o eventos de integración. Si el trabajo encuentra un evento en el estado "listo para publicar el evento", puede intentar volver a publicar ese evento en el bus de eventos.

Observe que, con este enfoque, sólo persiste los eventos de integración para cada microservicio de origen y sólo los eventos que desea comunicar a otros microservicios o sistemas externos. Por el contrario, en un sistema ES completo, también se almacenarían todos los eventos de dominio.

Por lo tanto, este enfoque balanceado es un sistema ES simplificado. Necesita una lista de eventos de integración con su estado actual ("listo para publicar" frente a "publicado"). Pero sólo necesita implementar estos estados para los eventos de integración. Y en este enfoque, no necesita almacenar todos sus datos de dominio como eventos en la base de datos transaccional, como lo haría en un sistema ES completo.

Si ya está utilizando una base de datos relacional, puede usar una tabla transaccional para almacenar eventos de integración. Para lograr la atomicidad en su aplicación, utilice un proceso de dos pasos basado en transacciones locales. Básicamente, usar una tabla IntegrationEvent en la misma base de datos donde tiene las entidades del dominio. Esa tabla funciona como un seguro para lograr la atomicidad, para que incluya eventos de integración persistentes en las mismas transacciones que están guardando sus datos de dominio.

Paso a paso, el proceso es el siguiente: la aplicación comienza una transacción de base de datos local. Luego actualiza el estado de las entidades del dominio e inserta un evento en la tabla de eventos de integración. Finalmente, guarda la transacción. Así obtiene la atomicidad deseada.

Al implementar los pasos de publicación de eventos, tiene estas opciones:

La figura 6-22 muestra el primero de estos dos enfoques.

Image

Figura 6-22. Atomicidad al puclicar eventos en el bus de datos

Al enfoque mostrado en la Figura 6-22 le falta un microservicio worker (como tarea de fondo independiente) adicional, que se encarga de verificar y confirmar el éxito de los eventos de integración publicados. En caso de falla, ese microservicio de verificación adicional puede leer eventos de la tabla y volver a publicarlos, es decir, que repita el paso identificado con el número 2.

Acerca del segundo enfoque: utilice la tabla EventLog como una cola y use siempre un microservicio worker para publicar los mensajes. En ese caso, el proceso es como el que se muestra en la figura 6-23. Esta muestra el microservicio adicional y la tabla es la única fuente para publicación de eventos.

Image

Figura 6-23. Atomicidad cuando se publican eventos en el bus de eventos con un microservicio worker

Para simplificar, el ejemplo de eShopOnContainers utiliza el primer enfoque (sin procesos adicionales o microservicios de verificación) más el bus de eventos. Sin embargo, eShopOnContainers no está manejando todos los posibles casos de falla. En una aplicación real implementada en la nube, debe aceptar el hecho de que eventualmente surgirán problemas y debe implementar esa verificación y la lógica para volver a enviar. Usar la tabla como cola puede ser más efectivo que el primer enfoque, si tiene esa tabla como fuente única de eventos cuando los publica con el worker a través del bus de eventos.

Implementando la atomicidad al publicar eventos a través del bus de eventos

El siguiente código muestra cómo puede crear una transacción única que involucre varios DbContext: un contexto relacionado con los datos originales que se actualizan y el segundo contexto relacionado con la tabla IntegrationEventLog.

Tenga en cuenta que la transacción en el siguiente ejemplo no será resiliente si las conexiones a la base de datos tienen algún problema en el momento en que se ejecuta el código. Esto puede suceder en sistemas basados en la nube como Azure SQL DB, que podría mover las bases de datos entre servidores. Para implementar transacciones resilientes en contextos múltiples, consulte la sección Implementando conexiones SQL resilientes con Entity Framework Core más adelante.

Para mayor claridad, el siguiente ejemplo muestra todo el proceso en un solo segmento de código. Sin embargo, en la implementación real de eShopOnContainers, se refactoriza y divide esta lógica en varias clases para que sea más fácil de mantener.

// Update Product from the Catalog microservice

//

public async Task<IActionResult> UpdateProduct([FromBody]CatalogItem
productToUpdate)

{

var catalogItem =

await _catalogContext.CatalogItems.SingleOrDefaultAsync(i => i.Id ==

productToUpdate.Id);

if (catalogItem == null) return NotFound();

 

bool raiseProductPriceChangedEvent = false;

IntegrationEvent priceChangedEvent = null;

if (catalogItem.Price != productToUpdate.Price)

raiseProductPriceChangedEvent = true;

 

if (raiseProductPriceChangedEvent) // Create event if price has changed

{

var oldPrice = catalogItem.Price;

priceChangedEvent = new ProductPriceChangedIntegrationEvent(catalogItem.Id,

productToUpdate.Price,

oldPrice);

}

// Update current product

catalogItem = productToUpdate;

 

// Just save the updated product if the Product's Price hasn't changed.

if !(raiseProductPriceChangedEvent)

{

await _catalogContext.SaveChangesAsync();

}

else // Publish to event bus only if product price changed

{

// Achieving atomicity between original DB and the IntegrationEventLog

// with a local transaction

using (var transaction = _catalogContext.Database.BeginTransaction())

{

_catalogContext.CatalogItems.Update(catalogItem);

await _catalogContext.SaveChangesAsync();

 

// Save to EventLog only if product price changed

if(raiseProductPriceChangedEvent)

await _integrationEventLogService.SaveEventAsync(priceChangedEvent);

 

transaction.Commit();

}

 

// Publish the intergation event through the event bus

_eventBus.Publish(priceChangedEvent);

 

integrationEventLogService.MarkEventAsPublishedAsync(

priceChangedEvent);

}

 

return Ok();

}

Después de crear el evento de integración ProductPriceChangedIntegrationEvent, la transacción que contiene la operación de dominio original (actualizar el elemento de catálogo) también incluye la persistencia del evento en la tabla EventLog. Esto lo convierte en una transacción única y siempre podrá verificar si se enviaron mensajes de eventos.

La tabla de registro de eventos se actualiza atómicamente con la operación de base de datos original, utilizando una transacción local en la misma base de datos. Si alguna de las operaciones falla, se lanza una excepción y la transacción revierte cualquier operación completada, manteniendo así la consistencia entre las operaciones del dominio y los mensajes de eventos registrados en la tabla.

Recibiendo mensajes de las suscripciones: manejadores de eventos en los microservicios receptores

Además de la lógica de suscripción de eventos, también debe implementar el código para los manejadores de eventos de integración (similares a un método callback). En el manejador de eventos se especifica dónde se recibirán y procesarán los mensajes de eventos de un cierto tipo.

Un manejador de eventos primero recibe una instancia del evento desde el bus de eventos. Luego ubica el componente a procesar relacionado con ese evento de integración, propagando y persistiendo el evento como un cambio en el estado en el microservicio receptor. Por ejemplo, si un evento ProductPriceChanged se origina en el microservicio de catálogo, se maneja en el microservicio de carrito de compras y también cambia el estado en este microservicio receptor, como se muestra en el siguiente código.

//Integration-Event handler

 

Namespace Microsoft.eShopOnContainers.Services.Basket.

API.IntegrationEvents.EventHandling

{

public class ProductPriceChangedIntegrationEventHandler :

IIntegrationEventHandler<ProductPriceChangedIntegrationEvent>

{

private readonly IBasketRepository _repository;

 

public ProductPriceChangedIntegrationEventHandler(

IBasketRepository repository)

{

_repository = repository ??

throw new ArgumentNullException(nameof(repository));

}

 

public async Task Handle(ProductPriceChangedIntegrationEvent @event)

{

var userIds = await _repository.GetUsers();

foreach (var id in userIds)

{

var basket = await _repository.GetBasket(id);

await UpdatePriceInBasketItems(@event.ProductId, @event.NewPrice,

basket);

}

}

 

private async Task UpdatePriceInBasketItems(int productId, decimal newPrice,

CustomerBasket basket)

{

var itemsToUpdate = basket?.Items?.

Where(x => int.Parse(x.ProductId) == productId).ToList();

 

if (itemsToUpdate != null)

{

foreach (var item in itemsToUpdate)

{

if(item.UnitPrice != newPrice)

{

var originalPrice = item.UnitPrice;

item.UnitPrice = newPrice;

item.OldUnitPrice = originalPrice;

}

}

 

await _repository.UpdateBasket(basket);

}

}

}

}

El manejador de eventos necesita verificar si el producto existe en alguna de las instancias del carrito de compras. También actualiza el precio del artículo para cada artículo relacionado en los carritos de compras. Finalmente, crea una alerta que se mostrará al usuario sobre el cambio de precio, como se muestra en la Figura 6-24.

Image

Figura 6-24. Mostrando el cambio de precio de un ítem, según lo indicado por eventos de integración

Idempotencia en los eventos de actualización

Un aspecto importante de los eventos de mensajes de actualización es que una falla en cualquier punto de la comunicación debe provocar que se vuelva a intentar el mensaje. De lo contrario, una tarea en segundo plano podría intentar publicar un evento que ya ha sido publicado, creando una condición de carrera. Debe asegurarse de que las actualizaciones sean idempotentes o de que proporcionen información suficiente para garantizar que pueda detectar un duplicado, descartarlo y enviar sólo una respuesta.

Como se señaló anteriormente, idempotencia significa que una operación se pueda realizar varias veces sin cambiar el resultado. En un entorno de mensajería, como cuando se comunican eventos, un evento es idempotente si se puede entregar varias veces sin cambiar el resultado para el microservicio receptor. Esto puede ser necesario debido a la naturaleza del evento en sí mismo o debido a la forma en que el sistema maneja el evento. La idempotencia del mensaje es importante en cualquier aplicación que utilice mensajes, no sólo en aplicaciones que implementan el patrón de bus de eventos.

Un ejemplo de operación idempotente es una declaración SQL que inserta datos en una tabla, sólo si esos datos no están ya en la tabla. No importa cuántas veces ejecute esa instrucción SQL de inserción, el resultado será el mismo: la tabla contendrá esa información. Idempotencia como esta también podría ser necesaria cuando se trata de mensajes si, por cualquier razón, los mensajes se pueden enviar y, por lo tanto, procesar más de una vez. Por ejemplo, si la lógica de reintento hace que un remitente envíe exactamente el mismo mensaje más de una vez, se debe asegurar de que sea idempotente.

Es posible diseñar mensajes idempotentes. Por ejemplo, puede crear un evento que diga "establecer el precio del producto a $25" en lugar de "agregar $5 al precio del producto". Puede procesar el primer mensaje varias veces y el resultado será el mismo. Eso no es cierto para el segundo mensaje. Pero incluso en el primer caso, es posible que no desee procesar el primer evento, porque el sistema también podría haber enviado un evento de cambio de precio más nuevo y estaría sobrescribiendo el nuevo precio.

Otro ejemplo podría ser un evento de orden-completada que se propaga a múltiples suscriptores. Es importante actualizar la información de pedido en otros sistemas sólo una vez, incluso si hay eventos de mensaje duplicados para el mismo evento de orden-completada.

Es conveniente tener algún tipo de identidad de evento, para que pueda crear una lógica que asegure que cada evento se procese sólo una vez por receptor.

Algunos procesos de mensajes son intrínsecamente idempotentes. Por ejemplo, si un sistema genera imágenes miniaturas (thumbnails), puede no importar cuántas veces se procesa el mensaje sobre la thumbnail generada, el resultado es que las miniaturas se generan y son las mismas siempre. Por otro lado, operaciones como llamar a una pasarela de pago para cargar una tarjeta de crédito pueden no ser idempotentes en absoluto. En estos casos, se debe asegurar de que el procesamiento de un mensaje varias veces tenga el efecto que espera.

Recursos adicionales

Desduplicando mensajes de eventos de integración

Puede asegurarse de que los eventos de mensajes se envíen y procesen una sola vez por suscriptor en diferentes niveles. Una forma es usar una característica de deduplicación ofrecida por la infraestructura de mensajería que esté utilizando. Otra es implementar lógica personalizada en su microservicio de destino. Tener validaciones tanto a nivel de transporte como a nivel de aplicación es su mejor apuesta.

Desduplicando eventos de mensaje a nivel del manejador de eventos

Una forma de asegurarse de que un evento sea procesado una sola vez por cualquier receptor, es identificando el evento para que el manejador pueda verificar si ya se procesó anteriormente. Por ejemplo, en la aplicación eShopOnContainers se usa ese enfoque, como puede ver en el código fuente de la clase UserCheckoutAcceptedIntegrationEventHandler. cuando recibe un comando CreateOrderCommand. (En este caso, “envolvemos” el comando CreateOrderCommand en un IdentifiedCommand, usando el eventMsg.RequestId como identificador, antes de enviarlo al manejador de comandos).

Desduplicando mensajes al usar RabbitMQ

Cuando se producen fallas intermitentes de la red, los mensajes pueden duplicarse y el receptor del mensaje debe estar listo para manejar esas duplicaciones. Si es posible, los receptores deben manejar los mensajes de una manera idempotente, lo cual es mejor que manejarlos explícitamente con desduplicación.

De acuerdo con la documentación de RabbitMQ, "si un mensaje se entrega a un consumidor (receptor) y luego se vuelve a poner en la cola (porque no fue confirmado antes de que fallara la conexión, por ejemplo), RabbitMQ marcará el mensaje como “entregado nuevamente” cuando se vuelva a entregar (ya sea el mismo consumidor o uno diferente).

Si el mensaje está marcado como "reentregado", el receptor debe tenerlo en cuenta, ya que el mensaje pudo haber sido procesado anteriormente. Pero eso no está garantizado, es posible que el mensaje nunca haya llegado al receptor después de que salió del intermediario de mensajes, tal vez debido a problemas de red. Por otro lado, si el mensaje no está marcado como "reentregado", se garantiza que el mensaje no se ha enviado más de una vez. Por lo tanto, el receptor necesita desduplicar mensajes o procesarlos de manera idempotente sólo si el mensaje está marcado como "reentregado".

Recursos adicionales

http://go.particular.net/eShopOnContainers

Probando servicios y aplicaciones web ASP.NET Core

Los controladores son una parte central de cualquier servicio API Web o aplicación Web MVC de ASP.NET Core. Por lo tanto, debe tener la seguridad de que se comportan según lo previsto. Las pruebas automatizadas le pueden proporcionar esta confianza y detectar errores antes de que lleguen a producción.

Debe probar cómo se comporta el controlador en función de entradas válidas o no válidas y probar las respuestas del controlador en función del resultado de la operación del negocio que realiza. Por lo tanto, debe tener este tipo de pruebas en sus microservicios:

Implementando pruebas unitarias para ASP.NET Core Web API

Las pruebas unitarias implican probar una parte de una aplicación, aislada de su infraestructura y dependencias. Cuando se prueba la lógica del controlador, sólo se prueba el contenido de una acción o método, no el comportamiento de sus dependencias o del propio framework. Las pruebas unitarias no detectan problemas en la interacción entre componentes, ese es el propósito de las pruebas de integración.

A medida que prueba las acciones de su controlador, asegúrese de enfocarse sólo en su comportamiento. Una prueba unitaria de controlador evita cosas como filtros, enrutamiento o model binding (el mapeo de los datos de la petición a un ViewModel o DTO). Debido a que se enfocan en probar sólo una cosa, las pruebas unitarias generalmente son simples de escribir y rápidas de ejecutar. Un conjunto bien escrito de pruebas unitarias se puede ejecutar con frecuencia sin generar demasiada carga.

Las pruebas unitarias se implementan en frameworks de pruebas como xUnit.net, MSTest, NUnit. Para la aplicación de ejemplo eShopOnContainers, estamos usando xUnit.

Cuando escriba una prueba unitaria para un controlador API Web, debe crear una instancia de la clase de controlador directamente usando la palabra new clave en C#, de modo que la prueba se ejecute lo más rápido posible. El siguiente ejemplo muestra cómo hacerlo al usar xUnit como el framework de pruebas.

[Fact]

public async Task Get_order_detail_success()

{

    //Arrange

    var fakeOrderId = "12";

    var fakeOrder = GetFakeOrder();

//...

 

    //Act

    var orderController = new OrderController(

_orderServiceMock.Object, 

_basketServiceMock.Object, 

_identityParserMock.Object);

 

    orderController.ControllerContext.HttpContext = _contextMock.Object;

    var actionResult = await orderController.Detail(fakeOrderId);

    //Assert

    var viewResult = Assert.IsType<ViewResult>(actionResult);

    Assert.IsAssignableFrom<Order>(viewResult.ViewData.Model);

}

Implementando pruebas de integración y funcionales para cada microservicio

Como se señaló, las pruebas de integración y funcionales tienen diferentes propósitos y objetivos. Sin embargo, la forma en que implementan ambas cuando prueba los controladores ASP.NET Core es similar, así que en esta sección nos concentraremos en las pruebas de integración.

Las pruebas de integración garantizan que los componentes de una aplicación funcionen correctamente cuando se ensamblan. ASP.NET Core soporta pruebas de integración usando frameworks de pruebas unitarias y un host web de pruebas (incluido en ASP.NET Core) que se puede usar para manejar peticiones sin tener que trabajar sobre la red.

A diferencia de las pruebas unitarias, las pruebas de integración con frecuencia involucran asuntos de infraestructura de las aplicaciones, como una base de datos, un sistema de ficheros, recursos de red o peticiones y respuestas web. En las pruebas unitarias se usan maquetas o simulaciones de objetos, para no tener que manejar esos asuntos de infraestructura. Pero el propósito de las pruebas de integración es confirmar que el sistema funciona como se espera con estos sistemas, así que para las pruebas de integración no se usan maquetas ni simulaciones. En cambio, se incluye infraestructura real, como el acceso a bases de datos o la invocación de servicios desde otros servicios.

Como las pruebas de integración abarcan mayores segmentos de código que las pruebas unitarias y debido a que las pruebas de integración dependen de elementos de infraestructura, tienden a ser órdenes de magnitud más lentas que las pruebas unitarias. Por lo tanto, es una buena idea limitar la cantidad de pruebas de integración se escriben y se ejecutan.

ASP.NET Core incluye un host web de pruebas, que se puede usar para manejar peticiones HTTP sin necesidad de pasar por la red, lo que significa que puede ejecutar esas pruebas más rápido que cuando se usa un servidor web real. El host web de prueba está disponible en un paquete NuGet como Microsoft.AspNetCore.TestHost. Puede agregarse a proyectos de prueba de integración y utilizarse para alojar aplicaciones ASP.NET Core.

Como puede ver en el siguiente código, cuando crea pruebas de integración para controladores ASP.NET Core, se crean instancias de los controladores a través del host de prueba. Esto es comparable a una solicitud HTTP, pero se ejecuta más rápido.

public class PrimeWebDefaultRequestShould

{

private readonly TestServer _server;

private readonly HttpClient _client;

public PrimeWebDefaultRequestShould()

{

// Arrange

_server = new TestServer(new WebHostBuilder()

.UseStartup<Startup>());

_client = _server.CreateClient();

}

[Fact]

public async Task ReturnHelloWorld()

{

// Act

var response = await _client.GetAsync("/");

response.EnsureSuccessStatusCode();

 

var responseString = await response.Content.ReadAsStringAsync();

 

// Assert

Assert.Equal("Hello World!",

responseString);

}

}

Recursos adicionales

Implementando pruebas de servicios para aplicaciones multi-contenedor

Como se señaló anteriormente, cuando se prueban aplicaciones multi-contenedor, todos los microservicios se deben ejecutar dentro del host de Docker o del cluster de contenedores. Las pruebas de servicio de principio a fin, que incluyen múltiples operaciones que involucran varios microservicios, requieren que se despliegue e inicie toda la aplicación en el host Docker ejecutando docker-compose up (o un mecanismo similar si está utilizando un orquestador). Una vez que se ejecuta la aplicación completa y todos sus servicios, puede ejecutar pruebas de integración y funcionales de principio a fin.

Hay algunos enfoques que puede usar para esto. En el fichero docker-compose.yml que utiliza para desplegar la aplicación (o similares, como docker-compose.ci.build.yml), en el nivel de solución puede expandir el punto de entrada para usar dotnet test. También puede usar otro fichero de composición que ejecute sus pruebas en la imagen a la que apunta. Al usar otro fichero de composición para pruebas de integración, que incluya sus microservicios y bases de datos en contenedores, debe asegurarse de que los datos relacionados siempre se restablezcan a su estado original antes de ejecutar las pruebas.

Una vez que la aplicación compuesta esté en funcionamiento, puede aprovechar los breakpoints y las excepciones si está ejecutando Visual Studio. O puede ejecutar las pruebas de integración automáticamente en su proceso de CI en Visual Studio Team Services o cualquier otro sistema de CI/CD que soporte contenedores Docker.

Implementando tareas en segundo plano en microservicios con IHostedService y la clase BackgroundService

Las tareas en segundo plano y las tareas programadas son algo que quizás necesite implementar eventualmente, tanto en una aplicación basada en microservicios como en cualquier otro tipo de aplicación. La diferencia al usar una arquitectura de microservicios es que puede implementar un proceso/contenedor único para alojar estas tareas en segundo plano, de modo que pueda escalar hacia arriba/abajo según lo necesite o incluso puede asegurarse de ejecutar una sola instancia de ese proceso/contenedor de microservicios.

Desde un punto de vista genérico, en .NET Core llamamos a este tipo de tareas Hosted Services, porque son servicios/lógica que usted aloja en su host/aplicación/microservicio. Tenga en cuenta que, en este caso, el servicio alojado simplemente significa una clase con la lógica de tareas en segundo plano.

Desde .NET Core 2.0, el framework proporciona una nueva interfaz llamada IHostedService que le ayuda a implementar fácilmente los hosted services. La idea básica es que puede registrar varias tareas (hosted services), que se ejecutan en segundo plano, mientras está corriendo su host o host web, como se muestra en la imagen 8-25.

Image

Figura 6-25. Using IHostedService in a WebHost vs. a Host

Note la diferencia que se hace entre WebHost y Host.

Un WebHost (clase base que implementa IWebHost) en ASP.NET Core 2.0 es el artefacto de infraestructura que utiliza para proporcionar funciones de servidor Http a su proceso, por ejemplo, si está implementando una aplicación web MVC o un servicio de API Web. Proporciona todas las nuevas bondades de la infraestructura en ASP.NET Core, lo que le permite utilizar la inyección de dependencias, insertar middlewares en el request pipeline, etc. y utilizar estos IHostedServices precisamente para las tareas en segundo plano.

Sin embargo, un Host (clase base que implementa IHost) es algo nuevo en .NET Core 2.1. Básicamente, un Host le permite tener una infraestructura similar a la que tiene con WebHost (inyección de dependencias, hosted services, etc.), pero en este caso, sólo necesita un proceso simple y más liviano como host, sin nada relacionado con las características de un servidor MVC, API Web o Http.

Por lo tanto, puede elegir y crear un proceso host especializado con IHost para manejar los servicios alojados y nada más, como un microservicio hecho sólo para alojar los IHostedServices o, alternativamente, puede extender un WebHost ASP.NET Core, tal como una aplicación ASP.NET Core Web API o MVC existente.

Cada enfoque tiene ventajas e inconvenientes, dependiendo de las necesidades del negocio y la escalabilidad. La conclusión básica es que si sus tareas de fondo no tienen nada que ver con HTTP (IWebHost) debería usar IHost, cuando esté disponible en. NET Core 2.1.

Registrando hosted services en un Host o WebHost

Vamos a profundizar más en la interfaz IHostedService ya que su uso es bastante similar en un WebHost o en un Host.

SignalR es un ejemplo de un artefacto que utiliza hosted services, pero también puede usarlo para cosas mucho más simples como:

Básicamente puede descargar cualquiera de esas acciones a una tarea en segundo plano basada en IHostedService.

La forma de agregar uno o varios IHostedServices a su WebHost o Host es registrándolos mediante inyección de dependencias en un WebHost de ASP.NET Core (o en un Host de .NET Core 2.1). Básicamente, debe registrar los hosted services dentro del conocido método ConfigureServices() de la clase Startup, como en el siguiente código de un WebHost de ASP.NET Core típico.

public IServiceProvider ConfigureServices(IServiceCollection services)

{

//Other DI registrations;

 

// Register Hosted Services

services.AddSingleton<IHostedService, GracePeriodManagerService>();

services.AddSingleton<IHostedService, MyHostedServiceB>();

services.AddSingleton<IHostedService, MyHostedServiceC>();

//...

}

En ese código, el hosted service GracePeriodManagerService es código real del microservicio de pedidos de eShopOnContainers, mientras que los otros dos son sólo dos ejemplos adicionales.

La ejecución de la tarea en segundo plano IHostedService se coordina con la vida de la aplicación (host o microservicio, para el caso). Se registran las tareas cuando al iniciar la aplicación y tiene la oportunidad de realizar algún proceso de cierre ordenado o de limpieza cuando se termina la aplicación.

También podría iniciar un hilo en segundo plano para ejecutar cualquier tarea, sin utilizar IHostedService. La diferencia es precisamente en el momento de cierre de la aplicación cuando ese hilo simplemente se terminaría sin tener la oportunidad de ejecutar acciones de cierre ordenadas.

La interfaz IHostedService

Cuando registra un IHostedService, .NET Core llamará a los métodos StartAsync() y StopAsync() de su tipo IHostedService durante el inicio y la terminación de la aplicación, respectivamente. Específicamente, se invoca StartAsync después de que el servidor ha comenzado y antes de que se dispare IApplicationLifetime.ApplicationStarted.

IHostedService, tal como se define en .NET Core, tiene el siguiente aspecto:

namespace Microsoft.Extensions.Hosting

{

//

// Summary:

// Defines methods for objects that are managed by the host.

public interface IHostedService

{

//

// Summary:

// Triggered when the application host is ready to start the service.

Task StartAsync(CancellationToken cancellationToken);

//

// Summary:

// Triggered when the application host is performing a graceful shutdown.

Task StopAsync(CancellationToken cancellationToken);

}

}

Como se puede imaginar, puede crear múltiples implementaciones de IHostedService y registrarlas en el contenedor de dependencias, en el método ConfigureServices(), como se ha mostrado anteriormente. Todos esos hosted services se iniciarán y se detendrán junto con la aplicación/microservicio.

Como desarrollador, usted es responsable de manejar la acción de terminación de sus servicios cuando el host dispara el método StopAsync().

Implementando IHostedService con un hosted service derivado de la clase BackgroundService

También podría crear una clase como hosted service personalizado desde cero e implementar IHostedService, como se debe hacer al usar .NET Core 2.0.

Sin embargo, dado que la mayoría de las tareas en segundo plano tienen necesidades bastante similares con respecto a la gestión de tokens de cancelación y otras operaciones típicas, .NET Core 2.1 proporcionará una clase base abstracta muy conveniente de la que puede derivarse, denominada BackgroundService.

Esa clase proporciona el trabajo principal necesario para configurar la tarea de segundo plano. Tenga en cuenta que esta clase vendrá en la librería .NET Core 2.1, así que no tendrá que escribirla.

Sin embargo, al momento de escribir este documento, .NET Core 2.1 no ha sido liberado. Por lo tanto, en eShopOnContainers que actualmente está usando. NET Core 2.0, estamos incorporando temporalmente esa clase del repositorio open-source de .NET Core 2.1 (sin necesidad de ninguna licencia propietaria aparte de la licencia open-source) porque es compatible con la interfaz actual de IHostedService en .NET Core 2.0. Cuando se libere .NET Core 2.1, sólo tendrá que apuntar al paquete NuGet correcto.

El siguiente código es la clase base abstracta BackgroundService implementada en .NET Core 2.1.

// Copyright (c) .NET Foundation. Licensed under the Apache License, Version 2.0.

/// <summary>

/// Base class for implementing a long running <see cref="IHostedService"/>.

/// </summary>

public abstract class BackgroundService : IHostedService, IDisposable

{

private Task _executingTask;

private readonly CancellationTokenSource _stoppingCts =

new CancellationTokenSource();

 

protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

 

public virtual Task StartAsync(CancellationToken cancellationToken)

{

// Store the task we're executing

_executingTask = ExecuteAsync(_stoppingCts.Token);

 

// If the task is completed then return it,

// this will bubble cancellation and failure to the caller

if (_executingTask.IsCompleted)

{

return _executingTask;

}

 

// Otherwise it's running

return Task.CompletedTask;

}

public virtual async Task StopAsync(CancellationToken cancellationToken)

{

// Stop called without start

if (_executingTask == null)

{

return;

}

 

try

{

// Signal cancellation to the executing method

_stoppingCts.Cancel();

}

finally

{

// Wait until the task completes or the stop token triggers

await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite,

cancellationToken));

}

 

}

 

public virtual void Dispose()

{

_stoppingCts.Cancel();

}

}

Cuando se deriva de la clase base abstracta anterior, gracias a esa implementación heredada, sólo necesita implementar el método ExecuteAsync() en su propia clase del hosted service, como en el siguiente código simplificado de eShopOnContainers, que supervisa una base de datos y publica eventos de integración en el bus de eventos cuando es necesario.

public class GracePeriodManagerService

: BackgroundService

{

private readonly ILogger<GracePeriodManagerService> _logger;

private readonly OrderingBackgroundSettings _settings;

 

private readonly IEventBus _eventBus;

 

public GracePeriodManagerService(IOptions<OrderingBackgroundSettings> settings,

IEventBus eventBus,

ILogger<GracePeriodManagerService> logger)

{

//Constructor’s parameters validations...

}

 

protected override async Task ExecuteAsync(CancellationToken stoppingToken)

{

_logger.LogDebug($"GracePeriodManagerService is starting.");

 

stoppingToken.Register(() =>

_logger.LogDebug($" GracePeriod background task is stopping."));

 

while (!stoppingToken.IsCancellationRequested)

{

_logger.LogDebug($"GracePeriod task doing background work.");

 

// This eShopOnContainers method is quering a database table

// and publishing events into the Event Bus (RabbitMS / ServiceBus)

CheckConfirmedGracePeriodOrders();

 

await Task.Delay(_settings.CheckUpdateTime, stoppingToken);

}

_logger.LogDebug($"GracePeriod background task is stopping.");

 

}

 

protected override async Task StopAsync (CancellationToken stoppingToken)

{

// Run your graceful clean-up actions

}

}

En este caso específico para eShopOnContainers, se está ejecutando un método de la aplicación que consulta una tabla de base de datos buscando pedidos con un estado específico y al aplicar cambios, publica eventos de integración a través del bus de eventos (debajo se puede usar RabbitMQ o Azure Service Bus).

Por supuesto, puede ejecutar cualquier otra tarea del negocio en segundo plano, en su lugar.

Por defecto, el token de cancelación se establece con un tiempo de espera de 5 segundos, aunque puede cambiar ese valor al construir su WebHost, usando la extensión UseShutdownTimeout del IWebHostBuilder. Esto significa que se espera que nuestro servicio se cancele en 5 segundos, de lo contrario será terminado de forma más abrupta.

El siguiente código estaría cambiando ese tiempo a 10 segundos.

WebHost.CreateDefaultBuilder(args)

.UseShutdownTimeout(TimeSpan.FromSeconds(10))

...

Diagrama de clases resumen

La siguiente figura representa un resumen de las clases e interfaces involucradas al implementar IHostedServices.

Image

Figura 6-26. Diagrama de clases mostrando las clases e interfaces relacionadas con IHostedService

Consideraciones de despliegue y puntos clave

Es importante tener en cuenta que la forma en que despliegue ASP.NET Core WebHost o .NET Core Host podría afectar la solución final. Por ejemplo, si despliega su WebHost en IIS o en un servicio de aplicaciones de Azure normal, su host puede terminar debido a los reinicios del app pool. Pero si está desplegando su host como un contenedor en un orquestador como Kubernetes o Service Fabric, puede controlar la cantidad garantizada de instancias activas de su host. Además, podría considerar otros enfoques en la nube especialmente diseñados para estos escenarios, como Azure Functions.

Pero incluso para un WebHost desplegado en un app pool (IIS), existen escenarios como el recargado (repopulating) o el vaciado (flushing) de la caché en memoria de la aplicación, que seguirían siendo aplicables.

La interfaz IHostedService proporciona una forma conveniente de iniciar tareas en segundo plano en una aplicación web ASP.NET Core (en .NET Core 2.0) o en cualquier proceso/host (comenzando en .NET Core 2.1 con IHost). Su principal beneficio es la oportunidad de tener un proceso de cierre ordenado para las tareas en segundo plano cuando el host se está apagando.

Recursos adicionales

https://blog.maartenballiauw.be/post/2017/08/01/building-a-scheduled-cache-updater-in-aspnet-core-2.html

https://www.stevejgordon.co.uk/asp-net-core-2-ihostedservice

https://github.com/aspnet/Hosting/tree/dev/samples/GenericHostSample