DDD之实体与值对象
传统的系统架构设计阶段,通常我们会将关注点放在数据上面,而不是领域上面。这种设计风格在软件开发中,使数据库占据了主导地位,我们总是有限考虑数据的属性(对应数据库的列)和关联关系(外键关联),而不是富有行为的领域概念。这样做的结果是直接将数据模型反映在对象模型上,导致这些表示领域模型的实体中含有大量的getter、setter方法,也就是贫血领域模型这不符合DDD的做法。
与传统数据模型设计优先不同,DDD 是先构建领域模型,针对实际业务场景构建实体对象和行为,再将实体对象映射到数据持久化对象。传统数据模型不具备行为能力,而DDD的领域模型实体含有丰富的行为能力。
实体
在DDD的领域模型中,实体应该是富有业务行为且具有唯一标识符的对象。在不同的设计阶段实体是可以改变的,但是根据唯一标识符始终能定位到这个唯一对象。
唯一标识符可以是用户指定的,也可以是通过应用程序生成的UUID或者通过持久化机制生成的序列值(Sequence),当然也可以是限界上下文中传递的过来的,但无论是哪一种生产方式都要具备全局唯一性(比如订单的流水号,一些电商场景订单的流水号是通过专门的工具生产全局唯一的)。
实体的可变性主要体现在不同的设计阶段,实体会根据所处阶段的侧重点不同,发生一定地形态变化。
实体的业务形态
在战略设计时,实体是领域模型的一个重要对象。领域模型中的实体是多个属性、操作或行为的载体。在事件风暴中,我们可以根据命令、操作或者事件,找出产生这些行为的业务实体对象,进而按照一定的业务规则将依存度高和业务关联紧密的多个实体对象和值对象进行聚类,形成聚合。你可以这么理解,实体和值对象是组成领域模型的基础单元。
实体的代码形态
在代码模型中,实体的表现形式是实体类,这个类包含了实体的属性和方法,通过这些方法实现实体自身的业务逻辑。在 DDD 里,这些实体类通常采用充血模型,与这个实体相关的所有业务逻辑都在实体类的方法中实现,跨多个实体的领域逻辑则在领域服务中实现。
实体的运行形态
实体以 DO(领域对象)的形式存在,每个实体对象都有唯一的 ID。我们可以对一个实体对象进行多次修改,修改后的数据和原来的数据可能会大不相同。但是,由于它们拥有相同的 ID,它们依然是同一个实体。比如商品是商品上下文的一个实体,通过唯一的商品 ID 来标识,不管这个商品的数据如何变化,商品的 ID 一直保持不变,它始终是同一个商品。
实体的数据库形态
在领域模型映射到数据模型时,一个实体可能对应 0 个、1 个或者多个数据库持久化对象。大多数情况下实体与持久化对象是一对一。在某些场景中,有些实体只是暂驻静态内存的一个运行态实体,它不需要持久化。比如,基于多个价格配置数据计算后生成的折扣实体。
而在有些复杂场景下,实体与持久化对象则可能是一对多或者多对一的关系。比如,用户 user 与角色 role 两个持久化对象可生成权限实体,一个实体对应两个持久化对象,这是一对多的场景。再比如,有些场景为了避免数据库的联表查询,提升系统性能,会将客户信息 customer 和账户信息 account 两类数据保存到同一张数据库表中,客户和账户两个实体可根据需要从一个持久化对象中生成,这就是多对一的场景。
如何创建一个实体
在通用语言的术语中,名词用于给概念命名,形容词用于描述这些概念,而动词则表示可以完成的操作。在对一个业务场景或者需求进行分析时,团队成员需要仔细阅读需求文字,听取领域专家的解析,从中提炼出关键的词组。比如:当我们听到“修改”这个词的时候,应该能联想到对应一个实体操作,当我们听到“校验、认证”这类词时,我们应该提供一些查询能力。实体也就是团队在这种一次次的讨论、总结过程中增加修改属性、确认唯一标识符、予以丰富的业务行为,最终形成一个领域模型实体。
当我们新建一个实体时,我们希望通过构造函数来初始化足够多的实体状态,这一方面有助于表该实体的身份,另一方面可以帮助客户端更容易地查找该实体。
值对象
在 DDD 中用来描述领域的特定方面,并且是一个没有标识符的对象,叫作值对象。也可理解为是若干个用于描述目的、具有整体概念和不可修改的属性的集合。在领域建模的过程中,值对象可以保证属性归类的清晰和概念的完整性,避免属性零碎。
值对象可以非常容易地创建、测试、使用、优化和维护,因此我们应该尽量使用值对象来建模而不是实体对象。在定义值对象时我们需要考虑其是否具备以下特性:
它度量或者描述了领域中的一件东西
它可以作为不变量
它将不同的相关的属性组合成一个概念整体(Conceptual Whole)
它度量和描述改变时,可以用另外一个值对象予以替换
它可以和其他值对象进行相等性比较(因为没有唯一标识符)
它不会对协作对象造成副作用
eg:一个人拥有名字和年龄属性,这里的名字和年龄不是一个具体能够映射成对象的东西,年龄是一个度量概念,名字是一个描述概念,把这些概念整合成一个概念整体就是值对象的具体表现形式。
除了没有唯一标识符外,值对象和实体对象另外一个比较大的区别就是,值对象具有不变性。体现到我们代码层面就是,在值对象初始化之后,任何方法都不能对该对象的属性状态进行修改。
值对象的业务形态
值对象是 DDD 领域模型中的一个基础对象,它跟实体一样都来源于事件风暴所构建的领域模型,都包含了若干个属性,它与实体一起构成聚合。值对象的属性集虽然在物理上独立出来了,但在逻辑上它仍然是实体属性的一部分,用于描述实体的特征。在值对象中也有部分共享的标准类型的值对象,它们有自己的限界上下文,有自己的持久化对象,可以建立共享的数据类微服务,比如数据字典。
值对象的代码形态
值对象在代码中有这样两种形态。如果值对象是单一属性,则直接定义为实体类的属性;如果值对象是属性集合,则把它设计为 Class 类,Class 将具有整体概念的多个属性归集到属性集合,这样的值对象没有 ID,会被实体整体引用。
我们看一下下面这段代码,person 这个实体有若干个单一属性的值对象,比如 Id、name 等属性;同时它也包含多个属性的值对象,比如地址 address。
值对象的运行形态
值对象实例化的对象则相对简单和乏味。除了值对象数据初始化和整体替换的行为外,其它业务行为就很少了。值对象嵌入到实体的话,有这样两种不同的数据格式,也可以说是两种方式,分别是属性嵌入的方式和序列化大对象的方式。
引用单一属性的值对象或只有一条记录的多属性值对象的实体,可以采用属性嵌入的方式嵌入。引用一条或多条记录的多属性值对象的实体,可以采用序列化大对象的方式嵌入。比如,人员实体可以有多个通讯地址,多个地址序列化后可以嵌入人员的地址属性。值对象创建后就不允许修改了,只能用另外一个值对象来整体替换。
案例 1:以属性嵌入的方式形成的人员实体对象,地址值对象直接以属性值嵌入人员实体中。
案例 2:以序列化大对象的方式形成的人员实体对象,地址值对象被序列化成大对象 Json 串后,嵌入人员实体中。
即使是关系型数据比如MySQL现在版本也支持了json格式存储和解析,配合json格式实现列存储可以很好的兼容这种嵌入实体模式。
值对象的数据库形态
DDD 引入值对象是希望实现从“数据建模为中心”向“领域建模为中心”转变,减少数据库表的数量和表与表之间复杂的依赖关系,尽可能地简化数据库设计,提升数据库性能。值对象在数据库持久化方面简化了设计,它的数据库设计大多采用非数据库范式,值对象的属性值和实体对象的属性值保存在同一个数据库实体表中。具体体现在领域建模时,我们可以将部分对象设计为值对象,保留对象的业务涵义,同时又减少了实体的数量;在数据建模时,我们可以将值对象嵌入实体,减少实体表的数量,简化数据库设计。
把地址信息以一个序列化大对象的方式嵌入用户表时就是一种很好的应用案例场景。即减少了单独设计一张地址表,也体现出了地址信息不具备任何业务行为的特性。
如何创建一个值对象
值对象是一把双刃剑,它的优势是可以简化数据库设计,提升数据库性能。但如果值对象使用不当,它的优势就会很快变成劣势。
序列化大对象嵌入实体:值对象采用序列化大对象的方法简化了数据库设计,减少了实体表的数量,可以简单、清晰地表达业务概念。这种设计方式虽然降低了数据库设计的复杂度,但却无法满足基于值对象的快速查询,会导致搜索值对象属性值变得异常困难。
属性嵌入实体:值对象采用属性嵌入的方法提升了数据库的性能,但如果实体引用的值对象过多,则会导致实体堆积一堆缺乏概念完整性的属性,这样值对象就会失去业务涵义,操作起来也不方便。
知道了两种使用方式的优缺点其实结果自己的项目场景就可以知道如何设计一个值对象了,在适当的场景使用适当的方式将是一把设计利刃。
实体和值对象之间的关系
唯一的身份标识和可变性特征将实体对象和值对象进行了区分。本质上,实体是看得到、摸得着的实实在在的业务对象,实体具有业务属性、业务行为和业务逻辑。而值对象只是若干个属性的集合,只有数据初始化操作和有限的不涉及修改数据的行为,基本不包含业务逻辑。
实体和值对象是微服务底层的最基础的对象,一起实现实体最基本的核心领域逻辑。同时实体对象和值对象共同构成了聚合。
在设计的时候应该用实体对象还是值对象,我觉得本着一个是否具有业务行为的原则就够了,有业务行为的就用实体对象,没有业务行为的就设计成值对象。