天天看點

Spring Validation及消息國際化.md1. 簡單示例2 與Spring內建3. 異常消息國際化

在項目中,接收到前端或者其它用戶端的調用請求時,需要對傳入的參數進行校驗。完成這些校驗最原始的做法就是編寫代碼一個個參數進行判斷,如判斷是否為空、長度是否符合要求、格式是否符合要求等;對于一些簡單的輸入還好,越複雜的輸入,這些校驗的代碼及邏輯越長,而且在校驗失敗後組裝的傳回消息也是因人而異,導緻同一項目裡面校驗失敗後傳回的消息不統一,最終結果就是使用者體驗較差。

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
}
                

調用方就能夠很容易的得到錯誤資訊了。