Bean Validation完結篇:你必須關注的邊邊角角(約束級聯、自定義約束、自定義校驗器、國際化失敗消息...)

每篇一句

沒有任何技術方案會是一種銀彈,任何東西都是有利弊的前端

相關閱讀

【小家Java】深刻了解數據校驗:Java Bean Validation 2.0(JSR30三、JSR34九、JSR380)Hibernate-Validation 6.x使用案例【小家Spring】Spring方法級別數據校驗:@Validated + MethodValidationPostProcessor優雅的完成數據校驗動做【小家Java】深刻了解數據校驗(Bean Validation):從深處去掌握@Valid的做用(級聯校驗)以及經常使用約束註解的解釋說明java

對Spring感興趣可掃碼加入wx羣: Java高工、架構師3羣(文末有二維碼)

前言

通常來講,對於web項目咱們都有必要對請求參數進行校驗,有的前端使用JavaScript校驗,可是爲了安全起見後端的校驗都是必須的。所以數據校驗不只僅是在web下,在方方面面都是一個重要的點。前端校驗有它的JS校驗框架(好比我以前用的jQuery Validation Plugin),後端天然也少不了。程序員

前面洋洋灑灑已經把數據校驗Bean Validation講了不少了,若是你已經運用在你的項目中,勢必將大大提升生產力吧,本文做爲完結篇(不是總結篇)就不用再系統性的介紹Bean Validation他了,而是旨在介紹你在使用過程當中不得不關心的周邊、細節~web

若是說前面是用機,那麼本文就有點玩機的意思~spring

BV(Bean Validation)的使用範圍

本次再次強調了這一點(設計思想是我認爲特別重要的存在):使用範圍。Bean Validation並不侷限於應用程序的某一層或者哪一種編程模型, 它能夠被用在任何一層, 除了web程序,也能夠是像Swing這樣的富客戶端程序中(GUI編程)。編程

我抄了一副業界著名的圖給你們:在這裏插入圖片描述Bean Validation的目標是簡化Bean校驗,將以往重複的校驗邏輯進行抽象和標準化,造成統一API規範;後端

說到抽象統一API,它可不是亂來的,只有當你能最大程度的獲得公有,這個動做纔有意義,至少它通常都是與業務無關的。抽象能力是對程序員分級的最重要標準之一數組

約束繼承

若是子類繼承自他的父類,除了校驗子類,同時還會校驗父類,這就是約束繼承(一樣適用於接口)。緩存

// child和person上標註的約束都會被執行
public class Child extends Person {
    ...
}複製代碼

注意:若是子類覆蓋了父類的方法,那麼子類和父類的約束都會被校驗安全

約束級聯(級聯校驗)

若是要驗證屬性關聯的對象,那麼須要在屬性上添加@Valid註解,若是一個對象被校驗,那麼它的全部的標註了@Valid的關聯對象都會被校驗,這些對象也能夠是數組、集合、Map等,這時會驗證他們持有的全部元素

Demo

@Getter
@Setter
@ToString
public class Person {

    @NotNull
    private String name;
    @NotNull
    @Positive
    private Integer age;

    @Valid
    @NotNull
    private InnerChild child;
    @Valid // 讓它校驗List裏面全部的屬性
    private List<InnerChild> childList;

    @Getter
    @Setter
    @ToString
    public static class InnerChild {
        @NotNull
        private String name;
        @NotNull
        @Positive
        private Integer age;
    }

}複製代碼

校驗程序:

public static void main(String[] args) {
        Person person = new Person();
        person.setName("fsx");
        Person.InnerChild child = new Person.InnerChild();
        child.setName("fsx-age");
        child.setAge(-1);
        person.setChild(child);

        // 設置childList
        person.setChildList(new ArrayList<Person.InnerChild>(){{
            Person.InnerChild innerChild = new Person.InnerChild();
            innerChild.setName("innerChild1");
            innerChild.setAge(-11);
            add(innerChild);

            innerChild = new Person.InnerChild();
            innerChild.setName("innerChild2");
            innerChild.setAge(-12);
            add(innerChild);
        }});

        Validator validator = Validation.byProvider(HibernateValidator.class).configure().failFast(false)
                .buildValidatorFactory().getValidator();
        Set<ConstraintViolation<Person>> result = validator.validate(person);

        // 輸出錯誤消息
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue())
                .forEach(System.out::println);
    }複製代碼

