Spring Boot 参数校验与全局异常处理
接口参数校验写不好,代码里就会长满 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 代码、不用重新编译,这一点本身就值得做。
十四、常见坑 / 最佳实践
- 只在
@RequestBody上加@Valid,忘了@PathVariable。后者需要类上@Validated才生效,否则约束被静默忽略。 - DTO 用
record但约束写在组件参数上不生效。Jakarta Validation 的约束要写在 record 的组件声明上,Hibernate Validator 6.2+ 才支持 record 校验;Spring Boot 3 已满足,但需确认版本。 - 嵌套对象忘记加
@Valid。List<AddressDTO>里的元素不会自动校验,必须在字段上加@Valid,否则嵌套约束全部跳过。 @Validated标在接口上。Spring 的方法级校验依赖代理,标在接口上时 CGLIB 代理读不到,应在实现类上标注。- 全局异常处理器返回 200。前端依赖状态码做分流时会被误导。校验失败返回 400,业务冲突返回 409,未授权返回 401,不要一律 200 +
code。 - 把
Exception的 message 直接返回。可能包含 SQL、堆栈、内部类名。对外统一文案,对内打日志并附traceId。
十五、小结
- Spring Boot 3 使用
jakarta.validation,依赖spring-boot-starter-validation。 @Valid触发嵌套校验,@Validated额外支持分组与方法级校验。@RequestBody失败抛MethodArgumentNotValidException,@PathVariable失败抛ConstraintViolationException,两者都要处理。- 分组校验一旦指定,未标注分组的约束不再执行,通用约束需显式归入分组。
- 兜底异常处理器只记日志,不向前端暴露原始异常信息。
- 点赞
- 收藏
- 关注作者
评论(0)