和小夥伴們仔細梳理一下 Spring 國際化吧!從用法到原始碼!

張哥說技術發表於2023-11-22

來源:江南一點雨

國際化(Internationalization,簡稱 I18N)是指在 Java 應用程式中實現國際化的技術和方法。Java 提供了一套強大的國際化支援,使開發人員能夠編寫適應不同語言、地區和文化的應用程式。

Java 國際化的主要目標是使應用程式能夠在不同語言環境下執行,並提供相應的本地化體驗。以下是 Java 國際化的一些關鍵概念和元件:

  1. ResourceBundle:ResourceBundle 是 Java 國際化的核心元件之一,用於儲存本地化的文字和其他資源。它根據當前的 Locale(區域設定)載入相應的資原始檔,以提供與使用者語言和地區相匹配的內容。
  2. Locale:Locale 表示特定的語言和地區。Java 中的 Locale 物件包含了語言、國家/地區和可選的變體資訊。透過使用 Locale,可以確定應用程式應該使用哪種語言和地區的本地化資源。
  3. MessageFormat:MessageFormat 是 Java 提供的一種格式化訊息的工具類。它允許開發人員根據不同的語言和地區,將佔位符替換為相應的值,並進行靈活的訊息格式化。
  4. DateFormat 和 NumberFormat:Java 提供了 DateFormat 和 NumberFormat 類,用於在不同的語言和地區格式化日期、時間和數字。這些類可以根據 Locale 的不同,自動適應不同的語言和地區的格式規則。
  5. Properties 檔案:Properties 檔案是一種常見的配置檔案格式,用於儲存鍵值對。在 Java 國際化中,可以使用 Properties 檔案來儲存本地化文字和其他資源的鍵值對。

透過使用 Java 國際化的技術和元件,開發人員可以輕鬆地為 Java 應用程式提供多語言支援。應用程式可以根據使用者的 Locale 載入相應的資源,並根據不同的語言和地區提供本地化的使用者介面、日期時間格式、數字格式等。這樣,應用程式就能夠更好地適應全球使用者的需求,提供更好的使用者體驗。

1. Java 國際化

經過前面的介紹,小夥伴們已經瞭解到,Java 本身實際上已經提供了一整套的國際化方案,Spring 中當然也有國際化,Spring 中的國際化實際上就是對 Java 國際化的二次封裝。

所以我們先來了解下 Java 中的國際化怎麼玩。

1.1 基本用法

首先我們需要定義自己的資原始檔,資原始檔命名方式是:

  • 資源名_語言名稱_國家/地區名稱.properties

其中 _語言名稱_國家/地區名稱 可以省略,如果省略的話,這個檔案將作為預設的資原始檔。

現在假設我在 resources 目錄下建立如下三個資原始檔:

和小夥伴們仔細梳理一下 Spring 國際化吧!從用法到原始碼!

三個資原始檔的內容分別如下。

content.properties:

hello=預設內容

content_en_US.properties:

hello=hello world

content_zh_CN.properties:

hello=你好世界!

接下來我們看下 Java 程式碼如何載入。

Locale localeEn = new Locale("en""US");
Locale localeZh = new Locale("zh""CN");
ResourceBundle res = ResourceBundle.getBundle("content", localeZh);
String hello = res.getString("hello");
System.out.println("hello = " + hello);

首先我們先來定義 Locale 物件,這個 Locale 物件相當於定義本地環境,說明自己當前的語言環境和地區資訊,然後呼叫 ResourceBundle.getBundle 方法去載入配置檔案,該方法第一個引數就是資源的名稱,第二個引數則是當前的環境,載入完成之後,就可以從 res 變數中提取出來資料了。而且這個提取是根據當前系統環境提取的。

在上面的案例中,如果配置的 locale 實際上並不存在,那麼就會讀取 content.properties 檔案中的內容(相當於這就是預設的配置)。

1.2 Format

Java 中的國際化還提供了一些 Format 物件,用來格式化傳入的資源。

Format 主要有三類,分別是:

  1. MessageFormat:這個是字串格式化,可以在資源中配置一些佔位符,在提取的時候再將這些佔位符進行填充。
  2. DateFormat:這個是日期的格式化。
  3. NumberFormat:這個是數字的格式化。

不過這三個完全可以單獨當成工具類來使用,並非總是要結合 I18N 一起來用,實際上我們在日常的開發中,就會經常使用 DateFormat 的子類 SimpleDateFormat。

