数据点

领域驱动设计的编码: 数据聚焦型开发的技巧

Julie Lerman

下载代码示例

Julie Lerman今年,Eric Evans 具有开创性的软件设计图书“Domain-Driven Design: Tackling Complexity in the Heart of Software”(领域驱动设计:软件核心复杂性应对之道)(Addison-Wesley Professional,2003 年,amzn.to/ffL1k)迎来了其出版十周年的纪念日。Evans 在本书中分享了其指导大型企业完成构建软件的过程的多年经验。他随后花了更长时间考虑如何概括帮助这些项目获得成功的模式 - 与客户交互、分析要解决的企业问题、组建团队和设计软件的架构。这些模式的焦点是企业的领域,它们共同组成领域驱动设计 (DDD)。利用 DDD,您可以为有问题的领域建模。这些模式是通过抽象化您对领域的知识产生的。即使在今天,重读 Martin Fowler 的前言和 Evans 的序言仍能获得对 DDD 本质的丰富概览。

在本专栏以及接下来的两个专栏中,我将分享一些指导原则,在我着手让我的代码从某些 DDD 技术模式获益时,这些原则帮助我那注重数据的 Entity Framework 大脑保持清晰的思维。

我为什么关注 DDD?

我对 DDD 的介绍摘自发布于 InfoQ.com 上的对 Jimmy Nilsson 的简短视频访谈。Jimmy Nilsson 是 .NET 社区(以及其他地方)一位受人尊敬的架构师,他在访谈中谈到了 LINQ to SQL 和 Entity Framework (bit.ly/11DdZue)。在访谈的最后,Nilsson 被要求列举他最喜欢的技术书籍。他回答道: “我最喜欢的计算机书籍是 Eric Evans 编著的“Domain-Driven Design”(领域驱动设计)。我觉得它就像诗一样美妙。它不仅内容精彩,还有诗一般的韵味,让人百读不厌。”诗一般的美妙!当时,我正在写我的第一本技术书籍“Programming Entity Framework”(实体框架编程)(O’Reilly Media,2009 年),这一描述引发了我的兴趣。因此,我去读了一点 Evans 的书,想看看到底写得如何。Evans 是一个行文优美而流畅的作者,再加上他在软件开发领域具有敏锐而自然的见解,读者在阅读本书时体验到了充分的乐趣。不过,使我感到震撼的还有我读到的内容。Evans 不仅文笔极佳,而且书中的内容也非常吸引我。他提到与客户建立关系并真正了解其业务和业务问题(与有问题的软件有关),而不只是苦苦编写代码。在我 25 年的软件开发生涯中,这个观点起到了举足轻重的作用。我想要实现更多。

我在 DDD 领域的边缘又徘徊了很多年,然后开始了解更多内容 - 我在一次会议上见到了 Evans,随后参加了他为期四天的浸入式研讨会。虽然我远远不是 DDD 方面的专家,但我发现在试图将自己的软件创建过程向更有条理且更易于管理的结构转变时,可立即使用“界定的上下文”模式。您可以阅读我的 2013 年 1 月专栏中的主题“使用 DDD 界定的上下文收缩 EF 模型”(msdn.microsoft.com/magazine/jj883952)。

从那时起,我对 DDD 的研究又深入了一步。我对 DDD 感到深深着迷,并从中获得了很多灵感,但我为从数据驱动的角度理解一些可推动成功的技术模式而纠结。似乎有很多开发人员也遇到了相同的问题,因此我想分享在 Evans 和很多其他 DDD 实践者和教师(包括 Paul Rayner、Vaughn Vernon、Greg Young、Cesar de la Torre 和 Yves Reynhout)的慷慨帮助和关心之下吸取的经验教训。

在为域建模时忘记持久性

为域建模就是重点关注企业的任务。在设计类型及其属性和行为时,我非常倾向于考虑关系如何在数据库中发挥作用以及我选择的对象关系映射 (ORM) 框架(即 Entity Framework)将如何处理我构建的属性、关系和继承层次结构。除非您为从事数据存储和检索业务的公司(像 Dropbox 一样)构建软件,否则数据持久性仅在您的应用程序中起到支持作用。这非常像调用天气源的 API 以便向用户显示当前温度,或者,从您的应用程序向外部服务发送数据(可能是 Meetup.com 上的注册信息)。当然,您的数据可能更加复杂,但利用 DDD 的上下文界定方法,通过关注行为和在构建类型时遵循 DDD 指导,持久性可能比您在今天构建的系统简单得多。

