领先技术

域模型设计

Dino Esposito

 

Dino Esposito
最新发布的 Entity Framework 4.1 和新的代码优先开发模式打破了服务器开发的基本规则:如果数据库没有准备就绪,则不要执行任一步骤。代码优先允许开发人员重点关注业务领域并根据类来为该领域建模。在某种程度上,代码优先模式鼓励在 .NET 环境中应用领域驱动设计 (DDD) 原则。业务领域包含相互关联的实体,其中每个实体都具有自己的作为属性而公开的数据,并且这些实体可以通过方法和事件公开行为。更为重要的是,每个实体都可能处于某一状态,并绑定到验证规则的动态列表。

为真实方案编写对象模型会引发一些当前演示和教程无法解决的问题。在本文中,我将挑战此问题,并讨论如何构建 Customer 类,我会就此简要介绍众多设计模式和设计实践,例如团体模式、聚合根、工厂以及代码协定和企业库验证应用程序块 (VAB) 等技术。

为获得相应参考,建议您查看相应的开放源项目,因为此处讨论的代码只是其中的一小部分。由 Andrea Saltarello 创建的 Northwind 入门工具包项目 (nsk.codeplex.com) 旨在介绍可构建多层解决方案的有效实践。

对象模型与域模型

讨论是使用对象模型还是域模型似乎并没有意义,在大多数情况下,这只是一个术语表述问题。但准确地使用术语是确保团队所有成员在使用特定术语时始终遵循同一概念的重要因素。

对于软件行业的几乎每个人而言,对象模型是一个可能与对象相关的泛型集合。域模型有何不同?域模型归根结底仍然是一个对象模型,因此,交替使用这两个术语可能不会产生严重的错误。但在专门强调使用“域模型”一词时,它可能仍会使大家对有关构成对象的形状产生某些期望。

域模型的这种用法与 Martin Fowler 给出的以下定义相关联:合并行为和数据的域的对象模型。相应地,该行为同时表示规则和特定逻辑(请参阅 bit.ly/6Ol6uQ)。

DDD 向域模型中添加了一组切实有效的规则。根据此观点,在广泛使用建议代替基元类型的值对象时,域模型不同于对象模型。例如,一个整数可能具有多种含义,它可能表示温度、金额、大小或数量。域模型针对各种不同的方案使用特定的值对象类型。

此外,域模型可识别聚合根。聚合根是一个通过组合其他实体而获取的实体。聚合根中的对象不具有外部相关性,即,在其中使用对象但不从根对象传递这些对象的用例不存在。比如,Order 实体就是一个典型的聚合根。Order 包含聚合 OrderItem,而不包含 Product。难以想象您必须使用 OrderItem 而它并不来自 Order,即使这将仅由您所使用的规范所确定。另一方面,您很可能具有这样一些用例,即,您在其中使用不涉及订单的 Product 实体。聚合根负责维护其处于有效状态的子对象并保留这些对象。

最后,某些域模型类可以提供用于创建新实例的公共工厂方法,而不是构造函数。如果类通常是独立的并且实际上不是层次结构的一部分,或者用于创建该类的步骤只是与客户端相关,则可以使用普通的构造函数。但是,在使用聚合根这样的复杂对象时,您还需要实例化之外的其他抽象级别。DDD 引入了工厂对象(简单的说,即某些类中的工厂方法)方式,这种方式可将客户端要求与内部对象及其关系和规则分离开来。可以在 bit.ly/oxoJD9 中找到有关 DDD 的清晰简明的介绍。

团体模式

让我们着重了解一下 Customer 类。根据上文所述,此处是可能的签名:

public class Customer : Organization, IAggregateRoot
{
  ...
}

谁是您的客户? 它是个人和/或组织? 团体模式建议您区别这两者,并清晰地定义哪些属性是公用属性,哪些属性仅属于个人或组织。 图 1 中的代码仅限于 Person 和 Organization;您可以根据业务领域的需要,将组织细分为非盈利组织和商业公司,从而细化代码内容。

图 1 基于团体模式的类

public abstract class Party
{
  public virtual String Name { get; set; }
  public virtual PostalAddress MainPostalAddress { get; set; }
}
public abstract class Person : Party
{
  public virtual String Surname { get; set; }
  public virtual DateTime BirthDate { get; set; }
  public virtual String Ssn { get; set; }
}
public abstract class Organization : Party
{
  public virtual String VatId { get; set; }
}

您必须始终记住,您的目标是生成一个可为您的实际业务领域精确建模的模型,而不是生成该业务的抽象表示。 如果您的要求只涉及作为个体的客户,那么并不完全需要应用团体模式,即使该模式引入后续可扩展性也是如此。

