Go to my github

DDD实战课(实战篇)--学习笔记

目录

  • DDD实践:如何用DDD重构中台业务模型?
  • 领域建模:如何用事件风暴构建领域模型?
  • 代码模型(上):如何使用DDD设计微服务代码模型?
  • 代码模型(下):如何保证领域模型与代码模型的一致性?
  • 边界:微服务的各种边界在架构演进中的作用?
  • 视图:如何实现服务和数据在微服务各层的协作?
  • 从后端到前端:微服务后,前端如何设计?
  • 知识点串讲:基于DDD的微服务设计实例
  • 基于DDD的微服务设计实例代码详解
  • 总结(一):微服务设计和拆分要坚持哪些原则?
  • 总结(二):分布式架构关键设计10问

DDD实践:如何用DDD重构中台业务模型?

传统企业应用分析

互联网电商平台和传统核心应用,两者面向的渠道和客户不一样,但销售的产品却很相似,它们之间的业务模型既有相同的地方,又有不同的地方。

image

  • 核心能力的重复建设。由于销售同质保险产品,二者在核心业务流程和功能上必然相似,因此在核心业务能力上存在功能重叠是不可避免的。
  • 通用能力的重复建设。传统核心应用的通用平台大而全,通常会比较重。而互联网电商平台离不开这些通用能力的支撑,但为了保持敏捷性,一般会自己建设缩小版的通用功能,比如用户、客户等。
  • 业务职能的分离建设。有一类业务功能,在互联网电商平台中建设了一部分,在传统核心应用中也建设了一部分,二者功能不重叠而且还互补,组合在一起是一个完整的业务职能。
  • 互联网电商平台和传统核心功能前后完全独立建设。

如何避免重复造轮子?

你需要站在企业高度,将重复的需要共享的通用能力、核心能力沉淀到中台,将分离的业务能力重组为完整的业务板块,构建可复用的中台业务模型。前端个性能力归前端,后端管理能力归后台。建立前、中、后台边界清晰,融合协作的企业级可复用的业务模型。

如何构建中台业务模型?

1. 自顶向下的策略

这种策略是先做顶层设计,从最高领域逐级分解为中台,分别建立领域模型,根据业务属性分为通用中台或核心中台。领域建模过程主要基于业务现状,暂时不考虑系统现状。自顶向下的策略适用于全新的应用系统建设,或旧系统推倒重建的情况。

image

2. 自底向上的策略

这种策略是基于业务和系统现状完成领域建模。首先分别完成系统所在业务域的领域建模;然后对齐业务域,找出具有同类或相似业务功能的领域模型,对比分析领域模型的差异,重组领域对象,重构领域模型。这个过程会沉淀公共和复用的业务能力,会将分散的业务模型整合。自底向上策略适用于遗留系统业务模型的演进式重构。

具体如何采用自底向上的策略来构建中台业务模型,主要分为这样三个步骤。

第一步:锁定系统所在业务域,构建领域模型。

image

在这些领域模型的清单里,我们可以看到二者之间有很多名称相似的领域模型。深入分析后你会发现,这些名称相似的领域模型存在业务能力重复,或者业务职能分散(比如移动支付和传统支付)的问题。那在构建中台业务模型时,你就需要重点关注它们,将这些不同领域模型中重复的业务能力沉淀到中台业务模型中,将分散的领域模型整合到统一的中台业务模型中,对外提供统一的共享的中台服务。

第二步:对齐业务域,构建中台业务模型。

image

首先我们可以将传统核心的领域模型作为主领域模型,将互联网电商领域模型作为辅助模型来构建中台业务模型。然后再将互联网电商中重复的能力沉淀到传统核心的领域模型中,只保留自己的个性能力,比如订单。中台业务建模时,既要关注领域模型的完备性,也要关注不同渠道敏捷响应市场的要求。

客户中台业务模型的构建过程

互联网电商客户主要面向个人客户,除了有个人客户信息管理功能外,基于营销目的它还有客户积分功能,因此它的领域模型有个人和积分两个聚合。

而传统核心客户除了支持个人客户外,还有单位和组织机构等团体客户,它有个人和团体两个领域模型。其中个人领域模型中除了个人客户信息管理功能外,还有个人客户的评级、重复客户的归并和客户的统一视图等功能,因此它的领域模型有个人、视图、评级和归并四个聚合。

构建多业务域的中台业务模型的过程,就是找出同一业务域内所有同类业务的领域模型,对比分析域内领域模型和聚合的差异和共同点,打破原有的模型,完成新的中台业务模型重组或归并的过程。

我们将互联网电商和传统核心的领域模型分解后,我们找到了五个与个人客户领域相关的聚合,包括:个人、积分、评级、归并和视图。这五个聚合原来分别分散在互联网电商和传统核心的领域模型中,我们需要打破原有的领域模型,进行功能沉淀和聚合的重组,重新找出这些聚合的限界上下文,重构领域模型。

最终个人客户的领域模型重构为:个人、归并和视图三个聚合重构为个人领域模型(客户信息管理),评级和积分两个聚合重构为评级积分领域模型(面向个人客户)。到这里我们就完成了个人客户领域模型的构建了。

总结成一句话就是:“分域建模型,找准基准域,划定上下文,聚合重归类。”

其它业务域重构后的中台业务模型

image

第三步:中台归类,根据领域模型设计微服务。

完成中台业务建模后,我们就有了下面这张图。根据中台下的领域模型就可以设计微服务了。

image

重构过程中的领域对象

传统核心客户领域模型重构之前,包含个人、团体和评级三个聚合,每个聚合内部都有自己的聚合根、实体、方法和领域服务等。

image

互联网电商客户领域模型重构前包含个人和积分两个聚合,每个聚合包含了自己的领域对象、方法和领域服务等。

image

传统核心和互联网电商客户领域模型重构成客户中台后,建立了个人、团体和评级积分三个领域模型。

部分领域对象可能会根据新的业务要求,从原来的聚合中分离,重组到其它聚合。新领域模型的领域对象,比如实体、领域服务等,在重组后可能还会根据新的业务场景和需求进行代码重构。

image

领域建模:如何用事件风暴构建领域模型?

事件风暴是一项团队活动,领域专家与项目团队通过头脑风暴的形式,罗列出领域中所有的领域事件,整合之后形成最终的领域事件集合,然后对每一个事件,标注出导致该事件的命令,再为每一个事件标注出命令发起方的角色。命令可以是用户发起,也可以是第三方系统调用或者定时器触发等,最后对事件进行分类,整理出实体、聚合、聚合根以及限界上下文。而事件风暴正是 DDD 战略设计中经常使用的一种方法,它可以快速分析和分解复杂的业务领域,完成领域建模。

事件风暴需要准备些什么?

1. 事件风暴的参与者

事件风暴采用工作坊的方式,将项目团队和领域专家聚集在一起,通过可视化、高互动的方式一步一步将领域模型设计出来。

领域专家就是对业务或问题域有深刻见解的主题专家,他们非常了解业务和系统是怎么做的,同时也深刻理解为什么要这样设计。

除了领域专家,事件风暴的其他参与者可以是 DDD 专家、架构师、产品经理、项目经理、开发人员和测试人员等项目团队成员。

2. 事件风暴要准备的材料

事件风暴参与者会将自己的想法和意见写在即时贴上,并将贴纸贴在墙上的合适位置,我们戏称这个过程是“刷墙”。所以即时贴和水笔是必备材料,另外,你还可以准备一些胶带或者磁扣,以便贴纸随时能更换位置。

值得提醒一下的是,在这个过程中,我们要用不同颜色的贴纸区分领域行为。

image

3. 事件风暴的场地

你只需要一堵足够长的墙和足够大的空间就可以了。墙是用来贴纸的,大空间可以让人四处走动,方便合作。撤掉会议桌和椅子的事件风暴,你会发现参与者们的效率更高。

4. 事件风暴分析的关注点

在领域建模的过程中,我们需要重点关注这类业务的语言和行为。比如某些业务动作或行为(事件)是否会触发下一个业务动作,这个动作(事件)的输入和输出是什么?是谁(实体)发出的什么动作(命令),触发了这个动作(事件)…我们可以从这些暗藏的词汇中,分析出领域模型中的事件、命令和实体等领域对象。

如何用事件风暴构建领域模型?

领域建模的过程主要包括产品愿景、业务场景分析、领域建模和微服务拆分与设计这几个重要阶段。

1. 产品愿景

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

image

2. 业务场景分析

场景分析是从用户视角出发的,根据业务流程或用户旅程,采用用例和场景分析,探索领域中的典型场景,找出领域事件、实体和命令等领域对象,支撑领域建模。事件风暴参与者要尽可能地遍历所有业务细节,充分发表意见,不要遗漏业务要点。

用户中台有这样三个典型的业务场景:

  • 第一个是系统和岗位设置,设置系统中岗位的菜单权限;
  • 第二个是用户权限配置,为用户建立账户和密码,设置用户岗位;
  • 第三个是用户登录系统和权限校验,生成用户登录和操作日志。

我们可以按照业务流程,一步一步搜寻用户业务流程中的关键领域事件,比如岗位已创建,用户已创建等事件。再找出什么行为会引起这些领域事件,这些行为可能是一个或若干个命令组合在一起产生的,比如创建用户时,第一个命令是从公司 HR 系统中获取用户信息,第二个命令是根据 HR 的员工信息在用户中台创建用户,创建完用户后就会产生用户已创建的领域事件。当然这个领域事件可能会触发下一步的操作,比如发布到邮件系统通知用户已创建,但也可能到此就结束了,你需要根据具体情况来分析是否还有下一步的操作。

image

3. 领域建模

领域建模时,我们会根据场景分析过程中产生的领域对象,比如命令、事件等之间关系,找出产生命令的实体,分析实体之间的依赖关系组成聚合,为聚合划定限界上下文,建立领域模型以及模型之间的依赖。领域模型利用限界上下文向上可以指导微服务设计,通过聚合向下可以指导聚合根、实体和值对象的设计。

具体可以分为这样三步。

  • 第一步:从命令和事件中提取产生这些行为的实体
  • 第二步:根据聚合根的管理性质从七个实体中找出聚合根
  • 第三步:划定限界上下文,根据上下文语义将聚合归类

image

image

到这里我们就完成了用户中台领域模型的构建了。那由于领域建模的过程中产生的领域对象实在太多了,我们可以借助表格来记录。

image

4. 微服务拆分与设计

原则上一个领域模型就可以设计为一个微服务,但由于领域建模时只考虑了业务因素,没有考虑微服务落地时的技术、团队以及运行环境等非业务因素,因此在微服务拆分与设计时,我们不能简单地将领域模型作为拆分微服务的唯一标准,它只能作为微服务拆分的一个重要依据。

用户中台微服务设计如果不考虑非业务因素,我们完全可以按照领域模型与微服务一对一的关系来设计,将用户中台设计为:用户、认证和权限三个微服务。但如果用户日志数据量巨大,大到需要采用大数据技术来实现,这时用户信息聚合与用户日志聚合就会有技术异构。虽然在领域建模时,我们将他们放在一个了领域模型内,但如果考虑技术异构,这两个聚合就不适合放到同一个微服务里了。我们可以以聚合作为拆分单位,将用户基本信息管理和用户日志管理拆分为两个技术异构的微服务,分别用不同的技术来实现它们。

代码模型(上):如何使用DDD设计微服务代码模型?

DDD 分层架构与微服务代码模型

image

业务逻辑从领域层、应用层到用户接口层逐层封装和协作,对外提供灵活的服务,既实现了各层的分工,又实现了各层的协作。因此,毋庸置疑,DDD 分层架构模型就是设计微服务代码模型的最佳依据。

微服务代码模型

微服务一级目录结构

image

各层目录结构
1. 用户接口层

