Spring Boot 参数校验与全局异常处理

举报
yd_227606267 发表于 2026/11/01 21:30:11 2026/11/01
【摘要】 用 Jakarta Validation 搭一套可复用的校验与统一错误响应。

接口参数校验写不好,代码里就会长满 if (name == null || name.isBlank());异常处理写不好,前端就会收到五种格式各异的错误响应。本文基于 Spring Boot 3.x 与 Jakarta Validation 3.0,给出一套可直接落地的校验与统一异常处理方案,并说明 @Valid 与 @Validated 的区别、分组校验、嵌套校验、自定义约束这些容易出错的地方。适合正在搭后端骨架或重构接口层的开发阅读。

一、依赖与命名空间

Spring Boot 3 使用 Jakarta EE 9+ 命名空间,所有 javax.validation 都要换成 jakarta.validation:

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

这个 starter 引入 hibernate-validator 与 jakarta.el。Spring Boot 2.x 使用的是 javax.validation,升级到 3.x 时若忘记改包名,会出现注解"没报错但也不生效"的情况。

二、在 DTO 上声明约束

public record UserCreateDTO(
        @NotBlank(message = "用户名不能为空")
        @Size(min = 3, max = 20, message = "用户名长度需在 3~20 之间")
        String username,

        @NotBlank(message = "密码不能为空")
        @Size(min = 8, max = 64, message = "密码长度需在 8~64 之间")
        String password,

        @NotNull(message = "年龄不能为空")
        @Min(value = 1, message = "年龄最小为 1")
        @Max(value = 150, message = "年龄最大为 150")
        Integer age,

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

        @Valid
        @NotEmpty(message = "地址列表不能为空")
        List<AddressDTO> addresses
) {
}

常用约束:

| 注解 | 适用类型 | 说明 |

| --- | --- | --- |

| @NotNull | 任意 | 不能为 null,空字符串可以通过 |

| @NotEmpty | CharSequence、集合、Map、数组 | 不能为 null 且长度大于 0 |

| @NotBlank | CharSequence | 不能为 null 且去除空白后长度大于 0 |

| @Size | 字符串、集合、Map、数组 | 长度范围 |

| @Min / @Max | 数值 | 闭区间 |

| @DecimalMin / @DecimalMax | 数值、BigDecimal | 支持字符串形式的小数 |

| @Pattern | CharSequence | 正则匹配 |

| @Email | CharSequence | 邮箱格式 |

三、在 Controller 上触发校验

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

    @PostMapping
    public Long create(@Valid @RequestBody UserCreateDTO dto) {
        return userService.create(dto);
    }

    @GetMapping("/{id}")
    public UserVO get(@PathVariable @Min(1) Long id) {
        return userService.findById(id);
    }
}

关键区别:

  • @Valid:Jakarta 标准注解,作用于方法参数、字段,触发嵌套校验。
  • @Validated:Spring 注解,是 @Valid 的变体,额外支持分组。标在类上时,Spring 会为该 Bean 创建 AOP 代理,从而让方法参数上的约束生效。

@PathVariable、@RequestParam 上的约束必须配合类上的 @Validated 才会执行,因为它们的校验由 MethodValidationPostProcessor 生成的方法级 AOP 完成,而不是 RequestResponseBodyMethodProcessor。

四、校验失败的异常类型

不同触发方式抛出的异常不同,全局处理必须全覆盖:

| 场景 | 异常 |

| --- | --- |

| @RequestBody @Valid 失败 | MethodArgumentNotValidException |

| @PathVariable / @RequestParam 约束失败 | ConstraintViolationException |

| @RequestParam 类型转换失败 | MethodArgumentTypeMismatchException |

| 请求体 JSON 格式错误 | HttpMessageNotReadableException |

| 缺少必填请求参数 | MissingServletRequestParameterException |

| 请求方法不支持 | HttpRequestMethodNotSupportedException |

| 自定义业务异常 | 自定义类 |

只处理 MethodArgumentNotValidException 是常见疏漏:@PathVariable 校验失败会抛 ConstraintViolationException,前端收到的是 500 而不是 400。

五、统一错误响应结构

public record ApiError(
        int code,
        String message,
        List<FieldError> errors,
        String path,
        Instant timestamp
) {
    public record FieldError(String field, String message) {
    }
}

