要使 Python 对象持久化,我们必须将其转换为字节并将字节写入文件。我们将此转换称为序列化;它也称为封送、放气或编码。我们将研究几种将 Python 对象序列化为字节流的方法。需要注意的是,我们的重点是表示对象的状态,而不是类及其方法和超类的完整定义。
序列化方案包括物理数据格式。每种格式都有一些优点和缺点。没有最佳格式来表示对象的状态。有助于将格式与逻辑数据布局区分开来,后者可能是简单的重新排序或更改空格的使用;布局更改不会更改对象的值,而是以不相关的方式更改字节序列。例如,CSV 物理格式可以有多种逻辑布局,并且仍然表示相同的基本数据。如果我们提供唯一的列标题,那么列的顺序并不重要。
一些序列化表示偏向于表示单个 Python 对象,而另一些表示可以保存单个对象的集合。即使单个对象是list
项,它仍然是单个 Python 对象。为了更新或替换列表中的一个项目,必须对整个列表进行反序列化和重新序列化。当需要灵活处理多个对象时,第 11 章、通过 Shelve、第 12 章、通过 SQLite和第 13 章介绍了更好的方法、发送共享对象
在大多数情况下,我们仅限于适合工作记忆的对象。我们将研究以下序列化表示:
- JavaScript 对象表示法(JSON):这是一种广泛使用的表示法。欲了解更多信息,请访问http://www.json.org 。
json
模块提供以这种格式加载和转储数据所需的类和函数。在 Python 标准库中,查看第 19 节Internet 数据处理,而不是第 12 节持久化。json
模块狭隘地关注 JSON 序列化。序列化任意 Python 对象这一更普遍的问题没有得到很好的处理。 - YAML 不是标记语言(YAML):这是 JSON 的扩展,可以简化序列化输出。欲了解更多信息,请查看http://yaml.org 。这不是 Python 库的标准部分;我们必须添加一个模块来处理这个问题。具体来说,
PyYaml
包具有许多 Python 持久化特性。 - pickle:
pickle
模块有自己独特的数据表示。由于这是 Python 库的一级部分,我们将仔细研究如何以这种方式序列化对象。这样做的缺点是,对于与非 Python 程序的数据交换来说,格式很差。它是第 11 章中shelve
模块通过货架存储和检索对象,以及第 13 章传输和共享对象中的消息队列的基础。 - 逗号分隔值(CSV):这对于表示复杂的 Python 对象可能不方便。由于它的使用如此广泛,我们需要找到用 CSV 符号序列化 Python 对象的方法。有关参考资料,请参阅Python 标准库的第 14 节文件格式,而不是第 12 节持久化,因为它只是一种文件格式,仅此而已。CSV 允许我们对无法放入内存的 Python 对象集合执行增量表示。
- XML:尽管存在一些缺点,但它的应用非常广泛,因此能够将对象转换为 XML 表示法并从 XML 文档中恢复对象非常重要。XML 解析是一个巨大的课题。参考资料在Python 标准库的第 20 节结构化标记处理工具中。解析 XML 有很多模块,每个模块都有不同的优点和缺点。我们将关注
ElementTree
。
除了这些简单的序列化格式之外,还可能存在混合问题。混合问题的一个例子是用 XML 编码的电子表格。这意味着我们在 XML 解析问题中有一个行和列数据表示问题。这将导致使用更复杂的软件来分离被展平为类似 CSV 的行的各种数据,以便我们可以恢复有用的 Python 对象
在本章中,我们将介绍以下主题:
- 理解持久化类、状态和表示
- 文件系统和网络注意事项
- 定义类以支持持久化
- 使用 JSON 进行转储和加载
- 用 YAML 倾倒和装载
- 用
pickle
倾倒和装载 - 使用 CSV 卸载和装载
- 用 XML 转储和加载
本章的代码文件可在上找到 https://git.io/fj2Uw 。
我们的 Python 对象主要存在于易失性计算机内存中。对象生命周期的上限是 Python 进程的持续时间。此生命周期进一步受到对象的限制,仅当存在对对象的引用时才持续。如果我们想要一个持续时间更长的对象,我们需要使它持久化。如果我们想从一个进程提取对象的状态,并将该状态信息提供给另一个进程,那么可以使用相同的持久化序列化技术来传输对象状态。
大多数操作系统都以文件系统的形式提供持久存储。这可能包括磁盘驱动器、闪存驱动器或其他形式的非易失性存储。将字节从内存持久化到文件系统变得异常困难。
这种复杂性的产生是因为内存中的 Python 对象引用了其他对象。对象引用其类。类引用任何基类。对象可能是一个容器并引用其他对象。对象的内存版本是引用和关系的网络。这些引用通常基于内存中的位置,这些位置不是固定的:只要尝试转储和恢复内存字节,就会破坏这些关系。
对象周围的引用网络包含其他基本上是静态的对象。例如,与对象中的实例变量相比,类定义的更改非常缓慢。Python 在对象的实例变量和类中定义的其他方法之间提供了一种形式上的区别。因此,序列化技术侧重于根据对象的实例变量持久化对象的动态状态。
我们实际上不需要做任何额外的事情来持久化类定义;我们已经有了一个非常简单的处理类的方法。类定义主要作为源代码存在。每次需要时,易失性内存中的类定义都会从源代码(或源代码的字节码版本)重建。如果我们需要交换类定义,我们就交换 Python 模块或包。
让我们来看看下一节中常见的 Python 术语。
Python 序列化术语倾向于关注单词dump和load。我们将要使用的大多数类将定义以下方法:
dump(object, file)
:将给定对象转储到文件中。dumps(object)
:这将转储一个对象,返回一个字符串表示。load(file)
:这将从文件加载对象,并返回构造的对象。loads(string)
:这将从字符串表示加载对象,并返回构造的对象。
没有正式的标准;任何形式的 ABC 继承或 mixin 类定义都不能保证方法名为或。然而,它们被广泛使用。通常,用于转储或加载的文件可以是任何类似于对象的文件。
要想有用,类文件对象必须实现一个简短的方法列表。一般情况下,负载需要read()
和readline()
。因此,我们可以使用io.StringIO
对象以及urllib.request
对象作为负载源。类似地,dump 对数据源的要求很少,主要使用write()
方法。接下来我们将深入探讨这些文件对象注意事项。
由于操作系统文件系统(和网络)以字节为单位工作,我们需要将对象实例变量的值表示为一个序列化的字节流。通常,我们将使用两步转换来获取字节:首先,我们将对象的状态表示为字符串;其次,我们将依赖 Pythonstr
类以标准编码方式提供字节。Python 将字符串编码为字节的内置特性巧妙地解决了问题的第二部分。这允许大多数序列化方法将重点放在创建字符串上。
当我们查看操作系统文件系统时,我们会看到两大类设备:块模式设备和字符模式设备。块模式设备也可以称为可搜索,因为操作系统支持搜索操作,可以以任意顺序访问文件中的任何字节。字符模式设备不可查找;它们是串行传输字节的接口。搜索将涉及某种时间旅行,以恢复过去的字节或查看未来的字节。
字符和块模式之间的这种区别会影响我们如何表示复杂对象或对象集合的状态。我们将在本章中讨论的序列化集中于最简单的公共特性集:有序字节流。字节流可以写入任何一种设备。
我们将在第 11 章、通过书架存储和检索对象,以及第 12 章、通过 SQLite存储和检索对象中看到的格式将需要块模式存储,以便对可能无法放入内存的更多对象进行编码。shelve
模块和SQLite
数据库需要块模式设备上的可查找文件。
在某种程度上,操作系统将块模式和字符模式设备统一到一个文件系统中。Python 标准库的某些部分实现了块和字符设备之间的公共功能集。当我们使用 Python 的urllib.request
时,我们可以访问网络资源以及本地文件。当我们打开一个本地文件时,urllib.request.urlopen()
函数将有限字符模式接口强加给块模式设备上的一个可查找文件。因为这种区别是不可见的,所以它允许单个应用程序使用网络或本地资源。
让我们定义支持持久化的类。
为了使用持久化,我们需要一些要保存的对象。我们将看一个简单的微博和博客上的帖子。以下是Post
的类定义:
from dataclasses import dataclass
import datetime
@dataclass
class Post:
date: datetime.datetime
title: str
rst_text: str
tags: List[ str ]
def as_dict( self ) -> Dict[ str , Any]:
return dict (
date = str ( self .date),
title = self .title,
underline = "-" * len ( self .title),
rst_text = self .rst_text,
tag_text = " " .join( self .tags),
)
实例变量是每个微博帖子的属性——日期、标题、一些文本和一些标签。我们的属性名为我们提供了一个提示,即文本应该位于r****eStructuredText(reST)标记中,尽管这与数据模型的其余部分基本无关。
为了支持模板的简单替换,as_dict()
方法返回已转换为字符串格式的值字典。稍后我们将使用string.Template
查看处理模板。类型提示反映了 JSON 的一般性质,其中生成的对象将是一个字典,其中包含从小型类型域中选择的字符串键和值。在本例中,所有的值都是字符串,也可以使用Dict[str, str]
;然而,这似乎过于具体,因此Dict[str, Any]
用于提供未来的灵活性。
除了基本数据值之外,我们还添加了一些值来帮助创建 reST 输出。tag_text
属性是标记值元组的扁平文本版本。underline
属性生成一个长度与标题字符串匹配的下划线字符串;这有助于其他格式很好地工作。
我们还将创建一个博客,作为帖子的集合。通过为帖子集合添加标题的附加属性,我们将使这个集合不仅仅是一个简单的列表。对于集合设计,我们有三种选择:包装、扩展或发明一个新类。我们将通过提供以下警告来避免一些混乱:如果你想让list
持久化,就不要扩展它。
Extending an iterable object can be confusing When we extend a sequence class, we might get confused with some serialization algorithms. This may wind up bypassing the extended features we put in a subclass of a sequence. Wrapping a sequence is usually a better idea than extending it.
这鼓励我们关注包装或发明。由于博客帖子形成了一个简单的序列,因此很少有地方会产生混淆,我们可以扩展一个列表。下面是一组微博帖子。我们已经建立了一个列表来创建Blog
类:
from collections import defaultdict
class Blog_x( list ):
def __init__ ( self , title: str , posts: Optional[List[Post]]= None ) -> None :
self .title = title
super (). __init__ (posts if posts is not None else [])
def by_tag( self ) -> DefaultDict[ str , List[Dict[ str , Any]]]:
tag_index: DefaultDict[ str , List[Dict[ str , Any]]] = defaultdict( list )
for post in self :
for tag in post.tags:
tag_index[tag].append(post.as_dict())
return tag_index
def as_dict( self ) -> Dict[ str , Any]:
return dict (
title = self .title,
entries =[p.as_dict() for p in self ]
)
除了扩展list
类之外,我们还包括了一个属性,即微博的标题。初始值设定项使用一种通用技术来避免将可变对象作为默认值提供。我们已经提供了None
作为posts
的默认值。如果posts
是None
,我们将使用新创建的空列表[]
。否则,我们将使用给定的值作为 POST。
此外,我们还定义了一个方法,该方法通过帖子的标记对帖子进行索引。在生成的defaultdict
中,每个键都是标签的文本。每个值都是共享给定标记的帖子列表。
为了简化string.Template
的使用,我们添加了另一个as_dict()
方法,将整个博客归结为一个简单的字符串和字典字典字典。这里的想法是只生成具有简单字符串表示的内置类型。在本例中,Dict[str, Any]
类型提示反映了返回值的一般方法。实际上,根据Post
条目的定义,瓷砖为str
,条目为List[Dict[str, Any]]
。额外的细节似乎没有完全的帮助,所以我们将提示保留为Dict[str, Any]
。
在下一节中,我们将看到如何呈现博客和帖子。
接下来我们将向您展示模板渲染过程。为了了解渲染的工作原理,以下是一些示例数据:
travel_x = Blog_x( "Travel" )
travel_x.append(
Post(
date =datetime.datetime( 2013 , 11 , 14 , 17 , 25 ),
title = "Hard Aground" ,
rst_text = """Some embarrassing revelation. Including ☹ and ⚓""" ,
tags =[ "#RedRanger" , "#Whitby42" , "#ICW" ],
)
)
travel_x.append(
Post(
date =datetime.datetime( 2013 , 11 , 18 , 15 , 30 ),
title = "Anchor Follies" ,
rst_text = """Some witty epigram. Including < & > characters.""" ,
tags =[ "#RedRanger" , "#Whitby42" , "#Mistakes" ],
)
)
我们已经以 Python 代码的形式序列化了Blog
和Post
对象。这可能是一个很好的方式来代表博客。在一些用例中,Python 代码是对象的完美表示。在第 14 章、配置文件和持久化中,我们将更详细地介绍如何使用 Python 对数据进行编码。
这里有一种方法可以让博客进入休息状态;类似的方法可用于创建降价(MD)。从这个输出文件中,docutilsrst2html.py
工具可以将 reST 输出转换为最终的 HTML 文件。这使我们不必偏离 HTML 和 CSS。另外,我们将使用 reST 编写第 20 章、质量和文档中的文档。有关文档的更多信息,请参见第 1 章、序言、工具和技术。
我们可以使用string.Template
类来完成此操作。然而,它又笨重又复杂。有许多附加模板工具可以执行更复杂的替换,包括模板本身中的循环和条件处理。您可以在找到备选方案列表 https://wiki.python.org/moin/Templating 。我们将向您展示一个使用 Jinja2 模板工具(的示例 https://pypi.python.org/pypi/Jinja2 )。以下是使用模板在 reST 中呈现此数据的脚本:
from jinja2 import Template
blog_template= Template( """
{{title}}
{{underline}}
{% for e in entries %}
{{e.title}}
{{e.underline}}
{{e.rst_text}}
:date: {{e.date}}
:tags: {{e.tag_text}}
{% endfor %}
Tag Index
=========
{% for t in tags %}
* {{t}}
{% for post in tags[t] %}
- `{{post.title}}`_
{% endfor %}
{% endfor %}
""")
print(blog_template.render(tags=travel.by_tag(), **travel.as_dict()))
{{title}}
和{{underline}}
元素(以及所有类似元素)向我们展示了如何将值替换到模板的文本中。使用**travel.as_dict()
调用render()
方法,以确保title
和underline
等属性将成为关键字参数。
{%for%}
和{%endfor%}
结构向我们展示了 Jinja 如何迭代Blog
中的Post
条目序列。在此循环体中,e
变量将是从每个Post
创建的字典。我们从字典中为每一篇文章挑选了特定的关键词,比如{{e.title}}
和{{e.rst_text}}
。
我们还迭代了Blog
的tags
集合。这是一个字典,包含每个标记的键和标记的帖子。循环将访问分配给t
的每个键。循环体将遍历字典值tags[t]
中的帖子。
``{{post.title}}_
构造使用 reST 标记生成指向文档中具有该标题的部分的链接。这种非常简单的标记是 reST 的优势之一。它允许我们将博客标题用作索引中的部分和链接。这意味着标题必须是唯一的,否则我们将得到 reST 渲染错误。
由于该模板迭代给定的博客,它将以一个平滑的运动呈现所有帖子。为 Python 内置的string.Template
无法迭代。这使得渲染Blog
的所有Posts
有点复杂。
让我们看看如何使用 JSON 转储和加载。
什么是 JSON?中的一节 https://www.json.org/ 网页说明如下:
"JSON (JavaScript Object Notation) is a lightweight data-interchange format. It is easy for humans to read and write. It is easy for machines to parse and generate. It is based on a subset of the JavaScript Programming Language, Standard ECMA-262 3rd Edition - December 1999. JSON is a text format that is completely language independent but uses conventions that are familiar to programmers of the C-family of languages, including C, C++, C#, Java, JavaScript, Perl, Python, and many others. These properties make JSON an ideal data-interchange language."
这种格式被广泛的语言和框架使用。CouchDB 等数据库将其数据表示为 JSON 对象,简化了应用程序之间的数据传输。JSON 文档的优点是看起来像 Pythonlist
和dict
文本值。它们易于阅读和手动编辑。
json
模块与内置 Python 类型一起工作。它不适用于我们定义的类,除非我们采取一些额外的步骤。接下来我们将研究这些扩展技术。对于以下 Python 类型,有一个到 JSON 使用的 JavaScript 类型的映射:
Python 类型 | JSON |
---|---|
dict |
object |
list 、tuple |
array |
str |
string |
int 、float |
number |
True |
true |
False |
false |
None |
null |
不支持其他类型;这意味着另一种类型的值必须强制为这些类型之一。这通常是通过我们可以插入到dump()
和load()
函数中的扩展函数来完成的。我们可以通过将微博对象转换为更简单的 Pythonlists
和dicts
来探索这些内置类型。当我们查看Post
和Blog
类定义时,我们已经定义了as_dict()
方法,将自定义类对象简化为内置 Python 对象。
以下是生成我们博客数据的 JSON 版本所需的代码:
import json
print(json.dumps(travel.as_dict(), indent=4))
以下是输出:
{
"entries": [
{
"title": "Hard Aground",
"underline": "------------",
"tag_text": "#RedRanger #Whitby42 #ICW",
"rst_text": "Some embarrassing revelation. Including \u2639 and \u2693",
"date": "2013-11-14 17:25:00"
},
{
"title": "Anchor Follies",
"underline": "--------------",
"tag_text": "#RedRanger #Whitby42 #Mistakes",
"rst_text": "Some witty epigram. Including < & > characters.",
"date": "2013-11-18 15:30:00"
}
],
"title": "Travel"
}
前面的输出向我们展示了如何将各种对象从 Python 转换为 JSON 符号。这方面的优点在于,我们的 Python 对象已经被写入了一个标准化的符号。我们可以与其他应用程序共享它们。我们可以将它们写入磁盘文件并保存它们。JSON 表示有几个令人不快的特性:
- 我们必须将 Python 对象重写为字典。更简单地转换 Python 对象,而不显式地创建额外的字典,会更好。
- 加载此 JSON 表示时,我们无法轻松重建原始的
Blog
和Post
对象。当我们使用json.load()
时,我们不会得到Blog
或Post
对象;我们将获取dict
并列出对象。我们需要提供一些额外的提示来重建Blog
和Post
对象。 - 在对象的
__dict__
中有一些我们不希望保留的值,例如Post
中带下划线的文本。
我们需要比内置 JSON 编码更复杂的东西。
让我们来看看下一节中的 JSON 类型提示。
前面显示的Blog
类和Post
类中的as_dict()
方法都使用了一个简单的Dict[str, Any]
类型提示来表示与 JSON 序列化兼容的数据结构。虽然类型提示对于这些示例很有意义,但这并不是 JSON 序列化所使用类型的理想一般描述。
可以轻松序列化的实际类型可以定义如下:
from typing import Union, Dict, List, Type
JSON = Union[Dict[str, 'JSON'], List['JSON'], int, str, float, bool, Type[None]]
目前,mypy不能很好地处理递归类型。因此,我们不得不使用以下方法:
JSON = Union[Dict[ str , Any], List[Any], int , str , float , bool , Type[ None ]]
本章中定义的类不使用这个更一般的JSON
类型提示。在本章中,我们只对 JSON 兼容 Python 对象的Dict[str, Any]
子集感兴趣。在某些上下文中,包含更复杂的提示以确保所涉及的类型是正确的可能会有所帮助。
让我们看看如何在类中支持 JSON。
为了正确支持以 JSON 表示法创建字符串,我们需要为可以自动转换的类型之外的类提供编码器和解码器。为了将我们独特的对象编码为 JSON,我们需要提供一个函数,将我们的对象简化为 Python 基本类型。json
模块调用此默认函数;它为未知类的对象提供默认编码。
要解码 JSON 表示法中的字符串并创建应用程序类(JSON 支持的基线类型之外的类)的 Python 对象,我们需要提供一个额外的函数。这个额外的函数将 Python 原语值的字典转换为我们的一个应用程序类的实例。这称为对象挂钩函数;用于将dict
转换为自定义类的对象。
json
模块文档表明,我们可能希望使用类暗示。Python 文档包括对 JSON-RPC 版本 1 规范的引用(参见http://json-rpc.org/wiki/specification 。他们建议将自定义类的实例编码为字典,如下所示:
{"__jsonclass__": ["ClassName", [param1,...]]}
与"__jsonclass__"
键关联的建议值是一个包含两项的列表:类名和创建该类实例所需的参数列表。该规范允许更多的特性,但它们与 Python 无关。
要从 JSON 字典中解码对象,对象钩子函数可以查找"__jsonclass__"
键,作为需要构建某个类的提示,而不是内置 Python 对象。类名可以映射到类对象,参数序列可以用于构建实例。
当我们查看其他复杂的 JSON 编码器(例如 Django Web 框架附带的编码器)时,我们可以看到它们提供了更复杂的自定义类编码。它们包括类、数据库主键和属性值。我们将研究如何实现定制的编码和解码。这些规则表示为插入 JSON 编码和解码函数的简单函数。
让我们看看如何定制 JSON 编码。
对于课堂暗示,我们将提供三条信息。我们将包含一个命名目标类的__class__
键。__args__
键将提供一系列位置参数值。__kw__
键将提供关键字参数值的字典。(我们不会使用__jsonclass__
键;它太长,看起来不太像蟒蛇。)这将涵盖__init__()
的所有选项。
这是一个遵循这种设计的编码器:
def blog_encode(object: Any) -> Dict[ str , Any]:
if isinstance (object, datetime.datetime):
return dict (
__class__ = "datetime.datetime" ,
__args__ =[],
__kw__ = dict (
year =object.year,
month =object.month,
day =object.day,
hour =object.hour,
minute =object.minute,
second =object.second,
),
)
elif isinstance (object, Post):
return dict (
__class__ = "Post" ,
__args__ =[],
__kw__ = dict (
date =object.date,
title =object.title,
rst_text =object.rst_text,
tags =object.tags,
),
)
elif isinstance (object, Blog):
return dict (
__class__ = "Blog" , __args__ =[object.title, object.entries], __kw__ ={}
)
else :
return object
此函数为我们展示了三个类的两种不同风格的对象编码:
- 我们使用关键字参数将
datetime.datetime
对象编码为单个字段的字典。 - 我们将
Post
实例编码为单个字段的字典,还使用关键字参数。 - 我们使用一系列位置参数将
Blog
实例编码为一系列标题和帖子条目。
else:
子句用于所有其他类:这将调用现有编码器的默认编码。这将处理内置类。我们可以使用此函数进行如下编码:
text = json.dumps(travel, indent=4, default=blog_encode)
我们提供了我们的函数blog_encode()
,作为json.dumps()
函数的default=
关键字参数。JSON 编码器使用此函数确定对象的编码。此编码器将生成如下所示的 JSON 对象:
{
"__args__": [
"Travel",
[
{
"__args__": [],
"__kw__": {
"tags": [
"#RedRanger",
"#Whitby42",
"#ICW"
],
"rst_text": "Some embarrassing revelation. Including \u2639 and \u2693\ufe0e",
"date": {
"__args__": [],
"__kw__": {
"minute": 25,
"hour": 17,
"day": 14,
"month": 11,
"year": 2013,
"second": 0
},
"__class__": "datetime.datetime"
},
"title": "Hard Aground"
},
"__class__": "Post"
},
.
.
.
"__kw__": {},
"__class__": "Blog"
}
我们删除了第二个博客条目,因为输出相当长。一个Blog
对象现在被一个dict
包装,该dict
提供类和两个位置参数值。类似地,Post
和datetime
对象用类名和关键字参数值包装。
让我们看看如何定制 JSON 解码。
为了用 JSON 符号从字符串中解码对象,我们需要在 JSON 解析的结构中工作。我们自定义类定义的对象编码为简单的dicts
。这意味着由 JSON 解码器解码的每个dict
都可以是我们定制的类之一。或者,序列化为dict
的内容应保留为dict
。
JSON 解码器对象挂钩是为每个dict
调用的函数,以查看它是否表示自定义对象。如果hook
函数无法识别dict
,则该字典为普通字典,应在不修改的情况下返回。下面是我们的对象挂钩函数:
def blog_decode(some_dict: Dict[str, Any]) -> Dict[str, Any]:
if set (some_dict.keys()) == { "__class__" , "__args__" , "__kw__"} :
class_ = eval (some_dict[ "__class__" ])
return class_(*some_dict[ "__args__" ], **some_dict[ "__kw__" ])
else :
return some_dict
每次调用此函数时,它都会检查定义对象编码的键。如果有三个键,则使用参数和关键字调用给定函数。我们可以使用此对象挂钩解析 JSON 对象,如下所示:
blog_data = json.loads(text, object_hook=blog_decode)
这将解码一段用 JSON 符号编码的文本,使用我们的blog_decode()
函数将dict
转换为适当的Blog
和Post
对象。
在下一节中,我们将了解安全性和eval()
问题。
一些程序员会反对在前面的blog_decode()
函数中使用eval()
函数,声称这是一个普遍存在的安全问题。愚蠢的是声称eval()
是普遍存在的问题。如果某个恶意参与者将恶意代码写入到对象的 JSON 表示中,则这是一个潜在的安全问题。本地恶意参与者可以访问 Python 源。为什么要把时间浪费在巧妙地调整 JSON 文件上?为什么不编辑 Python 源代码呢?
作为一个实际问题,我们必须考虑通过互联网传输 JSON 文档;这是一个实际的安全问题。然而,即使是这个问题也不能说明eval()
一般情况。
对于中间人(MITM攻击篡改了不可信文档的情况,可以制定一些规定。假设一个 JSON 文档在通过一个 web 界面时被篡改,该界面包含一个作为代理的不可信服务器。(SSL 是防止此问题的首选方法,因此我们必须假设部分连接也是不安全的。)
如有必要,为了应对可能的 MITM 攻击,我们可以用一个从名称映射到类的字典来替换eval()
。我们可以将class_ =eval(some_dict['__class__'])
表达式更改为:
class_ = {
"Post": Post,
"Blog": Blog,
"datetime.datetime": datetime.datetime
}[some_dict['__class__']]
这将防止 JSON 文档通过非 SSL 编码的连接传递时出现问题。每次应用程序设计更改以引入新类时,都需要调整此映射,这也会导致维护需求。
让我们在下一节中了解如何重构encode
函数。
编码函数不应公开有关转换为 JSON 的类的信息。为了使每个类都能正确封装,最好将序列化表示的创建重构到每个应用程序类中。我们不希望将所有编码规则堆积到类定义之外的函数中。
要对库类(如datetime
)执行此操作,我们需要为应用程序扩展datetime.datetime
。这导致我们的应用程序使用扩展的datetime
而不是datetime
库。为了避免使用内置的datetime
类,这可能会让人有点头疼。因此,我们可以选择在定制类和库类之间取得平衡。下面是两个类扩展,它们将创建 JSON 可编码类定义。我们可以在Blog
中添加一个属性:
@property
def _json( self ) -> Dict[str, Any]:
return dict(
__class__=self.__class__.__name__,
__kw__={},
__args__=[self.title, self.entries]
)
此属性将提供可由解码函数使用的初始化参数。我们可以将此属性添加到Post
:
@property
def _json(self) -> Dict[str, Any]:
return dict(
__class__=self.__class__.__name__,
__kw__=dict(
date= self.date,
title= self.title,
rst_text= self.rst_text,
tags= self.tags,
),
__args__=[]
)
与Blog
一样,此属性将提供可由解码函数使用的初始化参数。类型提示强调了 Python 对象的中间、JSON 友好表示,如Dict[str, Any]
这两个属性使我们可以修改编码器,使其更加简单。以下是为编码提供的默认函数的修订版本:
def blog_encode_2(object: Union[Blog, Post, Any) -> Dict[str, Any]:
if isinstance(object, datetime.datetime):
return dict(
__class__="datetime.datetime",
__args__=[],
__kw__=dict(
year= object.year,
month= object.month,
day= object.day,
hour= object.hour,
minute= object.minute,
second= object.second,
)
)
else:
try:
encoding = object._json
except AttributeError:
encoding = json.JSONEncoder().default(o)
return encoding
这里有两种情况。对于datetime.datetime
库类,此函数包括序列化,公开实现的细节。对于我们的Blog
和Post
应用程序类,我们可以依赖这些类具有一致的_json()
方法,该方法发出适合编码的表示。
让我们在下一节中了解如何标准化date
字符串。
我们的日期格式没有使用广泛使用的 ISO 标准文本格式。为了与其他语言更兼容,我们应该正确地将datetime
对象编码为标准字符串并解析标准字符串。
由于我们已经将日期视为一个特例,因此这似乎是一个合理的实现。它可以在编码和解码没有太多变化的情况下完成。考虑到编码的这个小变化:
if isinstance(object, datetime.datetime):
fmt= "%Y-%m-%dT%H:%M:%S"
return dict(
__class__="datetime.datetime.strptime",
__args__=[object.strftime(fmt), fmt],
__kw__={}
)
编码输出将静态方法命名为datetime.datetime.strptime()
,并提供编码的参数datetime
以及用于解码的格式。post 的输出现在类似于以下代码段:
{
"__args__": [],
"__class__": "Post_J",
"__kw__": {
"title": "Anchor Follies",
"tags": [
"#RedRanger",
"#Whitby42",
"#Mistakes"
],
"rst_text": "Some witty epigram.",
"date": {
"__args__": [
"2013-11-18T15:30:00",
"%Y-%m-%dT%H:%M:%S"
],
"__class__": "datetime.datetime.strptime",
"__kw__": {}
}
}
}
这表明我们现在有一个 ISO 格式的日期,而不是单个字段。我们也不再使用类名创建对象。__class__
值被扩展为类名或静态方法名。
在编写 JSON 文件时,我们通常会执行以下操作:
from pathlib import Path
with Path("temp.json").open("w", encoding="UTF-8") as target:
json.dump(travel3, target, default=blog_j2_encode)
我们用所需的编码打开文件。我们将 file 对象提供给json.dump()
方法。当我们读取 JSON 文件时,我们将使用类似的技术:
from pathlib import Path
with Path("some_source.json").open(encoding="UTF-8") as source:
objects = json.load(source, object_hook=blog_decode)
其思想是将 JSON 表示作为文本与结果文件上的任何字节转换分离开来。JSON 中有几个可用的格式选项。我们已经向您展示了四个空格的缩进,因为这似乎生成了好看的 JSON。作为替代方案,我们可以通过保留 indent 选项使输出更加紧凑。我们可以通过使分离器更简洁来进一步压缩它。
以下是在temp.json
中创建的输出:
{"__class__":"Blog_J","__args__":["Travel",[{"__class__":"Post_J","__args__":[],"__kw__":{"rst_text":"Some embarrassing revelation.","tags":["#RedRanger","#Whitby42","#ICW"],"title":"Hard Aground","date":{"__class__":"datetime.datetime.strptime","__args__":["2013-11-14T17:25:00","%Y-%m-%dT%H:%M:%S"],"__kw__":{}}}},{"__class__":"Post_J","__args__":[],"__kw__":{"rst_text":"Some witty epigram.","tags":["#RedRanger","#Whitby42","#Mistakes"],"title":"Anchor Follies","date":{"__class__":"datetime.datetime.strptime","__args__":["2013-11-18T15:30:00","%Y-%m-%dT%H:%M:%S"],"__kw__":{}}}}]],"__kw__":{}}
让我们看看如何使用 YAML 转储和加载。
https://yaml.org/ 关于 YAML 的网页说明如下:
YAML™ (rhymes with "camel") is a human-friendly, cross-language, Unicode-based data serialization language designed around the common native data types of agile programming languages.
json
模块的 Python 标准库文档解释了以下关于 JSON 和 YAML 的内容:
JSON is a subset of YAML 1.2. The JSON produced by this module's default settings (in particular, the default separators value) is also a subset of YAML 1.0 and 1.1. This module can thus also be used as a YAML serializer.
技术上,我们可以使用json
模块准备 YAML 数据。但是,json
模块不能用于反序列化更复杂的 YAML 数据。使用 YAML 有两个好处。首先,它是一种更复杂的符号,允许我们对对象的其他细节进行编码。其次,PyYAML 实现与 Python 有着深层次的集成,允许我们非常简单地创建 Python 对象的 YAML 编码。YAML 的缺点是它不像 JSON 那样广泛使用。我们需要下载并安装 YAML 模块。好的可以在找到 http://pyyaml.org/wiki/PyYAML 。
安装包后,我们可以将对象转储到 YAML 符号中:
import yaml
text = yaml.dump(travel2)
print(text)
以下是我们微博的 YAML 编码:
!!python/object:__main__.Blog
entries:
- !!python/object:__main__.Post
date: 2013-11-14 17:25:00
rst_text: Some embarrassing revelation. Including ☹ and ⚓
tags: !!python/tuple ['#RedRanger', '#Whitby42', '#ICW']
title: Hard Aground
- !!python/object:__main__.Post
date: 2013-11-18 15:30:00
rst_text: Some witty epigram. Including < & > characters.
tags: !!python/tuple ['#RedRanger', '#Whitby42', '#Mistakes']
title: Anchor Follies
输出相对简洁,但也令人愉快地完成。此外,我们可以轻松地编辑 YAML 文件以进行更新。类名用 YAML!!
标记编码。YAML 包含 11 个标准标签。yaml
模块包括十几个特定于 Python 的标记,外加五个复杂的Python 标记。
Python 类名由定义模块限定。在我们的例子中,模块恰好是一个简单的脚本,因此类名是__main__.Blog
和__main__.Post
。如果我们从另一个模块导入了这些类,那么类名将反映定义这些类的模块。
列表中的项目以块序列形式显示。每个项目以-
顺序开始;其余项目用两个空格缩进。当list
或tuple
足够小时,它可以流到一条线上。如果它变长,它将缠绕到多行上。要从 YAML 文档加载 Python 对象,我们可以使用以下代码:
copy = yaml.load(text)
这将使用标记信息定位类定义,并将 YAML 文档中找到的值提供给constructor
类。我们的微博对象将被完全重构。
在下一章中,我们将格式化文件中的 YAML 数据。
当我们编写 YAML 文件时,通常会执行以下操作:
from pathlib import Path
import yaml
with Path("some_destination.yaml").open("w", encoding="UTF-8") as target:
yaml.dump(some_collection, target)
我们用所需的编码打开文件。我们将 file 对象提供给yaml.dump()
方法;输出写在那里。当我们读取 YAML 文件时,我们将使用类似的技术:
from pathlib import Path
import yaml
with Path("some_source.yaml").open(encoding="UTF-8") as source:
objects= yaml.load(source)
其思想是将 YAML 表示形式作为文本与结果文件上的任何字节转换分离开来。我们有几个格式化选项来创建数据的更漂亮的 YAML 表示。下表显示了一些选项:
| explicit_start
| 如果为true
,则在每个对象前写入---
标记。 |
| explicit_end
| 如果为true
,则在每个对象后写入...
标记。如果我们要将一系列 YAML 文档转储到一个文件中,并且需要知道一个文件何时结束,下一个文件何时开始,那么我们可以使用这个或explicit_start
。 |
| version
| 给定一对整数(x、y),在开始处写入%YAML x.y
指令。这应该是version=(1,2)
。 |
| tags
| 给定一个映射,它会发出一个带有不同标记缩写的 YAML%TAG
指令。 |
| canonical
| 如果true
,则在每条数据上都包含一个标签。如果为false
,则假定为若干tags
。 |
| indent
| 如果设置为数字,则更改块使用的缩进。 |
| width
| 如果设置为数字,则将长项目包装的width
更改为多个缩进行。 |
| allow_unicode
| 如果设置为true
,则允许完全 Unicode 而不进行转义。否则,ASCII 子集之外的字符将应用转义。 |
| line_break
| 使用不同的行尾字符;默认为换行符。 |
在这些选项中,explicit_end
和allow_unicode
可能是最有用的。
有时,我们的一个类具有比属性值的默认 YAML 转储更好的整洁表示。例如,BlackjackCard
类定义的默认 YAML 将包括几个我们实际上不需要保留的派生值。
yaml
模块包括向类定义添加representer
和constructor
的规定。representer
用于创建 YAML 表示,包括标记和值。constructor
用于根据给定值构建 Python 对象。下面是另一个Card
类层次结构:
from enum import Enum
class Suit( str , Enum):
Clubs = "♣"
Diamonds = "♦"
Hearts = "♥"
Spades = "♠"
class Card:
def __init__ ( self , rank: str , suit: Suit,
hard: Optional[ int ]= None ,
soft: Optional[ int ]= None
) -> None :
self .rank = rank
self .suit = suit
self .hard = hard or int (rank)
self .soft = soft or int (rank)
def __str__ ( self ) -> str :
return f" { self .rank !s}{ self .suit.value !s} "
class AceCard(Card):
def __init__ ( self , rank: str , suit: Suit) -> None :
super (). __init__ (rank, suit, 1 , 11 )
class FaceCard(Card):
def __init__ ( self , rank: str , suit: Suit) -> None :
super (). __init__ (rank, suit, 10 , 10 )
我们使用了超类Card
来表示数字卡,并定义了两个子类AceCard
和FaceCard
来表示 aces 和 face 卡。在前面的示例中,我们广泛使用工厂函数来简化构造。工厂处理了从1
等级到AceCard
等级的映射,以及从11
、12
、13
等级到FaceCard
等级的映射。这是非常重要的,这样我们就可以使用简单的range(1,14)
为秩值构建一个甲板。
当从 YAML 加载时,该类将通过 YAML!!
标记完全拼写出来。唯一缺少的信息是与卡的每个子类关联的硬值和软值。硬点和软点有三种相对简单的情况,可以通过可选的初始化参数来处理。下面是使用默认序列化将这些对象转储为 YAML 格式时的情况:
- !!python/object:Chapter_10.ch10_ex2.AceCard
hard: 1
rank: A
soft: 11
suit: !!python/object/apply:Chapter_10.ch10_ex2.Suit
- ♣
- !!python/object:Chapter_10.ch10_ex2.Card
hard: 2
rank: '2'
soft: 2
suit: !!python/object/apply:Chapter_10.ch10_ex2.Suit
- ♥
- !!python/object:Chapter_10.ch10_ex2.FaceCard
hard: 10
rank: K
soft: 10
suit: !!python/object/apply:Chapter_10.ch10_ex2.Suit
- ♦
这些都是正确的,但对于像扑克牌这样简单的东西来说可能有点罗嗦。我们可以扩展yaml
模块,为这些简单对象生成更小、更聚焦的输出。让我们为我们的Card
子类定义representer
和constructor
。以下是三个功能和注册:
def card_representer(dumper: Any, card: Card) -> str :
return dumper.represent_scalar(
"!Card" , f" { card.rank !s}{ card.suit.value !s} " )
def acecard_representer(dumper: Any, card: Card) -> str :
return dumper.represent_scalar(
"!AceCard" , f" { card.rank !s}{ card.suit.value !s} " )
def facecard_representer(dumper: Any, card: Card) -> str :
return dumper.represent_scalar(
"!FaceCard" , f" { card.rank !s}{ card.suit.value !s} " )
yaml.add_representer(Card, card_representer)
yaml.add_representer(AceCard, acecard_representer)
yaml.add_representer(FaceCard, facecard_representer)
我们将每个Card
实例表示为一个短字符串。YAML 包含一个标记,用于显示应该从字符串构建哪个类。所有三个类都使用相同的格式字符串。这恰好与__str__()
方法相匹配,导致潜在的优化。
我们需要解决的另一个问题是从解析的 YAML 文档构造Card
实例。为此,我们需要构造函数。以下是三名施工人员和注册:
def card_constructor(loader: Any, node: Any) -> Card:
value = loader.construct_scalar(node)
rank, suit = value[:- 1 ], value[- 1 ]
return Card(rank, suit)
def acecard_constructor(loader: Any, node: Any) -> Card:
value = loader.construct_scalar(node)
rank, suit = value[:- 1 ], value[- 1 ]
return AceCard(rank, suit)
def facecard_constructor(loader: Any, node: Any) -> Card:
value = loader.construct_scalar(node)
rank, suit = value[:- 1 ], value[- 1 ]
return FaceCard(rank, suit)
yaml.add_constructor( "!Card" , card_constructor)
yaml.add_constructor( "!AceCard" , acecard_constructor)
yaml.add_constructor( "!FaceCard" , facecard_constructor)
解析标量值时,标签将用于定位特定的constructor
。然后,constructor
可以分解字符串并构建Card
实例的适当子类。下面是一个快速演示,每个类转储一张卡:
deck = [AceCard( "A" , Suit.Clubs), Card( "2" , Suit.Hearts), FaceCard( "K" , Suit.Diamonds)]
text = yaml.dump(deck, allow_unicode=True)
以下是输出:
- !AceCard 'A♣'
- !Card '2♥'
- !FaceCard 'K♦'
这为我们提供了可用于重建 Python 对象的卡片的简短、优雅的 YAML 表示。
我们可以使用以下语句重建我们的三卡组:
yaml.load(text, Loader=yaml.Loader)
这将解析表示,使用constructor
函数,并构建预期的对象。因为constructor
函数确保正确初始化,所以硬值和软值的内部属性被正确重建。
在向yaml
模块添加新构造函数时,必须使用特定的Loader
。默认行为是忽略这些额外的constructor
标记。当我们想要使用它们时,我们需要提供一个Loader
来处理扩展标签。
让我们看看下一节中的安全性和安全性加载。
原则上,YAML 可以构建任何类型的对象。这允许在没有适当 SSL 控制的情况下,对通过 internet 传输 YAML 文件的应用程序进行攻击。
YAML 模块提供了一个safe_load()
方法,该方法拒绝在构建对象时执行任意 Python 代码。这严重限制了可以加载的内容。对于不安全的数据交换,我们可以使用yaml.safe_load()
创建只包含内置类型的 Pythondict
和list
对象。然后我们可以从dict
和list
实例构建我们的应用程序类。这与我们使用 JSON 或 CSV 交换dict
的方式大致相似,必须使用它来创建适当的对象。
更好的方法是为我们自己的对象使用yaml.YAMLObject
mixin 类。我们使用它来设置一些类级属性,这些属性为yaml
提供提示,并确保对象的安全构造。
下面是我们如何定义安全传输的超类:
class Card2(yaml.YAMLObject):
yaml_tag = '!Card2'
yaml_loader = yaml.SafeLoader
这两个属性将提醒yaml
可以安全加载这些对象,而无需执行任意和意外的 Python 代码。Card2
的每个子类只需设置将使用的唯一 YAML 标记:
class AceCard2(Card2):
yaml_tag = '!AceCard2'
我们添加了一个属性,提醒yaml
这些对象只使用这个类定义。可安全装载物品;它们不会执行任意的不可信代码。
通过对类定义的这些修改,我们现在可以在 YAML 流上使用yaml.safe_load()
,而不用担心文档在不安全的 internet 连接上插入了恶意代码。为我们自己的对象显式使用yaml.YAMLObject
mixin 类,并设置yaml_tag
属性,有几个优点。它会产生稍微更紧凑的文件。这也导致了一个更好看的 YAML 文件——长的、通用的!!python/object:Chapter_10.ch10_ex2.AceCard
标记被较短的!AceCard2
标记所取代。
让我们看看如何使用 pickle 转储和加载。
pickle
模块是 Python 使对象持久化的本机格式。Python 标准库(https://docs.python.org/3/library/pickle.html 是关于pickle
的:
The pickle module can transform a complex object into a byte stream and it can transform the byte stream into an object with the same internal structure. Perhaps the most obvious thing to do with these byte streams is to write them onto a file, but it is also conceivable to send them across a network or store them in a database.
pickle
的焦点是 Python,而且只有 Python。这不是一种数据交换格式,如 JSON、YAML、CSV 或 XML,不能与用其他语言编写的应用程序一起使用。
pickle
模块以多种方式与 Python 紧密集成。例如,类的__reduce__()
和__reduce_ex__()
方法支持pickle
处理。
我们可以通过以下方式轻松整理我们的微博:
import pickle
from pathlib import Path
with Path("travel_blog.p").open("wb") as target:
pickle.dump(travel, target)
这会将整个travel
对象导出到给定文件。文件是以原始字节写入的,open()
函数使用"wb"
模式。
我们可以通过以下方式轻松恢复拾取的对象:
import pickle
from pathlib import Path
with Path("travel_blog.p").open("rb") as source:
copy = pickle.load(source)
由于 pickle 数据是以字节形式写入的,因此必须以"rb"
模式打开文件。pickle 对象将正确绑定到适当的类定义。底层字节流不是供人使用的。它在某种程度上是可读的,但它并不像 YAML 那样为可读性而设计。
在下一节中,我们将为可靠的腌菜加工设计一个类。
类的__init__()
方法实际上并不是用来解除对象锁定的。通过使用__new__()
绕过__init__()
方法,并将酸洗值直接设置到对象的__dict__
中。当我们的类定义包含__init__()
中的某些处理时,这种区别很重要。例如,如果__init__()
打开外部文件、创建 GUI 的某些部分或对数据库执行某些外部更新,则在取消勾选期间不会执行此操作。
如果我们在__init__()
处理过程中计算一个新的实例变量,就不会有实际问题。例如,考虑一个 B2JORY TY1TY 对象,该对象计算当创建 Type T3^时的 Type T2^实例的总数。普通的pickle
处理将保留此计算实例变量。当对象取消勾选时,将不会重新计算它。先前计算的值将简单地取消勾选。
依赖于__init__()
期间处理的类必须做出特殊安排,以确保此初始处理将正确进行。我们可以做两件事:
- 避免在
__init__()
中急于启动处理。相反,只执行最小的初始化处理。例如,如果存在外部文件操作,则应将这些操作推迟到需要时进行。如果有任何急切的总结计算,它们必须重新设计,以便懒散地完成。同样,任何初始化日志记录都不会正确执行。 - 定义
pickle
可以用来保存状态和恢复状态的__getstate__()
和__setstate__()
方法。然后,__setstate__()
方法可以调用__init__()
调用的相同方法,在普通 Python 代码中执行一次性初始化处理。
我们将看一个例子,其中加载到Hand
中的初始Card
实例是通过__init__()
方法记录的,用于审计目的。这里有一个版本的Hand
在取消勾选时无法正常工作:
audit_log = logging.getLogger("audit")
class Hand_bad:
def __init__ ( self , dealer_card: Card, *cards: Card) -> None :
self .dealer_card = dealer_card
self .cards = list (cards)
for c in self .cards:
audit_log.info( "Initial %s" , c)
def append( self , card: Card) -> None:
self .cards.append(card)
audit_log.info( "Hit %s" , card)
def __str__ ( self ) -> str :
cards = ", " .join( map ( str , self .cards))
return f" { self .dealer_card } | { cards } "
这有两个记录位置:在__init__()
和append()
期间。对于大多数创建Hand_bad
对象的情况,__init__()
处理都能很好地工作。当取消勾选以重新创建Hand_bad
对象时,它不起作用。以下是日志记录设置以查看此问题:
import logging,sys
audit_log = logging.getLogger("audit")
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
此设置将创建日志,并确保日志记录级别适合查看审核信息。下面是一个快速脚本,用于构建、pickle 和 unpickleHand
:
>>> h = Hand_bad(FaceCard("K", "♦"), AceCard("A", "♣"), Card("9", "♥"))
INFO:audit:Initial A♣
INFO:audit:Initial 9♥
>>> data = pickle.dumps(h)
>>> h2 = pickle.loads(data)
当我们执行此操作时,我们看到在__init__()
处理期间写入的日志条目。取消勾选Hand
时不会写入这些条目。任何其他__init__()
处理也将被跳过。
为了正确地编写用于取消勾选的审核日志,我们可以在整个类中设置延迟日志测试。例如,我们可以扩展__getattribute__()
来编写初始日志条目,无论何时从该类请求任何属性。这将导致有状态日志记录和一条if
语句,每当一个 hand 对象执行某项操作时都会执行该语句。更好的解决方案是利用pickle
保存和恢复状态的方式。
class Hand2:
def __init__ ( self , dealer_card: Card, *cards: Card) -> None :
self .dealer_card = dealer_card
self .cards = list (cards)
for c in self .cards:
audit_log.info( "Initial %s" , c)
def append( self , card: Card) -> None :
self .cards.append(card)
audit_log.info( "Hit %s" , card)
def __str__ ( self ) -> str :
cards = ", " .join( map ( str , self .cards))
return f" { self .dealer_card } | { cards } "
def __getstate__ ( self ) -> Dict[ str , Any]:
return vars ( self )
def __setstate__ ( self , state: Dict[ str , Any]) -> None :
# Not very secure -- hard for mypy to detect what's going on.
self . __dict__ .update(state)
for c in self .cards:
audit_log.info( "Initial (unpickle) %s" , c)
拾取时使用__getstate__()
方法收集对象的当前状态。此方法可以返回任何内容。例如,对于具有内部记忆缓存的对象,为了节省时间和空间,可能不会对缓存进行 pickle。此实现使用内部__dict__
而无需任何修改。
取消勾选时使用__setstate__()
方法重置对象的值。此版本将状态合并到内部__dict__
中,然后写入相应的日志条目。
在下一节中,我们将了解安全和全球问题。
在取消勾选期间,pickle 流中的全局名称可能导致对任意代码进行求值。通常,插入字节的全局名称是类名或函数名。但是,可以包括一个全局名称,该名称是模块中的一个函数,例如os
或subprocess
。这允许对试图在没有强大 SSL 控制的情况下通过 internet 传输 pickle 对象的应用程序进行攻击。为了防止执行任意代码,我们必须扩展pickle.Unpickler
类。我们将覆盖find_class()
方法,以更安全的方式替换它。我们必须考虑几个令人不快的问题,例如:
- 我们必须防止使用内置的
exec()
和eval()
功能。 - 我们必须防止使用可能被认为不安全的模块和包。例如,
sys
和os
应该被禁止。 - 我们必须允许使用我们的应用程序模块。
下面是一个施加一些限制的示例:
import builtins
class RestrictedUnpickler(pickle.Unpickler):
def find_class( self , module: str , name: str ) -> Any:
if module == "builtins" :
if name not in ( "exec" , "eval" ):
return getattr (builtins, name)
elif module in ( "__main__" , "Chapter_10.ch10_ex3" , "ch10_ex3" ):
# Valid module names depends on execution context.
return globals ()[name]
# elif module in any of our application modules...
elif module in ( "Chapter_10.ch10_ex2" ,):
return globals ()[name]
raise pickle.UnpicklingError(
f"global '{module}.{name}' is forbidden"
)
这个版本的Unpickler
类将帮助我们避免大量可能由篡改的 pickle 流引起的潜在问题。它允许使用除exec()
和eval()
之外的任何内置功能。它允许使用仅在__main__
中定义的类。在所有其他情况下,它都会引发一个例外。
让我们看看如何使用 CSV 转储和加载。
csv
模块将简单的list
或dict
实例编码并解码为 CSV 符号。与前面讨论的json
模块一样,这不是一个非常完整的持久化解决方案。然而,CSV 文件的广泛采用意味着经常需要在 Python 对象和 CSV 之间进行转换。
使用 CSV 文件需要在潜在的复杂 Python 对象和非常简单的 CSV 结构之间进行手动映射。我们需要仔细地设计映射,同时还要认识到 CSV 符号的局限性。这可能很困难,因为对象的表达能力与 CSV 文件的表格结构不匹配。
根据定义,CSV 文件每列的内容都是纯文本。从 CSV 文件加载数据时,我们需要在应用程序中将这些值转换为更有用的类型。电子表格执行意外类型强制的方式会使转换变得复杂。例如,我们可能有一个电子表格,其中美国邮政编码已被电子表格应用程序更改为浮点数。当电子表格保存为 CSV 时,邮政编码可能会成为一个令人困惑的数值。例如,缅因州班戈的邮政编码是 04401。当通过电子表格程序转换为数字时,该值变为 4401。
因此,我们可能需要使用诸如 row['zip'] .zfill(5)
或('00000'+row['zip'])[-5:]
之类的转换来恢复前导零。另外,不要忘记,一个文件可能包含 ZIP 和 ZIP 以及四个邮政编码,这使得数据清理更具挑战性。
为了使 CSV 文件的使用更加复杂,我们必须意识到它们经常被手动触摸,并且由于人为的调整而变得微妙地不兼容。对于软件来说,在面对现实世界中出现的异常情况时保持灵活性是很重要的。
当我们有相对简单的类定义时,我们通常可以将每个实例转换为一行简单、平坦的数据值。通常,NamedTuple
是 CSV 源文件和 Python 对象之间的良好匹配。另一方面,如果我们的应用程序将以 CSV 符号保存数据,我们可能需要围绕 NamedTuple
设计 Python 类。
当我们的类是容器时,我们通常很难确定如何在平面 CSV 行中表示结构化容器。这是对象模型和用于 CSV 文件或关系数据库的平面规范化表格结构之间的阻抗不匹配。阻抗失配没有很好的解决方案;它需要仔细设计。我们将从简单的平面对象开始,向您展示一些 CSV 映射。
让我们看看如何将简单序列转储到 CSV 中。
理想的映射是 CSV 文件中的NamedTuple
实例和行之间的映射。每行代表一个不同的 N amedTuple
。考虑下面的 Python 类定义:
from typing import NamedTuple
class GameStat(NamedTuple):
player: str
bet: str
rounds: int
final: float
我们已经在这个应用程序中定义了对象,使其具有简单、平坦的属性序列。数据库架构师称之为第一范式。没有重复的组,每个项都是一个原子数据块。
我们可以从模拟中生成这些对象,该模拟类似于以下代码:
from typing import Iterator, Type
def gamestat_iter(
player: Type[Player_Strategy], betting: Type[Betting], limit: int = 100
) -> Iterator[GameStat]:
for sample in range ( 30 ):
random.seed(sample) # Assures a reproducible result
b = Blackjack(player(), betting())
b.until_broke_or_rounds(limit)
yield GameStat(player. __name__ , betting. __name__ , b.rounds, b.betting.stake)
此迭代器将使用给定的玩家和下注策略创建 21 点模拟。它将执行游戏,直到玩家破产或在桌旁坐了 100 个回合。在每个会话结束时,它将生成一个带有玩家策略、下注策略、回合数和最终赌注的GameStat
对象。这将允许我们计算每个游戏、下注策略或组合的统计数据。以下是我们如何将其写入文件以供以后分析:
import csv
from pathlib import Path
with (Path.cwd() / "data" / "ch10_blackjack_1.csv" ).open( "w" , newline = "" ) as target:
writer = csv.DictWriter(target, GameStat._fields)
writer.writeheader()
for gamestat in gamestat_iter(Player_Strategy, Martingale_Bet):
writer.writerow(gamestat._asdict())
创建 CSV 编写器有三个步骤:
- 打开新行选项设置为
""
的文件。这将支持(可能)CSV 文件的非标准行结尾。 - 创建一个 CSV 编写器。在本例中,我们创建了
DictWriter
,因为它允许我们轻松地从字典对象创建行。GameStat._fields
属性提供 Python 属性名称,因此 CSV 列将精确匹配NamedTuple
类的GameStat
子类的属性。 - 在文件的第一行中写入头。通过提供一些关于 CSV 文件中的内容的提示,这使得数据交换稍微简单一些。
一旦准备好了writer
对象,我们就可以使用 writer 的writerow()
方法将每个字典写入 CSV 文件。在某种程度上,我们可以通过使用writerows()
方法稍微简化这一点。此方法需要一个迭代器,而不是单个行。下面是我们如何将writerows()
与迭代器一起使用:
data = gamestat_iter(Player_Strategy, Martingale_Bet)
with (Path.cwd() / "data" / "ch10_blackjack_2.csv" ).open( "w" , newline = "" ) as target:
writer = csv.DictWriter(target, GameStat._fields)
writer.writeheader()
writer.writerows(g._asdict() for g in data)
我们已经将迭代器分配给一个变量data
。对于writerows()
方法,我们从迭代器生成的每一行中获取一个字典。
让我们从 CSV 加载一个简单的序列。
我们可以使用如下循环从 CSV 文件加载简单、连续的对象:
with (Path.cwd() / "data" / "ch10_blackjack_1.csv" ).open() as source:
reader = csv.DictReader(source)
assert set (reader.fieldnames) == set (GameStat._fields)
for gs in (GameStat(**r) for r in reader):
print( gs )
我们已经为文件定义了一个读卡器。我们知道我们的文件有一个正确的标题,我们可以使用DictReader
。这将使用第一行定义属性名称。我们现在可以从 CSV 文件中的行构造GameStat
对象。我们使用了生成器表达式来构建行。
在本例中,我们假设列名与GameStat
类定义的属性名匹配。如有必要,我们可以通过比较reader.fieldnames
和GameStat._fields
来确认文件是否符合预期格式。由于顺序不必匹配,我们需要将每个字段名列表转换为一个集合。下面是检查列名的方法:
assert set(reader.fieldnames) == set(GameStat._fields)
我们忽略了从文件中读取的值的数据类型。当我们从 CSV 文件中读取时,这两个数字列将成为字符串值。因此,我们需要更复杂的逐行转换来创建适当的数据值。
以下是执行所需转换的典型 factory 函数:
def gamestat_rdr_iter(
source_data: Iterator[Dict[ str , str ]]
) -> Iterator[GameStat]:
for row in source_data:
yield GameStat(row[ "player" ], row[ "bet" ], int (row[ "rounds" ]), int (row[ "final" ]))
我们已经将int
函数应用于应该具有数值的列。在罕见的情况下,文件具有正确的头但数据不正确,我们将从失败的int()
函数中获得普通的ValueError
。我们可以按如下方式使用此生成器功能:
with (Path.cwd()/ "data" / "ch10_blackjack_1.csv" ).open() as source:
reader = csv.DictReader(source)
assert set (reader.fieldnames) == set (GameStat._fields)
for gs in gamestat_rdr_iter(reader):
print (gs)
这个版本的阅读器通过对数值进行转换,正确地重构了GameStat
对象。
让我们看看如何处理容器和复杂类。
当我们回顾我们的微博示例时,我们有一个包含许多Post
实例的Blog
对象。我们将Blog
设计为list
的包装,因此Blog
将包含一个集合。使用 CSV 表示时,我们必须设计从复杂结构到表格表示的映射。我们有三个共同的解决方案:
- 我们可以创建两个文件,一个博客文件和一个发布文件。博客文件只有
Blog
实例。在我们的示例中,每个Blog
都有一个标题。然后,每个Post
行都可以引用发布所属的Blog
行。我们需要为每个Blog
添加一个密钥。然后,每个Post
都会有一个指向Blog
键的外键引用。 - 我们可以在一个文件中创建两种行。我们将有
Blog
行和Post
行。我们的作者纠缠于各种类型的数据;我们的读者必须理清数据的类型。 - 我们可以在各种行之间执行关系数据库连接,在每个
Post
子行上重复Blog
父信息。
在这些选择中没有最佳解决方案。我们必须设计一个解决方案来处理平面 CSV 行和更结构化的 Python 对象之间的阻抗不匹配。数据的用例将定义一些优点和缺点。
创建两个文件需要为每个Blog
创建某种唯一标识符,以便Post
可以正确引用Blog
。我们不能轻易地使用 Python 内部 ID,因为它们不能保证每次 Python 运行时都是一致的。
一个常见的假设是Blog
标题是唯一的键;由于这是Blog
的一个属性,因此称为自然主键。这很少奏效;我们不能在不更新所有引用Blog
的Posts
标题的情况下更改Blog
标题。更好的计划是发明一个唯一标识符,并更新类设计以包含该标识符。这称为代理密钥。Pythonuuid
模块可以为此提供唯一标识符。
使用多个文件的代码与前面的示例几乎相同。唯一的变化是为Blog
类添加一个适当的主键。一旦定义了键,我们就可以如前面所示创建编写器和读取器,将Blog
和Post
实例处理到各自的文件中。
在下一节中,我们将转储多个行类型并将其加载到 CSV 文件中。
在一个文件中创建多种类型的行会使格式更加复杂。列标题必须成为所有可用列标题的并集。由于各种行类型之间可能存在名称冲突,我们可以按位置访问行(防止我们简单地使用csv.DictReader
),或者我们必须发明一个更复杂的列标题,将类和属性名称组合在一起。
如果我们为每一行提供一个充当类鉴别器的额外列,则过程会更简单。这个额外的列向我们显示了该行表示的对象的类型。对象的类名对此很有用。以下是我们如何使用两种不同的行格式将博客和帖子写入单个 CSV 文件:
with (Path.cwd() / "data" / "ch10_blog3.csv" ).open( "w" , newline = "" ) as target:
wtr = csv.writer(target)
wtr.writerow([ "__class__" , "title" , "date" , "title" , "rst_text" , "tags" ])
for b in blogs:
wtr.writerow([ "Blog" , b.title, None , None , None , None ])
for p in b.entries:
wtr.writerow([ "Post" , None , p.date, p.title, p.rst_text, p.tags])
我们在文件中创建了两种不同的行。有些行在第一列中有'Blog'
,并且只包含Blog
对象的属性。其他行在第一列中有'Post'
,并且只包含Post
对象的属性。
我们没有使专栏标题唯一,所以我们不能使用字典作者或读者。当按这样的位置分配列时,每一行根据它必须共存的其他类型的行分配未使用的列。这些附加列用None
填充。随着不同行类型数量的增加,跟踪各种位置列指定可能会变得很困难。
此外,单个数据类型的转换可能有些令人费解。特别是,我们忽略了时间戳和标记的数据类型。我们可以通过检查行鉴别器来重新组装我们的Blogs
和Posts
:
with (Path.cwd() / "data" / "ch10_blog3.csv" ).open() as source:
rdr = csv.reader(source)
header = next (rdr)
assert header == [ "__class__" , "title" , "date" , "title" , "rst_text" , "tags" ]
blogs = []
for r in rdr:
if r[ 0 ] == "Blog" :
blog = Blog(*r[ 1 : 2 ]) # type: ignore
blogs.append(blog)
elif r[ 0 ] == "Post" :
post = Post(*r[ 2 :]) # type: ignore
blogs[- 1 ].append(post)
这个片段将构建一个Blog
对象列表。每个'Blog'
行使用slice(1,2)
中的列定义Blog
对象。每个'Post'
行使用slice(2,6)
中的列定义一个Post
对象。这要求每个Blog
后面都有相关的Post
实例。外键不用于将两个对象绑定在一起。
我们对 CSV 文件中的列使用了两种假设,这些列的顺序和类型与类构造函数的参数相同。对于Blog
对象,我们使用blog = Blog(*r[1:2])
,因为唯一的一列是文本,它与constructor
类匹配。使用外部提供的数据时,此假设可能被证明是无效的。
# type: ignore
注释是必需的,因为读取器中的数据类型将是字符串,并且这些类型与上面提供的数据类类型定义不匹配。颠覆mypy检查来构造对象并不理想。
要构建Post
实例并执行适当的类型转换,需要一个单独的函数。此函数将映射类型并调用constructor
类。这里有一个映射函数来构建Post
实例:
import ast
def post_builder(row: List[ str ]) -> Post:
return Post(
date =datetime.datetime.strptime(row[ 2 ], "%Y-%m-%d %H:%M:%S" ),
title =row[ 3 ],
rst_text =row[ 4 ],
tags =ast.literal_eval(row[ 5 ]),
)
这将从一行文本正确地构建一个Post
实例。它将datetime
的文本和标记的文本转换为它们正确的 Python 类型。这具有使映射显式的优点。
在本例中,我们使用ast.literal_eval()
来解码更复杂的 Python 文本值。这允许 CSV 数据包含字符串值元组的文字表示:"('#RedRanger', '#Whitby42', '#ICW')"
。如果不使用ast.literal_eval()
,我们就必须为这个数据类型周围相当复杂的正则表达式编写自己的解析器。我们没有编写自己的解析器,而是选择序列化可以安全地反序列化的字符串对象的元组。
让我们看看如何使用迭代器过滤 CSV 行。
我们可以重构前面的加载示例来迭代Blog
对象,而不是构建Blog
对象的列表。这允许我们浏览一个大的 CSV 文件,并找到相关的Blog
和Post
行。此函数是一个生成器,分别生成每个Blog
实例:
def blog_iter(source: TextIO) -> Iterator[Blog]:
rdr = csv.reader(source)
header = next (rdr)
assert header == [ "__class__" , "title" , "date" , "title" , "rst_text" , "tags" ]
blog = None
for r in rdr:
if r[ 0 ] == "Blog" :
if blog:
yield blog
blog = blog_builder(r)
elif r[ 0 ] == "Post" :
post = post_builder(r)
blog.append(post)
if blog:
yield blog
此blog_iter()
函数创建Blog
对象并附加Post
对象。每次出现Blog
头,前一个Blog
就完成了,可以生成。最后,还必须交出最终的Blog
对象。如果我们想要Blog
实例的大列表,我们可以使用以下代码:
with (Path.cwd()/"data"/"ch10_blog3.csv").open() as source:
blogs = list(blog_iter(source))
这将使用迭代器构建一个Blogs
列表,在极少数情况下,我们实际上需要在内存中保存整个序列。我们可以使用以下方法单独处理每个Blog
,渲染它以创建 reST 文件:
with (Path.cwd()/"data"/"ch10_blog3.csv").open() as source:
for b in blog_iter(source):
with open(blog.title+'.rst','w') as rst_file:
render(blog, rst_file)
我们使用blog_iter()
函数阅读每个博客。读取后,可呈现为.rst
格式文件。可以运行一个单独的过程rst2html.py
将每个博客转换为 HTML。
我们可以很容易地添加一个过滤器,只处理选定的Blog
实例。我们可以添加一个if
语句来决定应该呈现哪个Blogs
,而不是简单地呈现所有Blog
实例。
让我们看看如何将合并的行转储并加载到 CSV 文件中。
将对象连接在一起意味着创建一个集合,其中每一行都有一组复合列。这些列将是子类属性和父类属性的并集。该文件将为每个子文件都有一行。每行的父属性将重复该子行的父属性值。这涉及到相当多的冗余,因为父值与每个单独的子项重复。当存在多个级别的容器时,这可能会导致大量重复数据。
这种重复的优点是,每一行都是独立的,不属于上面的行定义的上下文。我们不需要类鉴别器。对每个子对象重复父值。
这适用于形成简单层次结构的数据;每个子级都添加了一些父级属性。当数据涉及更复杂的关系时,简单化的父子模式就会崩溃。在这些示例中,我们将Post
标记集中到一列文本中。如果我们尝试将标签分成单独的列,它们将成为每个Post
的子项,这意味着每个标签都可能重复Post
的文本。显然,这不是一个好主意!
CSV 列标题必须是所有可用列标题的并集。由于各种行类型之间可能存在名称冲突,我们将使用类名限定每个列名。这将导致列标题,如'Blog.title'
和'Post.title'
。这允许使用DictReader
和DictWriter
,而不是柱的位置分配。但是,这些限定名与类定义的属性名并不完全匹配;这将导致更多的文本处理来解析列标题。下面是如何编写包含父属性和子属性的连接行:
with (Path.cwd() / "data" / "ch10_blog5.csv" ).open( "w" , newline = "" ) as target:
wtr = csv.writer(target)
wtr.writerow(
[ "Blog.title" , "Post.date" , "Post.title" , "Post.tags" , "Post.rst_text" ]
)
for b in blogs:
for p in b.entries:
wtr.writerow([b.title, p.date, p.title, p.tags, p.rst_text])
我们看到了限定的专栏标题。在这种格式中,每一行现在包含一个Blog
属性和Post
属性的并集。我们可以使用b.title
和p.title
属性在每篇文章中包含博客标题。
这种数据文件布局更容易准备,因为不需要用None
填充未使用的列。由于每个列名都是唯一的,我们可以轻松地切换到一个DictWriter
而不是一个简单的csv.writer()
。
重建博客条目需要两步操作。必须检查表示父对象和Blog
对象的列的唯一性。表示子对象和Post
对象的列是在最近找到的父对象的上下文中构建的。以下是从 CSV 行重建原始容器的方法:
def blog_iter2(source: TextIO) -> Iterator[Blog]:
rdr = csv.DictReader(source)
assert (
set (rdr.fieldnames)
== { "Blog.title" , "Post.date" , "Post.title" , "Post.tags" , "Post.rst_text" }
)
# Fetch first row, build the first Blog and Post.
row = next (rdr)
blog = blog_builder5(row)
post = post_builder5(row)
blog.append(post)
# Fetch all subsequent rows.
for row in rdr:
if row[ "Blog.title" ] != blog.title:
yield blog
blog = blog_builder5(row)
post = post_builder5(row)
blog.append(post)
yield blog
第一行数据用于构建一个Blog
实例,第一行Post
用于构建该Blog
实例。下面循环的不变条件假设存在一个合适的Blog
对象。拥有一个有效的Blog
实例可以使处理逻辑更加简单。Post
实例具有以下功能:
import ast
def post_builder5(row: Dict[ str , str ]) -> Post:
return Post(
date =datetime.datetime.strptime(
row[ "Post.date" ],
"%Y-%m-%d %H:%M:%S" ),
title =row[ "Post.title" ],
rst_text =row[ "Post.rst_text" ],
tags =ast.literal_eval(row[ "Post.tags" ]),
)
我们通过转换到constructor
类的参数来映射每行中的各个列。这将正确处理从 CSV 文本到 Python 对象的所有类型转换。
blog_builder5()
功能与post_builder5()
功能类似。由于属性较少且不涉及数据转换,因此未显示,留给读者作为练习。
让我们看看如何使用 XML 转储和加载。
Python 的xml
包包括许多解析 XML 文件的模块。还有一个可以生成 XML 文档的文档对象模型(DOM实现)。与前面的json
模块一样,XML 不是 Python 对象的完整持久化解决方案。然而,由于 XML 文件的广泛采用,经常需要在 Python 对象和 XML 文档之间进行转换。
使用 XML 文件涉及 Python 对象和 XML 结构之间的手动映射。我们需要仔细设计映射,同时要注意 XML 符号的约束。这可能很困难,因为对象的表达能力与 XML 文档的严格层次性之间不匹配。
XML 属性或标记的内容是纯文本。加载 XML 文档时,我们需要在应用程序中将这些值转换为更有用的类型。在某些情况下,XML 文档可能包含指示预期类型的属性或标记。
如果我们愿意忍受一些限制,我们可以使用plistlib
模块将一些内置 Python 结构作为 XML 文档发出。我们将在第 14 章、配置文件和持久化中检查此模块,我们将使用它加载配置文件。
The json
module offers ways to extend the JSON encoding to include our customized classes; the plistlib
module doesn't offer this additional hook.
当我们考虑转储 Python 对象以创建 XML 文档时,有三种常见的方法来构建文本:
- 在我们的类设计中包含 XML 输出方法:在这种情况下,我们的类发出可以组合成 XML 文档的字符串。这将序列化合并到潜在脆弱设计中的类中。
- 使用
xml.etree.ElementTree
构建ElementTree
节点并返回此结构:然后可以呈现为文本。这稍微不那么脆弱,因为它构建了一个抽象的文档对象模型,而不是文本。 - 使用外部模板并将属性填充到该模板:除非我们有一个复杂的模板工具,否则这不会很好。标准库中的
string.Template
类只适用于非常简单的对象。一般来说,应使用 Jinja2 或 Mako。这将 XML 与类定义分离。
有些项目包括通用 Python XML 序列化程序。尝试创建通用序列化程序的问题在于 XML 非常灵活;XML 的每个应用似乎都有独特的XML 模式定义(XSD)或文档类型定义(DTD)要求。
一个开放的 XML 文档设计问题是如何对原子值进行编码。有很多选择。我们可以使用特定于类型的标记,在标记的属性中使用属性名,例如<int name="the_answer">42</int>
。另一种可能是使用特定于属性的标记,标记的属性类型为:<the_answer type="int">42</the_answer>
。我们也可以使用嵌套标记:<the_answer><int>42</int></the_answer>
。或者,我们可以依赖一个单独的模式定义来建议the_answer
应该是一个整数,并且只将值编码为文本:<the_answer>42</the_answer>
。我们也可以使用相邻的标签:<key>the_answer</key><int>42</int>
。这不是一份详尽的清单;XML 为我们提供了很多选择。
在从 XML 文档恢复 Python 对象时,将分为两个步骤。通常,我们必须解析文档以创建文档对象。一旦这可用,我们就可以检查 XML 文档,从可用的标记组装 Python 对象。
一些 web 框架,如 Django,包括 Django 定义的类的 XML 序列化。这不是任意 Python 对象的一般序列化。Django 的数据建模组件狭义地定义了序列化。此外,还有一些包,例如dexml
、lxml
和pyxser
,作为 Python 对象和 XML 之间的替代绑定。退房http://pythonhosted.org/dexml/api/dexml.html 、http://lxml.de 和http://coder.cl/products/pyxser/ 了解更多信息。可在找到更长的候选软件包列表 https://wiki.python.org/moin/PythonXml 。
现在,让我们看看如何使用字符串模板转储对象。
将 Python 对象序列化为 XML 的一种方法是包含一个发出 XML 文本的方法。对于复杂对象,容器必须获取容器中每个项目的 XML。下面是我们的微博类结构的两个简单扩展,它们将 XML 输出功能添加为文本:
from dataclasses import dataclass, field, asdict
@dataclass
class Blog_X:
title: str
entries: List[Post_X] = field( default_factory = list )
underline: str = field( init = False )
def __post_init__( self ) -> None :
self .underline = "=" * len ( self .title)
def append( self , post: 'Post_X') -> None :
self .entries.append(post)
def by_tag( self ) -> DefaultDict[ str , List[Dict[ str , Any]]]:
tag_index: DefaultDict[ str , List[Dict[ str , Any]]] = defaultdict( list )
for post in self .entries:
for tag in post.tags:
tag_index[tag].append(asdict(post))
return tag_index
def as_dict( self ) -> Dict[ str , Any]:
return asdict( self )
def xml( self ) -> str :
children = " \n " .join(c.xml() for c in self .entries)
return f""" \
<blog><title> { self .title } </title>
<entries>
{ children }
<entries>
</blog>
"""
我们在这个基于数据类的定义中包含了一些内容。首先,我们定义了Blog_X
对象、标题和条目列表的核心属性。为了使条目可选,提供了一个字段定义,以使用list()
函数作为默认值的工厂。为了与前面显示的Blog
类兼容,我们还提供了一个 underline 属性,该属性由__post_init__()
特殊方法构建。
append()
方法在Blog_X
类级别提供,与 xxx 段的Blog
类兼容。它将工作委托给entries
属性。by_tag()
方法可用于通过 hashtags 构建索引
as_dict()
方法是为Blog
对象定义的,并根据该对象生成一个字典。在处理数据类时,dataclasses.asdict()
函数为我们完成了这项工作。为了与Blog
类兼容,我们将asdict()
函数包装到Blog_X
数据类的方法中
xml()
方法为该对象发出基于 XML 的文本。它使用相对简单的 f 字符串处理将值注入字符串。为了组装一个完整的条目,entries
集合被转换成一系列行,分配给children
变量,并格式化为生成的 XML 文本。
Post_X
类定义类似。如图所示:
@dataclass
class Post_X:
date: datetime.datetime
title: str
rst_text: str
tags: List[ str ]
underline: str = field( init = False )
tag_text: str = field( init = False )
def __post_init__( self ) -> None :
self .underline = "-" * len ( self .title)
self .tag_text = ' ' .join( self .tags)
def as_dict( self ) -> Dict[ str , Any]:
return asdict( self )
def xml( self ) -> str :
tags = "" .join( f"<tag> { t } </tag>" for t in self .tags)
return f""" \
<entry>
<title> { self .title } </title>
<date> { self .date } </date>
<tags> { tags } </tags>
<text> { self .rst_text } </text>
</entry>"""
这也有两个由__post_init__()
特殊方法创建的字段。它包括一个as_dict()
方法,以保持与前面显示的Post
类兼容。此方法使用asdict()
函数来完成从 dataclass 对象创建字典的实际工作。
这两个类都包含高度特定于类的 XML 输出方法。它们将发出用 XML 语法包装的相关属性。这种方法不能很好地推广。Blog_X.xml()
方法发出一个带有标题和条目的<blog>
标记。Post_X.xml()
方法发出一个具有各种属性的<post>
标记。在这两种方法中,使用"".join()
或"\n".join()
创建辅助对象,以从较短的字符串元素构建较长的字符串。当我们将Blog
对象转换为 XML 时,结果如下所示:
<blog><title>Travel</title>
<entries>
<entry>
<title>Hard Aground</title>
<date>2013-11-14 17:25:00</date>
<tags><tag>#RedRanger</tag><tag>#Whitby42</tag><tag>#ICW</tag></tags>
<text>Some embarrassing revelation. Including ☹ and ⚓</text>
</entry>
<entry>
<title>Anchor Follies</title>
<date>2013-11-18 15:30:00</date>
<tags><tag>#RedRanger</tag><tag>#Whitby42</tag><tag>#Mistakes</tag></tags>
<text>Some witty epigram.</text>
</entry>
<entries></blog>
这种方法有两个缺点:
- 我们忽略了 XML 名称空间。这是对用于发出标记的文本文本的一个小更改。
- 每个类还需要将
<
、&
、>
和"
字符正确转义为<
、>
、&
和"
XML 实体。html
模块包括html.escape()
功能。
这会发出正确的 XML;可以依靠它来工作;但它不是很优雅,也不能很好地概括。
在下一节中,我们将看到如何使用xml.etree.ElementTree
转储对象。
我们可以使用xml.etree.ElementTree
模块构建可以作为 XML 发出的Element
结构。使用xml.dom
和xml.minidom
进行此操作很有挑战性。domapi 需要一个顶级文档,然后构建各个元素。当试图序列化具有多个属性的简单类时,这个必需的上下文对象的存在会造成混乱。我们必须首先创建文档,然后序列化文档的所有元素,并提供文档上下文作为参数。
通常,我们希望设计中的每个类都构建一个顶级元素并返回该元素。大多数顶级元素都有一系列子元素。我们可以为构建的每个元素分配文本和属性。我们还可以指定一个尾,它是紧跟在闭合标记后面的无关文本。在某些内容模型中,这只是空白。由于名称较长,可能有助于按以下方式导入ElementTree
:
import xml.etree.ElementTree as XML
下面是我们的微博类结构的两个扩展,它们将 XML 输出功能添加为Element
实例。我们可以对Blog_X
类使用以下扩展:
import xml.etree.ElementTree as XML
from typing import cast
class Blog_E(Blog_X):
def xmlelt( self ) -> XML.Element:
blog = XML.Element( "blog" )
title = XML.SubElement(blog, "title" )
title.text = self .title
title.tail = " \n "
entities = XML.SubElement(blog, "entries" )
entities.extend(cast( 'Post_E' , c).xmlelt() for c in self .entries)
blog.tail = " \n "
return blog
我们可以对Post_X
类使用以下扩展:
class Post_E(Post_X):
def xmlelt( self ) -> XML.Element:
post = XML.Element( "entry" )
title = XML.SubElement(post, "title" )
title.text = self .title
date = XML.SubElement(post, "date" )
date.text = str ( self .date)
tags = XML.SubElement(post, "tags" )
for t in self .tags:
tag = XML.SubElement(tags, "tag" )
tag.text = t
text = XML.SubElement(post, "rst_text" )
text.text = self .rst_text
post.tail = " \n "
return post
我们已经编写了高度特定于类的 XML 输出方法。这些将构建具有正确文本值的Element
对象。
There's no fluent shortcut for building the subelements. We have to insert each text item individually.
在Blog.xmlelt()
方法中,我们能够执行Element.extend()
将所有单个 post 条目放入<entry>
元素中。这使我们能够灵活而简单地构建 XML 结构。这种方法可以优雅地处理 XML 名称空间。我们可以使用QName
类为 XML 名称空间构建限定名称。ElementTree
模块将名称空间限定符正确应用于 XML 标记。这种方法还将<
、&
、>
和"
字符正确转义为<
、>
、&
和"
XML 实体。这些方法的 XML 输出大部分与上一节相匹配。空白将是不同的。
为了构建最终输出,我们使用元素树模块的两个附加特性。它将类似于以下代码段:
tree = XML.ElementTree(travel5.xmlelt())
text = XML.tostring(tree.getroot())
travel5
对象是Blog_E
的实例。travel5.xmlelt()
评估结果为XML.Element
;这被包装成一个完整的XML.ElementTree
对象。树的根对象可以转换为有效的 XML 字符串,并打印或保存到文件中。
让我们看看如何加载 XML 文档。
从 XML 文档加载 Python 对象需要两个步骤。首先,我们需要解析 XML 文本以创建文档对象。然后,我们需要检查文档对象以生成 Python 对象。如前所述,XML 表示法的巨大灵活性意味着没有单一的 XML 到 Python 序列化。
遍历 XML 文档的一种方法是进行类似 XPath 的查询,以定位解析的各种元素。下面是一个遍历 XML 文档的函数,从可用的 XML 中发出Blog
和Post
对象:
def build_blog(document: XML.ElementTree) -> Blog_X:
xml_blog = document.getroot()
blog = Blog_X(xml_blog.findtext( "title" ))
for xml_post in xml_blog.findall( "entries/entry" ):
optional_tag_iter = (
t.text for t in xml_post.findall( "tags/tag" )
)
tags = list (
filter ( None , optional_tag_iter)
)
post = Post_X(
date =datetime.datetime.strptime(
xml_post.findtext( "date" ), "%Y-%m-%d %H:%M:%S"
),
title =xml_post.findtext( "title" ),
tags =tags,
rst_text =xml_post.findtext( "rst_text" ),
)
blog.append(post)
return blog
此函数遍历一个<blog>
XML 文档。它定位<title>
标记并收集该元素中的所有文本,以创建顶级Blog
实例。然后它定位在<entries>
元素中找到的所有<entry>
子元素。这些用于构建每个Post
对象。Post
对象的各种属性被单独转换。<tags>
元素中每个<tag>
元素的文本将转换为文本值列表。从日期的文本表示形式中解析日期。Post
对象分别附加到整个Blog
对象。本手册将 XML 文本映射到 Python 对象是解析 XML 文档的基本功能。
(t.text for t in xml_post.findall("tags/tag"))
生成器表达式的值没有Iterator[str]
类型。事实证明,t.text
属性的值具有Optional[str]
类型。结果表达式将创建一个类型提示为List[Optional[str]]
的列表,该类型提示与Post_X
类不直接兼容。
这个问题有两种解决方案:我们可以将Post_X
中的定义扩展为使用List[Optional[str]]
。这可能导致需要过滤掉应用程序中其他地方的None
对象。相反,我们将None
对象的删除推到了这个解析器中。filter(None, iterable)
函数将从 iterable 中删除所有 None 值;这会将具有List[Optional[str]]
类型提示的值转换为具有List[str]
类型提示的值。
这种转换和过滤是 XML 处理的重要部分。每个不同的数据类型或结构都必须序列化为 XML 兼容字符串,并从 XML 字符串反序列化。XML 提供了一种结构,但序列化的细节仍然是 Python 应用程序编程的重要部分。
我们研究了许多序列化 Python 对象的方法。我们可以用符号对类定义进行编码,包括 JSON、YAML、pickle、XML 和 CSV。这些符号中的每一种都有各种优点和缺点。
这些不同的库模块通常围绕着从外部文件加载对象或将对象转储到文件的思想工作。这些模块之间并不完全一致,但它们非常相似,允许我们应用一些常见的设计模式。
使用 CSV 和 XML 往往会暴露出最困难的设计问题。我们在 Python 中的类定义可能包含在 CSV 或 XML 表示法中没有良好表示的对象引用。
有许多方法可以序列化和持久化 Python 对象。我们还没有看到他们所有人。本节中的格式侧重于两个基本用例:
- 与其他应用程序的数据交换:我们可能会发布其他应用程序的数据或接受其他应用程序的数据。在这种情况下,我们经常受到其他应用程序接口的约束。通常,JSON 和 XML 被其他应用程序和框架用作首选的数据交换形式。在某些情况下,我们将使用 CSV 来交换数据。
- 我们自己应用程序的持久数据:在这种情况下,我们通常选择
pickle
,因为它是完整的,并且已经是 Python 标准库的一部分。然而,YAML 的一个重要优点是可读性;我们可以查看、编辑甚至修改该文件。
在使用这些格式时,我们有许多设计考虑因素。首先,这些格式倾向于序列化单个 Python 对象。它可能是其他对象的列表,但本质上是单个对象。例如,JSON 和 XML 具有在序列化对象之后写入的结束分隔符。对于来自更大域的单个对象的持久化,我们可以查看第 11 章中的shelve
和sqlite3
、*通过 Shelve 存储和检索对象、*和第 12 章、通过 SQLite 存储和检索对象。
JSON 是一种广泛使用的标准,但它不便于表示复杂的 Python 类。在使用 JSON 时,我们需要了解如何将对象简化为 JSON 兼容的表示形式。JSON 文档是人类可读的。JSON 的局限性使得通过 internet 传输对象具有潜在的安全性。
YAML 的使用不如 JSON 广泛,但它解决了序列化和持久化方面的许多问题。YAML 文档是人类可读的;对于可编辑的配置文件,YAML 是理想的。我们可以使用安全加载选项使 YAML 安全。
Pickle 非常适合简单、快速、本地持久化 Python 对象。它是从 Python 到 Python 传输的紧凑表示法。CSV 是一种广泛使用的标准。用 CSV 符号表示 Python 对象是一项挑战。当以 CSV 表示法共享数据时,我们通常会在应用程序中使用NamedTuple
对象。我们必须设计一个从 Python 到 CSV 和 CSV 到 Python 的映射。
XML 是另一种广泛用于序列化数据的表示法。XML 是非常灵活的,因此有多种方法可以用 XML 表示法对 Python 对象进行编码。由于 XML 用例的原因,我们通常有 XSD 或 DTD 形式的外部规范。解析 XML 以创建 Python 对象的过程总是相当复杂的。
因为每个 CSV 行在很大程度上独立于其他行,所以 CSV 允许我们对非常大的对象集合进行编码或解码。出于这个原因,CSV 对于无法放入内存的庞大集合的编码和解码通常非常方便。
在某些情况下,我们会遇到混合设计问题。在阅读大多数现代电子表格文件时,我们将 CSV 行和列问题包装在 XML 解析问题中。例如,考虑 OpenOffice。ODS 文件是压缩档案。档案中的一个文件是content.xml
文件。使用 XPath 搜索body/spreadsheet/table
元素将定位电子表格文档的各个选项卡。在每个表中,我们将找到(通常)映射到 Python 对象的table-row
元素。在每一行中,我们将找到包含构成对象属性的单个值的table-cell
元素。
在处理持久对象时,我们必须解决模式演化的问题。我们的对象具有动态和静态类定义。我们可以很容易地保持动态。我们的类定义是持久数据的模式。然而,这个类并不是绝对静态的。当一个类发生变化时,我们需要做一个准备来加载应用程序先前版本转储的数据。
最好考虑外部文件兼容性,以区分主要版本号和次要版本号。主要版本意味着文件不再兼容,必须进行转换。小版本应意味着文件格式兼容,升级过程中不涉及数据转换。
一种常见的方法是在文件扩展名中包含主版本号。我们可能有以.json2
或.json3
结尾的文件名,以指示所涉及的数据格式。支持持久文件格式的多个版本通常变得相当复杂。为了提供无缝的升级路径,应用程序应该能够解码以前的文件格式。通常,最好以最新和最好的文件格式保存数据,即使输入支持其他格式。
在下一章中,我们将讨论不关注单个对象的序列化。shelve
和sqlite3
模块为我们提供了序列化大量不同对象的方法。之后,我们将返回使用这些技术进行表征状态转移(REST)以在进程之间传输对象。此外,我们将再次使用这些技术来处理配置文件。
在第 11 章、*通过 Shelve 存储和检索对象、*和第 12 章、通过 SQLite 存储和检索对象中,我们将介绍两种常用的方法来创建更大的持久对象集合。这两章向我们展示了创建 Python 对象数据库的不同方法。
在第 13 章、传输和共享对象中,我们将这些序列化技术应用于在另一个进程中使对象可用的问题。我们将重点介绍 RESTfulWeb 服务,它是在进程之间传输对象的一种简单而流行的方法。
在第 14 章、配置文件和持久化中,我们将再次应用这些序列化技术。在本例中,我们将使用 JSON 和 YAML 等表示对应用程序的配置信息进行编码。