值对象的特征
首先,在将领域概念建模成值对象时,我们应该将通用语言考虑在内,这是建模值对象的首要原则,该原则将贯穿本章始末。
当你决定一个领域概念是否是一个值对象时,你需要考虑它是否拥有以下特征:
- 它度量或者描述了领域中的一件东西。
- 它可以作为不变量。
- 它将不同的相关的属性组合成一个概念整体(Conceptual Whole)。
- 当度量和描述改变时,可以用另一个值对象予以替换。
- 它可以和其他值对象进行相等性比较。
- 它不会对协作对象造成副作用[Evans]。
对于以上特征,我们将在下文中做详细讲解。在使用这种方法分析模型时,你会发现很多领域概念都可以设计成值对象,而不是你先前认为的实体对象。
度量或描述
当你的模型中的确存在一个值对象时,不管你是否意识到,它都不应该成为你领域中的一件东西,而只是用于度量或描述领域中某件东西的一个概念。一个人拥有年龄,这里的年龄并不是一个实在的东西,而只是作为你出生了多少年的一种度量。一个人拥有名字,同样这里的名字也不是一个实在的东西,而是描述了如何称呼这个人。
该特征和下面的“概念整体”特征是紧密联系在一起的。
不变性
一个值对象在创建之后便不能改变了。1例如,在使用Java或C#编程时,我们使用构造函数来创建值对象实例,此时传入的参数包含了该值对象的所有状态所需的数据信息。所传入的参数既可以作为该值对象的直接属性,也可以用于计算出新的属性。在下面的例子中,一个值对象将另外一个值对象作为属性:
1. 有时一个值对象是可以改变的, 但是这种情况非常特殊. 这里我们并不讨论可变的值对象. 如果你感兴趣, 可以参考[Evans]101页. ↩
package com.saasovation.agilepm.domain.model.product;
public final class BusinessPriority implements Serializable {
private BusinessPriorityRatings ratings;
public BusinessPriority(BusinessPriorityRatings aRatings) {
super();
this.setRatings(aRatings);
this.initialize();
}
...
}
光凭初始化是不能保证值对象的不变性的。在值对象初始化之后,任何方法都不能对该对象的属性状态进行修改。在上面的例子中,只有setRatings()和initializeO方法可以修改对象的状态,而它们只在对象构建过程中才被使用。方法setRatings()被声明为private,外界不能直接调用。2
2. 在有些情况下,一些框架——比如ORM或序列化类库(XML或JSON等)——可能需要setter方法来重建值对象。 ↩
此外,BusinessPriority必须保证除了构造函数之外,其他方法均不能调用setter 方法。之后我们还会讲到如何测试值对象的不变性。
根据需要,有时我们可以在值对象中维持对实体对象的引用。在这种情况下我们需要谨慎行事。当实体对象的状态发生改变时,引用它的值对象也将发生改变,由此违背了值对象的不变性。因此,在值对象中引用实体时,我们的出发点应该是不变性、表达性和方便性。否则,如果实体对象有可能违背值对象的不变性,那么我们便没有理由在值对象中引用实体对象。在本章的后续内容中,我们将讲到值对象的无副作用特征。
挑战你的假设
如果你认为一个值对象必须通过行为方法进行改变,那么你得问问自己这是否有必要。在这种情况下可以用其他值对象来替换吗?使用值对象替换可以简化设计。 有时将一个对象设计成不变对象是没有意义的,此时往往意味着该对象应该建糢成一个实体对象,请参考实体(5)。
概念整体
一个值对象可以只处理单个属性,也可以处理一组相关联的属性。在这组相关联的属性中,每一个属性都是整体属性所不可或缺的组成部分,这和简单地将一组属性组装在对象中是不同的。如果一组属性联合起来并不能表达一个整体上的概念,那么这种联合并无多大用处。
就像Ward Cunningham在他的整体值对象模式[Cunningham,Whole Value aka Value Object]中提到3,值对象{50,000,000美元俱有两个属性,一个是50,000,000, 一个是美元。单独一个50,000,000可能表示另外的意思,而单独一个“美元”更不能表示该值对象。只有当这两者联合起来才是一个表达货币度量的概念整体。因此我们并不希望将表示50,000,000的amount和表示美元的currency看作两个相对独立的属性,比如:
3. 有时也称为意义整体 ↩
// incorrectly modeled thing of worth
public class ThingOfWorth {
private String name; // attribute
private BigDecimal amount; // attribute
private String currency; // attribute
// ...
}
在上面的例子中,ThingOfWorth的客户端必须知道什么时候应该同时使用 amount和currency,并且还应该知道应该如何使用这两个属性,原因在于这两个属性并没有组成一个概念整体。我们需要更好的方式。
要正确地表达货币度量,我们不应该将以上两个属性分离开来,而应该将它们建模成一个整体值对象:{50,000,000美元}。下面的代码创建了一个整体值对象:
public final class MonetaryValue implements Serializable {
private BigDecimal amount;
private String currency;
public MonetaryValue(BigDecimal anAmount, String aCurrency) {
this.setAmount(anAmount);
this.setCurrency(aCurrency);
}
...
}
这并不是说MonetaryValue就是完美的,我们还可以用Currency值对象类型来表示货币单位。这里我们可以将currency属性从String类型替换成Currency类型。同时,我们还可以使用Factory和Builder[Gamma et al.]来创建该值对象,但是我们这里的重点在于讲解整体值对象。
在一个领域中,概念的整体性是非常重要的,因此作为整体值对象的 MonetaryValue已经不再单单是一个起描述作用的描述属性(Attribute) 了,而是一个资产属性(Property)。诚然,一个值对象可以拥有一个或多个描述属性(比如MonetaryValue拥有两个描述属性),但是对于持有该值对象实例的对象来说,该值对象便是一个资产属性。因此,一个价值50,000,000美元的物品一一ThingOfWorth——将拥有一个名为worth的资产属性——该属性即为一个表示{50,000,000美元}的值对象。请注意,这个资产属性的名字——worth——和该值对象类型的名字——MonetaryValue——只有在创建好了限界上下文(2)和通用语言之后才能确定。以下是一个改进后的例子:
//正确建模的ThingOfWorth
public class ThingOfWorth {
private ThingName name; // property
private MonetaryValue worth; // property
// ...
}
这里,一个ThingOfWorth对象拥有一个类型为MonetaryValue、名为worth的资产属性。该worth值对象即表示一种整体概念。
上面的代码还存在一点变化,这可能是你意料之外的。ThingOfWorth中的 name和worth同样重要,因此我们用ThingName类型取代了原来的String类型。虽然用String类型在一开始看来已经足够,但是在随后的迭代中,它将带来问题。围绕着name展开的领域逻辑有可能从ThingOfWorth模型中泄露出去,如下面的代码所示:
//有客户端处理命名相关逻辑
String name = thingOfWorth.name();
String capitalizedName = name.substring(0, 1).toUpperCase() + name.substring(1).toLowerCase();
在上面的代码中,客户端自己试图解决name的大小写问题。通过定义 ThingName类型,我们可以将与name有关的所有逻辑操作集中在一起。以上面的例子来说,ThingName可以在初始化时对name进行格式化,而不用客户端自身来处理。此时,ThinsOfWorth并没有直接包含3个毫无意义的描述属性,而是包含了2个具有专属类型的资产属性。
值对象的构造函数用于保证概念整体的有效性和不变性。我们希望值对象的构造函数可以一次性地构建好整个值对象。在初始化完成之后,我们便不允许对值对象做进一步修改了。上文中的BusinessPriority和MonetaryValue展示了这么一个过程。
还存在另一个层面的对基本值类型(比如String、Integer或Double)的滥用。有些编程语言(比如Ruby)允许我们简单地向一个类添加新的行为。此时,你可能会琢磨着使用浮点数来表示货币。如果需要计算不同货币之间的汇率,我们只需要向 Double 类加上 convertToCurrency(Currency aCurrency)行为方法即可。这可以是一种很炫的语言特性,但是在这种场景下使用语言特性就一定是一个好主意吗?首先,和货币相关的行为很有可能丢失在通用的浮点数计算中。另外,单单从Double类中我们也不能看出货币的概念。因此,我们需要向编程语言的默认类型中添加大量的信息来理解货币概念。毕竟,你需要传人一个Currency对象来通知Double类应该兑换成什么样的货币。更重要的是,Double类型丝毫没有表达出你的领域概念,由于没有使用通用语言,你很快就丢失掉了领域关注点。
挑战你的假设
如果你试图将多个属性加在一个实体上,但这样却弱化了各个属性之间的关系,那么此时你便应该考虑将这些相互关联的属性组合在一个值对象中了。每个值对象都是一个内聚的概念整体,它表达了通用语言中的一个概念。如果其中一个属性表达了一种描述性概念,那么我们应该把与该概念相关的所有属性集中起来。如果其中一个或多个属性发生了改变,那么可以考虑对整体值对象进行替换。
可替换性
在你的模型中,如果一个实体所引用的值对象能够正确地表达其当前的状态,那么这种引用关系可以一直维持下去。否则,我们需要将整个值对象替换成一个新的值对象实例。
值对象的可替换性可以通过数字的替换性来理解。假设领域中有一个名为 tota啲概念,该概念用整数表示。如果tota啲当前值被设成了3,但是之后需要重设为4,此时我们并不会将整数3修改成整数4,而是简单地将total的值重新赋值为4。
int total = 3;
//稍后...
total = 4;
这种替换值的方法是非常显然的,但是它却向我们展示了很重要的一点。在上例中,我们只是将total的值从3替换成了4。这并不是过度简化,而正是值对象替换工作方式。考虑下面一种更复杂的值对象替换:
FullName name = new FullName("Vaughn", "Vernon");
// later...
name = new FullName("Vaughn", "L", "Vernon");
首先,name通过firstName和lastName进行初始化,随后name变量被替换成了另一个FullName值对象实例,该实例中包含了firstName、middleName和lastName。这里,我们并没有使用FullName的某个方法来改变其自身的状态,因为这样破坏了值对象的不变性。我们使用了简单的替换将另一个FullName实例的引用重新赋给了name变量(这种方式的表达性并不强,我们将在下文讲到更好的替换方法)。
挑战你的假设
在有些情况下,值对象的属性将发生改变,如果此时你开始倾向于创建实体对象,那么你便需要* 战自己的极设了。思考一下,可以对整个值对象进行替换吗?考虑上文中的替换例子,你可能会认为创建一个新的值对象实例并不实用,并且缺乏表达性。即便你所处理的值对象非常复杂而又经常改变,我们依然可以对其进行替换。在下文中,我们将用一个例子来演示对整体值对象的替换,该替换过程是无副作用的、简单的,并且是富有表达性的。
值对象相等性
在比较两个值对象例时,我们需要检查这两个值对象的相等性。在整个系统中,有可能存在很多相等的值对象实例,但它们并不表示相同的实例引用。相等性通过比较两个对象的类型和属性来决定。如果两个对象的类型和属性都相等,那么这两个对象也是相等的。进而,如果两个或多个值对象实例是相等的,我们便可以用其中一个实例来替换另一个实例。
以下代码测试两个FullName值对象的相等性:
public boolean equals(Object anObject) {
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值对象实例中的每一个属性都与另一个FullName实例中的对应属性进行比较。如果两个对象中所有的属性都相等,那么我们便认为这两个值对象相等。由于FullName在创建时便对firstName和lastName进行了非null验证,这里在equals()方法中我们便没有必要再进行非mill验证了。另外,我们使用了属性的自封装性,即通过调用查询方法来获取某个属性。在Java中,equalsO方法和hashCodeO方法通常同时出现,我们将在下文中讲到hashCodeO方法。
思考一下,值对象的哪些特征可以用来支持聚合(1〇)的唯一标识性。我们需要值对象的相等性,比如在通过实体标识查询聚合时便会用到。同时,不变性也是重要的。实体的唯一标识是不能改变的,这可以部分地通过值对象的不变性达到。此外,我们还可以从值对象的概念整体特性中得到好处,因为实体的唯一标识是根据通用语言来命名的,并且需要在一个实例中包含所有的可以表示唯一标识的属性。然而,这里我们并不需要值对象的可替换性,因为我们不会替换聚合根的唯一标识。尽管如此,我们依然可以在聚合中使用值对象。此外,如果实体的唯一标识需要一些无副作用的行为,这些行为便可以在值对象上实现。
挑战你的假设
问问自己,你所设计的概念是否必须用实体来实现,是否从值对象中得到了足够的支持?如果该概■念不需要味一标识,那么请将其建模成一个值对象。
无副作用行为
一个对象的方法可以设计成一个无副作用函数(Side-Effect-Free Function) [Evans]。这里的函数表示对某个对象的操作,它只用于产生输出,而不会修改对象的状态。由于在函数执行的过程中没有状态改变,这样的函数操作也称为无副作用函数。对于不变的值对象而言,所有的方法都必须是无副作用函数,因为它们不能破坏值对象的不变性。你可以将这种特性看作是不变性的一部分,但是我更倾向于将该特性从不变性中分离出来,因为这样做可以强调出值对象的一大好处。否则,我们可能只会将值对象看成一个属性容器,而忽略了值对象模式的一个功能强大的特性一一无副作用函数。
函数式编程
函数式编程语言通常都强制性地保留了这种特性。事实上,纯函数式语言只允许有无副作用行为存在,并且要求所有的闭包只能接受和产生不变的值对象。
Bertrand Meyer在他的命令查询分离(CQS,请参考Martin Fowler的[Fowler, CQS])原则中,将无副作用函数描述为查询方法。一个查询方法即向某个对象问一个问题。根据定义,问题不应该对答案进行修改。
在下面的例子中,通过在一个FullName对象上调用无副作用方法将该对象本身替换成另一个实例:
FullName name = new FullName("Vaughn", "Vernon");
// later...
name = name.withMiddleInitial("L");
这和先前“可替换性”一节中的例子所产生的结果是一样的,但是代码更具表达性。这个无副作用的withMiddleInitial()方法的实现如下:
public FullName withMiddleInitial(String aMiddleNameOrInitial) {
if (aMiddleNameOrInitial == null) {
throw new IllegalArgumentException(
"Must provide a middle name or initial.");
}
String middle = aMiddleNameOrInitial.trim();
if (middle.isEmpty()) {
throw new IllegalArgumentException(
"Must provide a middle name or initial.");
}
return new FullName(this.firstName(), middle.substring(0, 1).toUpperCase(), this.lastName());
}
在上例中,withMidd丨elnitiaK)方法并没有修改值对象的状态,因此它不会产生副作用。该方法通过已有的firstName和lastName,外加传入的middleName创建了一个新的FullName值对象实例。此外,withMiddlelnitialO方法还捕获到了重要的领域业务逻辑,从而避免了将这些逻辑泄漏到客户端。
当值对象引用实体对象
一个值对象允许对传入的实体对象进行修改吗?如果值对象中的确有方法会修改实体对象,那么该方法还是无副作用的吗?该方法容易测试吗?我会说,既容易,也不容易。因此,如果一个值对象方法将一个实体对象作为参数时,最好的方式是,让实体对象使用该方法的返回结果来修改其自身的状态。
然而,这种方式存在一个问题。例如,在Scrum中,我们有个实体对象Product, 该对象被值对象BusinessPriority所使用:
float priority = businessPriority.priorityOf(product);
你能看出有什么不妥吗?我们至少可以看出以下问题:
- 这里的BusinessPriority值对象不仅依赖于Product,还试图去理解该实体的内部状态。我们应该尽量地使值对象只依赖于它自己的属性,并且只理解它自身的状态。虽然在有些情况下这并不可行,但这是我们的目标。
- 阅读本段代码的人并不知道使用了 Product的哪些部分。这种表达方法并不明确,从而降低了模型的清晰性。更好的方式是只传人需要用到的Product属性。
- 更重要的是,在将实体作为参数的值对象方法中,我们很难看出该方法是否会对实体进行修改,测试也将变得非常困难。因此,即便一个值对象承诺不会修改实体,我们也很难证明这一点。
有了以上的分析,我们需要对以上的值对象进行改进。要增加一个值对象的健壮性,我们传给值对象方法的参数依然应该是值对象。这样我们可以获得更高层次的无副作用行为。要实现这样的目标并不困难:
float priority = businessPriority.priority(product.businessPriorityTotals());
在上例中,我们只需要将Product实体的BusinessPriorityTotals值对象传给 priority()方法即可。你可能会认为priority()方法应该返回一个值对象类型,而不是float类型。这是正确的,特别是当priority是通用语言中的正式概念的时候。这种决定来自于持续改进模型的结果。通过分析,SaaSOvation的团队成员认为不应该由Product实体自身来计算priority,而应该将该功能交给领域服务(7),在本章后面,你将看到这种更好的解决方案。
如果你打算使用语言提供的基本值对象(例如primitive或wrapper),而不使用特定的值对象,那么你便是在欺骗自己的模型了。我们是无法将领域特定的无副作用函数分配给语言提供的基本值对象的。任何领域特有行为都将从值中分离出来。即便编程语言允许我们向基本值对象中添加新的行为,这能够在深层次上捕获领域概念吗?
挑战你的假设
如果你认为一个方法不能达到无副作用的要求,并且该方法肯定会修改该对象实例的状态,那么你应该挑战你的假设了。此时可以使用对象替换吗?上文中的例子便是一个非常简单的替换方案。在该例中,我们将原有属性和新传入属性结合起来创建一个新的值对象实例。在一个系统中,很少出现每个对象都是值对象的情况,有些对象必须通过实体进行建模。我们应该仔细地将值对象的特征和实体对象特征进行对比。通过思考和讨论,一个团队是能得到正确结论的。
当SaaSOvation的团队成员阅读了无副作用函数[Evans]和整体值对象的相关材料之后. 他们意识到应该更多地使用值对象。通过理解消化上文中所讲到的值对象特征.他们知道了如何更好地在领域中去发现那些自然存在的值对象。
所有东西都是值对象吗?
到现在,你可能倾向于将所有东西都看成值对象。这总比将所有东西看作实体好。此时你需要注意的是,有些真正简单的属性是没有必要特殊对待的。例如,对于有些布尔类型或数值类型的值对象来说,它们已经能够自给了,并不需要额外的功能支持,也并不和实体中的其他属性相关联。这些简单的属性称为意义整体。你可能还是会“错误地”将这些单一的属性封装成值对象类型。当你发现这样做有些过度时,你需要重构了。