https://www.jianshu.com/p/2432d0f51c0e
前言
本文Spring版本为 SpringBoot-2.0.7,所有源码相关类、方法、代码行都以此版本为基础。 代码行数: 使用 IDEA 的同学通过
Maven Projects -> Donwload Sources and Documentation下载源码及注释文档,保证行数的准确。 非常欢迎您指正在文章中出现的错误,包括但不限于 语句错误、描述错误、示例错误、代码理解错误。
参数校验是代码开发中必不可少的一环,一个方法中参数校验套了一个又一个 if-else,繁琐的操作让广大程序员诟病。
本文我们就讲一下 SpringBoot 结合 Hibernate-Validtor 校验参数、简化工作。
开始
spring-boot-starter-web 已经默认整合、提供了 Hibernate-Validator 的功能,只待我们去使用。
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><version>2.0.7.RELEASE</version></dependency>
创建一个实体类,并添加校验注解。
本小节只做简单的使用演示。 常用注解列表、注解说明、注解用法,以及·自定义校验注解·的教程。JSR 303 - Bean Validation 介绍及最佳实践
public class Student{@NotNullprivate String name;@NotNullprivate String sex;@Min(0)@Max(150)private int age;...get,set...}
接着编写 Controller 代码。
// @RestController// DemoController@GetMapping("/student")public String validator(@Validated Student student, BindingResult result) {if (result.hasErrors()) {return result.getFieldError().getDefaultMessage();}return "ok";}
启动程序后访问http://{host:prot}/student,将会返回:
must not be null
访问http://{host:prot}/student?name=zhangsan&sex=Male&age=22,将会返回:
ok
进阶
上面的教程还太简单,很多事情都很朦胧。
- 书写有没有什么规则?
- 我怎么知道‘must not be null’是指哪个参数?跟没提示一样。
- 每个 Controller 方法都要判断 BindingResult 还是好麻烦!我懒得写!
- 校验规则太少了,能不能自己写规则?
- 我想手动校验怎么办?
书写规则
@Validated 和 @Valid 的异同
@Validated 是 Spring 实现的JSR-303的变体 @Valid ,支持验证组的规范。 设计用于方便使用Spring的JSR-303支持,但不支持JSR-303特定。
@Valid JSR-303标准实现的校验注解。
| 注解 | 范围 | 嵌套 | 校验组 |
|---|---|---|---|
| @Validated | 可以标记类、方法、方法参数,不能用在成员属性(字段)上 | 不支持 | 支持 |
| @Valid | 可以标记方法、构造函数、方法参数和成员属性(字段)上 | 支持 | 不支持 |
两者都可以用在方法入参上,但都无法单独提供嵌套验证功能,都能配合嵌套验证注解@Valid进行嵌套验证。
嵌套验证示例:
public class ClassRoom{@NotNullString name;@Valid // 嵌套校验,校验参数内部的属性@NotNullStudent student;}
@GetMapping("/room") // 此处可使用 @Valid 或 @Validated, 将会进行嵌套校验public String validator(@Validated ClassRoom classRoom, BindingResult result) {if (result.hasErrors()) {return result.getFieldError().getDefaultMessage();}return "ok";}
BindingResult 的使用
BindingResult必须跟在被校验参数之后,若被校验参数之后没有BindingResult对象,将会抛出BindException。
@GetMapping("/room")public String validator(@Validated ClassRoom classRoom, BindingResult result) {if (result.hasErrors()) {return result.getFieldError().getDefaultMessage();}return "ok";}
不要使用 BindingResult 接收,String等简单对象的错误信息。简单对象校验失败,会抛出 ConstraintViolationException。
主要就是接不着,你要写也算是没关系…
// ❌ 错误用法,也没有特别的错,只是 result 是接不到值。@GetMapping("/room")@Validated // 启用校验public String validator(@NotNull String name, BindingResult result) {if (result.hasErrors()) {return result.getFieldError().getDefaultMessage();}return "ok";}
修改校验失败的提示信息
可以通过各个校验注解的message属性设置更友好的提示信息。
public class ClassRoom{@NotNull(message = "Classroom name must not be null")String name;@Valid@NotNullStudent student;}
@GetMapping("/room")@Validatedpublic String validator(ClassRoom classRoom, BindingResult result, @NotNull(message = "姓名不能为空") String name) {if (result.hasErrors()) {return result.getFieldError().getDefaultMessage();}return "ok";}
message属性配置国际化的消息也可以的,message中填写国际化消息的code,在抛出异常时根据code处理一下就好了。
@GetMapping("/room")@Validatedpublic String validator(@NotNull(message = "demo.message.notnull") String name) {if (result.hasErrors()) {return result.getFieldError().getDefaultMessage();}return "ok";}
// message_zh_CN.propertiesdemo.message.notnull=xxx消息不能为空// message_en_US.propertiesdemo.message.notnull=xxx message must no be null
省略 Controller 中的校验判断
可以利用参数校验失败后抛出异常这点,配置·统一异常拦截·,进行异常统一的处理,合理的将错误信息返回给前端。
抛砖(仅做示例):
// @RestControllerAdvice/* 数据校验处理 */@ExceptionHandler({BindException.class, ConstraintViolationException.class})public String validatorExceptionHandler(Exception e) {String msg = e instanceof BindException ? msgConvertor(((BindException) e).getBindingResult()): msgConvertor(((ConstraintViolationException) e).getConstraintViolations());return msg;}/*** 校验消息转换拼接** @param bindingResult* @return*/public static String msgConvertor(BindingResult bindingResult) {List<FieldError> fieldErrors = bindingResult.getFieldErrors();StringBuilder sb = new StringBuilder();fieldErrors.forEach(fieldError -> sb.append(fieldError.getDefaultMessage()).append(","));return sb.deleteCharAt(sb.length() - 1).toString().toLowerCase();}private String msgConvertor(Set<ConstraintViolation<?>> constraintViolations) {StringBuilder sb = new StringBuilder();constraintViolations.forEach(violation -> sb.append(violation.getMessage()).append(","));return sb.deleteCharAt(sb.length() - 1).toString().toLowerCase();}
注:getMessage和getDefaultMessage 都是直接获取注解上message属性的值,
扩展校验注解、校验规则
常用注解列表、注解说明、注解用法,以及·自定义校验注解·的教程。JSR 303 - Bean Validation 介绍及最佳实践
手动校验
若没有手动配置Validator对象,自然需要从 Spring 容器中获取校验器对象,注入使用。
此处给出一个手动校验的工具类,供大家参考。(lay了…写的自闭,如果对代码有疑问请联系我..持续更新)
代码中提到的与 Spring 集成,主要是对代码返回值的统一。(不支持普通对象…)
若都以注解的message属性来获取提示消息,可以删除 Spring 相关的代码。
若不以message属性作为消息,那么可以从bindingResult中获取字段、类、注解信息,拼装成消息码。
抛砖:
// config// @Configuration@Beanpublic Validator validator() {return ValidatorUtils.getValidator();}
import org.hibernate.validator.HibernateValidator;import org.springframework.util.ClassUtils;import org.springframework.validation.BindException;import org.springframework.validation.DataBinder;import org.springframework.validation.SmartValidator;import org.springframework.validation.beanvalidation.SpringValidatorAdapter;import javax.validation.ConstraintViolation;import javax.validation.ConstraintViolationException;import javax.validation.Validation;import javax.validation.Validator;import java.util.Set;/*** hibernate-validator校验工具类*/public class ValidatorUtils {private static Validator validator;private static SmartValidator validatorAdapter;static {// 快速返回模式validator = Validation.byProvider(HibernateValidator.class).configure().failFast(true).buildValidatorFactory().getValidator();}public static Validator getValidator() {return validator;}private static SmartValidator getValidatorAdapter(Validator validator) {if (validatorAdapter == null) {validatorAdapter = new SpringValidatorAdapter(validator);}return validatorAdapter;}/*** 校验参数,用于普通参数校验 [未测试!]** @param*/public static void validateParams(Object... params) {Set<ConstraintViolation<Object>> constraintViolationSet = validator.validate(params);if (!constraintViolationSet.isEmpty()) {throw new ConstraintViolationException(constraintViolationSet);}}/*** 校验对象** @param object* @param groups* @param <T>*/public static <T> void validate(T object, Class<?>... groups) {Set<ConstraintViolation<T>> constraintViolationSet = validator.validate(object, groups);if (!constraintViolationSet.isEmpty()) {throw new ConstraintViolationException(constraintViolationSet);}}/*** 校验对象* 使用与 Spring 集成的校验方式。** @param object 待校验对象* @param groups 待校验的组* @throws BindException*/public static <T> void validateBySpring(T object, Class<?>... groups)throws BindException {DataBinder dataBinder = getBinder(object);dataBinder.validate((Object[]) groups);if (dataBinder.getBindingResult().hasErrors()) {throw new BindException(dataBinder.getBindingResult());}}private static <T> DataBinder getBinder(T object) {DataBinder dataBinder = new DataBinder(object, ClassUtils.getShortName(object.getClass()));dataBinder.setValidator(getValidatorAdapter(validator));return dataBinder;}}
源码经验宝宝[拓展]
为什么 BindingResult 接收不到简单对象的校验信息?
跟进 Spring MVC 源码,发现:SpringMVC 在进行方法参数的注入(将 Http请求参数封装成方法所需的参数)时,不同的对象使用不同的解析器注入对象。
听着好像没什么关系。但其实就是,注入实体对象时使用ModelAttributeMethodProcessor而注入 String 对象使用AbstractNamedValueMethodArgumentResolver。而正是这个差异导致了BindingResult无法接受到简单对象(简单的入参参数类型)的校验信息。
啊?你问我什么是简单对象?emm…
不同的解析器支持不同类型的对象(不同的参数类型),需要看各解析器实现的supportsParameter()方法,标题提到的简单对象,意思是ModelAttributeMethodProcessor不支持的所有对象。
获取参数注入解析器的源码位于HandlerMethodArgumentResolverComposite#resolveArgument():120:
// HandlerMethodArgumentResolverComposite.classpublic Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {// 获取 parameter 参数的解析器HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);// 调用解析器获取参数return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);}// 获取 parameter 参数的解析器private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {// 从缓存中获取参数对应的解析器HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) {// 解析器是否支持该参数类型if (methodArgumentResolver.supportsParameter(parameter)) {result = methodArgumentResolver;this.argumentResolverCache.put(parameter, result);break;}}return result;}
注入 String 参数时,在AbstractNamedValueMethodArgumentResolver#resolveArgument()中,不会抛出BindException/ConstraintViolationException异常、也不会将 BindingResult 传入到方法中。
注入对象时在ModelAttributeMethodProcessor#resolveArgument():154 行的 validateIfApplicable(binder, parameter)语句,进行了参数校验,校验不通过并且实体对象后不存在BindingResult对象,则会在this#resolveArgument():156抛出BindException。
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {// bean 参数绑定和校验WebDataBinder binder = binderFactory.createBinder(webRequest, attribute, name);// 参数校验validateIfApplicable(binder, parameter);// 校验结果包含错误,并且该对象后不存在 BindingResult 对象,就抛出异常if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {throw new BindException(binder.getBindingResult());}// 在对象后注入 BindingResult 对象Map<String, Object> bindingResultModel = bindingResult.getModel();mavContainer.removeAttributes(bindingResultModel);mavContainer.addAllAttributes(bindingResultModel);}
在哪里抛出ConstraintViolationException?
可能有同学发现了,简单对象注入后并没有抛出异常,那这个参数在哪里被校验呢?
但是在 InvocableHandlerMethod#invokeForRequest():136 的 doInoke(args) 方法里,调用了 Mehtod.invoke() 调用真实的方法。到这里好像完了?!还是没有校验?那咋办,玄学啊!
当然,肯定不是玄学。这个方法的确是直接被调用了,但调用过程被拦截了。被方法级的拦截器拦住了。
拦截对象是 CglibAopProxy$CglibMethodInvocation 它还继承了ReflectiveMethodInvocation 这又是个啥呢,Spring 说它是个实现了 Spring AOP 方法调用接口的基类,可以拓展方法拦截的更高级的功能实现。
在ReflectiveMethodInvocation#process()方法的最后一行:
return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this);
这里的 Methodnterceptor 接口的真身是 MethodValidationInterceptor:
// MethodValidationInterceptor.classpublic Object invoke(MethodInvocation invocation) throws Throwable {ExecutableValidator execVal = this.validator.forExecutables();// 校验参数try {result = execVal.validateParameters(invocation.getThis(), methodToValidate, invocation.getArguments(), groups);}catch (IllegalArgumentException ex) {// 解决参数错误异常、再次校验methodToValidate = BridgeMethodResolver.findBridgedMethod(ClassUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()));result = execVal.validateParameters(invocation.getThis(), methodToValidate, invocation.getArguments(), groups);}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;}
over.
本文到此结束。
