有了文件系统支持的 Artisan 应用程序的数据持久性,我们就可以将注意力转向系统中央办公室端的等价物了。我们将重用前面定义的BaseDataObjectABC,以确保所有数据对象功能都可以以相同的方式调用(例如,使用get方法读取数据,使用save方法写入数据),但由于底层数据存储过程在实现上有显著差异,这就是大多数相似之处将结束的地方。我们还必须决定要使用哪些数据库选项。
本章将涵盖以下主题:
- 深入分析数据库选项并为数据对象持久性选择数据库引擎
- 为预期在中央办公室运行的代码定义数据访问策略
- 为所需的数据访问和持久性设计和实现一些支持类
- 实现中央办公室所需的具体数据对象:
- 工匠
- 产品
还有一些数据访问方面的考虑,这些考虑至少会推迟一些具体的实现,我们将对此进行详细讨论。
Artisan Gateway和Central Office 应用程序都需要项目结构,这样我们就有地方放置特定于它们的代码。这一需求体现在两个故事中:
- 作为一名开发人员,我需要一个用于 Central Office 应用程序的项目,这样我就可以放置相关代码并构建应用程序
- 作为一名开发人员,我需要一个 Artisan 网关项目,这样我就有地方放置相关代码并构建服务
上述结构只能从基本项目模板开始,如下所示:
随着 Artisan Gateway 和 Central Office 应用程序中业务对象的数据持久性功能的构建,可以添加更多模块,就像 Artisan 应用程序的项目结构中一样。数据存储引擎的选择可能会对是否需要这样做产生重大影响,但就目前而言,这就足够了。
驱动 Artisan Gateway 和 Central Office 应用程序后端数据存储引擎选择的故事实际上并不要求任何特定引擎,而是该引擎需要提供的:
- 作为 HMS 中央办公室业务对象数据的使用者,我需要将业务对象数据存储在共享数据存储中,以便多个使用者可以通过事务支持/保护同时访问数据,并达到他们需要访问数据的目的。
在真实场景中,基于系统管理员愿意安装和支持的任何数量的因素,可能存在允许、鼓励或不允许的特定数据库引擎;根据业务中使用的操作系统,有哪些选项可用;以及其他可能的外部因素。也可能存在发展制约因素;可能首选的数据库在所使用的语言中没有可靠的驱动程序/库,或者数据结构要求对可行的选项有直接影响。
另一个需要考虑的问题是如何访问数据(本地访问还是通过网络访问),这在前面的场景中是有一定代表性的。在这种情况下,由于多个用户可以同时访问系统的数据,因此在多个方面,拥有一个通过内部网络访问的中央数据库(任何类型)是最简单的解决方案:
- 它将依赖于可独立安装的数据库引擎。
- 这些引擎作为预先打包的安装,不需要开发人员努力创建或维护。
- 它们的功能可以在外部进行测试,因此,可以相信它会按照预期的方式运行;因此,开发不必测试引擎,而只需与之交互。
综上所述,这些因素将允许几种选择中的一种;一个标准的、基于 SQL 的 RDBMS 可以工作,许多可用的 NoSQL 数据库引擎也可以工作。
另一个要考虑的因素是对象数据结构将如何在各种数据库选项中表示。简单的对象,例如hms_core中的Address,可以很容易地用一个表在任何 RDBMS 中表示。更复杂的对象,例如带有嵌入的Address的Artisan或具有可变大小和可变内容属性数据的Product(metadata,需要相关属性的离散表(定义关系以便检索对象的相关属性)或支持动态,结构化数据。
因为它们是在典型的 RDBMS 实现中构建的,所以关系非常简单;每个Artisan有一个地址,每个Product有零到多个metadata项,如下所示:
当我们考虑如何实现不同的数据检索过程时,并发症就开始出现,即使用可能的从{To0T0}类方法中的置换,并假设实际工作发生在数据库引擎方面:
-
获取一个
Artisan及其address或一个Product及其metadata并不太复杂;假设一个oid值,它归结为以下变化:- 获取与
oid匹配的 artisan 或 product 记录,然后将其转换为dict,以便我们可以使用from_data_dict类方法创建实例 - 对于
Artisan:获取相关address记录,将其转换为dict,并插入第一个dict,创建为address - 对于
Product:获取相关metadata记录,将返回的记录转换成键/值dict,插入第一个dict,创建为metadata
- 获取与
-
通过调用适当的
from_data_dict类方法创建实例。 -
仅基于
oid值列表获取多个实例没有太大区别;它只是从检索具有匹配oid值的所有记录开始,然后整理数据,创建并返回实例列表。事实上,如果这个过程和单个-oid过程使用相同的代码,为单个oid返回一个(或零个)对象(如果没有匹配的oid就没有结果),那么使用它就不会很可怕。 -
仅基于一个局部
criteria值获取零到多个实例,通过company_name或name分别找到Artisan或Product,这本身也并不困难。该操作的数据库端的实际过程与纯基于oid的检索有很大不同,如下所示:-
根据传递的
criteria查找所有匹配,并跟踪每个匹配的oid值 -
然后,返回由这些
oid值标识的项目
-
-
通过
address或metadata值查找项目是类似的,但它从子表中获取识别结果的oid值的初始列表。 -
从单个表、父表或子表获取多个
criteria值是另一种必须处理的排列。 -
另一种排列是从同一标准集中的父表和子表中获取
criteria值。
前面的列表显示了必须考虑的六种不同变体,假设遵守了BaseDataObject.get的意图。这些变体也没有说明如何跨相关表处理数据的更新(或删除),这增加了复杂性。
虽然可以在数据库端用 SQL 实现所有这些功能,但这样的实现会很复杂;而且,即使是这样,它仍然是一个复杂的解决方案,伴随着所有潜在的风险。
一种容易实现但会导致更多进程时间和/或内存使用的折衷方法类似于 Artisan 应用程序中采用的方法:加载对BaseDataObject.get的任何调用的所有对象,然后在代码中整理结果。随着涉及的数据集的增长,被检索和发回的数据将增加,有效检索数据所需的时间(不仅仅是一个简单的“get me objects with any theoidvalues”)请求)将需要更长的时间才能在数据库中找到并传输到应用程序。如果有足够的时间或足够的数据,它将开始受到可伸缩性问题的影响。这种方法可能是可行的,并且可能会起作用(如果时间有限),前提是可以以某种方式管理多表更新和子记录的删除。事情的更新端可能完全在应用程序代码中进行管理,相关记录的删除可以在数据库端或应用程序代码中进行管理。
另一个仍然在基于 RDBMS 的解决方案中的选择是使用支持结构化但无模式数据的引擎;例如,MySQL 和 MariaDB 具有 JSON 字段类型,允许使用非常简单的表结构表示整个 Artisan 和 Product 记录,如下所示:
如果这些 JSON 字段允许对其中的数据结构执行查询,则支持BaseDataObject.get需要提供的所有选项,并且不必担心必须管理子表。出于所有实际目的,这种特定的方法几乎涉及使用 MySQL 替代 MongoDB 之类的 document store NoSQL 数据库,但没有 document store 数据库可能已经具备的一些功能。
综上所述,对于基于 RDBMS 的数据存储来说,这是一个非常复杂的问题,可能会被认为是不利的。然而,也有一些优势,即使乍一看它们似乎并不重要。RDBMS 数据存储通常允许在一个过程中执行多个查询。因此,从多个表检索数据所涉及的多个查询可以编写为多个查询语句,作为对引擎的单个调用执行。
大多数基于 SQL 的数据库还允许编写某种预编译/准备好的功能:存储过程或用户函数;意见;而且,可能还有其他可以将大量功能从应用程序代码中移出并进入数据库的结构。这些通常执行起来更快,而且,尽管 SQL 可能不支持广泛的功能(即使在过程和函数中),但可能有足够的可用性使其值得使用。最后,也许是最重要的一点,表的强制数据结构,加上几乎任何名副其实的 RDBMS 的关系功能,允许根据需要查询系统中几乎所有的数据,同时如果数据库设计合理,则强制所有系统数据的实际数据完整性。
如果选择基于 SQL 的 RDBMS 作为对象状态数据持久化的引擎,则使用该引擎持久化其状态数据的类将需要指定以下某些(或全部)属性:
host规范:数据库所在的主机名(FQDN、机器网络名称或 IP 地址)database名称:指定主机上状态数据将被读取和写入的数据库的名称- A
user:用于连接主机上的数据库 - A
password:用于连接主机上的数据库
实例还需要能够连接到数据库,这可以通过方法(get_connection或属性(connection实现,可以延迟实例化并写入,以便在需要时删除并重新创建活动的connection)。一旦建立了连接,它还需要一种方法来执行对数据库的查询(query可能)。如果这看起来很熟悉,那是因为这正是前面讨论BaseDatabaseConnector类概念时提到的结构。
在 NoSQL 方面,所有标准 NoSQL 优势都适用,如下所示:
-
由于数据库中没有任何硬性和快速的表结构,因此不需要花费大量的开发时间来更改存储的数据结构。一旦应用程序端的数据结构发生更改,任何新的或更新的记录都将在保存时进行调整。
-
大多数 NoSQL 选项已经具备处理
BaseDataObject.get有望提供的数据检索类型的功能,而在更传统的 RDBMS 解决方案中,这种类型的数据检索具有如此巨大的潜在复杂性。这可能会减少开发时间,简化代码维护,这两者都是好事。 -
数据写入(创建和更新)过程也将更易于实现,因为在基于 RDBMS 的方法中,需要单独表或不寻常数据结构的关系刚刚消失,实际上数据写入可以一次存储整个数据结构,而且不必担心确保子表中的故障会阻止父表被写入。
在这两个选项中,NoSQL 选项感觉更易于管理,同时仍然满足数据持久性故事的所有要求。在各种 NoSQL 选项中,MongoDB 感觉它需要对数据结构进行最少的更改,因为对象数据是从数据库读取和写入的;因此,MongoDB 将是我们将使用的后端数据存储引擎。
选择了数据库引擎之后,需要做出的另一个决定是,该引擎最终将位于与 Artisan 网关和中央办公室应用程序相关的位置。这两者都需要能够从同一位置读取和写入相同的数据。由于 MongoDB 可以跨网络使用,因此数据存储几乎可以位于通过该网络可访问的任何位置(甚至与两个组件之一位于同一台机器上)。
Artisan Gateway、多个中央办公室应用程序实例和hms_sys数据库之间关系的逻辑架构透视图如下图所示(允许任意数量的应用程序实例,但仅显示三个):
从开发的角度来看,如果每个逻辑组件都有一个易于识别的物理位置,那么物理架构就不那么重要了。在开发过程中,所有这些物理位置都可以位于开发人员的本地计算机上。一旦部署,Artisan 网关服务和hms_sys数据库可能会安装到不同的机器上,或者它们可能位于同一台机器上。这种安排将允许所有应用程序实例和服务共享公共数据,从它们可能居住的任何地方读取和写入hms_sys数据库。
**# 支持数据持久化的对象
在生产系统中,数据库安装不需要某些凭据就可以进行访问,这几乎是前所未闻的,还有其他一些参数需要跨各种对象类型进行跟踪,这些对象类型的数据将保存在数据存储中。由于这些参数对于使用中的所有不同对象类型都是通用的(在大多数情况下),因此创建一个可用于收集这些参数的机制似乎是合乎逻辑的第一步。在前面的 RDBMS 勘探中指出了最可能需要的通用参数,如下所示:
hostportdatabaseuserpassword
当hms_sys部署到生产环境时,几乎肯定会将其保存在某种配置文件中,现在就将该逻辑准备就绪,而不是等到以后再执行。所有数据存储配置和连接参数都可以在单个对象实例中捕获-aDatastoreConfig:
class DatastoreConfig:
"""
Represents a set of credentials for connecting to a back-end
database engine that requires host, port, database, user, and
password values.
"""除了port属性,它只允许0到65535之间的int值(TCP/IP 连接中有效端口的正常范围),在属性 getter、setter 和 deleter 方法中没有实质性的新内容。_set_port方法的值检查非常简单,如下所示:
def _set_port(self, value:int) -> None:
if type(value) != int:
raise TypeError(
'%s.port expects an int value from 0 through 65535, '
'inclusive, but was passed "%s" (%s)' %
(self.__class__.__name__, value, type(value).__name__)
)
if value < 0 or value > 65535:
raise ValueError(
'%s.port expects an int value from 0 through 65535, '
'inclusive, but was passed "%s" (%s)' %
(self.__class__.__name__, value, type(value).__name__)
)
self._port = value__init__方法也非常简单,尽管它没有必需的参数,因为不是所有的数据库引擎都需要所有的参数,并且类是非常通用的。由于配置不完整或无效而出现的连接问题必须在相关对象级别处理:
###################################
# Object initialization #
###################################
def __init__(self,
host=None, port=None, database=None, user=None, password=None
):
"""
Object initialization.
self .............. (DatastoreConfig instance, required) The instance
to execute against
host .............. (str, optional, defaults to None) the host-name
(FQDN, machine network-name or IP address) where
the database that the instance will use to persist
state-data resides
port .............. (int [0..65535], optional, defaults to None) the
TCP/IP port on the host that the database
connection will use
database .......... (str, optional, defaults to None) the name of
the database that the instance will use to persist
state-data
user .............. (str, optional, defaults to None) the user-name
used to connect to the database that the instance
will use to persist state-data
password .......... (str, optional, defaults to None) the password
used to connect to the database that the instance
will use to persist state-data
"""由于最终将需要从文件中读取配置数据,因此定义了一个类方法(from_config)来促进这一点,如下所示:
###################################
# Class methods #
###################################
@classmethod
def from_config(cls, config_file:(str,)):
# - Use an explicit try/except instead of with ... as ...
try:
fp = open(config_file, 'r')
config_data = fp.read()
fp.close()
except (IOError, PermissionError) as error:
raise error.__class__(
'%s could not read the config-file at %s due to '
'an error (%s): %s' %
(
self.__class__.__name__, config_file,
error.__class__.__name__, error
)
)
# - For now, we'll assume that config-data is in JSON, though
# other formats might be better later on (YAML, for instance)
load_successful = False
try:
parameters = json.loads(config_data)
load_successful = True
except Exception as error:
pass
# - YAML can go here
# - .ini-file format here, maybe?
if load_successful:
try:
return cls(**parameters)
except Exception as error:
raise RuntimeError(
'%s could not load configuration-data from %s '
'due to an %s: %s' %
(
cls.__name__, config_file,
error.__class__.__name__, error
)
)
else:
raise RuntimeError(
'%s did not recognize the format of the config-file '
'at %s' % (cls.__name__, config_file)
)然后,可以将用于开发的本地 MongoDB 连接创建为DatastoreConfig的实例,并使用连接到本地数据库所需的最小参数,如下所示:
# - The local mongod service may not require user-name and password
local_mongo = DatastoreConfig(
host='localhost', port=27017, database='hms_local'
)使用pymongo库读取和写入 Mongo 数据库中的数据需要几个步骤,如下所示:
-
必须建立与 Mongo 引擎的连接(使用
pymongo.MongoClient对象)。如果 Mongo 引擎需要,实际凭证(用户名和密码)将应用于此。连接(或客户端)允许指定… -
必须指定存储数据的数据库。配置中的
database值负责指定数据库的名称,而数据库本身,一个pymongo.database.Database对象,一旦由客户端/连接返回,就允许创建… -
实际文档(记录)所在的集合(一个
pymongo.collection.Collection对象),以及所有数据访问过程实际发生的集合。
hms_sys开发的连接/数据库/收集设置的一个非常简单的功能示例可能包括以下内容:
client = pymongo.MongoClient() # Using default host and port
database = client['hms_sys'] # Databases can be requested by name
objects = database['Objects'] # The collection of Object # documents/records此时,objects对象作为 MongoCollection提供了读取、写入和删除Objects集合/表中的文档/记录的方法。
集合中文档的组织可以是非常任意的。该objects集合可用于存储同一集合中的Artisan、Product和Order状态数据文档。没有功能上的原因阻止它。然而,在足够长的一段时间内,从该集合中读取数据的速度将比从集合中读取数据的速度慢得多,例如,这些集合将相同的Artisan、Product和Order状态数据文档分组到单独的集合中,每个对象类型对应一个集合。可能还有其他考虑因素也会使这样的分组有益。保持对象的类型相同可能会使通过 GUI 工具对其进行管理变得更容易,对于命令行管理工具也可能同样有益。
综合上述所有因素,数据存储和参数在hms_sys数据存储中对象之间的最佳集成包括以下内容:
- 到公共 MongoDB 实例的一个或多个客户端连接,其凭据和参数都是可配置的,最终由配置文件控制
- 中央办公室代码库中所有对象通用的一种数据库规范,其配置与客户端安装程序使用的配置相同
- 每个对象类型有一个集合规范,它可以像使用类名一样简单
做出所有这些决定后,我们可以创建一个 ABC,中央办公室应用程序和服务对象可以从中派生,其派生方式与 Artisan 应用程序数据对象从JSONFileDataObject派生的方式大致相同,正如我们在第 12 章中看到的,将对象数据持久化为文件,-称之为HMSMongoDataObject。由于 Artisan 网关服务和中央办公室应用程序都需要使用它,因此它需要位于一个对两者都可用的包中。如果不为此目的单独创建另一个包项目,那么它的逻辑位置将位于hms_core中的一个新模块中;而且,如果遵循 Artisan 代码库中建立的命名约定,则该模块将命名为data_storage.py。
如图所示,HMSMongoDataObject和最终的中央办公室数据对象之间的关系看起来很像 Artisan 应用程序的对应关系,尽管hms_co。Order不包括在内,因为它可能需要一些我们尚未探讨的特殊考虑:
HMSMongoDataObject的实现从BaseDataObject继承开始,包括以下内容:
class HMSMongoDataObject(BaseDataObject, metaclass=abc.ABCMeta):
"""
Provides baseline functionality, interface requirements, and
type-identity for objects that can persist their state-data to
a MongoDB-based back-end data-store.
"""因为我们将使用一个DatastoreConfig对象来跟踪所有派生类的公共配置,它将成为一个类属性(_configuration,如下所示:
###################################
# Class attributes/constants #
###################################
# - Keeps track of the global configuration for data-access
_configuration = NoneMongoDB 文档在创建时有一个_id值,如果传递给普通的from_data_dict来创建类的实例,将抛出一个错误。到目前为止,在我们的任何实现中都没有_id参数,也没有理由期望一个参数出现在任何地方,因为我们使用自己的oid属性作为对象记录的唯一标识符。为了防止这种情况发生,from_data_dict需要从其对象创建过程中显式删除该_id值,或者跟踪可能存在的所有有效参数,并相应地进行过滤。在这两种选择中,后者虽然稍微复杂一些,但感觉更稳定。在from_data_dict中创建对象期间需要对数据进行更细粒度过滤的情况下(不太可能),跟踪有效参数将比修改一长串密钥删除更容易维护:
# - Keeps track of the keys allowed for object-creation from
# retrieved data
_data_dict_keys = None由于我们已经决定,任何给定类型的所有对象都应该位于一个具有有意义且相关名称的集合中,因此最简单的方法就是使用类名作为 MongoDB 集合的名称,该集合声明类实例的数据所在。但是,我们不能排除可能需要改变这一点,因此另一个允许覆盖默认行为的类属性感觉像是一个明智的预防措施:
# - Allows the default mongo-collection name (the __name__
# of the class) to be overridden. This should not be changed
# lightly, since data saved to the old collection-name will
# no longer be available!
_mongo_collection = NoneHMSMongoDataObject的性质乍一看似乎相对正常,但有一个明显的差异,乍一看可能并不明显。由于任何给定类的数据访问都集中在该类的实例上,并且创建数据库连接和集合的计算成本可能会很高,为所有数据对象类建立单一连接是一个诱人的想法,即实现将实例级连接和数据库属性的底层存储属性设置为HMSMongoDataObject的成员,而不是派生类本身或这些类的实例。
实际上,这将要求hms_sys的所有数据对象都位于同一个数据库中,并始终通过同一个 MongoDB 实例进行访问。虽然这不是一个不合理的要求,但它可能会使移动实时系统数据成为问题。为了进行这样的数据移动,可能需要关闭整个系统。作为折衷方案,每个类的connection和database属性将成为该类的成员,例如,这将允许Artisan对象数据独立于Product数据进行移动。在系统不久的将来,这可能不是一个可能的考虑因素,但如果它有可能在某个方面减少工作量,那么这并不是一个坏的妥协:
###################################
# Property-getter methods #
###################################
def _get_collection(self) -> pymongo.collection.Collection:
try:
return self.__class__._collection
except AttributeError:
# - If the class specifies a collection-name, then use that
# as the collection...
if self.__class__._mongo_collection:
self.__class__._collection = self.database[
self.__class__._mongo_collection
]
# - Otherwise, use the class-name
else:
self.__class__._collection = self.database[
self.__class__.__name__
]
return self.__class__._collection
def _get_configuration(self) -> DatastoreConfig:
return HMSMongoDataObject._configuration
def _get_connection(self) -> pymongo.MongoClient:
try:
return self.__class__._connection
except AttributeError:
# - Build the connection-parameters we need:
conn_config = []
# - host
if self.configuration.host:
conn_config.append(self.configuration.host)
# - port. Ports don't make any sense without a
# host, though, so host has to be defined first...
if self.configuration.port:
conn_config.append(self.configuration.port)
# - Create the connection
self.__class__._connection = pymongo.MongoClient(*conn_config)
return self.__class__._connection
def _get_database(self) -> pymongo.database.Database:
try:
return self.__class__._database
except AttributeError:
self.__class__._database = self.connection[
self.configuration.database
]
return self.__class__._database出于删除的目的,collection、connection和database属性的处理方式也有所不同。getter 方法检索到的实际对象是延迟实例化的(在需要时创建,以便在不使用它们时减少系统负载),并且,由于它们在首次创建之前不存在(通过引用它们),因此真正删除它们更容易,而不是将其设置为某些默认值,例如None:
###################################
# Property-deleter methods #
###################################
def _del_collection(self) -> None:
# - If the collection is deleted, then the database needs
# to be as well:
self._del_database()
try:
del self.__class__._collection
except AttributeError:
# - It may already not exist
pass
def _del_connection(self) -> None:
# - If the connection is deleted, then the collection and
# database need to be as well:
self._del_collection()
self._del_database()
try:
del self.__class__._connection
except AttributeError:
# - It may already not exist
pass
def _del_database(self) -> None:
try:
del self.__class__._database
except AttributeError:
# - It may already not exist
pass属性定义与我们过去使用的略有不同,因为这些属性可以检索或删除,但不能设置。这与只能检索(打开)或关闭(删除)数据库和集合的想法相对应。因此,它们没有定义或附加到属性本身的 setter 方法,而 configuration 属性更进一步—它是只读的:
###################################
# Instance property definitions #
###################################
collection = property(
_get_collection, None, _del_collection,
'Gets or deletes the MongoDB collection that instance '
'state-data is stored in'
)
connection = property(
_get_connection, None, _del_connection,
'Gets or deletes the database-connection that the instance '
'will use to manage its persistent state-data'
)
database = property(
_get_database, None, _del_database,
'Gets or deletes the MongoDB database that instance '
'state-data is stored in'
)
configuration = property(
_get_configuration, None, None,
'Gets, sets or deletes the configuration-data '
'(DatastoreConfig) of the instance, from HMSMongoDataObject'
)__init__方法与JSONFileDataObject的__init__方法非常相似,具有相同的参数(以及相同的原因)。但是,由于我们没有需要设置默认值的属性,所以它只需要调用自己的父构造函数,如下所示:
###################################
# Object initialization #
###################################
def __init__(self,
oid:(UUID,str,None)=None,
created:(datetime,str,float,int,None)=None,
modified:(datetime,str,float,int,None)=None,
is_active:(bool,int,None)=None,
is_deleted:(bool,int,None)=None,
is_dirty:(bool,int,None)=None,
is_new:(bool,int,None)=None,
):
"""
Object initialization.
self .............. (HMSMongoDataObject instance, required) The
instance to execute against
"""
# - Call parent initializers if needed
BaseDataObject.__init__(self,
oid, created, modified, is_active, is_deleted,
is_dirty, is_new
)
# - Perform any other initialization needed与JSONFileDataObject一样,HMSMongoDataObject的_create和_update方法是不必要的。与前面使用的 JSON 文件方法一样,MongoDB 不区分创建和更新文档。这两个进程都将简单地将所有对象数据写入文档,并在必要时创建文档。由于它们是BaseDataObject所要求的,但在本上下文中不起作用,因此相同的实现,只要在开发人员有用信息中引发错误,就足够了:
###################################
# Instance methods #
###################################
def _create(self) -> None:
"""
Creates a new state-data record for the instance in the back-end
data-store
"""
raise NotImplementedError(
'%s._create is not implemented, because the save '
'method handles all the data-writing needed for '
'the class. Use save() instead.' %
self.__class__.__name__
)
def _update(self) -> None:
"""
Updates an existing state-data record for the instance in the
back-end data-store
"""
raise NotImplementedError(
'%s._update is not implemented, because the save '
'method handles all the data-writing needed for '
'the class. Use save() instead.' %
self.__class__.__name__
)由类级别collection及其database和connection祖先支持的save的实现非常简单。我们需要获取实例的data_dict并告诉 MongoDB 连接到insert该数据。这个过程中的一个复杂因素是前面提到的标准 MongoDB_id值。如果我们只调用insert,MongoDB 引擎将不会使用_id值来识别已经存在的文档是否确实存在。这将不可避免地导致在每次更新时为现有项目创建新的文档记录(而不是替换现有文档),在每次更新时使用过期实例污染数据。
在正常情况下,最简单的解决方案是在数据写入过程中将oid属性更改为_id,在数据读取过程中将_id属性更改为oid,或者简单地将迄今为止已建立的oid属性更改为_id在迄今定义的类中。第一个选项在每个to_data_dict和from_data_dict方法中只需要一点努力,包括Artisan数据对象中已经定义的方法,但它也更容易出错,并且需要额外的测试。这是一个可行的选择,但可能不是最好的选择。将oid属性的名称全部更改为_id会更简单(实际上,这只不过是一个大范围的搜索和替换操作),但它会给类留下一个看起来像受保护的属性名称,实际上是一个公共属性。从功能上讲,这没什么大不了的,但它与 Python 代码标准背道而驰,并且不是首选选项。
另一种选择是简单地确保 MongoDB 生成的hms_sys oid属性和_id值相同。虽然这确实意味着单个文档记录的大小将增加,但这一变化并不重要——每个文档记录的大小约为 12 字节。由于这可以通过save方法的过程处理,作为保存的data_dict值的简单加法(并且在from_data_dict检索期间,作为该过程的一部分,需要忽略或以其他方式处理),因此只需要在两个地方写入或维护它。
这感觉像是一个更干净的选择,即使存储了额外的数据。那么,save的最终实施如下:
def save(self):
if self._is_new or self._is_dirty:
# - Make sure to update the modified time-stamp!
self.modified = datetime.now()
data_dict = self.to_data_dict()
data_dict['_id'] = self.oid
self.collection.insert_one(data_dict)
self._set_is_dirty(False)
self._set_is_new(False)from_data_dict中的相应更改使用了前面定义的_data_dict_keys类属性。由于_data_dict_keys可能尚未定义,但需要定义,因此检查它是否已定义并发出更详细的错误消息将使调试这些(希望很少)情况变得更容易。一旦验证完毕,传入的data_dict将被简单地过滤到仅与类的__init__方法中的参数匹配的键,并将被传递到__init__以创建相关实例:
@classmethod
def from_data_dict(cls, data_dict):
# - Assure that we have the collection of keys that are
# allowed for the class!
if cls._data_dict_keys == None:
raise AttributeError(
'%s.from_data_dict cannot be used because the %s '
'class has not specified what data-store keys are '
'allowed to be used to create new instances from '
'retrieved data. Set %s._data_dict_keys to a list '
'or tuple of argument-names present in %s.__init__' %
(cls.__name__, cls.__name__, cls.__name__, cls.__name__)
)
# - Remove any keys that aren't listed in the class'
# initialization arguments:
data_dict = dict(
[
(key, data_dict[key]) for key in data_dict.keys()
if key in cls._data_dict_keys
]
)
# - Then create and return an instance of the class
return cls(**data_dict)为了允许同时配置所有HMSMongoDataObject派生类,我们需要为此提供一个类方法。该方法实现的一个警告是,所有派生类也将具有可用的方法,但该方法会更改HMSMongoDataObject类的_configuration属性,即使该属性是从派生类调用的。可以合理地预期,调用(例如,Artisan.configure将仅为其配置数据访问Artisan对象–但这不是应该发生的,因此我们将引发一个错误,以确保在尝试时不会被忽略:
###################################
# Class methods #
###################################
@classmethod
def configure(cls, configuration:(DatastoreConfig)):
"""
Sets configuration values across all classes derived from
HMSMongoDataObject.
"""
if cls != HMSMongoDataObject:
raise RuntimeError(
'%s.configure will alter *all* MongoDB configuration, '
'not just the configuration for %s. Please use '
'HMSMongoDataObject.configure instead.' %
(cls.__name__, cls.__name__)
)
if not isinstance(configuration, DatastoreConfig):
raise TypeError(
'%s.configure expects an instance of '
'DatastoreConfig, but was passed "%s" (%s)' %
(
cls.__name__, configuration,
type(configuration).__name__
)
)
HMSMongoDataObject._configuration = configuration由于所有与数据存储交互的类方法都需要相关的连接,并且在调用之前它可能不是由实例创建的,因此使用 helper 类方法获取连接将非常有用。也可以通过创建实例来强制获取所有相关的数据存储对象,但这会让人感觉繁琐且不直观:
@classmethod
def get_mongo_collection(cls) -> pymongo.collection.Collection:
"""
Helper class-method that retrieves the relevant MongoDB collection for
data-access to state-data records for the class.
"""
# - If the collection has already been created, then
# return it, otherwise create it then return it
try:
return cls._collection
except AttributeError:
pass
if not cls._configuration:
raise RuntimeError(
'%s must be configured before the '
'use of %s.get will work. Call HMSMongoDataObject.'
'configure with a DatastoreConfig object to resolve '
'this issue' % (cls.__name__, cls.__name__)
)
# - With configuration established, we can create the
# connection, database and collection objects we need
# in order to execute the request:
# - Build the connection-parameters we need:
conn_config = []
# - host
if cls._configuration.host:
conn_config.append(cls.configuration.host)
# - port. Ports don't make any sense without a
# host, though, so host has to be defined first...
if cls._configuration.port:
conn_config.append(cls.configuration.port)
# - Create the connection
cls._connection = pymongo.MongoClient(*conn_config)
# - Create the database
cls._database = cls._connection[cls._configuration.database]
# - and the collection
if cls._mongo_collection:
cls._collection = cls._database[cls._mongo_collection]
# - Otherwise, use the class-name
else:
cls._collection = cls._database[cls.__name__]
return cls._collectiondelete类方法的实现非常简单;它归结为迭代提供的oids,并在迭代中删除每一个。由于delete与数据存储交互,是一个类方法,它调用了我们首先定义的get_mongo_collection类方法:
@classmethod
def delete(cls, *oids):
"""
Performs an ACTUAL record deletion from the back-end data-store
of all records whose unique identifiers have been provided
"""
# - First, we need the collection that we're working with:
collection = cls.get_mongo_collection()
if oids:
for oid in oids:
collection.remove({'oid':str(oid)})
@classmethod
def from_data_dict(cls, data_dict):
# - Assure that we have the collection of keys that are
# allowed for the class!
if cls._data_dict_keys == None:
from inspect import getfullargspec
argspec = getfullargspec(cls.__init__)
init_args = argspec.args
try:
init_args.remove('self')
except:
pass
try:
init_args.remove('cls')
except:
pass
print(argspec)
if argspec.varargs:
init_args.append(argspec.varargs)
if argspec.varkw:
init_args.append(argspec.varkw)
raise AttributeError(
'%s.from_data_dict cannot be used because the %s '
'class has not specified what data-store keys are '
'allowed to be used to create new instances from '
'retrieved data. Set %s._data_dict_keys to a list '
'or tuple of argument-names present in %s.__init__ '
'(%s)' %
(
cls.__name__, cls.__name__, cls.__name__,
cls.__name__, "'" + "', '".join(init_args) + "'"
)
)
# - Remove any keys that aren't listed in the class'
# initialization arguments:
data_dict = dict(
[
(key, data_dict[key]) for key in data_dict.keys()
if key in cls._data_dict_keys
]
)
# - Then create and return an instance of the class
return cls(**data_dict)The result of a failed check of _data_dict_keys is an AttributeError that includes a list of the arguments of the __init__ method of the class, using the getfullargspec function of the inspect module. Python's inspect module provides a very thorough set of functions for examining code within the code that's running. We'll take a more in-depth look at the module when we start to look at metaprogramming concepts.
HMSMongoDataObject的get方法也从确保相关collection可用开始。从结构上看,它与JSONFileDataObject中的对应项非常相似,这并不令人惊讶,因为它执行相同类型的操作,并且使用BaseDataObject中定义的相同方法签名。由于 MongoDB 的可用功能比文件系统多,因此存在一些值得注意的差异:
@classmethod
def get(cls, *oids, **criteria) -> list:
# - First, we need the collection that we're working with:
collection = cls.get_mongo_collection()
# - The first pass of the process retrieves documents based
# on oids or criteria.我们将根据存在的oids和criteria组合来处理请求,而不是尝试为pymongo的find功能(包括oids和criteria动态生成参数(可能很复杂)机制。代码中的每个分支将生成一个data_dict列表以后可以转换为对象实例列表的项。
如果提供了oids,那么初始请求将只关注这些。目前,预期使用oids的get调用通常只涉及少数oids(事实上通常只有一个),因此使用非常基本的功能获取列表中单个oid对应的每个文档就足够了,至少目前是这样:
# - We also need to keep track of whether or not to do a
# matches call on the results after the initial data-
# retrieval:
post_filter = False
if oids:
# - oid-based requests should usually be a fairly short
# list, so finding individual items and appending them
# should be OK, performance-wise.
data_dicts = [
collection.find_one({'oid':oid})
for oid in oids
]如果需要处理更长的oids集合,那么pymongo也支持这一点;因此,我们会在适当的地方留下评论,以防万一我们以后需要它:
# - If this becomes an issue later, consider changing
# it to a variant of
# collection.find({'oid':{'$in':oids}})
# (the oids argument-list may need pre-processing first)如果同时提供了oids和criteria,那么最终的对象列表将需要使用matches方法进行过滤,因此必须对criteria的存在进行监控和跟踪。如果同时提供了oids和criteria,那么我们以后需要知道,为了过滤初始结果:
if criteria:
post_filter = True如果只传递了criteria,则可以通过单个调用检索整个data_dicts集合,使用列表理解从find返回的游标中收集找到的项目:
elif criteria:
# - criteria-based items can do a find based on all criteria
# straight away
data_dicts = [
item for item in collection.find(criteria)
]如果oids和criteria都未通过,那么我们将返回所有可用的内容,如下所示:
else:
# - If there are no oids specified, and no criteria,
# the implication is that we want *all* object-records
# to be returned...
data_dicts = [
item for item in collection.find()
]生成初始data_dict后,将使用它创建对象实例的初始列表,如下所示:
# - At this point, we have data_dict values that should be
# able to create instances, so create them.
results = [
cls.from_data_dict(data_dict)
for data_dict in data_dicts
if data_dict # <-- This could be None: check it!
]而且,如果我们还需要进一步过滤这些结果(如果我们之前将post_filter设置为True,那么现在可以使用JSONFileDataObject中使用的相同过滤过程,在初始结果中调用每个对象的matches方法,如果返回True则只将其添加到最终结果列表中,如下所示:
# - If post_filter has been set to True, then the request
# was for items by oid *and* that have certain criteria
if post_filter:
results = [
obj for obj in results if obj.matches(**criteria)
]
return results此时,Artisan Gateway 和 Central Office 数据对象所需的所有基本 CRUD 操作都应该很容易实现,只需从hms_core和HMSMongoDataObject中相应的Base类派生即可:
- 创建和更新操作仍然只需调用任何实例的
save方法即可完成。 - 读取操作由
get类方法处理,该方法还允许使用相当多的功能来查找对象,尽管以后可能需要额外的功能来支持更复杂的功能。 - 删除操作由
delete类方法处理;同样,可能需要不基于oid的删除功能,但现在,这就足够了。
到目前为止,我们创建的两个数据对象实现都覆盖了BaseDataObject中所需的_create和_update方法。在这种情况下,质疑为什么要实施这些措施是公平的。对这个问题的简短回答是,到目前为止,这两个实现在数据存储级别使用相同的过程来创建和更新记录和文档。因此,根本不需要它们。如果预期hms_sys永远不需要任何其他数据库后端,我们有理由从整个代码库中删除它们。
然而,如果使用 MongoDB 的决定走了另一条路,首选(或强制)后端数据存储引擎是 RDBMS(如 Microsoft SQL Server),会发生什么?或者,更糟糕的是,如果在系统运行后强制进行此类更改,会发生什么?
撇开必须进行的数据迁移规划不谈,只关注应用程序和服务代码,这种更改需要做什么?具体来说,不需要太多。对于给定的 RDBMS API/库,通用 SQL/RDBMS 引擎 ABC(HMSSQLDataObject)可能如下所示:
class HMSSQLDataObject(BaseDataObject, metaclass=abc.ABCMeta):
"""
Provides baseline functionality, interface requirements, and
type-identity for objects that can persist their state-data to
a (GENERIC) SQL-based RDBMS back-end data-store.
"""The HMSSQLDataObject class that is shown here is by no means complete, but should serve as a reasonable starting point for building a full implementation of such a class, which connects to and uses data from any of several RDBM systems. The complete code, such as it is, can be found in the hms_core/ch-10-snippets directory of the project code.
相同的_configuration类属性可能正在使用中,用于相同的目的。_data_dict_keys类属性也可能用于将记录字段缩减为from_data_dict中的有效参数字典。由于 SQL 对于各种 CRUD 操作,或者至少对于这些 CRUD 操作的特定起点,需要存储并可供类访问,因此一个可行的选择是将它们作为类属性附加:
###################################
# Class attributes/constants #
###################################
# - Keeps track of the global configuration for data-access
_configuration = None
# - Keeps track of the keys allowed for object-creation from
# retrieved data
_data_dict_keys = None
# - SQL for various expected CRUD actions:
_sql_create = """Some SQL string goes here"""
_sql_read_oids = """Some SQL string goes here"""
_sql_read_all = """Some SQL string goes here"""
_sql_read_criteria = """Some SQL string goes here"""
_sql_update = """Some SQL string goes here"""
_sql_delete = """Some SQL string goes here"""由于用于各种 CRUD 操作的 SQL 将包括存储数据的表,并且在大多数 RDBMS 中连接到数据库的过程处理与我们 MongoDB 方法中的connection和database等价的内容,因此只需要跟踪connection本身并将其作为属性提供:
###################################
# Property-getter methods #
###################################
def _get_connection(self):
try:
return self.__class__._connection
except AttributeError:
# - Most RDBMS libraries provide a "connect" function, or
# allow the creation of a "connection" object, using the
# parameters we've named in DatastoreConfig, or simple
# variations of them, so all we need to do is connect:
self.__class__._connection = RDBMS.connect(
**self.configuration
)
return self.__class__._connection与基于 Mongo 的实现中的等价物一样,connection被延迟实例化并执行实际删除,而不是重置为默认值,如下所示:
###################################
# Property-deleter methods #
###################################
def _del_connection(self) -> None:
try:
del self.__class__._connection
except AttributeError:
# - It may already not exist
pass相关属性声明相同,如下所示:
###################################
# Instance property definitions #
###################################
connection = property(
_get_connection, None, _del_connection,
'Gets or deletes the database-connection that the instance '
'will use to manage its persistent state-data'
)对象初始化也相同,如下所示:
###################################
# Object initialization #
###################################
def __init__(self,
oid:(UUID,str,None)=None,
created:(datetime,str,float,int,None)=None,
modified:(datetime,str,float,int,None)=None,
is_active:(bool,int,None)=None,
is_deleted:(bool,int,None)=None,
is_dirty:(bool,int,None)=None,
is_new:(bool,int,None)=None,
):
"""
Object initialization.
self .............. (HMSMongoDataObject instance, required) The
instance to execute against
oid ............... (UUID|str, optional, defaults to None) The unique
identifier of the object's state-data record in the
back-end data-store
created ........... (datetime|str|float|int, optional, defaults to None)
The date/time that the object was created
modified .......... (datetime|str|float|int, optional, defaults to None)
The date/time that the object was last modified
is_active ......... (bool|int, optional, defaults to None) A flag
indicating that the object is active
is_deleted ........ (bool|int, optional, defaults to None) A flag
indicating that the object should be considered
deleted (and may be in the near future)
is_dirty .......... (bool|int, optional, defaults to None) A flag
indicating that the object's data needs to be
updated in the back-end data-store
is_new ............ (bool|int, optional, defaults to None) A flag
indicating that the object's data needs to be
created in the back-end data-store
"""
# - Call parent initializers if needed
BaseDataObject.__init__(self,
oid, created, modified, is_active, is_deleted,
is_dirty, is_new
)
# - Perform any other initialization needed显著的实质性差异主要在于处理积垢操作的方法。在BaseDataObject中实现的原始save方法被保留,并将调用_create或_update方法,具体由实例的is_dirty或is_new属性值确定。这些方法中的每一个都负责从适当的类属性获取 SQL 模板,根据需要使用当前状态数据值填充它,清理生成的 SQL,并针对连接执行它:
###################################
# Instance methods #
###################################
def _create(self):
# - The base SQL is in self.__class__._sql_create, and the
# field-values would be retrieved from self.to_data_dict():
data_dict = self.to_data_dict()
SQL = self.__class__._sql_create
# - Some process would have to add the values, if not the keys,
# into the SQL, and the result sanitized, but once that was
# done, it'd become a simple query-execution:
self.connection.execute(SQL)
def _update(self):
# - The base SQL is in self.__class__._sql_update, and the
# field-values would be retrieved from self.to_data_dict():
data_dict = self.to_data_dict()
SQL = self.__class__._sql_update
# - Some process would have to add the values, if not the keys,
# into the SQL, and the result sanitized, but once that was
# done, it'd become a simple query-execution:
self.connection.execute(SQL)Sanitizing SQL is a very important security precaution, reducing the risk of a system being vulnerable to an SQL injection attack. These attacks can compromise data confidentiality and integrity, at a minimum, and can also raise the risk of authentication and authorization compromises, perhaps even across multiple systems, depending on password policies and the enforcement of them. Most RDBMS APIs will have some mechanism for sanitizing SQL before executing it, and some will also support query parameterization that can also reduce the risk of vulnerabilities. As a basic rule of thumb, if data supplied by a user is being passed into a query, or even into a stored procedure, it should be sanitized wherever/whenever possible.
delete类方法很简单:
###################################
# Class methods #
###################################
@classmethod
def delete(cls, *oids):
# - First, we need the database-connection that we're
# working with:
connection = cls.get_connection()
SQL = cls._sql_delete % oids
# - Don't forget to sanitize it before executing it!
result_set = connection.execute(SQL)get方法背后的大多数模式和方法应该看起来很熟悉;同样,它与迄今为止创建的实现BaseDataObject所需功能的方法具有相同的签名(并打算执行相同的活动):
@classmethod
def get(cls, *oids, **criteria) -> list:
# - First, we need the database-connection that we're
# working with:
connection = cls.get_connection()
# - The first pass of the process retrieves documents based
# on oids or criteria.
# - We also need to keep track of whether or not to do a
# matches call on the results after the initial data-
# retrieval:
post_filter = False # - Records are often returned as a tuple (result_set)
# of tuples (rows) of tuples (field-name, field-value):
# ( ..., ( ('field-name', 'value' ), (...), ... ), …)处理oid请求的分支机构如下:
if oids:
# - Need to replace any placeholder values in the raw SQL
# with actual values, AND sanitize the SQL string, but
# it starts with the SQL in cls._sql_read_oids
SQL = cls._sql_read_oids
result_set = connection.execute(SQL)
if criteria:
post_filter = Truecriteria分支机构如下:
elif criteria:
# - The same sort of replacement would need to happen here
# as happens for oids, above. If the query uses just
# one criteria key/value pair initially, we can use the
# match-based filtering later to filter further as needed
key = criteria.keys()[0]
value = criteria[key]
SQL = cls._sql_read_criteria % (key, value)
result_set = connection.execute(SQL)
if len(criteria) > 1:
post_filter = True仅获取所有其他内容的默认分支如下所示:
else:
SQL = cls._sql_read_all
result_set = connection.execute(SQL)所有分支都会生成一个data_dict值列表,这些值可用于创建对象实例,尽管它们可能不会作为字典值从后端数据存储返回。
如前面的代码注释所述,查询的最低公分母结果是元组中的元组,可能类似于以下内容:
# This is the outermost tuple, collecting all of the
# rows returned into a result_set:
(
# Each tuple at this level is a single row:
(
# Each tuple at this level is a key/value pair:
('oid', '43d240cd-4c9f-44c2-a196-1c7c56068cef'),
('first_name', 'John'),
('last_name', 'Smith'),
('email', 'john@smith.com'),
# ...
),
# more rows could happen here, or not...
)如果引擎或引擎的 pythonapi 提供了一种内置机制来将返回的行转换为字典实例,那么这可能是进行转换的首选方法。如果没有任何内置功能来处理该问题,那么将嵌套元组转换为一系列字典并不困难:
# - We should have a result_set value here, so we can convert
# it from the tuple of tuples of tuples (or whatever) into
# data_dict-compatible dictionaries:
data_dicts = [
dict(
[field_tuple for field_tuple in row]
)
for row in result_set
]从这一点上看,该过程与之前的实现非常相似,在JSONFileDataObject和HMSMongoDataObject中:
# - With those, we can create the initial list of instances:
results = [
cls.from_data_dict(data_dict)
for data_dict in data_dicts
]
# - If post_filter has been set to True, then the request
# was for items by oid *and* that have certain criteria
if post_filter:
results = [
obj for obj in results if obj.matches(**criteria)
]另一个(潜在的主要)差异涉及如何处理子对象,例如Artisan对象中的products。如果需要将这些子对象作为对象获取并用它们填充父对象,假设它们使用相同的BaseDataObject派生接口,则每个子类型都将有一个与其关联的类,这些类中的每个类都将有一个get方法,并且get方法将允许oid要指定为条件的父对象的。这将允许一个如下所示的过程,用于根据需要检索和附加任何子对象(以Artisan和Product类为例):
# - Data-objects that have related child items, like the
# Artisan to Product relationship, may need to acquire
# those children here before returning the results. If
# they do, then a structure like this should work most
# of the time:
for artisan in results:
artisan._set_products(
Product.get(artisan_oid=artisan.oid)
)
return results从HMSSQLDataObject派生的最终业务/数据对象类的其他成员现在大部分都是可以预期的,因为它们也是实现从其他两个DataObjectABC 派生的最终数据对象所必需的。它们将包括to_data_dict和matches实例方法和from_data_dict类方法的具体实现,以及各种特定于类的变量(主要是_sql类属性)。
到目前为止,在基金会方面已经做了很多工作,但随着最初的中央办公室类的创建工作的进行,这些工作即将取得成效。目前,由于假设 Central Office 应用程序和 Artisan Gateway 服务将使用相同的业务对象类,并且它们需要驻留在一个公共包中,而该包不是这些代码库的包集的一部分,因此它们应该驻留在哪里的最佳选择似乎是在hms_core中组成项目:
-
它已经在
hms_core的设计计划中,作为所有其他包的构建或部署的一部分 -
虽然可以创建另一个组件项目/包,专门用于这些具体类将提供的数据访问,但对于可能只有三个类的单个模块来说,这是相当大的开销(到目前为止)
如果在将来的某个时候,需要或希望将它们移动到不同的包/项目,例如,如果决定将中央办公室应用程序的数据访问更改为对 Artisan Gateway 的 web 服务调用,则相应地移动代码并不困难,尽管这会有些繁琐。
通过直接进入其中一个混凝土类,可能更容易理解有关基础的工作将如何获得回报,因此我们现在将从hms_core.co_objects.Artisan开始。
驱动具体状态数据持久化Artisan类的故事如下:
- 作为 Artisan manager,我需要能够管理(创建、修改和删除)系统中的 Artisan,以便他们的状态和信息保持最新。
与hms_artisan等价物一样,这是关于能够管理数据,而不是围绕数据管理过程的 UI。co_objects中任何数据对象的各种运动部件将涉及以下内容:
-
对象类型的属性,该属性将源自
hms_core.business_objects中相应的Base类 -
系统中所有数据对象的数据持久性相关属性,由
HMSMongoDataObject或其父BaseDataObject提供或要求 -
具体类从其派生的任何类继承的任何抽象成员的具体实现
以具体的Artisan类为例,所涉及的关系如下图所示:
在这种特殊情况下,只需要创建一个属性(需要从HMSMongoDataObject重写的_data_dict_keys类属性)。四个实例方法中的三个(add_product和remove_product以及matches在抽象方法中有具体的实现,这些抽象方法需要它们的实现,并且可以实现为对它们起源于的类中的原始方法的调用。
从BaseDataObject派生的任何类的to_data_dict方法都必须在本地实现(这正是已开发结构的性质),但该实现只不过是创建并返回dict值。
剩下的是from_data_dict,数据对象用来从字典创建实例的类方法;反过来,这些字典由后端数据存储中的数据检索提供。在数据对象没有任何子对象的情况下,BaseDataObject提供和要求的基线方法应该简单地作为继承类方法工作。确实具有子对象属性的对象类型(例如Artisan)必须容纳这些子对象属性,这将作为BaseDataObject中原始类方法的本地重写发生。
总之,实现这些数据对象中的大多数只涉及以下内容:
-
创建
_data_dict_keys类属性,该属性可以(或多或少)从类“__init__方法”的参数列表中复制和粘贴 -
通过对
BaseDataObject中定义的方法的调用来实现matches方法,该方法执行到HMSMongoDataObject -
从头开始实施
to_data_dict -
如果需要自定义方法,则从头开始实现
from_data_dict类方法 -
创建一个只需调用相关父类
__init__方法的__init__方法
因此,对于大多数类来说,最糟糕的情况是,从零到完整、具体的实现,需要开发两个详细的方法,以及一些复制和粘贴操作。
这两种方法在hms_core.co_objects.Artisan中体现如下:
class Artisan(BaseArtisan, HMSMongoDataObject):
"""
Represents an Artisan in the context of the Central Office
applications and services
"""_data_dict_keys对象是一项相当简单的工作,如下所示:
###################################
# Class attributes/constants #
###################################
_data_dict_keys = (
'contact_name', 'contact_email', 'address', 'company_name',
'website', 'oid', 'created', 'modified', 'is_active',
'is_deleted', 'products'
)__init__方法仍然有一个相当复杂的参数列表,但它们可以完全从源类复制,除非这些源类的__init__方法有一个参数列表(*products,在这种情况下)或一个关键字参数列表(为了使__init__签名尽可能简单,已经避免了这一点):
###################################
# Object initialization #
###################################
# TODO: Add and document arguments if/as needed
def __init__(self,
contact_name:str, contact_email:str,
address:Address, company_name:str=None,
website:(str,)=None,
# - Arguments from HMSMongoDataObject
oid:(UUID,str,None)=None,
created:(datetime,str,float,int,None)=None,
modified:(datetime,str,float,int,None)=None,
is_active:(bool,int,None)=None,
is_deleted:(bool,int,None)=None,
is_dirty:(bool,int,None)=None,
is_new:(bool,int,None)=None,
*products
):
"""
Object initialization.
self .............. (Artisan instance, required) The instance to
execute against
contact_name ...... (str, required) The name of the primary contact
for the Artisan that the instance represents
contact_email ..... (str [email address], required) The email address
of the primary contact for the Artisan that the
instance represents
address ........... (Address, required) The mailing/shipping address
for the Artisan that the instance represents
company_name ...... (str, optional, defaults to None) The company-
name for the Artisan that the instance represents
oid ............... (UUID|str, optional, defaults to None) The unique
identifier of the object's state-data record in the
back-end data-store
created ........... (datetime|str|float|int, optional, defaults to None)
The date/time that the object was created
modified .......... (datetime|str|float|int, optional, defaults to None)
The date/time that the object was last modified
is_active ......... (bool|int, optional, defaults to None) A flag
indicating that the object is active
is_deleted ........ (bool|int, optional, defaults to None) A flag
indicating that the object should be considered
deleted (and may be in the near future)
is_dirty .......... (bool|int, optional, defaults to None) A flag
indicating that the object's data needs to be
updated in the back-end data-store
is_new ............ (bool|int, optional, defaults to None) A flag
indicating that the object's data needs to be
created in the back-end data-store
products .......... (BaseProduct collection) The products associated
with the Artisan that the instance represents
"""
# - Call parent initializers if needed
BaseArtisan.__init__(self,
contact_name, contact_email, address, company_name
)
HMSMongoDataObject.__init__(self,
oid, created, modified, is_active, is_deleted,
is_dirty, is_new
)
if products:
BaseArtisan._set_products(*products)
# - Perform any other initialization needed可以调用父类方法的实例方法都是一行程序,使用适当的参数返回调用父类方法的结果:
###################################
# Instance methods #
###################################
def add_product(self, product:BaseProduct) -> BaseProduct:
return Hasproducts.add_product(self, product)
def matches(self, **criteria) -> (bool,):
return HMSMongoDataObject.matches(self, **criteria)
def remove_product(self, product:BaseProduct) -> None:
return Hasproducts.remove_product(self, product)to_data_dict方法可能会让人望而生畏,但是,由于结果字典中的键序列是不相关的,因此根据它们来源的类对它们进行分组可以根据需要复制其中的几个(与数据存储相关的):
def to_data_dict(self):
return {
# - BaseArtisan-derived items
'address':self.address.to_dict() if self.address else None,
'company_name':self.company_name,
'contact_email':self.contact_email,
'contact_name':self.contact_name,
'website':self.website,
# - BaseDataObject-derived items
'created':datetime.strftime(
self.created, self.__class__._data_time_string
),
'is_active':self.is_active,
'is_deleted':self.is_deleted,
'modified':datetime.strftime(
self.modified, self.__class__._data_time_string
),
'oid':str(self.oid),
}In retrospect, it might have been a better design to provide a method or property of each of the classes that would be responsible for generating their part of a final data_dict. That would've kept the code for generating those dictionary items in a single place, at a minimum, and would've allowed the final data_dict values to be assembled from all of the parent class values for each instance.
Artisan 类的from_data_dict使用与HMSMongoDataObject中原始类方法相同的逻辑和流程,但必须考虑address属性,该属性为None或包含Address实例:
###################################
# Class methods #
###################################
@classmethod
def from_data_dict(cls, data_dict):
# - This has to be overridden because we have to pre-process
# incoming address and (maybe, eventually?) product-list
# values...
if data_dict.get('address'):
data_dict['address'] = Address.from_dict(data_dict['address'])
####### NOTE: Changes made here, for whatever reason might
# arise, may also need to be made in
# HMSMongoDataObject.from_data_dict – it's the same
####### process!
# - Assure that we have the collection of keys that are
# allowed for the class!
if cls._data_dict_keys == None:
from inspect import getfullargspec
argspec = getfullargspec(cls.__init__)
init_args = argspec.args
try:
init_args.remove('self')
except:
pass
try:
init_args.remove('cls')
except:
pass
print(argspec)
if argspec.varargs:
init_args.append(argspec.varargs)
if argspec.varkw:
init_args.append(argspec.varkw)
# FullArgSpec(varargs='products', varkw=None
raise AttributeError(
'%s.from_data_dict cannot be used because the %s '
'class has not specified what data-store keys are '
'allowed to be used to create new instances from '
'retrieved data. Set %s._data_dict_keys to a list '
'or tuple of argument-names present in %s.__init__ '
'(%s)' %
(
cls.__name__, cls.__name__, cls.__name__,
cls.__name__, "'" + "', '".join(init_args) + "'"
)
)
# - Remove any keys that aren't listed in the class'
# initialization arguments:
data_dict = dict(
[
(key, data_dict[key]) for key in data_dict.keys()
if key in cls._data_dict_keys
]
) # - Then create and return an instance of the class
return cls(**data_dict)总共有七项需要具体实现,其中只有两项无法通过调用父类的等价项或编写非常简单的代码来管理,因此实现相当轻松。
具体Product对象数据持久化对应故事如下:
- 作为产品经理,我需要能够管理系统中的产品,以便其状态和信息保持最新。
实现此场景的代码甚至比Artisan对象的代码更简单;它不需要对对象属性进行任何特殊处理,因此from_data_dict可以简单地返回到HMSMongoDataObject中定义的默认值。它也不需要任何额外的方法,因此完整的功能实现实际上可以归结为_data_dict_keys类属性和__init__、matches和to_data_dict方法,其中matches被实现为对HMSMongoDataObject.matches的调用:
class Product(BaseProduct, HMSMongoDataObject):
"""
Represents a Product in the context of the Central Office
applications and services
"""
###################################
# Class attributes/constants #
###################################
_data_dict_keys = [
'name', 'summary', 'available', 'store_available',
'description', 'dimensions', 'metadata', 'shipping_weight',
'oid', 'created', 'modified', 'is_active', 'is_deleted'
]__init__方法有一个很长的参数集,这并不奇怪:
###################################
# Object initialization #
###################################
def __init__(self,
# - Arguments from HMSMongoDataObject
name:(str,), summary:(str,), available:(bool,),
store_available:(bool,),
# - Optional arguments:
description:(str,None)=None, dimensions:(str,None)=None,
metadata:(dict,)={}, shipping_weight:(int,)=0,
# - Arguments from HMSMongoDataObject
oid:(UUID,str,None)=None,
created:(datetime,str,float,int,None)=None,
modified:(datetime,str,float,int,None)=None,
is_active:(bool,int,None)=None,
is_deleted:(bool,int,None)=None,
is_dirty:(bool,int,None)=None,
is_new:(bool,int,None)=None,
):
"""
Object initialization.
self .............. (Product instance, required) The instance to
execute against
name .............. (str, required) The name of the product
summary ........... (str, required) A one-line summary of the
product
available ......... (bool, required) Flag indicating whether the
product is considered available by the artisan
who makes it
store_available ... (bool, required) Flag indicating whether the
product is considered available on the web-
store by the Central Office
description ....... (str, optional, defaults to None) A detailed
description of the product
dimensions ........ (str, optional, defaults to None) A measurement-
description of the product
metadata .......... (dict, optional, defaults to {}) A collection
of metadata keys and values describing the
product
shipping_weight ... (int, optional, defaults to 0) The shipping-
weight of the product
oid ............... (UUID|str, optional, defaults to None) The unique
identifier of the object's state-data record in the
back-end data-store
created ........... (datetime|str|float|int, optional, defaults to None)
The date/time that the object was created
modified .......... (datetime|str|float|int, optional, defaults to None)
The date/time that the object was last modified
is_active ......... (bool|int, optional, defaults to None) A flag
indicating that the object is active
is_deleted ........ (bool|int, optional, defaults to None) A flag
indicating that the object should be considered
deleted (and may be in the near future)
is_dirty .......... (bool|int, optional, defaults to None) A flag
indicating that the object's data needs to be
updated in the back-end data-store
is_new ............ (bool|int, optional, defaults to None) A flag
indicating that the object's data needs to be
created in the back-end data-store
"""
# - Call parent initializers if needed
BaseProduct.__init__(
self, name, summary, available, store_available,
description, dimensions, metadata, shipping_weight
)
HMSMongoDataObject.__init__(self,
oid, created, modified, is_active, is_deleted,
is_dirty, is_new
)
# - Perform any other initialization neededmatches和to_data_dict的实现非常简单,如下所示:
###################################
# Instance methods #
###################################
def matches(self, **criteria) -> (bool,):
return HMSMongoDataObject.matches(self, **criteria)
def to_data_dict(self):
return {
# - BaseProduct-derived items
'available':self.available,
'description':self.description,
'dimensions':self.dimensions,
'metadata':self.metadata,
'name':self.name,
'shipping_weight':self.shipping_weight,
'store_available':self.store_available,
'summary':self.summary,
# - BaseDataObject-derived items
'created':datetime.strftime(
self.created, self.__class__._data_time_string
),
'is_active':self.is_active,
'is_deleted':self.is_deleted,
'modified':datetime.strftime(
self.modified, self.__class__._data_time_string
),
'oid':str(self.oid),
}matches方法可能需要在稍后的 Artisan Gateway 服务创建期间或在构建各种应用程序 UI 时重新检查,因为尽管它在大多数情况下都有效,但它目前不允许具有任何元数据标准的get返回结果,除非criteria是正在搜索的唯一值(未传递oids)。不过,现在值得在此进行更详细的研究,因为它展示了数据对象代码如何与 MongoDB 交互的一些方面。
首先,让我们创建一些示例Product对象并保存它们,如下所示:
# - An example product - A copper-and-emerald necklace:
product = Product(
'Necklace #1',
'Showing some Product.get aspects', True, True,
metadata={
'metal':'Copper',
'gemstone':'Emerald',
}
)
product.save()
# - Silver-and-emerald necklace:
product = Product(
'Necklace #2',
'Showing some Product.get aspects', True, True,
metadata={
'metal':'Silver',
'gemstone':'Emerald',
}
)
product.save()
# - Copper-and-sapphire necklace:
product = Product(
'Necklace #3',
'Showing some Product.get aspects', True, True,
metadata={
'metal':'Copper',
'gemstone':'Sapphire',
}
)
product.save()
# - Silver-and-sapphire necklace:
product = Product(
'Necklace #4',
'Showing some Product.get aspects', True, True,
metadata={
'metal':'Silver',
'gemstone':'Sapphire',
}
)
product.save()找到有metadata表明其由银制成且有蓝宝石宝石宝石的产品是相当简单的,尽管它要求标准规范看起来有点奇怪:
# - importing json so we can usefully print the results:
import json
criteria = {
'metadata':{
'metal':'Silver',
'gemstone':'Sapphire',
}
}将标准创建为dict允许它们作为单个关键字参数集传递给Product.get,并允许标准规范按照我们的需要进行详细说明。例如,我们可以添加其他元数据、指定产品名称或添加出现在Product的data-dict表示中的任何其他对象属性(由to_data_dict返回)。结果将以对象列表的形式返回,通过打印对象的data-dict表示,我们可以看到结果:
products = Product.get(**criteria)
print(json.dumps(
[product.to_data_dict() for product in products],
indent=4, sort_keys=True)
)执行上述代码将生成与Product匹配的数据集,即我们的银和蓝宝石项链,如下所示:
值得一提的是,通过criteria不一定是多层次的dict,即使是metadata值。在此格式中使用criteria如下:
criteria = {
'metadata.metal':'Silver',
'metadata.gemstone':'Sapphire',
}这个标准结构也同样有效。pymongo connection对象提供的底层find()方法将此类点符号规范视为对嵌套对象结构的引用,该嵌套对象结构与前面显示的dict值非常相似,并将相应地处理请求。
在这个迭代中可能有故事和任务来处理Customer和Order对象的数据持久性。这些可能与Artisan和Product对象的故事具有相同的基本形状,看起来类似于以下Order示例:
- 作为订单经理,我需要能够管理系统中的订单,以便其状态和信息保持最新。
为此,我将采取以下措施:
-
为中央办公室数据存储设计并实现一个
Order类,该类允许持久化对象数据。 -
单元测试
Order类。
通常,在一个敏捷的迭代过程中,一个故事在被包含到迭代中之前必须被接受,而被接受的过程将涉及到足够的审查和分析,以充分理解所涉及的任务,并相应地编写和计划故事和任务。然而,在这种情况下,由于对外部系统(Web Store 应用程序)以及尚未详细说明的订单受理和处理工作流有很大的依赖性,因此除了简单地实现Customer和Order类之外,没有什么可以做的,在某种程度上取决于工匠需要的数据结构,直到本次迭代才定义。
出于上述所有原因,在本次迭代中没有关于这些对象及其数据持久性的故事。为 Artisan 网关和/或中央办公室应用程序创建的最终类的数据持久性方面将作为故事的一部分进行处理,以实现订单处理工作流。然而,与此同时,我们至少可以在一个单独的文件中(在future/co_objects.py中,在本章的代码中)删除这些类的最小结构,而数据对象定义过程在我们脑海中是新鲜的,以节省以后的一些工作。
到目前为止,我们只考虑了所有数据对象需要的两个 CRUD 操作:create和read。delete业务全面核算,但尚未得到证实;然而,由于这个过程非常简单,它可以等到我们对所有东西进行单元测试,以证明所有东西都是有效的。那么,缺少的是update操作,至少部分是这样。每次save()调用都会写入数据库的各种对象文档表明,写入对象数据的过程正在进行,但我们实际上还没有尝试更新任何内容;而且,如果我们现在就尝试,它会失败(并且会默默地失败)。失败的原因很简单,可以从HMSMongoDataObject.save的代码中看出:
def save(self):
if self._is_new or self._is_dirty:
# - Make sure to update the modified time-stamp!
self.modified = datetime.now()
data_dict = self.to_data_dict()
data_dict['_id'] = self.oid
self.collection.insert_one(data_dict)
self._set_is_dirty(False)
self._set_is_new(False)简而言之,这是因为我们正在检查_is_new和_is_dirty的状态,并且仅当其中一个是True时才调用数据库写入。默认情况下,创建数据对象时,其_is_dirty标志值设置为False。如果该值没有在某个地方发生更改,当对象的属性值发生更改时,save方法将永远不会将更改后的数据集写入数据库。
至少有两种不同的方法可以解决这个问题。更复杂的解决方案是为每个具体数据对象类的每个属性重新定义每个 setter 和 deleter 方法,以及它们各自的属性声明,以便这些方法调用它们的父方法和实例的_set_is_dirty方法。这是 Artisan 项目中对应对象所采用的方法。请参阅以下代码片段,其中使用了Product.name属性作为示例:
def _set_name(self, value):
BaseProduct._set_name(self, value)
self._set_is_dirty(True)
# ...
def _del_name(self):
BaseProduct._del_name(self)
self._set_is_dirty(True)
# ...
name = property(
# - Using the "original" getter-method and the "local" setter-
# and deleter methods
BaseProduct._get_name, _set_name, _del_name,
'Gets, sets or deletes the name of the Product'
)采用这种方法并不困难(甚至非常耗时),但它会增加一些额外的单元测试需求,因为这些方法和属性重写中的每一个都将注册为需要测试的新的本地类成员。不过,这并不是一件坏事,因为这些测试最终只涉及验证is_dirty状态变化是否在应该发生的时候发生。
另一种方法是简单地从HMSMongoDataObject.save中删除is_new和is_dirty检查条件。在许多方面,这是一种更简单的解决方案,但至少有一个警告:它将确保save在进行更改的代码中调用任何更改对象的名称。如果不仔细监控代码进行更改和保存更改的方式和时间,很可能会进行许多save调用,以增量方式更新任何给定对象的数据文档。这可能是一个重大问题,也可能不是(例如,对于少量数据更改,这不太可能对性能产生重大影响),但如果不进行密切监控,它可能会很快失控。如果数据存储有与之相关联的每个查询的成本,这似乎不太可能,从长期来看,这种低效率也会增加成本。
由于涉及更新数据的实际用例尚未开发(或者甚至还没有提供可以指导决策的故事),目前,为了结束这些故事,将采用后一种解决方案。这使事情暂时保持简单,我们知道如果需要更复杂的解决方案,将涉及到什么。然后,对HMSMongoDataObject.save进行如下修改:
def save(self):
# TODO: For the time being, we're going to assume that save
# operations don't need to care about whether the
# object's data is new or dirty, that we wouldn't be
# calling save unless we already knew that to be the
# case. If that changes, we'll want to check is_dirty
# and is_new, as shown below, *and* make sure that
# they get modified accordingly.
# if self._is_new or self._is_dirty:
# - Make sure to update the modified time-stamp!
self.modified = datetime.now()
data_dict = self.to_data_dict()
data_dict['_id'] = self.oid
self.collection.insert_one(data_dict)
self._set_is_dirty(False)
self._set_is_new(False) 与 Artisan 应用程序的数据持久性一样,我们考虑了(如果没有得到验证的话)中央办公室代码库中数据对象的所有 CRUD 操作需求。因为接口需求也是由相同的BaseDataObject继承定义的,即使在 ABC 和具体数据对象之间提供了额外的功能,所有数据对象的读写数据过程在整个系统中看起来都是相同的——至少到目前为止是如此。
然而,没有一个数据访问已经过单元测试,这是系统的一个关键项目;归根结底,数据即使不是系统中最重要的部分,也是系统中最重要的方面之一。那么,是时候改变上下文并编写那些单元测试了,我们将在下一章中进行。**






