国际化分析与处理

1 前言

在本地基于springboot的maven多模块项目中,拆出 module1module2module3 三个子模块,每个模块都有自己的国际化资源,启动项目前添加配置 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 流程图

resourcebundle-process.png

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范围有详细描述。

以下举例子说明:

  1. 假设传递 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
    
  2. 假设传递的 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
    
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.propertiesi18n/messages_zh.propertiesi18n/messages.properties 文件。

3.2 总结

ResourceBundle 类中大量使用了模板设计模式,通过 ResourceBundle.Control 对国际化资源的定位与加载的全流程进行定制化处理,十分灵活。

局限性:

  1. 默认情况下,只能加载找到的第一个文件,存在一定的不确定性。且目前基于maven构建的项目来说,模块化是很常见的。基于control定制需要花一定的功夫。
  2. 提供的方法较为原始、底层。需要做大量的封装处理。例如有如下的需求:
    • 基于 baseName=classpath*:i18n/messages 进行搜索。需要改写control.newBundle()
    • 拿到国际化信息后,能进行进一步渲染处理,例如:message=这是一个{1},具体的值在调用时渲染。
    • 假设国际化文件不是来源于classpath,而是文件系统或网络,基于control的改写难度更大。
      从ResourceBundle的资源定位和加载流程中,可以总结出一些步骤是国际化处理中的通用步骤:
  3. 加载的资源名称由用户指定,但具体文件的格式基本固定。Locale中有多个字段:language、region、。。 在最终构造资源名称时,基本都是 {baseName}_{language}_{region}.properties
  4. 指定一个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<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;
}

4 Spring中的国际化

spring提供了自己的国际化信息结构,类结构图如下所示。其中最重要的两个实现类是 ReloadableResourceBundleMessageSourceResourceBundleMessageSource

SprintMessaegSourceArchitecture
  1. MessageSource 接口定义了获取国际化资源的标准。
  2. AbstractMessageSource 抽象类将应用级的国际化功能进行了拆分:
    • 搜索并加载指定locale的功能 ( 将 resolveCode() 方法暴露给子类去实现 )
    • 找不到国际化信息时,回退使用默认信息
    • 国际化信息渲染
  3. MessageSourceSupport 提供了对资源渲染的基础支持
  4. AbstractMessageSource 有2个直接继承者:
    • StaticMessageSource:简易实现,支持以编程的方式注册消息。
    • AbstractResourceBasedMessageSource:从类名可看出,其子类实现者支持从资源中注册消息。
      AbstractMessageSource 存在一个集合变量 basenameSet,说明其支持从多个位置读取资源文件。

4.1 资源定位加载

Spring框架中主要使用 ResourceBundleMessageSourceReloadableResourceBundleMessageSource 实现该功能,两者具体有差别。

4.1.1 ResourceBundleMessageSource

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

4.1.2 ReloadableResourceBundleMessageSource

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();
}
reloadable-resourcebundle-messagesource

从上图可看出 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);
}

4.1.3 两者对比

ReloadableResourceBundleMessageSource 的类注释部分就能基本了解它们的区别:

  1. 资源名称basename指定:
    • 相同:两者都能指定多个basename,遍历查找指定的国际化code。都遵循基本的ResourceBundle规则 ( 不指定文件拓展名和语言代码 )。
    • 不同:
      • ResourceBundleMessageSource:默认情况下,只能支持 xxx/messagesxxx/mymessages 等名称格式。
      • ReloadableResourceBundleMessageSource:默认情况下,由 DefaultResourceLoader 类来支持 classpath:/file: 等多种形式的basename。
  2. 消息数据结构:
    • ResourceBundleMessageSource:直接使用 ResourceBundle 的map集合存储,通过 PropertyResourceBundle 加载。
    • ReloadableResourceBundleMessageSource:使用 Properties 存储,通过 PropertiesPersister 加载。可根据时间戳重加载特定文件。
  3. 加载文件的编码格式指定:
    • ResourceBundleMessageSource:默认为 ISO-8859-1,可指定编码,但对所有国际化文件的加载有效。
    • ReloadableResourceBundleMessageSource:根据优先级作如下处理:
      1. 为每个国际化文件的加载指定编码格式。
      2. 可指定编码格式。
      3. 默认的系统编码。

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<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中的使用

上述章节已介绍spring中i18n的加载,下面再介绍spring mvc中请求对象如何指定locale,获取本地化信息。对应的业务场景例如登录时的语言切换。

4.4.1 locale解析策略接口

springmvc定义了一套基于web的locale解析策略接口及实现:

LocaleResolverArchitectuer

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的需求。一次性指定后不会改变。
  • CookieLocaleResolverSessionLocaleResolver 分别从cookies、session获取localeContext。
    该类存在的意义:应用有记录或刷新locale的需求。例如记录在cookie、session中,同时能够在locale切换时刷新记录。

4.4.2 locale解析策略接口的应用

在spring-webmvc包中,仅有 DispatcherServletLocaleChangeInterceptor 使用到 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,但如果想使用它,必须考虑以下几点:

  1. LocaleChangeInterceptor 专用于切换locale,意味着切换后的locale需要能存储/刷新到某个地方
    否则例如自定义一个使用jwt时的UserContextInterceptor ( 记录当前请求locale到上下文,方便后续业务的查询 ) 即可,没必要写在 LocaleChangeInterceptor 中,会引起歧义。
  2. 不能使用原生的 AcceptHeaderLocaleResolverFixedLocaleResolver,它们不支持对请求包含的locale的存储/刷新,即调用 localeResolver.setLocale() 会报错。
  3. 按需调用 localeChangeInterceptor.setParamName() 方法。请求中携带的语言区域信息不一定在 locale 字段中。
  4. 按需重写 localeChangeInterceptor.preHandle() 方法。不一定从请求参数中获取,还有可能从请求头中获取。
  5. 后续流程中如果需要从请求中获取对应的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的这个规约,其他语言应该也有类似的处理)

  1. basename不能包含语言区域信息或文件后缀名

  2. 当指定的语言区域( 如 "zh_CN" ) 无法搜索到资源时,回退使用 "zh" 甚至 Locale.ROOT进行再次搜索

spring定义了自己的国际化资源加载接口 MessageSource 及相关实现,但也是遵守ResourceBundle的规约,同时进行了功能增强处理。

资源加载基本都使用了File/Path 或者URL类进行处理。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,504评论 4 365
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,898评论 1 300
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,218评论 0 248
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,322评论 0 214
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,693评论 3 290
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,812评论 1 223
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,010评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,747评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,476评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,700评论 2 251
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,190评论 1 262
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,541评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,206评论 3 240
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,129评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,903评论 0 199
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,894评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,748评论 2 274