Mythsman


乐极生悲,苦尽甘来。


字符集与字符编码的强化理解与操作实践

踩坑

最近在工作中遇到了一个说大不大说小不小的问题,就是当我解析一个xml文件的时候,抛出了一个"Invalid byte 2 of 2-byte UTF-8 sequence"的异常,这个异常会导致解析直接退出,显然不能容忍。查阅相关资料稍微定位了一下,大概知道是字符集的问题,仔细一看,xml文件中的确有中文字符,而且当我把这些中文字符删了之后的确又能解析成功。不过我还是不能理解这当中的缘由,不过由于时间原因,当时只是把中文字符删了就草草完工。现在回头想想这个坑还是不能留,顺便趁机补下字符集相关的知识。

字符集和字符编码

字符集

字符集的概念是一个非常容易让人混淆的概念,很多情况下我们都会把他跟字符编码当成是同一个概念,但是事实上这两个概念其实是完全不一样的。
所谓字符集,其实是对所有字符映射到唯一ID的一个映射表,或者叫hash表,比如我就可以定义一个字符集,这个字符集里只有四个字符----"我”,"是","帅","哥"。那么我就可以把这四个分别映射为0,1,2,3,二者一一对应:

我-0
是-1
帅-2
哥-3

字符编码

但是字符集只是规定了字符与数字之间映射关系,并没有规定如何在二进制文件中进行表示(编码)。我可以定义很多中字符编码方法,比如我可以认为所有的字符都占两个bit位,这样当读取文件流的时候,我就可以两个bit两个bit的去读,并按照下面的规则进行解析:

00-我
01-是
10-帅
11-哥

看上去没问题,但是有人可能会说,这种编码不好,为啥呢,因为这样子每个字符都占用了2个bit,可能在某些情况下"我"这个字符出现的次数非常多,其他的字符出现的非常少,那么使用上面的编码方法可能就会浪费空间。我们可以用类似Huffman编码的策略修改一下编码方法:

0-我
10-是
110-帅
111-哥

这其实就是构造了一个二叉树,每一个内部节点就是0或1,每一个叶子节点就是一个字符。当我们解析的时候就顺着这棵树去找相应的字符就行了。
这种编码能保证当“我”出现次数很多的时候,文件的大小能够变小。当然我们需要注意每个字符的编码都不能是其他字符的前缀,否则就会出现解析混乱。

其实所谓字符集和字符编码的关系就是这么简单。只是由于历史原因导致当前的字符集和字符编码比较杂乱,没有绝对的统一,因此才会出现各种"乱码"现象。

Unicode字符集与UTF-8编码

为什么要单独拿Unicode字符集跟UTF-8编码来说是呢,一方便是因为这两个东西被用的最广,尤其是Java语言的原生支持;另一方面正是因为用到广,因此这两个东西被人误解的最多。
我在一开始了解这两个东西的时候也很蒙,有的文章说Unicode是一种编码,有的文章说Unicode不是编码而是字符集,有的文章说UTF-8是一种Unicode编码,有的文章说UTF-8不是Unicode编码。。。现在回想起来,其实他们说的都对,又都不全对。

Unicode是一种字符集

没错,Unicode当然是一种字符集,他又被称为"万国码",能够表示很多很多的字符,具体的个数还在持续增加,目前根据WIKI上的说法,截至2017年6月已经增加到了13万个字符了。
所谓字符集,当然是想要多少有多少了,因此没有“Unicode能表示的最多字符数”这个概念。当需要增加新字符的时候,大不了把表格增加几行,然后对外发布个声明罢了。

Unicode有一个默认的编码叫UCS-2

这个概念是非常坑的,正式因为Unicode有一个默认的编码UCS-2(Universal Character Set),因此才导致了概念的混乱。我们可以在很多地方看见所谓“Unicode编码”这个概念,其实他们说的不是Unicode字符集,而是UCS-2编码。这种编码方式就像我之前举的第一个例子类似,是一种定长的编码方式,每一个字符都用两个字节来表示。这就导致了他最多只能表示2^16个字符。因此很多地方提到说"Unicode编码最多能表示65536个字符",其实指的是UCS-2编码。
显然,这种编码方式并不具备较好的扩展性。我们前面提到Unicode已经有13万个字符了,显然UCS-2编码搞不定了。因此当前很多系统都不会默认用UCS-2编码,而是用扩展性更好的UTF-8编码,不过在windows中还是经常会用到Unicode(UCS-2)编码。

UTF-8是Unicode字符集上的编码

其实UTF-8跟UCS-2一样,都是Unicode字符集上的编码,不过UTF-8使用的方式更像我上面举的第二个例子。采用UTF-8编码的字符有可能占用1个字节,比如ACSII码,也可能占用2-3字节,比如中文,也有可能占用4个以上字节,比如中日韩的一些超大字符集里的文字。正是由于UTF-8采用的变长编码,因此他能够更有扩展性,被用的也最广。
具体的编码方式这里就不多说了,网上资料有很多。

Java的字符支持

支持方式

