Skip to content

Latest commit

 

History

History
458 lines (342 loc) · 18.6 KB

i18n 国际化实现.md

File metadata and controls

458 lines (342 loc) · 18.6 KB

Java 国际化实现起来也比较简单,实际上就是读取配置文件中的数据而已,与普通的文件读取区别就是多了动态确定配置文件名。

关于 Java 实现国际化 Oracle 官网也有专门的教程:https://docs.oracle.com/javase/tutorial/i18n/index.html

这个教程很清晰,基本上看一遍就空白原理了,不过本文还是要赘述一下。

Java 实现国际化主要使用 java.util.ResourceBundle 类。该工具类主要是用于加载 resources 下的 Resource Bundle(就是我们配置的语言文件),根据 java.util.Locale 来进行国际化转化,所以在之前需要先介绍下 java.util.Locale。

java.util.Locale

Locale 类是 Java 内置的本地方言类,本身内置了多个国家方言,这些内置的基本上就够我们使用了。通过该类我们可以设置和获取指定方言,这个内置的国家方言为后面 Resource Bundle 的定义提供了标准。

看下基本示例:

public class I18nMain {

    public static void main(String[] args) {

        // 获取系统默认方言(默认方言获取到的是系统语言, 当前操作系统默认方言是 en_CN)
        Locale systemDefaultLocal = Locale.getDefault();
        System.out.println(systemDefaultLocal);

        // 设置系统默认方言
        // 除了获取系统默认方言外, 我们也可以明确设置系统默认方言. 比如 zh_CN(简体中文)
        Locale.setDefault(Locale.SIMPLIFIED_CHINESE);

        // 再次获取默认系统方言
        // 就会发现由 en_CN 变成 zh_CN 了
        System.out.println(Locale.getDefault());

        // 通过指定语言和地区构造方言实例, 如下 zh_TW(繁体中文):
        // 等效于 Locale.TRADITIONAL_CHINESE
        Locale createLocal = new Locale("zh", "TW");

        System.out.println(createLocal);
    }
}

set-locale-a04a0edf5f1173ba.png

另外,Locale 类中内置了许多方言实例(如下图),这些方言实例基本上就够我们使用了。如果业务太广不够用也可以自己构造 Locale 实例~

locale-4d306b99fe9d1faf.png

用法很简单,不过我们需要知道关于方言(java.util.Locale)的组成包括两部分:语言和区域。

注意上面输出的方言:zh_CN、en_CN、zh_TW,这是标准的方言格式。_ 前面的表示具体的国家、后面的表示的是语言。比如我们上面构造的实例:

Locale createLocal = new Locale("zh", "TW");

前面也说了,Locale 就是为 java.util.ResourceBundle 提供区域标准。这个所谓的标准就是读取指定的语言文件,比如上面构造的 Locale 输出信息是 zh_TW,那么 java.util.ResourceBundle 就会读取资源目录下的 zh_TW 文件。所谓的本地或就是通过这种方式来确定要读取的语言文件。

java.text.MessageFormat

在过去 i18n 消息时我们可能还需要做一些消息格式化定制需求。

简单地说 MessageFormat 就是一个占位符消息填充,与我们使用 slf4j 打印日志时使用的占位符一致,不过我们需要指定下标。看下示例:

public class MessageFormatMain {

    public static void main(String[] args) {
        MessageFormat format = new MessageFormat("hello, {0}. My name is {1}.");

        String message = format.format(new String[]{"Han Meimei", "Lilei"});

        System.out.println(message);
    }
}

输出如下:

hello, Han Meimei. My name is Lilei.

java.util.ResourceBundle

终于到了我们的主角上场表演了~

ResourceBundle 主要用于加载资源目录(通常是 resources 目录)下的语言包文件,先看下演示示例:

在 resources 目录下创建一个 i18n 目录,之后在目录下创建 Resource Bundle 文件:

create-resource-bundle-file-ecbeaca92756e0a7.png

resource-bundle-add-lang-869faf8570425d43.png

之后就会在 i18n 目录下出现三个语言包文件:

i18n-dir-QnSyO0vvMH4.png

这里我默认将 LanguageBundle 文件设置简体中文语言包,en_US 为英语语言包,zh_TW 设置为繁体中文语言包。

现在就添加语言配置:

i18n-add-item-0y5N5zw.png

国际化示例

public class ResourceBundleMain {

    // 语言包所在的目录
    // 值也可以为 i18n.LanguageBundle, 底层会自动将 . 转换为 /
    static final String BASE_NAME = "i18n/LanguageBundle";

    public static void main(String[] args) {

        // 设置默认方言为 简体中文 zh_CN
        Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
        ResourceBundle zhCnBundle = ResourceBundle.getBundle(BASE_NAME, Locale.getDefault());
        System.out.println(zhCnBundle.getString("greetings"));

        // 获取英文方言语言环境
        ResourceBundle usBundle = ResourceBundle.getBundle(BASE_NAME, Locale.US);
        System.out.println(usBundle.getString("greetings"));

        // 获取繁体中文方言语言环境
        ResourceBundle zhTwBundle = ResourceBundle.getBundle(BASE_NAME, Locale.TRADITIONAL_CHINESE);
        System.out.println(zhTwBundle.getString("greetings"));
    }

}

