《实现领域驱动设计》读书笔记(5) - 战术建模之值对象
系列大纲: 《实现领域驱动设计》读书笔记
本文大纲:
值对象用于度量和描述事物,即便一个领域概念必须建模成实体,在设计时也应该更偏向于将其作为值对象的容器,而不是子实体的容器
笔者曾一度认为值对象就是 C# 语言中使用
struct
结构来表示的多个数据代表一个整体的集合,后来发现书中讲到的值对象无关技术实现,而是从概念上定义它的职责,包括不可变性和非唯一性。
同样的,在有了实体这把武器之后,当一个实体需要嵌套其他对象时,实体经常遭到滥用。当面临将对象定义为实体还是值对象的选择时,由于缺乏对于值对象的充分认识,很多开发人员选择了嵌套实体。
不变性
当我们只关心某个对象的属性时,该对象便可作为一个值对象,为其添加有意义的属性,并赋予它们相应的行为。值对象在其生命周期中是「不可变」的,本身代表了某种状态,它没有任何身份标识,也应该尽量避免像实体一样复杂。在设计得当的前提下,我们可以对值对象的实例进行创建和传递,甚至在使用完之后将其直接扔掉。我们不必担心客户代码对值对象进行修改,一个值对象的生命周期可长可短,它就像一个无害的过客在系统中来来往往。
当决定一个领域概念是否是一个值对象时,考虑以下特征:
- 它度量或者描述了领域中的一件东西
- 它可以作为不变量
- 它将不同的相关的属性组合成一个概念性的整体
- 当度量和描述改变时,可以用另一个值对象予以替换
- 它可以和其他值对象进行相等性比较
- 它不会对协作对象造成副作用
为了保持值对象的不变性,创建它所依赖的参数必须一次性全部传给其构造函数,之后任何时间都不可能再改变它。有时根据需要,会在值对象中引用实体对象,这种情况需要谨慎,当实体对象的状态发生改变时,引用它的值对象也将发生改变,这违背了值对象不变性特征。
概念整体
编程语言提供的基元类型(如 string
, int
, double
等)似乎是值对象的最佳类型,但有时,这种思维方式会造成对基元类型的滥用。
假如需要在 「ThingOfWorth」 类中加入名为 「Name」 的属性,我们自然而然的会想到将其定义为 string
类型,但很快我们就发现该类型的名字需要以不同的方式进行展示,此时,处理展示方式的逻辑就会莫名其妙的由客户代码来完成,例如:
1 | // 客户代码 |
在以上示例中,客户代码自己试图解决 name
的大小写问题。通过定义 「ThingName」 类型,我们可以将与 name
有关的所有逻辑操作放到该类型中,然后在构造该值对象时进行格式化,客户代码只需调用相应的方法即可得到结果,而不必自行处理这些逻辑。
有些编程语言允许我们简单地向一个类添加新的行为(例如 C# 的扩展方法)。此时,你可能会想着用 Double
类型来表示货币,如果需要计算不同货币之间的汇率,我们只需要向 Double
类型添加 convertToCurrency(Currency aCurrency)
扩展方法即可。但是在这种场景下使用语言特性就一定是一个好主意吗?首先,和货币相关的行为很有可能丢失在浮点数计算中;其次,Double
类型也丝毫没有表达出领域概念。很快,我们就会丢掉领域关注点。
当你试图将多个属性加在一个实体上,这有可能弱化了各个属性之间的关系,那么此时就应该考虑将这些相互关联的属性组合在一个值对象中了。每个值对象都是一个「内聚的概念整体」,它表达了通用语言中的一个概念。
可替换性
值对象的可替换性可通过数字的替换来理解,假设领域中有一个名为 total
的概念,该概念用整数表示。如果 total
的当前值为 3
,但是之后需要重设为 4
,此时我们并不会将整数修改成 4
,而是简单地将 total
的值重新赋值为 4
。
从语言层面来说,这里的修改其实是对该属性赋新值,但看上去像是修改,实际上只是语法糖,原先为
3
的内存并不会被修改为4
,而是被新的代表4
的内存块替代。
考虑下面一种更复杂的值对象替换:
1 | FullName name = new FullName("金","沐"); |
这里,我们并没有使用 FullName 类型的某个方法来修改其自身的状态(这破坏了值对象的不变性),而是构造一个新的值对象实例来替换原来的实例。
值对象相等性
值对象的相等性应该由组成其实例的每一个属性及其类型来决定,在上文的 「FullName」 对象中,当两个 「FullName」 实例的每个属性及其类型都相等,我们才认为两个实例相等,尽管他们在内存中是不同的地址。值对象的相等性可用来支撑「聚合」唯一标识的比较,实体的唯一标识是不能改变的,这可以部分通过值对象的不变性实现。值对象的整体概念也可以用来支撑不只一个属性的实体标识,同时,如果实体的唯一标识需要一些「无副作用行为」,这些行为便可以在值对象上实现。
无副作用行为
一个对象的方法可以设计成一个「无副作用函数(Side-Effect Free Function)」,该函数表示对某个对象的操作,只用于产生输出,而不会修改对象的状态。对于不变的值对象而言,所有的方法都必须是无副作用函数。下面的例子通过调用 「FullName」 对象上的无副作用方法将该对象本身替换成另一个实例:
1 | FullName name = new FullName("金","沐"); |
这里的代码更具表达性,withMiddleInitial
方法并没有修改值对象的状态,因此它不会产生副作用。该方法通过已有 firstName
和 lastName,外加传入的
middleName
创建一个新的 FullName
值对象实例。withMiddleInitial()
还捕获到了重要的领域业务逻辑,从而避免了将这些逻辑泄漏到客户代码中。
这里所说的捕获重要的领域业务逻辑,是指该方法本身是具有表达性的,比起使用
new
语句创建实例,更像是调用了该实例支持的某个行为满足了客户代码的需求。
有些值对象的方法引用了实体,这存在一些问题。例如下面的代码,我们有一个实体对象 product
,该对象被值对象 BusinessPriority
引用。
1 | float priority = businessPriority.priorityOf(product); |
我们至少可以看出以下问题:
BusinessPriority
不仅依赖Product
类型,还试图去理解该实体的内部状态,我们应该尽量使值对象只依赖于它自己的属性,并且只理解它自身的状态。- 阅读本段代码的人并不知道使用了
Product
的哪些部分,这种表达方法并不明确,从而降低了模型的清晰度。更好的方式是只传入需要用到的Product
属性。 - 更重要的是,在将实体作为参数的值对象方法中,我们很难看出该方法是否会对实体进行修改,测试也将变得非常困难。
有了以上分析,我们需要对值对象进行改进,要增加一个值对象的健壮性,我们传给值对象方法的参数依然应该是值对象。这样我们可以获得更高层次的无副作用行为:
1 | float priority = businessPriority.priority(product.businessPriorityTotals()); |
这里,我们把 Product
实体的 BusinessPriorityTotals
值对象传递给了 priority()
方法。
如果打算使用编程语言提供的基本值对象类型,而不使用特定的值对象,我们是无法将领域特定的无副作用函数分配给编程语言提供的基元值对象的。有些真正简单的属性是没有必要特殊对待的。例如,一些布尔类型或数值类型,它们已经能够自给了,并不需要额外的功能支持,也并不和实体中的其他属性关联。这些简单的属性称为意义整体。
最小化集成
当模型概念从上游上下文流入下游上下文时,尽量使用值对象来表示这些概念。这样做的好处是可以达到最小化集成,即最小化下游模型中用于管理职责的属性数目。
用值对象表示标准类型
系统中既有表示事物的实体和描述实体的值对象,同时还存在「标准类型(Standard Type)」来区分不同的类型。假设通用语言中定义了一个 「PhoneNumber」 值对象,同时需要为每个 「PhoneNumber」 对象制定一个类型,用以区分家庭电话,移动电话,工作电话还是其他类型的电话号码。不同类型的电话号码类型需要建模成一种类的层级关系吗?为每一个类型创建一个类对于客户代码的使用来说是非常困难的。此时,你需要标准类型来描述不同的电话号码,比如 Home
,Mobile
,Work
或者 Other
。
枚举类型是实现标准类型的一种简单方法。枚举提供了一组有限数量的值对象,它非常轻量且无副作用。通常来说,没有必要为标准类型提供描述信息,只需要名字就足够了。为什么?文本描述通常只在用户界面层中才会用到,此时可以用一个显示资源和类型名字匹配起来。很多时候用于显示的文本都需要进行本地化,因此将这种功能放在模型中并不合适。通常来说,在模型中使用标准类型的名字是最好的方式。
为了维护方便,最好是为标准类型创建单独的限界上下文。
有些标准类型所表达的概念不像是某种标准而更像是一种状态,此时标准类型实现为状态模式,但为每一种状态创建单独的类会使系统变得复杂。对于实体的状态类来说,有些行为来自于自身,有些继承自抽象基类,这一方面在子类和父类之间形成了紧耦合,另一方面使代码的可读性变差。如果你不打算使用状态模式,那么枚举可能是最简单的方法。
一个共享不变的值对象可以从持久化存储中获取,此时可以通过标准类型的「领域服务」或「工厂」来获取值对象。我们应该为每组标准类型创建一个领域服务或工厂(比如一个服务处理电话号码类型,一个服务处理邮寄地址类型,另一个服务处理货币类型),服务或工厂将按需从持久化存储中获取标准类型,而客户方代码并不知道这些标准类型是来自数据库中的。另外,使用领域服务或工厂还使得我们可以加入不同的缓存机制,由于值对象在数据库中是只读的,并且在整个系统中是不变的,缓存机制也将变得更加简单安全。
总的来说,建议尽量使用枚举来表示标准类型,即便你认为某个标准类型更像一种状态模式。
实现
通常来说,值对象至少包含两个构造函数,第一个构造函数接受用于构建对象状态的所有属性参数,称为主构造函数。该构造函数调用私有的 setter
方法初始化默认的对象状态,该私有的 setter
方法向我们展示了一种自委派性。
只有主构造函数才能使用自委派性来设置属性值,除此之外,其他任何方法都不能使用
setter
方法。由于所有的setter
方法都是私有的,消费方是没有机会调用这些方法的,这是保持值对象不变性的两个重要因素。
第二个构造函数用于将一个值对象复制到另一个新的值对象,即复制构造函数。它将构造过程委派给主构造函数,先从原对象中取出各个属性,再将这些属性作为参数传给主构造函数。
复制构造函数对于测试来说是非常重要的,测试对象时,我们希望验证值对象的不变性,通过复制构造函数创建一个原实例的副本,验证两者的相等性。
持久化值对象
以下着重讨论如何持久化包含值对象的聚合实例。聚合的读取和保存通过资源库完成。
有时,值对象需要以实体的身份进行持久化。换句话说,某个值对象实例会单独占据一张表中的某条记录,而该表也是专门为这个值对象类型而设计的,它甚至拥有自己的主键列。当面临「对象 - 关系阻抗失配」时,考虑以下几个问题:
- 我当前所建模的概念表示领域中的一个东西呢,还是只是用于描述和度量其他东西?
- 如果该概念起描述作用,那么它是否满足值对象的几个特征?
- 将该概念建模成实体是不是只是持久化上的考虑?
- 将该概念建模成实体是不是因为它拥有唯一标识,我们关注的是对象实例的个体性,并且需要在其整个生命周期中跟踪其变化?
我们不应该使持久化机制影响到对值对象的建模。无论使用什么技术来完成数据建模,数据库实体,主键,引用完整性和索引都不能用来驱动你对领域概念的建模。
单个值对象
当实体包含单个值对象,值对象的属性需要和包含它的实体保存在一张数据表中时,其列名最好采用与数据库一致的形式,例如:
1 | BusinessPriority.Ratings.Benefit |
值对象集合序列化到单个列中
将一个 List 或 Set 的值对象保存在单个列中需要考虑以下问题:
- 列宽:有些对象集合可以包含任意多个元素,但数据库的列宽是有限制的。
- 查询:如果需要对该集合中的元素进行查询,无法用 SQL 语句实现,但从一个集合中查询一个或多个属性是比较少见的情况。
- 序列化器和反序列化器:需要自定义类型来实现序列化器和反序列化器,这只是增加了工作量。
使用数据库实体保存多个值对象
我们不能因为某个概念非常符合数据库实体而将其建模成领域模型中的实体。有时,是对象 - 关系阻抗失配需要我们采用这种方法,但这绝非 DDD 原则。要实现这种方案,我们可以采用「层超类型」,或又名「**委派身份标识(主键)**」。下面的例子使用了两层层超类型:
1 | public abstract class IdentifiedDomainObject: ISerializable |
接下来定义另一层层超类型,该层超类型是值对象专属的:
1 | public abstract class IdentifiedValueObject: IdentifiedDomainObject |
虽然 IdentifiedValueObject 什么也不做,但它显式地表明了建模意图。IdentifiedValueObject 还应该有另外一个专属于实体的抽象子类 Entity。现在,每一个值对象类型都可以方便地获得一个隐藏的委派主键,我们可以自由地将其映射成数据库实体,而在领域模型中将其建模成值对象。
委派标识主要用于数据建模,其没有领域模型含义,这里更多是说明当实体包含值对象集合并且需要对其进行查询时如何对它们进行持久化,这样的值对象在数据库中会有一张单独的表,但这并不代表他们就是领域模型中的实体。
ORM 与枚举状态对象
参考 《实现领域驱动设计》 P230