戏说领域驱动设计(十九)——外验

内验是针对领域模型自身的验证,其验证规则也是由领域模型自已来完成,只是触发的时机可能在工厂中也可能在构造函数中。与内验对应的当然就是外验了,这是用于对用户的输入和业务流程的前提或得更专业一点叫“前置条件”的检验。如果细化一点,可以将外验分成两个情况:用户输入和业务流程的前置条件。情况不同验证的方式也不一样,下面让我们展开了细聊。对了,额外多说一句,此处的“内验”和“外验”是我为了说明问题所起的名称,其实叫什么您只要能和团队成员说明白就行,名字并不是很重要。
一、基于外部输入的验证 对外部的输入进行验证其实很简单,有多种现成的手段可用比如SpringBoot里的类库“hibernate-validator”,引入后直接使用即可。这种验证方式仅限于视图模型或简单类型,不建议在领域模型中也进行使用,会造成BO与基础设施的强绑定,看过前面内容的您应该知道,减少对基础设施的依赖是六边型架构的典型特征。回到正题,我个人在面对外部输入的时候,如果是视图模型,便在模型中直接嵌入验证代码;如果是简单类型,则将验证的逻辑交付地一个验证工具进行。这样做的好处是业务逻辑中的代码量比较少,看起来干净;另外就是由于工具是可以复用的,所以减少的代码量总的算起来还是不少的,毕竟验证是一个刚需。熟悉本系列文章的老朋友应该发现我提到了很多次的“代码干净、整洁”,这个并非是可有可无的要求,而是应当在开发过程中随时要注意的。在满足需求的同时有效代码越少系统可维护性越高;涉及到工作交接或增加人手等相关工作,这些在IT团队中非常常见的情况本来成本是不低的,但如果能在代码书写度方面给予重视,成本是可以降下来的。
基于视图模型的验证,通过为所有的模型增加一个支持验证的基类来实现,具体类可通过对用于验证的方法进行覆盖来实现自定义的验证规则。这种实现简单明了,也不用做太多的额外的工作。虽然说Spring有现成的框架,但我不太喜欢在代码上加入各种注解,显得乱。现实中您可以使用Spring框架所提供的能力,而我在这里写出来是为了展示验证实现的思想。下面的类图展示了这种设计的类结构。
戏说领域驱动设计(十九)——外验
文章图片


“Validatable”接口在上一章已经进行了介绍,这里需要重点说明的是“VOBase”。所有的视图模型都从它继承,由于接口“Validatable”的存在使得视图模型具备了可验证性。方法“validate()”用于提供具体的验证逻辑,不过我们在VOBase只是对其做了简单的实现,毕竟抽象类也没什么可进行验证的。“ApprovalInfo”是一个视图模型具体类,对“validate()”方法进行了覆盖并加入了实现逻辑。其实我一度在想,在这里贴这类简单的代码是不是对您的技术水平有一定的侮辱,不过既然都看到这儿了您就索性多看两眼,毕竟我们想要强调的验证思想和意识,看看别人怎么做的再考虑自身如何提高。

public abstract class VOBase implements Validatable {@Override public ParameterValidationResult validate() { return ParameterValidationResult.success(); } }public class ApprovalInfo extends VOBase { @ApiModelProperty(value = "https://www.it610.com/article/审批人ID", required = true) private String approverId; @ApiModelProperty(value = "https://www.it610.com/article/审批建议", required = true) private String comment; @Override public ParameterValidationResult validate() { if (StringUtils.isEmpty(approverId)) { return ParameterValidationResult.failed(OperationMessages.INVALID_APPROVER_INFO); } if (StringUtils.isEmpty(comment)) { return ParameterValidationResult.failed(OperationMessages.INVALID_APPROVAL_COMMENT); } return ParameterValidationResult.success(); } }

我们再进一步思考一下,其实无论验证是针对VO还是基本类型的,本质上都是对参数的验证,那就完全可以将参数的验证规则抽象成规则对象,原理同内验是一样,只是内验把规则封装在了领域对象的内部。而且,上面我只是写了VO对象验证的逻辑并没有进行触发,也的确需要一个调用验证方法的点。对此,我们设计一个工具类“ParameterValidators”,类图如下所示。把验证规则如“VORule”封装在“ParameterValidators”中,用户在触发验证的时候会循环所有内嵌的规则并调用规则本身的验证逻辑。
戏说领域驱动设计(十九)——外验
文章图片


