一次乱码引发的思考

前言何为编码ASCIIISO8859-1GBKUnicodeUTF-8ANSIJava中编码规则java.util.Properties类来读取properties文件文件存储properties类对文件读取

前言

昨天做一个从properties文件读取短信内容,然后到程序中动态替换变量值,发送短信这么一个功能。本地测试完毕,发现没有任何问题,于是乎,就非常欣喜的提交了代码,提测。但是提交到Linux服务器上后,发送出去的短信一直乱码的状态,日志打印出的短信内容也是乱码状态,怎么办?先找解决方法,于是乎,找到了两种种解决方式:

  • 直接将短信内容改成unicode编码串放至properties文件
#注册成功短信 
sms.register.content=\u5C0A\u6599\u5DF2\u63D0\u4EA4{0}\uFF0C\u8BF7\u52FF\u6CC4\u9732\uFF0C\u4E3A\u4FDD
  • 在载入properties文件的地方指定UTF-8编码
<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
        <property name="order" value="0" />
        <property name="ignoreUnresolvablePlaceholders" value="true" />
        <property name="locations">
            <list>
                <value>classpath:application.properties</value>
            </list>
        </property>
        <!-- 指定UTF-8编码-->
        <property name="fileEncoding" value="UTF-8"/>
    </bean>

按照上述两种方法之一的果然解决了短信内容乱码的问题,但却引起我对编码这个一直模模糊糊的领域的思考。

  • unicode编码为什么就可以正常读取出来中文字符呢?

  • 为什么本地编辑器直接写中文可以正常读取?

于是乎,趁此机会,好好恶补梳理下这关于编码这块一知半解的盲区点。

何为编码

计算中只有0和1这两种状态,美国人根据8位2进制位组成一个字节来表示不同的字符和控制动作。所以刚开始出现了ASCII编码,后来世界上其他国家和地区也开始使用计算机,由一个字节来表示一个字符,总共也就256种可能,同时前128位字节都被美国人编码占了,一个字节不够,那就两个字节呗。于是各个国家针对自己的文化符号推出了自己的编码标准。

. ASCII

最开始计算机是美国使用的,英文中只有26个字母,再加上一些控制字符和特殊的转义字符全部都编码进去,一直到127号。刚好是8位字节用了后7位,所以由美国提出的最高位为0、包含了128位字符的编码标准称之为ASCII(American Standard Code for Information Interchange,美国信息互换标准代码)编码,这是很重要的一个编码标准,后面的编码基本都基于该编码扩展,所以基本上都兼容该编码标准。

比如大写字母A,在ASCII编码中是第65号元素,因此在计算机中存储为0100 0001

二进制          十进制     十六进制          符号
0100 0001       65          41             A

. ISO8859-1

ISO859-1是单字节编码的字符集,同时也是存储和传输的编码方式。美国人把一个字节的前127号字节位都编码进去了,这个时候欧洲人开始使用计算机了,他们把第8位也利用起来,但还是单字节编码同时包含了ASCII中的128位字符,同时扩展利用第8位(160-255之间为文字符号,128-159为控制字符),支持欧洲的大部分国家的编码字符。

. GBK

当中国人开始使用计算机时,已经没有可利用的字节状态来表示汉字,况且一个字节最多能表示256个字符,而常用汉字则达3000多个。于是我国便制定了一个GB2312的编码标准:

一个小于127的字符的意义与原来相同,但两个大于127的字符连在一起时,就表示一个汉字,前面的一个字节(他称之为高字节)从0xA1用到 0xF7,后面一个字节(低字节)从0xA1到0xFE,这样我们就可以组合出大约7000多个简体汉字了。在这些编码里,我们还把数学符号、罗马希腊的字母、日文的假名们都编进去了,连在 ASCII 里本来就有的数字、标点、字母都统统重新编了两个字节长的编码,这就是常说的”全角”字符,而原来在127号以下的那些就叫”半角”字符了。

