万字长文助你上手软件领域驱动设计 DDD

2022-03-29 13:40:14 浏览数 (1)

作者:faryrong,腾讯 CSIG 后台开发工程师

最近看了一本书《解构-领域驱动设计》,书中提出了领域驱动设计统一过程(DDDRUP),它指明了实践 DDD 的具体步骤,并很好地串联了各种概念、模式和思想。因此,我对书本内容做了梳理、简化,融入自己的理解,并结合之前阅读的书籍以及实践经验,最终形成这篇文章。希望可以帮助大伙理顺 DDD 的各种概念、模式和思想,降低上手 DDD 的门槛。

1.背景

领域驱动设计(DDD)由 Eric Evans 提出,并一经《领域驱动设计:软件核心复杂性应对之道》的发布,在软件行业中引起了不少的轰动。DDD 提供的一种新颖的,甚至有点“另类”的思维方式,它在告诉软件开发者“我们要用业务方案来解决业务问题,而不是技术方案解决业务问题”,有点魔法打败魔法的意思。DDD 虽然让人眼前一亮,但是所提倡的理念有点“违背直觉”(对开发人员而言),因此,在当时并没有流行开来。

后来,微服务架构的兴起,大伙惊奇地发现 DDD 是作为划分“微服务边界”的一把利器,并且 DDD 提及的很多设计理念与微服务架构十分契合,因此 DDD 逐渐被开发者们接受并流行起来。毫不夸张地说,了解和学习 DDD 可以算得上是如今软件行业从业者的一门必修课了。

但是!DDD 的学习曲线较为陡峭。作为一个小白,翻阅过很多相关的书籍、KM 文章和分享,但始终觉得未得要领、一知半解。原因有二:a) DDD 涉及的概念繁多,且不同概念的抽象层次不一样,如果我们直白地去理解,往往会感到疑惑,比如:子域和限界上下文都是用于将问题进行归类和收敛,他们的区别是什么?b)缺少过程指导,难以将概念有序的串联起来。作为方法论,DDD 给出了设计思想,核心原则以及常用工具,但是却缺少细致有序的方法步骤,导致难以上手实践。

幸运的是,最近看了一本书《解构-领域驱动设计》。这本书提出了领域驱动设计统一过程(DDDRUP),它指明了实践 DDD 的具体步骤,并很好地串联了各种概念、模式和思想。因此,我对书本内容做了梳理、简化,融入自己的理解,并结合之前阅读的书籍以及实践经验,最终形成这篇文章。希望可以帮助大伙理顺 DDD 的各种概念、模式和思想,降低上手 DDD 的门槛。

2.DDD 概要与实践感悟

经典必读书籍《领域驱动设计:软件核心复杂性应对之道》的书名包含了两个关键词:领域驱动复杂性,分别代表了 DDD 的核心原则以及解决的问题。

2.1 复杂性

系统的复杂性往往并不在技术上,而是来自领域本身、用户的活动或业务服务。当这种领域复杂性在设计中没有得到解决时,基础技术的构思再好也是无济于事。而系统的复杂度体现在三个方面:规模结构变化

规模:指的是系统所支持的功能点,以及功能点与功能点之间的的关系。DDD 通过子领域,限界上下文,聚合等模式对问题进行拆分和归类,不断收窄问题域,保证聚合边界内所解决的问题集合足够收敛和可控。

结构:指的是系统架构。系统架构是否分层;若分层,每层划分的职责边界是否清晰;架构的基本管理单元是什么,它决定了架构演进时的复杂度。DDD 通过分层架构,独立出领域层,且架构中的每层都有清晰的职责。整体架构的基本管理单元是聚合,它是一个完整的、自治的管理单元,当需要进行服务拆分时,可以直接以聚合作为基本单元进行拆分。

变化:指的是系统响应需求变化的能力。快速响应变化的有效手段是分离不易变逻辑和易变逻辑,"以不变应万变"。而通过分层架构独立的领域层正是不易变的逻辑。领域层是对领域知识的封装,其提供的领域服务具有经验性和前瞻性,是对领域内稳定的领域规则的表达。而领域层以外的应用层和基础设施层则是易变逻辑的封装。保证核心的独立和稳定,通过在调整应用层和基础设施层来实现快速响应需求变化。

2.2 领域驱动

领域驱动指的是以领域作为解决问题切入点,面对业务需求,先提炼出领域概念,并构建领域模型来表达业务问题,而构建过程中我们应该尽可能避免牵扯技术方案或技术细节。而编码实现更像是对领域模型的代码翻译,代码(变量名、方法名、类名等)中要求能够表达领域概念,让人见码明义。

结合实践经验,以下是本人对“领域驱动”的一些见解:

思维模式转变

实践 DDD 以前,我最常使用的是数据驱动设计。它的核心思路针对业务需求进行数据建模:根据业务需求提炼出类,然后通过 ORM 把类映射为表结构,并根据读写性能要求使用范式优化表与表之间的关联关系。数据驱动是从技术的维度解决业务问题,得出的数据模型是对业务需求的直接翻译,并没有蕴含稳定的领域知识/规则。一旦需求发生变化,数据模型就得发生变化,对应的库表的设计也需要进行调整。这种设计思维导致变化从需求穿透到了数据层,中间并没有稳定的,不易变的层级进行阻隔,最终导致系统响应变化的能力很差。

协同方式转变

过去由产品同学提出业务需求,研发同学根据业务需求的 tapd 进行技术方案设计,并编程实现。

这种协同方式的弊端在于:无法形成能够消除认知差异的模型。产品同学从业务角度提出用户需求,这些需求可能是易变的、定制化的,而研发同学在缺少行业经验的情况下,往往会选择直译,即根据需求直接转换为数据模型。而研发同学从技术实现角度设计技术方案,其中涉及很多的技术细节,产品同学无法从中判断是否与自己提出的业务诉求和产品规划相一致,最终形成认知差异。且认知差异会随着迭代不断被放大,最后系统变成一个大泥球。

DDD 通过解锁新角色”领域专家"以及模型驱动设计,有效地降低产品和研发的认知差异。领域专家是具有丰富行业经验和领域知识储备的人,他们能够在易变的、定制化的需求中提炼出清晰的边界,稳定的、可复用的领域概念和业务规则,并携手产品和研发共同构建出领域模型。领域模型是对业务需求的知识表达形式,它不涉及具体的技术细节(但能够指导研发同学进行编程实现),因此消除了产品和研发在需求认知上的鸿沟。而模型驱动设计则要求领域模型能够关联业务需求和编码实现,模型的变更意味着需求变更和代码变更,协作围绕模型为中心。

精炼循环

精炼循环指的是在统一语言,提炼领域概念,明确边界,构建模型,绑定实现过程中,这些环节相互影响和反馈,在不断的迭代试错-调整以最终沉淀出稳定的、深层次的模型的过程。比如,我们在提炼领域概念的时候会觉得统一语言定义不合理/有歧义,此时我们就会调整统一语言的定义,并重新进行提炼领域概念。通过精炼循环,我们逐步形成稳定的领域模型。在 DDD 中,让领域专家来主导概念提炼、边界划分等宏观设计,原因就在于领域专家的经验和行业洞见来源于过去已经迭代的无数个精炼循环,因此由这些宏观设计推导出来的领域模型,往往都是非常稳定的。

