字节,字符,Unicode及Web编码
1. 字节
字节是一个具体的概念,它表示的就是实实在在的数据,这里之所以从它开始说起,是因为在与计算机相关的数据描述中,它是基本单位(与比特位是等价的)。8 位二进制的比特位为 1 个字节,比如:
0010 1101
方便些写成 16 进制:
2D
这里需要强调的是,字节本身是不具有任何意义的东西。它仅仅是数据,或者,也可以说,它仅仅是数字。就像你看到一堆单纯的数字,比如“1 4334 332 2129 98 212”,没有任何意义。
所以,自然地,原始的字节数据,需要在特定的上下文环境中,以特定的方式去解读它,才有意义。
比如上面的一个字节数据 0010 1101
,我们可以说,它表示了 8 个灯泡的开关状态,也可以说,它表示的是 45
这个整数。
(反过来多说一点,要表示 8 个灯泡的开关状态,或者要表示 45 这个整数,也不是只有“一个字节”这样的形式)
2. 字符
字符就是一个“汉字”,一个“字母”,是有具体意义的信息。从信息的层面来说,一串字符,跟一段声音,一块图片,没有本质的区别。这篇文章就是由一个一个的字符构成的。
现在先简单地考虑一个问题,一个一个的字符,如何使用字节,表示出来?
(我想到这里,字节与字符的概念,应该是区分得很明显了吧)
3. 从字符到字节的编码
编码 这个概念,其实不太好定义,可大可小。广义地来说,协议,流程,这些都是一种编码。狭义地讲, ASCII 这种具体的概念是一种编码。后面不会强调说的 编码 到底是指什么,了解内容就好。
字符是需要展现的信息,字节是唯一的载体。那么使用字节去表示字符,就需要去“定义上下文”,或者说,就需要去约定一套方法。
如果我们约定的方法是:
- 以数字对应英文字母,
40 -> a
,41 -> b
,42 -> c
,43 -> d
... - 每个数字,以直接的二进制形式,保存在 一个字节 中。
假设要处理的字符序列是 bcda
,它对应的数字就是 41 42 43 40
,对应的二进制是:
0010 1001 -> 41 -> 0x29 -> b 0010 1010 -> 42 -> 0x2A -> c 0010 1011 -> 43 -> 0x2B -> d 0010 1000 -> 40 -> 0x28 -> a
这里的 40 -> a
,是我们自己的规定,我们以此,把字符转成了字节,并且以相同的规定,面对字节时,可以知道它们表示的 字符信息 。比如你从任何地址,读取了 4 字节数据,它们是:
29 2A 2B 28
那么你就可以知道这 4 个字节表示的是 bcda
这 4 个字母。
其实一开始,事情就是这么简单的。大家约定一套规则,解决如何用字节来表示字符的问题,这套简单的规则就是 ASCII 。
根据 ASCII , bcda
这 4 个字母,对应的字节分别是:
0x62 -> 0110 0010 -> b 0x63 -> 0110 0011 -> c 0x64 -> 0110 0100 -> d 0x61 -> 0110 0001 -> a
ASCII 算是“过时”但是仍能正常工作(因为新标准会兼容它)的东西,所以,可以很容易直接验证它:
echo -n 'bcda' > a.bin vim -b a.bin (%!xxd) (二进制编辑之后, %!xxd -r)
4. Unicode是什么
讲 Unicode 之后,再看看 ASCII ,其实它里面包含了两部分的内容。
- 字符与“码”的对应关系。即
a -> 0x61
的这套“码表”。 - “码”在字节序中又怎么表示。即
a -> 91 -> 0110 0001
。这点上,可以说有一些“巧合”, ASCII 中的内容,只需要一个字节就可以保存,那简单地按整数的二进制形式存成字节序就好了。
当然,我想当初 ASCII 在设计之时,也并没有刻意地分别考虑这两部分,因为通常情况下,码表中的内码与字节的最终编码,是可以简单地对应起来,不需要单独地去再设计一套额外的编码机制,完全可以为了字节编码的方便,再去反着设定特定的“内码”,这种情况即使在有四字节情况的 GB18030 中,也是如此。
但是在 Unicode 中,情况就不是这样了,当然,不排除有一些历史原因在里面。 Unicode 是要处理这个星球上的所有字符,所以,它的“码表”直接就上四字节了(你可以理解成 Unicdoe 在设计这张“码表”时根本就没想过字节编码方面的事),这样简单啊。在字节编码上要一一对应也行,反正就是一个字符占四个字节。
于是,对于纯英文来说,以前一个字符占一个字节,现在需要用四个字节来存,空间浪费比较严重,很多人纠结了……,简单的一一对应的编码方式不好用, Unicode 的标准又不能返回去重造,那么,就单独来考虑字节编码的事吧。
(我不知道 Unicode 把内码和编码方式分开,是刻意设计还是历史自然演变,也许今天的它是折衷的一个结果。但是,把码表独立抽象出来,编码方式再单独设计,这种做法很漂亮,我个人是这样认为的。)
所以,我们说 Unicode 是标准,这里面是有两层含义的。
- Unicode 定义了一张超级大的“码表”,它包含了世界上的所有文字,这张“码表”,是标准。字符在这张表中的标识,可以叫做 内码。这个星球上的所有语言,构成这些语言的每一个字符,都可以在这张码表中查到对应的唯一内码。
- Unicode 定义了 一些 具体的编码方式,描述了从内码到字节的转换规则。比如 UTF-8, UCS-4 。
总结一下,字符,在不同的标准下,其实都首先有一个“内码”的,然后再通过具体的编码方式,变成字节的形式。只是除了 Unicode ,其它的像 GB18030 这些标准,它的“内码”和“编码方式”是一一对应的。
举个例子:
字符 | Unicode内码 | UTF-8 编码后的字节 | UTF-32BE 编码后的字节 | GB18030内码(字节) | |
---|---|---|---|---|---|
蠟 | 88 1F | E8 A0 9F | 00 00 88 1F | CF 9E | |
筆 | 7B 46 | E7 AD 86 | 00 00 7B 46 | B9 50 | |
小 | 5C 0F | E5 B0 8F | 00 00 5C 0F | D0 A1 | |
新 | 65 B0 | E6 96 B0 | 00 00 65 B0 | D0 C2 |
Unicode 的意义,是让各种不同的系统,在处理和字符相关的场景时,有了一种统一参照(内码)。所以,一般在系统的流程中,从入口获取的是字节数据,然后解码,得到字符信息。出口时,再把字符消息编码成字节。
5. 关于Python代码的简单说明
后面的代码示例,使用的是 Python 2.x 版本。
在 Python 2.x 版本中,有两个对象可以用来对应 字节 和 字符 这两个概念。
str
对象,对应 字节 :
# -*- coding: utf-8 -*- s1 = '一些文本内容' s2 = '\xef\xff\x09\x21' s3 = '\xefa'
s1
输入的是一些文字,实际保存时就是原始的字节形式(源文件中存的什么字节,它就是什么字节。比如你的源文件是 UTF-8 ,那这个 s1
就是一串 UTF-8 编码的字符中)。
s2
就是直观的字节形式了,每个字节以 \x
开头。
s3
也是直观的字节形式,不同的是, ASCII 范围内的字节,会转成对应的字符显示出来。(但是你输入时仍然可以用 \x
的形式)
unicode
对象,对应 字符 :
# -*- coding: utf-8 -*- u1 = u'一些文本内容' u2 = u'\u4e00\u4e9b\u6587\u672c\u5185\u5bb9' u3 = u'\U0001f419'
u1
中保存的,就是抽象的字符信息了,直接打印出来,就是 u2
中的那个样子,会显示每个字符的 Unicode 码。 u3
是四字节码的表示方式(高位的 000
不能省)。
不管是 str
还是 unicode
,被 print
时,都会自动转换成当前标准输出的对应字符显示( unicode
会被自动编码),要看“原始”的消息,可以把对象放到 list
中再 print
:
s1 = '一些文本内容' print [s1] u1 = u'一些文本内容' print [u1]
unicode
与 str
之间的转换,就是字符到字节的, 编码 及 解码 过程。
s1 = '一些文本内容' s1.decode('utf8') == u'一些文本内容' u1 = u'一些文本内容' u1.encode('utf8') == '一些文本内容' u'一些文内容'.encode('gb18030') != u'一些文本内容'.encode('utf8') u'一些文内容'.encode('gb18030').decode('gb18030') == u'一些文本内容'.encode('utf8').decode('utf8')
6. Web相关的编码问题
6.1. 一个简单的 HTTP Server
接下来,就是去挨个看看跟 Web 相关的种种编码问题了。为此,我们需要先做一个简单的 HTTP 应用服务器,以便可以清楚地了解到报文交互的一些细节。
这里使用 Python 的 Tornado 框架实现一个简单的应用服务。代码如下:
# -*- coding: utf-8 -*- import tornado.web import tornado.httpserver import tornado.ioloop class Handler(tornado.web.RequestHandler): def get(self): print self.request.headers print self.request.query self.finish(u'UTF-8 中文'.encode('utf-8')) def post(self): print self.request.headers print [self.request.body] self.finish(u'GBK 中文'.encode('gbk')) class JSHandler(tornado.web.RequestHandler): def get(self): self.finish('later...') def main(): application = tornado.web.Application([('/', Handler), ('/js', JSHandler)], debug=True) server = tornado.httpserver.HTTPServer(application) server.listen(8888) print 'Server is starting on 8888 ...' tornado.ioloop.IOLoop().current().start() if __name__ == '__main__': main()
上面代码的细节就不说了,实现上注意三点即可:
- 获取请求的头信息。
- 获取请求参数的原始字节信息(特别是 POST 方法的 body 部分的原始字节)。
- 写回连接时的字节内容自己控制。
6.2. 页面上涉及编码标识的地方
在进行实际的相关试验前,先试着列一下页面中与编码标识有关的几个地方:
- 请求响应头中的 Content-Type 。比如:
Content-Type: text/html; charset=utf-8
。 - HTML 中的相关 meta 标签,比如:
<meta charset="utf-8" />
。 - HTML 中的 javascript 标签,比如:
<script src="abc.js" charset="utf-8"></script>
然后,再考虑会被编码影响的几个地方:
- 页面的渲染,主要是 HTML 部分。
- 数据的获取与处理,比如 js 中通过 ajax 获取数据。
- 用户输入内容的编码,比如 input , textarea 这些获取用户输入的东西。
- 数据的原始 form 提交。
- 数据的 ajax 提交。
- ...
简单来说,就是获取与提交两个方面,所有涉及交互的地方,都会有编码问题。后面会尽量实际确认每种场景对于编码的处理到底是以一个什么方式来做的。
6.3. 单纯的HTML页面渲染
class Handler(tornado.web.RequestHandler): def get(self): s = u'''<!DOCTYPE html> <html> <head> <title>编码的问题</title> <meta charset="gbk"> </head> <body> 中文 </body> '''.encode('gbk') self.set_header('Content-Type', 'text/html; charset=gbk') self.finish(s)
上面的代码,单纯响应一段 HTML 内容。内容的原始字节是 gbk 编码的。经过实际试验,结论是:
对于 HTML 内容的渲染,编码上浏览器只认响应的 Content-Type 头。meta 标签的编码指定与 HTML 内容渲染无关(关于后面这点我不太确定,因为在不设置 Content-Type 情况下,即使把 meta 设置成 GBK ,返回的 UTF16 的内容仍然能正常显示)。
6.4. 原始的form提交
class Handler(tornado.web.RequestHandler): def get(self): s = u'''<!DOCTYPE html> <html> <head> <title>编码的问题</title> <meta charset="gbk"> </head> <body> <form action="/" method="post"> <input name="a" /> <button type="submit">提交</button> </form> </body> '''.encode('gbk') self.set_header('Content-Type', 'text/html; charset=gbk') self.finish(s) def post(self): print [self.request.body], '*' * 40 self.redirect('/')
上面的代码,会使用 HTML 传统的方式提交表单。经过操作,结论是:
HTML 中的传统表单提交,内容的编码是和页面编码保持一致的。
简单来说,如果页面编码(参见前一节)是 gbk ,那么你在 input 中输入了“中文”两个字,那么最后提交出去的内容是 a=%D6%D0%CE%C4
。而如果是 utf-8 编码,那么提交出去的内容就是 a=%E4%B8%AD%E6%96%87
。
Python 中要解百分号的这种 URLEncode ,可以使用 urllib
模块中的方法处理:
>>> import urllib >>> s = '%D6%D0%CE%C4' >>> print [urllib.unquote(s).decode('gbk')] [u'\u4e2d\u6587'] >>> s = '%E4%B8%AD%E6%96%87' >>> print [urllib.unquote(s).decode('utf8')] [u'\u4e2d\u6587']
注意,这里说的 form 的直接提交,只针对需要编码的内容,像 multipart/form-data 这种本来就不涉及编码的,原来是什么字节提交时还是什么字节。
6.5. encodeURIComponent的编码问题
在后面的和 js 有关的编码当中,要确定在浏览器环境中的 js 里的“字节和字符”问题,真不太好办,js 这块基本上是空白。
不过好在浏览器有一组和 URLEncode 相关的函数,可以得到那种百分号的编码形式,这样,就可以看到“字节形式”了。
最开始我们已经确认过 HTML 页面自己的渲染时编码问题,在此基础之上,加入 js ,并使用 encodeURIComponent
看看结果如何:
class Handler(tornado.web.RequestHandler): def get(self): s = u'''<!DOCTYPE html> <html> <head> <title>编码的问题</title> <meta charset="gbk"> </head> <body> <span>中文</span> <form method="post" action="/"> <input type="text" name="a" /> <button type="submit">提交</button> </form> <script type="text/javascript"> console.log( encodeURIComponent( document.getElementsByTagName('span')[0].innerHTML.replace(/\s/g, '') )); document.getElementsByTagName('input')[0].addEventListener('change', function(){ console.log( encodeURIComponent( document.getElementsByTagName('input')[0].value.replace(/\s/g, '') )); }); </script> </body> '''.encode('gbk') self.set_header('Content-Type', 'text/html; charset=gbk') self.finish(s) def post(self): print [self.request.body], '*' * 40 self.redirect('/')
操作的结论是:
encodeURIComponent 总是以字符的 UTF-8 编码的字节来进行 URL 编码。
6.6. js加载时的编码
class Handler(tornado.web.RequestHandler): def get(self): s = u'''<!DOCTYPE html> <html> <head> <title>编码的问题</title> <meta charset="gbk"> </head> <body> <span></span> <script type="text/javascript" src="/js" charset="utf-8"></script> </body> '''.encode('gbk') self.set_header('Content-Type', 'text/html; charset=gbk') self.finish(s) def post(self): print [self.request.body], '*' * 40 self.redirect('/') class JSHandler(tornado.web.RequestHandler): def get(self): s = u''' document.getElementsByTagName('span')[0].innerHTML = 'にほんご'; console.log(encodeURIComponent('にほんご')); '''.encode('sjis') self.set_header('Content-Type', 'text/javascript; charset=Shift_JIS') self.finish(s)
上面的代码,有两个地方涉及到了编码的指定,一是 js 响应本身的 Content-Type 头,二是 script
标签的 charset
属性。结论是:
js加载时的编码按以下优先级处理:1. 响应的 Content-Type 头;2. script 标签的 charset 属性;3. 页面编码。
这里多说一点, firebug 中对于响应内容的显示(查看某个请求的响应内容),固定按 utf-8 编码处理。 chrome 的调试工具能正确处理各种编码,只要指定正确。
6.7. 加载js后又使用XHR加载其它编码资源
class Handler(tornado.web.RequestHandler): def get(self): s = u'''<!DOCTYPE html> <html> <head> <title>编码的问题</title> <meta charset="gbk"> </head> <body> <span></span> <script type="text/javascript" src="/js" charset="utf-8"></script> </body> '''.encode('gbk') self.set_header('Content-Type', 'text/html; charset=gbk') self.finish(s) def post(self): s = u'<em>電腦</em>'.encode('big5') self.set_header('Content-Type', 'text/html; charset=big5') self.finish(s) class JSHandler(tornado.web.RequestHandler): def get(self): s = u''' var xhr = new XMLHttpRequest(); xhr.open('POST', '/', false); xhr.send(); document.getElementsByTagName('span')[0].innerHTML = xhr.responseText; '''.encode('sjis') self.set_header('Content-Type', 'text/javascript; charset=Shift_JIS') self.finish(s)
上的代码,是在一个 gbk 编码的内容中,加载了 Shift_JIS 编码的 js 文件,js 文件中又通过 XHR 获取了 big5 编码的内容,并把响应内容添加到原来的 gbk 编码的页面中。
结论:
XHR 获取的内容的编码,由响应中的 Content-Type 头指定,如果未指定,按 UTF-8 处理。
6.8. XHR提交数据时的编码
class Handler(tornado.web.RequestHandler): def get(self): s = u'''<!DOCTYPE html> <html> <head> <title>编码的问题</title> <meta charset="gbk"> </head> <body> <span></span> <script type="text/javascript" src="/js" charset="utf-8"></script> </body> '''.encode('gbk') self.set_header('Content-Type', 'text/html; charset=gbk') self.finish(s) def post(self): print [self.request.body], '*' * 40 s = u'<em>電腦</em>'.encode('big5') self.set_header('Content-Type', 'text/html; charset=big5') self.finish(s) class JSHandler(tornado.web.RequestHandler): def get(self): s = u''' var xhr = new XMLHttpRequest(); xhr.open('POST', '/', false); xhr.send('にほんご'); document.getElementsByTagName('span')[0].innerHTML = xhr.responseText; '''.encode('sjis') self.set_header('Content-Type', 'text/javascript; charset=Shift_JIS') self.finish(s)
上面的代码中,虽然 xhr.send()
那里是 Shift_JIS 编码,但是实际执行时,浏览器提交的内容却是 UTF-8 编码的。结论:
XHR提交的内容总是 UTF-8 编码的原始字节。
6.9. 再看form的原始提交
从前面几部分的内容来看,对于提交数据,不管是用 encodeURIComponent
自己作编码,或者是 xhr.send()
直接提交未编码内容,浏览器都是统一按 UTF-8 处理的。但是,对于直接的 form 原始提交方法,浏览器却是按页面编码来的,这相当尴尬。
然后我去翻文档,看 form 标签支持的属性,果然,有一个 accept-charset ,这东西我以前还从来没有用过(因为真用不着,我 UTF-8 一条路走到黑),这个属性可以指定 form 提交的数据使用何种编码。
class Handler(tornado.web.RequestHandler): def get(self): s = u'''<!DOCTYPE html> <html> <head> <title>编码的问题</title> <meta charset="gbk"> </head> <body> <form action="/" method="post" accept-charset="Shift_JIS"> <input name="a" /> <button type="submit">提交</button> </form> </body> '''.encode('gbk') self.set_header('Content-Type', 'text/html; charset=gbk') self.finish(s) def post(self): print [self.request.body], '*' * 40 self.redirect('/')
7. 总结
先说明一下,这篇文章所有的内容,及相关代码,仅限于反映了 PC 上的现代浏览器的表现。移动端和 IE 我不知道是什么样的结果。
需要搞明白的东西,前面应该已经都讲清楚了,所以,在一个大系统中,不同的项目使用了不同的编码,这不是什么问题哈,但是需要:
- 响应时,总是设置正确的 Content-Type ,包括编码。
- 服务器端处理请求时,总是以 UTF-8 编码对待提交内容。
- 如果要直接提交表单,不使用 ajax 方式,则在 form 标签中,使用
accept-charset
属性指定编码为 UTF-8 。