你好,我是钟敬。

前面几节课我们学习了聚合,这节课我们继续学习DDD中另一个有用的概念——值对象。

DDD 把领域对象分成了两种:一种是实体,另一种是值对象。前面我们讨论的组织、员工等等都是实体。而值对象则往往是用来描述实体的属性“值”的。值对象在一些方面和实体有明显的区别,但在 DDD 提出以前,人们建模的时候,一般都只重视实体,对值对象的研究不够。DDD 强调实体和值对象的区别,可以让领域建模更加准确和深入。

但是,值对象的概念有些不太好理解,不过没关系,你可以暂时忘掉这个词本身,咱们用例子来一步一步地说明。

例一:员工状态

第一个例子是员工状态。在[第16课],我们实现了关于员工状态(EmpStatus)的两个业务规则:

还记得吗?在那节课末尾,我们问了一个问题:在目前的程序里,改变员工状态的业务规则是在员工对象中实现的,你觉得放在哪里会更合适?

可能你已经想到了,应该放在员工状态(EmpStatus)本身。其实员工状态就是个值对象,至于为什么,我们后面再说。这里我们先看看实现逻辑。

之前的员工状态转换代码是后面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package chapter18.unjuanable.domain.orgmng.emp;
// imports ...

public class Emp extends AggregateRoot {
// 其他属性 ...
protected EmpStatus status;

//其他方法 ...

public Emp becomeRegular() {
onlyProbationCanBecomeRegular();
status = REGULAR;
return this;
}

public Emp terminate() {
shouldNotTerminateAgain();
status = TERMINATED;
return this;
}

private void onlyProbationCanBecomeRegular() {
if (status != PROBATION) {
throw new BusinessException("试用期员工才能转正!");
}
}

private void shouldNotTerminateAgain() {
if (status == TERMINATED) {
throw new BusinessException("已经终止的员工不能再次终止!");
}
}
}

现在我们把规则校验移到员工状态(EmpStatus)里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package chapter18.unjuanable.domain.orgmng.emp;
// imports ...

public enum EmpStatus {
REGULAR("REG"), // 正式
PROBATION("PRO"), // 试用期
TERMINATED("TER"); // 终止

private final String code;

EmpStatus(String code) {
this.code = code;
}

public String code() {
return code;
}

//其他方法 ...

public EmpStatus becomeRegular() {
if (this != PROBATION) {
throw new BusinessException("试用期员工才能转正!");
}
return REGULAR;
}

public EmpStatus terminate() {
if (this == TERMINATED) {
throw new BusinessException("已经终止的员工不能再次终止!");
}
return TERMINATED;
}
}

这里,EmpStatus 的转正【 becomeRegular()】方法,首先会验证自己是否满足转正的条件,满足的话就返回“正式工”(REGULAR)状态,否则抛出异常。terminate() 方法也是类似的。也就是说员工状态对象自己知道怎样的状态转换是正确的。这种逻辑的封装,才是符合面向对象设计的。

采用了新的员工状态(EmpStatus)的员工(Emp)类,就可以改成后面这样了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package chapter18.unjuanable.domain.orgmng.emp;
// imports ...

public class Emp extends AggregateRoot {
// 其他属性 ...
protected EmpStatus status;

//其他方法 ...

public Emp becomeRegular() {
status = status.becomeRegular();
return this;
}

public Emp terminate() {
status = status.terminate();
return this;
}

// 删除了 onlyProbationCanBecomeRegular()
// 删除了 shouldNotTerminateAgain()

}

现在,Emp自己不再校验这两条业务规则了,而是委托给了EmpStatus,程序变得更加简洁。

现在,你可以先想一下,像员工状态这样的对象,和像员工这样的实体对象,有什么不一样?

例二:时间段

带着这个问题,我们再看一个值对象的例子。先观察一下目前的整个模型图。

发现了吧?这里有好几个实体都用到了开始日期结束日期。让我们思考几个问题。

首先,工作经验实体里“时间段不能重叠”这条业务规则,其实也应该适用于客户经理合同经理项目经理,只不过之前我们漏掉了。

其次,不论对于哪个实体,都应该满足“结束日期不能小于开始日期”这条规则。只不过这条规则是不言自明的,一般来说,业务人员不会专门提出来。但我们在程序里,还是要校验的,之前也还没有实现。