image

  • Assembler:实现 DTO 与领域对象之间的相互转换和数据交换。一般来说 Assembler 与 DTO 总是一同出现。
  • Dto:它是数据传输的载体,内部不存在任何业务逻辑,我们可以通过 DTO 把内部的领域对象与外界隔离。
  • Facade:提供较粗粒度的调用接口,将用户请求委派给一个或多个应用服务进行处理。
2. 应用层

image

虽然应用层和领域层都可以进行事件的发布和处理,但为了实现事件的统一管理,我建议你将微服务内所有事件的发布和订阅的处理都统一放到应用层,事件相关的核心业务逻辑实现放在领域层。

3. 领域层

image

按照 DDD 分层架构,仓储实现本应该属于基础层代码,但为了在微服务架构演进时,保证代码拆分和重组的便利性,我是把聚合仓储实现的代码放到了聚合包内。这样,如果需求或者设计发生变化导致聚合需要拆分或重组时,我们就可以将包括核心业务逻辑和仓储代码的聚合包整体迁移,轻松实现微服务架构演进。

4. 基础层

image

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

代码模型总目录结构

image

总结

那关于代码模型我还需要强调两点内容。

第一点:聚合之间的代码边界一定要清晰。聚合之间的服务调用和数据关联应该是尽可能的松耦合和低关联,聚合之间的服务调用应该通过上层的应用层组合实现调用,原则上不允许聚合之间直接调用领域服务。这种松耦合的代码关联,在以后业务发展和需求变更时,可以很方便地实现业务功能和聚合代码的重组,在微服务架构演进中将会起到非常重要的作用。

第二点:你一定要有代码分层的概念。写代码时一定要搞清楚代码的职责,将它放在职责对应的代码目录内。应用层代码主要完成服务组合和编排,以及聚合之间的协作,它是很薄的一层,不应该有核心领域逻辑代码。领域层是业务的核心,领域模型的核心逻辑代码一定要在领域层实现。如果将核心领域逻辑代码放到应用层,你的基于 DDD 分层架构模型的微服务慢慢就会演变成传统的三层架构模型了。

代码模型(下):如何保证领域模型与代码模型的一致性?

DDD 强调先构建领域模型然后设计微服务,以保证领域模型和微服务的一体性,因此我们不能脱离领域模型来谈微服务的设计和落地。但在构建领域模型时,我们往往是站在业务视角的,并且有些领域对象还带着业务语言。我们还需要将领域模型作为微服务设计的输入,对领域对象进行设计和转换,让领域对象与代码对象建立映射关系。

领域对象的整理

我们第一个重要的工作就是,整理事件风暴过程中产生的各个领域对象,比如:聚合、实体、命令和领域事件等内容,将这些领域对象和业务行为记录到下面的表格中。

image

从领域模型到微服务的设计

从领域模型到微服务落地,我们还需要做进一步的设计和分析。事件风暴中提取的领域对象,还需要经过用户故事或领域故事分析,以及微服务设计,才能用于微服务系统开发。

领域层的领域对象

下面我们就来看一下这些领域对象是怎么得来的?

1. 设计实体

大多数情况下,领域模型的业务实体与微服务的数据库实体是一一对应的。但某些领域模型的实体在微服务设计时,可能会被设计为多个数据实体,或者实体的某些属性被设计为值对象。

在分层架构里,实体采用充血模型,在实体类内实现实体的全部业务逻辑。这些不同的实体都有自己的方法和业务行为,比如地址实体有新增和修改地址的方法,银行账号实体有新增和修改银行账号的方法。

2. 找出聚合根

聚合根来源于领域模型,在个人客户聚合里,个人客户这个实体是聚合根,它负责管理地址、电话以及银行账号的生命周期。个人客户聚合根通过工厂和仓储模式,实现聚合内地址、银行账号等实体和值对象数据的初始化和持久化。

3. 设计值对象

根据需要将某些实体的某些属性或属性集设计为值对象。值对象类放在代码模型的 Entity 目录结构下。在个人客户聚合中,客户拥有客户证件类型,它是以枚举值的形式存在,所以将它设计为值对象。

4. 设计领域事件

如果领域模型中领域事件会触发下一步的业务操作,我们就需要设计领域事件。首先确定领域事件发生在微服务内还是微服务之间。然后设计事件实体对象,事件的发布和订阅机制,以及事件的处理机制。判断是否需要引入事件总线或消息中间件。

5. 设计领域服务

如果一个业务动作或行为跨多个实体,我们就需要设计领域服务。领域服务通过对多个实体和实体方法进行组合,完成核心业务逻辑。你可以认为领域服务是位于实体方法之上和应用服务之下的一层业务逻辑。

按照严格分层架构层的依赖关系,如果实体的方法需要暴露给应用层,它需要封装成领域服务后才可以被应用服务调用。所以如果有的实体方法需要被前端应用调用,我们会将它封装成领域服务,然后再封装为应用服务。

6. 设计仓储

每一个聚合都有一个仓储,仓储主要用来完成数据查询和持久化操作。仓储包括仓储的接口和仓储实现,通过依赖倒置实现应用业务逻辑与数据库资源逻辑的解耦。

应用层的领域对象

应用层的主要领域对象是应用服务和事件的发布以及订阅。

在严格分层架构模式下,不允许服务的跨层调用,每个服务只能调用它的下一层服务。服务从下到上依次为:实体方法、领域服务和应用服务。

image

1. 实体方法的封装

实体方法是最底层的原子业务逻辑。如果单一实体的方法需要被跨层调用,你可以将它封装成领域服务,这样封装的领域服务就可以被应用服务调用和编排了。如果它还需要被用户接口层调用,你还需要将这个领域服务封装成应用服务。经过逐层服务封装,实体方法就可以暴露给上面不同的层,实现跨层调用。

2. 领域服务的组合和封装

领域服务会对多个实体和实体方法进行组合和编排,供应用服务调用。如果它需要暴露给用户接口层,领域服务就需要封装成应用服务。

3. 应用服务的组合和编排

应用服务会对多个领域服务进行组合和编排,暴露给用户接口层,供前端应用调用。

领域对象与微服务代码对象的映射

在完成上面的分析和设计后,我们就可以建立像下图一样的,领域对象与微服务代码对象的映射关系了。

典型的领域模型

我们看一下下面这个图,我们对个人客户聚合做了进一步的分析。提取了个人客户表单这个聚合根,形成了客户类型值对象,以及电话、地址、银行账号等实体,为实体方法和服务做了封装和分层,建立了领域对象的关联和依赖关系,还有仓储等设计。关键是这个过程,我们建立了领域对象与微服务代码对象的映射关系。

image

在建立这种映射关系后,我们就可以得到如下图的微服务代码结构了。

image

非典型领域模型

那对于这类非典型模型,我们怎么办?

我们还是可以借鉴聚合的思想,仍然用聚合来定义这部分功能,并采用与典型领域模型同样的分析方法,建立实体的属性和方法,对方法和服务进行封装和分层设计,设计仓储,建立领域对象之间的依赖关系。唯一可惜的就是我们依然找不到聚合根,不过也没关系,除了聚合根管理功能外,我们还可以用 DDD 的其它设计方法。

边界:微服务的各种边界在架构演进中的作用?

微服务的设计要涉及到逻辑边界、物理边界和代码边界等等。

演进式架构

演进式架构就是以支持增量的、非破坏的变更作为第一原则,同时支持在应用程序结构层面的多维度变化。

随着业务的发展或需求的变更,在不断重新拆分或者组合成新的微服务的过程中,不会大幅增加软件开发和维护的成本,并且这个架构演进的过程是非常轻松、简单的。

这也是微服务设计的重点,就是看微服务设计是否能够支持架构长期、轻松的演进。

微服务还是小单体?

image

这种单体式微服务只定义了一个维度的边界,也就是微服务之间的物理边界,本质上还是单体架构模式。微服务设计时要考虑的不仅仅只有这一个边界,别忘了还要定义好微服务内的逻辑边界和代码边界,这样才能得到你想要的结果。

微服务边界的作用

在事件风暴中,我们会梳理出业务过程中的用户操作、事件以及外部依赖关系等,根据这些要素梳理出实体等领域对象。根据实体对象之间的业务关联性,将业务紧密相关的多个实体进行组合形成聚合,聚合之间是第一层边界。根据业务及语义边界等因素将一个或者多个聚合划定在一个限界上下文内,形成领域模型,限界上下文之间的边界是第二层边界。

逻辑边界主要定义同一业务领域或应用内紧密关联的对象所组成的不同聚类的组合之间的边界。事件风暴对不同实体对象进行关联和聚类分析后,会产生多个聚合和限界上下文,它们一起组成这个领域的领域模型。微服务内聚合之间的边界就是逻辑边界。一般来说微服务会有一个以上的聚合,在开发过程中不同聚合的代码隔离在不同的聚合代码目录中。

随着业务的快速发展,如果某一个微服务遇到了高性能挑战,需要将部分业务能力独立出去,我们就可以以聚合为单位,将聚合代码拆分独立为一个新的微服务,这样就可以很容易地实现微服务的拆分。

image

另外,我们也可以对多个微服务内有相似功能的聚合进行功能和代码重组,组合为新的聚合和微服务,独立为通用微服务。

物理边界主要从部署和运行的视角来定义微服务之间的边界。不同微服务部署位置和运行环境是相互物理隔离的,分别运行在不同的进程中。这种边界就是微服务之间的物理边界。

代码边界主要用于微服务内的不同职能代码之间的隔离。微服务开发过程中会根据代码模型建立相应的代码目录,实现不同功能代码的隔离。由于领域模型与代码模型的映射关系,代码边界直接体现出业务边界。代码边界可以控制代码重组的影响范围,避免业务和服务之间的相互影响。微服务如果需要进行功能重组,只需要以聚合代码为单位进行重组就可以了。

正确理解微服务的边界

微服务的拆分可以参考领域模型,也可以参考聚合,因为聚合是可以拆分为微服务的最小单位的。但实施过程是否一定要做到逻辑边界与物理边界一致性呢?也就是说聚合是否也一定要设计成微服务呢?答案是不一定的,这里就涉及到微服务过度拆分的问题了。

微服务的过度拆分会使软件维护成本上升,比如:集成成本、发布成本、运维成本以及监控和定位问题的成本等。在项目建设初期,如果你不具备较强的微服务管理能力,那就不宜将微服务拆分过细。当我们具备一定的能力以后,且微服务内部的逻辑和代码边界也很清晰,你就可以随时根据需要,拆分出新的微服务,实现微服务的架构演进了。

视图:如何实现服务和数据在微服务各层的协作?

服务的协作

1. 服务的类型

分层架构中的服务。按照分层架构设计出来的微服务,其内部有 Facade 服务、应用服务、领域服务和基础服务。

  • Facade 服务:位于用户接口层,包括接口和实现两部分。用于处理用户发送的 Restful 请求和解析用户输入的配置文件等,并将数据传递给应用层。或者在获取到应用层数据后,将 DO 组装成 DTO,将数据传输到前端应用。
  • 应用服务:位于应用层。用来表述应用和用户行为,负责服务的组合、编排和转发,负责处理业务用例的执行顺序以及结果拼装,对外提供粗粒度的服务。
  • 领域服务:位于领域层。领域服务封装核心的业务逻辑,实现需要多个实体协作的核心领域逻辑。它对多个实体或方法的业务逻辑进行组合或编排,或者在严格分层架构中对实体方法进行封装,以领域服务的方式供应用层调用。
  • 基础服务:位于基础层。提供基础资源服务(比如数据库、缓存等),实现各层的解耦,降低外部资源变化对业务应用逻辑的影响。基础服务主要为仓储服务,通过依赖倒置提供基础资源服务。领域服务和应用服务都可以调用仓储服务接口,通过仓储服务实现数据持久化。2. 服务的调用

2. 服务的调用

