2016 年 11 月

第 31 卷,第 11 期

数据点 - CQRS 和 EF 数据模型

作者 Julie Lerman

Julie Lerman命令查询职责分离 (CQRS) 是一种模式,它实际上在以下这些方面提供指导:分离读取数据的职责和引起系统的状态更改的职责(例如,发送确认消息或写入数据库),以及设计相应的对象和体系结构。其设计初衷是帮助高事务性系统,如银行系统。Greg Young 从 Bertrand Meyer 的命令查询分离 (CQS) 策略演化出 CQRS,Martin Fowler 认为其最有价值的概念是“如果你能清楚地将更改状态的方法与未更改状态的方法分离出来,那么这个模式会非常有用”(bit.ly/2cuoVeX)。CQRS 所添加的概念是为命令和查询创建完全分离的模型。

CQRS 经常被归入错误的类别,例如,作为体系结构的特定类型,或作为域驱动设计的一部分,或作为消息或事件。在 2010 年的博客文章“CQRS, Task-Based UIs, Event Sourcing, Agh!” (bit.ly/1fZwJ0L)中,Young 阐述了 CQRS 并不属于以上这些类别,而只是一个能够帮助进行体系结构决策的模式。CQRS 实际上是“有两个对象,其中之前仅有一个。” 尽管完全可以将 CQRS 应用到软件的数据模型或服务边界,但它并不特定于这些部分。事实上,他指出“最大的优点可能是,在处理命令和查询时,它会识别出其 (sic) 是不同的体系结构属性”。

在定义数据模型(在大多数情况下,通常使用实体框架 [EF])时,我变得喜欢在特定方案中利用此模式。一如既往,我的概念仅作为指导,而非规则,就像我选择以帮助我实现体系结构的方式来应用 CQRS 一样,我希望你也根据自己特定的需要来使用并调整 CQRS。

使用 EF 进行关系处理的优势

实体框架使设计阶段的关系处理工作变得如此简单。在进行查询时,这将大有裨益。借助实体间存在的关系,可在表达查询时导航这些关系。从数据库检索相关数据简单有效。在延迟加载或显式加载完成后,可使用包括方法或使用投影从预先加载进行选择。自 EF 的原始版本发布以来,或从我在 2011 年 6 月撰写“揭开实体框架策略的面纱: 加载相关数据” (msdn.com/magazine/hh205756) 至今,这些功能并没有太大的更改。

图 1 所示模型中的规范示例使查询易于在页面上显示客户的订单、行项和产品名称的详细信息。可以按如下所示编写有效查询:

var customersWithOrders = context.Customers
  .Include(c => c.Orders.Select(
  o => o.LineItems.Select(p => p.Product)))
  .ToList();

具有紧密耦合关系的实体框架数据模型
图 1 具有紧密耦合关系的实体框架数据模型

EF 会将其转换为 SQL,从而在一个数据库命令中检索所有相关数据。然后,EF 将从结果中具体化客户、客户订单、订单的行项、甚至每个行项的产品详细信息的完整图。

毫无疑问,它使填充页面(例如图 2 所示的 Window Presentation Foundation (WPF) 窗口)更为简单。我可以通过一行代码实现此操作:

customerViewSource.Source = customersWithOrders

绑定到单个对象图的数据控件
图 2 绑定到单个对象图的数据控件

以下是开发者钟爱的另一个优点: 在创建图形时,EF 将在数据库中往返工作,以插入父级、返回新的主键值,然后在构建和执行它们的插入命令前将其应用为子级的外键值。

所有这些功能都很神奇。但是这些神奇的功能也有缺点,在 EF 数据模型的案例中,实现这些神奇的功能需要紧密绑定关系,而这会在执行更新,有时甚至在执行查询时导致产生副作用。在使用导航属性将引用数据附加到新记录,然后调用 SaveChanges 时,将出现明显的副作用。举例来说,你可能创建一个新的行项并将其产品属性设置为来自数据库的现有产品的实例。在已连接的应用中(如 WPF 应用,其中 EF 可能跟踪对其对象所做的每个更改),EF 将获知该产品已预先存在。但在断开连接的情况下(其中 EF 仅在进行更改后跟踪对象),EF 会将该产品(如行项)视作新产品,并将其重新插入到数据库。当然,有针对这些问题的解决办法。对于此问题,我始终推荐设置外键值 (ProductId),而不是设置实例。在保存数据前,也有使用 EF 跟踪状态并解决问题的方法。事实上,我最近的专栏“处理 EF 中断开连接的实体的状态”(msdn.com/magazine/mt694083) 中演示了实现此方法的模式。

