Hibernate資料校驗簡介

御狐神 發表於 2021-11-25

我們在業務中經常會遇到引數校驗問題,比如前端引數校驗、Kafka訊息引數校驗等,如果業務邏輯比較複雜,各種實體比較多的時候,我們通過程式碼對這些資料一一校驗,會出現大量的重複程式碼以及和主要業務無關的邏輯。Spring MVC提供了引數校驗機制,但是其底層還是通過Hibernate進行資料校驗,所以有必要去了解一下Hibernate資料校驗和JSR資料校驗規範。

JSR資料校驗規範

Java官方先後釋出了JSR303與JSR349提出了資料合法性校驗提供的標準框架:BeanValidator,BeanValidator框架中,使用者通過在Bean的屬性上標註類似於@NotNull、@Max等標準的註解指定校驗規則,並通過標準的驗證介面對Bean進行驗證。

JSR註解列表

JSR標準中的資料校驗註解如下所示:

註解名 註解資料型別 註解作用 示例
AssertFalse boolean/Boolean 被註釋的元素必須為False @AssertFalse private boolean success;
AssertTrue boolean/Boolean 被註釋的元素必須為True @AssertTrue private boolean success;
DecimalMax BigDecimal/BigInteger/CharSequence/byte/short/int/long及其包裝類 被註釋的值應該小於等於指定的最大值 @DecimalMax("10") private BigDecimal value;
DecimalMin BigDecimal/BigInteger/CharSequence/byte/short/int/long及其包裝類 被註釋的值應該大於等於指定的最小值 @DecimalMin("10") private BigDecimal value;
Digits BigDecimal/BigInteger/CharSequence/byte/short/int/long及其包裝類 integer指定整數部分最大位數,fraction指定小數部分最大位數 @Digits(integer = 10,fraction = 4) private BigDecimal value;
Email CharSequence 字串為合法的郵箱格式 @Email private String email;
Future java中的各種日期型別 指定日期應該在當期日期之後 @Future private LocalDateTime future;
FutureOrPresent java中的各種日期型別 指定日期應該為當期日期或當期日期之後 @FutureOrPresent private LocalDateTime futureOrPresent;
Max BigDecimal/BigInteger/byte/short/int/long及包裝類 被註釋的值應該小於等於指定的最大值 @Max("10") private BigDecimal value;
Min BigDecimal/BigInteger/byte/short/int/long及包裝類 被註釋的值應該大於等於指定的最小值 @Min("10") private BigDecimal value;
Negative BigDecimal/BigInteger/byte/short/int/long/float/double及包裝類 被註釋的值應該是負數 @Negative private BigDecimal value;
NegativeOrZero BigDecimal/BigInteger/byte/short/int/long/float/double及包裝類 被註釋的值應該是0或者負數 @NegativeOrZero private BigDecimal value;
NotBlank CharSequence 被註釋的字串至少包含一個非空字元 @NotBlank private String noBlankString;
NotEmpty CharSequence/Collection/Map/Array 被註釋的集合元素個數大於0 @NotEmpty private List<string> values;
NotNull any 被註釋的值不為空 @NotEmpty private Object value;
Null any 被註釋的值必須空 @Null private Object value;
Past java中的各種日期型別 指定日期應該在當期日期之前 @Past private LocalDateTime past;
PastOrPresent java中的各種日期型別 指定日期應該在當期日期或之前 @PastOrPresent private LocalDateTime pastOrPresent;
Pattern CharSequence 被註釋的字串應該符合給定得到正規表示式 @Pattern(\d*) private String numbers;
Positive BigDecimal/BigInteger/byte/short/int/long/float/double及包裝類 被註釋的值應該是正數 @Positive private BigDecimal value;
PositiveOrZero BigDecimal/BigInteger/byte/short/int/long/float/double及包裝類 被註釋的值應該是正數或0 @PositiveOrZero private BigDecimal value;
Size CharSequence/Collection/Map/Array 被註釋的集合元素個數在指定範圍內 @Size(min=1,max=10) private List<string> values;

JSR註解內容

我們以常用的比較簡單的@NotNull註解為例,看看註解中都包含那些內容,如下邊的原始碼所示,可以看到@NotNull註解包含以下幾個內容:

  1. message:錯誤訊息,示例中的是錯誤碼,可以根據國際化翻譯成不同的語言。
  2. groups: 分組校驗,不同的分組可以有不同的校驗條件,比如同一個DTO用於create和update時校驗條件可能不一樣。
  3. payload:BeanValidation API的使用者可以通過此屬性來給約束條件指定嚴重級別. 這個屬性並不被API自身所使用.
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@Documented
@Constraint(validatedBy = { })
public @interface NotNull {
	String message() default "{javax.validation.constraints.NotNull.message}";

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

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

