Unicode.org 的网站标语:

Everyone in the world should be able to use their own language on phones and computers.

为什么需要 Unicode

Unicode 是计算机领域的一个标准,它整理、定义、编码的世界上大部分的文字系统,使得计算机能够呈现和处理人类文字。

计算机采用的是二进制格式的语言,不管是文字、图片、视频都是通过 0 1 来存储,所以人们自然而然的想要在人类语言和计算机的二进制语言中搭建一个桥梁(标准),让计算机能够以二进制形式来存储和处理人类的文字,关于 如何使用二进制来表达人类信息的问题 可以参考 编码 (豆瓣) 这本书的第九章二进制数。

上个世纪,美国在计算机领域一直处于领先地位,他们最先搞出了一套标准—— ASCII ,因为美国人说的是 English,所以 ASCII 就解决了英语(还有数字和其他的一些常用符号)转换成二进制的问题,后来国际标准化组织将其定为国际标准,也就是 ISO/IEC 646

ASCII 是使用一个标准 8 bit 字节的前 7 位来表示字符,也就是最多表示 2^7=128 个不同的字符,矛盾就此产生,计算机只有一种存储方式——二进制,但是人类在不同的地区,说着不同的语言,数量是成千上万的。ASCII 的编码数量只能适用于美国英语,当其他地区的国家想要使用计算机来表示自己的语言时,就不得不扩展 ASCII。

一些欧洲国家为了兼容已经存在的 ASCII,就将它扩展成了 EASCII(Extended ASCII),把 ASCII 码由 7 位扩充为 8 位,EASCII 的内码是由 0 到 255 共有 256(2^8) 个字符组成。EASCII 码比 ASCII 码扩充出来的符号(第八位)包括了表格符号、计算符号、希腊字母和特殊的拉丁符号,后来这个标准也被国际化—— ISO 8859

再后来,每个国家的语言各有不同,都想给自己订了一套标准,以及符号数量的大大增加(中日韩表意文字的加入),不同国家的计算机就开始说着“方言”,互相听不懂(乱码)了,相信程序员们一定都对以下这副对联不陌生:

上联:手持两把锟斤拷,口中疾呼烫烫烫。

下联:脚踏千朵屯屯屯,笑看万物锘锘锘。

为了解决标准不同,语言不通的问题,Unicode 诞生了 ,就像它 主页 上所描述的:

Unicode provides a unique number for every character,

no matter what the platform,

no matter what the program,

no matter what the language.

它囊括了世界上绝大部分的语言系统,并给每一个字符赋上了一个独一无二的编码,这样计算机只要都采用 Unicode 的标准,就不会出现语言不通的问题了。同样的,Unicode 也被国际组织标准化了—— ISO 10646

Unicode 平面及范围

目前的 Unicode 字符集分为 17 组,把每一组叫做一个 平面 ,每个平面拥有 65536(2^16)个代码点(Code Point)。

范围从 U+0000 至 U+FFFF 被称作基本多文种平面(Basic Multilingual Plane)。

范围从 U+10000 至 U+10FFFF 被称作补充平面(Supplementary Plane)。

目前为止,只是用了少数几个平面:

平面 始末字符值 中文名称 英文名称
0号平面 U+0000 - U+FFFF 基本多文种平面 Basic Multilingual Plane,简称 BMP
1号平面 U+10000 - U+1FFFF 多文种补充平面 Supplementary Multilingual Plane,简称 SMP
2号平面 U+20000 - U+2FFFF 表意文字补充平面 Supplementary Ideographic Plane,简称 SIP
3号平面 U+30000 - U+3FFFF 表意文字第三平面 Tertiary Ideographic Plane,简称 TIP
4号平面至13号平面 U+40000 - U+DFFFF (尚未使用)
14号平面 U+E0000 - U+EFFFF 特别用途补充平面 Supplementary Special-purpose Plane,简称 SSP
15号平面 U+F0000 - U+FFFFF 保留作为 私人使用区(A区) Private Use Area-A,简称 PUA-A
16号平面 U+100000 - U+10FFFF 保留作为 私人使用区(B区) Private Use Area-B,简称 PUA-B

可以看到 Unicode 理论上可以表示 17 * 65536 = 1114112 个字符,也就是说, 其范围在 0-1114111(十六进制:0x0000 - 0x10FFFF)之间

