本文会具体介绍Spring Validation各种场景下的最佳实际及其实现原理,死磕到底!

简略应用

Java API标准 (JSR303) 定义了Bean校验的规范validation-api,但没有提供实现。hibernate validation是对这个标准的实现,并减少了校验注解如@Email@Length等。Spring Validation是对hibernate validation的二次封装,用于反对spring mvc参数主动校验。接下来,咱们以spring-boot我的项目为例,介绍Spring Validation的应用。

引入依赖

如果spring-boot版本小于2.3.xspring-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>  

对于web服务来说,为避免非法参数对业务造成影响,在Controller层肯定要做参数校验的!大部分状况下,申请参数分为如下两种模式:

  1. POSTPUT申请,应用requestBody传递参数;
  2. GET申请,应用requestParam/PathVariable传递参数。

上面咱们简略介绍下requestBodyrequestParam/PathVariable的参数校验实战!

requestBody参数校验

POSTPUT申请个别会应用requestBody传递参数,这种状况下,后端应用 DTO 对象进行接管。只有给 DTO 对象加上@Validated注解就能实现主动参数校验。比方,有一个保留User的接口,要求userName长度是2-10accountpassword字段长度是6-20。如果校验失败,会抛出MethodArgumentNotValidException异样,Spring默认会将其转为400(Bad Request)申请。

DTO 示意数据传输对象(Data Transfer Object),用于服务器和客户端之间交互传输应用的。在 spring-web 我的项目中能够示意用于接管申请参数的Bean对象。

如果您正在学习Spring Boot,那么举荐一个连载多年还在持续更新的收费教程:http://blog.didispace.com/spr...
  • DTO字段上申明束缚注解
@Data  public class UserDTO {        private Long userId;        @NotNull      @Length(min = 2, max = 10)      private String userName;        @NotNull      @Length(min = 6, max = 20)      private String account;        @NotNull      @Length(min = 6, max = 20)      private String password;  }  
  • 在办法参数上申明校验注解
@PostMapping("/save")  public Result saveUser(@RequestBody @Validated UserDTO userDTO) {        return Result.ok();  }  

这种状况下,应用@Valid@Validated都能够

requestParam/PathVariable参数校验

GET申请个别会应用requestParam/PathVariable传参。如果参数比拟多 (比方超过 6 个),还是举荐应用DTO对象接管。否则,举荐将一个个参数平铺到办法入参中。在这种状况下,必须在Controller类上标注@Validated注解,并在入参上申明束缚注解 (如@Min等)。如果校验失败,会抛出ConstraintViolationException异样。代码示例如下:

@RequestMapping("/api/user")  @RestController  @Validated  public class UserController {        @GetMapping("{userId}")      public Result detail(@PathVariable("userId") @Min(10000000000000000L) Long userId) {            UserDTO userDTO = new UserDTO();          userDTO.setUserId(userId);          userDTO.setAccount("11111111111111111");          userDTO.setUserName("xixi");          userDTO.setAccount("11111111111111111");          return Result.ok(userDTO);      }        @GetMapping("getByAccount")      public Result getByAccount(@Length(min = 6, max = 20) @NotNull String  account) {            UserDTO userDTO = new UserDTO();          userDTO.setUserId(10000000000000003L);          userDTO.setAccount(account);          userDTO.setUserName("xixi");          userDTO.setAccount("11111111111111111");          return Result.ok(userDTO);      }  }  

对立异样解决

后面说过,如果校验失败,会抛出MethodArgumentNotValidException或者ConstraintViolationException异样。在理论我的项目开发中,通常会用对立异样解决来返回一个更敌对的提醒。比方咱们零碎要求无论发送什么异样,http的状态码必须返回200,由业务码去辨别零碎的异常情况。

