validator 自動化校驗

  • 2019 年 10 月 3 日
  • 筆記

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 380Bean 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 = "[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 統一處理驗證資訊。

參考資料

Bean Validation 2.0 (JSR 380)

hibernate-validator 最新版官方資料

hibernate-validator | github
公眾號:逸飛兮(專註於 Java 領域知識的深入學習,從源碼到原理,系統有序的學習)

逸飛兮