本章源码下载
前言
在项目中我们经常进行校验数据合法性,我们经常使用到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 个枚举常量,如下所示。
- SOURCE:在源文件中有效(即源文件保留)
- CLASS:在 class 文件中有效(即 class 保留)
- 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;
}
}
- 在控制器可以使用 BindingResult注入到方法签名上,springmvc会帮我们代理校验的结果,我们可以直接获取到校验结果,如方法add(@RequestBody @Valid UserVO vo, BindingResult rs)
这时如果有异常是不会报错中断线程的,要我们自己处理,因为我们已经代理了这个结果,不管校验时候是否错误。我们一般不推荐这么做。
- 推荐:推荐全局异常捕获进行统一的处理,我们只做业务开发,如方法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"}
枚举类验证例子参考:你还在用 if 判断参数是否在枚举范围内吗?我已经用上注解了。
拾遗-@RestControllerAdvice和@ControllerAdvice区别
@RestControllerAdvice与@ControllerAdvice的区别就和@RestController与@Controller的区别类似
@RestControllerAdvice注解包含了@ControllerAdvice注解和@ResponseBody注解。所以方法上可以写少一个@ResponseBody注解
写在最后
- 👍🏻:有收获的,点赞鼓励!
- ❤️:收藏文章,方便回看!
- 💬:评论交流,互相进步!