打印校驗失敗的消息:

age 不能爲null: null
childList[0].age 必須是正數: -11
child.age 必須是正數: -1
childList[1].age 必須是正數: -12複製代碼

約束失敗消息message自定義

每一個約束定義中都包含有一個用於提示驗證結果的消息模版message,而且在聲明一個約束條件的時候,你能夠經過這個約束註解中的message屬性來重寫默認的消息模版(這是自定義message最簡單的一種方式)。

若是在校驗的時候,這個約束條件沒有經過,那麼你配置的MessageInterpolator插值器會被用來當成解析器來解析這個約束中定義的消息模版, 從而獲得最終的驗證失敗提示信息。默認使用的插值器是org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator,它藉助org.hibernate.validator.spi.resourceloading.ResourceBundleLocator來獲取到國際化資源屬性文件從而填充模版內容~

資源解析器默認使用的實現是PlatformResourceBundleLocator,在配置Configuration初始化的時候默認被賦值:

private ConfigurationImpl() {
        this.validationBootstrapParameters = new ValidationBootstrapParameters();

        // 默認的國際化資源文件加載器USER_VALIDATION_MESSAGES值爲:ValidationMessages
        // 這個值就是資源文件的文件名~~~~
        this.defaultResourceBundleLocator = new PlatformResourceBundleLocator(
                ResourceBundleMessageInterpolator.USER_VALIDATION_MESSAGES
        );
        this.defaultTraversableResolver = TraversableResolvers.getDefault();
        this.defaultConstraintValidatorFactory = new ConstraintValidatorFactoryImpl();
        this.defaultParameterNameProvider = new DefaultParameterNameProvider();
        this.defaultClockProvider = DefaultClockProvider.INSTANCE;
    }複製代碼

這個解析器會嘗試解析模版中的佔位符( 大括號括起來的字符串,形如這樣{xxx})。它解析message的核心代碼以下(好比此處message模版是{javax.validation.constraints.NotNull.message}爲例):

public abstract class AbstractMessageInterpolator implements MessageInterpolator {
    ...
    private String interpolateMessage(String message, Context context, Locale locale) throws MessageDescriptorFormatException {
        // 若是message消息木有佔位符,那就直接返回  再也不處理了~
        // 這裏自定義的優先級是最高的~~~
        if ( message.indexOf( '{' ) < 0 ) {
            return replaceEscapedLiterals( message );
        }

        // 調用resolveMessage方法處理message中的佔位符和el表達式
        if ( cachingEnabled ) {
            resolvedMessage = resolvedMessages.computeIfAbsent( new LocalizedMessage( message, locale ), lm -> resolveMessage( message, locale ) );
        } else {
            resolvedMessage = resolveMessage( message, locale );
        }    
        ...
    }

    private String resolveMessage(String message, Locale locale) {
        String resolvedMessage = message;

        // 獲取資源ResourceBundle三部曲
        ResourceBundle userResourceBundle = userResourceBundleLocator.getResourceBundle( locale );
        ResourceBundle constraintContributorResourceBundle = contributorResourceBundleLocator.getResourceBundle( locale );
        ResourceBundle defaultResourceBundle = defaultResourceBundleLocator.getResourceBundle( locale );
        ...
    }
}複製代碼

