返回介绍

19.1 使用动态属性转换数据

发布于 2024-02-05 21:59:47 字数 25744 浏览 0 评论 0 收藏 0

在接下来的几个示例中,我们要使用动态属性处理 O'Reilly 为 OSCON 2014 大会提供的 JSON 格式数据源。示例 19-1 是那个数据源中的 4 个记录。3

3关于这个数据源及其使用规则,请阅读“DIY: OSCON schedule”一文。那个 JSON 文件有 744KB,我写作本书时还在网上。本书代码仓库中的 oscon-schedule/data/ 目录里有个副本,文件名为 osconfeed.json

示例 19-1 osconfeed.json 文件中的记录示例;节略了部分字段的内容

{ "Schedule":
  { "conferences": [{"serial": 115 }],
  "events": [
    { "serial": 34505,
    "name": "Why Schools Don´t Use Open Source to Teach Programming",
    "event_type": "40-minute conference session",
    "time_start": "2014-07-23 11:30:00",
    "time_stop": "2014-07-23 12:10:00",
    "venue_serial": 1462,
    "description": "Aside from the fact that high school programming...",
    "website_url": "http://oscon.com/oscon2014/public/schedule/detail/34505",
    "speakers": [157509],
    "categories": ["Education"] }
  ],
  "speakers": [
    { "serial": 157509,
    "name": "Robert Lefkowitz",
    "photo": null,
    "url": "http://sharewave.com/",
    "position": "CTO",
    "affiliation": "Sharewave",
    "twitter": "sharewaveteam",
    "bio": "Robert ´r0ml´ Lefkowitz is the CTO at Sharewave, a startup..." }
  ],
  "venues": [
    { "serial": 1462,
    "name": "F151",
    "category": "Conference Venues" }
  ]
  }
}

那个 JSON 源中有 895 条记录,示例 19-1 只列出了 4 条。可以看出,整个数据集是一个 JSON 对象,里面有一个键,名为 "Schedule";这个键对应的值也是一个映像,有 4 个键: "conferences"、"events"、"speakers" 和 "venues"。这 4 个键对应的值都是一个记录列表。在示例 19-1 中,各个列表中只有一条记录。然而,在完整的数据集中,列表中有成百上千条记录。不过,"conferences" 键对应的列表中只有一条记录,如上述示例所示。这 4 个列表中的每个元素都有一个名为 "serial" 的字段,这是元素在各个列表中的唯一标识符。

我编写的第一个脚本只用于下载那个 OSCON 数据源。为了避免浪费流量,我会先检查本地有没有副本。这么做是合理的,因为 OSCON 2014 大会已经结束,数据源不会再更新。

示例 19-2 没用到元编程,几乎所有代码的作用可以用这一个表达式概括:json.load(fp)。不过,这样足以处理那个数据集了。osconfeed.load 函数会在后面几个示例中用到。

示例 19-2 osconfeed.py:下载 osconfeed.json(doctest 在示例 19-3 中)

from urllib.request import urlopen
import warnings
import os
import json

URL = 'http://www.oreilly.com/pub/sc/osconfeed'
JSON = 'data/osconfeed.json'


def load():
  if not os.path.exists(JSON):
    msg = 'downloading {} to {}'.format(URL, JSON)
    warnings.warn(msg)  ➊
    with urlopen(URL) as remote, open(JSON, 'wb') as local:  ➋
      local.write(remote.read())

  with open(JSON) as fp:
    return json.load(fp)  ➌

❶ 如果需要下载,就发出提醒。

❷ 在 with 语句中使用两个上下文管理器(从 Python 2.7 和 Python 3.1 起允许这么做),分别用于读取和保存远程文件。

❸ json.load 函数解析 JSON 文件,返回 Python 原生对象。在这个数据源中有这几种数据类型:dict、list、str 和 int。

有了示例 19-2 中的代码,我们可以审查数据源中的任何字段,如示例 19-3 所示。

示例 19-3 osconfeed.py:示例 19-2 的 doctest

  >>> feed = load()  ➊
  >>> sorted(feed['Schedule'].keys())  ➋
  ['conferences', 'events', 'speakers', 'venues']
  >>> for key, value in sorted(feed['Schedule'].items()):
  ...   print('{:3} {}'.format(len(value), key))  ➌
  ...
    1 conferences
  494 events
  357 speakers
   53 venues
  >>> feed['Schedule']['speakers'][-1]['name']  ➍
  'Carina C. Zona'
  >>> feed['Schedule']['speakers'][-1]['serial']  ➎
  141590
  >>> feed['Schedule']['events'][40]['name']
  'There *Will* Be Bugs'
  >>> feed['Schedule']['events'][40]['speakers']  ➏
  [3471, 5199]

