Skip to content

Spring MVC 数据校验

当接口接收到请求时,我们需要对请求中中携带的数据进行校验。例如,用户注册时我们需要校验用户是否填写了用户名和密码,前端和后端都需要进行校验。

1. 引入依赖以及搭建环境

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

引入全局异常处理器:

java
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    @ExceptionHandler(Throwable.class)
    private R handleException(Throwable e){
        return R.error(e.getMessage(), null);
    }

}
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class R<T> {
    private int code;
    private String msg;
    @JsonRawValue
    private T data;
  
  	public static <T> R error(String msg, T data){
        return new R<T>(-1, msg, data);
    }
}

2. 校验初体验

2.1 请求参数校验

java
@GetMapping("/user")
public User getUserById(@RequestParam @NotNull String id) {
    return new User();
}

@NotNull表示参数id不能为空,如果为空则抛出异常:MissingServletRequestParameterException

结果如下:

image-20241222105951913

2.2 请求头校验

java
@GetMapping("/userInHeader")
public User getUserByIdInHeaser(@RequestHeader @NotNull String id) {
    return new User();
}

与上例同理,只不过参数位置放在了请求头。结果如下:

image-20241222110106235

2.3 请求体校验

我们将参数放在请求体中,例如,注册用户:

java
@Data
public class RegisterUserRequest {
    @NotNull(message = "用户名不能为空")
    private String username;
    @NotNull(message = "密码不能为空")
    private String password;
    @NotNull(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
}
java
@PostMapping("/registerUser")
public boolean registerUser(@RequestBody @Valid RegisterUserRequest user){
    return true;
}

RegisterUserRequest实体上,通过在字段上标注解的方式校验每个字段。

在接口方法上,通过@Valid注解开启校验。

结果如下:

image-20241222110737840

3. 校验注解

以下是一些常用的校验注解:

  • 空值检查:
    • @Null:验证注解的元素值必须为 null
    • @NotNull:验证注解的元素值不能为 null
    • @NotEmpty:验证注解的元素值不为 null 且不为空字符串(""),用于字符串、集合、Map 和数组。
    • @NotBlank:验证注解的元素值不为 null 且至少包含一个非空白字符,用于字符串。
  • 布尔值检查:
    • @AssertTrue:验证注解的元素值必须为 true
    • @AssertFalse:验证注解的元素值必须为 false
  • 数值检查:
    • @Min(value):验证注解的元素值必须大于等于指定的最小值。
    • @Max(value):验证注解的元素值必须小于等于指定的最大值。
    • @DecimalMin(value):验证注解的元素值必须大于等于指定的最小值,可以指定是否包含边界值。
    • @DecimalMax(value):验证注解的元素值必须小于等于指定的最小值,可以指定是否包含边界值。
    • @Digits(integer, fraction):验证注解的元素值是否是一个数字,且整数部分的位数不能超过 integer,小数部分的位数不能超过 fraction
    • @Positive: 验证注解的元素值必须是正数(不包括 0)。
    • @PositiveOrZero: 验证注解的元素值必须是正数或 0。
    • @Negative: 验证注解的元素值必须是负数(不包括 0)。
    • @NegativeOrZero: 验证注解的元素值必须是负数或 0。
  • 日期检查:
    • @Past:验证注解的元素值必须是一个过去的日期。
    • @Future:验证注解的元素值必须是一个将来的日期。
    • @PastOrPresent: 验证注解的元素值必须是过去或现在的日期。
    • @FutureOrPresent: 验证注解的元素值必须是将来或现在的日期。
  • 字符串检查:
    • @Size(min, max):验证注解的元素值的长度必须在指定的范围内。
    • @Pattern(regexp):验证注解的元素值必须符合指定的正则表达式。
    • @Email:验证注解的元素值必须是合法的电子邮件地址。

4. @Valid 和 @Validated

两者都可以用来开启校验,但@Valid 是 JSR-303 的标准注解,而 @Validated 是 Spring 提供的,支持分组校验。

接下来讲解如何使用@Validated进行分组校验。

首先,在实体中定义校验组:

java
@Data
public class UserRequest {
    @NotNull(groups = UserRequest.UpdateUser.class, message = "id不能为空")
    private String id;
    
    @NotNull(groups = {UserRequest.AddUser.class, UserRequest.UpdateUser.class}, message = "用户名不能为空")
    private String username;
    
