zoukankan      html  css  js  c++  java
  • 深入分析JavaWeb的中文编码问题

    1.几种常见的编码格式

     1.为什么需要编码?

    (1)在计算机存储信息的最小单位是1个字节(byte),即8个bit,所以能表示的字符范围是0-255个。

    (2)人类要表示的符号太多,无法用1个字节来完全表示。

      要解决这个矛盾必须要有一个新的数据结构char,而从char到byte必须编码。

    2.如何编码

    在计算机中提供了多种编码方式,常见的有ASCII、ISO-8859-1、GB2312、GBK、UTF-8、UTF-16等。其中GB2312、GBK、UTF-8、UTF-16都可以表示汉子,下面介绍几种编码方式。

    1.ACSII码

      ACSII码总共128个,用1个字节的低7位表示,0-31控制字符如回车、换行等,32-126是打印字符串,可以通过键盘输入(键盘上能够找到的)并且能够显示处理。

    2.ISO-8859-1

      128个字符显然是不够用的,于是ISO在ASCII的基础上扩展出ISO-8859-1到ISO8859-15,其中ISO-8859-1涵盖了大多数西欧语言字符,所以应用最广泛。8829-1仍然是单字节编码,总共能表示256个字符。

    3.GB2312

      该编码集的全称是《信息技术中文编码字符集》,它是双字节编码,总的编码范围是A1-F7,其中A1-A9是符号区,总共包含682个符号,B0-B7是汉子区,包含6763个汉字。

    4.GBK

      全称《汉字内码扩展规范》,是为了扩展GB2312,并加入更多的汉字。编码范围是8140-FEFE(去掉XX7F),总共23940个码位,它能表示21003个汉字,它的编码和GB2312兼容,也就是说用GB2312编码的汉字可以用GBK来解码,并且不会乱码。

    5.UTF-16

      Unicode(Universal Code 统一码)是ISO试图创建的超语言字典,世界上所有的语言都可以用这个字典来翻译。Unicode 只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。其实现方式主流的有UTF-8和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个字节组成。UTF-8有如下规则:

    (1)如果是1个字节,最高位(第8位)为0,则表示这是1个ASCII字符(00-7F)可见,所有ASCII编码已经是UTF-8编码了。

    (2)如果是1个字节,以11开头,则连续的1的个数暗示这个字符的字节数。例如:110xxxxx代表它是双字节UTF-8的首字节。

    (3)如果是1个字节,以10开始,代表它不是首字节,则需要向前查找才能得到当前字符的首字节。

    例如:

    1字节 0xxxxxxx 
    2字节 110xxxxx 10xxxxxx 
    3字节 1110xxxx 10xxxxxx 10xxxxxx 
    4字节 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 
    5字节 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 
    6字节 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 

    补充:参考网上的一个U8和unicode转换的例子  

    UTF-8中可以用来表示字符编码的实际位数最多有31位,即上表中x所表示的位。除去那些控制位(每字节开头的10等),这些x表示的位与UNICODE编码是一一对应的,位高低顺序也相同。 实际将UNICODE转换为UTF-8编码时应先去除高位0,然后根据所剩编码的位数决定所需最小的UTF-8编码位数。

    下表总结了编码规则,字母x表示可用编码的位。

    Unicode符号范围 | UTF-8编码方式
    (十六进制)       | (二进制)
    --------------------+---------------------------------------------
    0000 0000-0000 007F | 0xxxxxxx
    0000 0080-0000 07FF | 110xxxxx 10xxxxxx
    0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx
    0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

      以汉字严为例演示如何实现utf-8编码。"严"的unicode编码为4e25,二进制0100111000100101,根据上表,可以发现4E25处在第三行的范围内(0000 0800-0000 FFFF),,因此"严"的UTF-8编码需要三个字节,即格式是"1110xxxx 10xxxxxx 10xxxxxx"。然后,从"严"的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。这样就得到了,"严"的UTF-8编码是"11100100 10111000 10100101",转换成十六进制就是E4B8A5。

    验证如下:

    import java.math.BigInteger;
    
    public class Client {
    
        public static void main(String[] args) throws Exception {
            // Integer.toHexString('严') 获取unicode码
            // 十六进制unicode
            System.out.println(Integer.toHexString('严'));
            // 二进制的unicode
            System.out.println(hexString2binaryString(Integer.toHexString('严')));
    
            // 二进制的UTF-8
            System.out.println(binary("严".getBytes("utf-8"), 2));
            // 十六进制的UTF-8
            System.out.println(binary("严".getBytes("utf-8"), 16));
        }
    
        /**
         * 将byte[]转为各种进制的字符串
         * 
         * @param bytes
         *            byte[]
         * @param radix
         *            基数可以转换进制的范围,从Character.MIN_RADIX到Character.MAX_RADIX,
         *            超出范围后变为10进制
         * @return 转换后的字符串
         */
        public static String binary(byte[] bytes, int radix) {
            return new BigInteger(1, bytes).toString(radix);// 这里的1代表正数
        }
    
        /**
         * 16进制转二进制
         * 
         * @param hexString
         * @return
         */
        public static String hexString2binaryString(String hexString) {
            if (hexString == null || hexString.length() % 2 != 0)
                return null;
            String bString = "", tmp;
            for (int i = 0; i < hexString.length(); i++) {
                tmp = "0000" + Integer.toBinaryString(Integer.parseInt(hexString.substring(i, i + 1), 16));
                bString += tmp.substring(tmp.length() - 4);
            }
            return bString;
        }
    
        // 2进制转16进制
        public static String binaryString2hexString(String bString) {
            if (bString == null || bString.equals("") || bString.length() % 8 != 0)
                return null;
            StringBuffer tmp = new StringBuffer();
            int iTmp = 0;
            for (int i = 0; i < bString.length(); i += 4) {
                iTmp = 0;
                for (int j = 0; j < 4; j++) {
                    iTmp += Integer.parseInt(bString.substring(i + j, i + j + 1)) << (4 - j - 1);
                }
                tmp.append(Integer.toHexString(iTmp));
            }
            return tmp.toString();
        }
    
    }

    结果:

    4e25
    0100111000100101
    111001001011100010100101
    e4b8a5

      关于更多的参考:http://www.ruanyifeng.com/blog/2007/10/ascii_unicode_and_utf-8.html

    补充:Little endian 和 Big endian编码方式

    以汉字严为例,Unicode 码是4E25,需要用两个字节存储,一个字节是4E,另一个字节是25。存储的时候,4E在前,25在后,这就是 Big endian 方式;25在前,4E在后,这是 Little endian 方式。

    第一个字节在前,就是"大头方式"(Big endian),第二个字节在前就是"小头方式"(Little endian)。

    那么很自然的,就会出现一个问题:计算机怎么知道某一个文件到底采用哪一种方式编码?

    Unicode 规范定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做"零宽度非换行空格"(zero width no-break space),用FEFF表示。这正好是两个字节,而且FF比FE大1。

    如果一个文本文件的头两个字节是FE FF,就表示该文件采用大头方式;如果头两个字节是FF FE,就表示该文件采用小头方式。

    例如,另一种方式获取unicode码:

    System.out.println(binary("严".getBytes("unicode"), 16));

    结果:(表示大头存储方式)

    feff4e25  

    2.在Java中需要编码的场景

    1.I/O操作中存在的编码  

      涉及编码的地方一般是从字符到字节或者是从字节到字符的转换上,需要转换的场景主要是IO,包括磁盘IO和网络IO,这里先介绍磁盘IO。

      java中处理IO问题的接口如下:

      Reader类是在Java的IO读字符的父类,而InputStream是读字节的父类,InputStreamReader类就是关联字节到字符的桥梁,它负责在IO过程中读取字节到字符的转换,而对字节到字符的解码实现,它又委托StreamDecoder实现,在StreamDecoder解码过程中必须由用户指定Charset编码格式。值得注意的是,如果没有指定Charset,则将使用本地环境中的默认字符集,如在中文环境使用GBK。

      写的情况也类似,字符的父类是Writer,字节的父类是OutputStream,通过OutputStreamWriter转换字符到字节。同样,StreamEncoder负责将字符编码成字节,编码格式和默认编码规则与解码是一致的。如下:

     如下代码实现文件的读写:

    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.io.OutputStreamWriter;
    
    public class PlainTest {
    
        public static void main(String[] a) throws IOException {
            //
            String file = "G:/test.txt";
            String charSet = "UTF-8";
            OutputStreamWriter outputStreamWriter = new OutputStreamWriter(new FileOutputStream(file), charSet);
            try {
                outputStreamWriter.write("测试字符串");
            } finally {
                outputStreamWriter.close();
            }
    
            //
            InputStreamReader inputStreamReader = new InputStreamReader(new FileInputStream(file), charSet);
            StringBuffer stringBuffer = new StringBuffer();
            char[] buffer = new char[20];
            int count = 0;
            try {
                while ((count = inputStreamReader.read(buffer)) != -1) {
                    stringBuffer.append(buffer, 0, count);
                }
            } finally {
                inputStreamReader.close();
            }
            System.out.println(stringBuffer.toString());
        }
    
    }

      在我们的应用涉及IO操作时,只要注意指定统一的编码解码Charset字符集,一般不会出现乱码问题。对有些应用程序不指定字符编码,则在中文环境中会使用操作系统默认编码。不建议使用操作系统默认的编码,因为这样会使应用程序和运行环境绑定起来,在跨环境时很可能出现乱码问题。

    2. 内存中的编码

      在内存中进行从字符到字节的数据类型转换也涉及编码,在Java中用String表示字符串,所以String类就提供了转换到字节的方法,也支持将字节转换为字符串的构造函数。

            String string = "测试字符串";
            byte[] bytes = string.getBytes("UTF-8");
            String string2 = new String(bytes, "UTF-8");

    3.在Java中如何编解码

      以" I am 君山" 为例子介绍在java中如何以ISO-8859-1、GB2312、GBK、UTF-8、UTF-16进行编解码。

    java中编码用到的类图如下:

       首先根据指定的charsetName通过Charset.forName(charsetName)找到Charset类,然后根据Charset创建CharsetEncoder,再调用charsetEncoder.encode()方法对字符串进行编码,不同的编码类型都会对应到一个类中,实际的编码过程是在这些类中完成的。

    string.getBytes(charsetName)编码过程的时序图如下:

       

    1.按照ISO-8859-1编码

            String str = "I am 君山";
            String charset = "ISO-8859-1";
            byte[] bytes = str.getBytes(charset);
            System.out.println(binary(bytes, 16));
            System.out.println(new String(bytes, charset));

    结果:

    4920616d203f3f
    I am ??

       可以看出,7个char字符应该ISO-8859-1变为7个byte数组,ISO-8859-1是单字节编码,中文"君山"被转换成是 3f 的byte,也就是"?",所以经常出现中文变成?。中文字符经过该编码会丢失信息,通常称之为"黑洞",它会把不认识的字符吸收掉。

    2.GB2312编码

            String str = "I am 君山";
            String charset = "GB2312";
            byte[] bytes = str.getBytes(charset);
            System.out.println(binary(bytes, 16));
            System.out.println(new String(bytes, charset));

    结果:

    4920616d20befdc9bd
    I am 君山

       可以看出,前五个字符经过编码后仍然是五个字节,汉字被编码成双字节,GB2312只支持6763个汉字,所以并不是所有汉字都能够用GB2312编码。

    3.GBK编码

    结果:

    4920616d20befdc9bd
    I am 君山

      编码的结果与GB2312一样。GBK兼容GB2312,它们的编码算法一样,不同的是码表长度不同,GBK支持的汉字更多。所以只要是GB2312编码的汉字都可以用GBK进行解码,反之则不然。

    4.UTF-8编码

    结果:

    4920616d20e5909be5b1b1
    I am 君山

       UTF-8对单字节范围内的字符仍然采用单字节,汉字采用3个字节。这也是U8采用0-6字节变长编码的优点。

    5.UTF-16

    结果如下:(feff表示大头存储)

    feff004900200061006d0020541b5c71
    I am 君山

       UTF-16编码将char数组放大了一倍,单字节范围内的字符在高位补0变长两个字节,中文字符也变为两个字节。特点是编码效率高,规则很简单,缺点就是对单字节扩大为双字节浪费存储。

    补充:几种编码格式的比较 

      GB2312与GBK编码规则类似,但是GBK范围更大,它能处理所有汉字字符。GB2312编码的GBK都可以解码,反之不然。

      UTF-16与UTF-8都是处理Unicode编码,它们的编码规则不太相同。相对来说,UTF-16的编码效率更高,从字符到字节的相互转换更简单,进行字符串操作也更好。它适合在本地磁盘和内存之间使用,可以进行字符和字节之间的快速转换,如Java的内存管理就使用UTF-16编码。但是它不适合在网络之间传输,因为网络传输容易损坏字节流,一旦字节流损坏将很难修复,所以相比较而言UTF-8更适合网络传输。UTF-8对ASCII码采用单字节存储,另外单个字符损坏也不会影响后面的字符,在编码效率上介于GBK和UTF-16之间,所以U8在编码效率和编码安全性上做了平衡,是理想的编码方式。

    4.JavaWeb设计编码的地方

      从使用中文的角度来说, 有IO的地方就会设计编码,大部分的IO是网络IO,数据经过网络传输时是以字节为单位,所以所有的数据都被序列化为字节。在Java中,要被序列化必须实现Serializable接口。

    这里考虑两个问题:

    1.一段文本的实际大小如何计算?

      一个简单的例子就是如何压缩Cookie的大小,减少网络传输量。所谓的压缩只是将多个单字节字符变成一个多字节字符,较少的是String.length(),而并没有减少最终的字节数。例如将 ab 两个字符通过某种编码变成一个奇怪的字符,虽然字符数从两个变成一个,但是如果采用UTF-8编码,这个奇怪的字符最后经过编码可能会变成3个或者更多的字节。同样的,如果整型数字 1234567 作为字符串处理,则采用UTF-8编码占用7个字节;采用UTF-16将占用14个字节,但是作为int类型的数字来存储时则只需要4个字节。所以看一段文本的大小,只看字符本身的长度是没有意义的,即使一样的字符,采用不同的编码最终的存储结果也不同,所以从字符到字节一定要看编码类型。

    2.当我们在文本编辑器中输入汉字时它到底是怎么表示的?

      我们知道,在计算机里所有的信息都是0和1表示的,那么一个汉字到底是多少个0和1呢。例如在"淘宝"的unicode编码是 "feff6dd85b9d", feff表示大头存储方式,所以16进制是 "6dd85b9d"。在java中,每个char占2byte,所以两个汉字在java中用char表示,会占用4个字节。

      把这两个问题搞清楚以后,看一下在javaweb中哪些地方可能会存在编码转换。

      用户从发起一个HTTP请求,需要存在编码的地方是URL、Cookie、Parameter。服务端收到请求后要解析HTTP,其中URI、Cookie、Post参数需要解码,服务端还可以读取数据库中的数据----本地或者网络中其他地方的文本文件,这些数据都存在编码问题。当Servlet处理完所有的请求后,需要再将这些数据编码,通过Socket发送到用户请求的浏览器里,再通过浏览器解码成为文本。过程如下:

     2.1URL的编码解码

      用户提交一个URL,可能存在中文,因此需要编码。URL的组成部分如下:

     以tomcat作为Servlet Engine为例,把它们分别对应到下面这些配置文件中。

      Port对应在<Connector port="8080">中配置,ContextPath在<Context docBase="MyWeb" path="/examples"/>配置,ServletPath在Web应用的web.xml的<url-pattern>配置,PathInfo是我们请求的具体的Servlet,QueryString是要传递的参数。注意这里是在浏览器直接输入URL,所以是Get方式提交,如果是POST方法请求,QueryString将通过表单方式提交到服务器端。

      

      我们验证浏览器有中文的时候如何编码和解码,如下地址:

      可以看到"君"的编码后为"%e5%90%9b","山"为"%e5%b1%b1"。可以看到PathInfo的编码和QueryString的编码都是UTF-8。至于为什么会有%,URL的编码规范RFC3986可知,浏览器编码URL是将非ASCII码按照某种格式编码成16进制数字后将每个16进制表示的字节前加"%"。

    1.解释tomcat如何解码URI和QueryString

      对URL的URI部分、QueryString 进行解码的字符集是在connector的<Connector URIEncoding="UTF-8"/>中定义的,如果没有定义,将以默认的编码集,所以有中文URI时最好指定为UTF-8编码。 

    这里解释一下解决get乱码以及解决办法:

    (1)如果connector的URIEncoding是UTF-8编码,对URI和QueryString会采用UTF-8进行一次解码,如果前端的URI和QueryString是U8编码,会解码正确;

    (2)一般URI没人写中文,所以一般乱码是QueryString乱码;如果server.xml不是U8编码会默认以ISO-8859-1解码,而前台编码用的是U8,所以会导致解码失败。解决办法就是前台用JS进行encodeURI(encodeURI(param))进行编码。关于两次encodeURI的原因如下:

      前台一次encodeURI有可能导致后台解码乱码,比如前台用U8进行encodeURI,后台server.xml用ISO-8859-1解码就会乱码。

      前台两次编码,后台代码中主动进行一次解码不会出错。例如:

      汉字"中",一次encodeURI之后变为 "%e4%b8%ad", 两次encodeURI之后变为"%25e4%25b8%25ad" ,加%25是因为"%"编码后变为"%25"。后台接受到之后用server.xml指定的编码集解码为: "%e4%b8%ad",然后代码中主动解码一次即可:

    URLDecoder.decode("%E4%B8%AD", "utf-8")

    2. HTTPHeader的编解码

      当客户端发起一个HTTP请求时,除上面的URL外,还可能在Header中传递其他参数,比如cookie、redirectPath。对header的解码是在调用request.hetHeader时进行的,只能使用ISO-8859-1,不能指定其他编码,所以如果Header中有非ASCII码字符,解码肯定会乱码。

      所以Header中传递中文只能编码再添加到Header中。

    3.POST表单的编解码

      POST表单提交的参数的解码是在第一次调用request.getParameter时发生的,POST表单的参数传递 方式与QueryString不同,它是通过HTTP的BODY传递到服务端的。当我们提交时,浏览器首先将根据ContentType的Charset编码格式对在表单中填入的参数进行编码,然后提交到服务端,在服务端同样也是用ContentType的Charset编码格式进行解码。所以通过POST提交一般不会乱码,可以通过request.setCharacterEncoding进行设置。

      需要注意:一定要在第一次调用request.getParameter方法之前就设置request.setCharacterEncoding(charset),否则提交上来的数据也可能出现乱码。

      另外,针对multipart/form-data类型的参数,也就是上传文件的编码,同样也使用ContentType定义的字符集编码。需要注意的是,上传文件是用字节流的方式传输到服务器的本地临时目录,这个过程并没有涉及字符编码,而真正编码是在将文件内容添加到parameters中时,如果用这个不能编码,会使用ISO-8859-1。

    4.HTTP Body的编解码

      当用户请求的资源成功后,这些内容将通过Response返回客户端浏览器。z合格过程要先经过编码,再到浏览器进行解码。编码字符集可以通过response.setCharacterEncoding来设置,它将会覆盖request.getCharacterEncoding的值,并且通过Header的Content-Type返回客户端,浏览器收到返回的Socket流时将通过Content-Type的charset进行解码。如果返回的Content-Type没有设置charset,那么浏览器将根据<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />中的charset进行解码,如果也没指定会使用浏览器默认的编码来解码。

    5.JS需要编码的地方

      JS操作页面元素的情况越来越多,尤其是JS发起异步请求的情况遇到的编码问题更会经常出现。

     1.外部引入JS文件

      在一个单独的JS文件中包含字符串输入的情况,如:

    <!DOCTYPE html>
    <html>
    
        <head>
            <title></title>
            <script src="js/test.js"></script>
        </head>
        <body>
                        
        </body>
    
    </html>

    test.js代码如下:

    document.write("测试汉字");

    效果如下:

      这时如果script标签没有设置charset,浏览器会以当前页面默认的编码解析这个JS,由于上面JS没有指定页面的编码,也没有指定script的编码集,所以编码错误,可以指定页面编码,也可以指定script编码,如下:

    <script src="js/test.js" charset="UTF-8"></script>

    或者指定页面编码集:

    <meta charset="utf-8" />

    2.JS的URL编码

    JS处理编码有三个函数,escape、encodeURI和encodeURIComponent。

    关于这三个函数的区别以及用法参考之前的文章:https://www.cnblogs.com/qlqwjy/p/9934706.html

    3.其他需要编码的地方

      除了URL和 参数编码问题,在服务端还有很多地方需要编码,如:XML、Velocity、JSP或者从数据库读取数据等。

  • 相关阅读:
    ExtJS实战(3)spring
    在 Lotus Domino 7 应用程序中利用 IBM DB2 集成特性
    ExtJS实战(2)hibernate
    ExtJS实战(4)struts
    春节后找工作被面试的经历,好岗位分享给还在找工作中的软件开发爱好者们【转】
    hack专讲
    春节后面试别人的经历总结之二,好岗位分享给还在找工作中的软件开发爱好者们【转】
    asp 去除HTML格式
    创业型公司或发展中公司请不要随意给员工开空头支票~~!!!
    可展开滚动的QQ客服代码【蓝底客服中心】
  • 原文地址:https://www.cnblogs.com/qlqwjy/p/12581057.html
Copyright © 2011-2022 走看看