领先技术

适合常用应用程序的 CQRS

Dino Esposito

Dino Esposito领域驱动设计 (DDD) 于十年前为公众所知,它的出现使软件开发人员和架构师备受鼓舞。忽略其具体优缺点,DDD 体现了早期面向对象模式时代几乎与每个人都相关的旧梦:那就是围绕综合性对象模型构建应用程序,以满足利益干系人的要求,解除其后顾之忧。

过去 10 年中,许多开发商遵守 DDD 指南启动不少项目。一些项目成功了,一些没有。实际上,研发出一款包罗万象的对象模型以涵盖软件系统所有功能或非功能方面,这其实是美妙的乌托邦。尤其是在疯狂的高级用户体验时代,业务模型变化频繁,需求摇摆不定,一个稳定可靠的对象模型实际上就是一种幻想。

最近,另外一种包含其他缩写词(命令查询职责分离 (CQRS))的方法开始受到关注。CQRS 并不是软件专家最新的酷派玩具。它甚至并不像大多数可用示例所暗示的那样复杂。简单地说,CQRS 是一款具体的实施模型,无论其预期生命周期和复杂性如何,几乎都是最适用于所有类型的软件应用程序。

并不是只有一种方式操作 CQRS,至少有三种不同的方式。您甚至可以按照酒店房间和饮料的常见营销规则为它们的命名昵称:标准、高档和豪华。快速搜索 CQRS 示例和文章,您会发现它们中的大多数都属于豪华类。实际上,这对于大多数常用应用程序而言太过复杂,太难以企及了。

不管项目看上去有多么复杂,CQRS 对于软件开发而言都是一种很有价值的方法。总而言之,面向常用应用程序的 CQRS 实质上是经典多层式体系结构的再实现,为更多重大改变和改进开启了希望之门。

命令和查询

二十世纪八十年代在开发 Eiffel 编程语言时,Bertrand Meyer 总结出,软件具备可更改系统状态的命令和可读取系统状态的查询。任何软件语句都应该是命令或查询—不能是两者的组合。还有一种更好的方法来表达这种概念:提出一个问题不应该改变答案。CQRS 是对同一核心原则的较新的重述:命令和查询是不同的事情,应该分别实施。

如果两组操作被强制使用同一个编程堆栈和同一种模型,那么命令和查询间的逻辑分隔就无法显示得很好。这对于复杂的业务方案而言,尤其如此。单一模型(无论是对象模型、功能模型还是其他模型)很快就会变得无法管理。此模型成指数增长,变得大且复杂,占用时间和预算,但却从不按照它应有的方式运行。

CQRS 所追求的分离通过将查询操作分到一层中而将命令分到另一层中来实现。每一层都有其各自的数据模型、各自的服务集,且使用其各自的模式和技术组合构建而成。更重要的是,两层甚至可能位于两个截然不同的层中,且分别进行优化,互不影响。图 1 为 CQRS 体系结构奠定了基础。

一个规范的多层 CQRS 体系结构
图 1 一个规范的多层 CQRS 体系结构

只需意识到命令和查询是两个截然不同的事物,就对软件体系结构产生了深远的影响。例如,它会突然变得更易于设想,更容易为各个域层编码。命令堆栈中的域层只关注数据和业务,以及执行任务所需的安全规则。而另一方面,查询堆栈中的域层可能会像通过 ADO.NET 连接的直接 SQL 查询那样简单。

当您将安全检查放在显示入口时,查询堆栈就会变成薄薄的包装,围绕在实体框架或您查询数据所用的任何项四周。在每个域层内部,您还可以自由塑造数据的形状(这与域的需要相似),无需复制或重复数据来适应形形色色的显示和业务需要。

当 DDD 首次出现时,它旨在处理软件发展中的核心复杂问题。尽管之后,开发人员面临了许多复杂性问题。许多人认为它只是业务域的一部分。与此不同,大多数复杂性问题来自查询和命令的 Cartesian 产品。将命令与查询分离可将复杂性降低一个数量级。用数学术语大致来说,您可以依据 N+N 与 NxN 来比较 CQRS 和综合性域模型方法。

如何开始操作 CQRS?

您可以将基本的创建、读取、更新、删除 (CRUD) 系统转变为受 CQRS 启发的系统。假设您拥有规范的 ASP.NET MVC Web 应用程序,此应用程序可收集用户数据,并以各种形式显示。这就是大多数应用程序的做法,任何架构师都知道如何快速并有效地构建应用程序。考虑到 CQRS 后,您可以重写应用程序。您可能会惊讶,所需的改变非常小,但您可获得的收益却很可能是无限的。

您的规范系统在层中进行了编排组织。您的应用程序服务可从藕合用例的控制器中直接调用。应用程序服务(通常称为工作人员服务)与控制器并行位于 Web 服务器中。关于图 1,应用程序服务构成了应用程序层。应用程序层是您可在其中针对系统的其余部分运行命令和查询的平台。应用 CQRS 意味着您将使用两个不同的中间层。其中一层负责管理可改变系统状态的命令。另一层负责检索数据。图 2 显示示例 ASP.NET MVC 项目上的体系结构图。

适用于 ASP.NET MVC 项目的 CQRS 体系结构
图 2 适用于 ASP.NET MVC 项目的 CQRS 体系结构

您可从主要的 Web 服务器项目上创建多种类库项目(查询堆栈和命令堆栈)和引用。

查询堆栈

查询堆栈类库只关注数据检索。它尽可能使用与表示层中所用数据相匹配的数据模型。您几乎不需要任何业务规则,因为这些规则适用于可改变状态的命令。