這裡我把三個分別舉個例子給大家演示下。

MessageFormat

對於這種,我們在定義資源的時候,可以使用佔位符,例如下面這樣:

hello=你好世界!
name=你好 {0},歡迎來到 {1}

那麼這裡 {0}{1} 就是佔位符,將來讀取到這個字串之後,可以給佔位符的位置填充資料。

Locale localeEn = new Locale("en""US");
Locale localeZh = new Locale("zh""CN");
ResourceBundle res = ResourceBundle.getBundle("content", localeZh);
MessageFormat format = new MessageFormat(res.getString("name"));
Object[] arguments = new Object[]{"javaboy""Spring原始碼學習課程"};
String s = format.format(arguments);
System.out.println("s = " + s);

那麼最終列印結果如下:

和小夥伴們仔細梳理一下 Spring 國際化吧!從用法到原始碼!

DateFormat

這個是根據當前環境資訊對日期進行格式化,中文的就格式化為中文日期,英文就格式化為英文日期:

Date date = new Date();
DateFormat df = DateFormat.getDateInstance(DateFormat.LONG, new Locale("zh""CN"));
DateFormat df2 = DateFormat.getDateInstance(DateFormat.LONG, new Locale("en""US"));
System.out.println(df.format(date));
System.out.println(df2.format(date));

引數 LONG 表示演示完整的日期資訊。

執行結果如下:

和小夥伴們仔細梳理一下 Spring 國際化吧!從用法到原始碼!

NumberFormat

數字格式化這塊比較典型的就是關於貨幣的格式化了,我們來看個例子:

Locale localeEn = new Locale("en""US");
Locale localeZh = new Locale("zh""CN");
NumberFormat formatZh = NumberFormat.getCurrencyInstance(localeZh);
NumberFormat formatEn = NumberFormat.getCurrencyInstance(localeEn);
double num = 199.99;
System.out.println("formatZh.format(num) = " + formatZh.format(num));
System.out.println("formatEn.format(num) = " + formatEn.format(num));

根據不同的 Locale 來獲取不同的貨幣格式化例項。

最終列印結果如下:

和小夥伴們仔細梳理一下 Spring 國際化吧!從用法到原始碼!

Java 中提供的國際化,差不多就這麼玩!

2. Spring 國際化

Spring 的國際化,實際上就是在 Java 國際化的基礎之上做了一些封裝,提供了一些新的能力。

2.1 實踐

先來一個簡單的案例來看看 Spring 中的國際化怎麼使用。

首先我們的資原始檔跟前面第一小節的一致,不再贅述。

Spring 中需要我們首先提供一個 MessageSource 例項,常用的 MessageSource 例項是 ReloadableResourceBundleMessageSource,這是一個具備自動重新整理能力的 MessageSource,即,使用者修改了配置檔案之後,在專案不重啟的情況下,新的配置就能生效。

配置方式很簡答,我們只需要將這個 Bean 註冊到 Spring 容器中:

@Bean
ReloadableResourceBundleMessageSource messageSource() {
    ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();
    source.setBasenames("content");
    return source;
}

這個 Bean 在註冊的時候,有一個固定要求:beanName 必須是 messageSource。為什麼是這樣,等松哥一會分析原始碼的時候大家就看明白了。為 bean 設定 basename,也就是配置檔案的基礎名稱。

接下來我們就可以使用了:

AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(JavaConfig.class);
MessageSource source = ctx.getBean(MessageSource.class);
Locale localeZh = new Locale("zh""CN");
String hello = source.getMessage("hello"null, localeZh);
System.out.println("hello = " + hello);
Object[] params = new Object[]{"javaboy","world"};
String name = source.getMessage("name", params, localeZh);
System.out.println("name = " + name);

當然,一般在應用中,我們會對獲取資原始檔內容的方法進行封裝再用。

封裝類似下面這樣:

@Component
public class MessageUtils implements MessageSourceAware {
    private static MessageSource messageSource;
    private static Locale currentLocale = new Locale("zh","CN");

    public static Locale getCurrentLocale() {
        return currentLocale;
    }

    public static void setCurrentLocale(Locale currentLocale) {
        MessageUtils.currentLocale = currentLocale;
    }

    public static String getMessage(String key) {
        return messageSource.getMessage(key, null, key, currentLocale);
    }

