撰于 阅读 31

Spring Boot 实现 JSR 380优雅的参数校验

1. 介绍

在业务开发中,我们需要对前端传入的各种参数做格式、长度、合规性校验,如果全部用if去判断写在业务代码里,观感非常的不好,也不够轻量。

2. JSR 380 参数校验注解

Spring Boot 提供了简洁的方法,让我们能够利用 Java 校验 API (JSR 380) 中定义的注解进行参数校验。JSR 380,也被称为 Bean Validation 2.0,是 Java Bean 验证规范的一个版本。该规范定义了一系列注解,用于验证 Java Bean 对象的属性,确保它们满足某些条件或限制。

以下是 JSR 380 中提供的主要验证注解及其描述:

  • @NotNull: 验证对象值不应为 null。
  • @AssertTrue: 验证布尔值是否为 true。
  • @AssertFalse: 验证布尔值是否为 false。
  • @Min(value): 验证数字是否不小于指定的最小值。
  • @Max(value): 验证数字是否不大于指定的最大值。
  • @DecimalMin(value): 验证数字值(可以是浮点数)是否不小于指定的最小值。
  • @DecimalMax(value): 验证数字值(可以是浮点数)是否不大于指定的最大值。
  • @Positive: 验证数字值是否为正数。
  • @PositiveOrZero: 验证数字值是否为正数或零。
  • @Negative: 验证数字值是否为负数。
  • @NegativeOrZero: 验证数字值是否为负数或零。
  • @Size(min, max): 验证元素(如字符串、集合或数组)的大小是否在给定的最小值和最大值之间。
  • @Digits(integer, fraction): 验证数字是否在指定的位数范围内。例如,可以验证一个数字是否有两位整数和三位小数。
  • @Past: 验证日期或时间是否在当前时间之前。
  • @PastOrPresent: 验证日期或时间是否在当前时间或之前。
  • @Future: 验证日期或时间是否在当前时间之后。
  • @FutureOrPresent: 验证日期或时间是否在当前时间或之后。
  • @Pattern(regexp): 验证字符串是否与给定的正则表达式匹配。
  • @NotEmpty: 验证元素(如字符串、集合、Map 或数组)不为 null,并且其大小/长度大于0。
  • @NotBlank: 验证字符串不为 null,且至少包含一个非空白字符。
  • @Email: 验证字符串是否符合有效的电子邮件格式。
    除了上述的标准注解,JSR 380 也支持开发者定义和使用自己的自定义验证注解。此外,这个规范还提供了一系列的APIs和工具,用于执行验证和处理验证结果。大部分现代Java框架(如 Spring 和 Jakarta EE)都与 JSR 380 兼容,并支持其验证功能。

3. 项目集成

3.1 引入依赖

pom.xml 文件添加参数校验依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

3.2 实体类参数校验

package com.windblog.web.model;

import lombok.Data;

import javax.validation.constraints.*;

@Data
public class User {
    // 用户名
    @NotBlank(message = "用户名不能为空")
    private String username;
    // 性别
    @NotNull(message = "性别不能为空")
    private Integer sex;

    // 年龄
    @NotNull(message = "年龄不能为空")
    @Min(value = 18, message = "年龄必须大于或等于 18")
    @Max(value = 100, message = "年龄必须小于或等于 100")
    private Integer age;

    // 邮箱
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
}

3.3 Controller 参数校验

针对每个字段的校验注解添加完成后,还需要在 controller 层进行捕获,并将错误信息返回。编辑 TestController 类,代码如下:

@RestController
@Slf4j
public class TestController {

    @PostMapping("/test")
    @ApiOperationLog(description = "测试接口")
        public ResponseEntity<String> test(@RequestBody @Validated User user, BindingResult bindingResult) {
        // 是否存在校验错误
        if (bindingResult.hasErrors()) {
            // 获取校验不通过字段的提示信息
            String errorMsg = bindingResult.getFieldErrors()
                    .stream()
                    .map(FieldError::getDefaultMessage)
                    .collect(Collectors.joining(", "));

            return ResponseEntity.badRequest().body(errorMsg);
        }

        // 返参
        return ResponseEntity.ok("参数没有任何问题");
    }

}

解释一下上面代码中的关键部分:

  • @Validated: 告诉 Spring 需要对 User 对象执行校验;
  • BindingResult : 验证的结果对象,其中包含所有验证错误信息;

4. 全局异常处理

