使用 SpringBoot 进行优雅的数据验证

JSR-303 规范

在程序进行数据处理之前,对数据进行准确性校验是我们必须要考虑的事情。尽早发现数据错误,不仅可以防止错误向核心业务逻辑蔓延,而且这种错误非常明显,容易发现解决。

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

关于 JSR 303 – Bean Validation 规范,可以参考官网

对于 JSR 303 规范,Hibernate Validator 对其进行了参考实现 . Hibernate Validator 提供了 JSR 303 规范中所有内置 constraint 的实现,除此之外还有一些附加的 constraint。如果想了解更多有关 Hibernate Validator 的信息,请查看官网。

validation-api 内置的 constraint 清单

Constraint 详细信息
@AssertFalse 被注释的元素必须为 false
@AssertTrue 同 @AssertFalse
@DecimalMax 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin 同@DecimalMax
@Digits 带批注的元素必须是一个在可接受范围内的数字
@Email 顾名思义
@Future 将来的日期
@FutureOrPresent 现在或将来
@Max 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Min 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Negative 带注释的元素必须是一个严格的负数(0 为无效值)
@NegativeOrZero 带注释的元素必须是一个严格的负数(包含 0)
@NotBlank 同 StringUtils.isNotBlank
@NotEmpty 同 StringUtils.isNotEmpty
@NotNull 不能是 Null
@Null 元素是 Null
@Past 被注释的元素必须是一个过去的日期
@PastOrPresent 过去和现在
@Pattern 被注释的元素必须符合指定的正则表达式
@Positive 被注释的元素必须严格的正数(0 为无效值)
@PositiveOrZero 被注释的元素必须严格的正数(包含 0)
@Szie 带注释的元素大小必须介于指定边界(包括)之间

Hibernate Validator 附加的 constraint#

Constraint 详细信息
@Email 被注释的元素必须是电子邮箱地址
@Length 被注释的字符串的大小必须在指定的范围内
@NotEmpty 被注释的字符串的必须非空
@Range 被注释的元素必须在合适的范围内
@CreditCardNumber 被注释的元素必须符合信用卡格式

Hibernate Validator 不同版本附加的 Constraint 可能不太一样,具体还需要你自己查看你使用版本。 Hibernate 提供的 Constraintorg.hibernate.validator.constraints 这个包下面。

一个 constraint 通常由 annotation 和相应的 constraint validator 组成,它们是一对多的关系。也就是说可以有多个 constraint validator 对应一个 annotation。在运行时,Bean Validation 框架本身会根据被注释元素的类型来选择合适的 constraint validator 对数据进行验证。

有些时候,在用户的应用中需要一些更复杂的 constraint。Bean Validation 提供扩展 constraint 的机制。可以通过两种方法去实现,一种是组合现有的 constraint 来生成一个更复杂的 constraint,另外一种是开发一个全新的 constraint。

使用 Spring Boot 进行数据校验

Spring Validation 对 hibernate validation 进行了二次封装,可以让我们更加方便地使用数据校验功能。这边我们通过 Spring Boot 来引用校验功能。

如果你用的 Spring Boot 版本小于 2.3.x,spring-boot-starter-web 会自动引入 hibernate-validator 的依赖。如果 Spring Boot 版本大于 2.3.x,则需要手动引入依赖:

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.1.Final</version>
</dependency>

直接参数校验

有时候接口的参数比较少,只有一个活着两个参数,这时候就没必要定义一个 DTO 来接收参数,可以直接接收参数。

@Validated
@RestController
@RequestMapping("/user")
public class UserController {

    private static Logger logger = LoggerFactory.getLogger(UserController.class);

    @GetMapping("/getUser")
    @ResponseBody
    // 注意:如果想在参数中使用 @NotNull 这种注解校验,就必须在类上添加 @Validated;
    public UserDTO getUser(@NotNull(message = "userId不能为空") Integer userId){
        logger.info("userId:[{}]",userId);
        UserDTO res = new UserDTO();
        res.setUserId(userId);
        res.setName("程序员自由之路");
        res.setAge(8);
        return res;
    }
}