如果您已仔细研究 ORM(例如,了解如何使用 Entity Framework Fluent API 配置数据库映射),则应能根据需要实现持久性。最糟糕的情况是,您可能需要对您的类做一些调整。在极端情况下(例如,使用旧数据库时),您甚至可以添加专为数据库映射设计的持久性模型,然后使用 AutoMapper 等工具解析域模型和持久性模型之间的映射。

但是,这些关注点与您的软件用于解决的业务问题无关,因此持久性不应影响域设计。这对我来说是一个难题,因为我在设计实体时会忍不住考虑 EF 将如何推断其数据库映射。因此,我试图解决这件烦心事。

私有 Setter 和公共方法

另一个经验法则是将属性 setter 设为私有。您应使用修改属性的方法控制与 DDD 对象及其相关数据的交互,而不是允许通过调用代码来随机设置各种属性。不,我不是指 SetFirstName 和 SetLastName 之类的方法。例如,您在创建新客户前需要考虑一些规则,而不是实例化新的 Customer 类型并设置其各个属性。您可以将这些规则内置于 Customer 的构造函数中,使用 Factory Pattern 方法,甚至在 Customer 类型中包含 Create 方法。图 1 显示按照聚合根(即对象图的“父级”,也称为 DDD 中的“根实体”)的 DDD 模式定义的 Customer 类型。客户属性具有私有 setter,以便仅 Customer 类的其他成员能够直接影响这些属性。该类公开了构造函数以控制其实例化方式,并将无参数构造函数(Entity Framework 需要)隐藏为内部构造函数。

图 1 充当聚合根的类型的属性和方法

public class Customer : Contact
{
  public Customer(string firstName,string lastName, string email)
  { ... }
  internal Customer(){ ... }
  public void CopyBillingAddressToShippingAddress(){ ... }    
  public void CreateNewShippingAddress(
   string street, string city, string zip) { ... }
  public void CreateBillingInformation(
   string street, string city, string zip,
   string creditcardNumber, string bankName){ ... }    
  public void SetCustomerContactDetails(
   string email, string phone, string companyName){ ... }
  public string SalesPersonId { get; private set; }
  public CustomerStatus Status{get;private set;}
  public Address ShippingAddress { get; private set; }
  public Address BillingAddress { get;private set; }
  public CustomerCreditCard CreditCard { get; private set; }
}

Customer 类型通过以下方式控制和保护聚合中的其他实体(一些地址和信用卡类型):公开将用于创建和操作这些对象的特定方法(如 CopyBillingAddressToShippingAddress)。 聚合根必须确保通过在这些方法中实现的域逻辑和行为应用定义聚合中的每个实体的规则。 最重要的一点是,聚合根负责确保聚合中的固定条件逻辑和一致性。 我将在下一个专栏中详细讨论固定条件,同时,我建议您阅读 Jimmy Bogard 的博客文章“Strengthening Your Domain: Aggregate Construction”(增强您的域:聚合结构)(网址为 bit.ly/ewNZ52),该文很好地解释了聚合中的固定条件。

最后,Customer 公开的内容是行为而不是属性: CopyBillingAddressToShippingAddress、CreateNewShipping­Address、CreateBillingInformation 和 SetCustomerContactDetails。

请注意,从中派生 Customer 的 Contact 类型位于名为“Common”的另一个程序集中,因为其他类可能需要该类型。 我需要隐藏 Contact 的属性,但它们不能是私有属性,否则 Customer 将无法访问它们。 相反,应将其范围设为“受保护”:

public class Contact: Identity
{
  public string CompanyName { get; protected set; }
  public string EmailAddress { get; protected set; }
  public string Phone { get; protected set; }
}

关于标识的附注: Customer 和 Contact 可能看起来像 DDD 值对象,因为它们没有键值。但在我的解决方案中,键值由从中派生 Contact 的 Identity 类提供。这些类型都不是固定不变的,因此任何情况下都不能将它们视为值对象。

由于 Customer 继承自 Contact,因此 Customer 能够访问和设置这些受保护的属性,如以下 SetCustomerContactDetails 方法中所示:

public void SetCustomerContactDetails (string email, string phone, string companyName)

{

  EmailAddress = email;

  Phone = phone;

  CompanyName = companyName;

}

有时您只需 CRUD 就够了

