Spring Validation参数校验

简介

Spring Validation是在Spring Context包下的,在Spring Boot项目中,我们引入spring-boot-starter-web便会引入进来,Spring Validation是对Hibernate Validator的二次封装,使我们可以更方便的在Spring MVC中完成自动校验。

Hibernate Validator是对JSR-303(Bean Validation)的参考实现。Hibernate Validator 提供了JSR-303规范中所有内置constraint的实现,除此之外还有一些附加的constraint

JSR-303定义的constraint

Constraint Description
@Null 被注解的元素必须为null
@NotNull 被注解的元素必须不为null
@AssertTure 被注解的元素必须为ture
@AssertFalse 被注解的元素必须为false
@Min(value) 被注解的元素必须是数字且必须大于等于指定值
@Max(value) 被注解的元素必须是数字且必须小于等于指定值
@DecimalMin(value) 被注解的元素必须是数字且必须大于等于指定值
@DecimalMax(value) 被注解的元素必须是数字且必须小于等于指定值
@Size(max, min) 被注解的元素必须在指定的范围内
@Digits(integer, fraction) 被注解的元素必须是数字且其值必须在给定的范围内
@Past 被注解的元素必须是一个过去的日期
@Future 被注解的元素必须是一个将来的日期
@Pattern(value) 被注解的元素必须符合给定正则表达式

Hibernate Validator附加实现的constraint

Constraint Description
@Email 被注解的元素必须是Email地址
@Length(min, max) 被注解的元素长度必须在指定的范围内
@NotEmpty 被注解的元素必须非空
@Range 被注解的元素(可以是数字或者表示数字的字符串)必须在给定的范围内
@URL 被注解的元素必须是URL

当然,我们也可以自定义实现,自定义实现在下面使用中在讲吧。

使用

首先是引入依赖,在Spring Boot项目中,我们引入web就已经可以使用了,这里就不再具体赘述了。

使用@Validated注解拦截校验

先定义下要校验的实体吧:

public class User {

    @Length(min = 1, max = 22, message = "name字段不合法")
    private String name;
    @Min(value = 1, message = "age字段不合法")
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

Controller中,我们需要校验前端传递过来的参数,我们可以这么写

@RestController
public class TestController {

    @PostMapping("/test")
    public Object test(@RequestBody @Validated User user, BindingResult result) {
        if (result.hasErrors()) {
            return result.getAllErrors().stream()
              .map(ObjectError::getDefaultMessage)
              .collect(Collectors.toList());
        }
        return user;
    }
}

只需要在需要校验的实体前面打上@Validated注解就可以了,这时候,如果我们传递的参数符合要求,则会正常返回。否则返回:

[
    "age字段不合法",
    "name字段不合法"
]

它会将我们所有不合法信息一次性全部返回,在日常开发中,我们可以吧校验BindingResult是否有错误信息的校验统一抽出到一个工具类中去做处理,使用项目中统一格式返回错误信息就好。这就是一个最简单的校验示例了,其他注解也都是类似的,就不多举例了,可以自己尝试着玩玩。

在日常开发中想必都曾遇到过这样的需求,比如这个age这个字段,我想要这个字段只在PC端校验,在App端不做限制,这就需要用到分组校验了,每个注解都提供了一个group属性,利用这个属性就可以轻易做到以上需求。比如在User上的注解中加入group属性,指定其被校验的group

public class User {

    @Length(min = 1, max = 22, message = "name字段不合法", groups = {App.class, PC.class})
    private String name;
    @Min(value = 1, message = "age字段不合法", groups = PC.class)
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

Controller中的@Validated中指定当前group

@RestController
public class TestController {

    @PostMapping("/test")
    public Object test(@RequestBody @Validated(App.class) User user, BindingResult result) {
        if (result.hasErrors()) {
            return result.getAllErrors().stream()
              .map(ObjectError::getDefaultMessage)
              .collect(Collectors.toList());
        }
        return user;
    }
}

这时候我再使用两个不合法字段访问返回:

[
    "name字段不合法"
]

可以看到,它并没有对age字段进行校验。这就是它的分组校验。

在方法实现中拦截校验

它不只是在Controller校验前端传递过来的参数的时候可以用,它在方法中同样可以用,我们可以这样来使用:

@RestController
public class TestController {

