背景
在参数校验框架出现前,业务逻辑代码中经常会充斥着参数校验的代码,将参数校验视为业务逻辑的一部分不失为是一种较为灵活的做法,然而对于一些通用的校验,如字符串不能为空、数值范围限制等,如果还放在业务逻辑中,则会导致业务代码出现大量的重复,为了避免这种问题,Java 社区提出了 JSR-303 规范,用于对 bean 进行校验。
Spring 框架横空出世后,它又提出了一套更为简单易用的校验接口,校验作为 Spring 的核心特性之一,能够和 Spring 其他组件有机地整合到一起。
应用场景
Spring 内部主要将校验应用于 WEB 环境下参数绑定后的校验,并预留给用户一些接口用于常规校验。
常规参数校验
Spring 参数校验作为 spring-context 模块的一部分存在,Validator 是 Spring 参数校验的核心接口,我们先看下这个接口。
public interface Validator { // 当前验证器是否支持给定的类型 boolean supports(Class<?> clazz); // 校验给定的 target,提供的 errors 对象用于存储和获取错误信息 void validate(Object target, Errors errors); }
接口中只存在两个方法,supports
方法用于校验前确认是否支持给定的类型,validate
方法用于参数校验,比较奇怪的是校验结果使用参数中的 Errors 存储及获取。Validator 的类图如下。
Spring 未提供单独的 Validator 实现,而是将 JSR-303 中的 Validator 与 Spring Validator 整合到一起,SpringValidatorAdpter 作为 Spring Validator 与 JSR-303 Validator 的适配器,底层使用 JSR-303 作为实现,除此之外 使用最多的是 LocalValidatorFactoryBean,这个类可以配置创建 JSR-303 Validator 的参数。关于 JSR-303 java bean validation,你还可以参考《Java Bean Validation 详解》这篇文章,内容相对比较详实。
Errors 同样是一个相对重要的概念,其类图如下。
比较常用的 Errors 实现是 BeanPropertyBindingResult,这个类不仅表示用于表示校验结果,还表示数据绑定到对象的属性上的结果。
有了上述 Spring 校验相关的基础知识后,我们就可以在应用中使用 Spring Validator。首先需要引入 JSR-303 规范的实现,这里我们引入的是 hibernate-validator。
<dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-validator</artifactId> <version>6.0.22.Final</version> </dependency> <dependency> <groupId>org.glassfish</groupId> <artifactId>javax.el</artifactId> <version>3.0.1-b09</version> </dependency>
示例代码如下。
@Data public class LoginDTO { @NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "密码不能为空") private String password; } public class App { public static void main(String[] args) { AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(App.class); Validator validator = context.getBean(LocalValidatorFactoryBean.class); LoginDTO loginDTO = new LoginDTO(); Errors errors = new BeanPropertyBindingResult(loginDTO, "login"); validator.validate(loginDTO, errors); errors.getAllErrors().forEach(System.out::println); context.close(); } @Bean public LocalValidatorFactoryBean localValidatorFactoryBean() { return new LocalValidatorFactoryBean(); } }
打印结果如下。
Field error in object 'login' on field 'password': rejected value [null]; codes [NotBlank.login.password,NotBlank.password,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [login.password,password]; arguments []; default message [password]]; default message [密码不能为空] Field error in object 'login' on field 'username': rejected value [null]; codes [NotBlank.login.username,NotBlank.username,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [login.username,username]; arguments []; default message [username]]; default message [用户名不能为空]
可以看到,成功将校验的结果存到了 Errors 中,如果你使用的是 spring-boot,还可以直接引入 spring-boot-starter-validation,Spring 会自动引入相关依赖并注册 LocalValidatorFactoryBean 作为 bean,此时可以直接把 Validator 注入 bean 中。
web 环境参数校验
web 环境下,Spring 会从 request 中获取相关数据,然后绑定到 conroller 方法的参数上。有关校验的参数,具体可以分为三类。
简单类型的参数
Spring 从 request 中根据参数名获取数据然后设置到参数中,支持的注解包括 @CookieValue、@MatrixVariable、@PathVariable、@RequestAttribute、@RequestHeader、@RequestParam、@SessionAttribute。
对于这类参数,可以设置注解的 required 参数来配置参数是否必须(默认必须),如果参数必须且未传参数则会抛出 ServletRequestBindingException 异常。示例代码如下。
@RestController @RequestMapping("/user") public class UserController { @PostMapping("/login") Result<LoginBO> login(@RequestParam(required = true) String username, @RequestParam(required = true) String password) { return null; } }
那如果想对方法参数进行其他校验怎么办呢?Spring 内部还提供了一个 MethodValidationPostProcessor,这个处理器会利用 AOP 特性拦截标注了 @Validated 注解的 bean 的方法的执行,在目标方法执行前后执行校验。因此,将 MethodValidationPostProcessor 注册为 bean,然后在目标方法的参数上添加校验注解即可。
spring-boot 环境下可直接引入 spring-boot-starter-validation ,这个依赖同样会自动注册 MethodValidationPostProcessor 作为 bean。需要注意的是如果校验不通过抛出的是 JSR-303 规范中定义的 ConstraintViolationException 异常。
示例代码如下。
@Validated @RestController @RequestMapping("/user") public class UserController { @PostMapping("/login") Result<LoginBO> login(@NotBlank String username, @NotBlank String password) { return null; } }
非简单类型的表单参数
对于 get 请求或 x-www-form-urlencoded 类型的 post 请求,可以把单个简单类型的参数存入一个普通的 Java 对象中,将这个 Java 对象作为 controller 方法的参数,Spring 会自动从请求中获取参数信息然后实例化这个类并作为参数值传入方法中。
对于这类参数,在类的字段上添加校验相关注解,然后在 controller 方法参数上添加 @Validated 或 @Valid 或以 @Valid 开头命名的注解才会开启注解功能,此时如果校验不通过将会抛出 BindException 异常。校验的示例代码如下。
@Data public class LoginDTO { @NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "密码不能为空") private String password; } @RestController @RequestMapping("/user") public class UserController { @PostMapping("/login") Result<LoginBO> login(@Validated LoginDTO dto) { return null; } }
如果不想抛出异常,可以将要校验的方法参数的后一个参数设置为 Errors 类型或其子类型,由这个 Errors 接受参数校验结果,由于方法参数和数据绑定有关,通常我们可以设置为 BindingResult。示例代码如下。
@RestController @RequestMapping("/user") public class UserController { @PostMapping("/login") Result<LoginBO> login(@Validated LoginDTO dto, BindingResult bindingResult) { if (bindingResult.hasErrors()) { List<String> errorMessageList = bindingResult.getAllErrors().stream() .map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.toList()); } return null; } }
非简单类型的其他请求体参数
除了表单类型,目前我们使用最多的请求内容类型是 application/json,校验方式和表单类型一样,在参数前添加 @Validated 开启校验,后一个参数使用 Errors 类型可以接收校验结果。与表单类型校验不用的是,如果后一个参数不是 Errors 类型,抛出的异常是 MethodArgumentNotValidException。示例代码如下。
@RestController @RequestMapping("/user") public class UserController { @PostMapping("/login") Result<LoginBO> login(@Validated @RequestBody LoginDTO dto) { return null; } }
参数校验全局异常处理
手动使用 Validator 进行参数校验,或者在处理器方法参数中配置 Errors 类型的参数都可以直接取到校验结果,然而这种写法仍然和业务代码耦合在一起,虽然比较灵活,但是大多数情况如果校验不通过我们直接返回失败原因即可,因此对于这种通用的情况,我们可以定义一个全局的异常处理器,捕获 Spring 校验抛出的异常,并将异常转换为错误消息返回到前端。
@RestControllerAdvice public class GlobalExceptionHandler { // 处理标注了 @Validated 的类的方法调用参数校验失败导致的异常 @ExceptionHandler(ConstraintViolationException.class) public Result<?> handleConstraintViolationException(ConstraintViolationException e) { String message = e.getConstraintViolations().stream().map(ConstraintViolation::getMessage).collect(Collectors.joining("|")); return Result.fail(message); } // 处理表单类型请求普通参数缺失导致的异常 @ExceptionHandler(ServletRequestBindingException.class) public Result<?> handleServletRequestBindingException(ServletRequestBindingException e) { String message = e.getMessage(); return Result.fail(message); } // 处理表单类型请求的复杂参数校验失败导致的异常 @ExceptionHandler(BindException.class) public Result<?> handleBindException(BindException e) { String message = e.getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining("|")); return Result.fail(message); } // 处理 application/json 类型请求的参数校验失败导致的异常 @ExceptionHandler(MethodArgumentNotValidException.class) public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { String message = e.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining("|")); return Result.fail(message); } }
Spring Web 环境 Controller 方法参数校验原理解析
Spring 允许 Controller 处理器方法上定义请求相关的不同参数,Spring 在调用处理器方法时需要收集参数值,为此,Spring 定义了一个 HandlerMethodArgumentResolver 接口用于根据参数元数据解析出参数值,解析参数值时同时也会对参数值进行校验,这个接口最终会被 DispatchServlet 间接调用。
先看 HandlerMethodArgumentResolver 的接口定义。
public interface HandlerMethodArgumentResolver { // 是否支持参数 boolean supportsParameter(MethodParameter parameter); // 解析参数值 Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception; }
接口比较简单,只有两个方法,如果支持给定的方法参数,则会解析参数值,像常见的 @RequestParam、@RequestBody 就是由不同的实现处理的。以处理 @RequestBody 注解参数的 RequestResponseBodyMethodProcessor 为例,其参数校验的核心代码如下。
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); } } public abstract class AbstractMessageConverterMethodArgumentResolver implements HandlerMethodArgumentResolver { // 参数校验 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; } } } }
从这里可以看到 Spring 先解析参数值,解析后进行参数校验,并且还可以看出 Spring 会把 @Validated 的 value 参数值作为校验分组,而 @Valid 注解则无法指定分组,这也是 @Validated 和 @Valid 在 Spring 中的不同之处。
总结
本篇主要介绍了 Spring 使用 Validator 的各种方式,包括手动调用 Validator 接口方法校验、通过 MethodValidationPostProcessor 拦截目标对象方法执行校验、以及 web 环境下 Spring 内部自动进行的数据绑定和参数校验。数据绑定除了应用在 web 环境的处理器方法参数,还会应用到 Spring 内部的其他地方,是 Spring 的核心特性之一,下篇进行分析。