Python对象序列化与反序列(Pickle)

我们最近想要对爬虫拿到的下载结果进行存档,这个结果是一个Python对象(我们并不想简单的存一个HTML或json,想要让整个下载过程可以还原),于是就想到了用Python内置的pickle库(腌黄瓜库),序列化对象成bytes,需要的时候可以反序列化。

通过下面的代码可以简单了解pickle的用法和功能。

可以看到pickle的用法和json有点像,但是有几点根本区别:

json是跨语言的通用的一种数据交换格式,一般用文本表示,人类可读。pickle是用来序列化Python对象,只是针对Python的,序列化的结果是二进制数据,人类不可读。而且json默认只可以序列化一部分的内置类型,pickle可以序列化相当多的数据。

另外还有一个古老的marshal也是内置的。但这个库主要是针对.pyc文件的。不支持自定义的类型,也不完善。比如不能处理循环应用,如果有一个对象引用了自己,那么用marshal的话Python解释器就挂了。

版本兼容问题

由于pickle是针对Python的,Python有不同的版本(并且2与3之间差异非常大),所以就要考虑到序列化出来的对象能不能被更高(或低?)版本的Python反序列化出来。

目前一共有5个pickle的协议版本,版本越高对应Pyhton的版本越高,0-2针对Python2,3-4针对Python3.

  • Protocol version 0 is the original “human-readable” protocol and is backwards compatible with earlier versions of Python.
  • Protocol version 1 is an old binary format which is also compatible with earlier versions of Python.
  • Protocol version 2 was introduced in Python 2.3. It provides much more efficient pickling of new-style classes. Refer to PEP 307for information about improvements brought by protocol 2. (从这个版本往后,性能有显著提高)
  • Protocol version 3 was added in Python 3.0. It has explicit support for bytes objects and cannot be unpickled by Python 2.x. This is the default protocol, and the recommended protocol when compatibility with other Python 3 versions is required.
  • Protocol version 4 was added in Python 3.4. It adds support for very large objects, pickling more kinds of objects, and some data format optimizations. Refer to PEP 3154 for information about improvements brought by protocol 4.

pickle的大多数入口函数(例如dump()dumps()Pickler构造器)都接受一个协议版本的参数,其中内置了两个变量:

  • pickle.HIGHEST_PROTOCOL目前是4
  • pickle.DEFAULT_PROTOCOL目前是3

用法

和内置的json模块接口类似,dumps()用于返回序列化结果,dump()用于序列化然后写入文件。同理也有load()loads()。其中,序列化dump(s)的时候可以指定协议的版本,反序列化的时候就不用了,会自动识别版本。这个和zip命令很像。

内置类型的序列化

大多数内置的类型都支持序列化和反序列化。需要特殊注意的是函数。函数的序列化只是取其名字和所在的module。函数的代码和属性(Python的函数是第一等对象,可以有属性)都不会被序列化。这就要求函数所在的模块在unpickle的环境中必须是可以import的,否则会出现ImportErrorAttributeError

这里有个地方很有意思:所有的lambda函数都是不可Pickle的。因为它们的名字都叫做<lambda>

自定义类型的序列化

如同本文开头的实验代码一样,对我们自定义的对象在大部分情况下都不需要额外的操作就可以实现序列化/反序列化的操作。需要注意的是,在反序列化的过程中,并不是调用class的__init__()来初始化出一个对象,而是新建出一个未被初始化的实例,然后恢复它的属性(非常巧妙)。伪代码如下:

如果希望在序列化的过程中做一些额外操作,例如保存对象的状态,可以使用pickle协议的魔术方法,最常见的是__setstate__()__getstate__()

安全问题(!)

pickle文档的开头就说:千万不要unpickle一个未知来源的二进制。考虑下面的代码:

这段代码unpickle出来就是导入了os.system()然后调用echo。并没有啥副作用。但如果是rm -rf /·呢?

