likes
comments
collection
share

手写校验注解🔥🔥🔥还不错!

作者站长头像
站长
· 阅读数 17

本章源码下载

🔥🔥🔥本章源码下载已分享github

前言

在项目中我们经常进行校验数据合法性,我们经常使用到javax.validation.constraints包下注解,如@NotBlank @NotEmpty,@NotNull等。我们发现spring并没有提供一个枚举值的检验注解,所以本文实战手写一个这样功能的注解。

元注解

元注解是负责对其它注解进行说明的注解,自定义注解时可以使用元注解。Java5 定义 4个注解,分别是 @Documented、@Target、@Retention 和 @Inherited。

Java 8 又增加了 @Repeatable 和 @Native 两个注解。这些注解都可以在 java.lang.annotation 包中找到。下面主要介绍每个元注解的作用及使用。

@Documented

@Documented 是一个标记注解,没有成员变量。用 @Documented 注解修饰的注解类会被 JavaDoc 工具提取成文档。默认情况下,JavaDoc 是不包括注解的,但如果声明注解时指定了 @Documented,就会被 JavaDoc 之类的工具处理,所以注解类型信息就会被包括在生成的帮助文档中。

@Target

@Target 注解用来指定一个注解的使用范围,即被 @Target 修饰的注解可以用在什么地方。@Target 注解有一个成员变量(value)用来设置适用目标,value 是 java.lang.annotation.ElementType 枚举类型的数组,下表为 ElementType 常用的枚举常量。

名称说明
CONSTRUCTOR用于构造方法
FIELD用于成员变量(包括枚举常量)
LOCAL_VARIABLE用于局部变量
METHOD用于方法
PACKAGE用于包
PARAMETER用于类型参数(JDK 1.8新增)
TYPE用于类、接口(包括注解类型)或 enum 声明

@Retention

@Retention 用于描述注解的生命周期,也就是该注解被保留的时间长短。@Retention 注解中的成员变量(value)用来设置保留策略,value 是 java.lang.annotation.RetentionPolicy 枚举类型,RetentionPolicy 有 3 个枚举常量,如下所示。

  1. SOURCE:在源文件中有效(即源文件保留)
  2. CLASS:在 class 文件中有效(即 class 保留)
  3. RUNTIME:在运行时有效(即运行时保留)

生命周期大小排序为 SOURCE < CLASS < RUNTIME,前者能使用的地方后者一定也能使用。如果需要在运行时去动态获取注解信息,那只能用 RUNTIME 注解;

如果要在编译时进行一些预处理操作,比如生成一些辅助代码(如 ButterKnife),就用 CLASS 注解;

如果只是做一些检查性的操作,比如 @Override 和 @SuppressWarnings,则可选用 SOURCE 注解。

@Inherited

@Inherited 是一个标记注解,用来指定该注解可以被继承。使用 @Inherited 注解的 Class 类,表示这个注解可以被用于该 Class 类的子类。就是说如果某个类使用了被 @Inherited 修饰的注解,则其子类将自动具有该注解。

@Repeatable

@Repeatable 注解是 Java 8 新增加的,它允许在相同的程序元素中重复注解,在需要对同一种注解多次使用时,往往需要借助 @Repeatable 注解。Java 8 版本以前,同一个程序元素前最多只能有一个相同类型的注解,如果需要在同一个元素前使用多个相同类型的注解,则必须使用注解“容器”。

Java 8 之前的做法:

public @interface Roles {
    Role[] roles();
}

public @interface Role {
    String roleName();
}

public class RoleTest {
    @Roles(roles = {
            @Role(roleName = "role1"),
            @Role(roleName = "role2")
     })
    public String doString(){
        return "hello";
    }
}

Java 8 之后增加了重复注解,使用方式如下:

public @interface Roles {
    Role[] value();
}

@Repeatable(Roles.class)
public @interface Role {
    String roleName();
}

public class RoleTest {
    @Role(roleName = "role1")
    @Role(roleName = "role2")
    public String doString(){
        return "hello";
    }
}

