陈颂光
全栈工程师,承接从编译器到网站的各类软件开发与咨询,也可以聊历史哲学。
关注我的 GitHub

让世界各地的用户都能用上你的软件

虽然中国人口不少,但还有更多人在国外。要争取来自世界各地的客户,让软件符合当地的习惯就有助提升观感。除了众所周知的语言外,时区、图标、数字等也可能导致闹笑话。因此,国际化在早期就开始考虑的话往往能做得更好,要是由于早期过于依赖一个地区的特点,后期再作补救将会更为昂贵。

为了让软件能适应不同的语言和使用地区,良好的实践是把系统分解为语言文化无关的可执行代码和提供语言文化特定信息的数据,通过依赖注入方式把后者动态地注入前者,于是需要两个过程:

  • 国际化(i18n),即设计程序时保证主体可执行代码可以在不加修改(更不用重新编译)的情况下支持新的语言和地区。
  • 本地化(l10n),即为各种语言和地区提供相应的数据,如文本翻译、字体、日期时间格式、分词算法。

由于这是一般性的介绍,我们主要讨论国际化,并且以Java应用程序为代表性例子。至于本地化的方面,如怎样寻找翻译员或其它了解各地风俗习惯的人,不会有太多着墨,只强调一下不要太依赖机器翻译。

区域

一个区域由以下代码共同决定:

  • ISO 639语言代码格式如[a-zA-Z]{2,8},如en表示英语、zh表示汉语
  • ISO 15924文字代码格式如[a-zA-Z]{4},如Arab表示阿拉伯、Cyrl表示西里尔、Hans表示简体字、Hant表示繁体字、Kana表示平假名、Latn表示拉丁。
  • ISO 3166地区代码格式如[a-zA-Z]{2} | [0-9]{3},如CNCHN表示中国、USUSA表示美国
  • 可选的IETF BCP 47变种代码格式为(('_'|'-') ([0-9][0-9a-zA-Z]{3} | [0-9a-zA-Z]{5,8}))*
  • 其它扩展代码,如Unicode类型、历法、货币

Java中区域由java.util.Locale对象表示,取得这种对象的方法有:

  • LocaleBuilder类的链式方法生成,如new Locale.Builder().setLanguage("zh").setRegion("CN").build()
  • Locale类的构造器
    • Locale(String language)
    • Locale(String language, String country)
    • Locale(String language, String country, String variant)
  • Locale类的工厂方法forLanguageTag从IETF BCP 47语言标签生成
  • Locale类的常量
  • Locale类的getAvailableLocales​静态方法返回JVM所有已安装区域
  • Locale类的getDefault​()静态方法返回JVM默认区域

另外,Locale类的filter静态方法实现了RFC 4647中匹配最优区域的方法。通常来说,JVM的默认区域也应该是用户最可能想要的,但提供切换到其它区域的方法也往往是有益的,不过要留意保证用户在看不懂界面主要语言的情况下能发现它(对于不懂中文的人来说,一个名为“英文”的选项并没有意义)。

文本

对于每个呈现给用户的文本信息,都应当能适应用户的语言和区域。因此这样的信息不应直接写到程序的可执行代码中,而应当多一层间接以增加灵活性。标准的方法是把一个模块中每个需要翻译的字符串对应于一个惟一的键,然后为各个地区分别制作翻译文件,其中指出各个键对应的待翻译字符串在该地区中分别应翻译为什么。不过,在决定用什么键时,有时也并不是直截了当的,如汉语不区分复数,也没有大小写,但在其它语言中却可能不同数量有不同的复数形式,还可能有首字母大写。

虽然我们以翻译为例,但任何与区域有关的字符串也可用同样技术注入,比如:

  • 适用于于不同区域的图标、音频或视频文件的位置(它们可能带有语言或文字,同时一些记号在不同文化含义不同,有的人习惯用叉表示不选中、有的人习惯用叉表示选中)
  • 适用于于不同区域的颜色(同一颜色可能令不同人产生不同的文化观感,如红色可能表示喜庆或血腥)
  • 表示不同区域的电话号、邮编、证件号格式的正则表达式

在Unix世界,最流行的翻译机制要数GNU gettext

对于Java程序,翻译文件是名为的基本名_语言_文字_国家_变种.properties基本名_语言_文字_国家.properties基本名_语言_文字.properties基本名_语言_国家_变种.properties基本名_语言_国家.properties基本名.properties的文件(Java 9前需要用ISO 8859-1编码,但再现在已经可以用UTF-8,不用再native2ascii做自动Unicode转义)。它由若干物理行(用"\n""\r""\r\n"分隔)组成:

  • 只由空白字符('\n''\r''\t''\f'' ')组成的物理行是空行,会被忽略
  • 首个非空白字符为'#''!'的物理行是注释行,也会被忽略
  • 其它物理行用于保存键值对,如果一行以奇数个反斜杠'\\'结束,则视为与下一行合并为同一逻辑行,包括最后的反斜杠、行结束符和下一行开首的全部空白字符会被删去。
    • 键从一个逻辑行的首个非空白字符到(但不包括)首个没有被转义的=:或空白字符。
    • 键后紧随的空白被忽略,键后首个非空白字符为=:的话,它们和紧随的空白也被忽略
    • 逻辑行余下部分为值(可以为空),其中也可以用类似Java中字符的转义字符,但有以下区别:
      • 不支持八进制转义
      • \b不表示退格
      • \后如不构成合法转义序列,则\就像不存在一样而不是报错
      • Unicode转义序列中只容许用一个u

