validator 自动化校验

5,522 阅读21分钟

check

温馨提示

请收藏再看。此文篇幅太长,你短时间看不完;此文干货太多,错过太可惜。

示例代码可以关注逸飞兮(公众号)回复jy获取。

收获

  1. 讲解详细:能让你掌握使用 hibernate-validator 及类似校验工具的各种使用姿势
  2. 内容全面:可以当做知识字典来查询

what

注意:hibernate-validator 与 持久层框架 hibernate 没有什么关系,hibernate-validator 是 hibernate 组织下的一个开源项目

hibernate-validatorJSR 380(Bean Validation 2.0)JSR 303(Bean Validation 1.0)规范的实现。

JSR 380 - Bean Validation 2.0 定义了一个实体和方法验证的元数据模型和 API。

JavaEE(改名为:Jakarta EE)中制定了 validation 规范,即:javax.validation-api(现为 jakarta.validation-api,jar 包的名字改变,包里面的包名、类名未变,因此使用方式不变)包,spring-boot-starter-webspring-boot-starter-webflux 包都已引入此依赖,直接使用即可。

有点类似于 slf4j 与 logback(log4j2)的关系,使用的时候,代码中使用 javax.validate 提供的接口规范功能,加载的时候,根据 SPI 规范加载对应的规范实现类。

它和 hibernate 没什么关系,放心大胆的使用吧。

why

hibernate-validator 官方有如下说明:

以前的校验如下:

file

使用 hibernate-validator 后,校验逻辑如下:

file

controller、service、dao 层相同的校验逻辑可以使用同一个数据校验模型。

how

标识注解

@Valid(规范、常用)

标记用于验证级联的属性、方法参数或方法返回类型。 在验证属性、方法参数或方法返回类型时,将验证在对象及其属性上定义的约束。 此行为是递归应用的。

@Validated(spring)

spring 提供的扩展注解,可以方便的用于分组校验

22 个约束注解

下面除了列出的参数,每个约束都有参数 message,groups 和 payload。这是 Bean Validation 规范的要求。

其中,message 是提示消息,groups 可以根据情况来分组。

以下每一个注解都可以在相同元素上定义多个。

@AssertFalse

检查元素是否为 false,支持数据类型:boolean、Boolean

@AssertTrue

检查元素是否为 true,支持数据类型:boolean、Boolean

@DecimalMax(value=, inclusive=)

inclusive:boolean,默认 true,表示是否包含,是否等于 value:当 inclusive=false 时,检查带注解的值是否小于指定的最大值。当 inclusive=true 检查该值是否小于或等于指定的最大值。参数值是根据 bigdecimal 字符串表示的最大值。 支持数据类型:BigDecimal、BigInteger、CharSequence、(byte、short、int、long 和其封装类)

@DecimalMin(value=, inclusive=)

支持数据类型:BigDecimal、BigInteger、CharSequence、(byte、short、int、long 和其封装类) inclusive:boolean,默认 true,表示是否包含,是否等于 value: 当 inclusive=false 时,检查带注解的值是否大于指定的最大值。当 inclusive=true 检查该值是否大于或等于指定的最大值。参数值是根据 bigdecimal 字符串表示的最小值。

@Digits(integer=, fraction=)

检查值是否为最多包含 integer 位整数和 fraction 位小数的数字 支持的数据类型: BigDecimal, BigInteger, CharSequence, byte, short, int, long 、原生类型的封装类、任何 Number 子类。

@Email

检查指定的字符序列是否为有效的电子邮件地址。可选参数 regexpflags 允许指定电子邮件必须匹配的附加正则表达式(包括正则表达式标志)。 支持的数据类型:CharSequence

@Max(value=)

检查值是否小于或等于指定的最大值 支持的数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类

@Min(value=)

检查值是否大于或等于指定的最大值 支持的数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类

@NotBlank

检查字符序列是否为空,以及去空格后的长度是否大于 0。与 @NotEmpty 的不同之处在于,此约束只能应用于字符序列,并且忽略尾随空格。 支持数据类型:CharSequence

@NotNull

检查值是否null 支持数据类型:任何类型

@NotEmpty

检查元素是否为 null 支持数据类型:CharSequence, Collection, Map, arrays

@Size(min=, max=)

检查元素个数是否在 min(含)和 max(含)之间 支持数据类型:CharSequence,Collection,Map, arrays

@Negative

