你好,我是钟敬。
前面几节课我们学习了聚合,这节课我们继续学习DDD中另一个有用的概念——值对象。
DDD 把领域对象分成了两种:一种是实体,另一种是值对象。前面我们讨论的组织、员工等等都是实体。而值对象则往往是用来描述实体的属性“值”的。值对象在一些方面和实体有明显的区别,但在 DDD 提出以前,人们建模的时候,一般都只重视实体,对值对象的研究不够。DDD 强调实体和值对象的区别,可以让领域建模更加准确和深入。
但是,值对象的概念有些不太好理解,不过没关系,你可以暂时忘掉这个词本身,咱们用例子来一步一步地说明。
例一:员工状态
第一个例子是员工状态。在[第16课],我们实现了关于员工状态(EmpStatus)的两个业务规则:
还记得吗?在那节课末尾,我们问了一个问题:在目前的程序里,改变员工状态的业务规则是在员工对象中实现的,你觉得放在哪里会更合适?
可能你已经想到了,应该放在员工状态(EmpStatus)本身。其实员工状态就是个值对象,至于为什么,我们后面再说。这里我们先看看实现逻辑。
之前的员工状态转换代码是后面这样。
1 | package chapter18.unjuanable.domain.orgmng.emp; |
现在我们把规则校验移到员工状态(EmpStatus)里面。
1 | package chapter18.unjuanable.domain.orgmng.emp; |
这里,EmpStatus 的转正【 becomeRegular()】方法,首先会验证自己是否满足转正的条件,满足的话就返回“正式工”(REGULAR)状态,否则抛出异常。terminate() 方法也是类似的。也就是说员工状态对象自己知道怎样的状态转换是正确的。这种逻辑的封装,才是符合面向对象设计的。
采用了新的员工状态(EmpStatus)的员工(Emp)类,就可以改成后面这样了。
1 | package chapter18.unjuanable.domain.orgmng.emp; |
现在,Emp自己不再校验这两条业务规则了,而是委托给了EmpStatus,程序变得更加简洁。
现在,你可以先想一下,像员工状态这样的对象,和像员工这样的实体对象,有什么不一样?
例二:时间段
带着这个问题,我们再看一个值对象的例子。先观察一下目前的整个模型图。
发现了吧?这里有好几个实体都用到了开始日期和结束日期。让我们思考几个问题。
首先,工作经验实体里“时间段不能重叠”这条业务规则,其实也应该适用于客户经理、合同经理和项目经理,只不过之前我们漏掉了。
其次,不论对于哪个实体,都应该满足“结束日期不能小于开始日期”这条规则。只不过这条规则是不言自明的,一般来说,业务人员不会专门提出来。但我们在程序里,还是要校验的,之前也还没有实现。
在目前的代码里,工作经验的“时间段不能重叠”这条规则是在员工(Emp)对象里面实现的。那么“结束日期不能小于开始日期”这个规则,是否也应该在员工对象里实现呢?如果是,根据类似的思路,对于客户经理、合同经理几个对象的同样的规则,是不是也要在客户、合同等等对象里面实现呢?显然不应该,这样会导致代码重复。
那么,这些逻辑应该在哪里实现呢?如果按照过程式的思路,我们可以写一个工具类,里面用两个静态方法来实现这两条规则,然后给其他的类调用。那么,如果按照面向对象的思路应该怎么做呢?
没错,我们可以建一个时间段对象,英文可以叫 Period,把有关时间段的数据和逻辑封装起来。我们还是以工作经验为例,先看看领域模型怎么画。原来的样子是这样的。
有了时间段对象以后呢,就变成了这样。
开始日期、结束日期、“结束日期不能小于开始日期”的规则以及判断时间段重叠的方法都移到了新建的时间段对象。这样,工作经验对象只需把时间段作为属性就可以了。另外,为了在代码里说明问题,我们在时间段里又增加了一个合并操作,用于把两个时间段合并为一个时间段。尽管我们目前的例子还用不上。
现在我们来看看程序的变化。首先是新建的时间段类。
1 | package chapter18.unjuanable.domain.orgmng.emp; |
首先,我们把校验“结束日期不能小于开始日期”的逻辑放在了构造器里,以便保证最基本的正确性。
然后,判断时间段重叠的 overlap() 方法从原来的 Emp 类里面移到了这里。
接着,我们要注意一下合并两个时间段的 merge() 方法。它并没有改动当前的时间段和入参里的时间段,而是又新建了一个时间段,作为合并后的结果返回。事实上,你可能已经发现了,整个时间段类里,都没有可以改变自身值的方法,也就是说,时间段对象是只读对象。
应用了时间段对象的工作经验类就变成了后面这样。
1 | package chapter18.unjuanable.domain.orgmng.emp; |
这个类里面原来的开始日期和结束日期两个属性被时间段取代了。
咱们再看看员工类(Emp)的变化。
1 | package chapter18.unjuanable.domain.orgmng.emp; |
这里主要的变化就是,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元”。
好,今天的课程结束了,有什么问题欢迎在评论区留言,下节课,我们会探讨为什么要使用值对象。