對如上message的處理步驟大體總結以下:

  1. 若沒佔位符符號{須要處理,直接返回(好比咱們自定義message屬性值全是文字,就直接返回了)~
  2. 佔位符或者EL,交給resolveMessage()方法從資源文件裏拿內容來處理~
  3. 拿取資源文件,按照以下三個步驟尋找:
    1. userResourceBundleLocator:去用戶本身的classpath裏面去找資源文件(默認名字是ValidationMessages.properties,固然你也可使用國際化名)
    2. contributorResourceBundleLocator:加載貢獻的資源包
    3. defaultResourceBundle:默認的策略。去這裏於/org/hibernate/validator加載ValidationMessages.properties
  4. 須要注意的是,如上是加載資源的順序。不管怎麼樣,這三處的資源文件都會加載進內存的(並沒有短路邏輯)。進行佔位符匹配的時候,依舊遵照這規律
    1. 最早用本身當前項目classpath下的資源去匹配資源佔位符,若沒匹配上再用下一級別的資源~~~
    2. 規律同上,依次類推,遞歸的匹配全部的佔位符(若佔位符沒匹配上,原樣輸出,並非輸出null哦~)

      須要注意的是,由於{在此處是特殊字符,若你就想輸出{,請轉義:{

瞭解了這些以後,想自定義失敗消息message,就簡直不要太簡單了好很差,例子以下:

@Min(value = 10, message = "{com.fsx.my.min.message}")
    private Integer age;複製代碼

寫一個資源屬性文件,命名爲ValidationMessages.properties放在類路徑下,文件內容以下:

// 此處可使用佔位符{value}讀取註解對應屬性上的值
com.fsx.my.min.message=[自定義消息]最小值必須是{value}複製代碼

運行測試用例,打印輸出以下失敗消息:

age [自定義消息]最小值必須是10: -1複製代碼

完美(自定義的生效了)

說明:由於個人平臺是中文的,所以文件命名爲ValidationMessages_zh_CN.properties的效果也是同樣的,由於Hibernate Validation提供了Locale國際化的支持

---

Spring環境下自定義國際化消息

上面使用的是Hibernate Validation內置的對國際化的支持,因爲大部分狀況下咱們都是在Spring環境下使用數據校驗,所以有必要講講Spring加持狀況下的國家化作法。咱們知道Spring MVC是有專門作國際化的模塊的,所以國際化這個動做固然也是能夠交給Spring本身來作的,此處我也給一個Demo吧:

說明:即便在Spring環境下,你照常使用Hibernate Validation的國際化方案,依舊是沒有問題的~

一、向容器內配置驗證器(含有本身的國際化資源文件):

@Configuration
public class RootConfig {

    @Bean
    public LocalValidatorFactoryBean localValidatorFactoryBean() {
        LocalValidatorFactoryBean localValidatorFactoryBean = new LocalValidatorFactoryBean();

        // 使用Spring加載國際化資源文件
        //ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        //messageSource.setBasename("MyValidationMsg");

        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        messageSource.setBasename("MyValidationMsg"); // 注意此處名字就隨意啦,畢竟交給spring了`.properties`就不須要了哦
        messageSource.setCacheSeconds(120); // 緩存時長
        // messageSource.setFileEncodings(); // 設置編碼 UTF-8

        localValidatorFactoryBean.setValidationMessageSource(messageSource);
        return localValidatorFactoryBean;
    }
}複製代碼

運行單測:

@Slf4j
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {RootConfig.class})
public class TestSpringBean {

    @Autowired
    private LocalValidatorFactoryBean localValidatorFactoryBean;

    @Test
    public void test1() {
        Person person = new Person();
        person.setAge(-5);

        Validator validator = localValidatorFactoryBean.getValidator();
        Set<ConstraintViolation<Person>> result = validator.validate(person);

        // 輸出錯誤消息
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue())
                .forEach(System.out::println);
    }

}複製代碼

打印校驗失敗消息以下(完美生效):

age [自定義消息]最小值必須是10: -5複製代碼

說明:如果Spring應用,若是你還須要考慮國際化的話,我我的建議使用Spring來處理國際化,而不是Hibernate~(有種Spring的腦殘粉感受有木有,固然這不是強制的)

Spring MVC中如何自定義全局校驗器Validator

Spring MVC默認配置的(使用的)校驗器的執行代碼以下:

public class WebMvcConfigurationSupport implements ApplicationContextAware, ServletContextAware {\
    ...
    @Bean
    public Validator mvcValidator() {
        Validator validator = getValidator();
        if (validator == null) {
            if (ClassUtils.isPresent("javax.validation.Validator", getClass().getClassLoader())) {
                Class<?> clazz;
                try {
                    String className = "org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean";
                    clazz = ClassUtils.forName(className, WebMvcConfigurationSupport.class.getClassLoader());
                } catch (ClassNotFoundException | LinkageError ex) {
                    throw new BeanInitializationException("Failed to resolve default validator class", ex);
                }
                validator = (Validator) BeanUtils.instantiateClass(clazz);
            } else {
                validator = new NoOpValidator();
            }
        }
        return validator;
    }
    ...
}複製代碼

代碼很簡答,就不逐行解釋了。我概括以下:

  1. Spring MVC中校驗要想自動生效,必須導入了javax.validation.Validator才行,不然是new NoOpValidator()它木有校驗行爲
  2. Spring MVC最終默認使用的校驗器是OptionalValidatorFactoryBeanLocalValidatorFactoryBean的子類)~
  3. 顯然,要想校驗生效@EnableWebMvc也是必須的(SpringBoot環境另說)

那如何自定義一個全局的校驗器呢?最佳作法以下:

@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {
    ...
    @Override
    public Validator getValidator() {
        // return "global" validator
        return new LocalValidatorFactoryBean();
    }
    ...
}複製代碼

固然,你還可使用@InitBinder來設置,甚至能夠細粒度設置到只與當前Controller綁定的校驗器都是可行的(好比你可使用自定校驗器實現各類私有的、比較複雜的邏輯判斷)

==自定義約束==

JSRHibernate支持的約束條件已經足夠強大,應該是能知足咱們絕大部分狀況下的基礎驗證的。若是仍是不能知足業務需求,咱們還能夠自定義約束,也很簡單一事。

JSRHibernate提供的約束註解解釋說明:【小家Java】深刻了解數據校驗(Bean Validation):從深處去掌握@Valid的做用(級聯校驗)以及經常使用約束註解的解釋說明

自定義一個約束分以下三步(說是2步也成):

  1. 自定義一個約束註解
  2. 實現一個校驗器(實現接口:ConstraintValidator)
  3. 定義默認的校驗錯誤信息

給個Demo:此處以自定義一個約束註解來校驗集合的長度範圍@CollectionRange

一、自定義註解(此處使用得比較高級)

@Documented
@Constraint(validatedBy = {})
@SupportedValidationTarget(ValidationTarget.ANNOTATED_ELEMENT)
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Repeatable(value = CollectionRange.List.class)
@Size // 校驗動做委託給Size去完成  因此它本身並不須要校驗器~~~
@ReportAsSingleViolation // 組合組件通常建議標註上
public @interface CollectionRange {

    // 三個必備的基本屬性
    String message() default "{com.fsx.my.collection.message}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    // 自定義屬性  @OverridesAttribute這裏有點方法覆蓋的意思~~~~~~ 子類屬性覆蓋父類的默認值嘛
    @OverridesAttribute(constraint = Size.class, name = "min")
    int min() default 0;
    @OverridesAttribute(constraint = Size.class, name = "max")
    int max() default Integer.MAX_VALUE;

    // 重複註解
    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
    @Retention(RUNTIME)
    @Documented
    public @interface List {
        CollectionRange[] value();
    }
}複製代碼

二、實現一個校驗器此例用不着(下面會有)三、自定義錯誤消息固然,你能夠寫死在message屬性上,可是本處使用配置的方式來~

com.fsx.my.collection.message=[自定義消息]你的集合的長度必須介於{min}和{max}之間(包含邊界值)複製代碼

運行案例:

@Getter
@Setter
@ToString
public class Person {
    @CollectionRange(min = 5, max = 10)
    private List<Integer> numbers;
}

    // 測試用例
    public static void main(String[] args) {
        Person person = new Person();
        person.setNumbers(Arrays.asList(1, 2, 3));

        Validator validator = Validation.byProvider(HibernateValidator.class).configure().failFast(false)
                .buildValidatorFactory().getValidator();
        Set<ConstraintViolation<Person>> result = validator.validate(person);

        // 輸出錯誤消息
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue())
                .forEach(System.out::println);
    }複製代碼