下面是统一异常处理类

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(value = ConstraintViolationException.class)
    public Response handle1(ConstraintViolationException ex){
            StringBuilder msg = new StringBuilder();
        Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
        for (ConstraintViolation<?> constraintViolation : constraintViolations) {
            PathImpl pathImpl = (PathImpl) constraintViolation.getPropertyPath();
            String paramName = pathImpl.getLeafNode().getName();
            String message = constraintViolation.getMessage();
            msg.append("[").append(message).append("]");
        }
        logger.error(msg.toString(),ex);
        // 注意:Response类必须有get和set方法,不然会报错
        return new Response(RCode.PARAM_INVALID.getCode(),msg.toString());
    }

    @ExceptionHandler(value = Exception.class)
    public Response handle1(Exception ex){
        logger.error(ex.getMessage(),ex);
        return new Response(RCode.ERROR.getCode(),RCode.ERROR.getMsg());
    }

}

调用结果

# 这里没有传 userId

GET http://127.0.0.1:9999/user/getUser

HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sat, 14 Nov 2020 07:35:44 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
"rtnCode": "1000",
"rtnMsg": "[userId 不能为空]"
}

实体类 DTO 校验

定义一个 DTO

import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.NotEmpty;

public class UserDTO {

    private Integer userId;

    @NotEmpty(message = "姓名不能为空")
    private String name;

    @Range(min = 18,max = 50,message = "年龄必须在18和50之间")
    private Integer age;

    @DecimalMin(value = "0.00", message = "费率格式不正确",groups = UpdateFeeRate.class)
    @DecimalMax(value = "100.00", message = "费率格式不正确",groups = UpdateFeeRate.class)
    private BigDecimal gongzi;
    //省略get和set方法

}

接收参数时使用@Validated 进行校验

@PostMapping("/saveUser")
@ResponseBody
//注意:如果方法中的参数是对象类型,则必须要在参数对象前面添加 @Validated
public Response<UserDTO> getUser(@Validated @RequestBody UserDTO userDTO){
    userDTO.setUserId(100);
    Response response = Response.success();
    response.setData(userDTO);
    return response;
}

统一异常处理

@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Response handle2(MethodArgumentNotValidException ex){
    BindingResult bindingResult = ex.getBindingResult();
    if(bindingResult!=null){
        if(bindingResult.hasErrors()){
            FieldError fieldError = bindingResult.getFieldError();
            String field = fieldError.getField();
            String defaultMessage = fieldError.getDefaultMessage();
            logger.error(ex.getMessage(),ex);
            return new Response(RCode.PARAM_INVALID.getCode(),field+":"+defaultMessage);
        }else {
          logger.error(ex.getMessage(),ex);
          return new Response(RCode.ERROR.getCode(),RCode.ERROR.getMsg());
        }
    }else {
        logger.error(ex.getMessage(),ex);
        return new Response(RCode.ERROR.getCode(),RCode.ERROR.getMsg());
    }
}

调用结果

创建用户

POST http://127.0.0.1:9999/user/saveUser
Content-Type: application/json

{
"name1": "程序员自由之路",
"age": "18"
}

下面是返回结果

{
"rtnCode": "1000",
"rtnMsg": "姓名不能为空"
}

对 Service 层方法参数校验

个人不太喜欢这种校验方式,一半情况下调用 service 层方法的参数都需要在 controller 层校验好,不需要再校验一次。这边列举这个功能,只是想说 Spring 也支持这个。

@Validated
@Service
public class ValidatorService {

    private static final Logger logger = LoggerFactory.getLogger(ValidatorService.class);

    public String show(@NotNull(message = "不能为空") @Min(value = 18, message = "最小18") String age) {
    	logger.info("age = {}", age);
    	return age;
    }
}

分组校验

有时候对于不同的接口,需要对 DTO 进行不同的校验规则。还是以上面的 UserDTO 为列,另外一个接口可能不需要将 age 限制在 18 ~ 50 之间,只需要大于 18 就可以了。

这样上面的校验规则就不适用了。分组校验就是来解决这个问题的,同一个 DTO,不同的分组采用不同的校验策略。

public class UserDTO {

    public interface Default {
    }

    public interface Group1 {
    }

    private Integer userId;
    //注意:@Validated 注解中加上groups属性后,DTO中没有加group属性的校验规则将失效
    @NotEmpty(message = "姓名不能为空",groups = Default.class)
    private String name;

    //注意:加了groups属性之后,必须在@Validated 注解中也加上groups属性后,校验规则才能生效,不然下面的校验限制就失效了
    @Range(min = 18, max = 50, message = "年龄必须在18和50之间",groups = Default.class)
    @Range(min = 17, message = "年龄必须大于17", groups = Group1.class)
    private Integer age;

}