正常输出如下:

你好
Hello
你號

但是有时候你会发现输出的中文是乱码:

chinese-encoding-TgqYFjnY8aw.png

中文乱码问题

导致中文乱码的原因是由于 properties 文件编码的问题,默认情况下 properties 的编码格式是 ISO8895-1 编码格式,所以当出现中文时就会出现编码问题。

解决方案主要有两种:修改 properties 编码格式,但是这种是治标不治本。

第二种就是扩展源代码,直接修改文件流编码格式,这是推荐的方式。来看下怎么去扩展:

通过 Debug 跟踪源代码你会发现文件流生成是在 java.util.ResourceBundle.Control#newBundle 方法中实现的,Control 是 ResourceBundle 中的内部类。

跟踪的调用链如下:

java.util.ResourceBundle#getBundle(java.lang.String);
java.util.ResourceBundle#getBundleImpl;
java.util.ResourceBundle#findBundle;
java.util.ResourceBundle#loadBundle;
java.util.ResourceBundle.Control#newBundle;

所以我们需要扩展 Control#newBundle 方法。在此之前先来看下为什么会是该方法,一起阅读下该方法:

源码解析编码问题

public ResourceBundle newBundle(String baseName, Locale locale, String format,
                                ClassLoader loader, boolean reload)
            throws IllegalAccessException, InstantiationException, IOException {
    String bundleName = toBundleName(baseName, locale);
    ResourceBundle bundle = null;
    if (format.equals("java.class")) {
        try {
            @SuppressWarnings("unchecked")
            Class<? extends ResourceBundle> bundleClass
                = (Class<? extends ResourceBundle>)loader.loadClass(bundleName);

            // If the class isn't a ResourceBundle subclass, throw a
            // ClassCastException.
            if (ResourceBundle.class.isAssignableFrom(bundleClass)) {
                bundle = bundleClass.newInstance();
            } else {
                throw new ClassCastException(bundleClass.getName()
                             + " cannot be cast to ResourceBundle");
            }
        } catch (ClassNotFoundException e) {
        }
    } else if (format.equals("java.properties")) { // 第一处: 注意这个判断
        final String resourceName = toResourceName0(bundleName, "properties");
        if (resourceName == null) {
            return bundle;
        }
        final ClassLoader classLoader = loader;
        final boolean reloadFlag = reload;
        InputStream stream = null;
        try {
            stream = AccessController.doPrivileged(
                new PrivilegedExceptionAction<InputStream>() {
                    public InputStream run() throws IOException {
                        InputStream is = null;
                        if (reloadFlag) {
                            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 PropertyResourceBundle(stream);
            } finally {
                stream.close();
            }
        }
    } else {
        throw new IllegalArgumentException("unknown format: " + format);
    }
    return bundle;
}

因为我们的配置文件是 properties,所以会进入 第一处,通过断点你最终会发现会在 第二处 返回流数据。所以我们直接在这里修改流编码即可,其实修改流编码很简单。仅仅需要修改 new PropertyResourceBundle(stream) 这个代码即可。

通过查看 PropertyResourceBundle 源码你会发现他有两个构造方法:

java.util.PropertyResourceBundle#PropertyResourceBundle(java.io.InputStream);
java.util.PropertyResourceBundle#PropertyResourceBundle(java.io.Reader);

在 java.util.ResourceBundle.Control#newBundle 源码中使用的是第一个。

注意第二个构造方法使用的是 java.io.Reader 字符流,Java IO 主要使用的是适配器模式。如果对 JDK 源码比较熟悉的话你会发现 java.io.Reader 有一个子类:java.io.InputStreamReader

该类又有几个构造方法:

java.io.InputStreamReader#InputStreamReader(java.io.InputStream);
java.io.InputStreamReader#InputStreamReader(java.io.InputStream, java.lang.String);
java.io.InputStreamReader#InputStreamReader(java.io.InputStream, java.nio.charset.Charset);
java.io.InputStreamReader#InputStreamReader(java.io.InputStream, java.nio.charset.CharsetDecoder);

其中第二个个第三个构造方法可以设置编码格式,然后你就会发现解决办法有了,直接将方面的 第二处 源码如下即可:

bundle = new PropertyResourceBundle(new InputStreamReader(stream, StandardCharsets.UTF_8));

是不是很简单?

所以写一个 java.util.ResourceBundle.Control 扩展类,用于设置编码格式:

public class ResourceBundleControlEncode extends ResourceBundle.Control {

    private final Charset encode;

    public ResourceBundleControlEncode() {
        this(StandardCharsets.UTF_8);
    }


    public ResourceBundleControlEncode(Charset encode) {
        this.encode = encode;
    }

    /**
     * copy from {@link ResourceBundle.Control#newBundle(String, Locale, String, ClassLoader, boolean)}
     */
    @Override
    public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) throws IllegalAccessException, InstantiationException, IOException {
        String bundleName = toBundleName(baseName, locale);
        ResourceBundle bundle = null;
        if ("java.class".equals(format)) {
            try {
                @SuppressWarnings("unchecked")
                Class<? extends ResourceBundle> bundleClass
                        = (Class<? extends ResourceBundle>) loader.loadClass(bundleName);

                // If the class isn't a ResourceBundle subclass, throw a
                // ClassCastException.
                if (ResourceBundle.class.isAssignableFrom(bundleClass)) {
                    bundle = bundleClass.newInstance();
                } else {
                    throw new ClassCastException(bundleClass.getName()
                            + " cannot be cast to ResourceBundle");
                }
            } catch (ClassNotFoundException ignored) {
            }
        } else if ("java.properties".equals(format)) {
            final String resourceName = toResourceName0(bundleName, "properties");
            if (resourceName == null) {
                return null;
            }
            final ClassLoader classLoader = loader;
            final boolean reloadFlag = reload;
            InputStream stream;
            try {
                stream = AccessController.doPrivileged(
                        (PrivilegedExceptionAction<InputStream>) () -> {
                            InputStream is = null;
                            if (reloadFlag) {
                                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 PropertyResourceBundle(new InputStreamReader(stream, StandardCharsets.UTF_8));
                } finally {
                    stream.close();
                }
            }
        } else {
            throw new IllegalArgumentException("unknown format: " + format);
        }
        return bundle;
    }

    /**
     * copy from {@link ResourceBundle.Control#toResourceName0(String, String)}
     */
    private String toResourceName0(String bundleName, String suffix) {
        // application protocol check
        if (bundleName.contains("://")) {
            return null;
        } else {
            return toResourceName(bundleName, suffix);
        }
    }
}

扩展类 ResourceBundleControlEncode 提供了两个构造方法:

org.example.jackson.ResourceBundleControlEncode#ResourceBundleControlEncode();
org.example.jackson.ResourceBundleControlEncode#ResourceBundleControlEncode(java.nio.charset.Charset);

默认的构造方法设置的编码格式就是 UTF-8,第二个构造方法用于设置自定义编码。

扩展类已经写好了,该怎么使用呢?

前面获取方言资源文件我们使用的是 java.util.ResourceBundle#getBundle(java.lang.String) 方法,他其实有许多重载方法,其中y有如下重载方法:

java.util.ResourceBundle#getBundle(java.lang.String, java.util.ResourceBundle.Control);
java.util.ResourceBundle#getBundle(java.lang.String, java.util.Locale, java.util.ResourceBundle.Control);

好了,现在知道该怎么使用了,看下最终示例:

public class ResourceBundleMain {

    // 语言包所在的目录
    // 值也可以为 i18n.LanguageBundle, 底层会自动将 . 转换为 /
    static final String BASE_NAME = "i18n/LanguageBundle";

    public static void main(String[] args) {

        // 设置默认方言为 简体中文 zh_CN
        Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
        ResourceBundle zhCnBundle = ResourceBundle.getBundle(BASE_NAME, Locale.getDefault(), new ResourceBundleControlEncode());
        System.out.println(zhCnBundle.getString("greetings"));

        // 获取英文方言语言环境
        ResourceBundle usBundle = ResourceBundle.getBundle(BASE_NAME, Locale.US, new ResourceBundleControlEncode());
        System.out.println(usBundle.getString("greetings"));

        // 获取繁体中文方言语言环境
        ResourceBundle zhTwBundle = ResourceBundle.getBundle(BASE_NAME, Locale.TRADITIONAL_CHINESE, new ResourceBundleControlEncode());
        System.out.println(zhTwBundle.getString("greetings"));
    }

}

输出:

fix-chinese-encoding-b1t3HCAnX9I.png

完美~

Spring 国际化的实现

Spring 实现国际化主要使用的是 org.springframework.context.support.ResourceBundleMessageSource 类。该类接受一个编码属性,所以在 Spring 中我们直接指定编码格式就不会有中文乱码问题了。

那 Spring 是如何实现的呢?

通过阅读 org.springframework.context.support.ResourceBundleMessageSource 源码你会发现在其内部定义了一个内部类: org.springframework.context.support.ResourceBundleMessageSource.MessageSourceControl。

该类扩展了 java.util.ResourceBundle.Control 类:

SpringMessageSourceControl-OBXKunD2ZXc.png

我们直接看他的流式如何处理的:

SpringMessageSourceControlDefaultEncoding-5GuStCWPCX0.png

然后你就会发现与我们处理的类似,如果指定了编码格式就使用我们设置的编码格式,并且同样借助了 java.io.InputStreamReader 字符流类。

好了,最后我们来写一个 Bean 注册示例:

@Configuration
public class I18nConfig {

    static final String BASE_NAME = "i18n/LanguageBundle";
    
    @Bean
    public ResourceBundleMessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename(BASE_NAME);
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }
}

使用示例就不多说了~

完结,撒花~