作为聚合根类的客户

聚合根是模型中的一个类,它表示相对于其他实体不存在的独立实体。 在大多数情况下,您的聚合根只是单独的类,这些类不管理任何子对象,或者只是指向其他聚合的根。 图 2 详细显示了 Customer 类。

图 2 作为聚合根的 Customer 类

public class Customer : Organization, IAggregateRoot
{
  public static Customer CreateNewCustomer(
    String id, String companyName, String contactName)
  {
    ...
}
 
  protected Customer()
  {
  }
 
  public virtual String Id { get; set; }
    ...
public virtual IEnumerable<Order> Orders
  {
    get { return _Orders; }
  }
   
  Boolean IAggregateRoot.CanBeSaved
  {
    get { return IsValidForRegistration; }
  }
 
  Boolean IAggregateRoot.CanBeDeleted
  {
    get { return true; }
  }
}

正如您所看到的,Customer 类实现(自定义)IAggregateRoot 接口。 这就是该接口:

public interface IAggregateRoot
{
  Boolean CanBeSaved { get; }
  Boolean CanBeDeleted { get; }
}

成为聚合根意味着什么? 聚合根处理其子聚合对象的持久性,并负责强制实施与组相关的固定条件。 事实证明,聚合根应该能够检查能否保存或删除整个堆栈。 独立聚合根只返回 True,而不进行任何进一步检查。

工厂和构造函数

构造函数是特定于类型的。 如果对象只是一个类型(没有聚合并且没有复杂的初始化逻辑),那么使用普通的构造函数会更好。 但是,工厂通常是一个有用的额外抽象层。 工厂在实体类中可以是一个简单的静态方法,也可以是其自己的一个单独组件。 使用工厂方法还有助于实现可读性,因为它清楚地指明为何要创建该指定实例。 如果使用构造函数,那么您在处理不同实例化方案时将受到更多限制,因为构造函数不是已命名的方法,并且只能通过签名来识别它。 特别是对长签名,很难在稍后指出为何要获取特定实例。 图 3 显示了 Customer 类中的工厂方法。

图 3 Customer 类中的工厂方法

public static Customer CreateNewCustomer(
  String id, String companyName, String contactName)
{
  Contract.Requires<ArgumentNullException>(
           id != null, "id");
  Contract.Requires<ArgumentException>(
           !String.IsNullOrWhiteSpace(id), "id");
  Contract.Requires<ArgumentNullException>(
           companyName != null, "companyName");
  Contract.Requires<ArgumentException>(
           !String.IsNullOrWhiteSpace(companyName), "companyName");
  Contract.Requires<ArgumentNullException>(
           contactName != null, "contactName");               
  Contract.Requires<ArgumentException>(
           !String.IsNullOrWhiteSpace(contactName), "contactName");
 
  var c = new Customer
              {
                Id = id,
                Name = companyName,
                  Orders = new List<Order>(),
                ContactInfo = new ContactInfo
                              {
                                 ContactName = contactName
                              }
              };
  return c;
}

工厂方法是一个原子,可获取输入参数、执行其作业并返回指定类型的新实例。 应确保返回的实例处于有效状态。 工厂负责履行所有已定义的内部验证规则。

工厂还需要验证输入数据。 为此,可使用代码约定前提条件来保证代码的清晰和高度可读性。 还可以使用后置条件来确保返回的实例处于有效状态,如下所示:

Contract.Ensures(Contract.Result<Customer>().IsValid());

如果在整个类中使用固定条件,则经验表明,您无法始终提供这些固定条件。 固定条件的侵入性可能太强,特别是在复杂的大型模型中。 代码约定固定条件有时可能过于严格地遵循规则集,但在您的代码中,有时需要更多的灵活性。 因此,最好对必须强制执行固定条件的区域进行限制。

验证

可能需要验证域类中的属性,以确保没有保留为空的必需字段,没有将过长的文本放在受限容器中,并且相关值处于适当的范围内等等。 您还必须考虑进行跨属性验证以及复杂的业务规则。 如何进行代码验证?

验证涉及条件代码,最终涉及组合某些 if 语句,并返回布尔值。 使用明码编写验证层,并且可能不使用任何框架或技术,但这实际上并不是一个好主意。 所获得的代码的可读性不是很好,并且不便于更深入开发,但某些内容流畅的库正在改善这种情况。 受实际业务规则的限制,验证过程可能非常不稳定,您的实现必须考虑到这一点。 因此,您不能只编写验证代码,而是应该编写开放代码以便根据不同的规则验证同一数据。