文档给出的建议是在Unpickler.find_class()里面实现检查逻辑。函数方法在要求全局变量的时候必然调用。

压缩

pickle之后并不会自动压缩的,我觉得这个设计非常好,解耦,pickle就干pickle的事情,压缩交给别的库去做。而且你自己也可以发现,pickle之后的文件尽管不可读,但内容依然是以ascii码呈现的,并不是乱码。需要调用压缩库的compress。实测压缩之后,体积是之前的1/3左右,非常可观。

我目前已知的坑:

全局变量要保持可以导入的,这个有点难。要面对的问题是:我今天pickle的东西,在将来某一天需要打开,是否还能打开呢?

这里有几个版本:项目的版本、python的版本,pickle协议版本,项目依赖的包版本。其中python的版本和pickle版本我觉得可以放心依赖他们的向后兼容,容易解决。主要是项目和版本和依赖的版本,如果要Pickle的对象非常复杂,那么很可能老版本的备份无法兼容新版本。可能的解决办法就是对所有的依赖完全锁定,例如记录他们的hash值。如果要恢复某一个二进制序列,那么就还原出当时的特定依赖、项目的特定commit。

但是目前来说,我们的需求基本上就是pickle一个requests.Response对象。我觉得是可以依赖它们的向后兼容的。如果有一天requests有了breaking change,那么就算我们的pickle能兼容,代码也不可能兼容了,那时候可以考虑别的策略。

 

一次Mongodb的admin数据库导致的事故

Mongodb的gridfs一次插入数据的时候会自动创建几个索引,我们程序里面的账号没有createIndex权限,我需要手动创建一下。结果连接到mongo服务器之后忘记执行use xxxdb来切换数据库了,于是在admin数据库里面创建了一个索引,结果导出一边的程序报出来很多验证问题。

Mongo的admin数据库太脆弱了,只是创建一个索引就挂了。长个教训,以后千万不要手动修改它,更不要用admin保存数据

反思一下,这次操作失误其实爆出我平时一些不好的习惯。

首先,连接mongo应该指定目标数据。而我之前都是连接到admin,然后用use切换到目标数据库。这样难免会忘记。

第二,错误的在admin数据库执行createIndex,返回的结果明确显示索引创建成功。

但是我忽略了,继续在正确的数据库创建索引。不然可以早一些发现问题。

最后,创建索引应该自动化,比如gridfs这种对md5, filename创建索引的。

 

理解Python的Iterable和Iterator

今天看到一篇很有意思的文章,说Pyhton3的range是返回的什么?

很多人都会不假思索的说,这还不简单,在Python2中range()会返回list,到了Python3range已经使用xrange替换,返回的是一个迭代器(Iterator)。

恭喜你,答错了。

range()返回的是一个Iterable,并不是一个Iterator.

原理很简单,先简单说一下Iterable和Iterator,不要试图比较二者有什么不同,因为二者根本就是不同的概念。二者字面意思都非常明确:Iterable就是一个可迭代的对象,对其调用iter(Iterable)将会得到一个迭代器;而Iterator就是一个迭代器,对其调用next(Iterator)将会得到下一个元素。

Python推崇协议,说白了就是鸭子类型。你如果实现了__iter__(),(即对你调用iter()可以得到一个Iterator)那你就是一个Iterable;如果实现了__next__()__iter__()就是一个Iterator。

等等,Iterator不就是调用next()得到下一个元素就可以了?为什么Iterator也要实现Iterable的__iter__()方法,这不纯粹啊!

为什么Python的Iterator要实现__iter__()呢(通常的实现都是return self)。官方文档中说的相当清楚。

Iterators are required to have an __iter__() method that returns the iterator object itself so every iterator is also iterable and may be used in most places where other iterables are accepted.

