國(guó)際化分析與處理

1 前言

在本地基于springboot的maven多模塊項(xiàng)目中,拆出 module1、module2、module3 三個(gè)子模塊,每個(gè)模塊都有自己的國(guó)際化資源,啟動(dòng)項(xiàng)目前添加配置 spring.messages.basename=i18n/messages

啟動(dòng)項(xiàng)目后驗(yàn)證國(guó)際化時(shí)發(fā)現(xiàn), 僅 service-main 下面的 .properties 文件被加載?;谂龅降倪@個(gè)問題,決定認(rèn)真看一下springboot的國(guó)際化信息處理過程。


--- project
  |--- module1
  |  |--- src/main/resources
  |     |--- i18n
  |        |--- messages.properties
  |--- module2
  |  |--- src/main/resources
  |     |--- i18n
  |        |--- messages.properties
  |--- module3
  |  |--- src/main/resources
  |     |--- i18n
  |        |--- messages.properties
  |--- service-main (項(xiàng)目入口)
     |--- src/main/resource
        |--- i18n
           |--- messages.properties

spring.messages.basename 對(duì)應(yīng) MessageSourceProperties 類中的 basename 屬性,有如下注釋:

以逗號(hào)分隔的基名列表 ( 本質(zhì)上是一個(gè)完全限定的類路徑位置 ),每個(gè)基名都遵循 ResourceBundle 約定,并對(duì)基于 / 的位置提供寬松的支持。如果它不包含包限定符 ( 例如org.mypackage ) 時(shí),它將從類路徑根解析。

可以看出,spring框架遵循JDK ResourceBundle 定義的標(biāo)準(zhǔn)。因此下面從 ResourceBundle 開始進(jìn)行分析。

2 帶著問題分析

問題1:spring.message.basename 可以填寫哪些格式的值(xx,xx,xx)

問題2:對(duì)基于maven的多模塊項(xiàng)目,是否支持將分散在多個(gè)子模塊中的國(guó)際化信息收集整合

問題3:國(guó)際化的處理離不開 資源定位與加載,我接觸到的開源框架中,都有什么樣的處理?

3 ResourceBundle

ResourceBundle 類的基本用法如下,下面根據(jù) getBundle() 方法入口逐步了解它加載國(guó)際化的流程。


public static void main(String[] args) {
    ResourceBundle bundle = ResourceBundle.getBundle("i18n/messages", Locale.getDefault());
    String name = bundle.getString("name");
}

3.1 資源定位與加載

3.1.1 流程圖

resourcebundle-process.png

3.1.2 流程圖說明

3.1.2.1 資源定位

3.1.2.1.1 fallback機(jī)制

如果基于英語語言區(qū)域的locale無法搜索到資源,可定義是否切換其他語言區(qū)域的locale繼續(xù)搜索可用資源。例如:
英語語言區(qū)域:Locale.ENGLISH("en")、Locale.UK("en_GB")、Locale.US("en_US")
中文語言區(qū)域:Locale.CHINESE("zh")Locale.CHINA("zh_CN")
請(qǐng)勿與章節(jié)3.1.2.1.2(確定候選locales范圍) 弄混。下一個(gè)章節(jié)是確定當(dāng)前語言區(qū)域內(nèi)的可選locale范圍。


private static ResourceBundle getBundleImpl(String baseName, Locale locale,
                                            ClassLoader loader, Control control) {
    // ...
    for (Locale targetLocale = locale;
         targetLocale != null;
         targetLocale = control.getFallbackLocale(baseName, targetLocale)) {
        // findBundle
    }
    // ...
}

public static class Control {
    public Locale getFallbackLocale(String baseName, Locale locale) {
        if (baseName == null) {
            throw new NullPointerException();
        }
        Locale defaultLocale = Locale.getDefault();
        return locale.equals(defaultLocale) ? null : defaultLocale;
    }
}

上述代碼展現(xiàn)的第一個(gè)方法 getBundleImpl 中存在一個(gè)for循環(huán),作用就是在指定的 locale 無法定位到國(guó)際化文件 ( i18n/messages_en_US.properties ),或者只能定位到基于 Locale.ROOT ( 即 i18n/messages.properties ) 的國(guó)際化文件時(shí),使用其他 locales 進(jìn)行再次的搜索。

默認(rèn)情況下,如果指定的 locale 搜索失敗,control.getFallbackLocale() 會(huì)選用系統(tǒng)默認(rèn)的 locale

如有需要,可實(shí)現(xiàn)自己的Control進(jìn)行定制化fallback處理流程,如下所示:

/**
 * @author gdzwk
 */
public class MyControl extends ResourceBundle.Control {
    /**
     * 如果基于zh的locale無法找到,則不再查找
     * 如果基于en的locale無法找到,則再次使用(zh_CN)進(jìn)行查找
     * 其余情況,使用系統(tǒng)默認(rèn)locale進(jìn)行查找
     *
     * 如果fallback得到的locale與當(dāng)前l(fā)ocale相同,則沒有再次查找的必要
     */
      @Override
    public Locale getFallbackLocale(String baseName, Locale locale) {
        if (baseName == null) {
            throw new NullPointerException();
        }
        Locale targetLocale;
        switch (locale.getLanguage()) {
            case "zh":
                targetLocale = null;
                break;
            case "en":
                targetLocale = Locale.CHINA;
                break;
            default:
                targetLocale = Locale.getDefault();
                break;
        }
        return locale.equals(targetLocale) ? null : targetLocale;
    }
}
3.1.2.1.2 確定候選locales范圍

建議查看 control.getCandidateLocales(baseName, locale) 方法的注釋部分,其中對(duì)確定候選locales范圍有詳細(xì)描述。

