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 內容返回,也應該是基於多語言的。