在項目中,接收到前端或者其它用戶端的調用請求時,需要對傳入的參數進行校驗。完成這些校驗最原始的做法就是編寫代碼一個個參數進行判斷,如判斷是否為空、長度是否符合要求、格式是否符合要求等;對于一些簡單的輸入還好,越複雜的輸入,這些校驗的代碼及邏輯越長,而且在校驗失敗後組裝的傳回消息也是因人而異,導緻同一項目裡面校驗失敗後傳回的消息不統一,最終結果就是使用者體驗較差。
JSR-303為這類校驗提供了一個規範,并在JDK1.6起即提供了Validation包,其中包含了關鍵的ValidatorFactory及Validator、常用校驗注解等内容。Hibernate又在其基礎上提供了一個實作。注意這個實作與Hibernate資料庫讀寫中間件無任何關聯。
一般項目中都是直接使用Hibernate Validator來進行校驗。
1. 簡單示例
先來看下單個應用程式如何進行校驗。
1.1 實體類
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;
...
}
1.2 校驗
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}'}]
2 與Spring內建
Spring 中提供了
LocalValidatorFactoryBean
的實作,它實作了ValidatorFactory接口及JDK中的Validator接口;當将該對象的執行個體注入容器後,如果Classpath中包含有Hibernate Validator等校驗架構時,将會自動完成校驗架構的初始化。
2.1 配置
配置主要是需要将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();
}
}
2.2 使用
2.2.1 通過Validator直接使用
@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}'}]
2.2.2 使用Validated注解
也可以不通過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,
...
}
}
3. 異常消息國際化
3.1 消息國際化
從上述用例中可以看到,錯誤資訊在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”。 這樣,就可以在支援國際化的情況下同時指定校驗失敗的提示資訊了。
3.2 國際化優化-簡化使用
經過上文的處理後,校驗的異常消息已經完美的支援了國際,但仍舊有一個非常不友善的地方:每個校驗的注解上都要添加提示消息;如需要使用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,
這樣就在國際化的基礎上極大的簡化了校驗注解的使用。
3.3 使用BeanPostProcessor及反射進一步簡化
經過上面的處理後,校驗注解的使用已經很簡單了。但實際上還可以進一步簡化。如這樣:
@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及其對應的消息即可。
3.4 異常消息格式化
在上述測試中,可以看到,當出現異常時,其傳回消息包含的資訊較多,在項目中實際上一般用不到這些資訊,隻需要一些出錯的提示資訊即可。
經過驗證,當校驗出現問題時,最終将會抛出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
}
調用方就能夠很容易的得到錯誤資訊了。