微服务的服务调用包括三类主要场景:微服务内跨层服务调用,微服务之间服务调用和领域事件驱动。

image

微服务内跨层服务调用

微服务架构下往往采用前后端分离的设计模式,前端应用独立部署。前端应用调用发布在 API 网关上的 Facade 服务,Facade 定向到应用服务。应用服务作为服务组织和编排者,它的服务调用有这样两种路径:

  • 第一种是应用服务调用并组装领域服务。此时领域服务会组装实体和实体方法,实现核心领域逻辑。领域服务通过仓储服务获取持久化数据对象,完成实体数据初始化。
  • 第二种是应用服务直接调用仓储服务。这种方式主要针对像缓存、文件等类型的基础层数据访问。这类数据主要是查询操作,没有太多的领域逻辑,不经过领域层,不涉及数据库持久化对象。
微服务之间的服务调用

微服务之间的应用服务可以直接访问,也可以通过 API 网关访问。由于跨微服务操作,在进行数据新增和修改操作时,你需关注分布式事务,保证数据的一致性。

领域事件驱动

领域事件驱动包括微服务内和微服务之间的事件。微服务内通过事件总线(EventBus)完成聚合之间的异步处理。微服务之间通过消息中间件完成。异步化的领域事件驱动机制是一种间接的服务访问方式。

当应用服务业务逻辑处理完成后,如果发生领域事件,可调用事件发布服务,完成事件发布。当接收到订阅的主题数据时,事件订阅服务会调用事件处理领域服务,完成进一步的业务操作。

3. 服务的封装与组合

微服务的服务是从领域层逐级向上封装、组合和暴露的。

image

领域层

领域层实现核心业务逻辑,负责表达领域模型业务概念、业务状态和业务规则。主要的服务形态有实体方法和领域服务。

DDD 提倡富领域模型,尽量将业务逻辑归属到实体对象上,实在无法归属的部分则设计成领域服务。领域服务会对多个实体或实体方法进行组装和编排,实现跨多个实体的复杂核心业务逻辑。

应用层

应用层的主要服务形态有:应用服务、事件发布和订阅服务。

为了实现微服务内聚合之间的解耦,聚合之间的服务调用和数据交互应通过应用服务来完成。原则上我们应该禁止聚合之间的领域服务直接调用和聚合之间的数据表关联。

4. 两种分层架构的服务依赖关系

松散分层架构的服务依赖

在松散分层架构中,领域层的实体方法和领域服务可以直接暴露给应用层和用户接口层。松散分层架构的服务依赖关系,无需逐级封装,可以快速暴露给上层。

image

严格分层架构的服务依赖

在严格分层架构中,每一层服务只能向紧邻的上一层提供服务。虽然实体、实体方法和领域服务都在领域层,但实体和实体方法只能暴露给领域服务,领域服务只能暴露给应用服务。

image

数据对象视图

我们先来看一下微服务内有哪些类型的数据对象?它们是如何协作和转换的?

  • 数据持久化对象 PO(Persistent Object),与数据库结构一一映射,是数据持久化过程中的数据载体。
  • 领域对象 DO(Domain Object),微服务运行时的实体,是核心业务的载体。
  • 数据传输对象 DTO(Data Transfer Object),用于前端与应用层或者微服务之间的数据组装和传输,是应用之间数据传输的载体。
  • 视图对象 VO(View Object),用于封装展示层指定页面或组件的数据。

image

从后端到前端:微服务后,前端如何设计?

微服务架构通常采用前后端分离的设计方式。作为企业级的中台,在完成单体应用拆分和微服务建设后,前端项目团队会同时面对多个中台微服务项目团队,这时候的前端人员就犹如维修电工一样了。

要从一定程度上解决上述问题,我们是不是可以考虑先有效降低前端集成的复杂度呢?先做到前端聚合,后端解耦

从单体前端到微前端

在前端设计时我们需要遵循单一职责和复用原则,按照领域模型和微服务边界,将前端页面进行拆分。同时构建多个可以独立部署、完全自治、松耦合的页面组合,其中每个组合只负责特定业务单元的 UI 元素和功能,这些页面组合就是微前端。

微前端与微服务一样,都是希望将单体应用,按照规则拆分,并重组为多个可以独立开发、独立测试、独立部署和独立运维,松耦合的微前端或者微服务。以适应业务快速变化及分布式多团队并行开发的要求。

业务单元的组合形态

我们可以参照领域模型和微服务边界,建立与微服务对应的前端操作界面,将它与微服务组成业务单元,以业务组件的方式对外提供服务。业务单元包括微前端和微服务,可以独立开发、测试、部署和运维,可以自包含地完成领域模型中部分或全部的业务功能。

我们看一下下面这个图。一个虚框就是一个业务单元,微前端和微服务独立部署,业务单元内的微前端和微服务已完成前后端集成。你可以将这个业务单元理解为一个特定业务领域的组件。业务单元可以有多种组合方式,以实现不同的业务目标。

image

记住一点:微前端不宜与过多的微服务组合,否则容易变成单体前端。

微前端的集成方式

我们看一下下面这个图,微前端位于前端主页面和微服务之间,它需要与两者完成集成。

image

1. 微前端与前端主页面的集成

前端主页面是企业级的前端页面,微前端是业务单元的前端页面。微前端通过主页面的微前端加载器,利用页面路由和动态加载等技术,将特定业务单元的微前端页面动态加载到前端主页面,实现前端主页面与微前端页面的“拼图式”集成。

微前端完成开发、集成和部署后,在前端主页面完成微前端注册以及页面路由配置,即可实现动态加载微前端页面。

2. 微前端与微服务的集成

微前端与微服务独立开发,独立部署。在微前端注册到前端主页面前,微前端需要与微服务完成集成。它的集成方式与传统前后端分离的集成方式没有差异。微服务将服务发布到 API 网关,微前端调用发布在 API 网关中的服务,即完成业务单元内的前后端集成。

团队职责边界

前端项目团队专注于前端集成主页面与微前端的集成,完成前端主页面的企业级主流程的页面和流程编排以及微前端页面的动态加载,确保主流程业务逻辑和流程正确。前端项目除了要负责企业内页面风格的整体风格设计、业务流程的流转和控制外,还需要负责微前端页面动态加载、微前端注册、页面路由和页面数据共享等前端技术的实现。

中台项目团队完成业务单元组件的开发、测试和集成,确保业务单元内的业务逻辑、页面和流程正确,向外提供包含页面逻辑和业务逻辑的业务单元组件。

这样,前端项目团队只需要完成企业级前端主页面与业务单元的融合,前端只关注前端主页面与微前端页面之间的集成。

中台项目团队关注业务单元功能的完整性和自包含能力,完成业务单元内微服务和微前端开发、集成和部署,提供业务单元组件。

一个有关保险微前端设计的案例

保险集团为了统一运营,会实现寿险、财险等集团化的全险种销售。这样前端项目团队就需要用一个前端应用,集成非常多的不同产品的核心中台微服务,前端应用与中台微服务之间的集成将会更复杂。

如果仍然采用传统的单体前端模式,将会面临比较大的困难。

  • 第一是前端页面开发和设计的复杂性。
  • 第二是前端与微服务集成的复杂性。
  • 第三是前后端软件版本的协同发布。

那如何用一个前端应用实现全险种产品销售呢?怎样设计才能降低集成的复杂度,实现前端界面融合,后端中台解耦呢?

我们看一下下面这个图。我们借鉴了电商的订单模式实现保险产品的全险种订单化销售,在一个前端主页面可以将所有业务流程和业务操作无缝串联起来。虽然后端有很多业务单元(包含微服务和微前端),但用户始终感觉是在一个前端应用中操作。

image

微前端

每个微服务都有自己的微前端页面,实现领域模型的微服务前端页面操作。

业务单元

微服务与微前端组合为一个业务单元。由一个中台团队完成业务单元的开发、集成、测试和部署,确保业务单元内页面操作和业务逻辑正确。

前端主页面

前端主页面类似门户,包括页面导航以及部分通用的常驻主页面的共享页面,比如购物车。前端主页面和所有微前端应统一界面风格,符合统一的前端集成规范。按照正确的业务逻辑和规则,动态加载不同业务单元的微前端页面。前端主页面作为一个整体,协调核心和通用业务单元的微前端页面,完成业务操作和业务流程,提供全险种销售接触界面,包括商品目录、录单、购物车、订单、支付等操作。

虽然后端有很多业务单元在支持,但用户所有的页面操作和流转是在一个前端主页面完成的。在进行全险种的订单化销售时,用户始终感觉是在操作一个系统。这种设计方式很好地体现了前端的融合和中台的解耦。

微前端和业务单元化的设计模式可以减轻企业级中台,前后端应用开发和集成的复杂度,真正实现前端融合和中台解耦。它的主要价值和意义如下:

  • 前端集成简单
  • 项目职责专一
  • 隔离和依赖性
  • 降低沟通和测试成本
  • 更敏捷地发布
  • 降低技术敏感性
  • 高度复用性

知识点串讲:基于DDD的微服务设计实例

为了更好地理解 DDD 的设计流程,今天我会用一个项目来带你了解 DDD 的战略设计和战术设计,走一遍从领域建模到微服务设计的全过程,一起掌握 DDD 的主要设计流程和关键点。

项目基本信息

项目的目标是实现在线请假和考勤管理。功能描述如下:

1、请假人填写请假单提交审批,根据请假人身份、请假类型和请假天数进行校验,根据审批规则逐级递交上级审批,逐级核批通过则完成审批,否则审批不通过退回申请人。

2、根据考勤规则,核销请假数据后,对考勤数据进行校验,输出考勤统计。

战略设计

战略设计是根据用户旅程分析,找出领域对象和聚合根,对实体和值对象进行聚类组成聚合,划分限界上下文,建立领域模型的过程。

1. 产品愿景

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

事件风暴时,所有参与者针对每一个要点,在贴纸上写出自己的意见,贴到白板上。事件风暴主持者会对每个贴纸,讨论并对发散的意见进行收敛和统一,形成下面的产品愿景图。

image

我们把这个产品愿景图整理成一段文字就是:为了满足内外部人员,他们的在线请假、自动考勤统计和外部人员管理的需求,我们建设这个在线请假考勤系统,它是一个在线请假平台,可以自动考勤统计。它可以同时支持内外网请假,同时管理内外部人员请假和定期考勤分析,而不像 HR 系统,只管理内部人员,且只能内网使用。我们的产品内外网皆可使用,可实现内外部人员无差异管理。

通过产品愿景分析,项目团队统一了系统名称——在线请假考勤系统,明确了项目目标和关键功能,与竞品(HR)的关键差异以及自己的优势和核心竞争力等。

2. 场景分析

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

下面我就以请假和人员两个场景作为示例。

第一个场景:请假

用户:请假人

  • 请假人登录系统:从权限微服务获取请假人信息和权限数据,完成登录认证。
  • 创建请假单:打开请假页面,选择请假类型和起始时间,录入请假信息。保存并创建请假单,提交请假审批。
  • 修改请假单:查询请假单,打开请假页面,修改请假单,提交请假审批。
  • 提交审批:获取审批规则,根据审批规则,从人员组织关系中获取审批人,给请假单分配审批人。

第二个场景:审批

用户:审批人

  • 审批人登录系统:从权限微服务获取审批人信息和权限数据,完成登录认证。
  • 获取请假单:获取审批人名下请假单,选择请假单。
  • 审批:填写审批意见。逐级审批:如果还需要上级审批,根据审批规则,从人员组织关系中获取审批人,给请假单分配审批人。
  • 重复以上 4 步。最后审批人完成审批。

完成审批后,产生请假审批已通过领域事件。后续有两个进一步的业务操作:发送请假审批已通过的通知,通知邮件系统告知请假人;将请假数据发送到考勤以便核销。

image

下面这个图是人员组织关系场景分析结果图

image

