4.3.2 乱码问题详解

在Java程序中,一个常见的与编码相关的问题就是乱码问题,尤其是与中文相关的乱码问题。中文乱码问题和Java类路径(CLASSPATH)的问题一样,是很多刚接触Java的开发人员都会遇到的问题。经过前面的介绍,读者应该对Java中的编码格式有了一定的了解。所谓的乱码,指的是某种编码格式产生的字节序列被错误地用另外一种不兼容的编码格式进行解码,得到的结果通常是一些奇怪的字符。乱码的问题通常发生在字符串在Java程序的边界之间传递的时候,尤其是在字符串输入的时候。当一个字符串传入Java程序的时候,如果没有正确地指定其使用的编码格式,Java程序可能采用错误的编码格式进行解码,因此得到了错误的UTF-16字节序列。而在Java程序产生输出的时候,问题则相对较少。Java程序总是可以使用UTF-16或UTF-8作为其输出内容的编码格式。从应用的类别来说,桌面应用的乱码情况比较少。这主要是因为接收数据输入的一般是Java平台提供的用户界面组件。对于Web应用来说,出现乱码问题的情况就比较多。这主要是因为不同平台上的不同浏览器在发送字符串数据给服务器的时候,所采用的编码格式可能各不相同,不同的Web应用开发平台所提供的编码支持能力也不尽相同,这使得问题变得更加复杂。

下面着重对Web应用中的乱码问题进行比较深入的讨论。通常来说,用户的输入会出现在Web应用的两个地方:一个是作为Web应用的URL的一部分,比如对一个搜索引擎来说,用户提交的搜索关键词通常是直接出现在进行搜索的URL中的;另一个是出现在HTML表单提交的数据中,比如在一个新用户注册页面中,用户输入的相关信息会被以表单提交的方式传输到Web应用中。从HTTP请求的角度来说,前者对应的是GET请求,而后者对应的是POST请求。用户提供的这些信息都是以字符的形式输入的,通过HTTP协议传输之后被Web应用的后台接收到。

首先讨论HTTP GET请求。在GET请求中,相关的信息都是作为URL的一部分而出现的。在目前的Web应用开发实践中,使用有意义的URL被认为是一个很好的实践。比如对一个博客网站来说,一个常见的做法是把每篇文章的标题作为该文章的URL的一部分。这种做法带来的最直接的好处是对搜索引擎更加友好,在搜索结果中的排名比较靠前。因为搜索引擎通常会对URL中出现的内容赋予比较高的权重。对于只包含ASCII字符的内容来说,作为URL的一部分并不是一件困难的事情。不过对于ASCII之外的字符,如中文字符来说,则只有经过正确的编码之后才能出现在URL之中。

在与URL相关的规范RFC 3986[1]中对URL中允许出现的字符做了详细的规定。允许出现在URL中的字符分成两大类:保留字符和非保留字符。保留字符是指在某些情况下有特殊含义的字符,包括“!”、“*”、“'”、“(”、“)”、“;”、“:”、“@”、“&”、“=”、“+”、“$”、“,”、“/”、“?”、“#”、“[”和“]”。这些保留字符的含义各不相同,比如“/”用来分隔URL中的路径,“?”表示URL中查询字符串的开始,“&”用来分隔查询字符串中的不同参数等。非保留字符则是不具备特殊含义的字符,包括大小写英文字母、0到9的数字、“-”、“_”、“.”和“~”。对于保留字符来说,如果只希望表示这个字符本身,而忽略其特殊含义,需要对这个字符进行编码。URL中使用的编码格式是“百分号编码格式”,也就是将保留字符对应的ASCII编码值的二进制形式转换成相应的十六进制方式之后,再加上“%”作为前缀。例如,当希望在查询字符串中把“?”作为某个参数的值的一部分的时候,就需要对它进行百分号编码,因为这个时候没有用到“?”的特殊含义。“?”的ASCII编码是63,对应的十六进制形式是3F,因此百分号编码的结果是“%3F”。除了非保留字符之外的其他字符,要出现在URL中,都需要经过百分号编码。一个特例是空格,由于空格出现得比较频繁,除了可以使用其百分号编码格式“%20”之外,也可以用“+”字符来代替。这样的好处是可以减少URL的长度。

对于ASCII字符集之外的其他字符来说,RFC 3986规范的要求是这些字符应该先通过UTF-8编码格式得到其对应的字节序列,再对这个字节序列中的每个字节都使用百分号编码格式。比如中文字符“你好”,经过UTF-8编码之后的字节序列是“0xE4BDA0E5A5BD”。如果出现在URL中,则应该使用“%E4%BD%A0%E5%A5%BD”的形式。使用UTF-8是一种常见的做法,却不是唯一的做法。不同的Web应用有可能采用不同的编码格式,这取决于Web应用本身,因为这些URL最终是由应用本身来处理的。Java提供了java.net.URLEncoder类,根据不同的编码方式对字符串进行百分号编码,以在构造URL时使用。以百度搜索引擎为例,在百度的搜索URL中对输入的关键词用GB2312进行编码,所以当需要构造URL时,可以使用代码清单4-6中的代码实现。

代码清单4-6 URL编码方式


String url="http://www.baidu.com/s?wd="+URLEncoder.encode(keyword,"GB18030");


如果采用了错误的编码方式,百度搜索引擎的服务器在按照GB2312进行解码的时候,就会变成无法识别的字符。不过这种错误情况一般只发生在直接在外部程序中构造URL的时候,如一些网页抓取程序。一般用户都是直接单击页面内的超链接来访问新页面的。这些URL都是Web应用自己生成的,不存在这个问题。

