有感于,我们每天用各种的编辑器,嘴里喊着utf-8,BOM头,gbk,encode,decode,却鲜有人知道它们的由来和为什么这样做(好吧,也有可能就我一个人不知道)。最近找了很多资料,在这里做一个整理,和大家分享。
第一部分:关于Unicode,UTF8,Character Sets的前生今世(原创译文)
ASC II
总所周知计算机只能处理数字而不能处理字符,字符也总是使用数字进行表示,所以把哪些字符由哪些数字表示统一起来(而不是每台计算机有每台计算机的标准)非常重要。
比如说我的计算机用1表示A,2表示B,3表示C,诸如此类。而你的计算机用0表示A,1表示B,2表示C等等。那么当我发送给你一条内容为“HELLO”的消息时,数字8,5,12,12,15就通过光缆传输到了你那,但因为数字表示的字符不同,当你收到这串数字时,你会把它解码( decode )成“IFMMP”。所以为了有效的交流,我们必须对如何编码(encoding)这些字符达成一致。
终于,在十九世纪60年代,美国标准协会(American Standards Association)发明了7位(7-bit)编码方式,称为美国信息交换标准码(American Standard Code for Information Interchange),就是我们熟知的 ASCII 。在这种编码标准下,HELLO表示的数字是72,69,76,76,79,并且会以二进制1001000 1000101 1001100 1001100 1001111的形式进行传输。7位编码一共提供了128种可能,从0000000到1111111。那时所有的拉丁字符 的大小写,通用的标点,缩进,空格和其它一些控制符都能在ASCII中有一席之地。在1968年,美国总统Lyndon Johnson宣布所有的计算机必须使用和能读懂ASCII标准,ASCII成为官方标准。
8-bit
电报一类的工具当然乐于使用七位编码去传输信息,但是到了七十年代,计算机的处理器更乐意与2的次方打交道——他们已经可以用8位来存储字符,也就是说这将提供256种可能。
一个8位字符被存储的数字最大是255,但是ASCII标准最大只到127。这就意味着从128到255被空了出来。IBM用这些多余的数字来存储一些形状,希腊字母。比如200代表一个盒子的左下角╚,244代表小写的希腊字母 α 。这种编码方式的所有字符编码都列在 代码页(Code page)437中
但不像ASCII标准,128至255的字符序列从来都没有被标准化过。不同国家都用自己的字母表来填充这些多余的序列。不是所人都同意224代表 α ,甚至希腊人自己都有分歧,因为在希腊另一代码页737中,224代表小写 ω 。这个时期相当数量的新的代码页出现了,比如俄罗斯的IBM计算机使用的是代码页885,224代表的是西里尔(Cyrillic)字母 Я 。
即使在存在分歧的情况下,十九世纪八十年代微软也推出了自己的代码页,在西里尔文代码页Windows-1251中,224代表西里尔文字母 a ,而之前的 Я 的位置是223.
到了九十年代末期,大家做了一次标准化的尝试。15种8位字符集被推出,涵盖不同的字母表,比如西里尔文,阿拉伯文,希伯来文,土耳其文和泰文。它们被称为 ISO-8859-1 up to ISO-8859-16 (字母12被抛弃)。在西里尔标准ISO-8859-5中,224代表字母 р ,而 Я 的位置是207.
如果一位俄罗斯朋友发给你一份文档,你必须知道他使用的是哪一种字符集。文档本身只是数字序列而已,224代表的可能是Я,a 或者р。如果用错了字符集打开这份文档,那会是非常糟糕的一件事。
给1990年做个总结
在1990年左右的情况大致是这样的,文档可以用不同的语言书写,保存,并且在不同语言间交换。但是 你必须得知道他们用的是哪一种字符集 。当然不可能在一份文档中用多种语言。像中文和日文只能用完全不同的编码体系。
终于,互联网出现了!国际化和全球化让这个问题被放的更大,一个新的标准亟需出现。
Unicode来拯救人类了
从八十年代后期开始,一种新的标准已经被提出。它能给每一种语言中的每一个字符赋予唯一的标识,当然远远大于256.它被称为 Unicode ,至今为止它的版本是6.1,包括了超过110000个字符。
Unicode的头128个字符与ASCII一致。128至255则包含了货币符号,常用符号,还有 附加符号和变音符(accented characters) 。大部分都是借鉴自ISO-8859-1。在编号256之后,还有更多的变音符。在编号880之后开始进入希腊文字符集,然后是西里尔文,希伯来文,阿拉伯文,印度语,泰文。中文,日文和韩文从11904开始,其中也夹杂了其他的语言。
毫不含糊的说这的确是一件福利,每一个字符都由属于自己独一无二的数字表示。西里尔文的Я永远是1071,希腊文的α永远是945.而224永远是à,H仍然是72.注意官方书写的Unicode编码是以U+为开头的十六进制表示。所以H的正确写法应该是U+48而不是72(把十六进制转化为十进制:4*16+8=72)
最主要的问题是超出256的那部分。想当然8位已经容不下这些字符了。而Unicode并不是一个字符集或者是代码页。所以这也不是Unicode制定协会的问题。他们只是给出了一个解决问题的想法而剩下实际操作的问题则留给其他人去办了。下两节我们会讨论这个问题。
浏览器中的Unicode
8位已经容不下Unicode了,甚至16也已经容不下,虽然只有110116个字符真正被使用,但是已经定义字符已经升至了1114112个,这就意味着至少需要21位
从七十年代开始计算机已经变得非常先进了。八位的处理器早就过时了。现在的计算机已经拥有64位的处理器,所以我们为什么不可以把超出8位容纳范围的字符转移至32位或64位呢
我们当然可以这么做!
大部分的软件都是用C或者C++完成的,这两种语言支持一种名为"wide character"的数据类型。这种32为的字符数据类型被称为 wchar_t
,它是对C语言的8位数据类型char
的一种拓展。
从理论上来说,现代浏览器完全可以使用上面所说的这种字符类型,理论上它们就可以毫无压力的处理超过40亿个完全不同的字符,这对Unicode来说是莫大的喜讯——现代浏览器是可以使用Unicode的
UTF-8又来拯救人类了
既然浏览器可以应付32位的Unicode字符,那么还有什么问题? 现在的瓶颈是,字符的传输和读写。
使用Unicode的障碍仍然存在是因为:
- 相当数量的现存软件和各种协议都只能传输和读写8位字符
- 使用32位来存储或发送英文内容会让带宽变为原来的四倍
虽然浏览器内部对处理Unicode来说毫无压力,但你仍需从服务器端获取数据或者发送数据到服务器,你也需要把数据存在文件或者数据库中。所以你仍然需要用8位来存储110000个字符。
UTF-8 用一种很睿智的方式办到了。它的工作原理类似于键盘上的shift换挡键,一般来说你按下键盘上的H键打印出的是小写的h,但是如果按住shift键的同时按下H键,你就能打印出大写H
UTF-8把0至127留给ASCII,192至247作为换挡键(key),128至192作为被换挡的对象。举个例子,使用字符208和209能切换进入西里尔文字符范围,字符175后跟换挡键208对应符1071,也就是西里尔文中的Я,具体的计算方法是 (208%32)*64 + (175%64) = 1071。字符224至239是双倍换挡键。 字符190跟换挡键226,在加上字符128对应字符12160: ⾀ 字符240和之后的换挡键是三倍换挡键。
因此UTF-8被称为多字节(multi-byte)可变宽度(variable-width)编码,称之为多字节是因为比如Я这种的字符需要不止一个字节标识它,称之为可变宽度是因为一些像H这样的字符可能需要1个字节也可能需要4个字节。
Unicode还有一个好处在于它向前兼容ASCII。不像其他的一些解决方案,所以用纯ASCII编码的文档,也都能转为有效的UTF-8编码。这大大的解决了带宽和转化的麻烦。
给2003年做一个总结
UTF-8已经称为互联网最受欢迎的国际字符集。但当你浏览一份非英语语言文档时,你仍然需要知道它用的是什么字符集。为了能让所有的网页能最大范围的流通,网页所有者们必须保证他们的网页使用的是UTF-8编码。
许许多多的问题
如果每一个人使用都是UTF-8编码——非常好,天下太平。但事实并非如此,这便导致了一些混乱,想象一下一个非常典型的操作,一个用户给一篇博客添加评论:
- 网页显示一个供提交评论的表单
- 用户填写评论并且提交
- 评论被提交至服务器并且存在数据库中
- 该评论可能会从数据库中读出来并且展现在页面上
就这简简单单的流程会以很多方式走岔了,产生了以下这些问题:
HTML实体集(ENTITIES)
假设你对字符集一无所知,刚刚半小时看的东西全忘了。上面提到的那篇博客是用ISO-8859-1字符集显示的,字符集不能识别俄语,泰文或者中文,只能识别小部分希腊文。如果你把任意拷贝来的东西粘贴并且提交,现代浏览器会尝试把它们转化为HTML实体集,比如把Я转化为 Я
转化后的结果会存在数据库中,当评论被显示在网页上时是没有问题的,但是当你想把它导出为PDF或者输出在Email中,又或者在数据库中进行搜索时,问题就来了。
让人疑惑的字符
想象一下你有一个俄语网页,但是你忘了给你的网页指定字符集。有一个俄罗斯用户使用的默认字符集是ISO-8859-5。他想打一声招呼,"Hi",他会用键盘打出Привет,当这个用户提交时, 对字符的编码是根据的是发送页面的字符编码。 在这个例子中,Привет被编码成的序列是191,224,216,210,213,226。这些数字会通过网络传输到服务器,被存进数据库中
如果其他人也用ISO-8859-5字符集去查看这条评论,那么他能看到正确的文本。但是如果其他人使用的不同的俄罗斯字符集Windows-1251,他看的结果是їаШТХв,虽然仍然是俄文,但是已经完全没有意义了
英镑和版权符号
在英国一个非常普遍的问题是,英镑符号£被转成了£。英镑标志£在Unicode和ISO-8859-1中的代码都是163.回忆一下在UTF-8中任何大于127的字符被表示是至少需要两个数字。在这种情况下,在UTF-8的序列应该是194/163,因为这个序列计算的结果(194%32)*64 + (163%64) = 163
这样看来,如果你用ISO-8859-1字符集来阅读UTF-8序列,它的前面必然会多一个Â,也就是ISO-8859-1中的194.同样的事情也会发生在所有的Unicode序列为161至191的字符上,包括©,®, ¥
带有问号的黑钻石
相反,如果把俄文Привет用ISO-8859-5来保存,他储存的序列就是191,224等。如果你尝试用UTF-8打开,你会发现很多带有问号的黑钻石�。不像其他多字节(multi-byte)字符编码,你总是能知道你在UTF-8的哪个位置。当你看见一个数字介于192-247之间,你就知道你在一个多字节序列的头,如果你看到的数字位于128-191之间,你就知道在一个多字节序列的中间。
但这也就意味着191后跟224这种情况是不可能出现的,于是浏览器不知道该怎么办了,于是显示了��。
这个问题也可能出现在£带有©的词中。在ISO-8859-1中 £50代表的数字序列是163,53和48。53和48都不会引起问题,但是163从来都不会独自出现,于是在UTF-8中就会显示�50。同理如果你看见了�2012,八成是因为©2012在 ISO-8859-1中被输入,而在UTF-8中显示
空,问号和方形
即使你使用全部都是UTF-8编码,浏览器也有可能出现不知如何正确一个字符的情况,ASCII字符的1-31大多数是用来控制电报(teleprinters)传送的(比如收到(Acknowledge )和停止(Stop)),如果你想尝试显示它们,浏览器可能会打印出一个问号,空或者一个带有数字的方形
需要注意的还有,因为Unicode定义了超过110000个字符, 你的浏览器不可能有所有的字体来显示它们 。有一些字体缺省的字符就会显示问号,空或者方形。在一些古老的浏览器中,通常只要不是英文字符就会被显示方形。
数据库
上面的讨论都忽略一个步骤,就是把数据存进数据库中。类似MySQL的数据库都能对数据库,表,列指定字符集。但是这些指定都没有对网页指定字符集重要。
当从数据库中读和写数据时,MySQL实际上只是和数字打交道。如果你告诉储存163,它就这么做。如果你给他208/159,那么它就储存这两个数字。当你从数据库读数据时,你读出的也是同样的数据。
字符集对于数据库重要的地方在于,当你使用数据库函数进行比较,转化,测量数据的时候。比如一个字段的`LENGTH`属性就依赖它的字符集,用`LIKE`和`=`进行比较的时候也是。尤其是对两个字符串进行定序(collation)比较时。详细情况可以参考这篇博客
一个解决方案
上面所有的问题都是因为提交和读取使用的字符集不一致引起的。所以解决之道是确保你的每一个网页使用的都是UTF-8编码 。你可以在你的 <head>
标签后添加:
<metacharset="UTF-8"><metahttp-equiv="Content-type"content="text/html; charset=UTF-8">
这必须是你在制作网页时候的第一件事,它会让浏览器重新审视网页的代码。考虑到及时和效率,这个动作必须越早完成越好。你也可以给你的数据库指定UTF-8编码
需要注意的是,用户是可以用他们的浏览器编码中覆盖你的网页比编码,这种情况非常少,但是也说明这个解决方案并不是万能的。为了安全起见,你可以再网站后端对用户的输入进行编码校验。
已有的网站
如果你的网页已经海纳了百国语言,你需要把这些数据都转为UTF-8编码。如果数量不多,你可以在网页中把这些个字符找出来,用浏览器把这些数据转为UTF-8编码
第二部分:我遇见的编码问题
相信上面的文章已经可以解决了很多疑惑很问题了,我还想聊几个我遇到的问题。
印象中Mac下的terminal显示字符用的就是Unicode编码,所以使用命令行时基本不会遇见编码问题。但是在Window下的无论是cmd就悲剧了。
先把下面的文本存在window下的记事本中,比如叫text.txt中,供测试使用
ASCII abcde xyz German äöüÄÖÜß Polish ąęźżńł Russian абвгдежэюя CJK 你好
首先,如果你用原生的记事本工具编辑肯定是不能把上面的文本保存在记事本中,默认记事本使用的ASCII编码,它会提示你另存一份Unicode版本。我们当然需要使用的是这个Unicode版本。
在CMD中查看文本内容 type text.txt
,你看到的结果是
ASCII abcde xyz German 盲枚眉脛脰脺脽 Polish 膮臋藕偶艅艂 Russian 邪斜胁谐写械卸褝褞褟 CJK 浣犲ソ
为什么输出的全是乱码? 综上一节所述,原因无非是下面两个
- 使用的编码不正确
- 即使是编码正确,也可能CMD的字体没能显示某个字符
首先看第一个问题:CMD使用的编码是什么?
输入命令 chcp
(change code page),结果是 活动页代码936
。这是罪魁祸首之一。code page 936是什么?我们在维基百科上可以找到答案:
936– GBK SupportsSimplifiedChinese
没错,cmd默认使用的编码竟然是GBK——GBK是什么?GB这两个字母代表“国标”二字拼音首字母,你们感受一下吧……
据我stackoverflow的结果,cmd在不同语言中使用的是当地语言的编码,而不是Unicode。
那么我们可以把它的编码改为Utf-8吗?当然可以,执行命令 chcp 65001
,更多code page代码可以从上面的那个维基百科页面上找到。
让我们再用cmd查看文本结果:
S C I I a b c d e x y z G e r m a n äöüÄÖÜß P o l i s h
为什么还是有问题?因为刚刚说过的字体。
此时需要改变cmd的字体,在cmd窗体标题单击鼠标右键,选择属性,在字体中选择Lucida Console
此时再进行打印,你就能看到正确的结果了。
如果你有python开发经验的话,在python文件也需要注明文件的编码(至少在3.0版本以前需要这么做):
# -*- coding: utf-8 -*-
作用与在html文件head中声明的作用是一样的。让编译器重新审视文件编码
即使如此,当你在python直接定义一个包含字符串的变量并且尝试打印时,结果依然是乱码:
# -*- coding: utf-8 -*-
str ="测试"print str // 娴嬭瘯
其实你看到是已经被UTF-8编码过的字符串,然后又被控制台显示出来。如果你希望显示正确,必须再经过解码
print str.encode("utf-8")// 测试
当然最好的办法在定义时就定义为Unicode编码
str = u"测试"
其实Unicode还有很多地方可以聊,比如UTF-16 LE与UTF-16 BE,也就是big endian和little endian等等。但这就要开始牵扯更深,比如CPU,进制转换等,这些就不是我所擅长的范围了。在文章最后悔会给出一些文章链接,有兴趣的同学可以继续深入研究。
URL encoding
在谈URL encode之前,先要了解一下URL的语法。
你可能会觉得奇怪URL还有语法?有当然有。
一般我们接触的网页URL比如像 http://qingbob.com
,语法(以下的翻译可能不准确)非常简单:
协议(Scheme) | http |
主机地址(Host address) | qingbob.com |
但一个包含了完整语法的URL应该是这个样子的:
https://bob:bobby@qingbob.com:8080/file;p=1?q=2#third
对应的语法应该是
协议(Scheme) | https |
用户(User) | bob |
密码(Password) | bobby |
主机地址(Host address) | qingbob.com |
端口(Port) | 8080 |
路径(Path) | /file |
路径参数(Path parameters) | p=1 |
查询参数(Query parameters) | q=2 |
片段(Fragment) | third |
更详细的图解可以参考维基百科
看过了上面的语法,你大概已经回忆起十之八九,单个部分就不详解了,直接进入URL语法。
URL语法定义了如何把URL的各个部分组装起来,以及如何把他们区分开来。比如://
符号就是把协议与主机名区分开;主机名与路径用 /
区分开;查询参数只能跟在 ?
之后。这也就意味着有一些符号作为保留字为语法服务。可能有的符号在整个URL中都作为保留字,有的只是在某个部分作为保留字。 一旦某个保留字在它不应该出现的地方出现了(比如在文件名中出现了一个 ?
)。这个字符就需要被编码(encode)。
URL编码就是把一个字符转变成另一种对URL无损的表现形式,并且让它失去URL语法含义。通过字符编码(character encoding)把这个字符转化为十六进制,并且以百分号%开头,比如一个问号经过URL编码之后就是 %3F
。
想象我们有一条指向某张图片的URL: http://example.com/to_be_or_not_to_be?.jpg
。转化之后的结果就是 http://example.com/to_be_or_not_to_be%3F.jpg
,这样就避免了URL中有歧义的问号之后的查询语法。
一些陷阱
不同部分的不同保留字
一个空格在路径参数(path fragment)部分会被编码成 %20
(绝不是"+"),而 +
在参数路径部分则可以保留而不被编码。
而在查询部分(query)中,空格会被编码成"+"(为了向前兼容),或者"%20",而"+"会被编码成"%2B"。
举个例子,同样的字符串 blue+light blue
在不同的部分会被编码成不同样子:
var url ="http://example.com/blue+light blue?blue+light blue"
encodeURL(url)// http://example.com/blue+light%20blue?blue%2Blight+blue
保留字不是你想的那样
大部分人都忽略了"+"在路径部分是可以被允许的。还有一些其他的例子:
- "?"在查询部分可以允许不被转义的(unescaped)
- "/"在查询部分可以允许不被转义
- "="在路径参数或者查询参数部分可以允许不被转义
- ":@-._~!$&'()*+,;="在路径片段部分可以允许不被转义
- "/?:@-._~!$&'()*+,;="在片段部分可以允许不被转义
也就是说下面这个URL片段,是不会被编码是合法的:
http://qingbob.com/:@-._~!$&'()*+,=;:@-._~!$&'()*+,=:@-._~!$&'()*+,==?/?:@-._~!$'()*+,;=/?:@-._~!$'()*+,;
我们把上面这条URL好好总结一下:
协议(Scheme) | https |
主机地址(Host address) | qingbob.com |
路径(Path) | /:@-._~!$&'()*+,= |
路径参数名称(Path parameters name) | :@-._~!$&'()*+, |
路径参数值(Path parameters value) | :@-._~!$&'()*+,== |
查询参数名称(Query parameters name) | /?:@-._~!$'()* ,; |
查询参数值(Query parameters value) | /?:@-._~!$'()* ,;== |
片段(Fragment) | /?:@-._~!$&'()*+,;= |
URL在编码之后是不能被分析的
URL语法只在用来读被编码之前的URL。
假设我们有这么一条URL http://example.com/blue%2Fred%3Fand+green
,分析结果如下
协议(Scheme) | http |
主机(Host) | example.com |
路径碎片(Path segment) | blue%2Fred%3Fand+green |
解码之后的路径碎片(Decoded Path segment) | blue/red?and+green |
这样翻译的结果就成了,我们在寻找一个名为"blue/red?and+green"的文件。
但是如果我们在解码之后再进行翻译 http://example.com/blue/red?and+green
:
协议(Scheme) | http |
主机(Host) | example.com |
路径碎片(Path segment) | blue |
路径碎片(Path segment) | red |
查询参数名称(Query parameter name) | and green |
翻译的结果是,在"blue"文件夹中的名为"red?and+green"的文件。
明显结果就完全不同了,对URL语法进行分析一定要在编码之前进行进行。