实体和值对象:领域模型的基础单元
DDD战术设计中有两个重要的概念:实体(Entity)和值对象(Value Object)。二者是领域模型中非常重要的基础领域对象(Domain Object,DO)。
从DDD战略设计到战术设计会经历从业务建模到技术落地的多个不同阶段,阶段不同,这些领域对象的形态表现也会不同。在用户旅程分析或场景分析构建领域模型时,实体和值对象是偏业务领域的,主要体现为业务属性和业务行为。而当它们从领域模型映射到代码模型时,这些领域对象会变成代码对象,这时候的我们会更关注这些领域对象的依赖关系,关注如何一起按照聚合的业务规则实现业务逻辑。当这些领域对象持久化存储到数据库时,它们的名称和状态可能又会发生变化,此时我们需要将这些领域对象转换为持久化对象(Persistent Object,PO),完成数据的持久化。
所以,理解和区分实体和值对象在不同阶段的形态很重要,形态发生了变化,我们就需要对它进行转换。这些内容与微服务设计和代码实现有着非常密切的关系。
那么,实体和值对象在领域模型中起到什么样的作用?在战术设计时又该如何将它们映射到代码模型和数据模型中去呢?
实体
我们先来看看实体是什么?
在DDD的领域模型中有这样一类对象,它们拥有唯一标识符,并且它们的标识符在历经各种状态变更后仍能保持一致。对这些对象而言,重要的不是属性,而是其延续性和标识,这种对象的延续性和标识会跨越甚至超出软件的生命周期。我们把领域模型中这样的领域对象称为实体。
- 实体的业务形态
在DDD不同的设计阶段中,实体的形态是不同的。
在战略设计时,实体是领域模型的一个重要对象,它是业务形态的业务对象,集多个业务属性、业务操作或行为于一体。在进行用户旅程或业务场景分析时,我们可以根据命令、业务操作或者领域事件,找出产生这些业务行为的实体对象,进而按照一定的业务规则将依存度高和业务关联紧密的多个实体对象和值对象进行聚类,形成聚合。
你可以这么理解,实体和值对象是组成领域模型的基础单元。
- 实体的代码形态
在代码模型中,实体的表现形式是实体类,这个类包含了实体的属性和方法,通过这些方法实现实体自身的业务行为和业务逻辑。
DDD更强调面向对象的设计方法。这些实体类通常采用充血模型,与实体相关的所有业务逻辑都在实体类方法中实现,跨多个实体的领域逻辑则在领域服务中实现。
注意
充血模型与贫血模型的关键差异
在充血模型中,业务逻辑都在领域实体对象中实现,实体本身不仅包含了属性,还包含了它的业务行为。DDD领域模型中实体是一个具有业务行为和逻辑的对象。
而在贫血模型中领域对象大多只有setter和getter方法,业务逻辑统一放在业务逻辑层实现,而不是在领域对象中实现。
- 实体的运行形态
实体以领域对象(DO)的形式存在,每个实体对象都有唯一的ID。
我们可以对一个实体对象进行多次修改,修改后的实体数据和原来的数据可能会大不相同。但是,由于拥有相同的ID,它们依然是同一个实体。
比如商品是商品限界上下文的一个实体,通过唯一的商品ID来标识。不管这个商品的数据如何变化,商品的ID一直保持不变,所以它始终是同一个商品。
- 实体的数据库形态
与传统数据模型设计优先不同,DDD是先构建领域模型,通过场景分析找出实体对象和行为,再将实体对象映射到数据持久化对象。
在领域模型映射到数据模型时,一个实体可能对应0个、1个或者多个数据库持久化对象。大多数情况下实体与持久化对象是一对一。在某些场景中,有些实体只是暂驻内存的一个运行态实体,它不需要持久化。比如,基于多个价格配置数据计算后生成的折扣实体。
而在有些复杂场景下,实体与持久化对象则可能是一对多或者多对一的关系。比如,用户user与角色role两个持久化对象可生成权限实体,一个DO实体会对应两个持久化对象,这是一对多的场景。
再比如,有些场景为了避免数据库的联表查询,提升系统性能,会将客户信息customer和账户信息account两类数据保存到同一张数据库表中。客户和账户两个实体可根据需要从一个持久化对象中生成,这就是多对一的场景。
值对象
相对实体而言,值对象会更加抽象一些,在讲解概念时,我们会结合例子来讲。
我们先看一下《实现领域驱动设计》书中对值对象的定义。
值对象是通过对象属性值来识别的对象,它将多个相关属性组合为一个概念整体,用于描述领域的某个特定方面,并且是一个没有标识符的对象。
也就是说,值对象描述了领域中的某一个东西,这个东西是不可变的,它将不同的关联属性组合成了一个概念整体。当度量和描述改变时,我们可以用另外一个值对象予以替换。它可以和其他值对象进行相等性比较,不过不是基于ID,而是基于值对象的属性。因为不可修改的特性,它不会对协作对象带来副作用。
上面这两段对于值对象定义的阐述,可能还会有些晦涩,下面用更通俗的语言把定义讲清楚。
简单来说,值对象本质是一个属性集合,那这个集合里面有什么呢?它们是若干个基于描述目的、具有整体概念和不可修改的属性,在应用运行时,我们主要关注这些属性集的"值"。这个集合存在的意义又是什么?在领域建模的过程中,值对象可以保证属性归类的清晰和概念的完整性,避免出现零碎的属性。值对象通过抽象或标准化设计,可以采用数据冗余的方式在不同的业务领域实现数据流转。
人员实体原本包括:姓名、年龄、性别以及人员所在的省、市、县和街道等属性。这样在人员实体中,显示地址的多个属性就会显得很零碎了,对不对?
现在,我们可以将"省、市、县和街道"等属性拿出来,构成一个地址的属性集合,这个属性集合的名称就是地址值对象。
- 值对象的业务形态
值对象是领域模型中的一个基础对象,它跟实体一样都来源于事件风暴所构建的领域模型,都包含若干个属性,并与实体一起构成聚合。
下面我们不妨对照实体来看值对象的业务形态,这样就更好理解了。
实体和值对象都是若干属性的集合。实体一般是看得到、摸得着的实实在在的业务对象,具有业务属性、业务行为和业务逻辑。而值对象虽然也是若干个属性的集合,但它只有数据初始化操作和有限的不涉及修改数据的行为,基本不包含业务逻辑。值对象的属性集虽然在物理上独立出来了,但在逻辑上你仍然可以认为它是实体属性的一部分,用于描述实体的特征。
在值对象中也有部分共享的标准类型的值对象,它们有自己的限界上下文,有自己的持久化对象,可以建立共享的、提供查询服务的数据类微服务,比如数据字典。
- 值对象的代码形态
值对象在代码中有这样两种形态。如果值对象是单一属性,则直接定义为实体类的属性。如果值对象是属性集合,则将它设计为值对象类,这个类将具有整体概念的多个属性归集到属性集合,这样的值对象没有ID,会被实体整体引用。
我们看一下代码,Person这个实体有若干个单一属性的值对象,比如Id、gender等属性,同时它也包含多个属性的值对象,比如地址Address。
public class Person {
public String Id; //单一属性值对象,人员唯一主键
public String name;
public int age;
public boolean gender; //单一属性值对象
public Address address; //属性集值对象,被Person实体引用
//
}
public class Address{
//地址值对象,无主键ID
public String province;
public String city;
public String county;
public String street;
//
}
- 值对象的数据库形态
DDD引入值对象是希望实现从"数据建模为中心"向"领域建模为中心"的转变,减少数据库表的数量和表与表之间复杂的依赖关系,尽可能简化数据库设计,提升数据库性能。
如何理解用值对象来简化数据库设计呢?
传统的数据建模大多是根据数据库范式设计的,每一个数据库表对应一个实体,每一个实体的属性值用单独的一列来存储,一个实体主表会对应N个从表。而值对象简化了数据库持久化方面的设计,它的数据库设计大多采用了非数据库范式,值对象的属性值和引用它的实体对象的属性值一般保存在同一个数据库实体表中。
举个例子,还是基于人员和地址那个场景,实体和数据模型设计通常有两种解决方案:第一是把地址值对象的所有属性都嵌入人员实体表中,创建人员实体,创建人员数据库表;第二是创建人员和地址两个实体,同时创建人员和地址两张表。
第一种方案会破坏地址的业务含义和属性完整性。第二种方案增加了不必要的实体和数据库表,需要处理多个实体和表的关系,进而增加了数据库设计的复杂性。
那到底应该怎样设计,才能让业务含义清楚,同时又不让数据库变得复杂呢?
我们可以吸取这两个方案的优势,扬长避短。
在领域建模时,我们可以把地址作为值对象,把人员作为实体,这样就可以保留地址的业务含义和概念完整性。而在数据建模时,我们可以只创建人员持久化对象和人员数据库表,将地址的属性值嵌入人员实体数据库表中。这样既可以兼顾业务含义和表达,又不增加数据库的复杂度。但是这样设计,在值对象持久化时,会有一个从DO到PO转换的过程。
值对象数据嵌入实体表时,可以有两种不同的数据格式,也可以说是两种方式,分别是属性嵌入方式和序列化大对象方式。当实体引用单一属性值对象或单条记录多属性值对象时,可以采用属性嵌入方式嵌入实体表。当实体引用单条或多条记录的多属性值对象时,可以采用序列化大对象方式嵌入实体表。
在领域建模时,可以将部分领域对象设计为值对象。这样既保留了对象的业务含义,又减少了实体的数量。在数据建模时,可以将值对象嵌入实体表,减少实体表的数量,简化数据库设计。
另外,也有DDD专家认为,要想发挥领域对象的威力,就需要优先领域建模,弱化数据库的作用,只把数据库作为一个保存数据的仓库即可。即使违反数据库设计范式,也不用大惊小怪,只要业务能够顺利运行,就没什么关系。
- 值对象的优势和局限
值对象是一把双刃剑,它的优势是可以简化数据库设计,提升性能。但如果值对象使用不当,它的优势就会很快变成劣势。所谓"知彼知己,百战不殆",你需要理解值对象真正适合的场景。
值对象采用序列化大对象的方法简化了数据库设计,减少了实体表的数量,可以简单、清晰地表达业务概念。这种设计方式虽然降低了数据库设计的复杂度,却无法满足基于值对象的快速查询和统计分析,会导致搜索值对象属性值时变得异常困难。不过随着越来越多的数据库的新版本推出,不少数据库已经开始支持基于JSON串的查询方式了。
值对象采用属性嵌入的方法虽然提升了数据库的性能,但如果实体引用的值对象过多,则会在实体堆积一堆缺乏概念完整性的属性,这样值对象就会失去业务含义,操作起来也不方便。
值对象的不可变性,确保了值对象永远都是正确的,在并发环境下不会被意外修改。所以在它同时被多个实体引用时,可以实现重用和共享,从而提高系统性能。
鉴于值对象比实体更轻量级、高性能且线程安全,所以一般建议将领域对象优先设计为值对象,而非实体。你可以对照着以上这些优劣势,结合你的业务场景,好好想一想。如果在你的业务场景中,值对象的这些劣势都可以避免掉,那就请放心大胆地使用值对象吧。
实体和值对象的关系
实体和值对象都是微服务底层的最基础的领域对象,一起实现领域模型最基本的核心领域逻辑。值对象和实体在某些场景下可以互换。
其实,很多DDD专家在某些场景下,也很难判断到底应该将领域对象设计成实体还是值对象。可以说,值对象在某些场景下可以带来很好的价值,但并不是所有场景都适合值对象。你需要根据团队的设计和开发习惯,以及上面的优势和局限分析,选择最适合的实现方式。
另外,很多值对象的数据可能来源于其他聚合,它们以数据冗余的方式完成不同领域中数据的流转和共享。在值对象的数据源头聚合,以实体或聚合根的形式存在,完成实体和数据的集中维护和生命周期管理。而在自己的聚合中它则以值对象的形式存在,被聚合内的某一个实体引用。例如:在订单聚合中,订单实体有收货地址这个值对象。在生成订单实体时,会从个人中心的客户聚合中,获取地址实体数据组合成订单聚合的地址值对象。订单实体可以整体引用和修改地址值对象的数据,但不允许单独修改地址值对象的某一个属性数据,如street。所有地址数据的新增和修改等维护操作,都只能在客户聚合中完成,这样就可以实现业务职责的高内聚,也就是说,如果你要修改某个业务行为或数据,只需要修改一处就可以了。客户聚合中地址实体的数据是其他聚合地址值对象的数据源头
由于不同聚合中实体和值对象的这种关系,值对象还有一个重要的使用场景,那就是记录和生成业务的数据快照。值对象以数据冗余的方式记录业务发生那一刻前后序聚合之间的业务数据,还原业务发生那一时刻的数据场景。比如订单聚合在下单时会记录订单生成那一刻的商品和收货地址等概要基础数据信息,我们称之为跟单数据。这时订单聚合的商品和收货地址是以包含多个属性的属性集值对象的形式存在的,它们被订单聚合根引用。属性集值对象的设计方式与通过商品ID或地址ID单一属性值对象关联的方式不同,当商品或地址的源端聚合的商品实体或地址实体数据变更后,不会影响订单聚合中商品和收货地址值对象的快照数据,这样就可以记录业务发生那一刻的业务快照数据了。即使源端商品或地址所在聚合出现服务不可用的情况,也不会影响订单聚合中商品或地址相关的业务逻辑,很好地实现了应用的解耦和故障隔离。
如果你不关心业务发生时刻的数据,每次都实时获取最新的商品或地址数据,那么采用关联ID这种单一属性值对象的方式就可以了。不过这种实现方式会导致频繁的跨聚合查询,有时可能会因为聚合分散在不同的微服务中,而出现频繁地跨微服务的数据查询调用操作,增加微服务之间的耦合度。
小结
实体和值对象的目的都是抽象聚合若干属性以简化设计和沟通,两者都是经过属性聚类形成。实体着重唯一性和延续性,不在意属性的变化,即使属性全变了,它还是原来的那个它。值对象着重描述性,对属性的变化很敏感,属性变了,它就不是原来那个它了。
实体和值对象在DDD从战略设计向战术设计的推进过程中,在不同的阶段有不同形态。这个过程是从领域模型向系统模型落地的过程,比较复杂,也比较考验你的设计能力,很多时候我们都要结合自己的业务场景,选择最合适的方法来进行微服务设计。