從原始碼MessageSource的三個實現出發實戰spring·i18n國際化

2023-03-03 18:01:26

1.前言

網際網路業務出海,將已有的業務Copy to Global,並且開始對各個國家精細化,本土化的運營。對於開發人員來說,國際化很重要,在實際專案中所要承擔的職責是按照客戶指定的語言讓伺服器端返回相應語言的內容。本文基於spring的國際化支援,實現國際化的開箱即用,靜態檔案設定重新整理生效以及全域性異常國際化處理。

2.spring·i18n

ApplicationContext介面繼承了MessageSource介面,因此對外提供了internationalization(i18n)國際化的能力。如下就是常用的國際化中訊息轉換的三個方法:

public interface MessageSource {
    //通過code檢索對應Locale的訊息,如果找不到就使用defaultMessage作為預設值
    @Nullable
    String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
    //通過code檢索對應Locale的訊息,如果找不到會丟擲異常,NoSuchMessageException
    String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
    //和上面的方法其實本質是一樣的,只是通過resolvable去包裝了code,argument,defaultMessage。
    String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}
​

在spring初始化之後,如果能在容器中找到messageSource的bean,會使用它進行訊息解析轉換。如果找不到,spring自己會範例化一個DelegatingMessageSource,不過這個物件中所有的方法都是空實現,還是需要有具體的實現去做事情。

MessageSource介面有三個主要的實現類:

ResourceBundleMessageSourceReloadableResourceBundleMessageSourceStaticMessageSource

3.StaticMessageSource

3.1 簡單使用

StaticMessageSource,靜態記憶體訊息源,使用的比較少,他主要通過編碼的形式新增國際化對映對。可以在專案啟動時,手動注入一個StaticMessageSource

@Bean
StaticMessageSource messageSource(){
    StaticMessageSource messageSource = new StaticMessageSource();
    messageSource.addMessage("test1",Locale.CHINESE,"{0} 開始測試");
    messageSource.addMessage("test1",Locale.ENGLISH,"{0} start");
    return messageSource;
}

3.2 AOP動態化從DB中載入國際化設定

自定義一個MineStaticMessageSource,藉助StaticMessageSource的可編碼能力,可以簡單實現從資料庫中載入所有的設定資訊,並且注入到國際化設定中生效。如下:

專案啟動時就從DB中獲取所有的國際化設定資訊,組裝好後全部注入到MineStaticMessageSource中。

@Component
public class MineStaticMessageSource extends  StaticMessageSource implements InitializingBean {
​
    @Autowired
   private StaticMessageService staticMessageService;
    @Override
    public void afterPropertiesSet() throws Exception {
        List<StaticMessageDTO> staticMessages= staticMessageService.all();
        for (StaticMessageDTO staticMessage : staticMessages) {
            addMessage(staticMessage.getCode(),staticMessage.getLocale(),staticMessage.getMessage());
        }
    }
}

如何實現資料庫更改並動態感知重新整理呢?那就要在資料庫中設定修改時能感知到,並且通知到自定義的這個訊息物件去重新初始化國際化設定。有如下方案經供參考:

  • 通過AOP切面,攔截所有修改(增刪改)國際化設定的方法,在資料入庫成功之後,通過spring自帶的事件機制進行通知,可以使用@AfterReturning環繞。並針對不同的code進行重新組裝資料。

  • 實現上彎彎繞繞的,需要做很多編碼實現。而且需要考慮事務問題,異常問題。所有的資料都在StaticMessageSource的國際化map中,實際上我們並不能去刪除一個國際化設定,使用以下的addMessage增改設定是沒有問題的。

    private final Map<String, Map<Locale, MessageHolder>> messageMap = new HashMap<>();
    ​
    public void addMessage(String code, Locale locale, String msg) {
        Assert.notNull(code, "Code must not be null");
        Assert.notNull(locale, "Locale must not be null");
        Assert.notNull(msg, "Message must not be null");
        this.messageMap.computeIfAbsent(code, key -> new HashMap<>(4)).put(locale, new MessageHolder(msg, locale));
        if (logger.isDebugEnabled()) {
            logger.debug("Added message [" + msg + "] for code [" + code + "] and Locale [" + locale + "]");
        }
    }
    

