Spring REST 接口校验Validation

priority
Updated
Jun 25, 2021 01:31 PM
date
Apr 12, 2021
type
Post
slug
spring-validation
Created
Jun 5, 2020 08:34 AM
status
Published
tags
Spring
SpringMVC
SpringBoot
summary
之前一直做部署和数据库的工作,很少直接写接口。新工作天天写接口,虽然之前也粗看过接口验证@Valid和@Validated的介绍,但是实践起来还是有几个迷惑的地方: 1. @Validated和@Valid什么时候可以通用? 2. @Validated和@Valid有什么区别 3. 为什么有的时候验证会失效? 然后抽空看了一些文章,自己测试了一些,看看源码的注释,作了以下总结。

历史和概念

JSR:全称Java Specification Requests,意思是Java 规范提案。我们可以将其理解为Java为一些功能指定的一系列统一的规范。跟数据校验相关的最新的JSR为JSR 380。
Bean Validation 2.0的唯一实现就是Hibernate Validator,对应版本为6.0.1.Final,同时在2.0版本之前还有1.1(JSR 349)及1.0(JSR 303)两个版本,不过版本间的差异并不是我们关注的重点,而且Bean Validation 2.0本身也向下做了兼容。
Bean Validation2.0的全称为Jakarta Bean Validation2.0,关于Jakarta,感兴趣的可以参考这个链接:https://www.oschina.net/news/94055/jakarta-ee-new-logo,就是Java换了个名字。
Spring Validation 则在整合了Hibernate Validation 的基础上,以Spring的方式,支持Spring应用的输入输出校验,比如MVC入参校验,方法级校验等等,同时还提供了分组校验。
notion image
JSR规范定义类一系列注解和接口、类。注解包括声明约束校验@Valid,和具体的约束注解@Min,@NotNull等。
那么@Valid注解有什么作用?作为JSR规范的一个注解,其作用就是,声明属性、参数等该注解应用的地方需要被校验。至于如何校验,则是实现JSR规范的框架需要考虑的,也就是hibernate-validator和spring-validate考虑的事情。
@Min等具体的约束注解,除了声明当前注解应用的参数需要校验,还声明了其校验的规则。
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.14.Final</version>
    <scope>compile</scope>
</dependency>

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

@Valid和@Validated的区别

从注解的定义上看
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Valid 
@Validated用于类(类,接口,枚举),方法和方法参数,不可用于属性。
@Valid用于方法、属性、构造器,方法参数,不可用于类。
注意两个不可用,是二者使用中最大的区别。其他情况下,两者基本是等价的。

如何实现校验

约束注解的类型

  • 通过向方法或构造函数的参数添加约束注解来指定方法或构造函数的前置条件
  • 通过在方法体上添加约束注解来给方法或构造函数指定后置条件

写代码校验

//更多使用方式看Validation的注解
//线程安全
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
validator.validate(Object obj, Class<?>[] groups);

集成Spring

集成spring,一种是用在controller中,另一种使用是在普通的spring bean中。其实写这篇文章是使用中遇到了一些疑问,为什么有的时候,不需要类上添加@Validated注解就能生效?@Validated和@Valid能互相替代么?有的文章说@Validated配合group使用的时候,必须放在方法上?
  • 先说第一个问题:为什么有的时候,不需要类上@Validated注解就能生效
    • 那什么时候不需要@Validated就能生效呢?对于controller,当在@RequestBody上应用@Valid的时候,是不需要额外的处理的,因为Spring在处理post body的数据绑定时,借助于RequestResponseBodyMethodProcessor,完成了数据的校验。
      注意,以下的情况,其request body不是一个完整的对象,所以spring不会进行校验
      @PostMapping("test5")
      public ResponseEntity<?> test5(@Valid @RequestBody List<Person> personList) {
          return ResponseEntity.ok("ok");
      }
      
      @PostMapping("test6")
      public ResponseEntity<?> test6(@Validated @RequestBody List<Person> personList) {
          return ResponseEntity.ok("ok");
      }
      @PostMapping("test7")
      public ResponseEntity<?> test7(@Valid @NotEmpty @RequestBody List<Person> personList) {
          return ResponseEntity.ok("ok");
      }
  • @Validated和@Valid能互相替代么
    • 在二者都能使用的场景,是完全可以互相替代的,其作用都是声明了当前属性、对象or参数需要进行校验
      不能互相代替的场景:用在属性上;用在类上;分组校验