    @Autowired
    ObjectMapper objectMapper;

    @Autowired
    SmartValidator smartValidator;

    @GetMapping("/test")
    public Object test() {
        String context = "{\"name\": \"felixu\",\"age\": 0}";
        User user = null;
        try {
            user = objectMapper.readValue(context, User.class);
        } catch (IOException e) {
            e.printStackTrace();
        }
        BeanPropertyBindingResult result = new BeanPropertyBindingResult(user, "user");
        smartValidator.validate(user, result);
        if (result.hasErrors()) {
            return result.getAllErrors().stream()
              .map(ObjectError::getDefaultMessage)
              .collect(Collectors.toList());
        }
        return user;
    }
}

使用需要被校验的实体构造BeanPropertyBindingResult对象,然后将传递给SmartValidatorvalidate方法来完成跟上面相同的校验。validate有个重载方法,也接收分组,所以这种方式同样可以实现分组校验。

自定义实现

需求总是多变的,有时候,可能上面的校验方式并不能满足我们的要求,这时候就需要我们自定义一下校验了,要做到自定义注解来校验,我们需要做以下两步,首先实现ConstraintValidator(ps:原谅我的自恋。。。不要管我干了啥,关键是要知道可以用来干啥对不对,哈哈哈哈):

public class IsFelixuValidator implements ConstraintValidator<IsFelixu, String> {