3. 领域建模

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

领域建模是一个收敛的过程,分三步:

  • 第一步找出领域实体和值对象等领域对象;
  • 第二步找出聚合根,根据实体、值对象与聚合根的依赖关系,建立聚合;
  • 第三步根据业务及语义边界等因素,定义限界上下文。

下面我们就逐步详细讲解一下。

第一步:找出实体和值对象等领域对象

根据场景分析,分析并找出发起或产生这些命令或领域事件的实体和值对象,将与实体或值对象有关的命令和事件聚集到实体。

image

第二步:定义聚合

定义聚合前,先找出聚合根。从上面的实体中,我们可以找出“请假单”和“人员”两个聚合根。然后找出与聚合根紧密依赖的实体和值对象。我们发现审批意见、审批规则和请假单紧密关联,组织关系和人员紧密关联。

找出这些实体的关系后,我们发现还有刷卡明细、考勤明细和考勤统计,这几个实体没有聚合根。这种情形在领域建模时你会经常遇到,对于这类场景我们需要分情况特殊处理。

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

经过分析,我们建立了请假、人员组织关系和考勤三个聚合。其中请假聚合有请假单、审批意见实体和审批规则等值对象。人员组织关系聚合有人员和组织关系等实体。考勤聚合有刷卡明细、考勤明细和考勤统计等实体。

image

第三步:定义限界上下文

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

4. 微服务的拆分

理论上一个限界上下文就可以设计为一个微服务,但还需要综合考虑多种外部因素,比如:职责单一性、敏态与稳态业务分离、非功能性需求(如弹性伸缩、版本发布频率和安全等要求)、软件包大小、团队沟通效率和技术异构等非业务要素。

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

到这里,战略设计就结束了。通过战略设计,我们建立了领域模型,划分了微服务边界。下一步就是战术设计了,也就是微服务设计。下面我们以请假微服务为例,讲解其设计过程。

战术设计

战术设计是根据领域模型进行微服务设计的过程。这个阶段主要梳理微服务内的领域对象,梳理领域对象之间的关系,确定它们在代码模型和分层架构中的位置,建立领域模型与微服务模型的映射关系,以及服务之间的依赖关系。

战术设计包括以下两个阶段:分析微服务领域对象和设计微服务代码结构。

1. 分析微服务领域对象

服务的识别和设计

事件风暴的命令是外部的一些操作和业务行为,也是微服务对外提供的能力。它往往与微服务的应用服务或者领域服务对应。我们可以将命令作为服务识别和设计的起点。具体步骤如下:

  • 根据命令设计应用服务,确定应用服务的功能,服务集合,组合和编排方式。服务集合中的服务包括领域服务或其它微服务的应用服务。
  • 根据应用服务功能要求设计领域服务,定义领域服务。这里需要注意:应用服务可能是由多个聚合的领域服务组合而成的。
  • 根据领域服务的功能,确定领域服务内的实体以及功能。
  • 设计实体基本属性和方法。

另外,我们还要考虑领域事件的异步化处理。

我以提交审批这个动作为例,来说明服务的识别和设计。提交审批的大体流程是:

  • 根据请假类型和时长,查询请假审批规则,获取下一步审批人的角色。
  • 根据审批角色从人员组织关系中查询下一审批人。
  • 为请假单分配审批人,并将审批规则保存至请假单。
  • 通过分析,我们需要在应用层和领域层设计以下服务和方法。

应用层:提交审批应用服务。

领域层:领域服务有查询审批规则、修改请假流程信息服务以及根据审批规则查询审批人服务,分别位于请假和人员组织关系聚合。请假单实体有修改请假流程信息方法,审批规则值对象有查询审批规则方法。人员实体有根据审批规则查询审批人方法。下图是我们分析出来的服务以及它们之间的依赖关系。

image

服务的识别和设计过程就是这样了,我们再来设计一下聚合内的对象。

聚合中的对象

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

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

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

综上,我们就可以画出请假聚合对象关系图了。

image

在人员组织关系聚合中,我们可以建立人员之间的组织关系,通过组织关系类型找到上级审批领导。它的聚合根是人员,实体有组织关系(包括组织关系类型和上级审批领导),其中组织关系类型(如项目经理、处长、总经理等)是值对象。上级审批领导来源于人员聚合根,可设计为值对象。人员组织关系聚合将包含以下值对象:组织关系类型、上级审批领导。

综上,我们又可以画出人员组织关系聚合对象关系图了。

image

微服务内的对象清单

在确定各领域对象的属性后,我们就可以设计各领域对象在代码模型中的代码对象(包括代码对象的包名、类名和方法名),建立领域对象与代码对象的一一映射关系了。根据这种映射关系,相关人员可快速定位到业务逻辑所在的代码位置。在经过以上分析后,我们在微服务内就可以分析出如下图的对象清单。

image

2. 设计微服务代码结构

应用层代码结构

应用层包括:应用服务、DTO 以及事件发布相关代码。在 LeaveApplicationService 类内实现与聚合相关的应用服务,在 LoginApplicationService 封装外部微服务认证和权限的应用服务。

image

领域层代码结构

领域层包括一个或多个聚合的实体类、事件实体类、领域服务以及工厂、仓储相关代码。一个聚合对应一个聚合代码目录,聚合之间在代码上完全隔离,聚合之间通过应用层协调。

请假微服务领域层包含请假和人员两个聚合。人员和请假代码都放在各自的聚合所在目录结构的代码包中。如果随着业务发展,人员相关功能需要从请假微服务中拆分出来,我们只需将人员聚合代码包稍加改造,独立部署,即可快速发布为人员微服务。到这里,微服务内的领域对象,分层以及依赖关系就梳理清晰了。微服务的总体架构和代码模型也基本搭建完成了。

image

后续的工作

1. 详细设计

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

2. 代码开发和测试

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

基于DDD的微服务设计实例代码详解

上一节用事件风暴完成的“在线请假考勤”项目的领域建模和微服务设计,接下来我们在这个项目的基础上看看,用 DDD 方法设计和开发出来的微服务代码。点击 https://github.com/ouchuangxin/leave-sample 获取完整代码

项目回顾

“在线请假考勤”项目中,请假的核心业务流程是:请假人填写请假单提交审批;根据请假人身份、请假类型和请假天数进行校验并确定审批规则;根据审批规则确定审批人,逐级提交上级审批,逐级核批通过则完成审批,否则审批不通过则退回申请人。

请假微服务采用的 DDD 设计思想

image

聚合中的对象

image

请假微服务包含请假(leave)、人员(person)和审批规则(rule)三个聚合。leave 聚合完成请假申请和审核核心逻辑;person 聚合管理人员信息和上下级关系;rule 是一个单实体聚合,提供请假审批规则查询。

Leave 是请假微服务的核心聚合,它有请假单聚合根 leave、审批意见实体 ApprovalInfo、请假申请人 Applicant 和审批人 Approver 值对象(它们的数据来源于 person 聚合),还有部分枚举类型,如请假类型 LeaveType,请假单状态 Status 和审批状态类型 ApprovalType 等值对象。

下面我们通过代码来了解一下聚合根、实体以及值对象之间的关系。

1. 聚合根

聚合根 leave 中有属性、值对象、关联实体和自身的业务行为。Leave 实体采用充血模型,有自己的业务行为,具体就是聚合根实体类的方法,如代码中的 getDuration 和 addHistoryApprovalInfo 等方法。

聚合根引用实体和值对象,它可以组合聚合内的多个实体,在聚合根实体类方法中完成复杂的业务行为,这种复杂的业务行为也可以在聚合领域服务里实现。但为了职责和边界清晰,我建议聚合要根据自身的业务行为在实体类方法中实现,而涉及多个实体组合才能实现的业务能力由领域服务完成。

下面是聚合根 leave 的实体类方法,它包含属性、对实体和值对象的引用以及自己的业务行为和方法。

public class Leave {
    String id;
    Applicant applicant;
    Approver approver;
    LeaveType type;
    Status status;
    Date startTime;
    Date endTime;
    long duration;
    int leaderMaxLevel; //审批领导的最高级别
    ApprovalInfo currentApprovalInfo;
    List<ApprovalInfo> historyApprovalInfos; 

    public long getDuration() {
        return endTime.getTime() - startTime.getTime();
    }

    public Leave addHistoryApprovalInfo(ApprovalInfo approvalInfo) {
        if (null == historyApprovalInfos)
            historyApprovalInfos = new ArrayList<>();
        this.historyApprovalInfos.add(approvalInfo);
        return this;
    } 

    public Leave create(){
        this.setStatus(Status.APPROVING);
        this.setStartTime(new Date());
        return this;
}

//其它方法
}

2. 实体

审批意见实体 ApprovalInfo 被 leave 聚合根引用,用于记录审批意见,它有自己的属性和值对象,如 approver 等,业务逻辑相对简单。

public class ApprovalInfo {
    String approvalInfoId;
    Approver approver;
    ApprovalType approvalType;
    String msg;
    long time;
}

3. 值对象

审批人值对象 Approver。这类值对象除了属性集之外,还可以有简单的数据查询和转换服务。Approver 数据来源于 person 聚合,从 person 聚合获取审批人返回后,从 person 实体获取 personID、personName 和 level 等属性,重新组合为 approver 值对象,因此需要数据转换和重新赋值。

Approver 值对象同时被聚合根 leave 和实体 approvalInfo 引用。这类值对象的数据来源于其它聚合,不可修改,可重复使用。将这种对象设计为值对象而不是实体,可以提高系统性能,降低数据库实体关联的复杂度,所以我一般建议优先设计为值对象。

public class Approver {
    String personId;
    String personName;
    int level; //管理级别

    public static Approver fromPerson(Person person){
        Approver approver = new Approver();
        approver.setPersonId(person.getPersonId());
        approver.setPersonName(person.getPersonName());
        approver.setLevel(person.getRoleLevel());
        return approver;
    }
}

枚举类型的值对象 Status 的代码

public enum Status {
    APPROVING, APPROVED, REJECTED
}

由于值对象只做整体替换、不可修改的特性,在值对象中基本不会有修改或新增的方法

4. 领域服务

如果一个业务行为由多个实体对象参与完成,我们就将这部分业务逻辑放在领域服务中实现。领域服务与实体方法的区别是:实体方法完成单一实体自身的业务逻辑,是相对简单的原子业务逻辑,而领域服务则是多个实体组合出的相对复杂的业务逻辑。两者都在领域层,实现领域模型的核心业务能力。

一个聚合可以设计一个领域服务类,管理聚合内所有的领域服务。

请假聚合的领域服务类是 LeaveDomainService。领域服务中会用到很多的 DDD 设计模式,比如:用工厂模式实现复杂聚合的实体数据初始化,用仓储模式实现领域层与基础层的依赖倒置和用领域事件实现数据的最终一致性等。

public class LeaveDomainService {

    @Autowired
    EventPublisher eventPublisher;
    @Autowired
    LeaveRepositoryInterface leaveRepositoryInterface;
    @Autowired
    LeaveFactory leaveFactory;

