DDD领域驱动如何将业务拆分成微服务

2021/03/16

1、领域驱动设计

Domain Driven Design 领域驱动设计,简称DDD,就是基于模型驱动开发的设计思想。domain就是问题域,系统要解决的问题就是核心业务。

术语

  • 战略建模: 战略设计侧重于高层次、宏观上去划分和集成限界上下文,而战术设计则关注更具体使用建模工具来细化上下文;

  • 实体 Entity:带有业务行为的持久化对象,不直接与数据表映射,一般对应业务对象,具有业务属性和业务行为;

  • 值对象 Value Object:实体的单一属性或者属性集合,对实体的特征进行描述,比如颜色信息{name:黑色,css:fffff}

    1) 从业务形态来说,实体具有业务行为和业务逻辑,值对象只是若干个属性的集合,只有数据初始化和有限的不涉及修改数据的行为,基本不包含业务逻辑。值对象的属性在物理上独立出来的,但在逻辑上它依然是实体属性的一部分,用于描述实体的特征。

    2) 从代码形态来说,

    如果值对象是单一属性,那么它就是实体类的属性,

    如果值对象是属性集合,那么它就是Class,被实体整体引用。

    3) 从数据库形态来说,值对象本来就是实体的一部分,所以他们共用一张数据库表

    4) 实体和值对象都是个体化的对象,是微服底层最基础的对象,一起实现实体最基本的核心领域逻辑。

  • 聚合 Aggregate: 由业务和逻辑紧密关联的根实体,实体和值对象组合而成。领域的构成部分,聚合包含的实体和值对象就是领域对象,作为一个整体被外界访问。聚合可以指导详细设计。

  • 聚合根Aggregate Root:一个聚合中被其他实体围绕的根实体,通常是业务的核心

  • 限界上下文 Bounded Context ,其实就是聚合,对于问题域的一个解决方案

  • 领域模型:通过实体、值对象、领域服务对业务概念、状态、规则的表达形式。

  • 领域服务:领域的构成部分,基于业务逻辑,对一个或多个实体的方法进行组合封装,对外暴露成服务。

  • 领域事件 Event:分微服务内领域事件和微服务外领域事件,发布或订阅事件统一放在Application应用层,方便管理,领域事件的目的是为了保持业务数据的一致性,形成业务闭环。

  • 贫血模型:定义对象的简单属性值,没有业务逻辑上的方法,通常指值对象VO,属性值也可以称为状态

  • 充血模型:定义对象的属性(状态)和行为(方法),行为是有业务意义的,通常指BO,有业务行为的对象。

  • 事件风暴 Event Storm:划分微服务逻辑边界和物理边界,定义领域模型中的领域对象

聚合

在事件风暴中,我们根据业务操作(命令事件)找出实体和值对象,然后将业务关联紧密的实体和值对象进行组合,构成聚合。根据业务语义将多个聚合划定同一个限界上下文,通常一个限界上下文就是一个微服务。对这个微服务进行DDD分层,聚合位于领域层。一个微服务的领域层会包含多个聚合,共同实现核心业务逻辑,聚合内的实体以充血模型实现个体业务能力,以及业务逻辑的高内聚。

聚合是数据修改和持久化的基本单元,一个聚合对应一个仓储。聚合有一个聚合根和上下文边界,上下文边界就是根据业务单一职责和高内聚原则定义出聚合包含哪些实体和值对象,聚合之间的边界是松耦合的,微服务自然也是解耦的。

业务逻辑的跨域场景:

  • 业务场景需要一个聚合的A实体和B实体共同完成,业务逻辑用领域服务来实现;
  • 业务场景需要聚合C和聚合D中的两个服务共同完成,业务逻辑用应用服务组合两个聚合的领域服务实现;

聚合根

如果把聚合比作组织,聚合根则是组织的负责人,也叫根实体,它是聚合的管理者。

作用:

  • 作为实体,拥有实体的业务属性和业务行为,实现自身的业务逻辑(高内聚原则);

  • 作为聚合的管理者

    1) 在聚合内部,聚合根负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑;

    2) 在聚合之间,聚合根是聚合对外的接口人,以聚合根ID的方式接受外部请求和任务,实现上下文中的聚合之间的业务协同。也就是说,如果需要访问聚合内的实体,先访问聚合根,再导航到聚合内部的实体。

如何创建好的聚合

  1. 设计小聚合

    大部分的聚合都可以只包含根实体,无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。

  2. 通过唯一标识来引用其他聚合

    当存在对象之间的关联时,建议引用其唯一标识而非引用其整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造值对象。

  3. 聚合内数据强一致性,聚合之间数据最终一致性

    在一个事务中只修改一个聚合实例。如果一次业务操作涉及多个聚合的状态修改,应该采用领域事件的方式异步修改,实现聚合之间的解耦

  4. 通过应用层实现跨聚合的服务调用

    为实现聚合之间的解耦,应该避免跨聚合的领域服务调用和跨聚合的数据库表关联。

  5. 聚合内有一套不变的业务规则

    各实体和值对象按照统一的业务规则运行,实现对象数据的一致性,边界之外的任何东西都与该聚合无关

最后,适合自己的才是最好的,这些原则可以根据实际问题突破。

总结

  • 聚合的特点:高内聚,松耦合,作为拆分微服务的最小单位,一个微服务可以包含多个聚合,聚合之间的边界是微服务内的天然逻辑边界,在微服务架构演进时可以以聚合为单位进行拆分和组合
  • 聚合根的特点:聚合根是实体,有独立的生命周期,一个聚合只有一个聚合根。
  • 实体的特点:有ID标识,通过ID判断相等性,ID在聚合内唯一。状态可变,它依附于聚合根,其生命周期有聚合根管理。实体一般会持久化,但与数据库持久化对象(PO)不一定是一对一的关系。
  • 值对象的特点:无ID标识,不可变,无生命周期,值对象之间通过属性值判断相等性。它的核心本质是值,是一组概念完整的属性组成的集合,用于描述实体的状态和特征,值对象尽量只引用值对象。