精炼循环的核心是循环,它避免知识只朝单一方向流动,最终因各环节上的认知差异,最终导致模型无法在产品、领域专家和研发中达成一致、模型与实现割裂。

2.3 怎么才算 DDD?

我早期实践 DDD 的时候,认为代码分层遵循四层架构就是 DDD,抑或分离接口和实现,实现下沉至基础设施层就是 DDD,实则不然。结合上述内容,目前个人认为只要满足以下条件即为实践 DDD:

  • 构建出产品、领域专家和研发同学认知一致且便于交流的模型,并且模型与实现紧密绑定;
  • 模型逐步演进,反复消化和精炼;
  • 模型蕴含领域知识,足够稳定。

3.问题空间&解空间

3.1 问题空间&解空间

问题空间和解空间并非 DDD 特有的概念,而是人们为了区分真实世界和理念世界而提出的概念。问题空间表示的是真实世界,是具体的问题、用户的诉求,而解空间则是针对问题空间求解后构建的理念世界,其中包括了解决方案、模型等。

DDD 提出的战略设计覆盖了问题空间和解空间,而战术设计则聚焦在解空间上。明确 DDD 中的概念是作用于问题空间还是解空间,更有助于我们理解它们。

3.2 示例-学生管理系统的问题空间

学生管理系统(Student Management System,下文简称 SMS)作为 DDDRUP 的讲解示例,以下为其问题空间的描述。

代码语言:javascript复制
学校需要构建一个学生管理系统(Student Management System, SMS)。

通过这个管理系统,学生可以进行选课,查询成绩,查询绩点。

而老师则可以通过这个系统录入授课课程的成绩。录入的分数会由系统自动换算为绩点,规则如下:若分数>= 90,绩点为4.0;90>= 分数> 80,绩点为3.0;80 >= 分数 > 70,绩点为2.0;70 >= 分数 >= 60,绩点为1.0;成绩< 60,则没有绩点,并邮件通知教务员,由教务员联系学生商榷重修事宜。

成绩录入后的一周内,若出现录入成绩错误的情况,老师可提交修改申请,由教务员审核后即可完成修改。审核完成后系统会通过邮件告知老师审核结果。一周后成绩将锁定,不予修改。成绩锁定后,次日系统会自动计算各年级、各班的学生的总绩点(总绩点由各门课程的学分与其绩点进行加权平均后所得)。

而教务员则可以通过该系统发布可以选修的课程。同时,教务员能够查看到各年级,各班的学生的总绩点排名。

4.领域驱动设计统一过程(DDDRUP)

虽然领域驱动设计划分了战略设计战术设计,也提供了诸多模式和工具,但却没有一个统一过程去规范这两个阶段需要执行的活动、交付的工件以及阶段里程碑,甚至没有清晰定义这两个阶段如何衔接、它们之间执行的工作流到底是怎么样的。

而《解构-领域驱动设计》提出的 DDDRUP 给出了更细致的步骤、步骤与步骤之间的衔接,以及明确的阶段里程碑,最重要的是 DDDRUP 可以串联 DDD 的所有概念和模式,非常便于初学者做知识梳理和上手实践。下文我会依照 DDDRUP 的步骤流程进行讲述,而非战略设计 战术设计的思路。(DDDRUP 各步骤与战略&战术设计的关系见下表)。

5.全局分析阶段

全局分析阶段对问题空间进行的梳理和分析,形成统一语言(ubiquitous language), 获取问题空间的价值需求以及业务需求

5.1 形成统一语言

统一语言:蕴含领域知识的、团队内统一的领域术语。产品、领域专家以及开发人员掌握的领域知识存在差异,往往导致对同一个事物使用不同的术语。比如,商品的价格(Price)和商品的金额(Amount),它们本质是同一个东西,但是却有不同的术语表示。

统一语言会参与 DDDRUP 的全流程,且会在精炼循环过程中不断进行调整,以反映出更合适、更深层次的领域知识。

根据业务需求形成统一语言,有助于团队对事物的认知达成一致。统一语言可以通过词汇表的形式展示,其中词汇表最好还要包含术语对应的英文描述,便于研发同学在代码层面表达统一语言。示例-SMS 的统一语言词汇表如下。

5.2 价值需求分析

价值需求分析主要做的三个工作是:

  1. 识别利益相关者。利益相关者指的是与目标系统存在利益关系的人、团队或组织, 可以简单理解为目标系统的用户,或与目标系统有直接交互的人、团队或组织。
  2. 明确系统愿景。阐明目标系统要做什么,以及为何要做。
  3. 确定系统范围。确定系统问题空间的边界,明确系统什么该做,什么不该做。结合目标系统当前状态未来状态进行判断。当前状态指的是系统的可用资源,包括业务资源、人力资源,资金资源等;而未来的状态则由业务目标、组织的战略规划和产品规划共同构成。

并非任何系统都 DDD,DDD 的核心是解决领域复杂性,若系统逻辑简单,功能不多,引入 DDD 则会得不偿失。而在进行价值需求分析后,我们便能判断是否需要通过 DDD 驱动系统的设计。

5.3 业务需求分析
5.3.1 业务流程、业务场景、业务服务和业务规则

使用业务流程、业务场景、业务服务业务规则来表示业务需求。

业务流程:表示的是一个完整的、端对端的服务过程。

业务场景:按阶段性的业务目标划分业务流程,就可以获得业务场景。在示例-SMS 中,老师修改成绩就分为了老师“提交申请单”,以及教务员“同意申请单”两个场景。

业务服务:角色主动向目标系统发起服务请求完成一次完整的功能交互,以实现业务目标。角色可以用户、策略(定时任务)或者其他系统,完整则强调的是业务服务的执行序列的所有步骤都应该是连续且不可中断的。业务服务是业务需求分析最核心,也是最基础的单元,而业务流程和业务场景是为了更好地分析出业务服务。在示例-SMS 中的“同意申请单”场景中包含了两个业务服务:教务员“同意申请单”和系统“邮件通知”教务员。

业务规则:指对业务服务约束的描述,用于控制业务服务的对外行为。业务规则是业务服务正确性的基础。常见的业务规则有:a) 意如“若… , 就….” 的需求描述,比如示例-SMS 中可提炼出“若成绩录入时间间隔超过一周,不予修改”;b) 具有事务性的操作。

5.3.2 子领域

通过业务流程、业务场景和业务服务的梳理,基本可以分析出业务需求所需要的业务服务。然而,业务服务粒度太细,而问题空间又太大,我们需要找一个更粗粒度的业务单元,来帮助我们对业务服务进行聚类,一方面可以降低管理过多细粒度业务服务导致的额外复杂度,另一方面可以帮助领域专家和开发团队分析问题和设计方案时不至于陷入到业务细节中。而这个更粗粒度的业务单元就是子领域

子领域的作用

  • 划分问题空间,作为业务服务分类的边界;
  • 用于分辨问题空间的核心问题和次要问题。

子领域的分类:

  • 核心子领域:能够体现系统愿景,具有产品差异化和核心竞争力的业务服务;
  • 通用子领域:包含的内容缺乏领域个性,具有较强的通用性,例如权限管理和邮件管理;
  • 支撑子领域:包含的内容多为“定制开发”,其为核心子领域的功能提供了支撑。

