参数校验之Hibernate-validator的基本使用

Hibernate-validator是对validation-api的再次封装,在开发过程中是经常使用到(spring boot里面的spring-boot-starter-web是默认应用了Hibernate-validator包的),特别是参数校验,刚开始做开发工作的时候,都是在Service层用if…else…来判断参数的合法性,这个会使代码显得很臃肿,后来接触Hibernate-validator,真的是很好用的。这里分享出来,也是做个笔记,以后用到可以作为参考资料。

validation常用的注解和核心类

validation-api中的注解比较全,Hibernate-validator也有自己的一些注解,都是一些很好用的。

validation-api原生注解
  • @AssertTrue、@AssertFalse:作用在布尔类型的属性上,校验其值是true或者false
  • @DecimalMax、@DecimalMin:支持BigDecimal、BigInteger等类型,限定其最大值和最小值,包含界限值
  • @Min、@Max:支持int、BigDecimal、BigInteger等类型,限定最大值和最小值,包含界限值
  • @Digit:作用在数字上,有两个参数,分别是integer和fraction,integer用来限定小数点前的位数,fraction用来限定小数点后的位数
  • @Future:作用在日期类型上,根据官网的说法是Date和Calendar,要求传入的时间是未来的时间
  • @Null:用来表示当前字段是否为null
  • @Past:此注解和Future是相反的,要求传入的时间是过去的时间
  • @Size:作用在Collection集合、Map集合以及CharSequence类型(String是CharSequence子类),注解内有两个参数,分别是min、max,作为长度的上限和下限,包含上下限值
  • @Pattern:通过正则表达式表达式校验传入参数是否符合要求
Hibernate-validator封装注解
  • @NotBlank:这个注解其实不是validation包里面的,如果需要用它,需另外引入hibernate-validator包,用来限定属性不能为null和空串,一般都是用在String类型属性上,此注解会将传入的字符串的前面和后面的空清除,相当于执行了trim操作
  • @NotEmpty:限定属性不能为空,需要和@NotBlank注解区分开,两者有所不同,此注解不会执行trim操作
  • @Email:用来校验传入的数据是否符合邮箱格式的
  • @Range:范围值,包含两个主要的两个属性min和max,这个相当于对validator-api@Min@Max的再次封装,将两个注解合并为一个注解,包含边界值
其他说明

Hibernate-validator的注解不止这么多,还有如@SafeHtml@ScriptAssert等,这些在日常很少用,就不多去赘述了,感兴趣的可以研究一下,其实从字面上也能理解一二。

核心类

上面的注解校验结束后,会将结果封装到这个核心类里面,通过这个类提供的API获取是否有校验不通过的字段信息。核心类的名称是BindingResult,这个类是Spring封装的,在spring-context包内。

主要使用的两个API:

  • hasErrors():返回值为boolean类型,true表示由校验未通过的属性
  • getFieldError().getDefaultMessage():获取错误信息,也就是校验上面的注解中message属性对应的值

对象属性的校验

简单属性校验

对一个类中的几个非集合,非子对象的属性校验,这些校验是最常见的,也是最简单的。

  • 校验对象
1
2
3
4
5
6
7
@Data
public class ValidDemo {
@Size(min = 2, max = 5, message = "长度不符合要求")
private String name;
@Min(value = 10,message = "不合法年龄")
private Integer age;
}
  • 控制层方法的书写方式
1
2
3
4
5
@PostMapping("/post")
@ResponseBody
public String valid1(@RequestBody @Valid ValidDemo validDemo, BindingResult bindingResult) {
return bindingResult.hasErrors() ? bindingResult.getFieldError().getDefaultMessage() : "success";
}

需要注意点

  • 控制层方法中,要在需要校验的对象上加上@Valid注解,否则被校验对象里面的相关校验注解都是不能生效的
  • 控制层方法中,需要添加一个参数BindingResult,用来接收校验的结果,给出前端友好的响应
  • 这里的简单类型主要指的是基本类型、基本类型包装类和String
  • 另外最需要注意的点就是被校验对象,它不是基本类型、基本类型包装类和String,具体原因后面会继续说到
复杂属性校验

所谓的复杂类型,就是对象里面的属性不是简单的基本类型、基本类型包装类和String,而是包含Collection集合、嵌套对象等。

  • 校验对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Data
public class ValidDemo {
@Size(min = 2, max = 5, message = "长度不符合要求")
private String name;
@Min(value = 10, message = "不合法年龄")
private Integer age;
@NotNull(message = "用户列表不能为空")
@Valid
private List<User> users;
@NotNull(message = "订单信息不能为空")
@Valid
private Order order;
}
//内嵌的用户对象
@Data
public class User {
@NotBlank(message = "地址不能为空")
private String addr;
@Email(message = "邮箱格式不正确")
private String email;
}
//内嵌的订单对象
@Data
public class Order {
@DecimalMin(value = "1.00", message = "价格不正确")
private BigDecimal price;
}
  • 控制层方法
