Springboot 国际化消息和源码解读

写在前面

在 REST 接口的实现方案中,后端可以仅仅返回一个 code ,让前端根据 code 的内容做自定义的消息提醒。当然,也有直接显示后端返回消息的方案。在后端直接返回消息的方案中,如果要提供多个不同语种国家使用,则需要做国际化消息的实现。

x400 BAD_REQUEST
{
    "code": "user.email.token",
    "message": "The email is token."
}

实现的目标:

  1. validation 的 error code 可以按照不同语言响应;
  2. 自定义 error code 可以按照不同语言响应;
  3. 指定默认的语言;

版本说明

spring-boot: 2.1.6.RELEASE
sping: 5.1.8.RELEASE
java: openjdk 11.0.13

初始化项目

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

添加如下的 Controller 类和 User 实体类,其中的 ServiceException 是一个自定义的异常类,该异常会带上业务处理过程中的异常码。

@Validated
@RestController
@RequestMapping("/user")
public class UserController {

    private static final String TOKEN_EMAIL = "token@example.com";
    
    @PostMapping
    public User createUser(@RequestBody @Valid User user) {
        if (TOKEN_EMAIL.equalsIgnoreCase(user.getEmail())) {
            String message = String.format("The email %s is token.", user.getEmail());
            throw new ServiceException("user.email.token", message);
        }
        return user;
    }
}

@Data
public class User {
    @NotNull
    @Length(min = 5, max = 12)
    @Pattern(regexp = "^r.*", message = "{validation.user.username}")
    private String username;

    @NotNull
    @Email
    private String email;

    @Range(min = 12, message = "{validation.user.age}")
    private int age;
}

Validation 中实现国际化信息

基本实现

在默认的 SpringBoot 配置中,只要在请求中增加 Accept-Language 的 Header 即可实现 Validation 错误信息的国际化。如下的请求会使用返回中文的错误信息。

curl --location --request POST 'http://localhost:8080/user' \
--header 'Accept-Language: zh-CN' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "xx",
    "email": "token@example.com",
    "age": 24
}

对于自定义的 message,如 validation.user.username ,则只需要在 resource 目录下创建一个 basename 为 ValidationMessages 的国际化信息文件即可实现。

└── resources
    ├── ValidationMessages.properties
    ├── ValidationMessages_zh_CN.properties
    └── application.yml

validation.user.age=用户的年龄应该大于{min}.
validation.user.username=用户名应该以r字母开始

国家化信息文件文件名定义规则:

basename_<language>_<country_or_region>.properties
ValidationMessages_en.properties
ValidationMessages_zh_CN.properties

更多的文件,可以到 hibernate-validator 的 github 仓库查看(文末会给出链接)。

LocaleResolver 解析 locale 信息

在 spring-framework 的 spring-webmvc/.../i18n 中,可以找到如下三种不同的 LocaleResolver 的实现:

  • FixedLocaleResolver :
  • 固定 local,不做国际化更改。
  • 不可动态更改 local,否则抛出 UnsupportedOperationException
  • AcceptHeaderLocaleResolver :
  • 读取 request 的 header 中的 Accept-Language 来确定 local 的值。
  • 不可动态更改 local,否则抛出 UnsupportedOperationException
  • CookieLocaleResolver :
  • 将 local 信息保存到 cookie 中,可配合 LocaleChangeInterceptor 使用
  • SessionLocaleResolver :
  • 将 local 信息保存到 session 中,可配合 LocaleChangeInterceptor 使用

默认使用 AcceptHeaderLocaleResolver

设置指定固定的 locale

通过指定 spring.mvc.locale-resolver=fixed 可以使用固定的 locale。如果项目只有一种语言可以做该指定,以免客户端没有配置 Accept-Language 的 header 而出现多种不同的语言。

spring:
  mvc:
    locale-resolver: fixed
    locale: en

在 spring-boot 的 spring-boot-autoconfig/.../web/serlet 中可以找到如下的配置,该配置指定了 localeResolver 的配置方式。

// spring-boot: spring-boot-autoconfig/.../web/serlet
package org.springframework.boot.autoconfigure.web.servlet;

