3. 接口参数校验:从防御到契约
参数校验这事,说简单也简单,说复杂能写一本书。我早年接手过一个支付系统,线上出了个 P0 事故——用户传了个负数金额,结果系统退款了。你想想看,这种低级错误,就是参数校验没做到位。
这一章,咱们就把参数校验聊透。从基础的类型检查,到自定义注解,再到 TDD 驱动下的边界测试,一条线串下来。
3.1 参数校验的层次与职责
我个人习惯把校验分成三层:
| 层次 | 职责 | 典型场景 |
|---|---|---|
| 传输层校验 | 协议格式、报文完整性 | JSON 格式错误、签名验证 |
| 接口层校验 | 参数类型、必填、边界 | @NotNull、@Min、@Max |
| 业务层校验 | 业务规则、状态依赖 | 订单状态转换、库存检查 |
这一章我们聚焦在接口层校验。说白了,就是「这个请求能不能进入业务逻辑」的第一道门。
核心原则:接口层校验只做「语法检查」,不做「语义检查」。比如「年龄不能为负」是语法,「年龄必须大于18岁才能买烟」是语义。前者放接口层,后者放业务层。
3.2 参数类型校验:别让 String 混进 Integer
类型校验是最基础的防线。我记得有一次,前端传了个 "age": "twenty",后端直接 500 了。为什么?因为框架在反序列化时就抛了异常,连控制器都没进去。
在 Spring Boot 中,类型校验由 HttpMessageConverter 自动完成。但这里有个坑:
// 错误示范:用 String 接收再手动转换
@PostMapping("/user")
public Result addUser(@RequestBody Map<String, Object> body) {
String ageStr = (String) body.get("age");
// 手动解析... 容易遗漏异常处理
}
// 正确做法:用 DTO 明确类型
public class CreateUserRequest {
@NotNull
private Integer age; // 类型不对直接 400
}
我的习惯:永远用强类型 DTO 接收参数,别偷懒用 Map。Map 虽然灵活,但类型信息全丢了,校验注解也用不上。
3.3 必填参数与可选参数:契约的边界
必填参数,就是调用方必须提供的参数。可选参数,就是有默认值或可以为 null 的参数。
这里有个容易混淆的点:「必填」不等于「非空」。比如一个字符串参数,必填但允许传空字符串 ""。这时候用 @NotNull,而不是 @NotBlank。
public class OrderQuery {
@NotNull(message = "订单ID不能为空")
private String orderId; // 必填,允许空字符串
@NotBlank(message = "用户名不能为空")
private String username; // 必填,且不能是空白
private Integer page = 1; // 可选,有默认值
@Nullable
private String remark; // 可选,允许 null
}
我曾经踩过的坑:在更新接口里,把「可选参数」和「必填参数」混在一起校验。结果前端只传了要修改的字段,后端却报「必填字段缺失」。后来我学乖了:更新接口用 partial DTO,创建接口用 full DTO,分开校验。
3.4 参数边界值测试:TDD 的切入点
边界值测试,是 TDD 里最容易上手、也最容易发现 bug 的环节。为什么?因为程序员写代码时,往往只考虑「正常情况」,边界情况容易遗漏。
来看一个例子:分页查询接口。
public class PageRequest {
@Min(value = 1, message = "页码最小为1")
private int page;
@Min(value = 1, message = "每页条数最小为1")
@Max(value = 100, message = "每页条数最大为100")
private int size;
}
用 TDD 写测试时,我会列出这些边界:
- 正常值:page=1, size=20 → 通过
- 下边界:page=1, size=1 → 通过
- 上边界:page=999, size=100 → 通过
- 越下界:page=0, size=0 → 不通过
- 越上界:page=-1, size=101 → 不通过
- 特殊值:page=Integer.MAX_VALUE, size=Integer.MAX_VALUE → 不通过
@Test
void should_reject_page_less_than_one() {
var request = new PageRequest();
request.setPage(0);
request.setSize(20);
var violations = validator.validate(request);
assertThat(violations)
.anyMatch(v -> v.getPropertyPath().toString().equals("page"));
}
一个小技巧:写测试时,把边界值列成表格,贴在代码注释里。这样别人 review 时一眼就能看出你覆盖了哪些情况。我团队现在就这么干,效果很好。
3.5 自定义校验注解:当内置注解不够用
内置的 @NotNull、@Min、@Max 能覆盖 80% 的场景。但总有那 20% 需要自己动手。比如:校验手机号格式、校验枚举值是否合法、校验两个字段之间的关系。
自定义注解分三步走:
- 定义注解:声明
@Target、@Retention、@Constraint - 实现校验器:实现
ConstraintValidator接口 - 使用注解:在 DTO 字段上标注
// 第一步:定义注解
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
public @interface ValidPhone {
String message() default "手机号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 第二步:实现校验器
public class PhoneValidator implements ConstraintValidator<ValidPhone, String> {
private static final Pattern PHONE_PATTERN =
Pattern.compile("^1[3-9]\\d{9}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // null 由 @NotNull 处理
}
return PHONE_PATTERN.matcher(value).matches();
}
}
// 第三步:使用
public class RegisterRequest {
@NotNull
@ValidPhone
private String phone;
}
注意:自定义校验器里,null 值应该返回 true,让 @NotNull 去处理。这叫「单一职责」——每个注解只做一件事。
3.6 知识结构图:参数校验全景
下面这张图,把参数校验的核心脉络画出来了。你可以把它当作本章的「思维导图」:
3.7 实战:用 TDD 驱动一个校验注解
光说不练假把式。咱们用 TDD 的方式,写一个「校验密码强度」的自定义注解。
第一步:写测试
class PasswordValidatorTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void should_accept_strong_password() {
var dto = new PasswordDTO();
dto.setPassword("Abc@1234");
var violations = validator.validate(dto);
assertThat(violations).isEmpty();
}
@Test
void should_reject_password_without_uppercase() {
var dto = new PasswordDTO();
dto.setPassword("abc@1234");
var violations = validator.validate(dto);
assertThat(violations).isNotEmpty();
}
@Test
void should_reject_password_shorter_than_eight() {
var dto = new PasswordDTO();
dto.setPassword("Ab@1");
var violations = validator.validate(dto);
assertThat(violations).isNotEmpty();
}
}
第二步:让测试通过
public class StrongPasswordValidator
implements ConstraintValidator<StrongPassword, String> {
private static final Pattern PASSWORD_PATTERN =
Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true;
return PASSWORD_PATTERN.matcher(value).matches();
}
}
第三步:重构——把正则拆成多个小方法,方便阅读和维护。
我的经验:TDD 写校验注解特别爽。因为校验逻辑通常很独立,测试好写,边界清晰。你只要把「什么情况算通过」列清楚,代码自然就出来了。
3.8 避坑指南
- 不要在校验器里查数据库:接口层校验应该无状态、无 IO。查数据库是业务层的事。
- 分组校验要慎用:
@Validated(CreateGroup.class)虽然灵活,但容易让代码变复杂。我建议:不同场景用不同 DTO,而不是用分组。 - 错误信息要友好:别只返回「参数错误」,要说清楚哪个字段、为什么错、期望什么值。
- 国际化提前规划:如果系统要支持多语言,错误信息用 code,不要硬编码中文。
我曾经犯过的错:在一个接口里,同时用了 @Valid 和手动校验。结果同一个参数被校验了两次,线上性能监控发现这个接口响应时间比其他接口高 30%。排查了半天才发现是重复校验。记住:选一种方式,用到底。