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 的定义提供了标准。

看下基本示例:

  1. public class I18nMain {
  2. public static void main(String[] args) {
  3. // 获取系统默认方言(默认方言获取到的是系统语言, 当前操作系统默认方言是 en_CN)
  4. Locale systemDefaultLocal = Locale.getDefault();
  5. System.out.println(systemDefaultLocal);
  6. // 设置系统默认方言
  7. // 除了获取系统默认方言外, 我们也可以明确设置系统默认方言. 比如 zh_CN(简体中文)
  8. Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
  9. // 再次获取默认系统方言
  10. // 就会发现由 en_CN 变成 zh_CN 了
  11. System.out.println(Locale.getDefault());
  12. // 通过指定语言和地区构造方言实例, 如下 zh_TW(繁体中文):
  13. // 等效于 Locale.TRADITIONAL_CHINESE
  14. Locale createLocal = new Locale("zh", "TW");
  15. System.out.println(createLocal);
  16. }
  17. }

image.png

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

image.png

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

注意上面输出的方言:zh_CN、en_CN、zh_TW,这是标准的方言格式。

_ 前面的表示具体的国家、后面的表示的是语言。比如我们上面构造的实例:

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

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

java.text.MessageFormat

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

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

  1. public class MessageFormatMain {
  2. public static void main(String[] args) {
  3. MessageFormat format = new MessageFormat("hello, {0}. My name is {1}.");
  4. String message = format.format(new String[]{"Han Meimei", "Lilei"});
  5. System.out.println(message);
  6. }
  7. }

输出如下:

  1. hello, Han Meimei. My name is Lilei.

java.util.ResourceBundle

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

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

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

image.png

image.png

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

image.png

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

现在就添加语言配置:

image.png

国际化示例

  1. public class ResourceBundleMain {
  2. // 语言包所在的目录
  3. // 值也可以为 i18n.LanguageBundle, 底层会自动将 . 转换为 /
  4. static final String BASE_NAME = "i18n/LanguageBundle";
  5. public static void main(String[] args) {
  6. // 设置默认方言为 简体中文 zh_CN
  7. Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
  8. ResourceBundle zhCnBundle = ResourceBundle.getBundle(BASE_NAME, Locale.getDefault());
  9. System.out.println(zhCnBundle.getString("greetings"));
  10. // 获取英文方言语言环境
  11. ResourceBundle usBundle = ResourceBundle.getBundle(BASE_NAME, Locale.US);
  12. System.out.println(usBundle.getString("greetings"));
  13. // 获取繁体中文方言语言环境
  14. ResourceBundle zhTwBundle = ResourceBundle.getBundle(BASE_NAME, Locale.TRADITIONAL_CHINESE);
  15. System.out.println(zhTwBundle.getString("greetings"));
  16. }
  17. }

正常输出如下:

  1. 你好
  2. Hello
  3. 你號

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

image.png

中文乱码问题

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

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

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

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

跟踪的调用链如下:

  1. java.util.ResourceBundle#getBundle(java.lang.String);
  2. java.util.ResourceBundle#getBundleImpl;
  3. java.util.ResourceBundle#findBundle;
  4. java.util.ResourceBundle#loadBundle;
  5. java.util.ResourceBundle.Control#newBundle;

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

源码解析编码问题

  1. public ResourceBundle newBundle(String baseName, Locale locale, String format,
  2. ClassLoader loader, boolean reload)
  3. throws IllegalAccessException, InstantiationException, IOException {
  4. String bundleName = toBundleName(baseName, locale);
  5. ResourceBundle bundle = null;
  6. if (format.equals("java.class")) {
  7. try {
  8. @SuppressWarnings("unchecked")
  9. Class<? extends ResourceBundle> bundleClass
  10. = (Class<? extends ResourceBundle>)loader.loadClass(bundleName);
  11. // If the class isn't a ResourceBundle subclass, throw a
  12. // ClassCastException.
  13. if (ResourceBundle.class.isAssignableFrom(bundleClass)) {
  14. bundle = bundleClass.newInstance();
  15. } else {
  16. throw new ClassCastException(bundleClass.getName()
  17. + " cannot be cast to ResourceBundle");
  18. }
  19. } catch (ClassNotFoundException e) {
  20. }
  21. } else if (format.equals("java.properties")) { // 1️⃣ 注意这个判断
  22. final String resourceName = toResourceName0(bundleName, "properties");
  23. if (resourceName == null) {
  24. return bundle;
  25. }
  26. final ClassLoader classLoader = loader;
  27. final boolean reloadFlag = reload;
  28. InputStream stream = null;
  29. try {
  30. stream = AccessController.doPrivileged(
  31. new PrivilegedExceptionAction<InputStream>() {
  32. public InputStream run() throws IOException {
  33. InputStream is = null;
  34. if (reloadFlag) {
  35. URL url = classLoader.getResource(resourceName);
  36. if (url != null) {
  37. URLConnection connection = url.openConnection();
  38. if (connection != null) {
  39. // Disable caches to get fresh data for
  40. // reloading.
  41. connection.setUseCaches(false);
  42. is = connection.getInputStream();
  43. }
  44. }
  45. } else {
  46. is = classLoader.getResourceAsStream(resourceName);
  47. }
  48. return is;
  49. }
  50. });
  51. } catch (PrivilegedActionException e) {
  52. throw (IOException) e.getException();
  53. }
  54. if (stream != null) {
  55. try {
  56. // 2️⃣ 返回流
  57. bundle = new PropertyResourceBundle(stream);
  58. } finally {
  59. stream.close();
  60. }
  61. }
  62. } else {
  63. throw new IllegalArgumentException("unknown format: " + format);
  64. }
  65. return bundle;
  66. }

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

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

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

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

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

