一、实体是什么

开发者对实体或者Entity这个词语并不陌生,特别是在Java社区当中,这同数据库设计以及Hibernate的流行有关。我们先来看一下传统认知中的实体是什么。

1. 数据库设计中的实体

数据库设计有三个阶段:概念设计、逻辑设计、物理设计。我们在概念设计阶段绘制的E-R(Entity-Relationship)图,就有实体这个关键词。这里的实体,指的是现实世界对象或者事物的抽象,比如,读者、图书等。

来看一下数据库设计中的实体长什么样

实体属性

实体属性

实体关系

实体关系

这跟我们认知中的"实体"的概念似乎差不多。

归纳一下:就是一个对象,对象中有不同的属性,实体之间存在某种联系。

2. Hibernate中的实体

Hibernate中,也存在实体的概念。我们通常最常用的一个注解就是@Entity。通过Hibernate强大的映射关系可以将Entity

同数据库的表、字段做映射。在使用上,常规做法是Entity同数据库表以及属性都一一对应,可以理解为Entity代表着数据库的表,其实就跟数据库设计中的”实体“差不多。

而数据库的表仅仅是用于存储数据,Hibernate的Entity可以视为数据的载体,这是一个贫血模型

简单归纳一下,Hibernate的Entity,指的是数据模型 + getter/setter + 一堆映射

3.DDD中的实体

3.1 识别伪实体

回顾了数据库设计的实体和Hibernate的实体,我们发现,这些实体虽然代表了一个对象,但是在面向对象的设计(OOD)的几个要素中,它到底满足了什么面向对象的什么要素?似乎仅仅只有对象的属性,另外一个要素行为(方法)并没有得到体现。这时候,我们的关注点会被放在数据上,会重点关注表和字段、以及表的关联关系。

这跟传统的三层架构MVC一样,虽然我们在嘴上天天说面向对象,但是实体和MVC分层架构一点也不OOD。这也就意味着:我们用OOA的方式建模,但是却用OOP的方式写代码。虽然在简单的项目中我们可以凭借经验、文档来维持模型同代码之间的关系,但是随着需求的变更和人员的变动,项目慢慢变得不可控。

可见,它们都是"伪实体"。

3.2 DDD的实体

在DDD中的实体,指的是真正意义上的面向对象的实体,实体不再是一个数据对象(只有属性),它还包含了对象应该具有的属性,它是一个充血模型。这时候,我们将数据模型转换成了实体模型

实体应该是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,而不是同数据库表保持一致,并同数据库的持久化方式无关。

从下面两个角度来区别:

  • 数据角度。传统意义的实体其实是DO对象,一个DO代表着一张数据库表;而DDD中的实体Entity可以映射成多个不同的DO对象
  • 业务领域角度。传统意义的实体无法描述完整的业务(仅能描述业务的基本属性);而DDD中的实体Entity反映了某个业务领域中一种角色的业务本身(属性以及业务行为)。

4. 实体的特征

DDD将代码同业务模型关联起来,让代码反应业务。所以接下来,我们会多次强调一个概念:代码揭示业务,代码反映业务。

4.1 唯一标识

在DDD中,每个实体都是唯一的,所以需要一个唯一标识;需要唯一标识的原因是,实体是可变的,具有可变性,一个实体会具备不同的状态,我们需要通过唯一标识来区分实体。

注意,这里的唯一标识,指的是业务标识。比如身份证号,这是事实上存在的人的唯一标识;而把人作为数据存到数据库的时候,会设立一个自增的id(userId),这叫做委派标识,是为了方便存取而设立的同业务无关的唯一标识,通常用来做数据库主键。我们要将两种标识要区别开来。DDD说的唯一标识,通常指的是业务标识,或者是一个包含二者的值对象。

接下来我们对值对象进行一个说明。值对象也是DDD中的一个组件,通常,值对象会放到一个实体里面。来看一下例子

public class User{
	String name;
  int age;
  Address liveAddress;
  public void sleept(){
    ...
  }
  public void getup(){
    ...
  }
  ...
}

上述User实体中的Address就是一个值对象,它指的是一组有相关性的、不可变的、无状态的一组属性的集合,当然这里的不可变,不是绝对不可变,而是指它是客观存在并且是无状态的,而User是一个有状态的实体。

