国际化 Bean Validation 校验失败的错误消息

1、概览

在 Web 应用中,我们通常需要通过 spring-validation 对客户端提交的数据进行校验。如果校验失败则会抛出异常。默认情况下,关于校验失败细节的异常信息是英文。本文将会带你了解如何对校验失败时的异常消息进行国际化(也称为“本地化”)。

2、依赖

首先在 pom.xml 中添加 Spring Boot Starter WebSpring Boot Starter Validation

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

最新版本可在 Maven Central 上找到。

3、本地化信息存储

在 Java 应用中,通常使用 properties 文件存储本地化信息。一般称为 Resource Bundle。

这些文件是由键值对组成的纯文本文件。key 是信息检索的标识符,而对应的 value 则是相应语言的本地化消息。

接下来,我们创建 2 个 properties 文件。

CustomValidationMessages.properties 是默认的 properties 文件,文件名不包含任何语言名称。只要客户端指定的语言不支持,应用就会使用默认语言:

field.personalEmail=Personal Email
validation.notEmpty={field} cannot be empty
validation.email.notEmpty=Email cannot be empty

再创建一个额外的中文语言的 properties 文件 - CustomValidationMessages_zh.properties。只要客户端指定 zhzh-tw 等变体作为本地语言,应用语言就会切换为中文:

field.personalEmail=個人電郵
validation.notEmpty={field}不能是空白
validation.email.notEmpty=電郵不能留空

必须确保所有 properties 文件都是 UTF-8 编码。特别是在处理包含中文、日文和韩文等非拉丁字符的消息时,这一点尤为重要。

4、本地化消息检索

Spring Boot 通过 MessageSource 接口简化了本地化消息检索。它可以解析应用中 Resource Bundle,使我们无需额外工作即可获取不同本地语言的消息。

必须在 Spring Boot 中配置 MessageSource 的 Provider,然后才能使用它。在本教程中,我们使用 ReloadableResourceBundleMessageSource 作为实现。

它能够在不重启服务器的情况下重新加载消息 properties 文件。在应用的开发阶段,它可以在不重新部署整个应用的情况下看到消息变化时,这一点非常有用。

这里指定的默认编码(UTF-8),必须要和 properties 文件的编码一一致:

@Configuration
public class MessageConfig {

    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        messageSource.setBasename("classpath:CustomValidationMessages");
        // 默认编码
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }
}

5、Bean 校验

这里,定义了一个名为 User 的数据传输对象(DTO),其中包含一个 email 字段。

我们使用 Java Bean Validation 来验证这个 DTO 类。email 字段使用 @NotEmpty 进行注解,以确保它不能是空字符串。该注解是标准的 Java Bean Validation 注解:

public class User {

    @NotEmpty
    private String email;

    // get / set 方法省略
}

6、REST 服务

创建一个 REST 服务 UserService,用于通过 PUT 请求更新指定的 User 信息:

@RestController
public class UserService {

    @PutMapping(value = "/user", produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<UpdateUserResponse> updateUser(
      @RequestBody @Valid User user,
      BindingResult bindingResult) {

        if (bindingResult.hasFieldErrors()) {

            List<InputFieldError> fieldErrorList = bindingResult.getFieldErrors().stream()
              .map(error -> new InputFieldError(error.getField(), error.getDefaultMessage()))
              .collect(Collectors.toList());

            UpdateUserResponse updateResponse = new UpdateUserResponse(fieldErrorList);
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(updateResponse);
        }
        else {
            // 更新逻辑
            return ResponseEntity.status(HttpStatus.OK).build();
        }
    }

}

6.1、语言选择

通常的做法是使用 Accept-Language HTTP Header 来定义客户端的语言偏好。

我们可以使用 Spring Boot 中的 LocaleResolver 接口,从 HTTP 请求中的 Accept-Language 头信息获取本地语言。在本例中,无需显式定义 LocaleResolver。Spring Boot 会为我们提供一个默认的 LocaleResolver

然后,服务会根据该 Header 返回相应的本地化信息。如果服务不支持客户端指定的语言,那么会直接使用默认的语言,英语。

6.2、校验

我们在 updateUser(...) 方法中用 @ValidUser DTO 进行注解。这指示 Java Bean Validation 在调用 REST 服务时校验此对象。校验会自动进行,我们可以通过 BindingResult 对象检查校验结果。

每当存在字段错误(通过 bindingResult.hasFieldErrors() 确定)时,Spring Boot 会根据当前语言设置获取本地化的错误消息,并将该消息封装到字段错误实例中

我们要遍历 BindingResult 中的每个字段错误,并将它们收集到一个响应对象中,然后将响应发送回客户端。

6.3、响应对象

如果验证失败,服务会返回一个 UpdateResponse 对象,其中包含了指定语言形式的验证错误信息:

public class UpdateResponse {

