10.3 设计适于存储的对象
如果对象很简单,那么把它们存入shelf很简单。对于不是复杂的容器或者集合类型的对象,我们只需要创建一个键值对映射就可以。对于更复杂的对象,通常是指包含了其他对象的对象,关于对象的访问粒度和对象间引用,我们必须做一些额外的设计。
我们会先看看简单的情况,这种情况下,需要做的只是设计一个可以用来访问对象的键。然后,会介绍一些更复杂的情况,在这些情况下,必须要考虑到对象的访问粒度和对象间的引用。
10.3.1 为我们的对象设计键
shelve(和dbm)的一个重要功能是可以即时访问大量对象中的任意一个。shelve使用了一个类似于字典的映射。shelf的映射保存在持久化存储中,这样一来,我们放在shelf中的任意对象都会被序列化并保存。序列化的部分是用pickle模块完成的。
我们必须用某种键来标识已经存储在shelf中的对象,这种键会映射到对应的对象。和字典一样,会对键做快速的哈希处理。这里的哈希计算之所以快是因为键只能是一个字节串,哈希值是对这些字节的总和取模。由于Python的字符串简单地被编码为字节,因此这就意味着用字符串作为键是一种常用的方式。这和内置的dict不同,任何可变对象都可以作为键。
由于键用于定位值,因此键必须是唯一的。这就为设计类带来了一些需要考虑的因素,因为必须提供合适且唯一的键。一些情况下,问题域中会包含一个属性,这个属性就是明显的唯一的键。在那种情况下,可以简单地用这个属性创建键:shelf[object.key_attribute]= object。这是最简单的情况,但是并不通用。
在其他情况下,应用程序问题域不会为我们提供一个合适的唯一键。当对象的所有属性都是有可能变化的或者有可能不唯一时,这个问题会经常出现。例如,以美国公民为例,因为社会安全号并不是唯一的,社会安全局可以重复利用这些号码。另外,一个人可能会误报了SSN,这样应用程序就必须修改它。由于它可以被更改,因此这是它不能作为主键的第2个原因。
程序中可能会有一些非字符串类型的值可以作为主键的备选。例如,我们可能会有一个datetime对象、一个数字或者甚至将元组作为唯一标识符。在所有这些情况中,可能希望将这些值都编码为字节或者字符串。
对于没有明显主键的情形,我们可以尝试用一些值的组合创建唯一的组合键(composite key)。这并不总是一个非常好的主意,因为现在键不是原子的,对于键中任何一个部分的改变都会带来数据更新的问题。
最简单的方法通常是使用代理键(surrogate key)设计模式。这个键不依赖于对象中的数据,它是对象的一个替代品。这意味着对象的任何属性都可以被改变,并且不会带来什么副作用或者限制。Python内部的对象ID是代理键的一种示例。一个shelf键的字符串表示可以遵循这种模式:class:oid。
字符串键包含了对象所属的类和当前类示例的唯一标识符。我们可以用这种形式的键简单地将不同的类的对象保存在一个单一的shelf上。即使只准备在shelf上保存一种类型的对象,这种格式对于保存索引的命名空间、用于管理的元数据和以后的扩展都是非常有帮助的。
当我们有了一个适合的业务主键后,我们可能想做一些后面这样的事情持久化 shelf上的对象:self[object.class.name+":"+object.key_attribute]= object。
这为我们提供了一个独特的类名和唯一的键作为每个对象的简单标识符。对于代理键,需要定义为某种键的生成器。
10.3.2 为对象生成代理键
我们会用一个整数计数器生成唯一的代理键。为了保证能够正确地更新这个计数器,我们会将它和我们的其他数据都保存在shelf上。尽管Python有一个内置的对象ID,但是不应该使用Python内置的标识符作为代理键。Python内置的ID号码没有任何类型的保证。
由于我们将要在shelf上添加一些用于管理的对象,因此必须给这些对象带有特殊前缀的唯一键。我们会考虑使用_DB,这会作为一个仿制类保存在我们的shelf上。设计这些用于管理的对象时需要考虑的和设计应用程序对象类似。我们需要选择存储的粒度,有以下两种选择。
- 粗粒度(Coarse-Grained):可以创建一个单一的dict对象负责生成所有的代理键。一个类似于_DB:max这样的键就可以用于标识这个对象。在dict内部,可以将类名映射到当前使用的最大标识符值。每次创建一个新对象,我们都会从这个映射中取出一个ID赋值给该对象,然后也会替换shelf中的映射。我们会在下一节中展示粗粒度的解决方案。
- 细粒度(Fine-Grained):我们可以向数据库中添加许多项目,每个项目都包括了不同类对象的最大键值。每个这种额外的键项目都遵循_DB:max:class的格式。每个键的值都是一个整数,代表了当前已经赋值给类的最大序列标识符。
这里重要的一点是,我们分离了键的设计和应用程序中类的设计。我们可以(并且应该)让应用程序类的设计尽量简单。为了让shelve正常工作,一些必要的开销是允许的。
10.3.3 设计一个带有简单键的类
将shelve的键保存为已经在shelf上的对象的一个属性是很有帮助的。把键保存在对象中让删除或者替换对象更加容易。显然,当创建一个对象时,我们会从创建一个没有键的版本开始,直到将它保存到shelf上。一旦对象被保存在shelf上,就需要为对应的Python对象设置一个键属性,这样每个在内存中的对象都会包含一个正确的键。
获取对象时,有两种情况。我们可能想要获取一个键已知的特定对象,在这种情况下,shelf会将键映射到对应的对象。我们可能也想获取一个相关对象的集合,这些对象的键我们可能不知道,但是其他的一些属性值是已知的。这种情况下,我们会用某种搜索或者查询找出对象的键。下个小节会介绍搜索算法。
为了能够在对象中保存shelf的键,我们会为每个对象添加一个_id属性。它会维护每个保存到shelf上或者从shelf中获取的对象的键。这样的设计简化了从shelf上替换或者删除对象这样的维护操作。我们有下面几种方式可以把这个属性添加到类中。
- No:这不是类中必需的属性,这只是为了持久化产生的一个额外开销。
- Yes:这是很重要的数据,我们应该在init()中正确地初始化它。
建议不要在init()方法中定义组合键,它们不是类的基本组成部分,而只是持久化实现的一部分。一个组合键不会包含任何方法函数,例如,它永远不会作为应用程序层中业务逻辑层或者表示层的一部分。下面是Blog的高层定义。
class Blog:
def init( self, title, posts ):
self.title= title
def as_dict( self ):
return dict(
title= self.title,
underline= "="len(self.title),
)
我们只是提供了一个title属性和一点其他逻辑,可以将Blog.as_dict()方法与模板一起使用为RST标记提供字符串值。有关博客中帖子的部分会留到下一个章节中介绍。
可以用下面的方法创建一个Blog对象。
>>> b1= Blog( title="Travel Blog" )
可以用下面的方式将这个简单的对象保存在shelf上。
>>> import shelve
>>> shelf= shelve.open("blog")
>>> b1._id= 'Blog:1'
>>> shelf[b1._id]= b1
我们以创建一个新的shelf开始,文件名是"blog"。我们往Blog的实例b1中插入了键'Blog:1',然后用赋给_id属性的键将Blog对象保存到shelf上。
可以用下面的方式将对象取回。
>>> shelf['Blog:1']
<main.Blog object at 0x1007bccd0>
>>> shelf['Blog:1'].title
'Travel Blog'
>>> shelf['Blog:1']._id
'Blog:1'
>>> list(shelf.keys())
['Blog:1']
>>> shelf.close()
当调用shelf['Blog:1']时,它会从shelf上取回原始的Blog实例。正如我们从键列表中看到的,只是在shelf上保存了一个对象。由于最后关闭了shelf,因此对象会被持久化到文件系统中。我们可以退出Python命令行,然后重新启动,打开这个shelf,用定义的键获取对象,会看到对象仍然保存在shelf上。前面提到了查询的第2个用处:在不知道键的情况下定位一个元素。下面是根据一个给定标题搜索所有相关的博客的例子。
>>> shelf= shelve.open('blog')
>>> results = ( shelf[k] for k in shelf.keys() if
k.startswith('Blog:') and shelf[k].title == 'Travel Blog' )
>>> list(results)
[<main.Blog object at 0x1007bcc50>]
>>> r0= _[0]
>>> r0.title
'Travel Blog'
>>> r0._id
'Blog:1'
打开shelf访问对象,用results生成器表达式遍历shelf上的每个元素,查询出所有以'Blog:'为键的开始并且对象的title属性是'Travel Blog'的元素。
这里很重要的一点是键'Blog:1'是保存在对象本身中的。_id属性确保程序中的任何对象都有一个正确的键。现在可以修改对象,然后用原始的键来替换存在shelf上的对象。
10.3.4 为容器和集合设计类
当处理更复杂的容器或者集合时,类的设计会变得更复杂。第1个问题是容器的范围,我们必须确定shelf上对象的粒度。
当使用容器时,我们可以将整个容器作为一个单一的复杂对象保存到shelf上。在某种程度上,这种做法可能违背了在shelf上保存多个对象的初衷。保存一个巨大的容器为我们带来了粗粒度的存储结构。如果改变容器中的一个对象,那么整个容器都必须重新序列化并保存。如果可以高效地将全部对象都保存在一个单一容器中,那么为什么还要使用shelve?因此,我们必须找到一个符合程序需求的平衡点。
另外一个选择是将集合分解为独立的元素。用这种方法的话,最高层的Blog对象不再是一个合适的Python容器。父类可能会通过键的集合来获取每个子类,每个子对象可以用键获取父对象,这种键的使用方法在面向对象设计中并不常用。通常,对象只是简单地包含了指向其他对象的引用。当使用shelve(或者其他数据库)时,我们必须通过键使用间接引用。
现在每个子类都有两个键:它自己的主键和一个指向父对象主键的外键。这就带来了第2个问题,如何用字符串表示父类和它们的子类的键?
10.3.5 用外键引用对象
我们用来唯一标识一个对象的键是主键。当子对象引用父对象时,需要添加一些额外的设计。要如何格式化子对象的主键?有两种常用的设计子类主键的方法,这两种方法都基于类的对象间是何种依赖关系。
- "Child:cid":当子类可以独立于父类存在时,会考虑使用这种格式。例如,发票中的一个条目代表一个产品,即使没有这个代表产品的发票条目,这个产品依然可以存在。
- "Parent:pid:Child:cid":当子类不能脱离父类而独立存在时,我们会考虑使用这种格式。一个用户地址不能离开用户而单独存在。当子类完全依赖于父类时,子类的键可以包含父类的键来反映这种依赖关系。
与父类的设计一样,如果将主键和所有与子类有关的外键都保存起来是最简单的方法。建议不要在init()方法中初始化它们,因为它们只是持久化的一个部分。下面是Blog中的Post的通用定义。
import datetime
class Post:
def init( self, date, title, rst_text, tags ):
self.date= date
self.title= title
self.rst_text= rst_text
self.tags= tags
def as_dict( self ):
return dict(
date= str(self.date),
title= self.title,
underline= "-"*len(self.title),
rst_text= self.rst_text,
tag_text= " ".join(self.tags),
)
我们为每个 Post 都提供了一些属性。Post.as_dict()方法可以与模板一起为RST标记提供字符串值,我们避免了在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联系起来。我们会用几个步骤来完成。
1.打开shelf获取一个父类的Blog对象,我们称它为owner。
>>> import shelve
>>> shelf= shelve.open("blog")
>>> owner= shelf['Blog:1']
我们用了主键来定位owner对象。一个真实的应用程序可能会根据标题来搜索这个对象,可能也会创建索引来优化搜索的过程。我们会在下面介绍索引和搜索。
2.现在,我们可以将owner的键分配给每个Post对象,然后保存这些对象。
>>> p2._parent= owner._id
>>> p2._id= p2._parent + ':Post:2'
>>> shelf[p2._id]= p2
>>> p3._parent= owner._id
>>> p3._id= p3._parent + ':Post:3'
>>> shelf[p3._id]= p3
我们把父对象的信息保存在每个Post中。我们用父对象的信息创建主键。对于这种依赖关系的键,_parent属性的值是多余的,我们可以把它从键中删除。但是,如果我们为Posts设计了独立的键,_parent在键中就不是多余的。当我们查看这些键时,我们可以看到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,这样的做法更复杂一些。我们会单独讲解这种方法,因为通常会希望用索引来优化从父对象到子对象的路径。
10.3.6 为复杂对象设计CRUD操作
当我们将一个大集合分解为许多独立的细粒度对象时,shelf上会有许多不同类型的对象。因为它们是互相独立的对象,所以每个类都需要一系列单独的CRUD操作。在一些情况下,对象是完全独立的,一个作用于某个类的对象的操作不会影响这个对象以外的其他对象。
但是,在我们的例子中,Blog和Post对象之间有依赖关系。Post对象是某个Blog对象的子对象,并且这些子对象不能独立存在。当存在这种依赖关系时,就会有关系更复杂的操作需要设计。下面是设计时的一些考量。
- 基于独立(或者父)对象的CRUD操作。
- 我们可以创建一个全新的空父对象,并且分配一个新的主键给它。我们可以稍后再将子对象分配给这个父对象。类似于shelf['parent:'+object._id]=object这样的代码会创建父对象。
- 我们可以在不影响子对象的前提下修改或者获取父对象,在赋值语句的右边使用shelf['parent:'+some_id]获取父对象。一旦我们得到了父对象,我们可以用shelf['parent:'+object._id]= object保存修改。
- 删除一个父对象有两种方式。一种方式是级联删除所有和当前父对象相关的子对象。另一种选择是可以通过写代码来禁止删除那些仍然包含子对象的父对象。这两种方式都是合理的,我们可以根据问题域的需求做出正确的选择。
- 基于依赖(或者孩子)对象的CRUD操作。
- 我们可以创建一个引用了已经存在的父对象的子对象。我们必须处理设计键的问题,决定我们想为这些子对象使用哪种键。
- 我们可以在父对象之外更新、获取或者删除子对象。这个过程也包括将子对象分配给另外一个父对象。
由于替换对象和更新对象的代码是相同的,CRUD操作一般都可以通过简单的赋值语句来处理。删除通过del语句完成,删除与某个父对象相关的子对象可能需要一个查询操作获取这些子对象。然后,剩下的就是复杂一些的查询操作。
