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 写测试时,我会列出这些边界:

  1. 正常值:page=1, size=20 → 通过
  2. 下边界:page=1, size=1 → 通过
  3. 上边界:page=999, size=100 → 通过
  4. 越下界:page=0, size=0 → 不通过
  5. 越上界:page=-1, size=101 → 不通过
  6. 特殊值: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% 需要自己动手。比如:校验手机号格式、校验枚举值是否合法、校验两个字段之间的关系。

自定义注解分三步走:

  1. 定义注解:声明 @Target、@Retention、@Constraint
  2. 实现校验器:实现 ConstraintValidator 接口
  3. 使用注解:在 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 知识结构图:参数校验全景

下面这张图,把参数校验的核心脉络画出来了。你可以把它当作本章的「思维导图」:

接口参数校验知识结构图 接口参数校验 类型校验 必填 / 可选 边界值测试 自定义注解 DTO 强类型 @NotNull / @NotBlank @Min / @Max / @Size 定义注解 实现校验器 标注使用 TDD 驱动下的校验测试 先写边界测试 → 再写校验逻辑 → 最后重构

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%。排查了半天才发现是重复校验。记住:选一种方式,用到底。