Bean Validation完结篇:你必须关注的边边角角(约束级联、自定义约束、自定义校验器、国际化失败消息...)
每篇一句
没有任何技术方案会是一种银弹,任何东西都是有利弊的
相关阅读
【小家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入群"
字样,会手动邀请入群
关注A哥
Author | A哥(YourBatman) |
---|---|
个人站点 | www.yourbatman.cn |
yourbatman@qq.com | |
微 信 | fsx1056342982 |
活跃平台 |
|
公众号 | BAT的乌托邦(ID:BAT-utopia) |
知识星球 | BAT的乌托邦 |
每日文章推荐 | 每日文章推荐 |