领域模型中对象的层次从内到外依次是:值对象、实体、聚合、限界上下文

设计聚合Demo

以投保业务场景为例

  1. 采用事件风暴,根据业务行为投保过程,列出所有业务行为和命令事件,产出场景分析图
  2. 找出产生这些命令事件的实体和值对象,找出聚合根。判断一个实体是否聚合根的原则:是否有独立的生命周期,是否有全局唯一ID ,是否可以创建或修改其他对象。图中的聚合根是投保单和客户
  3. 根据业务单一职责和高内聚原则,找出与聚合根关联的实体和值对象
  4. 在聚合内根据聚合根、实体和值对象的依赖关系,画出关系图
  5. 多个聚合根据上下文划分到同一个限界上下文,通常一个限界上下文就是一个微服务,这样就完成了微服务的拆分。

2、拆分成微服务

DDD分层架构模型

  • 展示层(用户接口层)

    展现层负责向用户显示信息和解释用户指令,对接微服务的用户接口层

  • 应用层

    尽量简单,本身不包含业务规则,主要面向用例和流程相关的操作,它协调和指挥领域层的领域对象来完成业务逻辑。提供与业务无关的服务,如安全认证、权限校验、分布式和持久化事务控制或向外部应用发送基于事件的消息等(领域事件)。

  • 领域层

    整个服务的核心所在,实现全部业务逻辑。包含领域对象(实体、值对象)、领域服务。它负责表达业务概念、业务状态以及业务规则,具体表现形式就是领域模型。。包含了多个聚合,共同实现核心业务逻辑

  • 基础设施层

    为各层提供通用的技术能力,包括:

    a) 为应用层传递消息、提供 API 管理

    b) 为领域层提供数据库持久化机制等

    c) 它还能通过技术框架来支持各层之间的交互

依赖反转设计

依赖反转设计原则:底层服务(基础设施层)应依赖高层组件(用户接口层、应用层、领域层)所提供的接口。

传统的四层架构,其他层都依赖基础层,它成为了核心,但实际上领域层才是软件的核心。依赖倒置后,实现了对基础层的解耦,领域层就是软件核心。

1) 基础设施层都依赖用户接口层、应用层、领域层,对他们提供的接口进行实现

2) 用户接口层依赖应用层,它要调用应用层的应用服务

3) 应用层依赖领域层,它要调用领域层的领域服务

在《实现领域驱动设计》一书中,DDD分层有一个重要的原则:每层只能与位于下方的层发生耦合

微服务内的服务视图

一个微服务内有 Facade 接口(web接口)、应用服务、领域服务和基础服务(4层),各层服务协同配合,为外部提供服务。如下图:

  1. 接口服务(web接口)

    位于Interfaces用户接口层,处理用户发送的 Restful 请求和解析用户输入的配置文件等,并将信息传递给应用层。

  2. 应用服务

    位于Application应用层。用来表述应用和用户行为,负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果的拼装。

    这里的服务包括:

    a) 应用服务,本身不包含业务逻辑,对业务用例的执行结果拼装

    ​ 对微服务内的领域服务以及微服务外的应用服务(Feign方式调用)进行组合和编排;

    ​ 对基础层如文件、缓存等数据直接操作形成应用服务,对外提供粗粒度的服务,所以应用层要定义一些基础层的仓储服务接口,然后基础层实现仓储接口

    b) 领域事件相关服务包括两类:领域事件的发布和订阅。通过事件总线和消息队列实现异步数据传输,实现微服务之间的解耦。

  3. 领域服务

    位于Domain领域层,为完成领域中跨实体或值对象的操作转换而封装的服务

    领域服务就是对一个聚合内的同一个实体或多个实体的操作进行组合封装,对外暴露成服务,这些服务封装了核心的业务逻辑。

    实体自身的行为在实体类内部实现,向上封装成领域服务暴露。

    a) 为隐藏领域层的业务逻辑实现,所有领域方法(实体方法)和服务等均须通过领域服务对外暴露。

    b) 为实现微服务内聚合之间的解耦,原则上禁止跨聚合的领域服务调用和跨聚合的数据相互关联,通过应用服务去调用。

    领域包含多个聚合,聚合是由业务逻辑紧密关系的实体和值对象组合成。

  4. 基础服务

    位于Infrastructure基础层,为应用层和领域层提供资源服务(如数据库、缓存、消息队列等),实现各层的解耦,降低外部资源变化对业务逻辑的影响。

    基础服务主要为仓储服务,通过依赖反转的方式(从容器中加载bean)为应用层和领域层提供基础资源服务,领域服务和应用服务调用仓储服务接口,利用仓储实现持久化数据对象(DAO层)或直接访问基础资源(第三方API)。

微服务外的服务视图

主要有两个:

  • 前端应用

    微服务中的应用服务通过用户接口层组装和数据转换后,发布在 API 网关(基础层),为前端应用提供数据展示服务。

  • 外部应用

    当我们需要跨微服务数据处理时,通常会有两个场景

    1) 对实时性要求的场景,选择直接调用应用服务的方式,如果是新增修改的服务,因为是跨微服务会产生分布式事务,为保证数据的强一致性,需要整合分布式事务框架

    2) 对实时性要求不高的场景,选择异步化的领域事件驱动机制,通过消息队列实现异步数据传输,实现最终一致性

数据视图

