天天看点

SpingBoot项目使用@Validated和@Valid参数校验

作者:Java机械师

一、什么是参数校验?

我们在后端开发中,经常遇到的一个问题就是入参校验。简单来说就是对一个方法入参的参数进行校验,看是否符合我们的要求。比如入参要求是一个金额,你前端没做限制,用户随便过来一个负数,或者一个字母,那么我们的接口就会报错。

所以,通常我们需要在方法的开始处,对入参的参数进行校验,不符合要求就报错返回,不往下进行。这个校验的过程就是参数校验。

二、为什么需要统一参数校验?

明白了什么是参数校验,我们继续来看下个问题。现在我们就要加参数校验了,如果是一个方法,那很容易。但是如果一个项目的所有接口方法都需要校验呢?想想就头大,会出现很多重复且繁琐的代码,而且校验代码和业务代码混在一起,耦合太重。那么如何解决呢?

这就是今天我们的第二个问题,我们需要统一参数校验。如果你看过我之前介绍的《SpringBoot如何实现AOP》 那么就会想到,这里我们可以通过注解来进行切面参数校验。只需要在想要校验的方法上,加上相应的注解,方法在执行时,就会先走我们的切面方法,对入参进行校验。这样通过统一参数校验,就解决了上面我们提到的耦合、重复等问题。

当然,这个校验注解其实有人已经给我们提供了,有现成好用的了。这就是今天我们的主角Spring Validator框架和Javax Valid。

三、@Validated和@Valid区别

上面我们提到了,参数校验已经有现成的框架了,一个是Spring Validator框架,一个是Javax Valid,那么这两个有什么区别呢?

先说Javax Valid,这个是Java核心包给我们提供的,包名是validation-api。它遵循的是标准JSR-303规范,这个规范其实就是一个校验标准。因为是Java提供的,所以我们使用时不需要单独引入。它提供的最常使用的注解就是@Valid。

再说Spring Validator,它是Spring提供的,底层其实是对hibernate-validator的二次封装。hibernate-validator是上面提到的标准JSR-303规范的变种,但是大多数还是基于上述规范实现。Validator框架提供的最常使用的注解就是我们提到的@Validated。

两者在使用上区别不少,先看使用地方:

  • @Validated:可以用在类型、方法和方法参数上。但是不能用在成员属性(字段)上
  • @Valid:可以用在方法、构造函数、方法参数和成员属性(字段)上

另外@Validated支持分组校验,@Valid作为标准JSR-303规范,还没有支持分组校验。但是它支持加在字段上,所以支持嵌套校验。这些具体区别,我们在下面的文章都会提到。

概念都弄明白了,我们继续下个问题,就是如何使用它们?

四、如何使用@Validated和@Valid等注解参数校验?

这里我们还是以SpringBoot项目为例:

1、先引入jar包,上面已经说过了,Valid是Javax提供的,所以我们不需要引入。只需要引入Spring Validator就可以。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
复制代码           

我们点进去spring-boot-starter-validation包可以看到,实质上引入的还是hibernate-validatro。

SpingBoot项目使用@Validated和@Valid参数校验

备注:这里注意一下,Spring Boot 2.3以前的版本是默认引入了spring-boot-starter-validation的,不需要额外引入。以下是官网说明:

SpingBoot项目使用@Validated和@Valid参数校验

2、一般来说我们只需要对提供给前端的接口或者对外提供服务的接口进行校验即可。

3、参数校验通常分为几种情况,我们需要分开单独说:

3.1 校验RequestParam参数

@GetMapping(value = "/aaa")
public void selectAaa(@RequestParam("usercode") @NotBlank String code){
    }
复制代码           

这种最简单,比如校验String类型的参数code,只需要在方法参数前加上@NotBlank注解,就可以校验入参code不为空,为空则进行报错。但是这里注意一点,校验RequestParam参数时需要在类上添加@Validated注解。

3.2校验PathVariable参数

