前言:
本篇文章不是API参考文档,所以不会将用到的所有内容详细列出来。本文的目的主要是告诉读者关于Java的 Bean Validation在Spring的应用,并针对常见的场景进行说明,力求让读者对Java的Bean Validation有一个完整的认识和理解。
最后更新日期:2020-02-17
文章关键字:
- JSR-303
- Bean Validation 1.0/1.1/2.0
- MVC Validation
- Hibernate Validation
- Spring Validation
为了保证代码的正常运行,经常会对输入输出做大量的校验,以防止非法参数导致程序运行异常,Java 从2009年开始提出了 Bean Validation 1.0(也就是JSR-303)API,力求将输入输入的校验标准化和简单化,更重要的是将校验通用化。Hibernate Validation 是常用的针对Bean Validation API的实现之一(还有Apache BVal),并在Bean Validation 的API基础上,进行了扩展,以覆盖更多的场景。Spring Validation 则在整合了Hibernate Validation 的基础上,以Spring的方式,支持Spring应用的输入输出校验,比如MVC入参校验,方法级校验等等。至此,针对文章关键字已经进行了大概的说明,下面是他们之间的详细关系:
到目前为止Java Bean validation一共有三个版本。
概览
下面的代码片段是Controller中常见的代码,这里出现了@Valid
,@Validated
,@NotEmpty
等等和校验相关的注解,但是其目的却很简单:对uuid
和dtoList
两个参数进行校验,并且对list
中的元素也进行遍历校验。
后续我们在针对此代码片段进行详细说明。
@Validated
@RestController
public class DemoController {
@PutMapping("bean/validation/tips/{uuid}")
@Validated({Default.class, Update.class})
public ResponseEntity<List<ValidationDTO>> doSomething(
@PathVariable("uuid") @Size(min=32, max=32) String uuid,
@RequestBody @Valid @NotEmpty List<ValidationDTO> dtoList) {
// do something
}
}
@Valid和@Validated
@
Valid
(javax.validation
): 是Bean Validation 中的标准注解,表示对需要校验的 【字段/方法/入参】 进行校验标记@
Validated
(org.springframework.validation.annotation
):是Spring对@Valid
扩展后的变体,支持分组校验。
MVC中的校验
Spring中的校验有两种场景,一种是MVC中的controller
层校验,一种是添加@Validated
的bean的校验,上面提到的例子其实是两种场景的共用的情况。
MVC中的校验比较简单,在Controller的方法入参或者出参添加@Valid
或者@Validated
注解,即可对标记的对象进行校验。
假设需要校验的目标对象为Person
,Person
的每个字段都有一定的业务要求:
public class Person {
@NotBlank //名称不能为空
private String name;
@Pattern(regexp = "1[0-9]{10}") // 电话号码满足1开头,11位长的数字
private String number;
@NotEmpty //至少有一个地址
private List<String> address;
//getter/setter
}
则以下几种使用方法都是ok的
// test1: 使用Valied对Person进行校验
@PostMapping("test1")
public ResponseEntity<?> test1(@RequestBody @Valid Person person) {
return ResponseEntity.ok("ok");
}
// test2: 使用@Validated对person进行校验,并将错误信息绑定到BindingResult中
@PostMapping("test2")
public ResponseEntity<?> test2(@RequestBody @Validated Person person, BindingResult result) {
if (result.hasErrors()) {
for (FieldError fieldError : result.getFieldErrors()) {
//...
}
return ResponseEntity.badRequest().body("fail");
}
return ResponseEntity.ok("success");
}
// test3: 如果有多个需要校验的参数需要给到BindingResult中,则每个result需要紧跟着被校验对象
@PostMapping("test3")
public ResponseEntity<?> test3(@Validated Person person, BindingResult result,
@Validated Person person2, BindingResult result2) {
if (result.hasErrors()) {
for (FieldError fieldError : result.getFieldErrors()) {
//...
}
return ResponseEntity.badRequest().body("fail");
}
return ResponseEntity.ok("success");
}
综上代码所述:mvc的校验中@Valid
和@Validated
是可以互换的,行为基本一致。test1
中没有将校验的结果放到BindingResult
中,则controller
校验未通过时,会直接扔出异常,如没有自动捕获,则请求会返回BadRequest:400
。
校验对象树
上述例子中Person
是一个较为简单的DTO,如果是一个比较复杂的嵌套的DTO话,则校验的目标就不应该是一个对象,而是一个对象树(可以把每一复杂的对象属性看作一个节点)。这种情况只需要调整DTO中的校验注解,在需要进入到内部校验的对象或者数据集合添加@Valid
注解即可。Hibernate Validator官方文档中有较为详细的描述【占坑】。
public static class Employee {
@NotNull(groups = {Update.class})
private String uuid;
@NotBlank(message = "员工姓名不能为空")
private String name;
@Pattern(regexp = "1[0-9]{10}")
private String number;
@NotEmpty
private List<String> address;
@Valid // family中每一个Person对象都进行完整校验
@NotEmpty
private List<Person> family;
@Valid // employee对象也会被作为一个DTO完整校验
private Employee superior;
}
自定义错误信息&分组校验
上述Employee
中name
字段上的@NotEmpty
注解提供了message
,其作用是当校验未通过,将会使用message
的值作为错误消息返回。如果缺省的话,校验框架会自动生成消息如:"Employee.name can not be empty",大多数情况,校验注解中的message
都会配置为Spring的国际化消息的code
进行使用。
上述Employee
的uuid
主键字段上添加了NotNull
注解,但是提供了groups
,其值为Update.class
。其作用是当校验组包含Update.class
标记时,此校验注解才会生效,其他未提供组的校验注解默认为Default.class
组,也就是默认组。这个就是按组校验,如果要让Employee中所有的校验注解都生效,则需要使用@Validated({Update.class, Default.class})
,当然如果只需要默认组生效,直接用@Validated
或者@Validated(Default.class)
都可以。
下面是用法举例:
// 分组校验
@PostMapping("test1")
public ResponseEntity<?> test4(@RequestBody @Validated({Update.class, Default.class}) Employee employee) {
return ResponseEntity.ok("ok");
}
MVC的入参校验未生效
ok,到目前都是看起来一切都OK,但是注意下面例子中 test5/test6的
情况。
@PostMapping("test5")
public ResponseEntity<?> test5(@Valid @RequestBody List<Person> personList) {
return ResponseEntity.ok("ok");
}
@PostMapping("test6")
public ResponseEntity<?> test6(@Validated @RequestBody List<Person> personList) {
return ResponseEntity.ok("ok");
}
接口的批量操作是很常见的需求,比如批量新建数据,这个时候Controller的入参基本上都是集合的形式。但是奇怪的是这种写法并不会生效,无论是@Valid
或者@Validated
注解。为什么呢?
原因分析:
直接对List集合进行校验的行为和对自定的DTO校验的行为其实是有区别的,区别在于自定义的DTO是被作为一个整体对象校验(可以理解为一个入口),对象里的每一个字段都会被按照标记的注解进行校验。但是将List作为一个整体对象的时候,其内部是没有任何校验注解的,因为java源码中本身就没有添加校验相关的注解。上述的
test5
和test6
其本质是方法级别的校验,与下面这个例子test7
类似。这个时候@Valid
和@NotEmpty
都想把personList
作为一个字段来校验,但是MVC不支持这种模式,所以未生效。
@PostMapping("test7")
public ResponseEntity<?> test7(@Valid @NotEmpty @RequestBody List<Person> personList) {
return ResponseEntity.ok("ok");
}
解决方案:
解决办法有两种,一种是封装,将接口需要校验的参数封装为一个DTO,然后再校验。第二个种是使用Spring的方法级别的校验,在Controller的类上添加
@Validated
注解。注意任何Spring的bean都可以添加@Validated
注解来进行方法级别的校验,并不是只能用在Controller上,后续会进行详细说明。
详解@Validated
注解
关于@Validated
注解的功能,官方注释里面已经写的很清楚了,我这里简单翻译下:
- JSR-303的变种
@Valid
,支持验证组规范。支持基于Spring的JSR-303,但不支持JSR-303的特殊扩展。- 可以用于例如Spring MVC处理程序方法参数。通过{
@linkorg.springframework.validation.SmartValidator
}支持组验证。- 支持方法级的验证。在方法级别上添加此注解,会覆盖类上的组信息。但是方法上的注释不会作为切入点,要想方法上的注解生效,类上也必须添加注解。
- 支持元注解,可以添加在自定义注解上,组装为新的注解
通过官方的注释,已经能够明白这个注解的大部分功能了。上文也陆陆续续的提到的@Validated
注解,那么除了在MVC的校验中可以与@Valid
的替换外,其他情况如何来使用呢?
@Validated
加在类上
将@Validated
加在类上,Spring会将标注的类包装为切面,从而让类中的方法调用时,支持Java的校验,所以当使用@Validated
时,不仅可以用于Controller上,其他所有的Spring的bean也都可以使用。
因为@Validated
支持分组校验,当加在类上的@Validated
提供了分组参数时,默认会应用到类中所有的校验中。比如如下提供的例子,类上的@Validated
注解提供了Default
和Insert
两个分组标记参数,因此这两个组会默认应用到类中的doSomething
方法上。doSomething
方法的返回值应用了Insert
分组,在此类中就会生效。入参上添加的@NotEmpty
没有提供分组参数,默认为Default
分组,也会生效。反之,如果此例中类上的分组没有提供Default
分组,则下面doSomething
方法入参上的@NotEmpty
就不会生效。
@Validated({Insert.class, Default.class})
@Component
public class ValidatedTest {
public @NotNull(groups = Insert.class) Object doSomething(@NotEmpty Object[] arg) {
// do something
return null;
}
}
@Validated
加在方法上
当@Validated
注解单独加在方法上时,并不会按照预期的效果工作。因此,@Validated
注解加在类上是必要条件。方法上的@Validated
注解作用一般是覆盖类上提供的分组。
比如下例中的代码,因为方法上的分组覆盖了类上的分组信息,因此doSomething
方法上的@NotNull
因为分组不匹配的原因,并不会生效。
@Validated({Insert.class, Default.class})
@Component
public class ValidatedTest {
@Validated({Default.class})
public Object doSomething(@NotNull(groups = {Insert.class}) Object arg) {
// do something
return null;
}
}
实战
实际使用较为复杂的情况,会用到上文中提到的一个或者多个特性组合使用。继续使用文章开头的例子进行讲解。
@Validated
@RestController
public class DemoController {
@PutMapping("bean/validation/tips/{uuid}")
@Validated({Default.class, Update.class})
public ResponseEntity<List<ValidationDTO>> doSomething(
@PathVariable("uuid") @Size(min=32, max=32) String uuid,
@RequestBody @Valid @NotEmpty List<ValidationDTO> dtoList) {
// do something
}
}
本例中,首先类上添加了@Validated
注解,没有指定分组参数,因为默认为Default
分组。然后doSomething
方法添加了@Validated
注解并覆盖了类上的默认分组信息,额外添加了Update
分组。因此,此方法的校验会在Default
和Update
上生效。
uuid
参数上有一个@Size
注解,指定了字符串的长度只能为32,默认分组,因此会生效。
指定长度为32有什么意义,除了对生产环境的入参严格校验之外,对开发也是有帮助的。比如我经常会遇到对接的前端的代码有bug,传递了
undefined
到uuid
参数中,如果此时添加了长度校验,就可以一眼看出来问题,而不用再去debug代码。
dtoList
参数就有意思了,为了遍历校验到list
中的所有元素,需要添加@Valid
注解,除此之外,为了保证入参的有效性,避免无效的请求,添加了@NotEmpty
注解,保证集合中至少有一个元素。而方法上标注的分组信息Defult
和Update
会应用于集合中的每一个元素的校验上。
如果ValidationDTO
如下,则在Default
和Update
分组有效时只有content
和versionNumber
字段上的注解会生效。
class ValidationDTO {
@NotEmpty(groups = Insert.class)
private String id;
@NotBlank
private String content;
@NotNull(groups = Update.class)
private Long versionNumber;
@Valid
@NotEmpty(groups = Insert.class)
private List<ValidationDTO> children;
}
分组校验有什么意义:
实际的业务场景往往比较复杂,单个DTO可能会用于新建和更新等多个方法入参上,因为更新和新建的时候,业务需求的参数不一样,因此校验的要求也就不一样,这个时候如果没有分组校验的支持,我们可能需要建立两个DTO来分别满足新建和更新两种操作场景。而如果有了分组校验,就可以针对业务要求,只开启需要校验的分组,保证的代码的简洁和通用。
常见错误
- HV000151问题
javax.validation.ConstraintDeclarationException: HV000151: A method overriding another method must not redefine the parameter constraint configuration, but method XxxxImpl.
翻译过来就是说,子类重写的方法或者实现类的方法不能重新定义校验注解,如果校验注解不一致,则扔出HV000151问题。
但是以下情况是允许的:
- 覆盖父类或者接口的分组信息
public interface A {
void doSomething(@Valid Object arg);
}
@Component
@Validated
public class B implement A {
// 可以通过在子类或者实现上添加@Validated注解,
// 覆盖上层的默认分组信息,这样多个实现类就可以客制化校验信息
@Validated({NewGroup.class})
public void doSomething(@Valid Object arg) {
// do something
}
}