    @Transactional
    public void createLeave(Leave leave, int leaderMaxLevel, Approver approver) {
            leave.setLeaderMaxLevel(leaderMaxLevel);
            leave.setApprover(approver);
            leave.create();
    leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));
    LeaveEvent event = LeaveEvent.create(LeaveEventType.CREATE_EVENT, leave);
    leaveRepositoryInterface.saveEvent(leaveFactory.createLeaveEventPO(event));
    eventPublisher.publish(event);
    }

    @Transactional
    public void updateLeaveInfo(Leave leave) {
    LeavePO po = leaveRepositoryInterface.findById(leave.getId());
        if (null == po) {
                throw new RuntimeException("leave does not exist");
         }
     leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));
    }

    @Transactional
    public void submitApproval(Leave leave, Approver approver) {
       LeaveEvent event;
       if (ApprovalType.REJECT == leave.getCurrentApprovalInfo().getApprovalType()) {
       leave.reject(approver);
       event = LeaveEvent.create(LeaveEventType.REJECT_EVENT, leave);
       } else {
             if (approver != null) {
                 leave.agree(approver);
                 event = LeaveEvent.create(LeaveEventType.AGREE_EVENT, leave); } else {
                    leave.finish();
                    event = LeaveEvent.create(LeaveEventType.APPROVED_EVENT, leave);
                    }
           }
      leave.addHistoryApprovalInfo(leave.getCurrentApprovalInfo());
      leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));
      leaveRepositoryInterface.saveEvent(leaveFactory.createLeaveEventPO(event));
      eventPublisher.publish(event);
    }

    public Leave getLeaveInfo(String leaveId) {
    LeavePO leavePO = leaveRepositoryInterface.findById(leaveId);
    return leaveFactory.getLeave(leavePO);
    }

    public List<Leave> queryLeaveInfosByApplicant(String applicantId) {
        List<LeavePO> leavePOList = leaveRepositoryInterface.queryByApplicantId(applicantId);
    return leavePOList.stream().map(leavePO -> leaveFactory.getLeave(leavePO)).collect(Collectors.toList());
    }

    public List<Leave> queryLeaveInfosByApprover(String approverId) {
    List<LeavePO> leavePOList = leaveRepositoryInterface.queryByApproverId(approverId);
    return leavePOList.stream().map(leavePO -> leaveFactory.getLeave(leavePO)).collect(Collectors.toList());
    }
}
领域服务开发时的注意事项:

在领域服务或实体方法中,我们应尽量避免调用其它聚合的领域服务或引用其它聚合的实体或值对象,这种操作会增加聚合的耦合度。在微服务架构演进时,如果出现聚合拆分和重组,这种跨聚合的服务调用和对象引用,会变成跨微服务的操作,导致这种跨聚合的领域服务调用和对象引用失效,在聚合分拆时会增加你代码解耦和重构的工作量。

以下是一段不建议使用的代码。在这段代码里 Approver 是 leave 聚合的值对象,它作为对象参数被传到 person 聚合的 findNextApprover 领域服务。如果在同一个微服务内,这种方式是没有问题的。但在架构演进时,如果 person 和 leave 两个聚合被分拆到不同的微服务中,那么 leave 中的 Approver 对象以及它的 getPersonId() 和 fromPersonPO 方法在 person 聚合中就会失效,这时你就需要进行代码重构了。

public class PersonDomainService {

   public Approver findNextApprover(Approver currentApprover, int leaderMaxLevel) {
   PersonPO leaderPO = personRepository.findLeaderByPersonId(currentApprover.getPersonId());
        if (leaderPO.getRoleLevel() > leaderMaxLevel) {
            return null;
        } else {
            return Approver.fromPersonPO(leaderPO);
        }
   }
}

那正确的方式是什么样的呢?在应用服务组合不同聚合的领域服务时,我们可以通过 ID 或者参数来传数,如单一参数 currentApproverId。这样聚合之间就解耦了,下面是修改后的代码,它可以不依赖其它聚合的实体,独立完成业务逻辑。

public class PersonDomainService {
   
   public Person findNextApprover(String currentApproverId, int leaderMaxLevel) {
   PersonPO leaderPO = personRepository.findLeaderByPersonId(currentApproverId);
   if (leaderPO.getRoleLevel() > leaderMaxLevel) {
       return null;
    } else {
            return personFactory.createPerson(leaderPO);
      }
  }
}

领域事件

在创建请假单和请假审批过程中会产生领域事件。为了方便管理,我们将聚合内的领域事件相关的代码放在聚合的 event 目录中。领域事件实体在聚合仓储内完成持久化,但是事件实体的生命周期不受聚合根管理。

1. 领域事件基类 DomainEvent

你可以建立统一的领域事件基类 DomainEvent。基类包含:事件 ID、时间戳、事件源以及事件相关的业务数据。

public class DomainEvent {
    String id;
    Date timestamp;
    String source;
    String data;
}

2. 领域事件实体

请假领域事件实体 LeaveEvent 继承基类 DomainEvent。可根据需要扩展属性和方法,如 leaveEventType。data 字段中存储领域事件相关的业务数据,可以是 XML 或 Json 等格式。

public class LeaveEvent extends DomainEvent {
    LeaveEventType leaveEventType;
    public static LeaveEvent create(LeaveEventType eventType, Leave leave){
       LeaveEvent event = new LeaveEvent();
       event.setId(IdGenerator.nextId());
       event.setLeaveEventType(eventType);
       event.setTimestamp(new Date());
       event.setData(JSON.toJSONString(leave));
       return event;
    }
}

3. 领域事件的执行逻辑

一般来说,领域事件的执行逻辑如下:

第一步:执行业务逻辑,产生领域事件。

第二步:完成业务数据持久化。

leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));

第三步:完成事件数据持久化。

leaveRepositoryInterface.saveEvent(leaveFactory.createLeaveEventPO(event));

第四步:完成领域事件发布。

eventPublisher.publish(event); 

以上领域事件处理逻辑代码详见 LeaveDomainService 中 submitApproval 领域服务,里面有请假提交审批事件的完整处理逻辑。

4. 领域事件数据持久化

为了保证事件发布方与事件订阅方数据的最终一致性和数据审计,有些业务场景需要建立数据对账机制。数据对账主要通过对源端和目的端的持久化数据比对,从而发现异常数据并进一步处理,保证数据最终一致性。

对于需要对账的事件数据,我们需设计领域事件对象的持久化对象 PO,完成领域事件数据的持久化,如 LeaveEvent 事件实体的持久化对象 LeaveEventPO。再通过聚合的仓储完成数据持久化:

leaveRepositoryInterface.saveEvent(leaveFactory.createLeaveEventPO(event))。

事件数据持久化对象 LeaveEventPO 格式如下:

public class LeaveEventPO {
    @Id
    @GenericGenerator(name = "idGenerator", strategy = "uuid") 
    @GeneratedValue(generator = "idGenerator")
    int id;
    @Enumerated(EnumType.STRING)
    LeaveEventType leaveEventType;
    Date timestamp;
    String source;
    String data;
}

仓储模式

领域模型中 DO 实体的数据持久化是必不可少的,DDD 采用仓储模式实现数据持久化,使得业务逻辑与基础资源逻辑解耦,实现依赖倒置。持久化时先完成 DO 与 PO 对象的转换,然后在仓储服务中完成 PO 对象的持久化。

1. DO 与 PO 对象的转换

Leave 聚合根的 DO 实体除了自身的属性外,还会根据领域模型引用多个值对象,如 Applicant 和 Approver 等,它们包含多个属性,如:personId、personName 和 personType 等属性。

在持久化对象 PO 设计时,你可以将这些值对象属性嵌入 PO 属性中,或设计一个组合属性字段,以 Json 串的方式存储在 PO 中。

以下是 leave 的 DO 的属性定义:

public class Leave {
    String id;
    Applicant applicant;
    Approver approver;
    LeaveType type;
    Status status;
    Date startTime;
    Date endTime;
    long duration;
    int leaderMaxLevel;
    ApprovalInfo currentApprovalInfo;
    List<ApprovalInfo> historyApprovalInfos;
}

public class Applicant {
    String personId;
    String personName;
    String personType;
}

public class Approver {
    String personId;
    String personName;
    int level;
}

为了减少数据库表数量以及表与表的复杂关联关系,我们将 leave 实体和多个值对象放在一个 LeavePO 中。如果以属性嵌入的方式,Applicant 值对象在 LeavePO 中会展开为:applicantId、applicantName 和 applicantType 三个属性。

以下为采用属性嵌入方式的持久化对象 LeavePO 的结构。

public class LeavePO {
    @Id
    @GenericGenerator(name="idGenerator", strategy="uuid") 
    @GeneratedValue(generator="idGenerator")
    String id;
    String applicantId;
    String applicantName;
    @Enumerated(EnumType.STRING)
    PersonType applicantType;
    String approverId;
    String approverName;
    @Enumerated(EnumType.STRING)
    LeaveType leaveType;
    @Enumerated(EnumType.STRING)
    Status status;
    Date startTime;
    Date endTime;
    long duration;
    @Transient
    List<ApprovalInfoPO> historyApprovalInfoPOList;
}

2. 仓储模式

为了解耦业务逻辑和基础资源,我们可以在基础层和领域层之间增加一层仓储服务,实现依赖倒置。通过这一层可以实现业务逻辑和基础层资源的依赖分离。在变更基础层数据库的时候,你只要替换仓储实现就可以了,上层核心业务逻辑不会受基础资源变更的影响,从而实现依赖倒置。

一个聚合一个仓储,实现聚合数据的持久化。领域服务通过仓储接口来访问基础资源,由仓储实现完成数据持久化和初始化。仓储一般包含:仓储接口和仓储实现。

2.1 仓储接口

仓储接口面向领域服务提供接口。

public interface LeaveRepositoryInterface {
   void save(LeavePO leavePO);
   void saveEvent(LeaveEventPO leaveEventPO);
   LeavePO findById(String id);
   List<LeavePO> queryByApplicantId(String applicantId);
   List<LeavePO> queryByApproverId(String approverId);
}
2.2 仓储实现

仓储实现完成数据持久化和数据库查询。

@Repository
public class LeaveRepositoryImpl implements LeaveRepositoryInterface {

    @Autowired
    LeaveDao leaveDao;
    @Autowired
    ApprovalInfoDao approvalInfoDao;
    @Autowired
    LeaveEventDao leaveEventDao;

    public void save(LeavePO leavePO) { 
        leaveDao.save(leavePO);
        approvalInfoDao.saveAll(leavePO.getHistoryApprovalInfoPOList());
    }

    public void saveEvent(LeaveEventPO leaveEventPO){
        leaveEventDao.save(leaveEventPO);
    }

    @Override
    public LeavePO findById(String id) {
        return leaveDao.findById(id)
                .orElseThrow(() -> new RuntimeException("leave not found"));
    }

    @Override
    public List<LeavePO> queryByApplicantId(String applicantId) {
        List<LeavePO> leavePOList = leaveDao.queryByApplicantId(applicantId);
        leavePOList.stream()
                .forEach(leavePO -> {
                    List<ApprovalInfoPO> approvalInfoPOList = approvalInfoDao.queryByLeaveId(leavePO.getId());
                    leavePO.setHistoryApprovalInfoPOList(approvalInfoPOList);
                });
        return leavePOList;
    }

    @Override
    public List<LeavePO> queryByApproverId(String approverId) {
        List<LeavePO> leavePOList = leaveDao.queryByApproverId(approverId);
        leavePOList.stream()
                .forEach(leavePO -> {
                    List<ApprovalInfoPO> approvalInfoPOList = approvalInfoDao.queryByLeaveId(leavePO.getId());
                    leavePO.setHistoryApprovalInfoPOList(approvalInfoPOList);
                });
        return leavePOList;
    }
}

这里持久化组件采用了 Jpa。

public interface LeaveDao extends JpaRepository<LeavePO, String> {
    List<LeavePO> queryByApplicantId(String applicantId);
    List<LeavePO> queryByApproverId(String approverId);
}
2.3 仓储执行逻辑

以创建请假单为例,仓储的执行步骤如下。

第一步:仓储执行之前将聚合内 DO 会转换为 PO,这种转换在工厂服务中完成:

leaveFactory.createLeavePO(leave)。

第二步:完成对象转换后,领域服务调用仓储接口:

leaveRepositoryInterface.save。

第三步:由仓储实现完成 PO 对象持久化。

代码执行步骤如下:

public void createLeave(Leave leave, int leaderMaxLevel, Approver approver) {
  leave.setLeaderMaxLevel(leaderMaxLevel);
  leave.setApprover(approver);
  leave.create();
  leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave));
}

