3. 使用JSR 303实现请求参数校验与统一错误响应

使用JSR 303实现请求参数校验与统一错误响应 学习卡片

为什么需要 JSR 303 校验

在用户注册接口中,请求体字段(用户名、密码、邮箱、年龄)需要满足长度、格式、范围等约束。如果把这些判断全部写在 Controller 方法里,代码会迅速膨胀,且错误响应格式难以统一。JSR 303(Bean Validation)允许把约束声明在 DTO 字段上,由框架在参数绑定后自动执行校验,校验失败时抛出 MethodArgumentNotValidException,再交给全局异常处理器统一转换为结构化 JSON。

依赖与 DTO 约束声明

Spring Boot 2.3 之后需要显式引入校验实现。在 pom.xml 中加入:

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

定义注册请求 DTO,把约束直接标注在字段上:

public class RegisterRequest {

    @NotBlank(message = "用户名不能为空")
    @Size(min = 4, max = 20, message = "用户名长度需在4到20之间")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 8, max = 32, message = "密码长度需在8到32之间")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$",
             message = "密码必须同时包含字母和数字")
    private String password;

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

    @NotNull(message = "年龄不能为空")
    @Min(value = 18, message = "年龄不能小于18")
    @Max(value = 120, message = "年龄不能大于120")
    private Integer age;

    // getter / setter 省略
}
约束注解的 message 会作为字段级错误信息返回,建议写成对用户友好的中文提示,而不是默认的英文模板。

在 Controller 上启用校验

在接口参数前加 @Valid(或 @Validated),Spring MVC 会在绑定完成后触发校验:

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

    @PostMapping("/register")
    public ResponseEntity<String> register(@Valid @RequestBody RegisterRequest request) {
        // 校验通过后才会执行到这里
        return ResponseEntity.ok("注册成功:" + request.getUsername());
    }
}

统一错误响应结构

为了让前端稳定解析错误,先定义一个统一响应体:

public class ApiErrorResponse {
    private int code;
    private String message;
    private List<FieldErrorItem> fieldErrors;

    public ApiErrorResponse(int code, String message, List<FieldErrorItem> fieldErrors) {
        this.code = code;
        this.message = message;
        this.fieldErrors = fieldErrors;
    }

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

        public FieldErrorItem(String field, String message) {
            this.field = field;
            this.message = message;
        }
        // getter 省略
    }
    // getter 省略
}

用 @ControllerAdvice 捕获校验异常

校验失败时抛出的是 MethodArgumentNotValidException,其中 getBindingResult() 保存了所有字段错误。通过 @RestControllerAdvice 集中处理:

@RestControllerAdvice
public class GlobalValidationHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {

        List<ApiErrorResponse.FieldErrorItem> items = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(fe -> new ApiErrorResponse.FieldErrorItem(
                        fe.getField(), fe.getDefaultMessage()))
                .collect(Collectors.toList());

        ApiErrorResponse body = new ApiErrorResponse(400, "参数校验失败", items);
        return ResponseEntity.badRequest().body(body);
    }
}
如果同一个字段同时触发多个约束(例如密码既太短又不含数字),getFieldErrors() 可能返回多条记录。若前端只希望每个字段显示一条提示,可在流处理中按 field 去重,或使用 getFieldError() 只取第一条。

校验链路与错误响应结构

下图展示从请求进入 Controller 到返回结构化错误的完整链路,以及 ApiErrorResponse 的字段组成:

POST /api/users/register JSON 请求体 @Valid 触发校验 Bean Validation 执行约束 校验通过? BindingResult 执行 业务逻辑 否 MethodArgumentNotValidException 携带 BindingResult(所有字段错误) @RestControllerAdvice @ExceptionHandler 提取 fieldErrors → 构建 ApiErrorResponse HTTP 400 · ApiErrorResponse code: 400 message: "参数校验失败" fieldErrors: [ {field, message}, ... ] 约束注解来源 @NotBlank @Size @Email @Min @Max @Pattern

验证与结果判读

启动应用后,用以下请求验证校验是否生效:

curl -X POST http://localhost:8080/api/users/register \
  -H "Content-Type: application/json" \
  -d '{"username":"ab","password":"123","email":"not-an-email","age":15}'

预期返回 HTTP 400,响应体形如:

{
  "code": 400,
  "message": "参数校验失败",
  "fieldErrors": [
    {"field": "username", "message": "用户名长度需在4到20之间"},
    {"field": "password", "message": "密码长度需在8到32之间"},
    {"field": "email",    "message": "邮箱格式不正确"},
    {"field": "age",      "message": "年龄不能小于18"}
  ]
}

再发送一条全部合法的请求,应返回 HTTP 200 与 "注册成功:..."。若合法请求仍返回 400,优先检查:DTO 字段是否缺少 getter(Jackson 反序列化失败会先于校验报错)、@Valid 是否加在 @RequestBody 参数上、以及 spring-boot-starter-validation 是否已引入。

关键点:@Valid 负责触发校验,@ControllerAdvice 负责把异常翻译成统一 JSON。两者缺一不可——只加注解不处理异常,前端拿到的仍是 Spring 默认错误页或默认错误结构。