而程序为了取得翻译:

  1. 应获取一个java.util.ResourceBundle,这可以调用工厂方法如java.util.ResourceBundle.getBundle​("翻译文件的路径"),其中路径参照类路径(目录分隔符也可用用.),但不要写上基本名后的部分。在需要指定区域而不是默认区域的话,则可在getBundle​第二个参数中指定Locale对象。其实还有能控制加载过程的其它工厂方法,但这里不展开了。
  2. 调用java.util.ResourceBundle对象(如BUNDLE)的getString方法(如BUNDLE.getString("键"))得到翻译。如果在指定区域找不到翻译,会转而在去掉区域最后一个_部分后得到的区域找,如此类推,最后仍然找不到则抛出MissingResourceException

如果希望管理字符串之外其它类型的区域相关对象,又或者动态生成翻译(比如用机器翻译或词典服务,虽然不提倡),可以编写一个类扩展ResourceBundle然后覆盖getObject方法,此类的命名除后缀为class外与前面规则同。在同时有class文件和properties的情况下,后者会被忽略。

如果要呈现的信息中需要包含运行期才能确定的东西,则千万不要以为各语言中句子结构一样只用一个一个词翻译,如中文“空间的限额”在英文却是”Quota of storage“。这时应用java.text.Message.Formatformat​(String pattern, Object... arguments)方法生成信息文本,其中模式的格式如下:

MessageFormatPattern:
    String
    MessageFormatPattern FormatElement String

FormatElement:
    { ArgumentIndex }
    { ArgumentIndex , FormatType }
    { ArgumentIndex , FormatType , FormatStyle }

FormatType:
    number
    date
    time
    choice

FormatStyle:
    short
    medium
    long
    full
    integer
    currency
    percent
    SubformatPattern

其中,

  • String中可以通过用'包围来引用任何不含'的字符串(不平衡的话视为引用到模式结束),而'本身可用''表示,没有被引用的花括号必须平衡
  • SubformatPattern由指定的子格式类型解读
  • ArgumentIndex为由09组成的非负整数

当需要反复构造或解析信息时,则应构造一个MessageFormat对象,如MessageFormat template=new MessageFormat​("模式");,也可用构造器的第二个参数可指定Locale对象。然后就可以反过来用Object[] parse​(String source)方法或其变种解析信息。

以下是一个简单例子,以下是一个Java源文件com/github/chungkwong/HelloWorld.java

package com.github.chungkwong;
import java.text.MessageFormat;
import java.util.ResourceBundle;

public class HelloWorld{
	private static final ResourceBundle MESSAGE_BUNDLE=ResourceBundle.getBundle("com/github/chungkwong/MessagesBundle");
	public static void main(String[] args){
		if(args.length==1){
			System.out.println(MessageFormat.format(MESSAGE_BUNDLE.getString("HELLO"),args[0]));
		}else{
			System.err.println(MESSAGE_BUNDLE.getString("USAGE"));
		}
	}
}

以下是一个默认资源包com/github/chungkwong/MessagesBundle.properties:

HELLO=Hello, {0}
USAGE=Usage:\n\t HelloWorld "Your name"

以下是一个简体中文资源包com/github/chungkwong/MessagesBundle_zh_CN.properties

HELLO={0},你好
USAGE=用法:\n\t HelloWorld "你的名字"

这样,同样输入

javac com/github/chungkwong/HelloWorld.java
java com.github.chungkwong.HelloWorld Me

在简体中文环境会输出“Me,你好”,而在英文环境则会输出“Hello, Me”。

虽然国际化代码看来有点啰唆,但其实常见的IDE如Netbeans有国际化向导可自动生成框架代码。

最后,指出软件文档也该提供有翻译版。

复数形式

当需要处理复数时java.text.ChoiceFormat​类可用派上用场,它可以根据一个数所在区间决定应用的格式,构造器ChoiceFormat​(double[] limits,String[] formats)构造的格式在limit[i] ≤ X < limit[i+1]时会用上format[i],也可用形如-1#is negative| 0#is zero or fraction | 1#is one |1.0<is 1+ |2#is two |2<is more than 2. 的字符串构造。

日期时间

同一日期和时间在不同区域的表示也可能不同,如英文的”November 3, 1997“可能是法文的“3 novembre 1997”。

要格式化或解析日期时间,可以用java.text.DateFormat类,其中适当的格式(包括历法、时区)可以用工厂方法getDateTimeInstance​getDateInstancegetTimeInstance​得到:

  • 可选的第一个参数可指定详细程度:
    • SHORT形如“12.13.52”或“3:30pm”
    • MEDIUM形如“Jan 12, 1952”
    • LONG形如“January 12, 1952”或“3:30:32pm”
    • FULL形如“Tuesday, April 12, 1952 AD”或“3:30:42pm PST”
  • ​可选的第二个参数可指定Locale对象