检查元素是否严格为负数。零值被认为无效。 支持数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类

@NegativeOrZero

检查元素是否为负或零。 支持数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类

@Positive

检查元素是否严格为正。零值被视为无效。 支持数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类

@PositiveOrZero

检查元素是否为正或零。 支持数据类型: BigDecimal, BigInteger, byte, short, int, long, 原生类型的封装类, CharSequence 的任意子类(字符序列表示的数字), Number 的任意子类, javax.money.MonetaryAmount 的任意子类

@Null

检查值是否为 null 支持数据类型:任何类型

@Future

检查日期是否在未来 支持的数据类型: 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 的任何实现类

@FutureOrPresent

检查日期是现在或将来 支持数据类型:同@Future

@Past

检查日期是否在过去 支持数据类型:同@Future

@PastOrPresent

检查日期是否在过去或现在 支持数据类型:同@Future

@Pattern(regex=, flags=)

根据给定的 flag 匹配,检查字符串是否与正则表达式 regex 匹配 支持数据类型:CharSequence

实现示例

@Size

从上文可知,规范中,@Size 支持的数据类型有:CharSequence,Collection,Map, arrays hibernate-validator 中的实现如下:

file

针对 CharSequence、Collection、Map 都有一个实现,由于 arrays 有多种可能,提供了多个实现。 其中,SizeValidatorForCollection.java 如下:

import java.lang.invoke.MethodHandles;
import java.util.Collection;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.constraints.Size;

@SuppressWarnings("rawtypes")
// as per the JLS, Collection<?> is a subtype of Collection, so we need to explicitly reference
// Collection here to support having properties defined as Collection (see HV-1551)
public class SizeValidatorForCollection implements ConstraintValidator<Size, Collection> {

	private  static final Log LOG = LoggerFactory.make( MethodHandles.lookup() );

	private int min;
	private int max;

	@Override
	public void initialize(Size parameters) {
		min = parameters.min();
		max = parameters.max();
		validateParameters();
	}
    
	@Override
	public boolean isValid(Collection collection, ConstraintValidatorContext constraintValidatorContext) {
		if ( collection == null ) {
			return true;
		}
		int length = collection.size();
		return length >= min && length <= max;
	}

	private void validateParameters() {
		if ( min < 0 ) {
			throw LOG.getMinCannotBeNegativeException();
		}
		if ( max < 0 ) {
			throw LOG.getMaxCannotBeNegativeException();
		}
		if ( max < min ) {
			throw LOG.getLengthCannotBeNegativeException();
		}
	}
}

实现逻辑就是按照规范的说明来实现的。

实战

声明 Java Bean 约束

可以用以下方式声明约束:

  1. 字段级别约束
@NotNull
private String manufacturer;
  1. 属性级别约束
@NotNull
public String getManufacturer(){
  return manufacturer;
}
  1. 容器级别约束
private Map<@NotNull FuelConsumption, @MaxAllowedFuelConsumption Integer> fuelConsumption = new HashMap<>();
  1. 类级别约束 在这种情况下,验证的对象不是单个属性,而是完整的对象。如果验证依赖于对象的多个属性之间的相关性,则类级约束非常有用。 如:汽车中,乘客数量不能大于座椅数量,否则超载
@ValidPassengerCount
public class Car {

    private int seatCount;

    private List<Person> passengers;

    //...
}
  1. 约束继承 当一个类继承/实现另一个类时,父类声明的所有约束也会应用在子类继承的对应属性上。 如果方法重写,约束注解将会聚合,也就是此方法父类和子类声明的约束都会起作用。

  2. 级联验证 Bean Validation API 不仅允许验证单个类实例,也支持级联验证。 只需使用 @Valid 修饰对象属性的引用,则对象属性中声明的所有约束也会起作用。 如以下示例,当验证 Car 实例时,Person 对象中的 name 字段也会验证。

public class Car {
    @NotNull
    @Valid
    private Person driver;
    //...
}
public class Person {
    @NotNull
    private String name;
    //...
}

声明方法约束

参数约束

通过向方法或构造函数的参数添加约束注解来指定方法或构造函数的前置条件,官方示例如下:

public RentalStation(@NotNull String name){}

public void rentCar(@NotNull Customer customer,
                          @NotNull @Future Date startDate,
                          @Min(1) int durationInDays){}

返回值约束

通过在方法体上添加约束注解来给方法或构造函数指定后置条件,官方示例如下:

public class RentalStation {
    @ValidRentalStation
    public RentalStation() {
        //...
    }
    @NotNull
    @Size(min = 1)
    public List<@NotNull Customer> getCustomers() {
        //...
        return null;
    }
}

此示例指定了三个约束:

  • 任何新创建的 RentalStation 对象都必须满足 @validRentalStation 约束
  • getCustomers() 返回的客户列表不能为空,并且必须至少包含 1 个元素
  • getCustomers() 返回的客户列表不能包含空对象

级联约束

类似于 JavaBeans 属性的级联验证,@Valid 注解可用于标记方法参数和返回值的级联验证。

类似于 javabeans 属性的级联验证(参见第 2.1.6 节“对象图”),@valid 注释可用于标记可执行参数和级联验证的返回值。当验证用@valid 注释的参数或返回值时,也会验证在参数或返回值对象上声明的约束。 而且,也可用在容器元素中。


public class Garage {
    public boolean checkCars(@NotNull List<@Valid Car> cars) {
        //...
        return false;
    }
}

继承验证

当在继承体系中声明方法约束时,必须了解两个规则:

  • 方法调用方要满足前置条件不能在子类型中得到加强
  • 方法调用方要保证后置条件不能再子类型中被削弱

这些规则是由子类行为概念所决定的:在使用类型 T 的任何地方,也能在不改变程序行为的情况下使用 T 的子类。

当两个类分别有一个同名且形参列表相同的方法,而另一个类用一个方法重写/实现上述两个类的同名方法时,这两个父类的同名方法上不能有任何参数约束,因为不管怎样都会与上述规则冲突。 示例:

public interface Vehicle {
  void drive(@Max(75) int speedInMph);
}
public interface Car {
  void drive(int speedInMph);
}

public class RacingCar implements Car, Vehicle {
  @Override
  public void drive(int speedInMph) {
      //...
  }
}

分组约束

请求组

注意:上述的 22 个约束注解都有 groups 属性。当不指定 groups 时,默认为 Default 分组。

JSR 规范支持手动校验,不直接支持使用注解校验,不过 spring 提供了分组校验注解扩展支持,即:@Validated,参数为 group 类集合

分组继承

在某些场景下,需要定义一个组,它包含其它组的约束,可以用分组继承。 如:


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 {}

定义分组序列

默认情况下,不管约束是属于哪个分组,它们的计算是没有特定顺序的,而在某些场景下,控制约束的计算顺序是有用的。 如:先检查汽车的默认约束,再检查汽车的性能约束,最后在开车前,检查驾驶员的实际约束。 可以定义一个接口,并用 @GroupSequence 来定义需要验证的分组的序列。 示例:

@GroupSequence({ Default.class, CarChecks.class, DriverChecks.class })
public interface OrderedChecks {}

此分组用法与其它分组一样,只是此分组拥有按分组顺序校验的功能

定义序列的组和组成序列的组不能通过级联序列定义或组继承直接或间接地参与循环依赖关系。如果对包含此类循环的组计算,则会引发 GroupDefinitionException。

重新定义默认分组序列

@GroupSequence

@GroupSequence 除了定义分组序列外,还允许重新定义指定类的默认分组。为此,只需将@GroupSequence 添加到类中,并在注解中用指定序列的分组替换 Default 默认分组。

@GroupSequence({ RentalChecks.class, CarChecks.class, RentalCar.class })
public class RentalCar extends Car {}

在验证约束时,直接把其当做默认分组方式来验证

@GroupSequenceProvider

注意:此为 hibernate-validator 提供,JSR 规范不支持

可用于根据对象状态动态地重新定义默认分组序列。 需要做两步:

  1. 实现接口:DefaultGroupSequenceProvider
  2. 在指定类上使用 @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 开始使用,它允许您在级联验证期间使用与最初请求的组不同的组。

在可以使用 @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;

创建自定义约束

简单约束

三个步骤:

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

创建约束注解

此处示例展示编写一个注解,确保给定字符串全是大写或全是小写。 首先,定义一个枚举,列出所有情况:大写、小写

public enum CaseMode{
  UPPER,
  LOWER;
}

然后,定义一个约束注解

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@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.hibernate.validator.referenceguide.chapter06.CheckCase.message}";
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
    CaseMode value();

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