@RestControllerAdvice  public class CommonExceptionHandler {        @ExceptionHandler({MethodArgumentNotValidException.class})      @ResponseStatus(HttpStatus.OK)      @ResponseBody      public Result handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {          BindingResult bindingResult = ex.getBindingResult();          StringBuilder sb = new StringBuilder("校验失败:");          for (FieldError fieldError : bindingResult.getFieldErrors()) {              sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(", ");          }          String msg = sb.toString();         return Result.fail(BusinessCode.参数校验失败, msg);      }        @ExceptionHandler({ConstraintViolationException.class})      @ResponseStatus(HttpStatus.OK)      @ResponseBody      public Result handleConstraintViolationException(ConstraintViolationException ex) {          return Result.fail(BusinessCode.参数校验失败, ex.getMessage());      }  }  

最近整顿了一份最新的面试材料,外面收录了2021年各个大厂的面试题,打算跳槽的小伙伴不要错过,关注公众号后端面试那些事,回复:2022面经,即可获取

进阶应用

分组校验

在理论我的项目中,可能多个办法须要应用同一个DTO类来接管参数,而不同办法的校验规定很可能是不一样的。这个时候,简略地在DTO类的字段上加束缚注解无奈解决这个问题。因而,spring-validation反对了分组校验的性能,专门用来解决这类问题。还是下面的例子,比方保留User的时候,UserId是可空的,然而更新User的时候,UserId的值必须>=10000000000000000L;其它字段的校验规定在两种状况下一样。这个时候应用分组校验的代码示例如下:

  • 束缚注解上申明实用的分组信息groups
@Data  public class UserDTO {        @Min(value = 10000000000000000L, groups = Update.class)      private Long userId;        @NotNull(groups = {Save.class, Update.class})      @Length(min = 2, max = 10, groups = {Save.class, Update.class})      private String userName;        @NotNull(groups = {Save.class, Update.class})      @Length(min = 6, max = 20, groups = {Save.class, Update.class})      private String account;        @NotNull(groups = {Save.class, Update.class})      @Length(min = 6, max = 20, groups = {Save.class, Update.class})      private String password;        public interface Save {      }        public interface Update {      }  }  
  • @Validated注解上指定校验分组
@PostMapping("/save")  public Result saveUser(@RequestBody @Validated(UserDTO.Save.class) UserDTO userDTO) {        return Result.ok();  }    @PostMapping("/update")  public Result updateUser(@RequestBody @Validated(UserDTO.Update.class) UserDTO userDTO) {        return Result.ok();  }  

嵌套校验

后面的示例中,DTO类外面的字段都是根本数据类型String类型。然而理论场景中,有可能某个字段也是一个对象,这种状况先,能够应用嵌套校验

比方,下面保留User信息的时候同时还带有Job信息。须要留神的是,此时DTO类的对应字段必须标记@Valid注解

@Data  public class UserDTO {        @Min(value = 10000000000000000L, groups = Update.class)      private Long userId;        @NotNull(groups = {Save.class, Update.class})      @Length(min = 2, max = 10, groups = {Save.class, Update.class})      private String userName;        @NotNull(groups = {Save.class, Update.class})      @Length(min = 6, max = 20, groups = {Save.class, Update.class})      private String account;        @NotNull(groups = {Save.class, Update.class})      @Length(min = 6, max = 20, groups = {Save.class, Update.class})      private String password;        @NotNull(groups = {Save.class, Update.class})      @Valid      private Job job;        @Data      public static class Job {            @Min(value = 1, groups = Update.class)          private Long jobId;            @NotNull(groups = {Save.class, Update.class})          @Length(min = 2, max = 10, groups = {Save.class, Update.class})          private String jobName;            @NotNull(groups = {Save.class, Update.class})          @Length(min = 2, max = 10, groups = {Save.class, Update.class})          private String position;      }        public interface Save {      }        public interface Update {      }  }  

嵌套校验能够联合分组校验一起应用。还有就是嵌套汇合校验会对汇合外面的每一项都进行校验,例如List<Job>字段会对这个list外面的每一个Job对象都进行校验。

