CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
参数校验与异常处理
这一课你将能做到
用 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
) {}
@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
讲清楚要点
校验声明式优于命令式 :约束写在 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——「正常路径干净、异常路径完整」的接口才算生产级。
延伸