Spring Boot - 数据校验

2020-08-28  本文已影响0人  Whyn

[TOC]

简介

后端编程中,通常对于前端传递过来的数据,我们都需要进行校验,确保数据正确且安全。

最直接的方法当然是在 Controller 相应方法内对数据进行手动校验,但是,由于很多校验都具备相似性,因此这种做法稍显冗余。

因此,相关的校验规范就应运而生。比如:

JSR-303 是 Bean Validation 1.0 版本,随着越来越多的新规范并入,它的版本也一直在更新,比如,JSR-349 就是 Bean Validation 1.1 版本,而当前最新的版本为 JSR-380,也即 Bean Validation 2.0 版本...

由于 JSR-303 只提供规范,因此其实现需要其他库进行提供。当前使用最广泛的 Bean Validation 实现库为:hibernate-validator

hibernate-validator 是对 JSR-303 的实现,同时它也增添了其他一些校验注解,比如,@URL@Length@Ranger等。

而在 Spring 中,其也提供了相应的 Bean Validation 实现:Java Bean Validation
Spring Validation 主要是对 hibernate-validator 进行了二次封装,并在 SpringMVC 中添加了自动校验,以及将校验信息封装进特定类中等功能。

本文主要介绍下在 Spring Boot 中进行数据校验(Bean Validation)。

依赖添加

Spring Boot 中进行数据校验需要添加起步依赖:spring-boot-starter-validation,如下所示:

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

:在spring-boot-starter-web旧版本中,其内置了spring-boot-starter-validation,但是 Spring Boot 官方似乎认为并不是很多应用会使用数据校验功能,因此对其进行了移除。具体请参考:issue#19550

基本使用

数据校验最基本的操作就是使用相关注解对一个 Java Bean 内的相关字段进行约束,然后前端传递上来的数据会首先组装为相应的 Java Bean 对象,该对象会被移交到一个Validator,让其检查对象字段(即数据)是否满足约束,如果不满足的话,则会通过如抛出异常等方式通知系统。

具体的使用步骤如下所示:

  1. 首先定义一个需要校验的 Java Bean 类:

    @Data
    public class User {
        private int id;
    
        @NotBlank(message = "用户名不能为空")
        private String name;
    
        @NotNull(message = "请输入密码")
        @Length(min = 6, max = 10, message = "密码为 6 到 10 位")
        private String password;
    
        @Email
        private String email;
    }
    

    上述代码中,我们使用@NotBlank@NotNull@Length@Email等注解对User类中的相应字段进行了约束。
    各注解对应的约束内容请参考后文。

  2. 在 Controller 相应接口方法中,使用@Valid/@Validated等注解开启数据校验功能:

    @RestController
    @RequestMapping("validate")
    public class ValidationController {
    
        @PostMapping("/user")
        public String addUser(@Validated @RequestBody User user){
            return "add user successfully! " + user;
        }
    }
    
  3. 如果数据校验不通过,就会抛出一个MethodArgumentNotValidException异常。默认情况下,Spring 会将该异常及其信息以错误码 400 进行下发。我们可以通过自定义一个全局异常捕获器拦截该异常,提取出数据校验出错信息,进行展示:

    @RestControllerAdvice
    public class GlobalExceptionHandler {
    
        @ExceptionHandler(MethodArgumentNotValidException.class)
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        public String handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) {
            return e.getBindingResult().getFieldErrors()
                    .stream()
                    .map(fieldError -> {
                        return String.format("[%s: %s]\n", fieldError.getField(), fieldError.getDefaultMessage());
                    }).collect(Collectors.joining());
        }
    }
    

以上,就完成了一个基础的数据校验功能。

此时我们进行如下访问:

$ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"password\": \"123456\"}"
[name: 用户名不能为空]

$ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"12345\"}"
[password: 密码为 6 到 10 位]

$ curl http://localhost:8080/validate/user --header "Content-Type: application/json;charset=UTF-8" -X POST --data "{\"name\": \"Whyn\",\"password\": \"123456\"}"
add user successfully! User(id=0, name=Whyn, password=123456, email=null)

可以看到,结果符合预期。

