Spring Boot 使用 JSR303 实现参数验证

Spring Boot 使用 JSR303 实现参数验证

简介

JSR-303 是 JAVA EE 6 中的一项子规范,叫做 Bean Validation。

在任何时候,当你要处理一个应用程序的业务逻辑,数据校验是你必须要考虑和面对的事情。应用程序必须通过某种手段来确保输入进来的数据从语义上来讲是正确的。在通常的情况下,应用程序是分层的,不同的层由不同的开发人员来完成。很多时候同样的数据验证逻辑会出现在不同的层,这样就会导致代码冗余和一些管理的问题,比如说语义的一致性等。为了避免这样的情况发生,最好是将验证逻辑与相应的域模型进行绑定。

Bean Validation 为 JavaBean 验证定义了相应的元数据模型和 API。缺省的元数据是 Java Annotations,通过使用 XML 可以对原有的元数据信息进行覆盖和扩展。在应用程序中,通过使用 Bean Validation 或是你自己定义的 constraint,例如 @NotNull, @Max, @ZipCode, 就可以确保数据模型(JavaBean)的正确性。constraint 可以附加到字段,getter 方法,类或者接口上面。对于一些特定的需求,用户可以很容易的开发定制化的 constraint。Bean Validation 是一个运行时的数据验证框架,在验证之后验证的错误信息会被马上返回。

Bean Validation 规范内嵌的约束注解

约束注解名称 约束注解说明
@AssertTrue 验证 Boolean 对象是否为 true
@AssertFalse 验证 Boolean 对象是否为 false
@Null 验证对象是否为null
@NotNull 验证对象不能为null,无法查检空字符串
@NotBlank 验证去掉前后空格后的字符串不能为Null或者长度为0
@NotEmpty 验证对象(String/Collection/Map/Array)不能为null或者长度为0
@Size(min=, max=) 验证对象(String/Collection/Map/Array)长度是否在给定的范围内
@Length(min=, max=) 验证字符串的长度是否在给定的范围内
@Past 验证 Date 和 Calendar 对象是否在当前时间之前
@PastOrPresent 验证 Date 和 Calendar 对象是否在当前时间之前或当前时间
@Future 验证 Date 和 Calendar 对象是否在当前时间之后
@FutureOrPresent 验证 Date 和 Calendar 对象是否在当前时间之后或当前时间
@Pattern 验证 String 对象是否符合正则表达式的规则
@Min 验证 Number 和 String 对象是否大于等于指定的值
@Max 验证 Number 和 String 对象是否小于等于指定的值
@DecimalMax 验证整形和 BigDecimal 必须小于等于指定的值
@DecimalMin 验证整形和 BigDecimal 必须大于等于指定的值
@Digits 验证元素必须是数值
@Digits(integer=,fraction=) 验证元素是否为指定格式的数字,interger指定整数精度,fraction指定小数精度
@Valid 递归验证属性、方法参数或方法返回类型
@Email 验证是否为邮件地址,如果为null则不进行验证(通过验证)

基本应用

引入依赖

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-validationartifactId>
dependency>

在需要校验的bean的属性上加上注解

@Data
public class Employee {

    private Long id;

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

    @Email
    private String email;

    @Max(value = 120)
    private Integer age;

    private String phone;

}

使用测试一: 在Controller接口方法的接收参数中使用 @Valid修饰Employee

@PostMapping("add")
@ResponseBody
public Employee addEmployee(@Valid Employee employee) {
    employeeService.saveOrUpdate(employee);
    return employee;
}

异常的统一处理

参数校验不通过时,会抛出 BingBindException 异常,可以在统一异常处理中,做统一处理,这样就不用在每个需要参数校验的地方都用 BindingResult 获取校验结果了。

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(value = {MethodArgumentNotValidException.class, BindException.class})
    public ApiResult handleValidException(Exception e) {
        BindingResult bindingResult = null;
        if (e instanceof MethodArgumentNotValidException) {
            bindingResult = ((MethodArgumentNotValidException) e).getBindingResult();
        } else if (e instanceof BindException) {
            bindingResult = ((BindException) e).getBindingResult();
        }
        Map<String, String> errorMap = new HashMap<>(16);
        assert bindingResult != null;
        bindingResult.getFieldErrors().forEach((fieldError) -> {
                    log.error("请求参数错误:{},field{},errorMessage{}", fieldError.getObjectName(), fieldError.getField(), fieldError.getDefaultMessage());
                    errorMap.put(fieldError.getField(), fieldError.getDefaultMessage());
                }

        );
        return ApiResult.validateFailed(errorMap);
    }

}

访问POST http://localhost:81/emp/add?email=100qqcom测试 效果如下

{
  "code": 404,
  "msg": "参数检验失败",
  "data": {
    "name": "用户名不能为空",
    "email": "不是一个合法的电子邮件地址"
  }
}

使用测试二 直接使用 BindingResult 验证

@PostMapping("validAdd")
public ApiResult addEmployee(@Valid Employee employee, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        Map<String, String> map = new HashMap<>(4);
        bindingResult.getFieldErrors().forEach((item) -> {
            String message = item.getDefaultMessage();
            String field = item.getField();
            map.put(field, message);
        });
        return ApiResult.failed("非法参数", map);
    }
    employeeService.saveOrUpdate(employee);
    return ApiResult.success();
}