简单翻译一下,就是说Iterator也要求实现__iter__(),因为很多地方接收的参数是一个Iterable,如果所有的Iterator都是Iterable,那么这些用Iterable地方都可以无障碍地使用Iterator了。比如说for循环吧,关于for循环,我在Python的wiki中(已经比较老了)找到这样的描述:

 Basically, any object with an iterable method can be used in a for loop. Even strings, despite not having an iterable method – but we’ll not get on to that here.

即,for循环拿到一个Iterable的Iterator,然后使用这个Iterator进行迭代。如果Iterator实现了__iter__()方法,那么for循环就可以无障碍地对Iterator进行迭代了,Neat!想象一下,Python的生成器也是Iterator,如果for循环不能支持对Iterator迭代,生不如死啊。

所以对Iterator就有了这样一个“过分”的要求。我们可以认为,所有的Iterator都是Iterable。那么回到最初的问题,为什么range()反回的是一个Iterable而不是Iterator呢?

Iterator是有状态的,只能遍历一次,是“消费型”的,不可以“二次消费”。Iterable是没有状态的(这里不太严谨,这句话暂且不提是Iterator的Iterable),每一次对Iterable调用iter()都会得到一个新的迭代器。

考虑我们平时使用range(),我们认为这是一个表示范围的一个容器。可以使用这个容器去初始化成别的容器,这没有任何问题。

倘若range()返回的是迭代器,那么上面这个看起来在正常不过的代码就有麻烦了:

 

读到这里,你应该完全理解Iterable和Iterator了,再说点题外话。

有人可能觉得(我之前也这么觉得),这样的实现太不纯粹了,要是Iterator就是Iterator,Iterable就是Iterable不好吗?规定接口只接受Iterator,如果有Iterable的话必须手动获得一个新的Iterator然后使用。可以是可以,但是想象一下每次都初始化一个新的Iterator多费劲啊。并且肯定有地方会定义一个临时变量,万一忍不住忘记这已经迭代完了,被迭代第二次呢?

对Iterator进行迭代,是很符合人类思维的事情,因为迭代器本来就是可以迭代的。

对Iterable进行迭代,也是很符合人类思维的事情,”可迭代的对象“本来也是可迭代的,每次迭代的时候在内部初始化一个Iterator进行迭代。

所以现在想想,其实Iterator的这种行为就非常合理了。

Python是一门讲求实用主义的语言。随着越来越多地接触和使用这一门语言,我也越来越体会到这句话。另一个例子是Pyhton的元祖,这个数据结构的灵感来自ABC语言的compoundscompounds可以作为字典(在ABC中字典叫table)的合成键,也支持平行赋值。仅此而已。如果你想使用它,要么使用平行赋值取出全部的元素,要么作为一个整体来使用。这样compounds的作用变得非常纯粹——一个没有字段名字的记录的集合。而Python的tuple,却支持了迭代、下标、切片,给人的印象更像是frozenlist!概念上已经失去了compounds的纯粹。

但是这种实用主义的哲学让Python比ABC更灵活,更好用也更成功。

Although practicality beats purity.

Python 之禅如是说。

 

Python3 str混入bytes的解码问题

问题:"abc\\xe2\\x86\\x92"是一个str,其中混入了一些被错误转义的字符,需要解码得到"abc→",其中,b'\xe2\x86\x92'.decode()会得到

TL;DR

解释

"abc\\xe2\\x86\\x92"是一个str,其中\\是一个字符,前面一个反斜杠将后面一个转义了。所以这一共是15个字符。而我想要的事后面12个字符其实是表示一个utf-8编码的unicode字符。

Python提供了unicode-escape编码/解码器。可以编码/解码字面意思的unicode。编码的时候将\xe2四个字节,转换成一个字节。解码的时候,将3个字节表示的一个unicode字符,先展开unicode的表示形式,例如\u4f60,然后包括\,一个字符占一个字节,一共6个字节。

