侧边栏壁纸
博主头像
包包博主等级

talk is cheap,show me the code

  • 累计撰写 25 篇文章
  • 累计创建 59 个标签
  • 累计收到 54 条评论

SpringBoot后端数据校验实战

包包
2021-07-25 / 0 评论 / 10 点赞 / 3,116 阅读 / 22,546 字 / 正在检测是否收录...
温馨提示:
本文最后更新于 2022-04-19,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

一般我们会在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 验证注解的元素值是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有效
@Email CharSequence 邮箱地址 为null有效,默认正则 '.*'
@Size CharSequence、Collection、Map、Array 大小范围(length/size>0) 为null有效

6.hibernate-validator扩展注解

注解 支持Java类型 说明
@Length String 字符串长度范围
@Range 数值类型和String 指定范围
@URL String URL地址验证

基础校验

1.基本数据类型校验

如果Controller的接口方法中需要校验的参数是基本数据类型,如StringInteger等非封装的对象,那么可以按照如下步骤:

  1. 在Controller上添加@Validated
  2. 在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中的部分属性,那么可以按照如下步骤:

  1. 给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;
}
  1. 在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;
    }
}

然后编写统一的异常处理类:

  1. 在类上标注@RestControllerAdvice指定Controller所在的包,表示处理Controller中发生的异常。@RestControllerAdvice@ControllerAdvice@ResponseBody的合体,代表这个异常处理类返回的都是json数据
  2. 在类中定义方法,指定要处理的异常类型,那么发生对应类型的异常就会回调方法
@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层做校验的步骤如下:

  1. 首先创建Service接口,并在其方法中对要校验的参数标注@Valid
public interface EmployeeService {
    // 一定要在接口中标注@Valid,在实现类中标注会报异常
    void add(@Valid Employee employee);
}
  1. 创建实现类,并在其类上标注@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注解的属性minmax的值并保存到成员变量,然后校验时判断String的长度是否介于minmax之间

值得注意的是,如果@Size注解校验时发现了需要校验的参数为null,直接返回true,代表校验通过。这是因为校验非null的工作已经有@NotNull注解接管了,其他注解不应该校验null值,只有在不为null的时候校验自己的规则即可。如果既要校验null又想校验@Size,同时添加这2个注解即可,这样做既明确又灵活(@Size只做好自己的规则校验,不用跟null捆绑在一起),所以大多数校验注解都不会去校验null值,而只在非null值时校验自己的规则。所以推荐我们自定义注解也无需去校验null

2.自定义校验注解

假设我们要自定义一个校验规则的注解,校验某个字段的值是否在一个集合里面,步骤如下

  1. 首先自定义一个注解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>
  1. 在resource目录下新建properties资源文件,用于给自定义校验注解指定校验失败时默认的提示消息,key的值要于注解中message的默认值对应。如果message字段的默认值在注解中写死,可以省略这个创建资源文件的步骤

  1. 编写一个校验的类,实现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);
    }
}
  1. 在自定义校验注解上标注@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();
    }
}

此时再次测试,发现只要碰到校验失败的字段立即返回,不再校验其他字段

10

评论区