一個可以沉迷於技術的程式猿,wx加入加入技術群:fsx641385712 ...
每篇一句
沒有任何技術方案會是一種銀彈,任何東西都是有利弊的
相關閱讀
【小家Java】深入瞭解數據校驗:Java Bean Validation 2.0(JSR303、JSR349、JSR380)Hibernate-Validation 6.x使用案例
【小家Spring】Spring方法級別數據校驗:@Validated + MethodValidationPostProcessor優雅的完成數據校驗動作
【小家Java】深入瞭解數據校驗(Bean Validation):從深處去掌握@Valid的作用(級聯校驗)以及常用約束註解的解釋說明
前言
一般來說,對於web項目我們都有必要對請求參數進行校驗,有的前端使用JavaScript
校驗,但是為了安全起見後端的校驗都是必須的。因此數據校驗不僅僅是在web
下,在方方面面都是一個重要的點。前端校驗有它的JS校驗框架(比如我之前用的jQuery Validation Plugin
),後端自然也少不了。
前面洋洋灑灑已經把數據校驗Bean Validation
講了很多了,如果你已經運用在你的項目中,勢必將大大提高生產力吧,本文作為完結篇(不是總結篇)就不用再系統性的介紹Bean Validation
他了,而是旨在介紹你在使用過程中不得不關心的周邊、細節~
如果說前面是
用機
,那麼本文就有點玩機
的意思~
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
的處理步驟大致總結如下:
- 若沒占位符符號
{
需要處理,直接返回(比如我們自定義message屬性值全是文字,就直接返回了)~ - 有占位符或者EL,交給
resolveMessage()
方法從資源文件里拿內容來處理~ - 拿取資源文件,按照如下三個步驟尋找:
1.userResourceBundleLocator
:去用戶自己的classpath
裡面去找資源文件(預設名字是ValidationMessages.properties
,當然你也可以使用國際化名)
2.contributorResourceBundleLocator
:載入貢獻的資源包
3.defaultResourceBundle
:預設的策略。去這裡於/org/hibernate/validator
載入ValidationMessages.properties
- 需要註意的是,如上是載入資源的順序。無論怎麼樣,這三處的資源文件都會載入進記憶體的(並無短路邏輯)。進行占位符匹配的時候,依舊遵守這規律:
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
的國際化方案,依舊是沒有問題的~
1、向容器內配置驗證器(含有自己的國際化資源文件):
@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;
}
...
}
代碼很簡答,就不逐行解釋了。我歸納如下:
Spring MVC
中校驗要想自動
生效,必須導入了javax.validation.Validator
才行,否則是new NoOpValidator()
它木有校驗行為Spring MVC
最終預設使用的校驗器是OptionalValidatorFactoryBean
(LocalValidatorFactoryBean
的子類)~- 顯然,要想校驗生效
@EnableWebMvc
也是必須的(SpringBoot
環境另說)
那如何自定義一個全局的校驗器呢?最佳做法如下:
@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {
...
@Override
public Validator getValidator() {
// return "global" validator
return new LocalValidatorFactoryBean();
}
...
}
當然,你還可以使用@InitBinder
來設置,甚至可以細粒度設置到只與當前Controller
綁定的校驗器都是可行的(比如你可以使用自定校驗器實現各種私有的、比較複雜的邏輯判斷)
==自定義約束==
JSR
和Hibernate
支持的約束條件已經足夠強大,應該是能滿足我們絕大部分情況下的基礎驗證的。如果還是不能滿足業務需求,我們還可以自定義約束,也很簡單一事。
JSR
和Hibernate
提供的約束註解解釋說明:【小家Java】深入瞭解數據校驗(Bean Validation):從深處去掌握@Valid的作用(級聯校驗)以及常用約束註解的解釋說明
自定義一個約束分如下三步(說是2步也成):
- 自定義一個約束註解
- 實現一個校驗器(實現介面:
ConstraintValidator
) - 定義預設的校驗錯誤信息
給個Demo
:此處以自定義一個約束註解來校驗集合的長度範圍:@CollectionRange
1、自定義註解(此處使用得比較高級)
@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();
}
}
2、實現一個校驗器
此例用不著(下麵會有)
3、自定義錯誤消息
當然,你可以寫死在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)的。這個時候我們有兩種做法:
- 就在該屬性上標註多個註解即可(推薦)
- 自定義一個註解,把這些註解封裝起來,形成一個新的約束註解(使用場景相對較少)
自定義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入群"
字樣,會手動邀請入群