深圳幻海软件技术有限公司 欢迎您!

战略设计之上下文映射和系统分层架构

2023-02-28

在完成了限界上下文的识别(也就是系统“最粗粒度”的模块划分)后,我们需要对这些上下文之间的协作关系进行分析——即“限界上下文关系映射”。也只有在完成上下文关系映射后,我们才能真正的判定自己所做出的“限界上下文识别”是否真的达到了自己想要的“低耦合、高内聚”的目标。因为,通过“限界上下文映射”我们就能

在完成了限界上下文的识别(也就是系统“最粗粒度”的模块划分)后,我们需要对这些上下文之间的协作关系进行分析——即“限界上下文关系映射”。也只有在完成上下文关系映射后,我们才能真正的判定自己所做出的“限界上下文识别”是否真的达到了自己想要的“低耦合、高内聚”的目标。因为,通过“限界上下文映射”我们就能够看到:

  • 这些上下文之间有哪些协作关系?
  • 这些关系是强关联还是弱关联?

关于“限界上下文识别”和“限界上下文关系映射”,我认为这是 DDD 战略设计中最重要的部分,甚至可以说:这两个工作将决定了微服务切分是否有效的关键因素!

但是,肯定会有人说:限界上下文不用 DDD,我凭直觉就能识别出来。我的回答是:是的,你貌似可以!但更重要的是限界上下文的关系映射,这将决定做微服务拆分后、这些微服务之间是怎样做到“高内聚、低耦合”的。如果你不用 DDD 战略设计,我可以很负责任的告诉你:你将来的“微服务”之间的调用关系一定会变得无法控制!

在我实际工作中接触的某大型国企 IT 系统中,所谓业务中台上千万行代码,部署在十多个微服务中心,而 80%以上的外围接口调用、或前端界面服务请求,都要从十个以上的微服务中心全部走一遍!这是不是很可怕的灾难?系统的高可用性、业务需求的快速响应还有什么保障可言?

所以说:掌握好 DDD 战略设计,几乎是做好微服务设计必不可少的前提!不懂得 DDD,你做的“微服务拆分”可能还不如不拆分,单体应用可能更适合你!

在本节内容中,除了搞定限界上下文的映射之外,我还将对系统分层架构进行设计,并在最终给出项目组可直接用于开发的代码框架结构。

需要特别说明的是:在写到这篇的过程中,我反复和多位业界大拿请教,大家普遍认为我前面第三篇中业务子域的分类上“核心子域”太多了。我经过再三考虑,将“核心子域”进行了调整,这将影响到本篇关于系统分层结构的设计,您可以跳回到第三篇重新看下“业务子域识别与分类”这一小段。这里再次重申下:您在阅读中发现我的任何不妥之处 ,可随时与我交流,我发现不妥之处一定认真思考改进,感谢您的支持!

限界上下文映射

1.跨上下文用例识别

为了识别限界上下文之间的映射关系,我们需要对跨上下文的业务用例(也叫业务服务),从架构设计的技术视角绘制服务序列图,进而识别它们之间的映射关系。

首先,第一步,我们识别出所有的跨限界上下文的业务用例。在识别某个业务用例是否跨限界上下文时,一定要注意两个基本事实:1)其实大部分业务用例都是从“群买菜”小程序前端界面发起的,前端与服务端的交互,不算跨限界上下文;2)限界上下文提供的是服务端的业务服务,跨限界上下文一定要是服务端逻辑的相互关系。

关于如何识别“服务端的跨限界上下文”业务逻辑,我认为需要逐个分析前面罗列的所有业务用例,从如下两个角度筛选:

初步分析业务用例内部的逻辑,看是否需要多个上下文来承担职责。如果是,则需将该用例纳入分析范围;

分析业务用例图中的被包含的“子用例”,看是否存在上下文包含了被归类到别的上下文的情况。如果是,则需将该用例纳入分析范围;为此,识别结果如下图罗列:

需要说明的是:有几个用例看起来好像是跨上下文的,其实不是。分别说明如下:

  • “确认购买并付款”、“创建订单预支付”、“完成订单支付”、“补收客户货款”、“退客户货款”,这几个用例其实是订单上下文和支付系统之间的关系。考虑到我们的系统上下文设计,支付系统其实不是我们目标系统的工作范围,故这里不纳入范围。
  • “后台查询店铺商品”、“后台浏览店铺订单列表”、“管理店铺客户信息”、“管理店铺员工”、“开通店铺加盟并设置分成政策”、“添加品牌店铺到加盟列表”,看起来每个用例的名称上都涉及到 2 个限界上下文、并都跟“店铺”上下文发生关系,其实它们只是需要店铺 ID,并没有与店铺上下文发现任何业务交互,故实际上不作为“跨上下文”的用例来对待。

其次,这些业务用例中,从跨上下文协作关系角度来看,其中有些用例画服务序列图是会出现重复的,我们需要识别出来:

  •  “创建新店铺”、“编辑店铺信息”,会用到短信验证码验证手机号,故都属于跨店铺、平台集成两个限界上下文的,且交互逻辑一致,故重复。
  • “加商品到购物车”、“选购接龙商品到购物车”,其实都是涉及到订单和商品两个限界上下文的相同交互关系,故重复。
  • “创建接龙活动”、“编辑接龙活动”,这两个业务用例的操作序列,从跨上下文协作关系角度来看也是完全一样的,只是后者需加载原有“接龙”上下文内部已持久化的信息、前者不用。
  •  “创建付款订单”是“确认接龙付款”的子用例,故只需要保留“确认接龙付款”即可。
  •  “添加品牌店铺到加盟列表”、“从加盟列表删除品牌店铺”,这两个业务用例都是涉及到店铺和加盟两个上下文的关系,并且都是获取店铺相关信息、并将店铺信息传输给加盟上下文处理,故重复。

最终,我们确定下来,被用来绘制服务序列图,进而确定各上下文协作关系的业务用例罗列如下图:

2.基于跨上下文用例映射上下文关系

我们接下来的工作,就是要对这些业务用例,逐个进行技术分析,设计出服务序列图,然后根据服务序列图确定限界上下文的关系映射。

创建新店铺

创建新店铺涉及到手机号短信验证,故需要跨店铺和平台集成两个上下文。服务序列图如下:

基于该服务序列图识别出“店铺”和“平台集成”上下文关系如图(C 表示服务调用客户端、S 表示被调用服务端,以下同。DDD 标准方法中一般用“上下游关系”、及诸如“开发主机服务 OHS”等方式表达,但我认为那是废话,并没有清晰的表达强弱关联,故不在这里采用):

初始化店铺默认选项

根据产品 UI 原型的设计方案,新店铺创建后,系统机器人需要基于异步事件,对店铺的相关选项立即进行初始化:

  • 店铺默认门头图片(UI 允许创建人未设置门头图片);
  • 店铺是否开通订单短信提醒(随着业务发展可能会调整,故不放在创建店铺时设定);
  • 店铺的第一个员工(就是创建人自己);
  • 店铺默认的加盟分销政策(随着业务发展可能会调整,故不放在创建店铺时设定);
  • 店铺的首个接龙地点(详见产品 UI 原型,店铺接龙支持多个地点,但首个地点就是店铺地址);
  • 创建商家及商家账户(如果是创建人的第一家店铺);
  • 店铺默认商品分类(系统规则会按季节调整);
  • 店铺默认的商品搜索热词(系统后台会不定期根据某种策略更新);

这些默认选项,涉及到“店铺”、“员工”、“商品”、“接龙”、“商家账户”、“鉴权”共 6 个上下文的协作。服务序列图涉及如下:

根据该服务序列图,我们得出这 6 个限界上下文的协作关系如下图:

从上图的上下文依赖关系中,看到店铺对员工、商品、接龙、商家账户 4 个上下文产生了调用关系依赖,这是不合理的。因为从业务角度来说,其实是后 4 者依赖于店铺的存在而存在,而不是反过来。为此,我们做这样的调整:

  • 采用消息发布者/订阅者模式,让后 4 者依赖店铺上下文发布的“店铺已创建”消息;
  • 去掉“接龙”上下文对店铺首个接龙地点初始化的逻辑。本质上,仔细分析产品 UI 界面设计的要去,我们发现这其实是“群买菜小程序”在展示创建接龙活动的前端界面时,需调用“店铺”上下文服务来获取的信息:如果所选择店铺已经有接龙地址,则返回已有的接龙地址列表供选择;如果该店铺尚无首个接龙地址,则返回店铺地址作为默认接龙地址。