以下舉例子說明:

  1. 假設(shè)傳遞 baseName="i18n/messages",locale=Locale.CHINA ("zh", "CN"),最終返回的候選locales集合包含:

    locale.instance("zh_CN_#Hans"),        ---> 可能不包含
    locale.instance("zh_#Hans"),           ---> 可能不包含
    Locale.CHINA   ("zh_CN"),
    Locale.CHINESE ("zh"),
    Locale.ROOT    ("")                    ---> 每個(gè)范圍都會(huì)包含這個(gè)
    
    // 因此后續(xù)基于classpath的搜索可能如下:(使用classLoader.getResource(name)進(jìn)行驗(yàn)證)
    i18n/messages_zh_CN_#Hans.properties   ---> 可能不包含
    i18n/messages_zh_#Hans.properties      ---> 可能不包含
    i18n/messages_zh_CN.properties
    i18n/messages_zh.properties
    i18n/messages.properties
    
  2. 假設(shè)傳遞的 baseName="i18n/messages",locale=Locale.CHINESE ("zh"),最終返回的 List<Locale locales> 包含:

    locale.instance("zh_#Hans"),           ---> 可能不包含
    Locale.CHINESE ("zh"),
    Locale.ROOT    ("")                    ---> 每個(gè)范圍都會(huì)包含這個(gè)
    
    // 因此后續(xù)基于classpath的搜索可能如下:(使用classLoader.getResource(name)進(jìn)行驗(yàn)證)
    i18n/messages_zh_#Hans.properties      ---> 可能不包含
    i18n/messages_zh.properties
    i18n/messages.properties
    
3.1.2.1.3 倒序遍歷候選locales

采用倒序遍歷的原因,假設(shè)上一步得到的候選locales包括如下,均找到了對(duì)應(yīng)的國(guó)際化文件。在讀取某個(gè)key對(duì)應(yīng)的value時(shí),應(yīng)優(yōu)先選用 Locale.CHINA ("zh_CN") 對(duì)應(yīng)的文件內(nèi)容,除非找不到,才繼續(xù)讀取 Locale.CHINESE ("zh") 對(duì)應(yīng)的文件內(nèi)容。

第三次遍歷:Locale.CHINA  ("zh_CN")--> i18n/messages_zh_CN.properties : 包含鍵值對(duì) name=zh_CN
第二次遍歷:Locale.CHINESE("zh")   --> i18n/messages_zh.properties    : 包含鍵值對(duì) name=zh
第一次遍歷:Locale.ROOT   ("")     --> i18n/messsage.properties       : 包含鍵值對(duì) name=default
// 在查找的時(shí)候,優(yōu)先查詢當(dāng)前bundle的lookup集合,如果找不到,繼續(xù)查找parentBundle.lookup集合
最終返回的bundle對(duì)象: {
    lookup: keyValue集合,         --> 對(duì)應(yīng)i18n/messages_zh_CN.properties中找到的鍵值對(duì)
    parentBundle對(duì)象: {
        lookup: keyValue集合,     --> 對(duì)應(yīng)i18n/message_zh.properties中找到的鍵值對(duì)
        parentBundle對(duì)象: {
            lootup: keyValue集合, --> 對(duì)應(yīng)i18n/message.properties中找到的鍵值對(duì)
            parentBundle: null
        }
    }
}

下面展示其他情況的例子:

第三次遍歷:Locale.CHINA  ("zh_CN")--> i18n/messages_zh_CN.properties : 包含鍵值對(duì) name=zh_CN
第二次遍歷:Locale.CHINESE("zh")   --> i18n/messages_zh.properties    : 該文件不存在
第一次遍歷:Locale.ROOT   ("")     --> i18n/messsage.properties       : 包含鍵值對(duì) name=default
// 在查找的時(shí)候,優(yōu)先查詢當(dāng)前bundle的lookup集合,如果找不到,繼續(xù)查找parentBundle.lookup集合
最終返回的bundle對(duì)象: {
    lookup: keyValue集合,         --> 對(duì)應(yīng)i18n/messages_zh_CN.properties中找到的鍵值對(duì)
    parentBundle對(duì)象: {
        lookup: keyValue集合,     --> 對(duì)應(yīng)i18n/message.properties中找到的鍵值對(duì)
        parentBundle對(duì)象: null
    }
}
第三次遍歷:Locale.CHINA  ("zh_CN")--> i18n/messages_zh_CN.properties : 該文件不存在
第二次遍歷:Locale.CHINESE("zh")   --> i18n/messages_zh.properties    : 該文件不存在
第一次遍歷:Locale.ROOT   ("")     --> i18n/messsage.properties       : 包含鍵值對(duì) name=default
// 在查找的時(shí)候,優(yōu)先查詢當(dāng)前bundle的lookup集合,如果找不到,繼續(xù)查找parentBundle.lookup集合
最終返回的bundle對(duì)象: {
    lookup: keyValue集合,         --> 對(duì)應(yīng)i18n/message.properties中找到的鍵值對(duì)
    parentBundle對(duì)象: null
}
3.1.2.1.4 確定包名稱
  • Locale.CHINA = Locale.createConstant( lang: "zh", country: "CN" );
  • Locale.CHINESE = Locale.createConstant( lang: "zh", country: "" );
  • Locale.ROOT = Locale.createConstant( lang: "", country: "" );

上述例舉了Locale的結(jié)構(gòu)。需要搜索的包名稱由 control.toBundleName(baseName, locale) 方法確定,可根據(jù)需要定制。一般情況下包名構(gòu)造可簡(jiǎn)化為:

Locale.CHINA --> bundleName = {baseName}_{locale.lang}_{locale.country}
Locale.CHINESE --> bundleName = {baseName}_{locale.lang}       // locale.country為"",不添加
Locale.ROOT --> bundleName = {baseName}           // locale.lang、locale.country均為"",不添加

3.1.2.2 資源加載

通過章節(jié)3.1.2.1.4,可得知包名稱 bundleName。后續(xù)查找時(shí),control.newBundle() 方法會(huì)自動(dòng)加上 ".properties" 后綴拼湊出完整的classpath文件名稱。

最終的資源加載調(diào)用 classLoader.getResource(name) 方法。其中的name參數(shù)僅支持如下的格式。且只能拿到classpath中匹配到的第一個(gè)文件。