1
2
3
4
5
@GetMapping("/get")
@ResponseBody
public String valid1(@ModelAttribute @Valid ValidDemo validDemo, BindingResult bindingResult) {
return bindingResult.hasErrors() ? bindingResult.getFieldError().getDefaultMessage() : "success";
}

需要注意点

  • 复杂对象,里面嵌套Collection集合(List集合),必须在集合上加@Valid注解,否则集合内的对象(User)中属性加的注解不会生效
  • 同上理里面嵌套的对象(Order对象)上也是需要加上@Valid注解
  • 控制层方法,这里使用了GET请求,接收参数没有使用字段直接接收,而是使用对象,需要在对象上加@ModelAttribute注解,将GET请求的字段封装到此对象中,为什么这么做而不用单子段接收?下面说具体原因
GET和POST请求的区别

上面的两个例子说完了,在注意点上都留下了一个问题。为什么控制层都使用对象来接收参数,POST请求用对象接收是可以理解的,而GET请求更常用的使用字段来接收,但是为什么不用。

看个例子:

1
2
3
4
5
@GetMapping("/error")
@ResponseBody
public String error(@RequestParam("name") @Size(min = 2, max = 5, message = "名字长度不合法") String name) {
return "success";
}

控制层这么写,调试可以得到,这样写@Size是不会生效的。

但是又有人会觉得应该加上@Valid注解,但是事实依然不会生效。(想验证的可以尝试一下)

如果你必须用字段来接收GET请求的参数,且需要使用validation的校验,接下来给你一个不建议的校验方式。

不建议的校验方式

描述不多做赘述,直接上代码啦!

  • 第一步:在Spring容器中添加一个Bean:MethodValidationPostProcessor
1
2
3
4
@Bean
public MethodValidationPostProcessor methodValidationPostProcessor() {
return new MethodValidationPostProcessor();
}
  • 第二步:在需要校验的方法所在类上添加@Validated注解,注意是类上面,不是方法上。
1
2
@Validated
public class ValidatorController {}
  • 第三步:在方法上加上校验注解
1
2
3
4
5
@GetMapping("/error")
@ResponseBody
public String error(@RequestParam("name") @Size(min = 2, max = 5, message = "名字长度不合法") String name) {
return "success";
}

OK,到此就完成了,当校验不通过的时候,会给出以下信息:

1
2
3
4
5
6
7
8
{
"timestamp": 1556769001783,
"status": 500,
"error": "Internal Server Error",
"exception": "javax.validation.ConstraintViolationException",
"message": "No message available",
"path": "/valid/error"
}

到这里多少能看出来一点问题了,下面来罗列一下:

  • 响应信息不是给出注解里面已经预设好的”名字长度不合法”,而是一串异常信息,这个有点尴尬,这个直接响应给前端,会不会被骂死,你看着办吧。(也许你可以用切面去处理,但是不是有点太麻烦)
  • 正常一个控制层的Controller里面都会有很多方法,其他方法(如POST)是用对象来接收的参数的,用不到这个注解,直接使用@Valid就可以。但是不好意思,@Validated注解是写在方法上的,作用于全局,会导致@Valid不起作用,也就是说当前这个Controller所有的方法返回的都是上面的异常信息。为了兼容一个GET接收字段参数,把整个方法的校验规则都打乱了,是不是有点得不偿失。因此这种方式的校验是不推荐的,在上面复杂校验介绍里面其实已经给出解决方案了,不管是GET、POST还是其他类型的请求,都统一用对象接收。另外用对象接收是有好处的,比如一个GET查询有很多查询条件,如果都写在方法上会让整个方法很长,如果在传参的过程中,不小心把参数顺序弄错了,那就尴尬啦。用对象封装接收更科学更方便,个人觉得。

自定义校验规则

上面的校验规则都达不业务校验的要求,我需要有一个自定义的校验规则,那也是可以的,接下来说一下自定义校验规则的写法。

  • 自定义注解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = MapValidConstraint.class)
public @interface MapValidNull {

int value() default -1;

String message() default "Map集合不能为空";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@interface List {
MapValidNull[] value();
}
}
  • 注解处理类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 泛型的第一个是对应的注解,第二个是字段属性类型
