注:本文主要摘自《深入分析Java Web技术内幕》-许令波著
1、常见编码格式
1) ASCII码
ASCII码,总共有128个,用一个字节的低7位来表示,0-31是控制字符(换行、回车等),32-126是打印字符,可以通过键盘输入并且能够显示出来。
2) ISO-8859-1
128个字符显然是不够用的,于是ISO组织在ASCII码基础上又指定了一系列标准来扩展ASCII编码,它们是ISO-8859-1~ISO-8859-15,其中ISO-8859-1涵盖了大多数西欧语言字符,应用比较广泛。然而ISO-8859-1仍是单字节编码,可以表示256个字符。
3) GB2312
GB2312是双字节编码,总的编码范围是A1-F7,其中A1-A9是符号区,包含682个字符,从B0~F7是汉字区,包含6763个汉字。
4) GBK
GBK扩展了GB2312,加入了更多的汉字,它的编码范围为8140-FEFE(去掉XX7F),总共有23940个码位,可以表示21003个汉字,并兼容GB2312。
5) UTF-16
UTF-16定义了Unicode字符在计算机中的存取方法。UTF-16用两个字节表示Unicode格式,它是定长的表示方法,不论什么字符都可以用两个字节表示,两个字节是16bit,所以叫UTF-16。UTF-16表示字符非常方便,每两个字节表示一个字符,简化了字符串操作,这也是Java以UTF-16作为内存的字符格式的一个很重要的原因。
6) UTF-8
UTF-16统一采用两个字节表示一个字符,虽然在表示上非常方便;但是也存在缺点,有很大一部分字符用一个字节就可以表示的,现在要用两个字节表示,存储空间多了一倍,在网络传输中,无疑会增大网络传输流量。UTF-8采用了一种变长技术,每个编码区域有不同的字码长度,不同类型的字符可以由1-6个字节组成,规则如下:
l 如果一个字节,最高位为0,表示这是一个ASCII字符;
l 如果一个字节,以11开头,连续的1的个数暗示这个字符的字节数,如110xxxxx表示它是双字节UTF-8字符的首字节;
l 如果一个字节,以10开始,表示它不是首字节,需要向前查找才能得到当前字符的首字节;
对中文字符GB2312、GBK、UTF-16、UTF-8都可以处理,那如何选择编码格式呢?
GB2312与GBK编码规则类似,只是GBK所包含的范围更大,可以处理所有汉字。UTF-16与UTF-8都是处理Unicode编码,一个是定长编码,一个是变长编码。UTF-16为定长编码,编码效率最高,进行字符到字节的转换也简单,适合本地磁盘和内存之间使用,可以进行字节和字符的快速切换(Java的内存编码采用的是UTF-16)。UTF-16并不适合在网络中传输, UTF-16占用的空间较大;而UTF-8比较适合网络传输,在编码效率上介于GBK和UTF-16,是理想的中文编码方式。
2、Java编码时机
在JavaIO中,IO操作主要涉及字节流和字符流。当字节流和字符流相互转换时(包括磁盘IO和网络IO),需要进行编码,字节流和字符流相互转换过程如下所示:
字节到字符的转换,发生在文件读操作,InputStreamReader是字节到字符转换的桥梁,在转换过程中需要指定编码字符集,若没有指定则采用操作系统默认字符集(由于Java是跨平台的,最好指定编码字符集,否则容易出现乱码问题)。
字符到字节的转换,发生在文件写操作时,在进行转换时,同样需要指定编码格式,若没有指定则也将采用操作系统默认字符集。
在应用程序中只要涉及IO操作,只要指定统一的编码格式,一般不会出现乱码问题。特别是跨平台的应用程序,必须指定编码格式;否则,应用程序的编码格式将和操作系统环境关联起来,在其他环境很可能出现乱码问题。
此外,在内存中进行字符到字节的转换,也要指定编码格式。
3、为什么产生中文乱码
当用户从浏览器发起一个HTTP请求,存在编码的地方有URL、Cookie、Parameter。服务器端接受到HTTP请求后解析HTTP协议,其中URI、Cookie和Parameter都需要解码;此外服务端可能还要访问其他资源文件,如数据库、文本文件等,这些数据也存在编码问题。当Servlet处理完请求后,需要将数据返回给浏览器,经浏览器解析后才显示,这个过程也存在编码问题。这些过程如下所示
一次HTTP请求会有很多地方需要编码和解码,它们的编码、解码规则是什么呢?
3.1、URL编解码
用户提交一个URL,若存在中文,就需要对URL编码。URL是由多个部分组成的,如下
以Tomcat为例,URL的各个组成部分对应关系如下:
1) domain对应Tomcat配置文件server.xml中Host节点
<Host appBase="webapps"name="localhost" …>
2) port对应Tomcat配置文件server.xml中Connector节点
<Connectorport="9090"protocol="HTTP/1.1"… />
3) context path对应Tomcat配置文件server.xml的Host节点的子节点Context
<Context docBase="jzt"path="/jzt" … />
4) servlet path对应web应用w配置文件web.xml中的url-pattern节点
5) path info对应请求的servlet
6) parameter为对应GET请求的参数
在上面的请求中pathinfo和parameter中都出现了中文,那么在浏览器中输入URL时,浏览器是如何编码这个URL呢?最终浏览器(ChromeVersion 44.0.2403.157 Mac版)将URL编码为:
http://localhost:8080/examples/servlets/servlet/%E5%90%9B%E5%B1%B1?author=%E5%90%9B%E5%B1%B1
经过测试pathinfo和parameter中“君山”的编码为UTF-8编码。为什么会有%,URL编码规范RFC3986可知,浏览器编码URL将非ASCII字符按照某种编码格式编码成16进制后,将每个16进制表示的字节前加上“%”。
虽然上面pathinfo和parameter中的中文采用的是相同的编码,但不同浏览器对pathinfo的编码可能并不相同,对服务器的解码造成了很大困难。所以在实际应用中pathinfo一般并不采用中文。
Tomcat对URI部分进行解码的字符集是在connect中定义的,<ConnectorURIEncoding="UTF-8"port="9090"protocol="HTTP/1.1"…/>,如果没有定义将以默认编码ISO-8859-1来解析。所以有中文URI时最好将URIEncoding设置为UTF-8编码。
服务器对请求的参数是如何解析呢?GET请求的参数和POST请求的参数在服务端都是通过HttpRequest对象的getParameter方法获取。对请求参数的解码是第一次调用getParameter时进行的(以Tomcat为例,详情可参考Tomcat源码)。但是对GET请求和POST请求解析参数时,使用的字符集可能并不相同。那么GET请求参数和POST请求参数的解码字符集是在哪里定义的呢?
GET请求参数的解码字符集要么是Header中ContentType定义的Charset,要么就是默认的ISO-8859-1,要使用ContentType中定义的编码解码就需要设置Tomcat配置文件server.xml中connector的useBodyEncodingForURI属性为true,<ConnectorURIEncoding="UTF-8"useBodyEncodingForURI="true" …/>
useBodyEncodingForURI为true,将会对GET请求参数采用BodyEncoding(HTML页面中的contentType属性中定义的字符集)进行解码,而并不是对URI整体采用BodyEncoding进行解码。
URL编码和解码是比较复杂的,在应用程序中尽量避免使用非ASCII字符,不然很可能会遇到乱码问题。此外,服务器最好在<Connector/>设置URIEncoding和useBodyEncodingForURI两个参数。
3.2、HTTP Header编解码
当客户端发起一个HTTP请求时,除了URL外还可能会在Headr中传递其他参数,如Cookie等,这些参数很可能也会存在编码问题。
Tomcat对Header中参数解码也是调用HttpRequest对象的getHeader方法时进行的。如果请求的Header中没有解码字符集,将采用默认编码ISO-8859-1对参数进行解码,如果请求Header中有非ASCII字符则很可能会产生乱码。
当向服务器返回数据时,如果需要添加头信息,如Cookie,最好不要在Header中传递非ASCII码。如果一定要传递,可以先将这些字符采用URLEncoder进行编码,再添加到Header中。这样浏览器将Cookie信息发送到服务器时就可可以采用同样的字符集解码就好了。
3.3、POST参数编解码
POST请求的参数是通过HTTP的BODY传递到服务器端的。浏览器是如何对POST请求的参数进行编码呢?浏览器将根据请求页面的contentType中的charset编码格式对参数进行编码,然后提交到服务器。在服务器端,服务器也是根据contentType中的charset编码对其解码的(服务如何获取该值呢?),所以POST请求参数一般不会出现乱码。
注意;Tomcat在解析请求参数前会先获取Header中的contentt-type请求头,并检查这个content-type中的charset值,默认情况下浏览器在提交form表单时,提交的content-type中是不会含有charset信息的。所以如果没有调用requet.setCharacterEncoding方法设置解析编码集,那么表单的数据会按照系统的默认编码方式解析。注意一定要在第一次调用getParameter方法之前设置,否则POST请求参数也可能出现乱码。
此外,对multipart/form-data类型的参数,也就上传文件的编码,同样也使用ContentType中的字符集。上传文件是用字节流的方式传输到服务器的本地临时目录,这个过程并没有涉及字符编码。当将文件内容添加到parameters中时,将会进行编码,如果没有编码集可用,将采用默认编码ISO-8859-1。
3.4、HTTP BODY编解码
当用户请求成功后,请求的资源将通过Response返回给浏览器。这个过程先要经过编码再到浏览器进行解码,编码字符集可以通过response.serCharacterEncoding方法来设置,并通过Header的Content-Type返回给客户端。浏览器接收到响应后将根据Content-Type中的charset来解码。如果返回的HTTPHeader中没有charset,那么浏览器将根据HTML的
<metahttp-equiv="Content-Type"content="text/html; charset=UTF-8">
中charset来解码;如果也没有定义,那么浏览器将采用默认的编码来解码。
3.5、JS编码问题
在HTML页面或JSP页面引入外部JS文件时,外部文件可能存在中文。这时,如果没有在<script>标签内设置charset,浏览器就会以当前页面的默认的字符集来解析js文件。所以在引入外部js文件时,需要在<script>标签内添加charset属性,如下
<scripttype="text/javascript"src=""charset="utf-8"/>
此外,通过js发起异步调用的URL默认编码也是受浏览器影响的,如果使用原始的ajax调用,URL的默认编码,随浏览器的不同而不同;而且,不同的js框架对URL编码的处理也不同。为了解决该问题,在发送异步请求时可以调用JS的encodeURI方法来对URL中的字符进行UTF-8编码;解码时可以通过decodeURI。此外encodeURIComponent方法比encodeURI编码还要彻底,该函数通常用于将一个URL当作一个惨呼放在另一个URL中;解码可以通过decodeURIComponent。
当经过编码的URL传递到服务器时,需要使用java.net.URLDecoder来对参数进行解码,或将结果经编码后返回到浏览器可以通过java.net.URLEncoder。(服务器端的URLEncoder和URLDecoder与JS的encodeURIComponent和decodeURIComponent对应)。
3.6、其他编码问题
1) 数据库编码
访问数据库时一般都是通过JDBC驱动来完成的,用JDBC来存取数据要和数据的内置编码一致。在创建数据库时(以mysql为例)要显示指定字符集,如下
CREATE DATABASE IF NOT EXISTS db DEFAULT CHARSET utf8 COLLATEutf8_general_ci;
在连接数据库通过URL指定字符编码,如
url=”jdbc:mysql://localhost:3306/DB?useUnicode=true&characterEncoding=UTF-8”。
2) 下载文件
当我们下载文件时,若资源名为中文,有可能会出现乱码,此时我们可以先将资源名,采用URLEncoding进行编码,再将其写入到相应头中,如下
public void doPost(HttpServletRequest request, HttpServletResponse response)
throwsServletException,IOException
{
ServletContext context=this.getServletContext();
String path= context.getRealPath("/res/许嵩.bmp");
/*获取下载的资源*/
File file =newFile(path);
/*构造文件的输入流*/
InputStream is=newFileInputStream(file);
/*写:输出流*/
/*文件的下载头信息*/
response.setHeader("content-disposition","attachment;filename="+URLEncoder.encode(file.getName(),"UTF-8"));
OutputStream os= response.getOutputStream();
bytebuffer[]=newbyte[1024];
int len=0;
while((len=is.read(buffer))!=-1){
os.write(buffer,0,len);
}
3) xml、jsp等编码格式
xml编码格式:<?xmlversion="1.0"encoding="UTF-8"?>
jsp编码格式: <%@ page language="java"contentType="text/html; charset=UTF-8"pageEncoding="UTF-8"%>
3.7、一种不正常的解码
当我们使用request.getParameter获取参数后,若存在乱码,我们可能会采用下面的方式转码:
String(request.getParameter(name).getBytes(“ISO-8859-1”),”GBK”)
转码后可能会得到正确的汉字。由于ISO-8859-1字符集的编码范围为0000-00FF,正好和一个字节的编码范围对应。这种特性保证了使用ISO-8859-1进行编码和解码时可以保持编码数值“不变”。虽然中文字符经过网络传输时,被错误地“拆分”为两个欧洲字符,但由于在服务端第一次解析时也采用默认字符集IOS-8859-1进行解码,被错误拆分的两个字符又被合并在一起,从而组成一个正确的汉字。不建议使用该种解码方式,会增加额外的编码与解码过程。