写点什么

Springboot 国际化消息和源码解读

作者:DoneSpeak
  • 2021 年 12 月 26 日
  • 本文字数:8905 字

    阅读完需:约 29 分钟

写在前面

在 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.RELEASEsping: 5.1.8.RELEASEjava: 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; }}
复制代码


@Datapublic 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>.propertiesValidationMessages_en.propertiesValidationMessages_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/serletpackage 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@RestControllerAdvicepublic 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的源码。

参考

发布于: 1 小时前
用户头像

DoneSpeak

关注

Let the Work That I've Done Speak for Me 2018.05.10 加入

Java后端开发

评论

发布
暂无评论
Springboot国际化消息和源码解读