不同的地方是,创建重复注解 Role 时加上了 @Repeatable 注解,指向存储注解 Roles,这样在使用时就可以直接重复使用 Role 注解。从上面例子看出,使用 @Repeatable 注解更符合常规思维,可读性强一点。

两种方法获得的效果相同。重复注解只是一种简化写法,这种简化写法是一种假象,多个重复注解其实会被作为“容器”注解的 value 成员的数组元素处理。

@Native

使用 @Native 注解修饰成员变量,则表示这个变量可以被本地代码引用,常常被代码生成工具使用。

实战

添加依赖

JSR-303是Java为Bean数据合法性校验提供的标准框架,它定义了一整套校验注解,可以标注在成员变量,属性方法等之上。

hibernate-validator就提供了这套标准的实现,我们在用Springboot开发web应用时,会引入spring-boot-starter-web依赖,它默认会引入spring-boot-starter-validation依赖,而spring-boot-starter-validation中就引用了hibernate-validator依赖。

在springboot较高的版本中,spring-boot-starter-web已移除包含的hibernate-validator组件,请单独引入hibernate-validator

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>7.0.2.Final</version>
</dependency>

如果是spring boot2.x项目,推荐直接引用下面这个依赖,确保版本和springboot的版本匹配,否则有可能出现@Valid失效情况而项目不会报错

推荐自动适配springboot版本:

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

实战1:校验参数是否在列举的范围内

列举注解

@Documented
@Constraint(validatedBy = {ListValusConstraintValidator.class})
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
public @interface ListValue {

    //默认返回提示信息
    String message() default "参数不匹配!";
    //默认分组
    Class<?>[] groups() default {};
    //默认载体
    Class<? extends Payload>[] payload() default {};

    /**
     * 自定义
     * 需要校验String数组
     * @return
     */
    String[] value();

    /**
     * 自定义
     * true 必传
     * false 非必传
     * 
     * @return
     */
    boolean required() default true;
}

@Constraint注解可以注册多个验证器进行验证。

注解约束验证器

/**
 * @Description: Interger list集合校验器
 * @Author: jianweil
 */
public class ListValusConstraintValidator implements ConstraintValidator<ListValue, Object> {

    private Set<Object> valus = new HashSet<>();
    boolean required = true;

    @Override
    public void initialize(ListValue constraintAnnotation) {
        try {
            String[] value = constraintAnnotation.value();
            for (String i : value) {
                valus.add(i);
            }
            required = constraintAnnotation.required();
        } catch (Exception e) {
            throw new RuntimeException("参数校验转换出错", e);
        }
    }

    /**
     * 是否校验成功
     *
     * @param value   校验值 这里使用Object是兼顾了int类型和String类型,严格来说是要分别新建两个类型的注解约束验证器
     * @param context
     * @return
     */
    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (required) {
            if (Objects.isNull(value)) {
                //value为null
                return false;
            }
            return valus.contains(value.toString());
        } else {
            //value可以为null
            if (value == null) {
                return true;
            } else {
                //如果value不为null,校验是否是列举的值
                return valus.contains(value.toString());
            }
        }
    }
}

Spring MVC 框架会去调用 initialize方法对当前这个注解的拓展进行初始化,然后在校验过程中会执行 isValid 如果校验通过我们就返回 true,不通过我们就返回 false

如果不通过,就会抛出异常,异常的错误信息中会包含我们之前定义的 message.

请求VO

/**
 * @Description: 用户VO
 * @Author: jianweil
 * @date: 2022/3/1 10:03
 */
@Data
public class UserVO {

    @NotBlank(message = "姓名不能为空")
    private String name;

    @Min(value = 18, message = "年龄不能小于18")
    private int age;

    /**
     * 列举性别枚举值: 1女 2男
     */
    @ListValue(value = {"1", "2"}, message = "性别参数不对")
    private Integer gender;

    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;

}

控制器@Valid校验

@RestController
@RequestMapping("/test")
public class TestController {

