参数校验与全局异常处理体系

bee2026-10-0878 分钟0 次阅读
从 JSR-303 注解全家桶到分组校验、自定义校验器;再配 @RestControllerAdvice 全局异常兜底,把「校验失败 500」彻底消灭。
1 / 140
小节
〇、30 秒看懂
2 / 140

先说人话。参数校验就是「在动手做事之前,先检查对方给的东西合不合格」:用户名是不是空的、邮箱有没有 @、年龄是不是 18 岁以上。异常处理就是「事情做砸了之后,用什么姿势告诉外面到底砸在哪」。这两件事加起来占了一个后端接口一半的代码量——但它们都不该写在业务逻辑里,而应该写成规则(挂在字段上)和兜底(集中在一处)。

3 / 140

第一次见这几个词,一句话解释清楚:

4 / 140
  • 注解(annotation):写在代码上的「标签」,以 @ 开头,本身不干活,由框架在运行时读取并据此做事,例如 @NotBlank 只是个标签,真正去检查的是 Hibernate Validator
  • DTO:Data Transfer Object,专门用来「接收前端传来的那堆字段」的类。它和数据库实体分开,因为前端能改的东西比数据库少得多
  • BindingResult:一次校验的「成绩单」对象,里面记着哪个字段错、错在哪句提示
  • @RestControllerAdvice:一个「全局兜底组件」,专门捕获所有 Controller 抛出的异常并翻译成统一响应
  • HTTP 状态码:服务器对这次请求的一句话结论,200 成功、400 你的参数有问题、401 没登录、500 我这边炸了
5 / 140
类比

参数校验就是机场安检。你买票(发请求)时系统并不拦你;等你走到登机口(进入 Controller 之前),安检员才按一条条固定规则过一遍 X 光机:液体超量(@Size)、证件无效(@Pattern)、行李里带了违禁品(@NotBlank 没过)。规则写在「安检手册」上而不是写在每个空乘嘴里,所以任何航班的结果都一样:不合格就不放行,并且明确告诉你是哪一件行李、违反了哪一条。忘贴 @Valid 相当于这道口根本没开机器。

6 / 140
类比

异常处理就是医院分诊台。病人(异常)从各个科室跑出来,护士不会自己开药——她只做三件事:认出这是什么伤(按异常类型匹配处理器)、决定去哪个科(映射成对应的 HTTP 状态码)、登记病历号(打日志 + 生成 traceId)。最危险的不是没人管,而是门口挂了一块「所有病症一律开止痛药」的牌子——那就是只写了 @ExceptionHandler(Exception.class) 的兜底:骨折和发烧都返回 500,真正的病因被吞掉了。

7 / 140
架构图
图 · 本篇地图:校验与异常一共这几件事
图 · 本篇地图:校验与异常一共这几件事
8 / 140

上图四个盒子就是本篇的四类问题:规则怎么写(第三、六节)、什么时候触发(第二、四、五节)、异常谁来接(第八节)、响应长什么样(第九、十四节)。

9 / 140

学完这一篇,你应该能回答三个问题:

10 / 140
  • 同样一个「不能为空」,为什么写在 if 里要被抄一百遍,写成 @NotBlank 只用写一次?
  • 我的 DTO 里明明标了 @NotBlank,为什么传空字符串却照样进了数据库?(答案九成是 @Valid 忘写了)
  • 一个 BizException 从 Service 抛出后,凭什么最后变成 409 而不是一张 500 白页?中间谁接住了它?
11 / 140
小节
一、为什么校验不该写进业务代码
12 / 140

先看一段几乎每个人都写过的代码——「判空地狱」:

13 / 140
java
@PostMapping("/users")public Result<UserVO> create(@RequestBody Map<String, Object> body) {    String username = (String) body.get("username");    String email = (String) body.get("email");    Integer age = (Integer) body.get("age");    if (username == null || username.trim().isEmpty()) {        return Result.fail(40000, "用户名不能为空");    }    if (username.length() > 32) {        return Result.fail(40000, "用户名不能超过 32 个字符");    }    if (email == null || !email.matches("^[\\w.-]+@[\\w.-]+$")) {        return Result.fail(40000, "邮箱格式不正确");    }    if (age != null && (age < 18 || age > 120)) {        return Result.fail(40000, "年龄必须在 18 到 120 之间");    }    // ……终于轮到真正的业务逻辑,它已经被淹没在 if 里    return Result.ok(userService.create(username, email, age));}
14 / 140

这段代码有四个问题:校验逻辑和业务逻辑纠缠、每个接口都要抄一遍、错误结构不统一、Map 接参丢掉了类型。注解化改造后:

15 / 140
java
@PostMapping("/users")public Result<UserVO> create(@Valid @RequestBody UserCreateDTO dto) {    // 业务方法里只剩业务    return Result.ok(userService.create(dto));}public record UserCreateDTO(        @NotBlank(message = "用户名不能为空")        @Size(max = 32, message = "用户名不能超过 32 个字符")        String username,        @NotBlank(message = "邮箱不能为空")        @Email(message = "邮箱格式不正确")        String email,        @Min(value = 18, message = "年龄不能小于 18 岁")        @Max(value = 120, message = "年龄不能大于 120 岁")        Integer age) {}
16 / 140

同样的规则,写在了字段旁边,语义自解释、可复用、可被工具扫描。业务方法里彻底没有了 if——这就是校验框架存在的意义。

17 / 140
小节
二、依赖与触发方式:@Valid 与 @Validated
18 / 140

从 Spring Boot 2.3 起,校验能力被从 spring-boot-starter-web 中剥离,必须显式引入:

19 / 140
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-validation</artifactId></dependency>
20 / 140

它依赖 Hibernate Validator 实现了 JSR-303(Bean Validation)。两个注解长得像,职责却不同:

21 / 140
对照表
维度@Valid@Validated
来源JSR-303 标准(jakarta.validation)Spring 自有(org.springframework.validation.annotation)
分组校验不支持支持 groups 属性
可标注位置方法参数、字段、方法类、方法、参数
可否校验方法参数否是(打在类上,校验 @RequestParam 等)
级联校验支持(嵌套对象字段加 @Valid)支持
22 / 140

一句话记:对象整体校验用 @Valid,需要分组或校验散装参数用 @Validated。

23 / 140

两个注解之外,还要知道「开关到底按在哪一格」。下面这条链可以一格一格点,点第 ④ 格会看到:校验不是写在业务里的判断,而是参数解析完之后由框架统一跑的一轮。

24 / 140
交互图解
流程一次请求进 Controller 之前,校验插在哪一格1 / 7
从 ① 点到 ⑦,重点看第 ③ 与第 ④ 格:绑定和校验是两件事,开关是第三件事
→
→
→
→
→
→
① 请求到达
DispatcherServlet 拿到 /api/users,先按路径与方法找到那个 create(...) 处理器——此时还没有任何字段被检查过。
全部看懂了绑定 → 开关 → 校验 → 成绩单 → 异常 → 出口,六格各管一件事,报错时先定位自己卡在第几格。
25 / 140
小节
2.1 嵌套对象的级联校验
26 / 140
代码对照
代码java
public record OrderCreateDTO(        @NotBlank String orderNo,        @NotNull @Valid AddressDTO address,          // 加 @Valid 才会级联进 AddressDTO        @NotEmpty @Valid List<OrderItemDTO> items) {} // 集合元素的校验也靠它public record AddressDTO(        @NotBlank String province,        @NotBlank String city,        @NotBlank String detail) {}
解读

坑:嵌套对象的字段上只有约束注解、外层字段上却没有 @Valid,校验会「静默跳过」——AddressDTO 里的 @NotBlank 一个都不生效,请求照样通过。这是级联校验最经典的漏网场景。

27 / 140
小节
三、约束注解全家桶
28 / 140

约束注解有二十多个,但新手真正要分清的是「标了它到底拦得住什么」。别背表格——来玩一局闯关:左边是注解,右边点它真实的拦截范围,配错了当场告诉你为什么。

29 / 140
配对闯关
闯关注解 ↔ 它到底拦得住什么已配对 0/7 · 配错 0
七组都是硬映射,两列都打乱了,别靠位置猜——每条配错都会解释它最常见的误用
先点左边一个
30 / 140
要点

@NotNull / @NotEmpty / @NotBlank 三兄弟最容易选错。校验「姓名字段不能是空字符串」必须用 @NotBlank;用 @NotNull 时前端传 ""(空串)或 " "(空格)都会通过,然后一路流进数据库。

31 / 140
小节
四、方法参数校验:@Validated 打在类上
32 / 140

@RequestParam、@PathVariable 这类「散装参数」不能直接写 @Valid,必须把 @Validated 标在类上:

33 / 140
java
@Validated@RestController@RequestMapping("/api/users")public class UserController {    // @Validated 让类内所有方法参数约束生效    @GetMapping("/{id}")    public UserVO detail(            @PathVariable @Positive(message = "用户 ID 必须为正数") Long id,            @RequestParam @Min(value = 1, message = "页码从 1 开始") int page) {        return userService.detail(id, page);    }}
34 / 140

此时校验失败抛出的不是 MethodArgumentNotValidException,而是 ConstraintViolationException——两者的处理方式不同,全局异常处理器必须同时覆盖,否则就会掉到 500 兜底里。

35 / 140
小节
五、分组校验实战:新增与更新
36 / 140

同一个 DTO,新增时 id 必须为空(由数据库生成),更新时 id 必须非空。用分组就能用一套 DTO 表达两套规则:

37 / 140
java
// 1) 定义两个空接口作为分组标记public interface ValidGroups {    interface Create {}    interface Update {}}
38 / 140
java
public record UserDTO(        @Null(groups = ValidGroups.Create.class, message = "新增时不能指定 ID")        @NotNull(groups = ValidGroups.Update.class, message = "更新时必须指定 ID")        Long id,        @NotBlank(groups = {ValidGroups.Create.class, ValidGroups.Update.class},                  message = "用户名不能为空")        String username) {}
39 / 140
代码对照
代码java
@RestController@RequestMapping("/api/users")@Validatedpublic class UserController {    @PostMapping    public UserVO create(@Validated(ValidGroups.Create.class) @RequestBody UserDTO dto) {        return userService.create(dto);    }    @PutMapping("/{id}")    public UserVO update(@Validated(ValidGroups.Update.class) @RequestBody UserDTO dto) {        return userService.update(dto);    }}
解读

说明:不加 groups 的约束属于 Default 组,只有在 @Validated 不指定任何分组时才生效。一旦你在 @Validated(Xxx.class) 里指定了分组,那些没标 groups 的约束就会被跳过——这是分组校验最常见的「为什么我的校验突然不生效」的答案。

40 / 140
原理动画
动图 · 分组的闸门:Default 组为什么被跳过
动图 · 分组的闸门:Default 组为什么被跳过
41 / 140

把动图第五步记住:补救只有两条路,给约束补 groups,或者让分组接口 extends Default——第二种能让「未标 groups」的通用规则继续生效,改动最小。

42 / 140
小节
六、自定义校验注解:@Phone
43 / 140

内置注解覆盖不了业务规则时,就自己写一个。以手机号为例,它由注解与校验器两部分组成:

44 / 140
java
@Target({ElementType.FIELD, ElementType.PARAMETER})@Retention(RetentionPolicy.RUNTIME)@Constraint(validatedBy = PhoneValidator.class)     // 绑定校验器@Documentedpublic @interface Phone {    String message() default "手机号格式不正确";    Class<?>[] groups() default {};    Class<? extends Payload>[] payload() default {};    /** 是否允许为空;默认 true,配合 @NotBlank 使用 */    boolean required() default false;}
45 / 140
java
public class PhoneValidator implements ConstraintValidator<Phone, String> {    private static final Pattern CN_MOBILE = Pattern.compile("^1[3-9]\\d{9}$");    private boolean required;    @Override    public void initialize(Phone annotation) {        this.required = annotation.required();    }    @Override    public boolean isValid(String value, ConstraintValidatorContext context) {        if (value == null || value.isBlank()) {            return !required;        // 非必填时空值视为通过        }        return CN_MOBILE.matcher(value).matches();    }}
46 / 140
java
public record ProfileUpdateDTO(        @NotBlank String nickname,        @Phone(required = true, message = "请填写有效的手机号")        String mobile) {}
47 / 140

三个方法各有分工:initialize 读注解属性、isValid 做判断、message/groups/payload 是规范要求的固定成员(缺了框架会启动报错)。自定义校验器必须线程安全——Pattern 编译一次、复用一个实例即可。

48 / 140
小节
七、校验消息国际化
49 / 140

硬编码中文只能服务一种语言。把消息抽到资源文件,多语言就同时具备:

50 / 140
properties
# src/main/resources/messages.properties(默认,英文兜底)NotBlank=must not be blankSize=size must be between {min} and {max}Phone=invalid phone number# src/main/resources/messages_zh_CN.propertiesNotBlank=不能为空Size=长度必须在 {min} 到 {max} 之间Phone=手机号格式不正确
51 / 140

指定消息源与编码:

52 / 140
yaml
spring:  messages:    basename: messages    encoding: UTF-8
53 / 140

带国际化时,约束注解里的 message 写占位键而非硬编码文案,例如 @NotBlank(message = "{NotBlank}"),运行期由 MessageSource 按请求的 Locale 解析。

54 / 140
小节
八、全局异常处理体系(重头戏)
55 / 140

到目前为止,所有校验失败抛出的都是异常。如果不统一处理,默认会返回 Spring 自带的错误页或一坨堆栈——前端拿到的是 500 或 HTML,而不是结构化的「哪个字段错了」。

56 / 140

@RestControllerAdvice + @ExceptionHandler 是唯一的正解。下面是一份可直接复制的完整实现:

57 / 140
java
package com.example.common.exception;import com.example.common.api.ErrorCode;import com.example.common.api.Result;import jakarta.servlet.http.HttpServletRequest;import jakarta.validation.ConstraintViolationException;import lombok.extern.slf4j.Slf4j;import org.springframework.http.HttpStatus;import org.springframework.validation.BindException;import org.springframework.validation.FieldError;import org.springframework.web.HttpRequestMethodNotSupportedException;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.ResponseStatus;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.web.servlet.NoHandlerFoundException;import java.util.List;import java.util.UUID;/** * 全局异常兜底:把各类异常翻译成统一的 Result + 正确的 HTTP 状态码。 */@Slf4j@RestControllerAdvicepublic class GlobalExceptionHandler {    /** 1) @RequestBody 对象校验失败 */    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(MethodArgumentNotValidException.class)    public Result<List<FieldErrorVO>> handleInvalidBody(MethodArgumentNotValidException e) {        List<FieldErrorVO> errors = e.getBindingResult().getFieldErrors().stream()                .map(this::toFieldError)                .toList();        return Result.fail(ErrorCode.INVALID_PARAM.getCode(), "参数校验失败", errors);    }    /** 2) 表单 / @ModelAttribute 对象校验失败 */    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(BindException.class)    public Result<List<FieldErrorVO>> handleBind(BindException e) {        List<FieldErrorVO> errors = e.getBindingResult().getFieldErrors().stream()                .map(this::toFieldError)                .toList();        return Result.fail(ErrorCode.INVALID_PARAM.getCode(), "参数校验失败", errors);    }    /** 3) @Validated 在类上校验散装参数失败 */    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(ConstraintViolationException.class)    public Result<List<FieldErrorVO>> handleConstraint(ConstraintViolationException e) {        List<FieldErrorVO> errors = e.getConstraintViolations().stream()                .map(v -> new FieldErrorVO(lastNode(v.getPropertyPath()), v.getMessage()))                .toList();        return Result.fail(ErrorCode.INVALID_PARAM.getCode(), "参数校验失败", errors);    }    /** 4) 业务异常:错误码与状态码由异常自身携带 */    @ExceptionHandler(BizException.class)    public org.springframework.http.ResponseEntity<Result<Void>> handleBiz(            BizException e, HttpServletRequest request) {        ErrorCode ec = e.getErrorCode();        log.warn("biz exception: {} {} -> {}", request.getMethod(), request.getRequestURI(), e.getMessage());        return org.springframework.http.ResponseEntity                .status(ec.getHttpStatus())                .body(Result.fail(ec.getCode(), e.getMessage()));    }    /** 5) 找不到处理器 → 404 */    @ResponseStatus(HttpStatus.NOT_FOUND)    @ExceptionHandler(NoHandlerFoundException.class)    public Result<Void> handleNotFound(NoHandlerFoundException e) {        return Result.fail(ErrorCode.RESOURCE_NOT_FOUND);    }    /** 6) 请求方法不支持 → 405 */    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)    public Result<Void> handleMethod(HttpRequestMethodNotSupportedException e) {        return Result.fail(ErrorCode.METHOD_NOT_ALLOWED.getCode(), e.getMessage());    }    /** 7) 兜底:未知异常 → 500,带 traceId 便于排查 */    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)    @ExceptionHandler(Exception.class)    public Result<Void> handleUnknown(Exception e, HttpServletRequest request) {        String traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);        log.error("unhandled exception, traceId={}, uri={}", traceId, request.getRequestURI(), e);        return Result.fail(ErrorCode.SYSTEM_ERROR.getCode(),                "系统繁忙,请稍后重试(traceId: " + traceId + ")");    }    private FieldErrorVO toFieldError(FieldError fe) {        return new FieldErrorVO(fe.getField(), fe.getDefaultMessage());    }    private String lastNode(jakarta.validation.Path path) {        String s = path.toString();        int i = s.lastIndexOf('.');        return i >= 0 ? s.substring(i + 1) : s;    }    public record FieldErrorVO(String field, String message) {}}
58 / 140

关键点:

59 / 140
  • 处理器方法与异常类型就近匹配——BizException 的处理方法比兜底的 Exception 更具体,所以业务异常一定先被它接走
  • 每个处理器都用 @ResponseStatus 或 ResponseEntity 明确 HTTP 状态码,而不是一律 200
  • 兜底 Exception 必须放最后思考:它的职责是「不漏异常」,而不是「吞掉异常」——log.error 打全堆栈,只把 traceId 返回给用户
60 / 140
小节
九、错误响应结构设计
61 / 140

统一的错误响应应该同时具备三个要素:机器可读的错误码、人可读的消息、字段级明细。校验失败的响应示例:

62 / 140
代码对照
代码json
{  "code": 40000,  "message": "参数校验失败",  "data": [    { "field": "username", "message": "用户名不能超过 32 个字符" },    { "field": "email", "message": "邮箱格式不正确" },    { "field": "address.city", "message": "城市不能为空" }  ]}
解读
  • code:业务错误码,前端可据此做特定处理(如 40100 跳登录页)
  • message:面向用户的兜底文案
  • data:校验失败时是字段错误列表,成功时是业务数据。用 T 类型承载,结构不额外分叉
  • 系统错误时额外带 traceId:用户在反馈里报这串码,你就能在日志里精确定位这一次请求
63 / 140
原理动画
动图 · 一条违规从请求体走到 400
动图 · 一条违规从请求体走到 400
64 / 140

上面那串 data 里的字段列表不是凭空冒出来的,它就是动图第 ④ 步那张「成绩单」被展开的样子。想看它是怎么一页页写出来、又是怎么被读出来的,照着调试台走一遍——同一个 MethodArgumentNotValidException,从抛出到写出 400,中间只经过一条解析器链:

65 / 140
单步调试台
单步台跟着调试器走一遍:校验异常是怎么找到出口的1 / 7
按真实执行顺序点 7 步,注意第 4 步——匹配按「类型就近」,不是按代码书写顺序
被调试的代码
1create(dto); // 业务方法体一行都没执行
2// DispatcherServlet.doDispatch(...) 捕获到刚抛出的异常
3processDispatchResult(request, response, mv, exception);
4// 遍历 handlerExceptionResolvers,顺序由容器装配决定
5mv = resolver.resolveException(request, response, handler, exception);
6// ExceptionHandlerExceptionResolver: 找 @ExceptionHandler 方法
7mapped = findMethod(exception); // 按异常类型就近匹配
8return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
此刻的变量
异常类型MethodArgumentNotValidException
出错参数argument [0]
业务方法是否执行没有
调用栈
—
1异常是在参数解析阶段抛的,你的 create(...) 方法体压根没进去——这是很多人以为「业务代码没执行到」的原因。
66 / 140
原理动画
动图 · 异常被谁接住了
动图 · 异常被谁接住了
67 / 140
小节
十、两个高频坑
68 / 140
  • @Valid 加在基本类型参数上无效:@Valid @RequestParam String name 不会触发任何校验——@Valid 只负责「开始校验一个对象/级联」,它不能给字符串加约束。散装参数要用 @Validated 标在类上,参数处直接写 @NotBlank
  • 校验异常被兜底「吞掉」:如果你只写了 @ExceptionHandler(Exception.class),MethodArgumentNotValidException 会在匹配阶段就被它接走(因为它也是 Exception),结果校验失败返回的是 500。要么为校验异常写专门的处理器,要么让兜底只处理意外异常——顺序不是代码书写顺序,而是「谁更具体谁优先」
69 / 140

图 1 给出了完整的链路视角:

70 / 140
架构图
图 1 · 一次请求的校验与异常链路
图 1 · 一次请求的校验与异常链路
71 / 140
内核实验
TeaVM请求处理链路中的校验位置未启动
用 /users/42 观察参数解析阶段的转换,理解校验发生在参数解析之后
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
72 / 140
小节
十一、上手实验:把四条链都亲手跑一遍
73 / 140

看十遍不如点一次。下面六个内核实验请按顺序跑完,每个约 30 秒,它们正好对应校验与异常的六个现场。

74 / 140

先搞清「我的参数是怎么被填进方法里的」。@PathVariable / @RequestParam / @RequestBody 走的是三条不同的解析器路径,而校验发生在解析成功之后。依次点五个参数,特别注意最后一个「没有解析器接手」——那就是你项目里那句报错的源头:

75 / 140
内核实验
TeaVM参数解析器责任链:校验插在哪一环未启动
依次切换 pathvar / reqparam / body / special,最后点 none 看没人接手时的报错
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
76 / 140

再往下看响应是怎么写出去的。校验失败要变成 JSON、406 要找消息转换器、@Validated 打在类上时抛的还是另一种异常——这三件事都由这条链决定:

77 / 140
内核实验
TeaVM内容协商与消息转换:错误体怎么变成 JSON未启动
先看 json 与 accept,再点 fail 观察 406 从哪一步冒出来
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
78 / 140

这是本篇的主场:异常解析器链。五个参数对应五种命运——校验失败 400、类型转换失败、被你自己的 @ExceptionHandler 接走、被 @ControllerAdvice 兜住、以及最坏的一种「没人接管 → 500」。请把 none 那一档跑完,它会打印出白页响应的真实来源:

79 / 140
内核实验
TeaVM异常解析器链:谁把你的异常接走了未启动
按 valid → convert → handler → status → none 顺序点,盯住每一步最终返回的 HTTP 状态码
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
80 / 140

最后把镜头拉远,看一个请求穿过全站时校验与异常落在哪一站。切到 valid(参数校验失败)与 db(数据库故障)对比:同样是失败,一个停在入口、一个停在最底层,但出口都是同一个全局 Advice:

81 / 140
内核实验
TeaVM一个请求穿全站:失败时从哪条道出去未启动
对比 happy / valid / biz / db 四档,注意错误都在同一处被翻译成统一响应
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
82 / 140

还有一个坑只有亲手点才会痛:@Controller 与 @RestController 少写一个 @ResponseBody,你 return 的那个字符串就被当成视图名去找模板,浏览器收到的是一张白页而不是 JSON。错误处理写对了、出口的「内容形态」却错了,是同一个量级的事故。四档都点一遍,尤其是 missing:

83 / 140
内核实验
TeaVM@Controller 还是 @RestController:返回值到底被当成什么未启动
按 view → json → missing → string 的顺序点:看同一段 return "error" 在两种控制器里分别变成视图名、JSON 还是 404 白页
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
84 / 140

六个实验跑完,再回看一遍开头那张动画,此时每一步都应该能对号入座——第 3 步的 ExceptionHandlerExceptionResolver 就是你刚点的 err,第 4 步的「就近匹配」就是沙盘里 只有Exception.class 那一格炸掉的原因:

85 / 140
原理动画
动图 · 异常被谁接住了(复盘)
动图 · 异常被谁接住了(复盘)
86 / 140
小节
11.1 内核控制台:把上面的结论自己敲一遍
87 / 140

实验点完了,接下来换成你敲命令。下面这台控制台是真容器,回显全部由浏览器里的 Java 内核算出来——beans 告诉你 Advice 到底有没有被装进容器,lab 调的就是上面那六个场景:

88 / 140
内核控制台
89 / 140

beans 里找不到 GlobalExceptionHandler,就说明它不在启动类的包下——后面所有处理器都不会生效,这一项比检查注解拼写更值得先做。第 3、4 条连着看,就是 400 与 500 白页的分水岭;最后两条让你亲眼看见同一句 return "error" 在 @Controller 与 @RestController 里分别变成视图名还是 JSON。

90 / 140
小节
十二、决策:校验文案该硬编码还是走国际化
91 / 140
决策
决策这是一个新起的国内项目,短期内没有海外用户,校验提示文案该硬编码中文,还是走 messages 国际化文件?
92 / 140
小节
十三、沙盘:这个错到底会返回什么
93 / 140

「标了注解 = 会被校验」是新手最大的错觉。下面三个开关一拖,右侧立刻给出真实结果——先把它玩熟,再去背规则:

94 / 140
沙盘
沙盘这行代码到底会不会拦住脏数据
运行结果
无匹配结果
95 / 140

读这张沙盘的正确姿势:先看「触发方式」这一栏。凡是它的值为 无,无论后面怎么选,校验都不会跑;再看「约束写在」,散装参数和嵌套对象是两个特殊分支;最后看「全局处理器」,它决定这次失败对前端来说是 400 还是一坨 500。

96 / 140
小节
十四、常见报错速查
97 / 140

新手最怕「日志太长看不懂」。下面每一段原文都能整句复制去搜索,不要意译——搜索引擎认全类名,不认你的描述。

98 / 140

先看最该学会读的那张成绩单。MethodArgumentNotValidException 抛出时,真正的信息全在 BindingResult 里,很多人却只盯着栈顶:

99 / 140
text
org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.example.api.Result com.example.user.UserController.create(com.example.user.UserCreateDTO): [Field error in object 'userCreateDTO' on field 'username': rejected value []; codes [NotBlank.userCreateDTO.username,NotBlank.username,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [...]]; default message [用户名不能为空]]
100 / 140

这段长文本的读法只要三条:① for argument [0] = 第 0 个方法参数出错,不是返回值;② on field 'username' 才是真正出错的字段名,直接映射到你 DTO 里的属性;③ rejected value [] 显示实际收到的值(这里是空串),default message [...] 就是你会返给前端的那句中文。取数据的固定写法是 e.getBindingResult().getFieldErrors(),每个 FieldError 有 getField() / getRejectedValue() / getDefaultMessage() 三件套。

101 / 140

最该学会「读栈」的是这一类:异常在启动或第一个请求就炸,栈很长,但凶手只有一行。点一遍,看谁只是路过、谁才是真凶:

102 / 140
报错急救
报错急救UnexpectedTypeException: HV000030
@NotBlank 标到了 Integer 上的报错现场

本地一切正常,改了 DTO 的一个字段类型后启动即失败,日志里刷出长长一段栈,前端只看到 500。

jakarta.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'jakarta.validation.constraints.NotBlank' validating type 'java.lang.Integer'. Check configuration for 'age'
at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintValidatorManager.createValidatorInstance(ConstraintValidatorManager.java:126)
at org.hibernate.validator.internal.engine.ConstraintTree.validateSingleConstraint(ConstraintTree.java:171)
at org.springframework.validation.BeanValidator.validateDataInternal(BeanValidator.java:199)
at com.example.user.UserController.create(UserController.java:38)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
103 / 140

调试台里那条「谁接手谁定状态码」的解析器链,就是要留在脑子里的模型:同一个异常变成有用的 400 还是没用的 500,只取决于它从哪条出口出去。这也正是「一个全局 Advice」胜过「每个方法各写 try-catch」的根本原因:

104 / 140
架构图
图 · 每个方法 try-catch vs 一个全局 Advice
图 · 每个方法 try-catch vs 一个全局 Advice
105 / 140
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
Validation failed for argument [0] ... [Field error in object 'xxx' on field 'username': rejected value []]@Valid @RequestBody 的对象校验没过,框架抛 MethodArgumentNotValidException这不是 bug,是你在等 400。确认 Advice 里有 @ExceptionHandler(MethodArgumentNotValidException.class),并用 getBindingResult().getFieldErrors() 取字段明细本节 + 第八节
约束注解明明写了,传空串却照样进数据库(无任何报错)参数上忘了 @Valid / @Validated,校验根本没被触发;注解只是标签,没人按下开关在 @RequestBody 前面补 @Valid;散装参数则把 @Validated 打在类上第二、四节
jakarta.validation.ConstraintViolationException: detail.arg1: 页码从 1 开始@Validated 打在类上校验方法参数失败,抛的是 ConstraintViolationException,不是 MethodArgumentNotValidExceptionAdvice 里两者都要写;取字段用 e.getConstraintViolations() 遍历 getPropertyPath()第四节
org.springframework.web.bind.MethodArgumentNotValidException ... 但前端收到 500只写了 @ExceptionHandler(Exception.class),校验异常在匹配阶段被兜底抢走为校验异常单独写处理器;兜底只留给「真意外」第十节
HV000030: No validator could be found for constraint 'jakarta.validation.constraints.NotBlank' validating type 'java.lang.Integer'约束用错了类型:@NotBlank 只支持 CharSequence,标到了 Integer 上数值改用 @NotNull / @Min;集合用 @NotEmpty第三节
javax/validation/ValidationException: no provider 或启动时报 Unable to create a Configuration, because no Bean Validation provider could be foundSpring Boot 2.3 起校验被移出 spring-boot-starter-web,没引 starter加依赖 spring-boot-starter-validation第二节
JSON parse error: Cannot deserialize value of type \java.lang.Integer\ from String "abc"请求体字段类型转不动,抛 HttpMessageNotReadableException,发生在校验之前单独给它写处理器返回 400,并提示「字段类型不对」;别指望校验注解来救第十一节 conv 实验
org.springframework.validation.BindException: Validation failed for bean [userDTO] ...(表单 / GET 查询串绑定时出现)非 JSON 的参数绑定走的是 BindException,而 Advice 里只覆盖了 @RequestBody 那条补一个 @ExceptionHandler(BindException.class),与第一条共用同一段字段组装逻辑第八节
浏览器打开接口地址,看到的是一张纯 HTML 错误页而不是 JSON,日志里没有任何你的业务日志没有任何 @ExceptionHandler 接管,请求被转交给 /error(BasicErrorController)渲染默认页写 @RestControllerAdvice 兜底;调试期临时开 server.error.include-message=always、server.error.include-stacktrace=always 看清原因,上线前务必关回去本节末 + 第十三节沙盘
MethodArgumentNotValidException 一切正常但分组校验突然不生效用了 @Validated(Create.class),而未写 groups 的约束属于 Default 组,被整体跳过给需要生效的约束显式补 groups,或让分组接口 extends Default第五节
自定义 @Phone 启动即报 HV000151: A method annotated with @ConstraintValidator#isValid ... 或 must define the following attributes: [message, groups, payload]注解缺少规范要求的三个固定成员,或校验器没实现 ConstraintValidator补齐 message() / groups() / payload();检查 @Constraint(validatedBy = ...) 是否指向你的校验器第六节
106 / 140
提示

搜报错时只搜冒号后第一段原文(例如 No validator could be found for constraint),命中率远高于搜整句——不同版本的 Spring 会在后半句加话。

107 / 140
小节
14.1 配置生成器:排查期该开的几行,一次勾全
108 / 140

上面表格里「白页」「500」「看不到 message」这三类问题,很多是靠配置先把现场看清的。但这些开关生产环境一律要关掉,所以别手抄,勾出来再复制:

109 / 140
生成器
生成器把排查期该开的配置一次配全application.yml2 / 3
先只勾「服务器」,看 server.error.* 那几行怎么把 message 和 trace 暴露给前端;再叠加「日志」把校验失败的堆栈级别调对;最后勾「环境 profile」把这几行只留在 dev 里,用对照第十二节沙盘的最后一格
产物
server:
  port: 8080
  servlet:
    encoding: { charset: UTF-8, enabled: true, force: true }
  compression: { enabled: true, min-response-size: 2048 }

spring:
  application:
    name: demo-service

logging:
  level:
    root: INFO
    com.example.demoservice: DEBUG
    org.springframework.jdbc.core.JdbcTemplate: DEBUG   # 打 SQL 与参数
  file:
    name: logs/app.log
  logback:
    rollingpolicy: { max-file-size: 50MB, max-history: 14 }
勾了这些,代价与理由在这里
serverserver.port 被命令行 --server.port=8081 覆盖,也吃 SERVER_PORT 环境变量。
logging级别可按包精细控制;logging.level.root=DEBUG 会把三方库全打爆,别在生产这么干。
110 / 140

勾完顺手对一遍:include-message / include-stacktrace 只在 dev 生效,生产靠的是你自己 Advice 里的 traceId——框架的话永远不该说给前端听。

111 / 140
小节
十五、随堂自测
112 / 140
随堂自测
随堂自测DTO 字段上写了 @NotBlank,但前端传空字符串依然顺利入库。最可能的原因是?
先自己选一个,选中立刻告诉你对不对
113 / 140
随堂自测
随堂自测一个 Advice 里只写了 @ExceptionHandler(Exception.class)。此时 @Valid 校验失败,客户端会拿到什么?
先自己选一个,选中立刻告诉你对不对
114 / 140
小节
十六、动手练习
115 / 140
小节
第一档 · 照做
116 / 140

从零搭一条「会返回字段错误的注册接口」,完整可跑。第一步补依赖(Boot 2.3 之后必做):

117 / 140
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-validation</artifactId></dependency>
118 / 140
java
package com.example.valid;import jakarta.validation.*;import jakarta.validation.constraints.*;public record SignUpDTO(        @NotBlank(message = "用户名不能为空")        @Size(max = 32, message = "用户名不能超过 32 个字符")        String username,        @NotBlank(message = "邮箱不能为空")        @Email(message = "邮箱格式不正确")        String email,        @Min(value = 18, message = "年龄不能小于 18 岁")        Integer age) {}
119 / 140
java
package com.example.valid;import jakarta.validation.ConstraintViolationException;import org.springframework.http.HttpStatus;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.*;import java.util.List;@RestControllerAdviceclass ApiErrors {    record FieldErr(String field, String message) {}    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(MethodArgumentNotValidException.class)    List<FieldErr> invalidBody(MethodArgumentNotValidException e) {        return e.getBindingResult().getFieldErrors().stream()                .map(fe -> new FieldErr(fe.getField(), fe.getDefaultMessage()))                .toList();    }    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(ConstraintViolationException.class)    List<String> invalidParam(ConstraintViolationException e) {        return e.getConstraintViolations().stream()                .map(v -> v.getPropertyPath() + ": " + v.getMessage())                .toList();    }}
120 / 140
代码对照
代码java
package com.example.valid;import jakarta.validation.Valid;import jakarta.validation.constraints.Positive;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.*;@Validated                      // ← 少了这一行,下面 @Positive 完全不会生效@RestController@RequestMapping("/api/signup")class SignUpController {    @PostMapping    String create(@Valid @RequestBody SignUpDTO dto) {        return "ok:" + dto.username();    }    @GetMapping("/{id}")    String detail(@PathVariable @Positive(message = "ID 必须为正数") Long id) {        return "detail:" + id;    }}
解读

说明:类上这行 @Validated 是 org.springframework.validation.annotation.Validated,不是 jakarta.validation.Valid——两个名字很像、包完全不同,写错一个字符编译就不过。先原样跑通,再按第二档把它删掉对比响应;这正是「注解是规则、开关是开关」最快的一次体感。

121 / 140

发一个正常请求与两个坏请求:

122 / 140
bash
curl -i -X POST http://localhost:8080/api/signup \     -H "Content-Type: application/json" \     -d '{"username":"","email":"not-an-email","age":17}'
123 / 140

预期响应(状态行 + 响应体逐字对齐):

124 / 140
text
HTTP/1.1 400Content-Type: application/json[{"field":"username","message":"用户名不能为空"}, {"field":"email","message":"邮箱格式不正确"}, {"field":"age","message":"年龄不能小于 18 岁"}]
125 / 140

控制台日志里不应该出现 ERROR 级别的堆栈——校验失败属于「预期的 4xx」,把它打成 error 只会淹没真正的故障。如果你看到的是 Whitelabel HTML 页面,说明 ApiErrors 没被扫描到(包名不在启动类之下)。

126 / 140
小节
第二档 · 变体
127 / 140

目标:只做三处单行改动,观察三种完全不同的失败形态。

128 / 140
  1. 删掉 SignUpController.create 参数上的 @Valid,其余一字不改,重发同一个 curl。你会观察到:HTTP 200、ok: 后面跟着空用户名,日志里连一行警告都没有——这就是「静默跳过」,比报错更可怕。
  2. 把 ApiErrors 里第一个处理器的参数类型从 MethodArgumentNotValidException 换成 Exception,恢复 @Valid,重发 curl。你会观察到:状态码变成 500,响应体变成 Boot 默认的 {timestamp,status,error,path},日志里多出一坨 ERROR 堆栈——兜底把校验异常抢走了。
  3. 给 detail 方法的类上 @Validated 也删掉,请求 /api/signup/-1。你会观察到:不再抛 ConstraintViolationException,而是干干净净返回 detail:-1——散装参数的校验只认类上的 @Validated,参数上的 @Positive 单打独斗毫无作用。
129 / 140
小节
第三档 · 造一个
130 / 140

做一个「下单接口」POST /api/orders,要求把本篇所有能力串起来:请求体包含收货地址(嵌套对象)与商品行列表(集合元素),新增与修改共用一个 DTO 但规则不同。

131 / 140

验收清单:

132 / 140
  • [ ] 嵌套 AddressDTO 与 List<OrderItemDTO> 的校验真的生效(构造一个非法元素,确认响应里出现 items[1].skuId 这样的带下标字段名)
  • [ ] create 走 ValidGroups.Create、update 走 ValidGroups.Update,且 @NotBlank 这类通用规则在两个分组下都生效(用 groups 或 extends Default 任选一种实现)
  • [ ] 至少一个自定义注解(如 @Phone 或 @ChinaMobile),并在非法输入时返回自定义文案
  • [ ] GlobalExceptionHandler 至少覆盖 5 类:MethodArgumentNotValidException、BindException、ConstraintViolationException、BizException、Exception 兜底
  • [ ] 兜底分支返回体里带 traceId,且能在日志里用同一串 traceId grep 到完整堆栈
  • [ ] 用 curl 打出 4 种状态码:200 / 400(校验)/ 409 或业务码(库存不足)/ 500(人为抛一个 RuntimeException)
  • [ ] 说清一件事:为什么校验失败的日志级别应该是 warn 而不是 error
133 / 140
小节
十七、要点自查
134 / 140
自检

能不能一口气说出「触发校验的三种写法」?——参数上的 @Valid(对象)、类上的 @Validated(散装参数)、@Validated(分组.class)(分组)。缺一个就说明你还没分清它们的分工。

135 / 140
自检

MethodArgumentNotValidException 和 ConstraintViolationException 分别由谁抛出?取字段错误的方法分别叫什么?(getBindingResult().getFieldErrors() vs getConstraintViolations())

136 / 140
自检

为什么只写 @ExceptionHandler(Exception.class) 会把校验失败变成 500?关键词必须是「按异常类型就近匹配」。

137 / 140
自检

@NotBlank / @NotEmpty / @NotNull 三者对 " "(纯空格)各是什么结果?只有哪个会拦下来?

138 / 140
自检

看到 Whitelabel Error Page,你能立刻说出它意味着「没有任何 HandlerExceptionResolver 接管」吗?下一步该去看哪两处代码?

139 / 140
口诀

注解是规则,@Valid 是开关;分组一指定,Default 就靠边;异常就近接,兜底别抢线;字段进 Result,状态码给对,traceId 留在日志里。

140 / 140
总结

校验的尽头不是「多写几个 @NotBlank」,而是把规则从业务代码里抽出来、挂到字段上,再让一条统一链路替所有接口兜住错误。JSR-303 注解负责表达规则,@Validated 负责触发(含分组与散装参数),自定义校验器负责业务特例,而 @RestControllerAdvice 负责把这一切翻译成结构与状态码都正确的响应。做到最后一步,「校验失败 500」就再也不会出现了。