Encoding suitable as the contents of a Unicode literal in ASCII-encoded Python source code, except that quotes are not escaped. Decodes from Latin-1 source code. Beware that Python source code actually uses UTF-8 by default.

unicode-escape实际上做了两件事:

  1. escape。encode的时候,将一个unicode字符转换成多个字符,字面形式表示。decode的时候,将多个字符转成一个unicode字节。
  2. 正常encode/decode做的事情,提供strbytes之间的转换,但是只使用latin-1编码。

"abc\\xe2\\x86\\x92"中后半部分是bytes,所以应该先转换成bytes的正确格式。

现在是15个字符,后面12个应该是一个字符,按照unicode解释。

根据上面提到的,现在decode变成了latin-1编码的str,但实际上这是utf-8编码的,所以应该用latin-1解码然后重新用utf-8编码。

参考:

  1. 转换\\
  2. v2ex讨论
 

Python的命令行界面库

最近想写个命令行的图形界面的小玩具,类似htop,在命令行运行,但是不是那种输入-输出的模式,而是一种基于文字的图形界面,对于终端用户来说,比较友好。

我记得之前看到过一个不错的库,还跑过它的demo,费了不少劲才找到(你用就找不到不用就天天看到定律)。神奇的是,在这个过程中又发现不少类似的库…… 这下可纠结了。下面是整理的搜索过程中的资料,希望能帮到一些人。

1. curses

这是Python内置的一个module。不同的操作系统中不同的终端模拟器的行为可能是不一样的,curses就为你屏蔽了底层的细节,让你依赖curses写出的程序可以运行在各种终端上。也是基于这个目的,Python内置的curses基本上就是原生C curses的一个封装,实现了一些公共的操作,比如移动光标(cursor),滚动屏幕,擦除部分区域等。

另外,Windows上面的Python不会自带这个库,官方推荐了the Console moduleUniCurses

另另外,ncurses是curses的一个free版本。

我们如果想快速开发用户界面,一般会用到一些组件(widget),例如TextArea,Button,Form等,写过前端或安卓app等应该都很熟悉这些。这些东西curses都是没有提供的,并且如何响应事件等好像也是没有的,看过几个例子都是通过While True循环来监听事件。所以如果想写的界面稍微有点点复杂,这个库就力不从心了。Python的文档中推荐使用Urwid

2. pyCDK

SourceForge的页面已经打不开了…… 我在pypi上看到上次更新是2003年…… 再见!

3. Urwid

这个就很厉害了,官方网站没有太多介绍,但是从requirements可以看出,是提供了事件循环的,并且是可选的:

  • python-gi for GlibEventLoop (optional)
  • Twisted for TwistedEventLoop (optional)
  • Tornado for TornadoEventLoop (optional)
  • asyncio or trollius for AsyncioEventLoop (optional)

这就很牛逼了。从文档中,可以看到有一些Widget Classes,常用的一些Widget都有的,很赞,还有一些例子,上手应该也挺简单。我觉得首选就是它了。它更像是一个framework,而不是一个widget的库。某些情况下还是有不方便的地方。我觉得应该可以再搞一个库,弄一些常用、方便用的widget。但是想来在命令行写UI的需求实在太小吧,估计没人去做了。

4. Blessings

这个库只是curses的一个pitch和封装,解决了部分curses不好的地方,比如说函数调用啰嗦等。可以让你用string的format来控制显示,然后封装了一个Terminal,用户只用这一个对象就可以。可以说只是修修补补,但是主要的问题没有解决(UI组件和事件驱动)。

作者有另一个东西挺有意思,一个终端的游戏conway,就是用自己的这个库写的。

curses这种库用来当做一个终端的pygame来用应该没啥问题的,每一帧都重新绘制(终端本来也没几个字符),并不需要什么widget,因为基本都是自己画的游戏图形,但是并不适合来制作UI。

参考:

  1. Python ncurses, CDK, urwid difference
  2. google搜索找到不少有意思的相关项目