    /**
     * 捕获数据绑定结果,如果数据格式报错是不会触发全局的异常捕获,这里BindingResult已经捕获了
     *
     * @param vo
     * @param rs
     * @return
     */
    @PostMapping("/add")
    public Object add(@RequestBody @Valid UserVO vo, BindingResult rs) {
        //打印错误
        StringBuilder msg = new StringBuilder();
        if (rs.hasErrors()) {
            List<FieldError> fieldErrors = rs.getFieldErrors();
            fieldErrors.forEach(item -> msg.append(item.getDefaultMessage()).append(";"));
            return msg.toString();
        }
        return vo;
    }

    /**
     * 推荐:请求数据错误由全局异常处理器捕获,这里关注业务
     *
     * @param vo
     * @return
     */
    @PostMapping("/add2")
    public Object add2(@RequestBody @Valid UserVO vo) {
        System.out.println("检验成功...");
        return vo;
    }

}
  1. 在控制器可以使用 BindingResult注入到方法签名上,springmvc会帮我们代理校验的结果,我们可以直接获取到校验结果,如方法add(@RequestBody @Valid UserVO vo, BindingResult rs)

这时如果有异常是不会报错中断线程的,要我们自己处理,因为我们已经代理了这个结果,不管校验时候是否错误。我们一般不推荐这么做。

  1. 推荐:推荐全局异常捕获进行统一的处理,我们只做业务开发,如方法add2(@RequestBody @Valid UserVO vo)

全局异常捕获

/**
 * @Description: 全局异常处理器
 * @Author: jianweil
 * @date: 2022/3/1 10:37
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 接口参数数据格式错误异常
     */
    @ExceptionHandler({MethodArgumentNotValidException.class})
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.error("MethodArgumentNotValidException");
        StringBuilder msg = new StringBuilder();
        e.getBindingResult().getAllErrors().forEach(item -> msg.append(item.getDefaultMessage()).append(";"));
        //这里简单地返回字符串,大家可以封装统一格式的返回模型
        return msg.toString();
    }

    @ExceptionHandler({Exception.class})
    @ResponseStatus(HttpStatus.OK)
    @ResponseBody
    public Object handleException(Exception e) {
        System.out.println(e);
        return e.getMessage();
    }
}

请求测试

###正确请求
POST http://localhost:8080/test/add2
Content-Type: application/json

{"name": "ljw","age": 18,"gender": 1,"email": "10086@qq.com"}


###错误请求
POST http://localhost:8080/test/add2
Content-Type: application/json

{"name": "ljw","age": 18,"gender": 0,"email": "10086@qq.com"}

实战2:使用枚举类进行校验

在上面我们已经实现了列举所有符号要求的集合,使用注解方式进行校验,但是这种方法并不优雅,有点类似于魔法值,如@ListValue(value = {"1", "2"}, message = "性别参数不对")或@ListValue(value = {"A", "B"}, message = "性别参数不对"),语义并不清晰。

项目中,我们使用枚举代替了魔法值。我们可以把列举改为枚举。

枚举注解

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {EnumValueConstraintValidator.class})
public @interface EnumValue {

    // 默认错误消息
    String message() default "the integer is not one of the enum values";

    // 约束注解在验证时所属的组别
    Class<?>[] groups() default {};

    // 约束注解的有效负载
    Class<? extends Payload>[] payload() default {};

    /**
     * true 必传
     * false 非必传
     *
     * @return
     */
    boolean required() default true;
    
    /**
     * 需要校验枚举的值
     *
     * @return
     */
    Class<? extends Enum> value();

}

注解约束验证器

public class EnumValueConstraintValidator implements ConstraintValidator<EnumValue, Object> {

    private Set<Object> values = new HashSet<>();
    private Class<? extends Enum> enumClass;
    boolean required = true;