❶ feed 的值是一个字典,里面嵌套着字典和列表,存储着字符串和整数。

❷ 列出 "Schedule" 键中的 4 个记录集合。

❸ 显示各个集合中的记录数量。

❹ 深入嵌套的字典和列表,获取最后一个演讲者的名字。

❺ 获取那位演讲者的编号。

❻ 每个事件都有一个 'speakers' 字段,列出 0 个或多个演讲者的编号。

19.1.1 使用动态属性访问JSON类数据

示例 19-2 十分简单,不过,feed['Schedule']['events'][40]['name'] 这种句法很冗长。在 JavaScript 中,可以使用 feed.Schedule.events[40].name 获取那个值。在 Python 中,可以实现一个近似字典的类(网上有大量实现)4,达到同样的效果。我自己实现了 FrozenJSON 类,比大多数实现都简单,因为只支持读取,即只能访问数据。不过,这个类能递归,自动处理嵌套的映射和列表。

4最常提到的一个实现是 AttrDict,还有一个实现能快速创建嵌套的映射——addict

示例 19-4 演示 FrozenJSON 类的用法,源代码在示例 19-5 中。

示例 19-4  示例 19-5 定义的 FrozenJSON 类能读取属性,如 name,还能调用方法,如 .keys() 和 .items()

>>> from osconfeed import load
>>> raw_feed = load()
>>> feed = FrozenJSON(raw_feed)  ➊
>>> len(feed.Schedule.speakers)  ➋
357
>>> sorted(feed.Schedule.keys())  ➌
['conferences', 'events', 'speakers', 'venues']
>>> for key, value in sorted(feed.Schedule.items()):  ➍
...   print('{:3} {}'.format(len(value), key))
...
  1 conferences
494 events
357 speakers
 53 venues
>>> feed.Schedule.speakers[-1].name  ➎
'Carina C. Zona'
>>> talk = feed.Schedule.events[40]
>>> type(talk)  ➏
<class 'explore0.FrozenJSON'>
>>> talk.name
'There *Will* Be Bugs'
>>> talk.speakers  ➐
[3471, 5199]
>>> talk.flavor  ➑
Traceback (most recent call last):
  ...
KeyError: 'flavor'

❶ 传入嵌套的字典和列表组成的 raw_feed,创建一个 FrozenJSON 实例。

❷ FrozenJSON 实例能使用属性表示法遍历嵌套的字典;这里,我们获取演讲者列表的元素数量。

❸ 也可以使用底层字典的方法,例如 .keys(),获取记录集合的名称。

❹ 使用 items() 方法获取各个记录集合及其内容,然后显示各个记录集合中的元素数量。

❺ 列表,例如 feed.Schedule.speakers,仍是列表;但是,如果里面的元素是映射,会转换成 FrozenJSON 对象。

❻ events 列表中的 40 号元素是一个 JSON 对象,现在则变成一个 FrozenJSON 实例。

❼ 事件记录中有一个 speakers 列表,列出演讲者的编号。

❽ 读取不存在的属性会抛出 KeyError 异常,而不是通常抛出的 AttributeError 异常。

FrozenJSON 类的关键是 __getattr__ 方法。我们在 10.5 节的 Vector 示例中用过这个方法,那时用于通过字母获取 Vector 对象的分量(例如 v.x、v.y、v.z)。我们要记住重要的一点,仅当无法使用常规的方式获取属性(即在实例、类或超类中找不到指定的属性),解释器才会调用特殊的 __getattr__ 方法。

示例 19-4 的最后一行揭露了这个实现的一个小问题:理论上,尝试读取不存在的属性应该抛出 AttributeError 异常。其实,一开始我对这个异常做了处理,但是 __getattr__ 方法的代码量增加了一倍,而且偏离了我最想展示的重要逻辑,因此为了教学,后来我把那部分代码去掉了。

如示例 19-5 所示,FrozenJSON 类只有两个方法(__init__ 和 __getattr__)和一个实例属性 __data。因此,尝试获取其他属性会触发解释器调用 __getattr__ 方法。这个方法首先查看 self.__data 字典有没有指定名称的属性(不是键),这样 FrozenJSON 实例便可以处理字典的所有方法,例如把 items 方法委托给 self.__data.items() 方法。如果 self.__data 没有指定名称的属性,那么 __getattr__ 方法以那个名称为键,从 self.__data 中获取一个元素,传给 FrozenJSON.build 方法。这样就能深入 JSON 数据的嵌套结构,使用类方法 build 把每一层嵌套转换成一个 FrozenJSON 实例。