    public static String getMessage(String key, Locale locale) {
        return messageSource.getMessage(key, null, key, locale == null ? currentLocale : locale);
    }

    public static String getMessage(String key, String defaultMessage) {
        return messageSource.getMessage(key, null, defaultMessage == null ? key : defaultMessage, currentLocale);
    }

    public static String getMessage(String key, String defaultMessage, Locale locale) {
        return messageSource.getMessage(key, null, defaultMessage == null ? key : defaultMessage, locale == null ? currentLocale : locale);
    }

    public static String getMessage(String key, Object[] placeHolders) {
        return messageSource.getMessage(key, placeHolders, key, currentLocale);
    }

    public static String getMessage(String key, Object[] placeHolders, String defaultMessage) {
        return messageSource.getMessage(key, placeHolders, defaultMessage == null ? key : defaultMessage, currentLocale);
    }

    public static String getMessage(String key, Object[] placeHolders, Locale locale) {
        return messageSource.getMessage(key, placeHolders, key, locale == null ? currentLocale : locale);
    }

    public static String getMessage(String key, Object[] placeHolders, String defaultMessage, Locale locale) {
        return messageSource.getMessage(key, placeHolders, defaultMessage == null ? key : defaultMessage, locale == null ? currentLocale : locale);
    }

    @Override
    public void setMessageSource(MessageSource messageSource) {
        this.messageSource = messageSource;
    }
    
}

這個工具類實現了 MessageSourceAware 介面,這樣就可以拿到 messageSource 物件,然後將 getMessage 方法進行封裝。

用法其實並不難。

2.2 原理分析

再來看原理分析。

首先,在之前的分析中,小夥伴們知道,Spring 容器在初始化的時候,都會呼叫到 AbstractApplicationContext#refresh 方法,這個方法內部又呼叫了 initMessageSource 方法,沒錯,這個方法就是用來初始化 MessageSource 的,我們來看下這個方法的邏輯:

protected void initMessageSource() {
 ConfigurableListableBeanFactory beanFactory = getBeanFactory();
 if (beanFactory.containsLocalBean(MESSAGE_SOURCE_BEAN_NAME)) {
  this.messageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSource.class);
  // Make MessageSource aware of parent MessageSource.
  if (this.parent != null && this.messageSource instanceof HierarchicalMessageSource hms &&
    hms.getParentMessageSource() == null) {
   // Only set parent context as parent MessageSource if no parent MessageSource
   // registered already.
   hms.setParentMessageSource(getInternalParentMessageSource());
  }
 }
 else {
  // Use empty MessageSource to be able to accept getMessage calls.
  DelegatingMessageSource dms = new DelegatingMessageSource();
  dms.setParentMessageSource(getInternalParentMessageSource());
  this.messageSource = dms;
  beanFactory.registerSingleton(MESSAGE_SOURCE_BEAN_NAME, this.messageSource);
 }
}

這個方法首先判斷容器中是否存在一個名為 messageSource 的 Bean(MESSAGE_SOURCE_BEAN_NAME 常量的實際值就是 messageSource),如果存在,則檢查當前容器是否存在 parent,如果存在 parent 容器,那麼 parent 容器可能也會有一個 messageSource 物件,就把 parent 容器的 messageSource 物件設定給當前的 messageSource 作為 parentMessageSource。如果當前容器中不存在一個名為 messageSource 的 bean,那麼系統就會自動建立一個 DelegatingMessageSource 物件並註冊到 Spring 容器中。

從前面的介紹中大家就明白了為什麼我們向 Spring 容器中註冊 ReloadableResourceBundleMessageSource 的時候,beanName 必須是 messageSource,如果 beanName 不是 messageSource,那麼 Spring 容器就會自動建立另外一個 MessageSource 物件了,這就導致最終在獲取資源的時候出錯。

好啦,這是 MessageSource Bean 載入的方式。載入完成之後,這個 Bean 將來會被初始化,然後我們在需要的時候,呼叫這個 Bean 中的 getMessage 方法去獲取資源,現在我們就去分析 getMessage 方法。

松哥這裡的分析就以 ReloadableResourceBundleMessageSource 來展開,因為在整個 MessageSource 體系中,ReloadableResourceBundleMessageSource 是相對比較複雜的一個了,把這個搞懂了,剩下的幾個其實都很好懂了。