:上述代码如果数据校验不通过,就会抛出MethodArgumentNotValidException,其实是因为我们在为参数注解了@RequestBody,此时HttpMessageConverter会负责转换过程,当遇到数据校验失败时,就会抛出MethodArgumentNotValidException
而如果去除@RequestBody注解,默认就会由@ModelAttribute负责数据绑定和校验,如果此时校验失败,则会抛出BindException(更多详情,可参考:issue#14790),因此,为了程序更加健壮,最好为我们的全局异常处理器增加BindException异常捕获。如下所示:

@RestControllerAdvice
public class GlobalExceptionHandler {

    ...
    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String handleBindException(BindException e){
        return e.getBindingResult().getFieldErrors()
                .stream()
                .map(fieldError -> {
                    return String.format("[%s: %s]\n", fieldError.getField(), fieldError.getDefaultMessage());
                }).collect(Collectors.joining());
    }
}

此时,请求上述代码,结果如下:

$ curl http://localhost:8080/validate/user -X POST
[name: 用户名不能为空]
[password: 请输入密码]

$ curl http://localhost:8080/validate/user -X POST --data "name=Whyn"
[password: 请输入密码]

$ curl http://localhost:8080/validate/user -X POST --data "name=Whyn" --data "password=123456"
add user successfully! User(id=0, name=Whyn, password=123456, email=null, phoneNo=null)

上面是对复杂数据(Java Bean)的校验使用方式,而如果前端传递的是简单基本类型(比如String)或者是对路径变量(Path Variable)进行校验,可使用如下方式:

@RestController
@RequestMapping("validate")
@Validated
public class ValidationController {

    @GetMapping("/user/{id}")
    public String getUser(@PathVariable("id") @Min(10) int id) {
        return "User id is " + id;
    }

    @PutMapping("/user")
    public String updateUser(@RequestParam("name") @NotBlank String name,
                             @RequestParam("email") @Email String email) {
        User user = new User();
        user.setName(name);
        user.setEmail(email);
        return "update user done: " + user;
    }
}

可以看到,对于简单数据类型,我们将约束注解直接注解到相应参数上,然后在Controller类上使用@Validated注解,启动数据校验。

对于这种数据校验方式,当校验失败时,会抛出ConstraintViolationException,而不是我们上面对 Java Bean 校验失败抛出的MethodArgumentNotValidException异常,因此,可以为我们的全局异常处理器捕获该异常,进行处理。如下所示:

@RestControllerAdvice
public class GlobalExceptionHandler {
    ...
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String handleConstraintViolationException(ConstraintViolationException e) {
        return e.getConstraintViolations()
                .stream()
                .map(constraintViolation -> {
                    return String.format("[%s: %s]\n",
                            constraintViolation.getPropertyPath().toString(),
                            constraintViolation.getMessage());
                }).collect(Collectors.joining());
    }
}

请求上述代码,如下所示:

$ curl -X GET http://localhost:8080/validate/user/1
[getUser.id: must be greater than or equal to 10]

$ curl -X GET http://localhost:8080/validate/user/10
User id is 10

$ curl http://localhost:8080/validate/user -X PUT --data "name=" --data "email=10"
[updateUser.name: must not be blank]
[updateUser.email: must be a well-formed email address]

$ curl http://localhost:8080/validate/user -X PUT --data "name=Whyn" --data "email=10@qq.com"
update user done: User(id=0, name=Whyn, password=null, email=10@qq.com, extraInfo=null)

Bean Validation 相关注解

自定义Validator

前文讲过,数据校验功能是由Validator负责开启并校验的,在 SpringMVC 中,如果检测到 Bean Validation(比如,Hibernate Validator)存在于classpath路径上时,就会默认全局注册了一个ValidatorLocalValidatorFactoryBean,它会驱动@Valid@Validated开启数据校验。

LocalValidatorFactoryBean同时实现了javax.validation.ValidatorFactoryjavax.validation.Validatororg.springframework.validation.Validator三个接口,所以如果需要手动调用数据校验逻辑,可以通过 IOC 容器获取到这些接口的实例。如下所示:

上述获取的是系统默认的Validator,而如果我们想注入一个自定义Validator,有如下几种方法:

自定义约束注解

如果现存的约束注解无法满足我们的需求,那么我们可以通过自定义约束注解,来定制我们的数据校验逻辑。

在 Spring 中,自定义约束注解主要就是定义一个约束注解及其对应的Validator,两者通过@Constraint关联到一起。
默认情况下,全局校验器LocalValidatorFactoryBean会配置一个SpringConstraintValidatorFactory实例,SpringConstraintValidatorFactory实现了接口ConstraintValidatorFactory,因此它会在遇到自定义约束注解的时候,就会自动实例化@Constraint指定的关联Validator,从而完成数据校验过程。

详细过程可参考如下示例:

例子:假设我们想自定义一个约束注解,用于对手机号进行校验,要求满足手机号码的格式为:+86 13699328716,即以+86开头,然后中间一个或多个空格,后面是有效的手机号码。

自定义约束注解的步骤如下所示:

  1. 自定义一个约束注解:

    @Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = PhoneNoConstraintValidator.class)
    public @interface PhoneNoConstraint {
        String message() default "手机号码格式错误";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    }
    

    这里通过注解@Constraint将自定义注解PhoneNoConstraintPhoneNoConstraintValidator(即一个自定义Validator)关联到一起。

  2. 自定义一个Validator

    public class PhoneNoConstraintValidator implements ConstraintValidator<PhoneNoConstraint, String> {
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            String regex = "\\+86\\s+\\d{11}";
            Pattern pattern = Pattern.compile(regex);
            Matcher matcher = pattern.matcher(value);
            return matcher.matches();
        }
    }
    
  3. 使用自定义约束注解:

    @RestController
    @RequestMapping("validate")
    @Validated
    public class ValidationController {
    
        @PostMapping("/user/{id}")
        public String addPhoneNo(@PathVariable("id") int id,
                                 @RequestParam("phoneNo")
                                 @NotBlank(message = "手机号不能为空")
                                 @PhoneNoConstraint(message = "手机号必须以 +86 开头")
                                         String phoneNo) {
            return id + " => add phoneNo done: " + phoneNo;
    
        }
    }
    

    当程序运行时,遇到自定义约束注解@PhoneNoConstraint时,SpringConstraintValidatorFactory就会通过@PhoneNoConstraint上的@Constraint注解,获取得到其对应的Valiator,然后通过 Spring 创建该Validator实例,进行数据校验。利用这种机制,可以使得我们的自定义Validator享受到其他 Java Bean 一样的依赖注入功能。

    请求上述代码,结果如下:

    $ curl localhost:8080/validate/user/1 -X POST --data-urlencode "phoneNo=13699328716"
    [addPhoneNo.phoneNo: 手机号必须以 +86 开头]
    
    $ curl localhost:8080/validate/user/1 -X POST --data-urlencode "phoneNo=+86 13699328716"
    1 => add phoneNo done: +86 13699328716
    

    :如果 URL 包含+=&等特殊符号时,会被进行转义,比如,+会被转义为空格,这样后端接收的数据格式就永远是错误的,因此,发送数据前,应先对数据进行编码,所以上述curl命令使用--data-urlencode对数据进行编码,以确保特殊字符能成功发送。

其他

参考

上一篇下一篇

猜你喜欢

热点阅读