相信几乎每一位中文开发者都遇到过中文乱码问题,其表象各式各样,不同编程语言的解决方法也不一而同。但是从本质上来说,表象的背后是相通的原理,不论什么编程语言,解决途径的背后是相同的方法。

乱码分析思路

乱码是由于底层的数据与其表现形式不一致造成的,因此要理清乱码,首先要清楚数据流的变化。数据流可以抽象成:

1
输入 -> 程序 -> 输出

数据总是从 输入 流向 程序程序 处理完成后流向 输出。抽象是为了帮助我们理解数据所处的环节,从而找出乱码产生的地方,最终解决乱码问题。下面用例子说明怎么解决乱码。

假设用户修改了昵称后发现昵称是乱码,对应的处理逻辑依次是:

  1. 前端请求后台接口;
  2. 后台接口执行 MySQL 的 update 语句;
  3. 后台接口执行 MySQL 的 select 语句;
  4. 后台接口返回数据给前端;

首先,疏理出上述过程对应的数据流:

输入 程序 输出
1 用户改昵称 后台接口 MySQL update
2 MySQL select 后台接口 前端页面

其次,明确数据流每个环节的字符编码,假设每个环节的编码为:

输入 程序 输出
1 UTF-8 GBK GBK
2 GBK GBK UTF-8

最后,对字符编码不一致的环节进行编码转换:

1
2
3
4
5
1. 用户改昵称 --------------> 后台接口
              UTF-8 转 GBK

2. 后台接口 ----------------> 前端页面
              GBK 转 UTF-8

我们把上面的例子归纳总结一下,得到解决乱码的关键:

  1. 疏理出数据流
  2. 弄清数据流的每一环节的编码
  3. 对编码不一致的环节进行编码转换

字节与字符

要弄清每一环节的编码,需要先了解字节(byte)和字符(char)的区别,对于字符串(string)而言:

  • 字节是最小的存储单位;
  • 字符是最小的显示单位;

一个字节只能表示 256 个不同的字符,显然无法表示所有的文字,因此有了编码的概念。编码告诉计算机,哪些字节组成一个字符,或者怎么将一个字符存储成字节序列。同样的字符串,不同编码底层的字节序列可能是不同的。

以“你好”两个字为例,它的 GBK 和 UTF-8 的字节序列分别为:

1
2
  GBK: 0xC4 0xE3 0xBA 0xC3
UTF-8: 0xE4 0xBD 0xA0 0xE5 0xA5 0xBD

不同编程语言默认的字符编码是不同的,比如:

  • C、Python 2 不区分字节与字符的概念,它们的字符串直接由字节组成;
  • Golang、Rust、Python 3 的字符串默认是 UTF-8 编码;
  • Java 的字符串是 UTF-16 编码;

常见编码

我们要弄清每一环节的编码,首先就得知道常见的编码有哪些。

Unicode 与 UTF-8

计算机只存储二进制数字,因此是无法表示字符的。除非字符与数字之间能够一一映射,这样字符就能用数字表示了。最原始的 ASCII 码就定义了英文字母与数字间的映射关系,比如:

1
2
 h   e   l   l   o
104 101 108 108 111

ASCII 码的问题在于它无法表示包括中文、日文在内的非英文字符,因此有了 Unicode。Unicode(Universal Coded Character Set)是一组字符集,定义了每个字符与数字之间的映射关系,一个 Unicode 字符通常用 U+ 和两个十六进制数来表示。比如:

1
2
  你     好
U+4F60 U+597D

看上去似乎用某个 Unicode 字符对应的数字就可以表示那个字符了,那为什么还存在 UTF-8 编码?原因有两点。

  1. Unicode 的字符集将来可能发生扩展(比如越来越多 emoji 表情),扩展意味着无法确定上限,也就无法确定最后一个字符对应的最大数字是多少。进而,没法确定用多少个字节来表示一个 Unicode 字符对应的数字;

  2. 目前 Unicode 包含了 13 万个字符,几乎涵盖了所有国家的所有字符。虽然上限不可知,但是可以退而求其次,用四个字节来表示一个 Unicode 字符,总共可以表示 4 亿多个字符,未来的扩展空间足够大了。这种直观的编码方式就是 UTF-32,但是它的缺点显而易见,如果每个英文字母都要四个字节来表示,岂不是很浪费存储空间?

为了解决 Unicode 字符的存储问题,有了可变长度的字符编码 UTF-8(8-bit Unicode Transformation Format)。不同语言 UTF-8 占用的字节数可能不一样,比如:

  • 数字、英文字母占用 1 个字节,与 ASCII 码兼容;
  • 大部分中文占用 3 个字节,少数占用 4 个字节;

GBK、GB2312 与 GB18030

