大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
参数校验与异常处理
这一课你将能做到
- 用 Bean Validation 注解声明式校验入参,拦截非法数据并返回清晰的错误提示
- 写一个全局异常处理器,把校验失败、业务异常、未知异常统一转换成规范 JSON
- 建立业务错误码体系,让前端拿到结构一致的失败响应
为什么要学
上一课的创建接口,传 "title":"" 也能创建成功——脏数据进了库,前端展示乱成一团;而 Controller 里到处 if (title == null || title.isBlank()) 又会淹没业务逻辑。另外,一旦某个 Service 抛了未处理异常,接口直接甩出 500 白页堆栈,前端完全没法处理。
两件事各需要一个机制:校验负责「把不合法的请求挡在业务门口」,声明式注解一行顶十个 if;全局异常处理负责「所有失败出口收敛成统一格式」,业务代码里只管 throw,收尾交给处理器。这两个机制是接口「能用」到「能用得体面」的分水岭,也是本站后端接口规范的地基。
先建立图像
请求进入 Controller
↓ @Valid 触发校验(Hibernate Validator 执行)
├─ 校验失败 ──→ 抛 MethodArgumentNotValidException ─┐
↓ 校验通过 │
业务方法执行 │
├─ 业务不满足 ──→ throw BusinessException ──────────┤→ @RestControllerAdvice 统一捕获
↓ 业务成功 │
正常返回 │
├─ 未知异常 ──→ Exception ─────────────────────────┘
↓
统一 JSON:{code, message, errors?}
一句话:正常路径走业务,所有异常路径汇入 Advice 汇成一个出口。
| 名词 | 人话 |
|---|---|
| Bean Validation | Java 校验规范(jakarta.validation),定义 @NotBlank 等注解 |
| Hibernate Validator | 该规范的参考实现,Boot 引 starter-validation 即用 |
| @Valid | 触发校验的开关,标在要校验的参数上 |
| @RestControllerAdvice | 全局「异常翻译器」,所有 Controller 共享 |
跟着做
步骤 1:引入校验 starter
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
注意:Boot 2.3 起它不再是 starter-web 的一部分,必须单独引入,否则 @Valid 静默不生效。
步骤 2:给 DTO 加校验注解 + @Valid 触发
新建 DTO(改造上一课的 CreateRequest 为独立类并加校验):
package com.example.taskflow.dto;
import jakarta.validation.constraints.*;
public record TaskCreateRequest(
@NotBlank(message = "任务标题不能为空")
@Size(max = 50, message = "任务标题不能超过 50 字")
String title,
@NotNull(message = "优先级不能为空")
@Min(value = 1, message = "优先级最小为 1")
@Max(value = 5, message = "优先级最大为 5")
Integer priority,
@Pattern(regexp = "^(19|20)\\d{2}-\\d{2}-\\d{2}$", message = "截止日期格式应为 yyyy-MM-dd")
String deadline,
@Email(message = "通知邮箱格式不正确")
String notifyEmail
) {}
Controller 的参数前加 @Valid:
@PostMapping
public ResponseEntity<ApiResponse<Task>> create(@Valid @RequestBody TaskCreateRequest req) {
Task created = taskService.create(req.title(), req.priority());
return ResponseEntity.created(URI.create("/api/tasks/" + created.id()))
.body(ApiResponse.ok(created));
}
先故意发个非法请求看默认行为:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" \
-d '{"title":"","priority":99}'
返回 400 和一段冗长的默认 JSON,errors 数组里能看到各字段错误,但结构和你的统一响应不一致——这就是要接 Advice 的原因。
步骤 3:先定义业务异常和错误码
package com.example.taskflow.exception;
public enum ErrorCode {
TASK_NOT_FOUND(40401, "任务不存在"),
TASK_STATUS_INVALID(40001, "任务状态不合法"),
SYSTEM_ERROR(50000, "系统繁忙,请稍后重试");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int code() { return code; }
public String message() { return message; }
}
package com.example.taskflow.exception;
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.message());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() { return errorCode; }
}
业务代码里从此只写一句话:
public Task requireById(long id) {
return findById(id).orElseThrow(() -> new BusinessException(ErrorCode.TASK_NOT_FOUND));
}
继承 RuntimeException(非受检):业务异常不该逼着调用方到处 try-catch,统一交给 Advice。
步骤 4:全局异常处理器(本课核心)
package com.example.taskflow.exception;
import com.example.taskflow.common.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/** ① @Valid 校验失败(@RequestBody 场景) */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(
MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
for (FieldError fe : e.getBindingResult().getFieldErrors()) {
errors.putIfAbsent(fe.getField(), fe.getDefaultMessage());
}
return ResponseEntity.badRequest()
.body(new ApiResponse<>(40000, "参数校验失败", errors));
}
/** ② 业务异常:预期内,WARN 级别即可 */
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
log.warn("业务异常: {}", e.getErrorCode().message());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(e.getErrorCode().code(), e.getErrorCode().message()));
}
/** ③ 路径/查询参数类型转换失败(如 /api/tasks/abc) */
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(
MethodArgumentTypeMismatchException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(40002, "参数类型错误: " + e.getName()));
}
/** ④ 兜底:未知异常 500,详细栈只进日志不进响应 */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnknown(Exception e) {
log.error("未处理异常", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(ErrorCode.SYSTEM_ERROR.code(),
ErrorCode.SYSTEM_ERROR.message()));
}
}
重新发送非法请求,返回变成:
{
"code": 40000,
"message": "参数校验失败",
"data": {
"title": "任务标题不能为空",
"priority": "优先级最大为 5"
}
}
请求不存在的任务:{"code":40401,"message":"任务不存在","data":null}。前端从此只需要一套解析逻辑。
步骤 5:@RequestParam / @PathVariable 的校验(方法级)
@Valid 只对 @RequestBody 生效。校验散装参数要在类上标 @Validated,参数上写约束注解:
@RestController
@Validated // 开启方法级校验
@RequestMapping("/api/tasks")
public class TaskController {
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Task>> detail(
@PathVariable @Min(value = 1, message = "任务 ID 必须为正整数") long id) {
return ResponseEntity.ok(ApiResponse.ok(taskService.requireById(id)));
}
@GetMapping
public ResponseEntity<ApiResponse<List<Task>>> list(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "10") @Max(100) int size) {
return ResponseEntity.ok(ApiResponse.ok(taskService.list(null)));
}
}
这类校验失败抛的是 ConstraintViolationException,补一个处理分支:
/** ⑤ 方法级校验失败(@PathVariable/@RequestParam 场景) */
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraint(ConstraintViolationException e) {
String msg = e.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.findFirst().orElse("参数不合法");
return ResponseEntity.badRequest().body(ApiResponse.error(40000, msg));
}
步骤 6:验证清单
# 1. 空标题 + 超范围优先级 → 40000,errors 里两个字段提示
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"","priority":99}'
# 2. 邮箱格式错 → notifyEmail 的提示
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"学习校验","priority":1,"notifyEmail":"abc"}'
# 3. 类型错误 → 40002
curl http://localhost:8080/api/tasks/abc
# 4. 不存在 → 40401
curl http://localhost:8080/api/tasks/999
四种情况全部返回结构一致的 JSON,本课完成。
讲清楚要点
- 校验声明式优于命令式:约束写在 DTO 字段上(是什么),而不是 Controller 里写 if(怎么做);DTO 可复用于多个接口,约束只写一次。
- 两类校验入口对应两类异常:@RequestBody + @Valid →
MethodArgumentNotValidException;@Validated + 散装参数 →ConstraintViolationException。Advice 里两个分支都要写,漏一个就会出现默认 400 白页。 - Advice 的匹配规则是「最具体优先」:多个 @ExceptionHandler 都能处理时,Spring 选继承树上最精确的那个;因此 Business/Validation 的专用分支不会被子类 Exception 分支抢占。
- 异常响应的边界:业务异常消息可以直接给用户;未知异常只回「系统繁忙」,完整堆栈进日志——把内部细节(SQL、类名、路径)暴露给外网是安全事故。
- 错误码分层:40000 段参数错、40100 段未认证、40400 段资源不存在、50000 段系统错。前端按段做统一处理(401 跳登录、500 提示稍后重试)。
你可能会懵的地方
-
@Valid 和 @Validated 有什么区别? @Valid 是 jakarta 标准注解,标在参数/字段上触发级联校验;@Validated 是 Spring 的增强版,标在类上启用方法级校验,并支持分组(groups)。规则记忆:校验 body 用 @Valid,校验散装参数先给类加 @Validated。
-
为什么 DTO 用 Integer 而不是 int 接收 priority? int 基本类型有默认值 0,「没传」和「传了 0」无法区分,@NotNull 也永远不触发(拆箱后已经不是 null)。可空语义用包装类型。
-
校验能自定义规则吗? 能。实现
ConstraintValidator<自定义注解, 字段类型>,典型场景是「多字段联合校验」「依赖数据库的查重校验」。先检查内置注解(@Pattern 正则能覆盖大半场景)不够再自定义。 -
Service 层抛异常合适吗?Controller 里 try-catch 不好吗? Service 抛业务异常 + Advice 统一转换是标准姿势。Controller 里 try-catch 会让每个接口复制粘贴一遍处理逻辑,而且 Service 被别的入口(定时任务、MQ)调用时就没人兜底了。
-
为什么校验失败返回 400 而业务异常也返回 400,前端怎么区分? 用 body 里的业务 code 区分:40000 是「参数格式错」,40001/40401 是「业务规则不满足」。HTTP 状态码粗分类,业务码细处理,这正是统一响应结构存在的意义。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 只加校验注解,参数前没写 @Valid | 校验完全不触发 | @Valid @RequestBody 成对出现 |
| 忘引 starter-validation | 注解在但静默失效,最隐蔽的坑 | 引入 spring-boot-starter-validation |
| @NotBlank 标在 Integer 上 | 运行期报错:仅支持字符串 | 数值用 @NotNull,字符串用 @NotBlank |
| 兜底 Exception 处理器把 e.getMessage() 返回给前端 | 泄露内部实现细节 | 未知异常统一话术,堆栈只进日志 |
| Advice 里 catch 所有异常不打日志 | 线上问题无迹可寻 | 分级打日志:WARN 业务、ERROR 未知 |
卡点急救
- 加了 @Valid 却不校验:三查——starter-validation 引了没;@Valid 写在 @RequestBody 前面没;DTO 字段上的注解 import 的是
jakarta.validation.constraints.*而不是旧的 javax 包。 - 返回的是 Spring 默认 400 JSON 而不是统一格式:异常类型没被 Advice 接住。看响应里的异常提示确认类型(常见漏网:ConstraintViolationException、HttpMessageNotReadableException),补对应 @ExceptionHandler 分支。
- @ExceptionHandler 方法不生效:确认类上有 @RestControllerAdvice 且包在扫描范围内;确认抛出的异常类型与处理方法参数类型匹配(父子类关系也算)。
HttpMessageNotReadableException(请求体解析失败):JSON 格式错或字段类型不匹配,属于 400 范畴,可加分支返回友好提示。
面试常问
-
@Valid 和 @Validated 的区别? @Valid:JSR 标准,支持嵌套对象级联校验(子对象字段也要校验时,字段上标 @Valid);@Validated:Spring 提供,支持分组校验与类级方法参数校验。组合使用场景要能举例:新增和修改复用一个 DTO 但规则不同时用分组。
-
全局异常处理的原理? @RestControllerAdvice = @ControllerAdvice + @ResponseBody;Spring MVC 的 HandlerExceptionResolver 在 Controller 抛异常后,遍历所有 ControllerAdvice 找最匹配的 @ExceptionHandler 方法执行,返回值照常走消息转换器输出 JSON。
-
怎么设计统一的错误响应? 固定结构 code/message/data;错误码分段(参数/认证/权限/资源/系统);正常与失败同构;未知异常脱敏。能结合自己项目画出分段表是加分项。
-
校验注解失效的常见原因? 没引 validation starter、@Valid 漏标、字段注解用错类型(@NotBlank 用于非字符串)、方法级校验忘了类上 @Validated、旧 javax 包与新 jakarta 包混引。
自测三问
- @RequestBody 校验失败和 @PathVariable 校验失败分别抛什么异常?
- 为什么业务异常继承 RuntimeException 而不是 Exception?
- 未知异常的响应为什么不返回 e.getMessage()?日志应该怎么打?
小结一句话
校验注解把非法请求挡在业务之前,全局异常处理器把所有失败出口收敛成统一 JSON——「正常路径干净、异常路径完整」的接口才算生产级。
延伸
- Spring 官方文档:Validation
- Hibernate Validator 校验注解清单
- 下一课:JDBC 与 MyBatis——把内存 Map 换成真正的数据库。