DDD 分层架构中数据对象转换的过程如下图。

  • 前端应用 VO与用户接口层的DTO 通过 Restful 协议实现 JSON 格式和对象转换。
  • 微服务内应用服务需调用外部微服务的应用服务,则 DTO 的组装、 DTO 与 DO 的转换发生在应用层
  • 领域层通过领域对象(DO)作为领域实体和值对象的数据和行为载体。实体就是行为载体,值对象就是数据载体。

  • 领域层 DO 与 PO 的转换发生在基础层,基础层则利用持久化对象(PO)完成数据库的交换。

领域事件

领域事件主要用于解耦微服务,微服务之间不再是强一致性,而是基于事件的最终一致性、弱一致性。领域事件的发布和订阅形成完整的业务闭环,可以理解事件就是一个消息,目的是为了数据的业务一致性,如下图

  • 微服务内的领域事件

    通过事件总线(消息发布订阅,异步弱一致性)或利用应用服务(同步,强一致性)实现不同聚合之间的业务协同。所以有两个方向:

    a) 由于大部分事件的集成发生在同一个线程内,可以直接调用领域服务完成业务协同(强一致性)

    b) 基于DDD“一个事务只更新一个聚合根”的原则,一个事件如果同时更新多个聚合数据(调用暴露的领域服务去更新),可以考虑引入消息中间件或spring事件监听机制,通过异步化的方式对微服务内不同的聚合根采用不同的事务。(弱一致性)

    聚合在划分的时候要考虑单一职责原则和事务的原子性

  • 微服务之间的领域事件

    微服务之间的数据交互方式有两种:

    1) 领域事件驱动机制

    用于实时性要求不高的业务场景,实现不同微服务之间的解耦,事件库(表)可以用于微服务之间的数据对账,在应用、网络等出现问题后,可以实现源和目的端的数据比对,在数据暂时不一致的情况下仍可根据这些数据完成后续业务处理流程,保证微服务之间数据的最终一致性。事件表可以放在业务库,也可以独立一个事件库。

    2) 应用服务调用

    用于实时性要求高的业务场景,一旦涉及到跨微服务的数据修改,将会增加分布式事务控制成本,影响系统性能,需要整合分布式事务解决框架

  • 事件总线

    位于基础层,为应用层和领域层服务提供事件消息接收和分发等服务

    其大致流程如下: 服务触发并发布事件->事件总线事件分发。

    1) 如果是微服务内的订阅者(微服务内的其它聚合),则直接分发到指定订阅者

    2) 如果是微服务外的订阅者,则事件消息先保存到事件库(表)并异步发送到消息中间件。

    3) 如果同时存在微服务内和外订阅者,则分发到内部订阅者,并将事件消息保存到事件库(表)并异步发送到消息中间件,事件表要保存消息体。为了保证事务的一致性,事件表可以共享业务数据库。也可以采用多个微服务共享事件库的方式。当业务操作和事件发布操作跨数据库时,须保证业务操作和事件发布操作数据的强一致性(分布式事务框架)。

  • 事件数据持久化

    可以有两种方案

    1) 事件数据保存到微服务所在业务数据库的事件表中,利用本地事务保证业务操作和事件发布操作的强一致性。

    2) 事件数据保存到多个微服务共享的事件库中。需要注意的一点是:这时业务操作和事件发布操作会跨数据库操作,须保证事务的强一致性(如分布式事务机制

    事件数据的持久化可以保证数据的完整性,基于这些数据可以完成跨微服务数据的一致性比对。

微服务设计方法

分两个阶段

1、事件风暴

本阶段主要完成领域模型设计

讨论业务,划分出微服务逻辑边界和物理边界,定义领域模型中的领域对象,过程如下:

  • a) 产品愿景

    对产品的顶层价值设计,对产品目标用户、核心价值、差异化竞争点等信息达成一致,避免产品偏离方向。

    参与角色:业务需求方、产品经理和开发组长。

  • b) 场景分析

    产品解决的业务场景,从用户视角出发,探索业务领域中的典型场景,产出领域中需要支撑的场景分类用例操作以及不同子域之间的依赖关系,用以支撑领域建模。

    参与角色:产品经理、需求分析人员、架构师、开发组长和测试组长。

  • c) 领域建模

    领域就是问题域,通过对业务和问题域进行分析,建立领域模型,向上通过限界上下文指导微服务边界设计(拆分多少个微服务),向下通过聚合指导实体的对象设计

    参与角色:领域专家、产品经理、需求分析人员、架构师、开发组长和测试组长。

  • d) 微服务拆分和设计

    结合业务限界上下文与技术因素,对服务的粒度、分层、边界划分、依赖关系和集成关系进行梳理,完成微服务拆分和设计。

    微服务设计应综合考虑业务职责单一、敏态与稳态业务分离、非功能性需求(如弹性伸缩要求、安全性等要求)、团队组织和沟通效率、软件包大小以及技术异构等因素。

    参与角色:产品经理、需求分析人员、架构师、开发组长和测试组长。

2、领域对象及服务矩阵和代码模型设计

本阶段完成领域对象及服务矩阵文档以及微服务代码模型(代码目录结构)。

  • 领域对象及服务矩阵

    根据事件风暴过程领域对象和关系,

    ​ a) 梳理产出的限界上下文、聚合、实体、值对象、仓储、事件、应用服务、领域服务等领域对象以及各对象之间的依赖关系。

    ​ b) 确定各对象在分层架构中的位置和依赖关系,建立领域对象分层架构视图,为每个领域对象(实体、值对象)建立与代码模型对象的映射。

    参与角色:架构师和开发组长。

  • 微服务代码模型(层级、结构)

    根据领域对象在 DDD 分层架构中所在的层、领域类型、与代码对象的映射关系,定义领域对象在微服务代码模型中的包、类和方法名称等,设计微服务工程的代码层级和代码结构,明确各层间的调用关系。

    参与角色:架构师和开发组长。

代码结构模型

基于领域对象和服务矩阵设计阶段,建立领域对象与代码模型对象的映射关系。可以说是业务模型与代码模型的映射。

