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元”。
好,今天的课程结束了,有什么问题欢迎在评论区留言,下节课,我们会探讨为什么要使用值对象。
- escray 👍(3) 💬(1)
Java 代码写的少,居然不知道 enum 里面也可以有方法。按照类似的思路,是不是类似于状态机的代码都可以放在枚举类里面? 员工状态这样的对象(enum)是没有”生命“的。 时间段类也是第一次见到,原谅我书读得少,其实这段代码解决之前困扰我的一个问题,也是和历史版本相关的。 这里对于代码的重构是通过”设计“来完成的。 实体是靠独立于其他属性的标识 identity 来确定同一性 identity 的。 有单独标识,理论上可以改变的对象,叫做实体 Entity,是一个”东西“; 没有单独标识,并且不可以改变的对象,叫做值对象 Value Object,是一个”值“。 对于思考题, 1. 日期中的年、月、日三个属性,如果单独来看,拆散了”日期“这个对象的原始意义,只有当三个值都存在的时候,”日期“才有意义,时间也类似。 2. 货币如果要写成代码话,需要把币种种类也加进来,然后可能还需要增加一个”转换汇率“的属性值?同种货币的数值与数值相加,然后保留币种。
2023-01-30 - aoe 👍(7) 💬(2)
思考题 1. 在日期的定义为“以年月日确定某一天”的前提下,它在概念上不能再拆分,符合“原子值对象”的定义。例如:生日、节假日、工资到账时间这些都能定位到具体的日期,年月日缺一不可。 2. 代码如下 public enum Currency { GOLD_COIN("金币"), GEEK_COIN("极客币"), ; private String name; Currency(String name) { this.name = name; } } import java.math.BigDecimal; public class Money { private final BigDecimal value; private final Currency currency; public Money(BigDecimal value, Currency currency) { this.value = value; this.currency = currency; } public Money add(Money other) { if (other == null) { throw new IllegalArgumentException("货币不能为空"); } if (this.currency != other.currency) { throw new IllegalArgumentException("货币类型不一致,请转换成相同货币类型后进行计算"); } return new Money(this.value.add(other.value), this.currency); } public BigDecimal value() { return value; } } import org.junit.jupiter.api.Test; import java.math.BigDecimal; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; class MoneyTest { @Test void returns_3_when_1_plus_2(){ Money one = new Money(new BigDecimal("1"), Currency.GOLD_COIN); Money two = new Money(new BigDecimal("2"), Currency.GOLD_COIN); BigDecimal result = one.add(two).value(); assertThat(result).isEqualTo(new BigDecimal("3")); } } 读后感 原来 Java 中的 String 是值对象! 快照也是值对象 简单理解:不可变的对象就是值对象 开始日期、结束日期封装在了“时间段对象”以后模型也跟着变了,确实是保持了“模型与代码一致”
2023-01-17 - Ice 👍(5) 💬(1)
开始时间和结束时间修改成值对象之后,与之对应的数据库结构是否需要调整呢? 还是说在持久化层再做一次映射转换
2023-02-06 - 一剑 👍(1) 💬(1)
老师,我有个问题:EmpStatus的方法是Public的,所以Emp的EmpStatus属性是为了控制状态变更不被外部直接调用所以设成了protected么?但是在实际项目里,状态应该是要用Public对外公开的吧?但是一旦公开,就可能会被人绕过聚合根而直接调用emp.status.becomeRegular()了,这个怎么解决?
2024-01-10 - Geek_0052b7 👍(0) 💬(1)
一般money对象有两类方法,如add,addTo方法。第一种方法返回值是一个新的money对象。但addTo这样的方法,就会改变money的属性值。说是它是值对象,就不对?值对象不会改变自身属性? public Money addTo(Money other) { assertSameCurrencyAs(other); this.cent += other.cent; return this; }
2024-06-28 - YUHONGQIAN 👍(0) 💬(1)
钟老师您好,我有一个疑惑,领域模型中没有每个领域实体的具体字段,是如何识别的值对象,会不会识别的不够准确。按照理解应该是每个阶段的输出结果都能对下一个阶段起到支撑,但值对象这一章,感觉没办法通过之前的成果完整的分析值对象
2023-05-31 - benhuang 👍(0) 💬(1)
文中提到JAVA date对象是可变的带了很多问题,具体是什么问题
2023-03-17 - 6点无痛早起学习的和尚 👍(0) 💬(1)
思考题和一些问题: 问题:1. 所以本篇文章举例的员工状态、时间段 员工状态:原子值对象+依附于实体的值对象 时间段:复合值对象+独立的值对象 所以多种多样的值对象分类之间并不是完全独立不相交 思考题: public class Money { private Long value; private Currency currency; private Money(Long value, Currency currency) { this.value = value; this.currency = currency; } public static Money of(long value, Currency currency) { return new Money(value, currency); } public static Money addTwoMoney(Money money1, Money money2) { // 这里引入货币计算规则,把 2 个货币全部转成人民币,然后进行计算,再 new Money(value,人民币)返回 return new Money(0L, Currency.CNY); } @Getter @AllArgsConstructor public enum Currency { CNY("CNY"); //省略其他货币... private String name; } }
2023-02-15 - Geek_ab5b86 👍(0) 💬(1)
老师,类似员工实体中的身份证号idNum,我认为也是原子值对象,是不是说明也是不能修改的,实体内部不能加set方法,只能通过构造器传入值对象属性重新构造员工实体呢?
2023-01-16 - Geek_1e04e7 👍(0) 💬(1)
分别表达年月日的值对象也是有的,职责不一样
2023-01-16 - 阿昕 👍(0) 💬(1)
1.从含义上来看,年月日组合在一起是日期的一种固有格式,是一个整体,所以是原子值对象; 2.货币相加,需要先校验币种是否统一,是否需要转换;
2023-01-15 - bin 👍(0) 💬(6)
订单地址是值对象还是实体?这样看下来是实体?两个订单地址所有字段值都一样,很明显就违反了同一性。其实地址是值对象,订单地址不是。
2023-01-14 - py 👍(0) 💬(0)
当描述实体的属性本身有复杂值操作和约束时,属性可以引入值对象来优化代码
2024-09-06