Command and Query Responsibility Segregation (CQRS) Pattern

CQRS模式,就是命令和查询责任分离模式。

CQRS模式通过使用不同的接口来分离读取数据和更新数据的操作。CQRS模式可以最大化性能,扩展性以及安全性,还会为系统的持续演化提供更多的弹性,防止Update命令在域模型Level发生冲突。

问题

在传统的数据管理系统中,更新数据以及请求数据的命令是通过数据仓库中的同一类型的实体来执行操作的。这些实体属于关系数据中的表的一列或者几列。

通常,在这些系统中,所有的创建,读取,更新以及删除(CRUD)操作都是应用到相同的实体的。举例来说,一个数据传输对象(DTO)表示一个客户通过数据访问层(DAL)从数据仓库中获取。一个用户更新了客户DTO中的某些字段(可能通过数据绑定),然后DTO再通过DAL重新写回数据库。这个时候,读写操作使用的是同样的DTO。如图1:

图1.经典的CRUD架构

传统的CRUD设计在业务逻辑的比较简单的系统下十分好用。一些开发工具提供的脚手架等机制可以很容易的按照需求完成数据访问操作。 然而,传统的CRUD模式有一些缺点:

希望更深刻的了解使用CRUD架构的限制,可以参考CRUD, Only When You Can Afford It

解决方案

命令和查询责任分离模式(CQRS)通过使用不同的接口来分离那些读取数据(Queries)和更新数据(Commands)的操作。这也意味着,读取数据的模型和更新数据的模型是不一样的。这些模型是独立的,如下图,图2:

图2.基本的CQRS架构

对比基于CRUD系统的单一的数据模型(开发者所构建的概念模式),基于CQRS系统的分离的查询和更新的模型十分易于设计和实现。然而,其中一个劣势是在于,很多基于CRUD的设计,基本的模型都是可以通过一些脚手架机制自动生成的,而CQRS的模型需要手动来实现。

用来请求数据的读取模型和写入数据的更新模型可能访问的是同一个物理数据仓库,业务是通过使用SQL视图或者数据的投射来分离的。然而,更常见的一种方式是将数据分离到不同的物理数据仓库来最大化性能,扩展性以及安全性,如下图3.

图3.基于分离的数据仓库的CQRS架构

请求数据的数据仓库可以作为更新数据的数据仓库的一个只读的拷贝仓库,或者读写的仓库使用完全不同的结构也可以。使用多个只读数据仓库可以大幅度增强查询的性能以及UI的响应,尤其是分布式环境中,将只读的分发库放到距离应用实例较近的时候,可以很好的降低网络延迟,提高性能。一些数据库系统,比如SQL Server等,都会提供一些类似于failover的分发机制来最大化可用性。

读写数据仓库的分离还可以根据其不同的请求规模来合理配置相应的负载。通常来说,读负载比写负载要大很多很多。

当请求/读取模型包含了非标准信息的时候(参考Materialized View Pattern),当请求系统中的数据,或者从应用中不同的视图来获取数据的时候,性能是最大化的。关于CQRS更多的信息和实现细节,可以参考如下的一些文档:

实现CQRS的一些问题以及顾虑

当考虑实现CQRS模式的时候,需要考虑如下的一些问题:

何时使用该模式

该模式十分适合以下场景:

CQRS模式在以下的一些情况将不太适用:

Event-Sourcing和CQRS

CQRS模式通常是需要和Event-Sourcing模式联合使用的。基于CQRS系统会使用单独的读数据模型以及写数据模型,每个任务的相关操作通常都处于不同的数据仓库中。当配合Event-Sourcing使用的时候,写模型就是事件的集合,这也是命令式的源信息。基于CQRS系统的读模型来提供数据的具体化的视图,通常是高度非规范化的视图。这些视图都是根据接口和应用的显示需求定制的,可以最大化显示和查询的性能。

使用事件流来写入存储,而不是使用某个时刻的实际数据写入,可以防止单个更新模型冲突以及最大化性能和扩展性。这些事件可用于异步生成用于填充读取存储的数据的物化视图。

