14.限界上下文

268 阅读24分钟

限界上下文

1.介绍

限界上下文是指一个特定业务范围内的所有知识,包括相关的概念,流程和规则等

用来封装通用语言和领域对象,提供上下文环境,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性。这个边界定义了模型的适用范围,使团队所有成员能够明确地知道什么应该在模型中实现,什么不应该在模型中实现

2.案例理解

案例一

正如电商领域的商品一样,商品在不同的阶段有不同的术语,在销售阶段是商品,而在运输阶段则变成了货物。同样的一个东西,由于业务领域的不同,赋予了这些术语不同的涵义和职责边界,这个边界就可能会成为未来微服务设计的边界。看到这,我想你应该非常清楚了,领域边界就是通过限界上下文来定义的

1747229827171.png

案例二

在电商系统中,订单(Order)数据是核心业务数据,通常包含如下图所示的订单属性:

figur12130058.jpg

那么,如何设计Order这个领域对象呢?基于复用思想的Order对象设计如下图所示:

asdasdasdad231421io.png

上图展示了4个业务模块,而这些业务模块都依赖Order对象,即所有业务模块共同复用这个Order对象。从面向对象的角度来看,这似乎是合理的,但从DDD的角度来看,复用一个类将导致多个业务模块耦合。随着需求不断变化,这些业务模块的边界将会变得越来越模糊。Order对象的合理设计如下图所示:

figureasdasdasd132.jpg

在上图中,我们将原本的4个业务模块转换为对应的限界上下文。这些限界上下文对Order对象的依赖并不是复用,而是在通用Order对象的基础上添加自身所需的数据属性,从而形成自身独有的Order对象。然后这些Order对象都会基于OrderId这个全局唯一标识来与通用Order对象进行关联。通过这种方式,每一个限界上下文都是一个自治单元

3.三大用途

定义业务边界

限界上下文内部具有清晰的业务概念,流程和规则,可以帮助避免不同上下文之间的功能重叠和责任模糊

确定领域模型的生效范围

领域模型只能在各自的限界上下文内使用。限界上下文之间的领域模型重用是很危险的,应该尽量避免

治理业务复杂性

通过限界上下文的划分,业务系统被拆分成多个限界上下文,将原本庞大而复杂的业务分割为简单而概念完整的小业务,从而实现分而治之的目的。原本复杂且难以维护的业务操作,变成由多个限界上下文协作完成,降低了系统整体的复杂性

4.三大特点

独立的语境(独立模型)

独立语境这个词比较抽象,我们将通过几个示例来分析它背后的设计思想。下图是软件工程大师Martin Fowler介绍DDD时所采用的素材,展示了营销场景下两个限界上下文之间的关系:

figurex6.jpg

可以看到,销售上下文与售后支持上下文这两个限界上下文都需要客户(Customer)与商品(Product)信息,但它们对客户与商品的关注点是不同的。销售上下文可能需要了解客户的性别、年龄与职业,以便更好地制定推销策略。而售后支持上下文则不必关心这些信息,只需要知道客户的住址与联系方式。从业务语境上说,这属于概念相同但用法不同的典型场景,上图清晰地展示了为两个不同限界上下文分别建立独自的Customer与Product领域模型对象,而不是重用这两个对象

我们回到熟悉的电商业务场景。下图展示了4个限界上下文中对于商品这个概念所依赖的不同数据属性:

figure1110128.jpg

不难看出,同一个概念在不同的上下文中具有不同的关注点,这也是独立语境的一种表现形式

受控的边界

figureasdasd10129.jpg

在上图中,我们采用纵向拆分的方式对互联网医院系统进行了拆分,并基于不同的业务场景提炼出医生子系统、就诊子系统及患者子系统3个子系统。这些子系统都具备明确的边界,因此都可以提取为独立的限界上下文

自治的单元

每个限界上下文都应该是一个自治的单元。业界关于限界上下文的自治性存在一定的评判标准,即最小完备、稳定空间、自我履行及独立进化。在此基础上可以进一步提取限界上下文的自治效果图,如下图所示:

figure110130.jpg

在上图中,我们通过独立的语境确保上下文能够实现最小完备;通过抽象实现上下文的稳定空间;通过纵向切分实现上下文的自我履行能力,并通过封装确保实现上下文的独立进化能力

5.识别限界上下文(划分限界上下文)