name = i18n/messages.properties      // 描述文件
name = com/demo/MessageZhCN.java     // 描述類(ResourceBundle可以加載類,但一般不會(huì)這么使用,因此文中沒具體描述這部分。流程圖中有簡(jiǎn)略說明)

代碼追溯到這里,對(duì)于章節(jié)1中描述的問題,心里有了基本的答案。后續(xù)在加上結(jié)合spring的分析,即可驗(yàn)證。
如果 spring.messages.basename=i18n/messages 作為 basename 參數(shù)直接傳遞給 ResourceBundle.getBundle(xx) 方法。由于Locale.default = (zh_CN),因此最終只會(huì)匹配到classpath中找到的第一個(gè) i18n/messages_zh_CN.propertiesi18n/messages_zh.propertiesi18n/messages.properties 文件。

3.2 總結(jié)

ResourceBundle 類中大量使用了模板設(shè)計(jì)模式,通過 ResourceBundle.Control 對(duì)國(guó)際化資源的定位與加載的全流程進(jìn)行定制化處理,十分靈活。

局限性:

  1. 默認(rèn)情況下,只能加載找到的第一個(gè)文件,存在一定的不確定性。且目前基于maven構(gòu)建的項(xiàng)目來說,模塊化是很常見的?;赾ontrol定制需要花一定的功夫。
  2. 提供的方法較為原始、底層。需要做大量的封裝處理。例如有如下的需求:
    • 基于 baseName=classpath*:i18n/messages 進(jìn)行搜索。需要改寫control.newBundle()
    • 拿到國(guó)際化信息后,能進(jìn)行進(jìn)一步渲染處理,例如:message=這是一個(gè){1},具體的值在調(diào)用時(shí)渲染。
    • 假設(shè)國(guó)際化文件不是來源于classpath,而是文件系統(tǒng)或網(wǎng)絡(luò),基于control的改寫難度更大。
      從ResourceBundle的資源定位和加載流程中,可以總結(jié)出一些步驟是國(guó)際化處理中的通用步驟:
  3. 加載的資源名稱由用戶指定,但具體文件的格式基本固定。Locale中有多個(gè)字段:language、region、。。 在最終構(gòu)造資源名稱時(shí),基本都是 {baseName}_{language}_{region}.properties
  4. 指定一個(gè)locale時(shí),應(yīng)該將 {baseName}_{language}.properties{baseName}.properties 文件內(nèi)容包含進(jìn)來。
    最終都是由URL定位具體的文件,然后通過inputStream/reader讀取到property對(duì)象中。
    ResourceBundle約定:( 語言環(huán)境解析規(guī)則、后備規(guī)則 )
  • 不指定文件拓展名 ( .properties ) 或語言代碼 ( _zh_CN ):

    合法:i18n/messages、META-INF/mymessages
    非法:i18n/messages_zh  --> 這會(huì)導(dǎo)致最終搜索的文件名稱為: i18n/messages_zh_zh.properties等
    

3.3 自定義實(shí)現(xiàn)

現(xiàn)在基于ResourceBundle提供的Control進(jìn)行定制開發(fā),使其能支持如下的解析:

// 搜索classpath下所有匹配的i18n/messages文件,并且對(duì)相同locale的文件內(nèi)容進(jìn)行合并處理,從而滿足基于maven構(gòu)建的多模塊項(xiàng)目國(guó)際化需求
// 如需支持例如 "classpath*:i18n/**/mymessages"等更復(fù)雜的匹配,還需進(jìn)一步改寫
ResourceBundle.getBundle("clsspath*:i18n/messages", new MyControl());

代碼實(shí)現(xiàn):

/**
 * @author gdzwk
 */
public class MyControl extends ResourceBundle.Control {
    private static final String ALL_CLASSPATH_URL_PERFIX = "classpath*:";
    private static final String PROPERTY_ENCODING = "UTF-8";
    @Override
    public ResourceBundle newBundle(String baseName, Locale locale, String format,
                                    ClassLoader classLoader, boolean reload)
            throws IllegalAccessException, InstantiationException, IOException {
        // 例如將classpath*:i18n/messages_zh.properties全放到一個(gè)集合中
        String bundleName = super.toBundleName(baseName, locale);
        final String resourceName = bundleName + ".properties";
        MyPropertyResourceBundle bundle = null;
        if (format.equals("java.class")) {
            // 不支持
            bundle = null;
        } else if (format.equals("java.properties")) {
            if (bundleName.startsWith(ALL_CLASSPATH_URL_PERFIX)) {
                bundle = this.getBundleFromAllClasspath(resourceName, classLoader, reload);
            } else {
                bundle = this.getBundleFromClasspath(resourceName, classLoader, reload);
            }
        }
        return bundle;
    }

    private MyPropertyResourceBundle getBundleFromAllClasspath(String resourceName,
                                                               ClassLoader classLoader,
                                                               boolean reload) throws IOException {
        resourceName = resourceName.substring(ALL_CLASSPATH_URL_PERFIX.length(), resourceName.length());
        Enumeration<URL> enumeration = classLoader.getResources(resourceName);
        Map<String, URL> urlMap = new HashMap<>(16);
        URL tempURL;
        while (enumeration.hasMoreElements()) {
            tempURL = enumeration.nextElement();
            urlMap.put(tempURL.toString(), tempURL);
        }
        if (urlMap.isEmpty()) {
            return null;
        }
        MyPropertyResourceBundle bundle = new MyPropertyResourceBundle();
        for (URL url : urlMap.values()) {
            bundle.combine(this.propertyFromURL(url, reload));
        }
        return bundle;
    }
    private MyPropertyResourceBundle getBundleFromClasspath(String resourceName,
                                                            ClassLoader classLoader,
                                                            final boolean reload) throws IOException {
        MyPropertyResourceBundle bundle = null;
        InputStream stream = null;
        try {
            stream = AccessController.doPrivileged(
                new PrivilegedExceptionAction<InputStream>() {
                    @Override
                    public InputStream run() throws IOException {
                        InputStream is = null;
                        if (reload) {
                            URL url = classLoader.getResource(resourceName);
                            if (url != null) {
                                URLConnection connection = url.openConnection();
                                if (connection != null) {
                                    // Disable caches to get fresh data for
                                    // reloading.
                                    connection.setUseCaches(false);
                                    is = connection.getInputStream();
                                }
                            }
                        } else {
                            is = classLoader.getResourceAsStream(resourceName);
                        }
                        return is;
                    }
                });
        } catch (PrivilegedActionException e) {
            throw (IOException) e.getException();
        }
        if (stream != null) {
            try {
                bundle = new MyPropertyResourceBundle(new InputStreamReader(stream, PROPERTY_ENCODING));
            } finally {
                stream.close();
            }
        }
        return bundle;
    }