基于DDD分层的一个微服务目录分interfaces、application、domain 和 infrastructure 四个目录,如下图

1) Interfaces用户接口层

相当于web的Controller控制层,接受Request请求,将数据传递给Application层。主要代码是数据组装、Facade接口

  • assembler(装配):实现 dto 与领域对象(实体和值对象)之间的相互转换和数据交换
  • dto:数据传输的载体,内部不存在任何业务逻辑,dto让领域对象与外界隔离
  • facade 门面接口,对外提供调用接口,将请求委派给一个或多个应用服务进行处理(Application应用层)

2) Application应用层

相对于web的Service层,主要代码是对微服务内的领域服务和微服务外的应用服务进行组合封装的应用服务。为用户接口层提供展示数据支持。还有就是领域事件的发布和订阅,形成完整的业务闭环。

  • event: 事件包括 publish发布 和 subscribe订阅

    publish 目录主要存放微服务内领域事件发布相关代码。

    subscribe 目录主要存放微服务内聚合之间或外部微服务领域事件订阅处理相关代码

    注意:为了实现领域事件的统一管理,微服务内所有领域事件(包括应用层和领域层事件)的发布和订阅处理都统一放在应用层,那么应用层会依赖具体的MQ实现

  • service: 应用服务,对多个领域服务或外部应用服务进行封装、编排和组合,对外提供粗粒度的服务。还包括声明一些仓储服务接口,基础层把应用层依赖进来对仓储服务接口进行实现。

3) Domain领域层

主要代码是实体类方法和领域服务,对业务逻辑的封装

  • aggregate(聚合):聚合代码包的根目录,实际项目中以实际业务属性的名称来命名。

    聚合定义了领域对象之间的关系和边界,实现领域模型的内聚。

  • entity(实体):存放实体(含聚合根、实体和值对象)相关代码,同一实体所有相关的代码都放在一个实体类中,含对同一实体类多个对象操作的方法,如对多个对象的 count 等。

  • serivce(领域服务):根据业务逻辑对多个实体对象操作的服务代码,设计时一个领域服务对应一个类。

  • repository(仓储):存放聚合对应的查询或持久化领域对象的代码,通常包括仓储接口和仓储实现方法。为了方便聚合的拆分和组合,我们设定一个原则:一个聚合对应一个仓储。

    说明

    按照 DDD 分层原则,仓储实现本应属于基础层代码,但为了微服务代码拆分和重组的便利性,我们把聚合的仓储实现代码放到了领域层对应的聚合代码包内。如果需求或者设计发生变化导致聚合需要拆分或重新组合时,我们可以聚合代码包为单位,轻松实现微服务聚合的拆分和组合。就是说整个聚合代码包迁移,方便拆分组合。

4) Infrastructure基础层 [ˈɪnfrəstrʌktʃər]

主要代码是配置和基础资源服务,如redis缓存、消息中间件的支持

  • config 配置相关代码
  • util 主要存放平台、开发框架、消息、数据库、缓存、文件、总线、网关、第三方类库、通用算法等基础代码,可为不同的资源类别建立不同的子目录。

基于DDD分层的一个微服务的总目录结构如下图:

设计原则和场景

高内聚低耦合、复用、单一职责是最基本的了,强调以下几条

  • 要领域驱动设计,而不是数据驱动设计,也不是界面驱动设计

    领域就是核心业务功能

  • 要边界清晰的微服务

    随着需求或设计变化,微服务内的代码也会分分合合,逻辑边界清晰的微服务,可快速实现微服务代码的拆分和组合。微服务内聚合与聚合之间的领域服务以及数据原则上禁止相互产生依赖。如有必要可通过上层的应用服务编排或者事件驱动机制实现聚合之间的解耦,以利于聚合之间的组合和拆分。

  • 要职能清晰的分层

    分层架构中各层职能定位清晰,且都只能与其下方的层发生依赖,也就是说只能从外层调用内层服务,内层服务通过封装、组合或编排对外逐层暴露,服务粒度由细到粗。原则上禁止跨聚合的领域服务调用,应该通过跨聚合的应用服务调用内层的领域服务

    应用层负责服务的编排和组合

    领域层负责领域业务逻辑的实现

    基础层为各层提供资源服务

  • 要做自己能 hold 住的微服务,而不是过度拆分的微服务

    过度拆分必然会带来软件维护成本的上升,如:集成成本、运维成本以及监控和定位问题的成本。

新建系统的微服务设计

两种场景

  • 简单领域的建模

    根据事件风暴可以分解出事件、命令(行为)、实体、聚合和限界上下文

  • 复杂领域的建模

    拆分为多个子域,如:保险领域可以拆分为承保、理赔、收付费和再保等子域,承保子域还可以再拆分为投保、保单管理等子子域。对子域进行事件风暴分解出事件、命令、实体、聚合和限界上下文。

单体应用的微服务设计

只是将面临问题或性能瓶颈的模块拆分为微服务,而其余功能仍为单体

3、美团对DDD的落地

贫血症和失忆症

贫血领域对象(Anemic Domain Object)是指仅用作数据载体,而没有行为和动作的领域对象,也叫值对象。相对的就是我上面说的充血模型。

在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式(MVC),这时对象只是数据的载体,没有行为,只是对数据移动、处理和实现的过程。想想之前做的BO 数据模型是有行为方法的,就是充血模型了。

举例-简单抽取

场景需求:奖池里配置了很多奖项,我们需要按运营预先配置的概率抽中一个奖项。 实现非常简单,生成一个随机数,匹配符合该随机数生成概率的奖项即可。

贫血模型实现方案,先设计奖池和奖项的库表配置,如下:

// 奖池
class AwardPool {
    int awardPoolId;
    List<Award> awards;
    public List<Award> getAwards() {
        return awards;
    }
  