经过修改后的服务序列图如下:

根据该服务序列图,我们修改上下文的协作关系如下图(P 表示消息发布者,S 表示消息订阅者,下同):

加商品到购物车

客户浏览店铺商品,选中感兴趣的商品到购物车,以便后续的购买结算。考虑到添加商品到购物车中去的“时间”特殊性:我们需要在客户将商品加入到购物车时,为商品创建“快照”,以避免商品信息在后面被编辑修改(比如改了图片或描述、尤其是价格)时,影响到对客户的购买承诺。

为此,该业务用例(服务)就涉及到“订单”和“商品”两个上下文,服务序列图设计如下:

该序列图展示出订单和商品的上下文关系如图:

发送订单提醒

“发送订单提醒”需要在订单上下文中发起、“通知”上下文中发送通知,故涉及“订单”和“通知”两个上下文。该用例服务序列图如下:

考虑到订单消息提醒是没有副作用的、而且也不需要保证必须成功,故采用消息发布者/订阅者模式比较合适。也就是得到如下所示的“订单”和“通知”上下文的映射关系:

创建接龙活动

与“加商品到购物车”用例类似,商家在创建接龙活动、添加商品到接龙中去时也存在“时间”特殊性,需要为商品创建“快照”,以避免商品在后面被编辑修改(比如改了图片或描述、尤其是价格)时,影响到接龙活动的开展。为此,该业务用例(服务)就涉及到“接龙”和“商品”两个上下文,服务序列图设计如下:

该序列图展示出接龙和商品的上下文关系如图:

浏览我的接龙

按照产品 UI 设计文档,接龙只能在店铺下存在。而“浏览我的接龙”实际上是“浏览我被授权操作的所有店铺发布的、或其被授权店铺所加盟品牌店铺发布的、或我参与的”的所有接龙。故该用例涉及到“接龙”、“店铺”两个上下文,服务序列图如下:

该服务序列图展示出,实际上“接龙”、“店铺”这 2 个上下文没有发生关联关系。但这个服务序列图设计,有个“坏味道”的感觉:让群买菜小程序客户端承担了过多业务逻辑,这是不合理的。于是,我们将服务序列图调整为如下:

该服务序列图会导致如下的 2 个上下文之间的关系:

确认接龙付款

确认接龙付款从产品界面原型可以看出,确认接龙付款是从“查看接龙详情”界面发起的。客户在该界面上点击相应的商品加入购物车、或从购物车移除,然后点击“我要接龙”按钮进入该用例。

该用例允许用户设置提货方式、提货时间、联系人等信息后,点击“确认付款”按钮完成支付。该按钮点击后,按照产品原型设计,需要完成如下任务:

  • 创建订单;
  • 更新关联商品的销量,以便于后续商品列表和详情页面显示商品销量;
  • 如果下单客户首次购买对应店铺商品,则为该店铺初始化对应客户资料,以便于商家后续维护客户资料;
  • 记录客户参与了该接龙,以便于客户“浏览我的接龙”时,可包含该接龙;

根据上面的逻辑,我们画出服务序列图设计如下:

该服务序列图展示的相关限界上下文关系如下图:

这里可以看到上下文之间的调用关系比较多,并且“订单”与“商品”、“订单”与“客户”之间,还存在数据一致性的要求,不利于系统的“松耦合”。为了降低上下文之间的耦合性,我们分析业务需求发现:其实“订单创建”后“增加商品销量”、以及“为指定店铺初始化客户”是可以有一定的数据延迟的,并不需要通过“强”服务调用关系保障严格的数据一致性。为此,我们将服务序列图修改如下:

根据改进后的服务序列图,可以调整上下文映射关系如下图:

结算订单收入

结算订单收入分为两步:第一步,在客户确认订单完成、或机器人超时自动确认订单完成时,订单上下文通知商家账户上下文记录待结算的订单 ID(由于订单上下文缺乏“判断什么订单属于待结算状态”这样的领域知识,故只能由商家账户上下文来记录);第二步,系统机器人定时触发商家账户上下文对待结算订单进行收入结算。服务序列图设计如下:

其中第一步的操作,可以和“发送订单提醒”中的“订单已完成”领域事件合并处理。不过,因为这里是不可丢失的领域事件,所以这就要求采用“可靠事件模式”。基于该服务序列图,识别出“订单”和“商家账户”上下文的关系如图:

结算佣金收入

结算佣金收入可以和结算订单收入同时进行,也可以分开在两个用例中异步执行。考虑到为了将来支持允许品牌商在加盟政策中设置结算周期,故将其分开在两个业务用例异步执行。为此,设计其服务序列图如下:

该序列图展示出商家账户和订单的上下文关系如图:

3.限界上下文映射图

我们将上面针对各跨上下文业务用例分析后,得到的上下文映射关系进行汇总后最终得出下图:

图中实线是服务调用关系,属于“强关系”;虚线是消息通知关系,属于“弱关系”。

通过该限界上线文映射关系可以看出:我们总共有 9 个限界上下文,其中有强依赖关系的涉及到 9 个、强依赖关系链有 9 条。分别是:接龙依赖于店铺、商品和订单,店铺依赖于平台集成,订单依赖于商品,商家账户、员工、客户依赖于鉴权,商家账户依赖于订单。9 个上下文,理论上最多有 72 条依赖链条,我们分析汇总后只有 11 条,已经是很好的设计。

基于这样的综合评估,我们认为目前的设计已经到了“可接受程度”,暂时不再做更多的优化和调整了,以避免“过度设计”。

系统架构和代码框架

1.业务子域与上下文的映射

完成了限界上下文的关系映射,其实就有了对整体系统架构进行分层设计的基础。系统整体架构设计从分层角度来看,包括:边缘层、业务价值层、基础层。边缘层一般都是各种针对前端 UI 的控制器,业务价值层和基础层包含所有的限界上下文,其中基础层放的是对应到支撑子域、通用子域的限界上下文,而核心子域对应的限界上下文作为业务价值层。

为了区分业务价值层和基础层,我们先将前面得出的业务子域(见第三篇中全局分析内容)、与限界上下文进行如下表所示的关系映射:

需要说明的是:其中“商家管理”和“店铺管理”虽然是支撑子域,但由于分别被合并到“商家账户”、“店铺”上下文来开发实现,故其中逻辑的代码实现也被划分到了“业务价值层”。

2.菱形对称架构简介

在对整个系统进行“分层架构设计”之前,我们还需要先熟悉一个软件架构模式——菱形对称架构(以下简称菱形架构),如下图所示:

对“菱形架构”的说明如下:

菱形架构的基础,是依赖于“领域层”的界定。这涉及到后面才会进行的 DDD 战术设计内容,这里不做展开,您只需要知道:“领域层”放的是业务领域的关键业务知识,包括“聚合(内含实体对象、值对象)”和“领域服务”两类内容。事实上,我们所追求的“高内聚”主要体现的“领域层”,因为业务需求变化而引起的变化,我也希望主要通过“领域层”的“聚合”和“领域服务”的变化来实现。所以说,“领域层”是 DDD 的“核心代码所在地”。

所有非“领域逻辑”层的代码,我们都希望封装到“北向网关”或“南向网关”中去。具体说明如下:

“北向网关”其实就是上下文向外提供服务、或接受消息通知的入口处。如果上下文只需要向外提供同一个进程内部的应用服务调用接口、或接受消息通知(通过消息总线),则需要“本地”服务;如果上下文需要作为独立进程(这时候一般是云原生的独立“微服务”)向外输出服务、或接受消息通知(通过消息中间件),则需要将“本地服务”包装为“远程”服务。

也就是说:北向网关中的“本地服务”和“远程服务”是一一对应的,“远程服务”只负责将远程调用的出入参做格式转换并传给“本地服务”,而“本地服务”负责调用领域层的“聚合”或“领域服务”来完成具体的业务逻辑(关于“聚合”和“领域服务”的内容,我会在本专题后面的章节中演示)。

“南向网关”其实就是上下文用来将这 3 类技术细节从业务逻辑中“剥离”出来的主要手段(图中只画了第一种):访问底层数据库、调用其它上下文服务、向其它上下文发布消息通知。这 3 个技术细节的封装,在 DDD 战术设计中分别叫“资源库”、“客户端(也可以叫防腐层——ACL)”、“消息发布者”。

