Spring Validation参数效验

1,297 阅读7分钟

前言

一个项目为了防止非法参数对业务造成的影响,就需要对参数进行校验,手动参数校验的方式的缺点:代码入侵性强,后期代码难以维护,代码混乱。所以出现了一款框架validation,通过注解的方式即可实现参数的校验,比如:手机号、邮箱格式是否准确,用户名不能为空等。

前置知识

@Valid 是 JSR303 声明的,JSR是Java Specification Requests的缩写,其中 JSR303 是JAVA EE 6 中的一项子规范,叫做 Bean Validation,为 JavaBean 验证定义了相应的元数据模型和 API,需要注意的是,JSR 只是一项标准,它规定了一些校验注解的规范,但没有实现,而 Hibernate validation 对其进行实现。

Spring Validation 验证框架对参数的验证机制提供了@Validated(Spring JSR-303规范,是标准JSR-303的一个变种)。

@Valid@Validated区别

image.png

1. 基本使用 (使用Postman测试)

(1)加入依赖

注:从 boot-2.3.x开始,spring-boot-starter-web不再引入 spring-boot-starter-validation,所以需要额外手动引入validation依赖,而 2.3之前的版本只需要引入 web 依赖。

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.10</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <version>2.3.12.RELEASE</version>
</dependency>

(2)对象参数的使用

使用对象参数接收分为两种

  1. 使用 @RequestBody注解的application/json提交,
  2. 不使用 @RequestBody注解的 form-data提交。

使用对象接收参数,在需要校验对象的参数加上 @NotBlank注解,message是校验不通过的提示信息。

@Data
public class UserReq {

    @NotBlank(message = "name为必传参数")
    private String name;
    
    @NotBlank(message = "email为必传参数")
    private String email;
}

使用 @RequestBody

  • Api,在需要校验的对象前面加 @RequestBody注解以及@Validated或者@Valid注解,如果校验失败,会抛出MethodArgumentNotValidException异常
@RestController
public class GetHeaderController {
    //使用 @RequestBody,参数校验失败抛MethodArgumentNotValidException异常
    @PostMapping("save")
    public void save(@RequestBody @Validated UserReq req) {
}

image.png

不使用 @RequestBody

  • 只需要校验的对象前面加@Validated注解或者@Valid注解,如果校验失败,会抛出BindException异常
@PostMapping("save2")
public void save2(@Validated UserReq req){}

image.png

(3)基本类型的使用

  • 其实也就是路径传参,在参数前面加上相对应的校验注解,还必须在Controller类上加 @Validated注解。如果校验失败,会抛出ConstraintViolationException异常
@RestController
@Validated
public class GetHeaderController {
    
    @PostMapping("get")
    public void get(@NotBlank(message = "名称 is required") String name,@NotBlank(message = "邮箱 is required") String email) throws JsonProcessingException {

    }
}

image.png

(4)全局异常处理器

通过前面的测试,我们知道如果参数校验失败,三种使用场景会抛出三种异常或者警告,分别是MethodArgumentNotValidExceptionConstraintViolationExceptionBindException异常,每种异常的响应格式又不一致。所以在项目开发中,通常会使用统一异常处理来返回一个统一格式并友好的提示。

  配置完全局异常处理器重启项目,重新测试效果。
/**
 * 全局异常处理器
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * @RequestBody 上校验失败后抛出的异常是 MethodArgumentNotValidException 异常。
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public String handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        String messages = bindingResult.getAllErrors()
                .stream()
                .map(ObjectError::getDefaultMessage)
                .collect(Collectors.joining(";"));
        return messages;
    }
    /**
     * 不加 @RequestBody注解,校验失败抛出的则是 BindException
     */
    @ExceptionHandler(value = BindException.class)
    public String exceptionHandler(BindException e){
        String messages = e.getBindingResult().getAllErrors()
                .stream()
                .map(ObjectError::getDefaultMessage)
                .collect(Collectors.joining(";"));
        return messages;
    }

    /**
     *  @RequestParam 上校验失败后抛出的异常是 ConstraintViolationException
     */
    @ExceptionHandler({ConstraintViolationException.class})
    public String methodArgumentNotValid(ConstraintViolationException exception) {
        String message = exception.getConstraintViolations().stream().map(ConstraintViolation::getMessage).collect(Collectors.joining(";"));
        return message;
    }
}

(5)性能优化配置

默认情况下,是将类中所有的属性进行效验完成之后,才抛出异常的,这样会消耗性能,那能不能只要检测到一个效验不通过的,就抛出异常呢?只需要在容器提供如下代码:

@Configuration
public class ParamValidatorConfig {

    @Bean
    public Validator validator() {
        ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
                .configure()
                //failFast:只要出现校验失败的情况,就立即结束校验,不再进行后续的校验。
                .failFast(true)
                .buildValidatorFactory();
        return validatorFactory.getValidator();
    }