這個 getMessage 方法實際上是在 ReloadableResourceBundleMessageSource 的父類 AbstractMessageSource 中,換句話說,不同型別的 MessageSource 呼叫的 getMessage 方法是同一個:

@Override
public final String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) {
 String msg = getMessageInternal(code, args, locale);
 if (msg != null) {
  return msg;
 }
 if (defaultMessage == null) {
  return getDefaultMessage(code);
 }
 return renderDefaultMessage(defaultMessage, args, locale);
}

這個方法分兩步,首先呼叫 getMessageInternal 嘗試去解析出來 key 對應的 value,如果沒有找到合適的 value,那麼就會使用預設值。

我們先來看 getMessageInternal 方法:

@Nullable
protected String getMessageInternal(@Nullable String code, @Nullable Object[] args, @Nullable Locale locale) {
 if (code == null) {
  return null;
 }
 if (locale == null) {
  locale = Locale.getDefault();
 }
 Object[] argsToUse = args;
 if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
  String message = resolveCodeWithoutArguments(code, locale);
  if (message != null) {
   return message;
  }
 }
 else {
  argsToUse = resolveArguments(args, locale);
  MessageFormat messageFormat = resolveCode(code, locale);
  if (messageFormat != null) {
   synchronized (messageFormat) {
    return messageFormat.format(argsToUse);
   }
  }
 }
 Properties commonMessages = getCommonMessages();
 if (commonMessages != null) {
  String commonMessage = commonMessages.getProperty(code);
  if (commonMessage != null) {
   return formatMessage(commonMessage, args, locale);
  }
 }
 return getMessageFromParent(code, argsToUse, locale);
}

這個方法的邏輯還是比較簡單的,如果傳入的 code 為空就直接返回 null,如果傳入的 locale 為空,則獲取一個預設的 locale,這個預設的 locale 是根據當前作業系統的資訊獲取到的一個環境。

接下來,如果不想使用 MessageFormat 並且也沒有傳入 MessageFormat 所需要的引數,那麼就呼叫 resolveCodeWithoutArguments 方法去解析獲取到 Message 物件。如果是需要用到 MessageFormat 物件,那麼就呼叫 resolveCode 方法先去獲取到一個 MessageFormat,然後格式化資料並返回。

如果前面兩個都沒能返回,那麼就獲取到一個公共的資源,然後嘗試去解析 code,如果公共資源也還是沒能解析到,那麼就去 parent 中嘗試解析。

這裡涉及到的幾個方法,我們分別來看。

@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
 if (getCacheMillis() < 0) {
  PropertiesHolder propHolder = getMergedProperties(locale);
  String result = propHolder.getProperty(code);
  if (result != null) {
   return result;
  }
 }
 else {
  for (String basename : getBasenameSet()) {
   List<String> filenames = calculateAllFilenames(basename, locale);
   for (String filename : filenames) {
    PropertiesHolder propHolder = getProperties(filename);
    String result = propHolder.getProperty(code);
    if (result != null) {
     return result;
    }
   }
  }
 }
 return null;
}

resolveCodeWithoutArguments 方法在 ReloadableResourceBundleMessageSource 類中被重寫過了,所以這裡我們直接看重寫後的方法。

首先會去判斷快取時間是否小於 0,小於 0 表示不快取,那麼就去現場載入資料,否則就從快取中讀取資料。如果是現場載入資料的話,那麼就是根據傳入的 locale 物件呼叫 getMergedProperties 方法,獲取到 PropertiesHolder 物件,這個物件中封裝了讀取到的資原始檔以及資原始檔的時間戳(透過這個時間戳可以判斷資原始檔是否被修改過)。

getMergedProperties 方法的原始碼我這裡就不貼出來了,就大概和大家說一下大致的流程:首先會根據傳入的 basename 和 locale 定位出檔名,會定義出來多種檔名,例如傳入的 basename 是 content,locale 是 zh_CN,那麼最終生成的可能檔名有五種,如下:

  • content_zh_CN
  • content_zh
  • content_en_CN
  • content_en
  • content