既然知道了字符集的相关知识,就有必要了解一下在具体的编程工作中的注意点了。我们知道Java是原生支持Unicode的,他默认采用的就是UTF-8编码来处理文件以及存储字节码。一个最具体的表现就是,在java中,我们可以将一个中文赋值给一个char,而在C中,这样的操作是会报warning,并且中文会乱码的。
我们知道Java有个InputStreamReader,他的作用就是将从文件读取的字节流转化为字符流。他读取InputStream中的字节流,并且对他进行字符解码。我们可以通过在这里指定编码方式从而对编码流程进行控制。比如这样:

InputStream inputStream = new FileInputStream("/home/myths/examples.desktop");
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "UTF-8");

通过InputStreamReader,所有的字节都被转化成了Unicode字符集保存在了内存里。
在默认情况下,Java采用的就是UTF-8的编码解码方式,当我们需要用指定的解码方式去解析文件的时候,我们就可以在这里进行指定。

Java支持的字符集

我们在指定字符集的时候需要注意,这些字符集一定要被Java支持,否则就会抛出UnsupportedEncodingException”。事实上对与这些字符集的名字,Java能够做到不区分大小写,忽略横线之类的辅助字符,不过我们最好还是写成标准形式。他的标准形式可以如下获取:

Charset.availableCharsets().keySet().forEach(System.out::println);

编码方式预测

很多情况下,当我们拿到一堆乱码的文件时,我们非常想知道这玩意的编码方式到底是啥。。。其实原则上来说,这种事情目前是无法精确办到的。一个最极端的例子就是纯ASCII码文件,绝大多数的编码方式都支持ASCII码单字节保存,那么给你一个纯ASCII码文件,你可以说他是ASCII码编码,也可以说他是UTF-8编码。不过windows的团队耍了一个小聪明,当我们用他的记事本去保存文件的时候,他会在文件开头加上三个字节的标记,告诉windows说这是啥编码方式。这三个字节就叫万恶的BOM。在windows下BOM这个东西还是很管用的,可是到了其他环境下,就会发现多出三个空白字符,很多命令都会解析失败,这就非常讨厌了。。。
事实上,虽然没有一个绝对准确的编码方式的预测方法,但是还是会有一些统计规律的,有了这些规律,我们就有了一些工具。

file命令
file命令是Linux自带的文件信息查看工具,我们可以用这个命令来简单查看文件的编码方式:

myths@pc:~$ file -bi test.txt
text/plain; charset=utf-8

uchardet
uchardet是一个开源的工具,据说比file的更准。

myths@pc:~$ uchardet test.txt 
UTF-8

chardet
chardet是一个python小脚本,调用的是python的函数,准确性也不错,而且还提供置信度供我们参考。

myths@pc:~$ chardet test.txt 
test.txt: utf-8 (confidence: 0.94)

说白了这些方法基本都是用概率统计的方法估计字符集的,所以非ascii的字符越多,判断的就会越准。

编码转换工具

有时候我们可能希望将文件的编码方式进行转换。需要注意的是,所谓的转换文件的编码,其实包括下面几个步骤:

  1. 读取二进制流,
  2. 按照旧的编码规则进行解码成统一的字符集
  3. 根据字符集,按照新的编码规则进行编码成新的二进制流
  4. 将二进制流写入文件

因此在进行编码格式转化的时候实际上就修改了文件本身,这一点需要注意。
转换编码最简单的方法其实可以通过iconv这个命令来进行处理:

myths@pc:~$ iconv -f UTF-8 -t GBK sourcefile > outputfile

通过-f指定旧的解码方式,通过-t指定新的编码方式,并将结果输出到新的文件中。

综合实践

下面做一个小实验。我们现在有如下的乱码数据,问这些数据是用什么编码的,他的正确编码方式应该是什么。
由于乱码的字符复制粘贴会影响二进制表示,因此我们通过指定二进制的方式来生成测试文件。

echo -e "\xb5\xf3\xbc\xd2\xba\xc3\xa3\xac\xce\xd2\xca\xc7\xcb\xa7\xb8\xe7\xa3\xac\xbb\xb6\xd3\xad\xb4\xf3\xbc\xd2\xba\xcd\xce\xd2\xd7\xf6\xc5\xf3\xd3\xd1\xa1\xa3"> guess

那么这个guess里装的到底是啥呢?
点击查看答案和解析

答案与解析如果你电脑的默认字符集是GBK,那么或许你已经看到了答案了。 如果你电脑的默认字符集是UTF-8之类的,那你大概就要稍微折腾一番了。 1. 通过`file -bi guess`命令来猜测文件的字符集,发现是ISO-8859 2. 尝试iconv命令,发现并不能正常解码,放弃。 3. 通过`uchardet guess`命令来猜测文件的字符集,可以看到字符集是gb18030 4. 通过`iconv -f GB18030 -t UTF-8 guess `命令可以将字符集从GBK转换为UTF-8 5. 答案:“大家好,我是帅哥,欢迎大家和我做朋友。”

参考资料

linux-check-change-file-encoding
JAVA字符串与字符编码处理的终极解决
字符编码笔记:ASCII,Unicode和UTF-8
uchardet