参数校验与全局异常处理体系
先说人话。参数校验就是「在动手做事之前,先检查对方给的东西合不合格」:用户名是不是空的、邮箱有没有 @、年龄是不是 18 岁以上。异常处理就是「事情做砸了之后,用什么姿势告诉外面到底砸在哪」。这两件事加起来占了一个后端接口一半的代码量——但它们都不该写在业务逻辑里,而应该写成规则(挂在字段上)和兜底(集中在一处)。
第一次见这几个词,一句话解释清楚:
- 注解(annotation):写在代码上的「标签」,以
@开头,本身不干活,由框架在运行时读取并据此做事,例如@NotBlank只是个标签,真正去检查的是 Hibernate Validator - DTO:Data Transfer Object,专门用来「接收前端传来的那堆字段」的类。它和数据库实体分开,因为前端能改的东西比数据库少得多
BindingResult:一次校验的「成绩单」对象,里面记着哪个字段错、错在哪句提示@RestControllerAdvice:一个「全局兜底组件」,专门捕获所有 Controller 抛出的异常并翻译成统一响应- HTTP 状态码:服务器对这次请求的一句话结论,
200成功、400你的参数有问题、401没登录、500我这边炸了
参数校验就是机场安检。你买票(发请求)时系统并不拦你;等你走到登机口(进入 Controller 之前),安检员才按一条条固定规则过一遍 X 光机:液体超量(@Size)、证件无效(@Pattern)、行李里带了违禁品(@NotBlank 没过)。规则写在「安检手册」上而不是写在每个空乘嘴里,所以任何航班的结果都一样:不合格就不放行,并且明确告诉你是哪一件行李、违反了哪一条。忘贴 @Valid 相当于这道口根本没开机器。
异常处理就是医院分诊台。病人(异常)从各个科室跑出来,护士不会自己开药——她只做三件事:认出这是什么伤(按异常类型匹配处理器)、决定去哪个科(映射成对应的 HTTP 状态码)、登记病历号(打日志 + 生成 traceId)。最危险的不是没人管,而是门口挂了一块「所有病症一律开止痛药」的牌子——那就是只写了 @ExceptionHandler(Exception.class) 的兜底:骨折和发烧都返回 500,真正的病因被吞掉了。

上图四个盒子就是本篇的四类问题:规则怎么写(第三、六节)、什么时候触发(第二、四、五节)、异常谁来接(第八节)、响应长什么样(第九、十四节)。
学完这一篇,你应该能回答三个问题:
- 同样一个「不能为空」,为什么写在
if里要被抄一百遍,写成@NotBlank只用写一次? - 我的 DTO 里明明标了
@NotBlank,为什么传空字符串却照样进了数据库?(答案九成是@Valid忘写了) - 一个
BizException从 Service 抛出后,凭什么最后变成409而不是一张 500 白页?中间谁接住了它?
先看一段几乎每个人都写过的代码——「判空地狱」:
@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));}这段代码有四个问题:校验逻辑和业务逻辑纠缠、每个接口都要抄一遍、错误结构不统一、Map 接参丢掉了类型。注解化改造后:
@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) {}同样的规则,写在了字段旁边,语义自解释、可复用、可被工具扫描。业务方法里彻底没有了 if——这就是校验框架存在的意义。
从 Spring Boot 2.3 起,校验能力被从 spring-boot-starter-web 中剥离,必须显式引入:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId></dependency>它依赖 Hibernate Validator 实现了 JSR-303(Bean Validation)。两个注解长得像,职责却不同:
| 维度 | @Valid | @Validated |
|---|---|---|
| 来源 | JSR-303 标准(jakarta.validation) | Spring 自有(org.springframework.validation.annotation) |
| 分组校验 | 不支持 | 支持 groups 属性 |
| 可标注位置 | 方法参数、字段、方法 | 类、方法、参数 |
| 可否校验方法参数 | 否 | 是(打在类上,校验 @RequestParam 等) |
| 级联校验 | 支持(嵌套对象字段加 @Valid) | 支持 |
一句话记:对象整体校验用 @Valid,需要分组或校验散装参数用 @Validated。
两个注解之外,还要知道「开关到底按在哪一格」。下面这条链可以一格一格点,点第 ④ 格会看到:校验不是写在业务里的判断,而是参数解析完之后由框架统一跑的一轮。
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 一个都不生效,请求照样通过。这是级联校验最经典的漏网场景。
约束注解有二十多个,但新手真正要分清的是「标了它到底拦得住什么」。别背表格——来玩一局闯关:左边是注解,右边点它真实的拦截范围,配错了当场告诉你为什么。
@NotNull / @NotEmpty / @NotBlank 三兄弟最容易选错。校验「姓名字段不能是空字符串」必须用 @NotBlank;用 @NotNull 时前端传 ""(空串)或 " "(空格)都会通过,然后一路流进数据库。
@RequestParam、@PathVariable 这类「散装参数」不能直接写 @Valid,必须把 @Validated 标在类上:
@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); }}此时校验失败抛出的不是 MethodArgumentNotValidException,而是 ConstraintViolationException——两者的处理方式不同,全局异常处理器必须同时覆盖,否则就会掉到 500 兜底里。
同一个 DTO,新增时 id 必须为空(由数据库生成),更新时 id 必须非空。用分组就能用一套 DTO 表达两套规则:
// 1) 定义两个空接口作为分组标记public interface ValidGroups { interface Create {} interface Update {}}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) {}@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 的约束就会被跳过——这是分组校验最常见的「为什么我的校验突然不生效」的答案。

把动图第五步记住:补救只有两条路,给约束补 groups,或者让分组接口 extends Default——第二种能让「未标 groups」的通用规则继续生效,改动最小。
内置注解覆盖不了业务规则时,就自己写一个。以手机号为例,它由注解与校验器两部分组成:
@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;}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(); }}public record ProfileUpdateDTO( @NotBlank String nickname, @Phone(required = true, message = "请填写有效的手机号") String mobile) {}三个方法各有分工:initialize 读注解属性、isValid 做判断、message/groups/payload 是规范要求的固定成员(缺了框架会启动报错)。自定义校验器必须线程安全——Pattern 编译一次、复用一个实例即可。
硬编码中文只能服务一种语言。把消息抽到资源文件,多语言就同时具备:
# 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=手机号格式不正确指定消息源与编码:
spring: messages: basename: messages encoding: UTF-8带国际化时,约束注解里的 message 写占位键而非硬编码文案,例如 @NotBlank(message = "{NotBlank}"),运行期由 MessageSource 按请求的 Locale 解析。
到目前为止,所有校验失败抛出的都是异常。如果不统一处理,默认会返回 Spring 自带的错误页或一坨堆栈——前端拿到的是 500 或 HTML,而不是结构化的「哪个字段错了」。
@RestControllerAdvice + @ExceptionHandler 是唯一的正解。下面是一份可直接复制的完整实现:
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) {}}关键点:
- 处理器方法与异常类型就近匹配——
BizException的处理方法比兜底的Exception更具体,所以业务异常一定先被它接走 - 每个处理器都用
@ResponseStatus或ResponseEntity明确 HTTP 状态码,而不是一律 200 - 兜底
Exception必须放最后思考:它的职责是「不漏异常」,而不是「吞掉异常」——log.error打全堆栈,只把 traceId 返回给用户
统一的错误响应应该同时具备三个要素:机器可读的错误码、人可读的消息、字段级明细。校验失败的响应示例:
{ "code": 40000, "message": "参数校验失败", "data": [ { "field": "username", "message": "用户名不能超过 32 个字符" }, { "field": "email", "message": "邮箱格式不正确" }, { "field": "address.city", "message": "城市不能为空" } ]}code:业务错误码,前端可据此做特定处理(如40100跳登录页)message:面向用户的兜底文案data:校验失败时是字段错误列表,成功时是业务数据。用T类型承载,结构不额外分叉- 系统错误时额外带
traceId:用户在反馈里报这串码,你就能在日志里精确定位这一次请求

上面那串 data 里的字段列表不是凭空冒出来的,它就是动图第 ④ 步那张「成绩单」被展开的样子。想看它是怎么一页页写出来、又是怎么被读出来的,照着调试台走一遍——同一个 MethodArgumentNotValidException,从抛出到写出 400,中间只经过一条解析器链:
create(dto); // 业务方法体一行都没执行// DispatcherServlet.doDispatch(...) 捕获到刚抛出的异常processDispatchResult(request, response, mv, exception);// 遍历 handlerExceptionResolvers,顺序由容器装配决定mv = resolver.resolveException(request, response, handler, exception);// ExceptionHandlerExceptionResolver: 找 @ExceptionHandler 方法mapped = findMethod(exception); // 按异常类型就近匹配return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);| 异常类型 | MethodArgumentNotValidException |
| 出错参数 | argument [0] |
| 业务方法是否执行 | 没有 |

@Valid加在基本类型参数上无效:@Valid @RequestParam String name不会触发任何校验——@Valid只负责「开始校验一个对象/级联」,它不能给字符串加约束。散装参数要用@Validated标在类上,参数处直接写@NotBlank- 校验异常被兜底「吞掉」:如果你只写了
@ExceptionHandler(Exception.class),MethodArgumentNotValidException会在匹配阶段就被它接走(因为它也是Exception),结果校验失败返回的是 500。要么为校验异常写专门的处理器,要么让兜底只处理意外异常——顺序不是代码书写顺序,而是「谁更具体谁优先」
图 1 给出了完整的链路视角:

看十遍不如点一次。下面六个内核实验请按顺序跑完,每个约 30 秒,它们正好对应校验与异常的六个现场。
先搞清「我的参数是怎么被填进方法里的」。@PathVariable / @RequestParam / @RequestBody 走的是三条不同的解析器路径,而校验发生在解析成功之后。依次点五个参数,特别注意最后一个「没有解析器接手」——那就是你项目里那句报错的源头:
再往下看响应是怎么写出去的。校验失败要变成 JSON、406 要找消息转换器、@Validated 打在类上时抛的还是另一种异常——这三件事都由这条链决定:
这是本篇的主场:异常解析器链。五个参数对应五种命运——校验失败 400、类型转换失败、被你自己的 @ExceptionHandler 接走、被 @ControllerAdvice 兜住、以及最坏的一种「没人接管 → 500」。请把 none 那一档跑完,它会打印出白页响应的真实来源:
最后把镜头拉远,看一个请求穿过全站时校验与异常落在哪一站。切到 valid(参数校验失败)与 db(数据库故障)对比:同样是失败,一个停在入口、一个停在最底层,但出口都是同一个全局 Advice:
还有一个坑只有亲手点才会痛:@Controller 与 @RestController 少写一个 @ResponseBody,你 return 的那个字符串就被当成视图名去找模板,浏览器收到的是一张白页而不是 JSON。错误处理写对了、出口的「内容形态」却错了,是同一个量级的事故。四档都点一遍,尤其是 missing:
六个实验跑完,再回看一遍开头那张动画,此时每一步都应该能对号入座——第 3 步的 ExceptionHandlerExceptionResolver 就是你刚点的 err,第 4 步的「就近匹配」就是沙盘里 只有Exception.class 那一格炸掉的原因:

实验点完了,接下来换成你敲命令。下面这台控制台是真容器,回显全部由浏览器里的 Java 内核算出来——beans 告诉你 Advice 到底有没有被装进容器,lab 调的就是上面那六个场景:
beans 里找不到 GlobalExceptionHandler,就说明它不在启动类的包下——后面所有处理器都不会生效,这一项比检查注解拼写更值得先做。第 3、4 条连着看,就是 400 与 500 白页的分水岭;最后两条让你亲眼看见同一句 return "error" 在 @Controller 与 @RestController 里分别变成视图名还是 JSON。
「标了注解 = 会被校验」是新手最大的错觉。下面三个开关一拖,右侧立刻给出真实结果——先把它玩熟,再去背规则:
无匹配结果
读这张沙盘的正确姿势:先看「触发方式」这一栏。凡是它的值为 无,无论后面怎么选,校验都不会跑;再看「约束写在」,散装参数和嵌套对象是两个特殊分支;最后看「全局处理器」,它决定这次失败对前端来说是 400 还是一坨 500。
新手最怕「日志太长看不懂」。下面每一段原文都能整句复制去搜索,不要意译——搜索引擎认全类名,不认你的描述。
先看最该学会读的那张成绩单。MethodArgumentNotValidException 抛出时,真正的信息全在 BindingResult 里,很多人却只盯着栈顶:
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 [用户名不能为空]]这段长文本的读法只要三条:① for argument [0] = 第 0 个方法参数出错,不是返回值;② on field 'username' 才是真正出错的字段名,直接映射到你 DTO 里的属性;③ rejected value [] 显示实际收到的值(这里是空串),default message [...] 就是你会返给前端的那句中文。取数据的固定写法是 e.getBindingResult().getFieldErrors(),每个 FieldError 有 getField() / getRejectedValue() / getDefaultMessage() 三件套。
最该学会「读栈」的是这一类:异常在启动或第一个请求就炸,栈很长,但凶手只有一行。点一遍,看谁只是路过、谁才是真凶:
本地一切正常,改了 DTO 的一个字段类型后启动即失败,日志里刷出长长一段栈,前端只看到 500。
调试台里那条「谁接手谁定状态码」的解析器链,就是要留在脑子里的模型:同一个异常变成有用的 400 还是没用的 500,只取决于它从哪条出口出去。这也正是「一个全局 Advice」胜过「每个方法各写 try-catch」的根本原因:

| 报错原文(片段) | 真实原因 | 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,不是 MethodArgumentNotValidException | Advice 里两者都要写;取字段用 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 found | Spring 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 = ...) 是否指向你的校验器 | 第六节 |
搜报错时只搜冒号后第一段原文(例如 No validator could be found for constraint),命中率远高于搜整句——不同版本的 Spring 会在后半句加话。
上面表格里「白页」「500」「看不到 message」这三类问题,很多是靠配置先把现场看清的。但这些开关生产环境一律要关掉,所以别手抄,勾出来再复制:
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 }
勾完顺手对一遍:include-message / include-stacktrace 只在 dev 生效,生产靠的是你自己 Advice 里的 traceId——框架的话永远不该说给前端听。
从零搭一条「会返回字段错误的注册接口」,完整可跑。第一步补依赖(Boot 2.3 之后必做):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId></dependency>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) {}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(); }}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——两个名字很像、包完全不同,写错一个字符编译就不过。先原样跑通,再按第二档把它删掉对比响应;这正是「注解是规则、开关是开关」最快的一次体感。
发一个正常请求与两个坏请求:
curl -i -X POST http://localhost:8080/api/signup \ -H "Content-Type: application/json" \ -d '{"username":"","email":"not-an-email","age":17}'预期响应(状态行 + 响应体逐字对齐):
HTTP/1.1 400Content-Type: application/json[{"field":"username","message":"用户名不能为空"}, {"field":"email","message":"邮箱格式不正确"}, {"field":"age","message":"年龄不能小于 18 岁"}]控制台日志里不应该出现 ERROR 级别的堆栈——校验失败属于「预期的 4xx」,把它打成 error 只会淹没真正的故障。如果你看到的是 Whitelabel HTML 页面,说明 ApiErrors 没被扫描到(包名不在启动类之下)。
目标:只做三处单行改动,观察三种完全不同的失败形态。
- 删掉
SignUpController.create参数上的@Valid,其余一字不改,重发同一个 curl。你会观察到:HTTP 200、ok:后面跟着空用户名,日志里连一行警告都没有——这就是「静默跳过」,比报错更可怕。 - 把
ApiErrors里第一个处理器的参数类型从MethodArgumentNotValidException换成Exception,恢复@Valid,重发 curl。你会观察到:状态码变成 500,响应体变成 Boot 默认的{timestamp,status,error,path},日志里多出一坨ERROR堆栈——兜底把校验异常抢走了。 - 给
detail方法的类上@Validated也删掉,请求/api/signup/-1。你会观察到:不再抛ConstraintViolationException,而是干干净净返回detail:-1——散装参数的校验只认类上的@Validated,参数上的@Positive单打独斗毫无作用。
做一个「下单接口」POST /api/orders,要求把本篇所有能力串起来:请求体包含收货地址(嵌套对象)与商品行列表(集合元素),新增与修改共用一个 DTO 但规则不同。
验收清单:
- [ ] 嵌套
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,且能在日志里用同一串traceIdgrep 到完整堆栈 - [ ] 用 curl 打出 4 种状态码:
200/400(校验)/409或业务码(库存不足)/500(人为抛一个 RuntimeException) - [ ] 说清一件事:为什么校验失败的日志级别应该是
warn而不是error
能不能一口气说出「触发校验的三种写法」?——参数上的 @Valid(对象)、类上的 @Validated(散装参数)、@Validated(分组.class)(分组)。缺一个就说明你还没分清它们的分工。
MethodArgumentNotValidException 和 ConstraintViolationException 分别由谁抛出?取字段错误的方法分别叫什么?(getBindingResult().getFieldErrors() vs getConstraintViolations())
为什么只写 @ExceptionHandler(Exception.class) 会把校验失败变成 500?关键词必须是「按异常类型就近匹配」。
@NotBlank / @NotEmpty / @NotNull 三者对 " "(纯空格)各是什么结果?只有哪个会拦下来?
看到 Whitelabel Error Page,你能立刻说出它意味着「没有任何 HandlerExceptionResolver 接管」吗?下一步该去看哪两处代码?
注解是规则,@Valid 是开关;分组一指定,Default 就靠边;异常就近接,兜底别抢线;字段进 Result,状态码给对,traceId 留在日志里。
校验的尽头不是「多写几个 @NotBlank」,而是把规则从业务代码里抽出来、挂到字段上,再让一条统一链路替所有接口兜住错误。JSR-303 注解负责表达规则,@Validated 负责触发(含分组与散装参数),自定义校验器负责业务特例,而 @RestControllerAdvice 负责把这一切翻译成结构与状态码都正确的响应。做到最后一步,「校验失败 500」就再也不会出现了。