Bean Validation API 规范要求任何约束注解定义以下要求:

  • 一个 message 属性:在违反约束的情况下返回一个默认 key 以用于创建错误消息
  • 一个 groups 属性:允许指定此约束所属的验证分组。必须默认是一个空 Class 数组
  • 一个 payload 属性:能被 Bean Validation API 客户端使用,以自定义一个注解的 payload 对象。API 本身不使用此属性。自定义 payload 可以是用来定义严重程度。如下:
public class Severity{
  public interface Info extends Payload{}
  public interface Error extends Payload{}
}
public class ContactDetails{
  @NotNull(message="名字必填", payload=Severity.Error.class)
  private String name;
  
  @NotNull(message="手机号没有指定,但不是必填项", payload=Severity.Info.class)
  private String phoneNumber;
}

然后客户端在 ContactDetails 实例验证之后,可以通过 ConstraintViolation.getConstraintDescriptor().getPayload() 获取 severity ,然后根据 severity 调整其行为。 此外,约束注解上还修饰了一些元注解:

  • @Target:指定此注解支持的元素类型,比如:FIELD(属性)、METHOD(方法)等
  • @Rentention(RUNTIME):指定此类型的注解将在运行时通过反射方式可用
  • @Constraint():标记注解的类型为约束,指定注解所使用的验证器(写验证逻辑的类),如果约束可以用在多种数据类型中,则每种数据类型对应一个验证器。
  • @Documented:用此注解会被包含在使用方的 JavaDoc 中
  • @Repeatable(List.class):指示注解可以在相同的位置重复多次,通常具有不同的配置。List 包含注解类型。

验证器

创建了一个注解,还需要创建一个约束验证器,以用来验证使用注解的元素。

需要实现 Bean Validation 接口:ConstraintValidator 示例:


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() );
        }
    }
}

ConstraintValidator 指定了两个泛型类型:

  1. 第一个是指定需要验证的注解类
  2. 第二个是指定要验证的数据类型,当注解支持多种类型时,就要写多个实现类,并分别指定对应的类型

需要实现两个方法:

  • initialize() 让你可以获取到使用注解时所指定的参数(可以将它们保存起来以供下一步使用)
  • isValid() 包含实际的校验逻辑。注意:Bean Validation 规范建议将 null 值视为有效值。如果一个元素 null 不是一个有效值,则应该显示的用 @NotNull 标注。

isValid() 方法中的 ConstraintValidatorContext 对象参数:

当应用指定约束验证器时,提供上下文数据和操作。

此对象至少有一个 ConstraintViolation,可以是默认的,或者自定义的。


@Override
public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
    if ( object == null ) {
        return true;
    }

    boolean isValid;
    if ( caseMode == CaseMode.UPPER ) {
        isValid = object.equals( object.toUpperCase() );
    }
    else {
        isValid = object.equals( object.toLowerCase() );
    }

    if ( !isValid ) {
    // 禁用默认 ConstraintViolation,并自定义一个
        constraintContext.disableDefaultConstraintViolation();
        constraintContext.buildConstraintViolationWithTemplate(
                "{org.hibernate.validator.referenceguide.chapter06." +
                "constraintvalidatorcontext.CheckCase.message}"
        )
        .addConstraintViolation();
    }

    return isValid;
}

以上官方示例展示了禁用默认消息并自定义了一个错误消息提示。 hibernate-validator 提供了一个 ConstraintValidator 扩展接口,如下,此处不作详细介绍。

public interface HibernateConstraintValidator<A extends Annotation, T> extends ConstraintValidator<A, T> {
  default void initialize(ConstraintDescriptor<A> constraintDescriptor, HibernateConstraintValidatorInitializationContext initializationContext) {}
}

传递 payload 参数给验证器

目前需要通过 HibernateConstraintValidator 实现,参考以下官方示例,此处不作详细介绍。

HibernateValidatorFactory hibernateValidatorFactory = Validation.byDefaultProvider()
        .configure()
        .buildValidatorFactory()
        .unwrap( HibernateValidatorFactory.class );

Validator validator = hibernateValidatorFactory.usingContext()
        .constraintValidatorPayload( "US" )
        .getValidator();

// [...] US specific validation checks
validator = hibernateValidatorFactory.usingContext()
        .constraintValidatorPayload( "FR" )
        .getValidator();

// [...] France specific validation checks
public class ZipCodeValidator implements ConstraintValidator<ZipCode, String> {