public class WebMvcAutoConfiguration {
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "spring.mvc", name = "locale")
    public LocaleResolver localeResolver() {
        if (this.mvcProperties.getLocaleResolver() == WebMvcProperties.LocaleResolver.FIXED) {
            return new FixedLocaleResolver(this.mvcProperties.getLocale());
        }
        AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
        localeResolver.setDefaultLocale(this.mvcProperties.getLocale());
        return localeResolver;
    }
}

package org.springframework.boot.autoconfigure.web.servlet;

@ConfigurationProperties(prefix = "spring.mvc")
public class WebMvcProperties {
    ...
    /**
     * Locale to use. By default, this locale is overridden by the "Accept-Language"
     * header.
     */
    private Locale locale;
    /**
     * Define how the locale should be resolved.
     */
    private LocaleResolver localeResolver = LocaleResolver.ACCEPT_HEADER;
    ...
}

通过 Url 指定 Locale

通过 LocaleChangeInterceptor 获取请求中的 lang 参数来设定语言,并将 locale 保存到 Cookie 中,请求了一次之后,相同的请求就无需再带上该 lang 参数,即可使用原本已经设定的 locale。

@Configuration
@ConditionalOnProperty(prefix = "spring.mvc", name = "customer-locale-resolver", havingValue = "cookie")
public class MvcConfigurer implements WebMvcConfigurer {

    @Bean
    public LocaleResolver localeResolver(@Value("${spring.mvc.locale:null}") Locale locale) {
        CookieLocaleResolver resolver = new CookieLocaleResolver();
        if (locale != null) {
            resolver.setDefaultLocale(locale);
        }
        return resolver;
    }

    @Bean
    public LocaleChangeInterceptor localeInterceptor() {
        LocaleChangeInterceptor localeInterceptor = new LocaleChangeInterceptor();
        localeInterceptor.setParamName("lang");
        return localeInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeInterceptor());
    }
}

curl --location --request POST 'http://localhost:8080/user?lang=en' \
--header 'Content-Type: application/json' \
--data-raw '{
    "username": "rxdxxxx",
    "email": "token@example.com",
    "age": 24
}'

之后的请求只要带上含有设置的 cookie 即可。

curl --location --request POST 'http://localhost:8080/user' \
--header 'Content-Type: application/json' \
--header 'Cookie: org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE=zh-CN' \
--data-raw '{
    "username": "rxdxxxx",
    "email": "token@example.com",
    "age": 24
}'

自定义 Error Code 实现国际化信息

在前文给出的代码中,当 email 为 token@example.com 时,抛出邮箱已被占用的异常。自定义异常中的 code 不是通过 validator 校验,所以不能通过 validator 自动处理。需要通过 MessageSource 来将定义的 code 转化为 message,可以将 MessageSource 理解为一个 key-value 结构的类。

resources 目录下创建目录 i18n ,然后创建以 messages 为 basename 的国际化信息文件。如下面的目录结构。

└── resources
    ├── ValidationMessages.properties
    ├── ValidationMessages_zh_CN.properties
    ├── application.yml
    └── i18n
        ├── messages.properties
        ├── messages_en.properties
        └── messages_zh_CN.properties

message_zh_CN.properties

user.email.token=邮箱已存在

application.yml 中添加如下配置,即可让 springboot 生成含有 i18n/messages 中的 properties 的配置了。默认情况下, spring.messages.basename=messages ,也就是可以直接在 resources 目录下创建需要的 messages 国际化信息文件。这里为了把 i18n 配置都放到一个目录下才做了修改,也可以不修改。

spring:
  messages:
    basename: i18n/messages

定义 ApiExceptionHandler 捕获指定的异常,并在其中的 code 转化为 message。

package io.github.donespeak.springbootsamples.i18n.support;

import io.github.donespeak.springbootsamples.i18n.core.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.LocaleResolver;

import javax.servlet.http.HttpServletRequest;
import java.util.Locale;

@Slf4j
@RestControllerAdvice
public class ApiExceptionHandler {
    // 用于获取当前的locale
    private LocaleResolver localeResolver;
    // 含有配置的code-message对
    private MessageSource messageSource;

    public ApiExceptionHandler(LocaleResolver localeResolver, MessageSource messageSource) {
        this.localeResolver = localeResolver;
        this.messageSource = messageSource;
    }

