数据点

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

Julie Lerman

下载代码示例

Julie Lerman本月专栏将继续对 Eric Evans 力作《领域驱动设计:软件核心复杂性应对之道》(Addison-Wesley Professional,2003 年)问世 10 周年表示庆祝。某些认为数据最重要的开发人员希望从域驱动设计 (DDD) 的某些编码模式中获益,我将为他们介绍更多技巧。上月专栏要点:

  • 在为域建模时未考虑到持久性
  • 公开方法来驱动实体和聚合,而非属性 setter
  • 意识到某些子系统非常适合进行简单的创建、读取、更新和删除 (CRUD) 交互,不需要为域建模
  • 不尝试跨界定上下文共享数据或类型的好处

在本专栏中,您将了解术语“贫乏”和“丰富”域模型的含义,以及值对象的概念。对于开发人员而言,值对象似乎是一个非此即彼的主题。某些读者认为它非常浅显易懂,不值一提,另一些读者则感到十分困惑,摸不着头脑。我还建议大家在某些情况下使用值对象代替相关对象。

表现优劣的用词:贫乏的域模型

关于 DDD 中定义类的方式,您常常会听到两个术语:贫乏的域模型和丰富的域模型。在 DDD 中,域模型指的就是类。丰富的域模型是指符合 DDD 方式的模型,即不仅使用 getter 和 setter,还使用行为定义的类(或类型)。相反,贫乏的域模型则只包含 getter 和 setter(可能还有少数简单的方法),这种域模型虽然适用于许多场合,但无法从 DDD 中获益。

在最初使用实体框架 (EF) Code First 时,我要编写的类大概 99% 是贫乏的。图 1 提供了一个典型的示例,其中显示了 Customer 类型及作为其继承源的 Person 类型。我经常会加入一些方法,但基本类型只是具有 getter 和 setter 的架构。

图 1 典型的贫乏域模型类看上去像数据库表

 

public class Customer : Person
{
  public Customer()
  {
    Orders = new List<Order>();
  }
  public ICollection<Order> Orders { get; set; }
  public string SalesPersonId { get; set; }
  public ShippingAddress ShippingAddress { get; set; }
}
public abstract class Person
{
  public int Id { get; set; }
  public string Title { get; set; }
  public string FirstName { get; set; }
  public string LastName { get; set; }
  public string CompanyName { get; set; }
  public string EmailAddress { get; set; }
  public string Phone { get; set; }
}

最后,我观察这些类并意识到,我只不过在我的类中定义了一个数据库表。 这不一定是坏事,具体取决于您计划使用该类型做什么。

图 2 列出了更加丰富的 Customer 类型,这与我在上一专栏 (msdn.microsoft.com/magazine/dn342868) 中探讨的 Customer 类型相似;大家可以对比一下图 1 与图 2。 它公开的方法控制对属性以及聚合中其他类型的访问。 由于大家已在上一专栏中见过 Customer 类型,因此我对它进行了改造,以更好地表达本月要讨论的某些主题。

图 2 属于丰富域模型而非简单属性的 Customer 类型