子领域的功能分类策略:问题空间应该分为哪些子领域,需要团队对目标系统整体进行探索,并根据功能分类策略进行分解。

  • 业务职能:当目标系统运用于企业的生产和管理时,与目标系统业务有关的职能部门往往会影响目标系统的子领域划分,并形成一种简单的映射关系。这是康威定律的一种运用。
  • 业务产品:当目标系统为客户提供诸多具有业务价值的产品时,可以按照产品的内容与方向进行子领域划分。
  • 业务环节对贯穿目标系统的核心业务流程进行阶段划分,然后按照划分出来的每个环节确定子领域。(这也是我们最常用的策略)
  • 业务概念:捕捉目标系统中一目了然的业务概念,将其作为子领域。

划分子领域的过程存在很多经验因素,一个对该行业领域知识了如指掌的领域专家,可以在完成价值需求分析后,结合自身的领域经验,能够选择合适的聚类策略并给出稳定的子领域列表。但,没有领域经验也没有关系!因为根据知识消化循环思路,再经历多个迭代后收敛出来的子领域划分也会逐渐合理,逼急领域专家凭经验得出的子领域划分,只是可能需要的时间要长一些。

6.架构映射阶段

在架构映射阶段,我们需要识别限界上下文,并通过上下文映射表示限界上下文之间的协作关系。

6.1 限界上下文的定义和特征
6.1.1 限界上下文的定义

限界上下文是语义和语境的边界。在问题空间,统一语言形成了团队对领域概念的统一表达,子领域形成了领域概念之间的边界。而在解空间,限界上下文可以看做是统一语言 子领域的融合体,统一语言需要在限界上下文内才具有明确的业务含义。

以电商购物场景为例。在进行商品下单后,系统会生成一个订单;在用户付款完成后,系统也会生成一个订单;到了物流派送流程,系统还会生成一个订单。虽然这三个步骤中的领域概念都叫订单,但是他们的关注点/职责却不同:商品订单关注的是商品详情,支付订单关注的是支付金额和分润情况,物流订单关注的是收货地址。也就是说,商品、支付和物流分别为三个限界上下文,而订单作为统一语言需要在特定的限界上下文内,我们才能够明确其关注点/负责的职责。

6.1.2 限界上下文的特征

最小完备:限界上下文在履行属于自己的业务能力时,拥有的领域知识是完整的,无须针对自己的信息去求助别的限界上下文。

自我履行:限界上下文能够根据自己拥有的知识来完成业务能力。自我履行体现了限界上下文纵向切分业务能力的特征。

这里需要强调一下业务模块(横向切分)限界上下文(纵向切分)的区别。业务模块不具备完整、独立的业务能力,它没有按照同一个业务变化的方向进行。而限界上下文是对目标系统架构的纵向切分,切分的依据是从业务进行考虑的领域维度。为了提供完整的业务能力,在根据领域维度进行划分时,还需要考虑支撑业务能力的基础设施实现,如与该业务相关的数据访问逻辑,以及将领域知识持久化的数据库模型,形成纵向的逻辑边界,即限界上下文边界。

稳定空间:限界上下文必须防止和减少外部变化带来的影响。

独立进化:指减少限界上下文内部变化对外界产生的影响。

上述的四个特征可以帮助我们验证识别出来的限界上下文。限界上下文划分是否合理、职责分配是否合理(最小完备 & 自我履行),是否合理运用上下文映射的手段隔离外部变化的影响(稳定空间)、是否有合理的封装,对外提供的接口是否稳定(独立进化)?

6.2 限界上下文的识别
6.2.1 按业务维度识别

1. 归类

按照业务相关性对业务服务进行归类,业务相关性体现为:

  • 语义相关性:存在相同或相似的领域概念,对应于业务服务描述的名词,如果不同的业务服务操作了相同或相似的对象,即可认为它们存在语义相关性。
  • 功能相关性:体现领域行为的相关性,业务服务是否服务于同一个业务目标。

2. 归纳

归纳是对归类后的限界上下文进行命名。给限界上下文命名的过程,实际上也是对归类是否合理的再一次复查。限界上下文的命名同样需要遵循单一职责原则,它只能代表唯一的最能体现其特征的领域概念。倘若归类不合理,命名就会变得困难,这时候我们就需要反思(遵循知识消化循环)归类是否合理,并重新设计归类。

3. 边界梳理

归类和归纳之后,限界上下文的边界基本已经确定,边界梳理则是根据限界上下文特征(最小完备、自我履行、稳定空间和独立进化)以及子领域进行微调(当然也不排除大调)。

代码语言:javascript复制
为什么需要根据子领域进行限界上下文边界的调整?限界上下文和子领域的关系是什么?

理想的限界上下文与子领域的关系是一一对应的。上文提到,子领域是领域专家根据领域经验选择合适的功能分类策略进行划分,这个过程不会牵扯对业务服务的分析,体现的是领域专家对行业的洞见和深刻认识,可见获取子领域是一个自顶向下的过程。而限界上下文则是对业务服务进行归类、归纳、梳理和调整,最终形成一个个的边界,这是一个自下而上的过程。理想情况下,两者应该是双向奔赴的,自顶向下得到的子领域和自下而上得到的限界上下文能够完美契合!但是,现实哪有这么理想呢!所以一般情况下都需要我们进行调整,力求这两者能够一一对应。

这里就再cue一下知识消化循环。优秀的领域专家划分出来的子领域,往往能够实现与限界上下文的一一对应。这就是经验的力量!那经验是怎么来的呢?我认为是领域专家经历了无数个知识消化循环之后沉淀下来的。领域专家一开始也是小白,划分出来的子领域在映射为限界上下文之后发现不同限界之间可能存在语义重叠,角色在不同限界上下文之中履行的职责可能很相似,于是他们通过知识消化循环,不断调整限界上下文的边界,然后又通过限界上下文调整子领域。慢慢地,稳定、可复用的子领域就被沉淀下来了。因此,识别限界上下文不是一个单向的过程,而是一个根据子领域调整限界上下文,然后又根据限界上下文调整子领域的循环的过程。
6.2.2 验证

正交原则

正交性:如果两个或更多事物中的一个发生变化,不会影响其他事物,这些事物就是正交的。要破坏变化的传递性,就要保证每个限界上下文对外提供的业务服务不能出现雷同。

奥卡姆剃刀原理

“如无必要,勿增实体”。这是避免过度设计的良方,同样也是我们识别限界上下文的原则。如果对识别出来的限界上下文的准确性依然心存疑虑,比较务实的做法是保证限界上下文具备一定的粗粒度。遵循该原则,意味着当我们没有寻找到必须切分限界上下文的必要证据时,就不要增加新的限界上下文。

6.3 上下文映射

限界上下文封装了分离的业务能力,上下文映射则建立了限界上下文之间的关系。上下文映射提供了各种模式(防腐层、开放主机服务、发布语言、共享内核、合作者、客户方/供应方、分离方式、遵奉者、大泥球),本质是在控制变化在限界上下文之间传递所产生的影响

下文将提供服务的限界上下文称为“上游”上下文(U 表示),消费服务的限界上下文称为“下游”上下文(D 表示)。

6.3.1 防腐层

引入防腐层的目的是为了隔离耦合。防腐层往往位于下游,通过它隔离上游上下文发生的变化。

6.3.2 开放主机服务

