DDD 实战 :限界上下文映射和系统分层架构
Posted 90后小伙追梦之路
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了DDD 实战 :限界上下文映射和系统分层架构相关的知识,希望对你有一定的参考价值。
在完成了限界上下文的识别(也就是系统“最粗粒度”的模块划分)后,我们需要对这些上下文之间的协作关系进行分析——即“限界上下文关系映射”。也只有在完成上下文关系映射后,我们才能真正的判定自己所做出的“限界上下文识别”是否真的达到了自己想要的“低耦合、高内聚”的目标。因为,通过“限界上下文映射”我们就能够看到:1)这些上下文之间有哪些协作关系?2)这些关系是强关联还是弱关联?
关于“限界上下文识别”和“限界上下文关系映射”,我认为这是 DDD 战略设计中最重要的部分,甚至可以说:这两个工作将决定了微服务切分是否有效的关键因素!
但是,肯定会有人说:限界上下文不用 DDD,我凭直觉就能识别出来。我的回答是:是的,你貌似可以!但更重要的是限界上下文的关系映射,这将决定做微服务拆分后、这些微服务之间是怎样做到“高内聚、低耦合”的。如果你不用 DDD 战略设计,我可以很负责任的告诉你:你将来的“微服务”之间的调用关系一定会变得无法控制!
在我实际工作中接触的某大型国企 IT 系统中,所谓业务中台上千万行代码,部署在十多个微服务中心,而 80%以上的外围接口调用、或前端界面服务请求,都要从十个以上的微服务中心全部走一遍!这是不是很可怕的灾难?系统的高可用性、业务需求的快速响应还有什么保障可言?
所以说:掌握好 DDD 战略设计,几乎是做好微服务设计必不可少的前提!不懂得 DDD,你做的“微服务拆分”可能还不如不拆分,单体应用可能更适合你!
在本节内容中,除了搞定限界上下文的映射之外,我还将对系统分层架构进行设计,并在最终给出项目组可直接用于开发的代码框架结构。
需要特别说明的是:在写到这篇的过程中,我反复和多位业界大拿请教,大家普遍认为我前面第三篇中业务子域的分类上“核心子域”太多了。我经过再三考虑,将“核心子域”进行了调整,这将影响到本篇关于系统分层结构的设计,您可以跳回到第三篇重新看下“业务子域识别与分类”这一小段。这里再次重申下:您在阅读中发现我的任何不妥之处 ,可随时与我交流,我发现不妥之处一定认真思考改进,感谢您的支持!
4. 限界上下文映射
4.1 跨上下文用例识别
为了识别限界上下文之间的映射关系,我们需要对跨上下文的业务用例(也叫业务服务),从架构设计的技术视角绘制服务序列图,进而识别它们之间的映射关系。
首先,第一步,我们识别出所有的跨限界上下文的业务用例。在识别某个业务用例是否跨限界上下文时,一定要注意两个基本事实:1)其实大部分业务用例都是从“群买菜”小程序前端界面发起的,前端与服务端的交互,不算跨限界上下文;2)限界上下文提供的是服务端的业务服务,跨限界上下文一定要是服务端逻辑的相互关系。
关于如何识别“服务端的跨限界上下文”业务逻辑,我认为需要逐个分析前面罗列的所有业务用例,从如下两个角度筛选:
-
初步分析业务用例内部的逻辑,看是否需要多个上下文来承担职责。如果是,则需将该用例纳入分析范围;
-
分析业务用例图中的被包含的“子用例”,看是否存在上下文包含了被归类到别的上下文的情况。如果是,则需将该用例纳入分析范围;
为此,识别结果如下图罗列:
需要说明的是:有几个用例看起来好像是跨上下文的,其实不是。分别说明如下:
1. “确认购买并付款”、“创建订单预支付”、“完成订单支付”、“补收客户货款”、“退客户货款”,这几个用例其实是订单上下文和支付系统之间的关系。考虑到我们的系统上下文设计,支付系统其实不是我们目标系统的工作范围,故这里不纳入范围。
2. “后台查询店铺商品”、“后台浏览店铺订单列表”、“管理店铺客户信息”、“管理店铺员工”、“开通店铺加盟并设置分成政策”、“添加品牌店铺到加盟列表”,看起来每个用例的名称上都涉及到 2 个限界上下文、并都跟“店铺”上下文发生关系,其实它们只是需要店铺 ID,并没有与店铺上下文发现任何业务交互,故实际上不作为“跨上下文”的用例来对待。
其次,这些业务用例中,从跨上下文协作关系角度来看,其中有些用例画服务序列图是会出现重复的,我们需要识别出来:
1. “创建新店铺”、“编辑店铺信息”,会用到短信验证码验证手机号,故都属于跨店铺、平台集成两个限界上下文的,且交互逻辑一致,故重复。
2. “加商品到购物车”、“选购接龙商品到购物车”,其实都是涉及到订单和商品两个限界上下文的相同交互关系,故重复。
3. “创建接龙活动”、“编辑接龙活动”,这两个业务用例的操作序列,从跨上下文协作关系角度来看也是完全一样的,只是后者需加载原有“接龙”上下文内部已持久化的信息、前者不用。
4. “创建付款订单”是“确认接龙付款”的子用例,故只需要保留“确认接龙付款”即可。
5. “添加品牌店铺到加盟列表”、“从加盟列表删除品牌店铺”,这两个业务用例都是涉及到店铺和加盟两个上下文的关系,并且都是获取店铺相关信息、并将店铺信息传输给加盟上下文处理,故重复。
最终,我们确定下来,被用来绘制服务序列图,进而确定各上下文协作关系的业务用例罗列如下图:
4.2 基于跨上下文用例映射上下文关系
我们接下来的工作,就是要对这些业务用例,逐个进行技术分析,设计出服务序列图,然后根据服务序列图确定限界上下文的关系映射。
创建新店铺
创建新店铺涉及到手机号短信验证,故需要跨店铺和平台集成两个上下文。服务序列图如下:
基于该服务序列图识别出“店铺”和“平台集成”上下文关系如图(C 表示服务调用客户端、S 表示被调用服务端,以下同。DDD 标准方法中一般用“上下游关系”、及诸如“开发主机服务 OHS”等方式表达,但我认为那是废话,并没有清晰的表达强弱关联,故不在这里采用):
初始化店铺默认选项
根据产品 UI 原型的设计方案,新店铺创建后,系统机器人需要基于异步事件,对店铺的相关选项立即进行初始化:
1. 店铺默认门头图片(UI 允许创建人未设置门头图片);
2. 店铺是否开通订单短信提醒(随着业务发展可能会调整,故不放在创建店铺时设定);
3. 店铺的第一个员工(就是创建人自己);
4. 店铺默认的加盟分销政策(随着业务发展可能会调整,故不放在创建店铺时设定);
5. 店铺的首个接龙地点(详见产品 UI 原型,店铺接龙支持多个地点,但首个地点就是店铺地址);
6. 创建商家及商家账户(如果是创建人的第一家店铺);
7. 店铺默认商品分类(系统规则会按季节调整);
8. 店铺默认的商品搜索热词(系统后台会不定期根据某种策略更新);
这些默认选项,涉及到“店铺”、“员工”、“商品”、“接龙”、“商家账户”、“鉴权”共 6 个上下文的协作。服务序列图涉及如下:
根据该服务序列图,我们得出这 6 个限界上下文的协作关系如下图:
从上图的上下文依赖关系中,看到店铺对员工、商品、接龙、商家账户 4 个上下文产生了调用关系依赖,这是不合理的。因为从业务角度来说,其实是后 4 者依赖于店铺的存在而存在,而不是反过来。为此,我们做这样的调整:
1. 采用消息发布者/订阅者模式,让后 4 者依赖店铺上下文发布的“店铺已创建”消息;
2. 去掉“接龙”上下文对店铺首个接龙地点初始化的逻辑。本质上,仔细分析产品 UI 界面设计的要去,我们发现这其实是“群买菜小程序”在展示创建接龙活动的前端界面时,需调用“店铺”上下文服务来获取的信息:如果所选择店铺已经有接龙地址,则返回已有的接龙地址列表供选择;如果该店铺尚无首个接龙地址,则返回店铺地址作为默认接龙地址。
经过修改后的服务序列图如下:
根据该服务序列图,我们修改上下文的协作关系如下图(P 表示消息发布者,S 表示消息订阅者,下同):
加商品到购物车
客户浏览店铺商品,选中感兴趣的商品到购物车,以便后续的购买结算。考虑到添加商品到购物车中去的“时间”特殊性:我们需要在客户将商品加入到购物车时,为商品创建“快照”,以避免商品信息在后面被编辑修改(比如改了图片或描述、尤其是价格)时,影响到对客户的购买承诺。
为此,该业务用例(服务)就涉及到“订单”和“商品”两个上下文,服务序列图设计如下:
该序列图展示出订单和商品的上下文关系如图:
发送订单提醒
“发送订单提醒”需要在订单上下文中发起、“通知”上下文中发送通知,故涉及“订单”和“通知”两个上下文。该用例服务序列图如下:
考虑到订单消息提醒是没有副作用的、而且也不需要保证必须成功,故采用消息发布者/订阅者模式比较合适。也就是得到如下所示的“订单”和“通知”上下文的映射关系:
创建接龙活动
与“加商品到购物车”用例类似,商家在创建接龙活动、添加商品到接龙中去时也存在“时间”特殊性,需要为商品创建“快照”,以避免商品在后面被编辑修改(比如改了图片或描述、尤其是价格)时,影响到接龙活动的开展。为此,该业务用例(服务)就涉及到“接龙”和“商品”两个上下文,服务序列图设计如下:
该序列图展示出接龙和商品的上下文关系如图:
浏览我的接龙
按照产品 UI 设计文档,接龙只能在店铺下存在。而“浏览我的接龙”实际上是“浏览我被授权操作的所有店铺发布的、或其被授权店铺所加盟品牌店铺发布的、或我参与的”的所有接龙。故该用例涉及到“接龙”、“店铺”两个上下文,服务序列图如下:
该服务序列图展示出,实际上“接龙”、“店铺”这 2 个上下文没有发生关联关系。但这个服务序列图设计,有个“坏味道”的感觉:让群买菜小程序客户端承担了过多业务逻辑,这是不合理的。于是,我们将服务序列图调整为如下:
该服务序列图会导致如下的 2 个上下文之间的关系:
确认接龙付款
确认接龙付款从产品界面原型可以看出,确认接龙付款是从“查看接龙详情”界面发起的。客户在该界面上点击相应的商品加入购物车、或从购物车移除,然后点击“我要接龙”按钮进入该用例。
该用例允许用户设置提货方式、提货时间、联系人等信息后,点击“确认付款”按钮完成支付。该按钮点击后,按照产品原型设计,需要完成如下任务:
-
创建订单;
-
更新关联商品的销量,以便于后续商品列表和详情页面显示商品销量;
-
如果下单客户首次购买对应店铺商品,则为该店铺初始化对应客户资料,以便于商家后续维护客户资料;
-
记录客户参与了该接龙,以便于客户“浏览我的接龙”时,可包含该接龙;
根据上面的逻辑,我们画出服务序列图设计如下:
该服务序列图展示的相关限界上下文关系如下图:
这里可以看到上下文之间的调用关系比较多,并且“订单”与“商品”、“订单”与“客户”之间,还存在数据一致性的要求,不利于系统的“松耦合”。为了降低上下文之间的耦合性,我们分析业务需求发现:其实“订单创建”后“增加商品销量”、以及“为指定店铺初始化客户”是可以有一定的数据延迟的,并不需要通过“强”服务调用关系保障严格的数据一致性。为此,我们将服务序列图修改如下:
根据改进后的服务序列图,可以调整上下文映射关系如下图:
结算订单收入
结算订单收入分为两步:第一步,在客户确认订单完成、或机器人超时自动确认订单完成时,订单上下文通知商家账户上下文记录待结算的订单 ID(由于订单上下文缺乏“判断什么订单属于待结算状态”这样的领域知识,故只能由商家账户上下文来记录);第二步,系统机器人定时触发商家账户上下文对待结算订单进行收入结算。服务序列图设计如下:
其中第一步的操作,可以和“发送订单提醒”中的“订单已完成”领域事件合并处理。不过,因为这里是不可丢失的领域事件,所以这就要求采用“可靠事件模式”。基于该服务序列图,识别出“订单”和“商家账户”上下文的关系如图:
结算佣金收入
结算佣金收入可以和结算订单收入同时进行,也可以分开在两个用例中异步执行。考虑到为了将来支持允许品牌商在加盟政策中设置结算周期,故将其分开在两个业务用例异步执行。为此,设计其服务序列图如下:
该序列图展示出商家账户和订单的上下文关系如图:
4.3 限界上下文映射图
我们将上面针对各跨上下文业务用例分析后,得到的上下文映射关系进行汇总后最终得出下图:
图中实线是服务调用关系,属于“强关系”;虚线是消息通知关系,属于“弱关系”。
通过该限界上线文映射关系可以看出:我们总共有 9 个限界上下文,其中有强依赖关系的涉及到 9 个、强依赖关系链有 9 条。分别是:接龙依赖于店铺、商品和订单,店铺依赖于平台集成,订单依赖于商品,商家账户、员工、客户依赖于鉴权,商家账户依赖于订单。9 个上下文,理论上最多有 72 条依赖链条,我们分析汇总后只有 11 条,已经是很好的设计。
基于这样的综合评估,我们认为目前的设计已经到了“可接受程度”,暂时不再做更多的优化和调整了,以避免“过度设计”。
5. 系统架构和代码框架
5.1 业务子域与上下文的映射
完成了限界上下文的关系映射,其实就有了对整体系统架构进行分层设计的基础。系统整体架构设计从分层角度来看,包括:边缘层、业务价值层、基础层。边缘层一般都是各种针对前端 UI 的控制器,业务价值层和基础层包含所有的限界上下文,其中基础层放的是对应到支撑子域、通用子域的限界上下文,而核心子域对应的限界上下文作为业务价值层。
为了区分业务价值层和基础层,我们先将前面得出的业务子域(见第三篇中全局分析内容)、与限界上下文进行如下表所示的关系映射:
需要说明的是:其中“商家管理”和“店铺管理”虽然是支撑子域,但由于分别被合并到“商家账户”、“店铺”上下文来开发实现,故其中逻辑的代码实现也被划分到了“业务价值层”。
5.2 菱形架构简介
在对整个系统进行“分层架构设计”之前,我们还需要先熟悉一个软件架构模式——菱形架构,如下图所示:
对“菱形架构”的说明如下:
-
菱形架构的基础,是依赖于“领域层”的界定。这涉及到后面才会进行的 DDD 战术设计内容,这里不做展开,您只需要知道:“领域层”放的是业务领域的关键业务知识,包括“聚合(内含实体对象、值对象)”和“领域服务”两类内容。事实上,我们所追求的“高内聚”主要体现的“领域层”,因为业务需求变化而引起的变化,我也希望主要通过“领域层”的“聚合”和“领域服务”的变化来实现。所以说,“领域层”是 DDD 的“核心代码所在地”。
-
所有非“领域逻辑”层的代码,我们都希望封装到“北向网关”或“南向网关”中去。具体说明如下:
-
“北向网关”其实就是上下文向外提供服务、或接受消息通知的入口处。如果上下文只需要向外提供同一个进程内部的应用服务调用接口、或接受消息通知(通过消息总线),则需要“本地”服务;如果上下文需要作为独立进程(这时候一般是云原生的独立“微服务”)向外输出服务、或接受消息通知(通过消息中间件),则需要将“本地服务”包装为“远程”服务。
-
也就是说:北向网关中的“本地服务”和“远程服务”是一一对应的,“远程服务”只负责将远程调用的出入参做格式转换并传给“本地服务”,而“本地服务”负责调用领域层的“聚合”或“领域服务”来完成具体的业务逻辑(关于“聚合”和“领域服务”的内容,我会在本专题后面的章节中演示)。
-
“南向网关”其实就是上下文用来将这 3 类技术细节从业务逻辑中“剥离”出来的主要手段(图中只画了第一种):访问底层数据库、调用其它上下文服务、向其它上下文发布消息通知。这 3 个技术细节的封装,在 DDD 战术设计中分别叫“资源库”、“客户端(也可以叫防腐层——ACL)”、“消息发布者”。
-
从图中可以看出,“南向网关”有“端口”和“适配器”两种角色。简单点来说,“端口”是抽象接口(以 java 为例就是 interface),而“适配器”则是“接口实现”(以 java 为例就是实现了对应 interface 的类)。并且,一般来说“适配器”都是通过依赖注入(DI,控制反转 IoC 的一种)的方式集成到限界上下文的代码中(java spring 中一般是 bean 注入)。
-
“南向网关”区分“端口”和“适配器”两个角色的好处:一方面是可以让限界上下文内的任何代码都不直接依赖于具体的底层技术细节,如:采用哪种数据库(oracle 还是 mysql、甚至 nosql 数据库)、怎么调用其它上下文服务(本地调用还是远程 RPC)、怎么发布消息通知(本地消息总线、还是消息中间件);另一方面的好处是允许我们随时将构建在一个“微服务”甚至“单体应用”中的多个限界上下文进行拆分到多个进程(即多个“微服务”中),而并不会引起“领域层”、以及“北向网关”的任何代码修改(只要替换并重新打包被依赖注入的“适配器”类即可)。
-
很明显可以看出,“菱形架构”其实是限界上下文内部所采用的一个“软件架构模式”。而在整个系统范围内,因为包含多个限界上下文,DDD 设计理念并没有要求所有的上下文都严格遵循“菱形架构”——而完全可以根据实际需要(尤其是“基础层”的上下文),视情况而采用其它架构模式(如 MVC 三层架构、大数据计算架构等)。
5.3 系统分层架构图
有了前面的将上下文分为“基础层”和“业务价值层”、以及菱形架构概念的基础,再考虑到“群买菜”系统前端界面针对 3 类用户:商家、客户、平台运营,前两者使用微信小程序,最后一个使用 PC 端,以及相应“伴生系统”的协作边界。我们最后画出系统架构分层设计图如下:
对于上图,需要特别说明的是:
-
其中腾讯地图、短信系统、微信公众号因为不涉及到业务流程,只是一个功能点,故前面的业务子域、上下文、业务用例都没有展现。
-
有的上下文有“事件订阅”。这是根据我们前面“限界上下文映射图”中的描述,需要进行消息订阅的上下文才有。
-
其中比较特殊的一点:“平台集成上下文”没有“领域层”,这是因为它主要完成对微信公众号接口、短信接口、微信开放平台接口的封装,并不存在 DDD 战术设计所需要的“实体”对象这一“领域层”必须的基本要素。
5.4 代码框架结构
基于前面的系统分层架构设计,结合菱形架构模式,我们给出目标系统的如下代码框架结构(采用 java spring 框架开发):
对上图中各目录的划分和命名说明如下:
需要完整资料的朋友可以:点赞+转发+关注!后台小信封:回复【444】即可获取完整笔记资料!
-
foundation 目录存放的是“基础层”限界上下文,valueadded 目录存放的是“业务价值层”限界上下文,edge 目录存放的是“边缘层”限界上下文;
-
本质上,每个限界上下文都可以作为独立的 java 工程存在。但因为本项目由我一个人开发,所以就没有划分工程了;
-
每个限界上下文,采用“上下文名称+context”命名风格。如:“订单上下文”命名为 ordercontext;
-
每个限界上下文中,其内部目录结构说明如下:
-
north 目录存放的是“北向网关”的内容,包括 local 和 remote 子目录,分别对应北向网关的“本地服务”和“远程服务”。有的上下文的 north 目录下有 subsrciber 子目录,是为了存放消息订阅者代码的。
-
south 目录存放的是“南向网关”的内容,包括 port 和 adapter 子目录,分别对应南向网关的“端口”和“适配器”。
-
domain 目录存放的是“领域层”内容,也就是核心的业务逻辑所在。
-
pl 目录存放的是“发布语言”,其实就是北向网关的“本地服务”向外提供服务时,允许外部调用服务所使用的出入参对象类型。之所以将出入参对象类专门设定一个目录来管理,是因为这些出入参其实是和 domain 目录下的“实体类”是不同的,我们不希望将“领域层”内部的业务逻辑暴露出去(也就是不将“实体类”的业务逻辑暴露出去)。
-
业务价值层有个 sharedcontext,这是因为考虑到代码复用、可能会出现某些“值对象”、“发布语言(即服务接口出入参)”类被多个上下文共用,具体细节我在后面的战术设计中会讲到。
-
对于“群买菜”系统来说,由于小程序前端界面已经存在,本次是服务端做 DDD 改造,故不打算对前后端交互接口进行调整。为此,我为其设计了“边缘层”,也就是 BFF 层。这就是 edge 目录存放的代码内容。
DDD领域驱动设计实战-聚合(Aggregate)和聚合根(AggregateRoot)
实体和值对象组成聚合,再根据业务,将多个聚合划定到同一限界上下文,并在限界上下文内完成领域建模。
聚合只是单纯将一些共享父类、密切关联的对象聚集成一个对象树吗?如果是这样,对于存在于这个树中的对象,有没有一个实用的数目限制?
既然一个聚合可以引用另一个聚合,是否可以深度遍历下去,并且在此过程中修改对象?
聚合的不变条件和一致性边界是什么意思?
1 聚合
- 实体一般对应业务对象,具有业务属性和业务行为
- 值对象主要是属性集合,描述实体的状态和特征
但都只是个体化对象,其行为表现出的是个体能力。
1.1 意义
领域模型内的实体和值对象好比个体,而能让实体和值对象协同工作的组织就是聚合,用来确保这些领域对象在实现共同的业务逻辑时,能保证数据的一致性。
聚合由业务和逻辑紧密关联的实体和值对象组合而成,是数据修改和持久化的基本单元,每个聚合对应一个仓储,实现数据的持久化。
聚合有一个聚合根和上下文边界:
- 该边界根据业务单一职责和高内聚原则,定义了聚合内部应该包含哪些实体和值对象
- 聚合之间的边界是松耦合的
按这种方式设计出来的微服务自然就是高内聚、低耦合。
聚合属于领域层,领域层包含多个聚合,共同实现核心业务逻辑。聚合内的实体以充血模型实现个体业务能力,以及业务逻辑的高内聚。
跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现:
- 有的业务需同一聚合的A和B两个实体共同完成,就可将这段业务逻辑用领域服务实现
- 有的业务需聚合C和聚合D中的两个服务共同完成,使用应用服务来组合这俩服务
2 聚合根
为避免由于复杂数据模型缺少统一的业务规则控制,而导致聚合、实体之间数据不一致。
传统数据模型中的每一个实体都是同级对等,若任由实体无管控地调用数据修改,可能导致实体之间数据逻辑的不一致。而若使用锁则会增加代码复杂度,降低系统性能。
若把聚合比作组织,则聚合根就是该组织负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。
- 作为实体,拥有实体的属性和业务行为,实现自身的业务逻辑
- 作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定业务规则协同完成共同的业务逻辑
在聚合间,它还是聚合对外的接口人,以聚合根ID关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。即聚合间通过聚合根ID关联引用,若需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。
2.1 电商案例
典型的聚合根:库存、商品、订单等。
以订单为例,订单在聚合里是聚合根,与订单关联的有订单明细和收货地址:
- 订单明细包括商品ID、商品名称、价格及数量等。由于订单明细是多个,它是一个集合,它被设计为实体,被订单引用
- 订单只有一个收货地址,收货地址的值源于你的个人中心维护的收货地址,收货地址只能被整体替换,所以设计为值对象
3 聚合设计案例
DDD领域建模通常采用事件风暴,采用用例分析、场景分析和用户旅程分析等方法,通过头脑风暴列出所有可能的业务行为和事件,然后找出产生这些行为的领域对象,并梳理领域对象之间的关系,找出聚合根,找出与聚合根业务紧密关联的实体和值对象,再将聚合根、实体和值对象组合,构建聚合。
如下为保险的投保业务场景:
- 采用事件风暴,根据业务行为,梳理出在投保过程中发生这些行为的所有的实体和值对象
- 从众多实体中选出适合作为对象管理者的根实体(聚合根)。判断一个实体是否是聚合根,可分析:是否有独立生命周期?是否有全局唯一ID?是否可创建或修改其它对象?是否有专门模块管这个实体
- 根据业务单一职责和高内聚原则,找出与聚合根关联的所有紧密依赖的实体和值对象。构建出 1 个包含聚合根(唯一)、多个实体和值对象的对象集合,这个集合就是聚合
- 在聚合内根据聚合根、实体和值对象的依赖关系,画出对象的引用和依赖模型。
投保人和被保人的数据,是通过关联客户ID从客户聚合中获取的,在投保聚合里它们是投保单的值对象,这些值对象的数据是客户的冗余数据,即使未来客户聚合的数据发生了变更,也不会影响投保单的值对象数据。
从图还可看出实体之间的引用关系,比如在投保聚合里投保单聚合根引用了报价单实体,报价单实体则引用了报价规则子实体。
- 多个聚合根据业务语义和上下文一起划分到同一个限界上下文内。
4 设计原则
4.1 在一致性边界内建模真正的不变条件
要从限界上下文中发现聚合,需要了解模型中真正的不变条件,才能决定什么样的对象可以放在一个聚合。不变条件表示一个业务规则,该规则应该总是保持一致。
有多种类型的一致性:
- 事务一致性
要求立即性和原子性
- 最终一致性
在讨论不变条件时,我们讨论的是事务一致性。我们可能有以下不变条件:
c = a + b
当a等于2,b等于3时,c必等于5。根据这条规则,如果c不为5,便违背系统不变条件。为保持c的一致性,应该在模型中为这些属性设计一个边界:
AggregateTypel (
int a;
int b;
int c;
operations ...
聚合边界之内的所有内容组成一套不变的业务规则,任何操作都不能违背这些规则。边界之外的任何东西与该聚合都无关。因此,聚合表达了与事务一致性边界相同的意思。该例中,AggregateTypel拥有3个int属性,任何聚合都可拥有不同类型的属性。
聚合用来封装真正的不变性,而非简单地组合对象。聚合内有一套不变的业务规则,各实体和值对象按统一业务规则运行以实现对象数据的一致性,边界之外的任何东西都与该聚合无关,所以聚合能实现高内聚!
4.2 优先小聚合
聚合设计过大,会因为包含过多实体,导致实体间管理复杂,高频操作时出现并发冲突或数据库锁,即便能保证事务成功执行,依然有可能限制系统的性能和可伸缩性。
小聚合则可降低由于业务过大导致聚合重构的可能性,让领域模型更能适应业务变化。
得多“小”呢?
最极端的,一个聚合只有全局标识和单个属性,当然,这并非推荐的做法(除非正是需求所在)。推荐使用根实体(Root Entity)表示聚合,其中只包含最小数量的属性或值类型属性。
哪些属性是必需的?
最简单的:必须与其他属性保持一致的。比如,一个Product拥有name、description属性,它们需要保持一致,将它们放在两个不同的聚合中根本无意义。修改name时,很可能也会同时修改description。若只修改其一,多半是在修改语法上的错误或使description能更匹配name。
在聚合中,若认为有些被包含的部分应该建模成实体,怎么办?
该部分是否:
- 会随时间而改变
- 或能被全部替换
若可被全部替换,请将其建模成值对象,而非实体
很多时候,建模成实体的概念都可重构成值对象。优先选用值对象并非意味着聚合就是不变的,因为当值对象属性被替换成其他值时,根实体也就随之改变。
将聚合的内部建模成值对象有很多好处:
- 据所选用持久化机制,值对象可随根实体而序列化,而实体则需单独的存储区域并予以跟踪
- 实体还会带来一些不必要操作,如在使用Hibernate时,需对多表联合查询,而对单表读取快得多,而使用值对象也更方便安全
- 由于值对象不变,测试也相对简单
小聚合不仅有性能和可伸缩性上的好处,它还有助于事务成功执行,即可减少事务提交冲突。系统可用性也得到增强。
在你的领域中,迫使你设计大聚合的不变条件约束并不多。遇到这种情况,可考虑添加实体或集合,但始终都应将聚合设计得尽量小。
4.3 通过唯一标识引用其它聚合
聚合之间通过关联外部聚合根ID的方式引用,而非直接对象引用。外部聚合的对象放在聚合边界内管理,容易导致聚合的边界不清晰,也会增加聚合之间的耦合度。
4.4 在边界之外使用最终一致性
聚合内数据强一致性,聚合间数据最终一致性。
一次事务中,最多只能更改一个聚合的状态。若一次业务操作涉及多个聚合状态的更改,应采用领域事件异步修改相关的聚合,实现聚合间的解耦。
在不持有对象引用的情况下,不能修改其他聚合,因此可避免在同一事务中修改多个聚合。但这样限制性太强,因为在领域模型中,我们总需要对象之间的关联关系来完成任务。对此,又该怎么办呢?
4.5 通过应用层实现跨聚合的服务调用
为实现微服务内,聚合之间的解耦,还有未来以聚合为单位的微服务的组合和拆分,应避免跨聚合的领域服务调用和跨聚合的数据库表关联。
5 总结
- 聚合的特点
高内聚、低耦合,它是领域模型中最底层的边界,可作为拆分微服务的最小单位,但不推荐过度拆分。在对性能有极致要求的场景中,聚合可独立作为一个微服务,以满足版本的高频发布和弹性伸缩要求。
一个微服务可包含多个聚合,聚合之间的边界是微服务内天然的逻辑边界。有了该逻辑边界,在微服务架构演进时就可以聚合为单位进行拆分和组合。
- 聚合根的特点
聚合根是实体,有实体的特点,具有全局唯一标识,有独立的生命周期。一个聚合只有一个聚合根,聚合根在聚合内对实体和值对象采用直接对象引用的方式进行组织和协调,聚合根与聚合根之间通过ID关联的方式实现聚合之间的协同。
- 实体的特点
有ID标识,通过ID判断相等性,ID在聚合内唯一即可。状态可变,它依附于聚合根,其生命周期由聚合根管理。实体一般会持久化,但与数据库持久化对象不一定是一对一的关系。实体可引用聚合内的聚合根、实体和值对象。
- 值对象的特点
无ID,不可变,无生命周期,用完即扔。值对象之间通过属性值判断相等性。它的核心本质是值,是一组概念完整的属性组成的集合,用于描述实体的状态和特征。值对象尽量只引用值对象。
参考
- 《实现领域驱动设计》
- 聚合和聚合根:怎样设计聚合?
以上是关于DDD 实战 :限界上下文映射和系统分层架构的主要内容,如果未能解决你的问题,请参考以下文章