public class Customer : Contact
{
  public Customer(string firstName, string lastName, string email)
  {
    FullName = new FullName(firstName, lastName);
    EmailAddress = email;
    Status = CustomerStatus.Silver;
  }
  internal Customer()
  {
  }
  public void UseBillingAddressForShippingAddress()
  {
    ShippingAddress = new Address(
      BillingAddress.Street1, BillingAddress.Street2,
      BillingAddress.City, BillingAddress.Region,
      BillingAddress.Country, BillingAddress.PostalCode);
  }
  public void CreateNewShippingAddress(string street1, string street2,
   string city, string region, string country, string postalCode)
  {
    BillingAddress = new Address(
      street1,street2,
      city,region,
      country,postalCode)
  }
  public void CreateBillingInformation(string street1,string street2,
   string city,string region,string country, string postalCode,
   string creditcardNumber, string bankName)
  {
    BillingAddress = new Address      (street1,street2, city,region,country,postalCode );
    CreditCard = new CustomerCreditCard (bankName, creditcardNumber );
  }
  public void SetCustomerContactDetails
   (string email, string phone, string companyName)
  {
    EmailAddress = email;
    Phone = phone;
    CompanyName = 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 的公共接口。 有关详细信息,请参阅上月专栏的“私有 Setter 和公共方法”部分。 此示例旨在帮助大家更好地理解 DDD 中贫乏和丰富域模型的差别。

值对象可能会令人困惑

DDD 值对象虽然看上去简单,但对包括我在内的许多人而言,都非常令人困惑。 我曾经读到并听说过从多个角度描述值对象的多种不同方式。 幸运的是,每一种不同的解释并非相互冲突,而是帮助我对值对象有了更深入的了解。

就本质而言,值对象是一个没有标识键的类。

实体框架中有一个“复杂类型”的概念与之相当接近。 复杂类型没有标识键,通过它们可以封装一组属性。 EF 知道在涉及数据持久性时如何处理这些对象。 在数据库中,它们作为实体所映射到的表的字段进行存储。

例如,您可以不在 Person 类中定义 FirstName 属性和 LastName 属性,而是定义 FullName 属性:

public FullName FullName { get; set; }

FullName 可以是封装 FirstName 和 LastName 属性的类,并使用构造函数强制提供这两个属性。

但是,值对象并不是单纯的复杂类型。 在 DDD 中,值对象有三个决定性特征:

  1. 没有标识键(这与复杂类型一致)
  2. 固定不变
  3. 在检查它是否等同于同一类型的其他实例时,将比较它的所有值

它的不变性十分有趣。 虽然没有标识键,但类型的不变性却为其定义了标识。 您可以随时通过属性组合来标识实例。 由于固定不变,该类型的任何属性都不会更改,因此可以安全地使用该类型的值来标识特定的实例。 (大家已经知道 DDD 实体如何通过将 setter 设置为私有来保护自己免遭随机修改,但您可通过一个方法来影响这些属性。)值对象不仅会隐藏 setter,而且会阻止您修改任何属性。 如果您修改了某个属性,则意味着对象的值发生了更改。 由于整个对象表示其值,因此无法与其个别属性进行交互。 如果要让该对象具有不同的值,就需要创建该对象的一个新实例来存放一组新值。 最终根本不会出现值对象的属性发生更改的情况,因此可以说,“如果需要更改属性的值”这句话本身就非常矛盾。 可考虑并行使用字符串,这也是一种不变的类型(至少在我熟悉的所有语言中都是如此)。 在使用字符串实例时,不能替换字符串中的个别字符。 只能创建一个新字符串。

图 3 中显示了我的 FullName 值对象。 它不具备标识键属性。 大家可以看到,对它进行实例化的唯一方法就是同时提供两个属性值,您无法修改其中的任一属性。 因此,它符合不变性的要求。 最后一项要求是提供一种方式来比较是否等同于该类型的其他实例,作为其继承源的自定义 ValueObject 类(借自 Jimmy Bogard 在 bit.ly/13SWd9h 中提供的代码)无需满足该要求,因为这是一段非常复杂的代码。 虽然此 ValueObject 没有说明集合属性,但已满足我的需要,因为此值对象中没有任何集合。

图 3 FullName 值对象

public class FullName:ValueObject<FullName>
{
  public FullName(string firstName, string lastName)
  {
    FirstName = firstName;
    LastName = lastName;
  }
  public FullName(FullName fullName)
    : this(fullName.FirstName, fullName.LastName)
  {    }
  internal FullName() { }
  public string FirstName { get; private set; }
  public string LastName { get; private set; }
 // More methods, properties for display formatting
}

请记住,您可能需要一个经过修改的副本,例如与此 FullName 类似但使用不同的 FirstName 的副本;《实施域驱动设计》(Addison-Wesley Professional,2013 年)一书的作者 Vaughn Vernon 建议,FullName 可包括用于在现有实例的基础上创建新实例的方法:

public FullName WithChangedFirstName(string firstName)
{
  return new FullName(firstName, this.LastName);
}
public FullName WithChangedLastName(string lastName)
{
  return new FullName(this.FirstName, lastName);
}

当我加入持久性层(实体框架)时,可将其看作使用它的任何类中的 EF ComplexType 属性。 当我使用 FullName 作为 Person 类型的属性时,EF 会将 FullName 的属性存储在存储 Person 类型的数据库表中。 默认情况下,这些属性将在数据库中被命名为 FullName_FirstName 和 FullName_LastName。

FullName 非常简单。 即使您是进行面向数据库的设计,也可能不希望将 FirstName 和 LastName 存储在单独的表中。

值对象还是相关对象?

现在,请考虑另一种情形,假设 Customer 具有 ShippingAddress 和 BillingAddress:

public Address ShippingAddress { get; private set; }
public Address BillingAddress { get; private set; }

默认情况下(数据驱动的模式),我会创建 Address 作为一个实体,并加入 AddressId 属性。 同样,由于我“围绕数据进行思考”,我认为 Address 将在数据库中存储为单独的表。 下面,我设法让自己在为域建模时不考虑数据库,因此上述做法不会有任何后果。 但是,我会在某一刻使用 EF 加入数据层,而 EF 将采取同样的假设。 但 EF 无法正确计算映射。 它会假设 Address 和 Customer 之间存在 0..1:* 关系;换言之,一个 Address 可以有任意数量的相关 Customer,而一个 Customer 将没有 Address 或只有一个 Address。

我的 DBA 可能对此感到不满,更重要的是,我无法按照实体框架所做的同一假设(即许多 Customer 与零个或一个 Address 对应)来编写应用程序代码。 因此,EF 可能会影响我的数据持久性,或以意外方式进行检索。 因此,作为一名经常使用 EF 的开发人员,我的第一个方法是使用 Fluent API 修复 EF 映射。 但是,如果让 EF 修复此问题,我就不得不从 Address 添加重新指向 Customer 的导航属性,但我不想在模型中这样处理。 大家可以看到,如果使用 EF 映射来解决此问题,问题会更加复杂。

但如果退一步来关注域而非 EF 或数据库,只需将 Address 设置为值对象而非实体即可,这样会更加合理。 这需要三个步骤:

  1. 我需要从 Address 类型中移除键属性(可能名为 Id 或 AddressId)。
  2. 我需要确保 Address 固定不变。 其构造函数已经帮我填充了所有字段。 我需要移除可能允许更改任何属性的所有方法。
  3. 我需要确保能够根据属性和字段的值来检查 Address 是否等同。 通过再次从实用的 ValueObject 类(借自 Jimmy Bogard)进行继承,即可实现此目的(请参阅图 4)。

图 4 将 Address 设置为值对象

public class Address:ValueObject<Address>
{
  public Address(string street1, string street2, 
    string city, string region,
    string country, string postalCode) {
    Street1 = street1;
    Street2 = street2;
    City = city;
    Region = region;
    Country = country;
    PostalCode = postalCode;  }
  internal Address()  {  }
  public string Street1 { get; private set; }
  public string Street2 { get; private set; }
  public string City { get; private set; }
  public string Region { get; private set; }
  public string Country { get; private set; }
  public string PostalCode { get; private set; }
  // ... 
}

我依然按照之前的方式在我的 Customer 类中使用此 Address 类,只不过我需要修改地址;为此,我需要创建新的实例。

但现在,我不必再担心如何管理这种关系。 这还为值对象提供了另一项检验,即 Customer 是否确由其寄送和帐单信息定义,因为如果我向该客户销售某种商品,则很可能需要寄送商品,而且应付帐款部门将要求我提供帐单地址。

这并不是说每一种 1:1 或 1:0..1 关系都可以用值对象代替,但在此示例中,它却是一个良好的解决方案,大大简化了我的工作,让我不必再因为强制实体框架代我保持此关系而导致应接不暇的难题。

与 FullName 示例类似,如果将 Address 改为值对象,则意味着实体框会将 Address 视为 ComplexType 并将其所有数据都存储在 Customer 表中。 由于多年来一直专注于数据库规范化,我不禁会认为这是一种不好的做法,但它可被我的数据库轻松处理,而且非常适合我的特定域。

我可以想出很多个论点来反对使用这项技术,但这些论点都属于假设。任何与我的域无关的论点都不是有效的论点。 我们在编程中浪费了大量时间来避免万中之一但从未出现的情况。 对于在解决方案中添加那些先发制人的辅助工具的做法,我会尽量体谅。

尚未完结

我确实讲完了萦绕在这个数据怪人心头的一系列 DDD 概念。 我越深入地探索这些概念,就越想了解更多。 随着我一点点转变思维,我越发觉得这些模式非常合理。 它们一点也没有削弱我对数据持久性的兴趣,但将域与数据持久性和基础结构分离开来,让多年来将它们混为一谈的我感到豁然开朗。 不过,请务必记住,有许多软件活动并不需要 DDD。 DDD 有助于理清复杂的问题,但对于简单的问题,却往往会弄巧成拙。

在下一专栏中,我将探讨一些其他的 DDD 技术策略,它们最初看上去也与我数据优先的思维相冲突,例如放任双向关系、考虑使用固定条件,以及处理所有感知到的需求以触发来自聚合的数据访问。

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 课程。

衷心感谢以下技术专家对本文的审阅:Stephen Bohlen (Microsoft)
Stephen A. Bohlen 目前是 Microsoft Corporation 的一位高级技术推广人员,以前担任过执业架构师、CAD 经理、IT 技术专家、软件工程师、CTO 和顾问,拥有 20 多年的丰富经验,致力于帮助特定的 Microsoft 合作伙伴组织采用预发布的一流 Microsoft 开发产品和技术。