在验证过程中,有时您希望了解是否传递了无效数据,而有时您只希望收集相关错误并将其报告给其他代码层。 记住,代码约定不参与验证过程,它们检查各种条件,然后在条件不适用时引发异常。 通过集中式错误处理程序,您可以从异常中进行恢复并妥善降级。 通常,建议仅在域实体中使用代码约定,以便捕获可能导致出现不一致情况的潜在严重错误。 也可以在工厂中使用代码约定,在这种情况下,如果传递的数据无效,则代码必须引发异常。 是否在属性的 setter 方法中使用代码约定由您自己决定。 我更喜欢采用更舒适的方式,通过属性进行验证。 但可使用哪些属性呢?

数据批注与 VAB

数据批注命名空间和企业库 VBA 非常类似。 这两种框架均基于属性,可使用表示自定义规则的自定义类对其进行扩展。 在这两种情况下,您可以定义跨属性验证。 最后,这两种框架都具有验证程序 API,以评估实例并返回错误列表。 两者有何区别?

数据批注是 Microsoft .NET Framework 的一部分,不需要单独下载。 企业库是单独下载内容;在大型项目中并不重要,但它在企业方案中可能需要批准,因此仍会产生问题。 可以通过 NuGet 轻松安装企业库(请参阅本期专栏中的“使用 NuGet 管理项目库”一文)。

企业库 VBA 在以下方面优于数据批注:可以通过 XML 规则集对其进行配置。 XML 规则集是您用于描述所需验证的配置文件中的条目。 不用说,您能够以声明方式更改某些内容,甚至无需改动代码。 图 4 显示了一个示例规则集。

图 4 企业库规则集

<validation>
   <type assemblyName="..." name="ValidModel1.Domain.Customer">
     <ruleset name="IsValidForRegistration">
       <properties>
         <property name="CompanyName">
           <validator negated="false"
                      messageTemplate="The company name cannot be null" 
                      type="NotNullValidator" />
           <validator lowerBound="6" lowerBoundType="Ignore"
                      upperBound="40" upperBoundType="Inclusive" 
                      negated="false"
                      messageTemplate="Company name cannot be longer ..."
                      type="StringLengthValidator" />
         </property>
         <property name="Id">
           <validator negated="false"
                      messageTemplate="The customer ID cannot be null"
                      type="NotNullValidator" />
         </property>
         <property name="PhoneNumber">
           <validator negated="false"
                      type="NotNullValidator" />
           <validator lowerBound="0" lowerBoundType="Ignore"
                      upperBound="24" upperBoundType="Inclusive"
                      negated="false"
                      type="StringLengthValidator" />
         </property>
         <property name="FaxNumber">
           <validator negated="false"
                      type="NotNullValidator" />
           <validator lowerBound="0" lowerBoundType="Ignore"
                      upperBound="24" upperBoundType="Inclusive"
                      negated="false"
                      type="StringLengthValidator" />
         </property>
       </properties>
     </ruleset>
   </type>
 </validation>

规则集列出了您要应用于指定类型中的指定属性 (Property) 的属性 (Attribute)。 在代码中,您可以按如下所示验证规则集:

public virtual ValidationResults ValidateForRegistration()
{
  var validator = ValidationFactory
          .CreateValidator<Customer>("IsValidForRegistration");
  var results = validator.Validate(this);
  return results;
}

该方法将 IsValidForRegistration 规则集中列出的验证程序应用于指定实例。

关于验证和库的最后一点说明。 我在这里没有谈及每个常用的验证库,但它们之间并没有明显的区别。 重要的是考虑您的业务规则是否发生了更改及更改频率如何。 您可以在此基础上决定是数据批注、VBA、代码约定还是其他某个库更合适。 根据我的经验,如果您确切知道所需实现的目标,则可以轻松地选择“正确”的验证库。

总结

用于真实业务领域的对象模型几乎不能是属性和类的普通集合。 此外,在考虑技术问题之前应先考虑设计方面的事项。 一个设计完好的对象模型可以表达该领域所需的方方面面。 在大多数时候,这表示需要能够轻松进行初始化和验证,以及在属性和逻辑中大量使用的类。 不应该教条式地看待 DDD 实践,它应该成为明确前进方向的指南。

Dino Esposito 是《Programming Microsoft ASP.NET 4》(Microsoft Press,2011 年)和《Programming Microsoft ASP.NET MVC》(Microsoft Press,2011 年)的作者,同时也是《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2008 年)的合著者。 Esposito 定居于意大利,经常在世界各地的业内活动中发表演讲。 有关他的情况,请访问 Twitter 上的 twitter.com/despos

衷心感谢以下技术专家对本文的审阅: *Manuel Fahndrich 和 *Andrea Saltarello