后来发现好多古文、罕见字没有编码进去,于是又有了GBK标准。
只要第一个字节是大于127就固定表示这是一个汉字的开始,不管后面跟的是不是扩展字符集里的内容。结果扩展之后的编码方案被称为 GBK 标准,GBK 包括了 GB2312 的所有内容,同时又增加了近20000个新的汉字(包括繁体字)和符号。

后来少数民族也要用电脑了,于是我们再扩展,又加了几千个新的少数民族的字,GBK 扩成了 GB18030。从此之后,中华民族的文化就可以在计算机时代中传承了。

. Unicode

世界上每个国家都有自己的编码标准,显然很不利于国家间的信息传递。这时ISO(国际标准化组织)解决了这个问题。
废了所有的地区性编码方案,重新搞一个包括了地球上所有文化、所有字母和符号的编码!他们打算叫它"Universal Multiple-Octet Coded Character Set",简称 UCS, 俗称 "UNICODE"。

UNICODE 开始制订时,计算机的存储器容量极大地发展了,空间再也不成为问题了。于是 ISO 就直接规定必须用两个字节,也就是16位来统一表示所有的字符,对于ascii里的那些“半角”字符,UNICODE 包持其原编码不变,只是将其长度由原来的8位扩展为16位,而其他文化和语言的字符则全部重新统一编码。由于"半角"英文符号只需要用到低8位,所以其高8位永远是0,因此这种大气的方案在保存英文文本时会多浪费一倍的空间。

目前采用的是UCS-2标准,即2个字节来表示一个字符。Unicode只是一个编码字符集,它只规定了字符的对应的编码值,而并没有规定如何存储和传输。

. UTF-8

采用Unicode编码时,如果存储的是中文时,一个字符两个字节来表示,好像没啥大问题。但如果是英文时,这个时候它的高位永远都是0,只有低位是ACCII的值,则造成了一个空间的巨大浪费和带宽支出。于是就提出了一种针对Unicode编码的存储和传输方式-UTF-8 。

UTF-8是一种变长的Unicode编码的实现方式。它通过1-4个字节(最长是6个字节)的长度来对Unicode码表中的字符通过某种规则来重新编码,便于存储和传输。


  • 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的unicode码。因此对于英语字母,UTF-8编码和ASCII码是相同的。

  • 对于n字节的符号(n>1),第一个字节的前n位都设为1,第n+1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的unicode码。

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

为什么UTF-8编码一个字符最长是6个字节呢?
如果是6个字节,则其二进制为:
111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx
其中的X替代的是unicode编码集中的有效位数,刚好是32位,也就是unicode中的最长4个字节编码。

. ANSI

ANSI编码是windows的默认编码,对于英文文件是ASCII编码,对于简体中文文件是GBK编码(只针对Windows简体中文版,如果是繁体中文版会采用Big5码)。

总结完上面的编码理论知识后,来看下前面的主题中的两个疑惑点。

Java中编码规则

. java.util.Properties类来读取properties文件

spring中的PropertyPlaceholderConfigurer类来读取文件,其实就是java.util.Properties类来读取文件。Properties类中默认编码规则为ISO8859-1。

 try{
           File file = new File("E:\\aaa.txt");
           Properties propertiesUtil = new Properties();
           propertiesUtil.load(new FileInputStream(file));
           //propertiesUtil.load(new InputStreamReader(new FileInputStream(file),"utf-8"));
           System.out.println(propertiesUtil.get("key1"));
           System.out.println(propertiesUtil.get("key2"));
           System.out.println(((char)21517));
       }catch (Exception e){
          e.printStackTrace();
       }

aaa.txt文件内容如下:

key1=\u540C\u6B65\u5E26\u5A01\u5BCC\u901A
key2=我是一个

上述代码执行的结果是:

同步带威富通
我是一个

也就是说key2的取值是乱码的,而文件中写入汉字的Unicode编码串是正常的。但如果是启用注释行的代码则都是可以正常读取的。下面来分析其中的读取过程。

文件存储

先看下aaa.txt在Windows存储的二进制码。

aaa.txt文本文件


 

