第 8 章 数据的归宿
“在没有事实(数据)作为参考的情况下妄下结论是个可怕的错误。”
——阿瑟 · 柯南 · 道尔
一个运行中的程序会存取放在随机存取存储器(RAM)上的数据。RAM 读取速度快,但价格昂贵,需要持续供电,断电后保存在上面的数据会自动消失。磁盘速度比 RAM 慢,但容量大、费用低廉并且多次插拔电源线仍可保持数据。因此,计算机系统在数据存储设计中做出很大的努力来权衡磁盘和 RAM。程序员需要在非易失性介质(例如磁盘)上做持久化存储和检索数据。
本章会涉及不同类型的数据存储,它们基于不同的目的进行优化:普通文件、结构化文件和数据库。除了输入和输出,文件操作都会在 10.1 节讲到。
本章也是第一次讲到非标准的 Python 模块,也就是除了标准库之外的 Python 代码。你可以通过
pip
命令轻松地安装这些第三方库,更多的使用细节详见附录 D。
8.1 文件输入/输出
数据持久化最简单的类型是普通文件,有时也叫平面文件(flat file)。它仅仅是在一个文件名下的字节流,把数据从一个文件读入内存,然后从内存写入文件。Python 很容易实现这些文件操作,它模仿熟悉的和流行的 Unix 系统的操作。
读写一个文件之前需要打开它:
fileobj = open(filename, mode)
下面是对该 open()
调用的简单解释:
fileobj
是open()
返回的文件对象;filename
是该文件的字符串名;mode
是指明文件类型和操作的字符串。
mode
的第一个字母表明对其的操作。
r
表示读模式。w
表示写模式。如果文件不存在则新创建,如果存在则重写新内容。x
表示在文件不存在的情况下新创建并写文件。a
表示如果文件存在,在文件末尾追加写内容。
mode
的第二个字母是文件类型:
t
(或者省略)代表文本类型;b
代表二进制文件。
打开文件之后就可以调用函数来读写数据,之后的例子会涉及。
最后需要关闭文件。
接下来在一个程序中用 Python 字符串创建一个文件,然后返回。
8.1.1 使用write()
写文本文件
出于一些原因,我们没有太多的关于狭义相对论的五行打油诗(limerick1)。下面这首作为源数据:
1一种通俗幽默的短诗。——译者注
>>> poem = '''There was a young lady named Bright,
... Whose speed was far faster than light;
... She started one day
... In a relative way,
... And returned on the previous night.'''
>>> len(poem)
150
以下代码将整首诗写到文件 'relativity'
中:
>>> fout = open('relativity', 'wt')
>>> fout.write(poem)
150
>>> fout.close()
函数 write()
返回写入文件的字节数。和 print()
不一样,它没有增加空格或者换行符。同 样,你也可以在一个文本文件中使用 print()
:
>>> fout = open('relativity', 'wt')
>>> print(poem, file=fout)
>>> fout.close()
这就产生了一个问题:到底是使用 write()
还是 print()
? print()
默认会在每个参数后面添加空格,在每行结束处添加换行。 在之前的例子中,它在文件 relativity
中默认添加了一个换行。为了使 print()
与 write()
有同样的输出,传入下面两个参数。
sep
分隔符:默认是一个空格' '
end
结束字符:默认是一个换行符'\n'
除非自定义参数,否则 print()
会使用默认参数。在这里,我们通过空字符串替换 print()
添加的所有多余输出:
>>> fout = open('relativity', 'wt')
>>> print(poem, file=fout, sep='', end='')
>>> fout.close()
如果源字符串非常大,可以将数据分块,直到所有字符被写入:
>>> fout = open('relativity', 'wt')
>>> size = len(poem)
>>> offset = 0
>>> chunk = 100
>>> while True:
... if offset > size:
... break
... fout.write(poem[offset:offset+chunk])
... offset += chunk
...
100
50
>>> fout.close()
第一次写入 100 个字符,然后写入剩下的 50 个字符。
如果 'relativity'
文件已经存在,使用模式 x
可以避免重写文件:
>>> fout = open('relativity', 'xt')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
FileExistsError: [Errno 17] File exists: 'relativity'
可以加入一个异常处理:
>>> try:
... fout = open('relativity', 'xt')
... fout.write('stomp stomp stomp')
... except FileExistsError:
... print('relativity already exists!. That was a close one.')
...
relativity already exists!. That was a close one.
8.1.2 使用read()
、readline()
或者readlines()
读文本文件
你可以按照下面的示例那样,使用不带参数的 read()
函数一次读入文件的所有内容。但在读入文件时要格外注意,1 GB 的文件会用到相同大小的内存。
>>> fin = open('relativity', 'rt' )
>>> poem = fin.read()
>>> fin.close()
>>> len(poem)
150
同样也可以设置最大的读入字符数限制 read()
函数一次返回的大小。下面一次读入 100 个字符,然后把每一块拼接成原来的字符串 poem
:
>>> poem = ''
>>> fin = open('relativity', 'rt' )
>>> chunk = 100
>>> while True:
... fragment = fin.read(chunk)
... if not fragment:
... break
... poem += fragment
...
>>> fin.close()
>>> len(poem)
150
读到文件结尾之后,再次调用 read()
会返回空字符串(''
),if not fragment
条件被判为 False
。此时会跳出 while True
的循环。 当然,你也能使用 readline()
每次读入文件的一行。在下一个例子中,通过追加每一行拼接成原来的字符串 poem
:
>>> poem = ''
>>> fin = open('relativity', 'rt' )
>>> while True:
... line = fin.readline()
... if not line:
... break
... poem += line
...
>>> fin.close()
>>> len(poem)
150
对于一个文本文件,即使空行也有 1 字符长度(换行字符 '\n'
),自然就会返回 True
。当文件读取结束后,readline()
(类似 read()
)同样会返回空字符串,也被 if not line:
判 为 False
。
读取文本文件最简单的方式是使用一个迭代器(iterator),它会每次返回一行。这和之前的例子类似,但代码会更短:
>>> poem = ''
>>> fin = open('relativity', 'rt' )
>>> for line in fin:
... poem += line
...
>>> fin.close()
>>> len(poem)
150
前面所有的示例最终都返回单个字符串 poem
。函数 readlines()
调用时读入所有行,并返回单行字符串的列表:
>>> fin = open('relativity', 'rt' )
>>> lines = fin.readlines()
>>> fin.close()
>>> print(len(lines), 'lines read')
5 lines read
>>> for line in lines:
... print(line, end='')
...
There was a young lady named Bright,
Whose speed was far faster than light;
She started one day
In a relative way,
And returned on the previous night.>>>
之前我们让 print()
去掉每行结束的自动换行,因为前面的四行都有换行标志,而最后一行没有,所以导致解释器的提示符 >>>
出现在最后一行的最右边。
8.1.3 使用write()
写二进制文件
如果文件模式字符串中包含 'b'
,那么文件会以二进制模式打开。这种情况下,读写的是字节而不是字符串。
我们手边没有二进制格式的诗,所以直接在 0~255 产生 256 字节的值:
>>> bdata = bytes(range(0, 256))
>>> len(bdata)
256
以二进制模式打开文件,并且一次写入所有的数据:
>>> fout = open('bfile', 'wb')
>>> fout.write(bdata)
256
>>> fout.close()
再次,write()
返回到写入的字节数。
就像文本一样,二进制数据也可以分块写入:
>>> fout = open('bfile', 'wb')
>>> size = len(bdata)
>>> offset = 0
>>> chunk = 100
>>> while True:
... if offset > size:
... break
... fout.write(bdata[offset:offset+chunk])
... offset += chunk
...
100
100
56
>>> fout.close()
8.1.4 使用read()
读二进制文件
下面简单的例子只需要用 'rb'
打开文件即可:
>>> fin = open('bfile', 'rb')
>>> bdata = fin.read()
>>> len(bdata)
256
>>> fin.close()
8.1.5 使用with
自动关闭文件
如果你忘记关闭已经打开的一个文件,在该文件对象不再被引用之后 Python 会关掉此文件。这也就意味着,如果你在一个函数中打开一个文件,之后没有关闭它,那么在函数结束时该文件会被自动关掉。然而你可能已在一直运行中的函数或者程序的主要部分打开了一个文件。应该关闭文件,以完成剩下的所有写操作。
Python 的上下文管理器(context manager)会清理一些资源,例如打开的文件。它的形式为 with expression as variable
:
>>> with open('relativity', 'wt') as fout:
... fout.write(poem)
...
完成上下文管理器的代码后,文件会被自动关闭。
8.1.6 使用seek()
改变位置
无论是读或者写文件,Python 都会跟踪文件中的位置。函数 tell()
返回距离文件开始处的字节偏移量。函数 seek()
允许跳转到文件其他字节偏移量的位置。这意味着可以不用从头读取文件的每一个字节,直接跳到最后位置并只读一个字节也是可行的。
对于这个例子,使用之前写过的 256 字节的二进制文件 'bfile'
:
>>> fin = open('bfile', 'rb')
>>> fin.tell()
0
使用 seek()
读取文件结束前最后一个字节:
>>> fin.seek(255)
255
一直读到文件结束:
>>> bdata = fin.read()
>>> len(bdata)
1
>>> bdata[0]
255
seek()
同样返回当前的偏移量。
用第二个参数调用函数 seek()
:seek(offset,origin)
。
如果
origin
等于0
(默认为0
),从开头偏移offset
个字节;如果
origin
等于1
,从当前位置处偏移offset
个字节;如果
origin
等于2
,距离最后结尾处偏移offset
个字节。
这些值也在标准 os
模块中被定义:
>>> import os
>>> os.SEEK_SET
0
>>> os.SEEK_CUR
1
>>> os.SEEK_END
2
所以,我们可以用不同的方法读取最后一个字节:
>>> fin = open('bfile', 'rb')
文件结尾前的一个字节:
>>> fin.seek(-1, 2)
255
>>> fin.tell()
255
一直读到文件结尾:
>>> bdata = fin.read()
>>> len(bdata)
1
>>> bdata[0]
255
在调用
seek()
函数时不需要额外调用tell()
。前面的例子只是想说明两个函数都可以返回同样的偏移量。
下面是从文件的当前位置寻找的例子:
>>> fin = open('bfile', 'rb')
接下来的例子返回最后两个字节:
>>> fin.seek(254, 0)
254
>>> fin.tell()
254
在此基础上前进一个字节:
>>> fin.seek(1, 1)
255
>>> fin.tell()
255
最后一直读到文件结尾:
>>> bdata = fin.read()
>>> len(bdata)
1
>>> bdata[0]
255
这些函数对于二进制文件都是极其重要的。当文件是 ASCII 编码(每个字符一个字节)时,也可以使用它们,但是计算偏移量会是一件麻烦事。其实,这些都取决于文本的编码格式,最流行的编码格式(例如 UTF-8)每个字符的字节数都不尽相同。
8.2 结构化的文本文件
对于简单的文本文件,唯一的结构层次是间隔的行。然而有时候需要更加结构化的文本,用于后续使用的程序保存数据或者向另外一个程序传送数据。
结构化的文本有很多格式,区别它们的方法如下所示。
分隔符,比如 tab(
'\t'
)、逗号(','
)或者竖线('|'
)。逗号分隔值(CSV)就是这样的例子。'<'
和'>'
标签,例如 XML 和 HTML。标点符号,例如 JavaScript Object Notation(JSON2)。
缩进,例如 YAML(即 YAML Ain't Markup Language 的缩写),要了解更多可以去搜索。
混合的,例如各种配置文件。
2JSON 是一种轻量级的数据交换格式,它是基于 JavaScript 的一个子集。——译者注
每一种结构化文件格式都能够被至少一种 Python 模块读写。
8.2.1 CSV
带分隔符的文件一般用作数据交换格式或者数据库。你可以人工读入 CSV 文件,每一次读取一行,在逗号分隔符处将每行分开,并添加结果到某些数据结构中,例如列表或者字典。但是,最好使用标准的 csv
模块,因为这样切分会得到更加复杂的信息。
除了逗号,还有其他可代替的分隔符:
'|'
和'\t'
很常见。有些数据会有转义字符序列,如果分隔符出现在一块区域内,则整块都要加上引号或者在它之前加上转义字符。
文件可能有不同的换行符,Unix 系统的文件使用
'\n'
,Microsoft 使用'\r\n'
,Apple 之前使用'\r'
而现在使用'\n'
。在第一行可以加上列名。
首先读和写一个列表的行,每一行包含很多列:
>>> import csv
>>> villains = [
... ['Doctor', 'No'],
... ['Rosa', 'Klebb'],
... ['Mister', 'Big'],
... ['Auric', 'Goldfinger'],
... ['Ernst', 'Blofeld'],
... ]
>>> with open('villains', 'wt') as fout: # 一个上下文管理器
... csvout = csv.writer(fout)
... csvout.writerows(villains)
于是创建了包含以下几行的文件 villains:
Doctor,No
Rosa,Klebb
Mister,Big
Auric,Goldfinger
Ernst,Blofeld
现在,我们来重新读这个文件:
>>> import csv
>>> with open('villains', 'rt') as fin: # 一个上下文管理器
... cin = csv.reader(fin)
... villains = [row for row in cin] # 使用列表推导式
...
>>> print(villains)
[['Doctor', 'No'], ['Rosa', 'Klebb'], ['Mister', 'Big'],
['Auric', 'Goldfinger'], ['Ernst', 'Blofeld']]
停下来想想列表推导式(随时打开 4.6 节,温习一下它的语法)。我们利用函数 reader()
创建的结构,它在通过 for
循环提取到的 cin
对象中构建每一行。
使用 reader()
和 writer()
的默认操作。每一列用逗号分开;每一行用换行符分开。
数据可以是以字典为元素的列表(a list of dictionary),不仅仅是以列表为元素的列表(a list of list)。这次使用新函数 DictReader()
读取文件 villains,并且指定每一列的名字:
>>> import csv
>>> with open('villains', 'rt') as fin:
... cin = csv.DictReader(fin, fieldnames=['first', 'last'])
... villains = [row for row in cin]
...
>>> print(villains)
[{'last': 'No', 'first': 'Doctor'},
{'last': 'Klebb', 'first': 'Rosa'},
{'last': 'Big', 'first': 'Mister'},
{'last': 'Goldfinger', 'first': 'Auric'},
{'last': 'Blofeld', 'first': 'Ernst'}]
下面使用新函数 DictWriter()
重写 CSV 文件,同时调用 writeheader()
向 CSV 文件中第一行写入每一列的名字:
import csv
villains = [
{'first': 'Doctor', 'last': 'No'},
{'first': 'Rosa', 'last': 'Klebb'},
{'first': 'Mister', 'last': 'Big'},
{'first': 'Auric', 'last': 'Goldfinger'},
{'first': 'Ernst', 'last': 'Blofeld'},
]
with open('villains', 'wt') as fout:
cout = csv.DictWriter(fout, ['first', 'last'])
cout.writeheader()
cout.writerows(villains)
于是创建了具有标题行的新文件 villains:
first,last
Doctor,No
Rosa,Klebb
Mister,Big
Auric,Goldfinger
Ernst,Blofeld
回过来再读取写入的文件,忽略函数 DictReader()
调用的参数 fieldnames
,把第一行的值(first,last
)作为列标签,和字典的键做匹配:
>>> import csv
>>> with open('villains', 'rt') as fin:
... cin = csv.DictReader(fin)
... villains = [row for row in cin]
...
>>> print(villains)
[{'last': 'No', 'first': 'Doctor'},
{'last': 'Klebb', 'first': 'Rosa'},
{'last': 'Big', 'first': 'Mister'},
{'last': 'Goldfinger', 'first': 'Auric'},
{'last': 'Blofeld', 'first': 'Ernst'}]
8.2.2 XML
带分隔符的文件仅有两维的数据:行和列。如果你想在程序之间交换数据结构,需要一种方法把层次结构、序列、集合和其他的结构编码成文本。
XML 是最突出的处理这种转换的标记(markup)格式,它使用标签(tag)分隔数据,如下面的示例文件 menu.xml 所示:
<?xml version="1.0"?>
<menu>
<breakfast hours="7-11">
<item price="$6.00">breakfast burritos</item>
<item price="$4.00">pancakes</item>
</breakfast>
<lunch hours="11-3">
<item price="$5.00">hamburger</item>
</lunch>
<dinner hours="3-10">
<item price="8.00">spaghetti</item>
</dinner>
</menu>
以下是 XML 的一些重要特性:
标签以一个
<
字符开头,例如示例中的标签menu
、breakfast
、lunch
、dinner
和item
;忽略空格;
通常一个开始标签(例如
)跟一段其他的内容,然后是最后相匹配的结束标签,例如;
标签之间是可以存在多级嵌套的,在本例中,标签
item
是标签breakfast
、lunch
和dinner
的子标签,反过来,它们也是标签menu
的子标签;可选属性(attribute)可以出现在开始标签里,例如
price
是item
的一个属性;标签中可以包含值(value),本例中每个
item
都会有一个值,比如第二个 breakfast item 的pancakes
;如果一个命名为
thing
的标签没有内容或者子标签,它可以用一个在右尖括号的前面添加斜杠的简单标签所表示,例如
代替开始和结束都存在的标签
和;
存放数据的位置可以是任意的——属性、值或者子标签。例如也可以把最后一个
item
标签写作
。
XML 通常用于数据传送和消息,它存在一些子格式,如 RSS 和 Atom。工业界有许多定制化的 XML 格式,例如金融领域(http://www.service-architecture.com/articles/xml/finance_xml.html)。
XML 的灵活性导致出现了很多方法和性能各异的 Python 库。
在 Python 中解析 XML 最简单的方法是使用 ElementTree
,下面的代码用来解析 menu.xml 文件以及输出一些标签和属性:
>>> import xml.etree.ElementTree as et
>>> tree = et.ElementTree(file='menu.xml')
>>> root = tree.getroot()
>>> root.tag
'menu'
>>> for child in root:
... print('tag:', child.tag, 'attributes:', child.attrib)
... for grandchild in child:
... print('\ttag:', grandchild.tag, 'attributes:', grandchild.attrib)
...
tag: breakfast attributes: {'hours': '7-11'}
tag: item attributes: {'price': '$6.00'}
tag: item attributes: {'price': '$4.00'}
tag: lunch attributes: {'hours': '11-3'}
tag: item attributes: {'price': '$5.00'}
tag: dinner attributes: {'hours': '3-10'}
tag: item attributes: {'price': '8.00'}
>>> len(root) # 菜单选择的数目
3
>>> len(root[0]) # 早餐项的数目
2
对于嵌套列表中的每一个元素,tag
是标签字符串,attrib
是它属性的一个字典。ElementTree
有许多查找 XML 导出数据、修改数据乃至写入 XML 文件的方法,它的文档(https://docs.python.org/3.3/library/xml.etree.elementtree.html)中有详细的介绍。
其他标准的 Python XML 库如下。
xml.dom
JavaScript 开发者比较熟悉的文档对象模型(DOM)将 Web 文档表示成层次结构,它会把整个 XML 文件载入到内存中,同样允许你获取所有的内容。
xml.sax
简单的 XML API 或者 SAX 都是通过在线解析 XML,不需要一次载入所有内容到内存中,因此对于处理巨大的 XML 文件流是一个很好的选择。
8.2.3 HTML
在 Web 网络中,海量的数据以超文本标记语言(HTML)这一基本的文档格式存储。然而许多文档不遵循 HTML 的规则,导致很难进行解析。况且更多的 HTML 是用来格式化输出显示结果而不是用于交换数据。本章的主要内容是描述定义明确的数据格式,关于 HTML 的更多讨论放在第 9 章。
8.2.4 JSON
JavaScript Object Notation(JSON,http://www.json.org)是源于 JavaScript 的当今很流行的数据交换格式,它是 JavaScript 语言的一个子集,也是 Python 合法可支持的语法。对于 Python 的兼容性使得它成为程序间数据交换的较好选择。同样,在第 9 章的 Web 开发中会看到很多 JSON 的示例。
不同于众多的 XML 模块,Python 只有一个主要的 JSON 模块 json
(名字容易记忆)。下面的程序将数据编码成 JSON 字符串,然后再把 JSON 字符串解码成数据。用之前 XML 例子的数据构建 JSON 的数据结构:
>>> menu = \
... {
... "breakfast": {
... "hours": "7-11",
... "items": {
... "breakfast burritos": "$6.00",
... "pancakes": "$4.00"
... }
... },
... "lunch" : {
... "hours": "11-3",
... "items": {
... "hamburger": "$5.00"
... }
... },
... "dinner": {
... "hours": "3-10",
... "items": {
... "spaghetti": "$8.00"
... }
... }
... }
.
接下来使用 dumps()
将 menu
编码成 JSON 字符串(menu_json
):
>>> import json
>>> menu_json = json.dumps(menu)
>>> menu_json
'{"dinner": {"items": {"spaghetti": "$8.00"}, "hours": "3-10"},
"lunch": {"items": {"hamburger": "$5.00"}, "hours": "11-3"},
"breakfast": {"items": {"breakfast burritos": "$6.00", "pancakes":
"$4.00"}, "hours": "7-11"}}'
现在反过来使用 loads()
把 JSON 字符串 menu_json
解析成 Python 的数据结构(menu2
):
>>> menu2 = json.loads(menu_json)
>>> menu2
{'breakfast': {'items': {'breakfast burritos': '$6.00', 'pancakes':
'$4.00'}, 'hours': '7-11'}, 'lunch': {'items': {'hamburger': '$5.00'},
'hours': '11-3'}, 'dinner': {'items': {'spaghetti': '$8.00'}, 'hours': '3-10'}}
menu
和 menu2
是具有相同键值的字典,和标准的字典用法一样,得到的键的顺序是不尽相同的。
你可能会在编码或者解析 JSON 对象时得到异常,包括时间对象 datetime
(在 10.4 节详细介绍):
>>> import datetime
>>> now = datetime.datetime.utcnow()
>>> now
datetime.datetime(2013, 2, 22, 3, 49, 27, 483336)
>>> json.dumps(now)
Traceback (most recent call last):
# …… (删除栈跟踪以保存树)
TypeError: datetime.datetime(2013, 2, 22, 3, 49, 27, 483336) is not JSON serializable
>>>
上述错误发生是因为标准 JSON 没有定义日期或者时间类型,需要自定义处理方式。你可以把 datetime
转换成 JSON 能够理解的类型,比如字符串或者 epoch 值(第 10 章讲解):
>>> now_str = str(now)
>>> json.dumps(now_str)
'"2013-02-22 03:49:27.483336"'
>>> from time import mktime
>>> now_epoch = int(mktime(now.timetuple()))
>>> json.dumps(now_epoch)
'1361526567'
如果 datetime
值出现在正常转换后的数据中间,那么做上面的特殊转化是困难的。你可以通过继承修改 JSON 的编码方式(6.3 节中讲解),Python 中的 JSON 文档(https://docs.python.org/3.3/library/json.html)给出了一个复杂数字的例子,同样使 JSON 出现问题。下面为 datetime
修改编码方式:
>>> class DTEncoder(json.JSONEncoder):
... def default(self, obj):
... # isinstance()检查obj的类型
... if isinstance(obj, datetime.datetime):
... return int(mktime(obj.timetuple()))
... # 否则是普通解码器知道的东西:
... return json.JSONEncoder.default(self, obj)
...
>>> json.dumps(now, cls=DTEncoder)
'1361526567'
新类 DTEncoder
是 JSONEncoder
的一个子类。我们需要重载它的 default()
方法来增加处理 datetime
的代码。继承确保了剩下的功能与父类的一致性。
函数 isinstance()
检查 obj
是否是类 datetime.datetime
的对象,因为在 Python 中一切都是对象,isinstance()
总是适用的:
>>> type(now)
<class 'datetime.datetime'>
>>> isinstance(now, datetime.datetime)
True
>>> type(234)
<class 'int'>
>>> isinstance(234, int)
True
>>> type('hey')
<class 'str'>
>>> isinstance('hey', str)
True
对于 JSON 和其他结构化的文本格式,在不需要提前知道任何东西的情况下可以从一个文件中解析数据结构。然后使用
isinstance()
和相关类型的方法遍历数据。例如,如果其中的一项是字典结构,可以通过keys()
、values()
和items()
抽取内容。
8.2.5 YAML
和 JSON 类似,YAML(http://www.yaml.org)同样有键和值,但主要用来处理日期和时间这样的数据类型。标准的 Python 库没有处理 YAML 的模块,因此需要安装第三方库 yaml
(http://pyyaml.org/wiki/PyYAML)操作数据。load()
将 YAML 字符串转换为 Python 数据结构,而 dump()
正好相反。
下面的 YAML 文件 mcintyre.yaml 包含加拿大诗人 James McIntyre 的两首诗的信息:
name:
first: James
last: McIntyre
dates:
birth: 1828-05-25
death: 1906-03-31
details:
bearded: true
themes: [cheese, Canada]
books:
url: http://www.gutenberg.org/files/36068/36068-h/36068-h.htm
poems:
- title: 'Motto'
text: |
Politeness, perseverance and pluck,
To their possessor will bring good luck.
- title: 'Canadian Charms'
text: |
Here industry is not in vain,
For we have bounteous crops of grain,
And you behold on every field
Of grass and roots abundant yield,
But after all the greatest charm
Is the snug home upon the farm,
And stone walls now keep cattle warm.
类似于 true
、false
、on
和 off
的值可以转换为 Python 的布尔值。整数和字符串转换为 Python 等价的。其他语法创建列表和字典:
>>> import yaml
>>> with open('mcintyre.yaml', 'rt') as fin:
>>> text = fin.read()
>>> data = yaml.load(text)
>>> data['details']
{'themes': ['cheese', 'Canada'], 'bearded': True}
>>> len(data['poems'])
2
创建的匹配这个 YAML 文件的数据结构超过了一层嵌套。如果你想得到第二首诗歌的题目,使用 dict/list/dict 的引用:
>>> data['poems'][1]['title']
'Canadian Charms'
PyYAML 可以从字符串中载入 Python 对象,但这样做是不安全的。如果导入你不信任的 YAML,使用
safe_load()
代替load()
。最好还是使用safe_load()
,通过阅读 war is peace(http://nedbatchelder.com/blog/201302/war_is_peace.html)进一步了解载入 YAML 在 Ruby on Rails 平台上如何折中。
8.2.6 安全提示
你可以使用本章中介绍的所有格式保存数据对象到文件中,或者在文件中读取它们。在这个过程中也可能会产生安全性问题。
例如,下面引自 10 亿维基百科页面的 XML 片段定义了 10 个嵌套实体,每一项扩展 10 倍的子项,总共有 10 亿的扩展项:
<?xml version="1.0"?>
<!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
<!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
<!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
<!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
<!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
<!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>
糟糕的是,前面提到的 XML 库无法容纳 10 亿多的项。Defused XML(https://bitbucket.org/tiran/defusedxml)列出了这种攻击和 Python 库的其他缺点,并且指出了如何修改设置避免这些问题。或者使用 defusedxml
库作为安全的保护:
>>> # 不安全:
>>> from xml.etree.ElementTree import parse
>>> et = parse(xmlfile)
>>> # 受保护:
>>> from defusedxml.ElementTree import parse
>>> et = parse(xmlfile)
8.2.7 配置文件
许多程序提供多种选项和设置。动态的设置可以通过传入程序参数,但是持久的参数需要保存下来。因此,尽管你急着想快速定义自己的配置文件格式,但是最好不要这样做。你定义的格式可能既粗糙也没有节省时间。你需要同时维护写入配置文件的程序以及读取配置文件的程序(有时被称为解析程序)。其实,在程序中配置文件可以有许多好的选择,包括之前几节中提到的格式。
我们使用标准 configparser
模块处理 Windows 风格的初始化 .ini 文件。这些文件都包含 key = value 的定义,以下是一个简单的配置文件 settings.cfg 例子:
[english]
greeting = Hello
[french]
greeting = Bonjour
[files]
home = usrlocal
# 简单的插入:
bin = %(home)s/bin
下面的代码是把配置文件读入到 Python 数据结构:
>>> import configparser
>>> cfg = configparser.ConfigParser()
>>> cfg.read('settings.cfg')
['settings.cfg']
>>> cfg
<configparser.ConfigParser object at 0x1006be4d0>
>>> cfg['french']
<Section: french>
>>> cfg['french']['greeting']
'Bonjour'
>>> cfg['files']['bin']
'usrlocal/bin'
其他操作包括自定义修改也是可以实现的,请参阅文档 configparser
(https://docs.python.org/3.3/library/configparser.html)。如果你需要两层以上的嵌套结构,使用 YAML 或者 JSON。
8.2.8 其他交换格式
这些二进制数据交换格式通常比 XML 或者 JSON 更加快速和复杂:
MsgPack(http://msgpack.org)
Protocol Buffers(https://code.google.com/p/protobuf/)
Thrift(http://thrift.apache.org/)
因为它们都是二进制文件,所以人类是无法使用文本编辑器轻易编辑的。
8.2.9 使用pickle
序列化
存储数据结构到一个文件中也称为序列化(serializing)。像 JSON 这样的格式需要定制的序列化数据的转换器。Python 提供了 pickle
模块以特殊的二进制格式保存和恢复数据对象。
还记得 JSON 解析 datetime
对象时出现问题吗?但对于 pickle
就不存在问题:
>>> import pickle
>>> import datetime
>>> now1 = datetime.datetime.utcnow()
>>> pickled = pickle.dumps(now1)
>>> now2 = pickle.loads(pickled)
>>> now1
datetime.datetime(2014, 6, 22, 23, 24, 19, 195722)
>>> now2
datetime.datetime(2014, 6, 22, 23, 24, 19, 195722)
pickle
同样也适用于自己定义的类和对象。现在,我们定义一个简单的类 Tiny
,当其对象强制转换为字符串时会返回 'tiny'
:
>>> import pickle
>>> class Tiny():
... def __str__(self):
... return 'tiny'
...
>>> obj1 = Tiny()
>>> obj1
<__main__.Tiny object at 0x10076ed10>
>>> str(obj1)
'tiny'
>>> pickled = pickle.dumps(obj1)
>>> pickled
b'\x80\x03c__main__\nTiny\nq\x00)\x81q\x01.'
>>> obj2 = pickle.loads(pickled)
>>> obj2
<__main__.Tiny object at 0x10076e550>
>>> str(obj2)
'tiny'
pickled
是从对象 obj1
转换来的序列化二进制字符串。然后再把字符串还原成对象 obj1
的副本 obj2
。使用函数 dump()
序列化数据到文件,而函数 load()
用作反序列化。
因为
pickle
会创建 Python 对象,前面提到的安全问题也同样会发生,不要对你不信任的文件做反序列化。
8.3 结构化二进制文件
有些文件格式是为了存储特殊的数据结构,它们既不是关系型数据库也不是 NoSQL 数据库。下面会介绍其中的几种。
8.3.1 电子数据表
电子数据表,尤其是 Microsoft Excel,是广泛使用的二进制数据格式。如果你把电子数据表保存到一个 CSV 文件中,就可以利用之前提到的标准 csv
模块读取它。如果你有一个 xls 文件,也可以使用第三方库 xlrd
(http://pypi.python.org/pypi/xlrd)读写文件。
8.3.2 层次数据格式
层次数据格式(HDF5)是一种用于多维数据或者层次数值数据的二进制数据格式。它主要用在科学计算领域,快速读取海量数据集(GB 或者 TB)是常见的需求。即使某些情况下 HDF5 能很好地代替数据库,但它在商业应用上也是默默无闻的。它能适用于 WORM(Write Once/Read Many;一次写入,多次读取)应用,不用担心写操作冲突的数据保护。下面是两个可能有用的模块:
h5py
是功能全面的较低级的接口,参考文档(http://www.h5py.org/)和代码(https://github.com/h5py/h5py);PyTables
是具有数据库特征的较为高级的接口,参考文档(http://www.pytables.org/)和代码(http://pytables.github.com/)。
这两个模块都会在附录 C 中的 Python 科学应用中讨论,在这里提到它以防你有存储和检索海量大数据的需求以及愿意考虑不同于普通数据库的解决方案。一个很好的例子是 Million Song dataset(http://labrosa.ee.columbia.edu/millionsong/),就是通过 HDF5 格式下载歌曲数据。
8.4 关系型数据库
关系型数据库虽然只有 40 多年的历史,却无处不在。你一定曾经和它打过交道,使用时你会体会到它提供的如下功能:
多用户同时访问数据;
用户使用数据的保护;
高效地存储和检索数据;
数据被模式定义以及被约束限制;
Joins 通过连接发现不同数据之间的关系;
声明式(非命令式)查询语言,SQL(Structured Query Language)。
之所以被称为关系型(relational)是因为数据库展现了表单(table)形式的不同类型数据之间的关系。例如之前菜单的例子中,每一项和它的价格是有对应关系的。
表单是一个具有行和列的二元组,和电子数据表类似。要创建一个表单,需要给它命名,明确次序、每一项的名称以及每一列的类型。每一行都会存在相同的列,即使允许缺失项(也称为 null)。在菜单例子中,你创建的表单中应该把每一个待售的食物作为一行;每一行都存在相同的列,包括它的价格。
某一列或者某几列通常作为表单的主键,在表单中主键的值是独一无二的,防止重复增添数据项。这些键在查询时被快速索引,类似于图书的索引,方便快速地找到指定行。
每一个表单都附属于某数据库,类似于一个文件都存在于某目录下。两层的层次结构便于更好地组织和管理。
数据库一词有多种用法,用于指代服务器、表单容器以及存储的数据。如果你同时指代它们,可以称其为数据库服务器(database server)、数据库(database)和数据(data)。
如果你想通过非主键的列的值查找数据,可以定义一个二级索引,否则数据库服务器需要扫描整个表单,暴力搜索每一行找到匹配列的值。
表单之间可以通过外键建立关系,列的值受这些键的约束。
8.4.1 SQL
SQL 既不是一个 API 也不是一种协议,而是一种声明式语言,只需要告诉它做什么即可。它是关系型数据库的通用语言。SQL 查询是客户端发送给数据库服务器的文本字符串,指明需要执行的具体操作。
SQL 语言存在很多标准定义格式,但是所有的数据库制造商都会增加它们自己的扩展,导致产生许多 SQL 方言。如果你把数据存储在关系型数据库中,SQL 会带来一定的可移植性,但是方言和操作差异仍然会导致难以将数据移植到另一种类型的数据库中。
SQL 语句有两种主要的类型:
- DDL(数据定义语言)
处理用户、数据库以及表单的创建、删除、约束和权限等。
- DML(数据操作语言)
处理数据插入、选择、更新和删除。
表 8-1 列出了基本的 SQL DDL 命令。
表8-1:基本的SQL DDL命令
操作 | SQL模式 | SQL示例 |
---|---|---|
创建数据库 |
CREATE DATABASE dbname
|
CREATE DATABASE d
|
选择当前数据库 |
USE dbname
|
USE d
|
删除数据库以及表单 |
DROP DATABASE dbname
|
DROP DATABASE d
|
创建表单 |
CREATE TABLE tbname (coldefs)
|
CREATE TABLE t(id INT, count INT)
|
删除表单 |
DROP TABLE tbname
|
DROP TABLE t
|
删除表单中所有的行 |
TRUNCATE TABLE tbname
|
TRUNCATE TABLE t
|
为什么语句中命令都是大写字母? SQL 是不区分大小写的,但是一般为了区分命令和名称,在代码示例中还是用大写字母。
SQL 关系型数据库的主要 DML 操作可以缩略为 CRUD。
Create:使用
INSERT
语句创建Read:使用
SELECT
语句选择Update:使用
UPDATE
语句更新Delete:使用
DELETE
语句删除
表 8-2 列出了一些 SQL DML 命令。
表8-2:基本的SQL DML命令
操作 | SQL模式 | SQL示例 |
---|---|---|
增加行 |
INSERT INTO tbname VALUES(…)
|
INSERT INTO t VALUES(7,40)
|
选择全部行和全部列 |
SELECT * FROM tbname
|
SELECT * FROM t
|
选择全部行和部分列 |
SELECT cols FROM tbname
|
SELECT id,count from t
|
选择部分行部分列 |
SELECT cols FROM tbname WHERE condition
|
SELECT id,count from t WHERE count > 5 AND id = 9
|
修改一列的部分行 |
UPDATE tbname SET col = value WHERE condition
|
UPDATE t SET count=3 WHERE id=5
|
删除部分行 |
DELETE FROM tbname WHERE condition
|
DELETE FROM t WHERE count <= 10 OR id = 16
|
8.4.2 DB-API
应用程序编程接口(API)是访问某些服务的函数集合。DB-API(http://legacy.python.org/dev/peps/pep-0249/)是 Python 中访问关系型数据库的标准 API。使用它可以编写简单的程序来处理多种类型的关系型数据库,不需要为每种数据库编写独立的程序,类似于 Java 的 JDBC 或者 Perl 的 dbi。
它的主要函数如下所示。
connect()
连接数据库,包含参数用户名、密码、服务器地址,等等。
cursor()
创建一个 cursor 对象来管理查询。
execute()
和executemany()
对数据库执行一个或多个 SQL 命令。
fetchone()
、fetchmany()
和fetchall()
得到 execute
之后的结果。
下一节的 Python 数据库模块遵循 DB-API,但会有扩展和细节上的差别。
8.4.3 SQLite
SQLite(http://www.sqlite.org)是一种轻量级的、优秀的开源关系型数据库。它是用 Python 的标准库实现,并且存储数据库在普通文件中。这些文件在不同机器和操作系统之间是可移植的,使得 SQLite 成为简易关系型数据库应用的可移植的解决方案。它不像功能全面的 MySQL 或者 PostgreSQL,SQLite 仅仅支持原生 SQL 以及多用户并发操作。浏览器、智能手机和其他应用会把 SQLite 作为嵌入数据库。
首先使用 connect()
函数连接本地的 SQLite 数据库文件,这个文件和目录型数据库(管理其他的表单)是等价的。字符串 ':memory:'
仅用于在内存中创建数据库,有助于方便快速地测试,但是程序结束或者计算机关闭时所有数据都会丢失。
下一个例子会创建一个数据库 enterprise.db
和表单 zoo
用以管理路边繁华的宠物动物园业务。表单的列如下所示。
critter
可变长度的字符串,作为主键。
count
某动物的总数的整数值。
damages
人和动物的互动中损失的美元数目。
>>> import sqlite3
>>> conn = sqlite3.connect('enterprise.db')
>>> curs = conn.cursor()
>>> curs.execute('''CREATE TABLE zoo
(critter VARCHAR(20) PRIMARY KEY,
count INT,
damages FLOAT)''')
<sqlite3.Cursor object at 0x1006a22d0>
Python 只有在创建长字符串时才会用到三引号('''
),例如 SQL 查询。
现在往动物园中新增一些动物:
>>> curs.execute('INSERT INTO zoo VALUES("duck", 5, 0.0)')
<sqlite3.Cursor object at 0x1006a22d0>
>>> curs.execute('INSERT INTO zoo VALUES("bear", 2, 1000.0)')
<sqlite3.Cursor object at 0x1006a22d0>
使用 placeholder 是一种更安全的、插入数据的方法:
>>> ins = 'INSERT INTO zoo (critter, count, damages) VALUES(?, ?, ?)'
>>> curs.execute(ins, ('weasel', 1, 2000.0))
<sqlite3.Cursor object at 0x1006a22d0>
在 SQL 中使用三个问号表示要插入三个值,并把它们作为一个列表传入函数 execute()
。这些占位符用来处理一些冗余的细节,例如引用(quoting)。它们会防止 SQL 注入:一种常见的 Web 外部攻击方式,向系统插入恶意的 SQL 命令。
现在使用 SQL 获取所有动物:
>>> curs.execute('SELECT * FROM zoo')
<sqlite3.Cursor object at 0x1006a22d0>
>>> rows = curs.fetchall()
>>> print(rows)
[('duck', 5, 0.0), ('bear', 2, 1000.0), ('weasel', 1, 2000.0)]
按照数目(count)排序,重新获得它们:
>>> curs.execute('SELECT * from zoo ORDER BY count')
<sqlite3.Cursor object at 0x1006a22d0>
>>> curs.fetchall()
[('weasel', 1, 2000.0), ('bear', 2, 1000.0), ('duck', 5, 0.0)]
又需要按照降序得到它们:
>>> curs.execute('SELECT * from zoo ORDER BY count DESC')
<sqlite3.Cursor object at 0x1006a22d0>
>>> curs.fetchall()
[('duck', 5, 0.0), ('bear', 2, 1000.0), ('weasel', 1, 2000.0)]
哪种类型的动物花费最多呢?
>>> curs.execute('''SELECT * FROM zoo WHERE
... damages = (SELECT MAX(damages) FROM zoo)''')
<sqlite3.Cursor object at 0x1006a22d0>
>>> curs.fetchall()
[('weasel', 1, 2000.0)]
你可能会认为是 bears(花费最多)。实际上,最好还是查看一下实际数据。
在结束本节之前,有一点需要明确,如果我们已经打开了一个连接(connection)或者游标(cursor),不需要时应该关掉它们:
>>> curs.close()
>>> conn.close()
8.4.4 MySQL
MySQL(http://www.mysql.com)是一款非常流行的开源关系型数据库。不同于 SQLite,它是真正的数据库服务器,因此客户端可以通过网络从不同的设备连接它。
MysqlDB(http://sourceforge.net/projects/mysql-python)是最常用的 MySQL 驱动程序,但至今没有支持 Python 3。表 8-3 列出了 Python 连接 MySQL 的几个驱动程序。
表8-3:MySQL的驱动程序
名称 | 链接 | Pypi包 | 导入 | 注意 |
---|---|---|---|---|
MySQL Connector | http://dev.mysql.com/doc/connector-python/en/index.html | mysqlconnector-python |
mysql.connector
| |
PYMySQL | https://github.com/petehunt/PyMySQL/ | pymysql |
pymysql
| |
oursql | http://pythonhosted.org/oursql/ | oursql |
oursql
| 需要 SQL 客户端的 C 依赖库 |
8.4.5 PostgreSQL
PostgreSQL(http://www.postgresql.org/)是一款功能全面的开源关系型数据库,在很多方面超过 MySQL。表 8-4 列出了 Python 连接 PostgreSQL 的几个驱动程序。
表8-4:PostgreSQL的驱动程序
名称 | 链接 | Pypi包 | 导入 | 注意 |
---|---|---|---|---|
psycopg2
| http://initd.org/psycopg/ |
psycopg2
|
psycopg2
|
需要来自 PostgreSQL 客户端工具的 pg_config
|
pypostgresql
| http://python.projects.pgfoundry.org/ |
pypostgresql
|
postgresql
|
最流行的驱动程序是 psycopg2
,但是它的安装依赖 PostgreSQL 客户端的相关库。
8.4.6 SQLAlchemy
对于所有的关系型数据库而言,SQL 是不完全相同的,并且 DB-API 仅仅实现共有的部分。每一种数据库实现的是包含自己特征和哲学的方言。许多库函数用于消除它们之间的差异,最著名的跨数据库的 Python 库是 SQLAlchemy(http://www.sqlalchemy.org)。
它不在 Python 的标准库,但被广泛认可,使用者众多。在你的系统(Linux)中使用下面这条命令安装它:
$ pip install sqlalchemy
你可以在以下层级上使用 SQLAlchemy:
底层负责处理数据库连接池、执行 SQL 命令以及返回结果,这和 DB-API 相似;
再往上是 SQL 表达式语言,更像 Python 的 SQL 生成器;
较高级的是对象关系模型(ORM),使用 SQL 表达式语言,将应用程序代码和关系型数据结构结合起来。
随着内容的深入,上面提到的术语会变得熟悉。SQLAlchemy 实现在前面几节提到的数据库驱动程序的基础上。因此不需要导入驱动程序,初始化的连接字符串会作出分配,例如:
dialect + driver :// user : password @ host : port / dbname
字符串中的值代表如下含义。
dialect
数据库类型。
driver
使用该数据库的特定驱动程序。
user
和password
数据库认证字符串。
host
和port
数据库服务器的位置(只有特定情况下会使用端口号 :port
)。
dbname
初始连接到服务器中的数据库。
表 8-5 列出了常见方言和对应的驱动程序。
表8-5:SQLAlchemy连接
方言 | 驱动程序 |
---|---|
sqlite
|
pysqlite (可以忽略)
|
mysql
|
mysqlconnector
|
mysql
|
pymysql
|
mysql
|
oursql
|
postgresql
|
psycopg2
|
postgresql
|
pypostgresql
|
1. 引擎层
首先,我们试用一下 SQLAlchemy 的底层,它可以实现多于基本 DB-API 的功能。
以内置于 Python 的 SQLite 为例,连接字符串忽略 host
、port
、user
和 password
。dbname
表示存储 SQLite 数据库的文件,如果省去 dbname
,SQLite 会在内存创建数据库。如果 dbname
以斜线(/
)开头,那么它是文件所在的绝对路径(Linux 和 OS X 是斜线,而在 Windows 是例如 C:\ 的路径名)。否则它是当前目录下的相对路径。
以下是一个程序的所有部分,为了解释把它们隔开。
开始导入库函数,例子中使用了 import 的别名,用字符串 sa
指代 SQLAlchemy。我通常会这样做是因为 sa
要比 sqlalchemy
简洁得多:
>>> import sqlalchemy as sa
连接到数据库,并在内存中存储它(参数字符串 'sqlite:///:memory:'
也是可行的):
>>> conn = sa.create_engine('sqlite://')
创建包含三列的数据库表单 zoo
:
>>> conn.execute('''CREATE TABLE zoo
... (critter VARCHAR(20) PRIMARY KEY,
... count INT,
... damages FLOAT)''')
<sqlalchemy.engine.result.ResultProxy object at 0x1017efb10>
运行函数 conn.execute()
返回到一个 SQLAlchemy 的对象 ResultProxy
。马上你会看到它的用处。
顺便提一句,如果你之前从未创建过数据库表单,祝贺你,可以把它从你的人生清单(bucket list)去掉了。
现在向空表单里插入三组数据:
>>> ins = 'INSERT INTO zoo (critter, count, damages) VALUES (?, ?, ?)'
>>> conn.execute(ins, 'duck', 10, 0.0)
<sqlalchemy.engine.result.ResultProxy object at 0x1017efb50>
>>> conn.execute(ins, 'bear', 2, 1000.0)
<sqlalchemy.engine.result.ResultProxy object at 0x1017ef090>
>>> conn.execute(ins, 'weasel', 1, 2000.0)
<sqlalchemy.engine.result.ResultProxy object at 0x1017ef450>
接下来在数据库中查询放入的所有数据:
>>> rows = conn.execute('SELECT * FROM zoo')
在 SQLAlchemy 中,rows
不是一个列表,不能直接输出:
>>> print(rows)
<sqlalchemy.engine.result.ResultProxy object at 0x1017ef9d0>
但它可以像列表一样迭代,每次可以得到其中的一行:
>>> for row in rows:
... print(row)
...
('duck', 10, 0.0)
('bear', 2, 1000.0)
('weasel', 1, 2000.0)
这个例子几乎和 SQLite DB-API 提到的示例是一样的。一个优势是在程序开始时不需要导入数据库驱动程序,SQLAlchemy 从连接字符串(connection string)已经指定了。改变连接字符串就可以使得代码可移植到另一种数据库。另外一个优势是 SQLAlchemy 的连接池,如果想了解更多可以阅读它的文档(http://docs.sqlalchemy.org/en/latest/core/pooling.html)。
2. SQL表达式语言
再往上一层是 SQLAlchemy 的 SQL 表达式语言。它介绍了创建多种 SQL 操作的函数。相比引擎层,它能处理更多 SQL 方言的差异,对于关系型数据库应用是一种方便的中间层解决方案。
下面介绍如何创建和管理数据表 zoo
。同样也是一个程序的连续片段。
导入和连接同之前的完全一样:
>>> import sqlalchemy as sa
>>> conn = sa.create_engine('sqlite://')
在定义表单 zoo
时,开始使用一些表达式语言代替 SQL:
>>> meta = sa.MetaData()
>>> zoo = sa.Table('zoo', meta,
... sa.Column('critter', sa.String, primary_key=True),
... sa.Column('count', sa.Integer),
... sa.Column('damages', sa.Float)
... )
>>> meta.create_all(conn)
注意多行调用时的圆括号。Table()
方法的调用结构和表单的结构相一致,此表单中包含三列,在 Table()
方法调用时括号内部也调用三次 Column()
。
同时,zoo
是连接 SQL 数据库和 Python 数据结构的一个对象。
使用表达式语言的更多函数插入数据:
... conn.execute(zoo.insert(('bear', 2, 1000.0)))
<sqlalchemy.engine.result.ResultProxy object at 0x1017ea910>
>>> conn.execute(zoo.insert(('weasel', 1, 2000.0)))
<sqlalchemy.engine.result.ResultProxy object at 0x1017eab10>
>>> conn.execute(zoo.insert(('duck', 10, 0)))
<sqlalchemy.engine.result.ResultProxy object at 0x1017eac50>
接下来创建 SELECT 语句(zoo.select()
会选择出 zoo
对象表单的所有项,和 SELECT * FROM zoo
在普通 SQL 做的相同):
>>> result = conn.execute(zoo.select())
最后得到结果:
>>> rows = result.fetchall()
>>> print(rows)
[('bear', 2, 1000.0), ('weasel', 1, 2000.0), ('duck', 10, 0.0)]
3. 对象关系映射
在上一节中,对象 zoo
是 SQL 和 Python 之间的中间层连接。在 SQLAlchemy 的顶层,对象关系映射(ORM)使用 SQL 表达式语言,但尽量隐藏实际数据库的机制。你自己定义类,ORM 负责处理如何读写数据库的数据。在 ORM 这个复杂短语背后,最基本的观点是:同样使用一个关系型数据库,但操作数据的方式仍然和 Python 保持接近。
我们定义一个类 Zoo
,把它挂接到 ORM。这一次,我们使用 SQLite 的 zoo.db 文件以便于验证 ORM 是否有效。
和前两节一样,代码片段实际上是一个被解释所隔开的程序。如果不明白其中的部分代码,不要着急,SQLAlchemy 文档有全部的细节,这些资料可能会变得更加复杂。这里我仅仅想让你了解使用该方法的工作量,从而方便决定本章的哪种方法更适合你。
初始的 import 还是一样,这一次需要导入新的东西:
>>> import sqlalchemy as sa
>>> from sqlalchemy.ext.declarative import declarative_base
连接数据库:
>>> conn = sa.create_engine('sqlite:///zoo.db')
现在进入 SQLAlchemy 的 ORM,定义类 Zoo
,并关联它的属性和表单中的列:
>>> Base = declarative_base()
>>> class Zoo(Base):
... __tablename__ = 'zoo'
... critter = sa.Column('critter', sa.String, primary_key=True)
... count = sa.Column('count', sa.Integer)
... damages = sa.Column('damages', sa.Float)
... def __init__(self, critter, count, damages):
... self.critter = critter
... self.count = count
... self.damages = damages
... def __repr__(self):
... return "<Zoo({}, {}, {})>".format(self.critter, self.count, self.damages)
下面这行代码可以很神奇地创建数据库和表单:
>>> Base.metadata.create_all(conn)
然后通过创建 Python 对象插入数据,ORM 内部会管理这些:
>>> first = Zoo('duck', 10, 0.0)
>>> second = Zoo('bear', 2, 1000.0)
>>> third = Zoo('weasel', 1, 2000.0)
>>> first
<Zoo(duck, 10, 0.0)>
接下来,利用 ORM 接触 SQL,创建连接到数据库的会话(session):
>>> from sqlalchemy.orm import sessionmaker
>>> Session = sessionmaker(bind=conn)
>>> session = Session()
借助会话,把创建的三个对象写入数据库。add()
函数增加一个对象,而 add_all()
增加一个列表:
>>> session.add(first)
>>> session.add_all([second, third])
最后使整个过程完整:
>>> session.commit()
成功了吗?好的,在当前目录下创建了文件 zoo.db,可以使用命令行的 SQLite3
程序验证一下:
$ sqlite3 zoo.db
SQLite version 3.6.12
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> .tables
zoo
sqlite> select * from zoo;
duck|10|0.0
bear|2|1000.0
weasel|1|2000.0
本节的目的是介绍 ORM 和它在顶层的实现过程。SQLAlchemy 的作者撰写了完整的教程(http://docs.sqlalchemy.org/en/rel_0_8/orm/tutorial.html)。阅读后决定哪一级最适合你的需求:
普通 DB-API
SQLAlchemy 引擎层
SQLAlchemy 表达式语言
SQLAlchemy ORM
使用 ORM 避开复杂的 SQL 看似是个很自然的选择。到底应该使用哪一个?有些人认为应该避免使用 ORM(http://blog.codinghorror.com/object-relational-mapping-is-the-vietnam-of-computer-science/),但其他人觉得批判太重(http://java.dzone.com/articles/martin-fowler-orm-hate)。不管谁正确,ORM 终究是一种抽象,所有的抽象在某种情况下都会出现问题,毕竟它们是有纰漏的(http://www.joelonsoftware.com/articles/LeakyAbstractions.html)。当 ORM 不能满足需求时,必须要弄明白在 SQL 如何实现修正。借用互联网的一句话:一些人在遇到问题时理所当然地认为“我明白了,要使用 ORM”。但现在他们会有两个困扰。谨慎使用 ORM 以及多用于简单应用,但是应用足够简单的话,或许至少可以直接使用 SQL(或者是 SQL 表达式语言)。
或者尝试一些更为简单的,例如 dataset
(https://dataset.readthedocs.org/en/latest/)。它建立在 SQLAlchemy 之上,提供对于 SQL、JSON 以及 CSV 存储的简单 ORM。
8.5 NoSQL数据存储
有些数据库并不是关系型的,不支持 SQL。它们用来处理庞大的数据集、支持更加灵活的数据定义以及定制的数据操作。这些被统称为 NoSQL(以前的意思是 no SQL,现在理解为 not only SQL)。
8.5.1 dbm family
dbm
格式在 NoSQL 出现之前已存在很久了,它们是按照键值对的形式储存,封装在应用程序(例如网页浏览器)中,来维护各种各样的配置。从以下角度看,dbm 数据库和 Python 字典是类似的:
给一个键赋值,自动保存到磁盘中的数据库;
通过键得到对应的值。
下面简单的例子中,open()
方法的第二个参数 'r'
代表读;'w'
代表写;'c'
表示读和写,如果文件不存在则创建之:
>>> import dbm
>>> db = dbm.open('definitions', 'c')
同字典一样创建键值对,给一个键赋值:
>>> db['mustard'] = 'yellow'
>>> db['ketchup'] = 'red'
>>> db['pesto'] = 'green'
停下来看看数据库中存放了什么:
>>> len(db)
3
>>> db['pesto']
b'green'
现在关掉数据库,然后重新打开验证它是否被完整保存:
>>> db.close()
>>> db = dbm.open('definitions', 'r')
>>> db['mustard']
b'yellow'
键和值都以字节保存,因此不能对数据库对象 db
进行迭代,但是可以使用函数 len()
得到键的数目。注意 get()
和 setdefault()
函数只能用于字典的方法。
8.5.2 memcached
memcached
(http://memcached.org/)是一种快速的、内存键值对象的缓存服务器。它一般置于数据库之前,用于存储网页服务器会话数据。Linux 和 OS X 点此链接(https://code.google.com/p/memcached/wiki/NewInstallFromPackage)下载,而 Windows 系统在此(http://zurmo.org/wiki/installing-memcache-on-windows)下载。如果你想要尝试使用,需要一个 memcached 服务器和 Python 的驱动程序。
当然存在很多这样的驱动程序,其中能在 Python 3 使用的是 python3-memcached
(https://github.com/eguven/python3-memcached),可以通过下面这条命令安装:
$ pip install python-memcached
连接到一个 memcached 服务器之后,可以做以下事项:
赋值和取值
其中一个值的自增或者自减
删除其中一个键
数据在 memcached 并不是持久化保存的,后面的可能会覆盖早些写入的数据,这本来就是它的固有特性,因为它作为一个缓存服务器,通过舍弃旧数据避免程序运行时内存不足的问题。
你也可以同时连接到多个 memcached 服务器。不过下面的例子只连到一个:
>>> import memcache
>>> db = memcache.Client(['127.0.0.1:11211'])
>>> db.set('marco', 'polo')
True
>>> db.get('marco')
'polo'
>>> db.set('ducks', 0)
True
>>> db.get('ducks')
0
>>> db.incr('ducks', 2)
2
>>> db.get('ducks')
2
8.5.3 Redis
Redis(http://redis.io/)是一种数据结构服务器(data structure server)。和 memcached 类似,Redis 服务器的所有数据都是基于内存的(现在也可以选择把数据存放在磁盘)。不同于 memcached,Redis 可以实现:
存储数据到磁盘,方便断电重启和提升可靠性;
保存旧数据;
提供多种数据结构,不限于简单字符串。
Redis 的数据类型和 Python 很相近,Redis 服务器会是一个或多个 Python 应用程序之间共享数据的非常有帮助的中间件。据我的经验,值得用一定的篇幅介绍它。
Python 的 Redis 驱动程序 redis-py
在 GitHub(https://github.com/andymccurdy/redis-py)托管代码和测试用例,也可在此参考在线文档(http://redis-py.readthedocs.org/en/latest/)。可以使用这条命令安装它:
$ pip install redis
Redis 服务器自身就有好用的文档。如果在本地计算机(网络名为 localhost
)安装和启动了 Redis 服务器,就可以开始尝试下面的程序。
1. 字符串
具有单一值的一个键被称作 Redis 的字符串。简单的 Python 数据类型可以自动转换成 Redis 字符串。现在连接到一些主机(默认 localhost
)以及端口(默认 6379
)上的 Redis 服务器:
>>> import redis
>>> conn = redis.Redis()
redis.Redis('localhost')
或者 redis.Redis('localhost', 6379)
会得到同样的结果。
列出所有的键(目前为空):
>>> conn.keys('*')
[]
给键 'secret'
赋值一个字符串;给键 'carats'
赋一个整数;给键 'fever'
赋一个浮点数:
>>> conn.set('secret', 'ni!')
True
>>> conn.set('carats', 24)
True
>>> conn.set('fever', '101.5')
True
通过键反过来得到对应的值:
>>> conn.get('secret')
b'ni!'
>>> conn.get('carats')
b'24'
>>> conn.get('fever')
b'101.5'
这里的 setnx()
方法只有当键不存在时才设定值:
>>> conn.setnx('secret', 'icky-icky-icky-ptang-zoop-boing!')
False
方法运行失败,因为之前已经定义了 'secret':
>>> conn.get('secret')
b'ni!'
方法 getset()
会返回旧的值,同时赋新的值:
>>> conn.getset('secret', 'icky-icky-icky-ptang-zoop-boing!')
b'ni!'
先不急着继续下面的内容,看之前的操作是否可以运行?
>>> conn.get('secret')
b'icky-icky-icky-ptang-zoop-boing!'
使用函数 getrange()
得到子串(偏移量 offset:0 代表开始,-1 代表结束):
>>> conn.getrange('secret', -6, -1)
b'boing!'
使用函数 setrange()
替换子串(从开始位置偏移):
>>> conn.setrange('secret', 0, 'ICKY')
32
>>> conn.get('secret')
b'ICKY-icky-icky-ptang-zoop-boing!'
接下来使用函数 mset()
一次设置多个键值:
>>> conn.mset({'pie': 'cherry', 'cordial': 'sherry'})
True
使用函数 mget()
一次取到多个键的值:
>>> conn.mget(['fever', 'carats'])
[b'101.5', b'24']
使用函数 delete()
删掉一个键:
>>> conn.delete('fever')
True
使用函数 incr()
或者 incrbyfloat()
增加值,函数 decr()
减少值:
>>> conn.incr('carats')
25
>>> conn.incr('carats', 10)
35
>>> conn.decr('carats')
34
>>> conn.decr('carats', 15)
19
>>> conn.set('fever', '101.5')
True
>>> conn.incrbyfloat('fever')
102.5
>>> conn.incrbyfloat('fever', 0.5)
103.0
不存在函数 decrbyfloat()
,可以用增加负数代替:
>>> conn.incrbyfloat('fever', -2.0)
101.0
2. 列表
Redis 的列表仅能包含字符串。当第一次插入数据时列表被创建。使用函数 lpush()
在开始处插入:
>>> conn.lpush('zoo', 'bear')
1
在开始处插入超过一项:
>>> conn.lpush('zoo', 'alligator', 'duck')
3
使用 linsert()
函数在一个值的前或者后插入:
>>> conn.linsert('zoo', 'before', 'bear', 'beaver')
4
>>> conn.linsert('zoo', 'after', 'bear', 'cassowary')
5
使用 lset()
函数在偏移量处插入(列表必须已经存在):
>>> conn.lset('zoo', 2, 'marmoset')
True
使用 rpush()
函数在结尾处插入:
>>> conn.rpush('zoo', 'yak')
6
使用 lindex()
函数取到给定偏移量处的值:
>>> conn.lindex('zoo', 3)
b'bear'
使用 lrange()
函数取到给定偏移量范围(0~-1 代表全部)的所有值:
>>> conn.lrange('zoo', 0, 2)
[b'duck', b'alligator', b'marmoset']
使用 ltrim()
函数仅保留列表中给定范围的值:
>>> conn.ltrim('zoo', 1, 4)
True
使用函数 lrange()
得到一定范围的的值(0~-1 代表全部):
>>> conn.lrange('zoo', 0, -1)
[b'alligator', b'marmoset', b'bear', b'cassowary']
第 10 章会介绍如何使用 Redis 列表以及发布 - 订阅(publish-subscribe)用于实现任务队列。
3. 哈希表
Redis 的哈希表类似于 Python 中的字典,但它仅包含字符串,因此只能有一层结构,不能进行嵌套。下面的例子创建了一个 Redis 的哈希表 song
,并对它进行操作。
使用函数 hmset()
在哈希表 song
设置字段 do
和字段 re
的值:
>>> conn.hmset('song', {'do': 'a deer', 're': 'about a deer'})
True
使用函数 hset()
设置一个单一字段值:
>>> conn.hset('song', 'mi', 'a note to follow re')
1
使用函数 hget()
取到一个字段的值:
>>> conn.hget('song', 'mi')
b'a note to follow re'
使用函数 hmget()
取到多个字段的值:
>>> conn.hmget('song', 're', 'do')
[b'about a deer', b'a deer']
使用函数 hkeys()
取到所有字段的键:
>>> conn.hkeys('song')
[b'do', b're', b'mi']
使用函数 hvals()
取到所有字段的值:
>>> conn.hvals('song')
[b'a deer', b'about a deer', b'a note to follow re']
使用函数 hlen()
返回字段的总数:
>>> conn.hlen('song')
3
使用函数 hgetall()
取到所有字段的键和值:
>>> conn.hgetall('song')
{b'do': b'a deer', b're': b'about a deer', b'mi': b'a note to follow re'}
使用函数 hsetnx()
对字段中不存在的键赋值:
>>> conn.hsetnx('song', 'fa', 'a note that rhymes with la')
1
4. 集合
正如你会在下面看到的例子所示,Redis 的集合和 Python 的集合是完全类似的。
在集合中添加一个或多个值:
>>> conn.sadd('zoo', 'duck', 'goat', 'turkey')
3
取得集合中所有值的数目:
>>> conn.scard('zoo')
3
返回集合中的所有值:
>>> conn.smembers('zoo')
{b'duck', b'goat', b'turkey'}
从集合中删掉一个值:
>>> conn.srem('zoo', 'turkey')
True
新建一个集合以展示一些集合间的操作:
>>> conn.sadd('better_zoo', 'tiger', 'wolf', 'duck')
0
返回集合 zoo
和集合 better_zoo
的交集:
>>> conn.sinter('zoo', 'better_zoo')
{b'duck'}
获得集合 zoo
和集合 better_zoo
的交集,并存储到新集合 fowl_zoo
:
>>> conn.sinterstore('fowl_zoo', 'zoo', 'better_zoo')
1
哪一个会在集合 fowl_zoo
中?
>>> conn.smembers('fowl_zoo')
{b'duck'}
返回集合 zoo
和集合 better_zoo
的并集:
>>> conn.sunion('zoo', 'better_zoo')
{b'duck', b'goat', b'wolf', b'tiger'}
存储并集结果到新的集合 fabulous_zoo
:
>>> conn.sunionstore('fabulous_zoo', 'zoo', 'better_zoo')
4
>>> conn.smembers('fabulous_zoo')
{b'duck', b'goat', b'wolf', b'tiger'}
什么是集合 zoo
包含而集合 better_zoo
不包含的项?使用函数 sdiff()
得到它们的差集,sdiffstore()
将存储到新集合 zoo_sale
:
>>> conn.sdiff('zoo', 'better_zoo')
{b'goat'}
>>> conn.sdiffstore('zoo_sale', 'zoo', 'better_zoo')
1
>>> conn.smembers('zoo_sale')
{b'goat'}
5. 有序集合
Redis 中功能最强大的数据类型之一是有序表(sorted set 或者 zset)。它里面的值都是独一无二的,但是每一个值都关联对应浮点值分数(score)。可以通过值或者分数取得每一项。有序集合有很多用途:
排行榜
二级索引
时间序列(把时间戳作为分数)
我们把最后一个(时间序列)作为例子,通过时间戳跟踪用户的登录。在这里,时间表达使用 Unix 的 epoch 值(更多介绍见第 10 章),它由 Python 的 time()
函数返回:
>>> import time
>>> now = time.time()
>>> now
1361857057.576483
首先增加第一个访客:
>>> conn.zadd('logins', 'smeagol', now)
1
5 分钟后,又一名访客:
>>> conn.zadd('logins', 'sauron', now+(5*60))
1
两小时后:
>>> conn.zadd('logins', 'bilbo', now+(2*60*60))
1
一天后,负载并不是很多:
>>> conn.zadd('logins', 'treebeard', now+(24*60*60))
1
那么 bilbo
登录的次序是什么?
>>> conn.zrank('logins', 'bilbo')
2
登录时间呢?
>>> conn.zscore('logins', 'bilbo')
1361864257.576483
按照登录的顺序查看每一位访客:
>>> conn.zrange('logins', 0, -1)
[b'smeagol', b'sauron', b'bilbo', b'treebeard']
附带上他们的登录时间:
>>> conn.zrange('logins', 0, -1, withscores=True)
[(b'smeagol', 1361857057.576483), (b'sauron', 1361857357.576483),
(b'bilbo', 1361864257.576483), (b'treebeard', 1361943457.576483)]
6. 位图
位图(bit)是一种非常省空间且快速的处理超大集合数字的方式。假设你有一个很多用户注册的网站,想要跟踪用户的登录频率、在某一天用户的访问量以及同一用户在固定时间内的访问频率,等等。当然,你可以使用 Redis 集合,但如果使用递增的用户 ID,位图的方法更加简洁和快速。
首先为每一天创建一个位集合(bitset)。为了测试,我们仅使用 3 天和部分用户 ID:
>>> days = ['2013-02-25', '2013-02-26', '2013-02-27']
>>> big_spender = 1089
>>> tire_kicker = 40459
>>> late_joiner = 550212
每一天是一个单独的键,对应的用户 ID 设置位,例如第一天(2013-02-25
)有来自 big_spender(ID 1089)
和 tire_kicker(ID 40459)
的访问记录:
>>> conn.setbit(days[0], big_spender, 1)
0
>>> conn.setbit(days[0], tire_kicker, 1)
0
第二天用户 big_spender
又有访问:
>>> conn.setbit(days[1], big_spender, 1)
0
接下来的一天,朋友 big_spender
再次访问,并又有新人 late_joiner
访问:
>>> conn.setbit(days[2], big_spender, 1)
0
>>> conn.setbit(days[2], late_joiner, 1)
0
现在统计得到这三天的日访客数:
>>> for day in days:
... conn.bitcount(day)
...
2
1
2
查看某一天某个用户是否有访问记录?
>>> conn.getbit(days[1], tire_kicker)
0
显然 tire_kicker
在第二天没有访问。
有多少访客每天都会访问?
>>> conn.bitop('and', 'everyday', *days)
68777
>>> conn.bitcount('everyday')
1
让你猜三次他是谁:
>>> conn.getbit('everyday', big_spender)
1
最后,这三天中独立的访客数量有多少?
>>> conn.bitop('or', 'alldays', *days)
68777
>>> conn.bitcount('alldays')
3
7. 缓存和过期
所有的 Redis 键都有一个生存期或者过期时间(expiration date),默认情况下,生存期是永久的。也可以使用 expire()
函数构造 Redis 键的生存期,下面看到的设置值是以秒为单位的数:
>>> import time
>>> key = 'now you see it'
>>> conn.set(key, 'but not for long')
True
>>> conn.expire(key, 5)
True
>>> conn.ttl(key)
5
>>> conn.get(key)
b'but not for long'
>>> time.sleep(6)
>>> conn.get(key)
>>>
expireat()
命令给一个键设定过期时间,对于更新缓存是有帮助的,并且可以限制登录会话。
8.5.4 其他的NoSQL
NoSQL 服务器都要处理远超过内存的数据,并且很多服务器要使用多台计算机。表 8-6 列出了值得注意的服务器和它们的 Python 库。
表8-6:NoSQL数据库
Site | Python API |
---|---|
Cassandra(http://cassandra.apache.org/) | pycassa(https://github.com/pycassa/pycassa) |
CouchDB(http://couchdb.apache.org/) | couchdb-python(https://github.com/djc/couchdb-python) |
HBase(http://hbase.apache.org/) | happybase(https://github.com/wbolster/happybase) |
Kyoto Cabinet(http://fallabs.com/kyotocabinet/) | kyotocabinet(http://fallabs.com/kyotocabinet/pythondoc/) |
MongoDB(http://www.mongodb.org/) | mongodb(http://api.mongodb.org/python/current/) |
Riak(http://basho.com/riak/) | riak-python-client(https://github.com/basho/riak-pythonclient) |
8.6 全文数据库
最后,有一类特殊的数据库用于做全文检索。它们对所有内容都建索引,所以你可以检索到吟诵风车和满车奶酪的诗歌。表 8-7 是一些流行的开源软件以及它们的 Python API。
表8-7:全文数据库
Site | Python API |
---|---|
Lucene(http://lucene.apache.org/) | pylucene(http://lucene.apache.org/pylucene/) |
Solr(http://lucene.apache.org/solr/) | SolPython(http://wiki.apache.org/solr/SolPython) |
ElasticSearch(http://www.elasticsearch.org/) | pyes(https://github.com/aparo/pyes/) |
Sphinx(http://sphinxsearch.com/) | sphinxapi(https://code.google.com/p/sphinxsearch/source/browse/trunk/api/sphinxapi.py) |
Xapian(http://xapian.org/) | xappy(https://code.google.com/p/xappy/) |
Whoosh(https://bitbucket.org/mchaput/whoosh/wiki/Home) | 由 Python 编写,包含API |
8.7 练习
(1) 将字符串 'This is a test of the emergency text system'
赋给变量 test1
,然后把它写到文件 test.txt。
(2) 打开文件 test.txt,读文件内容到字符串 test2
。test1
和 test2
是一样的吗?
(3) 保存这些文本到 books.csv 文件。注意,字段间是通过逗号隔开的,如果字段中含有逗号需要在整个字段加引号。
author,book
J R R Tolkien,The Hobbit
Lynne Truss,"Eats, Shoots & Leaves"
(4) 使用 csv
模块和它的 DictReader
方法读取文件 books.csv 到变量 books
。输出变量 books
的值。DictReader
可以处理第二本书题目中的引号和逗号吗?
(5) 创建包含下面这些行的 CSV 文件 books.csv:
title,author,year
The Weirdstone of Brisingamen,Alan Garner,1960
Perdido Street Station,China Miéville,2000
Thud!,Terry Pratchett,2005
The Spellman Files,Lisa Lutz,2007
Small Gods,Terry Pratchett,1992
(6) 使用 sqlite3
模块创建一个 SQLite 数据库 books.db 以及包含字段 title
(text)、author
(text)以及 year
(integer)的表单 book
。
(7) 读取文件 books.csv,把数据插入到表单 book
。
(8) 选择表单 book
中的 title
列,并按照字母表顺序输出。
(9) 选择表单 book
中所有的列,并按照出版顺序输出。
(10) 使用 sqlalchemy
模块连接到 sqlite3 数据库 books.db,按照 (8) 一样,选择表单 book
中的 title
列,并按照字母表顺序输出。
(11) 在你的计算机安装 Redis 服务器和 Python 的 redis
库(pip install redis
)。创建一个 Redis 的哈希表 test
,包含字段 count(1)
和 name('Fester Bestertester')
,输出 test
的所有字段。
(12) 自增 test
的 count
字段并输出它。