关于 Hibernate Validator 的使用

之前学习过hibernate Validator,但当时做的笔记弄丢了,最近可能又要开始写Java,所以把这一部分学习一下,做个笔记。

Hibernate Validator用于值校验,能够避免业务中过多地出现校验业务代码。Hibernate Validator支持对自定义类型,集合类型和内置Java类型进行校验,同时支持定义校验组,即让类型约束从属于特定组,只在进行该组的校验时才发挥作用。

为什么要使用Hibernate Validator:

  1. 声明式,避免到处写丑陋的样板代码
  2. 更清晰地规范实体定义,校验注解本身就是一种注释
  3. 支持自定义校验逻辑和错误消息,其中还能注入依赖以实现更复杂逻辑

环境搭建和基本使用

通过下面的starter引入hibernate validator依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Hibernate Validator利用切面完成自己的逻辑,它根据上的@Validated注解去进行切入,并根据参数上的注解去进行校验。总的来说,要让校验起效,需要:

  1. spring-boot-starter-validation依赖引入
  2. 类上标注@Validated(**要且必须要在类上标注!必须是@Validated而非@Valid**)
  3. 方法的参数上标注相应校验注解(其中,实体类参数使用@Valid@Validated注解)
  4. 调用方法时不能从内部调用(切面嘛,懂的都懂)

注意,Hibernate Validator通过切面工作,因此它不仅能切入Controller,也能切入Service,但仅此而已,某些时候还是需要手动进行校验。

下面是一个极简例子,涉及到控制器和实体类的校验,它已经提出了许多要注意的部分:

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
@RestController
@Validated
public class SomeController
// 定义带校验的实体类(注意实体类上不需要加任何额外注解,加注解也是没用的)
@Data
public static class SomeDto {
@NotBlank // 对于实体类,注解加到字段上
private String name;

@NotBlank @Length(min=10) // 可以同时有多个校验 注解
private String value;

@Valid // 对实体类用 Valid 进行嵌套校验(注意Valid在字段为null的时候不检查!)
private SomeDto next;

private List<@Valid SomeDto> dtos; // 对集合进行校验(注意这里不会检查集合是否为null或空!同时也没检查集合元素是否为null!)
}
@PostMapping("/query")
public SomeDto query(
// 实体类参数用 @Valid@Validated注解进行校验
@Valid @RequestBody SomeDto dto,
@NotBlank @Length(min=10) @RequestParam String param) {
return dto;
}
}

@Valid校验能进行嵌套校验,但它会直接忽略掉null,因此上面的SomeDto定义中,next字段为null,以及dtos字段为null或空集合,或集合中存在null,都是容忍的。

Controller的参数校验似乎并非完全是通过切面完成的——即使Controller上未加@Validated注解,@Valid注解仍旧会生效,但是其它校验注解不会生效,因此最佳实践是,总是在类上加@Validated,不要嫌麻烦

关于Service的校验

Hibernate Validator有一条规则:A method overriding another method must not redefine the parameter constraint configuration,它是说,子类无法覆盖掉父类上的校验注解,即使父类上没有校验注解

上面的规则同时暗示了,Hibernate Validator的注解是能够继承的。一般而言,Spring项目中Service的接口和实现是分离的,如果要校验Service,根据上面的规则,我们应当:

  1. 在接口上标注@Validated注解
  2. 在接口上的方法参数中添加相应校验注解
  3. 在实现上不需要添加任何注解,或者保证实现上的注解和接口上的完全相同

初看感觉这个要求不太合理,但细想其实还好——按理来说,接口内部使用何种实现对接口的调用者是透明的,因此值的约束必须是定义自接口层级上的,实现对值的约束只能更宽,不能更窄,而我们无法判断约束的宽窄,所以就硬性要求它们保持一致。但其实作为业务的开发者来说,还是希望能够将注解只写在实现上。

常用校验注解

这里列出可能会常用的注解,注意几乎所有注解都认为null是合法的。注解主要在org.hibernate.validator.constraintsjavax.validation.constraints包下。

注解 作用
Email 检查邮箱是否合法 null合法
Past, Future, … 时间是否是过去或未来 null合法
Pattern 字符串必须满足正则 null合法
Size 字符串长度或集合必须满足特定大小范围 null合法,大小区间前闭后闭
Min, Max, Positive, Negative… 限制数字的最小值,最大值,正负性等 null合法,注意不要用Min和Max限制字符串长度,这个能启动,但运行时会报错
Length 字符串长度必须在特定范围 null合法,前闭后闭
Null 约束字段必须为null
NotNull 约束字段必须不能为null
NotEmpty 集合或字符串不能为null且非空
NotBlank 字符串不能为null且必须包含非空字符

手动校验实体类

有时候可能会想要进行手动校验,比如我们可能会想写mybatis拦截器,在插入和更新数据前进行校验,手动校验可以利用Spring提供的 Beanjavax.validation.Validator(这是它对JSR规范的实现),它返回”vioiations”,即实体对象对约束的违反。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Autowired
private javax.validation.Validator validator;

public void test() {
SomeDto dto = new SomeDto();
dto.setName("");
dto.setValue("");
Set<ConstraintViolation<SomeDto>> violations = validator.validate(dto);
if (violations.isEmpty()) {
// 校验成功,...
return;
}
for (ConstraintViolation<SomeDto> violation : violations) {
System.out.println(violation.getMessage());
}
}

拦截校验异常

Hibernate Validator会抛出如下异常:

  1. org.springframework.web.bind.MethodArgumentNotValidException,抛出在Controller的@Valid@Validated注解的实体类参数(这简直就是历史遗留问题),默认响应码是400,消息是”Bad Request”
  2. javax.validation.ConstraintViolationException,其他情况,默认响应码是500,消息是”Internal Server Error”,这是符合道理的——控制器的参数错误是用户的错误,服务层的参数错误是开发者的错误

