1. 规则验证是准确性的基础
规则验证是业务稳定性的重要保障手段,通过规则验证,可以验证和确保系统或业务逻辑的正确性和合规性,避免潜在的错误和问题。而规则的遗漏往往会伴随着线上bug的出现。
相信每个开发人员都曾面对过以下情况:
- 未对入参进行非空判断,在执行逻辑时导致空指针异常(NullPointerException,简称NPE);
- 未正确验证用户权限,导致未授权操作发生,普通用户也能执行该操作,最终产生安全问题;
- 在数据被存储到数据库时,没有进行完整性验证,导致无效数据被存储;
- 在业务逻辑中,未对可能抛出的异常进行适当的处理,导致系统无法正常运行;
- …
可见,验证对流程极为重要,不合理的输入会导致严重的业务问题。同时错误数据的影响也比想象中的大得多:
- 可能会导致整个写流程异常中断;
- 错误数据入库后,会对所有的读操作造成致命的伤害;
- 上游系统数据错误,下游系统纷纷“崩溃”;
2. 防御式编程
如何避免上述情况的发生,答案就在 防御式编程。
防御式编程(Defensive Programming)是一种软件开发方法,目的是在代码中预测可能出现的异常和错误情况,并用适当的措施对其进行处理,从而提高软件的健壮性和稳定性。通过防御式编程,软件开发人员可以在软件功能相对复杂的情况下,避免和减少由于程序错误所导致的不可预测的行为和不良影响,并保障软件在部署和运行时的正确性和稳定性,提高软件可靠性和安全性。
防御式编程的核心思想是在代码中尽量考虑一切可能出现的异常和错误情况,并在代码中针对这些异常和错误情况做出相应的处理。例如,可以使用异常捕获机制处理可能出现的异常,充分利用代码注释和约束条件来规范输入数据,使用断言(assert)来检查代码中的前置条件和后置条件等。
概念过于繁琐,简单理解:防御式编程就是:
- 不要相信任何输入,在正式使用前,必须保证参数的有效性;
- 不相信任何处理逻辑,在流程处理后,必须保证业务规则仍旧有效;
对输入参数保持怀疑,对业务执行的前提条件保存怀疑,对业务执行结果保持怀疑,将极大的提升系统的准确性!
3. 异常中断还是返回值中断?
在规则校验场景,优先使用异常进行流程中断。
3.1. 异常中断才是标配
在没有提供异常的编程语言中,我们只能使用特殊返回值来表示异常,这样的设计会将正常流程和异常处理流程混在一起,让语言失去了可读性。比如在 C 中,通用会使用 -1 或 NULL 来表示异常情况,所以在调用函数第一件事便是判断 result 是 NULL 或 -1,比如以下代码:
void readFileAndPrintContent(const char* filename) {
FILE* file = fopen(filename, "r");
if (file == NULL) {
// 文件无法打开,返回异常状态
fprintf(stderr, "Failed to open the file.\n");
return; // 直接返回,表示发生异常
}
char line[256];
while (fgets(line, sizeof(line), file) != NULL) {
printf("%s", line);
}
fclose(file);
}
在 Java 语言中,引入了完整的异常机制,以更好的处理异常情况,该机制有如下特点:
- 逻辑分离,将正常处理和异常处理进行分离。异常机制可以将错误处理代码从正常业务逻辑中分离出来,使得代码更加清晰和易读。同时,可以将异常处理代码集中在一起,便于理解和维护;
- 异常传播和捕获。当异常在方法中被抛出时,可以选择在当前方法中捕获并处理异常,或者将异常继续传播给调用者,直到找到合适的异常处理器。这种灵活的异常传播机制使得错误可以被适当地处理,而不会造成程序的中断;
- 异常信息传递。在异常对象中,Java 提供了丰富的信息用于描述异常的发生原因和上下文。包括异常类别、异常消息、异常发生的位置等。这些信息可以帮助开发人员快速定位和修复异常,提高代码的调试和维护效率;
在 Java 中异常处理变得简单且严谨:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class FileReadExample {
public static void readFileAndPrintContent(String filename) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(filename))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
public static void main(String[] args) {
try {
readFileAndPrintContent("example.txt");
} catch (IOException e) {
System.err.println("Exception occurred: " + e.getMessage());
System.exit(-1); // 返回错误代码 -1 表示发生异常
}
}
}
在日常业务开发中,当出现不符合业务预期时,直接通过异常对流程进行中断即可。
3.2. 立即中断还是阶段中断?
当出现不符合预期情况时,是直接抛出异常,还是完成整个阶段在抛出异常?这个需要看业务场景!
参数验证场景,需要对所有不合法信息进行收集,然后统一抛出异常,从而能够让用户一目了然的看到所有问题信息,以方便进行统一修改。
而在业务场景,不符合规则时,需要直接进行异常中断,避免对后续流程造成破坏。
4. 标准写流程中的规则验证
使用 DDD 进行开发时,一个标准的写流程包括:
其中,涉及5大类规则验证,如:
- 参数校验。对入参进行基本校验,比如是否为null、类型是否匹配等;
- 业务校验。特指对前置业务规则或资源的校验,比如库存是否足够、商品状态是否可售等;
- 状态校验。特指聚合内状态校验,核心为业务状态机,比如只有订单状态为待支付时,才能进行支付成功操作;
- 固定规则校验。如果聚合内有固定规则,在进行持久化操作前,需对规则进行验证,比如 订单支付金额 = 所有商品售卖价格总和 - 各类优惠总和;
- 存储引擎校验。基于存储引擎特性进行的最后保障,比如在表中创建唯一索引,从而避免用户的多次提交(幂等保护);
4.1. 参数校验
这是最基础的校验,没有太多的业务概念,只有简单的参数。其目的是 对数据格式进行验证。
针对这种通用能力,优先借助框架来完成,常用框架主要有:
- validation 框架。主要用于解决简单属性的验证;
- Verifiable + AOP。主要用于多个属性的验证;
4.1.1. Validation 框架
对于单属性的验证,可以使用 hibernate validation 框架来实现。Hibernate Validation 是一个基于 Java Bean 验证规范(Java Bean Validation)的验证框架,它提供了一系列的特性来实现对数据模型的验证和约束,其特性主要包括:
- 提供了一组验证注解,用于在数据模型的字段、方法参数、返回值等地方添加验证约束。例如,@NotNull 用于验证字段不能为空,@Email 用于验证邮箱格式,@Size 用于验证字符串长度等;
- 提供了许多内置的验证注解,用于实现常见的验证需求。例如,@NotBlank 用于验证字符串非空且必须包含至少一个非空白字符,@Pattern 用于验证字符串匹配指定的正则表达式,@Min 和 @Max 用于验证数字的最小值和最大值等;
- 除了使用内置的验证注解外,Hibernate Validation 还允许开发者通过自定义约束注解来定义和应用自定义的验证规则。通过创建自定义的约束注解,可以实现更加灵活和符合业务需求的验证规则;
- 允许将多个验证约束分组,并在需要时对特定的验证组进行验证。这样可以根据具体的场景选择性地执行验证操作,从而实现更加细粒度的验证控制;
- 提供了验证器和验证上下文等核心类,用于执行验证操作和获取验证结果。验证器负责执行验证操作,而验证上下文提供了丰富的方法来获取验证结果、获取验证错误信息等;
- 允许通过指定验证注解的验证顺序,来控制验证的执行顺序。这样可以确保在需要按顺序执行验证约束时,每个约束都会按照指定的顺序进行验证;
- 支持国际化,可以根据不同的语言环境为验证错误消息提供多语言支持。开发者可以定义并配置验证错误消息的资源文件,从而实现跨语言的验证错误消息;
特性非常多,我们最常用的就是在模型字段、方法参数、返回值增加相应功能的注解,比如在 CreateOrderCmd 中增加相关验证注解,从而避免手写代码:
@Data
public class CreateOrderCmd {
@NotNull
private Long userId;
@NotNull
private Long productId;
@NotNull
@Min(1)
private int count;
}
4.1.2. Verifiable +AOP
有些参数验证可能会比较复杂,需要对多个属性进行判断,此时 Validation 框架会显得无能为力。
当然,可以制定相应规范,在参数封装的类上统一提供一个 validate 方法,并在进入方法后使用参数前调用该方法。但,规范由人执行难免发生遗留。所以,最佳方案是将其内化到框架。如下图所示:
- 首先,定义一个接口 Verifiable,该接口只有一个 validate 方法;
- 其次,定义一个 ValidateIntercepter 类,基于前置拦截器对入参进行判断,如果实现 Verifiable 接口,则自动调用 validate 方法;
- 最后,基于 AOP 技术生成 Proxy,从而完成统一的参数校验;
当需要对多个参数进行校验时,只需要实现 Verifiable 接口的 validate 方法即可,无需手工对 validate 进行调用。
4.2. 业务校验
业务校验是业务逻辑执行的前置条件验证,包括外部校验和控制条件校验。
通常情况下,业务校验比较复杂,变化频次也比较高,所以对扩展性要求很高。但,业务规则本身比较独立,相互之间没有太多的依赖关系。为了更好的应对逻辑扩展,可以使用策略模型进行设计。如下图所示:
4.2.1. 业务验证器
业务验证器就是策略模型中的策略接口。
核心代码如下:
public interface BaseValidator<A> extends SmartComponent<A> {
void validate(A a, ValidateErrorHandler validateErrorHandler);
default void validate(A a){
validate(a, ((name, code, msg) -> {
throw new ValidateException(name, code, msg);
}));
}
}
该接口非常简单:
- 提供统一的 validate 方法定义;
- 继承自 SmartComponent,可以通过 boolean support(A a) 来验证该组件是否能被处理;
4.2.2. 共享数据 Context
有了统一的策略接口后,需要使用 Context 模式对入参进行管理。Context 可以是简单的数据容器,也可以是一个具有 LazyLoad 能力的加强容器,其核心功能就是在多个策略间实现数据的共享。
比如,在生单流程中的 CreateOrderContext 定义如下:
@Data
public class CreateOrderContext implements CreateOrderContext{
private CreateOrderCmd cmd;
@LazyLoadBy("#{@userRepository.getById(cmd.userId)}")
private User user;
@LazyLoadBy("#{@productRepository.getById(cmd.productId)}")
private Product product;
@LazyLoadBy("#{@addressRepository.getDefaultAddressByUserId(user.id)}")
private Address defAddress;
@LazyLoadBy("#{@stockRepository.getByProductId(product.id)}")
private Stock stock;
@LazyLoadBy("#{@priceService.getByUserAndProduct(user.id, product.id)}")
private Price price;
}
其中 @LazyLoadBy 是一个功能加强注解,在第一次访问属性的 getter 方法时,将自动触发数据加载,并将加载的数据设置到属性上,再第二次访问时,直接从属性上获取所需数据。
【注】对该部分感兴趣,可以学习 《Command\&Query Object 与 Context 模式》
4.2.3. 策略类管理
在有了策略接口 和 共享数据 Context 后,接下来便是按照业务需求实现高内聚低耦合的各种实现类。如下图所示:
这些组件如何进行管理,详见下图:
- 首先,在启动时,所有的 BusinessValidator 实例会被 Spring 注入到 ValidateService 中的 validators 集合;
- 在调用 validateBusiness 函数时,依次遍历 validators 集合,找到能处理该 Context 的 validator 实例并执行对应的 validate 方法;
这样做最大的好处便是,在验证组件中彻底实现“开闭原则”:
- 增加新的业务逻辑时,只需增加一个新的 Spring 组件,系统将自动完成集成;
- 在修改某个业务校验时,只会更改一个类,对其他校验没有影响;
认真思考后,可能会发现:这其实是责任链模式的一种变形。但,由于实现非常简单,在 Spring 框架中多次使用。
4.3. 状态校验
状态校验又成前置状态验证,是业务规则中最重要的一部分。
核心实体通常会有一个状态属性,状态属性的这些值共同组成一个标准的状态机。如下图所示:
这是一个订单实体的状态机,定义了各状态间的转换关系,这是领域设计中最为重要的一部分。当发生业务动作时,第一件事不是修改业务状态,而是判断当前状态下是否可以进行该操作。
比如,支付成功的核心业务:
public void paySuccess(PayByIdSuccessCommand paySuccessCommand){
if (getStatus() != OrderStatus.CREATED){
throw new OrderStatusNotMatch();
}
this.setStatus(OrderStatus.PAID);
PayRecord payRecord = PayRecord.create(paySuccessCommand.getChanel(), paySuccessCommand.getPrice());
this.payRecords.add(payRecord);
OrderPaySuccessEvent event = new OrderPaySuccessEvent(this);
this.events.add(event);
}
在进入逻辑处理前,先对状态进行判断,只有 “已创建” 才能接收 支付成功操作,并将状态转换为 “已支付”。
4.4. 固定规则校验
固定规则校验使用场景不多,但其威力巨大,可以从根源上解决逻辑错误。
在订单实体上存在大量的金额操作,比如:
- 优惠券。用户使用优惠券后,用户支付金额需减去优惠金额,同时优惠金额也会均摊到不同的订单项上;
- 优惠活动。和优惠券对订单的影响基本一致,但场景会更加复杂;
- 优惠叠加。优惠券和优惠活动一起使用,共同对订单进行修改;
- 手工改价。商家与用户协商一致后,商家可以在后台对订单的金额进行修改;
订单金额发生变化后,更新字段很多,但无论如何变化都需要满足一个公式:支付金额 = 售卖金额总和 - 优惠金额总和。
我们可以基于这个公式,在业务操作之后、数据库更新之前对规则进行校验,一旦规则不满足则说明处理逻辑出现问题,直接抛出异常中断处理流程。
4.4.1. JPA 支持
JPA 支持在数据保存或更新前对业务方法进行回调。
我们可以使用 回调注解 或 实体监听器 完成业务回调。
@PreUpdate
@PrePersist
public void checkBizRule(){
// 进行业务校验
}
checkBizRule 方法上增加 @PreUpdate 和 @PrePersist,在保存数据库或更新数据库之前,框架自动对 chekBizRule 方法进行回调,当方法抛出异常,处理流程被强制中断。
也可以使用 实体监听器 进行处理,如下例所示:
// 首先,定义一个 OrderListenrer
public class OrderListener {
@PrePersist
public void preCreate(Order order) {
order.checkBiz();
}
@PostPersist
public void postCreate(Order order) {
order.checkBiz();
}
}
// 在 Order 实体上添加相关配置
@Data
@Entity
@Table
@Setter(AccessLevel.PRIVATE)
// 配置 OrderListener
@EntityListeners(OrderListener.class)
public class Order implements AggRoot<Long> {
// 省略部分非关键代码
public void checkBizRule(){
// 进行业务校验
}
}
4.4.2. MyBatis 支持
MyBatis 对实体的生命周期支持并没有 JPA 中那么强大,但通过 Intercepter 仍旧能实现该功能。具体操作如下:
首先,自定义 Intercepter,判断参数并调用规则校验方法:
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class EntityInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement statement = (MappedStatement) args[0];
Object parameter = args[1];
// 在这里可以对参数进行判断,并执行相应的操作
if (parameter instanceof Order) {
Order order = (Order) parameter;
order.checkBizRule();
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可以在这里设置一些配置参数
}
}
然后,在 mybatis-config.xml 配置文件中增加 Intercepter 的配置,具体如下:
<configuration>
<!-- 其他配置 -->
<plugins>
<plugin interceptor="com.example.EntityInterceptor"/>
</plugins>
</configuration>
4.4.3. 业务框架扩展
Lego 框架对标准 Command 处理流程进行封装,流程中对固定规则校验进行了支持。如下图所示:
在标准写流程中的固定规则校验阶段会自动调用 ValidateService 中的 validateRule,整体结构和 业务校验基本一致,在这里就不在赘述。其中:
- 存在一个默认实现 AggBasedRuleValidator,可以通过重写聚合根上的 validate 方法来实现 JPA 和 MyBatis 同样的效果;
- 也可以自定义自己的 RuleValidator,将实现类注入到 Spring 容器即可完成与业务流程的集成;
4.5. 存储引擎校验
存储引擎提供了非常丰富的数据校验,比如 Not Null,Length、Unique 等;
一般情况下,在流程达到存储引擎前,所有的验证规则必须全部通过,尽量不要使用存储引擎作为兜底方案。但有一种情况极为特殊,也就只有存储引擎能够优雅的完成,那就是唯一键保护。
比如,在需要幂等保护时,我们通常将幂等键设置为唯一索引,从而保证不会出现重复提交的情况。
5.校验小结
为了保证脏数据(不符合业务预期的数据)不会进入到系统,我们将“防御式编程”思想用到了极致,在一个标准的写流程中共设立了5项关卡,从多维度多视角对数据进行保障:
- 参数校验。不要相信任何的输入信息,需要对系统输入进行严格校验;
- 业务校验。业务操作往往会依赖一些前置条件,这些前置条件累加在一起甚至比核心操作还要复杂,如何提供相互隔离且可扩展的设计便成了这个阶段的核心;
- 状态校验。对状态机进行保护,任何操作都存在前置状态,在非法状态下不允许执行,这就是这个阶段要解决的问题;
- 固定规则校验。当上层业务变化多且复杂时,可能会对某些固定规则造成破坏,所以,在业务操作完成后、数据操作之前,可以再次对固定规则进行验证;
- 存储引擎校验。校验优先在Java 代码中完成,不要将存储引擎校验作为常态校验。但,在唯一性保障方面,存储引擎是最简单最有效的策略;
5大关卡共同发力才能真正保障业务数据的有效性。