天天看点

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
}
                

调用方就能够很容易的得到错误信息了。