Dive Into Python3 中文版(四)
Chapter 13 序列化 Python 对象
" Every Saturday since we’ve lived in this apartment, I have awakened at 6:15, poured myself a bowl of cereal, added a quarter-cup of 2% milk, sat on this end of this couch, turned on BBC America, and watched Doctor Who. " — Sheldon, The Big Bang Theory
深入
序列化的概念很简单。内存里面有一个数据结构,你希望将它保存下来,重用,或者发送给其他人。你会怎么做?嗯, 这取决于你想要怎么保存,怎么重用,发送给谁。很多游戏允许你在退出的时候保存进度,然后你再次启动的时候回到上次退出的地方。(实际上, 很多非游戏程序也会这么干。) 在这个情况下, 一个捕获了当前进度的数据结构需要在你退出的时候保存到磁盘上,接着在你重新启动的时候从磁盘上加载进来。这个数据只会被创建它的程序使用,不会发送到网络上,也不会被其它程序读取。因此,互操作的问题被限制在保证新版本的程序能够读取以前版本的程序创建的数据。
在这种情况下,pickle 模块是理想的。它是 Python 标准库的一部分, 所以它总是可用的。它很快; 它的大部分同 Python 解释器本身一样是用 C 写的。 它可以存储任意复杂的 Python 数据结构。
什么东西能用pickle模块存储?
- 所有 Python 支持的 原生类型 : 布尔, 整数, 浮点数, 复数, 字符串,
bytes(字节串)对象, 字节数组, 以及None. - 由任何原生类型组成的列表,元组,字典和集合。
- 由任何原生类型组成的列表,元组,字典和集合组成的列表,元组,字典和集合(可以一直嵌套下去,直至Python 支持的最大递归层数).
- 函数,类,和类的实例(带警告)。
如果这还不够用,pickle模块也是可扩展的。如果你对可扩展性有兴趣,请查看本章最后的进一步阅读小节中的链接。
本章例子的快速笔记
本章会使用两个 Python Shell 来讲故事。本章的例子都是一个单独的故事的一部分。当我演示pickle 和 json 模块时,你会被要求在两个 Python Shell 中来回切换。
为了让事情简单一点,打开 Python Shell 并定义下面的变量:
>>> shell = 1
保持该窗口打开。 现在打开另一个 Python Shell 并定义下面下面的变量:
>>> shell = 2
贯穿整个章节, 在每个例子中我会使用shell变量来标识使用的是哪个 Python Shell。
保存数据到 Pickle 文件
pickle模块的工作对象是数据结构。让我们来创建一个:
1
>>> entry['title'] = 'Dive into history, 2009 edition'
>>> entry['article_link'] = 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'
>>> entry['comments_link'] = None
>>> entry['internal_id'] = b'\xDE\xD5\xB4\xF8'
>>> entry['tags'] = ('diveintopython', 'docbook', 'html')
>>> entry['published'] = True
>>> import time
>>> entry['published_date']
time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1)
- 在 Python Shell #1 里面。
- 想法是建立一个 Python 字典来表示一些有用的东西,比如一个 Atom 供稿的 entry。但是为了炫耀一下
pickle模块我也想保证里面包含了多种不同的数据类型。不需要太关心这些值。 time模块包含一个表示时间点(精确到 1 毫秒)的数据结构(time_struct)以及操作时间结构的函数。strptime()函数接受一个格式化过的字符串并将其转化成一个time_struct。这个字符串使用的是默认格式,但你可以通过格式化代码来控制它。查看time模块来获得更多细节。
这是一个很帅的 Python 字典。让我们把它保存到文件。
1
>>> import pickle
...
- 仍然在 Python Shell #1 中。
- 使用
open()函数来打开一个文件。设置文件模式为'wb'来以二进制写模式打开文件。把它放入with语句中来保证在你完成的时候文件自动被关闭。 pickle模块中的dump()函数接受一个可序列化的 Python 数据结构, 使用最新版本的 pickle 协议将其序列化为一个二进制的,Python 特定的格式, 并且保存到一个打开的文件里。
最后一句话很重要。
pickle模块接受一个 Python 数据结构并将其保存的一个文件。- 要做到这样,它使用一个被称为“pickle 协议”的东西序列化该数据结构。
- pickle 协议是 Python 特定的,没有任何跨语言兼容的保证。你很可能不能使用 Perl, PHP, Java, 或者其他语言来对你刚刚创建的
entry.pickle文件做任何有用的事情。 - 并非所有的 Python 数据结构都可以通过
pickle模块序列化。随着新的数据类型被加入到 Python 语言中,pickle 协议已经被修改过很多次了,但是它还是有一些限制。 - 由于这些变化,不同版本的 Python 的兼容性也没有保证。新的版本的 Python 支持旧的序列化格式,但是旧版本的 Python 不支持新的格式(因为它们不支持新的数据类型)。
- 除非你指定,
pickle模块中的函数将使用最新版本的 pickle 协议。这保证了你对可以被序列化的数据类型有最大的灵活度,但这也意味着生成的文件不能被不支持新版 pickle 协议的旧版本的 Python 读取。 - 最新版本的 pickle 协议是二进制格式的。请确认使用二进制模式来打开你的 pickle 文件,否则当你写入的时候数据会被损坏。
从 Pickle 文件读取数据
现在切换到你的第二个 Python Shell — 即不是你创建entry字典的那个。
2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'entry' is not defined
>>> import pickle
...
{'comments_link': None,
'internal_id': b'\xDE\xD5\xB4\xF8',
'title': 'Dive into history, 2009 edition',
'tags': ('diveintopython', 'docbook', 'html'),
'article_link':
'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
'published': True}
- 这是 Python Shell #2.
- 这里没有
entry变量被定义过。你在 Python Shell #1 中定义了entry变量, 但是那是另一个拥有自己状态的完全不同的环境。 - 打开你在 Python Shell #1 中创建的
entry.pickle文件。pickle模块使用二进制数据格式,所以你总是应该使用二进制模式打开 pickle 文件。 pickle.load()函数接受一个流对象, 从流中读取序列化后的数据,创建一个新的 Python 对象,在新的 Python 对象中重建被序列化的数据,然后返回新建的 Python 对象。- 现在
entry变量是一个键和值看起来都很熟悉的字典。
pickle.dump() / pickle.load()循环的结果是一个和原始数据结构等同的新的数据结构。
1
...
True
False
('diveintopython', 'docbook', 'html')
>>> entry2['internal_id']
b'\xDE\xD5\xB4\xF8'
- 切换回 Python Shell #1。
- 打开
entry.pickle文件。 - 将序列化后的数据装载到一个新的变量,
entry2。 - Python 确认两个字典,
entry和entry2是相等的。在这个 shell 里, 你从零开始构造了entry, 从一个空字典开始然后手工给各个键赋值。你序列化了这个字典并将其保存在entry.pickle文件中。现在你从文件中读取序列化后的数据并创建了原始数据结构的一个完美复制品。 - 相等和相同是不一样的。我说的是你创建了原始数据结构的一个完美复制品, 这没错。但它仅仅是一个复制品。
- 我要指出
'tags'键对应的值是一个元组,而'internal_id'键对应的值是一个bytes对象。原因在这章的后面就会清楚了。
不使用文件来进行序列化
前一节中的例子展示了如果将一个 Python 对象序列化到磁盘文件。但如果你不想或不需要文件呢?你也可以序列化到一个内存中的bytes对象。
>>> shell
1
<class 'bytes'>
True
pickle.dumps()函数(注意函数名最后的's')执行和pickle.dump()函数相同的序列化。取代接受流对象并将序列化后的数据保存到磁盘文件,这个函数简单的返回序列化的数据。- 由于 pickle 协议使用一个二进制数据格式,所以
pickle.dumps()函数返回bytes对象。 pickle.loads()函数(再一次, 注意函数名最后的's') 执行和pickle.load()函数一样的反序列化。取代接受一个流对象并去文件读取序列化后的数据,它接受包含序列化后的数据的bytes对象, 比如pickle.dumps()函数返回的对象。- 最终结果是一样的: 原始字典的完美复制。
字节串和字符串又一次抬起了它们丑陋的头。
pickle 协议已经存在好多年了,它随着 Python 本身的成熟也不断成熟。现在存在四个不同版本 的 pickle 协议。
- Python 1.x 有两个 pickle 协议,一个基于文本的格式(“版本 0”) 以及一个二进制格式(“版本 1”).
- Python 2.3 引入了一个新的 pickle 协议(“版本 2”) 来处理 Python 类对象的新功能。它是一个二进制格式。
- Python 3.0 引入了另一个 pickle 协议 (“版本 3”) ,显式的支持
bytes对象和字节数组。它是一个二进制格式。
你看, 字节串和字符串的区别又一次抬起了它们丑陋的头。 (如果你觉得惊奇,你肯定开小差了。) 在实践中这意味着, 尽管 Python 3 可以读取版本 2 的 pickle 协议生成的数据, Python 2 不能读取版本 3 的协议生成的数据.
调试 Pickle 文件
pickle 协议是长什么样的呢?让我们离开 Python Shell 一会会,来看一下我们创建的entry.pickle文件。
you@localhost:~/diveintopython3/examples$ ls -l entry.pickle
-rw-r--r-- 1 you you 358 Aug 3 13:34 entry.pickle
you@localhost:~/diveintopython3/examples$ cat entry.pickle
comments_linkqNXtagsqXdiveintopythonqXdocbookqXhtmlq?qX publishedq?
XlinkXJhttp://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition
q Xpublished_dateq
ctime
struct_time
?qRqXtitleqXDive into history, 2009 editionqu.
这不是很有用。你可以看见字符串,但是其他数据类型显示为不可打印的(或者至少是不可读的)字符。域之间没有明显的分隔符(比如跳格符或空格)。你肯定不希望来调试这样一个格式。
>>> shell
1
>>> import pickletools
>>> with open('entry.pickle', 'rb') as f:
... pickletools.dis(f)
0: \x80 PROTO 3
2: } EMPTY_DICT
3: q BINPUT 0
5: ( MARK
6: X BINUNICODE 'published_date'
25: q BINPUT 1
27: c GLOBAL 'time struct_time'
45: q BINPUT 2
47: ( MARK
48: M BININT2 2009
51: K BININT1 3
53: K BININT1 27
55: K BININT1 22
57: K BININT1 20
59: K BININT1 42
61: K BININT1 4
63: K BININT1 86
65: J BININT -1
70: t TUPLE (MARK at 47)
71: q BINPUT 3
73: } EMPTY_DICT
74: q BINPUT 4
76: \x86 TUPLE2
77: q BINPUT 5
79: R REDUCE
80: q BINPUT 6
82: X BINUNICODE 'comments_link'
100: q BINPUT 7
102: N NONE
103: X BINUNICODE 'internal_id'
119: q BINPUT 8
121: C SHORT_BINBYTES '脼脮麓酶'
127: q BINPUT 9
129: X BINUNICODE 'tags'
138: q BINPUT 10
140: X BINUNICODE 'diveintopython'
159: q BINPUT 11
161: X BINUNICODE 'docbook'
173: q BINPUT 12
175: X BINUNICODE 'html'
184: q BINPUT 13
186: \x87 TUPLE3
187: q BINPUT 14
189: X BINUNICODE 'title'
199: q BINPUT 15
201: X BINUNICODE 'Dive into history, 2009 edition'
237: q BINPUT 16
239: X BINUNICODE 'article_link'
256: q BINPUT 17
258: X BINUNICODE 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'
337: q BINPUT 18
339: X BINUNICODE 'published'
353: q BINPUT 19
355: \x88 NEWTRUE
356: u SETITEMS (MARK at 5)
357: . STOP
<mark>highest protocol among opcodes = 3</mark>
这个反汇编中最有趣的信息是最后一行, 因为它包含了文件保存时使用的 pickle 协议的版本号。在 pickle 协议里面没有明确的版本标志。为了确定保存 pickle 文件时使用的协议版本,你需要查看序列化后的数据的标记(“opcodes”)并且使用硬编码的哪个版本的协议引入了哪些标记的知识(来确定版本号)。pickle.dis()函数正是这么干的,并且它在反汇编的输出的最后一行打印出结果。下面是一个不打印,仅仅返回版本号的函数:
[下载 pickleversion.py]
import pickletools
def protocol_version(file_object):
maxproto = -1
for opcode, arg, pos in pickletools.genops(file_object):
maxproto = max(maxproto, opcode.proto)
return maxproto
实际使用它:
>>> import pickleversion
>>> with open('entry.pickle', 'rb') as f:
... v = pickleversion.protocol_version(f)
>>> v
3
序列化 Python 对象以供其它语言读取
pickle模块使用的数据格式是 Python 特定的。它没有做任何兼容其它编程语言的努力。如果跨语言兼容是你的需求之一,你得去寻找其它的序列化格式。一个这样的格式是JSON。 “JSON” 代表 “JavaScript Object Notation,” 但是不要让名字糊弄你。 — JSON 是被设计为跨语言使用的。
Python 3 在标准库中包含了一个 json模块。同 pickle模块类似, json模块包含一些函数,可以序列化数据结构,保存序列化后的数据至磁盘,从磁盘上读取序列化后的数据,将数据反序列化成新的 Pythone 对象。但两者也有一些很重要的区别。 首先, JSON 数据格式是基于文本的, 不是二进制的。RFC 4627 定义了 JSON 格式以及怎样将各种类型的数据编码成文本。比如,一个布尔值要么存储为 5 个字符的字符串'false',要么存储为 4 个字符的字符串 'true'。 所有的 JSON 值都是大小写敏感的。
第二,由于是文本格式, 存在空白(whitespaces)的问题。 JSON 允许在值之间有任意数目的空白(空格, 跳格, 回车,换行)。空白是“无关紧要的”,这意味着 JSON 编码器可以按它们的喜好添加任意多或任意少的空白, 而 JSON 解码器被要求忽略值之间的任意空白。这允许你“美观的打印(pretty-print)” 你的 JSON 数据, 通过不同的缩进层次嵌套值,这样你就可以在标准浏览器或文本编辑器中阅读它。Python 的 json 模块有在编码时执行美观打印(pretty-printing)的选项。
第三, 字符编码的问题是长期存在的。JSON 用纯文本编码数据, 但是你知道, “不存在纯文本这种东西。” JSON 必须以 Unicode 编码(UTF-32, UTF-16, 或者默认的, UTF-8)方式存储, RFC 4627 的第三部分 定义了如何区分使用的是哪种编码。
将数据保存至 JSON 文件
JSON 看起来非常像你在 Javascript 中手工定义的数据结构。这不是意外; 实际上你可以使用 JavaScript 的eval()函数来“解码” JSON 序列化过的数据。(通常的对非信任输入的警告 也适用, 但关键点是 JSON 是 合法的 JavaScript。) 因此, 你可能已经熟悉 JSON 了。
>>> shell
1
>>> basic_entry['id'] = 256
>>> basic_entry['title'] = 'Dive into history, 2009 edition'
>>> basic_entry['tags'] = ('diveintopython', 'docbook', 'html')
>>> basic_entry['published'] = True
>>> basic_entry['comments_link'] = None
>>> import json
- 我们将创建一个新的数据结构,而不是重用现存的
entry数据结构。在这章的后面, 我们将会看见当我们试图用 JSON 编码更复杂的数据结构的时候会发生什么。 - JSON 是一个基于文本的格式, 这意味你可以以文本模式打开文件,并给定一个字符编码。用 UTF-8 总是没错的。
- 同
pickle模块一样,json模块定义了dump()函数,它接受一个 Python 数据结构和一个可写的流对象。dump()函数将 Python 数据结构序列化并写入到流对象中。在with语句内工作保证当我们完成的时候正确的关闭文件。
那么生成的 JSON 序列化数据是什么样的呢?
you@localhost:~/diveintopython3/examples$ cat basic.json
{"published": true, "tags": ["diveintopython", "docbook", "html"], "comments_link": null,
"id": 256, "title": "Dive into history, 2009 edition"}
这肯定比 pickle 文件更可读。然而 JSON 的值之间可以包含任意数目的空把, 并且json模块提供了一个方便的途径来利用这一点生成更可读的 JSON 文件。
>>> shell
1
>>> with open('basic-pretty.json', mode='w', encoding='utf-8') as f:
- 如果你给
json.dump()函数传入indent参数, 它以文件变大为代价使生成的 JSON 文件更可读。indent参数是一个整数。0 意味着“每个值单独一行。” 大于 0 的数字意味着“每个值单独一行并且使用这个数目的空格来缩进嵌套的数据结构。”
这是结果:
you@localhost:~/diveintopython3/examples$ cat basic-pretty.json
{
"published": true,
"tags": [
"diveintopython",
"docbook",
"html"
],
"comments_link": null,
"id": 256,
"title": "Dive into history, 2009 edition"
}
将 Python 数据类型映射到 JSON
由于 JSON 不是 Python 特定的,对应到 Python 的数据类型的时候有很多不匹配。有一些仅仅是名字不同,但是有两个 Python 数据类型完全缺少。看看你能能把它们指出来:
| 笔记 | JSON | Python 3 |
|---|---|---|
| object | dictionary | |
| array | list | |
| string | string | |
| integer | integer | |
| real number | float | |
| * | true | True |
| * | false | False |
| * | null | None |
| * | 所有的 JSON 值都是大小写敏感的。 |
注意到什么被遗漏了吗?元组和 & 字节串(bytes)! JSON 有数组类型, json 模块将其映射到 Python 的列表, 但是它没有一个单独的类型对应 “冻结数组(frozen arrays)” (元组)。而且尽管 JSON 非常好的支持字符串,但是它没有对bytes 对象或字节数组的支持。
序列化 JSON 不支持的数据类型
即使 JSON 没有内建的字节流支持, 并不意味着你不能序列化bytes对象。json模块提供了编解码未知数据类型的扩展接口。(“未知”的意思是≴JSON 没有定义”。很显然json 模块认识字节数组, 但是它被 JSON 规范的限制束缚住了。) 如果你希望编码字节串或者其它 JSON 没有原生支持的数据类型,你需要给这些类型提供定制的编码和解码器。
>>> shell
1
{'comments_link': None,
'internal_id': b'\xDE\xD5\xB4\xF8',
'title': 'Dive into history, 2009 edition',
'tags': ('diveintopython', 'docbook', 'html'),
'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
'published': True}
>>> import json
...
Traceback (most recent call last):
File "<stdin>", line 5, in <module>
File "C:\Python31\lib\json\__init__.py", line 178, in dump
for chunk in iterable:
File "C:\Python31\lib\json\encoder.py", line 408, in _iterencode
for chunk in _iterencode_dict(o, _current_indent_level):
File "C:\Python31\lib\json\encoder.py", line 382, in _iterencode_dict
for chunk in chunks:
File "C:\Python31\lib\json\encoder.py", line 416, in _iterencode
o = _default(o)
File "C:\Python31\lib\json\encoder.py", line 170, in default
raise TypeError(repr(o) + " is not JSON serializable")
<mark>TypeError: b'\xDE\xD5\xB4\xF8' is not JSON serializable</mark>
- 好的, 是时间再看看
entry数据结构了。它包含了所有的东西: 布尔值,None值,字符串,字符串元组,bytes对象, 以及time结构体。 - 我知道我已经说过了,但是这值得再重复一次:JSON 是一个基于文本的格式。总是应使用 UTF-8 字符编码以文本模式打开 JSON 文件。
- 嗯,这可不好。发生什么了?
情况是这样的: json.dump() 函数试图序列化bytes对象 b'\xDE\xD5\xB4\xF8',但是它失败了,原因是 JSON 不支持bytes对象。然而, 如果保存字节串对你来说很重要,你可以定义自己的“迷你序列化格式。”
- 为了给一个 JSON 没有原生支持的数据类型定义你自己的“迷你序列化格式”, 只要定义一个接受一个 Python 对象为参数的函数。这个对象将会是
json.dump()函数无法自己序列化的实际对象 — 这个例子里是bytes对象b'\xDE\xD5\xB4\xF8'。 - 你的自定义序列化函数应该检查
json.dump()函数传给它的对象的类型。当你的函数只序列化一个类型的时候这不是必须的,但是它使你的函数的覆盖的内容清楚明白,并且在你需要序列化更多类型的时候更容易扩展。 - 在这个例子里面, 我将
bytes对象转换成字典。__class__键持有原始的数据类型(以字符串的形式,'bytes'), 而__value__键持有实际的数据。当然它不能是bytes对象; 大体的想法是将其转换成某些可以被 JSON 序列化的东西!bytes对象就是一个范围在 0–255 的整数的序列。 我们可以使用list()函数将bytes对象转换成整数列表。所以b'\xDE\xD5\xB4\xF8'变成[222, 213, 180, 248]. (算一下! 这是对的! 16 进制的字节\xDE是十进制的 222,\xD5是 213, 以此类推。) - 这一行很重要。你序列化的数据结构可能包含 JSON 内建的可序列化类型和你的定制序列化器支持的类型之外的东西。在这种情况下,你的定制序列化器抛出一个
TypeError,那样json.dump()函数就可以知道你的定制序列化函数不认识该类型。
就这么多;你不需要其它的东西。特别是, 这个定制序列化函数返回 Python 字典,不是字符串。你不是自己做所有序列化到 JSON 的工作; 你仅仅在做转换成被支持的类型那部分工作。json.dump() 函数做剩下的事情。
>>> shell
1
...
Traceback (most recent call last):
File "<stdin>", line 9, in <module>
json.dump(entry, f, default=customserializer.to_json)
File "C:\Python31\lib\json\__init__.py", line 178, in dump
for chunk in iterable:
File "C:\Python31\lib\json\encoder.py", line 408, in _iterencode
for chunk in _iterencode_dict(o, _current_indent_level):
File "C:\Python31\lib\json\encoder.py", line 382, in _iterencode_dict
for chunk in chunks:
File "C:\Python31\lib\json\encoder.py", line 416, in _iterencode
o = _default(o)
File "/Users/pilgrim/diveintopython3/examples/customserializer.py", line 12, in to_json
TypeError: time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1) is not JSON serializable
customserializer模块是你在前一个例子中定义to_json()函数的地方。- 文本模式, UTF-8 编码, yadda yadda。(你很可能会忘记这一点! 我就忘记过好几次! 事情一切正常直到它失败的时刻, 而它的失败很令人瞩目。)
- 这是重点: 为了将定制转换函数钩子嵌入
json.dump()函数, 只要将你的函数以default参数传入json.dump()函数。(万岁, Python 里一切皆对象!) - 好吧, 实际上还是不能工作。但是看一下异常。
json.dump()函数不再抱怨无法序列化bytes对象了。现在它在抱怨另一个完全不同的对象:time.struct_time对象。
尽管得到另一个不同的异常看起来不是什么进步, 但它确实是个进步! 再调整一下就可以解决这个问题。
import time
def to_json(python_object):
if isinstance(python_object, bytes):
return {'__class__': 'bytes',
'__value__': list(python_object)}
raise TypeError(repr(python_object) + ' is not JSON serializable')
- 在现存的
customserializer.to_json()函数里面, 我们加入了 Python 对象 (json.dump()处理不了的那些) 是不是time.struct_time的判断。 - 如果是的,我们做一些同处理
bytes对象时类似的事情来转换: 将time.struct_time结构转化成一个只包含 JSON 可序列化值的字典。在这个例子里, 最简单的将日期时间转换成 JSON 可序列化值的方法是使用time.asctime()函数将其转换成字符串。time.asctime()函数将难看的time.struct_time转换成字符串'Fri Mar 27 22:20:42 2009'。
有了两个定制的转换, 整个entry 数据结构序列化到 JSON 应该没有进一步的问题了。
>>> shell
1
>>> with open('entry.json', 'w', encoding='utf-8') as f:
... json.dump(entry, f, default=customserializer.to_json)
...
you@localhost:~/diveintopython3/examples$ ls -l example.json
-rw-r--r-- 1 you you 391 Aug 3 13:34 entry.json
you@localhost:~/diveintopython3/examples$ cat example.json
{"published_date": {"__class__": "time.asctime", "__value__": "Fri Mar 27 22:20:42 2009"},
"comments_link": null, "internal_id": {"__class__": "bytes", "__value__": [222, 213, 180, 248]},
"tags": ["diveintopython", "docbook", "html"], "title": "Dive into history, 2009 edition",
"article_link": "http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition",
"published": true}
从 JSON 文件加载数据
类似pickle 模块,json模块有一个load()函数接受一个流对象,从中读取 JSON 编码过的数据, 并且创建该 JSON 数据结构的 Python 对象的镜像。
>>> shell
2
>>> entry
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'entry' is not defined
>>> import json
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...
{'comments_link': None,
'internal_id': {'__class__': 'bytes', '__value__': [222, 213, 180, 248]},
'title': 'Dive into history, 2009 edition',
'tags': ['diveintopython', 'docbook', 'html'],
'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
'published_date': {'__class__': 'time.asctime', '__value__': 'Fri Mar 27 22:20:42 2009'},
'published': True}
- 为了演示目的,切换到 Python Shell #2 并且删除在这一章前面使用
pickle模块创建的entry数据结构。 - 最简单的情况下,
json.load()函数同pickle.load()函数的结果一模一样。你传入一个流对象,它返回一个新的 Python 对象。 - 有好消息也有坏消息。好消息先来:
json.load()函数成功的读取了你在 Python Shell #1 中创建的entry.json文件并且生成了一个包含那些数据的新的 Python 对象。接着是坏消息: 它没有重建原始的entry数据结构。'internal_id'和'published_date'这两个值被重建为字典 — 具体来说, 你在to_json()转换函数中使用 JSON 兼容的值创建的字典。
json.load() 并不知道你可能传给json.dump()的任何转换函数的任何信息。你需要的是to_json()函数的逆函数 — 一个接受定制转换出的 JSON 对象并将其转换回原始的 Python 数据类型。
# add this to customserializer.py
if json_object['__class__'] == 'time.asctime':
if json_object['__class__'] == 'bytes':
return json_object
- 这函数也同样接受一个参数返回一个值。但是参数不是字符串,而是一个 Python 对象 — 反序列化一个 JSON 编码的字符串为 Python 的结果。
- 你只需要检查这个对象是否包含
to_json()函数创建的'__class__'键。如果是的,'__class__'键对应的值将告诉你如何将值解码成原来的 Python 数据类型。 - 为了解码由
time.asctime()函数返回的字符串,你要使用time.strptime()函数。这个函数接受一个格式化过的时间字符串(格式可以自定义,但默认值同time.asctime()函数的默认值相同) 并且返回time.struct_time. - 为了将整数列表转换回
bytes对象, 你可以使用bytes()函数。
就是这样; to_json()函数处理了两种数据类型,现在这两个数据类型也在from_json()函数里面处理了。下面是结果:
>>> shell
2
>>> import customserializer
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...
{'comments_link': None,
'internal_id': b'\xDE\xD5\xB4\xF8',
'title': 'Dive into history, 2009 edition',
'tags': ['diveintopython', 'docbook', 'html'],
'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
'published': True}
- 为了将
from_json()函数嵌入到反序列化过程中,把它作为object_hook参数传入到json.load()函数中。接受函数作为参数的函数; 真方便! entry数据结构现在有一个值为bytes对象的'internal_id'键。它也包含一个'published_date'键,其值为time.struct_time对象。
然而,还有最后一个缺陷。
>>> shell
1
>>> import customserializer
>>> with open('entry.json', 'r', encoding='utf-8') as f:
... entry2 = json.load(f, object_hook=customserializer.from_json)
...
False
('diveintopython', 'docbook', 'html')
['diveintopython', 'docbook', 'html']
- 即使在序列化过程中加入了
to_json()钩子函数, 也在反序列化过程中加入from_json()钩子函数, 我们仍然没有重新创建原始数据结构的完美复制品。为什么没有? - 在原始的
entry数据结构中,'tags'键的值为一个三个字符串组成的元组。 - 但是重现创建的
entry2数据结构中,'tags'键的值是一个三个字符串组成的列表。JSON 并不区分元组和列表;它只有一个类似列表的数据类型,数组,并且json模块在序列化过程中会安静的将元组和列表两个都转换成 JSON 数组。大多数情况下,你可以忽略元组和列表的区别,但是在使用json模块时应记得有这么一回使。
进一步阅读
☞很多关于
pickle模块的文章提到了cPickle。在 Python 2 中,pickle模块有两个实现, 一个由纯 Python 写的而另一个用 C 写的(但仍然可以在 Python 中调用)。在 Python 3 中, 这两个模块已经合并, 所以你总是简单的import pickle就可以。你可能会发现这些文章很有用,但是你应该忽略已过时的关于的cPickle的信息.
使用pickle模块打包:
picklemodulepickleandcPickle— Python object serialization- Using
pickle - Python persistence management
使用 JSON 和 json 模块:
json— JavaScript Object Notation Serializer- JSON encoding and ecoding with custom objects in Python
扩展打包:
Chapter 14 HTTP Web 服务
" A ruffled mind makes a restless pillow. " — Charlotte Brontë
深入
简单地讲,HTTP web 服务是指以编程的方式直接使用 HTTP 操作从远程服务器发送和接收数据。如果你要从服务器获取数据,使用 HTTP GET;如果你要向服务器发送新数据,使用 HTTP POST. 一些更高级的 HTTP Web 服务 API 也允许使用 HTTP PUT 和 HTTP DELETE来创建、修改和删除数据。 换句话说,HTTP 协议中的“verbs (动作)” (GET, POST, PUT 和 DELETE) 可以直接对应到应用层的操作:获取,创建,修改,删除数据。
这个方法主要的优点是简单, 它的简单证明是受欢迎的。数据 — 通常是 XML 或 JSON — 可以事先创建好并静态的存储下来 ,或者由服务器端脚本动态生成, 并且所有主要的编程语言(当然包括 Python)都包含 HTTP 库用于下载数据。调试也很方便; 由于 HTTP web 服务中每一个资源都有一个唯一的地址(以 URL 的形式存在), 你可以在浏览器中加载它并且立即看到原始的数据.
HTTP web 服务示例:
- Google Data API 允许你同很多类型的 Google 服务交互, 包括 Blogger 和 YouTube。
- Flickr Services 允许你向Flickr下载和上传图片。
- Twitter API 允许你在Twitter发布状态更新。
- …以及更多
Python 3 带有两个库用于和 HTTP web 服务交互:
http.client是实现了RFC 2616, HTTP 协议的底层库.urllib.request建立在http.client之上一个抽象层。 它为访问 HTTP 和 FTP 服务器提供了一个标准的 API,可以自动跟随 HTTP 重定向, 并且处理了一些常见形式的 HTTP 认证。
那么,你应该用哪个呢?两个都不用。取而代之, 你应该使用 httplib2,一个第三方的开源库,它比http.client更完整的实现了 HTTP 协议,同时比urllib.request提供了更好的抽象。
要理解为什么httplib2是正确的选择,你必须先了解 HTTP。
HTTP 的特性
有五个重要的特性所有的 HTTP 客户端都应该支持。
缓存
关于 web 服务最需要了解的一点是网络访问是极端昂贵的。我并不是指“美元”和“美分”的昂贵(虽然带宽确实不是免费的)。我的意思是需要一个非常长的时间来打开一个连接,发送请求,并从远程服务器响应。 即使在最快的宽带连接上,延迟(从发送一个请求到开始在响应中获得数据所花费的时间)仍然高于您的预期。路由器的行为不端,被丢弃的数据包,中间代理服务器被攻击 — 在公共互联网上没有沉闷的时刻(never a dull moment),并且你对此无能为力。
Cache-Control: max-age 的意思是“一个星期以内都不要来烦我。”
HTTP 在设计时就考虑到了缓存。有这样一类的设备(叫做 “缓存代理服务器”) ,它们的唯一的任务是就是呆在你和世界的其他部分之间来最小化网络请求。你的公司或 ISP 几乎肯定维护着这样的缓存代理服务器, 只不过你没有意识到而已。 它们的能够起到作用是因为缓存是内建在 HTTP 协议中的。
这里有一个缓存如何工作的具体例子。 你通过浏览器访问diveintomark.org。该网页包含一个背景图片, wearehugh.com/m.jpg。当你的浏览器下载那张图片时,服务器的返回包含了下面的 HTTP 头:
HTTP/1.1 200 OK
Date: Sun, 31 May 2009 17:14:04 GMT
Server: Apache
Last-Modified: Fri, 22 Aug 2008 04:28:16 GMT
ETag: "3075-ddc8d800"
Accept-Ranges: bytes
Content-Length: 12405
<mark>Cache-Control: max-age=31536000, public</mark>
<mark>Expires: Mon, 31 May 2010 17:14:04 GMT</mark>
Connection: close
Content-Type: image/jpeg
Cache-Control 和 Expires 头告诉浏览器(以及任何处于你和服务器之间的缓存代理服务器) 这张图片可以缓存长达一年。 一年! 如果在明年,你访问另外一个也包含这张图片的页面,你的浏览器会从缓存中加载这样图片而不会产生任何网络活动.
等一下,情况实际上更好。比方说,你的浏览器由于某些原因将图片从本地缓存中移除了。可能是因为没有磁盘空间了或者是你清空了缓存,不管是什么理由。然而 HTTP 头告诉说这个数据可以被公共缓存代理服务器缓存(Cache-Control头中public关键字说明这一点)。缓存代理服务器有非常庞大的存储空间,很可能比你本地浏览器所分配的大的多。
如果你的公司或者 ISP 维护着这样一个缓存代理服务器,它很可能仍然有这张图片的缓存。 当你再次访问diveintomark.org 时, 你的浏览器会在本地缓存中查找这张图片, 它没有找到, 所以它发出一个网络请求试图从远程服务器下载这张图片。但是由于缓存代理服务器仍然有这张图片的一个副本,它将截取这个请求并从它的缓存中返回这张图片。 这意味这你的请求不会到达远程服务器; 实际上, 它根本没有离开你公司的网络。这意味着更快的下载(网络跃点变少了) 和节省你公司的花费(从外部下载的数据变少了)。
只有当每一个角色都做按协议来做时,HTTP 缓存才能发挥作用。一方面,服务器需要在响应中发送正确的头。另一方面,客户端需要在第二次请求同样的数据前理解并尊重这些响应头。 代理服务器不是灵丹妙药,它们只会在客户端和服务器允许的情况下尽可能的聪明。
Python 的 HTTP 库不支持缓存,而httplib2支持。
最后修改时间的检查
有一些数据从不改变,而另外一些则总是在变化。介于两者之间,在很多情况下数据还没变化但是将来可能会变化。 CNN.com 的供稿每隔几分钟就会更新,但我的博客的供稿可能几天或者几星期才会更新一次。在后面一种情况的时候,我不希望告诉客户端缓存我的供稿几星期,因为当我真的发表了点东西的时候,人们可能会几个星期后才能阅读到(由于他们遵循我的 cache 头—"几个星期内都不用检查这个供稿")。另一方面,如果供稿没有改变我也不希望客户端每隔 1 小时就来检查一下!
304: Not Modified 的意思是 “不同的日子,同样的数据(same shit, different day)。”
HTTP 对于这个问题也有一个解决方案。当你第一次请求数据时,服务器返回一个Last-Modified头。 顾名思义:数据最后修改的时间。diveintomark.org引用的这张背景图片包含一个Last-Modified头。
HTTP/1.1 200 OK
Date: Sun, 31 May 2009 17:14:04 GMT
Server: Apache
<mark>Last-Modified: Fri, 22 Aug 2008 04:28:16 GMT</mark>
ETag: "3075-ddc8d800"
Accept-Ranges: bytes
Content-Length: 12405
Cache-Control: max-age=31536000, public
Expires: Mon, 31 May 2010 17:14:04 GMT
Connection: close
Content-Type: image/jpeg
如果第二(第三,第四)次请求同样一个资源,你可以在你的请求中发送一个If-Modified-Since头,其值为你上次从服务器返回的时间。如果从那时开始,数据已经发成过变化,服务器会忽略If-Modified-Since头并返回新数据和200状态码给你。否则的话,服务器将发回一个特殊的 HTTP 304 状态码, 它的含义是“从上次请求到现在数据没有发生过变化.” 你可以在命令行上使用curl来测试:
you@localhost:~$ curl -I <mark>-H "If-Modified-Since: Fri, 22 Aug 2008 04:28:16 GMT"</mark> http://wearehugh.com/m.jpg
HTTP/1.1 304 Not Modified
Date: Sun, 31 May 2009 18:04:39 GMT
Server: Apache
Connection: close
ETag: "3075-ddc8d800"
Expires: Mon, 31 May 2010 18:04:39 GMT
Cache-Control: max-age=31536000, public
为什么这是一个进步?因为服务器发送304时, 它没有重新发送数据。你得到的仅仅是状态码。即使你的缓存副本已经过期,最后修改时间检查保证你不会在数据没有变化的情况下重新下载它。 (额外的好处是,这个304 响应同样也包含了缓存头。代理服务器会在数据已经“过期”的情况下仍然保留数据的副本; 希望数据实际上还没有改变,并且下一个请求以304状态码返回,并更新缓存信息。)
Python 的 HTTP 库不支持最后修改时间检查,而httplib2 支持。
ETags
ETag 是另一个和最后修改时间检查达到同样目的的方法。使用 ETag 时,服务器在返回数据的同时在ETag头里返回一个哈希码(如何生成哈希码完全取决于服务器,唯一的要求是数据改变时哈希码也要改变) diveintomark.org引用的背景图片包含有ETag 头.
HTTP/1.1 200 OK
Date: Sun, 31 May 2009 17:14:04 GMT
Server: Apache
Last-Modified: Fri, 22 Aug 2008 04:28:16 GMT
<mark>ETag: "3075-ddc8d800"</mark>
Accept-Ranges: bytes
Content-Length: 12405
Cache-Control: max-age=31536000, public
Expires: Mon, 31 May 2010 17:14:04 GMT
Connection: close
Content-Type: image/jpeg
ETag 的意思是 “太阳底下没有什么新东西。”
当你再次请求同样的数据时,你在If-None-Match头里放入 ETag 值。如果数据没有发生改变,服务器将会返回304状态码。同最后修改时间检查一样,服务器发回的只有304 状态码,不会再一次给你发送同样的数据。通过在请求中包含 ETag 哈希码,你告诉服务器如果哈希值匹配就不需要重新发送同样的数据了,因为你仍然保留着上次收到的数据.
再一次使用curl:
HTTP/1.1 304 Not Modified
Date: Sun, 31 May 2009 18:04:39 GMT
Server: Apache
Connection: close
ETag: "3075-ddc8d800"
Expires: Mon, 31 May 2010 18:04:39 GMT
Cache-Control: max-age=31536000, public
- ETag 一般使用引号包围, 但是引号是值的一部分。它们不是分隔符;
ETag头里面唯一的分隔符是ETag和"3075-ddc8d800"之间的冒号。这意味着你也需要将引号放在If-None-Match头发回给服务器。
Python HTTP 库不支持 ETag,而httplib2支持.
压缩
当我们谈论 HTTP web 服务的时候, 你总是会讨论到在线路上来回运送文本数据。可能是 XML,也可能是 JSON,抑或仅仅是纯文本。不管是什么格式,文本的压缩性能很好。XML 章节中的示例供稿在没压缩的情况下是 3070 字节,然而在 gzip 压缩后只有 941 字节。仅仅是原始大小的 30%!
HTTP 支持若干种压缩算法。最常见的两种是gzip 和 deflate。当你通过 HTTP 请求资源时,你可以要求服务器以压缩格式返回资源。你在请求中包含一个Accept-encoding头,里面列出了你支持的压缩算法。如果服务器也支持其中的某一种算法,它就会返回给你压缩后的数据(同时通过Content-encoding头标识它使用的算法)。接下来的事情就是由你去解压数据了。
Python 的 HTTP 库不支持压缩,但httplib2支持。
重定向
好的 URI 不会变化,但是有很多 URI 并没有那么好。网站可能会重新组织,页面移动到新位置。即使是 web 服务也可能重新安排。一个联合供稿http://example.com/index.xml 可能会移动到http://example.com/xml/atom.xml。或者当一个机构扩张和重组的时候,整个域名都可能移动; http://www.example.com/index.xml 变成 http://server-farm-1.example.com/index.xml.
Location 的意思是 “看那边!”
每一次你向 HTTP 服务器请求资源的时候, 服务器都会在响应中包含一个状态码。 状态码200的意思是一切正常,这就是你请求的页面; 状态码404的意思是找不到页面; (你很可能在浏览网页的时候碰到过 404)。300 系列的状态码意味着某种形式的重定向。
HTTP 有多种方法表示一个资源已经被移动。最常见两个技术是状态码302 和 301。 状态码 302 是一个 临时重定向; 它意味着, 资源被被临时从这里移动走了; (并且临时地址在Location 头里面给出)。状态码301是永久重定向; 它意味着,资源被永久的移动了; (并且在Location头里面给出了新的地址)。如果你得到302状态码和一个新地址, HTTP 规范要求你访问新地址来获得你要的资源,但是下次你要访问同样的资源的时候你应该重新尝试旧的地址。但是如果你得到301状态码和新地址, 你从今以后都应该使用新的地址。
urllib.request模块在从 HTTP 服务器收到对应的状态码的时候会自动“跟随”重定向, 但它不会告诉你它这么干了。你最后得到了你请求的数据,但是你永远也不会知道下层的库友好的帮助你跟随了重定向。结果是,你继续访问旧的地址,每一次你都会得到新地址的重定向,每一次urllib.request模块都会友好的帮你跟随重定向。换句话说,它将永久重定向当成临时重定向来处理。这意味着两个来回而不是一个,这对你和服务器都不好。
httplib2 帮你处理了永久重定向。它不仅会告诉你发生了永久重定向,而且它会在本地记录这些重定向,并且在发送请求前自动重写为重定向后的 URL。
避免通过 HTTP 重复地获取数据
我们来举个例子,你想要通过 HTTP 下载一个资源, 比如说一个 Atom 供稿。作为一个供稿, 你不会只下载一次,你会一次又一次的下载它。 (大部分的供稿阅读器会美一小时检查一次更新。) 让我们先用最粗糙和最快的方法来实现它,接着再来看看怎样改进。
>>> import urllib.request
>>> a_url = 'http://diveintopython3.org/examples/feed.xml'
<class 'bytes'>
>>> print(data)
<?xml version='1.0' encoding='utf-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
<title>dive into mark</title>
<subtitle>currently between addictions</subtitle>
<id>tag:diveintomark.org,2001-07-29:/</id>
<updated>2009-03-27T21:56:07Z</updated>
<link rel='alternate' type='text/html' href='http://diveintomark.org/'/>
…
- 在 Python 中通过 HTTP 下载东西是非常简单的; 实际上,只需要一行代码。
urllib.request模块有一个方便的函数urlopen(),它接受你所要获取的页面地址,然后返回一个类文件对象,您只要调用它的read()方法就可以获得网页的全部内容。没有比这更简单的了。 urlopen().read()方法总是返回bytes对象,而不是字符串。记住字节仅仅是字节,字符只是一种抽象。 HTTP 服务器不关心抽象的东西。如果你请求一个资源,你得到字节。 如果你需要一个字符串,你需要确定字符编码,并显式的将其转化成字符串。
那么,有什么问题呢?作为开发或测试中的快速试验,没有什么不妥的地方。我总是这么干。我需要供稿的内容,然后我拿到了它。相同的技术对任何网页都有效。但一旦你考虑到你需要定期访问 Web 服务的时候,(例如 每隔 1 小时请求一下这个供稿), 这样的做法就显得很低效和粗暴了。
线路上是什么?
为了说明为什么这是低效和粗暴的,我们来打开 Python 的 HTTP 库的调试功能,看看什么东西被发送到了线路上(即网络上).
>>> from http.client import HTTPConnection
>>> from urllib.request import urlopen
Connection: close
reply: 'HTTP/1.1 200 OK'
…further debugging information omitted…
- 正如我在这章开头提到的,
urllib.request依赖另一个标准 Python 库,http.client。正常情况下你不需要直接接触http.client。 (urllib.request模块会自动导入它。) 我们在这里导入它是为了让我们能够打开HTTPConnection类的调试开关,urllib.request使用这个类去连接 HTTP 服务器。 - 调式开关已经打开,有关 HTTP 请求和响应的信息会实时的打印出来。正如你所看见的,当你请求 Atom 供稿时,
urllib.request模块向服务器发送了 5 行数据。 - 第一行指定了你使用的 HTTP 方法和你访问的资源的路径(不包含域名)。
- 第二行指定了你请求的供稿所在的域名。
- 第三行指定客户端支持的压缩算法。我之前提到过,
urllib.request默认不支持压缩。 - 第四行说明了发送请求的库的名字。默认情况下是
Python-urllib加上版本号。urllib.request和httplib2都支持更改用户代理, 直接向请求里面加一个User-Agent头就可以了(默认值会被覆盖).
我们下载了 3070 字节,但其实我们可以只下载 941 个字节.
现在让我们来看看服务器返回了什么。
# continued from previous example
Server: Apache
Accept-Ranges: bytes
Expires: Mon, 01 Jun 2009 19:23:06 GMT
Vary: Accept-Encoding
Connection: close
Content-Type: application/xml
>>> len(data)
3070
urllib.request.urlopen()函数返回的response对象包含了服务器返回的所有 HTTP 头。它也提供了下载实际数据的方法,这个我们等一下讲。- 服务器提供了它处理你的请求时的时间。
- 这个响应包含了
Last-Modified头。 - 这个响应包含了
ETag头。 - 数据的长度是 3070 字节。请注意什么东西没有出现在这里:
Content-encoding头。你的请求表示你只接受未压缩的数据,(Accept-encoding: identity), 然后当然,响应确实包含未压缩的数据。 - 这个响应包含缓存头,表明这个供稿可以缓存长达 24 小时。(86400 秒).
- 最后,通过调用
response.read()下载实际的数据. 你从len()函数可以看出,一下子就把整个 3070 个字节下载下来了。
正如你所看见的,这个代码已经是低效的了;它请求(并接收)了未压缩的数据。我知道服务器实际上是支持 gzip 压缩的, 但 HTTP 压缩是一个可选项。我们不主动要求,服务器不会执行。这意味这在可以只下载 941 字节的情况下我们下载了 3070 个字节。Bad dog, no biscuit.
别急,还有更糟糕的。为了说明这段代码有多么的低效,让我再次请求一下同一个供稿。
# continued from the previous example
>>> response2 = urlopen('http://diveintopython3.org/examples/feed.xml')
send: b'GET /examples/feed.xml HTTP/1.1
Host: diveintopython3.org
Accept-Encoding: identity
User-Agent: Python-urllib/3.1'
Connection: close
reply: 'HTTP/1.1 200 OK'
…further debugging information omitted…
注意到这个请求有什么特别之处吗?它没有变化。它同第一个请求完全一样。没有If-Modified-Since头. 没有If-None-Match头. 没有尊重缓存头,也仍然没有压缩。
然后,当你发送同样的请求的时候会发生什么呢?你又一次得到同样的响应。
# continued from the previous example
Date: Mon, 01 Jun 2009 03:58:00 GMT
Server: Apache
Last-Modified: Sun, 31 May 2009 22:51:11 GMT
ETag: "bfe-255ef5c0"
Accept-Ranges: bytes
Content-Length: 3070
Cache-Control: max-age=86400
Expires: Tue, 02 Jun 2009 03:58:00 GMT
Vary: Accept-Encoding
Connection: close
Content-Type: application/xml
>>> data2 = response2.read()
3070
True
- 服务器仍然在发送同样的聪明的头:
Cache-Control和Expires用于允许缓存,Last-Modified和ETag用于“是否变化”的跟踪。甚至是Vary: Accept-Encoding头暗示只要你请求,服务器就能支持压缩。但是你没有。 - 再一次,获取这个数据下载了一共 3070 个字节…
- …和你上一次下载的 3070 字节完全一致。
HTTP 设计的能比这样工作的更好。 urllib使用 HTTP 就像我说西班牙语一样 — 可以表达基本的意思,但是不足以保持一个对话。HTTP 是一个对话。是时候更新到一个可以流利的讲 HTTP 的库了。
介绍 httplib2
在你使用httplib2前, 你需要先安装它。 访问code.google.com/p/httplib2/ 并下载最新版本。httplib2对于 Python 2.x 和 Python 3.x 都有对应的版本; 请确保你下载的是 Python 3 的版本, 名字类似httplib2-python3-0.5.0.zip。
解压该档案,打开一个终端窗口, 然后切换到刚生成的httplib2目录。在 Windows 上,请打开开始菜单, 选择运行, 输入cmd.exe 最后按回车(ENTER).
c:\Users\pilgrim\Downloads> <mark>dir</mark>
Volume in drive C has no label.
Volume Serial Number is DED5-B4F8
Directory of c:\Users\pilgrim\Downloads
07/28/2009 12:36 PM <DIR> .
07/28/2009 12:36 PM <DIR> ..
07/28/2009 12:36 PM <DIR> httplib2-python3-0.5.0
07/28/2009 12:33 PM 18,997 httplib2-python3-0.5.0.zip
1 File(s) 18,997 bytes
3 Dir(s) 61,496,684,544 bytes free
c:\Users\pilgrim\Downloads> <mark>cd httplib2-python3-0.5.0</mark>
c:\Users\pilgrim\Downloads\httplib2-python3-0.5.0> <mark>c:\python31\python.exe setup.py install</mark>
running install
running build
running build_py
running install_lib
creating c:\python31\Lib\site-packages\httplib2
copying build\lib\httplib2\iri2uri.py -> c:\python31\Lib\site-packages\httplib2
copying build\lib\httplib2\__init__.py -> c:\python31\Lib\site-packages\httplib2
byte-compiling c:\python31\Lib\site-packages\httplib2\iri2uri.py to iri2uri.pyc
byte-compiling c:\python31\Lib\site-packages\httplib2\__init__.py to __init__.pyc
running install_egg_info
Writing c:\python31\Lib\site-packages\httplib2-python3_0.5.0-py3.1.egg-info
在 Mac OS X 上, 运行位于/Applications/Utilities/目录下的Terminal.app程序。在 Linux 上,运行终端(Terminal)程序, 该程序一般位于你的应用程序菜单,在Accessories 或者 系统(System)下面。
you@localhost:~/Desktop$ <mark>unzip httplib2-python3-0.5.0.zip</mark>
Archive: httplib2-python3-0.5.0.zip
inflating: httplib2-python3-0.5.0/README
inflating: httplib2-python3-0.5.0/setup.py
inflating: httplib2-python3-0.5.0/PKG-INFO
inflating: httplib2-python3-0.5.0/httplib2/__init__.py
inflating: httplib2-python3-0.5.0/httplib2/iri2uri.py
you@localhost:~/Desktop$ <mark>cd httplib2-python3-0.5.0/</mark>
you@localhost:~/Desktop/httplib2-python3-0.5.0$ <mark>sudo python3 setup.py install</mark>
running install
running build
running build_py
creating build
creating build/lib.linux-x86_64-3.1
creating build/lib.linux-x86_64-3.1/httplib2
copying httplib2/iri2uri.py -> build/lib.linux-x86_64-3.1/httplib2
copying httplib2/__init__.py -> build/lib.linux-x86_64-3.1/httplib2
running install_lib
creating /usr/local/lib/python3.1/dist-packages/httplib2
copying build/lib.linux-x86_64-3.1/httplib2/iri2uri.py -> /usr/local/lib/python3.1/dist-packages/httplib2
copying build/lib.linux-x86_64-3.1/httplib2/__init__.py -> /usr/local/lib/python3.1/dist-packages/httplib2
byte-compiling /usr/local/lib/python3.1/dist-packages/httplib2/iri2uri.py to iri2uri.pyc
byte-compiling /usr/local/lib/python3.1/dist-packages/httplib2/__init__.py to __init__.pyc
running install_egg_info
Writing /usr/local/lib/python3.1/dist-packages/httplib2-python3_0.5.0.egg-info
要使用httplib2, 请创建一个httplib2.Http 类的实例。
>>> import httplib2
200
b"<?xml version='1.0' encoding='utf-8'?>\r\n<feed xmlns="
>>> len(content)
3070
httplib2的主要接口是Http对象。你创建Http对象时总是应该传入一个目录名,具体原因你会在下一节看见。目录不需要事先存在,httplib2会在必要的时候创建它。- 一旦你有了
Http对象, 获取数据非常简单,以你要的数据的地址作为参数调用request()方法就可以了。这会对该 URL 执行一个 HTTPGET请求. (这一章下面你会看见怎样执行其他 HTTP 请求, 比如POST。) request()方法返回两个值。第一个是一个httplib2.Response对象,其中包含了服务器返回的所有 HTTP 头。比如,status为200表示请求成功。content变量包含了 HTTP 服务器返回的实际数据。数据以bytes对象返回,不是字符串。 如果你需要一个字符串,你需要确定字符编码并自己进行转换。
☞你很可能只需要一个
httplib2.Http对象。当然存在足够的理由来创建多个,但是只有当你清楚创建多个的原因的时候才应该这样做。从不同的 URL 获取数据不是一个充分的理由,重用Http对象并调用request()方法两次就可以了。
关于httplib2返回字节串而不是字符串的简短解释
字节串。字符串。真麻烦啊。为什么httplib2不能替你把转换做了呢?由于决定字符编码的规则依赖于你请求的资源的类型,导致自动转化很复杂。httplib2怎么知道你要请求的资源的类型呢?通常类型会在Content-Type HTTP 头里面列出,但是这是 HTTP 的可选特性,并且并非所有的 HTTP 服务器都支持。如果 HTTP 响应没有包含这个头,那就留给客户端去猜了。(这通常被称为“内容嗅探(content sniffing)” ,但它从来就不是完美的。)
如果你知道你期待的资源是什么类型的(这个例子中是 XML 文档), 也许你应该直接将返回的字节串(bytes)对象传给xml.etree.ElementTree.parse() 函数。只要(像这个文档一样)XML 文档自己包含字符编码信息,这是可以工作的。但是字符编码信息是一个可选特性并非所有 XML 文档包含这样的信息。如果一个 XML 文档不包含编码信息,客户端应该去查看Content-Type HTTP 头, 里面应该包含一个charset参数。
但问题更糟糕。现在字符编码信息可能在两个地方:在 XML 文档自己内部,在Content-Type HTTP 头里面。如果信息在两个地方都出现了,哪个优先呢?根据RFC 3023 (我发誓,这不是我编的), 如果在Content-Type HTTP 头里面给出的媒体类型(media type)是application/xml, application/xml-dtd, application/xml-external-parsed-entity, 或者是任何application/xml的子类型,比如application/atom+xml 或者 application/rss+xml 亦或是 application/rdf+xml, 那么编码是
Content-TypeHTTP 头的charset参数给出的编码, 或者- 文档内的 XML 声明的
encoding属性给出的编码, 或者 - UTF-8
相反,如果在Content-Type HTTP 头里面给出的媒体类型(media type)是text/xml, text/xml-external-parsed-entity, 或者任何text/AnythingAtAll+xml这样的子类型, 那么文档内的 XML 声明的encoding属性完全被忽略,编码是
Content-TypeHTTP 头的charset参数给出的编码, 或者us-ascii
而且这还只是针对 XML 文档的规则。对于 HTML 文档,网页浏览器创造了用于内容嗅探的复杂规则(byzantine rules for content-sniffing) [PDF], 我们正试图搞清楚它们。.
“欢迎提交补丁.”
httplib2怎样处理缓存。
还记的在前一节我说过你总是应该在创建httplib2.Http对象是提供一个目录名吗? 缓存就是这样做的目的。
# continued from the previous example
200
b"<?xml version='1.0' encoding='utf-8'?>\r\n<feed xmlns="
>>> len(content2)
3070
- 没什么惊奇的东西。跟上次一样,只不过你把结果放入两个新的变量。
- HTTP
状态(status)码同上次一样还是200。 - 下载的内容也一样。
谁关心这些东西啊?退出你的 Python 交互 shell 然后打开一个新的会话,我来给你演示。
# NOT continued from previous example!
# Please exit out of the interactive shell
# and launch a new one.
>>> import httplib2
3070
200
True
- 让我们打开调试开关来看看线路上是什么。这是使用
httplib2打开http.client调试开关的方法.httplib2会打印出发给服务器的所有数据以及一些返回的关键信息。 - 使用同之前一样的目录创建
httplib2.Http对象。 - 请求同之前一样的 URL。 什么也没有发生。 更准确的说,没有东西发送到服务器,没有东西从服务器返回。没有任何形式的网络活动。
- 但我们还是接收到了数据,实际上是所有的数据。
- 我们也接收到表示请求成功的 HTTP 状态码。
- 这里是奥秘所在: 响应是从
httplib2的本地缓存构造出来的。你创建httplib2.Http对象是传入的目录里面保存了所有httplib2执行过的操作的缓存。
线路上有什么?没有东西。
☞如果你想要打开
httplib2的调试开关,你需要设置一个模块级的常量(httplib2.debuglevel), 然后再创建httplib2.Http对象。如果你希望关闭调试,你需要改变同一个模块级常量, 接着创建一个新的httplib2.Http对象。
你刚刚请求过这个 URL 的数据。那个请求是成功的(状态码: 200)。该响应不仅包含 feed 数据,也包含一系列缓存头,告诉那些关注着的人这个资源可以缓存长达 24 小时(Cache-Control: max-age=86400, 24 小时所对应的秒数)。 httplib2 理解并尊重那些缓存头,并且它会在.cache目录(你在创建Http对象时提供的)保存之前的响应。缓存还没有过期,所以你第二次请求该 URL 的数据时, httplib2不会去访问网络,直接返回缓存着的数据。
我说的很简单,但是很显然在这简单后面隐藏了很多复杂的东西。httplib2会自动处理 HTTP 缓存,并且这是默认的行为. 如果由于某些原因你需要知道响应是否来自缓存,你可以检查 response.fromcache. 否则的话,它工作的很好。
现在,假设你有数据缓存着,但是你希望跳过缓存并且重新请求远程服务器。浏览器有时候会应用户的要求这么做。比如说,按F5刷新当前页面,但是按Ctrl+F5会跳过缓存并向远程服务器重新请求当前页面。你可能会想“嗯,我只要从本地缓存删除数据,然后再次请求就可以了。” 你可以这么干,但是请记住, 不只是你和远程服务器会牵扯其中。那些中继代理服务器呢? 它们完全不受你的控制,并且它们可能还有那份数据的缓存,然后很高兴的将其返回给你, 因为(对它们来说)缓存仍然是有效的。
你应该使用 HTTP 的特性来保证你的请求最终到达远程服务器,而不是修改本地缓存然后听天由命。
# continued from the previous example
>>> response2, content2 = h.request('http://diveintopython3.org/examples/feed.xml',
send: b'GET /examples/feed.xml HTTP/1.1
Host: diveintopython3.org
user-agent: Python-httplib2/$Rev: 259 $
accept-encoding: deflate, gzip
cache-control: no-cache'
reply: 'HTTP/1.1 200 OK'
…further debugging information omitted…
>>> response2.status
200
False
{'status': '200',
'content-length': '3070',
'content-location': 'http://diveintopython3.org/examples/feed.xml',
'accept-ranges': 'bytes',
'expires': 'Wed, 03 Jun 2009 00:40:26 GMT',
'vary': 'Accept-Encoding',
'server': 'Apache',
'last-modified': 'Sun, 31 May 2009 22:51:11 GMT',
'connection': 'close',
'-content-encoding': 'gzip',
'etag': '"bfe-255ef5c0"',
'cache-control': 'max-age=86400',
'date': 'Tue, 02 Jun 2009 00:40:26 GMT',
'content-type': 'application/xml'}
httplib2允许你添加任意的 HTTP 头部到发出的请求里。为了跳过所有缓存(不仅仅是你本地的磁盘缓存,也包括任何处于你和远程服务器之间的缓存代理服务器), 在headers字典里面加入no-cache头就可以了。- 现在你可以看见
httplib2初始化了一个网络请求。httplib2理解并尊重两个方向的缓存头, — 作为接受的响应的一部分以及作为发出的请求的一部分. 它注意到你加入了一个no-cache头,所以它完全跳过了本地的缓存,然后不得不去访问网络来请求数据。 - 这个响应不是从本地缓存生成的。你当然知道这一点,因为你看见了发出的请求的调试信息。但是从程序上再验证一下也不错。
- 请求成功;你再次从远程服务器下载了整个供稿。当然,服务器同供稿数据一起也返回了完整的 HTTP 头。这里面也包含缓存头,
httplib2会使用它来更新它的本地缓存,希望你下次请求该供稿时能够避免网络请求。HTTP 缓存被设计为尽量最大化缓存命中率和最小化网络访问。即使你这一次跳过了缓存,服务器仍非常乐意你能缓存结果以备下一次请求
httplib2怎么处理Last-Modified和ETag头
Cache-Control和Expires 缓存头 被称为新鲜度指标(freshness indicators)。他们毫不含糊告诉缓存,你可以完全避免所有网络访问,直到缓存过期。而这正是你在前一节所看到的: 给出一个新鲜度指标, httplib2 不会产生哪怕是一个字节的网络活动 就可以提供缓存了的数据(当然除非你显式的要求跳过缓存).
那如果数据可能已经改变了, 但实际没有呢? HTTP 为这种目的定义了Last-Modified和Etag头。 这些头被称为验证器(validators)。如果本地缓存已经不是新鲜的,客户端可以在下一个请求的时候发送验证器来检查数据实际上有没有改变。如果数据没有改变,服务器返回304状态码,但不返回数据。 所以虽然还会在网络上有一个来回,但是你最终可以少下载一点字节。
>>> import httplib2
>>> httplib2.debuglevel = 1
>>> h = httplib2.Http('.cache')
connect: (diveintopython3.org, 80)
send: b'GET / HTTP/1.1
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 200 OK'
{'-content-encoding': 'gzip',
'accept-ranges': 'bytes',
'connection': 'close',
'content-length': '6657',
'content-location': 'http://diveintopython3.org/',
'content-type': 'text/html',
'date': 'Tue, 02 Jun 2009 03:26:54 GMT',
<mark>'etag': '"7f806d-1a01-9fb97900"',</mark>
<mark>'last-modified': 'Tue, 02 Jun 2009 02:51:48 GMT',</mark>
'server': 'Apache',
'status': '200',
'vary': 'Accept-Encoding,User-Agent'}
6657
- 取代供稿,我们这一次要下载的是网站的主页,是 HTML 格式的。这是你第一次请求这个页面,
httplib2没什么能做的,它在请求中发出最少量的头。 - 响应包含了多个 HTTP 头… 但是没有缓存信息。然而,它包含了
ETag和Last-Modified头。 - 在我写这个例子的时候,这个页面有 6657 字节。在那之后,它很可能已经变了, 但是不用担心这一点。
# continued from the previous example
connect: (diveintopython3.org, 80)
send: b'GET / HTTP/1.1
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
True
200
'304'
6657
- 你再次请求同一个页面,使用同一个
Http对象(以及同一个本地缓存)。 httplib2将ETagvalidator 通过If-None-Match头发送回服务器。httplib2也将Last-Modifiedvalidator 通过If-Modified-Since头发送回服务器。- 服务器查看这些验证器(validators), 查看你请求的页面,然后判读得出页面在上次请求之后没有改变过, 所以它发回了
304状态码不带数据. - 回到客户端,
httplib2注意到304状态码并从它的缓存加载页面的内容。 - 这可能会让人有些困惑。这里实际上有两个 状态码 —
304(服务器这次返回的, 导致httplib2查看它的缓存), 和200(服务器上次返回的, 并和页面数据一起保存在httplib2的缓存里)。response.status返回缓存里的那个。 - 如果你需要服务器返回的原始的状态码,你可以从
response.dict里面找到, 它是包含服务器返回的真实头部的字典. - 然而,数据还是保存在了
content变量里。一般来说,你不需要关心为什么响应是从缓存里面来的。(你甚至不需要知道它是从缓存里来的, 这是一件好事。httplib2足够聪明,允许你傻瓜一点。)request()返回的时候,httplib2就已经更新了缓存并把数据返回给你了。
http2lib怎么处理压缩
“我们两种音乐都有,乡村的和西方的。”
HTTP 支持两种类型的压缩。httplib2都支持。
>>> response, content = h.request('http://diveintopython3.org/')
connect: (diveintopython3.org, 80)
send: b'GET / HTTP/1.1
Host: diveintopython3.org
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 200 OK'
>>> print(dict(response.items()))
'accept-ranges': 'bytes',
'connection': 'close',
'content-length': '6657',
'content-location': 'http://diveintopython3.org/',
'content-type': 'text/html',
'date': 'Tue, 02 Jun 2009 03:26:54 GMT',
'etag': '"7f806d-1a01-9fb97900"',
'last-modified': 'Tue, 02 Jun 2009 02:51:48 GMT',
'server': 'Apache',
'status': '304',
'vary': 'Accept-Encoding,User-Agent'}
- 每一次
httplib2发送请求,它包含了Accept-Encoding头来告诉服务器它能够处理deflate或者gzip压缩。 - 这个例子中,服务器返回了 gzip 压缩过的负载,当
request()方法返回的时候,httplib2就已经解压缩了响应的体(body)并将其放在content变量里。如果你想知道响应是否压缩过, 你可以检查response['-content-encoding']; 否则,不用担心了.
httplib2怎样处理重定向
HTTP 定义了 两种类型的重定向: 临时的和永久的。对于临时重定向,除了跟随它们其他没有什么特别要做的, httplib2 会自动处理跟随。
>>> import httplib2
>>> httplib2.debuglevel = 1
>>> h = httplib2.Http('.cache')
connect: (diveintopython3.org, 80)
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 200 OK'
- 这个 URL 上没有供稿。我设置了服务器让其发出一个到正确地址的临时重定向。
- 这是请求。
- 这是响应:
302 Found。这里没有显示出来,这个响应也包含由一个Location头给出实际的 URL. httplib2马上转身并跟随重定向,发出另一个到在Location头里面给出的 URL:http://diveintopython3.org/examples/feed.xml的请求。
“跟随” 一个重定向就是这个例子展示的那么多。httplib2 发送一个请求到你要求的 URL。服务器返回一个响应说“不,不, 看那边.” httplib2 给新的 URL 发送另一个请求.
# continued from the previous example
{'status': '200',
'content-length': '3070',
'accept-ranges': 'bytes',
'expires': 'Thu, 04 Jun 2009 02:21:41 GMT',
'vary': 'Accept-Encoding',
'server': 'Apache',
'last-modified': 'Wed, 03 Jun 2009 02:20:15 GMT',
'connection': 'close',
'etag': '"bfe-4cbbf5c0"',
'date': 'Wed, 03 Jun 2009 02:21:41 GMT',
'content-type': 'application/xml'}
- 你调用
request()方法返回的response是最终 URL 的响应。 httplib2会将最终的 URL 以content-location加入到response字典中。这不是服务器返回的头,它特定于httplib2。- 没什么特别的理由, 这个供稿是压缩过的.
- 并且是可缓存的. (等一下你会看到,这很重要。)
你得到的response给了你最终 URL 的相关信息。如果你希望那些最后重定向到最终 URL 的中间 URL 的信息呢?httplib2 也能帮你。
# continued from the previous example
{'status': '302',
'content-length': '228',
'content-location': 'http://diveintopython3.org/examples/feed-302.xml',
'expires': 'Thu, 04 Jun 2009 02:21:41 GMT',
'server': 'Apache',
'connection': 'close',
'location': 'http://diveintopython3.org/examples/feed.xml',
'cache-control': 'max-age=86400',
'date': 'Wed, 03 Jun 2009 02:21:41 GMT',
'content-type': 'text/html; charset=iso-8859-1'}
<class 'httplib2.Response'>
>>> type(response.previous)
<class 'httplib2.Response'>
>>>
response.previous属性持有前一个响应对象的引用,httplib2跟随那个响应获得了当前的响应对象。response和response.previous都是httplib2.Response对象。- 这意味着你可以通过
response.previous.previous来反向跟踪重定向链到更前的请求。(场景: 一个 URL 重定向到第二个 URL,它又重定向到第三个 URL。这可能发生!) 在这例子里,我们已经到达了重定向链的开头,所有这个属性是None.
如果我们再次请求同一个 URL 会发生什么?
# continued from the previous example
connect: (diveintopython3.org, 80)
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
True
- 同一个 URL, 同一个
httplib2.Http对象 (所以也是同一个缓存)。 302响应没有缓存,所以httplib2对同一个 URL 发送了另一个请求。- 再一次,服务器以
302响应。但是请注意什么没有 发生: 没有第二个到最终 URL,http://diveintopython3.org/examples/feed.xml的请求。原因是缓存 (还记的你在前一个例子中看到的Cache-Control头吗?)。 一旦httplib2收到302 Found状态码, 它在发出新的请求前检查它的缓存. 缓存中有http://diveintopython3.org/examples/feed.xml的一份新鲜副本, 所以不需要重新请求它了。 - 当
request()方法返回的时候,它已经从缓存中读取了 feed 数据并返回了它。当然,它和你上次收到的数据是一样的。
换句话说,对于临时重定向你不需要做什么特别的处理。httplib2 会自动跟随它们,而一个 URL 重定向到另一个这个事实上不会影响httplib2对压缩,缓存, ETags, 或者任何其他 HTTP 特性的支持。
永久重定向同样也很简单。
# continued from the previous example
connect: (diveintopython3.org, 80)
send: b'GET /examples/feed-301.xml HTTP/1.1
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
True
- 又一次,这个 URL 实际上并不存在。我设置我的服务器来执行一个永久重定向到
http://diveintopython3.org/examples/feed.xml. - 这就是: 状态码
301。 但是再次注意什么没有发生: 没有发送到重定向后的 URL 的请求。为什么没有? 因为它已经在本地缓存了。 httplib2“跟随” 重定向到了它的缓存里面。
但是等等! 还有更多!
# continued from the previous example
True
True
- 这是临时和永久重定向的区别: 一旦
httplib2跟随了一个永久重定向, 所有后续的对这个 URL 的请求会被透明的重写到目标 URL 而不会接触网络来访问原始的 URL。 记住, 调试还开着, 但没有任何网络活动的输出。 - 耶, 响应是从本地缓存获取的。
- 耶, 你(从缓存里面)得到了整个供稿。
HTTP. 它可以工作。
HTTP GET 之外
HTTP web 服务并不限于GET请求。当你要创建点东西的时候呢?当你在论坛上发表一个评论,更新你的博客,在Twitter 或者 Identi.ca这样的微博客上面发表状态消息的时候, 你很可能已经使用了 HTTP POST.
Twitter 和 Identi.ca 都提供一个基于 HTTP 的简单的 API 来发布并更新你状态(不超过 140 个字符)。让我们来看看Identi.ca 的关于更新状态的 API 文档 :
Identi.ca 的 REST API 方法: statuses/update 更新已认证用户的状态。需要下面格式的
status参数。请求必须是POST.URL
https://identi.ca/api/statuses/update._format_Formats
xml,json,rss,atomHTTP Method(s)
POSTRequires Authentication
true
Parameters
status. Required. The text of your status update. URL-encode as necessary.
怎么操作呢?要在 Identi.ca 发布一条消息, 你需要提交一个 HTTP POST请求到http://identi.ca/api/statuses/update._format_. (format字样不是 URL 的一部分; 你应该将其替换为你希望服务器返回的请求的格式。所以如果需要一个 XML 格式的返回。你应该向https://identi.ca/api/statuses/update.xml发送请求。) 请求需要一个参数status, 包含了你的状态更新文本。并且请求必须是已授权的。
授权? 当然。要在 Identi.ca 上发布你的状态更新, 你得证明你的身份。Identi.ca 不是一个维基; 只有你自己可以更新你的状态。Identi.ca 使用建立在 SSL 之上的HTTP Basic Authentication (也就是RFC 2617) 来提供安全但方便的认证。httplib2 支持 SSL 和 HTTP Basic Authentication, 所以这部分很简单。
POST 请求同GET 请求不同, 因为它包含负荷(payload). 负荷是你要发送到服务器的数据。这个 API 方法必须的参数是status, 并且它应该是URL 编码过的。 这是一种很简单的序列化格式,将一组键值对(比如字典)转化为一个字符串。
'status=Test+update+from+Python+3'
- Python 带有一个工具函数用于 URL 编码一个字典:
urllib.parse.urlencode(). - 这就是 Identi.ca API 所期望的字典。它包含一个键,
status, 对应值是状态更新文本。 - 这是 URL 编码之后的字符串的样子。这就是会通过线路发送到 Identi.ca API 服务器的 HTTP
POST请求中的负荷 .
>>> from urllib.parse import urlencode
>>> import httplib2
>>> httplib2.debuglevel = 1
>>> h = httplib2.Http('.cache')
>>> data = {'status': 'Test update from Python 3'}
>>> resp, content = h.request('https://identi.ca/api/statuses/update.xml',
- 这是
httplib2处理认证的方法。add_credentials()方法记录你的用户名和密码。当httplib2试图执行请求的时候,服务器会返回一个401 Unauthorized状态码, 并且列出所有它支持的认证方法(在WWW-Authenticate头中).httplib2会自动构造Authorization头并且重新请求该 URL. - 第二个参数是 HTTP 请求的类型。这里是
POST. - 第三个参数是要发送到服务器的负荷 。我们发送包含状态消息的 URL 编码过的字典。
- 最后,我们得告诉服务器负荷是 URL 编码过的数据。
☞
add_credentials()方法的第三个参数是该证书有效的域名。你应该总是指定这个参数! 如果你省略了这个参数,并且之后重用这个httplib2.Http对象访问另一个需要认证的站点,可能会导致httplib2将一个站点的用户名密码泄漏给其他站点。
发送到线路上的数据:
# continued from the previous example
send: b'POST /api/statuses/update.xml HTTP/1.1
Host: identi.ca
Accept-Encoding: identity
Content-Length: 32
content-type: application/x-www-form-urlencoded
user-agent: Python-httplib2/$Rev: 259 $
status=Test+update+from+Python+3'
Host: identi.ca
Accept-Encoding: identity
Content-Length: 32
content-type: application/x-www-form-urlencoded
user-agent: Python-httplib2/$Rev: 259 $
status=Test+update+from+Python+3'
- 第一个请求,服务器以
401 Unauthorized状态码返回。httplib2从不主动发送认证头,除非服务器明确的要求。这就是服务器要求认证头的方法。 httplib2马上转个身,第二次请求同样的 URL 。- 这一次,包含了你通过
add_credentials()方法加入的用户名和密码。 - 成功!
请求成功后服务器返回什么?这个完全由 web 服务 API 决定。 在一些协议里面(就像 Atom Publishing Protocol), 服务器会返回201 Created状态码,并通过Location提供新创建的资源的地址。Identi.ca 返回200 OK 和一个包含新创建资源信息的 XML 文档。
# continued from the previous example
<?xml version="1.0" encoding="UTF-8"?>
<status>
<truncated>false</truncated>
<created_at>Wed Jun 10 03:53:46 +0000 2009</created_at>
<in_reply_to_status_id></in_reply_to_status_id>
<source>api</source>
<in_reply_to_user_id></in_reply_to_user_id>
<in_reply_to_screen_name></in_reply_to_screen_name>
<favorited>false</favorited>
<user>
<id>3212</id>
<name>Mark Pilgrim</name>
<screen_name>diveintomark</screen_name>
<location>27502, US</location>
<description>tech writer, husband, father</description>
<profile_image_url>http://avatar.identi.ca/3212-48-20081216000626.png</profile_image_url>
<url>http://diveintomark.org/</url>
<protected>false</protected>
<followers_count>329</followers_count>
<profile_background_color></profile_background_color>
<profile_text_color></profile_text_color>
<profile_link_color></profile_link_color>
<profile_sidebar_fill_color></profile_sidebar_fill_color>
<profile_sidebar_border_color></profile_sidebar_border_color>
<friends_count>2</friends_count>
<created_at>Wed Jul 02 22:03:58 +0000 2008</created_at>
<favourites_count>30768</favourites_count>
<utc_offset>0</utc_offset>
<time_zone>UTC</time_zone>
<profile_background_image_url></profile_background_image_url>
<profile_background_tile>false</profile_background_tile>
<statuses_count>122</statuses_count>
<following>false</following>
<notifications>false</notifications>
</user>
</status>
- 记住,
httplib2返回的数据总是字节串(bytes), 不是字符串。为了将其转化为字符串,你需要用合适的字符编码进行解码。Identi.ca 的 API 总是返回 UTF-8 编码的结果, 所以这部分很简单。 - 这是我们刚发布的状态消息。
- 这是新状态消息的唯一标识符。Identi.ca 用这个标识来构造在 web 上查看该消息的 URL。
下面就是这条消息:
HTTP POST 之外
HTTP 并不只限于GET 和 POST。 它们当然是最常见的请求类型,特别是在 web 浏览器里面。 但是 web 服务 API 会使用GET和POST之外的东西, 对此httplib2也能处理。
# continued from the previous example
>>> from xml.etree import ElementTree as etree
>>> status_id
'5131472'
- 服务器返回的是 XML, 对吧? 你知道如何解析 XML.
findtext()方法找到对应表达式的第一个实例并抽取出它的文本内容。在这个例子中,我们查找<id>元素.- 基于
<id>元素的文本内容,我们可以构造出一个 URL 用于删除我们刚刚发布的状态消息。 - 要删除一条消息,你只需要对该 URL 执行一个 HTTP
DELETE请求就可以了。
这就是发送到线路上的东西:
Host: identi.ca
Accept-Encoding: identity
user-agent: Python-httplib2/$Rev: 259 $
'
Host: identi.ca
Accept-Encoding: identity
user-agent: Python-httplib2/$Rev: 259 $
'
>>> resp.status
200
- “删除该状态消息.”
- “对不起,Dave, 恐怕我不能这么干”
- “没有授权‽ 恩. 请删除这条消息…
- …这是我的用户名和密码。”
- “应该是完成了!”
证明确实是这样的,它不见了。
进一步阅读
httplib2:
HTTP 缓存:
- HTTP 缓存教程 来自 Mark Nottingham
- 怎用使用 HTTP 头控制缓存 位于 Google Doctype
RFCs:
- RFC 2616: HTTP
- RFC 2617: HTTP Basic Authentication
- RFC 1951: deflate compression
- RFC 1952: gzip compression
Chapter 15 案例研究:将chardet移植到 Python 3
" Words, words. They’re all we have to go on. " — Rosencrantz and Guildenstern are Dead
概述
未知的或者不正确的字符编码是因特网上无效数据(gibberish text)的头号起因。在第三章,我们讨论过字符编码的历史,还有 Unicode 的产生,“一个能处理所有情况的大块头。”如果在网络上不再存在乱码这回事,我会爱上她的…因为所有的编辑系统(authoring system)保存有精确的编码信息,所有的传输协议都支持 Unicode,所有处理文本的系统在执行编码间转换的时候都可以保持高度精确。
我也会喜欢 pony。
Unicode pony。
Unipony 也行。
这一章我会处理编码的自动检测。
什么是字符编码自动检测?
它是指当面对一串不知道编码信息的字节流的时候,尝试着确定一种编码方式以使我们能够读懂其中的文本内容。它就像我们没有解密钥匙的时候,尝试破解出编码。
那不是不可能的吗?
通常来说,是的,不可能。但是,有一些编码方式为特定的语言做了优化,而语言并非随机存在的。有一些字符序列在某种语言中总是会出现,而其他一些序列对该语言来说则毫无意义。一个熟练掌握英语的人翻开报纸,然后发现“txzqJv 2!dasd0a QqdKjvz”这样一些序列,他会马上意识到这不是英语(即使它完全由英语中的字母组成)。通过研究许多具有“代表性(typical)”的文本,计算机算法可以模拟人的这种对语言的感知,并且对一段文本的语言做出启发性的猜测。
换句话说就是,检测编码信息就是检测语言的类型,并辅之一些额外信息,比如每种语言通常会使用哪些编码方式。
这样的算法存在吗?
结果证明,是的,它存在。所有主流的浏览器都有字符编码自动检测的功能,因为因特网上总是充斥着大量缺乏编码信息的页面。Mozilla Firefox 包含有一个自动检测字符编码的库,它是开源的。我将它导入到了 Python 2,并且取绰号为chardet模块。这一章中,我会带领你一步一步地将chardet模块从 Python 2 移植到 Python 3。
介绍chardet模块
在开始代码移植之前,如果我们能理解代码是如何工作的这将非常有帮助!以下是一个简明地关于chardet模块代码结构的手册。chardet库太大,不可能都放在这儿,但是你可以从chardet.feedparser.org下载它。
编码检测就是语言检测。
universaldetector.py是检测算法的主入口点,它包含一个类,即UniversalDetector。(可能你会认为入口点是chardet/__init__.py中的detect函数,但是它只是一个便捷的包装方法,它会创建UniversalDetector对象,调用对象的方法,然后返回其结果。)
UniversalDetector共处理5类编码方式:
- 包含字节顺序标记(BOM)的 UTF-n。它包括 UTF-8,大尾端和小尾端的 UTF-16,还有所有4字节顺序的 UTF-32 的变体。
- 转义编码,它们与7字节的 ASCII 编码兼容,非 ASCII 编码的字符会以一个转义序列打头。比如:ISO-2022-JP(日文)和 HZ-GB-2312(中文).
- 多字节编码,在这种编码方式中,每个字符使用可变长度的字节表示。比如:Big5(中文),SHIFT_JIS(日文),EUC-KR(韩文)和缺少 BOM 标记的 UTF-8。
- 单字节编码,这种编码方式中,每个字符使用一个字节编码。例如:KOI8-R(俄语),windows-1255(希伯来语)和 TIS-620(泰国语)。
- windows-1252,它主要被根本不知道字符编码的中层管理人员(middle manager)在 Microsoft Windows 上使用。
有 BOM 标记的 UTF-n
如果文本以 BOM 标记打头,我们可以合理地假设它使用了 UTF-8,UTF-16 或者 UTF-32 编码。(BOM 会告诉我们是其中哪一种,这就是它的功能。)这个过程在UniversalDetector中完成,并且不需要深入处理,会非常快地返回其结果。
转义编码
如果文本包含有可识别的能指示出某种转义编码的转义序列,UniversalDetector会创建一个EscCharSetProber对象(在escprober.py中定义),然后以该文本调用它。
EscCharSetProber会根据 HZ-GB-2312,ISO-2022-CN,ISO-2022-JP,和 ISO-2022-KR(在escsm.py中定义)来创建一系列的状态机(state machine)。EscCharSetProber将文本一次一个字节地输入到这些状态机中。如果某一个状态机最终唯一地确定了字符编码,EscCharSetProber迅速地将该有效结果返回给UniversalDetector,然后UniversalDetector将其返回给调用者。如果某一状态机进入了非法序列,它会被放弃,然后使用其他的状态机继续处理。
多字节编码
假设没有 BOM 标记,UniversalDetector会检测该文本是否包含任何高位字符(high-bit character)。如果有的话,它会创建一系列的“探测器(probers)”,检测这段广西是否使用多字节编码,单字节编码,或者作为最后的手段,是否为windows-1252编码。
这里的多字节编码探测器,即MBCSGroupProber(在mbcsgroupprober.py中定义),实际上是一个管理一组其他探测器的 shell,它用来处理每种多字节编码:Big5,GB2312,EUC-TW,EUC-KR,EUC-JP,SHIFT_JIS 和 UTF-8。MBCSGroupProber将文本作为每一个特定编码探测器的输入,并且检测其结果。如果某个探测器报告说它发现了一个非法的字节序列,那么该探测器则会被放弃,不再进一步处理(因此,换句话说就是,任何对UniversalDetector.feed()的子调用都会忽略那个探测器)。如果某一探测器报告说它有足够理由确信找到了正确的字符编码,那么MBCSGroupProber会将这个好消息传递给UniversalDetector,然后UniversalDetector将结果返回给调用者。
大多数的多字节编码探测器从类MultiByteCharSetProber(定义在mbcharsetprober.py中)继承而来,简单地挂上合适的状态机和分布分析器(distribution analyzer),然后让MultiByteCharSetProber做剩余的工作。MultiByteCharSetProber将文本作为特定编码状态机的输入,每次一个字节,寻找能够指示出一个确定的正面或者负面结果的字节序列。同时,MultiByteCharSetProber会将文本作为特定编码分布分析机的输入。
分布分析机(在chardistribution.py中定义)使用特定语言的模型,此模型中的字符在该语言被使用得最频繁。一旦MultiByteCharSetProber把足够的文本给了分布分析机,它会根据其中频繁使用字符的数目,字符的总数和特定语言的分配比(distribution ratio),来计算置信度(confidence rating)。如果置信度足够高,MultiByteCharSetProber会将结果返回给MBCSGroupProber,然后由MBCSGroupProber返回给UniversalDetector,最后UniversalDetector将其返回给调用者。
对于日语来说检测会更加困难。单字符的分布分析并不总能区别出EUC-JP和SHIFT_JIS,所以SJISProber(在sjisprober.py中定义)也使用双字符的分布分析。SJISContextAnalysis和EUCJPContextAnalysis(都定义在jpcntx.py中,并且都从类JapaneseContextAnalysis中继承)检测文本中的平假名音节字符(Hiragana syllabary characher)的出现次数。一旦处理了足够量的文本,它会返回一个置信度给SJISProber,SJISProber检查两个分析器的结果,然后将置信度高的那个返回给MBCSGroupProber。
单字节编码
说正经的,我的 Unicode pony 哪儿去了?
单字节编码的探测器,即SBCSGroupProber(定义在sbcsgroupprober.py中),也是一个管理一组其他探测器的 shell,它会尝试单字节编码和语言的每种组合:windows-1251,KOI8-R,ISO-8859-5,MacCyrillic,IBM855,and IBM866(俄语);ISO-8859-7和windows-1253(希腊语);ISO-8859-5和windows-1251(保加利亚语);ISO-8859-2和windows-1250(匈牙利语);TIS-620(泰国语);windows-1255和ISO-8859-8(希伯来语)。
SBCSGroupProber将文本输入给这些特定编码+语言的探测器,然后检测它们的返回值。这些探测器的实现为某一个类,即SingleByteCharSetProber(在sbcharsetprober.py中定义),它使用语言模型(language model)作为其参数。语言模型定义了典型文本中不同双字符序列出现的频度。SingleByteCharSetProber处理文本,统计出使用得最频繁的双字符序列。一旦处理了足够多的文本,它会根据频繁使用的序列的数目,字符总数和特定语言的分布系数来计算其置信度。
希伯来语被作为一种特殊的情况处理。如果在双字符分布分析中,文本被认定为是希伯来语,HebrewProber(在hebrewprober.py中定义)会尝试将其从 Visual Hebrew(源文本一行一行地被“反向”存储,然后一字不差地显示出来,这样就能从右到左的阅读)和 Logical Hebrew(源文本以阅读的顺序保存,在客户端从右到左进行渲染)区别开来。因为有一些字符在两种希伯来语中会以不同的方式编码,这依赖于它们是出现在单词的中间或者末尾,这样我们可以合理的猜测源文本的存储方向,然后返回合适的编码方式(windows-1255对应 Logical Hebrew,或者ISO-8859-8对应 Visual Hebrew)。
windows-1252
如果UniversalDetector在文本中检测到一个高位字符,但是其他的多字节编码探测器或者单字节编码探测器都没有返回一个足够可靠的结果,它就会创建一个Latin1Prober对象(在latin1prober.py中定义),尝试从中检测以windows-1252方式编码的英文文本。这种检测存在其固有的不可靠性,因为在不同的编码中,英文字符通常使用了相同的编码方式。唯一一种区别能出windows-1252的方法是通过检测常用的符号,比如弯引号(smart quotes),撇号(curly apostrophes),版权符号(copyright symbol)等这一类的符号。如果可能Latin1Prober会自动降低其置信度以使其他更精确的探测器检出结果。
运行2to3
我们将要开始移植chardet模块到 Python 3 了。Python 3 自带了一个叫做2to3的实用脚本,它使用 Python 2 的源代码作为输入,然后尽其可能地将其转换到 Python 3 的规范。某些情况下这很简单 — 一个被重命名或者被移动到其他模块中的函数 — 但是有些情况下,这个过程会变得非常复杂。想要了解所有它能做的事情,请参考附录,使用2to3将代码移植到 Python 3。接下来,我们会首先运行一次2to3,将它作用在chardet模块上,但是就如你即将看到的,在该自动化工具完成它的魔法表演后,仍然存在许多工作需要我们来收拾。
chardet包被分割为一些不同的文件,它们都放在同一个目录下。2to3能够立即处理多个文件:只需要将目录名作为命令行参数传递给2to3,然后它会轮流处理每个文件。
C:\home\chardet> python c:\Python30\Tools\Scripts\2to3.py -w chardet\
RefactoringTool: Skipping implicit fixer: buffer
RefactoringTool: Skipping implicit fixer: idioms
RefactoringTool: Skipping implicit fixer: set_literal
RefactoringTool: Skipping implicit fixer: ws_comma
--- chardet\__init__.py (original)
+++ chardet\__init__.py (refactored)
@@ -18,7 +18,7 @@
__version__ = "1.0.1"
def detect(aBuf):
~~- import universaldetector~~
<ins>+ from . import universaldetector</ins>
u = universaldetector.UniversalDetector()
u.reset()
u.feed(aBuf)
--- chardet\big5prober.py (original)
+++ chardet\big5prober.py (refactored)
@@ -25,10 +25,10 @@
# 02110-1301 USA
######################### END LICENSE BLOCK #########################
~~-from mbcharsetprober import MultiByteCharSetProber~~
~~-from codingstatemachine import CodingStateMachine~~
~~-from chardistribution import Big5DistributionAnalysis~~
~~-from mbcssm import Big5SMModel~~
<ins>+from .mbcharsetprober import MultiByteCharSetProber</ins>
<ins>+from .codingstatemachine import CodingStateMachine</ins>
<ins>+from .chardistribution import Big5DistributionAnalysis</ins>
<ins>+from .mbcssm import Big5SMModel</ins>
class Big5Prober(MultiByteCharSetProber):
def __init__(self):
--- chardet\chardistribution.py (original)
+++ chardet\chardistribution.py (refactored)
@@ -25,12 +25,12 @@
# 02110-1301 USA
######################### END LICENSE BLOCK #########################
~~-import constants~~
~~-from euctwfreq import EUCTWCharToFreqOrder, EUCTW_TABLE_SIZE, EUCTW_TYPICAL_DISTRIBUTION_RATIO~~
~~-from euckrfreq import EUCKRCharToFreqOrder, EUCKR_TABLE_SIZE, EUCKR_TYPICAL_DISTRIBUTION_RATIO~~
~~-from gb2312freq import GB2312CharToFreqOrder, GB2312_TABLE_SIZE, GB2312_TYPICAL_DISTRIBUTION_RATIO~~
~~-from big5freq import Big5CharToFreqOrder, BIG5_TABLE_SIZE, BIG5_TYPICAL_DISTRIBUTION_RATIO~~
~~-from jisfreq import JISCharToFreqOrder, JIS_TABLE_SIZE, JIS_TYPICAL_DISTRIBUTION_RATIO~~
<ins>+from . import constants</ins>
<ins>+from .euctwfreq import EUCTWCharToFreqOrder, EUCTW_TABLE_SIZE, EUCTW_TYPICAL_DISTRIBUTION_RATIO</ins>
<ins>+from .euckrfreq import EUCKRCharToFreqOrder, EUCKR_TABLE_SIZE, EUCKR_TYPICAL_DISTRIBUTION_RATIO</ins>
<ins>+from .gb2312freq import GB2312CharToFreqOrder, GB2312_TABLE_SIZE, GB2312_TYPICAL_DISTRIBUTION_RATIO</ins>
<ins>+from .big5freq import Big5CharToFreqOrder, BIG5_TABLE_SIZE, BIG5_TYPICAL_DISTRIBUTION_RATIO</ins>
<ins>+from .jisfreq import JISCharToFreqOrder, JIS_TABLE_SIZE, JIS_TYPICAL_DISTRIBUTION_RATIO</ins>
ENOUGH_DATA_THRESHOLD = 1024
SURE_YES = 0.99
.
.
<mark>. (it goes on like this for a while)</mark>
.
.
RefactoringTool: Files that were modified:
RefactoringTool: chardet\__init__.py
RefactoringTool: chardet\big5prober.py
RefactoringTool: chardet\chardistribution.py
RefactoringTool: chardet\charsetgroupprober.py
RefactoringTool: chardet\codingstatemachine.py
RefactoringTool: chardet\constants.py
RefactoringTool: chardet\escprober.py
RefactoringTool: chardet\escsm.py
RefactoringTool: chardet\eucjpprober.py
RefactoringTool: chardet\euckrprober.py
RefactoringTool: chardet\euctwprober.py
RefactoringTool: chardet\gb2312prober.py
RefactoringTool: chardet\hebrewprober.py
RefactoringTool: chardet\jpcntx.py
RefactoringTool: chardet\langbulgarianmodel.py
RefactoringTool: chardet\langcyrillicmodel.py
RefactoringTool: chardet\langgreekmodel.py
RefactoringTool: chardet\langhebrewmodel.py
RefactoringTool: chardet\langhungarianmodel.py
RefactoringTool: chardet\langthaimodel.py
RefactoringTool: chardet\latin1prober.py
RefactoringTool: chardet\mbcharsetprober.py
RefactoringTool: chardet\mbcsgroupprober.py
RefactoringTool: chardet\mbcssm.py
RefactoringTool: chardet\sbcharsetprober.py
RefactoringTool: chardet\sbcsgroupprober.py
RefactoringTool: chardet\sjisprober.py
RefactoringTool: chardet\universaldetector.py
RefactoringTool: chardet\utf8prober.py
现在我们对测试工具 — test.py — 应用2to3脚本。
C:\home\chardet> python c:\Python30\Tools\Scripts\2to3.py -w test.py
RefactoringTool: Skipping implicit fixer: buffer
RefactoringTool: Skipping implicit fixer: idioms
RefactoringTool: Skipping implicit fixer: set_literal
RefactoringTool: Skipping implicit fixer: ws_comma
--- test.py (original)
+++ test.py (refactored)
@@ -4,7 +4,7 @@
count = 0
u = UniversalDetector()
for f in glob.glob(sys.argv[1]):
~~- print f.ljust(60),~~
<ins>+ print(f.ljust(60), end=' ')</ins>
u.reset()
for line in file(f, 'rb'):
u.feed(line)
@@ -12,8 +12,8 @@
u.close()
result = u.result
if result['encoding']:
~~- print result['encoding'], 'with confidence', result['confidence']~~
<ins>+ print(result['encoding'], 'with confidence', result['confidence'])</ins>
else:
~~- print '******** no result'~~
<ins>+ print('******** no result')</ins>
count += 1
~~-print count, 'tests'~~
<ins>+print(count, 'tests')</ins>
RefactoringTool: Files that were modified:
RefactoringTool: test.py
看吧,还不算太难。只是转换了一些 impor 和 print 语句。说到这儿,那些 import 语句原来到底存在什么问题呢?为了回答这个问题,你需要知道chardet是如果被分割到多个文件的。
题外话,关于多文件模块
chardet是一个多文件模块。我也可以将所有的代码都放在一个文件里(并命名为chardet.py),但是我没有。我创建了一个目录(叫做chardet),然后我在那个目录里创建了一个__init__.py文件。*如果 Python 看到目录里有一个__init__.py文件,它会假设该目录里的所有文件都是同一个模块的某部分。*模块名为目录的名字。目录中的文件可以引用目录中的其他文件,甚至子目录中的也行。(再讲一分钟这个。)但是整个文件集合被作为一个单独的模块呈现给其他的 Python 代码 — 就好像所有的函数和类都在一个.py文件里。
在__init__.py中到底有些什么?什么也没有。一切。界于两者之间。__init__.py文件不需要定义任何东西;它确实可以是一个空文件。或者也可以使用它来定义我们的主入口函数。或者把我们所有的函数都放进去。或者其他函数都放,单单不放某一个函数…
☞包含有
__init__.py文件的目录总是被看作一个多文件的模块。没有__init__.py文件的目录中,那些.py文件是不相关的。
我们来看看它实际上是怎样工作的。
>>> import chardet
['__builtins__', '__doc__', '__file__', '__name__',
'__package__', '__path__', '__version__', 'detect']
<module 'chardet' from 'C:\Python31\lib\site-packages\chardet\__init__.py'>
- 除了常见的类属性,在
chardet模块中只多了一个detect()函数。 - 这是我们发觉
chardet模块不只是一个文件的第一个线索:“module”被当作文件chardet/目录中的__init__.py文件列出来。
我们再来瞟一眼__init__.py文件。
u = universaldetector.UniversalDetector()
u.reset()
u.feed(aBuf)
u.close()
return u.result
__init__.py文件定义了detect()函数,它是chardet库的主入口点。- 但是
detect()函数没有任何实际的代码!事实上,它所做的事情只是导入了universaldetector模块然后开始调用它。但是universaldetector定义在哪儿?
答案就在那行古怪的import语句中:
from . import universaldetector
翻译成中文就是,“导入universaldetector模块;它跟我在同一目录,”这里的我即指文件chardet/__init__.py。这是一种提供给多文件模块中文件之间互相引用的方法,不需要担心它会与已经安装的搜索路径中的模块发生命名冲突。该条import语句只会在chardet/目录中查找universaldetector模块。
这两条概念 — __init__.py和相对导入 — 意味着我们可以将模块分割为任意多个块。chardet模块由 36 个.py文件组成 — 36!但我们所需要做的只是使用chardet/__init__.py文件中定义的某个函数。还有一件事情没有告诉你,detect()使用了相对导入来引用了chardet/universaldetector.py中定义的一个类,然后这个类又使用了相对导入引用了其他 5 个文件的内容,它们都在chardet/目录中。
☞如果你发现自己正在用 Python 写一个大型的库(或者更可能的情况是,当你意识到你的小模块已经变得很大的时候),最好花一些时间将它重构为一个多文件模块。这是 Python 所擅长的许多事情之一,那就利用一下这个优势吧。
修复2to3脚本所不能做的
False is invalid syntax
你确实有测试样例,对吧?
现在开始真正的测试:使用测试集运行测试工具。由于测试集被设计成可以覆盖所有可能的代码路径,它是用来测试移植后的代码,保证 bug 不会埋伏在某个地方的一种不错的办法。
C:\home\chardet> python test.py tests\*\*
Traceback (most recent call last):
File "test.py", line 1, in <module>
from chardet.universaldetector import UniversalDetector
File "C:\home\chardet\chardet\universaldetector.py", line 51
self.done = constants.False
^
SyntaxError: invalid syntax
唔,一个小麻烦。在 Python 3 中,False是一个保留字,所以不能把它用作变量名。我们来看一看constants.py来确定这是在哪儿定义的。以下是constants.py在执行2to3脚本之前原来的版本。
import __builtin__
if not hasattr(__builtin__, 'False'):
False = 0
True = 1
else:
False = __builtin__.False
True = __builtin__.True
这一段代码用来允许库在低版本的 Python 2 中运行,在 Python 2.3 以前,Python 没有内置的bool类型。这段代码检测内置的True和False常量是否缺失,如果必要的话则定义它们。
但是,Python 3 总是有bool类型的,所以整个这片代码都没有必要。最简单的方法是将所有的constants.True和constants.False都分别替换成True和False,然后将这段死代码从constants.py中移除。
所以universaldetector.py中的以下行:
self.done = constants.False
变成了
self.done = False
啊哈,是不是很有满足感?代码不仅更短了,而且更具可读性。
No module named constants
是时候再运行一次test.py了,看看它能走多远。
C:\home\chardet> python test.py tests\*\*
Traceback (most recent call last):
File "test.py", line 1, in <module>
from chardet.universaldetector import UniversalDetector
File "C:\home\chardet\chardet\universaldetector.py", line 29, in <module>
import constants, sys
ImportError: No module named constants
说什么了?不存在叫做constants的模块?可是当然有constants这个模块了。它就在chardet/constants.py中。
还记得什么时候2to3脚本会修复所有那些导入语句吗?这个包内有许多的相对导入 — 即,在同一个库中,导入其他模块的模块 — 但是在Python 3 中相对导入的逻辑已经变了。在 Python 2 中,我们只需要import constants,然后它就会首先在chardet/目录中查找。在 Python 3 中,所有的导入语句默认使用绝对路径。如果想要在 Python 3 中使用相对导入,你需要显式地说明:
from . import constants
但是。2to3脚本难道不是要自动修复这些的吗?好吧,它确实这样做了,但是该条导入语句在同一行组合了两种不同的导入类型:库内部对constants的相对导入,还有就是对sys模块的绝对导入,sys模块已经预装在了 Python 的标准库里。在 Python 2 里,我们可以将其组合到一条导入语句中。在 Python 3 中,我们不能这样做,并且2to3脚本也不是那样聪明,它不能把这条导入语句分成两条。
解决的办法是把这条导入语句手动的分成两条。所以这条二合一的导入语句:
import constants, sys
需要变成两条分享的导入语句:
from . import constants
import sys
在chardet库中还分散着许多这类问题的变体。某些地方它是“import constants, sys”;其他一些地方则是“import constants, re”。修改的方法是一样的:手工地将其分割为两条语句,一条为相对导入准备,另一条用于绝对导入。
前进!
Name 'file' is not defined
open()代替了原来的 file()。PapayaWhip 则替代了原来的 black
再来一次,运行test.py来执行我们的测试样例…
C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
File "test.py", line 9, in <module>
for line in file(f, 'rb'):
NameError: name 'file' is not defined
这一条也出乎我的意外,因为在记忆中我一直都在使用这种风格的代码。在 Python 2 里,全局的file()函数是open()函数的一个别名,open()函数是打开文件用于读取的标准方法。在 Python 3 中,全局的file()函数不再存在了,但是open()还保留着。
这样的话,最简单的解决办法就是将file()调用替换为对open()的调用:
for line in open(f, 'rb'):
这即是我关于这个问题想要说的。
Can’t use a string pattern on a bytes-like object
现在事情开始变得有趣了。对于“有趣,”我的意思是“跟地狱一样让人迷茫。”
C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
File "test.py", line 10, in <module>
u.feed(line)
File "C:\home\chardet\chardet\universaldetector.py", line 98, in feed
if self._highBitDetector.search(aBuf):
TypeError: can't use a string pattern on a bytes-like object
我们先来看看self._highBitDetector是什么,然后再来调试这个错误。它被定义在UniversalDetector类的__init__方法中。
class UniversalDetector:
def __init__(self):
self._highBitDetector = re.compile(r'[\x80-\xFF]')
这段代码预编译一条正则表达式,它用来查找在 128–255 (0x80–0xFF)范围内的非 ASCII 字符。等一下,这似乎不太准确;我需要对更精确的术语来描述它。这个模式用来在 128-255 范围内查找非 ASCII 的bytes。
问题就出在这儿了。
在 Python 2 中,字符串是一个字节数组,它的字符编码信息被分开记录着。如果想要 Python 2 跟踪字符编码,你得使用 Unicode 编码的字符串(u'')。但是在 Python 3 中,字符串永远都是 Python 2 中所谓的 Unicode 编码的字符串 — 即,Unicode 字符数组(可能存在可变长字节)。由于这条正则表达式是使用字符串模式定义的,所以它只能用来搜索字符串 — 再强调一次,字符数组。但是我们所搜索的并非字符串,它是一个字节数组。看一看 traceback,该错误发生在universaldetector.py:
def feed(self, aBuf):
.
.
.
if self._mInputState == ePureAscii:
if self._highBitDetector.search(aBuf):
aBuf是什么?让我们原路回到调用UniversalDetector.feed()的地方。有一处地方调用了它,是测试工具,test.py。
u = UniversalDetector()
.
.
.
for line in open(f, 'rb'):
u.feed(line)
非字符数组,而是一个字节数组。
在此处我们找到了答案:UniversalDetector.feed()方法中,aBuf是从磁盘文件中读到的一行。仔细看一看用来打开文件的参数:'rb'。'r'是用来读取的;OK,没什么了不起的,我们在读取文件。啊,但是'b'是用以读取“二进制”数据的。如果没有标记'b',for循环会一行一行地读取文件,然后将其转换为一个字符串 — Unicode 编码的字符数组 — 根据系统默认的编码方式。但是使用'b'标记后,for循环一行一行地读取文件,然后将其按原样存储为字节数组。该字节数组被传递给了 UniversalDetector.feed()方法,最后给了预编译好的正则表达式,self._highBitDetector,用来搜索高位…字符。但是没有字符;有的只是字节。苍天哪。
我们需要该正则表达式搜索的并不是字符数组,而是一个字节数组。
只要我们认识到了这一点,解决办法就有了。使用字符串定义的正则表达式可以搜索字符串。使用字节数组定义的正则表达式可以搜索字节数组。我们只需要改变用来定义正则表达式的参数的类型为字节数组,就可以定义一个字节数组模式。(还有另外一个该问题的实例,在下一行。)
class UniversalDetector:
def __init__(self):
~~- self._highBitDetector = re.compile(r'[\x80-\xFF]')~~
~~- self._escDetector = re.compile(r'(\033|~{)')~~
<ins>+ self._highBitDetector = re.compile(b'[\x80-\xFF]')</ins>
<ins>+ self._escDetector = re.compile(b'(\033|~{)')</ins>
self._mEscCharSetProber = None
self._mCharSetProbers = []
self.reset()
在整个代码库内搜索对re模块的使用发现了另外两个该类型问题的实例,出现在charsetprober.py文件中。再次,以上代码将正则表达式定义为字符串,但是却将它们作用在aBuf上,而aBuf是一个字节数组。解决方案还是一样的:将正则表达式模式定义为字节数组。
class CharSetProber:
.
.
.
def filter_high_bit_only(self, aBuf):
~~- aBuf = re.sub(r'([\x00-\x7F])+', ' ', aBuf)~~
<ins>+ aBuf = re.sub(b'([\x00-\x7F])+', b' ', aBuf)</ins>
return aBuf
def filter_without_english_letters(self, aBuf):
~~- aBuf = re.sub(r'([A-Za-z])+', ' ', aBuf)~~
<ins>+ aBuf = re.sub(b'([A-Za-z])+', b' ', aBuf)</ins>
return aBuf
Can't convert 'bytes' object to str implicitly
奇怪,越来越不寻常了…
C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
File "test.py", line 10, in <module>
u.feed(line)
File "C:\home\chardet\chardet\universaldetector.py", line 100, in feed
elif (self._mInputState == ePureAscii) and self._escDetector.search(self._mLastChar + aBuf):
TypeError: Can't convert 'bytes' object to str implicitly
在此存在一个 Python 解释器与代码风格之间的不协调。TypeError可以出现在那一行的任意地方,但是 traceback 不能明确定地指出错误的位置。可能是第一个或者第二个条件语句(conditional),对 traceback 来说,它们是一样的。为了缩小调试的范围,我们需要把这条代码分割成两行,像这样:
elif (self._mInputState == ePureAscii) and \
self._escDetector.search(self._mLastChar + aBuf):
然后再运行测试工具:
C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
File "test.py", line 10, in <module>
u.feed(line)
File "C:\home\chardet\chardet\universaldetector.py", line 101, in feed
self._escDetector.search(self._mLastChar + aBuf):
TypeError: Can't convert 'bytes' object to str implicitly
啊哈!错误不在第一个条件语句上(self._mInputState == ePureAscii),是第二个的问题。但是,是什么引发了TypeError错误呢?也许你会想search()方法需要另外一种类型的参数,但是那样的话,就不会产生当前这种 traceback 了。Python 函数可以使用任何类型参数;只要传递了正确数目的参数,函数就可以执行。如果我们给函数传递了类型不匹配的参数,代码可能就会崩溃,但是这样一来,traceback 就会指向函数内部的某一代码块了。但是当前得到的 traceback 告诉我们,错误就出现在开始调用search()函数那儿。所以错误肯定就出在+操作符上,该操作用于构建最终会传递给search()方法的参数。
从前一次调试的过程中,我们已经知道aBuf是一个字节数组。那么self._mLastChar又是什么呢?它是一个在reset()中定义的实例变量,而reset()方法刚好就是被__init__()调用的。
class UniversalDetector:
def __init__(self):
self._highBitDetector = re.compile(b'[\x80-\xFF]')
self._escDetector = re.compile(b'(\033|~{)')
self._mEscCharSetProber = None
self._mCharSetProbers = []
<mark>self.reset()</mark>
def reset(self):
self.result = {'encoding': None, 'confidence': 0.0}
self.done = False
self._mStart = True
self._mGotData = False
self._mInputState = ePureAscii
<mark>self._mLastChar = ''</mark>
现在我们找到问题的症结所在了。你发现了吗?self._mLastChar是一个字符串,而aBuf是一个字节数组。而我们不允许对字符串和字节数组做连接操作 — 即使是空串也不行。
那么,self._mLastChar到底是什么呢?在feed()方法中,在 traceback 报告的位置以下几行就是了。
if self._mInputState == ePureAscii:
if self._highBitDetector.search(aBuf):
self._mInputState = eHighbyte
elif (self._mInputState == ePureAscii) and \
self._escDetector.search(self._mLastChar + aBuf):
self._mInputState = eEscAscii
<mark>self._mLastChar = aBuf[-1]</mark>
feed()方法被一次一次地调用,每次都传递给它几个字节。该方法处理好它收到的字节(以aBuf传递进去的),然后将最后一个字节保存在self._mLastChar中,以便下次调用时还会用到。(在多字节编码中,feed()在调用的时候可能只收到了某个字符的一半,然后下次调用时另一半才被传到。)但是因为aBuf已经变成了一个字节数组,所以self._mLastChar也需要与其匹配。可以这样做:
def reset(self):
.
.
.
~~- self._mLastChar = ''~~
<ins>+ self._mLastChar = b''</ins>
在代码库中搜索“mLastChar”,mbcharsetprober.py中也发现一个相似的问题,与之前不同的是,它记录的是最后2个字符。MultiByteCharSetProber类使用一个单字符列表来记录末尾的两个字符。在 Python 3 中,这需要使用一个整数列表,因为实际上它记录的并不是是字符,而是字节对象。(字节对象即范围在0-255内的整数。)
class MultiByteCharSetProber(CharSetProber):
def __init__(self):
CharSetProber.__init__(self)
self._mDistributionAnalyzer = None
self._mCodingSM = None
~~- self._mLastChar = ['\x00', '\x00']~~
<ins>+ self._mLastChar = [0, 0]</ins>
def reset(self):
CharSetProber.reset(self)
if self._mCodingSM:
self._mCodingSM.reset()
if self._mDistributionAnalyzer:
self._mDistributionAnalyzer.reset()
~~- self._mLastChar = ['\x00', '\x00']~~
<ins>+ self._mLastChar = [0, 0]</ins>
Unsupported operand type(s) for +: 'int' and 'bytes'
有好消息,也有坏消息。好消息是我们一直在前进着…
C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
File "test.py", line 10, in <module>
u.feed(line)
File "C:\home\chardet\chardet\universaldetector.py", line 101, in feed
self._escDetector.search(self._mLastChar + aBuf):
TypeError: unsupported operand type(s) for +: 'int' and 'bytes'
…坏消息是,我们好像一直都在原地踏步。
但我们确实一直在取得进展!真的!即使 traceback 在相同的地方再次出现,这一次的错误毕竟与上次不同。前进!那么,这次又是什么错误呢?上一次我们确认过了,这一行代码不应该会再做连接int型和字节数组(bytes)的操作。事实上,我们刚刚花了相当长一段时间来保证self._mLastChar是一个字节数组。它怎么会变成int呢?
答案不在上几行代码中,而在以下几行。
if self._mInputState == ePureAscii:
if self._highBitDetector.search(aBuf):
self._mInputState = eHighbyte
elif (self._mInputState == ePureAscii) and \
self._escDetector.search(self._mLastChar + aBuf):
self._mInputState = eEscAscii
<mark>self._mLastChar = aBuf[-1]</mark>
字符串中的元素仍然是字符串,字节数组中的元素则为整数。
该错误没有发生在feed()方法第一次被调用的时候;而是在第二次调用的过程中,在self._mLastChar被赋值为aBuf末尾的那个字节之后。好吧,这又会有什么问题呢?因为获取字节数组中的单个元素会产生一个整数,而不是字节数组。它们之间的区别,请看以下在交互式 shell 中的操作:
>>> len(aBuf)
3
>>> mLastChar = aBuf[-1]
191
<class 'int'>
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'bytes'
>>> mLastChar
b'\xbf'
b'\xbf\xef\xbb\xbf'
- 定义一个长度为 3 的字节数组。
- 字节数组的最后一个元素为 191。
- 它是一个整数。
- 连接整数和字节数组的操作是不允许的。我们重复了在
universaldetector.py中发现的那个错误。 - 啊,这就是解决办法了。使用列表分片从数组的最后一个元素中创建一个新的字节数组,而不是直接获取这个元素。即,从最后一个元素开始切割,直到到达数组的末尾。当前
mLastChar是一个长度为 1 的字节数组。 - 连接长度分别为 1 和 3 的字节数组,则会返回一个新的长度为 4 的字节数组。
所以,为了保证universaldetector.py中的feed()方法不管被调用多少次都能够正常运行,我们需要将self._mLastChar实例化为一个长度为 0 的字节数组,并且保证它一直是一个字节数组。
self._escDetector.search(self._mLastChar + aBuf):
self._mInputState = eEscAscii
~~- self._mLastChar = aBuf[-1]~~
<ins>+ self._mLastChar = aBuf[-1:]</ins>
ord() expected string of length 1, but int found
困了吗?就要完成了…
C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml
Traceback (most recent call last):
File "test.py", line 10, in <module>
u.feed(line)
File "C:\home\chardet\chardet\universaldetector.py", line 116, in feed
if prober.feed(aBuf) == constants.eFoundIt:
File "C:\home\chardet\chardet\charsetgroupprober.py", line 60, in feed
st = prober.feed(aBuf)
File "C:\home\chardet\chardet\utf8prober.py", line 53, in feed
codingState = self._mCodingSM.next_state(c)
File "C:\home\chardet\chardet\codingstatemachine.py", line 43, in next_state
byteCls = self._mModel['classTable'][ord(c)]
TypeError: ord() expected string of length 1, but int found
OK,因为c是int类型的,但是ord()需要一个长度为 1 的字符串。就是这样了。c在哪儿定义的?
# codingstatemachine.py
def next_state(self, c):
# for each byte we get its class
# if it is first byte, we also get byte length
byteCls = self._mModel['classTable'][ord(c)]
不是这儿; 此处c只是被传递给了next_state()函数。我们再上一级看看。
# utf8prober.py
def feed(self, aBuf):
for c in aBuf:
codingState = self._mCodingSM.next_state(c)
看到了吗?在 Python 2 中,aBuf是一个字符串,所以c就是一个长度为 1 的字符串。(那就是我们通过遍历字符串所得到的 — 所有的字符,一次一个。)因为现在aBuf是一个字节数组,所以c变成了int类型的,而不再是长度为 1 的字符串。也就是说,没有必要再调用ord()函数了,因为c已经是int了!
这样修改:
def next_state(self, c):
# for each byte we get its class
# if it is first byte, we also get byte length
~~- byteCls = self._mModel['classTable'][ord(c)]~~
<ins>+ byteCls = self._mModel['classTable'][c]</ins>
在代码库中搜索“ord(c)”后,发现sbcharsetprober.py中也有相似的问题…
# sbcharsetprober.py
def feed(self, aBuf):
if not self._mModel['keepEnglishLetter']:
aBuf = self.filter_without_english_letters(aBuf)
aLen = len(aBuf)
if not aLen:
return self.get_state()
for c in aBuf:
<mark>order = self._mModel['charToOrderMap'][ord(c)]</mark>
…还有latin1prober.py…
# latin1prober.py
def feed(self, aBuf):
aBuf = self.filter_with_english_letters(aBuf)
for c in aBuf:
<mark>charClass = Latin1_CharToClass[ord(c)]</mark>
c在aBuf中遍历,这就意味着它是一个整数,而非字符串。解决方案是相同的:把ord(c)就替换成c。
# sbcharsetprober.py
def feed(self, aBuf):
if not self._mModel['keepEnglishLetter']:
aBuf = self.filter_without_english_letters(aBuf)
aLen = len(aBuf)
if not aLen:
return self.get_state()
for c in aBuf:
~~- order = self._mModel['charToOrderMap'][ord(c)]~~
<ins>+ order = self._mModel['charToOrderMap'][c]</ins>
# latin1prober.py
def feed(self, aBuf):
aBuf = self.filter_with_english_letters(aBuf)
for c in aBuf:
~~- charClass = Latin1_CharToClass[ord(c)]~~
<ins>+ charClass = Latin1_CharToClass[c]</ins>
Unorderable types: int() >= str()
继续我们的路吧。
C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml
Traceback (most recent call last):
File "test.py", line 10, in <module>
u.feed(line)
File "C:\home\chardet\chardet\universaldetector.py", line 116, in feed
if prober.feed(aBuf) == constants.eFoundIt:
File "C:\home\chardet\chardet\charsetgroupprober.py", line 60, in feed
st = prober.feed(aBuf)
File "C:\home\chardet\chardet\sjisprober.py", line 68, in feed
self._mContextAnalyzer.feed(self._mLastChar[2 - charLen :], charLen)
File "C:\home\chardet\chardet\jpcntx.py", line 145, in feed
order, charLen = self.get_order(aBuf[i:i+2])
File "C:\home\chardet\chardet\jpcntx.py", line 176, in get_order
if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \
TypeError: unorderable types: int() >= str()
这都是些什么?“Unorderable types”?字节数组与字符串之间的差异引起的问题再一次出现了。看一看以下代码:
class SJISContextAnalysis(JapaneseContextAnalysis):
def get_order(self, aStr):
if not aStr: return -1, 1
# find out current char's byte length
<mark>if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \</mark>
((aStr[0] >= '\xE0') and (aStr[0] <= '\xFC')):
charLen = 2
else:
charLen = 1
aStr从何而来?再深入栈内看一看:
def feed(self, aBuf, aLen):
.
.
.
i = self._mNeedToSkipCharNum
while i < aLen:
<mark>order, charLen = self.get_order(aBuf[i:i+2])</mark>
看,是aBuf,我们的老战友。从我们在这一章中所遇到的问题你也可以猜到了问题的关键了,因为aBuf是一个字节数组。此处feed()方法并不是整个地将它传递出去;而是先对它执行分片操作。就如你在这章前面看到的,对字节数组执行分片操作的返回值仍然为字节数组,所以传递给get_order()方法的aStr仍然是字节数组。
那么以下代码是怎样处理aStr的呢?它将该字节第一个元素与长度为 1 的字符串进行比较操作。在 Python 2,这是可以的,因为aStr和aBuf都是字符串,所以aStr[0]也是字符串,并且我们允许比较两个字符串的是否相等。但是在 Python 3 中,aStr和aBuf都是字节数组,而aStr[0]就成了一个整数,没有执行显式地强制转换的话,是不能对整数和字符串执行相等性比较的。
在当前情况下,没有必要添加强制转换,这会让代码变得更加复杂。aStr[0]产生一个整数;而我们所比较的对象都是常量(constant)。那就把长度为 1 的字符串换成整数吧。我们也顺便把aStr换成aBuf吧,因为aStr本来也不是一个字符串。
class SJISContextAnalysis(JapaneseContextAnalysis):
~~- def get_order(self, aStr):~~
~~- if not aStr: return -1, 1~~
<ins>+ def get_order(self, aBuf):</ins>
<ins>+ if not aBuf: return -1, 1</ins>
# find out current char's byte length
~~- if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \~~
~~- ((aBuf[0] >= '\xE0') and (aBuf[0] <= '\xFC')):~~
<ins>+ if ((aBuf[0] >= 0x81) and (aBuf[0] <= 0x9F)) or \</ins>
<ins>+ ((aBuf[0] >= 0xE0) and (aBuf[0] <= 0xFC)):</ins>
charLen = 2
else:
charLen = 1
# return its order if it is hiragana
~~- if len(aStr) > 1:~~
~~- if (aStr[0] == '\202') and \~~
~~- (aStr[1] >= '\x9F') and \~~
~~- (aStr[1] <= '\xF1'):~~
~~- return ord(aStr[1]) - 0x9F, charLen~~
<ins>+ if len(aBuf) > 1:</ins>
<ins>+ if (aBuf[0] == 0x202) and \</ins>
<ins>+ (aBuf[1] >= 0x9F) and \</ins>
<ins>+ (aBuf[1] <= 0xF1):</ins>
<ins>+ return aBuf[1] - 0x9F, charLen</ins>
return -1, charLen
class EUCJPContextAnalysis(JapaneseContextAnalysis):
~~- def get_order(self, aStr):~~
~~- if not aStr: return -1, 1~~
<ins>+ def get_order(self, aBuf):</ins>
<ins>+ if not aBuf: return -1, 1</ins>
# find out current char's byte length
~~- if (aStr[0] == '\x8E') or \~~
~~- ((aStr[0] >= '\xA1') and (aStr[0] <= '\xFE')):~~
<ins>+ if (aBuf[0] == 0x8E) or \</ins>
<ins>+ ((aBuf[0] >= 0xA1) and (aBuf[0] <= 0xFE)):</ins>
charLen = 2
~~- elif aStr[0] == '\x8F':~~
<ins>+ elif aBuf[0] == 0x8F:</ins>
charLen = 3
else:
charLen = 1
# return its order if it is hiragana
~~- if len(aStr) > 1:~~
~~- if (aStr[0] == '\xA4') and \~~
~~- (aStr[1] >= '\xA1') and \~~
~~- (aStr[1] <= '\xF3'):~~
~~- return ord(aStr[1]) - 0xA1, charLen~~
<ins>+ if len(aBuf) > 1:</ins>
<ins>+ if (aBuf[0] == 0xA4) and \</ins>
<ins>+ (aBuf[1] >= 0xA1) and \</ins>
<ins>+ (aBuf[1] <= 0xF3):</ins>
<ins>+ return aBuf[1] - 0xA1, charLen</ins>
return -1, charLen
在代码库中查找ord()函数,我们在chardistribution.py中也发现了同样的问题(更确切地说,在以下这些类中,EUCTWDistributionAnalysis,EUCKRDistributionAnalysis,GB2312DistributionAnalysis,Big5DistributionAnalysis,SJISDistributionAnalysis和EUCJPDistributionAnalysis)。对于它们存在的问题,解决办法与我们对jpcntx.py中的类EUCJPContextAnalysis和SJISContextAnalysis的做法相似。
Global name 'reduce' is not defined
再次陷入中断…
C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml
Traceback (most recent call last):
File "test.py", line 12, in <module>
u.close()
File "C:\home\chardet\chardet\universaldetector.py", line 141, in close
proberConfidence = prober.get_confidence()
File "C:\home\chardet\chardet\latin1prober.py", line 126, in get_confidence
total = reduce(operator.add, self._mFreqCounter)
NameError: global name 'reduce' is not defined
根据官方手册:What’s New In Python 3.0,函数reduce()已经从全局名字空间中移出,放到了functools模块中。引用手册中的内容:“如果需要,请使用functools.reduce(),99%的情况下,显式的for循环使代码更有可读性。”你可以从 Guido van Rossum 的一篇日志中看到关于这项决策的更多细节:The fate of reduce() in Python 3000。
def get_confidence(self):
if self.get_state() == constants.eNotMe:
return 0.01
<mark>total = reduce(operator.add, self._mFreqCounter)</mark>
reduce()函数使用两个参数 — 一个函数,一个列表(更严格地说,可迭代的对象就行了) — 然后将函数增量式地作用在列表的每个元素上。换句话说,这是一种良好而高效的用于综合(add up)列表所有元素并返回其结果的方法。
这种强大的技术使用如此频繁,所以 Python 就添加了一个全局的sum()函数。
def get_confidence(self):
if self.get_state() == constants.eNotMe:
return 0.01
~~- total = reduce(operator.add, self._mFreqCounter)~~
<ins>+ total = sum(self._mFreqCounter)</ins>
由于我们不再使用operator模块,所以可以在文件最上方移除那条import语句。
from .charsetprober import CharSetProber
from . import constants
~~- import operator~~
可以开始测试了吧?(快要吐血的样子…)
C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\blog.worren.net.xml Big5 with confidence 0.99
tests\Big5\carbonxiv.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\catshadow.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\coolloud.org.tw.xml Big5 with confidence 0.99
tests\Big5\digitalwall.com.xml Big5 with confidence 0.99
tests\Big5\ebao.us.xml Big5 with confidence 0.99
tests\Big5\fudesign.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\kafkatseng.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\ke207.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\leavesth.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\letterlego.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\linyijen.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\marilynwu.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\myblog.pchome.com.tw.xml Big5 with confidence 0.99
tests\Big5\oui-design.com.xml Big5 with confidence 0.99
tests\Big5\sanwenji.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\sinica.edu.tw.xml Big5 with confidence 0.99
tests\Big5\sylvia1976.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\tlkkuo.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\tw.blog.xubg.com.xml Big5 with confidence 0.99
tests\Big5\unoriginalblog.com.xml Big5 with confidence 0.99
tests\Big5\upsaid.com.xml Big5 with confidence 0.99
tests\Big5\willythecop.blogspot.com.xml Big5 with confidence 0.99
tests\Big5\ytc.blogspot.com.xml Big5 with confidence 0.99
tests\EUC-JP\aivy.co.jp.xml EUC-JP with confidence 0.99
tests\EUC-JP\akaname.main.jp.xml EUC-JP with confidence 0.99
tests\EUC-JP\arclamp.jp.xml EUC-JP with confidence 0.99
.
.
.
316 tests
天哪,伙计,她真的欢快地跑起来了!/me does a little dance
总结
我们学到了什么?
- 尝试大批量地把代码从 Python 2 移植到 Python 3 上是一件让人头疼的工作。没有捷径。它确实很困难。
- 自动化的
2to3脚本确实有用,但是它只能做一些简单的辅助工作 — 函数重命名,模块重命名,语法修改等。之前,它被认为是一项会让人印象深刻的大工程,但是最后,实际上它只是一个能智能地执行查找替换机器人。 - 在移植
chardet库的时候遇到的头号问题就是:字符串和字节对象之间的差异。在我们这个情况中,这种问题比较明显,因为整个chardet库就是一直在执行从字节流到字符串的转换。但是“字节流”出现的方式会远超出你的想象。以“二进制”模式读取文件?我们会获得字节流。获取一份 web 页面?调用 web API?这也会返回字节流。 - 你需要彻底地了解所面对的程序。如果那段程序是自己写自然非常好,但是至少,我们需要够理解所有晦涩难懂的细节。因为 bug 可能埋伏在任何地方。
- 测试样例是必要的。没有它们的话不要尝试着移植代码。我自信移植后的
chardet模块能在 Python 3 中工作的唯一理由是,我一开始就使用了测试集合来检验所有主要的代码路径。如果你还没有任何测试集,在移植代码之前自己写一些吧。如果你的测试集合太小,那么请写全。如果测试集够了,那么,我们就又可以开始历险了。