值对象(ValueObject)- Martin Fowler博客


/**
 * 献给我最尊敬的偶像Martin Fowler
 * 原文出处:https://martinfowler.com/bliki/ValueObject.html
 * @author dogstar.huang <chanzonghuang@gmail.com> 2017-03-10
 */

本翻译已征得Martin Fowler同意,并链接在博客原文下方。

编程时,我发现把东西表示成一个组合物是很有用的。一个二维坐标由x和y组合。一定数量金额由一个数值和一种货币组成。一个日期范围由开始和结束日期组成,而日期又可以由年、月、日组成。

当我这样做的话,我就会遇到了两个组合物是否是一样的问题。假设我有两个都是表示笛卡尔坐标(2,3)的point对象,把他们当作是相等是有意义的。根据其属性来判断相等性的对象被称为值对象,在这里的属性就是他们的x和y坐标。

但除非我在编程时很小心,不然在我的程序里可能得不到那样的效果。

比如想在JavaScript表示一个坐标。

const p1 = {x: 2, y: 3};  
const p2 = {x: 2, y: 3};  
assert(p1 !== p2);  // 不是我想要的  

遗憾的是,测试通过了。它之所以这样是因为JavaScript通过寻找他们的引用来测试js对象的相等性,而忽视他们所包含的值。

在很多场景里,使用引用而不是值是有意义的。如果我正在加载和操纵一堆销售订单,把每个订单加载到一个单独的地方是有意义的。如果我想看下Alice最新的订单有没有在下一批派送里,我可以使用Alice订单的内存引用,或者标记符,然后看一下该内存引用是否在此派送的订单列表里。对于这个检测,我不用担心有什么在订单里。类似地我可能依赖于一个唯一的订单号,检测Alice的订单号有没有在派送列表里。

所以,我发现思考怎么把这两类对象:值对象和引用对象,区分开来是很有用的[1]。我需要确保我已理解我怎么期望各个对象处理相等性和编程他们,才能让他们可以根据我的设想行动。我怎么做,取决于我正在使用的编程语言。

有些语言把全部组合的数据当作值。如果我在Clojure构建了一个简单的组合,它会看起来像这样。

> (= {:x 2, :y 3} {:x 2, :y 3})
true  

这是函数式风格 -- 把所有东西视为不可变值。

但我用的不是函数式语言,我仍然能经常创建值对象。以Java为例,我喜欢默认Point类的行为是这样。

assertEquals(new Point(2, 3), new Point(2, 3)); // Java  

这种方式之所以有效,是因为Point类通过针对值进行检测重写了默认的equals方法。[2] [3]

在JavaScript里,我可以做一些类似的事情。

class Point {  
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  equals (other) {
    return this.x === other.x && this.y === other.y;
  }
}
const p1 = new Point(2,3);  
const p2 = new Point(2,3);  
assert(p1.equals(p2));  

JavaScript这里的问题是,我定义的这个equals方法对于其他JavaScript库是一个谜。

const somePoints = [new Point(2,3)];  
const p = new Point(2,3);  
assert.isFalse(somePoints.includes(p)); // not what I want

// 所以我不得不这样做
assert(somePoints.some(i => i.equals(p)));  

在Java这不是一个问题,因为Object.equals在核心库中定义,并且其他类库使用它来进行比较(==通常只用于原始类型)。

值对象其中一个好的成果是,我不需要担心我在内存中是否有指向同一对象的引用,或者相同的值有不同的引用。然而如果我不够小心的话,天真愚蠢就会导致问题的发生,我会通过少量的Java代码来演示这点。

Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016"));

// 这意味着我们需要一个退休party
Date partyDate = retirementDate;

// 但那天是周三,让我们周末再来举行party
partyDate.setDate(5);

assertEquals(new Date(Date.parse("Sat 5 Nov 2016")), retirementDate);  
// 天哪,现在我要工作多三天 :-(

这是一个Aliasing Bug例子,我在某个地方改变了一个日期,结果超出了我的期望[4]。为了避免Aliasing Bug,我遵循了一个简单但重要的规则:值对象应该是不可变的。如果想改变party的日期,我可以创建一个新的对象来替代。

Date retirementDate = new Date(Date.parse("Tue 1 Nov 2016"));  
Date partyDate = retirementDate;

// 把日期作为不可变
partyDate = new Date(Date.parse("Sat 5 Nov 2016"));

// 这样我还是在周天退休
assertEquals(new Date(Date.parse("Tue 1 Nov 2016")), retirementDate);  