    private MyPropertyResourceBundle propertyFromURL(final URL url, final boolean reload) throws IOException {
        MyPropertyResourceBundle bundle = null;
        InputStream stream = null;
        try {
            stream = AccessController.doPrivileged(
                new PrivilegedExceptionAction<InputStream>() {
                    @Override
                    public InputStream run() throws IOException {
                        InputStream is = null;
                        if (reload) {
                            URLConnection connection = url.openConnection();
                            if (connection != null) {
                                // Disable caches to get fresh data for
                                // reloading.
                                connection.setUseCaches(false);
                                is = connection.getInputStream();
                            }
                        } else {
                            is = url.openStream();
                        }
                        return is;
                    }
                });
        } catch (PrivilegedActionException e) {
            throw (IOException) e.getException();
        }
        if (stream != null) {
            try {
                bundle = new MyPropertyResourceBundle(new InputStreamReader(stream, PROPERTY_ENCODING));
            } finally {
                stream.close();
            }
        }
        return bundle;
    }
}
import sun.util.ResourceBundleEnumeration;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.util.*;
/**
 * 參照PropertyResourceBundle
 * @author gdzwk
 */
public class MyPropertyResourceBundle extends ResourceBundle {
    // 添加額外構(gòu)造函數(shù),用于合并多個(gè)bundle對(duì)象
    public MyPropertyResourceBundle() {
        lookup = new HashMap<>(16);
    }

    public MyPropertyResourceBundle (InputStream stream) throws IOException {
        Properties properties = new Properties();
        properties.load(stream);
        lookup = new HashMap(properties);
    }

    public MyPropertyResourceBundle (Reader reader) throws IOException {
        Properties properties = new Properties();
        properties.load(reader);
        lookup = new HashMap(properties);
    }

    @Override
    public Object handleGetObject(String key) {
        if (key == null) {
            throw new NullPointerException();
        }
        return lookup.get(key);
    }

    @Override
    public Enumeration<String> getKeys() {
        ResourceBundle parent = this.parent;
        return new ResourceBundleEnumeration(lookup.keySet(),
                (parent != null) ? parent.getKeys() : null);
    }

    @Override
    protected Set<String> handleKeySet() {
        return lookup.keySet();
    }

    // 合并其他bundle對(duì)象的數(shù)據(jù)
    public void combine(MyPropertyResourceBundle others) {
        if (others != null) {
            lookup.putAll(others.lookup);
        }
    }

    // ==================privates====================
    private Map<String,Object> lookup;
}

4 Spring中的國(guó)際化

spring提供了自己的國(guó)際化信息結(jié)構(gòu),類結(jié)構(gòu)圖如下所示。其中最重要的兩個(gè)實(shí)現(xiàn)類是 ReloadableResourceBundleMessageSourceResourceBundleMessageSource。

SprintMessaegSourceArchitecture
  1. MessageSource 接口定義了獲取國(guó)際化資源的標(biāo)準(zhǔn)。
  2. AbstractMessageSource 抽象類將應(yīng)用級(jí)的國(guó)際化功能進(jìn)行了拆分:
    • 搜索并加載指定locale的功能 ( 將 resolveCode() 方法暴露給子類去實(shí)現(xiàn) )
    • 找不到國(guó)際化信息時(shí),回退使用默認(rèn)信息
    • 國(guó)際化信息渲染
  3. MessageSourceSupport 提供了對(duì)資源渲染的基礎(chǔ)支持
  4. AbstractMessageSource 有2個(gè)直接繼承者:
    • StaticMessageSource:簡(jiǎn)易實(shí)現(xiàn),支持以編程的方式注冊(cè)消息。
    • AbstractResourceBasedMessageSource:從類名可看出,其子類實(shí)現(xiàn)者支持從資源中注冊(cè)消息。
      AbstractMessageSource 存在一個(gè)集合變量 basenameSet,說明其支持從多個(gè)位置讀取資源文件。

4.1 資源定位加載

Spring框架中主要使用 ResourceBundleMessageSource、ReloadableResourceBundleMessageSource 實(shí)現(xiàn)該功能,兩者具體有差別。

4.1.1 ResourceBundleMessageSource

ResourceBundleMessageSource 內(nèi)部調(diào)用 ResourceBundle 類進(jìn)行具體的國(guó)際化資源定位和加載,詳情請(qǐng)看章節(jié)3。

ResourceBundle 只支持從單個(gè)basename ( 例如 i18n/messages ) 查找指定語言區(qū)域的資源。ResourceBundleMessageSource 對(duì)此做了一層封裝,定義了一個(gè)集合變量 ( 如下所示 ) 允許用戶定義多個(gè)basename,以在多個(gè)位置搜索。最終會(huì)返回搜索到指定語言區(qū)域的第一個(gè)的資源。

// Map<basename, Map<Locale, ResourceBundle>>
private final Map<String, Map<Locale, ResourceBundle>> cachedResourceBundles = new ConcurrentHashMap<>();

測(cè)試?yán)樱?/p>

