返回文章列表

Java 后端

参数校验与全局异常处理

如何进行参数校验与全局异常处理

2026 年 05 月 07 日178869published

参数校验与全局异常处理

一、为什么要做参数校验与全局异常处理

首先要明白:本文所说的参数校验是基于spring-boot-starter-validation引入的注解校验,相对于传统的 if-else 判空、校验数值,仅用一个注解就能免去许多烦恼;而做全局异常处理,核心是为了让后端出错时也有统一、可控、好理解的返回。比如说,接口正常成功时返回 Result.success(...),失败时也应该统一返回类似:

{  
    "code": 400,  
    "message": "文章标题不能为空",  
    "data": null 
}

而不是有的接口返回字符串,有的返回 Spring 默认错误 JSON,有的甚至返回 HTML 错误页。同时,如果不做全局异常处理,每个 Controller 都要写 try-catch

try {
    ...
} catch (Exception e) {
    return Result.failed(e.getMessage());
}

这样代码会非常啰嗦,而且容易漏处理。如果有全局异常处理,只需要在业务层直接:

throw new BusinessException("文章不存在"); 

然后全局异常处理器统一转换成前端能识别的错误响应。


二、参数校验:分层落地

参数校验主要靠 Spring Validation + DTO 注解 + Controller 的 @Valid 自动完成。

整理流程如下:

前端传参
  -> Spring 把 JSON/query/form 绑定成 DTO
  -> 看到 Controller 参数上有 @Valid
  -> 按 DTO 字段上的校验注解检查
  -> 校验失败时抛异常
  -> GlobalExceptionHandler 统一返回错误结果

首先,DTO 里会绑定各种注解,比如分页查询对象:

@Data
public class PageQuery {

    @Min(value = 1, message = "页码不能小于 1")
    private Long pageNum = 1L;

    @Min(value = 1, message = "每页数量不能小于 1")
    @Max(value = 100, message = "每页数量不能超过 100")
    private Long pageSize = 10L;
}

再比如文章保存请求:

@Data
public class ArticleSaveRequest {

    @NotBlank(message = "文章标题不能为空")
    private String title;

    @NotBlank(message = "文章 slug 不能为空")
    private String slug;

    @NotBlank(message = "文章内容不能为空")
    private String contentMd;

    @NotBlank(message = "文章状态不能为空")
    private String status;
    
    xxxx xxxx xxxx;
}

可以看到,通过spring-boot-starter-validation引入的注解@Max@Min@NotBlank等,将参数规范绑定在了 DTO,这样当前端传参被 Spring 校验时就可以知道是否符合要求了。这里注解中的message是自定义消息,它负责当参数校验失败时返回你指定的信息,比如说@NotBlank(message = "文章内容不能为空"):如果这个字段不满足 NotBlank 规则,就把错误提示设置为 "文章内容不能为空",方便前端直接展示。

Controller 层传参有两种方法:一种是前端以 JSON 格式传参,这时候需要加@RequestBody注解,负责将 JSON 请求体转换为 DTO 对象1;还有一种是 url 参数,这时候就不用加@RequestBody,Spring 会自动将参数绑定到 DTO 上。

然后,在 Controller 里用 @Valid 或者 @Validated 注解触发参数校验:

@Operation(summary = "分页获取已发布文章")
@GetMapping
public Result<PageResult<ArticleListVO>> page(@Valid ArticlePublicQueryRequest request) {
    return Result.success(articleService.getPublicPage(request));
}

这里要注意, Controller 传参必须要加@Valid 或者 @Validated,否则参数校验根本不会进行!


三、全局异常处理器

异常类

我们知道,异常是导致程序的正常流程被中断的事件,当我们的代码在校验业务逻辑时遇到了不符合要求的情况(比如某个字段为空,某项数值超出规范),此时就不能走正常的返回,应该给前端一个明确的错误提示;通过我们自定义的异常,很轻松就能做到。

要自定义异常,我们首先要继承RuntimeException类,如下:

public class BusinessException extends RuntimeException {

    public BusinessException(String message) {
        super(message);
    }
}

这里选择继承RuntimeException的原因有很多,最主要的一点是继承它以后,调用者不强制 try-catch,也不强制在方法上写 throws,使用时直接抛出异常:

public User getUserById(Long id) {
    User user = userMapper.selectById(id);

    if (user == null) {
        throw new BusinessException("用户不存在");
    }

    return user;
}

这里不用写public User getUserById(Long id) throws BusinessException,也不用在 Controller 里强制 try-catch,所以它很适合配合 Spring Boot 的全局异常处理器

它的构造方法调用了super(message),这说明它将报错信息赋给了父类;点开RuntimeException可以看到:

public RuntimeException(String message) {
    super(message);
}

这说明RuntimeException又将报错赋给了它的父类;追本溯源可以发现,在Throwable中,这条消息最终被赋给了其私有变量detailMessage;这说明异常的层次如下:

Throwable
├── Error
└── Exception
    ├── RuntimeException
    └── 其他 Exception

在这个博客后端系统中,除了前文提到的业务异常BusinessException,还定义了一个异常UnauthorizedException,顾名思义,其意义为未授权异常,它表示认证失败/未登录/token 无效/token 过期:

public class UnauthorizedException extends RuntimeException {
    public UnauthorizedException(String message) {
        super(message);
    }
}

由于它们都继承 RuntimeException,所以 service 或 interceptor 里可以直接 throw,不需要方法签名上写 throws。

其他异常如参数校验时的ConstraintViolationException,文件大小超限MaxUploadSizeExceededException等等都已经写好了,只需借鉴前人智慧拿来用即可。

异常处理时机

了解了异常的一些基本知识,我们想知道:异常究竟是什么时候被处理的呢?

全局异常处理器的触发时机是:当 Controller、Service、Mapper 等调用链中抛出了异常,并且这个异常没有被自己 try-catch 处理掉时,Spring MVC 会接管这个异常,然后去找匹配的 @ExceptionHandler 方法处理,这里的@ExceptionHandler注解留到后文全局异常处理器章节讲解。

正常情况下,请求GET /users/1的流程如下:

请求进入
  ↓
DispatcherServlet  // DispatcherServlet 可以理解成 Spring MVC 的总调度器,所有请求基本都要先经过它。
  ↓
Controller
  ↓
Service
  ↓
Mapper
  ↓
返回结果
  ↓
Spring 转成 JSON
  ↓
响应给前端

然而,如果 Service 抛出 BusinessException

public User getUserById(Long id) {
    User user = userMapper.selectById(id);

    if (user == null) {
        throw new BusinessException("用户不存在");
    }

    return user;
}

Controller:

@GetMapping("/{id}")
public Result<User> getUser(@PathVariable Long id) {
    return Result.success(userService.getUserById(id));
}

还会return Result.success();吗?肯定不会!

当代码执行到:

throw new BusinessException("用户不存在");

后,当前方法会立刻中断,然后异常会一层一层往外抛:

Service
  ↓
Controller
  ↓
DispatcherServlet
  ↓
GlobalExceptionHandler

最后执行:

handleBusinessException(BusinessException e)

返回:

{
  "code": 400,
  "message": "用户不存在",
  "data": null
}

整体流程如下:

请求进入
  ↓
DispatcherServlet
  ↓
Controller 调用 Service
  ↓
Service 抛出 BusinessException
  ↓
Controller 没有 try-catch
  ↓
异常继续往外抛
  ↓
DispatcherServlet 捕获到异常
  ↓
交给异常解析器 HandlerExceptionResolver
  ↓
找到 @ExceptionHandler(BusinessException.class)
  ↓
执行全局异常处理方法
  ↓
返回统一 JSON

全局异常处理器