结果如下

{
  "code": 400,
  "msg": "非法参数",
  "data": {
    "name": "用户名不能为空",
    "age": "最大不能超过120",
    "email": "不是一个合法的电子邮件地址"
  }
}

分组解决校验

新增和修改对于实体的校验规则是不同的,例如id是自增的时,新增时id要为空,修改则必须不为空;新增和修改,若用的恰好又是同一种实体,那就需要用到分组校验。

校验注解都有一个groups属性,可以将校验注解分组,我们看下@NotNull的源码:

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(NotNull.List.class)
@Documented
@Constraint(
    validatedBy = {}
)
public @interface NotNull {
    String message() default "{javax.validation.constraints.NotNull.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface List {
        NotNull[] value();
    }
}

从源码可以看出 groups 是一个Class类型的数组,那么就可以创建一个Groups.

public class Groups {
    public interface Add {
    }

    public interface Update {
    }
}

给参数对象的校验注解添加分组

@Data
public class Employee {
    
    @Null(message = "新增不需要指定id" , groups = Groups.Add.class)
    @NotNull(message = "修改需要指定id" , groups = Groups.Update.class)
    private Long id;

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

    @Email
    private String email;

    @Max(value = 120)
    private Integer age;

    private String phone;

}

Controller 中原先的@Valid不能指定分组 ,需要替换成@Validated

@PostMapping("validatedAdd")
public ApiResult addEmployee2(@Validated({Groups.Add.class}) Employee employee) {
    return ApiResult.success();
}

访问POST http://localhost:81/emp/validatedAdd?id=12测试,结果如下

{
  "code": 404,
  "msg": "参数检验失败",
  "data": {
    "id": "新增不需要指定id"
  }
}

自定义校验注解

虽然JSR303和springboot-validator 已经提供了很多校验注解,但是当面对复杂参数校验时,还是不能满足我们的要求,这时候我们就需要 自定义校验注解。

验证Employee类的手机号格式是否正确

自定义 IsMobile 注解类

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

    //允许为空的属性
    boolean required() default true;

    //如果校验不通过返回的提示信息
    String message() default "手机号码格式错误";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

手机号格式校验器

public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {

    /**
     * 默认值_false,用于接收注解上自定义的 required
     */
    private boolean required = false;

    /**
     * 初始化方法
     * 通过该方法我们可以拿到我们的注解
     *
     * @param constraintAnnotation 约束注释
     */
    @Override
    public void initialize(IsMobile constraintAnnotation) {
        // 接收我们自定义的属性,是否为空
        required = constraintAnnotation.required();
    }

    /**
     * 判断是否校验成功
     *
     * @param value                      需要校验的值
     * @param constraintValidatorContext 约束验证器上下文
     * @return boolean
     */
    @Override
    public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
        //如果手机号不为空且进行校验,则进行判断
        if (!StringUtils.isEmpty(value) && required) {
            return isMobile(value);
        }
        return true;
    }

    public static boolean isMobile(String mobile) {
        if (mobile.length() != 11) {
            return false;
        } else {
            // 移动号段正则表达式
            String pat1 = "^((13[4-9])|(147)|(15[0-2,7-9])|(178)|(18[2-4,7-8]))\\d{8}|(1705)\\d{7}$";
            // 联通号段正则表达式
            String pat2 = "^((13[0-2])|(145)|(15[5-6])|(176)|(18[5,6]))\\d{8}|(1709)\\d{7}$";
            // 电信号段正则表达式
            String pat3 = "^((133)|(153)|(177)|(18[0,1,9])|(149)|(199))\\d{8}$";
            // 虚拟运营商正则表达式
            String pat4 = "^((170))\\d{8}|(1718)|(1719)\\d{7}$";

            Pattern pattern1 = Pattern.compile(pat1);
            Matcher match1 = pattern1.matcher(mobile);
            boolean isMatch1 = match1.matches();
            if (isMatch1) {
                return true;
            }
            Pattern pattern2 = Pattern.compile(pat2);
            Matcher match2 = pattern2.matcher(mobile);
            boolean isMatch2 = match2.matches();
            if (isMatch2) {
                return true;
            }
            Pattern pattern3 = Pattern.compile(pat3);
            Matcher match3 = pattern3.matcher(mobile);
            boolean isMatch3 = match3.matches();
            if (isMatch3) {
                return true;
            }
            Pattern pattern4 = Pattern.compile(pat4);
            Matcher match4 = pattern4.matcher(mobile);
            return match4.matches();
        }
    }
}

在实体类的phone上加上@IsMobile注解

测试POST http://localhost:81/emp/validAdd?name=chen&phone=137777

{
  "code": 400,
  "msg": "非法参数",
  "data": {
    "phone": "手机号码格式错误"
  }
}

总结

自定义注解需要去手动实现两个文件:自定义注解类 + 注解校验器类

自定义注解类:message() + groups() + payload() 必须;

注解校验器类:继承 ConstraintValidator 类<注解类,注解参数类型> + 两个方法(initialize:初始化操作、isValid:逻辑处理)

你可能感兴趣的:(SpringBoot)