public static void main(String[] args) {
    ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
    // 默認(rèn)編碼為 ISO-8859-1 (為避免讀取亂碼,properties文件統(tǒng)一編碼為UTF-8)
    messageSource.setDefaultEncoding("UTF-8");
    messageSource.addBasenames("i18n/messages");
    // messageSource.addBasenames("...");
    // 懶加載,只有查詢具體信息才會(huì)加載并緩存相關(guān)國(guó)際化信息
    String msg = messageSource.getMessage("name", null, Locale.getDefault());
}

4.1.2 ReloadableResourceBundleMessageSource

ResourceBundleMessageSource 相比,這個(gè)就前面多了 Reloadable,因此可以推測(cè)該類對(duì) ResourceBundleMessageSource 進(jìn)行了改進(jìn),可以實(shí)現(xiàn)國(guó)際化資源的重加載。以下對(duì)這個(gè)加載進(jìn)行分析:

// 三個(gè)成員變量
// Map<basename, Map<locale, List<filename>>>   拿到后永久緩存,未找到remove調(diào)用處
private final ConcurrentMap<String, Map<Locale, List<String>>> cachedFilenames = new ConcurrentHashMap<>();
// Map<filename, propertiesHolder>   指定緩存過期時(shí)間后,使用該緩存。
// 緩存不過期時(shí),緩存一次后,基本不會(huì)再被使用,而是調(diào)用下面的 cacheMergedProperties
private final ConcurrentMap<String, PropertiesHolder> cachedProperties = new ConcurrentHashMap<>();
// Map<locale, propertiesHolder>   當(dāng)緩存不過期時(shí),使用該緩存
private final ConcurrentMap<Locale, PropertiesHolder> cachedMergedProperties = new ConcurrentHashMap<>();

// 緩存刷新關(guān)鍵方法
public void clearCache() {
    this.cachedProperties.clear();
      this.cachedMergedProperties.clear();
}
reloadable-resourcebundle-messagesource

從上圖可看出 ReloadableResourceBundleMessageSource 根據(jù)basename的不同,支持多種加載方式。

因此在基于maven構(gòu)建的多模塊項(xiàng)目中,想查找不同子模塊的國(guó)際化資源,只需要列出所有的資源位置即可。示例如下:

public static void main() {
    // 定位classpath下所有的國(guó)際化資源
    PathMatchingResourcePatternResolver pp = new PathMatchingResourcePatternResolver();
    Resource[] resources = pp.getResources("classpath*:i18n/*.properties");
    // 搜集資源url
    Set<String> urlSet = new HashSet<>(resources.length);
    for (Resource resource : resources) {
        urlSet.add(resource.getURL().toString());
    }
    // 定義basenames
    ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
    messageSource.addBaseNames(urlSet.toArray(new String[0]));
    // 資源加載
    String msg = messageSource.getMessage("xxxxx", null, Locale.ROOT);
}

4.1.3 兩者對(duì)比

ReloadableResourceBundleMessageSource 的類注釋部分就能基本了解它們的區(qū)別:

  1. 資源名稱basename指定:
    • 相同:兩者都能指定多個(gè)basename,遍歷查找指定的國(guó)際化code。都遵循基本的ResourceBundle規(guī)則 ( 不指定文件拓展名和語言代碼 )。
    • 不同:
      • ResourceBundleMessageSource:默認(rèn)情況下,只能支持 xxx/messages、xxx/mymessages 等名稱格式。
      • ReloadableResourceBundleMessageSource:默認(rèn)情況下,由 DefaultResourceLoader 類來支持 classpath:/、file: 等多種形式的basename。
  2. 消息數(shù)據(jù)結(jié)構(gòu):
    • ResourceBundleMessageSource:直接使用 ResourceBundle 的map集合存儲(chǔ),通過 PropertyResourceBundle 加載。
    • ReloadableResourceBundleMessageSource:使用 Properties 存儲(chǔ),通過 PropertiesPersister 加載??筛鶕?jù)時(shí)間戳重加載特定文件。
  3. 加載文件的編碼格式指定:
    • ResourceBundleMessageSource:默認(rèn)為 ISO-8859-1,可指定編碼,但對(duì)所有國(guó)際化文件的加載有效。
    • ReloadableResourceBundleMessageSource:根據(jù)優(yōu)先級(jí)作如下處理:
      1. 為每個(gè)國(guó)際化文件的加載指定編碼格式。
      2. 可指定編碼格式。
      3. 默認(rèn)的系統(tǒng)編碼。

4.2 資源渲染

spring的國(guó)際化渲染沒有單獨(dú)定義自己的接口,而是直接使用了JDK中的 MessageFormat 渲染,用法可參考鏈接。

MessageFormat.format("hello, {0}", "world");

4.3 SpringBoot中的使用

SpringBoot默認(rèn)使用 MessageSourceAutoConfiguration 初始化 MessageSource,默認(rèn)使用 ResourceBundleMessageSource,可自定義 ReloadableResourceBundleMessageSource 覆蓋默認(rèn)bean,從而實(shí)現(xiàn)功能更強(qiáng)的國(guó)際化信息加載方式。

以下示例實(shí)現(xiàn)對(duì) spring.messages.basename=classpath*:i18n/messages*.properties 的解析:

@Bean
public MessageSource messageSource(MessageSourceProperties properties) {
    ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
    // 解析application.properties中的basename字段
    if (StringUtils.hasText(properties.getBasename())) {
        String[] basenames = StringUtils.commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(properties.getBasename()));
          if (basenames.length > 0) {
            Set<String> basenameMap = new HashSet<>(basenames.length * 4);
            PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
            for (String basename : basenames) {
                if (basename.startsWith(ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX)) {
                    try {
                        Resource[] resources = resolver.getResources(basename);
                        for (Resource r : resources) {
                            String urlPath = r.getURL().toString();
                            int lastPointIndex = urlPath.lastIndexOf(".");
                            basenameMap.add(urlPath.substring(0, lastPointIndex));
                        }
                    } catch (IOException e) {
                        log.error("", e);
                    }
                } else {
                    basenameMap.add(basename);
                }
            }
            messageSource.setBasenames(basenameMap.toArray(new String[0]));
        }
    }
    // ......
    return messageSource;
}