4.ResourceBundleMessageSource

ResourceBundleMessageSource,資源包訊息源。通過在專案的classpath中定義多個filename.properties,然後在建立ResourceBundleMessageSource時將定義的檔名都注入到其中的basenameSet屬性中。專案啟動就可以把檔案中的設定讀取翻譯展示。

4.1 簡單使用

建立ResourceBundleMessageSource並注入到spring容器中,

@Bean
ResourceBundleMessageSource messageSource(){
    ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
    messageSource.setBasenames("test-i18n");
    return messageSource;
}

建立test-i18n.properties檔案:

test.message=hello,world!

測試成功:

@RequestMapping("/get")
public String get(String code, HttpServletRequest request) {
    return messageSource.getMessage(code,null,request.getLocale());
}
​
//返回值 hello,world!

4.2 原始碼解析·熱載入靜態檔案

ResourceBundleMessageSource對於訊息的解析處理時,對於Basenames中的多個檔案會依次建立對應的ResourceBundle,並根據code返回對應的message。做一個實驗,專案啟動之後,對設定的靜態檔案中的設定熱修改,再請求一次,值會發生變化嗎?

不會。因為ResourceBundleMessageSource中有快取機制,對於前文說的建立的ResourceBundle會根據Basename進行快取,系統啟動之後,就快取了所有的ResourceBundle。快取結構是:Basename中包含<Locate,ResourceBundle>

那麼,如何實現動態載入修改過的靜態檔案呢?從原始碼中我們可以看到:

private final Map<String, Map<Locale, ResourceBundle>> cachedResourceBundles =
            new ConcurrentHashMap<>();
​
if (getCacheMillis() >= 0) {
    // Fresh ResourceBundle.getBundle call in order to let ResourceBundle
    // do its native caching, at the expense of more extensive lookup steps.
    return doGetBundle(basename, locale);
}
else {
    // Cache forever: prefer locale cache over repeated getBundle calls.
    Map<Locale, ResourceBundle> localeMap = this.cachedResourceBundles.get(basename);
}

有個屬cacheMillis性控制了是否會走快取,當cacheMillis大於0時,每次都不會走快取,重新生成ResourceBundle,那麼最基本的優化點就是如下了。

@Bean
    ResourceBundleMessageSource messageSource(){
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasenames("test-i18n");
        messageSource.setCacheSeconds(10);
        return messageSource;
    }

實現的效果是每次進入快取判斷分支時都會不走快取,重新生成ResourceBundle,也就實現了動態載入靜態檔案的效果。

4.3 不同語言的國際化設定

以上只是通過ResourceBundle讀取了properties檔案,並解析message返回。實際專案使用中會根據各個國家,各個語言版本進行單獨的設定,做到對外輸出的國際化。比如,目前公司業務分佈在中國,日本,菲律賓,一套後端服務要做到返回資料的國際化,就需要按照一定的格式去設定。命名規範:自定義名_語言程式碼_國別程式碼.properties。比如:

test-i18n_zh_CN.properties
test-i18n_ja_JP.properties
test-i18n_en_PH.properties

值得注意的是:設定正確的編碼,banseName為字首。test-i18n.properties為基礎類別設定,在程式碼中實際上是ResourceBundle的父類別,如果某個國家語言設定中不存在某個code,在父類別中存在,那麼也是可以正常獲取值的。

@Bean
ResourceBundleMessageSource messageSource(){
    ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
    messageSource.setBasenames("test-i18n");
    messageSource.setCacheMillis(1000L);
    messageSource.setDefaultEncoding("UTF-8");
    return messageSource;
}

5.ReloadableResourceBundleMessageSource

