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);
}
}
另外,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_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
你號
但是有时候你会发现输出的中文是乱码:
中文乱码问题
导致中文乱码的原因是由于 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)}
*/
@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"));
}
}
输出:
完美~
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 注册示例:
@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;
}
}
使用示例就不多说了~
完结,撒花~