介绍

在实施DDD的过程中,识别限界上下文是一大难点,但也并非无章可循。在本节中,我们将分别从业务维度、工作维度及技术维度展开介绍,讨论有效识别限界上下文的方法和技巧

从业务边界划分

基于业务边界划分限界上下文是最常见的方法。我们可以将整个系统按照不同的业务功能划分为若干个上下文,每个上下文都有自己的职责和目标。例如,在一个电商系统中,可以将订单,商品,用户等子业务划分为不同的上下文。这种方法的优点是简单明了,易于理解和实现

从工作维度划分

从工作维度识别限界上下文跟团队工作相关,是一类依托于管理理念的限界上下文识别方法。DDD实施过程所崇尚的团队是特征团队。正常情况下,一个特征团队可以同时应对多个限界上下文的开发需求。因此,判断限界上下文识别是否合理的标准就是:一个特征团队是否能够同时开发若干个限界上下文

从工作维度来说,如果一个特征团队无法同时开发若干个限界上下文,则说明这些限界上下文之间存在较强的依赖关系,边界的拆分不够合理,需要进一步识别限界上下文

从技术维度划分

从技术维度识别限界上下文是最符合技术人员思维方式的,通过一个示例就可以让读者掌握这种识别方法。例如,在电商系统中,我们基于业务维度或工作维度已经成功提取了若干限界上下文,如下图所示:

2131231241asdasio.png

现在,我们明确系统应考虑质量需求,即系统需要应对高并发场景下的商品查询需求。从技术维度出发,我们可以专门提炼一个实现多元化搜索功能的限界上下文,如图所示:

asdasd213124rawio.png

接着,我们进一步明确系统应考虑架构需求,即为了实现技术复用,系统需要在商品、支付、搜索等功能中根据用户画像实现千人千面的商品推荐。显然,从技术维度出发,我们也可以专门提炼一个实现推荐功能的限界上下文,如下图所示:

asdas1111dio.png

正如前面示例所展示的,基于技术维度的识别方法往往在业务维度和工作维度之后应用,这也符合正常的系统建模顺序,先提炼业务需求,再梳理技术需求

6.映射

介绍

多个限界上下文之间需要协作才能完成功能,那么如何进行协作呢,又有什么关系呢,这就是映射(Context Mapping)

DDD认为每一个限界上下文都不是独立存在的,多数情况下,多个限界上下文通力协作才能完成一个完整业务场景,而上下文映射(Context Mapping)可以使限界上下文中的边界变得更加清晰和可控

什么是上下文映射?每个限界上下文都有一套自己的语言,如果在某个限界上下文中使用其他限界上下文中的概念,就需要一个翻译器。这个 翻译器在不同领域之间进行概念转化、信息传递的动作称为上下文映射

案例理解

我们可以通过一个具体的示例来展示上下文映射的场景和需求,如下图所示:

figure1210137.jpg

上图展示了一个购买商品的完整交互流程,可以看到,这里通过单向箭头展示了不同限界上下文之间的调用,但在每条线上都标注了一个问号,表示需要明确这种调用的具体关系。这引出了接下来要讨论的一个话题——调用的上下游关系。在DDD中,限界上下文的上下游关系如下图所示:

1748761069488.png

在上图中,U代表Upstream,即上游,而D代表Downstream,即下游。其中上游上下文作用于下游上下文,而下游上下文则依赖上游上下文。我们可以通过上下文之间的依赖关系简单判断上下游关系。下图展示了订单上下文和商品上下文之间的上下游关系

1748761142699.png

从依赖关系上说,由于订单上下文需要调用商品上下文以获取订单中商品的详细信息,因此订单上下文依赖商品上下文。这意味着订单上下文位于下游,而商品上下文位于上游

具体映射模式

介绍

在DDD中,上下文映射包含两大类模式,分别面向管理角度和技术角度,前者包括5种团队协作模式(合作模式、客户–供应商模式、发布者–订阅者模式、分离模式和遵奉者模式),而后者包括4种通信集成模式(防腐层模式、开放主机服务模式、发布语言模式和共享内核模式)

97d4b92feda262c0ef6bd251010fba70.png

合作模式

如果两个限界上下文的团队要么一起成功,要么一起失败,那么他们应建立合作关系。两个团队不仅应该在接口的演化上进行合作以同时满足两个系统的需求,而且应该为相互关联的软件功能制订好计划表,以确保这些功能在同一个发布中完成