工厂模式

对于大型的复杂领域模型,聚合内的聚合根、实体和值对象之间的依赖关系比较复杂,这种过于复杂的依赖关系,不适合通过根实体构造器来创建。为了协调这种复杂的领域对象的创建和生命周期管理,在 DDD 里引入了工厂模式(Factory),在工厂里封装复杂的对象创建过程。

当聚合根被创建时,聚合内所有依赖的对象将会被同时创建。

工厂与仓储模式往往结对出现,应用于数据的初始化和持久化两类场景。

  • DO 对象的初始化:获取持久化对象 PO,通过工厂一次构建出聚合根所有依赖的 DO 对象,完数据初始化。
  • DO 的对象持久化:将所有依赖的 DO 对象一次转换为 PO 对象,完成数据持久化。

下面代码是 leave 聚合的工厂类 LeaveFactory。其中 createLeavePO(leave)方法组织 leave 聚合的 DO 对象和值对象完成 leavePO 对象的构建。getLeave(leave)通过持久化对象 PO 构建聚合的 DO 对象和值对象,完成 leave 聚合 DO 实体的初始化。

public class LeaveFactory {
   
   public LeavePO createLeavePO(Leave leave) {
   LeavePO leavePO = new LeavePO();
   leavePO.setId(UUID.randomUUID().toString());
   leavePO.setApplicantId(leave.getApplicant().getPersonId());
   leavePO.setApplicantName(leave.getApplicant().getPersonName());
   leavePO.setApproverId(leave.getApprover().getPersonId());
   leavePO.setApproverName(leave.getApprover().getPersonName());
   leavePO.setStartTime(leave.getStartTime());
   leavePO.setStatus(leave.getStatus());
   List<ApprovalInfoPO> historyApprovalInfoPOList = approvalInfoPOListFromDO(leave);
   leavePO.setHistoryApprovalInfoPOList(historyApprovalInfoPOList);
   return leavePO;
}


   public Leave getLeave(LeavePO leavePO) {
   Leave leave = new Leave();
   Applicant applicant = Applicant.builder()
       .personId(leavePO.getApplicantId())
       .personName(leavePO.getApplicantName())
       .build();
   leave.setApplicant(applicant);
   Approver approver = Approver.builder()
       .personId(leavePO.getApproverId())
       .personName(leavePO.getApproverName())
       .build();
   leave.setApprover(approver);
   leave.setStartTime(leavePO.getStartTime());
   leave.setStatus(leavePO.getStatus());
   List<ApprovalInfo> approvalInfos = getApprovalInfos(leavePO.getHistoryApprovalInfoPOList());
   leave.setHistoryApprovalInfos(approvalInfos);
   return leave;
   }


//其它方法
}

服务的组合与编排

应用层的应用服务完成领域服务的组合与编排。一个聚合的应用服务可以建立一个应用服务类,管理聚合所有的应用服务。比如 leave 聚合有 LeaveApplicationService,person 聚合有 PersonApplicationService。

在请假微服务中,有三个聚合:leave、person 和 rule。我们来看一下应用服务是如何跨聚合来进行服务的组合和编排的。以创建请假单 createLeaveInfo 应用服务为例,分为这样三个步骤。

第一步:根据请假单定义的人员类型、请假类型和请假时长从 rule 聚合中获取请假审批规则。这一步通过 approvalRuleDomainService 类的 getLeaderMaxLevel 领域服务来实现。

第二步:根据请假审批规则,从 person 聚合中获取请假审批人。这一步通过 personDomainService 类的 findFirstApprover 领域服务来实现。

第三步:根据请假数据和从 rule 和 person 聚合获取的数据,创建请假单。这一步通过 leaveDomainService 类的 createLeave 领域服务来实现。

由于领域核心逻辑已经很好地沉淀到了领域层中,领域层的这些核心逻辑可以高度复用。应用服务只需要灵活地组合和编排这些不同聚合的领域服务,就可以很容易地适配前端业务的变化。因此应用层不会积累太多的业务逻辑代码,所以会变得很薄,代码维护起来也会容易得多。

以下是 leave 聚合的应用服务类。

public class LeaveApplicationService{

    @Autowired
    LeaveDomainService leaveDomainService;
    @Autowired
    PersonDomainService personDomainService;
    @Autowired
    ApprovalRuleDomainService approvalRuleDomainService;
    
    public void createLeaveInfo(Leave leave){
    //get approval leader max level by rule
    int leaderMaxLevel = approvalRuleDomainService.getLeaderMaxLevel(leave.getApplicant().getPersonType(), leave.getType().toString(), leave.getDuration());
    //find next approver
    Person approver = personDomainService.findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);
    leaveDomainService.createLeave(leave, leaderMaxLevel, Approver.fromPerson(approver));
    }

    public void updateLeaveInfo(Leave leave){
    leaveDomainService.updateLeaveInfo(leave);
    }

    public void submitApproval(Leave leave){
    //find next approver
    Person approver = personDomainService.findNextApprover(leave.getApprover().getPersonId(), leave.getLeaderMaxLevel());
    leaveDomainService.submitApproval(leave, Approver.fromPerson(approver));
    }
    
    public Leave getLeaveInfo(String leaveId){
        return leaveDomainService.getLeaveInfo(leaveId);
    }

    public List<Leave> queryLeaveInfosByApplicant(String applicantId){
    return leaveDomainService.queryLeaveInfosByApplicant(applicantId);
    }

    public List<Leave> queryLeaveInfosByApprover(String approverId){
    return leaveDomainService.queryLeaveInfosByApprover(approverId);
    }
}

应用服务开发注意事项:

为了聚合解耦和微服务架构演进,应用服务在对不同聚合领域服务进行编排时,应避免不同聚合的实体对象,在不同聚合的领域服务中引用,这是因为一旦聚合拆分和重组,这些跨聚合的对象将会失效。

在 LeaveApplicationService 中,leave 实体和 Applicant 值对象分别作为参数被 rule 聚合和 person 聚合的领域服务引用,这样会增加聚合的耦合度。下面是不推荐使用的代码。

public class LeaveApplicationService{

  public void createLeaveInfo(Leave leave){
  //get approval leader max level by rule
  ApprovalRule rule = approvalRuleDomainService.getLeaveApprovalRule(leave);
  int leaderMaxLevel = approvalRuleDomainService.getLeaderMaxLevel(rule);
  leave.setLeaderMaxLevel(leaderMaxLevel);
  //find next approver
  Approver approver = personDomainService.findFirstApprover(leave.getApplicant(), leaderMaxLevel);
  leave.setApprover(approver);
  leaveDomainService.createLeave(leave);
  }
}

那如何实现聚合的解耦呢?我们可以将跨聚合调用时的对象传值调整为参数传值。一起来看一下调整后的代码,getLeaderMaxLevel 由 leave 对象传值调整为 personType,leaveType 和 duration 参数传值。findFirstApprover 中 Applicant 值对象调整为 personId 参数传值。

public class LeaveApplicationService{

  public void createLeaveInfo(Leave leave){
  //get approval leader max level by rule
  int leaderMaxLevel = approvalRuleDomainService.getLeaderMaxLevel(leave.getApplicant().getPersonType(), leave.getType().toString(), leave.getDuration());
  //find next approver
  Person approver = personDomainService.findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);
  leaveDomainService.createLeave(leave, leaderMaxLevel, Approver.fromPerson(approver));
  }
}

在微服务演进和聚合重组时,就不需要进行聚合解耦和代码重构了。

微服务聚合拆分时的代码演进

如果请假微服务未来需要演进为人员和请假两个微服务,我们可以基于请假 leave 和人员 person 两个聚合来进行拆分。由于两个聚合已经完全解耦,领域逻辑非常稳定,在微服务聚合代码拆分时,聚合领域层的代码基本不需要调整。调整主要集中在微服务的应用服务中。

我们以应用服务 createLeaveInfo 为例,当一个微服务拆分为两个微服务时,看看代码需要做什么样的调整?

1. 微服务拆分前

createLeaveInfo 应用服务的代码如下:

public void createLeaveInfo(Leave leave){
    
    //get approval leader max level by rule
    int leaderMaxLevel = approvalRuleDomainService.getLeaderMaxLevel(leave.getApplicant().getPersonType(), leave.getType().toString(), leave.getDuration());
    //find next approver
    Person approver = personDomainService.findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);
    leaveDomainService.createLeave(leave, leaderMaxLevel, Approver.fromPerson(approver));
}

2. 微服务拆分后

leave 和 person 两个聚合随微服务拆分后,createLeaveInfo 应用服务中下面的代码将会变成跨微服务调用。

Person approver = personDomainService.findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);

由于跨微服务的调用是在应用层完成的,我们只需要调整 createLeaveInfo 应用服务代码,将原来微服务内的服务调用 personDomainService.findFirstApprover 修改为跨微服务的服务调用:personFeignService. findFirstApprover。

同时新增 ApproverAssembler 组装器和 PersonResponse 的 DTO 对象,以便将 person 微服务返回的 person DTO 对象转换为 approver 值对象。

// PersonResponse为调用微服务返回结果的封装
//通过personFeignService调用Person微服务用户接口层的findFirstApprover facade接口
PersonResponse approverResponse = personFeignService. findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);
Approver approver = ApproverAssembler.toDO(approverResponse);

在原来的 person 聚合中,由于 findFirstApprover 领域服务已经逐层封装为用户接口层的 Facade 接口,所以 person 微服务不需要做任何代码调整,只需将 PersonApi 的 findFirstApprover Facade 服务,发布到 API 网关即可。

如果拆分前 person 聚合的 findFirstApprover 领域服务,没有被封装为 Facade 接口,我们只需要在 person 微服务中按照以下步骤调整即可。

第一步:将 person 聚合 PersonDomainService 类中的领域服务 findFirstApprover 封装为应用服务 findFirstApprover。

@Service
public class PersonApplicationService {

  @Autowired
  PersonDomainService personDomainService;
  
  public Person findFirstApprover(String applicantId, int leaderMaxLevel) {
  return personDomainService.findFirstApprover(applicantId, leaderMaxLevel);
  }
}

第二步:将应用服务封装为 Facade 服务,并发布到 API 网关。

@RestController
@RequestMapping("/person")
@Slf4j
public class PersonApi {
  
  @Autowired
  @GetMapping("/findFirstApprover")
  public Response findFirstApprover(@RequestParam String applicantId, @RequestParam int leaderMaxLevel) {
  Person person = personApplicationService.findFirstApprover(applicantId, leaderMaxLevel);
          return Response.ok(PersonAssembler.toDTO(person));
  }
}

服务接口的提供

用户接口层是前端应用与微服务应用层的桥梁,通过 Facade 接口封装应用服务,适配前端并提供灵活的服务,完成 DO 和 DTO 相互转换。

当应用服务接收到前端请求数据时,组装器会将 DTO 转换为 DO。当应用服务向前端返回数据时,组装器会将 DO 转换为 DTO。

1. facade 接口

facade 接口可以是一个门面接口实现类,也可以是门面接口加一个门面接口实现类。项目可以根据前端的复杂度进行选择,由于请假微服务前端功能相对简单,我们就直接用一个门面接口实现类来实现就可以了。

public class LeaveApi {   
  @PostMapping
  public Response createLeaveInfo(LeaveDTO leaveDTO){
          Leave leave = LeaveAssembler.toDO(leaveDTO);
          leaveApplicationService.createLeaveInfo(leave);
          return Response.ok();
  }
  
  @PostMapping("/query/applicant/{applicantId}")
  public Response queryByApplicant(@PathVariable String applicantId){
  List<Leave> leaveList = leaveApplicationService.queryLeaveInfosByApplicant(applicantId);
  List<LeaveDTO> leaveDTOList = leaveList.stream().map(leave -> LeaveAssembler.toDTO(leave)).collect(Collectors.toList());
          return Response.ok(leaveDTOList);
  }

//其它方法
}

