Skip to content

第52课:Validation 参数校验

🎯 学习目标

  • 理解参数校验为什么应该靠框架集中处理,而不是散落在业务代码中。
  • 掌握 Jakarta Bean Validation 的常用注解和 Spring Boot 集成方式。
  • 能区分 @Valid@Validated、分组校验、嵌套校验和方法参数校验。
  • 能设计统一错误响应,让前端准确知道哪个字段错了。
  • 能识别校验位置错误、包装类型误用、分组滥用等常见问题。

📖 一、为什么需要参数校验

没有统一校验时,Service 代码会变成这样:

java
public void createUser(UserCreateRequest request) {
    if (request.getUsername() == null || request.getUsername().isBlank()) {
        throw new IllegalArgumentException("username is required");
    }
    if (request.getPassword() == null || request.getPassword().length() < 8) {
        throw new IllegalArgumentException("password too short");
    }
    if (request.getAge() != null && request.getAge() < 0) {
        throw new IllegalArgumentException("age invalid");
    }

    // 真正的业务逻辑被校验代码淹没
}

问题:

text
校验逻辑重复
错误格式不统一
Controller 和 Service 分工混乱
字段规则无法从 DTO 上直接看出
接口文档难以自动生成准确约束

Validation 的目标是把字段约束声明在 DTO 上,让框架自动校验。


📖 二、依赖配置

Spring Boot 3 使用 Jakarta Validation:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Spring Boot 2 使用的是 javax.validation 包名,Spring Boot 3 使用 jakarta.validation 包名。升级时要注意导包变化。


📖 三、常用校验注解

注解作用适用类型
@NotNull不能为 null任意对象
@NotBlank不能为 null,且去空格后不能为空String
@NotEmpty不能为 null,且长度/集合大小不能为 0String、Collection、Map、数组
@Size长度或集合大小范围String、Collection、数组
@Min / @Max数值最小/最大数字
@Positive必须为正数数字
@Email邮箱格式String
@Pattern正则匹配String
@Past / @Future过去/未来时间日期时间

示例:

java
import jakarta.validation.constraints.*;

public class UserCreateRequest {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在 3 到 20 之间")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 8, max = 64, message = "密码长度必须在 8 到 64 之间")
    private String password;

    @Email(message = "邮箱格式不正确")
    private String email;

    @Min(value = 0, message = "年龄不能小于 0")
    @Max(value = 150, message = "年龄不能大于 150")
    private Integer age;
}

📖 四、Controller 中启用校验

1. 请求体校验

java
@RestController
@RequestMapping("/api/users")
public class UserController {

    @PostMapping
    public UserDTO create(@Valid @RequestBody UserCreateRequest request) {
        return userService.create(request);
    }
}

如果请求体不满足约束,Spring 会抛出 MethodArgumentNotValidException

2. 查询参数校验

类上需要 @Validated

java
@Validated
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping
    public List<UserDTO> list(
            @Min(value = 1, message = "页码从 1 开始") @RequestParam Integer page,
            @Max(value = 100, message = "每页最多 100 条") @RequestParam Integer size) {
        return userService.list(page, size);
    }
}

方法参数校验失败通常抛出 ConstraintViolationException


📖 五、统一错误响应

只加注解还不够。企业项目必须把错误响应格式统一。

java
public class ApiError {
    private String code;
    private String message;
    private List<FieldErrorItem> fields;
}

public class FieldErrorItem {
    private String field;
    private String message;
}

全局异常处理:

java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiError> handleBodyValidation(MethodArgumentNotValidException e) {
        List<FieldErrorItem> fields = e.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new FieldErrorItem(error.getField(), error.getDefaultMessage()))
            .toList();

        ApiError body = new ApiError("VALIDATION_ERROR", "请求参数不合法", fields);
        return ResponseEntity.badRequest().body(body);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ApiError> handleParamValidation(ConstraintViolationException e) {
        List<FieldErrorItem> fields = e.getConstraintViolations()
            .stream()
            .map(v -> new FieldErrorItem(v.getPropertyPath().toString(), v.getMessage()))
            .toList();

        ApiError body = new ApiError("VALIDATION_ERROR", "请求参数不合法", fields);
        return ResponseEntity.badRequest().body(body);
    }
}

这样前端可以明确展示:

json
{
  "code": "VALIDATION_ERROR",
  "message": "请求参数不合法",
  "fields": [
    {"field": "username", "message": "用户名不能为空"},
    {"field": "password", "message": "密码长度必须在 8 到 64 之间"}
  ]
}

📖 六、嵌套校验

如果 DTO 里包含子对象,需要在字段上继续加 @Valid

java
public class OrderCreateRequest {
    @NotNull(message = "用户 ID 不能为空")
    private Long userId;

    @Valid
    @NotEmpty(message = "订单明细不能为空")
    private List<OrderItemRequest> items;
}

public class OrderItemRequest {
    @NotNull(message = "商品 ID 不能为空")
    private Long productId;

    @Positive(message = "数量必须为正数")
    private Integer quantity;
}

如果忘记 @Valid,外层对象会校验,但 items 里的字段不会继续校验。


