数据点

领域驱动设计的编码:数据聚焦型开发的技巧 - 第 3 部分

Julie Lerman

下载代码示例

这是我这一系列讲座的最后一部分,面向那些侧重数据的开发者,向他们讲述域驱动的设计 (DDD) 所使用的一些更具挑战性的编码概念。作为使用 Entity Framework (EF) 的 Microsoft .NET Framework 开发者,并且在很长时间内从事数据优先(甚至数据库优先)的开发工作,我曾极其痛苦地试图了解如何将我的技能与一些 DDD 实现技术相结合。即使我并没有在项目中使用完整的 DDD 实现(从客户端交互直到代码),我仍从多种 DDD 工具中获益匪浅。

在最后一讲中,我将介绍 DDD 编码的两个重要技术模式,以及如何将其应用到我所使用的对象关系映射 (ORM) 工具 EF 中。在之前的讲座中,我讲述了一对一关系。在这里,我将论述 DDD 所侧重的单向关系,以及它们如何影响您的应用程序。这种选择会导致困难的决策:认识到您最好不使用 EF 执行的一些“奇妙”的关系。同时,我还将讨论一下在聚合根与存储库之间平衡任务的重要性。

从根开始生成单向关系

从我开始使用 EF 生成模型时,双向关系已成为一种标准,我不假思索地就使用这种关系。实现双向导航的功能是有意义的。在拥有订单和客户的情况下,能够查看客户的订单是非常好的功能,而且对于某个订单,能够访问其客户数据也是非常便利的。无需多想,我在订单及其明细项目之间也生成了双向关系。订单与明细项目之间的关系确实会有用。但是,如果您停下来,稍微思考一下,您拥有明细项目且需要追溯到其订单的情况非常少见。我能想像到的这种情况之一是,您在针对产品进行报告,希望对通常哪些产品会一起订购进行分析,或者分析中涉及到客户或发运数据。在这些情况下,您可能需要从产品导航到包含该产品的明细项目,然后回到订单。不过,我仅在报告场景中看到这种情况,而这种情况下我不太需要处理侧重于 DDD 的对象。

如果我只需要从订单导航到明细项目,什么方法可以最有效地描述我的模型中的此类关系?

如我所述,DDD 侧重于单向关系。Eric Evans 的建议是:“尽可能地限制关系非常重要”以及“了解域可能会发现自然的方向偏离”。管理复杂的关系,特别是依赖于 Entity Framework 来维持关联时,绝对会导致许多混乱情况。我已经撰写了大量关于数据点的专栏,专门解释了 Entity Framework 中的关联。不论消除何种程度的复杂性,都有可能会带来好处。

考虑一下我在这一系列中对于 DDD 使用过的简单销售模型,在从订单到其明细项目的方向中确实出现了偏差。我无法想像不从订单开始创建、删除或编辑明细项目的情况。

如果您回顾一下我以前在该系列中生成的 Order 聚合,订单并不控制明细项目。例如,需要使用 Order 类的 CreateLineItem 方法来添加新的明细项目:

 

public void CreateLineItem(Product product, int quantity)
{
  var item = new LineItem
  {
    OrderQty = quantity,
    ProductId = product.ProductId,
    UnitPrice = product.ListPrice,
    UnitPriceDiscount = CustomerDiscount + PromoDiscount
  };
  LineItems.Add(item);
}

LineItem 类型具有 OrderId 属性,但没有 Order 属性。 这意味着,可以设置 OrderId 的值,但不能从 LineItem 导航到实际的 Order 实例。

在这种情况下,按照 Evans 的话说:“施加了遍历方向”。实际上,我确保了能够从 Order 遍历到 LineItem,但反方向则不行。

这种方法有其含义,不仅在模型中,而且还在数据层内。 我使用 Entity Framework 作为 ORM 工具,它只需通过 Order 类的 LineItems 属性便足以很好地理解此关系。 由于我碰巧遵循了 EF 的约定,它能够理解 LineItem.OrderId 是我的返回到 Order 类的外键属性。 如果我为 OrderId 使用了其他名称,对于 Entity Framework 来说,这个过程就要复杂得多。