开放主机服务定义公开服务的协议(亦称为“服务契约”),包括通信方式、传递消息的格式(协议),让限界上下文可以被当做一组服务访问。开放主机服务也可以视为一种承诺,保证开放的服务不会轻易做出变化。

对于进程内的开放主机服务,称为本地服务(对应 DDD 中的应用服务)。

对于进程间的开放主机服务,成为远程服务。根据选择的分布式通信技术的不同,又可以定义出类型不同的远程服务:

  • 面向服务行为,比如基于 RPC,称为提供者(Provider);
  • 面向服务资源,比如基于 REST,称为资源(Resource);
  • 面向事件,比如基于消息中间件,称为订阅者(Subscriber);
  • 面向视图模型,比如基于 MVC,称为控制器(Controller);
6.3.3 发布语言

发布语言是一种公共语言,用于两个限界上下文之间的模型转换。防腐层和开放主机服务都是访问领域模型时建立的一层包装,前者针对发起调用的下游(通过基础设施层体现),后者针对响应请求的上游(通过应用层 远程服务),以避免上下游之间的通信集成将各自的领域模型引入进来,造成彼此之间的强耦合。因此,防腐层和开放主机服务操作的对象都不应该是各自的领域模型,这正是引入发布语言的原因。(对于熟悉云 API 的小伙伴就会发现,其实云 API 根据我们定义的接口生成对应的 Request 对象和 Response 对象,并集成在云 API 的 SDK 中,这些对象就是发布语言)。

一般情况下,发布语言根据开放主机服务的服务契约进行定义。

说到这里,我们惊讶地发现防腐层开放主机服务发布语言可以完美联动!

6.3.4 共享内核

共享内核指将限界上下文中的领域模型直接暴露给其他限界上下文使用。注意,这会削弱了限界上下文边界的控制力。上面我们讲述的防腐层、开放主机服务以及发布语言无不传达一种思想,限界上下文不能直接暴露自己的领域模型或直接访问其他限界上下文的领域模型,一定要有隔离层!

但是,在特定的场景下,共享内核不见得不是一种合理的方式。任何软件设计决策都要考量成本与收益,只有收益高于成本,决策才是合理的。一般对于一些领域通用的值对象是相对稳定的,这些类型通常属于通用子领域,会被系统中几乎所有的限界上下文复用,那么这些领域模型就适合使用共享内核的方式。共享内核的收益不言而喻,而面临的风险则是共享的领域模型可能产生的变化。

6.3.5 合作者

合作关系指的是协作的限界上下文由不同的团队负责,且这些团队之间具有要么一起成功,要么一起失败的强耦合关系。合作者模式要求参与的团队一起做计划、一起提交代码、一起开发和部署,采用持续集成的方式保证两个限界上下文的集成度与一致性,避免因为其中一个团队的修改影响集成点的失败。

6.3.6 客户方/供应方

当一个限界上下文单向地为另一个限界上下文提供服务时,它们对应的团队就形成了客户方/供应方模式。这是最为常见的团队协作模式,客户方作为下游团队,供应方作为上游团队,二者协作的主要内容包括:

  • 下游团队对上游团队提出的服务
  • 上游团队提供的服务采用什么样的协议与调用方式
  • 下游团队针对上游服务的测试策略
  • 上游团队给下游团队承诺的交付日期
  • 当上游服务的协议或调用方式发生变更时,如何控制变更
6.3.7 分离方式

分离方式的团队协作模式是指两个限界上下文之间没有一丁点关系。如果此时双方使用到了相似/相同的领域模型,则可以通过拷贝的方式解决,保证限界上下文之间的物理隔离!

6.3.8 遵奉者

当上游的限界上下文处于强势地位,且上游团队响应不积极时,我们可以采用遵奉者模式。即下游严格遵从上游团队的模型,以消除复杂的转换逻辑。

当下游团队选择“遵奉”于上游团队设计的模型时,意味着:

  • 可以直接复用上游上下文的模型(好的);
  • 减少了两个限界上下文之间模型的转换成本(好的);
  • 使得下游限界上下文对上游产生了模型上的强依赖(坏的)。
6.3.9 大泥球

一定要避免制造大泥球!大泥球的特点:

  • 越来越多的聚合因为不合理的关联和依赖导致交叉污染;
  • 对大泥球的维护牵一发而动全身;
  • 强调“个人英雄主义”,只有个别“超人”能够理清逻辑。
6.4 示例-SMS 的限界上下文及其映射

示例-SMS 的限界上下文可划分为:

  • 成绩上下文
  • 课程上下文
  • 审批上下文
  • 权限上下文
  • 邮件上下文

上下文映射图如下所示。

7.领域建模阶段

领域建模阶段由领域分析建模,领域设计建模和领域实现建模组成。在正式讲解建模活动前,先了解一下什么是模型驱动设计。

7.1 模型驱动设计

模型是一种知识形式,它对知识进行了选择性的简化和有意的结构化,从而解决信息超载的问题。模型便于人们理解信息的意义,并专注核心问题。

建模过程一般由分析活动设计活动实现活动组成。每一次建模活动都是一次对知识的提炼和转换,并产生相应的模型,即分析模型设计模型实现模型

建模过程并非是分析、设计和实现单向的前后串行过程,而是相互影响,不断切换和递进的关系。模型驱动设计的建模过程是:分析中蕴含了设计,设计中夹带了实现,甚至实现后还要回溯到设计和分析的一种迭代的螺旋上升的演进过程。

根据分解问题的视角不同,我们日常建立的模型可以大致分为以下三类:

  • 数据模型:将问题空间抽取出来的概念视为数据信息,在求解过程中关注数据实体的样式和它们之间的关系,由此建立的模型就是数据模型。
  • 服务模型:将每个问题视为目标系统为客户端提供的服务,在求解过程就会关注客户端发起的请求以及服务返回的响应,由此建立的模型就是服务模型。
  • 领域模型:围绕问题空间的业务需求,在求解过程中力求提炼出表达领域知识的逻辑概念,由此建立的模型就是领域模型。
7.1.1 领域模型驱动设计

一个优秀的领域模型应该具备以下的特征(我们也可以说具备这些特征的模型就是领域模型):

  • 运用统一语言来表达领域中的概念;
  • 蕴含业务活动和规则等领域知识;
  • 对领域知识进行适度的提炼和抽象;
  • 由一个迭代的演进过程建立;
  • 有助于产品、领域专家和开发同学进行交流。

领域建模阶段目的便是建立领域模型。领域模型由领域分析模型领域设计模型以及领域实现模型共同组成,它们也分别是领域分析建模、领域设计建模和领域实现建模三个建模活动的产物。

值得注意的是,领域模型并非由开发团队单方面输出的产物,而是由产品、领域专家和开发团队共同协作的结果。领域专家通过领域模型能够判断系统所支持的领域能力,以及由此编排出来的上层业务能力;开发团队通过领域模型能够形成基本的代码框架(包括架构分层,每层需要定义的接口,接口的命名等)。同理,领域模型的调整,也意味着领域知识或业务规则的变化,也预示着系统所支持的业务能力和代码实现同样需要作出改变。

7.2 领域分析建模

领域分析建模:在限界上下文内,以“领域”为中心,提炼业务服务中的领域概念,确定领域概念之间的关系,最终形成领域分析模型。领域分析模型描述了各个限界上下文中的领域概念,以及领域概念之间的关系。