    public void setAwards(List<Award> awards) {
        this.awards = awards;
    }
    ......
}
// 奖项
class Award {
   int awardId;
   int probability;//概率
  
   ......
}

Service代码实现,设计一个LotteryService,在其中的drawLottery()方法写服务逻辑

AwardPool awardPool = awardPoolDao.getAwardPool(poolId);//sql查询,将数据映射到AwardPool对象
for (Award award : awardPool.getAwards()) {
   //寻找到符合award.getProbability()概率的award
}

我们发现,在业务领域里非常重要的抽奖,我的业务逻辑都是写在Service中的,Award充其量只是个数据载体,没有任何行为。简单的业务系统采用这种贫血模型和过程化设计是没有问题的但在业务逻辑复杂了,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。

更好的是采用领域模型的开发方式,将数据和行为封装在一起,并与现实世界中的业务对象相映射。各类具备明确的职责划分,将领域逻辑分散到领域对象中。继续举我们上述抽奖的例子,使用概率选择对应的奖品就应当放到AwardPool类中。

系统复杂性应对

解决复杂和大规模软件的武器可以被粗略地归为三类

  • 分治

    a) 把问题空间分割为规模更小且易于处理的若干子问题。分割后的问题需要足够小,以便一个人单枪匹马就能够解决他们;

    b) 其次,必须考虑如何将分割后的各个部分装配为整体。分割得越合理越易于理解,在装配成整体时,所需跟踪的细节也就越少。即更容易设计各部分的协作方式。

    评判什么是分治得好,即高内聚低耦合。

  • 抽象

    使用抽象能够精简问题空间,而且问题越小越容易理解。举个例子,从北京到上海出差,可以先理解为使用交通工具前往,但不需要一开始就想清楚到底是高铁还是飞机,以及乘坐他们需要注意什么。

  • 知识

    DDD让我们知道如何抽象出限界上下文以及如何去分治。

与微服务的关系

强调从业务维度分模块分治。

  • 业务架构——根据业务需求设计业务模块及其关系
  • 系统架构——设计系统和子系统的模块
  • 技术架构——决定采用的技术及框架

DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

设计领域模型的一般步骤如下:

  1. 根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;
  2. 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象;
  3. 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
  4. 为聚合根设计仓储,并思考实体或值对象的创建方式;
  5. 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。

限界上下文

一个由显示边界限定的特定职责。领域模型便存在于这个边界之内,包括它的属性和操作。

一个给定的业务领域会包含多个限界上下文,想与一个限界上下文沟通,则需要通过显示边界进行通信。系统通过确定的限界上下文来进行解耦,而每一个上下文内部紧密组织,职责明确,具有较高的内聚性。

一个很形象的隐喻:细胞质所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。

可以理解限界上下文就是聚合,领域的解系统

所以划分限界上下文,就是划分聚合,从需求出发,按业务领域划分,步骤如下:

  • 考虑产品所讲的通用语言,从中提取一些术语称之为概念对象,寻找对象之间的联系;
  • 从需求里提取一些动词,观察动词和对象之间的关系;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的界限上下文
  • 我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整。

基于上面提到的抽奖系统,需求场景:

  • 运营,可以配置一个抽奖活动,该活动面向一个特定的用户群体,并针对一个用户群体发放一批不同类型的奖品(优惠券,激活码,实物奖品等)
  • 用户,通过活动页面参与不同类型的抽奖活动。

我们划分M端抽奖管理和C端用户抽奖两个领域

产品的需求概述如下:

  1. 抽奖活动有活动限制,例如用户的抽奖次数限制,抽奖的开始和结束的时间等;
  2. 一个抽奖活动包含多个奖品,可以针对一个或多个用户群体;
  3. 奖品有自身的奖品配置,例如库存量,被抽中的概率等,最多被一个用户抽中的次数等等;
  4. 用户群体有多种区别方式,如按照用户所在城市区分,按照新老客区分等;
  5. 活动具有风控配置,能够限制用户参与抽奖的频率。

1、划分限界上下文

根据产品的需求,我们提取了一些关键性的概念作为子域,形成我们的限界上下文,如下图:

  • 抽奖上下文:整个领域的核心,承担着用户抽奖的核心业务,抽奖中包含了奖品和用户群体的概念。

  • 活动准入上下文:对于活动的限制,我们定义了活动准入的通用语言,将活动开始/结束时间,活动可参与次数等限制条件
  • 库存上下文:对于抽奖的奖品库存量,由于库存的行为与奖品本身相对解耦,库存关注点更多是库存内容的核销,且库存本身具备通用性,可以被奖品之外的内容使用
  • 风控上下文:针对C端用户存在一些刷单行为,
  • 计数上下文:活动准入、风控、抽奖等领域都涉及到一些次数的限制

2、梳理上下文关系图

康威定律:任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。

康威定律告诉我们,系统结构应尽量的与组织结构保持一致

我们认为团队结构(无论是内部组织还是团队间组织)就是组织结构,限界上下文就是系统的业务结构。因此,团队结构应该和限界上下文保持一致。

梳理清楚上下文之间的关系的好处

在团队内部

  • 任务更好拆分,一个开发人员可以全身心的投入到相关的一个单独的上下文中;
  • 沟通更加顺畅,一个上下文可以明确自己对其他上下文的依赖关系,从而使得团队内开发直接更好的对接。

在团队之间

  • 每个团队在它的上下文中能够更加明确自己领域内的概念,因为上下文是领域的解系统;

  • 对于限界上下文之间发生交互,团队与上下文的一致性,能够保证我们明确对接的团队和依赖的上下游。

