首个冲刺的服务设计
这里说的“服务”其实是前面第 7 篇中识别出来的“服务功能”。这里服务设计将遵循第 7 篇中已经列出的服务契约来进行。
1
服务设计的步骤和方法
顾名思义,“服务设计”其实就是针对前面梳理出来服务契约表中各个“服务功能”内部逻辑的实现。之所以要完成这部分设计,是为了将“服务功能”内部的逻辑落实到聚合(含实体和值对象)、领域服务等 DDD 设计要素上去,避免出现“贫血模型”代码(相对应的,我们称符合 DDD 设计的代码为“富领域模型”)。
要达到这个目的,我们可以通过如下的 5 个步骤来完成服务设计:
1. 服务功能梳理。我们需要基于“业务用例规约”描述,采用“动词短语”的方式,将“服务契约表”中的“服务功能”进行职责描述。事实上,这里的“服务功能”就是我们在菱形架构中提到的“应用服务”(“远程服务”只是“应用服务”跨进程的服务包装输出)。为了完成这个工作,我们需要考虑如下 3 个方面:
2. 任务分解。任务分解的目的,是将服务功能这一“应用服务”作为总体“组合任务”来看待,然后将其分解为多个子任务。这些子任务可能还会包含子组合任务、原子任务两种情况,最终分解的粒度是一定要到“原子任务”的粒度。从“业务用例”开始,我们理解的最终分解结构图示如下:
「张逸按:图中的“业务用例”在我的著作《解构领域驱动设计》中,被称为“业务服务”。本质上,业务服务的定义和业务用例是不相同的。」
而具体分解规则如下:
a) “原子任务”遵循“单一知识原则”,是可以被一个聚合、或一个端口(含资源库端口、客户端端口、发布者端口等情况)来独立完成的部分;
b) “组合任务”更多是协调多个聚合、或需要协调聚合与端口、或需要单独考虑某些与业务变化趋势不一致的技术性变化而的抽离出来的业务逻辑,这些逻辑一般都是“无状态服务”的协作模式,是一种对聚合和/或端口逻辑的编排。
c) “组合任务”的分解粒度,需要注意的一些细节是:在判断逻辑编排的位置时,如何判断是在“领域服务”中实现,还是“应用服务”实现?有如下主要两个考虑因素:
i. 一般来说,“非此不可、顺序只能如此”的业务逻辑放在“领域服务”中,而“可有可无、顺序可随意调整”的业务逻辑,则放到“应用服务”中。比如:假设“提交订单”时包含 3 步:“创建订单、清空购物车、向商家发送通知”。其中“创建订单、清空购物车”是“非此不可、顺序只能如此”的部分,所以放“领域服务”,而“向商家发送通知”则属于“可有可无、顺序可随意调整”的部分,所以放“应用服务”。简单点总结来说:其实“应用服务”中的逻辑是“可编排”的,而领域服务内的逻辑其实是“不可编排”的。
ii. 领域服务处于“核心领域”层,而应用服务处于“北向网关”层,因此如果涉及到跟对外接口转换相关的处理逻辑(例如:根据输入信息转换为某个实体对象或值对象),应该放到应用服务,而不应该出现在领域服务中。
3. 职责分配。其实就是将“原子服务”分配到具体的聚合或端口去实现、将“子组合服务”分配到“领域服务”去实现。总结起来,职责分配遵循如下的规则:
a) 子组合任务,分配职责到“领域服务”(需要将“应用服务”拆分为多个“领域服务”的逻辑,上面已经说明);
b) 原子任务,不需要访问数据存储、其它上下文等外部资源,则分配职责到“聚合”(具体可能是聚合中某个实体或值对象类的行为方法);
c) 原子任务,需要访问数据存储的,分配职责到“资源库端口”;
d) 原子任务,需要访问其它上下文服务的,分配职责到“客户端端口”;
e) 原子任务,需要发布领域事件消息的,分配职责到“发布者端口”;
f) 如果“应用服务”还需要跨进程提供服务(比如:对客户端、对微服务其它进程),则还需要将“应用服务”对外包装成“远程服务”。
“坏味道”识别和改进。在完成“职责分配”后,建议采用序列图的方式来检查是否有设计的“坏味道”,并对发现的“坏味道”进行设计优化。如下图所示:
其中:
a) 五角星表示供客户端角色使用的服务,应该只有一个,如果有多个就应该拆分到多个“应有服务”中去;
b) 三角形一个对象向另一个对象发起的调用,如果一个对象生命线上出现多个,说明该对象要么承担了协调控制角色,要么就是坏味道。而“协调控制角色”大多由“领域服务”或“应用服务”承担,且即便如此它们也不宜承担过多协调控制职责(一般为 3 个以下),“远程服务”、“聚合”、“端口”等则完全不该承担协调控制职责。
c) 菱形表示一个对象承担的职责,也不宜过多。如果过多,某个对象在所有“应用服务”实现序列图中,加起来有超过十多甚至二三十个以上职责,则可能该对象违背了“单一知识原则”,承担了过多的职责需要再进行细粒度职责分解。
这里的对象和方法命名规则建议:
a) 远程服务以服务类型作为类名后缀:控制器类服务后缀为 Controller,资源类服务后缀为 Resource,事件发布者后缀为 Provider,事件订阅者后缀为 Subscriber。
b) 应用服务以 AppService 为类名后缀。
c) 领域服务以 Service 为类名后缀。不过,也可以考虑将体现服务价值的动词名词化,比如叫:OrderPlacingService。这样做的缺点是可能会导致大量碎片化的领域服务。为了减少这种情况,建议在类似 OrderService 这样的领域服务类下的方法过多(比如超过二三十个)时,可以采用这样的命名规则。
d) 应用服务和领域服务的方法名建议以动宾结构的短语为主,要能够描述出服务的业务价值。如:submitOrder()。
e) 资源库端口名称以对应的聚合名称加 Repository 后缀。
f) 客户端端口名称以“宾动结构”加 Client 后缀,如:WxPrepayingClient。
2
鉴权上下文服务设计
微信用户登录
服务功能梳理
根据业务用例“登录系统”的用例规约描述,我们知道“微信用户登录”主要完成如下步骤(其它步骤不属于 sprint1 范围,暂不考虑):
同时,根据前面的“动词建模”我们还发现该服务功能应该记录“登录日志”。为此,我们将该服务功能需要完成的逻辑完善后,“动词短语化”如下:
任务分解
首先,我们对服务功能进行任务分解。上面的服务功能描述中,关于用户记录、登录日志、登录令牌的动作,都是可以再继续“原子化”分解的。如下:
其次,我们发现前面两个原子任务其实是可以组合到一个“组合任务”中,就叫“微信后台登录并校验”好了。所以,我们可以把任务分解调整成如下内容:
职责分配
下面我对上节分解后的任务进行职责分配。按照分配原则,结合上一章的“聚合设计”,我们分配如下:
除了上面的原子任务、组合任务的职责分配外,我们还需要考虑另一个问题:哪些组合任务由领域服务来协调、哪些组合任务由应用服务来协调。根据我们前面的区分原则:“非此不可、顺序只能如此”的业务逻辑放在“领域服务”中编排,否则就放在“应用服务”中编排。我们分析上面的几个组合服务可以看出,下面这些步骤是“非此不可、顺序只能如此”的业务逻辑:
1. 必须先完成“调用微信 auth.code2Session 接口登录微信后台”后,才能进行后面的“确保用户记录存在”,因为后者要用到前者返回的 openid;
2. 必须在“确保用户记录存在”后,才能“记录登录日志”和“生成登录令牌”,因为后两者需要用到用户标识 ID;
3. 从返回给小程序前端(用户角色)的角度来说,“生成登录令牌”是不可或缺的,不能被省略的;
而下面是可能涉及到“可有可无、顺序可随意调整”相关业务逻辑的考量:
1. 从返回给小程序前端(用户角色)的角度来说,“记录登录日志”其实是可有可无的;
2. 从执行的顺序来说,“记录登录日志”是在“生成用户登录令牌”之前还是之后,其实是可以随意安排的;
3. 但从时标对象为了规避法律纠纷风险的角度来说,“记录登录日志”又是不可或缺的;
为此,我们将上面的任务分解职责分配结果调整为如下结果:
坏味道识别
我们先根据上一节的职责分配后的“动词短语性功能描述”,画出该服务功能的实现序列图如下(需要说明的是:下面的远程服务我们以 Resouce 后缀命名,是以为我们虽然这里开发的是“群买菜”服务端第二版,并为了兼容第一版我们开发了 BFF 边缘层,但我们仍然希望第二版本开发的远程服务是可以作为远程资源服务,支持 RESTful 规范的):
备注说明如下:
1. 图中用各种颜色标出了“菱形架构”各个角色(不包含“南向适配器”,因为那是依赖注入的)。
2. 五角星表示供客户端使用的服务请求,三角形表示对象发起调用,菱形表示对象承担的职责。
从该序列图中,我们看到:大部分调用发起和职责承担均没有“坏味道”。但有个“创建对象”的地方需要注意:
1. 新登录微信用户,我们现在是让 User 聚合根实体类自带的工厂方法去创建对象。这是有问题的:因为创建这个对象时,需要用到小程序客户端提交的微信用户信息(昵称、头像、性别、地区等),而这些信息我们是通过类似 WxLoginRequest 这样的发布语言类(其实是一种 DTO——纯数据传输对象)传给应用服务的。我们在 User 类的工厂方法中创建实例,就会使得 User 类依赖于 WxLoginRequest 发布语言类,这是不合理的:从代码分层上,出现了“内层依赖外层”的问题,不符合简洁架构原则。为此,我们需要在应用服务层,就要求通过 WxLoginRequest 发布语言类的工厂方法来创建 User 实例,并将该实例传入到领域服务后,由领域服务判断是否将其作为全新的 User 对象看待。
这里需要说明的是:关于实体对象的创建工厂到底应该怎么实现,有如下几种选择,分别适用于不同的情况:
用得最多的,是聚合自带工厂方法。这种方案适用于聚合实体对象的创建过程,不需要依赖于其它外在信息(即需要通过南向网关端口去其它上下文获取信息的情况)、且对象基本必填属性的设置比较简单。这种聚合自身的工厂方法在 java 中都是 static 方法,名称往往为 createXxx、instanceOf、of 等。
如果聚合实体对象的非必填属性比较多,且基本必填属性比较简单,且聚合自身就能够决定如何创建对象实例,则可以考虑采用“构建者”设计协作模式。这种协作模式有两种方式:方式 1,聚合提供基本的工厂方法+内嵌的 builder 类(builder 类的各个方法都设置某个聚合属性,并返回 builder 对象实例以便实现“流式接口 fluent interface”);方式 2,聚合提供基本的工厂方法+一系列设置非必填属性的方法(但这些方法都返回聚合对象本身以便实现“流式接口 fluent interface”)。从简洁性上来说,我个人倾向于第二种方式。
如果聚合实体对象是从客户端调用请求时填入的信息,则可以在“发布语言类”中实现聚合的工厂方法,因为这时候“发布语言类”具备创建聚合对象的最多业务知识。——这种情况,正好是我们这里提到的根据小程序用户信息创建 User 实例的需求。
对于聚合内出现“整体-部分”协作模式的实体对象关系,则可以让代表“整体”的实体对象来实现代表“部分”实体对象的工厂方法。如“订单-订单项”之间,则可以在“订单”类中实现“订单项”的工厂方法。
如果聚合自身不具备足够的信息、且客户端提交请求的信息也不够用来创建实例,还需要组合其它外在信息(即需要通过南向网关端口去获取来自外部存储、其它上下文的信息),则建议引入专门的聚合工厂类、或工厂方法协作模式、或抽象工厂协作模式来实现。
为此,我们将服务序列图调整如下:
根据优化后序列图,最终得出任务分解如下:
「张逸按:本文的例子中将最外面的组合任务分配给了应用服务,在我的著作《解构领域驱动设计》中,应用服务属于北向网关,应将业务服务(它是任务树的根)分配给应用服务。组合任务是任务树的枝,只能分配给领域服务,如此才能避免将业务逻辑泄露到网关层。」
获取微信绑定手机号
经过 4 步骤设计后的服务功能分解如下:
该服务功能的序列图如下:
3
订单上下文服务设计
保存购物车
经过 4 步骤设计后的服务功能分解如下:
该服务功能的序列图如下:
查询购物车
经过 4 步骤设计后的服务功能分解如下(以下的所有服务功能就只给出结果,不再展示中间分析过程):
该服务功能的序列图如下:
创建付款订单
经过 4 步骤设计后的服务功能分解如下:
该服务功能的序列图如下:
这里需要说明的是:
其实,这里“结算订单商品”和前面保存/查询购物车时“结算购物车商品”时,其 client 端口调用的“商品上下文”的服务功能,都是“商品上下文”的服务功能“计算多商品结算信息”提供的。
创建订单预支付
经过 4 步骤设计后的服务功能分解如下:
该服务功能的序列图如下:
查询订单详情
经过 4 步骤设计后的服务功能分解如下:
该服务功能的序列图如下:
生效订单
经过 4 步骤设计后的服务功能分解如下:
需要说明的是:上面的逻辑省略了创建品牌子订单等跟品牌加盟相关的内容,因为那是后面冲刺的内容,故不包含。
该服务功能的序列图如下:
查询订单列表(CQRS)
正如前面战略设计技术决策中所说,这个查询列表的服务放在“业务查询中心”微服务中,采用 CQRS 查询代码模型,不采用领域代码模型,故不做 DDD 战术服务设计。
查询订单列表(CQRS)
同上,这个服务功能不采用领域代码模型,采用 CQRS 查询代码模型,故不做 DDD 战术服务设计。
确认订单完成
经过 4 步骤设计后的服务功能分解如下:
该服务功能的序列图如下:
自动确认超时订单
经过 4 步骤设计后的服务功能分解如下:
该服务功能的序列图如下:
删除订单
经过 4 步骤设计后的服务功能分解如下:
该服务功能的序列图如下:
自动取消超时订单
经过 4 步骤设计后的服务功能分解如下:
「张逸按:这里分解的任务树,将两个组合任务分配给应用服务,明显使得网关层的应用服务具有了一定的领域逻辑,这是违背菱形对称架构的。」
该服务功能的序列图如下:
到此,我就完成了 sprint1 服务设计的主要工作——包含鉴权上下文 2 个、订单上下文 12 个共计 14 个服务功能的“服务设计”工作,还剩下 7 个服务的设计,我将在下篇中完成它们。下篇中还将包含战术设计的相关技术决策(这种技术决策只有第一次冲刺才有),进而完成 sprint1 的全部战术设计。另外,本次设计的全部对应代码,我也已经更新到 gitee 代码库,网址是:https://gitee.com/samson-shu/starshop。