--- title: Spring 校验 date: 2022-12-22 17:42:28 order: 21 categories: - Java - 框架 - Spring - Spring核心 tags: - Java - 框架 - Spring permalink: /pages/b827ab36/ --- # Spring 校验 Java API 规范(`JSR303`)定义了`Bean`校验的标准`validation-api`,但没有提供实现。`hibernate validation`是对这个规范的实现,并增加了校验注解如`@Email`、`@Length`等。`Spring Validation`是对`hibernate validation`的二次封装,用于支持`spring mvc`参数自动校验。 ## 快速入门 ### 引入依赖 如果 spring-boot 版本小于 2.3.x,spring-boot-starter-web 会自动传入 hibernate-validator 依赖。如果 spring-boot 版本大于 2.3.x,则需要手动引入依赖: ```xml org.hibernate.validator hibernate-validator-parent 6.2.5.Final ``` 对于 web 服务来说,为防止非法参数对业务造成影响,在 Controller 层一定要做参数校验的!大部分情况下,请求参数分为如下两种形式: - POST、PUT 请求,使用 requestBody 传递参数; - GET 请求,使用 requestParam/PathVariable 传递参数。 实际上,不管是 requestBody 参数校验还是方法级别的校验,最终都是调用 Hibernate Validator 执行校验,Spring Validation 只是做了一层封装。 ### 校验示例 (1)在实体上标记校验注解 ```kotlin @Data @NoArgsConstructor @AllArgsConstructor public class User implements Serializable { @NotNull private Long id; @NotBlank @Size(min = 2, max = 10) private String name; @Min(value = 1) @Max(value = 100) private Integer age; } ``` (2)在方法参数上声明校验注解 ```less @Slf4j @Validated @RestController @RequestMapping("validate1") public class ValidatorController { /** * {@link RequestBody} 参数校验 */ @PostMapping(value = "save") public DataResult save(@Valid @RequestBody User entity) { log.info("保存一条记录:{}", JSONUtil.toJsonStr(entity)); return DataResult.ok(true); } /** * {@link RequestParam} 参数校验 */ @GetMapping(value = "queryByName") public DataResult queryByName( @RequestParam("username") @NotBlank @Size(min = 2, max = 10) String name ) { User user = new User(1L, name, 18); return DataResult.ok(user); } /** * {@link PathVariable} 参数校验 */ @GetMapping(value = "detail/{id}") public DataResult detail(@PathVariable("id") @Min(1L) Long id) { User user = new User(id, "李四", 18); return DataResult.ok(user); } } ``` (3)如果请求参数不满足校验规则,则会抛出 `ConstraintViolationException` 或 `MethodArgumentNotValidException` 异常。 ### 统一异常处理 在实际项目开发中,通常会用统一异常处理来返回一个更友好的提示。 ```java @Slf4j @ControllerAdvice public class GlobalExceptionHandler { /** * 处理所有不可知的异常 */ @ResponseBody @ResponseStatus(HttpStatus.OK) @ExceptionHandler(Throwable.class) public Result handleException(Throwable e) { log.error("未知异常", e); return new Result(ResultStatus.HTTP_SERVER_ERROR.getCode(), e.getMessage()); } /** * 统一处理请求参数校验异常(普通传参) * * @param e ConstraintViolationException * @return {@link DataResult} */ @ResponseBody @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler({ ConstraintViolationException.class }) public Result handleConstraintViolationException(final ConstraintViolationException e) { log.error("ConstraintViolationException", e); List errors = new ArrayList<>(); for (ConstraintViolation violation : e.getConstraintViolations()) { Path path = violation.getPropertyPath(); List pathArr = StrUtil.split(path.toString(), ','); errors.add(pathArr.get(0) + " " + violation.getMessage()); } return new Result(ResultStatus.REQUEST_ERROR.getCode(), CollectionUtil.join(errors, ",")); } /** * 处理参数校验异常 * * @param e MethodArgumentNotValidException * @return {@link DataResult} */ @ResponseBody @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler({ MethodArgumentNotValidException.class }) private Result handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) { log.error("MethodArgumentNotValidException", e); List errors = new ArrayList<>(); for (ObjectError error : e.getBindingResult().getAllErrors()) { errors.add(((FieldError) error).getField() + " " + error.getDefaultMessage()); } return new Result(ResultStatus.REQUEST_ERROR.getCode(), CollectionUtil.join(errors, ",")); } } ``` ## 进阶使用 ### 分组校验 在实际项目中,可能多个方法需要使用同一个 DTO 类来接收参数,而不同方法的校验规则很可能是不一样的。这个时候,简单地在 DTO 类的字段上加约束注解无法解决这个问题。因此,spring-validation 支持了分组校验的功能,专门用来解决这类问题。 (1)定义分组 ```java @Target({ ElementType.FIELD, ElementType.PARAMETER }) @Retention(RetentionPolicy.RUNTIME) public @interface AddCheck { } @Target({ ElementType.FIELD, ElementType.PARAMETER }) @Retention(RetentionPolicy.RUNTIME) public @interface EditCheck { } ``` (2)在实体上标记校验注解 ```less @Data public class User2 { @NotNull(groups = EditCheck.class) private Long id; @NotNull(groups = { AddCheck.class, EditCheck.class }) @Size(min = 2, max = 10, groups = { AddCheck.class, EditCheck.class }) private String name; @IsMobile(message = "不是有效手机号", groups = { AddCheck.class, EditCheck.class }) private String mobile; } ``` (3)在方法上根据不同场景进行校验分组 ```less @Slf4j @Validated @RestController @RequestMapping("validate2") public class ValidatorController2 { /** * {@link RequestBody} 参数校验 */ @PostMapping(value = "add") public DataResult add(@Validated(AddCheck.class) @RequestBody User2 entity) { log.info("添加一条记录:{}", JSONUtil.toJsonStr(entity)); return DataResult.ok(true); } /** * {@link RequestBody} 参数校验 */ @PostMapping(value = "edit") public DataResult edit(@Validated(EditCheck.class) @RequestBody User2 entity) { log.info("编辑一条记录:{}", JSONUtil.toJsonStr(entity)); return DataResult.ok(true); } } ``` ### 嵌套校验 前面的示例中,DTO 类里面的字段都是基本数据类型和 String 类型。但是实际场景中,有可能某个字段也是一个对象,这种情况先,可以使用嵌套校验。 post 比如,上面保存 User 信息的时候同时还带有 Job 信息。需要注意的是,此时 DTO 类的对应字段必须标记@Valid 注解。 ```less @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`字段会对这个 list 里面的每一个 Job 对象都进行校验 ### 自定义校验注解 (1)自定义校验注解 `@IsMobile` ```less @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) @Retention(RUNTIME) @Constraint(validatedBy = MobileValidator.class) public @interface IsMobile { String message(); Class[] groups() default {}; Class[] payload() default {}; } ``` (2)实现 `ConstraintValidator` 接口,编写 `@IsMobile` 校验注解的解析器 ```java import cn.hutool.core.util.StrUtil; import io.github.dunwu.spring.core.validation.annotation.IsMobile; import io.github.dunwu.tool.util.ValidatorUtil; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; public class MobileValidator implements ConstraintValidator { @Override public void initialize(IsMobile isMobile) { } @Override public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { if (StrUtil.isBlank(s)) { return false; } else { return ValidatorUtil.isMobile(s); } } } ``` ### 自定义校验 可以通过实现 `org.springframework.validation.Validator` 接口来自定义校验。 有以下要点 - 实现 `supports` 方法 - 实现 `validate` 方法 - 通过 `Errors` 对象收集错误 - `ObjectError`:对象(Bean)错误: - `FieldError`:对象(Bean)属性(Property)错误 - 通过 `ObjectError` 和 `FieldError` 关联 `MessageSource` 实现获取最终的错误文案 ```less package io.github.dunwu.spring.core.validation; import io.github.dunwu.spring.core.validation.annotation.Valid; import io.github.dunwu.spring.core.validation.config.CustomValidatorConfig; import io.github.dunwu.spring.core.validation.entity.Person; import org.springframework.stereotype.Component; import org.springframework.validation.Errors; import org.springframework.validation.ValidationUtils; import org.springframework.validation.Validator; import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.List; @Component public class CustomValidator implements Validator { private final CustomValidatorConfig validatorConfig; public CustomValidator(CustomValidatorConfig validatorConfig) { this.validatorConfig = validatorConfig; } /** * 本校验器只针对 Person 对象进行校验 */ @Override public boolean supports(Class clazz) { return Person.class.equals(clazz); } @Override public void validate(Object target, Errors errors) { ValidationUtils.rejectIfEmpty(errors, "name", "name.empty"); List fields = getFields(target.getClass()); for (Field field : fields) { Annotation[] annotations = field.getAnnotations(); for (Annotation annotation : annotations) { if (annotation.annotationType().getAnnotation(Valid.class) != null) { try { ValidatorRule validatorRule = validatorConfig.findRule(annotation); if (validatorRule != null) { validatorRule.valid(annotation, target, field, errors); } } catch (Exception e) { e.printStackTrace(); } } } } } private List getFields(Class clazz) { // 声明Field数组 List fields = new ArrayList<>(); // 如果class类型不为空 while (clazz != null) { // 添加属性到属性数组 Collections.addAll(fields, clazz.getDeclaredFields()); clazz = clazz.getSuperclass(); } return fields; } } ``` ### 快速失败(Fail Fast) Spring Validation 默认会校验完所有字段,然后才抛出异常。可以通过一些简单的配置,开启 Fali Fast 模式,一旦校验失败就立即返回。 ```scss @Bean public Validator validator() { ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class) .configure() // 快速失败模式 .failFast(true) .buildValidatorFactory(); return validatorFactory.getValidator(); } ``` ## Spring 校验原理 ### Spring 校验使用场景 - Spring 常规校验(Validator) - Spring 数据绑定(DataBinder) - Spring Web 参数绑定(WebDataBinder) - Spring WebMVC/WebFlux 处理方法参数校验 ### Validator 接口设计 - 接口职责 - Spring 内部校验器接口,通过编程的方式校验目标对象 - 核心方法 - `supports(Class)`:校验目标类能否校验 - `validate(Object,Errors)`:校验目标对象,并将校验失败的内容输出至 Errors 对象 - 配套组件 - 错误收集器:`org.springframework.validation.Errors` - Validator 工具类:`org.springframework.validation.ValidationUtils` ### Errors 接口设计 - 接口职责 - 数据绑定和校验错误收集接口,与 Java Bean 和其属性有强关联性 - 核心方法 - `reject` 方法(重载):收集错误文案 - `rejectValue` 方法(重载):收集对象字段中的错误文案 - 配套组件 - Java Bean 错误描述:`org.springframework.validation.ObjectError` - Java Bean 属性错误描述:`org.springframework.validation.FieldError` ### Errors 文案来源 Errors 文案生成步骤 - 选择 Errors 实现(如:`org.springframework.validation.BeanPropertyBindingResult`) - 调用 reject 或 rejectValue 方法 - 获取 Errors 对象中 ObjectError 或 FieldError - 将 ObjectError 或 FieldError 中的 code 和 args,关联 MessageSource 实现(如:`ResourceBundleMessageSource`) ### spring web 校验原理 #### RequestBody 参数校验实现原理 在 spring-mvc 中,`RequestResponseBodyMethodProcessor` 是用于解析 `@RequestBody` 标注的参数以及处理`@ResponseBody` 标注方法的返回值的。其中,执行参数校验的逻辑肯定就在解析参数的方法 `resolveArgument()` 中: ```java @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)) { // 如果存在校验错误,则抛出 MethodArgumentNotValidException throw new MethodArgumentNotValidException(parameter, binder.getBindingResult()); } } if (mavContainer != null) { mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult()); } } return adaptArgumentIfNecessary(arg, parameter); } ``` 可以看到,resolveArgument()调用了 validateIfApplicable()进行参数校验。 ```java 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; } } } ``` 以上代码,就解释了 Spring 为什么能同时支持 `@Validated`、`@Valid` 两个注解。 接下来,看一下 WebDataBinder.validate() 的实现: ```typescript @Override public void validate(Object target, Errors errors, Object... validationHints) { if (this.targetValidator != null) { processConstraintViolations( // 此处调用 Hibernate Validator 执行真正的校验 this.targetValidator.validate(target, asValidationGroups(validationHints)), errors); } } ``` 通过上面代码,可以看出 Spring 校验实际上是基于 Hibernate Validator 的封装。 #### 方法级别的参数校验实现原理 Spring 支持根据方法去进行拦截、校验,原理就在于应用了 AOP 技术。具体来说,是通过 `MethodValidationPostProcessor` 动态注册 AOP 切面,然后使用 `MethodValidationInterceptor` 对切点方法织入增强。 ```java 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`: ```scss 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> 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; } } ``` 实际上,不管是 requestBody 参数校验还是方法级别的校验,最终都是调用 Hibernate Validator 执行校验,Spring Validation 只是做了一层封装。 ## 问题 **Spring 有哪些校验核心组件**? - 检验器:`org.springframework.validation.Validator` - 错误收集器:`org.springframework.validation.Errors` - Java Bean 错误描述:`org.springframework.validation.ObjectError` - Java Bean 属性错误描述:`org.springframework.validation.FieldError` - Bean Validation 适配:`org.springframework.validation.beanvalidation.LocalValidatorFactoryBean` ## 参考资料 - [Spring 官方文档之 Core Technologies](https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/core.html#beans) - [《小马哥讲 Spring 核心编程思想》](https://time.geekbang.org/course/intro/265) - https://juejin.cn/post/6856541106626363399