这个和校验RequestParam参数一样,类上加@Validated注解,具体需要校验的方法入参上,加对应的@NotBlank,@Min等注解校验即可。

3.3校验表单提交参数

前端表单提交时,请求类型contentType是application/x-www-form-urlencoded或者multipart/form-data。这时参数也是以key1=value1&key2=value2这种参数形式带过来的,通常我们可以使用RequestParam参数接收,但是当入参比较多时,我们一般会以实体类接收。像下面这样:

public class demo{
    public void selectAaa(@Validated UserForm form){
    xxx业务代码
    }
}


@Data
@Accessors(chain = true)
public class UserForm {
    @NotBlank(message = "code不能为空")
    private String code;
    ……
}
复制代码           

在这里我们加了@Validated或者@Valid注解校验实体UserForm form入参,具体的校验在实体中通过 @NotBlank校验code不为空。

3.4校验RequestBody入参

这个也是我们最常使用的校验方式,毕竟Post请求还是占大多数。

@PostMapping(value = "/aaa")
public void selectAaa(@RequestBody @Validated UserForm form){
    }
    
@Data
@Accessors(chain = true)
public class UserForm {
@NotBlank(message = "code不能为空")
private String code;
……
}

复制代码           

上面我们对入参实体UserForm加了@Validated或者@Valid注解,表明进行校验。具体的校验我们在UserForm实体中,对字段code使用@NotBlank注解,进行非空校验。这样,通过几个注解,就可以实现对入参字段的校验。这里注意和3.3的区别,这里我们加了@RequestBody注解,是从请求body中解析json格式的参数到实体的。而3.3是是key1=value1&key2=value2这种url参数形式带过来的,从路径中解析到实体的。

为什么要区分各种情况,主要是为了下面我们的异常处理,继续往下看。

4、加了校验后,如果入参不符合我们的要求,就会抛出异常,所以我们还需要进行异常处理。这里也是使用Spring Boot的全局异常处理,进行捕获处理。具体可以参考《SpringBoot优雅的全局异常处理》,这里只简单写下参数异常的捕获处理方法。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(value = {MethodArgumentNotValidException.class,
            ConstraintViolationException.class, BindException.class})
    public Result<String> handleValidatedException(Exception e) {
        log.error("拦截到参数校验未通过,异常信息:" + ExceptionUtil.stacktraceToString(e));
        //@RequestBody参数校验报错
        String errorMsg = "参数校验未通过: ";
        if (e instanceof MethodArgumentNotValidException) {
            MethodArgumentNotValidException ex = (MethodArgumentNotValidException) e;
            errorMsg = errorMsg + ex.getBindingResult().getAllErrors().stream().map(ObjectError::getDefaultMessage).collect(Collectors.joining(", "));
            // 直接校验具体参数报错
        } else if (e instanceof ConstraintViolationException) {
            ConstraintViolationException ex = (ConstraintViolationException) e;
            errorMsg = errorMsg + ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage).collect(Collectors.joining(", "));
            //校验对象参数报错
        } else if (e instanceof BindException) {
            BindException ex = (BindException) e;
            errorMsg = errorMsg + ex.getAllErrors().stream().map(ObjectError::getDefaultMessage).collect(Collectors.joining(", "));
        }
        return Result.fail(errorMsg);
    }
}
复制代码           

可以看到,我们上面的异常处理代码,分别捕获了MethodArgumentNotValidException.class, ConstraintViolationException.class, BindException.class三种异常。

ConstraintViolationException.class异常对应的就是校验RequestParam参数和校验PathVariable参数,这两种校验不通过,系统会抛出此异常。

BindException.class异常,对应的是校验表单提交参数,校验不通过,系统会抛出此异常。

MethodArgumentNotValidException.class异常对应的是校验RequestBody入参,校验不通过,系统会抛出此异常。