    @ExceptionHandler(ServiceException.class)
    public ResponseEntity<Object> handleServiceException(ServiceException ex, HttpServletRequest request, WebRequest webRequest) {
        HttpHeaders headers = new HttpHeaders();
        HttpStatus status = HttpStatus.BAD_REQUEST;
        // 获取当前请求的locale
        Locale locale = localeResolver.resolveLocale(request);
        log.info("the local for request is {} and the default is {}", locale, Locale.getDefault());
        // 将code转化为message
        String message = messageSource.getMessage(ex.getCode(), null, locale);
        ApiError apiError = new ApiError(ex.getCode(), message);
        log.info("The result error of request {} is {}", request.getServletPath(), ex.getMessage(), ex);
        return new ResponseEntity(apiError, headers, status);
    }
}

code 转化为国际化信息的 message 也就配置完成了。如果对于其他的在 ResponseEntityExceptionHandler 中定义的 Exception 也需要做国际化信息转化的话,也可以按照上面处理 ServiceException 的方法进行定义即可。

源码探究

按照上面的代码操作,已经可以解决需要实现的功能了。这部分将会讲解国际化信息的功能涉及到的源码部分。

Springboot 如何获取 messages 文件生成 MessageSource

在 Springboot 的 spring-boot-autoconfig 中配置 MessageSource 的 Properties 类为 MessageSourceProperties ,该类默认指定了 basename 为 messages。

package org.springframework.boot.autoconfigure.context;

public class MessageSourceProperties {
    ...
    // 默认值
    private String basename = "messages";
    ...
}

下面的 MessageSourceAutoConfiguration 为配置 MessageSource 的实现。

package org.springframework.boot.autoconfigure.context;

public class MessageSourceAutoConfiguration {
    ...
    @Bean
    public MessageSource messageSource(MessageSourceProperties properties) {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        if (StringUtils.hasText(properties.getBasename())) {
            // 设置 MessageSource的basename
            messageSource.setBasenames(StringUtils
                    .commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(properties.getBasename())));
        }
        if (properties.getEncoding() != null) {
            messageSource.setDefaultEncoding(properties.getEncoding().name());
        }
        messageSource.setFallbackToSystemLocale(properties.isFallbackToSystemLocale());
        Duration cacheDuration = properties.getCacheDuration();
        if (cacheDuration != null) {
            messageSource.setCacheMillis(cacheDuration.toMillis());
        }
        messageSource.setAlwaysUseMessageFormat(properties.isAlwaysUseMessageFormat());
        messageSource.setUseCodeAsDefaultMessage(properties.isUseCodeAsDefaultMessage());
        return messageSource;
    }
    ...
}

Validation 如何使用到 ValidationMessages

springboot 的 spring-boot-autoconfigure/.../validation/ValidationAutoConfiguration 为 Validator 的配置类。

package org.springframework.boot.autoconfigure.validation;

/**
 * @since 1.5.0
 */
@Configuration
@ConditionalOnClass(ExecutableValidator.class)
@ConditionalOnResource(resources = "classpath:META-INF/services/javax.validation.spi.ValidationProvider")
@Import(PrimaryDefaultValidatorPostProcessor.class)
public class ValidationAutoConfiguration {
    // 创建Validator
    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    @ConditionalOnMissingBean(Validator.class)
    public static LocalValidatorFactoryBean defaultValidator() {
        LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
        // 提供message和LocaleResolver
        MessageInterpolatorFactory interpolatorFactory = new MessageInterpolatorFactory();
        factoryBean.setMessageInterpolator(interpolatorFactory.getObject());
        return factoryBean;
    }

    @Bean
    @ConditionalOnMissingBean
    public static MethodValidationPostProcessor methodValidationPostProcessor(Environment environment,
                                                                              @Lazy Validator validator) {
        MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
        // proxy-target-class="true" 则使用cglib2
        boolean proxyTargetClass = environment.getProperty("spring.aop.proxy-target-class", Boolean.class, true);
        processor.setProxyTargetClass(proxyTargetClass);
        processor.setValidator(validator);
        return processor;
    }
}

这里重点关注一下 MessageInterpolatorFactory 类,该类最后会创建一个 org.hibernate.validator.messageinterpolatio.ResourceBundleMessageInterpolator

package org.springframework.boot.validation;

public class MessageInterpolatorFactory implements ObjectFactory<MessageInterpolator> {

    private static final Set<String> FALLBACKS;