然而,实际上在基本多语言平面内,从 U+D800 U+DFFF 之间的码位区段是 永久保留不映射到 Unicode 字符 (具体原因在后面的 UTF-16 中可以看到)。所以通过计算可得,共有 0x10FFFF - (0xDFFF - 0xD800) = 0x10F800 即十进制:1112064 个码位(code point)可用来映射字符。

下面简单介绍前三个平面。

基本多文种平面

基本多文种平面(Basic Multilingual Plane, 缩写 BMP),或称第 0 平面或 0 号平面(Plane 0),是 Unicode 中的一个编码区块,这个平面中汇集了最常用的字符。编码范围从 U+0000 U+FFFF ,其中中文常用的汉字集中在 U+4E00 U+9FFF

第一辅助平面

第一辅助平面又称多文种补充平面(Supplementary Multilingual Plane,缩写 SMP,或简称 Plane 1),主要摆放 绝大多数古代文字 ,现时已不再使用或很少使用文字、速记、数学字母符号、音符、图形符号及用于学者的专业论文中使用的古老或过时的语言书写符号,以及网络通信等使用的绘文字。范围在 U+10000 U+1FFFF

第二辅助平面

第二辅助平面又称为表意文字补充平面(Supplementary Ideographic Plane,缩写SIP,或简称Plane 2),整个范围在 U+20000 U+2FFFF 。整个平面配置的都是一些 罕用的汉字或地区的方言用字 ,如粤语用字及越南语的字喃。

此平面摆放了 中日韩统一表意文字扩展B区 (4万3253个汉字)、 中日韩统一表意文字扩展C区 (4149个汉字)、 中日韩统一表意文字扩展D区 (222个汉字)、 中日韩统一表意文字扩展E区 (5762个汉字)、 中日韩统一表意文字扩展F区 (7473个汉字)以及 中日韩兼容表意文字增补 (CJK Compatibility Ideographs Supplement)。 CJK 指的是中国(China)、日本(Japan)、韩国(Korea)。


Unicode 和 UTF 的关系

通俗的来说,Unicode 是一种标准,规定了每个字符对应的数字,但是没有规定数字存储在具体的计算机系统中排列的顺序,占据多少个字节,所以 UTF(Unicode 转换格式,Unicode Transformation Format),也就是 Unicode 的 具体实现 应运而生了。

这种先抽象后实现的方式,在计算机非常常见,往往根据不同的问题,抽象成多层模型,每层模型负责不同的功能,有利于解耦合,模块化。

对于通用字符编码的解决方案,Unicode 委员会根据因特网架构委员会(Internet Architecture Board,简称 IAB)提出的三层模型的基础上,增加了两层,也就是 Unicode 五层模型,具体的技术细节参考: UTR#17: Character Encoding Model

根据这个五层模型,可以看出,UTF 主要是运行在第三层(Character Encoding Form,简称 CEF)和第四层模型上的(Character Encoding Scheme,简称 CES)。

接下来的内容中,会简单介绍下 3 个比较出名的实现:UTF-32、UTF-16 以及 UTF-8。


Unicode 和 UCS 的关系

UCS 是“全球字符集”(Universal Character Set)的英文缩写,表示ISO/IEC 10646和 Unicode 所定义的全部字符。

虽然本文一直混着说 ISO 10646 和 Unicode,但历史上,这两个项目一开始是各自独立研究统一字符编码的问题。

因此最初制定了不同的标准,1991 年前后,两个项目的参与者都认识到,世界不需要两个不兼容的字符集。于是,它们开始合并双方的工作成果,并为创立一个单一编码表而协同工作。

从Unicode 2.0开始,Unicode采用了与ISO 10646-1相同的字库和字码。同时,ISO也承诺,ISO 10646 将不会替超出 U+10FFFF 的 UCS-4 编码赋值,以使得两者范围保持一致。


UTF-32

在不考虑存储消耗,一个最简单的实现就是用固定长度的字节序来存储一个 Unicode 字符,那么这个固定长度的数字该是多少呢?从 Unicode 的范围我们可以算出, log2(1114112) ≈ 21 ,至少要 21 个二进制位才能完整表述 Unicode 的所有编码,也就是说最少需要 3 个字节,为了之后考虑扩展,就取 4 个字节来对一个 Unicode 的码位进行编码,这就是 UTF-32