以下是另一常见问题:所需的导航属性。根据与对象的交互方式,你可能不会注意导航属性,但 EF 一定会注意该属性是否缺失。我在另一篇专栏文章“设法应对缺少的外键”(msdn.com/magazine/hh708747)中介绍了此类问题。

因此,是的,有针对此问题的解决方法。但是,也可以利用 CQRS 模式来创建无需解决办法的更简洁、更明确的 API。这也意味着,它们将更易于维护,且更不容易带来其他副作用。

为 DbContext 和域类应用 CQRS 模式

我经常使用 CQRS 模式来帮助自己解决此问题。即使这的确意味着所拆分的任何模型都将生成双倍的类(尽管代码未必是双倍的)。我不仅创建两个单独的 DbContexts,而且通常最终会创建域类对,其中每个域类专注于与读取或写入相关的任务。

我将此模型用作示例,此模型与我已演示的较简单模型略有不同。此示例来自我最近为 Pluralsight 课程构建的一个大型解决方案。在该模型中,有一个 SalesOrder 类,它在域中充当聚合根。换而言之,SalesOrder 类型控制聚合中任何其他相关类型将要进行的操作—它控制如何创建 LineItem、折扣如何计算、邮寄地址如何导出等。如果回顾一下我刚才提及的任务,就会发现,它们更关注于订单的创建。如果只是从数据库读取订单信息,则真的无需担心为订单创建新行项的相关规则。

另一方面,与仅向数据库推送数据时所关注的信息比较,在查看数据时,可能有更多有趣的信息要查看。

查询数据的模型

图 3 演示我的解决方案的 Order.Read.Domain 项目中的 SalesOrder 类型。此处有很多属性,而创建更好的显示数据的方法只有一个。不会在此处看到业务规则,因为我无需担心数据验证。

图 3 定义为用于数据读取的 SalesOrder 类型