	/**
	 * Defines several {@link NotNull} annotations on the same element.
	 */
	@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
	@Retention(RUNTIME)
	@Documented
	@interface List {

		NotNull[] value();
	}
}

錯誤訊息message、分組group這些功能我們程式中使用比較多,在我介紹Spring Validator資料校驗的文章中有詳細說明,但是關於payload我們接觸的比較少,下面我們舉例說明以下payload的使用,下面的示例中,我們用payload來標識資料校驗失敗的嚴重性,通過以下程式碼。在校驗完一個ContactDetails的示例之後, 你就可以通過呼叫ConstraintViolation.getConstraintDescriptor().getPayload()來得到之前指定到錯誤級別了,並且可以根據這個資訊來決定接下來到行為.

public class Severity {
    public static class Info extends Payload {};
    public static class Error extends Payload {};
}

public class ContactDetails {
    @NotNull(message="Name is mandatory", payload=Severity.Error.class)
    private String name;

    @NotNull(message="Phone number not specified, but not mandatory", payload=Severity.Info.class)
    private String phoneNumber;

    // ...
}

JSR校驗介面

通過前面的JSR校驗註解,我們可以給某個類的對應欄位新增校驗條件,那麼怎麼去校驗這些校驗條件呢?JSR進行資料校驗的核心介面是Validation,該介面的定義如下所示,我們使用比較多的介面應該是<T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups);,該方法可以用於校驗某個Object是否符合指定分組的校驗規則,如果不指定分組,那麼只有預設分組的校驗規則會生效。

public interface Validator {

	/**
	 * Validates all constraints on {@code object}.
	 */
	<T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups);

	/**
	 * Validates all constraints placed on the property of {@code object}
	 * named {@code propertyName}.
	 */
	<T> Set<ConstraintViolation<T>> validateProperty(T object, String propertyName,Class<?>... groups);

	/**
	 * Validates all constraints placed on the property named {@code propertyName}
	 * of the class {@code beanType} would the property value be {@code value}.
	 */
	<T> Set<ConstraintViolation<T>> validateValue(Class<T> beanType, String propertyName, Object value, Class<?>... groups);

	/**
	 * Returns the descriptor object describing bean constraints.
	 * The returned object (and associated objects including
	 * {@link ConstraintDescriptor}s) are immutable.
	 */
	BeanDescriptor getConstraintsForClass(Class<?> clazz);

	/**
	 * Returns an instance of the specified type allowing access to
	 * provider-specific APIs.
	 * <p>
	 * If the Jakarta Bean Validation provider implementation does not support
	 * the specified class, {@link ValidationException} is thrown.call
	 */
	<T> T unwrap(Class<T> type);

	/**
	 * Returns the contract for validating parameters and return values of methods
	 * and constructors.
	 */
	ExecutableValidator forExecutables();
}

Hibernate資料校驗

基於JSR資料校驗規範,Hibernate新增了一些新的註解校驗,然後實現了JSR的Validator介面用於資料校驗。

Hibernate新增註解

註解名 註解資料型別 註解作用 示例
CNPJ CharSequence 被註釋的元素必須為合法的巴西法人國家登記號 @CNPJ private String cnpj;
CPF CharSequence 被註釋的元素必須為合法的巴西納稅人註冊號 @CPF private String cpf;
TituloEleitoral CharSequence 被註釋的元素必須為合法的巴西選民身份證號碼 @TituloEleitoral private String tituloEleitoral;
NIP CharSequence 被註釋的元素必須為合法的波蘭稅號 @NIP private String nip;
PESEL CharSequence 被註釋的元素必須為合法的波蘭身份證號碼 @PESEL private String pesel;
REGON CharSequence 被註釋的元素必須為合法的波蘭區域編號 @REGON private String regon;
DurationMax Duration 被註釋的元素Duration的時間長度小於指定的時間長度 @DurationMax(day=1) private Duration duration;
DurationMin Duration 被註釋的元素Duration的時間長度大於指定的時間長度 @DurationMin(day=1) private Duration duration;
CodePointLength CharSequence 被註釋的元素CodPoint數目在指定範圍內,unicode中每一個字元都有一個唯一的識別碼,這個碼就是CodePoint。比如我們要限制中文字元的數目,就可以使用這個 @CodePointLength(min=1) private String name;
ConstraintComposition 其它資料校驗註解 組合註解的組合關係,與或等關係 ---
CreditCardNumber CharSequence 用於判斷一個信用卡是不是合法格式的信用卡 @CreditCardNumber private String credictCardNumber;
Currency CharSequence 被註釋的元素是指定型別的匯率 @Currency(value = {"USD"}) private String currency;
ISBN CharSequence 被註釋的元素是合法的ISBN號碼 @ISBN private String isbn;
Length CharSequence 被註釋的元素是長度在指定範圍內 @Length(min=1) private String name;
LuhnCheck CharSequence 被註釋的元素可以通過Luhn演算法檢查 @LuhnCheck private String luhn;
Mod10Check CharSequence 被註釋的元素可以通過模10演算法檢查 @Mod10Check private String mod10;
ParameterScriptAssert 方法 引數指令碼校驗 ————
ScriptAssert 類指令碼校驗 ————
UniqueElements 集合 集合中的每個元素都是唯一的 @UniqueElements private List<String> elements;