实际上 UTF-32 的最高位(符号位)不使用,为 0,所以每一个字符由 0 到十六进制的 7FFFFFFF 的 31 位数值表示。

UTF-32的主要优点是可以利用它的 定长字节的特性 可以直接由 Unicode 码位来进行索引。在编码序列中查找第 N 个编码是一个常数时间操作。然而,这个定长字节也是它的一个缺陷,在 ASCII 中表示的英文符号,最少其实只需要一个字节,但是在 UTF-32 中,扩大了整整四倍,这使得人们开始思考有没有一种方法能够节省传输字符的空间开销。

UTF-32 和 UCS-4

原本 ISO 10646 标准定义了一个32位的编码形式,称作 UCS-4,通用字符集(UCS)的每一个字符由 0 到十六进制的 7FFFFFFF 的31位数值表示(符号位未使用且零),所以UTF-32和UCS-4能表示的字符是相同的, 都是使用定长字节序来表示 Unicode 字符

UTF-32 编码示例

示例一,大写字母 A 使用 UTF-32 表示:

大写字母 A 的 Unicode 值为 U+0041 ,从这里可以看出 UTF-32 对于单字节就可以编码的英文字母而言,空间浪费了很多。

UTF-32BE
十六进制:00 00 00 41
二进制:00000000 00000000 00000000 01000001

UTF-32LE
十六进制:41 00 00 00
二进制:01000001 00000000 00000000 00000000

示例二,汉字“一” 使用 UTF-32 表示:

汉字“一”的 Unicode 值为 U+4E00

UTF-32BE
十六进制:00 00 4E 00
二进制:00000000 00000000 01001110 00000000

UTF-32LE
十六进制:00 4E 00 00
二进制:00000000 01001110 00000000 00000000

示例三,汉字“𪜾”使用 UTF-32 表示:

汉字“𪜾”的 Unicode 值为 U+2A73E

UTF-32BE
十六进制:00 02 A7 3E
二进制:00000000 00000010 10100111 00111110

UTF-32LE
十六进制:3E A7 02 00    
二进制:00111110 10100111 00000010 00000000

UTF-16

UTF-16 就是一种 变长 表示的 Unicode 实现。它使用了 2 个或者 4 个字节来表示一个 Unicode 编码。

UTF-16 和 UCS-2

和前面提到的 UTF-32 和 UCS-4 类似,在 UTF-16 之前,ISO 10646 标准为通用字符集(UCS)定义了一种 16 位的编码形式(即 UCS-2),其编码 固定占用2个字节 ,它包含 65536个 编码空间,在前面提到过 Unicode 总共有 17 个平面,每个平面能容纳 65536 个码位, 所以 UCS-2 只能编码第 0 平面,即基本多文种平面(BMP) ,显然这是不够的,为了能够编码剩下的辅助平面,变长度的 UTF-16 出现了。

UTF-16 的编码方式

我们首先需要把 0x0000-0x10FFFF 这个总的范围划分成三个部分:

  1. U+0000~U+D7FF && U+E000~U+FFFF

  2. U+10000~U+10FFFF

  3. U+D800~U+DFFF

第一部分,也就是包含了最常用字符的基本多文种平面,UTF-16 与 UCS-2 编码这个范围内的码位 为16 比特长的单个码元(即 16 位,2 个字节) ,数值等价于对应的码位。

第二部分,即辅助平面(Supplementary Planes)中的码位,在 UTF-16 中被编码为 一对 16 比特长的码元(即 32 位,4 个字节) ,称作代理对(Surrogate Pair)。

第三部分,标准规定 U+D800~U+DFFF 的值不对应于任何字符。

代理对的具体算法如下:

  1. 用 2个字节(即 16 比特长的单个码元)表示处于 U+0000~U+FFFF 范围的字符。

  2. 对于 U+10000~U+10FFFF 范围的字符,将其码位值减去 0x10000 ,得到的范围值为 0x00~0xFFFFF

  3. 由于 log2(0xFFFFF)≈20 ,至少需要 20 个比特位表示辅助平面字符,所以采用 4 个字节(即 2 个 16 比特长的码元)来表示,高 10 位加上 0xD800 得到第一个码元(或称高位代理),低 10 位加上 0xDC00 得到第二个码元(或称低位代理)。

  4. 将第 3 步得到的两个码元组合就得到最终表示辅助平面字符的编码结果。

