有许多应用程序需要单独保存对象。我们在第 10 章中所看到的序列化和保存技术——JSON、YAML、Pickle、CSV 和 XML偏向于处理单个对象。有时,我们需要从一个更大的域中持久化单独的对象。
具有持久对象的应用程序可以演示四种用例,总结为CRUD 操作:创建、检索、更新和删除。这里的想法是,这些操作中的任何一个都可以应用于域中的任何对象;这就需要一种更复杂的持久化机制,而不是将所有对象整体加载或转储到一个文件中。除了浪费内存外,简单加载和转储的效率通常低于细粒度、不同的逐对象存储。
使用更复杂的存储将使我们更仔细地审视责任分配。分离各种关注点为我们提供了应用软件体系结构的总体设计模式。这些高级设计模式的一个例子是多层架构:
- 表示层:包括网络浏览器或移动应用。它还可以包括用于本地安装的应用程序的图形用户界面(GUI。在某些情况下,这也可以是文本命令行界面。
- 应用层:虽然这通常基于 web 服务器,但也可能是本地安装软件的一部分。应用层可以有效地细分为处理层和数据模型层。处理层包括体现应用程序行为的类和函数。数据模型层定义问题域的对象模型。
- 数据层:可进一步细分为接入层和持久层。访问层提供对持久对象的统一访问。持久化层序列化对象并将其写入持久化存储。这就是实现更复杂的存储技术的地方。
由于这些层中的某些层可以细分,因此该模型有许多变体。它可以称为三层体系结构,以识别最明显的区别。它也可以被称为一个n层架构,以允许细微程度的头发分裂
数据层可以使用shelve
模块等模块进行持久化。这个模块定义了一个类似映射的容器,我们可以在其中存储对象。每个存储的对象都会被 pickle 并写入一个文件。我们还可以从文件中取消勾选和检索任何对象。shelve
模块依赖dbm
模块来保存和检索对象。
本节将重点介绍整个数据层中的访问层和持久层。这些层之间的接口将是单个应用程序中的类接口。在本章中,我们将重点讨论简单的类到类接口。我们将在第 13 章使用 REST 传输和共享对象中查看数据层的基于网络的接口。
在本章中,我们将介绍以下主题:
- 分析持久对象用例
- 创建书架
- 设计可搁置物体
- 搜索、扫描和查询
- 为
shelve
设计接入层 - 创建索引以提高效率
- 添加更多的索引维护
- 索引更新的写回替代方案
本章的代码文件可在上找到 https://git.io/fj2Ur 。
我们在第 10 章、序列化和保存—JSON、YAML、Pickle、CSV 和 XML中介绍的持久化机制侧重于读取和写入具有一个或多个对象的序列化表示的压缩文件。如果我们想更新文件的任何部分,我们必须替换整个文件。这是对数据使用紧凑表示法的结果:很难找到文件中单个对象的位置,如果大小发生变化,很难替换对象。所有的数据都是序列化和编写的,而不是用智能、复杂的算法来解决这些困难。
当我们有一个包含许多持久、独立和可变对象的更大的问题域时,我们会为用例引入一些额外的深度:
- 我们可能不希望一次将所有对象加载到内存中。对于许多b**ig data应用程序,可能不可能一次将所有对象加载到内存中。
- 我们可能只更新对象域中的小子集或单个实例。加载然后转储所有对象以更新一个对象是相对低效的处理。
- 我们可能不会一次性倾倒所有物品;我们可能在逐渐积累对象。一些格式,如 YAML 和 CSV,允许我们将其自身附加到一个文件中,而不太复杂。其他格式,如 JSON 和 XML,具有终止符,因此很难简单地附加到文件中。
我们还可以考虑更多的特性。通常会将序列化、持久化、事务一致性以及并发写访问合并到数据库的一个伞式概念中。shelve
模块本身并不是一个全面的数据库解决方案。shelve
使用的底层dbm
模块不直接处理并发写入。它也不处理多操作事务。可以对文件使用低级操作系统锁定来容忍并发更新。对于并发写访问,最好使用适当的数据库或 RESTful 数据服务器。更多信息请参见第 12 章、通过 SQLite存储和检索对象、第 13 章、传输和共享对象。
让我们来看看下一节的酸性质。
我们的设计必须考虑如何将 Ty1 T1 酸性质应用到我们的数据库中。通常,应用程序会在相关操作包中进行更改;捆绑包应将数据库从一个一致状态更改为下一个一致状态。事务捆绑包的目的是隐藏可能与数据的其他用户不一致的任何中间状态。
多操作事务的一个示例可能涉及更新两个对象,以使总数保持不变。我们可能从一个金融账户中扣除资金,然后存入另一个账户。为了使数据库处于一致、有效的状态,总平衡必须保持不变。
ACID 属性描述了我们希望数据库事务作为一个整体的行为。有四条规则定义了我们的期望:
- 原子性:事务必须是原子的。如果一个事务中有多个操作,则应完成所有操作或不完成任何操作。永远不可能查看部分完成的事务。
- 一致性:事务必须保证整个数据库的一致性。它必须将数据库从一种有效状态更改为另一种有效状态。事务不应损坏数据库或在并发用户之间创建不一致的视图。所有用户看到的已完成交易的净效果相同。
- 隔离:每一笔交易的处理都应该与所有其他交易完全隔离。我们不能让两个并发用户干扰彼此尝试的更新。我们应该始终能够将并发访问转换为(可能更慢的)串行访问,并且数据库更新将产生相同的结果。通常使用锁来实现这一点。
- 耐久性:对数据库的更改应该在文件系统中正确持久。
当我们处理内存中的 Python 对象时,很明显,我们可以得到ACI,,但我们没有得到D。根据定义,内存中的对象不是持久的。如果我们试图从多个并发进程中使用shelve
模块而不进行锁定或版本控制,我们可能只获得D,但会丢失ACI属性。
shelve
模块不直接支持原子性;它缺乏处理由多个操作组成的事务的集成技术。如果我们有多个操作事务,并且我们需要原子性,那么我们必须确保它们作为一个单元全部工作或全部失败。这可能涉及在try:
语句之前保存状态;出现问题时,异常处理程序必须恢复数据库的以前状态。
shelve
模块不能保证所有变化的耐久性。如果我们将一个可变对象放在工具架上,然后更改内存中的对象,那么工具架文件上的持久版本不会自动更改*。如果我们要改变搁置对象,我们的应用程序必须明确更新搁置对象。我们可以要求工具架对象通过写回模式跟踪更改,但使用此功能可能会导致性能低下。*
*这些缺失的特性相对简单,只需一些额外的锁定和日志即可实现。它们不是shelf
实例的默认特性。当需要完整的 ACID 功能时,我们通常会切换到其他形式的持久化。然而,当应用程序不需要完整的 ACID 功能集时,shelve
模块可能非常有用。
在下一节中,我们将了解如何创建工具架。
创建工具架的第一部分使用模块级函数shelve.open()
来创建持久的工具架结构。第二部分是正确关闭文件,以便将所有更改写入底层文件系统。我们将在为搁置部分设计访问层的更完整的示例中介绍这一点。
在引擎盖下,shelve
模块使用dbm
模块来完成打开文件和从键映射到值的实际工作。dbm
模块本身是底层 DBM 兼容库的包装器。因此,shelve
功能有许多潜在的实现。好消息是dbm
实现之间的差异在很大程度上无关紧要。
shelve.open()
模块功能需要两个参数:文件名和文件访问模式。通常,我们希望默认模式'c'
打开一个现有的工具架,或者在它不存在的情况下创建一个。
备选方案适用于特殊情况:
'r'
为只读书架。'w'
是一个读写工具架,必须存在,否则将引发异常。'n'
是一个新的空架子;任何以前的版本都将被覆盖。
关闭工具架以确保它正确地持久化到磁盘是绝对必要的。shelf 本身不是上下文管理器,但应始终使用contextlib.closing()
函数确保 shelf 已关闭。有关上下文管理器的更多信息,请参见第 6 章、使用可调用对象和上下文。
在某些情况下,我们可能还希望在不关闭文件的情况下显式地将工具架同步到磁盘。shelve.sync()
方法将在关闭前保持更改。理想化的生命周期类似于以下代码:
import shelve
from contextlib import closing
from pathlib import Path
db_path = Path.cwd() / "data" / "ch11_blog"
with closing(shelve.open(str(db_path))) as shelf:
process(shelf)
我们已经打开了一个工具架,并为一些执行应用程序实际工作的函数提供了该工具架。此过程完成后,上下文将确保工具架已关闭。如果process()
函数引发异常,搁板仍将正确关闭。
让我们看看如何设计可搁置的对象。
如果我们的对象相对简单,那么把它们放在架子上就很简单了。对于那些不是复杂容器或大型集合的对象,我们只需要制定一个键到值的映射。对于更复杂的对象(通常是包含其他对象的对象),我们必须就对象之间访问和引用的粒度做出一些额外的设计决策。我们首先来看一个简单的例子,在这个例子中,我们所要设计的就是用来访问对象的键。然后,我们将研究更复杂的情况,粒度和对象引用在其中发挥作用。
让我们看看如何使用类型提示设计对象。
Python 类型提示在为工具架定义对象方面提供了相当大的帮助。在本章中,我们将强调使用@dataclass
装饰器来创建适合持久化的对象。
数据类概念非常有用,因为它使定义对象状态的属性非常清晰。属性不会隐藏在方法定义中。如果没有@dataclass
,属性通常由__init__()
方法隐含。但是,在某些类中,属性是动态定义的,这使得属性不一致,并导致在从搁置的表示恢复对象状态时可能出现问题。
pickle
模块用于对对象进行序列化。有关对象 Pickle 的更多信息,请参见第 10 章、序列化和保存–JSON、YAML、Pickle、CSV 和 XML。
在以下部分中,我们将研究如何定义用于唯一标识工具架集合中对象的键。一个唯一的密钥是必不可少的。在某些问题域中,会有一个属性或属性组合是唯一的。尽管如此,创建一个由应用程序生成并分配给每个持久对象的代理密钥通常是有利的,以确保唯一性。
让我们看看如何为对象设计关键点。
shelve
和dbm
模块提供了对任意巨大宇宙中任何物体的即时访问。shelve
模块创建了一个类似于字典的映射。shelf 映射存在于持久化存储上,因此我们放在 shelf 上的任何对象都将被序列化并保存。
我们必须用唯一的键标识每个搁置对象。字符串值是键的常见选择。这对我们的类施加了一些设计考虑,以提供适当的唯一键。在某些情况下,问题域将具有一个明显的唯一密钥属性。在这种情况下,我们可以简单地使用该属性来构造这个键。例如,如果我们的类有一个唯一的属性值key_attribute
,那么我们可以使用shelf[object.key_attribute] = object
。这是最简单的情况,为更复杂的情况设置了模式。
当我们的应用程序问题没有提供适当的唯一密钥时,我们将不得不生成一个代理键值。当对象的每个属性都可能是可变的或可能是非唯一的时,经常会出现此问题。在这种情况下,我们可能必须创建一个代理键,因为没有唯一的值,也没有任何值的组合
我们的应用程序可能具有非字符串值,这些值是主键的候选值。例如,我们可能有一个datetime
对象或一个数字。在这些情况下,我们可能希望将值编码为字符串。
在没有明显主键的情况下,我们可以尝试定位创建唯一的复合键的值组合。这可能会变得复杂,因为现在密钥不是原子的,对密钥的任何部分进行更改都可能会产生数据更新问题。
遵循一种称为代理键的设计模式通常是最简单的。该键不依赖于对象中的数据;它是对象的代理。这意味着可以更改对象的任何属性,而不会导致复杂性或限制。Python 的内部对象 ID 是一种代理键的示例。
工具架键的字符串表示可以遵循以下模式:class_name:oid
。键字符串包括对象的类class_name
,与类实例的唯一标识符oid
配对。使用这种形式的键,我们可以很容易地将不同类别的对象存储在一个架子上。即使我们认为工具架中只有一种类型的对象,这种格式仍然有助于为索引、管理元数据和将来的扩展保存名称空间。
当我们有一个合适的自然关键点时,我们可能会执行以下操作以将对象保留在工具架中:
shelf[f"{object.__class__.__name__}:{object.key_attribute}"] = object
这将使用 f 字符串创建一个具有不同类名和唯一键值的键。对于每个对象,此字符串标识符必须是唯一的。对于代理键,我们需要定义某种生成器来发出不同的值。
下一节将讨论如何为对象生成代理键。
生成唯一代理项键的一种方法是使用整数计数器。为了确保正确更新此计数器,我们会将其与其他数据一起存储在书架上。即使 Python 有一个内部对象 ID,我们也不应该使用 Python 的内部标识符作为代理键。Python 的内部 ID 号没有任何保证。
当我们将要向工具架添加一些管理对象时,我们必须为这些对象提供具有独特前缀的唯一键。我们将使用_DB
。这将是我们工具架中管理对象的类名。这些管理对象的设计决策类似于应用程序对象的设计。我们需要选择存储的粒度。我们有两个选择:
- 粗粒度:我们可以创建一个包含代理密钥生成的所有管理开销的
dict
对象。单个键(如_DB:max
)可以识别此对象。在这个dict
中,我们可以将类名映射到使用的最大标识符值。每次我们创建一个新对象时,我们都会从这个映射中分配 ID,然后还替换工具架中的映射。我们将在下面的设计一个具有简单键的类小节中展示粗粒度解决方案。 - 细粒度:我们可以向数据库中添加许多项,每个项对于不同类别的对象都有最大的键值。这些额外的关键项中的每一项都具有
_DB:max:class
的形式。每个键的值都只是一个整数,这是迄今为止为给定类分配的最大顺序标识符。
这里需要考虑的一个重要问题是,我们已经将应用程序类的键设计与类设计分开。我们可以(也应该)尽可能简单地设计我们的应用程序对象。我们应该增加足够的开销,使shelve
正常工作,但不能再增加。
让我们看看如何用一个简单的键设计一个类。
将shelve
键存储为搁置对象的属性很有帮助。将密钥保留在对象中可以使对象更易于删除或替换。显然,在创建对象时,我们将从对象的无键版本开始,直到它存储在工具架中。一旦存储,Python 对象需要设置一个 key 属性,以便内存中的每个对象都包含一个正确的 key。
检索对象时,有两个用例。我们可能需要一个键已知的特定对象。在这种情况下,工具架将把关键点映射到对象。我们可能还需要一个相关对象的集合,这些对象的键不知道,但可能由其他一些属性的值知道。在本例中,我们将通过某种搜索或查询来发现对象的键。我们将在下面的为复杂对象设计 CRUD 操作部分中介绍搜索算法。
为了支持在对象中保存工具架键,我们将为每个对象添加一个_id
属性。它会将shelve
键保存在每个放在架子上或从架子上取回的物品中。这将简化管理需要在工具架中替换或从工具架中移除的对象。例如,代理键不具有任何方法函数,并且它永远不属于应用层或表示层的处理层。以下是对整体Blog
的定义:
from dataclasses import dataclass, asdict, field
@dataclass
class Blog:
title: str
entries: List[Post] = field(default_factory=list)
underline: str = field(init=False, compare=False)
# Part of the persistence, not essential to the class.
_id: str = field(default="", init=False, compare=False)
def __post_init__(self) -> None:
self.underline = "=" * len(self.title)
我们已经提供了基本的title
属性。entries
属性是可选的,默认值为空列表。underline
被计算为长度与标题匹配的字符串;这使得重构文本格式的某些部分稍微简单一些
我们可以通过以下方式创建一个Blog
对象:
>>> b1 = Blog(title="Travel Blog")
这将在博客中有一个单独帖子的空列表。当我们将此简单对象存储在工具架中时,我们可以执行以下操作:
>>> import shelve
>>> from pathlib import Path
>>> shelf = shelve.open(str(Path.cwd() / "data" / "ch11_blog"))
>>> b1._id = 'Blog:1'
>>> shelf[b1._id] = b1
我们从打开一个新架子开始。该文件最终将被称为ch11_blog.db
。我们在Blog
实例b1
中放入了一个键Blog:1
。我们使用_id
属性中给出的键将该Blog
实例存储在 shelf 中。
我们可以这样从货架上取回物品:
>>> shelf['Blog:1']
Blog(title='Travel Blog', entries=[], underline='===========', _id='Blog:1')
>>> shelf['Blog:1'].title
'Travel Blog'
>>> shelf['Blog:1']._id
'Blog:1'
>>> list(shelf.keys())
['Blog:1'] >>> shelf.close()
当我们参考shelf['Blog:1']
时,它会从架子上取下我们原来的Blog
实例。我们只在架子上放了一个物体,从钥匙列表中可以看出。因为我们关闭了工具架,所以对象是持久的。我们可以退出 Python,重新启动它,打开工具架,然后使用指定的键查看对象是否仍在工具架上。前面,我们提到了第二个用例,用于在不知道密钥的情况下检索和定位项目。以下是一个搜索,可查找具有给定标题的所有博客:
>>> path = Path.cwd() / "data" / "ch11_blog"
>>> shelf = shelve.open(str(path))
>>> results = (shelf[k]
... for k in shelf.keys()
... if k.startswith('Blog:') and shelf[k].title == 'Travel Blog'
... )
>>> list(results) [Blog(title='Travel Blog', entries=[], underline='===========', _id='Blog:1')]
我们打开架子,以便接触到这些物品。results
生成器表达式检查工具架中的每个项目,以定位键以'Blog:'
开头的项目,对象的 title 属性为'Travel Blog'
字符串。
重要的是,'Blog:1'
键存储在对象本身中。_id
属性确保我们的应用程序正在处理的任何项都有正确的密钥。现在,我们可以改变对象的任何属性(键除外),并使用其原始键在工具架中替换它。
现在,让我们看看如何为容器或集合设计类。
当我们有更复杂的容器或集合时,我们需要做出更复杂的设计决策。一个问题是关于我们搁置对象的粒度。
当我们有一个对象时,比如Blog
,它是一个容器,我们可以将整个容器作为一个单一的、复杂的对象保存在我们的架子上。在某种程度上,这可能会首先破坏在一个架子上放置多个对象的目的。存储大型容器涉及粗粒度存储。如果我们更改一个包含的对象,那么整个容器都必须序列化和存储。如果我们最终在一个容器中有效地浸泡了整个宇宙的物体,为什么要使用shelve
?我们必须找到一个适合应用程序要求的平衡点。
另一种方法是将集合分解为单独的项目。在这种情况下,我们的顶级Blog
对象将不再是一个合适的 Python 容器。父级可以使用一组键引用每个子级。每个子对象都可以通过键引用父对象。这种键的使用在面向对象设计中是不常见的。通常,对象只包含对其他对象的引用。当使用shelve
(或其他数据库)时,我们可以通过键强制使用间接引用。
每个子对象现在将有两个键:它自己的主键,加上父对象的主键外键。这导致了第二个设计问题,即为父母及其子女表示键字符串。
下一节将演示如何通过外键引用对象。
我们用来唯一标识对象的键是它的主键。当子对象引用父对象时,我们需要做出额外的设计决策。我们如何构造孩子们的主键?基于对象类之间存在的依赖类型,子键有两种常见的设计策略:
"Child:*cid*"
:当我们的孩子可以独立于拥有的父母而存在时,我们可以使用此选项。例如,发票上的项目指的是产品;即使产品没有发票项,该产品也可以存在。"Parent:*pid*:Child:*cid*"
:当孩子没有父母就无法生存时,我们可以使用此选项。如果客户不首先包含地址,那么客户地址就不存在。当子项完全依赖于父项时,子项的键可以包含所属父项的 ID 以反映此依赖关系。
与父类设计一样,如果保留主键和与每个子对象关联的所有外键,则最简单。我们建议不要在__init__()
方法中初始化它们,因为它们只是持久化的特性。以下是Blog
中Post
的一般定义:
import datetime
from dataclasses import dataclass, field, asdict
from typing import List
@dataclass
class Post:
date: datetime.datetime
title: str
rst_text: str
tags: List[str]
underline: str = field(init=False)
tag_text: str = field(init=False)
# Part of the persistence, not essential to the class.
_id: str = field(default='', init=False, repr=False, compare=False)
_blog_id: str = field(default='', init=False, repr=False, compare=False)
def __post_init__(self) -> None:
self.underline = "-" * len(self.title)
self.tag_text = " ".join(self.tags)
我们为每个微博帖子提供了几个属性。dataclasses
模块的asdict()
功能可以与模板一起使用,以提供可用于创建 JSON 符号的字典。我们避免提及Post
的主键或任何外键。以下是Post
实例的两个示例:
p2 = Post(date=datetime.datetime(2013,11,14,17,25),
title="Hard Aground",
rst_text="""Some embarrassing revelation. Including ☹ and ⚓""",
tags=("#RedRanger", "#Whitby42", "#ICW"),
)
p3 = Post(date=datetime.datetime(2013,11,18,15,30),
title="Anchor Follies",
rst_text="""Some witty epigram. Including < & > characters.""",
tags=("#RedRanger", "#Whitby42", "#Mistakes"),
)
我们现在可以通过设置属性将这两个 post 对象与其拥有的 blog 对象关联起来。我们将通过以下步骤完成此操作:
- 打开架子,取回父
Blog
对象。将其保存在owner
变量中,以便我们可以访问_id
属性:
>>> import shelve
>>> shelf = shelve.open("blog")
>>> owner = shelf['Blog:1']
- 将此所有者的密钥分配给每个
Post
对象并保存这些对象。将父项信息放入每个Post
。我们使用父级信息来构建主键。对于这种依赖的密钥,_parent
属性值是冗余的;它可以从钥匙中推断出来。但是,如果我们为Posts
使用独立的密钥设计,则_parent
不会在密钥中重复:
>>> p2._blog_id = owner._id
>>> p2._id = p2._blog_id + ':Post:2'
>>> shelf[p2._id]= p2
>>> p3._blog_id = owner._id
>>> p3._id = p3._blog_id + ':Post:3'
>>> shelf[p3._id]= p3
当我们查看键时,我们可以看到Blog
加上两个Post
实例:
>>> list(shelf.keys())
['Blog:1:Post:3', 'Blog:1', 'Blog:1:Post:2']
当我们找到任何孩子Post
时,我们将知道适合个人张贴的家长Blog
:
>>> p2._parent 'Blog:1'
>>> p2._id 'Blog:1:Post:2'
从父项Blog
到其子项Post
,以另一种方式跟随这些键,就成了在工具架集合中查找匹配键的问题。
下一节将讨论如何为复杂对象设计 CRUD 操作。
当我们将一个较大的集合分解为多个单独的细粒度对象时,我们将有多个对象类。因为它们是独立的对象,所以它们将为每个类生成单独的 CRUD 操作集。在某些情况下,对象是独立的,对一个类的对象的操作在该单个对象之外没有影响。在一些关系数据库产品中,它们成为级联操作。删除一个Blog
条目可以级联为删除相关的Post
条目。
在前面的示例中,Blog
和Post
对象具有依赖关系。Post
对象是父Blog
的子对象;没有父母,孩子就不能生存。当我们有了这些依赖关系,我们就有了一个更纠结的操作集合来设计。以下是一些注意事项:
- 考虑下面关于独立(或父)对象的 CRUD 操作的如下内容:
- 我们可以创建一个新的空父对象,为该对象分配一个新的主键。我们可以稍后将子项分配给此父项。类似于
shelf['parent:'+object._id] = object
的代码在工具架中创建父对象。 - 我们可以更新或检索此父项,而不对子项产生任何影响。我们可以在作业右侧执行
shelf['parent:'+some_id]
来检索父项。一旦我们拥有了对象,我们就可以执行shelf['parent:'+object._id] = object
来保持一个更改。 - 删除父项可能导致以下两种行为之一。一种选择是级联删除以包括引用父级的所有子级。或者,我们可以编写代码来禁止删除仍然具有子引用的父级。两者都是明智的,选择是由问题领域强加的要求驱动的。
- 我们可以创建一个新的空父对象,为该对象分配一个新的主键。我们可以稍后将子项分配给此父项。类似于
- 考虑依赖性(或子)对象上的 CRUD 操作的以下内容:
- 我们可以创建一个引用现有父级的新子级。我们还必须决定我们想为孩子和父母使用什么样的钥匙。
- 我们可以更新、检索或删除父级之外的子级。这可能包括将子项指定给其他父项。
由于替换对象的代码与更新对象的代码相同,CRUD 处理的一半是通过简单赋值语句处理的。删除是通过del
语句完成的。删除与父级关联的子级可能涉及检索以定位子级。剩下的就是对检索过程的检查,这可能会更复杂一些。
下一节将讨论搜索、扫描和查询。
如果我们检查数据库中的所有对象并应用过滤器,则搜索可能效率低下。我们更喜欢使用更集中的项目子集。我们将在创建索引以提高效率部分中了解如何创建更有用的索引。然而,暴力扫描所有对象的后备计划总是有效的。对于很少发生的搜索,创建更高效索引所需的计算可能不值得节省时间
Don't panic; searching, scanning, and querying are synonyms. We'll use the terms interchangeably.
当一个子类具有独立样式键时,我们可以使用迭代器在键上扫描某个Child
类的所有实例。以下是定位所有子项的生成器表达式:
children = (shelf[k]
for k in shelf.keys()
if k.startswith("Child:"))
这将查看工具架中的每个键,以选择以"Child:"
开头的子集。我们可以在此基础上,通过使用更复杂的生成器表达式应用更多标准:
children_by_title = (c
for c in children
if c.startswith("Child:") and c.title == "some title")
我们使用了一个嵌套的生成器表达式来扩展初始children
查询,添加了条件。像这样的嵌套生成器表达式在 Python 中非常有效。这不会对数据库进行两次扫描。这是一次扫描,有两个条件。来自内部生成器的每个结果都会反馈给外部生成器以生成结果。
当子类具有依赖样式键时,我们可以使用具有更复杂匹配规则的迭代器在工具架中搜索特定父类的子类。下面是一个生成器表达式,用于查找给定父级的所有子级:
children_of = (shelf[k]
for k in shelf.keys()
if k.startswith(parent+":Child:"))
这种依赖样式的键结构使得在一个简单循环中删除父项和所有子项特别容易:
query = (key
for key in shelf.keys()
if key.startswith(parent))
for k in query:
del shelf[k]
当使用分层"Parent:*p**id*:Child:*cid*"
键时,我们必须小心将父母与子女分开。使用此多部分键,我们将看到许多以"Parent:*pid*"
开头的对象键。其中一个键将是正确的父键,简单地说就是"Parent:*p**id*"
。其他键将是带有"Parent:*p**id*:Child:*cid*"
的子键。我们通常使用三种条件进行暴力搜索:
key.startswith(f"Parent:{pid}")
:找到父母和孩子的结合;这不是常见的要求。key.startswith(f"Parent:{pid}:Child:")
:查找给定父项的子项。startswith()
的另一种选择是正则表达式,如r"^(Parent:\d+):(Child:\d+)$"
,用于匹配键。key.startswith(f"Parent:{pid}")
和":Child:" not in key
:查找父母,不包括任何子女。另一种方法是使用正则表达式(如r"^Parent:\d+$"
)来匹配键。
所有这些查询都可以通过建立索引来优化,从而将搜索空间限制在更有意义的子集上。
让我们来看看如何设计一个访问层为
下面是应用程序如何使用shelve
。我们将看一看应用程序中编辑和保存微博帖子的部分。我们将把应用程序分为两层:应用程序层和数据层。在应用层中,我们将区分两个层:
- 应用处理:在应用层中,对象不是持久的。这些类将体现整个应用程序的行为。这些类响应用户对命令、菜单项、按钮和其他处理元素的选择。
- 问题域数据模型:这些对象将被写入书架。这些对象体现了整个应用程序的状态。
必须修改定义独立的Blog
和Post
的类,以便我们可以在 shelf 容器中单独处理它们。我们不希望通过将Blog
转换为集合类来创建单个大型容器对象。
在数据层中,根据数据存储的复杂性,可能有许多功能。我们将重点介绍这两个功能:
- 访问:这些组件提供对问题域对象的统一访问。我们将关注访问层。我们将定义一个
Access
类,该类提供对Blog
和Post
实例的访问。它还将管理钥匙以定位货架上的Blog
和Post
对象。 - 持久化:组件将问题域对象序列化并写入持久化存储。这是
shelve
模块。访问层将依赖于此。
我们将把Access
课程分成三部分。以下是第一部分,显示文件打开和关闭操作:
import shelve
from typing import cast
class Access:
def __init__ ( self ) -> None :
self .database: shelve.Shelf = cast(shelve.Shelf, None )
self .max: Dict[ str , int ] = { "Post" : 0 , "Blog" : 0 }
def new( self , path: Path) -> None :
self .database: shelve.Shelf = shelve.open( str (path), "n" )
self .max: Dict[ str , int ] = { "Post" : 0 , "Blog" : 0 }
self .sync()
def open( self , path: Path) -> None :
self .database = shelve.open( str (path), "w" )
self .max = self .database[ "_DB:max" ]
def close( self ) -> None :
if self .database:
self .database[ "_DB:max" ] = self .max
self .database.close()
self .database = cast(shelve.Shelf, None )
def sync( self ) -> None :
self .database[ "_DB:max" ] = self .max
self .database.sync()
def quit( self ) -> None :
self .close()
对于Access.new()
,我们将创建一个新的空书架。对于Access.open()
,我们将打开一个现有的书架。对于关闭和同步,我们确保将当前最大键值的小字典发布到工具架中。
cast()
函数用于通过为self.database
分配None
对象来打破类型提示。此属性的类型提示为shelve.Shelf
。cast()
函数告诉 mypy 我们完全知道None
不是Shelf
的实例。
我们还没有讨论如何实现一个Save As...
方法来复制文件。我们也没有解决在不保存选项的情况下退出以恢复到数据库文件的以前版本的问题。这些附加功能包括使用os
模块管理文件副本。
除了打开和关闭数据库的基本方法外,我们还需要在博客和帖子上执行 CRUD 操作的方法。原则上,我们有八种不同的方法。以下是使用Blog
和Post
对象更新工具架的一些方法:
def create_blog( self , blog: Blog) -> Blog:
self .max[ 'Blog' ] += 1
key = f"Blog: { self .max[ 'Blog' ] } "
blog._id = key
self .database[blog._id] = blog
return blog
def retrieve_blog( self , key: str ) -> Blog:
return self .database[key]
def create_post( self , blog: Blog, post: Post) -> Post:
self .max[ 'Post' ] += 1
post_key = f"Post: { self .max[ 'Post' ] } "
post._id = post_key
post._blog_id = blog._id
self .database[post._id] = post
return post
def retrieve_post( self , key: str ) -> Post:
return self .database[key]
def update_post( self , post: Post) -> Post:
self .database[post._id] = post
return post
def delete_post( self , post: Post) -> None :
del self .database[post._id]
我们提供了一组最小的方法来将Blog
与其相关的Post
实例放在书架上。当我们创建一个Blog
时,create_blog()
方法首先计算一个新的密钥,然后用该密钥更新Blog
对象,最后将Blog
对象保存在工具架中。我们突出显示了更改书架内容的行。简单地在工具架中设置一个项目,类似于在字典中设置一个项目,将使对象持久化。
当我们添加帖子时,我们必须提供父项Blog
,以便两者在书架上正确关联。在这种情况下,我们获取Blog
键,创建一个新的Post
键,然后用键值更新Post
。更新后的Post
可以保存在货架上。create_post()
中突出显示的线条使对象持久保存在架子上。
在不太可能的情况下,我们尝试添加一个Post
而之前没有添加父Blog
,我们将出现属性错误,因为Blog._id
属性将不可用。
我们提供了有代表性的方法来替换Post
和删除Post
。还有其他几种可能的操作;我们没有包括替换Blog
或删除Blog
的方法。当我们编写删除Blog
的方法时,我们必须解决当仍然存在Post
对象时防止删除的问题,或者级联删除以包括Post
对象。最后,有一些搜索方法充当迭代器来查询Blog
和Post
实例:
def __iter__ ( self ) -> Iterator[Union[Blog, Post]]:
for k in self .database:
if k[ 0 ] == "_" :
continue # Skip the administrative objects
yield self .database[k]
def blog_iter( self ) -> Iterator[Blog]:
for k in self .database:
if k.startswith( 'Blog:' ):
yield self .database[k]
def post_iter( self , blog: Blog) -> Iterator[Post]:
for k in self .database:
if k.startswith( 'Post:' ):
if self .database[k]._blog_id == blog._id:
yield self .database[k]
def post_title_iter( self , blog: Blog, title: str ) -> Iterator[Post]:
return (p for p in self .post_iter(blog) if p.title == title)
我们已经定义了一个默认迭代器__iter__()
,用于过滤掉键以_
开头的内部对象。到目前为止,我们只定义了一个这样的键,_DB:max
,但这种设计给我们留下了发明其他键的空间。
blog_iter()
方法迭代Blog
条目。因为数据库包含许多不同类型的对象,所以我们必须显式地丢弃不以"Blog:"
开头的条目。一个单独的索引对象通常是比这种应用于所有键的强力过滤器更好的方法。我们将在下面的编写演示脚本部分中了解这一点。
post_iter()
方法迭代特定博客中的帖子。成员资格测试基于查看每个Post
对象内部以检查_blog_id
属性。title_iter()
方法检查与特定标题匹配的帖子。这将检查工具架中的每个键,这可能是一个低效的操作。
我们还定义了一个迭代器,用于定位给定博客中具有请求标题的帖子,post_title_iter()
。这是一个简单的生成器函数,使用post_iter()
方法函数,只返回匹配的标题。
在下一节中,我们将编写一个演示脚本。
我们将使用一个技术 spike 向您展示应用程序如何使用这个Access
类来处理微博对象。spike 脚本将把一些Blog
和Post
对象保存到数据库中,以显示应用程序可能使用的操作序列。这个演示脚本可以扩展为单元测试用例。
更完整的单元测试将向我们展示所有的特性都存在并且工作正常。这个小脚本向我们展示了Access
是如何工作的:
from contextlib import closing
from pathlib import Path
path = Path.cwd() / "data" / "ch11_blog"
with closing(Access()) as access:
access.new(path)
# Create Example access.create_blog(b1)
for post in p2, p3:
access.create_post(b1, post)
# Retrieve Example
b = access.retrieve_blog(b1._id)
print(b._id, b)
for p in access.post_iter(b):
print(p._id, p)
我们已经在访问层上创建了Access
类,以便将其包装在上下文管理器中。目标是确保访问层正确关闭,而不考虑可能引发的任何异常。
通过Access.new()
,我们创建了一个名为'blog'
的新书架。这可以通过 GUI 导航到文件| New 来完成。我们将新博客b1
添加到书架上。Access.create_blog()
方法将使用其 shelf 键更新Blog
对象。也许有人在页面上填空,然后在 GUI 应用程序上单击新博客。
一旦我们添加了Blog
,我们就可以添加两个帖子。父Blog
项中的密钥将用于为每个子Post
项构建密钥。同样,这个想法是用户填写一些字段并单击 GUI 上的新帖子。
还有最后一组查询,用于从工具架中转储键和对象。这向我们展示了这个脚本的最终结果。我们可以执行Access.retrieve_blog()
来检索创建的博客条目。我们可以使用Access.post_iter()
对该博客中的帖子进行迭代。
使用contextlib.closing()
上下文管理器可确保最终的Access.close()
功能评估将数据库保存到持久存储中。这也将刷新用于生成唯一密钥的self.max
字典。
下一节将讨论如何创建索引以提高效率。
效率的规则之一是避免搜索。我们前面的示例对工具架中的键使用迭代器效率很低。更强烈地说,使用搜索定义了一个低效的应用程序。我们将强调这一点。
Brute-force search is perhaps the worst possible way to work with data. Try to design indexes based on subsets or key mappings to improve performance.
为了避免搜索,我们需要创建索引,列出用户最可能想要的项目。这样可以节省您阅读整个工具架以查找项目或项目子集的时间。工具架索引不能引用 Python 对象,因为这会改变对象存储的粒度。索引将只列出键值,单独的检索是为了得到有问题的对象。这使得对象之间的导航变得间接,但仍然比对书架上的所有项目进行暴力搜索快得多。
作为索引的一个示例,我们可以在工具架中保留与每个Blog
相关联的Post
键的列表。我们可以很容易地更改add_blog()
、add_post()
和delete_post()
方法来更新相关的Blog
条目。以下是这些博客更新方法的修订版本:
class Access2(Access):
def create_post( self , blog: Blog, post: Post) -> Post:
super ().create_post(blog, post)
# Update the index; append doesn't work.
blog_index = f"_Index: { blog._id } "
self .database.setdefault(blog_index, [])
self .database[blog_index] = self .database[blog_index] + [post._id]
return post
def delete_post( self , post: Post) -> None :
super ().delete_post(post)
# Update the index.
blog_index = f"_Index: { post._blog_id } "
index_list = self .database[post._blog_id]
index_list.remove(post._id)
self .database[post._blog_id] = index_list
def post_iter( self , blog: Blog) -> Iterator[Post]:
blog_index = f"_Index: { blog._id } "
for k in self .database[blog_index]:
yield self .database[k]
大多数方法都是从Access
类继承而来,没有任何更改。我们扩展了三种方法来为给定博客创建有用的儿童索引:
create_post()
delete_post()
post_iter()
create_post()
方法使用create_post()
超类将Post
对象保存到工具架。然后使用setdefault()
确认"_Index:{blog}"
对象在货架上。此对象将是一个列表,其中包含相关帖子的键。使用以下语句更新密钥列表:
self .database[blog_index] = self .database[blog_index] + [post._id]
这是更新工具架所必需的。我们不能简单地使用self.database[blog_index].append(post._id)
。字典的这种就地更新方法对于 shelf 对象并不像预期的那样有效。相反,我们必须使用self.database[blog_index]
从货架上取回该对象。更新检索到的对象,然后使用简单的赋值语句替换工具架中的对象。
类似地,delete_post()
方法通过从拥有博客的_post_list
中删除未使用的帖子来保持索引最新。与create_post()
一样,对 shelf 进行了两次更新:del
语句删除Post
,然后更新Blog
对象以从相关索引中删除键。
这一变化深刻地改变了我们对Post
对象的查询。我们能够用更高效的操作替换post_iter()
中所有项目的扫描。此循环将根据保存在Blog
的_post_list
属性中的密钥快速生成Post
对象。另一个主体是此生成器表达式:
return (self.database[k] for k in blog._post_list)
对post_iter()
方法进行优化的目的是消除对所有密钥进行匹配密钥的搜索。我们将搜索所有键替换为对相关键的适当序列进行简单迭代。一个简单的定时测试,在更新Blog
和Post
以及将Blog
呈现给 RST 之间交替进行,向我们展示了以下结果:
Access Layer Access: 33.5 seconds
Access Layer Access2: 4.0 seconds
正如预期的那样,消除搜索减少了处理Blog
及其个体Posts
所需的时间。变化是深刻的:几乎 86%的处理时间浪费在搜索相关帖子上。
让我们看看如何创建缓存。
我们为每个Blog
添加了一个索引,用于定位属于Blog
的Posts
。我们还可以向工具架添加一个顶级缓存,该缓存定位所有Blog
实例的速度稍微快一点。基本设计与上一节所示类似。对于要添加或删除的每个博客,我们必须更新有效密钥的缓存。我们还必须更新迭代器以正确使用索引。下面是另一个类设计,用于协调对对象的访问:
class Access3(Access2):
def new( self , path: Path) -> None :
super ().new(path)
self .database[ "_Index:Blog" ] = list ()
def create_blog(self, blog: Blog) -> Blog:
super().create_blog(blog)
self.database["_Index:Blog"] += [blog._id]
return blog
def blog_iter( self ) -> Iterator[Blog]:
return ( self .database[k] for k in
self .database[ "_Index:Blog" ])
在创建新数据库时,我们添加了一个管理对象和一个索引,索引键为"_Index:Blog"
。该索引将是一个列表,其中包含每个Blog
条目的键。当我们添加一个新的Blog
对象时,我们也会用修改后的密钥列表更新这个"_Index:Blog"
对象。
当我们迭代Blog
帖子时,我们使用索引列表,而不是数据库中的键的强制搜索。这比使用 shelf 对象的内置keys()
方法来定位Blog
柱要快一些。
以下是测量结果:
Access Layer Access: 33.5 seconds
Access Layer Access2: 4.0 seconds
Access Layer Access3: 3.9 seconds
在下一节中,我们将学习如何添加更多索引维护。
显然,一个工具架的索引维护方面可能会增长。通过我们简单的数据模型,我们可以轻松地为Posts
的标签、日期和标题添加更多顶级索引。下面是另一个访问层实现,它为Blogs
定义了两个索引。一个索引只是列出了Blog
项的键。另一个索引提供基于Blog
标题的键。我们假设标题不是唯一的。我们将分三部分介绍这个访问层。以下是 CRUD 处理的创建部分:
class Access4(Access3):
def new( self , path: Path) -> None :
super ().new(path)
self .database[ "_Index:Blog_Title" ] = dict ()
def create_blog( self , blog):
super ().create_blog(blog)
blog_title_dict = self .database[ "_Index:Blog_Title" ]
blog_title_dict.setdefault(blog.title, [])
blog_title_dict[blog.title].append(blog._id)
self .database[ "_Index:Blog_Title" ] = blog_title_dict
return blog
我们又增加了一个索引。在本例中,有一个dict
为我们提供给定标题字符串的键列表。如果每个标题都是唯一的,那么每个列表都将是一个单例键。如果标题不是唯一的,则每个标题都有一个Blog
键列表。
当我们添加一个Blog
实例时,我们也会更新标题索引,标题索引要求我们从架子上获取已有的dict
,追加到映射到Blog's
标题的键列表中,然后将defaultdict
放回架子上。
Blog
对象的更新可能涉及更改Blog
属性的标题。如果出现标题更改,这将导致一对复杂的更新:
- 从索引中删除旧标题。由于每个标题都有一个键列表,因此此操作将从列表中删除一个键。如果列表现在为空,则可以从字典中删除整个标题条目。
- 将新标题添加到索引中。这与添加新的
Blog
对象的操作相呼应。
是否需要这种额外的复杂性?确保的唯一方法是收集应用程序实际使用的查询的实际性能详细信息。维护索引会有成本,使用索引避免搜索会节省时间。这两者之间有很好的平衡,通常需要一些数据收集和实验来确定书架的最佳用途。
让我们看一下写回索引更新的替代方案。
我们可以要求用writeback=True
打开货架。这将通过在活动内存中保留每个对象的缓存版本来跟踪对可变对象的更改。这改变了Access
类的设计。本章前面几节中所示的Access
类的示例迫使应用程序对update_blog()
和update_post()
进行方法调用,以确保对外部文件的更改被持久化。在写回模式下工作时,应用程序可以自由更改对象的值,shelf
模块将保留更改,而无需任何额外的方法调用。但是,此自动更新不会更新辅助索引,因为它们是由应用程序的访问层生成的。
在工具架不广泛使用附加索引值的应用程序中,写回模式可能是有利的。它简化了应用程序处理,减少了对复杂Access
类的需求
在使用shelve
时,我们必须解决模式演化的问题。类定义定义持久数据的模式。然而,这个类并不是绝对静态的。如果我们更改一个类定义,模式就会演变。在这次更改之后,我们将如何从书架上取回对象?一个好的设计通常包括以下技术的一些组合。
对方法的更改不会更改持久化对象表示形式。我们可以将这些更改分类为次要更改,因为搁置的数据仍然与更改的类定义兼容。一个新的软件版本可以有一个新的次要版本号,用户应该确信它可以正常工作。
对属性的更改将更改持久化对象表示。我们可以称之为这些重大更改,而搁置的数据将不再与新的类定义兼容。不应通过修改类定义来对表示进行重大更改。这些类型的更改应该通过添加一个新的子类和提供一个更新的工厂函数来创建类的任一版本的实例来实现。
我们可以灵活地支持多个版本,也可以使用一次性转换。为了灵活,我们必须依赖工厂函数来创建对象的实例。灵活的应用程序将避免直接创建对象。通过使用工厂函数,我们可以确保应用程序的所有部分都可以一致地工作。我们可以这样做以支持灵活的模式更改:
def make_blog(*args, **kw):
version = kw.pop('_version',1)
if version == 1: return Blog(*args, **kw)
elif version == 2: return Blog2(*args, **kw)
else: raise ValueError(f"Unknown Version {version}")
这种工厂函数需要一个_version
关键字参数来指定要使用的Blog
类定义。这允许我们升级模式以使用不同的类,而不会破坏我们的应用程序。Access
层可以依赖这种函数来实例化对象的正确版本。
这种灵活性的另一种选择是一次性转换。应用程序的此功能将使用旧类定义获取所有搁置对象,转换为新类定义,并以新格式将它们存储在新的工具架上。
我们了解了如何使用shelve
模块的基本知识。这包括创建工具架和设计键以访问我们放置在工具架中的对象。我们还了解需要一个访问层来在机架上执行较低级别的 CRUD 操作。其思想是,我们需要区分专注于应用程序的类定义和支持持久化的其他管理细节。
shelve
模块的优点之一是允许我们非常简单地保存不同的项目。这就增加了设计负担,以确定项目的适当粒度。粒度太细,我们浪费时间从分散在数据库中的片段组装容器对象。粒度太粗,我们会浪费时间获取和存储不相关的项目。
因为架子需要钥匙,所以我们必须为我们的物品设计合适的钥匙。我们还必须管理各种对象的密钥。这意味着使用附加属性来存储键,并可能创建附加的键集合,作为工具架上项目的索引。
用于访问shelve
数据库中项目的密钥类似于weakref
;这是间接引用。这意味着需要额外的处理来跟踪和访问引用中的项目。有关weakref
的更多信息,请参见第三章无缝集成-基本特殊方法。
键的一个选择是定位一个属性或属性组合,这些属性或属性组合是正确的主键,并且不能更改。另一种选择是生成不能更改的代理密钥;这允许更改所有其他属性。由于shelve
依赖pickle
来表示货架上的项目,因此我们有一个 Python 对象的高性能本机表示。这降低了设计要放在架子上的类的复杂性。任何 Python 对象都可以持久化。
由于使用shelve
时相对复杂,我们的应用软件必须更合理地分层。一般来说,我们将研究具有如下层次的软件体系结构:
- 表示层:顶层用户界面,可以是 web 表示,也可以是桌面 GUI。
- 应用层:使应用工作的内部服务或控制器。这可以称为处理模型,它不同于逻辑数据模型。
- 业务层或****问题域模型层:定义业务域或问题空间的对象。这有时被称为逻辑数据模型。我们已经用微博
Blog
和Post
为例,研究了如何对这些对象进行建模。 - 基础设施方面:一些应用程序包括许多交叉关注点或方面,如日志记录、安全性和网络访问。这些问题往往是普遍存在的,并跨越多个层面。
- 数据接入层。这些是访问数据对象的协议或方法。我们研究了如何设计类来从
shelve
存储访问我们的应用程序对象。 - 持久层。这是在文件存储中看到的物理数据模型。
shelve
模块实现持久化。
当查看本章和第 12 章、使用 SQLite存储和检索对象时,很明显,掌握面向对象编程涉及到一些更高层次的设计模式。我们不能简单地孤立地设计类;我们需要看看如何将课程组织成更大的结构。最后,也是最重要的,暴力搜索是一件可怕的事情;必须避免这种情况。
下一章将大致与本章平行。我们将考虑使用 SQLite 而不是shelve
来持久化对象。这有点棘手,因为 SQL 数据库不提供存储复杂 Python 对象的方法,从而导致阻抗不匹配问题。在使用关系数据库(如 SQLite)时,我们将研究两种解决此问题的方法。
在第 13 章传输和共享对象中,我们将重点从简单的持久化转移到传输和共享对象。这将依赖于我们在本章中看到的持久化,它将在混合中添加网络协议。*