可以看到实际存储中,对应的Unicode编码串就是当成普通的英文字符处理,\对应的ACSII编码就是5C,u对应的ASCII编码就是75。
而对于中文“我是一个”的处理则不然,“我”的Unicode编码为\u6211。
6211对应的二进制编码为:0110 0010 0001 0001
根据Unicode编码与UTF-8编码对应的规则,“我”这个中文字符应该是以三个字节来进行UTF-8编码的。

1110xxxx 10xxxxxx 10xxxxxx

将6211对应的二进制编码填充进去则得到:11100110 10001000 10010001 ,转成16进制则为E6 88 91,可以看到与通过编辑器工具查看的编码是一样的。所以当中文以UTF-8编码存储时,首先是先将中文字符转成对应的Unicode编码,然后将该编码转成UTF-8表示的三个字节的二进制编码存储在文本文件中。

properties类对文件读取

/**
** 以字节输入流作为构造函数的参数
*/

 public synchronized void load(InputStream inStream) throws IOException {
        load0(new LineReader(inStream));
    }
private String loadConvert (char[] inint off, int len, char[] convtBuf{
        if (convtBuf.length < len) {
            int newLen = len * 2;
            if (newLen < 0) {
                newLen = Integer.MAX_VALUE;
            }
            convtBuf = new char[newLen];
        }
        char aChar;
        char[] out = convtBuf;
        int outLen = 0;
        int end = off + len;

        while (off < end) {
            aChar = in[off++];
            if (aChar == '\\') {
                aChar = in[off++];
                if(aChar == 'u') {
                    // Read the xxxx
                    int value=0;
                    for (int i=0; i<4; i++) {
                        aChar = in[off++];
                        switch (aChar) {
                          case '0'case '1'case '2'case '3'case '4':
                          case '5'case '6'case '7'case '8'case '9':
                             value = (value << 4) + aChar - '0';
                             break;
                          case 'a'case 'b'case 'c':
                          case 'd'case 'e'case 'f':
                             value = (value << 4) + 10 + aChar - 'a';
                             break;
                          case 'A'case 'B'case 'C':
                          case 'D'case 'E'case 'F':
                             value = (value << 4) + 10 + aChar - 'A';
                             break;
                          default:
                              throw new IllegalArgumentException(
                                           "Malformed \\uxxxx encoding.");
                        }
                     }
                    out[outLen++] = (char)value;
                } else {
                    if (aChar == 't') aChar = '\t';
                    else if (aChar == 'r') aChar = '\r';
                    else if (aChar == 'n') aChar = '\n';
                    else if (aChar == 'f') aChar = '\f';
                    out[outLen++] = aChar;
                }
            } else {
                out[outLen++] = aChar;
            }
        }
        return new String (out0, outLen);
    }

loadConvert()方法中当读取到\u开头的字符时,就当成是unicode编码来处理,直到读取到该编码结束。将Unicode编码得到的十进制进行强转成字符则得到该中文字符。

out[outLen++] = (char)value;

这也就解释了为什么properties文件中,写的是Unicode编码的16进制串可以正常翻译成中文字符的原因。


同理,来看下直接写中文的方式是如何读取的呢?
上面已分析得出“我”字符的实际上是三个字节编码存储的。那么字节流中就是E6 88 91 ,对应的十进制就是230 136 145,而Properties类默认编码为ISO8859-1,所以直接将230翻译成了æ 字符,而ISO8859-1编码实际上从160-255之间才有实际的实体字符与之对应。所以Properties类自动填充\u,上述后俩字节于是就变成了

\u0088 \u0091 

最终翻译出来就是ˆ‘这俩unicode编码表中的字符。最后“我”这个中文字符于是读取出来就是我 ,这也就是我们经常看到的中文乱码。
而如果是采用指定编码格式的字符流进行处理的话则不会发生乱码的现象。

propertiesUtil.load(new InputStreamReader(new FileInputStream(file),"utf-8"));

InputStreamReader是将字节流转成字符流,所以会将该字节流以指定编码去读,因此也能读出正常的中文字符。

posted @ 2018-07-09 17:25  骑着单车的程序猿  阅读(1581)  评论(0)    收藏  举报