如果需要定制格式,则可以用SimpleDateFormat​类,用法见javadocs。另外,结合DateFormatSymbols可控制各月份、周天、时区名。

另一个问题来自时区、夏令时、历法等等,这时java.time包,提供了一些帮助,但仍然是有限度的,如支持穆斯林历、佛历、民国历和日本帝国历却不支持农历。

数值

同一数值在不同区域的表示也可能不同,如美国人用.作小数点,而法国人用,作小数点,一不小心就错了很多个量级。法国的“1.234,56”等于美国的“1,234.56”。

要格式化或解析数值,可以用java.text.DateFormat类,其中适当的格式(包括历法、时区)可以用工厂方法getCurrencyInstance​getIntegerInstancegetPercentInstance​getNumberInstance​getInstance​​得到,其中可选的第一个参数可指定Locale对象。我们还可以设置整数或小数部分位数限制和舍入模式。

如果需要定制格式,则可以用DecimalFormat​类,用法见javadocs。另外,结合DecimalFormatSymbols可控制货币号、百分号、分隔符、NaN名、无穷大名、零名和负号。

值得注意的是单位(包括度量衡和货币)也与区域有关。不能仅把单位换掉而值却不换算,一米可不是一码。

输入输出文本的组件

一个输出组件能正确显示文本,需要满足一些条件

  • 为了显示字符,需要保证安装了包含它的字体。JRE支持TrueType和PostScript Type 1字体,字体应该放在JRE的lib/fonts目录或者所在操作系统指定的字体目录(如Solaris或Linux的~/.fonts
  • 支持从右到左显示(想像阿拉伯文)并能正确处理左到右文本与右到左文本的混排(想像英文文章中引用了一句阿拉伯谚语)

对于输入组件,还有更多的问题:

  • 保证系统输入法能够使用,否则用户可能根本无法输入他们的文字(如键盘放不下中日韩字符,残疾人还有其它输入装置)。值得一提的是,即使是普通应用程序,在保存或打印时也可能应调用InputContext.endComposition提交文本。
  • 确定字符到底输入到哪(如光标的左侧还是右侧)

由于存在太多你意想不到的文字,导致各种上面列出的和没有列出的烦杂问题,一般不要自制显示文本的组件,而应该使用已经在世界各地广泛使用的。

其它考虑

关于Unicode,中国人往往会忽略了一些在中文不显著的问题,这里仅举一个例子,就是对于用户来说同一的字符或字符串文本可能有多个不同的Unicode表示方式。从而在处理前可能需要先规范化,如java.text.Normalizer.normalize("ℝ",java.text.Normalizer.Form.NFKD),其中规范化可分为NFC(先规范分解,再规范合成)、NFD(规范分解)、NFKC(先相容分解,再相容合成)、NFKD(相容分解)。

  • 规范等价的两个Unicode序列在正确显示时对用户来说没有任何区别,例如韩国的谚文、欧洲的重音字符(比如“Á”可表示为U+00C1U+0041 U+0301)。
  • 相容等价的两个Unicode序列虽然在语义上相同,但在显示形式可能有细微区别,如字体变种(如“R”与“ℝ”)、上下标(如“2”与“²”)、全半角(如“,”与“,”)、分数(如“1/3”与“⅓”)、连写(如“ffi”与“ffi”)。

我们不能要求所有程序员成为Unicode专家,但知道以下切实易行的实践有助减低碰壁的机会:

  • 由于历史遗留(Unicode代码点范围已经到了0x10FFFF,两个字节长的char不足以表示全部Unicode代码点)问题String中许多方法的索引值都基于UTF-16代码单元而不是基于代码点,所以要支持全部较新版本的Unicode字符,要注意使用基于代码点的方法。
  • 当需要保存或传输文本时,尽可能使用基于Unicode的编码(首选UTF-8)以便表示世界上几乎所有正在使用的字符,要是使用更小的字符集(如ASCII),则到要保存或传输外文时要在保持兼容性前提下补救后悔就太迟了。
  • 不要自行判断字母类型,而应当用Character类的方法,如用Character.isLetter(ch)而不要用((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')),因为存在许多其它字母。
  • 避免用String类的equals方法,用java.text.Collator类中区域感知的方法,像Collator.getInstance().equals("abc", "ABC")
  • 避免用String类的compareTo方法,用java.text.Collator类中区域感知的方法,像Collator.getInstance().compare("abc", "ABC")
  • 不要企图自行寻找字符、单词、句子或可换行位置,应该用java.text.BreakIterator类的方法getCharacterInstancegetWordInstance​getSentenceInstance​getLineInstance​获取迭代器,然后用setText方法输入待扫描句子。
  • 当需要处理左到右和右到左文本混排时要按显示顺序访问各字符,请用StringCharacterIterator类。

最后如果你真的打算让JRE支持更多的区域,可以自己实现java.text.spijava.util.spijava.awt.im.spi中的接口并放到类路径。

关键词 可用性 java