    static {
        Set<String> fallbacks = new LinkedHashSet<>();
        fallbacks.add("org.hibernate.validator.messageinterpolation" + ".ParameterMessageInterpolator");
        FALLBACKS = Collections.unmodifiableSet(fallbacks);
    }

    @Override
    public MessageInterpolator getObject() throws BeansException {
        try {
            // 这里提供默认的MessageInterpolator,获取到ConfigurationImpl
            // 最终得到 org.hibernate.validator.messageinterpolation.ResourceBundleMessageInterpolator
            return Validation.byDefaultProvider().configure().getDefaultMessageInterpolator();
        }
        catch (ValidationException ex) {
            MessageInterpolator fallback = getFallback();
            if (fallback != null) {
                return fallback;
            }
            throw ex;
        }
    }
    ...
}

ResourceBundleMessageInterpolatorAbstractMessageInterpolator 的一个子类,该类定义了 validation messages 文件的路径,其中 org.hibernate.validator.ValidationMessages 为 hibernate 提供的,而用户自定义的为 ValidationMessages

package org.hibernate.validator.messageinterpolation;

public abstract class AbstractMessageInterpolator implements MessageInterpolator {
  /**
   * The name of the default message bundle.
   */
  public static final String DEFAULT_VALIDATION_MESSAGES = "org.hibernate.validator.ValidationMessages";

  /**
   * The name of the user-provided message bundle as defined in the specification.
   */
  public static final String USER_VALIDATION_MESSAGES = "ValidationMessages";

  /**
   * Default name of the message bundle defined by a constraint definition contributor.
   *
   * @since 5.2
   */
  public static final String CONTRIBUTOR_VALIDATION_MESSAGES = "ContributorValidationMessages";
    
    ...

}

在 Springboot 2.6.0 及之后,对 validation 进行了修改。如果需要使用,则需要按照如下的方式引入:

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

此外,还对 MessageInterpolatorFactory 进行了修改,允许设置外部的 MessageSource。还增加了一个 MessageSourceMessageInterpolator 来整合 messageSource 和 MessageInterpolator。


public class MessageInterpolatorFactory implements ObjectFactory<MessageInterpolator> {
    ...
  private final MessageSource messageSource;

  public MessageInterpolatorFactory() {
    this(null);
  }

  /**
   * Creates a new {@link MessageInterpolatorFactory} that will produce a
   * {@link MessageInterpolator} that uses the given {@code messageSource} to resolve
   * any message parameters before final interpolation.
   * @param messageSource message source to be used by the interpolator
   * @since 2.6.0
   */
  public MessageInterpolatorFactory(MessageSource messageSource) {
        // 允许使用外部的messageSource
    this.messageSource = messageSource;
  }

  @Override
  public MessageInterpolator getObject() throws BeansException {
    MessageInterpolator messageInterpolator = getMessageInterpolator();
    if (this.messageSource != null) {
      return new MessageSourceMessageInterpolator(this.messageSource, messageInterpolator);
    }
    return messageInterpolator;
  }

  private MessageInterpolator getMessageInterpolator() {
    try {
      return Validation.byDefaultProvider().configure().getDefaultMessageInterpolator();
    }
    catch (ValidationException ex) {
      MessageInterpolator fallback = getFallback();
      if (fallback != null) {
        return fallback;
      }
      throw ex;
    }
  }
    ...
}

MessageSourceMessageInterpolator 会优先使用 messageSource 处理,再通过 messageInterpolator 处理。

package org.springframework.boot.validation;

class MessageSourceMessageInterpolator implements MessageInterpolator {
    ...
    @Override
    public String interpolate(String messageTemplate, Context context) {
        return interpolate(messageTemplate, context, LocaleContextHolder.getLocale());
    }

    @Override
    public String interpolate(String messageTemplate, Context context, Locale locale) {
        // 优先通过messageSource替换占位符,在通过messageInterpolator做处理
        String message = replaceParameters(messageTemplate, locale);
        return this.messageInterpolator.interpolate(message, context, locale);
    }
    ...
}

如需了解更多的信息,可以去查看 org.springframework.validation.beanvalidation。LocalValidatorFactoryBean 的源码。

参考


原文:Springboot国际化消息和源码解读_Java_DoneSpeak_InfoQ写作社区