再聊聊ReloadableResourceBundleMessageSource,相比於上文的ResourceBundleMessageSource,有以下變化:

  • 載入資源的方式不同:ResourceBundleMessageSource通過 JDK 提供的 ResourceBundle 載入資原始檔;ReloadableResourceBundleMessageSource通過 PropertiesPersister 載入資源,支援 xmlproperties 兩個格式,優先載入 properties 格式的檔案。如果同時存在 properties 和 xml 的檔案,會只載入 properties 的內容;
  • 靜態檔案的熱載入方式發生了變化,cacheMillis引數作用發生了變化。

5.1 簡單使用

建立ReloadableResourceBundleMessageSource並注入到spring容器中,

@Bean
ReloadableResourceBundleMessageSource messageSource(){
    ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
    messageSource.setBasenames("classpath:test-i18n");
    messageSource.setDefaultEncoding("UTF-8");
    return messageSource;
}

建立test-i18n_zh_CN.properties檔案:

test.message=你好,世界!

測試成功:

@RequestMapping("/get")
public String get(String code, HttpServletRequest request) {
    return messageSource.getMessage(code,null,request.getLocale());
}
​
//返回值 你好,世界!

5.2 原始碼解析·不一樣的快取引數

首先我們看一下快取部分的程式碼:

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;
            }
        }
    }
}

對於ReloadableResourceBundleMessageSource,設定messageSource.setCacheSeconds(10);的效果和前文說的ResourceBundleMessageSource的快取控制條件相同,只有設定為<0時[預設值為-1],才會進入快取流程,而大於0則走向了檔案載入&有條件重新整理的流程。

@Bean
ReloadableResourceBundleMessageSource messageSource(){
    ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
    messageSource.setBasenames("classpath:test-i18n");
    messageSource.setDefaultEncoding("UTF-8");
    return messageSource;
}

不設定的話預設值為-1。並且有意思的是,因為是採用PropertiesPersister進行檔案的解析,所以快取的資料來源就的國際化組態檔中的key-value鍵值對,根據locale去讀取所有的檔名,並將所有的key-value鍵值對全部都快取到記憶體中的properties。同時使用locale進行路由不同的PropertiesHolder

後續每次獲取message的時候,都會從這個大properties[merged properties]中嘗試獲取,找得到就返回,找不到就拋異常。

//快取各個語言的mergedHolder
PropertiesHolder mergedHolder = this.cachedMergedProperties.get(locale);
//根據設定去讀取所有的檔名
List<String> filenames = calculateAllFilenames(basenames[i], locale);
//從快取的properties中讀取code對應的設定
String result = propHolder.getProperty(code);
if (result != null) {
    return result;
}

cacheMillis引數和前文ResourceBundleMessageSource不同點:

  • ResourceBundleMessageSourcecacheMillis只做了一件事,就是粗粒度地控制了是否走快取流程,並且對於本地靜態檔案的重新整理是每一次都會重新整理。
  • ReloadableResourceBundleMessageSourcecacheMillis多了另一個職責-超時重新整理靜態檔案,當不走快取流程時,會通過比對上次重新整理時間和[當前時間-cacheMillis]的大小去選擇是否重新重新整理原生的靜態檔案設定到記憶體中。

5.3 原始碼解析·雙重快取·重新整理的奧義

從上文可知,設定messageSource.setCacheSeconds(10);

控制快取時間為10s,ReloadableResourceBundleMessageSource便具備了超時重新整理的能力。

以下,originalTimestamp是上次properties重新整理的時間戳,getCacheMillis()獲取的是cacheMillis,目前我們的設定是10s,以下程式碼的判斷很清晰了,如果重新整理時間是在【當前時間減去快取控制時間】之後,那麼就直接使用原來的propHolder,不做重新整理操作。

if (propHolder != null) {
    originalTimestamp = propHolder.getRefreshTimestamp();
    if (originalTimestamp == -1 || originalTimestamp > System.currentTimeMillis() - getCacheMillis()) {
        // Up to date
        return propHolder;
    }
}