在服务器端处理GET请求的时候,一般底层应用服务器会提供相关的URL解码功能,只需要对应用服务器进行配置即可。比如Tomcat是通过URIEncoding来进行配置的。如果希望自己来处理URL的解码,可以使用java.net.URLDecoder类的decode方法。

在通过页面中的HTML表单来提交数据的时候容易产生问题。从浏览器提交来的字符可能会经过多个不同层次的编码转换,每个环节都可能产生错误。表单提交的数据一般是用“application/x-www-form-urlencoded”作为其内容类型。正如类型名称中的“urlencoded”所表示的含义一样,表单中的内容也是以类似GET请求中的URL的编码方式来进行编码的。对于其中包含的字符,也使用百分号编码的方式。而在编码的时候使用的字符集应该在POST请求的内容类型中显式地声明。比如在用GB18030进行编码的时候,就应该使用“application/x-www-form-urlencoded;charset=gb18030”作为POST请求的HTTP头“Content-Type”的值。

浏览器在对表单内容进行编码的时候使用的字符集由当前页面的字符集来确定。当前页面的字符集的确定取决于下面几个因素:返回页面的HTTP响应中的“Content-Type”头中给出的字符集,如“text/html;charset=utf-8”;页面中通过<meta>标签声明的字符集;用户通过浏览器的界面手动选择的字符集。如果确定的字符集是UTF-8,那么在表单提交的时候,非ASCII字符会在以UTF-8编码之后转换成百分数编码形式发送给服务器。通过HTML中<form>元素的“accept-charset”属性可以设置与页面编码不同的专供表单提交使用的编码格式。不过需要注意浏览器兼容性问题,不同的浏览器对于这个属性的支持是不同的。但是浏览器在完成编码之后,一般不会把所用的字符集声明出来。大部分浏览器都只是用“application/x-www-form-urlencoded”作为HTTP头“Content-Type”的值,而不会显式地使用“application/x-www-form-urlencoded;charset=utf8”这样的格式。这也是造成乱码问题的一个很重要的原因。

将编码之后的数据发送到服务器之后,服务器端的Java程序一般不直接处理HTTP请求,而是依靠底层的框架来完成。一般的Java Web应用都基于servlet规范进行开发。HTTP请求的入口一般是某个servlet实现类中的doGet、doPost或doPut方法。在这几个方法中通过以参数方式传入的HttpServletRequest类的对象就可以获取到浏览器端发送过来的数据。对于GET和内容类型为“application/x-www-form-urlencoded”的POST请求,都可以通过HttpServletRequest类的对象的getParameter方法来得到参数的值。对于POST或PUT请求,还可以通过getInputStream来得到用来读取请求中的内容的java.io.InputStream类的对象。如果读取到的是InputStream类的对象,处理起来相对容易一些,因为是字节流。而通过getParameter方法得到的是String类的对象,这其中就涉及编码的问题。如果没有在“Content-Type”头中显式指定,根据相关的规范,ISO 8859-1是默认的编码格式。也就是说,虽然包含HTML表单的网页使用了UTF-8作为编码格式,浏览器也按照UTF-8的格式提交了数据,如果服务器或代码中没有经过正确的设置,底层实现将使用默认的ISO 8859-1进行处理,这样就会产生乱码问题。

对于新开发的Web应用来说,推荐在整个应用的各个层次上都使用UTF-8作为统一的编码格式。即便不使用UTF-8,编码格式也应该统一为一种。所有的HTML页面都通过<meta>标签来声明使用UTF-8作为编码格式。也需要将文件本身的编码设置为UTF-8。同时对服务器进行配置以发送正确的HTTP响应头信息。JSP页面则通过“<%@page pageEncoding="UTF-8"%>”来声明页面的编码格式。而在服务器端,通过HttpServletRequest类的setCharacterEncoding方法来显式地设置解析时使用的编码格式。常用的做法是通过一个过滤器javax.servlet.Filter接口的实现来对所有请求的HttpServletRequest类的对象进行设置。代码清单4-7给出了一个简单的实现,基本的思路是,如果请求中没有指定编码格式,就设置为默认的UTF-8格式。

代码清单4-7 设置编码格式的过滤器


public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)

throws IOException, ServletException{

if(request.getCharacterEncoding()==null){

request.setCharacterEncoding("UTF-8");

}

chain.doFilter(request, response);

}


在某些情况下,并不能要求对所有的页面都使用同样的UTF-8编码格式。这种情况在处理遗留系统的时候比较常见,比如需要把一个遗留系统的Web前端对接到新的服务器端。这就会造成新旧两种Web前端使用不同编码格式的情况,而同一个后台要能够对它们的请求做出正确处理。为了能够在这两种情况下都正确地进行编码,需要在请求中显式地指明所用的编码格式。由于浏览器不会直接在表单提交中添加编码格式的标识,最直接的做法就是自己加上一个额外的查询参数,如“encoding=GB18030”。在过滤器中就可以通过检查这个参数的值来设置相应的编码格式。不过要注意的是,不能直接通过HttpServletRequest类的getParameter方法来获取这个参数的值,因为getParameter方法在执行的过程中就已经对请求中的内容进行了解码处理,之后再通过HttpServletRequest类的setCharacterEncoding方法进行设置就没有意义了,得到的仍然是乱码。一种可行的解决办法就是由程序自己来解析URL中的查询字符串,从中得到编码格式的参数值,从而可以解决这个问题。如果请求不是由浏览器本身来发送,而是通过浏览器中的XMLHttpRequest对象或是Java程序来发出的,就具备了直接修改HTTP头的能力。这个时候,就可以使用正确的“Content-Type”头来指明编码格式了。

[1]规范的具体内容见:http://www.ietf.org/rfc/rfc3986.txt。