    @Override
    public void initialize(IsFelixu annotation) {
        
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if ("felixu".equals(value)) {
            return true;
        }
        return false;
    }
}

isValid便是我们的校验逻辑,true为通过校验。

然后我们实现注解:

@Documented
@Constraint(
    // 指定对应的校验类
    validatedBy = {IsFelixuValidator.class}
)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsFelixu {

    String message() default "this value is not felixu";
    // 这两个属性必须要存在
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

这样就ok了,我们继续使用之前的来做测试,在Username属性上加上@IsFelixu注解,此时测试,如果不传递namefelixu的值,则会提示如下信息:

[
    "this value is not felixu",
    "age字段不合法"
]

这个多多少少看着有点沙雕,我决定拿个别的举例了,先看注解

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

    String message() default "精度不符合要求";
    // 最少小数位
    int min() default 0;
    // 最多小数位
    int max() default Integer.MAX_VALUE;
    // 是否固定小数位,固定多少位,-1 为不固定
    int fixed() default -1;

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

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

emmmm,这是个校验精度的,我们可以看到它有好几个属性,比如min啊、max的之类的,就是为了标注当前值要最小几位小数,最多多少位小数。

下面我们再来看看是怎么实现校验的:

public class PrecisionValidator implements ConstraintValidator<Precision, Object> {

  	// Hibernate validator 自带的一些错误提示
    private static final Log LOG = LoggerFactory.make(MethodHandles.lookup());

    private int min;

    private int max;

    private int fixed;

    // 项目启动时,此方法便会被调用,可以拿到注解中各属性的值,用以做合法性校验
    // 比如这里就检查了这三个属性的合法性
    @Override
    public void initialize(Precision precision) {
        min = precision.min();
        max = precision.max();
        fixed = precision.fixed();
        validateParameters();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        // 数字转字符串
        if (value instanceof Number)
            value = String.valueOf(value);
        if (value instanceof CharSequence) {
            // 这是自己的一个工具类,可以不用管,就是把 value 以 . 切割成数组
            List<String> vals = Splitters.DOT.splitToList((String) value);
            // 如果只有 1 位,说明没有小数位,直接放行
            if (vals.size() == 1)
                return true;
            // 不是 2 位说明参数是瞎特么传的
            if (vals.size() != 2)
                return false;
            // 获取小数位的长度
            int length = vals.get(1).length();
            // 判断有没有指定固定小数位, -1 认为是没有
            if (fixed != -1)
                return length == fixed;
            // 判断长度是否在范围内
            return length >= min && length <= max;
        }
        return false;
    }
   
    // 注解属性值校验方法
    private void validateParameters() {
        if (min < 0)
            throw LOG.getMinCannotBeNegativeException();
        if (max < 0)
            throw LOG.getMaxCannotBeNegativeException();
        if (max < min)
            throw LOG.getLengthCannotBeNegativeException();
        if (fixed < 0 && fixed != -1)
            throw new IllegalArgumentException("The fixed cannot be negative.");
    }
}

之前实现那个沙雕注解的时候有些方法没有详细介绍,可以看一下上面这个实现中的注释。当时这个是为了满足产品的一个沙雕要求,正好拿出来举个。

补充说明

在上面举例中,我在controller中注入了BindingResult来获取错误信息,可以将错误信息封装到一个工具类中来统一返回,例如下面代码中的onValidFail方法,将错误信息封装到统一返回中

public class RespDTO<T> implements Serializable{

    public int code;

    public String error;

    public T data;

    public static <T> RespDTO<T> onSuc() {
        return onSuc(null);
    }

    public static <T> RespDTO<T> onSuc(T data) {
        return build(ErrorCode.OK.getCode(), ErrorCode.OK.getMsg(), data);
    }

    public static <T> RespDTO<T> onValidFail(BindingResult result) {
        String errorMsg = result.getAllErrors()
                .stream()
                .map(objectError -> {
                    FieldError error = (FieldError) objectError;
                    return error.getField() + ", " + error.getDefaultMessage();
                })
                .collect(Collectors.joining("\n"));
        return build(ErrorCode.PARAM_ERROR.getCode(), errorMsg, null);
    }

    public static <T> RespDTO<T> onFail(ErrorCode errorCode) {

        return onFail(errorCode.getCode(), errorCode.getMsg());
    }

    public static <T> RespDTO<T> onFail(int code, String msg) {
        return build(code, msg, null);
    }

    private static <T> RespDTO<T> build(int ret, String msg, T data) {
        return new RespDTO<T>(ret, msg, data);
    }
}

当然我们也可以不去注入BindingResult,而直接使用注解,这样校验失败就会抛出异常,再由我们自己的统一异常拦截去拦截,之后再处理成统一返回,这样也是可以的,比如下面这样写controller

public class DemoController {
  	@PostMapping
    public RespDTO<Boolean> create(@Validated({Create.class, Default.class}) @RequestBody RoutineInfo routineInfo) {
        Account account = accountService.getDefaultAccount(routineInfo.getUserId());
        routineInfo.setAccountId(account.getId());
        return RespDTO.onSuc(routineInfoService.save(routineInfo));
    }
}

然后定义异常拦截去拦截:

@RestControllerAdvice
public class DemoExceptionHandler {
  	@ExceptionHandler(BindException.class)
    public ResponseEntity<RespDTO<Object>> handleBindException(BindException e) {
        return new ResponseEntity<>(RespDTO.onValidFail(e), HttpStatus.OK);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<RespDTO<Object>> methodArgumentNotValidHandler(MethodArgumentNotValidException e) {
        return new ResponseEntity<>(RespDTO.onValidFail(e.getBindingResult()), HttpStatus.OK);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<RespDTO<Object>> validationExceptionHandler(ConstraintViolationException e) {
        Set<ConstraintViolation<?>> violations = e.getConstraintViolations();
        Map<String, String> result = new HashMap<>(violations.size());
        for (ConstraintViolation<?> violation : violations) {
            String fieldName = ((PathImpl) violation.getPropertyPath()).getLeafNode().getName();
            result.put(fieldName, violation.getMessage());
        }
        BindException exception = new BindException(e, "exception");
        result.forEach((key, value) -> exception.addError(new FieldError(exception.getObjectName(), key, value)));
        return new ResponseEntity<>(RespDTO.onValidFail(exception.getBindingResult()), HttpStatus.OK);
    }
}

这样校验失败抛出的异常便会被统一的异常拦截器拦截到,然后被处理成统一返回,返回给前端了。

结语

JSR-303 的发布使得在数据自动绑定和验证变得简单,使开发人员在定义数据模型时不必考虑实现框架的限制。当然Bean Validation还只是提供了一些最基本的constraint

上面只是相对简单的用法,在实际的开发过程中,用户可以根据自己的需要组合或开发出更加复杂的constraint。这就需要想象力了,从上面的用法中应该可以想到很多地方可以去使用,但是设计和实现时,往往需要考虑诸多因素,比如易用性和封装的复杂度,等等方面,还需要自己去考量了。

你可能感兴趣的:(Spring)