下面讲述如何通过“快速建模法”来构建领域分析模型。

7.2.1 名词建模

找到业务服务中的名词,在统一语言指导下将其映射为领域概念。

7.2.2 动词建模

识别动词并不是为领域模型对象分配职责、定义方法,而是将识别出来的动词当做一个领域行为,然后看它是否产生了影响管理、法律或财务的过程数据。若存在,则将这些过程数据作为领域概念放到领域分析模型中。注意,这里的过程数据是要求会对企业运营和管理产生影响的数据,比如示例-SMS 系统中老师提交修改申请,就会产生申请单这个过程数据,而请求流水记录、任务执行记录都不属于过程数据。动词建模通过分析领域行为是否产生过程数据来找到隐藏的领域概念,弥补了名词建模的不足。

特别地,对于会产生领域事件的动词,一般可以抽象出一个已完成该动作的状态。

7.2.3 提取隐式概念

除了“名词”和“动词”,概念中其他重要的类别也可以在模型中显式地表现出来,主要包括:约束规格

约束

约束一般是对领域概念的限制,我们可以将约束条件提取到自己的方法中,并通过方法名显式地表达约束的含义。比如示例-SMS 中关于 GPA 运算的约束。

有些时候,约束条件无法用单独一个方法来轻松表达,抑或约束条件中会使用到与对象职责无关的信息,那么我们就可以将其提取到一个显式的对象中。

规格(SPECIFICATION)

很多时候业务规则并不适合作为实体或值对象的职责,而且规则的变化和组合也会掩盖领域对象的含义。但是,将规则移出领域层则导致领域代码无法表达模型。此时,我们可以定义规格谓词形式的显式值对象),它用于确定对象是否满足指定的标准。规格将规则保留在领域层,由于规格是一个完备的对象,所以这种设计也能更加清晰地反映模型。

规格一般有如下三种用法:

  • (验证)验证对象,检查它是否能满足某些标准,比如示例-SMS 中成绩实体在修改分数时就需要通过规约判断当前是否满足修改的标准;
  • (选择)从集合中选择一个符合要求的对象,可以搭配资源库使用
  • (根据要求来创建)指定在创建新对象时必须满足某种要求。

规格由“谓词”概念演变而来,因此我们可以使用“AND”,“OR”和“NOT”等运算对规格进行组合和修改。比如在 SMS 中,教务员需要查询流程完结的申请单,我们就可以通过“AND”组合不同的规格进行实现。

7.2.4 归纳抽象

对于有定语修饰的名词,要注意分辨它们是类型的差异,还是值的差异。如配送地址和家庭地址,订单状态和商品状态。如果是值的差异,类型相同,应归并为一个领域概念(如,配送地址和家庭地址);而类型不同,则不能合并(如,订单状态和商品状态)。

特别地,当定语修饰的名词中,定语表示的是不同的限界上下文,且名词相同时(即名称相同、含义不同的领域概念),我们应该尽可能调整命名,确保含义不同的领域概念的名称不同,以避免不必要的歧义和沟通上的误解。比如:商品的订单和库存的订单在特定限界上下文内都可以命名为 order,但是如果把库存的订单改为库存的配送单 delivery 效果会更好。

7.2.5 确认关系

根据业务需求和领域知识,判断领域概念之间是否存在关联。且对于 1:N, N:1, M:N 的关联关系,我们需要判断是否可以为这些关联关系定义一个新的类型,比如作品与读者存在 1:N 的关系,我们可以定义“订阅”这个概念来描述这种关系。

注意,我们需要尽量避免对象中的双向关系,即对象 A 关联对象 B,而对象 B 关联对象 A。当两个对象存在双向关系时,会为管理他们的生命周期带来额外的复杂度。我们应该规定一个遍历方向,来表明一个方向的关联比另一个方向的关联更有意义且更重要,比如示例 SMS 中,成绩会关联课程(成绩实例中包含课程 ID),而课程不会关联成绩。当然,当双向关系是领域的一个概念时,我们还是应该保留它。

7.2.6 示例-SMS 的领域分析模型

通过名词建模,动词建模和归纳抽象后,可提炼出以下领域对象:成绩(Result)、绩点(gpa)、总成绩(total result)、总绩点(total gpa)、学年(school year)、学期(semester)、课程(course)、学分(credit)、申请单(application receipt),邮件(mail),排名(rank),申请单状态(application receipt status)

这些领域对象之间的关系如下图所示。

7.3 领域设计建模

领域设计建模的核心工作就是设计聚合设计服务,在这之前我们需要先了解一下设计要素(实体、值对象、聚合、工厂、资源库、领域服务、领域事件)。

7.3.1 设计要素

领域驱动设计强调以“领域”为核心驱动力。设计领域模型时应该尽量避免陷入到技术实现的细节约束中。但很多时候我们又不得不去思考一些非领域相关的问题:

  • 领域模型对象在身份上是否存在明确的差别?
  • 领域模型对象的加载以及对象间的关系如何处理?
  • 领域模型对象如何实现数据的持久化?
  • 领域模型对象彼此之间如何做到弱依赖地完成状态的变更通知?

为了解答上述的四个问题,DDD 提供了很多的设计要素,它们能够帮助我们在不陷入到具体技术细节的情况下进行领域模型的设计

7.3.1.1 实体

实体的核心三要素:身份标识属性领域行为

身份标识:身份标识的主要目的是管理实体的生命周期。身份标识可分为:通用类型和领域类型。通用类型 ID 没有业务含义;而领域类型 ID 则组装了业务逻辑,建议使用值对象作为领域类型 ID。

属性:实体的属性用来说明主体的静态特征,并持有数据与状态。属性分为:原子属性和组合属性。组合属性可以是实体,也可以是值对象,取决于该属性是否需要身份标识。我们应该尽可能将实体的属性定义为组合属性,以便于在实体内部形成各自的抽象层次。

领域行为:体现了实体的动态特征。实体具有的领域行为一般可以分为:

  • 变更状态的领域行为:变更状态的领域行为体现的是实体/值对象内部的状态转移,对应的方法入参为期望变更的状态。(有入参,无出参);
  • 自给自足的领域行为:自给自足意味着实体对象只操作了自己的属性,不外求于别的对象。(无入参);
  • 互为协作的领域行为:需要调用者提供必要的信息。(有入参,有出参);
  • 创建行为:代表了对象在内存的从无到有。创建行为由构造函数履行,但对于创建行为较为复杂或需要表达领域语义时,我们可以在实体中定义简单工厂方法,或使用专门的工厂类进行创建。(有出参,且出参为特定实体实例)。

7.3.1.2 值对象

一个领域概念到底该用值对象还是实体类型,判断依据:

  • 业务的参与者对它的相等判断是依据值还是依据身份标识;
  • 确定对象的属性值是否会发生变化,如果变化了,究竟是产生一个完全不同的对象,还是维持相同的身份标识;
  • 生命周期的管理。值对象无需进行生命周期管理。

值对象具有不变性。值对象完成创建后,其属性和状态就不应该再进行变更了,如果需要更新值对象,则通过创建新的值对象进行替换。