輸出校驗信息以下(校驗成功):

numbers [自定義消息]你的集合的長度必須介於5和10之間(包含邊界值): [1, 2, 3]複製代碼

組合約束

這塊比較簡單,不少狀況下一個字段是須要有多個約束(不爲空且大於0)的。這個時候咱們有兩種作法:

  1. 就在該屬性上標註多個註解便可(推薦
  2. 自定義一個註解,把這些註解封裝起來,造成一個新的約束註解(使用場景相對較少)

自定義message消息==可以使用的變量==

咱們知道約束的失敗消息message裏是可使用{}佔位符來動態取值的,默認狀況下可以取到約束註解裏的全部屬性值,而且也只能取到那些屬性的值

but,有的時候爲了友好展現,咱們須要自定義message裏可取的值怎麼辦呢?下面給個例子,讓你們知道怎麼自定義可以使用佔位符的參數(備註:須要基於自定義註解):

自定義一個性別約束註解

@Documented
@Retention(RUNTIME)
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Constraint(validatedBy = {GenderConstraintValidator.class}) 
public @interface Gender {

    // 三個必備的基本屬性
    String message() default "{com.fsx.my.gender.message}";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    int gender() default 0; //0:男生  1:女生
}複製代碼

配置的消息資源是:

com.fsx.my.gender.message=[自定義消息]此處只能容許性別爲[{zhGenderValue}]的複製代碼

很顯然,此處咱們須要讀取zhGenderValue這個自定義的屬性值,而且但願它是中文。因此看看下面我實現的這個校驗器吧:

public class GenderConstraintValidator implements ConstraintValidator<Gender, Integer> {

    int genderValue;

    @Override
    public void initialize(Gender constraintAnnotation) {
        genderValue = constraintAnnotation.gender();
    }

    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {
        //添加參數  校驗失敗的時候可用
        HibernateConstraintValidatorContext hibernateContext = context.unwrap(HibernateConstraintValidatorContext.class);
        hibernateContext.addMessageParameter("zhGenderValue", genderValue == 0 ? "男" : "女"); // 友好展現
        //hibernateContext.buildConstraintViolationWithTemplate("{zhGenderValue}").addConstraintViolation();

        if (value == null) {
            return false; // null is not valid
        }
        return value == genderValue;
    }
}複製代碼

運行單測:

@Getter
@Setter
@ToString
public class Person {
    @Gender(gender = 0)
    private Integer personGender;
}

    public static void main(String[] args) {
        Person person = new Person();
        person.setPersonGender(1);

        Validator validator = Validation.byProvider(HibernateValidator.class).configure().failFast(false)
                .buildValidatorFactory().getValidator();
        Set<ConstraintViolation<Person>> result = validator.validate(person);

        // 輸出錯誤消息
        result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue())
                .forEach(System.out::println);
    }複製代碼

打印以下:

personGender [自定義消息]此處只能容許性別爲[男]的: 1複製代碼

完美(效果達到)

總結

若是說前面文章是用機,那這篇能夠稱做是玩機了Bean Validation是java官方定義的bean驗證標準,如今最新的版本爲2.x,hibernate validator做爲其標準實現,對其進行了擴展,增長了多種約束,若是仍然不能知足業務需求,咱們還能夠自定義約束。數據校驗Bean Validation這一大塊的內容到此就告一段落了,但願講解的全部內容能給你實際工做中帶來幫助,祝好~

知識交流

若文章格式混亂,可點擊原文連接-原文連接-原文連接-原文連接-原文連接

==The last:若是以爲本文對你有幫助,不妨點個讚唄。固然分享到你的朋友圈讓更多小夥伴看到也是被做者本人許可的~==

**若對技術內容感興趣能夠加入wx羣交流:`Java高工、架構師3羣`。若羣二維碼失效,請加wx號:fsx641385712(或者掃描下方wx二維碼)。而且備註:"java入羣" 字樣,會手動邀請入羣**

相關文章
相關標籤/搜索