04-编码方式的坑

字符集只是一个规则集合的名字,对于一个字符集来说要正确编码转码一个字符需要三个关键元素:

  • 字库表(character repertoire):相当于所有可读或者可显示字符的数据库,字库表决定了整个字符集能够展现表示的所有字符的范围
  • 编码字符集(coded character set):用一个编码值code point来表示一个字符在字库中的位置
  • 字符编码(character encoding form):编码字符集和实际存储数值之间的转换关系

统一字库表的目的是为了能够涵盖世界上所有的字符,但实际使用过程中会发现真正用的上的字符相对整个字库表来说比例非常低。

例如,中文地区的程序几乎不会需要日语字符,而一些英语国家甚至简单的ASCII字库表就能满足基本需求。而如果把每个字符都用字库表中的序号来存储的话,每个字符就需要3个字节(这里以Unicode字库为例),这样对于原本用仅占一个字符的ASCII编码的英语地区国家显然是一个额外成本(存储体积是原来的三倍)。

Unicode就是编码字符集,而UTF-8就是字符编码,即Unicode规则字库的一种实现形式。

随着互联网的发展,对同一字库集的要求越来越迫切,Unicode标准也就自然而然的出现。它几乎涵盖了各个国家语言可能出现的符号和文字,并将为他们编号。Unicode的编号从0000开始一直到10FFFF共分为17个Plane,每个Plane中有65536个字符。而UTF-8则只实现了第一个Plane,可见UTF-8虽然是一个当今接受度最广的字符集编码,但是它并没有涵盖整个Unicode的字库,这也造成了它在某些场景下对于特殊字符的处理困难。

UTF-8

UTF-8编码为变长编码。最小编码单位(code unit)为一个字节。一个字节的前1-3个bit为描述性部分,后面为实际序号部分。

  • 如果一个字节的第一位为0,那么代表当前字符为单字节字符,占用一个字节的空间。0之后的所有部分(7个bit)代表在Unicode中的序号
  • 如果一个字节以110开头,那么代表当前字符为双字节字符,占用2个字节的空间。110之后的所有部分(5个bit)加上后一个字节的除10外的部分(6个bit)代表在Unicode中的序号。且第二个字节以10开头
  • 如果一个字节以1110开头,那么代表当前字符为三字节字符,占用3个字节的空间。110之后的所有部分(5个bit)加上后两个字节的除10外的部分(12个bit)代表在Unicode中的序号。且第二、第三个字节以10开头
  • 如果一个字节以10开头,那么代表当前字节为多字节字符的第二个字节。10之后的所有部分(6个bit)和之前的部分一同组成在Unicode中的序号

具体每个字节的特征可见下表,其中x代表序号部分,把各个字节中的所有x部分拼接在一起就组成了在Unicode字库中的序号。

Byte 1Byte 2Byte3
0xxx xxxx
110x xxxx10xx xxxx
1110 xxxx10xx xxxx10xx xxxx

例子

实际字符在Unicode字库序号的十六进制在Unicode字库序号的二进制UTF-8编码后的二进制UTF-8编码后的十六进制
$0024010 01000010 010024
¢00A2000 1010 00101100 0010 1010 0010C2 A2
20AC0010 0000 1010 11001110 0010 1000 0010 1010 1100E2 82 AC
  • 3个字节的UTF-8十六进制编码一定是以E开头的
  • 2个字节的UTF-8十六进制编码一定是以CD开头的
  • 1个字节的UTF-8十六进制编码一定是以比8小的数字开头的

乱码

乱码的出现是因为:编码和解码时用了不同或者不兼容的字符集。一个用UTF-8编码后的字符,用GBK去解码。由于两个字符集的字库表不一样,同一个汉字在两个字符表的位置也不同,最终就会出现乱码。

要从乱码字符中反解出原来的正确文字需要对各个字符集编码规则有较为深刻的掌握。但是原理很简单,这里用最常见的UTF-8被错误用GBK展示时的乱码为例,来说明具体反解和识别过程。

借助MySQL来操作。

编码

mysql> select hex(convert('你好' using utf8));
+-----------------------------------+
| hex(convert('你好' using utf8))   |
+-----------------------------------+
| E4BDA0E5A5BD                      |
+-----------------------------------+
1 row in set (0.00 sec)

mysql> select hex(convert('你' using utf8));
+--------------------------------+
| hex(convert('你' using utf8))  |
+--------------------------------+
| E4BDA0                         |
+--------------------------------+
1 row in set (0.00 sec)

