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_CHINESELocale createLocal = new Locale("zh", "TW");System.out.println(createLocal);}}

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

用法很简单,不过我们需要知道关于方言(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 文件:


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

这里我默认将 LanguageBundle 文件设置简体中文语言包,en_US 为英语语言包,zh_TW 设置为繁体中文语言包。
现在就添加语言配置:

国际化示例
public class ResourceBundleMain {// 语言包所在的目录// 值也可以为 i18n.LanguageBundle, 底层会自动将 . 转换为 /static final String BASE_NAME = "i18n/LanguageBundle";public static void main(String[] args) {// 设置默认方言为 简体中文 zh_CNLocale.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你號
但是有时候你会发现输出的中文是乱码:

中文乱码问题
导致中文乱码的原因是由于 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")) { // 1️⃣ 注意这个判断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 {// 2️⃣ 返回流bundle = new PropertyResourceBundle(stream);} finally {stream.close();}}} else {throw new IllegalArgumentException("unknown format: " + format);}return bundle;}
因为我们的配置文件是 properties,所以会进入 1️⃣ 中,通过断点你最终会发现会在 2️⃣ 处返回流数据。所以我们直接在这里修改流编码即可,其实修改流编码很简单。仅仅需要修改 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);
其中第二个个第三个构造方法可以设置编码格式,然后你就会发现解决办法有了,直接将方面的 2️⃣ 源码如下即可:
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)}*/@Overridepublic 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 checkif (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_CNLocale.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"));}}
输出:

完美~
Spring 国际化的实现
Spring 实现国际化主要使用的是 org.springframework.context.support.ResourceBundleMessageSource 类。该类接受一个编码属性,所以在 Spring 中我们直接指定编码格式就不会有中文乱码问题了。
那 Spring 是如何实现的呢?
通过阅读 org.springframework.context.support.ResourceBundleMessageSource 源码你会发现在其内部定义了一个内部类: org.springframework.context.support.ResourceBundleMessageSource.MessageSourceControl。
该类扩展了 java.util.ResourceBundle.Control 类:

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

然后你就会发现与我们处理的类似,如果指定了编码格式就使用我们设置的编码格式,并且同样借助了 java.io.InputStreamReader 字符流类。
好了,最后我们来写一个 Bean 注册示例:
@Configurationpublic class I18nConfig {static final String BASE_NAME = "i18n/LanguageBundle";@Beanpublic ResourceBundleMessageSource messageSource() {ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();messageSource.setBasename(BASE_NAME);messageSource.setDefaultEncoding("UTF-8");return messageSource;}}
使用示例就不多说了~
完结,撒花~
