写点什么

DDD 领域驱动设计实战 (三)- 深入理解实体

作者:JavaEdge
  • 2021 年 12 月 20 日
  • 本文字数:4018 字

    阅读完需:约 13 分钟

DDD领域驱动设计实战(三)-深入理解实体

1 前言

实体是领域模型中的领域对象。


MVC 开发人员总将关注点放在数据,而非领域。因为在软件开发中,DB 占据主导地位。首先考虑的是数据的属性(即数据库的列)和关联关系(外键关联),而不是富有行为的领域概念。导致将数据模型直接反映在对象模型,那些表示领域模型的实体(Entity)被包含了大量 getter/setter。虽然在实体模型中加入 getter/setter 并非大错, 但这不是 DDD 做法。


过于强调实体的作用却忽视了值对象。受到 DB 和持久化框架影响,实体被滥用,于是开始讨论如何避免大范围使用实体...

2 为什么使用实体

当我们需要考虑一个对象的个性特征或需要区分不同对象时,就引入实体这个领域概念。


一个实体是一个唯一的东西,可在一段时间内持续变化。这些对象重要的不是属性,而是其延续性和标识,会跨越甚至超出软件生命周期。


也正是 唯一身份标识和可变性(mutability) 特征将实体对象区别于值对象。


实体建模并非总是完美。很多时候,一个领域概念应该建模成值对象,而非实体。这意味着 DDD 开发 CRUD 软件系统时可能更适用。但由于只从数据出发,CRUD 系统是不能创建出好的业务模型的。


在可以使用 DDD 时,我们会将数据模型转变为实体模型。


通过标识区分对象,而非属性:此时应将标识作为主要的模型定义。同时保持简单类定义,关注对象在生命周期中的连续性和唯一标识性。不应该通过对象的状态形式和历史来区分不同实体……对于什么是相同的东西,模型应该给出定义。


如何正确使用和设计实体呢?

3 唯一标识

在实体设计早期,关注能体现实体身份唯一性的主要属性和行为及如何查询实体,忽略次要的属性和行为。


设计实体时,首先考虑实体的本质特征,特别是实体的唯一标识和对实体的查找,而不是一开始便关注实体的属性和行为。只有在对实体的本质特征有用的情况下,才加入相应的属性和行为。


找到多种能够实现唯一标识性的方式,同时考虑如何在实体生命周期内维持唯一性。实体的唯一标识不见得一定有助对实体的查找和匹配。将唯一标识用于实体匹配通常取决于标识的可读性。比如:


  • 若系统提供根据人名查找功能,但此时一个 Person 实体的唯一标识可能不是人名,因为重名情况很多

  • 若某系统提供根据公司税号的查找功能,税号便可作为 Company 实体的唯一标识


值对象可用于存放实体的唯一标识。值对象是不变(immutable)的,这就保证了实体身份的稳定性,并且与身份标识相关的行为也可得到集中处理。便可避免将身份标识相关的行为泄漏到模型的其他部分或客户端中去。

3.1 创建实体身份标识的策略

每种技术方案都存在副作用。比如将关系型 DB 用于对象持久化时,这样的副作用将泄漏到领域模型。创建前需考虑标识生成的时间、关系型数据的引用标识和 ORM 在标识创建过程中的作用等,还会考虑如何保证唯一标识的稳定性。


3.2 标识稳定性

绝大多数场景不应修改实体的唯一标识,可在实体的整个生命周期中保持标识的稳定性。


可通过一些简单措施确保实体标识不被修改。可将标识的 setter 方法向用户隐藏。也可在 setter 方法种添加逻辑以确保标识在已存在时不再更新,比如可使用一些断言:


  • username属性是User实体的领域标识,该属性只能进行一次修改,并且只能在 User 对象内修改。setter 方法setUsername实现了自封装性且对客户端不可见。当实体的 public 方法自委派给该 setter 方法时,该方法将检查username属性,看是否已被赋值。若是,表明该 User 对象的领域标识已经存在,程序将抛异常。

  • 这个 setter 方法并不会阻碍 Hibernate 重建对象,因对象在创建时,它的属性都是使用默认值,且采用无参构造器,因此username属性的初始值为 null。然后,Hibernate 将调用 setter 方法,由于 username 属性此时为 null,该 setter 方法得以正确地执行,username 属性也将被赋予正确的标识值。

4 实体的形态

4.1 业务形态

战略设计时,实体是领域模型的一个重要对象。领域模型中的实体是多个属性、操作或行为的载体。


事件风暴中,可根据命令、操作或者事件,找出产生这些行为的业务实体对象,进而按业务规则将依存度高和业务关联紧密的多个实体对象和值对象进行聚类,形成聚合。


实体和值对象是组成领域模型的基础单元。

4.2 代码形态

即实体类,包含实体的属性和方法,通过这些方法实现实体自身的业务逻辑。采用充血模型:


  • 该实体相关的所有业务逻辑都在实体类的方法中实现

  • 跨多个实体的领域逻辑则在领域服务中实现

4.3 运行形态

实体以 DO(领域对象)形式存在,每个实体对象都有唯一 ID。可对实体做多次修改,所以一个实体对象可能和它之前状态存在较大差异。但它们拥有相同身份标识(identity),所以始终是同一实体。


比如商品是商品上下文的一个实体,通过唯一的商品 ID 标识,不管这商品的数据(比如价格)如何变,商品 ID 不会变,始终是同一商品。