Hibiernate資料校驗

如何使用Hibernate進行資料校驗呢?我們知道JSR規定了資料校驗的介面Validator,Hibernate用ValidatorImpl類中實現了Validator介面,我們可以通過Hibernate提供的工廠類HibernateValidator.buildValidatorFactory建立一個ValidatorImpl例項。使用Hibernate建立一個Validator例項的程式碼如下所示。

ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
    .configure()
    .addProperty( "hibernate.validator.fail_fast", "true" )
    .buildValidatorFactory();
Validator validator = validatorFactory.getValidator();

Hibernate校驗原始碼

通過上面的內容,我們知道Hibernate可以用工廠方法例項化一個Validator介面的例項,這個例項可以用於帶有校驗註解的校驗JavaBean,那麼Hibernate底層是如何實現這些校驗邏輯的呢?我們以如下JavaBean為例,解析Hibernate校驗的原始碼。

@Data
public class Person {

    @NotBlank
    @Size(max=64)
    private String name;

    @Min(0)
    @Max(200)
    private int age;
}

ConstraintValidator介紹

ConstraintValidator是Hibernate中資料校驗的最細粒度,他可以校驗指定註解和型別的數值是否合法。比如上面例子中的@Max(200)private int age;,對於age欄位的校驗就會使用一個叫MaxValidatorForInteger的ConstraintValidator,這個ConstraintValidator在校驗的時候會判斷指定的數值是不是大於指定的最大值。

public class MaxValidatorForInteger extends AbstractMaxValidator<Integer> {

	@Override
	protected int compare(Integer number) {
		return NumberComparatorHelper.compare( number.longValue(), maxValue );
	}
}

public abstract class AbstractMaxValidator<T> implements ConstraintValidator<Max, T> {

	protected long maxValue;

	@Override
	public void initialize(Max maxValue) {
		this.maxValue = maxValue.value();
	}

	@Override
	public boolean isValid(T value, ConstraintValidatorContext constraintValidatorContext) {
		// null values are valid
		if ( value == null ) {
			return true;
		}

		return compare( value ) <= 0;
	}

	protected abstract int compare(T number);
}

ConstraintValidator初始化

我們在前面的內容中說到Hibernate提供了ValidatorImpl用於資料校驗,那麼ValidatorImpl和ConstraintValidator是什麼關係呢,簡單來說就是ValidatorImpl在初始化的時候會初始化所有的ConstraintValidator,在校驗資料的過程中呼叫這些內建的ConstraintValidator校驗資料。內建ConstraintValidator的對應註解的@Constraint(validatedBy = { })是空的。

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@Documented
@Constraint(validatedBy = { }) // 這兒是空的
public @interface AssertFalse {

	String message() default "{javax.validation.constraints.AssertFalse.message}";

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

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

	/**
	 * Defines several {@link AssertFalse} annotations on the same element.
	 *
	 * @see javax.validation.constraints.AssertFalse
	 */
	@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
	@Retention(RUNTIME)
	@Documented
	@interface List {

		AssertFalse[] value();
	}
}

自定義ConstraintValidator

如果Hibernate和JSR中的註解不夠我用,我需要自定義一個註解和約束條件,我們應該怎麼實現呢。實現一個自定義校驗邏輯一共分兩步:1.註解的實現。2.校驗邏輯的實現。比如我們需要一個校驗欄位狀態的註解,我們可以使用以下示例定義一個註解:

@Target( { METHOD, FIELD, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = StatusValidator.class)
@Documented
public @interface ValidStatus {
    String message() default "狀態錯誤 ";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    /**
     * 有效的狀態值集合,預設{1,2}
     */
    int[] value() default {1,2};
}

實現了註解之後,我們需要實現註解中的@Constraint(validatedBy = StatusValidator.class),示例程式碼如下:

/**
 * 校驗狀態是否屬於指定狀態集
 (ConstraintValidator後指定的泛型物件型別為
 註解類和註解註釋的欄位型別<ValidStatus, Integer>)
 */
public class StatusValidator implements ConstraintValidator<ValidStatus, Integer> {
    private Integer[] validStatus;

    @Override
    public void initialize(ValidStatus validStatus) {
        int[] ints = validStatus.value();
        int n = ints.length;
        Integer[] integers = new Integer[n];
        for (int i = 0; i < n; i++) {
            integers[i] = ints[i];
        }
        this.validStatus = integers;
    }

    @Override
    public boolean isValid(Integer n, ConstraintValidatorContext constraintValidatorContext) {
        List<Integer> status = Arrays.asList(validStatus);
        if (status.contains(n)) {
            return true;
        }
        return false;
    }
}

Validator的特性

四種約束級別

成員變數級別的約束

約束可以通過註解一個類的成員變數來表達。如下程式碼所示:

@Data
public class Person {

    @NotBlank
    @Size(max=64)
    private String name;

    @Min(0)
    @Max(200)
    private int age;
}

屬性約束

如果你的模型類遵循javabean的標準,它也可能註解這個bean的屬性而不是它的成員變數。關於JavaBean的介紹可以看我的另外一篇部落格。

@Data
public class Person {

    private String name;

    @Min(0)
    @Max(200)
    private int age;

    @NotBlank
    @Size(max=64)
    public String getName(){
        return name;
    }
}

集合約束

通過在約束註解的@Target註解在約束定義中指定ElementType.TYPE_USE,就可以實現對容器內元素進行約束

類級別約束

一個約束被放到類級別上,在這種情況下,被驗證的物件不是簡單的一個屬性,而是一個完整的物件。使用類級別約束,可以驗證物件幾個屬性之間的相關性,比如不允許所有欄位同時為null等。

@Data
@NotAllFieldNull
public class Person {

    private String name;

    @Min(0)
    @Max(200)
    private int age;

    @NotBlank
    @Size(max=64)
    public String getName(){
        return name;
    }
}

校驗註解的可繼承性

父類中新增了約束的欄位,子類在進行校驗時也會校驗父類中的欄位。

遞迴校驗

假設我們上面例子中的Person多了一個Address型別的欄位,並且Address也有自己的校驗,我們怎麼校驗Address中的欄位呢?可以通過在Address上新增@Valid註解實現遞迴校驗。

@Data
public class Person {

    private String name;

    @Min(0)
    @Max(200)
    private int age;

    @Valid
    public Address address;
}

@Data
public class Address{

    @NotNull
    private string city;
}

方法引數校驗

我們可以通過在方法引數中新增校驗註解,實現方法級別的引數校驗,當然這些註解的生效需要通過一些AOP實現(比如Spring的方法引數校驗)。


public void createPerson(@NotNull String name,@NotNull Integer age){

}

方法引數交叉校驗

方法也支援引數之間的校驗,比如如下註解不允許建立使用者時候使用者名稱和年齡同時為空,註解校驗邏輯需要自己實現。交叉校驗的引數是Object[]型別,不同引數位置對應不同的Obj。

@NotAllPersonFieldNull
public void createPerson( String name,Integer age){

}

方法返回值校驗

public @NotNull Person getPerson( String name,Integer age){
    return null;
}

分組功能

我在另一篇介紹Spring校驗註解的文章中說過,在Spring的校驗體系中,@Valid註解不支援分組校驗,@Validated註解支援分組校驗。 事實上這並不是JSR註解中的@Valid不支援分組校驗,而是Spring層面把@Valid註解的分組校驗功能遮蔽了。

所以原生的JSR註解和Hibernate校驗都支援分組校驗功能,具體校驗邏輯可以參考我有關Spring資料校驗的文章。

分組繼承

我們知道JSR分組校驗功能是使用註解中的group欄位,group欄位儲存了分組的類別,那麼如果分組的類之間有繼承關係,分組校驗會被繼承嗎?答案是會的。

分組順序

如果我們在校驗的過程中需要指定校驗順序,那麼我們可以給校驗條件分組,分組之後就會按照順序校驗物件中的各個屬性。

GroupSequence({ Default.class, BaseCheck.class, AdvanceCheck.class })
public interface OrderedChecks {
}

Payload

如果我們需要在不同的情況下有不同的校驗方式,比如中英文環境之類的,這種時候用分組就不是很合適了,可以考慮使用PayLoad。使用者可以在初始化Validator時候指定當前環境的payload,然後在校驗環節拿到環境中的payload走不同的校驗流程:

ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
        .configure()
        .constraintValidatorPayload( "US" )
        .buildValidatorFactory();

Validator validator = validatorFactory.getValidator();

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

我是御狐神,歡迎大家關注我的微信公眾號:wzm2zsd

qrcode_for_gh_83670e17bbd7_344-2021-09-04-10-55-16

本文最先發布至微信公眾號,版權所有,禁止轉載!

相關文章