本文章已同步分享在CSDN、、掘金,作为原创记录凭证。转载请标明出处,谢谢。
个人博客,欢迎访问☺
[TOC]
1 前言
在本地基于springboot的maven多模块项目中,拆出 module1
、module2
、module3
三个子模块,每个模块都有自己的国际化资源,启动项目前添加配置 spring.messages.basename=i18n/messages
。
启动项目后验证国际化时发现, 仅 service-main
下面的 .properties
文件被加载。基于碰到的这个问题,决定认真看一下springboot的国际化信息处理过程。
--- project
|--- module1
| |--- src/main/resources
| |--- i18n
| |--- messages.properties
|--- module2
| |--- src/main/resources
| |--- i18n
| |--- messages.properties
|--- module3
| |--- src/main/resources
| |--- i18n
| |--- messages.properties
|--- service-main (项目入口)
|--- src/main/resource
|--- i18n
|--- messages.properties
spring.messages.basename
对应 MessageSourceProperties
类中的 basename
属性,有如下注释:
以逗号分隔的基名列表 ( 本质上是一个完全限定的类路径位置 ),每个基名都遵循
ResourceBundle
约定,并对基于/
的位置提供宽松的支持。如果它不包含包限定符 ( 例如org.mypackage ) 时,它将从类路径根解析。
可以看出,spring框架遵循JDK ResourceBundle
定义的标准。因此下面从 ResourceBundle
开始进行分析。
2 带着问题分析
问题1:spring.message.basename
可以填写哪些格式的值(xx,xx,xx)
问题2:对基于maven的多模块项目,是否支持将分散在多个子模块中的国际化信息收集整合
问题3:国际化的处理离不开 资源定位与加载,我接触到的开源框架中,都有什么样的处理?
3 ResourceBundle
ResourceBundle
类的基本用法如下,下面根据 getBundle()
方法入口逐步了解它加载国际化的流程。
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机制
如果基于英语语言区域的locale无法搜索到资源,可定义是否切换其他语言区域的locale继续搜索可用资源。例如:
英语语言区域:Locale.ENGLISH("en")
、Locale.UK("en_GB")
、Locale.US("en_US")
中文语言区域:Locale.CHINESE("zh")
、Locale.CHINA("zh_CN")
请勿与章节3.1.2.1.2(确定候选locales范围) 弄混。下一个章节是确定当前语言区域内的可选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;
}
}
上述代码展现的第一个方法 getBundleImpl
中存在一个for循环,作用就是在指定的 locale
无法定位到国际化文件 ( i18n/messages_en_US.properties
),或者只能定位到基于 Locale.ROOT ( 即 i18n/messages.properties
) 的国际化文件时,使用其他 locales
进行再次的搜索。
默认情况下,如果指定的 locale
搜索失败,control.getFallbackLocale()
会选用系统默认的 locale
。
如有需要,可实现自己的Control进行定制化fallback处理流程,如下所示:
/**
* @author gdzwk
*/
public class MyControl extends ResourceBundle.Control {
/**
* 如果基于zh的locale无法找到,则不再查找
* 如果基于en的locale无法找到,则再次使用(zh_CN)进行查找
* 其余情况,使用系统默认locale进行查找
*
* 如果fallback得到的locale与当前locale相同,则没有再次查找的必要
*/
@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)
方法的注释部分,其中对确定候选locales范围有详细描述。
以下举例子说明:
-
假设传递
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 ("") ---> 每个范围都会包含这个 // 因此后续基于classpath的搜索可能如下:(使用classLoader.getResource(name)进行验证) i18n/messages_zh_CN_#Hans.properties ---> 可能不包含 i18n/messages_zh_#Hans.properties ---> 可能不包含 i18n/messages_zh_CN.properties i18n/messages_zh.properties i18n/messages.properties
-
假设传递的
baseName="i18n/messages",locale=Locale.CHINESE ("zh")
,最终返回的List
包含:locale.instance("zh_#Hans"), ---> 可能不包含 Locale.CHINESE ("zh"), Locale.ROOT ("") ---> 每个范围都会包含这个 // 因此后续基于classpath的搜索可能如下:(使用classLoader.getResource(name)进行验证) i18n/messages_zh_#Hans.properties ---> 可能不包含 i18n/messages_zh.properties i18n/messages.properties
3.1.2.1.3 倒序遍历候选locales
采用倒序遍历的原因,假设上一步得到的候选locales包括如下,均找到了对应的国际化文件。在读取某个key对应的value时,应优先选用 Locale.CHINA ("zh_CN")
对应的文件内容,除非找不到,才继续读取 Locale.CHINESE ("zh")
对应的文件内容。
第三次遍历:Locale.CHINA ("zh_CN")--> i18n/messages_zh_CN.properties : 包含键值对 name=zh_CN
第二次遍历:Locale.CHINESE("zh") --> i18n/messages_zh.properties : 包含键值对 name=zh
第一次遍历:Locale.ROOT ("") --> i18n/messsage.properties : 包含键值对 name=default
// 在查找的时候,优先查询当前bundle的lookup集合,如果找不到,继续查找parentBundle.lookup集合
最终返回的bundle对象: {
lookup: keyValue集合, --> 对应i18n/messages_zh_CN.properties中找到的键值对
parentBundle对象: {
lookup: keyValue集合, --> 对应i18n/message_zh.properties中找到的键值对
parentBundle对象: {
lootup: keyValue集合, --> 对应i18n/message.properties中找到的键值对
parentBundle: null
}
}
}
下面展示其他情况的例子:
第三次遍历:Locale.CHINA ("zh_CN")--> i18n/messages_zh_CN.properties : 包含键值对 name=zh_CN
第二次遍历:Locale.CHINESE("zh") --> i18n/messages_zh.properties : 该文件不存在
第一次遍历:Locale.ROOT ("") --> i18n/messsage.properties : 包含键值对 name=default
// 在查找的时候,优先查询当前bundle的lookup集合,如果找不到,继续查找parentBundle.lookup集合
最终返回的bundle对象: {
lookup: keyValue集合, --> 对应i18n/messages_zh_CN.properties中找到的键值对
parentBundle对象: {
lookup: keyValue集合, --> 对应i18n/message.properties中找到的键值对
parentBundle对象: null
}
}
第三次遍历:Locale.CHINA ("zh_CN")--> i18n/messages_zh_CN.properties : 该文件不存在
第二次遍历:Locale.CHINESE("zh") --> i18n/messages_zh.properties : 该文件不存在
第一次遍历:Locale.ROOT ("") --> i18n/messsage.properties : 包含键值对 name=default
// 在查找的时候,优先查询当前bundle的lookup集合,如果找不到,继续查找parentBundle.lookup集合
最终返回的bundle对象: {
lookup: keyValue集合, --> 对应i18n/message.properties中找到的键值对
parentBundle对象: 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的结构。需要搜索的包名称由 control.toBundleName(baseName, locale)
方法确定,可根据需要定制。一般情况下包名构造可简化为:
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 资源加载
通过章节3.1.2.1.4,可得知包名称 bundleName。后续查找时,control.newBundle()
方法会自动加上 ".properties" 后缀拼凑出完整的classpath文件名称。
最终的资源加载调用 classLoader.getResource(name)
方法。其中的name参数仅支持如下的格式。且只能拿到classpath中匹配到的第一个文件。
name = i18n/messages.properties // 描述文件
name = com/demo/MessageZhCN.java // 描述类(ResourceBundle可以加载类,但一般不会这么使用,因此文中没具体描述这部分。流程图中有简略说明)
代码追溯到这里,对于章节1中描述的问题,心里有了基本的答案。后续在加上结合spring的分析,即可验证。
如果spring.messages.basename=i18n/messages
作为basename
参数直接传递给ResourceBundle.getBundle(xx)
方法。由于Locale.default = (zh_CN),因此最终只会匹配到classpath中找到的第一个i18n/messages_zh_CN.properties
或i18n/messages_zh.properties
或i18n/messages.properties
文件。
3.2 总结
ResourceBundle 类中大量使用了模板设计模式,通过 ResourceBundle.Control
对国际化资源的定位与加载的全流程进行定制化处理,十分灵活。
局限性:
- 默认情况下,只能加载找到的第一个文件,存在一定的不确定性。且目前基于maven构建的项目来说,模块化是很常见的。基于control定制需要花一定的功夫。
- 提供的方法较为原始、底层。需要做大量的封装处理。例如有如下的需求:
- 基于
baseName=classpath*:i18n/messages
进行搜索。需要改写control.newBundle() - 拿到国际化信息后,能进行进一步渲染处理,例如:
message=这是一个{1}
,具体的值在调用时渲染。 - 假设国际化文件不是来源于classpath,而是文件系统或网络,基于control的改写难度更大。
从ResourceBundle的资源定位和加载流程中,可以总结出一些步骤是国际化处理中的通用步骤:
- 基于
- 加载的资源名称由用户指定,但具体文件的格式基本固定。Locale中有多个字段:language、region、。。 在最终构造资源名称时,基本都是
{baseName}_{language}_{region}.properties
- 指定一个locale时,应该将
{baseName}_{language}.properties
、{baseName}.properties
文件内容包含进来。
最终都是由URL定位具体的文件,然后通过inputStream/reader读取到property对象中。
ResourceBundle约定:( 语言环境解析规则、后备规则 )
-
不指定文件拓展名 (
.properties
) 或语言代码 (_zh_CN
):合法:i18n/messages、META-INF/mymessages 非法:i18n/messages_zh --> 这会导致最终搜索的文件名称为: i18n/messages_zh_zh.properties等
3.3 自定义实现
现在基于ResourceBundle提供的Control进行定制开发,使其能支持如下的解析:
// 搜索classpath下所有匹配的i18n/messages文件,并且对相同locale的文件内容进行合并处理,从而满足基于maven构建的多模块项目国际化需求
// 如需支持例如 "classpath*:i18n/**/mymessages"等更复杂的匹配,还需进一步改写
ResourceBundle.getBundle("clsspath*:i18n/messages", new MyControl());
代码实现:
/**
* @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全放到一个集合中
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 enumeration = classLoader.getResources(resourceName);
Map 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() {
@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() {
@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 {
// 添加额外构造函数,用于合并多个bundle对象
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 getKeys() {
ResourceBundle parent = this.parent;
return new ResourceBundleEnumeration(lookup.keySet(),
(parent != null) ? parent.getKeys() : null);
}
@Override
protected Set handleKeySet() {
return lookup.keySet();
}
// 合并其他bundle对象的数据
public void combine(MyPropertyResourceBundle others) {
if (others != null) {
lookup.putAll(others.lookup);
}
}
// ==================privates====================
private Map lookup;
}
4 Spring中的国际化
spring提供了自己的国际化信息结构,类结构图如下所示。其中最重要的两个实现类是 ReloadableResourceBundleMessageSource
、ResourceBundleMessageSource
。
-
MessageSource
接口定义了获取国际化资源的标准。 -
AbstractMessageSource
抽象类将应用级的国际化功能进行了拆分:- 搜索并加载指定locale的功能 ( 将
resolveCode()
方法暴露给子类去实现 ) - 找不到国际化信息时,回退使用默认信息
- 国际化信息渲染
- 搜索并加载指定locale的功能 ( 将
-
MessageSourceSupport
提供了对资源渲染的基础支持 -
AbstractMessageSource
有2个直接继承者:-
StaticMessageSource
:简易实现,支持以编程的方式注册消息。 -
AbstractResourceBasedMessageSource
:从类名可看出,其子类实现者支持从资源中注册消息。
AbstractMessageSource
存在一个集合变量basenameSet
,说明其支持从多个位置读取资源文件。
-
4.1 资源定位加载
Spring框架中主要使用 ResourceBundleMessageSource
、ReloadableResourceBundleMessageSource
实现该功能,两者具体有差别。
4.1.1 ResourceBundleMessageSource
ResourceBundleMessageSource
内部调用 ResourceBundle
类进行具体的国际化资源定位和加载,详情请看章节3。
ResourceBundle
只支持从单个basename ( 例如 i18n/messages
) 查找指定语言区域的资源。ResourceBundleMessageSource
对此做了一层封装,定义了一个集合变量 ( 如下所示 ) 允许用户定义多个basename,以在多个位置搜索。最终会返回搜索到指定语言区域的第一个的资源。
// Map>
private final Map> cachedResourceBundles = new ConcurrentHashMap<>();
测试例子:
public static void main(String[] args) {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
// 默认编码为 ISO-8859-1 (为避免读取乱码,properties文件统一编码为UTF-8)
messageSource.setDefaultEncoding("UTF-8");
messageSource.addBasenames("i18n/messages");
// messageSource.addBasenames("...");
// 懒加载,只有查询具体信息才会加载并缓存相关国际化信息
String msg = messageSource.getMessage("name", null, Locale.getDefault());
}
4.1.2 ReloadableResourceBundleMessageSource
和 ResourceBundleMessageSource
相比,这个就前面多了 Reloadable
,因此可以推测该类对 ResourceBundleMessageSource
进行了改进,可以实现国际化资源的重加载。以下对这个加载进行分析:
// 三个成员变量
// Map>> 拿到后永久缓存,未找到remove调用处
private final ConcurrentMap>> cachedFilenames = new ConcurrentHashMap<>();
// Map 指定缓存过期时间后,使用该缓存。
// 缓存不过期时,缓存一次后,基本不会再被使用,而是调用下面的 cacheMergedProperties
private final ConcurrentMap cachedProperties = new ConcurrentHashMap<>();
// Map 当缓存不过期时,使用该缓存
private final ConcurrentMap cachedMergedProperties = new ConcurrentHashMap<>();
// 缓存刷新关键方法
public void clearCache() {
this.cachedProperties.clear();
this.cachedMergedProperties.clear();
}
从上图可看出 ReloadableResourceBundleMessageSource
根据basename的不同,支持多种加载方式。
因此在基于maven构建的多模块项目中,想查找不同子模块的国际化资源,只需要列出所有的资源位置即可。示例如下:
public static void main() { // 定位classpath下所有的国际化资源 PathMatchingResourcePatternResolver pp = new PathMatchingResourcePatternResolver(); Resource[] resources = pp.getResources("classpath*:i18n/*.properties"); // 搜集资源url Set
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 两者对比
从 ReloadableResourceBundleMessageSource
的类注释部分就能基本了解它们的区别:
- 资源名称basename指定:
- 相同:两者都能指定多个basename,遍历查找指定的国际化code。都遵循基本的ResourceBundle规则 ( 不指定文件拓展名和语言代码 )。
- 不同:
-
ResourceBundleMessageSource
:默认情况下,只能支持xxx/messages
、xxx/mymessages
等名称格式。 -
ReloadableResourceBundleMessageSource
:默认情况下,由DefaultResourceLoader
类来支持classpath:
、/
、file:
等多种形式的basename。
-
- 消息数据结构:
-
ResourceBundleMessageSource
:直接使用ResourceBundle
的map集合存储,通过PropertyResourceBundle
加载。 -
ReloadableResourceBundleMessageSource
:使用Properties
存储,通过PropertiesPersister
加载。可根据时间戳重加载特定文件。
-
- 加载文件的编码格式指定:
-
ResourceBundleMessageSource
:默认为ISO-8859-1
,可指定编码,但对所有国际化文件的加载有效。 -
ReloadableResourceBundleMessageSource
:根据优先级作如下处理:- 为每个国际化文件的加载指定编码格式。
- 可指定编码格式。
- 默认的系统编码。
-
4.2 资源渲染
spring的国际化渲染没有单独定义自己的接口,而是直接使用了JDK中的 MessageFormat
渲染,用法可参考链接。
MessageFormat.format("hello, {0}", "world");
4.3 SpringBoot中的使用
SpringBoot默认使用 MessageSourceAutoConfiguration
初始化 MessageSource
,默认使用 ResourceBundleMessageSource
,可自定义 ReloadableResourceBundleMessageSource
覆盖默认bean,从而实现功能更强的国际化信息加载方式。
以下示例实现对 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 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中的使用
上述章节已介绍spring中i18n的加载,下面再介绍spring mvc中请求对象如何指定locale,获取本地化信息。对应的业务场景例如登录时的语言切换。
4.4.1 locale解析策略接口
springmvc定义了一套基于web的locale解析策略接口及实现:
LocaleResolver & LocaleContextResolver
先查看接口中的方法定义,从而了解这套locale解析策略的行为。
/**
* 用于基于web的locale设置解析策略的接口,该策略允许通过请求进行locale设置解析,
* 并通过请求和响应进行locale设置修改。
*
* 此接口允许基于请求、会话、cookies等的实现。默认实现为 AcceptHeaderLocalerSolver,
* 只需要使用由响应的HTTP头提供的请求locale设置。
*
* 使用 RequestContext.getLocale() 检索控制器或视图中的当前locale设置,独立于实际的
* 解析策略。
*
* 注意: 从spring4.0开始,有个名为 LocaleContextResolver 的扩展策略接口,用于获取
* LocaleContext 对象(可能包括关联的时区信息)。spring提供的解析器实现在适当的地方实现
* 扩展的 LocaleContextResolver 接口。
*/
public interface LocaleResolver {
Locale resolveLocale(HttpServletRequest request);
void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale);
}
/**
* 扩展了 LocaleResolver,增加了对丰富的语言环境的支持(可能包括语言环境和时区信息)。
*/
public interface LocaleContextResolver extends LocaleResolver {
LocaleContext resolveLocaleContext(HttpServletRequest request);
void setLocaleContext(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable LocaleContext localeContext);
}
LocaleResolver
接口定义了从请求中获取locale以及修改请求和响应的locale。
LocaleContextResolver
接口是对 LocaleResolver
接口的补充。本地化需求中,除了locale,还可能包含其他信息,例如时区,甚至业务特定的信息。
-
AcceptHeaderLocaleResolver
:为默认实现,在WebMvcAutoConfiguration
可验证。仅实现了LocaleResolver
接口,由请求头的Accept-Language
字段确定使用的locale。
该实现类存在意义:应用没有记录或刷新locale的需求。仅获取前端请求包含的locale,以便在这次请求中使用对应的国际化信息,但不考虑locale是否存储在客户端或服务器端。 -
FixedLocaleResolver
:locale固定下来,不受请求的影响。
该实现类存在的意义:应用没有切换locale的需求。一次性指定后不会改变。 -
CookieLocaleResolver
、SessionLocaleResolver
分别从cookies、session获取localeContext。
该类存在的意义:应用有记录或刷新locale的需求。例如记录在cookie、session中,同时能够在locale切换时刷新记录。
4.4.2 locale解析策略接口的应用
在spring-webmvc包中,仅有 DispatcherServlet
、LocaleChangeInterceptor
使用到 LocaleResolver
及其实现类。
DispatcherServlet
其中 DispatcherServlet
初始化了需要使用的 LocaleResolver
类。如果容器中没有定义 LocaleResovler
实例,DispatcherServlet
将在静态类加载 DispatcherServlet.properteis
文件时指定的 AcceptHeaderLocaleResolver
:
/**
* HTTP请求处理程序/控制器的中央调度程序,例如用于Web UI控制器或基于HTTP的远程服务导出器。
* 向注册的处理程序调度以处理Web请求,从而提供便利的映射和异常处理功能。
*
* 该servlet非常灵活:安装适当的适配器类后,几乎可以用于任何工作流程。
* 它提供以下功能,使其区别于其他请求驱动的Web MVC框架:
*
* 1. 它基于JavaBeans配置机制。
* 2. 它可以使用任何HandlerMapping实现(预先构建或作为应用程序的一部分提供)来控制将请求路由到
* 处理程序对象。默认值为BeanNameUrlHandlerMapping和RequestMappingHandlerMapping。
* 可以将HandlerMapping对象定义为Servlet的应用程序上下文中的bean,实现HandlerMapping
* 接口,并覆盖默认的HandlerMapping(如果存在)。可以给HandlerMappings任何bean名称
* (它们通过类型进行测试)。
* 3. 它可以使用任何HandlerAdapter;这允许使用任何处理程序接口。默认适配器为
* HttpRequestHandlerAdapter,SimpleControllerHandlerAdapter,分别用于Spring的
* HttpRequestHandler和Controller接口。默认的RequestMappingHandlerAdapter也将被注册。
* 可以将HandlerAdapter对象作为Bean添加到应用程序上下文中,从而覆盖默认的HandlerAdapters。
* 像HandlerMappings一样,可以为HandlerAdapters提供任何bean名称(它们通过类型进行测试)。
* 4. 可以通过HandlerExceptionResolver指定调度程序的异常解决策略,例如,将某些异常映射到错误页面。
* 默认值为ExceptionHandlerExceptionResolver,ResponseStatusExceptionResolver和
* DefaultHandlerExceptionResolver。可以通过应用程序上下文覆盖这些
* HandlerExceptionResolvers。可以给HandlerExceptionResolver任何bean名称
* (它们通过类型进行测试)。
* 5. 可以通过ViewResolver实现来指定其视图解析策略,将符号视图名称解析为View对象。默认值为
* InternalResourceViewResolver。可以将ViewResolver对象作为bean添加到应用程序上下文中,
* 从而覆盖默认的ViewResolver。可以为ViewResolvers指定任何bean名称(它们通过类型进行测试)。
* 6. 如果用户未提供View或视图名称,则配置的RequestToViewNameTranslator将当前请求转换为视图名称。
* 对应的bean名称是“ viewNameTranslator”;默认值为DefaultRequestToViewNameTranslator。
* 7. 调度程序解决多部分请求的策略由MultipartResolver实现确定。其中包括对Apache Commons FileUpload
* 和Servlet 3的实现。典型的选择是CommonsMultipartResolver。MultipartResolver bean的名称是
* “ multipartResolver”; 默认为无。
* 8. 其语言环境解析策略由LocaleResolver确定。现成的实现通过HTTP accept标头,cookie或会话来工作。
* LocaleResolver Bean名称为“ localeResolver”;默认值为AcceptHeaderLocaleResolver。
* 9. 其主题解析策略由ThemeResolver确定。包括用于固定主题以及cookie和会话存储的实现。ThemeResolver
* Bean名称为“ themeResolver”;默认值为FixedThemeResolver。
*
* 注意:仅当相应的HandlerMapping(用于类型级注释)和/或 HandlerAdapter(用于方法级注释)时,
* 才会处理@RequestMapping注释出现在调度程序中。默认情况下就是这种情况。但是,如果您要定义自定义
* HandlerMappings或HandlerAdapters,则需要确保也定义了相应的自定义RequestMappHandlerMapping
* 和/或 RequestMappingHandlerAdapter - 前提是您打算使用@RequestMapping。
*
* Web应用程序可以定义任意数量的DispatcherServlet。每个Servlet将在其自己的命名空间中允许,并使用
* 映射,处理程序等加载器自身的应用程序上下文。仅ContextLoaderListener加载的根应用程序上下文
* (如果有)将被共享。
*
* 从Spring3.1开始,DispatcherServlete现在可以注入web应用上下文,而不是在内部创建它自己的上下文。
* 这在Servlet3.0+环境中非常有用,该环境支持以编程的方式注册Servlet实例。有关详情,请参加
* DispatcherServlet(WebApplicationContext) javadoc。
*/
public class DispatcherServlet extends FrameworkServlet {
@Override
public void onRefresh(ApplicationContext context) {
initStrategies(context);
}
/**
* 初始化此servlet使用的策略对象。
* 可以在子类中重写,以初始化其他策略对象。
*/
protected void initStrategies(ApplicationContext context) {
// ...
initLocaleResolver(context);
// ...
}
/**
* 初始化此类使用的LocaleResolver。
* 如果在BeanFactory中没有为此名称空间定义给定名称的bean,我们默认为AcceptHeaderLocaleResolver。
*/
private void initLocaleResolver(ApplicationContext context) {
try {
this.localeResolver = context.getBean(LOCALE_RESOLVER_BEAN_NAME, LocaleResolver.class);
}
catch (NoSuchBeanDefinitionException ex) {
// 使用默认的LocaleResolver -> AcceptHeaderLocaleResolver
this.localeResolver = getDefaultStrategy(context, LocaleResolver.class);
}
}
}
# DispatcherServlet.properties
org.springframework.web.servlet.LocaleResolver=org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver
# ...
LocaleChangeInterceptor
该拦截器专门用于根据请求切换locale。一般切换locale时,前端指定要切换的locale存放在请求头或请求参数中。而 LocaleChangeInterceptor
默认从请求参数中获取 locale
参数的值。代码如下图所示,关键方法是 localeResolver.setLocale()
:
public class LocaleChangeInterceptor extends HandlerInterceptorAdapter {
public static final String DEFAULT_PARAM_NAME = "locale";
// 指定从哪个请求参数拿值
@Getter
@Setter
private String paramName = DEFAULT_PARAM_NAME;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws ServletException {
// 从请求参数中获取需要切换的locale
String newLocale = request.getParameter(getParamName());
if (newLocale != null) {
if (checkHttpMethod(request.getMethod())) {
// 获取DispatcherServlet中指定的LocaleResolver,默认情况下是AcceptoHeaderLocaleResolver
LocaleResolver localeResolver = RequestContextUtils.getLocaleResolver(request);
if (localeResolver == null) {
throw new IllegalStateException("No LocaleResolver found: not in a DispatcherServlet request?");
}
try {
// 修改请求/响应的相关信息(AcceptHeaderLocaleResolver不支持该方法,会报错)
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;
}
}
在项目中实现国际化切换不一定需要基于LocaleChangeInterceptor,但如果想使用它,必须考虑以下几点:
LocaleChangeInterceptor
专用于切换locale,意味着切换后的locale需要能存储/刷新到某个地方。
否则例如自定义一个使用jwt时的UserContextInterceptor ( 记录当前请求locale到上下文,方便后续业务的查询 ) 即可,没必要写在LocaleChangeInterceptor
中,会引起歧义。- 不能使用原生的
AcceptHeaderLocaleResolver
、FixedLocaleResolver
,它们不支持对请求包含的locale的存储/刷新,即调用localeResolver.setLocale()
会报错。- 按需调用
localeChangeInterceptor.setParamName()
方法。请求中携带的语言区域信息不一定在locale
字段中。- 按需重写
localeChangeInterceptor.preHandle()
方法。不一定从请求参数中获取,还有可能从请求头中获取。- 后续流程中如果需要从请求中获取对应的locale,建议使用
RequestContextUtils.getLocale(request)
。( 不过一般我们的应用都会选择定义自己的ThreadLocale来存储相关信息 )
4.4.3 spring boot中的使用
基于spring-mvc的springboot应用中,WebMvcConfiguration
有如下设置,可通过自定义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 总结
经过一轮分析、思考、画流程图、总结,对项目上所需的国际化方面的使用和原理有了很深的了解。
问题1:spring.message.basename
可以填写哪些格式的值(xx,xx,xx)
以springboot中的使用为例,填写值的格式由具体的使用的
MessageSource
决定。
默认情况下由ResourceBundleMessageSource
负责国际化信息定位加载,只能识别如下的文本,不包含"classpath"等前缀,也不包含 "_zh??"、".properties" 等后缀。且检索到第一个文件即停止搜索。i18n/messages,msg/mymessage,abc/haha // 支持逗号分隔,但不能存在 ”classpath:“ 等
可自定义仿写
ResourceBundleMessageSource
实现更多格式的 basename解析,但没这个必要。
注入一个ReloadableResourceBundleMessageSource
,可替换默认实现,支持如下格式的basename:i18n/message、 classpath:i18n/messages、 // classpath: 前缀 file:///xxx、 // 文件协议 /xxxxx、 //
问题2:对基于maven的多模块项目,是否支持将分散在多个子模块中的国际化信息收集整合
通过
ReloadableResourceBundleMessageSource
可间接支持,但需要自己解析"classpath*:",如章节4.3所示。
问题3:国际化的处理离不开资源定位与加载,我接触到的开源框架中,都有什么样的处理?
jdk的ResourceBundle确定了一个规约:(也可能不是jdk的这个规约,其他语言应该也有类似的处理)
basename不能包含语言区域信息或文件后缀名
当指定的语言区域( 如 "zh_CN" ) 无法搜索到资源时,回退使用 "zh" 甚至 Locale.ROOT进行再次搜索
spring定义了自己的国际化资源加载接口
MessageSource
及相关实现,但也是遵守ResourceBundle的规约,同时进行了功能增强处理。资源加载基本都使用了File/Path 或者URL类进行处理。