又有一个我们熟悉的接口“Validatable”,截止到目前已经用到了三次了,感觉这是我的职业生涯中设计最成功的接口,复用度极高。多说一句,其实很多程序员在使用接口的时候只是为了用而用,实际上并没有考虑为什么、要在什么场景用。这里有一个小小的提示:接口的作用是“赋能”,您在设计的时候要从对象能力这个角度去考虑,千万别用岔了,否则容易出现设计过度的情况。回归正文,通过上面的类图我们可以知道,有多个具体的类实现了“Validatable”接口,比如“StringNotNullRule ”、“VORule”等,“ParameterValidators”则汇聚了这些规则。代码片段请看示例。
final public class ParameterValidators { /** * 验证 * @throws IllegalArgumentException 参数异常 */ public void validate() throws IllegalArgumentException { if (this.parameters.isEmpty()) { return; } for (Validatable parameter : this.parameters) { ParameterValidationResult validationResult = parameter.validate(); if (!validationResult.isSuccess()) { throw new IllegalArgumentException(validationResult.getMessage()); } } }/** * 增加待验证的视图模型 * @param vo 视图模型 * @param messageIfVoIsNull 当视图模型为空时的提示信息 */ public ParameterValidators addVoRule(VOBase vo, String messageIfVoIsNull) { this.parameters.add(new VORule(vo, messageIfVoIsNull)); return this; }/** * 增加业务模型ID验证 * @param targetValue 待验证参数的值 * @param errorMessage 错误提示 * @return 参数验证器 */ public ParameterValidators addStringNotNullRule(String targetValue, String errorMessage) { this.parameters.add(new StringNotNullRule(targetValue, errorMessage)); return this; } }class VORule implements Validatable { private VOBase vo; private String messageIfVoIsNull; @Override public ParameterValidationResult validate() { if (vo == null) { if (StringUtils.isEmpty(messageIfVoIsNull)) { return ParameterValidationResult.failed(OperationMessages.INVALID_BUSINESS_INFO); } return ParameterValidationResult.failed(messageIfVoIsNull); } ParameterValidationResult validationResult = vo.validate(); if (!validationResult.isSuccess()) { return ParameterValidationResult.failed(validationResult.getMessage()); } return ParameterValidationResult.success(); } }

针对视图模型的验证实际上是调用了VO对象的验证逻辑;针对简单类型的验证则是设计了一些验证规则如“StringNotNullRule”。“ParameterValidators”包含了一些“add*”方法,通过调用这些方法把待验证的目标加到本对象中,“validate”会循环其内部包含的规则并触发验证,一旦有不合法的情况出现则直接抛出异常。您也可以通过将异常信息进行汇聚和包装来统一给出验证结果。有了这些基础设施的支撑,我们在业务代码中进行参数验证时会节省很多精力,写出的代码看起来很干净、整洁,如下片段所示。
public CommandHandlingResult terminate(DeploymentResultVO resultVO, Long approvalFormId, OperatorInfo operatorInfo) { try { ParameterValidators.build() .addVoRule(resultVO, OperationMessages.INVALID_DEPLOYMENT_RESULT) .addVoRule(operatorInfo, OperationMessages.INVALID_OPERATOR_INFO) .addObjectNotNullRule(approvalFormId, OperationMessages.INVALID_APPROVAL_FROM_INFO) .validate(); …… } catch (IllegalArgumentException | ApprovalFormOperationException e) { logger.error(e.getMessage(), e); return new CommandHandlingResult(false, e.getMessage(), null); } }