2. DTO 数据组装

组装类(Assembler):负责将应用服务返回的多个 DO 对象组装为前端 DTO 对象,或将前端请求的 DTO 对象转换为多个 DO 对象,供应用服务作为参数使用。组装类中不应有业务逻辑,主要负责格式转换、字段映射等。Assembler 往往与 DTO 同时存在。LeaveAssembler 完成请假 DO 和 DTO 数据相互转换。

public class LeaveAssembler {

    public static LeaveDTO toDTO(Leave leave){
        LeaveDTO dto = new LeaveDTO();
        dto.setLeaveId(leave.getId());
        dto.setLeaveType(leave.getType().toString());
        dto.setStatus(leave.getStatus().toString());
        dto.setStartTime(DateUtil.formatDateTime(leave.getStartTime()));
        dto.setEndTime(DateUtil.formatDateTime(leave.getEndTime()));
        dto.setCurrentApprovalInfoDTO(ApprovalInfoAssembler.toDTO(leave.getCurrentApprovalInfo()));
        List<ApprovalInfoDTO> historyApprovalInfoDTOList = leave.getHistoryApprovalInfos()
                .stream()
                .map(historyApprovalInfo -> ApprovalInfoAssembler.toDTO(leave.getCurrentApprovalInfo()))
                .collect(Collectors.toList());
        dto.setHistoryApprovalInfoDTOList(historyApprovalInfoDTOList);
        dto.setDuration(leave.getDuration());
        return dto;
    }

    public static Leave toDO(LeaveDTO dto){
        Leave leave = new Leave();
        leave.setId(dto.getLeaveId());
        leave.setApplicant(ApplicantAssembler.toDO(dto.getApplicantDTO()));
        leave.setApprover(ApproverAssembler.toDO(dto.getApproverDTO()));
        leave.setCurrentApprovalInfo(ApprovalInfoAssembler.toDO(dto.getCurrentApprovalInfoDTO()));
        List<ApprovalInfo> historyApprovalInfoDTOList = dto.getHistoryApprovalInfoDTOList()
                .stream()
                .map(historyApprovalInfoDTO -> ApprovalInfoAssembler.toDO(historyApprovalInfoDTO))
                .collect(Collectors.toList());
        leave.setHistoryApprovalInfos(historyApprovalInfoDTOList);
        return leave;
    }
}

DTO 类:包括 requestDTO 和 responseDTO 两部分。

DTO 应尽量根据前端展示数据的需求来定义,避免过多地暴露后端业务逻辑。尤其对于多渠道场景,可以根据渠道属性和要求,为每个渠道前端应用定义个性化的 DTO。由于请假微服务相对简单,我们可以用 leaveDTO 代码做个示例。

@Data
public class LeaveDTO {
    String leaveId;
    ApplicantDTO applicantDTO;
    ApproverDTO approverDTO;
    String leaveType;
    ApprovalInfoDTO currentApprovalInfoDTO;
    List<ApprovalInfoDTO> historyApprovalInfoDTOList;
    String startTime;
    String endTime;
    long duration;
    String status;
}

总结

聚合与聚合的解耦:当多个聚合在同一个微服务时,很多传统架构开发人员会下意识地引用其他聚合的实体和值对象,或者调用其它聚合的领域服务。因为这些聚合的代码在同一个微服务内,运行时不会有问题,开发效率似乎也更高,但这样会不自觉地增加聚合之间的耦合。在微服务架构演进时,如果聚合被分别拆分到不同的微服务中,原来微服务内的关系就会变成跨微服务的关系,原来微服务内的对象引用或服务调用将会失效。最终你还是免不了要花大量的精力去做聚合解耦。虽然前期领域建模和边界划分得很好,但可能会因为开发稍不注意,而导致解耦工作前功尽弃。

微服务内各层的解耦:微服务内有四层,在应用层和领域层组成核心业务领域的两端,有两个缓冲区或数据转换区。前端与应用层通过组装器实现 DTO 和 DO 的转换,这种适配方式可以更容易地响应前端需求的变化,隐藏核心业务逻辑的实现,保证核心业务逻辑的稳定,实现核心业务逻辑与前端应用的解耦。而领域层与基础层通过仓储和工厂模式实现 DO 和 PO 的转换,实现应用逻辑与基础资源逻辑的解耦。

总结(一):微服务设计和拆分要坚持哪些原则?

微服务的演进策略

在从单体向微服务演进时,演进策略大体分为两种:绞杀者策略和修缮者策略。

1. 绞杀者策略

绞杀者策略是一种逐步剥离业务能力,用微服务逐步替代原有单体系统的策略。它对单体系统进行领域建模,根据领域边界,在单体系统之外,将新功能和部分业务能力独立出来,建设独立的微服务。新微服务与单体系统保持松耦合关系。

随着时间的推移,大部分单体系统的功能将被独立为微服务,这样就慢慢绞杀掉了原来的单体系统。绞杀者策略类似建筑拆迁,完成部分新建筑物后,然后拆除部分旧建筑物。

2. 修缮者策略

修缮者策略是一种维持原有系统整体能力不变,逐步优化系统整体能力的策略。它是在现有系统的基础上,剥离影响整体业务的部分功能,独立为微服务,比如高性能要求的功能,代码质量不高或者版本发布频率不一致的功能等。

通过这些功能的剥离,我们就可以兼顾整体和局部,解决系统整体不协调的问题。修缮者策略类似古建筑修复,将存在问题的部分功能重建或者修复后,重新加入到原有的建筑中,保持建筑原貌和功能不变。一般人从外表感觉不到这个变化,但是建筑物质量却得到了很大的提升。

其实还有第三种策略,就是另起炉灶,顾名思义就是将原有的系统推倒重做。建设期间,原有单体系统照常运行,一般会停止开发新需求。而新系统则会组织新的项目团队,按照原有系统的功能域,重新做领域建模,开发新的微服务。在完成数据迁移后,进行新旧系统切换。

不同场景下的领域建模策略

1. 新建系统

新建系统又分为简单和复杂领域建模两种场景。

简单领域建模

简单的业务领域,一个领域就是一个小的子域。在这个小的问题域内,领域建模过程相对简单,直接采用事件风暴的方法构建领域模型就可以了。

复杂领域建模

对于复杂的业务领域,领域可能需要多级拆分后才能开始领域建模。领域拆分为子域,甚至子域还需要进一步拆分。

对于复杂领域,我们可以分三步来完成领域建模和微服务设计。

第一步,拆分子域建立领域模型

第二步,领域模型微调

第三步,微服务的设计和拆分

2. 单体遗留系统

如果我们面对的是一个单体遗留系统,只需要将部分功能独立为微服务,而其余仍为单体,整体保持不变,比如将面临性能瓶颈的模块拆分为微服务。我们只需要将这一特定功能,理解为一个简单子领域,参考简单领域建模的方式就可以了。在微服务设计中,我们还要考虑新老系统之间服务和业务的兼容,必要时可引入防腐层。

DDD 使用的误区

很多人在接触微服务后,但凡是系统,一概都想设计成微服务架构。其实有些业务场景,单体架构的开发成本会更低,开发效率更高,采用单体架构也不失为好的选择。同样,虽然 DDD 很好,但有些传统设计方法在微服务设计时依然有它的用武之地。下面我们就来聊聊 DDD 使用的几个误区。

1. 所有的领域都用 DDD

很多人在学会 DDD 后,可能会将其用在所有业务域,即全部使用 DDD 来设计。DDD 从战略设计到战术设计,是一个相对复杂的过程,首先企业内要培养 DDD 的文化,其次对团队成员的设计和技术能力要求相对比较高。在资源有限的情况下,应聚焦核心域,建议你先从富领域模型的核心域开始,而不必一下就在全业务域推开。

2. 全部采用 DDD 战术设计方法

不同的设计方法有它的适用环境,我们应选择它最擅长的场景。DDD 有很多的概念和战术设计方法,比如聚合根和值对象等。聚合根利用仓储管理聚合内实体数据之间的一致性,这种方法对于管理新建和修改数据非常有效,比如在修改订单数据时,它可以保证订单总金额与所有商品明细金额的一致,但它并不擅长较大数据量的查询处理,甚至有延迟加载进而影响效率的问题。

而传统的设计方法,可能一条简单的 SQL 语句就可以很快地解决问题。而很多贫领域模型的业务,比如数据统计和分析,DDD 很多方法可能都用不上,或用得并不顺手,而传统的方法很容易就解决了。

因此,在遵守领域边界和微服务分层等大原则下,在进行战术层面设计时,我们应该选择最适合的方法,不只是 DDD 设计方法,当然还应该包括传统的设计方法。这里要以快速、高效解决实际问题为最佳,不要为做 DDD 而做 DDD。

3. 重战术设计而轻战略设计

战略设计时构建的领域模型,是微服务设计和开发的输入,它确定了微服务的边界、聚合、代码对象以及服务等关键领域对象。领域模型边界划分得清不清晰,领域对象定义得明不明确,会决定微服务的设计和开发质量。没有领域模型的输入,基于 DDD 的微服务的设计和开发将无从谈起。因此我们不仅要重视战术设计,更要重视战略设计。

4. DDD 只适用于微服务

DDD 是在微服务出现后才真正火爆起来的,很多人会认为 DDD 只适用于微服务。在 DDD 沉默的二十多年里,其实它一直也被应用在单体应用的设计中。

具体项目实施时,要吸取 DDD 的核心设计思想和理念,结合具体的业务场景和团队技术特点,多种方法组合,灵活运用,用正确的方式解决实际问题。

微服务设计原则

第一条:要领域驱动设计,而不是数据驱动设计,也不是界面驱动设计。

微服务设计首先应建立领域模型,确定逻辑和物理边界以及领域对象后,然后才开始微服务的拆分和设计。而不是先定义数据模型和库表结构,也不是前端界面需要什么,就去调整核心领域逻辑代码。在设计时应该将外部需求从外到内逐级消化,尽量降低对核心领域层逻辑的影响。

第二条:要边界清晰的微服务,而不是泥球小单体。

微服务上线后其功能和代码也不是一成不变的。随着需求或设计变化,领域模型会迭代,微服务的代码也会分分合合。边界清晰的微服务,可快速实现微服务代码的重组。微服务内聚合之间的领域服务和数据库实体原则上应杜绝相互依赖。你可通过应用服务编排或者事件驱动,实现聚合之间的解耦,以便微服务的架构演进。

第三条:要职能清晰的分层,而不是什么都放的大箩筐。

分层架构中各层职能定位清晰,且都只能与其下方的层发生依赖,也就是说只能从外层调用内层服务,内层通过封装、组合或编排对外逐层暴露,服务粒度也由细到粗。应用层负责服务的组合和编排,不应有太多的核心业务逻辑,领域层负责核心领域业务逻辑的实现。各层应各司其职,职责边界不要混乱。在服务演进时,应尽量将可复用的能力向下层沉淀。

第四条:要做自己能 hold 住的微服务,而不是过度拆分的微服务。

微服务过度拆分必然会带来软件维护成本的上升,比如:集成成本、运维成本、监控和定位问题的成本。企业在微服务转型过程中还需要有云计算、DevOps、自动化监控等能力,而一般企业很难在短时间内提升这些能力,如果项目团队没有这些能力,将很难 hold 住这些微服务。

微服务拆分需要考虑哪些因素?

1. 基于领域模型

基于领域模型进行拆分,围绕业务领域按职责单一性、功能完整性拆分。

2. 基于业务需求变化频率

识别领域模型中的业务需求变动频繁的功能,考虑业务变更频率与相关度,将业务需求变动较高和功能相对稳定的业务进行分离。这是因为需求的经常性变动必然会导致代码的频繁修改和版本发布,这种分离可以有效降低频繁变动的敏态业务对稳态业务的影响。