到这,我们通过@Validated或者@Valid校验基本的入参,已经基本完成了。引入jar包,在对应类和方法上添加注解校验,处理校验不通过抛出的异常,就可以正常运行了。但是这些只是最基本的校验,下面再介绍一些特殊场景的校验方法。

五、分组校验和嵌套校验

这两种校验方式也是我们经常会遇到的,这也是@Validated和@Valid的一个区别。 @Validated支持分组校验,@Valid支持嵌套校验。

  1. 分组校验

比如我们现在有一个入参UserForm,在接口A和B中都使用,那么正常我们是这么校验参数的。

@PostMapping(value = "/aaa")
public void selectAaa(@RequestBody @Validated UserForm form){
    }
    
@Data
@Accessors(chain = true)
public class UserForm {
    @NotBlank(message = "code不能为空")
    private String code;
    @NotBlank(message = "name不能为空")
    private String name;
    private String sex;
    ……
}
复制代码           

我们在接口selectAaa中校验了入参code和name。那么现在如果我们还有个接口selectBbb,入参也是用UserForm接收,但是这个接口我们需要校验code,name和sex。那么我们需要在sex上也加上@NotBlank注解。但是这样的话,我们的接口selectAaa也会校验sex。这样问题就来了,同一个UserForm无法用在多个接口中接收入参了。有没有好的解决办法呢?笨办法当然是再建一个UserForm2了。我们这里不讨论这样合不合适,我们的解决方案就是分组校验。

分组校验主要分两步,第一步写一个分组校验类。

/**
 * Validation分组校验类,继承Default,不分组的默认校验
 */
public interface ValidGroup extends Default {
    interface demo extends ValidGroup {
        interface a extends demo {
        }

        interface b extends demo {

        }

        interface demo1 extends ValidGroup {

        }
    }

}
复制代码           

这个分组校验类可以选择继承或者不继承Default类,如果继承,那么不分组的默认也会校验。这个是什么意思呢?我们继续往下看。

回到刚才的接口selectAaa和selectBbb中,selectAaa不需要校验sex,而selectBbb需要校验。那么我们就可以在sex字段上加分组校验。

@PostMapping(value = "/bbb")
public void selectBbb(@RequestBody
@Validated(value = ValidGroup.demo.a.class) UserForm form){
    }
    
@Data
@Accessors(chain = true)
public class UserForm {
    @NotBlank(message = "code不能为空")
    private String code;
    @NotBlank(message = "name不能为空")
    private String name;
    @NotBlank(groups = ValidGroup.demo.a.class, message = "性别不能为空")
    private String sex;
    ……
}
复制代码           

可以看到,我们在NotBlank里加了group属性,在方法入参Validated上也加了value属性,都指定的同一个分组ValidGroup.demo.a.class。这样就可以起到分组校验的作用。selectAaa入参时,只校验code和name。SelectBbb入参时,因为指定了分组,所以会校验同一个分组下的sex字段。至于code和name是否校验,就由我们的ValidGroup类是否继承Default决定,这里继承了,则默认校验未分组的字段。那么SelectBbb校验的就是code,name和sex。这样我们分组校验就实现了,我们的同一个接收实体,就可以用在不同的接口上了。

  1. 嵌套校验

我们再来看一种场景,入参还是使用UserForm接收,但是UserForm里有个属性是爱好interest,而interest也是个实体。里面又有新的属性,比如interest也有code和name属性。

@Data
@Accessors(chain = true)
public class UserForm {
    @NotBlank(message = "code不能为空")
    private String code;
    @NotBlank(message = "name不能为空")
    private String name;
    @NotBlank(groups = ValidGroup.demo.a.class, message = "性别不能为空")
    private String sex;
    @NotNull
    private Interest interest;
    ……
}
@Data
@Accessors(chain = true)
public class Interest {
    @NotBlank
    private String code;
    @NotBlank
    private String name;
    ……
}
复制代码           