使用方式

@PostMapping("/saveUserGroup")
@ResponseBody
//注意:如果方法中的参数是对象类型,则必须要在参数对象前面添加 @Validated
//进行分组校验,年龄满足大于 17
public Response<UserDTO> saveUserGroup(@Validated(value = {UserDTO.Group1.class}) @RequestBody UserDTO userDTO){
    userDTO.setUserId(100);
    Response response = Response.success();
    response.setData(userDTO);
    return response;
}

使用 Group1 分组进行校验,因为 DTO 中,Group1 分组对 name 属性没有校验,所以这个校验将不会生效。

分组校验的好处是可以对同一个 DTO 设置不同的校验规则,缺点就是对于每一个新的校验分组,都需要重新设置下这个分组下面每个属性的校验规则。

分组校验还有一个按顺序校验功能。

考虑一种场景:一个 bean 有 1 个属性(假如说是 attrA),这个属性上添加了 3 个约束(假如说是@NotNull、@NotEmpty、@NotBlank)。默认情况下,validation-api 对这 3 个约束的校验顺序是随机的。也就是说,可能先校验@NotNull,再校验@NotEmpty,最后校验@NotBlank,也有可能先校验@NotBlank,再校验@NotEmpty,最后校验@NotNull。

那么,如果我们的需求是先校验@NotNull,再校验@NotBlank,最后校验@NotEmpty。@GroupSequence 注解可以实现这个功能。

public class GroupSequenceDemoForm {

    @NotBlank(message = "至少包含一个非空字符", groups = {First.class})
    @Size(min = 11, max = 11, message = "长度必须是11", groups = {Second.class})
    private String demoAttr;

    public interface First {

    }

    public interface Second {

    }

    @GroupSequence(value = {First.class, Second.class})
    public interface GroupOrderedOne {
        // 先计算属于 First 组的约束,再计算属于 Second 组的约束
    }


    @GroupSequence(value = {Second.class, First.class})
    public interface GroupOrderedTwo {
        // 先计算属于 Second 组的约束,再计算属于 First 组的约束
    }

}

使用方式

// 先计算属于 First 组的约束,再计算属于 Second 组的约束
@Validated(value = {GroupOrderedOne.class}) @RequestBody GroupSequenceDemoForm form

嵌套校验

前面的示例中,DTO 类里面的字段都是基本数据类型和 String 等类型。

但是实际场景中,有可能某个字段也是一个对象,如果我们需要对这个对象里面的数据也进行校验,可以使用嵌套校验。

假如 UserDTO 中还用一个 Job 对象,比如下面的结构。需要注意的是,在 job 类的校验上面一定要加上@Valid 注解。

public class UserDTO1 {

    private Integer userId;
    @NotEmpty
    private String name;
    @NotNull
    private Integer age;
    @Valid
    @NotNull
    private Job job;

    public Integer getUserId() {
        return userId;
    }

    public void setUserId(Integer userId) {
        this.userId = userId;
    }

    public String getName() {
        return name;
    }

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

    public Integer getAge() {
        return age;
    }

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

    public Job getJob() {
        return job;
    }

    public void setJob(Job job) {
        this.job = job;
    }

    /**
     * 这边必须设置成静态内部类
     */
    static class Job {
        @NotEmpty
        private String jobType;
        @DecimalMax(value = "1000.99")
        private Double salary;

        public String getJobType() {
            return jobType;
        }

        public void setJobType(String jobType) {
            this.jobType = jobType;
        }

        public Double getSalary() {
            return salary;
        }

        public void setSalary(Double salary) {
            this.salary = salary;
        }
    }

}

使用方式

@PostMapping("/saveUserWithJob")
@ResponseBody
public Response<UserDTO1> saveUserWithJob(@Validated @RequestBody UserDTO1 userDTO){
userDTO.setUserId(100);
Response response = Response.success();
response.setData(userDTO);
return response;
}

测试结果

POST http://127.0.0.1:9999/user/saveUserWithJob
Content-Type: application/json

{
"name": "程序员自由之路",
"age": "16",
"job": {
"jobType": "1",
"salary": "9999.99"
}
}

{
"rtnCode": "1000",
"rtnMsg": "job.salary:必须小于或等于 1000.99"
}