这里可能有点难理解。再解释一下,一个人换了地方住,变的是这个User的Address(只是liveAddress引用的变化),而Address本身还是客观存在不变的,是值对象。如果一个User无了,就真的完蛋了,所以说User是有状态的,是实体。

怎么判断是否应该将一组属性建模成为值对象?

  • 业务唯一标识缺失性。为什么强调是无唯一的业务标识?因为我们在DDD的过程中要从领域的视角出发而不是从数据库出发。就比如Address,虽然在定义Address的时候可能会带一个id,但是要注意,这个id是数据库标识,在业务领域上是不存在的,从业务上我们如何判断是一个地址?是不是通过邮编+省+市+街道+小区+门牌号,这些属性共同决定了一个整体。

  • 整体性。这一组属性是否是有关联系,并能够作为一个整体而存在。

  • 不变性。值对象是客观存在无状态的、是不变的。假如一个人换了地方住,变的是这个User的Address(只是liveAddress引用的变化),而Address本身还是客观存在不变的;如果我在数据库编辑Address或者删掉Address,是不是发生了变化?错了,DDD是围绕着业务领域建模的,无论编辑Address还是删掉Address,只是从数据角度进行了处理罢了,不会对真实对象造成影响。

  • 依附性。值对象一般用于描述实体的某一部分特征,所以值对象需要依附在实体上。

4.2 值对象的好处

4.2.1 语义增强

值对象的出现至少具备了一点好处:语义增强

用值对象表达整体概念

假设一个User中的Address有很多,居住地、工作地,用值对象来表示,就只需要liveAddress和workAddress表示即可。

如果采用值对象:

public class User{
	...
  Address liveAddress;
	Address workAddress;
  ...
}

public class Address{
  String street;
	String province;
  String city;
  String houseNo;
}

可以看到,上述代码不仅揭示了实体同值对象之间的关系,还揭示了Address中一组属性的整体性。

而如果没有值对象,就会变成:

public class User{
	...
  String workStreet;
	String workProvince;
  String workCity;
  String workHouseNo;
  
  String liveStreet;
	String liveProvince;
  String liveCity;
  String liveHouseNo;
  ...
}

如果此时有人问你:一个地址包含哪些属性?

你一定会急忙去翻代码查看。如果这时候我们要修改地址,你是否能确保能将所有的地址的字段都修改完整而不是漏了一两个字段(这时候你只能去看设计这个类的人是怎么修改的)?

这样的代码是没有办法反映出真实的业务情况的,也就是说代码与业务脱离了联系。

用值对象封装唯一标识

再举一个例子,来看下面这个类。

public class User{
  //委派标识-系统编号
	Long sysId;
  //姓名
  String name;
  //身份证号
  String idNo;
  //居住地
  Address liveAddress;
  ...
}

如果一个实体中同时存在业务标识委派标识,从数据库设计层面上,我们称之为联合主键,如何准确识别出二者之间的这种关系呢?

对于以上的User类,如果此时我问你 “联合主键是哪两个字段?” 你是不是需要思考好一会儿,甚至要去数据库查DDL,然后才能给我答复。

如果我们采用值对象。

public class User{
  //标识
	Identity identity;
  //证件号
  String idNo;
  //居住地
  Address liveAddress;
  ...
}

public class Identity{
  //委派标识-系统编号
	Long sysId;
  //姓名
  String name;
}

这时候我再问出同样的问题,你是不是马上就能告诉我答案了呢?值对象Identity的引入,标示了在当前领域中,“一个系统(sysId)中,用户名(name)不允许重复” 这样的业务含义。

我们这时候就会说,这是反映业务知识的代码。

4.2.2 降低实体类的复杂度

如果值对象中的属性全部扎堆放在实体类,那么实体类就被迫承担了一些原本不应该属于它的职能。

假设我们现在需要校验地址信息是否完整。

如果没有用到值对象,代码如下所示:

public class User{
  String workStreet;
	String workProvince;
  String workCity;
  String workHouseNo;
  ...  
  public void setWorkHouseNo(String workHouseNo){
    if (StringUtils.isEmpty(workHouseNo)) {
      throw new IllegaleException("房间号为空");
    }
    this.workHouseNo = workHouseNo;
  }  
}

我们在setWorkHouseNo方法中对workHouseNo做了非空的判断,但是实际上这个非空的判断逻辑跟User实体一点关系也没有,这相当于给实体类强行背上了一个包袱。

如果加上值对象,则如下所示:

public class User{
	Address workAddress;
  ...
}

public class Address{
  String street;
	String province;
  String city;
  String houseNo;
  public void setWorkHouseNo(String workHouseNo){
    if (StringUtils.isEmpty(workHouseNo)) {
      throw new IllegaleException("房间号为空");
    }
    this.workHouseNo = workHouseNo;
  }  
}

使用值对象后将会帮助实体类卸下沉重的包袱,降低实体类的复杂度。

二、实体的的设计过程

那么该如何设计出一个实体。

2.1 寻找实体的本质特征

2.1.1 同领域专家一起思考

如果让纯研发人员来设计,势必会变成一个典型的数据建模的过程:建表、建字段、对象如何映射、甚至加上很多技术用语、技术实现细节用到的字段。这样建立出来的模型领域专家是读不懂的。这时候,领域专家也应该加入其中。

2.1.2 构建或者完善统一语言

研发人员需要同领域专家一同完善统一语言。领域涉及那些对象、对象的名字叫什么,对象之间是如何区分的。这里面会涉及到很多名词、形容词、动词。

其中,名词用于给概念命名;形容词用于描述概念;动词用于描述动作。

2.1.3 从统一语言中讨论实体的本质特征

DDD其实是一个不断迭代和改进的软件设计方法。所以在起步的时候,不要想着一步到位,我们先把最重要的东西梳理好。

对于实体而言。我们首先要关注的应该是这个实体是什么,它与其他实体的本质区别是什么。

所以,首先要考虑的是实体的本质特征,即是实体的唯一标识和对实体的查找,而不是实体的属性和行为,只有对实体的本质特征有用的情况下,我们才把它加入到实体中。而只有使用从统一语言中提取出来的信息来建模,才是整个团队都能读得懂的。

假设此时团队已经形成了一部分统一语言,那么可以问自己下面几个问题:

Q1:当前有哪些实体?

你当前思考的领域中,有哪些实体,写出他们的名字。

Q2:这些实体的唯一标识是什么?

这些有哪些需要委派标识,领域标识和委派标识分别是什么。

Q3:唯一标识如何生成?

是用数据库自增的方式,还是在代码层面生成,还是用外部的ID生成公共服务来生成,为什么?

Q4:唯一标识何时生成?

常规情况下,一般是数据库自增即可,但是如果是事件驱动的方式,将一个新增的实体扔到消息队列之前我们必须提前生成唯一标识,否则这个实体将会变得不可追踪。

Q5:要查找一个实体,需要通过哪些要素?

是需要根据姓名去查找实体,还是根据类型去查找?在如何查找出一个对象的讨论中,我们将会得到更多有用的实体属性

Q6:现有属性中,哪些属性可以封装为值对象?

对上面的讨论结果进一步建模,让实体模型变得更健康。

2.1.4 基于需求和通用语言挖掘实体的关键行为

经过上面的过程,我们可以拿到实体的重要属性。接下来可以转向实体的行为,我们仍然先去寻找实体的关键行为。

关键行为应该从需求中挖掘。

挖掘实体行为的过程中,我们要重点强调语义:让代码反映具体的业务。

假设有以下需求:

用户有时候会搬家,所以需要修改居住地址。

很容易可以想到需要新增一个属性liveAddress

public class User{
	private Address liveAddress;
}

用户可以修改居住地址,意味着需要对liveAddress做整体的替换。研发人员会首先想到加上一个setter

public class User{
	private Address houseAddress;
  public void setHouseAddress(Address houseAddress){
    this.houseAddress = houseAddress;
  }
}

上述代码可以描述“修改地址”这样的概念,但是“搬家”的概念却丢失了,这时候我们的接口不能反映需求真实的意图。所以,我们要修改一下方法的命名。