這五種,前兩個是根據傳入的引數生成的,接下來兩個是根據當前系統資訊生成的檔名,最後一個則是預設的檔名,接下來就會根據這五個不同的檔名嘗試去載入配置檔案的,載入配置檔案的時候是倒著來的,就是先去查詢 content.properties 檔案,找到了,就把找到的資料存入到一個 Properties 中,然後繼續找上一個,上一個檔案要是存在,則將之也存入到 properties 配置檔案中,這樣,如果有重複的 key,後者就會覆蓋掉前者,換言之,上面這個檔名列表中,第一個檔名的優先順序是最高的,因為它裡邊的 key 如果跟前面的 key 重複了,會覆蓋掉前面的 key。

這就是 getMergedProperties 方法的大致邏輯。最後就從這個方法的返回值中,找到我們需要的資料返回。

這是不快取的情況,如果快取的話,那麼就去快取中讀取資料並返回。

大家看去快取中讀取資料的時候,首先也是呼叫 calculateAllFilenames 方法獲取到所有可能的檔名(獲取到的結果就是上面列出來的),然後根據檔名去獲取資料,這次獲取是順序獲取的,即先去查詢 content_zh_CN 這個檔案,存在的話就直接返回了,這也顯示了上面的列表中,從上往下優先順序依次降低。然後遍歷檔名,呼叫 getProperties 方法獲取對應的 properties 檔案,這個獲取的過程中,會去檢查檔案的時間戳,檢查資原始檔是否被修改過,如果被修改過就重新讀取,否則就使用之前已經讀取到的快取資料。

以上就是 resolveCodeWithoutArguments 方法的大概邏輯。

resolveCode 方法的邏輯實際上和 resolveCodeWithoutArguments 類似,唯一的區別在於,resolveCodeWithoutArguments 方法中,儲存資料的 Properties 實際上就是我們的資原始檔,而在 resolveCode 方法中,儲存資料的是一個雙層 Map,外層 Map key 是 code,即傳入的資源的 key,value 則是一個 Map,裡邊這個 Map 的 key 是 locale 物件,value 則是一個 MessageFormat 物件,查詢的時候根據使用者傳入的 code 先找到一個 Map,然後再根據使用者傳入的 locale 找到 MessageFormat,然後返回。其他邏輯基本上都是一致的了。

3.附錄

搜刮了一個語言簡稱表,分享給各位小夥伴:

語言簡稱
簡體中文(中國)zh_CN
繁體中文(台灣)zh_TW
繁體中文(中國香港)zh_HK
英語(中國香港)en_HK
英語(美國)en_US
英語(英國)en_GB
英語(全球)en_WW
英語(加拿大)en_CA
英語(澳大利亞)en_AU
英語(愛爾蘭)en_IE
英語(芬蘭)en_FI
芬蘭語(芬蘭)fi_FI
英語(丹麥)en_DK
丹麥語(丹麥)da_DK
英語(以色列)en_IL
希伯來語(以色列)he_IL
英語(南非)en_ZA
英語(印度)en_IN
英語(挪威)en_NO
英語(新加坡)en_SG
英語(紐西蘭)en_NZ
英語(印度尼西亞)en_ID
英語(菲律賓)en_PH
英語(泰國)en_TH
英語(馬來西亞)en_MY
英語(阿拉伯)en_XA
韓文(韓國)ko_KR
日語(日本)ja_JP
荷蘭語(荷蘭)nl_NL
荷蘭語(比利時)nl_BE
葡萄牙語(葡萄牙)pt_PT
葡萄牙語(巴西)pt_BR
法語(法國)fr_FR
法語(盧森堡)fr_LU
法語(瑞士)fr_CH
法語(比利時)fr_BE
法語(加拿大)fr_CA
西班牙語(拉丁美洲)es_LA
西班牙語(西班牙)es_ES
西班牙語(阿根廷)es_AR
西班牙語(美國)es_US
西班牙語(墨西哥)es_MX
西班牙語(哥倫比亞)es_CO
西班牙語(波多黎各)es_PR
德語(德國)de_DE
德語(奧地利)de_AT
德語(瑞士)de_CH
俄語(俄羅斯)ru_RU
義大利語(義大利)it_IT
希臘語(希臘)el_GR
挪威語(挪威)no_NO
匈牙利語(匈牙利)hu_HU
土耳其語(土耳其)tr_TR
捷克語(捷克共和國)cs_CZ
斯洛維尼亞語sl_SL
波蘭語(波蘭)pl_PL
瑞典語(瑞典)sv_SE
西班牙語(智利)es_CL

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70024923/viewspace-2996447/,如需轉載,請註明出處,否則將追究法律責任。

相關文章