    @NotNull(groups = {UserRequest.AddUser.class, UserRequest.UpdateUser.class}, message = "密码不能为空")
    private String password;
    
    @NotNull(groups = {UserRequest.AddUser.class, UserRequest.UpdateUser.class}, message = "邮箱不能为空")
    @Email(groups = {UserRequest.AddUser.class, UserRequest.UpdateUser.class}, message = "邮箱格式不正确")
    private String email;

    // 以下定义了两个校验组
    public interface AddUser{}
    public interface UpdateUser{}
}

然后在接口方法中,通过@Validated指定校验组:

java
@PostMapping("/addUser")
public boolean addUser(@RequestBody @Validated(UserRequest.AddUser.class) UserRequest userRequest){
    return true;
}

@PostMapping("/updateUser")
public boolean updateUser(@RequestBody @Validated(UserRequest.UpdateUser.class) UserRequest userRequest){
    return true;
}

最后请求接口,结果如下:

image-20241222112533996

image-20241222112603061

在实际情况中,我们通过将新增和修改请求参数定义为两个实体分别校验,降低代码冗余。

5. 获取校验结果

在上面的案例中,如果请求体参数校验失败,抛出的异常为:MethodArgumentNotValidException。我们可以在全局异常处理器中精确处理该异常:

java
@ExceptionHandler(MethodArgumentNotValidException.class)
private R handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
    List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
    Map<String, String> errorMap = new HashMap<>();
    if(fieldErrors != null && fieldErrors.size() > 0){
        fieldErrors.forEach(x->{
            errorMap.put(x.getField(), x.getDefaultMessage());
        });
    }

    return R.error(-1, "请求参数校验失败", errorMap);
}

如果之后参数校验失败,那么就会按字段返回具体的失败原因:

image-20241222114006459

6. 自定义校验注解

在有的时候,已有的注解并不能满足我们的需求,需要我们自定义注解来实现自己的校验逻辑。比如,我们自定义注解来校验传入的性别是否为“男”或“女”两个枚举值之一。

首先定义枚举类:

java
public enum GenderEnum {
    MALE("男"),
    FEMALE("女");
    private String desc;
    private GenderEnum(String desc) {
        this.desc = desc;
    }

    public String getDesc() {
        return desc;
    }
  	
  	public String toString() {
        return desc;
    }
}

然后自定义注解:

java
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {GenderEnumValidator.class}) // 指定校验器
public @interface GenderEnumConstraint {
    // 默认的消息提示
    String message() default "性别必须为男或女";

    //  指定校验的组
    Class<?>[] groups() default { };

    // 指定校验的负载,一般用不到(虽然不知道有什么用,不过写在这里吧)
    Class<? extends Payload>[] payload() default { };

    // 指定校验的枚举类
    Class<? extends Enum<?>> enumClass();

}

在第三行指定校验器,所以我们要实现ConstraintValidator接口:

java
public class GenderEnumValidator implements ConstraintValidator<GenderEnumConstraint, String> {

    private List<String> enumValues;

    @Override
    public void initialize(GenderEnumConstraint constraintAnnotation) {
        Class<? extends Enum<?>> enumClass = constraintAnnotation.enumClass();
        enumValues = Arrays.stream(enumClass.getEnumConstants())
                .map(x->((GenderEnum)x).getDesc()) // 获取枚举值的名称
                .collect(Collectors.toList());
    }


    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) { // 允许为空
            return true;
        }
        return enumValues.contains(value.toString());
    }
}

接口第一个泛型表示校验注解,第二个泛型表示要校验的数据类型。

之后,我们就可以像使用其他校验注解一样使用该注解进行校验了:

java
@Data
public class UserRequest {
		// ... 省略其他字段

    @GenderEnumConstraint(
            groups = {UserRequest.AddUser.class, UserRequest.UpdateUser.class},
            enumClass = GenderEnum.class)
    private String gender;

    // 以下定义了两个校验组
    public interface AddUser{}
    public interface UpdateUser{}
}

结果如下:

image-20241222124550998

其实,对于枚举类型的校验,我们也可以通过Jackson反序列化异常来实现:

首先在application.yml中开启配置:

yaml
spring:
  jackson:
    serialization:
      write-enums-using-to-string: true
    deserialization:
      read-enums-using-to-string: true

然后在实体定义中,将数据类型改为枚举:

java
@Data
public class UserRequest {
		// ... 省略其他字段
    private GenderEnum gender;

}

结果如下:

image-20241222125511821