对于参数校验的异常,如果每个接口都需要处理,那么也太麻烦了,所以我们需要添加一个全局异常处理,统一帮我们去处理校验的不通过等其他异常

4.1 自定义一个基础异常接口

package com.windblog.common.exception;

public interface BaseExceptionInterface {
    String getErrorCode();

    String getErrorMessage();
}

4.2 自定义错误码枚举

新建 enums 包,用于统一放置枚举类,在该包中,创建 ResponseCodeEnum 异常码枚举类,代码如下:

package com.windblog.common.enums;

import com.windblog.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;


@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {

    // ----------- 通用异常状态码 -----------
    SYSTEM_ERROR("10000", "出错啦,后台小哥正在努力修复中..."),

    // ----------- 业务异常状态码 -----------
    PRODUCT_NOT_FOUND("20000", "该产品不存在(测试使用)"),
    ;

    // 异常码
    private String errorCode;
    // 错误信息
    private String errorMessage;

}

4.3 自定义业务异常 BizException

创建 BizException , Biz 是业务英文 Business 的缩写:

package com.windblog.common.exception;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class BizException extends RuntimeException {
    // 异常码
    private String errorCode;
    // 错误信息
    private String errorMessage;

    public BizException(BaseExceptionInterface baseExceptionInterface) {
        this.errorCode = baseExceptionInterface.getErrorCode();
        this.errorMessage = baseExceptionInterface.getErrorMessage();
    }
}

我们让 BizException 继承自运行时异常 RuntimeException, 同时定义了两个基本字段:

  • errorCode : 异常码;
  • errorMessage: 错误信息,用于提供给调用者;
    另外,还定义了一个构造器,入参是前面创建的 BaseExceptionInterface,通过它可以方便的构造一个业务异常。

4.4 使用 @ControllerAdvice

Spring Boot 提供了 @ControllerAdvice 注解,它允许我们定义一个全局的异常处理类。这个类可以捕获应用中抛出的所有异常,并根据需要进行处理。

在 common 模块中的 pom.xml 中添加如下依赖,因为 @ControllerAdvice 注解在这个依赖中:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

4.5 创建全局异常处理类

在 exception 包下,创建全局异常处理类 GlobalExceptionHandler ,代码如下:


package com.windblog.common.exception;

import com.windblog.common.utils.Response;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 捕获自定义业务异常
     * @return
     */
    @ExceptionHandler({ BizException.class })
    @ResponseBody
    public Response<Object> handleBizException(HttpServletRequest request, BizException e) {
        log.warn("{} request fail, errorCode: {}, errorMessage: {}", request.getRequestURI(), e.getErrorCode(), e.getErrorMessage());
        return Response.fail(e);
    }
}

上述代码中,通过 @ControllerAdvice 注解,我们将 GlobalExceptionHandler 声明为了全局异常处理类。在其中,定义了一个 handleBizException() 方法,并通过 @ExceptionHandler 注解指定只捕获 BizException 自定义业务异常。然后,打印了相关错误日志,并组合了统一的响应格式返回。

4.6 捕获参数异常

为了解放双手,我们可以通过全局异常处理器来捕获该异常,统一返回错误信息,改造 GlobalExceptionHandler 类,添加 handleMethodArgumentNotValidException() 方法,代码如下:

    /**
     * 捕获参数校验异常
     * @return
     */
    @ExceptionHandler({ MethodArgumentNotValidException.class })
    @ResponseBody
    public Response<Object> handleMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException e) {
        // 参数错误异常码
        String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();

        // 获取 BindingResult
        BindingResult bindingResult = e.getBindingResult();

        StringBuilder sb = new StringBuilder();

        // 获取校验不通过的字段,并组合错误信息,格式为: email 邮箱格式不正确, 当前值: '123124qq.com';
        Optional.ofNullable(bindingResult.getFieldErrors()).ifPresent(errors -> {
            errors.forEach(error ->
                    sb.append(error.getField())
                            .append(" ")
                            .append(error.getDefaultMessage())
                            .append(", 当前值: '")
                            .append(error.getRejectedValue())
                            .append("'; ")

            );
        });

        // 错误信息
        String errorMessage = sb.toString();

        log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);

        return Response.fail(errorCode, errorMessage);
    }

上述代码中,我们通过 @ExceptionHandler 注解捕获了 MethodArgumentNotValidException.class 类型的异常,并从异常实体类中获取了 BindingResult 对象,从而获取到具体哪些字段校验不通过,最终组合错误信息并返回。

记得添加对应错误的枚举