六、全局异常处理器

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleBodyValidation(MethodArgumentNotValidException ex,
                                         HttpServletRequest request) {
        List<ApiError.FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(fe -> new ApiError.FieldError(fe.getField(), fe.getDefaultMessage()))
                .toList();
        return build(400, "参数校验失败", errors, request);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleMethodValidation(ConstraintViolationException ex,
                                           HttpServletRequest request) {
        List<ApiError.FieldError> errors = ex.getConstraintViolations().stream()
                .map(v -> new ApiError.FieldError(
                        v.getPropertyPath().toString(), v.getMessage()))
                .toList();
        return build(400, "参数校验失败", errors, request);
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiError handleUnreadable(HttpMessageNotReadableException ex,
                                     HttpServletRequest request) {
        return build(400, "请求体格式错误", List.of(), request);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiError> handleBusiness(BusinessException ex,
                                                   HttpServletRequest request) {
        return ResponseEntity.status(ex.getStatus())
                .body(build(ex.getCode(), ex.getMessage(), List.of(), request));
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiError handleUnknown(Exception ex, HttpServletRequest request) {
        log.error("unhandled exception, path={}", request.getRequestURI(), ex);
        return build(500, "服务器内部错误", List.of(), request);
    }

    private ApiError build(int code, String message,
                           List<ApiError.FieldError> errors, HttpServletRequest request) {
        return new ApiError(code, message, errors, request.getRequestURI(), Instant.now());
    }
}

要点:

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody,返回值直接序列化为 JSON。
  • @ExceptionHandler(Exception.class) 必须放在最后,Spring 按最具体匹配,不会抢占前面的处理器。
  • 兜底处理器不要把原始异常信息返回给前端,只记日志。e.printStackTrace() 式的返回会泄露表名、SQL、内网地址。
  • 需要返回非 200 状态码时用 ResponseEntity,@ResponseStatus 只能写死一个值。

七、分组校验

同一个 DTO 在新增和更新时约束不同:

public interface Create {
}

public interface Update {
}

public record UserDTO(
        @Null(groups = Create.class, message = "新增时不能指定 ID")
        @NotNull(groups = Update.class, message = "更新时必须指定 ID")
        Long id,

        @NotBlank(groups = {Create.class, Update.class}, message = "用户名不能为空")
        String username
) {
}

Controller 指定分组:

@PostMapping
public Long create(@Validated(Create.class) @RequestBody UserDTO dto) { ... }

@PutMapping
public void update(@Validated(Update.class) @RequestBody UserDTO dto) { ... }

注意:一旦指定分组,未标注分组的约束不会再执行。这是分组校验最容易踩的坑,需要把通用约束也写进分组。

八、自定义约束

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
public @interface Phone {
    String message() default "手机号格式不正确";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class PhoneValidator implements ConstraintValidator<Phone, String> {
    private static final Pattern PATTERN = Pattern.compile("^1[3-9]\\d{9}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isBlank()) {
            return true;   // 空值交给 @NotBlank 处理
        }
        return PATTERN.matcher(value).matches();
    }
}

约定:自定义约束只判断"格式",空值返回 true,由 @NotNull/@NotBlank 单独负责。否则一个字段会出现重复的错误信息。

九、自定义业务异常

业务异常应自带状态码与错误码,避免在 Controller 里到处 throw new RuntimeException:

public class BusinessException extends RuntimeException {

    private final int status;
    private final int code;

    public BusinessException(int status, int code, String message) {
        super(message);
        this.status = status;
        this.code = code;
    }

    public static BusinessException notFound(String message) {
        return new BusinessException(404, 40400, message);
    }

    public static BusinessException conflict(String message) {
        return new BusinessException(409, 40900, message);
    }

    public int getStatus() {
        return status;
    }

    public int getCode() {
        return code;
    }
}

BusinessException 继承 RuntimeException,不会污染方法签名,也能被 @Transactional 默认回滚规则覆盖。对外暴露的 code 用于前端分支处理,message 用于展示,两者分离后文案可以随时调整而不影响前端逻辑。

十、校验的位置:Controller 还是 Service

参数校验分两层:

  • 格式校验放在 DTO 上,用 Jakarta Validation 注解,在 Controller 入口完成,失败直接返回 400,不进入业务逻辑。
  • 业务校验放在 Service 里,例如"用户名是否已存在""余额是否充足",这类规则依赖数据库状态,不适合用注解表达。

不要把所有校验都塞进 DTO 注解。@Pattern 里写正则能表达"手机号格式",但表达不了"这个手机号必须属于当前企业"。后者应在 Service 中显式判断并抛 BusinessException,保持校验逻辑与业务语义同层。

十一、Spring Boot 3 的 ProblemDetail

Spring 6 内置了 RFC 7807 的 ProblemDetail,需要标准错误格式时可直接使用:

@ExceptionHandler(BusinessException.class)
public ProblemDetail handleBusiness(BusinessException ex) {
    ProblemDetail detail = ProblemDetail.forStatusAndDetail(
            HttpStatus.valueOf(ex.getStatus()), ex.getMessage());
    detail.setTitle("业务处理失败");
    detail.setProperty("code", ex.getCode());
    detail.setProperty("timestamp", Instant.now());
    return detail;
}

开启 spring.mvc.problemdetails.enabled=true 后,Spring MVC 内置异常也会自动转换为 application/problem+json。

十二、测试校验逻辑

校验规则属于接口契约,应当有测试覆盖。用 MockMvc 可以直接断言状态码与错误响应:

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void shouldReturn400WhenUsernameBlank() throws Exception {
        String body = """
                {"username": "", "password": "12345678", "age": 20}
                """;
        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(body))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("username"));
    }
}

