validator 自動化校驗
- 2019 年 10 月 3 日
- 筆記
溫馨提示
請收藏再看。此文篇幅太長,你短時間看不完;此文乾貨太多,錯過太可惜。
示例程式碼可以關注逸飛兮
(公眾號)回復jy
獲取。
收穫
- 講解詳細:能讓你掌握使用
hibernate-validator
及類似校驗工具的各種使用姿勢 - 內容全面:可以當做知識字典來查詢
what
注意:hibernate-validator 與 持久層框架 hibernate
沒有什麼關係,hibernate-validator 是 hibernate 組織下的一個開源項目。
hibernate-validator
是 JSR 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-web
、spring-boot-starter-webflux
包都已引入此依賴,直接使用即可。
有點類似於 slf4j 與 logback(log4j2)的關係,使用的時候,程式碼中使用 javax.validate
提供的介面規範功能,載入的時候,根據 SPI 規範載入對應的規範實現類。
它和 hibernate
沒什麼關係,放心大膽的使用吧。
why
hibernate-validator 官方有如下說明:
以前的校驗如下:
使用 hibernate-validator
後,校驗邏輯如下:
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 子類。
檢查指定的字元序列是否為有效的電子郵件地址。可選參數 regexp
和 flags
允許指定電子郵件必須匹配的附加正則表達式(包括正則表達式標誌)。
支援的數據類型: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 在類路徑中,ReadablePartial
和ReadableInstant
的任何實現類
@FutureOrPresent
檢查日期是現在或將來
支援數據類型:同@Future
@Past
檢查日期是否在過去
支援數據類型:同@Future
@PastOrPresent
檢查日期是否在過去或現在
支援數據類型:同@Future
@Pattern(regex=, flags=)
根據給定的 flag
匹配,檢查字元串是否與正則表達式 regex
匹配
支援數據類型:CharSequence
實現示例
@Size
從上文可知,規範中,@Size 支援的數據類型有:CharSequence,Collection,Map, arrays
hibernate-validator 中的實現如下:
針對 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 約束
可以用以下方式聲明約束:
- 欄位級別約束
@NotNull private String manufacturer;
- 屬性級別約束
@NotNull public String getManufacturer(){ return manufacturer; }
- 容器級別約束
private Map<@NotNull FuelConsumption, @MaxAllowedFuelConsumption Integer> fuelConsumption = new HashMap<>();
- 類級別約束
在這種情況下,驗證的對象不是單個屬性,而是完整的對象。如果驗證依賴於對象的多個屬性之間的相關性,則類級約束非常有用。
如:汽車中,乘客數量不能大於座椅數量,否則超載
@ValidPassengerCount public class Car { private int seatCount; private List<Person> passengers; //... }
-
約束繼承
當一個類繼承/實現另一個類時,父類聲明的所有約束也會應用在子類繼承的對應屬性上。
如果方法重寫
,約束註解將會聚合,也就是此方法父類和子類聲明的約束都會起作用。 -
級聯驗證
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 規範不支援
可用於根據對象狀態動態地重新定義默認分組序列。
需要做兩步:
- 實現介面:DefaultGroupSequenceProvider
- 在指定類上使用 @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
指定了兩個泛型類型:
- 第一個是指定需要驗證的註解類
- 第二個是指定要驗證的數據類型,當註解支援多種類型時,就要寫多個實現類,並分別指定對應的類型
需要實現兩個方法:
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 = "[email protected]"; @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 統一處理驗證資訊。
參考資料
hibernate-validator | github
公眾號:逸飛兮(專註於 Java 領域知識的深入學習,從源碼到原理,系統有序的學習)