在目前的代码里,工作经验的“时间段不能重叠”这条规则是在员工(Emp)对象里面实现的。那么“结束日期不能小于开始日期”这个规则,是否也应该在员工对象里实现呢?如果是,根据类似的思路,对于客户经理合同经理几个对象的同样的规则,是不是也要在客户合同等等对象里面实现呢?显然不应该,这样会导致代码重复。

那么,这些逻辑应该在哪里实现呢?如果按照过程式的思路,我们可以写一个工具类,里面用两个静态方法来实现这两条规则,然后给其他的类调用。那么,如果按照面向对象的思路应该怎么做呢?

没错,我们可以建一个时间段对象,英文可以叫 Period,把有关时间段的数据和逻辑封装起来。我们还是以工作经验为例,先看看领域模型怎么画。原来的样子是这样的。

有了时间段对象以后呢,就变成了这样。

开始日期结束日期、“结束日期不能小于开始日期”的规则以及判断时间段重叠的方法都移到了新建的时间段对象。这样,工作经验对象只需把时间段作为属性就可以了。另外,为了在代码里说明问题,我们在时间段里又增加了一个合并操作,用于把两个时间段合并为一个时间段。尽管我们目前的例子还用不上。

现在我们来看看程序的变化。首先是新建的时间段类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package chapter18.unjuanable.domain.orgmng.emp;
import java.time.LocalDate;

public class Period {
private LocalDate start;
private LocalDate end;

private Period(LocalDate start, LocalDate end) {
//创建时校验基本规则
if (start == null || end == null || start.isAfter(end)) {
throw new IllegalArgumentException(
"开始和结束日期不能为空,且结束日期不能小于开始日期!");
}
this.start = start;
this.end = end;
}

//用于构造对象
public static Period of(LocalDate start, LocalDate end){
return new Period(start, end);
}

// 判断是否与另一时间段重叠
public boolean overlap(Period other) {
if (other == null) {
throw new IllegalArgumentException("入参不能为空!");
}
return other.start.isBefore(this.end)
&& other.end.isAfter(this.start);
}

// 合并两个时间段
public Period merge(Period other) {
LocalDate newStart = this.start.isBefore(other.start) ?
this.start : other.start;

LocalDate newEnd = this.end.isAfter(other.end) ?
this.end : other.end;

return new Period(newStart, newEnd);
}

public LocalDate getStart() {
return start;
}

public LocalDate getEnd() {
return end;
}
}

首先,我们把校验“结束日期不能小于开始日期”的逻辑放在了构造器里,以便保证最基本的正确性。

然后,判断时间段重叠的 overlap() 方法从原来的 Emp 类里面移到了这里。

接着,我们要注意一下合并两个时间段的 merge() 方法。它并没有改动当前的时间段和入参里的时间段,而是又新建了一个时间段,作为合并后的结果返回。事实上,你可能已经发现了,整个时间段类里,都没有可以改变自身值的方法,也就是说,时间段对象是只读对象

应用了时间段对象的工作经验类就变成了后面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package chapter18.unjuanable.domain.orgmng.emp;
//imports ...

public class WorkExperience extends AuditableEntity {
private Long id;
private Long tenantId;
private Period period;
protected String company;

protected WorkExperience(Long tenantId, Period period
, LocalDateTime createdAt, Long createdBy) {

super(createdAt, createdBy);
this.tenantId = tenantId;
this.period = period;
}

// setters and getters ...
}

这个类里面原来的开始日期结束日期两个属性被时间段取代了。

咱们再看看员工类(Emp)的变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package chapter18.unjuanable.domain.orgmng.emp;
//imports ...

public class Emp extends AggregateRoot {
//其他属性 ...
protected List<WorkExperience> experiences;

// 其他方法 ...

//原来的开始日期和结束日期两个参数变成了时间段
public void addExperience(Period period, String company
, Long userId) {
durationShouldNotOverlap(period);

WorkExperience newExperience = new WorkExperience(
tenantId
, period
, LocalDateTime.now()
, userId)
.setCompany(company);

experiences.add(newExperience);
}

//这个方法的实现发生了变化
private void durationShouldNotOverlap(Period newPeriod) {
if (experiences.stream().anyMatch(
e -> e.getPeriod().overlap(newPeriod))) {
throw new BusinessException("工作经验的时间段不能重叠!");
}
}

// 其他方法 ...
}