因为事件存储是权威的信息源,所以可以删除实体化视图,并重放所有过去事件,以在系统演化时或者当读取模型必须更改时创建当前状态的最新表示。具体化视图实际上是数据的的只读缓存。

当考虑CQRS和Event-Sourcing配合实用的时候,需要考虑如下一些点:

想了解更多可以参考Event-Sourcing模式和Materialized-View模式以及MSDN上面的CQRS Journey.尤其是开发者最好阅读章节Introducing Event Sourcing来了解这个模式并且这个模式是如何配合CQRS工作的。

使用举例

下面的代码展示了一个CQRS模式实现的部分片段,其中对读和写的使用了不同的模型来定义。该模型接口不规定底层数据存储的任何功能,并且它们可以独立地进化和微调,因为这些接口是分开的。

下面的代码展示了读模型的定义:

// Query interface
namespace ReadModel {
    public interface ProductsDao
    {
        ProductDisplay FindById(int productId);
        IEnumerable<ProductDisplay> FindByName(string name);
        IEnumerable<ProductInventory> FindOutOfStockProducts();
        IEnumerable<ProductDisplay> FindRelatedProducts(int productId);
    }

    public class ProductDisplay
    {
        public int ID { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public decimal UnitPrice { get; set; }
        public bool IsOutOfStock { get; set; }
        public double UserRating { get; set; }
    }

    public class ProductInventory
    {
        public int ID { get; set; }
        public string Name { get; set; }
        public int CurrentStock { get; set; }
    }
}

系统允许用户来给产品打分。应用代码通过使用如下代码中的RateProduct命令来实现:

public interface Icommand
{
    Guid Id { get; }
}

public class RateProduct : Icommand
{
    public RateProduct()
    {
        this.Id = Guid.NewGuid();
    }

    public Guid Id { get; set; }
    public int ProductId { get; set; }
    public int rating { get; set; }
    public int UserId {get; set; }
}

系统通过ProductsCommandHandler类来处理由应用所发送的命令。客户端通常通过诸如队列一类的消息系统将命令发送给Domain接口来完成命令的执行。命令处理器接收到命令后再调用Domain接口的方法。命令的粒度可以设计的细粒度一些,可以有效减少冲突请求的概率。下面的代码大概展示了ProductsCommandHandler类的一些方法。

public class ProductsCommandHandler : ICommandHandler<AddNewProduct>,
    ICommandHandler<RateProduct>, ICommandHandler<AddToInventory>, ICommandHandler<Con rmItemShipped>,
    ICommandHandler<UpdateStockFromInventoryRecount>
{
    private readonly IRepository<Product> repository;
    public ProductsCommandHandler (IRepository<Product> repository)
    {
        this.repository = repository;
    }

    void Handle (AddNewProduct command)
    {
        ...
    }

    void Handle (RateProduct command)
    {
        var product = repository.Find(command.ProductId);
        if (product != null)
        {
            product.RateProuct(command.UserId, command.rating);
            repository.Save(product);
        }
    }

    void Handle (AddToInventory command)
    {
        ...
    }

    void Handle (Con rmItemsShipped command)
    {
        ...
    }

    void Handle (UpdateStockFromInventoryRecount command)
    {
        ...
    }
}

下面的代码展示了写接口中的ProductsDomain接口:

public interface ProductsDomain
{
    void AddNewProduct(
        int id, string name, string description, decimal price);
    void RateProduct(int userId int rating);
    void AddToInventory(int productId, int quantity);
    void ConfirmItemsShipped(int productId, int quantity);
    void UpdateStockFromInventoryRecount(
        int productId, int updatedQuantity);
}

同时注意ProductsDomain接口包含的方法都是属于Domain上面有意义的。通常来说,在CRUD环境中,这些方法都会有一些泛型化的名字,比如Save或者Update之类的,并且有一个DTO作为唯一的参数。CQRS模式中,方法可以更好的自定义来匹配组织的要求以及业务或者库存的管理等等,如上面代码中的AddToInventory(int productId, int quantity)等。

相关的模式

如果考虑实现CQRS模式的话,也可以同时参考一下如下的一些资料: