CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Spring Boot 实战入门/课时/参数校验与异常处理
RawBlame
课时目录16 节

Spring Boot 实战入门

  1. 01Spring Boot 是什么
  2. 02Starter 与自动配置
  3. 03配置与 Profile
  4. 04IoC 与依赖注入
  5. 05Bean 作用域与生命周期
  6. 06Spring AOP 入门
  7. 07Spring MVC 与 REST
  8. 08参数校验与异常处理
  9. 09JDBC 与 MyBatis
  10. 10Spring Data JPA 入门
  11. 11事务管理
  12. 12Spring Security 基础
  13. 13JWT 与 API 鉴权
  14. 14单元测试与 MockMvc
  15. 15Actuator 与健康检查
  16. 16综合练习与进阶路线

参数校验与异常处理

@Valid、全局 @ControllerAdvice、业务错误码。

30 分钟

参数校验与异常处理

这一课你将能做到

  1. 用 Bean Validation 注解声明式校验入参,拦截非法数据并返回清晰的错误提示
  2. 写一个全局异常处理器,把校验失败、业务异常、未知异常统一转换成规范 JSON
  3. 建立业务错误码体系,让前端拿到结构一致的失败响应

为什么要学

上一课的创建接口,传 "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 ValidationJava 校验规范(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,本课完成。

讲清楚要点

  1. 校验声明式优于命令式:约束写在 DTO 字段上(是什么),而不是 Controller 里写 if(怎么做);DTO 可复用于多个接口,约束只写一次。
  2. 两类校验入口对应两类异常:@RequestBody + @Valid → MethodArgumentNotValidException;@Validated + 散装参数 → ConstraintViolationException。Advice 里两个分支都要写,漏一个就会出现默认 400 白页。
  3. Advice 的匹配规则是「最具体优先」:多个 @ExceptionHandler 都能处理时,Spring 选继承树上最精确的那个;因此 Business/Validation 的专用分支不会被子类 Exception 分支抢占。
  4. 异常响应的边界:业务异常消息可以直接给用户;未知异常只回「系统繁忙」,完整堆栈进日志——把内部细节(SQL、类名、路径)暴露给外网是安全事故。
  5. 错误码分层: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 未知

卡点急救

  1. 加了 @Valid 却不校验:三查——starter-validation 引了没;@Valid 写在 @RequestBody 前面没;DTO 字段上的注解 import 的是 jakarta.validation.constraints.* 而不是旧的 javax 包。
  2. 返回的是 Spring 默认 400 JSON 而不是统一格式:异常类型没被 Advice 接住。看响应里的异常提示确认类型(常见漏网:ConstraintViolationException、HttpMessageNotReadableException),补对应 @ExceptionHandler 分支。
  3. @ExceptionHandler 方法不生效:确认类上有 @RestControllerAdvice 且包在扫描范围内;确认抛出的异常类型与处理方法参数类型匹配(父子类关系也算)。
  4. 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 包混引。

自测三问

  1. @RequestBody 校验失败和 @PathVariable 校验失败分别抛什么异常?
  2. 为什么业务异常继承 RuntimeException 而不是 Exception?
  3. 未知异常的响应为什么不返回 e.getMessage()?日志应该怎么打?

小结一句话

校验注解把非法请求挡在业务之前,全局异常处理器把所有失败出口收敛成统一 JSON——「正常路径干净、异常路径完整」的接口才算生产级。

延伸

  • Spring 官方文档:Validation
  • Hibernate Validator 校验注解清单
  • 下一课:JDBC 与 MyBatis——把内存 Map 换成真正的数据库。
加载本节练习…
← 上一课Spring MVC 与 REST下一课 →JDBC 与 MyBatis