BOSS 应用DDD的 一些基础概念

本文是以最简单的方式去叙述DDD的概念。DDD 与 微服务 是紧密相关的, 所以必须是先了解 DDD 的方式才比较好的去实现 微服务。

1. 面对的问题 -- 为什么需要 DDD

  • 复杂的业务不停的变化, 各模块的耦合度不停的增大
  • 经常出现业务的分歧
  • 代码的编写方式, 不够好(都是贫血模型)
  • 不够清晰的业务边界
  • 可扩展性差
  • 无法快速的上线和功能的更新

2. 基本概念

1. 领域, 子域和界限上下文

  1. 一句话, 通用语言和限界上下文 是对领域驱动设计的关键
  2. 基础概念
  • 领域模型: 特定业务领域的软件模型。

栗子: 培训机构管理系统, 就是一个自己独有的领域, 这个是大的领域; 1对N 中, 他有就有自己独有的领域。
同时领域又是一个 单一的、内聚的、全功能的模型。

  • 通用语言 和 限界上下文: 通用语言 就是在 限界上下文中。 通用语言 是在这个限界上下文中的共享语言; 限界上下文 是业务的概念性的边界。

栗子1: 给学生分配学管师 张三, 在学生的限界上下文中, 分配某个学管师, 不是 USER; 张三给一对一课程考勤, 在一对一的限界上下文中, 张三 是考勤人, 不是User。 这时, 同一个 张三, 在 学生的团队里面交流的时候, 他就是一个学管师; 在 一对一团队中, 他就是 考勤人。 所以 通用语言是针对不同的限界上下文的, 一一对应的。
栗子2: 在 老师分配可教科目上下文 中, 老师只要是角色和姓名的属性,老师是本上下文的一个通用语言; 如果在登录上下文中, 那么他的登录的密码和账户、组织架构就是必要的属性, 登录principal是本上下文的一个通用语言。 但是 大家都是映射到 实体 User, 在不同的上下文中有不同的概念。

 **一个好的限界上下文中, 每个术语都应该是一个领域的概念。** 限界上下文 主要是一个 **语义**上的边界。
  • 子域/核心域: 就是限界上下文中的最核心的领域。 比如, 订单上下文, 订单和订单产品就是核心域。
  • 支撑子域 : 从不同的角度看, 不同域对应不同的限界上下文, 都有可能是 核心 或者 支撑, 只是角度的不同。

栗子: 一对一上下文中, 一对一课程是一个核心子域, 而考勤操作人员 是一个 支撑的资源; 但是从用户上下文, 人员就是一个核心域。

  • 通用语言 和 限界上下文: 通用语言 就是在 限界上下文中。 通用语言 是在这个限界上下文中的共享语言; 限界上下文 是业务的概念性的边界。

栗子1: 给学生分配学管师 张三, 在学生的限界上下文中, 分配某个学管师, 不是 USER; 张三给一对一课程考勤, 在一对一的限界上下文中, 张三 是考勤人, 不是User。 这时, 同一个 张三, 在 学生的团队里面交流的时候, 他就是一个学管师; 在 一对一团队中, 他就是 考勤人。 所以 通用语言是针对不同的限界上下文的, 一一对应的。
栗子2: 在 老师分配可教科目上下文 中, 老师只要是角色和姓名的属性,老师是本上下文的一个通用语言; 如果在登录上下文中, 那么他的登录的密码和账户、组织架构就是必要的属性, 登录principal是本上下文的一个通用语言。 但是 大家都是映射到 实体 User, 在不同的上下文中有不同的概念。

 **一个好的限界上下文中, 每个术语都应该是一个领域的概念。** 限界上下文 主要是一个 **语义**上的边界。
  • 限界上下文 包括了 表, 领域, UI, 应用服务等, SOA, REST, 等多种对象。
  • 限界上下文, 不大也不小, 足够描述本领域的意义即可。 有可能影响的是: 技术, 任务分配, 对业务部够理解。
  1. 如何实现 限界上下文和领域的划分
  • 抓住 通用语言 和 限界上下文

  • 具体步骤:

    1. 确定好 物理模型 和 概念性的模型, 并且确定名字和行为。 列出认为属于这个模块内的所有用例。栗子, 一对一考勤, 一对一排课, 一对一扣费等。
    2. 创建简单的 术语表。 从用例的关键字的出现频率中, 发现通用的术语。栗子, 一对一课程, 考勤操作人, 扣费操作人, 学生等。
    3. 确定好 哪些是 核心域, 哪些是 支撑域。
    4. 多沟通和 交流, 以代码为主, 逐渐抛弃文档。 栗子, 表述清晰的代码方法, 就是一个通用语言的描述。
    5. 做好测试后, 可以让业务人员都一起来看 业务是否写得正确。
    6. 在Idea 中, 限界上下文 表现出 module的方式

栗子: 只有学管师可以对一对一的课程进行扣费, 下面的代码是领域服务的代码 - 错误示例:

void charge(Course course, User user)  {
  if(!user.hasRole(StudyManager)) 
      throw new Excption(不是学管师);
  courseAttandance.setCourse(course);
  courseAttandance.setStudyManager(user);
}

问题:

  1. 权限的控制不是 一对一上下文中的通用语言

  2. 一对一上下文 不应该有 用户上下文的实体 存在(user)

  3. 核心域 应该是 course 和 courseAttandance, 支撑域应该是 考勤人员(不是学管师)

  4. 关键点

  • “开” 和 “闭” 的结合, 闭 是每个上下文内部是闭合的; 开 是上下文之间是开发的。

  • 领域模型设计, 有 战略战术 层面的理解。 划分好限界上下文, 是一个战略的体现; 而每个上下文中 使用到的技术, 是 战术 上的不同。

  • 战略上, 一起是以业务相关。 不是以 技术和架构相关。

  • 如果上下文划分清晰,DDD 是非常轻量级的

2. 上下文映射图

不同的限界上下文的 上下游的关系。 例如权限山下文, 和 订单的上下文的之间的关系。

3. 架构

4. 实体

有一个 ID 的, 可以流转在整个系统中的, 有唯一标示的对象, 且是 有数据、有业务逻辑的对象。

栗子: 就好像人, 有身份证ID, 有眼有手, 同时又是可以跑 又是可以跳。

  1. 原项目中的实体 - 错误示范
  @Entity
  customer{
        id
        getter  -- name age phone address
        setter
  }
}

这个是我们项目中经常碰到实体, 这种实体没有任何的业务意义, 都是在操作数据的 getter 和 setter, 而且都是一个非常强大的实体, 任何的改动, 只要一有变化, 就可以直接到数据库中。

  1. DDD 应该有的实体
  @Entity
  customer{
        id
        getter  -- name age phone address
        setter
        changePhone(int);
        changeName(string);
  }
}

但是在 DDD 中, 实体的概念是不同的。 是有属性 有动作的, 是有状态的对象。而且 与之相关联的动作都是业务相关的。 比如, changePhone, changeName 等。 有状态 就是意味着, 所有的对对象的修改, 都是必须 先获取实体, 同时 执行业务的方法。
方法的名字 必须是在描述业务的行为

  1. 改变思考方式
    与之前的一拿到需求就赶紧设计表不同, DDD 的思考更加是在业务层面上的思考。 以前是数据驱动开发的, 需要转成 领域驱动开发的。

4. 值对象

可以整体替换的, 描述一个值的对象。 int 是一个值对象, String 都是一个值对象, 就是一个可以整体替换的对象。
整体替换 就是值对象内属性是有关联性。

栗子: course 中的 startTime, endTime, courseHour, length(时长), 他们是互相的影响, 改了其中的一个值, 就会对另外几个值进行修改。
Old: 在course实体中, 设置好 courseHours 值后, 计算好另外3个, 再设置另外3个值, 这才不会导致数据出现差异。 如果某人设置好一个值后, 忘记给另外3个设置, 就会导致到数据有差异。
New: course 有一个 值对象 courseTimeObject 属性, 包含了上述的4个属性。 同时, 这个值对象有不同的业务方法, 比如 CourseTimeObject setHour(int 3 ), setHour中 已经计算好其他3个值了, 返回 一个 courseTimeObject 的值对象, 之后, 整体的进行替换 course.setCourseTimeObejct(courseTimeObject )

栗子: 我们的 contractProduct 有好多的属性, 比如说是 实际剩余资金, 实际消耗资金, 优惠剩余资金, 优惠消耗资金, etc 课时。 如果同样适用 值对象的方式, 而且这个值对象有 扣费、回滚等, 并且可以返回一个值对象, 就不会像直接设值给CP 那么容易出错了。

值对象 可以包含业务逻辑, 但是必须是整体替换的。 同时 值对象 对于测试(直接上UnitTest就可以) 是非常的方便的。 同时出错的范围都给收小了。

5. 领域服务

有时候, 实体服务未必可以表现足够的意义, 需要多个实体一起提供服务, 这时就是需要领域服务了。其实他是和实体的服务是平等的关系的, 都是完成业务逻辑的。

栗子: 权限验证上下文中, 如果对一个用户的 组织架构 和 用户角色进行一并的验证。 这里就涉及到两个领域了, 一个是 组织架构, 一个角色。 他们都是数据权限验证上下文的, 所以应该创建一个 领域的服务, 来做这个业务。

领域服务 有下面的特点:

  1. 不会管理事务
  2. 不能暴露内部的逻辑到外部
  3. 领域模型 处理的是单一的业务, 领域服务处理的是 同一个上下文中的 综合性的业务。

6. 领域事件

领域事件是可以使用系统异构的一个方式。

栗子: 一对一扣费后, 需要对CP 和 StudentMvc 进行重新的计算。
Old: 直接调用 accountChargeRecord.save, cp.setCunsmeAmount, cpService.updateAmount, studentMvn.set...
New:当扣费完成后, 发送一个 CourseCharged 事件, 其他的上下文监听着这个事件, 同时可以对这个事件进行处理。

