# 六.值对象
认识值类型的优点.
值类型用于度量和描述事务,我们可以非常容易的对值进行创建,测试,使用,优化和维护.
当你只关心某个对象的属性时候,该对象便可以作为一个值对象,为其添加有意义的属性,并赋予它相应的行为.我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性.
# 值对象的特征
- 它度量或者描述了领域中的一件东西.
- 它可以作为不变量.
- 它将不同的相关属性组合成一个概念整体.
- 当度量和描述改变时,可以用另一个值对象予以替换.
- 它可以和其他值对象进行相等性比较.
- 它不会对协作对象造成副作用.
# 度量或描述
当你的模型中的确存在一-个值对象时,不管你是否意识到,它都不应该成为你领域中的一-件东西,而只是用于度量或描述领域中某件东西的一个概念。一个人拥有年龄,这里的年龄并不是一个实在的东西, 而只是作为你出生了多少年的一种度量。一个人拥有名字,同样这里的名字也不是个实在的东西,而是描述了如何称呼这个人。 该特征和下面的“概念整体”特征是紧密联系在一起的。
# 概念整体
一个值对象可以只处理单个属性,也可以处理一组相关联的属性.在这组相关联的属性中,每一个属性都是整体属性不可或缺的一部分,这和简单的将一组属性装在对象中是不同的,如果一组属性联合起来不能表达一个整体上的概念,那么这种联合并无多大用处.
例:
整体值对象,{500,美元}具有两个属性,一个是500,一个是美元,单独的500,可能表达其他意思,单独美元更不能表示该值对象,只有这两个联合起来,才是表达货币度量的概念.因此这两个属性(amount,currency)并不应该独立.
//不正确建模的ThingOfWorth
public class ThingOfWorth {
private String name ;//描述属性
private BigDecimal amount; //描述属性
private String currency; //描述属性
// ...
}
/ /正确建模的ThingOfWorth
public class ThingOfWorth {
private ThingName name ;//资产属性
private MonetaryValue worth;//资产属性
//...
}
public final class MonetaryValue implements Serializable{
(
private BigDecimal amount;
//这里MonetaryValue并不是完美的,只是用于讲解整体值对象.我们还可以将currency的String 换为Currency类型值对象,
private String currency;
public MonetaryValue (BigDecimal anAmount, String aCurrency) {
this.setAmount(anAmount) ;
this.setCurrency(aCurrency) ;
}
//....
}
//客户端尝试自己解决name大小写的问题,通过定义ThingName类型,我们可以将于name相关的逻辑集中在一起,
ThingName {
/ /有客户端处理命名相关逻辑
String name = thingOfWorth. name () ;
String capital izedName = name . substring(0, 1) . toUpperCase () + name . substring(1) . toLowerCase () ;
}
此时,ThingsOfWorth并没有直接包含三个毫无意义的描述属性,而是包含了两个具有专属类型的资产属性.
# 不变性
一个值对象在创建之后就不能改变了,例如使用构造方法来创建对象实例,此时传入的参数包含该值对象的所有状态所需的数据信息,所传入的参数既可以作为该值对象的直接属性,也可以用于计算出新属性.
(有时值对象是可以改变的,但是非常罕见,这里不讨论.)
仅凭借初始化还不能保证值对象不变性,初始化后,任何方法都不能对改对象的属性进行修改,所以应保证构造方法中的set方法也是private的,外接不能直接调用.
# 可替换性
当实体引用的值对象无法正确表达当前的状态,那么需要将峥哥值对象进行替换为另一个新的值对象.
例如:在这里,我们不会把3修改成4,而是,直接将total的值从3替换成了4.
int total = 3;
//稍后,需要重新设置为4
total = 4;
我们只是将total的值从3替换成了4。这并不是过度简化,而正是值对象替换的工作方式.
更复杂的例子:这里,当变成全名的时候,我们没有将name进行改变.而是将name重新赋值新的name.
FullName name = new FullName("Vaughn", "Vernon") ;
//稍后...
name = new FullName("Vaughn", "L", "Vernon") ;
(这种方式表达性不强,下面我们会讲到更好的替换方法.)
# 值对象相等性
在比较两个值对象例时,我们需要检查这两个值对象的相等性.
public boolean equals(object an0bject) {
boolean equalObjects = false;
if (anObject != null &&
this.getClass() == anObject.getClass()) {
FullName typedobject = (FullName) anObject;
equalobjects =
this.firstName().equals(typedObject.firstName()) &&
this.lastName().equals(typedobject.lastName());
}
return equalobjects;
}
由于FullName在创建时候就进行了属性的非Null验证,这里equals就没必要再验证非Null了,
# 无副作用行为
值对象模式的一个功能强大的特性-无副作用函数.
一个对象的方法可以设计成一个无副作用函数,这里的函数表示对某个对象的操作,它只用于产生输出,而不会修改对象的状态。由于在函数执行的过程中没有状态改变,这样的函数操作也称为无副作用函数。对于不变的值对象而言,所有的方法都必须是无副作用函数,因为它们不能破坏值对象的不变性。
函数式编程
函数式编程语言通常都强制性地保留了这种特性。事实上,纯函数式语言只允许有无副作用行为存在,并且要求所有的闭包只能接受和产生不变的值对象。
例:调用无副作用方法将该对象本身替换.
FullName name = new FullName("Vaughn", "Vernon") ;
//稍后...
name = name.withMiddleInitial("L") ;
这个和先前的"可替换性"一节的例子产生的结果是一样的,但是代码更具有表达性,它的内部实现.
public FullName withMiddleInitial(String aMiddleNameOrInitial) {
if (aMiddleNameOrInitial == null) {
throw new IllegalArgumentException(
"Must provide a middle name orinitial.");
}
String middle = aMiddleNameOrInitial.trim();
if (middle.isEmpty()) {
throw new IllegalArgumentException(
"Must provide a middle name orinitial.");
}
return new FullName(
this.firstName(),
middle.substring(0, 1).toUpperCase(),
this.lastName());
}
}
此外,withMiddleInitial()方法还捕获到了重要的领域业务逻辑,从而避免了将这些逻辑泄漏到客户端。
# 当值对象引入实体对象
一个值对象允许对传入的实体对象进行修改吗?如果值对象中的确有方法会修改实体对象,那么该方法还是无副作用的吗?该方法容易测试吗?我会说,既容易,也不容易。因此,如果一个值对象方法将一一个实体对象作为参数时,最好的方式是,让实体对象使用该方法的返回结果来修改其自身的状态。
float priority = businessPriority.priorityOf(product) ;
这里本身就存在一些问题.
- 这里BusinessPriority值对象不仅依赖了Product,还试图去理解实体的内部状态,我们应该使值对象尽量只依赖它自己的属性,只理解自身的状态,(虽然有时候不可行,但)这是我们的目标.
- 阅读本段代码,并不知道使用了Product的哪一部分,表达不明确,降低模型清晰性.更好是只传入用到的部分.
- 更重要的是,在将实体作为参数的值对象方法中,我们很难看出该方法是否会对实体进行修改,测试也将变得困难.即使一个值对象承诺不会修改实体,我们也很难证明.
# 最小化集成
在所有的DDD项目中,通常存在多个限界上下文,这意味着我们需要找到合适的方法对这些上下文进行集成。当模型概念从上游下文流人下游上下文中时,尽量使用值对象来表示这些概念。这样的好处是可以达到最小化集成,即可以最小化下游模型中用于管理职责的属性数目。使用不变的值对象使得我们做更少的职责假设。
# 用值对象表示标准类型
你的通用语言定义了一个PhoneNumber值对象,同时需要为每个PhoneNumber对象制定一个类型。 “这个号码是家庭电话、移动电话、工作电话还是其他类型的电话号码?”不同类型的电话号码类型需要建模成一种类的层级关系吗?为每一个类型创建一个类对于客户端使用来说是非常困难的。此时,你需要的是使用标准类型来描述不同类型的电话号码,比如Home、Mobile、Work或者Other。 正如先前所讨论的,在一个金融领域中,我们需要一个Currency值对象来表示一个MonetaryValue对象的货币类型。在这个例子中, 一个标准类型可以用于表示AUD、CAD、CNY. EUR. GBP、JPY和USD等货币类型。使用标准类型可以避免伪造货币。虽然一个不正确的货币类型可能被赋给MonetoryValue,但是一个不存在的货币类型是不能的。如果使用字符串属性来表示货币类型,那么便有可能导致一种不正确的状态。想想如果将表示美元的dollar拼写成了doolar,结果会怎么样?
Java的枚举是实现标准类型的一种简单方法。枚举提供了一组有限数量的值对象,它是非常轻量的,并且无副作用。
public enum GroupMemberType {
GROUP {
public boolean isGroup() {
return true;
}
},
USER {
public boolean isUser() {
return true;
}
};
public boolean isGroup() {
return false;
}
public boolean isUser() {
return false;
}
}
protected GroupMember toGroupMember() {
GroupMember groupMember =
new GroupMember(
this.tenantId(),
this.username(),
GroupMemberType.USER);//枚举标准类型
return groupMember;
}
# 测试值对象
# 实现
BusinesePrioriy示例.它向我们展示了不变性,概念整体,可替换性,相等性,无副作用行为.以及"策略模式".
一般来说,至少为值对象创建两个构造函数,
- 接受用于构建对象状态的所有属性参数,是主要构造函数,调用私有的setter方法,
- 第二个构造函数用于将一个值对象复制到另一个新的值对象,即复制构造函数。该构造函数采用浅复制(Shallow Copy)的方式,因为它也是将构造过程委派给主构造函数的,先从原对象中取出各个属性值,再将这些属性值作为参数传给主构造函数。当然,我们也可以采用深复制(Deep Copy) 或者克隆(clone) 的方式,即为每个所引用的属性都创建一份其自身的备份。然而这种方式既复杂,也没有必要。当需要深度复制时,我们才考虑添加该功能。但是对于不变的值对象来说,在不同的实例间共享属性是不会出现什么问题的。
/*
团队成员决定使该值对象实现Serializable接口。有时,一个值对象实例是需要序列化的,比如当和某个远程系统通信时。另外,当需要对值对象持久化时,序列化也是有帮助的。
BusinessPriority自身维护了一个类型为BusinessPriorityRatings的值对象属性ratings。该属性描述了一个待定项的业务价值,它向BusinessPriority提供了benefit. cost. penalty和risk 等排名信息,使得我们可以在BusinessPriority上进行不同类型的计算。
*/
public final class BusinessPriority implements Serializable {
private static final long serialVersionUID = 1L;
private BusinessPriorityRatings ratings;
public BusinessPriority(BusinessPriorityRatings aRatings) {
super();
this.setRatings(aRatings);
}
public BusinessPriority(BusinessPriority aBusinessPriority) {
this(aBusinessPriority.ratings());
}
}
# 持久化值对象
# 拒绝由数据库建模泄漏带来的不利影响
多数时候,持久化值对象时候,都是一种非范式方式完成的,即所有属性和实体对象都保存在相同的数据库表中,这样可以优化对值对象的保存和读取,并且可以防止持久化逻辑泄漏到模型中.
但是,当聚合中维持了一个值对象集合时候,便会发生值对象需要以实体身份进行持久化,这是否意味着我们应该将领域对象建模成实体而不是值对象呢?当然不是。当你面临这种阻抗失配时,你应该从领域模型的角度,而不是持久化的角度去思考问题。要达到这样的目的,问问自己以下几个问题:
- 我当前所建模的概念表示领域中的一个东西呢,还是只是用于描述和度量其他东西?
- 如果该概念起描述作用,那么它是否满足先前所提到的值对象的几大特征?
- 将该概念建模成实体是不是只是持久化机制上的考虑?
- 将该概念建模成实体是不是因为它拥有唯一标识, 我们关注的是对象实例的个体性,并且需要在其整个生命周期中跟踪其变化?
如果你的答案是“描述,是的,是的,不是”,那么此时你应该使用值对象。我们不应该使持久化机制影响到对值对象的建模。
# orm与单个值对象
向数据库中保存单个值对象是非常直接的。基本的思路是将值对象与其所在的实体对象保存在同一张表中,值对象的每一个属性保存为一列, 换句话讲是通过一种非范式的方式将单个值对象与实体对象保存在相同的数据库记录中。此时采用标准的命名约定是有好处的。
# 多个值对象序列化到单个列中
使用ORM将多个值对象的集合映射到数据库中是困难的。我们这里所说的集合是指实体对象所引用的List或者Set,这些集合中可以包含零个、一个或多个值对象元素。当然困难也不是克服不了,但是这里的对象-关系阻抗失配表现得更加明显。
一种方法是将整个集合序列化形成某种文本.然后将此文本保存到单个列中.这种方式是存在缺点的,但是有时候和好处比起来,缺点就不那么突出了.此时便可以考虑这种方案.以下是这种方式的潜在缺点.
- 列宽:有时我们无法估计集合中元素最大数据量,或者序列化后的最大数量,或者值对象存在String类型,无法估计长度的,因为String没有长度上限,不管那种情况,都可能超过数据库最大列宽,例如,mysql的InnoDB引擎,varchar最大宽度65535字符,另外,整条数据也是65535,而oracle中,varchar2/nvarchar2最大为4000,如果我们无法估计,我们要避免.
- 必须查询:由于值对象集合被序列化到扁平化文本中,此时值对象属性不能用于sql查询了,所以如果其中一个属性有查询必要,则不能使用这种方案.当然,只是从一个集合中查询一个或多个属性,是比较少见的.
- 需要自定义类型:要采用这种方案,必须自定义类型来处理每个集合的序列化,反序列化,这和其他缺点比不那么突出,我们只需要一种自定义类型的实现便可以支持集合中的每种值对象类型.
# 使用数据库实体保存多个值对象
详见书中.
# 使用联合表保存多个值对象
...
# ORM与枚举状态对象
# 小结
- 学到了值对象的特征并如何用值对象.
- 学到了如何使用值对象来简化集成复杂性.
- 学到了使用值对象来表示领域标准类型以及如何实现.
- 如何测试,实现,持久化值对象.