二、业务流程前置条件验证 业务流程前置条件的验证相对要比参数验证复杂得多,比如这样的需求“用户下订单前,需要判断库存是否大于0且账户不能是冻结状态”,这里的两个约束是下单业务的前置条件。如果您仔细分析一下会发现前置验证的条件验证不同于参数和对象的验证:前者一般需要使用其它服务提供或从数据库中查询出的数据作为判断依据;而后者一般是对自身属性的判断,不需要使用外部数据,您还真别小看这种不同,它限制了后续验证的实现方式,后面我们会详解。上述作为假想的案例,乍一看感觉实现起来应该非常简单,在用户创建订单对象前把库存信息和账户信息分别查询出来,并根据需求进行条件的验证,代码可能是下面这样的。
@Service
public class OrderService { public void placeOrder(OrderDetail orderDetail, string accountId) { AccountVO account = this.accountService.find(accountId); if (account.getStatus == AccountStatus.FREEZEN) { throw new IllegalOperationException(); } StockVO stock = this.stockService.find(orderDetail.getProductId()); if (stock.getAmount() < 0) { throw new IllegalOperationException(); } Order order = OrderFactory.create(orderDetail); …… } }

我相信大多数开发都会按上述代码的方式进行开发。实际上这种方式有点四不像,“Order”使用了面向对象编程而两个验证条件是典型的面向过程思维。这里有三个显示的问题:1)当前的验证条件有两个,如果再加上新的条件呢?比如“下单前,账户信用额度要大于0;账户余额要大于0;用户必须实名认证的;必须是首单用户等”,我可以一口气说出几十种条件,按上述的写法肯定要包含大量的“if……”,代码基本就没法看了;2)这些前置条件其实是一种业务规则,您把业务规则放到应用服务中是不合理的。因为我们一直强调,应用服务中只做业务流程控制,不应该包含业务逻辑,面向过程的代码才会这么干;3)这些业务的前置条件没有复用的可能性。比如“首单用户”规则,在秒杀订购场景需要使用;在购买具备优惠活动的产品时也会有需要,所以你不得不在使用的时候把代码全复制过来。这种代码上线的时候容易,一旦涉及到规则变更,改起来就是个噩梦,你能说得清楚有多少个地方使用了重复的代码吗?
问题我们已经列举了出来,那么如何解决这些问题?我们可以简单的根据上面所说的三点问题一一解决掉。针对问题一,可以把前置条件的验证全提到一个服务中或另一个方法中即可解决;针对问题二,可以把这些业务规则独立出去作为一个个的领域模型,只是我们需要注意前文中说过的这些规则所用的数据来源于外部系统或数据库,而领域模型是不能使用这些基础设施的,所以就需要你在构造的时候把这些信息先从应用服务中提出来;针对问题三,既然能把每个规则封装成独立的领域模型,那这些规则就具备了复用性,所以针对问题二的解决方案是一箭双调的。
有了解决思路我们就需要考虑一下如何设计实现,既然订单服务中有这么多的限制条件,我们可以做一个验证的的框架,这种框架不仅能用于订单服务的验证,如果设计得当也可以在其它服务内部复用,毕竟前置条件验证是一个刚性需求。另外,框架需要提供验证所需要的信息比如进行数据库查询,需要组织验证规则,所以其实现一定是个应用服务。据此,我们的类图所下所示。
戏说领域驱动设计(十九)——外验
文章图片

这个类图相对复杂一点,让我们来解释一下具体的含义。这里面有一个似曾相识的老朋友“Validatable”接口,不过这个和前面的不太一样(其实可以一样的,只是案例代码实现有先后,如果您打算使用本文的设计思想,请尽量实现统一),验证的方法中多了一个参数“ValidationContext”,这是一个抽象类,需要在具体实现的时候包含用于获取验证数据的信息。以上面的下单场景为例,当然就是账户ID“accountId”和订单详情“orderDetail”。所以您需要新建一个继承自“ValidationContext”的具体类并把账户ID作为属性,用于验证的应用服务使用账号ID调用账户服务来获取账号信息。下面代码片段为“Validatable”接口的定义以及验证应用服务的示例。

public interface Validatable { /** * 验证方法 * @param validationContext验证上下文 * @throws ValidationException 验证异常 */ void validate(final ValidationContext validationContext) throws ValidationException; }public abstract class ValidationServiceBase implements Validatable {private ThreadLocal validatorThreadLocal = new ThreadLocal(); /** * 验证服务 * * @param validationContext 验证信息上下文 * @throws OrderValidationException 验证异常 */ @Override public void validate(final ValidationContext validationContext) throws ValidationException { this.validatorThreadLocal.set(new Validator()); this.buildValidator(this.validatorThreadLocal.get()); this.validatorThreadLocal.get().validate(validationContext); }/** * 构建验证器 * @param validator 验证器 */ protected abstract void buildValidator(Validator validator); }