通过 DDD 让域模型模式变得流行起来实质上是组织域逻辑的方式。当您从前端生成查询时,您只能处理部分应用程序逻辑和用例。术语业务逻辑通常由应用程序特定逻辑和固定域逻辑组合后产生。您知道持久性格式和表示格式后,您要做的就是,像在传统的 ADO.NET/SQL 查询中那样映射数据。

回顾一下任何您可从代表系统业务域的应用程序层中调用的代码非常有用。因此,固定 API 可表达系统的核心逻辑。理想情况下,您应该确保不一致和不协调运行不会出现在公开的 API 中。因此,为了执行查询堆栈的只读特性,在默认的实体框架上下文对象四周添加包装类,以连接到数据库,如以下代码所示:

public class Database : IDisposable
{
  private readonly QueryDbContext _context = new QueryDbContext();
  public IQueryable<Customer> Customers
  {
    get { return _context.Customers; }
  }
  public void Dispose()
  {
   _context.Dispose();
  }
}

Matches 类已作为基本 DbContext 类的 DbSet<T> 集合实现。因此,它提供了访问基础数据库的完整权限,您可以使用它来设置查询并通过 LINQ to Entities 更新操作。

设置查询管道的基本步骤是只允许查询访问数据库。这是包装类的角色,其中 Matches 作为 IQueryable<T> 公开。应用程序层将会使用 Database 包装类实现查询,其旨在将数据添加至以下表示中:

var model = new RegisterViewModel();
using (var db = new Database())
{
  var list = (from m in db.Customers select m).ToList();
  model.ExistingCustomers = list;
}

现在数据源和表示之间存在直接连接。您现在只是出于显示目的读取数据并设置其格式。您希望通过登录和 UI 约束在入口处执行授权。但如果未能执行,您可以在过程中添加更多的层并通过 IQueryable 数据集合启用数据交换。数据模型与数据库相同且具有相同的持久性。有时,此模型可称为分层表达式树 (LET)。

此时您应该注意几件事情。首先,您现在处于读取管道中,其中通常不存在业务规则。您所拥有的就是授权规则和筛选器。这些在应用程序层级别众所周知。在过程中您无需处理数据传输对象问题。您有一个持久性模型和多个真实的数据容器适于此视图。在应用程序服务中,您将以以下模式结束:

var model = SpecificUseCaseViewModel();
model.SomeCollection = new Database()
     .SomeQueryableCollection
     .Where(m => SomeCondition1)
     .Where(m => SomeCondition2)
     .Where(m => SomeCondition3)
     .Select(m => new SpecificUseCaseDto
       {
         // Fill up
       })
     .ToList();
return model;

在代码段中发现的所有数据传输对象特定于您正在实施的显示用例。它们正是用户希望在您正构建的 Razor 视图看到的内容,且此类不可避免。此外,您可以使用临时 IQueryable 扩展方法代替所有的 Where 子句,并将所有代码转变为以特定于域的语言编写的对话框。

第二点要注意的是,查询堆栈与持久性相关。命令堆栈和查询堆栈以最简单的 CQRS 形式共享相同的数据库。此体系结构使 CQRS 与经典 CRUD 系统相似。这使得抗拒变化的人们更容易适应。不过,您可以设计后端,以便命令和查询堆栈针对其特定的目的优化自己的数据库。同步两个数据库就成为另外一个问题。

命令堆栈

在 CQRS 中,命令堆栈只关注执行修改应用程序状态的任务。应用程序层通过将命令推送到管道接收来自显示和协调执行中的请求。表达式“将命令推送到管道”是 CQRS 各种版本中的初始版本。

最简单的情形是,推送命令将只包括调用事务脚本。这将触发普通工作流,此工作流可完成任务要求的所有步骤。从应用程序层中推送命令非常简单,如以下代码所示:

public void Register(RegisterInputModel input)
{
  // Push a command through the stack
  using (var db = new CommandDbContext())
  {
    var c = new Customer {
      FirstName = input.FirstName,
      LastName = input.LastName };
    db.Customers.Add(c);
    db.SaveChanges();
  }
}

如果需要,您可以将控制权交到包含服务的真实域层以及您需要在其中执行完整业务逻辑的域模型中。但是,使用 CQRS 并不一定会将您绑定到 DDD 以及诸如聚合、工厂和值对象的事项中。您可以获得命令/查询分离的收益,而无需其他复杂的域模型。

超越常规 CQRS

CQRS 的强大体现在,您可以随意优化命令和查询管道,而不存在优化其中一个却损坏另一个的风险。CQRS 的最基本形式是,使用一个共享的数据库,调用不同的库以在应用程序层中读取和编写。

较复杂的形式可能包含多个数据库、多语言持久性、用于查询目的的数据反规范化、事件溯源,更重要的是,包含可将命令递交给后端的更为灵活的方式。它之所以更加灵活,是因为它使用总线发送命令并发布事件,可让您在需要时以与管理流程图相似的方式定义和修改任何任务。同时,您可以通过向总线组件添加功能和特征来以虚拟的方式对需要的地方进行伸缩调整。

许多开发人员称赞 CQRS,但倾向于限制协作和高级应用程序的适用性。CQRS 不是顶级体系结构,且不涉及具体技术。在一定程度上,CQRS 甚至不涉及设计模式,但是它本身就是模式。它简单而又功能强大,非常适合常用应用程序。


Dino Esposito 是《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2014 年)和《Programming ASP.NET MVC 5》(Microsoft Press,2014 年)的合著者。作为 JetBrains 的 Microsoft .NET Framework 和 Android 平台的技术推广人员,Esposito 经常在全球行业活动中发表演讲,并在 software2cents.wordpress.com 上以及 twitter.com/despos 上的推文中分享他对于软件的愿景。

衷心感谢以下技术专家对本文的审阅:Jon Arne Saeteras