    private List<InputFieldError> fieldErrors;

    // get / set 
}

InputFieldError 是一个占位符类,用于存储包含错误的字段以及错误信息:

public class InputFieldError {

    private String field;
    private String message;

    // get / set 
}

7、校验信息类型

用以下请求体向 REST 服务 /user 发起 PUT 请求:

{
    "email": ""
}

User 对象包含了一个空的 email,该请求会触发验证错误。

7.1、标准消息

如果不在请求中提供任何语言信息,典型的响应如下,其中包含一条英文信息:

{
    "fieldErrors": [
        {
            "field": "email",
            "message": "must not be empty"
        }
    ]
}

接着,用以下 Accept-Language HTTP Header 发起另一个请求:

accept-lanaguage: zh-tw

此时,服务会从中文 Resource Bundle 中检索信息。响应如下:

{
    "fieldErrors": [
        {
            "field": "email",
            "message": "不得是空的"
        }
    ]
}

这些是 Java Bean Validation 提供的标准校验信息。可以从默认的实现 Hibernate Validator 中找到完整的消息列表。

不过,这个消息看起来并不是太友好。我们可以进行修改。

7.2、覆盖消息

我们可以覆盖 Java Bean Validation 实现中定义的默认消息。只需定义一个以 ValidationMessages.properties 为 basename 的 properties 文件即可:

javax.validation.constraints.NotEmpty.message=The field cannot be empty

使用相同的 basename,为中文创建另一个 properties 文件 ValidationMessages_zh.properties

javax.validation.constraints.NotEmpty.message=本欄不能留空

再次调用服务,响应信息会被我们定义的信息所取代:

{
    "fieldErrors": [
        {
            "field": "email",
            "message": "The field cannot be empty"
        }
    ]
}

虽然修改了验证消息,但是消息内容不够清晰,消息应该显示出哪个字段校验失败。

7.3、自定义消息

之前已经在 CustomValidationMessages Resource Bundle 中定义了自定义消息。

现在,在 User DTO 的验证注解中使用新消息:{validation.email.notEmpty} 。大括号表示消息是一个 Properties Key,需要从相应的 Resource Bundle 中解析:

public class User {

    @NotEmpty(message = "{validation.email.notEmpty}")
    private String email;

    // get / set
}

当我们向服务发起请求时,会看到如下信息:

{
    "fieldErrors": [
        {
            "field": "email",
            "message": "Email cannot be empty"
        }
    ]
}

7.4、在消息中插入值

通过在消息中加入字段名称,我们大大改进了消息。不过,在处理多个字段时,可能会出现一个难题。假设我们有 30 个字段,每个字段需要三种不同类型的验证。这需要在每个本地化 Resource Bundle 中定义 90 条验证信息。

我们可以利用消息占位符来解决这个问题,在向用户展示之前,这些占位符会被动态替换为实际值。在上述场景中,这种方法可以将验证信息的数量减少到 33 条,其中包含 30 个字段名和 3 条唯一的验证信息。

Java Bean Validation 不支持自定义占位符的验证消息。不过,我们可以定义包含附加属性的自定义验证。

这一次,我们用一个新的自定义注解 @FieldNotEmpty 来注解 User。基于现有的 message 属性,我们将引入一个新的属性字段来表示字段名称:

public class User {