GBK、GB2312 与 GB18030 是最常见的三种中文字符编码,其中“GB”意为“国家标准”。三者关系很简单:

  1. GB2312 包含了 6763 个字符,每个字符都用两个字节表示。但是它不支持繁体字等汉字,因此有了 GBK;
  2. GBK 包含 21886 个字符,每个字符都用两个字节表示,兼容 GB2312,支持繁体中文、日文假名,但不支持韩国字;
  3. GB18030 兼容 GBK 和 GB2312,是一种可变长度字符编码,不仅支持中日韩文字,还支持少数民族文字;

整理一下,也就是:

编码名 占用字节数 支持字符集 兼容情况
GB2312 2 不支持繁体字
GBK 2 支持繁体字 GB2312
GB18030 可变长 支持中日韩文字 GBK、GB2312

编码与解码

编码(encode)与解码(decode)是一组对立的概念,单独看某一个没有任何意义。因为「将 src 编码成 dst」,也可以说成是「将 src 解码成 dst」,比如下面两句话是一个意思:

  • 将字节序列编码为 GBK 字符串;
  • 将字节序列解码为 GBK 字符串;

因此如果进行编码转换,就必须清楚不同语境下 encodedecode 操作的 src、dst 分别指什么。不同语言的字符串组成不尽相同,那么 encode 的语义也可能不一样。

对 Python 2 而言:

  • decodestr 转成 unicode
  • encodeunicode 转成 str
1
2
3
4
# UTF-8 str => unicode
s = "你好".decode('UTF-8')
# unicode => GBK str
s = s.encode('GBK')

对 Python 3 而言:

  • decodebytes 转成 str
  • encodestr 转成 bytes
1
2
3
4
# UTF-8 bytes => str
s = b'\xE4\xBD\xA0\xE5\xA5\xBD'.decode('utf-8')
# str => GBK bytes
s = s.encode('gbk')

对 Golang 的 simplifiedchinese 官方库而言:

  • decode 将 GBK bytes 转成 UTF-8 bytes
  • encode 将 UTF-8 bytes 转成 GBK bytes
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
    "bytes"
    "encoding/hex"
    "fmt"
    "golang.org/x/text/encoding/simplifiedchinese"
    "golang.org/x/text/transform"
    "io"
)

func GbkEncoder(data []byte) io.Reader {
    return transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewEncoder())
}

func GbkDecoder(data []byte) io.Reader {
    return transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder())
}

func main() {
    buf := make([]byte, 6)
    // GBK bytes => UTF-8 bytes
    n, _ := GbkDecoder([]byte{0xC4, 0xE3, 0xBA, 0xC3}).Read(buf)
    fmt.Println(hex.Dump(buf[:n]))
    // UTF-8 bytes => GBK bytes
    n, _ = GbkEncoder([]byte{0xE4, 0xBD, 0xA0, 0xE5, 0xA5, 0xBD}).Read(buf)
    fmt.Println(hex.Dump(buf[:n]))
}

乱码在哪里

任何能设置编码的地方,都可能导致乱码。这里列举出最常引入乱码的点,方便大家排查:

  • 前端页面编码;
  • 代码文件编码(只影响字符串常量);
  • 文件编码;
  • 数据库编码;
  • Linux 系统编码(LANGLC_ALL等变量);
  • 终端(如:Putty、XShell、SecureCRT、iTerm2)编码;

下面以 GBK 和 UTF-8 编码为例,列举出最常见的乱码情况:

  • 「文件/数据库/前端输入」是 GBK 编码,「编程语言」当作 UTF-8 字符串处理;
  • 「代码文件」是 GBK 编码,导致字符串常量是 GBK 编码,而「编程语言」的默认编码是 UTF-8;
  • 「程序」print 的字符串是 GBK 编码,而「终端」是 UTF-8 编码,导致显示出来是乱码;
  • 「终端」是 GBK 编码,程序从终端读取用户输入,「编程语言」按 UTF-8 处理;

如果不清楚某一环节的编码,可以将日志打到文件中,再用 iconv 工具进行编码转换,试探编码到底是哪一种。比如你猜测 foo.txt 文件是 GBK 编码,同时 locale 命令输出的编码和终端的编码都是 UTF-8,运行下面命令,如果正常显示出“你好”两个字,说明 foo.txt 的编码确实是我们猜测的 GBK 编码:

1
2
3
4
5
6
7
# 打印出文件的字节序列
$ hexdump foo.txt
0000000 c4 e3 ba c3
0000004
# 将 foo.txt 从 GBK 编码转换为 UTF-8 编码
$ iconv -f gbk -t utf-8 foo.txt
你好

如果弄清了每一环节的编码,就知道该在哪进行编码转换了。此时注意尽量在「逻辑边界」处进行编码转换,一方面避免重复进行编码转换带来的开销,另一方面降低心智负担。

选择什么编码

UTF-8。如果没有历史包袱,永远不要用 GBK,因为:

  • 万一将来遇到越南语或泰文,GBK 就没辙了;
  • 现代编程语言的默认编码一般是 UTF-8,不可能是 GBK;
  • 虽然 GBK 存储中文占用的字节数比 UTF-8 少,但是与开发效率相比,这点节省的存储成本不值一提;