<返回更多

DDD架构下的防御式编程:5大关卡共同保障业务数据的有效性

2023-12-03  今日头条  后端技术分享
加入收藏
一般情况下,在流程达到存储引擎前,所有的验证规则必须全部通过,尽量不要使用存储引擎作为兜底方案。但有一种情况极为特殊,也就只有存储引擎能够优雅的完成,那就是唯一键保护。

1. 规则验证是准确性的基础

规则验证是业务稳定性的重要保障手段,通过规则验证,可以验证和确保系统或业务逻辑的正确性和合规性,避免潜在的错误和问题。而规则的遗漏往往会伴随着线上bug的出现。

相信每个开发人员都曾面对过以下情况:

可见,验证对流程极为重要,不合理的输入会导致严重的业务问题。同时错误数据的影响也比想象中的大得多:

2. 防御式编程

如何避免上述情况的发生,答案就在 防御式编程。

防御式编程(Defensive Programming)是一种软件开发方法,目的是在代码中预测可能出现的异常和错误情况,并用适当的措施对其进行处理,从而提高软件的健壮性和稳定性。通过防御式编程,软件开发人员可以在软件功能相对复杂的情况下,避免和减少由于程序错误所导致的不可预测的行为和不良影响,并保障软件在部署和运行时的正确性和稳定性,提高软件可靠性和安全性。

防御式编程的核心思想是在代码中尽量考虑一切可能出现的异常和错误情况,并在代码中针对这些异常和错误情况做出相应的处理。例如,可以使用异常捕获机制处理可能出现的异常,充分利用代码注释和约束条件来规范输入数据,使用断言(assert)来检查代码中的前置条件和后置条件等。

概念过于繁琐,简单理解:防御式编程就是:

  1. 不要相信任何输入,在正式使用前,必须保证参数的有效性;
  2. 不相信任何处理逻辑,在流程处理后,必须保证业务规则仍旧有效;

对输入参数保持怀疑,对业务执行的前提条件保存怀疑,对业务执行结果保持怀疑,将极大的提升系统的准确性!

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 中异常处理变得简单且严谨:

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 进行开发时,一个标准的写流程包括:

DDD架构下的防御式编程:5大关卡共同保障业务数据的有效性

其中,涉及5大类规则验证,如:

4.1. 参数校验

这是最基础的校验,没有太多的业务概念,只有简单的参数。其目的是 对数据格式进行验证。

针对这种通用能力,优先借助框架来完成,常用框架主要有:

4.1.1. Validation 框架

对于单属性的验证,可以使用 hibernate validation 框架来实现。Hibernate Validation 是一个基于 Java Bean 验证规范(Java Bean 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 方法,并在进入方法后使用参数前调用该方法。但,规范由人执行难免发生遗留。所以,最佳方案是将其内化到框架。如下图所示:

DDD架构下的防御式编程:5大关卡共同保障业务数据的有效性

 

当需要对多个参数进行校验时,只需要实现 Verifiable 接口的 validate 方法即可,无需手工对 validate 进行调用。

4.2. 业务校验

业务校验是业务逻辑执行的前置条件验证,包括外部校验和控制条件校验。

通常情况下,业务校验比较复杂,变化频次也比较高,所以对扩展性要求很高。但,业务规则本身比较独立,相互之间没有太多的依赖关系。为了更好的应对逻辑扩展,可以使用策略模型进行设计。如下图所示:

DDD架构下的防御式编程:5大关卡共同保障业务数据的有效性

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);
        }));
    }
}

该接口非常简单:

  1. 提供统一的 validate 方法定义;
  2. 继承自 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 后,接下来便是按照业务需求实现高内聚低耦合的各种实现类。如下图所示:

DDD架构下的防御式编程:5大关卡共同保障业务数据的有效性

这些组件如何进行管理,详见下图:

DDD架构下的防御式编程:5大关卡共同保障业务数据的有效性

这样做最大的好处便是,在验证组件中彻底实现“开闭原则”:

认真思考后,可能会发现:这其实是责任链模式的一种变形。但,由于实现非常简单,在 Spring 框架中多次使用。

4.3. 状态校验

状态校验又成前置状态验证,是业务规则中最重要的一部分。

核心实体通常会有一个状态属性,状态属性的这些值共同组成一个标准的状态机。如下图所示:

DDD架构下的防御式编程:5大关卡共同保障业务数据的有效性

这是一个订单实体的状态机,定义了各状态间的转换关系,这是领域设计中最为重要的一部分。当发生业务动作时,第一件事不是修改业务状态,而是判断当前状态下是否可以进行该操作。

比如,支付成功的核心业务:

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 处理流程进行封装,流程中对固定规则校验进行了支持。如下图所示:

DDD架构下的防御式编程:5大关卡共同保障业务数据的有效性

在标准写流程中的固定规则校验阶段会自动调用 ValidateService 中的 validateRule,整体结构和 业务校验基本一致,在这里就不在赘述。其中:

  1. 存在一个默认实现 AggBasedRuleValidator,可以通过重写聚合根上的 validate 方法来实现 JPA 和 MyBatis 同样的效果;
  2. 也可以自定义自己的 RuleValidator,将实现类注入到 Spring 容器即可完成与业务流程的集成;

4.5. 存储引擎校验

存储引擎提供了非常丰富的数据校验,比如 Not Null,Length、Unique 等;

一般情况下,在流程达到存储引擎前,所有的验证规则必须全部通过,尽量不要使用存储引擎作为兜底方案。但有一种情况极为特殊,也就只有存储引擎能够优雅的完成,那就是唯一键保护。

比如,在需要幂等保护时,我们通常将幂等键设置为唯一索引,从而保证不会出现重复提交的情况。

5.校验小结

为了保证脏数据(不符合业务预期的数据)不会进入到系统,我们将“防御式编程”思想用到了极致,在一个标准的写流程中共设立了5项关卡,从多维度多视角对数据进行保障:

5大关卡共同发力才能真正保障业务数据的有效性。

关键词:DDD架构      点击(9)
声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多DDD架构相关>>>