[转载:http://www.regexlab.com/zh/encoding.htm]
级别:初级
摘要:本文将完整,通俗地介绍字符编码,软件国际化等相关概念,也就是编码问题,内容涵盖常说的“中文问题”,“乱码问题”。本文针对亚洲的读者,讲解了产生乱码问题的原理以及解决办法。同时也针对西方的读者,讲解了字符编码的概念,为西方国家的朋友开发国际化软件打一个必要的基础。
“编码问题”是一个被经常讨论的话题。即使这样,时常出现的乱码仍然困扰着大家。虽然有很多的办法可以用来消除乱码,但我们并不一定理解这些办法的内在原理。我们在写出代码并尝试所掌握的办法之前,仍然无法保证乱码不会出现。而有的乱码产生的原因,实际上是 JDBC 驱动或 ODBC 驱动本身有问题所导致的。因此,不仅是初学者会对编码问题感到模糊,不少的编程高手同样对编码问题缺乏准确的理解。
本文将讲解编码问题产生的由来,编码概念的正确理解,各种环节编码的处理办法,以及产生乱码的原因和解决办法。
|
最早诞生的 ASCII 码只包含了英语字母和一些常用标点符号,编号为0~127。后来制定了扩展 ASCII 码,增加了一些欧洲语言中的字母和其他的一些符号,编号为0~255。在计算机中,使用一个字节来存储一个字母或符号。
随着计算机的发展,各个国家和地区为了能在计算机上使用自己的语言,纷纷研究技术方案和制定相关标准。通常采取的办法是,保留 ASCII 中的0~127部分不变,然后使用128~255范围的2个字节来表示1个字符。比如:汉字‘中’在 GB2312 标准中,使用 [D6][D0] 这两个字节表示。不同的国家和地区制定了不同的标准,由此产生了 GB2312, BIG5, JIS 等各自的编码标准。
这些所有使用 2 个字节来代表一个字符的各种汉字延伸编码方式,称为 ANSI 编码。在简体中文系统下,ANSI 编码代表 GB2312 编码,在繁体中文系统下,ANSI 编码代表 BIG5 编码,在日文操作系统下,ANSI 编码代表 JIS 编码。
这些标准之间互不兼容,比如:在汉语和日语中都有‘中’字,同样为了表示‘中’字,GB2312 使用的两个字节与 JIS 使用的两个字节是不一样的。
|
在很长一段时间里,计算机只支持英语和欧洲的语言,一个字母或符号就用一个字节表示,字符的含义就相当于字节。比如 C 语言中的一个字符(char)就表示一个字节。当汉语等语言被支持以后,汉字需要使用多个字节表示,因此,字符的含义不再等同于字节。现在我们必须明确区分这两者的概念:
字符: |
人们使用的记号,抽象意义上的一个符号。比如:‘1’,‘中’,‘a’,‘$’,‘¥’,……。它们之间的地位完全平等。 |
字节: |
计算机中存储数据的单元,一个8位的二进制数,是一个很具体的存储空间。 |
在这里,我们对字符和字节的含义进行了区分。当提到“字符”的时候,我们只需要想到它是一个符号,不要去考虑它在内存中是什么样子。当我们提到“字节”的时候,我们只需要想到它是一个8位二进制数,不要去考虑它表示哪个字符。对于某个字符,不同的编码标准可能会规定不同的字节来表示。因此,单纯的问:“计算机中某个字符是用怎么存储的”这个问题是没有意义的。
在 Java 语言中 char 的含义与在 C 语言中是不同的。Java 中的 char 表示“字符”,代表一个抽象意义上的符号,byte 表示“字节”。而在 C 语言中 char 代表一个字节。
|
不同的国家和地区所制定的不同标准,都只规定了各自所需的字符。比如:汉字标准(GB2312)中就没有规定韩国语字符的表示办法。这些标准中规定的内容有两层含义:
各个国家和地区在制定编码标准的时候,“字符的集合”和“编码”都是同时制定的。因此,平常我们所说的“字符集”,比如:GB2312, GBK, JIS 等,除了有“字符的集合”这层含义外,同时也包含了“编码”的含义。
|
不同字符集的编码规则互不兼容。两个不同字符分别属于两个字符集,采用各自编码规则后可能得到相同的字节,因此,当我们想要在计算机中同时表示这两个字符时就无法做到。
为了解决这个问题,使国际间信息交流更加方便,国际组织制定了 UNICODE 字符集。UNICODE 包含了各种语言里面所使用到的所有字符,并为每一个字符赋予了一个唯一的序号。不论在什么平台下,什么程序中,不论用在什么语言里,UNICODE 对同一个字符赋予的序号总是相同的。
与其他“字符集”不同的是,“UNICODE”并不代表一种“编码”,只代表一个“字符的集合”。用来给 UNICODE 字符集编码的标准有很多种,比如:UTF-8, UTF-7, UTF-16, UnicodeLittle, UnicodeBig 等。
|
在大家正确理解了相关概念后,以下的章节都是对这些概念在实际应用场合的更深入的讲解。如果你在进行以下章节的过程中,感觉理解比较困难,说明前面所讲的概念你还没有理解正确,有必要回头去复习一下第 2 节中的内容。
在 UNICODE 被采用之前,计算机想要记录一段文字,内存中实际存放的内容是:按照指定编码规则得到的字节串。也就是按照 ANSI 编码方式存储在内存中的。比如:在中文 DOS, Windows 95, Windows 98 操作系统中,字符串 "中文123" 存放在内存中时,实际存放的是 [D6][D0][CE][C4][31][32][33] 这7个字节。('中' 和 '文' 分别占2个字节。)
而在 UNICODE 被采用之后,计算机想要记录一段文字时,内存中不再存放根据特定编码而得到的字节串,而改为存放各个字符在 UNICODE 中的序号。比如:在 Windows NT/2000/XP, Linux, Java 系统中,字符串 "中文123" 存放在内存中时,实际记录的是 20013, 25991, 49, 50, 51 这5个序号。当不同语言中的字符需要同时表示时,不会因为编码冲突而无法表示。
字符串实际所占内存空间的大小,要取决于当前系统采用多少字节来存放一个“序号”。如果使用2个字节存放一个序号,那么 "中文123" 在内存中就占10个字节。如果使用4个字节存放一个序号,那么 "中文123" 就占20个字节。
|
在 Java 和 C++ 中,分别代表字节和字符的数据类型是:
|
字节 |
字符 |
字节串 |
字符串 |
---|---|---|---|---|
Java |
byte |
char |
byte[] |
String |
C++ |
char |
wchar_t |
char* |
char*(ANSI), wchar_t*(UNICODE), CString(Visual C++) |
在 Java 中,“字符串”与“字节串”之间可以方便地按照指定编码规则进行转化:
byte [] b = "中文123".getBytes("GB2312"); // 从字符串按照 GB2312 得到字节串 System.out.println(b.length); // 得到长度为7个 (D6,D0,CE,C4,31,32,33) String str = new String(b, "GB2312"); // 从字节串按照 GB2312 得到字符串 System.out.println("中文123".length()); // 得到长度为5,因为是5个字符 |
在 java.io.* 包里面有很多类,其中,以“Stream”结尾的类都是用来操作“字节串”的类,以“Reader”,“Writer”结尾的类都是用来操作“字符串”的类。任何一个文件保存到硬盘上的时候,都是以字节为单位保存的,当要把“字符串”保存到硬盘上的文本文件,必然要选择一种编码。
有两种方法来指定编码:
String str = "中文123"; // 第一种办法:先用指定编码转化成字节串,然后用 Stream 类写入 OutputStream os = new FileOutputStream("1.txt"); byte [] b = str.getBytes("utf-8"); os.write(b); os.close(); // 第二种办法:构造指定编码的 Writer 来写入字符串 Writer ow = new OutputStreamWriter(new FileOutputStream("2.txt"), "utf-8"); ow.write(str); ow.close(); // 最后得到的 1.txt 和 2.txt 都是 9 个字节。(汉字在 utf-8 编码规则中占3字节) |
在 C++ 中,操作字节串(char*)和操作字符串(wchar_t*)各有一套函数。在 Windows API 中,字符串与字节串相互转化的函数是:MultiByteToWideChar() 和 WideCharToMultiByte()。
|
我们在安装数据库的时候,比如安装 MySQL、MS SQL Server、Oracle 时,都会要求选择一种编码。数据库中跟字符串相关的类型有:char, varchar, text, nchar, nvarchar, ntext 等。其中,char, varchar, text 这几种类型的存储方式,是按照所选编码将文本转化为字节进行存储的。char(10) 和 varchar(10) 的长度限制,指的是字节的长度限制。另外的 nchar, nvarchar, ntext 这几种类型的存储方式,是直接按照字符的 UNICODE 序号进行存储的,nchar(10) 和 nvarchar(10) 的长度限制,指的是字符数限制。
比如,在使用 GB2312 编码的 SQL Server 数据库中,对于如下表:
CREATE TABLE TEST( VAR_10 varchar(10) NULL, NVAR_10 nvarchar(10) NULL ) |
能够存储的最大字符串长度分别是:
对于 MySQL 和 Oracle ,细节的地方不一定完全相同。
访问数据库最常用的接口是 JDBC 或者 ODBC。这些访问接口在读取字符串字段时,负责与数据库服务器交互,获知数据库服务器使用的是哪一种编码,并将数据库服务器传输过来的字节流,按照正确的编码还原成“字符”串,然后交给应用程序。
|
当网页上的表单(form)提交数据到 Web 服务器时,'?', '&', '=', '%', '+' 等符号在提交的数据中都有特殊的定义和用途。因此,如果所要提交的某个表单项中,包含这些符号,那么这些符号就需要进行一下转化,然后服务器再进行逆向转化而得到本来所要提交的数据。
转化的格式是:%XX (百分号再跟上被转化符号的16进制编码)。比如:'?' 会被转化成 '%3F',空格 ' ' 会被转化成 '+' 或者 '%20'。
如果所要提交的表单数据中,包含有汉字,那么浏览器会将会将每个汉字根据当前页面所使用的的编码转化成字节,然后将每个字节采用 %XX 的格式提交到服务器。服务器端遇到 %XX 格式的数据时,会根据 XX 编号得到字节,然后再根据同样的编码得到字符串。
举例说明:提交内容“a=%D6%D0%CE%C4%31%32%33”到服务器,通过服务器提供的功能获取表单项“a”得到的内容是:“中文123”。
|
当一段 Text 或者 HTML 通过电子邮件传送时,发送的内容首先通过一种指定的字符编码转化成“字节串”,然后再将得到的“字节串”通过一种指定的传输编码(Content-Transfer-Encoding)进行转化得到另一串“字节串”。比如,打开一封电子邮件源代码,可以看到:
Content-Type: text/plain; charset="gb2312" Content-Transfer-Encoding: base64 sbG+qcrQuqO17cf4yee74bGjz9W7+b3wudzA7dbQ0MQNCg0KvPKzxqO6uqO17cnnsaPW0NDEDQoNCg== |
当二进制文件,比如图片、word文档等,通过电子邮件发送时,不存在通过字符编码进行转化这个步骤,而是直接按照字节流,通过传输编码(Content-Transfer-Encoding)进行转化得到另一串“字节串”。
Content-Type: application/x-zip-compressed; name="=?gb2312?B?XEytWy2LzQLnppcA==?=" Content-Transfer-Encoding: base64 Content-Disposition: attachment; filename="=?gb2312?B?XEytWy2LzQLnppcA==?=" UEsDBAoAAAAAAGNJHzMAAAAAAAAAAAAAAAAJAAAAyO28/rmyz+0vUEsDBBQAAAAIALZ9HjPyoUd36gAA……(省略) |
使用传输编码进行转化的目的,主要是为了使电子邮件源代码都处于可打印字符范围之内。
最常用的 Content-Transfer-Encoding 有:Base64 和 Quoted-Printable 两种。在对二进制文件和中文文本进行转化时,Base64 得到的“字节串”比 Quoted-Printable 更短。在英文文本进行转化时,Quoted-Printable 得到的“字节串”比 Base64 更短。
需要注意的是,Base64 和 Quoted-Printable 等编码属于“Content-Transfer-Encoding”,是将“字节串”转化成为另一个“字节串”的编码,与平时提到的 GB2312, BIG5, JIS, UTF8 等字符编码是两种完全不同类型的概念,不能混淆。
邮件标题,或者附件的文件名,表示方法为:“=?gb2312?B?XEytWy2LzQLnppcA==?=”,虽然看起来很复杂,其实同样也是“字符编码”与“传输编码”两个概念:
在使用 JavaMail 发送邮件时,字符编码和传输编码的指定方法为:
// 设定标题 msg.setSubject("标题 - 测试"); // 设定内容,并指定字符编码 msg.setText ("内容 - 中文123", "GB2312"); // 指定传输编码 msg.setHeader ("Content-Transfer-Encoding","Quoted-Printable"); |
将指定传输编码的 setHeader() 位于 setText() 之前,则不能起作用。
|
一直在微软的平台下进行开发的程序员,很少遇到乱码问题,比如:Visual Basic, ASP, .NET 等。并不是因为他们全都掌握和理解字符与编码的相关技术,而是因为微软的平台没有概念上的错误,所提供的 ODBC 驱动,ASP 引擎,.NET 平台等没有将编码问题遗留给使用者。
而我们常用的很多开源项目,中间往往有一些处理不正确的环节。比如:Tomcat,JDBC,MySQL 等项目。有些乱码产生的根源,就是由这些开源项目中的错误造成的。这些项目大部分参与者都是西方人,他们对怎样支持亚洲语言容易产生一个误解。而 JDK 本身没有问题。
这些遗留的编码问题,留给了本不应该来处理这些问题的广大程序员们,也就是我们。我们中的几乎所有人,在刚开始遇到乱码时,都不知所措。现在,经过大家的努力,虽然我们有了很多方法可以消除乱码,但仍然对编码问题存在误解。在想办法消除乱码时,只能是试,而并不理解。
|
非 UNICODE 软件所使用的字符串,在内存中都是以 ANSI 编码方式存储的。软件在日文环境下开发,软件中的字符串常量就是以 JIS 编码的字节形式存储的,软件在简体中文环境下开发,就是以 GB2312 编码的字节形式存储的。软件在运行时,只是直接将这些字节交给系统提供的 API,系统则按照本地 ANSI 编码的字符串来处理。如果运行时的语言环境与开发时不同,界面上显示出来的就是乱码。
相比之下,采用 UNICODE 的软件,字符串常量都是以 UNICODE 的序号来存储的。不管在什么语言环境下,同一个编号都代表同一个符号。因此,采用 UNICODE 的日文软件,在中文操作系统上运行时,仍然可以看到正常的日文界面。
非 UNICODE 软件将逐渐被淘汰。过渡时期,出现了“南极星”“AppLocale”等软件,可以用来在一个语言环境下模拟另一个语言环境,运行在不同环境下开发的软件。
|
东西方人对编码误解是不同的:
对编码的误解 |
|
---|---|
东方人 |
一直以来的非 UNICODE 软件开发,字符串都是以 ANSI 编码的字节形式存在的。这种以字节形式存在的字符串,必须知道是哪种编码才能被正确显示。这使我们形成了一个惯性思维:“字符串”的编码。 当 UNICODE 被支持后,Java 中的 String 是以字符的“序号”来存储的,不是以“某种编码的字节”来存储的,因此已经不存在“字符串的编码”这个概念了。只有在“字符串”与“字节串”转化时,或者,将一个“字节串”当成一个 ANSI 字符串时,才有编码的概念。 |
西方人 |
西方的同行们,在将“字节串”转化成“字符串”时,容易产生的误解是:认为每“一个字节”就是“一个字符”。而事实上,应该按照编码规则,有可能“多个字节”才能得到“一个字符”。 在字符集标准中,ISO-8859-1 字符集的范围是 0~255,字符与字节的转换关系是:一一对应。 认为每“一个字节”就是“一个字符”而得到的“字符串”,只能再通过 getBytes() 得到了之前的“字节串”,重新进行处理。这就是为什么我们经常使用 |
底层开发的人对编码的误解,导致的乱码的产生。上层人员对编码的误解,导致了消除乱码的复杂性。只要对概念理解正确,乱码问题其实也容易解决。
|
如果 ODBC 或 JDBC 驱动在读取字符串字段时,没有正确的将数据库发送过来的“字节流”使用正确的“编码”进行转化,所得到的字符串就是乱码。通常的错误同样是:简单的认为每“一个字节”就是“一个字符”。而没有根据数据库所选的编码进行转化。
解决的办法通常是:getBytes("ISO-8859-1") 的到原始的字节,再使用正确的编码重新new String()。
|
在 Tomcat 3.3 以前,request.getParameter() 所得到的字符串,都是以每“一个字节”就是“一个字符”的方式返回的。从页面提交到 Tomcat 服务器的中文内容,request.getParameter() 得到的字符串都是乱码。
要得到正确的中文字符串,只能是使用 getBytes("ISO-8859-1") 的到原始的“字节串”,再按照正确的编码重新 new String()。这个乱码问题的产生,是由 Tomcat 自身引起的,采用 new String(str.getBytes("ISO-8859-1"), "GB2312") 这样的用法也是迫不得已。
从 Tomcat 4 以后,request 中增加了一个新的接口方法:request.setCharsetEncoding()。这个方法的作用是:在调用 request.getParameter() 之前,设定与提交页面相同的编码,则可以使 request.getParameter() 返回正确的字符串。
进行实际测试,对于以下的 JSP 页面:
<% request.setCharacterEncoding("GB2312"); response.setContentType("text/html; charset=GB2312"); String aaa = request.getParameter("aaa"); if(aaa != null) out.print(aaa.length() + "<br>"); out.print(aaa); %> <hr> <form method=POST action="?"> <input type=text name=aaa><input type=submit value=POST> </form> <form method=GET> <input type=text name=aaa><input type=submit value=GET> </form> |
输入中文,进行提交,实际测试结果如下:
Tomcat 3.3.2 |
Tomcat 4.1.31 |
Tomcat 5.0.28 |
Tomcat 5.5.9 |
|
---|---|---|---|---|
POST |
显示乱码(1) |
正确显示中文 |
正确显示中文 |
正确显示中文 |
GET |
显示乱码 |
正确显示中文 |
显示乱码(2) |
显示乱码 |
(1) |
Tomcat 3.3 以前没有 request.setCharacterEncoding() 方法,因此自然得到乱码。 |
(2) |
不知何故,Tomcat 5 以后的 request.setCharacterEncoding() 方法对 GET 方法提交的数据无效,对 POST 提交的数据处理正确。这样的效果还不如 Tomcat 3.3 那样更好。 |
大多数浏览器都支持的脚本方法 escape(),可以采用汉字的 UNICODE 序号将汉字转化成 %uXXXX 的格式用来提交到服务器。但 Tomcat 的 request.getParameter() 方法不支持这种格式。
|
电子邮件与其它场合不同点就是,除了有“字符编码”外,还有“传输编码”(Content-Transfer-Encoding)。邮件内容中编码的指定方法是:在 MIME 格式的头部分别指定 charset 和 Content-Transfer-Encoding。邮件标题中编码的指定方法是:=?xxx?B?xxxxxxx?= 格式,在一行中简单明了的描述了两种类型的编码以及编码后的标题内容。
如果遇到显示乱码,一种可能是:未指定“字符编码”和“传输编码”,直接将ANSI编码的“字节流”使用在邮件中。另一种可能是:指定了错误的字符编码。
不可避免我们有时会收到一些不规范的电子邮件,解决的办法只能是,选择正确的编码方式来查看。我们在发送邮件的时候,尽可能的按照规范,正确指定“字符编码”和“传输编码”。