如果您正在学习Spring Boot,那么举荐一个连载多年还在持续更新的收费教程:http://blog.didispace.com/spr...

汇合校验

如果申请体间接传递了json数组给后盾,并心愿对数组中的每一项都进行参数校验。此时,如果咱们间接应用java.util.Collection下的list或者set来接收数据,参数校验并不会失效!咱们能够应用自定义list汇合来接管参数:

  • 包装List类型,并申明@Valid注解
public class ValidationList<E> implements List<E> {        @Delegate      @Valid      public List<E> list = new ArrayList<>();        @Override      public String toString() {          return list.toString();      }  }  

@Delegate注解受lombok版本限度,1.18.6以上版本可反对。如果校验不通过,会抛出NotReadablePropertyException,同样能够应用对立异样进行解决。

比方,咱们须要一次性保留多个User对象,Controller层的办法能够这么写:

@PostMapping("/saveList")  public Result saveList(@RequestBody @Validated(UserDTO.Save.class) ValidationList<UserDTO> userList) {        return Result.ok();  }  

自定义校验

业务需要总是比框架提供的这些简略校验要简单的多,咱们能够自定义校验来满足咱们的需要。自定义spring validation非常简单,假如咱们自定义加密id(由数字或者a-f的字母组成,32-256长度)校验,次要分为两步:

  • 自定义束缚注解
@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<? extends Payload>[] 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) {            if (value != null) {              Matcher matcher = PATTERN.matcher(value);              return matcher.find();          }          return true;      }  }  

这样咱们就能够应用@EncryptId进行参数校验了!

编程式校验

下面的示例都是基于注解来实现主动校验的,在某些状况下,咱们可能心愿以编程形式调用验证。这个时候能够注入javax.validation.Validator对象,而后再调用其api

@Autowired  private javax.validation.Validator globalValidator;    @PostMapping("/saveWithCodingValidate")  public Result saveWithCodingValidate(@RequestBody UserDTO userDTO) {      Set<ConstraintViolation<UserDTO>> validate = globalValidator.validate(userDTO, UserDTO.Save.class);        if (validate.isEmpty()) {        } else {          for (ConstraintViolation<UserDTO> 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();  }  

@Valid@Validated区别

图片

实现原理

requestBody参数校验实现原理

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();            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) {        Annotation[] annotations = parameter.getParameterAnnotations();      for (Annotation ann : annotations) {            Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);            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()实现。

@Override  public void validate(Object target, Errors errors, Object... validationHints) {      if (this.targetValidator != null) {          processConstraintViolations(                this.targetValidator.validate(target, asValidationGroups(validationHints)), errors);      }  }  

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

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

下面提到的将参数一个个平铺到办法参数中,而后在每个参数后面申明束缚注解的校验形式,就是办法级别的参数校验。实际上,这种形式可用于任何Spring Bean的办法上,比方Controller/Service等。其底层实现原理就是AOP,具体来说是通过MethodValidationPostProcessor动静注册AOP切面,而后应用MethodValidationInterceptor对切点办法织入加强

public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessorimplements InitializingBean {      @Override      public void afterPropertiesSet() {            Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);            this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));      }        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<Object>> result;          try {                result = execVal.validateParameters(                  invocation.getThis(), methodToValidate, invocation.getArguments(), groups);          }          catch (IllegalArgumentException ex) {              ...          }            if (!result.isEmpty()) {              throw new ConstraintViolationException(result);          }            Object returnValue = invocation.proceed();            result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);            if (!result.isEmpty()) {              throw new ConstraintViolationException(result);          }          return returnValue;      }  }  

实际上,不论是requestBody参数校验还是办法级别的校验,最终都是调用Hibernate Validator执行校验,Spring Validation只是做了一层封装

我的项目源码:

关注公众号后端面试那些事,回复:源码,即可获取源码地址

起源:https://juejin.cn/post/685654...