碰到一个头疼的问题,让我搞了很久.....

第一时间获取干货文章,关注微信公众号:不会说话的刘同学

最近新来的同事在做项目的时候碰到一个很头疼的问题,在做的过程中还好,一到调试就出现了乱码问题,让他很抓狂,跑过来问我,我想这种问题怎么能难倒我呢,于是我耐心的给他讲解了一番

完事后我想何不出一篇文章呢,于是就....

为啥需要编码

计算机无法识别我们人类语言,为了能够让计算机识别我们人类语言就采用了某种符号来表示,但是我们人类的语言太多了,表示这些语言的符号也很多,且不能完全用一个字节来表示,所以就必须进行拆分或一些翻译的工作才能让计算机识别我们人类的语言,而这种拆分或翻译的过程我们就称为编码

我们都知道,计算机的 1byte = 8bit ,其可以表示的符号是0~255个,如果用这些符号来表示英文字符是完全足够了,但是因为人类的语言太多了,无法完全用一个byte来表示,比如中文,因此就有了一种新的数据结构char,从char到byte必须编码

上面我们讲了让计算机识别我们人类的语言,通过某种符号来表示,但对于这些符号计算机又是如何来翻译成我们人类的语言的呢

计算机提供了多种翻译方式,比如ASCII、ISO-8859-1、GB2313、GBK、UTF-8等,这些我们统称为编码格式,可以把它们看作是字典,它们规定了转化规则,按照这个规则就可以让计算机正确的表示我们的字符
编码格式有很多种,就目前来看一共有七种主要得编码格式,它们之间的编码效率以及编码结构都各有不同

七种编码格式

ASCII码

ASCII码一共有128个,用1个字节的低7位表示,0 ~ 31是控制字符如换行、删除等,32 ~ 126是打印字符,可以通过键盘输入并且能够显示出来,下图是ASCII码表

ascii.jpeg

ISO-8859-1

128个字符很显然是不够的,于是ISO组织就在ASCII码基础上对其进行了扩展,它们是ISO-8859-1至ISO-8859-15,其中ISO-8859-1涵盖了大多数的西欧语言字符,也是用单字节表示,它总共能表示256个字符
对于ISO-8859-1的对照码表可以参考这个网址: https://www.haomeili.net/Code/ASCII?key=iso-8859-1

GB2312

GB23121的全称是《信息技术中文编码字符集》,它是双字节编码,总的编码范围是A1F7,其中A1A9是符号区,总共包含682个符号;B0~F7是汉字区,包含6763个汉字

对于GB2312的对照码表可以参考这个网址:https://www.toolhelper.cn/Encoding/GB2312

GBK

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

对于GBK的对照码表可以参考这个网址:https://www.toolhelper.cn/Encoding/GBK

GB18030

GB18030全称是《信息技术中文编码字符集》,它可能是单字节、双字节或者四字节编码,它的编码与GB2312编码兼容,但是这种编码格式在实际应用使用得并不广泛

UTF-16

说到UTF必须提到Unicode(Universal Code 统一码),我们也称为万国码,世界上所有的语言都可以通过这个码表来翻译

对于Unicode的对照码表可以参考这个网址:https://www.cnblogs.com/csguo/p/7401874.html

而UTF-16具体定义了Unicode字符在计算机中的存取方法,UTF-16用两个字节来表示Unicode的转化格式,它采用定长的表示方法,即不论什么字符都可以用两个字节表示,因为两个自己是16位,所以叫UTF-16。UTF-16表示字符非常的方便,每两个字节表示一个字符,这极大的简化了字符串的操作,这也是Java以UTF-16作为内存的字符存储格式的一个原因

UTF-8

UTF-16虽然表示字符非常的方便,但是有些时候本来用一个字节就可以表示的字符,UTF-16也用了了两个字节表示,这无形中将存储空间扩大了一倍,这在网络间传输中也会增大网络传输的流量。

而UTF-8采用了一种变长技术,每个编码区域有不同的字码长度,也就是说不同的字符用不同的字节长度表示,这就减少了存储空间的占用

I/O操作中存在的编码

上面我们讲了编码存在于字节到字符(或字符到字节)之间的转换,那么这就涉及到我们具体的一些I/O操作,其中就包括磁盘I/O和网络I/O

磁盘I/O存在的编码

