010.SpringBoot中使用参数校验框架validator

266 阅读9分钟

本节目标

  • springboot校验框架validator简介
  • springboot集成Validator校验框架
  • 全局异常捕获校验框架异常
  • 分组校验
  • 自定义校验注解

validator简介

在日常的接口开发中,为了防止非法参数对业务造成影响,经常需要对接口的参数做校验,例如登录的时候需要校验用户名密码是否为空,创建用户的时候需要校验邮件、手机号码格式是否准确。Validator框架就是为了解决开发人员在开发的时候少写代码,提升开发效率;Validator专门用来进行接口参数校验,例如常见的必填校验,email格式校验等。Spring Boot中使用Hibernate Validator进行后端效验,而Hibernate Validator遵循了JSR303参数校验规范并且进行了扩展。

JSR(Java Specification Requests)是Java界的重要标准;JSR又细分很多标准,其中JSR303就代表Bean Validation。更多细节可参考:jcp.org/en/jsr/deta…

简言之,validator是用来进行参数校验的一个框架。

validator支持的注解

注解功能
@AssertFalse可以为null,如果不为null的话必须为false
@AssertTrue可以为null,如果不为null的话必须为true
@DecimalMax设置不能超过最大值
@DecimalMin设置不能超过最小值
@Digits设置必须是数字且数字整数的位数和小数的位数必须在指定范围内
@Future日期必须在当前日期的未来
@Past日期必须在当前日期的过去
@Max最大不得超过此最大值
@Min最大不得小于此最小值
@NotNull不能为null,可以是空
@Null必须为null
@Pattern必须满足指定的正则表达式
@Size集合、数组、map等的size()值必须在指定范围内
@Email必须是email格式
@Length长度必须在指定范围内
@NotBlank字符串不能为null,字符串trim()后也不能等于“”
@NotEmpty不能为null,集合、数组、map等size()不能为0;字符串trim()后可以等于“”
@Range值必须在指定范围内
@URL必须是一个URL

集成Validator

1. SpringBoot三板斧之添加依赖

<!-- springboot -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency><dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

注:从springboot-2.3开始,校验包被独立成了一个starter组件,所以需要引入validation和web,而springboot-2.3之前的版本只需要引入 web 依赖就可以了。

2.定义请求的Bean

@Data
public class UserBO {
    @Length(min = 6,max = 12,message = "userid长度必须位于6到12之间")
    private String userid;
​
    @Range(min = 1, max = 100, message = "年龄填写范围[1-100]")
    private Integer age;
​
    @NotBlank(message = "名字为必填项")
    private String name;
​
    @Email(message = "请填写正确的邮箱地址")
    private String email;
​
    @Pattern(regexp = "男|女|未知", message = "性别请填写男、女或者未知")
    private String sex;
​
    @NotEmpty(message = "级别不能为空")
    private String level;
}

3.测试controller

@Validated
@RestController
public class ValidatorTestController {
​
    /**
     * RequestBody校验
     */
    @PostMapping("/test1")
    public R<UserBO> test1(@Validated @RequestBody UserBO userBO){
        return R.ok(userBO);
    }
​
    /**
     * Form表单校验
     */
    @PostMapping(value = "/test2")
    public R<UserBO> test2(@Validated UserBO userBO){
        return R.ok(userBO);
    }
​
    /**
     * 单个参数校验.当使用单参数校验时需要在Controller上加上@Validated注解,否则不生效
     */
    @GetMapping(value = "/test3")
    public R<Void> test3(@Email(message = "请填写正确的邮箱地址") String email){
        return R.ok(StrUtil.format("{}校验成功!", email));
    }
​
}

4.全局异常捕获

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
​
    /**
     * 默认全局异常处理。
     * @param e the e
     * @return ResultData
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public R<Void> exception(Exception e) {
        log.error("全局异常信息 ex={}", e.getMessage(), e);
        return R.fail(e.getMessage());
    }
​
    /**
     * 捕获校验框架Validator抛出的异常
     * BindException: 表单数据校验失败
     * ConstraintViolationException: 单参数校验失败
     * MethodArgumentNotValidException: RequestBody校验失败
     */
    @ExceptionHandler(value = {BindException.class, ConstraintViolationException.class, MethodArgumentNotValidException.class})
    public R<Void> handleValidatedException(Exception e) {
        if (e instanceof MethodArgumentNotValidException) {
            // RequestBody校验失败
            MethodArgumentNotValidException ex = (MethodArgumentNotValidException) e;
            return R.fail(ex.getBindingResult().getAllErrors().stream()
                            .map(ObjectError::getDefaultMessage)
                            .collect(Collectors.joining("; "))
            );
        } else if (e instanceof ConstraintViolationException) {
            // 单参数校验失败
            ConstraintViolationException ex = (ConstraintViolationException) e;
            return R.fail(ex.getConstraintViolations().stream()
                            .map(ConstraintViolation::getMessage)
                            .collect(Collectors.joining("; "))
            );
        } else if (e instanceof BindException) {
            // 表单数据校验失败
            BindException ex = (BindException) e;
            return R.fail(ex.getAllErrors().stream()
                            .map(ObjectError::getDefaultMessage)
                            .collect(Collectors.joining("; "))
            );
        }
        return R.fail();
    }
}