4.4 SpringMVC中的使用

上述章節(jié)已介紹spring中i18n的加載,下面再介紹spring mvc中請(qǐng)求對(duì)象如何指定locale,獲取本地化信息。對(duì)應(yīng)的業(yè)務(wù)場(chǎng)景例如登錄時(shí)的語言切換。

4.4.1 locale解析策略接口

springmvc定義了一套基于web的locale解析策略接口及實(shí)現(xiàn):

LocaleResolverArchitectuer

LocaleResolver & LocaleContextResolver

先查看接口中的方法定義,從而了解這套locale解析策略的行為。

/**
 * 用于基于web的locale設(shè)置解析策略的接口,該策略允許通過請(qǐng)求進(jìn)行l(wèi)ocale設(shè)置解析,
 * 并通過請(qǐng)求和響應(yīng)進(jìn)行l(wèi)ocale設(shè)置修改。
 *
 * 此接口允許基于請(qǐng)求、會(huì)話、cookies等的實(shí)現(xiàn)。默認(rèn)實(shí)現(xiàn)為 AcceptHeaderLocalerSolver,
 * 只需要使用由響應(yīng)的HTTP頭提供的請(qǐng)求locale設(shè)置。
 *
 * 使用 RequestContext.getLocale() 檢索控制器或視圖中的當(dāng)前l(fā)ocale設(shè)置,獨(dú)立于實(shí)際的
 * 解析策略。
 *
 * 注意: 從spring4.0開始,有個(gè)名為 LocaleContextResolver 的擴(kuò)展策略接口,用于獲取
 * LocaleContext 對(duì)象(可能包括關(guān)聯(lián)的時(shí)區(qū)信息)。spring提供的解析器實(shí)現(xiàn)在適當(dāng)?shù)牡胤綄?shí)現(xiàn)
 * 擴(kuò)展的 LocaleContextResolver 接口。
 */
public interface LocaleResolver {
    Locale resolveLocale(HttpServletRequest request);

    void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale);
}

/**
 * 擴(kuò)展了 LocaleResolver,增加了對(duì)豐富的語言環(huán)境的支持(可能包括語言環(huán)境和時(shí)區(qū)信息)。
 */
public interface LocaleContextResolver extends LocaleResolver {
    LocaleContext resolveLocaleContext(HttpServletRequest request);

    void setLocaleContext(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable LocaleContext localeContext);
}

LocaleResolver 接口定義了從請(qǐng)求中獲取locale以及修改請(qǐng)求和響應(yīng)的locale
LocaleContextResolver 接口是對(duì) LocaleResolver 接口的補(bǔ)充。本地化需求中,除了locale,還可能包含其他信息,例如時(shí)區(qū),甚至業(yè)務(wù)特定的信息。

  • AcceptHeaderLocaleResolver:為默認(rèn)實(shí)現(xiàn),在 WebMvcAutoConfiguration 可驗(yàn)證。僅實(shí)現(xiàn)了 LocaleResolver 接口,由請(qǐng)求頭的 Accept-Language 字段確定使用的locale。
    該實(shí)現(xiàn)類存在意義:應(yīng)用沒有記錄或刷新locale的需求。僅獲取前端請(qǐng)求包含的locale,以便在這次請(qǐng)求中使用對(duì)應(yīng)的國(guó)際化信息,但不考慮locale是否存儲(chǔ)在客戶端或服務(wù)器端。
  • FixedLocaleResolver:locale固定下來,不受請(qǐng)求的影響。
    該實(shí)現(xiàn)類存在的意義:應(yīng)用沒有切換locale的需求。一次性指定后不會(huì)改變。
  • CookieLocaleResolver、SessionLocaleResolver 分別從cookies、session獲取localeContext。
    該類存在的意義:應(yīng)用有記錄或刷新locale的需求。例如記錄在cookie、session中,同時(shí)能夠在locale切換時(shí)刷新記錄。

4.4.2 locale解析策略接口的應(yīng)用

在spring-webmvc包中,僅有 DispatcherServlet、LocaleChangeInterceptor 使用到 LocaleResolver 及其實(shí)現(xiàn)類。

DispatcherServlet

其中 DispatcherServlet 初始化了需要使用的 LocaleResolver 類。如果容器中沒有定義 LocaleResovler 實(shí)例,DispatcherServlet 將在靜態(tài)類加載 DispatcherServlet.properteis 文件時(shí)指定的 AcceptHeaderLocaleResolver