显然,合作模式是一种反模式,造成这种模式的根本原因是循环依赖,即两个限界上下文中包含对方所依赖的部分,如下图所示:

1748761395333.png

打破循环依赖的基本策略有两种——上移和下移,它们的实现效果如下图所示:

1748761437014.png

可以看到,在存在循环依赖关系的两个上下文中抽象出共同依赖部分,并把这些依赖部分进行上移或下移就可以消除循环依赖。这些策略在日常代码开发中也非常实用

客户–供应商模式

当两个团队处于一种上下游关系时,上游团队的计划中应该顾及下游团队的需求。这是现实中最为常见的一种团队协作模式。我们将提供服务的一方称为上游,使用服务的一方称为下游,而这种关系则称为客户–供应商(Customer-Supplier,C-S)关系

下图展示的就是一种常见的客户–供应商模式示例,来自软件开发场景:

1748761603827.png

使用服务的下游团队向提供服务的上游团队提出领域需求,并确定测试策略。而上游团队则明确采用的协议和调用方式,并承诺需求的交互日期。当上游团队无法按时交付需求时则应通知下游团队。客户–供应商关系符合我们对于软件开发场景的基本认知

发布者–订阅者模式

发布者–订阅者(Publisher-Subscriber,P-S或Pub-Sub)模式也是一种常见的上下文映射方式。这个模式并不包含在Eric Evans提出的上下文映射模式中,但随着领域事件概念的提出,发布者–订阅者模式也被普遍用于处理限界上下文之间的协作关系

发布者–订阅者模式与客户–供应商模式最大的区别在于,发布者–订阅者模式是上游主动发起业务的变化,而不是被动等待下游去调用上游。下图展示了基于发布者–订阅者模式的订单上下文和物流上下文:

1748761731948.png

那么,为什么可以在订单上下文和物流上下文中采用发布者–订阅者模式呢?这是因为订单下单和物流发货之间并不存在严格的时序要求,系统可以在订单下单一段时间之后再启动物流收件和派件。而物流上下文也可以根据物流的具体流转情况通知订单上下文最新的物流信息。由于这两个步骤都可以做成异步,因此非常适合发布者–订阅者模式。在发布者–订阅者模式中,上下文之间的交互媒介是事件

前面我们提到通过上移和下移策略可以消除上下文之间的循环依赖,而发布者–订阅者模式则为人们提供了实现这一目标的第3种实现策略——通过发布者–订阅者模式消除循环依赖,如下图所示:

1748761790630.png

不难看出,相较于客户–供应商模式,发布者–订阅者模式的耦合程度更低,使用也更广泛

分离模式

所谓分离模式(Separate Way),指的是两个限界上下文没有任何关系。没有关系其实就是一种非常好的设计,因为它们可以独立变化,互相影响。下图展示了分离模式的一种示例:

1748761902330.png

在上图中,订单上下文和货币上下文之间属于分离关系,因为两者之间并没有严格意义上的依赖关系。货币可以独立于订单而存在,而订单也不需要直接使用货币

遵奉者模式

我们考虑这样一个问题:当上游上下文不积极响应下游上下文的需求时,下游上下文应该怎么办?通常,有以下3种处理方式

  1. 分离方式:下游上下文切断对上游服务的依赖,自己来实现
  2. 防腐层:复用上游服务,但领域模型由下游团队自己开发,然后用防腐层实现上下游领域模型之间的转换
  3. 遵奉者:严格遵从上游团队的模型,消除复杂的模型转换逻辑

这里出现了两个新的名词——防腐层(Anti-Corruption Layer,ACL)和遵奉者(Conformist)。防腐层是一种通信集成模式,其目的是在上下游之间构建一层适配层,专门用来处理上下游上下文之间的差异,本章后续内容将对其展开介绍。而遵奉者是一种团队协作模式,下游上下文直接遵循上游上下文的数据模型。我们通过一个示例来展示这三者之间的区别。假设系统中存在一个活动上下文和一个财务上下文,它们之间的上下游关系如下图所示:

1748762047331.png

基于上图,我们可以梳理出如下3种处理方式对应的效果:

  1. 分离方式:活动上下文可以自己实现一套财务处理模型
  2. 防腐层:活动上下文复用财务上下文的财务占用、扣减等服务,防腐层将财务上下文返回的数据转换为活动上下文内部的数据模型
  3. 遵奉者:位于下游的活动上下文将完全遵循财务上下文的模型