    @Bean
    public MethodValidationPostProcessor methodValidationPostProcessor() {
        MethodValidationPostProcessor methodValidationPostProcessor = new MethodValidationPostProcessor();
        methodValidationPostProcessor.setValidator(validator());
        return methodValidationPostProcessor;
    }

}

MethodValidationPostProcessorSpring提供的来实现基于方法MethodJSR校验的核心处理器,最终会由 MethodValidationInterceptor进行校验拦截。

(6)其余类型

常用校验注解有@NotNull,@NotBlank,@NotEmpty

  • @NotNull:适用于任何类型,不能为null,但可以是 (""," ")

  • @NotBlank:只能用于 String,不能为null,而且调用 trim() 后,长度必须大于0,必须要有实际字符。

  • @NotEmpty:用于 String、Collection、Map、Array,不能为null,长度必须大于0。

除了以上注解,还有很多注解,查看@NotBlank注解所在的包路径。

image.png

注解备注适用类型示例
@AssertFalse被注释的元素必须为 false,null 值是有效的。boolean 和 Boolean@AssertFalse(message = "该参数必须为 false")
@AssertTrue被注释的元素必须为 true,null 值是有效的。boolean 和 Boolean@AssertTrue(message = "该参数必须为 true")
@DecimalMax被注释的元素必须是一个数字,其值必须小于或等于指定的最大值,null 值是有效的。BigDecimal、BigInteger、CharSequence、byte、short、int、long以及包装类型@DecimalMax(value = "100",message = "该参数不能大于 100")
@DecimalMin被注释的元素必须是一个数字,其值必须大于或等于指定的最小值,null 值是有效的。BigDecimal、BigInteger、CharSequence、byte、short、int、long以及包装类型@DecimalMax(value = "0",message = "该参数不能小于 0")
@Digits被注释的元素必须是可接受范围内的数字,null 值是有效的。BigDecimal、BigInteger、CharSequence、byte、short、int、long以及包装类型@Digits(integer = 3,fraction = 2,message = "该参数整数位数不能超出3位,小数位数不能超过2位")
@Max被注释的元素必须是一个数字,其值必须小于或等于指定的最大值,null 值是有效BigDecimal、BigInteger、byte、short、int、long以及包装类型@Max(value = 200,message = "最大金额不能超过 200")
@Min被注释的元素必须是一个数字,其值必须大于或等于指定的最小值,null 值是有效的。BigDecimal、BigInteger、byte、short、int、long以及包装类型@Min(value = 0,message = "最小金额不能小于 0")
@Negative被注释的元素必须是负数,null 值是有效BigDecimal、BigInteger、byte、short、int、long、float、double 以及包装类型@Negative(message = "必须是负数")
@NegativeOrZero被注释的元素必须是负数或 0,null 值是有效的。BigDecimal、BigInteger、byte、short、int、long、float、double 以及包装类型@NegativeOrZero(message = "必须是负数或者为0")
@Positive被注释的元素必须是正数,null 值是有效的。BigDecimal、BigInteger、byte、short、int、long、float、double 以及包装类型@Positive(message = "必须是正数")
@PositiveOrZero被注释的元素必须是正数或0,null 值是有效的。BigDecimal、BigInteger、byte、short、int、long、float、double 以及包装类型@PositiveOrZero(message = "必须是正数或者为0")
@Future被注释的元素必须是未来的日期(年月日),null 值是有效的。基本所有的时间类型都支持。常用的:Date、LocalDate、LocalDateTime、LocalTime、Instant@Future(message = "预约日期要大于当前日期")
@FutureOrPresent被注释的元素必须是现在或者未来的日期(年月日),null 值是有效的。基本所有的时间类型都支持。常用的:Date、LocalDate、LocalDateTime、LocalTime、Instant@FutureOrPresent(message = "预约日要大于当前日期")
@Past被注释的元素必须是过去的日期,null 值是有效的。基本所有的时间类型都支持。常用的:Date、LocalDate、LocalDateTime、LocalTime、Instant@Past(message = "出生日期要小于当前日期")
@PastOrPresent被注释的元素必须是过去或者现在的日期,null 值是有效的。基本所有的时间类型都支持。常用的:Date、LocalDate、LocalDateTime、LocalTime、Instant@PastOrPresent(message = "出生时间要小于当前时间")
@NotBlank被注释的元素不能为空,并且必须至少包含一个非空白字符CharSequence@NotBlank(message = "name为必传参数")
@NotEmpty被注释的元素不能为 null 也不能为空CharSequence、Collection、Map、Array@NotEmpty(message = "不能为null或者为空")
@NotNull被注释的元素不能为null任意类型@NotNull(message = "不能为null")
@Null被注释的元素必须为null任意类型@Null(message = "必须为null")
@Email被注释的元素必须是格式正确的电子邮件地址,null 值是有效的。CharSequence@Email(message = "email格式错误,请重新填写")
@Pattern被注释的元素必须匹配指定的正则表达式,null 值是有效的。CharSequence@Pattern(regexp = "^1[3456789]\d{9}$",message = "手机号格式不正确")
@Size被注释的元素大小必须在指定范围内,null 值是有效的。CharSequence、Collection、Map、Array@Size(min = 5,max = 20,message = "字符长度在 5 -20 之间")