3. 基于应用性能

识别领域模型中性能压力较大的功能。因为性能要求高的功能可能会拖累其它功能,在资源要求上也会有区别,为了避免对整体性能和资源的影响,我们可以把在性能方面有较高要求的功能拆分出去。

4. 基于组织架构和团队规模

除非有意识地优化组织架构,否则微服务的拆分应尽量避免带来团队和组织架构的调整,避免由于功能的重新划分,而增加大量且不必要的团队之间的沟通成本。拆分后的微服务项目团队规模保持在 10~12 人左右为宜。

5. 基于安全边界

有特殊安全要求的功能,应从领域模型中拆分独立,避免相互影响。

6. 基于技术异构等因素

领域模型中有些功能虽然在同一个业务域内,但在技术实现时可能会存在较大的差异,也就是说领域模型内部不同的功能存在技术异构的问题。由于业务场景或者技术条件的限制,有的可能用.NET,有的则是 Java,有的甚至大数据架构。对于这些存在技术异构的功能,可以考虑按照技术边界进行拆分。

总结(二):分布式架构关键设计10问

前面我们重点讲述了领域建模、微服务设计和前端设计方法,它们组合在一起就可以形成中台建设的整体解决方案。而中台大多基于分布式微服务架构,这种企业级的数字化转型有很多地方值得我们关注和思考。

我们不仅要关注企业商业模式、业务边界以及前中台的融合,还要关注数据技术体系、微服务设计、多活等多领域的设计和协同。结合实施经验和思考,今天我们就来聊聊分布式架构下的几个关键问题。

一、选择什么样的分布式数据库?

分布式架构下的数据应用场景远比集中式架构复杂,会产生很多数据相关的问题。谈到数据,首先就是要选择合适的分布式数据库。分布式数据库大多采用数据多副本的方式,实现数据访问的高性能、多活和容灾。

目前主要有三种不同的分布式数据库解决方案。它们的主要差异是数据多副本的处理方式和数据库中间件。

1. 一体化分布式数据库方案

它支持数据多副本、高可用。多采用 Paxos 协议,一次写入多数据副本,多数副本写入成功即算成功。代表产品是 OceanBase 和高斯数据库。

2. 集中式数据库 + 数据库中间件方案

它是集中式数据库与数据库中间件结合的方案,通过数据库中间件实现数据路由和全局数据管理。数据库中间件和数据库独立部署,采用数据库自身的同步机制实现主副本数据的一致性。集中式数据库主要有 MySQL 和 PostgreSQL 数据库,基于这两种数据库衍生出了很多的解决方案,比如开源数据库中间件 MyCat+MySQL 方案,TBase(基于 PostgreSQL,但做了比较大的封装和改动)等方案。

3. 集中式数据库 + 分库类库方案

它是一种轻量级的数据库中间件方案,分库类库实际上是一个基础 JAR 包,与应用软件部署在一起,实现数据路由和数据归集。它适合比较简单的读写交易场景,在强一致性和聚合分析查询方面相对较弱。典型分库基础组件有 ShardingSphere。

小结:这三种方案实施成本不一样,业务支持能力差异也比较大。一体化分布式数据库主要由互联网大厂开发,具有超强的数据处理能力,大多需要云计算底座,实施成本和技术能力要求比较高。集中式数据库 + 数据库中间件方案,实施成本和技术能力要求适中,可满足中大型企业业务要求。第三种分库类库的方案可处理简单的业务场景,成本和技能要求相对较低。在选择数据库的时候,我们要考虑自身能力、成本以及业务需要,从而选择合适的方案。

二、如何设计数据库分库主键?

与客户接触的关键业务,我建议你以客户 ID 作为分库主键。这样可以确保同一个客户的数据分布在同一个数据单元内,避免出现跨数据单元的频繁数据访问。跨数据中心的频繁服务调用或跨数据单元的查询,会对系统性能造成致命的影响。

三、数据库的数据同步和复制

在微服务架构中,数据被进一步分割。为了实现数据的整合,数据库之间批量数据同步与复制是必不可少的。数据同步与复制主要用于数据库之间的数据同步,实现业务数据迁移、数据备份、不同渠道核心业务数据向数据平台或数据中台的数据复制、以及不同主题数据的整合等。

传统的数据传输方式有 ETL 工具和定时提数程序,但数据在时效性方面存在短板。分布式架构一般采用基于数据库逻辑日志增量数据捕获(CDC)技术,它可以实现准实时的数据复制和传输,实现数据处理与应用逻辑解耦,使用起来更加简单便捷。

四、跨库关联查询如何处理?

跨库关联查询是分布式数据库的一个短板,会影响查询性能。在领域建模时,很多实体会分散到不同的微服务中,但很多时候会因为业务需求,它们之间需要关联查询。

关联查询的业务场景包括两类:第一类是基于某一维度或某一主题域的数据查询,比如基于客户全业务视图的数据查询,这种查询会跨多个业务线的微服务;第二类是表与表之间的关联查询,比如机构表与业务表的联表查询,但机构表和业务表分散在不同的微服务。

如何解决这两类关联查询呢?

对于第一类场景,由于数据分散在不同微服务里,我们无法跨多个微服务来统计这些数据。你可以建立面向主题的分布式数据库,它的数据来源于不同业务的微服务。采用数据库日志捕获技术,从各业务端微服务将数据准实时汇集到主题数据库。在数据汇集时,提前做好数据关联(如将多表数据合并为一个宽表)或者建立数据模型。面向主题数据库建设查询微服务。这样一次查询你就可以获取客户所有维度的业务数据了。你还可以根据主题或场景设计合适的分库主键,提高查询效率。

对于第二类场景,对于不在同一个数据库的表与表之间的关联查询场景,你可以采用小表广播,在业务库中增加一张冗余的代码副表。当主表数据发生变化时,你可以通过消息发布和订阅的领域事件驱动模式,异步刷新所有副表数据。这样既可以解决表与表的关联查询,还可以提高数据的查询效率。

五、如何处理高频热点数据?

常见的做法是将这些高频热点数据,从数据库加载到如 Redis 等缓存中,通过缓存提供数据访问服务。这样既可以降低数据库的压力,还可以提高数据的访问性能。

另外,对需要模糊查询的高频数据,你也可以选用 ElasticSearch 等搜索引擎。

六、前后序业务数据的处理

在微服务设计时你会经常发现,某些数据需要关联前序微服务的数据。比如:在保险业务中,投保微服务生成投保单后,保单会关联前序投保单数据等。在电商业务中,货物运输单会关联前序订单数据。由于关联的数据分散在业务的前序微服务中,你无法通过不同微服务的数据库来给它们建立数据关联。

如何解决这种前后序的实体关联呢?

一般来说,前后序的数据都跟领域事件有关。你可以通过领域事件处理机制,按需将前序数据通过领域事件实体,传输并冗余到当前的微服务数据库中。

你可以将前序数据设计为实体或者值对象,并被当前实体引用。在设计时你需要关注以下内容:如果前序数据在当前微服务只可整体修改,并且不会对它做查询和统计分析,你可以将它设计为值对象;当前序数据是多条,并且需要做查询和统计分析,你可以将它设计为实体。

这样,你可以在货物运输微服务,一次获取前序订单的清单数据和货物运输单数据,将所有数据一次反馈给前端应用,降低跨微服务的调用。如果前序数据被设计为实体,你还可以将前序数据作为查询条件,在本地微服务完成多维度的综合数据查询。只有必要时才从前序微服务,获取前序实体的明细数据。这样,既可以保证数据的完整性,还可以降低微服务的依赖,减少跨微服务调用,提升系统性能。

七、数据中台与企业级数据集成

分布式微服务架构虽然提升了应用弹性和高可用能力,但原来集中的数据会随着微服务拆分而形成很多数据孤岛,增加数据集成和企业级数据使用的难度。你可以通过数据中台来实现数据融合,解决分布式架构下的数据应用和集成问题。

你可以分三步来建设数据中台。

第一,按照统一数据标准,完成不同微服务和渠道业务数据的汇集和存储,解决数据孤岛和初级数据共享的问题。

第二,建立主题数据模型,按照不同主题和场景对数据进行加工处理,建立面向不同主题的数据视图,比如客户统一视图、代理人视图和渠道视图等。

第三,建立业务需求驱动的数据体系,支持业务和商业模式创新。

数据中台不仅限于分析场景,也适用于交易型场景。你可以建立在数据仓库和数据平台上,将数据平台化之后提供给前台业务使用,为交易场景提供支持。

八、BFF 与企业级业务编排和协同

企业级业务流程往往是多个微服务一起协作完成的,每个单一职责的微服务就像积木块,它们只完成自己特定的功能。那如何组织这些微服务,完成企业级业务编排和协同呢?

你可以在微服务和前端应用之间,增加一层 BFF 微服务(Backend for Frontends)。BFF 主要职责是处理微服务之间的服务组合和编排,微服务内的应用服务也是处理服务的组合和编排,那这二者有什么差异呢?

BFF 位于中台微服务之上,主要职责是微服务之间的服务协调;应用服务主要处理微服务内的服务组合和编排。在设计时我们应尽可能地将可复用的服务能力往下层沉淀,在实现能力复用的同时,还可以避免跨中心的服务调用。

BFF 像齿轮一样,来适配前端应用与微服务之间的步调。它通过 Façade 服务适配不同的前端,通过服务组合和编排,组织和协调微服务。BFF 微服务可根据需求和流程变化,与前端应用版本协同发布,避免中台微服务为适配前端需求的变化,而频繁地修改和发布版本,从而保证微服务核心领域逻辑的稳定。

九、分布式事务还是事件驱动机制?

分布式架构下,原来单体的内部调用,会变成分布式调用。如果一个操作涉及多个微服务的数据修改,就会产生数据一致性的问题。数据一致性有强一致性和最终一致性两种,它们实现方案不一样,实施代价也不一样。

对于实时性要求高的强一致性业务场景,你可以采用分布式事务,但分布式事务有性能代价,在设计时我们需平衡考虑业务拆分、数据一致性、性能和实现的复杂度,尽量避免分布式事务的产生。

领域事件驱动的异步方式是分布式架构常用的设计方法,它可以解决非实时场景的数据最终一致性问题。基于消息中间件的领域事件发布和订阅,可以很好地解耦微服务。通过削峰填谷,可以减轻数据库实时访问压力,提高业务吞吐量和处理能力。你还可以通过事件驱动实现读写分离,提高数据库访问性能。对最终一致性的场景,我建议你采用领域事件驱动的设计方法。

十、多中心多活的设计

分布式架构的高可用主要通过多活设计来实现,多中心多活是一个非常复杂的工程,下面我主要列出以下几个关键的设计。

  • 选择合适的分布式数据库。数据库应该支持多数据中心部署,满足数据多副本以及数据底层复制和同步技术要求,以及数据恢复的时效性要求。
  • 单元化架构设计。将若干个应用组成的业务单元作为部署的基本单位,实现同城和异地多活部署,以及跨中心弹性扩容。各单元业务功能自包含,所有业务流程都可在本单元完成;任意单元的数据在多个数据中心有副本,不会因故障而造成数据丢失;任何单元故障不影响其它同类单元的正常运行。单元化设计时我们要尽量避免跨数据中心和单元的调用。
  • 访问路由。访问路由包括接入层、应用层和数据层的路由,确保前端访问能够按照路由准确到达数据中心和业务单元,准确写入或获取业务数据所在的数据库。
  • 全局配置数据管理。实现各数据中心全局配置数据的统一管理,每个数据中心全局配置数据实时同步,保证数据的一致性。

课程链接:

http://gk.link/a/10sUJ

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 (MingsonZheng@outlook.com) 。

posted @ 2021-06-19 19:22  MingsonZheng  阅读(488)  评论(0编辑  收藏  举报