mysql> select hex(convert('好' using utf8));
+--------------------------------+
| hex(convert('好' using utf8))  |
+--------------------------------+
| E5A5BD                         |
+--------------------------------+
1 row in set (0.00 sec)

中文,“你”的UTF-8编码为E4BDA0,“好”的UTF-8编码为E5A5BD

mysql> select hex(convert('你好' using gbk));
+----------------------------------+
| hex(convert('你好' using gbk))   |
+----------------------------------+
| C4E3BAC3                         |
+----------------------------------+
1 row in set (0.00 sec)

mysql> select hex(convert('你' using gbk));
+-------------------------------+
| hex(convert('你' using gbk))  |
+-------------------------------+
| C4E3                          |
+-------------------------------+
1 row in set (0.00 sec)

mysql> select hex(convert('好' using gbk));
+-------------------------------+
| hex(convert('好' using gbk))  |
+-------------------------------+
| BAC3                          |
+-------------------------------+
1 row in set (0.00 sec)

中文,“你”的GBK编码为C4E3,“好”的GBK编码为BAC3

整理在表格里里看:

中文UTF-8GBK
E4BDA0C4E3
E5A5BDBAC3

UTF-8对中文使用了三个字节来编码,而GBK只使用了2个字节来编码。

解码

这些都是16进制的表示,所以需要前缀0x

# gbk编码的你,用utf-8解码,结果是null
mysql> select convert(0xC4E3 using utf8);
+----------------------------+
| convert(0xC4E3 using utf8) |
+----------------------------+
| NULL                       |
+----------------------------+
1 row in set, 1 warning (0.00 sec)

mysql> select convert(0xC4E3 using GBK);
+---------------------------+
| convert(0xC4E3 using GBK) |
+---------------------------+
| 你                        |
+---------------------------+
1 row in set (0.00 sec)

# utf-8编码的你,用gbk解码,结果是null
mysql> select convert(0xE4BDA0 using gbk);
+-----------------------------+
| convert(0xE4BDA0 using gbk) |
+-----------------------------+
| NULL                        |
+-----------------------------+
1 row in set, 1 warning (0.00 sec)

mysql> select convert(0xE4BDA0 using utf8);
+------------------------------+
| convert(0xE4BDA0 using utf8) |
+------------------------------+
| 你                           |
+------------------------------+
1 row in set (0.00 sec)

因为一个中文字太短,错误编码的时候就显示了,使用两个中文字,就能看到乱码的效果了。

# utf-8编码的你好,用gbk解码为三个字
mysql> select convert(0xE4BDA0E5A5BD using gbk);
+-----------------------------------+
| convert(0xE4BDA0E5A5BD using gbk) |
+-----------------------------------+
| 浣犲ソ                            |
+-----------------------------------+
1 row in set (0.01 sec)

# gbk编码的你好,用utf-8解码为null
mysql> select convert(0xC4E3BAC3 using utf8);
+--------------------------------+
| convert(0xC4E3BAC3 using utf8) |
+--------------------------------+
| NULL                           |
+--------------------------------+
1 row in set, 1 warning (0.00 sec)

emoji问题

Emoji在Unicode位于\u1F601-\u1F64F区段的字符。这超过了目前常用的UTF-8字符集的编码范围\u0000-\uFFFF

如何将emoji存入MySQL数据库。

一般MySQL数据库的默认字符集都会配置成UTF-8(三字节),而utf8mb4在5.5以后才被支持,很少会将系统默认字符集改成utf8mb4。当把一个需要4字节UTF-8编码才能表示的字符存入数据库的时候就会报错:

ERROR 1366: Incorrect string value: '\xF0\x9D\x8C\x86' for column

# 试图将一串Bytes插入到一列中,而这串Bytes的第一个字节是`\xF0`意味着这是一个四字节的UTF-8编码。

当MySQL表和列字符集配置为UTF-8的时候是无法存储这样的字符。

  • 升级MySQL到5.6或更高版本,并且将表字符集切换至utf8mb4。
  • 把内容存入到数据库之前做一次过滤,将Emoji字符替换成一段特殊的文字编码,然后再存入数据库中。之后从数据库获取或者前端展示时再将这段特殊文字编码转换成Emoji显示。假设用-*-1F601-*-来替代4字节的Emoji。
上次修改: 16 April 2020