示例 19-5  explore0.py:把一个 JSON 数据集转换成一个嵌套着 FrozenJSON 对象、列表和简单类型的 FrozenJSON 对象

from collections import abc


class FrozenJSON:
  """一个只读接口,使用属性表示法访问JSON类对象
  """

  def __init__(self, mapping):
    self.__data = dict(mapping)  ➊

  def __getattr__(self, name):  ➋
    if hasattr(self.__data, name):
      return getattr(self.__data, name)  ➌
    else:
      return FrozenJSON.build(self.__data[name])  ➍

  @classmethod
  def build(cls, obj):  ➎
    if isinstance(obj, abc.Mapping):  ➏
      return cls(obj)
    elif isinstance(obj, abc.MutableSequence):  ➐
      return [cls.build(item) for item in obj]
    else:  ➑
      return obj

❶ 使用 mapping 参数构建一个字典。这么做有两个目的:(1) 确保传入的是字典(或者是能转换成字典的对象);(2) 安全起见,创建一个副本。

❷ 仅当没有指定名称(name)的属性时才调用 __getattr__ 方法。

❸ 如果 name 是实例属性 __data 的属性,返回那个属性。调用 keys 等方法就是通过这种方式处理的。

❹ 否则,从 self.__data 中获取 name 键对应的元素,返回调用 FrozenJSON.build() 方法得到的结果。5

5这一行中的 self.__data[name] 表达式可能抛出 KeyError 异常。我们应该处理这个异常,抛出 AttributeError 异常,因为这才是 __getattr__ 方法应该抛出的异常种类。建议勤奋的读者实现错误处理代码,当作一个练习。

❺ 这是一个备选构造方法,@classmethod 装饰器经常这么用。

❻ 如果 obj 是映射,那就构建一个 FrozenJSON 对象。

❼ 如果是 MutableSequence 对象,必然是列表,6 因此,我们把 obj 中的每个元素递归地传给 .build() 方法,构建一个列表。

6数据源是 JSON 格式,而在 JSON 中,只有字典和列表是集合类型。

❽ 如果既不是字典也不是列表,那么原封不动地返回元素。

注意,我们没有缓存或转换原始数据源。在迭代数据源的过程中,嵌套的数据结构不断被转换成 FrozenJSON 对象。这么做没问题,因为数据集不大,而且这个脚本只用于访问或转换数据。

从随机源中生成或仿效动态属性名的脚本都必须处理一个问题:原始数据中的键可能不适合作为属性名。下一节处理这个问题。

19.1.2 处理无效属性名

FrozenJSON 类有个缺陷:没有对名称为 Python 关键字的属性做特殊处理。比如说像下面这样构建一个对象:

>>> grad = FrozenJSON({'name': 'Jim Bo', 'class': 1982})

此时无法读取 grad.class 的值,因为在 Python 中 class 是保留字:

>>> grad.class
  File "<stdin>", line 1
  grad.class
       ^
SyntaxError: invalid syntax

当然,可以这么做:

>>> getattr(grad, 'class')
1982

但是,FrozenJSON 类的目的是为了便于访问数据,因此更好的方法是检查传给 FrozenJSON.__init__ 方法的映射中是否有键的名称为关键字,如果有,那么在键名后加上 _,然后通过下述方式读取:

>>> grad.class_
1982

为此,我们可以把示例 19-5 中只有一行代码的 __init__ 方法改成示例 19-6 中的版本。

示例 19-6 explore1.py:在名称为 Python 关键字的属性后面加上 _

def __init__(self, mapping):
  self.__data = {}
  for key, value in mapping.items():
    if keyword.iskeyword(key):  ➊
      key += '_'
    self.__data[key] = value

➊ keyword.iskeyword(...) 正是我们所需的函数;为了使用它,必须导入 keyword 模块;这个代码片段没有列出导入语句。

如果 JSON 对象中的键不是有效的 Python 标识符,也会遇到类似的问题:

>>> x = FrozenJSON({'2be':'or not'})
>>> x.2be
  File "<stdin>", line 1
  x.2be
    ^
SyntaxError: invalid syntax

这种有问题的键在 Python 3 中易于检测,因为 str 类提供的 s.isidentifier() 方法能根据语言的语法判断 s 是否为有效的 Python 标识符。但是,把无效的标识符变成有效的属性名却不容易。对此,有两个简单的解决方法,一个是抛出异常,另一个是把无效的键换成通用名称,例如 attr_0、attr_1,等等。为了简单起见,我将忽略这个问题。