對於不走快取流程的分支,其中這裡也有一個快取。這裡的快取是根據所有的國際化組態檔名作為key的快取,而之前是通過Locate作為key進行快取,這是最大的區別。這樣做的好處就是,可以做到按檔案進行重新整理。

PropertiesHolder propHolder = this.cachedProperties.get(filename);

原始碼閱讀中,一些小的技術細節也值得我們去品味,比如,對於每一個檔案持有物件propHolder內部都有一個ReentrantLock,在多執行緒環境下,也能保證只有一個執行緒去進行檔案讀寫重新整理。這就保證了費時的操作可以儘可能地由單執行緒完成。

private final ReentrantLock refreshLock = new ReentrantLock();
​
propHolder.refreshLock.lock();
​
try {
    PropertiesHolder existingHolder = this.cachedProperties.get(filename);
    if (existingHolder != null && existingHolder.getRefreshTimestamp() > originalTimestamp) {
        return existingHolder;
    }
    return refreshProperties(filename, propHolder);
}
finally {
    propHolder.refreshLock.unlock();
}

對於需要重新整理的key,呼叫refreshProperties(filename, propHolder);完成重新整理,重新整理操作很簡單,從類路徑下讀取對應檔名的靜態檔案,並裝載到記憶體中的properties中。同時設定檔案最後的更新時間lastModified到propHolder中。

Properties props = loadProperties(resource, filename);
propHolder = new PropertiesHolder(props, fileTimestamp);

並且可以看到,設定本次重新整理的時間戳,重新建立新的propHolder,並設定到快取結構cachedProperties中去,完成本次的重新整理。

propHolder.setRefreshTimestamp(refreshTimestamp);
this.cachedProperties.put(filename, propHolder);

以上,完成的效果就是:對於國際化的設定,當獲取message時,如果本地靜態檔案修改之後,只要超過10秒就會重新整理重新載入最新的設定資訊到快取中。

6.全域性例外處理的國際化設定

業務對外跑出的異常,是國際化轉換最重要的出口處。對於全域性例外處理的方案老生常談了。只需要使用幾個註解就可以勝任。

@Slf4j
@ControllerAdvice
public class RestExceptionHandler {
​
    @ExceptionHandler(value = BaseBizException.class)
    public CommonResult<Object> handle(BaseBizException e) {
        log.error("bizException", e);
        return CommonResult.buildError(e.getErrorCode(), e.getErrorMsg());
    }
}
​

那麼如何結合以上我們的i18n的messageSource達成國際化轉換呢?只需要稍稍改造就能完成。

  1. 全域性例外處理類中注入messageSource
  2. 業務例外處理方法新增Locale引數,他是國際化轉換的路由因子。
  3. 使用messageSourcegetMessage做國際化翻譯,其中我們也可以把引數都帶進來,這樣就能做到引數化的國際化翻譯。
  4. 最後就是吐出去,給親愛的使用者了。
@Autowired
MessageSource messageSource;
​
@ExceptionHandler(value = BaseBizException.class)
public CommonResult<Object> handleAccessDeniedException(BaseBizException e, HttpServletRequest request,
                                                               Locale locale) {
    log.error("bizException", e);
    String errorMessage = messageSource.getMessage(e.getMessage(), e.getArgs(), locale);
    return CommonResult.buildError(e.getErrorCode(),errorMessage);
}

7.後續的思考

通過本地國際化語言靜態檔案可以實現多個語言的設定,並且配合快取和檔案重新整理機制也能做到系統執行中的熱更新。但是,現實中,我們很多服務都做了微服務部署,一個系統有多個範例。那麼這種檔案的形式就有了挑戰。要麼一個個去改伺服器上的檔案,要麼就是通過一些統一掛載盤的形式去實現檔案統一修改,但這些都不是最優解,還容易出錯。再看看輪子們,現在有了nacos,有了apollo,這些設定中心都具有遠端設定,中心化儲存,可監聽(實時更新)的能力,我們可以考慮結合這些輪子去改造spring的i18n實現。