在项目中,接收到前端或者其它客户端的调用请求时,需要对传入的参数进行校验。完成这些校验最原始的做法就是编写代码一个个参数进行判断,如判断是否为空、长度是否符合要求、格式是否符合要求等;对于一些简单的输入还好,越复杂的输入,这些校验的代码及逻辑越长,而且在校验失败后组装的返回消息也是因人而异,导致同一项目里面校验失败后返回的消息不统一,最终结果就是用户体验较差。
JSR-303为这类校验提供了一个规范,并在JDK1.6起即提供了Validation包,其中包含了关键的ValidatorFactory及Validator、常用校验注解等内容。Hibernate又在其基础上提供了一个实现。注意这个实现与Hibernate数据库读写中间件无任何关联。
一般项目中都是直接使用Hibernate Validator来进行校验。
先来看下单个应用程序如何进行校验。
public class TestEntity { @Size(min = 1, max = 10) private String msg; @Size(min = 1, max = 20) private String name; @Max(12) @Min(10) private int age; @Email @NotNull private String email; @Pattern(regexp = "\\d+") private String test; ... }
TestEntity testEntity = new TestEntity(); testEntity.setAge(11); testEntity.setMsg("22"); ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); System.out.println(validatorFactory); Validator validator = validatorFactory.getValidator(); System.out.println(validator.validate(testEntity));
其中主要涉及到ValidatorFactory及Validator两个对象;由于测试工程中包含有hibernate的Validation,因此打印结果如下:
... org.hibernate.validator.internal.engine.ValidatorFactoryImpl@ba8d91c ... [ConstraintViolationImpl{interpolatedMessage='个数必须在1和10之间', propertyPath=msg, rootBeanClass=class com.liuqi.learn.entity.TestEntity, messageTemplate='{javax.validation.constraints.Size.message}'}, ConstraintViolationImpl{interpolatedMessage='不能为null', propertyPath=email, rootBeanClass=class com.liuqi.learn.entity.TestEntity, messageTemplate='{javax.validation.constraints.NotNull.message}'}]
Spring 中提供了LocalValidatorFactoryBean
的实现,它实现了ValidatorFactory接口及JDK中的Validator接口;当将该对象的实例注入容器后,如果Classpath中包含有Hibernate Validator等校验框架时,将会自动完成校验框架的初始化。
配置主要是需要将LocalValidatorFactoryBean的实例注入到Spring容器中去,有两种方式:
A. 通过Bean注入
@Configuration public class ValidationConfig { @Bean public Validator validator() { return new LocalValidatorFactoryBean(); } }
B. 通过WebMvcConfigurer进行配置
@Configuration public class ValidationConfig implements WebMvcConfigurer { @Override public Validator getValidator() { return new LocalValidatorFactoryBean(); } }
@Autowired private Validator validator; @GetMapping("/validation") public String validation() { TestEntity testEntity = new TestEntity(); testEntity.setAge(11); testEntity.setMsg("22"); return validator.validate(testEntity).toString(); }
请求路径后得到的返回消息:
[ConstraintViolationImpl{interpolatedMessage='不能为null', propertyPath=email, rootBeanClass=class com.liuqi.learn.entity.TestEntity, messageTemplate='{javax.validation.constraints.NotNull.message}'}]
也可以不通过Validator,而直接在方法参数中通过Validated注解来进行校验:
@PostMapping("/validation") public String validation(@Validated @RequestBody TestEntity testEntity) { return "validation"; }
此时前端请求:
{ "age": 1, "name": "liuqi" }
返回的消息:
Body: { path: /test/validation, error: Bad Request, message: Validation failed for object='testEntity'. Error count: 2, errors: [ { codes: [ Min.testEntity.age, Min.age, Min.int, Min ], bindingFailure: false, code: Min, field: age, defaultMessage: 最小不能小于10, objectName: testEntity, ... } }
从上述用例中可以看到,错误信息在defaultMessage字段中,其提示是:最小不能小于10;这个默认的消息是在hibernate-validator*.jar中的ValidationMessages.properties文件中定义的;
如果不使用这个默认的消息,就需要在使用注解的时候指定其message属性:
@Max(12) @Min(value = 10, message = "age必须大于等于10") private int age;
此时返回的defaultMessage则会是指定的Message。
但在message中直接写提示消息存在一个问题,就是当系统需要支持国际化时,这种方式明显不合适。因此,如果要考虑国际化,那么在message中就需要指定消息的code而不是消息本身,然后在国际化文件中配置该code对应的消息内容。Hibernate默认会加载Classpath下名称为ValidationMessages.properties的文件,因此,我们可以在Classpath下添加该名称的文件,并在其中定义Code及其对应的消息:
validation.min=不能小于{value}
然后在校验的字段上使用:
@Max(12) @Min(value = 10, message = "age{validation.min}") private int age;
此时校验失败的defaultMessage消息:age不能小于10; 可以看到Hibernate会自动替换{}中的内容,将其当成Code,然后去国际化文件中查找对应的消息内容。
但此时还有个问题,age这种英文直接传给前台,前台不能直接展示给用户看,直接写中文也不符合我们国际化的初衷。那么很自然的也想要将这个字段进行国际化,此时可以在properties文件中定义一个age:
validation.min=不能小于{value}
age=年龄
然后在校验的地方修改message属性:
@Max(12) @Min(value = 10, message = "{age}{validation.min}") private int age;
执行后发现返回的消息将会是“年龄不能小于10”。 这样,就可以在支持国际化的情况下同时指定校验失败的提示信息了。
经过上文的处理后,校验的异常消息已经完美的支持了国际,但仍旧有一个非常不方便的地方:每个校验的注解上都要添加提示消息;如需要使用Min注解多个属性,实际上每个属性上都要添加{validation.min}这个Code;实际上,所有Min注解使用的都是同一message,除了前面的字段名称不一样。
如果能够不指定validation.min这部分,仅按如下方式使用,而后台处理消息时能够自动将当前注解对应的默认提示消息附加上,则能够大大简化校验代码的编写:
@Max(12) @Min(value = 10, message = "{age}") private int age;
最完美的情况就是上面这样的仅指定{age},而不需要指定validation.min,由校验框架自动添加后面的消息。
现在问题就是如何实现这种方式。
而LocalValidatorFactoryBean中的messageInterpolator可以完成这项内容;它用于将message指定的内容转换成最终显示的内容;因此我们可以在其关键方法interpolate中进行处理,根据校验注解的类型来附加其对应的消息,具体处理如下:
@Configuration public class ValidationConfig implements WebMvcConfigurer { @Override public Validator getValidator() { LocalValidatorFactoryBean localValidatorFactoryBean = new LocalValidatorFactoryBean(); // localValidatorFactoryBean.setValidationMessageSource(messageSource); localValidatorFactoryBean.setMessageInterpolator(new MessageInterpolator()); return localValidatorFactoryBean; } private class MessageInterpolator extends ResourceBundleMessageInterpolator { @Override public String interpolate(String message, Context context, Locale locale) { // 获取注解类型 String annotationTypeName = context.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(); // 根据注解类型获取自定义的消息Code String annotationDefaultMessageCode = VALIDATION_ANNATATION_DEFAULT_MESSAGES.get(annotationTypeName); if (null != annotationDefaultMessageCode && !message.startsWith("javax.validation") && !message.startsWith("org.hibernate.validator.constraints")) { // 如果注解上指定的message不是默认的javax.validation或者org.hibernate.validator等开头的情况, // 则需要将自定义的消息Code拼装到原message的后面; message += "{" + annotationDefaultMessageCode + "}"; } return super.interpolate(message, context, locale); } } private static final Map<String, String> VALIDATION_ANNATATION_DEFAULT_MESSAGES = new HashMap<String, String>(20) {{ put("Min", "validation.message.min"); put("NotNull", "validation.message.notNull"); }}; }
然后在ValidationMessages.properties中定义相关项:
validation.message.min=不能小于{value}
validation.message.notNull=不能为空
age=年龄
email=邮箱
修改TestEntity中的校验配置:
@Max(12) @Min(value = 10, message = "{age}") private int age; @Email @NotNull(message = "{email}") private String email;
Controller仍旧使用之前的,请求后返回的消息:
defaultMessage: 邮箱不能为空, ... defaultMessage: 年龄不能小于10,
这样就在国际化的基础上极大的简化了校验注解的使用。
经过上面的处理后,校验注解的使用已经很简单了。但实际上还可以进一步简化。如这样:
@Max(12) @Min(value = 10) private int age; @Email @NotNull private String email;
默认为校验注解的message指定Code为属性名称的message,即使得其与下面的配置等价:
@Max(12) @Min(value = 10, message = "{age}") private int age; @Email @NotNull(message = "{email}") private String email;
这个地方就需要使用到Spring的Bean生命周期及反射相关的知识了。
通过对Spring的Bean生命周期分析,可以使用BeanPostProcessor这个接口,在Bean实例化及注入完成后进行处理,如果当前Bean是Controller的实例时,查找其所有带有被Validated注解参数的方法,然后一个个的处理。
具体的代码如下:
@Component public class EntityBeanPostProcessor implements BeanPostProcessor { private static final Logger logger = LoggerFactory.getLogger(EntityBeanPostProcessor.class); @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { if (!bean.getClass().isAnnotationPresent(Controller.class) && !bean.getClass().isAnnotationPresent(RestController.class)) { return bean; } Method[] methods = bean.getClass().getDeclaredMethods(); for (Method method : methods) { Parameter[] parameters = method.getParameters(); for (Parameter parameter : parameters) { if (!parameter.isAnnotationPresent(Validated.class)) { continue; } Class<?> parameterType = parameter.getType(); Field[] fields = parameterType.getDeclaredFields(); for (Field field : fields) { String fieldName = field.getName(); Annotation[] annotations = field.getDeclaredAnnotations(); for (Annotation annotation : annotations) { String annotationName = annotation.annotationType().getName(); if (!annotationName.startsWith("javax.validation.constraints") && !annotationName.startsWith("org.hibernate.validator.constraints")) { // 如果不是JDK中的校验注解并且不是Hibernate中的校验注解,不需要处理 continue; } // 否则,如果注解存在message属性,并且未进行指定,则根据属性名称直接为注解指定message属性; Field messageField; try { InvocationHandler invocationHandler = Proxy.getInvocationHandler(annotation); Field memberValuesField = invocationHandler.getClass().getDeclaredField("memberValues"); if (null == memberValuesField) { continue; } memberValuesField.setAccessible(true); Map<String, String> map = (Map<String, String>) memberValuesField.get(invocationHandler); String message = map.get("message"); // 如果message已经存在,并且是默认的消息,才进行替换,否则不替换 if (message.startsWith("{javax.validation") || message.startsWith("{org.hibernate.validator.constraints")) { map.put("message", "{" + fieldName + "}"); } } catch (NoSuchFieldException | IllegalAccessException e) { logger.error("配置校验注解的Message属性失败!", e); continue; } } } } } return bean; } }
经过以上处理,在使用校验注解时即可以不需要指定message属性了,而只是简单的在国际化文件中指定与属性名称相同的code及其对应的消息即可。
在上述测试中,可以看到,当出现异常时,其返回消息包含的信息较多,在项目中实际上一般用不到这些信息,只需要一些出错的提示信息即可。
经过验证,当校验出现问题时,最终将会抛出MethodArgumentNotValidException类型的异常,因此可以通过ControllerAdvice及ExceptionHandler来对异常消息进行转换:
@ControllerAdvice public class ValidationAdvice { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity processMethodArgumentNotValidException(MethodArgumentNotValidException ex) { Map<String, Object> responseBody = new HashMap<>(3); responseBody.put("status_code", "500"); String errorMessage = ex.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage) .reduce((s1, s2) -> s1.concat(",").concat(s2)).get(); responseBody.put("status_msg", "校验失败,失败消息:" + errorMessage); return new ResponseEntity(responseBody, null, HttpStatus.INTERNAL_SERVER_ERROR); } }
当然此处也需要引入国际化支持。
这样最终返回的消息就会是:
Body: { status_code: 500, status_msg: 校验失败,失败消息:邮箱不能为空,年龄不能小于10 }
调用方就能够很容易的得到错误信息了。