从图中可以看出,“南向网关”有“端口”和“适配器”两种角色。简单点来说,“端口”是抽象接口(以 java 为例就是 interface),而“适配器”则是“接口实现”(以 java 为例就是实现了对应 interface 的类)。并且,一般来说“适配器”都是通过依赖注入(DI,控制反转 IoC 的一种)的方式集成到限界上下文的代码中(java spring 中一般是 bean 注入)。

“南向网关”区分“端口”和“适配器”两个角色的好处:一方面是可以让限界上下文内的任何代码都不直接依赖于具体的底层技术细节,如:采用哪种数据库(oracle 还是 mysql、甚至 nosql 数据库)、怎么调用其它上下文服务(本地调用还是远程 RPC)、怎么发布消息通知(本地消息总线、还是消息中间件);另一方面的好处是允许我们随时将构建在一个“微服务”甚至“单体应用”中的多个限界上下文进行拆分到多个进程(即多个“微服务”中),而并不会引起“领域层”、以及“北向网关”的任何代码修改(只要替换并重新打包被依赖注入的“适配器”类即可)。

很明显可以看出,“菱形架构”其实是限界上下文内部所采用的一个“软件架构模式”。而在整个系统范围内,因为包含多个限界上下文,DDD 设计理念并没有要求所有的上下文都严格遵循“菱形架构”——而完全可以根据实际需要(尤其是“基础层”的上下文),视情况而采用其它架构模式(如 MVC 三层架构、大数据计算架构等)。

3.系统分层架构图

有了前面的将上下文分为“基础层”和“业务价值层”、以及菱形架构概念的基础,再考虑到“群买菜”系统前端界面针对 3 类用户:商家、客户、平台运营,前两者使用微信小程序,最后一个使用 PC 端,以及相应“伴生系统”的协作边界。我们最后画出系统架构分层设计图如下:

对于上图,需要特别说明的是:

其中腾讯地图、短信系统、微信公众号因为不涉及到业务流程,只是一个功能点,故前面的业务子域、上下文、业务用例都没有展现。

有的上下文有“事件订阅”。这是根据我们前面“限界上下文映射图”中的描述,需要进行消息订阅的上下文才有。

其中比较特殊的一点:“平台集成上下文”没有“领域层”,这是因为它主要完成对微信公众号接口、短信接口、微信开放平台接口的封装,并不存在 DDD 战术设计所需要的“实体”对象这一“领域层”必须的基本要素。

4.代码框架结构

基于前面的系统分层架构设计,结合菱形架构模式,我们给出目标系统的如下代码框架结构(采用 java spring 框架开发):

对上图中各目录的划分和命名说明如下:

foundation 目录存放的是“基础层”限界上下文,valueadded 目录存放的是“业务价值层”限界上下文,edge 目录存放的是“边缘层”限界上下文;

本质上,每个限界上下文都可以作为独立的 java 工程存在。但因为本项目由我一个人开发,所以就没有划分工程了;

每个限界上下文,采用“上下文名称+context”命名风格。如:“订单上下文”命名为 ordercontext;

每个限界上下文中,其内部目录结构说明如下:

north 目录存放的是“北向网关”的内容,包括 local 和 remote 子目录,分别对应北向网关的“本地服务”和“远程服务”。有的上下文的 north 目录下有 subsrciber 子目录,是为了存放消息订阅者代码的。

south 目录存放的是“南向网关”的内容,包括 port 和 adapter 子目录,分别对应南向网关的“端口”和“适配器”。

domain 目录存放的是“领域层”内容,也就是核心的业务逻辑所在。

pl 目录存放的是“发布语言”,其实就是北向网关的“本地服务”向外提供服务时,允许外部调用服务所使用的出入参对象类型。之所以将出入参对象类专门设定一个目录来管理,是因为这些出入参其实是和 domain 目录下的“实体类”是不同的,我们不希望将“领域层”内部的业务逻辑暴露出去(也就是不将“实体类”的业务逻辑暴露出去)。

业务价值层有个 sharedcontext,这是因为考虑到代码复用、可能会出现某些“值对象”、“发布语言(即服务接口出入参)”类被多个上下文共用,具体细节我在后面的战术设计中会讲到。

对于“群买菜”系统来说,由于小程序前端界面已经存在,本次是服务端做 DDD 改造,故不打算对前后端交互接口进行调整。为此,我为其设计了“边缘层”,也就是 BFF 层。这就是 edge 目录存放的代码内容。