嵌套校验可以结合分组校验一起使用。还有就是嵌套集合校验会对集合里面的每一项都进行校验,例如 List 字段会对这个 list 里面的每一个 Job 对象都进行校验。这个点
在下面的@Valid 和@Validated 的区别章节有详细讲到。

集合校验

如果请求体直接传递了 json 数组给后台,并希望对数组中的每一项都进行参数校验。此时,如果我们直接使用 java.util.Collection 下的 list 或者 set 来接收数据,参数校验并不会生效!我们可以使用自定义 list 集合来接收参数:

包装 List 类型,并声明@Valid 注解

public class ValidationList<T> implements List<T> {

    // @Delegate是lombok注解
    // 本来实现List接口需要实现一系列方法,使用这个注解可以委托给ArrayList实现
    // @Delegate
    @Valid
    public List list = new ArrayList<>();


    @Override
    public int size() {
        return list.size();
    }

    @Override
    public boolean isEmpty() {
        return list.isEmpty();
    }

    @Override
    public boolean contains(Object o) {
        return list.contains(o);
    }
    //.... 下面省略一系列List接口方法,其实都是调用了ArrayList的方法

}

调用方法

@PostMapping("/batchSaveUser")
@ResponseBody
public Response batchSaveUser(@Validated(value = UserDTO.Default.class) @RequestBody ValidationList<UserDTO> userDTOs){
    return Response.success();
}

调用结果

Caused by: org.springframework.beans.NotReadablePropertyException: Invalid property 'list[1]' of bean class [com.csx.demo.spring.boot.dto.ValidationList]: Bean property 'list[1]' is not readable or has an invalid getter method: Does the return type of the getter match the parameter type of the setter?
at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:622) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.beans.AbstractNestablePropertyAccessor.getNestedPropertyAccessor(AbstractNestablePropertyAccessor.java:839) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyAccessorForPropertyPath(AbstractNestablePropertyAccessor.java:816) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]
at org.springframework.beans.AbstractNestablePropertyAccessor.getPropertyValue(AbstractNestablePropertyAccessor.java:610) ~[spring-beans-5.2.6.RELEASE.jar:5.2.6.RELEASE]

会抛出 NotReadablePropertyException 异常,需要对这个异常做统一处理。这边代码就不贴了。

自定义校验器

在 Spring 中自定义校验器非常简单,分两步走。

自定义约束注解

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

    // 默认错误消息
    String message() default "加密id格式错误";

    // 分组
    Class[] groups() default {};

    // 负载
    Class[] payload() default {};

}

实现 ConstraintValidator 接口编写约束校验器

public class EncryptIdValidator implements ConstraintValidator<EncryptId, String> {

    private static final Pattern PATTERN = Pattern.compile("^[a-f\\d]{32,256}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // 不为null才进行校验
        if (value != null) {
            Matcher matcher = PATTERN.matcher(value);
            return matcher.find();
        }
        return true;
    }
}

编程式校验

上面的示例都是基于注解来实现自动校验的,在某些情况下,我们可能希望以编程方式调用验证。这个时候可以注入
javax.validation.Validator 对象,然后再调用其 api。

@Autowired
private javax.validation.Validator globalValidator;

// 编程式校验
@PostMapping("/saveWithCodingValidate")
public Result saveWithCodingValidate(@RequestBody UserDTO userDTO) {
Set<constraintviolation> validate = globalValidator.validate(userDTO, UserDTO.Save.class);
    // 如果校验通过,validate 为空;否则,validate 包含未校验通过项
    if (validate.isEmpty()) {
    // 校验通过,才会执行业务逻辑处理

    } else {
        for (ConstraintViolation userDTOConstraintViolation : validate) {
            // 校验失败,做其它逻辑
            System.out.println(userDTOConstraintViolation);
        }
    }
    return Result.ok();

}

快速失败(Fail Fast)配置

Spring Validation 默认会校验完所有字段,然后才抛出异常。可以通过一些简单的配置,开启 Fali Fast 模式,一旦校验失败就立即返回。

@Bean
public Validator validator() {
    ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
          .configure()
          // 快速失败模式
          .failFast(true)
          .buildValidatorFactory();
    return validatorFactory.getValidator();
}

校验信息的国际化

Spring 的校验功能可以返回很友好的校验信息提示,而且这个信息支持国际化。