那么,@Validated放在类上,有什么作用呢?
根据文档的注释,@Validated放在spring bean的类上,spring会为其创建一个切面代理,对于其所有的public方法,如果方法有声明需要验证的参数,则进行验证。
当@Validated放在方法上时,则没有创建切面的功能,仅仅是用来声明该方法所有的需要验证的参数适用的group,并覆盖类上提供的分组。
综上,Controller中对于RequestBody,可以直接配合@Valid or @Validated使用;但是对于@RequesyParam 、@PathVariable或者Query Object的情形,必须在类上添加@Validated。
对于普通service bean,也需要类上添加@Validated。
因此,“有的文章说@Validated配合group使用的时候,必须放在方法上“这个说法是错误的。

注解的嵌套

当校验一个对象的时候,其内部嵌套了另一个对象,默认是不会校验的。此时需要在被嵌套的对象上加@Valid,才可进行嵌套校验。示例如下
@Data
public class Person {

    // 错误消息message是可以自定义的
    @NotNull(groups = Simple.class)
    public String name;

    @Positive(groups = Default.class)
    public Integer age;

    @NotNull(groups = Complex.class)
    @NotEmpty(groups = Complex.class)
    private List<@Email String> emails;

    @Valid
    private NestPerson nestPerson;

    // 定义两个组 Simple组和Complex组
    public interface Simple {
    }

    public interface Complex {

    }
}

// 用于进行嵌套校验
@Data
public class NestPerson {
    @NotNull
    String name;

    @Valid
    Person person;
}

注解的继承

当在继承体系中声明方法约束时,必须了解两个规则:
  • 方法调用方要满足前置条件不能在子类型中得到加强
  • 方法调用方要保证后置条件不能在子类型中被削弱
如果子类继承自他的父类,除了校验子类,同时还会校验父类,这就是约束继承,这同样适用于接口。如果子类覆盖了父类的方法,那么子类和父类的约束都会被校验。

最佳实践

  • controller固定添加@Validated
  • query方法如果是query object,参数上加@Valid
    • @Validated生成的AOP会验证Object本身,但是Object内属性属于嵌套,不会验证,必须添加@Valid才行
  • post方法body参数添加@Valid
  • 需要分组验证,使用@Validated替代@Valid(并不需要放在方法上)
  • object内嵌套了object,被嵌套的属性加@Valid
  • request param和path variable直接使用具体的验证注解
以上标记绿色的情况,@Valid和@Validated可互相替换

常用验证注解

JSR标准