5.postman测试一下

image-20230615154400342

比较@Valid 和 @Validated 两个注解

在某些项目中两种注解都有可能看到,@Valid注解与@Validated注解功能大部分类似;两者的不同主要在于:

  • @Valid属于javax下的,而@Validated属于spring框架下的
  • @Valid支持嵌套校验、而@Validated不支持
  • @Validated支持分组,而@Valid不支持
  • @Validated 进行校验的时候,当校验不通过的时候,程序会抛出400异常,阻止方法中的代码执行,这时需要 再写一个全局校验异常捕获处理类,然后返回校验提示。

总结:

在springboot框架下,尽量都用@Validated即可。

分组校验

一个请求对象在添加时需要的填写的数据,在更改时未必需要;反之亦然。就如UserBO对象,userid这个值,在添加时不影响提供,但是在编辑时则必须提供。这时就需要用到分组校验了。

1.定义分组接口

public interface ValidGroup extends Default {
    interface Create extends ValidGroup{
    }
    interface Update extends ValidGroup{
    }
    interface Query extends ValidGroup{
    }
    interface Delete extends ValidGroup{
    }
}

2.请求对象指定分组

@Data
public class UserBO {
    @Length(min = 6,max = 12,message = "userid长度必须位于6到12之间")
    @NotBlank(groups = {ValidGroup.Update.class}, message = "用户id不能为空")
    private String userid;
​
    @Range(min = 1, max = 100, message = "年龄填写范围[1-100]")
    @NotNull(groups = {ValidGroup.Create.class, ValidGroup.Update.class},message = "年龄为必填项")
    private Integer age;
​
    @NotBlank(groups = {ValidGroup.Create.class, ValidGroup.Update.class},message = "名字为必填项")
    private String name;
​
    @Email(message = "请填写正确的邮箱地址")
    private String email;
​
    @Pattern(regexp = "男|女|未知", message = "性别请填写男、女或者未知")
    @NotBlank(groups = {ValidGroup.Create.class, ValidGroup.Update.class},message = "性别为必填项")
    private String sex;
​
    @NotEmpty(message = "级别不能为空")
    private String level;
}

注意:

如果对所有分组都要求不为空的话,其实是不用指定分组的。比如上面的age,name,sex。

3.controller中指定分组

@PostMapping(value = "/add")
public R<Void> add(@Validated(value=ValidGroup.Create.class) @RequestBody UserBO userBO){
    return R.ok();
}
​
@PostMapping(value = "/update")
public R<Void> update(@Validated(value=ValidGroup.Update.class) @RequestBody UserBO userBO){
    return R.ok();
}

4.分组测试

image-20230615170436786

image-20230615170551969

可以看到相同的请求参数,在更新请求中,是报错的。

自定义校验注解

虽然Validator在大多数业务场景都够用了,尤其是支持正则表达式这个注解,能解决很多复杂的情况。但是我们依然有可能遇到一些需要自定义注解的场景。比如:我们在请求信息中一般会有一个sign验证的验证码。这个验证码的规则可能就比较复杂,这时我们就需要自定义注解进行验签了。

1.验签码校验需求描述

签名计算规则如下:

  • 首先将请求参数中的每一个字段按照0-9-A-Z-a-z的顺序排序(ASCII字典序),若遇到相同首字母,则看第二个字母,以此类推。
  • 排序后的参数以key=value形式使用“&”字符连接,并拼接上通讯密钥key值(注意:通讯密钥key直接拼接),即为待签名字符串。
  • 使用SHA256签名算法计算待签名字符串的SHA256值,然后转换成16进制字符串。

注意:

根据HTTP协议要求,传递参数的值中如果存在特殊字符(如:&、@等),则该值需要做URLEncoding UTF-8编码处理,这样接收方才能正确接收到参数信息。这种情况下,待签名数据应该是原始值而不是Encoding编码之后的值。没有值的参数,包含空字符串、空格、全空白字符、若干制表符等,如:""," "," ","\t","\n","\r"以及空字符串、空格、全空白字符、若干制表符的组合,接口无需传入,亦无需参与计算签名

2.自定义注解

@Documented
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {SignValidator.class})
public @interface SignCheck {
    //签名字段名,默认是sign,可以认为指定
    String signFieldName() default "sign";
    //默认提示的信息
    String message() default "验签失败";
    //分组的指定
    Class<?>[] groups() default { };
    //有效负载。用于保存一些关键信息。
    Class<? extends Payload>[] payload() default { };
}
  • @Documented

    表明这个注解应该被 javadoc此类的工具文档化

  • @Target

    表明这个注解应该在哪里使用

    • TYPE :用在类、接口、或者枚举上
    • FIELD:用在字段(包括枚举常量)上
    • METHOD:用在方法上
    • PARAMETER:用在参数上
    • CONSTRUCTOR:用在构造方法上
    • LOCAL_VARIABLE:用在局部变量上
    • ANNOTATION_TYPE:用在注解上
    • PACKAGE:用在包上
  • @Retention

    注解的生命周期。

    • SOURCE : 保留在源码级别,编译成class后就丢失
    • CLASS:保留在class文件中,在JVM运行时丢失。这是默认值
    • RUNTIME:在JVM运行时保留,可以被反射调用到
  • @Constraint

    用来指定由哪个类来具体执行校验逻辑。这里指名的target为注解,说明自定义的注解是可以加这个注解的

    @Documented
    @Target({ ANNOTATION_TYPE })
    @Retention(RUNTIME)
    public @interface Constraint {
        ......
    }
    

3.自定义校验逻辑实现

@Slf4j
public class SignValidator implements ConstraintValidator<SignCheck, Object> {

    private SignCheck signCheck;

    @Override
    public void initialize(SignCheck constraintAnnotation) {
        this.signCheck = constraintAnnotation;
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if(value instanceof CommonRequest) {
            CommonRequest request = (CommonRequest) value;
            //判断请求对象中的sign是否有值.如果没有sign字段,或者sign的字段值为空,直接返回校验失败
            Object fieldValue = BeanUtil.getFieldValue(request, this.signCheck.signFieldName());
            if(ObjectUtil.isNull(fieldValue) || StrUtil.isBlank(String.valueOf(fieldValue))){
                return false;
            }
            //按照预先定义的逻辑,对请求对象生成签名
            String generateSign = generateSign(request);
            return StrUtil.equalsIgnoreCase(request.getSign(), generateSign);
        }
        return false;
    }

    //按照预先定义的逻辑,对请求对象生成签名
    public String generateSign(CommonRequest request) {
        // 获得对象的所有属性进行排序
        Map<String, Object> beanMap = BeanUtil.beanToMap(request);
        // 对所有属性进行排序
        TreeMap<String, Object> treeMap = new TreeMap<>(beanMap);
        beanMap.keySet().forEach(key -> treeMap.put(key, beanMap.get(key)));
        //用=号和&号连接参数.排除掉sign字段
        StringBuilder sb = new StringBuilder();
        for (String key : treeMap.keySet()) {
            if(this.signCheck.signFieldName().equals(key)){
                continue;
            }
            sb.append(key).append("=").append(treeMap.get(key)).append("&");
        }
        if(sb.length() > 0){
            sb.deleteCharAt(sb.length() - 1);
            //拼接私钥
            sb.append("1234567890");
        }
        //进行sha256加密
        return DigestUtil.sha256Hex(sb.toString());
    }
}

4.加上注解

@Data
@SignCheck
public class CommonRequest {
    /**
     * 版本号。固定版本号为V1.0
     */
    @NotEmpty(message = "版本号不能为空")
    private String version;


    /**
     * 签名。采用的算法:SHA256(除sign字段的其他字段ASCII字典升序)
     */
    @NotEmpty(message = "签名不能为空")
    private String sign;
}

5.测试

image-20230616150423665

代码地址

gitee.com/mayuanfei/S…下的springboot11

记忆印记

  • Validator框架是一个参数校验框架。用于验证请求参数的
  • 采用全局异常处理方式来统一返回校验框架抛出来的异常
  • 在springboot框架中统一使用@Validated注解来进行参数验证
  • 知道分组校验的应用场景
  • 知道有自定义校验注解,回头需要的时候来这里查查