这个时候,我们只在方法入参上使用@Validated校验UserForm,那么code,name,sex等属性校验都没问题,但是interest属性就会有问题了,它只会校验interest这个对象是否为空,而不会校验这个对象内部的属性code和name是否符合要求,即使是我们在Interest里面的字段加上@NotBliank等注解了。比如有个interest入参只有name=篮球,没有传code,也是可以正常入参的。显然这样不符合我们的入参要求,那么这种如何校验呢,这里就需要用到我们的嵌套校验了。

嵌套校验也比较简单,在方法入参上,使用@Valid进行校验UserForm。而在UserForm中,我们对Interest校验时,继续使用@Valid校验(这里还记得我们在上面讲@Valid和@Validated区别时说的吗?@Valid是支持加在字段上的,所以这里能使用@Valid)。而在Interest对象中,我们继续使用@NotBlank等注解校验具体字段即可。

//嵌套校验
@PostMapping(value = "/bbb")
public void selectBbb(@RequestBody @Valid UserForm form){
    //xxx业务代码
    }
@Data
@Accessors(chain = true)
public class UserForm {
    @NotBlank(message = "code不能为空")
    private String code;
    @NotBlank(message = "name不能为空")
    private String name;
    @NotBlank(groups = ValidGroup.demo.a.class, message = "性别不能为空")
    private String sex;
    
    //这里用@Valid加在字段上,表明需要校验内部属性,NotNull是校验interest对象是否存在
    @Valid
    @NotNull
    private Interest interest;
    //List格式也可以这么校验
    @Valid
    @NotNull
    private List<Interest> interest;
    ……
}
@Data
@Accessors(chain = true)
public class Interest {
    @NotBlank
    private String code;
    @NotBlank
    private String name;
    ……
}
复制代码           

这样,我们就可以既校验interest对象是否传参了,又校验interest内部的code等字段是否符合规范了。 另外这里的嵌套校验,注意可能发生数据绑定错误,需要在全局异常中添加一下代码进行绑定初始化:

@InitBinder
private void activateDirectFieldAccess(DataBinder dataBinder) {
    dataBinder.initDirectFieldAccess();
}
复制代码           

这里再列举一种嵌套校验的使用场景,有时方法入参是List格式,我们也可以用@Validated配合@Valid做嵌套校验。 方法入参上使用@Valid校验,UserForm里使用@NotBlank等校验具体参数,这里注意需要在类上加@Validated才能生效。

@RestController
@Validated
public class test{
        //嵌套校验
@PostMapping(value = "/bbb")
public void selectBbb(@RequestBody @Valid List<UserForm> formList){
    //xxx业务代码
    }
}
复制代码           

六、Service层校验

上面我们提到了,一般我们需要入参校验的有两种情况,第一种,给前端提供的方法。第二种,就是对外提供的服务。因为如果是我们自己的内部方法,肯定是自己来入参,就不需要校验了。但是提供给别人的方法,就没法保证了。第一种情况,给前端的接口我们都会放在controller层,也就是我们上面讲的各种使用情形。

但是总有特殊情况,比如第二种,我们直接通过service层提供对外的RPC接口服务。有时也需要在service层方法做入参校验。这里简单提下service层需要注意的点。如果我们同时定义了接口和实现类,那么@Validated注解需要加在接口上,而不是实现类上。如果只有实现类,那直接加在实现类上是没问题的。

七、结语

到这,我们今天的文章就算结束了,大家跟着文章,应该可以在项目中满足大多数场景的使用了。当然,还有些配置因为篇幅问题这里没提,大家后面可以根据自己的使用场景修改完善。

比如参数校验时,第一个参数校验失败了,就返回异常,无需全部校验,这就需要开启Fast模式。还有可以使用的校验注解,文章没有一一列出,大家在使用中也不需要记,只需要记住几个常用的注解就行了,在使用时现查就可以了。当然如果一些复杂的逻辑,框架本身提供的注解无法满足业务需求,那就需要我们写自定义注解,实现ConstraintValidator接口写自定义校验类来校验了。

继续阅读