    public String countryCode;

    @Override
    public boolean isValid(String object, ConstraintValidatorContext constraintContext) {
        if ( object == null ) {
            return true;
        }

        boolean isValid = false;

        String countryCode = constraintContext
                .unwrap( HibernateConstraintValidatorContext.class )
                .getConstraintValidatorPayload( String.class );

        if ( "US".equals( countryCode ) ) {
            // checks specific to the United States
        }
        else if ( "FR".equals( countryCode ) ) {
            // checks specific to France
        }
        else {
            // ...
        }

        return isValid;
    }
}

message

当违反约束时,应该用到的消息 需要定义一个 ValidationMessages.properties文件,并记录以下内容:

# org.hibernate.validator.referenceguide.chapter06.CheckCase 是注解 CheckCase 的全类名
org.hibernate.validator.referenceguide.chapter06.CheckCase.message=Case mode must be {value}.

如果发生验证错误,验证运行时将使用为注解的 message 属性指定的默认值来查找此资源包中的错误消息。

类级别约束

类级别约束,用来验证整个对象的状态。其定义方式与上述简单约束定义相同。只不过 @Target 中的值需要包含 TYPE

当做自定义属性注解使用

因为类级别约束验证器可以获取此类实例的所有属性,因此可以用来对其中某些属性做约束。

public class ValidPassengerCountValidator
        implements ConstraintValidator<ValidPassengerCount, Car> {

    @Override
    public void initialize(ValidPassengerCount constraintAnnotation) {}

    @Override
    public boolean isValid(Car car, ConstraintValidatorContext constraintValidatorContext) {
        if ( car == null ) {
            return true;
        }
        // 用来验证两个属性之间必须满足一种关系
        // 验证乘客数量不能大于座椅数量
        boolean isValid = car.getPassengers().size() <= car.getSeatCount();

        if ( !isValid ) {
            constraintValidatorContext.disableDefaultConstraintViolation();
            constraintValidatorContext
                    .buildConstraintViolationWithTemplate( "{my.custom.template}" )
                    .addPropertyNode( "passengers" ).addConstraintViolation();
        }

        return isValid;
    }
}

组合约束

@NotNull
@Size(min = 2, max = 14)
@CheckCase(CaseMode.UPPER)
@Target({ METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE })
@Retention(RUNTIME)
@Constraint(validatedBy = { })
@Documented
public @interface ValidLicensePlate {
    String message() default "{org.hibernate.validator.referenceguide.chapter06." +
            "constraintcomposition.ValidLicensePlate.message}";

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

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

一个注解拥有多个注解的功能,而且此组合注解通常不需要再指定验证器。此注解验证之后会得到违反所有约束的集合,如果想违反其中一个约束之后就有对应的违约信息,可以使用 @ReportAsSingleViolation

//...
@ReportAsSingleViolation
public @interface ValidLicensePlate {

    String message() default "{org.hibernate.validator.referenceguide.chapter06." +
            "constraintcomposition.reportassingle.ValidLicensePlate.message}";

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

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

实操示例


// 实体类
/** 验证参数都设置符合条件的默认值 */
@Data
public class ValidatorVO {

  @NotBlank private String name = "1";

  @Min(0)
  @Max(200)
  private Integer age = 20;

  @PastOrPresent
  @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
  private LocalDateTime birthday = LocalDateTime.now().minusDays(1);

  @Digits(integer = 4, fraction = 2)
  @DecimalMax(value = "1000")
  @DecimalMin(value = "0")
  private BigDecimal money = new BigDecimal(10);

  @Email private String email = "123456@qq.com";

  @NotNull private String username = "username";

  @Size(max = 2)
  private List<String> nickname;

  @Positive /*(message = "身高不能为负数")*/ private Double height = 100D;

