一般我们会在Controller的接口中对前端传递的参数做数据校验,这是一个后端开发人员的基本素养
在SpringBoot项目中,为了不让一大堆复杂的校验代码入侵业务逻辑,通常会用校验注解来简化代码
要使用校验注解,首先要引入hibernate-validator
依赖
<!--JSR303数据校验支持-->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>5.4.1.Final</version>
</dependency>
如果是Springboot工程,那么引入
spring-boot-starter-web
后就会自动引入hibernate-validator
常用校验注解
可以在需要校验的参数上标注下列校验注解
限制 | 说明 |
---|---|
@Null | 限制只能为null |
@NotNull | 限制必须不为null |
@AssertFalse | 限制必须为false |
@AssertTrue | 限制必须为true |
@DecimalMax(value) | 限制必须为一个不大于指定值的数字 |
@DecimalMin(value) | 限制必须为一个不小于指定值的数字 |
@Digits(integer,fraction) | 限制必须为一个小数,且整数部分的位数不能超过integer,小数部分的位数不能超过fraction |
@Future | 限制必须是一个将来的日期 |
@Max(value) | 限制必须为一个不大于指定值的数字 |
@Min(value) | 限制必须为一个不小于指定值的数字 |
@Past | 限制必须是一个过去的日期 |
@Pattern(value) | 限制必须符合指定的正则表达式 |
@Size(max,min) | 限制字符长度必须在min到max之间 |
@Past | 验证注解的元素值(日期类型)比当前时间早 |
@NotEmpty | 验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0) |
@NotBlank | 验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的空格 |
验证注解的元素值是Email,也可以通过正则表达式和flag指定自定义的email格式 |
1.空与非空检查
注解 | 支持Java类型 | 说明 |
---|---|---|
@Null | Object | 为null |
@NotNull | Object | 不为null |
@NotBlank | CharSequence | 不为null,且必须有一个非空格字符 |
@NotEmpty | CharSequence、Collection、Map、Array | 不为null,且不为空(length/size>0) |
2.Boolean值检查
注解 | 支持Java类型 | 说明 | 备注 |
---|---|---|---|
@AssertTrue | boolean、Boolean | 为true | 为null有效 |
@AssertFalse | boolean、Boolean | 为false | 为null有效 |
3.日期检查
注解 | 支持Java类型 | 说明 | 备注 |
---|---|---|---|
@Future | Date、Calendar、Instant、LocalDate、LocalDateTime、LocalTime、MonthDay、OffsetDateTime、OffsetTime、Year、YearMonth、ZonedDateTime、HijrahDate、JapaneseDate、MinguoDate、ThaiBuddhistDate | 验证日期为当前时间之后 | 为null有效 |
@FutureOrPresent | Date、Calendar、Instant、LocalDate、LocalDateTime、LocalTime、MonthDay、OffsetDateTime、OffsetTime、Year、YearMonth、ZonedDateTime、HijrahDate、JapaneseDate、MinguoDate、ThaiBuddhistDate | 验证日期为当前时间或之后 | 为null有效 |
@Past | Date、Calendar、Instant、LocalDate、LocalDateTime、LocalTime、MonthDay、OffsetDateTime、OffsetTime、Year、YearMonth、ZonedDateTime、HijrahDate、JapaneseDate、MinguoDate、ThaiBuddhistDate | 验证日期为当前时间之前 | 为null有效 |
@PastOrPresent | Date、Calendar、Instant、LocalDate、LocalDateTime、LocalTime、MonthDay、OffsetDateTime、OffsetTime、Year、YearMonth、ZonedDateTime、HijrahDate、JapaneseDate、MinguoDate、ThaiBuddhistDate | 验证日期为当前时间或之前 | 为null有效 |
4.数值检查
注解 | 支持Java类型 | 说明 | 备注 |
---|---|---|---|
@Max | BigDecimal、BigInteger,byte、short、int、long以及包装类 | 小于或等于 | 为null有效 |
@Min | BigDecimal、BigInteger,byte、short、int、long以及包装类 | 大于或等于 | 为null有效 |
@DecimalMax | BigDecimal、BigInteger、CharSequence,byte、short、int、long以及包装类 | 小于或等于 | 为null有效 |
@DecimalMin | BigDecimal、BigInteger、CharSequence,byte、short、int、long以及包装类 | 大于或等于 | 为null有效 |
@Negative | BigDecimal、BigInteger,byte、short、int、long、float、double以及包装类 | 负数 | 为null有效,0无效 |
@NegativeOrZero | BigDecimal、BigInteger,byte、short、int、long、float、double以及包装类 | 负数或零 | 为null有效 |
@Positive | BigDecimal、BigInteger,byte、short、int、long、float、double以及包装类 | 正数 | 为null有效,0无效 |
@PositiveOrZero | BigDecimal、BigInteger,byte、short、int、long、float、double以及包装类 | 正数或零 | 为null有效 |
@Digits(integer = 3, fraction = 2) | BigDecimal、BigInteger、CharSequence,byte、short、int、long以及包装类 | 整数位数和小数位数上限 | 为null有效 |
5.其他
注解 | 支持Java类型 | 说明 | 备注 |
---|---|---|---|
@Pattern | CharSequence | 匹配指定的正则表达式 | 为null有效 |
CharSequence | 邮箱地址 | 为null有效,默认正则 '.*' |
|
@Size | CharSequence、Collection、Map、Array | 大小范围(length/size>0) | 为null有效 |
6.hibernate-validator扩展注解
注解 | 支持Java类型 | 说明 |
---|---|---|
@Length | String | 字符串长度范围 |
@Range | 数值类型和String | 指定范围 |
@URL | String | URL地址验证 |
基础校验
1.基本数据类型校验
如果Controller的接口方法中需要校验的参数是基本数据类型,如String
、Integer
等非封装的对象,那么可以按照如下步骤:
- 在Controller上添加
@Validated
- 在Controller的接口方法中给需要校验的参数前面添加校验注解
@RestController
@RequestMapping("validate")
@Validated
public class ValidateController {
@GetMapping("test")
public String test(@NotBlank(message = "名称不能为空") String name) {
return "success";
}
}
请求参数不传name,校验不通过,会抛出javax.validation.ConstraintViolationException
异常
2.javabean校验
如果Controller的接口方法中需要校验的参数是封装的javabean,想要校验bean中的部分属性,那么可以按照如下步骤:
- 给javabean中需要校验属性上添加校验注解
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
// 字符串长度校验,长度必须在2~5之间
@Length(min = 2, max = 5)
private String name;
private Integer age;
@JsonFormat(pattern = "yyyy-MM-dd")
@Past // 日期校验,日期必须是过去的一个日期
private Date birth;
}
- 在Controller的请求处理方法参数声明中,在需要校验的Bean的形参前面加上
@Valid
注解,并在该Bean的形参后面紧跟着声明一个BindingResult
类型的参数(注意,中间不能夹杂任何其他类型的参数
),这样校验的结果就会保存在BindingResult
对象中
@RestController
@RequestMapping("validate")
public class ValidateController {
@PostMapping("test1")
public String testValidate(@RequestBody @Valid User user, BindingResult bindingResult){
System.out.println(user);
System.out.println(bindingResult);
return "success";
}
}
我们故意传入一些校验不通过的数据进行测试
name和birth的校验将会不通过,控制台打印输出如下
User(name=baobao, age=18, birth=Sun Jul 25 08:00:00 CST 2021)
org.springframework.validation.BeanPropertyBindingResult: 2 errors
Field error in object 'user' on field 'name': rejected value [baobao]; codes [Length.user.name,Length.name,Length.java.lang.String,Length]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.name,name]; arguments []; default message [name],5,2]; default message [长度需要在2和5之间]
Field error in object 'user' on field 'birth': rejected value [Sun Jul 25 08:00:00 CST 2021]; codes [Past.user.birth,Past.birth,Past.java.util.Date,Past]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.birth,birth]; arguments []; default message [birth]]; default message [需要是一个过去的时间]
BindingResult
对象中包含了校验错误信息
注意:
如果我们在Controller方法参数中不加
BindingResult
对象捕获校验失败信息,那么校验失败信息会以org.springframework.web.bind.MethodArgumentNotValidException
异常形式被异常解析器处理2021-07-23 23:27:33.372 WARN 4352 --- [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public java.lang.String com.baobao.springboot.validate.controller.ValidateController.testValidate(com.baobao.springboot.validate.entity.User) with 2 errors: [Field error in object 'user' on field 'birth': rejected value [Sun Jul 25 08:00:00 CST 2021]; codes [Past.user.birth,Past.birth,Past.java.util.Date,Past]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.birth,birth]; arguments []; default message [birth]]; default message [需要是一个过去的时间]] [Field error in object 'user' on field 'name': rejected value [baobao]; codes [Length.user.name,Length.name,Length.java.lang.String,Length]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [user.name,name]; arguments []; default message [name],5,2]; default message [长度需要在2和5之间]] ]
如果Controller中需要校验的参数是带泛型的List,那么在参数对应位置标注
@Valid
,同时要在类上标注@Validated
,这样校验才会生效。并且效果是针对List中的每个对象都做校验校验失败后会抛出
javax.validation.ConstraintViolationException
异常,并且会用从0开始的索引来表明是List中的哪个对象的字段校验不通过
校验异常统一处理
之前我们介绍的都是在需要校验数据的方法中利用BindingResult
对象来获取校验错误信息,而实际开发中需要在统一的异常处理类中对校验失败异常进行处理
首先定义一个Controller接口返回通用json数据对应的类
public class R extends HashMap<String, Object> {
private static final long serialVersionUID = 1L;
public R(int code, String msg) {
put("code", code);
put("msg", msg);
}
public static R error() {
return error(500, "未知异常,请联系管理员");
}
public static R error(String msg) {
return error(500, msg);
}
public static R error(int code, String msg) {
return new R(code, msg);
}
public static R ok() {
return new R(200, "请求成功");
}
public static R ok(String msg) {
return new R(200, msg);
}
public static R ok(Map<String, Object> map) {
R r = R.ok();
r.putAll(map);
return r;
}
@Override
public R put(String key, Object value) {
super.put(key, value);
return this;
}
}
然后编写统一的异常处理类:
- 在类上标注
@RestControllerAdvice
指定Controller所在的包,表示处理Controller中发生的异常。@RestControllerAdvice
是@ControllerAdvice
和@ResponseBody
的合体,代表这个异常处理类返回的都是json数据 - 在类中定义方法,指定要处理的异常类型,那么发生对应类型的异常就会回调方法
@Slf4j
@RestControllerAdvice(basePackages = "com.baobao.springboot.validate.controller")
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public R handleValidateException(Exception e) {
log.error("数据校验出现问题{},异常类型{}",e.getMessage(),e.getClass());
return R.error();
}
}
然后我们修改Controller中的测试方法,将BindingResult
的逻辑去掉,碰到校验失败时框架会自动将异常抛出
@PostMapping("test1")
public R testValidate(@RequestBody @Valid User user){
System.out.println(user);
return R.ok();
}
此时发送校验失败的请求参数测试,会触发校验失败异常,由GlobalExceptionHandler
捕获并返回结果
此时控制台日志打印的异常类型是MethodArgumentNotValidException
知道了异常的具体类型,我们就在GlobalExceptionHandler
专门添加一个处理校验失败异常MethodArgumentNotValidException
的方法,从MethodArgumentNotValidException
对象中获取BindingResult
,进而获取校验失败信息返回
@Slf4j
@RestControllerAdvice(basePackages = "com.baobao.springboot.validate.controller")
public class GlobalExceptionHandler {
// 处理校验失败异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public R handleValidateException(MethodArgumentNotValidException e) {
// 获取BindingResult
BindingResult bindingResult = e.getBindingResult();
Map<String, String> errorMap = new HashMap<>(8);
// 将校验错误字段和错误信息提取到map中
bindingResult.getFieldErrors().forEach(item -> errorMap.put(item.getField(),item.getDefaultMessage()));
return R.error(400,"数据校验出错").put("error", errorMap);
}
// 处理其他异常
@ExceptionHandler(Exception.class)
public R handleOtherException(Exception e) {
return R.error(e.getMessage());
}
}
再次测试,返回的结果如下
因为之前的校验普通非Bean类型字段和带泛型List失败时抛出的是javax.validation.ConstraintViolationException
异常,所以我们还需要在GlobalExceptionHandler
中添加针对ConstraintViolationException
的处理方法
@Slf4j
@RestControllerAdvice(basePackages = "com.baobao.springboot.validate.controller")
public class GlobalExceptionHandler {
// 处理校验失败异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public R handleValidateException(MethodArgumentNotValidException e) {
// 获取BindingResult
BindingResult bindingResult = e.getBindingResult();
Map<String, String> errorMap = new HashMap<>(8);
// 将校验错误字段和错误信息提取到map中
bindingResult.getFieldErrors().forEach(item -> errorMap.put(item.getField(),item.getDefaultMessage()));
return R.error(400,"数据校验出错").put("error", errorMap);
}
// 处理校验失败异常
@ExceptionHandler(ConstraintViolationException.class)
public R handleValidException(ConstraintViolationException e){
// 获取异常信息
Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations();
// 将异常信息收集到Map,key为校验失败的字段,value为失败原因
Map<Path, String> errorMap = constraintViolations.stream().collect(Collectors.toMap(ConstraintViolation::getPropertyPath, ConstraintViolation::getMessage));
// 返回校验失败信息
return R.error(400, "数据校验出错").put("errorMap", errorMap);
}
// 处理其他异常
@ExceptionHandler(Exception.class)
public R handleOtherException(Exception e) {
return R.error(e.getMessage());
}
}
修改Controller中对应方法的返回值
测试test方法返回结果如下
测试test2方法返回结果如下
Bean的级联嵌套校验
假设我们有Employee
,其中有个属性不是基本类型,而是Department
类型
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Employee {
private Long id;
@NotBlank(message = "员工姓名不能为空")
private String name;
@Positive(message = "年龄必须大于0")
private Integer age;
private Department department;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Department {
private Long id;
@NotBlank(message = "部门名称不能为空")
private String name;
private List<Employee> employeeList;
}
在Controller中定义一个测试方法,校验Employee
对象
@PostMapping("test3")
public R addEmployee(@RequestBody @Valid Employee employee) {
return R.ok();
}
此时对Employee
的其他校验会生效,但是对department
属性的校验不会生效。我们发送如下请求测试
可以发现age校验不通过,但是department
没有携带name属性,本来应该校验不通过,实际上没有生效。想要Employee
中的deparment
属性的校验也生效,主需要在属性上添加@Valid
注解即可
此时再次测试效果如下
同样,如果想校验带泛型的List,比如Department
中的employeeList
属性,只需要在对应位置加@Valid
注解即可
此时的效果是:会校验List中每个Employee
对象的属性,并在校验错误结果中标注是List中的哪个下标的元素的哪个字段校验失败,比如employeeList[0].name
Service层校验
一般情况下,我们都是在Controller层接收前端数据并做校验。但是某些情况下也必须在Service层做校验,比如RPC调用的时候。在Service层做校验的步骤如下:
- 首先创建Service接口,并在其方法中对要校验的参数标注
@Valid
public interface EmployeeService {
// 一定要在接口中标注@Valid,在实现类中标注会报异常
void add(@Valid Employee employee);
}
- 创建实现类,并在其类上标注
@Validated
,注意与Controller层校验不同,这里无论是基本数据类型校验还是Bean校验,都要在类上加这个注解,否则不生效
@Service
@Validated // 无论基本数据类型还是bean类型都要标注
public class EmployeeServiceImpl implements EmployeeService {
@Override
public void add(Employee employee) {
}
}
然后我们在Controller中取消掉校验,注入Service并调用
此时插入不合法数据测试,结果如下
对于Service层校验,要重点注意以下几点:
@Valid注解一定要标在接口方法需要校验的参数上,不能标在实现类的方法参数上
- 对于
@Validated
注解,无论是基本数据类型校验还是Bean校验,都要加这个注解
。可以加在接口上,也可以加在实现类上。推荐加在实现类上
,这样更灵活,因为如果加在接口上,那么如果接口有多个实现类,所有实现类都会开启校验,无法手动控制哪些校验生效,哪些不生效- 如果Service层没有采用接口和实现类的形式,而是直接写一个类,没有接口,此时只需要把
@Validated
加到类上,@Valid
加到类中方法需要校验的参数上即可让校验生效- 校验失败的异常是
javax.validation.ConstraintViolationException
自定义校验注解
1.校验注解的原理分析
思考如下2个问题:
- 为什么同一个校验注解可以针对多种不同类型的参数生效?比如
@Size
可以针对List、String生效 - 为什么大部分的注解在参数为null可以校验通过,只有参数不为null时才会真正去校验
以@Size
注解为例,我们看一下它的源码
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(List.class)
@Documented
@Constraint(validatedBy = { }) // 校验注解的实际实现类
public @interface Size {
String message() default "{javax.validation.constraints.Size.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
/**
* @return size the element must be higher or equal to
*/
int min() default 0;
/**
* @return size the element must be lower or equal to
*/
int max() default Integer.MAX_VALUE;
/**
* Defines several {@link Size} annotations on the same element.
*
* @see Size
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@interface List {
Size[] value();
}
}
注意到注解上的@Constraint(validatedBy = { })
,它实际指定了哪些类会读取该注解信息并进行校验,这些类都会实现ConstraintValidator
接口
// 实际实现校验的类,泛型A为对应的校验注解,泛型T为被校验注解A标注的那个需要校验的参数类型
public interface ConstraintValidator<A extends Annotation, T> {
// 初始化方法,会传入校验注解A,可以从中获取注解中的基本属性或自定义属性,保存到该接口实现类的属性中,便于后续校验时使用
default void initialize(A constraintAnnotation) {
}
// 实际的校验方法,value为实际需要校验的值。校验成功返回true,失败返回false
boolean isValid(T value, ConstraintValidatorContext context);
}
通过源码可以发现,对于@Size
注解,有非常多的ConstraintValidator
的实现类,分别实现对于不同类型的@Size
校验
这些针对不同类型的校验类都在ConstraintHelper
中与对应的注解绑定好了
我们查看以下实现String类型@Size
校验的类SizeValidatorForCharSequence
public class SizeValidatorForCharSequence implements ConstraintValidator<Size, CharSequence> {
private static final Log LOG = LoggerFactory.make( MethodHandles.lookup() );
private int min;
private int max;
// 初始化方法
@Override
public void initialize(Size parameters) {
// 获取@Size注解min、max属性的值,并保存到成员变量中
min = parameters.min();
max = parameters.max();
validateParameters();
}
// 校验方法
@Override
public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) {
// 如果需要校验的参数为null,直接返回true
if ( charSequence == null ) {
return true;
}
// 校验String的长度
int length = charSequence.length();
return length >= min && length <= max;
}
private void validateParameters() {
if ( min < 0 ) {
throw LOG.getMinCannotBeNegativeException();
}
if ( max < 0 ) {
throw LOG.getMaxCannotBeNegativeException();
}
if ( max < min ) {
throw LOG.getLengthCannotBeNegativeException();
}
}
}
可以发现在初始化方法中获取了@Size
注解的属性min
和max
的值并保存到成员变量,然后校验时判断String的长度是否介于min
和max
之间
值得注意的是,如果
@Size
注解校验时发现了需要校验的参数为null,直接返回true,代表校验通过。这是因为校验非null的工作已经有@NotNull
注解接管了,其他注解不应该校验null值,只有在不为null的时候校验自己的规则即可。如果既要校验null又想校验@Size
,同时添加这2个注解即可,这样做既明确又灵活(@Size
只做好自己的规则校验,不用跟null捆绑在一起),所以大多数校验注解都不会去校验null值,而只在非null值时校验自己的规则。所以推荐我们自定义注解也无需去校验null
2.自定义校验注解
假设我们要自定义一个校验规则的注解,校验某个字段的值是否在一个集合里面,步骤如下
- 首先自定义一个注解ContainsIn
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface ContainsIn {
// 默认提示的消息,可以指定资源文件中的key,也可以直接写死
String message() default "{com.baobao.springboot.validate.validate.ContainsIn.message}";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
// 自定义属性,校验目标字段是否在这个数组中
int[] vals() default {};
}
注意这个注解要依赖于javax.validation
,所以要在pom中引入依赖(如果是SpringBoot项目可以不引入,spring-boot-starter-web
默认会引入)
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
- 在resource目录下新建properties资源文件,用于给自定义校验注解指定校验失败时默认的提示消息,key的值要于注解中message的默认值对应。如果message字段的默认值在注解中写死,可以省略这个创建资源文件的步骤
- 编写一个校验的类,实现
ConstraintValidator
接口,获取注解的vals属性,并实现校验逻辑
// 2个泛型分别是自定义校验注解的类型和需要校验的字段的类型
public class ContainsInValidator implements ConstraintValidator<ContainsIn, Integer> {
// 保存自定义校验注解@ContainsIn中指定的数组
private Set<Integer> set = new HashSet<>();
/**
* 初始化方法
* @param constraintAnnotation 自定义的校验注解
*/
@Override
public void initialize(ContainsIn constraintAnnotation) {
// 拿到自定义校验注解的vals属性的值,获取目标值数组
int[] vals = constraintAnnotation.vals();
// 遍历数组,将其放入容器
for (int val : vals) {
set.add(val);
}
}
/**
* 校验方法
* @param value 需要校验的值
* @param context
* @return true表示校验通过,false表示不通过
*/
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
// 当value不为空时,判断要校验的值是否在注解声明的数组中
return value == null || set.contains(value);
}
}
- 在自定义校验注解上标注
@Constraint
,指定使用自定义的校验类
我们给User类中的age字段添加自定义校验注解,要求age只能传1,3,5这3个值
再次测试,只有age传1,3,5才可以校验通过
分组校验
1.Bean分组校验
试想一个场景,如果我们对Controller的不同方法想采用不同的校验规则,但是不同的方法中需要校验的参数又是同一个实体类,比如新增员工时id必须为null,即不能带id过来,因为数据库设置了自增主键;修改员工时必须带上id。这样该如何实现呢?此时需要用到分组校验。首先在Employee
类中新建2个接口,分别代表新增分组和修改分组
然后在Employee
类上,给id字段添加多个校验注解,每个校验注解指定分组,这样就可以对不同的分组启动不同的校验规则
最后在Controller中定义新增员工和修改员工的方法,给需要校验的参数标注@Validated
,并指定启用哪个分组的校验规则
测试新增员工
测试修改员工
通过上面的测试结果我们可以发现,如果在Controller中的方法给参数指定了校验分组,那么实体类中标注了校验注解,但是没有指定分组的校验规则将不生效
,比如上面Employee
中的age以及department
中的级联校验字段name。这是因为bean中的属性标注了校验注解后,如果指定了分组,那么就只属于对于的分组,如上面的id
属性,Null属于Add组,NotNull属于Update组;但是没有指定分组的校验注解,都属于默认Default
分组。如果在Controller的方法参数中标注了@Validated
校验注解,并指定了自定义分组,那么那些没有指定分组的校验注解将无法生效。解决方案是,@Validated
指定了自定义分组进行特殊字段区分校验后,再添加Default
分组,对没有指定过分组的属性也进行校验。通常把针对任何情况都需要校验的属性不指定分组(即归为Default
组)
此时再次测试可以发现没有指定分组的校验注解也可以生效
2.集合类型分组校验
假设在Controller同时存在批量更新和批量添加Employee
的方法,接收的参数均为List<Employee>
@PostMapping("test5")
public R addEmployeeBatch(@RequestBody @Validated({Employee.Add.class, Default.class}) List<Employee> employeeList) {
return R.ok();
}
@PutMapping("test6")
public R updateEmployeeBatch(@RequestBody @Validated({Employee.Update.class, Default.class}) List<Employee> employeeList) {
return R.ok();
}
此时分组校验将会失效
在这种场景下如果想采用分组校验,只能自定义校验注解
。创建注解@ValidListGroup
,自定义一个属性表示用哪个分组对集合进行校验
@Target({ FIELD, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = { })
public @interface ValidListGroup {
String message() default "";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
// 自定义属性,代表对集合类型的数据采用哪个分组进行校验
Class<?>[] groupings() default {Default.class};
}
然后自定义一个工具类ValidatorUtils
,从容器中获取Validator
对象
@Component
public class ValidatorUtils {
public static Validator validator;
// 注入Validator对象
@Autowired
public void setValidator(Validator validator) {
ValidatorUtils.validator = validator;
}
}
然后自定义校验器ListGroupValidator
,实现ConstraintValidator
接口。获取自定义注解上指定的groupings
分组信息,然后遍历List,对每个对象调用原生的Validator
进行校验获取所有字段的校验错误结果。如果至少有1个对象校验失败,那么就抛出异常给全局异常处理器处理
public class ListGroupValidator implements ConstraintValidator<ValidListGroup, List> {
// 自定义注解@ValidListGroup中指定的校验分组
private Class<?>[] groupings;
/**
* 校验器初始化方法
* @param constraintAnnotation 校验注解对象
*/
@Override
public void initialize(ValidListGroup constraintAnnotation) {
// 获取注解上指定的分组
groupings = constraintAnnotation.groupings();
}
/**
* 校验方法
* @param list 需要校验的对象
* @param context 上下文
* @return 校验通过返回true,否则返回false
*/
@Override
public boolean isValid(List list, ConstraintValidatorContext context) {
// 保存校验错误的map,key为list中对象的索引下标,value为这个对象所有校验错误字段信息的集合
Map<Integer, Set<ConstraintViolation<Object>>> errors = new HashMap<>();
// 遍历list,校验每个元素
for (int i = 0; i < list.size(); i++) {
// 获取指定索引的元素
Object object = list.get(i);
// 用工具类获取validator对象,进行分组校验,返回错误结果
Set<ConstraintViolation<Object>> error = ValidatorUtils.validator.validate(object, groupings);
// 如果当前校验的对象有属性错误
if (error.size() > 0){
// 保存错误结果
errors.put(i, error);
}
}
// 如果list中至少有1个对象校验失败,那么就抛出校验失败异常,携带失败信息
if (errors.size() > 0){
throw new ListGroupValidException(errors);
}
return true;
}
}
自定义异常ListGroupValidException
,只是简单保存了校验失败信息集合
public class ListGroupValidException extends RuntimeException {
private Map<Integer, Set<ConstraintViolation<Object>>> errors;
public ListGroupValidException(Map<Integer, Set<ConstraintViolation<Object>>> errors) {
this.errors = errors;
}
public Map<Integer, Set<ConstraintViolation<Object>>> getErrors() {
return errors;
}
public void setErrors(Map<Integer, Set<ConstraintViolation<Object>>> errors) {
this.errors = errors;
}
}
最终可以在全局异常处理器中对异常进行处理,注意接收的异常类型一定要是ValidationException
,在方法中强转为自定义异常,否则会报错
// 处理自定义集合分组校验注解的校验失败异常
@ExceptionHandler(ValidationException.class)
public R handleValidException(ValidationException e){
// 创建最终的错误结果
Map<Integer, Map<Path, String>> errorMap = new HashMap<>(8);
// 强转为自定义异常
Throwable throwable = e.getCause();
ListGroupValidException exception = (ListGroupValidException) throwable;
// 获取异常信息
Map<Integer, Set<ConstraintViolation<Object>>> errors = exception.getErrors();
// 将异常信息收集到Map,key为集合中校验失败元素的索引,value为校验失败字段和原因
errors.forEach((k, v) -> {
errorMap.put(k, v.stream().collect(Collectors.toMap(ConstraintViolation::getPropertyPath, ConstraintViolation::getMessage)));
});
// 返回校验失败信息
return R.error(400, "集合数据分组校验出错").put("errorMap", errorMap);
}
最后给自定义注解@ValidListGroup
加上关联的自定义校验器
在Controller对应的方法中标注自定义注解,即可实现集合的分组校验
测试效果如下
以上方式会校验List集合中的每个对象。如果想要一旦对某个对象的某个字段校验失败,立即停止校验,返回错误,可以给自定义注解新增一个属性,代表是否开启快速失败模式
然后修改自定义校验器的逻辑,判断注解是否开启了快速失败,如果开启,一旦发现校验错误立即退出校验循环即可
然后给Controller中的自定义List分组校验注解中添加quickFail
属性,开启快速失败模式
此时List中如果有多个元素不满足校验规则,也只会返回第一个校验失败元素的校验信息,测试效果如下
Bean属性之间的关联校验
思考这样一个场景,我们在Controller
中定义了一个添加、更新员工2合1的方法,如果传入的对象id为null,就是添加,id不为null就是更新。那么此时如果要校验添加时员工姓名不能为空,修改时可以为空该怎么实现呢?这就要用到参数间的关联校验,员工姓名需要根据id的值是否为null来决定是否采用非空校验,思路是给员工姓名加上非空校验注解,并指定分组Add。此时Controller
层的方法中参数的校验注解不能指定Add分组,因为我们事先无法得知传入的参数id是否为空,所以要在程序运行时根据id的值,动态地给name字段加校验分组
首先给姓名字段加上校验注解,指定分组
然后创建一个类,实现DefaultGroupSequenceProvider<T>
,指定泛型为要校验的bean类型。根据id是否为空决定是否加入Add组对姓名进行校验
public class EmployeeGroupSequenceProvider implements DefaultGroupSequenceProvider<Employee> {
@Override
public List<Class<?>> getValidationGroups(Employee employee) {
// 创建需要校验的分组集合
List<Class<?>> defaultGroupSequence = new ArrayList<>();
// 添加默认分组Default
defaultGroupSequence.add(Employee.class);
// 根据employee的id是否为空,决定是否加上Add组
Optional.ofNullable(employee).ifPresent(e -> {
Long id = e.getId();
if (id == null){
// id为空,加上Add组
defaultGroupSequence.add(Employee.Add.class);
}
});
return defaultGroupSequence;
}
}
最后在需要校验的bean上标注@GroupSequenceProvider
,指定自定义分组提供类即可
此时,如果在Controller
的方法中,对需要校验的bean指定分组,就会按照指定的分组校验。如果不指定分组,那么我们自定义的DefaultGroupSequenceProvider<Employee>就会生效
,首先加上默认分组的校验,然后判断id的值是否为空,如果为空则动态添加上Add
分组对姓名进行非空校验。我们在Controller中创建一个方法测试
@PostMapping("test7")
public R saveOrUpdateEmployee(@RequestBody @Valid Employee employee) {
return R.ok();
}
此时如果id为null,则会对name进行校验
如果id不为null,则不会对name进行校验
注意:上面例子中,当id为null时,要想让name的Add分组校验生效,必须先通过Default分组的校验,如果Default分组的校验不通过,那么将不会再对Add分组进行校验。比如age字段属于Default分组,当age校验不通过时,name的校验规则将不生效
配置快速失败模式
默认情况下,在校验时,会校验bean中或者方法中的所有参数,把所有错误返回。比如我们校验下列User类型
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
// 字符串长度校验,长度必须在2~5之间
@Length(min = 2, max = 5)
private String name;
@Positive(message = "年龄必须大于0")
private Integer age;
@JsonFormat(pattern = "yyyy-MM-dd")
@Past // 日期校验,日期必须是过去的一个日期
private Date birth;
}
如果我们想在一旦发现某个字段校验不通过后立即返回,不再继续校验其他字段,可以配置快速失败模式
。配置方式为创建一个校验配置类,往容器中注入一个快速失败校验器,替换掉默认的校验器
@Configuration
public class ValidationConfig {
/**
* 配置校验框架 快速返回模式
*/
@Bean
public Validator validator() {
ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
.configure()
.failFast(true) // 开启快速失败模式
.buildValidatorFactory();
return validatorFactory.getValidator();
}
}
此时再次测试,发现只要碰到校验失败的字段立即返回,不再校验其他字段
评论区