2253 字
约 7 分钟
14
S06-X09 · 参数校验与全局异常处理

前端传 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}$");
    }
}

三个必须记住的点:

  1. message()、groups()、payload() 是规范要求的成员,缺一个运行期报 UnexpectedTypeException。
  2. 空值必须返回 true:多数约束把 null 视为合法,交给 @NotNull 管;对 null 返回 false 会在字段缺失时报出误导性的格式错误。
  3. 跨字段约束写在类级别(@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. 自测

  1. @NotNull、@NotEmpty、@NotBlank 三个分别在哪类输入上有区别?
  2. @Valid 与 @Validated 在组的处理上有什么不同?标了组的约束在 @Valid 下会跑吗?
  3. @Valid @RequestBody 失败抛哪个异常?类上 @Validated 的参数失败抛哪个?
  4. 自定义约束的 isValid 收到 null 应该返回什么?为什么?
  5. 两个 @ExceptionHandler 都能处理同一异常时,谁生效?与声明顺序有关吗?
  6. Filter 里抛出的异常为什么进不了 @ControllerAdvice?两个可行改法是什么?
S06-X09 · 参数校验与全局异常处理
https://www.clxhxhhr.top/posts/5845/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。