这里主要的变化就是,durationShouldNotOverlap() 里判断重叠的逻辑委托给了时间段对象来实现,而不是原来那样,由员工对象自己来实现。

值对象的概念

现在,我们来看一看员工状态(EmpStatus)和时间段(Period)这两个对象有什么共性。为了说明问题,我们把它们和员工这样的实体对照着看。

首先,咱们要先说一个“同一性”的概念,英文是 identity。如果表面上看起来有两个对象,但实际上指的是同一个东西,就说这两者具有同一性。判断同一性的问题,就是如何确定“一个对象就是这个对象自身”的问题。

比如说,在一个公司里,用员工号来区分员工,或者说员工对象的标识是员工号。张三的员工号是“001”。他去年的年龄是32岁,或者说这个员工对象的年龄属性是32。今年年龄变成了33岁。但是他的员工号还是“001”。

所以,我们就知道,尽管属性变了,但还是那个张三。即使这个公司里还有另一个叫张三的人,员工号是“002”,年龄也是33岁,但由于员工号不同,尽管姓名和年龄这两个属性都相同,我们也知道这是两个不同的员工对象。

所以说,实体是靠独立于其他属性的标识来确定“同一性”的。顺便说一句,这里说的“标识”的英文也是 identity,和“同一性”其实是一个词。

员工状态时间段就没有这样的标识。也可以说,它们的所有属性加在一起就是自身的标识,所以就没有独立于其他属性的标识。

比如说,时间段的标识就是起始日期结束日期两个属性组合在一起,不需要其他单独的属性作为标识了。也就是说,时间段的所有属性值作为一个整体确定了自身的“同一性”。换句话说,只要属性值“变”了,那就已经是另外一个对象了,也就是不具有同一性了。

让我们想一下,如果时间段【2022年1月1日 ~ 2022年2月1日】的结束日期“变成”了2022年3月1日,这时,新的时间段【2022年1月1日 ~ 2022年3月1日】就已经是另外一个对象了,而原来的【2022年1月1日 ~ 2022年2月1日】这个对象本身还在那里,并没有发生改变。所以,说时间段对象“变化”本身是没有意义的。所以时间段是不可变的。

同样,员工状态也是不可变的。你可能会问了:不对呀,员工状态明明是可以改变的,比如说,可以由“试用期”改成“正式工”?

让我们仔细品一品。员工的状态(status)是员工的一个属性。这个属性的类型是员工状态(EmpStatus)。“试用期”和“正式工”这两个对象是员工状态类型的实例。员工的状态属性值可以由“试用期”变为“正式工”。这时变化的是员工的属性,而“试用期”和“正式工”这两个对象本身是不变的。

在DDD里,像员工这样有单独的标识,理论上可以改变的对象,就叫做实体(Entiy);像员工状态时间段这样没有单独的标识,并且不可改变的对象,就叫值对象(Value Object)。

从直观上看,实体是一个“东西”,而值对象是一个“值”,往往用来描述一个实体的属性,这也是值对象名字的由来。

那么在程序上,怎么实现这种概念上的不变性呢?我们只需要把这些对象的属性值,作为构造器参数传入来创建对象,而不提供任何方法来改变对象就可以了。

多种多样的值对象

在生活中我们会遇到多种多样的值对象,但值对象的概念比实体要难理解一些。所以下面,我再多举一些例子,并且把它们分一分类,以便你加深理解。

原子值对象 vs 复合值对象

首先,我们可以把值对象分成原子的和复合的。

所谓原子值对象,是在概念上不能再拆分的值对象。比如说,整数、布尔值,日期、颜色以及状态等等,一般都建模成值对象。他们只有一个属性,不能再分了。

而复合值对象是其他对象组合起来的值对象。

举个例子,“长度”对象是由“数值”和“长度单位”两个属性组成的,比如“5米”“3毫米”等等。“姓名”一般也认为是值对象,由“姓”和“名”两个属性组成,如果考虑国际化,还要加上“中间名”。“地址”常常也认为是值对象,属性包括“国家”“省”“市”“区”“街道”“门牌号”等。还有,“字符串”也是复合值对象,它是由一系列的字符组成的,这种组合方式和前面几种不太一样。