以下验证注解都可以在相同元素上定义多个。
  1. @Size(min=, max=)
    1. 检查元素个数是否在 min(含)和 max(含)之间 支持数据类型:CharSequence,Collection,Map, arrays。不支持数字!!null视为通过
  1. @NotEmpty
    1. 检查元素是否不为 null 支持数据类型:CharSequence, Collection, Map, arrays;null视为不通过
  1. @NotBlank
    1. 不为null,且至少包含一个非空格字符。与 @NotEmpty 的不同之处在于,此约束只能应用于字符序列,并且忽略尾随空格。 支持数据类型:CharSequence
  1. @Max(value=)
    1. 检查值是否小于或等于指定的最大值 支持的数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字,即可以转换为字数字的字符串), Number 的任意子类, javax.money.MonetaryAmount 的任意子类
  1. @Min(value=)
    1. 检查值是否大于或等于指定的最大值 支持的数据类型: 同@Max
  1. @AssertFalse
    1. 检查元素是否为 false,支持数据类型:boolean、Boolean
  1. @AssertTrue
    1. 检查元素是否为 true,支持数据类型:boolean、Boolean
  1. @DecimalMax(value=, inclusive=)
    1. inclusive:boolean,默认 true,表示是否包含,是否等于 value:当 inclusive=false 时,检查带注解的值是否小于指定的最大值。当 inclusive=true 检查该值是否小于或等于指定的最大值。参数值是根据 bigdecimal 字符串表示的最大值。 支持数据类型:BigDecimal、BigInteger、CharSequence、(byte、short、int、long 和其封装类)
  1. @DecimalMin(value=, inclusive=)
    1. 支持数据类型:BigDecimal、BigInteger、CharSequence、(byte、short、int、long 和其封装类) inclusive:boolean,默认 true,表示是否包含,是否等于 value: 当 inclusive=false 时,检查带注解的值是否大于指定的最大值。当 inclusive=true 检查该值是否大于或等于指定的最大值。参数值是根据 bigdecimal 字符串表示的最小值。
  1. @Digits(integer=, fraction=)
    1. 检查值是否为最多包含 integer 位整数和 fraction 位小数的数字 支持的数据类型: BigDecimal, BigInteger, CharSequence, byte, short, int, long 、原生类型的封装类、任何 Number 子类。
  1. @Email
    1. 检查指定的字符序列是否为有效的电子邮件地址。可选参数 regexpflags 允许指定电子邮件必须匹配的附加正则表达式(包括正则表达式标志)。 支持的数据类型:CharSequence
  1. @NotNull
    1. 检查值是否null 支持数据类型:任何类型
  1. @Negative
    1. 检查元素是否严格为负数。零值被认为无效。 支持数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类
  1. @NegativeOrZero
    1. 检查元素是否为负或零。 支持数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类
  1. @Positive
    1. 检查元素是否严格为正。零值被视为无效。 支持数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类
  1. @PositiveOrZero
    1. 检查元素是否为正或零。 支持数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类
  1. @Null
    1. 检查值是否为 null 支持数据类型:任何类型
  1. @Future
    1. 检查日期是否在未来 支持的数据类型: java.util.Date, java.util.Calendar, java.time.Instant, java.time.LocalDate, java.time.LocalDateTime, java.time.LocalTime, java.time.MonthDay, java.time.OffsetDateTime, java.time.OffsetTime, java.time.Year, java.time.YearMonth, java.time.ZonedDateTime, java.time.chrono.HijrahDate, java.time.chrono.JapaneseDate, java.time.chrono.MinguoDate, java.time.chrono.ThaiBuddhistDate 如果 Joda Time API 在类路径中,ReadablePartialReadableInstant 的任何实现类
  1. @FutureOrPresent
    1. 检查日期是现在或将来 支持数据类型:同@Future
  1. @Past
    1. 检查日期是否在过去 支持数据类型:同@Future
  1. @PastOrPresent
    1. 检查日期是否在过去或现在 支持数据类型:同@Future
  1. @Pattern(regex=, flags=)
    1. 根据给定的 flag 匹配,检查字符串是否与正则表达式 regex 匹配 支持数据类型:CharSequence

hibernate提供的注解

参考org.hibernate.validator.constraints包提供的注解。常用的如下
  1. @Range:把@Max和@Min组合起来了
  1. @Length:
  1. @DurationMax
  1. @DurationMin
  1. @CodePointLength
  1. @UniqueElements
  1. @URL

创建自定义约束

三个步骤:
  • 创建一个约束注解
  • 实现一个验证器
  • 定义一个默认的错误消息

创建约束注解

@Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = CheckCaseValidator.class)
@Documented@Repeatable(List.class)
public @interface CheckCase {
    String message() default "{org.jys.CheckCase}";
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
    CaseMode value();

    @Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })
    @Retention(RUNTIME)
    @Documented
    @interface List {
        CheckCase[] value();
    }
}

实现验证器

public class CheckCaseValidator implements ConstraintValidator<CheckCase, String> {
    private CaseMode caseMode;
    @Override
    public void initialize(CheckCase constraintAnnotation) {
        this.caseMode = constraintAnnotation.value();
    }
    @Override
    public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
        if ( object == null ) {
            return true;
        }
        if ( caseMode == CaseMode.UPPER ) {
            return object.equals( object.toUpperCase() );
        }else {
            return object.equals( object.toLowerCase() );
        }
    }
}

定义默认错误消息

sspath下新建一个ValidationMessages.properties文件,加入约束注解中定义的消息模板配置。
org.jys.CheckCase = 核载人数为{value},请勿超载

分组

注意:所有的约束注解都有 groups 属性。当不指定 groups 时,默认约束为 Default 分组,默认校验也是校验Default分组。
校验的时候,在方法org.hibernate.validator.internal.engine.ValidatorImpl#determineGroupValidationOrder
中,如果传入的group是空,则默认使用default group进行校验。
分组有利于我们重用同一个对象,在不同的时候进行不同的验证。当使用代码手动验证的时候,也需要传入需要验证的分组。但是@Valid不支持分组,此时需要使用支持声明分组的@Validated代替了。示例如下
public class SuperCar extends Car {
    @AssertTrue(
            message = "Race car must have a safety belt",
            groups = RaceCarChecks.class
    )
    private boolean safetyBelt;
    // getters and setters ...
}
public interface RaceCarChecks extends Default {}
⚠️
自定义的接口此时必须继承Default,因为没有声明分组的接口默认是default分组的,不继承将不会被验证!!

