Dino Esposito: 一个领域模型的设计

  英文原文:Design of a Domain Model

  最新发布的 Entity Framework 4.1 和新的 Code First 开发模式打破了服务器程序开发的基本规则:如果数据库没有准备就绪,不要轻举妄动(Don’t take a single step)Code First 允许开发人员重点关注业务领域并根据“类”(class)来为该领域建模。在某种程度上, Code First 模式鼓励在 .NET 环境中应用“领域驱动设计 (DDD) ”原则。业务领域由相互关联的实体构成,这些实体通过属性对外公开自己的数据,通过方法和事件对外公开自己的行为。更重要的是,每个实体都可能处于某一状态,并且与一组动态的验证规则相绑定。

  为实际应用场景编写对象模型会面临一些在演示程序和教程中没有涉及的问题。在本文中,我将挑战这些问题,并讨论如何构建 Customer 类,我会就此简要介绍一些设计模式和设计实践,例如Party模式、聚合根(aggregate roots)、工厂(factories)以及代码协定(Code Contracts)和企业库验证应用程序块 (VAB) 等技术。

  有一个开源项目可以作为参考,这里讨论的代码就是其中的一小部分。 它就是由 Andrea Saltarello 创建的 Northwind Starter Kit 项目 (nsk.codeplex.com) ,该项目旨在介绍构建多层解决方案的有效实践。

  对象模型(Object Model) vs. 领域模型(Domain Model)

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

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

  领域模型的这种用法与 Martin Fowler 给出的以下定义相关:

由行为和数据组合而成的领域的对象模型。相应地,这些行为用于表达业务规则和特定的业务逻辑(请参阅 P of EAA page 116)。

An object model of the domain that incorporates both behavior and data. In turn, the behavior expresses both rules and specific logic.

  DDD 向领域模型中添加了一些实用的规则。从这个角度看,领域模型不同于对象模型,它更多推荐使用值对象(value objects)而不是基元类型(primitive types)。例如在对象模型中,一个整数可能具有多种含义,它可能表示温度、金额、大小或数量。而在领域模型中,针对各种不同的场景会使用特定的值对象类型。

  此外,领域模型需要识别出聚合根。聚合根是一个通过组合其他实体而得到的实体。聚合根中的对象与外部没有直接的关联,也就是不存在这样的用例——不经过根对象而直接使用这些对象。比如,Order 实体就是一个典型的聚合根。 Order 包含聚合的 OrderItem,而不包含 Product。 难以想象您使用一个OrderItem 而它并不来自 Order(即使这只是由specs决定的,译者注:也就是通过规约查询直接得到相应的OderItem)。另一方面,您很可能具有这样一些用例,您在其中使用不涉及订单的 Product 实体。聚合根负责维护处于有效状态的子对象并持久化这些对象。

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

  Party模式

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

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

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

  代码1 基于Party模式的类

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; }
}

  您必须始终记住,您的目标是构建一个可为您的实际业务领域精确建模的模型,而不是生成该业务的抽象表示。如果您的需求只涉及作为个体的客户(Customer),那么 Party 模式不是必需的,即使该模式带来了后续可扩展性。

  作为聚合根的Customer类

  聚合根是模型中的一个类,它表示一个独立的实体——在与其他实体的关系中并不存在(one that doesn’t exist in relation to other entities,译者注:也就是与其他实体不存在关联)。在大多数情况下,您的聚合根只是单独的类,这些类不管理任何子对象,或者只是指向其他聚合的根。 “代码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 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; }
}

  成为聚合根意味着什么? 聚合根处理所包含的子聚合对象的持久化,并负责强制实施与该组对象相关的不变条件( invariant conditions)。因此,聚合根应该能够检查整个聚合对象堆(stack)是否能被保存或删除。独立聚合根只返回 True,而不进行任何进一步检查。

  工厂和构造函数

  构造函数是特定于类型的。如果对象只是一个类型(没有聚合并且没有复杂的初始化逻辑),那么使用普通的构造函数会更好。工厂通常是一个有用的额外抽象层。工厂可以是实体类中的一个简单的静态方法,也可以是一个单独的组件。使用工厂方法还可以让代码更具可读性,因为通过它你可以清楚地知道为何要这样实例化。如果使用构造函数,那么您在处理不同实例化场景时将受到更多的限制,因为构造函数的方法名不能随意更改(只能与类同名),只能通过签名来识别它。特别是长签名(有很多参数的构造函数),在以后使用时会很难弄明白为什么要这样实例化。 “代码3”显示了 Customer 类中的工厂方法。

  代码3 Customer 类中的工厂方法

public static Customer CreateNewCustomer(
String id, String companyName, String contactName)
{
Contract.Requires(
id != null, "id");
Contract.Requires(
!String.IsNullOrWhiteSpace(id), "id");
Contract.Requires(
companyName != null, "companyName");
Contract.Requires(
!String.IsNullOrWhiteSpace(companyName), "companyName");
Contract.Requires(
contactName != null, "contactName");
Contract.Requires(
!String.IsNullOrWhiteSpace(contactName), "contactName");

var c = new Customer
{
Id = id,
Name = companyName,
Orders = new List(),
ContactInfo = new ContactInfo
{
ContactName = contactName
}
};
return c;
}

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

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

Contract.Ensures(Contract.Result().IsValid()); 

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

  验证

  可能需要验证领域类中的属性,以确保必填字段不为空,文本没有超出长度限制,并且相关数值处于规定的范围内等等。您还必须考虑进行跨属性验证以及复杂的业务规则。如何进行代码验证?

  验证涉及条件代码,最终涉及组合某些 if 语句,并返回布尔值。不借助任何框架或技术,纯手工编写验证层也许可行,但实际上并不是一个好主意。这样编写出来的代码的可读性和后续改进的方便性得不到保证,通过一些流畅的代码工具库(fluent libraries)可以改善这种情况。受实际业务规则的限制,验证规则可能会经常变化,您的实现必须考虑到这一点。因此,您不能只编写针对当前验证规则的代码,而是应该编写能够适应验证规则变化的更灵活的代码。

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

  Data Annotations(数据注解)与企业库VAB(Enterprise Library Validation Enterprise Block)

  Data Annotations 命名空间和企业库 VAB 非常类似。这两种框架均基于Attribute,可以使用表示自定义规则的自定义类对其进行扩展。在这两种情况下,您都可以定义跨属性(property)验证。最后,这两种框架都提供了验证API,用于评估实例并返回错误列表。这两者有何区别?

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

  企业库 VAB 在以下方面优于Data Annotations:可以通过 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("IsValidForRegistration");
var results = validator.Validate(this);
return results;
}

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

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

  总结

  用于真实业务领域的对象模型几乎不会仅仅是属性和类的简单集合。此外,在考虑技术问题之前应先考虑设计方面的事项。一个设计完好的对象模型可以表达该领域所需的方方面面。在大多数时候,这代表可以轻松地对“类”进行初始化和验证,可以方便地给“类”增加更多的属性与逻辑。不应该教条式地看待 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。

你可能感兴趣的:(DDD)