直接上代码:

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Result<Void>> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));
        return buildResponse(ResultCode.BAD_REQUEST, message, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(BindException.class)
    public ResponseEntity<Result<Void>> handleBindException(BindException ex) {
        String message = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));
        return buildResponse(ResultCode.BAD_REQUEST, message, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Result<Void>> handleBusinessException(BusinessException ex) {
        return buildResponse(ResultCode.BAD_REQUEST, ex.getMessage(), HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(UnauthorizedException.class)
    public ResponseEntity<Result<Void>> handleUnauthorizedException(UnauthorizedException ex) {
        return buildResponse(ResultCode.UNAUTHORIZED, ex.getMessage(), HttpStatus.UNAUTHORIZED);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Result<Void>> handleException(Exception ex) {
        log.error("Unhandled exception", ex);
        return buildResponse(ResultCode.ERROR, "System error, please try again later.", HttpStatus.INTERNAL_SERVER_ERROR);
    }

    private ResponseEntity<Result<Void>> buildResponse(ResultCode resultCode, String message, HttpStatus httpStatus) {
        return ResponseEntity.status(httpStatus).body(Result.failed(resultCode, message));
    }
}

我们首先来看类上的注解@RestControllerAdvice

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

它会让 Spring 自动扫描这个类,然后当 Controller 执行过程中出现异常时,Spring 会来这里找合适的 @ExceptionHandler方法处理,可以理解成:给所有 Controller 配一个“全局异常处理助手”。

@RestControllerAdvice 本质上约等于 @ControllerAdvice + @ResponseBody

也就是说:

  • @ControllerAdvice:对 Controller 做全局增强,配合 @ExceptionHandler,可以统一处理 Controller 层及 Spring MVC 请求处理过程中抛出的异常
  • @ResponseBody:方法返回值直接写入响应体,通常序列化成 JSON

GlobalExceptionHandler类的方法有许多,我们挑了几个重要的讲解。首先来看每个方法上都有的注解@ExceptionHandler,它是 Spring MVC 提供的异常处理器注解,它的作用是指定某个方法专门处理某一种或多种异常。我们可以从字面意思上理解:既然是异常处理器,那肯定要处理某一种或几种异常,所以要把异常类写进来,比如说@ExceptionHandler(BusinessException.class)。这个注解要搭配前面讲过的@RestControllerAdvice,可以这么记:

  • @RestControllerAdvice:声明全局异常处理类
  • @ExceptionHandler:声明具体处理哪种异常

当然,@ExceptionHandler可以一次声明多个异常,不过实际项目中,通常建议分开写,便于返回不同错误码和提示。

接下来我们了解一下异常处理方法的统一返回体:ResponseEntity<Result<Void>>,这是一个嵌套结构,可以拆成两层来理解:外层的ResponseEntity<>管 HTTP 响应,内层的Result<>管项目自己的统一响应体。ResponseEntity 是 Spring 提供的响应包装类,可以控制:

  • HTTP 状态码 HttpStatusCode,比如 400、401、500
  • 响应头header,比如 Content-Type
  • 响应体,也就是 body

buildResponse()方法中,我们可以看到它的创建方法:

return ResponseEntity.status(httpStatus).body(Result.failed(resultCode, message));

这说明它支持链式创建,由HttpStatusCodebody组成,这里响应头header并没有传入,说明返回的是默认响应头,最终效果可能如下所示:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "code": 400,
  "message": "文章不存在",
  "data": null
}

拆解完外部,我们接下来看看内部Result<Void>,它很好理解:不返回任何数据。这里可能有人会有疑惑:为什么泛型是Void而不是void?道理其实很简单:void不能当作泛型,void 表示方法没有返回值;Void 是 Java 类型,用在泛型里,表示这个 Result 不携带数据。

既然讲到了返回体,那么当然就要讲构造返回体的方法buildResponse(),这个方法负责统一构造错误响应,它做的事情其实就一句话:**把异常信息包装成统一的 HTTP 响应。**它的三个参数分别是:

ResultCode resultCode 

控制项目统一返回体里的业务状态码,比如 BAD_REQUESTUNAUTHORIZEDERROR

String message 

控制返回给前端的错误提示。

HttpStatus httpStatus 

控制真正的 HTTP 状态码,比如 400401500

接下来我们看函数逻辑:

ResponseEntity.status(httpStatus)

这部分设置 HTTP 状态码。比如:HttpStatus.BAD_REQUEST表示 HTTP 状态码是 400

然后:

.body(Result.failed(resultCode, message))

这部分设置响应体,也就是返回给前端的 JSON 内容。

Result.failed(resultCode, message) 会生成类似这样的对象:

{
  "code": 400,
  "message": "文章不存在",
  "data": null
}

要注意的是,第一,ResultCode HttpStatus 尽量保持语义一致。比如 UNAUTHORIZED 对应 HttpStatus.UNAUTHORIZED,这样前端好判断。第二,Result<Void> 表示错误响应不携带业务数据,data 通常就是 null。异常处理里一般用它很合适。

最后,我们来看异常处理器中的方法,在了解了前面的知识后,这些方法变得容易理解许多;我们简要介绍一下:

  • handleMethodArgumentNotValid:JSON 请求体参数错
  • handleBindException:query/form 参数错
  • handleBusinessException:业务规则不通过
  • handleUnauthorizedException:没登录或 token 不对
  • handleException:系统未知错误兜底

其中handleException作为兜底异常处理很关键,因为任何没被前面几个方法匹配到的未知异常,都会走这里。因此,它必须写在所有异常捕获方法的最后一个作兜底处理。


四、总结

总的来说,参数校验主要分为以下3个步骤:

  • 依赖引入 — spring-boot-starter-validation
  • DTO 层声明校验规则
  • Controller 层触发校验

而全局异常处理负责将业务层抛出的异常捕获并设置返回格式,最终返回给前端一个清晰可读的错误信息。


Footnotes

  1. 谁负责转换?主要是 Spring MVC 里的 HTTP 消息转换器(HttpMessageConverter) 完成的,在 Spring Boot 里,默认通常使用 Jackson 来处理 JSON 和 Java 对象之间的转换。

上一篇

已经是第一篇了

下一篇

已经是最后一篇了