/**
 * HTTP請(qǐng)求處理程序/控制器的中央調(diào)度程序,例如用于Web UI控制器或基于HTTP的遠(yuǎn)程服務(wù)導(dǎo)出器。
 * 向注冊(cè)的處理程序調(diào)度以處理Web請(qǐng)求,從而提供便利的映射和異常處理功能。
 *
 * 該servlet非常靈活:安裝適當(dāng)?shù)倪m配器類后,幾乎可以用于任何工作流程。
 * 它提供以下功能,使其區(qū)別于其他請(qǐng)求驅(qū)動(dòng)的Web MVC框架:
 *
 * 1. 它基于JavaBeans配置機(jī)制。
 * 2. 它可以使用任何HandlerMapping實(shí)現(xiàn)(預(yù)先構(gòu)建或作為應(yīng)用程序的一部分提供)來控制將請(qǐng)求路由到
 *    處理程序?qū)ο?。默認(rèn)值為BeanNameUrlHandlerMapping和RequestMappingHandlerMapping。
 *    可以將HandlerMapping對(duì)象定義為Servlet的應(yīng)用程序上下文中的bean,實(shí)現(xiàn)HandlerMapping
 *    接口,并覆蓋默認(rèn)的HandlerMapping(如果存在)??梢越oHandlerMappings任何bean名稱
 *   (它們通過類型進(jìn)行測(cè)試)。
 * 3. 它可以使用任何HandlerAdapter;這允許使用任何處理程序接口。默認(rèn)適配器為
 *    HttpRequestHandlerAdapter,SimpleControllerHandlerAdapter,分別用于Spring的
 *    HttpRequestHandler和Controller接口。默認(rèn)的RequestMappingHandlerAdapter也將被注冊(cè)。
 *    可以將HandlerAdapter對(duì)象作為Bean添加到應(yīng)用程序上下文中,從而覆蓋默認(rèn)的HandlerAdapters。
 *    像HandlerMappings一樣,可以為HandlerAdapters提供任何bean名稱(它們通過類型進(jìn)行測(cè)試)。
 * 4. 可以通過HandlerExceptionResolver指定調(diào)度程序的異常解決策略,例如,將某些異常映射到錯(cuò)誤頁面。
 *    默認(rèn)值為ExceptionHandlerExceptionResolver,ResponseStatusExceptionResolver和
 *    DefaultHandlerExceptionResolver。可以通過應(yīng)用程序上下文覆蓋這些
 *    HandlerExceptionResolvers??梢越oHandlerExceptionResolver任何bean名稱
 *   (它們通過類型進(jìn)行測(cè)試)。
 * 5. 可以通過ViewResolver實(shí)現(xiàn)來指定其視圖解析策略,將符號(hào)視圖名稱解析為View對(duì)象。默認(rèn)值為
 *    InternalResourceViewResolver??梢詫iewResolver對(duì)象作為bean添加到應(yīng)用程序上下文中,
 *    從而覆蓋默認(rèn)的ViewResolver。可以為ViewResolvers指定任何bean名稱(它們通過類型進(jìn)行測(cè)試)。
 * 6. 如果用戶未提供View或視圖名稱,則配置的RequestToViewNameTranslator將當(dāng)前請(qǐng)求轉(zhuǎn)換為視圖名稱。
 *    對(duì)應(yīng)的bean名稱是“ viewNameTranslator”;默認(rèn)值為DefaultRequestToViewNameTranslator。
 * 7. 調(diào)度程序解決多部分請(qǐng)求的策略由MultipartResolver實(shí)現(xiàn)確定。其中包括對(duì)Apache Commons FileUpload
 *    和Servlet 3的實(shí)現(xiàn)。典型的選擇是CommonsMultipartResolver。MultipartResolver bean的名稱是
 *    “ multipartResolver”; 默認(rèn)為無。
 * 8. 其語言環(huán)境解析策略由LocaleResolver確定?,F(xiàn)成的實(shí)現(xiàn)通過HTTP accept標(biāo)頭,cookie或會(huì)話來工作。
 *    LocaleResolver Bean名稱為“ localeResolver”;默認(rèn)值為AcceptHeaderLocaleResolver。
 * 9. 其主題解析策略由ThemeResolver確定。包括用于固定主題以及cookie和會(huì)話存儲(chǔ)的實(shí)現(xiàn)。ThemeResolver
 *    Bean名稱為“ themeResolver”;默認(rèn)值為FixedThemeResolver。
 *
 * 注意:僅當(dāng)相應(yīng)的HandlerMapping(用于類型級(jí)注釋)和/或 HandlerAdapter(用于方法級(jí)注釋)時(shí),
 * 才會(huì)處理@RequestMapping注釋出現(xiàn)在調(diào)度程序中。默認(rèn)情況下就是這種情況。但是,如果您要定義自定義
 * HandlerMappings或HandlerAdapters,則需要確保也定義了相應(yīng)的自定義RequestMappHandlerMapping
 * 和/或 RequestMappingHandlerAdapter - 前提是您打算使用@RequestMapping。
 *
 * Web應(yīng)用程序可以定義任意數(shù)量的DispatcherServlet。每個(gè)Servlet將在其自己的命名空間中允許,并使用
 * 映射,處理程序等加載器自身的應(yīng)用程序上下文。僅ContextLoaderListener加載的根應(yīng)用程序上下文
 * (如果有)將被共享。
 *
 * 從Spring3.1開始,DispatcherServlete現(xiàn)在可以注入web應(yīng)用上下文,而不是在內(nèi)部創(chuàng)建它自己的上下文。
 * 這在Servlet3.0+環(huán)境中非常有用,該環(huán)境支持以編程的方式注冊(cè)Servlet實(shí)例。有關(guān)詳情,請(qǐng)參加
 * DispatcherServlet(WebApplicationContext) javadoc。
 */
public class DispatcherServlet extends FrameworkServlet {

    @Override
    public void onRefresh(ApplicationContext context) {
        initStrategies(context);
    }

    /**
     * 初始化此servlet使用的策略對(duì)象。
     * 可以在子類中重寫,以初始化其他策略對(duì)象。
     */
    protected void initStrategies(ApplicationContext context) {
        // ...
        initLocaleResolver(context);
        // ...
    }

    /**
     * 初始化此類使用的LocaleResolver。
     * 如果在BeanFactory中沒有為此名稱空間定義給定名稱的bean,我們默認(rèn)為AcceptHeaderLocaleResolver。
     */
    private void initLocaleResolver(ApplicationContext context) {
          try {
            this.localeResolver = context.getBean(LOCALE_RESOLVER_BEAN_NAME, LocaleResolver.class);
        }
        catch (NoSuchBeanDefinitionException ex) {
            // 使用默認(rèn)的LocaleResolver -> AcceptHeaderLocaleResolver
            this.localeResolver = getDefaultStrategy(context, LocaleResolver.class);
        }
    }

}
# DispatcherServlet.properties
org.springframework.web.servlet.LocaleResolver=org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver
# ...

LocaleChangeInterceptor

該攔截器專門用于根據(jù)請(qǐng)求切換locale。一般切換locale時(shí),前端指定要切換的locale存放在請(qǐng)求頭或請(qǐng)求參數(shù)中。而 LocaleChangeInterceptor 默認(rèn)從請(qǐng)求參數(shù)中獲取 locale 參數(shù)的值。代碼如下圖所示,關(guān)鍵方法是 localeResolver.setLocale()