限界上下文有哪些关系:

  1. 合作关系(PartnerShip):两个上下文紧密合作的关系

  2. 防腐层(Anti Corruption Layer):一个上下文A通过一些适配和转换与另一个上下文B交互,不要让B上下文里的领域概念侵入到A上下文的领域层,否则,A就被B腐化了。一个上下文可以理解成一个微服务,一个系统,所以防腐层是为了隔离两个系统的概念。怎么隔离?就是将系统间交互的模型转换适配,防腐层尽量把系统提供者的模型转换为系统使用者的模型,目的是为了系统使用者方便。

    使用场景:

    • 一个大的系统由多个独立的小组进行维护,可能会出现有些系统重构为新系统,又要保持和其他系统的连接, 此时用防腐层做到独立迭代和兼容,从设计模式的角度看,这就是Facade外观模式。

      重构单体为微服务时,利用防腐层可以逐步开发新服务,并利用底层旧model, 防腐层就是胶水代码

    • 微服务中多个边界上下文的领域知识需要共享, 可以利用防腐层隔离两个系统

    • 新旧系统切换时, 有些新系统需要和旧系统打交道, 此时可以利用防腐层隔离新旧系统。

      旧系统通过ACL提供多个微服务:

    ACL的接口:

    • 提供一组Service
    • 提供Entity

    设计模式的实现

    • Facade外观模式
    • Adapter适配器模式

    上图显示了采用两个子系统的应用程序。 子系统 A 通过防腐层调用子系统 B。 子系统 A 与防腐层之间的通信始终使用子系统 A 的数据模型和体系结构。防腐层向子系统 B 发出的调用符合该B子系统的数据模型或方法。 防腐层包含在两个系统之间转换所必需的所有逻辑。 该层可作为应用程序内的组件或作为独立服务实现。

  3. 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问

抽奖平台上下文的映射关系图如下:

PS-合作关系,ACL-防腐层

3、上下文建模

在抽奖上下文中,我们通过抽奖(DrawLottery)这个聚合根来控制抽奖行为,可以看到,一个抽奖包括了抽奖ID(LotteryId)以及多个奖池(AwardPool),而一个奖池针对一个特定的用户群体(UserGroup)设置了多个奖品(Award)。

另外,在抽奖领域中,我们还会使用抽奖结果(SendResult)作为输出信息,使用用户领奖记录(UserLotteryLog)作为领奖凭据和存根。

4、结算中心实例

项目分4个子模块

  • settlement-common 项目的公共代码模块、公共依赖模块

  • settlement-core 基础模块

  • settlement-service 提供的服务模块,这里按业务拆分6个微服务模块

    1) settlement-smc-service 结算中心,处理销售、退货、索赔等结算业务

    2) settlement-cdc-service 信用额度中心,处理信用授信、客户款项计算相关业务

    3) settlement-mcc-service 收款中心,处理收款、退款、转款相关业务

    4) settlement-ivc-sevice 发票中心,处理销售单拆分合并形成发票池及电子发票,纸质专票对接发票平台开票

    5) settlement-coc-service 费用中心,处理费用报销和申请

    6) settlement-rcc-service 对账中心,处理客户往来对账,核销相关业务

  • settlement-web 供前端应用的用户层接口服务模块,分2个微服务

    settlement-console-web:对外,前端应用请求处理。

    settlement-inner-web:对内,任务调度处理,整合了xxl-job分布式任务调度框架。

    它们通过feign调用settlement-service里的每个微服务的用户接口层的Controller接口方法。

服务模块DDD分层

以settlement-ar-service为例,如下图:

一开始个人觉得,基于DDD分层的原则,如下依赖更简洁清晰

与设计人沟通一番后,领域层确实不能依赖基础层,目的是为了解耦,领域层对数据的处理是定义到仓储接口,具体的仓储实现是定义在基础层,领域层不能耦合具体的仓储实现,因为实际支持的数据库可以选择mysql、oracle、portsql等,到时更换数据库了,领域层代码不用修改。

传统的MVC三层结构中的DAO层数据库访问层代码放到基础层,那就是仓储实现,原来我们Service层是直接调用DAO层的Mapper(用Mybatis举例),现在的话要加一层仓储接口,具体的仓储实现类在基础层调用Mapper去操作数据库。

  • settlement-ar-api

    封装feign调用接口被第三方作为依赖服务引入,就像前面提到的settlement-console-web会在pom依赖这个api子模块,因为要用到它的feign接口。

  • settlement-ar-app 应用层

    封装应用服务,对微服内的领域服务以及微服务外的应用服务(Feign方式调用)进行组合和编排,数据结果的拼装(DO转换DTO给上层的用户接口层)

  • settlement-ar-domain 领域层

    包含多个聚合,共同实现微服务的核心业务逻辑,就是领域模型的落地实现层。仓储接口如HelloWorldRepo

  • settlement-ar-facade 用户接口层

    处理用户请求Request,将VO转换为DTO,传递数据给应用层

  • settlement-ar-infrastructure 基础层

    仓储接口的实现代码层,仓储接口实现类如HelloWorldRepoImpl,还有其他的基础资源服务实现,如消息队列、缓存

  • settlement-ar-starter 微服务的启动模块

    依赖settlement-ar-facade 和 settlement-ar-infrastructure 启动整个微服务

现在想想,其实基础层依赖反转更合适。

发票模块

1、申请开票

根据不同角色的旅程和场景分析,尽可能全面地梳理出从前端操作到后端业务逻辑发生的所有用例操作、命令、领域事件以及外部依赖关系等信息。

用户:系统

1) mq订阅开票申请数据的消息,将消息体转换为DTO

2) 校验申请数据,校验逻辑可写在DTO方法内(app层),也可DTO转换DO后写在DO方法内(domain层)

  • 不通过,mq发布校验失败消息

  • 通过,生成发票申请记录,具体就是保存发票头、行到数据库表