我们在项目开发中经常会涉及到对于磁盘的I/O操作,Java给我们提供了对于磁盘的I/O操作类InputStream和OutputStream,其子类InputStramReader类就是负责在I/O过程中处理读取字节到字符之间的转换,对于字节到字符的编码实现,它又委托StreamDecoder去做,在StreamReader解码过程中必须由用户指定Charset编码格式,如果没有指定Charset编码格式,那么就会使用系统默认的字符集

这也就是为什么我们在对于本地文件中字符读取的时候会出现乱码的关键原因,所以我们在做I/O的操作过程中一定要指定编码格式

那么对于写也是同样的,从字符到字节通过OutputStreamWriter类来操作,具体的编码由StreamEncoder类负责

我们再来看下操作示例

我当前本地有个abc.txt文件,文件里有一行中文字符
image.png

并且这个文件我设置的编码字符集是GB2312

下面我直接用两种编码方式来读取

public static void main(String[] args) throws IOException {
        //  第一种直接用UTF-8来读取
        StringBuilder stringBuilder = new StringBuilder();
        InputStreamReader inputStreamReader = new InputStreamReader(new FileInputStream("C:\\Users\\Domino\\Desktop\\abc.txt"),"UTF-8");
        int count = 0;
        char[] buf = new char[64];
        while ((count = inputStreamReader.read(buf)) != -1){
            stringBuilder.append(buf,0,count);
        }
        System.out.println("UTF-8字符集 读取字符:"+stringBuilder.toString());

        // 第二种 直接用GB2312来读取
        StringBuilder stringBuilder2 = new StringBuilder();
        InputStreamReader inputStreamReader2 = new InputStreamReader(new FileInputStream("C:\\Users\\Domino\\Desktop\\abc.txt"),"GB2312");
        int count2 = 0;
        char[] buf2 = new char[64];
        while ((count2 = inputStreamReader2.read(buf2)) != -1){
            stringBuilder2.append(buf2,0,count2);
        }
        System.out.println("GB2312字符集 读取字符:"+stringBuilder2.toString());
}

第一种毋庸置疑肯定是会乱码的,因为原字符以GB2312的编码方式,第二种就可以正常展示,下面是运行结果
image.png

同样对于写入操作

public static void main(String[] args) throws IOException {
        OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream("C:\\Users\\Domino\\Desktop\\abc.txt"),"UTF-8");
        writer.write("好的的");
        writer.flush();
}

我往GB2312字符编码格式的文件里写入UTF-8的字符,这里写入操作肯定也是乱码的,我们可以看下结果

image.png

为了弄清楚乱码的原因,我们再具体来看下不同编码格式的编码结构

用这一样字符串为例,看不同的编码格式是如何进行存储的

 public static void main(String[] args) {
        String str = "I am 刘同学";
        char[] isoHex = HexUtil.encodeHex(str, Charset.forName("ISO-8859-1"));
        System.out.println(isoHex);

        char[] gb2312Hex = HexUtil.encodeHex(str, Charset.forName("GB2312"));
        System.out.println(gb2312Hex);

        char[] gbkHex = HexUtil.encodeHex(str, Charset.forName("GBK"));
        System.out.println(gbkHex);

        char[] utf16Hex = HexUtil.encodeHex(str, Charset.forName("UTF-16"));
        System.out.println(utf16Hex);

        char[] utf8Hex = HexUtil.encodeHex(str, Charset.forName("UTF-8"));
        System.out.println(utf8Hex);
}

这里我将字符串的不同编码字符集以char数组来进行输出,我们再来看看将char数组转换成字节是怎样的

ISO-8859-1编码:
字符集编码结构.jpg
8个char字符经过ISO-8859-1编码转换成7个byte数组,ISO-8859-1是单字节编码,中文“刘同学”就被转换成了3f,3f也就是“?”字符,我们在写代码读取字符的时候经常会有中文变成“?”,也有可能是使用了ISO-8859-1

GB2312编码:
字符集编码结构.png

GB2312对于中文字符的存储是采用双字节,前面的五个字符经过编码分别占用一个字节,后面三个中文字符分别需要占用两个字节

GB2312有一个从char到byte的码表,不同的字符编码就是从这个码表找到与每个字符对应的字符,然后拼接成byte数组

GBK编码:
字符集编码结构.png
GBK与GB2313的编码结构是一样的,它们的编码算法也是一样的,不同的是,它们的码表长度不一样,GBK包含的汉字字符更多,所以只要是经过GB2312编码的汉字都可以用GBK编码,反之不然

