参数校验
Java API 的规范 JSR303 定义了校验的标准 validation-api ,其中一个比较出名的实现是 hibernate validation。
spring validation 是对其的二次封装,常用于 SpringMVC 的参数自动校验,参数校验的代码就不需要再与业务逻辑代码进行耦合了。
①@PathVariable 和 @RequestParam 参数校验
Get 请求的参数接收一般依赖这两个注解,但是处于 url 有长度限制和代码的可维护性,超过 5 个参数尽量用实体来传参。
对 @PathVariable 和 @RequestParam 参数进行校验需要在入参声明约束的注解。
如果校验失败,会抛出 MethodArgumentNotValidException 异常。
@RestController(value = "prettyTestController") @RequestMapping("/pretty") public class TestController { private TestService testService; @GetMapping("/{num}") public Integer detail(@PathVariable("num") @Min(1) @Max(20) Integer num) { return num * num; } @GetMapping("/getByEmail") public TestDTO getByAccount(@RequestParam @NotBlank @Email String email) { TestDTO testDTO = new TestDTO(); testDTO.setEmail(email); return testDTO; } @Autowired public void setTestService(TestService prettyTestService) { this.testService = prettyTestService; } }
校验原理
在 SpringMVC 中,有一个类是 RequestResponseBodyMethodProcessor,这个类有两个作用(实际上可以从名字上得到一点启发)
用于解析 @RequestBody 标注的参数
处理 @ResponseBody 标注方法的返回值
解析 @RequestBoyd 标注参数的方法是 resolveArgument。
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor { /** * Throws MethodArgumentNotValidException if validation fails. * @throws HttpMessageNotReadableException if {@link RequestBody#required()} * is {@code true} and there is no body content or if there is no suitable * converter to read the content with. */ @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); //如果校验不通过,就抛出MethodArgumentNotValidException异常 //如果我们不自己捕获,那么最终会由DefaultHandlerExceptionResolver捕获处理 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); } } public abstract class AbstractMessageConverterMethodArgumentResolver implements HandlerMethodArgumentResolver { /** * Validate the binding target if applicable. * <p>The default implementation checks for {@code @javax.validation.Valid}, * Spring's {@link org.springframework.validation.annotation.Validated}, * and custom annotations whose name starts with "Valid". * @param binder the DataBinder to be used * @param parameter the method parameter descriptor * @since 4.1.5 * @see #isBindExceptionRequired */ protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) { //获取参数上的所有注解 Annotation[] annotations = parameter.getParameterAnnotations(); for (Annotation ann : annotations) { //如果注解中包含了@Valid、@Validated或者是名字以Valid开头的注解就进行参数校验 Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann); if (validationHints != null) { //实际校验逻辑,最终会调用Hibernate Validator执行真正的校验 //所以Spring Validation是对Hibernate Validation的二次封装 binder.validate(validationHints); break; } } } }
②@RequestBody 参数校验
Post、Put 请求的参数推荐使用 @RequestBody 请求体参数。
对 @RequestBody 参数进行校验需要在 DTO 对象中加入校验条件后,再搭配 @Validated 即可完成自动校验。
如果校验失败,会抛出 ConstraintViolationException 异常。
//DTO @Data public class TestDTO { @NotBlank private String userName; @NotBlank @Length(min = 6, max = 20) private String password; @NotNull @Email private String email; } //Controller @RestController(value = "prettyTestController") @RequestMapping("/pretty") public class TestController { private TestService testService; @PostMapping("/test-validation") public void testValidation(@RequestBody @Validated TestDTO testDTO) { this.testService.save(testDTO); } @Autowired public void setTestService(TestService testService) { this.testService = testService; } }
校验原理
声明约束的方式,注解加到了参数上面,可以比较容易猜测到是使用了 AOP 对方法进行增强。
而实际上 Spring 也是通过 MethodValidationPostProcessor 动态注册 AOP 切面,然后使用 MethodValidationInterceptor 对切点方法进行织入增强。
public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean { //指定了创建切面的Bean的注解 private Class<? extends Annotation> validatedAnnotationType = Validated.class; @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()); } } 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 { //方法入参校验,最终还是委托给Hibernate Validator来校验 //所以Spring Validation是对Hibernate Validation的二次封装 result = execVal.validateParameters( invocation.getThis(), methodToValidate, invocation.getArguments(), groups); } catch (IllegalArgumentException ex) { ... } //校验不通过抛出ConstraintViolationException异常 if (!result.isEmpty()) { throw new ConstraintViolationException(result); } //Controller方法调用 Object returnValue = invocation.proceed(); //下面是对返回值做校验,流程和上面大概一样 result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups); if (!result.isEmpty()) { throw new ConstraintViolationException(result); } return returnValue; } }
自定义异常与统一拦截异常
原来的代码中可以看到有几个问题:
- 抛出的异常不够具体,只是简单地把错误信息放到了 Exception 中
- 抛出异常后,Controller 不能具体地根据异常做出反馈
- 虽然做了参数自动校验,但是异常返回结构和正常返回结构不一致
自定义异常是为了后面统一拦截异常时,对业务中的异常有更加细颗粒度的区分,拦截时针对不同的异常作出不同的响应。
而统一拦截异常的目的一个是为了可以与前面定义下来的统一包装返回结构能对应上,另一个是我们希望无论系统发生什么异常,Http 的状态码都要是 200 ,尽可能由业务来区分系统的异常。
//自定义异常 public class ForbiddenException extends RuntimeException { public ForbiddenException(String message) { super(message); } } //自定义异常 public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); } } //统一拦截异常 @RestControllerAdvice(basePackages = "com.example.demo") public class ExceptionAdvice { /** * 捕获 {@code BusinessException} 异常 */ @ExceptionHandler({BusinessException.class}) public Result<?> handleBusinessException(BusinessException ex) { return Result.failed(ex.getMessage()); } /** * 捕获 {@code ForbiddenException} 异常 */ @ExceptionHandler({ForbiddenException.class}) public Result<?> handleForbiddenException(ForbiddenException ex) { return Result.failed(ResultEnum.FORBIDDEN); } /** * {@code @RequestBody} 参数校验不通过时抛出的异常处理 */ @ExceptionHandler({MethodArgumentNotValidException.class}) 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(); if (StringUtils.hasText(msg)) { return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), msg); } return Result.failed(ResultEnum.VALIDATE_FAILED); } /** * {@code @PathVariable} 和 {@code @RequestParam} 参数校验不通过时抛出的异常处理 */ @ExceptionHandler({ConstraintViolationException.class}) public Result<?> handleConstraintViolationException(ConstraintViolationException ex) { if (StringUtils.hasText(ex.getMessage())) { return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), ex.getMessage()); } return Result.failed(ResultEnum.VALIDATE_FAILED); } /** * 顶级异常捕获并统一处理,当其他异常无法处理时候选择使用 */ @ExceptionHandler({Exception.class}) public Result<?> handle(Exception ex) { return Result.failed(ex.getMessage()); } }
总结
做好了这一切改动后,可以发现 Controller 的代码变得非常简洁,可以很清楚地知道每一个参数、每一个 DTO 的校验规则,可以很明确地看到每一个 Controller 方法返回的是什么数据,也可以方便每一个异常应该如何进行反馈。
这一套操作下来后,我们能更加专注于业务逻辑的开发,代码简介、功能完善,何乐而不为呢?