UTF-16 编码示例

示例一,大写字母 A 使用 UTF-16 表示:

大写字母 A 的 Unicode 值为 U+0041 位于 BMP 平面内,所以采用两个字节的编码。

UTF-16BE
十六进制:00 41
二进制:00000000 01000001

UTF-16LE
十六进制:41 00
二进制:01000001 00000000

示例二,汉字“一” 使用 UTF-16 表示:

汉字“一”的 Unicode 值为 U+4E00 位于 BMP 平面内,所以采用两个字节的编码。

UTF-16BE
十六进制:4E 00
二进制:01001110 00000000

UTF-16LE
十六进制:00 4E
二进制:00000000 01001110

示例三,汉字“𪜾”使用 UTF-16 表示:

汉字“𪜾”的 Unicode 值为 U+2A73E 位于辅助平面内,所以采用四个字节的编码。

0x2A73E 减去 0x10000 得到 0x1A73E 转换成二进制为 00011010011100111110

高十位(0001101001,即 0x69)加上 0xD800 得到高位代理 0xD869

低十位(1100111110,即 0x33E)加上 0xDC00 得到低位代理 0xDF3E

两个码元(代理)相组合就成为了一个字符的最终表示。

UTF-16BE
十六进制:D8 69 DF 3E
二进制:11011000 01101001 11011111 00111110

UTF-16LE
十六进制:69 D8 3E DF
二进制:01101001 11011000 00111110 11011111

UTF-8

在计算机中,较小的编码点(比如英文字母)的使用频率最高,用一个字节表示最节省空间,所以人们提出了 UTF-8,同样是一种针对 Unicode 的 可变长度 字符编码,也是一种前缀码,它可以用一至四个字节对 Unicode 字符集中的所有有效编码点进行编码。

UTF-8 就是为了解决向后兼容 ASCII 码而设计,Unicode 中前 128 个字符,使用与 ASCII 码相同的二进制值的单个字节进行编码,而且字面与ASCII码的字面一一对应,这使得原来处理ASCII字符的软件无须或只须做少部分修改,即可继续使用。因此, 它逐渐成为电子邮件、网页及其他存储或发送文字优先采用的编码方式

UTF-8 的编码方式

  1. U+0000 U+007F :128个 US-ASCII 字符只需一个字节编码。

  2. U+0080 U+07FF :带有附加符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及它拿字母则需要两个字节编码。

  3. U+0800 U+FFFF :其他基本多文种平面(BMP)中的字符(这包含了大部分常用字,如大部分的汉字)使用三个字节编码。

  4. 其他极少使用的Unicode 辅助平面的字符使用四字节编码。

获取到了某个字符的 Unicode 的码位值,转换成二进制表示,然后从低到高填入下面表格中的 UTF-8 格式中,下面表格中的 w,x,y,z 均指占位符,可以直接看下一小节的示例,更容易理解。

代码范围(十六进制) 标量值(二进制) UTF-8(二进制/十六进制) 注释
000000 - 00007F 共128个代码 00000000 00000000 0zzzzzzz 0zzzzzzz(00-7F) ASCII字符范围,字节由零开始
七个z 七个z
000080 - 0007FF 共1920个代码 00000000 00000yyy yyzzzzzz 110yyyyy(C0-DF) 10zzzzzz(80-BF) 第一个字节由110开始,接着的字节由10开始
三个y;二个y;六个z 五个y;六个z
000800 - 00D7FF、00E000 - 00FFFF 共61440个代码 00000000 xxxxyyyy yyzzzzzz 1110xxxx(E0-EF) 10yyyyyy 10zzzzzz 第一个字节由1110开始,接着的字节由10开始
四个x;四个y;二个y;六个z 四个x;六个y;六个z
010000 - 10FFFF 共1048576个代码 000wwwxx xxxxyyyy yyzzzzzz 11110www(F0-F7) 10xxxxxx 10yyyyyy 10zzzzzz 将由11110开始,接着的字节由10开始
三个w;二个x;四个x;四个y;二个y;六个z 三个w;六个x;六个y;六个z

UTF-8 编码示例

示例一,大写字母 A 使用 UTF-8 表示:

大写字母 A 的 Unicode 值为 U+0041 位于 U+0000 U+007F 范围内,所以采用单个字节的编码。

UTF-8
十六进制:41
二进制:01000001

示例二,希腊文小写字母 α 用 UTF-8 表示:

希腊文小写字母 α 的 Unicode 值为 U+03B1 位于 U+0080 U+07FF 范围内,所以采用两个字节的编码。

  1. 0x03B1 转换成二进制 001110110001

  2. 填入 110yyyyy 10zzzzzz 的格式中。

  3. 得到 yyyyy 为 01110,zzzzzz 为 110001。

  4. 结果为 11001110(CE) 10110001(B1)

UTF-8
十六进制:CE B1
二进制:11001110 10110001

示例三,汉字“一” 使用 UTF-8 表示:

汉字“一”的 Unicode 值为 U+4E00 位于 U+0800 U+FFFF 范围内,所以采用三个字节的编码。

  1. 0x4E00 转换成二进制 0100111000000000

  2. 填入 1110xxxx 10yyyyyy 10zzzzzz 的格式中。

  3. 得到 xxxx 为 0100,yyyyyy 为 111000,zzzzzz 为 000000。

  4. 结果为 11100100(E4) 10111000(B8) 10000000(80)

UTF-8
十六进制:E4 B8 80
二进制:11100100 10111000 10000000

示例四,汉字“𪜾”使用 UTF-16 表示:

汉字“𪜾”的 Unicode 值为 U+2A73E 位于 0x10000 0x10FFFF 范围内,所以采用四个字节的编码。

  1. 0x2A73E 转换成二进制 00101010011100111110

  2. 填入 11110www 10xxxxxx 10yyyyyy 10zzzzzz 的格式中。

  3. 得到 www 为 000,xxxxxx 为 101010,yyyyyy 为 011100,zzzzzz 为 111110。

  4. 结果为 11110000(F0) 10101010(AA) 10011100(9C) 10111110(BE)

UTF-8
十六进制:F0 AA 9C BE
二进制:11110000 10101010 10011100 10111110

UTF-8 根据这种方式可以处理更大数量的字符。原来的规范允许长达6字节的序列,可以覆盖到31位(通用字符集原来的极限)。尽管如此,2003 年 11 月 UTF-8 被 RFC 3629 重新规范,只能使用原来 Unicode 定义的区域, U+0000 U+10FFFF ,也就是说最多四个字节。


关于小端/大端存储

在上面的实例中,UTF-32 和 UTF-16 均有小端存储(little-endian)和大端存储(big-endian)的方式。

这两个不同的方式,指的是在电脑内存中或在数字通信链路中,一个字由多字节组成,根据这些多字节的排列顺序的不同,划分出了小端序和大端序。

考虑到篇幅,这里不在展开细说,可以参考以下资料:

  1. 深入理解计算机系统(原书第3版) (豆瓣) 这本书的第二章的2.1.3小节寻址和字节顺序。

  2. Endianness - Wikipedia


BOM 是什么

BOM(Byte Order Mark),字节顺序标记,是位于码点 U+FEFF 的 Unicode 字符的名称。当以 UTF-16 或 UTF-32 来将 UCS/Unicode 字符所组成的字符串编码时, 这个字符被用来标示其字节序 (也就是上一个小节提到的大端/小段序),需要注意的是,UTF-8 不需要用 BOM 来表示字节序。

它常被用来当做标示文件是以UTF-8、UTF-16或UTF-32编码的标记,换句话说,在文字流的开头放置一个编码的 BOM,可以表明文本是Unicode,并识别所使用的编码方案。

UTF-8 的 BOM

UTF-8 的 BOM 是 0xEF 0xBB 0xBF ,它只是用来标志当前的文件是个以 UTF-8 来编码的文件,不用来表示字节序。

这里有个小问题: 0xEF 0xBB 0xBF 是怎么来的?

前面提到,Unicode 用来表示 BOM 的特殊码点的值是 U+FEFF ,所以可以按照之前介绍的算法来计算最终存储在 UTF-8 编码的文件中的字节:

  1. U+FEFF 位于 U+0800 U+FFFF 范围内,所以采用三个字节的编码。

  2. U+FEFF 转换成二进制 1111111011111111

  3. 填入 1110xxxx 10yyyyyy 10zzzzzz 的格式中。

  4. 得到 xxxx 为 1111,yyyyyy 为 111011,zzzzzz 为 111111。

  5. 结果为 11101111(EF) 10111011(BB) 10111111(BF)