UTF-16编码:
字符集编码结构 (1).png
UTF-16对应的每个编码都有两个字节构成,所以字节数组被扩大了1倍,单字节范围内的字节在高位补0变成两个字节,中文字符也变成两个字节,但是这种编码规则简单高效,在编码的时候不用考虑但双字节问题

UTF-8编码:
字符集编码结构 (2).png
UTF-16虽然编码效率很高,但是对单字节范围内的字符也放大了1倍,这无疑是浪费了存储空间,另外UTF-16是采用顺序编码,不能对单个字符的编码值进行校验,如果中间的一个码值损坏,后面的所有码值都将受影响。而UTF-8不存在这些问题,UTF-8对单字节范围内的字符任然采用1个字节表示,对汉字采用3个字节表示

网络I/O存在的编码

这里的网络I/O主要我们在使用网络接收和发送数据的时候,常常会出现乱码问题,那么在整个数据发送或接收的过程中到底哪里会出现编码问题呢

我们以一个用户发送HTTP请求到服务端为例,那么在整个请求到响应的过程中存在编码的地方是请求的URL、Cookie和POST表单参数,在服务端接收到请求的时候需要对其进行解码,服务端接在处理请求的时候可能还需要访问数据库里的数据、本地或网络中其他地方的文本文件,这些数据都可能存在编码问题,整个过程如下

字符集编码结构 (3).png
我们来具体看下一次HTTP请求中各个地方所用到的编码

URL的编解码

用户提交一个URL,在这个URL中可能存在中文(这里暂时先不考虑POST表单参数),因此需要用到编码,我们以一个这样的URL为例

字符集编码结构 (5).png

这里URI和请求参数都出现了中文,因此浏览器会对其进行编码,我们使用Firefox浏览器来看看具体是怎么编码的

image.png
浏览器将URI里的中文编码成了E5 88 98 E5 90 8C E5 AD A6,而请求参数里的中文编码成了E5 BC A0 E4 B8 89,根据编码规则可以判断,URI里的中文编码是UTF-8编码,请求参数里的编码是GBK编码,对于这些编码为什么要用%,这是因为RFC3986规范,它将非ACII字符按照某种编码格式编成16进制之后,会在16进制表示的字节前面加上%

我们再来看下Tomcat(这里是以Tomcat8.0为例)是如何对这个URI部分进行解码的

 protected void convertURI(MessageBytes uri, Request request) throws IOException {
        ByteChunk bc = uri.getByteChunk();
        int length = bc.getLength();
        CharChunk cc = uri.getCharChunk();
        cc.allocate(length, -1);
        //从 server.xml 文件里获取设置的对于URI解码的字符集,默认是UTF-8
        Charset charset = connector.getURICharset();

        B2CConverter conv = request.getURIConverter();
        if (conv == null) {
            // 设置编码字符集
            conv = new B2CConverter(charset, true);
            request.setURIConverter(conv);
        } else {
            conv.recycle();
        }

        try {
             // 对URI进行解析
            conv.convert(bc, cc, true);
            uri.setChars(cc.getBuffer(), cc.getStart(), cc.getLength());
        } catch (IOException ioe) {
            request.getResponse().sendError(HttpServletResponse.SC_BAD_REQUEST);
        }
}

具体解析的地方我用注释标注了

这里之所以是通过Connector对象去获取编码字符集,是因为Tomcat会将server.xml文件里的Connector解析成一个对象

我们在配置Tomcat端口的时候会在server.xml里的 节点里配置,当然这个节点除了配置端口外,还可以配置用于对URI解码的字符集,如果不配置的话默认是直接用UTF-8进行解码,我们如果有要修改的要求话可以直接在节点里添加一个 URIEncoding 属性就行了----

我们再来看下对于URL里的请求参数是怎么解码的,以GET方式HTTP请求的请求参数与以POST请求的请求参数都可以通过request.getPatameter获取参数值。对它们的解码是在request.getPatameter第一次被调用的时候进行的

那何为"第一次被调用的时候进行解码的" 呢,我们都知道一个HTTP请求到服务端对于服务端的,服务端对于参数的获取有可能在多处地方会调用request.getPatameter,那当第一次调用request.getPatameter获取参数的时候,这个参数就已经被解码了,后面其他的调用就不会再一次进行解码。这里解码默认是用UTF-8
那这个请求参数的解码字符集要怎么去设置呢,这里要注意下,如果我们在 server.xml 文件里的 添加了URIEncoding属性的话,那么在给请求参数解码的时候也会用URIEncoding这个参数所指定的字符集