4.4 数据库形态

DDD 先构建领域模型,针对业务场景构建实体对象和行为,再将实体对象映射到数据持久化对象。


在领域模型映射到数据模型时,一个实体可能对应 0、1 或多个数据库持久化对象。大多数情况下实体与持久化对象是一对一。在某些场景中,有些实体只是暂驻静态内存的一个运行态实体,无需持久化。比如,基于多个价格配置数据计算后生成的折扣实体。


有些复杂场景,实体与持久化对象可能是一对多或多对一:


  • 一对多用户 user 与角色 role 两个持久化对象可生成权限实体,一个实体对应两个持久化对象

  • 多对一有时为避免 DB 的联表查询,会将客户信息 customer 和账户信息 account 两类数据保存至同一张数据库表,客户和账户两个实体可根据需要从一个持久化对象中生成

实体本质的探索

一开始在 Java 代码中建模大量实体关系。将太多关注点放在数据库、表、列和对象映射上。导致所创建的模型实际上只是含有大量 getter/setter 的贫血领域模型。我们应该在 DDD 上有更多的思考。如果我们认为对象就是一组命名的类和在类上定义的操作,除此之外并不包含其他内容,那就错了。如果一些特定的领域场景会在今后继续使用,这时可以用一个轻量的文档将它们记录下来。简单形式的通用语言可以是一组术语和一些简单的用例场景。 但是,如果我们就此认为通用语言只包含术语和用例场景,那么我们又错了。在最后,通用语言应该直接反映在代码中,而要保持设计文档的实时更新是非常困难的,甚至是不可能的。

5 创建实体

新建一个实体时,我们总期望通过构造器就能初始化足够多的实体状态,因为这样更容易通过各种条件查找到该实体。


在使用及早生成唯一标识的策略时,构造器至少需接受唯一标识参数。若还有可能通过其他方式查找实体,比如名字或描述信息,那应该将这些参数一并传给构造器。


有时一个实体维护一或多个不变条件(Invariant,在整个实体生命周期中都必须保持事务一致性的一种状态) 。


不变条件主要是聚合所关注的,但聚合根也是实体。


如果实体的不变条件要求该实体所包含的对象都不能为 null 或必须由其他状态计算所得,那么这些状态也需作为参数传递给构造器。


public class User extends Entity {  ...  // 每一个User对象都必须包含tenantld、username, password和person属性。  // 即在User对象得到正确实例化后,这些属性不能为null  // 由User对象的构造器和实例变量对应的setter方法保证这点  protected User (Tenantld aTenantld          String aUsername,          String aPassword,          Person aPerson) (    this();    this.setPassword(aPassword);    this.setPerson(aPerson);    this.setTenantld(aTenantld);    this.setUsername(aUsername);    this.initialize();  }  ...  protected void setPassword(String aPassword) {     if (aPassword == null) {      throw new 11legalArgumentException(        "The password may not be set to null.");    )    this.password = aPassword;  )    protected void setPerson(Person aPerson) (    if (aPerson == null) (       throw new IllegalArgumentException("The person may not be set to null.");    }    this.person = aPerson;  }    protected void setTenantld(Tenantld aTenantld) (     if (aTenantld == null) {      throw new IllegalArgumentException("The tenantld may not be set to null.");     }    this.tenantld = aTenantld;  }  protected void setUsername(String aUsername) (    if (this.username != null) {      throw new IIlegalStateException("The username may not be changed.n);    }    if (aUsername == null) {      throw new IllegalArgumentException("The username may not be set to null.");     }    this.username = aUsername;  }  
复制代码


User 对象展示了一种自封装性。在构造器对实例变量赋值时,把操作委派给实例变量对应的 setter 方法,便保证了实例变量的自封装性。实例变量的自封装性使用 setter 方法来决定何时给实例变量赋值。


每个 setter 方法都“代表着实体”对所传进的参数做非 null 检查,这里的断言称为守卫(Guard)。setter 方法的自封装性技术可能会变得非常复杂。所以对于复杂的创建实体场景,可使用工厂。


User 对象的构造函数被声明为 protected。 Tenant 实体即为 User 实体的工厂也是同一个模块中唯一能够访问 User 构造器的类。这样一来,只有 Tenant 能够创建 User 实例。


public class Tenant extends Entity {  // 该工厂简化对User的创建,同时保证了Tenantld在User和Person对象中的正确性  // 该工厂能够反映通用语言。  public User registerUser(String aUsername,               String aPassword,               Person aPerson) {    aPerson.setTenantld(this.tenantld());    User user = new User(this.tenantld(), aUsername, aPassword, aPerson);    return user;}
复制代码


参考

  • https://tech.meituan.com/2017/12/22/ddd-in-practice.html

  • 《实现领域驱动设计》

  • 实体和值对象:从领域模型的基础单元看系统设计

  • https://blog.csdn.net/Taobaojishu/article/details/106152641

发布于: 4 小时前阅读数: 5
用户头像

JavaEdge

关注

正在征服世界的 Javaer。 2019.09.25 加入

曾就职于百度、携程、华为等大厂,阿里云开发者社区专家博主、腾讯云+社区2019、2020年度最佳作者、慕课网认证作者、CSDN博客专家,简书优秀创作者兼《程序员》专题管理员,牛客网著有《Java源码面试解析指南》。

评论

发布
暂无评论
DDD领域驱动设计实战(三)-深入理解实体