public class LocaleChangeInterceptor extends HandlerInterceptorAdapter {

    public static final String DEFAULT_PARAM_NAME = "locale";
    // 指定從哪個(gè)請(qǐng)求參數(shù)拿值
    @Getter
    @Setter
    private String paramName = DEFAULT_PARAM_NAME;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws ServletException {
        // 從請(qǐng)求參數(shù)中獲取需要切換的locale
        String newLocale = request.getParameter(getParamName());
        if (newLocale != null) {
            if (checkHttpMethod(request.getMethod())) {
                // 獲取DispatcherServlet中指定的LocaleResolver,默認(rèn)情況下是AcceptoHeaderLocaleResolver
                LocaleResolver localeResolver = RequestContextUtils.getLocaleResolver(request);
                if (localeResolver == null) {
                    throw new IllegalStateException("No LocaleResolver found: not in a DispatcherServlet request?");
                }
                try {
                    // 修改請(qǐng)求/響應(yīng)的相關(guān)信息(AcceptHeaderLocaleResolver不支持該方法,會(huì)報(bào)錯(cuò))
                    localeResolver.setLocale(request, response, parseLocaleValue(newLocale));
                }
                catch (IllegalArgumentException ex) {
                    if (isIgnoreInvalidLocale()) {
                        if (logger.isDebugEnabled()) {
                            logger.debug("Ignoring invalid locale value [" + newLocale + "]: " + ex.getMessage());
                        }
                    }
                    else {
                        throw ex;
                    }
                }
            }
        }
        // Proceed in any case.
        return true;
    }

}

在項(xiàng)目中實(shí)現(xiàn)國(guó)際化切換不一定需要基于LocaleChangeInterceptor,但如果想使用它,必須考慮以下幾點(diǎn):

  1. LocaleChangeInterceptor 專用于切換locale,意味著切換后的locale需要能存儲(chǔ)/刷新到某個(gè)地方。
    否則例如自定義一個(gè)使用jwt時(shí)的UserContextInterceptor ( 記錄當(dāng)前請(qǐng)求locale到上下文,方便后續(xù)業(yè)務(wù)的查詢 ) 即可,沒必要寫在 LocaleChangeInterceptor 中,會(huì)引起歧義。
  2. 不能使用原生的 AcceptHeaderLocaleResolverFixedLocaleResolver,它們不支持對(duì)請(qǐng)求包含的locale的存儲(chǔ)/刷新,即調(diào)用 localeResolver.setLocale() 會(huì)報(bào)錯(cuò)。
  3. 按需調(diào)用 localeChangeInterceptor.setParamName() 方法。請(qǐng)求中攜帶的語言區(qū)域信息不一定在 locale 字段中。
  4. 按需重寫 localeChangeInterceptor.preHandle() 方法。不一定從請(qǐng)求參數(shù)中獲取,還有可能從請(qǐng)求頭中獲取。
  5. 后續(xù)流程中如果需要從請(qǐng)求中獲取對(duì)應(yīng)的locale,建議使用 RequestContextUtils.getLocale(request)。( 不過一般我們的應(yīng)用都會(huì)選擇定義自己的ThreadLocale來存儲(chǔ)相關(guān)信息 )

4.4.3 spring boot中的使用

基于spring-mvc的springboot應(yīng)用中,WebMvcConfiguration 有如下設(shè)置,可通過自定義bean覆蓋,或添加 spring.mvc.locale,具體看需求。

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

5 總結(jié)

經(jīng)過一輪分析、思考、畫流程圖、總結(jié),對(duì)項(xiàng)目上所需的國(guó)際化方面的使用和原理有了很深的了解。

問題1:spring.message.basename 可以填寫哪些格式的值(xx,xx,xx)

以springboot中的使用為例,填寫值的格式由具體的使用的 MessageSource 決定。
默認(rèn)情況下由 ResourceBundleMessageSource 負(fù)責(zé)國(guó)際化信息定位加載,只能識(shí)別如下的文本,不包含"classpath"等前綴,也不包含 "_zh??"、".properties" 等后綴。且檢索到第一個(gè)文件即停止搜索。

i18n/messages,msg/mymessage,abc/haha     // 支持逗號(hào)分隔,但不能存在 ”classpath:“ 等

可自定義仿寫 ResourceBundleMessageSource 實(shí)現(xiàn)更多格式的 basename解析,但沒這個(gè)必要。
注入一個(gè) ReloadableResourceBundleMessageSource,可替換默認(rèn)實(shí)現(xiàn),支持如下格式的basename:

i18n/message、
classpath:i18n/messages、   // classpath: 前綴
file:///xxx、               // 文件協(xié)議
/xxxxx、                    //

問題2:對(duì)基于maven的多模塊項(xiàng)目,是否支持將分散在多個(gè)子模塊中的國(guó)際化信息收集整合

通過 ReloadableResourceBundleMessageSource 可間接支持,但需要自己解析"classpath*:",如章節(jié)4.3所示。

問題3:國(guó)際化的處理離不開資源定位與加載,我接觸到的開源框架中,都有什么樣的處理?

jdk的ResourceBundle確定了一個(gè)規(guī)約:(也可能不是jdk的這個(gè)規(guī)約,其他語言應(yīng)該也有類似的處理)

  1. basename不能包含語言區(qū)域信息或文件后綴名

  2. 當(dāng)指定的語言區(qū)域( 如 "zh_CN" ) 無法搜索到資源時(shí),回退使用 "zh" 甚至 Locale.ROOT進(jìn)行再次搜索

spring定義了自己的國(guó)際化資源加載接口 MessageSource 及相關(guān)實(shí)現(xiàn),但也是遵守ResourceBundle的規(guī)約,同時(shí)進(jìn)行了功能增強(qiáng)處理。

資源加載基本都使用了File/Path 或者URL類進(jìn)行處理。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容