对动态属性的名称做了一些处理之后,我们要分析 FrozenJSON 类的另一个重要功能——类方法 build 的逻辑。这个方法把嵌套结构转换成 FrozenJSON 实例或 FrozenJSON 实例列表,因此 __getattr__ 方法使用这个方法访问属性时,能为不同的值返回不同类型的对象。

除了在类方法中实现这样的逻辑之外,还可以在特殊的 __new__ 方法中实现,如下一节所述。

19.1.3 使用 __new__ 方法以灵活的方式创建对象

我们通常把 __init__ 称为构造方法,这是从其他语言借鉴过来的术语。其实,用于构建实例的是特殊方法 __new__:这是个类方法(使用特殊方式处理,因此不必使用 @classmethod 装饰器),必须返回一个实例。返回的实例会作为第一个参数(即 self)传给 __init__ 方法。因为调用 __init__ 方法时要传入实例,而且禁止返回任何值,所以 __init__ 方法其实是“初始化方法”。真正的构造方法是 __new__。我们几乎不需要自己编写 __new__ 方法,因为从 object 类继承的实现已经足够了。

刚才说明的过程,即从 __new__ 方法到 __init__ 方法,是最常见的,但不是唯一的。__new__ 方法也可以返回其他类的实例,此时,解释器不会调用 __init__ 方法。

也就是说,Python 构建对象的过程可以使用下述伪代码概括:

# 构建对象的伪代码
def object_maker(the_class, some_arg):
  new_object = the_class.__new__(some_arg)
  if isinstance(new_object, the_class):
    the_class.__init__(new_object, some_arg)
  return new_object

# 下述两个语句的作用基本等效
x = Foo('bar')
x = object_maker(Foo, 'bar')

示例 19-7 是 FrozenJSON 类的另一个版本,把之前在类方法 build 中的逻辑移到了 __new__ 方法中。

示例 19-7 explore2.py:使用 __new__ 方法取代 build 方法,构建可能是也可能不是 FrozenJSON 实例的新对象

from collections import abc


class FrozenJSON:
  """一个只读接口,使用属性表示法访问JSON类对象
  """

  def __new__(cls, arg):  ➊
    if isinstance(arg, abc.Mapping):
      return super().__new__(cls)  ➋
    elif isinstance(arg, abc.MutableSequence):  ➌
      return [cls(item) for item in arg]
    else:
      return arg

  def __init__(self, mapping):
    self.__data = {}
    for key, value in mapping.items():
      if iskeyword(key):
        key += '_'
      self.__data[key] = value

  def __getattr__(self, name):
    if hasattr(self.__data, name):
      return getattr(self.__data, name)
    else:
      return FrozenJSON(self.__data[name])  ➍

❶ __new__ 是类方法,第一个参数是类本身,余下的参数与 __init__ 方法一样,只不过没有 self。

❷ 默认的行为是委托给超类的 __new__ 方法。这里调用的是 object 基类的 __new__ 方法,把唯一的参数设为 FrozenJSON。

❸ __new__ 方法中余下的代码与原先的 build 方法完全一样。

❹ 之前,这里调用的是 FrozenJSON.build 方法,现在只需调用 FrozenJSON 构造方法。

__new__ 方法的第一个参数是类,因为创建的对象通常是那个类的实例。所以,在 FrozenJSON.__new__ 方法中,super().__new__(cls) 表达式会调用 object.__new__(FrozenJSON),而 object 类构建的实例其实是 FrozenJSON 实例,即那个实例的 __class__ 属性存储的是 FrozenJSON 类的引用。不过,真正的构建操作由解释器调用 C 语言实现的 object.__new__ 方法执行。

OSCON 的 JSON 数据源有一个明显的缺点:索引为 40 的事件,即名为 'There *Will* Be Bugs' 的那个,有两位演讲者,3471 和 5199,但却不容易找到他们,因为提供的是编号,而 Schedule.speakers 列表没有使用编号建立索引。此外,每条事件记录中都有 venue_serial 字段,存储的值也是编号,但是如果想找到对应的记录,那就要线性搜索 Schedule.venues 列表。接下来的任务是,调整数据结构,以便自动获取所链接的记录。

19.1.4 使用shelve模块调整OSCON数据源的结构

标准库中有个 shelve(架子)模块,这名字听起来怪怪的,可是如果知道 pickle(泡菜)是 Python 对象序列化格式的名字,还是在那个格式与对象之间相互转换的某个模块的名字,就会觉得以 shelve 命名是合理的。泡菜坛子摆放在架子上,因此 shelve 模块提供了 pickle 存储方式。