在存在上下游关系的两个团队中,如果上游团队已经没有能力提供下游团队之所需,下游团队便会孤立无援,只能盲目使用上游团队的模型,这就是遵奉者的概念。本质上,遵奉者就是一种妥协,也是一种反模式

显然,一旦采用遵奉者模式,一方面,下游上下文可以直接复用上游上下文的模型,减少了两个上下文之间模型的转换成本,这是它的优势;另一方面,不可避免地,遵奉者模式也导致下游上下文对上游上下文产生了模型上的强依赖

防腐层

防腐层是应对上游服务变化的利器,尤其是当下游上下文有多个地方依赖某一个上游上下文时,一旦上游上下文发生变化,下游上下文如果不做防腐处理,就会面临大面积的修改

当上游上下文中存在多个下游上下文时,如果都需要隔离变化,那么每个下游上下文都应实现防腐层,成本比较大。此时可以考虑单独抽取一个只有防腐层功能的限界上下文,避免代码重复。例如,为了让订单上下文和售后上下文都能够使用第三方支付功能,我们专门实现了一个支付防腐层上下文,如下图所示:

1748762180829.png

防腐层是应对遗留系统改造的基本模式,基本思想是在软件设计过程中,如果遇到问题无法解决,不妨先考虑添加一层。下图展示了基于防腐层将遗留系统改造为新系统的过程:

1748762223875.png

开放主机服务

开放主机服务(Open Host Service,OHS)指的是上游提供一些公开的服务,暴露它们的通信方式和数据格式,并且承诺这些服务不会轻易做出变化。也就是说,OHS定义了一种协议,让其他上下文通过该协议来访问上游的服务。和防腐层不同,OHS是上游服务吸引更多下游调用者的诱饵

请注意,防腐层的实现位于下游限界上下文,而开放主机服务则位于上游限界上下文,两者往往组合使用,如下图所示:

1748772535831.png

那么,如果必须选择,你更倾向于使用防腐层还是开放主机服务呢?事实上,上游上下文作为被依赖方往往会被多个下游上下文消费,如果引入防腐层模式意味着需要为每个下游上下文都开发一个几乎完全一样的防腐层,这将导致“重复造轮子”。因此,如果上下游上下文都在开发团队内部,又或者两者之间建立了良好的团队协作,笔者更倾向于在上游上下文中定义开放主机服务

发布语言模式

发布语言(Published Language,PL)模式通常和开放主机服务模式配合使用,主要用于实现两个限界上下文之间的模型转换,确保在两个限界上下文之间存在一种共享的公用的语言

请注意,防腐层和开放主机服务操作的对象都不应该是各自的领域模型对象。因此,防腐层和开放主机服务之间的操作需要在发布语言中进行一一映射。下图展示了领域模型中的对象和发布语言中的消息契约之间的区别:

1748794779137.png

可以通过一个示例来理解发布语言的作用和效果。例如,订单上下文需要调用支付上下文,那么可以专门提炼调用过程的输入和输出为InvokingPaymentRequest和PaymentExecutingResponse对象,如下图所示:

1748794836207.png

显然,InvokingPaymentRequest和PaymentExecutingResponse都不是订单上下文和支付上下文中的领域模型对象,而更像是一种数据传输对象(Data TransferObject,DTO)组件

共享内核模式

所谓共享内核,指的是一个限界上下文将自己的领域模型暴露出去供其他限界上下文使用。共享内核不能像其他限界上下文那样自由地更改,但共享内核也会造成耦合。因此应该只将那些非常稳定且具有复用价值的领域模型封装到共享内核上下文中

共享内核对模型和代码的共享将产生一种紧密的依赖性。我们需要为模型共享的部分指定一个显式的边界,并保持共享内核的小型化。共享内核具有特殊的状态,在未与另一个团队协商的情况下,这种状态是不能改变的

共享内核能够节约研发成本和提升研发效率,有效地防止多个团队重复“造轮子”​。共享上下文往往会在后续的系统架构演变过程中被抽象为平台服务,即一种特定类型的支撑子域,并指定某一研发团队专门负责其推进和维护