领域事件的处理 有 restful 和 MQ 的方式, 各有利弊, 需要慎重考虑。
同时, 这个事件中心, 是一个基础的架构, 而且有可能是 多重来回的消息传递(比如, A领域将某个值设置成pending, 发送一个消息给到B 领域, 等待B领域处理好后, 发送一个消息给到A领域, 将某值设置会 success), 方法幂等 等考虑。

7. 业务

业务 是 DDD 中最核心的点。 以前我们是根据数据来驱动开发的, 一上来就是设计表, 设计关联等, 来一个service dao 就可以开干了; 但是 在 DDD 中, 需要对业务进行深入的理解, 分析限界上下文, 才可以下手。
实体领域模型, 值对象, 领域服务, 领域事件, 这个4个, 是对 业务逻辑的 最核心的点。 外部可以包着 任何的应用服务都可以。 比如外部可以是 微信的, APP 的, PC的应用服务 都没有关系。 同时, 上述的实体的领域模型 和 领域服务 是 面向内部 或者 面向应用服务的业务方法, 对每个方法就是业务的方法, 其实可以设置相应的 Security 权限 Annotation, 就可以和 spring 结合得很好了。例如: Role - 对用户进行改名Auth。
同时, 客户化时: 经常会有, 当xxx 的时候, 如果xxx的时候, 就有xxx 的结果。 如果以业务去理解, 其实是不同的业务工作过程中的插入的处理, 所以客户化时, 不应从数据层面去理解条件, 应从业务的角度。

8. 聚合

一堆的实体和值对象, 组成一个聚合。 聚合使用聚合根对外进行暴露。

Old: cp 有一个属性是contract, contract 有cp list 的属性; student 有 studentComment 的属性, studentComment 有student的属性。 上面是现实之中的情况, 不停的设置属性, 不停的使用 hibernate 进行导航。
缺点: 1. 互相关联, 是一个大的聚合, 互相耦合; 2. 无法确定修改的范围, 因为有导航 就是可以跨域聚合去修改别的聚合的模型。

聚合, 就是在同一个限界上下文中的 不同 实体模型的的聚合。
判断的标准:

  1. 单个事务 中管理一致性; 一个事务, 只会修改到一个聚合
  2. 是否 共同生死

栗子: 比如 student, studentComment, student 没有 comment 一样可存活, 所以 student 与 comment 不是共存亡的聚合。 所以 student 和 comment 是两个不同的聚合根。

有聚合后, 操作的方法的改变:

  1. 聚合根 是 进入这个聚合的入口, 限制入口的范围, 同时不需要暴露聚合内部的结构。
  2. 一个事务, 只会修改到一个聚合。
  3. 可以使用 关联 ID, 而不是导航的 实体模型。 因为 id 是值对象, 不可变的, 那么就不会有延时加载等各种问题的出现。

9. 工厂

生产聚合的一个工厂。 因为一个实体初始化, 可能需要后续多个有关联的实体进行实体化的。 所以尽量不要使用new, 应该使用build 的方式 进行 实体的产生, 因为有一些内在的逻辑需要添加到聚合中, 比如 institutionId。

10. 资源库

资源库有两种, 一种是面向集合的, 一种是面向持久化的。 使用hibernate, 就是使用面向集合的方式做资源库。

DAO是一个面向数据角度去看资源库的设计; DDD 资源库, 是面向 集合Set 的角度去看。 所以类似于Set 的操作, 如 remove, add, 等 都是应用在DDD 资源库上的。 同时, update 的操作, 都是ORM 感知到有 dirty 后 自动到数据库中的; add 的操作, 就有需要 save 的操作。 不用在 DDD 资源库 有特别的 update的操作。

CQRS: 最好的实现, 命令和查询分开的方式。一个命令资源库 是 只有 findById的查询 操作, 和 一些必要的数据库操作, 比如save 等; 另外一个查询库是可以对相应的实体进行 查询的操作。 那么查询库可以方便的做成 任意的方式了, 使用springData 或者 使用其他的读取方式都可以。 命令的资源库方法 返回 void; 查询的资源库方法 返回值对象。

11. 集成界限上下文

RestFul 的集成: 1. 增加一个 Adapter(远程连接到另外的上下文), Translator(对JSON 数据进行解析, 或者本地上下文的模型)
消息的集成: 异步的方式, 通常会造一个假的本地模型, 加上 状态, 发送消息后, 接着监听回传的消息, 对这个状态和值 进行设置。 消息中心的基础设置会更加多。

应用服务: 负责 用例流 的协调。所有的逻辑都在领域模型中, 所以 应用服务 是一个非常薄的一层。
应用服务 管理着 事务。
应用服务的参数, 可以组装成 命令 的结构, 用于回滚操作, 或者队列的操作。
应用服务层, 可以做到端口和适配器模式。 类似于 MQ 发送消息的方式, 因为这样就可以配置不同的适配器和端口, 就可以得到输出了, 不是方法的返回。

推荐阅读更多精彩内容