本文章已同步分享在、简书、掘金,作为原创记录凭证。转载请标明出处,谢谢。 个人博客,欢迎访问☺
在本地基于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.propertiesspring.messages.basename 对应 MessageSourceProperties 类中的 basename 属性,有如下注释:
以逗号分隔的基名列表 ( 本质上是一个完全限定的类路径位置 ),每个基名都遵循 ResourceBundle 约定,并对基于 / 的位置提供宽松的支持。如果它不包含包限定符 ( 例如org.mypackage ) 时,它将从类路径根解析。
可以看出,spring框架遵循JDK ResourceBundle 定义的标准。因此下面从 ResourceBundle 开始进行分析。
问题1:spring.message.basename 可以填写哪些格式的值(xx,xx,xx)
问题2:对基于maven的多模块项目,是否支持将分散在多个子模块中的国际化信息收集整合
问题3:国际化的处理离不开 资源定位与加载,我接触到的开源框架中,都有什么样的处理?
ResourceBundle 类的基本用法如下,下面根据 getBundle() 方法入口逐步了解它加载国际化的流程。
public static void main(String[] args) { ResourceBundle bundle = ResourceBundle.getBundle("i18n/messages", Locale.getDefault()); String name = bundle.getString("name"); }如果基于英语语言区域的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; } }建议查看 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 locales> 包含:
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采用倒序遍历的原因,假设上一步得到的候选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 }上述例举了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.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 文件。
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等现在基于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<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 { // 添加额外构造函数,用于合并多个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<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对象的数据 public void combine(MyPropertyResourceBundle others) { if (others != null) { lookup.putAll(others.lookup); } } // ==================privates==================== private Map<String,Object> lookup; }spring提供了自己的国际化信息结构,类结构图如下所示。其中最重要的两个实现类是 ReloadableResourceBundleMessageSource、ResourceBundleMessageSource。
MessageSource 接口定义了获取国际化资源的标准。
AbstractMessageSource 抽象类将应用级的国际化功能进行了拆分:
搜索并加载指定locale的功能 ( 将 resolveCode() 方法暴露给子类去实现 )找不到国际化信息时,回退使用默认信息国际化信息渲染MessageSourceSupport 提供了对资源渲染的基础支持
AbstractMessageSource 有2个直接继承者:
StaticMessageSource:简易实现,支持以编程的方式注册消息。AbstractResourceBasedMessageSource:从类名可看出,其子类实现者支持从资源中注册消息。AbstractMessageSource 存在一个集合变量 basenameSet,说明其支持从多个位置读取资源文件。
Spring框架中主要使用 ResourceBundleMessageSource、ReloadableResourceBundleMessageSource 实现该功能,两者具体有差别。
ResourceBundleMessageSource 内部调用 ResourceBundle 类进行具体的国际化资源定位和加载,详情请看章节3。
ResourceBundle 只支持从单个basename ( 例如 i18n/messages ) 查找指定语言区域的资源。ResourceBundleMessageSource 对此做了一层封装,定义了一个集合变量 ( 如下所示 ) 允许用户定义多个basename,以在多个位置搜索。最终会返回搜索到指定语言区域的第一个的资源。
// Map<basename, Map<Locale, ResourceBundle>> private final Map<String, Map<Locale, ResourceBundle>> 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()); }和 ResourceBundleMessageSource 相比,这个就前面多了 Reloadable,因此可以推测该类对 ResourceBundleMessageSource 进行了改进,可以实现国际化资源的重加载。以下对这个加载进行分析:
// 三个成员变量 // Map<basename, Map<locale, List<filename>>> 拿到后永久缓存,未找到remove调用处 private final ConcurrentMap<String, Map<Locale, List<String>>> cachedFilenames = new ConcurrentHashMap<>(); // Map<filename, propertiesHolder> 指定缓存过期时间后,使用该缓存。 // 缓存不过期时,缓存一次后,基本不会再被使用,而是调用下面的 cacheMergedProperties private final ConcurrentMap<String, PropertiesHolder> cachedProperties = new ConcurrentHashMap<>(); // Map<locale, propertiesHolder> 当缓存不过期时,使用该缓存 private final ConcurrentMap<Locale, PropertiesHolder> 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<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); }从 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:根据优先级作如下处理: 为每个国际化文件的加载指定编码格式。可指定编码格式。默认的系统编码。spring的国际化渲染没有单独定义自己的接口,而是直接使用了JDK中的 MessageFormat 渲染,用法可参考链接。
MessageFormat.format("hello, {0}", "world");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<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; }上述章节已介绍spring中i18n的加载,下面再介绍spring mvc中请求对象如何指定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切换时刷新记录。
在spring-webmvc包中,仅有 DispatcherServlet、LocaleChangeInterceptor 使用到 LocaleResolver 及其实现类。
其中 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 # ...该拦截器专门用于根据请求切换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来存储相关信息 )
基于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; }经过一轮分析、思考、画流程图、总结,对项目上所需的国际化方面的使用和原理有了很深的了解。
问题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类进行处理。
