我們在業務中經常會遇到引數校驗問題,比如前端引數校驗、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; |
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註解包含以下幾個內容:
- message:錯誤訊息,示例中的是錯誤碼,可以根據國際化翻譯成不同的語言。
- groups: 分組校驗,不同的分組可以有不同的校驗條件,比如同一個DTO用於create和update時校驗條件可能不一樣。
- 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
本文最先發布至微信公眾號,版權所有,禁止轉載!