简单的CQRS实现与原始SQL和DDD
系列文章目录
一、简单的CQRS实现与原始SQL和DDD
二、使用EF的领域模型的封装和持久化透明(PI)
三、REST API数据验证
四、领域模型验证
五、如何发布和处理领域事件
六、处理领域事件:缺失的部分
七、发件箱模式(The Outbox Pattern)
八、.NET Core中的旁路缓存模式(Cache-Aside Pattern)
引言
我经常遇到关于CQRS模式实现的问题。甚至比我经常看到的"是在上下文中用ORM还是纯SQL访问数据库哪个更快”的讨论遇到的更多。
在这篇文章中,我想向你展示如何使用. Net Core快速实现简单的REST API应用程序。
开宗明义,这是CQRS最简单的版本——通过写模型(Write Model)的更新立即更新了读模型(Read Model),因此我们在这里不保证最终的一致性( eventual consistency)。然而,许多应用程序不需要最终的一致性,因为推荐使用两个单独的模型对读写进行逻辑划分,这种做法在大多数解决方案中更加有效。
特别地,对于这篇文章,我准备了例子,一个完全可用的应用程序,请参阅Github上的完整源代码:https://github.com/kgrzybek/sample-dotnet-core-cqrs-api
我的目标
下面是我想通过创建这个解决方案来实现的目标:
- 写模型和读模型的清晰分离和隔离。
- 使用读模型检索数据应该尽可能快。
- 写模型应该用DDD方法实现。DDD实现的级别应该取决于领域复杂性的级别。
- 应用程序逻辑应该与图形用户界面(GUI)分离。
- 所选的库应该是成熟的、知名的和受支持的。
设计
组件之间的高级流程如下:
正如你所看到的,读取的过程非常简单,因为我们应该尽可能快地查询数据。这里我们不需要更多的抽象层和复杂的方法。从查询对象(query object)获取参数,对数据库执行原始SQL并返回数据——仅此而已。
在写支持的情况下是不同的。写作通常需要更高级的技术,因为我们需要执行一些逻辑,做一些计算或简单地检查一些条件(特别是不变量[invariants])。通过ORM工具的变更跟踪(change tracking)和使用存储库模式(Repository Pattern),我们可以让我们的领域模型保持完整(好吧,几乎)。
解决方案
下图展示了用于完成读请求操作的组件之间的流程:
GUI负责创建Query对象:
/// <summary>
/// Get customer order details.
/// 获取订单详情
/// </summary>
/// <param name="orderId">Order ID.</param>
[Route("{customerId}/orders/{orderId}")]
[HttpGet]
[ProducesResponseType(typeof(OrderDetailsDto), (int)HttpStatusCode.OK)]
public async Task<IActionResult> GetCustomerOrderDetails([FromRoute]Guid orderId)
{
var orderDetails = await _mediator.Send(new GetCustomerOrderDetailsQuery(orderId));
return Ok(orderDetails);
}
internal class GetCustomerOrderDetailsQuery : IRequest<OrderDetailsDto>
{
public Guid OrderId { get; }
public GetCustomerOrderDetailsQuery(Guid orderId)
{
this.OrderId = orderId;
}
}
然后,查询处理程序处理查询:
internal class GetCustomerOrderDetialsQueryHandler : IRequestHandler<GetCustomerOrderDetailsQuery, OrderDetailsDto>
{
private readonly ISqlConnectionFactory _sqlConnectionFactory;
public GetCustomerOrderDetialsQueryHandler(ISqlConnectionFactory sqlConnectionFactory)
{
this._sqlConnectionFactory = sqlConnectionFactory;
}
public async Task<OrderDetailsDto> Handle(GetCustomerOrderDetailsQuery request, CancellationToken cancellationToken)
{
using (var connection = this._sqlConnectionFactory.GetOpenConnection())
{
const string sql = "SELECT " +
"[Order].[Id], " +
"[Order].[IsRemoved], " +
"[Order].[Value] " +
"FROM orders.v_Orders AS [Order] " +
"WHERE [Order].Id = @OrderId";
var order = await connection.QuerySingleOrDefaultAsync<OrderDetailsDto>(sql, new {request.OrderId});
const string sqlProducts = "SELECT " +
"[Order].[ProductId] AS [Id], " +
"[Order].[Quantity], " +
"[Order].[Name] " +
"FROM orders.v_OrderProducts AS [Order] " +
"WHERE [Order].OrderId = @OrderId";
var products = await connection.QueryAsync<ProductDto>(sqlProducts, new { request.OrderId });
order.Products = products.AsList();
return order;
}
}
}
public class SqlConnectionFactory : ISqlConnectionFactory, IDisposable
{
private readonly string _connectionString;
private IDbConnection _connection;
public SqlConnectionFactory(string connectionString)
{
this._connectionString = connectionString;
}
public IDbConnection GetOpenConnection()
{
if (this._connection == null || this._connection.State != ConnectionState.Open)
{
this._connection = new SqlConnection(_connectionString);
this._connection.Open();
}
return this._connection;
}
public void Dispose()
{
if (this._connection != null && this._connection.State == ConnectionState.Open)
{
this._connection.Dispose();
}
}
}
第一件事是获得打开的数据库连接,它是使用SqlConnectionFactory类实现的。这个类由IoC容器解析,具有HTTP请求生命周期范围,因此我们可以确定在请求处理期间只使用一个数据库连接。
第二件事是针对数据库准备和执行原始SQL。我尽量不直接引用表,而是引用数据库视图。这是一种创建抽象并将应用程序与数据库模式解耦的好方法,因为我希望尽可能地隐藏数据库内部。
对于SQL执行,我使用微型ORM Dapper库,因为它几乎和本地ADO.NET一样快,并且没有样板API(boilerplate API)。
写模型
下图展示了写请求操作的流程:
写请求处理开始类似于读,但我们创建的是命令对象而不是查询对象:
/// <summary>
/// Add customer order.
/// </summary>
/// <param name="customerId">Customer ID.</param>
/// <param name="request">Products list.</param>
[Route("{customerId}/orders")]
[HttpPost]
[ProducesResponseType((int)HttpStatusCode.Created)]
public async Task<IActionResult> AddCustomerOrder(
[FromRoute]Guid customerId,
[FromBody]CustomerOrderRequest request)
{
await _mediator.Send(new AddCustomerOrderCommand(customerId, request.Products));
return Created(string.Empty, null);
}
然后,CommandHandler被调用:
public class AddCustomerOrderCommandHandler : IRequestHandler<AddCustomerOrderCommand>
{
private readonly ICustomerRepository _customerRepository;
private readonly IProductRepository _productRepository;
public AddCustomerOrderCommandHandler(
ICustomerRepository customerRepository,
IProductRepository productRepository)
{
this._customerRepository = customerRepository;
this._productRepository = productRepository;
}
public async Task<Unit> Handle(AddCustomerOrderCommand request, CancellationToken cancellationToken)
{
var customer = await this._customerRepository.GetByIdAsync(request.CustomerId);
var selectedProducts = request.Products.Select(x => new OrderProduct(x.Id, x.Quantity)).ToList();
var allProducts = await this._productRepository.GetAllAsync();
var order = new Order(selectedProducts, allProducts);
customer.AddOrder(order);
await this._customerRepository.UnitOfWork.CommitAsync(cancellationToken);
return Unit.Value;
}
}
命令处理程序与查询处理程序看起来不一样。在这里,我们使用DDD方法对聚合(Aggregates)和实体(Entities)使用更高级别的抽象。我们需要它们,因为在这种情况下,要解决的问题往往比通常的读模式更复杂。命令处理程序将聚合水化(hydrates aggregate),调用聚合方法并将更改保存到数据库。
Customer 聚合可定义如下:
public class Customer : Entity
{
public Guid Id { get; private set; }
private readonly List<Order> _orders;
private Customer()
{
this._orders = new List<Order>();
}
public void AddOrder(Order order)
{
this._orders.Add(order);
this.AddDomainEvent(new OrderAddedEvent(order));
}
public void ChangeOrder(Guid orderId, List<OrderProduct> products, IReadOnlyCollection<Product> allProducts)
{
var order = this._orders.Single(x => x.Id == orderId);
order.Change(products, allProducts);
this.AddDomainEvent(new OrderChangedEvent(order));
}
public void RemoveOrder(Guid orderId)
{
var order = this._orders.Single(x => x.Id == orderId);
order.Remove();
this.AddDomainEvent(new OrderRemovedEvent(order));
}
}
public class Order : Entity
{
public Guid Id { get; private set; }
private bool _isRemoved;
private decimal _value;
private List<OrderProduct> _orderProducts;
private Order()
{
this._orderProducts = new List<OrderProduct>();
this._isRemoved = false;
}
public Order(List<OrderProduct> orderProducts, IReadOnlyCollection<Product> allProducts)
{
this.Id = Guid.NewGuid();
this._orderProducts = orderProducts;
this.CalculateOrderValue(allProducts);
}
internal void Change(List<OrderProduct> products, IReadOnlyCollection<Product> allProducts)
{
foreach (var product in products)
{
var orderProduct = this._orderProducts.SingleOrDefault(x => x.ProductId == product.ProductId);
if (orderProduct != null)
{
orderProduct.ChangeQuantity(product.Quantity);
}
else
{
this._orderProducts.Add(product);
}
}
var existingProducts = this._orderProducts.ToList();
foreach (var existingProduct in existingProducts)
{
var product = products.SingleOrDefault(x => x.ProductId == existingProduct.ProductId);
if (product == null)
{
this._orderProducts.Remove(existingProduct);
}
}
this.CalculateOrderValue(allProducts);
}
internal void Remove()
{
this._isRemoved = true;
}
private void CalculateOrderValue(IReadOnlyCollection<Product> allProducts)
{
this._value = this._orderProducts.Sum(x => x.Quantity * allProducts.Single(y => y.Id == x.ProductId).Price);
}
}
架构
基于知名的洋葱架构(Onion Architecture)解决方案结构如下:
只定义了3个项目:
- API项目,API端点和应用程序逻辑(命令和查询处理程序)使用功能文件夹(Feature Folders)方法。
- 使用领域模型(Domain Model)的领域项目
- 基础设施项目-集成了数据库。
总结
在这篇文章中,我试图提出最简单的方法来实现CQRS模式,使用原始sql脚本作为读模型端处理,DDD方法作为写模型端实现。这样做,我们能够在不损失开发速度的情况下实现更多的分离关注点(separation of concerns)。引入这种解决方案的成本非常低,而且它的回报非常快。
我没有详细描述DDD实现,所以我鼓励您再次检查示例应用程序的存储库(repository)——可以用作您的应用程序的工具包启动器,就像我的应用程序一样。