2. 分组校验

通过分组校验达到复用@Validated注解的效果

(1)定义分组接口,继承 Default接口。

/**
 * 使用分组效验
 */
public interface ZfbPayGroup extends Default {
}

(2)在字段上添加 Group分组接口

在需要区分组的字段上加 groups 参数。在本例中在 payName加了groups 参数,值为 ZfbPayGroup.class,代表对组为 ZfbPayGroup的进行payName参数校验。

@Data
public class PayReq {

    @NotBlank(message = "支付名称不能为空",groups = {ZfbPayGroup.class})
    private String payName;

    @NotNull(message = "支付金额不能为空")
    private BigDecimal payAmount;
    
}

(3)使用 group

创建两个接口,在 zfbPaySave接口中声明@Validated校验组wxbPaySave接口正常编写。

@PostMapping("zfbPaySave")
public void zfbPaySave(@RequestBody @Validated(value = {ZfbPayGroup.class}) PayReq req) throws JsonProcessingException {
    ObjectMapper mapper = new ObjectMapper();
    System.out.println( mapper.writeValueAsString(req));
}

@PostMapping("wxbPaySave")
public void wxbPaySave(@RequestBody @Validated PayReq req) throws JsonProcessingException {
    ObjectMapper mapper = new ObjectMapper();
    System.out.println( mapper.writeValueAsString(req));
}

(4) 测试

image.png

image.png

3. 嵌套校验

什么是嵌套使用呢?就是一个对象中包含另外一个对象,另外一个对象的字段也是需要进行校验。示例如下:

  • UserReq

    嵌套校验需要在效验的对象加上 @Valid 注解

@Data
public class UserReq {

    @NotBlank(message = "name为必传参数")
    private String name;

    private String email;
    
    @NotNull(message = "proReq对象不能为空")
    @Valid
    private ProReq proReq;
}
  • ProReq
@Data
public class ProReq {

    @NotBlank(message = "proName为必传参数")
    private String proName;

}

4. 集合校验

在某些场景下,我们需要使用集合接收前端传递的参数,并对集合中的每个对象都进行参数校验。但是这时我们的参数校验并不会生效!如下写法:

@PostMapping("save3")
public String save3(@RequestBody @Validated List<UserReq> req){
    return "成功";
}

下面介绍两种方式对集合进行效验!

方式一

@Validated + @Valid两个注解同时使用!缺点:不能使用分组效验!如果该实体不需要用到分组功能,可以使用该方式!

@RestController
@Validated
public class GetHeaderController {
    @PostMapping("save3")
    public String save3(@RequestBody @Valid @NotEmpty(message = "该集合不能为空") List<UserReq> req){
        return "成功";
    }
}

方式二

  • 自定义一个List
@Data
public class ValidList<E> implements List<E> {
    // 使用该注解就不需要手动重新 List 中的方法了
    @Delegate
    @Valid
    public List<E> list = new ArrayList<>();
    
}
  • @Delegate,为 lombok 的注解,表示该属性的所有对象的实例方法都将被该类代理。
  • 编码如下:
@PostMapping("save4")
public String save4(@RequestBody @Validated @NotEmpty(message = "该集合不能为空") ValidList<UserReq> req){
    return "成功";
}

5. 自定义校验规则

使用校验电话号码是否合法来举例

  • validatedBy的值要指定我们自定义的约束验证器
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = { PhoneValidator.class })
public @interface Phone {


    String message() default "手机号码格式异常";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

}
  • 实现ConstraintValidator约束验证器接口
public class PhoneValidator implements ConstraintValidator<Phone,String> {

    private static final String REGEX = "^1[3456789]\\d{9}$";
    
    /**
     *
     * @param value
     * @param context
     * @return:返回 true 表示效验通过
     */
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // 不为null才进行校验
        if (value != null) {
            return value.matches(REGEX);
        }
        return true;
    }
}
  • 接下来就可以使用 @Phone注解了。
@Data
public class UserReq {

    @NotBlank(message = "name为必传参数")
    private String name;

    @Email(message = "email格式错误,请重新填写")
    @NotBlank(message = "email为必传参数")
    private String email;

    @NotNull(message = "proReq对象不能为空")
    @Valid
    private ProReq proReq;
    
    @Phone
    @NotBlank(message = "手机号码为必传参数")
    private String tel;
}