3) 如果是电子发票,开票申请的发票流水号写入redis的list队列,并以发票流水号为key,待推送发票平台的数据DTO为value写入redis的hash中,格式如下:

tobePushInvoiceList: 发票流水号1,发票流水号2…..

tobePushHashMap:{发票流水号1:待推送数据1,发票流水号2:待推送数据2}…

如果是纸质发票,暂不处理

场景分析图

实体命令事件关系图:根据场景分析图,将与实体或值对象有关的命令和事件聚集到实体

聚合对象关系图:

服务分层关系图:根据场景分析图,划分出应用服务、领域服务、实体方法

2、开票数据推送开票平台

根据不同角色的旅程和场景分析,尽可能全面地梳理出从前端操作到后端业务逻辑发生的所有用例操作、命令、领域事件以及外部依赖关系等信息。

用户:系统

1) 系统定时从redis读取开票数据,并推送到开票平台进行开票

读取redis待推送列表list的元素 发票流水id,根据发票流水id读取redis的待推送hash获取发票数据,调用开票平台接口推送数据,推送完成后,修改发票状态为已推送,发票流水id从redis待推送list中移除->发票数据从redis待推送hash中移除 ->发票流水id写入redis已推送list(pushedInvoiceList)

2) 开票平台异步调用系统接口,更新对应开票结果状态,mq发布开票结果消息

mios代码的web接口是InvoiceController.invoiceResult()

3) 定时调用开票平台获取开票结果。

xxl-job任务从redis的pushedInvoiceList获取发票流水号,调用开票平台查询开票结果,已有结果的

​ a.更新开票状态

​ b.mq发布发票开票结果消息

​ c.发票流水号从已推送list(pushedInvoiceList)删除

场景分析图

实体命令事件关系图:根据场景分析图,将与实体或值对象有关的命令和事件聚集到实体

服务分层关系图:根据场景分析图,划分出应用服务、领域服务、实体方法

5、请假考勤实例

产品功能

基于DDD拆分微服务设计实例,目标是实现在线请假和考勤管理:

  1. 请假人填写请假单提交审批,根据请假人身份、请假类型和请假天数进行校验,根据审批规则逐级递交上级审批,逐级核批通过则完成审批,否则审批不通过退回申请人。
  2. 根据考勤规则,核销请假数据后,对考勤数据进行校验,输出考勤统计

用例场景分析

根据不同角色的旅程和场景分析,尽可能全面地梳理出从前端操作到后端业务逻辑发生的所有用例操作、命令、领域事件以及外部依赖关系等信息。

请假场景

  • 请假

    用户: 请假人

    a) 请假人登录系统:从权限微服务获取请假人信息和权限数据,完成登录认证。

    b) 创建请假单:打开请假页面,选择请假类型和起始时间,录入请假信息。保存并创建请 假单,提交请假审批。

    c) 修改请假单:查询请假单,打开请假页面,修改请假单,提交请假审批。

    d) 提交审批:根据请假类型和时长,获取审批规则,根据审批规则,从人员组织关系中获取审批人,给请假单分配审批人。

  • 审批

    用户: 审批人

    a) 审批人登录系统:从权限微服务获取审批人信息和权限数据,完成登录认证

    b) 获取请假单:获取审批人名下请假单,选择请假单。

    c) 审批:填写审批意见。

    d) 逐级审批:如果还需要上级审批,根据审批规则,从人员组织关系中获取审批人,给请 假单分配审批人。重复以上 4 步,

    最后审批人完成审批

完成审批后,产生请假审批已通过领域事件(上面提交请假审批也应该产生领域事件通知审批人)。后续有两个进一步的业务操作:

  1. 发送请假审批已通过的通知,通知邮件系统告知请假人;
  2. 将请假数据发送到考勤以便核销。

场景分析结果图如下:

人员场景

用户:后台管理员

a) 管理员登录系统:从权限微服务获取登录人信息和权限数据,完成登录认证

b) 创建人员:打开创建人员页面,外部人员需要填写人员信息,内部人员则从HR获取人员信息,保存并创建人员。

场景分析结果图如下:

领域建模

找出实体和值对象

领域对象:实体、值对象、命令、事件

根据场景分析图,找出产生命令或事件的实体和值对象,将与实体或值对象有关的命令和事件聚集到实体。得到实体与命令事件关系图:

图例:蓝色-命令,绿色-实体,黄色-事件

定义聚合

根据实体与命令事件关系图

1、找出聚合根,可以找出“请假单”和“人员”两个聚合根

2、找出与聚合根紧密依赖的实体,我们发现“审批意见”、”审批规则”和“请假单”紧密依赖,”组织关系“和”人员“紧密依赖。

刷卡明细、考勤明细和考勤统计这几个实体,它们之间相互独立,找不出聚合根,不是富领域模型,但它们一起完成考勤业务逻辑,具有很高的业务内聚性。我们将这几个业务关联紧密的实体,放在一个考勤聚合内。在微服务设计时,我们依然采用 DDD 的设计和分析方法。由于没有聚合根来管理聚合内的实体,我们可以用传统的方法来管理实体。

最终我们建立了请假、人员组织关系和考勤三个聚合,聚合图如下:

定义限界上下文

得到聚合图后下一步就是划分限界上下文了,它可以指导微服务的拆分,理论上一个限界上下文就是一个微服务。

由于人员组织关系聚合与请假聚合,共同完成请假的业务功能,两者在请假的限界上下文内。考勤聚合则单独构成考勤统计限界上下文。因此我们为业务划分请假和考勤统计两个限界上下文,建立请假和考勤两个领域模型,如上面的聚合图所示。

微服务拆分

理论上一个限界上下文就是一个微服务,在这个项目,我们划分微服务主要考虑职责单一性原则。因此根据限界上下文就可以拆分为请假和考勤两个微服务。其中请假微服务包含人员组织关系和请假两个聚合,考勤微服务包含考勤聚合。到这里,战略设计就结束了。