带 BOM 的 UTF-8 编码示例

示例,使用带 BOM 的 UTF-8 编码方式来存储一个 ABC 的字符串

UTF-8 是兼容 ASCII 的,所以用一个字节来表示一个英文字母,找到 ABC 的 Unicode 码值,并在开头添加 BOM 即可:

带 BOM 的 UTF-8
十六进制:EF BB BF 41 42 43

UTF-16 和 UTF-32 的 BOM

UTF-16 和 UTF-32 两者的 BOM 规则相同,但与 UTF-8 不同的是,UTF-16 和 UTF-32 的 BOM 有用来标志是以何种字节序存储的用途。

相较于 UTF-8 需要计算转换,用 UTF-16 和 UTF-32 来表示 U+FEFF 就可以不需要计算,根据此 Unicode 码点值可以直接得到:

大端序,UTF-16 的 BOM 为: 0xFE 0xFF

小端序,UTF-16 的 BOM 为: 0xFF 0xFE

大端序,UTF-32 的 BOM 为: 00 00 FE FF

小端序,UTF-32 的 BOM 为: FF FE 00 00

带 BOM 的 UTF-16、UTF-32 的编码示例

示例,使用带 BOM 的 UTF-16 和 UTF-32 编码方式来存储一个 ABC 的字符串

UTF-16 使用 2 个字节存储英文字母,UTF-32 则用 4 个字节存储英文字母,在开头根据不同的字节序添加不同的 BOM 即可。

带 BOM 的 UTF-16BE
十六进制:FE FF 00 41 00 42 00 43

带 BOM 的 UTF-16LE
十六进制:FF FE 41 00 42 00 43 00

带 BOM 的 UTF-32BE
十六进制:00 00 FE FF 00 00 00 41 00 00 00 42 00 00 00 43

带 BOM 的 UTF-32LE
十六进制:FF FE 00 00 41 00 00 00 42 00 00 00 43 00 00 00

小结

我通过收集了互联网上有关 Unicode 编码的一些资料,边学边写下了这篇 Blog,关于计算机编码历史的部分,由于内容较大,并没有做细致的考究。另外, 编码算法的部分请以 ISO 组织、Unicode 官方资料以及 RFC 文档为准

如何让计算机的编码人类语言的问题 ,我觉得是一个十分庞大且复杂的问题。全世界这么多种语言,几十万种不同的符号(有根据考古发现的远古时期古文字,也有近代才诞生的 Emoji),确立标准的过程中又会涉及到的数学,经济,政治,文化,国家等等各种问题,想要用一个统一的标准来解决,实在是一个宏大的工程。

本文先是从计算机用 0 和 1 表示字符开始,描述了 Unicode 出现之前的状况,再到 Unicode 的范围和编码规则,说明了 Unicode 的几种较有名的实现的原理:UTF-32、UTF-16、UTF-8,其中又涉及到了编码中的大小端问题和 Unicode 的字节顺序标记(BOM)问题。

我只简单的讲述了关于 Unicode 和计算机编码问题的冰山一角,如果需要更深入的了解历史、编写相关的类库或是了解编码规则的数学原理,则需要进一步翻阅官方的技术标准文件。


参考

  1. ✔️ ❤️ ★ Unicode 字符百科

  2. Unicode - 维基百科,自由的百科全书

  3. UTF-8 - 维基百科,自由的百科全书

  4. UTF-16 - 维基百科,自由的百科全书

  5. UTF-32 - 维基百科,自由的百科全书

  6. Unicode Standard

  7. Character (Java SE 11 & JDK 11 )

  8. Byte order mark - Wikipedia

  9. 刨根究底字符编码之十一——UTF-8编码方式与字节序标记 - 笨笨阿林 - 博客园

  10. Information on RFC 2781 » RFC Editor

  11. Information on RFC 3629 » RFC Editor

  12. https://www.bilibili.com/video/BV1xP4y1J7CS

  13. 字符编码的前世今生 - 掘金

  14. https://mp.weixin.qq.com/s/hlH6gNVzPRg05MCGZLgYPw