    @FieldNotEmpty(message = "{validation.notEmpty}", field = "{field.personalEmail}")
    private String email;

    // get / set
}

现在,为 @FieldNotEmpty 定义两个属性:

@Documented
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = {FieldNotEmptyValidator.class})
public @interface FieldNotEmpty {

    String message() default "{validation.notEmpty}";

    String field() default "Field";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

}

@FieldNotEmpty 是一个校验注解,并使用 FieldNotEmptyValidator 作为 Validator 实现:

public class FieldNotEmptyValidator implements ConstraintValidator<FieldNotEmpty, Object> {

    private String message;
    private String field;

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        return (value != null && !value.toString().trim().isEmpty());
    }

}

isValid(...) 方法执行验证逻辑,只需确定 value 是否为空。如果 value 为空,该方法将从请求上下文中获取与当前本地语言相对应的属性字段和消息的本地化消息。。

执行结果如下:

{
    "fieldErrors": [
        {
            "field": "email",
            "message": "{field.personalEmail} cannot be empty"
        }
    ]
}

message 属性及其相应的占位符已成功获取。但是,我们希望使用实际的值替换 {field.personalEmail}

7.5、自定义 MessageInterpolator

问题出在默认的 MessageInterpolator 上。它只替换一次占位符。我们需要再次对消息进行占位符替换,以便用本地化消息替换后续占位符。在这种情况下,我们必须定义一个自定义 Message Interpolator 来替换默的:

public class RecursiveLocaleContextMessageInterpolator extends AbstractMessageInterpolator {

    private static final Pattern PATTERN_PLACEHOLDER = Pattern.compile("\\{([^}]+)\\}");

    private final MessageInterpolator interpolator;

    public RecursiveLocaleContextMessageInterpolator(ResourceBundleMessageInterpolator interpolator) {
        this.interpolator = interpolator;
    }

    @Override
    public String interpolate(MessageInterpolator.Context context, Locale locale, String message) {
        int level = 0;
        while (containsPlaceholder(message) && (level++ < 2)) {
            message = this.interpolator.interpolate(message, context, locale);
        }
        return message;
    }

    private boolean containsPlaceholder(String code) {
        Matcher matcher = PATTERN_PLACEHOLDER.matcher(code);
        return matcher.find();
    }

}

RecursiveLocaleContextMessageInterpolator 只是一个装饰器。当检测到消息中包含任何大括号占位符时,它会使用封装的 MessageInterpolator 重新进行占位符替换。

实现后,需要通过 Spring Boot 配置为 Bean。

MessageConfig 中添加 2 个方法:

@Bean
public MessageInterpolator getMessageInterpolator(MessageSource messageSource) {
    MessageSourceResourceBundleLocator resourceBundleLocator = new MessageSourceResourceBundleLocator(messageSource);
    ResourceBundleMessageInterpolator messageInterpolator = new ResourceBundleMessageInterpolator(resourceBundleLocator);
    return new RecursiveLocaleContextMessageInterpolator(messageInterpolator);
}

@Bean
public LocalValidatorFactoryBean getValidator(MessageInterpolator messageInterpolator) {
    LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
    bean.setMessageInterpolator(messageInterpolator);
    return bean;
}

getMessageInterpolator(...) 方法返回我们自己的实现。该实现封装了 ResourceBundleMessageInterpolator,它是 Spring Boot 的默认 MessageInterpolatorgetValidator() 用于注册 Validator,以便在 Web 服务中使用我们自定义的 MessageInterpolator

现在,再测试一次。你可以看到如下详细的信息,占位符已经被替换为实际的值:

{
    "fieldErrors": [
        {
            "field": "email",
            "message": "Personal Email cannot be empty"
        }
    ]
}

8、总结

本文介绍了如何在国际化应用中,对 Bean Validation 校验失败的错误消息进行国际化。


参考:https://www.baeldung.com/rest-localized-validation-messages