概述
前段时间陆陆续续有一些同事跟我询问中文乱码问题,每个人的问题也都大同小异。而我最早之前也一直想写一篇这样的文章,无奈都腾不出富裕的时间,或者说拖延症比较严重(其实还是懒),这次就索性对自己狠一把,对这个问题做一个总结。
我们知道http协议是请求-响应式的,平常出现的乱码问题也就都隐藏在这一问一答之中,如果能明白字符在这个期间所走的链路,以及在这个链路中都经历了怎样的字符转换,那么遇到任何烦人的乱码问题也能够迎刃而解。
下面我会根据自身工作中的经历,讲述基于http协议在开发过程中遇到的字符乱码问题。
响应(response)时遇到的乱码问题
两千多年前孔子看见颜回煮饭时先偷偷吃了一些,便用言语责怪了颜回。颜回解释并没有偷吃,是有脏东西掉进锅里了,他把有脏东西的饭捞出来吃掉了。后来孔子感慨,所信者目也,而目犹不可信.
当你在浏览器里看到响应内容是乱码时,你会认为一定是程序吐出的字符就是乱码,解决这个问题的办法就是修改程序。事实真的是这样的吗?为了说明这个问题,我写了一段简单的程序用来模拟web程序,这段程序的作用就是输出utf-8编码的“中国”两个字符。下面我们用火狐和chrome访问这个程序:
用火狐访问http://localhost:8080
用chrome访问http://localhost:8080
从上面可以看到,对于相同的输出,不同的浏览器展现了不同的结果。Firefox在浏览器正文显示的是乱码,而在下面的“响应”标签中显示了正确的字符。Chrome则跟Firefox相反,正文显示正确,标签”response”显示乱码。并且两个浏览器显示的乱码也是不一致的, firefox显示成了三个字符,chrome则显示成六个字符。
上面说过,我的这段web程序是将“中国”这两个字符按照utf-8编码输出的,
难道是在输出的过程中被转换成了别的编码?为了一探究竟我需要看到程序输出的原始字节码,原始字节码用firefox和chrome自带的工具是看不到的,这里我用wireshark分别对两个两个浏览器做了抓包。
“中国”这两个字符在utf-8编码中对应的编码为e4b8ad(中)、e59bbd(国),如果我们抓到的包中也看到的是这六个字节,那就说明程序的输出是没有问题。
对firefox的抓包:
对chrome的抓包:
通过wireshark可以看到两个浏览器的到的结果都是一样的,Data部分都是e4b8ade59bbd,和我们的预期一致。不同的是firefox发送请求用了404个字节,chrome用了494个字节,这个其实是两种浏览器在发送请求时,带的请求头不一样,比如用来说明浏览器身份的User-Agent请求头。
既然程序的输出没有问题,那就是浏览器为什么会展示成乱码呢? 我们都知道http程序在吐出内容时还会携带一些响应头,依次来对内容做一些说明,我们上面这段程序携带的响应头如下:
可以看到只带了一个Content-Length头用来说明内容的字节长度,至于如何解释这六个字节浏览器是不知道的,所以浏览器此时只能“猜测”了。首先http协议本身就是字符型协议,既然响应头没有更多的说明,那默认就认为输出的内容也是字符内容了,剩下的问题就是“猜测”这六个字节是那种字符的编码了。从chrome的显示可以看到,chrome在浏览器窗口中显示了正确的utf-8编码,在”response”标签中且使用了错误的编码来解释这六个字节。Firefox则正好相反”响应”标签中“猜”对了编码,但是浏览器窗口中且使用了的错误的编码。
需要注意的是这里用“猜测”这个词其实是不准确的,实际上每个字符编码都有其特定的规则,如果对所有字符编码规则都很熟悉,给一段字节序,是可以推导出它的字符编码的。
知道了问题所在解决起来就很容易了,在http协议中有一个Content-Type头,用它可以指定内容的类型和内容的字符编码。现在我们为输出加上响应头Content-Type:text/plain; charset=utf-8,分别用两种浏览器访问http://localhost:8080,看到的响应头如下:
此时firefox的浏览器窗口和chrome的“response”标签都显示了正确的字符。
截止到目前我们得到的结论应该是这样的,charset指定的编码需要和输出内容保持一致,这样在显示的时候才不会出现乱码。下面我们换一种方式来访问我们的资源,我们分别使用telnet和curl来访问http://localhost:8080
通过Telnet来访问:
因为我这段web程序并没有处理任何http的请求头,它的默认动作是只要建立好tcp连接后就直接输出内容,所以看到在telnet的时并没有发送任何http协议需要的请求头,且依然可以输出内容。
从图中可以看到,charset=utf-8没错,并且我对程序没有做任何的改动,也就是说程序输出的编码和Content-Type指定的编码是一致的,但我们并没有看到正确的字符。
通过curl来访问:
可以看到响应头和内容显示,跟使用telnet访问时是一样的,内容都出现了乱码。
所以我们上面通过浏览器访问资源所得到的,关于输出编码和charset保持一致就不会出现乱码的结论是错误的吗?当然不是,不过前提是结论前必须加上“浏览器”这个限定词。实际上我们把http的响应分成数据获取和数据解释这两个步骤就会很容易理解这问题,首先在数据获取这个步骤中,浏览器、telnet、curl是没有区别的,都是和web程序先建立tcp连接,然后获取web程序返回的数据。不同的是在数据解释这个步骤中,浏览器是符合http规范的,http规范中说响应头Content-Type中的charset指定的编码,就是响应内容的实际编码,所以浏览器正确的显示了字符。我们用telnet和curl演示的例子只是负责获取数据这一个步骤,对于数据解释这个步骤是有发起命令的终端来负责的,而终端跟http协议没有半毛钱关系,终端只会只用预先设定的编码规则来显示内容。
下面是我把中端的编码设置为utf-8,然后用curl访问的结果
程序没有做任何改动,但是乱码消失了。
不在响应头中指定编码规则就真的不行吗?
将程序的响应头Content-Type设置为text/html,不设置charset,然后分别在两个浏览器中访问。
在firefox中访问:
在chrome中访问:
可以看到firefox中出现了乱码,chrome中没有。现在我们改动一下程序的输出内容,输出内容为:
<html><head><meta charset=”utf-8”></head>中国</html>
然后再用两个浏览器分别访问。
Firfox的访问:
乱码消失了。
Chrom的访问:
显示正确。
从上面的四张图可以看到,我们没有在响应头中指定内容的编码,但仍然没有出现乱码问题,原因就在Content-Type:text/html和响应内容中的<meta charset=”utf-8”>标签,这个标签对html内容本身做了一个自描述。想xml这种标签语言也可以像html这样进行自描述,也就是说对于响应是xml的内容,即使没有charset指定编码,xml也可以通过自描述对指定正确的编码。
最后需要注意的是,在处理不带charset的字符内容时,浏览器不同处理方式也不同,即使相同浏览器但版本不一样,处理方式也不一定一样。所以我这里介绍的乱码在你本地不一定会出现,但是为了确保所有浏览器不出问题,最好在响应头上加上charset并让其和内容的实际编码保持一致。如果提供的http资源并不是用在浏览器中直接访问的,而是用来提供接口供各个系统调用的,没有指定charset时就需要用其它方式来告知对方内容编码。
请求(Request)过程中出现的乱码问题
请求过程中出现乱码时主要出现在两个地方,一个是请求发送时所用的编码,另一个是web应用接收到请求后解码时所有的编码。请求发送时用什么编码,主要取决于发送请求所用的客户端,这里我们以浏览器和telnet为客户端来说明。Web应用层我们使用个tomcat来举例说明,所以如果你在工作中用的不是
tomcat,那么在解码请求时会和这里介绍的解码行为不一致,但是原理是一样的,原理明白了也就可以触类旁通了。
开始之前先解释下URL的组成:
{http://localhost:8080[/app/servletpath]}?(name=xxx)
{}:代表URL
[]:代表URI
():代表查询参数
对tomcat使用默认设置,使用如下的代码来接收请求
直接在chrome中输入 http://localhost:8080/app/中国?name=中国 得到的结果如下:
name: ä¸å½
queryString: name=%E4%B8%AD%E5%9B%BD
pathInfo: /app/ä¸å½/
requestURL: http://localhost:8080/app/%E4%B8%AD%E5%9B%BD/
从打印的信息可以知道,queryString和请求URL在发送之前chrome先把中文按照utf-8进行了百分号编码(关于百分号编码可以看http://deyimsf.iteye.com/blog/2312462),从里这判断出请求发送的时候编码是正确的,但是在使用Request.getParameter()和Request.getPathInfo()的时候出现了解码错误。在tomcat文档中可以看到有URIEncoding一个参数,文档对它的解释如下:
This specifies the character encoding used to decode the URI bytes, after %xx decoding the URL. If not specified, ISO-8859-1 will be used.
大概意思是tomcat会使用URIEncoding指定的编码对URI部分进行百分解码,如果没有指定则使用ISO-8859-1对其进行解码。通过这段解释可以知道,出现乱码的原因是未用URIEncoding指定正确的编码。下面我们将URIEncoding设置为utf-8看会出现什么结果,在tomcat的server.xml文件中配置如下:
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" URIEncoding="utf-8"/>
直接在chrome中输入 http://localhost:8080/app/中国?name=中国,结果如下:
name: 中国
queryString: name=%E4%B8%AD%E5%9B%BD
pathInfo: /app/中国/
requestURL: http://localhost:8080/app/%E4%B8%AD%E5%9B%BD/
可以看到乱码消失了,并且入参name的乱码也消失了,这说明URIEncoding对QueryString也是起作用的。
在上面的例子中我们可以看到chrome在发送请求之前,会把所有中文进行百分号编码再发送出去,并且字符编码使用的utf-8。实际上在生产过程中为了保证不出现乱码,对请求进行百分号编码(又叫URL编码)是必须的,至于为什么要进行百分号编码,可以看我早前写的一遍文章http://deyimsf.iteye.com/admin/blogs/1776082,这篇文章对为什么要百分号编码做了一个简单的解释。
由于http协议只规定请求发送时应该进行编码,并没有规定使用那种编码,所以chrome的这种处理方式,并不能代表所有的浏览器。仅同一个请求中的URI部分和Query String部分,有些浏览器的编码方式也有可能是不一样的。比如我在工作中就遇到过URI部分使用GBK编码(没有进行百分编码),而Querty String且使用的是urf-8进行百分号编码的浏览器。解决这个问题的办法就是我们在发
送任何请求之前,一定要对有中文的地方使用某种字符编码(比如utf-8)对其进行百分号编码。
关于请求体中字符编码的问题
我们上面说的乱码问题都出现在URL和Query String中,还有一种容易出现乱码的问题是在http的请求体中。使用http中的post方法提交表单就可以将入参放入到请求体中。
服务端用于接收post请求的代码很简单,如下:
非常简单,接收到入参之后直接在控制台输出。
Firefox中进行post访问:
Chrome中进行post访问:
然后在两个浏览器中分别点击提交按钮。
Firefox中提交后,后台获得结果如下:
name: Öйú
Chrome中提交后,后台后的结果如下:
name: 中国
两个浏览器再提交后都出现了乱码,并且出现了两种乱码,因为服务端的程序是一样的,所以从这个现象我们可以推测出,两个浏览器在发送请求时使用的编码肯定是不一样的,暂时还看不出是客户端问题还是服务端的问题。下面我们使用wireshark来看看两个浏览器在发送请求体时,使用的原始编码是什么。
Firefox发送请求的wireshark截图:
Chrome发送请求的wireshark截图:
分别看两张图的最下面蓝色区域,可以看到firefox部分是
name=%D6%D0%B9%FA
chrome的部分是
name=%26%2320013%3B%26%2322269%3B
相同的地方是两个浏览器都对入参name的值做了百分号编码,不同的是使用的字符编码不一样,两个浏览器发送请求时,分别使用了自己认为是“正确”的字符编码对入参做了百分号编码。有没有办法让不同的浏览器在发送post请求时使用同一的编码呢?一种简单粗暴的办法是,我们用js来控制post提交,并且在提交前将所有的入参都按照统一的字符编码(如utf-8编码)做百分号编码。
现在来看看另一种办法,上面我们在对请求提交之前为两个浏览器分别截了两张图,可以看到在firefox和chrome获取表单后的http响应头,这两张图的分别只有三个同样的响应头Server、Content-Length、Date,现在我们为这个http响应增加一个Content-Type:text/html; charset=utf-8,然后分别在两个浏览器中输入”中国”并按提交按钮。
此时可以看到,两个浏览器发送的请求提都变成了
name=%E4%B8%AD%E5%9B%BD
即urf-8形式的百分号编码。
两个浏览器提交后,后台获得的数据是
name: ä¸å½
还是乱码,只不过现在乱的一样了。
这里我们后台获取入参值的时候,使用了和前面获取Query String中的入参时一样的方法, Request.getParameter(),tomcat中的URIEncoding设置和前面是一致的,用的是utf-8编码。浏览器发送请求使用的是同样编码规则,后台接收参数也是使用的同样的方法,唯一不同的是http请求方法不一样,一个get,一个是post。所以到这里可以得出一个结论,URIEncoding对post方式不起作用。这里需要用到Request.setCharsetEncoding()方法,这个方法只对请求体起作用。
服务端代码变成如下形式:
注意Request.setCharsetEncoding()方法一定要放在所有Request.getParameter()等方法之前。
使用Content-Type请求头指定字符编码
前面我们一直使用Content-Type作为响应头,来明确响应内容的字符编码,其实这http协议头也可以用在请求中,可用用来指定请求体中的字符编码。
现在我们将服务端的中的Request.setCharacterEncoding()部分注释掉,我们使用telnet程序来模拟浏览器发送请求,模拟操作如下:
可以看到为Content-Type头增加了charset=utf-8设置。
这时候在看后端打印出了正确的编码:
name: 中国
最后的出的结论是,http使用post方式提交表单时,发送请求所使用的编码有响应头Content-Type中的charset决定,如果在获取表单的响应中没有设置charset,则浏览器根据自身“喜好”来决定。服务器端在解析请求体内容时,解码编码用Request.setCharsetEncoding()方法(j2ee)或者请求头Content-Type来指定。
关于ISO8859-1的问题
前面我们介绍了三种设置服务端解析字符的编码方式,以此来避免解码过程中出现的乱码问题,分别是URIEncoding、setCharsetEncoding()、Content-Type。如果不用这三种方式,那么对于tomcat来说,它会默认使用ISO8859-1对字符做解码。
服务端程序做如下改造:
客户端我们使用chrome浏览器:
其它地方用默认值,这其中包括tomcat中不设置URIEncodng,代码中没有Reqeust.setCharsetEncodnig(),请求头Content-Type中没有charset。然后用我们前面提到的所有访问方式,比如多种浏览器的get请求、多种浏览器的post请求,前提是发送请求时一定要对中文做百分号编码。所有这些方式都试过一遍之后你会发现,不关那种方式,只要入参name的值使用的是utf-8编码(后台的doGet方法里用的是utf-8,需要和这里保持一致),后台都不会出现乱码。是不是感觉很神(诡)奇(异)。下面我们通过走进字符编码的最底层,来一起剖析这个神奇的现象。
如果一个字符从输入到输出出现了乱码,那么在这个输入输出的中间过程中一定发生过编码转换。对于我们当前的测试用例,发生了六次编码转换:
1.浏览器对字符做百分号编码
2.Tomcat解百分号编码
3.ISO8859-1编码转java内码
4.Java内码转ISO8859-1编码
5.把字节数组当成utf-8编码转java内码
6.java内码转输出编码
开始解释这六次编码转换之前,先明确一些描述规则。
*字符:直接用其字面意思来书写,比如字符”a”、”中”等
*字节:用16进制加上前缀0x表示,比如ascii字符”a”字节表示就是0x61
*String.getBytes(“utf-8”): 把java内码转成utf-8编码
*new String(bytes[],”utf-8”): 把字节数组当成utf-8编码-转成java内码
浏览器对字符做百分号编码
前面我们已经知道,对于”中国”这两个字符,他们的utf-8编码分别是0xE4B8AD、0xE59BBD,每个字符占用三个字节。经过百分号编码后变成了%E4%B8%AD、%E5%9B%BD,可以看到百分号编码对原始编码是无损的,它只是把原始字节变成了%+原始字节的16进制表示。比如字节0xE4,转成百分号编码为%E4,有一个字节变成了三个字节。
Tomcat解码百分号编码
解码百分号编码也很简单,其实就是去掉百分号,然后将百分号后的两个字节合并成一个字节,如百分号编码%E4,解码后变为字节0xE4。到这一步“中国”这两个字符就变成了0xE4B8AD、0xE59BBD。
详细的百分编码可以看http://deyimsf.iteye.com/admin/blogs/2312462。
ISO8859-1转java内码
ISO8859-1可以简单理解为ascii的升级版本,我们知道ascii只用到了一个字节中的后7位,高位始终是0,所以它最多可以表示128个字符。ISO8859-1可ascii一样都是单字节字符集,不同的是它把最高位利用起来了,增加了一些西方字符(如±、÷等字符),详细内容可以参考https://zh.wikipedia.org/wiki/ISO/IEC_8859-1。
我们这里说的java内码是java程序运行时,在内存中存储字符的编码,用的是unicode标准中定义的utf-16编码。在java中处理字符就是各种字符编码转java内码,java内码再转各种字符编码。举一个简单的例子,java处理字符类似翻译官翻译语言。比如一个母语是汉语,精通日语和英语的翻译官,他在将日语转成英语或英语转成日语时,一定会先别他们转成母语,然后再转成其它语言。看到这里你有可能会说,厉害的翻译官不需要转成母语,或者翻译官的母语也不是一种,有可能好多种。但是目前我们的大部分计算机语言就只有一种母语。
ISO8859-1和java内码(utf-16)介绍完了就可以说转换的问题了。utf-16是一个把Unicode码点值编码成16位(两个字节)整数的序列,它会把unicode字符编码成2字节或四字节。前面说了ISO8859-1是8位长的单字节字符编码,所以utf-16编码和ISO8859-1编码是不兼容的,但是utf-16包含ISO8859-1中的所有字符,所以他们的编码之间也是有对应关系的。
在unicode文档中翻看下面两篇文档
http://www.unicode.org/charts/PDF/U0000.pdf
http://www.unicode.org/charts/PDF/U0080.pdf
从中可以看到,ISO8859-1所表示的所有字符,在unicode字符集中都可以找到,并且他们对应的unicode的码点值就是在原有的编码前加上8位0,比如ISO8859-1中字符”a”表示为0x61, 在unicode字符集中字符”a”表示为0x0061。有了对应关系就可以进行编码转换了。
在上面第2步(Tomcat解码百分号编码)后,“中国”这两个字符在内存中是这样的0xE4B8ADE59BBD,正好六个字节。我们知道这其实是这两个字符的utf-8编码序列,但是由于我们并没有告诉tomcat这是什么字符编码序列,所有tomcat就认为这是一个ISO8859-1编码序列,并把它告诉了java程序,java程序要做的就是把这个字节序列按照ISO8859-1转换成utf-16,转换成功后的对应关系是这样的:
ISO8859-1 0xE4 0xB8 0xAD 0xE5 0x9B 0xBD
UTF-16 0x00E4 0x00B8 0x00AD 0x00E5 0x009B 0x00BD
可以看到原本的两个字符,在java中变成了六个字符;原本的六个字节,在java中变成了12个字节。
Java内码转换成ISO8859-1编码
这一步骤实际上是在执行我们例子程序中
getBytes(“iso8859-1”)这个方法,也就是把utf-16转换成ISO8859-1。有第三步(ISO8859-1转java内码)中的对应表格可以看到,utf-16转ISO8859-1只需要把每个字符前面的8位0去掉就可以了,转换成功后俩个字符就又变成了0xE4B8ADE59BBD。虽然两次转换过程中,对字节的解释是错误的,但是并没有丢失原始字节信息。
把字节数组当成utf-8编码转java内码
这一步执行的是上面例子程序中的new String(0xE4B8ADE59BBD,”utf-8”)方法,因为我们的字节数组本来就是utf-8编码,所以按照utf-8来转码肯定是没问题的,转换成功后的对应关系是这一样的:
UTF-8 0xE4B8AD 0xE59BBD
UTF-16 0x4E2D 0x56FD
到这里“中国”这两个字符在java内部才得到了正确的表示。
Java内码转输出编码
这一步执行的是上面例子程序中的System.out.println(“中国”)方法,现在“中国”这两个字符在java内部用utf-16得到了正确的表示,剩下的最后一步就是对外输出,也就是对外翻译的过程,我们这里用的java自带的println方法,这个方法会根据当前平台的自身编码进行输出,比如你的平台环境是中文,那输出的可能就是GBK编码。如果你不行用平台编码,想自己决定输出编码,很简单
System.out.write(“中国”.getByte(“字符编码”));
这样就可以了。
前段时间陆陆续续有一些同事跟我询问中文乱码问题,每个人的问题也都大同小异。而我最早之前也一直想写一篇这样的文章,无奈都腾不出富裕的时间,或者说拖延症比较严重(其实还是懒),这次就索性对自己狠一把,对这个问题做一个总结。
我们知道http协议是请求-响应式的,平常出现的乱码问题也就都隐藏在这一问一答之中,如果能明白字符在这个期间所走的链路,以及在这个链路中都经历了怎样的字符转换,那么遇到任何烦人的乱码问题也能够迎刃而解。
下面我会根据自身工作中的经历,讲述基于http协议在开发过程中遇到的字符乱码问题。
响应(response)时遇到的乱码问题
两千多年前孔子看见颜回煮饭时先偷偷吃了一些,便用言语责怪了颜回。颜回解释并没有偷吃,是有脏东西掉进锅里了,他把有脏东西的饭捞出来吃掉了。后来孔子感慨,所信者目也,而目犹不可信.
当你在浏览器里看到响应内容是乱码时,你会认为一定是程序吐出的字符就是乱码,解决这个问题的办法就是修改程序。事实真的是这样的吗?为了说明这个问题,我写了一段简单的程序用来模拟web程序,这段程序的作用就是输出utf-8编码的“中国”两个字符。下面我们用火狐和chrome访问这个程序:
用火狐访问http://localhost:8080
用chrome访问http://localhost:8080
从上面可以看到,对于相同的输出,不同的浏览器展现了不同的结果。Firefox在浏览器正文显示的是乱码,而在下面的“响应”标签中显示了正确的字符。Chrome则跟Firefox相反,正文显示正确,标签”response”显示乱码。并且两个浏览器显示的乱码也是不一致的, firefox显示成了三个字符,chrome则显示成六个字符。
上面说过,我的这段web程序是将“中国”这两个字符按照utf-8编码输出的,
难道是在输出的过程中被转换成了别的编码?为了一探究竟我需要看到程序输出的原始字节码,原始字节码用firefox和chrome自带的工具是看不到的,这里我用wireshark分别对两个两个浏览器做了抓包。
“中国”这两个字符在utf-8编码中对应的编码为e4b8ad(中)、e59bbd(国),如果我们抓到的包中也看到的是这六个字节,那就说明程序的输出是没有问题。
对firefox的抓包:
对chrome的抓包:
通过wireshark可以看到两个浏览器的到的结果都是一样的,Data部分都是e4b8ade59bbd,和我们的预期一致。不同的是firefox发送请求用了404个字节,chrome用了494个字节,这个其实是两种浏览器在发送请求时,带的请求头不一样,比如用来说明浏览器身份的User-Agent请求头。
既然程序的输出没有问题,那就是浏览器为什么会展示成乱码呢? 我们都知道http程序在吐出内容时还会携带一些响应头,依次来对内容做一些说明,我们上面这段程序携带的响应头如下:
可以看到只带了一个Content-Length头用来说明内容的字节长度,至于如何解释这六个字节浏览器是不知道的,所以浏览器此时只能“猜测”了。首先http协议本身就是字符型协议,既然响应头没有更多的说明,那默认就认为输出的内容也是字符内容了,剩下的问题就是“猜测”这六个字节是那种字符的编码了。从chrome的显示可以看到,chrome在浏览器窗口中显示了正确的utf-8编码,在”response”标签中且使用了错误的编码来解释这六个字节。Firefox则正好相反”响应”标签中“猜”对了编码,但是浏览器窗口中且使用了的错误的编码。
需要注意的是这里用“猜测”这个词其实是不准确的,实际上每个字符编码都有其特定的规则,如果对所有字符编码规则都很熟悉,给一段字节序,是可以推导出它的字符编码的。
知道了问题所在解决起来就很容易了,在http协议中有一个Content-Type头,用它可以指定内容的类型和内容的字符编码。现在我们为输出加上响应头Content-Type:text/plain; charset=utf-8,分别用两种浏览器访问http://localhost:8080,看到的响应头如下:
此时firefox的浏览器窗口和chrome的“response”标签都显示了正确的字符。
截止到目前我们得到的结论应该是这样的,charset指定的编码需要和输出内容保持一致,这样在显示的时候才不会出现乱码。下面我们换一种方式来访问我们的资源,我们分别使用telnet和curl来访问http://localhost:8080
通过Telnet来访问:
因为我这段web程序并没有处理任何http的请求头,它的默认动作是只要建立好tcp连接后就直接输出内容,所以看到在telnet的时并没有发送任何http协议需要的请求头,且依然可以输出内容。
从图中可以看到,charset=utf-8没错,并且我对程序没有做任何的改动,也就是说程序输出的编码和Content-Type指定的编码是一致的,但我们并没有看到正确的字符。
通过curl来访问:
可以看到响应头和内容显示,跟使用telnet访问时是一样的,内容都出现了乱码。
所以我们上面通过浏览器访问资源所得到的,关于输出编码和charset保持一致就不会出现乱码的结论是错误的吗?当然不是,不过前提是结论前必须加上“浏览器”这个限定词。实际上我们把http的响应分成数据获取和数据解释这两个步骤就会很容易理解这问题,首先在数据获取这个步骤中,浏览器、telnet、curl是没有区别的,都是和web程序先建立tcp连接,然后获取web程序返回的数据。不同的是在数据解释这个步骤中,浏览器是符合http规范的,http规范中说响应头Content-Type中的charset指定的编码,就是响应内容的实际编码,所以浏览器正确的显示了字符。我们用telnet和curl演示的例子只是负责获取数据这一个步骤,对于数据解释这个步骤是有发起命令的终端来负责的,而终端跟http协议没有半毛钱关系,终端只会只用预先设定的编码规则来显示内容。
下面是我把中端的编码设置为utf-8,然后用curl访问的结果
程序没有做任何改动,但是乱码消失了。
不在响应头中指定编码规则就真的不行吗?
将程序的响应头Content-Type设置为text/html,不设置charset,然后分别在两个浏览器中访问。
在firefox中访问:
在chrome中访问:
可以看到firefox中出现了乱码,chrome中没有。现在我们改动一下程序的输出内容,输出内容为:
<html><head><meta charset=”utf-8”></head>中国</html>
然后再用两个浏览器分别访问。
Firfox的访问:
乱码消失了。
Chrom的访问:
显示正确。
从上面的四张图可以看到,我们没有在响应头中指定内容的编码,但仍然没有出现乱码问题,原因就在Content-Type:text/html和响应内容中的<meta charset=”utf-8”>标签,这个标签对html内容本身做了一个自描述。想xml这种标签语言也可以像html这样进行自描述,也就是说对于响应是xml的内容,即使没有charset指定编码,xml也可以通过自描述对指定正确的编码。
最后需要注意的是,在处理不带charset的字符内容时,浏览器不同处理方式也不同,即使相同浏览器但版本不一样,处理方式也不一定一样。所以我这里介绍的乱码在你本地不一定会出现,但是为了确保所有浏览器不出问题,最好在响应头上加上charset并让其和内容的实际编码保持一致。如果提供的http资源并不是用在浏览器中直接访问的,而是用来提供接口供各个系统调用的,没有指定charset时就需要用其它方式来告知对方内容编码。
请求(Request)过程中出现的乱码问题
请求过程中出现乱码时主要出现在两个地方,一个是请求发送时所用的编码,另一个是web应用接收到请求后解码时所有的编码。请求发送时用什么编码,主要取决于发送请求所用的客户端,这里我们以浏览器和telnet为客户端来说明。Web应用层我们使用个tomcat来举例说明,所以如果你在工作中用的不是
tomcat,那么在解码请求时会和这里介绍的解码行为不一致,但是原理是一样的,原理明白了也就可以触类旁通了。
开始之前先解释下URL的组成:
{http://localhost:8080[/app/servletpath]}?(name=xxx)
{}:代表URL
[]:代表URI
():代表查询参数
对tomcat使用默认设置,使用如下的代码来接收请求
@Override public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.println("name: "+req.getParameter("name")); System.out.println("queryString: "+req.getQueryString()); System.out.println("pathInfo: "+req.getPathInfo()); System.out.println("requestURL: "+req.getRequestURL()); }
直接在chrome中输入 http://localhost:8080/app/中国?name=中国 得到的结果如下:
name: ä¸å½
queryString: name=%E4%B8%AD%E5%9B%BD
pathInfo: /app/ä¸å½/
requestURL: http://localhost:8080/app/%E4%B8%AD%E5%9B%BD/
从打印的信息可以知道,queryString和请求URL在发送之前chrome先把中文按照utf-8进行了百分号编码(关于百分号编码可以看http://deyimsf.iteye.com/blog/2312462),从里这判断出请求发送的时候编码是正确的,但是在使用Request.getParameter()和Request.getPathInfo()的时候出现了解码错误。在tomcat文档中可以看到有URIEncoding一个参数,文档对它的解释如下:
This specifies the character encoding used to decode the URI bytes, after %xx decoding the URL. If not specified, ISO-8859-1 will be used.
大概意思是tomcat会使用URIEncoding指定的编码对URI部分进行百分解码,如果没有指定则使用ISO-8859-1对其进行解码。通过这段解释可以知道,出现乱码的原因是未用URIEncoding指定正确的编码。下面我们将URIEncoding设置为utf-8看会出现什么结果,在tomcat的server.xml文件中配置如下:
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" URIEncoding="utf-8"/>
直接在chrome中输入 http://localhost:8080/app/中国?name=中国,结果如下:
name: 中国
queryString: name=%E4%B8%AD%E5%9B%BD
pathInfo: /app/中国/
requestURL: http://localhost:8080/app/%E4%B8%AD%E5%9B%BD/
可以看到乱码消失了,并且入参name的乱码也消失了,这说明URIEncoding对QueryString也是起作用的。
在上面的例子中我们可以看到chrome在发送请求之前,会把所有中文进行百分号编码再发送出去,并且字符编码使用的utf-8。实际上在生产过程中为了保证不出现乱码,对请求进行百分号编码(又叫URL编码)是必须的,至于为什么要进行百分号编码,可以看我早前写的一遍文章http://deyimsf.iteye.com/admin/blogs/1776082,这篇文章对为什么要百分号编码做了一个简单的解释。
由于http协议只规定请求发送时应该进行编码,并没有规定使用那种编码,所以chrome的这种处理方式,并不能代表所有的浏览器。仅同一个请求中的URI部分和Query String部分,有些浏览器的编码方式也有可能是不一样的。比如我在工作中就遇到过URI部分使用GBK编码(没有进行百分编码),而Querty String且使用的是urf-8进行百分号编码的浏览器。解决这个问题的办法就是我们在发
送任何请求之前,一定要对有中文的地方使用某种字符编码(比如utf-8)对其进行百分号编码。
关于请求体中字符编码的问题
我们上面说的乱码问题都出现在URL和Query String中,还有一种容易出现乱码的问题是在http的请求体中。使用http中的post方法提交表单就可以将入参放入到请求体中。
服务端用于接收post请求的代码很简单,如下:
@Override public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.println("name: "+ req.getParameter("name")); }
非常简单,接收到入参之后直接在控制台输出。
Firefox中进行post访问:
Chrome中进行post访问:
然后在两个浏览器中分别点击提交按钮。
Firefox中提交后,后台获得结果如下:
name: Öйú
Chrome中提交后,后台后的结果如下:
name: 中国
两个浏览器再提交后都出现了乱码,并且出现了两种乱码,因为服务端的程序是一样的,所以从这个现象我们可以推测出,两个浏览器在发送请求时使用的编码肯定是不一样的,暂时还看不出是客户端问题还是服务端的问题。下面我们使用wireshark来看看两个浏览器在发送请求体时,使用的原始编码是什么。
Firefox发送请求的wireshark截图:
Chrome发送请求的wireshark截图:
分别看两张图的最下面蓝色区域,可以看到firefox部分是
name=%D6%D0%B9%FA
chrome的部分是
name=%26%2320013%3B%26%2322269%3B
相同的地方是两个浏览器都对入参name的值做了百分号编码,不同的是使用的字符编码不一样,两个浏览器发送请求时,分别使用了自己认为是“正确”的字符编码对入参做了百分号编码。有没有办法让不同的浏览器在发送post请求时使用同一的编码呢?一种简单粗暴的办法是,我们用js来控制post提交,并且在提交前将所有的入参都按照统一的字符编码(如utf-8编码)做百分号编码。
现在来看看另一种办法,上面我们在对请求提交之前为两个浏览器分别截了两张图,可以看到在firefox和chrome获取表单后的http响应头,这两张图的分别只有三个同样的响应头Server、Content-Length、Date,现在我们为这个http响应增加一个Content-Type:text/html; charset=utf-8,然后分别在两个浏览器中输入”中国”并按提交按钮。
此时可以看到,两个浏览器发送的请求提都变成了
name=%E4%B8%AD%E5%9B%BD
即urf-8形式的百分号编码。
两个浏览器提交后,后台获得的数据是
name: ä¸å½
还是乱码,只不过现在乱的一样了。
这里我们后台获取入参值的时候,使用了和前面获取Query String中的入参时一样的方法, Request.getParameter(),tomcat中的URIEncoding设置和前面是一致的,用的是utf-8编码。浏览器发送请求使用的是同样编码规则,后台接收参数也是使用的同样的方法,唯一不同的是http请求方法不一样,一个get,一个是post。所以到这里可以得出一个结论,URIEncoding对post方式不起作用。这里需要用到Request.setCharsetEncoding()方法,这个方法只对请求体起作用。
服务端代码变成如下形式:
@Override public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("utf-8"); System.out.println("name: "+req.getParameter("name")); }
注意Request.setCharsetEncoding()方法一定要放在所有Request.getParameter()等方法之前。
使用Content-Type请求头指定字符编码
前面我们一直使用Content-Type作为响应头,来明确响应内容的字符编码,其实这http协议头也可以用在请求中,可用用来指定请求体中的字符编码。
现在我们将服务端的中的Request.setCharacterEncoding()部分注释掉,我们使用telnet程序来模拟浏览器发送请求,模拟操作如下:
可以看到为Content-Type头增加了charset=utf-8设置。
这时候在看后端打印出了正确的编码:
name: 中国
最后的出的结论是,http使用post方式提交表单时,发送请求所使用的编码有响应头Content-Type中的charset决定,如果在获取表单的响应中没有设置charset,则浏览器根据自身“喜好”来决定。服务器端在解析请求体内容时,解码编码用Request.setCharsetEncoding()方法(j2ee)或者请求头Content-Type来指定。
关于ISO8859-1的问题
前面我们介绍了三种设置服务端解析字符的编码方式,以此来避免解码过程中出现的乱码问题,分别是URIEncoding、setCharsetEncoding()、Content-Type。如果不用这三种方式,那么对于tomcat来说,它会默认使用ISO8859-1对字符做解码。
服务端程序做如下改造:
@Override public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { System.out.println("name: "+new String(req.getParameter("name").getBytes("iso8859-1"),"utf-8")); } @Override public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doGet(req, resp); }
客户端我们使用chrome浏览器:
其它地方用默认值,这其中包括tomcat中不设置URIEncodng,代码中没有Reqeust.setCharsetEncodnig(),请求头Content-Type中没有charset。然后用我们前面提到的所有访问方式,比如多种浏览器的get请求、多种浏览器的post请求,前提是发送请求时一定要对中文做百分号编码。所有这些方式都试过一遍之后你会发现,不关那种方式,只要入参name的值使用的是utf-8编码(后台的doGet方法里用的是utf-8,需要和这里保持一致),后台都不会出现乱码。是不是感觉很神(诡)奇(异)。下面我们通过走进字符编码的最底层,来一起剖析这个神奇的现象。
如果一个字符从输入到输出出现了乱码,那么在这个输入输出的中间过程中一定发生过编码转换。对于我们当前的测试用例,发生了六次编码转换:
1.浏览器对字符做百分号编码
2.Tomcat解百分号编码
3.ISO8859-1编码转java内码
4.Java内码转ISO8859-1编码
5.把字节数组当成utf-8编码转java内码
6.java内码转输出编码
开始解释这六次编码转换之前,先明确一些描述规则。
*字符:直接用其字面意思来书写,比如字符”a”、”中”等
*字节:用16进制加上前缀0x表示,比如ascii字符”a”字节表示就是0x61
*String.getBytes(“utf-8”): 把java内码转成utf-8编码
*new String(bytes[],”utf-8”): 把字节数组当成utf-8编码-转成java内码
浏览器对字符做百分号编码
前面我们已经知道,对于”中国”这两个字符,他们的utf-8编码分别是0xE4B8AD、0xE59BBD,每个字符占用三个字节。经过百分号编码后变成了%E4%B8%AD、%E5%9B%BD,可以看到百分号编码对原始编码是无损的,它只是把原始字节变成了%+原始字节的16进制表示。比如字节0xE4,转成百分号编码为%E4,有一个字节变成了三个字节。
Tomcat解码百分号编码
解码百分号编码也很简单,其实就是去掉百分号,然后将百分号后的两个字节合并成一个字节,如百分号编码%E4,解码后变为字节0xE4。到这一步“中国”这两个字符就变成了0xE4B8AD、0xE59BBD。
详细的百分编码可以看http://deyimsf.iteye.com/admin/blogs/2312462。
ISO8859-1转java内码
ISO8859-1可以简单理解为ascii的升级版本,我们知道ascii只用到了一个字节中的后7位,高位始终是0,所以它最多可以表示128个字符。ISO8859-1可ascii一样都是单字节字符集,不同的是它把最高位利用起来了,增加了一些西方字符(如±、÷等字符),详细内容可以参考https://zh.wikipedia.org/wiki/ISO/IEC_8859-1。
我们这里说的java内码是java程序运行时,在内存中存储字符的编码,用的是unicode标准中定义的utf-16编码。在java中处理字符就是各种字符编码转java内码,java内码再转各种字符编码。举一个简单的例子,java处理字符类似翻译官翻译语言。比如一个母语是汉语,精通日语和英语的翻译官,他在将日语转成英语或英语转成日语时,一定会先别他们转成母语,然后再转成其它语言。看到这里你有可能会说,厉害的翻译官不需要转成母语,或者翻译官的母语也不是一种,有可能好多种。但是目前我们的大部分计算机语言就只有一种母语。
ISO8859-1和java内码(utf-16)介绍完了就可以说转换的问题了。utf-16是一个把Unicode码点值编码成16位(两个字节)整数的序列,它会把unicode字符编码成2字节或四字节。前面说了ISO8859-1是8位长的单字节字符编码,所以utf-16编码和ISO8859-1编码是不兼容的,但是utf-16包含ISO8859-1中的所有字符,所以他们的编码之间也是有对应关系的。
在unicode文档中翻看下面两篇文档
http://www.unicode.org/charts/PDF/U0000.pdf
http://www.unicode.org/charts/PDF/U0080.pdf
从中可以看到,ISO8859-1所表示的所有字符,在unicode字符集中都可以找到,并且他们对应的unicode的码点值就是在原有的编码前加上8位0,比如ISO8859-1中字符”a”表示为0x61, 在unicode字符集中字符”a”表示为0x0061。有了对应关系就可以进行编码转换了。
在上面第2步(Tomcat解码百分号编码)后,“中国”这两个字符在内存中是这样的0xE4B8ADE59BBD,正好六个字节。我们知道这其实是这两个字符的utf-8编码序列,但是由于我们并没有告诉tomcat这是什么字符编码序列,所有tomcat就认为这是一个ISO8859-1编码序列,并把它告诉了java程序,java程序要做的就是把这个字节序列按照ISO8859-1转换成utf-16,转换成功后的对应关系是这样的:
ISO8859-1 0xE4 0xB8 0xAD 0xE5 0x9B 0xBD
UTF-16 0x00E4 0x00B8 0x00AD 0x00E5 0x009B 0x00BD
可以看到原本的两个字符,在java中变成了六个字符;原本的六个字节,在java中变成了12个字节。
Java内码转换成ISO8859-1编码
这一步骤实际上是在执行我们例子程序中
System.out.println("name: "+new String(req.getParameter("name").getBytes("iso8859-1"),"utf-8"));
getBytes(“iso8859-1”)这个方法,也就是把utf-16转换成ISO8859-1。有第三步(ISO8859-1转java内码)中的对应表格可以看到,utf-16转ISO8859-1只需要把每个字符前面的8位0去掉就可以了,转换成功后俩个字符就又变成了0xE4B8ADE59BBD。虽然两次转换过程中,对字节的解释是错误的,但是并没有丢失原始字节信息。
把字节数组当成utf-8编码转java内码
这一步执行的是上面例子程序中的new String(0xE4B8ADE59BBD,”utf-8”)方法,因为我们的字节数组本来就是utf-8编码,所以按照utf-8来转码肯定是没问题的,转换成功后的对应关系是这一样的:
UTF-8 0xE4B8AD 0xE59BBD
UTF-16 0x4E2D 0x56FD
到这里“中国”这两个字符在java内部才得到了正确的表示。
Java内码转输出编码
这一步执行的是上面例子程序中的System.out.println(“中国”)方法,现在“中国”这两个字符在java内部用utf-16得到了正确的表示,剩下的最后一步就是对外输出,也就是对外翻译的过程,我们这里用的java自带的println方法,这个方法会根据当前平台的自身编码进行输出,比如你的平台环境是中文,那输出的可能就是GBK编码。如果你不行用平台编码,想自己决定输出编码,很简单
System.out.write(“中国”.getByte(“字符编码”));
这样就可以了。
发表评论
-
双面if
2019-01-16 23:09 635在介绍nginx变量时 ... -
nginx中的脚本(理论篇)
2018-09-06 08:07 1034按照常规的打法或者 ... -
URL编码
2016-07-20 17:39 1455URL编码又称为百分号编 ... -
我是如何构建高并发下响应式读服务的
2015-10-08 18:07 184一. 该系统的特点 该系统主要为商城全站提供数据读服 ... -
编码和乱码问题
2014-11-09 16:30 4350背景 程序员一提到编码应该都不陌生,像gbk、utf-8、as ... -
HTTP访问
2013-03-05 14:52 0GET /comic/15372.html?p=15 HTTP ... -
html中data类型的url
2013-01-31 09:55 0针对于一些小的数据,可以在网页中直接嵌入,而不是从外部文件载入 ... -
为什么要进行URL编码
2013-01-26 15:18 8657我们都知道Http协议中参数的传输是"key= ... -
Http访问
2013-01-24 17:34 0GET /comic/15372.html?p=15 HTTP ... -
URL编码(Tomcat)
2013-01-23 17:54 0{http://localhost:8080[/app/ser ... -
Http学习
2012-12-20 23:57 0HTTP1.1允许客户端不用等待上次请求结果返回,就可以发出 ... -
url重写,jsp默认支持sessionEcab
2012-12-20 23:56 0首先Tomcat context.xml 配置文件中 做如下设 ... -
MessageFormat用法,JDK国际化
2012-12-28 18:50 156String value = "hello{0}{1 ... -
表单异步提交编码问题
2012-12-20 23:53 42场景: 页面显示用GBK编码 表单中有文本框 ... -
Jsp输出
2012-07-08 00:20 411JSP在翻译成.java文件后有下面两行代码 JspWri ... -
编码转换
2012-07-08 00:11 106浏览器向服务器传递参数,不管是什么编码,都要转换成Unicod ... -
利用java注解拼装HQL
2012-05-25 16:04 1247工作中我经常会遇到这样一个场景: 一个可以进行检索的功能 ...
相关推荐
本文将从计算机存储及传输字符的基本编码标准入手,详细介绍各种不同的字符编码标准,并深入分析在Web开发过程中乱码产生的根本原因。最后,我们将根据Web开发的主要环节,提出一系列可行的解决方案,以帮助开发者更...
本文将深入探讨如何解决Web开发中的乱码问题,通过分析标题“web开发乱码解决”及描述“跟人对web开发过程中解决乱码的心得体会”,结合部分代码示例,提供一系列解决方案。 ### 解决方案一:使用Filter设置字符...
Java Web开发中的中文乱码问题是一个常见的困扰,尤其是在处理用户输入和数据显示时。问题的核心在于不同组件和环境之间编码方式的不一致。本文将深入探讨Java Web的编码机制,JSP运行原理,以及如何解决常见的乱码...
通过对JSP文件与响应编码方式的设置、文件头部的字节顺序标记(BOM)、表单数据的读取、请求参数的处理等多个方面进行深入分析,帮助开发者更好地理解和解决这一问题。 #### 1. 设置JSP文件与响应编码方式 在JSP...
在WEB开发过程中,乱码问题是一个常见的困扰,尤其是在涉及到字符编码的时候。乱码现象主要出现在数据的输入、处理和输出阶段,例如用户提交的表单数据、数据库存储的数据或者网页显示的内容。本篇文章将深入探讨...
文章首先详细分析中文乱码问题产生的原因,然后提出合理的解决方案。 一、中文乱码问题产生的原因 在 JavaWeb 技术开发中,中文乱码问题是由于 Java 系统的输入、输出和操作系统的默认编码字符集不一致导致的。 ...
UTF-8是一种变长编码,可以兼容全世界几乎所有的字符,因此在Web开发中广泛使用。 2. **乱码的产生**:乱码通常发生在以下几个环节: - 请求乱码:客户端(浏览器)与服务器交互时,POST或GET参数中的中文字符如果...
在IT领域,尤其是在Web开发中,遇到中文乱码问题是一个常见的挑战,特别是在处理WebService时。本文将深入探讨“WebSevice中文乱码”的问题,包括其产生的原因、影响以及解决方案,帮助开发者更好地理解和应对这一...
在java Web应用开发中,软件开发人员最容易遇到的问题就是中文的乱码问题,其中最常见的有两种,JSP页面中文显示乱码和表单提交参数中文乱码。本文通过深入分析这两种中文乱码问题产生的原因,分别给出了对应的解决方案...
本文将深入探讨Freemarker中中文乱码的成因及解决策略。 ### 成因分析 中文乱码问题主要源于编码不一致。在Freemarker中,乱码可能发生在多个环节:模板文件的读取、数据模型的处理以及最终HTML页面的渲染。具体来...
在Java Web开发中,中文乱码问题是一个常见的技术难题,尤其在处理HTTP请求时尤为突出。本文将深入探讨HTTP请求中的中文乱码现象,并提供相应的解决方案。 #### 二、HTTP请求方式与乱码分析 HTTP请求分为GET和POST...
在Java Web开发中,乱码问题是一个常见的挑战,它涉及到字符编码、数据传输以及环境配置等多个方面。本文将深入探讨这些问题及其解决方案。 首先,我们需要理解什么是乱码。乱码通常出现在字符集不匹配的情况下,即...
Java中文乱码问题是Java开发中常见的问题,尤其是在Web开发中,乱码问题会导致页面显示混乱,影响用户体验。解决乱码问题需要了解编码的基本原理和各种编码格式的区别。 编码的原因可以总结为两点:计算机中存储...
本书共分4部分,从xml、servlet、jsp和应用的角度向读者展示了java web开发中各种技术的应用,循序渐进地引导读者快速掌握java web开发。. 本书内容全面,涵盖了从事java web开发所应掌握的所有知识。在知识的讲解...
本文将深入探讨jQuery在处理中文数据时遇到的乱码问题,并提供一系列有效的解决方案。 #### 一、理解乱码原因 在讨论解决方法之前,我们首先需要了解导致jQuery中文乱码的根本原因。主要可以从以下几点分析: 1. ...
本书共分4部分,从xml、servlet、jsp和应用的角度向读者展示了java web开发中各种技术的应用,循序渐进地引导读者快速掌握java web开发。. 本书内容全面,涵盖了从事java web开发所应掌握的所有知识。在知识的讲解...
此外,解决汉字乱码问题还需要注意如下几点心得: - 对于不同版本的Java,它们的默认编码可能不同,需要根据实际的Java版本进行相应的编码设置。 - 在进行国际化软件开发时,应该避免在系统中使用默认编码,而应该...
在IT领域,特别是Web开发中,Struts框架作为Java Web应用的一个重要组成部分,其在处理中文字符时常常遇到乱码问题。这个问题不仅影响了用户体验,也增加了开发者的调试难度。本文将深入探讨Struts框架中中文乱码的...