分组序列

默认情况下,不管约束是属于哪个分组,它们的计算是没有特定顺序的,而在某些场景下,控制约束的计算顺序是有用的。 如:先检查汽车的默认约束,再检查汽车的性能约束,最后在开车前,检查驾驶员的实际约束。 可以定义一个接口,并用 @GroupSequence 来定义需要验证的分组的序列。 示例:
@GroupSequence({ Default.class, CarChecks.class, DriverChecks.class })
public interface OrderedChecks {}
该接口的作用和普通的分组的功能是一样的,不过其同时集成了多个分组并定义了分组验证的顺序。

重新定义默认分组序列

@GroupSequence
@GroupSequence 除了定义分组序列外,还允许重新定义指定类的默认分组。为此,只需将@GroupSequence 添加到需要校验约束的类中,并在注解中用指定序列的分组替换 Default 默认分组。
@GroupSequence({ RentalChecks.class, CarChecks.class, RentalCar.class })
public class RentalCar extends Car {}
在验证约束时,直接把其当做默认分组方式来验证
@GroupSequenceProvider
注意:此为 hibernate-validator 提供,JSR 规范不支持
可用于根据对象状态动态地重新定义默认分组序列。 需要做两步:
  1. 实现接口:DefaultGroupSequenceProvider
  1. 在指定类上使用 @GroupSequenceProvider,并指定 value 为上一步的类
示例:
public class RentalCarGroupSequenceProvider
        implements DefaultGroupSequenceProvider<RentalCar> {
    @Override
    public List<Class<?>> getValidationGroups(RentalCar car) {
        List<Class<?>> defaultGroupSequence = new ArrayList<Class<?>>();
        defaultGroupSequence.add( RentalCar.class );
        if ( car != null && !car.isRented() ) {
            defaultGroupSequence.add( CarChecks.class );
        }
        return defaultGroupSequence;
    }
}
@GroupSequenceProvider(RentalCarGroupSequenceProvider.class)
public class RentalCar extends Car {
    @AssertFalse(message = "The car is currently rented out", groups = RentalChecks.class)
    private boolean rented;
    public RentalCar(String manufacturer, String licencePlate, int seatCount) {
        super( manufacturer, licencePlate, seatCount );
    }
    public boolean isRented() {
        return rented;
    }
    public void setRented(boolean rented) {
        this.rented = rented;
    }
}

分组转换

如果你想把与汽车相关的检查和驾驶员检查一起验证呢?当然,您可以显式地指定验证多个组,但是如果您希望将这些验证作为默认组验证的一部分进行,该怎么办?这里 @ConvertGroup 开始使用,它允许您在级联验证期间使用与最初请求的组不同的组。
说人话就是:以下例子中,假设Car类里面包含类Driver类,Driver类默认验证的是default,Car类默认验证的也是default分组,但是当driver放在car里面作为属性的时候,期望验证DriverCheck分组。由于@Validated不能应用与属性,此时需要@ConvertGroup进行分组转换。
在可以使用 @Valid 的任何地方,都能定义分组转换,也可以在同一个元素上定义多个分组转换 必须满足以下限制:@ConvertGroup 只能与 @Valid 结合使用。如果不是,则抛出 ConstraintDeclarationException。在同一元素上有多个 from 值相同的转换规则是不合法的。在这种情况下,将抛出 ConstraintDeclarationException。from 属性不能引用分组序列。在这种情况下会抛出 ConstraintDeclarationException
⚠️
警告:规则不是递归执行的。将使用第一个匹配的转换规则,并忽略后续规则。
例如,如果一组 @ConvertGroup 声明将组 a 链接到 b,将组 b 链接到 c,则组 a 将被转换到 b,而不是 c。
示例:
// 当 driver 为 null 时,不会级联验证,使用的是默认分组,当级联验证时,使用的是 DriverChecks 分组
@Valid
@ConvertGroup(from = Default.class, to = DriverChecks.class)
private Driver driver;

参考文章

  1. Complete Guide to Validation With Spring Boot
  1. validator 自动化校验
  1. Spring官网阅读(十七)Spring中的数据校验
  1. Jakarta Bean Validation specification
 

© Song 2015 - 2021