该类又有几个构造方法:

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

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

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

是不是很简单?

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

  1. public class ResourceBundleControlEncode extends ResourceBundle.Control {
  2. private final Charset encode;
  3. public ResourceBundleControlEncode() {
  4. this(StandardCharsets.UTF_8);
  5. }
  6. public ResourceBundleControlEncode(Charset encode) {
  7. this.encode = encode;
  8. }
  9. /**
  10. * copy from {@link ResourceBundle.Control#newBundle(String, Locale, String, ClassLoader, boolean)}
  11. */
  12. @Override
  13. public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) throws IllegalAccessException, InstantiationException, IOException {
  14. String bundleName = toBundleName(baseName, locale);
  15. ResourceBundle bundle = null;
  16. if ("java.class".equals(format)) {
  17. try {
  18. @SuppressWarnings("unchecked")
  19. Class<? extends ResourceBundle> bundleClass
  20. = (Class<? extends ResourceBundle>) loader.loadClass(bundleName);
  21. // If the class isn't a ResourceBundle subclass, throw a
  22. // ClassCastException.
  23. if (ResourceBundle.class.isAssignableFrom(bundleClass)) {
  24. bundle = bundleClass.newInstance();
  25. } else {
  26. throw new ClassCastException(bundleClass.getName()
  27. + " cannot be cast to ResourceBundle");
  28. }
  29. } catch (ClassNotFoundException ignored) {
  30. }
  31. } else if ("java.properties".equals(format)) {
  32. final String resourceName = toResourceName0(bundleName, "properties");
  33. if (resourceName == null) {
  34. return null;
  35. }
  36. final ClassLoader classLoader = loader;
  37. final boolean reloadFlag = reload;
  38. InputStream stream;
  39. try {
  40. stream = AccessController.doPrivileged(
  41. (PrivilegedExceptionAction<InputStream>) () -> {
  42. InputStream is = null;
  43. if (reloadFlag) {
  44. URL url = classLoader.getResource(resourceName);
  45. if (url != null) {
  46. URLConnection connection = url.openConnection();
  47. if (connection != null) {
  48. // Disable caches to get fresh data for
  49. // reloading.
  50. connection.setUseCaches(false);
  51. is = connection.getInputStream();
  52. }
  53. }
  54. } else {
  55. is = classLoader.getResourceAsStream(resourceName);
  56. }
  57. return is;
  58. });
  59. } catch (PrivilegedActionException e) {
  60. throw (IOException) e.getException();
  61. }
  62. if (stream != null) {
  63. try {
  64. // 更改流的编码格式, 解决中文编码问题
  65. bundle = new PropertyResourceBundle(new InputStreamReader(stream, StandardCharsets.UTF_8));
  66. } finally {
  67. stream.close();
  68. }
  69. }
  70. } else {
  71. throw new IllegalArgumentException("unknown format: " + format);
  72. }
  73. return bundle;
  74. }
  75. /**
  76. * copy from {@link ResourceBundle.Control#toResourceName0(String, String)}
  77. */
  78. private String toResourceName0(String bundleName, String suffix) {
  79. // application protocol check
  80. if (bundleName.contains("://")) {
  81. return null;
  82. } else {
  83. return toResourceName(bundleName, suffix);
  84. }
  85. }
  86. }

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

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

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

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

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

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

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

  1. public class ResourceBundleMain {
  2. // 语言包所在的目录
  3. // 值也可以为 i18n.LanguageBundle, 底层会自动将 . 转换为 /
  4. static final String BASE_NAME = "i18n/LanguageBundle";
  5. public static void main(String[] args) {
  6. // 设置默认方言为 简体中文 zh_CN
  7. Locale.setDefault(Locale.SIMPLIFIED_CHINESE);
  8. ResourceBundle zhCnBundle = ResourceBundle.getBundle(BASE_NAME, Locale.getDefault(), new ResourceBundleControlEncode());
  9. System.out.println(zhCnBundle.getString("greetings"));
  10. // 获取英文方言语言环境
  11. ResourceBundle usBundle = ResourceBundle.getBundle(BASE_NAME, Locale.US, new ResourceBundleControlEncode());
  12. System.out.println(usBundle.getString("greetings"));
  13. // 获取繁体中文方言语言环境
  14. ResourceBundle zhTwBundle = ResourceBundle.getBundle(BASE_NAME, Locale.TRADITIONAL_CHINESE, new ResourceBundleControlEncode());
  15. System.out.println(zhTwBundle.getString("greetings"));
  16. }
  17. }

输出:

image.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 类:

image.png

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

image.png

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

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

  1. @Configuration
  2. public class I18nConfig {
  3. static final String BASE_NAME = "i18n/LanguageBundle";
  4. @Bean
  5. public ResourceBundleMessageSource messageSource() {
  6. ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
  7. messageSource.setBasename(BASE_NAME);
  8. messageSource.setDefaultEncoding("UTF-8");
  9. return messageSource;
  10. }
  11. }

使用示例就不多说了~

完结,撒花~