1. 概念
国际化是指软件开发时,应该具备支持多种语言和地区的功能。 换句话说就是,开发的软件需要能同时应对不同国家和地区的用户访问,并根据用户地区和语言习惯,提供相应的、符合用具阅读习惯的页面和数据。
例如,iphone 手机在注册的时候会要求选择系统语言,我会选择简体中文,台湾同胞会选择中文繁体,美国人会选择英文。选择不同的系统语言后,手机界面上的文字语言、时间时区等,都会跟随变动。
在针对国际化的开发中,我们经常能见到 i18n
,实际上就是国际化的英文internationalization
的缩写,和 k8s
类似:
i
和n
分别为首末字符18
则为中间的字符数。
本文核心关注国际化中多语言的方案,后续再写时区。当然,我写的是比较浅显的概念,方案上可能也比较简单,大家可基于此入门。
2. 代码示例
在 Spring Boot 中,对于国际化的支持,默认是通过 AcceptHeaderLocaleResolver 解析器来完成的,这个解析器,默认是通过请求头的 Accept-Language
字段来判断当前请求所属的环境的,进而给出合适的响应。
我们可以先写代码,通过代码示例感受一下。
我们创建一个 Spring Boot 项目,不需要特殊的 pom 依赖,因为 ApplicationContent 默认就实现了国际化能力。
1. controller
我们先写一个 controller api:
@RestController
@RequestMapping("")
public class DemoController {
private final static String ERROR_KEY_USERNAME_NOT_EXISTS = "user.username.not.exists";
private final MessageSource messageSource;
public DemoController(MessageSource messageSource) {
this.messageSource = messageSource;
}
private final Predicate<String> checkUsernamePredicate = (String username) -> {
if (Objects.isNull(username)) {
return false;
}
return username.startsWith("T");
};
@GetMapping("hello")
public String sayHello(@RequestParam("username") String username) {
if (checkUsernamePredicate.test(username)) {
return "Hello!" + username;
}
return messageSource.getMessage(ERROR_KEY_USERNAME_NOT_EXISTS, new String[]{username}, LocaleContextHolder.getLocale());
}
}
sayHello 的接口,如果传入的 username 参数不是以 T 开头,就会返回以 user.username.not.exists
为key,对应的多语言值。
对应多语言值的内容,我们配置在项目 resources 的目录中。
2. messages 文件
我们在 resources 目录下创建4个文件:
messages.properties
user.username.not.exists=账号 {0} 不存在
messages_zh_CN.properties
user.username.not.exists=账号 {0} 不存在
messages_zh_TW.properties
user.username.not.exists=帳號 {0} 不存在
messages_en_US.properties
user.username.not.exists=Account {0} does not exist
3. 测试
测试接口,传入参数 username=Jock,但 Header中带的 Accept-Language 值不同,对应的结果分别是:
- Accept-Language 不传:
默认值,启用文件 messages.properties,结果为 “账号 Jock 不存在”。 - Accept-Language=zh-CN:
启用文件 messages_zh_CN.properties,结果为 “账号 Jock 不存在”。 - Accept-Language=zh-TW:
启用文件 messages_zh_TW.properties,结果为 “帳號 Jock 不存在”。 - Accept-Language=en-US:
启用文件 messages_en_US.properties,结果为 “Account Jock does not exist”。
根据结果来看,达到了我们的预期结果。
4. messages文件位置
示例中,多语言的文件是写在 resources 目录中,文件命名也都是 messages开头的,但其实是可以自定义的。
假设我们将上述的文件放在 resources/i18n 目录下,我们可以通过在 application.propreties 文件中配置生效:
spring.messages.basename=i18n/messages
3. 源码阅读
我们简单看看 Spring 框架中是怎么封装的。
3.1. MessageSourceAutoConfiguration
在前面的示例代码中,最核心的方法是出自于 MessageSource,我们可以直接注入 MessageSource 的 Bean,说明在容器初始化时,自动装载了这个Bean,代码就在这个类中。
MessageSourceAutoConfiguration.java
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME, search = SearchStrategy.CURRENT)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@Conditional(ResourceBundleCondition.class)
@EnableConfigurationProperties
public class MessageSourceAutoConfiguration {
private static final Resource[] NO_RESOURCES = {};
@Bean
@ConfigurationProperties(prefix = "spring.messages")
public MessageSourceProperties messageSourceProperties() {
return new MessageSourceProperties();
}
@Bean
public MessageSource messageSource(MessageSourceProperties properties) {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
if (StringUtils.hasText(properties.getBasename())) {
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;
}
protected static class ResourceBundleCondition extends SpringBootCondition {
private static ConcurrentReferenceHashMap<String, ConditionOutcome> cache = new ConcurrentReferenceHashMap<>();
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
String basename = context.getEnvironment().getProperty("spring.messages.basename", "messages");
ConditionOutcome outcome = cache.get(basename);
if (outcome == null) {
outcome = getMatchOutcomeForBasename(context, basename);
cache.put(basename, outcome);
}
return outcome;
}
private ConditionOutcome getMatchOutcomeForBasename(ConditionContext context, String basename) {
ConditionMessage.Builder message = ConditionMessage.forCondition("ResourceBundle");
for (String name : StringUtils.commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(basename))) {
for (Resource resource : getResources(context.getClassLoader(), name)) {
if (resource.exists()) {
return ConditionOutcome.match(message.found("bundle").items(resource));
}
}
}
return ConditionOutcome.noMatch(message.didNotFind("bundle with basename " + basename).atAll());
}
private Resource[] getResources(ClassLoader classLoader, String name) {
String target = name.replace('.', '/');
try {
return new PathMatchingResourcePatternResolver(classLoader)
.getResources("classpath*:" + target + ".properties");
}
catch (Exception ex) {
return NO_RESOURCES;
}
}
}
}
在这个类的代码中,我们可以分成3个部分:
MessageSourceProperties
:读配置文件中的参数,生成对应配置类的 Bean。MessageSource
:基于 MessageSourceProperties 配置类中的值,初始化 MessageSource 实例,生成对应的 Bean。ResourceBundleCondition
:和 @Condition 配合,判断 MessageSourceAutoConfiguration 当前配置类是否装载进容器。
3.1.1. MessageSourceProperties
该类对应配置文件的前缀是:
@ConfigurationProperties(prefix = "spring.messages")
MessageSourceProperties.java
public class MessageSourceProperties {
private String basename = "messages";
private Charset encoding = StandardCharsets.UTF_8;
@DurationUnit(ChronoUnit.SECONDS)
private Duration cacheDuration;
private boolean fallbackToSystemLocale = true;
private boolean alwaysUseMessageFormat = false;
private boolean useCodeAsDefaultMessage = false;
... ...
}
还记得示例代码中,messages.properties 默认在 resources 的根目录下,而且默认文件名是 messages 。如果需要修改,可以在配置文件中修改 spring.messages.basename
的值。
就是源自于这里的配置类代码,而该属性的默认值为 messages
。
3.1.2. MessageSource
这里的代码只是创建 MessageSource 的对象,基于 MessageSourceProperties 对象的属性值,对 MessageSource 对象的属性进行赋值,并注册 Bean。
有关这个类的方法,后续再介绍。
3.1.3. ResourceBundleCondition
在当前配置类上有 @Conditional(ResourceBundleCondition.class)
的注解。
1. @Conditional
@Conditional.java
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Conditional {
Class<? extends Condition>[] value();
}
@Conditional
注解是 Spring-context 模块提供了一个注解,该注解的作用是可以根据一定的条件来使 @Configuration 注解标记的配置类是否生效。
value()
值为实现 Condition 接口的一个 Class,Spring 框架根据实现Conditon 接口的 matches 方法返回true或者false来做以下操作,如果matches方法返回true,那么该配置类会被 Spring 扫描到容器里, 如果为false,那么 Spring 框架会自动跳过该配置类不进行扫描装配。
2. ResourceBundleCondition
该注解中,value 为 ResourceBundleCondition.class,按要求是实现了 Condition 接口。该类的定义也在当前类中,是个静态内部类。
通过阅读代码不难理解,方法中还是通过获取 spring.messages.basename
,判断是否有定义多语言的配置文件,当存在配置文件时 match 为 true,否则为 false。
即不存在多语言配置文件时,当前 MessageSourceProperties 配置类不会加载为容器中的 Bean,配置类中 @Bean 修饰的那些 Bean,同样也都不会被加载。
3.2. MessageSource
还是在前面的示例代码中,我们做多语言翻译的,是调用 MessageSource 接口的方法。
MessageSource.java
public interface MessageSource {
@Nullable
String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}
可以看到这个接口只有3个重载的 getMessage
方法,实现类我们可以看 AbstractMessageSource
。下面拿 MessageSource 的一个方法追溯下去:
1. MessageSource.getMessage
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
2. AbstractMessageSource
AbstractMessageSource 类中对应该方法的实现方法为:
public final String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
String msg = this.getMessageInternal(code, args, locale);
if (msg != null) {
return msg;
} else {
String fallback = this.getDefaultMessage(code);
if (fallback != null) {
return fallback;
} else {
throw new NoSuchMessageException(code, locale);
}
}
}
可以看到调用的核心方法是 this.getMessageInternal(code, args, locale);
@Nullable
protected String getMessageInternal(@Nullable String code, @Nullable Object[] args, @Nullable Locale locale) {
if (code == null) {
return null;
} else {
if (locale == null) {
locale = Locale.getDefault();
}
Object[] argsToUse = args;
if (!this.isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
String message = this.resolveCodeWithoutArguments(code, locale);
if (message != null) {
return message;
}
} else {
argsToUse = this.resolveArguments(args, locale);
MessageFormat messageFormat = this.resolveCode(code, locale);
if (messageFormat != null) {
synchronized(messageFormat) {
return messageFormat.format(argsToUse);
}
}
}
Properties commonMessages = this.getCommonMessages();
if (commonMessages != null) {
String commonMessage = commonMessages.getProperty(code);
if (commonMessage != null) {
return this.formatMessage(commonMessage, args, locale);
}
}
return this.getMessageFromParent(code, argsToUse, locale);
}
}
这个方法里面,当有传入参数时,调用的是:
- this.resolveCode(code, locale);
- messageFormat.format(argsToUse);
this.resolveCode(code, locale);
是个接口,我们看看 ResourceBundleMessageSource 中的实现方法。
3. ResourceBundleMessageSource
ResourceBundleMessageSource.(String code, Locale locale);
@Nullable
protected MessageFormat resolveCode(String code, Locale locale) {
Set<String> basenames = this.getBasenameSet();
Iterator var4 = basenames.iterator();
while(var4.hasNext()) {
String basename = (String)var4.next();
ResourceBundle bundle = this.getResourceBundle(basename, locale);
if (bundle != null) {
MessageFormat messageFormat = this.getMessageFormat(bundle, code, locale);
if (messageFormat != null) {
return messageFormat;
}
}
}
return null;
}
其中2个核心的方法调用是
ResourceBundle bundle = this.getResourceBundle(basename, locale);
MessageFormat messageFormat = this.getMessageFormat(bundle, code, locale);
this.getResourceBundle(basename, locale);
@Nullable
protected ResourceBundle getResourceBundle(String basename, Locale locale) {
if (this.getCacheMillis() >= 0L) {
return this.doGetBundle(basename, locale);
} else {
Map<Locale, ResourceBundle> localeMap = (Map)this.cachedResourceBundles.get(basename);
ResourceBundle bundle;
if (localeMap != null) {
bundle = (ResourceBundle)localeMap.get(locale);
if (bundle != null) {
return bundle;
}
}
try {
bundle = this.doGetBundle(basename, locale);
if (localeMap == null) {
localeMap = (Map)this.cachedResourceBundles.computeIfAbsent(basename, (bn) -> {
return new ConcurrentHashMap();
});
}
localeMap.put(locale, bundle);
return bundle;
} catch (MissingResourceException var5) {
if (this.logger.isWarnEnabled()) {
this.logger.warn("ResourceBundle [" + basename + "] not found for MessageSource: " + var5.getMessage());
}
return null;
}
}
}
this.getMessageFormat(bundle, code, locale);
@Nullable
protected MessageFormat getMessageFormat(ResourceBundle bundle, String code, Locale locale) throws MissingResourceException {
Map<String, Map<Locale, MessageFormat>> codeMap = (Map)this.cachedBundleMessageFormats.get(bundle);
Map<Locale, MessageFormat> localeMap = null;
if (codeMap != null) {
localeMap = (Map)codeMap.get(code);
if (localeMap != null) {
MessageFormat result = (MessageFormat)localeMap.get(locale);
if (result != null) {
return result;
}
}
}
String msg = this.getStringOrNull(bundle, code);
if (msg != null) {
if (codeMap == null) {
codeMap = (Map)this.cachedBundleMessageFormats.computeIfAbsent(bundle, (b) -> {
return new ConcurrentHashMap();
});
}
if (localeMap == null) {
localeMap = (Map)codeMap.computeIfAbsent(code, (c) -> {
return new ConcurrentHashMap();
});
}
MessageFormat result = this.createMessageFormat(msg, locale);
localeMap.put(locale, result);
return result;
} else {
return null;
}
}
其实还可以继续追溯下去,但后续的代码就不列出来了。通过这部分代码,可以梳理出大致的逻辑:基于 basename
读取多语言配置文件值,将各语言(Locale)、各个 code 和 value 加载进内存(map),并设置缓存。后续可以基于 code 和 Locale 拿对应语言的值了。
4. 单个多语言文件方案
官方的方案设计上,多语言 code 的值是有两处分类:
- Locale语种分类:通过 basename 中定义的不同语种的配置文件,做语种上的分类,如:messages_zh_CN.properties、messages_en_US.properties,分别对应 Locale.CHINA、Locale.US。
- code 分类:在每个语种的多语言文件中,会基于 key-value,设置不同code的值。
但我们其实也可以尝试另一种方案,只创建一个多语言文件,将不同语种都存在这一个文件中,只不过不同语种的code生成规则不同。
如下示例。
1. controller
@RestController
@RequestMapping("")
public class DemoController {
private final static String CUSTOM_ERROR_KEY_USERNAME_NOT_EXISTS = "i18n.%s.user.username.not.exists";
private final MessageSource messageSource;
public DemoController(MessageSource messageSource) {
this.messageSource = messageSource;
}
private final Predicate<String> checkUsernamePredicate = (String username) -> {
if (Objects.isNull(username)) {
return false;
}
return username.startsWith("T");
};
@GetMapping("bye")
public String sayBye(@RequestParam("username") String username) {
if (checkUsernamePredicate.test(username)) {
return "Bye!" + username;
}
Locale locale=LocaleContextHolder.getLocale();
String code = String.format(CUSTOM_ERROR_KEY_USERNAME_NOT_EXISTS, locale.getCountry().toLowerCase());
return messageSource.getMessage(code, new String[]{username}, locale);
}
}
2. messages 文件
我们在 resources 目录下创建1个文件:
messages.properties
i18n.cn.user.username.not.exists=账号 {0} 不存在 i18n.us.user.username.not.exists=Account {0} does not exist i18n.tw.user.username.not.exists=帳號 {0} 不存在
3. 测试
测试接口,传入参数 username=Jock,但 Header中带的 Accept-Language 值不同,对应的结果分别是:
- Accept-Language 不传:
默认值,启用文件 messages.properties,结果为 “账号 Jock 不存在”。 - Accept-Language=zh-CN:
启用文件 messages_zh_CN.properties,结果为 “账号 Jock 不存在”。 - Accept-Language=zh-TW:
启用文件 messages_zh_TW.properties,结果为 “帳號 Jock 不存在”。 - Accept-Language=en-US:
启用文件 messages_en_US.properties,结果为 “Account Jock does not exist”。
根据结果来看,同样也达到了我们的预期结果。
5. 方案思考
5.1. 配置文件动态修改
一个完整的国际化多语言方案,不光只是能读多语言的值,还应该包括如何修改。
上述方案中,所有语言的值都是写在 properties 配置文件中的,那么可能不能每次维护,都从配置文件里面改吧。
很多项目都在使用 nacos、apollo 之类的配置中心,我们可以基于配置中心统一修改多语言的配置值。
当然项目上甚至可能开发了一套维护多语言的界面系统, nacos 这类配置中心也提供开放 SDK 接口,供我们系统的后端服务做多语言值同步。
5.2. 多语言其他方案
思考一下 Spring 实现的这一套多语言方案,整体设计上十分简单:
- 基于 语种、多语言code、值,维护一套 key-value 容器。
- 根据 api 请求头,拿到当前的语种。
- 基于当前语种、多语言code,去 key-value 容器中拿值。
基于这个需求,我想在校的学生也是可以设计好表结构,实现这一套 API的。
所以说并不局限于Spring的这套框架,如果我不用它,基于数据库设计一个多语言系统,其实也没啥问题。如果担心性能,大不了在 key-value 容器的读取时加上缓存。
5.3. 翻译
在了解 Spring 框架的国际化多语言之前,我以为它能实现自动翻译。但是没想到每个单词每个语种的翻译,都需要我们自己去维护。其实也能理解,毕竟很多系统是有行业内专业名词的,需要自行维护。
但如果是可以使用通用翻译的话,那么可以自己再开发一个程序,通过调用有道翻译、百度翻译之类的API,替我们做全局的多语言翻译。然后针对个别特殊的名词,再自定义修改翻译内容,最终推送到配置中心的文件中。
5.4. 应用
在项目中,国际化多语言应该是处于底层基础能力。因为如果产品面向国际化,几乎所有业务的微服务都有多语言翻译的需求。
另外,像项目上通常在 API Response 里面全局封装 Error Message 内容返回,也应该是基于多语言的。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。