shelve.open 高阶函数返回一个 shelve.Shelf 实例,这是简单的键值对象数据库,背后由 dbm 模块支持,具有下述特点。

shelve.Shelf 是 abc.MutableMapping 的子类,因此提供了处理映射类型的重要方法。

此外,shelve.Shelf 类还提供了几个管理 I/O 的方法,如 sync 和 close;它也是一个上下文管理器。

只要把新值赋予键,就会保存键和值。

键必须是字符串。

值必须是 pickle 模块能处理的对象。

shelve(https://docs.python.org/3/library/shelve.html)、dbm(https://docs.python.org/3/library/dbm.html)和 pickle 模块(https://docs.python.org/3/library/pickle.html)的详细用法和注意事项参见文档。现在值得关注的是,shelve 模块为识别 OSCON 的日程数据提供了一种简单有效的方式。我们将从 JSON 文件中读取所有记录,将其存在一个 shelve.Shelf 对象中,键由记录类型和编号组成(例如,'event.33950' 或 'speaker.3471'),而值是我们即将定义的 Record 类的实例。

实例 19-8 是 schedule1.py 脚本的 doctest,使用 shelve 模块处理数据源。若想以交互式方式测试,要执行 python -i schedule1.py 命令运行脚本,启动加载了 schedule1 模块的控制台。主要工作由 load_db 函数完成:调用 osconfeed.load 方法(在示例 19-2 中定义)读取 JSON 数据,把通过 db 传入的 Shelf 对象中的各条记录存储为一个个 Record 实例。这样处理之后,获取演讲者的记录就容易了,例如 speaker = db['speaker.3471']。

示例 19-8 测试 schedule1.py 脚本(见示例 19-9)提供的功能

>>> import shelve
>>> db = shelve.open(DB_NAME)  ➊
>>> if CONFERENCE not in db:  ➋
...   load_db(db)  ➌
...
>>> speaker = db['speaker.3471']  ➍
>>> type(speaker)  ➎
<class 'schedule1.Record'>
>>> speaker.name, speaker.twitter  ➏
('Anna Martelli Ravenscroft', 'annaraven')
>>> db.close()  ➐

❶ shelve.open 函数打开现有的数据库文件,或者新建一个。

❷ 判断数据库是否填充的简便方法是,检查某个已知的键是否存在;这里检查的键是 conference.115,即 conference 记录(只有一个)的键。7

7也可以使用 len(db) 判断,不过,如果是大型 dbm 数据库,那就很耗费时间。

❸ 如果数据库是空的,那就调用 load_db(db),加载数据。

❹ 获取一条 speaker 记录。

❺ 它是示例 19-9 中定义的 Record 类的实例。

❻ 各个 Record 实例都有一系列自定义的属性,对应于底层 JSON 记录里的字段。

❼ 一定要记得关闭 shelve.Shelf 对象。如果可以,使用 with 块确保 Shelf 对象会关闭。8

8doctest 有个突出的弱点:无法正确地设置资源并保证将其销毁。我使用 py.test 为 schedule1.py 脚本写了很多测试,在示例 A-12 中。

schedule1.py 脚本的代码在示例 19-9 中。

示例 19-9 schedule1.py:访问保存在 shelve.Shelf 对象里的 OSCON 日程数据

import warnings

import osconfeed  ➊

DB_NAME = 'data/schedule1_db'
CONFERENCE = 'conference.115'


class Record:
  def __init__(self, **kwargs):
    self.__dict__.update(kwargs)  ➋


def load_db(db):
  raw_data = osconfeed.load()  ➌
  warnings.warn('loading ' + DB_NAME)
  for collection, rec_list in raw_data['Schedule'].items():  ➍
    record_type = collection[:-1] ➎
    for record in rec_list:
      key = '{}.{}'.format(record_type, record['serial'])  ➏
      record['serial'] = key  ➐
      db[key] = Record(**record)  ➑

❶ 加载示例 19-2 中的 osconfeed.py 模块。

❷ 这是使用关键字参数传入的属性构建实例的常用简便方式(详情参见下文)。

❸ 如果本地没有副本,从网上下载 JSON 数据源。

❹ 迭代集合(例如 'conferences'、'events',等等)。

❺ record_type 的值是去掉尾部 's' 后的集合名(即把 'events' 变成 'event')。

❻ 使用 record_type 和 'serial' 字段构成 key。

❼ 把 'serial' 字段的值设为完整的键。

❽ 构建 Record 实例,存储在数据库中的 key 键名下。

Record.__init__ 方法展示了一个流行的 Python 技巧。我们知道,对象的 __dict__ 属性中存储着对象的属性——前提是类中没有声明 __slots__ 属性,如 9.8 节所述。因此,更新实例的 __dict__ 属性,把值设为一个映射,能快速地在那个实例中创建一堆属性。9

9顺便说一下,2001 年 Alex Martelli 在“The simple but handy‘collector of a bunch of named stuff’class”诀窍中分享这个技巧时使用的类名是 Bunch。

 我不会重述 19.1.2 节讨论的细节,不过要知道,在某些应用中,Record 类可能要处理不能作为属性名使用的键。

示例 19-9 中定义的 Record 类太简单了,因此你可能会问,为什么之前没用,而是使用更复杂的 FrozenJSON 类。原因有两个。第一,FrozenJSON 类要递归转换嵌套的映射和列表;而 Record 类不需要这么做,因为转换好的数据集中没有嵌套的映射和列表,记录中只有字符串、整数、字符串列表和整数列表。第二,FrozenJSON 类要访问内嵌的 __data 属性(值是字典,用于调用 keys 等方法),而现在我们也不需要这么做了。

 Python 标准库中至少有两个与 Record 类似的类,其实例可以有任意个属性,由传给构造方法的关键字参数构建——multiprocessing.Namespace 类 [ 文档源码] 和 argparse.Namespace 类 [ 文档源码]。我之所以自己实现 Record,是为了说明一个重要的做法:在 __init__ 方法中更新实例的 __dict__ 属性。

像上面那样调整日程数据集之后,我们可以扩展 Record 类,让它提供一个有用的服务:自动获取 event 记录引用的 venue 和 speaker 记录。这与 Django ORM 访问 models.ForeignKey 字段时所做的事类似:得到的不是键,而是链接的模型对象。在下一个示例中,我们要使用特性来实现这个服务。

19.1.5 使用特性获取链接的记录

下一个版本的目标是,对于从 Shelf 对象中获取的 event 记录来说,读取它的 venue 或 speakers 属性时返回的不是编号,而是完整的记录对象。用法如示例 19-10 中的交互代码片段所示。

示例 19-10 摘自 schedule2.py 脚本的 doctest

>>> DbRecord.set_db(db)  ➊
>>> event = DbRecord.fetch('event.33950')  ➋
>>> event  ➌
<Event 'There *Will* Be Bugs'>
>>> event.venue  ➍
<DbRecord serial='venue.1449'>
>>> event.venue.name  ➎
'Portland 251'
>>> for spkr in event.speakers:  ➏
...   print('{0.serial}: {0.name}'.format(spkr))
...
speaker.3471: Anna Martelli Ravenscroft
speaker.5199: Alex Martelli

❶ DbRecord 类扩展 Record 类,添加对数据库的支持:为了操作数据库,必须为 DbRecord 提供一个数据库的引用。

❷ DbRecord.fetch 类方法能获取任何类型的记录。

❸ 注意,event 是 Event 类的实例,而 Event 类扩展 DbRecord 类。

❹ event.venue 返回一个 DbRecord 实例。

❺ 现在,想找出 event.venue 的名称就容易了。这种自动取值是这个示例的目标。

❻ 还可以迭代 event.speakers 列表,获取表示各位演讲者的 DbRecord 对象。

图 19-1 绘出了本节要分析的几个类。

Record

__init__ 方法与 schedule1.py 脚本(见示例 19-9)中的一样;为了辅助测试,增加了 __eq__ 方法。

DbRecord

Record 类的子类,添加了 __db 类属性,用于设置和获取 __db 属性的 set_db 和 get_db 静态方法,用于从数据库中获取记录的 fetch 类方法,以及辅助调试和测试的 __repr__ 实例方法。

Event

DbRecord 类的子类,添加了用于获取所链接记录的 venue 和 speakers 属性,以及特殊的 __repr__ 方法。

图 19-1:改进的 Record 类和两个子类(DbRecordEvent)的 UML 类图

DbRecord.__db 类属性的作用是存储打开的 shelve.Shelf 数据库引用,以便在需要使用数据库的 DbRecord.fetch 方法及 Event.venue 和 Event.speakers 属性中使用。我把 __db 设为私有类属性,然后定义了普通的读值方法和设值方法,以防不小心覆盖 __db 属性的值。基于一个重要的原因,我没有使用特性去管理 __db 属性:特性是用于管理实例属性的类属性。10

10Stack Overflow 中有个题为“Class-level read only properties in Python”的问题,为类中的只读属性提供了解决方案,其中包括 Alex Martelli 提供的一个方案。这些方案要用到元类,因此学习那些方案之前可能要先读本书第 21 章。

本节的代码在本书仓库里的 schedule2.py 模块中。这个模块有 100 多行,因此我会分成几部分分析。

schedule2.py 脚本的前几个语句在示例 19-11 中。

示例 19-11 schedule2.py:导入模块,定义常量和增强的 Record 类

import warnings
import inspect  ➊

import osconfeed

DB_NAME = 'data/schedule2_db'  ➋
CONFERENCE = 'conference.115'


class Record:
  def __init__(self, **kwargs):
    self.__dict__.update(kwargs)

  def __eq__(self, other):  ➌
    if isinstance(other, Record):
      return self.__dict__ == other.__dict__
    else:
      return NotImplemented

➊ inspect 模块在 load_db 函数中使用(参见示例 19-14)。

➋ 因为要存储几个不同类的实例,所以我们要创建并使用不同的数据库文件;这里不用示例 19-9 中的 'schedule1_db',而是使用 'schedule2_db'。

➌ __eq__ 方法对测试有重大帮助。

在 Python 2 中,只有“新式”类支持特性。在 Python 2 中定义新式类的方法是,直接或间接继承 object 类。示例 19-11 中的 Record 类是一个继承体系的基类,用到了特性;因此,在 Python 2 中声明 Record 类时,开头要这么写:11

class Record(object):
  # 余下的代码……

11在 Python 3 中明确指明继承 object 类没有错,但是多余,因为现在所有类都是新式的。此例说明,与过去告别能让语言更简洁。如果要在 Python 2 和 Python 3 中运行同一段代码,应该显式继承 object 类。

接下来,schedule2.py 脚本定义了两个类——一个自定义的异常类型和 DbRecord 类,参见示例 19-12。

示例 19-12 schedule2.py:MissingDatabaseError 类和 DbRecord 类

class MissingDatabaseError(RuntimeError):
  """需要数据库但没有指定数据库时抛出。"""  ➊


class DbRecord(Record):  ➋

  __db = None  ➌

  @staticmethod  ➍
  def set_db(db):
    DbRecord.__db = db  ➎

  @staticmethod  ➏
  def get_db():
    return DbRecord.__db

  @classmethod  ➐
  def fetch(cls, ident):
    db = cls.get_db()
    try:
      return db[ident]  ➑
    except TypeError:
      if db is None:  ➒
        msg = "database not set; call '{}.set_db(my_db)'"
        raise MissingDatabaseError(msg.format(cls.__name__))
      else:  ➓
        raise

  def __repr__(self):
    if hasattr(self, 'serial'):  ⓫
      cls_name = self.__class__.__name__
      return '<{} serial={!r}>'.format(cls_name, self.serial)
    else:
      return super().__repr__()  ⓬

❶ 自定义的异常通常是标志类,没有定义体。写一个文档字符串,说明异常的用途,比只写一个 pass 语句要好。

❷ DbRecord 类扩展 Record 类。

❸ __db 类属性存储一个打开的 shelve.Shelf 数据库引用。

❹ set_db 是静态方法,以此强调不管调用多少次,效果始终一样。

❺ 即使调用 Event.set_db(my_db),__db 属性仍在 DbRecord 类中设置。

❻ get_db 也是静态方法,因为不管怎样调用,返回值始终是 DbRecord.__db 引用的对象。

❼ fetch 是类方法,因此在子类中易于定制它的行为。

❽ 从数据库中获取 ident 键对应的记录。

❾ 如果捕获到 TypeError 异常,而且 db 变量的值是 None,抛出自定义的异常,说明必须设置数据库。

❿ 否则,重新抛出 TypeError 异常,因为我们不知道怎么处理。

⓫ 如果记录有 serial 属性,在字符串表示形式中使用。

⓬ 否则,调用继承的 __repr__ 方法。

现在到这个示例的重要部分了——Event 类,如示例 19-13 所示。

示例 19-13 schedule2.py:Event 类

class Event(DbRecord):  ➊

  @property
  def venue(self):
    key = 'venue.{}'.format(self.venue_serial)
    return self.__class__.fetch(key)  ➋

  @property
  def speakers(self):
    if not hasattr(self, '_speaker_objs'):  ➌
      spkr_serials = self.__dict__['speakers']  ➍
      fetch = self.__class__.fetch  ➎
      self._speaker_objs = [fetch('speaker.{}'.format(key))
                  for key in spkr_serials]  ➏
    return self._speaker_objs  ➐

  def __repr__(self):
    if hasattr(self, 'name'):  ➑
      cls_name = self.__class__.__name__
      return '<{} {!r}>'.format(cls_name, self.name)
    else:
      return super().__repr__()  ➒

❶ Event 类扩展 DbRecord 类。

❷ 在 venue 特性中使用 venue_serial 属性构建 key,然后传给继承自 DbRecord 类的 fetch 类方法(详情参见下文)。

❸ speakers 特性检查记录是否有 _speaker_objs 属性。

❹ 如果没有,直接从 __dict__ 实例属性中获取 'speakers' 属性的值,防止无限递归,因为这个特性的公开名称也是 speakers。

❺ 获取 fetch 类方法的引用(稍后会说明这么做的原因)。

❻ 使用 fetch 获取 speaker 记录列表,然后赋值给 self._speaker_objs。

❼ 返回前面获取的列表。

❽ 如果记录有 name 属性,在字符串表示形式中使用。

❾ 否则,调用继承的 __repr__ 方法。

在示例 19-13 中的 venue 特性里,最后一行返回的是 self.__class__.fetch(key),为什么不直接使用 self.fetch(key) 呢?对这个 OSCON 数据源来说,可以使用后者,因为事件记录都没有 'fetch' 键。哪怕只有一个事件记录有名为 'fetch' 的键,那么在那个 Event 实例中,self.fetch 获取的是 fetch 字段的值,而不是 Event 继承自 DbRecord 的 fetch 类方法。这个缺陷不明显,很容易被测试忽略;在生产环境中,如果会场或演讲者记录链接到那个事件记录,获取事件记录时才会暴露出来。

 从数据中创建实例属性的名称时肯定有可能会引入缺陷,因为类属性(例如方法)可能被遮盖,或者由于意外覆盖现有的实例属性而丢失数据。这个问题可能是 Python 字典默认不能像 JavaScript 对象那样访问的主要原因。

如果 Record 类的行为更像映射,可以把动态的 __getattr__ 方法换成动态的 __getitem__ 方法,这样就不会出现由于覆盖或遮盖而引起的缺陷了。使用映射实现 Record 类或许更符合 Python 风格。可是,如果我采用那种方式,就发掘不了动态属性编程的技巧和陷阱了。

这个示例最后的代码是重写的 load_db 函数,如示例 19-14。

示例 19-14 schedule2.py:load_db 函数

def load_db(db):
  raw_data = osconfeed.load()
  warnings.warn('loading ' + DB_NAME)
  for collection, rec_list in raw_data['Schedule'].items():
    record_type = collection[:-1]  ➊
    cls_name = record_type.capitalize()  ➋
    cls = globals().get(cls_name, DbRecord)  ➌
    if inspect.isclass(cls) and issubclass(cls, DbRecord):  ➍
      factory = cls  ➎
    else:
      factory = DbRecord  ➏
    for record in rec_list:  ➐
      key = '{}.{}'.format(record_type, record['serial'])
      record['serial'] = key
      db[key] = factory(**record)  ➑

❶ 目前,与 schedule1.py 脚本(见示例 19-9)中的 load_db 函数一样。

❷ 把 record_type 变量的值首字母变成大写(例如,把 'event' 变成 'Event'),获取可能的类名。

❸ 从模块的全局作用域中获取那个名称对应的对象;如果找不到对象,使用 DbRecord。

❹ 如果获取的对象是类,而且是 DbRecord 的子类……

❺ ……把对象赋值给 factory 变量。因此,factory 的值可能是 DbRecord 的任何一个子类,具体的类取决于 record_type 的值。

❻ 否则,把 DbRecord 赋值给 factory 变量。

❼ 这个 for 循环创建 key,然后保存记录,这与之前一样,不过……

❽ ……存储在数据库中的对象由 factory 构建,factory 可能是 DbRecord 类,也可能是根据 record_type 的值确定的某个子类。

注意,只有事件类型的记录有自定义的类——Event。不过,如果定义了 Speaker 或 Venue 类,load_db 函数构建和保存记录时会自动使用这两个类,而不会使用默认的 DbRecord 类。

本章目前所举的示例是为了展示如何使用基本的工具,如 __getattr__ 方法、hasattr 函数、getattr 函数、@property 装饰器和 __dict__ 属性,来实现动态属性。

特性经常用于把公开的属性变成使用读值方法和设值方法管理的属性,且在不影响客户端代码的前提下实施业务规则,如下一节所述。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文