由于值对象的属性是在其创建的时候就完成传入的,那么值对象所具有的领域行为大部分情况下都是“自给自足的领域行为”,即入参为空。这些领域行为一般提供以下的能力。

  • 自我验证:验证传入值对象的外部数据是否正确,一般在创建该值对象时进行验证。
  • 自我组合:当值对象涉及到数值运算时,可以定义相同类型值对象的方法,使值对象具有自我组合能力。比如示例-SMS 中,在统计成绩时会涉及学分相加的运算,因此我们可以将相加运算定义为可组合的方法,便于调用者使用。
  • 自我运算:根据业务规则对属性值进行运算的行为。

在进行领域设计建模时,要善于运用值对象而非内建类型去表达细粒度的领域概念。相比于内建类型,值对象的优势有:

  • 值对象在类型层面就可以表达领域概念,而不仅仅依赖命名;
  • 值对象可以封装领域行为,进行自我验证,自我组合,自我运算。

7.3.1.3 聚合

聚合的基本特征:

  • 聚合是包含了实体和值对象的一个边界。
  • 聚合内包含的实体和值对象形成一棵树,只有实体才能作为这棵树的根。
  • 外部对象只允许持有聚合根的引用,以起到边界控制作用。
  • 聚合作为一个完整的领域概念整体,其内部会维护这个领域概念的完整性。
  • 由聚合根统一对外提供履行该领域概念职责的行为方法,实现内部各个对象之间的行为协作。

7.3.1.4 工厂

聚合中的工厂:一个类或方法只要封装了聚合对象的创建逻辑,都可以认为是工厂。表现形式如下:

  • 引入专门的聚合工厂(尤其适合需要通过访问外部资源来完成创建的复杂创建逻辑)
  • 聚合自身担任工厂(简单工厂模式)
  • 服务契约对象或装配器(assembler)担任工厂(负责将外部请求对象 DTO 转换为实体)
  • 使用构建者组装聚合

注意!这里工厂创建的基本单元是聚合,而非实体,注意与实体中的创建行为区分。

7.3.1.5 资源库

资源库是对数据访问的一种业务抽象,用于解耦领域层与外部环境,使领域层变得更为纯粹。资源库可以代表任何可以获取资源的仓库,例如网络或其他硬件环境,而不局限于数据库。

一个聚合对应一个资源库。领域驱动设计引入资源库,主要目的是管理聚合的生命周期。资源库负责聚合记录的查询与状态变更,即“增删改查”操作。资源库分离了聚合的领域行为和持久化行为,保证了领域模型对象的业务纯粹性。

值得注意的是,资源库的操作单元是聚合。当我们定义资源库的接口时,接口的入参应该为聚合的根实体。如果要访问聚合内的非根实体,也只能通过资源库获得整个聚合后,将根实体作为入口,在内存中访问封装在聚合边界内的非根实体对象。

代码语言:javascript复制
资源库与数据访问对象(DAO)的区别:

根本区别在于,数据访问对象在访问数据时,并无聚合的概念,也就是没有定义聚合的边界约束领域模型对象,使得数据访问对象的操作粒度可以针对领域层的任何模型对象。数据访问对象(DAO)可以自由地操作实体和值对象。没有聚合边界控制的数据访问,会在不经意间破坏领域概念的完整性,突破聚合不变量的约束,也无法保证聚合对象的独立访问与内部数据的一致性。

其次,资源库是基于领域模型对存储系统进行的抽象,因此资源库中的方法命名可以表达领域概念;而数据访问对象(DAO)是存储系统对外暴露的抽象,其方法命名更贴合数据库本身的操作。

**7.3.1.6 领域服务 **

聚合通过聚合根的领域行为对外提供服务,而领域服务则是对聚合根的领域行为的补充。因此,我们应该尽量优先通过聚合根的领域行为来满足业务服务

那什么场景下我们会需要用到领域服务呢?有如下两个:

  • 生命周期管理。为了避免领域知识的泄露,应用服务不会直接引用聚合生命周期相关的服务(工厂、资源库接口),而聚合根实体一般不会依赖资源库接口,此时就需要领域服务进行组合对外暴露。
  • 依赖外部资源为了保证聚合的稳定性,聚合根实体不会依赖防腐层接口。因此,当聚合对外暴露的服务需要设计外部资源访问时,就需要通过领域服务来完成。

7.3.1.7 领域事件

领域事件属于领域层的领域模型对象,由限界上下文中的聚合发布,感兴趣的聚合(同一限界上下文/不同限界上下文)可以进行消费。而当一个事件由应用层发布,则该事件为应用事件。

引入领域事件首要目的是更好地跟踪实体状态的变更,并在状态变更时,通过事件消息的通知完成领域模型对象之间的协作。

领域事件的特征

  • 领域事件代表了领域的概念;
  • 领域事件是已经发生的事实(表示事件的名称应该是过去时,比如 Committed);
  • 领域事件是不可变的领域对象;
  • 领域事件会基于某个条件而触发。

领域事件的用途

  • 发布状态变更;
  • 发布业务流程中的阶段性成果;
  • 异步通信。

领域事件应该包含:

  • 身份标识,即事件 ID,为通用类型的身份标识;
  • 事件发生的时间戳,便于记录和跟踪;
  • 属性需要针对订阅者的需求,在增强事件反向查询之间进行权衡。增强事件指属性中包含订阅者所需的所有数据;反向查询则是属性包含事件 ID,当订阅者需要数据时通过事件 ID 进行反向查询。
7.3.2 设计聚合

在领域设计模型中,聚合是最小的设计单元。

7.3.2.1 设计的经验法则

这里有四条经验法则:

  1. 在聚合边界内保护业务规则不变性。
  2. 聚合要设计得小巧。
  3. 通过身份标识符关联关系其他聚合。
  4. 使用最终一致性更新其他聚合。

下面展开讲述法则 1 和法则 3。

法则 1 在聚合边界内保护业务规则不变性

法则 1 包含了两个关键点:a) 参与维护业务规则不变性的领域概念应该置于同一个聚合内;b) 在任何情况下都要保护业务规则不变性。比如,在 sms 系统中分数和绩点具有转换关系,这是业务规则的不变性,因此这两个概念被放在了同一个聚合边界内;当出现老师修改分数的场景时,需要保证绩点的换算同时被执行。由于这里绩点对象是值对象,不需要关心其生命周期管理的问题。当业务规则涉及到多个实体时,就需要通过本地事务来保证规则不变性(即实体间基于业务规则的数据一致性)。

法则 3 通过身份标识符关联其他聚合。

注意这里强调了关联关系,关联关系会涉及聚合 A 对聚合 B 的生命周期管理的问题,对于这种聚合间的关联关系,我们通过身份标识建立关联。而当聚合 A 引用聚合 B,但不需要对聚合 B 进行生命周期管理时,我们认为这是一种依赖关系(比如方法中的入参,而非类中的属性),对于聚合间的依赖关系,我们可以通过对象引用(聚合根实体的引用)的方式建立依赖。(PS:假设设计之初难以判断聚合之间到底是关联关系,还是依赖关系,我们就统一使用身份标识符作为关系引用即可)

聚合间的依赖关系通常分为两种方式

  • 职责的委派:一个聚合作为另一个聚合的方法参数, 就会形成职责的委派。
  • 聚合的创建:一个聚合创建另外一个聚合,就会形成实例化的依赖关系。

7.3.2.2 设计步骤

1. 理顺对象图

分析对象是实体还是值对象。

2. 分解关系薄弱处