@Component
public class MapValidConstraint implements ConstraintValidator<MapValidNull, Map> {

private int value;

@Override
public void initialize(MapValidNull constraintAnnotation) {
this.value = constraintAnnotation.value();
}

@Override
public boolean isValid(Map map, ConstraintValidatorContext context) {
//TODO 具体判断逻辑……
return true;
}
}
  • 然后及时使用注解,和预定义的注解使用方式是一样的。
1
2
@MapValidNull
private Map<String,String> map;

业务校验工具

直接在Controller中写检查注解+BindingResult基本就能满足日常开发需求,但是还有一种情况下是满足不了的,就是非HTTP接口,在使用Dubbo或者其他RPC调用的服务就不能使用这种方式,需要另外的在业务代码中进行校验。这个时候就无法使用BindingResult来直接获取校验结果。怎么办呢?

Hibernate-validator里面可以手动的执行校验,使用到的工具类是Validator。简单看一下这个类的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface Validator {

//直接检查整个类中的所有字段
<T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups);

//指定检查类中某个字段
<T> Set<ConstraintViolation<T>> validateProperty(T object,
String propertyName,
Class<?>... groups);

<T> Set<ConstraintViolation<T>> validateValue(Class<T> beanType,
String propertyName,
Object value,
Class<?>... groups);

BeanDescriptor getConstraintsForClass(Class<?> clazz);

<T> T unwrap(Class<T> type);

ExecutableValidator forExecutables();
}

使用到的是前两个方法,一般使用到第一个就足够啦。下面看一下检查的具体逻辑。

  • 首先需要检查的字段上需要加上注解,可以是Hibernate-validator里面自带的注解,也可以是自定义注解

  • 在业务代码中获取Validator实例,调用validate方法,将类作为参数传入进去,如下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    //构建Validator实例
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();
    //调用校验方法
    Article article = new Article();
    Set<ConstraintViolation<T>> validates = validator.validate(artilce); //调用校验方法
    if(CollectionUtils.isEmpty(validates)) System.out.println("全部检查通过");
    return validates.iterator().next().getMessage(); //取第一个错误信息返回
  • 如果只是校验类中的某个字段,调用validateProperty方法,传入需要检查的对象和需要检查的字段名称,如下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    //构建Validator实例
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();
    //调用校验方法
    Article article = new Article();
    Set<ConstraintViolation<T>> validates = validator.validate(artilce,"title"); //调用校验方法,只检查title字段
    if(CollectionUtils.isEmpty(validates)) System.out.println("检查通过");
    return validates.iterator().next().getMessage(); //取第一个错误信息返回

这种检查方式还是会经常用到的,每次都去获取Validator实例以及判断检查结果,代码会比较臃肿,所以这里可以封装一个工具类。(可以根据实际的项目对这个工具类进行修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class ValidatorUtil {

public final static String VALIDATE_PASS = "VALIDATE_PASS";

private static Validator validator;

static {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
}

/**
* 校验对象内所有的字段
*
* @param validateBean 被检查的对象
* @param <T> 对象类型
* @return 成功返回固定信息:VALIDATE_PASS,失败默认返回第一个异常信息
*/
public static <T> String validate(T validateBean) {
Set<ConstraintViolation<T>> validates = validator.validate(validateBean);
if (CollectionUtils.isEmpty(validates)) return VALIDATE_PASS;
return validates.iterator().next().getMessage();
}

/**
* 校验对象内的指定字段
*
* @param validateBean 被检查的对象
* @param propertyName 指定字段名称
* @param <T> 对象类型
* @return 成功返回固定信息:VALIDATE_PASS,失败默认返回第一个异常信息
*/
public static <T> String validateProperty(T validateBean, String propertyName) {
Set<ConstraintViolation<T>> validates = validator.validateProperty(validateBean, propertyName);
if (CollectionUtils.isEmpty(validates)) return VALIDATE_PASS;
return validates.iterator().next().getMessage();
}
}

总结

到这里关于Hibernate-validator的使用就写完啦,主要说的是下面几点内容。

  • 介绍Hibernate-validatorvalidation-api中已经定义好的检查注解,以及这些注解的基本使用规则
  • 基于HTTP接口的参数校验,GET、POST请求都有涉及到,并且特殊说明接收参数是对象还是单个字段存在的区别
  • 说明为什么不用单个字段接收参数的问题
  • 自定义检查注解,实现个性化的检查规则
  • 基于非HTTP接口服务,实现用工具类在业务代码中完成参数校验

微信公众号


本文作者:IT-CRUD
原文地址:http://blog.itcrud.com/blogs/2019/05/validation-original
版权归作者所有,转载请注明出处

支付宝 微信

如果文章对你有帮助,欢迎点击上方按钮打赏作者