这块功能暂时暂时不常用,具体可以参考这篇文章

@Validated 和@Valid 的区别联系

首先,@Validated 和@Valid 都能实现基本的验证功能,也就是如果你是想验证一个参数是否为空,长度是否满足要求这些简单功能,使用哪个注解都可以。

但是这两个注解在分组、注解作用的地方、嵌套验证等功能上两个有所不同。下面列下这两个注解主要的不同点。

  • @Valid 注解是 JSR303 规范的注解,@Validated 注解是 Spring 框架自带的注解;
  • @Valid 不具有分组校验功能,@Validate 具有分组校验功能;
  • @Valid 可以用在方法、构造函数、方法参数和成员属性(字段)上,@Validated 可以用在类型、方法和方法参数上。但是不能用在成员属性(字段)上,两者是否能用于成员属性(字段)上直接影响能否提供嵌套验证的功能;
  • @Valid 加在成员属性上可以对成员属性进行嵌套验证,而@Validate 不能加在成员属性上,所以不具备这个功能。

这边说明下,什么叫嵌套验证。

我们现在有个实体叫做 Item:

public class Item {

    @NotNull(message = "id不能为空")
    @Min(value = 1, message = "id必须为正整数")
    private Long id;

    @NotNull(message = "props不能为空")
    @Size(min = 1, message = "至少要有一个属性")
    private List<Prop> props;

}

Item 带有很多属性,属性里面有:pid、vid、pidName 和 vidName,如下所示:

public class Prop {

    @NotNull(message = "pid不能为空")
    @Min(value = 1, message = "pid必须为正整数")
    private Long pid;

    @NotNull(message = "vid不能为空")
    @Min(value = 1, message = "vid必须为正整数")
    private Long vid;

    @NotBlank(message = "pidName不能为空")
    private String pidName;

    @NotBlank(message = "vidName不能为空")
    private String vidName;

}

属性这个实体也有自己的验证机制,比如 pid 和 vid 不能为空,pidName 和 vidName 不能为空等。
现在我们有个 ItemController 接受一个 Item 的入参,想要对 Item 进行验证,如下所示:

@RestController
public class ItemController {

    @RequestMapping("/item/add")
    public void addItem(@Validated Item item, BindingResult bindingResult) {
        doSomething();
    }

}

在上图中,如果 Item 实体的 props 属性不额外加注释,只有@NotNull 和@Size,无论入参采用@Validated 还是@Valid 验证,Spring Validation 框架只会对 Item 的 id 和 props 做非空和数量验证,不会对 props 字段里的 Prop 实体进行字段验证,也就是@Validated 和@Valid 加在方法参数前,都不会自动对参数进行嵌套验证。也就是说如果传的 List 中有 Prop 的 pid 为空或者是负数,入参验证不会检测出来。

为了能够进行嵌套验证,必须手动在 Item 实体的 props 字段上明确指出这个字段里面的实体也要进行验证。由于@Validated 不能用在成员属性(字段)上,但是@Valid 能加在成员属性(字段)上,而且@Valid 类注解上也说明了它支持嵌套验证功能,那么我们能够推断出:@Valid 加在方法参数时并不能够自动进行嵌套验证,而是用在需要嵌套验证类的相应字段上,来配合方法参数上@Validated 或@Valid 来进行嵌套验证。

我们修改 Item 类如下所示:

public class Item {

    @NotNull(message = "id不能为空")
    @Min(value = 1, message = "id必须为正整数")
    private Long id;

    @Valid // 嵌套验证必须用@Valid
    @NotNull(message = "props不能为空")
    @Size(min = 1, message = "props至少要有一个自定义属性")
    private List<Prop> props;

}

然后我们在 ItemController 的 addItem 函数上再使用@Validated 或者@Valid,就能对 Item 的入参进行嵌套验证。此时 Item 里面的 props 如果含有 Prop 的相应字段为空的情况,Spring Validation 框架就会检测出来,bindingResult 就会记录相应的错误。

Spring Validation 原理简析

现在我们来简单分析下 Spring 校验功能的原理。

方法级别的参数校验实现原理

所谓的方法级别的校验就是指将@NotNull 和@NotEmpty 这些约束直接加在方法的参数上的。

比如

@GetMapping("/getUser")
@ResponseBody
public R getUser(@NotNull(message = "userId 不能为空") Integer userId){
//
}

或者