聚合本质是一个高内聚的边界,因此我们可以根据领域对象之间关系的强弱来定义出聚合的边界。对象间的关系由强到弱可以分为:泛化关系,关联关系和依赖关系。其中关联关系和依赖关系在 7.3.2.1 小节已讲述,而泛化关系可以理解为是继承关系(即父子关系)。

泛化关系

虽然泛化关系是强耦合关系,但是根据对业务理解的视角不同,会产生不同的设计:

  • 整体视角:调用者并不关心特化的子类之间的差异,而是将整个继承体系视为一个整体。此时应以泛化的父类作为聚合根。
  • 独立视角:调用这只关注具体的特化子类,体现了概念的独立性,此时应以特化的子类作为独立的聚合根。

关联关系

上述提到过,聚合间的关联关系会涉及聚合 A 对聚合 B 的生命周期管理,这其实是一个比较宽松的约束。那聚合内实体的关联关系应该是怎么样的呢?生命周期一致的、共存亡的,当主实体被销毁时,从实体也随之会被销毁。比如商品实体和商品明细实体。而在示例-SMS 中,成绩和总成绩会被定义为两个聚合,原因是总成绩在成绩锁定后被统计,随后将不再发生改变,可见两者不存在上述的共存亡的关联关系。

PS: 实际上根据关联关系来区分边界的方法同样适用于限界上下文的边界划分。比如示例-SMS 中的课程和成绩生命周期不同,先有课程,后有成绩;而且成绩锁定后,课程被撤销也不会对成绩有影响,因此就可以定义出课程上下文和成绩上下问。

依赖关系

依赖关系主要体现的是实体间的职责委派和创建行为,可以分到不同的聚合边界。

3. 调整聚合边界

根据业务规则调整聚合边界。为了维护业务规则的不变性,相关的实体应该至于同一个聚合边界内。

7.3.3 设计服务

这里的服务是对应用服务领域服务领域行为(实体提供的方法)和端口(资源库接口、防腐层接口)的统称。

7.3.3.1 分解任务

业务服务包含若干个组合服务,组合服务包含若干个原子服务领域行为端口都可以认为是原子服务。

7.3.3.2 分配职责

应用服务:匹配业务服务,提供满足业务需求的服务接口。应用服务自身并不包含任何领域逻辑,仅负责协调领域模型对象,通过它们的领域能力组合完整一个完整的应用目标。

领域服务:匹配组合服务,执行业务功能,若原子任务为无状态行为或独立变化的行为,也可以匹配领域服务。控制多个聚合与端口之间的协作,由它来承担组合任务的执行。

领域行为:匹配原子服务,提供业务功能的业务实现。强调无状态和独立变化,由实体提供。

端口:匹配原子服务,抽象对外资源的访问,主要的端口包括资源库接口和防腐层接口。

代码语言:javascript复制
虽然上述给出了应用服务、领域服务、领域行为和端口与业务服务、组合服务和原子服务的匹配关系,但是对于应用服务、领域服务、领域行为和端口之间的关联关系却还不清晰,这里结合书中内容和个人实践给出一个参考。

应用服务:核心职责是编排聚合间的领域服务。
- 领域服务
- 防腐层接口:当多聚合间领域服务进行协作后需要访问外部资源,此时相关的防腐层逻辑应该至于应用层。(防腐层是上下文映射的方式,并非领域模型特有)
- 工厂:特指服务契约对象或装配器担任工厂,即将DTO转换为实体的工厂。
- 领域行为:在上述工厂创建实体后,若只需要调用实体的领域行为,而不需要涉及生命周期管理,可直接在应用服务中进行调用。

领域服务:细粒度的领域对象可能会把领域层的知识泄露到应用层中。这产生的结果是应用层不得不处理复杂的、细致的交互,从而使得领域知识蔓延到应用层或用户界面代码当中,而领域层会丢失这些知识。明智地引入领域层服务有助于在应用层和领域层之间保持一条明确的界限,因此应用层多数情况下也不会直接引用聚合的领域行为。
- 工厂
- 领域行为
- 防腐层接口:聚合内需要依赖外部资源,则将防腐逻辑收拢在领域服务中。
- 资源库接口

领域行为:不要关联资源库和防腐层接口。
7.3.4 示例-SMS 的领域设计模型

聚合设计

服务设计

下面只罗列非查询类的服务设计。

7.4 领域实现建模

领域实现建模关注的并非是如何进行代码实现,而是如何验证代码实现的正确性,保证实现的高质量

7.4.1 领域模型与测试金字塔

领域模型中的服务包括了应用服务领域服务领域行为端口。其中通过 Provider(面向服务行为)、Resource(面向服务资源)、Subscriber(面向事件)、Controller(面向视图模型)对外进行暴露的,我们称为远程服务

领域模型中的服务与测试金字塔的关系如下图所示。

7.4.2 测试驱动开发

领域实现建模提倡的是测试驱动开发的编程思想,即要求开发者在进行逻辑实现前,优先进行测试用例的编写,站在调用者角度而非实现者角度去思考接口。

在上述测试金字塔中,开发者需要关注的是单元测试(不依赖任何外部资源的测试就是单元测试)。在领域设计建模阶段,我们对业务服务/应用服务进行分解,定义出了领域行为和领域服务。对于领域行为,由于其不依赖外部资源,因此我们可以直接编写单元测试;而对于领域服务,其可能会通过端口访问外部资源,此时我们需要对端口进行 mock,以隔离外部资源对领域逻辑验证的干扰。特别地,单元测试一定要覆盖所有对业务规则的验证,这是保证领域行为和领域服务正确性的基础。

单元测试编码规范:

  • 测试类的命名应与被测试类保持一致,为“被测类名称 Test 后缀”。
  • 测试方法表达业务或业务规则为目的。
  • 测试方法体遵循 Given-When-Then 模式。Given: 为要测试的方法提供准备,包括创建被测试对象,为调用方法准备输入参数实参等;When: 调用被测试的方法,遵循单一职责原则,在一个测试方法的 When 部分,应该只有一条语句对被测方法进行调用;Then: 对被测方法调用后的结果进行预期验证。

8.分层架构与代码骨架

8.1 分层架构

代码架构分层是经典 DDD 四层:用户接口层应用层领域层基础设施层

需要注意的的地方是:

  • 用户接口层根据通信方式的不同,区分开了 Provider(面向服务行为)、Subscriber(面向事件)、Controller(面向视图模型&资源) 、Task(面向策略/定时任务)。
  • 基础设施层单独划分了 infranstructure-impl 模块。为了保证领域层的纯洁性,DDD 通过依赖倒置把访问外部系统(数据库,第三方系统)的服务的实现都下放到了基础设施层,而 infranstructure-impl 模块 则是对这些实现进行了归集。这样做的好处有两个:第一,依赖关系明确,(infransturcture-impl —> domain,application), (interface、application、domain —> infranstructure);第二,拆分服务更便捷。当我们需要部分领域独立拆分出来的时候,在实现层面就只需要关注 infransturcture-impl 模块 即可。
  • Infranstructure-impl 模块依赖应用层的原因是应用层可能会抽象出防腐层接口,需要 infranstruct-impl 为其提供实现。
8.2 代码骨架
8.2.1 用户接口层

用户接口层的核心职能:协议转换和适配、鉴权、参数校验和异常处理。

