前言
本文章主要讨论了在Java web系统中乱码产生的内在原理, 是认识和解决乱码问题的基础. 如果您对乱码问题还没有一个清晰的概念, 请尝试阅读本文. 另外, 本文也讨论了最近流行的Ajax技术中的乱码问题, 如果您在使用Ajax技术中遇到了乱码, 本文对您也有一定的参考价值<1>。
2006年5月30日
为什么会出现乱码
我们都知道, 在冯·诺伊曼(Neumann János)<2>体系的计算机中, 任何数据都是以二进制的形式存在的。我们在键盘上输入以及我们在屏幕上看到的中文、日文、英文字符,最终都是内存或者硬盘上的二进制数据。那么,这种二进制数据和屏幕上的中文、日文、英文字符之间相互的关系就需要通过一种映射来表达,我们把这种关系称之为“字符映射表”<3>。
图 1-1 乱码产生的原因
譬如说, 字符集Shift_JIS规定“う”这个字保存到存储介质中为“82A4”;而字符集GBK规定存储介质中的“82A4”代表字符“鷛”。 因此,我们在日文系统<4>中把“あいうえお”这个字符串保存在某个文件中,然后这个文件被带到一个中文系统上,读取这个文件后就产生了乱码,如图1-1。
在计算机的存储介质<5>中,保存的皆为二进制的数据。计算机本身并没有一种方法知道当前的数据是日文的“う”还是中文的“鷛”。<6>
任何一段文本(或者字符串)被保存到存储介质中的时候都需要有一个字符映射表与之相对应。我们在处理文本(或者字符串)的时候需要清楚地知道当前文本(或者字符串)的编码方式<7>是什么。
javac –encoding …
Java作为现在最流行的一种开发语言运行在Java虚拟机上。 运行前,需要把Java的源代码编译成byte code,编译后的byte code被Java虚拟机解释执行。<8>
Java虚拟机认为运行在其上的byte code的编码方式是Unicode<9>,而Java源代码以本地的(local)文本文件的形式存在,那么Java编译器(通常是javac.exe)就需要知道当前的Java源文件对应的字符映射表,然后把其中的字符转化为Unicode的字符。
非常幸运地是,一般情况下,Java有一套很好的机制帮助我们完成了后面的种种编码转换工作,而使得编程人员不需要太在意代码的字符集以便把注意力集中在应用程序的逻辑实现上。
假设我们使用Java编写一个小程序,在控制台打印“あいうえお”五个字符。(如图2-1) 由于“任何一段文本(或者字符串)被保存到存储介质中的时候都需要有一个字符映射表与之相对应”,所以当我们使用文本编辑器(包括Eclipse等,非Microsoft Office)把一段文本保存到硬盘上的时候,我们需要指定当前这段文本所对应的编码方式。(一般地,如果不指定字符映射表,文本编辑器会采用系统默认的字符映射表保存文本。) 也就是说,我们在日文Windows XP下编写的Java源代码会采用MS932<4>这个字符映射表保存到文件中(如图2-1的①处)。
图 2-1 编写、编译并运行Java代码
因为Java虚拟机认为运行在其上的byte code的编码方式是Unicode(如图2-1的②处),所以Java编译器会把Java源代码编译成Unicode形式的byte code。但是因为计算机本身并没有一种方法知道当前Java源文件的编码方式,所以,如果不指定编码方式的话,默认地,Java编译器会采用系统默认的字符映射表读取Java源文件。即,如果在Windows XP日文版下编译此Java程序,那么就会使用MS932格式转化其中的字符串,如果在Windows XP中文版下编译此Java程序,那么就会使用MS936格式转化其中的字符串。如果把Windows XP日文版下编写的Java源代码拿到Windows XP中文版下编译自然就会出现错误了。
因此,Java编译器(特指javac.exe)向我们提供了一个-encoding的参数,我们可以使用-encoding告诉Java编译器采用哪种字符映射表来读取Java源代码。
(如图2-1的③处)那么在控制台上可以正确地显示“あいうえお”又是为什么呢?Java的System.out.println函数,默认地采用当前系统的默认字符映射表来输出字符串。即,在WindowsXP日文版下会把Unicode的“あいうえお”按照MS932的格式输出出来;在WindowsXP中文版下会把Unicode的“あいうえお”按照MS936的格式输出出来。所以,编译后的Java byte code可以在任何系统上正确地运行。也就是Java所谓的“Write Once, Run Anywhere.”
另外,我们因此也知道,如果使用System.out.println输出的字符串是乱码的话,也并不能说明此字符串是有问题的。
和web相关的编码问题
在Java web系统中,我们主要使用HTTP协议在网络上通讯。 我们把浏览器称为HTTP客户端,把web服务器称为HTTP服务端。两者通过请求(request)和响应(response)的方式传递数据。
图3-1 Web中的编码方式
设想在Windows XP日文版上使用IE浏览器提交“あいうえお”几个字符,然后HTTP服务端再把这几个字符打印在HTTP客户端的屏幕上。(如图3-1) 其中可能在五处发生了字符集的转换,一处是输入的时候,二处是把字符串通过网络提交到服务器的时候,三处是在服务器端处理字符串的时候,四处是把字符串通过网络返回给客户端的时候,五处是在客户端显示的时候。
因为HTTP协议是一个文本传输协议,所以通过HTTP协议在网络上传输的数据一定是有一个对应的字符集的。一般地,这个字符集是ISO-8859-1<11>。所以在2处和4处“あいうえお”的编码方式是ISO-8859-1。我们通过实验也可以证明,在1处和5处是HTTP客户端指定的编码方式<28>,在3处是服务器转码后的编码方式<29>。
由于现有的HTTP客户端和服务器端已经帮我们很好的封装了HTTP协议的实现,所以一般我们在做Java Web Programming程序的时候不考虑在网上传递的数据格式。
在Java web系统中指定编码方式
在Java web系统中, 我们遇到的最多的项目就是采用JSP和Servlet技术的项目了。那么,在使用JSP和Servlet技术的web系统中,设置字符集的地方可能有五处。 下面我们先来讨论和响应相关的四处。
一、 pageEncoding<12>
我们可以在JSP页面上加入指令(directives)
<%@page pageEncoding=“Shift_JIS”%>
JSP在运行前会被JSP编译器编译成Servlet,然后服务器加载此Servlet处理客户端请求。 JSP中,指令是传递给JSP编译器的参数,即告诉JSP编译器如何编译JSP。 Page指令中的pageEncoding属性即告诉JSP编译器使用的是哪种字符映射表来读取当前JSP文件的源代码。<30>如果没有指定pageEncoding属性,默认地,JSP编译器采用当前系统的默认字符映射表来读取JSP页面。
图3-1 Page指令的pageEncoding属性
譬如,我们经常遇到的在Windows下编写的Java web应用程序发布到Solaris后,JSP不能编译,通常是由于没有指定pageEncoding造成的。
二、 ContentType
另外,我们可以在JSP页面上加入含有ContentType的page指令
<%@ page contentType=“text/html; charset=Shift_JIS”%>
这个指令的效果等同于
response.setContentType(“text/html; charset=Shift_JIS”);
达到的目的有两个。
其一,在响应的HTTP文本中加入Content-type报头(header)。客户端会根据Content-type来读取网络上传输的数据。<13>
其二,通知web容器如何把文本(或者字符串)转化为网络上传输的二进制数据。<14>
需要注意的是,我们使一个字符串在网络上传输和把一个字符串保存到文件中本质上是相同的,我们都需要一个字符映射表来映射字符和byte之间的关系。
图3-2 关于设置ContentType的作用
假设我们需要把“あいうえお”这个字符串发送给客户端,那么我们可以通过上面两种方式(即page指令和response对象)设置ContentType为“Shift_JIS”。 设置后,服务器会认为是“あいうえお”是使用“Shift_JIS”编码的字符串,并且以此变为比特流发送到客户端。
客户端在接收到HTTP响应后并不知道服务器端是“あいうえお”,它得到的只是一堆比特数据,那么它会根据HTTP响应的报头Content-type中的设置,把这堆比特数据转化为“あいうえお”。,<15>
一、 Meta Data
最后一个设置字符集的地方就是HTML页面的meta标签。 一般地
<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">
这里设置的字符集是告诉浏览器如何显示HTML页面。<16>
图3-3 关于meta data的作用
总结, 对于客户端页面显示乱码,如果服务器端数据正常的话,那么可能是以上四种地方设置有误。如果pageEncoding设置错误,一般表现为JSP页面无法编译,或者编译后JSP页面中固有的字符串不能正常显示;如果Content Type设置错误,一般表现为JSP页面全部或者大部分为乱码,调整浏览器的显示编码格式后,仍然不能解决;如果meta data设置错误,一般表现为JSP页面全部为乱码,调整浏览器的显示编码格式后,可以解决。
每种变量如果不设置,则采用缺省值。如果不设置pageEncoding,JSP编译器采用当前系统默认的字符映射表来读取JSP文件;如果不设置Content-type,则采用ISO-8859-1<17>来作为Content-Type。
提交数据的编码方式
一般地,客户端提交给服务器的数据有两种形式,GET和POST。 使用GET方式提交数据的时候,HTTP消息中没有报体(Message Body),提交的数据存在于URL中<18>;使用POST提交数据的时候, 提交的数据存在于HTTP消息的报体中。 (另外,最近比较流行使用XMLHtttpRequest对象提交数据,我们将在下一节中讨论。)
无论以哪种方式提交数据,这些数据都要经过编码和URL Encoding<19>两个过程。
图4-1 URL Character Encoding<19>
在服务端会做一遍上述编码过程的逆过程,从而得到“あいうえお”。 那么,问题就是服务端如何知道客户端传递过来的字符串使用什么编码方式?
首先,假设我们在页面表单里输入了“あいうえお”,那么HTTP客户端(浏览器)会使用什么编码方式对它进行编码?HTTP客户端(浏览器)会使用当前页面的显示字符集对它进行编码。
显示字符集是指显示某个页面的时候所使用的字符集。既不是meta中指定的字符集<20>,也非Content-type中指定的字符集。
在Microsoft Internet Explorer中,显示字符集可以在下面这里看到。
图4-2 IE的显示字符集
在Mozilla Firefox中,显示字符集可以在下面这里看到。
图4-3 Firefox的显示字符集
客户端会根据当前页面的显示字符集编码当前页面上表单中的数据,并提交到服务端。
或者说,可以通过改变页面的显示字符集来改变提交数据的编码方式。
其次,当数据提交到服务端,服务器端如何知道客户端传递过来的数据是采用什么编码方式?答案是因不同服务器不同而不同。
对于Tomcat, Tomcat会认为客户端提交的数据全部采用ISO-8859-1的方式编码,所以Tomcat会采用ISO-8859-1的形式解码;而Weblogic会采用响应客户端页面的编码方式解码。但是无论哪种服务器采用哪种方式,都不能保证是我们想要的!
那么我们如何来指定我们想要的解码方式呢?
在Java web系统中,我们可以采用request.setEncoding(<encoding_str>)来指定客户端的编码方式。 这行代码即告诉request对象,客户端的编码方式是<encoding_str>。由此,我们就可以保证客户端提交的数据和服务器端接收的数据采用同样的编码方式。
关于Ajax系统编码方式的讨论
2005年,Ajax作为最热门的名词之一使使用Ajax技术的项目一下多起来。 因为初次使用这种技术,所以其中最大的问题之一就是编码问题。
Ajax的核心是XMLHttpRequest对象。总体来说, XMLHttpRequest对象是一个简单的对象。 我们可以调用它的send方法向服务器端发送数据,以及可以调用它的responseText和responseXML来接收从服务器端返回的数据。
根据W3C的定义<21>, 我们使用XMLHttpRequest对象的send方法发送的数据总是为Unicode(UTF-8)的编码方式;使用XMLHttpRequest对象的responseText接收的数据根据Content-type<22>中指定的不同而不同。使用XMLHttpRequest对象的responseXML接收的数据必须符合XML规范,且Content-type中的字符集必须和XML的字符集相同。譬如指定返回Shift_JIS编码的XML数据。
response.setContentType("text/xml;charset=Shift_JIS");
response.getWriter().write("<?xml version=\"1.0\" encoding=\"Shift_JIS\"?>");
如果没有指定XML的prolog, 则默认编码为UTF-8<23>。 所以必须指定Content-type也应该为UTF-8<24>。
我们在提交数据的编码方式中讨论过,使用表单提交数据需要通过两个阶段的编码,一个是使用某种字符集编码,然后再使用URL Character Encoding编码。这样做是因为在HTTP协议下网络中传输的是ISO-8859-1的字符,我们通过这种方式可以把任何字符集转化为符合ISO-8859-1字符集的字符串。 但是,我们使用XMLHttpRequest提交数据的时候,就没有URL Character Encoding这一步。
图5-1没有经过URL Character Encoding的数据
当然,幸运地是现在的服务器都非常健壮,可以做上述操作的逆操作,这样我们编写的代码还是可以运行的。但是这不是一个好习惯。
一般地,我们使用Ajax技术的时候,需要手工的进行URL Character Encoding。 有三个Javascript函数可以帮助我们做这一点。escape, encodeURI和encodeURIComponent<25>。推荐使用encodeURIComponent<26>。
小结
在Java系统中,因为底层(byte code)为UTF-8的,所以我们的程序采用什么样的编码方式跟操作系统并没有什么关系。在Java web系统中,无论客户端的操作系统或者服务器的操作系统,只要能保证数据传输的时候采用统一的编码方式,那么就不会有乱码问题出现。
当在某个特定的系统下发生乱码时,我们需要判断乱码发生的地方。此时,使用一些HTTP分析工具<27>是一个好办法。 有时候,我们使用一些服务器或者框架(framework),它们为我们做了一些编码转换的工作,这时候问题就变得复杂起来。解决这种问题有两点,其一是本文中提交的基本原理,其二是这种服务器(或者框架)的特性。
有时候我们开发的系统页面非常复杂,在上面使用了框架(frame),Ajax甚至flash等技术,这样有可能导致在一个浏览器窗口中存在几种编码方式,这种情况是可能存在的,遇到乱码问题就需要具体问题具体分析了。
THE END
参考
<1> 对这篇文章感兴趣的同学可以给我写邮件
<2> 冯·诺伊曼结构也称为普林斯顿结构,参考Wikipedia中关于“冯·诺伊曼结构”的介绍
<3> 参考INNA中关于的Character Sets的定义
<4> 默认在Windows XP日文版中系统的编码方式为MS932,基本等同于IANA的Shift_JIS; 默认在Windows XP中文版中系统的编码方式为MS936,基本等同于IANA的GBK
<5> 这里的存储介质指内存中的变量,磁盘上的文件以及网络上传输的数据等
<6> 其实这种说法并不准确,有些系统有默认的编码方式,譬如XML默认编码方式是UTF-8 (参考W3C中XML 1.0的规范文档Extensible Markup Language (XML) 1.0 (Third Edition)的2.2 Characters);有些系统在字符串中加入了编码方式,譬如邮件的标题前面带有“=?GB2312?”这样制定的字符集(参考RFC2047的2. Syntax of encoded-words)
<7> 下文中“编码方式”,“字符映射表”,“字符集”均指同一个意思。
<8> 参考Java虚拟机规范 —— The JavaTM Virtual Machine Specification
<9> 参考Java虚拟机规范 2.1 Unicode
<10> HTTP协议的描述在RFC2616中
<11> 根据RFC2616,HTTP协议是基于文本的协议,在HTTP层传递的是使用ISO-8859-1编码的文本数据。关于这个问题的具体讨论在我讲的《Java Web Programming》中第一章第二节。
<12> 本节内容可以参考JavaServer Pages Specification。 目前最新版的JavaServer Pages Specification 2.1是JSR245
<13> 在HTTP 1.0规范中, 如何确定网络上传输的数据格式是采用Content-Type的,但是在HTTP 1.1规范中采用了Content-Language这个报头。 另外,需要注意,编码方式(字符集)和报头Content-Encoding没有关系。 参考RFC2616
<14> 这句话不准确,根据HTTP协议(RFC2616),HTTP协议是基于文本的协议,虽然在TCP/IP层传递的是比特流,但是在HTTP层传递的应该是文本数据。所以这句话应该说是在HTTP层传递的是使用ISO-8859-1编码的文本数据。但是这样会导致我们讨论的问题复杂化,所以我们简单认为在网络上传输的是比特流。进一步讨论可以给我写邮件
<15> 在客户端接收到服务器端传递过来的数据的时候,实际上是文本数据,采用ISO-8859-1编码。 因为HTTP报头完全符合ISO-8859-1的编码格式,所以客户端可以正确的读取HTTP报头中的信息。然后,客户端会把HTTP报文按照ISO-8859-1转化为比特数据,接着把这些比特数据按照HTTP报头中Content-type的设置转化为相应的字符。 我们在讨论中为了简单起见省略了这些步骤。
<16> 如果有Content-type报头,那么浏览器一般会优先采用Content-type报头中定义的字符集。
<17> 对于Tomcat服务器
<18> 虽然在RFC中没有规定URL的最大长度,但是使用Internet Explorer支持的URL最大长度为2083个字符。 参考 KB208427
<19> 关于URL Character Encoding 可以参考RFC1738 2.2节
<20> 但是设置meta data会影响客户端的字符集设置
<21> 参考W3C的《The XMLHttpRequest Object》
<22> 此处为HTTP 1.0规范的定义, 在HTP 1.1规范中, 编码方式定义在Content-Language中, 参考RFC2616
<23> 参考W3C的《XML Base》
<24> 如果没有指定,默认的是ISO-8859-1
<25> MSDN中对这三个函数的描述分别如下 escape, encodeURI 和 encodeURIComponent
<26> 在很多目前流行的Ajax框架中都使用这个函数。而仍然有些旧的系统在使用escape,我以为这是个误区
<27> 在我讲的《Java Web Programming》中使用了一种叫Webscrab的工具,另外我写这篇文章时使用一个叫做Http Analyzer的工具
<28> 非客户端操作系统默认的编码方式, 在“提交数据的编码方式”一节中具体讨论
<29> 非服务器端操作系统的默认编码方式, 在“提交数据的编码方式”一节中具体讨论
<30> 参考我的讲座《Java Web Programming》中第六章第一节的内容