这两个异常都需要被拦截才能妥善把校验信息响应给前端……但这样真的好吗?全给到前端不是会让坏家伙有可乘之机吗?总之贴上(实际操作时应当像ruoyi那样,正常响应和错误响应形式保持一致):

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
@RestControllerAdvice
public class GlobalExceptionHandler {

// 处理 ConstraintViolationException
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<?> handleConstraintViolationException(ConstraintViolationException ex, WebRequest request) {
Map<String, String> errors = new HashMap<>();
ex.getConstraintViolations().forEach(violation ->
errors.put(violation.getPropertyPath().toString(), violation.getMessage())
);
return new ResponseEntity<>(errors, HttpStatus.INTERNAL_SERVER_ERROR);
}

// 处理 MethodArgumentNotValidException
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
String parameterName = ex.getParameter().getParameterName();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(parameterName + "." + error.getField(), error.getDefaultMessage())
);
return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
}

// 处理其他异常
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleGlobalException(Exception ex, WebRequest request) {
// ...
}
}

自定义校验注解

有时候需要自定义校验,比如要校验身份证,这个肯定没有被提供,需要手写。这时候需要自定义Validator和注解。

Validator不关心它被标注在字段上还是标注到参数上,它直接拿到值然后去做校验,但同时也允许获取当前的字段路径等信息以构建错误消息

Spring Boot会惰性地为为每个不同的注解创建相应的Validator实例,从而让每个Validator都负责同一个注解(类型相同,且所有字段值相同),避免任何并发问题,同时支持在创建Validator时注入依赖。

编写自定义校验注解需要:

  1. 创建自定义注解,注解需要是Runtime的,需要能够标注到字段和参数上(也可以让注解能标注到类上,这允许对整个类进行校验),注解需要引用下一步中编写的自定义Validator,注解必须包含groups,message, payload字段(可以直接抄现成的)
  2. 创建自定义Validator,如果注解能够校验多种类型,则每个类型都需要一个Validator,Validator类要实现ConstraintValidator<注解, T>在构造函数中注入Spring Bean依赖,在initialize方法中注入注解,并在isValid中线程安全地进行校验
  3. 实现isValid时,第一个参数是字段值,第二个参数是当前上下文,isValid方法返回true或false,true表示校验通过,false表示不通过,此时hibernate validator会根据上下文去构造相应violation,此时可以自定义错误消息。

下面编写一个身份证校验注解,其中演示了如何在校验过程中获取Bean使得能和系统其他部分进行交互,以及如何修改错误消息。

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
39
40
41
42
43
44
45
/**
* 校验是否合法身份证,null 认为是合法
*/
@Target({ FIELD, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = IsIdCard.IsIdCardValidator.class)
public @interface IsIdCard {
String message() default "Invalid Id Card"; // 这个消息会是默认消息,它里面能使用{}插值注解上的字段
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
// 不需要,也不应该标注 @Component
class IsIdCardValidator implements javax.validation.ConstraintValidator<IsIdCard, String> {
private final SomeService someService;
private IsIdCard anno;
// 通过构造函数注入bean
public IsIdCardValidator(SomeService someService) {
this.someService = someService;
}
// 通过initialize方法注入注解
@Override
public void initialize(IsIdCard constraintAnnotation) {
this.anno = constraintAnnotation;
}

// 实际校验操作
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// 修改掉原来的violation信息,比如做国际化之类的
context.disableDefaultConstraintViolation(); // 移除掉原来的violation信息
context.buildConstraintViolationWithTemplate("身份证不合法:" + value)
.addConstraintViolation();

// 遵循规范,null认为合法
if (value == null) {
return true;
}

return isValidIdCard(value);
}
private static boolean isValidIdCard(String idCard) {
// ...
}
}
}

分组校验

所有校验注解都有groups参数(除了Validated,它直接用value),表示校验所属的校验组。在进行校验时,通过@Validated的value参数指定只校验特定组的注解(注意它标识在参数上时表示只校验这些组的注解,标识在字段上时表示该校验属于这些组,这是两种不同的语义)。校验组使用任意Interface进行标识,这些Inteface不需要任何实际操作。

分组有如下性质:

  1. 未指定groups的校验注解,默认属于javax.validation.groups.Default组,因此一旦指定了校验组,那没有处在任何校验组中的校验注解不会生效
  2. 校验组可以继承,表示它同时对应多个组,比如可以**定义组去继承Default**,这样即使在groups参数中只指定该组,也会校验到未指定groups的校验注解。
  3. 可以使用GroupSequence表示按顺序校验多个组,但它不会引入继承关系。
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
interface OnInsert { }
interface OnUpdate { }
interface Always extends Default, OnInsert, OnUpdate {}
// 定义带校验的实体类(注意实体类上不需要加任何额外注解)
@Data
public static class SomeDto {
@NotNull // 等同于@NotNull(groups=Default.class),它在创建和更新时均未被校验
String key;

@NotNull(groups=Always.class) // 创建,更新,以及未指定groups时均校验
String x;

// 创建时ID必须为null,更新时必须不为空
@Null(groups = OnInsert.class)
@NotBlank(groups = OnUpdate.class)
String id;

// 创建和更新时都需要验证value非空
@NotBlank(groups = {OnInsert.class, OnUpdate.class})
String value;
}
@PostMapping("/insert")
public void insert(@Validated(OnInsert.class) @RequestBody SomeDto dto) {
}
@PostMapping("/update")
public void update(@Validated(OnUpdate.class) @RequestBody SomeDto dto) {
}