前端传 age=-1 但字段是 String;分页传 size=100000 把库打满;phone 少一位,业务层才在 SQL 上撞约束并返回 500。三类问题不在同一层,混在一起就会出现"有的 400、有的 500、有的返回一坨堆栈"。
校验要分层,异常要归口:链路按 入参 → 业务 → 持久化 → 兜底 四段拆。
1. 三层校验各管什么
| 层 | 手段 | 该负责 | 不该负责 |
|---|---|---|---|
| Controller 入参 | @Valid / @Validated + 约束注解 |
形状与格式:非空、长度、范围、格式、枚举值 | 业务规则(余额够不够)、跨字段一致性 |
| Service | 显式判断 + 领域异常 | 业务不变式:状态机、权限、幂等 | 数据格式(已在上层过滤) |
| 数据库 | NOT NULL、UNIQUE、CHECK、外键 |
并发下的最终一致性兜底 | 用户可读的错误提示 |
三层不是重复劳动:Controller 拦掉绝大多数脏输入,Service 表达业务语义,数据库负责并发竞争下的正确性。只做一层都会漏——只在 Controller 校验,绕过 HTTP 的定时任务照样写脏数据;只靠数据库,用户看到的是 Duplicate entry 这种原始报错。
2. 常用约束与分组校验
public class UserCreateReq {
@NotNull @Min(1) private Long id;
@NotBlank @Size(min = 2, max = 32) private String username;
@Email @NotBlank private String email;
@NotNull @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式错误") private String phone;
}
| 注解 | 语义 | 易错点 |
|---|---|---|
@NotNull |
不为 null |
空字符串 "" 通过 |
@NotEmpty |
非 null 且长度 > 0 |
纯空白字符串通过 |
@NotBlank |
非 null 且去空白后非空 |
只能用于 CharSequence |
@Size |
长度/集合大小范围 | 不校验内容 |
@Pattern |
正则匹配 | null 值时不校验(由 @NotNull 负责) |
@Min / @Max |
数值范围 | 只对数值类型生效 |
分组校验解决"新增要校验 id 为空、修改要校验 id 非空"这类场景:
public interface Create {}
public interface Update {}
public class UserReq {
@Null(groups = Create.class, message = "新增不能带 id")
@NotNull(groups = Update.class, message = "修改必须带 id")
private Long id;
}
@PostMapping("/users")
public void create(@Validated(Create.class) @RequestBody UserReq req) { }
标了 groups 的约束,在未标 groups 的校验里完全不执行:@Valid 走 Default 组,两者互不相干。要多组同时校验,用 @Validated({Create.class, Default.class}) 或 @GroupSequence:
@GroupSequence({Basic.class, Advanced.class})
public interface Ordered {}
@GroupSequence 的语义是短路而非并集:前一组失败就跳过后续组。典型用法是"轻量格式校验通过后才做重校验(如查库唯一性)"。
3. 嵌套对象与集合的校验
@NotNull 只证明对象引用存在,不校验内部字段。穿透需要 @Valid 挂在该字段上:
| 写法 | 效果 |
|---|---|
@NotNull private Address address; |
仅 address 不为 null,内部字段不校验 |
@NotNull @Valid private Address address; |
校验 address 内部字段 |
@Valid @NotEmpty private List<Item> items; |
校验集合非空且每个元素内部 |
public class OrderCreateReq {
@Valid @NotEmpty(message = "明细不能为空")
private List<Item> items;
private static class Item {
@NotNull private Long skuId;
@Min(1) @Max(999) private Integer qty;
}
}
嵌套校验失败时 FieldError.getField() 返回的是 items[0].skuId 这类索引路径,前端按路径回填,不要截末段。
4. 方法级校验与两种异常
类上标 @Validated 会让 MethodValidationPostProcessor 给该 Bean 套一层 AOP 代理,对方法参数校验。它与 @Valid @RequestBody 是两条路径:
| 对比项 | @Valid @RequestBody |
类上 @Validated + 参数注解 |
|---|---|---|
| 触发者 | RequestResponseBodyMethodProcessor |
MethodValidationInterceptor |
| 失败异常 | MethodArgumentNotValidException |
ConstraintViolationException |
| 异常里带什么 | BindingResult(字段、拒绝值、消息) |
ConstraintViolation 集合(属性路径、消息) |
| 生效范围 | 仅带 @RequestBody 的参数 |
所有方法参数(含 Service) |
@RestController
@Validated
public class UserController {
@GetMapping("/users/{id}")
public User get(@PathVariable @Min(1) Long id) { // 失败 → ConstraintViolationException
return service.get(id);
}
}
注意类上 @Validated 会给整个 Bean 建代理:同类内 this. 自调用绕过代理,表现为"Service 的参数校验没生效"(Spring AOP 的通用限制)。
5. 自定义约束
复合约束(多个 @Pattern 组合)或跨字段比较(start < end)需要自己写:
@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> {
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null || value.isEmpty()) return true; // 空值交给 @NotBlank
return value.matches("^1[3-9]\\d{9}$");
}
}
三个必须记住的点:
message()、groups()、payload()是规范要求的成员,缺一个运行期报UnexpectedTypeException。- 空值必须返回
true:多数约束把null视为合法,交给@NotNull管;对null返回false会在字段缺失时报出误导性的格式错误。 - 跨字段约束写在类级别(
@Target(TYPE)),validator 泛型是整类,才能同时读到两个字段。
6. @RestControllerAdvice 的分层组织
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class) // @RequestBody 入参校验失败
public ApiResult<Void> onBodyInvalid(MethodArgumentNotValidException e) {
return ApiResult.fail(ErrorCode.INVALID_PARAM, toMsg(e.getBindingResult().getFieldErrors()));
}
@ExceptionHandler(ConstraintViolationException.class) // 方法参数校验失败
public ApiResult<Void> onParamInvalid(ConstraintViolationException e) {
return ApiResult.fail(ErrorCode.INVALID_PARAM, toMsg(e.getConstraintViolations()));
}
@ExceptionHandler(BizException.class)
public ApiResult<Void> onBiz(BizException e) {
log.warn("business error code={} msg={}", e.getCode(), e.getMessage());
return ApiResult.fail(e.getCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResult<Void> onUnknown(Exception e, HttpServletRequest req) {
log.error("unhandled error uri={}", req.getRequestURI(), e); // 堆栈进日志
return ApiResult.fail(ErrorCode.INTERNAL, "服务内部错误");
}
}
优先级规则:异常继承链上越具体的方法越优先。BizException extends RuntimeException 时它赢过 Exception 处理器,与声明顺序无关;两个 @ControllerAdvice 都能处理时,@Order 值小的赢。
| 返回体字段 | 设计要点 |
|---|---|
code |
业务错误码,与 HTTP 状态码解耦(HTTP 200 + 业务码 40001 是常见做法) |
message |
面向用户的可读信息,不含内部类名、SQL |
HTTP 状态码:参数错误用 400 更合语义;若网关或前端统一按 200 解析响应体,则全员 200 + 业务码。两种都自洽,同一项目别混着来。
7. 不该做的三件事
| 做法 | 后果 | 替代 |
|---|---|---|
| 把堆栈返回给前端 | 泄露包结构、SQL、内网地址 | 只返回 code + 通用文案,堆栈进日志 |
| 用异常控制正常流程 | 异常构造贵、日志噪音大 | 用返回值或 Optional 表达"未找到" |
兜底 Exception 里不记日志 |
出错无证据,只能靠复现 | log.error 打完整堆栈 |
第三条最隐蔽:兜底处理器只 return 却没 log.error(..., e),等于把未预期异常静默吞掉——前端看到"系统错误",后端日志干净如初。
8. 排错
| 现象 | 根因 | 处理 |
|---|---|---|
@Valid 不生效 |
缺 spring-boot-starter-validation(Boot 2.3 起不再默认带) |
显式引入,确认 jakarta.validation 在类路径 |
| 分组校验不生效 | @Valid 走了 Default 组,约束标在自定义组上 |
用 @Validated(XxxGroup.class) 指定组 |
| 嵌套对象不校验 | 字段上只写了 @NotNull 没写 @Valid |
补 @Valid,集合写 List<@Valid T> |
ConstraintViolationException 无人接 |
只写了处理 MethodArgumentNotValidException |
增加 ConstraintViolationException 处理器 |
| Filter 里抛的异常直接 500 | Filter 在 DispatcherServlet 之前,@ControllerAdvice 看不到 |
在 Filter 内 try-catch 写响应,或改用 HandlerInterceptor |
| 兜底处理器返回 200 | 未标 @ResponseStatus 也未返回 ResponseEntity |
明确状态码,别让框架默认 200 |
| Service 参数校验不生效 | 同类内 this. 自调用绕过 AOP 代理 |
拆到另一个 Bean,或去掉自调用 |
Filter 那条展开一下:OncePerRequestFilter 抛的异常不经过 HandlerExceptionResolver,因为请求还没进 DispatcherServlet。要么在 Filter 内捕获并手写 JSON,要么把校验挪到 HandlerInterceptor#preHandle。
9. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 校验规则随租户/配置变化 | 规则引擎或数据库驱动规则表 | 注解是编译期常量 |
| 需要"全部错误一次返回" | 检查 BindingResult,不要抛异常 |
一次只报一个错,体验差 |
| 并发唯一性校验 | 数据库唯一索引 + 捕获 DataIntegrityViolationException |
先查后插在并发下必失 |
10. 自测
@NotNull、@NotEmpty、@NotBlank三个分别在哪类输入上有区别?@Valid与@Validated在组的处理上有什么不同?标了组的约束在@Valid下会跑吗?@Valid @RequestBody失败抛哪个异常?类上@Validated的参数失败抛哪个?- 自定义约束的
isValid收到null应该返回什么?为什么? - 两个
@ExceptionHandler都能处理同一异常时,谁生效?与声明顺序有关吗? - Filter 里抛出的异常为什么进不了
@ControllerAdvice?两个可行改法是什么?