但是,在这一情形中,我可以向现有订单添加新的 LineItem,如下所示:

order.CreateLineItem(aProductInstance, 2);
var repo = new SimpleOrderRepository();
repo.AddAndUpdateLineItemsForExistingOrder(order);
repo.Save();

order 变量现在表示带有已有订单和单个新 LineItem 的图形。 已有订单来自数据库,并且 OrderId 中已经有值,但新的 LineItem 只有 OrderId 属性具有默认值,该值为 0。

我的存储库方法接受该订单图形,将其添加到我的 EF 上下文中,然后应用正确的状态,如图 1 中所示。

图 1:将状态应用到订单图形

public void AddAndUpdateLineItemsForExistingOrder(Order order)
{
_context.Orders.Add(order);
_context.Entry(order).State = EntityState.Unchanged;
foreach (var item in order.LineItems)
{
  // Existing items from database have an Id & are being modified, not added
  if (item.LineItemId > 0)
  {
    _context.Entry(item).State = EntityState.Modified;
  }
}
}

如果您不熟悉 EF 行为,这里加以说明,Add 方法会导致上下文开始跟踪图形中的所有内容(订单和单个明细项目)。 同时,使用 Added 状态标记图形中的每个对象。 但是,由于此方法侧重于使用已有订单,我知道该 Order 不是新的,因此,该方法通过将 Order 实例设置为 Unchanged 来修复其状态。 它还检查任何已有 LineItems 并将其状态设置为 Modified,从而在数据库中更新它们而不是作为新项插入。 在更为具体的应用程序中,我倾向使用模式以更确定地了解每个对象的状态,不过在这个例子中我不希望过多涉及其他细节。 (在 Rowan Miller 的博客上可以看到此模式的一个早期版本,网址为 bit.ly/1cLoo14;我们合著的书籍《Programming Entity Framework:DbContext》[O’Reilly Media, 2012] 中提供了更新过的例子。)

由于所有这些操作在上下文跟踪对象时完成,Entity Framework 还会“神奇地”在我的新 LineItem 实例中修复 OrderId 的值。 因此,在我调用 Save 时,LineItem 知道了 OrderId 值为 1。

不再使用神奇的 EF 关系管理 — 对于更新

出现这种好运气是因为我的 LineItem 类型碰巧遵循了 EF 的外键名约定。 如果我将它命名为 OrderId 之外的名称,例如 OrderFK,则必须对类型进行一些更改(例如,引入不需要的 Order 导航属性),然后指定 EF 映射。 这就不如人意了,因为您增加了复杂性而只是为了满足 ORM。 有时候这种情况可能是必要的,但如果不必要,我希望能够避免。

更简单的方法就是不再使用 EF 关系中奇妙的依赖关系,而是控制代码中外键的设置。

第一步是告知 EF 忽略此关系,否则它将继续查找外键。

下面是我在 DbContext.OnModelBuilder 方法覆盖中使用的代码,这样 EF 就不会关注该关系:

modelBuilder.Entity<Order>().Ignore(o => o.LineItems);

现在,我将自行控制关系。 这意味着重构,因此我将构造函数添加到需要 OrderId 和其他值的 LineItem 中;这使得 LineItem 更像是 DDD 实体,我非常满意。 我还必须修改 Order 中的 CreateLineItem 方法,以便使用该构造函数而不是对象初始值。

图 2 显示了存储库方法的更新版本。

图 2 存储库方法

public void UpdateLineItemsForExistingOrder(Order order)
{
  foreach (var item in order.LineItems)
  {
    if (item.LineItemId > 0)
    {
      _context.Entry(item).State = EntityState.Modified;
    }
    else
    {
      _context.Entry(item).State = EntityState.Added;
      item.SetOrderIdentity(order.OrderId);
    }
  }
}

