DDD 实战 (11):冲刺 1 代码 TDD 实现之道
接上篇《DDD 实战 (10):冲刺 1 战术之服务设计(下)及技术决策》后,我们接下来的重点,就是要展示真正的代码实现了。在本篇中,我将围绕 TDD(Test-driven development, 测试驱动开发)编程方法为核心,演示前面完成的相关 DDD 设计是如何落地的。
在本篇中,我将首先介绍 TDD 三重奏(写测试-写功能-重构)和相关原则,然后用实际代码演示 TDD 的工作流程,最后我会讲到编程过程中采用哪些技巧处理一些现实的技术细节问题。
5.首个冲刺的代码实现
5.1 开始之前
5.1.1 为什么要 TDD
在引入了 DDD 对软件进行分析、设计后,我个人还强烈建议:在编码实现上采用 TDD 方法。之所以这么推荐,是出于这样的考虑:
正如前面《DDD 实战 (9):冲刺 1 战术之服务设计(上)》、《DDD 实战 (10):冲刺 1 战术之服务设计(下)及技术决策》两篇所述,通过 DDD 的战术设计,我们会得出很多“应用服务”、“领域服务”、“聚合”的设计,以及它们之间的分工协作关系。这些内容,从本质上来说,其实是完成了很多个“独立逻辑单元”的设计。
这些“应用服务”、“领域服务”、“聚合”所代表的独立“逻辑单元”,如果不采用 TDD 方式开发,是很难保证它们内部的逻辑实现,就跟我们设计时考虑的一模一样。或者换句话说:一方面,我们通过 DDD 设计来保证了“现实业务世界”到“代码设计世界”的同构映射;另一方面,我们还需要有一种方法来保证“代码设计世界”到“代码实现世界”的同构映射。而 TDD 就是“代码设计世界”到“代码实现世界”同构映射的最佳方法!
5.1.2 TDD 方法和相关原则
在开始之前,我们先来看看 TDD 方法指导下,我们该怎么进行代码的开发。
首先,我们来看 TDD 的“工作节奏”。其实,TDD 说起来概念很简单,就是“先写测试代码,再写功能代码”。但乍一听这句话,很多程序员会很懵——我功能代码还没写,怎么让我写测试代码呢?这其实涉及到 TDD 的工作“三重奏”:
第一奏,选择测试点,编写测试代码,用 assert 断言对结果进行检查。此时运行测试代码,一定会提示失败,代码处于“红灯”状态。
第二奏,编写功能代码。注意,写的功能代码,仅限于让刚才的测试代码通过,这里代码会变成“绿灯”状态。
第三奏,进行代码优化重构。通过分析代码,消灭功能和测试代码中可能的“坏味道”。重构过程中,代码会逐渐从“红灯”变为“绿灯”状态。
第三奏完成后,就可以继续挑选下一个测试点,重新进行“三重奏”,直到完成全部代码开发。
其次,我们来看如何怎么选择测试点。测试点的选择,其实方法很简单,只需要遵循如下的 3 个步骤:
基于前面“服务设计”中的服务任务分解,逐步从原子任务(聚合或端口)、到组合任务(领域服务)、到应用服务,按照“先聚合再端口、先原子再组合、从内向外”的分解步骤选择测试点。我们来看一个实际的服务任务分解例子,如下图所示的“微信用户登录”:
从上图的任务分解可以看出,我们先要进行“确保用户记录存在(组合任务、领域服务)”这一组业务的测试。而这一组业务逻辑,我们本着“先聚合再端口、先原子再组合、从内向外”的步骤,可以识别出如图所示的从 1.1 到 1.7 的测试用例,它们的编号分别说明如下:
为了保证后面测试查找已有 User 时,User 持久化功能已经可用,所以我们先测试未找到用户时“设置新用户 openid、并进行新增用户持久化”。
而“设置新用户 openid;(原子任务,聚合行为)”属于聚合行为、“新增用户持久化(原子任务,资源库端口)”属于端口行为,故先测试前者。所以“设置新用户 openid;(原子任务)”就作为第一个测试用例,编号为“1.1”;而“新增用户持久化(原子任务,资源库端口)”则作为第二个测试用例,编号为“1.2”。
相应的,“更新已有用户信息;(原子任务,聚合行为)”和“更新用户持久化;(原子任务,资源库端口)”分别编号为“1.3”和“1.4”。
按照“先聚合后端口”的原则,“查找用户记录(原子任务,资源库端口)”编号为“1.5”。
再次按照“从内向外”的原则,将“确保用户记录存在;(组合任务,领域服务)”编号为 1.6 和 1.7,分别对应下面的两个分支:openid 对应用户已经存在、和不存在。
类似的,我们还可以对“生成微信登录令牌;(组合任务,领域服务)”、“生成用户登录令牌;(组合任务,领域服务)”、“记录用户登录日志;(组合任务,领域服务)”这三组组合任务(领域服务)选择相应的测试点。
判断每个测试用例是否通过的标准(即断言怎么写),主要参考前面《DDD 实战 (7):战术设计、整体流程与首次冲刺》给出的“业务用例规格书”中描述的“验收标准”(如果发现那里的验收标准描述不够准确,可以回头去迭代完善它)。
正常情况下,需要考虑每个待测试功能的“正常”和“异常”分支,实现“白盒分支覆盖”。
再次,我们说说编写测试代码的一些基本原则。一般来说,我们编写测试代码需要遵循如下的 FIRST 原则:
F: Fast 快速,每个测试运行非常快,能够做到每秒执行几百甚至几千个;
I: Isolated 独立,每个测试能够清晰的隔离一个失败,不会组合多个失败;
R: Repeatable 可复现,每个测试都可重复多次运行,且每次以同样的方式成功或失败;
S: Self-verifying 自我验证,每个测试自动化的判定成功或失败,无需人工观察或干预;
T: Timely 及时,测试必须及时编写、更新和维护;
这里面,F、R、S 是三个最重要的原则,就是说:一方面代码要是能够自动化运行(比如:在代码中准备测试数据,无需等待人工输入等待)、另一方面测试代码一定要用 assert 断言语句来明确判断结果(不能靠输出日志,用肉眼判断是否结果正确)。
再其次,我们来看看代码质量的一些基本准则和重构要求。一般来说,既要避免“过度开发”或“过度抽象”,又要满足“功能需求”,我们的代码(包括功能代码和测试代码)需要遵循如下的原则:
功能正确:通过所有测试;
减少重复:尽可能避免重复,抽取提炼公共逻辑。注意,是"尽可能"不是"完全无"重复;
代码可读:尽可能清晰表达,针对核心领域的业务逻辑,考虑引入领域内部的统一语言来表达实现逻辑;
更少代码:做恰如其分的设计,只要做到了减少重复和代码可读,就不要进一步拆分代码元素;
需要说明的是:当如上 4 条原则发生冲突时,以如上所列顺序作为优先级,进行由高到低的取舍。比如:如果发现更少代码会使得代码可读性变差,则放弃更少代码而优先考虑可读性。
更多更好的代码重构的建议,推荐大家去看看《重构 改善既有代码的设计》(马丁·福勒(Martin Fowler) 著,熊节,林从羽 译,各大电商平台有售)这本书,这里面有很多具体的技巧和例子。
最后,这里给出 TDD 编程“三定律”。三定律描述如下(基本对应 TDD“三重奏”):
定律 1:一次只写一个刚好失败的测试,作为新加功能的"精炼文档"——事实上,每个测试用例可以说是需求文档的“简化提炼”;
定律 2:不写任何产品功能代码,除非它刚好让失败的测试通过;
定律 3:只在测试全部通过的情况下做代码重构,或开始新加功能开发;
5.2 鉴权上下文代码实现
说完了 TDD 的基本概念和原则,我们下面就要实际来看代码怎么写了。提示一下,本篇的所有代码,均可以从gitee仓库,或github仓库获得。
5.2.1 完整展示一个应用服务的 TDD 任务分解:微信用户登录
现在让我们再次回到“微信用户登录;(组合任务,应用服务)”这一服务设计,对 TDD 开发的任务进行完整的分解。如下图所示:
从图中可以看出,我打算根据“先聚合再端口、先原子再组合、从内向外”的分解步骤(以下简称“分解步骤”),将测试任务按照任务分解的方式分为 6 组,说明如下:
第一组是针对领域服务“确保用户记录存在”的,在其内部将再根据分解步骤切分更细的测试任务(正如前述分解为 1.1~1.7);
第二组针对领域服务“生成用户登录令牌”,在其内部将再切分更细的测试任务(仍然遵循前面的分解步骤);
第三组针对领域服务“微信后台登录并校验”,在其内部将再切分更细的测试任务;
第四组是将前面三组测试扩展到更外层的组合任务“生成微信登录令牌(领域服务)”上;
第五组针对领域服务“记录用户登录日志”,在其内部将再切分更细的测试任务;
第六组就扩展到了最外层的组合任务“微信登录服务(应用服务)”的测试;
这样分完后,我们发现这个分解过程,非常类似于项目经理在进行项目管理时所做的“WBS(工作分解结构)”——将一个大的任务、先分块、再在分块中分更小的块。
下面我们一一来看各个分块里面的代码如何通过“三重奏”方式实现。
5.2.2 完整展示一个领域服务的 TDD 代码实现
为了完整展示 TDD 实现代码的过程,我在这里完整的展示一个领域服务的代码实现。在前面的介绍“TDD 方法和相关原则”时,我们已经将“确保用户记录存在”这一领域服务的实现拆分成了 1.1~1.7 的测试用例。如下图:
但在这里,我们再次检视这些测试用例的划分时,发现一个问题:在测试用例 1.2 中,我们需要对新建用户的持久化是否成功进行验证,就必须先要保证 UserRepository 已经具备根据用户 id“重建”User 对象的能力。为此,我们需要在“1.1 设置新用户 openid”和“1.2 新增用户持久化”之间插入一个新的测试用例“根据 userId 重建 User 对象”。调整后的测试用例设计如下图:
a. “1.1 设置新用户 openid”之 TDD 三重奏
第一奏,写测试代码如下(其中需要第二奏实现的业务代码,用红色框出来了):
这里有两点要注意:
测试代码永远是“given-when-then”三段论,也就是说“给定...,当做..,最后应该...”。这种格式的好处,就是将测试的工作模式固定下来,不会“写着写着”程序员就忘了应该怎么写测试代码,且非常有利于准确估算代码开发工作量。
方法名,建议是完整的英文语句,类似于“should....doing/giving...”的语句,不要怕方法名字长,而更应该注重于读代码的人一眼就能看出这个测试用例的目的是啥。
上面的测试代码写过后,我们对该代码调用的 User.of 工厂方法、user.setOpenid 实体方法,都是空实现,所以最后的 assertEquals 一定会失败。此时,代码处于“红灯”状态。
第二奏,写功能代码(前述红色框出来的方法内部代码)。在这里,我们将 User.of 工厂方法、user.setOpenid 实体方法分别实现如下图:
需要注意的有两点:
为了避免 User 对象被随意的创建,我们使用 of 工厂方法而不是构造子来实例化对象,而将 User 类的构造子隐藏了起来。
这里的 setOpenid 是使用 lombok 标签实现的,并且引入了 WxOpenId 值对象类,并进行了 JPA 类型适配。具体的相关技巧我们后面描述。
代码写到这里,就完成了第二重奏,代码状态转变为“绿灯”。
第三奏,重构代码。由于这个例子代码很简单,基本没什么业务逻辑,所以也没有重构的必要性。唯一需要注意的细节是:为了确保 assertEquals 检查成功,我们需要为 WxOpenid 值对象类重载 Object 基类的 equals 方法如下图:
到此,我们演示了一个完整的 TDD 代码实现过程。
b. 实现该组其它任务
该组 1.2~1.8 任务的全部测试代码,如下面图示(需要第二奏实现的业务功能代码,全部用红色框出来了):
从如上测试实现的过程中,我们可以看出:每次只测试了一项目标、并逐步驱动业务功能代码的开发。
c. 关于代码重构
我们大家都知道:在 TDD 第二奏完成业务功能代码(仅限于为了让测试通过)后,往往代码是不那么优美的,尤其是可能会出现重复编码、对象抽象不足等问题。由于领域服务“确保用户记录存在”的业务逻辑比较简单,不存在业务逻辑重构的代码,我们在后面的示例中会专门着重介绍。
在这里,我们专门演示一种“特殊”的代码重构——即重构测试用例代码。这就是:我们在“1.4 更新用户持久化;(原子任务,资源库端口)”测试用例代码中,为了验证更新后的用户信息是否正确,做了很多对象属性的对比,原始代码如下图所示:
可以看出,这里的红色箭头所指向的代码是很啰嗦的。而且,我们可以预判出,后面的测试用例中,肯定还有很多类似的检查 User 对象相关属性是否一致的逻辑。事实上,在后面的“1.5 针对微信前端小程序获得用户信息字段,更新已有用户信息(聚合行为)”、“1.7 确保前端微信用户信息对应的记录存在(老用户)”、“1.8 确保前端微信用户信息对应的记录存在(新用户)”、“6.1 微信用户正常登录”等共 7 个测试用例中都用到了。
为了避免代码重复(这是一种程序员难以忍受的“坏味道”——低效率拷贝粘贴重复劳动),我们将这些对比的逻辑,抽取一个公共的方法中,是比较靠谱的做法。又考虑到,该段逻辑在实际的业务功能中其实是没有需求的,为此,我们将其抽取到测试类的私有方法中,如下图:
然后,我们刚才“1.4 更新用户持久化;(原子任务,资源库端口)”的测试代码,就可以重构为如下内容(见红色箭头所指):
5.2.3 完成应用服务“微信用户登录”其它组的 TDD 实现
a. 第 2~6 组的测试用例设计
第二组测试用例设计如下图:
这里需要注意的是:本来,生成用户登录令牌只有一步动作,但由于存在新老用户两种情况,所以需要进行不同情况分支的覆盖,故设计了 4 个测试用例。
第三组测试用例设计如下图:
同样,需要考虑用户的微信登录 code 是否有效、以及是否出现网络上消息内容被篡改的可能性,设计了不同白盒分支覆盖的测试用例。
第四组如下图:
由于这里已经是很外层的领域服务逻辑,内部的各种情况分支已经在前面的测试用例覆盖,故这里只需要考虑登录正常和异常的情况。
第五组如下图:
这是极其简单的一个业务逻辑,不存在逻辑分支覆盖,故只有一个测试用例。
第六组如下图:
考虑到微信前端 code 存在过期不过期、是否新老用户、前端调用登录的网络传输过程中信息完整性被破坏等情况,进行了各种分支覆盖。
b. 关于各种情况分支的白盒覆盖
在设计测试案例时,我们还需要考虑各种情况下逻辑的分支。如:我们在对“2. 生成用户登录令牌;(组合任务,领域服务)”这一组测试任务进行用例分解时,需要考虑“新用户首次创建令牌、老用户更新令牌”两种情况。为此,我们对这个组合任务的测试工作分解如下表:
* 2.1. 创建用户登录令牌;(相关任务,用户聚合行为,用户原来无令牌,创建新令牌) * 2.2. 创建用户登录令牌;(相关任务,用户聚合行为,用户原来有令牌,更新令牌) * 2.3. 生成用户登录令牌;(组合任务,领域服务,保存令牌后重建用户对象,令牌正确) * 2.4. 生成用户登录令牌;(组合任务,领域服务,老用户更新令牌后保存,再重新加载后令牌已更新)
要知道,我们作为程序员,是最清楚自身的代码逻辑的,只有我们程序员自己才知道如何进行“白盒”分支覆盖。所以,这是一个特别重要的观念:尽可能在自己代码的单元测试中(TDD 的测试其实是单元测试),从“白盒”的角度考虑覆盖可能的各种分支。
说到这里,可能会有很多人会想:写测试代码这么麻烦,增加了多少编码工作量啊,在实际项目中这么做是不是不值当?我的观点是:这些多出来的代码工作量,绝对是“值得投入的”!因为这些单元测试用例的代码覆盖,将大大减少后面在交付测试后,由测试发现 BUG 后的代码调试和查错时间!虽然写业务功能代码的进度看起来慢了,其实后面整个项目的进度大概率会加快,而且代码质量还更好。事实上,随着 TDD 编码方式的熟练和习惯,程序员实现业务功能代码的速度可能并没有降低——很重要的一个原因,就是写测试用例会逼着程序员去确认业务需求细节,而这些早期对需求细节的确认,将大大减少代码的返工和重复开发。
c. 测试中实现第三方系统的模拟器
在实际的业务系统开发中,往往存在第三方“伴生系统”。如果这些“伴生系统”被我们在“南向网关”层调用,为了保证测试的 FIRST 原则,尤其是 F(快速运行)和 R(可重现),就需要实现“伴生系统”的模拟器。在本实例中,对微信 code2session 接口的调用,就需要实现其模拟器。
我们知道,DDD 菱形架构中,“南向网关”的端口是可以随意被不同的适配器替换的,而这种对第三方“伴生系统”的调用,就是通过南向网关的“客户端端口”实现的。对于微信 code2session 登录校验,我们设计的“客户端端口”定义如下图:
对应这个“客户端端口”的、真实发起到微信开放平台调用的适配器类实现如下图(注:其实现使用了 binarywang 的开源组件,见 github 库https://github.com/Wechat-Group/WxJava):
显然,这里的代码是需要设置微信小程序的相关配置信息、并调用微信平台接口才能正常执行的(将红色框起来的代码)。
通过阅读微信开放平台文档关于小程序登录的介绍,我们知道,任何一个前端小程序生成的 code,只能被使用一次。为了确保测试代码别反复执行,我们需要针对该端口开发另一个适配器:模拟适配器。我在这里写出来的模拟适配器见下图:
显然可以看出,这个模拟适配器是针对某些指定的输入参数、返回某些固定的结果的(当然,这些固定结果也是微信平台真实返回的值,只是我保存下来后固定返回而已)。
实现了“客户端端口”的模拟器,在 spring boot 项目中,我们还需要根据当前运行环境是否生产环境,而让代码自动选择使用真实的适配器、还是模拟适配器。为此,我使用了 spring 框架自带的注解 @Conditional,并实现了相应的判定类。这两个适配器的 @Conditional 注解如下图(见红色框):
考虑到 ProductionProfileCondition 和 DevOrTestProfileCondition 可能会在多个“限界上下文”中被使用,我就将这两个类的实现,放到了 SharedContext 中,代码如下图所示
5.3 商品和订单上下文(命令模型)代码实现
5.3.1 商品上下文代码实现中的重构
限于篇幅,这里不打算对 sprint1 的所有代码实现进行介绍。事实上,一一介绍也没有意义,大家有兴趣可以自己去下载代码来看。再次提示,本篇的所有代码,均可以从gitee仓库,或github仓库获得。
在本篇中,我只抽出一个 sprint1 中包含到的“商品上下文”应用服务,即“计算多商品结算;(组合任务,应用服务)”,对其代码实现过程中的“TDD 第三奏:重构”进行经验分享。
这个应用服务是“订单上下文”中“订单应用服务、购物车应用服务”都会用到的公共服务。该服务设计的任务分解见下图:
具体的 TDD 实现代码的过程细节,这里也不做太多介绍,而重点对涉及到“重构代码”的部分进行说明。
5.3.1.1 商品资源库多实例加载,性能问题引起重构
按照上面的“计算多商品结算”应用服务的任务分解,我们需要实现“从数据库重建多个商品对象”的方法,该方法是在商品资源库类(ProductRepository)中实现的。关于该方法的测试代码截图如下:
其中,ProductRepository 的 instancesOf()方法,实现代码如下:
可以看出,它是通过循环调用通用资源库类 GenericRepository 方法 findById() 来实现的。
这种实现方式有个缺点:就是多个商品 ID 会要求执行多条 SQL 语句,这从运行性能上来说,是很差的。为此,我们将这部分代码重构如下图:
可以看出,这里要求通用资源库类 GenericRepository 增加方法 findByIds(),以期望在该方法中通过 SQL IN 操作,实现一次数据库查询交互,返回多条商品信息。该代码实现如下图:
这里就展示了一个因为“性能问题”而引起的重构实例。
5.3.1.2 结算结果检查,重复代码引起的重构
对于“计算每个商品的结算价格和结算总价”这一聚合行为的测试,考虑到商品是否限购、商品是否有优惠、以及两种不同优惠策略,我们设计了如下 4 个测试用例:
对于这里的每个测试用例,一开始我们是这么写测试代码的:
从红色框线的地方可以看出:代码很啰嗦,而且后面 4 个测试用例都会反复用到这一比对逻辑。为此,我们需要将公共的、针对商品结算这一“瞬态对象”是否相等的逻辑抽象出来。
与前面那个“鉴权上下文”中对用户相关属性是否相同的比对不同,我们这里的 ProductSettlment 瞬态对象内容是否相同的比对,很可能会在后面的业务逻辑中被用到,所以我们将这一公共逻辑加到 ProductSettlment 类的行为中去,并通过重载 Object 基类的 equals() 方法来实现,如下图代码:
需要说明的是:考虑到“生鲜”类商品的现实情况,对于商品结算数量的对比只支持到小数点第三位。
这是又一个从“测试代码”中发现公共逻辑、但被提取到领域对象行为中去的一个重构实例。
5.3.1.3 结算结果返回结构,隐藏 BUG 引起重构
最后一个重构,涉及到多个商品结算“瞬态对象”如何返回的问题。我们先来看看“商品”实体对象 Product 类的“结算价格”方法 settlePrice() 的源代码:
可以看出,该方法返回的是个 ProductSettlement 对象。这是一种“瞬态对象”,既不属于实体对象、也不属于值对象。而是属于领域代码在运行过程中,需要“临时”在内存中存在的对象。
现在的问题是:我们的领域服务“计算多商品结算”,需要一次性对多个商品的价格进行结算,并返回结果给调用者。我们该用什么结构返回呢?
一开始,我用的是 List<ProductSettlement> 返回,领域服务“计算多商品结算”的代码截图如下:
而对应的领域服务测试用例如下图:
我对测试用例“3.1 根据多个已有产品的 id(所有商品均可售)、下单数量,正确完成结算”的测试代码编写如下图:
这样的代码,一开始做测试时发现是通过测试的。但是,当我使用 maven package 进行打包时,却有时候成功、有时候失败,这就让我很头疼。
后来,我就对 ProductSettlement 的 equals() 方法中加上打印输出,一旦发现不相等的属性时,就输出提示信息。才发现用 HashMap 传入给 ProductSettlementService 领域服务方法 calcSettlement() 的 productCountsMap 内的 keySet()返回,并不是原来 put 进去的顺序。
对于这个很“隐晦”的 bug,我试过使用 LinkedHashMap/ TreeMap,仍然不能解决问题。事实上,我们去看过 LinkedHashMap/ TreeMap 的源代码后,就知道它们其实并不保证 keySet()返回结果的顺序。为此,唯一能解决问题的办法,就是重构 ProductSettlementService 领域服务方法 calcSettlement() 的返回结果,不再使用 List<ProductSettlement> ,而是使用 Map<LongIdentity, ProductSettlement>,以便在返回结果的 key 中保存 productId,并要求调用方在处理时自行根据 productId 获取对应的 ProductSettlement。
修改后的 ProductSettlementService 领域服务方法 calcSettlement() 如下图:
相应的测试用例代码,修改如下:
这是一个写好代码后,在后面的集成测试时发现隐晦 BUG,而引起的代码重构的例子。
5.3.3 订单上下文代码实现中的重构
5.3.2.1 订单对象模型设计的迭代
在订单上下文“创建付款订单”等应用服务实现的过程中,发现前面文章《DDD 实战 (8):冲刺 1 战术之聚合设计》中对订单上下文聚合设计的一个错误。在该篇中,我们设计的订单上下的对象模型如下图:
事实上,这里的“Order”实体和“OrderPayment”实体,并不是一对多的“聚合”关系,而是“一对一”的关联关系。为此,我们将该对象模型图修改如下图:
通过这个例子的演示,是为了告诉大家:其实 DDD 的设计,是个反复迭代的过程,不要指望一次性在前面的设计中、使用一种“瀑布模型”的工作方式,就能够一次性搞定设计。很可能在后面的代码实现时,发现错误、或者需要调整设计。
这种“迭代式设计修改”是很正常的现象!也就是说:“重构”不一定仅仅是代码的重构,很可能还会涉及到前面所做的“业务用例规格书、聚合设计、服务设计”等任何一个工作成果的重构。作为程序员,在编写代码实现时,非常重要的就是要及时和架构设计师、需求分析师保持沟通,架构设计师、需求分析师也要用开放的心态随时保持对成果物的更新和调整——而不要有着“瀑布模型”的心态,认为自己的工作在早期都做完了,后面编码测试阶段没自己啥事了。
5.3.2.2 将支付平台特定 xml 请求串的生成逻辑进行剥离
订单上下文 TDD 驱动方式实现代码的细节,这里也不一一介绍。只对代码实现中发现的一个“逻辑剥离”的代码重构进行介绍。
我们知道,小程序的订单支付,需要用到支付平台接口(这里有点特殊,由于微信支付允许反复发起支付,故没有为其开发测试模拟器)。而我们的应用程序,是不希望只能支持一种支付平台的。现在我们的小程序运行在微信平台上,只需要“微信支付”;但是,如果我们将小程序移植到支付宝、或抖音等平台时,我们希望不要修改领域核心代码,而只需要替换相应的“客户端端口”的适配器类就好。
当然,与“特定支付平台”的接口交互,是放在“客户端端口”的适配器类中的。支付客户端端口定义如下图:
而针对微信平台的适配器类实现代码如下图:
理论上来说,如果将小程序移植到其它支付平台,只要开发一个新的 PrepayClient 的适配器类即可。
但现在有个问题:事实上,我们向支付平台发起支付请求时(即“订单预支付”),需要按照支付平台的格式要求,组装请求的订单内容字符串(一般为 xml 格式)。而这些字符串,必须要在用户提交订单的时候就生成、而不是到订单预支付时生成,这就导致不可能将该字符串的生成放到 PrepayClient 的适配器类中去实现了。之所以要在“提交订单”的时候生成,理由如下:
用户“提交订单”和“订单预支付”必须是两次交互,而不能是一次交互。因为我们允许用户在提交订单后,由于“手滑”等原因没有完成支付、而在已有的订单上再次发起支付。
“订单预支付”我们希望是个响应极快的服务,不会因为要进行很多复杂的计算处理(一般是结合订单的很多信息)、而导致该服务的响应时间达不到要求。对电商平台来说,支付成功率是个很核心的业务指标。为了提高支付成功率,哪怕只有 50ms 的服务器计算处理延时,也是我们难以忍受的。
这两个步骤的独立,体现到订单领域服务的实现上,就是两个方法,如下图:
因此,我们需要将“支付平台要求的请求字符串”在“提交订单”时就生成好,暂存到“订单”实体中并持久化,然后“订单预支付”时从 Order 聚合的属性中(其实是 OrderPayment 实体属性)直接获得而无需逻辑处理。
为此,为了实现支付平台变更这一“技术因素”和订单业务逻辑变化这一“业务因素”两个独立演变方向的剥离,避免耦合,我们需要将这个“支付平台请求字符串”的生成过程独立出来。这就需要我们将这个“支付平台请求字符串”的逻辑抽象为一个通用接口 PrepayRequestGenerationService(方便随时替换为其它支付平台)。
首先,我又回到前面《DDD 实战 (9):冲刺 1 战术之服务设计(上)》的服务设计,将原来如下图所示的“创建付款订单”的服务序列图:
修改为如下图(该图中,专门增加了 PrepayRequestGenerationService 领域服务类,用于生成支付请求字符串):
其次,我们实现这个通用接口 PrepayRequestGenerationService 的定义 。如下图:
最后,我们需要为这个接口,实现一个微信支付平台的实现类 WxPrepayRequestGenerationService,部分代码截图如下:
这里,其实还有个待决架构技术决策问题:这个具体平台的实现类,到底应该放在目录结构的什么位置呢?我个人的想法,是模仿 port/adapter 模式,将接口放到 port 中、而实现类放到 adapter 中,但总感觉有点奇怪。大家有什么好的建议,欢迎随时与我交流。
这里演示的是一个“演进因素”与领域核心业务逻辑演进不一致时的“重构”策略。事实上,大家可以花力气去好好看看 Martin Flower 的这本书《重构 改善既有代码的设计》,这里面有很多不错的代码重构建议。
5.4 商品上下文(查询模型)代码实现
查询模型即 CQRS 的代码实现,方式比较传统,我这里使用的是 MyBatis Plus,就只实现了一个服务:获取店铺内商品目录及列表(CQRS)。其代码结构如下图:
其中:
标号 1 处,放的就是根据数据库表生成的 java bean 映射(即所谓的 entity 类),比如 Product 类代码如下图:
标号 2 处,放的就是 mybatis 的 mapper 定义,都基本是空接口,如下图:
标号 3 处,放的是所谓“服务类”接口和实现。比如查询店铺的上架商品(按商品分类建立树状结构),服务接口代码为(从这里可以看出,这种 CQRS 服务的代码返回结果,可以很复杂,数据结构定义很随意):
该服务接口类的实现代码比较啰嗦,各种查询和数据结构组装,大致贴出来,其核心代码长下面这样(不建议仔细看,价值不大):
标号 4 处,放的是对外输出的 RESTful 资源接口,这里不再赘述。
整个 CQRS 查询模型的代码,唯一值得提醒的是:这种查询类代码,确实不太适合在领域模型下实现,所以建议还是分离代码模型去实现。甚至,如果可以的话,可以通过实现某个“动态可配置的 SQL 查询”通用类来实现查询。
5.5 代码实现细节相关技巧
在本篇的代码实践中,有些具体的战术细节问题,这里也一并介绍下。
5.5.1 MapStruct 的引入和使用注意
任何服务端代码,总是最终要向外提供服务的。因为我们不想将 DDD 领域层的实体对象、值对象等逻辑暴露到调用者那里,或者说不想因为调用者输入、输出格式的变化,而影响到业务领域的核心逻辑。我们就必须要使用 DTO(数据传输对象),将领域层的实体对象、值对象相关属性转换为 DTO 对象的属性,再暴露给调用者。
在本系列的文章中,我们将 DTO 放在 pl(发布语言层)。很显然,要在 DTO 和领域层之间交换信息,就会出现大量的 getter/setter 代码调用,这是很繁琐的、也是程序员所不喜欢的“机械化劳动”。为此,我们引入 mapstruct 组件,帮助我们简化这部分工作。
正式使用 mapstruct 前,建议阅读官方文档《MapStruct 1.5.2.Final Reference Guide》。文档不长,基本上 1 个小时内肯定能够阅读完了。英语不好的话,直接谷歌翻译以后的版本,看起来基本也没啥偏差。
5.5.1.1 引入 mapstruct
其实很简单,maven 项目中,只要在 pom.xml 加入依赖,即可使用。如下图:
但在 pom.xml 中,有几个注意事项:
如果使用了 swagger 文档生成工具,一定要对其进行排除,如下图:
如果使用了 lombok 代码自动生成工具,一定要注意在 maven 编译器中的配置顺序,必须 lombok 先生成代码、再 mapstruct 生成代码。如下图配置:
在本篇的代码中,我们将 mapstruct 的代码,全部放在 pl 层中,如下图所示举例:
图中红色框出来的,都是 mapstruct 要求的 mapper 接口配置。这些 mapper 接口的配置其实很简单,只是定义要如何在 DTO 和实体类之间进行属性转换。例如,下面两个 mapper 定义了 Order 实体对象和 OrderResponse 之间的转换:
有了这些 mapper 定义,我们就可以在 DTO 类中,通过简单的调用就实现转换。如下图:
可以看出,OrderResponse 这个 DTO 类,将 Order 转换为自身的方法,只是调用了 mapstruct 接口 OrderResponseMapper 的 convert 方法。
需要特别说明的是:一定只能在 DTO 类方法中实现 DTO 和实体类之间的转换,千万不要在领域层的实体类中实现这种转换。因为按照菱形架构要求(或者经典的六边形架构要求),我们是不希望领域核心层的代码,依赖任何对外输入输出的逻辑的(即:内层不允许依赖外层、但外层可以依赖内层)。而外围 pl 层(发布语言层),是可能随着前端界面、渠道接入等需求的变化而变化的。
5.5.1.2 mapstruct 使用限制
mapstruct 作为开源组件,其本身也还在演进中。目前从我自己使用的情况来看,它在某些情况下,还是不太适用的。
这主要发生在要将 DTO 转换为领域实体对象时。因为 mapstruct 要求实体对象必须有公开的构造子、或者有一个简单格式的 builder 构建者模式,而我们领域层实体对象其实很多时候是不适合提供公开构造子、甚至简单的 builder 构建者模式也不能满足实体对象的实例化需求。这种情况下,就不建议使用 mapstruct,而直接在 DTO 的方法中实现领域实体对象的实例化。
例如,在“订单提交”应用服务中,就需要将前端用户填入的“订单提交请求”转换为领域层 Order 实体对象,这里面包含 Order 本身的构建、以及 Order 中订单行 OrderItem 列表对象的构建。这部分 DTO 转换代码如下图:
上图红色框出来的代码,就是根据 OrderSubmitRequest 这一 DTO 类去构造 Order 实例的转换代码。这个转换代码中,有两个地方 mapstruct 实现不了:
不允许调用 Order 类的构造函数,而调用 Order 类的工厂方法 createFor 实例化 Order 对象。当然,这不是强制要求,实际上 Order 类作为聚合根对象,是可以有公开构造子的,但我在编码习惯上不喜欢这么做,总觉得构造子的灵活性不佳。
不允许调用 OrderItem 类的构造函数,只能通过 Order 类的 addItem()方法来为订单增加订单行。这是必须要保持的“领域层设计原则”,不能将 OrderItem 的构造子公开出来,因为 OrderItem 不是聚合根,是不允许外界直接对其构造实例的。
也就是说:一般情况下,稍微复杂一点的客户端请求 DTO,要转换成领域层实体对象,可能受限于领域层“只允许访问聚合根”的条件,mapstruct 就发挥不了作用。
当然,将来如果 mapstruct 版本继续进化,支持相对灵活的 builder 构建器,应该也可以用到这种场合。
5.5.1.3 mapstruct 引用模式
按照 mapstruct 的文档,其 mapper 的引用,是可以有两种模式的。模式一就是我前面代码中的示例,使用 Mappers 工厂方法获取实例,类似这样的代码:
模式二是使用 spring 依赖注入的方式,如下面这样的代码:
在本篇中,我使用的是模式一(我推荐大家也使用模式一),其根本性理由是:我都是在 DTO 类中调用 mapper 的,而 DTO 往往是 spring-web 框架根据 http 请求协议、将用户前端的输入数据自动转换出来的“非单例”对象,这是没法执行 spring 的单例 bean 注入的。
5.5.2 JPA 适配和实体类构造
在前一篇《DDD 实战 (10):冲刺 1 战术之服务设计(下)及技术决策》中,我们指出因为要对“聚合”整体而非单实体对象进行“插入、重建、更新、删除”等操作,故决定引入 JPA 来作为 ORM 框架。但在实际使用 JPA 的过程中,我们需要注意一些细节问题,否则可能会把好不容易设计好的 DDD 领域实体模型弄的很乱。
5.5.2.1 JPA 类型适配
由于数据库类型和 java 对象类型之间的不适配,包含 4 种情况需要特殊说明:
每个 JPA entity 类,必须有 ID 主键字段定义。在我的代码中,由于所有的实体类,均具有 LongIdentity 自定义类型的主键,故代码一般使用 @EmbeddedId 注解来标记。
任何自定义类型的类,如果要成为 JPA entity 的属性,必须是两种情况的一种:
使用 @Embeddable 注解修饰该自定义类,并在 JPA entity 中使用 @Embedded 注解表明该属性。如本篇中用到的 LongIdentity、PriceFen、NonNegativeDecimal 等类型。示例代码:
使用类似 @OneToMany、@ManyToMany、@OneToOne 等建立起两个 JPA entity 之间的关系映射。如下面示例:
对于一个 entity 中使用同一个自定义类型来定义多个属性的情况,需要通过 @AttributeOverride 注解指名这些多属性是如何映射到数据库表的,否则会出现字段命名冲突。代码示例如图(注意红色箭头所指):
5.5.2.2 JPA 对象模式适配
JPA 对象模式的适配,有几个小细节需要注意。说明如下:
对于真正是两个实体类之间的关联关系,建议通过 @ManyToOne、@OneToMany、@OneToOne、@ManyToMany 等注解进行关联。
对于实体中的“值对象”属性,如果想将“值对象”的数据持久化到独立的数据库表中,千万不要使用 @OneToMany 这样的关联注解,这种关联注解是仅用于 JPA entity 的。不要为了实现持久化,而改变 DDD 对象类的性质!
对于实体的“值对象”属性,当需要将值对象的内容持久化到一个单独的数据库表时,可以使用这两个注解:@ElementCollection(用来指明是“值对象”) 和 @CollectionTable(用来指明独立的表名、外键字段等) 。比如 User 类的 UserToken 值对象,可以作为 User 类的属性,代码示例如下:
问题是:JPA 这种支持“值对象”保存到单独表的做法,不支持“一对一”的关系。对于 User 对象只有一个有效 UserToken 这种情况,需要使用特殊的情况。我的做法是:将 UserToken 的 List 属性设置为不可读、然后定义一个独立的方法 currentToken()用来返回那个唯一的 UserToken。代码示例如下:
当不需要将实体对象的属性持久化到独立的数据库表中时,只需要使用 @Embedded 注解,JPA 就会将其保存到对应的实体表字段中去(可通过 @Column 注解指定列名)。
5.5.2.3 JPA 下枚举类型的处理
很多值对象其实是“枚举类型”,如:订单送货方式、订单状态、支付状态等等。JPA 默认情况下,对枚举类型的支持不是很好,只支持要么是“枚举的变量名称”、要么是“枚举的数字序号”持久化到数据库。这在很多时候,是很不方便的。如果使用“枚举的变量名称”,因为我们是中文环境,会看起来很别扭;如果使用“枚举的数字序号”,就必须从 0 开始,也很别扭。
对于这种情况,我在代码中使用了 Hibernate 的 UserType 接口,通过如下 3 步骤来实现枚举类型的处理:
在“共享内核上下文”,即 SharedContext 模块中,作为公共类实现了一个自定义的 PersistentEnumUserType 抽象基类。该基类的代码截图如下:
然后,在每个限界上下文的软件分层中,增加了一层“usertype”,并在该层实现具体的 UserType 类。代码示例如下(针对订单状态值对象类型):
最后,在领域核心 domain 层中,对于实体类如果使用枚举类型,就使用 hibernate 注解 @Type 来标记枚举属性。代码示例如下(针对订单的状态属性):
5.5.2.4 JPA 对领域实体对象的“侵入”问题
总体来看,其实 JPA(源于 Hibernate)是对实体对象有一定的“侵入”的,尤其是加了那么多乱七八糟的注解后,让 Domain Entity 对象类看起来不那么“优雅”。
如果说这些“注解”尚可忍受,那有一个“侵入”问题则几乎是无法忍受的:JPA 要求所有的实体类,都必须有个无参数的默认构造子。
这将会产生如下的两个“坏味道”:
大大“破坏”我们领域实体类的“封装性”。我们知道,按照 DDD 聚合设计的原则,除了聚合根以外,是不允许其它实体对象向外暴露“构造子”的。
我们的实体类,其构造过程往往带有特定的“业务逻辑”,而无参数的“默认构造子”使得我们有点“无所适从”,不知道怎么给特定业务含义的“实体类”定义默认构造子的行为。
幸运的是:JPA 要求的默认构造子,是可以 protected 模式的。为此,我的建议是:将所有需要 JPA 持久化的实体类,均设置一个 protected 模式的无参数构造子。
5.5.3 北向网关 remote 和 local 服务的边界
一般来说,在 TDD 测试用例的设计中,是不需要考虑远程服务的测试的。这是因为,我们是这样定义北向网关的远程服务和本地服务之间的职责边界的:
远程服务,只负责对外网络协议层的数据包装,没有任何实质性的业务逻辑。甚至连用户端请求参数取值的正确与否都不做判断。
本地服务,负责将 DTO 对象转换为领域对象,并调用领域服务执行业务逻辑。
需要特别说明的是:由于 Spring-web 框架支持将 http 请求自动转换为 DTO 对象,故一般建议将 DTO 对象作为远程服务的输入参数,并将该输入参数直接“透传”到本地服务去执行。
按照如上的职责边界,我们可以看看“鉴权上下文”和“订单上下文”的远程服务代码示例分别如下(红色箭头所指,可以看出只是起到透传作用):
5.5.4 关于测试数据确保测试自动可重复
为了保障测试用例的自动化、可重复的运行,在测试代码中,需要考虑测试数据的准备。一般来说,我们本着这样的原则准备测试数据:
当测试用例所使用的测试数据,是本“限界上下文”对应的实体数据时,使用已经测试过的功能代码来生成测试数据。例如:在“商品上下文”对商品价格结算进行测试用例设计时,每个测试用例都会在前面“given”部分准备测试数据,例如下图所示:
当测试用例所使用的测试数据,不是本“限界上下文”的实体数据时,使用 sql 语句预先在数据库中插入一些测试数据。例如:在订单上下文的测试用例中,需要用到鉴权上下文的用户数据、商品上下文的商品数据,则这些数据建议使用 sql 语句预先插入。如下图:
为了确保这些原则的执行,我们需要注意如下两方面编码细节:
因为要使用业务功能代码准备本限界上下文的测试数据,所以测试用例的设计要注意先后次序。对于前面举例的“商品结算”测试用例,需要在前面的测试用例中,先完成“商品新增的持久化”测试,然后再在后面完成“商品价格结算”的测试。
使用 SQL 脚本准备跨限界上下文的测试数据,我们要注意控制插入数据的数量和复杂度,并注意将 SQL 脚本当做程序代码一样进行版本管理,并谨慎编排这些 SQL 脚本的执行顺序。因为整个工程在进行集成测试、DevOps 自动化时,需要考虑这些数据随时可创建、可删除。
本篇写到这里,应该算讲清楚了如何基于 TDD 方法进行 DDD 设计的代码实现了。再强调一遍:使用 TDD 是为了确保“代码设计”到“代码实现”的同构。一般来说,除了这个最根本的目的,使用 TDD 还会为我们带来如下 3 个好处:1)任务分解驱动,工作量可视可控,非常有利于冲刺中的进度估算和控制;2)测试任务驱动,每次仅为了满足测试通过而编写功能代码,避免代码的过度设计、过度抽象;3)单元测试代码经过严格的逻辑覆盖,确保了高质量的代码交付。
本篇结束后,下篇将会是本系列的终结篇:主要说一下微服务拆分的一个关键问题——如何将原来处于一个微服务中的多个“限界上下文”,通过简单的“客户端端口适配器的重实现”而无需修改任何领域层代码,就能够做到将两个“限界上下文”拆分到不同的微服务。
版权声明: 本文为 InfoQ 作者【深清秋】的原创文章。
原文链接:【http://xie.infoq.cn/article/1b54d75814e80ee6e63a37011】。文章转载请联系作者。
评论 (1 条评论)