在您的应用程序中,并非所有内容都需要使用 DDD 来创建。DDD 用来帮助处理复杂的行为。如果您只需执行一些粗略而随意的编辑或查询,则一个简单类(或一组类)就足够了。此简单类的定义方式与您平时使用 EF Code First(使用属性和关系)时一样,它还与插入、更新和删除方法(通过存储库或只是 DbContext)结合使用。因此,若要完成创建订单及其明细项目之类的操作,您可能需要 DDD 来帮助执行特殊的业务规则和行为。例如,下订单的是否是此金星级客户?在这种情况下,您需要获取一些客户详细信息来确定答案是否为“是”,如果是这样,则对要添加到订单的每个项目应用 10% 的折扣。用户是否已提供其信用卡信息?随后,您可能需要调用验证服务来确保该信用卡有效。

DDD 的关键在于将域逻辑作为方法包含在域的实体类中,并在无状态业务对象中利用 OOP 而不是实现“事务脚本”,这是典型演示件 Code First 类的外观。 

不过,有时您执行的所有操作都是非常标准的,例如创建联系人记录 (姓名、地址、推荐人等)并保存该信息。这就是所谓的创建、阅读、更新和删除 (CRUD)。您不需要创建聚合、根和行为即可满足该要求。

您的应用程序很可能包含复杂行为和简单 CRUD 的组合。花时间来阐明行为吧,不要把时间、精力和金钱浪费在过度设计事实上非常简单的应用程序部件的架构上。在这些情况下,标识不同子系统或界定的上下文之间的边界很重要。界定的上下文可能在很大程度上是由数据驱动的(即 CRUD),而另一方面,以核心领域界定的关键上下文应按 DDD 方法进行设计。

共享数据在复杂系统中可能是个大麻烦

我遇到了另一个问题,当其他人满怀好意地试图进一步说明时,我抱怨不已,对跨子系统共享类型和数据充满担心。很明显,我不可能鱼与熊掌兼得,因此我必须重新考虑我的假设:我绝对必须积极地跨系统共享类型,并让所有这些类型与同一数据库中的同一表进行交互。

我正在学习实际考虑需在何处共享数据,然后迎接挑战。有些事情可能不值得尝试,例如,从不同的上下文映射到单个表,甚至单个数据库。最常见的示例是共享尝试满足系统中所有人的需求的 Contact。您如何为大量系统中可能需要的 Contact 类型协调和利用源代码管理?如果有系统需要修改该 Contact 类型的定义,该怎么办?对于 ORM,您如何将在多个系统中使用的 Contact 映射到单个数据库中的单个表?

通过说明您并不总是需要指向单个数据库中的同一个人员表,DDD 可指导您避免共享域模型和数据。

我反对此行为的最大理由源于这 25 年来我对重用(重用代码和重用数据)的好处的关注。因此,我对以下观点既感到费解又很有兴趣: 复制数据并不是犯罪行为。当然,并非所有数据都适合此新模式(对我而言)。不过,对于轻量型内容(如人员姓名),情况又如何呢?如果您将人员的名字和姓氏复制到多个表中,甚至复制到专用于软件解决方案的各个子系统的多个数据库中,会怎么样呢?从长远来看,通过消除共享数据的复杂性,可大大简化构建系统这一工作。在任何情况下,您必须始终最大程度地减少在不同的界定上下文中复制数据和属性的工作。有时,您只需要客户的 ID 和状态便能计算 Pricing 界定的上下文中的折扣。可能只在 Contact Management 界定的上下文中需要此客户的名字和姓氏。

但是,仍然有如此多的信息需要在系统之间进行共享。您可以利用 DDD 称之为“反损坏层”(可以像服务或消息队列一样简单)的工具来确保当有人在一个系统中创建了新联系人时,您可以确定该人员在其他位置是否已存在,或确保人员及常用标识键已在其他子系统中创建。

下个月前要考虑的大量问题

当我学习和理解领域驱动型设计的技术方面、努力协调旧习惯和新思想以及无数次觉得茅塞顿开时,我在这里讨论的心得真正起到了拨开云雾见青天的效果。有时,这只是一种观点,我在这里表达这些心得的方式反映了帮助我更清晰地了解事物的观点。

我将在下一个专栏中分享我的得意时刻,并将在其中讨论您可能听到过的表现优劣的用词: “贫乏的域模型”及其 DDD 同类“丰富的域模型”。我还将讨论不定向关系,并讨论在您使用 Entity Framework 的情况下,在需要添加数据持久性时要考虑的内容。我还将探讨更多的 DDD 主题,这些主题为我缩短您自己的学习曲线的工作带来了大量困难。

到那时,为何不更详细地观察您自己的类并了解如何更倾向于做一个控制狂,隐藏这些属性 setter 并公开更多描述性和显式方法。还要记住: 不允许使用“SetLastName”方法。开个玩笑!

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

衷心感谢以下技术专家对本文的审阅: Cesar de la Torre (Microsoft)