  @FutureOrPresent
  @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
  private LocalDateTime nextBirthday = LocalDateTime.now().plusDays(1);
}

在使用此对象时,需要验证,则用 @Valid 注解修饰。

级联验证

注意:需要级联验证的属性需要加上 @Valid 注解修饰,如:

// 验证参数都设置符合条件的默认值
@NotNull @Valid private HairVO hair = new HairVO();

/** 验证参数都设置符合条件的默认值 */
@Data
public class HairVO {
  @Positive private Double length = 10D;
  @Positive private Double Diameter = 1D;
  @NotBlank private String color = "black";
}

分组

请求分组

这里的普通分组,是指单独的一个接口,没有继承

// 分组:使用一个空接口做标识
public interface HasIdGroup {}
@Data
public class ValidatorManual {
  @NotNull(groups = HasIdGroup.class)
  private Integer id;
}
  /**
   * 分组校验
   * 分组不匹配时,校验注解不起作用,注意:Default 分组也不起作用
   * <p>
   * 不同于 JSR-303(javax.validate) 规范的实现,提供 JSR-303 group 的扩展实现
   */
  @PostMapping
  public boolean addUser(@Validated(NoIdGroup.class) ValidatorVO user, BindingResult result) {
    if (result.hasErrors()) {
      for (ObjectError error : result.getAllErrors()) {
        log.error(error.getDefaultMessage());
      }
      return false;
    }
    return true;
  }

  /**
   * 分组校验
   * 分组匹配时,校验注解起作用,但这里只校验 HasIdGroup 分组,默认分组不校验
   * <p>
   * 不同于 JSR-303(javax.validate) 规范的实现,提供 JSR-303 group 的扩展实现
   */
  @PutMapping
  public boolean updateUser(@Validated(HasIdGroup.class) ValidatorVO user, BindingResult result) {
    if (result.hasErrors()) {
      for (ObjectError error : result.getAllErrors()) {
        log.error(error.getDefaultMessage());
      }
      return false;
    }
    return true;
  }

分组继承

如果想要默认分组起作用,而其他分组也要校验,怎么操作呢? 可以在使用的时候,指定校验多个分组,如下:

public boolean addUser1(@Validated({Default.class,NoIdGroup.class}) 
ValidatorVO user, BindingResult result){}

但因为此处,是想 Default 分组一直都要校验,每次都带上有些赘余,因此建议分组在定义的时候继承默认分组,如下:

public interface DefaultInherGroup extends Default {}
/** 验证参数都设置符合条件的默认值 */
@Data
public class ValidatorVO {
 
  @NotNull (groups = HasIdGroup.class)
  // 再加上继承分组
  @NotNull (groups = DefaultInherGroup.class)
  private Integer id = 1;
}

测试

简单测试


/**
 * 接口,需要测试的对象用 @Valid 修饰
 */
@Slf4j
@RequestMapping("/user")
@RestController
public class ValidatorController {

  @GetMapping
  public boolean getUser(@Valid ValidatorVO user, BindingResult result) {
    if (result.hasErrors()) {
      for (ObjectError error : result.getAllErrors()) {
        log.error(error.getDefaultMessage());
      }
      return false;
    }
    return true;
  }
}


// 测试类

@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringBootExampleApplicationTests {

  @Autowired WebApplicationContext context;

  private MockMvc mvc;
  private DateTimeFormatter formatter;

  @Before
  public void setMvc() throws Exception {
    mvc = MockMvcBuilders.webAppContextSetup(context).build();
    formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
  }

  @Test
  public void verificationFailedWhenNameIsBlank() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("name", ""))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenAgeGreaterThan200() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("age", "201"))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenBirthdayIsFuture() throws Exception {
    mvc.perform(
            MockMvcRequestBuilders.get("/user")
                .param("birthday", formatter.format(LocalDateTime.now().plusDays(1))))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenMoneyGreaterThan1000() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("money", "1001"))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenFractionOverflow() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("money", "999.222"))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenFractionOverflowAndGreaterThan1000() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("money", "1001.222"))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenEmailNotMatchFormat() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("email", "111222@"))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenUsernameIsNull() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("username", null))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenNicknameGreaterThan2() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("nickname", "小明", "小蓝", "小兰"))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenHeightIsNotPositive() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("height", "0"))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void verificationFailedWhenNextBirthdayIsPast() throws Exception {
    mvc.perform(
            MockMvcRequestBuilders.get("/user")
                .param("nextBirthday", formatter.format(LocalDateTime.now().minusDays(1))))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }
}

