Azure Cosmos DB es el servicio de base de datos distribuido globalmente de Microsoft para aplicaciones de misión crítica. Azure Cosmos DB proporciona distribución global llave en mano, escalado elástico de rendimiento y almacenamiento en todo el mundo, latencias de un dígito en milisegundos en el percentil 99, cinco niveles de consistencia bien definidos y alta disponibilidad garantizada, todos respaldados por SLA líderes en la industria. Azure Cosmos DB indexa automáticamente los datos sin necesidad de que se ocupe de la administración de esquema ni de los índices. Es multi modelo y soporta modelos de documentos, clave-valor, grafos y columnas.
Figura 7-19. Distribución global de Azure Cosmos DB
Cuando utiliza un modelo C# para implementar el agregado que usará la API de Azure Cosmos DB, el agregado puede ser similar a las clases POCO de C# utilizadas con EF Core. La diferencia está en la forma de usarlos desde las capas de aplicación e infraestructura, como en el siguiente código:
// C# EXAMPLE OF AN ORDER AGGREGATE BEING PERSISTED WITH AZURE COSMOS DB API
// *** Domain Model Code ***
// Aggregate: Create an Order object with its child entities and/or value objects.
// Then, use AggregateRoot’s methods to add the nested objects & apply invariants.
Order orderAggregate = new Order
{
Id = "2017001",
OrderDate = new DateTime(2005, 7, 1),
BuyerId = "1234567",
PurchaseOrderNumber = "PO18009186470"
}
Address address = new Address
{
Street = "100 One Microsoft Way",
City = "Redmond",
State = "WA",
Zip = "98052",
Country = "U.S."
}
orderAggregate.UpdateAddress(address);
OrderItem orderItem1 = new OrderItem
{
Id = 20170011,
ProductId = "123456",
ProductName = ".NET T-Shirt",
UnitPrice = 25,
Units = 2,
Discount = 0;
};
//Using methods with domain logic within the entity. No anemic-domain model
orderAggregate.AddOrderItem(orderItem1);
// *** End of Domain Model Code ***
// *** Infrastructure Code using Cosmos DB Client API ***
Uri collectionUri = UriFactory.CreateDocumentCollectionUri(databaseName,
collectionName);
await client.CreateDocumentAsync(collectionUri, order);
// As your app evolves, let's say your object has a new schema. You can insert
// OrderV2 objects without any changes to the database tier.
Order2 newOrder = GetOrderV2Sample("IdForSalesOrder2");
await client.CreateDocumentAsync(collectionUri, newOrder);
Puede ver que la forma en que trabaja con su modelo de dominio puede ser similar a la que usa cuando la infraestructura es EF. Aún usa los mismos métodos de raíz de agregación para garantizar la consistencia, invariantes y validaciones dentro del agregado.
Sin embargo, cuando persiste su modelo en la base de datos NoSQL, el código y la API cambian dramáticamente en comparación con el código EF Core o cualquier otro código relacionado con las bases de datos relacionales.
Puede acceder a las bases de datos de Azure Cosmos DB desde el código .NET que se ejecuta en contenedores, como desde cualquier otra aplicación .NET. Por ejemplo, los microservicios Locations.API y Marketing.API en eShopOnContainers se implementan para que puedan consumir las bases de datos de Azure Cosmos DB.
Sin embargo, hay una limitación en Azure Cosmos DB, desde el punto de vista del entorno de desarrollo Docker. Incluso aunque hay un emulador de bases de datos Azure Cosmos DB que puede ejecutarse en una máquina de desarrollo local (como una PC), la menos hasta finales de 2017, sólo soporta Windows, no Linux.
También existe la posibilidad de ejecutar este emulador en Docker, pero sólo en Contenedores Windows, no Linux. Esa es una desventaja inicial para el entorno de desarrollo si su aplicación se implementa como contenedores Linux, ya que, actualmente, no puede implementar Contenedores Linux y Windows en Docker para Windows al mismo tiempo. O bien todos los contenedores que se implementan tienen que ser para Linux o para Windows.
El despliegue ideal y más sencillo para una solución de desarrollo/pruebas es poder desplegar sus sistemas de bases de datos como contenedores junto con sus contenedores personalizados, para que sus entornos de desarrollo/pruebas sean siempre consistentes.
Las bases de datos de Cosmos DB soportan la API de MongoDB para .NET, así como el protocolo nativo MongoDB. Esto significa que, al usar los controladores existentes, la aplicación escrita para MongoDB ahora puede comunicarse con Cosmos DB y usar las bases de datos de Cosmos DB en lugar de las bases de datos de MongoDB, como se muestra en la Figura 7-20.
Figura 7-20. Usando la API y el protocolo de MongoDB para acceder a Azure Cosmos DB
Este es un enfoque muy conveniente para las pruebas de conceptos en entornos Docker con contenedores Linux porque la imagen MongoDB Docker es una imagen multi-arch que soporta contenedores Docker Linux y Windows.
Como se muestra en la imagen 9-21, al usar la API de MongoDB, eShopOnContainers soporta contenedores MongoDB Linux y Windows para el entorno de desarrollo local, pero luego puede pasar a una solución en la nube PaaS escalable como Azure Cosmos DB simplemente cambiando la cadena de conexión de MongoDB para apuntar a Azure Cosmos DB.
Figura 7-21. eShopOnContainers usando contenedores MongoDB para desarrollo y Azure Cosmos DB para producción
La instancia de producción de Azure Cosmos DB se estaría ejecutando en la nube de Azure, como un servicio PaaS escalable.
Sus contenedores .NET Core personalizados pueden ejecutarse en un host Docker de desarrollo local (que usa Docker para Windows en una máquina con Windows 10) o desplegarse en un entorno de producción, como Kubernetes en Azure AKS o Azure Service Fabric. En este segundo entorno, sólo desplegaría los contenedores personalizados .NET Core pero no el contenedor MongoDB, ya que usaría Azure Cosmos DB en la nube para manejar los datos en producción.
Un beneficio claro si utiliza la API de MongoDB es que su solución podría ejecutarse en ambos motores de base de datos, MongoDB o Azure Cosmos DB, por lo que las migraciones a diferentes entornos deberían ser fáciles. Sin embargo, a veces vale la pena utilizar una API nativa (que es la API nativa de Cosmos DB) para aprovechar al máximo las capacidades de un motor de base de datos específico.
Para una mejor comparación entre simplemente usar MongoDB versus Cosmos DB en la nube, consulte los beneficios de usar Azure Cosmos DB en esta página.
En eShopOnContainers estamos utilizando la API de MongoDB porque nuestra prioridad era fundamentalmente tener un entorno consistente de desarrollo/pruebas, utilizando una base de datos NoSQL que también pudiese funcionar con Azure Cosmos DB.
Sin embargo, si planea usar la API de MongoDB para acceder a Azure Cosmos DB en Azure para aplicaciones de producción, primero debe analizar cuáles son las diferencias en capacidades y rendimiento al usar la API de MongoDB para acceder a Azure Cosmos DB, en comparación con el uso de la API nativa de Azure Cosmos DB. Dependiendo del caso, podría ser similar para que pueda seguir usando la API de MongoDB y obtenga la ventaja de soportar dos motores de base de datos NoSQL al mismo tiempo, pero también podría ser diferente si hay alguna capacidad especial en Azure Cosmos DB que no funciona de la misma manera cuando se usa la API de MongoDB.
Por el contrario, también podría usar los clusters de MongoDB como la base de datos de producción en la nube de Azure, también el con MongoDB Azure Service. Pero ese no es un servicio PaaS provisto por Microsoft. En este caso, Azure solo está hospedando esa solución proveniente de MongoDB.
Básicamente, esto es sólo un descargo de responsabilidad para aclarar que no siempre se debe usar la API de MongoDB contra Azure Cosmos DB, como lo estamos haciendo en eShopOnContainers, porque era una opción conveniente para los contenedores de Linux. La decisión debe basarse en las necesidades y pruebas específicas que debe hacer para su aplicación en producción.
La API de MongoDB para .NET se basa en paquetes NuGet que debe agregar a sus proyectos, como en Locations.API que se muestra en la siguiente imagen.
Figura 7-22. Referencias a los paquetes NuGet de MongoDB en un proyecto .NET Core
Con respecto al código, hay varias áreas para investigar, como se explica en las siguientes secciones.
En primer lugar, debe definir un modelo, que contendrá los datos provenientes de la base de datos, en el espacio de memoria de su aplicación. Aquí hay un ejemplo del modelo utilizado para Locations en eShopOnContainers.
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver.GeoJsonObjectModel;
using System.Collections.Generic;
public class Locations
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; }
public int LocationId { get; set; }
public string Code { get; set; }
[BsonRepresentation(BsonType.ObjectId)]
public string Parent_Id { get; set; }
public string Description { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
public GeoJsonPoint<GeoJson2DGeographicCoordinates> Location
{ get; private set; }
public GeoJsonPolygon<GeoJson2DGeographicCoordinates> Polygon
{ get; private set; }
public void SetLocation(double lon, double lat) => SetPosition(lon, lat);
public void SetArea(List<GeoJson2DGeographicCoordinates> coordinatesList)
=> SetPolygon(coordinatesList);
private void SetPosition(double lon, double lat)
{
Latitude = lat;
Longitude = lon;
Location = new GeoJsonPoint<GeoJson2DGeographicCoordinates>(
new GeoJson2DGeographicCoordinates(lon, lat));
}
private void SetPolygon(List<GeoJson2DGeographicCoordinates> coordinatesList)
{
Polygon = new GeoJsonPolygon<GeoJson2DGeographicCoordinates>(
new GeoJsonPolygonCoordinates<GeoJson2DGeographicCoordinates>(
new GeoJsonLinearRingCoordinates<GeoJson2DGeographicCoordinates>(
coordinatesList)));
}
}
Puede ver que hay algunos atributos y tipos provenientes de los paquetes NuGet de MongoDB.
Las bases de datos NoSQL suelen ser muy adecuadas para trabajar con datos no relacionales, pero jerárquicos. En este ejemplo, estamos usando tipos de MongoDB especialmente diseñados para geo-localizaciones, como GeoJson2DGeographicCoordinates.
En eShopOnContainers, hemos creado un contexto de base de datos personalizado donde implementamos el código para recuperar la base de datos y las MongoCollections, como en el siguiente código:
public class LocationsContext
{
private readonly IMongoDatabase _database = null;
public LocationsContext(IOptions<LocationSettings> settings)
{
var client = new MongoClient(settings.Value.ConnectionString);
if (client != null)
_database = client.GetDatabase(settings.Value.Database);
}
public IMongoCollection<Locations> Locations
{
get
{
return _database.GetCollection<Locations>("Locations");
}
}
}
En C#, como los controladores de API Web o repositorios personalizados, se puede escribir un código similar al siguiente cuando realiza una consulta a través de la API de MongoDB, siendo el objeto _context una instancia de la clase LocationsContext anterior.
public async Task<Locations> GetAsync(int locationId) { var filter = Builders<Locations>.Filter.Eq("LocationId", locationId); return await _context.Locations .Find(filter) .FirstOrDefaultAsync(); } |
Al crear un objeto MongoClient, se necesita un parámetro fundamental, que es precisamente el ConnectionString apuntando, en el caso de eShopOnContainers, a un contenedor MongoDB Docker local o a la base de datos de "producción" de Azure Cosmos DB. Esa cadena de conexión proviene de las variables de entorno definidas en los ficheros docker-compose.override.yml cuando se implementa con docker-compose o Visual Studio, como en el siguiente código yml.
# docker-compose.override.yml version: '3' services: # Other services locations.api: environment: # Other settings - ConnectionString=${ESHOP_AZURE_COSMOSDB:-mongodb://nosql.data} |
La variable de entorno ConnectionString se resuelve de está forma: si está definida la variable global ESHOP_AZURE_COSMOSDB en el fichero .env con la cadena de conexión de Azure Cosmos DB, la usará para acceder a esta base de datos en la nube. Si no está definida, tomará el valor mongodb://nosql.data y usará el contenedor de desarrollo de mongodb.
En el código siguiente puede ver el fichero .env con la variable de entorno global con la cadena de conexión al Azure Cosmos DB, como está implementado en eShopOnContainers.
# .env file, in eShopOnContainers root folder # Other Docker environment variables ESHOP_EXTERNAL_DNS_NAME_OR_IP=localhost ESHOP_PROD_EXTERNAL_DNS_NAME_OR_IP=<YourDockerHostIP>
#ESHOP_AZURE_COSMOSDB=<YourAzureCosmosDBConnData>
#Other environment variables for additional Azure infrastructure assets #ESHOP_AZURE_REDIS_BASKET_DB=<YourAzureRedisBasketInfo> #ESHOP_AZURE_SERVICE_BUS=<YourAzureServiceBusInfo> |
Debe eliminar el comentario de la línea ESHOP_AZURE_COSMOSDB y actualizarla con su cadena de conexión real para Azure Cosmos DB, obtenida del Azure Portal como se explica en este tutorial: Conectar una aplicación MongoDB a Azure Cosmos DB.
Si la variable global ESHOP_AZURE_COSMOSDB está vacía, lo que significa que está comentada en el fichero .env, usará una cadena de conexión por defecto a MongoDB, que apunta al contenedor MongoDB local implementado en eShopOnContainers, que se llama nosql.data y se definió en el fichero docker-compose, como se muestra en el siguiente código .yml.
# docker-compose.yml version: '3' services: # ...Other services... nosql.data: image: mongo |
https://docs.microsoft.com/azure/cosmos-db/modeling-data
https://vaughnvernon.co/?p=942
https://docs.microsoft.com/azure/cosmos-db/mongodb-introduction
https://docs.microsoft.com/azure/cosmos-db/create-mongodb-dotnet
https://docs.microsoft.com/azure/cosmos-db/local-emulator
https://docs.microsoft.com/azure/cosmos-db/connect-mongodb-account
https://hub.docker.com/r/microsoft/azure-cosmosdb-emulator/
https://hub.docker.com/r/_/mongo/
https://docs.microsoft.com/azure/cosmos-db/mongodb-mongochef
Los principios SOLID son técnicas clave para ser utilizadas en cualquier aplicación moderna y de misión crítica, como el desarrollo de un microservicio con patrones DDD. SOLID es un acrónimo que agrupa cinco principios fundamentales:
SOLID es más acerca de cómo diseñar su aplicación o capas internas de microservicio y sobre cómo desacoplar dependencias entre ellas. No está relacionado con el dominio, sino con el diseño técnico de la aplicación. El principio final, el principio de Inversión de Dependencia (DI), le permite desacoplar la capa de infraestructura del resto de las capas, lo que permite una mejor implementación desacoplada de las capas DDD.
La Inyección de Dependencias (también DI) es una forma de implementar el principio de Inversión de Dependencia. Es una técnica para lograr un acoplamiento flexible entre los objetos y sus dependencias. En lugar de crear instancias directas de los colaboradores o usar referencias estáticas, los objetos que una clase necesita para realizar sus acciones se proporcionan (o "inyectan") a la clase. Muy a menudo, las clases declararán sus dependencias a través de su constructor, permitiéndoles seguir el principio de Dependencias Explícitas. La Inyección de Dependencias generalmente se basa en contenedores específicos de Inversión de Control (IoC). ASP.NET Core proporciona un contenedor IoC integrado simple, pero también puede usar su contenedor IoC favorito, como Autofac o Ninject.
Siguiendo los principios SOLID, sus clases tenderán naturalmente a ser pequeñas, bien factorizadas y fáciles de probar. Pero, ¿cómo puede saber si se están inyectando demasiadas dependencias en sus clases? Si usa DI a través del constructor, será fácil detectarlo simplemente mirando el número de parámetros del constructor. Si hay demasiadas dependencias, esto generalmente es una señal (un code smell – algo que no huele bien) de que su clase está tratando de hacer demasiado y probablemente esté violando el principio de Responsabilidad Única.
Tomaría otra guía para cubrir SOLID en detalle. Por lo tanto, esta guía requiere que sólo tenga un conocimiento mínimo de estos temas y esté consciente de ellos.
Como se mencionó anteriormente, la capa de aplicación se puede implementar tanto como parte del artefacto (ensamblado) que está construyendo, como dentro de un proyecto de API Web o un proyecto de aplicación web MVC. En el caso de un microservicio creado con ASP.NET Core, la capa de aplicación generalmente será su librería API Web. Si desea separar lo que proviene de ASP.NET Core (su infraestructura más sus controladores) del código de capa de aplicación personalizada, también puede colocar su capa de aplicación en una librería de clases separada, pero eso es opcional.
Por ejemplo, el código de la capa de aplicación del microservicio de pedidos se implementa directamente como parte del proyecto Ordering.API (un proyecto ASP.NET Core Web API), como se muestra en la figura 7-23.
Figura 7-23. La capa de aplicación en el proyecto ASP.NET Core Web API Ordering.API
ASP.NET Core incluye un contenedor IoC simple integrado (representado por la interfaz IServiceProvider) que soporta por defecto la inyección por constructor y ASP.NET hace que ciertos servicios estén disponibles a través de DI. ASP.NET Core utiliza el término servicio para cualquiera de los tipos que registra y que se inyectará a través de DI. Los servicios del contenedor integrado se configuran en el método ConfigureServices en la clase Startup de su aplicación. Sus dependencias se implementan en los servicios que necesitan los tipos y que también se registran en el contenedor IoC.
Normalmente, desea inyectar dependencias que implementen objetos de infraestructura. Una dependencia muy típica para inyectar es un repositorio. Pero podría inyectar cualquier otra dependencia de infraestructura que pueda tener. Para implementaciones más simples, puede inyectar directamente su objeto de patrón de unidad de trabajo (el objeto DbContext de EF), porque el DBContext es también la implementación de los objetos de persistencia de su infraestructura.
En el siguiente ejemplo, puede ver cómo .NET Core está inyectando los objetos requeridos del repositorio a través del constructor. La clase es un manejador de comandos, que trataremos en la siguiente sección.
// Sample command handler
public class CreateOrderCommandHandler
: IAsyncRequestHandler<CreateOrderCommand, bool>
{
private readonly IOrderRepository _orderRepository;
private readonly IIdentityService _identityService;
private readonly IMediator _mediator;
// Using DI to inject infrastructure persistence Repositories
public CreateOrderCommandHandler(IMediator mediator,
IOrderRepository orderRepository,
IIdentityService identityService)
{
_orderRepository = orderRepository ??
throw new ArgumentNullException(nameof(orderRepository));
_identityService = identityService ??
throw new ArgumentNullException(nameof(identityService));
_mediator = mediator ??
throw new ArgumentNullException(nameof(mediator));
}
public async Task<bool> Handle(CreateOrderCommand message)
{
// Create the Order AggregateRoot
// Add child entities and value objects through the Order aggregate root
// methods and constructor so validations, invariants, and business logic
// make sure that consistency is preserved across the whole aggregate
var address = new Address(message.Street, message.City, message.State,
message.Country, message.ZipCode);
var order = new Order(message.UserId, address, message.CardTypeId,
message.CardNumber, message.CardSecurityNumber,
message.CardHolderName, message.CardExpiration);
foreach (var item in message.OrderItems)
{
order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice,
item.Discount, item.PictureUrl, item.Units);
}
_orderRepository.Add(order);
return await _orderRepository.UnitOfWork
.SaveEntitiesAsync();
}
}
La clase usa los repositorios inyectados para ejecutar la transacción y persistir los cambios de estado. No importa si esa clase es un manejador de comandos, un método de controlador ASP.NET Web API o un servicio de aplicación DDD. En última instancia, es una clase simple que usa repositorios, entidades del dominio y otras, para coordinar la aplicación, de forma similar a un manejador de comandos. La Inyección de Dependencias (DI) funciona de la misma manera para todas las clases mencionadas, como en el ejemplo, que usa DI basado en el constructor.
Antes de usar los objetos inyectados a través de los constructores, necesita saber dónde registrar las interfaces y las clases que producen los objetos inyectados en las clases de la aplicación, a través de DI. (Usando DI basada en el constructor, como se mostró anteriormente).
Cuando utiliza el contenedor integrado de IoC proporcionado por ASP.NET Core, debe registrar los tipos que desea inyectar en el método ConfigureServices en el fichero Startup.cs, como en el siguiente código:
// Registration of types into ASP.NET Core built-in container public void ConfigureServices(IServiceCollection services) { // Register out-of-the-box framework services. services.AddDbContext<CatalogContext>(c => { c.UseSqlServer(Configuration["ConnectionString"]); }, ServiceLifetime.Scoped ); services.AddMvc(); // Register custom application dependencies. services.AddScoped<IMyCustomRepository, MyCustomSQLRepository>(); } |
El patrón más común al registrar tipos en un contenedor IoC es registrar un par de tipos: una interfaz y su clase de implementación relacionada. Luego, cuando solicita un objeto desde el contenedor IoC a través de cualquier constructor, solicita un objeto de cierto tipo de interfaz. Por ejemplo, en el código anterior, la última línea indica que cuando alguno de sus constructores tiene una dependencia en IMyCustomRepository (interfaz o abstracción), el contenedor IoC inyectará una instancia de la clase de implementación MyCustomSQLServerRepository.
Al usar DI en .NET Core, es posible que desee poder escanear un ensamblado y registrar automáticamente sus tipos por convención. Esta función no está disponible actualmente en ASP.NET Core. Sin embargo, puede usar la librería Scrutor para eso. Este enfoque es conveniente cuando tiene docenas de tipos que deben registrarse en su contenedor IoC.
También puede usar contenedores IoC adicionales y conectarlos a la línea de proceso de peticiones (pipeline) de ASP.NET Core, como en el servicio de microservicio de pedidos en eShopOnContainers, que usa Autofac. Al usar Autofac, generalmente registra los tipos a través de módulos, lo que le permite dividir los tipos de registro entre múltiples ficheros dependiendo de dónde estén sus tipos, del mismo modo que podría tener los tipos de aplicaciones distribuidos en múltiples librerías de clases.
Por ejemplo, el siguiente es el módulo Autofac de la aplicación para el proyecto API Web Ordering.API con los tipos que serán inyectados.
public class ApplicationModule : Autofac.Module
{
public string QueriesConnectionString { get; }
public ApplicationModule(string qconstr)
{
QueriesConnectionString = qconstr;
}
protected override void Load(ContainerBuilder builder)
{
builder.Register(c => new OrderQueries(QueriesConnectionString))
.As<IOrderQueries>()
.InstancePerLifetimeScope();
builder.RegisterType<BuyerRepository>()
.As<IBuyerRepository>()
.InstancePerLifetimeScope();
builder.RegisterType<OrderRepository>()
.As<IOrderRepository>()
.InstancePerLifetimeScope();
builder.RegisterType<RequestManager>()
.As<IRequestManager>()
.InstancePerLifetimeScope();
}
}
Autofac también tiene facilidades para escanear ensamblados y registrar tipos por convenciones de nombre.
El proceso y los conceptos de registro son muy similares a la forma en que puede registrar tipos con el contenedor ASP.NET Core iOS integrado, pero la sintaxis al usar Autofac es un poco diferente.
En el código de ejemplo, la abstracción IOrderRepository se registra junto con la clase de implementación OrderRepository. Esto significa que siempre que un constructor declare una dependencia a través de la abstracción o interfaz IOrderRepository, el contenedor IoC inyectará una instancia de la clase OrderRepository.
El tipo de ámbito de instancia determina cómo se comparte una instancia entre las peticiones para el mismo servicio o dependencia. Cuando se realiza la solicitud para una dependencia, el contenedor IoC puede devolver lo siguiente:
En el ejemplo de DI a través del constructor mostrado en la sección anterior, el contenedor IoC estaba inyectando repositorios a través de un constructor en una clase. Pero, exactamente ¿dónde fueron inyectados? En una API Web simple (por ejemplo, el microservicio de catálogo en eShopOnContainers), los inyecta en el nivel de controladores MVC, en un constructor de controladores, como parte del request pipeline de ASP.NET Core. Sin embargo, en el código inicial de esta sección (la clase CreateOrderCommandHandler del servicio Ordering.API en eShopOnContainers), la inyección de dependencias se realiza mediante el constructor de un manejador de comandos en particular. Permítanos explicarle qué es un manejador de comando y por qué le gustaría usarlo.
El patrón de comando está intrínsecamente relacionado con el patrón de CQRS que se introdujo anteriormente en esta guía. CQRS tiene dos lados. El primero es las consultas, usando consultas simplificadas con el micro ORM de Dapper, que explicamos anteriormente. El segundo lado son los comandos, que son el punto de partida para las transacciones y el canal de entrada desde el exterior del servicio.
Como se muestra en la figura 7-24, el patrón se basa en aceptar comandos del lado del cliente, procesarlos según las reglas del modelo de dominio y finalmente persistir los estados con transacciones.
Figura 7-24. Vista simplificada, de alto nivel, de los comandos o el “lado transaccional” en el patrón CQRS
Un comando es una solicitud para que el sistema realice una acción que cambie el estado del sistema. Los comandos son imprescindibles y se deben procesar sólo una vez.
Dado que los comandos son imperativos, normalmente se nombran con un verbo en el modo infinitivo (por ejemplo, "crear" o "actualizar") y pueden incluir el tipo agregado, como CreateOrderCommand. A diferencia de un evento, un comando no es un hecho del pasado, es sólo una solicitud y por lo tanto puede ser rechazada.
Los comandos se pueden originar desde la interfaz de usuario, como resultado de un usuario que inicia una petición o desde un administrador de procesos, cuando éste le está pidiendo a un agregado que realice una acción.
Una característica importante de un comando es que se debe procesar sólo una vez por un solo receptor. Esto se debe a que un comando es una acción o transacción única que se desea realizar en la aplicación. Por ejemplo, el mismo comando de creación de orden no se debe procesar más de una vez. Esta es una diferencia importante entre comandos y eventos. Los eventos pueden procesarse varias veces, porque muchos sistemas o microservicios pueden estar interesados en el evento.
Además, en caso de que el comando no sea idempotente, es importante que sólo se procese una vez. Un comando es idempotente si se puede ejecutar varias veces sin cambiar el resultado, ya sea por la naturaleza del comando o por la forma en que el sistema lo maneja.
Es una buena práctica hacer que sus comandos y actualizaciones sean idempotentes, cuando tenga sentido bajo las reglas en invariantes del negocio en su dominio. Por ejemplo, y para reutilizar el mismo anterior, si por alguna razón (reintento de lógica, intentos de piratería, etc.) el mismo comando CreateOrderCommand llega a su sistema varias veces, debe poder identificarlo y asegurarse de no crear varias órdenes. Para hacerlo, debe adjuntar algún tipo de identidad en las operaciones e identificar si el comando o la actualización ya se procesaron.
Un comando se envía a un solo receptor, los comandos no se publican. La publicación es para eventos que establecen un hecho: que algo ha sucedido y podría ser interesante para los manejadores de eventos. En el caso de los eventos, a quien los publica no le interesa quiénes son los receptores ni lo que hacen con ellos. Pero los eventos del dominio o de integración son una historia diferente ya presentada en secciones anteriores.
Un comando se implementa con una clase que contiene campos de datos o colecciones, con toda la información necesaria para ejecutarlo. Un comando es un tipo especial de Data Transfer Object (DTO), que se usa específicamente para solicitar cambios o transacciones. El comando en sí se basa exactamente en la información necesaria para procesarlo y nada más.
El siguiente ejemplo muestra la clase CreateOrderCommand simplificada. Este es un comando inmutable que se utiliza en el microservicio de pedido en eShopOnContainers.
// DDD and CQRS patterns comment
// Note that it is recommended that you implement immutable commands
// In this case, immutability is achieved by having all the setters as private
// plus being able to update the data just once, when creating the object
// through the constructor.
// References on immutable commands:
// http://cqrs.nu/Faq
// https://docs.spine3.org/motivation/immutability.html
// http://blog.gauffin.org/2012/06/griffin-container-introducing-command-support/
// https://msdn.microsoft.com/library/bb383979.aspx
[DataContract]
public class CreateOrderCommand : IRequest<bool>
{
[DataMember]
private readonly List<OrderItemDTO> _orderItems;
[DataMember]
public string City { get; private set; }
[DataMember]
public string Street { get; private set; }
[DataMember]
public string State { get; private set; }
[DataMember]
public string Country { get; private set; }
[DataMember]
public string ZipCode { get; private set; }
[DataMember]
public string CardNumber { get; private set; }
[DataMember]
public string CardHolderName { get; private set; }
[DataMember]
public DateTime CardExpiration { get; private set; }
[DataMember]
public string CardSecurityNumber { get; private set; }
[DataMember]
public int CardTypeId { get; private set; }
[DataMember]
public IEnumerable<OrderItemDTO> OrderItems => _orderItems;
public CreateOrderCommand()
{
_orderItems = new List<OrderItemDTO>();
}
public CreateOrderCommand(List<BasketItem> basketItems, string city,
string street,
string state, string country, string zipcode,
string cardNumber, string cardHolderName, DateTime cardExpiration,
string cardSecurityNumber, int cardTypeId) : this()
{
_orderItems = MapToOrderItems(basketItems);
City = city;
Street = street;
State = state;
Country = country;
ZipCode = zipcode;
CardNumber = cardNumber;
CardHolderName = cardHolderName;
CardSecurityNumber = cardSecurityNumber;
CardTypeId = cardTypeId;
CardExpiration = cardExpiration;
}
public class OrderItemDTO
{
public int ProductId { get; set; }
public string ProductName { get; set; }
public decimal UnitPrice { get; set; }
public decimal Discount { get; set; }
public int Units { get; set; }
public string PictureUrl { get; set; }
}
}
Básicamente, la clase de comando contiene todos los datos necesarios para realizar una transacción del negocio, usando los objetos del modelo de dominio. Por lo tanto, los comandos son simplemente estructuras de datos que contienen datos de sólo lectura y ningún comportamiento. El nombre del comando indica su propósito. En muchos lenguajes como C#, los comandos se representan como clases, pero no son clases verdaderas en el sentido real de orientado a objetos.
Como característica adicional, los comandos son inmutables, porque el uso esperado es que sean procesados directamente por el modelo de dominio. No necesitan cambiar durante su vida proyectada. En una clase C#, la inmutabilidad se puede lograr al no tener ningún setter u otros métodos que cambien el estado interno.
Por ejemplo, la clase de comando para crear un pedido probablemente sea similar en términos de datos de pedido que desea crear, pero probablemente no necesite los mismos atributos. Por ejemplo, CreateOrderCommand no tiene una ID de pedido, porque la orden aún no se ha creado.
Muchas clases de comando pueden ser simples, requiriendo sólo unos pocos campos sobre algún estado que necesita ser cambiado. Ese sería el caso si sólo está cambiando el estado de un pedido de "en proceso" a "pagado" o "enviado" utilizando un comando similar al siguiente:
[DataContract] public class UpdateOrderStatusCommand :IAsyncRequest<bool> { [DataMember] public string Status { get; private set; } [DataMember] public string OrderId { get; private set; } [DataMember] public string BuyerIdentityGuid { get; private set; } } |
Algunos desarrolladores hacen que sus objetos de interfaz de usuario por petición sean distintos de sus DTO de comando, pero eso es sólo una cuestión de preferencia. Es una separación tediosa, con poco valor agregado y los objetos son casi exactamente iguales. Por ejemplo, en eShopOnContainers, algunos comandos vienen directamente del lado del cliente.
Debería implementar una clase específica de manejador de comandos para cada comando. Así es como funciona el patrón y es donde usará el objeto del comando, los objetos de dominio y los objetos del repositorio de infraestructura. El manejador de comandos es, de hecho, el corazón de la capa de aplicación en términos de CQRS y DDD. Sin embargo, toda la lógica de dominio debe estar contenida dentro de las clases de dominio, dentro de las raíces de agregación (entidades raíz), entidades secundarias o servicios de dominio, pero no dentro del manejador de comandos, que es una clase de la capa de aplicación.
Un manejador de comando recibe un comando y obtiene un resultado del agregado que se utiliza. El resultado debería ser una ejecución exitosa del comando o una excepción. En el caso de una excepción, el estado del sistema no debe modificarse.
El manejador de comandos generalmente sigue los siguientes pasos:
Normalmente, un manejador de comandos trata con un agregado único, controlado por su raíz de agregación (entidad raíz). Si múltiples agregados se vean afectados por la recepción de un solo comando, podría usar eventos de dominio para propagar estados o acciones a través de múltiples agregados.
El punto importante aquí es que cuando se procesa un comando, toda la lógica de dominio debe estar dentro del modelo de dominio (los agregados), completamente encapsulada y lista para las pruebas unitarias. El manejador de comandos solo actúa como un coordinador, para obtener el modelo de dominio de la base de datos y, como último paso, indicar a la capa de infraestructura (repositorios) que persista en los cambios cuando se modifique el modelo. La ventaja de este enfoque es que puede refactorizar la lógica de dominio en un modelo de dominio de comportamiento aislado, completamente encapsulado, rico y expresivo, sin tener que cambiar el código en las capas de aplicación o infraestructura, que son el nivel de fontanería (manejadores de comandos, API web, repositorios, etc.).
Cuando los manejadores de comandos se vuelven complejos, con demasiada lógica, eso puede ser un code smell. Revíselos y, si encuentra lógica de dominio, refactorice el código para mover ese comportamiento a los métodos de los objetos del dominio (la entidad raíz y las secundarias).
Como ejemplo de una clase de manejador de comandos, el siguiente código muestra la misma clase CreateOrderCommandHandler que vimos al principio de este capítulo. En este caso, queremos resaltar el método Handle y las operaciones con los objetos/agregados del modelo de dominio.
public class CreateOrderCommandHandler
: IAsyncRequestHandler<CreateOrderCommand, bool>
{
private readonly IOrderRepository _orderRepository;
private readonly IIdentityService _identityService;
private readonly IMediator _mediator;
// Using DI to inject infrastructure persistence Repositories
public CreateOrderCommandHandler(IMediator mediator,
IOrderRepository orderRepository,
IIdentityService identityService)
{
_orderRepository = orderRepository ??
throw new ArgumentNullException(nameof(orderRepository));
_identityService = identityService ??
throw new ArgumentNullException(nameof(identityService));
_mediator = mediator ??
throw new ArgumentNullException(nameof(mediator));
}
public async Task<bool> Handle(CreateOrderCommand message)
{
// Create the Order AggregateRoot
// Add child entities and value objects through the Order aggregate root
// methods and constructor so validations, invariants, and business logic
// make sure that consistency is preserved across the whole aggregate
var address = new Address(message.Street, message.City, message.State,
message.Country, message.ZipCode);
var order = new Order(message.UserId, address, message.CardTypeId,
message.CardNumber, message.CardSecurityNumber,
message.CardHolderName, message.CardExpiration);
foreach (var item in message.OrderItems)
{
order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice,
item.Discount, item.PictureUrl, item.Units);
}
_orderRepository.Add(order);
return await _orderRepository.UnitOfWork
.SaveEntitiesAsync();
}
}
Estos son pasos adicionales que ocurren en un manejador de comandos:
La siguiente pregunta es cómo invocar un manejador de comandos. Puede llamarlo manualmente desde cada controlador ASP.NET Core relacionado. Sin embargo, ese enfoque estaría demasiado acoplado y no es lo ideal.
Las otras dos opciones principales, que son las recomendadas, son:
Como se muestra en la figura 7-25, en un enfoque CQRS se usa un mediador inteligente, similar a un bus en memoria, que es lo suficientemente listo como para redirigir al manejador de comandos correcto, según el tipo de comando o DTO que se recibe. Las flechas unidireccionales representan las dependencias entre los objetos (en muchos casos, inyectados a través de DI) con sus interacciones relacionadas.
Figura 7-25. Usando el patrón Mediador en un microservicio CQRS
La razón por la que tiene sentido usar el patrón Mediador es que, en las aplicaciones empresariales, las solicitudes de procesamiento pueden complicarse. Desea poder manejar una cantidad indeterminada de intereses transversales como registro, validaciones, auditoría y seguridad. En estos casos, puede confiar en una línea de proceso de mediadores (consulte el patrón Mediador) para proporcionar una forma de manejar esos comportamientos adicionales o intereses transversales.
Un mediador es un objeto que encapsula el "cómo" de este proceso: coordina la ejecución en función del estado, la forma como se invoca el manejador de comandos o la data que proporciona al manejador. Con un componente mediador puede manejar intereses transversales de forma centralizada y transparente mediante la aplicación de decoradores (o comportamientos del pipeline, a partir de MediatR v3). (Para más información, vea el patrón Decorador).
Los decoradores y los comportamientos son similares a la Programación Orientada a Aspectos (AOP), sólo se aplica a una línea de proceso específica, administrada por el componente mediador. Los aspectos de AOP, que manejan intereses transversales, se aplican en función de los aspect weavers (“tejedores de aspectos”), inyectados en el tiempo de compilación o en base a la intercepción de llamadas a objetos. A veces se dice que los enfoques típicos de AOP funcionan "como magia", porque no es fácil ver cómo AOP hace su trabajo. Cuando se trata de problemas graves o errores, AOP puede ser difícil de depurar. Por otro lado, estos decoradores/comportamientos son explícitos y se aplican sólo en el contexto del mediador, por lo que la depuración es mucho más predecible y fácil.
Por ejemplo, en el servicio de microservicio de eShopOnContainers, implementamos dos comportamientos de ejemplo, una clase LogBehavior y una clase ValidatorBehavior. La implementación de los comportamientos se explica en la siguiente sección mostrando cómo eShopOnContainers usa los comportamientos de MediatR 3.
Otra opción es usar mensajes asíncronos basados en intermediarios o colas de mensajes, como se muestra en la figura 7-26. Esa opción también se puede combinar con el componente mediador, justo antes del controlador de comandos.
Figura 7-26. Usando colas de mensajes (procesos independientes) con comandos CQRS
El uso de colas de mensajes para aceptar los comandos puede complicar aún más la línea de procesamiento de los comandos, ya que probablemente deba dividirla en dos procesos conectados a través de una cola de mensajes externos. Aun así, debe usarlas si necesita tener una mayor escalabilidad y rendimiento, basados en la mensajería asíncrona. Tenga en cuenta que en el caso de Figura 7-26, el controlador sólo envía el mensaje de comando a la cola y se regresa. Luego, los manejadores de comandos procesan los mensajes a su propio ritmo. Esa es una gran ventaja de las colas: la cola de mensajes puede actuar como un búfer en los casos en los que se necesita una “hiperescalabilidad”, como en el caso de las transacciones en los mercados de valores o cualquier otro escenario con un gran volumen de datos de entrada.
Sin embargo, debido a la naturaleza asíncrona de las colas de mensajes, debe resolver cómo comunicarse con la aplicación cliente sobre el éxito o el fracaso del proceso del comando. Como regla general, nunca debe usar comandos de "disparar y olvidar". Cada aplicación del negocio necesita saber si un comando fue procesado con éxito o, al menos, validado y aceptado.
Por lo tanto, ser capaz de responder al cliente después de validar un mensaje de comando que se envió a una cola asíncrona, esto agrega complejidad al sistema, en comparación con un proceso del comando en memoria, que devuelve el resultado después de ejecutar la transacción. Con las colas, es posible que deba devolver el resultado del proceso a través de otros mensajes de resultados de otras operaciones, lo que requerirá componentes adicionales y comunicación personalizada en su sistema.
Además, los comandos asíncronos son unidireccionales, que en muchos casos podrían no ser necesarios, como se explica en el siguiente intercambio interesante entre Burtsev Alexey y Greg Young en una conversación en línea:
[Burtsev Alexey] He encontrado mucho código donde la gente usa el manejo de comandos asíncronos o mensajes en una dirección, sin ninguna razón para hacerlo (no están haciendo una operación larga, no están ejecutando código asíncrono externo, ni siquiera cruzan el límite de la aplicación para estar usando el bus de mensajes). ¿Por qué introducen esta complejidad innecesaria? Y, de hecho, hasta ahora no he visto un ejemplo de código CQRS con manejadores de comandos bloqueadores, aunque debería funcionar bien en la mayoría de los casos.
[Greg Young] [...] no existe un comando asíncrono; en realidad es otro evento. Si debo aceptar lo que me envías y disparar un evento si no estoy de acuerdo, ya no me estás diciendo que haga algo [es decir, ya no es un comando]. Eres tú diciéndome que algo se ha hecho. Esto parece una ligera diferencia al principio, pero tiene muchas implicaciones.
Los comandos asíncronos aumentan en gran medida la complejidad de un sistema, porque no hay una forma simple de indicar fallas. Por lo tanto, no se recomiendan los comandos asíncronos excepto cuando hay requisitos de escalabilidad o en casos especiales, cuando se comunican los microservicios internos a través de mensajes. En esos casos, debe diseñar un sistema de informe y recuperación separado para las fallas.
En la versión inicial de eShopOnContainers, decidimos utilizar un procesamiento de comandos sincrónico, iniciado a partir de peticiones HTTP y manejado con el patrón Mediador. Eso le permite fácilmente devolver el éxito o el fracaso del proceso, como en la implementación de CreateOrderCommandHandler.
En cualquier caso, esta debe ser una decisión basada en los requisitos del negocio de su aplicación o microservicio.
Como una implementación de ejemplo, esta guía propone usar el pipeline en memoria (en el mismo proceso), basado en el patrón de Mediador para gestionar la recepción de comandos y enrutarlos a los manejadores de comandos adecuados. La guía también propone aplicar comportamientos para atender los intereses transversales.
Para la implementación en .NET Core, hay múltiples librerías open source disponibles que implementan el patrón Mediador. La librería utilizada en esta guía es MediatR, que es open source y fue creada por Jimmy Bogard, pero podría usar otro enfoque. MediatR es una librería pequeña y simple que le permite procesar en memoria mensajes como los comandos y permite aplicar decoradores o comportamientos.
El uso del patrón Mediador le ayuda a reducir el acoplamiento y a aislar los otros intereses del trabajo solicitado, mientras se conecta automáticamente al manejador que realiza ese trabajo, en este caso, a los manejadores de comandos.
Otra buena razón para utilizar el patrón Mediador fue explicada por Jimmy Bogard al revisar esta guía:
Creo que vale la pena mencionar las pruebas aquí: proporciona una ventana buena y consistente al comportamiento de su sistema. Petición de entrada, respuesta de salida. Hemos encontrado ese aspecto bastante valioso en la construcción de pruebas de comportamiento consistentes.
En primer lugar, echemos un vistazo a un código de controlador API Web de ejemplo, donde realmente usaría el objeto mediador. Si no estuviera usando el objeto mediador, necesitaría inyectar todas las dependencias para ese controlador, cosas como un objeto logger y otros. Por lo tanto, el constructor sería bastante complicado. Por otro lado, si usa el objeto mediador, el constructor puede ser mucho más simple, con sólo unas pocas dependencias en lugar de las muchas que tendría si tuviera una por operación transversal, como puede apreciar en el siguiente ejemplo:
public class MyMicroserviceController : Controller { public MyMicroserviceController(IMediator mediator, IMyMicroserviceQueries microserviceQueries) // ... |
Puede ver que el mediador proporciona un constructor de controlador API Web limpio y delgado. Además, dentro de los métodos del controlador, el código para enviar un comando al mediador es casi una línea:
[Route("new")] [HttpPost] public async Task<IActionResult> ExecuteBusinessOperation([FromBody]RunOpCommand runOperationCommand) { var commandResult = await _mediator.SendAsync(runOperationCommand);
return commandResult ? (IActionResult)Ok() : (IActionResult)BadRequest(); } |
En eShopOnContainers, un ejemplo más avanzado que el anterior, sería cuando se presente un objeto CreateOrderCommand desde el microservicio de pedido. Pero, dado que el proceso del negocio de pedidos es un poco más complejo y, en nuestro caso, realmente comienza en el servicio del carrito de compras, esta acción de enviar el objeto CreateOrderCommand se realiza desde un manejador de eventos de integración llamado UserCheckoutAcceptedIntegrationEvent.cs en lugar de un controlador API Web simple, llamado desde la aplicación cliente como en el ejemplo anterior más sencillo.
Sin embargo, la acción de enviar el comando a MediatR es bastante similar, como se muestra en el siguiente código.
var createOrderCommand = new CreateOrderCommand(eventMsg.Basket.Items,
eventMsg.UserId, eventMsg.City,
eventMsg.Street, eventMsg.State,
eventMsg.Country, eventMsg.ZipCode,
eventMsg.CardNumber,
eventMsg.CardHolderName,
eventMsg.CardExpiration,
eventMsg.CardSecurityNumber,
eventMsg.CardTypeId);
var requestCreateOrder = new IdentifiedCommand<CreateOrderCommand,bool>(
createOrderCommand,
eventMsg.RequestId);
result = await _mediator.Send(requestCreateOrder);
Sin embargo, este caso también es un poco más avanzado porque también estamos implementando comandos idempotentes. El proceso CreateOrderCommand debe ser idempotente, por lo que, en caso de que se repita el mismo mensaje a través de la red (por cualquier motivo, como reintentos), el pedido sólo se debe procesar una vez.
Esto se implementa al empaquetar el comando del negocio (en este caso, CreateOrderCommand) e insertarlo en un IdentifiedCommand genérico, que se rastrea mediante un ID de cada mensaje que llega a través de la red, que debe ser idempotente.
En el siguiente código, puede ver que IdentifiedCommand no es más que un DTO con ID más el objeto de comando empresarial.
public class IdentifiedCommand<T, R> : IRequest<R> where T : IRequest<R> { public T Command { get; } public Guid Id { get; }
public IdentifiedCommand(T command, Guid id) { Command = command; Id = id; } } |
Entonces, el CommandHandler para IdentifiedCommand, llamado IdentifiedCommandHandler.cs básicamente verificará si la identificación que viene como parte del mensaje ya existe en una tabla. Si ya existe, ese comando no se procesará nuevamente, por lo que se comporta como un comando idempotente. Ese código de infraestructura se realiza mediante la llamada al método _requestManager.ExistAsync() a continuación.
// IdentifiedCommandHandler.cs
public class IdentifiedCommandHandler<T, R> :
IAsyncRequestHandler<IdentifiedCommand<T, R>, R>
where T : IRequest<R>
{
private readonly IMediator _mediator;
private readonly IRequestManager _requestManager;
public IdentifiedCommandHandler(IMediator mediator,
IRequestManager requestManager)
{
_mediator = mediator;
_requestManager = requestManager;
}
protected virtual R CreateResultForDuplicateRequest()
{
return default(R);
}
public async Task<R> Handle(IdentifiedCommand<T, R> message)
{
var alreadyExists = await _requestManager.ExistAsync(message.Id);
if (alreadyExists)
{
return CreateResultForDuplicateRequest();
}
else
{
await _requestManager.CreateRequestForCommandAsync<T>(message.Id);
// Send the embeded business command to mediator
// so it runs its related CommandHandler
var result = await _mediator.Send(message.Command);
return result;
}
}
}
Dado que IdentifiedCommand actúa como el envoltorio (wrapper) de un comando del negocio, cuando se necesita procesar el comando y el Id no está repetido, toma ese comando interno y lo reenvía a MediatR, como en la última parte del código que se muestra arriba al ejecutar _mediator.Send (message.Command), desde IdentifiedCommandHandler.cs.
Al hacer eso, se vinculará y ejecutará el manejador de comandos del negocio, en este caso, CreateOrderCommandHandler que está ejecutando transacciones contra la base de datos de pedidos, como se muestra en el siguiente código.
// CreateOrderCommandHandler.cs
public class CreateOrderCommandHandler
: IAsyncRequestHandler<CreateOrderCommand, bool>
{
private readonly IOrderRepository _orderRepository;
private readonly IIdentityService _identityService;
private readonly IMediator _mediator;
// Using DI to inject infrastructure persistence Repositories
public CreateOrderCommandHandler(IMediator mediator,
IOrderRepository orderRepository,
IIdentityService identityService)
{
_orderRepository = orderRepository ??
throw new ArgumentNullException(nameof(orderRepository));
_identityService = identityService ??
throw new ArgumentNullException(nameof(identityService));
_mediator = mediator ??
throw new ArgumentNullException(nameof(mediator));
}
public async Task<bool> Handle(CreateOrderCommand message)
{
// Add/Update the Buyer AggregateRoot
var address = new Address(message.Street, message.City, message.State,
message.Country, message.ZipCode);
var order = new Order(message.UserId, address, message.CardTypeId,
message.CardNumber, message.CardSecurityNumber,
message.CardHolderName, message.CardExpiration);
foreach (var item in message.OrderItems)
{
order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice,
item.Discount, item.PictureUrl, item.Units);
}
_orderRepository.Add(order);
return await _orderRepository.UnitOfWork
.SaveEntitiesAsync();
}
}
Para que MediatR tenga conocimiento de las clases manejadoras de comando, necesita registrar las clases del mediador y las de los manejadores en su contenedor IoC. Por defecto, MediatR utiliza Autofac como el contenedor IoC, pero también puede usar el contenedor ASP.NET Core IoC incorporado o cualquier otro contenedor compatible con MediatR.
El siguiente código muestra cómo registrar los tipos y comandos de MediatR cuando se utilizan módulos Autofac.
public class MediatorModule : Autofac.Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterAssemblyTypes(typeof(IMediator).GetTypeInfo().Assembly)
.AsImplementedInterfaces();
// Register all the Command classes (they implement IAsyncRequestHandler)
// in assembly holding the Commands
builder.RegisterAssemblyTypes(
typeof(CreateOrderCommand).GetTypeInfo().Assembly).
AsClosedTypesOf(typeof(IAsyncRequestHandler<,>));
// Other types registration
//...
}
}
Aquí es donde "sucede la magia" con MediatR.
Como cada controlador de comandos implementa la interfaz genérica IAsyncRequestHandler<T>, al registrar los ensamblados, el código registra con RegisteredAssemblyTypes todos los tipos asignados como IAsyncRequestHandler mientras relaciona los CommandHandlers con sus Commands, gracias a la relación establecida en la clase CommandHandler, como en el siguiente ejemplo:
public class CreateOrderCommandHandler : IAsyncRequestHandler<CreateOrderCommand, bool> { |
Ese es el código que correlaciona los comandos con los manejadores de comando. El manejador es sólo una clase simple, pero hereda de RequestHandler<T>, donde <T> es el tipo del comando y MediatR se asegura de que se invoque con la carga útil correcta (el comando).
Hay una cosa más: ser capaz de aplicar los intereses transversales al pipeline de MediatR. También puede ver al final del código del módulo de registro Autofac, cómo se está registrando un tipo de comportamiento, específicamente, una clase LoggingBehavior y una clase ValidatorBehavior. Pero también se podrían agregar otros comportamientos personalizados.
public class MediatorModule : Autofac.Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterAssemblyTypes(typeof(IMediator).GetTypeInfo().Assembly)
.AsImplementedInterfaces();
// Register all the Command classes (they implement IAsyncRequestHandler)
// in assembly holding the Commands
builder.RegisterAssemblyTypes(
typeof(CreateOrderCommand).GetTypeInfo().Assembly).
AsClosedTypesOf(typeof(IAsyncRequestHandler<,>));
// Other types registration
//...
builder.RegisterGeneric(typeof(LoggingBehavior<,>)).
As(typeof(IPipelineBehavior<,>));
builder.RegisterGeneric(typeof(ValidatorBehavior<,>)).
As(typeof(IPipelineBehavior<,>));
}
}
Esa clase LoggingBehavior se puede implementar como el siguiente código, que registra información sobre el controlador de comandos que se está ejecutando y si fue exitoso o no.
public class LoggingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger) =>
_logger = logger;
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next)
{
_logger.LogInformation($"Handling {typeof(TRequest).Name}");
var response = await next();
_logger.LogInformation($"Handled {typeof(TResponse).Name}");
return response;
}
}
Simplemente implementando esta clase de decorador y decorando el pipeline con ella, todos los comandos procesados a través de MediatR registrarán información sobre la ejecución.
El microservicio de pedidos de eShopOnContainers también aplica un segundo comportamiento para las validaciones básicas, la clase ValidatorBehavior que se basa en la librería FluentValidation, como se muestra en el siguiente código:
public class ValidatorBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
private readonly IValidator<TRequest>[] _validators;
public ValidatorBehavior(IValidator<TRequest>[] validators) =>
_validators = validators;
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next)
{
var failures = _validators
.Select(v => v.Validate(request))
.SelectMany(result => result.Errors)
.Where(error => error != null)
.ToList();
if (failures.Any())
{
throw new OrderingDomainException(
$"Command Validation Errors for type {typeof(TRequest).Name}",
new ValidationException("Validation exception", failures));
}
var response = await next();
return response;
}
}
Luego, en base a la librería FluentValidation, creamos la validación para los datos pasados con CreateOrderCommand, como en el siguiente código:
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(command => command.City).NotEmpty();
RuleFor(command => command.Street).NotEmpty();
RuleFor(command => command.State).NotEmpty();
RuleFor(command => command.Country).NotEmpty();
RuleFor(command => command.ZipCode).NotEmpty();
RuleFor(command => command.CardNumber).NotEmpty().Length(12, 19);
RuleFor(command => command.CardHolderName).NotEmpty();
RuleFor(command => command.CardExpiration).NotEmpty().Must(BeValidExpirationDate).WithMessage("Please specify a valid card expiration date");
RuleFor(command => command.CardSecurityNumber).NotEmpty().Length(3);
RuleFor(command => command.CardTypeId).NotEmpty();
RuleFor(command => command.OrderItems).Must(ContainOrderItems).WithMessage("No order items found");
}
private bool BeValidExpirationDate(DateTime dateTime)
{
return dateTime >= DateTime.UtcNow;
}
private bool ContainOrderItems(IEnumerable<OrderItemDTO> orderItems)
{
return orderItems.Any();
}
}
Podría crear validaciones adicionales. Esta es una forma muy limpia y elegante de implementar sus validaciones de comandos.
De forma similar, podría implementar otros comportamientos para aspectos adicionales o intereses transversales que quiera aplicar a los comandos cuando los procese.