DDD 实战 (6):从需求到代码实现生鲜电商系统
完成了“限界上下文识别”和“限界上下文关系映射”这两个 DDD 战略设计阶段最重要的工作,甚至给出了代码目录结构,我们在 DDD 战略设计阶段就剩下最后一个内容:战略层面的技术决策。本篇就是要完成这部分内容。
特别说明:我在写这篇的过程中,发现前面第四、五篇的上下文识别和关系映射中一些错误:将“确认订单付款”和“确认接龙付款”错误的合并为一个用例“确认购买并付款”(其实应该是包含子用例“创建付款订单”),并且相应的跨上下文用例中也遗漏了“确认接龙付款”。我已经更新了四、五两篇内容,您可以回头查看。
6. 战略技术决策
一个相对大型的系统会有多个相关方面的战略技术决策,一般来说会涉及如下 3 个角度:
首先,从代码模型角度,系统性的考虑跟各个限界上下文内部代码模型有关的 4 个重要决策:是否采用规则引擎、是否采用 CQRS 模式、是否采用命令总线模式、是否采用事件溯源模式。
其次,结合康威定律,考虑影响开发团队成员能力相关的技术决策,包括:开发语言和开发框架的选型、资源层各种技术组件的选型。这两方面都需要既结合应用场景的实际需要、也要结合开发团队的技术能力或人员招募方面的现实情况来考虑。
开发语言和框架是需要逐个限界上下文考量的,严格意义上来说,为了尽可能高的开发效率和解耦,极端一点甚至可以做到每个限界上下文采用不同的开发语言和框架(比如:python 适合做大数据或 AI 相关、java 适合企业级 OLTP 应用、C/C++语言适合硬件通讯类应用等等)。
资源层的技术组件包括:目录服务组件(如 zookeeper)、缓存组件(redis、memcache 等)、消息中间件组件(ActiveMQ、kafka 等)、数据库选型(关系数据库 oracle/mysql 等、非关系数据库 mongodb、分布式数据库 HBase 等)。
最后,我们处于云计算时代,云原生被大面积应用,所以战略设计必须得考虑运维层面的微服务如何划分(事实上,微服务是物理部署设计,这属于运维而非逻辑层面的的设计概念),以及配套的数据库架构和事务一致性设计。
6.1 代码模型视角
6.1.1 规则引擎考量
规则引擎是一种嵌入在应用程序中的组件,主要目的是将业务决策从应用程序代码中分离出来,使用预定义的、可编辑的语义逻辑而不是硬编码来表达业务规则。确切地说,规则引擎接受数据输入,解释性执行业务规则逻辑判断,并根据判断结果执行业务决策。一般来说,“业务规则”就是一大串类似“if-else-then”的逻辑判断。引入规则引擎将使得业务人员可以直接编辑业务规则、而不用等待开发人员漫长的开发上线过程(有点“低代码”的味道)。
在应用系统中引入规则引擎很可能会打破 DDD 领域设计的模型结构,因为“业务规则”的配置可能会需要一些与正常业务能力完全不同的“领域模型”——该领域模型仅用于“业务规则”的属性输入。在极端情况下,软件架构师甚至专门设立“规则上下文”,将所有的“业务规则”执行都放到该上下文去执行。但引入“规则上下文”很可能会导致其它上下文对其的“紧耦合”——过多的调用关系、以及运行态下过高的调用频次。
本目标系统因为涉及的业务规则很少(仅有店铺营业时间、商品价格计算、商品限购、店铺加盟分成等少数几个规则),并且可变动性不大,故不打算引入规则引擎。
对于有些系统如:涉及 SKU 品类大类上百种、小类几十万种的电商平台(如京东淘宝等)的商品定价和优惠活动、电信运营商的各类资费套餐、大卖场的各类促销活动等,建议考虑引入规则引擎,但务必谨慎引入“规则上下文”——建议只将规则引擎作为上下文内部的一个技术组件使用。
6.1.2 CQRS 模式考量
在一个实际的业务系统中,前端对服务端的请求一般可分为两类:查询类和命令类。查询类请求指的就是查询数据(如:按关键词查询商品列表等),命令类请求指的是执行某个动作的命令(如:提交订单、更新客户信息等)。CQRS 即命令查询职责分离,意思是将“查询”和“命令”类请求分别用不同的软件架构来实现。引入该模式主要是考虑如下两个原因:
很多前端界面所需的“查询”类操作非常复杂,很可能需要跨多个“聚合实体”对象的复杂组合关系,甚至可能导致“限界上下文”之间的复杂耦合关系。
“查询”类操作往往耗时较长,且前端界面必须“同步等待”服务端的返回;而“命令”类操作很多响应速度很快,甚至可能前端界面并不需要等待服务端返回。在微服务系统架构中,如果将“查询”和“命令”逻辑分离到不同的“限界上下文”(进而分离到不同的“微服务”),可以很大的改善“命令”类操作的响应性能和稳定性。
在 CQRS 模式架构中,“查询”类操作不需要考虑严格的领域模型,直接采用程序员最熟悉的传统 Controller/Service/DAO 程序架构、甚至直接在数据库中使用视图等手段来完成查询,很可能是“工作效率最高”的开发方式。但需要注意的是:CQRS 模式系统中,并不代表领域模型完全没有查询功能——对于根据实体 ID 从 DB 中“重构”领域对象的查询操作,还是要必须保留的。一般来说,当在某限界上下文引入 CQRS 模式时,该限界上下文的软件架构如下图所示:
从上图中可以看出,CQRS 是一种典型的“一个限界上下文被拆分为多个微服务”的例子。事实上,CQRS 还有一种“更为彻底”的实现方式——结合“事件总线”,实现命令和查询操作在数据层的“读写分离”。如下图:
在本目标系统中,考虑到前端会有一些灵活要求、且未来很可能会发生频繁变化的“查询类”界面,如:商品目录的展示、对客户查询订单需求变化的灵活支撑、对不同用户展示接龙活动方式的灵活变化,我们决定在“订单”、“商品”、“接龙”三个限界上下文中引入 CQRS 模式。考虑到“群买菜”早期还没有达到巨量的业务并发量,故暂时不实现“命令”和“查询”的读写分离(即在这几个限界上下文中,引入第一种方式的“CQRS 模式”)。
6.1.3 命令总线模式考量
对于“命令”式请求,是有两种实现方式的:一种是同步方式,前端界面(或外围接口)发出命令,等待服务端执行命令后才返回;另一种是异步模式,服务端先将命令输入信息在“命令总线”中保存下来,事后再在“命令处理器”后端进程中慢慢处理。第二种处理方式,就是“命令模式”。一般来说,引入“命令模式”主要是考虑两种因素:
前端界面(或外围接口)从业务逻辑来来说,并不需要等待服务端实际处理完,只需知道服务端已正确收到命令即可(一般见于前台营业员或客服坐席已经在提交业务之前,为客户完成了一系列业务规则检查,后端系统几乎不会出现业务规则失败的情况)。
服务端处理命令的逻辑比较复杂,可能会消耗较长的时间(一般是好几秒以上)。
需要说明的是:如果引入“命令总线模式”,前端界面到服务端的请求就只剩下“查询”类了,就不再需要严格遵循 DDD 领域模型来实现查询,所以必然同时实现了 CQRS 模式。此时,软件结构如下图所示(该图
未实现“读写分离”——理论上也是可以“读写分离”的):
在本目标系统的设计中,考虑到大部分请求,都是客户在前端小程序发起、需要“等待服务端处理完成”才能继续后续操作的业务,为保证用户体验、且早期软件的业务量不大,故不引入命令模式。
6.1.4 事件溯源模式考量
在 DDD 理论中,事件溯源模式是基于“事件风暴建模”的。由于“群买菜”系统的事件流没有那么复杂,逻辑也相对清晰,所以不打算引入“事件风暴建模”,也就相应的不打算引入“事件溯源模式”作为代码模型。
顾名思义,“事件溯源模式”的代码模型,其实是一种将“历史事件既然发生、其历史痕迹就永远不可抹除”理念作为核心编程模型的思想:系统记录每个事件的内容、并将其持久化后,每次需要查询某个实体(如订单)的状态时,就将持久化的“历史事件”全部载入后“重新播放”,进而确定实体的最终状态。而不像我们普通的编程模型,其实是将实体被更新后的状态信息进行持久化的。
这种编程模型有它的优势,但并不适合“群买菜”系统,故不打算采用。
当然,在“群买菜”中存在很多的事件消息在上下文之间传播,考虑到后面的微服务拆分,以及有些事件消息还要求是“可靠传输”,我的设计是引入具有可靠传输特性的消息中间件来实现事件消息的传播。
6.2 结合团队能力视角
6.2.1 开发语言和开发框架考量
考虑到“群买菜”系统目前是初步版本,只有纯 OLTP 类型服务,不存在 OLAP 型服务,以及其它诸如 AI 算法等内容,加上目标系统希望运行的 linux 服务器,故在服务端所有限界上下文全部采用如下的技术栈:
ava/spring MVC 框架为基础的技术栈;
java 持久化采用 mybatis plus;
考虑目前业务量不大,java 后台机器人采用 Quartz;
微服务开发使用 spring cloud 框架;
6.2.2 资源层技术组件考量
资源层技术组件一般包括:目录服务组件(如 zookeeper)、缓存组件(redis、memcache 等)、消息中间件组件(ActiveMQ、kafka 等)、数据库选型(关系数据库 oracle/mysql 等、非关系数据库 mongodb、分布式数据库 HBase 等)。我们一个个来看:
“群买菜”发展早期,对数据库要求不高,就直接采用 MySql 8.0;
目前没有很高的信息展示灵活性要求,不引入 Nosql 数据库如 mongodb 等;
目前没有很高的性能访问要求,不引入 redis 等缓存组件;
考虑到消息中间件要求”可靠消息传输“,引入 ActiveMQ 消息中间件;
”群买菜“系统早期业务量不大,既然微服务开发框架使用 spring cloud,就不再引入其它目录服务等微服务相关的运行组件;
6.3 系统运维实施视角
6.3.1 微服务划分考量
“微服务”严格意义上来说,应该叫“微服务器”,是在微服务平台(如 k8s)调度下“容器”(如 docker)中运行的一个“进程”实例。因此,“微服务”是物理部署层面的概念,之所以“微”是微在跟传统的服务器相比,它只是某个物理宿主机下的一个“进程”实例,而不是将整个宿主机作为服务器——很可能一个宿主机被分割成了很多个“微服务”。
很多对“微服务”不甚了了的人,以为“微服务”就是一个个服务接口(如:RESTful 接口),每个服务接口是一个“微服务”,这其实是一种错误的认识。所以,正确理解“微服务”一词,最好在认知把它解读为“微服务器”。
既然“微服务”是物理部署层面的概念,那就是从运维角度在考虑问题,“微服务”之间的边界其实就是“代码运行时的进程边界”;而“限界上下文”是从业务领域知识的角度,进行代码的逻辑结构的拆分,“限界上下文”之间的边界就是“业务领域”的知识边界。所以,两者是完全不一样的概念。
由于软件的物理部署一定是基于逻辑结构,所以说:“微服务”是基于但不能等同于“限界上下文”的。当然,最简单粗暴的方法,就是让“微服务”和“限界上下文”一对一映射。但在现实的复杂大型系统中,这种“简单粗暴的一对一映射”可能并不是一个负责任的架构设计。为此,我们需要建立一套从“限界上下文”到“微服务”设计的工作思路。
要强调的一点是:事实上,做好了 DDD 战略设计的“限界上下文识别”和“上下文关系映射”,实现了“限界上下文”级别的“高内聚、低耦合”,加上下节所说的合理的“数据库架构设计和事务一致性策略”,已经很好的解决了“代码逻辑”之间的耦合性问题,其实是可以做到“微服务随时可拆可合”的。这种情况下采用“单体应用”是不会造成代码开发维护角度的“大泥球”现象的,不需要太多纠结到底有没有“拆得很彻底”的问题。
之所以能做到“微服务随时可拆可合”,其实是对“菱形架构”中南向网关适配器进行更换、并必要时配合相应上下文的北向网关实现方式即可。具体包括:
南向网关中用来实现 DB 操作的“资源库适配器”,当需要将处于同一个微服务的限界上下文拆分到不同微服务时,一般只需要将资源库适配器类更换为支持“最终事务一致性”的实现方式即可。
上下文之间存在服务调用的情况,当需要将处于同一个微服务的限界上下文拆分到不同微服务时,只需要将调用方的客户端适配器从本地调用的实现方式替换为远程调用的实现方式,同时为被调用上下文封装出“远程服务”接口(辅助以 DTO 数据传输对象实现)即可。
上下文之间存在消息事件通知的情况,当需要将处于同一个微服务的限界上下文拆分到不同微服务时,只需要将消息发布(南向网关适配器)和订阅者(北向网关订阅者)从本地的事件总线更换为消息中间件实现方式即可。
因此,我们“拆分微服务”的起点,应该是“从单体应用”出发,遵循“奥卡姆剃刀原则”,能不拆就不拆、必须拆才拆。而不是像很多人误以为的那样:尽量拆分为多个微服务。具体到操作层面,我本人是采用如下“三板斧”进行微服务拆分的:
1. 领域模型设计。这里就是“限界上下文识别”和“限界上下文映射”,前面 4-5 两篇已经说得很清楚了。
2. 开发因素决策。开发因素的一些决策,会影响到微服务如何拆分。这里涉及到如下几个方面,分别详述如下:
a) 技术栈差异。这就是前面在结合团队能力视角提到的开发语言、开发框架、资源层技术组件的选型等。一般来说,建议不同的技术栈(含开发语言、开发框架、技术组件等的组合),就拆分到不同的微服务中。在“群买菜”系统中,因为目前只有一个技术栈,故没必要因为这个而拆分微服务。
b) 强弱关联分组。在我们的“限界上下文映射关系图”中,有些上下文之间是有强关联——服务调用关系,而有些上下文之间是有弱关联——事件消息通知关系。根据强弱关系,我们可以将限界上下文分为不同的几组,这几组之间建议拆分为不同的微服务。“群买菜”系统的限界上下文映射图如下,从图中可以看出,所有上下文之间都能够通过强关联串接上,故没必要因为这个因素而进行微服务拆分。
c) 代码模型分组。在前面的纯应用架构视角分析中,我们考虑了规则引擎、CQRS、命令模式、事件模式等不同的代码模型。一般来说,为了让开发团队能够在同一个熟练的“代码模型”下工作,建议不同代码模型放到不同的微服务——这样就可以让不同的开发团队负责不同的“微服务”开发,否则开发团队在多个“代码模型”上工作会效率低下、精神错乱的。即使是同一个上下文里面,也可以因为 CQRS/命令模式的区分而拆分为两个微服务。在前面的代码模型分析中,我们决定将“订单、商品、接龙”上下文引入 CQRS 模式,故将“订单、商品、接龙”3 个上下文的“查询模型”部分拆分出一个微服务“业务查询中心”。
d) 传输信息复杂度。在限界上下文的强服务调用关系中,有些服务调用之间需要传输的数据模型是很复杂且内容多变的,如果将它们拆分到不同的微服务进程中,将会使得:1)数据序列化/反序列化很复杂,传输性能很差;2)代码需要将内部的实体对象类做很繁琐的 DTO(数据传输对象,纯粹用来在进程间传输数据的一种对象)。为此,如果遇到这种情况,我们是不建议进行微服务拆分的。在“群买菜”系统的上下文映射图中,我们需要分析各限界上下文“强调用关系”看看它们都执行了什么调用、传输了什么信息。如下图
:
从图中可以看出:“订单”和“接龙”从“商品”获取指定 ID 的商品信息,这将会是很复杂的数据结构(因为商品信息的复杂性、多变性)。为此,我们可以确定“商品、订单、接龙”这 3 个上下文在一个上下文中,不进行微服务拆分。
3. 运维差异分析。既然微服务是代码物理部署的设计,我们就必须从运维角度分析一些可能影响到微服务拆分的差异。具体来说,这里会涉及到如下几个方面:
a) 需求变更频度差异。一般来说,变更永远是运维的最大克星。因此,从运维角度我们必须要考虑“需求变更频度差异”的问题。一般来说,需求变更频度差异巨大的模块之间,我们需要给予的“回归测试”、“灰度发布”等运维策略是完全不一样的。在实际的业务系统中,架构师可以根据对业务的理解,或者跟产品经理和业务规划部门的交流,知道有些上下文涉及的业务需求会有非常频繁的变更,而有的上下文涉及的业务需求变更频率很低,甚至它们的需求变更频率都有数量级别的差异(即:有的可能一个月只变更几次,而有的每个月会变更几十上百次需求)。对于这种情况,我们建议是进行微服务切分。“群买菜”系统的各上下文之间,很明显可以看出来需求频度可以分为三组:订单、商品、接龙一组(主要取决于系统将要支撑的消费者端业务发展变化);鉴权、平台集成一组(主要取决于微信开放平台的接口演变);客户、员工、商家账户、店铺一组(主要取决于平台支撑商家管理规则的变化)。为此,我们是可以对应的切分出来 3 个新的微服务:业务处理中心(含商品、订单、接龙的 3 个上下文的“命令”部分)、平台接入中心(含鉴权、平台接入上下文)、商家运营中心(含商家账户、员工、客户、店铺上下文)。加上前面的业务查询中心(含商品、订单、接龙的 3 个上下文的“查询”部分),我们就有 4 个微服务了。
b) 高可用级别差异。不同的限界上下文,在实际应用系统中,可能业务对它的高可用级别是不同的。下表是关于系统高可用的各种级别定义:
从表中可以看出,如果上下文之间的高可用要求不同,运维策略会有很大的差异,甚至有可能基本不投入太多资源进行运维。为此,我们是需要根据高可用级别的差异,对微服务进行切分的。在“群买菜”系统中,由于整个系统基本就是一个展现给用户的小程序,故不存在这种差异,也就不存在因为这个因素而切分微服务的需要。但在某些应用系统中,比如会存在“日志”上下文,专门用来记录各种交易日志等,该上下文的高可用要求可能就比较低(一般为 99%),则建议将其独立出来一个微服务。
c) 业务规模差异。如果上下文之间存在请求业务量上的数量级差异(即十倍甚至百倍以上差异,建议百倍以上差异),比如:上下文 A 每天要提供亿级业务请求量支撑,而 B 上下文之需要提供百万甚至十万级业务请求量支撑,则建议将其分离到不同的微服务中。“群买菜”系统不存在这种情况,故不做这方面的切分。
d) 数据规模差异。如果上下文之间存在数据记录数的数量级差异(即十倍甚至百倍以上差异,建议百倍以上差异),比如:上下文 A 的数据库记录数为千万级,而 B 的数据库记录数为万级,则建议将其分离到不同的微服务中。甚至,可能因为数据记录数量级上的差异,而在两个上下文选择不同的数据库组件。对于千万级以上记录,可能选择商业级如 oracle 等、或搭建复杂的高可用分布式数据库 HBase 等;而对于百万级甚至十万级以上记录数的上下文,可能选择 MySQL、PostgreSQL 等非商业化软件。“群买菜”系统由于是早期发展阶段,还没有那么大业务量,故不考虑这个因素。
经过上述这一系列从开发、运维两个角度的分析,我们最终得出“群买菜”系统分为如下 4 个微服务:
6.3.2 数据库架构和事务一致性考量
完成了微服务的拆分,接下来就要考虑最后一个特别重要的设计:前面我们说过,微服务的拆分,本质上是考虑将哪些限界上下文进行“进程”边界角度的拆分。而这会导致跟数据库有关的两个连带问题:
1. 数据库架构设计问题。微服务本来是希望通过将单体应用解构进而实现 IT 运维层面的“故障隔离”,而如果多个“微服务”仍然连接的是同一个数据库,则使得微服务拆分后的“故障隔离”效果大打折扣。为此,这就是要考虑数据库架构如何设计。在 DDD 战略设计思想下,我们不要再将“数据库”作为核心设计要素,而只是将其作为实现上下文逻辑的一个“基础设施”,我们可以根据不同上下文的需要使用不同的数据库产品(包括关系数据库、非关系数据库,甚至缓存数据库等),而不要反过来围绕数据库来进行微服务设计。一般来说,最理想的情况是每个“微服务”都访问自身独立使用的数据库,且根据各微服务的实际需要选型合适的数据库、及对应的数据库架构。
2. 事务一致性问题。本来单体应用下比较容易实现的事务一致性,到了“微服务跨进程”情况下就不得不考虑如何做到“数据一致性”。一般来说,按照 CAP 定理(即分布式系统下,数据一致性、可用性、分区容错性这三条要求之间,永远只能最多同时满足两种情况之一:要么是“一致性+分区容错性”,要么是“可用性+分区容错性”)。一般来说,大部分应用系统都是通过某种技术手段实现“最终事务一致性”,也就是优先选择“可用性+分区容错性”策略。
对于“群买菜”系统来说,我们如下考虑这两个设计要素:
已知前面设计成了 4 个微服务,其中“业务查询中心”和“业务处理中心”前面已经说过共用一个数据库,剩下“平台接入中心”和“商家运营中心”可以使用两个独立库。考虑到“群买菜”早期数据量不大,我们就用 mysql 同一个实例下的不同库来实现(各上下文使用不同的默认数据库用户登录)。
我们这样设计数据库架构后,就要考察是否会出现跨数据库的事务一致性问题。我们可以根据上下文映射分析中找到的上下文之间的“服务调用强关系”来看,如下表:
从上表可以看出:只有接龙到订单的服务调用关系要求事务一致性。而从现在我们划分的 4 个微服务来看,接龙和订单的业务“命令”逻辑都处于“业务处理中心”微服务中,故不存在跨进程的事务一致性问题。
当然,理论上“接龙”和“订单”也是可以拆分到不同的微服务中的。一般对于这种情况,考虑到其中可能还会存在订单相关的复杂处理流程关系,建议采用“工作流引擎”支持的 Saga(长时间运行事务)模式来达到“最终事务一致性”效果。
到此为止,终于完成了 DDD 战略设计部分的内容。其实,这些内容有些已经超出了 DDD 的范畴,加上了很多我自己认为应该在战略设计阶段完成的内容。从下篇开始,我们就将进入 DDD 战术设计,到了那里我们就要引入 scrum 敏捷迭代的概念,通过多版本迭代来完成整个“群买菜”的代码开发了。
版权声明: 本文为 InfoQ 作者【深清秋】的原创文章。
原文链接:【http://xie.infoq.cn/article/49784ee2b0d0d6e1aee0b3654】。文章转载请联系作者。
评论