    /**
     * 这个方法做一些初始化校验
     *
     * @param constraintAnnotation
     */
    @Override
    public void initialize(EnumValue constraintAnnotation) {
        enumClass = constraintAnnotation.value();
        try {
            // 先判断该enum是否实现了getValue方法
            enumClass.getDeclaredMethod(ValidatorEnumMapper.METHOD_NAME);
            for (Enum e : enumClass.getEnumConstants()) {
                Method declaredMethod = e.getClass().getDeclaredMethod(ValidatorEnumMapper.METHOD_NAME);
                Object obj = declaredMethod.invoke(e);
                values.add(obj);
            }
            required = constraintAnnotation.required();
        } catch (NoSuchMethodException e) {
            throw new IllegalArgumentException("the enum class has not getValue method", e);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 这个方法写具体的校验逻辑:校验数字是否属于指定枚举类型的范围
     *
     * @param value
     * @param constraintValidatorContext
     * @return
     */
    @Override
    public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
        if (required) {
            if (Objects.isNull(value)) {
                return false;
            }
            return isPass(value);

        } else {
            //value可以为null
            if (Objects.isNull(value)) {
                return true;
            }
            return isPass(value);
        }
    }

    /**
     * value不为null时检验是否匹配
     *
     * @param value
     * @return
     */
    private boolean isPass(Object value) {
        //不为null
        Enum[] enumConstants = enumClass.getEnumConstants();
        if (enumConstants == null) {
            // 如果不是枚举类型,返回 enumConstants = null
            return true;
        }
        return values.contains(value);
    }
}

顶层接口

/**
 * @Description: 枚举校验映射接口
 * @Author: jianweil
 * @date: 2022/3/1 15:24
 */
public interface ValidatorEnumMapper<T> {

    /**
     * 名称和下面方法名称相同
     */
    public static final String METHOD_NAME = "getValue";

    /**
     * 注解约束验证器调用这个方法获取枚举的值
     **/
    public T getValue();
}

因为枚举可能有多个属性,我们需要拿请求值到枚举类中要校验的属性做对比,所以设计一个接口,枚举类要实现该接口的抽象方法。

枚举类

public enum Gender implements ValidatorEnumMapper<Integer> {

    male(1, "男"),
    female(2, "女");

    private int value;
    private String type;

    Gender(int value, String type) {
        this.value = value;
        this.type = type;
    }

    @Override
    public Integer getValue() {
        return this.value;
    }
}

请求VO

/**
 * @Description: 用户VO
 * @Author: jianweil
 * @date: 2022/3/1 10:03
 */
@Data
public class UserVO {

    @NotBlank(message = "姓名不能为空")
    private String name;

    @Min(value = 18, message = "年龄不能小于18")
    private int age;
    /**
     * 列举性别枚举值: 1女 2男
     */
    @ListValue(value = {"1", "2"}, message = "性别参数不对", required = false)
    private Integer gender;

    @EnumValue(value = Gender.class, message = "不在系统支持的枚举范围内",required = false)
    private Integer genderEnum;

    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;

}

控制器@Valid校验

@RestController
@RequestMapping("/test")
public class TestController {
    /**
     * 推荐:请求数据错误由全局异常处理器捕获,这里关注业务
     *
     * @param vo
     * @return
     */
    @PostMapping("/add2")
    public Object add2(@RequestBody @Valid UserVO vo) {
        System.out.println("检验成功...");
        return vo;
    }

}

请求测试

###正确请求
POST http://localhost:8080/test/add2
Content-Type: application/json

{"name": "ljw","age": 18,"gender": 1,"genderEnum": 1,"email": "10086@qq.com"}

###正确请求
POST http://localhost:8080/test/add2
Content-Type: application/json

{"name": "ljw","age": 18,"gender": 1,"genderEnum": null,"email": "10086@qq.com"}

###错误请求
POST http://localhost:8080/test/add2
Content-Type: application/json

{"name": "ljw","age": 18,"gender": 1,"genderEnum": 3,"email": "10086@qq.com"}

拾遗-@RestControllerAdvice和@ControllerAdvice区别

@RestControllerAdvice与@ControllerAdvice的区别就和@RestController与@Controller的区别类似

@RestControllerAdvice注解包含了@ControllerAdvice注解和@ResponseBody注解。所以方法上可以写少一个@ResponseBody注解

写在最后

  • 👍🏻:有收获的,点赞鼓励!
  • ❤️:收藏文章,方便回看!
  • 💬:评论交流,互相进步!