我们前面说过了,用于验证的服务是一个应用服务,所以我们为这个服务设计了一个基类,也就是上面的“ValidationServiceBase”,方法“validate”用于触发验证逻辑;方法“buildValidator”用于在其中加入待验证的规则,注意:这些规则是领域模型。这里引入了一个新的对象“Validator”,作为验证规则的容器里面包含了“ValidationSpecificationBase”类型对象的列表。在触发ValidationServiceBase.validate()方法时,会调用Validator.validate(),后者会遍历Validator中的验证规则“ValidationSpecificationBase”再调用每个规则的validate()方法。不论是“ValidationServiceBase”、“Validator”还是“ValidationSpecificationBase”,由于实现了“Validatable”接口,所以都会包含方法“validate()”,具体代码如下所示。
public class Validator implements Validatable { //订单验证规则列表 private List specifications = new ArrayList(); /** * 验证方法 * @param validationContext验证上下文 * @throws ValidationException 验证异常 */ @Override public synchronized void validate(final ValidationContext validationContext) throws ValidationException { Iterator iterator= this.specifications.iterator(); while (iterator.hasNext()) { ValidationSpecificationBase validationSpecification = iterator.next(); validationSpecification.validate(validationContext); } clearSpecifications(); } }

public abstract class ValidationSpecificationBase implements Validatable {}public class AccountBalanceSpec extends ValidationSpecificationBase { private Customer customer; public AccountBalanceSpec(Customer customer) { this.customer = customer; }@Override protected void validate(ValidationContext validationContext) throws OrderValidationException { if (this.customer.getBalance == 0) { throw new OrderValidationException(); } } }

有了上述的基本类型作支撑,我们就可以在业务代码中加入用于验证的领域模型和用于验证的应用服务,案例中的“判断账户余额”验证规则可参看上面代码“AccountBalanceSpec”的实现(再提示一次:这是一个领域模型)。那么余下的就是看如何设计用于验证的应用服务了,代码如下片段。
@Service public class OrderValidationService extends ValidationServiceBase { /** * 构建验证器 * * @param validator 验证器 */ @Override protected void buildValidator(Validator validator) { Customer customer = this.constructAccount(validator); //账号状态验证 validator.addSpecification(new AccountBalanceSpec(customer));
//可加入其它验证规则 }private Customer constructAccount(Validator validator) { String accountId = (OrderValidationContext)validator.getContext(); //通过调用远程服务查询账户信息
AccountVO = ……
//构建客户信息
Customer customer = ……
return customer; } }

有了验证服务,我们就可以按如下代码的方式实现下单场景的验证。对比一下前面的那种四不像的方式,您觉得这种方式是不是要好得多。
@Service public class OrderService { @Resource private OrderValidationService orderValidationService; public void placeOrder(OrderDetail orderDetail, string accountId) { OrderValidationContext context = new OrderValidationContext(orderDetail, accountId); this.orderValidationService.validate(context); Order order = OrderFactory.create(orderDetail); …… } }

总结 【戏说领域驱动设计(十九)——外验】本章代码有点多,如果您一遍没整明白,可以多看几次。为了减少代码的量,我阉割了部分内容,所以如果出现对应不上的情况是正常的。最重要的是您得学会一种面向对象编程的思想和解决问题的思路。我在前面的文章中提过二级验证,此处的两级就是指外验与内验。另外多提一句,两能验证只适用于命令类的方法。查询直接通过参数验证即可,不需要这么复杂的判断。这里其实暗含一个思想:在设计命令类方法的时候务必要保持谨慎的态度,做到足够的验证是对自己的一种保护。截止到本章结束,我们已经总结了验证相关的知识。如果您在回顾一下内容就会发现通过这两种验证,您在写业务代码也就是编写业务模型中的代码的时候,根本不用判断这个字段是否为空,那个字段是否数据不对;下沉到比如DAO层也不用再写验证相关的代码,因为DAO的上层是BO,数据是否正确在BO中已经进行了保障。

    推荐阅读