产品代码都给你看了,可别再说不会DDD(七):实体与值对象
这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云(https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。

本系列包含以下文章:
案例项目介绍
既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。
码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。
在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语。
在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。
码如云的源代码是开源的,可以通过以下方式访问:
实体与值对象
在本系列的上一篇聚合根与资源库中,我们讲到了聚合根的设计与实现,事实上聚合根本身即是一种实体(Entity),在本文中我们将对实体以及与之相对立的值对象(Value Object)做展开讲解。
在对聚合根的深入分析中,我们发现其中存在两种类型的对象,一种是具有生命周期的对象(比如成员(Member)),另一种是只起描述作用的对象(比如地址(Address)),前者称为实体,后者称为值对象,充分认识这两种对象之间的区别,对DDD落地有着举足轻重的作用。我们希望达到的目的是,将尽量多的概念建模为值对象,因为值对象比实体更加简单。

实体的生命周期意味着实体具有从产生到消亡的整个过程,这个过程往往比较漫长。比如,在码如云中,成员(Member)对象的生命周期可能超过几年甚至几十年的时间。相比之下,值对象不存在生命周期可言。为了讲解更加直观,让我们来分别看看值对象和实体的例子。在码如云中,地址(Address)即是一个值对象:
//Address
@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class Address {
private final String province; //省份
private final String city; //城市
private final String district; //区县
private final String address; //详细地址
//......此处省略更多代码
}
聚合根成员(Member)则是一个实体对象:
//Member
@Getter
@Document(MEMBER_COLLECTION)
@TypeAlias(MEMBER_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Member extends AggregateRoot {
private String name;//名字
private Role role;//角色
private String mobile;//手机号
private String email;//邮箱
private IdentityCard identityCard;//身份证
//...此处省略更多代码
}
咋一看,实体和值对象似乎没有什么区别,都是Java对象而已,但事实上,实体和值对象在唯一标识、相等性和可变性等方面均存在很大的区别。
唯一标识
值对象的“描述性作用”也意味着它无需唯一标识(即ID)即可完成其使命,而实体则恰恰相反。在本例中,值对象Address没有ID,而实体Member的唯一标识则存在于其父类AggregateRoot的id字段中:
//AggregateRoot
@Getter
public abstract class AggregateRoot implements Identified {
private String id;//聚合根ID
private String tenantId;//租户ID
//...此处省略更多代码
}
更多关于AggregateRoot的内容,请参考本系列的聚合根与资源库一文。
在DDD中,所有的聚合根都是实体对象,但并不是所有的实体都是聚合根,不过从实践上来看了,绝大多数的实体对象都是聚合根。因此,在DDD项目中最常见的情况是:作为实体对象的聚合根包含了大量的值对象。
对于聚合根而言,由于已经是领域模型中的顶层对象,其唯一标识应该是全局唯一的;而对于聚合根下的其他实体而言,由于其作用范围被限制在了聚合根内部,因此对应的唯一标识在聚合根下唯一即可。比如,在码如云中,一个应用(App)包含了多个页面(Page),App是聚合根,Page是App下的实体,App的ID必须全局唯一,而Page的ID在其所属的App下唯一即可。
实体的唯一标识可以有多种方式生成,有些业务数据天然即是唯一标识,比如对于人员来说,身份证号即可直接用于唯一标识。不过需要注意的是,只有那些不变的业务字段才能用于唯一标识,否则,当这些业务字段发生更新时,所有引用它的地方都需要做相应更新。更多的时候,我们建议采用一个无业务含义的ID作为唯一标识,比如UUID或者通过雪花算法生成的ID等,又由于UUID的无序性在大数据量场景下可能存在性能问题,因此我们更偏向于雪花算法ID。
有些技术框架可以设置延后对实体ID的生成,比如Hibernate和数据库自增ID等,在DDD中,我们强烈建议不要采用这些方式,因为这些方式所创建出来的实体对象直到保存到数据库的最后一刻都是非法的,更好的方式是在新建实体时即为之设置ID。
在码如云中,我们通过雪花算法为聚合根生成ID,并且在构造函数中完成了对ID的赋值,以达到在新建时即为ID赋值的目的。比如,在Member对象的其中一个构造函数中,我们调用了newMemberId()为新成员生成ID:
//Member
//创建Member
public Member(String name, String mobile, User user) {
super(newMemberId(), user);
this.name = name;
this.mobile = mobile;
//...此处省略更多代码
}
//通过雪花算法生成成员ID
public static String newMemberId() {
return "MBR" + newSnowflakeId();
}
有时,为了一些纯技术上原因,我们需要为值对象设置ID。比如,如果采用通过ORM框架持久化租户(Tenant),则需要将Tenant中的发票地址(invoiceAddress)保存到一张单独的数据库表中,由于数据库表之间需要有外键关联,因此需要将Address继承自一个层超类IdentifiedValueObject,在IdentifiedValueObject中包含有用于数据库表外键关联的id字段。
此时的Tenant实现如下:
//Tenant
@Getter
@Document(TENANT_COLLECTION)
@TypeAlias(TENANT_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Tenant extends AggregateRoot {
private String name;//租户名称
private InvoiceTitle invoiceTitle;//发票抬头
private Address invoiceAddress;//发票地址
//...此处省略更多代码
}
层超类IdentifiedValueObject实现如下:
//IdentifiedValueObject
public abstract class IdentifiedValueObject {
private String id;
}
此时的Address继承自IdentifiedValueObject:
//Address
public class Address extends IdentifiedValueObject {
private final String province;
//...此处省略更多代码
}
需要强调的是,以上“为值对象设置ID”的做法仅仅是一种技术上的实践,不能将其与业务相混淆,为此我们引入了一个层超类IdentifiedValueObject将与技术相关的内容作为一个单独的关注点来处理,从而实现了技术与业务的隔离。不过,在码如云,由于我们采用了MongoDB,从而避开了ORM,因此不存在本例中的问题。
相等性判断
实体对象通过ID进行相等性判断,而值对象通过其自身携带的属性进行相等性判断。举个例子,对于一对双胞胎而言,每人都是一个实体对象,由于二人的身份证号(唯一标识)是不同的,因此无论二人长得多么的相像,均不能认为是同一个人;相反,对于其中某一人来说,哪怕是整容到面目全非,也依然是同一个人,因为其ID始终没变。又比如,对于常见的值对象货币(Currency)而言,其价值通过其面值决定,因此一张刚从印钞厂出来的崭新百元大钞和一张沾满了细菌的百元纸币是可以等值互换的,因为它们所携带的面值是相同的。
在编码实践上,最显著的区别是值对象需要实现equals()和hashCode()方法,而实体则不需要。在码如云中,我们通过Lombok为值对象自动生成equals()和hashCode()方法,比如对于存储身份证信息的IdentityCard,其实现为:
//IdentityCard
@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class IdentityCard {
private String number;
private String name;
}
其中的@Value注解隐式地为IdentityCard对象实现了equals()和hashCode()方法。
可变性
实体和值对象的另一个区别是:实体对象是可变的(Mutable),而值对象是不可变的(Immutable)。对于实体对象而言,我们可以通过调用其上的方法直接更改其状态;而对于值对象而言,如果需要改变其状态,我们只能创建一个新的值对象,然后在新对象中包含改变后的状态。
对实体对象的直接状态变更比较好理解,这里重点讲一讲对值对象的不可变性的编码处理。对于值对象Address,如果我们需要修改其下的详细地址,具体的实现如下:
//Address
//修改详细地址
public Address changeTo(String detailAddress) {
return Address.builder()
.province(this.province)
.city(this.city)
.district(this.district)
.address(detailAddress)
.build();
}
这里,我们并未直接修改Address对象的address属性,而是新建了一个Address对象,然后将无需修改的字段(比如provice)原封不动地拷贝到新对象中,而将需要修改的字段(address)在新对象中设置为传入的最新值,最后返回这个新建的对象。
不可变性要求值对象必须满足以下约束:
- 不能有共有的setter方法,否则外界可以直接修改其内部的状态
- 不能有导致内部状态变化的共有方法
值对象的好处
本文一开始就提到我们应该将尽量多的对象建模为值对象,因为它比实体更加的简单,事实上值对象有多种好处。
首先,因为值对象是不可变的,所以不可变对象所拥有的好处值对象都有,比如使得对程序的调试和推理更加的简单,线程安全等。
其次,值对象作为一个概念上的整体(Conceptual Whole),它将与之相关的业务逻辑包含在其内部,不仅体现了内聚性,也增加了业务表达力,而这正是DDD所提倡的,比如对于本文中的Address,你是希望直接操作4个原始字段(province,city,district和address)呢,还是操作一个Address对象呢?
另外,值对象由于也包含了业务逻辑,因此可以完成自我验证,这样无论何时我们拿到一个值对象时,都可以相信这是一个合法的对象,而不用在值对象之外再做验证。
例如,在码如云中,定位信息被存放在Geopoint值对象中:
@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class Geopoint {
private static final float EARTH_RADIUS_METERS = 6371000;
private final Float longitude;//经度
private final Float latitude;//纬度
public float distanceFrom(Geopoint that) {
return distanceBetween(this.longitude, this.latitude, that.longitude, that.latitude);
}
private float distanceBetween(float lng1, float lat1, float lng2, float lat2) {
double dLat = Math.toRadians(lat2 - lat1);
double dLng = Math.toRadians(lng2 - lng1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
return (float) (EARTH_RADIUS_METERS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
}
public boolean isPositioned() {
return longitude != null && latitude != null;
}
}
可以看到,Geopoint将经度longitude和纬度latitude封装在一起,成为一个概念上的整体。外部调用方无需单独处理经度和纬度数据,而是直接通过这个整体性的Geopoint对象即可完成对定位信息的操作。此外,distanceFrom()和distanceBetween都是包含业务逻辑的方法,符合“行为饱满的领域对象”原则。再则,通过isPositioned()方法使得Geopoint可以自行完成业务验证。
角色可变
实体和值对象的划分并不是固定不变的,而是根据其所处的限界上下文决定的。一个概念在一个上下文中是一个实体对象,但是在另外的上下文中则可能是一个值对象。比如,对于上文中的货币Currency,在日常的的交易活动中,货币很明显应该被建模为一个值对象,因为在对其抽象之后我们忽略了货币的颜色,编号,新旧程度等属性,而只关注其面值。但是,如果哪天央行要做一个系统来管理每一张货币(比如对每张货币进行位置跟踪),那么则需要根据货币的编号进行管理,此时的货币则变成了一个实体对象。

总结
实体和值对象是领域对象中的两种不同类型的对象,它们在唯一标识、相等性和可变性等方面均存在不同。在DDD项目中,所有的聚合根均是实体,但是在实际建模过程中,由于值对象在不变性等方面的好处,我们应该尽量将业务概念建模为值对象。在下文应用服务与领域服务中,我们将对应用服务和领域服务做详细讲解。
产品代码都给你看了,可别再说不会DDD(七):实体与值对象的更多相关文章
- 瞧一瞧,看一看呐,用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!!
瞧一瞧,看一看呐用MVC+EF快速弄出一个CRUD,一行代码都不用写,真的一行代码都不用写!!!! 现在要写的呢就是,用MVC和EF弄出一个CRUD四个页面和一个列表页面的一个快速DEMO,当然是在不 ...
- iOS开发UI篇—从代码的逐步优化看MVC
iOS开发UI篇—从代码的逐步优化看MVC 一.要求 要求完成下面一个小的应用程序. 二.一步步对代码进行优化 注意:在开发过程中,优化的过程是一步一步进行的.(如果一个人要吃五个包子才能吃饱,那么他 ...
- 每一行代码都有记录—如何用git一步步探索项目的历史
每一行代码都有一块被隐藏了的文档信息. 下面的代码片段不管是谁写的,其第4行因为某些原因要访问一个DOM结点的clientLeft属性,但却对结果不作任何处理.这十分的莫名其妙,你能告诉我他们为什么要 ...
- Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!
本文原题“从实践角度重新理解BIO和NIO”,原文由Object分享,为了更好的内容表现力,收录时有改动. 1.引言 这段时间自己在看一些Java中BIO和NIO之类的东西,也看了很多博客,发现各种关 ...
- 圣诞节,把网站所有的js代码都压缩成圣诞树吧。
本文分两章节,分别讲解如何使用js2image这个库生成可以运行的圣诞树代码 和 js2image的原理. github地址:https://github.com/xinyu198736/js2ima ...
- delphi 动态绑定代码都某个控件
delphi 动态绑定代码都某个控件 http://docwiki.embarcadero.com/CodeExamples/Berlin/en/Rtti.TRttiType_(Delphi)Butt ...
- 产品经理都知道MVP,但是它可能不再是产品研发最好的模型了
产品经理都知道MVP,但是它可能不再是产品研发最好的模型了 孟小白Aspire • 2017-09-01 • 汽车交通 要简单.讨喜.完整,不要最小可行性产品.这对创业公司的第一个产品来说很重要. M ...
- appium+python解决每次运行代码都提示安装Unlock以及AppiumSetting的问题
appium+python解决每次运行代码都提示安装Unlock以及AppiumSetting的问题(部分安卓机型) 1.修改appium-android-driver\lib下的android-he ...
- 使用Junit测试一个 spring静态工厂实例化bean 的例子,所有代码都没有问题,但是出现java.lang.IllegalArgumentException异常
使用Junit测试一个spring静态工厂实例化bean的例子,所有代码都没有问题,但是出现 java.lang.IllegalArgumentException 异常, 如下图所示: 开始以为是代码 ...
- 轻松解决 CSS 代码都在一行的问题
前言 最近在做博客园的界面美化,用的是博客园[guangzan]的开源项目,配置超级简单,只需要复制粘贴代码就好啦. 但在粘贴 CSS 代码时遇到一个问题,那就是所有代码都挤在了一行,没有一点排板的样 ...
随机推荐
- C++面试八股文:知道std::unordered_set/std::unordered_map吗?
某日二师兄参加XXX科技公司的C++工程师开发岗位第27面: 面试官:知道std::unordered_set/std::unordered_map吗? 二师兄:知道.两者都是C++11引入的新容器, ...
- 超详细整合SSM框架--(Spring + Spring MVC + MyBatis)
超详细整合SSM框架--(Spring + Spring MVC + MyBatis) 阅读该文章之前首先要清楚Spring框架,SpringMVC框架,Mybatis框架. SSM框架,是Sprin ...
- linux_mint_21 vim配置:剪贴板支持和输入法自动切换
一.vim的剪贴板支持 有的vim版本不支持系统剪切板,也就是说在vim编辑器外面复制的内容,不能够粘贴到vim中;在vim中通过yy.d.c复制剪切的内容也不能粘贴在vim编辑器外面. 这实在是有点 ...
- PostgreSQL 10 文档: 系统表
第 51 章 系统目录 目录 51.1. 概述 51.2. pg_aggregate 51.3. pg_am 51.4. pg_amop 51.5. pg_amproc 51.6. pg_attrde ...
- TodoList - 开源研究系列文章
接着上次的代码,整理出一个待办列表的程序,比较简单易用,读者可自行修改成自己需要的程序. 1. 项目目录: 目录见下图,对代码进行了划分,比较简单.主处理类在Helper目录里. 2. ...
- TCP超时分析
参考链接: Linux 建立 TCP 连接的超时时间分析 Linux 建立 TCP 连接的超时时间分析 Linux 系统默认的建立 TCP 连接的超时时间为 127 秒. 2 分 7 秒即 127 秒 ...
- Redis从入门到放弃(3):发布与订阅
1.介绍 Redis是一个快速.开源的内存数据库,支持多种数据结构,如字符串.哈希.列表.集合.有序集合等.除了基本的数据存储和检索功能外,Redis还提供了许多高级功能,其中之一就是发布订阅(Pub ...
- 压缩CSS样式与js样式
方法一: 使用插件:JS & CSS Minifier (Minify) 使用方法: 效果: 或者按下F1,输入命令:Minify:Document
- python分割多个分隔符
想一次指定多个分隔符,可以用re模块 import retext='3.14:15'result = re.split('[.:]', text)print(result) 输出结果如下: ['3', ...
- [mysql]MGR简介与部署
前言 MySQL Group Replication,简称MGR,是MySQL官方于2016年推出的一个全新的高可用解决方案,采用Paxos分布式一致性协议作为高可用和一致性解决方案.在MGR之前的高 ...