背景
代码开发过程中,参数的有效性校验是一项很繁琐的工作, 如果参数简单,就那么几个参数,直接通过ifelse可以搞定,如果参数太多,比如一个大对象有100多个字段作为入参,你如何校验呢? 仍使用ifelse就是体力活了, Hibernate Validator 是很好的选择。
官方文档入口: https://hibernate.org/validator/
文章示例基于6.0版本,可以参考6.0的官方文档:https://docs.jboss.org/hibernate/validator/6.0/reference/en-US/html_single/#validator-gettingstarted
maven依赖
Hibernate validator 依赖
<!-- hibernate validator -->
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.0.13.Final</version>
</dependency>
<dependency>
<groupId>javax.el</groupId>
<artifactId>javax.el-api</artifactId>
<version>3.0.1-b06</version>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>javax.el</artifactId>
<version>2.2.6</version>
</dependency>
为了能让示例代码跑起来的一些必要依赖
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13</version>
</dependency>
支持的校验注解
javax.validation.constraints 包下面的校验注解都支持,如下面这些注解,基本上见名知意, 就不一一解释了
Max 最大值校验
Min 最小值校验
Range 范围校验,Min和Max的组合
NotBlank 不为空白字符的校验
NotEmpty 数组、集合等不为空的校验
NotNull 空指针校验
Email 邮箱格式校验
....
下面通过示例代码来说明校验器常用的几种使用方式: 简单对象校验、分组校验、
简单对象校验
建一个需要检验的参数类:
@Data
public class SimpleBean {
@NotBlank(message = "姓名不能为空")
private String name;
@NotNull(message = "年龄不能为空")
@Range(min = 0, max = 100, message = "年龄必须在{min}和{max}之间")
private Integer age;
@NotNull(message = "是否已婚不能为空")
private Boolean isMarried;
@NotEmpty(message = "集合不能为空")
private Collection collection;
@NotEmpty(message = "数组不能为空")
private String[] array;
@Email
private String email;
/*
真实场景下面可能还有几十个字段
省略 ... ...
*/
}
校验测试
public class ValidateTest {
//初始化一个校验器工厂
private static ValidatorFactory validatorFactory = Validation
.byProvider(HibernateValidator.class)
.configure()
//校验失败是否立即返回: true-遇到一个错误立即返回不在往下校验,false-校验完所有字段才返回
.failFast(false)
.buildValidatorFactory();
Validator validator = validatorFactory.getValidator();
/**
* 简单对象校验
*/
@Test
public void testSimple() {
SimpleBean s=new SimpleBean();
s.setAge(5);
s.setName(" ");
s.setEmail("email");
Set<ConstraintViolation<SimpleBean>> result=validator.validate(s);
System.out.println("遍历输出错误信息:");
//getPropertyPath() 获取属性全路径名
//getMessage() 获取校验后的错误提示信息
result.forEach(r-> System.out.println(r.getPropertyPath()+":"+r.getMessage()));
}
}
测试结果
遍历输出错误信息:
email:不是一个合法的电子邮件地址
collection:集合不能为空
array:数组不能为空
name:姓名不能为空
isMarried:是否已婚不能为空
嵌套对象校验
嵌套对象
上面是简单对象的校验,我们来尝试嵌套对象的校验,类结构如下:
|--OrgBean
|----EmployeeBean
|------List<PersonBean>
OrgBean.java代码,对于嵌套对象校验要注意, 需要在内部引用的对象上用到@Valid注解,否则不会校验被引用对象的内部字段
@Data
public class OrgBean {
@NotNull
private Integer id;
@Valid //如果此处不用Valid注解,则不会去校验EmployeeBean对象的内部字段
@NotNull(message = "employee不能为空")
private EmployeeBean Employee;
}
EmployeeBean.java代码
@Data
public class EmployeeBean {
@Valid
@NotNull(message = "person不能为空")
/**
* 此处用到容器元素级别的约束: List<@Valid @NotNull PersonBean>
* 会校验容器内部元素是否为null,否则为null时会跳过校验
* NotNull注解的target包含ElementType.TYPE_USE,因此NotNull可以给泛型注解
*/
private List<@Valid @NotNull PersonBean> people;
}
PersonBean.java
@Data
public class PersonBean {
@NotBlank(message = "姓名不能为空")
private String name;
@NotNull(message = "年龄不能为空")
@Range(min = 0, max = 100, message = "年龄必须在{min}和{max}之间")
private Integer age;
@NotNull(message = "是否已婚不能为空")
private Boolean isMarried;
@NotNull(message = "是否有小孩不能为空")
private Boolean hasChild;
@NotNull(message = "小孩个数不能为空")
private Integer childCount;
@NotNull(message = "是否单身不能为空")
private Boolean isSingle;
}
校验测试代码
@Test
public void testNested() {
PersonBean p=new PersonBean();
p.setAge(30);
p.setName("zhangsan");
//p.setIsMarried(true);
PersonBean p2=new PersonBean();
p2.setAge(30);
//p2.setName("zhangsan2");
p2.setIsMarried(false);
//p2.setHasChild(true);
OrgBean org=new OrgBean();
//org.setId(1);
List<PersonBean> list=new ArrayList<>();
list.add(p);
list.add(p2);
//增加一个null,测试是否会校验元素为null
list.add(null);
EmployeeBean e=new EmployeeBean();
e.setPeople(list);
org.setEmployee(e);
Set<ConstraintViolation<OrgBean>> result=validator.validate(org);
System.out.println("遍历输出错误信息:");
result.forEach(r-> System.out.println(r.getPropertyPath()+":"+r.getMessage()));
}
测试结果
id:不能为null
Employee.people[0].childCount:小孩个数不能为空
Employee.people[0].isSingle:是否单身不能为空
Employee.people[1].hasChild:是否有小孩不能为空
Employee.people[0].isMarried:是否已婚不能为空
Employee.people[1].name:姓名不能为空
Employee.people[1].childCount:小孩个数不能为空
Employee.people[2].<list element>:不能为null
Employee.people[0].hasChild:是否有小孩不能为空
Employee.people[1].isSingle:是否单身不能为空
结果分析:
(1)可以看到打印结果中校验的属性名有一长串: Employee.people[0].childCount
这是由于ConstraintViolation.getPropertyPath()函数返回的是属性的全路径名称。
(2)还有List元素中的值为null也进行了校验:Employee.people[2].:不能为null
这是因为使用了容器元素级别的校验,这种校验器可以使用在泛型参数里面,如注解在List元素的泛型里面增加@NotNull注解: private List<@Valid @NotNull PersonBean> people;
如果没有该注解,则list.dd(null)添加的空指针元素不会被校验。
/**
* 此处用到容器元素级别的约束 List<@Valid @NotNull PersonBean> 会校验容器内部元素是否为null,否则为null时会跳过校验
* NotNull注解的target包含ElementType.TYPE_USE,因此NotNull可以给泛型注解
*/
private List<@Valid @NotNull PersonBean> people;
Hibernate Validator 约束级别
(1)字段级别: 在字段上面添加校验注解
本质上就是可以添加在字段上的注解,@Target({ElementType.FIELD})。
(2)属性级别: 在方法上面添加注解,如注解在getName()方法上
本质上就是可以添加在方法上的注解,@Target({ElementType.METHOD}) 。
(3)容器级别:在容器里面添加注解
本质上就是可以添加在泛型上的注解,这个是java8新增的特性,@Target({ElementType.TYPE_USE})。
如这些类都可以支持容器级别的校验:java.util.Iterable实现类,java.util.Map的key和values,java.util.Optional,java.util.OptionalInt,java.util.OptionalDouble,java.util.OptionalLong 等, 如:
List<@Valid @NotNull PersonBean> people;
private Map<@Valid Part, List<@Valid Manufacturer>> partManufacturers;
(4)类级别:添加在类上面的校验注解
需要@Target({ElementType.TYPE})标注,当然如果有@Target({ElementType.TYPE_USE})也行,因为TYPE_USE包含TYPE。
分组校验
有这样一个需求:当People对象为已婚时(isMarried字段为true),需要校验”配偶姓名“、”是否有小孩“等字段不能为空,当People对象为未婚时,需要校验“是否单身”等其他字段不能为空, 这种需求可以通过分组检验来实现,将校验逻辑分为两个组,然后每次调用校验接口时指定分组即可实现不同的校验。 如果不管“是否已婚”都需要校验的字段(如姓名、年龄这些字段等),则可以同时指定两个分组。
静态分组
静态分组主要在类上面是使用GroupSequence
注解指定一个或者多个分组,用于处理不同的校验逻辑,我觉得这个基本上是写死的不能更改,用不用分组区别不大,因此没什么好说的,可以跳过直接看后面的动态分组。
@GroupSequence({ Group.UnMarried.class, Group.Married.class })
public class RentalCar extends PeopleBean {
... ...
}
动态分组
“未婚”和“已婚”两个分组的代码如下,由于分组必须是一个Class,而且有没有任何实现只是一个标记而已,因此我可以用接口。
public interface Group {
//已婚情况的分组校验
interface Married {}
//未婚情况的分组校验
interface UnMarried {}
}
校验对象:People2Bean.java
@Data
public class People2Bean {
//不管是否已婚,都需要校验的字段,groups里面指定两个分组
@NotBlank(message = "姓名不能为空",groups = {Group.UnMarried.class, Group.Married.class})
private String name;
@NotNull(message = "年龄不能为空",groups = {Group.UnMarried.class, Group.Married.class})
@Range(min = 0, max = 100, message = "年龄必须在{min}和{max}之间",groups = {Group.UnMarried.class, Group.Married.class})
private Integer age;
@NotNull(message = "是否已婚不能为空",groups = {Group.UnMarried.class, Group.Married.class})
private Boolean isMarried;
//已婚需要校验的字段
@NotNull(message = "配偶姓名不能为空",groups = {Group.Married.class})
private String spouseName;
//已婚需要校验的字段
@NotNull(message = "是否有小孩不能为空",groups = {Group.Married.class})
private Boolean hasChild;
//未婚需要校验的字段
@NotNull(message = "是否单身不能为空",groups = {Group.UnMarried.class})
private Boolean isSingle;
}
测试代码:通过isMarried的值来动态指定分组校验
@Test
public void testGroup() {
PeopleBean p=new PeopleBean();
p.setAge(30);
p.setName(" ");
p.setIsMarried(false);
Set<ConstraintViolation<PeopleBean>> result;
//通过isMarried的值来动态指定分组校验
if(p.getIsMarried()){
//如果已婚,则按照已婚的分组字段
result=validator.validate(p, Group.Married.class);
}else{
//如果未婚,则只校验未婚的分组字段
result=validator.validate(p, Group.UnMarried.class);
}
System.out.println("遍历输出错误信息:");
result.forEach(r-> System.out.println(r.getPropertyPath()+":"+r.getMessage()));
}
测试结果,可以发现,未婚校验了isSingle字段,符合预期
遍历输出错误信息:
name:姓名不能为空
isSingle:是否单身不能为空
将上述代码中的isMarried设置为true:p.setIsMarried(false)
再次执行结果如下,也是符合预期的
遍历输出错误信息:
name:姓名不能为空
hasChild:是否有小孩不能为空
spouseName:配偶姓名
动态分组优化
有没有发现上面的分组校验代码实现不够好?本来校验我是要完全交给validator框架的,但是我还得在校验框架之外面额外判断isMarried再来决定校验方式(如下代码),这样校验代码从校验框架外泄了,不太优雅,有没有优化的空间呢?
if(p.getIsMarried()){
//如果已婚,则按照已婚的分组字段
result=validator.validate(p, Group.Married.class);
}else{
//如果未婚,则只校验未婚的分组字段
result=validator.validate(p, Group.UnMarried.class);
}
其实通过DefaultGroupSequenceProvider
接口可以优化,这才是真正的动态分组校验,在该接口实现中判断isMarried值,来实现动态设置分组,也就是将校验的额外判断逻辑从校验框架外层转移到了校验框架中,外层业务代码只需要调用校验接口即可,而无需关注具体的校验逻辑,这样的框架才是优秀的。
如下PeopleGroupSequenceProvider.java类实现了DefaultGroupSequenceProvider接口
public class PeopleGroupSequenceProvider implements DefaultGroupSequenceProvider<People2Bean> {
@Override
public List<Class<?>> getValidationGroups(People2Bean bean) {
List<Class<?>> defaultGroupSequence = new ArrayList<>();
// 这里必须将校验对象的类加进来,否则没有Default分组会抛异常,这个地方还没太弄明白,后面有时间再研究一下
defaultGroupSequence.add(People2Bean.class);
if (bean != null) {
Boolean isMarried=bean.getIsMarried();
///System.err.println("是否已婚:" + isMarried + ",执行对应校验逻辑");
if(isMarried!=null){
if(isMarried){
System.err.println("是否已婚:" + isMarried + ",groups: "+Group.Married.class);
defaultGroupSequence.add(Group.Married.class);
}else{
System.err.println("是否已婚:" + isMarried + ",groups: "+Group.UnMarried.class);
defaultGroupSequence.add(Group.UnMarried.class);
}
}else {
System.err.println("isMarried is null");
defaultGroupSequence.add(Group.Married.class);
defaultGroupSequence.add(Group.UnMarried.class);
}
}else{
System.err.println("bean is null");
}
return defaultGroupSequence;
}
}
People2Bean.java类上要用到@GroupSequenceProvider
注解指定一个GroupSequenceProvider
@GroupSequenceProvider(PeopleGroupSequenceProvider.class)
public class People2Bean {
//字段同上
//... ...
}
测试代码
@Test
public void testGroupSequence(){
People2Bean p=new People2Bean();
p.setAge(30);
p.setName(" ");
System.out.println("----已婚情况:");
p.setIsMarried(true);
Set<ConstraintViolation<People2Bean>> result=validator.validate(p);
System.out.println("遍历输出错误信息:");
result.forEach(r-> System.out.println(r.getPropertyPath()+":"+r.getMessage()));
System.out.println("----未婚情况:");
p.setIsMarried(false);
result=validator.validate(p);
System.out.println("遍历输出错误信息:");
result.forEach(r-> System.out.println(r.getPropertyPath()+":"+r.getMessage()));
}
测试结果符合预期
----已婚情况:
遍历输出错误信息:
name:姓名不能为空
spouseName:配偶姓名不能为空
hasChild:是否有小孩不能为空
----未婚情况:
遍历输出错误信息:
name:姓名不能为空
isSingle:是否单身不能为空
自定义校验器
Hibernate中有不少约束校验器,但是不一定能满足你的业务,因此它还支持自定义约束校验器,一般是一个约束注解配合一个校验器使用,校验器需要实现ConstraintValidator
接口,然后约束注解中通过`@Constraint(validatedBy = {ByteLengthValidator.class})绑定校验器即可。 这里我写三个示例来说明:
自定义枚举校验
在开发过程中,有很多参数类型限制只能使用某些枚举值,我们可以通过自定义的校验器来做约束,以最简单的性别举例,在我国性别只有男和女,校验注解定义如下: EnumRange.java
@Documented
@Constraint(
//这个配置用于绑定校验器:EnumRangeValidator
validatedBy = {EnumRangeValidator.class}
)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(EnumRange.List.class)
public @interface EnumRange {
//自定义默认的消息模板
String message() default "枚举值不正确,范围如下:{}";
//枚举类,用于在校验器中限定值的范围
Class<? extends Enum> enumType();
//分组
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE,
ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
//支持数组校验
public @interface List {
EnumRange[] value();
}
}
校验器类:EnumRangeValidator.java 实现 ConstraintValidator 接口, ConstraintValidator<EnumRange,String> 接口的第一个泛型参数绑定EnumRange注解,第二个参数绑定要校验的值类型,这里是String。
public class EnumRangeValidator implements ConstraintValidator<EnumRange,String> {
private Set<String> enumNames;
private String enumNameStr;
@Override
public void initialize(EnumRange constraintAnnotation) {
Class<? extends Enum> enumType=constraintAnnotation.enumType();
if(enumType==null){
throw new IllegalArgumentException("EnumRange.enumType 不能为空");
}
try {
//初始化:将枚举值放到Set中,用于校验
Method valuesMethod = enumType.getMethod("values");
Enum[] enums = (Enum[]) valuesMethod.invoke(null);
enumNames = Stream.of(enums).map(Enum::name).collect(Collectors.toSet());
enumNameStr = enumNames.stream().collect(Collectors.joining(","));
} catch (Exception e) {
throw new RuntimeException("EnumRangeValidator 初始化异常",e);
}
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
if(value==null){
return true;
}
boolean result = enumNames.contains(value);
if(!result){
//拿到枚举中的message,并替换变量,这个变量是我自己约定的,
//你在使用注解的message中有花括号,这里会被替换为用逗号隔开展示的枚举值列表
String message = constraintValidatorContext
.getDefaultConstraintMessageTemplate()
.replace("{}",enumNameStr);
//禁用默认值,否则会有两条message
constraintValidatorContext.disableDefaultConstraintViolation();
//添加新的message
constraintValidatorContext.
buildConstraintViolationWithTemplate(message)
.addConstraintViolation();
}
return result;
}
}
我们来定义一个性别的枚举:当然,你还可以用其他自定义枚举,只要是枚举值这个校验就就能生效
public enum SexEnum {
F("女"),
M("男");
String desc;
SexEnum(String desc){
this.desc=desc;
}
}
被校验的类:Person2Bean.java
@Data
public class Person2Bean {
@NotBlank(message = "姓名不能为空")
private String name;
@Range(min = 0, max = 100, message = "年龄必须在{min}和{max}之间")
private Integer age;
//性别用到上面的自定义注解,并指定枚举类SexEnum,message模板里面约定变量绑定“{}”
@EnumRange(enumType = SexEnum.class, message = "性别只能是如下值:{}")
private String sex;
}
校验测试代码
@Test
public void testSelfDef() {
Person2Bean s=new Person2Bean();
//性别设置为“A",校验应该不通过
s.setSex("A");
//s.setFriendNames(Stream.of("zhangsan","李四思").collect(Collectors.toList()));
Set<ConstraintViolation<Person2Bean>> result=validator.validate(s);
System.out.println("遍历输出错误信息:");
result.forEach(r-> System.out.println(r.getPropertyPath()+":"+r.getMessage()));
}
校验结果如下:性别设置为“A",校验应该不通过不是枚举值中的F和M,因此符合预期
遍历输出错误信息:
sex:性别只能是如下值:F,M
name:姓名不能为空
自定义字节数校验器
参数的字段值要存入数据库,比如某个字段用的 Oracle 的 Varchar(4) 类型,那么该字段值的不能超过4个字节,一般可能会想到应用 @Length 来校验,但是该校验器校验的是字符字符串长度,即用 String.length() 来校验的,英文字母占用的字节数与String.length()一致没有问题,但是中文不行,根据不同的字符编码占用的字节数不一样,比如一个中文字符用UTF8占用3个字节,用GBK占用两个字节,而一个英文字符不管用的什么编码始终只占用一个字节,因此我们来创建一个字节数校验器。
校验注解类:ByteMaxLength.java
@Documented
//绑定校验器:ByteMaxLengthValidator
@Constraint(validatedBy = {ByteMaxLengthValidator.class})
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(ByteMaxLength.List.class)
public @interface ByteMaxLength {
//注意这里的max是指最大字节长度,而非字符个数,对应数据库字段类型varchar(n)中的n
int max() default Integer.MAX_VALUE;
String charset() default "UTF-8";
Class<?>[] groups() default {};
String message() default "【${validatedValue}】的字节数已经超过最大值{max}";
Class<? extends Payload>[] payload() default {};
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface List {
ByteMaxLength[] value();
}
}
校验最大字节数的校验器:ByteMaxLengthValidator.java ,注意里面约定了两个绑定变量:chMax 和 enMax,分别对应中、英文的最大字符数,用于message模板中使得错误提示更加友好
public class ByteMaxLengthValidator implements ConstraintValidator<ByteMaxLength,String> {
private int max;
private Charset charset;
@Override
public void initialize(ByteMaxLength constraintAnnotation) {
max=constraintAnnotation.max();
charset=Charset.forName(constraintAnnotation.charset());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
if(value==null){
return true;
}
int byteLength = value.getBytes(charset).length;
//System.out.println("byteLength="+byteLength);
boolean result = byteLength<=max;
if(!result){
//这里随便用一个汉字取巧获取每个中文字符占用该字符集的字节数
int chBytes = "中".getBytes(charset).length;
System.out.println("chBytes="+chBytes);
//计算出最大中文字数
int chMax = max/chBytes;
//拿到枚举中的message,并替换变量,这个变量是我自己约定的,
//约定了两个绑定变量:chMax 和 enMax
String message = constraintValidatorContext
.getDefaultConstraintMessageTemplate()
.replace("{chMax}",String.valueOf(chMax))
.replace("{enMax}",String.valueOf(max));
//禁用默认值,否则会有两条message
constraintValidatorContext.disableDefaultConstraintViolation();
//添加新的message
constraintValidatorContext.
buildConstraintViolationWithTemplate(message)
.addConstraintViolation();
}
return result;
}
}
校验类
@Data
public class Person2Bean {
/**
* message里面用到了前面约定的两个变量:chMax和enMax,
* 至于${validatedValue}是框架内置的变量,用于获取当前被校验对象的值
*/
@ByteMaxLength(max=4,charset = "UTF-8"
, message = "姓名【${validatedValue}】全中文字符不能超过{chMax}个字,全英文字符不能超过{enMax}个字母")
private String name;
/**
* 该注解可以用于泛型参数:List<String> ,
* 这样可以校验List中每一个String元素的字节数是否符合要求
*/
private List<@ByteMaxLength(max=4,charset = "UTF-8",
message = "朋友姓名【${validatedValue}】的字节数不能超过{max}")
String> friendNames;
@Range(min = 0, max = 100, message = "年龄必须在{min}和{max}之间")
private Integer age;
//@EnumRange(enumType = SexEnum.class, message = "性别只能是如下值:{}")
private String sex;
}
校验测试代码
@Test
public void testSelfDef() {
Person2Bean s=new Person2Bean();
s.setName("张三");
//s.setSex("M");
s.setFriendNames(Stream.of("zhangsan","李四思","张").collect(Collectors.toList()));
Set<ConstraintViolation<Person2Bean>> result=validator.validate(s);
System.out.println("遍历输出错误信息:");
result.forEach(r-> System.out.println(r.getPropertyPath()+":"+r.getMessage()));
}
运行结果,可以发现List中的元素也可以校验
遍历输出错误信息:
name:姓名【张三】全中文字符不能超过1个字,全英文字符不能超过4个字母
friendNames[0].<list element>:朋友姓名【zhangsan】的字节数不能超过4
friendNames[1].<list element>:朋友姓名【李四思】的字节数不能超过4
由于上面用的UTF-8编码,max=4,中文占三个字节,因此只能一个中文字符,换成GBK试一下
@ByteMaxLength(max=4,charset = "GBK"
, message = "姓名【${validatedValue}】全中文字符不能超过{chMax}个字,全英文字符不能超过{enMax}个字母")
private String name;
//可以用于校验数组元素:List<String>
private List<@ByteMaxLength(max=4,charset = "GBK",
message = "朋友姓名【${validatedValue}】的字节数不能超过{max}")
String> friendNames;
同样的测试代码发现校验结果不一样了:name="张三"校验通过了,由于GBK中文值占2个字节而不是3个字节
friendNames[1].<list element>:朋友姓名【李四思】的字节数不能超过4
friendNames[0].<list element>:朋友姓名【zhangsan】的字节数不能超过4
自定义类级别的校验器
类级别的校验器没什么特别的,无非是其可以注解到类上面,即由@Target({ElementType.TYPE})标注的注解。但是某些特殊场景非常有用,字段上的校验器只能用于校验单个字段,如果我们需要对多个字段进行特定逻辑的组合校验就非常有用了。
下面的示例用于校验:订单价格==商品数量*商品价格
@OrderPrice注解:OrderPrice.java
@Documented
//绑定校验器
@Constraint(validatedBy = {OrderPriceValidator.class})
//可以发现没有 ElementType.TYPE 该注解也能用到类上面,这是因为ElementType.TYPE_USE包含ElementType.TYPE
@Target({ElementType.TYPE_USE, ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(OrderPrice.List.class)
public @interface OrderPrice {
Class<?>[] groups() default {};
String message() default "订单价格不符合校验规则";
Class<? extends Payload>[] payload() default {};
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface List {
OrderPrice[] value();
}
}
校验器: OrderPriceValidator.java,注意ConstraintValidator<OrderPrice, OrderBean>第二个泛型参数为被校验的类OrderBean
public class OrderPriceValidator implements ConstraintValidator<OrderPrice, OrderBean> {
@Override
public void initialize(OrderPrice constraintAnnotation) {
}
@Override
public boolean isValid(OrderBean order, ConstraintValidatorContext constraintValidatorContext) {
if(order==null){
return true;
}
return order.getPrice()==order.getGoodsPrice()*order.getGoodsCount();
}
}
被校验类:OrderBean.java
@Data
//类上面用到自定义的校验注解
@OrderPrice
public class OrderBean {
@NotBlank(message = "商品名称不能为空")
private String goodsName;
@NotNull(message = "商品价格不能为空")
private Double goodsPrice;
@NotNull(message = "商品数量不能为空")
private Integer goodsCount;
@NotNull(message = "订单价格不能为空")
private Double price;
@NotBlank(message = "订单备注不能为空")
private String remark;
}
校验测试代码
@Test
public void testSelfDef2() {
OrderBean o=new OrderBean();
o.setGoodsName("辣条");
o.setGoodsCount(5);
o.setGoodsPrice(1.5);
o.setPrice(20.5);
Set<ConstraintViolation<OrderBean>> result=validator.validate(o);
System.out.println("遍历输出错误信息:");
result.forEach(r-> System.out.println(r.getPropertyPath()+":"+r.getMessage()));
}
测试执行结果如下:符合预期
遍历输出错误信息:
:订单价格不符合校验规则
remark:订单备注不能为空
EL表达式
其实在上面的示例中,可以看到在message中已经使用到了EL表达式:
@ByteMaxLength(max=4,charset = "GBK"
, message = "姓名【${validatedValue}】全中文字符不能超过{chMax}个字,全英文字符不能超过{enMax}个字母")
private String name;
包含在${
与}
之间的就是EL表达式,比如这里的${validatedValue}
, validatedValue是内置的变量,用于存储当前被校验对象的值,更复杂的用法不仅仅是取值,还可以做各种逻辑运算、内置函数调用等,如下面这些用法:
@Size(
min = 2,
max = 14,
message = "The license plate '${validatedValue}' must be between {min} and {max} characters long"
)
@Min(
value = 2,
message = "There must be at least {value} seat${value > 1 ? 's' : ''}"
)
DecimalMax(
value = "350",
message = "The top speed ${formatter.format('%1$.2f', validatedValue)} is higher than {value}"
)
@DecimalMax(value = "100000", message = "Price must not be higher than ${value}")
上面有一种不包含$
符号,只包含在花括号{}
的表达式,这种表达式只能用于简单的变量替换,如果没有该变量也不会报错,只是会被原样输出,而${validatedValue}
这个里面的表达式如果错了则会抛异常。
比如@Length注解有两个变量min和max,其实像groups、payload都可以获取到其值,也就是在message中可以获取当前注解的所有成员变量值(除了message本身)。
public @interface Length {
int min() default 0;
int max() default 2147483647;
String message() default "{org.hibernate.validator.constraints.Length.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
... ...
}
如:
@Length(min=1,max=10,message = "字符长度请控制在{min}到{max}之间,分组校验:{groups},消息:{message}")
private String name;
上述代码的message中{min}、{max}、{groups}最终在错误消息输出时hi可以被对应的变量值替换的,但是{message}就会被原样输出,因为不可能在message里面获取它自己的值。
校验框架对EL表达式的支持对于自定义消息模板非常有用,可以使错误消息提示更加友好。
SpringMVC中如何使用
上面的示例代码都是在单元测试中使用,validator类也是自己手动创建的,在spring中validator需要通过容器来创建,除了上面的maven依赖,还需在spring.xml中为校验器配置工厂bean
<mvc:annotation-driven validator="validator"/>
<bean id="validator"
class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean">
<property name="providerClass" value="org.hibernate.validator.HibernateValidator"/>
<property name="validationMessageSource" ref="messageSource"/>
</bean>
<bean id="messageSource"
class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
然后在Controller类中方法的参数增加@Valid
注解即可
@RequestMapping("/update")
public String update(@Valid PersonBean person) {
//TODO ...
}
总结
写到这里,上面提到的validator框架用法基本能满足我们大多数业务场景了,我是最近在为公司写业务代码过程中对各种繁琐的校验头痛不已,前期都是直接用ifelse搞定,后面觉得干体力活没意思,因此通过validator框架把公司代码现有校验逻辑重构了一遍,非常受用,重构时比较痛苦,但是后面再使用就非常轻松了,上面这些场景都是我真实用到的,因此在这里总结一下做个笔记。
所有代码都在如下仓库: github-validator