JAVA web开发中的编码问题-解决乱码问题
本文源自以前开发Java web程序多次遇到的乱码问题的积累。
首先需要申明的是,在网上有关解决java web开发乱码问题的文章太多了,象什么request.setCharacterEncoding("UTF-8"), response.setContentType("text-html;charset=UTF-8"), 写一个Fileter等等,不可尽信。这些文章可能都是真实的,但是能引起java web开发乱码问题的因素太多了,因为有很多环节都牵涉到编码和解码,所以网上的文章千万不能拿来照用,否则只会问题越调越多,代码越写越乱。
OK,然后开始我自己的亲身经历。在以前的EC V1.2中,采用gb2312的时候,为什么不会出现乱码?其实是这样的:
1. 首先我在JSP页面中设置了:
<%@ page contentType="text/html;charset=GB2312" language="java" pageEncoding="GB2312" %>
从在网上搜索到的一些资料看,这句代码的意思应该是这样的,首先通过pageEncoding="GB2312"是告诉JSP的编译器,让JSP编译器在编译期间,碰到非ASCII码的时候,用什么码来处理的。然后前面的contextType和charset指的是app server再把页面数据传给IE的时候,使用的是什么编码。
不过众所周知,在HTML中,也有一句类似的HTML:
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
至今我还不知道这样的一句代码和上述的一句JSP代码有什么明显的差别。我目前的理解是,前面的JSP的那句代码,是通过设置HTTP头信息来设定字符编码的,有点类似response.setContentType;而后面那句是纯粹的HTML代码,不存在设置HTTP头信息的问题。至少我现在可以确定的是,meta这样一句HTML代码,是显示的告诉浏览器,用什么编码来解析我传给他的字符串。
这里有我搜索到的一些资料,里面解释了浏览器的渲染引擎是如何处理编码的:
首先浏览器根据Web Server发送的Content-Type Header,里的Charset信息来决定自己如何渲染html的显示。如果没有Content-Type,就根据Html页面里的<meta>中的Content-Type来决定渲染的字符编码。如果上述那些都没有显式指定,那么IE会比较聪明的尝试猜测给他的字符是什么编码,依据的是一些经验公式,如果猜不出来,IE会使用ISO-8859-1编码,其他浏览器我不是很清楚了。不过浏览器在无法决定编码的时候,一般都会使用ISO-8859-1这个编码。
所以,在这里我的建议是,上述两句代码都可以考虑加到JSP中,这样确保编码是我们所设定的那个。因为这个编码设定除了影响浏览器的渲染引擎之外,还会影响浏览器对数据的编码,如下:
OK, 回到一开始的问题。我们在JSP页面中写了这么一句设置charset的代码后,当我们处于一个有form表单的页面,而且在form填写了一些中文或其他字符之后,浏览器会发生什么样的行为呢?经过查阅资料,浏览器在将数据进行编码的时候,是这样一个逻辑:
(1) <Form>属性的accept-charset,指定的字符编码
(2) <meta>指定的Content-Type
(3) url-encoding 默认的字符编码.
OK,所以我们看到了,上面写的两句代码的作用就体现出来了,特别是meta的设定。上述顺序标准按照HTML Internationalization (参考RFC 2070)规范的顺序。根据实际的经验,FireBird 浏览器(或Mozilla Familly) 完全顺从这个顺序。但IE 6 是<meta>指定的Content-Type优先级别最高,所以,在HTML面在<body>标记之前,<head>标记后第一个写入<meta>来声明本页的默认字符编码,是良好的习惯。
OK, 现在明白了,当我们在form中填写了一些文本之后,浏览器会按照我们设定的编码将这些数据进行编码,回到上面我们EasyCluster V1.2中,浏览器就会使用GB2312的方式进行编码然后传给web server
2. OK, 在JSP中做完上述的事情之后,EasyCluster V1.2中有这样两种操作:
(1) EC进行了一个插入记录的操作。此时EC将得到的数据原封不动的拼成一个SQL语句(该SQL语句中有中文字符,当然是用GB2312进行编码的),然后给MySQL进行处理。
(2) EC进行了一个查询操作,此时SQL语句中没有中文字符,但是返回的结果集中会含有中文字符。此时EC在取出结果集中每个field的时候,会做一下编码转换:
new String(tmp_rst.getString("content").getBytes("ISO-8859-1"), "GB2312");
也就是从ISO-8859-1编码转换成GB2312,然后把这个数据封装到一个class中,然后送给JSP页面显示,中文显示正常。
OK,那现在问题来了,为什么这样处理就没有乱码问题呢?其实这个问题一直都没有搞清,上面的那种方法完全是实验出来的,没有理论支持。其实这里面主要就是牵涉到了MySQL是如何处理数据的。于是翻阅了MySQL中专门有关characterset的一章,找到了答案,如下:
MySQL中有关character set的内容,还会牵涉到一个collation的东西,collation其实就是比较character set的一个逻辑,因为character set是规定的字符的存放方式的,而collation其实就是当比较两个字符串的时候,知道了字符串是什么character set,此时MySQL是如何比较这两个字符串的--这就是collation。每个character set可以对应于多个collation,每个character set都有一个default collation。这里我们其实不关注collation,因为他只是在比较字符串的时候发生作用,我们的目的是不出现乱码。所以本文对collation就不详细解释了,有兴趣的自己参考MySQL reference。在mysql的命令行中,我们可以输入SHOW CHARACTER SET, SHOW COLLATION这样的命令来查看MySQL支持的所有character set和collation。
Character set support currently is included in the MySISAM, MEMORY (HEAP), and (as of MySQL 4.1.2) InnoDB storage engines. The ISAM storage engine does not include character set support; there are no plans to change this, because ISAM is deprecated.
MySQL中有关character set分成五个等级:server, database, table, column, connection. 针对这五个等级都可以设定自己独立的character。设定server的character set,是通过给mysqld传递--default-character-set="xxx"或者通过配置/etc/my.cnf来完成的,具体看MySQL的手册;对于database,table,column,都是通过SQL语句来完成的,如:
- CODE: SELECT ALL
CREATE DATABASE db_name DEFAULT CHARACTER SET latin1 COLLATE latin1_swedish_ci;
CREATE TABLE `adminmails` (
`email` varchar(255) NOT NULL default '',
`description` varchar(255) default NULL,
PRIMARY KEY (`email`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1;
注意,在mysql中,UTF-8要写成UTF8,没有中间的dash。
一般我们都只需设定character set,此时mysql会自动将collation设成该character set对应的default collation。而且database,table,column这三个等级他们的character set设定是从低到高以此覆盖的,也就是说,如果显式设定了column的character set,那么table的character set就不生效,依此类推。只有当低一级的character set没有设定的时候,才会沿用高一级的character set设定。如果这三个等级的character set都没设定,那么他们统一用server的character set,server的character set如果没有设定,MySQL的默认配置就是latin1(ISO-8859-1)。
OK,然后就是今天的主角,connection的character set设定。当MySQL处理一个connection和查询的时候,他是这么一个逻辑:
(1) What character set is the query in when it leaves the client? The server takes the character_set_client variable to be the character set in which queries are sent by the client.
(2) What character set should the server translate a query to after receiving it? For this, character_set_connection and collation_connection are used by the server. It converts queries sent by the client from character_set_client to character_set_connection (except for string literals that have an introducer such as _latin1 or _utf8). collation_connection is important for comparisons of literal strings. For comparisons of strings with column values, it does not matter because columns have a higher collation precedence.
(3) What character set should the server translate to before shipping result sets or error messages back to the client? The character_set_results variable indicates the character set in which the server returns query results to the client. This includes result data such as column values, and result metadata such as column names.
这个逻辑描述的很清楚了,总结一下:
(1) 当mysql收到一个query的时候,mysql会认为该query字符串用的编码是mysql中character_set_client这个variable所设定的编码。在mysql中,我们可以使用命令 SHOW VARIABLE来查看所有的mysql中的variable。
(2) 当mysql收到这个query后,mysql会使用character_set_connection这个variable中设定的编码来对query进行转换和翻译。所以这里就牵涉到了character_set_client这里面设定的编码和character_set_connection这里面设定的编码之间的转换。在Linux下安装的MySQL 4.1,默认情况下所有的编码和这些variable的值都是latin1(ISO-8859-1)。
(3) OK,当mysql执行完query之后,会查看character_set_results这个variable中设定的编码,然后以这个编码将resultset中的字符进行编码,然后发送回client。所以,这里面又有一个转换的过程,那就是如果在table和column中设定的character set和这个character_set_results variable中设定的不一样的时候,这里面就有一个从table,column编码到character_set_results中编码的转换过程。
(1) 首先是插入记录的时候,EC给mysql的是gb2312编码的query string,此时MySQL会认为这个query string是以character_set_client variable中设定的编码方式编码的,也就是ISO-8859-1方式编码的;然后mysql check character_set_connection variable的值,发现这个值也是ISO-8859-1,而且他发现数据库表,column中也是ISO-8859-1,于是mysql便把我们的query string转成了ISO-8859-1格式,然后将数据存进了数据库的表中。在这个转换过程中,字符是无损的,因为ISO-8859-1是一个只有256个字符的字符集,对于大于256的字符,mysql不做任何操作,只是呆板的将数据拷贝过去。同时这也解释了为什么我们在mysql的管理工具中看我们存储的数据是乱码的原因了,因为mysql认为这些数据是ISO-8859-1的,而且表结构中也是ISO-8859-1的,而其实这些数据都是gb2312的,自然就看不到中文了。
(2) 然后是我们开始查询了,由于查询的sql语句中没有中文字符,所以前两个步骤没有影响,关键是第三个步骤,mysql会查看character_set_result这个variable中设定的编码,发现是ISO-8859-1,于是他将数据取出来,然后转换成ISO-8859-1,然后发送给client,此时gb2312的字符串要转换成ISO-8859-1的,其实这个转换是个无损的转换,因为ISO-8859-1就是一个256个字符的一个字符集,对于超出256范围的字符,他不会做任何事情,只是呆板的将数据保留下来而已,所以,当我们收到这个resultset的时候,其实得到的是ISO-8859-1编码的string,于是EC做了一个ISO-8859-1到GB2312的编码转换,于是页面上中文显示正确。
3. 上面的文字已经将整个流程描述清楚了,呵呵。现在来看我们在做UTF-8的时候,为什么会出现乱码了。首先我们做的utf8test这个web app之中,JSP中的编码设定成了UTF-8,于是浏览器将数据编码成了UTF-8格式的发给我们的程序,我们的程序不做任何操作,将数据给了mysql,由于mysql中那些variable都是设成的ISO-8859-1,于是mysql将数据存在了数据库表中(数据库表和column的character set设的是UTF-8)。当我们的程序执行查询的时候,mysql将数据以ISO-8859-1编码的格式传给我们,结果我们也没做任何操作,就把数据送去给JSP显示,由于JSP中设置的是UTF-8,自然就出现乱码了。换句话说,如果我们的程序在查询取得数据的时候,做一个ISO-8859-1编码到UTF-8的转换,也就没有乱码了,这一点已经得到验证了。
4. 现在我要介绍的是一种更好的实现方式。就是使用mysql中的SET NAMES UTF8这个sql命令。执行了SET NAMES UTF8这个命令,就等于执行了:
mysql> SET character_set_client = utf8;
mysql> SET character_set_results = utf8;
mysql> SET character_set_connection = utf8;
这样三个命令,所以,mysql将以utf8的方式存放我们的数据,同时在查询返回结果的时候,也会已utf8的方式编码resultset然后返回,此时我们的代码中,无论是插入记录,还是查询记录,都不要任何额外的代码,只需要在JSP中统一将编码设成UTF-8就OK了。而且,由于表中的character set也是UTF-8,所以,此时我们在数据库管理工具的界面中,就能看到正确的汉字了,而不是乱码了。
需要注意的是,SET NAMES这个命令只对一次当前查询生效,也就是说每次执行sql之前,都需要执行一下这个命令。这个没问题,我们可以将这个动作集成到BaseBean中去执行,这样就OK了。每个逻辑类里面就不需要再做这样的动作了。
5. 加了SET NAMES这样的代码之后,需要注意一个问题-那就是数据库表和column中的character set必须是UTF-8,如果不是UTF-8,比如是默认的latin1(ISO-8859-1),那么,由于我们SET NAMES UTF8,已经明确告知了mysql,我们的query string是UTF-8编码的,此时mysql却发现数据库表中是ISO-8859-1编码的,此时mysql就会爆出一个warning,告知本次操作会有Data Truncted,虽然mysql最终会把数据插到表中,但是这个warning会导致我们的代码得到一个错误。所以如果我们执行了SET NAMES,不要忘记修改数据库中表,column的character set和SET NAMES的一致。
6. 至此,我们已经将乱码问题解释清楚了,最后附上正确的utf8test的所有源文件。千万不要随便看网上的文章,再遇到乱码问题的时候,我们应该首先用new String(xxx.getBytes("xxx"), "xxx")这种方式来猜测一下string是什么编码的,然后定位出在哪个环节,string的编码和我们预想的不一样,从而定位出问题。本次从utf8test这个例子能发现上面的这一系列的东西,就是因为无意中,我在得到了mysql的返回之后,用了一下上面的代码,得知mysql给我的字符串不是以UTF-8编码,从而定位出是mysql的问题。
(1) 在代码中查询的时候,自己做ISO-8859-1到UTF-8的转换。这种做法需要在代码的每个逻辑类中加代码,但是数据库结构方便不用动,表和column都使用默认的latin1即可。
(2) 在每次查询之前,执行一下SET NAMES UTF8,这样的好处是代码不用修改太多,修改一个基类就可以,但是要把数据库表,column中的character set改成UTF8。而且在数据库中,可以正确看到中文。
(3) 千万不要轻信网上的文章,网上的文章方法太多了,什么request.setCharacterEncoding, response.setContentType, URIEncoding, 在mysql jdbc connection pool的url配置中加上useUnicode=true, characterEncoding=utf8等等,这些都没有用。关键还是要自己定位问题,先定位乱码出现在哪个阶段,然后再对症下?,方能药到病除。
8. 这里再补充一个Tomcat 5.x中的配置,在Connector这个element中配置URIEncoding="UTF-8",这个配置项让tomcat对于GET类型的form提交,用UTF-8来编码,默认tomcat会用ISO-8859-1编码。不过对于EC来说,我们所有的form提交都是POST类型的,所以不配置也无妨
在上述五篇文章的描述中,我们是这样认为的:
1. 在JSP中,我们使用了<%page和<meta来设置UTF-8,然后我们就认为IE会把我们form表单中的文字编码成UTF-8编码,然后传给web server
2. 然后我们认为web server将这些文字传给了我们的应用程序,然后我们的应用程序又把这些文字拼成SQL,传给了MySQL。
在这里理解有些误差!在Tomcat 5.x下,Tomcat不会原封不动的将文字传给我们的应用程序,事实上, Tomcat总是会把文字编码成ISO-8859-1,然后传到我们的程序,网上一些文章说在Tomcat的server.xml中设置URIEncoding=UTF-8,还有设置什么useBody...,这些都是没有用的!!!这是我实验的结果,Tomcat总是非常执着的将文字编码成ISO-8859-1,然后传给我们的程序。
所以,在上面的文章描述中,其实由于我们在执行SQL之前,会先执行一下SET NAMES UTF8,所以其实MySQL帮我们把字符从ISO-8859-1转到了UTF-8!所以我们才能没有乱码。
以前做的那个utf8的测试,我们没有set names,所以数据从浏览器开始,一直存到数据库中的时候,都是ISO-8859-1的编码,最后要取出显示的时候,要显示成UTF-8,所以出现乱码。
所以,总结一下,上面的五篇文章没有什么大的问题,只有一个瑕疵,就是浏览器的确是把文字编码成了UTF-8,但是Tomcat会把这个字符再编码成ISO-8859-1,然后传给我们的程序,而不是上面五篇文章中认为的Tomcat会把字符原封不动的给我们。
OK,所以上面的文章是正确的,中文问题是能解决的,只不过解决不了一种中文问题,这种情况也就是为什么这次我能发现Tomcat这个特性的原因所在:
在Struts中,我们都知道,如果一个form在Action类的时候验证没通过,此时我们会调用forward.getInputForward(),将页面倒回到form表单的jsp,此时会发现原来我们填好的中文变成了乱码。就是在这个情景下,我发现了Tomcat的这个问题。因为这个情景下数据没有进mysql,mysql就不会为我们做编码转换。所以就出现了上面的乱码问题。问题不在Struts上,就是在Tomcat上,由于Tomcat将文字编码成了ISO-8859-1,所以要这样解决:
在调用forward.getInputForward之前,手动将form中的string修改一下,如:
PropertyUtils.setSimpleProperty(form, "subject", new String(subject.getBytes("ISO-8859-1"), "UTF-8"));
PropertyUtils.setSimpleProperty(form, "content", new String(content.getBytes("ISO-8859-1"), "UTF-8"));
这样就OK了!