代码语言:javascript复制
├── controller                             //面向视图模型&资源
│   ├── ResultController.java
│   ├── assembler                         // 装配器,将VO转换为DTO
│   │   └── ResultAssembler.java
│   └── vo                                // VO(View Object)对象
│       ├── EnterResultRequest.java
│       └── ResponseVO.java
├── provider                               // 面向服务行为
├── subscriber                             // 面向事件
└── task                                   // 面向策略
    └── TotalResultTask.java
8.2.2 应用层

应用层的核心职能:编排领域服务、事务管理、发布应用事件。

代码语言:javascript复制
├── assembler                              // 装配器,将DTO转换为DO
│   ├── ResultAssembler.java
│   └── TotalResultAssembler.java
├── dto                                    // DTO(Data Transfer Object)对象
│   ├── cmd                                // 命令相关的DTO对象
│   │   ├── ComputeTotalResultCmd.java
│   │   ├── EnterResultCmd.java
│   │   └── ModifyResultCmd.java
│   ├── event                             // 应用事件相关的DTO对象, subscriber负责接收
│   └── qry                               // 查询相关的DTO对象
└── service                                // 应用服务
    ├── ResultApplicationService.java
    ├── event                              // 应用事件,用于发布
    └── adapter                            // 防腐层适配器接口
8.2.3 领域层

代码组织以聚合为基本单元。

代码语言:javascript复制
├── result                                 // 成绩聚合
│   ├── entity                            // 成绩聚合内的实体
│   │   └── Result.java
│   ├── service                           // 领域服务
│   │   ├── ResultDomainService.java
│   │   ├── event                         // 领域事件
│   │   ├── adapter                       // 防腐层适配器接口
│   │   ├── factory                       // 工厂
│   │   └── repository                    // 资源库
│   │       └── ResultRepository.java
│   └── valueobject                        // 成绩聚合的值对象
│       ├── GPA.java
│       ├── ResultUK.java
│       ├── SchoolYear.java
│       └── Semester.java
└── totalresult                             // 总成绩聚合
    ├── ... 这段有点长,其代码结构与成绩聚合一致,因此省略 ...
8.2.4 基础设施实现层

该层主要提供领域层接口(资源库、防腐层接口)和应用层接口(防腐层接口)的实现。

代码组织基本以聚合为基本单元。对于应用层的防腐层接口,则直接以 application 作为包名组织。

代码语言:javascript复制
├── application                                  // 应用层相关实现
│   └── adapter                                 // 防腐层适配器接口实现
│       ├── facade                              // 外观接口
│       └── translator                          // 转换器,DO -> DTO
├── result                                       // 成绩聚合相关实现
│   ├── adapter
│   │   ├── facade
│   │   └── translator
│   └── repository                              // 成绩聚合资源库接口实现
│       └── ResultRepositoryImpl.java
└── totalresult                                  // 总成绩聚合相关实现
    ├── adapter
    │   ├── CourseAdapterImpl.java
    │   ├── facade
    │   └── translator
    └── repository
        └── TotalResultRepositoryImpl.java

9.杂谈

9.1 DDD 与微服务

微服务拆解指的是把一个单体服务拆分为粒度“足够小”的多个服务,而这里的“足够小”是一个主观的,没有任何标准的定义。尽管如此,我们对“”这个词还是有一些基本要求的:足够内聚,足够独立,足够完备,这才使得拆分出来的微服务收益大于投入,试想如果一个微服务提供的业务功能会牵扯到与其他众多微服务的协作,那岂不是芭比 Q 了。

而上述我们对微服务的基本要求,实际上与限界上下文的特征(最小完备,自我履行,稳定空间,独立进化)不谋而合,因此,我们可以把限界上下文映射为微服务。我在日常实践中,都是将限界上下文和微服务的关系进行一一对应的,但这不是绝对的!限界上下文是站在领域角度给出的逻辑边界,而微服务的设计往往还要考虑物理边界,以及实际的质量需求(性能,可用性,安全性等),比如当我们采用的是 CQRS 架构,领域模型会被分为命令模型和查询模型,虽然它们同属一个限界上下文,但是它们往往是物理隔离的。因此,限界上下文只能作为微服务拆分的指导,而拆分过程中需要考虑质量需求,架构设计等技术因素。

9.2 事务
9.2.1 本地事务

上文在提及限界上下文识别和聚合设计的时候其实都提到需要考虑事务属性,即需要通过本地事务来保证业务规则的不变性/一致性。这里我们会疑惑的是:谁来承担管理事务的职责?事务管理的边界是什么?

应用层承担管理事务的职责

事务本质是一种技术手段,而领域模型本身与技术无关,因此事务应该由应用层负责管理。

事务管理的边界是聚合,有时限界上下文也可以

资源库操作的基本单元是聚合,因此事务管理的边界是聚合便是自然而然得出的结论。这里需要考虑的是当需要保证事务属性的不仅仅只有资源库操作,还包括发布领域事件时(即保证聚合落库和事件发布的原子性),我们可能需要采用可靠事件模式,即通过把领域事件落库事件表来表示事件的发布。此时应用层在管理事务时就没什么心智负担了。当然,采用可靠事件模式实际是限制了领域模型的实现,也算是技术对领域模型的一种入侵吧,但相比于解放应用层而言,应该是利大于弊。

我们也知道,应用层的核心职责是负责编排和协调不同聚合的领域服务,而应用层又负责事务管理,自然我们能推到出事务管理的边界是多个聚合(即限界上下文)。但这里有两个关注点:

a)一般是出于质量需求(性能会好一些,时效性更高一些);

b)同一个限界上下文内的多个聚合共享一个 DB。

9.2.2 Saga 事务

为了避免耦合,DDD 主张通过柔性事务来保证跨聚合、跨限界上下文的最终一致性。而目前业界比较主流的应用是 Saga 模式:通过使用异步消息来协调一系列本地事务,从而维度多个服务之间的数据一致性。而另一个非常著名的柔性事务方案 TCC 为啥没有 Saga 契合呢?

TCC 共分为三个阶段:

  1. Try 阶段:准备阶段,对资源进行锁定或预留;
  2. Confirm 阶段:提交阶段,执行实际的操作;
  3. Cancel 阶段:补偿阶段,任意执行的操作出错了,就需要执行补偿,即释放 Try 阶段预留的资源。

可以看到 TCC 实际对领域模型的侵入是比较大的:

a)TCC 要求领域模型设计时,定义相关的属性以支持资源锁定/预留的问题;

b)TCC 对服务接口定义做出了要求,领域模型需要提供 Try,Confirm 和 Cancel 相应的领域服务。

Saga 模式并不要求其对资源进行锁定/预留,而其补偿操作也是通过执行操作的逆操作来完成(比如支付的逆操作是退款)。而大部分情况下,完整的领域模型都会对外提供操作及其逆操作。

10. 参考

  1. 《解耦-领域驱动设计》
  2. 《领域驱动设计:软件核心复杂性应对之道》
  3. 《实现领域驱动设计》
  4. 《微服务架构设计模式》
  5. 极客时间《DDD 实战课》
  6. 极客时间《如何落地业务建模》
  7. 《领域驱动设计精粹》

最近其他好文:

深入揭秘 epoll 是如何实现 IO 多路复用的

低代码是什么?有什么优势

Go 高性能编程技法

0 人点赞