16、数据校验与防御式编程:输入校验、断言、契约式设计、防御式编程原则、参数校验框架

大家好,我是你们的老朋友。今天我们来聊聊一个看似基础,实则决定系统生死的话题——数据校验与防御式编程。

说实话,我见过太多系统崩溃,根源就出在「信任输入」上。你想想看,一个用户或者另一个服务传过来的数据,凭什么相信它是合法的?

防御式编程的核心思想很简单:不要信任任何外部输入,把系统想象成一个戒备森严的城堡。

核心观点: 防御式编程不是增加代码量,而是降低系统的「意外死亡率」。每一条校验,都是在给系统买保险。

16.1 输入校验:第一道防线

输入校验是防御式编程的起点。我个人习惯把校验分为两个层面:

  • 语法校验: 数据格式对不对?比如邮箱有没有@符号,手机号是不是11位。
  • 语义校验: 数据合不合理?比如年龄不能是负数,订单金额不能超过库存价值。

我在项目中遇到过一个问题:一个支付接口只做了语法校验,没做语义校验。结果用户传了一个「金额为0」的订单,系统竟然通过了。嗯,那次损失虽然不大,但教训深刻。

我的建议: 永远在系统边界做校验。所谓边界,就是数据从外部进入系统的那一刻。比如API入口、消息队列消费端、文件导入接口。

16.2 断言:开发阶段的「安全气囊」

断言这东西,很多人觉得可有可无。但我告诉你,它其实是开发阶段的「安全气囊」。

断言和普通校验最大的区别是什么?断言只在开发环境和测试环境生效,生产环境默认关闭。 为什么?因为断言是用来发现「不可能发生」的情况的。

// 一个典型的断言示例
public void withdraw(double amount) {
    assert amount > 0 : "取款金额必须大于0";
    assert balance >= amount : "余额不足,当前余额:" + balance;
    
    // 实际业务逻辑
    balance -= amount;
}

我曾经在一个金融项目中,用断言捕获了一个极其隐蔽的并发bug。两个线程同时扣款,余额竟然变成了负数。断言在测试环境直接炸了,帮我们定位到了问题。如果当时用的是普通if判断,可能就被吞掉了。

注意: 不要用断言来做参数校验!断言可以关闭,但参数校验必须永远执行。这是很多新手容易犯的错误。

16.3 契约式设计:先谈好规矩再干活

契约式设计(Design by Contract)是防御式编程的高级形态。说白了,就是调用者和被调用者之间先签一份合同。

这份合同包含三部分:

契约类型 含义 违反后果
前置条件 调用者必须满足的条件 调用者bug
后置条件 方法执行后保证的结果 实现者bug
不变式 对象状态始终满足的条件 设计缺陷

举个例子:

// 契约式设计风格
public class BankAccount {
    // 不变式:余额不能为负
    private double balance;
    
    /**
     * 前置条件:amount > 0
     * 后置条件:balance == old(balance) + amount
     */
    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("存款金额必须为正数");
        }
        double oldBalance = this.balance;
        this.balance += amount;
        assert this.balance == oldBalance + amount : "后置条件被违反";
    }
}

我个人觉得,契约式设计最大的价值在于「明确责任」。调用者错了就是调用者的锅,实现者错了就是实现者的锅。调试的时候,一眼就能看出问题出在哪一端。

16.4 防御式编程的五大原则

做了这么多年架构,我总结出五条防御式编程的核心原则:

  1. 永远不要信任输入 —— 哪怕是你自己写的上游模块。
  2. 快速失败(Fail Fast) —— 一旦发现异常,立刻停止执行,不要试图「修复」数据。
  3. 避免魔法值 —— 所有常量都应该有名字,否则改起来就是灾难。
  4. 使用不可变对象 —— 能不变就别变,变了容易出鬼。
  5. 记录所有异常 —— 不要吞异常,不要catch了什么都不做。

避坑指南: 我曾经在一个项目中看到有人写了这样的代码:try { ... } catch (Exception e) {}。空的catch块!这等于告诉系统:「出错了?没关系,假装没发生。」结果数据全乱了,查了三天才找到原因。

16.5 参数校验框架:别再手写了

手动写参数校验?太累了,而且容易遗漏。我建议使用成熟的参数校验框架。

以Java生态为例,最常用的是 Bean Validation(JSR 380),配合Hibernate Validator实现。

// 使用注解进行参数校验
public class UserDTO {
    
    @NotBlank(message = "用户名不能为空")
    @Size(min = 2, max = 20, message = "用户名长度必须在2-20之间")
    private String username;
    
    @NotNull
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;
    
    @Email(message = "邮箱格式不正确")
    private String email;
    
    @Min(value = 1, message = "年龄不能小于1")
    @Max(value = 150, message = "年龄不能超过150")
    private Integer age;
    
    // getters and setters
}

// 在Controller中使用
@PostMapping("/user")
public Result createUser(@Valid @RequestBody UserDTO userDTO) {
    // 如果校验失败,框架会自动抛出异常
    userService.createUser(userDTO);
    return Result.success();
}

这样做的好处很明显:

  • 校验逻辑和业务逻辑分离
  • 统一异常处理
  • 代码量减少60%以上
  • 团队有统一的校验规范

小技巧: 自定义校验注解也很简单。比如我做过一个「身份证号校验」的注解,一行注解就能搞定复杂的校验逻辑。团队里大家都爱用。

知识体系总览

下面这张图,是我对本章知识体系的梳理。你可以把它当作一张「防御地图」:

防御式编程知识体系 防御式编程 输入校验 断言 契约式设计 五大原则 参数校验框架 语法校验 语义校验 前置条件 后置条件 不变式 不信任输入 快速失败 避免魔法值 Bean Validation 自定义注解

这张图把防御式编程的五个核心模块串起来了。你可以看到,输入校验和断言是基础,契约式设计是方法论,五大原则是指导思想,参数校验框架是落地工具。

好了,今天就聊到这儿。记住一句话:防御式编程不是不信任别人,而是对自己写的代码负责。 下次写代码前,先想想——如果输入是恶意构造的,我的系统扛得住吗?


公众号:蓝海资料掘金营,微信deep3321