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 防御式编程的五大原则
做了这么多年架构,我总结出五条防御式编程的核心原则:
- 永远不要信任输入 —— 哪怕是你自己写的上游模块。
- 快速失败(Fail Fast) —— 一旦发现异常,立刻停止执行,不要试图「修复」数据。
- 避免魔法值 —— 所有常量都应该有名字,否则改起来就是灾难。
- 使用不可变对象 —— 能不变就别变,变了容易出鬼。
- 记录所有异常 —— 不要吞异常,不要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%以上
- 团队有统一的校验规范
小技巧: 自定义校验注解也很简单。比如我做过一个「身份证号校验」的注解,一行注解就能搞定复杂的校验逻辑。团队里大家都爱用。
知识体系总览
下面这张图,是我对本章知识体系的梳理。你可以把它当作一张「防御地图」:
这张图把防御式编程的五个核心模块串起来了。你可以看到,输入校验和断言是基础,契约式设计是方法论,五大原则是指导思想,参数校验框架是落地工具。
好了,今天就聊到这儿。记住一句话:防御式编程不是不信任别人,而是对自己写的代码负责。 下次写代码前,先想想——如果输入是恶意构造的,我的系统扛得住吗?
公众号:蓝海资料掘金营,微信deep3321