中文是世界上最复杂、最完备的语言之一。有时我会为自己是个中国人而感到幸运,非凡是当我看到我的一些外国朋友为学习这门语言(尤其是写汉字)而绞尽脑汁的时候。可当我用J2EE开发本地化Web应用时,又会感到很不幸。下面我就说说为什么。
尽管java平台和大多数J2EE服务器都能很好地支持国际化,我在开发中文或日文应用时仍会碰到许多有关多字节字符方面的问题:
·编码和字符集之间有什么区别?
·为什么多字节字符应用从一种操作系统迁移到另一种上时显示会有差异?
·为什么多字节字符应用从一种应用服务器迁移到另一种上时显示会有差异?
·为什么我的多字节字符应用在IE浏览器里显示正常,可到了Mozila浏览器里却又不行?
·为什么以UTF-16(通用转换格式)编码的应用在大多数J2EE服务器上都不能很好地显示?
假如你也有同样的问题,本文将有助于你找到答案。
字符的基础知识
字符在计算机出现之前就早已存在。大约3,000年前,古代中国就出现了一些非凡的文字符号(即甲骨文)。这些文字符号有特定的外形和含义,它们中的大部分都有名字和发音。所有这些文字符号汇集成了字码表,一套从属于特定语言的独特字符集合,它们与计算机没有任何关系。几千年过去了,许多语言都在发展,数以千计的字符被创造出来。如今我们要将所有这些字符都数字化为0和1,这样计算机才能理解它们。
用键盘输入单词的时候要用到字符输入法。对于简单的字符,键盘和字符间存在着一对一的映射关系;而对于较复杂的语言,需要多次敲击键盘才能输入一个字符。
在你能从屏幕上看到字符之前,操作系统必须先将字符存放在内存里。实际上操作系统在字码表的字符与一系列非负整数之间定义了一一对应的关系,它们被存放在内存里并被操作系统调用,这些整数被称为字符代码。
字符可以用文件存储或通过网络传输。软件用字符编码来定义每个字符的字符代码与八进制序列数之间的对应方法(算法)。有些字符代码对应一个字节,如ASCII码;还有一些字符代码需要对应两个或更多的字节(如中文和日文),这种对应关系依靠于不同的字符编码方式。
不同的语言使用不同的字码表,每个字码表都有一些特定的编码方式。在某些情况下,当你选用某种语言时就已经不自觉地选择了某种字符编码方式。例如当你选用中文的时候,在默认情况下你用的可能就是GBK中文字码表及称作GBK的特定字符编码方式。
为了不致混淆,我避免使用字符集这个词。显然,字符集与字码表是同义词。字符集在HTTP Mime(多用途网际邮件扩充协议)页头里被误用,其实这里的“charset(字符集)”是指“encoding(编码)”。
Java的特征之一是字符是16位的,这样就能支持Unicode(一种表示各种语言中许多不同种类的字符的标准方式)。不幸的是,这个特征在开发多字节J2EE应用中也引发了许多问题,本文将就此进行讨论。
开发阶段引起的显示问题
J2EE应用开发包括若干个阶段(如图1所示),每个阶段都可能导致多字节字符显示问题。
图1 J2EE应用开发生命周期
编码阶段
当你开始J2EE应用编码时,大多数情况下你会用JBuilder、NetBean之类的IDE,或者是UltraEdit、Vi之类的编辑器。无论你选择了哪一个,只要在jsp(JavaServer Pages)、Java或Html文件中有文字字符串,而且这些字符串是像中文或日文这样的多字节字符,那么你要是不小心的话就很可能会碰到显示问题。
文字字符串是存储于文件中的静态信息,不同语言的字符采用不同的编码方式。大多数IDE的默认编码方式是ISO-8859-1,这种编码方式适合ASCII字符,但会使多字节字符丢失信息。例如,中文版的NetBean在对文件编码时其默认编码方式就不幸为ISO-8859-1。在我编写带有中文字符的JSP文件时(如图2所示),看上去一切正常。我前面提到,屏幕上显示的所有字符都在内存中,与编码方式没有直接的关系。在保存文件后,假如关闭IDE再重新打开,这些字符就显示为乱码(如图3所示),这是因为ISO-8859-1编码方式在存储中文字符时会丢失一些信息。
图2 NetBeans里的中文字符
图3 中文字符成了乱码
字符编码API
在servlet和JSP规范里有几个API用来控制J2EE应用的字符编码过程。对于servlet请求,setCharacterEncoding()方法对当前HTTP请求设定编码方式;对于servlet响应,setContentType()方法和setLocale()方法对HTTP响应输出设置Mime头的编码方式。
这些API本身不会引发问题,但假如你忘了用它们就有问题了。例如在有些服务器上你可以正确无误地显示多字节字符而不必在代码中使用上述的任何一个API,但在其它的服务器上运行应用时字符却变成了乱码。多字节字符显示问题的成因在于服务器在处理HTTP请求和响应期间如何对字符进行编码。以下是服务器确定请求和响应的编码方式的规则,对大多数服务器都适用:
在处理servlet请求时,服务器按以下次序(自上而下)来确定请求的字符编码方式:
·代码中指定的设置(如setCharacterEncoding()方法中指定的编码方式)
·厂商的初始设置
·默认的设置
在处理servlet响应时,服务器按以下次序(自上而下)来确定响应的字符编码方式:
·代码中指定的设置(如setContentType() 方法和setLocale()方法中指定的编码方式)
·厂商的初始设置
·默认的设置
按照上述规则,假如在代码中用API进行了指定,所有的服务器都会按指定的字符编码方式编码,否则服务器就会各行其道。有些厂商用HTTP表单的隐藏字段(hidden fields)来确定请求的编码方式,还有些厂商则采用它们自己配置文件中的特定设置。即使是默认设置也不尽相同,大多数厂商采用ISO-8859-1作为默认设置,还有少数厂商采用操作系统的本地设置值。因此,一些带有多字节字符的应用在迁移到另一个厂商的J2EE服务器上时就会出现显示问题。
编译阶段
假如设置正确,在编辑的时候就能在源文件中存储多字节的文字字符串,但这些源文件不能直接执行。假如编写的是servlet代码,这些Java文件在部署到应用服务器之前必须先被编译成类文件。对于JSP文件,应用服务器在执行前会自动将其编译成类文件。在编译阶段,字符编码问题仍有可能存在。为了运行下面这个简单示例,请下载本文的源代码。
程序清单1 EncodingTest.java
1import java.io.ByteArrayOutputStream;
2import java.io.OutputStreamWriter;
34public class EncodingTest {
5public static void main(String[] args) {
6OutputStreamWriter out = new OutputStreamWriter(new ByteArrayOutputStream());
7 System.out.PRintln("Current Encoding:"+out.getEncoding());
8System.out.println("Literal output:??o?£?");
// You may not see this Chinese String
9}
10 }
有关这段源代码的说明如下:
·?我们用下面的代码确定系统当前的编码方式:
6OutputStreamWriter out = new OutputStreamWriter(new ByterrayOutputStream());
7System.out.println("Current Encoding:"+out.getEncoding());
·第8行包含直接打印输出中文文字字符串(由于操作系统语言设置的原因可能造成该字符串不能正常显示)的代码。
·用GBK编码方式保存这个Java源文件。
执行结果如图4所示。
图4 示例程序的输出。
从图4的执行结果中我们可以归纳出:
·Java编译器(javac)将系统的语言环境作为默认的编码设置,Java运行时(Java Runtime Environment.)也如此。
·只有第一次的运行结果是正确的,其它的字符串显示都有问题。
·仅当运行时的编码设置与源文件保存时的编码方式相一致时才能正确显示多字节文字字符串(否则就必须进行转码,参见“运行时阶段”部分)。
服务器配置阶段
在运行J2EE应用之前,一般会根据特定的需要对应用进行配置。在上一节中,我们发现不同的语言设置会导致文字字符串显示出问题。实际上配置存在于不同的层面,它们都可能会引发多字节字符问题。
操作系统层
操作系统对语言的支持非常重要。前面提到服务器端对语言的支持会影响JVM默认的编码设置,而在客户端的语言支持(如字体)也能直接影响字符的显示,但这不是本文要讨论的重点。
J2EE应用服务器层
大多数服务器都有一个基本服务器设置,可用来配置默认的字符编码处理方式。清单2就是Tomcat配置文件的一部分(位于$TOMCAT_HOME/conf/web.xml)。
清单2 web.xml
<servlet
<servlet-namejsp</servlet-name
<servlet-classorg.apache.jASPer.servlet.JspServlet</servlet-class
<init-param
<param-namefork</param-name
<param-valuefalse</param-value
</init-param
<init-param
<strong
<param-namejavaEncoding</param-name
<param-valueUTF8</param-value
</strong
</init-param
<load-on-startup3</load-on-startup
</servlet
Tomcat以参数javaEncoding来确定从JSP文件生成Java源文件的Java文件编码方式。这里的默认值是UTF-8,这意味着假如JSP文件中的中文字符以GBK编码保存,将会以UTF-8编码(浏览器端设置)显示,在这种情况下就可能会出问题。
JVM层
大多数服务器都答应同时运行多个实例,且每个服务器实例都能有自己的JVM实例。此外,还可对每一个JVM实例分别设置。大多数服务器用本地设置来为每个实例定义默认的语言支持。
图5 Sun ONE 应用服务器设置
图5显示的是Sun ONE(开放网络环境)应用服务器的一个本地单个实例设置。该设置给出了登录系统和标准输出的默认字符编码方式。
此外,不同的服务器使用的JVM版本可能会不同,而不同的JDK版本支持的编码标准各异,所有这些都会导致迁移问题。例如Sun ONE应用服务器与Tomcat都支持J2SE 1.4,而有些服务器只支持到J2SE 1.3。J2SE 1.4支持Unicode 3.1,它具有许多早期版本所没有的新特性。
单个应用层
每个部署在服务器上的应用在运行前都可以为其配置独立的编码设置,这就使得在同一个服务器实例上能够运行多个采用不同语言的应用。一些服务器用以下的字符编码设置为每个部署的应用指定其应使用的编码方式:
<locale-charset-info default-locale="en_US"
</locale-charset-map locale="zh_CN" agent="Mozilla/4.77 [en] (Windows NT 5.0; U)"
charset="GBK"</locale-charset-info
这种分层配置的目的是为了灵活性和可维护性。但不幸的是,当在服务器间迁移时这种做法就可能导致出问题,因为并非所有的服务器配置都遵循标准。比如说,假如在一个支持本地字符集设置的服务器上开发了应用,那么当把该应用迁移到另一个不支持这种编码设置的服务器上时就可能会碰到问题。
运行时阶段
在运行过程中J2EE应用很可能会与其它外部系统通信。应用也许会读写文件,或者用数据库治理数据,有时候还可能用LDAP(轻量目录访问协议)服务器存储标识信息。在这些情况下,J2EE应用和外部系统之间需要进行数据交换。假如数据中带有象中文这样的多字节字符,就可能会碰到问题。
大部分的外部系统都有他们自己的编码设置。例如LDAP服务器很可能使用UTF-8对字符编码;Oracle数据库系统用环境变量NLS_LANG来指定编码方式。假如Oracle是安装在中文操作系统上,该变量的默认设置为ZHS16GBK,也就是用GBK编码方式来存储中文字符。因此当J2EE应用的编码设置与外部系统不同时需要进行转码,通常用以下代码来完成这一工作:
byte[] defaultBytes = original.getBytes(current_encoding);
String newEncodingStr = new String(defaultBytes, old_encoding);
以上代码给出了如何将字符串从一种编码方式转换为另一种。例如你在LDAP服务器中用UTF-8编码存储了一个用户名(多字节字符),而在J2EE应用中用的却是GBK编码,因此当应用从LDAP服务器中取用户名时就可能被错误地编码。要解决这个问题,可以用original.getBytes("GBK")得到原始的字节,然后用new String(defaultBytes, "UTF-8")构造一个新字符串,这样就可以正确显示了。
客户端显示阶段
现在大多数J2EE应用都采用浏览器/服务器架构,以浏览器作为客户端。要在浏览器里正确显示多字节字符,需要注重以下几个方面:
浏览器语言支持:
为能正确地显示多字节字符,浏览器及其所运行的操作系统应提供对特定语言的支持,比如字体和字码表。
浏览器编码设置
服务器返回的HTML头(<meta http-equiv="content-type" content="text/html;charset=gb2312")向浏览器声明了该页面使用的编码方式,否则浏览器将使用默认编码设置或自动进行匹配。当然,用户也可以对页面的编码进行设定,如图6所示。
图6 Netscape的编码设置页
因此假如页面没有声明,多字节字符就可能显示不正确,在这种情况下用户必须手工设定当前页面的编码方式。
HTTP POST编码
用HTML页面的Form标签向服务器提交数据会使情况变得更为复杂。浏览器的编码方式取决于当前页面的编码设定,对Form标签也照此处理。这意味着假如ASCII格式的HTML页面用ISO-8859-1编码,那么用户在此页面中将不能提交中文字符。这是因为所有提交的数据都用ISO-8859-1编码,这将使中文字符丢失字节。所有的浏览器都遵守这个HTML标准。
HTTP GET编码
URL链接中带有多字节字符会使事情复杂化,像<A href = getuser.jsp?name=**View detail information of this user</A(**代表多字节字符)。这种情况很常见,例如在链接里加入用户名或其它信息以便传给下一页。但RFC (因特网标准草案) 2396中并未明确规定URL中有非US-ASCII字符时的格式,不同的浏览器会采用它们自己的方式来编码URL中的多字节字符。
以Mozila为例(如图7/8/9/10),通常是在HTTP请求发送前对URL编码。我们知道在URL编码过程中,首先根据某种编码方式(如UTF-8或GBK)将一个多字节字符转换成两个或更多的字节,然后每个字节用3个字符组成的字符串%xy来表示,其中xy是表示该字节的两个十六进制数。这方面的更多信息可参考HTML规范。不管怎样,URL编码所采用的编码方式取决于当前页面的编码方式。
我用下面这个gbk_test.jsp页面做演示:
清单3 gbk_test.jsp
<%@page contentType="text/html;charset=GBK"%<HTML
<BODY
<a href='/chartest/servlet/httpGetTest?name=王'<h1Test for GBK encoded URL</h1</a </BODY</HTML
x738b是一个中文字符的转义值,这个中文字符就是我的姓。该页面如图7所示。
图7 Mozilla的URL
当鼠标移动到该链接上时,链接的地址就会在状态栏中显示出来,可以看到URL中嵌入了一个中文字符。点击页面中的链接,可以从地址栏中清楚地看到该字符已被URL编码。字符x738b被编码为%CD%F5,这是URL编码与GBK编码共同作用的结果。在服务器端,用request.getQueryString()方法取出查询字符串;为与查询字符串相比较,接下来的一行用另一种方法getParameter(String)来显示字符,如图8所示。
图8 Mozilla中的URL编码
把当前页面的编码方式由GBK改为UTF-8,再次点击页面中的链接,出现的结果为:x738b被编码为%E7%8E%8B,如图9所示,这是URL编码与UTF-8共同作用的结果。
图9 Mozilla中的URL编码
Microsoft的IE浏览器却以不同的方式处理多字节的URL编码。IE在HTTP请求发送前不对URL编码,URL编码方式取决于当前页面的编码方式,如图10所示。
图10 IE不对URL编码
IE还有一个高级选项设置,可以强制浏览器总是以UTF-8编码方式发送URL请求,如图11所示。
图11 IE中的高级选项设置
根据以上说明我们会面临一个问题:假如应用页面的URL链接中带有多字节字符,只要用GBK编码就能在Mozilla里正常使用;但假如用户的客户端是IE,且在设置中强制浏览器以UTF-8编码发送URL请求,那么在使用中就会碰到问题。
多字节字符问题的解决方案
编写能运行于任何服务器、在任何浏览器中都能正常显示的J2EE应用是个挑战,下面是一些针对J2EE应用多字节字符问题的解决方案:
通用原则:从不假定客户端(浏览器)和服务器端有任何默认设置。
& 在编辑阶段,不要假定IDE的默认编码设置是你想要的,要手工设置它们。
假如IDE不支持特定语言,就在Java代码中用\uXXXX转义序列,在HTML页面中用XXX转义序列,或用随JDK分发的native2ascii工具将本地文字字符串转换成Unicode转义序列,这样就能避免绝大部分问题。
&在编码阶段,从不假定服务器默认的编码处理设置是正确的,而用下面的方法显式指定:
·请求:setCharacterEncoding()
·响应:setContentType(), setLocale(), <%@ page contentType="text/html; charset=encoding" %
在为多种语言开发应用时,采用UTF-8编码方式或将所有语言的字符都用\uXXXX转义序列表示。
· 在编译Java类时,确保当前语言环境变量与编码方式正确匹配。
· 在配置阶段,尽可能地使用标准设置。例如在Servlet 2.4规范中有一个配置每个应用的字符编码方式的标准:
<locale-encoding-mapping-list
<locale-encoding-mapping
<localeja</locale
<encodingShift_JIS</encoding
</locale-encoding-mapping
</locale-encoding-mapping-list
当与外部系统通信时,尽可能地找出这些系统的编码方式,假如编码不同就进行转码。可以用UnicodeFormatter.java作为调试器打印所有的字节:
清单4 UnicodeFormatter.java
import java.io.*;public class UnicodeFormatter{ static public String byteToHex(byte b) {
// Returns hex String representation of byte b
char hexDigit[] = {
'0', '1', '2', '3', '4', '5', '6', '7',
'8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
char[] array = { hexDigit[(b 4) & 0x0f], hexDigit[b & 0x0f] };
return new String(array);
} static public String charToHex(char c) {
// Returns hex String representation of char c
byte hi = (byte) (c 8);
byte lo = (byte) (c & 0xff);
return byteToHex(hi) + byteToHex(lo);
}}
总在HTML页面中对编码方式做显式声明,例如<meta http-equiv="content-type" content="text/html;charset=gb2312",不要假定浏览器的默认设置是正确的。
·不要在链接里加入多字节字符,例如查询字符串里不要用用户名,改用用户ID。
· 假如必须在链接里加入多字节字符,那就要对URL进行手工编码,可以在服务器端处理(用Java),也可以在客户端处理(用javascript或VBscript)。
难题之一:UTF-16
有了前面的知识,现在我们来分析一个真正的难题,这是我负责的一个ISV(独立软件开发商)在项目中碰到的:J2EE中的UTF-16。
现行的中文字符标准(GB18030)定义并支持27,484个中文字符。尽管这个数字看起来很大,实际上对中国人来说还不够。目前中文拥有60,000多个字符,每年还在快速增长,这个状况对中国政府在信息化方面的工作会产生严重影响。例如我姐姐的名在标准字符集中就没有,因此银行或邮电系统的计算机就打不出她的名。
我的ISV希望能建立一套完整的、能让所有人满足的中文字符系统。它定义了自己的字码表,有两个现成的字符字码表可供选择:采用GB18030标准,可扩充到1600万个字符;或采用Unicode 3.1标准,可支持1,112,064个字符。GB18030标准定义了编码规则,也叫做GB18030,它用起来很简便,目前已被JDK支持。然而假如采用Unicode 3.1标准,我们就可以从三种编码方式中进行选择:UTF-8、UTF-16或UTF-32。
我的ISV希望用UTF-16编码来处理它对中文字符的Unicode扩展。UTF-16编码最主要的特点就是所有的ASCII字符都被编码为16位的单元,这会在各个阶段引发问题。在几个服务器上做过测试后,ISV发现J2EE应用根本不支持UTF-16编码。果真如此吗?我们一起来分析开发的各个阶段以找出问题所在。
编辑阶段
假如在Java、JSP或HTML源文件中有多字节文字字符串,就需要用支持它们的IDE。我用的是NetBeans,只要将文本编码属性设置为UTF-16就能轻松支持UTF-16编码。图12所示的是一个用UTF-16编码的JSP页面,它里面只有一个静态文字字符串“hello world!”。该页面在Tomcat上运行,在Mozilla中显示。
图12 Mozilla中用UTF-16编码的页面
编译阶段
由于在Java或JSP源文件中带有用UTF-16编码的字符,因此需要编译器的支持。可以用javac -encoding UTF-16命令来编译Java源文件,而在NetBeans里则可通过GUI方便地设置编译器的属性。通过一些简单的代码测试可以发现:假如servlet文件中的字符是用UTF-16编码的,那么运行时就不会有问题。
运行时动态编译的JSP文件值得我们注重。幸运的是,大多数服务器可以对JSP页面的编码方式进行配置;而不幸的是,在Tomcat和Sun ONE应用服务器上做测试时,我发现用来将JSP文件转换为servlet Java源文件的Jasper不能识别被UTF-16编码过的JSP标签(比如<%page..%),所有这些标签都被当作文字字符串处理了!我认为问题的根源可能在于Jasper(大多数应用服务器都用它做JSP编译器),因为它以字节为单位来识别JSP的特定记号和标签。
浏览器测试
现在我们看到由于识别被UTF-16编码过的JSP标签失败,JSP不能支持UTF-16编码的文字字符,而servlets却没有这个问题。
且慢!为使测试更能说明问题,我们在测试代码中加入POST功能,让用户通过HTML的Form标签提交UTF-16编码的字符。从本文的资源一节中下载下面的示例程序:servlet PostForm.java和servlet ByteTest.java。Servlet PostForm.java用来输出一个用UTF-16编码的页面,它有一个用来向服务器提交数据的表单。在ByteTest.java里,由于不能确定服务器是否配置为UTF-16编码方式,我没有用request.getParameter()方法显示浏览器提交的数据,而改用request.getInputStream()方法从请求中提取原始数据,然后打印从浏览器得到的每一个字节。
清单5 PostForm.java
public class PostForm extends HttpServlet {....
protected void processRequest(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html;charset=UTF-16");
PrintWriter out = response.getWriter();
out.println("<html<head");
out.println("<meta content="text/html; charset=UTF-16\" http-equiv="content-type\"");
out.println("</head<body");
out.println("<form action=\"servlet/ByteTest\" method=\"POST\"");
out.println("<input type=\"text\" name=\"name\"<input type=\"submit\"");
out.println("</form</body</html");
out.close();
}
....
}
清单6 ByteTest.java
public class ByteTest extends HttpServlet {
...
protected void processRequest(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
ServletInputStream in = request.getInputStream();
response.setContentType("text/html");
PrintWriter out = response.getWriter();
byte[] postdata = new byte[50];
int size = in.read(postdata,0,50);
in.close();
out.println("<html");
out.println("<head");
out.println("<titleServlet</title");
out.println("</head");
out.println("<body");
printBytes(out,postdata, size, "postdata");out.println("</body");
out.println("</html");
out.close();
}...}
在运行过程中PostForm页面显然会用UTF-16编码,而ByteTest的输出结果又会是什么呢?
·IE:尽管页面是用UTF-16编码的,浏览器对所有输入的字符都采用UTF-8编码。
·Mozilla:无论在这个UTF-16编码的页面里输入什么字符,只有“=”这个字符能显示出来,这个运行结果显然是错误的。
结论
J2EE应用只能在以下条件下使用UTF-16编码:
·只用于servlet编程
·浏览器只限制于用IE
·虽然浏览器端的页面用UTF-16编码,在服务器端要用UTF-8解码
实际上,在J2EE应用中使用UTF-8编码并不困难。在Unicode 3.1标准中,UTF-8编码与UTF-16编码能处理的字符数目是一样的,只是在存储和处理效率方面有差异。
结束语
由此可见,假如J2EE应用碰到了多字节字符问题,你一定要深入到开发生命周期的各个阶段,检查服务器和客户端的配置情况,并借助调试工具,这样才能找出问题的根源所在。