public class User{
	private Address houseAddress;
  public void movingHouse(Address houseAddress){
    this.houseAddress = houseAddress;
  }
}

将setHouseAddress改成movingHouse,这时候实体行为才是真正反映了真实的业务行为。所以,在挖掘关键行为的过程中,我们始终需要反思一下:当前的属性和行为是否真正反映了业务行为

发现关键行为的方法

除了讨论/头脑风暴这种的方式以外,我们还可以通过测试驱动的方式发现新的重要行为。比如,我们现在要对上述的 "用户有时候会搬家,所以需要修改居住地址" 这条需求编写测试用例。

  @Test
  public void testMovingHome(){
    User user = ...
    Address newHouseAddress = new Adress(...);
    user.movingHouse(new A);
    AssertTrue(newHouseAddress.equals(user.getHouseAddress()));
  }

可以发现,我们挖掘出了getHouseAddress()这个方法。

2.1.4 基于角色建模实体

对实体建模有一个很重要的方面:发现对象的角色和职责。我们需要思考我们当前的实体对象,是不是同时承担了多个角色应该承担的职责。

比如对于Person实体,Person的角色可能是Teacher(他是学校老师),也可以是Student(他是在读博士,是从博士生导师)。不同的角色有不同的属性和行为。如果把Teacher和Student的属性和行为都放到Person中,Person就承担了太多的职责了。

如果一个人具备多种角色,角色建模有两种不同的方式。

  • 将角1.色抽象为接口。让Person实现同时实现Teacher接口和Student接口;
  • 将角色抽象为类。让Person分别作为Teacher和Student的属性存在;
2.1.4.1 将角色抽象为接口

先来来第一种方法:

public interface Teacher{
  //授课(老师)
  void giveAclass();
}
public interface Student{
  //上课(学生)
  void takeAclass();
}

//Person同时实现两个接口
public class Person implements Student,Teacher{
  private String name;
  ... 
  public void takeAclass(){
    ...
  }
  public void giveAclass(){
    ...
  }
}

public class Test{
  public static void main(String args[]){
    //1.实现类
    Persion person = getPerson();//省略创建Person的过程
    //2.场景一:学生上课
    Student student = (Student)persion
    student.takeAclass();
    //3.场景二:老师授课  
    Teacher teacher = (Teacher)persion  
    teacher.giveAclass();
  }
  
}

角色抽象为接口的时候,耦合在于实现类。在不同的场景下,我们将实现类Person转化为角色来使用,这样能避免在不同的场景下暴露出不必要的行为。

2.1.4.2 将角色抽象为类

接下来我们看一下第二种方法:

public class Teacher{
  Pserson person;
	public void giveAclass(){
    ...
  }
}
public interface Student{
  Pserson person;
  public void takeAclass(){
    ...
  }
}

//Person同时实现两个接口
public class Person implements Student,Teacher{
  private String name;
}

public class Test{
  public static void main(String args[]){
    //1.创建Person
    Persion person = new Person("Jack");
    //2.场景一:学生上课
    Student student = new Student(person);
    student.takeAclass();
    //3.场景二:老师授课  
    Teacher teacher = new Teacher(person);
    teacher.giveAclass();
  }
  
}

通过创建不同角色类,我们便可以使用不同的类来执行方法。

2.4.1.3 二者的差别

从语义上看,二者都能够表达出相同的概念。

但是思考一下,对于上述授课和上课两个不同的场景来说,第1~3个步骤中,领域的核心行为其实是第2步和第3步。

  • 将角色抽象为接口的时候:无论实现类Person怎么变化,领域核心行为是不变的,无论Person怎么变化,第2~3步骤的代码逻辑都是不变的。这是面向接口编程所带来的好处。但是Person实现类也因此承担了较多的职责;
  • 将角色抽象为类的时候:实现类的变化会影响领域核心行为,因为角色本身就是一个实现类;但是Person类和2个角色类的职责相对更分明一些;

在DDD中更推崇将角色抽象为接口,我们会比较重视模型的稳定性:在实现类上随便耦合,从而得到一个更稳定的模型。而在实际中我们应该综合考虑不同的场景,使用不同的方式来对角色建模。