除了使用URIEncoding属性之外,还可以通过在Header中的ContentType定义Charset,如果要使用这种方式的话那就必须在节点里添加 useBodyEncodingForURI="true" ----
ContentType具体定义规则如下:

Content-Type : charset=utf-8

这里要注意下,如果在节点中添加了useBodyEncodingForURI="true" ,而在ContentType不指定具体的编码字符集的话,那么将会默认使用ISO-8859-1

所以我们在对于网络中的数据读取如果不想看到乱码的情况的话,我们可以利用好 URIEncoding 和 useBodyEncodingForURI 这两个参数

HTTP Header 的编解码

当客户端发起一个HTTP请求时,除了上面的URL外还可能会在Header中传递其他参数,如Cookie、redirectPath等,这些用户设置的值也可能存在编码问题,那么Tomcat又是怎么解决的呢

对于Header中的参数获取是通过request.getHeader方法获取的,而对于Header的编解码字符集我们无法进行设置,Tomcat会直接使用ISO-8859-1进行解码,因此我们在Header中最好不要添加非ASCII字符,否则的话肯定是会出现乱码问题

如果非要传递非ASCII字符,我们可以先将这些字符URLEncoder编码再添加到Header中,当要获取字符的时候再通过URLDecoder进行解码,这样就可以很好的解决乱码问题了

POST表单的编解码

POST表单提交的数据解码也是通过第一次调用request.getParameter发生的,与上面的请求参数获取不同的是,它是通过HTTP的BODY传递到服务端。当我们在页面上单击提交按钮时浏览器首先将根据ContentType的Charset编码格式对在表单中填入的参数进行编码,服务器同样也是用ContentType中的字符集进行解码,所以通过POST表单提交的参数只要设置好编码格式一般是不会出现乱码问题,当然我们也可以通过request.setCharacterEncoding来进行设置编码字符集

在上面说过不管是表单提交参数还是通过QueryParam来提交参数,服务端对其解码都是通过第一次调用request.getParameter发生的,这样做虽然会提升效率,但其实也会造成一个问题

我们平时在开发中不知道有没有碰到过这个问题,就是明明已经通过request.setCharacterEncoding设置了正确的编码字符集了,但当去获取参数的时候还是会造成乱码问题,好像失效了样

这个问题到这里其实很好解释了,可能是我们代码中在调用 request.setCharacterEncoding 之前已经第一次调用了 request.getParameter ,所以导致所有的参数都按照默认的字符集解码了,后面再通过 request.setCharacterEncoding 设置编码字符集然后再调用 request.getParameter 获取参数进行时候并不会再一次进行解码,当然就会造成这种失效的现象

在做这种网络请求在使用这种body的方式提交参数的时候最好还是通过ContentType指定编码字符集,为什么这么说呢,因为Tomcat在解析的过程会判断ContentType里有没有Charset参数,如果有的话那么解析参数的时候会根据指定的编码字符集来进行解码,下面是它的源码

private static String getCharsetFromContentType(String contentType) {
        if (contentType == null) {
            return null;
        }
        // 判断ContentType中有没有 charset 参数
        int start = contentType.indexOf("charset=");
        if (start < 0) {
            return null;
        }
        // 如果有的话就直接截取出指定的字符集
        String encoding = contentType.substring(start + 8);
        int end = encoding.indexOf(';');
        if (end >= 0) {
            encoding = encoding.substring(0, end);
        }
        encoding = encoding.trim();
        if ((encoding.length() > 2) && (encoding.startsWith("\""))
            && (encoding.endsWith("\""))) {
            encoding = encoding.substring(1, encoding.length() - 1);
        }
        return encoding.trim();
}

主要的代码逻辑我用注释标明了下

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

HTTP BODY的编解码

当请求响应给用户的时候,响应的内容将通过Response返回给客户端浏览器,这个过程要先经过编码,再到浏览器进行解码,编解码的字符集可以通过respone.setCharacterEncoding来设置,如果没有设置那么浏览器将根据HTML的 中的charset来解码,如果也没定义的话那么浏览器将使用默认的编码来解码

posted @ 2022-10-29 17:09  不会说话的刘同学  阅读(13)  评论(0)    收藏  举报