什么是字符编码
计算机底层只能处理基于电路实现的二进制0和1,而现实应用中,我们看到的都是各种字符,包括日常各种电子文档读写,开发人员的代码等。所以必须要有一种方式将我们看到的字符和计算机底层能够处理的二进制数0和1映射起来,计算机处理的时候通过这个映射将字符转换为二进制数处理,处理完了后再通过这个映射转换为字符显示出来展示给用户,这个字符和数字之间的映射集合就是所谓的字符编码。
ASCII编码和ISO-8859-1编码
1960年左右计算机在美国开始发明出现的时候,只有英文字符、数字字符和一些控制字符,也就是我们现在在计算机键盘上看到的这些字符,比如abcd...ABCD加上数字字符0123...89和控制字符,数量加起来不超过128,我们知道一个字节(8个bit位)可以表示256个状态,因此可以编码256个字符,所以只需要一个字节就可以编码,因为不超过128,为了编码效率,可以只使用一个字节中的前7个比特位编码,也就是使用7个二进制位编码,这就是最早的ASCII字符编码,直到今天也在广泛使用,因为后面出现的各种字符集虽然使用多个字节编码,但是基本都是和ASCII编码是兼容的,也就是不管用多少个字节,最低位字节中前7个比特位还是表示ASCII编码。
从1960年开始经过了20多年,随着计算机向其它国家的普及,逐渐出现了其它的字符,128个ASCII字符(7个bit位)已经不够表示后面出现的字符了,但是这个时候出现的新字符又不是很多,数量小于128个,于是使用ASCII字符集编码中的一个字节的前7个比特位和之前未使用的第8个比特位一起用来编码出现的新字符,这就是ISO-8859-1字符编码,在有的系统中如MySQL的数据库中也叫做Latin1编码。
简单总结下,就是ASCII和ISO-8859-1字符编码都是使用单个字节编码字符,ASCII使用了单个字节的前7个比特位,ISO-8859-1使用单个字节的所有8个比特位编码字符。
Unicode编码的出现
随着计算机以及信息技术发展到全世界,尤其是互联网的普及,单字节的编码方式明显不够用了,因为每个国家和地区都有自己的文字字符,比如我们熟悉的中文(包括繁体中文和简体中文),日文,韩文,还有很多其它国家的语言文字,于是各个国家开始制定基于自己语言文字的编码方式,中文就出现了GBK(包括繁体中文)和GB2312,还有其它国家类似的也有自己的编码集,但这样的编码最大的问题是不能跨国家跨语言使用,比如翻译应用,需要在不同的编码方式中切换,传输的时候不仅要传输数据,还要传输编码,给上层应用带来了很大的负担,这本质上就是我们今天日常遇到的各种乱码出现的原因。因此需要一种字符集编码能够编码世界上所有国家和地区的所有字符,这就是Unicode编码出现的原因。
Unicode编码的作用
Unicode编码要解决的问题是能够使用一套统一的编码方式解决世界上所有字符的编码,全世界通用,每个字符在这个字符集中都对应唯一的数字,不会出现不同的字符对应同一个数字。可以做下类比:Unicode字符集就是一个Map映射函数,输入是世界上的每一个字符,输出是一个唯一对应的数字。这个唯一对应的数字也叫做码点:在Java语言中叫CodePoint,Java语言中java.lang.Character类中各种codePoint开头的方法,在Golang语言中叫Rune,utf8的包下面有各种操作方法,比如在平时使用U+16进制数字表示,现在最新的Unicode范围从U+0000到U+10FFFF(0到1114111),可以表示110多万个数字(当然并不是所有的码点数字都有对应的字符,有一些预留),简单想一下中文字符常用的也就几千个,而Unicode可以包含100多万个字符。使用110多万个码点足以涵盖了地球上目前已知的所有字符。
UTF编码的必要性
有了Unicode编码后,对于每个字符都有对应的数字,反过来每个数字就有对应的字符,但是有很多个字符的时候,比如一篇电子文档,互联网上一个个网页,怎么存储和传输,最简单粗暴的方式就是把每个字符转换为对应的Unicode码点数字,然后存储,但这时候问题来了,如果按这种存储方式读取数字解析字符,很显然会出现问题,我们知道信息的传递是以字节为基本单位的,而Unicode最大值是100多万,需要20多个bit位(至少3个字节)才能编码,也就是说有的Unicode码点或码点所代表的传输字符不全是类似ASCII或ISO-8859-1的单个字节,这样收到信息的一端在解析的时候就有切分的问题,比如存储后的数字是123,怎么确定是表示一个字符的数字码点(123)还是两个字符的数字码点(1和23 或者 12和3)或者三个字符码点(1和2和3)。
当然还有一种方式就是使用Unicode码点表示每个字符,然后每个字符中间加上特定的分割符号,或者使用等长的字节编码所有的Unicode字符,比如都使用4个字节,按4个字节等长度切割,但这样会出现存储效率的问题,因为现实的信息存储传输中并不是所有字符出现的概率都相等,而是有些常用的字符出现概率大,有些概率小,根据哈夫曼编码原理,无论是存储还是传输,效率最高的方式是最常用的出现最多的字符采用最短的编码,出现少的字符用稍长的编码。
为了解决上述出现在多个字符同时传输或存储过程中的Unicode码点表示和解析切割问题,就出现了对Unicode编码的转换模式,当然最常见的就是UTF编码,我们看到的以UTF开头的,UTF全称是Unicode Transformation Format,Unicode转换格式,这里注意区分下Unicode和UTF的区别。
UTF中有很多种,下面重点介绍目前使用最广泛的UTF-8,顺便再说明下UTF-16编码,因为历史遗留原因,JAVA语言中标准库字符默认的编码方式就是UTF-16。
UTF8编码原理
首先说明下UTF8编码这里面的8意思是使用8个bit位(一个字节)为单位编码的,即1个字节的倍数,也就是说使用1个字节或2个字节或3个字节或4个字节编码,UTF-8对Unicode码点(U+0000(0)到U+10FFFF(1114111))具体的编码范围格式如下面的表格所示:
第一个码点 | 最后一个码点 | Byte1 | Byte2 | Byte3 | Byte4 |
---|---|---|---|---|---|
U+0000(0) | U+007F(127) | 0xxxxxxx | |||
U+0080(128) | U+07FF(2047) | 110xxxxx | 10xxxxxx | ||
U+0800(2048) | U+FFFF(65535) | 1110xxxx | 10xxxxxx | 10xxxxxx | |
U+10000(65536) | U+10FFFF(1114111) | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx |
规律说明:上表格的xxxxx就是具体的Unicode码点填充位置,上面表格中每一行第一个字节(Byte1)中的1的个数就表示了表示按多少个字节截取编码,后面跟的字节都是10开头
UTF8编码3字节举例
上面的表格有点抽象,举个示例说明下:比如对于鸿度的'鸿'这个字符,我们先获取这个字符在全世界唯一的Unicode码点数字,可以通过如下代码输出:
Java语言中:
System.out.println(Character.codePointAt("鸿", 0));
Go语言中:
fmt.Println('鸿')
输出的十进制Unicode码点值为40511,对应的16进制表示为U+9E3F,这个值的范围属于上面表格里面的第3行,也就是如果使用UTF8编码字符'鸿'需要用3个字节,填充格式为1110xxxx 10xxxxxx 10xxxxxx,继续把该Unicode码点转换为二进制格式为 1001 1110 0011 1111,用该二进制位填充,如下所示第1行为UTF8编码填充格式,第2行为字符'鸿'对应的Unicode码点的二进制,第3行为编码后的二进制值
填充格式: 1110 x x x x 10 x x x x x x 10 x x x x x x
Unicode码点: 1 0 0 1 1 1 1 0 0 0 1 1 1 1 1 1
UTF8编码值: 1110 1 0 0 1 10 1 1 1 0 0 0 10 1 1 1 1 1 1
上面得到的UTF8编码后的二进制值按照一个字节为单位分开即为:11101001 10111000 10111111
转化为Java中对应的三个字节的十进制数分别为: -23 -72 -65
我们使用Java代码来验证下,使用Java中的如下代码:
System.out.println(Arrays.toString("鸿".getBytes("UTF-8")));
打印输出的结果刚好是:[-23, -72, -65]
再补充说明下:前面表格中第1行U+0000到U+007F,即0到127使用1个字节编码,这个与ASCII编码值是完全一样的,因此说UTF-8编码与ASCII编码是完全兼容的,必须清楚的是实际中有些编码和ASCII编码是不兼容的。
UTF8编码4字节举例
从前面的表格中得知大于65535的Unicode字符,使用UTF8需要4个字节编码,下面我们选取Unicode码点大于65535的值,以表情符😀举例:使用如下代码:先获取这个字符在全世界唯一的Unicode码点数字,可以通过如下代码输出:
Java语言中:
System.out.println(Character.codePointAt("😀", 0));
输出该表情符的Unicode码点是128512,显然大于65535,128512对应的16进制码点值为 U+1F600
这个值的范围属于上面表格里面的第4行,也就是如果使用UTF8编码字符'😀'需要用4个字节,填充格式为11110xxx 10xxxxxx 10xxxxxx 10xxxxxx,继续把该Unicode码点转换为二进制格式为
0001 1111 0110 0000 0000,用该二进制位填充,如下所示第1行为UTF8编码填充格式
填充格式: 11110 x x x 10 x x x x x x 10 x x x x x x 10 x x x x x x
Unicode码点: 0 0 0 0 1 1 1 1 1 0 1 1 0 0 0 0 0 0 0 0 0
UTF8编码值: 11110 0 0 0 100 1 1 1 1 1 100 1 1 0 0 0 100 0 0 0 0 0
上面得到的UTF8编码后的二进制值按照一个字节为单位分开即为:
11110001 10111111 10011000 10000000
转化为Java中对应的四个字节的十进制数分别为: -16, -97, -104, -128
我们使用Java代码来验证下,使用Java中的如下代码:
System.out.println(Arrays.toString("😀".getBytes("UTF-8")));
打印输出的结果为:[-16, -97, -104, -128],和上面手动转换的一致。
简单总结下,UTF8编码对Unicode中码点小于65535的字符使用至多3个字节编码,对于大于65535的字符使用4个字节编码,这也是我们开发经常在MySQL中看到的字符编码UTF8mb3和UTF8mb4的区别,这里的UTF8mb3和UTF8mb4都指的是UTF8编码,其中mb3指的是UTF8中最多可以使用3个字节编码,也就是1个或2个或3个字节,mb4指的是最多可以使用4个字节编码,这也是为什么如果在MySQL中使用了表情符,我们必须手动指定编码为UTF8mb4的原因。
UTF16编码原理
上面详细说明了UTF-8的编码方式,UTF-8可以说是现在最流行的编码方式,除了一些历史遗留原因以及一些老系统采用非UTF-8编码,新构建的系统都应该默认使用UTF-8编码。
因为JAVA语言内部默认采用的编码方式是UTF-16,所以这里再详细解释下UTF-16的编码规则,UTF-16里面的16也和UTF-8中的8类似,使用16个bit位(2个字节为单位编码),也就是说使用2(21)个字节或4(22)个字节编码的。
UTF-16对于Unicode码点从U+0000(0)到U+D7FF(55295),U+E0000(57344)到U+FFFF(65535)之间的字符,使用2个字节编码,如下:
0-----------------55295-----------------57344----------------------65535
U+0000 U+D7FF U+E0000 U+FFFF
对于U+D7FF(55295)到U+E0000(57344)之间,也就是U+D800(55296)到U+DFFFF(57343)共2048个码点,Unicode永久保留,专门用于大于65535的Unicode字符编码填充使用没有分配任何字符(在Java的java.lang.Character官方类文档里也叫做low surrogates and high surrogates),后面进行编码举例的时候会进一步详细说明。
上面说明了UTF16编码要么使用2个字节编码,要么使用4个字节编码,下面我们分别举例说明:
UTF16编码2字节举例
首先还是以鸿度的"鸿"这个字符举例,使用UTF16编码,前面在UTF8编码举例中已经知道"鸿"这个字符返回的十进制Unicode码点为40511(小于65535),40511对应的十六进制为U+9E3F,使用
System.out.println(Arrays.toString("鸿".getBytes("UTF-16")));
输出的字节数组是[-2, -1, -98, 63],对应16进制是0x FE FF 9E 3F,前面的FEFF是为了表示是大端(bigEndian)存储还是小端(littleEndian)存储,可以看出16进制编码值和Unicode码点是一样的。实际上
对于0到65535以内的Unicode码点,UTF16编码值和Unicode码点就是相等的。
UTF16编码4字节举例
那么对于对于码点大于65535的字符(中文字符平时比较少见,但表情字符是大于65535的),
即对于U+010000(65536)到U+10FFFFF(1114111)之间的字符码点,Unicode使用4个字节编码,
首先在这个范围的Unicode码点,为了填充,编码前统一会减去0x10000,即(U+10000(65536)到U+10FFFF(1114111))减去U+10000等于(0x00000到0xFFFFF),减去后的数字范围刚好可以使用20个bit位可以表示,如下:
65536----------------------------------------------------- 1114111
U+10000--------------------------------------------------- U+10FFFF
减去 U+10000
等于 U+00000--------------------------------------------------- U+0FFFFF
前面UTF16编码原理中提到 Unicode把永久保留的U+D800到U+DFFF范围的数字分为2个部分(0xD800~0xDBFF)和(0xDC00~0xDFFF),也叫2个桶,2个桶对应的2进制范围表示是
1101 1000 0000 0000 ———— 1101 1011 1111 1111
1101 1100 0000 0000 ———— 1101 1111 1111 1111
上面红色部分的0总共有20个,也就是用来填充前面减去U+10000后的数字,这个数字对应的二进制比特位分别会填充到上面红色部分的0,填充后把原来含有红色0填充的两个值拼到一起后的值就是Unicode码点大于65535的字符对应的UTF16编码
下面我们选取Unicode码点大于65535的值,还是以表情符😀举例:使用如下代码:
System.out.println(Character.codePointAt("😀", 0));
获取的该表情符号的Unicode码点是128512,显然大于65535,128512对应的16进制码点值为 0x1F600,用该码点值减去0x010000得到0xF600 即二进制 0000 1111 01 10 0000 0000,我们把低位20个bit位依次填充上面红色的0 得到😀的UTF16编码即为:
1101 1000 0011 1101 1101 1110 0000 0000
通过java代码获取😀的UTF16编码:
System.out.println(Arrays.toString("😀".getBytes("UTF16")));
得到的结果是 [-2, -1, -40, 61, -34, 0] 即 FE FF D8 3D DE 0,去除大小端标志位FEFF后为
D8 3D DE 0
转换为二进制为:101 1000 0011 1101 1101 1110 0000 0000,和上面手动转换的结果完全一致。
UTF8和UTF16比较
从上面的编码规则可以看出,UTF8根据Unicode码点的值范围,对于码点在0到127范围的字符使用1个字节编码,对于128到2047范围的使用2个字节编码,对于2048到65535的字符使用3个字节编码,大于65535的字符使用4个字节编码,而UTF16对于小于等于65535的Unicode字符统一使用2个字节编码,对于大于65535的Unicode字符统一使用4个字节编码。
如果只考虑大部分中文的编码,使用UTF8需要3个字节,使用UTF16需要2个字节,似乎UTF16编码效率更高,但现实中不是这样的,互联网传输的内容中有很大比例的字符,如所有的英文字符,仅仅使用UTF8下面的1个字节就可以编码,只有一小部分是需要UTF8下的三个字节编码,根据统计UTF8的编码效率要比UTF16的编码效率更高,这也是为什么UTF8使用更普遍的原因。
编程语言的编码说明
如果使用Unicode码点超过65535的字符做测试,如表情符号😀,前面已经知道该符号的码点是 128512,16进制表示为 0x1F600,使用编程语言类库获取该字符的长度如下:
● Java语言
对于上个世纪90年代出现的Java语言,一开始内部默认使用的是UTF16编码,如下如果我们使用Java中的字符串长度函数获取含有一个表情字符😀的字符串长度
System.out.println("😀".length());
最后输出结果为2,明明是1个字符为什么会输出2呢,这是因为JAVA语言的字符串内部默认采用的是UTF16编码,而Java基本类型 char(包装类型是java.lang.Character)的能够表示的范围是0到65535,也就是对于65535以内的Unicode字符,使用1个char(即2个字节)编码保存,获取字符串长度就会得到正确的值,但是对于Unicode码点大于65535的字符,内部一个char放不下,按照前文UTF16编码的规则会切割为2个char(即4个字节)编码保存,这时候调用获取字符串长度的函数就会输出2,这就是1个字符输出长度为2的原因,实际中应该注意。
因此如果我们的字符中含有Unicode码点大于65535的字符,比如对于一些含有表情符号的字符做处理的时候,如果要统计字符串的长度,就不能使用JAVA语言中的String类中的length方法,而应用使用下面统计Unicode码点数的方法java.lang.Character.codePointCount获取字符的数量,当然这是JAVA的历史遗留问题,实际中大多数情况下不会造成问题,因为绝大数情况下我们使用的字符Unicode编码值都是在65535以内的,很少遇到要计算大于Unicode码点值大于65535的字符的长度。
● Go语言
Go语言2009年出现的时候就默认使用的UTF8编码,所有就不存在上述提到的JAVA语言中的编码问题,如下通过下面的方法调用,最后输出结果为1,fmt.Println(utf8.RuneCountInString("😀")),在Go语言中把Unicode码点叫做Rune(翻译为某种神秘符号)。
Base64和Base64URL编码
最后简单提下Base64编码,虽然与Unicode编码关系不大,因为实际中比较常用,比如微服务鉴权JWT令牌。Base64是将二进制的数据转换为可见文本,一般用于HTTP传输不可见字符,64是指64个基本字符,包括A-Z、a-z、0-9、+/=,也就是在键盘上能看到的常用ASCII字符,64表示2的6次方,也就是把8位二进制的字节序列,按照6位比特位进行切割,每6位比特编码为1个可见字符,如果末尾不够6位比特,通过填充的方式处理。标准的Base64编码会使用如下64个字符ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/。
因为在Base64中的+和/在HTTP协议URL中都属于特殊字符,其中+号表示转义的空格,/表示URL路径分割符,所以如果使用标准的Base64编码作为URL参数传输就可能就会出现错误,所以就出现了Base64URL编码解决这个问题,Base64URL编码就是分别使用-和_分别替换标准Base64中的+和/字符,即Base64使用下面64个字符编码:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_。所以在微服务鉴权JWT令牌中使用的是Base64URL编码,而不是标准的Base64编码。
实际中的Base64有可能使用的既不是标准的Base64,也不是Base64URL,而是可以任意自定义顺序的Base64字符,如bcrypt中使用的Base64编码字符为:
./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789。
总结
本文介绍了计算机字符编码出现的原因,和所有计算机软件系统一样,计算机字符编码也是根据实际的需求不断迭代演进的,从最早的ASCII编码通过1个字节中的前7个比特位编码128个字符,后来出现了使用1个字节的所有8个比特位编码256个字符的ISO-8859-1(Latin1)编码,最后字符集不断变大,出现了一统天下的Unicode字符集,Unicode字符可以编码世界上所有的字符,但是Unicode字符在存储传输的过程中就会遇到切割转换的问题,因此出现了UTF编码用于Unicode编码的存储转换,这里面包括UTF8和UTF16编码,为了兼顾存储传输和处理效率,UTF8编码成为了我们今天编码的事实上的标准。最后结尾简单介绍了下Base64编码以及为什么使用Base64URL编码微服务JWT令牌的原因。作为开发者我们应该理解计算机字符编码背后的原理,以便在实践中更好的解决各种与编码有关的问题。