@WebMvcTest 只加载 Web 层,@RestControllerAdvice 会被自动纳入,因此全局异常处理也能一起验证。Spring Boot 3.4 起 @MockBean 已标记为过时,应改用 @MockitoBean。

十三、校验消息的国际化

校验注解的 message 支持占位符 {key},配合 MessageSource 可以做多语言。Spring Boot 默认从类路径的 messages.properties 读取:

# src/main/resources/messages.properties
user.username.notblank=用户名不能为空
user.username.size=用户名长度需在 {min}~{max} 之间
@NotBlank(message = "{user.username.notblank}")
@Size(min = 3, max = 20, message = "{user.username.size}")
private String username;

{min}、{max} 是约束注解的属性占位符,由 Hibernate Validator 自动替换。如果资源文件放在 i18n/ 目录下,需要配置 spring.messages.basename=i18n/messages。

国际化在纯中文项目里价值不大,但把文案从注解中抽出来后,修改提示语不需要改 Java 代码、不用重新编译,这一点本身就值得做。

十四、常见坑 / 最佳实践

  1. 只在 @RequestBody 上加 @Valid,忘了 @PathVariable。后者需要类上 @Validated 才生效,否则约束被静默忽略。
  2. DTO 用 record 但约束写在组件参数上不生效。Jakarta Validation 的约束要写在 record 的组件声明上,Hibernate Validator 6.2+ 才支持 record 校验;Spring Boot 3 已满足,但需确认版本。
  3. 嵌套对象忘记加 @Valid。List<AddressDTO> 里的元素不会自动校验,必须在字段上加 @Valid,否则嵌套约束全部跳过。
  4. @Validated 标在接口上。Spring 的方法级校验依赖代理,标在接口上时 CGLIB 代理读不到,应在实现类上标注。
  5. 全局异常处理器返回 200。前端依赖状态码做分流时会被误导。校验失败返回 400,业务冲突返回 409,未授权返回 401,不要一律 200 + code。
  6. 把 Exception 的 message 直接返回。可能包含 SQL、堆栈、内部类名。对外统一文案,对内打日志并附 traceId。

十五、小结

  • Spring Boot 3 使用 jakarta.validation,依赖 spring-boot-starter-validation。
  • @Valid 触发嵌套校验,@Validated 额外支持分组与方法级校验。
  • @RequestBody 失败抛 MethodArgumentNotValidException,@PathVariable 失败抛 ConstraintViolationException,两者都要处理。
  • 分组校验一旦指定,未标注分组的约束不再执行,通用约束需显式归入分组。
  • 兜底异常处理器只记日志,不向前端暴露原始异常信息。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。