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 流程圖

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ì)描述。
以下舉例子說明:
-
假設(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 -
假設(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.properties或i18n/messages_zh.properties或i18n/messages.properties文件。
3.2 總結(jié)
ResourceBundle 類中大量使用了模板設(shè)計(jì)模式,通過 ResourceBundle.Control 對(duì)國(guó)際化資源的定位與加載的全流程進(jìn)行定制化處理,十分靈活。
局限性:
- 默認(rèn)情況下,只能加載找到的第一個(gè)文件,存在一定的不確定性。且目前基于maven構(gòu)建的項(xiàng)目來說,模塊化是很常見的?;赾ontrol定制需要花一定的功夫。
- 提供的方法較為原始、底層。需要做大量的封裝處理。例如有如下的需求:
- 基于
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ó)際化處理中的通用步驟:
- 基于
- 加載的資源名稱由用戶指定,但具體文件的格式基本固定。Locale中有多個(gè)字段:language、region、。。 在最終構(gòu)造資源名稱時(shí),基本都是
{baseName}_{language}_{region}.properties - 指定一個(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)類是 ReloadableResourceBundleMessageSource、ResourceBundleMessageSource。

-
MessageSource接口定義了獲取國(guó)際化資源的標(biāo)準(zhǔn)。 -
AbstractMessageSource抽象類將應(yīng)用級(jí)的國(guó)際化功能進(jìn)行了拆分:- 搜索并加載指定locale的功能 ( 將
resolveCode()方法暴露給子類去實(shí)現(xiàn) ) - 找不到國(guó)際化信息時(shí),回退使用默認(rèn)信息
- 國(guó)際化信息渲染
- 搜索并加載指定locale的功能 ( 將
-
MessageSourceSupport提供了對(duì)資源渲染的基礎(chǔ)支持 -
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();
}

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

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):
LocaleChangeInterceptor專用于切換locale,意味著切換后的locale需要能存儲(chǔ)/刷新到某個(gè)地方。
否則例如自定義一個(gè)使用jwt時(shí)的UserContextInterceptor ( 記錄當(dāng)前請(qǐng)求locale到上下文,方便后續(xù)業(yè)務(wù)的查詢 ) 即可,沒必要寫在LocaleChangeInterceptor中,會(huì)引起歧義。- 不能使用原生的
AcceptHeaderLocaleResolver、FixedLocaleResolver,它們不支持對(duì)請(qǐng)求包含的locale的存儲(chǔ)/刷新,即調(diào)用localeResolver.setLocale()會(huì)報(bào)錯(cuò)。- 按需調(diào)用
localeChangeInterceptor.setParamName()方法。請(qǐng)求中攜帶的語言區(qū)域信息不一定在locale字段中。- 按需重寫
localeChangeInterceptor.preHandle()方法。不一定從請(qǐng)求參數(shù)中獲取,還有可能從請(qǐng)求頭中獲取。- 后續(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)該也有類似的處理)
basename不能包含語言區(qū)域信息或文件后綴名
當(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)行處理。