namespace Order.Read.Domain {
 public class SalesOrder : Entity  {
  protected SalesOrder()   {
    LineItems = new List<LineItem>();
  }
  public DateTime OrderDate { get; set; }
  public DateTime? DueDate { get; set; }
  public bool OnlineOrder { get; set; }
  public string PurchaseOrderNumber { get; set; }
  public string Comment { get; set; }
  public int PromotionId { get; set; }
  public Address ShippingAddress { get; set; }
  public CustomerStatus CurrentCustomerStatus { get; set; }
  public double Discount   {
    get { return CustomerDiscount + PromoDiscount; }
  }
  public double CustomerDiscount { get; set; }
  public double PromoDiscount { get; set; }
  public string SalesOrderNumber { get; set; }
  public int CustomerId { get; set; }
  public double SubTotal { get; set; }
  public ICollection<LineItem> LineItems { get; set; }
  public decimal CalculateShippingCost()   {
    // Items, quantity, price, discounts, total weight of item
    // This is the job of a microservice we can call out to
    throw new NotImplementedException();
  }
}

将此类型与图 4 中的 SalesOrder 比较,我已针对方案对其进行了定义,其中我将 SalesOrder 数据存储到数据库(不论该数据是新订单还是我正在编辑的订单)。在此版本中有更多业务逻辑。工厂方法以及专用和受保护的构造函数可确保在没有可用的特定数据的情况下无法创建订单。有几种包含逻辑和规则的方法可解决如何为一个订单创建新行项以及如何应用邮寄地址的问题。有一种方法可控制一组特定订单详细信息的修改方法和修改时间。

图 4 创建和更新数据的 SalesOrder 类型

namespace Order.Write.Domain {
  public class SalesOrder : Entity   {
    private readonly Customer _customer;
    private readonly List<LineItem> _lineItems;
    public static SalesOrder Create(IEnumerable<CartItem>
      cartItems, Customer customer) {
      var order = new SalesOrder(cartItems, customer);
      return order;
    }
    private SalesOrder(IEnumerable<CartItem> cartItems, Customer customer) : this(){
      Id = Guid.NewGuid();
      _customer = customer;
      CustomerId = customer.CustomerId;
      SetShippingAddress(customer.PrimaryAddress);
      ApplyCustomerStatusDiscount();
      foreach (var item in cartItems)
      {
        CreateLineItem(item.ProductId, (double) item.Price, item.Quantity);
      }
      _customer = customer;
    }
    protected SalesOrder() {
      _lineItems = new List<LineItem>();
      Id = Guid.NewGuid();
      OrderDate = DateTime.Now;
    }
    public DateTime OrderDate { get; private set; }
    public DateTime? DueDate { get; private set; }
    public bool OnlineOrder { get; private set; }
    public string PurchaseOrderNumber { get; private set; }
    public string Comment { get; private set; }
    public int PromotionId { get; private set; }
    public Address ShippingAddress { get; private set; }
    public CustomerStatus CurrentCustomerStatus { get; private set; }
    public double Discount{
      get { return CustomerDiscount + PromoDiscount; }
    }
    public double CustomerDiscount { get; private set; }
    public double PromoDiscount { get; private set; }
    public string SalesOrderNumber { get; private set; }
    public int CustomerId { get; private set; }
    public double SubTotal { get; private set; }
    public ICollection<LineItem> LineItems  {
      get { return _lineItems; }
    }
    public void CreateLineItem(int productId, double listPrice, int quantity)
    {
      // NOTE: more rules to be implemented here
      var item = LineItem.Create(Id, productId, quantity, listPrice,
        CustomerDiscount + PromoDiscount);
      _lineItems.Add(item);
    }
    public void SetShippingAddress(Address address) {
      ShippingAddress = Address.Create(address.Street, address.City,
        address.StateProvince, address.PostalCode);
    }
    public bool HasLineItems(){
      return LineItems.Any();
    }
    public decimal CalculateShippingCost() {
      // Items, quantity, price, discounts, total weight of item
      // This is the job of a microservice we can call out to
      throw new NotImplementedException();
    }
    public void ApplyCustomerStatusDiscount() {
      // The guts of this method are in the sample
    }
    public void SetOrderDetails(bool onLineOrder,
      string PONumber, string comment, int promotionId, double promoDiscount){
      OnlineOrder = onLineOrder;
      PurchaseOrderNumber = PONumber;
      Comment = comment;
      PromotionId = promotionId;
      PromoDiscount = promoDiscount;
    }
  }
}

SalesOrder 的写入版本更为复杂。但是,如果我需要使用读取版本工作,那么就不会有所有这些无关的写入逻辑来妨碍我操作了。如果你认同可读代码是不容易出错的代码这一指南,那么你可能像我一样有另一个理由来喜欢使用分离模式了。当然,有些人(如 Young)可能会认为甚至这个类也包含了太多逻辑。但就我们的目的而言,这样就行了。

CQRS 模式让我能够在定义类时专注于填充 SalesOrder 的问题(在本案例中很少)和单独构建 SalesOrder 的问题。这些类的确具有某些共同点。例如,SalesOrder 类的两个版本均使用 ICollection<List> 属性定义 LineItem 类型的关系。

现在,让我们看一下它们的数据模型;即,我用于进行数据访问的 DbContext 类。

OrderReadContext 为 SalesOrder 实体定义一个 DbSet:

public DbSet<SalesOrder> Orders { get; set; }

EF 发现相关 LineItem 类型并构建模型,如图 5 中所示。但是,由于 EF 要求 DbSet 公开,这还使得任何人都可以调用 OrderReadContext.SaveChanges。层在这里非常有用。Andrea Saltarello 提供了封装 DbContext 的好方法,以便仅公开 DbSet ,而使用此类的开发者(未来的你)没有直接访问 OrderReadContext 的权限。这将有助于避免意外调用读取模型上的 SaveChanges。

基于 OrderReadContext 的数据模型
图 5 基于 OrderReadContext 的数据模型

此类的一个简单示例是:

public class ReadModel {
  private OrderReadContext readContext = null;
  public ReadModel() {
    readContext = new OrderReadContext();
  }
  public IQueryable<SalesOrder> Orders {
    get {
      return readContext.Orders;
    }
  }
}

可向此实现添加的另一层保护是利用 SaveChanges 为虚拟这一事实。可替代 SaveChanges 以使其从不调用内部 DbContext.SaveChanges 方法。

OrderWriteContext 定义两个 DbSet:不仅针对 SalesOrder 定义,而且还针对 LineItem 实体定义:

public DbSet<SalesOrder> Orders { get; set; }
public DbSet<LineItem> LineItems { get; set; }

这已经非常有趣,因为我没有在其他 DbContext 中公开 LineItem 的 DbSet。在 OrderReadContext 中,我将仅通过 SalesOrders 进行查询。我不会直接对 LineItem 进行查询,所以无需为该类型公开 DbSet。请记得在查询中填充 WPF 窗口,如图 2 中所示。我通过 Orders DbSet 预先加载了 LineItem。

OrderWriteContext 中的另一个重要逻辑是,我已使用 Fluent API 以显式的方式通知 EF 忽略 SalesOrder 和 LineItem 之间的关系:

protected override void OnModelCreating(DbModelBuilder modelBuilder) {
  modelBuilder.Entity<SalesOrder>().Ignore(s => s.LineItems);
}

生成的模型如图 6 中所示。

基于 OrderWriteContext 的数据模型
图 6 基于 OrderWriteContext 的数据模型

这意味着我不能使用 EF 从 SalesOrder 导航到 LineItem。但它不能阻止我在自己的业务逻辑中执行此操作;正如你看到的那样,我在 SalesOrder 类中有很多与 LineItem 交互的代码。但是,我将无法编写导航到 LineItem 的查询,如 context.SalesOrders.Include(s=>s.LineItems)。你可能会对此惊慌失措,但当我提醒你这是用于写入数据,而不是用于读取数据的模型之后,就无需为此慌乱了。EF 可使用 OrderReadContext 检索相关数据,且不会出现任何问题。

用于写入的自由关系型 DbContext 的优点和缺点

所以,我从将写入职责与查询职责分离这一操作中获得了什么? 对我来说,其缺点显而易见。我需要维护更多代码。更重要的是,EF 不会奇迹般地为我更新图形。我必须手动执行更多的操作,以确保在我插入、更新或删除数据时,关系得到正确的处理。例如,如果你有向 SalesOrder 添加新 LineItem 的代码,则在将 LineItem 永久保存到数据库时,仅写入 myOrder.Line­Items.Add(someItem) 不会触发 EF 向 LineItem 推送 orderId。必须显式设置该 orderId 值。如果回顾图 4 中的 SalesOrder 的 CreateLineItem 方法,你会发现我已在其中涵盖此方法。在我的系统中,该方法是为订单创建新行项的唯一方法,这意味着我不能在缺少应用 orderId 的关键步骤的其他位置编写代码。你可能想要问的第二个问题是: “如果我想要更改某个特定行项的 orderId,该执行什么操作?” 在我的系统中,这是没有太大意义的操作。我可以看到从订单删除行项。我可以看到向订单添加行项。但是没有允许更改 orderId 的业务逻辑。不过,我不禁思考这些“该执行什么操作”,因为我已习惯了将这些功能构建到我的数据模型中。

除了对关系的显式控制外,拆分读取和写入逻辑也让我思考默认添加到我的数据模型的所有逻辑,而其中的一些逻辑将永远不会使用。而这些无关的逻辑可能强制我编写避免其副作用的解决方法。

我之前提出的问题,即在读取数据但并不想对其进行更新时,引用数据被意外地重新添加到数据库或被引入 null 值,这些问题也将消失。为读取定义的类可能包含想要查看但不想对其更新的值。我的 SalesOrder 示例没有此特定问题。但是,写入类可避免将想要查看但不想对其更新的属性包括在内,从而避免了使用 null 值覆盖忽略的属性。

确保值得执行此操作

CQRS 会为你的系统开发增加大量工作。请确保阅读对 CQRS 何时对要解决的问题来说可能是技术过度使用这一问题提供指南的文章,例如 Udi Dahan 撰写的这篇文章,见于 bit.ly/2bIbd7i 。Dino Esposito 的“适合常用应用程序的 CQRS” (msdn.com/magazine/mt147237) 也提供了见解。我对此模式的特定使用并不是你所认为的是对 CQRS 的一种大力推广,但 CQRS 所提供的拆分读取和写入的“权限”帮助我减少了解决方案的复杂性,其中过量的数据模型阻碍了此问题的解决。在编写额外代码以解决副作用或编写额外代码为解决问题提供更简洁、更直接的道路之间找到平衡需要一些经验和信心。但是有时候,直觉就是最好的指南。


Julie Lerman是 Microsoft MVP、.NET 导师和顾问,住在佛蒙特州的山区。您可以在全球的用户组和会议中看到她对数据访问和其他 .NET 主题的演示。她的博客地址是 thedatafarm.com/blog。她是“Entity Framework 编程”及其 Code First 和 DbContext 版本(全都出版自 O’Reilly Media)的作者。通过 Twitter 关注她:@julielerman 并在 juliel.me/PS-Videos 上观看其 Pluralsight 课程。

衷心感谢以下技术专家对本文的审阅: Andrea Saltarello(托管设计)(andrea.saltarello@manageddesigns.it)
Andrea Saltarello 来自意大利米兰,是一名企业家和软件架构师,他还喜欢为实际项目编写代码以获取有关设计决策的反馈。作为培训师和演讲者,他在欧洲有一些课程和会议的演讲安排,例如 TechEd Europe、 DevWeek 和 Software Architect。他自 2003 年以来一直是 Microsoft MVP,并在近期被任命为 Microsoft 区域主管。他热爱音乐,钟情于 Depeche Mode,自从第一次听到该乐队的歌曲“Everything Counts”时,就爱上了这个乐队。