18 值对象(上):到底什么是值对象?
你好,我是钟敬。
前面几节课我们学习了聚合,这节课我们继续学习DDD中另一个有用的概念——值对象。
DDD 把领域对象分成了两种:一种是实体,另一种是值对象。前面我们讨论的组织、员工等等都是实体。而值对象则往往是用来描述实体的属性“值”的。值对象在一些方面和实体有明显的区别,但在 DDD 提出以前,人们建模的时候,一般都只重视实体,对值对象的研究不够。DDD 强调实体和值对象的区别,可以让领域建模更加准确和深入。
但是,值对象的概念有些不太好理解,不过没关系,你可以暂时忘掉这个词本身,咱们用例子来一步一步地说明。
例一:员工状态
第一个例子是员工状态。在 第16课,我们实现了关于 员工状态(EmpStatus)的两个业务规则:
还记得吗?在那节课末尾,我们问了一个问题:在目前的程序里,改变员工状态的业务规则是在 员工 对象中实现的,你觉得放在哪里会更合适?
可能你已经想到了,应该放在 员工状态(EmpStatus)本身。其实 员工状态 就是个值对象,至于为什么,我们后面再说。这里我们先看看实现逻辑。
之前的员工状态转换代码是后面这样。
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)里面。
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)类,就可以改成后面这样了。
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,把有关 时间段 的数据和逻辑封装起来。我们还是以 工作经验 为例,先看看领域模型怎么画。原来的样子是这样的。
有了 时间段 对象以后呢,就变成了这样。
开始日期、 结束日期、“结束日期不能小于开始日期”的 规则 以及判断 时间段重叠 的方法都移到了新建的 时间段 对象。这样, 工作经验 对象只需把 时间段 作为属性就可以了。另外,为了在代码里说明问题,我们在 时间段 里又增加了一个 合并 操作,用于把两个时间段合并为一个时间段。尽管我们目前的例子还用不上。
现在我们来看看程序的变化。首先是新建的 时间段 类。
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() 方法。它并没有改动当前的 时间段 和入参里的 时间段,而是又新建了一个 时间段,作为合并后的结果返回。事实上,你可能已经发现了,整个 时间段 类里,都没有可以改变自身值的方法,也就是说,时间段对象是 只读对象。
应用了 时间段 对象的 工作经验 类就变成了后面这样。
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)的变化。
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元”。
好,今天的课程结束了,有什么问题欢迎在评论区留言,下节课,我们会探讨为什么要使用值对象。