@Validated
@Service
public class ValidatorService {

    private static final Logger logger = LoggerFactory.getLogger(ValidatorService.class);

    public String show(@NotNull(message = "不能为空") @Min(value = 18, message = "最小18") String age) {
    	logger.info("age = {}", age);
    	return age;
    }

}

都属于方法级别的校验。这种方式可用于任何 Spring Bean 的方法上,比如 Controller/Service 等。

其底层实现原理就是 AOP,具体来说是通过 MethodValidationPostProcessor 动态注册 AOP 切面,然后使用 MethodValidationInterceptor 对切点方法织入增强。

public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessorimplements InitializingBean {
    @Override
    public void afterPropertiesSet() {
        //为所有`@Validated`标注的 Bean 创建切面
        Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
        //创建 Advisor 进行增强
        this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
    }

    //创建Advice,本质就是一个方法拦截器
    protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
        return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
    }

}

接着看一下 MethodValidationInterceptor:

public class MethodValidationInterceptor implements MethodInterceptor {
    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        //无需增强的方法,直接跳过
        if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
            return invocation.proceed();
        }
        //获取分组信息
        Class[] groups = determineValidationGroups(invocation);
        ExecutableValidator execVal = this.validator.forExecutables();
        Method methodToValidate = invocation.getMethod();
        Set<constraintviolation> result;
        try {
            //方法入参校验,最终还是委托给 Hibernate Validator 来校验
            result = execVal.validateParameters(
            invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
        } catch (IllegalArgumentException ex) {
            ...
        }
        //有异常直接抛出
        if (!result.isEmpty()) {
            throw new ConstraintViolationException(result);
        }
        //真正的方法调用
        Object returnValue = invocation.proceed();
        //对返回值做校验,最终还是委托给 Hibernate Validator 来校验
        result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
        //有异常直接抛出
        if (!result.isEmpty()) {
            throw new ConstraintViolationException(result);
        }
        return returnValue;
    }
}

DTO 级别的校验

@PostMapping("/saveUser")
@ResponseBody
//注意:如果方法中的参数是对象类型,则必须要在参数对象前面添加 @Validated
public R saveUser(@Validated @RequestBody UserDTO userDTO){
    userDTO.setUserId(100);
    return R.SUCCESS.setData(userDTO);
}

这种属于 DTO 级别的校验。在 spring-mvc 中,RequestResponseBodyMethodProcessor 是用于解析@RequestBody 标注的参数以及处理@ResponseBody 标注方法的返回值的。显然,执行参数校验的逻辑肯定就在解析参数的方法 resolveArgument()中。

public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
    @Override
    public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

        parameter = parameter.nestedIfOptional();
        //将请求数据封装到DTO对象中
        Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
        String name = Conventions.getVariableNameForParameter(parameter);

        if (binderFactory != null) {
            WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
            if (arg != null) {
                // 执行数据校验
                validateIfApplicable(binder, parameter);
                if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
                    throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
                }
            }
            if (mavContainer != null) {
                mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
            }
        }
        return adaptArgumentIfNecessary(arg, parameter);
    }

}

可以看到,resolveArgument()调用了 validateIfApplicable()进行参数校验。

protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
    // 获取参数注解,比如@RequestBody、@Valid、@Validated
    Annotation[] annotations = parameter.getParameterAnnotations();
    for (Annotation ann : annotations) {
        // 先尝试获取@Validated 注解
        Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
        //如果直接标注了@Validated,那么直接开启校验。
        //如果没有,那么判断参数前是否有 Valid 起头的注解。
        if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
            Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
            Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints});
            //执行校验
            binder.validate(validationHints);
            break;
        }
    }
}

看到这里,大家应该能明白为什么这种场景下@Validated、@Valid 两个注解可以混用。我们接下来继续看 WebDataBinder.validate()实现。

最终发现底层最终还是调用了 Hibernate Validator 进行真正的校验处理。

404 等错误的统一处理

参考博客

参考

Spring Validation 实现原理及如何运用

SpringBoot 参数校验和国际化使用

@Valid 和@Validated 区别
Spring Validation 最佳实践及其实现原理,参数校验没那么简单!

出处:https://www.cnblogs.com/54chensongxia/p/14016179.html

热门相关:亿万盛宠只为你   大妆   性爱寄宿家庭:轮流性爱   哥哥,我们应该做秘密朋友吗   她的品味