级联测试

  /** 级联验证:当验证属性对象中包含的一个属性不满足要求,则验证失败 */
  @Test
  public void verificationFailedWhenPropertiesNotPassVerification() throws Exception {
    mvc.perform(MockMvcRequestBuilders.get("/user").param("hair.length", "-1"))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

分组测试

请求分组

// ValidatorController.java
  /**
   * 分组校验
   * 分组不匹配时,校验注解不起作用
   * <p>
   * 不同于 JSR-303(javax.validate) 规范的实现,提供 JSR-303 group 的扩展实现
   */
  @PostMapping
  public boolean addUser(@Validated(NoIdGroup.class) ValidatorVO user, BindingResult result) {
    if (result.hasErrors()) {
      for (ObjectError error : result.getAllErrors()) {
        log.error(error.getDefaultMessage());
      }
      return false;
    }
    return true;
  }
  /**
   * 分组校验
   * 分组匹配时,校验注解起作用
   * <p>
   * 不同于 JSR-303(javax.validate) 规范的实现,提供 JSR-303 group 的扩展实现
   */
  @PutMapping
  public boolean updateUser(@Validated(HasIdGroup.class) ValidatorVO user, BindingResult result) {
    if (result.hasErrors()) {
      for (ObjectError error : result.getAllErrors()) {
        log.error(error.getDefaultMessage());
      }
      return false;
    }
    return true;
  }
  
  /**
   * 分组校验
   * 指定多个分组进行匹配
   * <p>
   * 不同于 JSR-303(javax.validate) 规范的实现,提供 JSR-303 group 的扩展实现
   */
  @PostMapping("/1")
  public boolean addUser1(@Validated({Default.class,NoIdGroup.class}) ValidatorVO user, BindingResult result) {
    if (result.hasErrors()) {
      for (ObjectError error : result.getAllErrors()) {
        log.error(error.getDefaultMessage());
      }
      return false;
    }
    return true;
  }
/** 注解校验,此种方式是由 spring 注解提供 */
  @Test
  public void validateFailedWhenGroupMatched() throws Exception {
    mvc.perform(MockMvcRequestBuilders.put("/user").param("id", ""))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }

  @Test
  public void validateSucWhenGroupNotMatched() throws Exception {
    mvc.perform(MockMvcRequestBuilders.post("/user").param("id", "").param("name", ""))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }
  
    /** 匹配的分组起作用,不匹配的不起作用 */
  @Test
  public void validateFailedByGroup() throws Exception {
    mvc.perform(MockMvcRequestBuilders.post("/user/1").param("id", "").param("name", ""))
        .andExpect(MockMvcResultMatchers.status().isOk())
        .andExpect(MockMvcResultMatchers.content().string("true"));
  }
/** 手动使用工具校验,此种方式由 JSR 规范提供 */
  @Test
  public void validateSucWhenGroupNotMatched() {
    ValidatorManual vm = new ValidatorManual();
    Set<ConstraintViolation<ValidatorManual>> validateResult = validator.validate(vm);
    assertEquals(0, validateResult.size());
  }

  @Test(expected = AssertionError.class)
  public void validateFailedWhenGroupMatched() {
    ValidatorManual vm = new ValidatorManual();
    Set<ConstraintViolation<ValidatorManual>> validateResult =
        validator.validate(vm, HasIdGroup.class);
    for (ConstraintViolation msg : validateResult) {
      log.error(msg.getMessage());
    }
    assertEquals(0, validateResult.size());
  }

分组继承

  // ValidatorController.java
@GetMapping("/1")
public boolean getUser1(@Validated(DefaultInherGroup.class) ValidatorVO user, BindingResult result) {
if (result.hasErrors()) {
  for (ObjectError error : result.getAllErrors()) {
    log.error(error.getDefaultMessage());
  }
  return false;
}
return true;
}
// 测试类
@Test
public void validateFailedWhenGroupMatched1() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/user/1").param("id", "").param("name", ""))
    .andExpect(MockMvcResultMatchers.status().isOk())
    .andExpect(MockMvcResultMatchers.content().string("true"));
}

进一步的了解

hibernate-validator 是根据 Java SPI 机制提供的接口,因此使用的时候只要类路径有实现类存在,代码中尽管用 javax.validate.xxxx 就可以了,如果需要切换实现类,换掉实现类就行了,使用的代码不需要改。

使用场景

需要验证数据的地方很多,使用这样一个校验框架,会方便太多,代码少了,bug 少了,如果认为提示方式不够友好,可以合理扩展消息提醒、消息国际化等,也可以用 AOP 统一处理验证信息。

参考资料

Bean Validation 2.0 (JSR 380)

hibernate-validator 最新版官方资料

hibernate-validator | github 公众号:逸飞兮(专注于 Java 领域知识的深入学习,从源码到原理,系统有序的学习)

逸飞兮