我们知道Unicode为国际化(I18n)提供了坚实的基础。但是Unicode不等同于国际化。使用Unicode的Java语言,若是使用不当,同样达不到国际化的目的。让我们来看一下Java是怎样处理Unicode的。
和C语言不同,Java的字符类型“char”是一个16位长的整数,而C语言的char是8位,等同于一个字节,只能表示单字节的字符(拉丁语系文字)。所以Java可以直接用一个char来表示一个Unicode字符(包括中文、英文、日文……),大大简化了字符和字符串的操作。
因为Java字符总是Unicode字符,所以在后文中,如果不加说明,“字符”或“char”都是指16位的Unicode字符,而“字节”或“byte”都是指8位字节。
然而,当今多数计算机系统,都是以字节为存储运算的基本单元。这就使得在Java中,用Unicode表示的字符串无法直接写到文件中或保存到数据库中。必须以某一种方式,将字符串转换成便于传输和存储的字节流才行。这种将Unicode字符转换成字节的操作,就叫做“字符编码”(encoding)。
前面说过Unicode有两种字节表示法:UTF-8和UTF-16。所以将Unicode以UTF-8和UTF-16编码是最直接和自然的事了。以上面的“我爱Alibabaあいう”为例,用Big-endian(高位字节在前,低位字节在后)的UTF-16编码,可以表示成:
我们也可以把同样的字符串转换成UTF-8。UTF-8是变长的编码,对于ASCII码字符,不需要改变,就已经是UTF-8了,但一个中文要用三个字节来表示:
使用UTF-16或UTF-8编码的数据,必须使用支持Unicode的软件来处理,例如支持Unicode的文本编辑器。目前存在的大量软件,不一定都支持Unicode。因此我们往往将Unicode转换成某一种本地字符集,例如:
本地字符集名目之多,无法全部列举。最重要是,大多数字符集只映射到Unicode中的部分字符,且字符集之间互相交错,互不兼容。
那么,如果在将Unicode转换到某一本地字符集时,发现这一编码字符集不包含这个字符,怎么办呢?例如:“我爱Alibaba”这个字符串(简体中文),如果转换成繁体中文的BIG5编码,就会变成:“我?Alibaba”。原来,Unicode规定,转换时碰到“看不懂”的字符,一律用“?(0x3F)”表示。
这就解释了一种常见的“乱码”情形:好端端的页面,显示在浏览器上却变成了无数个问号。原因就是Java在输出网页时,使用了错误的编码方式。后面将更详细地解释这个问题。
同样的,如果我们要从文件或数据库中读取文本数据,因为我们读到的是一个字节流,所以我们需要使用正确的编码方法,将字节流恢复成字符流。这个操作叫做“解码”(decoding)。
如果指定了错误的编码方法,那么就会得到不正确的字符流。和编码过程类似,Unicode规定,在解码时,发现“看不懂”的字节,一律用“�(0xFFFD)”表示。例如:将“我爱Alibaba”以UTF-8的编码方式保存在一个文件中,用繁体中文编码BIG5读入,就会变成:“�����婢libaba”。因为UTF-8字节序列E6 88 91 E7 88不是一个合法的BIG5编码序列,而第六个字节B1和后面一个字节41(原本是字母“A”)碰巧可以构成一个BIG5字符“婢”。
反过来说,是不是经过解码的字符序列中,不包含问号“?”,就代表解码方法是正确的呢?显然不是!
最典型的错误就是:用ISO-8859-1来解码中文文件。这导致了更隐蔽的错误。因为ISO-8859-1的字符编码正好和Unicode的最前面256个字符相同,换句话说,在ISO-8859-1编码之前加上“00”就变成了Unicode。正是由于这个特殊性,ISO-8859-1似乎成了“万能”的编码而被广泛地误用!
仍以“我爱Alibaba”为例,如果用ISO-8859-1解码此文件,我们可以得到一个看似“合法”的字符串:
很明显,使用ISO-8859-1解码中文文件的人,只是把Unicode字符看作是16位的“字节”而已。对Java而言,“我爱”这两个字符不代表中文字符“我爱”,只不过是4个欧洲字符和符号而已。
在Java中,主要是通过输入输出流来进行编码和解码的。输入输出流分成两种:
字节流(Octet Stream)
从java.io.InputStream或java.io.OutputStream派生的,负责读写字节(byte)。 例如:java.io.FileInputStream、java.io.ByteArrayInputStream、 java.io.FileOutputStream、java.io.ByteArrayOutputStream等。
字符流(Character Stream)
从java.io.Reader或java.io.Writer派生的,负责读写字符(char)。例如:java.io.StringReader、java.io.StringWriter等。
而联系这两种流的,分别是OutputStreamWriter和InputStreamReader。由这两个类来实现Java字符的编码和解码。
下面的完整的例子演示了Java如何把一个包含中文的字符串,以GBK编码的方式保存到一个文本文件中。
当然,除了输出到文件,事实上可以使用任何输出流,例如使用ByteArrayOutputStream将可将字节流保存在内存中。
下面的完整的例子演示了Java如何读取一个文件,并把文件的内容以GBK方式解码。
当然也可以从任何输入流中获得字节,然后用同样的方法转换成字符。例如,通过ByteArrayInputStream,可以从内存中的byte[]数组中取得字节流。
另一种常见的编码和解码的方法,是通过Java的String类完成的。下面的程序演示了Java如何使用String.getBytes()方法,将字符串编码成指定形式的字节的。
运行的结果为:
下面的程序,使用String(bytes, charset)构造函数,也实现了读取一个文件的内容,并以指定编码方式(GBK)解码成字符串的功能。
注意:上面这段程序只是演示String(bytes, charset)构造函数,如果要读取大量的文本,这种方式的性能肯定不如前面使用InputStreamReader的程序示例。
java.util.ResourceBundle
通过ResourceBundle,我们可以把特定语言相关的信息放在程序之外。这样当我们要在已有产品的基础上,增加一种语言或地区的支持时,只需要增加一种ResourceBundle的实现即可。
数字、货币、日期、时间的格式化
中国人表示日期的习惯是:“2003年5月24日 星期六”,而美国人则习惯于:“Saturday, May 24, 2003”。Java程序代码可以不关心这些差别。在运行时刻,Java可以根据不同的语言或地区习惯,自动按不同的格式风格显示这些内容。
除了DateFormat,java.text包中还包括了很多其它格式化类。
1. NumberFormat
2. DecimalFormat
3. DateFormat
4. SimpleDateFormat
5. MessageFormat
6. ChoiceFormat
检测字符属性
前文提到,Unicode不仅定义了统一的字符集,而且为这些字符及编码数据提出应用的方法以及对语义数据进行补充。而Java可以直接查看Unicode所定义的这些字符属性。
传统的非国际化的程序常常这样检测一个字符是否属于字母、数字还是空白:
这样的程序没有考虑除了英文和其它少数几种语言之外的语言习惯。例如:西腊字母“αβγ”也应该算是字母,汉字中全角数字“123”也是数字,全角空格“ ”(U+3000)也属于空白。正确的程序应该是这样的:
下面列出了Character中用来判定字符属性的方法:
1. Character.isDigit
2. Character.isLetter
3. Character.isLetterOrDigit
4. Character.isLowerCase
5. Character.isUpperCase
6. Character.isSpaceChar
7. Character.isDefined
此外,Unicode还为每个统一字符定义了很多属性。我们可以通过Character相应方法取得这些属性。例如可以用下面的代码判定一个字符是否为“中日韩统一汉字”:
更多Character类细节请参阅Java API文档。
字符串比较和排序
字符间的逻辑顺序不一定和Unicode编码的数值顺序一致。利用java.text.Collator可以比较两个Unicode字符串的逻辑顺序。
检测字符串的边界
在应用中,我们经常需要检测字符串的边界:检测字符(character)、词(word)、句子(sentence)、行(line)的边界。例如,显示一段文字,需要在屏幕的右边界处对文本断行。断行不是任意的。例如,你不能把一个英文单词拆开。
使用java.text.BreakIterator可以实现字符串边界的检测。
以上只是简单地列举了Java中和国际化相关的功能。具体描述这些内容,超出了本文的议题。可以从Java文档中取得更详细的信息:Java国际化指南。