现在你知道为什么Java里面String对象是不可变的了吧?因为它是值对象。但是你可能又发现一个问题,Java里Date(日期)是可变的,而我们上面说日期是值对象,不可变。这是为什么呢?

其实呀,把Date实现成可变的,是早期JDK设计的一个错误,这带来了很多问题(比如说线程不安全)。直到JDK8引入了新的日期和时间库,也就是LocalDate、LocalDatetime这些类型,才完美地解决了这个问题。而这些新的类型都是不可变的。

你看,哪怕是发明Java的牛人,有时候也没搞清楚什么是值对象。

最后还有一种常见的复合值对象,就是所谓“快照”。

比如修改员工的时候,可能需要把修改历史留下来,也就是我们可以看到员工信息的各个版本。一种做法就是建一个员工历史表,里面的字段和员工表差不多。每次修改,都把修改前的员工数据存一份到历史表。这些信息,就是员工在某个时刻的“快照”。快照是不可变的,因为它是历史信息,历史是不可改变的。多数值对象都比较小,但快照有时会很大,但仍然是值对象。

独立的值对象 vs 依附于实体的值对象

另外,值对象还可以分成独立的和依附于实体的。比如说,“时间段”“整数”都是独立的,它们可以用来描述任何实体的属性,所以可以不依附于任何实体而单独存在。但是,员工状态就是依附于实体的,它只能表达员工这个实体的状态,脱离了员工,员工状态也就没有单独存在的意义了。

可数值对象 vs 连续值对象

**值对象也可以分成可数的和连续的。**可数值对象是离散的,可以一个一个列出来。比如说整数和日期、员工状态都是可数的。而实数则是连续的值对象。像颜色这样的值对象,在自然界里本来是连续的,但由于技术的限制,在计算机里一般实现为可数的,比如说,一些老式的系统只支持256种颜色。

预定义值对象 vs 非预定义值对象

最后,值对象还可以分成预定义的和非预定义的。

所谓预定义的,就是需要以某种方式在系统里,把这种对象的值定义出来,常见的方式有程序里的枚举类型、数据库定义表,配置文件等。比如说,员工状态的三个对象“试用期”“正式工”“终止”,就是用枚举的方式定义在程序里的。而用于构造地址的“省”“市”则常常定义在数据库表里。

非预定义的值对象就不必预先定义在系统里了,比如说“整数”,由于是无限的,根本就没有办法预定义。我们不可能用一个数据库表把所有整数都定义进去,当然,也没这个必要。

我们列举了这么多种值对象,把它们和前面值对象的定义对比着来想,是不是明白多了?

总结

好,今天先讲到这,我们来总结一下。

这节课,我们首先把业务规则封装到了员工状态时间段两个对象中。然后把这两个对象,和员工实体做对比,总结出了实体值对象的区别。主要包括两方面:

第一,从“同一性”来说,实体靠独立于其他属性的标识来确定“同一性”;而值对象靠所有属性值作为一个整体来确定“同一性”,没有脱离其他属性的单独的标识。

第二,从“可变性”来说,实体是可变的;值对象是不可变的。值对象的不可变性,并不来自于外在的约束,而是来自于值对象的本质,也就是说,谈论值对象是否可变本身是没有意义的。实体和值对象在可变性上的区别,其实,又是从“同一性”推导出来。

讨论完概念,我们又按照不同的维度给值对象分类,并举了更多的例子,以便你加深理解。

值对象的理解,比实体要稍微难一些,如果你还有些不太想得通的地方,没关系,在后面的课里,我们还会更深入的学习。

思考题

1.日期包括了年、月、日三个属性,那么,为什么日期是原子值对象,而不是复合值对象呢?

2.货币(Money) 也是常见的值对象,包括“币值”和“币种”两个属性。你能不能写个货币类的程序,实现两个货币的值相加的功能呢?例如,“5元加5元等于10元”。

好,今天的课程结束了,有什么问题欢迎在评论区留言,下节课,我们会探讨为什么要使用值对象。