请注意,我不用再添加订单图形然后将订单的状态修复为 Unchanged。 实际上,由于 EF 不了解关系,如果我调用了 context.Orders.Add(order),它会添加 order 实例,但不会像以前一样添加相关明细项目。

相反,我迭代图形的明细项目,不仅将现有明细项目的状态设置为 Modified,还将新明细项目的状态设置为 Added。 我使用的 DbContext.Entry 语法完成两项任务。 在设置状态之前,它会检查以了解上下文是否已意识到(或者“跟踪”)该特定实体。 如果没有,则它在内部连接实体。 现在,它可以响应代码设置状态属性的情况。 因此,在该行代码中,我连接并设置 LineItem 的状态。

我的代码现在也遵循将 EF 用于 DDD 的另一个忠告:不要依赖于 EF 来管理关系。 EF 执行许多奇妙的功能,在许多情形下大有裨益。 多年来我很高兴地受益于其中。 但是,对于 DDD 聚合,您实际上是希望在自己的模型中管理这些关系,而不是依赖于数据层来为您执行必要的操作。

由于我在为键(例如 Order.OrderId)使用整数并依赖于我的数据库来为这些键提供值时陷入困境,我需要在存储库中为新聚合(例如带有明细项目的订单)进行一些额外的工作。 我需要紧密地控制持久性,这样才能使用旧式的插入图形模式:插入订单、获取数据库生成的新 OrderId 值、将该值应用到新的明细项目,然后将它们保存到数据库。 这是必需的,因为我已经中断了通常使用 EF 来完成这些奇妙操作的关系。 您可以在下载的示例中查看我如何在存储库中实现这一点。

经过了几年,我终于准备好停止依赖数据库来创建我的标识符,开始为我的键值使用可以在应用程序中生成和分配的 GUID。 这使得我能够进一步将我的域与数据库分隔开。

保持神奇的 EF 关系管理 — 对于查询

在我的模型中放弃 EF 关系后,对于在上一情形中执行更新确实非常有益。 但是,我并不希望放弃 EF 的所有关系功能。 从数据库查询时,加载相关数据是我希望留用的功能之一。 不论是预先加载、延迟加载还是显式加载,我乐于享受 EF 无需表示和执行附加查询就能获得相关数据的优点。

这就是分离所关注概念的延伸观点发挥作用的地方。 在遵循 DDD 设计规则的过程中,相似的类采用不同的表示形式很常见。 例如,您可能使用设计用于客户管理上下文中的 Customer 类来完成此操作,与之相对的是仅仅用于填充选取列表的 Customer 类,该选取列表中只需要客户的姓名和标识符。

还可以采用不同的 DbContext 定义。 在检索数据的情形中,您可能需要能意识到 Order 与 LineItems 之间关系的上下文,这样可以从数据库中预先加载订单及其明细项目。 但是,在执行我前面所进行的更新时,您可能需要显式忽略该关系的上下文,这样可以更加精确地控制域。

对于您可能采用软件解决的特定复杂问题子集,这种情况的一个极端观点是称为命令查询职责分离 (CQRS) 的模式。 CQRS 引导您考虑将数据检索(读取)和数据存储(写入)视为单独的系统,需要不同的模型和体系结构。 在一个小示例中,我重点强调了对数据检索操作和数据存储操作采用不同的关系理解的优点,这可以让您了解 CQRS 所能帮助您实现的功能。 您可以从 CQRS Journey 这个非常好的资源了解 CQRS 的更多信息,网址为 msdn.microsoft.com/library/jj554200

数据访问在存储库中进行,而不是聚合根

现在,我希望回顾一下,并解决最后一个问题,这个问题在我开始关注单向关系时困扰了我许久。 (这并不是说再也没有关于 DDD 的问题,而是说这是我在这一系列中的最后一个主题。)对于我们“数据库优先”的思维方式,这是一个关于单向关系的常见问题:(使用 DDD)进行数据访问的确切位置在哪里?