请注意,共享内核通常以库的形式(如Java的JAR包)被其他限界上下文复用,本身不提供远程服务。共享内核也可以看作一种折中方案和反模式,建议慎用

影响上下文映射的考量点

介绍

当我们考虑应该如何设计上下文之间的映射关系时,有一些考量点会影响甚至决定上下文映射的结果。本节将围绕这一话题展开讨论

领域行为产生的依赖

领域行为确定了上下文映射的结果。领域行为产生的依赖主要涉及如下两点:

  1. 职责由谁来履行?这意味着通过领域行为可以确定限界上下文
  2. 谁发起对该职责的调用?这意味着通过协作关系的限界上下文可以确定上下游关系

这两点有点抽象,接下来将通过一个具体的示例展开介绍。假设存在一个用户提交订单的业务场景,那么用户这个概念是否应该包含在订单上下文中呢?答案显然是否定的。“由用户发起调用”仅代表用户通过用户界面发起对后端服务的请求。限界上下文的边界并不包含前端的用户界面,不能让前端承担本由后端封装的业务逻辑。领域模型是排除参与者在外的客观模型,作为参与者的用户应该被排除在这个模型之外。下图展示了订单上下文与用户的交互过程:

1748795535345.png

领域模型产生的依赖

相比领域行为产生的依赖,领域模型产生的依赖更加难以把握。我们同样来看一个示例。在电商系统中,当设计“根据用户获取订单列表”功能时,订单和用户之间存在下图所示的数据模型:

1748795568544.png

针对这一场景,方案一是在Customer对象中内嵌一组Order对象,如下图所示:

1748795592392.png

方案二是在Order对象中包含对Customer对象的引用,如下图所示:

1748795686067.png

那么应该选择哪种方案呢?可以注意到,在方案一中Customer对象中内嵌的是整个Order对象,而方案二中Order对象包含的是Customer对象的ID。显然,第二种方案优于第一种方案,因为针对某一个对象ID的引用并不会导致对这个对象本身的依赖。这就是领域模型依赖的一种处理方式

如果我们将讨论的范围进一步扩大,还可以探讨“重用”和“分离”这两种针对领域模型依赖的处理策略。我们同样以电商系统中常见的“订单上下文查询订单时如何获取订单中商品信息”这一场景进行切入。如果我们采用“重用”策略,就意味着在订单上下文中会复用商品上下文中的领域模型,即两个限界上下文之间采用遵奉者模式,商品上下文作为上游。而如果我们采用“分离”策略,就意味着需要在订单上下文中定义属于自己的、与商品相关的领域模型

通过分析,可以发现“重用”策略和“分离”策略各有优劣势。下表对此做了总结:

1748795734679.png

在DDD设计理念中,我们倾向于使用“分离”策略而非“重用”策略。我们为两个不同限界上下文分别创建独立的领域对象,而非领域模型的重用

数据产生的依赖

数据产生的依赖主要分为两种情况。第一种情况是数据存放在一处,领域模型仅仅是内存对象。例如,在订单上下文的内存中完成对来自用户上下文的用户对象的转换,订单上下文本身不保存用户数据。第二种情况是数据在各个限界上下文中分散存储,用唯一ID进行关联。例如,用户信息在各个限界上下文中表现为不同的属性,且分别进行了存储。对于这两种数据依赖情况的处理,我们在前面的内容中实际上已经明确了解决方案,即推荐采用第二种情况下的处理方案,这里不再展开介绍

关于数据依赖,需要特别讨论的点在于OLTP和OLAP这两个概念。联机事务处理(Online Transaction Processing,OLTP)主要用于处理企业级的常规业务操作,强调数据的精确、事务的原子性和并发性;而联机分析处理(Online AnalyticalProcessing,OLAP)则使用多维数据分析技术和聚合算法,可以将大量数据划分为各种不同的角度,方便开展数据分析

在日常开发中,限界上下文的提炼和映射主要面向OLTP场景,这也是业务系统开发的主要场景。但有些时候,我们需要实现类似推荐上下文这样的面向OLAP场景的限界上下文,如下图所示:

1748795793221.png

在上图中,推荐上下文综合应用商品上下文、订单上下文和用户上下文中的业务数据,并构建了独立的推荐能力。推荐上下文和其他3个上下文之间不存在上下游关系。也就是说,在OLAP场景下,上下文之间不应该存在上下游关系。这是DDD上下文映射的一条规则