通过战略设计,我们建立了领域模型,划分了微服务边界。下一步就是战术设计了,也就是微服务设计。

战略设计=领域建模+微服务拆分

一路走来,基本上是这样的路线:

场景分析图 -》实体与命令事件关系图-》聚合图-》划分限界上下文-》完成微服务拆分

战术设计=微服务设计

微服务设计

分两个阶段:分析微服务领域对象和设计微服务代码结构

1、分析微服务领域对象

主要工作包括:

1) 服务的识别与分层?根据命令识别服务

场景分析图中的命令是微服务对外提供的能力,它是与应用服务或者领域服务对应的。以提交审批这个命令为例说明,它的场景流程描述是这样的:根据请假类型和时长,查询请假审批规则,获取下一步审批人的角色。根据审批角色从人员组织关系中查询下一审批人,为请假单分配审批人。

根据场景分析图,在应用层和领域层设计出以下服务和方法

  • 应用层:提交审批应用服务
  • 领域层:查询审批规则领域服务,根据审批规则查询审批人领域服务,修改请假流程信息领域服务,

2) 应用服务由哪些服务组合和编排完成?

应用服务的服务集合中的服务包括领域服务或其它微服务的应用服务。根据应用服务功能要求设计领域服务,定义领域服务。看下面的服务分层依赖关系图

应用服务-》领域服务-》实体方法

3) 领域服务包括哪些实体和实体方法?

提交审批命令的分析,我们得出服务分层依赖关系图:

这里我们发现一个原则:一个聚合对应一个仓储服务接口,接口的命名应该跟聚合根的命名相关联。

4) 哪个实体是聚合根?

根据聚合图,我们知道有请假单、人员两个聚合根,考勤聚合没有聚合根,但由于业务的内聚性,把考勤作为一个微服务,使用传统的Controller/Service/Dao三层代码结构。

5) 实体有哪些属性和方法?

  • 在请假聚合中,聚合根是请假单

    实体

    请假单经多级审核后,会产生多条审批意见,为了方便查询,我们可以将审批意见设计为实体。请假审批通过后,会产生请假审批通过的领域事件,因此还会有请假事件实体。请假聚合有以下实体:审批意见(记录审批人、审批状态和审批意见)和请假事件实体。

    值对象

    我们再来分析一下请假单聚合的值对象。请假人和下一审批人数据来源于人员组织关系聚合中的人员实体,可设计为值对象。人员类型、请假类型和审批状态是枚举值类型,可设计为值对象。确定请假审批规则后,审批规则也可作为请假单的值对象。

    请假单聚合将包含以下值对象:请假人、下一审批人、人员类型、请假类型、审批状态和审批规则。

    为什么审批规则是值对象?因为审批规则会在后续审批流程中多次使用(根据审批规则查询下一审批人),将他设计为值对象保存到请假单,后面请假单信息修改,影响到审批规则,会整体替换审批规则数据。(像数据冗余)

    得出请假聚合对象关系图

  • 在人员组织关系聚合中,聚合根是人员

    实体有组织关系(包括组织关系类型和上级审批领导)。其中组织关系类型(如项目经理、处长、总经理等)是值对象。上级审批领导来源于人员聚合根,可设计为值对象。

    得出人员组织关系聚合对象关系图:

6) 哪些对象应该设计为值对象?就是实体的属性

根据上面的服务分层依赖关系图和聚合对象关系图,我们得出了微服务内的对象清单,设计出各领域对象(应用服务、领域服务、实体方法、事件发布订阅、实体、值对象)在代码模型中的代码对象(包括代码对象的包名、类名和方法名),建立领域对象与代码对象的一一映射关系了。对象清单如下图:

到此,基本上是这样的路线:

服务分层依赖关系图-》聚合对象关系图-》微服务的对象清单

2、设计微服务代码结构

根据微服务内的对象清单,我们可以定义请假微服务的代码结构

1) 应用层代码结构

包括应用服务、事件发布订阅相关代码

2)领域层代码结构

领域层包括一个或多个聚合的实体类、事件实体类、领域服务以及工厂、仓储相关代码。一个聚合对应一个聚合代码目录,聚合之间在代码上完全隔离,聚合之间通过应用层协调。请假微服务领域层包含请假和人员两个聚合。人员和请假代码都放在各自的聚合所在目录结构的代码包中。如果随着业务发展,人员相关功能需要从请假微服务中拆分出来,我们只需将人员聚合代码包稍加改造,独立部署,即可快速发布为人员微服务。到这里,微服务内的领域对象,分层以及依赖关系就梳理清晰了。微服务的总体架构和代码模型也基本搭建完成

源码:https://gitee.com/jacobmj/study-demo/tree/master/jacob-ddd-smooth

后续工作

1、详细设计

在完成领域模型和微服务设计后,我们还需要对微服务进行详细的设计。主要设计以下内容:实体属性、数据库表和字段、实体与数据库表映射、服务参数规约及功能实现等。

2、代码开发和测试

开发人员只需要按照详细的设计文档和功能要求,找到业务功能对应的代码位置,完成代码开发就可以了。代码开发完成后,开发人员要编写单元测试用例,基于挡板模拟依赖对象完成服务测试。

总结

通过上面的例子,把DDD设计过程走了一遍,有两个阶段

  1. 战略设计从事件风暴开始,然后我们要找出实体等领域对象,找出聚合根构建聚合,划分限界上下文,建立领域模型。
  2. 战术设计从事件风暴的命令开始,识别和设计服务,建立各层服务的依赖关系,设计微服务内的实体和值对象,找出微服务中所有的领域对象,并建立领域对象与代码对象的映射关系。

DDD对微服务的拆分设计流程如下图:

参考

  • 欧创新《DDD实战课》

Post Directory