当然,如果他们真的是不可变的话,把值对象当作是不可变的就更容易了。对于对象,通过简单地不提供任何设置的方法,我可以通常做到这点。所以,我早期的JavaScript类会看起来像是这样:[5]

class Point {  
  constructor(x, y) {
    this._data = {x: x, y: y};
  }
  get x() {return this._data.x;}
  get y() {return this._data.y;}
  equals (other) {
    return this.x === other.x && this.y === other.y;
  }
}

不可变性是我用于避免aliasing bug最爱的技术,还可以通过确保赋值总是产生一个副本来避免。有些语言提供了这个功能,例如C#里的结构。

把一个概念当作引用对象还是值对象取决于你的上下文。在大部分场景里,把一个投递的地址当作是一个简单的带值相等的文本的结构是值得的。但一个更复杂的映射系统可能把投递地址链接到一个复杂的、引用可产生更大意义的分层模型。与大多数建模的问题一样,具体问题具体分析。[6]

用合适的值对象替换公共的原语,例如字符串,通常是一个好主意。当我可以用一个字符串表示一个电话号码时,转换为一个电话号码对象使得变量和参数更明确(当开发语言支持时会有类型检测),验证自然的关注,和避免不适用的行为(例如在整型ID数字上进行算术操作)。

例如坐标、货币或者范围这些小对象是值对象的好例子。但如果更大的结构没有任何概念标记或者不需要在程序里共享引用,是可以经常把他们编程为值对象。这在本质上更适合于函数式语言,默认为不可变性。[7]

我发现值对象,尤其是小的那些,经常会被忽然 -- 被看作是太不重要以致不值得去考虑。但一旦我构建了一组好的值对象,我发现我可以在他们之上创建丰富的行为。如果想要体验一下,可试用一下Range类,看下它是怎样通过更丰富的行为来防止各种重复比较开始和结束属性。我经常遇到像这样的领域特定值对象代码库可以作一个重构的关注点,从而大大简化系统。如此一个简化经常会让人感到惊讶,直到人们一次又一次地看到了它 -- 那时它将会是一位好朋友。

致谢

James Shore,Beth Andres-Beck,和Pete Hodgson分享了他们在JavaScript中使用值对象的经验。

Graham Brooks,James Birnie,Jeroen Soeters,Mariano Giuffrida,Matteo Vaccari,Ricardo Cavalcanti,和Steven Lowe在我们内部邮件列表上提供了有价值的评论。

扩展阅读

Vaughn Vernon的描述可能是从DDD视角关于值对象最具深度的讨论。他涵盖了如何在值和实体之间权衡,实现技巧以及持久化值对象的技术。

这个词在早期就开始获得关注。那时讨论他们的两本书是PoEAADDD。在Ward的Wiki上也有一些有趣的讨论。

术语困惑的一个来源是在世纪之交某些J2EE文献为数据传送对象(Data Transfer Object)使用了“值对象”。这种用法现在几乎都消失了,但有可能你会遇到它。

注解

1、在领域驱动设计里,Evan分类对比了值对象和实体。我把实体看作是引用对象的一种通用形式,但只在领域模型里使用术语“实体”,而引用对象/值对象二分法则对所有代码都有用。

2、严格上,这是在awt.geom.Point2D里完成的,这是awt.Point的超类。

3、在Java里大部分对象的比较是通过equals来完成的 -- 这有一点尴尬,因为我不得不记住使用equals而不是相等操作符==。这一点很烦人,但Java程序员很快就习惯了因为String的行为和这一样。其他OO语言可以避免这点 -- Ruby使用了==操作符,但允许它被重载。

4、对于pre-Java-8日期和时间系统里最糟糕的特征来说,这是一个强大的竞争对手 -- 但我会投它一票。谢天谢地,通过Java 8 的java.time包,我们可以避免这些的大部分。

5、严格来说这不是不可变的,因为客户端可以操作_data属性。但在实践中,一个纪律严明的团队可以让它成为不可变的。如果我考虑到这个团队还不够纪律严明,那么我会使用freeze。确实在一个简单的JavaScript对象上我可以使用冻结,但我更倾向使用声明访问器的类的显式。

6、关于这点,在Evan的《领域特定语言》一书里有更多讨论。

7、不可变性对于引用对象也是有价值的 -- 如果在一个Get请求里,销售订单不会发生改变,那么让它成为不可变是有价值的;并且会使得复制它更安全,如果那样有用的话。但如果我决定基于一个唯一订单号来判断相等性的话,那样就不能把销售订单作为一个值对象。

dogstar

一位喜欢翻译的开发人员,艾翻译创始人之一。

广州