Java 国际化基础
上文说了一大通,总结一下,其实很简单:
- 字符编码是抽象字符在计算机中的数字表示。
- 字符编码集( character set ,简称字符集)是一批字符编码的集合。世界上存在大量互不兼容的字符集,给国际交流带来了困难。
- ASCII 码是最古老的字符编码,它总共只定义了 7 位共 128 个字母、数字和符号。但它是其它所有字符编码的基础。
- Unicode 用 16 位整数编码,将世界上所有主要文字的字符统一起来了。如果利用代理对( surrogate pair )最多可以表示从 0 到 1FFFF 的字符。然而绝大多数情况下,只需要用到 0 到 FFFF 之间的字符就足够了。
- Unicode 常用 UTF-8 和 UTF-16 来表示。 7 位的 ASCII 码不用作任何变化,就已经是 UTF-8 了。但 UTF-8 需要用 3 个字节来表示一个汉字。
- ISO 8859 系列字符集,定义了单字节字符编码的标准。其中最特殊的是 ISO-8859-1 编码,它的编码和 Unicode 中最开始的 256 个字符编码完全相同。
- GB18030 编码是中国大陆的国家标准,在字汇上等同于 Unicode ,在编码上和 GB2312 编码以及 GBK 编码兼容。
我们知道 Unicode 为国际化( I18n )提供了坚实的基础。但是 Unicode 不等同于国际化。使用 Unicode 的 Java 语言,若是使用不当,同样达不到国际化的目的。让我们来看一下 Java 是怎样处理 Unicode 的。
Java 的字符类型
和 C 语言不同, Java 的字符类型 “ char ” 是一个 16 位长的整数,而 C 语言的 char 是 8 位,等同于一个字节,只能表示单字节的字符(拉丁语系文字)。所以 Java 可以直接用一个 char 来表示一个 Unicode 字符(包括中文、英文、日文 …… ),大大简化了字符和字符串的操作。
因为 Java 字符总是 Unicode 字符,所以在后文中,如果不加说明, “ 字符 ” 或 “ char ” 都是指 16 位的 Unicode 字符,而 “ 字节 ” 或 “ byte ” 都是指 8 位字节。
编码( encoding )
然而,当今多数计算机系统,都是以字节为存储运算的基本单元。这就使得在 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 转换成某一种本地字符集,例如:
- 英文可转换成 ISO-8859-1 。
- 中文可转换成 GB2312 、 GBK 、 BIG5 或是 GB18030 等。
- 日文可以转换成 SJIS 或 ISO-2022-JP 等。
- 韩文可以转换成 ISO-2022-KR 等。
本地字符集名目之多,无法全部列举。最重要是,大多数字符集只映射到 Unicode 中的部分字符,且字符集之间互相交错,互不兼容。
那么,如果在将 Unicode 转换到某一本地字符集时,发现这一编码字符集不包含这个字符,怎么办呢?例如: “ 我爱 Alibaba” 这个字符串(简体中文),如果转换成繁体中文的 BIG5 编码,就会变成: “ 我 ?Alibaba” 。原来, Unicode 规定,转换时碰到 “ 看不懂 ” 的字符,一律用 “? ( 0x3F ) ” 表示。
这就解释了一种常见的 “ 乱码 ” 情形:好端端的页面,显示在浏览器上却变成了无数个问号。原因就是 Java 在输出网页时,使用了错误的编码方式。后面将更详细地解释这个问题。
解码( decoding )
同样的,如果我们要从文件或数据库中读取文本数据,因为我们读到的是一个字节流,所以我们需要使用正确的编码方法,将字节流恢复成字符流。这个操作叫做 “ 解码 ” ( 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 对国际化的支持
Java I/O
在 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 编码的方式保存到一个文本文件中。
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
public class TestEncoding {
public static void main(String[] args) {
try {
writeStringToFile(" 我爱Alibaba", "c:/ilovealibaba.txt", "GBK");
} catch (IOException e) {
e.printStackTrace();
}
}
public static void writeStringToFile(String str, String filename, String charset)
throws IOException {
FileOutputStream ostream = new FileOutputStream(filename);
OutputStreamWriter writer = new OutputStreamWriter(ostream, charset);
try {
writer.write(str);
} finally {
writer.close();
}
}
}
当然,除了输出到文件,事实上可以使用任何输出流,例如使用 ByteArrayOutputStream 将可将字节流保存在内存中。
下面的完整的例子演示了 Java 如何读取一个文件,并把文件的内容以 GBK 方式解码。
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
public class TestDecoding {
public static void main(String[] args) {
try {
System.out.println(
readStringFromFile("c:/ilovealibaba.txt", "GBK"));
} catch (IOException e) {
e.printStackTrace();
}
}
public static String readStringFromFile(String filename, String charset) throws IOException {
FileInputStream istream = new FileInputStream(filename);
InputStreamReader reader = new InputStreamReader(istream, charset);
StringBuffer string = new StringBuffer();
char[] buffer = new char[128];
int count = 0;
try {
while ((count = reader.read(buffer)) != -1) {
string.append(buffer, 0, count);
}
} finally {
reader.close();
}
return string.toString();
}
}
当然也可以从任何输入流中获得字节,然后用同样的方法转换成字符。例如,通过 ByteArrayInputStream ,可以从内存中的 byte[] 数组中取得字节流。
字符串处理
另一种常见的编码和解码的方法,是通过 Java 的 String 类完成的。下面的程序演示了 Java 如何使用 String.getBytes() 方法,将字符串编码成指定形式的字节的。
import java.io.UnsupportedEncodingException;
public class TestStringGetBytes {
public static void main(String[] args) {
try {
dumpBytes(" 我爱Alibaba", "GBK");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
public static void dumpBytes(String str, String charset) throws UnsupportedEncodingException {
byte[] bytes = str.getBytes(charset);
// 显示bytes 的内容,每行显示4 个
for (int i = 0; i < bytes.length; i++) {
System.out.print(Integer.toHexString(bytes & 0xFF));
System.out.print(" ");
if ((i + 1) % 4 == 0) {
System.out.println();
}
}
System.out.println();
}
}
运行的结果为:
ce d2 b0 ae
41 6c 69 62
61 62 61
下面的程序,使用 String(bytes, charset) 构造函数,也实现了读取一个文件的内容,并以指定编码方式( GBK )解码成字符串的功能。
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
public class TestNewString {
public static void main(String[] args) {
try {
System.out.println(
readStringFromFile("c:/ilovealibaba.txt", "GBK"));
} catch (IOException e) {
e.printStackTrace();
}
}
public static String readStringFromFile(String filename, String charset) throws IOException {
FileInputStream istream = new FileInputStream(filename);
ByteArrayOutputStream ostream = new ByteArrayOutputStream();
byte[] buffer = new byte[128];
int count = 0;
try {
while ((count = istream.read(buffer)) != -1) {
ostream.write(buffer, 0, count);
}
} finally {
istream.close();
}
byte[] stringBytes = ostream.toByteArray();
// 使用指定charset ,将bytes[] 转换成字符串
return new String(stringBytes, charset);
}
}
注意
上面这段程序只是演示 String(bytes, charset) 构造函数,如果要读取大量的文本,这种方式的性能肯定不如前面使用 InputStreamReader 的程序示例。
其它和国际化相关的功能
java.util.ResourceBundle
通过 ResourceBundle ,我们可以把特定语言相关的信息放在程序之外。这样当我们要在已有产品的基础上,增加一种语言或地区的支持时,只需要增加一种 ResourceBundle 的实现即可。
数字、货币、日期、时间的格式化
中国人表示日期的习惯是: “2003 年 5 月 24 日 星期六 ” ,而美国人则习惯于: “Saturday, May 24, 2003” 。 Java 程序代码可以不关心这些差别。在运行时刻, Java 可以根据不同的语言或地区习惯,自动按不同的格式风格显示这些内容。
import java.text.DateFormat;
import java.util.Date;
import java.util.Locale;
public class TestDateFormat {
public static void main(String[] args) {
Date date = new Date();
System.out.println(DateFormat.getDateInstance(DateFormat.FULL, Locale.CHINA).format(date));
System.out.println(DateFormat.getDateInstance(DateFormat.FULL, Locale.US).format(date));
}
}
除了 DateFormat , java.text 包中还包括了很多其它格式化类。
1. NumberFormat
2. DecimalFormat
3. DateFormat
4. SimpleDateFormat
5. MessageFormat
6. ChoiceFormat
检测字符属性
前文提到, Unicode 不仅定义了统一的字符集,而且为这些字符及编码数据提出应用的方法以及对语义数据进行补充。而 Java 可以直接查看 Unicode 所定义的这些字符属性。
传统的非国际化的程序常常这样检测一个字符是否属于字母、数字还是空白:
char ch;
...
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) {
// ch 是一个字母
}
...
if (ch >= '0' && ch <= '9') {
// ch 是一个数字
}
...
if ((ch == ' ') || (ch =='\r') || (ch =='\n') || (ch == '\t')) {
// ch 是一个空白
}
这样的程序没有考虑除了英文和其它少数几种语言之外的语言习惯。例如:西腊字母 “αβγ” 也应该算是字母,汉字中全角数字 “ 123 ” 也是数字,全角空格 “ ” ( U+3000 )也属于空白。正确的程序应该是这样的:
char ch;
...
if (Character.isLetter(ch)) {
...
if (Character.isDigit(ch)) {
...
if (Character.isSpaceChar(ch)) {
...
下面列出了 Character 中用来判定字符属性的方法:
1. Character.isDigit
2. Character.isLetter
3. Character.isLetterOrDigit
4. Character.isLowerCase
5. Character.isUpperCase
6. Character.isSpaceChar
7. Character.isDefined
此外, Unicode 还为每个统一字符定义了很多属性。我们可以通过 Character 相应方法取得这些属性。例如可以用下面的代码判定一个字符是否为 “ 中日韩统一汉字 ” :
char ch;
...
Character.UnicodeBlock block = Character.UnicodeBlock.of(ch);
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS) {
// 是CJK 统一汉字
}
更多 Character 类细节请参阅 Java API文档 。
字符串比较和排序
字符间的逻辑顺序不一定和 Unicode 编码的数值顺序一致。利用 java.text.Collator 可以比较两个 Unicode 字符串的逻辑顺序。
检测字符串的边界
在应用中,我们经常需要检测字符串的边界:检测字符( character )、词( word )、句子( sentence )、行( line )的边界。例如,显示一段文字,需要在屏幕的右边界处对文本断行。断行不是任意的。例如,你不能把一个英文单词拆开。
使用 java.text.BreakIterator 可以实现字符串边界的检测。
以上只是简单地列举了 Java 中和国际化相关的功能。具体描述这些内容,超出了本文的议题。可以从 Java 文档中取得更详细的信息: Java国际化指南 。
上一篇:中文化和国际化问题浅析-字符串简介 (2)