EF 最初发布时,唯一使用数据库的方法是对现有数据库实施反向工程。 因此,如前所述,我习惯于每个关系都是双向的。 如果数据库中的 Customers 和 Orders 表具有描述一对多关系的主键/外键约束,那么我在模型中就会看到这种一对多关系。 客户具有指向订单集合的导航属性。 订单具有指向 Customer 实例的导航属性。

在发展到模型优先和代码优先(可以描述模型和生成数据库)的过程中,我继续使用该模式,在关系的两端定义导航属性。 EF 很好用,映射更简单,编码也更自然。

因此,在 DDD 中,当我发现使用的 Order 聚合根能够意识到 CustomerId 甚至可能意识到完整的 Customer 类型,但是却无法从 Order 导航回 Customer 时,我非常沮丧。 我首先提出的问题是:“如果我要查找某个客户的所有订单,应该怎么办?”。我始终认为我应该能够这么做,而且我习惯于依赖具有双向导航的访问。

如果逻辑是从我的订单聚合根开始,我该怎么解决这个问题? 最初我也错误地认为要通过聚合根来完成所有操作,但这无济于事。

实际的解决方案让我觉得自己愚不可及。 在这里,我分享了自己的愚蠢想法,以防有人跟我一样误入歧途。 能够帮助我解决问题的,既不是聚合根,也不是 Order。 不过,在侧重于 Order 的存储库中(也是我用于执行查询和持久性的存储库),我的问题的答案显而易见:

public List<Order>GetOrdersForCustomer(Customer customer)
  {
    return _context.Orders.
      Where(o => o.CustomerId == customer.Id)
      .ToList();
  }

该方法返回 Order 聚合根的列表。 当然,如果我在 DDD 的工作范围中创建此项,并且我知道必须在特定上下文中使用它,而不是“以防万一”,那么我会不辞辛苦地将该方法放到存储库中。有可能我会在报告应用程序或者类似应用程序中需要它,但是在针对生成销售订单设计的上下文中则无必要。

我的问题刚刚开始

在过去的几年中,我对 DDD 有所了解。此系列中我介绍的主题,是我在数据层中使用 Entity Framework 时,在理解或弄清如何实现的过程中遇到的最困难的问题。 其中一些挫折源自我多年来思考软件的角度,我习惯于从我的数据库工作方式来考虑问题。 转变了这个角度之后豁然开朗,因为这让我将重点放在眼前的问题上,也就是我所设计软件的域问题。 与此同时,我确实需要找到良好的平衡,因为在添加到我的解决方案中时,可能会出现数据层问题。

在使用 Entity Framework 将我的类直接映射回数据库时,我重点思考的是其工作原理。不过,考虑到域逻辑与数据库之间可能存在另一层(或更多层)也很重要。 例如,您可能会有一个域逻辑与之交互的服务。 在这种情况下,从您的域逻辑中映射时,数据层的重要性很低(或者根本不重要);这种问题现在由服务来处理。

有很多方法可用于实现软件解决方案。 即使我没有实现完整的端到端 DDD 方法(这需要对此相当得精通),我的整个工作仍然从通过 DDD 学习到的知识和技术中获益颇多。

Julie Lerman是 Microsoft MVP、.NET 导师和顾问,住在佛蒙特州的山区。她在世界各地的用户组和会议中演示过数据访问和其他 Microsoft .NET Framework 主题。她是《Programming Entity Framework》(2010) 以及“代码优先”版 (2011) 和 DbContext 版 (2012)(均出自 O’Reilly Media)的作者,博客网址为 thedatafarm.com/blog。通过她的 Twitter(网址为 twitter.com/julielerman)关注她,并在 juliel.me/PS-Videos 上观看其 Pluralsight 课程。

衷心感谢以下技术专家对本文的审阅:Stephen Bohlen (Microsoft)