📖 七、分组校验

同一个 DTO 在创建和更新时规则可能不同:

java
public interface CreateGroup {}
public interface UpdateGroup {}
java
public class UserRequest {
    @NotNull(message = "更新时 ID 不能为空", groups = UpdateGroup.class)
    private Long id;

    @NotBlank(message = "用户名不能为空", groups = CreateGroup.class)
    private String username;
}

Controller:

java
@PostMapping
public UserDTO create(@Validated(CreateGroup.class) @RequestBody UserRequest request) {
    return userService.create(request);
}

@PutMapping("/{id}")
public UserDTO update(@Validated(UpdateGroup.class) @RequestBody UserRequest request) {
    return userService.update(request);
}

分组校验有用,但不要滥用。规则差异很大时,创建和更新分别定义 DTO 通常更清晰。


⚠️ 八、常见陷阱

1. 基本类型无法表达缺失

错误示例:

java
@Min(1)
private int page;

int 默认值是 0,无法区分“用户没传”和“用户传了 0”。请求 DTO 建议使用包装类型:

java
@NotNull
@Min(1)
private Integer page;

2. @NotNull 不等于非空字符串

java
@NotNull
private String username;

这允许 """ "。字符串必填通常用 @NotBlank

3. 忘记在 Controller 参数上加 @Valid

DTO 上写了注解,但 Controller 没有:

java
public UserDTO create(@RequestBody UserCreateRequest request)

校验不会执行。应改为:

java
public UserDTO create(@Valid @RequestBody UserCreateRequest request)

4. 把业务规则都塞进注解

字段格式校验适合 Validation;复杂业务规则仍应放在 Service。

例如“用户名是否已存在”“库存是否足够”“优惠券是否可用”不是简单字段校验。


🆚 九、Java vs C 对比

维度CJava + Validation
参数校验手写 if 判断注解声明 + 框架自动执行
错误聚合手动收集BindingResult/异常统一处理
嵌套对象手动遍历结构体@Valid 递归校验
Web 集成取决于框架Spring MVC 自动集成

Java 的优势是把规则贴近 DTO 声明,接口约束更可读,也更容易生成 OpenAPI 文档。


💡 十、最佳实践

  • 请求 DTO 使用包装类型,避免基本类型默认值掩盖“未传参”。
  • 字符串必填用 @NotBlank,集合必填用 @NotEmpty
  • 嵌套对象和集合元素校验必须加 @Valid
  • Controller 入参用 Validation 做格式和边界校验,Service 负责业务规则。
  • 全局异常处理必须统一错误响应格式。
  • 创建和更新规则差异很大时,优先拆 DTO,而不是过度依赖分组。
  • 错误消息应面向调用方清晰表达,不要暴露内部类名或堆栈。
  • Validation 规则应与 Swagger/OpenAPI 文档保持一致。

🎓 小结

Validation 让参数约束从分散的 if 判断变成集中声明。它的关键不是少写几行代码,而是让接口边界清晰、错误响应统一、业务代码更专注。

掌握 Validation 后,再结合 JSON DTO 和 Swagger/OpenAPI,就能形成完整的“请求结构、字段约束、接口文档、错误返回”闭环。


🧭 十一、项目落地清单

一个接口上线前,Validation 至少要检查:

text
1. 请求体参数是否加了 @Valid 或 @Validated。
2. Controller 类上是否为查询参数校验加了 @Validated。
3. 字符串必填是否使用 @NotBlank,而不是只用 @NotNull。
4. 数字范围是否使用包装类型,避免默认值掩盖缺失。
5. 集合是否校验非空和元素内容。
6. 嵌套对象是否加了 @Valid。
7. 错误响应是否能返回字段名和错误原因。
8. Validation 错误是否不会打印完整堆栈给前端。
9. 复杂业务规则是否放在 Service,而不是塞进注解。
10. Swagger/OpenAPI 文档是否能反映字段必填和格式。

推荐错误响应格式:

json
{
  "code": "VALIDATION_ERROR",
  "message": "请求参数不合法",
  "fields": [
    {
      "field": "email",
      "message": "邮箱格式不正确"
    }
  ]
}

这个格式比单纯返回 "参数错误" 更适合前端表单回显。


🔍 十二、自测问题

学习完本节后,应该能回答:

text
@Valid 和 @Validated 有什么区别?
@NotNull、@NotEmpty、@NotBlank 分别适合什么场景?
为什么请求 DTO 中建议使用 Integer 而不是 int?
嵌套对象为什么需要额外加 @Valid?
MethodArgumentNotValidException 和 ConstraintViolationException 分别何时出现?
分组校验什么时候适合,什么时候不如拆 DTO?
哪些规则属于字段校验,哪些规则应该留在 Service?

参数校验是接口的第一道防线。它越清晰,业务代码越干净,接口文档也越可信。


📌 十三、学习建议

建议写一个 UserCreateRequest,分别测试:

text
字段缺失
字段为空字符串
字段只有空格
邮箱格式错误
嵌套对象字段错误

然后观察全局异常处理返回的字段路径和错误消息,确认前端能直接使用这些信息做表单提示。