Python-软件工程实用指南(全)

Python 软件工程实用指南(全)

原文:zh.annas-archive.org/md5/7ADF76B4555941A3D7672888F1713C3A

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

最终,本书的目的是阐明务实的软件工程原则以及它们如何应用于 Python 开发。因此,本书的大部分内容都致力于探索和实施我认为是现实范围的,但可能是不切实际的项目:一个分布式产品管理和订单履行系统。在许多情况下,功能是从头开始开发的,从最基本的概念和假设出发,这些概念和假设构成了系统的基础。在现实世界的情况下,很可能会有现成的解决方案来处理许多实现细节,但我认为揭示底层理论和要求是至关重要的,以便理解为什么事情会发生。我认为,这是编程和软件工程之间的本质区别的一个重要部分,无论涉及哪种编程语言。

在许多方面,Python 是一种罕见的语言——它是一种动态语言,但又是强类型的。它也是一种面向对象的语言。这些特点使得它成为一种非常灵活,有时甚至是强大的语言。虽然这可能是我的观点,但我坚信很难找到另一种语言,既像 Python 一样通用,又像 Python 一样易于编写和维护代码。Python 在语言官方网站上列出了许多成功案例,这一点并不让我感到意外(www.python.org/about/success/)。同样,我并不感到意外的是 Python 是至少两家大型公共云提供商——亚马逊和谷歌的核心支持语言之一。即便如此,它仍然经常被认为只是一种脚本语言,我真诚地希望本书也能证明这种观点是错误的。

本书的受众

这本书旨在针对具有一定 Python 经验的开发人员,希望将他们的技能从“只是编写代码”扩展到更加“软件工程”方向。假定读者已经掌握了 Python 基础知识——函数、模块和包,以及它们与项目结构中文件的关系,以及如何从其他包中导入功能。

本书涵盖的内容

第一章,编程与软件工程,讨论了编程(仅仅是编写代码)与软件工程之间的区别——学科、思维方式以及它们的影响。

第二章,软件开发生命周期,详细研究了软件开发生命周期,特别关注与软件工程相关的输入、需求和结果。

第三章,系统建模,探讨了对系统及其组件的功能、数据流和进程间通信方面进行建模和绘图的不同方式,以及这些信息对于软件工程的意义。

第四章,方法论、范式和实践,深入探讨了当前的过程方法论,包括一些敏捷过程的变体,以及审视每种方法的优缺点,然后审查面向对象编程OOP)和函数式编程范式。

第五章,hms_sys 系统项目,介绍了本书中用于练习软件工程设计和开发思维的示例项目背后的概念。

第六章,开发工具和最佳实践,调查了一些更常见(或至少是容易获得的)开发工具——用于编写代码和以减少持续开发工作和风险的方式进行管理。

第七章《设置项目和流程》演示了一个示例结构,可用于任何 Python 项目或系统,并介绍了在建立与源代码控制管理、自动化测试和可重复构建和部署流程兼容的共同起点时的思考过程。

第八章《创建业务对象》开始了hms_sys项目的第一次迭代,定义了核心库业务对象数据结构和功能。

第九章《测试业务对象》在设计、定义和执行可重复自动化测试业务对象代码之后,结束了hms_sys项目的第一次迭代。

第十章《思考业务对象数据持久性》探讨了应用程序中对数据持久性的常见需求,一些更常见的机制,以及选择“最佳匹配”数据存储解决方案的标准,以满足各种实施需求。

第十一章《数据持久性和 BaseDataObject》通过设计和实现一个通用的抽象数据访问策略,可以在项目的任何组件中重复使用,开始了hms_sys项目的第二次迭代。

第十二章《将对象数据持久化到文件》通过具体实现抽象的数据访问层(DAL),将业务对象数据持久化到本地文件,继续了第二次迭代的工作。

第十三章《将数据持久化到数据库》实现了一个具体的数据访问层,该层可以从常用的 NoSQL 数据库 MongoDB 中存储和检索数据,并将该方法与等效的基于 SQL 的数据访问层的要求进行了比较。

第十四章《测试数据持久性》通过实施针对第二次迭代中构建的两种不同数据访问层策略的自动化测试,来结束hms_sys项目的第二次迭代。

第十五章《服务的解剖》分析了独立服务的常见功能要求,并通过构建抽象服务/守护程序类来实现,这些类可重复使用以创建各种具体的服务实现。

第十六章《工匠网关服务》通过分析系统组件的通信需求,几种实现这些通信的选项,保护它们,并最终将它们融入项目的核心服务的具体实现,开始了hms_sys项目的第三次迭代。

第十七章《处理服务事务》考虑了hms_sys组件之间所有必要的业务对象通信,提取了它们的一些共同功能,并介绍了实现它们所需的过程。

第十八章《测试和部署服务》总结了本书中hms_sys的开发,并调查和解决了服务/守护程序应用程序的一些常见自动化测试问题。

第十九章《Python 中的多处理和高性能计算》介绍了编写可以在单台计算机上扩展到多个处理器,或在集群计算环境中扩展到多台计算机的 Python 代码的理论和基本实践,并提供了在常见高性能计算系统上执行 Python 代码的起点代码结构变化。

为了充分利用本书

您应该特别了解以下内容:

  • 如何下载和安装 Python(在撰写本书时使用了 3.6.x,但这里的代码预计在 3.7.x 中可以少量或不需要修改地工作)

  • 如何编写 Python 函数

  • 如何编写基本的 Python 类

  • 如何使用 pip 安装 Python 模块,以及如何将模块导入您的代码

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接将文件发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packt.com

  2. 选择 SUPPORT 选项卡

  3. 单击“代码下载和勘误”

  4. 在搜索框中输入书名,然后按照屏幕上的说明进行操作

下载文件后,请确保使用最新版本的解压缩软件解压缩文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上github.com/PacktPublishing/****Hands-On-Software-Engineering-with-Python。我们还有其他代码包来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781788622011_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"在src目录中是项目的包树。"

代码块设置如下:

def SetNodeResource(x, y, z, r, v):
    n = get_node(x,y)
    n.z = z
    n.resources.add(r, v)

当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:

 def __private_method(self, arg, *args, **kwargs):
        print('%s.__private_method called:' % self.__class__.__name__)
        print('+- arg ...... %s' % arg)
        print('+- args ..... %s' % str(args))
        print('+- kwargs ... %s' % kwargs)

任何命令行输入或输出都以以下方式编写:

$python setup.py test

粗体:表示新术语、重要单词或屏幕上看到的单词,例如菜单或对话框中的单词,也会在文本中以这种方式出现。例如:"从管理面板中选择系统信息。"

警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。

第一章:编程与软件工程

开发商通常有特定的级别、等级或职级,表示每个级别员工所期望的经验、专业知识和行业智慧水平。这些可能会因地点而异(也许相差很大),但典型的结构看起来像以下内容:

  • 初级开发人员:初级开发人员通常是指没有太多编程经验的人。他们可能知道编写代码的基础知识,但不会超出这个范围。

  • 开发人员:中级开发人员(根据可能适用的任何正式头衔)通常具有足够的经验,可以依靠他们编写相当可靠的代码,几乎不需要监督。他们可能有足够的经验来确定实施细节和策略,并且他们通常会对不同代码块如何(以及会)相互作用以及什么方法将最小化这些交互中的困难有一定的了解。

  • 高级开发人员:高级开发人员具有足够的经验 - 即使专注于一组特定的产品/项目 - 以牢固掌握典型开发工作中涉及的所有技术技能。在他们的职业生涯中,他们几乎总是能够牢牢掌握许多涉及的非技术(或半技术)技能,尤其是政策和程序,以及鼓励或强制执行业务价值的策略和战术,如稳定性和开发工作的可预测性。他们可能不是这些领域的专家,但他们会知道何时提出风险,并且通常会建议几种减轻这些风险的选择。

在高级开发人员的水平之上,术语和定义通常变化更大,技能集通常开始更多地关注业务相关的能力和责任(范围和影响),而不是技术能力或专业知识。

编程和软件工程之间的分界线在于开发人员和高级开发人员之间的差异,就技术能力和专业知识而言。在初级水平上,有时甚至在开发人员水平上,努力往往只集中在编写代码以满足任何适用的要求,并遵守正在进行的任何标准。在高级开发人员水平上,软件工程具有对相同最终结果的宏观视图。更大的画面涉及对以下事项的意识和关注:

  • 标准,包括技术/开发和其他方面的最佳实践

  • 编写代码以实现的目标,包括与之相关的业务价值

  • 代码所属的整个系统的形状和范围

更大的画面

那么,这个更大的画面是什么样的?有三个易于识别的关注领域,还有一个(称之为用户交互)要么贯穿其中,要么被分解成自己的组。

软件工程必须注意标准,特别是非技术(业务)标准,以及最佳实践。这些可能会或可能不会被遵循,但由于它们是标准或最佳实践,不遵循它们应该始终是一个有意识的(并且可辩护的)决定。业务流程标准和实践通常会跨越多个软件组件,如果在开发过程中没有考虑到一定程度的纪律和规划,使它们更加可见,那么它们可能会难以跟踪。在纯粹与开发相关的一面,标准和最佳实践可以极大地影响代码的创建和维护,以及其持续的有用性,甚至只是在必要时找到给定代码块的能力。

编写代码的目的很少只是为了编写代码。通常情况下,它几乎总是与其他价值相关联,特别是如果与产品相关联的业务价值或实际收入。在这些情况下,可以理解的是,支付开发工作的人会非常感兴趣,以确保一切都按预期工作(代码质量),并且可以在预期时间部署(过程可预测性)。

代码质量问题将在几章后的hms_sys项目开发中得到解决,而过程可预测性主要受到第五章中讨论的开发方法论的影响,hms_sys 系统项目

剩下的政策和程序相关的问题通常通过建立和遵循各种标准、流程和最佳实践来管理,在项目(或者开发团队)启动期间将对这些项目进行详细检查——例如,设置源代码控制、制定标准的编码约定,并计划可重复、自动化的测试。理想情况下,一旦这些开发过程得以建立,保持其运行和可靠性的持续活动将成为习惯,成为日常工作的一部分,几乎淡出了背景。

最后,更多地关注代码方面,软件工程必须必要地关注整个系统,牢记系统的普遍视角。软件由许多元素组成,这些元素可能被分类为原子;它们在正常情况下是不可分割的单位。就像它们的现实世界的对应物一样,当它们开始互动时,事情变得有趣,希望也是有用的。不幸的是,这也是意外(甚至危险)行为——bug——通常开始出现的时候。

这种意识可能是更难培养的。它依赖于可能不明显、未记录或不容易获得的知识。在大型或复杂的系统中,甚至可能不明显从何处开始查找,或者要问什么样的问题来尝试找到获取这些知识所需的信息。

提出问题

对于任何给定的代码块,可以提出与代码块一样多的不同问题,即使是非常简单的代码,在复杂的系统中,也会因问题而引发更多问题。

如果没有明显的起点,从以下非常基本的问题开始是一个很好的第一步:

  • 谁将使用这个功能?

  • 他们将用它做什么?

  • 何时,何地他们将能够访问它?

  • 它试图解决什么问题?例如,他们为什么需要它?

  • 它必须如何工作?如果细节不足,将其分解为两个单独的问题是有用的:

  • 如果执行成功会发生什么?

  • 如果执行失败会发生什么?

挖掘整个系统的更多信息通常从以下基本问题开始:

  • 这段代码与系统的哪些其他部分有交互?

  • 它如何与他们互动?

在确定了所有的活动部分后,思考“如果发生了什么…”的情景是识别潜在的故障点、风险和危险交互的好方法。您可以提出以下问题:

  • 如果这个期望一个数字的参数被传入一个字符串会发生什么?

  • 如果该属性不是预期的对象会发生什么?

  • 如果其他对象尝试在它已经被更改时改变这个对象会发生什么?

每当一个问题得到答案时,只需问,还有什么?这有助于验证当前答案是否相当完整。

让我们看看这个过程是如何进行的。为了提供一些背景,正在为一个系统编写一个新的函数,该系统用于跟踪地图网格上的矿产资源,包括金、银和铜三种资源。网格位置是从一个公共原点以米为单位测量的,每个网格位置都记录了一个浮点数,范围从 0.0 到 1.0,表示在网格方块中发现资源的可能性有多大。开发数据集已经包括了四个默认节点 - 在(00)、(01)、(10)和(11)处 - 没有值,如下所示:

系统已经定义了一些类来表示单个地图节点,并提供了一些函数来从它们所在的中央数据存储中提供对这些节点及其属性的基本访问:

常量、异常和各种目的的函数已经存在,如下:

  • node_resource_names:这包含了系统关注的所有资源名称,并且可以被视为和处理为字符串列表:['gold','silver','copper']

  • NodeAlreadyExistsError:如果尝试创建一个已经存在的MapNode,将会引发异常

  • NonexistentNodeError:如果请求一个不存在的MapNode,将会引发异常

  • OutOfMapBoundsError:如果请求一个不允许存在于地图区域的MapNode,将会引发异常

  • create_node(x,y):创建并返回一个新的默认MapNode,在此过程中将其注册到全局节点数据集中

  • get_node(x,y):在全局可用节点数据集中找到并返回指定(xy)坐标位置的MapNode

开发者首次尝试编写代码来为给定节点设置单个资源的值,作为项目的一部分。生成的代码如下(假设所有必要的导入已经存在):

def SetNodeResource(x, y, z, r, v):
    n = get_node(x,y)
    n.z = z
    n.resources.add(r, v)

从功能上讲,这段代码可以正常运行,它将按照开发者的预期进行一系列简单的测试;例如,执行以下操作:

SetNodeResource(0,0,None,'gold',0.25) print(get_node(0,0)) SetNodeResource(0,0,None,'silver',0.25) print(get_node(0,0)) SetNodeResource(0,0,None,'copper',0.25) print(get_node(0,0))

结果如下输出:

按照这个标准,代码和它的函数都没有问题。现在,让我们提出一些问题,如下:

  • 谁将使用这个功能?:这个函数可能会被两个不同的应用程序前端中的任何一个调用,由现场测量员或测量后的评估员。测量员可能不经常使用它,但如果他们在调查中看到明显的矿床迹象,他们应该以 100%的确定性记录下来,表示在该网格位置发现资源的可能性;否则,他们将完全不改变资源评级。

  • 他们将如何使用它?:在基本要求(为给定节点设置单个资源的值)和前面的答案之间,这个问题似乎已经得到了回答。

  • 何时、何地可以访问它?:通过被测量员和评估员应用程序使用的库。没有人会直接使用它,但它将被集成到这些应用程序中。

  • 它应该如何工作?:这个问题已经得到了回答,但是引发了另一个问题:是否会有必要一次添加多个资源评级?如果有一个好的地方可以实现它,那可能值得注意。

  • 这段代码与系统的其他部分有什么交互?:除了代码中明显的部分外,它还使用MapNode对象、这些对象的资源和get_node函数。

  • 如果尝试更改现有的MapNode会发生什么?:根据最初编写的代码,这是预期的行为。这是代码编写来处理的正常路径,它有效。

  • 如果节点不存在会发生什么?:定义了NonexistentNodeError这一事实是一个很好的线索,表明至少有一些地图操作需要节点在完成之前存在。通过调用现有函数对其进行快速测试,如下所示:

SetNodeResource(0,6,None,'gold',0.25)

前面的命令导致以下结果:

这是因为开发数据在该位置尚未具有 MapNode。

  • 如果节点在给定位置无法存在会发生什么?:同样,定义了OutOfMapBoundsError。由于开发数据中没有越界节点,并且代码目前无法通过越界节点不存在这一事实,因此无法很好地看到如果尝试这样做会发生什么。

  • 如果在此时不知道z值会发生什么?:由于create_node函数甚至不期望z值,但 MapNode 实例具有一个,因此在现有节点上调用此函数可能会覆盖现有的 z-高度值,从长远来看,这可能是一个关键错误。

  • 这是否符合所有适用的各种开发标准?:没有关于标准的任何细节,可以合理地假设定义的任何标准可能至少包括以下内容:

  • 代码元素的命名约定,如函数名和参数;在与get_node相同逻辑级别的现有函数中,使用SetNodeResources作为新函数的名称,虽然在语法上完全合法,但可能违反了命名约定标准。

  • 至少有一些关于文档的努力,但没有。

  • 一些内联注释(也许),如果需要向未来的读者解释代码的某些部分——这也没有,尽管在这个版本中的代码量相当大,且采用了相对直接的方法,但是否有任何需要是值得讨论的。

  • 如果执行失败会发生什么?:如果执行过程中出现问题,它应该抛出明确的错误,并提供合理详细的错误消息。

  • 如果为任何参数传递了无效值会发生什么?:其中一些可以通过执行当前函数进行测试(如之前所做),同时提供无效参数——首先是超出范围的数字,然后是无效的资源名称。

考虑以下使用无效数字执行的代码:

SetNodeResource(0,0,'gold',2)

前面的代码导致以下输出:

另外,考虑以下带有无效资源类型的代码:

SetNodeResource(0,0,'tin',0.25)

前面的代码导致以下结果:

根据这些示例,函数本身可能在执行过程中成功或引发错误;因此,实际上只需要对这些潜在错误进行某种方式的处理。

可能会有其他问题,但前面的问题足以实施一些重大变化。在考虑了前面答案的影响并解决了这些答案暴露出的问题后,函数的最终版本如下:

def set_node_resource(x, y, resource_name, 
    resource_value, z=None):
    """
Sets the value of a named resource for a specified 
node, creating that node in the process if it doesn't 
exist.

Returns the MapNode instance.

Arguments:
 - x ................ (int, required, non-negative) The
                      x-coordinate location of the node 
                      that the resource type and value is 
                      to be associated with.
 - y ................ (int, required, non-negative) The 
                      y-coordinate location of the node 
                      that the resource type and value is 
                      to be associated with.
 - z ................ (int, optional, defaults to None) 
                      The z-coordinate (altitude) of the 
                      node.
 - resource_name .... (str, required, member of 
                      node_resource_names) The name of the 
                      resource to associate with the node.
 - resource_value ... (float, required, between 0.0 and 1.0, 
                      inclusive) The presence of the 
                      resource at the node's location.

Raises
 - RuntimeError if any errors are detected.
"""
    # Get the node, if it exists
    try:
        node = get_node(x,y)
    except NonexistentNodeError:
        # The node doesn't exist, so create it and 
        # populate it as applicable
        node = create_node(x, y)
    # If z is specified, set it
    if z != None:
        node.z = z
# TODO: Determine if there are other exceptions that we can 
#       do anything about here, and if so, do something 
#       about them. For example:
#    except Exception as error:
#        # Handle this exception
    # FUTURE: If there's ever a need to add more than one 
    #    resource-value at a time, we could add **resources 
    #    to the signature, and call node.resources.add once 
    #    for each resource.
    # All our values are checked and validated by the add 
    # method, so set the node's resource-value
    try:
        node.resources.add(resource_name, resource_value)
        # Return the newly-modified/created node in case 
        # we need to keep working with it.
        return node
    except Exception as error:
        raise RuntimeError(
            'set_node_resource could not set %s to %0.3f '
            'on the node at (%d,%d).' 
            % (resource_name, resource_value, node.x, 
            node.y)
        )

暂时剥离注释和文档,这可能看起来与原始代码并无太大不同——只添加了九行代码,但差异很大,如下所示:

  • 它不假设节点总是可用的。

  • 如果请求的节点不存在,则创建一个新节点进行操作,使用为此目的定义的现有函数。

  • 它不假设每次尝试添加新资源都会成功。

  • 当这样的尝试失败时,它会引发一个显示发生了什么的错误。

所有这些额外的项目都是早些时候提出的问题的直接结果,以及对如何处理这些问题的答案做出的有意识的决定。这种最终结果是编程和软件工程思维方式之间的区别真正显现的地方。

总结

软件工程不仅仅是编写代码。经验;对细节的关注;以及对代码功能、与系统其他部分的交互等方面提出问题;都是从编程思维向软件工程思维转变的重要方面。获得经验所需的时间可以通过简单地提出正确的问题来缩短,也许可以显著地缩短。

除了创建和管理代码的领域之外,还有一些完全不同的因素需要进行审查和质疑。它们主要关注的是在开发工作周围的预开发规划中可以或应该期望什么,这始于对典型软件开发生命周期的理解。

第二章:软件开发生命周期

所有软件开发,包括 Python 或其他语言,都遵循可重复的模式,或者有一个生命周期。软件(或系统)开发生命周期(SDLC)可以作为自己独特的开发方法论,提供适用于开发过程的一系列任务和活动。也就是说,即使没有正式的 SDLC 包裹着一个开发过程,任何或所有通过 SDLC 进行的活动仍然可能发生,并且其中产生的任何或所有工件可能在项目开发过程中可用。

从实际发展的角度来看,无论是正式的还是非正式的软件开发生命周期(SDLC)产生的所有工件,可能都不会特别有用,特别是那些在生命周期过程的最初几个阶段产生的工件。即便如此,在开发过程中获得的知识越多,开发工作就越不太可能朝着与系统长期意图相悖的方向发展。

为了充分探索 SDLC 可能提供的内容,我们将使用互联网上可以找到的更详细的 SDLC 之一。它将生命周期分解为十个阶段,按照以下顺序执行,除非开发方法论进行了流程调整:

  • 初始概念/愿景

  • 概念开发

  • 项目管理规划

  • 需求分析和定义

  • 系统架构和设计

  • 开发(编写代码)和质量保证

  • 系统集成、测试和验收

  • 实施/安装/分发

  • 操作/使用和维护

  • 退役

许多这些单独的阶段可以合并在一起,或者可以分解成更小的子阶段,但是这种分解——这十个阶段——是一组具有相似范围的相似活动的有用分组。

前三个阶段可能都发生在编写任何代码之前,定义高层概念和目标,并计划如何实现这些目标。最后三个通常发生在代码完成后,尽管随着新功能的想法或错误的出现,代码开发可能会重新启动以解决这些问题。平衡的 4 到 7 阶段,大致可分类为开发过程,尽管除了第 6 阶段的实际编写代码之外,这种分类可能取决于正在进行的开发过程或方法论,这可能在第 3 阶段决定,如果没有由外部政策或力量决定的话。

不同的软件开发方法论(特别是敏捷方法)可能更多地以按需方式处理这些问题,通过迭代或故事的方式分组阶段活动,或者按照这里列出的顺序进行。这些变化的更深入探讨可以在第四章中找到,方法论、范式和实践

SDLC 的预开发阶段

在编写第一行代码之前,项目可能需要进行大量的思考和工作。在开发开始时,不是所有的工作都会被看到,而且实际上,在许多情况下,可能不会产生所有可能的预开发工作。即使创建了这些工件,它们可能没有任何正式的结构或文档,或者可能不像所期望的那样完整或详细。尽管如此,了解在开发过程中可能可用的有用或有趣的内容,至少可以帮助回答在实际编写代码部分出现的问题。

初始概念/愿景

项目或系统生命周期中的第一件事通常是其构想。在幕后,这通常涉及对某种未满足的需求的认识,或者某些东西不按预期运行,尽管也可能出现其他变化。作为这种认识的一部分,通常会有一系列构想系统将提供的功能、好处或功能,这将推动系统的开发,并确定开发何时完成。在这个最初的、非常高层次的概述中,可能没有太多细节——我们需要更好的库存管理方式,也许是整个愿景,例如——但也可能会出现更多细节。

概念和好处可能来自于系统利益相关者:寻求更好工作方式的业务人员,也许意识到现有系统并不如预期那样有效的开发人员,或者难以维护。系统管理员可能担心现有系统的管理难度,并希望采取一种更新、更好的方法,或者最初的愿景可能是完全新的,至少在业务环境中是这样——我们需要一种方式来跟踪交付卡车车队的燃油效率,也许我们的客户可以在线订购我们的产品?

希望如果有现成的解决方案或产品可以满足这些需求的部分,那么这些选项将会被进行详细调查——甚至可能到达愿景所有者能够指出这些产品的某些功能集,并说:“我们想要类似的东西。”拥有接近实际需求的功能示例可以在预开发设计和开发过程中节省大量时间,几乎总是值得询问是否有所需功能的示例随着设计和开发过程的进行而出现。如果进行了这种调查,却没有找到任何接近的选项,那么其中也蕴含着有用的信息——缺少了什么?产品 X 做了什么不能满足概念中的需求?如果没有进行调查,或者调查没有结果,那么最初的概念很可能只是一两句话。不过,这没关系,因为随着概念的开发,更多的细节将在后期提取出来。

在作者的经验中,“没有进行调查”的情况发生的频率比预期的要高,特别是在那些大力投资于自己产品开发的企业,或者希望拥有所有代码的企业中。

在更正式的流程中,可能还会进行其他分析,寻找以下内容:

  • 特定用户需求:系统内用户必须能够做什么,可能还有他们应该能够做什么。可能还有一系列好有的功能——用户希望能够做的事情,但并非功能上的必要性。

  • 具体功能需求:系统需要解决的问题,或者至少在重大程度上缓解的问题。

  • 风险:通常是与业务流程相关的风险,但这些风险也可能在后期指导设计和开发。

  • 成本:无论是金钱还是资源。很可能这些信息从开发过程的角度来看并不会产生太多用处,但也不排除偶尔会有重要信息出现的可能。

  • 操作可行性:检查概念系统如何满足其被构想出来的需求。与成本分析一样,很可能不会有太多直接有用于开发目的的东西,但它可能会确定操作或设计领域存在可行性疑虑,并且这些疑虑可能会在系统开发时塑造设计和/或实施。

因此,最好的情况是,无论是正式流程还是非正式流程中对细节的足够关注,初始概念可能会产生有关以下内容的信息或文档:

  • 系统预期的收益或功能(通常至少从高层次开始)

  • 一系列具体的高级功能需求

  • 一系列具体的用户需求

  • 未由现成系统提供的具体功能或功能(从而证明了定制开发的努力)

  • 要减轻的具体风险

  • 要解决的具体功能或可行性问题

所有这些在开发进行时都至少有一定价值,并希望它们能够融入设计或需求,然后进入开发。

概念开发

概念开发主要关注于深化初始概念中出现的一些高级细节,为后续生命周期的努力提供细节和方向。这一步的更重要方面之一是生成各种系统建模工件,这些工作涉及的内容足够多,将在单独的章节中进行介绍。这一阶段产生的与开发相关的信息的平衡可能更多地集中在将业务流程与系统功能结合起来,并提供一些关于系统目标的细节。在这里还有空间至少定义基本的用户体验和/或用户界面,特别是它们与流程/功能的连接。

系统中嵌入的业务流程的定义包括识别系统跟踪的业务对象,可以针对这些对象采取的行动以及这些行动的结果,至少是这样。如果需要更多细节,可以应用前面描述的那种质疑,第一章编程与软件工程中可以得到大量信息。

这个系统概念将在第三章系统建模中重新讨论,以说明如何深化系统的高级技术设计方面可能会取得进展。

例如,考虑一个系统的概念,该概念始于他们需要一种方法来跟踪交付卡车车队的燃油效率。从那里开始解决业务对象和活动可能会回答一些非常基本的问题,比如以下问题:

  • 系统跟踪什么?:车队中的各辆卡车,这些卡车不定期的里程表里程,以及这些卡车的加油,至少是这些。

  • 加油看起来是什么样子?:首先是加油时的燃料数量和里程表读数。这两个数据点可以用来计算燃油效率,燃油效率是用各自的单位(加仑或升,英里或公里)计算的。燃油效率成为任何给定卡车的任何给定加油的计算,任何给定卡车的当前里程表读数可以从其上次加油的里程表读数中获取。

  • 对于任何给定的卡车应该保留多少次加油?:如果系统的目标之一是检测卡车的燃油效率下降,以便标记维护,或者触发与之相关的交付调度的审查,那么显然需要跟踪不止一次这样的加油 - 也许是所有的加油。

  • 谁将使用系统,如何以及在哪里?:至少需要两种类型的物理访问点:一个是来自移动设备的(给卡车加油时),另一个是来自办公室电脑的(用于报告目的,如果没有其他)。这一系列使用案例告诉我们,我们要么在看一个网络应用程序,要么是一些专门的电话和电脑应用程序集,可以通过服务层访问一些共同的数据存储。

可能还有其他问题可以提出,但仅这四个问题可能就提供了足够的信息来充分利用主要概念设计决策,尽管后者可能需要更多的探索才能最终确定。类似的质疑,询问诸如特定类型的用户可以对系统做什么,直到没有更多的用户和活动,也可以产生更具体的系统目标:

  • 各种用户可以记录加油,提供当前里程表读数和涉及的燃料数量:

  • 交付司机(在当地加油站)

  • 车队维护人员(在主办公室,那里有公司加油站)

  • 当卡车的计算燃油效率下降到其平均值的 90%以下时,车队维护人员将收到警报,以便安排卡车进行检查

  • 办公室工作人员还将在卡车的计算燃油效率下降到其平均值的 90%以下时收到警报,以便检查卡车的交付轮次

用户将如何以及在哪里与系统交互的问题可能会引发一些关于用户体验和界面设计的讨论和设计决策。在这种情况下,也许在讨论系统是网络应用程序还是专门的电话和桌面应用程序之后,决定将其制作成网络应用程序,并使用 Clarity Design System 作为 UI,因为系统愿景的主要利益相关者喜欢它在屏幕上处理卡片的方式:

项目管理规划

生命周期的这个阶段是所有概念项目希望以一种形式或方式汇聚在一起,准备开始实际编码的阶段。如果有一个正式的 PMP 文件作为结果,其大纲可能看起来像这样:

  • 业务目的

  • 目标

  • 目标

  • 包括什么

  • 不包括什么

  • 关键假设

  • 项目组织:

  • 角色和责任

  • 利益相关者

  • 沟通

  • 风险、问题和依赖关系

  • 可交付成果的初步时间表

  • 变更管理

  • 风险和问题管理

开发人员不需要所有这些项目,但知道在哪里寻找各种信息碎片和他们需要的信息(或者在某些情况下,联系谁获取信息)是有利的,因此:

业务目的目标目标部分应该理想地收集所有原始愿景信息(从最初的概念/愿景阶段开始),以及在概念设计完成后添加或更改的任何细节。这些很可能包括需求分析和定义工作的起点,这些工作在生命周期的开发特定阶段进行。此外,包括什么不包括什么关键假设部分应该揭示开发的实际范围,同时提供高层设计决策和任何相关的高层系统建模信息。风险,问题依赖关系可能提供特定的关注事项或其他利益,这将有助于塑造开发工作。最后,变更管理将设定期望(至少在高层次上)对系统进行更改时预期或计划的流程。

能够回答关于系统实施的问题或做出决策的人员,这些问题超出了纯开发范围的人员可能会列在角色和责任和/或利益相关者部分,尽管可能会有特定的建立流程来提出这些问题在沟通部分。

即使在项目管理期望周围没有正式文档,先前提到的大部分信息仍应该为开发人员所知—毕竟,不用花时间去追踪谁可以回答问题,就可以把更多时间用于实际编写代码。

SDLC 的开发特定阶段

自敏捷方法论问世以来,以及许多敏捷方法论的广泛采用,软件开发生命周期(SDLC)的开发特定阶段的具体形式可以有很大的变化。不同的方法论对于优先考虑或强调什么做出了不同的决定,这些差异反过来会产生明显不同的流程和工件,以完成直接关注开发人员需求和活动的正式 SDLC 阶段的目标。已经有很多关于几种敏捷过程的书籍,因此完整讨论它们远远超出了本书的范围,但它们都涉及以下活动。

需求分析和定义

需求分析和定义涉及发现和详细说明系统的具体需求—系统需要允许用户如何使用它。用户显然包括最终用户,从使用系统进行日常业务的办公室工作人员,到外部最终用户,如客户。不那么明显的是,用户还应该包括系统管理员,通过某些报告流程从系统接收数据的工作人员,以及可能以任何方式与系统互动的其他人,或者被系统所影响的人—包括开发人员自己。

首先,需求是关于这些交互的,开发人员必须知道系统期望的是什么,以便编写代码来提供这些功能。

系统架构和设计

如果需求分析和定义是关于系统提供什么,系统架构和设计主要是关于这些功能如何工作。各种开发方法论处理架构和设计的差异不太在于如何,而更多地在于何时定义它们。基本上,给定一组需求(系统背后的意图,或者为什么),实现细节(如何)几乎肯定会更多地由这些需求和如何最好地在编程语言中实现它们的具体细节决定,而不是由它们何时被确定,整合或正式化。

开发人员需要知道如何最好地实现所需的功能,这就是这个阶段关注的内容。

开发和质量保证

这个阶段的开发部分可能需要最少的解释:这是实际编写代码的时候,使用定义的需求来确定代码的目标,使用架构/设计来确定如何编写代码。可以说,这个阶段的质量保证部分应该被单独分组,因为其中涉及的许多活动实质上是不同的——毕竟,在执行手动测试计划时,很少有代码编写,如果有的话。也就是说,自动化测试的概念,可能能够取代许多旧式手动测试计划执行活动,至少起初需要大量的代码。一旦建立了这些测试套件,回归测试就变得简单得多,耗时也变少。开发方法论对这个阶段的质量保证方面的关注通常集中在质量保证活动何时进行,而这些活动的实际期望通常是开发标准和最佳实践的结合。

开发人员需要知道他们所期望的质量保证工作,并在开发过程中进行规划(也许编写代码)。自动化测试也是日益流行的持续集成(CI)和持续交付/部署(CD)实践的关键基础。

系统集成、测试和验收

如果系统的规模或复杂程度超过一定程度,那么开发工作中产生的新代码必须被纳入更大的系统环境中只是时间问题。还需要注意与其他系统的交互,以及在这些场景中引发的任何影响。在规模较小、复杂程度较低的系统中,这种集成可能在开发过程中实现。

无论哪种情况,新功能(或修改后的功能)的集成需要进行测试,以确保它没有破坏任何东西,无论是在本地系统还是与其交互的任何其他系统中。

开发人员需要知道他们的代码如何以及在何处适应更大的系统,以及如何集成它。与前一阶段的质量保证部分一样,开发人员还需要知道他们所期望的测试工作,出于同样的原因。

SDLC 的开发后阶段

SDLC 的部分在系统的核心代码编写完成后发生的,仍然会对开发周期产生重大影响。从历史上看,它们可能并不涉及大量的实际开发工作——一些代码可能是为了各种特定目的而编写的,比如打包系统的代码,或者在目标环境中进行安装。例如,如果系统的代码结构或者很少情况下系统编写的语言并不会阻止它,那么大部分为了支持开发后活动而编写的代码可能会在开发过程的早期阶段就被创建,以满足其他需求。

举例来说,打包代码库和/或创建一些安装机制很可能会在第一次需要在用户验收测试环境中安装代码库时进行。如果提前知道这种期望——在某种程度上应该知道的——那么为了编写安装程序可能会在任何真正的代码被创建之前就开始。在那之后,进一步的努力通常会不经常发生,因为需要向包结构添加新组件,或者需要进行安装过程的更改。在这个层面上的更改通常会很小,并且通常会随着过程的成熟和代码库的安装而越来越少。这种过程演变至少是 DevOps 和一些持续交付实践的起点。

开发人员需要知道系统应该如何分发和安装,以便他们可以根据这些需求进行规划,根据需要编写代码来促进这些过程。

SDLC 的最后两个阶段,涉及系统的日常使用和最终退役,对于核心开发过程来说通常不太相关。最可能的例外情况是重新进入开发周期阶段,以处理错误或添加新功能或功能(操作/使用和维护阶段的使用和维护部分)。

从系统管理员的角度来看,负责执行各个阶段活动的工作人员,开发人员对他们所需的知识和流程的贡献方式与所有前期开发人员对开发者知识和流程的贡献方式非常相似。系统管理和维护人员将寻找并使用开发过程中产生的各种工件,以便能够执行他们与系统相关的日常工作。这些工件很可能大部分是知识,以文档形式存在,也许偶尔会有系统管理工具。

开发人员需要知道在后期开发活动中需要哪些信息,以便能够提供相关文档或编写代码来促进常见或预期的任务。

最后,关于系统停用的过程,将其下线,可能永远不再使用:某人,可能是在业务决策层,将不得不提供指导,甚至是关于需要发生什么的正式业务政策和程序。至少,这些可能包括以下内容

  • 保留和归档系统数据的要求(或者如果是敏感数据,应该如何处理)

  • 通知用户系统停用的要求

可能还有更多,甚至更多——这非常依赖于系统本身,无论是结构上还是功能上,以及可能适用的任何业务政策。

开发人员需要知道当系统最终永久关闭时应该发生什么,以便他们可以进行相应的规划和文档记录。了解在完全和永久关闭期间如何处理事务可能会对系统流程和数据在正常系统操作期间执行正常数据删除时的处理方式提供重要见解。

总结

即使没有正式的 SDLC,很多 SDLC 中产生的信息对开发人员来说仍然是有利的。如果足够的信息可用,并且足够详细、易于访问,并且最重要的是准确的,它肯定可以帮助区分项目只是编程和真正是良好工程软件之间的差异。

另一个对于产生这种差异的重要贡献者是关于系统本身的类似信息的可用性,以及任何或所有几个系统模型工件。这些提供了更多面向实施的细节,应该至少和各种 SDLC 工件中的政策和程序级别信息一样有用。接下来我们将看看这些。

第三章:系统建模

任何系统建模过程的目标是定义和记录系统某个方面的概念模型,通常分别关注系统的一个(或多个)特定方面。系统模型可以用形式化的架构描述语言来定义,例如统一建模语言UML),在这些情况下,可以非常详细 - 直到类的最小所需属性和方法成员。在敏捷方法论的需求分析过程中,这个层面的细节通常是流动的 - 或者至少不是最终确定的,并且将在第四章方法、范例和实践中更详细地讨论。

在更高、更少细粒度的层面上,有几个系统模型视图在开发过程中特别引人关注,特别是关于更大的整体情况:

  • 架构,逻辑和物理

  • 业务流程和规则

  • 数据结构和流动

  • 进程间通信

  • 系统范围/规模

架构,逻辑和物理

逻辑和物理架构规范的目标是分别定义和记录系统的逻辑和物理组件,以便清楚地阐明这些组件元素之间的关系。任何一种努力产生的成果都可以是文本文档或图表,它们都有各自的优点和缺点。

文本文档通常更容易产生,但除非有一些可以应用的架构文档标准,否则格式可能会因系统团队而异。这种差异可能会使得最终产物难以在原始团队之外被理解。如果开发人员在团队之间没有太多流动,或者新开发人员大量涌入团队,这可能并不是一个重大问题。还很难确保所有移动部件或它们之间的连接都得到充分考虑。

图表的主要优点是它们相对容易理解。如果图表具有明显的指示器或符号,可以明确指示,例如,一个组件是数据库服务,另一个是应用程序,那么它们之间的区别一目了然。图表也具有更容易为非技术观众理解的优势。

在这两种情况下,基于文本或基于图表的文档显然是最有用的,如果它们构造良好,并提供了系统的准确视图或模型。

逻辑架构

开发通常更关注系统的逻辑架构而不是物理架构。只要系统中实际代码的部署、连接和使用各种与逻辑组件相关的物理组件的机制已经就位,并且考虑到了任何物理架构约束,通常不需要更多的信息,因此从这个角度来看,任何给定组件的位置并不那么重要。这通常意味着物理架构的详细分解最多只是一个好东西,或者最多只是一个应该有的东西。这也假设所讨论的结构不是某种如此常见以至于需要对其进行文档化的东西。例如,在野外有许多遵循相同常见的三层结构的系统,请求-响应循环如下进行:

  1. 用户通过表示层发出请求

  2. 该请求被转交给应用层

  3. 应用程序从数据层检索所需的任何数据,可能在此过程中进行一些操作或聚合

  4. 应用层 生成响应并将其返回给表示层

  5. 表示层 将该响应返回给用户

以图表形式,该结构可能如下所示:

这种三层架构在 Web 应用程序中特别常见,其中:

  • 表示层 是 Web 服务器(Web 浏览器只是远程输出渲染组件)

  • 应用层 是由 Web 服务器调用的代码,并生成对 Web 服务器的响应,使用任何语言和/或框架编写

  • 数据层 是多种后端数据存储变体之一,用于在请求之间保留应用程序数据

例如,考虑前面提到的加油跟踪系统概念的以下逻辑架构。它作为一个很好的例子,说明了这种三层架构在 Web 应用程序中的应用,其中一些特别标识的组件:

物理架构

逻辑架构文档和物理架构文档之间的主要区别在于,逻辑架构的关注点在于识别系统的功能元素,而物理架构则需要额外的步骤,指定这些功能元素执行的实际设备。在逻辑架构中识别的个别项目可能在物理上驻留在共同的设备上。实际上,唯一的限制是物理设备的性能和能力。这意味着这些不同的物理架构在逻辑上都是相同的;它们都是实现相同三层 Web 应用程序逻辑架构的有效方式:

随着行业对虚拟化、无服务器和基于云的技术的热情,由亚马逊网络服务和 VMware 等公共和私有云技术提供,物理架构规范是否真的是物理架构往往成为一种语义争论。在某些情况下,可能没有单一可识别的物理计算机,就像如果有一台专用的服务器硬件一样,但在许多情况下,这种区别是无关紧要的。如果它的行为像一个独立的物理服务器,那么在定义物理架构的目的上,它可以被视为一个物理服务器。在这种情况下,从文档的角度来看,将虚拟服务器视为真实服务器并不会丢失任何知识价值。

在考虑系统中的许多无服务器元素时,只要它在与其他元素交互的角度上表现得像一个真实设备,那么它仍然可以被表示为一个物理架构元素。也就是说,假设一个假设的 Web 应用程序完全存在于某个公共云中,其中:

  • 该云允许定义无服务器函数

  • 将为处理以下内容定义函数,并为每个实体的后端数据库也存储在云中:

  • 顾客

  • 产品

  • 订单

相应的物理架构可能如下所示:

这种无服务器架构的实际实现示例可以在三个大型公共云中实现:亚马逊网络服务(AWS)、Azure 和谷歌云平台(GCP)。这些公共云平台都提供了可以为网站提供服务的虚拟服务器实例,也许还可以提供数据库。该结构中的处理器服务器可以使用无服务器函数(AWS Lambda,或 Azure 和 GCP 中的 Cloud Functions)来驱动网站和数据库之间的交互,因为网站向处理器元素中的函数发送事件。

总体而言,逻辑和物理架构规范至少提供了开发与非应用程序层进行交互所需的一些信息。即使文档中需要特定的凭据但未提供,例如,知道系统的数据层使用何种类型的数据库定义了数据层将如何被访问。

用例(业务流程和规则)

在任何系统中,最重要的是它是否按照所有它应该支持的用例来执行。代码必须为每个用例编写,并且每个用例对应于一个或多个业务流程或规则,因此每个用例都需要根据开发过程的适当程度来定义和记录。与逻辑和物理架构一样,可以将这些定义执行为文本或某种图表,这些方法具有之前提到的相同优点和缺点。

统一建模语言(UML)为用例提供了一个高级的图表标准,主要用于捕捉特定类型的用户(在 UML 的术语中称为操作者)与他们预期与之交互的流程之间的关系。这是一个很好的开始,如果流程本身非常简单,已经广泛记录,或者在开发团队中已知,那么它甚至可能足够。在用例部分中讨论的 Refuel-Tracker 应用程序概念的用例图目前非常简单,并且回溯到在第二章中为其建立的系统目标。不过,这一次,我们将为它们附上一些名称以供图表参考:

  • 加油:各种用户可以记录加油,提供当前里程表读数和涉及的燃油数量:

  • 送货司机(在当地加油站)

  • 车队维护人员(在总部,那里有公司加油站)

  • 维护警报:当卡车的计算燃油效率降低到其平均值的 90%以下时,车队维护人员将收到警报,以便安排检查卡车。

  • 路线审查警报:办公室工作人员也会在卡车的计算燃油效率降低到其平均值的 90%以下时收到警报,以便检查卡车的送货路线。

这三个用例如果是首选文档,则很容易绘制图表。以下的流程列表也是一个可行的选择。在某些方面,它实际上比标准图表更好,因为它提供了一些标准用例图无法捕捉的系统业务规则:

即使修改图表以包括一些缺失的信息(加油是什么,以及两个«trigger»项目周围的规则),它仍然只是讲述故事的一部分:谁预期(或允许)使用特定的流程功能。余额,用例下面的实际流程,仍然是未知的,但需要暴露出来,以便编写代码来使它们真正起作用。这也可以通过某种纯文本或图表来处理。查看已识别的加油流程,它可以分解为以下内容:

  • 司机车队技术人员记录卡车的加油,提供:

  • 当前里程表读数

  • 用于填充卡车的燃油量

  • 这些值被存储(可能在应用程序数据库中,尽管这可能不是实际要求的一部分),并与卡车关联(如何指定尚未确定)。

  • 该应用程序计算加油的燃油效率:(当前里程表读数减去上次里程表读数)÷燃油数量。

  • 如果效率小于或等于该卡车最近效率值的 90%,则触发路线审查警报。

  • 如果效率小于或等于该卡车前四个效率值中至少一半的 90%,则触发维护警报。

流程图(如下面的流程图)是否会为文档增加价值可能取决于所描述的流程,以及团队甚至个人的偏好。这五个步骤作为一个简单的流程图,简单到除了它们的文本描述之外可能不会增加任何价值,但更复杂的流程可能会受益于流程图:

从开发人员的角度来看,用例映射到一个或多个必须实现的函数或方法,如果有流程流程记录,那些解释了它们将如何在运行时执行。

数据结构和流程

在这两者之间,基本的用例和业务流程文档可能提供足够的信息,以使数据在系统中的结构和流程变得明显,或者至少透明到开发不需要任何额外信息。我们一直在研究的加油流程可能属于这一类,但是让我们看看它的数据流图可能会是什么样子。

流程图中的数据(流程图中的加油数据)在用例部分中已经定义,并且至少有一些相关数据流也已经记录,但是将一些名称与这些值相关联,并知道它们是什么类型的值将是有帮助的:

  • odometer:当前里程表读数(可能是一个<int>值)

  • fuel_quantity:用于加满卡车的燃料量(可能是一个<float>值)

  • truck_id:正在加油的卡车(应用程序数据库中卡车记录的唯一标识符 - 为了简单起见,我们假设它也是<int>

在过程中,还可能需要传递一个加油效率值给路线审查警报和/或维护警报流程:

  • re:计算得到的加油效率值,一个<float>

在这个非常简单的情况下,数据元素只是按名称和类型进行了记录。图表指示它们何时开始可用,或者何时它们被明确传递给一个流程 - 否则它们被假定为整个过程中都可用。然后数据元素只是添加到先前的流程图中:

在一个更复杂的系统中,具有更复杂数据结构、更多数据结构、更多使用这些数据结构的流程,或者这些因素的几种组合之一,源和目的地导向的流程图可能是更好的选择 - 一些不关注流程内部工作,只关注需要什么数据以及它来自哪里的东西。

数据流文档/图告诉开发人员期望的数据在哪里产生,以及在流程完成后它将在哪里/是否存储。

进程间通信

不同的流程之间进行通信是非常常见的。在最基本的层面上,这种通信可能采取的形式可能只是一个函数或方法从它们共享的代码中的某个地方调用另一个。然而,随着流程的扩展,特别是如果它们分布在不同的物理或虚拟设备上,这些通信链通常会变得更加复杂,有时甚至需要专门的通信协议。类似的通信流程复杂性也可能出现在相对简单的系统中,如果存在需要考虑的进程间依赖关系。

在几乎任何通信机制比方法调用其他方法更复杂的情况下,或者可能是一个方法或进程写入数据,另一个进程将在下次执行时接收并运行,值得记录这些通信将如何工作。如果将进程之间的基本通信单元视为消息,那么通常至少记录以下内容将为编写实现这些进程间通信机制的代码提供一个坚实的起点:

  • 消息包含的内容:期望的具体数据:

  • 消息中需要的内容

  • 可能存在的额外/可选数据

  • 消息的格式:如果消息以某种方式序列化,转换为 JSON、YAML 或 XML,例如,需要注意

  • 消息如何传输和接收:它可以在数据库中排队,直接通过某些网络协议传输,或者使用专用的消息队列系统,如 RabbitMQ、AWS SQS 或 Google Cloud Platform 的发布/订阅

  • 消息协议适用的约束类型:例如,大多数消息队列系统将保证每个排队的消息传递一次,但不会超过一次。

  • 消息在接收端如何管理:在某些分布式消息队列系统中,例如 AWS SQS 的某些变体,消息必须从队列中主动删除,以免被接收多次,并且可能被多次执行。而其他系统,如 RabbitMQ,在检索消息时会自动删除消息。在大多数其他情况下,消息只存在于到达目的地并被接收的时间。

进程间通信图通常可以建立在逻辑架构和用例图的基础上。一个提供了通信过程的逻辑组件,另一个确定了需要相互通信的进程。记录的数据流也可能有助于整体情况,并且值得从识别可能在其他地方被忽略的任何通信路径的角度来看。

例如,加油追踪器:

  • 可以访问现有的路线调度应用程序的数据库,该应用程序为路线调度员提供了仪表板。

  • 维护警报功能可以利用属于已购买的现成车队维护系统的网络服务调用,该系统有自己的仪表板,由车队技术人员使用。

在这些情况下,路线审查和维护警报流程所涉及的相关消息非常简单:

  • 路线调度数据库的更新,也许标记了卡车上次安排的路线为低效路线,或者可能是一些通知,会在仪表板上弹出,提醒路线调度员审查路线。

  • 向维护跟踪系统发出的 JSON-over-REST API 调用

该消息将适用于已显示的用例图的简单变体:

订单处理、履行和运输系统可能使用 RabbitMQ 消息传递来处理订单履行,从产品数据源传递整个订单和简单的库存检查,以确定订单是否可以履行。它可能还使用几个网络服务 API 调用来管理订单装运,通过类似的网络服务调用将装运信息推回订单。消息流(为简洁起见省略数据结构)可能如下所示:

对于开发重点在进程间通信的主要收获是,之前确定的数据如何从系统的一点传输到另一点。

系统范围和规模

如果所有这些项目都被记录和/或绘制出来,如果做得彻底和准确,它们将共同提供一个系统的整体范围的全面视图:

  • 每个系统组件的角色应该在逻辑架构中被确定。

  • 每个组件实际所在的位置应该在物理架构中被确定。

  • 系统应该实现的每个用例(以及希望每个业务流程)都应该在用例文档中被确定,并且任何不那么明显的基础流程都应该至少有一个粗略的顺利路径分解。

  • 从一个地方或过程移动到另一个地方的每个数据块都应该在数据流中被确定,具有足够的细节来整合出该数据结构的相当完整的图像。

  • 至少对于系统中涉及更多的不仅仅是从代码库中的一个功能或方法传递系统对象的部分,应该确定管理数据移动的格式和协议。

  • 这些数据的存储位置和方式应该可以从逻辑和可能的物理架构中看出来。

唯一显著缺失的部分是系统的规模。如果范围是系统中正在使用或正在移动的对象类型的数量,那么规模将是这些对象的实际数量,无论是静止的(例如存储在数据库中)还是在任何给定时间内活跃的。

规模可能很难准确预测,这取决于系统的上下文。例如,用于说明的假设加油跟踪器和订单处理/履行/发货系统通常会更可预测:

  • 用户数量将是相当可预测的:所有员工和所有客户基本上覆盖了这两者的最大用户群。

  • 使用的对象数量也将是相当可预测的:毕竟,运送公司只有那么多卡车,而订单系统的公司,虽然可能不太可预测,但仍然会对大多数订单的数量有一个大致的了解。

当系统或应用程序进入用户空间,比如网络时,甚至在很短的时间内也有潜在的巨大变化。在任何情况下,都应该进行一些关于预期和最大/最坏情况规模的规划。这种规划可能对设计和实施产生重大影响——例如,一次从几百或几千条记录中获取和处理十几条记录并不需要像从几百万或几十亿条记录中获取这些记录那样需要关注效率,这只是一个基本的例子——关于代码可能如何编写。如果为了应对潜在的大规模使用而进行规划,需要能够扩展到多个服务器,或者负载均衡请求,这可能也会对代码产生影响,尽管可能在更高的进程间通信层面。

总结

本章的所有组件、数据和文档,以及前两章的内容,都可能在任何软件工程项目中使用。实际可用的数量可能部分取决于在前期开发过程中涉及多少纪律,即使没有任何正式的关联。这种纪律可能是因为有一个非常有才能的项目经理。

对数据的可用性的时间、数量和质量的另一个影响因素通常是在项目、系统或团队的整个生命周期中采用的开发方法。一些更常见的方法在管理这些前期开发工作方面的方式有着显著不同,它们的处理可能会产生重大差异。

第四章:方法论、范式和实践

可以说,软件工程,至少是现在通常所认为的,真正开始存在于第一个正式确定的软件开发方法论。这种方法论(最终在 1976 年被称为瀑布)使人们开始思考的不仅仅是软件的工作原理,或者如何编写代码,而是围绕编写代码的过程需要看起来像什么,以使其更有效。从那时起,大约有十几种其他方法论出现了,至少有一种情况下,各种敏捷方法论的集合,有近十几种不同的子变体,尽管 Scrum 几乎可以肯定是最为人熟知的,而 Kanban 可能是第二熟知的。

当这些方法论不断成长和成熟时,计算能力的增加最终也导致了更新、更有用或更高效的开发范式。面向对象编程(OOP)和函数式编程(FP)可能是最为人熟知的对原始过程式编程范式的进步,而过去几十年一直占主导地位。自动化代码集成和推广实践(分别是持续集成和交付)近年来也变得流行起来。

在本章中,我们将涵盖以下主题:

  • 过程方法论

  • 瀑布

  • 敏捷:

  • Scrum

  • 看板

  • 开发范式:

  • 面向对象编程(OOP)

  • 函数式编程(FP)

  • 开发实践:

  • 持续集成

  • 持续交付

过程方法论

在某种程度上,所有开发过程方法论都是在一些共同现实的边界内管理开发的变体:

  • 每个人每天可以投入到项目中的有用工作时间是有限的

  • 项目可用资源的限制,无论是人员、设备还是资金

  • 项目完成时有一个最低可接受的质量标准

这有时被表达为项目管理的铁三角:

关于速度点的主要关注是时间——最常见的焦点可能是项目需要在特定截止日期前完成,或者有一些其他时间约束,可能只能通过增加团队的开发人员(增加成本)或者采取捷径(降低质量)来克服。

成本点的变化是成本点的一个常见主题——任何花钱的事情,无论是额外的开发人员、更新/更快/更好的工具等等。

减少可用资源/人员会降低项目完成的速度和/或最终的质量。

质量点显然关注质量措施,这可能包括特定的内部或外部标准,但也可能包括不那么明显的项目,比如长期可维护性和对新功能和功能的支持。至少需要更多的开发人员时间来优先考虑质量,这会降低速度,增加成本。

通常,对三角形的三个点最多只能给予两个点的重要性(无论“重要性”可能适用于哪个值),从而产生三种优先级可能性:

  • 快速、廉价的开发,但以牺牲质量为代价

  • 快速、高质量的开发,但成本更高

  • 高质量、廉价的开发,需要更长时间来完成

精益创业方法(或简称精益)有时被认为是可以克服铁三角约束的替代过程方法论,但超出了本书的范围。可以在www.castsoftware.com/glossary/lean-development找到其概念的合理介绍。

有三种特定的开发流程方法论值得在本书的背景下进行深入研究。首先,我们将研究瀑布模型,以便为两种敏捷方法论——Scrum 和 Kanban 提供一个参照框架,同时还会简要地介绍其他一些方法。本书的范围远远无法对它们进行全面讨论,但意图是为每种方法提供足够的细节,以说明它们的重点、优势和劣势。至少,这应该提供一个基准,让人们知道在任何一种方法中工作时可以期待什么,将每种方法的阶段与第三章的 SDLC 模型的阶段联系起来,展示发生了什么、何时发生以及如何发生。

瀑布

瀑布的渊源可能可以追溯到制造和/或建筑规划。在许多方面,这是一种非常简单的规划和实施开发工作的方法,基本上可以分解为定义和设计要构建的内容,构建它,测试它,部署它。

更正式地说,这是六个单独的阶段,按照这个顺序执行:

  • 需求

  • 分析

  • 设计

  • 实施

  • 测试

  • 安装和操作:

这些阶段与 SDLC 的阶段顺序相当吻合。无论是偶然还是有意为之,它们的目标都是为了实现许多相同的目标。它们的重点可能最好总结为努力在交付设计给开发之前设计、记录和定义开发所需的一切。在理想的执行中,设计和需求信息将为开发人员提供一切所需,一旦实施开始,项目经理可能完全不需要干预。

从概念上讲,这种方法是有一定价值的——如果一切都得到了彻底和准确的记录,那么开发人员将拥有他们所需要的一切,他们可以完全专注于编写代码来实现需求。文档作为初始项目规格的一部分已经创建,因此一旦软件部署,管理生成系统的任何人都将可以访问它,其中一些文档甚至可能是面向用户的,并且对他们可用。

如果做得好,它几乎肯定会捕捉并允许在实施过程中的依赖关系,并提供一个易于遵循的事件顺序。总的来说,这种方法论非常容易理解。这几乎是一种反射性的建设方法:决定要做什么,计划如何做,做,检查所做的是否符合要求,然后就完成了。

然而,在实践中,要实现一个良好的瀑布计划和执行并不容易,除非执行需求分析设计阶段的人员非常优秀,或者需要花费足够的时间(也许是很长时间)来达到并审查这些细节。这假设需求一开始就是可以确定的,而这经常不是事实,并且它们在中途不会发生变化,而这种情况比人们想象的更常见。由于它的重点是首先进行文档记录,因此长期应用于大型或复杂系统时往往会变得缓慢——不断更新不断增长的文档集需要时间,而几乎总是需要额外的(并且不断增加的)时间来防止不可控制的膨胀影响系统周围的其他支持结构。

瀑布流程的前三个阶段(需求分析设计)包括 SDLC 模型的前五个阶段:

  • 初始概念/愿景

  • 概念开发

  • 项目管理规划

  • 需求分析和定义

  • 系统架构和设计

理想情况下,这些将包括这些阶段的任何文档/成果,以及任何系统建模项目(第三章系统建模),所有这些都打包好供开发人员使用和参考。通常,这些过程将涉及一个专门的项目规划者,负责与各种利益相关者、架构师等进行交流和协调,以便组装整个项目。

在一个定义明确且管理良好的瀑布过程中,这三个阶段产生的成果并交给开发和质量保证的成果是一个文档或一组文档,构成了一个项目计划。这样的计划可能会非常长,因为它理想情况下应该捕捉到所有在开发前和开发后有用的产出:

  • 目标和目标(可能是在高层次)

  • 包括在完成工作中的内容和预期的内容:

  • 完整的需求分解

  • 需要减轻或至少注意的任何风险、问题或依赖关系

  • 架构、设计和系统模型考虑因素(新结构或对现有结构的更改):

  • 逻辑和/或物理架构项目

  • 使用案例

  • 数据结构和流程

  • 进程间通信

  • 开发计划

  • 质量保证/测试计划(s)

  • 变更管理计划

  • 安装/分发计划

  • 退役计划

瀑布过程的实施测试阶段,除了以项目计划作为起点参考外,很可能会遵循一个简单而非常典型的过程:

  • 开发人员编写代码

  • 开发人员测试代码(编写和执行单元测试),修复任何功能问题并重新测试直到完成

  • 开发人员将完成的代码交给质量保证进行进一步测试

  • 质量保证测试代码,如果发现问题,则将其交还给开发人员

  • 经过测试/批准的代码被推广到实际系统

这个过程在所有开发工作和方法论中都很常见,除非有重大偏差,否则以后不会再提到它。

瀑布的安装和操作阶段包括 SDLC 模型中的安装/分发操作/使用和维护阶段。它也可能包括退役阶段,因为这可能被视为特殊的操作情况。与实施测试阶段一样,这些阶段很可能会以一种易于预期的方式进行——除了项目计划文档中可能存在的任何相关信息外,实际上没有什么可以指导任何偏离简单、常识方法的东西,无论在系统的上下文中常识的价值是什么。

虽然瀑布通常被认为是一种过时的方法,往往以过于死板的方式实施,并且在长期基础上更多或更少需要超级人员才能发挥作用,但只要存在一个或多个条件,它仍然可以发挥作用:

  • 需求和范围被准确分析,并完全考虑到

  • 在执行过程中,需求和范围不会发生重大变化

  • 系统对方法论来说不会太大或太复杂

  • 系统的变化对方法论来说不会太大或太复杂

其中,第一个通常是没有政策和程序支持的情况下不能依赖的事情,而这通常远远超出了开发团队的控制范围。后两者几乎不可避免地会在足够长的时间内变得不可逾越,因为系统很少会随着时间的推移变得更小或更简单,对更大更复杂系统的更改往往会变得更大更复杂。

敏捷(一般)

到了 20 世纪 90 年代初,开发过程的观念发生了翻天覆地的变化。瀑布模型虽然被广泛采用,甚至在美国政府承包商政策中也得到了应用,但开始显示出在应用于大型和复杂系统时固有的缺陷。其他非瀑布方法学的使用也开始显示出过于繁重、过于易于产生逆生产的微观管理以及各种其他抱怨和担忧的迹象。

因此,对开发过程的大量思考开始集中在轻量级、迭代和较少管理密集型的方法上,最终形成了敏捷宣言和支撑其的十二个原则:

  • 我们正在通过实践和帮助他人实践,发现开发软件的更好方法。通过这项工作,我们已经开始重视:

  • 个人和互动胜过流程和工具

  • 可工作的软件胜过全面的文档

  • 与合同谈判相比,更重视与客户的合作

  • 响应变化胜过遵循计划

也就是说,虽然右侧的项目有价值,但我们更重视左侧的项目。我们遵循这些原则:

  • 我们的最高优先级是通过及早和持续交付有价值的软件来满足客户。

  • 欢迎变化的需求,即使在开发的后期。敏捷过程利用变化为客户的竞争优势。

  • 频繁交付可工作的软件,从几周到几个月,更偏好较短的时间跨度。

  • 业务人员和开发人员必须在整个项目期间每天一起工作。

  • 围绕着积极主动的个人建立项目。给予他们所需的环境和支持,并相信他们能够完成工作。

  • 向开发团队传达信息的最有效方法是面对面的交谈。

  • 可工作的软件是进展的主要衡量标准。

  • 敏捷过程促进可持续发展。赞助商、开发人员和用户应该能够持续保持稳定的步伐。

  • 持续关注技术卓越和良好设计可以增强敏捷性。

  • 简单性——最大程度地减少未完成的工作量——是必不可少的。

  • 最佳的架构、需求和设计来自于自组织团队。

  • 定期团队反思如何变得更有效,然后调整和调整其行为。

您可以参考敏捷宣言网站Agilemanifesto.org/获取更多详细信息。

在应用程序中,这些原则导致了不同方法论之间的一些共同特征。其他敏捷方法可能存在例外情况,但对于我们的目的,以及对本文讨论的具体方法论,这些共同特征如下:

  • 开发按照一系列迭代进行,每个迭代都有一个或多个目标

  • 每个目标都是最终系统的一个子集

  • 在每个迭代结束时,系统是可部署和可操作的(也许只适用于特定的操作价值)

  • 需求以小块详细定义,并且可能直到它们要被处理的迭代之前才被定义

Scrum 被称为最受欢迎的,或者至少是最广泛使用的敏捷开发方法(《敏捷报告》的第 12 届年度报告将其列为 56%的敏捷方法正在使用),因此可能值得更加详细地关注。Kanban 是另一种敏捷方法,也值得一些研究,即使只是因为它更接近本书中主要系统项目的呈现方式。

还有一些其他敏捷方法论,至少也值得快速查看,因为它们可以为开发工作带来一些特定的关注点,无论是独立使用,还是与其他方法论混合使用。

企业也在探索对敏捷流程进行补充和修改,以改进它们并解决原始概念未包含的需求。其中一个这样的流程是规模化敏捷框架,用于改进更大规模的敏捷流程的使用。

Scrum

Scrum 大致包括以下几个部分:

  • Scrum 方法论围绕着称为冲刺的有限时间迭代。

  • 冲刺被定义为开发团队(有时还包括利益相关者)可以达成一致的一段固定时间。

  • 冲刺持续时间通常是相同的,但如果有理由这样做,那么这个持续时间可以被改变,无论是暂时的还是永久的(直到下一次改变)。

  • 每个冲刺都有一组与之相关的功能/特性,开发团队已经承诺在冲刺结束时完成。

  • 每个功能/特性项目都由一个用户故事描述。

  • 团队确定他们可以承诺在冲刺期间完成哪些用户故事,考虑到冲刺的持续时间。

  • 用户故事的优先级由利益相关者(通常是产品负责人)确定,但可以进行协商。

  • 团队定期聚集来整理待办事项列表,这可能包括:

  • 估计没有大小的故事

  • 为用户故事添加任务级别的细节

  • 如果存在功能依赖或与大小相关的执行问题,将故事细分为更小、更易管理的块,并获得相关利益相关者的批准

  • 团队在最后审查冲刺,寻找做得好的事情,或者寻找改进做得不太好的事情的方法。

  • 团队定期会议计划下一个冲刺。

  • 团队每天有一个简短的会议(站立会议),其目的是揭示自上次更新以来发生了什么变化的状态。这些会议最为人熟知的格式,虽然不是唯一的格式,是每个参与者快速发表一句话:

  • 他们自上次站立会议以来所做的工作,无论是完整还是其他。

  • 他们计划在下一个站立会议之前要处理的工作。

  • 他们正在处理的障碍,其他团队成员可能能够提供帮助。

故事的大小估计不应该基于任何时间估计。这样做往往会忽略对复杂性和风险的评估,这可能是非常重要的,并且意味着期望所有开发人员能够在相同的时间内完成相同的故事,这可能不会是情况。而应该使用故事点或者 T 恤尺码(额外小,小,中,大,额外大和额外额外大)!

  1. 从开始到结束,一个典型的冲刺会按照以下方式展开,假设一切顺利:

  2. 第 1 天冲刺启动活动

  3. 故事和任务被设置在任务板上,无论是真实的还是虚拟的,都处于未开始状态,按优先级排序。

  4. 团队成员认领要处理的故事,从优先级最高的项目开始。如果有多个人在处理一个故事,他们会各自认领与之相关的任务。认领的故事会被移动到任务板上的进行中状态。

  5. 第 1 天-冲刺结束前的一天:开发和质量保证。

  6. 每日站立会议(可能在第一天被跳过)。

  7. 开发

  8. 当任务完成时,它们的状态会在任务板上更新以表示完成。

  9. 当故事完成后,它们会在开发后移动到任务板上的下一个状态。这一列可能是开发完成准备质量保证,或者根据团队结构合理的其他状态描述。

  10. 如果遇到障碍,他们会通知Scrum Master,负责促进解决阻塞问题。如果不能立即解决,被阻塞的故事或任务的状态应该在任务板上更新,并且开发人员继续处理他们能够处理的下一个任务或故事。

    1. 随着路障的解决,它们所阻碍的项目重新进入开发状态,并从那时起正常进展。没有什么可以说开发人员在解决了路障后必须继续处理该项目。
  • 质量保证活动

  • 如果质量保证人员嵌入到开发团队中,他们的流程通常类似于开发活动,只是他们会从显示开发完成项目的任何列中开始测试一个故事。

  • 测试一个故事应该至少包括该故事的验收标准

  • 测试可能会包括不属于验收标准的功能测试。

  • 故事验收:如果有任何已完成但尚未被接受的故事,它们可以被相关利益相关者演示和接受或拒绝。被拒绝的项目可能会回到开发中未开始状态,这取决于为什么被拒绝以及可以做什么来解决被拒绝的原因。

  • Sprint 结束日

  • 演示和接受任何剩余的故事。

  • 如果之前没有时间进行,应该进行下一个 Sprint 的准备:

  • Sprint 规划,为下一个 Sprint 准备用户故事。

  • 待办事项梳理,为需要这些细节的用户故事准备和定义细节和任务。

  • 接受剩余的故事。

  • 回顾会议——团队聚集在一起,确定以下内容:

  • Sprint 中表现良好的地方,以便尝试利用使其表现良好的因素。

  • Sprint 中表现不佳或根本不起作用的地方,以避免将来出现类似情况。

所有的日常活动都围绕着一个任务板展开,它提供了一个快速的机制,方便地看到正在进行的工作以及每个项目的状态:

一个示例任务板,显示了不同开发阶段的故事和任务。所示的任务板比技术上所需的更详细的状态列——最基本的列集将是故事,顶层故事的细节存放在那里,直到完成,未开始进行中,用于 Sprint 中的任务,以及完成,任务(可能还有故事)完成、测试并准备接受时所在的位置。

Scrum 的优先事项是其专注于透明度、检查和自我纠正,以及对不断变化的需求和要求的适应性。任务板是方法论透明度方面的重要组成部分,允许任何感兴趣的人一目了然地看到开发工作的当前状态。但事情并不止于此——还有一个称为产品负责人的角色,他充当开发团队和系统的所有利益相关者之间的中心沟通点。他们参加每日站立会议,以便近乎实时地了解进展、路障等,并且有望代表整个利益相关者集合发言和做出决策。他们还负责在出现问题或关注点时将团队成员与外部利益相关者联系起来,如果产品负责人自己无法解决问题。他们的角色对于确保向利益相关者提供有关进行中的开发工作的透明度和不让开发团队受到他们的持续状态报告的负担之间保持良好平衡至关重要。

Scrum 期望在过程本身中进行相当多的自我检查,并鼓励对过程结果——所创建的软件以及用于创建它的实践和纪律——进行类似的检查,通过优先考虑团队的开放性和成员之间的交流,提供一种提高风险和阻碍条件可见性的机制,甚至在一定程度上通过鼓励涉及最小工作量以实现给定功能目标的用户故事。当出现问题或问题时,强调立即沟通和随时有人可以提供指导和做出决策,以便快速解决这些问题,并最小程度地干扰正在进行的开发过程。

Scrum 或许是从适应变化的角度来看最好的方法之一。想象一下,一个开发团队在两周(或更长时间)的迭代的第一周一直在项目的各个部分上工作。在那时,利益相关者层面上的某人突然决定需要对其中一个故事进行更改。这种变化需要的原因可能有好的、坏的或中立的几种可能。

也许故事背后的功能被认为已经过时,根本不再需要——如果故事尚未完成,那么它可以简单地从迭代中移除,并从待办事项中拉取另一个故事进行处理,如果有的话,它的规模不大于被移除的故事。如果已经编写了针对该故事的代码,那么它可能需要被移除,但就对代码库的影响而言,就是这样了。如果故事已经完成,那么相关的代码也会被移除,但不会拉取新的工作(额外的故事)。

如果故事发生了变化——例如,其背后的功能被改变以更好地适应用户需求或期望——那么这个故事就会以与被移除相同的方式从当前迭代中撤回,至少是这样。如果有时间重新调整故事并将其重新插入迭代,那么可以这样做,否则它将被添加到待办事项列表中,可能是按优先级透视在列表的顶部或附近。

偶尔,迭代可能会出现偏离预期的情况,但该方法也对如何处理这种情况有期望。如果由于任何原因迭代无法成功完成,它应该停止,并计划一个新的迭代从上一个迭代结束的地方开始。

Scrum 的一些有利方面包括:

  • Scrum 非常适合可以分解为小而快速努力的工作。即使在大型系统中,如果对大型代码库的添加或更改可以用简短、低工作量的故事来描述,那么 Scrum 是一个很好的应用过程。

  • Scrum 非常适合在其领域内具有相对一致技能的团队。也就是说,如果团队中的所有开发人员都可以在项目的主要语言中编写代码而无需太多帮助,那么这种团队动态比只有六名团队成员中的一名能够做到这一点要好。

同时,由于 Scrum 过程中涉及的结构,有一些注意事项:

  • 由于迭代代表了完成一组故事和功能的承诺,即使有很好的理由,改变正在进行的迭代也是麻烦的、耗时的和具有破坏性的。这意味着,无论是谁在做出可能需要改变正在进行的迭代的决定的位置上,都需要意识到这些决定可能带来的潜在影响——理想情况下,也许他们会避免在没有真正非常好的理由的情况下对迭代进行破坏性的改变。

  • Scrum 可能不太适合满足项目或系统级的截止日期,除非团队在系统和代码库的整个领域具有相当多的专业知识。迭代截止日期风险较小,尽管它们可能需要改变或减少范围,以便按迭代交付可工作的软件。

  • 如果团队成员发生变化,开发工作和产出就会变得不太可预测——每个新团队成员,特别是如果他们在不同时间加入团队,都会对团队的可预测性产生一定影响,直到新团队名单有时间稳定下来。Scrum 对这些变化特别敏感,因为新团队成员可能没有满足迭代承诺所需的所有必要部落知识。

  • 如果团队成员不都在同一物理区域,Scrum 可能效果不佳,甚至根本行不通。使用现代远程会议,进行每日站立会议仍然是可能的,其他各种会议也是如此,但 Scrum 旨在是协作的,因此更容易直接接触其他团队成员很快就变得重要,一旦出现问题或疑问。

  • 除非经过精心管理,Scrum 往往会加强团队中技能集的隔离——如果只有一个开发人员知道系统需要的第二语言编写代码的方法,那个人将更频繁或默认地被选中执行任何需要这种知识的任务或故事,以满足迭代的承诺。有意识地将加强隔离的故事或任务转变为团队或成对开发工作可以在很大程度上减少这些影响,但如果没有努力,或者没有支持减少这些隔离,它们将持续存在。

  • 如果系统有很多外部依赖(例如来自其他团队的工作),或者开发人员必须应对大量的质量控制工作,Scrum 可能会具有挑战性。如果这些质量控制要求与法律或监管要求相关联,这可能会特别棘手。确保外部依赖本身更可预测可以在很大程度上缓解这些挑战,但这可能超出团队的控制范围。

Scrum 和 SDLC 模型的阶段

我们的 SDLC 模型中对开发工作至关重要的阶段发生在 Scrum 过程的特定部分:

  • 开发开始之前:

  • 需求分析和定义发生在故事创建和修饰过程的部分,通常在冲刺规划期间进行一些后续工作。目标是在故事被包含在冲刺之前,每个故事的需求都是已知和可用的。

  • 系统架构和设计项目遵循相同的模式,尽管一个迭代中的故事也可能有架构和/或设计任务。

  • 开发过程本身:

  • 显然,开发发生在冲刺期间。

  • 质量保证活动通常也作为冲刺的一部分进行,应用于开发人员认为每个故事完成时。如果测试活动揭示问题,故事将回到“开发中”状态,或者可能是任务板上的较早状态,并将尽快进行修正。

  • 系统集成和测试也可能在冲刺期间进行,假设有环境可用于执行这些活动并使用新代码。

  • 验收可以在每个故事通过所有 QA 和系统集成和测试活动的基础上逐个故事进行,也可以在冲刺结束的演示和验收会议上一次性进行。

很容易理解为什么 Scrum 如此受欢迎——从开发者的角度来看,通过纪律性的规划和投入精心关注以确保开发人员的时间得到尊重和合理分配,他们的日常关注点减少到了他们当下正在处理的工作。在一个成熟的团队中,具有相对一致的技能和对系统及其代码库的良好工作知识,从业务角度来看,Scrum 将是相当可预测的。最后,Scrum 如果得到谨慎和纪律的管理,是自我纠正的——随着问题或关注点的出现,无论是与流程相关,还是在某种程度上与系统和代码库相关,流程都会提供解决和纠正这些问题的机制。

Kanban

作为一个流程,Kanban 与 Scrum 有很多相似之处:

  • 主要工作单位是用户故事。

  • 故事具有相同类型的故事级别的流程状态,以至于相同类型的任务板,无论是真实的还是虚拟的,都用于跟踪和提供工作进行中的可见性。

  • 在开始工作之前,故事应该准备好所有的要求和其他相关信息。这意味着存在某种故事整理过程,尽管它可能没有 Scrum 中等效的形式化结构。

看板,与 Scrum 不同:

  • 没有时间限制——没有冲刺。

  • 不要求或期望每日状态/站立会议,尽管这是一个足够有用的工具,因此通常被采用。其他变体和方法,也许首先关注被阻止的项目,然后关注进行中的项目的问题,然后其他任何问题,也是可行的。

  • 不要求或期望故事被规模化,尽管这是一个足够有用的工具,尤其是如果它是优先为开发故事进行规模化的标准。

Kanban 的主要重点可以描述为努力减少上下文变化,这表现为在完成单个故事之前,不断地工作,然后再转移到下一个故事。这经常导致根据需求对功能进行优先排序,这在存在故事之间功能依赖关系的情况下非常适用。

在 Scrum 流程中,可能也会出现工作直到完成的重点,但实际上并不是期望的,因为 Scrum 的目标是在一个冲刺中完成所有故事,并且可能需要团队中其他人的帮助来在任何时候完成一个故事。

Kanban 的整个流程非常简单:

  • 故事(及其任务)准备就绪,并为工作进行优先排序

  • 一个或多个开发人员选择一个故事,并一直工作直到完成,然后再选择另一个故事,依此类推

  • 在进行开发和处理当前故事的工作时,新故事会随着细节的逐渐明确而准备就绪,并相应地进行优先排序

Kanban 与 Scrum 有不同的政策和程序,提供了不同的优势:

  • Kanban 非常适用于存在重要知识或专业技能孤立的工作,因为它专注于完成功能,无论需要多长时间。

  • Kanban 处理的故事和功能既大又不容易分割成更小的逻辑或功能块,而无需经过将它们细分为冲刺大小块的过程(但请参见下一节对此的缺点)。

  • Kanban 直接限制了进行中的工作,这减少了开发人员过度工作的可能性,前提是工作流程得到正确和良好的规划。

  • Kanban 允许利益相关者随时添加新的工作,并具有任何优先级,尽管最好避免中断进行中的工作

  • 只要每个故事都是独立的且可交付的,每个完成的故事在被接受后就可以立即安装或实施

它也有自己的一套注意事项:

  • 看板在开发中更容易出现瓶颈,特别是如果后续故事存在大规模或长期的依赖关系——例如,可能需要三周才能完成的数据存储系统,即对需要它的许多小类结构存在依赖,如果数据存储系统完成,这些结构可能在几天内就能实现。

  • 由于看板在高于个别故事的更高层次上并没有提供任何具体的里程碑,因此如果出于外部业务原因需要这些里程碑,就需要更直接和有意识的努力来建立这些里程碑。

  • 在看板流程中,通常需要更多的有意识的思考和努力来开发分阶段的功能,以使其更有效——例如,任何具有“必须具有”、“应该具有”和“可以具有”功能的功能都需要从一开始就提供对未来阶段目标的一些认识和指导,以保持高效。

  • 看板不要求整个团队都了解工作的设计基础,这可能导致误解,甚至导致开发工作目标不一致。有意识地打破设计,并提高对更大规模需求的整体认识可能是必要的,而一开始可能并不明显。

看板和 SDLC 模型的阶段

许多敏捷流程,特别是那些以故事作为基本工作单位的流程,有很多相似之处。由于在讨论 Scrum 时已经对大多数与故事相关的内容进行了详细描述,因此后续使用故事的其他方法只会注意到主题的变化:

  • 开发开始之前:需求分析和定义,系统架构和设计的工作方式与 Scrum 中的工作方式基本相同,出于许多相同的原因。主要区别在于,看板中期望的结构较少正式,以实现将需求和架构细节附加到故事中。通常情况下,这种情况发生在有时间和/或认为有需要的情况下,例如开发团队接近可用故事的情况。

  • 开发过程本身:开发和质量保证流程是故事完成过程中的一部分。系统集成和测试也是如此,接受基本上必须在故事的生命周期中发生,因为没有一个结束冲刺的会议来展示开发结果并获得接受。

由于看板的结构较少正式,流程仪式较少,以及其流程的及时性易于理解,因此看板易于理解,且相对容易管理。在关键点上需要一些额外的关注,并且有能力识别这些关键点,有助于保持事情的顺利进行,但只要识别和解决这些关键点的能力随着时间的推移而提高,流程也会随之改善。

其他敏捷方法

Scrum 和看板并不是唯一的两种敏捷方法,甚至也不是唯一值得考虑的两种方法。其他一些值得注意的方法包括极限编程作为一个独立的方法,以及特性驱动开发和测试驱动开发,可以作为独立的方法,也可以作为其他方法的混合物。

极限编程

极限编程XP)最显著的特点可能是成对编程方法,这可能是其实施的一个组成部分。其背后的意图/期望是,两名开发人员共用一台计算机编写代码,这理想情况下可以提高他们的专注力、合作能力,更快地解决任何挑战,并能更快、更好、更可靠地检测到潜在的风险,这些风险是固有于所生成的代码中的。在成对编程的情况下,两名开发人员在编写代码和审查代码的过程中会频繁交替。并非所有的 XP 实施都使用成对编程方法,但当它不适用时,其他流程,如广泛和频繁的代码审查和单元测试,是必要的,以至少保持部分因不使用该选项而丢失的好处。

作为一种方法论,XP 可能无法处理高度复杂的代码库或对代码库的高度复杂更改,而不牺牲其开发速度的一部分。它也倾向于需要比 Scrum 和 Kanban 等更及时的方法更多的密集规划和需求,因为成对开发人员应该理想情况下能够尽可能自主地工作在代码上。成对团队拥有的信息越多,他们需要花费的时间就越少,而且对他们的努力造成的干扰也就越少。XP 实际上没有任何跟踪进度或保持努力和障碍可见的方法,但可以采用或从其他方法中添加一些东西是完全可能的。

特征驱动开发

特征驱动开发FDD)过程中的主要工作单元是一个特征。这些特征是详细系统建模工作的最终结果,重点是在显著细节上创建一对多的领域模型,绘制出特征在系统领域中的位置,它们如何(或是否)预期相互交互——这些信息应该来自用例数据结构模型和进程间通信模型。一旦整体模型建立,就会构建并优先考虑特征列表,以至少尝试将列表中每个特征的实施时间框架保持在合理的最大限度内——两周似乎是典型的限制。如果一个单独的特征预计需要超过最长可接受的时间,就会将其细分,直到可以在该时间段内完成和交付。

一旦完整的功能列表准备好进行实施,就会计划围绕固定时间周期完成这些功能的迭代。在每个迭代中,将功能或功能集分配给开发人员,单独或成组。这些开发人员制定最终的实施设计,并在需要时进行审查和完善。一旦设计被认为是稳固的,就会进行代码的开发和测试以实施设计,并将产生的新代码推广到构建或分发准备就绪的代码库进行部署。

FDD 与几种开发最佳实践相辅相成——自动化测试、配置管理和定期构建,以便,即使它们不是完整的、正式的持续集成过程,它们也非常接近。特征团队通常很小,动态形成,并且至少应该有两个人员,以促进协作和早期反馈,特别是在特征的设计和实施质量上。

FDD 可能是大型和复杂系统的一个很好的选择——通过将工作分解为小的可管理的功能,即使在非常大型、非常复杂的系统的情况下,开发也将是可维护的,并且成功率很高。围绕让任何个体功能运行起来的过程是简单且易于理解的。除了偶尔的签入以确保开发不会因某种原因而停滞外,FDD 非常轻量级且不会干扰。功能团队通常会有一个与之相关的首席开发人员,负责协调开发工作并在必要时完善实施细节。然而,这意味着首席开发人员不太可能为实际代码做出贡献,特别是如果他们大部分时间都在执行协调或设计完善工作,或者指导团队的其他成员。

测试驱动设计

测试驱动设计TDD),顾名思义,首先专注于使用代码库的自动化测试来指导开发工作。整个过程分解为以下步骤:

  • 对于正在实现的每个功能目标(新功能或增强功能):

  • 编写一个新的测试或一组测试,直到被测试的代码满足被测试的任何合同和期望为止。

  • 确保新的测试按预期失败,不会因其他原因失败。

  • 编写通过新测试的代码。最初可能非常笨拙和不优雅,但只要满足测试中嵌入的要求,这并不重要。

  • 根据需要对新代码进行改进和/或重构,重新测试以确保测试仍然通过,将其移动到代码库中的适当位置(如果需要),并确保它满足代码库作为整体的其他标准和期望。

  • 运行所有测试,以证明新代码仍然通过新测试,并且没有其他测试因新代码而失败。

TDD 作为一个过程提供了一些明显的好处:

  • 系统中的所有代码都将进行测试,并至少具有完整的回归测试套件

  • 由于编写代码的主要目标只是通过为其创建的测试,因此代码通常只能足够实现这一目标,这通常会导致更小、更易管理的代码库

  • 同样,TDD 代码往往更加模块化,这几乎总是一件好事,而且通常会导致更好的架构,这也有助于更易管理的代码

主要的权衡,显然也是,测试套件必须被创建和维护。随着系统的增长,它们将变得越来越庞大,并且执行起来需要更长的时间,尽管显著的增加(希望)需要一段时间才能显现。创建和维护测试套件需要时间,这本身就是一种纪律——有人认为编写良好的测试是一种艺术形式,甚至有相当多的真理。除此之外,人们倾向于寻找错误的度量标准来显示测试的表现如何:例如代码覆盖率,甚至只是单个测试用例的数量,这些指标并不表示测试的质量。

开发范式

编程在最初出现时,通常受到硬件能力和当时可用的简单过程代码的高级语言的限制。在那种范式中,程序是一系列步骤,从头到尾执行。一些语言支持子程序,甚至可能支持简单的函数定义功能,还有一些方法,例如循环遍历代码的部分,以便程序可以继续执行,直到达到某种终止条件,但总的来说,这是一系列非常蛮力的,从头到尾的过程的集合。

随着基础硬件能力随着时间的推移不断改进,更复杂的功能开始变得更容易获得——正式的函数现在通常被认为更强大,或者至少具有灵活的循环和其他流程控制选项等。然而,除了一些通常只在学术界的大厅和墙壁内才能获得的语言外,在主流努力中,直到 20 世纪 90 年代,当面向对象编程首次开始成为重要甚至主导范式时,程序化方法并没有发生太多重大变化。

以下是一个相当简单的程序化程序的示例,它要求输入一个网站的 URL,读取其数据,并将该数据写入文件:

#!/usr/bin/env python
"""
An example of a simple procedural program. Asks the user for a URL, 
retrieves the content of that URL (http:// or https:// required), 
writes it to a temp-file, and repeats until the user tells it to 
stop.
"""

import os

import urllib.request

if os.name == 'posix':
    tmp_dir = '/tmp/'
else:
    tmp_dir = 'C:\\Temp\\'

print('Simple procedural code example')

the_url = ''
while the_url.lower() != 'x':
    the_url = input(
        'Please enter a URL to read, or "X" to cancel: '
    )
    if the_url and the_url.lower() != 'x':
        page = urllib.request.urlopen(the_url)
        page_data = page.read()
        page.close()
        local_file = ('%s%s.data' % (tmp_dir, ''.join(
            [c for c in the_url if c not in ':/']
            )
        )).replace('https', '').replace('http', '')
        with open(local_file, 'w') as out_file:
            out_file.write(str(page_data))
            print('Page-data written to %s' % (local_file))

print('Exiting. Thanks!')

面向对象编程

面向对象编程的独特特点是(毫不奇怪)它通过对象的实例来表示数据并提供功能。对象是数据结构或属性的集合,它们具有相关的功能(方法)附加到它们上。对象根据需要从类构造,通过定义属性和方法,它们共同定义了对象是什么,或者拥有什么,以及对象能做什么。面向对象的方法允许以一种显著不同且通常更有用的方式处理编程挑战,因为这些对象实例会跟踪自己的数据。

以下是与之前显示的简单程序化示例相同的功能,但使用面向对象的方法编写:

#!/usr/bin/env python
"""
An example of a simple OOP-based program. Asks the user for a URL, 
retrieves the content of that URL, writes it to a temp-file, and 
repeats until the user tells it to stop.
"""

# Importing stuff we'll use
import os

import urllib.request

if os.name == 'posix':
    tmp_dir = '/tmp/'
else:
    tmp_dir = 'C:\\Temp\\'
if not os.path.exists(tmp_dir):
    os.mkdirs(tmp_dir)

# Defining the class

class PageReader:
    # Object-initialization method
    def __init__(self, url):
        self.url = url
        self.local_file = ('%s%s.data' % (tmp_dir, 
                ''.join(
                [c for c in the_url if c not in ':/']
                )
            )).replace('https', '').replace('http', '')
        self.page_data = self.get_page_data()
    # Method to read the data from the URL
    def get_page_data(self):
        page = urllib.request.urlopen(self.url)
        page_data = page.read()
        page.close()
        return page_data
    # Method to save the page-data
    def save_page_data(self):
        with open(self.local_file, 'w') as out_file:
            out_file.write(str(self.page_data))
            print('Page-data written to %s' % (self.local_file))

if __name__ == '__main__':
    # Almost the same loop...
    the_url = ''
    while the_url.lower() != 'x':
        the_url = input(
            'Please enter a URL to read, or "X" to cancel: '
        )
        if the_url and the_url.lower() != 'x':
            page_reader = PageReader(the_url)
            page_reader.save_page_data()
    print('Exiting. Thanks!')

尽管这执行的是与用户所关心的完全相同的任务,以完全相同的方式,但在其背后的是一个执行所有实际工作的PageReader类的实例。在此过程中,它存储各种数据,可以作为该实例的成员进行访问。也就是说,page_reader.urlpage_reader.local_filepage_reader.page_data属性都存在,如果需要检索这些数据,可以检索并使用page_reader.get_page_data方法再次调用以获取页面上的新数据。重要的是要注意这些属性附加到实例上,因此可以拥有多个PageReader实例,每个实例都有自己的数据,可以使用自己的数据执行相同的操作。也就是说,如果执行以下代码:

python_org = PageReader('http://python.org')
print('URL ................ %s' % python_org.url)
print('Page data length ... %d' % len(python_org.page_data))
google_com = PageReader('http://www.google.com')
print('URL ................ %s' % google_com.url)
print('Page data length ... %d' % len(google_com.page_data))

将产生以下输出:

面向对象的设计和实现使得开发复杂系统以及相关复杂交互的工作在很大程度上变得更容易,尽管它可能并非所有开发挑战和努力的灵丹妙药。然而,如果遵循良好的面向对象设计原则,通常会使代码更易编写、更易维护,且更不容易出错。面向对象设计原则的全面讨论远远超出了本书的范围,但如果不遵循一些更基本的原则,可能会导致许多困难,其中一些原则如下:

  • 对象应该具有单一责任——每个对象应该只做(或代表)一件事,并且做得很好

  • 对象应该对扩展开放,但对修改关闭——除非是全新的功能,否则对实例的更改不应该需要修改实际代码

  • 对象应该封装变化的部分——不应该需要使用对象来了解它是如何做和做什么的,只需要知道它可以做到

  • 对象的使用应该是对接口的编程练习,而不是对实现的编程练习——这是一个复杂的主题,值得进行一些详细讨论,并提供一些实质和背景,因此在第九章中会进行详细讨论,测试业务对象,同时制定hms_sys项目的架构

函数式编程

函数式编程FP)是一种围绕通过一系列纯函数传递控制的开发方法,避免共享状态和可变数据结构的概念。也就是说,在 FP 中,大部分真正的功能都包装在函数中,对于任何给定的输入,它们总是返回相同的输出,并且不修改任何外部变量。从技术上讲,纯函数不应该向任何地方写入数据——无论是记录到控制台或文件,还是写入文件——如何满足这种输出需求是一个远超出本书范围的讨论。

以下是前两个示例中的相同功能,但是使用了函数式编程方法进行编写(即使只是勉强,因为它执行的任务并不是那么复杂):

#!/usr/bin/env python
"""
An example of a simple FP-based program. Asks the user for a URL, 
retrieves the content of that URL, writes it to a temp-file, and 
repeats until the user tells it to stop.
"""

# Importing stuff we'll use
import os

import urllib.request

if os.name == 'posix':
    tmp_dir = '/tmp/'
else:
    tmp_dir = 'C:\\Temp\\'
if not os.path.exists(tmp_dir):
    os.mkdirs(tmp_dir)

# Defining our functions

def get_page_data(url):
    page = urllib.request.urlopen(url)
    page_data = page.read()
    page.close()
    return page_data

def save_page_data(local_file, page_data):
    with open(local_file, 'w') as out_file:
        out_file.write(str(page_data))
        return('Page-data written to %s' % (local_file))

def get_local_file(url):
  return ('%s%s.data' % (tmp_dir, ''.join(
      [c for c in the_url if c not in ':/']
      )
    )).replace('https', '').replace('http', '')

def process_page(url):
    return save_page_data(
        get_local_file(url), get_page_data(url)
    )

def get_page_to_process():
    the_url = input(
        'Please enter a URL to read, or "X" to cancel: '
    )
    if the_url:
        return the_url.lower()
    return None

if __name__ == '__main__':
    # Again, almost the same loop...
    the_url = get_page_to_process()
    while the_url not in ('x', None):
        print(process_page(the_url))
        the_url = get_page_to_process()
    print('Exiting. Thanks!')

再次,这段代码执行的是完全相同的功能,并且它与前两个示例一样以相同的离散步骤/过程执行。然而,它这样做,而不必实际存储它正在使用的各种数据——在过程本身中没有可变数据元素,只有在process_page函数的初始输入中,即使如此,它也不会长时间保持可变状态。主函数process_page也不使用任何可变值,只是其他函数调用的结果。所有的组件函数都会返回一些东西,即使只是None值。

函数式编程并不是一种新的范式,但直到相对最近才被广泛接受。它有可能像面向对象编程一样具有根本性的颠覆性。它也不同,从许多方面来看,因此转向它可能会很困难——毕竟,它依赖于完全不同的方法,并且在现代其他开发范式中或者说是基于一个非常不典型的无状态基础。然而,这种无状态的特性,以及它在执行过程中强制执行严格的事件顺序,有可能使基于 FP 的代码和过程比它们的面向对象或过程化的对应物更加稳定。

开发实践

至少有两种后开发过程自动化实践已经出现,要么是作为一些增量开发方法的结果,要么仅仅是同时出现的:持续集成和持续交付(或部署)。

持续集成

持续集成CI),简单来说,是将新的或修改的代码合并到一个共享环境中的可重复自动化过程,无论是在某种定时基础上,还是作为一些事件的结果,比如提交更改到源代码控制系统。其主要目标是尽早在代码推广或部署过程中检测潜在的集成问题,以便在部署到实时生产分支之前解决任何出现的问题。为了实施 CI 过程,无论使用何种工具来控制或管理它,都有一些先决条件:

  • 代码需要在某种版本控制系统中进行维护,并且理想情况下,应该有一个且仅有一个 CI 进程将执行的分支。

  • 构建过程应该是自动化的,无论是按照预定的时间表触发,还是作为对版本控制系统的提交的结果。

  • 作为构建过程的一部分,所有自动化测试(特别是单元测试,但任何可以有用地执行的集成或系统测试至少应该被考虑包含)都应该执行。关于何时执行这些测试可能值得讨论,因为可能有两个或更多的机会窗口,它们都有各自的优势:

  • 如果工具和流程可以防止提交或构建,或者在测试失败时将提交回滚到其上一个良好状态,那么在提交和构建完成之前执行测试将防止未通过测试的代码被提交。在这种情况下的权衡是可能会导致两个或更多代码更改源的冲突变化显著混乱,并且需要相应重要的注意力来解决。此外,如果有问题的代码无法提交,那可能会使将有问题的代码移交给可能能够快速解决问题的不同开发人员变得困难。

  • 在构建后执行的测试将允许已经失败了一个或多个测试的代码被提交到集体代码库,但至少已知存在问题。根据这些问题的形状和范围,它可能会破坏构建——这可能会对整个团队的生产力造成破坏。

  • 需要建立某种通知流程,以提醒开发人员存在问题——特别是如果问题导致构建失败。

  • 该过程需要确保每个提交都经过测试并成功构建。

  • 成功构建的结果需要以某种方式提供——无论是通过某种脚本化或自动化部署到特定的测试环境,使新构建的安装程序可供下载,还是任何其他最适合产品、团队或利益相关者需求的机制。

有了这些,流程的其余部分只是解决一些流程规则和期望,并在需要时实施、监控和调整它们:

  • 提交应该何时发生?每天?在故事、功能或任何适用的工作单元的开发结束时?

  • 提交-测试-构建过程需要多快才能运行?如果有的话,可以采取哪些步骤使其足够快以便有用?

持续交付或部署

持续交付或部署CD)是 CI 过程的自然延伸或衍生,它将每个成功的构建收集所有涉及的组件,并直接部署它(通常用于 Web 和云驻留应用程序和系统),或者采取必要的步骤使新构建可用于部署——例如创建最终的、面向最终用户或生产就绪的安装包,但实际上不部署它。

完整的 CD 过程将允许仅基于源代码控制系统中的信息创建、更新或重新创建生产系统。它还可能涉及一些配置管理发布管理工具在系统管理方面,并且这些工具可能会对系统的设计和实施施加特定的要求,无论是在功能上还是在架构上,或者两者兼而有之。

摘要

希望这几章至少让你对在软件工程中有用的开发工作中的所有流动部分(除了实际编写代码之外)有所了解。很可能任何一个团队或公司都会选择哪种方法论,以及在开发前和开发后的过程中会发挥什么作用。即便如此,了解它们会带来什么期望,或者在其各种组合背景下工作时可能引起关注的原因,都是有用的信息,通常是程序员和软件工程师之间的期望之一。

说了这么多,现在是时候更深入地看待这些组合的核心内容了——开发过程本身。为了做到这一点,我们需要一个系统——一个要处理的项目。

第五章:hms_sys 系统项目

接下来的几章将重点介绍一个虚构公司手工制品的项目,该公司专门连接消费者和创造并销售各种独特手工制品的工匠。这些产品涵盖了各种材料和用途,包括家具、工艺品和珠宝首饰,如用于服装的珠子和小零件。基本上,只要有人愿意制作,另一个人愿意购买的任何东西。

系统的目标

手工制品(HMS)现在正在寻找一种简化业务流程的方法,以便允许工匠通过主网站提供其商品。目前,当工匠制作出他们愿意出售的商品时,他们会向HMS中央办公室的某人发送电子邮件,如果是新产品,则附上一张或多张照片,有时如果是以前提供的产品的新版本或套装,则附上新照片。HMS中央办公室的某人将相关信息复制到他们的网络系统中,并进行一些基本设置以使商品可用。然后,一旦消费者决定要订购工匠制作的商品,订单将通过另一个涉及HMS中央办公室向工匠发送订单信息的手动流程进行处理。

所有这些手动流程都很耗时,有时容易出错。偶尔会出现这样的情况,处理信息以使第一个订单开始运转的时间太长,以至于有多个客户尝试购买同一件商品:

手工制品的网站采用的是一个不易修改的现成系统。它确实有一个 API,但该 API 设计用于内部访问流程,因此存在安全问题,不便于通过新的 Web 应用程序开发允许工匠连接到它。

这家虚构公司的业务可能并不是非常现实。它确实感觉不像实际上能够与 Etsy 或(也许)craigslist 或 eBay 等现有企业竞争。即便如此,该系统的实施概念在某种程度上是合理的,因为它们是需要在几个真实问题领域中实施的任务的变体。它们只是以一种不寻常的方式结合在一起。

由于以下章节旨在代表单独的开发迭代,在至少在某种程度上类似于看板方法的过程中,有一些值得注意的开发前流程的产物,这些值得注意的产物在进入这些迭代/章节之前是值得注意的。

开发开始前已知/设计的内容

新系统的主要目标是简化并(尽可能)自动化现有流程,以将工匠的产品放入在线目录中。具体包括:

  • 工匠应该能够提交产品信息,而无需经过基于电子邮件的流程。作为这一变化的一部分:

  • 将执行一些数据输入控制,以防止简单的错误(缺失或无效数据)。

  • 工匠将能够修改其产品数据,但有一些限制,并且在这些修订生效之前仍需要进行审核。但至少,他们将能够停用实时产品列表,并激活已停用的现有商品。

  • 产品评审员将能够直接进行修订(至少对于简单的更改),并将商品退回进行重大修订。这一部分的流程定义不够明确,可能需要在开发周期后期进一步详细和定义。

  • 产品经理的数据输入任务将大大减少,至少就设置新产品而言。新系统将处理大部分或全部任务。

新流程的用例图如下,然后,在进行任何详细设计之前:

打算为每个工匠提供一个可安装的应用程序,使他们能够与HMS总部进行交互。该本地应用程序将连接到一个工匠网关,该网关将处理工匠与总部的通信,并将工匠的传入数据存储为一种暂存区,以便待批准的任何内容。从那里,评审员(和/或产品经理)应用程序将允许产品评审员和经理将工匠提供的产品移入主网店,使用其本机 API。在这一点上,逻辑架构和一些粗略的进程间通信流程如下所示:

在这些图表和之前提到的初始概念之间,已经捕捉到了许多具体的用户需求。在开发过程中可能会出现更多需求,或者至少在开发计划(迭代故事)制定过程中会出现更多需求。

工匠及其产品背后的实际数据结构尚不清楚,只知道产品是可以由一个且仅一个工匠拥有的独特元素。需要更多细节来实现这些,以及确定数据移动到何处(以及何时),但它们之间的关系已经可以绘制成图:

关于这些元素内部数据结构的当前缺乏信息也使得任何类型的 UI 设计规范变得困难,甚至是不可能的。同样,要确定任何不是已经隐含在用例和逻辑架构/数据流图中的业务规则也将是困难的。在能够识别出更多有用信息之前,这些也需要更多细节。

还有一些其他各种项目可以从这些信息中推断出,并分为以下几个开发前步骤之一:

  • 风险

  • 评审/管理应用程序网店数据库之间的连接是单向的,这可能表明需要仔细控制数据流。实际上,可能至少需要应用程序能够从数据库中读取数据,这样就可以找到并修改现有产品,而不是一遍又一遍地创建新的产品条目。

  • 用例图显示,工匠可以激活或停用产品而不涉及产品评审员,但架构和流程没有明显的方法来处理该功能。至少应对从工匠网关到网店数据库的连接进行检查,但这是可以在相关开发迭代期间进行的事情。由于网店系统具有 API,可能可以通过从工匠 网关网店应用程序发出 API 调用来管理该过程,但尚未进行评估。

  • 项目管理规划数据

  • 如果项目已经进入开发阶段,那么很可能所有的可行性、成本分析和其他业务层面的审查都已经完成并获得批准。虽然可能不需要这些结果中的具体信息,但知道如果出现问题,它们可能是可用的是一件好事。

迭代章节将是什么样子

为了展示在敏捷过程下开发系统的样子,hms_sys的开发将分解为几个迭代。每个迭代都有一个单一的高层目标,涵盖一个或多个章节,并涉及一组共同的故事。在第四章中讨论的敏捷方法中,这些章节更接近于 Kanban 方法,因为每个迭代中完成的故事数量和总大小在不同迭代之间有显着变化。在 Scrum 环境中,这些迭代将受到时间限制,分解为时间限制的块 - 也就是说,每个迭代都计划持续一段特定的时间。以下章节及其对应的迭代目标是以目标为导向的,每个目标旨在实现系统功能的某个里程碑。在这方面,它们也接近于遵循特征驱动开发模型。

每个迭代都将解决相同的五个项目:

  • 迭代目标

  • 故事和任务的组装:

  • 来自 SDLC 模型的需求分析和定义活动,如/如果需要

  • 系统架构和设计活动,也来自 SDLC 模型,如/如果需要

  • 编写和测试代码。

  • 系统集成、测试和验收。

  • 开发后的考虑和影响:

  • 实施/安装/分发

  • 运营/使用和维护

  • 停用

迭代目标和故事

每个迭代将有一个非常具体且相对集中的一组目标,建立在以前迭代的成就之上,直到最终系统完成。按顺序,每个迭代的目标是:

  • 开发基础:建立项目和流程。每个功能迭代在完成时都需要可测试、可构建和可部署,因此在系统项目的早期需要注意确保在开发进展中有一种共同的基础来构建这些功能。

  • 业务对象基础:定义和开发业务对象数据结构和功能。

  • 业务对象数据持久性:确保可以根据需要存储和检索使用的各种业务对象。

  • 服务基础:构建主办公室和工匠服务的基本功能,这将成为整个系统通信和数据交换过程的支柱。

  • 服务通信:定义、详细说明和实施系统各组件之间的实际通信过程,特别是服务层的实现。

每个迭代都有可能令人惊讶地需要进行大量的设计和实现级别的决策,并且有很多机会在各种功能、概念和实现场景中运用各种软件工程原则。

每个迭代的努力将被记录在一组用户故事中,这些故事在审查 Scrum 和 Kanban 方法时描述了类型。每个迭代的完成标准将包括完成或至少解决与之相关的所有故事。有些故事可能需要推迟到以后的迭代,以适应功能依赖关系,例如,在这种情况下,可能无法在系统开发的较晚阶段完成这些故事的实现。

编写和测试代码

一旦所有故事都被详细定义以允许开发,代码本身将被编写,既用于与每个故事相关的实际功能,也用于该代码的自动化测试 - 具有内置回归测试功能的单元测试。如果可能和实际,还将编写集成和系统测试代码,以便从这些角度提供相同的自动化、可重复的新代码测试。每个迭代的最终目标将是一个可部署和功能的代码库,经过测试(并且可以按需重新测试)。在早期迭代期间可能不完整甚至无法使用,但在提供的功能方面将是稳定和可预测的。

这一过程的大部分内容将构成接下来几章的主要内容。毕竟,编写代码是开发的关键方面。

开发后的考虑和影响

hms_sys的运营/使用、维护和停用阶段将在开发完成后进行深入讨论,但在开发过程中,将努力预测与系统生命周期相关的特定需求。在这些努力中可能会编写代码来解决系统活跃生命周期中的问题,但任何在这些努力中出现的预期需求,至少可以作为开发工作的一部分写成一些文档,供系统管理员使用。

总结

hms_sys的预开发和高层概念设计项目相当直接,至少在预开发规划周期结束时可用的细节水平上是这样。一旦为各个迭代功能的用户故事详细阐述,更多细节将浮出水面,还有一系列问题和实施决策和细节。然而,首先会发生一个迭代。

如暗示的那样,第一个迭代更关注工具、流程和实践的定义,这些将在最终系统的真正开发过程中发挥作用。很可能大部分决策和设置已经由开发团队和团队管理者决定。即便如此,值得看一些决策和决策标准,这些决策和标准对开发过程中的工作效果有重大影响。

第六章:开发工具和最佳实践

在实际开发hms_sys之前,需要做出几项决定。在现实世界的情况下,一些(也许全部)这些决定可能是由开发团队或者团队上面的管理层在政策层面上做出的。有些决定,比如 IDE/代码编辑器程序,可能是每个团队成员个人的决定;只要不同开发人员的选择之间没有冲突,或者由此引起的任何问题,那就没有问题。另一方面,保持一些一致性也不是坏事;这样,每个团队成员在处理其他团队成员触及的代码时都知道可以期待什么。

这些选择可以分为两大类:开发工具的选择和最佳实践(和标准)的运用,具体包括以下内容:

  • 集成开发环境选项

  • 源代码控制管理选项

  • 代码和开发流程标准,包括将 Python 代码组织成包

  • 设置和使用 Python 虚拟环境

开发工具

需要考虑的两个最重要的工具导向决策,毫不奇怪地围绕着通过开发生命周期创建、编辑和管理代码。

集成开发环境(IDE)选项

在不使用完整的集成开发环境IDE)的情况下编写和编辑代码是完全可能的。最终,任何能够读取和写入任意类型或带有任意文件扩展名的文本文件的东西在技术上都是可用的。然而,许多 IDE 提供额外的、面向开发的功能,可以节省时间和精力——有时甚至可以节省大量的时间和精力。一般来说,权衡是,任何给定的 IDE 提供的功能和功能越多,它就越不轻量级,也就越复杂。找到一个所有开发团队成员都能同意的 IDE 可能是困难的,甚至痛苦,大多数 IDE 都有缺点,可能没有一个单一、明显的正确选择。这是非常主观的。

在查看代码编辑和管理工具时,只有真正的 IDE 将被考虑。正如前面所述,文本编辑器可以用来编写代码,市面上有很多识别各种语言格式的文本编辑器,包括 Python。然而,无论它们有多好(有些确实非常好),如果它们没有提供以下至少一项功能能力,它们将不被考虑。这只是一个时间问题,直到列表中的某个功能是必需的,但却不可用,至少这种可能性会分散注意力,最坏的情况下,可能会成为一个关键问题(尽管这似乎不太可能)。功能集的标准如下:

  • 大型项目支持:在讨论的目的上,大型项目涉及开发两个或更多个不同的可安装的 Python 包,这些包具有不同的环境要求。一个例子可能包括一个business_objects类库,它被两个独立的包如online_storeback_office所使用,为不同的用户提供不同的功能。这种情况的最佳情况将包括以下内容:

  • 支持不同的 Python 解释器(可能作为单独的虚拟环境)在不同的包项目中

  • 具有和管理项目间引用的能力(在这个例子中,online_storeback_office 包将能够对 business_objects 库有有用的引用)

  • 不太重要,但仍然非常有用的是,能够同时打开和编辑多个项目,这样当一个包项目的更改需要在另一个包项目中进行相应的更改时,开发人员几乎不需要进行上下文的变化

  • 重构支持:在足够长的时间内,不改变系统行为的情况下对系统代码进行更改是不可避免的。这是重构的教科书定义。重构工作通常需要至少能够在多个文件中查找和替换实体名称,可能还涉及多个库。在更复杂的范围内,重构可能包括创建新的类或类的成员,将功能移动到代码的不同位置,同时保持代码的接口。

  • 语言探索:检查项目使用但不是项目一部分的代码是有帮助的,至少偶尔是。这比听起来更有用,除非你很幸运拥有完美的记忆,因此从不必查找函数签名,模块成员等。

  • 代码执行:在开发过程中实际运行正在处理的代码是非常有帮助的。不得不从编辑器退出到终端以运行代码,测试对其进行更改,这是一种上下文的改变,至少是乏味的,而在适当的情况下,实际上可能会对过程产生破坏性影响。

这些项目将按照以下标准进行评分,从好到坏:

  • 极好

  • 很棒

  • 公平

  • 一般

  • 糟糕

这些是作者的观点,显然,所以要以适当的心态对待。你对这些任何或所有的个人观点,或者你对它们的需求,可能会有很大不同。

许多 IDE 具有各种花里胡哨的功能,可以在编写或管理代码的过程中帮助,但并非真正关键。这些功能的例子包括以下内容:

  • 从某个地方导航到代码实体的定义位置

  • 代码完成和自动建议,允许开发人员根据他们开始输入的实体名称的前几个字符,快速轻松地从列表中选择实体

  • 代码颜色和呈现,提供了一个易于理解的视觉指示,给出了代码块的内容 - 注释,类,函数和变量名称等

这些也将按照相同的标准进行评分,但由于它们不是关键功能,因此仅作为额外信息项呈现。

所有以下 IDE 都适用于所有主要操作系统 - Windows,Macintosh 和 Linux(可能还包括大多数 UNIX 系统),因此,评估开发工具包的 IDE 部分的重要标准在这三个中都是无效的。

IDLE

IDLE 是一个简单的 IDE,用 Python 编写,使用Tkinter GUI,这意味着它应该可以在 Python 可以运行的任何地方运行。它通常是默认 Python 安装的一部分,但即使默认情况下没有包含,也很容易安装,不需要外部依赖或其他语言运行环境。

  • 大型项目支持:差

  • 重构支持:差

  • 语言探索:好

  • 代码执行:好

  • 花里胡哨:公平

IDLE 默认情况下不提供任何项目管理工具,尽管可能有提供部分功能的插件。即使有插件可用,也可能需要每个文件都在单独的窗口中打开,这样在多个文件之间工作最终会变得乏味,甚至可能变得不切实际,甚至可能根本不可能。

尽管 IDLE 的搜索和替换功能包括一个不错的功能 - 基于正则表达式的搜索 - 但就重构目的而言,这就是有意义或有用的功能。任何重大的重构工作,甚至是广泛但范围较小的更改,都需要相对高程度的手动工作。

IDLE 真正闪亮的地方在于它能够深入挖掘系统中可用的包和模块。它提供了一个类浏览器,允许直接探索 Python 路径中的任何可导入命名空间,以及一个路径浏览器,允许探索所有可用的命名空间。这些唯一的缺点是缺乏搜索功能,以及每个类浏览器都必须驻留在单独的窗口中。如果这些不是问题,那么给予一个很高的评价似乎也不过分。

IDLE 允许通过按下一个键来执行任何打开的文件,执行的结果/输出显示在一个单独的 Python shell 窗口中。没有提供传递参数给这些执行的功能,但这可能只有在项目涉及接受参数的命令行程序时才是一个问题。IDLE 还提供了一个语法检查,识别代码中检测到的第一个语法问题,这可能有些用处。

IDLE 可靠的功能之一是代码的着色。有一些扩展可以提供诸如自动完成和一些代码编写辅助功能(例如自动生成闭合括号),但似乎没有一个是功能性的。

以下是 IDLE 的屏幕截图,显示了控制台,代码编辑窗口,类和路径浏览器窗口,以及搜索和替换窗口:

IDLE 可能是小型代码项目的合理选择 - 任何不需要打开的文件比用户在其各自窗口中显示的更多的东西。它很轻量级,具有相当稳定(偶尔古怪)的 GUI。但对于涉及多个可分发包的项目来说,它并不适合。

Geany

Geany是一个轻量级的代码编辑器和集成开发环境,支持多种语言,包括 Python。它作为一个可安装的应用程序在所有主要操作系统上都可用,尽管在 Windows 上有一些功能是不可用的。Geany 可以从www.geany.org免费下载:

  • 大型项目支持:一般

  • 重构支持:一般

  • 语言探索:一般

  • 代码执行:好

  • 花里胡哨:好

这是 Geany 的屏幕截图,显示了几个项目插件的侧边栏,一个打开的代码文件,项目设置以及搜索和替换窗口:

Geany 的界面使得同时打开多个文件变得更加容易,而在 IDLE 中进行相同的任务将会更加困难;每个打开的文件都位于 UI 中的一个标签中,使得多文件编辑变得更加容易处理。即使在其最基本的安装配置中,它也支持基本的项目结构,并且有一些不同的面向项目的插件,可以更轻松/更好地管理和查看项目的文件。通常,对于大型项目的支持,它缺少实际上可以同时打开多个项目的能力,尽管支持跨不同项目源树打开多个文件。通过一些仔细的规划,并且审慎配置各个项目的设置,可以管理不同的执行要求,甚至是一组相关项目中特定的Python 虚拟环境,尽管需要一些纪律来保持这些环境的隔离和高效。正如屏幕截图所示,Geany 还提供了项目级别的编译和构建/制作命令设置,这可能非常方便。

Geany 的重构支持略优于 IDLE,主要是因为它具有多文件搜索和替换功能。没有针对重构操作的开箱即用支持,例如在整个项目或项目集中重命名 Python 模块文件,因此这是一个完全手动的过程,但是通过一些小心(再次,纪律)甚至这些操作也不难正确管理,尽管可能会很乏味和/或耗时。

Geany 的语言探索能力看起来似乎不应该获得如此高的评分,就像给出的平庸一样。除了实际打开与给定项目相关联的每个 Python 命名空间之外,这至少可以允许在符号面板中探索这些包之外,实际上并没有太多显而易见的支持来深入了解底层语言。Geany 在这里的救赎是非常强大的自动完成功能。一旦输入了可识别语言元素的前四个字符 - 无论该元素是项目中打开文件的一部分还是导入模块的一部分 - 所有与当前输入文本匹配的元素名称都会显示并可选择,如果所选项目是函数或方法,则为该项目提供的代码提示包括该项目的参数签名。

Geany 的代码执行能力相当不错 - 在某些方面略优于 IDLE,尽管在足够的程度或足够的领域内,这并不足以获得更高的评分。通过在项目设置的早期关注需求和细节,可以配置特定项目的执行设置以使用特定的 Python 解释器,例如作为特定虚拟环境的一部分,并允许从其他项目的虚拟环境安装和代码库中导入。不利的一面是这样做需要一定程度的规划,并且在管理相关虚拟环境时引入了额外的复杂性。

Geany 的开箱即用功能与 IDLE 提供的功能相当,但有一个重大改进;有许多常见和有用任务和需求的即用插件。

Eclipse 变体+ PyDev

由 Eclipse 基金会(www.eclipse.org)管理的 Eclipse 平台旨在为任何语言和开发重点提供强大,可定制和功能齐全的 IDE。这是一个开源项目,并且至少产生了两个不同的子变体(专注于 Web 开发的 Aptana Studio 和专注于 Python 开发的 LiClipse)。

这里将使用 LiClipse 安装作为比较的基础,因为它不需要特定于语言的设置即可开始编写 Python 代码,但也值得注意的是,任何具有相同插件和扩展(PyDev 用于 Python 语言支持,EGit 用于 Git 支持)的 Eclipse 衍生安装都将提供相同的功能。总之,Eclipse 可能并不适合所有人。它可能是一个非常沉重的 IDE,特别是如果它为多种语言提供支持,并且可能具有显着的操作占用内存和 CPU 使用率 - 即使其支持的语言和功能集是相当受控制的:

  • 大型项目支持:很好

  • 重构支持:好

  • 语言探索:一般

  • 代码执行:好

  • 铃铛和口哨:好

这是 LiClipse 的屏幕截图,显示了打开的代码文件的代码大纲视图,项目属性以及从打开的代码文件中的 TODO 注释自动生成的任务列表:

Eclipse 对大型 Python 项目的支持非常好:

  • 可以定义多个项目并同时进行修改

  • 每个项目都可以有自己独特的 Python 解释器,这可以是项目特定的虚拟环境,允许每个项目基础上具有不同的包要求,同时还允许执行

  • 可以设置项目以使用其他项目作为依赖项的项目引用设置,并且代码执行将考虑这些依赖项;也就是说,如果在设置了不同项目作为引用/依赖项的项目中运行代码,第一个项目仍将可以访问第二个项目的代码和已安装的包。

所有基于 Eclipse 的 IDE 的重构支持也相当不错,提供了对代码元素重命名,包括模块,提取变量和方法以及生成属性和其他代码结构的过程。可能还有其他重构功能是上下文相关的,因此乍一看并不明显。

一旦将 Python 环境与项目关联起来,该环境的结构就完全可以在项目的 UI 中使用。单独这样做可以通过相关环境进行包和功能的深入探索。不那么明显的是,单击已安装包的成员(例如,在第五章的示例代码中的urllib.requesthms_sys 系统项目,或该模块提供的urlopen函数)将带开发人员转到项目安装中实际模块的实际成员(方法或属性)。

Eclipse 系列的 IDE 为 Python 代码提供了相当不错的执行能力,尽管需要一些时间来适应。任何模块或包文件都可以根据需要执行,并且将显示任何结果,无论是输出还是错误。对特定文件的执行还会生成一个内部运行配置,可以根据需要进行修改或删除。

Eclipse/PyDev 的铃铛和口哨在很大程度上与 Geany 和 IDLE 相当,提供了可用和可配置的代码和结构颜色,提供了自动建议和自动完成。LiClipse 特别提供的一个潜在重要项目是集成的 Git 客户端。LiClipse 的 Git 集成在克隆任何存储库之前就显示在这里:

其他

这些并不是 Python 开发的唯一可用的 IDE,也不一定是最好的。根据各种专业和半专业团体的投票,其他流行的选择包括:

  • PyCharm(社区版或专业版):PyCharm 一直是 Python 开发中受欢迎的 IDE。其功能列表包括 Geany 和 Eclipse/PyDev 工具中已经注意到的大部分功能,还具有与 Git、Subversion 和 Mercurial 版本控制系统的开箱即用集成,以及专业版中用于与各种流行的 RDBMS(如 MySQL 和 SQL Server)一起使用的 UI 和工具。对于 Python Web 应用程序的开发来说,这可能是一个很好的首选,前提是其项目管理功能不会被代码库压倒。PyCharm 可以在www.jetbrains.com/pycharm下载。

  • Visual Studio Code:VS Code 被誉为是一个闪电般快速的代码编辑器,并且通过大量的扩展提供了许多功能,适用于各种语言和目的。虽然它是支持 Python 的较新的 IDE 之一,但它正在迅速成为脚本任务的热门选择,并且在更大的面向应用程序的努力方面具有很大的潜力。Visual Studio 可以在code.visualstudio.com下载。

  • Ninja IDE:根据其功能列表,Ninja 具有 Geany 提供的大部分基本功能,还增加了一个单一的内置项目管理子系统,听起来很有用和吸引人。Ninja IDE 可以在ninja-ide.org下载。

源代码管理

无论被描述为版本控制系统还是修订控制系统,源代码管理SCM)或其他名称,更常见和更受欢迎的 SCM 提供了一系列功能和能力,使开发过程的某些方面更容易、更快或至少更稳定。这些包括以下内容:

  • 允许多个开发人员在相同代码库的相同部分上合作,而无需过多担心彼此的工作被覆盖

  • 跟踪代码库的所有版本,以及在每次提交新版本时谁做了什么更改

  • 提供对每个新版本提交时所做更改的可见性

  • 为特定目的维护相同代码库的不同版本,其中最常见的变化可能是为不同环境创建版本,代码更改在其中进行并通过推广,这可能包括:

  • 本地开发环境

  • 共享开发环境,所有开发人员的本地代码更改首先混合在一起

  • 用于 QA 和更广泛的集成测试的共享测试服务器

  • 用户验收测试服务器,使用真实的、类似生产的数据,可以用来向需要最终批准变更推广到现场环境或构建的人演示功能

  • 具有完整生产数据副本访问权限的暂存环境,以便能够执行需要访问该数据集的负载和其他测试

  • 现场环境/构建代码库

虽然这些系统在内部功能上至少有几种主要变化,但从开发人员的角度来看,只要它们按预期运行并且运行良好,这些功能上的差异可能并不重要。这些基本功能以及它们与各种手动努力的变体一起,允许以下操作:

  • 开发人员可以回滚到先前版本的完整代码库,对其进行更改,并将其重新提交为新版本,这对于以下情况可能很有用:

  • 查找并删除或修复提交后甚至推广后意外引起重大问题的更改

  • 创建代码的新分支,以尝试其他方法来实现已提交的功能

  • 多个具有不同专业领域专长的开发人员可以共同解决同一个问题和/或代码的部分,从而使他们能够更快地解决问题或编写代码。

  • 具有较强架构背景或技能集的开发人员可以定义基本的代码结构(例如类及其成员),然后将其提交给其他人完全实现。

  • 系统领域专家可以轻松审查代码库的更改,识别功能或性能风险,然后再将其推广到一个严苛的环境之前。

  • 配置管理器可以访问和部署代码库的不同版本到它们的各种目标环境

可能还有许多其他更具体的应用程序,一个良好的 SCM 系统,特别是如果它与开发和代码推广流程有良好的联系,可以帮助管理。

典型的 SCM 活动

无论使用哪种 SCM 系统,也不管具体的命令变化,可能最常见的使用模式是以下操作序列:

  • 获取给定代码库的版本:

  • 通常,这将是最近的版本,可能来自特定的开发分支,但可以获取任何需要检索的分支或版本。无论如何,该过程将在本地文件系统的某个位置创建所请求的代码库的完整副本,准备进行编辑。

  • 对本地代码副本进行更改。

  • 在提交更改之前对任何差异进行对比:

  • 这一步的目标是拉取对同一代码库所做的任何更改,并找到并解决本地更改与其他人可能在同一代码中所做的更改之间的任何冲突。一些当前的 SCM 允许在提交到共享存储库之前进行本地提交。在这些 SCM 中,这种对比可能在提交到共享存储库之前并不那么关键,但是在每次本地提交时这样做通常会将冲突的解决分解成更小、更易管理的部分。

  • 提交到共享存储库:

  • 一旦完成了这一步,所做的更改现在可以供其他开发人员检索(如果需要,还可以与之对比冲突)。

这种使用模式可能涵盖了大多数开发工作,即任何涉及在已建立的分支上工作,并且不需要新分支的工作。创建新分支也并不少见,特别是如果预计对现有代码库的大部分进行重大更改。对于不同环境可能会有嵌套分支的策略也并不少见,其中更深层的分支在被推广到更稳定的分支之前仍在等待某些审查或接受。

分支结构如下所示:

例如,从[dev]分支上升到[test]的代码推广过程被简化为向上合并,从较低的分支复制代码到较高的分支,然后如有必要,再从较高的分支分支回到较低的分支。

通常会为特定项目创建单独的分支,特别是如果有两个或更多正在进行的工作,可能会进行广泛和/或重大的更改,尤其是如果这些工作预计会相互冲突。项目特定的分支通常会从共享开发分支中获取,如下所示:

[project1][project2]分支的代码完成时,它将被提交到自己的分支,然后合并到现有的[dev]分支中,在此过程中检查并解决任何冲突。

有数十种 SCM 可用,其中约有十几种是开源系统,免费使用。最流行的系统有:

  • Git(远远领先)

  • Subversion

  • Mercurial

Git

Git 是目前使用最广泛的 SCM 系统。它是一个分布式 SCM 系统,可以以非常低的成本保留代码库和其他内容的本地分支,同时仍然能够将本地提交的代码推送到共享的中央存储库,多个用户可以从中访问和工作。最重要的是,它能够处理大量并发的提交(或补丁)活动,这并不奇怪,因为它是为了适应 Linux 内核开发团队的工作而编写的,那里可能会有数百个这样的补丁/提交。它快速高效,基本功能的命令相对容易记忆,如果使用命令行是首选的话。

Git 在正常命令和流程之外有更多的功能,也就是说,可能包括之前提到的获取/编辑/调和/提交步骤的八九个命令,但 Git 总共有 21 个命令,其他 12-13 个提供的功能通常不太需要或使用。有传闻称,除非他们在处理一定规模或复杂性的项目,否则大多数开发人员可能更接近这些人所在的那一端。

Git 也有不少 GUI 工具,尽管许多 IDE,无论是为了最小化上下文切换,还是出于其他原因,都提供了一些与 Git 交互的界面,即使是通过可选插件。其中最好的工具还会在出现问题时(例如提交或推送)检测到,并提供一些解决问题的指导。还有独立的 Git-GUI 应用程序,甚至与内置系统工具集成,比如 TortoiseGit(tortoisegit.org/),它将 Git 功能添加到 Windows 文件资源管理器中。

Subversion

Subversion(或 SVN)是一种自 2004 年初以来就在使用的较老的 SCM。它是今天仍在使用的最受欢迎的非分布式 SCM 之一。与它之前的大多数 SCM 一样,SVN 存储了每个检出的分支的代码和内容的完整本地副本,并在提交过程中上传这些内容(可能是完整的)。它也是一个集中式而不是分布式系统,这意味着所有的分支和合并都必须相对于代码基础的主要副本进行,无论它可能存在于何处。

尽管 Git 的各种底层差异和流行程度,SVN 仍然是管理团队源代码的一个完全可行的选择,即使它不如 Git 高效或受欢迎。它完全支持典型的获取-编辑-提交工作循环,只是没有 Git 提供的灵活性。

Git 和 SVN 的基本工作流程比较

尽管所有主流 SCM 都支持基本的检出、工作、合并和提交工作流程,但值得看看 Git 需要的一些额外的流程步骤。显然,每个额外的步骤都是开发人员在代码完全提交之前必须执行的额外任务,尽管它们都不一定是长时间运行的任务,因此影响很少会是实质性的。另一方面,每个涉及的额外步骤都提供了一个额外的点,在这个点之前可以对代码进行额外的修改,然后再将其附加到代码的主要版本上。

比较Git 工作流(左)和SVN 工作流(右):

  • 获取当前版本的代码并对其进行编辑的过程在根本上是相同的。

  • Git 允许开发人员暂存更改。然而,也许五个文件中有三个文件的代码修改已经完成,并且准备好至少在本地提交,而其他两个文件仍然需要大量工作。由于在提交之前必须在 Git 中暂存更改,因此可以将已完成的文件暂存,然后分别提交,而其他文件仍在进行中。未提交的暂存文件仍然可以根据需要进行编辑和重新暂存(或不进行暂存);直到实际提交更改集,一切仍处于进行中状态。

  • Git 的提交更改是针对本地存储库的,这意味着可以继续进行编辑,以及对本地提交进行操作,直到一切都符合最终主存储库提交的要求。

  • 在最终推送提交到主存储库操作之前,两者都提供了在从主分支合并的能力。实际上,这可以在最终提交之前的任何时候发生,但是 Git 的暂存然后提交的粒度方法很适合以更小、更易管理的块来执行此操作,这通常意味着从主源代码合并下来的任何合并也会更小,更容易管理。在 SVN 方面,没有理由不能执行类似的定期合并,只是在开发过程中进行本地提交例程时更容易记住这样做。

其他 SCM 选项

Git 和 SVN 并不是唯一的选择,绝对不是。下一个最受欢迎的选择是以下几种:

  • Mercurial:一种免费的、开源的 SCM,用 Python 编写,使用类似 Git 的分布式结构,但不需要 Git 所需的更改暂存操作。Mercurial 已被 Google 和 Facebook 内部采用。

  • Perforce Helix Core:一种专有的、分布式的 SCM,至少在一定程度上与 Git 命令兼容,面向企业客户和使用。

最佳实践

有许多标准和最佳实践围绕着开发,至少在涉及的代码基数达到一定复杂程度之后。它们被认为是这样,因为它们解决(或预防)了各种困难,如果不遵循这些困难很可能会出现。其中相当多的标准也间接地关注着代码的未来性,至少从尝试使新开发人员(或可能是同一开发人员,也许是几年后)更容易理解代码的功能,如何找到特定的代码块,或者扩展或重构它的角度来看。

这些指导方针大致分为两类,无论编程语言如何:

  • 代码标准:关于代码结构和组织的指导方针和概念,虽然不一定关注代码的功能方式,而更多地关注使其易于理解和导航

  • 流程标准:围绕着确保代码行为良好以及对其进行更改时可以尽量减少麻烦和干扰的指导方针和概念

Python 在这方面增加了另外两个项目,它们不太适合于那些与编程语言无关的类别;它们是 Python 特定上下文中的能力和功能要求的结果:

  • 包组织:如何在文件系统级别最好地组织代码;何时何地生成新的模块文件和包目录

  • 何时以及如何使用 Python 虚拟环境:它们的作用是什么,以及如何最好地利用它们来处理一组给定的代码

代码标准

在最后,代码级别的标准实际上更多地是为了确保代码本身以可预测和易理解的方式编写和结构化。当这些标准被遵循,并且被与代码库一起工作的开发人员合理理解时,可以合理地期望任何开发人员,甚至是从未见过特定代码块的开发人员,仍然能够做到以下几点:

  • 阅读并更容易理解代码及其功能

  • 寻找一个代码元素(类、函数、常量或其他项),只能通过名称或命名空间来快速、轻松地识别

  • 在现有结构中创建符合这些标准的新代码元素

  • 修改现有的代码元素,并了解需要与这些更改一起修改的与标准相关的项目(如果有)

Python 社区有一套指南(PEP-8),但也可能存在其他内部标准。

PEP-8

至少有一部分 Python 的基因是基于这样的观察:代码通常被阅读的次数比被编写的次数多。这是其语法的重要功能方面的基础,特别是与 Python 代码结构相关的方面,比如使用缩进来表示功能块。也许不足为奇的是,最早的 Python 增强提案之一(PEP)是专注于如何在样式变化没有功能意义的情况下保持代码的可读性。PEP-8 是一个很长的规范,如果直接从当前 Python 页面打印,有 29 页(www.python.org/dev/peps/pep-0008),但其中重要的方面值得在这里总结。

其中第一个,也许是最重要的一点是认识到,虽然如果所有 Python 代码都遵循相同的标准会是理想的,但有许多可辩护的理由不这样做(参见 PEP-8 中的“愚蠢的一致性是小心思想的小恶魔”)。这些包括但不限于以下情况:

  • 当应用 PEP-8 样式指南会使代码变得不易阅读,即使对于习惯于遵循标准的人也是如此

  • 为了与周围的代码保持一致,而周围的代码也没有遵循它们(也许是出于历史原因)

  • 因为除了样式指南之外没有理由对代码进行更改

  • 如果遵守这些指南会破坏向后兼容性(更不用说功能了,尽管这似乎不太可能)

PEP-8 特别指出它是一个样式指南,正如 Solidity v0.3.0 的样式指南介绍中所提到的:

“样式指南是关于一致性的。遵循本样式指南是重要的。项目内的一致性重要。一个模块或函数内的一致性是最重要的”。

这意味着可能有很好(或至少是可辩护的)理由不遵守一些或所有的指南,即使是对于新代码。例如可能包括以下情况:

  • 使用另一种语言的命名约定,因为功能是等效的,比如在提供相同 DOM 操作功能的 Python 类库中使用 JavaScript 命名约定

  • 使用非常具体的文档字符串结构或格式,以符合文档管理系统对所有代码(Python 或其他)的要求

  • 遵循与 PEP-8 建议的标准相矛盾的其他内部标准

最终,由于 PEP-8 是一套样式指南,而不是功能性指南,最糟糕的情况就是有人会抱怨代码不符合公认的标准。如果您的代码永远不会在组织外共享,那可能永远不会成为一个问题。

PEP-8 指南中有三个宽松的分组,其成员可以简要总结如下:

代码布局

  • 缩进应为每级四个空格:

  • 不要使用制表符

  • 悬挂缩进应尽可能使用相同的规则,具体规则和建议请参阅 PEP-8 页面

  • 功能行不应超过 79 个字符的长度,长文本字符串应限制在每行 72 个字符的长度,包括缩进空格

  • 如果一行必须在运算符(+,-,*,and,or 等)周围中断,那么在运算符之前中断

  • 用两个空行包围顶级函数和类定义

注释

  • 与代码相矛盾的注释比没有注释更糟糕——当代码发生变化时,始终优先保持注释的最新状态!

  • 注释应该是完整的句子。第一个单词应该大写,除非它是以小写字母开头的标识符(永远不要改变标识符的大小写!)。

  • 块注释通常由一个或多个段落组成,由完整句子构成,每个句子以句号结束。

命名约定

  • 包和模块应该有短名称,并使用lowercase或(如果必要)lowercase_words命名约定

  • 类名应使用CapWords命名约定

  • 函数和方法应使用lowercase_words命名约定

  • 常量应使用CAP_WORDS命名约定

PEP-8 中还有其他一些太长而无法在此进行有用总结的项目,包括以下内容:

  • 源文件编码(感觉可能很快就不再是一个关注点)

  • 导入

  • 表达式和语句中的空格

  • 文档字符串(它们有自己的 PEP:www.python.org/dev/peps/pep-0257

  • 设计继承

这些,以及 PEP-8 的实质性“编程建议”部分,在hms_sys项目的开发过程中将被遵循,除非它们与其他标准冲突。

内部标准

任何给定的开发工作、团队,甚至公司,可能都有特定的标准和期望,关于代码的编写或结构。也可能有功能标准,例如定义系统消耗的各种功能的外部系统类型的政策,支持哪些 RDBMS 引擎,将使用哪些 Web 服务器等。对于本书的目的,功能标准将在开发过程中确定,但是一些代码结构和格式标准将在此处定义。作为起点,将应用 PEP-8 的代码布局、注释和命名约定标准。除此之外,还有一些代码组织和类结构标准也将发挥作用。

模块中的代码组织

将遵循 PEP-8 的结构和顺序指南,包括模块级别的文档字符串,来自__future__的导入,各种 dunder 名称(一个__all__列表,支持对模块成员的from [module] import [member]使用,以及一些有关模块的标准__author____copyright____status__元数据),然后是来自标准库的导入,然后是第三方库,最后是内部库。

之后,代码将按成员类型组织和分组,按照以下顺序,每个元素按字母顺序排列(除非有功能上的原因,使得该顺序不可行,比如类依赖于或继承自尚未定义的其他类,如果它们是严格顺序的):

  • 模块级常量

  • 在模块中定义自定义异常

  • 函数

  • 旨在作为正式接口的抽象基类

  • 旨在作为标准抽象类或混合类的抽象基类

  • 具体类

所有这些结构约束的目标是为整个代码库提供一些可预测性,使得能够轻松定位给定模块成员,而不必每次都去搜索它。现代集成开发环境(IDE)可以通过在代码中控制点击成员名称并直接跳转到该成员的定义,这可能使这种方式变得不必要,但如果代码将被查看或阅读者无法访问这样的 IDE,以这种方式组织代码仍然具有一定价值。

因此,模块和包头文件遵循非常特定的结构,并且该结构设置在一组模板文件中,一个用于通用模块,一个用于包头(__init__.py)模块。在结构上,它们是相同的,只是在起始文本/内容之间有一些轻微的变化。然后module.py模板如下:

#!/usr/bin/env python
"""
TODO: Document the module.
Provides classes and functionality for SOME_PURPOSE
"""

#######################################
# Any needed from __future__ imports  #
# Create an "__all__" list to support #
#   "from module import member" use   #
#######################################

__all__ = [
    # Constants
    # Exceptions
    # Functions
    # ABC "interface" classes
    # ABC abstract classes
    # Concrete classes
]

#######################################
# Module metadata/dunder-names        #
#######################################

__author__ = 'Brian D. Allbee'
__copyright__ = 'Copyright 2018, all rights reserved'
__status__ = 'Development'

#######################################
# Standard library imports needed     #
#######################################

# Uncomment this if there are abstract classes or "interfaces" 
#   defined in the module...
# import abc

#######################################
# Third-party imports needed          #
#######################################

#######################################
# Local imports needed                #
#######################################

#######################################
# Initialization needed before member #
#   definition can take place         #
#######################################

#######################################
# Module-level Constants              #
#######################################

#######################################
# Custom Exceptions                   #
#######################################

#######################################
# Module functions                    #
#######################################

#######################################
# ABC "interface" classes             #
#######################################

#######################################
# Abstract classes                    #
#######################################

#######################################
# Concrete classes                    #
#######################################

#######################################
# Initialization needed after member  #
#   definition is complete            #
#######################################

#######################################
# Imports needed after member         #
#   definition (to resolve circular   #
#   dependencies - avoid if at all    #
#   possible                          #
#######################################

#######################################
# Code to execute if the module is    #
#   called directly                   #
#######################################

if __name__ == '__main__':
    pass

模块模板和包头文件模板之间唯一的真正区别是初始文档和在__all__列表中包含子包和模块命名空间成员的特定调用:

#!/usr/bin/env python
"""
TODO: Document the package.
Package-header for the PACKAGE_NAMESPACE namespace. 
Provides classes and functionality for SOME_PURPOSE """

#######################################
# Any needed from __future__ imports  #
# Create an "__all__" list to support #
#   "from module import member" use   #
#######################################

__all__ = [
    # Constants
    # Exceptions
    # Functions
    # ABC "interface" classes
    # ABC abstract classes
    # Concrete classes
 # Child packages and modules ]

#######################################
# Module metadata/dunder-names        #
#######################################

# ...the balance of the template-file is as shown above...

将这些作为开发人员可用的模板文件也使得开始一个新模块或包变得更快更容易。复制文件或其内容到一个新文件比只创建一个新的空文件多花几秒钟,但准备好开始编码的结构使得维护相关标准变得更容易。

类的结构和标准

类定义,无论是用于具体/可实例化的类还是任何 ABC 变体,都有一个类似的结构定义,并将按照以下方式排列成分组:

  • 类属性和常量

  • 属性获取方法

  • 属性设置方法

  • 属性删除方法

  • 实例属性定义

  • 对象初始化(__init__

  • 对象删除(__del__

  • 实例方法(具体或抽象)

  • 重写标准内置方法(__str__

  • 类方法

  • 静态方法

选择了属性的 getter、setter 和 deleter 方法的方法,而不是使用方法装饰,是为了更容易地将属性文档保存在类定义的单个位置。使用属性(严格来说,它们是受控属性,但属性是一个更短的名称,并且在几种语言中具有相同的含义)而不是一般属性是对单元测试要求的让步,并且是尽可能接近其原因引发错误的策略。这两者将很快在流程标准部分的单元测试部分讨论。

具体类的模板然后包含以下内容:

# Blank line in the template, helps with PEP-8's space-before-and-after rule
class ClassName:
    """TODO: Document the class.
Represents a WHATEVER
"""
    ###################################
    # Class attributes/constants      #
    ###################################

    ###################################
    # Property-getter methods         #
    ###################################

#     def _get_property_name(self) -> str:
#         return self._property_name

    ###################################
    # Property-setter methods         #
    ###################################

#     def _set_property_name(self, value:str) -> None:
#         # TODO: Type- and/or value-check the value argument of the 
#         #       setter-method, unless it's deemed unnecessary.
#         self._property_name = value

    ###################################
    # Property-deleter methods        #
    ###################################

#     def _del_property_name(self) -> None:
#         self._property_name = None

    ###################################
    # Instance property definitions   #
    ###################################

#     property_name = property(
#         # TODO: Remove setter and deleter if access is not needed
#         _get_property_name, _set_property_name, _del_property_name, 
#         'Gets, sets or deletes the property_name (str) of the instance'
#     )

    ###################################
    # Object initialization           #
    ###################################

    # TODO: Add and document arguments if/as needed
    def __init__(self):
        """
Object initialization.

self .............. (ClassName instance, required) The instance to 
                    execute against
"""
        # - Call parent initializers if needed
        # - Set default instance property-values using _del_... methods
        # - Set instance property-values from arguments using 
        #   _set_... methods
        # - Perform any other initialization needed
        pass # Remove this line 

    ###################################
    # Object deletion                 #
    ###################################

    ###################################
    # Instance methods                #
    ###################################

#     def instance_method(self, arg:str, *args, **kwargs):
#         """TODO: Document method
# DOES_WHATEVER
# 
# self .............. (ClassName instance, required) The instance to 
#                     execute against
# arg ............... (str, required) The string argument
# *args ............. (object*, optional) The arglist
# **kwargs .......... (dict, optional) keyword-args, accepts:
#  - kwd_arg ........ (type, optional, defaults to SOMETHING) The SOMETHING 
#                     to apply
# """
#         pass

    ###################################
    # Overrides of built-in methods   #
    ###################################

    ###################################
    # Class methods                   #
    ###################################

    ###################################
    # Static methods                  #
    ###################################
# Blank line in the template, helps with PEP-8's space-before-and-after rule

除了__init__方法,几乎总是会被实现,实际的功能元素,即属性和方法,都被注释掉。这允许模板中预期存在的标准,并且开发人员可以选择,只需复制并粘贴他们需要的任何代码存根,取消注释整个粘贴的块,重命名需要重命名的内容,并开始编写代码。

抽象类的模板文件与具体类的模板文件非常相似,只是增加了一些项目来适应在具体类中不存在的代码元素:

# Remember to import abc!
# Blank line in the template, helps with PEP-8's space-before-and-after rule
class AbstractClassName(metaclass=abc.ABCMeta):
    """TODO: Document the class.
Provides baseline functionality, interface requirements, and 
type-identity for objects that can REPRESENT_SOMETHING
"""
    ###################################
    # Class attributes/constants      #
    ###################################

    # ... Identical to above ...

    ###################################
    # Instance property definitions   #
    ###################################

#     abstract_property = abc.abstractproperty()

#     property_name = property(

    # ... Identical to above ...

    ###################################
    # Abstract methods                #
    ###################################

#     @abc.abstractmethod
#     def instance_method(self, arg:str, *args, **kwargs):
#         """TODO: Document method
# DOES_WHATEVER
# 
# self .............. (AbstractClassName instance, required) The 
#                     instance to execute against
# arg ............... (str, required) The string argument
# *args ............. (object*, optional) The arglist
# **kwargs .......... (dict, optional) keyword-args, accepts:
#  - kwd_arg ........ (type, optional, defaults to SOMETHING) The SOMETHING 
#                     to apply
# """
#         pass

    ###################################
    # Instance methods                #
    ###################################

    # ... Identical to above ...

    ###################################
    # Static methods                  #
    ###################################
# Blank line in the template, helps with PEP-8's space-before-and-after rule

还有一个类似的模板可用于旨在作为正式接口的类定义;定义了类的实例的功能要求,但不提供这些要求的任何实现。它看起来非常像抽象类模板,除了一些名称更改和删除任何具体实现的内容:

# Remember to import abc!
# Blank line in the template, helps with PEP-8's space-before-and-after rule
class InterfaceName(metaclass=abc.ABCMeta):
    """TODO: Document the class.
Provides interface requirements, and type-identity for objects that 
can REPRESENT_SOMETHING
"""
    ###################################
    # Class attributes/constants      #
    ###################################

    ###################################
    # Instance property definitions   #
    ###################################

#     abstract_property = abc.abstractproperty()

    ###################################
    # Object initialization           #
    ###################################

    # TODO: Add and document arguments if/as needed
    def __init__(self):
        """
Object initialization.

self .............. (InterfaceName instance, required) The instance to 
                    execute against
"""
        # - Call parent initializers if needed
        # - Perform any other initialization needed
        pass # Remove this line 

    ###################################
    # Object deletion                 #
    ###################################

    ###################################
    # Abstract methods                #
    ###################################

#     @abc.abstractmethod
#     def instance_method(self, arg:str, *args, **kwargs):
#         """TODO: Document method
# DOES_WHATEVER
# 
# self .............. (InterfaceName instance, required) The 
#                     instance to execute against
# arg ............... (str, required) The string argument
# *args ............. (object*, optional) The arglist
# **kwargs .......... (dict, optional) keyword-args, accepts:
#  - kwd_arg ........ (type, optional, defaults to SOMETHING) The SOMETHING 
#                     to apply
# """
#         pass

    ###################################
    # Class methods                   #
    ###################################

    ###################################
    # Static methods                  #
    ###################################
# Blank line in the template, helps with PEP-8's space-before-and-after rule

这五个模板一起应该为编写大多数项目中预期的常见元素类型的代码提供了坚实的起点。

函数和方法注释(提示)

如果你之前曾经使用过 Python 函数和方法,你可能已经注意到并对之前模板文件中一些方法中的一些意外语法感到困惑,特别是这里加粗的部分:

def _get_property_name(self) -> str:

def _set_property_name(self, value:str) -> None:

def _del_property_name(self) -> None:

def instance_method(self, arg:str, *args, **kwargs):

这些是 Python 3 支持的类型提示的示例。hms_sys代码将遵循的标准之一是所有方法和函数都应该有类型提示。最终的注释可能会被用来使用装饰器来强制对参数进行类型检查,甚至以后可能会在简化单元测试方面发挥作用。在短期内,有一些预期,自动生成文档系统将关注这些内容,因此它们现在是内部标准的一部分。

类型提示可能还不够常见,因此了解它的作用和工作原理可能值得一看。考虑以下未注释的函数及其执行结果:

def my_function(name, price, description=None):
    """
A fairly standard Python function that accepts name, description and 
price values, formats them, and returns that value.
"""
    result = """
name .......... %s
description ... %s
price ......... %0.2f
""" % (name, description, price)
    return result

if __name__ == '__main__':
    print(
        my_function(
            'Product #1', 12.95, 'Description of the product'
        )
    )
    print(
        my_function(
            'Product #2', 10
        )
    )

执行该代码的结果看起来不错:

就 Python 函数而言,这相当简单。my_function函数期望一个nameprice,还允许一个description参数,但是这是可选的,默认为None。函数本身只是将所有这些收集到一个格式化的字符串值中并返回它。price参数应该是某种数字值,其他参数应该是字符串,如果它们存在的话。在这种情况下,根据参数名称,这些参数值的预期类型可能是显而易见的。

然而,价格参数可以是几种不同的数值类型中的任何一种,并且仍然可以运行——显然intfloat值可以工作,因为代码可以无错误运行。decimal.Decimal值也可以,甚至complex类型也可以,尽管那将是毫无意义的。类型提示注释语法存在的目的是为了提供一种指示预期值的类型或类型的方式,而不需要强制要求。

这是相同的函数,带有类型提示:

def my_function(name:str, price:(float,int), description:(str,None)=None) -> str:
    """
A fairly standard Python function that accepts name, description and 
price values, formats them, and returns that value.
"""
    result = """
name .......... %s
description ... %s
price ......... %0.2f
""" % (name, description, price)
    return result

if __name__ == '__main__':
    print(
        my_function(
            'Product #1', 12.95, 'Description of the product'
        )
    )
    print(
        my_function(
            'Product #2', 10
        )
    )

 # - Print the __annotations__ of my_function
    print(my_function.__annotations__)

这里唯一的区别是每个参数后面的类型提示注释和函数第一行末尾的返回类型提示,它们指示了每个参数的预期类型以及调用函数的结果的类型:

my_function(name:str, price:(float,int), description:(str,None)=None) -> str:

函数调用的输出是相同的,但函数的__annotations__属性显示在输出的末尾:

所有类型提示注释实际上只是填充了my_function__annotations__属性,如前面执行的结果所示。本质上,它们提供了关于函数本身的元数据,可以以后使用。

因此,所有这些标准的目的是:

  • 帮助保持代码尽可能可读(基本 PEP-8 约定)

  • 保持文件中代码的结构和组织可预测(模块和类元素组织标准)

  • 使得创建符合这些标准的新元素(模块、类等)变得更容易(各种模板)

  • 在未来,提供一定程度的未来保障,以允许自动生成文档、方法和函数的类型检查,以及可能探索一些单元测试的效率(类型提示注释)

过程标准

过程标准关注的是针对代码库执行的各种过程的目的。最常见的两种分开的实体是以下两种:

  • 单元测试: 确保代码经过测试,并且可以根据需要重新测试,以确保其按预期工作

  • 可重复的构建过程: 设计成无论你使用什么构建过程,可能作为结果的安装过程都是自动化的、无错误的,并且可以根据需要重复执行,同时尽可能少地需要开发人员的时间来执行

综合起来,这两个也导致了集成单元测试和构建过程的想法,这样,如果需要或者希望的话,构建过程可以确保其生成的输出已经经过测试。

单元测试

人们,甚至开发人员,认为单元测试是确保代码库中不存在错误的过程并不罕见。虽然在较小的代码库中这是有一定道理的,但这实际上更多是单元测试背后真正目的的结果:单元测试是确保代码在所有合理可能的执行情况下表现出可预测行为。这种差异可能微妙,但仍然是一个重要的差异。

让我们从单元测试的角度再次看一下前面的my_function。它有三个参数,一个是必需的字符串值,一个是必需的数字值,一个是可选的字符串值。它不会根据这些值或它们的类型做出任何决定,只是将它们转储到一个字符串中并返回该字符串。让我们假设提供的参数是产品的属性(即使实际情况并非如此)。即使没有涉及任何决策,该功能的某些方面也会引发错误,或者在这种情况下可能会引发错误:

  • 传递一个非数值的price值将引发TypeError,因为字符串格式化不会使用指定的%0.2f格式格式化非数值

  • 传递一个负的price值可能会引发错误——除非产品实际上可能具有负价格,否则这是没有意义的

  • 传递一个数值的price,但不是一个实数(比如一个complex数)可能会引发错误

  • 传递一个空的name值可能会引发错误——我们假设产品名称不接受空值是没有意义的

  • 传递一个多行的name值可能是应该引发错误的情况

  • 传递一个非字符串的name值也可能会因类似的原因而引发错误,非字符串的description值也是如此

除了列表中的第一项之外,这些都是函数本身的潜在缺陷,目前都不会引发任何错误,但所有这些都很可能导致不良行为。

错误。

以下基本测试代码收集在test-my_function.py模块中。

即使没有引入正式的单元测试结构,编写代码来测试所有良好参数值的代表性集合也并不难。首先,必须定义这些值:

# - Generate a list of good values that should all pass for:
#   * name
good_names = [
    'Product', 
    'A Very Long Product Name That is Not Realistic, '
        'But Is Still Allowable',
    'None',  # NOT the actual None value, a string that says "None"
]
#   * price
good_prices = [
    0, 0.0, # Free is legal, if unusual.
    1, 1.0, 
    12.95, 13, 
]
#   * description
good_descriptions = [
    None, # Allowed, since it's the default value
    '', # We'll assume empty is OK, since None is OK.
    'Description',
    'A long description. '*20,
    'A multi-line\n\n description.'
]

然后,只需简单地迭代所有良好组合并跟踪任何因此而出现的错误:

# - Test all possible good combinations:
test_count = 0
tests_passed = 0
for name in good_names:
    for price in good_prices:
        for description in good_descriptions:
            test_count += 1
            try:
                ignore_me = my_function(name, price, description)
                tests_passed += 1
            except Exception as error:
                print(
                    '%s raised calling my_function(%s, %s, %s)' % 
                    (error.__class__.__name__, name, price, description)
                )
if tests_passed == test_count:
    print('All %d tests passed' % (test_count))

执行该代码的结果看起来不错:

接下来,对每个参数定义坏值采取类似的方法,并检查每个可能的坏值与已知的好值:

# - Generate a list of bad values that should all raise errors for:
#   * name
bad_names = [
   None, -1, -1.0, True, False, object()
]
#   * price
bad_prices = [
    'string value', '', 
    None, 
    -1, -1.0, 
    -12.95, -13, 
]
#   * description
bad_description = [
   -1, -1.0, True, False, object()
]

# ...

for name in bad_names:
    try:
        test_count += 1
        ignore_me = my_function(name, good_price, good_description)
        # Since these SHOULD fail, if we get here and it doesn't, 
        # we raise an error to be caught later...
        raise RuntimeError()
    except (TypeError, ValueError) as error:
        # If we encounter either of these error-types, that's what 
        # we'd expect: The type is wrong, or the value is invalid...
        tests_passed += 1
    except Exception as error:
        # Any OTHER error-type is a problem, so report it
        print(
            '%s raised calling my_function(%s, %s, %s)' % 
            (error.__class__.__name__, name, good_price, good_description)
        )

即使只是放置了 name 参数测试,我们已经开始看到问题:

并在价格和描述值上添加类似的测试后:

for price in bad_prices:
    try:
        test_count += 1
        ignore_me = my_function(good_name, price, good_description)
        # Since these SHOULD fail, if we get here and it doesn't, 
        # we raise an error to be caught later...
        raise RuntimeError()
    except (TypeError, ValueError) as error:
        # If we encounter either of these error-types, that's what 
        # we'd expect: The type is wrong, or the value is invalid...
        tests_passed += 1
    except Exception as error:
        # Any OTHER error-type is a problem, so report it
        print(
            '%s raised calling my_function(%s, %s, %s)' % 
            (error.__class__.__name__, good_name, price, good_description)
        )

for description in bad_descriptions:
    try:
        test_count += 1
        ignore_me = my_function(good_name, good_price, description)
        # Since these SHOULD fail, if we get here and it doesn't, 
        # we raise an error to be caught later...
        raise RuntimeError()
    except (TypeError, ValueError) as error:
        # If we encounter either of these error-types, that's what 
        # we'd expect: The type is wrong, or the value is invalid...
        tests_passed += 1
    except Exception as error:
        # Any OTHER error-type is a problem, so report it
        print(
            '%s raised calling my_function(%s, %s, %s)' % 
            (error.__class__.__name__, good_name, good_price, description)
        )

问题列表还更长,共有 15 项,如果不加以解决,任何一项都可能导致生产代码错误:

因此,仅仅说单元测试是开发过程中的一个要求是不够的;必须考虑这些测试实际上做了什么,相关的测试策略是什么样的,以及它们需要考虑什么。一个良好的基本起点测试策略可能至少包括以下内容:

  • 在测试参数或特定类型的属性时使用了哪些值:

  • 数值应该至少包括偶数和奇数变化、正数和负数值,以及零

  • 字符串值应包括预期值、空字符串值和仅仅是空格的字符串(" ")

  • 对于每个被测试元素,了解每个值何时有效何时无效的一些理解。

  • 必须为通过和失败的情况编写测试

  • 必须编写测试,以便执行被测试元素中的每个分支

最后一项需要一些解释。到目前为止,被测试的代码没有做出任何决定——无论参数的值如何,它都会以完全相同的方式执行。对于基于参数值做出决定的代码执行完整的单元测试必须确保为这些参数传递测试值,以调用代码可以做出的所有决定。通常情况下,通过确保良好和不良的测试值足够多样化,就可以充分满足这种需求,但当复杂的类实例进入图景时,确保这一点可能会变得更加困难,这些情况需要更密切、更深入的关注。

在围绕类模板的讨论中早些时候就指出,将使用正式属性(受管理属性),而这背后的原因与单元测试政策有关。我们已经看到,相对容易生成可以在函数或方法执行期间检查特定错误类型的测试。由于属性是方法的集合,每个方法都用于获取、设置和删除操作,由property关键字打包,因此执行对传递给设置方法的值的检查,并在传递的值或类型无效(因此可能在其他地方引发错误)时引发错误,将使得单元测试实施遵循之前显示的结构/模式至少在某种程度上更快、更容易。使用class-concrete.py模板中的property_name属性的基本结构表明,实现这样的属性是相当简单的:

###################################
# Property-getter methods         #
###################################

def _get_property_name(self) -> str:
    return self._property_name

###################################
# Property-setter methods         #
###################################

def _set_property_name(self, value:(str, None)) -> None:
    if value is not None and type(value) is not str:
        raise TypeError(
            '%s.property_name expects a string or None '
            'value, but was passed "%s" (%s)' % (
                self.__class__.__name__, value, 
                type(value).__name__
            )
        )
    self._property_name = value

###################################
# Property-deleter methods        #
###################################

def _del_property_name(self) -> None:
    self._property_name = None

###################################
# Instance property definitions   #
###################################

property_name = property(
    _get_property_name, _set_property_name, _del_property_name, 
    'Gets, sets or deletes the property_name (str|None) of the instance'
)

涉及 18 行代码,这至少比property_name是一个简单的、未管理的属性所需的 17 行代码多,如果property_name在创建实例的过程中被设置,那么使用这个属性的类的__init__方法中可能还会有至少两行代码。然而,权衡之处在于受管理的属性属性将是自我调节的,因此在其他地方使用它时,不需要太多检查其类型或值。它可以被访问的事实,即在访问属性之前,它所属的实例没有抛出错误,意味着它处于已知(和有效)状态。

可重复构建过程

拥有构建过程的想法可能起源于需要在其代码执行之前进行编译的语言,但即使对于像 Python 这样不需要编译的语言,建立这样一个过程也有优势。在 Python 的情况下,这样的过程可以从多个项目代码库中收集代码,定义要求,而不实际将它们附加到最终包中,并以一致的方式打包代码,准备进行安装。由于构建过程本身是另一个程序(或至少是一个类似脚本的过程),它还允许执行其他代码以满足需要,这意味着构建过程还可以执行自动化测试,甚至可能部署代码到指定的目的地,本地或远程。

Python 的默认安装包括两个打包工具,distutils是一组基本功能,setuptools在此基础上提供了更强大的打包解决方案。如果提供了打包参数,setuptools运行的输出是一个准备安装的包(一个 egg)。创建包的常规做法是通过一个setup.py文件,该文件调用setuptools提供的 setup 函数,可能看起来像这样:

#!/usr/bin/env python
"""
example_setup.py

A bare-bones setup.py example, showing all the arguments that are 
likely to be needed for most build-/packaging-processes
"""

from setuptools import setup

# The actual setup function call:
setup(
    name='',
    version='',
    author='',
    description='',
    long_description='',
    author_email='',
    url='',
    install_requires=[
        'package~=version',
        # ...
    ],
    package_dir={
        'package_name':'project_root_directory',
        # ...
    },
    # Can also be automatically generated using 
    #     setuptools.find_packages...
    packages=[
        'package_name',
        # ...
    ],
    package_data={
        'package_name':[
            'file_name.ext',
            # ...
        ]
    },
    entry_points={
        'console_scripts':[
            'script_name = package.module:function',
            # ...
        ],
    },
)

所示的参数都与最终包的特定方面有关:

  • 名称:定义最终包文件的基本名称(例如,MyPackageName

  • 版本:定义包的版本,这个字符串也将成为最终包文件名称的一部分

  • 作者:包的主要作者的姓名

  • 描述:包的简要描述

  • 长描述:包的长描述;通常通过打开和读取包含长描述数据的文件来实现,如果包打算上传到 Python 网站的包存储库,则通常以 Markdown 格式呈现

  • 作者电子邮件:包的主要作者的电子邮件地址

  • 网址:包的主页网址

  • install_requires:需要安装的包名称和版本要求的列表,以便使用包中的代码 - 依赖项的集合

  • package_dir:将包名称映射到源目录的字典;所示的'package_name':'project_root_directory'值对于将源代码组织在srclib目录下的项目来说是典型的,通常与setup.py文件本身在文件系统中的同一级别

  • packages:将添加到最终输出包中的包的列表;setuptools模块还提供了一个find_packages函数,它将搜索并返回该列表,并提供了使用模式列表来定义应该排除什么的明确排除包目录和文件的规定

  • package_data:需要包含在其映射到的包目录中的非 Python 文件的集合;也就是说,在所示的示例中,setup.py运行将寻找package_name包(来自包列表),并将file_name.ext文件包含在该包中,因为它已被列为要包含的文件

  • entry_points:允许安装程序为代码库中特定函数创建命令行可执行别名;它实际上会创建一个小型的标准 Python 脚本,该脚本知道如何找到并加载包中指定的函数,然后执行它

对于为hms_sys创建的第一个包,将对实际setup.py的创建、执行和结果进行更详细的查看。还有一些选项用于指定、要求和执行自动化单元测试,这些将被探讨。如果它们提供了所需的测试执行和失败停止功能,那么setuptools.setup可能足以满足hms_sys的所有需求。

如果发现有额外的需求,标准的 Python 设置过程无法管理,无论出于什么原因,都需要一个备用的构建过程,尽管它几乎肯定仍然会使用setup.py运行的结果作为其过程的一部分。为了尽可能地保持备用方案(相对)简单,并确保解决方案在尽可能多的不同平台上可用,备用方案将使用 GNU Make。

Make 通过执行在Makefile中指定的每个目标的命令行脚本来运行。一个简单的Makefile,包含用于测试和执行setup.py文件的目标,非常简单:

# An example Makefile

main: test setup
        # Doesn't (yet) do anything other than running the test and 
        # setup targets

setup:
        # Calls the main setup.py to build a source-distribution
        # python setup.py sdist

test:
        # Executes the unit-tests for the package, allowing the build-
        # process to die and stop the build if a test fails

从命令行运行 Make 过程就像执行make一样简单,也许还可以指定目标:

第一次运行(未指定任何目标的make)执行Makefile中的第一个目标:mainmain目标又有testsetup目标作为先决条件目标指定,在继续执行自己的流程之前执行。如果执行make main,将返回相同的结果。第二次和第三次运行,分别执行特定的目标make testmake setup

因此,Make 是一个非常灵活和强大的工具。只要给定的构建过程步骤可以在命令行中执行,就可以将其纳入基于 Make 的构建中。如果不同的环境需要不同的流程(例如devteststagelive),则可以设置与这些环境对应的 Make 目标,允许一个构建过程处理这些变化,只需执行make devmake live,尽管在目标命名上需要一些小心,以避免在这种情况下两个不同但逻辑上合理的test目标之间的名称冲突。

集成单元测试和构建过程

如前所述,构建过程应允许纳入并执行为项目创建的所有可用自动化测试(至少是单元测试)。该集成的目标是防止未通过测试套件的代码可构建,因此可部署,并确保只有经证明良好的代码可用于安装,至少在生产代码级别。

可能需要允许损坏的代码,在本地或共享开发构建级别构建,尽管只是因为开发人员可能需要安装损坏的构建来解决问题。这将是非常具体的,取决于处理这种情况的政策和程序。基于五个环境的可能政策集可能归结为以下内容:

  • 本地开发:根本不需要测试

  • 共享开发:测试是必需的,但是失败的测试不会中断构建过程,因此损坏的构建可以被推广到共同的开发服务器;但是损坏的构建会被记录,这些日志在需要紧急推广代码时很容易获得。

  • QA/测试:与共享开发环境相同

  • 暂存(和用户验收测试环境:必须执行并通过测试才能安装或推广代码

  • 生产环境:与暂存相同

如果标准的setuptools-based 打包过程允许运行测试,导致失败的测试中止打包工作,并且在安装期间不需要执行测试,那么这提供了这种政策集的足够功能覆盖,尽管可能需要使用包装器(如 Make)提供特定于环境的目标和构建过程,以处理政策的一致性/覆盖。

如果制定并遵循了单元测试和构建过程标准,最终结果往往是代码很容易构建和部署,无论其状态如何,并且在所有已知情况下都以已知(且可证明)的方式运行。这并不意味着它将没有错误,尽管,只要测试套件很全面和完整,它就不太可能有任何重大错误,但这并不是一个保证。

建立相关流程需要一些额外开销,特别是在单元测试方面,维护这些流程的开销更大,但对系统稳定性的影响和影响可能是惊人的。

作者曾为一家广告公司编写过一个资产目录系统,该系统每个工作日都有多达 300 人在使用,遵循这些流程指南。在四年的时间里,包括对系统进行了更新和显著改变版本,报告的错误总数(不包括用户错误、数据输入错误或企业级访问权限)只有四个。这些流程标准产生了影响。

为 Python 代码定义包结构

Python 中的包结构规则很重要,因为它们将决定在尝试从该包中导入成员时可以访问到哪些代码。包结构也是整体项目结构的一个子集,可能对自动化构建过程产生重大影响,也可能对单元测试的设置和执行产生影响。让我们首先从检查可能的顶层项目结构开始,如下所示,然后审查 Python 包的要求,并看看它如何适应整体项目:

这个项目结构假设最终构建将安装在 POSIX 系统上 - 大多数 Linux 安装、macOS、UNIX 等。对于 Windows 安装可能有不同的需求,在hms_sys开发周期中将进行探讨,当我们开始为其制定远程桌面应用程序时。即便如此,这个结构可能仍然保持不变:

  • bin目录旨在收集最终用户可以执行的代码和程序,无论是从命令行还是通过操作系统的 GUI。这些项目可能会或可能不会使用主包的代码,尽管如果它们是 Python 可执行文件,那么它们很可能会使用。

  • etc目录是存储配置文件的地方,然后etc目录下的example_project目录将用于存储与项目最终安装实例非常特定的配置。将项目特定的配置放在顶层目录中可能是可行的,甚至可能是更好的方法,这将需要根据项目的具体情况进行评估,并可能取决于安装项目的最终用户是否有权限安装到全局目录。

  • scratch-space目录只是一个收集在开发过程中可能有用的任何随机文件的地方 - 概念验证代码,笔记文件等。它不打算成为构建的一部分,也不会被部署。

  • src目录是项目代码所在的地方。我们很快就会深入探讨。

  • var目录是 POSIX 系统存储需要以文件形式持久保存的程序数据的地方。其中的cache目录是缓存文件的标准 POSIX 位置,因此其中的example_project目录将是项目代码专门用于缓存文件的位置。在var中有一个专门的、项目特定的目录,不在cache中,这也是提供的。

项目上下文中的包

src目录中是项目的包树。在example_project目录下的每个具有__init__.py文件的目录级别都是一个正式的 Python 包,并且可以通过 Python 代码中的导入语句访问。一旦这个项目被构建和安装,假设其中的代码是按照相关的导入结构编写的,那么以下所有内容都将是项目代码的合法导入:

import example_project 导入整个example_project命名空间
import example_project.package 导入example_project.package和它的所有成员
from example_project import package
from example_project.package import member 假设member存在,从example_project.package导入它
import example_project.package.subpackage 导入example_project.package.subpackage和它的所有成员
from example_project.package import subpackage
from example_project.package.subpackage import member 假设member存在,从example_project.package.subpackage导入它

Python 包的典型模式是围绕功能的共同领域将代码元素分组。例如,一个在非常高的层次上专注于 DOM 操作(HTML 页面结构)并支持 XML、XHTML 和 HTML5 的包可能会这样分组:

  • dom (__init__.py)

  • generic (__init__.py)

  • [用于处理元素的通用类]

  • html (__init__.py)

  • generic (generic.py)

  • [用于处理 HTML 元素的通用类]

  • forms (forms.py)

  • html5 (__init__.py)

  • [用于处理 HTML-5 特定元素的类]

  • forms (forms.py)

  • xhtml (__init__.py)

  • [用于处理 XHTML 特定元素的类]

  • forms (forms.py)

  • xml (__init__.py)

因此,该结构的完整实现可能允许开发人员通过创建一个生活在dom.html5.forms.EmailField命名空间中的类的实例来访问 HTML5 的 Email 字段对象,并且其代码位于.../dom/html5/forms.py中,作为一个名为EmailField的类。

决定代码库结构中特定类、函数、常量等应该存在的位置是一个复杂的话题,将在hms_sys的早期架构和设计的一部分中进行更深入的探讨。

使用 Python 虚拟环境

Python 允许开发人员创建虚拟环境,将所有基线语言设施和功能收集到一个单一位置。一旦设置好,这些虚拟环境就可以安装或移除其中的包,这允许在环境上下文中执行的项目访问可能不需要在基本系统中的包和功能。虚拟环境还提供了一种跟踪这些安装的机制,这反过来允许开发人员只跟踪与项目本身相关的那些依赖和要求。

虚拟环境也可以被使用,只要小心谨慎地考虑,就可以允许项目针对特定版本的 Python 语言进行开发 - 例如,一个不再受支持的版本,或者一个在开发机器的操作系统中还太新而无法作为标准安装。这最后一个方面在开发 Python 应用程序以在各种公共云中运行时非常有用,比如亚马逊的 AWS,在那里 Python 版本可能比通常可用的要新,也可能与语言早期版本有显著的语法差异。

语言级别的重大变化并不常见,但过去确实发生过。虚拟环境不能解决这些问题,但至少可以更轻松地维护不同版本的代码。

假设适当的 Python 模块(Python 3 中的venv)已经安装,创建虚拟环境,激活和停用它在命令行级别是非常简单的:

python3 -m venv ~/py_envs/example_ve

在指定位置(在这种情况下,在名为example_ve的目录中,在用户主目录中名为py_envs的目录中)创建一个新的、最小的虚拟环境:

source ~/py_envs/example_ve/bin/activate

这激活了新创建的虚拟环境。此时,启动python会显示它正在使用版本 3.5.2,并且命令行界面在每一行前面都加上(example_ve),以显示虚拟环境是激活的:

deactivate

这停用了活动的虚拟环境。现在从命令行启动python会显示系统的默认 Python 版本 2.7.12。

安装、更新和删除包,并显示已安装的包,同样也很简单:

这将再次激活虚拟环境:

source ~/py_envs/example_ve/bin/activate

这显示了当前安装的软件包列表。它不显示任何属于核心 Python 分发的软件包,只显示已添加的软件包。

pip freeze

在这种情况下,第一次运行还指出环境中的当前版本的pip已经过时,可以使用以下命令进行更新:

pip install –upgrade pip

pip软件包本身是基本 Python 安装的一部分,即使它刚刚更新,这也不会影响通过再次调用pip freeze返回的软件包列表。

为了说明pip如何处理新软件包的安装,使用了pillow库,这是一个用于处理图形文件的 Python API:

pip install pillow

由于pillow不是标准库,它出现在另一个pip freeze调用的结果中。 pip freeze的结果可以作为项目结构的一部分转储到要求文件(例如requirements.txt),并与项目一起存储,以便软件包依赖关系实际上不必存储在项目的源树中,或者与之一起存储在 SCM 中。这将允许项目中的新开发人员简单地创建自己的虚拟环境,然后使用另一个pip调用安装依赖项:

pip install -r requirements.txt

然后卸载了pillow库,以展示其外观,使用了以下命令:

pip uninstall pillow

pip程序在跟踪依赖关系方面做得很好,但可能并非百分之百可靠。即使卸载软件包会删除它列为依赖项的内容,但仍在使用,也很容易使用另一个pip调用重新安装它。

然后,虚拟环境允许对与项目关联的第三方软件包进行很好的控制。然而,它们也有一些小小的代价:它们必须被维护,尽管很少,当一个开发人员对这些外部软件包进行更改时,需要一些纪律来确保这些更改对其他在同一代码库上工作的开发人员是可用的。

总结

有很多因素可能会影响代码的编写和管理,甚至在编写第一行代码之前。它们中的每一个都可能对开发工作的顺利进行或该工作的成功产生一定影响。幸运的是,有很多选择,并且在决定哪些选择起作用以及如何起作用时有相当大的灵活性,即使假设一些团队或管理层的政策没有规定它们。

关于hms_sys项目中这些项目的决定已经注意到,但由于下一章将真正开始开发,可能值得再次提出:

  • 代码将使用 Geany 或 LiClipse 作为 IDE 进行编写。它们都提供了代码项目管理设施,应该能够处理预期的多项目结构,并提供足够的功能,以使跨项目导航相对轻松。最初,该工作将使用 Geany,如果 Geany 变得过于麻烦或无法处理项目的某些方面,则将保留 LiClipse,或者在开发进展后无法处理项目的某些方面。

  • 源代码管理将使用 Git 进行,指向 GitHub 或 Bitbucket 等外部存储库服务。

  • 代码将遵循 PEP-8 的建议,直到除非有令人信服的理由不这样做,或者它们与任何已知的内部标准冲突。

  • 代码将按照各种模板文件中的结构进行编写。

  • 可调用对象-函数和类方法-将使用类型提示注释,直到除非有令人信服的理由不这样做。

  • 所有代码都将进行单元测试,尽管测试策略的细节尚未被定义,除了确保测试所有公共成员之外。

  • 系统中的每个代码项目都将有自己的构建过程,使用标准的setup.py机制,并在需要时使用基于Makefile的流程进行包装。

  • 每个构建过程都将集成单元测试结果,以防止构建在任何单元测试失败时完成。

  • 项目内的包结构尚未定义,但将随着开发的进行而逐渐展开。

  • 每个项目都将拥有并使用自己独特的虚拟环境,以保持与每个项目相关的要求和依赖项分开。这可能需要一些构建过程的调整,但还有待观察。

第七章:设置项目和流程

我们的第一个迭代是为所有以下迭代以及项目最初完成后的任何开发工作做好准备。这种准备工作需要为预期复杂程度的任何新开发工作进行,但可能不会被分解为自己的迭代。许多基础结构的创建可以作为其他迭代的一部分来管理;例如,当需要它的第一个开发开始时创建项目的结构。采取这种方法的权衡是,较早的定义工作很可能会在后续开发展开时被显著改变,因为最初的结构无法容纳多个 Python 虚拟环境,或者将新项目添加到系统代码库中。

拥有一些标准的结构定义,比如第六章,《开发工具和最佳实践》,将最大程度地减少这些问题,但可能无法完全防止它们。

本章将涵盖大多数项目共有的设置和准备工作:

  • 源代码管理(SCM)

  • 项目组织

  • 单元测试结构

  • 构建和部署流程

迭代目标

这次迭代的交付成果主要集中在以下方面:

  • 主存储库,存储在 Git 服务器或服务(例如本地服务器、GitHub 或 Bitbucket)中,包含系统及其组件项目的完整空项目结构

  • 系统中每个可部署的类库或应用程序的组件项目

  • 系统中每个组件项目的单元测试套件都可以执行,并且其执行对每个组件项目都通过

  • 每个组件项目的构建过程-也是可执行的-会产生一个可部署的软件包,即使该软件包起初是基本无用的

故事和任务的组装

开发人员的需求也可以表达为故事,并附有要执行的任务。这些基础故事可能会在多个项目中重复使用,并且如果是这样,它们可能会随着时间的推移而不断发展,以更好地捕捉跨开发工作的常见需求和目标-即使是对于根本不同的系统。这些应该足以作为现在的起点:

  • 作为开发人员,我需要知道系统的源代码将如何被管理和版本控制,以便我能够适当地保留/存储我编写的代码:
  1. 为系统创建一个空的 SCM 存储库-hms_sys

  2. 填充存储库所需的基线信息和文档,以供持续使用

  3. 建立和分发开发团队成员访问存储库所需的凭据

  • 作为开发人员,我需要知道系统的完整结构看起来是什么样子,至少在高层次上,以便我能够编写符合该结构的代码。这将涉及:
  1. 分析用例以及逻辑和物理架构,以定义组件项目的需求和结构

  2. 为每个组件项目构建标准的项目起点

  3. 为每个组件项目实施一个最小的setup.py,完成源代码包的构建

  4. 确定是否要为组件项目使用 Python 虚拟环境,实施它们,并记录如何复制它们

  • 作为开发人员,我需要知道如何以及在哪里为代码库编写单元测试,以便在编写代码后创建单元测试。我还需要确保代码经过彻底测试:
  1. 定义单元测试的标准/要求(覆盖率、按类型的标准值等)

  2. 实施强制执行这些标准的机制

  3. 定义单元测试代码将存放在组件项目结构中的位置

  4. 为每个组件项目实施一个基本的顶层测试,以确保没有任何失败

  • 作为开发人员,我需要知道如何将组件项目的单元测试集成到该组件项目的构建过程中,以便构建可以自动执行单元测试,其中包括:

  • 确定如何将单元测试集成到构建过程中;以及

  • 确定如何处理不同环境的构建/测试集成

设置 SCM

由于此迭代中需要进行的大部分活动最终需要存储在 SCM 中,因此将首先进行列表中的第一个故事及其任务:

  • 作为开发人员,我需要知道系统的源代码将如何被管理和版本控制,以便我能够适当地保留/存储我编写的代码:
  1. 为系统创建一个空的 SCM 存储库——hms_sys

  2. 填充存储库所需的基线信息和文档,以供日常使用

  3. 建立并分发团队成员访问存储库所需的凭据

hms_sys的代码将存储在 Bitbucket(bitbucket.org)中的 Git 存储库中,因此第一步是在那里设置一个新存储库:

新存储库的设置如下:

  • 所有者:拥有存储库的用户。如果多个用户通过 Bitbucket 帐户访问存储库,或者与之关联的组,这些用户和组将作为此设置的选项可用。

  • 存储库名称:存储库的(必需)名称。理想情况下,存储库名称应该与其包含的系统或项目轻松关联起来,由于hms_sys既是整个项目的名称,而且尚未被使用,因此被使用。

  • 访问级别:确定存储库是公共的还是私有的。由于hms_sys不打算供公众查阅或分发,因此存储库已被设置为私有。

  • 包括 README?:系统是否将在创建过程中创建一个README文件。选项如下:

  • 否:如果需要/希望,将需要手动创建文件。

  • 是,带模板:创建一个带有最少信息的基本文件。选择此选项是为了创建一个基本的README文件。

  • 是,有教程(适用于初学者)。

  • 版本控制系统:允许存储库使用 Git 或 Mercurial 作为其 SCM 引擎。选择了 Git,因为这是我们决定使用的。

高级设置必须扩展才能使用,并且如下所示:

  • 描述:如果选择了“是,带模板”选项,此处提供的任何描述都将添加到README文件中。

  • 派生:控制是否/如何允许从存储库派生。选项如下:

  • 允许派生:任何有权限的人都可以派生存储库

  • 仅允许私有派生

  • 不允许派生

  • 项目管理:允许将问题跟踪和 wiki 系统与存储库集成。

  • 语言:指定存储库中代码的主要编程语言。最初,此设置除了按其主要语言对存储库进行分类外,并不起作用。一些 SCM 提供商将使用语言设置来预先填充 Git 的.gitignore文件,其中包含常被忽略的文件模式,因此如果可能的话,指定它是有利的。

单击“创建存储库”按钮后,将创建存储库:

从任何存储库的概述页面,连接和克隆/拉取存储库的 HTTPS 和 SSH 选项都可用,有必要权限的任何人都可以克隆它(以任何首选方式)到本地副本进行操作:

有几种初始化新的 Git 存储库的方法。这个过程从存储库的提供者开始,确保存储库格式良好且可访问,同时允许进行一些初始配置和文档设置,以后不必手动完成。

此时,故事中的两项任务已解决:

  1. 为系统创建一个空的 SCM 存储库——hms_sys

  2. 建立并分发开发团队成员访问存储库所需的凭据。由于存储库是通过外部服务提供商的界面创建的,因此访问所需的凭据是在那里管理的,任何与存储库的帐户或组相关联的用户帐户都具有他们需要的访问权限,或者可以通过提供商系统中的用户管理来获得访问权限。

剩下的任务,填充了基线信息和持续使用所需的文档,与尚未解决的项目结构有关,但仍然有一些可以解决的独立项目。

首先是在顶层存储库目录中创建和记录基本组件项目。最初,创建一个顶层项目,包含整个系统代码库可能是一个好主意——这将提供一个单一的项目,用于组织跨两个或多个组件项目的项目,以及涵盖整个系统的任何内容。

在 Geany 中,通过使用 Project → New 来完成,提供项目名称、项目文件路径和项目的基本路径:

由于 Geany 项目文件存储可能因机器而异的文件系统路径,这些路径需要添加到 Git 的.gitignore文件中:

# .gitignore for hms_sys project
# Geany project-files
*.geany

.gitignore文件最终是 Git 提交或推送代码到中央存储库时 Git 将忽略的文件和/或文件夹的列表。与.gitignore中路径匹配的任何文件或文件夹将不会被 SCM 跟踪。

此外,可能需要记录创建本地hms_sys.geany文件的说明,以便任何其他需要的开发人员可以根据需要创建。这类信息可以放入README.md文件中,并且在添加系统的组件项目时将进行类似的工作:

# hms_sys

The system-level repository for the hms_sys project, from "Hands On 
Software Engineering with Python," published by Packt.

## Geany Project Set-up

Geany project-files (`*.geany`) are in the `.gitignore` for the entire 
repository, since they have filesystem-specific paths that would break 
as they were moved from one developer's local environment to another. 
Instructions for (re-)creating those projects are provided for each.

### HMS System (Overall) -- `hms_sys.geany`

This is an over-arching project that encompasses *all* of the component 
projects. It can be re-created by launching Geany, then using 
Project → New and providing:

 * *Name:* HMS System (Overall)
 * *Filename:* `[path-to-git-repo]/hms_sys/hms_sys.geany`
 * *Base path:* `[path-to-git-repo]/hms_sys`

一旦这些更改被暂存、本地提交并推送到主存储库,那里应该出现一个修订后的README.md文件和一个新的.gitignore,但不会出现hms_sys.geany项目文件:

随着组件项目被添加到代码库中,应该遵循相同类型的文档和设置,产生类似的结果。此时,第一个故事的最终任务已经完成,如果被判定为完成并获得批准,那么它将被审查和关闭。

创建组件项目的存根

然后,进行下一个故事:

  • 作为开发人员,我需要知道系统的完整结构是什么样子,至少在高层次上,这样我才能编写适合该结构的代码:
  1. 分析用例和逻辑和物理架构,以定义组件项目的需求和结构。

  2. 为每个确定的组件项目构建标准项目起点

  3. 为每个组件项目实现一个最小的setup.py,完成源包构建

组件项目分析

逻辑架构以及第六章的用例图,开发工具和最佳实践,指出了三个明显的组件项目,需要分别为以下内容进行核算:

  • 工匠应用程序

  • 工匠门户

  • 审查/管理应用程序

这些组件项目中的每一个都需要访问一些常见的对象类型——它们都需要能够处理产品实例,并且它们中的大多数也需要能够处理工匠订单实例:

可能还有其他业务对象,从这个分解中并不立即显而易见,但是有任何业务对象的事实都表明可能需要第四个组件项目来收集提供这些业务对象及其功能的代码。考虑到这一点,初始的组件项目结构归结如下:

  • HMS 核心hms-core):一个类库,收集所有基线业务对象定义,以提供工匠产品订单等对象的表示

  • 中央办公室应用hms-co-app):提供一个可执行的应用程序,允许中央办公室工作人员执行需要与工匠关于产品订单以及可能其他项目进行通信的各种任务

  • 工匠应用hms-artisan):提供一个可执行的本地应用程序,允许工匠管理产品订单,根据需要与中央办公室进行通信

  • HMS 工匠网关hms-gateway):提供一个可执行服务,工匠应用程序和中央办公室应用程序用于在工匠和中央办公室之间发送信息

组件项目设置

关于hms-core代码将如何包含在需要它的其他项目的分发中,稍后将需要做出一些决定,但这些不需要立即解决,因此它们将被搁置。与此同时,为每个组件项目设置起点项目结构是下一步。目前,基本结构在所有四个组件项目中都是相同的;唯一的区别在于各种文件和目录的名称。

hms-core为例,因为这是第一个逻辑上要开始工作的代码集,项目结构将如下所示:

打包和构建过程

为项目设置最小标准的 Python 打包,并提供基本的构建过程,对之前讨论过的setup.pyMakefile文件几乎没有做出任何改变。在编写代码之前只有一些具体的内容可用:setup.py将使用的包名称和主包的顶级目录,以及可以添加到Makefile中的setup.py文件。Makefile的更改是最简单的:

# Makefile for the HMS Core (hms-core) project

main: test setup
        # Doesn't (yet) do anything other than running the test and 
        # setup targets

setup:
        # Calls the main setup.py to build a source-distribution
        # python setup.py sdist

test:
        # Executes the unit-tests for the package, allowing the build-
        # process to die and stop the build if a test fails

setup.py文件,尽管它已经填充了一些起始数据和信息,但仍然基本上是我们之前看到的同样基本的起点文件:

#!/usr/bin/env python

from setuptools import setup

# The actual setup function call:
setup(
    name='HMS-Core',
    version='0.1.dev0',
    author='Brian D. Allbee',
    description='',
    package_dir={
        '':'src',
        # ...
    },
    # Can also be automatically generated using 
    #     setuptools.find_packages...
    packages=[
        'hms_core',
        # ...
    ],
    package_data={
#        'hms_core':[
#            'filename.ext',
#            # ...
#        ]
    },
    entry_points={
#        'console_scripts':[
#            'executable_name = namespace.path:function',
#            # ...
#        ],
    },
)

这个结构暂时还不会包括核心包之外的各种目录和文件——在这一点上,没有迹象表明它们中的任何一个是必需的,因此它们的包含将被推迟,直到确实需要它们。即使没有这些,setup.py文件也可以成功构建和安装源分发包,尽管在构建过程中会抛出一些警告,并且安装的包目前还没有提供任何功能:

在更大(或至少更正式结构化)的开发商店中,组件项目的构建/打包过程可能需要适应不同环境的不同构建:

  • 本地环境,比如开发人员的本地机器

  • 一个共享的开发环境,所有开发人员的本地代码更改首先混合在一起

  • 一个用于 QA 和更广泛的集成测试的共享测试服务器

  • 使用真实的、类似生产的数据的用户验收测试服务器,可以用来向需要最终批准变更的人演示功能

  • 具有完整生产数据副本访问权限的暂存环境,以便能够执行需要访问该数据集的负载和其他测试

  • live 环境/构建代码库

至少有一些潜力需要在这些不同的构建(localdevteststagelive,用户验收构建暂时假定与阶段构建相同)之间进行重大区分。然而,在开发工作的这一阶段,实际上并没有什么可以区分的,因此唯一能做的就是计划如果需要时会发生什么。

在任何给定环境需要完全不同的包结构之前,当前的setup.py文件将保持不变。几乎不太可能存在一个环境特定的需求,这种需求在所有环境中都不常见。如果确实出现这种需求,那么方法将是为每个具有任何独特需求的环境创建一个单独的setup.py,并手动或通过Makefile执行该特定的setup.py。经过一些谨慎和思考,这应该可以将任何特定于环境的差异包含在一个单一位置,并以合理标准的方式进行。

这意味着Makefile将需要进行更改。具体来说,每个特定环境的构建过程(从devlive)都需要一个目标,并且需要一种管理特定环境文件的方法。由于make过程可以操作文件,创建目录等,将使用以下策略:

  • 通过为特定环境的文件添加构建目标/环境名称前缀来识别特定于环境的文件。例如,代码库中将有一个dev-setup.py文件,以及一个test-setup.py文件,依此类推。

  • 修改Makefile以复制项目代码树中所有可以更改(和销毁)的相关文件,而不影响核心项目文件

  • 添加一个过程,将在临时副本中查找并重命名所有特定于环境的文件,以满足特定环境的构建需求,并删除临时树中与构建无关的特定环境文件。

  • 执行setup.py文件

Makefile的更改将至少在起点上看起来像这样。

首先,定义一个通用的临时构建目录——本地构建将是默认的,并且将简单地执行标准的setup.py文件,就像原始过程一样

# Makefile for the HMS Core (hms-core) project
TMPDIR=/tmp/build/hms_core_build

local: setup
 # Doesn't (yet) do anything other than running the test and 
 # setup targets

setup:
 # Calls the main setup.py to build a source-distribution
 ~/py_envs/hms/core/bin/python setup.py sdist

unit_test:
 # Executes the unit-tests for the package, allowing the build-
 # process to die and stop the build if a test fails
 ~/py_envs/hms/core/bin/python setup.py test

创建一个新的目标build_dir,用于创建临时构建目录,并将可以成为任何构建的项目文件复制到其中

build_dir:
 # Creates a temporary build-directory, copies the project-files 
 # to it.
 # Creating "$(TMPDIR)"
 mkdir -p $(TMPDIR)
 # Copying project-files to $(TMPDIR)
 cp -R bin $(TMPDIR)
 cp -Ret cetera$(TMPDIR)
 cp -R src $(TMPDIR)
 cp -R var $(TMPDIR)
 cp setup.py $(TMPDIR)

为每个环境编写一个准备目标,以及每个环境的最终目标,将重命名和删除文件,并在临时构建目录中执行setup.py文件

dev_prep:
 # Renames any dev-specific files so that they will be the "real" 
 # files included in the build.
 # At this point, there are none, so we'll just exit

dev: unit_test build_dir dev_prep
 # A make-target that generates a build intended to be deployed 
 # to a shared development environment.
 cd $(TMPDIR);~/py_envs/hms/core/bin/python setup.py sdist

因此,当针对此Makefile执行make dev时,dev目标运行unit_test目标,然后使用build_dir目标创建项目的临时副本。之后,使用dev_prep处理文件名更改和其他环境的文件删除。然后才会执行剩余的setup.py

Python 虚拟环境

最后要解决的任务是确定是否要为各个组件项目使用 Python 虚拟环境,如有需要则创建它们,并记录如何创建它们,以便其他开发人员在需要时能够复制它们。

鉴于组件项目之间的结构、对它们的了解以及预期安装代码与其他系统成员的交互方式,显然没有必要为不同的环境建立,甚至没有明显的优势。只要在开发过程中充分注意和遵守,确保每个组件项目的setup.py或其他构建过程工件或配置中添加了依赖关系,最有可能出现的最坏情况是在执行测试安装的过程中发现缺少的依赖关系。在其他方面没有错误的实时安装中,可能会出现一些微不足道的低效率,例如hms-gateway项目可能会安装数据库或 GUI 库,它不需要或不使用,或者两个组件项目可能都安装了其他用户安装的消息系统库,但并不需要。

这些都不会对单个组件项目的操作构成任何即将发生的威胁,但它们确实会将不必要的代码引入到安装中。如果不仔细观察和管理,不必要的库安装可能会大量增加,这可能成为未来安全问题的一个因素。更糟糕的是,任何潜在的安全问题可能不会被视为结果;如果没有人真正意识到某个程序安装了不需要的东西,那么直到为时已晚才会得到修复。

为了确保系统安全,可以采取的第一步是确保它们只安装了必要的功能。这样做不会覆盖所有可能性,但会减少保持当前补丁和安全问题所需的带宽。

逐个项目跟踪依赖关系是虚拟环境可以发挥作用的地方。这是为每个项目单独设置它们的一个优点。另一个支持这种做法的观点是,一些平台,如各种公共云,将需要能够在其部署过程中包含依赖包的能力,而虚拟环境将把它们很好地与核心系统安装包集分开。在这方面,虚拟环境也是一种未来的保障。

因此,在开发hms_sys的情况下,我们将为每个组件项目设置一个单独的虚拟环境。如果以后证明它们是不必要的,它们总是可以被删除的。创建、激活和停用它们的过程非常简单,并且可以在任何方便的地方创建——实际上没有标准位置——命令因操作系统而异,如下所示:

虚拟环境活动 操作系统
Linux/MacOS/Unix Windows
创建 python3 -m venv ~/path/to-myenv
激活 source ~/path/to-myenv/bin/activate
停用 deactivate

创建和激活虚拟环境后,可以像在虚拟环境之外一样使用pip(或pip3)在其中安装包。安装的包存储在虚拟环境的库中,而不是全局系统库中。

记录哪些虚拟环境与哪些组件项目相关,只是将创建它所需的命令复制到项目级文档的某个地方。对于hms_sys,这些将存储在每个组件项目的README.md文件中。

让我们回顾一下这个故事的任务:

  • 分析用例,逻辑和物理架构,以定义组件项目的需求和结构——完成

  • 为每个已识别的组件项目构建标准项目起点——完成

  • 为每个组件项目实施一个最小的setup.py文件,完成源包构建—完成

  • 确定是否要为组件项目使用 Python 虚拟环境,实施它们,并记录如何重现它们—完成

  • 提供一个单元测试结构

在上一章的最后指出,尽管已经设定了对所有代码进行单元测试的期望,并且所有模块和类的公共成员都受到了该要求的约束,但也指出尚未定义任何测试策略细节,这正是本次迭代中单元测试故事的重要部分:

  • 作为开发人员,我需要知道如何以及在何处为代码库编写单元测试,以便在编写代码后创建单元测试。我还需要确保代码经过彻底测试:
  1. 定义单元测试标准/要求(覆盖率、按类型的标准值等)

  2. 实施一个机制来强制执行这些标准

  3. 定义单元测试代码将存放在组件项目结构中的何处

  4. 为每个组件项目实施一个基本的顶层测试,以确保没有任何失败

这些单元测试材料的大部分内容都是从 Python 2.7.x 代码转换和改编而来的,关于这一点的讨论可以在作者的博客上找到(从bit.ly/HOSEP-IDIC-UT开始)。尽管该代码是为较旧版本的 Python 编写的,但可能还可以从那里的单元测试文章中获得额外的见解。

可以说,应该测试所有成员,而不仅仅是公共成员——毕竟,如果涉及到的代码在任何地方被使用,那么就应该在可预测行为方面也要符合相同的标准,是吗?从技术上讲,没有理由不能这样做,特别是在 Python 中,受保护和私有类成员实际上并不受保护或私有——它们只是按照惯例被视为这样——在 Python 的早期版本中,受保护的成员是可以访问的,而私有成员(以两个下划线作为前缀:__private_member)在派生类中是不能直接访问的,除非通过它们的变形名称来调用。在 Python 3 中,尽管名称修饰仍在起作用,但在语言级别上不再强制执行名义上的受保护或私有范围。这很快就可以证明。考虑以下类定义:

class ExampleParent:

    def __init__(self):
        pass

    def public_method(self, arg, *args, **kwargs):
        print('%s.public_method called:' % self.__class__.__name__)
        print('+- arg ...... %s' % arg)
        print('+- args ..... %s' % str(args))
        print('+- kwargs ... %s' % kwargs)

    def _protected_method(self, arg, *args, **kwargs):
        print('%s._protected_method called:' % self.__class__.__name__)
        print('+- arg ...... %s' % arg)
        print('+- args ..... %s' % str(args))
        print('+- kwargs ... %s' % kwargs)

    def __private_method(self, arg, *args, **kwargs):
        print('%s.__private_method called:' % self.__class__.__name__)
        print('+- arg ...... %s' % arg)
        print('+- args ..... %s' % str(args))
        print('+- kwargs ... %s' % kwargs)

    def show(self):
        self.public_method('example public', 1, 2, 3, key='value')
        self._protected_method('example "protected"', 1, 2, 3, key='value')
        self.__private_method('example "private"', 1, 2, 3, key='value')

如果我们创建ExampleParent的一个实例,并调用它的show方法,我们期望看到所有三组输出,这正是发生的:

如果使用dir(ExampleParent)检查ExampleParent类结构,可以看到所有三种方法:['_ExampleParent__private_method', …, '_protected_method', 'public_method', …]。在 Python 的早期版本中,从ExampleParent派生的类仍然可以访问public_method_protected_method,但如果通过该名称调用__private_method,则会引发错误。在 Python 3(以及一些较新版本的 Python 2.7.x)中,情况已经不再是这样了。

class ExampleChild(ExampleParent):
    pass

创建这个类的一个实例,并调用它的show方法会产生相同的结果:

从技术上讲,那么 Python 类的所有成员都是公共的。

那么,从定义单元测试策略的角度来看,如果所有类成员都是公共的,这意味着什么?如果遵守了公共/受保护/私有的约定,那么以下内容适用:

  • 公共成员应该在与它们定义的类相对应的测试套件中进行测试(它们的原始类)

  • 大多数受保护的成员可能打算被派生类继承,并且应该在与定义它们的类相对应的测试套件中进行深入测试

  • 私有成员应该被视为真正的私有成员——在其原始类之外根本不可访问——或者被视为可能发生突发变化而无需警告的实现细节

  • 继承成员不需要再次进行任何测试,因为它们已经针对其原始类进行了测试

  • 从其父类重写的成员将在与其被重写的类相关的套件中进行测试

建立一个适用于所有这些规则的单元测试过程是可能的,尽管它相当复杂且足够实质性,以至于将其封装在某种可重复使用的函数或类中将非常有利,这样它就不必在每个测试过程中重新创建,或者在测试策略发生变化时在数十甚至数百个副本中进行维护。最终目标是拥有一个可重复的测试结构,可以快速轻松地实现,这意味着它也可以以与先前模块和包头部相同的方式进行模板化。

首先,我们需要一些东西来测试。具体来说,我们需要具有方法的类,这些方法属于先前指出的类别:

  • 本地定义

  • 从父类继承

  • 从父类重写

这涵盖了所有公共/受保护/私有选项。虽然先前没有明确提到,但我们还应该包括一个至少有一个抽象方法的类。它们仍然是类,也需要进行测试;只是还没有被讨论过。它们不需要非常复杂来说明测试过程,尽管它们应该返回可测试的值。考虑到所有这些,这里是一组简单的类,我们将用它们来进行测试,并生成核心测试过程:

这些文件位于hms_sys代码库中,位于顶层scratch-space目录中。

import abc

class Showable(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def show(self):
        pass

class Parent(Showable):

    _lead_len = 33

    def __init__(self, arg, *args, **kwargs):
        self.arg = arg
        self.args = args
        self.kwargs = kwargs

    def public(self):
        return (
            ('%s.arg [public] ' % self.__class__.__name__).ljust(
                self.__class__._lead_len, '.') + ' %s' % self.arg
            )

    def _protected(self):
        return (
            ('%s.arg [protected] ' % self.__class__.__name__).ljust(
                self.__class__._lead_len, '.') + ' %s' % self.arg
            )

    def __private(self):
        return (
            ('%s.arg [private] ' % self.__class__.__name__).ljust(
                self.__class__._lead_len, '.') + ' %s' % self.arg
            )

    def show(self):
        print(self.public())
        print(self._protected())
        print(self.__private())

class Child(Parent):
    pass

class ChildOverride(Parent):

    def public(self):
        return (
            ('%s.arg [PUBLIC] ' % self.__class__.__name__).ljust(
                self.__class__._lead_len, '.') + ' %s' % self.arg
            )

    def _protected(self):
        return (
            ('%s.arg [PROTECTED] ' % self.__class__.__name__).ljust(
                self.__class__._lead_len, '.') + ' %s' % self.arg
            )
    def __private(self):
        return (
            ('%s.arg [PRIVATE] ' % self.__class__.__name__).ljust(
                self.__class__._lead_len, '.') + ' %s' % self.arg
            )

创建每个具体类的快速实例,并调用每个实例的show方法,显示预期的结果:

基本单元测试

Python 中的单元测试由内置的unittest模块支持。可能还有其他模块也提供单元测试功能,但unittest是 readily available 的,它默认安装在 Python 虚拟环境中,并且至少作为起点,提供了我们所需的所有测试功能。先前类的初始测试模块非常简单,即使它除了定义适用于被测试代码的测试用例类之外,什么也没做:

#!/usr/bin/env python

import unittest

class testShowable(unittest.TestCase):
    pass

class testParent(unittest.TestCase):
    pass

class testChild(unittest.TestCase):
    pass

class testChildOverride(unittest.TestCase):
    pass

unittest.main()

test开头的每个类(并且派生自unittest.TestCase)将由模块末尾的unittest.main()调用实例化,并且这些类中以test开头的每个方法都将被执行。如果我们向其中一个添加测试方法,例如testParent,并按以下方式运行测试模块:

class testParent(unittest.TestCase):
    def testpublic(self):
        print('### Testing Parent.public')
    def test_protected(self):
        print('### Testing Parent._protected')
    def test__private(self):
        print('### Testing Parent.__private')

可以看到测试方法的执行:

如果print()调用被替换为pass,如下面的代码所示,输出会更简单,对于每个执行而不引发错误的测试用例的测试方法,会打印一个句点:

class testParent(unittest.TestCase):
    def testpublic(self):
        pass
    def test_protected(self):
        pass
    def test__private(self):
        pass

执行时,会产生以下结果:

到目前为止,一切都很顺利;我们有可以执行的测试,所以下一个问题是如何应用我们想要应用的测试策略规则。第一个策略,为每个源模块拥有一个测试模块,是项目结构的一个方面,而不是与测试执行流程相关的一个方面。为了解决这个问题,我们真正需要做的就是定义在任何给定项目中测试代码将存放的位置。由于我们知道我们将来会想要在构建过程中运行测试,我们需要有一个公共的测试目录,一个刚好在其中的文件(称之为run_tests.py)可以按需运行项目的所有测试,以及一个测试目录和文件结构,该结构对该文件应该是可访问的,这最终看起来像是hms_core组件项目的这样:

识别缺失的测试用例类

早些时候指出的测试目标的平衡都需要能够检查被测试的代码,以识别需要进行测试的模块成员,以及这些成员的成员。这可能听起来令人生畏,但 Python 提供了一个专门用于此目的的模块:inspect。它提供了一系列非常强大的函数,可以用于在运行时检查 Python 代码,这可以用来生成成员名称的集合,进而用于确定高级测试覆盖是否符合我们正在建立的标准。

为了说明,我们需要测试的前述类将被保存在一个名为me.py的模块中,这使它们可以被导入,每一步展示关于me模块的所需信息的过程都将被收集在inspect_me.py中,如此所示。相应的测试用例将存在于test_me.py中,它将首先作为一个几乎空白的文件开始——一开始不会在那里定义任何测试用例类。

第一步是识别我们将需要测试用例类的me的目标成员。就目前而言,我们所需要的只是目标模块中的类的列表,可以按如下方式检索:

#!/usr/bin/env python

import inspect

import me as target_module

target_classes = set([
    member[0] for member in 
    inspect.getmembers(target_module, inspect.isclass)
])
# target_classes = {
#   'Child', 'ChildOverride', 'Parent', 'Showable'
# } at this point

一步一步,正在发生的是这样的:

  1. 正在导入inspect模块。

  2. 正在导入me模块,使用target_module作为其默认模块名的覆盖——我们希望能够保持导入的模块名称可预测且相对恒定,以便在以后更容易地重用,而这从这里开始。

  3. target_module调用inspectgetmembers函数,使用isclass作为过滤谓词。这将返回一个类似('ClassName', <class object>)的元组列表。这些结果通过列表推导提取出只有类名的列表,并将该列表传递给 Python 的set,以产生发现的类名的正式集合。

Python 的set类型是一种非常有用的基本数据类型,它提供了一个可迭代的值集合,这些值是不同的(在集合中从不重复),并且可以与其他集合合并(使用union),从其他集合中删除其成员(使用difference),以及一系列其他操作,这些操作都符合标准集合理论的预期。

有了这些名称,创建一组预期的测试用例类名就很简单了:

expected_cases = set([
    'test%s' % class_name 
    for class_name in target_classes
    ]
)
# expected_cases = {
#   'testChild', 'testShowable', 'testChildOverride', 
#   'testParent'
# } at this point

这只是另一个列表推导,它构建了一个以test开头的类名集合,从目标类名集合中。与收集目标模块中的类名的方法类似,可以用类似的方法找到存在于test_me.py模块中的测试用例类:

import unittest

import test_me as test_module

test_cases = set([
    member[0] for member in 
    inspect.getmembers(test_module, inspect.isclass)
    if issubclass(member[1], unittest.TestCase)
])
# test_cases, before any TestCase classes have been defined, 
# is an empty set

除了对每个找到的成员进行issubclass检查,这将限制集合的成员为从unittest.TestCase派生的类的名称,这与构建初始target_classes集合的过程完全相同。现在我们有了收集预期和实际定义的内容的集合,确定需要创建的测试用例类是一个简单的事情,只需从预期的集合中删除已定义的测试用例名称:

missing_tests = expected_cases.difference(test_cases)
# missing_tests = {
#   'testShowable', 'testChild', 'testParent', 
#   'testChildOverride'
# }

如果missing_tests不为空,则其名称集合代表需要创建的测试用例类名称,以满足“所有成员将被测试”的政策的第一部分。此时对结果的简单打印就足够了:

if missing_tests:
    print(
        'Test-policies require test-case classes to be '
        'created for each class in the code-base. The '
        'following have not been created:\n * %s' % 
        '\n * '.join(missing_tests)
    )

已经确定了需要创建的缺失的测试用例类项,它们可以添加到test_me.py中:

#!/usr/bin/env python

import unittest

class testChild(unittest.TestCase):
    pass

class testChildOverride(unittest.TestCase):
    pass

class testParent(unittest.TestCase):
    pass

class testShowable(unittest.TestCase):
    pass

if __name__ == '__main__':
    unittest.main()

一旦它们被添加(并且一旦从unittest.TestCase派生出子类,因为之前执行了识别实际测试用例类的检查),就不再有需要解决的缺失的测试用例。

类似的方法也可以用于识别应该进行测试的模块级函数——毕竟,它们也是模块的公共成员,而政策关注的正是模块的公共成员。对函数或任何其他可调用元素进行测试的实际实现将遵循稍后为类方法建立的结构和过程。

实际上,可能无法轻松使用这种类型的过程识别的唯一公共成员是未受管理的属性——在模块级别创建的模块常量或变量。尽管这些仍然可以进行测试,并且可以说应该进行测试,但它们是未受管理的,可以在运行时更改,而没有任何检查来确保它们不会在后续某个地方出现问题,这可能会使围绕它们的任何正式测试政策几乎成为一种浪费时间。也就是说,测试它们并没有坏处,即使只是为了确保对它们的更改(有意或意外的)不会被忽视并在以后引发问题和错误。

识别缺失的测试方法

之前用于识别模块中的类的inspect.getmembers函数也可以用于识别其他目标元素的其他成员类型,例如类的属性和方法。识别任一成员的过程与之前已经展示的识别模块中的类的过程类似,看起来像这样(对于属性):

target_class = target_module.Parent

target_properties = set([
    member[0] for member in 
    inspect.getmembers(target_class, inspect.isdatadescriptor)
])
# target_properties = {'__weakref__'}

与在模块中查找类的过程的唯一显著差异是被检查的目标(在这种情况下是target_class,我们已将其设置为Parent类)和谓词(inspect.isdatadescriptor),它将结果过滤为数据描述符——受控属性或正式属性。

在第六章 开发工具和最佳实践中,当讨论和定义各种内部代码标准时,注意到使用受控属性/属性的一个重要方面是对于单元测试目的的重要性:知道为任何给定属性测试的值类型。这是采用这种方法的另一个优势:使用内置的property()函数定义的类属性可以被检测为需要测试的类成员。尽管未受管理的属性可能是可检测的,但可能不容易识别为需要测试的类的成员,并且几乎可以肯定这种识别几乎肯定不是可以自动化的。

类似的inspect.getmembers调用可以用于识别类方法:

target_functions = set([
    member[0] for member in 
    inspect.getmembers(target_class, inspect.isfunction)
])
target_methods = set([
    member[0] for member in 
    inspect.getmembers(target_class, inspect.ismethod)
])
target_methods = target_methods.union(target_functions)
# target_methods = {
#   '_Parent__private', 'public', 'show', 
#   '_protected', '__init__'
# }

这两个成员名称集合都包括测试策略不要求测试的项目,尽管__weakref__属性是所有类的内置属性,而_Parent__private方法条目与我们最初的__private方法相关联,这两者都不需要包含在我们所需测试方法的列表中。通过简单地添加对属性列表名称中前导__的检查,可以实现一些基本的过滤(因为根据我们的测试策略,我们永远不会测试私有属性)。这将处理掉测试列表中的__weakref__,并允许公共和受保护的属性出现。

在向Parent添加属性声明(prop)并添加过滤条件后,我们将得到以下结果:

target_properties = set([
    member[0] for member in 
    inspect.getmembers(target_class, inspect.isdatadescriptor)
    if not member[0].startswith('__')
])
# target_properties = {'prop'}

然而,同样的方法并不适用于查找需要测试的类方法;一些常见的方法,比如__init__,其名称会基于名称进行过滤,但是我们希望确保需要测试的成员。这种简单的基于名称的过滤也无法处理不包括在类中但在该类中没有定义的成员名称,比如Child类的所有属性和成员。虽然基于名称的过滤是朝着正确方向迈出的一步,但感觉是时候退一步,看看更广泛的解决方案,一个能考虑成员定义位置的解决方案。

这涉及以更复杂的方式构建测试名称列表,并注意每个类的方法解析顺序MRO),这可以在类的内置__mro__属性中找到。我们将从定义一个空集开始,并获取类的 MRO,然后获取与目标类相同的属性名称列表:

property_tests = set()
sourceMRO = list(target_class.__mro__)
sourceMRO.reverse()
# Get all the item's properties
properties = [
    member for member in inspect.getmembers(
        target_class, inspect.isdatadescriptor)
    if member[0][0:2] != '__'
]
# sourceMRO = [
#   <class 'object'>, <class 'me.Showable'>, 
#   <class 'me.Parent'>
# ]

我们还需要跟踪属性的定义位置,即它来自哪个类,以及属性的实际实现。我们希望从每个完整的数据结构开始,将名称与源类和最终实现关联起来,但最初用None值初始化。这将允许最终的结构在填充后用于识别类的成员,这些成员在那里没有定义:

propSources = {}
propImplementations = {}
for name, value in properties:
    propSources[name] = None
    propImplementations[name] = None
# Populate the dictionaries based on the names found
for memberName in propSources:
    implementation = target_class.__dict__.get(memberName)
    if implementation and propImplementations[memberName] != implementation:
        propImplementations[memberName] = implementation
        propSources[memberName] = target_class
# propImplementations = {
#   "prop": <property object at 0x7fa2f0edeb38>
# }
# propSources = {
#   "prop": <class 'me.Parent'>
# }
# If the target_class is changed to target_module.Child:
# propImplementations = {
#   "prop": None    # Not set because prop originates in Parent
# }
# propSources = {
#   "prop": None    # Also not set for the same reason
# }

有了这些数据,生成所需属性测试方法列表与之前显示的所需测试用例类列表类似:

property_tests = set(
    [
        'test%s' % key for key in propSources 
        if propSources[key] == target_class
    ]
)
# property_tests = {'testprop'}
# If the target_class is changed to target_module.Child:
# property_tests = set()

获取和筛选类的方法成员的过程几乎相同,尽管我们将包括所有成员,甚至是以__开头的成员,并获取函数或方法,以确保包括类和静态方法。

method_tests = set()
sourceMRO = list(target_class.__mro__)
sourceMRO.reverse()
# Get all the item's methods
methods = [
    member for member in inspect.getmembers(
        target_class, inspect.isfunction)
] + [
    member for member in inspect.getmembers(
        target_class, inspect.ismethod)
]

用于跟踪方法源和实现的dict项的构建过程可以主动跳过本地、私有成员以及已定义为抽象的成员:

methSources = {}
methImplementations = {}
for name, value in methods:
    if name.startswith('_%s__' % target_class.__name__):
        # Locally-defined private method - Don't test it
        continue
    if hasattr(value, '__isabstractmethod__') and value.__isabstractmethod__:
        # Locally-defined abstract method - Don't test it
        continue
    methSources[name] = None
    methImplementations[name] = None

测试名称列表生成的平衡是相同的:

method_tests = set(
    [
        'test%s' % key for key in methSources 
        if methSources[key] == target_class
    ]
)
# method_tests = {
#   'testpublic', 'test__init__', 'test_protected', 
#   'testshow'
# }
# If the target_class is changed to target_module.Child:
# method_tests = set()
# If the target_class is changed to target_module.Showable:
# method_tests = set()

那么,从所有这些探索中得出了什么结论?简而言之,它们如下:

  • 可以自动化检测模块的成员应该需要创建测试用例

  • 虽然可以自动化验证所需的测试用例是否存在于与给定源模块对应的测试模块中,但仍需要一些纪律来确保创建测试模块

  • 可以自动化检测对于任何给定的测试用例/源类组合需要哪些测试方法,并且可以在不需要测试私有和抽象成员的情况下进行

尽管这是相当多的代码。大约 80 行,没有一些实际测试类成员和问题公告,以及剥离所有注释后。这比应该被复制和粘贴的代码要多得多,尤其是对于具有高破坏潜力或影响的流程。最好能够将所有内容都保存在一个地方。幸运的是,unittest模块的类提供了一些选项,可以使逐模块的代码覆盖测试变得非常容易——尽管这将首先需要一些设计和实现。

创建可重用的模块代码覆盖测试

一个良好的单元测试框架不仅允许为代码元素的成员创建测试,还提供了在运行任何测试之前以及在所有测试执行成功或失败后执行代码的机制。Python 的unittest模块在各个TestCase类中处理这一点,允许类实现setUpClasstearDownClass方法来分别处理测试前和测试后的设置和拆卸。

这意味着可以创建一个测试类,该类可以被导入,扩展具有特定于模块的属性,并添加到测试模块中,该测试模块可以利用刚刚显示的所有功能来执行以下操作:

  • 查找目标模块中的所有类和函数

  • 确定测试模块中需要存在哪些测试用例类,并测试它们以确保它们存在

  • 确定每个源模块成员的测试用例类需要存在哪些测试,以满足我们的单元测试政策和标准。

  • 检查这些测试方法是否存在

代码覆盖测试用例类将需要知道要检查哪个模块以找到所有信息,但它应该能够自行管理其他所有内容。最终,它将定义自己的一个测试,以确保源模块中的每个类或函数在测试模块中都有一个相应的测试用例类:

def testCodeCoverage(self):
    if not self.__class__._testModule:
        return
    self.assertEqual([], self._missingTestCases, 
        'unit testing policies require test-cases for all classes '
        'and functions in the %s module, but the following have not '
        'been defined: (%s)' % (
            self.__class__._testModule.__name__, 
            ', '.join(self._missingTestCases)
        )
    )

它还需要能够提供一种机制,以允许检查属性和方法测试方法。如果可以实现的话,以完全自动化的方式进行这样的检查是很诱人的,但可能有些情况会比值得的麻烦。至少目前,通过创建一些装饰器来使这些测试附加到任何给定的测试用例类变得容易,这些测试将被添加到可用的测试中。

Python 的装饰器本身是一个相当详细的主题。现在,不要担心它们是如何工作的,只要知道它们的使用方式,并相信它们是有效的。

我们的起点只是一个从unittest.TestCase派生的类,该类定义了前面提到的setUpClass类方法,并对定义的类级_testModule属性进行了一些初始检查——如果没有测试模块,那么所有测试应该简单地跳过或通过,因为没有任何被测试的内容:

class ModuleCoverageTest(unittest.TestCase):
    """
A reusable unit-test that checks to make sure that all classes in the 
module being tested have corresponding test-case classes in the 
unit-test module where the derived class is defined.
"""
@classmethod
def setUpClass(cls):
    if not cls._testModule:
        cls._missingTestCases = []
        return

@classmethod行是内置的类方法装饰器。

我们需要首先找到目标模块中所有可用的类和函数:

cls._moduleClasses = inspect.getmembers(
     cls._testModule, inspect.isclass)
cls._moduleFunctions = inspect.getmembers(
     cls._testModule, inspect.isfunction)

我们将跟踪被测试模块的名称作为类和函数成员的额外检查标准,以防万一:

cls._testModuleName = cls._testModule.__name__

跟踪类和函数测试的机制类似于初始探索中的源和实现字典:

cls._classTests = dict(
   [
       ('test%s' % m[0], m[1]) 
       for m in cls._moduleClasses
       if m[1].__module__ == cls._testModuleName
   ]
)
cls._functionTests = dict(
   [
       ('test%s' % m[0], m[1]) 
       for m in cls._moduleFunctions
       if m[1].__module__ == cls._testModuleName
   ]
)

所需测试用例类名称的列表是所有类和函数测试用例类名称的聚合列表:

cls._requiredTestCases = sorted(
   list(cls._classTests.keys()) + list(cls._functionTests.keys())
)

实际测试用例类的集合将稍后用于测试:

cls._actualTestCases = dict(
    [
      item for item in 
      inspect.getmembers(inspect.getmodule(cls), 
      inspect.isclass) 
    if item[1].__name__[0:4] == 'test'
       and issubclass(item[1], unittest.TestCase)
    ]
)

接下来,我们将生成缺少的测试用例名称列表,该列表由类testCodeCoverage测试方法使用:

cls._missingTestCases = sorted(
   set(cls._requiredTestCases).difference(
       set(cls._actualTestCases.keys())))

此时,该单独的测试方法将能够执行,并且会输出指示缺少哪些测试用例的输出。如果我们将test_me.py模块写成如下形式:

from unit_testing import ModuleCoverageTest

class testmeCodeCoverage(ModuleCoverageTest):
    _testModule = me

if __name__ == '__main__':
    unittest.main()

然后在执行后,我们将得到以下结果:

要使顶层代码覆盖测试通过,只需添加缺少的测试用例类:

class testmeCodeCoverage(ModuleCoverageTest):
    _testModule = me

class testChild(unittest.TestCase):
    pass

class testChildOverride(unittest.TestCase):
    pass

class testParent(unittest.TestCase):
    pass

class testShowable(unittest.TestCase):
    pass

if __name__ == '__main__':
    unittest.main()

这种以主动方式确保代码覆盖率的方法非常适合使单元测试变得不那么麻烦。如果编写测试的过程始于一个通用测试,该测试将告诉测试开发人员在每一步中缺少了什么,那么编写测试的整个过程实际上就是重复以下步骤,直到没有测试失败为止:

  • 执行测试套件

  • 如果有测试失败,进行必要的代码更改以使最后一个测试通过。

  • 如果是缺少测试失败,添加必要的测试类或方法

  • 如果是因为源代码中的代码而失败,请在验证所涉及的测试值应该通过后相应地更改

继续前进!

为了能够测试测试模块中所有测试用例类中缺少的属性和方法测试,我们需要找到它们并按类进行跟踪。这基本上与我们之前发现的过程相同,但存储的值必须能够按类名检索,因为我们希望单个覆盖测试实例检查所有源代码和测试用例类,因此我们将它们存储在两个字典中,propSources用于每个的源,propImplementations用于实际的功能对象:

cls._propertyTestsByClass = {}
for testClass in cls._classTests:
    cls._propertyTestsByClass[testClass] = set()
    sourceClass = cls._classTests[testClass]
    sourceMRO = list(sourceClass.__mro__)
    sourceMRO.reverse()
    # Get all the item's properties
    properties = [
         member for member in inspect.getmembers(
               sourceClass, inspect.isdatadescriptor)
            if member[0][0:2] != '__'
         ]
    # Create and populate data-structures that keep track of where 
    # property-members originate from, and what their implementation 
    # looks like. Initially populated with None values:
    propSources = {}
    propImplementations = {}
    for name, value in properties:
        propSources[name] = None
        propImplementations[name] = None
     for memberName in propSources:
        implementation = sourceClass.__dict__.get(memberName)
        if implementation \
           and propImplementations[memberName] != implementation:
               propImplementations[memberName] = implementation
               propSources[memberName] = sourceClass
         cls._propertyTestsByClass[testClass] = set(
            [
               'test%s' % key for key in propSources 
               if propSources[key] == sourceClass
            ]
)

方法测试的获取方式与之前的探索方式相同:

cls._methodTestsByClass = {}
for testClass in cls._classTests:
    cls._methodTestsByClass[testClass] = set()
    sourceClass = cls._classTests[testClass]
    sourceMRO = list(sourceClass.__mro__)
    sourceMRO.reverse()
# Get all the item's methods
methods = [
   member for member in inspect.getmembers(
          sourceClass, inspect.ismethod)
   ] + [
   member for member in inspect.getmembers(
          sourceClass, inspect.isfunction)
   ]
# Create and populate data-structures that keep track of where 
# method-members originate from, and what their implementation 
# looks like. Initially populated with None values:
methSources = {}
methImplementations = {}
for name, value in methods:
    if name.startswith('_%s__' % sourceClass.__name__):
       # Locally-defined private method - Don't test it
         continue
    if hasattr(value, '__isabstractmethod__') \
       and value.__isabstractmethod__:
       # Locally-defined abstract method - Don't test it
         continue                methSources[name] = None
       methImplementations[name] = None
  for memberName in methSources:
       implementation = sourceClass.__dict__.get(memberName)
          if implementation \
             and methImplementations[memberName] != implementation:
             methImplementations[memberName] = implementation
             methSources[memberName] = sourceClass
   cls._methodTestsByClass[testClass] = set(
        [
            'test%s' % key for key in methSources 
            if methSources[key] == sourceClass
        ]
)

一旦执行了最后两个代码块,代码覆盖测试类将完整地列出测试模块中每个测试用例类所需的所有测试方法。属性测试集合(cls._propertyTestsByClass)是稀疏的,因为与任何类相关联的属性只有一个,即Parent.prop

{
    "testChild": set(),
    "testChildOverride": set(),
    "testParent": {"testprop"},
    "testShowable": set()
}

方法测试结构(cls._methodTestsByClass)有更多内容,准确地表示了ChildOverride类中的public_protected方法需要它们自己的测试方法,并且Showable中的抽象show方法不需要被测试:

{
    "testChild": set(),
    "testChildOverride": {
        "test_protected", "testpublic"
    },
    "testParent": {
        "test__init__", "test_protected", 
        "testpublic", "testshow"
    },
    "testShowable": set()
}

这些数据是处理所需属性和方法测试的所有内容。剩下的就是想出一种方法将它们附加到每个测试用例类上。

属性和方法测试装饰器

装饰器可以被视为接受另一个函数作为参数,并在装饰的函数周围扩展或包装其他功能的函数,而不实际修改它。任何可调用的东西——函数、类的实例方法或(在本例中)属于类的类方法——都可以用作装饰函数。在这种情况下,代码覆盖测试用例类将使用装饰器函数结构定义两个类方法(AddPropertyTestingAddMethodTesting),以便向使用它们进行装饰的任何类添加新方法(testPropertyCoveragetestMethodCoverage)。由于这两个方法是主代码覆盖类的嵌套成员,它们可以访问类中的数据,特别是生成的所需属性和方法测试名称列表。此外,因为它们是装饰函数本身的嵌套成员,它们将可以访问这些方法中的变量和数据。

这两个装饰器方法几乎是相同的,除了它们的名称、消息和它们查找数据的位置,因此只详细介绍第一个AddMethodTesting。该方法首先检查以确保它是ModuleCoverageTest类的成员,这确保了它要查看的数据仅限于与源代码和测试模块相关的数据:

@classmethod
def AddMethodTesting(cls, target):
    if cls.__name__ == 'ModuleCoverageTest':
        raise RuntimeError('ModuleCoverageTest should be extended '
            'into a local test-case class, not used as one directly.')
    if not cls._testModule:
        raise AttributeError('%s does not have a _testModule defined '
          'as a class attribute. Check that the decorator-method is '
          'being called from the extended local test-case class, not '
          'from ModuleCoverageTest itself.' % (cls.__name__))

函数开始时传入的target参数是一个unittest.TestCase类(尽管它没有明确进行类型检查)。

它还需要确保要使用的数据是可用的。如果不可用,无论出于什么原因,都可以通过显式调用刚刚定义的setUpClass方法来解决:

try:
   if cls._methodTestsByClass:
      populate = False
    else:
        populate = True
except AttributeError:
    populate = True
if populate:
    cls.setUpClass()

下一步是定义一个函数实例来实际执行测试。这个函数被定义得好像它是类的成员,因为在装饰过程完成时它将成为类的成员,但因为它嵌套在装饰器方法内部,所以它可以访问并保留到目前为止在装饰器方法中定义的所有变量和参数的值。其中最重要的是target,因为它将被装饰的类。target值本质上附加到正在定义/创建的函数上:

def testMethodCoverage(self):
    requiredTestMethods = cls._methodTestsByClass[target.__name__]
    activeTestMethods = set(
      [
          m[0] for m in 
          inspect.getmembers(target, inspect.isfunction)
          if m[0][0:4] == 'test'
      ]
    )
    missingMethods = sorted(
        requiredTestMethods.difference(activeTestMethods)
    )
    self.assertEquals([], missingMethods, 
        'unit testing policy requires test-methods to be created for '
        'all public and protected methods, but %s is missing the '
        'following test-methods: %s' % (
        target.__name__, missingMethods
    )
)

测试方法本身非常简单:它创建了一组活动的测试方法名称,这些名称在附加到的测试用例类中被定义,然后从覆盖测试类中检索到的测试用例类的必需测试方法中移除这些名称,如果还有剩余的,测试将失败并宣布缺少了什么。

剩下的就是将函数附加到目标上并返回目标,以便不会中断对它的访问:

target.testMethodCoverage = testMethodCoverage
return target

一旦这些装饰器被定义,它们就可以像这样应用于单元测试代码:

class testmeCodeCoverage(ModuleCoverageTest):
    _testModule = me

@testmeCodeCoverage.AddPropertyTesting
@testmeCodeCoverage.AddMethodTesting
class testChild(unittest.TestCase):
    pass

@testmeCodeCoverage.AddPropertyTesting
@testmeCodeCoverage.AddMethodTesting
class testChildOverride(unittest.TestCase):
    pass

@testmeCodeCoverage.AddPropertyTesting
@testmeCodeCoverage.AddMethodTesting
class testParent(unittest.TestCase):
    pass

@testmeCodeCoverage.AddPropertyTesting
@testmeCodeCoverage.AddMethodTesting
class testShowable(unittest.TestCase):
    pass

有了它们,测试运行开始报告缺少了什么:

创建单元测试模板文件

刚刚显示的测试集合的最基本起点将作为任何其他关注单个模块的测试集合的起点。然而,hms_sys的预期代码结构包括整个代码包,并且可能包括这些包内的包。我们还不知道,因为我们还没有到那一步。这将对最终的单元测试方法产生影响,以及对模板文件的创建产生影响,以使得创建这些测试模块更快速和更少出错。

主要影响集中在这样一个想法上,即我们希望能够通过单个调用执行整个项目的所有测试,同时在组件项目的测试套件中不需要执行每个测试的情况下,只需运行一个或多个测试以针对包结构中更深层次的内容。因此,将测试按照与它们正在测试的包相同类型的组织结构进行拆分,并允许在任何级别的测试模块调用或被父级模块导入时导入子测试。

为此,单元测试模板模块需要适应与主代码库相同类型的导入功能,同时跟踪由测试运行发起的任何导入过程产生的所有测试。幸运的是,unittest模块还提供了可以用来管理这种需求的类,例如TestSuite类,它是可以执行的测试集合,并且可以根据需要向其添加新测试。最终的测试模块模板看起来很像我们之前创建的模块模板,尽管它以一些搜索和替换的样板注释开头:

#!/usr/bin/env python

# Python unit-test-module template. Copy the template to a new
# unit-test-module location, and start replacing names as needed:
#
# PackagePath  ==> The path/namespace of the parent of the module/package
#                  being tested in this file.
# ModuleName   ==> The name of the module being tested
#
# Then remove this comment-block

"""
Defines unit-tests for the module at PackagePath.ModuleName.
"""

#######################################
# Any needed from __future__ imports  #
# Create an "__all__" list to support #
#   "from module import member" use   #
#######################################

与提供应用功能的包和模块不同,单元测试模块模板不需要提供太多的**all**条目,只需要提供模块本身中的测试用例类和任何子测试模块:

__all__ = [
    # Test-case classes
    # Child test-modules
]

所有测试模块中都会发生一些标准导入,并且还可能存在第三方导入的可能性,尽管这可能不太常见:

#######################################
# Standard library imports needed     #
#######################################

import os
import sys
import unittest

#######################################
# Third-party imports needed          #
#######################################

#######################################
# Local imports needed                #
#######################################

from unit_testing import *

#######################################
# Initialization needed before member #
#   definition can take place         #
#######################################

所有的测试模块都将定义一个名为LocalSuiteunittest.TestSuite实例,其中包含所有本地测试用例,并且在需要时可以在父模块中按名称导入:

#######################################
# Module-level Constants              #
#######################################

LocalSuite = unittest.TestSuite()

#######################################
# Import the module being tested      #
#######################################

import PackagePath.ModuleName as ModuleName

我们还将定义一些样板代码,用于定义代码覆盖测试用例类:

#######################################
# Code-coverage test-case and         #
# decorator-methods                   #
#######################################

class testModuleNameCodeCoverage(ModuleCoverageTest):
    _testModule = ModuleName

LocalSuite.addTests(
    unittest.TestLoader().loadTestsFromTestCase(
        testModuleNameCodeCoverage
   )
)

从这一点开始,除了模块的__main__执行之外的所有内容都应该是测试用例类的定义:

#######################################
# Test-cases in the module            #
#######################################

#######################################
# Child-module test-cases to execute  #
#######################################

如果以后需要导入子测试模块,这里有用于执行此操作的代码结构,已注释并准备好复制、粘贴、取消注释和根据需要重命名:

# import child_module
# LocalSuite.addTests(child_module.LocalSuite._tests)

还有更多标准模块部分,遵循标准模块和包模板的组织结构:

#######################################
# Imports to resolve circular         #
# dependencies. Avoid if possible.    #
#######################################

#######################################
# Initialization that needs to        #
# happen after member definition.     #
#######################################

#######################################
# Code to execute if file is called   #
# or run directly.                    #
#######################################

最后,还有一些用于直接执行模块、运行测试并在没有失败时显示和写出报告的准备:

if __name__ == '__main__':
    import time
    results = unittest.TestResult()
    testStartTime = time.time()
    LocalSuite.run(results)
    results.runTime = time.time() - testStartTime
    PrintTestResults(results)
    if not results.errors and not results.failures:
        SaveTestReport(results, 'PackagePath.ModuleName',
            'PackagePath.ModuleName.test-results')

模板提供了一些可以在首次复制到最终测试模块时找到并替换的项目:

  • PackagePath:被测试模块的完整命名空间,减去模块本身。例如,如果为一个完整命名空间为hms_core.business.processes.artisan的模块创建了一个测试模块,PackagePath将是hms_core.business.processes

  • ModuleName:被测试的模块的名称(使用前面的例子中的artisan

搜索和替换操作还将为嵌入在模板中的ModuleCoverageTest子类定义提供一个唯一的名称。一旦这些替换完成,测试模块就可以运行,就像前面的例子中所示的那样,并且将开始报告缺少的测试用例和方法。

遵循这种结构的每个测试模块都在一个unittest.TestSuite对象中跟踪其本地测试,该对象可以被父测试模块导入,并且可以根据需要从子TestSuite实例中添加测试,模板文件中有一个注释掉的示例,显示了这种情况的样子:

# import child_module
# LocalSuite.addTests(child_module.LocalSuite._tests)

最后,模板文件利用了自定义的unit_testing模块中定义的一些显示和报告函数,将总结的测试结果数据写入控制台,并且(当测试运行时没有失败)写入一个本地文件,如果需要的话可以在源代码控制中进行跟踪。

将测试与构建过程集成

只剩下一个故事/任务集,即如何将单元测试与组件项目的任何构建过程集成起来:

  • 作为开发人员,我需要知道如何将组件项目的单元测试集成到该组件项目的构建过程中,以便构建可以自动执行单元测试:

  • 确定如何将单元测试集成到构建过程中

  • 确定如何处理不同环境的构建/测试集成

在组件项目中刚刚定义的单元测试结构中,将它们集成到构建过程中相对容易。在基于setup.py文件的构建中,测试模块可以在setup函数的test_suite参数中指定,并且可以通过执行python setup.py test来运行测试。在hms_sys组件项目中,还需要将单元测试标准代码的路径添加到setup.py中:

#!/usr/bin/env python

# Adding our unit testing standards
import sys
sys.path.append('../standards')

from setuptools import setup

# The actual setup function call:
setup(
    name='HMS-Core',
    version='0.1.dev0',
    author='Brian D. Allbee',
    description='',
    package_dir={
        '':'src',
        # ...
    },
    # Can also be automatically generated using 
    #     setuptools.find_packages...
    packages=[
        'hms_core',
        # ...
    ],
    package_data={
#        'hms_core':[
#            'filename.ext',
#            # ...
#        ]
    },
    entry_points={
#        'console_scripts':[
#            'executable_name = namespace.path:function',
#            # ...
#        ],
    },
# Adding the test suite for the project
    test_suite='tests.test_hms_core',
)

如果需要基于 Makefile 的构建过程,setup.py test的具体调用可以简单地包含在相关的 Make 目标中:

# Makefile for the HMS Core (hms-core) project

main: test setup
        # Doesn't (yet) do anything other than running the test and 
        # setup targets

setup:
        # Calls the main setup.py to build a source-distribution
        # python setup.py sdist

test:
        # Executes the unit-tests for the package, allowing the build-
        # process to die and stop the build if a test fails
        python setup.py. test

setup.py中执行的测试套件将返回适当的值,以阻止 Make 进程在出现错误或失败时停止。

摘要

除了设置新团队或新业务之外,大多数这些流程和政策很可能在项目开始之前就已经建立好了——通常是在团队承担的第一个项目之前或期间。大多数开发商和团队都会发现这一章节中提出的解决方案的需求,并且会采取行动。

所有这些项目都已经设置并提交到版本控制系统,为随后的迭代开发工作奠定了基础。第一个“真正的”迭代将着手处理基本业务对象的定义和实现。

第八章:创建业务对象

在第七章中检查hms_sys的逻辑架构,设置项目和流程,整个系统范围内出现了一些常见的业务对象类型:

如前图所示的对象,解释如下:

  • 一个Artisan对象代表一个Artisan——一个最终用户,他创建要出售的产品项目,并通过系统将这些产品提供给 HMS 中央办公室。Artisans被收集在中央办公室的数据结构中,并且在一定程度上可以由中央办公室工作人员管理,但是他们的实际数据大部分需要由个体工匠自己拥有和管理;这样,他们可以尽可能地控制自己的信息,中央办公室工作人员不必管理工匠的数据更改,例如,如果他们更改地址,或者想要添加或更改公司名称。

  • 产品是一个物理对象的表示,是工匠创造并出售的物品。

  • 订单是顾客通过 HMS 网店订购产品的结果。

这三种对象类型还暗示了另外两种之前没有提到的对象类型:

  • 代表实际下订单的顾客,并且可以附加到一个或多个订单

  • 地址,代表可以发货或收货的物理位置,也可以附加到一个或多个订单,可能是顾客的属性,几乎肯定是工匠的属性

本章将介绍将这些对象实现为通用类库的实现,该类库可以被应用程序和服务项目的代码利用,包括设计、实现、自动化测试和构建过程,将其转化为可部署的包。

本章涵盖以下内容:

  • 迭代目标

  • 故事和任务的组装

  • 类的快速审查

  • hms_sys中实现基本业务对象

  • 测试业务对象

  • 分发和安装考虑

  • 质量保证和验收

  • 操作/使用、维护和停用考虑

迭代目标

因此,这次迭代的交付成果是一个类库,可以与真实项目的包和代码一起安装或合并,用户应用程序和服务可以提供这些业务对象的通用表示结构:

  • hms_core包/库

  • 单元测试

  • 能够作为独立包构建

  • 包括提供以下基本表示的基类:

    • 工匠
  • 顾客

  • 订单

  • 产品

故事和任务的组装

由于业务对象包的组件旨在被系统中的其他包消耗或使用,因此大部分相关故事仍然专注于提供开发人员需要的东西:

  • 作为开发人员,我需要一个通用的定义和功能结构来表示系统中的地址,以便我可以将它们合并到需要它们的系统部分中:

  • 定义BaseAddress抽象基类(ABC)

  • 实现BaseAddress ABC

  • BaseAddress ABC 进行单元测试

  • 作为开发人员,我需要一个通用的定义和功能结构来表示系统中的工匠,以便我可以将它们合并到需要它们的系统部分中:

  • 定义BaseArtisan ABC

  • 实现BaseArtisan ABC

  • BaseArtisan ABC 进行单元测试

  • 作为开发人员,我需要一个通用的定义和功能结构来表示系统中的顾客,以便我可以将它们合并到需要它们的系统部分中:

  • 定义BaseCustomer ABC

  • 实现BaseCustomer ABC

  • BaseCustomer ABC 进行单元测试

  • 作为开发人员,我需要一个通用的定义和功能结构来表示系统中的订单,以便我可以将它们合并到需要它们的系统部分中:

  • 定义一个BaseOrder ABC

  • 实现BaseOrder ABC

  • BaseOrder ABC 进行单元测试

  • 作为开发人员,我需要一个通用的定义和功能结构来表示系统中的产品,以便我可以将它们合并到需要它们的系统部分中:

  • 定义一个BaseProduct ABC

  • 实现BaseProduct ABC

  • BaseProduct ABC 进行单元测试

  • 作为Artisan,我需要将业务对象库与我的应用程序一起安装,以便应用程序能够按需工作,而无需我安装它的依赖组件:

  • 确定setup.py是否可以基于包含来自本地项目结构之外的包,并在可以的情况下实现它

  • 否则,实现基于Makefile的过程,将hms_core包含在其他项目的打包过程中

  • 作为中央办公室用户,我需要将业务对象库与我的应用程序一起安装,以便应用程序能够按需工作,而无需我安装它的依赖组件:

  • 验证Artisan打包/安装过程是否也适用于中央办公室的安装

  • 作为系统管理员,我需要安装业务对象库与Artisan网关服务,以便它能够按需工作,而无需我安装它的依赖组件:

  • 验证Artisan打包/安装过程是否也适用于Artisan网关安装

值得注意的是,虽然这种设计从定义了许多抽象类开始,但这并不是唯一的方式。另一个可行的选择是从每个其他库中的简单 Concrete Classes 开始,然后提取这些类的共同要求,并创建 ABC 来强制执行这些要求。这种方法会更快地产生具体的功能,同时将结构和数据标准推迟到后面,并要求将相当多的代码从 Concrete Classes 移回到 ABC,但这仍然是一个可行的选择。

快速审查类

在任何面向对象的语言中,类都可以被视为创建对象的蓝图,定义了这些对象作为类的实例的特征、拥有的东西以及可以做的事情。类经常代表现实世界的对象,无论是人、地方还是物品,但即使它们不是,它们也提供了一套简洁的数据和功能/功能,适合逻辑概念单元。

随着hms_sys的开发进展,将设计和实现几个类,包括具体类和抽象类。在大多数情况下,设计将从类图开始,即一对多类的绘图,显示每个类的结构以及它们之间的任何关系:

Concrete Class旨在被实例化,从提供的蓝图创建对象实例。Abstract Class为具有特定Class Members(具体或抽象)的对象提供基线功能、接口要求和类型标识,这些成员将被继承或需要在从它们派生的类中实现。这些成员的范围,包括PropertiesMethods,按照约定,公共成员用+表示,私有成员用-表示,受保护的成员用#表示,尽管如前所述,Python 并没有真正的受保护或私有成员。不过,这些至少提供了成员的预期范围的一些指示。

在 hms_sys 中实现基本的业务对象

在开发过程的这一阶段,我们只是不知道所有业务对象类的确切功能是否将在即将构建的两个应用程序和服务中发挥作用。数据所有权规则——确定用户可以在对象内创建、更新或删除哪些数据——尚未详细说明,因此还不能做出这些决定。然而,仅基于这些对象的目的,我们已经有足够的信息来开始定义它们代表的数据以及这些数据点周围应该存在的约束。

我们可能已经有足够的信息来知道某些对象类型需要存在某些功能,例如,Artisan对象需要能够添加和删除相关的Product对象,即使我们还不知道这将如何运作,或者是否有关于这些对象的数据所有权规则。我们还可以对哪些类需要是抽象的做出一些合理的猜测(因为它们的实际实现将在应用程序和服务之间变化)。

Address

Address类表示一个物理位置——可以将某物邮寄或运送到的地方,或者可以在地图上找到的地方。无论对象在什么上下文中遇到,地址的属性都将是一致的——也就是说,地址是地址,无论它是与ArtisanCustomer还是Order相关联的——并且在这一点上,可以放心地假设任何地址的整体都可以被其所属的对象更改,或者都不可以。在这一点上,除非有相反的信息,否则似乎不需要将地址作为后端数据结构中的单独项存储;尽管它们可能会有自己的有意义的独立存在,但没有理由假设它们会有。

考虑到这一点,至少目前为止,将地址作为抽象类并不感觉是必要的:

地址是一个愚蠢的数据对象,至少目前为止;它由一个数据结构组成,但没有方法或功能。类本身的属性相当简单,并且围绕它们有一些规则:

  • street_address是位置的街道地址。它应该是一个单行字符串值,是必需的(不能是空的),并且可能不允许除空格之外的任何空白字符。street_address的一个示例值可能是1234 Main Street

  • building_address是地址的可选第二行,用于指示关于实际位置的街道地址的更多细节。示例可能包括公寓号、套房或办公室位置或编号等。如果在任何给定的地址中存在,它应该是一个具有与street_address相同约束的字符串值,但同样,它是一个可选值。

  • city是一个必需的字符串值,同样限制为单行,并且与street_address具有相同的空白规则。

  • region是一个可选的字符串值,具有与postal_codecountry相同的约束,至少目前是这样。

这最后三个属性很难在没有某种特定国家上下文的情况下制定规则。在某些国家,地址可能没有地区或邮政编码,而在其他国家,它们可能有完全不同的名称和数据要求,尽管这似乎不太可能。例如,考虑到在美国,地区和postal_code代表邮政编码(五个数字,带有一个可选的破折号和另外四个数字),而在加拿大,它们代表一个领土或省份和一个字母数字混合的邮政编码。对于一些要求,可能会有一个按国家划分的解决方案,在初步处理完属性定义之后将对此进行检查。

Address的初始实现非常简单;我们首先定义一个具有可用属性的类:

class Address:
    """
Represents a physical mailing-address/location
"""
    ###################################
    # Class attributes/constants      #
    ###################################

# ... removed for brevity

    ###################################
    # Instance property definitions   #
    ###################################

    building_address = property(
        _get_building_address, _set_building_address, 
        _del_building_address, 
        'Gets, sets or deletes the building_address (str|None) '
        'of the instance'
    )
    city = property(
        _get_city, _set_city, _del_city, 
        'Gets, sets or deletes the city (str) of the instance'
    )
    country = property(
        _get_country, _set_country, _del_country, 
        'Gets, sets or deletes the country (str|None) of the '
        'instance'
    )
    region = property(
        _get_region, _set_region, _del_region, 
        'Gets, sets or deletes the region (str|None) of the '
        'instance'
    )
    postal_code = property(
        _get_postal_code, _set_postal_code, _del_postal_code, 
        'Gets, sets or deletes the postal_code (str|None) of '
        'the instance'
    )
    street_address = property(
        _get_street_address, _set_street_address, 
        _del_street_address, 
        'Gets, sets or deletes the street_address (str) of the '
        'instance'
    )

每个property调用都指定了必须实施的 getter、setter 和 deleter 方法。getter 方法都非常简单,每个方法都返回存储该属性实例数据的相关属性值:

    ###################################
    # Property-getter methods         #
    ###################################

    def _get_building_address(self) -> (str,None):
        return self._building_address

    def _get_city(self) -> str:
        return self._city

    def _get_country(self) -> (str,None):
        return self._country

    def _get_region(self) -> (str,None):
        return self._region

    def _get_postal_code(self) -> (str,None):
        return self._postal_code

    def _get_street_address(self) -> str:
        return self._street_address

尽管必须实施一些逻辑以强制执行前面提到的类型和值规则,但设置方法也相对简单。到目前为止,地址的属性分为两类:

  • 必填,非空,单行字符串(例如street_address

  • 可选(None)或非空,单行字符串值(building_address

所需值的实现将都遵循相同的模式,以street_address为例:

    def _set_street_address(self, value:str) -> None:
        # - Type-check: This is a required str value
        if type(value) != str:
            raise TypeError(
                '%s.street_address expects a single-line, '
                'non-empty str value, with no whitespace '
                'other than spaces, but was passed '
                '"%s" (%s)' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
        # - Value-check: no whitespace other than " "
        bad_chars = ('\n', '\r', '\t')
        is_valid = True
        for bad_char in bad_chars:
            if bad_char in value:
                is_valid = False
                break
        # - If it's empty or otherwise not valid, raise error
        if not value.strip() or not is_valid:
            raise ValueError(
                '%s.street_address expects a single-line, '
                'non-empty str value, with no whitespace '
                'other than spaces, but was passed '
                '"%s" (%s)' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
        # - Everything checks out, so set the attribute
        self._street_address = value

设置方法的过程,从头到尾,如下所示:

  1. 确保提交的valuestr类型,并且如果不是这种情况则引发TypeError

  2. 创建一个禁止字符列表——换行符、回车符和制表符('\n''\r''\t')——不应该允许在值中出现

  3. 假设该值有效,直到另有确定(is_valid = True

  4. 检查值中是否存在这些非法字符,并且如果存在,则标记该值为无效

  5. 检查值是否只是空格(value.strip())或是否找到了任何无效字符,如果是,则引发ValueError

  6. 如果没有引发错误,则将属性的内部存储属性设置为现在经过验证的值(self._street_address = value

相同的代码,将street_address更改为city,处理了城市属性的 setter 实现。这个属性 setter 的过程/流程将反复出现,在这个迭代和后续的迭代中。从现在开始使用时,它将被称为标准必需文本行属性 setter。

可选属性使用非常相似的结构,但首先检查(并允许)None值,因为将它们的值设置为None在技术上是有效的/允许的。building_address属性 setter 就是这一过程的一个例子:

    def _set_building_address(self, value:(str,None)) -> None:
        if value != None:
            # - Type-check: If the value isn't None, then it has to 
            #   be a non-empty, single-line string without tabs
            if type(value) != str:
                raise TypeError(
                    '%s.building_address expects a single-line, '
                    'non-empty str value, with no whitespace '
                    'other than spaces or None, but was passed '
                    '"%s" (%s)' % 
                    (
                        self.__class__.__name__, value, 
                        type(value).__name__
                    )
                )
            # - Value-check: no whitespace other than " "
            bad_chars = ('\n', '\r', '\t')
            is_valid = True
            for bad_char in bad_chars:
                if bad_char in value:
                    is_valid = False
                    break
            # - If it's empty or otherwise not valid, raise error
            if not value.strip() or not is_valid:
                raise ValueError(
                    '%s.building_address expects a single-line, '
                    'non-empty str value, with no whitespace '
                    'other than spaces or None, but was passed '
                    '"%s" (%s)' % 
                    (
                        self.__class__.__name__, value, 
                        type(value).__name__
                    )
                )
            # - If this point is reached without error, then the 
            #   string-value is valid, so we can just exit the if
        self._building_address = value

这个 setter 方法的过程,就像前面的标准必需文本行属性一样,将会经常出现,并且将被称为标准可选文本行属性 setter。

删除方法也将非常简单——如果删除了这些属性中的任何一个,都可以将其设置为None,以便它们仍然具有值(从而避免在其他地方引用时出现AttributeError的实例),但可以用于指示没有值的值:

    def _del_building_address(self) -> None:
        self._building_address = None

    def _del_city(self) -> None:
        self._city = None

    def _del_country(self) -> None:
        self._country = None

    def _del_region(self) -> None:
        self._region = None

    def _del_postal_code(self) -> None:
        self._postal_code = None

    def _del_street_address(self) -> None:
        self._street_address = None

通过定义属性及其基础方法,使类可用的唯一剩下的就是定义其__init__方法,以便实际接受和存储相关属性的Address实例的创建。

很诱人只坚持简单的结构,接受并要求各种地址元素的顺序与它们通常使用的顺序相同,类似于这样:

    def __init__(self, 
        street_address,                  # 1234 Main Street
        building_address,                # Apartment 3.14
        city, region, postal_code,       # Some Town, ST, 00000
        country                          # Country. Maybe.
        ):

同样有效的另一种方法是允许参数的默认值,这些默认值将转换为实例创建的可选属性:

    def __init__(self, 
        street_address,                  # 1234 Main Street
        city,                            # Some Town
        building_address=None,           # Apartment 3.14
        region=None, postal_code=None,   # ST, 00000
        country=None                     # Country
        ):

从功能的角度来看,这两种方法都是完全有效的——可以使用任一种方法创建Address实例——但第一种方法可能更容易理解,而第二种方法则允许创建一个最小的实例,而无需每次都担心指定每个参数值。关于使用哪种参数结构应该涉及一些严肃的思考,包括以下因素:

  • 谁将创建新的Address实例?

  • 这些Address创建过程是什么样的?

  • 何时何地需要新的Address实例?

  • 它们将如何被创建?也就是说,这个过程周围是否会有某种 UI,并且是否会有任何一致性?

“谁”这个问题有一个非常简单的答案,而且大多数情况下也能回答其他问题:几乎任何用户都可能需要能够创建新地址。中央办公室工作人员在设置新的Artisan账户时可能会需要。Artisans偶尔可能需要,如果他们需要更改他们的地址。顾客虽然只是间接地,在他们下第一个订单时会需要,而且可能需要为运输单独创建地址,而不是使用他们自己的默认/账单地址。甚至Artisan网关服务可能需要创建Address实例,作为处理数据来回移动的过程的一部分。

在大多数情况下,会涉及某种 UI:顾客订单相关项目的网店表单,以及Artisan和中央办公室应用程序中的任何 GUI。在地址创建过程中有一个 UI,将参数从 UI 传递给__init__的责任只对开发人员来说才重要或关注。因此,这些问题虽然能够揭示功能需求是什么,但在选择两种参数形式之间并没有太大帮助。

也就是说,__init__可以以一种方式定义,而为Address创建另一种结构的方法,例如standard_address

    @classmethod
    def standard_address(cls, 
            street_address:(str,), building_address:(str,None), 
            city:(str,), region:(str,None), postal_code:(str,None), 
            country:(str,None)
        ):
        return cls(
            street_address, city, building_address, 
            region, postal_code, country
        )

这样就允许__init__使用结构,利用各种默认参数值:

def __init__(self, 
    street_address:(str,), city:(str,), 
    building_address:(str,None)=None, region:(str,None)=None, 
    postal_code:(str,None)=None, country:(str,None)=None
    ):
    """
Object initialization.

self .............. (Address instance, required) The instance to 
                    execute against
street_address .... (str, required) The base street-address of the 
                    location the instance represents
city .............. (str, required) The city portion of the street-
                    address that the instance represents
building_address .. (str, optional, defaults to None) The second 
                    line of the street address the instance represents, 
                    if applicable
region ............ (str, optional, defaults to None) The region 
                    (state, territory, etc.) portion of the street-
                    address that the instance represents
postal_code ....... (str, optional, defaults to None) The postal-code 
                    portion of the street-address that the instance 
                    represents
country ........... (str, optional, defaults to None) The country 
                    portion of the street-address that the instance 
                    represents
"""
    # - Set default instance property-values using _del_... methods
    self._del_building_address()
    self._del_city()
    self._del_country()
    self._del_postal_code()
    self._del_region()
    self._del_street_address()
    # - Set instance property-values from arguments using 
    #   _set_... methods
    self._set_street_address(street_address)
    self._set_city(city)
    if building_address:
        self._set_building_address(building_address)
    if region:
        self._set_region(region)
    if postal_code:
        self._set_postal_code(postal_code)
    if country:
        self._set_country(country)

这使得Address在功能上是完整的,至少对于本次迭代中关于它的故事来说是这样。

在任何类正在开发过程中,开发人员可能会出现关于他们设想的用例的问题,或者在考虑类的某些方面时会出现问题。在Address被完善时出现的一些例子如下:

  • 如果在实例中删除了非默认属性值,会发生什么?如果删除了必需的值,那么实例将不再是完整的,从技术上讲是无效的结果——甚至可能会发生这样的删除吗?

  • 有一个 Python 模块,pycountry,它收集 ISO 衍生的国家和地区信息。是否希望尝试利用这些数据,以确保国家/地区的组合是现实的?

  • Address最终是否需要任何输出能力?例如标签文本?或者可能需要生成 CSV 文件中的一行?

这些问题可能值得保存在某个地方,即使它们从未变得相关。如果没有某种项目系统存储库来保存这些问题,或者开发团队中没有一些流程来保存它们,以免它们丢失,它们总是可以被添加到代码本身中,作为某种注释,也许像这样:

# TODO: Consider whether Address needs some sort of #validation 
#       mechanism that can leverage pycountry to assure #that 
#       county/region combinations are kosher.
#       pycountry.countries—collection of countries
#       pycountry.subdivisions—collection of regions by #country
# TODO: Maybe we need some sort of export-mechanism? Or a 
#       label-ready output?
# TODO: Consider what can/should happen if a non-default #property-
#       value is deleted in an instance. If a required #value is 
#       deleted, the instance is no longer well-formed...
class Address:
    """
#Represents a physical mailing-address/location
"""

BaseArtisan

Artisan类代表参与手工制品市场的工匠——一个通过中央办公室的网店销售产品的人。知道几乎每个用户与最终Artisan类的交互都几乎肯定会有不同的功能规则,因此在hms_core代码库中创建一个抽象类来定义其他包中任何具体Artisan的共同功能和要求是有意义的。我们将把这个类命名为BaseArtisan

就像我们刚刚完成的Address类一样,BaseArtisan的设计和实现始于一个类图:

抽象类通常具有指示它们是抽象的命名约定。在这种情况下,Base 的前缀就是这个指示符,并且将在开发过程中用于其他抽象类。

BaseArtisan旨在为系统中任何部分的任何Artisan关联的所有属性提供一组通用的状态数据规则和功能。属性本身将是具体的实现。此外,BaseArtisan还旨在以add_productremove_product方法的形式提供一些(最小的)功能要求。由于工匠和产品彼此相关,因此一个具体的Artisan对象需要能够添加和删除Product对象,但是关于这些过程的具体细节可能会在两个应用程序和使用该功能的服务之间有所不同,因此它们将是抽象的——需要在从BaseArtisan派生的任何类中被覆盖/实现。

该类图还包括了之前创建的Address类,两个类之间有一个菱形结束的连接器。该连接表示Address类被用作BaseArtisan的聚合属性——也就是说,BaseArtisan的地址属性是Address的一个实例。在地址属性本身中也有这种表示,地址属性的类型指定为<Address>。简单来说,一个BaseArtisan有一个Address

也可以将BaseArtisan定义为从Address继承。该关系的类图几乎与上面相同,除了连接器,如下所示:

在这种关系中,BaseArtisan是一个Address——它将拥有Address的所有属性,以及可能在后续添加的任何方法成员。这两种关系都是完全合法的,但在继承上使用聚合(或组合)方法而不是依赖继承有一些值得注意的优势,这些优势值得在移动到BaseArtisan的实现之前注意。

OO 原则-组合优于继承

很可能最明显的优势之一是结构容易理解。一个“工匠”实例将有一个地址属性,该属性是另一个对象,该对象有其自己的相关属性。在“工匠”级别上,只有一个重要的地址,这可能看起来并不重要。然而,其他对象,比如“顾客”和“订单”,可能有多个关联的地址(例如,账单地址和送货地址),甚至可能有几个:“顾客”可能有几个需要保留和可用的送货地址。

随着系统的对象库变得越来越庞大和复杂,使用纯继承的设计方法将不可避免地导致大量的类树,其中许多类可能只是提供功能,目的仅仅是为了被继承。基于组合的设计将减少这种复杂性,在更大更复杂的库中可能会显著减少,因为功能将封装在单个类中,这些类的实例本身就成为属性。

然而,这种组合也有一些潜在的缺点:深度嵌套的对象,属性的属性的属性无休止地,可能会导致长链的数据结构。例如,在hms_sys的上下文中,如果一个“订单”有一个“顾客”,顾客又有一个“送货地址”,那么从“订单”中找到该地址的“邮政编码”看起来会像是order.customer.shipping_address.postal_code。这并不是一个非常深或复杂的路径来获取涉及的数据,因为属性名称很容易理解,所以理解整个路径并不困难。与此同时,很容易想象这种嵌套会失控,或者依赖于不那么容易理解的名称。

还有可能(也许很可能)需要一个类来提供一些组合属性类方法的本地实现,这增加了父对象类的复杂性。举个例子,假设刚才提到的shipping_address的地址类有一个方法,检查各种运输 API 并返回一个从最低到最高成本排序的列表—称之为find_best_shipping。如果有一个要求order对象能够使用该功能,那可能最终会在订单类级别定义一个find_best_shipping方法,调用地址级别的方法并返回相关数据。

然而,这些都不是重大的缺点。只要在确保设计逻辑和易于理解,成员名称有意义的情况下进行一些纪律性的练习,它们可能不会比单调更糟。

从更纯粹的面向对象的角度来看,一个更重要的问题是菱形问题。考虑以下代码:

class Root:
    def method(self, arg, *args, **kwargs):
        print('Root.method(%s, %s, %s)' % (arg, str(args), kwargs))

class Left(Root):
    def method(self, arg, *args, **kwargs):
        print('Left.method(%s, %s, %s)' % (arg, str(args), kwargs))

class Right(Root):
    def method(self, arg, *args, **kwargs):
        print('Right.method(%s, %s, %s)' % (arg, str(args), kwargs))

class Bottom(Left, Right):
    pass

b = Bottom()

这些类形成了一个菱形,因此有了菱形问题的名称:

以下代码执行时会发生什么:

b.method('arg', 'args1', 'args2', keyword='value')

哪个方法会被调用?除非语言本身定义了如何解决歧义,否则唯一可以肯定的是Root的方法不会被调用,因为LeftRight类都对其进行了重写。

Python 通过使用类定义中指定的继承顺序作为方法解析顺序MRO)来解决这种性质的歧义。在这种情况下,因为Bottom被定义为从LeftRight继承—class Bottom(Left, Right)—这个顺序将被用来确定实际执行哪个可用的method

# Outputs "Left.method(arg, ('args1', 'args2'), {'keyword': 'value'})"

尽管似乎不太可能任何可安装的hms_sys组件会达到继承问题成为重大关注的程度,但并不能保证永远不会发生。鉴于这一点,以及从基于继承到基于组合的重构工作可能会非常痛苦并且容易引入破坏性变化,即使在这一点上,基于组合的方法,即使具有一些固有的缺点,也感觉像是更好的设计。

实现 BaseArtisan 的属性

为了将工匠表示为一个人(可能还有公司名称),具有位置和产品,BaseArtisan提供了六个属性成员:

  • contact_name工匠的联系人姓名。它应该是一个标准的必需文本行属性,如前所定义。

  • contact_emailcontact_name中提到的人的电子邮件地址。它应该是一个格式良好的电子邮件地址,并且是必需的。

  • company_name是一个标准的可选文本行属性(可选,因为并非所有工匠都有公司名称)。

  • address将是必需的,并且将是Address的一个实例。

  • website工匠的可选网站地址。如果存在,它将需要是一个格式良好的 URL。

  • products将是BaseProduct对象的集合,方式与address是一个Address实例的方式相似。一些关于产品的实现细节将被推迟,直到BaseProduct被完全定义。

与之前一样,流程从创建类开始,并定义其实现的属性:

class BaseArtisan(metaclass=abc.ABCMeta):
    """
Provides baseline functionality, interface requirements, and 
type-identity for objects that can represent an Artisan in 
the context of the HMS system.
"""

metaclass=abc.ABCMeta包含在内定义了BaseArtisan作为抽象基类,使用abc模块的ABCMeta功能:

    ###################################
    # Instance property definitions   #
    ###################################

    address = property(
        _get_address, _set_address, _del_address, 
        'Gets, sets or deletes the physical address (Address) '
        'associated with the Artisan that the instance represents'
    )
    company_name = property(
        _get_company_name, _set_company_name, _del_company_name, 
        'Gets, sets or deletes the company name (str) associated '
        'with the Artisan that the instance represents'
    )
    contact_email = property(
        _get_contact_email, _set_contact_email, _del_contact_email, 
        'Gets, sets or deletes the email address (str) of the '
        'named contact associated with the Artisan that the '
        'instance represents'
    )
    contact_name = property(
        _get_contact_name, _set_contact_name, _del_contact_name, 
        'Gets, sets or deletes the name of the contact (str) '
        'associated with the Artisan that the instance represents'
    )
    products = property(
        _get_products, None, None, 
        'Gets the collection of products (BaseProduct) associated '
        'with the Artisan that the instance represents'
    )
    website = property(
        _get_website, _set_website, _del_website, 
        'Gets, sets or deletes the URL of the website (str) '
        'associated with the Artisan that the instance represents'
    )

由于company_namecontact_name是标准的可选和必需的文本行实现,就像在创建Address类时描述的那样,它们的实现将遵循在那里建立的模式,并且不会被详细检查。它们的过程与Address.building_addressAddress.street_address的过程相同,唯一变化的是 getter、setter 和 deleter 方法的名称以及存储属性的状态数据属性。

同样,与除产品之外的所有属性相关的_get__del_方法将遵循已经建立的相同基本模式:

  • Getter 方法将简单地返回存储在相应状态存储属性中的值

  • 删除方法将将相应状态存储属性的值设置为None

例如,addresscompany_namecontact_email的 getter 和 deleter 方法的实现可以与先前显示的完全相同的过程,即使address不是一个简单的值属性,contact_email还没有被实现:

    def _get_address(self) -> (Address,):
        return self._address

    def _del_address(self) -> None:
        self._address = None

    def _get_company_name(self) -> (str,None):
        return self._company_name

    def _del_company_name(self) -> None:
        self._company_name = None

    def _get_contact_email(self) -> (str,None):
        return self._contact_email

    def _del_contact_email(self) -> None:
        self._contact_email = None

这可能感觉像大量样板文件,复制和粘贴的代码,但这是能够执行由 setter 方法处理的类型和值检查的成本。setter 方法本身是保持所需的高度数据类型和完整性的魔法发生的地方。

address属性的 setter 可能会出乎意料地简单,因为实际上只需要强制执行传递给它的任何值必须是Address类的实例。没有值检查,因为任何成功创建的Address实例都将在初始化过程中执行自己的类型和值检查:

    def _set_address(self, value:Address) -> None:
        if not isinstance(value, Address):
            raise TypeError(
                '%s.address expects an Address object or an object '
                'derived from Address, but was passed "%s" (%s) '
                'instead, which is not.' %
                (value, type(value).__name__)
            )
        self._address = value

contact_email的 setter 可以工作得像在Address._set_street_address中定义的标准必需文本行 setter 过程一样。毕竟,它有一些相同的数据规则——它是一个必需值,不能是空的,而且由于它是一个电子邮件地址,它不能是多行或包含制表符。然而,由于它是一个电子邮件地址,它也不能包含空格,并且有其他字符限制是所有电子邮件地址共有的,这些限制在原始结构中没有考虑到。由于该属性的要求包括它是一个格式良好的电子邮件地址,可能有其他更好的方法来验证传递给 setter 的值。

理想情况下,应用程序将希望确保电子邮件地址既格式良好又有效。然而,确实只有一种方法可以实现其中任何一种,而且这超出了hms_sys的范围,即使尝试实现也是有意义的:发送确认电子邮件,并且在收到确认响应之前/除非不存储该值。

有许多方法可以让我们完成大部分验证格式良好的电子邮件地址的工作。可能最好的起点是使用正则表达式与该值匹配,或者删除所有格式良好的电子邮件地址,并且在执行替换后不允许设置该值,除非剩下的内容为空。使用正则表达式可能不会保证该值格式良好,但它将捕获许多无效值。将其与email.utils模块中找到的一些标准 Python 功能结合起来,至少可以使代码达到一个测试点,以查找失败的格式良好的地址,并允许修改检查过程。

首先,我们需要从email.utils中导入parseaddr函数和re模块中的一些项目,以便创建我们将用于测试的正则表达式对象。这些导入应该发生在模块的顶部:

#######################################
# Standard library imports needed     #
#######################################

import abc # This was already present
import re

from email.utils import parseaddr

接下来,我们将创建一个模块级常量正则表达式对象,用于检查电子邮件地址值:

EMAIL_CHECK = re.compile(
    r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)'
)

这将匹配以一个或多个字符AZ(大写或小写)、任何数字 0-9 或下划线、句点、加号或破折号开头的整个字符串,然后是@,然后是大多数域名。这种结构是在互联网上进行快速搜索时找到的,可能不完整,但看起来应该适用于大多数电子邮件地址。现在,setter 方法的所有实现需要做的就是检查该值是否为字符串,从字符串中解析出可识别的地址,检查解析后的值,如果一切正常,设置数据存储属性的值:

    def _set_contact_email(self, value:str) -> None:
        # - Type-check: This is a required str value
        if type(value) != str:
            raise TypeError(
                '%s.contact_email expects a str value that is a '
                'well-formed email address, but was passed '
                '"%s" (%s)' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
        # - Since we know it's a string, we can start by parsing value 
        #   with email.utils.parseaddr, and using the second item of 
        #   that result to check for well-formed-ness
        check_value = parseaddr(value)[1]
        # - If value is not empty, then there was *something* that was
        #   recognized as being an email address
        valid = (check_value != '')
        if valid:
            # - Try removing an entire well-formed email address, as 
            #   defined by EMAIL_CHECK, from the value. If it works, 
            #   there will either be a remnant or not. If there is 
            #   a remnant, it's considered badly-formed.
            remnant = EMAIL_CHECK.sub('', check_value)
            if remnant != '' or not value:
                valid = False
        if not check_value or not valid:
            raise TypeError(
                '%s.contact_email expects a str value that is a '
                'well-formed email address, but was passed '
                '"%s" (%s)' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
        self._contact_email = value

类似的方法应该是网站 setter 方法的一个很好的起点,使用以下作为正则表达式进行测试:

URL_CHECK = re.compile(
    r'(^https?://[A-Za-z0-9][-_A-Za-z0-9]*\.[A-Za-z0-9][-_A-Za-z0-9\.]*$)'
)

它以与Address._set_building_address中建立的相同可选值检查开始,但使用URL_CHECK正则表达式对象来检查传入的值,方式与_set_contact_email相同:

    def _set_website(self, value:(str,None)) -> None:
        # - Type-check: This is an optional required str value
        if value != None:
            if type(value) != str:
                raise TypeError(
                    '%s.website expects a str value that is a '
                    'well-formed URL, but was passed '
                    '"%s" (%s)' % 
                    (
                        self.__class__.__name__, value, 
                        type(value).__name__
                    )
                )
            remnant = URL_CHECK.sub('', value)
            if remnant != '' or not value:
                raise TypeError(
                    '%s.website expects a str value that is a '
                    'well-formed URL, but was passed '
                    '"%s" (%s)' % 
                    (
                        self.__class__.__name__, value, 
                        type(value).__name__
                    )
                )
        self._website = value

现在只剩下一个属性要实现:productsproducts属性具有一些方面,一开始可能不明显,但对应该如何实现它可能有潜在的重要影响。首先,它是其他对象的集合——无论是列表、字典还是其他什么——但无论如何,它都不是像address那样的单个对象。此外,它被定义为只读属性:

    products = property(
        _get_products, None, None, 
        'Gets the collection of products (BaseProduct) associated '
        'with the Artisan that the instance represents'
    )

property定义中只提供了 getter 方法。这是有意为之,但需要一些解释。

由于产品旨在处理产品对象的集合,因此products属性本身不能更改为其他内容非常重要。例如,如果产品是可设置的,就有可能执行以下操作:

# Given artisan = Artisan(...whatever initialization…)
artisan.products = 'Not a product collection anymore!'

当然,可以实施类型和值检查代码来防止这种赋值方式,尽管属性本身没有与之关联的 setter 方法,但我们几乎肯定会在以后需要一个,而且它应该实施该类型和值检查。然而,它的使用可能仅限于在创建工匠实例期间填充实例的产品。

另一个潜在的问题是,可能会以容易出错和难以调节的方式更改集合的成员资格。例如,使用相同的artisan实例,并假设产品的底层数据存储是列表,没有任何阻止代码执行以下任何操作:

artisan.products.append('This is not a product!')
artisan.products[0] = 'This is also not a product!'

同样,允许任意删除工匠的产品(del artisan.products)可能不是一个好主意。

因此,至少,我们希望确保以下内容:

  • 不允许或不能影响真实的底层数据的products成员资格

  • 仍然允许访问(也许是操作)单个products成员的成员,也就是说,给定产品实例的列表,从中读取数据并向其写入数据不受其所在集合的限制

即使没有开发某种自定义集合类型,也有几种选择。由于products属性使用 getter 方法来获取和返回值,因此可以更改返回的数据,以便:

  • 直接返回实际数据的副本,这样更改返回集合的成员资格不会影响原始集合

  • 将数据以不同的集合类型返回副本;例如,如果真实数据存储在列表中,返回该列表的元组将提供与原始列表相同的可迭代序列功能,但不允许更改副本本身的成员资格

Python 通过对象引用跟踪对象——也就是说,它通过与分配给对象的名称相关联的内存中的位置来关注对象实际存在的位置——因此,当从已经存在的对象列表创建对象的列表或元组时,新集合的成员与原始列表中存在的对象相同,例如:

# - Create a class to demonstrate with
class Example:
    pass

# -  Create a list of instances of the class
example_list = [
    Example(), Example(), Example(), Example()
]

print('Items in the original list (at %s):' % hex(id(example_list)))
for item in example_list:
    print(item)

# Items in the original list (at 0x7f9cd9ed6a48):
# <__main__.Example object at 0x7f9cd9eed550>
# <__main__.Example object at 0x7f9cd9eed5c0>
# <__main__.Example object at 0x7f9cd9eed5f8>
# <__main__.Example object at 0x7f9cd9eed630>

创建原始列表的副本将创建一个新的独立集合,其中仍然包含相同的成员:

new_list = list(example_list)
print('Items in the new list (at %s):' % hex(id(new_list)))
for item in new_list:
    print(item)

# Items in the new list (at 0x7f9cd89dca88):
# <__main__.Example object at 0x7f9cd9eed550>
# <__main__.Example object at 0x7f9cd9eed5c0>
# <__main__.Example object at 0x7f9cd9eed5f8>
# <__main__.Example object at 0x7f9cd9eed630>

创建元组也需要类似的方式:

new_tuple = tuple(example_list)
print('Items in the new tuple (at %s):' % hex(id(new_tuple)))
for item in new_tuple:
    print(item)

# Items in the new tuple (at 0x7f9cd9edd4a8):
# <__main__.Example object at 0x7f9cd9eed550>
# <__main__.Example object at 0x7f9cd9eed5c0>
# <__main__.Example object at 0x7f9cd9eed5f8>
# <__main__.Example object at 0x7f9cd9eed630>

因此,返回从原始状态数据值创建的新列表或元组将处理防止对属性值进行的更改影响真正的基础数据。目前,元组返回选项似乎是更好的选择,因为它更加严格,这种情况下_get_products将被实现如下:

def _get_products(self) -> (tuple,):
  return tuple(self._products)

删除方法_del_products不能使用None作为默认值,因为现在已经有了 getter。它将必须更改为其他内容,因为尝试返回一个None默认值的tuple会引发错误。目前,删除的值将更改为一个空列表:

def _del_products(self) -> None:
  self._products = []

最后,这是设置方法,_set_products

    def _set_products(self, value:(list, tuple)) -> None:
        # - Check first that the value is an iterable - list or 
        #   tuple, it doesn't really matter which, just so long 
        #   as it's a sequence-type collection of some kind.
        if type(value) not in (list, tuple):
            raise TypeError(
                '%s.products expects a list or tuple of BaseProduct '
                'objects, but was passed a %s instead' % 
                (self.__class__.__name__, type(value).__name__)
            )
        # - Start with a new, empty list
        new_items = []
        # - Iterate over the items in value, check each one, and 
        #   append them if they're OK
        bad_items = []
        for item in value:
            # - We're going to assume that all products will derive 
            #   from BaseProduct - that's why it's defined, after all
            if isinstance(item, BaseProduct):
                new_items.append(item)
            else:
                bad_items.append(item)
        # - If there are any bad items, then do NOT commit the 
        #   changes -- raise an error instead!
        if bad_items:
            raise TypeError(
                '%s.products expects a list or tuple of BaseProduct '
                'objects, but the value passed included %d items '
                'that are not of the right type: (%s)' % 
                (
                    self.__class__.__name__, len(bad_items), 
                    ', '.join([str(bi) for bi in bad_items])
                )
            )
        self._products = value

综合起来,这些变化相当大地限制了对产品属性的更改:

  • 属性本身是只读的,不允许设置或删除值

  • 从 getter 方法返回的值与实际存储在其状态数据中的值相同,但不同,并且虽然它仍然允许访问原始集合的成员,但不允许更改原始集合的成员资格

  • 设置方法强制对整个集合进行类型检查,确保集合的成员只由适当的对象类型组成

尚未考虑的是对集合成员进行实际更改的过程——这种能力在方法成员中。

实现 BaseArtisan 的方法

BaseArtisan,按照当前的设计,应该提供两个抽象方法:

  • add_product,需要一个机制来添加products到实例的产品集合中,需要在派生的具体类中实现

  • remove_product,同样需要一个机制来从派生实例的products集合中删除项目

这些被指定为抽象方法,因为虽然在hms_sys的应用和服务可安装组件中,每个方法几乎肯定会涉及一些共同的功能,但在这些相同的组件中也几乎肯定会有显著的实现差异——例如,artisans 可能是唯一可以真正从他们的products集合中删除项目的用户。

通常,在大多数支持定义抽象方法的编程语言中,这些方法不需要提供任何实际的实现。事实上,定义方法为抽象方法可能会禁止任何实现。Python 并不强制这种限制在抽象方法上,但也不期望有任何实现。因此,我们的抽象方法不需要比这更复杂:

 @abc.abstractmethod
 def add_product(self, product:BaseProduct):
    pass

 @abc.abstractmethod
 def remove_product(self, product:BaseProduct):
    pass

虽然我们允许在抽象方法中放入具体实现,但是在某些情况下,可以利用这一点,在一个地方提供基线功能。这两种方法,add_productremove_product,属于这种情况:

  • 添加产品总是需要进行类型检查,当出现无效类型时引发错误,并将新项目附加到实例的集合中

  • 从实例的产品集合中删除指定产品总是涉及到删除产品

考虑到这些因素,将这些常见流程放入抽象方法中实际上是有益的,就好像它们是具体实现一样。这些流程可以从派生类实例中调用,无论在执行基线本身之前还是之后,都可以加入或不加入额外的逻辑。考虑在BaseArtisan中实现add_product的基本方法如下:

    @abc.abstractmethod
    def add_product(self, product:BaseProduct):
        """
Adds a product to the instance's collection of products.

Returns the product added.

self ....... (BaseArtisan instance, required) The instance to 
             execute against
product ...  (BaseProduct, required) The product to add to the 
             instance's collection of products

Raises TypeError if the product specified is not a BaseProduct-
  derived instance

May be implemented in derived classes by simply calling
    return BaseArtisan.add_product(self, product)
"""
        # - Make sure the product passed in is a BaseProduct
        if not isinstance(product, BaseProduct):
            raise TypeError(
                '%s.add_product expects an instance of '
                'BaseProduct to be passed in its product '
                'argument, but "%s" (%s) was passed instead' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
        # - Append it to the internal _products list
        self._products.append(product)
        # - Return it
        return product

一个派生类——例如,位于总部应用程序中的Artisan类——将需要实现add_product,但可以按照以下方式实现:

    def add_product(self, product:BaseProduct):
        # - Add any additional checking or processing that might 
        #   need to happen BEFORE adding the product here

        # - Call the parent add_product to perform the actual 
        #   addition
        result = BaseArtisan.add_product(self, product)

        # - Add any additional checking or processing that might 
        #   need to happen AFTER adding the product here

        # - Return the product
        return result

不过,这种方法存在一个权衡:派生类可以实现一个全新的add_product流程,跳过现成的验证/业务规则。另一种方法是定义一个抽象验证方法(也许是_check_products),它处理验证过程,并由add_product的具体实现直接调用。

remove_product方法可以类似地定义,并且可以在派生类实例中以类似的方式实现:

    @abc.abstractmethod
    def remove_product(self, product:BaseProduct):
        """
Removes a product from the instance's collection of products.

Returns the product removed.

self ....... (BaseArtisan instance, required) The instance to 
             execute against
product ...  (BaseProduct, required) The product to remove from 
             the instance's collection of products

Raises TypeError if the product specified is not a BaseProduct-
  derived instance
Raises ValueError if the product specified is not a member of the 
  instance's products collection

May be implemented in derived classes by simply calling
    return BaseArtisan.remove_product(self, product)
"""
        # - Make sure the product passed in is a BaseProduct.
        #   Technically this may not be necessary, since type 
        #   is enforced in add_product, but it does no harm to 
        #   re-check here...
        if not isinstance(product, BaseProduct):
            raise TypeError(
                '%s.add_product expects an instance of '
                'BaseProduct to be passed in its product '
                'argument, but "%s" (%s) was passed instead' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
        try:
            self._products.remove(product)
            return product
        except ValueError:
            raise ValueError(
                '%s.remove_product could not remove %s from its '
                'products collection because it was not a member '
                'of that collection' % 
                (self.__class__.__name__, product)
            )

可能还有其他方法适合添加到BaseArtisan中,但如果有的话,它们可能会在具体Artisan类的实现中出现。现在,我们可以在定义了__init__方法之后称BaseArtisan为完成:

    def __init__(self, 
        contact_name:str, contact_email:str, 
        address:Address, company_name:str=None, 
        **products
        ):
        """
Object initialization.

self .............. (BaseArtisan 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
products .......... (BaseProduct collection) The products associated 
                    with the Artisan that the instance represents
"""
        # - Call parent initializers if needed
        # - Set default instance property-values using _del_... methods
        self._del_address()
        self._del_company_name()
        self._del_contact_email()
        self._del_contact_name()
        self._del_products()
        # - Set instance property-values from arguments using 
        #   _set_... methods
        self._set_contact_name(contact_name)
        self._set_contact_email(contact_email)
        self._set_address(address)
        if company_name:
            self._set_company_name(company_name)
        if products:
            self._set_products(products)
        # - Perform any other initialization needed

基础客户

定义客户数据结构的类非常简单,并且使用了已经在AddressBaseArtisan中建立的代码结构来定义其所有属性。就像BaseArtisan与具体Artisan实例的关系一样,预期Customer对象在其所能做的事情上会有很大的变化,也许在系统的不同组件之间允许的数据访问上也会有所不同。再次,我们将首先定义一个 ABC——BaseCustomer——而不是一个具体的Customer类:

BaseCustomer的属性包括:

  • name,一个标准的必填文本行。

  • billing_addressshipping_address,除了它们的名称之外,与BaseArtisan中定义的地址属性相同。shipping_address将是可选的,因为客户可能只有一个地址用于两者。

BaseCustomer的唯一值得一提的新方面是在初始化期间对shipping_address进行注释。BaseCustomer.__init__基本上会遵循之前类定义中展示的相同结构/方法:

    def __init__(self, 
        name:str, billing_address:Address, 
        shipping_address(Address,None)=None
    ):
        """
Object initialization.

self .............. (BaseCustomer instance, required) The instance to 
                    execute against
name .............. (str, required) The name of the customer.
billing_address ... (Address, required) The billing address of the 
                    customer
shipping_address .. (Address, optional, defaults to None) The shipping 
                    address of the customer.
"""
        # - Call parent initializers if needed
        # - Set default instance property-values using _del_... methods
        self._del_billing_address()
        self._del_name()
        self._del_shipping_address()
        # - Set instance property-values from arguments using 
        #   _set_... methods
        self._set_name(name)
        self._set_billing_address(billing_address)
        if shipping_address:
            self._set_shipping_address(shipping_address)
        # - Perform any other initialization needed

shipping_address参数的注释(Address,None)是新的,有点新意。我们以前使用过内置类型作为注释类型,以及在可选参数规范中使用过内置的非None类型和NoneAddress.__init__在几个地方使用了这种表示法。尽管这段代码使用了我们定义的一个类,但它的工作方式是一样的:Address类也是一种类型,就像以前的例子中的str一样。它只是在这个项目中定义的一种类型。

基础订单

创建几乎任何愚蠢的数据对象类,甚至是大多数愚蠢的数据对象类,其过程非常相似,无论这些类代表什么,至少只要这些努力的整个范围内的数据结构规则保持不变。随着创建更多这样的面向数据的类,将需要更少的新方法来满足特定需求,直到最终将有一套简洁的方法来实现所需的各种类型和值约束的各种属性。

BaseOrder类,与BaseProduct一起显示,是这种效果的一个很好的例子,至少乍一看是这样的:

BaseOrder属性列表非常简短,因为订单实际上代表的只是与一组产品的客户关系:

  • customerBaseCustomer的一个实例,而BaseCustomer又有该顾客billing_addressshipping_address属性;除了属性值的类型将是BaseCustomer实例之外,可以合理地假设它将以与BaseCustomerAddress类型属性相同的方式运行

  • productsBaseProduct实例的集合,可能可以完全像BaseArtisanproducts属性一样运行——毕竟,它将做同样的事情,存储产品实例并防止对这些实例的改变——因此,它的初始实现将直接从BaseArtisan复制过来

简而言之,除了在顾客属性的情况下更改名称外,这两个属性已经有了已建立的实现模式,因此在BaseOrder中没有实质性的新内容可展示。

有时直接从一个类复制代码到另一个类是一个有争议的话题;即使一切都完美运行,根据定义,这是复制代码,这意味着如果以后出现问题,就需要维护多个副本的代码。

BaseProduct

BaseProduct ABC 也有大量接近样板的属性代码,尽管其中只有三个属性符合到目前为止已经建立的实现模式:

  • name 是一个标准的必需文本行属性。

  • summary 是一个标准的必需文本行属性。

  • description 是一个可选的字符串值。

  • dimensions 是一个标准的可选文本行属性。

  • shipping_weight 是一个必需的数字值,可能仅用于确定运输成本,但也可能出现在网店的产品展示中。

  • metadata 是元数据键(字符串)和值(也是字符串)的字典。这是一个新的数据结构,所以我们很快就会详细研究它。

  • available 是一个必需的布尔值,允许工匠指示产品在 HMS 网店上可供销售,尽管可能对中央办公室工作人员可见。

  • store_available 也是一个必需的布尔值,表示 HMS 网店是否应该考虑产品可用。它打算由中央办公室工作人员控制,尽管可能对工匠可见。

BaseProduct目前只有两个关联的方法,用于管理与产品实例相关的元数据值:

  • set_metadata 将在实例上设置元数据键/值

  • remove_metadata 将从实例中删除元数据键和值

namesummarydimensions属性作为标准的必需和可选文本行,将遵循这些模式。description几乎是一个可选文本行的实现;唯一需要改变的是删除空格字符检查,然后就可以使用了:

# These lines aren't needed for description
# - Value-check: no whitespace other than " "
bad_chars = ('\n', '\r', '\t')
for bad_char in bad_chars:
    if bad_char in value:
       is_valid = False
       break

shipping_weight属性的实现在 setter 方法_set_shipping_weight中最为显著,但(希望)与项目中属性的典型方法结构相符,这是可以预期的:

def _set_shipping_weight(self, value:(int,)):
  if type(value) != int:
    raise TypeError(
      '%s.shipping_weight expects a positive integer '
      'value, but was passed "%s" (%s)' % 
      (
         self.__class__.__name__, 
         value, type(value).__name__
       )
    )
   if value <= 0:
    raise ValueError(
      '%s.shipping_weight expects a positive integer '
       'value, but was passed "%s" (%s)' % 
       (
          self.__class__.__name__, 
          value, type(value).__name__
       )
    )
   self._shipping_weight = value

对于available属性的实现也可以这样说,尽管允许使用正式的布尔值(TrueFalse)和整数值等价物(10)作为有效的 setter 值参数是有道理的。这在对象状态数据可能无法存储为真布尔值的情况下留有余地,尽管这是一个不太可能的情况,但也不是不可能的:

def _set_available(self, value:(bool,int)):
   if value not in (True, False, 1, 0):
      raise ValueError(
        '%s.available expects either a boolean value '
         '(True|False) or a direct int-value equivalent '
         '(1|0), but was passed "%s" (%s)' % 
          (self.__class__.__name__, value, type(value).__name__)
          )
   if value:
      self._available = True
        else:
          self._available = False

这样就只剩下了metadata属性的实现。元数据可能最好被视为关于其他数据的数据——在这种情况下,是关于类基本上代表的产品的数据。在这种特殊情况下,metadata属性旨在提供高度灵活的数据,这些数据可能在一个产品(或产品类型)到另一个产品之间变化很大,同时仍然以相对简单的方式在更严格定义的类/对象结构中提供。这在 Hand Made Stuff 的需求背景下是很重要的,因为工匠通过他们的网店销售的产品几乎可以是任何东西:珠宝、木制品、金属家具、服装、珠宝等。虽然有一些描述可能适用于任何产品——例如它是由什么制成的,也许一些基本项目,比如颜色——但有一些描述使得几乎不可能在当前产品类结构中对整个可用范围的产品进行分类,而不是要求在当前产品类结构中有更多的数据结构,或者有很多产品类型,这些产品类型几乎肯定会在彼此之间有一个难以想象的复杂关系。

因此,初始实现和设计将围绕着维护每个对象的基于dict的元数据结构。如果以后出现更严格的要求(例如,要求木制品必须指定木材的类型),则可能需要进行相应的重构工作,但目前一个简单的dict看起来是合理的。

BaseArtisanBaseOrderproducts属性一样,BaseProductmetadata需要难以轻易或意外更改——它应该需要一些有意识的决定来进行更改。鉴于metadata结构预期提供用于对产品进行分类的数据,至少键将受到一定限制。元数据名称应该有意义并且相当简短。metadata值也应该是如此,尽管它们可能比相应的键受到的限制要少。

综合考虑所有这些项目,获取器和删除器方法与其他属性的等效方法并没有显着不同——通常只是名称更改和不同的删除默认值:

    ###################################
    # Property-getter methods         #
    ###################################

    # ... 

    def _get_metadata(self) -> (dict,):
        return self._metadata

    # ... 

    ###################################
    # Property-deleter methods        #
    ###################################

    # ... 

    def _del_metadata(self) -> None:
        self._metadata = {}

设置方法通常是最常见的地方,其中存在显着的差异;在这种情况下,当调用时,期望是清除任何现有的元数据并用新的经过验证的键和值集合替换它。这将更改属性中的整个集合,而不仅仅是它的一些或全部成员。由于该类还将提供专用方法来允许添加新的metadata,或更改metadata中的现有项目,并且该方法将需要对键和值进行所需的任何验证,_set_metadata属性设置方法将使用同名的set_metadata方法来确保所有元数据都符合相同的标准。

第一步是确保传入的值是一个字典:

    ###################################
    # Property-setter methods         #
    ###################################
# ... 

def _set_metadata(self, value:(dict,)):
 if type(value) != dict:
  raise TypeError(
   '%s.metadata expects a dictionary of metadata keys '
    '(strings) and values (also strings), but was passed '
         '"%s" (%s)' % 
    (self.__class__.__name__, value, type(value).__name__)
         )

我们将设置一个变量来跟踪遇到的任何无效值,并使用与在初始化期间清除当前元数据的相同机制_del_metadata

badvalues = []
self._del_metadata()

完成这些后,我们可以遍历值的键和值,对每一对调用set_metadata,直到它们都被记录,并捕获任何错误以提供更有用的错误消息时需要:

if value: # Checking because value could be an empty dict: {}
  for name in value:
     try:
       # - Since set_metadata will do all the type- and 
       #   value-checking we need, we'll just call that 
       #   for each item handed off to us here...
           self.set_metadata(name, value[name])
     except Exception:
       # - If an error was raised,then we want to capture 
       #   the key/value pair that caused it...
             badvalues.append((name, value[name]))

如果检测到任何错误的值,那么我们将希望引发错误并记录它们。如果没有错误发生,那么属性已被重新填充:

if badvalues:
   # - Oops... Something's not right...
    raise ValueError(
      '%s.metadata expects a dictionary of metadata keys '
      '(strings) and values, but was passed a dict with '
      'values that aren\'t allowed: %s' % 
         (self.__class__.__name__, str(badvalues))
       )

set_metadata方法看起来很像我们各种属性 setter 方法——元数据中的键和(目前)值都像标准的必需文本行属性一样操作——因此对每个属性执行的类型和数值检查看起来会非常熟悉:

def set_metadata(self, key:(str,), value:(str,)):
   """
Sets the value of a specified metadata-key associated with the product 
that the instance represents.

self .............. (BaseProduct instance, required) The instance to 
                    execute against
key ............... (str, required) The metadata key to associate a 
                    value with
value ............. (str, required) The value to associate with the 
                    metadata key
"""

这里是对key参数值的类型和数值检查:

if type(key) != str:
  raise TypeError(
    '%s.metadata expects a single-line, '
     'non-empty str key, with no whitespace '
     'other than spaces, but was passed "%s" (%s)' % 
     (
        self.__class__.__name__, key, 
        type(key).__name__
      )
    )
   # - Value-check of key: no whitespace other than " "
        bad_chars = ('\n', '\r', '\t')
        is_valid = True
        for bad_char in bad_chars:
            if bad_char in key:
                is_valid = False
                break
   # - If it's empty or otherwise not valid, raise error
    if not key.strip() or not is_valid:
       raise ValueError(
         '%s.metadata expects a single-line, '
         'non-empty str key, with no whitespace '
         'other than spaces, but was passed "%s" (%s)' % 
          (
            self.__class__.__name__, key, 
            type(key).__name__
          )
       )

这里是对value参数值的类型和数值检查:

if type(value) != str:
  raise TypeError(
    '%s.metadata expects a single-line, '
    'non-empty str value, with no whitespace '
    'other than spaces, but was passed "%s" (%s)' % 
    (
       self.__class__.__name__, value, 
       type(value).__name__
    )
  )
  # - Value-check of value: no whitespace other than " "
     bad_chars = ('\n', '\r', '\t')
     is_valid = True
     for bad_char in bad_chars:
        if bad_char in value:
          is_valid = False
          break
  # - If it's empty or otherwise not valid, raise error
      if not value.strip() or not is_valid:
        raise ValueError(
          '%s.metadata expects a single-line, '
          'non-empty str value, with no whitespace '
          'other than spaces, but was passed "%s" (%s)' % 
            (
               self.__class__.__name__, value, 
               type(value).__name__
            )
         )
     self._metadata[key] = value

删除metadata需要的代码要短得多,也更简单,尽管它也假设如果试图删除不存在的元数据,则不需要引发错误。可能需要允许出现这样的错误,但目前的假设是不需要:

def remove_metadata(self, key):
        """
Removes the specified metadata associated with the product that the 
instance represents, identified by the key

self .............. (BaseProduct instance, required) The instance to 
                    execute against
key ............... (str, required) The key that identifies the 
                    metadata value to remove
"""
        try:
            del self._metadata[key]
        except KeyError:
            pass

通过BaseProduct完成,hms_core类库的必需范围得到满足。单元测试仍需编写,并解决由此产生的任何问题。

处理重复的代码 - HasProducts

BaseArtisanBaseOrder都有products属性,其行为方式相同,以至于这些属性的原始实现基本上涉及将代码从一个属性复制并粘贴到另一个属性中。在这种特定情况下可能并不是什么大问题(因为hms_core类库很小,成员很少,只有两个地方需要维护重复的代码),但在更大的库中,或者如果有很多重复的代码,问题可能会很快变得非常棘手。由于 Python 允许类从多个父类继承,我们可以利用这种能力来定义一个新的 ABC——HasProducts,将所有与产品属性相关的代码放在一个地方:

这种方法是面向对象原则的一种变体,通常被称为混入——一个包含功能具体实现以供其他类使用的类。

HasProducts的实现本质上只是BaseArtisanBaseOrder的产品属性代码的集合或重新打包:

class HasProducts(metaclass=abc.ABCMeta):
    """
Provides baseline functionality, interface requirements, and 
type-identity for objects that can have a common products 
property whose membership is stored and handled in the same 
way.
"""

getter、setter 和 deleter 方法:

###################################
# Property-getter methods         #
###################################

def _get_products(self) -> (tuple,):
   return tuple(self._products)

###################################
# Property-setter methods         #
###################################

def _set_products(self, value:(list, tuple)) -> None:
# - Check first that the value is an iterable - list or 
#   tuple, it doesn't really matter which, just so long 
#   as it's a sequence-type collection of some kind.

 if type(value) not in (list, tuple):
   raise TypeError(
     '%s.products expects a list or tuple of BaseProduct '
     'objects, but was passed a %s instead' % 
     (self.__class__.__name__, type(value).__name__)
            )
  # - Start with a new, empty list
  new_items = []
  # - Iterate over the items in value, check each one, and 
  #   append them if they're OK
 bad_items = []
for item in value:
 # - We're going to assume that all products will derive 
 #   from BaseProduct - That's why it's defined, after all
      if isinstance(item, BaseProduct):
         new_items.append(item)
      else:
         bad_items.append(item)
 # - If there are any bad items, then do NOT commit the 
 #   changes -- raise an error instead!
     if bad_items:
      raise TypeError(
      '%s.products expects a list or tuple of BaseProduct'
      'objects, but the value passed included %d items '
      'that are not of the right type: (%s)' % 
      (
         self.__class__.__name__, len(bad_items), 
         ', '.join([str(bi) for bi in bad_items])
      )
   )
   self._products = value

###################################
# Property-deleter methods        #
###################################

  def _del_products(self) -> None:
    self._products = []

products属性定义:

###################################
# Instance property definitions   #
###################################

products = property(
_get_products, None, None,
'Gets the products (BaseProduct) of the instance'
)

对象初始化:

###################################
# Object initialization           #
###################################

def __init__(self, *products):
        """
Object initialization.

self .............. (HasProducts instance, required) The instance to 
                    execute against
products .......... (list or tuple of BaseProduct instances) The 
                    products that were ordered
"""
        # - Call parent initializers if needed
        # - Set default instance property-values using _del_... methods
        self._del_products()
        # - Set instance property-values from arguments using 
        #   _set_... methods
        if products:
            self._set_products(products)
        # - Perform any other initialization needed

###################################
# Abstract methods                #
###################################

用于添加和删除产品的抽象方法:

    @abc.abstractmethod
    def add_product(self, product:BaseProduct) -> BaseProduct:
        """
Adds a product to the instance's collection of products.

Returns the product added.

self ....... (HasProducts instance, required) The instance to 
             execute against
product ...  (BaseProduct, required) The product to add to the 
             instance's collection of products

Raises TypeError if the product specified is not a BaseProduct-
  derived instance

May be implemented in derived classes by simply calling
    return HasProducts.add_product(self, product)
"""
        # - Make sure the product passed in is a BaseProduct
        if not isinstance(product, BaseProduct):
            raise TypeError(
                '%s.add_product expects an instance of '
                'BaseProduct to be passed in its product '
                'argument, but "%s" (%s) was passed instead' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
        # - Append it to the internal _products list
        self._products.append(product)
        # - Return it
        return product

    @abc.abstractmethod
    def remove_product(self, product:BaseProduct):
        """
Removes a product from the instance's collection of products.

Returns the product removed.

self ....... (HasProducts instance, required) The instance to 
             execute against
product ...  (BaseProduct, required) The product to remove from 
             the instance's collection of products

Raises TypeError if the product specified is not a BaseProduct-
  derived instance
Raises ValueError if the product specified is not a member of the 
  instance's products collection

May be implemented in derived classes by simply calling
    return HasProducts.remove_product(self, product)
"""
        # - Make sure the product passed in is a BaseProduct.
        #   Technically this may not be necessary, since type 
        #   is enforced in add_product, but it does no harm to 
        #   re-check here...
        if not isinstance(product, BaseProduct):
            raise TypeError(
                '%s.add_product expects an instance of '
                'BaseProduct to be passed in its product '
                'argument, but "%s" (%s) was passed instead' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
        try:
            self._products.remove(product)
            return product
        except ValueError:
            raise ValueError(
                '%s.remove_product could not remove %s from its '
                'products collection because it was not a member '
                'of that collection' % 
                (self.__class__.__name__, product)
            )

BaseArtisanBaseOrder中使用HasProducts并不困难,尽管它涉及重构以删除已经存在的代码,这些代码将覆盖HasProducts中的公共代码。首先要确保使用HasProducts的类继承自它:

class BaseArtisan(HasProducts, metaclass=abc.ABCMeta):
    """
Provides baseline functionality, interface requirements, and 
type-identity for objects that can represent an Artisan in 
the context of the HMS system.
"""

派生类的__init__方法必须被修改为调用HasProducts__init__,以确保它执行所有相关的初始化任务:

def __init__(self, 
  contact_name:str, contact_email:str, 
  address:Address, company_name:str=None, 
  **products
  ):
    """
Object initialization.
"""
   # - Call parent initializers if needed
# This is all that's needed to perform the initialization defined 
# in HasProducts
        HasProducts.__init__(self, *products)

新类的默认值和实例值设置过程不再需要担心处理products属性的设置,因为这由HasProducts.__init__处理:

        # - Set default instance property-values using _del_... methods
        self._del_address()
        self._del_company_name()
        self._del_contact_email()
        self._del_contact_name()
# This can be deleted, or just commented out.
#        self._del_products()
     # - Set instance property-values from arguments using 
        #   _set_... methods
        self._set_contact_name(contact_name)
        self._set_contact_email(contact_email)
        self._set_address(address)
        if company_name:
            self._set_company_name(company_name)
# This also can be deleted, or just commented out.
#        if products:
#            self._set_products(products)

最后,每个派生类中的products属性以及它们关联的 getter、setter 和 deleter 方法都可以被移除:

# This also can be deleted, or just commented out.
#    products = property(
#         _get_products, None, None,
#         'Gets the products (BaseProduct) of the instance'
#    )

使用HasProductsBaseArtisanBaseOrder中实现后,hms_core包的完整结构和功能暂时完成——暂时是因为尚未进行单元测试。整个包的类图显示了所有的组成部分以及它们之间的关系:

总结

总的来说,这些类提供的定义可以被描述为“愚蠢的数据对象”。它们提供的功能与特定数据结构的定义和规范直接相关,几乎没有其他功能。即使是HasProducts及其派生类也属于这一类,因为那里提供的功能严格关注于提供数据结构和控制如何操作该结构。随着从这些类派生出的其他类的创建,这些类将开始变得更智能,首先是对个体对象数据的持久化。

首先,需要编写这些类的单元测试,以确保它们已经经过测试,并且可以按需重新测试。由于这代表了编码目标的重大转变,并且将涉及对测试目标及其实现方式进行深入研究,因此这个第一次单元测试需要有自己的章节。

第九章:测试业务对象

一旦定义和测试了核心业务对象,它们可以作为其他包中的基础类使用,以提供具体的类功能。采用这种方法至少有两个优点:

  • 核心类将处理数据类型、数据结构和数据验证的所有代码放在一个地方,这减少了依赖它们的其他代码库的复杂性

  • 一旦为核心对象创建了通过的单元测试,它们提供的所有功能就不需要在其他地方进行测试

  • 这些测试可以按需执行,并集成到最终构建过程中,提供一套完整的回归测试,以确保未来的更改不会在执行构建之前破坏现有功能

使用之前提到的测试扩展来构建这些单元测试的过程虽然不难,但一开始会很耗时。在本章中将审查整个过程,建立一些测试模式,我们将在后面的章节中重复使用这些模式,然后将它们整合到包构建过程中。

本章涵盖以下内容:

  • 测试业务对象

  • 分发和安装考虑

  • 质量保证和验收

  • 操作/使用、维护和停用考虑

开始单元测试过程

使用我们在上一章中定义的标准单元测试结构/框架,可以让我们快速、轻松地开始对任何代码库进行单元测试。它也非常适合迭代测试开发过程。一旦配置项在其中被一对搜索和替换操作设置好,起始点测试模块立即开始报告测试用例和方法的情况。我们的初始测试模块只是以下内容(为了保持列表的简洁,删除了一些注释):

#!/usr/bin/env python
"""
Defines unit-tests for the module at hms_core.
"""
#######################################
# Standard library imports needed     #
#######################################

import os
import sys
import unittest

#######################################
# Local imports needed                #
#######################################

from idic.unit_testing import *

#######################################
# Module-level Constants              #
#######################################

LocalSuite = unittest.TestSuite()

#######################################
# Import the module being tested      #
#######################################
import hms_core as hms_core

#######################################
# Code-coverage test-case and         #
# decorator-methods                   #
#######################################

class testhms_coreCodeCoverage(ModuleCoverageTest):
    # - Class constants that point to the namespace and module 
    #   being tested
    _testNamespace = 'hms_core'
    _testModule = hms_core

LocalSuite.addTests(
    unittest.TestLoader().loadTestsFromTestCase(
        testhms_coreCodeCoverage
    )
)

#######################################
# Test-cases in the module            #
#######################################

#######################################
# Code to execute if file is called   #
# or run directly.                    #
#######################################

if __name__ == '__main__':
    import time
    results = unittest.TestResult()
    testStartTime = time.time()
    LocalSuite.run(results)
    results.runTime = time.time() - testStartTime
    PrintTestResults(results)
    if not results.errors and not results.failures:
        SaveTestReport(results, 'hms_core',
            'hms_core.test-results')

执行测试模块产生以下结果:

然后,测试运行输出告诉我们,我们需要为被测试模块中定义的六个类生成测试用例类;具体来说,我们需要创建testAddresstestBaseArtisantestBaseCustomertestBaseOrdertestBaseProducttestHasProducts测试用例类。3

为了利用标准单元测试结构提供的属性和方法覆盖测试,每个测试方法都应该使用testhms_coreCodeCoverage提供的AddMethodTestingAddPropertyTesting装饰器进行装饰:

#######################################
# Test-cases in the module            #
#######################################

@testhms_coreCodeCoverage.AddMethodTesting
@testhms_coreCodeCoverage.AddPropertyTesting
class testAddress(unittest.TestCase):
    pass
LocalSuite.addTests(
    unittest.TestLoader().loadTestsFromTestCase(
        testAddress
    )
)

@testhms_coreCodeCoverage.AddMethodTesting
@testhms_coreCodeCoverage.AddPropertyTesting
class testBaseArtisan(unittest.TestCase):
    pass
LocalSuite.addTests(
    unittest.TestLoader().loadTestsFromTestCase(
        testBaseArtisan
    )
)

@testhms_coreCodeCoverage.AddMethodTesting
@testhms_coreCodeCoverage.AddPropertyTesting
class testBaseCustomer(unittest.TestCase):
    pass
LocalSuite.addTests(
    unittest.TestLoader().loadTestsFromTestCase(
        testBaseCustomer
    )
)
@testhms_coreCodeCoverage.AddMethodTesting
@testhms_coreCodeCoverage.AddPropertyTesting
class testBaseOrder(unittest.TestCase):
    pass
LocalSuite.addTests(
    unittest.TestLoader().loadTestsFromTestCase(
        testBaseOrder
    )
)
@testhms_coreCodeCoverage.AddMethodTesting
@testhms_coreCodeCoverage.AddPropertyTesting
class testBaseProduct(unittest.TestCase):
    pass
LocalSuite.addTests(
    unittest.TestLoader().loadTestsFromTestCase(
        testBaseProduct
    )
)

@testhms_coreCodeCoverage.AddMethodTesting
@testhms_coreCodeCoverage.AddPropertyTesting
class testHasProducts(unittest.TestCase):
    pass
LocalSuite.addTests(
    unittest.TestLoader().loadTestsFromTestCase(
        testHasProducts
    )
)

一旦这些测试就位,重新运行测试模块将生成一个(很长的!)需要在测试策略测试通过之前解决的项目清单。需求的完整清单足够长,直接包含在书中只会导致 2-3 页的项目符号列表。然而,完整的结果包含在hms_core代码库的miscellany/initial-test-run.txt中。整个初始输出太长,无法在此处完整重现,但输出的开头和结尾如下,并指定了需要在六个测试用例类中实现的总共 105 个测试方法:

从那时起,测试编写过程只是重复以下循环,直到所有测试通过为止:

  • 选择需要编写的缺失测试方法或一组测试方法

  • 将测试方法添加到适用的测试用例类中,并设置为失败,因为它们尚未实现

  • 运行测试模块以验证测试是否按预期失败

  • 对于每个测试方法:

  • 在方法中编写真实的测试代码

  • 执行测试模块,并确保该方法中唯一的失败是添加的显式失败,纠正任何出现的问题

  • 删除显式失败

即使有标准单元测试过程提供的指导,也不可否认为编写模块的所有单元测试,即使是相对较短的hms_core模块,可能会非常乏味。然而,有一些方法可以使这个过程至少变得更快一些,因为我们知道有一些我们期望的常见值类型和格式。我们将首先为Address类编写测试,该类具有我们将要处理的最大属性集合之一。随着这些测试的建立,一些常见的(可重复使用的)测试值将开始出现。

这次单元测试过程还将产生一个测试用例类模板文件(test-case-class.py),该文件将包含在书籍的代码模板目录中。

对 Address 类进行单元测试

Address类的测试最初报告需要编写以下测试方法:

  • 方法: test__init__test_del_building_addresstest_del_citytest_del_countrytest_del_postal_codetest_del_regiontest_del_street_addresstest_get_building_addresstest_get_citytest_get_countrytest_get_postal_codetest_get_regiontest_get_street_addresstest_set_building_addresstest_set_citytest_set_countrytest_set_postal_codetest_set_regiontest_set_street_addresstest_standard_address

  • 属性: testbuilding_addresstestcitytestcountrytestpostal_codetestregionteststreet_address

对被测试类的属性的测试方法的主要关注点可以说是确保属性使用适当的方法进行其 getter、setter 和 deleter 功能。如果这一点被确认为正确,那么处理属性及其值的实际过程可以仅在这些方法的测试方法中进行测试。考虑到这一点,Address的大部分属性测试将如下所示:

def testproperty_name(self):
   # Tests the property_name property of the Address class
   # - Assert that the getter is correct:
     self.assertEqual(
         Address.property_name.fget, 
         Address._get_property_name, 
        'Address.property_name is expected to use the '
        '_get_property_name method as its getter-method'
     )
      # - If property_name is not expected to be publicly                       # settable,
      #   the second item here 
      #   (Address._set_property_name) should 
      #   be changed to None, and the failure message           #   adjusted 
      #   accordingly:
           self.assertEqual(
            Address.property_name.fset, 
            Address._set_property_name, 
           'Address.property_name is expected to use the '
           '_set_property_name method as its setter-method'
        )
    #   If property_name is not expected to be publicly     #   deletable,
    #   the second item here (Address._del_property_name)     #   should 
    #   be changed to None, and the failure message         #   adjusted 
     #   accordingly:
       self.assertEqual(
          Address.property_name.fdel, 
          Address._del_property_name, 
          'Address.property_name is expected to use the '
          '_del_property_name method as its deleter-method'
      )

通过在代码块中切换模板化的property_name为实际的属性名称,可以相当快速地创建单个属性测试,例如,实现testbuilding_address

def testbuilding_address(self):
# Tests the building_address property of the Address class
# - Assert that the getter is correct:
     self.assertEqual(
        Address.building_address.fget, 
        Address._get_building_address, 
       'Address.building_address is expected to use the '
       '_get_building_address method as its getter-method'
     )
# - Assert that the setter is correct:
     self.assertEqual(
        Address.building_address.fset, 
        Address._set_building_address, 
       'Address.building_address is expected to use the '
       '_set_building_address method as its setter-method'
     )
# - Assert that the deleter is correct:
       self.assertEqual(
       Address.building_address.fdel, 
       Address._del_building_address, 
      'Address.building_address is expected to use the '
      '_del_building_address method as its deleter-method'
     )

获取器和删除器方法的测试通常也会非常简单 - 它们最终只需要确保它们从正确的内部存储属性中检索数据,并将该属性的值设置为预期的默认值。test_del_building_address测试方法作为一个例子:

def test_del_building_address(self):
# Tests the _del_building_address method of the Address 
# class
   test_object = Address('street address', 'city')
    self.assertEqual(
       test_object.building_address, None, 
       'An Address object is expected to have None as its default '
       'building_address value if no value was provided'
    )
# - Hard-set the storage-property's value, call the 
#   deleter-method, and assert that it's what's expected 
#   afterwards:
    test_object._building_address = 'a test value'
    test_object._del_building_address()
    self.assertEqual(
      test_object.building_address, None, 
      'An Address object is expected to have None as its '
      'building_address value after the deleter is called'
    )

值得注意的是,为了测试删除器方法(以及后来的获取器和设置器方法),我们实际上必须创建被测试对象的实例 - 这就是测试方法的第三行所做的事情(test_object = Address…)。一旦创建了该实例,如果正在测试的属性在测试对象的创建中不是必需的或作为其一部分提供,我们还可以(并且应该)测试实例的默认/删除值。即使为测试对象提供了一个值,通过设置底层存储属性中的值,调用删除器方法,并在之后验证结果,删除过程的测试在几乎所有情况下都将保持不变。

测试相应的 getter 方法将是类似的;它实际上只需要提供属性是否从正确的存储属性中检索数据:

def test_get_building_address(self):
# Tests the _get_building_address method of the Address 
# class
  test_object = Address('street address', 'city')
  expected = 'a test-value'
  test_object._building_address = expected
  actual = test_object._get_building_address()
  self.assertEqual(
    actual, expected, 
   'Address._get_building_address was expected to return '
   '"%s" (%s), but returned "%s" (%s) instead' % 
   (
       expected, type(expected).__name__,
       actual, type(actual).__name__,
   )
)

通常有用的是设置可以传递给测试的核心断言的expectedactual值,特别是如果检索这些值涉及使用方法或函数。这不会产生功能上的差异,但以后阅读起来会更容易,保持易于理解和可读性是非常重要的,比保持被测试代码可读和可理解更重要——毕竟,测试代码是质量保证工作,不应该因为加密结构而出现错误。

值得注意的是,citystreet_address属性的测试方法略有不同,因为它们都是在实例创建期间设置的属性。

def test_del_city(self):
   # Tests the _del_city method of the Address class
   expected = 'city'
   test_object = Address('street address', expected)
   self.assertEqual(
     test_object.city, expected, 
    'An Address object is expected to have "%s" (%s) as its '
    'current city value, since that value was provided' % 
       (expected, type(expected).__name__)
     )
# - Since we have a value, just call the deleter-method, 
#   and 
#   assert that it's what's expected afterwards:
     test_object._del_city()
       self.assertEqual(
         test_object.city, None, 
         'An Address object is expected to have None as its '
         'city value after the deleter is called'
     )

不同之处在于,由于预期创建的测试对象将提供一个值,因此我们在创建测试对象之前设置了预期值进行测试,然后使用该预期值创建测试对象,然后测试以确保删除器在对象创建期间不会删除最初设置的值。尽管如此,明确告知时它被删除的测试本质上是相同的。

一旦使用这些模式建立了所有 getter 和 deleter 方法的测试,测试模块运行开始显示进展。正在运行的 29 个测试之一(也是失败的一个)是代码覆盖测试,它正在捕捉BaseArtisan和其他hms_core类的缺失测试用例类,这些类已经被注释掉,以便更轻松地处理testAddress测试方法的结果输出。剩下的八个失败中,有六个是testAddress的设置方法测试,我们将在下一步实现,另外两个是test__init__teststandard_address,我们将最后看一下:

与 getter 和 deleter 方法对应的测试方法很简单,因为被测试的方法本身相当简单。它们(到目前为止)不做任何决定,也不对值进行任何操作;它们只是返回当前值,或者在不需要对替换值做任何决定的情况下进行替换。此外,它们也没有参数需要处理。

设置方法更复杂;它们会做出决策,会有参数(即使只有一个),并且可能预期根据这些参数的类型和值而表现出不同的行为。因此,相应的测试方法可能也会因此而变得更复杂,这种期望是有根据的。对于良好设计的测试来说,测试复杂性将随着输入复杂性的增加而增长,因为这些测试必须检查输入的所有逻辑变体。当我们测试属性的设置方法时,这将开始变得明显,首先从Address.building_address开始。

良好设计的单元测试需要做几件事情,其中并非所有事情一开始就显而易见。最明显的事项可能是测试所有快乐路径输入可能性:预期类型和预期有效值的输入,应该在没有错误的情况下执行并产生预期的结果,无论这些结果是什么。也许不那么明显的是,单元测试还应该使用一组已知的坏值进行代表性样本集的测试,这些值预计会引发错误并阻止被测试的过程完成错误数据。让我们再次以此为基础来看一下Address_set_building_address方法:

def _set_building_address(self, value:(str,None)) -> None:
    if value != None:
 # - Type-check: If the value isn't None, then it has to 
 #   be a non-empty, single-line string without tabs
    if type(value) != str:
       raise TypeError(
       '%s.building_address expects a single-line, '
       'non-empty str value, with no whitespace '
       'other than spaces or None, but was passed '
       '"%s" (%s)' % 
          (
             self.__class__.__name__, value, 
             type(value).__name__
          )
                )
  # - Value-check: no whitespace other than " "
         bad_chars = ('\n', '\r', '\t')
         is_valid = True
         for bad_char in bad_chars:
            if bad_char in value:
               is_valid = False
               break
 # - If it's empty or otherwise not valid, raise error
     if not value.strip() or not is_valid:
         raise ValueError(
         '%s.building_address expects a single-line, '
         'non-empty str value, with no whitespace '
         'other than spaces or None, but was passed '
         '"%s" (%s)' % 
           (
              self.__class__.__name__, value, 
              type(value).__name__
           )
        )
 # - If this point is reached without error, then the 
 #   string-value is valid, so we can just exit the if
      self._building_address = value

可以合理测试的良好值包括以下内容:

  • None——如果将None作为值传递,则它将简单地通过并设置在内部存储属性中。

  • 任何单行非空字符串,不包含制表符或空格字符以外的其他空白字符。

可行的坏值包括以下内容:

  • 任何不是字符串的值。

  • 空字符串。

  • 包含任何换行字符或任何不是空格的空白的字符串。

  • 一个什么都不是的空格字符的字符串;这个项目不太明显,但是代码会引发ValueError,因为这样的输入会被值检查代码中的if not value.strip()捕获。对仅包含空格的字符串调用.strip()的结果是一个空字符串,这将被评估为False(-ish),从而引发错误。

_set_building_address方法不会尝试进行任何内容验证,因此我们目前不必担心;我们默认假设,如果有人费心输入一个格式良好的building_address值,那么输入的值将是准确的。

早些时候,business_address属性被归类为标准可选文本行属性。如果这个分类是正确的,那么生成一个好的标准可选文本行属性值的单一列表将是可能的,也是有利的,这样这些值就可以被用于逻辑上适用于所有属性测试的所有属性。这个列表,作为测试模块中的一个常量,可能会像这样:

GoodStandardOptionalTextLines = [
    'word', 'hyphenated-word', 'short phrase', 
    'A complete sentence.', 
    'A short paragraph. This\'s got some punctuation, '
    'including "quoted text."',
    None # Because optional items are allowed to be None
]

然后,测试test_set_business_address中的好值就变得很简单,只需要遍历该值列表,调用 setter 方法,并断言在设置值后 getter 方法的结果与预期值匹配:

# - Create an object to test with:
test_object = Address('street address', 'street_address')
# - Test all permutations of "good" argument-values:
  for expected in GoodStandardOptionalTextLines:
     test_object._set_building_address(expected)
     actual = test_object._get_building_address()
     self.assertEqual(
        expected, actual, 
        'Address expects a building_address value set to '
        '"%s" (%s) to be retrieved with a corresponding '
        'getter-method call, but "%s" (%s) was returned '
        'instead' % 
     (
expected, type(expected).__name__, 
         actual, type(actual).__name__, 
     )
  )

如果我们已经在其他地方测试了属性与 getter 方法相关联,那么也可以对属性进行断言,而不是对 getter 方法进行断言。

对应的坏值列表将包括之前列出的所有坏项,并且看起来会像这样:

BadStandardOptionalTextLines = [
    # Bad string values
    'multiple\nlines', 'also multiple\rlines', 
    'text\twith\tabs',
    # Values that aren't strings at all
    1, True, 0, False, object(), 
    # empty and whitespace-only strings
    '', '  ',
]

相应的坏值测试与之前显示的好值迭代类似,只是它们将专门寻找执行预期失败的情况,并且如果这些情况没有发生或以意外的方式发生,则会失败:

# - Test all permutations of "bad" argument-values:
for value in BadStandardOptionalTextLines:
   try:
      test_object._set_building_address(value)
     # - If this setter-call succeeds, that's a 
     #   test-failure!
      self.fail(
         'Address._set_business_address should raise '
         'TypeError or ValueError if passed "%s" (%s), '
         'but it was allowed to be set instead.' % 
                (value, type(value).__name__)
        )
    except (TypeError, ValueError):
    # - This is expected, so it passes
         pass
    except Exception as error:
        self.fail(
          'Address._set_business_address should raise '
          'TypeError or ValueError if passed an invalid '
          'value, but %s was raised instead: %s.' % 
                (error.__class__.__name__, error)
        )

通过使用try...except块,这个测试过程将执行以下操作:

  • 如果 setter 方法允许设置坏值而不引发错误,则明确失败

  • 如果坏值在测试对象中设置时引发预期的错误(在大多数情况下是TypeErrorValueError),则通过

  • 如果在执行期间 setter 方法引发了除了预期的两种类型之外的任何错误,则失败

相同的测试方法结构可以用于Address的所有标准可选文本行值/类型的属性,而不需要更改 setter 方法名称。基本上,Address的所有属性 setter,除了标准必需文本行项目citystreet_address之外,都是相同的,只是名称不同。

然而,可选文本行属性和必需文本行属性之间唯一的区别是,可选项可以允许None作为有效参数,而必需项则不行。如果我们为这些差异创建单独的测试值列表,并更改测试方法使用的列表,那么相同的结构,只是具有不同的好和坏值,仍然可以工作:

GoodStandardRequiredTextLines = [
    'word', 'hyphenated-word', 'short phrase', 
    'A complete sentence.', 
    'A short paragraph. This\'s got some punctuation, '
    'including "quoted text."',
]
BadStandardRequiredTextLines = [
    # Bad string values
    'multiple\nlines', 'also multiple\rlines', 
    'text\twith\tabs',
    # Values that aren't strings at all
    1, True, 0, False, object(), 
    # empty and whitespace-only strings
    '', '  ',
    None # Because optional items are NOT allowed to be None
]

# ... 

def test_set_city(self):
    # Tests the _set_city method of the Address class
    # - Create an object to test with:
    test_object = Address('street address', 'street_address')
    # - Test all permutations of "good" argument-values:
    for expected in GoodStandardRequiredTextLines:
        test_object._set_city(expected)
        actual = test_object._get_city()
        self.assertEqual(
            expected, actual, 
            'Address expects a city value set to '
            '"%s" (%s) to be retrieved with a corresponding '
            'getter-method call, but "%s" (%s) was returned '
            'instead' % 
            (
                expected, type(expected).__name__, 
                actual, type(actual).__name__, 
            )
        )
    # - Test all permutations of "bad" argument-values:
    for value in BadStandardRequiredTextLines:
        try:
            test_object._set_city(value)
            # - If this setter-call succeeds, that's a 
            #   test-failure!
            self.fail(
                'Address._set_business_address should raise '
                'TypeError or ValueError if passed "%s" (%s), '
                'but it was allowed to be set instead.' % 
                (value, type(value).__name__)
            )
        except (TypeError, ValueError):
            # - This is expected, so it passes
            pass
        except Exception as error:
            self.fail(
                'Address._set_business_address should raise '
                'TypeError or ValueError if passed an invalid '
                'value, but %s was raised instead: %s.' % 
                (error.__class__.__name__, error)
            )

在所有 setter 方法测试就位后,重新运行测试模块显示只有三个测试失败:

除了其他测试用例类的覆盖测试之外,只剩下__init__standard_address方法需要测试。

测试__init__方法并不困难。它真正需要建立的是在创建新对象实例的初始化过程中,适当调用各种属性设置器。其他测试已经证实了属性连接到它们预期的 getter/setter/deleter 方法,并且这些方法正在按照预期进行。由于我们有预定义的良好值列表,可以迭代这些值,所以可以简单地设置一个(大)嵌套循环集来检查这些值的所有可能组合,因为它们适用于每个属性。循环的嵌套级别非常深(足够深,以至于以下代码每行只缩进两个空格以适应页面),但它有效:

def test__init__(self):
  # Tests the __init__ method of the Address class
  # - Test all permutations of "good" argument-values:
  for building_address in GoodStandardOptionalTextLines:
    for city in GoodStandardRequiredTextLines:
      for country in GoodStandardOptionalTextLines:
        for postal_code in GoodStandardOptionalTextLines:
          for region in GoodStandardOptionalTextLines:
            for street_address in GoodStandardRequiredTextLines:
              test_object = Address(
                street_address, city, building_address,
                region, postal_code, country
              )
              self.assertEqual(test_object.street_address, street_address)
              self.assertEqual(test_object.city, city)
              self.assertEqual(test_object.building_address, building_address)
              self.assertEqual(test_object.region, region)
              self.assertEqual(test_object.postal_code, postal_code)
              self.assertEqual(test_object.country, country)

同样的方法在实现teststandard_address时同样有效:

def teststandard_address(self):
  # Tests the standard_address method of the Address class
  # - Test all permutations of "good" argument-values:
  for street_address in GoodStandardRequiredTextLines:
    for building_address in GoodStandardOptionalTextLines:
      for city in GoodStandardRequiredTextLines:
        for region in GoodStandardOptionalTextLines:
          for postal_code in GoodStandardOptionalTextLines:
            for country in GoodStandardOptionalTextLines:
              test_object = Address.standard_address(
                street_address, building_address, 
                city, region, postal_code, 
                country
              )
              self.assertEqual(test_object.street_address, street_address)
              self.assertEqual(test_object.building_address, building_address)
              self.assertEqual(test_object.city, city)
              self.assertEqual(test_object.region, region)
              self.assertEqual(test_object.postal_code, postal_code)
              self.assertEqual(test_object.country, country)

这样,Address类的测试就完成了:

模块的单元测试过程的平衡实际上包括重新激活其他测试用例类,为它们创建基线失败的测试方法,然后运行测试模块并编写和纠正测试,正如前面所述。由于测试过程的执行方式,生成的输出将按照每个测试用例类的每个测试方法按字母顺序排列。因此,HasProducts的测试用例类将最后执行,在其中,testproducts方法之后是test_del_productstest_get_productstest_set_products。在输出中,处理最后失败的测试用例所需的时间比滚动整个输出查找正在处理的单个特定测试方法要少,因此剩下的测试将按照这个顺序进行处理和讨论。

单元测试 HasProducts

products属性的测试方法testproducts必须考虑属性的只读性质——记住products属性设置为防止或至少最小化对底层list值的随意操作的可能性。除了对 setter 和 deleter 方法分配的测试的更改之外,它基本上与以前的属性测试方法相同:

def testproducts(self):
    # Tests the products property of the HasProducts class
    # - Assert that the getter is correct:
    self.assertEqual(
        HasProducts.products.fget, 
        HasProducts._get_products, 
        'HasProducts.products is expected to use the '
        '_get_products method as its getter-method'
    )
    # - Assert that the setter is correct:
    self.assertEqual(
        HasProducts.products.fset, None, 
        'HasProducts.products is expected to be read-only, with '
        'no associated setter-method'
    )
    # - Assert that the deleter is correct:
    self.assertEqual(
        HasProducts.products.fdel, None, 
        'HasProducts.products is expected to be read-only, with '
        'no associated deleter-method'
    )

对于像HasProducts这样的 ABC 的方法进行测试,在某种程度上,与像Address这样的具体类的过程相同:必须创建一个作为 ABC 实例的测试对象,然后将相关的测试值传递给方法并断言它们的结果。但是,如果 ABC 具有抽象成员,则无法实例化,因此必须定义并使用一个具有抽象成员最小实现的一次性派生类来代替具体类来创建测试对象。为了测试HasProducts的成员方法,该类是HasProductsDerived,它看起来像这样:

class HasProductsDerived(HasProducts):
    def __init__(self, *products):
        HasProducts.__init__(self, *products)
# NOTE: These do NOT have to actually *do* anything, they
# merely have to *exist* in order to allow an instance 
    #       to be created:
    def add_product(self, product):
        pass
    def remove_product(self, product):
        pass

定义了该类后,可以创建_get_products_set_products_del_products的测试,这些测试是迄今为止使用的测试策略的直接变体,尽管它们首先需要使用throwaway类定义GoodProductsBadProducts

#  Since we needed this class in order to generate good #  product-
#   setter test-values, but it wasn't defined until now, #   we'll 
#   create the GoodProducts test-values here...
GoodProducts = [
    [
        BaseProductDerived('test1', 'summary1', True, True),
        BaseProductDerived('test2', 'summary2', True, True),
    ],
    (
        BaseProductDerived('test3', 'summary3', True, True),
        BaseProductDerived('test4', 'summary4', True, True),
    ),
]
BadProducts = [
    object(), 'string', 1, 1.0, True, None,
    ['list','with','invalid','values'],
    [
        BaseProductDerived('test4', 'summary4', True, True), 
        'list','with','invalid','values'
    ],
    ('tuple','with','invalid','values'),
    (
        BaseProductDerived('test4', 'summary4', True, True), 
        'tuple','with','invalid','values'
    ),
]

一旦这些也就位了,测试方法如下:

def test_del_products(self):
# Tests the _del_products method of the HasProducts class
   test_object = HasProductsDerived()
   self.assertEqual(test_object.products, (),
   'HasProducts-derived instances are expected to return '
   'an empty tuple as a default/deleted value'
   )
# - Test all permutations of "good" argument-values:
        test_object._set_products(GoodProducts[0])
        self.assertNotEqual(test_object.products, ())
        test_object._del_products()
        self.assertEqual(test_object.products, ())

def test_get_products(self):
 # Tests the _get_products method of the HasProducts class
        test_object = HasProductsDerived()
 # - Test all permutations of "good" argument-values:
        expected = GoodProducts[1]
        test_object._products = expected
        self.assertEqual(test_object._get_products(), expected)

    def test_set_products(self):
# Tests the _set_products method of the HasProducts class
        test_object = HasProductsDerived()
# - Test all permutations of "good" argument-values:
        for expected in GoodProducts:
            test_object._set_products(expected)
            if type(expected) != tuple:
                expected = tuple(expected)
            self.assertEqual(expected, test_object._get_products())
# - Test all permutations of each "bad" argument-value 
#   set against "good" values for the other arguments:
        for value in BadProducts:
            try:
                test_object._set_products(value)
                self.fail(
                    'HasProducts-derived classes should not allow '
                    '"%s" (%s) as a valid products value, but it '
                    'was allowed to be set.' % 
                    (str(value), type(value).__name__)
                )
            except (TypeError, ValueError):
                pass

HasProducts.__init__的测试方法使用了与test_set_products相同类型的方法:

def test__init__(self):
  # Tests the __init__ method of the HasProducts class
  # - Test all permutations of "good" argument-values:
        for expected in GoodProducts:
            test_object = HasProductsDerived(*expected)
            if type(expected) != tuple:
                expected = tuple(expected)
            self.assertEqual(test_object.products, expected)

由于HasProducts在其add_productremove_product方法背后隐藏了具体功能,因此也可以以同样的方式测试该功能,但是根据我们的测试策略,任何调用这些方法的派生类方法仍然必须单独进行测试,因此在这个时候额外的努力并没有太大意义。

单元测试 BaseProduct

BaseProduct的属性测试方法不需要任何新的东西;它们遵循与具有完整 get/set/delete 功能的属性相同的方法,除了对metadata属性的测试,它测试为只读属性,就像我们刚刚展示的对HasProducts.products的测试一样。

BaseProduct的许多测试方法也将遵循先前建立的模式——测试标准必需和可选文本行的好值和坏值变体,但也有一些需要新的或至少是变体的方法。

set_metadataremove_metadata方法的测试与以前的测试有足够的不同,值得更仔细地检查。为了测试新的元数据键/值项的添加,有必要跟踪一个预期值,以便可以执行相同的键和值的添加。测试方法中通过创建一个空字典(expected = {})来实现这一点,在调用测试对象的set_metadata方法的迭代中对其进行修改。随着每次迭代的进行,预期值相应地被改变,并与实际值进行比较:

def testset_metadata(self):
 # Tests the set_metadata method of the BaseProduct class
  test_object = BaseProductDerived('name', 'summary', True, True)
  expected = {}
 # - Test all permutations of "good" argument-values:
  for key in GoodStandardRequiredTextLines:
      value = '%s value'
      expected[key] = value
      test_object.set_metadata(key, value)
      self.assertEqual(test_object.metadata, expected)

对坏键和值集的测试使用一个好值,用于未被测试的任何项,并迭代坏值,确保适当的错误被引发:

    # - Test all permutations of each "bad" argument-value 
    #   set against "good" values for the other arguments:
    value = GoodStandardRequiredTextLines[0]
    for key in BadStandardRequiredTextLines:
        try:
            test_object.set_metadata(key, value)
            self.fail(
              'BaseProduct.set_metadata should not allow '
              '"%s" (%s) as a key, but it raised no error' 
                % (key, type(key).__name__)
            )
        except (TypeError,ValueError):
            pass
        except Exception as error:
           self.fail(
              'BaseProduct.set_metadata should raise TypeError '
              'or ValueError if passed  "%s" (%s) as a key, '
              'but %s was raised instead:\n    %s' % 
                (
                    key, type(key).__name__,
                    error.__class__.__name__, error
                )
            )
    key = GoodStandardRequiredTextLines[0]
    for value in BadStandardRequiredTextLines:
        try:
            test_object.set_metadata(key, value)
            self.fail(
              'BaseProduct.set_metadata should not allow '
              '"%s" (%s) as a value, but it raised no error' 
                % (value, type(value).__name__)
            )
        except (TypeError,ValueError):
            pass
        except Exception as error:
            self.fail(
                'BaseProduct.set_metadata should raise TypeError '
                'or ValueError if passed  "%s" (%s) as a value, '
                'but %s was raised instead:\n    %s' % 
                (
                    value, type(value).__name__,
                    error.__class__.__name__, error
                )
            )

BaseProductremove_metadata方法的测试方法使用了类似的策略来跟踪预期值,以便将测试结果与之进行比较。唯一的显著区别是,预期值(以及测试对象的metadata)需要在尝试删除任何metadata值之前进行填充:

def testremove_metadata(self):
    # Tests the remove_metadata method of the BaseProduct class
    # - First we need sopme meadata to remove
    test_object = BaseProductDerived('name', 'summary', True, True)
    expected = {
        'materials':'wood',
        'material-names':'cherry,oak',
        'finish':'gloss'
    }
    for key in expected:
        test_object.set_metadata(key, expected[key])
    self.assertEqual(test_object.metadata, expected)
    # - Test all permutations of "good" argument-values:
    keys = list(expected.keys())
    for key in keys:
        del expected[key]
        test_object.remove_metadata(key)
        self.assertEqual(test_object.metadata, expected)

BaseProduct的布尔值属性availablestore_available的 setter 方法的测试仍然使用了在其他地方使用的相同的好值和坏值迭代方法,只是它们需要一个不同的好值和坏值列表来进行测试:

GoodBooleanOrIntEquivalents = [
    True, False, 1, 0
]
BadBooleanOrIntEquivalents = [
    'true', '', (1,2), tuple()
]

同样,对_set_shipping_weight的测试方法需要另一组值列表,对_set_metadata的测试方法也是如此:

GoodWeights = [
    0, 1, 2, 0.0, 1.0, 2.0, 1.5
]
BadWeights = [
    -1, -1.0, object(), 'true', '', (1,2), tuple()
]
GoodMetadataDicts = [
    {},
    {'spam':'eggs'}
]
BadMetadataDicts = [
    -1, -1.0, object(), 'true', '', (1,2), tuple()
]

_set_shipping_weight的初始测试运行也促使对构成有效运输重量的假设进行审查。经过反思,而且在这一点上并不知道测量单位是什么,这些值很可能需要允许浮点值,特别是如果最终需要允许磅、千克甚至吨的运输,尽管这可能是不太可能的。

系统不应该对有效的运输重量设置任何限制,除了确保它是一个数字(因为它总是会是)并且不是负数。毕竟,产品可能包括像一张书法作品或一张纸上的插图这样的东西,这些东西重量都不会很重。另一方面,几十磅到一吨或更多的重量范围内的大理石半身像甚至大型金属雕塑也同样可能。

考虑到所有这些因素,_set_shipping_weight被修改为允许更广泛的值类型,并且还允许零值:

def _set_shipping_weight(self, value:(int,float)):
    if type(value) not in (int, float):
        raise TypeError(
            '%s.shipping_weight expects a non-negative numeric '
            'value, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, 
                value, type(value).__name__
            )
        )
    if value < 0:
        raise ValueError(
            '%s.shipping_weight expects a non-negative numeric '
            'value, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, 
                value, type(value).__name__
            )
        )
    self._shipping_weight = value

_set_description的测试还需要一个额外的新值列表来测试坏值;描述可以是任何字符串值,因为它目前是这样实现的,目前还没有适当捕捉坏值的坏值列表:

BadDescriptions = [
    # Values that aren't strings at all
    1, True, 0, False, object(), 
    # empty and whitespace-only strings
    '', '  ',
]

对 BaseOrder 进行单元测试

根据覆盖测试,对BaseOrder进行单元测试只关注测试customer属性以及与该属性交互的任何方法。这是因为BaseOrder继承自HasProducts。由于HasProducts的成员没有在BaseOrder中被覆盖,它们仍然属于HasProducts,并且已经进行了相应的测试:

BaseProductHasProducts的测试过程一样,测试BaseOrder需要创建一个一次性的派生类,用于测试方法成员。由于BaseOrder还期望在对象构造期间提供客户实例,因此我们还需要创建一个BaseCustomer派生类来提供这样的对象,并且需要良好和不良的客户值进行测试:

class BaseCustomerDerived(BaseCustomer):
    pass

GoodCustomers = [
    BaseCustomerDerived('customer name', Address('street-address', 'city'))
]
BadCustomers = [
    '', 'string', 1, 0, True, False, 1.0, 0.0, object(), [],
]

BaseCustomerDerived类不需要实现任何内容,因为BaseCustomer本身没有抽象成员,这引发了一个有趣的想法:如果它没有任何抽象成员,为什么我们一开始就将其定义为抽象类呢?这一决定背后的最初想法是,预计客户对象在系统的不同组件之间可以做的事情以及允许的数据访问可能会有很大的变化。

自我们最初的实现以来,这种期望没有改变,因此仍然有效。与此同时,可以创建一个BaseCustomer的实际实例,因为它没有定义抽象成员,这至少有可能在某个地方引入错误;如果我们相信BaseCustomer确实是抽象的,即使它没有提供抽象成员,创建它的具体实例也不应该被允许。至少可以通过在BaseCustomer__init__方法中添加几行代码来管理,尽管这样做可能会感觉有些尴尬:

def __init__(self, 
  name:(str,), billing_address:(Address,), 
  shipping_address:(Address,None)=None
):

   # ...

   # - Prevent a direct instantiation of this class - it's 
        #   intended to be abstract, even though it has no 
        #   explicitly-abstract members:
        if self.__class__ == BaseCustomer:
            raise NotImplementedError(
                'BaseCustomer is intended to be an abstract class, '
                'even though it does not have any explicitly '
                'abstract members, and should not be instantiated.'
            )

这本质上检查了正在创建的对象的类类型,并且如果正在创建的对象是抽象类本身的实例,则引发NotImplementedError。当我们为该类编写test__init__方法时,我们将不得不记住测试这一点,因此现在在测试方法中值得注意一下,以免以后遗失:

def test__init__(self):
    # Tests the __init__ method of the BaseCustomer class
    # - Test to make sure that BaseCustomer can't be 
    #   instantiated on its own!
    # - Test all permutations of "good" argument-values:
    # - Test all permutations of each "bad" argument-value 
    #   set against "good" values for the other arguments:
    self.fail('test__init__ is not yet implemented')

除此之外,创建BaseCustomerDerived类和GoodCustomersBadCustomers值列表以进行测试,允许所有testBaseOrder测试用例类的测试结构遵循到目前为止一直在使用的通常模式。

对 BaseCustomer 进行单元测试

BaseCustomer的所有属性 getter、setter 和 deleter 方法测试都遵循典型的模式,尽管通常最好在每个测试中创建单独的实例来处理test_object。否则,很快就会导致一个测试对共同对象进行更改,从而使其他测试失败,并且为每个测试创建单独的测试对象可以很好地解决这个问题:

test_object = BaseCustomer(
    'customer name', Address('street-address', 'city')
)

__init__的测试需要明确测试是否可以创建BaseCustomer对象,正如前面所述,这仍然是以前测试用例类中建立的测试结构的典型代表:

def test__init__(self):
# Tests the __init__ method of the BaseCustomer class
# - BaseCustomer is an abstract class, but has no abstract 
#   members, so this was set up to keep it from being 
#   accidentally used in an inappropriate fashion
    try:
       test_object = BaseCustomer(
       'customer name', Address('street-address', 'city')
       )
       self.fail(
          'BaseCustomer is expected to raise '
          'NotImplementedError if instantiated directly, '
                'but did not do so'
       )
     except NotImplementedError:
            pass

测试方法的其余部分符合以前测试的预期,对一组相关的良好值进行迭代,并断言它们在实例化时按预期传递到属性中:

# - Test all permutations of "good" argument-values:
    for name in GoodStandardRequiredTextLines:
       for billing_address in GoodAddresses:
          # - Testing without a shipping-address first
            test_object = BaseCustomerDerived(
                name, billing_address
            )
            self.assertEqual(test_object.name, name)
            self.assertEqual(
                test_object.billing_address, 
                billing_address
             )
            for shipping_address in GoodAddresses:
               test_object = BaseCustomerDerived(
                  name, billing_address, 
                   shipping_address
             )
             self.assertEqual(
                test_object.shipping_address, 
                shipping_address
             )

对 BaseArtisan 进行单元测试

到目前为止,我们已经建立了应该用于所有针对BaseArtisan的测试的模式:

  • 它是一个抽象类,因此我们需要为测试目的创建一个派生类(BaseArtisanDerived

  • 所有的属性 getter、setter 和 deleter 方法都遵循已经建立的模式之一:

  • 所有的 getter 和 deleter 方法测试都是标准的

  • address几乎是对BaseCustomer中的账单和送货地址属性的测试的直接复制,并且使用相同的GoodAddresses/BadAddresses值列表

  • company_name是一个标准的可选文本行测试,就像我们已经测试过的许多其他属性一样

  • contact_emailwebsite的 setter 方法也遵循标准模式,尽管它们需要新的良好和不良值列表进行测试

  • contact_name是一个标准的必需文本行属性,并且像所有其他这样的属性一样进行测试

以下演示了良好和不良值列表的示例:

GoodEmails = [
    'someone@somewhere.com',
    'brian.allbee+hosewp@gmail.com',
]
BadEmails = [
    '', 'string', -1, -1.0, object(), 'true', '', (1,2), tuple()
]
GoodURLs = [
    'http://www.google.com',
    'https://www.google.com',
]
BadURLs = [
    '', 'string', -1, -1.0, object(), 'true', '', (1,2), tuple()
]

然而,对BaseArtisan的测试揭示了在__init__方法中没有提供website参数,也没有在构造对象期间支持传递website,因此相应地进行了修改:

def __init__(self, 
    contact_name:str, contact_email:str, 
    address:Address, company_name:str=None, 
    website:(str,)=None, 
    **products
    ):

    # ...

    # - Call parent initializers if needed
    HasProducts.__init__(self, *products)
    # - Set default instance property-values using _del_... methods
    self._del_address()
    self._del_company_name()
    self._del_contact_email()
    self._del_contact_name()
    self._del_website()
    # - Set instance property-values from arguments using 
    #   _set_... methods
    self._set_contact_name(contact_name)
    self._set_contact_email(contact_email)
    self._set_address(address)
    if company_name:
        self._set_company_name(company_name)
    if website:
        self._set_website(website)

最后,这样就完成了系统的第一个模块的 118 个测试:

到目前为止已经建立的单元测试模式

对系统中第一个模块的单元测试进行了大量探索,这种探索已经建立了一些模式,这些模式将经常出现在编写的其他系统代码的单元测试中,因此除非它们有重大的新方面,否则从这一点开始它们将不会被重新审查。

这些模式如下:

  • 迭代好和坏的值列表,这些值对于正在测试的成员是有意义的:

  • 标准可选文本行值

  • 标准必需的文本行值

  • 布尔值(及其数值等价物)

  • 元数据值

  • 非负数值(例如重量值)

  • 验证属性方法关联——到目前为止,在每种情况下都是 getter 方法,以及在预期的地方是 setter 和 deleter 方法

  • 验证 getter 方法是否检索其底层存储属性值

  • 验证 deleter 方法是否按预期重置其底层存储属性值

  • 验证 setter 方法是否按预期强制执行类型和值检查

  • 验证初始化方法(__init__)是否按预期调用所有的 deleter 和 setter 方法

分发和安装考虑因素

默认的setup.py,添加了hms_core的包名称并删除了注释,非常基本,但仍然提供了构建可部署的 Python 包所需的一切hms_core代码库。它还提供了执行为包创建的所有单元测试的能力,给定它们所在的路径,并且能够找到已经使用的单元测试扩展:

#!/usr/bin/env python

# - Provide an import-path for the unit-testing standards we're using:
import sys
sys.path.append('../standards')

# - Standard setup.py import and structure
from setuptools import setup

# The actual setup function call:
setup(
    name='HMS-Core',
    version='0.1.dev0',
    author='Brian D. Allbee',
    description='',
    package_dir={
        '':'src',
    },
    packages=[
        'hms_core',
    ],
    test_suite='tests.test_hms_core',
)

执行以下操作:

python setup.py test

这将执行项目的tests/test_hms_core目录中的整个测试套件:

执行以下操作:

python setup.py sdist

这将创建包的源分发,然后可以使用以下命令安装:

pip install HMS-Core-0.1.dev0.tar.gz

这可以在包文件所在的目录的终端会话中完成。

此时,setup.py构建过程将引发一些错误,但这些错误都不会阻止包的构建或安装:

  • 警告:sdist:未找到标准文件:应该有 README、README.rst、README.txt 之一

  • 警告:检查:缺少必需的元数据:url

  • 警告:检查:缺少元数据:如果提供了'author',则必须同时提供'author_email'

安装后,hms_core包可以像任何其他 Python 包一样使用:

在这个迭代中,最初的三个故事集中在hms_core和其他组件项目库之间的构建和部署过程如何交互,目前尚未解决:

  • 作为一名工匠,我需要业务对象库与我的应用程序一起安装,以便应用程序能够按需工作,而无需我安装其依赖组件

  • 作为中央办公室用户,我需要业务对象库与我的应用程序一起安装,以便应用程序能够按需工作,而无需我安装其依赖组件

  • 作为系统管理员,我需要业务对象库与工匠网关服务一起安装,以便它能够按需工作,而无需我安装其依赖组件

在这一点上,因为我们没有其他库可以进行测试,实际上不能对其进行执行——我们将不得不等待至少一个可安装软件包的实际实现,然后才能处理这些问题,因此它们将被放回待办事项,并在实际可以处理时再次处理。

质量保证和验收

由于该库提供的功能是基础性的,旨在被其他库使用,因此在正式的质量保证(QA)过程中,实际上没有太多公共功能可以进行有意义的测试。如果这个迭代中涉及到正式的 QA 过程,最多只能执行单元测试套件,并验证这些测试是否能够正常执行而没有失败或错误。

同样,由于迭代中涉及的大部分故事都是为了开发人员的利益,因此几乎不需要外部验收;库中各种类存在并按预期运行应该足以接受这些故事。

  • 作为开发人员,我需要系统中表示地址的通用定义和功能结构,以便我可以将它们纳入需要它们的系统部分。

  • 作为开发人员,我需要系统中表示工匠的通用定义和功能结构,以便我可以将它们纳入需要它们的系统部分。

  • 作为开发人员,我需要系统中表示客户的通用定义和功能结构,以便我可以将它们纳入需要它们的系统部分。

  • 作为开发人员,我需要系统中表示订单的通用定义和功能结构,以便我可以将它们纳入需要它们的系统部分。

  • 作为开发人员,我需要系统中表示产品的通用定义和功能结构,以便我可以将它们纳入需要它们的系统部分。

目前,安装方面的故事有点奇怪——它们特别关注各种最终用户的单个可安装软件包,这目前是这样,但随着开发的进展,其他库中将会有更多功能。就目前情况而言,可以说这些故事满足了所有陈述的要求,只因为只有一个组件安装:

  • 作为 Artisan,我需要将业务对象库与我的应用程序一起安装,以便应用程序能够按需工作,而无需我安装其依赖组件。

  • 作为中央办公室用户,我需要将业务对象库与我的应用程序一起安装,以便应用程序能够按需工作,而无需我安装其依赖组件。

  • 作为系统管理员,我需要将业务对象库与 Artisan Gateway 服务一起安装,以便它能够按需工作,而无需我安装其依赖组件。

也可以说,尽管这些故事在此时此刻是完整的,但它们将不得不在尚未构建的各种应用程序和服务组件的开发周期中重复。在这些组件有自己的代码、构建和包之前,就没有需要处理的依赖关系。

操作/使用、维护和停用考虑

考虑到这个软件包的简单性,以及它没有外部依赖,对于软件包的操作和使用,或者停用它,都没有明显的考虑或潜在的关注点。在后一种情况下,停用将只是卸载软件包(pip uninstall HMS-Core)。维护考虑也将同样限制在更新软件包本身,只需通过重新运行原始安装过程并使用新的软件包文件来管理。

总结

这次迭代已经定义了代表系统重要功能方面的基本业务对象,这些对象代表了最终系统的数据元素。然而,它们都只提供了基本的结构和一些关于构成这些元素有效结构的业务规则,除此之外,还没有存储这些元素、检索它们或与它们进行交互的机制,除了直接在代码中通过它们的属性。

下一次迭代章节将深入研究系统应用程序和服务层所需的存储和状态数据持久性。

第十章:考虑业务对象数据持久性

大多数程序和系统都需要存储和检索数据以进行操作。毕竟,将数据嵌入代码本身是不切实际的。涉及的数据存储形式可以根据底层存储机制、应用程序或服务的特定需求,甚至名义上的非技术约束(如不需要用户安装其他软件)而大相径庭,但无论这些因素加起来是什么,根本需求始终是一样的。

hms_sys的各个组件项目/子系统也不例外:

  • Artisan Application需要允许Artisan用户管理Artisan正在创建和销售的products,并且至少需要管理部分自己的业务实体数据

  • Artisan Gateway服务可能至少需要为artisansproductsorders以及相关的CustomerAddress对象分阶段数据,因为这些对象包含的数据会通过各种流程移动

  • Central Office Application需要能够管理ArtisanProduct的部分数据,并且可能需要读取订单数据,即使只是出于故障排除目的

到目前为止,还没有具体的要求说明这些数据将如何持久化,甚至在哪里,尽管Artisan Application可能需要在本地保留数据并将其传播到Artisan Gateway或通过Central Office Application访问,如下图所示:

本次迭代将通过对hms_sys中各个组件项目的数据持久性机制的需求、实施和测试进行分析,从而开始一些基本的分析。然而,目前我们甚至不清楚后端数据存储是什么样子,因此我们无法编写任何有用的指导如何实现数据持久性的故事。显然,这需要更多的调查工作才能在规划和执行本次迭代之前进行。

本章将研究以下主题:

  • 迭代(敏捷)过程通常如何处理没有足够信息来执行的故事

  • 一般有哪些数据存储和持久性选项

  • 在决定各种hms_sys组件项目如何处理数据访问之前,应该检查哪些数据访问策略

迭代是(在某种程度上)灵活的

在许多敏捷方法中,有特定的工件和/或流程旨在处理这种迭代开始的情况——即存在某种功能的需求,即使只是暗示性的,但实际上没有足够的信息来对这种需求进行任何开发进展。甚至可能已经有一些看似完整的故事,但缺少了一些开发所需的细节。在这种情况下,这些故事可能类似于以下内容:

  • 作为Artisan,我需要我的Product数据被本地存储,这样我就可以在不必担心连接到可能无法立即访问的外部系统的情况下使用它。

  • 作为产品经理/批准人,我需要能够访问任何/所有artisansProduct信息,以便我可以在网店中管理这些产品的可用性

  • 作为系统管理员,我需要Artisan GatewayProduct和相关数据与主Web Store应用程序分开存储,以便在发布到公共站点之前可以安全地分阶段处理

所有这些故事看起来可能都是完整的,因为它们定义了每个用户的需求,但它们缺乏关于这些功能应如何运作的任何信息。

进入 Spike。

尖峰,起源于 XP 方法论,并已被其他几种敏捷方法论(正式或非正式地)采纳,本质上是为了研究并返回其他故事可用的计划细节的故事。理想情况下,需要围绕它们生成尖峰的故事将在进入迭代之前被识别出来 - 如果这种情况没有发生,信息不足的故事将是无法工作的,并且不可避免地会发生某种洗牌,以推迟不完整的故事直到它们的尖峰完成,或者将尖峰及其结果纳入修订后的迭代计划中。前者往往更有可能发生,因为没有来自尖峰的信息,估算目标故事将是非常困难的,甚至可能是不可能的。与我们之前提到的原始故事相关的尖峰故事可能会被写成这样:

  • 作为开发人员,我需要知道 Artisan 应用程序数据的存储和检索方式,以便我可以为这些过程编写代码

  • 作为开发人员,我需要知道中央办公应用程序数据的存储和检索方式,以便我可以为这些过程编写代码

  • 作为开发人员,我需要知道 Artisan Gateway 数据的存储和检索方式,以便我可以为这些过程编写代码

为了解决这些问题并完成本次迭代的故事,了解可用的选项将是有帮助的。一旦这些选项被探索,它们可以在应用程序和系统的服务层的背景下进行权衡,并可以做出一些最终的实施方法决策,以及编写一些最终的故事来应对。

数据存储选项

所有将受到认真考虑的选项都具有一些共同的特性:

  • 他们将允许数据脱机存储,这样应用程序或服务程序不需要持续运行以确保相关数据不会丢失

  • 它们必须允许应用程序和服务执行至少四个标准 CRUD 操作中的三个:

  • 创建:允许存储新对象的数据。

  • 读取:允许访问现有对象的数据,一次一个,一次全部,可能还带有一些过滤/搜索功能。

  • 更新:允许在需要时更改现有数据。

  • 删除:允许(也许)删除不再相关的对象的数据。至少,标记这样的数据,以便它不会普遍可用也可以。

它们还应该根据 ACID 特性进行检查和评估,尽管这些属性中并非所有都可能在hms_sys的数据需求背景下是必不可少的。然而,没有一个是不可实现的:

  • 原子性:数据交易应该是全有或全无的,因此如果数据写入的一部分失败,正在写入的整个数据集也应该失败,使数据处于稳定状态

  • 一致性:数据交易应始终导致整个数据集中的有效数据状态,遵守和遵守任何存储系统规则(应用级规则是应用程序的责任)

  • 隔离性:数据交易应始终导致与它们的组成更改按相同顺序逐个执行时会发生的最终状态相同

  • 耐久性:一旦提交,数据交易应以防止由系统崩溃、断电等原因造成的损失的方式存储

关系数据库

关系数据库管理系统RDBMSes)是可用于应用程序的更成熟的数据存储方法之一,其选项已经普遍使用了几十年。它们通常将数据存储为表中的单独记录(有时称为),这些表(或关系)定义了所有成员记录的字段名称()和类型。表通常定义了一个主键字段,为表中的每条记录提供唯一标识符。一个简单的定义用户记录的表的示例可能如下所示:

表中的每条记录都是一致的数据结构,例如在前面的例子中,所有用户都会有user_idfirst_namelast_nameemail_address的值,尽管除user_id之外的字段的值可能为空或为NULL。任何表中的数据都可以通过查询访问或组装,而无需更改表本身,并且可以在查询中连接表,以便在另一个表中关联拥有的记录,例如订单。

这种结构通常被称为模式,它既定义了结构,又强制执行数据约束,如值类型和大小。

关系数据库最常见的查询语言是结构化查询语言SQL)—或者至少是它的某个变体。SQL 是一种 ANSI 标准,但有许多可用的变体。可能还有其他的,但 SQL 几乎肯定是最受欢迎的选择,并且非常成熟和稳定。

SQL 本身就是一个复杂的话题,即使不考虑它在数据库引擎之间的变化,也足以值得一本专门的书。随着hms_sys迭代的进行,我们将探讨一些 SQL,并解释发生了什么。

优点和缺点

关系数据库数据存储的一个更重要的优势是它能够在单个查询请求中检索相关记录,例如前面提到的用户/订单结构。大多数关系数据库系统还允许在单个请求中进行多个查询,并将每个查询的记录集作为单个结果集返回。例如,可以查询相同的用户和订单表结构,以返回单个用户及该用户的所有订单,这在应用程序对象结构中具有一些优势,其中一个对象类型具有一个或多个与其关联的对象集合。

对于大多数关系数据库引擎来说,另一个可能重要的优势是它们对事务的支持——允许一组潜在复杂的数据更改或插入在任何单个数据操作失败的情况下作为一个整体回滚。这几乎可以保证在任何 SQL RDBMS 中都可以使用,并且在处理金融系统时是非常重要的优势。对于处理资金流动的系统,事务支持可能是一个功能性要求——如果不是,那么很可能值得问一下为什么不是。支持跨多个操作的事务是完全 ACID 兼容性的一个关键方面——如果没有,原子性、一致性和(在某种程度上)隔离标准将受到怀疑。幸运的是,几乎任何值得被称为关系数据库系统的系统都将提供足够满足任何可能出现的需求的事务支持。

许多关系数据库系统还支持创建视图和存储过程/函数,可以使数据访问更快速、更稳定。视图在实际上是预定义的查询,通常跨越多个表,并且通常用于检索与它们绑定的表中的特定数据子集。存储过程和函数可以被视为应用程序函数的近似等价物,接受某些输入,执行一些任务,并可能返回由执行这些任务生成的数据。至少,存储过程可以用来代替编写查询,这具有一些性能和安全性的好处。

大多数关系数据库中表的固有模式可能既是优势也是缺点。由于该模式强制执行数据约束,因此表中存在不良数据的可能性较小。预期为字符串值或整数值的字段将始终是字符串或整数值,因为不可能将字符串字段设置为非字符串值。这些约束确保数据类型的完整性。然而,这种权衡是,值类型(有时甚至是值本身)在进入或离开数据存储时可能需要进行检查和/或转换。

如果关系数据库有一个缺点,那可能是包含数据的表的结构是固定的,因此对这些表进行更改需要更多的时间和精力,而这些更改可能会影响访问它们的代码。例如,在数据库中更改字段名称很可能会破坏引用该字段名称的应用功能。大多数关系数据库系统还需要单独的软件安装和全天候运行的服务器硬件,就像相关的应用程序一样。这可能对任何特定项目是一个问题,也可能不是,但特别是如果该服务器位于他人的基础设施中,这可能是一个成本考虑因素。

扩展关系数据库管理系统可能仅限于为服务器本身增加更多的性能——改进硬件规格、增加内存或将数据库移动到新的更强大的服务器。前述的一些数据库引擎还有额外的软件包,可以提供多服务器规模,例如横向扩展到多个仍然像单个数据库服务器一样的服务器。

MySQL/MariaDB

MySQL 是一种流行的关系数据库管理系统,始于 1990 年代中期的一个开源项目。MariaDB 是 MySQL 的一个由社区维护的分支,旨在作为 MySQL 的一个可替换的替代品,并且在 MySQL(现在由 Oracle 拥有)停止以开源许可发布时仍然作为一个开源选项可用。在撰写本书时,MySQL 和 MariaDB 是可以互换的。

两者使用相同的 SQL 变体,与标准 SQL 的语法差异通常非常简单。MySQL 是——而 MariaDB 被认为是——更适用于读取/检索数据而不是写入数据,但对于许多应用程序来说,这些优化可能不会明显。

MySQL 和 MariaDB 可以通过使用集群化和/或复制软件附加到基本安装来进行横向扩展,以满足高可用性或负载需求,尽管为了真正有效,需要额外的服务器(真实或虚拟)。

有几个 Python 库可用于连接和与 MySQL 交互,由于 MariaDB 旨在能够直接替代 MySQL,因此预计这些相同的库可以在不修改的情况下用于 MariaDB 访问。

MS-SQL

微软的 SQL Server 是一种专有的基于 SQL 的数据库管理系统,使用自己的标准 SQL 变体(T-SQL——就像 MySQL 的变体一样,差异通常是微不足道的,至少对于简单到稍微复杂的需求来说)。

MS-SQL 也具有用于高可用性和负载场景的集群和复制选项,需要离散服务器以最大化水平扩展的效果。

至少有两种 Python 选项可用于连接和处理 MS-SQL 数据库:

  • pymssql:这专门利用了 MS-SQL 使用的表格数据流TDS)协议,并允许更直接地连接到后端引擎

  • pyodbc:这通过开放数据库连接ODBC)协议提供数据库连接,微软在 2018 年中已经对其表示信心

PostgresQL

PostgreSQL 是另一个开源数据库选项,是一种设计重点在于符合标准的对象关系数据库系统。作为 ORDBMS,它允许以更面向对象的方式定义数据结构,具有类似于从其他表/类继承的类的功能。它仍然使用 SQL——它自己的变体,但对于大多数开发目的来说,差异基本可以忽略,并且有几种 Python 选项可用于连接和处理数据库。它还具有复制和集群支持,与先前选项的注意事项相同。

NoSQL 数据库

在撰写本文时,有数十种 NoSQL 数据库选项可用,既作为独立/本地服务安装,也作为云数据库选项。它们设计的主要驱动因素包括以下重点:

  • 支持大量用户:数以万计的并发用户,也许是数百万,并且应尽可能小地影响其性能

  • 高可用性和可靠性:即使一个或多个数据库节点完全离线,也能与数据进行交互

  • 支持高度流动的数据结构:允许结构化数据不受严格的数据模式约束,甚至可以跨同一数据存储集合中的记录

从开发的角度来看,这个列表中的最后一点可能是最重要的,允许根据需要定义几乎任意的数据结构。

如果在关系型数据库管理系统(RDBMS)中,表的概念是一种存储模型,那么在 NoSQL 数据库连续体中有许多替代存储模型:

  • 文档存储:每个记录等价物都是包含创建时使用的任何数据结构的文档。文档通常是 JSON 数据结构,因此允许在不同数据类型之间进行一些区分——字符串、数字和布尔作为简单值,嵌套列表/数组和对象用于更复杂的数据结构,并且还允许使用正式的null值。

  • 键/值存储:每个记录等价物只是一个值,可以是任何类型,并且由单个唯一键标识。这种方法可以被认为是等同于单个 Python dict结构的数据库。

  • 宽列存储:每个记录可以被认为属于具有非常大(无限?)数量列的 RDBMS 表,也许有主键,也许没有。

还有一些变体感觉像是结合了这些基本模型的方面。例如,在 Amazon 的 DynamoDB 中创建数据存储,首先要定义一个表,需要定义一个键字段,并且还允许定义一个辅助键字段。一旦创建了这些,这些表的内容就像一个文档存储一样。因此,最终的结果就像一个键/文档存储(每个键指向一个文档的键/值存储)。

NoSQL 数据库通常是非关系型的,尽管也有例外。从开发的角度来看,这意味着在处理存储和检索来自 NoSQL 数据存储的应用程序数据时,至少需要考虑三种方法之一:

  • 永远不要使用与其他数据相关的数据——确保每个记录都包含作为单个实体所需的一切。这里的折衷是,很难,甚至不可能解决记录(或与记录关联的对象)被两个或更多其他记录/对象共享的情况。一个例子可能是多个用户都是成员的用户组。

  • 处理代码中与记录之间的关系。使用刚提到的相同的用户/组概念,这可能涉及到一个Group对象,读取所有相关的User记录,并在实例化过程中使用来自该数据的User对象填充users属性。可能会有一些并发更改相互干扰的风险,但不会比在基于关系型数据库的系统中进行相同类型的过程的风险更大。这种方法还意味着数据将按对象类型进行组织——一个独立的User对象数据集合和一个独立的Group对象数据集合,但任何允许区分不同对象类型的机制都可以工作。

  • 选择一个提供某种关系支持的后端数据存储引擎。

NoSQL 数据库也不太可能支持事务,尽管再次有提供完全符合 ACID 的事务能力的选项,处理数据存储级别的事务要求的标准/选项与前面提到的处理关系能力的标准/选项非常相似。即使没有任何事务支持的数据库仍然会对单个记录进行 ACID 兼容——在这个复杂程度上,要求兼容的是记录是否成功存储。

优势和缺点

鉴于大多数 NoSQL 选项背后的高可用性和并发用户关注,他们比关系型数据库管理系统更适合于可用性和可扩展性重要的应用程序,这一点应该并不奇怪。这些属性在大数据应用程序和云中更为重要,正如主要云提供商都在这一领域提供自己的产品,并为一些知名的 NoSQL 选项提供起点所证明的那样:

  • 亚马逊(AWS):

  • DynamoDB

  • 谷歌:

  • Bigtable(用于大数据需求)

  • 数据存储

  • 微软(Azure):

  • Cosmos DB(前身为 DocumentDB)

  • Azure 表存储

在开发过程中,更或多或少地任意定义数据结构的能力也可以是一个重要的优势,因为它消除了定义数据库模式和表的需要。潜在的折衷是,由于数据结构可以同样任意地改变,使用它们的代码必须被编写为容忍这些结构的变化,或者可能必须计划一些有意识的努力来应用这些变化到现有数据项,而不会破坏系统和它们的使用。

例如,考虑之前提到的User类 - 如果需要向类添加password_hash属性,以提供身份验证/授权支持,实例化代码可能需要考虑它,并且任何现有的用户对象记录可能不会有该字段。在代码方面,这可能并不是什么大问题 - 在初始化期间将password_hash作为可选参数处理将允许创建对象,并且如果未设置它,则将其存储为 null 值将处理数据存储方面,但需要计划、设计和实施某种机制以提示用户提供密码以存储真实值。如果在基于 RDBMS 的系统中进行类似更改,将需要发生相同类型的过程,但很可能会有已建立的流程来更改数据库模式,并且这些流程可能包括修改模式和确保所有记录具有已知起始值。

考虑到可用的选项数量,也不足为奇的是它们在执行类似任务时存在差异(有时是显著的)。也就是说,从数据中检索记录,只需提供要检索的项目的唯一标识符(id_value),使用不同的库和基于数据存储引擎的语法/结构:

  • 在 MongoDB 中(使用connection对象):

  • connection.find_one({'unique_id':'id_value'})

  • 在 Redis 中(使用redis connection):

  • connection.get('id_value')

  • 在 Cassandra 中(使用query值和criteria列表,针对 Cassandrasession对象执行):

  • session.execute(query, criteria)

每个不同的引擎可能会有其自己独特的执行相同任务的方法,尽管可能会出现一些常见的名称 - 毕竟,对于函数或方法名称,如 get 或 find,只有那么多的替代方案是有意义的。如果系统需要能够与多个不同的数据存储后端引擎一起工作,这些都是设计和实施通用(可能是抽象的)数据存储适配器的良好候选者。

由于关系和事务支持因引擎而异,这种不一致性也可能是 NoSQL 数据存储的一个缺点,尽管如果它们缺乏,至少有一些选项可以追求。

MongoDB

MongoDB 是一个免费的开源 NoSQL 文档存储引擎 - 也就是说,它将整个数据结构存储为单独的文档,如果不是 JSON,也非常类似于 JSON。在 Python 中发送到和从MongoDB数据库检索的数据使用 Python 本机数据类型(dictlist集合,任何简单类型,如strint,可能还有其他标准类型,如datetime对象)。

MongoDB 被设计为可用作分布式数据库,支持高可用性、水平扩展和地理分布。

像大多数 NoSQL 数据存储解决方案一样,MongoDB 是无模式的,允许 MongoDB 集合中的文档(大致相当于 RDBMS 中的表)具有完全不同的结构。

其他 NoSQL 选项

如前所述,有数十种 NoSQL 数据库选项可供选择。以下是三种具有 Python 驱动程序/支持的本地安装的 NoSQL 数据库的更受欢迎的选项:

  • Redis:键/值存储引擎

  • Cassandra:宽列存储引擎

  • Neo4j:图数据库

其他数据存储选项

另一个选项——对于大量数据或在重要并发用户负载下可能效果不佳的选项——是将应用程序数据简单地存储为本地机器上的一对多文件。随着简单结构化数据表示格式(如 JSON)的出现,这可能比乍一看更好,至少对于某些需求来说:特别是 JSON,具有基本值类型支持和表示任意复杂或大型数据结构的能力,是一个合理的存储格式。

最大的障碍是确保数据访问至少具有一定程度的 ACID 兼容性,尽管与 NoSQL 数据库一样,如果所有事务都是单个记录,仍然可以依靠 ACID 兼容性,原因是事务的简单性。

在使用文件存储应用程序数据时必须解决的另一个重要问题是语言或基础操作系统如何处理文件锁定。如果其中一个允许在写入过程中或不完整的情况下读取打开的文件,那么读取不完整数据文件的读取就会误读可用数据,然后将错误数据提交到文件中,可能导致至少数据丢失,甚至可能在过程中破坏整个数据存储。

显然那将是不好的。

访问速度也可能是一个问题,因为文件访问比内存中存储的数据访问速度要慢。

也就是说,有一些策略可以应用于使本地基于文件的数据存储免受这种失败的影响,只要数据只从代码中的单一来源访问。解决潜在的访问速度问题也可以在同一过程中完成,过程如下:

  • 使用数据的程序开始:

  • 从持久文件系统数据存储中将数据读入内存

  • 使用程序,并发生数据访问:

  • 从内存中读取数据的副本,并传递给用户

  • 以某种方式更改数据:

  • 注意到更改,并在返回控制权给用户之前将更改提交到文件系统数据存储

  • 关闭程序:

  • 在终止之前,将检查所有数据以确保没有仍在等待的更改

  • 如果有变化,请等待它们完成

  • 如果需要,将所有数据重新写入文件系统数据存储

选择数据存储选项

查看hms_sys的逻辑架构,并允许Artisan Application使用原始图表中不存在的本地数据存储,开发需要关注三个数据库:

Web-Store Database连接到Web-Store Application,因此无法进行修改。当前的期望是对该数据库中的数据进行修改将通过Web-Store Application提供的 API 调用来处理。因此,此时可以搁置对该数据库的数据访问。

另一方面,artisan Database根本不存在,将必须在开发hms_sys的过程中创建。可以安全地假设,鉴于第一次迭代中与安装相关的 artisan 级别的故事,最好尽可能减少他们需要执行的软件安装数量。这反过来又表明,在Artisan Application级别,本地文件系统数据存储可能是首选选项。这允许以下操作:

  • 数据存储在安装或应用程序的初始设置期间在本地生成

  • 工匠可以在本地管理他们的数据,即使他们离线

  • Artisan无需进行任何额外的软件安装来管理数据存储

由于预计Artisan 应用程序将是本地桌面应用程序,这很好地符合之前提到的一组过程,以使基于文件的数据存储安全稳定。如果Artisan安装了多个Artisan 应用程序(例如在多台机器上各安装一个),则存在一些数据冲突的风险,但实际上任何本地数据存储选项都会存在这种风险 - 除非将数据存储移到共同的在线数据库,否则真的没有办法减轻这种特定的担忧,而这超出了目前hms_sys的开发范围。

关于集中数据和应用程序的想法将在以后更详细地进行检查。目前,Artisan 级别的所有内容都将与 Artisan 应用程序本地驻留。

hms_sys 数据库目前也不存在。不像artisan 数据库,它旨在允许多个并发用户 - 任何数量的中央办公室用户可能在任何给定时间审查或管理产品,因为工匠正在提交产品信息进行审查,并且在这些活动进行时,也可以设置相关工匠的订单从网络商店中中继或拉出。综合起来,这足以排除本地文件存储方法 - 它可能仍然可以做到,并且在当前使用水平下甚至可能是可行的,但如果使用/负载增加太多,可能会迅速遇到扩展问题。

考虑到,即使我们不知道将使用什么后端引擎,知道它不会是Artisan 应用程序使用的相同存储机制,就确认了之前提到的想法,即我们最好定义一个通用的数据访问方法集,围绕该结构生成某种抽象,并在每个应用程序或服务对象级别定义具体实现。采取这种方法的优势实际上归结为相同的面向对象设计原则OODP)的变体:多态。

多态(和面向接口编程)

多态,简单来说,是对象在代码中可以互换而不会破坏任何东西的能力。为了实现这一点,这些对象必须在整个范围内呈现公共接口成员 - 相同的可访问属性和方法。理想情况下,这些公共接口成员也应该是唯一的接口成员,否则有破坏这些对象互换性的风险。在基于类的结构中,通常最好将该接口定义为一个单独的抽象 - 在 Python 中是一个 ABC,有或没有具体成员。考虑以下一组用于连接和查询各种关系数据库后端的类:

其中:

  • BaseDatabaseConnector是一个抽象类,要求所有派生类实现一个查询方法,并提供hostdatabaseuserpassword属性,这些属性将用于实际连接到给定的数据库

  • 具体类MySQLConnectorMSSQLConnectorODBCConnector分别实现了所需的query方法,允许实例实际执行针对连接到的数据库的查询

只要连接属性(host,…,password)存储在配置文件中(或者实际代码之外的任何地方),并且有一种方法来指定在运行时定义哪种连接器类型,甚至可能在执行期间切换,那么允许在运行时定义这些不同连接类型并不难。

这种可互换性反过来又允许编写代码,而不需要了解进程如何工作,只需要知道应该如何调用以及期望返回什么结果。这是编程到接口而不是到实现的实际示例,这在第五章《hms_sys 系统项目》中提到,以及封装变化的概念。这两者经常同时出现,就像在这种情况下一样。

以这种方式替换对象还有另一个好处,可以称之为未来证明代码库。如果在将来的某个时候,使用先前显示的数据连接器的代码突然需要能够连接到并使用尚未可用的数据库引擎,那么使其可用的工作量将相对较小,前提是它使用了与已经存在的连接参数和类似的连接过程。例如,要创建一个PostgreSQLConnector(用于连接到PostgreSQL数据库),只需要创建这个类,从BaseDatabaseConnector派生,并实现所需的query方法。这仍然需要一些开发工作,但不像如果每个数据库连接过程都有自己独特的类那样需要的工作量那么大。

数据访问设计策略

在我们开始为这个迭代编写故事之前,我们需要进行的最后一点分析是确定对象数据访问的责任将在哪里。在脚本或其他纯过程化的上下文中,简单地连接到数据源,根据需要读取数据,根据需要修改数据,并将任何更改重新写出可能就足够了,但这只有在整个过程相对静态时才可行。

hms_sys这样的应用程序或服务中,数据使用非常像是随机访问的场景——可能会有常见的程序,甚至看起来很像简单脚本的逐步实现,但这些过程可能(并且将)以完全不可预测的方式启动。

这意味着我们需要具有易于调用和可重复的数据访问过程,而且需要付出最小的努力。考虑到我们已经知道至少会有两种不同的数据存储机制在起作用,如果我们能够设计这些过程,使得无论底层数据存储看起来如何,都可以使用完全相同的方法调用,那么未来的支持和开发也会变得更加容易——再次抽象出这些过程,让代码使用接口而不是实现。

一种可以实现这种抽象的选项是从数据源开始,使每个数据源都意识到正在进行的对象类型,并存储它需要能够为每个对象类型执行 CRUD 操作的信息。这在技术上是可行的实现,但会变得非常复杂,因为需要考虑和维护每种数据存储和业务对象类型的组合。即使初始类集仅限于三种数据存储变体(Artisan Application的文件系统数据存储,通用 RDBMS 数据存储和通用 NoSQL 数据存储),也有四种操作(CRUD)跨三种数据存储类型的四种业务对象,总共有 48 种排列组合(4×3×4)需要构建、测试和维护。每添加一个新的操作,比如说,能够搜索业务对象数据存储,以及每个新的需要持久化的业务对象类型和每种新的数据存储类型,都会使排列组合数量成倍增加——每增加一个,数量就增加到 75 个项目(5×3×5),这可能很容易失控。

如果我们退一步思考我们实际需要的所有这些组合,可能存在一种不同且更可管理的解决方案。对于每个需要持久化的业务对象,我们需要能够执行以下操作:

  1. 为新对象创建记录。

  2. 读取单个对象的记录,以某种方式标识,并返回该项的实例。

  3. 在对其进行更改后,更新单个对象的记录。

  4. 删除单个对象的记录。

  5. 根据某些条件匹配找到并返回零到多个对象。

能够标记对象处于特定状态——活动与非活动,以及已删除(实际上没有删除基础记录)可能也很有用。跟踪创建和/或更新日期/时间也是一种常见做法——这有时对于排序目的很有用,如果没有其他用途的话。

所有 CRUD 操作直接与对象类型本身相关——也就是说,我们需要能够创建、读取、更新、删除和查找Artisan对象,以便与它们一起使用。这些实例的各种对象属性可以根据需要在实例创建的上下文中检索和填充,作为实例创建过程的一部分创建,或根据需要与拥有实例或单独更新。考虑到这些从属操作,跟踪对象的记录是否需要创建或更新也可能很有用。最后,我们需要跟踪每个对象状态数据记录在数据存储中的唯一标识符。将所有这些放在一起,以下是BaseDataObject ABC 可能看起来像的:

这些属性都是具体的,在BaseDataObject级别内部实现:

  • oid是对象的唯一标识符,是一个UUID值,在数据访问期间将存储为字符串并转换。

  • createdmodified是 Python datetime对象,可能也需要在数据访问期间转换为字符串值表示。

  • is_active是一个标志,指示是否应将给定记录视为活动记录,这允许对记录的活动/非活动状态进行一些管理,从而对应该记录的对象进行管理。

  • is_deleted是一个类似的标志,指示记录/对象是否应被视为已删除,即使它实际上仍然存在于数据库中。

  • is_dirtyis_new是标志,用于跟踪对象的相应记录是否需要更新(因为它已更改)或创建(因为它是新的)。它们是本地属性,不会存储在数据库中。

使用 UUID 而不是数字序列需要更多的工作,但在网络应用程序和服务实现中具有一些安全优势——UUID 值不容易预测,并且有 16³² 个可能的值,使得对它们的自动化利用变得更加耗时。可能存在要求(或至少有一种愿望)永远不真正删除记录。在某些行业或者对于需要满足某些数据审计标准的上市公司来说,希望至少在一段时间内保留所有数据并不罕见。

BaseDataObject 定义了两个具体的和三个抽象的实例方法:

  • create(抽象和受保护的)将要求派生类实现一个过程,用于创建和写入相关数据库的状态数据记录。

  • matches(具体)将在被调用的实例的属性值与传递给它的条件的相应值匹配时返回一个布尔值。这将在 get 方法中实现基于条件的过滤中起到关键作用,这将很快讨论。

  • save(具体)将检查实例的 is_dirty 标志,调用实例的 update 方法并在其为 True 时退出,然后检查 is_new 标志,如果为 True 则调用实例的 create 方法。这样做的最终结果是,任何继承自 BaseDataObject 的对象都可以简单地被告知 save 自身,将采取适当的操作,即使它没有任何操作。

  • to_data_dict(抽象)将返回对象状态数据的 dict 表示,其中的值以可以写入状态数据记录所在的数据库的格式和类型为准。

  • update(抽象和受保护的)是 create 方法的更新实现对应物,用于更新对象的现有状态数据记录。

BaseDataObject 还定义了四个类方法,所有这些方法都是抽象的——因此,这些方法中的每一个都绑定到本身,而不是类的实例,并且必须由从 BaseDataObject 派生的其他类实现:

  • delete 对由提供的 *oids 标识的每条记录执行物理记录删除。

  • from_data_dict 返回一个填充有提供的 data_dict 中的状态数据的类的实例,这通常是从针对这些记录所在的数据库的查询中得到的。它是 to_data_dict 方法的对应物,我们已经描述过了。

  • get 是从数据库中检索状态数据的主要机制。它被定义为允许返回特定记录(*oids 参数列表)和过滤条件(在 **criteria 关键字参数中,这些参数预期将传递给每个对象的匹配条件),并将根据这些值返回一个未排序的对象实例列表。

  • sort 接受一个对象列表,并使用传递给 sort_by 的回调函数或方法对它们进行排序。

BaseDataObject 捕获了所有功能要求和常见属性,这些属性需要存在才能让业务对象类和实例负责其数据存储交互。暂时不考虑任何数据库引擎问题,定义一个数据持久性能力的业务对象类,比如 Artisan Application 中的 Artisan,变得非常简单——最终的具体 Artisan 类只需要继承自 BaseArtisanBaseDataObject,然后实现这些父类所需的九个抽象方法。

如果可以安全地假定任何给定的应用程序或服务实例将始终为每种业务对象类型使用相同的数据存储后端,那么这种方法就足够了。任何特定于引擎的需求或功能都可以简单地添加到每个最终的具体类中。但是,也可以将特定数据存储引擎(例如 MongoDB 和 MySQL)所需的任何属性收集到一个额外的抽象层中,然后让最终的具体对象从其中一个派生出来:

在这种情况下,最终的Artisan类可以从MongoDataObjectMySQLDataObject中派生出来,并且可以强制执行执行针对这些特定后端引擎的数据访问方法所需的任何数据。这些中间层的 ABC 也可能为每种引擎类型提供一些有用的方法,例如,使用create_sql类属性中的模板 SQL,并用to_data_dict()结果中的实例数据值填充它,可以创建用于 MySQL 调用创建实例的最终 SQL。这种方法将保持任何给定业务对象类所需的大部分数据访问信息在该类中,并与业务对象本身相关联,这看起来不像一个坏主意,尽管如果需要支持很多组合,它有可能变得复杂。它还将保持向所有数据对象添加新功能所需的工作量(在类树的BaseDataObject级别)更可管理——添加新的抽象功能仍然需要在所有派生的具体类中实现,但任何具体的更改将被继承并立即可用。

数据访问决策

有了所有这些因素,现在是时候做出一些关于各个组件项目的对象如何跟踪其数据的决定了。为了在所有对象数据访问周围有一个单一的接口,我们将实现先前描述的BaseDataObject ABC,或者非常类似它的东西,并从先前迭代中构建的相关业务对象类的组合中派生出我们最终的数据持久化具体类。最终,我们将得到我们所谓的数据对象的类,它们能够读取和写入自己的数据。

Artisan Application中,由于我们不需要担心并发用户同时与数据交互,也不想在没有更好的选择的情况下给Artisan用户增加额外的软件安装,我们将使用本地文件来存储对象数据来构建数据持久性机制。

在将在中央办公室环境中运行的代码中,我们将有并发用户,至少可能会有,并且数据存储需要集中在专用数据库系统中。没有明显需要正式的数据库驻留模式(尽管有一个也不是坏事),因此使用 NoSQL 选项应该可以缩短开发时间,并在数据结构需要意外更改时提供一些灵活性。当我们到达开发工作的那部分时,我们将更详细地重新审视这些选项。

为什么要从头开始?

这种功能结构将从头开始构建,但在其他情境中可能也有其他可以起作用甚至更好的选择。例如,有几个对象关系映射器ORM)包/库可供使用,允许在代码中定义数据库和结构,并传播到数据存储中,其中一些集成到完整的应用程序框架中。这些包括 Django 的models模块,它是整体 Django web 应用程序框架的一部分,是开发 Web 应用程序的常见和流行选项。其他变体包括 SQLAlchemy,提供了一个在 SQL 操作上的抽象层和一个用于处理对象数据的 ORM。

还有特定的驱动程序库适用于几种数据库选项(SQL 和 NoSQL 都有),其中一些可能提供 ORM 功能,但所有这些都至少提供连接到数据源并执行查询或对这些数据源执行操作的基本功能。完全可以编写代码,简单地执行针对 RDBMS(如 MySQL 或 MariaDB)的 SQL,或者执行与该 SQL 对应的函数针对 NoSQL 引擎(如 MongoDB)或甚至云驻留数据存储(如 Amazon 的 DynamoDB)。对于简单的应用程序,这实际上可能是一个更好的方法,至少最初是这样。这将减少开发时间,因为迄今为止我们探讨的各种抽象层根本不会出现在图中,而且代码本身会具有一定类型的简单性,因为它所需要做的就是执行基本的 CRUD 操作,甚至可能并非所有这些操作。

正在为hms_sys开发的数据对象结构将暴露出许多涉及数据访问框架设计的基本原则,这也是选择从头开始的方法的部分原因。另一个原因是,因为它将处于全面 ORM 方法和低级“执行对连接的查询”实现策略之间的某个地方,它将展示这两种方法的许多相关方面。

摘要

数据访问机制和流程有很多选择,虽然偶尔会有要求几乎强制使用其中一种,但可能没有一种方法适用于所有开发工作。特别是,如果时间很重要,寻找现成的解决方案可能是一个很好的起点,但如果要求或其他限制不允许轻松应用其中之一,创建自定义解决方案也是可以的。

在深入研究特定数据存储机制之前,逻辑的起点可能是定义集体数据访问需求的抽象层-即定义BaseDataObject ABC-这就是我们接下来要解决的问题。

第十一章:数据持久性和 BaseDataObject

本章将专注于BaseDataObject ABC(抽象基类)的开发和测试,我们将在hms_artisanArtisan Application)和hms_gatewayArtisan Gateway服务)组件项目中都需要它。hms_coCentral Office Application)代码库可能也需要利用相同的功能。在后面深入研究hms_co代码时,我们将更深入地了解这一点。

目前,我们期望BaseDataObject看起来像这样:

之前描述的驱动BaseDataObject设计和实现的故事如下:

  • 作为开发人员,我需要一个通用的结构来提供整个系统可用的业务对象的状态数据的持久性,以便我可以构建相关的最终类

BaseDataObjecthms_core中的业务对象定义没有功能上的关联,但它提供的功能仍然需要对所有真实的代码库(应用程序和Artisan Gateway服务)可用,因此它应该存在于hms_core包中,但可能不应该与上一次迭代的业务对象定义一起。从长远来看,如果hms_core的各个成员被组织成将元素分组到共同目的或主题的模块,那么理解和维护hms_core包将更容易。在本次迭代结束之前,当前的hms_core.__init__.py模块将被重命名为更具指示性的名称,并且它将与一个新模块一起存在,该模块将包含所有数据对象的类和功能:data_object.py

还有两个与BaseDataObject结构和功能相关的故事,它们的需求将在开发类的过程中得到满足:

  • 作为任何数据使用者,我需要能够创建、读取、更新和删除单个数据对象,以便对这些对象执行基本的数据管理任务。

  • 作为任何数据使用者,我需要能够搜索特定的数据对象,以便我可以使用找到的结果项。

BaseDataObject ABC

BaseDataObject的大部分属性都是布尔值,表示类的实例是否处于特定状态的标志。这些属性的实现都遵循一个简单的模式,这个模式已经在上一次迭代中的BaseProductavailable属性的定义中展示过。这个结构看起来像这样:

###################################
# Property-getter methods         #
###################################

def _get_bool_prop(self) -> (bool,):
    return self._bool_prop

###################################
# Property-setter methods         #
###################################

def _set_bool_prop(self, value:(bool,int)):
    if value not in (True, False, 1, 0):
        raise ValueError(
            '%s.bool_prop expects either a boolean value '
            '(True|False) or a direct int-value equivalent '
            '(1|0), but was passed "%s" (%s)' % 
            (self.__class__.__name__, value, type(value).__name__)
        )
    if value:
        self._bool_prop = True
    else:
        self._bool_prop = False

###################################
# Property-deleter methods        #
###################################

def _del_bool_prop(self) -> None:
    self._bool_prop = False

###################################
# Instance property definitions   #
###################################

bool_prop = property(
    _get_bool_prop, _set_bool_prop, _del_bool_prop, 
    'Gets sets or deletes the flag that indicates whether '
    'the instance is in a particular state'
)

这些属性背后的删除方法,因为它们也用于在初始化期间设置实例的默认值,当删除属性时应该产生特定的值(调用这些方法):

###################################
# Property-deleter methods        #
###################################

def _del_is_active(self) -> None:
    self._is_active = True

def _del_is_deleted(self) -> None:
    self._is_deleted = False

def _del_is_dirty(self) -> None:
    self._is_dirty = False

def _del_is_new(self) -> None:
    self._is_new = True

除非被派生类或特定对象创建过程覆盖,从BaseDataObject派生的任何实例都将以这些值开始:

  • is_active == True

  • is_deleted == False

  • is_dirty == False

  • is_new == True

因此,新创建的实例将是活动的,未删除的,未修改的,新的,假设是创建新对象的过程通常是为了保存一个新的、活动的对象。如果在实例创建之间进行了任何状态更改,这些更改可能会在过程中将is_dirty标志设置为True,但is_newTrue的事实意味着对象的记录需要在后端数据存储中被创建而不是更新。

与标准布尔属性结构的唯一重大偏差在于它们的定义过程中属性本身的文档:

###################################
# Instance property definitions   #
###################################

is_active = property(
    _get_is_active, _set_is_active, _del_is_active, 
    'Gets sets or deletes the flag that indicates whether '
    'the instance is considered active/available'
)
is_deleted = property(
    _get_is_deleted, _set_is_deleted, _del_is_deleted, 
    'Gets sets or deletes the flag that indicates whether '
    'the instance is considered to be "deleted," and thus '
    'not generally available'
)
is_dirty = property(
    _get_is_dirty, _set_is_dirty, _del_is_dirty, 
    'Gets sets or deletes the flag that indicates whether '
    'the instance\'s state-data has been changed such that '
    'its record needs to be updated'
)
is_new = property(
    _get_is_new, _set_is_new, _del_is_new, 
    'Gets sets or deletes the flag that indicates whether '
    'the instance needs to have a state-data record created'
)

BaseDataObject的两个属性createdmodified在类图中显示为datetime值-表示特定日期的特定时间的对象。datetime对象存储日期/时间的年、月、日、小时、分钟、秒和微秒,并提供了一些方便之处,例如,与严格作为时间戳数字值管理的等效值或日期/时间的字符串表示相比。其中一个方便之处是能够从字符串中解析值,允许属性背后的_set_created_set_modifiedsetter 方法接受字符串值而不是要求实际的datetime。同样,datetime提供了从时间戳创建datetime实例的能力-从公共起始日期/时间开始经过的秒数。为了完全支持所有这些参数类型,有必要定义一个通用的格式字符串,用于从字符串中解析datetime值并将其格式化为字符串。至少目前来看,最好将该值存储为BaseDataObject本身的类属性。这样,所有从中派生的类都将默认可用相同的值:

class BaseDataObject(metaclass=abc.ABCMeta):
    """
Provides baseline functionality, interface requirements, and 
type-identity for objects that can persist their state-data in 
any of several back-end data-stores.
"""
    ###################################
    # Class attributes/constants      #
    ###################################

    _data_time_string = '%Y-%m-%d %H:%M:%S'

setter 方法比大多数方法要长一些,因为它们处理四种不同的可行值类型,尽管只需要两个子进程来覆盖所有这些变化。setter 过程首先通过类型检查提供的值并确认它是接受的类型之一:

def _set_created(self, value:(datetime,str,float,int)):
    if type(value) not in (datetime,str,float,int):
        raise TypeError(
            '%s.created expects a datetime value, a numeric '
            'value (float or int) that can be converted to '
            'one, or a string value of the format "%s" that '
            'can be parsed into one, but was passed '
            '"%s" (%s)' % 
            (
                self.__class__.__name__, 
                self.__class__._data_time_string, value, 
                type(value).__name__, 
            )
        )

处理合法的两种数字类型都相当简单。如果检测到错误,我们应该提供更具体的消息,说明遇到的问题的性质:

 if type(value) in (int, float):
   # - A numeric value was passed, so create a new 
   #   value from it
      try:
         value = datetime.fromtimestamp(value)
      except Exception as error:
         raise ValueError(
             '%s.created could not create a valid datetime '
             'object from the value provided, "%s" (%s) due '
             'to an error - %s: %s' % 
             (
                self.__class__.__name__, value, 
                type(value).__name__, 
                error.__class__.__name__, error
              )
           )

处理字符串值的子进程类似,除了调用datetime.strptime而不是datetime.fromtimestamp,并使用_data_time_string`类属性来定义有效的日期/时间字符串外:

 elif type(value) == str:
    # - A string value was passed, so create a new value 
    #   by parsing it with the standard format
      try:
         value = datetime.strptime(
         value, self.__class__._data_time_string
         )
       except Exception as error:
          raise ValueError(
            '%s.created could not parse a valid datetime '
            'object using "%s" from the value provided, '
            '"%s" (%s) due to an error - %s: %s' % 
             (
                 self.__class__.__name__, 
                 self.__class__._data_time_string, 
                 value, type(value).__name__, 
                 error.__class__.__name__, error
              )
          )

如果原始值是datetime的实例,那么之前的任何一个子进程都不会被执行。如果它们中的任何一个被执行,那么原始值参数将被替换为datetime实例。无论哪种情况,该值都可以存储在底层属性中:

# - If this point is reached without error,then we have a 
#   well-formed datetime object, so store it
self._created = value

对于BaseDataObjectcreatedmodified应该始终有一个值,如果在需要时没有可用值(通常只有在保存数据对象的状态数据记录时才需要),则应该为当前值创建一个值,可以在 getter 方法中使用datetime.now()来实现:

def _get_created(self) -> datetime:
    if self._created == None:
        self.created = datetime.now()
    return self._created

这反过来意味着删除方法应该将属性存储属性的值设置为None

def _del_created(self) -> None:
    self._created = None

相应的属性定义是标准的,只是created属性不允许直接删除;允许对象删除自己的创建日期/时间是没有意义的:

###################################
# Instance property definitions   #
###################################

created = property(
    _get_created, _set_created, None, 
    'Gets, sets or deletes the date-time that the state-data '
    'record of the instance was created'
)

# ...

modified = property(
    _get_modified, _set_modified, _del_modified, 
    'Gets, sets or deletes the date-time that the state-data '
    'record of the instance was last modified'
)

BaseDataObject的最后一个属性可能是最关键的oid,它旨在唯一标识给定数据对象的状态数据记录。该属性被定义为通用唯一标识符UUID)值,Python 在其uuid库中提供。使用 UUID 作为唯一标识符而不是一些更传统的方法,例如序列记录号,至少有两个优点:

  • UUID 不依赖于数据库操作的成功才可用:它们可以在代码中生成,而无需担心等待 SQL INSERT 完成,例如,或者在 NoSQL 数据存储中可能可用的任何相应机制。这意味着更少的数据库操作,可能也更简单,这样事情就更容易了。

  • UUID 不容易预测: UUID 是一系列由 32 个十六进制数字组成的字符串(其中有一些破折号将它们分成了本讨论不相关的部分),例如ad6e3d5c-46cb-4547-9971-5627e6b3039a。如果它们是由uuid库提供的几个标准函数之一生成的,它们的序列,即使不是真正随机的,也至少足够随机,使得对于恶意用户来说,找到给定值非常困难,有 3.4×10³⁴个可能的值要查找(每个十六进制数字有 16 个值,31 个数字因为其中一个被保留)。

UUID 的不可预测性在具有通过互联网访问的数据的应用程序中尤其有用。通过顺序编号识别记录,使恶意进程更容易命中某种 API 并按顺序检索每个记录,其他条件相同。

然而,还有一些注意事项:

  • 并非所有的数据库引擎都会将 UUID 对象识别为可行的字段类型。这可以通过将实际的 UUID 值存储在数据对象中来管理,但是将这些值的字符串表示写入和从数据库中读取。

  • 使用 UUID 作为唯一标识符的数据库操作可能会产生非常轻微的性能影响,特别是如果使用字符串表示而不是实际值。

  • 它们固有的不可预测性可以使对数据的合法检查变得困难,如果没有其他可以用来查询的标识标准(针对其他标识标准)。

即使将优势放在一边,BaseDataObject将使用 UUID 作为对象标识(oid属性)的原因是一系列要求和预期的实现的结合:

  • Artisan Application将不会有一个真正的数据库支持它。它最终可能会成为一个简单的本地文档存储,因此为任何给定的数据对象生成唯一标识符必须是自包含的,不依赖于应用程序代码库之外的任何东西。

  • 相同的 oid 值需要在Artisan ApplicationArtisan Gateway**服务之间传播。尝试在任意数量的工匠之间协调身份可能会很快导致身份冲突,而要减轻这种情况可能需要更多的工作(也许是更多),而不会对系统的要求或系统中的各种可安装组件的交互方式进行重大改变。两个随机生成的 UUID 之间发生碰撞的可能性非常低(对于所有实际目的来说几乎不可能),仅仅是因为涉及的可能值的数量。

oid属性的实现将遵循与基于datetime的属性类似的模式。获取方法将根据需要创建一个,设置方法将接受UUID对象或其字符串表示,并在内部创建实际的UUID对象,删除方法将将当前存储值设置为None

def _get_oid(self) -> UUID:
    if self._oid == None:
        self._oid = uuid4()
    return self._oid

# ...

def _set_oid(self, value:(UUID,str)):
    if type(value) not in (UUID,str):
        raise TypeError(
            '%s.oid expects a UUID value, or string '
            'representation of one, but was passed "%s" (%s)' % 
            (self.__class__.__name__, value, type(value).__name__)
        )
    if type(value) == str:
        try:
            value = UUID(value)
        except Exception as error:
            raise ValueError(
                '%s.oid could not create a valid UUID from '
                'the provided string "%s" because of an error '
                '%s: %s' % 
                (
                    self.__class__.__name__, value, 
                    error.__class__.__name__, error
                )
            )
    self._oid = value

# ...

def _del_oid(self) -> None:
    self._oid = None

BaseDataObject的大多数方法都是抽象的,包括所有的类方法。它们都没有任何可能在派生类中重用的具体实现,因此它们都是非常基本的定义。

    ###################################
    # Abstract methods                #
    ###################################

    @abc.abstractmethod
    def _create(self) -> None:
        """
Creates a new state-data record for the instance in the back-end 
data-store
"""
        raise NotImplementedError(
            '%s has not implemented _create, as required by '
            'BaseDataObject' % (self.__class__.__name__)
        )

    @abc.abstractmethod
    def to_data_dict(self) -> (dict,):
        """
Returns a dictionary representation of the instance which can 
be used to generate data-store records, or for criteria-matching 
with the matches method.
"""
        raise NotImplementedError(
            '%s has not implemented _create, as required by '
            'BaseDataObject' % (self.__class__.__name__)
        )

    @abc.abstractmethod
    def _update(self) -> None:
        """
Updates an existing state-data record for the instance in the 
back-end data-store
"""
        raise NotImplementedError(
            '%s has not implemented _update, as required by '
            'BaseDataObject' % (self.__class__.__name__)
        )

    ###################################
    # Class methods                   #
    ###################################

    @abc.abstractclassmethod
    def delete(cls, *oids):
        """
Performs an ACTUAL record deletion from the back-end data-store 
of all records whose unique identifiers have been provided
"""
        raise NotImplementedError(
            '%s.delete (a class method) has not been implemented, '
            'as required by BaseDataObject' % (cls.__name__)
        )

    @abc.abstractclassmethod
    def from_data_dict(cls, data_dict:(dict,)):
        """
Creates and returns an instance of the class whose state-data has 
been populate with values from the provided data_dict
"""
        raise NotImplementedError(
            '%s.from_data_dict (a class method) has not been '
            'implemented, as required by BaseDataObject' % 
            (cls.__name__)
        )

    @abc.abstractclassmethod
    def get(cls, *oids, **criteria):
        """
Finds and returns all instances of the class from the back-end 
data-store whose oids are provided and/or that match the supplied 
criteria
"""
        raise NotImplementedError(
            '%s.get (a class method) has not been implemented, '
            'as required by BaseDataObject' % (cls.__name__)
        )

to_data_dict实例方法和from_data_dict类方法旨在提供机制,将实例的完整状态数据表示为dict,并从这样的dict表示中创建一个实例。from_data_dict方法应该促进记录检索和转换为实际的程序对象,尤其是在 Python 中的标准 RDBMS 连接库中,如果数据库中的字段名与类的属性名相同。在 NoSQL 数据存储中也应该有类似的用法。尽管to_data_dict方法在写入数据存储时可能有用,但它将需要根据标准匹配对象(我们马上会讨论的matches方法)。

PEP-249,当前的Python 数据库 API 规范,定义了符合 PEP 标准的库中的数据库查询的预期,至少会返回元组列表作为结果集。大多数成熟的数据库连接器库还提供了一种方便的机制,以返回一个dict记录值列表,其中每个dict将字段名映射为源记录的值。

_create_update方法只是记录创建和记录更新过程的要求,并最终将被save方法调用。然而,单独的记录创建和记录更新过程的需求可能并不适用于所有数据存储引擎;一些,特别是在 NoSQL 领域,已经提供了写入记录的单一机制,并且根本不关心它是否已经存在。其他一些可能提供某种机制,允许首先尝试创建一个新记录,如果失败(因为找到了重复的键,表明记录已经存在),则更新现有记录。这个选项在MySQLMariaDB数据库中可用,但可能也存在于其他地方。在任何这些情况下,覆盖保存方法以使用这些单一接触点的过程可能是一个更好的选择。

delete类方法是不言自明的,sort可能也是如此。

get方法需要一些检查,即使没有任何具体的实现。正如前面所述,它旨在成为从数据库检索状态数据并接受零到多个对象 ID(*oids参数列表)和过滤标准(在**criteria关键字参数中)的主要机制。整个get过程实际上的预期工作如下:

  • 如果oids不为空:
  1. 执行所需的任何低级查询或查找以找到与提供的oids之一匹配的对象,使用from_data_dict处理每个记录并生成对象列表

  2. 如果criteria不为空,则将当前列表过滤为那些与标准的matches结果为True的对象

  3. 返回结果列表

  • 否则,如果criteria不为空:

  • 执行所需的任何低级查询或查找以找到与提供的标准值之一匹配的对象,使用from_data_dict处理每个记录并生成对象列表

  • 将当前列表过滤为那些与标准的matches结果为True的对象

  • 返回结果列表

  • 否则,执行所需的任何低级查询或查找以检索所有可用对象,再次使用from_data_dict处理每个记录,生成对象列表并简单地返回它们所有

综合考虑,oidscriteria值的组合将允许get类方法找到并返回执行以下操作的对象:

  • 匹配一个或多个oidsget(oid[, oid, …, oid])

  • 匹配一个或多个oids和一些criteria的集合:get(oid[, oid, …, oid], key=value[, key=value, …, key=value])

  • 匹配一个或多个criteria键/值对,无论找到的项目的oids如何:get(key=value[, key=value, …, key=value])

  • 这只是存在于后端数据存储中的:get()

这留下了matchessave方法,这两个方法是类中唯一的两个具体实现。matches的目标是提供一个实例级机制,用于比较实例与标准名称/值,这是get方法中使用和依赖的过程,以实际找到匹配项。它的实现比起一开始可能看起来要简单,但依赖于对set对象的操作,并且依赖于一个经常被忽视的 Python 内置函数(all),因此代码中的过程本身有很多注释:

###################################
# Instance methods                #
###################################

def matches(self, **criteria) -> (bool,):
    """
Compares the supplied criteria with the state-data values of 
the instance, and returns True if all instance properties 
specified in the criteria exist and equal the values supplied.
"""
    # - First, if criteria is empty, we can save some time 
    #   and simply return True - If no criteria are specified, 
    #   then the object is considered to match the criteria.
    if not criteria:
        return True
    # - Next, we need to check to see if all the criteria 
    #   specified even exist in the instance:
    data_dict = self.to_data_dict()
    data_keys = set(check_dict.keys())
    criteria_keys = set(criteria.keys())
    # - If all criteria_keys exist in data_keys, then the 
    #   intersection of the two will equal criteria_keys. 
    #   If that's not the case, at least one key-value won't 
    #   match (because it doesn't exist), so return False
    if criteria_keys.intersection(data_keys) != criteria_keys:
        return False
    # - Next, we need to verify that values match for all 
    #   specified criteria
    return all(
        [
            (data_dict[key] == criteria[key]) 
            for key in criteria_keys
        ]
    )

all函数是一个很好的便利,如果它被传递的可迭代对象中的所有项都评估为True(或至少是真的,因此非空字符串、列表、元组和字典以及非零数字都被认为是True),它将返回True。如果可迭代对象中的任何成员不是True,则返回False,如果可迭代对象为空,则返回True。如果出现这些条件,matches的结果将是False

  • criteria中的任何键都不存在于实例的data_dict中- 一个无法匹配的标准键,本质上

  • criteria中指定的任何值都不完全匹配实例的data_dict中的相应值

save方法非常简单。它只是根据实例的is_newis_dirty标志属性的当前状态调用实例的_create_update方法,然后在执行后重置这些标志,使对象变得干净并准备好接下来可能发生的任何事情:

    def save(self):
        """
Saves the instance's state-data to the back-end data-store by 
creating it if the instance is new, or updating it if the 
instance is dirty
"""
        if self.is_new:
            self._create()
            self._set_is_new = False
            self._set_is_dirty = False
        elif self.is_dirty:
            self._update()
            self._set_is_dirty = False
            self._set_is_new = False

BaseDataObject的初始化应该允许为其所有属性指定值,但不需要这些值:

    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,
    ):

实际的初始化过程遵循了先前为所有参数建立的可选参数模式:对于每个参数,如果参数不是None,则调用相应的_del_方法,然后调用相应的_set_方法。让我们以oid参数为例:

        # - Call parent initializers if needed
        # - Set default instance property-values using _del_... methods

        # ...

        self._del_oid()
        # - Set instance property-values from arguments using 
        #   _set_... methods
        if oid != None:
            self._set_oid(oid)

        # ...

        # - Perform any other initialization needed

这个初始化方法的签名变得非常长,有七个参数(忽略self,因为它总是存在的,并且总是第一个参数)。知道我们最终将定义具体类作为BaseDataObject和已定义的业务对象类的组合,这些具体类的__init__签名也可能会变得更长。然而,这正是BaseDataObject的初始化签名使所有参数都是可选的原因之一。与其中一个业务对象类结合使用时,例如BaseArtisan,其__init__签名如下:

def __init__(self, 
    contact_name:str, contact_email:str, 
    address:Address, company_name:str=None, 
    website:(str,)=None, 
    *products
    ):

从这两者派生的Artisan__init__签名,虽然很长...

def __init__(self, 
    contact_name:str, contact_email:str, 
    address:Address, company_name:str=None, 
    website:(str,)=None, 
    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
    ):

...只需要BaseArtisan需要的contact_namecontact_emailaddress参数,并允许所有参数都被传递,就像它们是关键字参数一样,像这样:

artisan = Artisan(
    contact_name='John Doe', contact_email='john@doe.com', 
    address=my_address, oid='00000000-0000-0000-0000-000000000000', 
    created='2001-01-01 12:34:56', modified='2001-01-01 12:34:56'
)

允许将整个参数集定义为单个字典,并使用传递关键字参数集的相同语法将其整体传递给初始化程序:

artisan_parameters = {
    'contact_name':'John Doe',
    'contact_email':'john@doe.com', 
    'address':my_address,
    'oid':'00000000-0000-0000-0000-000000000000', 
    'created':'2001-01-01 12:34:56', 
    'modified':'2001-01-01 12:34:56'
}
artisan = Artisan(**artisan_parameters)

在 Python 中,使用**dictionary_name将参数传递给字典的语法是一种常见的参数参数化形式,特别是在参数集合非常长的函数和方法中。这需要在开发过程的设计方面进行一些思考和纪律,并且需要对必需参数非常严格,但从长远来看,它比一开始看起来的更有帮助和更容易使用。

这个最后的结构将对从BaseDataObject派生的各种类的from_data_dict方法的实现至关重要- 在大多数情况下,它应该允许这些方法的实现不仅仅是这样:

@classmethod
def from_data_dict(cls, data_dict):
    return cls(**data_dict)

单元测试BaseDataObject

就目前而言,对BaseDataObject进行单元测试将会是有趣的。测试matches方法,这是一个依赖于抽象方法(to_data_dict)的具体方法,而抽象方法又依赖于派生类的实际数据结构(properties),在BaseDataObject的测试用例类的上下文中,要么是不可能的,要么是没有意义的:

  • 为了测试matches,我们必须定义一个非抽象类,其中包含to_data_dict的具体实现,以及一些实际属性来生成该dict

  • 除非该派生类也恰好是系统中需要的实际类,否则它在最终系统代码中没有相关性,因此在那里的测试不能保证其他派生类在matches中不会出现问题

  • 即使完全放置matches方法的测试,测试save也同样毫无意义,原因是它是一个依赖于在BaseDataObject级别上是抽象和未定义的方法的具体方法

当实现BaseArtisan时,我们定义了它的add_productremove_product方法为抽象,但仍然在两者中编写了可用的具体实现代码,以便允许派生类简单地调用父类的实现。实际上,我们要求所有派生类都实现这两个方法,但提供了一个可以从派生类方法内部调用的实现。同样的方法应用到BaseDataObject中的matchessave方法,基本上会强制每个派生具体类的测试要求,同时允许在需要覆盖该实现之前或除非需要覆盖该实现之前使用单一实现。这可能感觉有点狡猾,但这种方法似乎没有任何不利之处:

  • 以这种方式处理的方法仍然必须在派生类中实现。

  • 如果由于某种原因需要覆盖它们,测试策略仍将要求对它们进行测试。

  • 如果它们只是作为对父类方法的调用实现,它们将起作用,并且测试策略代码仍将识别它们为派生类的本地方法。我们的测试策略表示这些方法需要一个测试方法,这允许测试方法针对派生类的特定需求和功能执行。

然而,测试save不必采用这种方法。最终,就该方法而言,我们真正关心的是能够证明它调用了_create_update抽象方法并重置了标志。如果可以在测试BaseDataObject的过程中测试和建立这个证明,我们就不必在其他地方进行测试,除非测试策略代码检测到该方法的覆盖。这将使我们能够避免在以后的所有最终具体类的所有测试用例中散布相同的测试代码,这是一件好事。

开始data_objects模块的单元测试非常简单:

  1. 在项目的test_hms_core目录中创建一个test_data_object.py文件

  2. 执行头部注释中指出的两个名称替换

  3. 在同一目录的__init__.py中添加对它的引用

  4. 运行测试代码并进行正常的迭代测试编写过程

__init__.py中对新测试模块的引用遵循我们的单元测试模块模板中已经存在的结构,复制现有代码中以# import child_module开头的两行,然后取消注释并将child_module更改为新的测试模块:

#######################################
# Child-module test-cases to execute  #
#######################################

import test_data_objects
LocalSuite.addTests(test_data_objects.LocalSuite._tests)

# import child_module
# LocalSuite.addTests(child_module.LocalSuite._tests)

这个添加将新的test_data_objects模块中的所有测试添加到顶层__init__.py测试模块中已经存在的测试中,从而使顶层测试套件能够执行子模块的测试:

test_data_objects.py中的测试也可以独立执行,产生相同的失败,但不执行所有其他现有的测试:

data_objects.py编写单元测试的迭代过程与在上一次迭代中为基本业务对象编写测试的过程没有区别:运行测试模块,找到失败的测试,编写或修改该测试,并重复运行直到所有测试通过。由于BaseDataObject是一个抽象类,需要一个一次性的派生具体类来执行一些测试。除了针对BaseDataObjectoidcreatedmodified属性的面向值的测试之外,我们已经建立了覆盖其他所有内容的模式:

  • 迭代好和坏值列表,这些值对于正在测试的成员是有意义的:

  • (尚不适用)标准可选文本行值

  • (尚不适用)标准必需文本行值

  • 布尔(和数值等效)值

  • (尚不适用)非负数值

  • 验证属性方法的关联——到目前为止在每种情况下都是获取方法,以及预期的设置方法和删除方法

  • 验证获取方法检索其底层存储属性值

  • 验证删除方法按预期重置其底层存储属性值

  • 验证设置方法是否按预期执行类型检查和值检查

  • 验证初始化方法(__init__)按预期调用所有删除和设置方法

这三个属性(oidcreatedmodified)除了没有已定义的测试模式之外,还共享另一个共同特征:如果请求属性并且属性尚未存在值(即,底层存储属性的值为None),则这三个属性都将创建一个值。这种行为需要一些额外的测试,超出了测试方法开始时确认获取方法读取存储属性的正常确认(使用test_get_created来说明):

def test_get_created(self):
    # Tests the _get_created method of the BaseDataObject class
    test_object = BaseDataObjectDerived()
    expected = 'expected value'
    test_object._created = expected
    actual = test_object.created
    self.assertEquals(actual, expected, 
        '_get_created was expected to return "%s" (%s), but '
        'returned "%s" (%s) instead' % 
        (
            expected, type(expected).__name__,
            actual, type(actual).__name__
        )
    )

到目前为止,测试方法与获取方法的测试非常典型,它设置一个任意值(因为正在测试的是获取方法是否检索到值,而不仅仅是这个),并验证结果是否与设置的值相同。然后,我们将存储属性的值强制设置为 None,并验证获取方法的结果是否是适当类型的对象——在这种情况下是datetime

    test_object._created = None
    self.assertEqual(type(test_object._get_created()), datetime, 
        'BaseDataObject._get_created should return a '
        'datetime value if it\'s retrieved from an instance '
        'with an underlying None value'
    )

属性设置方法(在这种情况下为_set_created)的测试方法必须考虑属性的所有不同类型变化,这些类型对于_set_created来说都是合法的——datetimeintfloatstr值,然后根据输入类型设置预期值,然后调用被测试的方法并检查结果:

def test_set_created(self):
    # Tests the _set_created method of the BaseDataObject class
    test_object = BaseDataObjectDerived()
    # - Test all "good" values
    for created in GoodDateTimes:
        if type(created) == datetime:
            expected = created
        elif type(created) in (int, float):
            expected = datetime.fromtimestamp(created)
        elif type(created) == str:
            expected = datetime.strptime(
                created, BaseDataObject._data_time_string
            )
        test_object._set_created(created)
        actual = test_object.created
        self.assertEqual(
            actual, expected, 
            'Setting created to "%s" (%s) should return '
            '"%s" (%s) through the property, but "%s" (%s) '
            'was returned instead' % 
            (
                created, type(created).__name__,
                expected, type(expected).__name__, 
                actual, type(actual).__name__, 
            )
        )
    # - Test all "bad" values
    for created in BadDateTimes:
        try:
            test_object._set_created(created)
            self.fail(
                'BaseDataObject objects should not accept "%s" '
                '(%s) as created values, but it was allowed to '
                'be set' % 
                (created, type(created).__name__)
            )
        except (TypeError, ValueError):
            pass
        except Exception as error:
            self.fail(
                'BaseDataObject objects should raise TypeError '
                'or ValueError if passed a created value of '
                '"%s" (%s), but %s was raised instead:\n'
                '    %s' % 
                (
                    created, type(created).__name__, 
                    error.__class__.__name__, error
                )
            )

删除方法的测试结构上与之前实施的测试过程相同,尽管:

def test_del_created(self):
    # Tests the _del_created method of the BaseDataObject class
    test_object = BaseDataObjectDerived()
    test_object._created = 'unexpected value'
    test_object._del_created()
    self.assertEquals(
        test_object._created, None,
        'BaseDataObject._del_created should leave None in the '
        'underlying storage attribute, but "%s" (%s) was '
        'found instead' % 
        (
            test_object._created, 
            type(test_object._created).__name__
        )
    )

具有相同结构的created更改为modified,测试modified属性的基础方法。具有非常相似结构的created更改为oid和预期类型更改为UUID,作为oid属性的属性方法测试的起点。

然后,测试_get_oid看起来像这样:

def test_get_oid(self):
    # Tests the _get_oid method of the BaseDataObject class
    test_object = BaseDataObjectDerived()
    expected = 'expected value'
    test_object._oid = expected
    actual = test_object.oid
    self.assertEquals(actual, expected, 
        '_get_oid was expected to return "%s" (%s), but '
        'returned "%s" (%s) instead' % 
        (
            expected, type(expected).__name__,
            actual, type(actual).__name__
        )
    )
    test_object._oid = None
    self.assertEqual(type(test_object.oid), UUID, 
        'BaseDataObject._get_oid should return a UUID value '
        'if it\'s retrieved from an instance with an '
        'underlying None value'
    )

测试_set_oid看起来像这样(请注意,类型更改还必须考虑不同的预期类型和值):

    def test_set_oid(self):
        # Tests the _set_oid method of the BaseDataObject class
        test_object = BaseDataObjectDerived()
        # - Test all "good" values
        for oid in GoodOIDs:
            if type(oid) == UUID:
                expected = oid
            elif type(oid) == str:
                expected = UUID(oid)
            test_object._set_oid(oid)
            actual = test_object.oid
            self.assertEqual(
                actual, expected, 
                'Setting oid to "%s" (%s) should return '
                '"%s" (%s) through the property, but "%s" '
                '(%s) was returned instead.' % 
                (
                    oid, type(oid).__name__, 
                    expected, type(expected).__name__, 
                    actual, type(actual).__name__, 
                )
            )
        # - Test all "bad" values
        for oid in BadOIDs:
            try:
                test_object._set_oid(oid)
                self.fail(
                    'BaseDatObject objects should not accept '
                    '"%s" (%s) as a valid oid, but it was '
                    'allowed to be set' % 
                    (oid, type(oid).__name__)
                )
            except (TypeError, ValueError):
                pass
            except Exception as error:
                self.fail(
                    'BaseDataObject objects should raise TypeError '
                    'or ValueError if passed a value of "%s" (%s) '
                    'as an oid, but %s was raised instead:\n'
                    '    %s' % 
                    (
                        oid, type(oid).__name__, 
                        error.__class__.__name__, error
                    )
                )

随着所有数据对象测试的完成(目前为止),现在是时候将生活在包头文件(hms_core/__init__.py)中的类定义移动到一个专门为它们的模块文件中:business_objects.py。虽然这纯粹是一个命名空间组织上的问题(因为类本身都没有被改变,只是它们在包中的位置发生了变化),但从长远来看,这是非常有意义的。移动完成后,包中的类有了逻辑分组:

业务对象定义以及直接与这些类型相关的项目都将存在于hms_core.business_objects命名空间中,并且可以从那里导入,例如:

from hms_core.business_objects import BaseArtisan

如果需要,hms_core.business_objects的所有成员都可以被导入:

import hms_core.business_objects

同样,与仍在开发中的数据对象结构相关的功能都将存在于hms_core.data_objects命名空间中:

from hms_core.data_objects import BaseDataObject

或者,模块的所有成员都可以通过以下方式导入:

import hms_core.data_objects

基本数据对象结构准备就绪并经过测试,现在是时候开始实现一些具体的、数据持久化的业务对象,首先是 Artisan 应用程序中的业务对象。

摘要

BaseDataObject的实现提供了我们之前确定的所有常见数据访问需求的机制(所有 CRUD 操作):

  • 它允许派生数据对象一旦被实例化,就可以创建和更新它们的状态数据。

  • 它提供了一个单一的机制,允许从数据存储中读取一个或多个数据对象,并且作为一个额外的奖励,还允许根据除了数据对象的oid之外的标准来检索对象。

  • 它提供了一个用于删除对象数据的单一机制。

这些方法的实际实现是数据对象本身的责任,它们将直接与每种对象类型使用的存储机制相关联。

Artisan 应用程序的数据存储,读取和写入用户机器上的本地文件,在许多方面来说,是两种数据存储选项中较为简单的,因此我们将从这里开始。

第十二章:将对象数据持久化到文件

乍一看,读取和写入文件系统驻留数据存储的过程可能看起来比许多基于数据库的存储机制的等效过程简单得多。毕竟,读写文件是一个非常基本的过程。但实际上,这是一个稍微复杂的过程。需要采取预防措施来处理诸如文件系统权限、应用程序使用数据访问的硬关闭,甚至系统崩溃等问题。虽然这些使开发变得复杂,但它们可能更具挑战性,因为它们更难以识别为可能性,而不是实施保障措施。

本章将涵盖以下内容:

  • hms_artisan的基本组件项目设置

  • 进一步的抽象层,以封装基于文件系统的数据存储需求

  • hms_artisan组件项目中数据对象的开发包括以下内容:

  • Artisans

  • 产品

  • 订单

设置 hms_artisan 项目

有了我们需要的所有基础类(到目前为止)在hms_core中定义,我们可以开始在其他项目中构建与它们对应的具体类。由于计划是让Artisan 应用程序具有自定义的本地数据存储机制,这可能会比中央办公室应用程序和 Artisan 网关服务中的等效机制更复杂,因此从这个项目开始并创建一个项目结构来满足这个故事的需求可能是最合理的:

  • 作为开发人员,我需要一个项目来为 Artisan 应用程序提供一个放置相关代码和构建应用程序的地方。

最初,hms_artisan类的代码可以从hms_artisan/__init__.py文件开始,就像hms_core中的业务对象 ABC 一样从其根__init__.py文件开始,但可以合理地假设,这些类似的原因的变体很可能出现在 Artisan 应用程序代码库中。考虑到这一点,我们将创建一个artisan_objects.py模块来对它们进行分组和组织。这也将更容易地将我们可能需要的任何数据存储类(它们本身不是数据对象)放在同一个包中的一个单独模块中。我们可以很容易地将Artisan 应用程序的所有代码放入一个单独的模块(hms_artisan.py)中,而不是在包目录中以及相关文件中。这样做没有功能上的理由,但除非可以确定从单一模块文件实现到包结构不需要更改,否则会增加长期风险,需要重新组织整个命名空间文件结构。起始项目结构看起来非常像第七章中定义的默认结构,设置项目和流程:

这种重新组织并不困难,但如果还需要重新组织单元测试模块,那么它就会更加耗时。当这样的重新组织正在进行时,它有可能限制其他人对代码库的工作,这些人不是重新组织的一部分。直到完成之前,它还有可能使源代码控制管理变得非常混乱,这对于开发团队的成员来说并不是一个很好的情况。

尽管我们可能需要一个单独的模块来实际应用程序,但从一开始就将代码细分为逻辑分组是有意义的。

创建本地文件系统数据存储

Artisans 对存储数据的需求包括两个故事:

  • 作为 Artisan,我需要一个本地数据存储来存储所有系统数据,这样我就不必连接到互联网来进行更改

  • 作为一个工匠,我需要我的本地数据存储尽可能简单,不需要额外的软件安装,这样我就不必担心安装和维护数据库系统以及工匠应用程序

各种工匠应用程序数据对象与BaseDataObject之间的最终关系可能只是让每个工匠级别的类直接从BaseDataObject派生。实际上,如果在工匠级别只有一个这样的类,并且在可预见的将来没有期望发生变化,采取这种方法是很有意义的。处理记录文件的创建、更新其中的数据、读取或删除的代码可以存在于一个类中。然而,由于我们需要关注的对象类型有三种,因此将基于文件的数据存储的共同功能收集到另一个抽象类中,该抽象类位于BaseDataObject和具体hms_artisan类之间,例如hms_artisan..Artisan,至少有一些潜在的好处:

该中间类JSONFileDataObject将扩展BaseDataObject,添加特定于管理以 JSON 格式文件集合中存在的对象状态数据任务的功能和数据。同时,它将保留来自BaseDataObject的抽象要求,或者提供它们的具体实现并使它们可用于诸如hms_artisan..Artisan之类的类。这种继承结构的净收益是,理想情况下,执行针对对象的 JSON 后备数据存储的 CRUD 操作所需的所有功能都能够驻留在一个地方。实际上,一些具体细节可能必须驻留在具体类实现中,否则,它们最终都可以包装到一个类中,但几乎肯定会有相当多的共同点可以在中间继承级别中实现。

JSONFileDataObject派生的任何类的更完整的目标集,至少应包括以下内容:

  • 任何派生类的所有存储数据可能应该存储在一个位置

  • 每个对象类型(类)的实例数据可能应该存储在顶层位置的一个共同位置

  • 任何给定实例的数据可能应该存储在一个单独的文件中,其名称可以与存储其数据的实例唯一相关联

此外,还有一些应该具有或值得考虑的功能:

  • 如果处理过程不涉及每次执行数据读取时找到、打开、读取和创建对象,数据读取操作将更快。这样做的一个权衡是,每当执行改变数据的操作时,它们必须负责对所涉及的任何数据进行适当的修改,无论它们存在的所有位置。例如,如果有一个从持久文件中读取的对象的内存集合:

  • 创建操作必须将新对象添加到内存存储中

  • 更新操作必须写入数据存储文件,并更新内存对象

  • 删除操作必须删除相关文件,并从内存存储中删除适当的对象

这些都不是特别难以实现的。

实现 JSONFileDataObject

定义JSONFileDataObject抽象类始于标准的ABCMeta元类规范,以及用于各种目的的类级属性:

class JSONFileDataObject(BaseDataObject, metaclass=abc.ABCMeta):
    """
Provides baseline functionality, interface requirements, and 
type-identity for objects that can persist their state-data as 
JSON files in a local file-system file-cache
"""
    ###################################
    # Class attributes/constants      #
    ###################################

    _file_store_dir = None
    _file_store_ready = False
    _loaded_objects = None

其中:

  • _file_store_dir是一个默认的文件系统目录规范,最终需要从配置文件中读取。目前,为了单元测试目的,它将具有一个硬编码的值,可以在开发和测试期间使用,并且当我们到达 Artisan 应用程序的实现时,我们将查看配置设置。

  • _file_store_ready是一个标志值,用于指示类是否已从数据文件中加载了所有可用对象,因此在执行任何 CRUD 操作之前是否需要加载它们。

  • _loaded_objects是存储类加载的对象集合的位置。实际对象存储将是对象实例的dict,但在加载操作完成之前,默认为None,以便在以后确定未加载(None)和已加载但没有对象(空dict)的状态之间进行区分。

由于它继承自BaseDataObject,该类将从那里定义的抽象要求开始,并且如果不满足这些要求就无法实例化。但是,由于我们希望JSONFileDataObject也是抽象的,它也具有标准的 ABC 元类规范,并且本身也是抽象的。

JSONFileDataObject的初始化方法的签名与其派生自的BaseDataObject相同,但在该过程中执行了一些额外的任务:

###################################
# 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 .............. (JSONFileDataObject instance, required) The 
                    instance to execute against
oid ............... (UUID|str, optional, defaults to None) 
created ........... (datetime|str|float|int, optional, defaults to None) 
modified .......... (datetime|str|float|int, optional, defaults to None) 
is_active ......... (bool|int, optional, defaults to None) 
is_deleted ........ (bool|int, optional, defaults to None) 
is_dirty .......... (bool|int, optional, defaults to None) 
is_new ............ (bool|int, optional, defaults to None) 
"""

涉及的第一个新功能是检查_file_store_dir类属性的非None值。由于这些类的整个目的是能够将对象数据保存到 JSON 文件中,而这需要一个实际存放这些文件的位置,如果没有指定位置,这将是一个关键问题,将阻止任何有用的 CRUD 操作的执行,因此如果检测到问题,则会引发错误:

 # - When used by a subclass, require that subclass to 
 #   define a valid file-system path in its _file_store_dir 
 #   class-attribute - that's where the JSON files will live
    if self.__class__._file_store_dir == None:
        raise AttributeError(
            '%s has not defined a file-system location to '
            'store JSON data of its instances\' data. Please '
            'set %s._file_store_dir to a valid file-system '
            'path' % 
            (self.__class__.__name__, self.__class__.__name__)
        )

同样,即使指定了文件存储位置,该位置也必须存在,并且代码在用户帐户下以相应权限运行时必须可访问。然后,每个类都需要检查位置是否存在(如果不存在则创建),并确保可以写入、读取和删除文件。这个检查过程可能会在每次创建类的实例时触发,但如果该过程已经完成一次,那么从那时起跳过它应该是可以接受的:

if not self.__class__._file_store_ready:
  # - The first time the class is used, check the file-
  #   storage directory, and if everything checks out, 
  #   then re-set the flag that controls the checks.
if not os.path.exists(self.__class__._file_store_dir):
  # - If the path-specification exists, try to 
  #   assure that the *path* exists, and create it 
  #   if it doesn't. If the path can't be created, 
  #   then that'll be an issue later too, so it'll 
  #   need to be dealt with.
       try:
           os.makedirs(self.__class__._file_store_dir)
        except PermissionError:
            raise PermissionError(
               '%s cannot create the JSON data-store '
               'directory (%s) because permission was '
               'denied. Please check permissions on '
               'that directory (or its parents, if it '
               'hasn\'t been created yet) and try '
               'again.' % 
                 (
                     self.__class__.__name__, 
                     self.__class__._file_store_dir
                  )
              )

值得注意的是,由于_file_store_ready值是一个类属性,该值将在整个 Python 运行期间持续存在。也就是说,以 Artisan 应用程序为例,将会发生以下情况:

  1. 应用程序已启动

  2. 在某个时候,数据对象类实例被初始化(比如,一个Product),并且检查过程成功验证了产品对象的所有数据存储需求,并相应地将_file_store_ready设置为True

  3. 用户对应用程序进行操作,不与任何产品对象进行交互

  4. 另一个产品对象被初始化,但由于_file_store_ready标志已设置为True,因此跳过了检查过程

但是,一旦应用程序关闭,该标志值就会消失,因此在下次启动应用程序时,初始化产品对象时会重复检查过程。

正如前面已经指出的,文件访问权限也通过首先写入文件进行检查:

  # - Check to make sure that files can be 
  #   created there...
     try:
        test_file = open(
        '%s%stest-file.txt' % 
        (self.__class__._file_store_dir, os.sep), 
            'w'
        )
         test_file.write('test-file.txt')
         test_file.close()
     except PermissionError:
         raise PermissionError(
             '%s cannot write files to the JSON data-'
             'store directory (%s) because permission was '
             'denied. Please check permissions on that '
              'directory and try again.' % 
            (
                self.__class__.__name__, 
                self.__class__._file_store_dir
             )
           )

然后,通过读取刚刚写入的文件:

 # - ... that files can be read from there...
    try:
       test_file = open(
       '%s%stest-file.txt' % 
        (self.__class__._file_store_dir, os.sep), 
             'r'
        )
           test_file.read()
           test_file.close()
           except PermissionError:
                raise PermissionError(
                    '%s cannot read files in the JSON data-'
                    'store directory (%s) because permission was '
                    'denied. Please check permissions on that '
                    'directory and try again.' % 
                    (
                        self.__class__.__name__, 
                        self.__class__._file_store_dir
                    )
                )

最后,通过删除该文件:

            # - ... and deleted from there...
            try:
                os.unlink(
                    '%s%stest-file.txt' % 
                    (self.__class__._file_store_dir, os.sep)
                )
            except PermissionError:
                raise PermissionError(
                    '%s cannot delete files in the JSON data-'
                    'store directory (%s) because permission was '
                    'denied. Please check permissions on that '
                    'directory and try again.' % 
                    (
                        self.__class__.__name__, 
                        self.__class__._file_store_dir
                    )
                )
            # - If no errors were raised, then re-set the flag:
            self._file_store_ready = True

__init__()的其余部分遵循了先前建立的相同结构。由于该类有一个父类BaseDataObject,因此调用该初始化程序,但由于没有要初始化或设置值的本地属性,因此没有这些调用。所有其他属性的初始化都由对BaseDataObject.__init__的调用处理:

    # - Call parent initializers if needed
    BaseDataObject.__init__(
        self, oid, created, modified, is_active, is_deleted, 
        is_dirty, is_new
    )
    # - Set default instance property-values using _del_... methods
    # - Set instance property-values from arguments using 
    #   _set_... methods
    # - Perform any other initialization needed

三种方法,要么是由BaseDataObject中的抽象所需的,要么是具体实现的,需要在JSONFileDataObject中进行处理。_create_update方法是BaseDataObject所需的,但在这个类的上下文中并没有太多意义,因为无论是创建还是更新操作,都会进行相同的基本操作。尽管这两个方法都已经实现,但它们只是提供一些对开发人员有用的信息,以便开发人员遇到错误时能够引发错误:

def _create(self) -> None:
    """
Creates a new state-data record for the instance in the back-end 
data-store
"""
    # - Since all data-transactions for these objects involve 
    #   a file-write, we're just going to define this method 
    #   in order to meet the requirements of BaseDataObject, 
    #   make it raise an error, and override the save method 
    #   to perform the actual file-write.
    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
"""
    # - Since all data-transactions for these objects involve 
    #   a file-write, we're just going to define this method 
    #   in order to meet the requirements of BaseDataObject, 
    #   make it raise an error, and override the save method 
    #   to perform the actual file-write.
    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__
    )

然后,这些更改将所有写入数据到文件的责任都放在了save方法上,无论被保存的数据代表新的/创建操作还是编辑/更新操作。虽然不太可能,但在程序运行时,存储数据文件的目录的权限可能会发生变化。它们最初被检查过,但这只意味着它们在被检查时是有效的,因此写入数据到文件的过程也应该独立地检查它们:

def save(self):
    """
Saves the instance's state-data to the back-end data-store by 
creating it if the instance is new, or updating it if the 
instance is dirty
"""
    if self.is_new or self.is_dirty:

它确实需要首先确认对象已经加载到内存中,使用_load_objects;在执行时,这将始终是调用继承的类方法的类的实例,因此必须显式地将类作为参数传递:

# - Make sure objects are loaded:
self.__class__._load_objects(self.__class__)

然后,它保存数据并确认对象本身存储在内存中:

# - Try to save the data:
 try:
  # - Open the file
   fp = open(
     '%s%s-data%s%s.json' %
         (
            self.__class__._file_store_dir, os.sep, 
            self.__class__.__name__, os.sep, 
            self.oid
         ), 'w'
    )
      # - Write the instance's data-dict to the file as JSON
      json.dump(fp, self.to_data_dict(), indent=4)
      # - re-set the new and dirty state-flags
      self._set_is_dirty(False)
      self._set_is_new(False)
      # - Update it in the loaded objects
      self.__class__._loaded_objects[self.oid] = self

如果文件写入失败(json.dump调用)出现与权限相关的错误,那么所有内存更新都不会被提交,并且应该引发更加用户友好的错误消息,以防需要显示给最终用户:

except PermissionError:
   # - Raise a more informative error
      raise PermissionError(
         '%s could not save an object to the JSON data-'
         'store directory (%s) because permission was '
         'denied. Please check permissions on that '
         'directory and try again.' % 
       (
           self.__class__.__name__, 
           self.__class__._file_store_dir
 )
   )
# - Any other errors will just surface for the time being

相同的公共存储位置文件系统路径值不仅允许save方法变得具体,还允许deleteget类方法成为JSONFileDataObject的具体类方法。因为类属性定义了查找与任何/所有对象实例相关的数据文件所需的内容,delete代码可以直接进行所需的文件删除操作,并进行适当的错误处理:

@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, ensure that objects are loaded
    cls._load_objects(cls)
    # - For each oid specified, try to remove the file, handling 
    #   any errors raised in the process.
    failed_deletions = []
    for oid in oids:
        try:
            # - Try to delete the file first, so that deletion 
            #   failures won't leave the files but remove the 
            #   in-memory copies
            file_path = '%s%s%s-data%s%s.json' %(
                cls._file_store_dir, os.sep, 
                cls.__name__, os.sep, oid
            )
            # - Delete the file at file_path
            os.unlink(file_path)
            # - Remove the in-memory object-instance:
            del cls._loaded_objects[str(oid)]
        except PermissionError:
            failed_deletions.append(file_path)
    if failed_deletions:
        # - Though we *are* raising an error here, *some* deletions 
        #   may have succeeded. If this error-message is displayed, 
        #   the user seeing it need only be concerned with the 
        #   items that failed, though...
        raise PermissionError(
            '%s.delete could not delete %d object-data %s '
            'because permission was denied. Please check the '
            'permissions on %s and try again' % 
            (
                cls.__name__, len(failed_deletions), 
                ('files' if len(failed_deletions) > 1 else 'file'), 
                ', '.join(failed_deletions)
            )
        )

get方法不需要直接访问文件的读取权限 - _load_objects类方法处理了这一点,加载了get所依赖的所有数据 - 一旦相关对象存在于内存中,即使有条件或对象 ID 和criteria的组合,找到它们也是非常简单和快速的:

@classmethod
def get(cls, *oids, **criteria):
    """
Finds and returns all instances of the class from the back-end 
data-store whose oids are provided and/or that match the supplied 
criteria
"""
    # - First, ensure that objects are loaded
    cls._load_objects(cls)

如果提供了oids,则该过程必须考虑到这些oids,以及如果提供了criteria,也要考虑到criteria

    # - If oids have been specified, then the initial results are all 
    #   items in the in-memory store whose oids are in the supplied 
    #   oids-list
    if oids:
        oids = tuple(
            [str(o) for o in oids]
        )
        # - If no criteria were supplied, then oids are all we need 
        #   to match against:
        if not criteria:
            results = [
                o for o in cls._loaded_objects.values()
                if str(o.oid) in oids
            ]
        # - Otherwise, we *also* need to use matches to find items 
        #   that match the criteria
        else:
            results = [
                o for o in cls._loaded_objects.values()
                if str(o.oid) in oids
                and o.matches(**criteria)
            ]
        # - In either case, we have a list of matching items, which 
        #   may be empty, so return it:
        return results

如果没有提供oids,但提供了criteria,则该过程类似:

    # - If oids were NOT specified, then the results are all objects 
    #   in memory that match the criteria
    elif criteria:
        results = [
            o for o in cls._loaded_objects
            if o.matches(**criteria)
        ]
        return results
        # - If neither were specified, return all items available:
        else:
            return list(cls._loaded_objects.values())

在这两个分支中,基于criteria的任何过滤都是由各个对象的matches方法处理的,这使得通过特定属性值搜索对象的过程非常简单。

所有这些都依赖于_load_objects类方法来检索和填充所有对象的内存副本,这些对象的数据已经被持久化为 JSON 文件,并将它们附加到相关的类中,在_loaded_objects字典中定义为一个公共类属性:

def _load_objects(cls, force_load=False):
    """
Class-level helper-method that loads all of the objects in the 
local file-system data-store into memory so that they can be 
used more quickly afterwards.

Expected to be called by the get class-method to load objects 
for local retrieval, and other places as needed.

cls .......... (class, required) The class that the method is 
               bound to
force_load ... (bool, optional, defaults to False) If True, 
               forces the process to re-load data from scratch, 
               otherwise skips the load process if data already 
               exists.
"""

如果数据尚未加载(由_loaded_objects属性包含None值表示),或者如果需要显式重新加载数据(在force_load参数中收到True值),则该方法检索类数据目录中所有文件的列表,在验证相关目录存在后,尝试创建它们(如果它们不存在),并在需要创建但无法创建时引发错误:

    if cls._loaded_objects == None or force_load:
        if not os.path.exists(cls._file_store_dir):
            # - If the path-specification exists, try to 
            #   assure that the *path* exists, and create it 
            #   if it doesn't. If the path can't be created, 
            #   then that'll be an issue later too, so it'll 
            #   need to be dealt with.
            try:
                os.makedirs(cls._file_store_dir)
            except PermissionError:
                raise PermissionError(
                    '%s cannot create the JSON data-store '
                    'directory (%s) because permission was '
                    'denied. Please check permissions on '
                    'that directory (or its parents, if it '
                    'hasn\'t been created yet) and try '
                    'again.' % 
                    (cls.__name__, cls._file_store_dir)
                )
        class_files_path = '%s%s%s-data' % (
            cls._file_store_dir, os.sep, 
            cls.__name__
        )
        if not os.path.exists(class_files_path):
            try:
                os.makedirs(class_files_path)
            except PermissionError:
                raise PermissionError(
                    '%s cannot create the JSON data-store '
                    'directory (%s) because permission was '
                    'denied. Please check permissions on '
                    'that directory (or its parents, if it '
                    'hasn\'t been created yet) and try '
                    'again.' % 
                    (cls.__name__, class_files_path)
                )
        # - Get a list of all the JSON files in the data-store 
        #   path
        files = [
            fname for fname in os.listdir(
                '%s%s%s-data' % (
                    cls._file_store_dir, os.sep, 
                    cls.__name__
                )
            ) if fname.endswith('.json')
        ]

如果找到任何文件,则尝试读取每个文件,将其从预期的 JSON 编码的data_dict转换为实际的类实例,并将实例添加到_loaded_objects属性中。由于_loaded_objects是一个类属性,加载的值将持续存在,只要该类定义处于活动状态。除非显式清除或重新定义类本身,否则这将持续到运行代码的 Python 解释器的持续时间,使得进程中读取的数据可以持续存在:

 cls._loaded_objects = {}
    if files:
      for fname in files:
         item_file = '%s%s-data%s%s' % (
         self.__class__._file_store_dir, os.sep, 
         self.__class__.__name__, os.sep, fname
        )
      try:
        # - Read the JSON data
        fp = open(item_file, 'r')
        data_dict = json.load(fp)
        fp.close()
        # - Create an instance from that data
        instance = cls.from_data_dict(data_dict)
        # - Keep track of it by oid in the class
        cls._loaded_objects[instance.oid] = instance

由于在 Artisan Application 运行时,数据文件本身或文件的父目录的文件系统权限可能发生变化,文件读取可能会抛出PermissionError异常,因此这些异常被捕获并跟踪直到进程完成:

   # - If permissions are a problem, raise an 
   #   error with helpful information
      except PermissionError as error:
         raise PermissionError(
             '%s could not load object-data from '
             'the data-store file at %s because '
             'permission was denied. Please check '
             '(and, if needed, correct) the file- '
             'and directory-permissions and try '
             'again' % 
             (cls.__name__, item_file)
           )

同样,如果数据文件的内容无效,则会引发错误,尽管在这种情况下是立即的。立即性的理由是数据已经损坏,需要在允许发生任何更改之前解决。

# - If data-structure or -content is a problem, 
#   raise an error with helpful information
     except (TypeError, ValueError) as error:
          raise error.__class__(
              '%s could not load object-data from '
              'the data-store file at %s because '
              'the data was corrupt or not what '
              'was expected (%s: %s)' % 
              (
                  cls.__name__, item_file, 
                  error.__class__.__name__, error
              )
          )
# - Other errors will simply surface, at 
#   least for now

任何其他错误都将级联到调用代码,由那里处理或允许中止应用程序的执行。

原始目标,包括应该具有或者很好具有的功能,在这一点上都已经考虑到了,形成了一套完整的 CRUD 操作机制:

  • 任何派生类的所有存储数据可能都应该位于一个位置。这是通过_file_store_dir类属性来强制执行的。

  • 每个对象类型(类)的实例数据可能应该存储在顶层位置的一个共同位置,并且任何给定实例的数据可能应该存储在一个单独的文件中,其名称可以与存储其数据的实例唯一相关联。这些是通过确保所有使用的文件路径都包含类名来进行管理,因此,例如,所有产品实例数据将存储在_file_store_dir/Product-data/*.json文件中。

  • 如果处理不涉及每次执行数据读取时找到、打开、读取和创建对象,数据读取操作将会更快。_load_objects类方法执行加载,并确保在执行任何 CRUD 操作之前调用它,以确保它们可用。创建、更新和删除过程都考虑了持久数据文件和与这些实例相关的内存中实例。

hms_artisan 的具体业务对象

在 Artisan Application 中具体类的最终定义实际上归结为以下内容:

  • 定义每个具体类:

  • hms_core中对应的基类派生

  • 从刚刚定义的JSONFileDataObject中派生

  • 收集新类__init__方法的参数,该方法需要考虑父类的所有参数。

  • 实现父类所需的任何抽象实例和类方法,其中许多已经设置允许派生类调用父类的抽象方法。

  • 设置一个_file_store_dir类属性值,可以被类的实例使用,直到最终应用程序配置完成。

如果将这些关系绘制成图表可能更容易理解:

处理 is_dirty 和属性

BaseDataObject提供了is_dirty属性,用于指示对象的状态数据何时发生了更改(例如,当调用了各种_set__del_方法时,应将其设置为True)。由于具体对象的属性设置器和删除器方法,如在其对应的基类中定义的,根本不知道该功能,因此由具体对象来实现该功能。

然而,由于这些 setter 和 deleter 方法可以在派生的具体类定义中被调用,实现非常简单。以Artisanaddress属性为例,我们基本上定义了本地的 setter 和 deleter 方法,调用它们在BaseArtisan中的对应方法:

###################################
# Property-setter methods         #
###################################

def _set_address(self, value:Address) -> None:
    # - Call the parent method
    result = BaseArtisan._set_address(self, value)
    self._set_is_dirty(True)
    return result

# ...

###################################
# Property-deleter methods        #
###################################

def _del_address(self) -> None:
    # - Call the parent method
    result = BaseArtisan._del_address(self)
    self._set_is_dirty(True)
    return result

一旦这些被定义,属性本身必须重新定义以指向适当的方法。如果没有这一步,Artisan对象的属性仍然会指向BaseArtisan的 setter 和 deleter 方法,因此is_dirty标志永远不会被设置,数据更改永远不会被保存:

###################################
# Instance property definitions   #
###################################

address = property(
    BaseArtisan._get_address, _set_address, _del_address, 
    'Gets, sets or deletes the physical address (Address) '
    'associated with the Artisan that the instance represents'
)

这种模式将适用于hms_artisan类的所有属性。

这也意味着,所有这些类,因为它们都在执行它们的__init__方法期间使用它们各自的_del_方法来初始化实例值,当对象被创建时也可能需要显式地将is_dirty重置为False

这是一种非常简单的处理对象实例的脏状态的方法。这种实现背后的基本假设是,任何发生的属性设置或删除都会对适用状态值进行更改,因此实例会因此变得脏。即使新值与属性的旧值相同,也是如此。在某些云数据存储中,每个数据库事务都会产生实际的货币成本的系统中,可能值得额外的努力来在执行设置代码或删除代码之前检查属性值,甚至不进行更改,更不用说设置is_dirty标志,如果传入的新值与现有值不同。

hms_artisan.Artisan

工匠需要能够在 Artisan 应用程序中操纵自己的数据:

  • 作为一名工匠,我需要能够创建、管理和存储自己的系统数据,以便我可以保持其最新状态

Artisan类提供满足本故事需求的数据结构和持久性的初始代码非常轻量级,因为大部分功能都是从hms_coreBaseArtisan(用于属性和数据结构)和JSONFileDataObject(用于方法和持久性功能)继承的。不计注释和文档,实际代码只有不到 60 行:

class Artisan(BaseArtisan, JSONFileDataObject, object):
    """
Represents an Artisan in the context of the Artisan Application
"""
    ###################################
    # Class attributes/constants      #
    ###################################

    # TODO: Work out the configuration-based file-system path 
    #       for this attribute
    _file_store_dir = '/tmp/hms_data'

__init__方法具有一个长而详细的参数签名,有 12 个参数(其中三个是必需的),以及products参数列表。这可能看起来令人生畏,但大多数情况下不需要(稍后会详细说明)。它真正需要做的就是调用父初始化程序来设置适用的属性值:

    ###################################
    # Object initialization           #
    ###################################

    # TODO: Add and document arguments if/as needed
    def __init__(self,
        # - Required arguments from BaseArtisan
        contact_name:str, contact_email:str, address:Address, 
        # - Optional arguments from BaseArtisan
        company_name:str=None, website:(str,)=None, 
        # - Optional arguments from BaseDataObject/JSONFileDataObject
        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,
        # - the products arglist from BaseArtisan
        *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
website ........... (str, optional, defaults to None) The the URL of 
                    the website associated with the Artisan that the 
                    instance represents
oid ............... (UUID|str, optional, defaults to None) 
created ........... (datetime|str|float|int, optional, defaults to None) 
modified .......... (datetime|str|float|int, optional, defaults to None) 
is_active ......... (bool|int, optional, defaults to None) 
is_deleted ........ (bool|int, optional, defaults to None) 
is_dirty .......... (bool|int, optional, defaults to None) 
is_new ............ (bool|int, optional, defaults to None) 
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, website, *products
        )
        JSONFileDataObject.__init__(
            self, oid, created, modified, is_active, 
            is_deleted, is_dirty, is_new
        )
        # - Set default instance property-values using _del_... methods
        # - Set instance property-values from arguments using 
        #   _set_... methods
        # - 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 BaseDataObject.matches(self, **criteria)

    def remove_product(self, product:BaseProduct) -> BaseProduct:
        return HasProducts.remove_product(self, product)

例外的是to_data_dict方法,这必须针对每个具体的类进行定制。不过,它所需要做的就是返回一个应该被持久化的所有属性和值的dict,并且可以在对象初始化时使用。address属性存在问题,从能够将其存储在 JSON 文件的角度来看,这将很快得到检查。

datetimeUUID属性被转换为出站数据字典的字符串值,并且它们在Artisan对象的初始化期间已经被放置,以便将它们转换回其本机数据类型:

    def to_data_dict(self) -> (dict,):
        return {
            # Properties from BaseArtisan:
            'address':self.address,
            'company_name':self.company_name,
            'contact_email':self.contact_email,
            'contact_name':self.contact_name,
            'website':self.website, 
            # - Properties from BaseDataObject (through 
            #   JSONFileDataObject)
            '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),
        }

单个类方法,就像前面大部分的实例方法一样,也使用了具有实现的原始抽象类方法:

    ###################################
    # Class methods                   #
    ###################################

    @classmethod
    def from_data_dict(cls, data_dict:(dict,)):
        return cls(**data_dict)

Artisan.__init__的长参数签名乍看起来可能有点令人生畏。毕竟有很多参数,而且 Python 的语言规定要求参数必须在方法和函数参数定义中的可选参数之前,这意味着其中三个参数必须首先出现(尽管它们相对于彼此的顺序由开发人员决定)。

然而,大多数情况下,__init__方法可能不会直接调用。从数据存储中检索的数据创建实例预计将使用类的from_data_dict方法处理,可能看起来像这样:

# - open the data-file, read it in, and convert it to a dict:
with open('data-file.json', 'r') as artisan_file:
    artisan = Artisan.from_data_dict(json.load(artisan_file))

Artisan实例也可以通过传递值字典直接创建:

artisan = Artisan(**data_dict)

该方法的唯一考虑因素是传递的data_dict中必须有有效条目的必需参数,并且data_dict不能包含在__init__方法中不存在的键名称 - 本质上,对象创建等同于以下内容:

artisan = Artisan(
    contact_name='value', contact_email='value', address=<Address Object>
    # ... and so on for any relevant optional arguments
)

当创建 JSON 输出时,“地址”属性存在问题,核心问题在于“地址”类无法直接序列化为 JSON:

import json
address = Address('12345 Main Street', 'City Name')
a = Artisan('John Smith', 'j@smith.com', address)
print(json.dumps(a.to_data_dict(), indent=4))

如果执行上述代码,TypeError: <hms_core.business_objects.Address object> is not JSON serializable将被引发。

尽管有几种可能的解决方案,但由于我们已经建立了将对象转换为字典值并从中读取/创建对象的模式,最像该模式的解决方案是在hms_core中的原始Address类上实现to_dictfrom_dict方法,并更改to_data_dict结果以使用实例的addressto_dict。新的Address方法很简单:

    ###################################
    # Instance methods                #
    ###################################

    def to_dict(self) -> (dict,):
        return {
            'street_address':self.street_address,
            'building_address':self.building_address,
            'city':self.city,
            'region':self.region,
            'postal_code':self.postal_code,
            'country':self.country
        }

    ###################################
    # Class methods                   #
    ###################################

    @classmethod
    def from_dict(cls, data_dict):
        return cls(**data_dict)

Artisan.to_data_dict的更改一样:

    def to_data_dict(self) -> (dict,):
        return {
            # Properties from BaseArtisan:
            '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, 
            # - Properties from BaseDataObject (through 
            #   JSONFileDataObject)
            '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),
        }

有了这些更改,重新运行之前引发TypeError的代码现在产生可用的 JSON,这意味着to_data_dict调用的结果可以直接用于编写到文件系统数据存储中持久保存Artisan数据所需的 JSON 文件:

hms_artisan.Product

工匠对“产品”对象数据有类似的数据持久性需求:

  • 作为一名工匠,我需要能够创建、管理和存储“产品”数据,以便我可以在中央办公室系统中保持“产品”信息的最新状态

hms_artisan..Product类,就像包的Artisan类一样,利用其对应的hms_core基类(BaseProduct)和JSONFileDataObject ABC,以最小化实际代码在具体实现中所需的数量。

实际上,唯一的真正区别在于__init__方法(具有不同的参数,并调用不同的父初始化方法集):

    def __init__(self, 
        # - Required arguments from BaseProduct
        name:(str,), summary:(str,), available:(bool,), 
        store_available:(bool,), 
        # - Optional arguments from BaseProduct
        description:(str,None)=None, dimensions:(str,None)=None,
        metadata:(dict,)={}, shipping_weight:(int,)=0, 
        # - Optional arguments from BaseDataObject/JSONFileDataObject
        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
"""
        # - Call parent initializers if needed
        BaseProduct.__init__(
            self, name, summary, available, store_available, 
            description, dimensions, metadata, shipping_weight
        )
        JSONFileDataObject.__init__(
            self, oid, created, modified, is_active, 
            is_deleted, is_dirty, is_new
        )
        # - Set default instance property-values using _del_... methods
        # - Set instance property-values from arguments using 
        #   _set_... methods
        # - Perform any other initialization needed

to_data_dict方法(必须考虑类的不同属性):

    def to_data_dict(self) -> (dict,):
        return {
            # Properties from BaseProduct:
            '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,
            # - Properties from BaseDataObject (through 
            #   JSONFileDataObject)
            '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),
        }

类似地简单创建Product对象,并转储其to_data_dict结果,产生可行的 JSON 输出:

p = Product('name', 'summary', True, True)
print(json.dumps(p.to_data_dict(), indent=4))

这产生了以下结果:

hms_artisan.Order

工匠需要能够本地保存订单数据的能力:

  • 作为一名工匠,我需要能够创建、管理和存储“订单”数据,以便在订单传达给我时履行订单,并将其标记为中央办公室已履行

然而,订单数据在结构层面上与我们迄今为止所探索的“工匠”和“产品”数据有些不同:

  • 实际上,“订单”归根结底是一个客户与一对多产品的关联。

  • 工匠不需要跟踪单个客户,除非与订单相关,因此工匠需要不是数据对象的Customer对象,就像“工匠”对象有与它们相关联的Address一样,它们本身不是数据对象。

  • 作为“订单”一部分的“客户”对象也有一个必须考虑的“地址”。

  • 与订单相关的产品至少意味着它们可能有与之关联的数量 - 例如,客户可能想订购一个产品的两个,另一个的五个,第三个的一个 - 并且实际上不需要传输所有的“产品”数据,只要提供订单中每个“产品”的oid即可。这将足够 Artisan 应用程序从其本地“产品”数据存储中查找产品的信息。

回顾最后一项,质疑了hms_coreBaseOrder的一些结构,或者至少是否在 Artisan 应用程序的范围内相关。按照当前的定义,它派生自hms_core...HasProducts,最初的意图是将实际的“产品”对象与“订单”相关联。这在中央办公室或网关服务上可能是有意义的,但在 Artisan 应用程序的上下文中并不会特别有用。更好的订单到产品关系可能是在“订单”中存储每个“产品”的“oid”和数量,并在必要时让应用程序和服务查找它们:

退一步看看 Artisan 应用程序的“订单”到底是什么,似乎是一个“地址”,加上一个“名称”属性(订单所属的人),以及一些“产品”数量数据。产品规格的oid和数量值之间的关联可以很容易地在dict属性中进行管理,并且添加和删除订单项目的过程可以包装在一个接受oid和数量值的单个方法中。

这似乎是 Artisans 订单数据的一个更好的解决方案。他们实际上不需要知道比这个结构涵盖的数据更多的东西:

  • 订单所属的人(“名称”)

  • 它发送到哪里(从“地址”派生的属性)

  • 订单中有哪些产品,以及数量(“项目”)

然后,“订单”类从“地址”和JSONFileDataObject派生,并具有通常的类属性:

class Order(Address, JSONFileDataObject, object):
    """
Represents an Order in the context of the Artisan Application
"""
    ###################################
    # Class attributes/constants      #
    ###################################

    # TODO: Work out the configuration-based file-system path 
    #       for this attribute
    _file_store_dir = '/tmp/hms_data'

属性定义、getter、setter 和删除方法以及属性声明都遵循我们到目前为止在其他地方使用的模式,_get_items返回当前属性的副本,以防止对实际数据的不必要操作。设置器和删除器方法还必须显式调用_set_is_dirty(True)以确保在删除或设置本地属性时实例的is_dirty标志得到适当的更改,并且属性本身及其从“地址”继承的 setter 和删除器方法必须被覆盖。有两个本地 getter 方法:

    ###################################
    # Property-getter methods         #
    ###################################

    def _get_items(self) -> dict:
        return dict(self._items)

    def _get_name(self) -> (str,None):
        return self._name

大多数 setter 方法调用其祖先方法,设置is_dirty并“退出”,但与本地 getter 对应的两个方法是完整的实现:

    ###################################
    # Property-setter methods         #
    ###################################

    def _set_building_address(self, value:(str,None)) -> None:
        result = Address._set_building_address(self, value)
        self._set_is_dirty(True)
        return result

    def _set_city(self, value:str) -> None:
        result = Address._set_city(self, value)
        self._set_is_dirty(True)
        return result

    def _set_country(self, value:(str,None)) -> None:
        result = Address._set_country(self, value)
        self._set_is_dirty(True)
        return result

    def _set_items(self, value:(dict,)) -> None:
        if type(value) != dict:
            raise TypeError(
                '%s.items expects a dict of UUID keys and int-'
                'values, but was passed "%s" (%s)' % 
                (self.__class__.__name__, value,type(value).__name__)
            )
        self._del_items()
        for key in value:
            self.set_item_quantity(key, value[key])
        self._set_is_dirty(True)

    def _set_name(self, value:(str,)) -> None:
        self._name = value
        self._set_is_dirty(True)

    def _set_region(self, value:(str,None)) -> None:
        result = Address._set_region(self, value)
        self._set_is_dirty(True)
        return result

    def _set_postal_code(self, value:(str,None)) -> None:
        result = Address._set_postal_code(self, value)
        self._set_is_dirty(True)
        return result

    def _set_street_address(self, value:str) -> None:
        result = Address._set_street_address(self, value)
        self._set_is_dirty(True)
        return result

删除方法遵循相同的模式:

    ###################################
    # Property-deleter methods        #
    ###################################

    def _del_building_address(self) -> None:
        result = Address._del_building_address(self)
        self._set_is_dirty(True)
        return result

    def _del_city(self) -> None:
        result = Address._del_city(self)
        self._set_is_dirty(True)
        return result

    def _del_country(self) -> None:
        result = Address._del_country(self)
        self._set_is_dirty(True)
        return result

    def _del_items(self) -> None:
        self._items = {}
        self._set_is_dirty(True)

    def _del_name(self) -> None:
        self._name = None
        self._set_is_dirty(True)

    def _del_region(self) -> None:
        result = Address._del_region(self)
        self._set_is_dirty(True)
        return result

    def _del_postal_code(self) -> None:
        result = Address._del_postal_code(self)
        self._set_is_dirty(True)
        return result
    def _del_street_address(self) -> None:
        result = Address._del_street_address(self)
        self._set_is_dirty(True)
        return result
        self._set_is_dirty(True)

属性也遵循相同的模式:

    ###################################
    # Instance property definitions   #
    ###################################

    building_address = property(
        Address._get_building_address, _set_building_address, 
        _del_building_address, 
        'Gets, sets or deletes the building_address (str|None) '
        'of the instance'
    )
    city = property(
        Address._get_city, _set_city, _del_city, 
        'Gets, sets or deletes the city (str) of the instance'
    )
    country = property(
        Address._get_country, _set_country, _del_country, 
        'Gets, sets or deletes the country (str|None) of the '
        'instance'
    )
    items = property(
        _get_items, None, None,
        'Gets the items associated with the order, a dict of OID '
        'keys with quantity values'
    )
    name = property(
        _get_name, _set_name, _del_name, 
        'Gets, sets or deletes the name associated with the order'
    )
    region = property(
        Address._get_region, _set_region, _del_region, 
        'Gets, sets or deletes the region (str|None) of the '
        'instance'
    )
    postal_code = property(
        Address._get_postal_code, _set_postal_code, _del_postal_code, 
        'Gets, sets or deletes the postal_code (str|None) of '
        'the instance'
    )
    street_address = property(
        Address._get_street_address, _set_street_address, 
        _del_street_address, 
        'Gets, sets or deletes the street_address (str) of the '
        'instance'
    )

初始化过程(__init__)再次具有很长的签名,因为它必须适应其父类的所有参数,以及本地属性的参数:

    ###################################
    # Object initialization           #
    ###################################

    def __init__(self, 
        name:(str,),
        # - Required arguments from Address
        street_address:(str,), city:(str,), 
        # - Local optional arguments
        items:(dict,)={},
        # - Optional arguments from Address
        building_address:(str,None)=None, region:(str,None)=None, 
        postal_code:(str,None)=None, country:(str,None)=None,
        # - Optional arguments from BaseDataObject/JSONFileDataObject
        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 .............. (Order instance, required) The instance to 
                    execute against
name .............. (str, required) The name of the addressee
street_address .... (str, required) The base street-address of the 
                    location the instance represents
city .............. (str, required) The city portion of the street-
                    address that the instance represents
items ............. (dict, optional, defaults to {}) The dict of 
                    oids-to-quantities of products in the order
building_address .. (str, optional, defaults to None) The second 
                    line of the street address the instance represents, 
                    if applicable
region ............ (str, optional, defaults to None) The region 
                    (state, territory, etc.) portion of the street-
                    address that the instance represents
postal_code ....... (str, optional, defaults to None) The postal-code 
                    portion of the street-address that the instance 
                    represents
country ........... (str, optional, defaults to None) The country 
                    portion of the street-address that the instance 
                    represents
oid ............... (UUID|str, optional, defaults to None) 
created ........... (datetime|str|float|int, optional, defaults to None) 
modified .......... (datetime|str|float|int, optional, defaults to None) 
is_active ......... (bool|int, optional, defaults to None) 
is_deleted ........ (bool|int, optional, defaults to None) 
is_dirty .......... (bool|int, optional, defaults to None) 
is_new ............ (bool|int, optional, defaults to None) 
"""
        # - Call parent initializers if needed
        Address.__init__(
            self, street_address, city, building_address, region, 
            postal_code, country
        )
        JSONFileDataObject.__init__(
            self, oid, created, modified, is_active, 
            is_deleted, is_dirty, is_new
        )
        # - Set default instance property-values using _del_... methods
        self._del_items()
        self._del_name()
        # - Set instance property-values from arguments using 
        #   _set_... methods
        self._set_name(name)
        if items:
            self._set_items(items)
        # - Perform any other initialization needed
        self._set_is_dirty(False)

matches方法仍然可以调用BaseDataObjectmatches方法;没有期望需要进行任何更多或不同的匹配:

def matches(self, **criteria) -> (bool,):
  return BaseDataObject.matches(self, **criteria)

在订单中设置项目数量的过程需要进行相当多的类型和值检查,但所有这些都遵循了在先前代码中使用的模式,包括类型检查、将oid字符串值转换为UUID对象以及检查有效值:

    def set_item_quantity(self, oid:(UUID,str), quantity:(int,)) -> None:
        if type(oid) not in (UUID, str):
            raise TypeError(
                '%s.set_item_quantity expects a UUID or string '
                'representation of one for its oid argument, but '
                'was passed "%s" (%s)' % 
                (self.__class__.__name__, oid, type(oid).__name__)
            )
        if type(oid) == str:
            try:
                oid = UUID(oid)
            except Exception as error:
                raise ValueError(
                    '%s.set_item_quantity expects a UUID or string '
                    'representation of one for its oid argument, but '
                    'was passed "%s" (%s) which could not be '
                    'converted into a UUID (%s: %s)' % 
                    (
                        self.__class__.__name__, oid, 
                        type(oid).__name__, error.__class__.__name__, 
                        error
                    )
                )
        if type(quantity) != int:
            raise TypeError(
                '%s.set_item_quantity expects non-negative int-value '
                'for its quantity argument, but was passed "%s" (%s)' 
                % (
                    self.__class__.__name__, quantity, 
                    type(quantity).__name__
                )
            )
        if quantity < 0:
            raise ValueError(
                '%s.set_item_quantity expects non-negative int-value '
                'for its quantity argument, but was passed "%s" (%s)' 
                % (
                    self.__class__.__name__, quantity, 
                    type(quantity).__name__
                )
            )

如果给定项目的“数量”为零,则将删除该问题中的项目,而不是留下本质上是订单中给定产品的零件的情况:

 if quantity != 0:
     self._items[oid] = quantity
 else:
     try:
        del self._items[oid]
     except KeyError:
         pass

数据字典生成主动将实例的项目转换为具有字符串值键的字典,而不是UUID对象,但在其他方面基本上与迄今为止编写的实现相当典型。

    def to_data_dict(self) -> (dict,):
        return {
            # - Local properties
            'name':self.name,
            'street_address':self.street_address,
            'building_address':self.building_address,
            'city':self.city,
            'region':self.region,
            'postal_code':self.postal_code,
            'country':self.country,
            # - Generate a string:int dict from the UUID:int dict
            'items':dict(
                [
                    (str(key), int(self.items[key])) 
                    for key in self.items.keys()
                ]
            ),
            # - Properties from BaseDataObject (through 
            #   JSONFileDataObject)
            '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),        }

_load_objectsfrom_data_dict类方法与先前代码中使用的方法相同。Address类的standard_address方法不能保持原样,因为它被Order继承,任何调用它的尝试都会导致错误 - 它不会有新的必需的name参数 - 因此它被覆盖为一个新的类方法,几乎具有相同的参数集(添加name),可以用来生成一个新的Order实例,没有添加任何项目,但所有其他相关信息都有。

    ###################################
    # Class methods                   #
    ###################################

    @classmethod
    def standard_address(cls, 
            name:(str,), street_address:(str,), 
            building_address:(str,None), city:(str,), 
            region:(str,None), postal_code:(str,None), 
            country:(str,None)
        ):
        return cls(
            name=name, street_address=street_address, city=city,
            building_address=building_address, region=region, 
            postal_code=postal_code, country=country
        )

这些数据存储操作的结果可以在文件系统中看到:

除非迭代后期由单元测试引发任何更正或更改,否则这就是 Artisan Application 中所有具有任何预期需要持久化数据的类。通过创建每个类的最小数据实例对数据持久化功能进行基本测试,显示它们确实将 JSON 数据写入预期位置,并且写入的数据至少在表面上是正确的。仍然需要进行详细的单元测试,以确保数据确实被准确地写入和检索,而不会丢失或损坏,但这些对象的主要开发工作已经完成。

这些具体类与hms_core等价类之间的关系有所改变,Order作为一个类不再附加到hms_core..BaseOrder,并且在 Artisan Application 级别删除了Customer类:

结构化 JSON 数据中的基础数据存储也可以被重新用于提供对某种远程 API 的数据访问和 CRUD 操作。例如,一个 RESTful/JSON web 服务,返回相同的 JSON 结构或接受它们作为创建和更新请求的有效载荷,几乎可以肯定地在大多数情况下使用这些对象,只需要进行一点修改。如果这个系统要比本书中所述的更进一步,这种方法可能值得考虑。

总结

尽管仍需要进行彻底测试,这将在第十四章中进行,测试数据持久性,但基于基于 JSON 的数据文件持久性的初步测试目前看来相当可靠。通过BaseDataObject需要的 CRUD 操作,通过JSONFileDataObject传递给所有具体数据对象,都已经存在并且运行良好。Order类结构的更改可能会引起对原始设计的一些担忧,但处理起来并不困难。这种变化应该在迭代的批准过程中特别指出,因为它代表对原始设计的改变,但目前看来并不会引起任何重大担忧。

一个数据持久化机制完成后,当概念仍然新鲜时,是时候看看由真实数据库引擎支持的等效过程,用于中央办公应用程序和服务。

第十三章:将数据持久化到数据库

在 Artisan 应用程序的基于文件系统的数据持久化完成后,现在是时候将注意力转向系统中央办公室端的等效部分了。我们将重用之前定义的BaseDataObject ABC,以确保所有数据对象功能可以以相同的方式调用(例如,使用get方法读取数据和save写入数据),但由于底层数据存储过程在实现上有很大不同,这就是大部分相似之处的结束。我们还需要决定要使用哪种数据库选项。

本章将涵盖以下主题:

  • 深入分析数据库选项并选择用于数据对象持久化的数据库引擎

  • 为在中央办公室运行的代码定义数据访问策略

  • 设计和实现一些支持数据访问和持久化的支持类

  • 实现中央办公室所需的具体数据对象:

  • Artisan

  • 产品

还有一些数据访问方面的考虑,将推迟至少一些具体的实现,并将详细讨论。

Artisan Gateway 和 Central Office 应用程序对象

Artisan Gateway 和 Central Office 应用程序都需要项目结构,这样我们就有了一个放置特定于它们各自的代码的地方。这个需求包含在两个故事中:

  • 作为开发人员,我需要一个 Central Office 应用程序的项目,这样我就有了一个放置相关代码和构建应用程序的地方

  • 作为开发人员,我需要一个 Artisan Gateway 的项目,这样我就有了一个放置相关代码和构建服务的地方

上述结构可以从基本项目模板开始,如下所示:

随着 Artisan Gateway 和 Central Office 应用程序中业务对象的数据持久化功能的构建,可以添加更多的模块,就像在 Artisan 应用程序的项目结构中一样。选择数据存储引擎可能会对此产生重大影响,但目前来看,这应该足够了。

选择后端数据存储引擎

驱动 Artisan Gateway 和 Central Office 应用程序后端数据存储引擎选择的故事实际上并不强制使用任何特定的引擎,只是需要该引擎提供以下内容:

  • 作为 HMS 中央办公室的业务对象数据的消费者,我需要业务对象数据存储在共享数据存储中,以便数据可以被多个消费者同时访问,并具有事务支持/保护,并且他们需要访问数据。

在现实世界的情况下,可能会有特定的数据库引擎是允许的、鼓励的或不允许的,这取决于许多因素,例如系统管理员愿意安装和支持的内容;根据企业使用的操作系统,可用的选项;以及可能的其他外部因素。还可能存在开发约束;也许首选的数据库在所使用的语言中没有可靠的驱动程序/库,或者数据结构要求直接影响了可行的选项。

另一个考虑因素,也在前述情景中有所体现,就是数据的访问方式(本地与网络访问)。在这种情况下,由于多个用户可以同时访问系统的数据,拥有一个可以通过内部网络访问的中央数据库(无论是哪种类型)是最简单的解决方案,从许多方面来看:

  • 它将依赖于可独立安装的数据库引擎。

  • 这些作为预打包安装的引擎,不需要开发人员努力创建或维护。

  • 它们的功能可以在外部进行测试,因此可以信任其按预期行为;因此,开发不必测试引擎,而只需与其交互。

综合考虑这些因素,可以选择以下几种选项之一;标准的基于 SQL 的关系型数据库管理系统可以工作,许多可用的 NoSQL 数据库引擎也可以。

另一个要考虑的因素是对象数据结构在各种数据库选项中的表示方式。简单对象,例如hms_core中的Address,可以在任何关系型数据库管理系统中以单个表格轻松表示。更复杂的对象,例如带有其嵌入式AddressArtisan,或具有可变大小和可变内容属性数据(metadata)的Product,要么需要为相关属性创建离散表(并定义关系,以便可以检索对象的相关属性),要么需要支持动态结构化数据。

由于它们将在典型的关系型数据库管理系统实现中构建,因此关系非常简单;每个Artisan都有一个地址,每个Product都有零到多个metadata项,看起来类似于以下内容:

当我们考虑如何实现不同的数据检索过程时,就会出现复杂性,使用BaseDataObject.get类方法的可能排列,并假设真正的工作发生在数据库引擎的一侧:

  • 获取一个Artisan及其address,或一个Product及其metadata,并不太复杂;假设一个oid值,它归结为以下变化:

  • 获取与oid匹配的工匠或产品记录,然后将其转换为dict,以便我们可以使用from_data_dict类方法创建实例

  • 对于Artisan:获取相关的address记录,将其转换为dict,并将其插入到作为address创建的第一个dict

  • 对于Product:获取相关的metadata记录,将返回的记录转换为键/值dict,并将其插入到作为metadata创建的第一个dict

  • 通过调用适当的from_data_dict类方法创建实例。

  • 基于仅oid值列表获取多个实例并没有太大的不同;它只是从检索具有匹配oid值的所有记录开始,然后整理数据并创建并返回实例列表。实际上,如果此过程和单个oid过程使用相同的代码,对于单个oid返回一个(或零)对象(如果没有匹配的oid则不返回结果),那么使用起来并不会太糟糕。

  • 仅基于一个本地criteria值获取零到多个实例——仅通过company_namename找到ArtisanProduct,本身也并不困难。数据库操作的实际过程与纯oid基础的检索有很大不同,如下所示:

  • 您可以根据传递的criteria找到所有匹配项,并跟踪每个匹配项的oid

  • 然后,您返回由这些oid值标识的项目

  • 通过addressmetadata值查找项目类似,但它会从子表格获取结果的初始oid值列表。

  • 从单个表格、父表格或子表格获取多个criteria值,是另一个必须处理的排列。

  • 另一个排列是从父表格和子表格中获取criteria值在同一个条件集中。

前面的列表显示了六种不同的变化,假设BaseDataObject.get的意图得到了尊重。这些并没有解决如何跨相关表处理数据的更新(或删除)的问题,这增加了更多的复杂性。

虽然可能可以在数据库端的 SQL 中实现它们所有,但这样的实现将会很复杂。如果开发人员不是非常有经验的数据库管理员,这可能根本不可行;即使是,它仍然是一个复杂的解决方案,带有所有随之而来的潜在风险。

一个可以很容易实现的权衡方法,但会增加更多的处理时间和/或内存使用,类似于 Artisan 应用程序中采用的方法:加载所有调用BaseDataObject.get的对象,然后在代码中对结果进行排序。随着涉及的数据集增长,检索和发送回的数据将增长,并且需要有用地检索数据的时间不仅仅是一个简单的“获取任何这些oid值的对象”请求将需要更长的时间在数据库中找到并传输到应用程序。足够的时间或足够的数据,它将开始遭受可扩展性问题。这种方法可能是可行的,它可能会起作用(如果有限的时间),只要多表更新和子记录的删除可以以某种方式进行管理。事情的更新方面可能纯粹由应用程序代码进行管理,相关记录的删除可以在数据库端或应用程序代码中进行管理。

另一个仍处于基于 RDBMS 的解决方案领域的选项是使用支持结构化但无模式的数据的引擎;例如,MySQL 和 MariaDB 具有 JSON 字段类型,可以使用非常简单的表结构表示整个 Artisan 和 Product 记录,如下所示:

只要这些 JSON 字段允许对其内部的数据结构执行查询,BaseDataObject.get需要提供的所有选项都得到支持,而无需担心管理子表。在所有实际目的上,这种特定方法基本上涉及使用 MySQL 替代文档存储 NoSQL 数据库(如 MongoDB),但没有文档存储数据库可能已经具有的一些功能。

综上所述,这对于基于 RDBMS 的数据存储来说可能是一种被认为不利的复杂性。然而,也有一些优点,即使乍一看可能不那么重要。RDBMS 数据存储通常允许一次执行多个查询。因此,从多个表中检索数据所涉及的多个查询可以编写为多个查询语句,作为对引擎的单个调用执行。

大多数基于 SQL 的数据库还允许编写某种预编译/准备好的功能:存储过程或用户函数;视图;也许还有其他构造,可以将大量功能块移出应用程序代码并移到数据库中。这些通常更快执行,尽管 SQL 可能不支持广泛的功能(即使在过程和函数中),但可能有足够的可用性使其使用值得。最后,也许最重要的是,表的强制数据结构,再加上任何名副其实的 RDBMS 的关系能力,允许在需要时查询系统中的任何数据,同时在合理设计的数据库中强制执行所有系统数据的数据完整性。

如果选择基于 SQL 的 RDBMS 作为对象状态数据持久性的引擎,使用该引擎来持久化其状态数据的类将需要指定以下属性中的一些(或全部)。

  • 主机规范:数据库所在的主机名(FQDN、机器网络名称或 IP 地址)

  • 数据库名称:指定的主机上将读取和写入状态数据的数据库的名称

  • user:这将用于连接到主机上的数据库

  • password:这将用于连接到主机上的数据库

实例还需要能够连接到数据库,这可以通过一个方法(也许是get_connection)或一个属性(connection,可以懒惰地实例化,并编写成当需要时可以删除并重新创建活动的connection)。一旦建立了连接,它还需要一个方法来对数据库执行查询(也许是query)。如果这看起来很熟悉,那是因为这正是之前提到的BaseDatabaseConnector类的确切结构。

在 NoSQL 方面,所有标准的 NoSQL 优势都适用,如下所示:

  • 由于数据库中没有涉及硬性的表结构,因此在存储的数据结构发生变化时,不需要花费大量的开发时间。一旦应用程序端的数据结构发生了变化,任何新的或更新的记录在保存时都将进行调整。

  • 大多数 NoSQL 选项已经具有处理BaseDataObject.get所承诺提供的数据检索类型的功能,并且在传统的 RDBMS 解决方案中具有潜在的复杂性。这可能会导致开发时间更少,代码更简单,这两者都是好事。

  • 数据写入(创建和更新)过程也将更容易实现,因为在基于 RDBMS 的方法中需要单独的表或不寻常的数据结构的关系实际上消失了,数据写入可以一次存储整个数据结构,而不必担心子表中的故障会阻止父表的写入。

在这两个选项中,NoSQL 选项似乎更容易管理,同时仍能满足数据持久化故事的所有要求。在各种 NoSQL 选项中,MongoDB 似乎需要对数据结构进行最少的更改,因为对象数据是从数据库中读取和写入的;因此,MongoDB 将是我们将使用的后端数据存储引擎。

Central Office 项目的数据访问策略

选择了数据库引擎之后,需要做出的另一个决定是该引擎最终将在 Artisan Gateway 和 Central Office 应用程序的哪个位置存储。这两者都需要能够从相同的位置读取和写入相同的数据。由于 MongoDB 可以跨网络使用,数据存储可以几乎放在任何可以通过该网络访问的地方(甚至可以放在两个组件中的一个机器上)。

因此,Artisan Gateway、多个 Central Office 应用程序实例和hms_sys数据库之间的逻辑架构视角将如下图所示(允许任意数量的应用程序实例,但仅显示三个):

从开发的角度来看,物理架构并不那么重要,只要每个逻辑组件都有一个可以轻松识别的物理位置。在开发过程中,所有这些物理位置都可以在开发人员的本地计算机上。一旦部署,Artisan Gateway 服务和hms_sys数据库可能安装在不同的机器上,或者它们可能驻留在同一台机器上。这种安排将允许所有应用程序实例和服务共享公共数据,从它们可能存在的任何地方读取和写入hms_sys数据库。

支持数据持久化的对象

在生产系统中,几乎不可能不需要一些访问凭据,还有其他需要跟踪的参数,这些参数将在各种对象类型中保存在数据存储中。由于这些参数对于所有不同的对象类型(大部分情况下)都是通用的,创建一个可以用来收集它们的机制似乎是一个合乎逻辑的第一步。在 RDBMS 的探索中已经注意到了可能需要的通用参数,如下所示:

  • host

  • port

  • database

  • user

  • password

hms_sys部署到生产环境时,这些几乎肯定会保存在某种配置文件中,现在就把这个逻辑放在那里,而不是等到以后再做。所有数据存储配置和连接参数可以在一个单独的对象实例中捕获 - 一个DatastoreConfig

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属性之外,它只允许int值从065535(TCP/IP 连接中有效端口的正常范围),在属性的获取器、设置器和删除器方法中没有什么实质性的新内容。_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 数据库进行读写数据需要一些步骤,如下所示:

  1. 必须建立到 Mongo 引擎的连接(使用pymongo.MongoClient对象)。这是实际凭据(用户名和密码)将应用的地方,如果 Mongo 引擎需要它们的话。连接(或客户端)允许指定…

  2. 必须指定数据存储的数据库。配置中的database值负责指定数据库的名称,一旦由客户端/连接返回,数据库本身,一个pymongo.database.Database对象,允许创建…

  3. 实际文档(记录)所在的集合(一个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

在这一点上,作为 Mongo Collectionobjects对象提供了用于在Objects集合/表中读取、写入和删除文档/记录的方法。

集合中文档的组织可以是非常任意的。objects集合可以用来存储ArtisanProductOrder状态数据文档,所有在同一个集合中。没有功能上的原因阻止这样做。然而,随着时间的推移,从该集合中读取数据的速度会比从将这些ArtisanProductOrder状态数据文档分组到单独的集合中的集合中读取的速度要慢得多 - 每种对象类型一个集合。可能还有其他考虑因素会使这样的分组有益。将相同类型的对象保存在一起可能会使通过 GUI 工具更容易管理它们,并且对于命令行管理工具也可能有类似的好处。

考虑到所有前述因素,hms_sys数据存储中对象之间数据存储和参数的相对最佳集成应包括以下内容:

  • 一个或多个客户端连接到一个共同的 MongoDB 实例,其凭据和参数都是可配置的,并最终由配置文件控制

  • 中央办公室代码库中所有对象的一个通用数据库规范,与客户端设置使用的相同配置

  • 每个对象类型的一个集合规范,可以简单地使用类的名称

在做出所有这些决定之后,我们可以创建一个 ABC,中央办公室应用程序和服务对象可以从中派生,方式与 Artisan 应用程序数据对象从JSONFileDataObject派生的方式类似,就像我们在第十二章中看到的那样,称之为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 = None

当创建 MongoDB 文档时,它们会有一个_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 = None

HMSMongoDataObject的属性看起来乍一看相对正常,但有一个重要的区别可能一开始并不明显。由于任何给定类的数据访问都集中在该类的实例上,并且创建数据库连接和集合可能是计算密集型的,拥有所有数据对象类的单个连接是一个诱人的想法 - 该实现将使实例级的connectiondatabase属性的底层存储属性成为HMSMongoDataObject的成员,而不是派生类本身或这些类的实例。

实际上,这将要求hms_sys的所有数据对象都驻留在同一个数据库中,并且始终通过相同的 MongoDB 实例访问。虽然这并不是一个不合理的要求,但可能会使移动实时系统数据变得棘手。整个系统可能需要关闭以进行此类数据移动。作为一种妥协,每个类的connectiondatabase属性将成为该类的成员,而不是该类的成员 - 例如,这将允许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

为了删除,collectionconnectiondatabase属性也有不同的处理方式。由 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 方法,并且配置属性进一步采取了这一步 - 它是只读的:

    ###################################
    # 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方法并不是必需的。MongoDB 与之前使用的 JSON 文件方法一样,不区分创建和更新文档。两个过程都只是将所有对象数据写入文档,必要时创建文档。由于它们是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及其databaseconnection祖先支持的save的实现非常简单。我们需要获取实例的data_dict并告诉 MongoDB 连接insert该数据。这个过程中的一个复杂因素是之前提到的标准 MongoDB_id值。如果我们仅仅调用insert,那么 MongoDB 引擎将没有_id值用于标识已经存在的文档是否实际存在。这将不可避免地导致在每次更新时为现有项目创建新的文档记录(而不是替换现有文档),从而在每次更新时污染数据,使其包含过时的实例。

在正常情况下,最简单的解决方案是在数据写入过程中将oid属性更改为_id,并在数据读取过程中将_id更改回oid,或者简单地将到目前为止已经建立的oid属性更改为类中已定义的_id。第一种选项只需要在每个to_data_dictfrom_data_dict方法中稍微努力一下,包括已经在Artisan数据对象中定义的方法,但它往往更容易出错,而且需要额外的测试。这是一个可行的选择,但可能不是最好的选择。全面更改oid属性的名称为_id会更简单(实际上只是一个大规模的搜索和替换操作),但这将使类具有看起来像是受保护的属性名称,实际上是一个公共属性。从功能上讲,这并不是什么大问题,但它违反了 Python 代码标准,也不是一个首选选项。

另一个选择是简单地确保hms_sys oid属性和 MongoDB 生成的_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

由于所有与数据存储交互的类方法都需要相关的连接,并且在调用之前可能还没有被实例创建,因此有一个辅助类方法来获取连接将是有用的。也可以通过创建一个实例来强制获取所有相关的数据存储对象,但这感觉很麻烦和违反直觉:

    @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._collection

delete类方法的实现非常简单;它归结为遍历提供的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)

失败检查_data_dict_keys的结果是一个AttributeError,其中包括类的__init__方法的参数列表,使用inspect模块的getfullargspec函数。Python 的inspect模块提供了一套非常全面的函数,用于检查正在运行的代码。当我们开始研究元编程概念时,我们将更深入地研究该模块。

HMSMongoDataObjectget方法也是通过确保相关的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.

我们不会尝试为pymongofind功能动态生成包括oidscriteria的参数(可能是复杂的机制),我们将根据存在的oidscriteria的组合来处理请求。代码中的每个分支将导致一个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)

如果同时提供了oidscriteria,则最终的对象列表将需要使用matches方法进行过滤,因此必须监视和跟踪criteria的存在。如果同时提供了oidscriteria,那么我们将需要在以后知道这一点,以便过滤初始结果:

            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)
            ]

如果未传递oidscriteria,那么我们将希望返回所有可用的内容,如下所示:

        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_coreHMSMongoDataObject中的相应Base类派生即可:

  1. 创建和更新操作仍然只需调用任何实例的save方法即可完成。

  2. 读取操作由get类方法处理,该方法还允许对查找对象进行相当多的功能,尽管以后可能需要支持更复杂功能的附加功能。

  3. 删除操作由delete类方法处理;同样,可能会有基于oid以外的删除功能的需求,但目前这样就足够了。

RDBMS 实现

到目前为止,我们创建的两个数据对象实现都覆盖了BaseDataObject中所需的_create_update方法。在这种情况下,对于为什么要放置它们,可以提出质疑。对这个问题的简短回答是,到目前为止,已经合并在一起的两个实现在数据存储级别上使用了相同的过程来创建和更新记录和文档。因此,它们根本不需要。如果预期hms_sys永远不需要任何其他数据库后端,我们有理由从整个代码库中删除它们。

然而,如果使用 MongoDB 的决定走向不同的方向,并且首选(或强制)的后端数据存储引擎是诸如 Microsoft SQL Server 之类的 RDBMS,那会发生什么呢?或者更糟糕的是,如果在系统运行之后强制进行这种改变会发生什么呢?

暂时搁置数据迁移规划,专注于应用程序和服务代码,这种改变需要什么?实际上并不需要太多。对于给定的 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.
"""

这里显示的HMSSQLDataObject类绝不是完整的,但应该作为构建这样一个类的完整实现的合理起点,该类连接到并使用来自多个 RDBM 系统的数据。完整的代码可以在项目代码的hms_core/ch-10-snippets目录中找到。

相同的_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 方法中connectiondatabase的等效部分,因此只需要跟踪和作为属性可用的是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

显著的、实质性的差异主要在处理 CRUD 操作的方法中。原始的BaseDataObject中实现的save方法保留在原地,并且将调用_create_update方法,由实例的is_dirtyis_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)

清理 SQL 是非常重要的安全预防措施,减少系统容易受到 SQL 注入攻击的风险。这些攻击最少会危害数据的保密性和完整性,还可能提高认证和授权的风险,甚至可能跨多个系统,具体取决于密码策略及其执行。大多数 RDBMS API 在执行 SQL 之前都会有一些机制来清理 SQL,有些还支持查询参数化,这也可以减少漏洞的风险。基本的经验法则是,如果用户提供的数据被传递到查询中,甚至传递到存储过程中,无论何时何地都应该尽可能地进行清理。

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 = True

criteria分支如下:

        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...
)

如果引擎或引擎的 Python API 提供了将返回的行转换为字典实例的内置机制,那可能是首选的方法。如果没有内置处理它的任何内容,将嵌套元组转换为一系列字典并不难做到:

        # - 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
        ]

从这一点开始,过程基本上与以前的实现一样,在JSONFileDataObjectHMSMongoDataObject中:

        # - 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作为条件。这将允许进行以下过程,用于检索和附加任何需要的子对象(以ArtisanProduct类为例):

        # - 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派生的最终业务/数据对象类的其他成员,大部分现在应该是预期的,因为它们也是从另外两个DataObject ABC 派生的最终数据对象的实现所需的。它们将包括to_data_dictmatches实例方法的具体实现以及from_data_dict类方法,以及各种类特定变量(主要是_sql类属性)。

中央办公室项目的具体业务对象

到目前为止,关于基础设施已经付出了很多努力,但是随着最初的中央办公室类的创建开始,这些努力即将得到回报。目前,由于假设中央办公室应用程序和 Artisan 网关服务将使用相同的业务对象类,并且它们需要驻留在一个不是任何这些代码库的一部分的公共软件包中,它们应该驻留的最佳选择似乎是hms_core组件项目:

  • hms_core已经在设计计划中作为所有其他软件包的构建或部署的一部分

  • 虽然肯定可以创建另一个专门用于这些具体类将提供的数据访问的组件项目/包,但对于可能只是一个单一模块,只有三个类(到目前为止),这是相当多的开销

如果将来有需要或愿望将它们移动到不同的软件包/项目中——比如,如果决定将中央办公室应用程序的数据访问更改为对 Artisan 网关的网络服务调用,将相应地移动代码不难,尽管有些乏味。

通过立即深入其中一个具体类,很可能更容易理解基础工作将如何得到回报,因此我们现在将这样做,从hms_core.co_objects.Artisan开始。

hms_core.co_objects.Artisan

推动具体的、状态数据持久化Artisan类的故事如下:

  • 作为 Artisan 经理,我需要能够管理(创建、修改和删除)系统中的工匠,以便保持其状态和信息的最新。

hms_artisan相同,这是关于能够管理数据,而不是围绕数据管理过程的 UI。co_objects中任何数据对象的各种移动部分将涉及以下操作:

  • 对象类型的属性,将源自hms_core.business_objects中相应的Base

  • 系统中所有数据对象的数据持久化相关属性,由HMSMongoDataObject或其父类BaseDataObject提供或需要

  • 从具体类继承的任何抽象成员的具体实现,从它派生的任何类中继承

以具体的Artisan类为例,涉及的关系如下图所示:

在这种特殊情况下,只有一个属性(需要从HMSMongoDataObject覆盖的_data_dict_keys类属性)需要创建。四个实例方法中的三个(add_productremove_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),
        }

回顾来看,也许提供每个类的一个方法或属性来负责生成它们的一部分最终data_dict可能是更好的设计。这将使生成这些字典项的代码保持在一个地方,至少可以从所有实例的父类值中组装最终的data_dict值。

“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)

总共有七个项目需要具体实现,只有两个项目不能通过调用父类的等效方法或编写非常简单的代码来管理,实现起来相当轻松。

hms_core.co_objects.Product

具体Product对象数据持久性的相应故事如下:

  • 作为产品经理,我需要能够在系统中管理产品,以便保持其状态和信息的最新状态。

实现这种情况的代码甚至比Artisan对象的代码更简单;它不需要对对象属性进行任何特殊处理,因此from_data_dict可以简单地回退到在HMSMongoDataObject中定义的默认值。它也不需要任何额外的必需方法,因此一个完整的、功能性的实现实际上只需要_data_dict_keys类属性和__init__matchesto_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 needed

matchesto_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 时,因为虽然它对大多数情况都适用,但目前不允许使用任何元数据标准返回结果,除非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,并允许标准规范尽可能详细。例如,我们可以添加其他元数据,指定产品名称,或添加出现在Productdata-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 连接”对象提供的find()方法将这种类型的点符号规范视为对一个嵌套对象结构的引用,该结构看起来很像先前显示的dict值,并将相应地处理请求。

其他 hms_core.co_objects 类

在这个迭代中,可能会有关于CustomerOrder对象的故事和任务来处理数据持久性,这些故事可能会采用与ArtisanProduct对象相同的基本形式,看起来类似于以下Order示例:

  • 作为订单经理,我需要能够管理系统中的订单,以便保持其状态和信息的最新。

为了做到这一点,我会做以下事情:

  • 为 Central Office 数据存储设计和实现一个Order类,允许对象数据持久化。

  • Order类进行单元测试。

通常,在敏捷的迭代过程中,故事在被包含在迭代中之前必须被接受,并且其被接受的过程涉及足够的审查和分析,以达到对涉及的任务的充分理解,并相应地编写和计划故事和任务。然而,在这种情况下,由于对外部系统(Web Store Application)和尚未详细说明的订单接受和处理工作流程有重大依赖,除了对CustomerOrder类进行基本实现之外,几乎没有什么可以做的。特别是工作流程将在某种程度上取决于工匠需要的数据结构,而这在本次迭代之前尚未定义。

基于上述所有原因,本次迭代中没有处理这些对象及其数据持久性的故事。Artisan Gateway 和/或 Central Office 应用程序创建的最终类的数据持久性方面将作为实现订单处理工作流程的故事的一部分来处理。与此同时,我们可以至少在单独的文件中(在本章的代码中的future/co_objects.py中)为这些类的最低结构制作存根,以便在我们的记忆中保存一些努力。

考虑其他 CRUD 操作

到目前为止,我们只考虑了所有数据对象需要的两个 CRUD 操作:createread。删除操作已经被考虑,但尚未被证明;然而,由于该过程非常简单,可以等到我们对所有内容进行单元测试,以证明一切正常。那么缺失的部分就是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_newis_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 Application 的数据持久化一样,我们已经考虑(如果不是证明)了中央办公室代码库中存储的数据对象的所有 CRUD 操作要求。因为接口要求也由相同的BaseDataObject继承定义,即使在该 ABC 和具体数据对象之间提供了额外功能,所有数据对象的读取和写入过程在整个系统中看起来都是相同的 - 至少目前是这样。

尽管数据访问尚未进行单元测试,但这对于系统来说是一个关键问题;归根结底,数据是系统中最重要的部分,如果不是最重要的部分,那么肯定也是其中最重要的部分之一。因此,现在是时候改变上下文并编写这些单元测试了,我们将在下一章中进行。

第十四章:数据持久性测试

代码的可重复单元测试在数据持久性的情况下很少比这更重要。代码可能随着时间的推移而发生变化或被替换,甚至可能完全更改为完全不同的系统,用完全不同的语言编写,但一旦数据存在,它可能比使用它的任何代码库都更持久。可以说,系统中的数据通常存在真正的业务价值,因此对与其交互的过程进行测试,并有可能破坏该价值的过程进行测试非常重要。

考虑到这一点,本章的大部分内容将集中在以下内容上:

  • 编写本次迭代中创建的数据对象和相关类的单元测试:

  • 新的hms_artisan

  • 新的hms_core

  • 将这些测试与构建过程集成

还添加了足够多的新功能,因此必须对以下内容进行一些注意:

  • 新代码对构建过程的其他影响

  • 演示新代码以及如何促进相关故事的验收

  • 新代码如何影响操作、使用、维护和停用的关注点

编写单元测试

编写新数据对象类的单元测试的大部分过程可以简单地遵循之前迭代中建立的过程:

  1. 为正在测试的包创建顶级测试模块。

  2. 识别正在测试的包的子模块,并为每个创建相应的测试模块。

  3. 将子测试模块的引用添加到包测试模块中并导入它们的测试。

  4. 对于每个子测试模块:

  • 执行模块并为每个报告为缺失的项目创建测试用例类

  • 执行模块并为每个报告为缺失的成员(属性或方法)创建测试方法

需要创建几个测试模块,每个模块对应本次迭代中涉及的项目的src目录中创建的每个模块,得到以下结果:

  • hms_core/../data_objects.py → test_hms_core/test_data_objects.py(已经测试过,但为了完整起见列在这里)

  • hms_artisan/../data_storage.py → test_hms_artisan/test_data_storage.py

  • hms_artisan/../artisan_objects.py → test_hms_artisan/test_artisan_objects.py

  • hms_core/../co_objects.py → test_hms_core/test_co_objects.py

测试hms_artisan.data_storage

此时,hms_artisan.data_storage的单元测试都与测试JSONFileDataStore类有关。由于该类实际上所做的事情,通常的单元测试模式不适用,甚至根本不适用。它没有要测试的属性,而且可以测试的一个类属性(_file_store_dir)被派生类覆盖。

值得肯定的是,默认属性是否符合预期,因为如果它不默认为None,可能会导致派生类和这些类的实例失败:

def test_file_store_dir(self):
    self.assertEqual(
        JSONFileDataObject._file_store_dir, None, 
        'JSONFileDataObject._file_store_dir is expected to provide '
        'a None default value that must be overridden by derived '
        'classes, but it is set to "%s" (%s)' % 
        (
            JSONFileDataObject._file_store_dir, 
            type(JSONFileDataObject._file_store_dir).__name__
        )
    )

就方法的测试而言,虽然有几种方法,但它们有些相互交织,并且它们经常依赖于抽象方法的实现,这些抽象方法本身不在 ABC 中,因此无法使用:

  • getdeletesave都调用_load_objects辅助类方法

  • _load_objects方法依赖于from_data_dict的具体实现,以生成其他方法所引用的对象集合

  • save方法还需要to_data_dict方法的具体实现

由于单元测试是关于证明可预测功能,因此问题变成了:我们能证明什么?

第一个,也可能是最明显的项目是对象初始化的工作方式与BaseDataObject中的工作方式几乎相同:

class testJSONFileDataObject(unittest.TestCase):

    ###################################
    # Tests of class methods          #
    ###################################

    def test__init__(self):
        # Tests the __init__ method of the JSONFileDataObject class
        # - All we need to do here is prove that the various 
        #   setter- and deleter-method calls are operating as 
        #   expected -- same as BaseDataObject
        # - deleters first
        test_object = JSONFileDataObjectDerived()
        self.assertEquals(test_object._created, None)
        self.assertEquals(test_object._is_active, True)
        self.assertEquals(test_object._is_deleted, False)
        self.assertEquals(test_object._is_dirty, False)
        self.assertEquals(test_object._is_new, True)
        self.assertEquals(test_object._modified, None)
        self.assertEquals(test_object._oid, None)
        # - setters
        oid = uuid4()
        created = GoodDateTimes[0]
        modified = GoodDateTimes[1]
        is_active = False
        is_deleted = True
        is_dirty = True
        is_new = False
        test_object = JSONFileDataObjectDerived(
            oid, created, modified, is_active, is_deleted, 
            is_dirty, is_new
        )
        self.assertEquals(test_object.oid, oid)
        self.assertEquals(test_object.created, created)
        self.assertEquals(test_object.is_active, is_active)
        self.assertEquals(test_object.is_deleted, is_deleted)
        self.assertEquals(test_object.is_dirty, is_dirty)
        self.assertEquals(test_object.is_new, is_new)
        self.assertEquals(test_object.modified, modified)

GoodDateTimes的单元测试值与我们用来测试BaseDataObject的值相同。

由于_create_update方法将不会被使用,我们可以证明当调用它们时它们会引发预期的错误:

def test_create(self):
   # Tests the _create method of the JSONFileDataObject class
     test_object = JSONFileDataObjectDerived()
       try:
         test_object._create()
         self.fail(
           'JSONFileDataObject is not expected to raise '
            'NotImplementedError on a call to _create'
          )
        except NotImplementedError:
            pass
        except Exception as error:
            self.fail(
                'JSONFileDataObject is not expected to raise '
                'NotImplementedError on a call to _create, but %s '
                'was raised instead:\n - %s' %
                (error.__class__.__name__, error)
            )

def test_update(self):
   # Tests the _update method of the JSONFileDataObject class
   test_object = JSONFileDataObjectDerived()
     try:
         test_object._update()
         self.fail(
            'JSONFileDataObject is not expected to raise '
            'NotImplementedError on a call to _update'
          )
      except NotImplementedError:
         pass
      except Exception as error:
         self.fail(
             'JSONFileDataObject is not expected to raise '
             'NotImplementedError on a call to _update, but %s '
             'was raised instead:\n - %s' %
             (error.__class__.__name__, error)
          )

由于单独的 CRUD 操作以及_load_objects方法是相互关联的,它们之间会有很多重叠——对一个方法的测试将不得不执行其他方法的测试作为自己的测试过程的一部分,以真正证明一切都按预期工作。编写这种复杂的测试是乏味的,但更重要的是,需要更多的努力和纪律来维护,因此更容易脱离正在测试的代码。在这种情况下,更好的选择可能是跳过这些测试,并创建一个更大、统一的测试来测试所有相关功能。Python 的标准unittest模块提供了一个skip装饰器函数,可以标记要在标准单元测试运行中跳过的测试,并调用它需要记录跳过测试的原因。在这种情况下,原因是所有相关方法将在不同的测试方法中进行一次大规模的测试:

@unittest.skip(
    'Since the file-load process provided by _load_objects is '
    'used by many of the CRUD operations, it is tested  as part of '
    'testCRUDOperations'
  )
def test_load_objects(self):
    # Tests the _load_objects method of the JSONFileDataObject class
      self.fail('test_load_objects is not yet implemented')

@unittest.skip(
    'Since deleting a data-file is part of the CRUD operations, '
    'it is tested as part of testCRUDOperations'
  )
def testdelete(self):
    # Tests the delete method of the JSONFileDataObject class
      self.fail('testdelete is not yet implemented')

@unittest.skip(
    'Since reading data-files is part of the CRUD operations, '
    'it is tested as part of testCRUDOperations'
  )
def testget(self):
    # Tests the get method of the JSONFileDataObject class
    self.fail('testget is not yet implemented')

@unittest.skip(
    'Since creating a data-file is part of the CRUD operations, '
    'it is tested as part of testCRUDOperations'
  )
def testsave(self):
     # Tests the save method of the JSONFileDataObject class
     self.fail('testsave is not yet implemented')

这样,大部分JSONFileDataObject的测试责任都落在一个单独的测试方法上——这不是代码强制执行标准测试政策所必需的,但它代表了在个别类成员测试覆盖率和可维护性之间的最佳折衷:testCRUDOperations。在其中并没有太多机会进行优雅的处理;它必须通过大量的条件和对象状态来强行执行,这仅仅是因为正在测试的方法的性质。但是,如果它经过深思熟虑,它将使得从它派生的类的测试不必再测试常见功能。

它首先要做的是确保内存和文件系统中都有一个干净的对象存储库。为了做到这一点,必须定义一个一次性类,其中包含确保所有必要的方法类都被创建的最低限度的功能。这个类JSONFileDataObjectDerived看起来是这样的:

class JSONFileDataObjectDerived(JSONFileDataObject):

我们提供了一个文件存储位置,该位置没有被任何真实对象使用,可以随时删除并重新创建对象数据:

_file_store_dir = '/tmp/hms_artisan_test'

因为这些测试涉及文件系统数据持久性,它们是针对进行系统开发的操作系统编写的——一个 Linux 安装——尽管它们在任何类 Unix 操作系统上都可以执行而无需修改。将它们转换为在 Windows 下运行并不困难:

创建一个测试数据目录(例如C:\TestData),并将所有以/tmp/开头的文件系统引用更改为C:\\TestData\\(注意双反斜杠),并修改其余的文件系统路径以使用 Windows 的文件系统表示法(C:\\TestData\\path\\to\\some\\file.ext,再次注意双反斜杠)。

我们提供所需功能的最低限度,尽可能使用父类的默认或经过验证/可证明的功能,或者使用最简单的可能实现:

def matches(self, **criteria) -> (bool,):
   return BaseDataObject.matches(self, **criteria)

@classmethod
def from_data_dict(cls, data_dict:(dict,)):
   return cls(**data_dict)

在没有默认或可继承功能的情况下,我们保持最低限度的功能,以使测试具有意义——对于to_data_dict方法,这意味着坚持BaseDataObject的所有派生类所需的属性和数据结构:

def to_data_dict(self):
   return {
        '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),
        }

然后,让我们通过直接清除内存中的对象缓存,并删除存储位置中的任何文件,来开始testCRUDOperations测试方法:

def testCRUDOperations(self):
   # - First, assure that the class-level data-object collection 
   #   (in JSONFileDataObjectDerived._loaded_objects) is None, 
   #   and that the file-repository does not exist.
   JSONFileDataObjectDerived._loaded_objects = None
   if os.path.exists(JSONFileDataObjectDerived._file_store_dir):
      rmtree(JSONFileDataObjectDerived._file_store_dir)

rmtree函数来自一个名为shutils的 Python 包,它可以递归地从指定位置删除文件和子目录,并在目标位置不存在时引发错误。内置的os模块中的os.path.exists调用检查指定路径处的文件或目录是否存在,如果存在则返回True,否则返回False

我们至少需要一个对象存储在新清除的缓存中,以开始我们的测试过程,因此接下来是创建一个数据对象,并保存其状态数据:

# - Next, create an item and save it
first_object = JSONFileDataObjectDerived()
first_object.save()
# - Verify that the file exists where we're expecting it
self.assertTrue(
    os.path.exists(
         '/tmp/hms_artisan_test/JSONFileDataObjectDerived-'
         'data/%s.json' % first_object.oid
       )
    )
# - and that it exists in the in-memory cache
    self.assertNotEqual(
          JSONFileDataObjectDerived._loaded_objects.get(
            str(first_object.oid)
          ), None
    )

创建并保存一个对象后,我们可以验证数据写入和读取过程是否允许我们读取我们期望被写入的相同数据。我们可以利用类的matches方法,因为它最终是从BaseDataObject继承而来,并且之前已经经过测试。

由于matches使用to_data_dict生成的data dict,而其中不包括不持久的属性,比如is_dirtyis_new,这些需要单独检查:

# - Verify that the item can be retrieved, and has the same 
#   data
first_object_get = JSONFileDataObjectDerived.get()[0]
self.assertTrue(
      first_object.matches(**first_object_get.to_data_dict())
)
self.assertEqual(
      first_object.is_dirty, first_object_get.is_dirty
)
self.assertEqual(
      first_object.is_new, first_object_get.is_new
)

如果对使用matches作为数据结构验证过程有任何疑虑,一个可行的替代方案是显式检查检索到的对象的每个属性与原始属性相对应。使用matches只是一种便利,而不是必须的。

接下来,我们将检查确保多个对象被保存和读取如预期。由于文件和对象的键都是对象的oid的函数,而且我们现在知道文件和内存中的数据对象的创建与一个对象的创建有关,我们只需要确保多个对象不会出现任何问题。创建另外两个对象还允许我们稍后重新验证整个集合:

# - Create and save two more items
second_object = JSONFileDataObjectDerived()
second_object.save()
third_object = JSONFileDataObjectDerived()
third_object.save()
# - Verify that all three items can be retrieved, and that 
#   they are the expected objects, at least by their oids: 
#   Those, as part of the file-names, *will* be unique and 
#   distinct...
all_objects = JSONFileDataObjectDerived.get()
expected = set(
     [o.oid for o in [first_object, second_object, third_object]]
)
actual = set([o.oid for o in all_objects])
self.assertEqual(expected, actual)

我们还需要测试删除行为是否符合预期,从内存缓存中删除已删除的对象并删除适用的文件。在执行删除之前,我们需要确认要删除的文件是否存在,以避免删除执行后出现错误的测试结果:

# - Verify that the file for the second item exists, so the 
#   verification later of its deletion is a valid test
self.assertTrue(
    os.path.exists(
        '/tmp/hms_artisan_test/JSONFileDataObjectDerived-'
        'data/%s.json' % second_object.oid
     )
)

然后我们可以删除该项,并验证从内存和文件系统中的删除:

# - Delete the second item
JSONFileDataObjectDerived.delete(second_object.oid)
# - Verify that the item has been removed from the loaded-
#   object store and from the filesystem
self.assertEqual(
            JSONFileDataObjectDerived._loaded_objects.get(second_object.oid), 
            None
)
self.assertFalse(
os.path.exists(
        '/tmp/hms_artisan_test/JSONFileDataObjectDerived-'
        'data/%s.json' % second_object.oid
     )
 )

我们还需要验证更新状态数据的数据写入是否有效。我们可以通过更改现有对象的is_activeis_deleted标志,然后保存它,并检索其副本进行比较,并使用matches进行检查:

# - Update the last object created, and save it
third_object._set_is_active(False)
third_object._set_is_deleted(True)
third_object.save()
# - Read the updated object and verify that the changes made 
#   were saved to the file.
third_object_get = JSONFileDataObjectDerived.get(third_object.oid)[0]
self.assertEqual(
       third_object.to_data_dict(),
       third_object_get.to_data_dict()
     )
self.assertTrue(
       third_object.matches(**third_object_get.to_data_dict())
     )
self.assertEqual(
       third_object.is_dirty, third_object_get.is_dirty
     )
self.assertEqual(
       third_object.is_new, third_object_get.is_new
     )

如果以后可能向此测试用例类添加其他测试,并且为了清理不再需要的文件,我们将重复清除内存和磁盘对象存储的过程。如果以后为任何目的创建其他测试需要以特定状态开始内存和磁盘存储,它们将不得不安排设置该状态,但它们不必担心首先清除它:

# - Since other test-methods down the line might need to start 
#   with empty object- and file-sets, re-clear them both
JSONFileDataObjectDerived._loaded_objects = None
if os.path.exists(JSONFileDataObjectDerived._file_store_dir):
   rmtree(JSONFileDataObjectDerived._file_store_dir)
self.fail('testCRUDOperations is not complete')

原始的test_file_store_dir测试方法没有考虑到派生类不允许在没有设置为None以外的其他值的_file_store_dir类属性的情况下实例化自己。修改这一点,并使用另一个从JSONFileDataObject派生的类,这个类本质上是用于 CRUD 操作测试的JSONFileDataObjectDerived类的副本,但没有属性规范,允许将其作为原始测试方法的一部分进行测试:

###################################
# Tests of class properties       #
###################################

def test_file_store_dir(self):
  self.assertEqual(
      JSONFileDataObject._file_store_dir, None, 
      'JSONFileDataObject._file_store_dir is expected to provide '
      'a None default value that must be overridden by derived '
      'classes, but it is set to "%s" (%s)' % 
      (
           JSONFileDataObject._file_store_dir, 
           type(JSONFileDataObject._file_store_dir).__name__
      )
    )
    try:
       test_object = NoFileStoreDir()
       self.fail(
           'Classes derived from JSONFileDataObject are expected '
           'to define a _file_store_dir class-attribute, or cause '
           'instantiation of objects from classes that don\'t '
           'have one defined to fail with an AttributeError'
       )
     except AttributeError:
         pass

测试 hms_artisan.artisan_objects

初始单元测试设置完成后,需要实现 74 个测试,这主要是由于在hms_core中的Base对应类中覆盖了属性及其 setter 和 deleter 方法。由于属性及其重写方法之间的主要区别在于在设置或删除调用期间自动更改实例的is_dirty属性,因此在这个级别上与属性相关的测试可能需要关注的唯一事情可能是这个:

所有属性的测试都接近迄今为止使用的标准结构,基本上是验证每个属性是否具有适当的获取器、设置器和删除器方法关联。唯一的真正区别在于指定了哪些方法。例如,查看testArtisan.testcontact_name,它测试Artisan.contact_name,测试设置器和删除器方法的断言在结构上与BaseArtisan的测试相同——它们断言 Artisan 的设置器和删除器方法与属性的设置和删除操作相关联。

获取方法的断言是不同的地方:

def testcontact_name(self):
    # Tests the contact_name property of the Artisan class
    # - Assert that the getter is correct:
    self.assertEqual(
        BaseArtisan.contact_name.fget, 
        Artisan._get_contact_name, 
        'Artisan.contact_name is expected to use the '
        'BaseArtisan._get_contact_name method as its getter-method'
    )
    # - Assert that the setter is correct:
    self.assertEqual(
        Artisan.contact_name.fset, 
        Artisan._set_contact_name, 
        'Artisan.contact_name is expected to use the '
        '_set_contact_name method as its setter-method'
    )
    # - Assert that the deleter is correct:
    self.assertEqual(
        Artisan.contact_name.fdel, 
        Artisan._del_contact_name, 
        'Artisan.contact_name is expected to use the '
        '_del_contact_name method as its deleter-method'
    )

由于Artisan类为每个设置器和删除器方法提供了重写方法,但没有为获取器方法提供重写方法,因此属性的这一方面的断言指向原始获取器方法,即在BaseArtisan中定义并继承的方法。即使对于没有本地设置器或删除器方法的属性,例如Product.metadata,也是如此,它由testProduct.testmetadata测试:

def testmetadata(self):
    # Tests the metadata property of the Product class
    # - Assert that the getter is correct:
    self.assertEqual(
        Product.metadata.fget, 
        BaseProduct._get_metadata, 
        'Product.metadata is expected to use the '
        'BaseProduct._get_metadata method as its getter-method'
    )
    # - Assert that the setter is correct:
    self.assertEqual(
        Product.metadata.fset, 
        None, 
        'Product.metadata is expected to be read-only, with no setter'
    )
    # - Assert that the deleter is correct:
    self.assertEqual(
        Product.metadata.fdel, 
        None, 
        'Product.metadata is expected to be read-only, with no deleter'
    )

设置器和删除器方法本身的测试也可以非常简单,但有一个警告。如果基本假设是:

  • hms_core.business_objects中从Base类继承的所有属性都将被测试(就目前而言是真的)

  • 这些测试可以被信任,以证明这些属性在设置或删除时的可预测行为

  • 本地设置器和删除器方法将始终回调到它们的测试对应方法

然后,在测试本地方法时,需要检查它们是否相应地设置了is_dirty。然而,实际上可能没有任何方法来验证这些假设是否在单元测试集中发挥作用。这变成了一种知道这些项目是预期的、标准程序,并在开发新代码时保持这些程序的问题。如果这些原则和程序可以被信赖,那么派生类属性方法覆盖的测试就不需要像它们的祖先那样经过同样程度的努力/细节,可以简单如下:

def test_del_address(self):
    # Tests the _del_address method of the Artisan class
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    self.assertEqual(test_object.is_dirty, False, 
        'A newly-created instance of an Artisan should '
        'have is_dirty of False'
    )
    test_object._del_address()
    self.assertEqual(test_object.is_dirty, True, 
        'The deletion of an Artisan address should set '
        'is_dirty to True'
    )

# ...

def test_set_address(self):
    # Tests the _set_address method of the Artisan class
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    self.assertEqual(test_object.is_dirty, False, 
        'A newly-created instance of an Artisan should '
        'have is_dirty of False'
    )
    test_object._set_address(GoodAddresses[0])
    self.assertEqual(test_object.is_dirty, True, 
        'Setting an Artisan address should set '
        'is_dirty to True'
    )

数据字典方法(to_data_dictfrom_data_dict)在所有数据对象中都是通用的,并且作为结果出现在要实现的所有测试用例类的测试列表中。所有这些都有编写良好、彻底的单元测试的特殊挑战。to_data_dict的变化都遵循一个相当一致的模式:

  1. 遍历每个应该出现在输出中的属性的(希望是短的)代表性值列表

  2. 创建一个预期的字典值,可以用来与输出进行比较

  3. 断言预期的字典和to_data_dict的结果是相同的

理论上,确保测试所有可能的良好和坏的值组合的最佳方法是遍历所有这些可能的组合,将循环嵌套在其他循环中,以便例如测试所有可能的namestreet_addresscity值的组合。实际上,使用该策略构建的测试将需要很长时间来执行,因为要测试的组合数量很大(name值的数量×street_address值的数量×city值的数量等等)。需要出现在数据字典表示中的属性最少的类是Order类,除了已经测试过的其他类继承的属性外,还有五个本地属性。相关的testto_data_dict方法的不完整开始,只包括其中一个属性,共 72 行:

def testto_data_dict(self):
    # Tests the to_data_dict method of the Order class
    for name in GoodStandardRequiredTextLines[0:2]:
        for street_address in GoodStandardRequiredTextLines[0:2]:
            for city in GoodStandardRequiredTextLines[0:2]:
                # - At this point, we have all the required 
                #   arguments, so we can start testing with 
                #   partial expected dict-values
                test_object = Order(
                    name, street_address, city,
                )
                expected = {
                    'name':name,
                    'street_address':street_address,
                    'city':city,
                    # - The balance are default values...
                    'building_address':None,
                    'region':None,
                    'postal_code':None,
                    'country':None,
                    'items':{},
                    # - We also need to include the data-object 
                    #   items that should appear!
                    'created':datetime.strftime(
                            test_object.created, 
                            test_object._data_time_string
                        ),
                    'modified':datetime.strftime(
                            test_object.modified, 
                            test_object._data_time_string
                        ),
                    'oid':str(test_object.oid),
                    'is_active':test_object.is_active,
                    'is_deleted':test_object.is_deleted,
                }
                self.assertEqual(
                    test_object.to_data_dict(), expected
                )

需要测试的每个附加属性都会导致当前循环内的另一个循环,并创建一个新的测试对象,确保包括正在测试的新属性项/参数:

for items in GoodOrderItems:
  test_object = Order(
       name, street_address, city,
       items=items,
  )

每个子循环都必须创建自己的expected值:

expected = {
    'name':name,
    'street_address':street_address,
    'city':city,
    'building_address':None,
    'region':None,
    'postal_code':None,
    'country':None,
    'items':items,
    'created':datetime.strftime(
         test_object.created, 
         test_object._data_time_string
     ),
    'modified':datetime.strftime(
         test_object.modified, 
         test_object._data_time_string
     ),
     'oid':str(test_object.oid),
     'is_active':test_object.is_active,
     'is_deleted':test_object.is_deleted,
}

每个子循环还必须执行自己的断言来测试expectedtest_object.to_data_dict调用返回的实际值是否相符:

self.assertEqual(
     test_object.to_data_dict(), expected
)

在这一点上,还有四个属性需要测试,每个属性都将以自己的嵌套循环开始:

for building_address in GoodStandardOptionalTextLines[0:2]:
    for region in GoodStandardOptionalTextLines[0:2]:
        for postal_code in GoodStandardOptionalTextLines[0:2]:
            for country in GoodStandardOptionalTextLines[0:2]:
                pass

强制失败,并注明测试方法尚未完成,有助于防止假阳性的出现,并且还可以帮助跟踪在大量结果列表中正在进行的测试:

self.fail('testto_data_dict is not complete')

各种from_data_dict方法的测试同样复杂且深度嵌套,原因是相同的变体,它们必须考虑所有可能提供的值的合理可能性。在Order类中测试该方法的不完整开始显示了在 72 行中开始形成的模式:

def testfrom_data_dict(self):
    # Tests the from_data_dict method of the Order class

由于每个迭代段的预期值中应始终存在默认的None值,因此我们可以一次定义它们,然后在每个需要的点添加到预期值中:

defaults = {
   'building_address':None,
   'region':None,
   'postal_code':None,
   'country':None,
   'items':{},
}

嵌套循环的集合本身与测试to_data_dict的循环相同,从所有必需的属性/参数的变体开始:

for name in GoodStandardRequiredTextLines[0:2]:
    for street_address in GoodStandardRequiredTextLines[0:2]:
        for city in GoodStandardRequiredTextLines[0:2]:

每个循环段需要创建一个带有当前值的data_dict,并创建一个测试对象:

# - At this point, we have all the required 
#   arguments, so we can start testing with 
#   partial expected dict-values
    data_dict = {
        'name':name,
        'street_address':street_address,
        'city':city,
    }
    test_object = Order.from_data_dict(data_dict)

由于我们还将测试to_data_dict,我们可以假定它对于与测试对象的data-dict进行比较是可信的。如果to_data_dict测试失败,它们将自行引发这些失败,并且不允许测试运行通过,直到这些失败得到解决,结果是相同的测试失败:

actual = test_object.to_data_dict()

创建预期值有点复杂。它始于前面defaults值的副本(因为我们不希望测试迭代污染主要默认值)。我们还需要从实例中捕获预期值,因为我们期望它们出现在最终数据字典中:

# - Create a copy of the defaults as a starting-point
expected = dict(defaults)
instance_values = {
    'created':datetime.strftime(
           test_object.created, 
           test_object._data_time_string
         ),
     'modified':datetime.strftime(
           test_object.modified, 
           test_object._data_time_string
         ),
     'oid':str(test_object.oid),
     'is_active':test_object.is_active,
     'is_deleted':test_object.is_deleted,
   }

在这一点上构建expected值,只是简单地更新它与数据字典和实例值。完成后,我们可以执行实际的测试断言:

expected.update(instance_values)
expected.update(data_dict)
self.assertEqual(expected, actual)

与以前一样,每个需要测试的属性/参数都需要自己的嵌套循环,并且需要从最顶层循环复制相同的过程。在每个连续的循环级别上,data_dict值必须包含更多的数据以传递给from_data_dict方法,但每个子循环的平衡在其他方面都是相同的:

for items in GoodOrderItems:
   # - Same structure as above, but adding items
   data_dict = {
        'name':name,
        'street_address':street_address,
        'city':city,
        'items':items,
    }
    test_object = Order.from_data_dict(data_dict)
    actual = test_object.to_data_dict()
    expected = dict(defaults)
    instance_values = {
        'created':datetime.strftime(
                 test_object.created, 
                 test_object._data_time_string
               ),
        'modified':datetime.strftime(
                 test_object.modified, 
                 test_object._data_time_string
               ),
         'oid':str(test_object.oid),
         'is_active':test_object.is_active,
         'is_deleted':test_object.is_deleted,
    }
    expected.update(instance_values)
    expected.update(data_dict)
    self.assertEqual(expected, actual)
    for building_address in GoodStandardOptionalTextLines[0:2]:
    for region in GoodStandardOptionalTextLines[0:2]:
    for postal_code in GoodStandardOptionalTextLines[0:2]:
    for country in GoodStandardOptionalTextLines[0:2]:
        pass
self.fail('testfrom_data_dict is not complete')

测试matches方法的结果实际上没有预期的那么复杂。毕竟,一个完整的测试需要测试对象实例的所有属性,对TrueFalse结果进行测试,标准可能是 1 个值或 12 个值,或者(理论上)数十个或数百个。幸运的是,通过使用与to_data_dictfrom_data_dict测试相同的嵌套循环结构,但变化以创建用于测试的标准,并确定在每一步中预期值需要是什么,实际上并不那么困难。测试过程从创建一个具有已知功能数据的对象的每个属性开始:

def testmatches(self):
    # Tests the matches method of the Order class
    # - First, create an object to test against, with as complete 
    #   a data-set as we can manage
    test_object = Order(
        name = GoodStandardRequiredTextLines[0],
        street_address = GoodStandardRequiredTextLines[0],
        city = GoodStandardRequiredTextLines[0],
        building_address = GoodStandardOptionalTextLines[0],
        region = GoodStandardOptionalTextLines[0],
        postal_code = GoodStandardOptionalTextLines[0],
        country = GoodStandardOptionalTextLines[0],
    )

嵌套循环结构遍历一系列数字(01),并根据循环中的属性相关的值的类型从适当的列表中检索测试值,创建或添加到条件,并根据任何先前的预期值和循环的条件值与相应对象属性的比较来确定预期结果是否应为TrueFalse。在此之后,剩下的就是断言预期值是否等于调用测试对象的matches方法返回的实际值:

# - Then we'll iterate over some "good" values, create criteria
for name_num in range(0,2):
   name = GoodStandardRequiredTextLines[name_num]
   criteria = {'name':name}
   expected = (name == test_object.name)
   self.assertEqual(expected, test_object.matches(**criteria))

每个子循环关注其父级中设置的expected值的原因是为了确保更高级别的False结果不会被当前循环级别的潜在True结果覆盖。例如,在测试迭代的这一点上,如果name导致False结果(因为它与test_object.name不匹配),即使street_address匹配,它仍应返回False结果:

for str_addr_num in range(0,2):
    street_address = GoodStandardRequiredTextLines[str_addr_num]
    criteria['street_address'] = street_address
    expected = (expected and street_address == test_object.street_address)
    self.assertEqual(expected, test_object.matches(**criteria))

每个子循环的模式,除了添加到条件中的属性值的名称和expected值的重新定义之外,在循环树的所有层次上都是相同的:

for city_num in range(0,2):
   city = GoodStandardRequiredTextLines[city_num]
   criteria['city'] = city
   expected = (expected and city == test_object.city)
   self.assertEqual(expected, test_object.matches(**criteria))
   for bldg_addr_num in range(0,2):
       building_address = GoodStandardOptionalTextLines[bldg_addr_num]
       criteria['building_address'] = building_address
         expected = (
             expected and 
             building_address == test_object.building_address
            )
            self.assertEqual(expected, test_object.matches(**criteria))
            for region_num in range(0,2):
                for pc_num in range(0,2):
                    for cntry_num in range(0,2):
                        country=GoodStandardOptionalTextLines[cntry_num]
self.fail('testmatches is not complete')

所有新数据对象共有的最后一个方法是_load_objects辅助类方法。初始单元测试引发了一些语法问题,这使得有必要删除JSONFileDataObject中该方法的抽象,并在每个从属类中实现一个覆盖类方法,所有这些方法都调用原始类方法,如下所示:

@classmethod
def _load_objects(cls, force_load=False):
    return JSONFileDataObject._load_objects(cls, force_load)

这反过来开始提高测试运行中方法的要求。这些测试的实施并不困难,在一定程度上建立在最初为JSONFileDataObject编写的原始测试方法上。对Order类进行的测试结构是最简单的例子,并且开始方式基本相同,但是强制清除磁盘和内存数据存储,但在将磁盘位置设置为一次性目录之后:

def test_load_objects(self):
    # Tests the _load_objects method of the Order class
    # - First, forcibly change Order._file_store_dir to a disposable 
    #   temp-directory, and clear the in-memory and on-disk stores
    Order._file_store_dir = '/tmp/test_artisan_objects/'
    Order._loaded_objects = None
    if os.path.exists(Order._file_store_dir):
        rmtree(Order._file_store_dir)
    self.assertEqual(Order._loaded_objects, None)

为了测试加载过程,需要创建并保存一些对象:

# - Iterate through some objects, creating them and saving them.
    for name in GoodStandardRequiredTextLines[0:2]:
       for street_address in GoodStandardRequiredTextLines[0:2]:
          for city in GoodStandardRequiredTextLines[0:2]:
              test_object = Order(name, street_address, city)
              test_object.save()

创建每个对象时,都会验证其在内存和磁盘存储中的存在:

# - Verify that the object exists
#   - in memory
self.assertNotEqual(
    Order._loaded_objects.get(str(test_object.oid)), 
    None
)
#   - on disk
file_path = '%s/Order-data/%s.json' % (
    Order._file_store_dir, test_object.oid
)
self.assertTrue(
    os.path.exists(file_path), 
    'The file was not written at %s' % file_path
)

还需要清除内存存储,重新加载它,并验证新创建的对象是否仍然存在。这在每个对象创建迭代中都会发生:

# - Make a copy of the OIDs to check with after clearing 
#   the in-memory copy:
oids_before = sorted([str(key) for key in Order._loaded_objects.keys()])
# - Clear the in-memory copy and verify all the oids 
#   exist after a _load_objects is called
Order._loaded_objects = None
Order._load_objects()
oids_after = sorted(
    [str(key) for key in Order._loaded_objects.keys()]
)
self.assertEqual(oids_before, oids_after)

通过迭代实例列表,随机选择一个实例,删除该实例,并验证其删除方式与最初的创建方式相同,以验证删除过程是否移除了内存和磁盘对象:

# - Delete items at random and verify deletion and load after each
instances = list(Order._loaded_objects.values())
while instances:
   target = choice(instances)
   Order.delete(target.oid)
   # - Verify that the object no longer exists
   #   - in memory
   self.assertEqual(
       Order._loaded_objects.get(str(test_object.oid)), 
       None
   )
   #   - on disk
   file_path = '%s/Order-data/%s.json' % (
       Order._file_store_dir, target.oid
   )
   self.assertFalse(
        os.path.exists(file_path), 
        'File at %s was not deleted' % file_path
   )
   # - Make a copy of the OIDs to check with after clearing 
   #   the in-memory copy:
   oids_before = sorted(
        [str(key) for key in Order._loaded_objects.keys()]
   )
   # - Clear the in-memory copy and verify all the oids 
   #   exist after a _load_objects is called
   Order._loaded_objects = None
   Order._load_objects()
   oids_after = sorted([str(key) for key in Order._loaded_objects.keys()])
   self.assertEqual(oids_before, oids_after)

每次迭代结束时,实例列表都会更新:

instances.remove(target)

最后,为了安全起见,任何可能剩下的文件都会被删除:

# - Clean up any remaining in-memory and on-disk store items
Order._loaded_objects = None
if os.path.exists(Order._file_store_dir):
    rmtree(Order._file_store_dir)

大多数测试方法的平衡遵循先前建立的模式:

  • 各种属性及其 getter、setter 和 deleter 方法使用本节开头提到的结构

  • 各种__init__方法仍然为所有参数/属性的合理子集创建并断言参数到属性设置

然而,还有一些离群值。首先,定义了但没有实现的sort类方法,作为BaseDataObject中的抽象类方法,已经出现。在这一点上,我们甚至不知道我们是否需要它,更不用说它需要采取什么形式了。在这种情况下,推迟其实现和该实现的测试似乎是明智的。为了允许忽略所需的单元测试,可以用unittest.skip进行装饰:

@unittest.skip(
    'Sort will be implemented once there\'s a need for it, '
    'and tested as part of that implementation'
)
def testsort(self):
    # Tests the sort method of the Artisan class
    # - Test all permutations of "good" argument-values:
    # - Test all permutations of each "bad" argument-value 
    #   set against "good" values for the other arguments:
    self.fail('testsort is not yet implemented')

Artisan 类中又出现了两个离群值:add_productremove_product,在此之前没有可测试的具体实现。通过添加GoodproductsBadproducts值列表进行测试,testadd_product与以前利用值列表进行测试的测试方法非常相似:

def testadd_product(self):
    # Tests the add_product method of the Artisan class
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    self.assertEqual(test_object.products, ())
    check_list = []
    for product in Goodproducts[0]:
        test_object.add_product(product)
        check_list.append(product)
        self.assertEqual(test_object.products, tuple(check_list))
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    for product in Badproducts:
        try:
            test_object.add_product(product)
            self.fail(
                'Artisan.add_product should not allow the '
                'addition of "%s" (%s) as a product-item, but '
                'it was allowed' % (product, type(product).__name__)
            )
        except (TypeError, ValueError):
            pass

测试remove_product的过程是通过使用相同的过程创建产品集合,然后逐个删除它们,并在每次迭代中验证删除:

def testremove_product(self):
    # Tests the remove_product method of the Artisan class
    test_object = Artisan('name', 'me@email.com', GoodAddress)
    self.assertEqual(test_object.products, ())
    for product in Goodproducts[0]:
        test_object.add_product(product)
    check_list = list(test_object.products)
    while test_object.products:
        product = test_object.products[0]
        check_list.remove(product)
        test_object.remove_product(product)
        self.assertEqual(test_object.products, tuple(check_list))

因为hms_artisan..Order是从头开始构建的,其属性方法测试需要明确执行与之前提到的相同类型的is_dirty检查,但还必须实现几种标准属性测试中的任何一种。典型的删除器和设置器方法测试如下所示:

def test_del_building_address(self):
    # Tests the _del_building_address method of the Order class
    test_object = Order('name', 'street_address', 'city')
    self.assertEqual(
        test_object.building_address, None, 
        'An Order object is expected to have None as its default '
        'building_address value if no value was provided'
    )
    # - Hard-set the storage-property's value, call the 
    #   deleter-method, and assert that it's what's expected 
    #   afterwards:
    test_object._set_is_dirty(False)
    test_object._building_address = 'a test value'
    test_object._del_building_address()
    self.assertEqual(
        test_object.building_address, None, 
        'An Order object is expected to have None as its '
        'building_address value after the deleter is called'
    )
    self.assertTrue(test_object.is_dirty,
        'Deleting Order.building_address should set is_dirty to True'
    )

# ...

def test_set_building_address(self):
    # Tests the _set_building_address method of the Order class
    # - Create an object to test with:
    test_object = Order('name', 'street_address', 'city')
    # - Test all permutations of "good" argument-values:
    for expected in GoodStandardOptionalTextLines:
        test_object._set_building_address(expected)
        actual = test_object._get_building_address()
        self.assertEqual(
            expected, actual, 
            'Order expects a building_address value set to '
            '"%s" (%s) to be retrieved with a corresponding '
            'getter-method call, but "%s" (%s) was returned '
            'instead' % 
            (
                expected, type(expected).__name__, 
                actual, type(actual).__name__, 
            )
        )
    # - Test is_dirty after a set
    test_object._set_is_dirty(False)
    test_object._set_building_address(GoodStandardOptionalTextLines[1])
    self.assertTrue(test_object.is_dirty,
        'Setting a new value in Order.business_address should '
        'also set the instance\'s is_dirty to True'
    )
    # - Test all permutations of "bad" argument-values:
    for value in BadStandardOptionalTextLines:
        try:
            test_object._set_building_address(value)
            # - If this setter-call succeeds, that's a 
            #   test-failure!
            self.fail(
                'Order._set_business_address should raise '
                'TypeError or ValueError if passed "%s" (%s), '
                'but it was allowed to be set instead.' % 
                (value, type(value).__name__)
            )
        except (TypeError, ValueError):
            # - This is expected, so it passes
            pass
        except Exception as error:
            self.fail(
                'Order._set_business_address should raise '
                'TypeError or ValueError if passed an invalid '
                'value, but %s was raised instead: %s.' % 
                (error.__class__.__name__, error)
            )

hms_artisan命名空间的所有测试的最终测试报告显示,除了明确跳过的七个测试外,所有测试都已运行,没有测试失败:

测试新的 hms_core 类

在进行模块的单元测试的常规设置过程之后(创建测试模块,执行测试模块,为每个报告为缺失的项目创建测试用例类,执行测试模块,并为每个报告为缺失的项目创建测试方法),初始结果显示需要实现的测试要少得多,只有 11 个需要填充:

不过,这些结果有一个警告:它们不包括BaseDataObjectHMSMongoDataObject所需的数据对象方法的测试,只包括作为创建的ArtisanProduct类的一部分定义的属性和方法的测试。这些属性和方法位于它们自己的测试模块中,需要实现另外 33 个测试:

单元测试 hms_core.data_storage.py

DatastoreConfig类的大部分测试遵循先前建立的测试模式。值得注意的例外是测试其from_config类方法,它需要实际的配置文件进行测试。通过创建一个充满良好值的配置文件来测试所有良好值并不看起来与涉及从dict值创建对象实例的其他测试方法有多大不同,尽管开始时对所有良好测试值的相同迭代:

# - Test all permutations of "good" argument-values:
config_file = '/tmp/datastore-test.json'
for database in good_databases:
    for host in good_hosts:
        for password in good_passwords:
            for port in good_ports:
                for user in good_users:
                    config = {
                        'database':database,
                        'host':host,
                        'password':password,
                        'port':port,
                        'user':user,
                    }

这是创建临时配置文件的地方:

fp = open('/tmp/datastore-test.json', 'w')
json.dump(config, fp)
fp.close()

然后调用from_config,并执行各种断言:

test_object = DatastoreConfig.from_config(config_file)
self.assertEqual(test_object.database, database)
self.assertEqual(test_object.host, host)
self.assertEqual(test_object.password, password)
self.assertEqual(test_object.port, port)
self.assertEqual(test_object.user, user)
os.unlink(config_file)

在测试每个参数/属性(databasehostpasswordportuser)的各种错误值时使用了类似的方法/结构(它们看起来都很像测试错误数据库值的测试):

# - Test all permutations of each "bad" argument-value 
#   set against "good" values for the other arguments:
# - database
host = good_hosts[0]
password = good_passwords[0]
port = good_ports[0]
user = good_users[0]
for database in bad_databases:
    config = {
        'database':database,
        'host':host,
        'password':password,
        'port':port,
        'user':user,
    }
    fp = open('/tmp/datastore-test.json', 'w')
    json.dump(config, fp)
    fp.close()
    try:
        test_object = DatastoreConfig.from_config(config_file)
        self.fail(
            'DatastoreConfig.from_config should not '
            'accept "%s" (%s) as a valid database config-'
            'value, but it was allowed to create an '
            'instance' % (database, type(database).__name__)
        )
    except (RuntimeError, TypeError, ValueError):
        pass

HMSMongoDataObject的大部分测试过程也与先前建立的测试编写模式相同:

  • 因为该类派生自BaseDataObject,所以有许多相同的必需测试方法依赖于实现抽象功能,因此创建了一个派生类进行测试,即使只是为了确保依赖方法调用是成功的

  • _create_update方法的测试与测试它们的hms_artisan对应方法基本相同,因为它们也只是简单地引发NotImplementedError

测试任何HMSMongoDataObject派生类的功能都需要一个运行中的 MongoDB 安装。如果没有,测试可能会引发错误(希望至少能指出问题所在),或者可能会一直等待连接到 MongoDB,直到连接尝试超时解决。

本地属性,因为它们都使用其底层存储属性的实际删除,并且是懒惰实例化(在需要时创建,如果它们尚不可用),因此需要与以前的属性测试不同的方法。为了将所有相关的测试代码放在一个地方,test_del_方法已被跳过,并且属性删除方面的测试与test_get_方法合并。以test_get_connection为例:

def test_get_connection(self):
    # Tests the _get_connection method of the HMSMongoDataObject class
    # - Test that lazy instantiation on a new instance returns the 
    #   class-attribute value (_connection)
    test_object =  HMSMongoDataObjectDerived()
    self.assertEqual(
        test_object._get_connection(), 
        HMSMongoDataObjectDerived._connection
    )
    # - Test that deleting the current connection and re-aquiring it 
    #   works as expected
    test_object._del_connection()
    self.assertEqual(
        test_object._get_connection(), 
        HMSMongoDataObjectDerived._connection
    )
    # - There may be more to test later, but this suffices for now...

每个测试的过程都类似:

  1. 创建一个test_object实例

  2. 断言当调用测试属性 getter 时返回公共类属性值(在这种情况下为HMSMongoDataObjectDerived._connection

  3. 调用删除方法

  4. 重新断言当再次调用 getter 时返回公共类属性值

在调用删除方法和 getter 方法之间进行断言,断言类属性值已被删除可能也是一个好主意,但只要最终的 getter 调用断言仍然通过,这并不是真正必要的。

HMSMongoDataObject的测试用例类中有几个项目依赖于实际的数据库连接,以便能够有用。此外,还有一些直接与该依赖关系相关的测试方法可以跳过,或者其实现值得注意。由于我们需要一个数据库连接,因此每次测试用例类运行时都必须进行配置。理想情况下,它不应该为每个需要连接的测试运行 - 如果它确实如此,至少在目前的系统规模上并不是什么大问题,但在更大规模的系统中,为每个需要它的测试方法创建一个新的数据库可能会减慢速度。也许会大大减慢。

幸运的是,标准的 Python unittest模块提供了可以用来初始化数据库连接数据,并在所有测试完成后删除用于测试的数据库的方法。分别是setUptearDown方法。setUp只需要配置数据访问,因为HMSMongoDataObjects会在需要时负责创建它需要的connectiondatabasecollection对象:

def setUp(self):
    # - Since we need a database to test certain methods, 
    #   create one here
    HMSMongoDataObject.configure(self.__class__.config)

tearDown负责完全删除为测试用例类创建的测试数据库,并简单地创建一个MongoClient,然后使用它来删除配置中指定的数据库:

def tearDown(self):
    # - delete the database after we're done with it, so that we 
    #   don't have data persisting that could bollix up subsequent 
    #   test-runs
    from pymongo import MongoClient
    client = MongoClient()
    client.drop_database(self.__class__.config.database)

如果我们尝试断言任何预期值或行为,setUptearDown方法将不会像典型的测试方法一样行为 - 任何失败的断言都将简单地引发错误。这意味着,虽然我们可以断言配置已经准确完成,但从报告的角度来看,它实际上并没有做任何有用的事情。在这种情况下,如果配置调用没有引发任何错误,并且依赖于它的各种测试方法都通过了,那么可以认为配置正在按预期进行。在这种情况下,我们可以跳过相关的测试方法:

@unittest.skip(
    'The fact that the configuration works in setUp is sufficient'
)
def test_get_configuration(self):
    # Tests the _get_configuration method of the HMSMongoDataObject class
    # - Test all permutations of "good" argument-values:
    # - Test all permutations of each "bad" argument-value 
    #   set against "good" values for the other arguments:
    self.fail('test_get_configuration is not yet implemented')

@unittest.skip(
    'The fact that the configuration works in setUp is sufficient'
)
def testconfigure(self):
    # Tests the configure method of the HMSMongoDataObject class
    self.fail('testconfigure is not yet implemented')

为了完全测试deletegetsave方法,我们必须实现一个一次性的派生类 - HMSMongoDataObjectDerived

class HMSMongoDataObjectDerived(HMSMongoDataObject):

    _data_dict_keys = (
        'name', 'description', 'cost', 'oid', 'created', 'modified', 
        'is_active', 'is_deleted'
    )

我们希望有一些本地属性可以用来测试get,但它们不需要更多,只需要在初始化期间设置为简单的属性,并在to_data_dict调用的结果中出现:

def __init__(self, name=None, description=None, cost=0, 
    oid=None, created=None, modified=None, is_active=None, 
    is_deleted=None, is_dirty=None, is_new=None
  ):
    HMSMongoDataObject.__init__(
    self, oid, created, modified, is_active, is_deleted, 
    is_dirty, is_new
  )
    self.name = name
    self.description = description
    self.cost = cost

def to_data_dict(self):
    return {
         # - "local" properties
         'name':self.name,
         'description':self.description,
         'cost':self.cost,
         # - standard items from HMSMongoDataObject/BaseDataObject
         'created':self.created.strftime(self.__class__._data_time_string),
         'is_active':self.is_active,
         'is_deleted':self.is_deleted,
         'modified':self.modified.strftime(self.__class__._data_time_string),
         'oid':str(self.oid),
        }

def matches(self, **criteria):
    return HMSMongoDataObject.matches(self, **criteria)

为了测试delete方法,我们需要首先创建并保存一些对象:

def testdelete(self):
    # Tests the delete method of the HMSMongoDataObject class
    # - In order to really test get, we need some objects to test 
    #   against, so create a couple dozen:
    names = ['Alice', 'Bob', 'Carl', 'Doug']
    costs = [1, 2, 3]
    descriptions = [None, 'Description']
    all_oids = []
    for name in names:
        for description in descriptions:
            for cost in costs:
                item = HMSMongoDataObjectDerived(
                    name=name, description=description, cost=cost
                )
                item.save()
                all_oids.append(item.oid)

我们希望测试我们可以删除多个项目和单个项目,因此我们将删除创建的对象集合的后半部分,然后删除剩余项目的后半部分,依此类推,直到只剩下一个对象。在每次迭代中,我们删除当前的oid集合,并验证它们在被删除后是否不存在。最后,我们验证所有创建的对象都已被删除:

# - Delete varying-sized sets of items by oid, and verify that 
#   the deleted oids are gone afterwards...
while all_oids:
     try:
        oids = all_oids[len(all_oids)/2:]
        all_oids = [o for o in all_oids if o not in oids]
     except:
        oids = all_oids
        all_oids = []
     HMSMongoDataObjectDerived.delete(*oids)
     items = HMSMongoDataObjectDerived.get(*oids)
     self.assertEqual(len(items), 0)
# - Verify that *no* items exist after they've all been deleted
items = HMSMongoDataObjectDerived.get()
self.assertEqual(items, [])

测试get采用类似的方法 - 创建几个具有易于识别属性值的项目,这些值可以用作criteria

def testget(self):
   # Tests the get method of the HMSMongoDataObject class
   # - In order to really test get, we need some objects to test 
   #   against, so create a couple dozen:
   names = ['Alice', 'Bob', 'Carl', 'Doug']
   costs = [1, 2, 3]
   descriptions = [None, 'Description']
   for name in names:
      for description in descriptions:
         for cost in costs:
             HMSMongoDataObjectDerived(
                  name=name, description=description, cost=cost
             ).save()

然后我们可以迭代相同的值,创建一个criteria集合来使用,并验证返回的对象是否具有我们传递的criteria值。首先是一个criteria值:

# - Now we should be able to try various permutations of get 
#   and get verifiable results. These tests will fail if the 
#   _data_dict_keys class-attribute isn't accurate...
for name in names:
    criteria = {
        'name':name,
    }
    items = HMSMongoDataObjectDerived.get(**criteria)
    actual = len(items)
    expected = len(costs) * len(descriptions)
    self.assertEqual(actual, expected, 
        'Expected %d items returned (all matching name="%s"), '
        'but %d were returned' % 
        (expected, name, actual)
    )
    for item in items:
        self.assertEqual(item.name, name)

然后我们使用多个criteria进行测试,以确保多个criteria值的行为符合预期:

for cost in costs:
    criteria = {
         'name':name,
         'cost':cost,
    }
    items = HMSMongoDataObjectDerived.get(**criteria)
    actual = len(items)
    expected = len(descriptions)
    self.assertEqual(actual, expected, 
         'Expected %d items returned (all matching '
         'name="%s" and cost=%d), but %d were returned' % 
         (expected, name, cost, actual)
   )
   for item in items:
       self.assertEqual(item.name, name)
       self.assertEqual(item.cost, cost)

在“删除”和“获取”方法的测试之间,我们实际上已经测试了“保存”方法 - 毕竟我们必须保存对象才能获取或删除它们,因此可以说testsave实际上并不是真正需要的。为了进行实际测试,而不是跳过另一个测试,我们将实施它,并用它来测试我们也可以通过其oid值获取对象:

# - Noteworthy because save/get rather than save/pymongo-query.
#   another option would be to do a "real" pymongo query, but that 
#   test-code would look like the code in get anyway...?
def testsave(self):
   # Tests the save method of the HMSMongoDataObject class
   # - Testing save without using get is somewhat cumbersome, and 
   #   perhaps too simple...?
   test_object = HMSMongoDataObjectDerived()
   test_object.save()
   expected = test_object.to_data_dict()
   results = HMSMongoDataObjectDerived.get(str(test_object.oid))
   actual = results[0].to_data_dict()
   self.assertEqual(actual, expected)

一旦所有内容都实施并通过,最终的测试输出显示有 47 个测试,其中有五个被跳过:

单元测试 hms_core.co_objects.py

co_objects中的ArtisanProduct类,就像hms_artisanartisan_objects模块中的对应类一样,必须被覆盖以在修改了状态数据记录的任何属性时提供适当的is_dirty行为。因此,必须创建相应的测试方法,就像在测试hms_artisan软件包中的对应类时发生的那样。实际上,两个模块都进行了相同的更改,因此两个软件包中存在的类的测试类和其中的测试方法结果是相同的。

单元测试和信任

早些时候已经指出,单元测试代码的真正目的是确保代码在所有可能的执行情况下都以可预测的方式运行。从非常实际的角度来看,这也是在代码库中建立信任的一种方式。在这种情况下,必须就可以简单地接受这种信任的地方划定一条线。例如,这次迭代中的各种单元测试都侧重于确保为数据持久性创建的代码可以从数据库引擎获取并传递所有必要的内容。它并不关心用于连接到数据库引擎的库是否值得信赖;对于我们的目的,我们假设它是可信赖的,至少直到我们遇到无法用其他方式解释的测试失败。

单元测试为可能使用我们的代码的其他人提供了信任 - 知道已经测试了所有需要测试的内容,并且所有测试都已通过。

构建/分发,演示和验收

各个模块的构建过程不会有太大变化,尽管现在有了单元测试,可以将其添加到用于打包各个 Python 软件包的setup.py文件中。已经存在的setup函数可以在进行最小更改的情况下用于执行整个测试套件,只需提供指向根测试套件目录的test_suite参数即可。

可能需要确保已将测试套件目录的路径添加到sys.path中:

#!/usr/bin/env python

import sys
sys.path.append('../standards')
sys.path.append('tests/test_hms_core') # <-- This path

然后,当前的setup函数调用包括test_suite,如下所示:

setup(
    name='HMS-Core',
    version='0.1.dev0',
    author='Brian D. Allbee',
    description='',
    package_dir={
        '':'src',
    },
    packages=[
        'hms_core',
    ],
    test_suite='tests.test_hms_core',
)

然后可以使用python setup.py test执行整个测试套件,该命令返回测试执行的逐行摘要及其结果:

将代码打包到组件项目中仍然使用python setup.py sdist,并且仍然会生成可安装的软件包:

展示新的数据持久性功能可以通过多种方式完成,但需要在一次性/临时数据库中创建一次性/临时演示数据对象。test_co_objects测试模块中有代码可以做到这一点,因此可以基于该结构创建一个最小的数据对象类(称之为ExampleObject以示范目的),然后运行:

HMSMongoDataObject.configure(
    DatastoreConfig(database='demo_data')
)

print('Creating data-objects to demo with')
names = ['Alice', 'Bob', 'Carl', 'Doug']
costs = [1, 2, 3]
descriptions = [None, 'Description']
for name in names:
    for description in descriptions:
        for cost in costs:
            item = ExampleObject(
                name=name, description=description, cost=cost
            )
            item.save()

它负责生成可以检查的数据集。从那时起,任何工具 - 命令行mongo客户端或 GUI,例如 Robo3T - 都可以用于查看和验证数据是否实际上已被持久化:

如果需要更详细的验收示例 - 例如每种业务对象类型的示例 - 可以编写类似的脚本来创建ArtisanProduct实例并保存它们。同样,就hms_artisan数据对象类而言,仅显示为示例/演示环境中的对象编写的文件应该就足够了。

操作/使用,维护和 decommissioning 考虑

就这些项目而言,还没有实质性的变化:

  • 尽管现在有三个包,但它们仍然非常简单。

  • 尽管我们通过包含pymongo库添加了外部依赖,但我们还没有到需要担心如何处理该依赖的地步。

  • 显然需要安装 MongoDB,但在代码准备好集成到共享环境之前,这甚至不是问题 - 目前本地开发可以使用本地数据库引擎。

  • 从退役的角度来看,卸载软件实际上并没有什么变化,只是现在有三个要卸载的软件包 - 但每个软件包的过程都是上一次迭代结束时的过程的变体(pip uninstall HMS-Core)。

总结

虽然在后续迭代中可能会有其他数据访问和数据持久化调整,也有一些数据对象的具体细节因为与其他系统集成的原因而尚不清楚,但大部分数据对象的工作已经完成。

到目前为止,针对hms_sys代码库的开发迭代主要关注的是系统功能 - 确保数据结构良好形成,可以验证,并且将生存时间超过单个用户会话或 Python 运行。从用户角度与系统数据的交互尚未得到解决。不过,在解决这个问题之前,还有另一层需要至少进行分析,如果可能的话,进行构建 - 即工匠网关服务,它充当远程工匠和中央办公室工作人员数据汇聚的中心点。

第十五章:服务的解剖结构

hms_sys中攻击的下一个逻辑功能块是 Artisan Gateway 服务。该服务等待来自 Artisan 或中央办公室最终用户的输入,根据需要创建或更新对象数据,并可能将该数据与网络商店系统的数据库同步。预计两个最终用户应用程序将完全随机地与 Artisan Gateway 服务通信;每当有人想要更改数据时,它都会准备好并等待处理该请求。

然而,在我们真正实现这项服务之前,我们需要解决任何服务可以或应该如何在 Python 中编写的问题。为此,我们将不得不检查和理解以下内容:

  • 服务结构的基本实现,包括以下内容:

  • 管理服务实例配置的选项

  • 如何服务可以读取并响应请求

  • 服务在以下环境中如何启动和何时启动:

  • 一个相当现代的、符合 POSIX 标准的系统(例如 Linux)

  • Windows

  • 是否有其他更好的设计,可以在 Python 可用的任何操作系统上运行

为了更好地理解服务的实现和执行的这些方面,我们将从头开始构建一个基本的服务结构,然后可以将其用作最终的 Artisan Gateway 服务的基础。

什么是服务?

服务在最基本的层面上只是在计算机后台运行的程序。它们通常等待来自某个地方的输入,根据该输入执行一些操作,并返回数据,至少表明所采取的操作是成功还是失败。在最基本的层面上,输入甚至可能不是对用户可见的东西;等待网络活动、监视文件系统,甚至只是在某种定时控制的基础上运行的服务,在今天的许多操作系统中都很常见。

服务应始终可用,持续运行,只要主机机器正在运行;这对于它们的编写和实现有一些影响,如下所示:

  • 它们必须非常容错:每当发生意外情况时崩溃并死机的服务,并且必须因此重新启动,是没有什么用的。

  • 它们应该尽可能是功能上自包含的;可能会失败的外部依赖项(并导致运行中的服务崩溃)应该受到严格的审视。

  • 因为它们的操作可能对用户完全不可见,所以设计不良或实现不良的服务可能会占用系统资源,最终可能导致整个机器崩溃。即使没有涉及多处理,也需要小心和纪律,以避免诸如永不终止的循环或将孤立对象、数据或功能留在内存中的功能等问题。如果发生这些情况,只是时间问题(或服务的负载),直到内存或可用 CPU 减少到零。

服务结构

总之,服务并不一定非常复杂。如果有操作系统设施可用于管理实际的代码执行(启动和关闭),它们在结构上可能并不比以下代码更复杂:

#!/usr/bin/env python
"""
A simple daemon-like function that can be started from the command-line.
"""
    import syslog
    from time import sleep

    def main_program():
        iterations = 0
        syslog.syslog('Starting %s' % __file__)
        while True:
            # TODO: Perform whatever request-acquisition and response-
            #       generation is needed here...
            syslog.syslog('Event Loop (%d)' % iterations)
            sleep(10)
            iterations += 1
        syslog.syslog('Exiting %s' % __file__)

    if __name__ == '__main__':
        main_program()

当运行前面的代码时,它不会生成用户可见的输出,但是观察系统日志(在 Linux 机器上使用tail -f /var/log/syslog)会显示它正在按预期进行操作,如下所示:

  • 在进入主循环之前,它将启动消息写入日志文件。

  • 在每次循环中,它执行以下操作:

  • 将带有迭代号的消息写入日志

  • 休眠 10 秒

  • 增加迭代计数器

退出消息没有被写入日志文件,但这在这一点上是预期的,因为停止主循环的唯一方法是终止程序本身,这将终止程序而不退出循环。从启动到几次迭代的典型日志输出如下:

这当然不是一个服务,但它说明了可能被认为是任何服务共同功能的最低限度。

大多数服务的核心是一个循环,直到服务被关闭或终止。在循环中,服务将实际检查输入,有几种方式。一些更常见的变体包括以下内容:

  • 它可以等待通过网络套接字传入的请求(Web 服务将使用此方法)。

  • 它可以等待来自标准输入(stdin)的传入数据。

  • 它可以主动轮询来自外部队列系统的传入消息,例如 RabbitMQ,或基于云的等效系统,例如 AWS 的 SQS 或 Google Cloud Platform 的 Cloud Pub/Sub。

这只是服务输入的一些可能性。其他机制不适合直接等待某些模型的事件,可以将事件推送到本地队列,并让服务从该队列机制中观察或轮询。

除了最基本的服务外,传入的请求将需要进行评估,以确定必须调用哪些功能来处理请求。将传入请求数据与特定功能相关联的最常见机制可能是一个大的if…elif…else结构,将处理请求的责任传递给特定和专用功能,看起来像以下内容:

# - Evaluate the incoming request:
    if request['path'].startswith('/product'):
       return handle_product_request(request)
    elif request['path'].startswith('/artisan'):
       return handle_artisan_request(request)
    elif request['path'].startswith('/customer'):
       return handle_customer_request(request)
    else:
# - Invalid request, so return an error
       return handle_invalid_request(request)

然后,每个handle_{something}_request函数将负责处理传入的请求,确定如何处理它,并返回结果数据。

有一个标准的 Python 库python-daemon,它进一步采用了这种基本方法,允许将函数包装在基本的守护程序上下文中。相同的基本函数,使用python-daemon DaemonContext包装,非常相似,如下面的代码片段所示:

#!/usr/bin/env python
"""
A bare-bones daemon implementation.
"""
    import syslog
    from daemon import DaemonContext
    from time import sleep

    def main_program():
        iterations = 0
        syslog.syslog('Starting %s' % __file__)
        while True:
        # TODO: Perform whatever request-acquisition and response-
        #       generation is needed here...
            syslog.syslog('Event Loop (%d)' % iterations)
            sleep(10)
            iterations += 1
        syslog.syslog('Exiting %s' % __file__)

    if __name__ == '__main__':
        with DaemonContext():
            main_program()

术语服务守护程序在本书中是可以互换的;它们都指的是同一种后台进程程序。

执行此代码产生几乎相同的结果(除了在日志消息中出现的文件名,实际上是相同的)。一旦守护进程代码运行,实际的差异基本上是看不见的。使用DaemonContext提供了一些操作方面,这些操作方面是裸骨的,仅处理功能的代码所不涉及的,这被认为是守护进程进程的最佳实践:

  • 确保在启动期间与命令相关联的任何打开文件都被关闭

  • 将进程的工作目录更改为已知和/或安全的目录

  • 设置文件创建权限掩码,以便进程创建的文件将具有已知(且可安全)的权限设置

  • 执行系统级进程设置,以允许进程本身在后台运行

  • 将进程与任何终端活动分离,以便在启动守护进程进程后不响应终端输入

尽管python-daemon是一个标准库,但它可能不是标准 Python 安装的一部分。如果不是,可以使用pip install python-daemon进行安装。

然后,python-daemon模块提供了一种非常简单的方法来管理编写守护程序和服务的最佳实践操作。但是,使用它可能存在潜在问题。它不适用于没有类 Unix 密码数据库的系统(它依赖于pwd模块,仅适用于 Unix)。至少对于需要在 Windows 系统上运行的服务,这将排除它。

最终,知道服务实现不必多于一个永久循环的单个函数调用,主要关注点(除了服务逻辑的实现)可能是如何让主机操作系统启动、停止和管理服务实例。我们将在本章末尾更详细地讨论这一点,但首先需要检查一些其他常见的服务实现模式和关注点。

配置

服务经常需要进行配置,而不必更改实际的服务代码,以便最终用户或活动服务的管理者不必自己成为开发人员,就能有效地管理运行的服务实例。有几种选项可用于从文件中读取配置和设置值,每种都有其自身的优势和劣势。为了更好地进行比较和对比,让我们检查一下提供以下服务配置的变化:

  • 记录信息、警告、错误和严重级别的消息:

  • 向控制台输出信息和警告级别的消息

  • 所有信息,包括信息和警告级别的消息,都记录到一个单一的通用日志文件中,其位置是可配置的

  • 监听来自队列服务的输入消息,例如 RabbitMQ,或者基于云的队列服务,例如 AWS 的 SQS 或 Google Cloud Platform 的 Pub/Sub,并且需要知道以下内容:

  • 要监听的队列名称或 URL

  • 多久检查一次传入消息

  • 访问所讨论队列的凭据

Windows 风格的.ini 文件

Python 有一个用于处理 INI 文件(或者至少类似于基本 Windows INI 文件的文件)的标准包:configparser。一个兼容的类似 INI 的文件,提供了先前列出的项目的配置,可能看起来像以下内容:

[DEFAULT]
# This section handles settings-values that are available in other 
# sections.
# - The minimum log-level that's in play
log_level:      INFO
queue_type:     rabbit
queue_check:    5

[console_log]
# Settings for logging of messages to the console
# - Message-types to log to a console
capture:        INFO, WARNING

[file_log]
# Settings for file-logging
log_file:       /var/log/myservice/activity.log

[rabbit_config]
# Configuration for the RabbitMQ server, if queue_type is "rabbit"
server:         10.1.10.1
port:           5672
queue_name:     my-queue
user:           username
password:       password

INI 风格配置文件的一些优点包括以下内容:

  • 文件结构允许使用注释。任何以#;开头的行都是注释,不会被解析,这允许在配置文件中进行内联文档。

  • [DEFAULT]部分指定的值会被所有其他部分继承,并且按照最初指定的方式可用,或者在后续部分中进行覆盖。

  • 该格式本身已经存在很长时间,因此非常成熟和稳定。

可以使用一个简单的脚本检查此配置文件的值,列出每个配置部分中的可用值,并显示使用configparser工具解析的格式的一些潜在缺点:

生成此输出的脚本位于Iteration 3的代码中,位于hms-gateway/scratch-space/configuration-examples/ini_config.py

该格式的一些潜在缺点包括以下内容:

  • [DEFAULT]配置部分中的值会被所有其他部分继承,即使它们与实际情况无关。例如,queue_typequeue_check值在console_logfile_log部分中是可用的,尽管它们实际上并不相关。

  • 所有配置值都是字符串,可能需要转换为它们的实际值类型:int类型的queue_checkrabbit_config:port,可能是str值的list类型的console_log:capture,以及可能出现的其他任何值的bool类型转换等。

  • 该格式实际上只支持两个级别的配置数据(部分和其成员)。

尽管这些约束可能不会太有问题。知道它们的存在通常足以计划如何对其进行适应,而适应的形式可能不会比没有[DEFAULT]部分更复杂,将配置值分组到更连贯的部分中,例如loggingqueue

JSON 文件

JSON 数据结构也是存储配置文件数据的一个可行选择。JSON 支持不同类型和复杂的数据结构。这两者都是优势,尽管可能微不足道,但它们超过了基本的 INI 文件结构。虽然没有预定义的组织结构,但是确定配置值应该如何分组或组织是开发人员需要考虑的事情。配置数据也没有跨部分继承,因为没有可以继承的部分。尽管如此,它是一个简单、强大且相对容易理解的选项。前面的 INI 风格配置文件的近似 JSON 等效可能如下所示:

{
    "logging": {
        "log_level": "INFO",
        "console_capture": ["INFO","WARNING"],
        "log_file": "/var/log/myservice/activity.log"
    },
    "queue": {
        "queue_type": "rabbit",
        "queue_check": 5,
        "server": "10.1.10.1",
        "port": 5672,
        "queue_name": "my-queue",
        "user": "username",
        "password": "password"
    }
}

如果 JSON 有任何缺点(就其作为配置文件格式的使用而言),它们可能包括没有好的方法允许文件内注释。Python 的json模块提供的loadloads函数(分别用于转换 JSON 字符串和 JSON 文件)如果在解析 JSON 数据时除了数据结构之外还有其他内容,会引发错误JSONDecodeError。这并不是致命问题,但是在配置文件中添加注释(因此,文档)的能力确实有很多优势,特别是如果该配置将由不是开发人员或不愿意(或无法)深入代码本身以解决系统某个方面的配置的人来管理。

YAML 文件

另一个很好的配置文件候选者是 YAML。YAML 在许多方面类似于 JSON,它提供了结构化和类型化的数据表示,并支持复杂的嵌套数据结构。此外,它允许内联注释,pyyaml模块支持对在基于 JSON 的方法中根本无法使用的数据结构进行提示。YAML 像 Python 一样,使用缩进作为结构组织机制,指示(在 YAML 的情况下)项目之间的键/值关系。前面的 JSON 配置文件的等效形式(带有注释,并将所有元素(对象、列表成员等)分解为文件中的离散项)可能如下所示:

# Logging configuration
logging:
    console_capture:
        - INFO
        - WARNING
    log_file: /var/log/myservice/activity.log
    log_level: INFO
# Queue configuration
queue:
    queue_type: rabbit
    # Credentials
    user: username
    password: password
    # Network
    server: 10.1.10.1
    port: 5672
    # Queue settings
    queue_name: my-queue
    queue_check: 5

我们将在本章后面继续讨论使用 YAML 配置服务的想法。显然,YAML 并不是唯一的选择,但它是更好的选择之一,允许很好地结合易于理解、注释/文档的能力以及多个值类型的可用性。

记录服务活动

由于服务通常在后台不可见地运行,它们通常以某种方式记录其活动,即使只是为了提供对服务调用期间发生的事情的一些可见性。Python 提供了一个logging模块,允许从运行中的程序记录事件和消息的灵活性。以下是一个非常简单、蛮力的完整记录过程的例子:

import logging

# - Define a format for log-output
formatter = logging.Formatter(
    '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# - Get a logger. Once defned anywhere, loggers (with all their 
#   settings and attached formats and handlers) can be retrieved 
#   elsewhere by getting a logger instance using the same name.
logger = logging.getLogger('logging-example')
logger.setLevel(logging.DEBUG)
# - Create a file-handler to write log-messages to a file
file_handler = logging.FileHandler('example.log')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
# - Attach handler to logger
logger.addHandler(file_handler)

# - Log some messages to show that it works:
logger.critical('This is a CRITICAL-level message')
logger.debug('This is a DEBUG-level message')
logger.error('This is an ERROR-level message')
logger.info('This is an INFO-level message')
logger.warn('This is a WARNING-level message')

执行时,前面的脚本会生成以下日志输出:

Python 的Logger对象(由getLogger调用返回)可以设置为关注不同优先级级别的日志消息。从最不重要到最重要(从生产系统的角度来看),可用的默认级别(以及它们的一些典型用途)如下:

  • DEBUG:记录进程运行时的信息、它们所采取的步骤等,以便提供对代码执行细节的一些可见性。

  • INFO:信息项,例如请求处理过程的开始和结束时间;也许还有关于进程本身的详细信息或指标,例如传递了什么参数,或者给定的执行时间比预期长,但仍然完成了。

  • WARNING:并未阻止进程或操作完成,但因某种原因可疑的条件,例如完成时间远远超出预期。

  • ERROR:代码执行时遇到的实际错误,可能包括详细的回溯信息,可帮助开发人员找出实际导致错误的原因。

  • CRITICAL:记录在运行代码发生关键/致命故障之前拦截的信息 - 实际上导致执行终止的东西。在设计良好且实施良好的代码中,特别是对于始终可用的服务,很少需要记录此级别的消息。错误将被捕获并记录为ERROR级别的项目,错误发生后需要进行任何清理,将会进行,将发送一个指示发生错误的响应给请求者,服务将继续等待下一个请求。

任何给定级别的消息的实际处理和记录由Logger对象及/或其各种处理程序控制。Logger对象本身不会接受优先级低于其设置的优先级的消息。在示例代码中使用logger.setLevel(logging.DEBUG)将允许任何标准消息优先级,而将其更改为logger.setLevel(logging.ERROR)将仅允许接受ERRORCRITICAL消息。同样,处理程序将忽略任何低于其配置接受的优先级的传入消息 - 在上一个示例中使用file_handler.setLevel(logging.DEBUG)

通过在代码本身中结合详细的日志记录,包括在必要时使用DEBUG级别的项目,并对允许的消息优先级进行一些配置,相同的代码可以为不同的环境微调其自己的日志输出。例如:

def some_function(*args, **kwargs):
    logger.info('some_function(%s, %s) called' % (str(args), str(kwargs)))
    if not args and not kwargs:
        logger.warn(
            'some_function was called with no arguments'
        )
    elif args:
        logger.debug('*args exists: %s' % (str(args)))
        try:
            x, y = args[0:2]
            logger.debug('x = %s, y = %s' % (x, y))
            return x / y
        except ValueError as error:
            logger.error(
                '%s: Could not get x and y values from '
                'args %s' % 
                (error.__class__.__name__, str(args))
            )
        except Exception as error:
            logger.error(
                '%s in some_function: %s' % 
                (error.__class__.__name__, error)
            )
    logger.info('some_function complete')

此代码集根据在logger中设置的日志优先级的差异记录以下内容:

生成此日志信息的完整脚本位于迭代 3代码中,位于hms-gateway/scratch-space/logging-examples/logging-example.py

与 YAML 配置一样,我们将在本章后面构建在此日志结构的基础上,作为构建可重用的基础守护程序结构的一部分。

处理请求和生成响应

大多数服务都会遵循某种请求-响应过程模型。接收到一个请求,无论是来自与服务交互的人类用户还是其他进程;然后服务读取请求,确定如何处理它,执行所需的任何操作,并生成并返回响应。

至少有三种常见的请求类型足以值得进行详细检查 - 文件系统、HTTP/web 消息和基于队列的请求 - 每种请求类型都对服务接收到请求的方式有其自己的基本假设,并对设计和执行产生不同的影响。

对于任何给定的请求类型生成的响应通常意味着相同基本类型的响应机制。也就是说,来自某种文件系统变体的请求通常会生成一种以某种文件系统输出形式表达的响应。这可能并非总是如此,但很可能在许多(也许大多数)情况下都是如此。

基于文件系统

来自本地文件系统的请求和响应通常(并不奇怪地)涉及从本地文件读取和写入数据。这种类型的最简单的请求和响应结构是一个服务从一个文件中读取数据,处理它,并将结果写入另一个文件,可能在每次读取时删除或清空传入的文件,并在每次写入时替换输出文件,或者在生成和返回每个响应时追加到它。单个输入和输出文件的实现可以利用 Python 的sys模块的stdinstdout功能,或者覆盖其中的一个(或两个)。

Windows 和 POSIX 操作系统(Linux,macOS)都有特殊的文件类型,称为命名管道,它们驻留在文件系统上,并且像文件一样运行,可以通过标准文件访问代码打开、读取和写入。主要区别在于,命名管道文件可以同时被多个不同的进程打开和写入/读取。这样,任意数量的进程可以向文件添加请求,将它们排队等待服务读取和处理。命名管道也可以用于服务输出。

另一种变体是监视本地文件系统中文件的更改,包括在给定位置创建新文件,以及更改(甚至删除)现有文件。在最基本的情况下,这将涉及生成和维护要跟踪的文件列表,并定期检查实际的文件系统结构,以确定这些文件的存在和修改时间。遵循这种模式的实现可能会有一个常见的输入文件目录,并且在每次通过主服务循环时,它会检查新文件,读取它们,执行并在处理完成后删除文件(以保持要监视的文件数量相对较小)。

对于监视的文件数量足够大,以至于创建和刷新该列表的计算成本太高,不切实际,使用pyinotify库的功能来监视文件系统事件是一个可行的替代方案,尽管在 POSIX/Linux 和 Windows 版本的库之间存在差异。

基于 HTTP 或 Web 的

基于 HTTP 的服务(Web 服务),顾名思义,使用 HTTP 协议接收请求并向这些请求发送响应。作为网络感知服务的子集,Web 服务允许从除服务实际运行的机器之外的机器访问服务。Web 服务不一定要在公共互联网上可访问;它们可以完全存在于本地网络中,并且在这些边界内同样有效。但是,它们必须遵守一些基本的最低标准,并且可能受益于遵守其他标准。

遵守 HTTP 协议的请求方法可能是最重要的标准之一。在网站中最常见的方法,并且任何名副其实的 Web 浏览器都支持的方法如下:

  • GET:用于检索数据

  • POST:用于使用附加有效负载创建数据,尽管POST通常用于 Web 应用程序的createupdate操作

协议中还有其他几种可用的方法,包括:

  • PUTPATCH:用于使用附加有效负载整体或部分更新数据

  • DELETE:用于删除数据

  • OPTIONS:用于提供指示可用方法的数据,特别是可以在接收系统上创建或更改数据的方法,例如POSTPUTDELETE请求,尤其是如果请求是从服务的域之外的地方发出的

其他可能涉及的方法包括HEADCONNECTTRACE。根据服务的设计和实现,每种 HTTP 方法都可以作为类的特定函数或方法来实现,使得每种请求类型都能够强制执行其特定的任何要求,同时仍然允许一些常见需求的功能,比如提取POSTPUTPATCH请求的有效负载。

来自 Web 服务调用的响应,即使是空响应,也是必需的;否则,调用客户端将等待直到请求超时。Web 服务响应受限于可以通过 HTTP 协议传输的数据类型,这并不是非常有限的,但可能需要一些额外的开发工作来支持二进制资源响应(例如图像)。就目前而言,在撰写本书时,大多数纯文本表示的响应似乎以 JSON 数据结构返回,但 XML、HTML 和纯文本响应也是可能的。

虽然完全可以纯粹用 Python 编写一个完整的 Web 服务,但有许多与协议相关的项目可能最好由几个库、包或框架中的任何一个来处理,因为这样做将减少需要编写、测试和维护的代码量。选项包括但不限于以下内容:

  • 编写一个作为Web 服务器网关接口WSGI)应用程序的 Web 服务,可以通过 Apache 或 NGINX Web 服务器访问

  • 使用 Django REST 框架

  • 使用 Flask 框架的 Flask-RESTful 扩展

基于 Web 服务器和框架的解决方案也将受益于底层 Web 服务器和框架软件的安全更新,而无需进行内部安全审计。

如果期望将 Web 服务暴露给公共互联网,任何这些选项都比从头开始编写服务要好得多,仅仅因为这个原因。这不会消除对潜在安全问题的意识,但它会将这些问题的范围减少到服务功能本身的代码。

基于消息队列的

消息队列系统,如 RabbitMQ 和各种基于云的选项,对于某些类型的应用有几个优势。它们通常允许几乎任何消息格式的使用,只要它可以表示为文本,并且它们允许消息保持在挂起状态,直到它们被明确检索和处理,使消息保持安全并准备好使用,直到这些消息的最终消费者准备消费它们。例如,考虑以下情景:

  1. 两个用户通过存在于消息队列服务器上的分布式队列向服务发送消息

  2. 用户#1 发送了他们的第一条消息

  3. 服务接收并处理该消息,但可能尚未在队列中删除它

  4. 由于某种原因重新启动服务-可能是为了将其更新到新版本,或者因为服务器本身正在重新启动

  5. 无论如何,在服务重新上线之前,用户#2 发送了他们的第一条消息。

  6. 用户#1 发送了另一条消息

在目标服务完成启动之前,情景如下:

一旦目标服务完成启动,它只需轮询消息队列服务器以检索任何挂起的消息,并对其执行,就像在重新启动之前一样。

从用户#1 和用户#2 的角度来看,他们对服务的访问没有中断(尽管他们可能在收到响应时出现了明显甚至显著的延迟)。无论目标服务的不活动期是几秒还是几小时,这都是成立的。无论如何,最终用户发送的消息/命令都会被保存,直到可以执行,因此没有任何努力会被浪费。

如果对这些请求的响应也通过队列过程传输,那么消息的持久性也是成立的。因此,一旦响应由目标服务生成并发送,用户就能够接收到它们,即使在发送之前他们已经关闭并回家了。响应消息会等到接收系统再次活动时,然后它们将被传递并执行。

基于队列的请求和响应循环非常适合管理长时间运行和/或异步进程,只要处理消息的代码考虑到这种可能性。

其他请求类型

Python 提供了足够的通用网络功能,可以从头开始编写服务,以读取和响应几乎任何所需的网络流量。Web 和基于队列的服务类型是该功能的具体应用,在底层由额外的库支持,以不同程度地满足每种服务的特定需求,如下所示:

  • Web 服务可能会至少部分使用http.serversocket模块提供的功能;http.server.HTTPServersocketserver.TCPServer类是最可能的起点,但http.server.ThreadingHTTPServer也有潜在的可行性。

  • 基于队列的服务可能有专门构建的库可用,用于与它们附加的底层队列服务进行交互,包括以下内容:

  • pika,用于 RabbitMQ 队列服务

  • boto3,用于 AWS SQS 服务,从创建boto3.SQS.Client对象开始

没有某种支持库的基于套接字的服务可能会从前面的列表中指出的socketserver.TCPServer类开始,或者可能从其 UDP 等效socketserver.UDPServer开始。还有ThreadingForking混合类可用,可用于提供支持线程或(在符合 POSIX 的系统上)分叉的基本服务器类,以处理更大的用户负载水平。

请求和响应格式

从纯技术/功能的角度来看,服务实现可以是数据和格式无关的。也就是说,没有功能上的理由,一个服务不能接受原始二进制数据输入并返回原始二进制输出。毕竟,数据就是数据。然而,即使在服务真正关心的数据不容易被人类读取的情况下,格式化传入请求和传出响应也有优势,可以提供一定程度的人类可读性。至少,这样可以使请求和响应的调试更容易。

在这方面,请求和响应数据与配置文件需求有很多相似之处,如下所示:

  • 能够传递结构化和类型化的数据同样有利

  • 让数据结构至少在某种程度上对休闲读者/观察者可理解,也是一件好事

  • 能够表示相当复杂的数据结构——列表和嵌套对象——也感觉有利

考虑到相同类型的问题,解决方案也是类似的,这意味着使用 JSON 或 YAML 等序列化格式也是有道理的。这样做会增加一些额外的开发工作量;例如,将 JSON 格式的传入数据转换为本地数据结构,或者将本地数据结构响应转换为 JSON。不过,这种努力通常会相当微不足道。

在这两种格式中,JSON 可以说是更好的通用-用途解决方案。它已经得到了很好的建立,并且在更广泛的潜在服务客户端中得到了直接支持,因为它本质上是 Web 浏览器的本地数据格式。然而,YAML 仍然是一个可行的替代方案,特别是在不需要 Web 浏览器客户端支持的情况下。

通用服务设计

考虑到我们迄今探讨的配置和日志可能性,除非可以合理地期望只需要编写一个服务,否则裸骨的服务作为函数的方法似乎越来越不可行。当然,仍然可以采用这种基本方法,但是如果有必要创建另一个服务,那么如果有一个通用的起点来创建任何服务,无论它预期要做什么,将更有效(至少在某种程度上更有效地利用开发人员的时间)。因此,我们将定义一组抽象基类ABC),它们定义了我们将来期望从任何服务或守护进程中获得的功能和功能的最低公共分母,并将其用作hms_sys的 Artisan Gateway Service 的起点。

将服务定义为类而不是函数的原因在于,我们可以合理地期望至少有一些属性和方法对所有服务/守护进程都是共同的,这在简单的基于函数的设计中可能难以维护。这些包括以下内容:

  • 沿着之前介绍的示例日志记录代码的中心化日志记录设施

  • 服务的配置值很可能需要在多个端点之间访问,这可能更容易通过基于类的设计来管理

  • 使用可插拔的请求、响应和格式化机制几乎肯定会更容易开发和维护,因为这些机制将由封装所有必要功能的类表示

这里定义的类没有利用之前提到的任何可用标准库实体(例如,socketserver.TCPServer的正常、线程化或分叉变体)。它们只是任何服务的基线起点,至少在某个层面上,并且如果需要的话,可能会使用任何这些服务器类作为附加的混合。在另一个层面上,它们可以被认为纯粹是服务类所需功能的示例,尽管它们也可以用作某些应用程序的服务类。

这些类也是纯粹的同步。它们一次处理一个请求,处理完毕并返回响应,然后获取下一个请求并处理。这可能足够应付低负载场景,比如在hms_sys系统项目的情境中预期的那种,但对于其他用例可能不够,特别是如果涉及实时响应和更高计算成本的过程。在第十九章中,我们将讨论处理这些情况的一些选项,Python 中的多进程和 HPC*,同时讨论本地进程扩展选项。

我们要构建的 ABC 集合如下:

考虑以下内容:

  • BaseDaemon是创建实际提供服务的类的起点

  • BaseRequestHandler提供了一个起点,用于定义可调用对象,用于实际处理传入的请求,并负责使用从BaseResponseFormatter派生的类的实例格式化结果

  • BaseResponseFormatter是一个类似的可调用对象类,它将把响应数据结构转换为序列化的字符串值,准备好作为队列中的消息、HTTP 响应或者其他最适合特定响应需求的格式返回

BaseDaemon ABC

BaseDaemon的实现始于一个标准的 ABC 定义,以及一些类级别的属性/常量,如下所示:

class BaseDaemon(metaclass=abc.ABCMeta):
"""
Provides baseline functionality, interface requirements, and type-identity for objects that can act as a daemon/service managed by facilities in the local OS 
(like systemd) or by third-party service-configurators (like NSSM)
"""
    ###################################
    #   Class attributes/constants    #
    ###################################

    _handler_classes = {}
    _handler_keys = []

由于日志记录是任何服务的关键方面,确保一些日志记录参数始终可用是一个好主意。首先是设置一个存储默认日志配置的类级常量,如下所示:

# - Default logging information
    _logging = {
        'name':None,
        'format':'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
        'file':{
            'logfile':None,
            'level':logging.INFO,
        },
        'console':{
            'level':logging.ERROR,
        }
    }

这些默认值由一个名为_create_logger的通用方法使用,该方法由类提供为具体方法,以确保日志始终可用,但可以覆盖控制它的参数:

def _create_logger(self):
    """
Creates the instance's logger object, sets up formatting for log-entries, and 
handlers for various log-output destinations
"""
    if not self.__class__._logging.get('name'):
        raise AttributeError(
            '%s cannot establish a logging facility because no '
            'logging-name value was set in the class itself, or '
            'through configuration settings (in %s).' % 
            (self.__class__.__name__, self.config_file)
        )

在检查是否指定了日志记录器名称之后,使用_logging类属性来定义一个通用的日志输出格式,如下所示:

    try:
        logging_settings = self.__class__._logging
        # - Global log-format
        formatter = logging.Formatter(logging_settings['format'])
        # - The main logger
        self._logger = logging.getLogger(
            logging_settings['name']
        )
        # - By default, the top-level logger instance will accept anything. 
        #   We'll change that to the appropriate level after checking the 
        #   various log-level settings:
        final_level = logging.DEBUG

相同的日志设置允许独立控制日志的文件和控制台输出。基于文件的日志输出需要一个logfile规范,并允许独立的level

        if logging_settings.get('file'):
            # - We're logging *something* to a file, so create a handler 
            #   to that purpose:
            if not self.__class__._logging['file'].get('logfile'):
                raise AttributeError(
                    '%s cannot establish a logging facility because no '
                    'log-file value was set in the class itself, or '
                    'through configuration settings (in %s).' % 
                    (self.__class__.__name__, self.config_file)
                )
            # - The actual file-handler
            file_handler = logging.FileHandler(
                logging_settings['file']['logfile']
            )
            # - Set the logging-level accordingly, and adjust final_level
            file_handler.setLevel(logging_settings['file']['level'])
            final_level = min(
                [
                     logging_settings['file']['level'],
                     final_level
                ]
            )
            # - Set formatting and attach it to the main logger:
            file_handler.setFormatter(formatter)
            self._logger.addHandler(file_handler)

随着每个日志输出的创建和附加,日志级别用于重置final_level值,最终允许设置过程对输出附加的日志对象进行日志级别的微调。控制台日志输出设置看起来与文件日志输出类似,只是没有文件名,因为它不需要:

    if logging_settings.get('console'):
        # - We're logging *something* to the console, so create a 
        #   handler to that purpose:
        # - The actual console-handler
        console_handler = logging.StreamHandler()
        # - Set the logging-level accordingly, and adjust final_level
        console_handler.setLevel(
            logging_settings['console']['level']
        )
        final_level = min(
            [
                 logging_settings['console']['level'],
                 final_level
            ]
         )
        # - Set formatting and attach it to the main logger:
        console_handler.setFormatter(formatter)
        self._logger.addHandler(console_handler)
        # - For efficiency's sake, use the final_level at the logger itself. 
        #   That should (hopefully) allow logging to run (trivially) 
        #   faster, since it'll know to skip anything that isn't handled by 
        #   at least one handler...
        self._logger.setLevel(final_level)

为了确保日志始终可用,到目前为止的所有设置都在try…except结构中执行。如果在设置日志过程中发生任何错误,将引发最终的RuntimeError,目的是停止所有执行,以便修复导致日志失败的任何问题:

except Exception as error:
    raise RuntimeError(
        '%s could not complete the set-up of its logging '
        'facilities because %s was raised: %s' % 
            (
                self.__class__.__name__, error.__class__.__name__, 
                error
            )
    )
# - Log the fact that we can log stuff now :-)
    self.info(
        'Logging started. Other messages may have been output to '
        'stdout/terminal prior to now'
    )

一旦实例的logger对象属性被创建,记录任何消息只是简单地调用实例的各种记录方法之一。这些方法——criticaldebugerrorinfowarn——看起来多少相似,并将消息写入各种日志记录输出的适当优先级,或者如果尚未创建logger,则退回到打印消息:

###################################
#        Logging methods          #
###################################

def critical(self, msg, *args, **kwargs):
    if self.logger:
        self.logger.critical(msg, *args, **kwargs)
    else:
        print('CRITICAL - %s' % msg)

def debug(self, msg, *args, **kwargs):
    if self.logger:
        self.logger.debug(msg, *args, **kwargs)
    else:
        print('DEBUG    - %s' % msg)

该类的属性在很大程度上与早期代码中使用的结构和模式相似,其相关的 setter 方法附加了典型的类型和值检查:

    ###################################
    #  Instance property definitions  #
    ###################################

    config_file = property(
        _get_config_file, None, None, 
        'Gets the configuration-file used to set up the instance'
    )
    logger = property(
        _get_logger, None, None, 
        'Gets the logger for the instance'
    )

config_file属性的 setter 方法值得更仔细地查看,因为它执行了一些检查,以确保传递的值是一个可读文件:

def _set_config_file(self, value:(str,)):
    if type(value) != str:
        raise TypeError(
            '%s.config_file expects a string value that points '
            'to a readable configuration-file on the local file-'
            'system, but was passed "%s" (%s)' % 
            (self.__class__.__name__, value, type(value).__name__)
        )
    if not os.path.isfile(value):
        if type(value) != str:
            raise TypeError(
                '%s.config_file expects a string value that '
                'points to a readable configuration-file on the '
                'local file-system, but was passed "%s" (%s), '
                'which is not a file' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
    if not os.access(value, os.R_OK):
        if type(value) != str:
            raise TypeError(
                '%s.config_file expects a string value that '
                'points to a readable configuration-file on the '
                'local file-system, but was passed "%s" (%s), '
                'which is not a READABLE file' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__
                )
            )
    self.debug(
        '%s.config_file set to %s' % (self.__class__.__name__, value)
    )
    self._config_file = value

一旦配置文件被验证为可供使用,该类提供的另一个具体方法configure可以被调用来读取并将其应用到类的实例。configure方法负责读取文件,将其转换为通用数据结构,并将其传递给一个实际将配置数据应用到实例的必需/抽象方法:_on_configuration_loaded

这种责任划分允许一个通用方法configure始终可用,同时允许任何给定类的特定需求被抽象化并成为派生类_on_configuration_loaded的责任:

def configure(self):
    """
Reads the instance's configuration-file, converts it to a dictionary of values, then hands the responsibility for actually configuring the instance off to its required _on_configuration_loaded method
"""
    try:
        self.info('Loading configuration for %s' % self.__class__.__name__)
    except RuntimeError:
        # - This should only happen during start-up...
        print('Loading configuration for %s' % self.__class__.__name__)
    try:
        fp = open(self.config_file, 'r')
        config_data = yaml.load(fp)
        fp.close()
    except Exception as error:
        raise RuntimeError(
            '%s.config could not read configuration-data from '
            '%s, %s was raised: %s' % 
            (
                self.__class__.__name__, config_file, 
                error.__class__.__name__, error
            )
        )
    # - With the configuration read, it's time to actually 
    #   configure the instance
    self._on_configuration_loaded(**config_data)

_on_configuration_loaded方法可以包含其他类可能选择使用的一些具体代码,如下所示:

@abc.abstractmethod
def _on_configuration_loaded(self, **config_data):
    """
Applies the configuration to the instance. Since there are configuration values that may exist for any instance of the class, this method should be called by derived classes in addition to any local configuration.
"""
    if config_data.get('logging'):
        # - Since the class' logging settings are just a dict, we can 
        #   just update that dict, at least to start with:
        self.__class__._logging.update(config_data['logging'])
        # - Once the update is complete, we do need to change any logging-
        #   level items, though. We'll start with the file-logging:
        file_logging = self.__class__._logging.get('file')
        if file_logging:
            file_level = file_logging.get('level')
            if not file_level:
                file_logging['level'] = logging.INFO
            elif type(file_level) == str:
                try:
                    file_logging['level'] = getattr(
                        logging, file_level.upper()
                    )
                except AttributeError:
                    file_logging['level'] = logging.INFO
        # - Similarly, console-logging
        console_logging = self.__class__._logging.get('console')
        if console_logging:
            console_level = console_logging.get('level')
            if not console_level:
                console_logging['level'] = logging.INFO
            elif type(console_level) == str:
                try:
                    console_logging['level'] = getattr(
                        logging, console_level.upper()
                    )
                except AttributeError:
                    console_logging['level'] = logging.INFO

如果使用了这个标准配置,它将寻找一个类似以下的 YAML 配置文件:

logging:
    console:
        level: error
    file:
        level: debug
        logfile: /var/log/daemon-name.log
    format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    name: daemon-name

值得注意的是,各种配置方法可能会处理日志记录设置,并且在日志记录完成之前需要记录消息。这就是之前显示的日志记录方法具有回退到打印功能的原因。

刚刚显示的默认实现确实做到了这一点。这就是创建BaseDaemon实例时执行的所有代码。初始化本身非常基本,尽管其中有一些新的值得注意的项目,如下所示:

def __init__(self, config_file:(str,)):
    """
Object initialization.
self .............. (BaseDaemon instance, required) The instance to 
                    execute against
config_file ....... (str, file-path, required) The location of the 
                    configuration-file to be used to configure the 
                    daemon instance
"""
    # - Call parent initializers if needed
    # - Set default instance property-values using _del_... methods
    self._del_config_file()
    self._del_logger()
    # - Set instance property-values from arguments using 
    #   _set_... methods
    self._set_config_file(config_file)
    # - Perform any other initialization needed
    # - Read configuration and override items as needed
    self.configure()
    # - Set up logging
    self._create_logger()
    # - Set up handlers to allow graceful shut-down
    signal.signal(signal.SIGINT, self.stop)
    signal.signal(signal.SIGTERM, self.stop)
    self.debug(
        'SIGINT and SIGTERM handlers for %s created' % 
        (self.__class__.__name__)
    )
    # - Set up the local flag that indicates whether we're expected 
    #   to be running or not:
    self._running = False

值得注意的第一项是对signal.signal()的调用。这些使用 Python 的signal模块来设置信号事件处理过程,以便如果类的运行实例在操作系统级别被终止或在终端会话中被中断,它不会立即死掉。相反,这些调用会捕获操作系统发出的终止(SIGTERM)和中断(SIGINT)信号,并允许运行的代码在终止执行之前对其做出反应。在这种情况下,它们都调用实例的stop方法,这给了服务实例机会告诉它的main循环终止,从而实现了优雅的关闭。

实现这一点的最简单方法是有一个实例值(在这种情况下是self._running),该值由服务的主循环用于确定是否继续。该标志值在上一个__init__方法的末尾设置。

虽然服务类的main循环方法是类的最重要的方面(毕竟没有这个方法,服务实际上什么也做不了),但main循环是特定于派生类的。它是必需的,但实际上不能在 ABC 级别实现,因此将其作为抽象方法,如下所示:

@abc.abstractmethod
def main(self):
    """
The main event-loop (or whatever is equivalent) for the service instance.
"""
    raise NotImplementedError(
        '%s.main has not been implemented as required by '
        'BaseDaemon' % (self.__class__.__name__)
    )

为了允许在服务启动之前和终止之后需要触发的进程,我们为每个preflightcleanup提供了具体的方法。这些方法被设置为具体方法,而不是抽象方法,以便它们始终可用,但可以根据需要进行重写。它们的默认实现只是记录它们已被调用:

    def cleanup(self):
        """
Performs whatever clean-up actions/activities need to be executed after the main process-loop terminates. Override this in your daemon-class if needed, otherwise it can be left alone.
"""
        self.info('%s.cleanup called' % (self.__class__.__name__))

    def preflight(self):
        """
Performs whatever pre-flight actions/activities need to be executed before starting the main process. Override this in your daemon-class if needed, otherwise it can be left alone.
"""
        self.info('%s.preflight called' % (self.__class__.__name__))

preflight方法可能对实现reload方法(在不停止服务实例的情况下,重新获取任何本地、可能被更改的数据,然后恢复)很有用。

最后,服务实例需要能够通过单个简单的命令启动、停止,甚至可能重新启动。相应的方法非常简单,如下所示:

def start(self):
    """
Starts the daemon/service that the instance provides.
"""
    if self._running:
        self.info(
            '%s instance is already running' %     (self.__class__.__name__)
        )
        return
    self.preflight()
    self.info('Starting %s.main' % self.__class__.__name__)
    self.main()
    self.cleanup()

def stop(self, signal_num:(int,None)=None, frame:(FrameType,None)=None):
    """
Stops the daemon-process. May be called by a signal event-handler, in which case the signal_num and frame values will be passed. Can also be called directly without those argument-values.

signal_num ........ (int, optional, defaults to None) The signal-number, if any, that prompted the shutdown.
frame ............. (Stack-frame, optional, defaults to None) The associated stack-frame.
"""
    self.info('Stopping %s' % self.__class__.__name__)
    self.debug('+- signal_num ... %s' % (signal_num))
    self.debug('+- frame ........ %s' % (frame))
    self._running = False

def restart(self):
    """
Restarts the daemon-process by calling the instance's stop then start methods. This may not be directly accessible (at least not in any useful fashion) outside the running instance, but external daemon/service managers should be able to simply kill the running process and start it up again.
"""
    self.info('Restarting %s' % self.__class__.__name__)
    self.stop()
    self.start()

这个类使用了几个需要包含的包/库,因此我们必须确保将它们包含在类所在的模块中,如下所示:

#######################################
#   Standard library imports needed   #
#######################################

import atexit
import logging
import os
import signal
import yaml

from types import FrameType    # Used by the signal handlers 

有了这段代码,创建一个新的服务类(相当于本章开头的简单基于函数的示例)就非常简单了:

class testdaemon(BaseDaemonizable):
    def _on_configuration_loaded(self, **config_data):
        try:
            BaseDaemonizable._on_configuration_loaded(self, **config_data)
            self.info('%s configuration has been loaded:' % 
                (self.__class__.__name__)
            )
        except Exception as error:
            self.error(‘%s: %s' % (error.__class__.__name__, error))
    def main(self):
        iteration = 0
        self._running = True
        self.info('Starting main daemon event-loop')
        while self._running:
            iteration += 1
            msg = 'Iteration %d' % iteration
            self.info(msg)
            sleep(10)
        self.info('%s main loop terminated' % (self.__class__.__name__))

以下屏幕截图显示了从启动testdaemon到杀死它后的几次迭代的输出和记录的消息。它显示了我们期望的代码行为:

这个基本服务不使用任何请求处理程序类——它太简单了,不需要它们——但更现实的服务实现几乎肯定需要这种能力。每个处理程序类都需要在服务实例启动之前注册,并且需要一种方法来关联来自传入请求的某个属性或值,以识别要创建的处理程序类,以生成对请求的响应。

在执行过程中,随着请求的到来,这些请求必须被检查,以确定确定哪个处理程序类将用于创建一个实例。然后执行可以交给该实例来创建响应。

处理程序类注册过程并不困难,但其中有相当多的类型和值检查,以避免以后出现不良、模糊或冲突的结果。它被实现为一个类方法,这样在服务实例化之前就可以建立键(端点、命令、消息类型或适用于传入请求的任何内容)和这些键后面的处理程序类之间的关联:

    @classmethod
    def register_handler(cls, handler_class:(type,), *keys):
        """
Registers a BaseRequestHandler *class* as a candidate for handling 
requests for the specified keys
"""
        if type(handler_class) != type \
            or not issubclass(handler_class, BaseRequestHandler):
            raise TypeError(
                '%s.register_handler expects a *class* derived from '
                'BaseRequestHandler as its handler_class argument, but '
                'was passed "%s" (%s), which is not such a class' % 
                (cls.__name__, value, type(value).__name__)
            )
        if not keys:
            raise ValueError(
                '%s.register_handler expects one or more keys, each '
                'a string-value, to register the handler-class with, '
                'but none were provided' % (cls.__name__)
            )
        # - Check for malformed keys
        bad_keys = [
            key for key in keys
            if type(key) != str or '\n' in key or '\r' in key
            or '\t' in key or key.strip() != key or not key.strip()
        ]
        if bad_keys:
            raise ValueError(
                '%s.register_handler expects one or more keys, each a '
                'single-line, non-empty string-value with no leading '
                'or trailing white-space, and no white-space other '
                'than spaces, but was passed a list including %s, '
                'which do not meet these criteria' % 
                (cls.__name__, '"' + '", "'.join(bad_keys) + '"')
            )
        # - Check for keys already registered
        existing_keys = [
            key for key in keys if key in cls._handler_classes.keys()
        ]
        if existing_keys:
            raise KeyError(
                '%s.register_handler is not allowed to replace handler-'
                'classes already registered, but is being asked to do '
                'so for %s keys' % 
                (cls.__name__, '"' + '", "'.join(existing_keys) + '"')
            )
        # - If this point is reached, everything is hunky-dory, so add 
        #   the handler_class for each key:
        for key in keys:
            cls._handler_classes[key] = handler_class

查找要实例化以处理给定请求的类的过程,给定一个键,也不难;请参阅以下代码:

def find_request_handler(self, key:(str,)):
    """
Finds a registered BaseRequestHandler class that is expected to be able 
to handle the request signified by the key value, creates an instance 
of the class, and returns it.
"""
    # - Set up the _handler_keys if it hasn't been defined yet. 
    #   The goal here is to have a list of registered keys, sorted from 
    #   longest to shortest so that we can match based on the 
    #   longest registered key/path/command-name/whatever that 
    #   matches the incoming value:
    if not self.__class__._handler_keys:
        self.__class__._handler_keys = sorted(
            self.__class__._handler_classes.keys(),
            key=lambda k: len(k), 
            reverse=True
        )
    # - Find the first (longest) key that matches the incoming key:
    for candidate_key in self.__class__._handler_keys:
        if candidate_key.startswith(key):
        # - If we find a match, then create an instance of 
        #   the class and return it
            result = self.__class__._handler_classes[candidate_key]
            return result(self)
    return None

这个方法将返回它能找到的第一个与传入请求键匹配的实例,并且它将返回它能找到的最长键匹配,以便允许同一个类处理多个键,并且(希望)消除坏键匹配的可能性。考虑一个与client对象交互的 Web 服务,这些对象可以有从属的client对象,通过包含以下内容的路径来访问这些客户端:

  • /client/{client_id}:使用client_handler对象处理请求

  • /client/{client_id}/client/{subordinate_id}:使用subordinate_handler对象处理请求

为了确保应该由subordinate_handler处理的请求不会意外地获取并使用client_handler,匹配过程会迭代端点键列表,从最长到最短,首先匹配较长的键,然后返回适当的类。

BaseRequestHandler 和 BaseResponseFormatter ABCs

没有从这些类派生的具体实现,它们实际上并没有什么。它们使用了本书中一直在使用的相同标准属性结构,具有典型的类型检查。它们提出的唯一新概念是抽象的组合(这并不新鲜)和利用 Python 的__call__魔术方法。

我们将在下一章中间接地查看这些类(至少是间接地),当从这些类派生的具体实现为hms_sys Artisan Gateway Service 创建时。

当一个类有一个__call__方法时,该类的实例可以被调用,就好像它们是函数,其所需的参数在__call__方法本身的签名中定义。实际上,可调用的类实例可以被认为是可配置的函数。可调用类的每个实例可以具有完全不同的状态数据,在其自己的范围内保持一致。举个简单的例子,考虑以下代码:

class callable_class:
    def __init__(self, some_arg, some_other_arg):
        self._some_arg = some_arg
        self._some_other_arg = some_other_arg

    def __call__(self, arg):
        print('%s(%s) called:' % (self.__class__.__name__, arg))
        print('+- self._some_arg ......... %s' % (self._some_arg))
        print('+- self._some_other_arg ... %s' % (self._some_other_arg))

假设我们创建一个实例并称之为以下内容:

instance1 = callable_class('instance 1', 'other arg')
instance1('calling instance 1')

然后我们将得到以下输出:

我们可以创建额外的实例,并调用它们,而不会影响第一个实例的结果:

instance2 = callable_class('instance 2', 'yet other arg')
instance2('calling instance 2')

前面的代码产生以下结果:

通过将这两个类的__call__方法设为抽象,我们实际上要求它们实现一个__call__方法,允许每个实例被调用,就好像它是一个函数,同时允许每个实例访问任何类实例可用的属性和方法。

将其应用到BaseRequestHandler,这意味着每个实例都将直接引用daemon实例,具有其所有的日志记录设施,其startstoprestart方法,以及原始配置文件;因此,以下内容将适用:

  • 请求处理程序实例不必做任何非常复杂的事情来记录处理细节

  • 对个别请求处理程序的配置是可行的,甚至可以存储在守护程序本身使用的同一配置文件中,尽管目前,配置仍然必须被读取和执行

  • 可以编写一个或多个处理程序(需要适当的注意,包括身份验证和授权),允许服务请求重新启动服务

其他服务守护程序,具有更多/其他功能,可以在服务实例本身的级别提供对每个端点可访问的公共功能。因此,使用完整一套这些请求处理程序和响应格式化程序对象的服务将包括以下内容:

  • 一个从BaseDaemon派生的单个服务实例,具有以下内容:

  • 一个到多个BaseRequestHandler派生类被注册并可用于实例化和响应传入请求,每个类又可以创建和调用多个BaseResponseFormatter派生类的实例,以生成最终的输出数据

  • 通过一个main的实现,根据这些类的注册确定为每个请求创建和调用哪个类。

使用请求处理程序处理工匠和产品交互以及响应格式化程序实现的工匠网关服务的请求-响应循环流程可能如下所示:

逐步:

  1. 一个请求被发送到工匠网关服务

  2. 服务根据请求中的预定义context确定应该实例化和调用工匠处理程序

  3. 该处理程序知道它需要生成 JSON 输出,因此,在执行生成可以格式化的响应所需的任何处理之后,它获取一个JSON 格式化程序实例并调用该实例生成最终的响应

  4. 响应被返回给工匠处理程序

  5. 工匠处理程序响应返回给工匠网关服务

  6. 工匠网关服务响应返回给请求的发起者

大部分流程依赖于BaseRequestHandlerBaseResponseFormatter类未提供的具体实现。正如前面的图表所示,它们非常简单。BaseRequestHandler从标准的抽象类结构开始,如下所示:

class BaseRequestHandler(metaclass=abc.ABCMeta):
    """
Provides baseline functionality, interface requirements, and 
type-identity for objects that can process daemon/service requests, 
generating and returning a response, serialized to some string-based 
format.
"""

每个派生类可以有一个与之关联的默认格式化程序类,因此该类的实例的最终调用不需要指定格式化程序,如下所示:

    ###################################
    #    Class attributes/constants   #
    ###################################

    _default_formatter = None

请求处理程序可以从它们被创建的服务/守护实例中受益。至少,这允许处理程序类使用守护程序的日志记录设施。因此,我们将跟踪该守护程序作为实例的属性,如下所示:

    ###################################
    #    Property-getter methods      #
    ###################################

    def _get_daemon(self) -> (BaseDaemon,):
        return self._daemon
    ###################################
    #    Property-setter methods      #
    ###################################

    def _set_daemon(self, value:(BaseDaemon,)) -> None:
        if not isinstance(value, BaseDaemon):
            raise TypeError(
                '%s.daemon expects an instance of a class derived '
                'from BaseDaemon, but was passed "%s" (%s)' % 
                (self.__class__.__name__, value, type(value).__name__)
            )
        self._daemon = value

    ###################################
    #    Property-deleter methods     #
    ###################################

    def _del_daemon(self) -> None:
        self._daemon = None

    ###################################
    #  Instance property definitions  #
    ###################################

    daemon = property(
        _get_daemon, None, None, 
        'Gets, sets or deletes the daemon associated with the instance'
    )

实例的初始化必须提供一个参数来设置实例的daemon属性,但除此之外没有太多内容:

    ###################################
    #     Object initialization       #
    ###################################

    def __init__(self, daemon:(BaseDaemon,)):
        """
Object initialization.
self .............. (BaseRequestHandler instance, required) The 
                    instance to execute against
daemon ............ (BaseDaemon instance, required) The daemon that the 
                    request to be handled originated with.
"""
# - Set default instance property-values using _del_... methods
        self._del_daemon()
# - Set instance property-values from arguments using 
#   _set_... methods
        self._set_daemon(daemon)

由于 ABC 的整个重点是要求实例可以被创建它们的服务调用,我们将需要一个__call__方法。每当实例被调用时,它将有一个需要处理和响应的传入请求。允许传递一个formatter也是一个好主意,它可以覆盖默认的formatter类型,指定为一个类属性。随着处理程序类的具体实现的编写,需要考虑如何处理类没有指定formatter类型,并且在调用本身中没有提供formatter类型的情况。尽管这可能会在请求类型之间有很大的差异,但现在还没有必要深入讨论这个问题:

    ###################################
    #        Abstract methods         #
    ###################################

    @abc.abstractmethod
    def __call__(self, request:(dict,), formatter=None) -> (str,):
"""
Makes the instance callable, providing a mechanism for processing the 
supplied request, generating a data-structure containing the response 
for the request, formatting that response, and returning it.
self .............. (BaseRequestHandler instance, required) The instance to execute against
request ........... (dict, required) The request to be handled
formatter ......... (BaseResponseFormatter instance, optional, if not 
"""
        pass

BaseResponseFormatter ABC 也开始作为一个标准的抽象类。它也使用相同的daemon属性,并添加一个request_handler属性,使用类似的 setter 方法,允许格式化程序实例访问创建它的请求实例,以及接收请求的守护实例:

    def _set_request_handler(self, value:(BaseRequestHandler,)) -> None:
        if not isinstance(value, BaseRequestHandler):
            raise TypeError(
                '%s.request_handler expects an instance of a class '
                'derived from BaseRequestHandler, but was passed '
                '"%s" (%s)' % 
                (self.__class__.__name__, value, type(value).__name__)
            )
        self._request_handler = value

请求处理程序在创建实例时需要被要求,原因与需要要求daemon相同:

    def __init__(self, 
        daemon:(BaseDaemon,), 
        request_handler:(BaseRequestHandler,),
    ):
"""
Object initialization.

self .............. (BaseResponseFormatter instance, required) The 
                    instance to execute against
daemon ............ (BaseDaemon instance, required) The daemon that the 
                    request to be handled originated with.
request_handler ... (BaseRequesthandler instance, required) The request-handler object associated with the instance.
"""
        # - Set default instance property-values using _del_... methods
        self._del_daemon()
        self._del_request_handler()
        # - Set instance property-values from arguments using 
        #   _set_... methods
        self._set_daemon(daemon)
        self._set_request_handler(request_handler)

最后,与BaseRequestHandler一样,我们将要求任何派生类实现一个__call__方法:

    @abc.abstractmethod
    def __call__(self, response:(dict,)) -> (str,):
        """
Makes the instance callable, providing a mechanism for formatting a 
standard response-dictionary data-structure.

self .............. (BaseRequestHandler instance, required) The 
                    instance to execute against
response .......... (dict, required) The response to be formatted
"""
        pass

一般来说,类(特别是具体类)如果这么简单(只有一个方法,加上它们的初始化器__init__)并不是最佳的实现方法。一个只有一个方法的类通常可以被处理为一个单独的函数,即使该函数有一组更复杂的参数。随着具体实现的进展,格式化程序类很可能会落入这个类别。如果是这样,将对其进行重构(希望是简单的)函数,但目前BaseResponseFormatter将被保留下来,因为它已经被编写。

BaseRequestHandler ABC 在这方面不太值得担心。与不同后端数据对象交互的请求可以被分组到这些对象类型的处理程序中;例如,为工匠创建一个ArtisanHandler,为产品创建一个ProductHandler。可以预见到,每个处理程序至少会有一些用于各种 CRUD 操作的方法,这些方法将在__call__方法处理请求时被调用,但在特定用例和服务上下文中还会出现其他需求,如下所示:

  • 在 Web 服务上下文中,可能需要实现多达五种额外的方法 - 每种方法对应一个HEADCONNECTOPTIONSTRACEPATCH HTTP 方法

  • 在没有像 Web 服务的 HTTP 方法那样严格定义的操作集的服务上下文中,甚至还有更多的潜力可以添加额外的方法 - 甚至每个业务流程可能需要支持的请求都可以有一个方法

即使有这些复杂性,实现处理请求/响应周期的功能也是可行的。它们只是更大、更复杂的功能,很可能更难以长期改变或维护。

将服务与操作系统集成

在进入具体功能之前,服务实现难题的最后一个重要部分是编写一个用 Python 编写的服务程序,以实际在操作系统级别执行服务。该过程的具体细节会因不同的操作系统而异(尤其是在不同版本的某些操作系统 - 尤其是 Linux 上),但有一些通用操作必须在各个方面进行处理,如下所示:

  • 服务需要在所运行的机器启动时启动

  • 服务需要在所运行的机器被关闭或重新启动时优雅地停止

  • 服务需要能够重新启动(通常只是一个停止-然后-启动的过程)

一些服务模型可能还会受益于能够重新加载它们的数据和/或配置,而不会在此过程中中断服务访问,特别是如果等效的重新加载过程比重新启动耗时。可能还有其他特定场景下有用的操作。

对这些机制的探索将使用之前展示的testdaemon类。

使用 systemctl 运行服务(Linux)

Linux 发行版正在摆脱旧的 System V 风格的启动进程,转向一个更新的机制,即systemd守护进程及其相关的systemctl命令行工具。由systemd/systemctl管理的服务至少需要一个定义启动和关闭过程的配置文件,一个控制这些过程如何被操作系统处理的类型定义,以及启动或停止服务进程所需的可执行文件。一个最简单的testdaemon.service配置文件可能如下所示:

[Unit]
Description=testdaemon: a simple service example written in Python

[Service]
Type=forking
ExecStart=/usr/bin/python /usr/local/bin/testdaemon.py
ExecStop=/usr/bin/pkill -f testdaemon.py

在前面的代码中,以下内容适用:

  • Unit/Description条目只是服务的简短描述,通常不过是一个名称。

  • Service/Type定义了启动过程将由systemd守护程序处理的方式。在这种情况下,执行将被分叉,以便调用它的任何进程不再与它关联,并且可以在不停止服务本身的情况下终止。

  • Service/ExecStart定义了启动服务的进程,本例中通过执行testdaemon.py文件作为 Python 脚本来执行。

  • Service/ExecStop定义了停止服务的进程,本例中通过杀死所有进程的方式来停止服务,这些进程的名称中带有testdaemon.py

假设实际的testdaemon类可以从某个已安装的包中导入,启动服务的testdaemon.py脚本可以简单如下:

#!/usr/bin/env python

# - Import the service-class
    from some_package import testdaemon
# - The location of the config-file
    config_file = '/path/to/config.yaml'
# - Create an instance of the service class
    d = testdaemon(config_file)
# - Start it.
    d.start()

有了这两个文件,从命令行启动,重新启动和停止服务的命令分别如下:

systemctl start testdaemon.service

systemctl restart testdaemon.service

systemctl stop testdaemon.service

systemd管理的服务必须启用才能在启动时启动,如下所示:

systemctl enable testdaemon.service

上述命令要求在相应的systemd .service文件中添加安装规范,如下所示:

...
ExecStop=/usr/bin/pkill -f testdaemon.py

[Install]
WantedBy=multi-user.target

systemd服务配置还有很多其他选项,但这些最基本的设置将允许使用标准命令行工具自动启动和管理服务。

使用 NSSM(Windows)运行服务

在 Windows 机器上安装用 Python 编写的服务的最简单方法是使用Non-Sucking Service ManagerNSSM)。 NSSM 提供了一种简单的方式来包装特定的可执行文件(主要是python.exe文件),以及参数(testdaemon.py脚本),并将它们作为 Windows 服务提供。使用nssm install启动 NSSM 提供了一个窗口,其中包含了基本服务设置所需的所有字段,如下所示:

单击安装服务按钮后,服务将在 Windows 服务管理器中可用,如果需要,可以更改其启动类型,以及所有其他标准 Windows 服务设置和属性:

还可以通过运行nssm install <service-name>更改由 NSSM 创建的服务的属性,该命令显示用于创建服务条目的相同 UI。

如果 NSSM 打包的服务无法启动,它将向标准 Windows 事件日志记录有用的信息;调试启动问题应从那里开始。如果有任何问题,很可能与权限相关,例如服务帐户无法访问脚本文件、配置文件等。

macOS,launchd 和 launchctl

Macintosh 操作系统macOS)在底层是 Unix 变种,因此在许多方面,与 Linux 和 Windows 服务安装相比,问题或差异会更少。 macOS 提供了与systemdsystemctl大致相当的东西:launchdlaunchctl程序。它们提供了与最小限度的服务启动和关闭控制功能相同类型的服务,还提供了许多额外的选项,用于处理基于各种系统事件的服务进程。

免责声明:在撰写本书时,没有 macOS 机器可供测试,因此,尽管本节应该是完整的并且可以按原样使用,但在出版之前可能存在未识别的问题。

一个最基本的launchd兼容的服务配置文件需要包含一个服务标签,当服务启动时执行的程序,以及程序需要的任何参数:正是systemd所需的,尽管launchd管理的服务的配置文件是 XML 文件。使用testdaemon.py作为启动实际服务对象的脚本,并提供运行时加载和保持活动控制的基本起点配置如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>Label</key>
        <string>testdaemon</string>
        <key>Program</key>
        <string>/path/to/python</string>
        <key>ProgramArguments</key>
        <string>/path/to/testdaemon.py</string>
        <key>RunAtLoad</key>
        <true/>
        <!-- 
            A very basic keep-alive directive. There may be better options:
            See "SuccessfulExit" and "Crashed" subkeys
        -->
        <key>KeepAlive</key>
        <true/>
    </dict>
</plist>

一旦配置在launchd文件的标准位置之一,该服务可以分别启动、重新启动和停止,如下所示:

launchctl start testdaemon.service

launchctl restart testdaemon.service

launchctl stop testdaemon.service

在其他系统上管理服务

尽管目前在 Linux 系统中管理服务进程的趋势是,正如所指出的,向着systemd/systemctl发展,但仍然可能有一些操作系统仍在使用 System V 风格的初始化脚本。这样一个脚本的最基本起点可能如下所示:

#!/bin/sh

# - The action we're concerned with appears as $1 in a standard 
#   bash-script
    case $1 in
        start)
            echo "Starting $0"
            /usr/bin/python /usr/local/bin/testdaemon.py
            ;;
        stop)
            echo "Stopping $0"
            /usr/bin/pkill -f testdaemon.py
            ;;
        restart)
            echo "Restarting $0"
            /usr/bin/pkill -f testdaemon.py
            /usr/bin/python /usr/local/bin/testdaemon.py
            ;;
    esac

在 System V 管理的环境中,服务本身必须负责确保它与调用它的任何进程(终端会话或操作系统本身的启动进程)分离。否则,服务进程可能只是启动,然后在实际执行任何操作之前终止。

随着时间的推移,这种情况可能会越来越少见,但仍然有可能。在daemons模块中有一个类BaseDaemonizable,它处理服务类实例的守护进程化,包括将进程 ID(PID)写入到已知位置的文件中,以防服务进程的某部分需要。从那里派生一个服务类,而不是从BaseDaemon派生,应该能够满足大部分不同的需求,同时仍然保留BaseDaemon的结构。

总结

本章创建的服务基础应该为几乎任何服务提供一个坚实的共同起点,尽管可能需要对结构进行微调或覆盖现有功能以满足特定用例的要求。有了这些基础,路径就清晰了,可以在hms_sys中实际创建 Artisan Gateway Service,它将在下一章中连接 Artisan 和 Central Office 的数据流。

第十六章:工匠网关服务

为了实现最终用户和网关守护程序之间的通信,我们需要详细研究并就守护程序的几个运行方面进行一些决策-它将如何工作,数据如何发送和接收,以及如何对这些数据进行操作。在本章中,我们将对此进行详细研究,并编写代码来实现基于这些决策的过程。

本章涵盖以下主题:

  • 定义来回发送的数据结构(消息)的样子,以及它需要提供的内容,包括一个签名消息实现,无论使用何种机制发送数据,都应该起作用

  • 检查发送和接收数据的两个基本选项:消息队列和 Web 服务

  • 消息将如何处理,独立于传输机制

  • 实现基于消息队列的传输机制所需的基本结构

  • 在基于 Web 服务的方法中会遇到什么变化(以及如何处理)

  • 工匠网关的进出流量将是什么样子

  • 将这些流量模式最小集成到现有数据对象的当前流程中

概述和目标

hms_sys系统的背景下,工匠网关迄今为止只是宽泛地定义了它的作用-它被描述为在工匠和中央办公室之间的通信的中央联系点,特别是关于“产品”和“订单”对象的-它的作用是什么。它的工作方式和何时工作的具体细节还没有被触及,尽管至少后者可能是非常明显的,遵循一个简单的规则,即所做的更改(由谁)需要尽快传播到所有相关方。这些更改在很大程度上取决于谁在进行更改。至少,以下过程可能会出现:

  • 工匠可以创建新的“产品”数据

  • 工匠可以更新当前的“产品”数据

  • 工匠可以直接删除“产品”

  • 中央办公室工作人员可以标记“产品”为可用-这只是“产品”更新过程的一个专门变体

  • 中央办公室工作人员也可以对产品进行内容更改-这也是一种更新变体-对可以更改的内容有一些限制

  • 最终用户可以间接创建“订单”对象,需要以某种方式传播给工匠

  • 工匠可以在履行过程中更新订单

所有这些过程都是对“产品”和/或“订单”对象的 CRUD 操作的变体,可能不需要比每个子系统中相关类的 _create 或 _update 方法提供的功能更多。它们应该涵盖实际存储数据更改的大部分,也许全部。

这些数据更改的传输,无论最终的时间或协议是什么样子,都有一些共同因素,需要处理以下步骤的特定变体:

  • 本地进行数据更改(创建、更新或删除)

  • 验证数据更改,以确保数据格式良好并符合数据结构要求

  • 数据更改在本地存储(如果适用)

  • 数据更改被序列化并传输到工匠网关服务,执行需要进行的任何操作

这些步骤并未解决冲突更改的可能性,例如工匠和中央办公室的某人在相同的数据更改时间段内对相同数据进行不同更改的可能性。处理这种可能性的策略可能甚至不是必要的,取决于正在进行的具体数据更改业务规则,但也必须进行检查。

这只留下了关于传输方法本身的决定。由于预计进行数据更改的个别用户不会在同一物理位置,我们需要某种网络传输协议 - 一种 Web 服务或基于消息队列的过程,如第十五章中讨论的那样,服务的解剖。如果从头开始编写 Web 服务,可能需要进行大量工作,可能需要编写处理身份验证、授权和处理特定 HTTP 方法并将它们与特定 CRUD 操作绑定到个别数据对象类型的代码。这些单独之间的复杂性足以值得查看现有的服务框架,如 Flask 或 Django,而不是编写(并且必须测试)所有相关代码。

考虑到系统只需要关注之前确定的七个操作(工匠:创建、更新或删除产品等),编写这七个函数并允许队列协议中的消息在必要时调用它们会更简单。通过为每个工匠分配一个独特的队列,并可能为每个工匠发出的每条消息签名,可以显著减轻围绕身份验证和授权的潜在问题。通过这两种方法,可以简单地通过来自与他们相关联的特定队列的消息来确定工匠的身份。再加上每条消息上的签名,只要它可以由工匠的应用程序生成并由工匠网关服务验证,而不会在消息中传输任何秘密数据,就提供了一个相当健壮的身份验证机制。在这种情况下,授权问题几乎微不足道 - 任何给定的通道,只要它可以与用户类型或甚至特定用户关联,就可以简单地允许访问(从而执行)与该用户或类型相关的操作。

在高层次上,无论选择哪种传输机制,工匠/产品操作的数据流都会如下所示:

在哪里:

  • 各种消息(创建产品更新产品删除产品)及其各自的{payload}数据(或删除操作的{product_id})由本地工匠应用程序创建,并传输到工匠网关服务

  • 这些消息被读取、验证,并用于确定应调用哪个服务方法(artisan_create_product等)

  • 相关方法处理执行期间工匠网关数据存储中所需的任何数据存储

类似的数据流也将存在于中央办公室用户可以对产品对象执行的所有操作,以及工匠订单对象的交互,至少是这样。此外,可能还需要为更具体的中央办公室角色中更具体的数据对象操作提供相关操作。中央办公室工作人员将需要能够管理工匠对象,至少可能还有订单对象。

迭代故事

尽管许多这些故事的某些方面依赖于尚未被检查的一些 UI 实现,但每个故事都有一些非 UI 功能方面可以被有用地检查和处理。考虑到这一点,至少在最初,本次迭代相关的故事如下:

  • 作为一名工匠,我需要能够将数据更改发送到工匠网关,以便这些更改可以根据需要传播和执行

  • 作为中央办公室用户,我需要能够将数据更改发送到工匠网关,以便这些更改可以根据需要传播和执行

  • 作为 Artisan 经理,我需要能够创建Artisan对象,以便我可以管理 Artisans

  • 作为 Artisan 经理,我需要能够删除Artisan对象,以便我可以管理 Artisans

  • 作为 Artisan 经理,我需要能够更新Artisan对象,以便我可以管理 Artisans

  • 作为 Artisan,我需要能够创建“产品”对象,以便我可以管理我的产品供应

  • 作为 Artisan,我需要能够删除“产品”对象,以便我可以管理我的产品供应

  • 作为 Artisan,我需要能够更新“订单”对象,以便我可以指示中央办公室何时完成订单的我的部分

  • 作为 Artisan,我需要能够更新“产品”对象,以便我可以管理我的产品供应

  • 作为 Artisan,我需要能够更新自己的Artisan对象,以便我可以在 HMS 中央办公室管理我的信息

  • 作为产品经理,我需要能够激活“产品”对象,以便我可以管理产品的可用性

  • 作为产品经理,我需要能够停用“产品”对象,以便我可以管理产品的可用性

  • 作为产品经理,我需要能够更新“产品”对象,以便我可以管理 Artisan 无法管理的产品信息

  • 作为向 Artisan Gateway 服务发送消息的任何用户,我需要这些消息被签名,以便在执行之前可以验证

除了最后一项之外,这些项或多或少地按照在实际用例中需要执行的顺序进行了分组:中央办公室用户(充当 Artisan 经理)需要创建代表 Artisans 的对象,然后才能期望 Artisans 做任何事情,Artisans 必须能够创建“产品”对象,然后才能期望中央办公室用户(充当产品经理)对这些对象做任何事情。

消息

在认真考虑传输机制选项之前,有必要明确定义传输的消息是什么。至少,考虑到 Artisan Gateway 服务中的数据流的情况,以及典型数据对象的实际数据是什么,显然消息需要能够处理结构化数据。在内部,这可能最好由dict表示,因为它们易于序列化和反序列化为至少两种易于传输的不同格式:JSON 和 YAML。我们已经为可以存储状态数据的对象建立了数据字典结构。例如,从 Artisan 的角度来看,已经将数据字典渲染为 JSON 的“产品”看起来是这样的:

{
    "oid": "a7393e5c-c30c-4ea4-8469-e9cd4287714f", 
    "modified": "2018-08-19 07:13:43", 
    "name": "Example Product", 
    "created": "2018-08-19 07:13:43", 
    "description": "Description  TBD", 
    "metadata":{
        "wood": "Cherry, Oak"
    }, 
    "available": false, 
    "dimensions": "2½\" x 4\" x ¾\"", 
    "shipping_weight": 0.5, 
    "summary": "Summary TBD", 
}

这提供了由 Artisan 发起的任何创建或更新“产品”的操作所需的所有数据,但没有指定需要对数据执行什么操作。它也没有与之关联的签名数据,我们希望提供以完成前面提到的迭代故事的最后一部分。这两个项目,操作和签名,都需要添加到消息中,但不需要添加到消息数据中,这样在接收端创建“产品”对象的实例就不必处理从传入数据结构中删除非产品数据。

在消息的上下文中,它们都是元数据:关于数据的数据,本例中描述了对真实数据的操作以及应该使用什么签名来验证消息的完整性。一个更完整的消息,旨在更新现有产品(提供描述和摘要,并使该项目可用),看起来可能是这样的(假设在更新操作期间传输了所有产品数据):

{
    "data":{
        "oid": "a7393e5c-c30c-4ea4-8469-e9cd4287714f", 
        "modified": "2018-08-19 07:41:56", 
        "name": "Example Product", 
        "created": "2018-08-19 07:13:43", 
        "description": "Cherry and oak business-card holder", 
        "metadata": {
            "wood": "Cherry, Oak"
        }, 
        "available": true, 
        "dimensions": "2½\" x 4\" x ¾\"", 
        "shipping_weight": 0.5, 
        "summary": "Cherry and oak business-card holder", 
    },
    "operation":"update",
    "signature":"{Hash hexdigest}"
}

作为输出目标的数据结构为我们提供了足够的信息来实现一个DaemonMessage类,以表示发送到或从 Artisan Gateway 服务接收的任何消息。DaemonMessage是一个具体的类,位于hms_core.daemons模块中。它以典型的类声明开始,并定义了一个类常量,稍后将用于将字符串值编码为字节值,无论是在实例方法还是类方法中:

class DaemonMessage(object):
    """
Represents a *signed* message being sent to or received from a 
BaseDaemon instance.
"""
    ###################################
    # Class attributes/constants      #
    ###################################

    # - class-constant encoding-type for signature processes
    __encoding = 'utf-8'

DaemonMessage的大多数属性遵循我们迄今为止一直在使用的标准 getter、setter 和 deleter 方法/属性声明模式。其中一个属性signature需要在每次调用时返回一个计算出的值,并且只有一个 getter 方法定义 - _get_signature

    ###################################
    # Property-getter methods         #
    ###################################

# ...

    def _get_signature(self) -> str:
        if not self.data:
            raise RuntimeError(
                '%s.signature cannot be calculated because there is '
                'no data to sign' % (self.__class__.__name__)
            )
        if not self.signing_key:
            raise RuntimeError(
                '%s.signature cannot be calculated because there is '
                'no key to sign data with' % (self.__class__.__name__)
            )
        return sha512(
            bytes(
                # - We're using json.dumps to assure a consistent 
                #   key-order here...
                json.dumps(self.data, sort_keys=True), self.__encoding
            ) + self.signing_key
        ).hexdigest()

_get_signature方法在其实现中有几个值得注意的方面。首先,由于只有在有数据要签名和有签名密钥值要对数据进行签名时,签名才应该可用,因此它积极地检查这些值,如果没有设置任何一个,则引发RuntimeError。其次,其返回值必须确保数据结构的哈希始终对于相同的数据结构而言是相同的。Python 的dict数据结构不能保证在多个dict值之间具有相同的键序列,即使它们之间存在相同的键。

由于哈希机制需要一个bytes值,并且将dict渲染为bytes(使用str()转换作为中间转换机制)不会始终返回相同的bytes序列进行哈希处理,因此需要一些机制来确保实例的data dict始终被渲染为一致的str/bytes序列。由于用于生成签名的哈希处理的值可能以字符串形式开始,并且json.dumps提供了递归排序输出键的机制,这是一个快速简单的解决方案。

选择json.dumps是基于简单性和便利性。从长远来看,创建一个OrderedDict实例(来自collections模块),按顺序将每个元素添加到新实例中,然后对其字符串值进行哈希处理可能更好。如果没有其他选择,这将消除任何可能存在的数据结构问题,这些数据结构包含无法序列化为 JSON 的值。另一个选择是对 YAML 值进行哈希处理,因为它以更清晰的方式处理无法直接序列化的数据类型。

属性 setter 和 deleter 方法的实现都很典型,不需要太多解释,尽管与 operation 属性对应的 setter 方法(_set_operation)会检查传入的值是否在有限的选项集合中。

到目前为止,与我们迄今为止使用的典型属性模式相比,DaemonMessage的一个重要偏差是,它将大部分属性公开为可设置和可删除的。这一决定背后的理由是,当消息首次需要创建时,可能并不知道dataoperationsigning_key的所有值,甚至在消息被其他进程发送之前可能需要对它们进行修改。允许在运行时设置或删除它们可以减轻后续使用DaemonMessage实例的任何此类问题。结合在运行时计算值的签名实现(并在返回之前检查所需的属性值),这样可以在以后提供我们所需的灵活性,同时仍保留这些属性的类型和值检查:

    ###################################
    # Instance property definitions   #
    ###################################

    data = property(
        _get_data, _set_data, _del_data, 
        'Gets, sets, or deletes the data/content of the message'
    )
    operation = property(
        _get_operation, _set_operation, _del_operation, 
        'Gets, sets, or deletes the operation of the message'
    )
    signature = property(
        _get_signature, None, None, 
        'Gets the signature of the message'
    )
signing_key = property(
        _get_signing_key, _set_signing_key, _del_signing_key, 
        'Gets, sets, or deletes the signing_key of the message'
    )

因此,DaemonMessage的初始化不需要提供这些属性中的任何一个来构造实例,但允许提供所有属性:

    ###################################
    # Object initialization           #
    ###################################

    def __init__(self, 
        operation:(str,None)=None, data:(dict,None)=None, 
        signing_key:(bytes,str,None)=None
    ):
        """
Object initialization.

self .............. (DaemonMessage instance, required) The instance to 
                    execute against
operation ......... (str, optional, defaults to None) The operation 
                    ('create', 'update', 'delete' or 'response') that 
                    the message is requesting
data .............. (dict, optional, defaults to None) The data of the 
                    message
signing_key ....... (bytes|str, optional, defaults to None) The raw 
                    data of the signing-key to be used to generate the 
                    message-signature.
"""
        # - Call parent initializers if needed
        # - Set default instance property-values using _del_... methods
        self._del_data()
        self._del_operation()
        self._del_signing_key()
        # - Set instance property-values from arguments using 
        #   _set_... methods
        if operation:
            self.operation = operation
        if data:
            self.data = data
        if signing_key:
            self.signing_key = signing_key

由于DaemonMessage类的目的是提供一种简单、一致的方式来生成序列化为 JSON 的消息,并且这需要从dict值进行序列化,因此我们提供了相应的方法:

    def to_message_dict(self):
        return {
            'data':self.data,
            'operation':self.operation,
            'signature':self.signature,
        }

    def to_message_json(self):
        return json.dumps(self.to_message_dict())

同样,我们需要一种方法来从 JSON 中反序列化消息,使用中间的从字典方法。这些都是作为类方法实现的,允许创建消息实例并使用签名密钥进行验证。该功能的关键方面都驻留在from_message_dict类方法中:

    @classmethod
    def from_message_dict(cls, 
        message_dict:(dict,), signing_key:(bytes,str)
    ):
        """
message_dict ...... (dict, required) The incoming message as a dict, 
                    that is expected to have the following structure:
                    {
                        'data':dict,
                        'operation':str, # (create|update|delete|response)
                        'signature':str # (hash hex-digest)
                    }
signing_key ....... (bytes|str, optional, defaults to None) The raw 
                    data of the signing-key to be used to generate the 
                    message-signature.
"""

首先对传入的参数执行典型的类型和值检查:

        if type(message_dict) != dict:
            raise TypeError(
                '%s.from_message_dict expects a three-element '
                'message_dict value ({"data":dict, "signature":str, '
                '"operation":str}), but was passed "%s" (%s)' % 
                (cls.__name__, data, type(data).__name__)
            )
        if type(signing_key) not in (bytes,str):
            raise TypeError(
                '%s.from_message_dict expects a bytes or str signing_key '
                'value, but was passed "%s" (%s)' % 
                (cls.__name__, signing_key, type(signing_key).__name__)
            )
if type(signing_key) == str:
            signing_key = bytes(signing_key, cls.__encoding)

从传入的message_dict的数据和操作值以及signing_key参数创建一个新的DaemonMessage实例,确保所有数据都存在且格式良好:

        _data = message_dict.get('data')
        if not _data:
            raise ValueError(
                '%s.from_message_dict expects a three-element dict '
                '({"data":dict, "signature":str, "operation":str}), '
                'but was passed "%s" (%s) which did not include a '
                '"data" key' % 
                (cls.__name__, data, type(data).__name__)
            )
        _signature = message_dict.get('signature')
        if not _signature:
            raise ValueError(
                '%s.from_message_dict expects a three-element dict '
                '({"data":dict, "signature":str, "operation":str}), '
                'but was passed "%s" (%s) which did not include a '
                '"signature" key' % 
                (cls.__name__, data, type(data).__name__)
            )
        _operation = message_dict.get('operation')
        if not _operation:
            raise ValueError(
                '%s.from_message_dict expects a three-element dict '
                '({"data":dict, "operation":str, "operation":str}), '
                'but was passed "%s" (%s) which did not include a '
                '"operation" key' % 
                (cls.__name__, data, type(data).__name__)
            )
        result = cls(_operation, _data, signing_key)

一旦存在新的DaemonMessage实例,只要其数据具有相同的键和值,并且用于生成签名的本地signing_key与用于在传输之前创建原始消息的signing_key相同,则两条消息的签名值应该是相同的。如果不是,则消息存在可疑情况。签名失败的可能原因并不多:

  • 消息中的数据以某种方式损坏/更改

  • 本地和远程的signing_key值不同

在任何情况下,都不应采取任何行动 - 要么数据本身存在可疑情况,要么无法验证消息的真实性。在任何签名失败的情况下,我们会引发一个自定义错误,InvalidMessageError

        if result.signature == _signature:
            return result
        raise InvalidMessageError(
            'The message %s, with a signature of "%s" did not match '
            'the expected signature. Message DENIED' % 
            (_data, result.signature)
        )

将 JSON 序列化消息转换为DaemonMessage实例的过程只是解码传入的 JSON,然后将结果dict数据结构输入到from_message_dict中,返回结果对象:

    @classmethod
    def from_message_json(cls, json_in:(str,), signing_key:(bytes,str)):
        return cls.from_message_dict(json.loads(json_in), signing_key)

将消息序列化为 JSON 格式并不影响 Artisan Gateway 服务实际传输这些消息的选项。提到的两种选项,即 Web 服务和消息队列方法,都可以处理 JSON 消息格式 - 因此在这方面,这种消息策略非常便携。

DaemonMessage的签名过程严重依赖于创建和管理消息的签名密钥的想法 - 没有它们,消息无法发送或读取 - 在继续之前应该讨论一些重要的考虑因素。

与任何加密过程一样,基于哈希的签名依赖于必须创建和保护的秘密值(在本例中为signing_key)。在创建signing_key方面,有几个因素需要牢记,但最重要的两个领域如下:

  • 值越长,破解的难度就越大

  • 它包含的字符越多,破解的难度就越大

这些底层的数学原理相当简单:迭代 10 个值所需的时间比迭代 100 个值所需的时间少,因此任何类型的秘密值中可能的变化越多,迭代所有这些值所需的时间就越长。可能值的数量可以用数学方式表示为(每个字符的值的数量)^(字符串中的字符数量),因此一个具有 255 个可能字符的 128 字符signature_key将涉及 255¹²⁸个可能值,或大约 1.09 × 10³⁰⁸个组合,必须检查以保证计算出这样大小和范围的signature_key。每秒进行十亿次这样的计算,或者每年进行约 3.15 × 10¹⁶次计算,从技术上/数学上讲,仍然可能破解这样的signing_key,但假设哈希算法没有任何可以利用的重大缺陷,这在实际上是不切实际的。

创建所需长度的signature_key相当简单。Python 的os模块提供了一个名为urandom的函数,返回一个适用于加密使用的字符序列(作为bytes对象),并且可以是任意长度,因此生成一个非常长的密钥就像调用以下内容一样简单:

os.urandom(1024)

如果需要,结果可以转换为十六进制字符串值进行存储,并使用bytes.fromhex()从该十六进制字符串转换回来。

import os

example_key = os.urandom(1024)
print(example_key)

example_key_hex = example_key.hex()
print(example_key_hex)

example_from_hex = bytes.fromhex(example_key_hex)
print(example_from_hex == example_key)

# Yields:
# b'!\x0cW\xe5\x89\x7fan ... a LOT more gibberish-looking characters...'
# 210c57e5897f616ec9157f759617e7b4f ... a LOT more hexadecimal digits...
# True

保护机密值通常涉及以下某种组合:

  • 确保它们在静止状态下加密,这样即使存储机密的数据存储被破坏,机密本身也不能轻易使用。

  • 确保它们在传输中加密,以防止中间人利用漏洞轻松访问可用的密钥。

  • 定期更改(轮换)它们,以减少捕获的机密可能在不再有用之前被破坏的可能性。

对 Artisans 的signing_key值的创建和管理(也许还包括与中央办公室到 Artisan 的通信),以及实施某种密钥轮换过程的可能性将在第十七章 处理服务事务中进行更详细的检查。

确保它们在传输中加密可能是决定如何传输消息的一个重要因素。在传输中加密将需要为 Web 服务或本地托管的消息队列实施创建加密证书。消息队列方法可能允许使用私有证书,而 Web 服务可能需要来自公共证书颁发机构的证书。

在传输任何机密信息时,始终应实施传输中的加密,而signing_key显然属于该类别!

静止状态下的加密感觉对于这种范围的系统可能有些过度,尽管可以使用 PyCrypto 等库在代码中实现,或者通过配置 MongoDB 引擎使用其加密存储引擎(在 MongoDB Enterprise 中可用)。这也会给系统增加比目前似乎合理的更多复杂性,包括(再次)密钥的创建和管理。

决定消息传输机制

随着现在传递的消息结构得到解决,现在是深入研究如何传输这些消息的选项的好时机。最终,需要就如何实施处理故事的流程做出决定:

  • 作为 Artisan,我需要能够将产品和订单数据更改发送到 Artisan 网关,以便根据需要传播和采取行动。

  • 作为中央办公室用户,我需要能够将 Artisan 和产品数据更改发送到 Artisan 网关,以便根据需要传播和采取行动。

在之前讨论的两个选项(基于 Web 服务或消息队列的实现)中,使用消息队列似乎更合适:

  • 考虑到预期的操作数量有限,基于队列的方法将涉及较少的开发工作,可能比基于 Web 服务的实现更少复杂。

  • 无需处理任何协议级别的细节(HTTP 方法,数据有效载荷结构的变化等),这些都需要在实现 Web 服务时处理。

  • 无需编写完整的 HTTP 服务器(从头开始,或使用http.server包提供的服务器类之一),或将功能/代码与几种 Web 框架选项(例如 Flask 或 Django REST 框架)集成。

  • 消息可以被发送并简单地等待在其队列中,直到它们被检索并采取行动,所以:

  • 只要队列服务器可访问,所有最终用户都可以继续使用其应用程序而不会中断。

  • Artisan 网关本身随时可能被关闭(进行维护、更新,甚至移动到不同的服务器上)。

不过,这种方法也有一些注意事项/权衡:

  • 包含冲突数据更改的消息,尽管它们仍将被检索和处理,但可能需要额外的手动注意来协调这些更改。在 Web 服务上下文中也可能发生同样的事情,但在消息队列中更有可能发生。

  • 作为一个在网络上进行的主动过程,消息检索可能比直接读取发送到工匠网关的传入请求需要更长的时间。因此,服务吞吐量可能会受到影响,但即使完整的消息操作周期需要 10 秒,也可以允许每小时进行 360 次操作(每天超过 8,600 次操作,或者一年内超过 3,100,000 次操作),假设它们不是并行执行的。

  • 如果消息队列提供程序崩溃,导致消息无法首先被传递,这可能会中断最终用户的应用程序使用。

  • 必须对消息队列的分配进行一些考虑:

  • 如果每个工匠都有自己的队列,进入和离开工匠网关,那么至少需要存储和管理有关这些队列的一些数据,并且必须单独检查每个工匠到网关的队列。

  • 如果所有工匠共享一个到工匠网关的入站队列,那么对于每个操作,都必须实现消息的来源是哪个工匠。

  • 由于消息协议中没有隐含的响应要求来指示消息是否已被执行(或者由于错误而无法执行),因此需要发送给用户的消息的任何响应都必须被主动/独立地发送。

  • 作为一个工匠,我需要为自己创建一个消息队列,并将其分配给我,以便我可以将我的数据更改发送到工匠网关。

使用 RabbitMQ 进行消息队列实现

hms_sys项目将使用 RabbitMQ 作为其消息队列提供程序。RabbitMQ 得到积极维护,并且是一种零成本解决方案,同时还提供付费支持和咨询选项,这使其成为一个很好的低预算选择。此外,还有一个名为pika的 Python 库(使用pip install pika进行安装),它提供了从 RabbitMQ 服务器发送和接收消息所需的所有关键功能,而无需深入实现一个解决方案。RabbitMQ 的制造商 Pivotal Software 还提供了一个商业版本,其中包括额外的管理功能以及支持协议。

消息队列实现还有其他选项,包括来自亚马逊(SQS)、微软(Azure Service Bus)和谷歌(Cloud Pub/Sub)的基于云的解决方案,所有这些解决方案都有相应的 Python 库可供使用。可在本地安装的选项包括 Apache Kafka 和 ActiveMQ,以及 Kestrel。还有一个通用的 AMQP 库(amqp)可供使用,应该允许连接和与使用至少基本 AMQP 协议的任何消息队列服务进行交互。

使用pika向 RabbitMQ 实例发送消息相当简单。以下是一个简单的示例,使用DaemonMessage类生成和签署消息:

#!/usr/bin/env python
# - scratch-space/rabbitmq-sender.py
# - Derived from RabbitMQ - RabbitMQ tutorial - "Hello world!"
#   https://www.rabbitmq.com/tutorials/tutorial-one-python.html

# The pika library is for communicating with RabbitMQ
import pika

# Use DaemonMessage to format our messages
from hms_core.daemons import DaemonMessage

由于我们正在传输DaemonMessage,我们需要生成签名密钥和消息数据:

# Message-items
# - Signing-key
signing_key = '!:iLL>S@]BN;h%"h\'<2cPGsaKA 3vbGJ'
# - Message data (a dict)
message_data = {
    'hello':'Hello from %s' % __file__,
    'random_number':3, # not a random number yet
}

然后我们创建消息:

# - The actual message to be sent
message = DaemonMessage(
    'response', message_data, signing_key
)

接下来,我们建立与 RabbitMQ 服务器的连接:

# RabbitMQ connection and related items
# - Create a connection
connection = pika.BlockingConnection(
    pika.ConnectionParameters('localhost')
)
# - Create (or at least specify) a channel
channel = connection.channel()
# - Create or specify a queue
channel.queue_declare(queue='hello')

然后发送消息,并关闭连接:

# Send the message
channel.basic_publish(
    exchange='', routing_key='hello', 
    body=message.to_message_json()
)

# Close the connection
connection.close()

执行此脚本不会生成任何输出,但可以使用rabbitmqctl命令行工具来验证消息是否已发送:

再次运行脚本,然后使用rabbitmqctl list_queues工具,可以看到另一条消息已准备好在队列中等待:

RabbitMQ 需要提供一个通道(或者也许队列名称是对服务器上的消息进行组织分组的一个很好的描述),我们将考虑稍后使用特定工匠按特定 Artisans 对消息进行分隔。考虑以下队列名称声明:

# - Create or specify a queue
channel.queue_declare(queue='hello')

# Send the message
channel.basic_publish(
    exchange='', routing_key='hello', 
    body=message.to_message_json()
)

在这里,前面的队列名称声明更改为以下内容:

# - Create or specify a queue
channel.queue_declare(queue='queue_name') # Changed here

# Send the message
channel.basic_publish(
    exchange='', routing_key='queue_name',  # Changed here also
    body=message.to_message_json()
)

当我们使用rabbitmqctl list_queues审查队列和消息计数时,我们看到出现了一个新队列(queue_name),其中有一条消息:

从队列中读取消息要复杂一些,但并不显著。一个示例脚本,用于读取先前运行的rabbitmq-sender.py脚本发送到我们队列的消息,开始方式基本相同:

#!/usr/bin/env python
# - scratch-space/rabbitmq-receiver.py
# - Derived from RabbitMQ - RabbitMQ tutorial - "Hello world!"
#   https://www.rabbitmq.com/tutorials/tutorial-one-python.html

import pika

from pprint import pprint
from hms_core.daemons import DaemonMessage

我们需要使用相同的签名密钥值,否则将无法读取检索到的消息:

signing_key = '!:iLL>S@]BN;h%"h\'<2cPGsaKA 3vbGJ'

通过提供一个回调函数来处理消息处理,该回调函数接受从队列中获取消息的过程返回的所有消息属性:

# - Define a message-handler function
def message_handler(ch, method, properties, body):
    print('Handling a message:')
    # - Some print-output removed to keep the listing here shorter

重要的是,我们将消息处理功能封装在try...except块中,这样如果在消息处理过程中出现问题,它不会终止我们稍后设置的主要消息轮询循环。在这种情况下,至少可能会引发一个错误:我们之前定义的InvalidMessageError错误 - 如果由于无效签名而无法创建DaemonMessage,则会引发该错误:

    try:
        message = DaemonMessage.from_message_json(
            body.decode(), signing_key
        )
        print(
            '+- message ........ (%s) %r' % 
            (type(message).__name__, message)
        )
        print(
            '   +- operation ... (%s) %r' % 
            (type(message.operation).__name__, message.operation)
        )
        print(
            '   +- signature ... (%s) %r' % 
            (type(message.signature).__name__, message.signature)
        )
        print(
            '   +- data ........ (%s)' % 
            (type(message.data).__name__)
        )
        print('-- Message-data '.ljust(80,'-'))
        pprint(message.data)
        print('='*80)
    except Exception as error:
        print('%s: %s' % (error.__class__.__name__, error))

创建连接的过程,以及将通道或队列名称与其关联的过程是相同的:

# Create a connection
connection = pika.BlockingConnection(
    pika.ConnectionParameters('localhost')
)
# - Create (or at least specify) a channel
channel = connection.channel()
# - Create or specify a queue
channel.queue_declare(queue='hello')

在这种情况下,我们正在消费消息,而不是发送消息,所以我们需要设置好:

# - Set up a consumer
channel.basic_consume(
    message_handler, queue='hello', no_ack=True
)

最后,我们可以开始监听消息:

# - Listen for messages
print('Listening for messages:')
print('='*80)
channel.start_consuming()

在执行时,此脚本设置自己的事件循环,监听指定的队列/通道上的消息。这大致相当于BaseDaemon.main要求派生守护程序类的事件循环,尽管实际的守护程序实现可能不使用它。一旦运行此脚本,它就会读取并输出先前由第一个脚本发送的两条消息的内容:

这也使我们能够验证具有相同内容并使用相同签名密钥的两条消息的签名是相同的。鉴于发送两条消息之间的消息数据和签名密钥输入没有更改,这是预期的行为。

想象一下我们更改了签名密钥:

#!/usr/bin/env python
# - scratch-space/rabbitmq-bad-sender.py
# - Derived from RabbitMQ - RabbitMQ tutorial - "Hello world!"
#   https://www.rabbitmq.com/tutorials/tutorial-one-python.html

# ... Interim script-code removed for brevity

# Message-items
# - Signing-key
signing_key = 'Invalid signing key'

# ...

然后重新运行相同的脚本;我们从消息监听器中得到不同的结果:

这作为额外的验证,消息签名过程将按预期工作:不允许创建具有无效签名的消息,因此不会被执行。

该消息处理功能,经过一些小的更改,可以作为 Artisan Gateway 的主类的main循环的基础:

class ArtisanGatewayDaemon(BaseDaemon):
    """
Provides the main ArtisanGateway daemon/service.
"""

我们仍然需要一个消息处理功能,但现在它被定义为服务类的一个方法:

    def _handle_message(self, message:(dict,)) -> None:
        self.info(
            '%s._handle_message called:' % self.__class__.__name__
        )
        self.info(str(message))

ArtisanGatewayDaemon类的main循环可以作为接收器脚本的原始功能的简单重新转换开始:

    def main(self):
        """
The main event-loop (or whatever is equivalent) for the service instance.
"""

最初,为了建立所需的功能是可行的,我们将使用之前建立的相同的signing_keyconnectionchannel值。最终,这些将取决于配置值 - 指定签名密钥,或者至少在哪里或如何获取它 - 并且取决于最终实现是否沿着拥有单独的工匠队列的路径,可能会有几个队列名称/通道,或者只有一个。目前,只有在先前脚本中使用的一个允许我们建立基本的队列读取功能:

        signing_key = '!:iLL>S@]BN;h%"h\'<2cPGsaKA 3vbGJ'
        connection = pika.BlockingConnection(
            pika.ConnectionParameters('localhost')
        )
        channel = connection.channel()
        channel.queue_declare(queue='hello')

main 执行的循环的基本结构类似于第十五章中 testdaemon 的主循环的结构,只要类的内部 _running 标志为 True,循环就会继续,执行队列检查和处理传入的消息。一旦循环终止,无论是通过类的 stop 方法还是通过在 BaseDaemon.__init__ 的执行期间由 ArtisanGatewayDaemon.__init__ 注册的信号之一,控制都会退出,并在完全终止之前调用类的 cleanup 方法。

主要的区别,正如预期的那样,是在每次循环迭代中实际发生的事情。在这种情况下,channel 被轮询以获取下一个可用消息,如果检测到一个消息,它将被读取,转换为 DaemonMessage,然后被确认,并交给之前定义的消息处理方法。它需要相同类型的 connectionchannel

    # - To start with, we're just going to use the same 
    #   parameters for our pika connection and channel as 
    #   were used in the rabbitmq-sender.py script.
    connection = pika.BlockingConnection(
        pika.ConnectionParameters(
            self.connection_params['host'],
            self.connection_params.get('port'),
            self.connection_params.get('path'),
        )
    )
    # - Create (or at least specify) a channel
    channel = connection.channel()
    # - Create or specify a queue
    channel.queue_declare(queue=self.queue_name)

一旦这些都建立好了,main 循环就非常简单:

    # - Normal BaseDaemon main-loop start-up:
    self._running = True
    self.info('Starting main daemon event-loop')
    # - Rather than use a channel-consumer (see the example in 
    #   rabbitmq-reciever.py), we're going to actively poll for 
    #   messages *ourselves*, in order to capture just the 
    #   message-body - that's what we really care about in 
    #   this case...
    while self._running:
        try:
            # - Retrieve the next message from the queue, if 
            #   there is one, and handle it...
            method_frame, header, body = channel.basic_get(self.queue_name)
            if method_frame:
                # - Any actual message, valid or not, will 
                #   generate a method_frame
                self.debug('received message:')
                message = DaemonMessage.from_message_json(
                    body.decode(), self.signing_key
                )
                self.debug('+- %s' % message.data)
                # - If we've received the message and processed 
                #   it, acknowledge it on basic principle
                channel.basic_ack(method_frame.delivery_tag)
                self._handle_message(message)
        except InvalidMessageError as error:
            # - If message-generation fails (bad signature), 
            #   we still need to send an acknowledgement in order 
            #   to clear the message from the queue
            err = '%s: %s' % (error.__class__.__name__, error)
            self.error(err)
            channel.basic_ack(method_frame.delivery_tag)
        except Exception as error:
            # Otherwise, we just log the error and move on
            err = '%s: %s' % (error.__class__.__name__, error)
            self.error(err)
            for line in traceback.format_exc().split('\n'):
                self.error(line)
    self.info('%s main loop terminated' % (self.__class__.__name__))

为了测试这一点,快速创建了一个基本的配置文件,主要用于记录信息,并创建了一个具有该配置的新类的实例,并启动了它。从启动到关闭的日志输出,包括发送一个好消息,一个坏消息,然后又一个好消息,显示一切都按预期运行:

这个守护程序实例的快速基本配置非常简单:

# Logging configuration
# scratch-space/hms_ag_conf.yaml
logging:
  format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
  name: hms_ag
  console:
    level: info
  file:
    level: info
    logfile: "/tmp/hms_ag.log"

队列参数也应该驻留在配置文件中,并由守护程序实例获取。附加的配置值最终看起来像这样:

queue:
  type: rabbit
  connection:
    host: localhost
    port: 5672
    path: /
  queue_name: "central-office"
signing_key: "0T*)B{Y#.C3yY8J>;1#<b\\q^:.@ZQjg2 tG~3(MJab_"

加载这些值的过程涉及添加一些实例属性,这些属性大多遵循到目前为止使用的正常模式:

  • connection_params:一个字典值,其值从配置文件的连接部分中检索,用于创建 RabbitMQ 连接

  • queue_name:一个字符串,它是实例将要监听的队列名称/通道

  • signing_key:一个 bytesstr 值,它是实例将用于创建发送到其队列上或从其队列上接收的 DaemonMessage 实例的签名密钥

实际获取和存储这些值只需要在类的 _on_configuration_loaded 方法中添加。最初,它所做的只是调用 BaseDaemon 父类的相同方法,以建立日志功能,这一点保持不变:

    def _on_configuration_loaded(self, **config_data):
        # - Call the BaseDaemon function directly to set up logging, 
        #   since that's provided entirely there...
        BaseDaemon._on_configuration_loaded(self, **config_data)

接下来检索特定于队列的项目。尽管目前没有预期会需要其他队列系统,但我们不能排除将来可能需要的可能性,因此代码从允许将来允许这种可能性的假设开始:

        queue_config = config_data.get('queue')
        if queue_config:
            try:
                if queue_config['type'] == 'rabbit':
                    self._connection_params = queue_config['connection']
                    self.info(
                        'Connection-parameters: %s' % 
                        self.connection_params
                        )
                    self._queue_name = queue_config['queue_name']
                    self.info(
                        'Main queue-name: %s' % self.queue_name
                        )
                # If other queue-types are eventually to be supported, 
                # their configuration-load processes can happen here, 
                # following this pattern:
                # elif queue_config['type'] == 'name':
                #    # Configuration set-up for this queue-type...
                else:
                    raise RuntimeError(
                        '%s could not be configured because the '
                        'configuration supplied did not specify a '
                        'valid queue-type (%s)' % 
                        (self.__class__.__name__, queue_config['type'])
                    )
            except Exception as error:
                raise RuntimeError(
                    '%s could not be configured because of an '
                    'error -- %s: %s' % 
                    (
                        self.__class__.__name__, 
                        error.__class__.__name__, error
                    )
                )
        else:
            raise RuntimeError(
                '%s could not be configured because the configuration '
                'supplied did not supply message-queue configuration' % 
                (self.__class__.__name__)
            )

签名密钥也在配置文件中,因此接下来是获取和存储它:

        # - The signing-key is also in configuration, so get it too
        try:
            self._signing_key = config_data['signing_key']
        except Exception as error:
            raise RuntimeError(
                '%s could not be configured because of an error '
                'retrieving the required signing_key value -- %s: %s' % 
                (
                    self.__class__.__name__, 
                    error.__class__.__name__, error
                )
            )

至少目前为止,这样做就足以处理掉主要部分中使用的硬编码值,同时保持类的功能。对原始消息发送脚本的变体(在本章代码的 scratch-space/rabbitmq-sender-daemon-queue.py 中)的执行显示,守护程序仍然按预期运行,监听并对有效消息进行操作。

处理消息

为了实际处理消息的数据,我们需要定义一个格式良好的命令消息实际上是什么样子,实现可以执行允许的命令的方法,并实现知道如何调用这些方法的功能,给定一个格式良好且经过验证的消息来执行。列表中的第一项非常简单,但可能有许多不同的有效实现模式。考虑到,此时,我们可以通过DaemonMessage传输四种不同的操作动作:'create''update''delete''response'。这些操作动作直接对应于标准的 CRUD 操作,除了'response'值,即使那个值,也可能大致相当于read操作。对于任何给定的数据对象类型,这些操作分别需要执行相同的过程:

  1. 创建相关类的新实例,并使用from_data_dict类方法(或者可能是一个新的等效类方法)填充来自消息的状态数据,并save新实例

  2. 使用get类方法检索相关类的现有实例,使用消息中的新值更新该实例的任何状态数据(这可能会受益于创建一个新方法,例如update_from_message),并save该实例

  3. 使用delete类方法查找并删除消息数据指定的实例

  4. 使用get类方法检索消息数据指定的实例的数据字典表示,并使用to_data_dict方法生成找到实例的数据结构的消息

守护程序需要有多达 16 个{action}_{object}方法,每种操作/对象组合都需要一个,以确保所有组合都被考虑到。对于每种对象类型(工匠、顾客、订单和产品),方法集看起来会像这样(方法名称不言自明):

  • create_artisan

  • update_artisan

  • delete_artisan

  • response_artisan

尚未考虑的一个关键数据,需要确定在接收命令消息时执行哪些方法,就是对象类型。DaemonMessage类没有特定的属性用于对象类型,因为最初的想法是这样做可能会不必要地限制将来对同时具有operation和对象类型的消息的使用。修改DaemonMessage以允许对象类型的指定并不困难。这只需要添加一个可选属性,允许__init__方法中的另一个可选参数,并在调用它的任何其他方法中考虑它。然而,采取这些措施似乎是不必要的:结构化数据的消息本身可以很容易地包含必要的数据。例如,考虑一个看起来像这样的“创建工匠”消息:

{
    "data":{
        "target":"artisan",
        "properties":{
            "address":"1234 Main Street, etc.",
            "company_name":"Wirewerks",
            "contact_email":"jsmith@wirewerks.com",
            "contact_name":"John Smith",
            "website":"http://wirewerks.com",
        }
    },
    "operation":"create",
    "signature":"A long hex-string"
}

如果任何命令消息具有操作并且在其数据中指示对象类型(target值)以及要在操作中使用的属性作为标准结构,那将同样有效。类似的数据结构也适用于更新操作:

{
    "data":{
        "target":"artisan",
        "properties":{
            "address":"5432 West North Dr, etc.",
            "modified":"2019-06-27 16:42:13",
            "oid":"287db9e0-2fcc-4ff1-bd59-ff97a07f7989",
        }
    },
    "operation":"update",
    "signature":"A long hex-string"
}

对于删除操作:

{
    "data":{
        "target":"artisan",
        "properties":{
            "oid":"287db9e0-2fcc-4ff1-bd59-ff97a07f7989",
        }
    },
    "operation":"delete",
    "signature":"A long hex-string"
}

以及响应操作:

{
    "data":{
        "target":"artisan",
        "properties":{
            "oid":"287db9e0-2fcc-4ff1-bd59-ff97a07f7989",
        }
    },
    "operation":"response",
    "signature":"A long hex-string"
}

根据消息的操作和data.target值确定调用哪个方法只是一长串if…elif…else决策:

def _handle_message(self, message:(dict,)) -> None:
    self.info(
        '%s._handle_message called:' % self.__class__.__name__
    )

因为我们以后需要目标(用于决策)和属性(作为方法的参数传递),所以首先获取它们:

    target = message.data.get('target')
    properties = message.data.get('properties')

每种operationtarget的组合看起来都非常相似。从create操作开始:

    if message.operation == 'create':

如果目标是已知的、允许的类型之一,那么我们可以直接调用适当的方法:

        if target == 'artisan':
            self.create_artisan(properties)
        elif target == 'customer':
            self.create_customer(properties)
        elif target == 'order':
            self.create_order(properties)
        elif target == 'product':
            self.create_product(properties)

如果target是未知的,我们希望抛出一个错误:

        else:
            raise RuntimeError(
                '%s error: "%s" (%s) is not a recognized '
                'object-type/target' % 
                (
                    self.__class__.__name__, target, 
                    type(target).__name__
                )
            )

其他操作也基本相同 - 例如update操作:

    elif message.operation == 'update':
        if target == 'artisan':
            self.update_artisan(properties)
        elif target == 'customer':
            self.update_customer(properties)
        elif target == 'order':
            self.update_order(properties)
        elif target == 'product':
            self.update_product(properties)
        else:
            raise RuntimeError(
                '%s error: "%s" (%s) is not a recognized '
                'object-type/target' % 
                (
                    self.__class__.__name__, target, 
                    type(target).__name__
                )
            )

deleteresponse操作相似到足以没有必要在这里重复,但它们在代码中是存在的。最后,我们还捕获操作未被识别的情况,并在这些情况下引发错误:

    else:
        raise RuntimeError(
            '%s error: "%s" (%s) is not a recognized '
            'operation' % 
            (
                self.__class__.__name__, operation, 
                type(operation).__name__
            )
        )

由于数据对象设计/结构和传入消息的结构,实际操作方法相对简单。例如,创建Artisan

def create_artisan(self, properties:(dict,)) -> None:
    self.info('%s.create_artisan called' % self.__class__.__name__)
    self.debug(str(properties))
    # - Create the new object...
    new_object = Artisan.from_data_dict(properties)
    #   ...and save it.
    new_object.save()

更新Artisan

def update_artisan(self, properties:(dict,)) -> None:
    self.info('%s.update_artisan called' % self.__class__.__name__)
    self.debug(str(properties))
    # - Retrieve the existing object, and get its data-dict 
    #   representation
    existing_object = Artisan.get(properties['oid'])
    data_dict = existing_object.to_data_dict()
    # - Update the data-dict with the values from properties
    data_dict.update(properties)
    # - Make sure it's flagged as dirty, so that save will 
    #   *update* instead of *create* the instance-record, 
    #   for data-stores where that applies
    data_dict['is_dirty'] = True
    # - Create a new instance of the class with the revised 
    #   data-dict...
    new_object = Artisan.from_data_dict(data_dict)
    #   ...and save it.
    new_object.save()

删除Artisan

def delete_artisan(self, properties:(dict,)) -> None:
    self.info('%s.delete_artisan called' % self.__class__.__name__)
    self.debug(str(properties))
    # - Delete the instance-record for the specified object
    Artisan.delete(properties['oid'])

Artisan响应:

def response_artisan(self, properties:(dict,)) -> dict:
    self.info('%s.response_artisan called' % self.__class__.__name__)
    self.debug(str(properties))
    # - Since get allows both oids and criteria, separate those 
    #   out first:
    oid = properties.get('oid')
    criteria = {
        item[0]:item[1] for item in properties.items()
        if item[0] != 'oid'
    }
    return Artisan.get(oid, **criteria)

队列和相关的 Artisan 属性

由于工匠将通过特定队列与网关通信,并且这些队列必须被识别并与它们各自的工匠保持一致关联,我们需要在各种代码库中有机制来存储队列标识,并将它们与其工匠所有者关联。

队列规范本身可以通过向Artisan对象的类添加一个属性(queue_id)来简单实现。由于网关服务和 Artisan 应用中的 Artisan 对象都将使用queue_id,因此在hms_core.business_objects.BaseArtisan类中实现这一点是有意义的,在那里它将被继承到需要的所有地方。属性的获取器和删除器方法是典型的实现,property声明也是如此,尽管它遵循只读属性模式。设置器方法也是非常典型的:

def _set_queue_id(self, value:(str)) -> None:
    if type(value) != str:
        raise TypeError(
            '%s.queue expects a single-line printable ASCII '
            'string-value, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, value, 
                type(value).__name__
            )
        )
    badchars = [
        c for c in value 
        if ord(c)<32 or ord(c) > 127 
        or c in '\n\t\r'
    ]
    if len(badchars) != 0:
        raise ValueError(
            '%s.queue expects a single-line printable ASCII '
            'string-value, but was passed "%s" that contained '
            'invalid characters: %s' % 
            (
                self.__class__.__name__, value, 
                str(tuple(badchars))
            )
        )
    self._queue_id = value

工匠还需要跟踪每个工匠独有的签名密钥属性,但存在于消息传输过程的 Artisan 应用端和 Artisan 网关端的本地Artisan对象中。签名密钥作为bytes值,可能不容易以其原生值类型存储:bytes值在本地 Artisan 数据存储中已经实现,可能对其他地方使用的 MongoDB 存储也有问题,因为bytes值不是本地 JSON 可序列化的。

幸运的是,bytes类型提供了实例和类方法,用于将值序列化和反序列化为十六进制字符串值。序列化字节值只需调用值的hex()方法,从十六进制字符串创建字节值则通过调用bytes.fromhex(hex_string)来实现。使用hex()/fromhex()完整序列化/反序列化字节值的简单示例显示了该值按需保留:

import os

raw_key=os.urandom(24)
print('raw_key (%s)' % type(raw_key).__name__)
print(raw_key)
print()

serialized = raw_key.hex()
print('serialized (%s)' % type(serialized).__name__)
print(serialized)
print()

unserialized = bytes.fromhex(serialized)
print('unserialized (%s)' % type(unserialized).__name__)
print(unserialized)
print()

print('unserialized == raw_key: %s' % (unserialized == raw_key))

此代码的输出将如下所示:

Artisan 类的相应属性(signing_key)也遵循典型的只读属性结构,并且除了其设置器方法外,没有什么不同。设置器方法必须允许原始bytes值和bytes值的十六进制字符串表示,并存储bytes值:

def _set_signing_key(self, value:(bytes,str)):
    if type(value) not in (bytes,str):
        raise TypeError(
            '%s.signing_key expects a bytes-value of no less '
            'than 64 bytes in length, or a hexadecimal string-'
            'representation of one, but wa passed "%s" (%s)' % 
            (self.__class__.__name__, value, type(value).__name__)
        )

如果传递了一个字符串,它会尝试使用bytes.fromhex()进行转换:

    if type(value) == str:
        try:
            value = bytes.fromhex(value)
        except:
            raise ValueError(
                '%s.signing_key expects a bytes-value of no '
                'less than 64 bytes in length, or a hexadecimal '
                'string-representation of one, but wa passed '
                '"%s" (%s), which could not be converted from '
                'hexadecimal into bytes' % 
                (
                    self.__class__.__name__, value, 
                    type(value).__name__)
                )
            )

它还强制签名密钥的最小长度,任意设置为64字节(512 位):

    if len(value) < 64:
        raise ValueError(
            '%s.signing_key expects a bytes-value of no less '
            'than 64 bytes in length, or a hexadecimal string-'
            'representation of one, but wa passed "%s" (%s), '
            'which was only %d bytes in length after conversion' % 
            (
                self.__class__.__name__, value, 
                type(value).__name__, len(value)
            )
        )
    self._signing_key = value

对应的最终Artisan对象必须在它们的to_data_dict方法和__init__方法中考虑这些新属性。to_data_dict的更改看起来是一样的 - 以hms_core.co_objects.Artisan为例,并显示添加到返回的 dict 结果的新属性,它们最终看起来像这样:

def to_data_dict(self) -> (dict,):
    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),
        # Queue- and signing-key values
        'queue_id':self.queue_id,
        'signing_key':self.signing_key.hex(),
    }

__init__方法的更改有所不同:因为新的queue_idsigning_key属性是在BaseArtisan.__init__执行时分配的,所以该方法必须实际调用删除器和设置器方法:

def __init__(self, 
    contact_name:str, contact_email:str, 
    address:Address, company_name:str=None, 
    queue_id:(str,None)=None, signing_key:(bytes,str,None)=None, 
    website:(str,None)=None
    *products
    ):
    """Doc-string omitted for brevity"""
    # - Call parent initializers if needed
    # ... omitted for brevity
    # - Set instance property-values from arguments using 
    #   _set_... methods
    self._set_contact_name(contact_name)
    self._set_contact_email(contact_email)
    self._set_address(address)
    # New queue_id and signing_key properties
    self._set_queue_id(queue_id)
    self._set_signing_key(signing_key)
    if company_name:
        self._set_company_name(company_name)
    if website:
        self._set_website(website)

由于queue_idsigning_key在技术上是必需的属性,如果时间允许,将它们移动到__init__签名的必需参数部分,位于addresscompany_name之间,将是正确的做法。在这种情况下,更多的是空间限制而不是时间,因此它们被添加到签名中的一个易于处理的位置,而不是不得不审查、修改和重新显示代码中已经存在的各种BaseArtisan.__init__调用。但是,它们仍将作为必需属性工作,因为 setter 方法不会接受默认的None值,并且它们被调用而不使用company_namewebsite使用的检查。

co_objects.Artisanartisan_objects.Artisan__init__方法只需要更新以包括它们签名中的新参数,并将这些参数传递给它们的BaseArtisan.__init__调用。对co_objects.Artisan.__init__的修订如下:

def __init__(self,
    contact_name:str, contact_email:str, 
    address:Address, company_name:str=None, 
# New queue_id and signing_key arguments
    queue_id:(str,None)=None, 
    signing_key:(bytes,str,None)=None, 
    website:(str,None)=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
):
    """Doc-string omitted for brevity"""
    # - Call parent initializers if needed
    BaseArtisan.__init__(self, 
        contact_name, contact_email, address, company_name, 
# New queue_id and signing_key arguments
        queue_id, signing_key, 
        website
    )
    # ... other initialization omitted for brevity
    # - Perform any other initialization needed

基于 Web 服务的守护程序的要求

如果我们要改为使用基于 Web 服务的实现来实现 Artisan 网关,有一些共同因素和一些必须克服的障碍。可以说,最重要的障碍将是实现完整的HTTP方法 - POSTGETPUTDELETE - 官方和符合标准的方法,对应于我们期望使用的CreateReadUpdateDelete CRUD 操作的方法。

如果命令传输的介质仍然是DaemonMessage类的序列化和带签名的消息输出,我们需要能够以至少两种不同的方式传递完整的签名消息:

  • GETDELETE操作的查询字符串格式:GET 并不打算支持POSTPUT方法允许的相同类型的有效负载功能,尽管似乎没有任何官方立场表明DELETE是否应该支持它,但最安全的做法可能是假设它不会,并相应地编写代码。

  • 在 POST 和 PUT 操作的两种不同有效负载格式中。到目前为止,我们还没有详细讨论任何产品数据;即使没有要求支持产品图像的传输,也只是时间问题。HTTP POSTPUT操作允许在请求体中发送有效负载,并允许以两种不同格式(编码)发送有效负载在标准 Web 表单请求上下文中:

  • 作为一个键值字符串列表,看起来非常像GET请求中的等效内容

  • 作为更详细的编码,请求中的每个字段都具有与键值列表相同的名称和数据,但还允许字段指定它们包含特定的数据类型 - 例如,文件,以及其他数据,例如文件名

后一种编码在允许文件上传的 Web 页面中看到,作为相关<form>标记中的enctype="multipart/form-data"属性。提交这样的表单,包括有效负载中的两个文件,将生成一个类似于以下内容的HTTP请求:

在这个例子中:

  • {field-separator}是一个随机字符串,唯一标识每个字段数据集的开始

  • {content-length}是有效负载的总大小

  • {field-name}是其数据包含在该部分中的字段的名称

  • {field-value}是不是文件上传字段的字段中的文本数据

  • {file-name}是正在上传的文件的名称,就像它在客户端机器上存在的那样

  • {MIME-type}是正在传输的文件类型的指示器,例如image/png

  • {file-data}是与字段对应的文件的数据

为了支持仅具有这三个数据块的有效负载,我们必须找到或创建能够可靠解析出每个数据部分并处理每个被吐出的数据块的代码。虽然至少有一个这样的库,requests-toolbelt,但在某些核心 Python 版本(3.3.0 和 3.3.1)中已知存在问题,因此它可能是一个可行的选择,具体取决于所使用的 Python 版本。从头开始编写(和测试)处理multipart/form-data有效负载的代码将是一个耗时的过程。

假设所有这些都已经处理,虽然编写能够捕获和处理传入请求的网络监听器并不困难,但这也可能需要相当长的时间,特别是在测试方面,只是为了能够可靠(并可证明地)处理传入请求。在 Web 服务场景中,几乎肯定最好的选择是从已经处理所有这些需求和要求的成熟的 Web 应用程序包中开始,并编写代码,简单地将传入请求映射到处理程序方法,就像消息队列实现所做的那样。好的一面是,签名消息应该可以在这种情况下使用,并且底层操作方法可能不需要进行任何重大修改。

与服务之间的流量

服务的通信链的消息接收方面已经就位,在ArtisanGatewaymain方法中,但除了围绕消息生成的一些部分之外,还没有实现消息发送功能。每种数据对象类型在修改、创建或删除时都需要向其对应的子系统发送相关的命令消息。例如,如果 Artisan 创建了一个新的产品,创建该Product对象的行为需要向 Gateway 服务发送一个“创建产品”消息。同样,如果中央办公室工作人员对产品进行了更改,Gateway 服务需要向适当的 Artisan 应用程序实例发送一个“更新产品”消息。

在这些场景的 Artisan 应用程序方面,发送任何消息所需的所有队列参数将是恒定的。它们将始终将消息发送到相同的队列服务器,使用相同的端口,使用相同的连接和通道。与其在初始化期间将所有消息队列设置传递给各种数据对象,这可能会显着复杂化它们,并且如果以后需要不同的消息传输机制,会使代码难以处理,不如创建另一个包含所有这些设置并提供向队列服务器发送任意消息的方法的类:RabbitMQSender。在定义该类的过程中,我们还可以利用 Python 类/实例关系的某些方面,使得创建发送方实例变得更加容易:

  • 定义了类属性的 Python 类的实例也具有相同名称和值的实例属性。也就是说,如果RabbitMQSender有一个名为_host的类属性,值为 localhost,那么创建的所有RabbitMQSender实例都将具有相同的 localhost 值的_host属性。

  • 更改实例属性的值不会影响类属性的值。

  • 更改类属性的值也会更改相应的实例值,前提是它们没有在这些实例中被明确设置。因此,如果创建了RabbitMQSender的一个实例,然后更改了RabbitMQSender._host,那么实例的_host值将相应更新。

综合考虑,并在应用设计时谨慎,这些允许定义RabbitMQSender,以便可以配置,允许使用类的可用实例仅需最基本的调用,如my_sender = RabbitMQSender()

如果以后需要不同的消息传输机制,引入RabbitMQSender将从BaseMessageSender派生的抽象层可能是一个好主意 - 或许需要该消息发送方法和所有相关的传输机制属性。这将为所有传输机制提供一个公共接口,并且如果/当需要时,更容易地在它们之间切换。

因此,RabbitMQSender从典型的类定义开始,其中各种连接属性和其他消息传输常量被定义为受保护的类属性:

class RabbitMQSender(object):
    """
Provides baseline functionality, interface requirements, and 
type-identity for objects that can send messages to a RabbitMQ 
message-queue that shares configuration across all derived 
classes
"""
    ###################################
    # Class attributes/constants      #
    ###################################

    # - Common RabbitMQ parameters
    _host = None
    _port = None
    _queue_name = None
    _virtual_host = None

与之对应的属性只有 getter 方法,因此它们不能轻易/意外地被更改:

    def _get_host(self):
        return self._host

    def _get_port(self):
        return self._port

    def _get_queue_name(self):
        return self._queue_name

    def _get_virtual_host(self):
        return self._virtual_host

它们与典型的只读属性结构中的属性名称相关联:

    host = property(
        _get_host, None, None, 
        'Gets the host (FQDN or IP-address) of the RabbitMQ '
        'server that derived objects will send messages to'
    )
    port = property(
        _get_port, None, None, 
        'Gets the TCP/IP port on the RabbitMQ server that '
        'derived objects will send messages to'
    )
    queue_name = property(
        _get_queue_name, None, None, 
        'Gets the name of the queue on the RabbitMQ server that '
        'derived objects will send messages to'
    )
    virtual_host = property(
        _get_virtual_host, None, None, 
        'Gets the "virtual_host" on the RabbitMQ server that '
        'derived objects will send messages to'
    )

connectionchannel属性遵循典型的延迟实例化模式,当第一次请求它们时创建,并且也作为只读属性公开:

    def _get_channel(self):
        try:
            return self._channel
        except AttributeError:
            # - Create (or at least specify) a channel
            self._channel = self.connection.channel()
            # - Create or specify a queue
            self._channel.queue_declare(queue=self._queue_name)
            return self._channel

    def _get_connection(self):
        try:
            return self._connection
        except AttributeError:
            self._connection = pika.BlockingConnection(
                # Parameters 
                pika.ConnectionParameters(
                    host=self._host,
                    port=self.port,
                    virtual_host=self.virtual_host
                )
            )
            return self._connection
# ...

    channel = property(
        _get_channel, None, None, 
        'Gets the channel that the instance will send messages to'
    )
    connection = property(
        _get_connection, None, None, 
        'Gets the connection that the instance will send messages '
        'with/through'
    )

不需要属性设置器或删除器方法,也不需要类的__init__功能。实例的所有属性将有效地引用类属性值,可以通过单个类方法调用进行设置:

    @classmethod
    def configure(cls, 
        queue_name:(str), host:(str,), port:(int,None)=None, 
        virtual_host:(str,None)=None
    ):
        cls._queue_name = queue_name
        cls._host = host
        if port:
            cls._port = port
        if virtual_host:
            cls._virtual_host = virtual_host

在 Artisan 应用程序的上下文中,预配置所有RabbitMQSender实例所需的一切就是使用适当的设置调用RabbitMQSender.configure,可能来自 Artisan 应用程序实例的配置文件:

RabbitMQSender.configure(
    queue_name = configuration['queue_name'],
    host = configuration['host'],
    port = configuration.get('port'),
    virtual_host = configuration.get('virtual_host'),
)

最后,发送消息的过程由一个单一方法提供:

    def send_message(self, message:(DaemonMessage)):
        """
Sends the supplied message to the RabbitMG server common to 
all RabbitMQSender objects

self .............. (RabbitMQSender instance, required) The 
                    instance to execute against
message ........... (DaemonMessage, required) The message to send.
"""
        # - Note that exchange is blank -- we're just using the 
        #   default exchange at this point...
        self.channel.basic_publish(
            exchange='', routing_key=self.queue_name, 
            body=message.to_message_json()
        )

在消息传输过程的 Artisan 应用程序端,创建RabbitMQSender实例并调用其send_message方法应该处理我们需要的实际消息传输。在 Artisan Gateway 端,当向 Artisan 应用程序实例发送消息时,该过程将类似 - 在某些方面简化,可能不需要RabbitMQSender(或等效)类,或者可能需要类似的变体以更好地处理多个传出队列。我们将集成 Artisan 端的流程,并在第十七章中更详细地研究网关的需求,处理服务事务

测试和部署的影响

在迭代的这一点上,除了对各种属性和方法进行标准单元测试之外,这些属性和方法并未涉及任何消息传输,从测试的角度来看,没有太多可以做的。我们还没有将消息传递与数据更改集成在一起,我们将在第十七章中进行研究,处理服务事务,而且在任何方向上,没有完整的发送和接收过程可用,即使从手动测试的角度来看,也没有太多可以做的,这已经被探索过了。

现在为 Artisan Gateway 守护程序制定任何部署细节似乎为时过早,出于类似的原因,尽管在这一点上,似乎一个非常基本的setup.py/Makefile安排可能会处理我们需要的一切。

总结

尽管我们现在已经拥有了完成迭代所需的所有基础,但只有三个故事甚至有可能关闭:

  • 作为一个工匠,我需要能够将数据更改发送到 Artisan Gateway,以便根据需要传播和采取行动

  • 作为中央办公室用户,我需要能够将数据更改发送到 Artisan Gateway,以便根据需要传播和采取行动

  • 作为向 Artisan Gateway 服务发送消息的任何用户,我需要这些消息被签名,以便在执行之前可以验证。

然而,这些基础包括一个功能齐全(尚未经过测试)的 Artisan Gateway 守护程序/服务,一种生成可以被该服务和远程应用程序执行的命令消息的机制,以及实际传输这些命令消息的基本流程。在这些成就之间,很有可能我们已经完成了这三个故事,但在它们经过测试之前,我们无法证明它们可以被执行。

为了证明闭环的必要测试,以及尚未实施的故事的平衡,都依赖于在 Artisan 和中央办公室应用程序的数据对象级别集成各种 CRUD 操作,并通过必要的消息传播这些数据更改到 Artisan Gateway,以及(如果需要)从网关到远程 Artisan 和中央办公室应用程序的传播,这将在下一章中讨论。

第十七章:处理服务交易

系统组件之间以及它们各自管理的数据对象之间存在大量的交互潜力。虽然我们已经解决了一些确定数据更改或命令消息传输的机制,但我们还没有开始探索这些交互的具体细节。简而言之,我们仍然需要解决所有本地 CRUD 操作的数据流(因此,消息传输)是什么样子的问题。

在本章中,我们将涵盖以下主题:

  • 工匠创建产品

  • 工匠和中央办公室工作人员对产品的激活和停用

  • 由工匠和中央办公室工作人员对产品数据进行更改

  • 由工匠删除产品

  • 由中央办公室工作人员创建工匠

  • 由工匠和中央办公室工作人员对工匠数据进行更改

  • 由中央办公室工作人员删除工匠

  • Web 商店创建订单,并将该信息传达给工匠以便履行

  • 从 Web 商店取消订单,并将该信息传达给工匠

  • 工匠履行订单项目

剩余的故事

自从我们在第十六章中的工匠网关服务,只(暂时)关闭了三个故事,还有几个(十一个)需要解决。采用的RabbitMQSender和 RabbitMQ 消息传输策略的实施也引发了关于如何传播一些这些过程所需的工件的问题,特别是签名密钥,还有一个待定的决定,即工匠网关是否会使用一个消息队列来处理工匠的入站流量,还是每个工匠一个消息队列,这可能会增加另一个故事:

  • 作为工匠,我需要创建一个消息队列并分配给我,以便我可以将我的数据更改发送到工匠网关

大部分尚未完成的故事都代表了一个数据流程,一个与系统上下文中特定用户执行的特定操作相关联的数据交易。每个流程又是 CRUD 操作的某种变体,通常是根据相关消息的指示创建、更新或删除一个或多个数据对象。在审查系统中每个用户角色可用的各种业务对象上进行各种 CRUD 操作的可能性时,出现了五个新故事:

  • 作为工匠,我需要能够停用“产品”对象,以便我可以管理“产品”的可用性(这可能由一般的更新操作处理)

  • 作为工匠,我需要在下订单时被通知,其中包括我的产品供应之一,以便我可以履行订单的部分(最终,由中央办公室一侧的某些活动触发的工匠驻地“订单”对象的创建)

  • 作为客户,我需要将我的订单的相关部分传达给适当的工匠,以便他们可以履行我的订单的部分(前一个故事的另一半,但可能会增加一些功能需求)

  • 作为取消订单的客户,我需要将取消订单的相关部分传达给适当的工匠,以便他们不会履行订单的部分(基本上是删除工匠驻地的“订单”对象,但在工匠应用程序方面通知)

  • 作为工匠,我需要在订单被取消时被通知,其中包括我的产品供应之一,以便我可以停止与之相关的任何进行中的履行活动,并根据需要更新我的“产品”状态(再次是前一个故事的另一半)

所有这些交易都遵循相似的模式:

  • 需要发送的对象的相关消息数据用于创建一个消息(使用DaemonMessage)。

  • 消息由发送方(RabbitMQSender的一个实例)发送到工匠网关服务

  • 服务读取消息,并调用适当的 [process-method],这可能会与工匠网关数据存储进行交互。

  • [process-method] 本身可能需要发送其他消息,要么返回给工匠网关服务本身进行进一步的本地处理,要么通过服务返回给工匠。发送后续消息的过程将非常相似,但可能会有额外的变化——新消息的目的地:

主要的变化点在于消息数据本身,这些变化应该由业务规则来塑造,规则规定了用户角色对这些对象可以做什么。

一点重组

在深入研究各个事务的细节之前,最近的代码需要进行一些微小的重组。RabbitMQSenderDaemonMessage类最初是在hms_core.daemons模块中编写的,因为那似乎是一个合乎逻辑的地方来保存它们——它们仍然与守护进程相关,但也与工匠应用程序的某些部分(也许是中央办公室应用程序)相关,这些部分与各种守护进程类本身没有任何联系。由于我们还发现了各种对象需要能够生成消息数据结构的需求,并且感觉这应该由不同的抽象来处理,因此将这两个类移动到一个新的hms_core模块(hms_core.messaging)中,并在那里添加新的抽象是合乎逻辑的——这样,所有与消息相关的类都在一个地方。将自定义异常InvalidMessageError移动到新模块中也感觉像是一个明智的步骤,因为它也严格与消息相关。

这些代码移动需要对工匠网关服务的主模块进行一些微不足道的更改,例如从这里更改原始导入:

from hms_core.daemons import BaseDaemon, DaemonMessage, \ 
    InvalidMessageError 
from hms_core.daemons import BaseDaemon

到以下位置:

from hms_core.daemons import BaseDaemon
from hms_core.messaging import DaemonMessage, InvalidMessageError

类似的变化在任何已生成的测试脚本中也是必要的,以便它们仍然有用。

这种代码重组可能是不可避免的,至少从长期来看:只是一个时间问题,总会有一些东西在原来的位置感觉不对,需要移动到更好的位置。一般来说,越早发现这种重组的需求,越好,因为如果要交互的代码越少,就越不容易出现问题或干扰。另外,可能不用说,但在继续之前重新运行可能已创建的任何测试代码总是一个好主意,以确保在继续之前没有明显的错误。在这种情况下,守护进程的最终测试脚本(scratch-space/daemon-artisan-tests.py)显示出一些小问题,这些问题必须解决——不是因为代码移动,而是因为在关闭 第十六章 工匠网关服务的代码之前没有重新运行。不过,问题在变成真正的错误之前就被发现了。

对象事务的准备

前面的代码重组为我们提供了一个坚实而合乎逻辑的地方,可以创建我们之前提到的新抽象基类ABC)。这个新 ABC 的目标是要求派生类能够提供一个准备好的消息数据结构,可以作为DaemonMessage的数据参数传递给其__init__方法,从而简化为任何需要消息的对象创建消息的过程,并允许该过程的代码存在于各个数据对象类本身的一部分。与迄今为止在代码中演变的命名约定保持一致,这可能最好写成一个名为to_message_data的实例方法。另一个考虑的选项是to_message_dict,但该方法名称已经存在于其他地方,并且与DaemonMessage参数的关系不那么密切。

to_message_data方法可以完全抽象,抽象方法本身没有提供具体实现——与迄今为止在hms_sys代码库中定义的许多抽象方法不同,实际上没有任何通用功能可以回退。

就是这样,真的。新的 ABC 不需要任何其他想法。它只是定义了一个新方法的要求。它甚至不需要一个__init__方法,因为没有需要作为实例属性值传递的东西(尽管它仍然会继承所有类最终派生自的对象类的__init__方法)。因此,它的整个定义如下:

class HasMessageData(metaclass=abc.ABCMeta):
    """
Provides interface requirements, and type-identity for objects that 
are expected to provide a to_message_data method.
"""

    ###################################
    # Abstract methods                #
    ###################################

    @abc.abstractmethod
    def to_message_data(self) -> (dict,):
        """
Creates and returns a dictionary representation of the instance 
that is safe to be passed to a DaemonMessage instance during or 
after creation as the data of that message.

self .............. (HasMessageData instance, required) The 
                    instance to execute against
"""
        raise NotImplementedError(
            '%s.to_message_data has not been implemented as '
            'required by HasMessageData' % 
            (self.__class__.__name__)
        )

一个没有具体功能定义的 ABC 与其他面向对象语言提供的形式接口声明几乎一样接近 Python 代码。它仍然只是一个抽象基类,就像迄今为止为项目构建的其他 ABC 一样,但它所做的只是生成派生类在实例化之前必须实现的一组功能要求。在这种情况下,当我们将HasMessageData应用于已在hms_core.co_objectshms_artisan.artisan_objects命名空间中定义的各种数据对象类(ArtisanProduct类以及hms_artisan命名空间中的Order),这立即建立了这些类必须实现to_message_data的要求,而不关心它们如何实现。

hms_sys中,由于具体的ArtisanOrderProduct类都是从hms_core中定义的 ABC 派生的,我们实际上可以将HasMessageData附加到这些 ABC 上,而不是具体类。最终结果将是一样的——具体类将被要求实现to_message_data——并且工作量会(非常轻微地)减少。权衡的是,任何将来从BaseArtisanBaseOrderBaseProduct派生的类也将被要求实现to_message_data,即使没有必要。虽然这并不感觉可怕,但它确实对未来的开发施加了一些功能要求,这可能是不合理的。目前,由于我们知道当前的具体类应该派生自HasMessageData,我们将直接从中派生它们——如果将来需要更改,将要求深入继承树的工作量更小。

to_message_data的具体实现在代码中提供了一个逻辑挂钩,用于实施关于每个对象允许在消息中发送什么的业务规则限制。也就是说,工匠和中央办公室用户都不被允许更改或设置所有对象的所有状态数据——它们各自控制特定属性。即使用户类型拥有对象类型(工匠和产品),也有其他用户拥有的属性(例如产品和store_available)。由于to_message_data将用于实际生成消息数据,而这些数据将用于在每个消息事务的接收端进行更改,因此通过将其生成的数据结构限制为用户类型可以创建或更改的值,可以防止每个用户对对象数据进行非法更改。当我们逐步处理每个用户/对象/操作组合的具体交易时,我们将深入研究这一点。

产品对象交易

由于产品数据交易的一组具有最多个体交易(七个),我们将从这些交易开始,希望它们能更早地暴露出设计中的任何差距。每个交易都与原始迭代故事之一相关联,并且与交易过程相关的特定故事将被调用。用户/对象组合的to_message_data的特定实现将在该组合的第一个交易中定义,并在后续交易细节中根据需要进行细化。还将解决该特定组合的任何其他特定需求。

由于对任何对象的各种操作都需要对对象进行标识,因此在所有to_message_data输出中,对象被传输的oid属性是唯一的常量。它在每个操作中都起着重要作用。

  • 在创建新对象时,必须在消息中提供oid,以便我们不会在不同的应用程序安装或环境中得到不同的唯一标识符。这已经通过继承自BaseDataObjectoid值的生成来处理,如果不存在,则创建oid

  • 在更新现有对象时,必须提供oid,以便检索和更改原始对象。

  • 在删除现有对象时,同样需要提供相同的标识 - 必须提供oid以标识正在被删除的对象。

  • 尽管我们目前还没有响应消息的用例(在标准 CRUD 操作结构中更或多等同于读取),但它也需要一个oid值,以便识别应该被获取和返回的对象。

工匠 - 创建产品

工匠需要创建产品的相关故事,来自先前的列表。

  • 作为工匠,我需要能够创建Product对象,以便管理我的Product产品

工匠用户拥有Product对象的大部分数据点。实际上,他们真正不应该能够创建或更改的唯一属性是store_available标志,该标志控制给定产品是否在中央办公室运行的 Web 商店上可用。因此,hms_artisan.artisan_objects.Productto_message_data输出看起来非常像它的to_data_dict方法:

def to_message_data(self) -> (dict,):
    """
Creates and returns a dictionary representation of the instance 
that is safe to be passed to a DaemonMessage instance during or 
after creation as the data of that message.
"""
    return {
        'oid':str(self.oid),
        # - Properties from BaseProduct:
        'available':self.available,
        'description':self.description,
        'dimensions':self.dimensions,
        'metadata':self.metadata,
        'name':self.name,
        'shipping_weight':self.shipping_weight,
        'summary':self.summary,
        # - Date/time values, since we may well want/need to 
        #   keep those in sync across environments
        'created':datetime.strftime(
            self.created, self.__class__._data_time_string
        ),
        'modified':datetime.strftime(
            self.modified, self.__class__._data_time_string
        ),
    }

createdmodified值包含在此数据结构中,基于这样的假设,它们也应该在工匠和中央办公室的数据存储中保持同步 - 这可能会使 UI 逻辑更容易地检测到在显示实例数据之前需要注意的任何更改,尽管这几乎肯定需要一些标准化的共同时间 - 在所有应用程序和服务实例中都是一致的。

给定一个新的Product对象(new_product)和工匠的签名密钥(signing_key),将new_product传输到工匠网关服务变得非常简单:

new_product_message = DaemonMessage(
    'create', new_product.to_message_data(), signing_key
)
# - Assumes that RabbitMQSender has already been configured...
#   it would be slightly more involved if this were the first time 
#   it was used...
sender = RabbitMQSender()
sender.send_message(new_product_message)

接受这些消息并实际创建新Product的工匠网关方法是ArtisanGatewayDaemon.create_product。由于这是一个服务中的方法,特别是因为它对数据进行更改(在这种情况下创建新数据),因此它的过程几乎与其本身一样多地记录了大部分日志,尽管其中大部分是调试日志,并且只有在服务配置为以该级别记录事件时才会记录:

def create_product(self, properties:(dict,)) -> None:
    self.info('%s.create_product called' % self.__class__.__name__)
    if type(properties) != dict:
        raise TypeError(
            '%s.create_product expects a dict of Product '
            'properties, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, properties, 
                type(properties).__name__
            )
        )
    self.debug('properties ... %s:' % (type(properties)))
    self.debug(str(properties))
    # - Create the new object...
    new_object = Product.from_data_dict(properties)
    self.debug('New object created successfully')
    #   ...and save it.
    new_object.save()
    self.info(
        'New Product %s created successfully' % new_object.oid
    )

在这一点上,各种网关方法并不确定传入消息是否被授权进行方法所做的更改。我们将在以后进行检查。

中央办公室 - 批准/列出产品

中央办公室工作人员能够激活产品的相关故事,来自先前的故事集合是:

  • 作为产品经理,我需要能够激活“产品”对象,以便我可以管理“产品”的可用性

中央办公室拥有产品的store_available标志,因此他们的to_message_dict版本,位于hms_code.co_objects.Product中,至少最初,要简单得多:

def to_message_data(self) -> (dict,):
    """
Creates and returns a dictionary representation of the instance 
that is safe to be passed to a DaemonMessage instance during or 
after creation as the data of that message.
"""
    return {
        'oid':str(self.oid),
        # - Properties from BaseProduct:
        'store_available':self.store_available,
        # - Date/time values, since we may well want/need to 
        #   keep those in sync across environments
        'modified':datetime.strftime(
            self.modified, self.__class__._data_time_string
        ),
    }

相关的消息传输,使用product_to_activate``Product对象和中央办公室的signing_key一样简单,就像我们之前看过的新产品传输一样:

product_message = DaemonMessage(
    'update', product_to_activate.to_message_data(), signing_key
)
sender = RabbitMQSender()
sender.send_message(product_message)

相同的消息结构和传输过程也将解决中央办公室需要能够停用产品的问题,这是最初的迭代故事之一:

  • 作为产品经理,我需要能够停用“产品”对象,以便我可以管理“产品”的可用性

接受这些消息并更新相关Product的 Artisan Gateway 方法是ArtisanGatewayDaemon.update_product。与create_product一样,它在执行过程中记录得相当详细:

def update_product(self, properties:(dict,)) -> None:
    self.info('%s.update_product called' % self.__class__.__name__)
    if type(properties) != dict:
        raise TypeError(
            '%s.update_product expects a dict of Product '
            'properties, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, properties, 
                type(properties).__name__
            )
        )
    self.debug('properties ... %s:' % (type(properties)))
    self.debug(str(properties))
    # - Retrieve the existing object, and get its data-dict 
    #   representation
    existing_object = Product.get(properties['oid'])
    self.debug(
        'Product %s retrieved successfully' % existing_object.oid
    )
    data_dict = existing_object.to_data_dict()
    # - Update the data-dict with the values from properties
    data_dict.update(properties)
    # - Make sure it's flagged as dirty, so that save will 
    #   *update* instead of *create* the instance-record, 
    #   for data-stores where that applies
    data_dict['is_dirty'] = True
    # - Create a new instance of the class with the revised 
    #   data-dict...
    new_object = Product.from_data_dict(data_dict)
    #   ...and save it.
    new_object.save()
    self.info('Product %s updated successfully' % new_object.oid)

中央办公室 - 修改产品数据

中央办公室需要更改产品数据的相关故事,如前面的列表所示:

  • 作为产品经理,我需要能够更新“产品”对象,以便我可以管理工匠无法管理的“产品”信息

可以合理地假设中央办公室希望能够对特定产品属性进行更改,而无需通过工匠发送它们 - 对产品内容进行简单的拼写更正或类似的更改,这些更改会传递到他们的 Web 商店。由于没有明确定义哪些属性会涉及,让我们假设这些属性包括产品的“名称”、“描述”和“摘要”。在这种情况下,为hms_code.co_objects.Product创建的to_message_data需要更改以包括这些值:

def to_message_data(self) -> (dict,):
    """
Creates and returns a dictionary representation of the instance 
that is safe to be passed to a DaemonMessage instance during or 
after creation as the data of that message.
"""
    return {
        'oid':str(self.oid),
        # - Properties from BaseProduct:
        'description':self.description,
        'name':self.name,
        'store_available':self.store_available,
        'summary':self.summary,
        # - Date/time values, since we may well want/need to 
        #   keep those in sync across environments
        'modified':datetime.strftime(
            self.modified, self.__class__._data_time_string
        ),
    }

此实施引入了一个可能不希望的副作用:中央办公室用户执行的任何更新操作都可以一次更新所有这些属性。如果不希望出现这种行为,则可以追求一些选项:

  • 可以向ArtisanGatewayDaemon添加其他方法来处理更具体的操作,例如set_product_availability,它只会更改store_available标志值。这可能需要以下操作:

  • DaemonMessage添加相应的允许“操作”

  • 检查起源于工匠的消息,以便他们不会意外或故意执行不应被允许的商店可用性更改

  • 出站消息数据的过滤,以删除其中不适用于特定操作的任何元素,可以作为消息生成的一部分实施:

  • 可以向具体的Product类添加辅助方法来执行该过滤

  • UI 可以负责确定应发送何种类型的消息,并且可以执行该过滤

目前,允许任何更新操作跨多个逻辑操作进行更新似乎没有任何实际伤害,因此现在可以放任不管。

目前,中央办公室角色的修改可以通过与批准/上市操作使用的相同消息构造、传输和处理过程来处理 - 这只是数据更新的另一种变体。

工匠 - 更新产品数据

工匠需要更新产品数据的相关故事,如前面的列表所示:

  • 作为工匠,我需要能够更新“产品”对象,以便我可以管理我的“产品”提供

工匠更新和创建交易之间唯一的真正区别是与传出消息相关联的“操作” - 我们已经在工匠Product对象的to_message_data结果中包含了modified属性:

product_message = DaemonMessage(
    'update', product_to_update.to_message_data(), signing_key
)
sender = RabbitMQSender()
sender.send_message(product_message)

从工匠发起的数据更改在流程上与从中央办公室用户发起的数据更改相同-他们可以使用相同的ArtisanGatewayDaemon.update_product方法来实际执行这些更改-因此不需要新代码。

由于工匠还控制产品可用性标志(available),因此在工匠级别也适用于中央办公室产品批准清单中指出的相同考虑因素。这包括两个原始迭代故事集中没有的故事,但应为完整起见包括:

  • 作为一名工匠,我需要能够激活Product对象,以便我可以管理Product的可用性

  • 作为一名工匠,我需要能够停用Product对象,以便我可以管理Product的可用性

这些也可以通过相同的现有数据更新过程来处理,只要没有要求将激活/停用更改与数据结构的其他更改隔离开来。即使出现这样的要求,也可以在事务的消息发起端处理它们,将消息的内容限制为仅active标志和标识要激活或停用的产品的oid

工匠-删除产品

Artisan 需要删除产品的相关故事,如前所述,是:

  • 作为一名工匠,我需要能够删除Product对象,以便我可以管理我的Product产品

如前所述,删除操作实际上只需要被删除项目的oid才能成功执行。任何其他信息都将是浪费带宽,尽管如果这不是一个问题,删除的代码实际上只是在消息中再次发送operation

product_message = DaemonMessage(
    'delete', product_to_delete.to_message_data(), signing_key
)
sender = RabbitMQSender()
sender.send_message(product_message)

执行更紧密的消息并不困难-最终,它不需要更多的东西,只需要更直接地控制消息数据,将其限制为仅相关的对象 ID。一种方法是直接创建消息数据,如下所示:

message_data = {
    'oid':str(product_to_delete.oid)
}
product_message = DaemonMessage('delete',message_data, signing_key)
sender = RabbitMQSender()
sender.send_message(product_message)

Artisan 网关(delete_product)中的相应删除方法比创建或更新相同原因的过程要简单得多:实际上只需要被删除数据的oid

def delete_product(self, properties:(dict,)) -> None:
    self.info('%s.delete_product called' % self.__class__.__name__)
    self.debug(str(properties))
    # - Delete the instance-record for the specified object
    Product.delete(properties['oid'])
    self.debug(
        'Product %s deleted successfully' % properties['oid']
    )

Artisan 对象交易

发送Artisan对象消息的过程不会与先前显示的Product对象的示例有显著偏差。 createupdate消息的创建和传输通常会遵循以下类似结构:

# - Typical create-object message-creation and -transmission
create_message = DaemonMessage(
    'create', object_to_create.to_message_data(), signing_key
)
sender = RabbitMQSender()
sender.send_message(create_message)

# - Typical update-object message-creation and -transmission
update_message = DaemonMessage(
    'update', object_to_update.to_message_data(), signing_key
)
sender = RabbitMQSender()
sender.send_message(update_message)

删除消息,取决于关于发送完整对象数据集还是只发送所需的oid值的决定,通常会遵循以下两种结构之一:

# - Transmit the full object-data-set as the delete-message
delete_message = DaemonMessage(
    'delete', object_to_delete.to_message_data(), signing_key
)
sender = RabbitMQSender()
sender.send_message(delete_message)

# - Transmit *only* the required oid as the delete-message:
message_data = {
    'oid':str(product_to_delete.oid)
}
delete_message = DaemonMessage('delete', message_data, signing_key)
sender = RabbitMQSender()
sender.send_message(delete_message)

Product对象一样,Artisan对象在 Artisan Gateway 服务中的 CRUD 操作方法从技术上讲并不复杂。实际上,除了正在处理的对象的具体细节以及与执行这些操作所涉及的各种方法相关的命令消息的特定预期结构之外,它们与其Product对象对应物是相同的。例如,Artisan Gateway 服务的update_artisan方法如下所示:

def update_artisan(self, properties:(dict,)) -> None:
    self.info('%s.update_artisan called' % self.__class__.__name__)
    if type(properties) != dict:
        raise TypeError(
            '%s.update_artisan expects a dict of Artisan '
            'properties, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, properties, 
                type(properties).__name__
            )
        )
    self.debug('properties ... %s:' % (type(properties)))
    self.debug(str(properties))
    # - Retrieve the existing object, and get its data-dict 
    #   representation
    existing_object = Artisan.get(properties['oid'])
    self.debug(
        'Artisan %s retrieved successfully' % existing_object.oid
    )
    data_dict = existing_object.to_data_dict()
    # - Update the data-dict with the values from properties
    data_dict.update(properties)
    # - Make sure it's flagged as dirty, so that save will 
    #   *update* instead of *create* the instance-record, 
    #   for data-stores where that applies
    data_dict['is_dirty'] = True
    # - Create a new instance of the class with the revised 
    #   data-dict...
    new_object = Artisan.from_data_dict(data_dict)
    #   ...and save it.
    new_object.save()
    self.info('Artisan %s updated successfully' % new_object.oid)

总的来说,各种Artisan操作都遵循与Product操作建立的/为其建立的相同模式。

中央办公室-创建工匠

中央办公室工作人员能够创建工匠的相关故事,如前面的故事集是:

  • 作为一名工匠经理,我需要能够创建Artisan对象,以便我可以管理工匠

“工匠”对象是不寻常的,因为它们在逻辑上由它们所代表的工匠拥有,但是它们是由中央办公室创建的。这意味着中央办公室代码库将需要两种根本不同的消息格式:一种用于创建“工匠”,一种用于更新它。如果我们从创建目的的完整消息结构开始,我们可以更好地评估它是否在以后的更新过程中存在任何风险或复杂性:

def to_message_data(self) -> (dict,):
    """
Creates and returns a dictionary representation of the instance 
that is safe to be passed to a DaemonMessage instance during or 
after creation as the data of that message.
"""
    return {
        'oid':str(self.oid),
        # - 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
        ),
        'modified':datetime.strftime(
            self.modified, self.__class__._data_time_string
        ),
        # Queue- and signing-key values
        'queue_id':self.queue_id,
        'signing_key':self.signing_key.hex(),
    }

由于创建“工匠”的过程几乎肯定涉及创建和存储与工匠相关联的消息队列的标识符(queue_id)和初始的signing_key,这些值包括在中央办公室的Artisan.to_message_data方法中。我们仍然需要定义签名密钥和队列标识符如何在“工匠”对象内部实际创建,但它们必须以某种方式发送到工匠网关,以便它们可以用于发送、接收和验证消息到和从工匠应用实例。

从安全的角度来看,这些流程非常重要:请记住,签名密钥被视为秘密数据值,应该谨慎对待,不要毫无必要地传输或在保护数据时不加注意。在许多方面,它相当于用户密码 - 与一个且仅一个用户相关联的秘密值。如果签名密钥是密码,那么队列标识符可以被视为大致相当于用户名 - 这些数据可能不太保密,但仍应谨慎对待,因为它可能唯一标识一个用户,并与一个真正的秘密相关联,共同形成一组用户凭据。随着queue_idsigning_key创建和管理的实施细节的展开,我们很可能需要重新审视这个消息结构,所以目前我们将它保持在当前状态。

中央办公室 - 更新工匠数据

中央办公室工作人员能够更新工匠数据的相关故事,来自先前的故事集合:

  • 作为一名工匠经理,我需要能够更新“工匠”对象,以便我可以管理工匠

一旦创建了“工匠”对象,它的大部分属性可以说是由该对象所代表的工匠拥有。从常识的角度来看,工匠用户最了解他们的数据是否是最新的,而且保持数据最新对他们最有利。也就是说,暂且不考虑queue_idsigning_key属性,直到它们的流程被更详细地阐述出来,允许中央办公室用户修改工匠数据的风险并不显著,前提是所做的更改被传播到工匠用户自己那里,并且也可以由他们自己进行更改。这种情况的警告是,oid属性不应该被任何人更改 - 中央办公室或工匠用户 - 但这几乎是不言而喻的。毕竟,这是“工匠”对象的唯一标识符,唯一标识符不应该轻易更改。

考虑到所有这些,目前不需要对中央办公室的Artisan.to_message_data方法进行修改,以满足这个故事,尽管随着创建过程一样,随着queue_idsigning_key管理流程的定义和实施,可能会出现变化。

中央办公室 - 删除工匠

中央办公室工作人员能够删除工匠数据的相关故事,来自先前的故事集合:

  • 作为一名工匠经理,我需要能够删除“工匠”对象,以便我可以管理工匠

尽管删除 Artisan 的过程可能会有其他影响,例如删除或至少停用其所有产品,但从生成删除命令消息的角度来看,没有任何想到的影响。与Product对象的删除过程一样,真正需要的唯一属性值是要删除的 Artisan 的oid,以及关于在该上下文中使用完整消息正文还是为删除过程目的创建特定消息的任何决定,可能也适用于这个上下文。

Artisan – 更新 Artisan 数据

早期故事集中与 Artisan 能够更新 Artisan 数据相关的故事是:

  • 作为一个 Artisan,我需要能够更新自己的Artisan对象,以便我可以在 HMS 中央办公室管理我的信息。

无论最终围绕Artisanqueue_idsigning_key属性的过程的形状如何,作为机密信息的这些值在没有保护的情况下永远不应该通过开放的互联网发送——至少在传输过程中加密它们。没有这些值,Artisan 用户对 Artisan 数据的更改可以以未加密的方式传输,因此 Artisan 更新的基本消息结构几乎是中央办公室命名空间中等效的复制:

    def to_message_data(self) -> (dict,):
        """
Creates and returns a dictionary representation of the instance 
that is safe to be passed to a DaemonMessage instance during or 
after creation as the data of that message.
"""
        return {
            'oid':str(self.oid),
            # - 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
            ),
            'modified':datetime.strftime(
                self.modified, self.__class__._data_time_string
            )
        }

在中央办公室和 Artisan 代码库之间,我们允许任一用户类型修改大部分 Artisan 数据。其中大部分是一些联系信息的变化,没有任何功能上的影响,而余下的政策已经制定,尚未实施(oid),或者仍在等待进一步定义(queue_idsigning_key)。两种用户类型对这些属性拥有完全控制权可能存在的最大风险是同时发生冲突的更改(可能最好在 UI 级别处理),或者持续发生冲突的更改(一个用户更改一个值,另一个用户再次更改,如此往复)。

订单对象交易

自从在artisan_objects模块中定义了具体的Order类以来,订单及其在系统中对应的对象并没有被讨论过多。部分原因是其他类(特别是ArtisanProduct)是源自hms_sys代码库的数据表示。不过,artisan_objects.Order类的状态基本上是完整的,具有完整的数据持久性和一个具体的实现,预期能够处理到目前为止的所有要求。

然而,由于这些原因,订单的几个方面被忽略了。这次迭代的原始故事集只包括一个与订单相关的故事——Artisan 需要能够在履行过程中更新订单——没有提供任何路径让订单首先到达 Artisan,更不用说在此之前的任何事情了。在订单完成履行之前取消订单的可能性也没有考虑进去。考虑到这些项目的客户到网关和网关到 Artisan 的路径,这增加了四个新的故事,将首先解决。

处理订单也受到 Web Storefront 系统细节故意模糊的影响。有数十种甚至数百种选项,大多数是用流行的/主流语言编写的,并具有不同程度的可扩展性。与其选择任何一种,我们基本上假设hms_sys集成可以以某种方式完成,这可能包括至少以下可能性:

  • 一个蛮力过程,按计划执行,可以从商店的数据中获取新的原始订单信息,并触发 Artisan Gateway 的订单创建过程

  • 商店系统通过某种小型的自定义扩展,可以向 Artisan Gateway 发送创建订单消息,直接或通过消息队列,执行其订单创建过程

  • 如果商店系统是用 Python 编写的(在这个领域至少有十一个选项),它实际上可能能够导入所需的任何hms_sys代码,或许添加一些配置,并直接执行相关的hms_sys代码

在现实世界的情况下,跨系统集成可能已经是一组非常具体的要求,但为了说明问题,并且专注于项目,这些故意被搁置了。

客户-将订单项目传达给工匠

客户能够将订单项目传达给工匠的相关故事,从早期的故事集合中:

  • 作为客户,我需要我的订单的相关部分被传达给适当的工匠,以便他们能够完成订单的部分。

订单在hms_sys中的生命周期比其他对象复杂得多。与Artisan对象或者Product对象不同,订单预计具有较短的活跃寿命;被创建后进行一次处理,然后存档或者甚至被删除。相比之下,一旦创建,Artisan对象预计会持续存在,直到中央办公室/工匠关系结束。Product对象可能会在活跃状态下存在很长一段时间,但也可能会持续存在,只要其所属工匠的中央办公室/工匠关系持续存在。尽管它们的生命周期长度可能会有很大差异,但它们基本上是被创建并持续存在(无论是否有修改)。

相比之下,一个相对简单的Order,通过hms_sys支持的简单子集,可能看起来像这样:

其中:

  • 初始的Order(用于产品P1P2P3)由Web Storefront创建,并移交给Artisan Gateway进行分发和处理,由相关的工匠用户处理

  • Artisan GatewayOrder消息发送到与订单中产品相关的Artisan Applications(在这个例子中是Artisan #2,但订单中没有包含他们的产品):

  • 一个用于产品P1P3Order被发送到Artisan #1

  • 一个用于产品P2Order被发送到Artisan #3

  • Artisan #1完成了产品P1的订单部分(P1 Fulfilled),并将更新消息发送回Artisan Gateway,在那里该部分的完成情况被记录和存储。

  • 类似的循环(P2 Fulfilled)发生在Artisan #3身上,与原始订单中的产品P2有关。

  • 最终的完成循环(P3 Fulfilled)由Artisan #1执行

  • 订单及其所有的完成情况完成后,可以进行存档、删除或者以其他需要的方式处理

由于从未创建过能够访问 Artisan Gateway 服务的具体Order类,这是需要做的第一件事。虽然不知道订单数据将如何被传达到服务,但仍然需要能够执行后续的往返测试,因此除了将其定义为从HMSMongoDataObject(就像co_objects模块中的其他数据对象类一样)和BaseOrder(来自business_objects模块)派生的基本类之外,几乎没有其他更多的事情可做。它可能会在后期出现添加或更改,但是从这两个类中派生Order将为其提供足够的功能,以便进行测试。

在对 Artisan 应用程序的 Order 类定义进行了所有分析工作之后,这似乎是中央办公室代码(co_objects)中相应类的更好起点,尽管在过程中需要进行一些修改/转换。首先,它需要从JSONFileDataObject而不是HMSMongoDataObject派生,但由于这两者又都是从BaseDataObject派生的,新的Order类的相当部分已经通过这种继承变化实现了。

这两个Order类之间有足够的共同代码,几乎肯定值得花时间将这些共同项目移回BaseOrder中。设计,甚至实现具体类,然后将它们的共同功能聚集到共同的父类中,与从基础开始构建的设计或实现方法一样有效,尽管在这种情况下是意外发生的。

除此之外,我们需要一个机制,让 Web 商店系统能够创建一个Order。到目前为止,我们还没有关于这个过程的任何规范,但这并不妨碍我们创建一个类方法,希望最终能够在这个容量中使用。为了近期的测试目的,它将被设置为接受一个作为customer派生的BaseCustomer对象,以及一个产品标识符的列表,目的是在将来的某个时候对customer进行修订。起初,我们只关心一个可以被调用来创建一个完整的Order并附加相关Product对象的方法:

def create_order_from_store(
    cls, customer:(BaseCustomer,str,dict), **order_items
):
    """
Creates and returns a new order-instance, whose state is populated 
with data from the     

customer .......... (Type TBD, required) The customer that placed 
                    the order
order_items ....... (dict [oid:quantity], required) The items and 
                    their quantities in the order
"""

可以合理地假设商店前端将能够将产品标识符及其数量作为 Order 的一种dict值传递,并且它不会跟踪整个Product对象,至少不是以hms_sys代码使用的相同结构。在order_itemskeys()中提供的 Product oid值列表中,检索要添加到创建的order实例中的产品只是将所有可用产品过滤为 Order 中特定项目的集合,同时保留它们的相关数量:

    # - Get all the products and quantities identified by the 
    #   incoming oid-values in order_items
    products = {
        product:order_items[str(product.oid)] 
        for product in Product.get()
        if str(product.oid) in order_items.keys()
    ]

这里生成的产品是由字典生成的,由字典理解生成,其键是Product对象,值是订单中这些产品的数量。然后,我们需要获取customer

# TODO: Determine how customer-data is going to be #provided 
# (probably a key/value string, could be a JSON packet 
# that could be converted to a dict), and find or create 
# a customer object if/as needed. In the interim, for 
# testing purposes, accept a BaseCustomer-derived object.
  if not isinstance(customer, BaseCustomer):
      raise NotImplementedError(
          "%s.create_order_from_store doesn't yet accept "
          "customer arguments that aren't BaseCustomer-"
          "derived objects, sorry" % (cls.__name__)
      )

最后,新的Order实例被创建,保存(确保其数据被持久化),并返回(以防调用代码在创建后立即引用它):

# - Create the order-instance, making sure it's tagged 
#   as new and not dirty so that the save process will 
#   call _create
new_order = cls(
    customer, is_dirty=False, is_new=True, *products
)
# - Save it and return it
new_order.save()
return new_order

Order类也需要一个to_message_data方法,就像它们的 Product 和 Artisan 对应物一样,一旦定义了一个,就可以使用基本上与之前建立的相同的消息传输过程:

def to_message_data(self) -> (dict,):
    """
Creates and returns a dictionary representation of the instance 
that is safe to be passed to a DaemonMessage instance during or 
after creation as the data of that message.
"""
    return {
        # - Local properties
        'name':self.name,
        'street_address':self.street_address,
        'building_address':self.building_address,
        'city':self.city,
        'region':self.region,
        'postal_code':self.postal_code,
        'country':self.country,
        # - Generate a string:int dict from the UUID:int dict
        'items':{
            str(key):int(self.items[key]) 
            for key in self.items.keys()
        },
        # - Properties from BaseDataObject (through 
        #   HMSMongoDataObject)
        'modified':datetime.strftime(
            self.modified, self.__class__._data_time_string
        ),
        'oid':str(self.oid),
    }

这个过程意味着一个新的故事,可能主要用于 UI 开发,但可能对 Artisan 应用的设计和实现也有一些影响:

  • 作为一名 Artisan,我需要在包含我的产品供应之一的订单被下达时得到通知,以便我能够履行我的订单部分。

由于 Web 商店前端创建新Order还需要将新Order对象传递给每个工匠(回顾订单流程图),并且合理地预期只有流程的商店到网关服务部分会调用create_order_from_store,乍一看,这似乎是一个合理的地方来实现消息传递,但这样做将无法访问服务的日志记录设施,因此两个系统之间的通信失败可能会丢失。相反,如果 Web 商店前端向工匠网关发出创建订单消息,网关服务可以调用create_order_from_store并在执行时记录所需的事件。为了说明这一点,将假定采用这种方法。在这种情况下,create_order_from_store已经完整,工匠/订单消息作为网关服务的create_order方法的一部分发生。其代码的第一个主要部分看起来非常像其他创建过程:

def create_order(self, properties:(dict,)) -> None:
    self.info('%s.create_order called' % self.__class__.__name__)
    if type(properties) != dict:
        raise TypeError(
            '%s.create_order expects a dict of Order '
            'properties, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, properties, 
                type(properties).__name__
            )
        )
    self.debug('properties ... %s:' % (type(properties)))
    self.debug(str(properties))
# - Create the new object...
    new_order = Order.create_order_from_store(properties)
    self.info(
        'New Order %s created successfully' % new_order.oid
    )

由于create_order_from_store方法已经保存了新订单,我们不需要在这里保存它——它已经存在于数据存储中,并且可以在代码到达这一点后立即被其他进程检索到。为了继续,并发送必要的Order消息给需要知道它们的各个工匠,我们需要弄清楚系统中每个工匠与哪些产品(以及数量)相关联。

由于Artisan可以拥有Product,但Product不会跟踪它们属于哪个Artisan(回顾起来可能是一个不错的添加),我们现在唯一的选择是加载Artisan,并为每个产品搜索它。这并不是最佳选择,肯定值得考虑更改,但现在可以使用。

new_order变量持有一个Order对象,如果表示为字典,将如下所示:

{
    'oid':<UUID>,
    'name':<str>,
    # - Shipping-address properties
    'street_address':<str>,
    'building_address':<str> or None,
    'city':<str>,
    'region':<str>,
    'postal_code':<str>,
    'country':<str> or None,
    # - order-items
    'items':{
        <Product object #1>:<int>,
        <Product object #2>:<int>,
        <Product object #3>:<int>,
    },
}

将其转换为 Artisan/item:quantity 值的字典是简单的,如果以一种蛮力的方式完成:

    artisan_orders = {}
    # - Get all the artisans
    all_artisans = Artisan.get()
    # - Sort out which artisan is associated with each item 
    #   in the order, and create or add to a list of 
    #   products:quantities for each
    for product in new_order.products:
        try:
            artisan = [
                candidate for candidate in all_artisans
                if product.oid in [
                    p.oid for p in candidate.products
                ]
            ][0]

如果找到与产品相关联的工匠,那么需要执行两种情况中的一种:要么artisan已经存在于artisan_orders dict中,这种情况下我们只需将项目数据附加到与artisan相关的当前项目列表中,要么他们还没有产品匹配,这种情况下我们为artisan创建一个条目,其值是包含相关项目数据的列表:

item_data = {
  str(oid):new_order.products[product]
}
if artisan_orders.get(artisan):
   artisan_orders[artisan].append(item_data)
else:
   artisan_orders[artisan] = [item_data]
if artisan_orders.get(artisan):
   artisan_orders[artisan].append(product)
else:
   artisan_orders[artisan] = [product]

尽管不应该发生,但有可能出现订单中出现没有可识别的artisan与之关联的产品。如何处理该错误情况的具体细节可能取决于网络商店系统。即使将这种考虑放在一边,也应该以某种尚未定义的方式处理。但至少,失败应该被记录下来:

except IndexError:
   self.error(
       '%s.create_order could not find an '
       'artisan-match for the product %s' % 
       (product.oid)
   )
self.debug('All artisan/product associations handled')

完成这种排序后,artisan_orders字典将看起来像这样,其中artisan_orders中的每个键都是一个实际的Artisan对象,具有任何此类实例的属性和方法,与产品oid和相关的数量:

{
    <Artisan #1>:{
        <str<UUID>>:<int>,
        <str<UUID>>:<int>,
    },
    <Artisan ...>:{
        <str<UUID>>:<int>,
    },
    <Artisan #{whatever}>:{
        <str<UUID>>:<int>,
        <str<UUID>>:<int>,
    },
}

Python 字典实例可以使用几乎任何东西作为键:任何不可变的内置类型(如strint值,甚至tuple值,但不是list或其他dict值)都可以用作dict中的键。此外,用户定义类的实例,甚至这些类本身,也是可行的。内置类的实例,或者内置类本身,可能不是有效的dict键。

有了完整和完整的artisan_orders,向每个工匠发送订单消息的过程相对简单——遍历每个工匠键,构建消息数据的结构,工匠应用程序的Order类期望创建一个DaemonMessage来签署消息,并发送它:

sender = RabbitMQSender()
self.info('Sending order-messages to artisans:')
for artisan in artisan_orders:
# Get the products that this artisan needs to be concerned #with
items = artisan_orders[artisan]
# - Create a message-structure that 
#   artisan_objects.Order.from_message_dict can handle
new_order_data = {
    'target':'order',
    'properties':{
        'name':new_order.name,
        'street_address':new_order.street_address,
                'building_address':new_order.building_address,
                'city':new_order.city,
                'region':new_order.region,
                'postal_code':new_order.postal_code,
                'country':new_order.country,
                'items':items,
                'oid':str(new_order.oid),
            },
        }
        # - Create the signed message
        order_message = DaemonMessage(
            'create', new_order_data, artisan.signing_key
        )

向特定手艺人发送消息需要另一个更改:RabbitMQSendersend_message方法最初并不是用来发送消息到除了默认队列之外的队列。每个手艺人有自己的消息队列是有道理的,为了使用特定的队列,它必须被接受为send_message的参数。网关端调用发送消息反映了这一点(将artisan.queue_id作为参数传递):

# - Send the message to the artisan
sender.send_message(order_message, artisan.queue_id)
self.info(
    '+- Sent order-message with %d products to '
    'Artisan %s' % (len(items), artisan.oid)
)

RabbitMQSender.send_message中的相关更改并不复杂:添加一个可选的queue_name参数,并检查是否已提供,如果没有提供,则返回到配置的默认队列名称就足够了。

def send_message(self, message:(DaemonMessage), 
        # Added queue_name
        queue_name:(str,None)=None
    ):
    if type(message) != DaemonMessage:
        raise TypeError(
            '%s.send_message expects a DaemonMessage instance '
            'as its message argument, but was passed "%s" (%s)' % 
            (
                self.__class__.__name__, message, 
                type(message).__name__
            )
        )
 # Using the optional queue_name to override the default
    if not queue_name:
        queue_name = self.queue_name
 # - Note that exchange is blank -- we're just using the 
 #   default exchange at this point…
 # - Also note that we're using queue_name instead of the 
 #   original self.queue_name default...
    self.channel.basic_publish(
        exchange='', routing_key=queue_name, 
        body=message.to_message_json()
  )

顾客 - 取消订单

顾客能够取消订单的相关故事,来自先前的故事集合是:

  • 作为取消订单的顾客,我需要相关部分的取消被传达给适当的手艺人,以便他们不会履行他们的订单部分。

订单取消与订单创建有一个共同点:取消的起点应该是顾客,几乎肯定是通过 Web 商店前端可用的某些功能。在与订单创建形成相同假设的情况下,Web 商店前端将能够向手艺人网关服务发送消息,指示已启动取消,同样允许网关在一个单一的消息处理程序方法中处理它:在这种情况下是delete_order

delete_order消息处理程序方法最终必须执行两个任务:

  • 给定一个由oid标识的订单,它必须追踪哪些手艺人参与了最初的订单。该过程的一部分可以与create_order中的手艺人和产品的识别相同。该代码的产品识别方面可能不需要,但包含它并不会造成任何伤害,甚至可能在以后利用它来防止部分履行的订单被取消。

  • 它必须生成并发送消息给与订单相关联的手艺人应用程序:一个带有订单oid作为数据有效负载的删除消息。

手艺人/产品关联,在create_orderdelete_order代码中产生artisan_orders,可能值得将其移入ArtisanGatewayDaemon类中的一个通用辅助方法:它与当前的方法中写的内容完全相同。目前只有两个该代码的实例,并且这些代码在代码中是相邻的,这可能不是一个必须的,但只要有两个相同的代码实例,对其中一个的任何更改也必须对另一个进行更改。

与订单创建过程一样,订单取消意味着一个新的故事,可能主要用于 UI 开发,但对手艺人应用程序可能有一些额外的设计和实现影响:

  • 作为手艺人,我需要在订单被取消时得到通知,其中包括我的产品供应,以便我可以停止与之相关的任何进行中的履行活动,并根据需要更新我的产品状态。

解决这个故事的基础,当它变得活跃时,应该大部分 - 如果不是全部 - 已经在订单删除消息中就位。

手艺人 - 完成订单中的物品

手艺人能够完成订单中的物品的相关故事,来自先前的故事集合是:

  • 作为手艺人,我需要能够更新订单对象,以便我可以在订单的我的部分完成时向中央办公室指示。

最终,由工匠履行订单的全部或部分行为只是另一个更新过程,至少从消息传递的角度来看是这样。到目前为止,尽管在任何Order类中都没有跟踪已履行项目的机制,但在解决这个问题之前,这将是有问题的。幸运的是,已履行项目的模型基本上可以与原始订单项目的模型相同 - 一个 Product oid键和int数量的集合(特指dict)。将该属性添加到artisan_objects.Order,并使用items属性作为模型或指南,需要以下步骤:

  • __init__中包括fulfilled_items,一个dict,作为参数,并以与items参数/属性集成相同的方式集成它

  • 为其创建gettersetterdeleter方法

  • 创建fulfilled_items属性,与_get_fulfilled_items相关联

  • 确保to_data_dict方法在其输出结果中包含fulfilled_items的表示

  • 确保from_data_dict类方法不需要对传入的fulfilled_items值进行任何特殊处理

由于fulfilled_items将遵循Orderitems属性的相同约束,禁止直接修改fulfilled_items的成员。这种禁止的基本原理是相似的:我们希望尽可能严格地控制这些成员的修改,以防止坏数据的更改。同时,我们需要允许工匠履行订单项目(同时执行所有相关检查,以确保数据更改是有效的)。

为了实现这一点,artisan_objects.Order类需要一个方法,允许工匠用户标记项目为已履行:

def fulfill_items(self, oid:(UUID,str), quantity:(int,)):
    """
Assigns a number of items fulfilled to a given item-oid, and sets the 
is_dirty state of the instance to True
"""

对于工匠来说,订单履行数据是更重要的数据之一,因此在允许更改保存之前,我们将以几种不同的方式检查每个参数。检查过程从标准类型和值检查开始(剥离错误消息以保持列表的简洁):

if type(oid) not in (UUID,str):
   raise TypeError() # Expecting a UUID or str-UUID value
if type(oid) != UUID:
   try:
      oid = UUID(oid)
   except:
      raise ValueError() # Could not convert a str value to a UUID
if type(quantity) != int:
   raise TypeError()
if quantity < 0:
   raise ValueError() # Should not fulfill a negative quantity

我们还将检查以确保正在履行的项目实际上是订单的一部分:

if oid not in self._items:
   raise RuntimeError(
       '%s.fulfill_item was asked to fulfill an item '
       '(%s) that doesn\'t exist in the order-items' % 
       (self.__class__.__name__, oid)
)

我们将检查以确保履行数量不大于订单中的数量:

if quantity > self._items[oid]:
   raise RuntimeError(
         '%s.fulfill_item was asked to fulfill an item '
         '(%s) in a higher quantity (%d) than was '
         'ordered (%d)' % 
         (
            self.__class__.__name__, oid, quantity, 
            self._items[oid]
         )
   )
# If everything checks out, then update the quantity, etc.
self._fulfilled_items[oid] = quantity
self._set_is_dirty(True)

类似的更改,减去fulfill_items方法,还需要对中央办公室Order类(co_objects.Order)进行处理以处理履行消息。目前,直到我们可以专注于下一章中的往返消息测试,这些可以通过简单地从artisan_objects.Order复制代码来实现。

在这么多代码之间进行复制是重构Order类的另一个理由,重新定义BaseOrder,并从中派生具体的类。本书中的时间和空间限制可能不允许对这个过程进行太多讨论,但我们至少会在测试期间或之后简要地看一下。

何时发送消息?

到目前为止,我们已经花了相当长的时间来深入研究相关消息将如何生成和发送,但很少关于何时发生,除了对订单创建和取消的检查。由于消息直接对应于各种本地 CRUD 操作,很容易将消息调用简单地添加到它们已经具有的_create_update方法中,确保考虑我们在BaseDataObject中定义的is_dirtyis_new标志。然而,在走下这条路之前,最好看一下所有的消息处理过程,从起源到完成,以确保它们有一个清晰的过程终止。我们需要确保避免的情况,以Product更新过程为例,看起来像这样:

在哪里:

  1. 工匠对其产品进行更改:
  • 本地数据更改已执行

  • 他们的Artisan 应用程序Artisan 网关发送消息:更新产品“X”

  1. Artisan 网关接收到消息:
  • 执行本地数据更改

  • 向相应的Artisan 应用程序发送消息:更新产品“X”

  1. Artisan 应用程序接收到消息:
  • 执行本地数据更改,可能没有任何更新的数据

  • Artisan 网关发送消息:更新产品“X”

在最后一步结束时,如果没有一些检查过程或退出条件,该过程将回到第二步,并进入一个实际上不执行任何操作的更新消息的无限循环。在任何可以发生多个数据更改起点的更新过程中,都可能出现相同的情况:Artisan对象可以由它们代表的工匠和中央办公室工作人员更新。Order对象目前是豁免的,但很容易想象在将来需要顾客在订单传递给将履行其中物品的工匠后修改订单的情况。

最终,由于各种数据对象类的save方法对其正在执行的数据更改的来源没有意识,它们无法对数据更改执行后是否适合发送消息做出任何决定。因此,一个可能的解决方案是允许(甚至要求)在每个save中提供该信息的额外参数,并且可以用于确定是否需要发送消息。这种修改的结构可能看起来像这样(对于存储在 Artisan 应用程序代码库中的数据对象):

def save(self, origin:(str,)):
    """
Saves the instance's state-data to the back-end data-store by 
creating it if the instance is new, or updating it if the 
instance is dirty
"""
    # - Perform the data-save process as it currently exists
    if self.is_new and origin != 'artisan':
        # - Send "create" message
    elif self.is_dirty and origin != 'artisan':
        # - Send "update" message
    self._set_is_new(False)
    self._set_is_dirty(False)

可以在BaseDataObject(当前定义了save)和每个具体数据对象之间添加一个额外的抽象层,该抽象层将覆盖BaseDataObject.save方法。这种抽象-额外的 ABC-至少需要在 Artisan 应用程序和 Artisan 网关代码库中创建,并且根据尚未完全探索的实现细节,中央办公室应用程序可能也需要另一个变体。

权衡之处在于所有数据对象都必须注意其数据更改的来源。这感觉…混乱、复杂,至少乍一看可能难以维护。

另一种可能性是修改DaemonMessage:如果消息本身包含某些内容,比如指示其来源的数据,那么这些消息的处理程序就能够判断在处理完数据更改后是否需要发送消息。在这种设计方案中,由 Artisan 发起的Product更新消息,包括origin规范,可能如下所示(在转换为 JSON 之前):

{
    'operation':'update',
    'origin':'artisan',
    'data': {
        'target':'product',
        'properties':{
            'oid':str(new_order.oid),
            'name':'Revised Product Name',
            # - Other product-data skipped for brevity
        },
    },
    'signature':'signature hex-string'
}

ArtisanGatewayDaemon服务类中对应的update_product处理程序方法,以及其他处理程序方法,目前期望一个dictproperties)来执行,并且在服务的main循环中由ArtisanGatewayDaemon._handle_message调用以读取消息进行处理。我们可以改变各个处理程序方法的期望,传递原始的messageDaemonMessage实例)而不是,使处理程序方法负责将传入的message分解为properties并对其进行处理,就像它们已经做的那样,并让它们负责确定是否需要发送消息并发送消息。

给定一个带有originDaemonMessage,以及一个全局可访问的值来与该来源进行比较,决定是否发送消息,并在需要时发送消息并不复杂。如果它在网关服务的任何地方(即self是服务实例),它看起来会更或多少像这样:

# self.message_origin is an attribute containing 'gateway'
# - message is the incoming DaemonMessage instance
# - message.origin is 'artisan'
# - artisan is the relevant Artisan object
if message.origin == self.message_origin:
    sender = RabbitMQSender()
    outbound_message = DaemonMessage(
        operation=message.operation,
        origin=self.message_origin,
        data=message.data,
        signing_key=self.signing_key
    )
    sender.send_message(order_message, artisan.queue_id)

用于创建outbound_message的数据可能会有所不同,这取决于是使用新创建或最近更新的对象的数据字典还是消息字典。

因此,当一个传入的“消息”被执行时:

  • 检查了它的origin

  • 如果origin是本地的,那么将创建并发送相应的outbound_message,使用传入message的原始operation,本地originsigning_key,以及适当的data

  • 否则,整个分支都会被跳过

要添加的代码不多——仅仅九行,假设发送方没有在其他地方创建。对DaemonMessage的更改相当琐碎:添加origin属性并确保它在所有地方都被考虑到(基本上是在operation属性已经被使用的任何地方)。在这一点上,这也不代表对现有代码的重大更改——到目前为止,我们只为订单创建和更新创建了出站消息。

如果有一个瓶颈,那就是需要获取与操作相关的Artisan实例,以便出站消息可以使用适当的消息队列(artisan.queue_id)。无论我们决定追求什么方法,这都是必要的,所以在这种情况下可能是一个平衡(它还会使我们之前看到的修改save的想法变得更加复杂)。

即使有了这一点,这种方法仍然感觉很可靠。在这一点上,_handle_message的更改主要是参数和变量名称的更改:

def _handle_message(self, message:(DaemonMessage,)) -> None:
    self.info(
        '%s._handle_message called:' % self.__class__.__name__
    )
    target = message.data.get('target')
    self.debug('+- target ....... (%s) %s' % (
        type(target).__name__, target)
    )
    self.debug('+- operation .... (%s) %s' % (
        type(message.operation).__name__, message.operation)
    )
    if message.operation == 'create':
        if target == 'artisan':
            self.create_artisan(message)

# ... removed for brevity

    elif message.operation == 'update':
        if target == 'artisan':
            self.update_artisan(message)
        elif target == 'customer':
            self.update_customer(message)
        elif target == 'order':
            self.update_order(message)
        elif target == 'product':
            self.update_product(message)
        else:
            raise RuntimeError(
                '%s error: "%s" (%s) is not a recognized '
                'object-type/target' % 
                (
                    self.__class__.__name__, target, 
                    type(target).__name__
                )
            )

    # ... removed for brevity

    else:
        raise RuntimeError(
            '%s error: "%s" (%s) is not a recognized '
            'operation' % 
            (
                self.__class__.__name__, message.operation, 
                type(message.operation).__name__
            )
        )

处理程序方法(以update_product为例)基本保持不变:

def update_product(self, message:(DaemonMessage,)) -> None:
    self.info('%s.update_product called' % self.__class__.__name__)
      if type(message) != DaemonMessage:
         raise TypeError(
             '%s.update_product expects a DaemonMessage '
             'instance, but was passed "%s" (%s)' % 
             (
                self.__class__.__name__, message, 
                type(message).__name__
             )
         )

我们仍然需要properties;我们只是在个别处理程序方法中获取它们,而不是在_handle_message中获取:

properties = message.data.get('properties')
self.debug('properties ... %s:' % (type(properties)))
self.debug(str(properties))

从那一点到修改的对象保存为止,代码保持不变:

#   ... and save it.
new_object.save()
self.info('Product %s updated successfully' % new_object.oid)

然后我们可以检查是否需要发送出站消息,获取相关的Artisan,创建message并发送:

if message.origin == self.message_origin:
  # - Acquire the Artisan whose Product this is
  artisan = self.get_artisan_from_product(new_object)
  sender = RabbitMQSender()
  outbound_message = DaemonMessage(
       operation=message.operation,
       origin=message.origin,
       data=message.data,
       signing_key=self.signing_key
   )
   sender.send_message(order_message, artisan.queue_id)

由于从Product中获取Artisan将是一个经常出现的主题,因此创建了一个辅助方法(get_artisan_from_product)来简化该过程。这也突出了产品和工匠之间更直接关联的最终需求,但是基于数据对象查询的过程现在足够了:

def get_artisan_from_product(
       self, product:(UUID,str,BaseProduct)
    ) -> (Artisan):
    # TODO: Add artisan (owner) to Product classes, and use 
    #       that instead. For now, use this approach
    all_artisans = Artisan.get()
    if isinstance(product, BaseProduct):
       product = product.oid
    elif type(product) == str:
       product = UUID(product)
    for artisan in all_artisans:
        if product in [p.oid for p in artisan.products]:
           return artisan

在结束本章之前的最后一个考虑:当我们开始这一部分开发时,关于消息队列是作为“所有工匠的一个”还是“每个工匠的一个”实施的决定仍在等待。虽然没有做出正式决定,但在思考消息处理过程时可能出现了其他考虑:

  • 每个工匠至少需要两个单独的消息队列:一个用于发送到工匠的流量,一个用于从他们那里发送的流量。如果实施了所有流量的单一队列,那么:

  • 代码必须被修改以包括origin(已完成)和destination,以确保例如,网关放入队列的消息不会被网关读取

  • 即使有了这个,一个尚未被适当目的地读取和执行的消息几乎肯定会阻止队列中的其他消息被读取和执行,而不是仍然需要更多的代码更改和相关的复杂性

  • 如果每个工匠都有一个独特的消息队列用于传入和传出的消息,那么整套复杂性将会消失。还需要一些额外的工作——提供一些标识单个传入和传出队列的手段——但是如果每个队列只处理单向流量,到或从一个工匠应用和网关服务,这将大大简化事情,并且开发成本应该是相当小的。

  • 作为一个附带的好处,由于队列中的每条消息,仅仅因为它来自该队列,就可以立即与该队列所属的工匠相关联。

拥有多个消息队列的唯一剩下的成本是会存在多个队列,而这主要是由消息队列服务器承担的成本。

总结

本章的开发工作在整个系统的代码库中分散进行,主要是因为一些需求差距或实现需要和细节的出现,这些需求和细节是特定功能展开时出现的。理想情况下,在实际的努力中,很多东西本应该早早地出现,并且被表达为附加到迭代故事中的具体任务,尽管一些可能仍然发生——我们在本章和之前的章节中做出了一些决定,这些决定塑造了工作的需要,可能没有被捕捉在最初的故事分析练习中。

因此,目前的代码很可能是错误的。也许是严重错误的。尽管如此,在这个迭代的故事中取得了很大的进展,即使它们还不能正式关闭:

  • 已经定义了处理系统组件之间所有数据流的基本功能,并有一些具体的实现作为其他具体实现的起点。

  • 已经确定需要进行更改以完成消息的传输和接收,即使还没有实施。

  • 已经建立了对何时以及如何发送这些消息的基本理解。

在大多数迭代故事完成之前仍然有工作要做——即使不考虑 UI 方面的考虑,我们仍然没有可证明的消息流。在下一章中,将专注于完成这些工作,并且将采用明显的测试驱动方法,即使这不是一个正式的 TDD 过程。

第十八章:测试和部署服务

第十七章,处理服务事务,以未经测试的通过网络实现对数据对象的 CRUD 操作的接收结束,这些操作源自工匠和中央办公室应用程序。由于证明(和展示)这些功能将需要进行质量保证和故事批准,而且由于该代码没有结构化或有用的可重复测试,在本章中,我们将详细讨论以下主题:

  • 识别和处理测试服务应用程序的挑战

  • 打包和部署服务涉及什么

  • 展示服务功能的方法

此外,由于hms_sys的功能开发几乎已经完成,我们将对hms_sys中仍然需要完成的工作以使其对最终用户有用以及对其进行可能的未来增强进行一些思考和研究。

测试服务的挑战

测试服务虽然不难,但可能比到目前为止展示的相对基本的单元测试要复杂得多。例如,从工匠到网关的一般hms_sys数据流中的每个点都有特定和个别的测试问题,但整个流程应该尽可能完整,以便可以根据需要执行端到端的流程验证。

端到端的流程可能如下图所示:

从头到尾,对这个数据流的测试计划需要至少解决以下问题:

  • 以一种可以用来验证最终过程的方式创建message-data

  • 创建DaemonMessage(尽管可能不测试它是否准确创建了——应该已经有单元测试来测试这一点)

  • 发送结果消息

  • 通过与原始message-data进行比较,验证工匠网关服务接收消息的结果是否符合预期

根据服务操作的具体情况,可能发生在消息传输和接收之间的步骤可能不实际(或不可能)进行测试:

  • 测试整个过程中的send_message()部分必须采取措施来确保可以在没有其他过程(在这种情况下是网关服务)在消息可以被验证之前消耗消息的情况下进行消息的传输。如果send_message的单元测试考虑到了这一点,以至于send_message方法本身可以被认为是可信的,那么更大范围的过程测试可以安全地跳过测试整个过程的这一部分。

  • 同样,对各种[process-method]的测试应该提供对整个流程的各个部分的可信度。另一种方法是修改这些方法,以便在过程中观察它们的操作,在这种情况下,它们实际上不是相同的方法,而且应用的任何测试可能是毫无意义的。

鉴于整个流程的每个部分都应该有自己的单元测试,问这些问题是公平的:“通过测试整个流程,我们到底得到了什么?仅仅单独的单元测试就足够了吗?”简短的答案(尽管可能被视为作者的观点)是——从某种角度来看,所有流程测试实际上都是 Artisan Gateway 的main方法的单元测试——根据传入消息的内容做出调用哪个方法的决定的事件循环。仅从这个角度来看,鉴于ArtisanGatewayDaemon.main是该类中关键的功能块,必须进行彻底测试。还要考虑到,我们测试政策要求的单元测试基本上涵盖了流程图中的所有框:message-dataDaemonMessagesend_message等等。它们并没有覆盖流程图中的箭头。虽然代码可能不会错过箭头代表的步骤之一,但这并非不可能,因此一个更高级别的端到端流程测试将揭示任何这些空白,从而证明整个流程的可信度。同样,这些流程的最终结果都需要是可验证的——例如,如果启动了一个 Artisan-Creating-Product 流程,需要确保一旦流程完成,就可以从数据存储中检索到新的Product对象,并且数据正确。

最后,由于各种流程都是在后台进行的,如果有错误进入生产安装,它们很可能很难进行调试:

  • 几乎没有办法查看启动流程执行的各个 Artisan 应用程序安装

  • 来回发送的消息,除非极其详细地记录其内容/数据,否则不会持续足够长时间以便在生产环境中进行调试时可读和可用

  • 没有更详细的日志记录,特定的守护进程调用是不可见的,它们的结果(如果有的话)无法与它们来源的原始数据进行对比

整体测试策略

在编写实现完整流程测试的代码之前,需要努力完成并成功执行所有未完成的单元测试。完成后,我们可以逻辑地认为流程测试中出现的任何失败都是因为流程中的某些问题,尽管我们可能希望验证子流程步骤,并针对某些条件引发失败。这可能会随着流程测试的编写而发展。

每个具有一组相应流程的业务对象都需要检查适用的任何/所有以下流程:

  • 对象的创建,以及其数据的本地和远程持久性:

  • 每个允许执行更新的角色

  • 确保测试有效和无效的更新尝试

  • 更新对象的数据:

  • 每个允许执行更新的角色

  • 确保测试有效和无效的更新尝试

  • 对象的删除:

  • 每个允许执行删除的角色

  • 确保测试有效和无效的删除尝试

  • 验证尝试后适用的本地和远程数据更改

确定什么构成无效尝试需要考虑至少以下问题:

  • 在测试的流程中的任何步骤,可能会有什么被损坏,应该阻止流程成功完成的情况?

  • 在测试的流程中的任何步骤,可能会有恶意更改,应该阻止流程成功完成的情况是什么?

  • 已经有哪些测试考虑了这些情况?

  • 需要为未考虑的任何场景创建哪些测试?

对于网关服务,存在潜在的坏数据变体的点有:

  • 尝试创建或更改业务对象实例的无效尝试: 这些应该大部分由业务对象本身的创建和更新过程的单元测试覆盖——这些测试应该确保,例如,只允许良好形式的数据创建和更新,并且在引发异常后,消息传输过程甚至不应该触发。这些情况实际上无法在网关守护程序的上下文中进行测试,但必须在与其通信的应用程序中进行测试。

  • 接收到未经授权的数据事件消息:DaemonMessage中对消息签名的测试应该确保具有无效签名的消息引发错误。作为其延伸,数据事件处理的测试应该确保如果引发未经授权的消息错误,它会被干净地处理,并且不执行任何数据更改。

  • 接收到带有无效数据的授权数据事件消息:假设消息发起方与数据事件相关的单元测试已经完成,这可能是恶意活动的指示。暂且不考虑测试方面的考虑,应该对该事件周围的日志进行一些审查,以确保该类别的事件被记录。无论是恶意还是不恶意,消息接收端的相应单元测试应该确保引发某种异常,并且数据事件处理测试应该确保处理任何引发的异常,并且不执行任何数据更改。

尽管这些测试严格来说不是单元测试(它们会被正式分类为系统或集成测试的某种混合),我们仍然可以利用unittest模块的功能,该模块一直驱动着系统的所有自动化测试。如果有这样的愿望,这将允许将过程测试集成到完整的测试套件中,并作为其一部分运行,或者独立运行,或者根据需要/愿望进行单独运行。

值得注意的单元测试变体

需要实施的大部分单元测试都相当整洁地落入了自hms_sys开发工作开始以来一直在进行的标准流程中,对此没有什么新的说法。然而,还有一小部分其他测试具有一些值得注意的变体。

随着大量依赖于标准 Python 模块(例如atexitloggingsignaldaemons代码库中)或安装以满足特定需求的各种第三方模块的新代码的出现,测试政策的另一个方面浮出水面:如何深入(甚至是否)测试几乎只是其他来源功能的包装器的功能。可以合理地假设,任何作为 Python 分发本身的一部分的软件包在包含在分发中之前都经过了彻底的测试。可以合理地假设,可以通过pip工具安装的任何软件包也经过了彻底的测试,尽管这可能会因软件包而异。

这些都是可能被表达为“信任框架”的变体。基本上,这归结为在假设通过pip(或操作系统提供的其他设施)安装的软件包已经经过了足够的测试(无论“足够”的价值是什么)。如果认为这些已经经过了足够的测试,它们就不需要被分类测试。使用受信任的框架功能开发的功能是否需要进行测试,可能取决于外部功能的具体使用方式。

在列出显著变化的单元测试时,应该记住这一点。

hms_core包中,BaseDaemonBaseDaemonizable的 ABCs 的测试具有相应的具体类定义(分别为BaseDaemonDerivedBaseDaemonizableDerived),这些类用于根据需要创建测试实例。这本身并不是新鲜事——我们以前已经使用具体派生类来简化 ABC 的测试。不过,创建任一可测试的实例都需要在实例构造期间传递一个配置文件。该文件的创建和清理由setUpClasstearDownClass方法处理,这些方法在TestCase类上定义:

class testBaseDaemon(unittest.TestCase):

# ...

    @classmethod
    def setUpClass(cls):
        # - Create a basic config-file that can be used to create
        #   instances of BaseDaemonDerived.
        config_data = """logging:
  format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
  name: example
  console:
    level: info
  file:
    level: debug
    logfile: "/tmp/example.log"
"""
        cls._config_file = 'example.config'
        with open(cls._config_file, 'w') as fp:
            fp.write(config_data)

    @classmethod
    def tearDownClass(cls):
        try:
            os.unlink(cls._config_file)
        except:
            pass

setUpClass执行时,在任何测试方法触发之前,它会在当前工作目录中创建一个可用的配置文件(example.config),填充它的基本配置数据,并在一个类属性(cls._config_file)中跟踪文件名,以便测试方法可以访问。在测试方法中创建测试对象的典型模式看起来像这样:

    def testmethod(self):
        # Tests the method method of the BaseDaemon class
        test_object = BaseDaemonDerived(self._config_file)
        # - Whatever assertions and other test-processes are needed...

hms_core.daemons成员进行了一些测试,其中一些被积极跳过。BaseDaemon的各种控制方法(startstoprestart)被勉强跳过。尝试测试它们的根本问题在于,就目前而言,它们只不过是对其他方法的调用集合,其中许多方法本身将被测试。平衡点落在对框架的信任范畴。最多,只有一个决策点(在 start 中,检查实例的_running标志)可能有用地进行测试,但在更改该标志值之前,它必须非常快地发生,以终止实例的进程。最终,只要守护程序的实例启动、停止和重新启动没有错误,这些方法就表现如预期,通过明确测试相应的方法几乎没有什么收益。

对于BaseDaemonizable中的daemonizepreflightstart方法也出于类似的原因做出了类似的决定,另外一个问题是,许多被调用的方法是由标准模块提供的,并且它们本身也属于信任框架的范畴。

BaseDaemon的所有日志包装方法(criticaldebugerrorinfowarn)都被积极跳过。这一决定背后的理由是,只要它们调用的Logger实例被正确创建,这些实例就属于“信任框架”的范畴。

BaseDaemonizable的属性(其值为文件系统路径(stdinstdoutstderrpidfile)几乎遵循我们之前建立的标准测试结构。主要区别在于它们是文件系统路径值,因此这些属性的测试方法需要包括有效和无效的路径,以及由于文件系统权限而无法写入或读取的格式良好的路径。这些测试也与操作系统紧密相关:例如,在 Windows 中完全有效的文件路径在类似 Linux 或 macOS 使用的 POSIX 风格文件系统中可能无效。

BaseDaemon.config_fileconfigure方法也需要类似的策略。

BaseDaemoncleanuppreflight方法属于一个独特的类别:默认情况下,它们只是记录(在info日志级别)它们已被调用,以便启动和关闭活动日志可以宣布它们已被执行。如果派生类实际上没有覆盖这些方法,BaseDaemon提供的基线功能将被调用,并执行相同的日志记录。如果应用了早期提到的日志包装器方法相同的标准,那么cleanuppreflight都属于“信任框架”分类。但是,如果将来的需要改变其中一个方法,添加超出简单调用日志的内容,会发生什么?在这种情况下,如果跳过测试,即使应该进行测试,也不会执行任何测试。事实上,无法预料到会对相关测试产生影响的变化,因此必须假定一定程度的预期纪律——任何对这些基本方法进行实质性更改的人也必须相应地更新相应的测试。

在构建和执行这些单元测试的过程中,一些在开发周期早期被存根化但实际上从未被使用的类,需要进行测试。由于这些从未被需要(甚至在许多情况下从未被实现),这些类本身已被移除,相应的测试要求也随之消失。

hms_core.messaging中的RabbitMQSender类有一个方法send_message,部分属于“信任框架”类别。它还需要测试以确保对DaemonMessage实例的类型检查是被考虑的。综合起来,该方法的完整测试几乎只是类型检查测试,并确保该方法执行时不会出错。经过一些考虑,还在send_message中实现了检索发送的消息,或者至少执行确认,以便它不会永远停留在某个测试队列中。

剩下的未完成的测试,所有这些测试都遵循标准单元测试流程的合理简单变化,如下所示:

  • hms_core.business_objectshms_artisan.artisan_objects中:

  • 测试Artisan.queue_idArtisan.signing_key属性

  • hms_core.co_objects中:

  • 测试Artisan.to_message_data方法

  • 测试新的Order

  • hms_core.daemons中:

  • 测试BaseDaemon._create_logger

  • hms_core.messaging中:

  • 测试DaemonMessage

  • 测试HasMessageData

  • 测试InvalidMessageError

  • 测试RabbitMQSender的标准项目

  • hms_artisan.artisan_objects中:

  • 测试Artisan.to_message_data方法

hms_Gateway命名空间之外的所有测试类中,留下了ArtisanGatewayDaemon类的属性和方法,这些属性和大部分方法可以按照标准的测试政策和流程进行测试。最值得注意的例外是ArtisanGatewayDaemon.main,它将在测试模块中被跳过,并且现在可以进行端到端流程测试。

测试工匠交易

对工匠的端到端流程测试需要包括以下内容:

  • 创建一个工匠,就像从中央办公室工作人员那里发起的那样

  • 更新一个工匠,就像从中央办公室工作人员那里发起的那样

  • 更新一个工匠,就像从工匠自己那里发起的那样

  • 删除一个工匠,就像从中央办公室工作人员那里发起的那样

由于我们不测试类,这一直是我们所有单元测试的模式,我们不需要我们标准单元测试扩展的所有功能,但我们将希望使用足够相同的结构和至少一些在那里创建的实用程序,以便将流程测试与hms_Gateway命名空间的常规单元测试运行集成。考虑到这一点,起点代码看起来与我们以前的测试模块非常相似。

#!/usr/bin/env python
"""
Defines end-to-end process-tests for the ArtisanGatewayDaemon
"""

#######################################
# Standard library imports needed     #
#######################################

import os
import sys
import unittest

由于我们实际上只需要我们一直在使用的单元测试扩展的输出和报告保存功能,我们只会导入这些功能:

#######################################
# Local imports needed                #
#######################################

from idic.unit_testing import PrintTestResults, SaveTestReport

模块级常量保持不变,由于我们将对运行中的ArtisanGatewayDaemon类进行测试,我们已经知道我们需要导入它:

#######################################
# Module-level Constants              #
#######################################

LocalSuite = unittest.TestSuite()

#######################################
# Imports needed for testing          #
#######################################

from hms_Gateway.daemons import ArtisanGatewayDaemon

我们将首先测试的四个流程可以分别由单个测试方法表示。这些方法中的每一个都必须提供每个流程测试步骤需要执行的任何代码,但它们可以从明确的失败开始:

#######################################
# Test-cases in the module            #
#######################################

class testArtisanProcesses(unittest.TestCase):

    def testArtisanCreateFromCO(self):
        self.fail('testArtisanCreateFromCO is not yet implemented')

    def testArtisanUpdateFromCO(self):
        self.fail('testArtisanUpdateFromCO is not yet implemented')

    def testArtisanUpdateFromArtisan(self):
        self.fail('testArtisanUpdateFromArtisan is not yet implemented')
    def testArtisanDeleteFromCO(self):
        self.fail('testArtisanDeleteFromCO is not yet implemented')

由于我们正在使用标准的单元测试扩展,我们仍然需要主动将每个测试用例类添加到本地测试套件中:

LocalSuite.addTests(
    unittest.TestLoader().loadTestsFromTestCase(
        testArtisanProcesses
    )
)

最后,由于我们希望能够独立运行流程测试模块,我们将包括与之前所有模块中相同的if __name__ == '__main__'代码块,它将提供测试结果的输出并将结果保存到报告文件中,如果没有失败的话:

#######################################
# Code to execute if file is called   #
# or run directly.                    #
#######################################

if __name__ == '__main__':
    import time
    results = unittest.TestResult()
    testStartTime = time.time()
    LocalSuite.run(results)
    results.runTime = time.time() - testStartTime
    PrintTestResults(results)
    if not results.errors and not results.failures:
        SaveTestReport(results, 'hms_Gateway.ModuleName',
            'hms_Gateway.EndToEndProcesses.test-results')

由于所有这些测试都需要ArtisanGatewayDaemon类的运行实例,我们还需要确保有一个可用。因为类的运行实例是一个独立于任何其他进程的服务,启动服务实例不能作为任何测试方法的正常部分发生——main循环将启动,并且在它终止之前不会有任何其他进展,这使得实际测试main控制的流程变得不可能。

有几种选择可以缓解这个问题:

  • 测试过程可以以某种方式使用操作系统服务控制设施来启动本地服务实例,就像在部署后控制它一样。从长远来看,这可能是一个更好的方法,但在开发过程的这一点上,我们实际上无法部署服务代码,所以这将需要等待未来的开发。然而,这种方法存在一个权衡:为了使测试准确,服务必须在每次执行测试套件时部署,或者必须创建一些等效的机制来模仿已部署的服务。

  • 由于服务最终只是一个类的实例,测试过程可以创建一个实例并启动它,让测试执行,然后终止用于测试的服务实例。虽然这是一个更复杂的解决方案,但至少在某种程度上感觉更好:每个测试套件都可以针对专门为这些测试定制的服务实例执行,包括具有不同消息队列的服务实例,如果必要,可以在解决测试方法引发的问题时进行检查,而无需整理可能庞大的消息集。

实施第二个选项涉及使用先前提到的setUpClasstearDownClass方法,在任何测试执行之前创建服务实例并使其运行,并在所有测试完成后关闭该实例。由于每个业务对象过程集合都应该有一个测试用例类,因此设置setUpClasstearDownClass以便它们可以被各种测试用例类重用也是一个不错的计划。我们可以通过创建一个包含两种方法所需逻辑的类来简化这一过程,然后从该新类和迄今为止一直是测试用例类的骨干的unittest.TestCase类派生出各个测试用例类:

class NeedsArtisanGateway:

    @classmethod
    def setUpClass(cls):
        """
Creates and starts an instance of the ArtisanGatewayDaemon that 
can be used during execution of the tests.
"""

顺便说一句,这表明可以向测试套件添加辅助类——这些类在测试执行期间提供一些功能或所需的能力,但它们本身不是测试用例类。

我们需要创建一个配置文件,服务实例将使用该文件,但在这之前,我们将存储一些我们可能在测试方法中需要的值作为类属性,以便以后在需要时可以访问它们:

        cls.Gateway_signing_key = os.urandom(64).hex()
        cls.Gateway_queue_id = 'hms_ag_%s_process_test' % cls.queue_name
        cls.Gateway_config_file = 'process_test.config'

配置数据可以设置为一个字符串,遵循先前建立的配置结构。如果需要,可以通过向类添加变量/属性值来定制服务实例,并确保这些值被传递到字符串中,就像这里的cls.Gateway_queue_idcls.Gateway_signing_key属性一样:

        cls.Gateway_config_data="""# Logging configuration
logging:
  format: "%%(asctime)s - %%(name)s - %%(levelname)s - %%(message)s"
  name: hms_ag_process_test
  file:
    level: debug
    logfile: "/tmp/hms_ag_process_test.log"
queue:
  type: rabbit
  connection:
    host: localhost
    port: 5672
    path: /
  queue_name: "%s"
signing_key: "%s"
""" % (cls.Gateway_queue_id, cls.Gateway_signing_key)

配置数据被写入一个临时配置文件,该文件由测试用例类使用,方式与我们在测试BaseDaemon时所做的方式相同:

with open(cls.Gateway_config_file, 'w') as fp:
    fp.write(cls.Gateway_config_data)

由于我们可能需要访问服务实例本身,我们将创建并存储该实例作为另一个类属性:

cls.Gateway = ArtisanGatewayDaemon(cls.Gateway_config_file)

启动服务实例需要执行其启动方法,以便该过程独立于运行测试代码。为了实现这一点,我们将使用 Python 的multiprocessing模块中的Process类,告诉它在启动Process时调用哪个方法,并且该进程应该被视为daemon,使其执行独立于其他运行的代码。设置好之后,我们可以启动Process,执行存储在cls.Gateway中的服务实例的启动方法:

cls.Gateway_process = Process(target=cls.Gateway.start, daemon=True)
cls.Gateway_process.start()

multiprocessing模块将在第十九章中进行更详细的探讨,Python 中的多处理和 HPC,在那里我们将探索跨多个进程和机器分配计算负载的各种策略和方法。

拆卸要简单得多:存储控制运行服务实例的进程(cls.Gateway_process)后,只需终止该Process(调用terminate方法),并删除临时配置文件,以便不会在测试代码中留下它。由于进程的终止可能在拆卸执行完成之前不完整,因此还添加了一个短暂的延迟:

    @classmethod
    def tearDownClass(cls):
        # - Stop the service-instance
        cls.Gateway_process.terminate()
        # - Clean up (delete) the temp. config-file
        os.unlink(cls.Gateway_config_file)
        # - Add a short delay to allow the process-termination time 
        #   to complete before proceeding with the next item...
        time.sleep(1)

在测试用例类中使用NeedsArtisanGateway类需要进行一些微不足道的代码更改:每个测试用例类都需要从NeedsArtisanGatewayunittest.TestCase派生开始:

class testArtisanProcesses(NeedsArtisanGateway, unittest.TestCase):

此外,由于NeedsArtisanGateway需要一个queue_name类属性来创建Gateway_queue_id类属性,因此需要定义它:

queue_name = 'artisan'

然而,从那时起,剩下的一切都保持不变:

def testArtisanCreateFromCO(self):
    self.fail('testArtisanCreateFromCO is not yet implemented')

# ...

在实施任何测试之前,测试模块中需要进行一些配置和设置。预计所有过程测试都需要数据访问能力,因此我们需要导入主数据存储类,以及数据存储配置类,并配置数据访问以允许这些功能:

from hms_core.data_storage import DatastoreConfig, HMSMongoDataObject

config = DatastoreConfig(
    database='hms_proc_tests',
)
HMSMongoDataObject.configure(config)

同样,由于过程测试都涉及消息传输,我们需要能够创建发送者对象——RabbitMQSender的实例——以及DaemonMessage对象。这些也需要被导入,并且需要进行基本的RabbitMQSender.configuration调用:

from hms_core.messaging import DaemonMessage, RabbitMQSender, \
    MESSAGE_ORIGINS
RabbitMQSender.configure(
    'hms_ag_process_test', 'localhost', 5672, '/'
)

import行中的MESSAGE_ORIGINS是一个新的模块常量,一个包含名称和值的集合,可以用来控制哪些值是集合的成员,与它们相关联的名称是什么,并确定给定值是否是集合的成员。它的定义如下:

MESSAGE_ORIGINS = namedtuple(
    'MESSAGE_ORIGINS', ['artisan', 'central_office']
)(
    artisan='artisan',
    central_office='central-office',
)

Python 确实有一些官方的枚举类,但是否则最适合满足这个需求的enum.Enum不允许检查任意值是否属于枚举。这些差异可以在本章代码中的hms_Gateway/scratch-space中的enumeration-example.py文件中看到的结果中看到。

最后,由于测试过程将使用具有不同命名空间的相同名称的类(例如,hms_core.co_objects.Artisanhms_artisan.artisan_objects.Artisan,都命名为 Artisan),我们需要导入它们并在过程中重命名,如下所示:

from hms_core.co_objects import Artisan as COArtisan
from hms_artisan.artisan_objects import Artisan as ARArtisan

从这一点开始,任何创建COArtisan对象都将是hms_core.co_objects.Artisan类的实例,而ARArtisan对象将是hms_artisan.artisan_objects.Artisan的实例。

有了这些,第一个过程测试方法的实现终于可以开始了。它从创建sender对象开始,该对象将用于发送测试消息:

def testArtisanCreateFromCO(self):
    sender = RabbitMQSender()

为了测试 Artisan 创建过程,我们必须创建一个 Artisan:

    parameters = {
        'contact_name':'contact-name',
        'contact_email':'no-one@me.co',
        'address':{
            'street_address':'street-address',
            'city':'city',
        },
        'queue_id':self.Gateway_queue_id,
        'signing_key':self.Gateway_signing_key,
    }
    new_artisan = COArtisan.from_data_dict(parameters)

然后我们创建要发送的message,并发送它:

    message = DaemonMessage(
        operation='create', 
        origin=MESSAGE_ORIGINS.central_office,
        data={
            'target':'artisan',
            'properties':new_artisan.to_message_data(),
        },
        signing_key=self.Gateway_signing_key
    )
    sender.send_message(message, self.Gateway_queue_id)

在代码的这一点上,消息已经发送,但没有简单的方法来确定它是否已经被接收,更不用说被处理了。如果没有实际编写代码(可能是大量的代码)来跟踪消息及其状态,那么暂停处理直到我们相当肯定消息已经被传递并被处理的选项就不多了。下一个最佳选项,也是需要更少的代码工作的选项,尽管它会减慢测试过程,就是简单地延迟执行一小段时间——足够让消息被传递并被处理,但不至于使运行测试变得问题严重。使用time.sleep,我们将延迟处理 5 秒,至少目前是这样。以后可能需要增加,或者如果需要更好地了解过程需要多长时间才能完成,也可以减少:

time.sleep(5)

一旦消息被接收并被处理,如果一切顺利,那么 Gateway 服务将创建一个new_artisan对象,并保存到它正在使用的数据库中。测试过程的下一步是确保实际上创建并存储了一个新对象:

    try:
        verify_artisan = COArtisan.get(str(new_artisan.oid))[0]
    except IndexError:
        self.fail(
            'Although the new artisan (oid: %s) was created, '
            'it could not be retrieved' % (new_artisan.oid)
        )

知道新对象已经创建,我们可以检查确保新对象的数据与最初发送的数据是相同的。由于任何数据对象的数据字典表示都将是最全面的——它应该包括所有被持久化的数据——这就是原始Artisan和新创建和检索到的Artisan的简单比较:

    self.assertEquals(
        verify_artisan.to_data_dict(), new_artisan.to_data_dict()
    )

如果测试过程通过了这个检查,那么我们就完成了用于测试创建的new_artisan对象,并且可以从数据库中删除它:

    COArtisan.delete(str(new_artisan.oid))

这结束了对流程的“快乐路径”测试——在这里,一切都是按照预期创建、格式化和发送的。测试未经授权和格式不正确的消息需要更多的工作,因为我们将会规避ArtisanDaemonMessage类执行的检查。因此,首先从未经授权的消息开始,消息的签名与接收端计算的签名不匹配,我们需要首先创建一个未经授权的消息。我们可以使用现有的消息,因为它仍然存在,提取我们将要发送的数据,然后改变一些东西——可以是数据值或签名:

unauthorized_message_data = message.to_message_dict()
unauthorized_message_data['data']['properties']['website'] = \
    'http://some-bogus-website.com'

由于我们已经有了一个sender,我们可以使用它的channel,以及实例的Gateway_queue_id,来规避正常的发送过程,该过程期望一个DaemonMessage实例。相反,我们将发送刚刚创建的未经授权消息的 JSON 转储:

sender.channel.basic_publish(
    exchange='', routing_key=self.Gateway_queue_id, 
    body=json.dumps(
        unauthorized_message_data, sort_keys=True
    )
)

这个分支的测试部分关注的是数据更改是否通过了Gateway服务。如果通过了,它将生成一个新的Artisan记录,我们可以检索相应的对象。如果通过了,我们可以,那么就出了问题,我们明确导致测试失败。如果检索尝试失败(引发IndexError,因为返回的结果集是一个零长度列表,并且在[0]位置没有元素),那就是预期/期望的行为,我们可以简单地忽略错误,通过测试的这一部分:

    try:
        verify_artisan = COArtisan.get(str(new_artisan.oid))[0]
        self.fail(
            'An unauthorized message should not execute a data-'
            'change'
        )
    except IndexError:
        pass

测试一个无效但经过授权的消息的工作方式基本相同,但我们将改变消息的数据,然后使用正常的DaemonMessage/sender流程:

    invalid_message_data = new_artisan.to_message_data()
    # - Alter a data-value, like website
    invalid_message_data['website'] = 12.345
    invalid_message = DaemonMessage(
        operation='create', 
        origin=MESSAGE_ORIGINS.central_office,
        data={
            'target':'artisan',
            'properties':invalid_message_data,
        },
        signing_key=self.Gateway_signing_key
    )
    sender.send_message(invalid_message, self.Gateway_queue_id)
    try:
        verify_artisan = COArtisan.get(str(new_artisan.oid))[0]
        self.fail(
            'An unauthorized message should not execute a data-'
            'change'
        )
    except IndexError:
        pass

中央办公室更新工匠和中央办公室删除工匠流程的变体看起来非常相似,每个流程都会执行以下操作:

  • 创建一个本地工匠并保存它,以便有一个将要被操作的数据对象。

  • 在继续之前,可以选择验证新创建的工匠是否存在于数据库中,尽管如果Artisan.save方法在其他测试中被认为是可信的,这一步可以被跳过

  • 创建一个适当的message来执行正在测试的流程,并发送它

  • 对比同一个工匠的第二个实例的测试结果:

  • 更新过程测试必须着重更改所有可以合法更改的字段,这些字段可以由测试所扮演的角色(作为中央办公室用户/工匠经理)来更改。在这方面,它可能看起来非常像以前针对Artisan.to_data_dict等方法的单元测试,这些方法返回对象的字典表示

  • 它还应该着重尝试对工匠进行不允许的更改,并验证这些尝试是否失败

  • 删除过程测试将会更简单,因为它所需要做的就是尝试重新获取测试对象(使用类似于verify_artisan = COArtisan.get(str(new_artisan.oid))[0]的方法,我们之前已经看过),如果在执行删除后检索失败,则测试通过

对无效访问尝试进行测试,比如工匠创建工匠,也应该被实施,并且其代码结构与先前显示的测试代码的部分类似。然而,在这些测试通过之前,必须实施实际检查各种操作方法中的消息的机制。使用传入DaemonMessageorigin,这可能看起来像这样,显示了一个一般的、任何角色都允许的检查和一个特定角色的检查,并以Gateway服务的create_artisan方法为例:

def create_artisan(self, message:(DaemonMessage,)) -> None:
    self.info('%s.create_artisan called' % self.__class__.__name__)

    # ...

    # - Assure that only the appropriate roles can execute this 
    #   method. First check against *all* valid origins (at a 
    #   minimum, this check should occur in *all* methods)
    if message.origin not in MESSAGE_ORIGINS:
        raise RuntimeError(
            'Malformed message: "%s" is not an accepted '
            'message-origin' % message.origin
        )
    # - Alternately, check against specific roles/origins instead, 
    #   if they are limited
    if message.origin != MESSAGE_ORIGINS.central_office:
        raise RuntimeError(
            'Unauthorized Action: "%s" is not allowed to '
            'execute this method' % message.origin
        )

对无效角色/操作执行变体的测试看起来非常像我们之前看到的invalid_message的测试,验证当提供一个格式良好的消息尝试执行一个不被任何给定角色/origin允许的操作时,操作方法不会执行。

测试起源于关系的应用程序端的交易过程稍微复杂一些,仅仅是因为到目前为止这些应用程序还没有进行重大的开发。为了测试这些过程,至少最初需要创建一个应用程序过程的简化模拟——在以后,当有相当完整和经过测试的应用程序时,最好实际运行它们的本地实例。工匠和中央办公室应用程序都需要一个模拟,并且需要以与网关服务守护程序类似的方式提供 CRUD 操作方法。工匠应用程序的模拟可能从这样的代码开始:

class ArtisanapplicationMock:

    # ... Properties and initialization would need to be fleshed 
    #   out, obviously...

    # CRUD-operation methods to implement
    def update_artisan(self, message:(DaemonMessage,)) -> (None,):
        # TODO: Implement this method
        pass

    def create_order(self, message:(DaemonMessage,)) -> (None,):
        # TODO: Implement this method
        pass

    def update_order(self, message:(DaemonMessage,)) -> (None,):
        # TODO: Implement this method
        pass

    def delete_order(self, message:(DaemonMessage,)) -> (None,):
        # TODO: Implement this method
        pass

    def update_product(self, message:(DaemonMessage,)) -> (None,):
        # TODO: Implement this method
        pass

网关服务的结构可以被部分重用,以提供将消息路由到其各自操作方法的方法:

    def _handle_message(self, message:(DaemonMessage,)) -> (None,):
        # - This method would look very much like its counterpart 
        #   in hms_Gateway.daemons.ArtisanGatewayDaemon
        # TODO: Implement this method
        pass

然而,与其有一个main循环不如有一个单一方法更好,该方法就像通过网关服务的main循环的单次通过。对于测试目的,这允许更严格地控制消息的处理,以便任意数量的测试消息可以作为测试过程的一部分发送。然后,可以调用ArtisanapplicationMock方法来读取和处理所有消息,这导致这些消息可以被测试。这个方法,handle_pending_messages,看起来仍然很像ArtisanGatewayDaemon.main,尽管:

def handle_pending_messages(self) -> (None,):
    # - Create a connection
    connection = pika.BlockingConnection(
        pika.ConnectionParameters(
            self.connection_params['host'],
            self.connection_params.get('port'),
            self.connection_params.get('path'),
        )
    )
    # - Create (or at least specify) a channel
    channel = connection.channel()
    # - Create or specify a queue
    channel.queue_declare(queue=self.queue_name)
    # - Get *all* pending messages, and execute against them
    polling = True
    while polling:
        try:
            # - Retrieve the next message from the queue, if 
            #   there is one, and handle it...
            method_frame, header, body = channel.basic_get(self.queue_name)
            if method_frame:
                # - Any actual message, valid or not, will 
                #   generate a method_frame
                message = DaemonMessage.from_message_json(
                    body.decode(), self.signing_key
                )
                # - We've received the message, and will 
                #   process it, so acknowledge it on basic 
                #   principle
                channel.basic_ack(method_frame.delivery_tag)
                self._handle_message(message)
            else:
                polling = False
        except InvalidMessageError as error:
            # - If message-generation fails (bad signature), 
            #   we still need to send an acknowledgement in order 
            #   to clear the message from the queue
            channel.basic_ack(method_frame.delivery_tag)

有了这个,以及中央办公室应用程序的相应模拟,通过网关服务传递到其他应用程序并进行更改的交易的测试过程将类似于更简单交易的测试过程,比如创建一个工匠:

  1. 为操作创建消息,带有适当的来源和数据

  2. 该消息被发送到网关服务

  3. 执行任何服务级数据更改的验证,可能需要延迟以确保已经有时间传递并执行消息

  4. 调用适当应用程序模拟类的handle_pending_messages方法来读取和处理传入的消息

  5. 执行预期结果的测试——为创建事务创建新的本地数据,为更新事务更改现有数据,为删除事务删除现有数据

整个过程——创建用于测试目的的模拟更复杂系统或对象的代码——称为模拟。模拟允许编写测试,而无需依赖实际(通常更复杂)的真实代码实现。

产品和订单的测试,在大部分情况下,可以遵循类似的模式。主要的区别当然在于正在创建和操作的对象类型,以及根据每个角色/操作组合的业务规则,各种角色被允许对这些对象做什么。可能需要定义额外的测试来专门针对某些操作——例如,工匠完成订单的一部分,这本质上只是一个更新操作。然而,这应该只会改变项目履行数据,而不是所有的数据。即便如此,这几乎肯定会遵循这里概述的类似的测试过程和结构。

演示服务

许多迭代开发过程的核心要求是代码的功能可以向利益相关者展示,以便他们有足够的信息来同意故事的需求已经得到满足,或者指出这些需求中的任何差距。演示服务对满足该要求提出了一些独特的挑战:

  • 一切发生的都是“幕后”不可见的。

  • 许多事情发生得如此迅速,以至于根本没有时间看到导致最终结果的中间步骤

  • 很可能不会有任何与用户界面相关的内容,或者即使有,也不会提供足够的可见性来充分展示这些过程的细节

有时,就像网关服务一样,还有一些外部系统——数据库、消息队列服务等,需要对正在运行的代码可用,以便演示过程实际上能够成功运行。演示准备需要考虑到这一点,并确保任何需要的外部服务的运行实例可用。在这种情况下,由于开发和测试已经依赖于这些相同的服务可用,这不是问题,只要可以从开发环境运行代码演示。

已经实施的过程测试可以执行,以证明代码的行为是可预测的,这是一个很好的演示项目,但它并不能解决我们最初的问题。展示各种过程内部运作的一个非常基本的方法是编写一个演示脚本,执行与最终代码中发生的相同任务,以任何逻辑或所需的顺序,但以用户可控的块,并在需要时显示相关数据。这是一种蛮力、基本的方法,但可以使过程中的步骤可见(解决第一个问题),并在用户说要执行每个步骤时执行(解决第二个问题)。实际上,它通过为特定目的创建用户界面来解决了前两个问题。尽管完整的演示脚本太长而无法在此重现,但它基本上看起来像过程测试:

#!/usr/bin/env python
"""
A (partial) script that runs through the various processes we need 
to demo for the Artisan Gateway service.
"""

# - Imports needed for the demo-script
import unittest
import os

from hms_core.co_objects import Artisan as COArtisan
from hms_core.messaging import DaemonMessage, RabbitMQSender, \
    MESSAGE_ORIGINS

各种项目的配置,比如演示过程将使用的数据存储或消息队列,需要在代码的这一点提供:

因为整个服务的单元测试位于一个包结构中(与真实代码的结构相同),可以导入整个单元测试套件,并编写一个函数按需执行它们:

from test_hms_Gateway import LocalSuite

def demoUnitTests():
    print(
        '+== Showing that unit-tests run without error '.ljust(79,'=') + '+'
    )
    results = unittest.TestResult()
    LocalSuite.run(results)
    print('+== Unit-tests run complete '.ljust(79,'=') + '+\n\n')

每个数据交易过程的演示也可以封装在演示模块中的单独函数中。除了显示演示运行时的信息和提示运行演示的人允许其继续的新代码之外,它们看起来很像相应的过程测试方法:

def demoArtisanCreateFromCO():
    print(
        '+== Central Office Creating Artisan '.ljust(79,'=') + '+'
    )

用于创建一个Artisan测试对象的代码几乎相同:

    parameters = {
        'contact_name':'contact-name',
        'contact_email':'no-one@me.co',
        'address':{
            'street_address':'street-address',
            'city':'city',
        },
        'queue_id':'bogus-queue-id',
        'signing_key':os.urandom(64),
    }
    new_artisan = COArtisan.from_data_dict(parameters)

由于演示需要显示new_artisan对象的初始状态,以便在传输其创建消息之前显示数据按预期持久化,因此需要对对象的data-dict进行一些简单的蛮力输出:

    initial_state = new_artisan.to_data_dict()
    print('| Initial state:'.ljust(79, ' ') + '|')
    for key in sorted(initial_state.keys()):
        print(
            (
                ('| +- %s ' % key).ljust(24, '.') + ' %s' % initial_state[key]
            )[0:78].ljust(79, ' ') + '|'
        )
    print('+' + '-'*78 + '+')

创建消息并发送消息几乎相同,除了用于标识发送消息的队列的queue_id。出于同样的原因,它也具有与相应过程测试相同的time.sleep延迟:

    sender = RabbitMQSender()
    # - Send the new-COArtisan message to the service
    message = DaemonMessage(
        operation='create', 
        origin=MESSAGE_ORIGINS.central_office,
        data={
            'target':'artisan',
            'properties':new_artisan.to_message_data(),
        },
        signing_key=parameters['signing_key']
    )
    sender.send_message(message, parameters['queue_id'])
    # - The message has been sent, but we have to wait for 
    #   a bit before it is received and acted upon before we 
    #   can verify the creation happened
    time.sleep(5)

结果的显示本质上与我们之前看到的显示initial_state的代码相同;它只是使用了检索到的数据库持久化实例的data-dict,而不是原始实例:

    verify_artisan = COArtisan.get(str(new_artisan.oid))[0]
    verify_state = new_artisan.to_data_dict()
    print('| Saved state:'.ljust(79, ' ') + '|')
    for key in sorted(verify_state.keys()):
        print(
            (
                ('| +- %s ' % key).ljust(24, '.') + ' %s' % verify_state[key]
            )[0:78].ljust(79, ' ') + '|'
        )
    print('+' + '='*78 + '+')

由于显示原始数据和持久化数据是一个逻辑分组,脚本在继续演示的下一步之前等待用户输入:

    print('\n')
    input('[Enter] to continue the demo')
    print('\n')

在此演示函数中设置剩余项目,并且可能需要所有其他演示函数,如果模块直接执行,则可以通过简单调用每个演示函数来执行整个演示脚本(if __name__ == '__main__'):

if __name__ == '__main__':
    demoArtisanCreateFromCO()

仅使用第一个演示方法的第一部分的输出就可以显示数据持久性是准确的:

接下来的这个第一个演示函数的步骤将类似:

  • 将执行与数据显示包装在变更之前和之后

  • 在适用时显示正在进行的数据更改,以便能够看到这些更改

  • 演示预期的失败案例,例如无效的消息数据或签名,以及任何基于角色的变体

过程测试方法正在证明(和执行)的内容与这些相同过程的演示几乎肯定会非常相似,测试方法将提供大部分所需的所有演示函数的代码。

打包和部署服务

由于hms_Gatewayhms_core项目各自都有自己的setup.py文件,因此打包和部署过程不需要比以下更复杂:

  • 执行每个setup.py以生成可安装的软件包

  • 将这些软件包文件移动到将运行网关服务的服务器

  • 使用以下代码进行安装:

  • pip install HMS-Core-0.1.dev0.tar.gz

  • pip install HMS-Gateway-0.1.dev0.tar.gz

  • 为新安装创建必要位置的配置文件

允许网关守护程序在系统启动时自动启动并在系统关闭时关闭所需的配置将根据目标机器的操作系统而变化(稍后会详细介绍)。

另一方面,如果需要一个单一软件包,那么需要在打包过程中进行src目录的整合,这需要作为打包过程的一部分进行。如果不能通过正常的setuptools.setup函数的参数的某种组合来实现,那么可以通过Makefile和对项目中已经存在的setup.py进行微小更改来完成。

在项目的主要源代码目录之外包含源代码的支持在作者的经验中,早期版本的 Python 和/或setuptools包存在零星问题。如果这些问题在当前版本中得到解决,那么可能可以使用setuptools.setuppackage_dir参数,可能结合setuptools.find_package函数,指示主setup函数在当前项目之外的其他包源树的位置。这里描述的Makefile方法不够优雅,可能会有其他(通常是次要的)问题,但只需基本的setup.py功能/要求就可以始终正常工作。

setup.py文件的相关更改很简单,只需要将hms_core软件包名称添加到要包含在分发中的软件包列表中:

# The actual setup function call:
setup(
    name='HMS-Artisan-Gateway',
    version='0.1.dev0',

    # ...

    packages=[
        'hms_Gateway',
        'hms_core',
    ],

    # ...

)

由于setup.py不关心它从哪里运行,一个简单的强制解决方案可以将所有相关源代码收集到一个单一位置作为Makefile目标的起点可能从这里开始:

full_package:
        # Create a temporary packaging directory to copy all the 
        # relevant files to
        mkdir -p /tmp/Gateway-packaging
        # Copy those files
        cp -R src/hms_Gateway /tmp/Gateway-packaging
        cp -R ../hms-core/src/hms_core /tmp/Gateway-packaging
        # - Change to the temporary packaging directory, execute setup.py
        cd /tmp/Gateway-packaging;python setup.py
        # - Move the resulting package to somewhere outside the 
        #       temporary packaging directory, after assuring that the 
        #       location exists
        mkdir -p ~/Desktop/HMS-Builds
        mv /tmp/Gateway-packaging/dist/* ~/Desktop/HMS-Builds
        # - Clean up the temporary directory
        rm -fR /tmp/Gateway-packaging

逐步来看,实际上目标正在做以下事情:

  • 创建临时构建目录

  • 将每个项目的整个软件包目录复制到该目录

  • 进入目录并执行典型的setup.py运行(使用修改后的setup.py文件)

  • 确保文件系统上存在一个目录,可以将最终软件包文件移动到该目录

  • 将新创建的软件包文件移动到该目录

  • 删除临时构建目录

合并Makefile/setup.py过程的最终输出将是一个单个的包文件,HMS-Gateway-0.1.dev0.tar.gz,其中包括hms_Gatewayhms_core包目录,可以通过pip install HMS-Gateway-0.1.dev0.tar.gz进行安装。

所有操作系统的共同考虑因素

无论网关服务守护进程在什么操作系统下运行,它都需要一个完整的配置文件,位于已知位置,存储服务启动时需要了解的所有设置。这个配置文件的基本 Linux 版本(位于目标机器上运行服务的/etc/hms/hms_Gateway.conf中)看起来非常像第十六章中使用的使用 RabbitMQ 实现消息队列部分的最基本示例,Artisan 网关服务

# HMS Artisan Gateway Service Configuration
# - Used by the hms_Gateway.daemons.ArtisanGatewayDaemon class
#   to launch an active instance of the service
logging:
  format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
  name: hms_Gateway
# If console-logging is desired, uncomment these lines
#  console:
#    level: info
  file:
    level: error
    logfile: "/var/log/hms/hms_Gateway.log"
queue:
  type: rabbit
  connection:
    host: rabbitmq.hms.com
    port: 5672
    path: /
  queue_name: "central-office"
# Signing-key should be generated and added to configuration 
# during installation. It should be a 64-character, 
# bytes-type-compatible string-value, and will likely need to be 
# explicitly quoted
signing_key: ""

这个配置文件故意不是打包过程的一部分——否则,每次安装更新时,都会有一些覆盖现有和运行配置的风险。一旦最终配置就位,它在任何正常情况下都不应该需要修改。在 Linux 版本的配置文件和在 Windows 服务器上使用的配置文件之间唯一的区别是日志文件路径(logging:file:logfile),它需要指向 Windows 文件系统路径。

我们将在 Windows 和 Linux 操作系统下检查的服务管理选项允许通过简单的命令行执行来启动服务守护进程。较旧的 Linux 服务管理可能需要一个独立的 Bash 或 Python 脚本,以在操作系统的核心功能和用户与系统的交互之间进行桥接。然而,随着这些更现代的选项的出现,我们可以以与在开发过程中进行测试时相同的方式在生产系统上启动服务守护进程,只需在hms_Gateway/daemons.py的末尾添加几行代码:

if __name__ == '__main__':
    daemon = ArtisanGatewayDaemon('/etc/hms/hms_Gateway.conf')
    daemon.start()

当 Python 模块直接由 Python 解释器执行时——例如python -m hms_Gateway.daemons,或者python /path/to/hms_Gateway/daemons.py——if __name__ == '__main__'条件将评估为True,并且该if语句内的代码将被执行。在这种情况下,它创建了一个ArtisanGatewayDaemon的实例,传递了硬编码的配置文件路径,然后调用daemon对象的start方法,启动服务。

Linux(systemd)执行

在一个相当新的 Linux 系统上,服务管理由另一个服务处理:systemd。需要配置systemd以便知道何时以及如何启动服务守护进程,如何关闭它,并如何重新启动它,以及一些其他信息,用于确定服务在系统引导过程中何时启动。网关服务的一个最基本的systemd配置文件起点如下:

[Unit]
Description = Artisan Gateway Service
After      = network-online.target 
[Service]
# - Start-up process
ExecStart   = python -m hms_Gateway.daemons
# - How to shut the service down
ExecStop    = pkill -f hms_Gateway.daemons
ExecRestart = pkill -f hms_Gateway.daemons;python -m hms_Gateway.daemons

# - If it stops unexpectedly, do we want it to restart?
Restart     = always

[Install]
# - This corresponds, at least roughly, to runlevel 3, after 
#   a complete system start
WantedBy    = multi-user.target

其中提到的关键字的角色如下:

  • 描述是服务的简单描述

  • After指示在启动服务守护进程之前应完全建立的操作状态目标——在这种情况下,由于网关服务需要网络访问,我们指示它应在网络在线目标完成后启动,期望在那时所有网络功能都将可用

  • ExecStart是一个可以由操作系统执行的命令,用于启动服务

  • ExecStop是一个用于停止服务的命令——在这种情况下,使用pkill操作系统实用程序来查找(-f)并杀死与hms_Gateway.daemons字符串匹配的任何进程

  • Restart允许systemd在服务意外死机时自动重新启动服务

  • WantedBy是一个操作系统状态指示器,在这种情况下,它定义了服务守护程序在何种情况下启动 - 当达到(标准)多用户可运行级别时,典型的命令行服务器系统

一旦这两个配置文件都就位,网关服务应该在系统启动后自动启动,在系统关闭时干净地关闭,并且可以使用以下标准命令手动启动、停止和重新启动:

  • systemctl start hms_Gateway

  • systemctl stop hms_Gateway

  • systemctl restart hms_Gateway

Windows(NSSM)执行

在 Windows 机器上运行网关服务需要一些中间件,以在即将执行的 Python 代码周围创建一个与服务兼容的包装器。其中一个更受欢迎和稳定的中间件选项是Non-Sucking Service ManagerNSSM)。NSSM 提供了一个 GUI,用于创建、安装和管理用各种语言编写的服务 - 一般来说,如果一个程序可以从命令行运行,NSSM 几乎肯定可以将其作为 Windows 服务运行。

NSSM 可能需要以管理员权限运行,但无论如何,它都是从命令行启动的 - C:\path\to\nssm.exe install启动 GUI,并且所有所需的设置都存在于其中一个选项卡下。应用程序选项卡定义了要执行的程序的路径(在我们的情况下是python.exe),以及所需的参数(要运行的 Python 脚本),以及服务名称,用于标识服务:

如果需要修改现有的 NSSM 管理的服务,可以通过执行 NSSM 程序并在命令中指定服务名称来访问该服务:例如C:\path\to\nssm.exe install hms_Gateway

详细选项卡允许提供显示名称和描述,在 Windows 服务管理界面中显示。它还允许控制启动类型:服务是自动启动还是在其他情况下启动:

一旦点击安装服务按钮,就完成了 - 新服务,由 NSSM 包装和管理,可在 Windows 服务管理员中使用!

此时,hms_sys的“功能基础”可能已经相当完整:已经考虑到了预期的所有数据流,如果业务规则规定的限制没有在业务逻辑中实现,至少有足够的支持来快速实现与之相关的决策。

尽管如此,我们实际上还没有关闭大部分迭代开始的故事,尽管回顾起来,这些故事的目标太宽泛,以至于没有 UI 开发就无法关闭。如果它们被分成两个(或更多)故事,每个故事集中在最终用户的目标和需求上,看起来基本相同:

  • 作为一名工匠经理,我需要能够在 GUI 中创建“工匠”对象,以便我可以快速轻松地管理工匠

  • 作为一名工匠经理,我需要能够在 GUI 中删除“工匠”对象,以便我可以快速轻松地管理工匠

每个故事都会有一个相应的故事,更侧重于确保会有一些代码,一些与 GUI 相关的故事可以从中开始,并建立在其上。它们可能看起来像这样:

  • As

    • 作为 UI 开发人员,我需要一种机制来发送创建工匠消息到网关服务,以便我可以创建一个 UI 来执行该过程
  • 作为 UI 开发人员,我需要一种机制来发送删除工匠消息到网关服务,以便我可以创建一个 UI 来执行该过程

或者,如果每个原始故事的开发过程都采取了确保每个最终用户操作的整个过程,从 GUI 到服务到数据库到(如果适用)另一个用户应用程序,都有与之相关的任务,那么原始编写的故事就可以完全完成。

在现实世界的情况下,这种差距本来应该在故事整理的过程中得到解决,甚至在它们被放入活跃迭代之前就得到解决。故事整理是开发团队的活动,其中对传入的故事进行审查、完善,并在必要时与利益相关者一起进行调整,以确保可以完成。这个过程的一部分涉及审查故事及其相关任务,以确保故事完成所需的一切都得到了考虑。这样的审查几乎肯定会揭示,要么原始故事在这里最初的呈现中有代表故事所需的一切的任务,要么将原始故事分成 UI 和机制故事是必要的。

尽管如此,原始集合中的一些故事似乎可以关闭,除非在演示和审查过程中出现调整:

  • 作为 Artisan,我需要能够将数据更改发送到 Artisan Gateway,以便根据需要传播和执行这些更改

  • 作为中央办公室用户,我需要能够将数据更改发送到 Artisan Gateway,以便根据需要传播和执行这些更改

  • 作为向 Artisan Gateway 服务发送消息的任何用户,我需要这些消息被签名,以便在执行之前进行验证

hms_sys 的开发未来可能会走向何方

hms_sys仍然需要大量工作才能真正完成,但所有需要暴露的设计、开发和流程原则到目前为止都已经完成,所以这感觉是一个很好的时机,可以离开这个项目,继续其他事情。然而,在继续之前,还有一些容易识别的项目可以被拾起并进行工作。

代码审查、重构和清理

目前的代码中至少有几个项目可以进行审查和纠正。

到目前为止,还没有要求任何请求-响应过程,只需简单返回任何数据对象。然而,有一些方法被存根化,以解决这些潜在需求(在ArtisanGatewayDaemon中的各种response_{object}方法),即使这些需求从未出现过。虽然保留它们不会有害,但最终会需要测试用例类和/或测试方法,这些类和方法是测试政策所要求的,但实际上并没有任何作用。被测试的方法什么也不做,也不会在可预见的未来做任何事情。至少,这些方法及与之相关的任何测试可能应该被注释掉,甚至可以完全删除,以保持代码更清晰。

由于在 Artisan 和 Central Office 上为订单创建了不同的类,花一些时间来筛选它们的共同功能和接口,并在hms_core中重新定义BaseOrder类也会使代码更加清晰。这也将需要重新设计相关的单元测试,并可能(可能是微不足道地)涉及使用当前Order类的其他类。

hms_core中存在中央办公室类,虽然当时这是可以理解的决定,但从长远来看可能会对数据完整性造成一定风险:作为hms_core的成员,它们目前作为 Artisan 应用程序的一部分分发(依赖于hms_core),并且可能会被一个不满意的 Artisan 使用。尽管风险可能微不足道,但这绝对不是不可能的情况,Artisan 应用程序没有任何理由拥有只有中央办公室员工才能使用的代码。重新组织这些项目到一个单独的项目/模块中,或者修改构建/打包过程以主动从 Artisan 应用程序的代码库中删除该模块,感觉是一个不错的主意,消除了关于部署代码给不应该使用它的用户的任何担忧。

可能需要对daemons.py模块的位置和使用进行类似的重新组织努力,这时我们并没有真正为最终用户应用程序设计,只是一系列在基本层面上实现的功能要求的集合,因此对应用程序本身的功能没有真正的感觉。设计可能涉及本地服务,即使只在主应用程序活动时运行,这种情况下将daemons.py保留在hms_core命名空间是有意义的。另一方面,如果最终用户应用程序不使用这样的服务,那么没有理由将相关代码部署到任何一个最终用户应用程序中,并将其移入自己的可部署包中,或者移入一个独立但依赖的项目也不是一个坏主意。

至少有几个单元测试(测试各种to_data_dict方法的测试可能是最明显的)由于测试参数在深度嵌套的循环中的使用方式,随着时间的推移,执行时间会越来越长。目前,有多达十几个值变化被测试(或可能被测试),每个变化只使用了少数值。对于每个变化,有三个值,和 12 个要测试的变化,每个变化都在自己的循环中,这就是 3¹²——超过 50 万个——断言将在每次执行该测试方法时执行。这需要时间来执行。重新设计各种嵌套循环测试方法,使每个值变体都单独测试,取消循环嵌套,将显著加快测试执行速度——现在只需要 36(3 × 12)个断言,而不是现在需要的 50 万个。这样做的代价是测试代码会变得更长,并且可能(稍微)更难以维护,但从长远来看节省的时间将是值得的。

开发 UI

Python 应用程序有数十种 GUI 选项可用,即使列表仅限于可在多个操作系统/平台上使用的选项,Python 网站上维护了一个列表(wiki.python.org/moin/GuiProgramming)。最常用的都具有丰富的功能,每个都可以写一整本书。值得注意的 GUI 框架和工具包包括以下内容:

  • Tkinter:作为 Python 安装的一部分分发

  • PyGObject:用于许多 Linux/Gnome 应用程序的 GUI,与 GnomePython 和 PyGTK 相关(pygobject.readthedocs.io/en/latest/

  • Kivy:这包括对 Android 和 iOS(iPhone)应用程序的支持(kivy.org/

Tkinter是 Python 应用程序 GUI 的事实标准,并且已经随 Python 发行版一起发布了很长时间。虽然它提供的 GUI 元素在很多方面都相当基本,但它提供了足够多的元素来满足各种应用程序需求。作为较成熟的选项之一,有大量的文档可用(参见wiki.python.org/moin/TkInter),而且它非常稳定。还有许多扩展包可用,可能满足基本 Tkinter 安装无法满足的需求,包括Python Mega****widgetsPMWpmw.sourceforge.net/doc/)。虽然 Tkinter GUI 可能不是世界上最吸引人的,但它们的外观与底层操作系统的 GUI 引擎紧密绑定,具有所有相关的变化,它们是极其实用的。

Tkinter 没有复杂的依赖关系,使其非常便携;给定的 Tkinter GUI 将在任何操作系统上都可以正常运行,基于检测存在的操作系统进行的简单调整通常并不困难,尽管可能需要提前进行重大规划。

如果你曾经在具有 Gnome 前端的 Linux 系统上工作过,很可能你已经接触过基于PyGObject的 GUI,无论你是否知道。尽管它是开源 Gnome 项目的一部分,因此可能更专注于满足各种 Linux 系统的需求,但 PyGObject 在 Windows 和 Macintosh 系统上也是一个可行的选择。与 Python 可用的大多数 GUI 框架一样,PyGObject 确实涉及至少一些额外的软件安装,即使它们不是直接可见的,但这些应该由 PyGObject 本身的安装过程来管理。PyGObject 假定至少对部件外观有一定控制,从操作系统的底层 GUI 引擎中夺取这种控制,以提供更吸引人的外观。

Kivy是一个流行的选择,通常被引用为需要移动技术支持的 Python 应用程序的首选 GUI 框架(Android 和 iOS 应用程序)。根据他们的画廊页面中的几个条目来判断(kivy.org/#gallery),它可以提供非常干净和吸引人的 GUI。Kivy 使用自己的设计语言来定义 GUI 的布局和元素外观。通过 Kivy 实现对移动应用程序的支持是通过将完整的 Python 安装与每个 Android apk或 iOS app文件捆绑在一起来实现的。

另一个选择,虽然一开始听起来可能有点奇怪,但是可以将 Artisan 和 Central Office 应用程序实现为本地 Web 服务器,并使用 HTML、CSS 和 JavaScript 来创建 GUI。这并不像听起来那么牵强:Python 在http.server模块中包含各种 Web 服务器类(docs.python.org/3.6/library/http.server.html),即使它们中没有一个可以直接使用,也可以扩展它们以提供所缺少的功能。虽然提供的服务器可能不像专用 Web 服务器(Apache 或 IIS)那样强大或功能丰富,但它们实际上并不需要,因为在任何给定时间只有少数用户访问它。

订单履行和航运 API

Artisan 执行的订单履行过程中涉及的基本数据更改是相当详细和理解的,但肯定还有改进的空间。一个非常好的功能是与用于交付这些已履行订单物品的各种航运公司的在线 API 集成。这种集成,取决于其周围的所有要求的形状,可能本身就是一个重大的开发工作,并且可能包括以下内容:

  • 允许工艺品用户在交易过程中提供包裹或运输 ID,用于单个和多个商品的履行。

  • 向客户发送确认邮件(如果 API 不能自行处理)并提供发货跟踪信息

  • 向中央办公室发送某种通知,表明订单商品已经发货,这将触发手动或自动支付工艺品制作者的流程

除此之外,还需要为各种承运人 API 定义(因为不太可能有两个 API 使用完全相同的请求结构),并为它们制定测试策略和实施,如果 API 本身没有提供任何测试工具,很可能需要进行广泛的模拟。

总结

服务的测试,特别是以可重复的方式进行的测试,可以用作持续的回归测试,具有自己的特殊挑战,但没有一个是不可克服的。这里提出的方法是一个坚实的起点,并且可以根据需要详细阐述,以满足几乎任何测试要求。也就是说,这些方法是相当完整的,如果出现新的测试要求,无论是通过发现和修复错误,还是出现新的功能要求需要在测试中反映,都可以很容易地管理/维护。

第十九章:Python 中的多处理和 HPC

高性能计算HPC)简单来说,就是在应用程序执行过程中使用并行处理来将计算负载分布到多个处理器上,通常跨越多台机器。有几种 MPC 策略可供选择,从利用本地多处理器计算机架构的定制应用程序到专用的 MPC 系统,如 Hadoop 或 Apache Spark。

在本章中,我们将探索并应用不同的 Python 功能,从针对数据集中的元素逐个执行基线算法开始,并研究以下主题:

  • 构建利用本地可用的多处理器架构的并行处理方法,并使用 Python 的multiprocessing模块来限制这些方法

  • 定义并实施一种跨多台机器的方法来并行化基线串行过程,从根本上创建一个基本的计算集群

  • 探索如何在专用的、行业标准的 HPC 集群中使用 Python 代码

需要考虑的共同因素

以并行方式执行的代码在开发过程中还有一些额外的因素需要考虑。第一个考虑因素是程序的输入。如果针对任何一组数据的主要操作被包装在一个函数或方法中,那么数据就被传递给函数。函数执行其需要做的任何事情,然后控制权被交还给调用函数的代码。在并行处理的情况下,同一个函数可能会被调用任意次数,使用不同的数据,控制权以不同于它们开始执行的顺序返回给调用代码。随着数据集变得更大,或者提供更多的处理能力来并行化函数,就必须对调用该函数的方式以及何时(在什么情况下)进行更多的控制,以减少或消除这种可能性。还可能需要控制在任何给定时间内正在处理的数据量,即使只是为了避免使代码运行的机器不堪重负。

这种情况的一个例子似乎是有必要的。考虑对同一个函数的三次调用,都在几毫秒内完成,其中第一次和第三次调用在一秒内完成,但是第二次调用由于某种原因需要十秒钟。对该函数的调用顺序将如下:

  • 第一通话

  • 第二通话

  • 第三通话

然而,它们返回的顺序如下:

  • 第一通话(一秒钟后)

  • 第三通话(同样是一秒钟后)

  • 第二通话(十秒钟后)

潜在的问题是,如果期望函数的返回按照它们被调用的顺序返回,即使只是隐含地如此,对第三通话需要第二通话的依赖,那么期望的数据将不会出现,第三通话将以一种非常令人困惑的方式失败。

这些对输入数据的控制,以及作为结果的并行化过程何时、如何以及多频率执行的控制,有几种名称,但我们将在这里使用术语“编排”。编排可以采用多种形式,从对小数据集的简单循环,为数据集中的每个元素启动并行进程,到大规模的、基于消息的过程请求-响应机制。

还必须对一组并行进程的输出进行详细考虑。Python 中可用的一些并行化方法根本不允许将函数调用的结果直接返回给调用代码(至少目前还不允许)。其他方法可能允许,但只有在活动进程完成并且代码主动附加到该进程时才允许,阻塞对任何其他进程的访问,直到目标进程完成为止。处理输出的更常见策略之一是创建要并行化的进程,使它们成为“发射并忘记”调用——调用函数处理数据的实际处理,并将结果发送到某个共同的目的地。目的地可以包括多进程感知队列(由多进程模块提供的Queue类)、将数据写入文件、将结果存储到数据库,或者发送某种异步消息到某个地方,该地方独立于这些进程的编排或执行存储结果。这些进程可能有几种不同的术语,但在这里我们将使用“分派”进行探索。分派也可能在一定程度上受到正在进行的编排进程的控制,或者根据进程的复杂性可能有它们自己的独立编排。

这些过程本身,以及它们的结果的任何后续使用,也需要额外考虑,至少在潜在上是这样。由于最终的目标是让一些独立的进程同时处理数据集的多个元素,而且没有确切的方法来预测任何单个进程可能需要多长时间来完成,因此有很大可能会出现两个或更多个进程以不同的速度解决和分派它们的数据。即使相关数据元素的预期运行时间相同,这也可能是真实的。因此,对于任何给定的元素处理顺序,不能保证结果将以启动对这些元素的进程的相同顺序进行分派。这在分布式处理架构中尤其如此,因为实际执行工作的个别机器可能有其他程序在消耗它们可用的 CPU 周期、内存或其他运行进程所需的资源。

尽可能保持进程和它们的结果的独立性,将在很大程度上有助于减轻特定的担忧。独立的进程不会与或依赖于任何其他进程进行交互,消除了任何跨进程冲突的潜力,而独立的分派则消除了跨结果数据污染的可能性。如果需要具有依赖关系的进程,仍然可以实现,但可能需要额外的工作(很可能是以分派为重点的编排)来防止并行进程的结果可用时产生冲突。

一个简单但昂贵的算法

首先,我们需要解决一个问题。为了保持对并行处理的各种机制的关注,该问题的领域需要容易理解。同时,它需要允许处理任意大的数据集,最好是具有不可预测的数据集中每个元素的运行时间,并且结果是不可预测的。为此,我们要解决的问题是确定某个整数值范围内每个数字的所有因子。也就是说,对于任何给定的正整数值x,我们希望能够计算并返回x能够被整除的所有整数值的列表。计算并返回单个数字的因子列表(factors_of)的函数相对简单:

def factors_of(number:(int)) -> (list):
    """
Returns a list of factors of the provided number: 
All integer-values (x) between 2 and number/2 where number % x == 0
"""
    if type(number) != int:
        raise TypeError(
            'factors_of expects a positive integer value, but was passed '
            '"%s" (%s)' % (number, type(number).__name__)
        )
    if number < 1:
        raise ValueError(
            'factors_of expects a positive integer value, but was passed '
            '"%s" (%s)' % (number, type(number).__name__)
        )
    return [
        x for x in range(2, int(number/2) + 1)
        if number % x == 0
    ]

虽然这个函数本身只处理一个数字,但调用它多次以处理任何一组数字的过程可以扩展到任意数量的数字,从而在需要时为我们提供任意大的数据集能力。运行时间有些可预测——应该可以对各种范围内的数字得到合理的运行时间估计,尽管它们会根据数字的大小而变化。如果需要一个真正不可预测的运行时间模拟,我们可以预先生成要处理的数字列表,然后逐个随机选择它们。最后,逐个数字的结果是不可预测的。

一些测试设置

可能有用的是捕获一组样本数字的运行时信息,比如从10,000,00010,001,000,捕获总运行时间和每个数字的平均时间。可以轻松组装一个简单的脚本(serial_baseline.py),对每个数字依次执行factors_of函数(串行):

#!/usr/bin/env python
"""serial_baseline.py

Getting data that we can use to estimate how long a factor_of call will 
take for some sample "large" numbers.
"""

print(
    '# Execution of %s, using all of one CPU\'s capacity' % __file__
)
print('='*80)
print()

import time
from factors import factors_of

# - The number we'll start with
range_start = 10000000
# - The number of calls we'll make to the function
range_length = 1000
# - The number that we'll end with - range *stops* at the number 
#   specified without including it in the value-set
range_end = range_start + range_length + 1
# - Keep track of the time that the process starts
start_time = time.time()
# - Execute the function-call the requisite number of times
for number in range(range_start, range_end):
    factors_of(number)
# - Determine the total length of time the process took to execute
run_time = time.time() - start_time
# - Show the relevant data
print(
    '%d iterations executed in %0.6f seconds' % 
    (range_length, run_time)
)
print(
    'Average time per iteration was %0.6f seconds' %
    (run_time/range_length)
)

假设参与计算过程的所有机器在处理能力方面基本相同,那么这个脚本的输出可以合理估计执行factors_of计算对接近10,000,000值的数字所需的时间。最初在一台性能强大的新笔记本电脑上测试时,输出如下:

为了后续的测试目的,我们还将创建一个常量测试数字列表(TEST_NUMBERS),选择以提供相当广泛的处理时间范围。

TEST_NUMBERS = [
    11,         # Should process very quickly
    16,         # Also quick, and has factors
    101,        # Slower, but still quick
    102,        # Slower, but still quick
    1001,       # Slower still, but still fairly quick
    1000001,    # Visibly longer processing time
    1000000001, # Quite a while
]

选择这七个数字是为了提供一系列较大和较小数字,以及调用factors_of函数的各个运行时间。由于只有七个数字,任何使用它们的测试运行(而不是前面代码中使用的 1,000 个数字)将需要较少的时间来执行,同时仍然可以在需要时提供一些关于各个运行时间的见解。

本地并行处理

本地处理并行化的主要重点将放在multiprocessing模块上。还有一些其他模块可能可用于一些并行化工作(这些将在后面讨论),但multiprocessing提供了最好的灵活性和能力组合,同时对来自 Python 解释器或其他操作系统级干扰的限制最小。

正如从模块的名称可以预期的那样,multiprocessing提供了一个类(Process),它便于创建子进程。它还提供了许多其他类,可以用来使与子进程的工作更容易,包括Queue(一个多进程感知的队列实现,可用作数据目的地),以及ValueArray,它们允许单个和多个值(相同类型的)分别存储在跨多个进程共享的内存空间中。

Process对象的完整生命周期包括以下步骤:

  1. 创建Process对象,定义启动时将执行的函数或方法,以及应传递给它的任何参数

  2. 启动Process,开始执行

  3. 加入Process,等待进程完成,阻止调用进程的进一步执行,直到它完成

为了比较,创建了一个基于多进程的基准定时测试脚本,相当于serial_baseline.py脚本。这两个脚本之间的显着差异始于导入多进程模块:

#!/usr/bin/env python
"""multiprocessing_baseline.py

Getting data that we can use to estimate how long a factor_of call will 
take for some sample "large" numbers.
"""

print(
    '# Execution of %s, using all available CPU capacity (%d)' % 
    (__file__, multiprocessing.cpu_count())
)
print('='*80)

import multiprocessing
import time

因为正在创建多个进程,并且因为它们需要在全部创建后进行轮询,所以我们创建了一个processes列表,并在创建每个新的process时将其附加。在创建进程对象时,我们还指定了一个name,这对功能没有影响,但在测试中如果需要显示,这会使事情变得更加方便:

# - Keep track of the processes
processes = []
# - Create and start all the processes needed
for number in range(range_start, range_end):
    process = multiprocessing.Process(
        name='factors_of-%d' % number,
        target=factors_of,
        args=(number,),
    )
    processes.append(process)
    process.start()

一旦为每个process调用process.start(),它就会在后台启动和运行,直到完成。尽管各个进程在完成后不会终止:但是当调用process.join()并且已加入的进程已完成时,才会发生这种情况。由于我们希望所有进程在加入任何一个进程之前开始执行(这会阻止循环的继续),因此我们单独处理所有的加入-这也给了已启动的每个进程一些时间运行直到完成:

# - Iterate over the entire process-set, and use join() to connect 
#   and wait for them
for process in processes:
    process.join()

在与之前的脚本在同一台机器上运行,并且在后台运行相同的程序的情况下,此测试脚本的输出显示了原始运行时间的显着改善:

这是一个改进,即使没有任何驱动它的编排,除了底层操作系统管理的任何东西(它只是将相同的 1,000 个数字传递给调用factors_of函数的Process实例):总运行时间约为串行处理所需时间的 55%。

为什么只有 55%?为什么不是 25%,或者至少接近 25%?没有一种编排来控制运行多少进程,这创建了 1,000 个进程,并且在操作系统级别产生了所有相关的开销,并且必须依次给它们每个人一些时间,因此发生了很多上下文切换。更仔细调整的编排过程应该能够减少运行时间,但可能不会减少太多。

朝着有用的多进程解决方案迈出的下一步将是实际能够检索子进程操作的结果。为了提供一些实际发生的可见性,我们还将通过整个过程打印几个项目。我们还将随机排列测试数字的顺序,以便每次运行都以不同的顺序执行它们,这将(通常)显示进程是如何交织在一起的:

#!/usr/bin/env python
"""multiprocessing_tests.py
Also prints several bits of information as it runs, but those 
can be removed once their purpose has been served
"""

import multiprocessing
import random
# - If we want to simulate longer run-times later for some reason, 
#   this will need to be uncommented
# import time

from datetime import datetime

我们将使用之前设置的TEST_NUMBERS,并将它们随机排列成一个列表:

# - Use the small, fixed set of numbers to test with:
from factors import TEST_NUMBERS
# - Randomize the sequence of numbers
TEST_NUMBERS.sort(key=lambda i:random.randrange(1,1000000))

为了实际捕获结果,我们需要一个可以在计算时发送它们的地方:multiprocessing.Queue的一个实例:

queue = multiprocessing.Queue()

如前所述,结果queue对象存储在顶级进程(multiprocessing_tests.py脚本)和所有子Process对象的进程都可以访问的内存中。

由于我们将把结果存储在queue对象中,因此需要修改factors_of函数来处理这一点。我们还将添加一些“print()”调用来显示函数何时被调用以及何时完成其工作:

def factors_of(number:(int)) -> (list):
    """
Returns a list of factors of the provided number: 
All integer-values (x) between 2 and number/2 where number % x == 0
"""
    print(
        '==> [%s] factors_of(%d) called' % 
        (datetime.now().strftime('%H:%M:%S.%f'), number)
    )

类型和值检查保持不变:

    if type(number) != int:
        raise TypeError(
            'factors_of expects a positive integer value, but was passed '
            '"%s" (%s)' % (number, type(number).__name__)
        )
    if number < 1:
        raise ValueError(
            'factors_of expects a positive integer value, but was passed '
            '"%s" (%s)' % (number, type(number).__name__)
        )
# - If we want to simulate longer run-times later for some reason, 
#   this will need to be uncommented
#    time.sleep(10)

number的因子的实际计算保持不变,尽管我们将结果分配给一个变量,而不是返回它们,以便我们可以在函数完成时以不同的方式处理它们:

    factors = [
            x for x in range(2, int(number/2) + 1)
            if number % x == 0
        ]
    print(
        '<== [%s] factors_of(%d) complete' % 
        (datetime.now().strftime('%H:%M:%S.%f'), number)
    )

我们将使用queue.put()而不是返回计算的值,将它们添加到queue正在跟踪的结果中。queue对象并不特别关心添加到其中的数据是什么-任何对象都将被接受-但是为了保持一致性,并确保每个发送回来的结果都具有该数字和该数字的因子,我们将put一个具有这两个值的tuple

    queue.put((number, factors))

准备好所有这些后,我们可以开始测试脚本的主体:

print(
    '# Execution of %s, using all available CPU capacity (%d)' % 
    (__file__, multiprocessing.cpu_count())
)
print('='*80)
print()

我们需要跟踪开始时间以便稍后计算运行时间:

start_time = time.time()

创建和启动调用factors_of的进程与之前使用的基本结构相同:

processes = []
for number in TEST_NUMBERS:
    # - Thread has been created, but not started yet
    process = multiprocessing.Process(
        name='factors_of-%d' % number,
        target=factors_of,
        args=(number,),
    )
    # - Keeping track of the individual threads
    processes.append(process)
    # - Starting the current thread
    process.start()

此时,我们有一组已启动但可能不完整的子进程在后台运行。如果最初创建和启动的几个进程是针对较小的数字,它们可能已经完成,只是在等待join()来完成它们的执行并终止。另一方面,如果较大的数字是第一个被执行的,那么第一个子进程可能会在一段时间内继续运行,而其他具有较短单独运行时间的进程可能会在后台空转,等待join()。无论如何,我们可以简单地迭代进程项列表,并依次join()每个进程,直到它们全部完成:

for process in processes:
    print(
        '*** [%s] Joining %s process' % 
        (datetime.now().strftime('%H:%M:%S.%f'), process.name)
    )
    process.join()

一旦所有的join()调用都完成了,queue将会以任意顺序包含所有数字的结果。子进程的繁重工作已经全部完成,所以我们可以计算最终的运行时间并显示相关信息:

# - Determine the total length of time the process took to execute
run_time = time.time() - start_time
# - Show the relevant data
print('='*80)
print(
    '%d factor_of iterations executed in %0.6f seconds' % 
    (len(TEST_NUMBERS), run_time)
)
print(
    'Average time per iteration was %0.6f seconds' % 
    (run_time/len(TEST_NUMBERS))
)

实际访问结果,本例中仅用于显示目的,需要调用队列对象的get方法——每次get调用都会获取并移除队列中之前放入的一个项目,现在我们可以简单地打印queue.get()直到queue为空为止:

print('='*80)
print('results:')
while not queue.empty():
    print(queue.get())

在测试运行结果中有几个值得注意的项目,如下图所示:

所有以==>开头的行显示了在运行过程中factors_of函数的调用发生的位置。毫不奇怪,它们都在进程的开始附近。以***开头的行显示了进程的加入位置——其中一个发生在Process创建事件的中间。以<==开头的行显示了factors_of的调用完成位置,之后它们保持空闲状态,直到对应的process.join()被调用。

根据对factors_of的调用,测试数字的随机序列是11, 101, 102, 1000000001, 16, 10000011001。完成的调用序列是 11, 101, 102, 16, 1001, 1000001100000000——一个略有不同的序列,joins序列(因此最终结果的序列)也略有不同。所有这些都证实了各个进程独立于主进程(for number in TEST_NUMBERS循环)开始、执行和完成。

有了Queue实例,并建立了一种访问子进程结果的方式,这就是基本的本地多进程并行化所需的一切。如果有功能需求,还有一些可以调整或增强的地方:

  • 如果需要限制活跃子进程的数量,或者对它们的创建、启动和加入进行更精细的控制,可以构建一个更结构化的编排器:

  • 允许的进程数量可以根据机器上可用的 CPU 数量进行限制,可以使用multiprocessing.cpu_count()来获取。

  • 无论进程数量是如何确定的,限制活跃进程的数量可以通过多种方式进行管理,包括使用一个Queue来处理挂起的请求,另一个用于结果,第三个用于准备加入的请求。覆盖每个Queue对象的put方法,以便检查其他队列的状态,并在这些其他队列中触发适当的操作/代码,可以让单个队列控制整个过程。

  • 编排功能本身可以包装在一个Process中,与分发子进程数据后可能需要的任何数据处理也可以包装在Process中。

  • 多进程模块还提供了其他对象类型,可能对某些多进程场景有用,包括以下内容:

  • multiprocessing.pool.Pool类——提供/控制一组工作进程的对象,可以向其提交作业,支持异步结果、超时和回调等功能

  • 提供多种管理器对象选项,可以在不同进程之间共享数据,包括在不同机器上运行的进程之间通过网络共享

线程

Python 还有另一个本地并行化库——thread。它提供的thread对象的创建和使用方式与multiprocessing.Process对象的方式非常相似,但基于线程的进程在与父进程相同的内存空间中运行,而Process对象在启动时实际上会创建一个新的 Python 解释器实例(具有与父 Python 解释器的一些连接能力)。

因为线程在同一个解释器和内存空间中运行,它们无法像“进程”一样访问多个处理器。

线程对机器上多个 CPU 的访问是由用于运行代码的 Python 解释器的功能决定的。随 Python 一起提供的标准解释器(CPython)和另一种选择的 PyPy 解释器都共享这一限制。IronPython 是在.NET 框架下运行的解释器,而 Jython 在 Java 运行时环境中运行,它们没有这种限制。

基于线程的并行化也更有可能遇到与 Python 的全局解释器锁(GIL)冲突。GIL 积极地阻止多个线程同时执行或更改相同的 Python 字节码。除了一些潜在的长时间运行的进程,这些进程发生在 GIL 的控制之外——如 I/O、网络、一些图像处理功能以及各种库,如 NumPy——除了这些例外,任何大部分执行时间用于解释或操作 Python 字节码的多线程 Python 程序最终都会遇到 GIL 瓶颈,从而失去其并行化。

有关 GIL 的更多信息,为什么存在,它的作用等等,可以在 Python 维基上找到wiki.python.org/moin/GlobalInterpreterLock

跨多台机器并行化

另一种常见的并行化策略是将计算过程的工作负载分布到多台机器(物理或虚拟)上。在本地并行化受到限制的情况下,最终受限于单台机器上的 CPU 数量、核心数量或两者的组合,机器级并行化受限于可以用于解决问题的机器数量。在当今这个时代,有大量的虚拟机可以在公共云和私人数据中心中提供,相对容易地将可用机器的数量扩展到与问题的计算需求相匹配的数量。

这种类型的横向可扩展解决方案的基本设计比本地解决方案的设计更复杂——它必须完成相同的任务,但要分离执行这些任务的能力,以便它们可以在任意数量的机器上使用,并提供执行进程和接受远程任务完成时的结果的机制。为了具有合理的容错能力,还需要更多地了解远程进程机器的状态,并且这些机器必须主动向中央控制器发送通知,以防发生会干扰它们工作能力的事件。典型的逻辑架构在高层次上看起来是这样的:

在这里:

  • 编排器是在一台机器上运行的进程,负责获取进程数据集的部分,并将其发送给下一个可用的工作节点。

  • 它还跟踪可用的工作节点,可能还跟踪每个工作节点的容量。

  • 为了实现这一点,Orchestrator 必须能够注册和注销 Worker 节点。

  • Orchestrator 可能还需要跟踪每个 Worker 节点的一般健康/可用性,并能够将任务与这些节点关联起来——如果一个节点变得不可用,并且仍有待处理的任务,那么它可以重新分配这些任务给其他可用的 Worker 节点。

  • 每个 Worker 节点是在单独的机器上运行的进程,当运行时,它接受传入消息项中的进程指令,执行生成结果所需的进程,并在完成时向 Dispatcher 发送结果消息。

  • 每个 Worker 节点还必须在变为可用时向 Orchestrator 宣布,以便注册,并在正常关闭时通知 Orchestrator,以便相应地注销它。

  • 如果由于错误而无法处理传入消息,Worker 还应该能够将该信息传回 Orchestrator,使其在可能时将任务重新分配给另一个 Worker。

  • Dispatcher 是在一台机器上运行的进程,负责接受结果消息数据,并根据需要执行相应的操作——将其存储在数据库中,写入文件等。Dispatcher 可以是同一台机器,甚至是 Orchestrator 的同一进程——只要处理与调度相关的消息项得到适当处理,而不会拖累编排过程,它在哪里都可以。

这种系统的基本结构可以使用已经在第十六章中展示的代码来实现,工匠网关服务

  • Orchestrator 和 Worker 节点可以被实现为类似于ArtisanGatewayDaemon的守护进程。如果确定 Dispatcher 需要独立,它也可以是类似的守护进程。

  • 它们之间的消息传递可以使用DaemonMessage对象的变体来处理,提供相同的签名消息安全性,通过 RabbitMQ 消息系统传输。

  • 该消息传输过程可以利用已经定义的RabbitMQSender类(也来自第十六章,工匠网关服务)。

这种方法的完整实现超出了本书的范围,但它的关键方面可以被详细检查,以便读者如果愿意的话可以编写实现。

共同功能

现有的DaemonMessage类需要被修改或重写,以接受 Orchestrator、Worker 和 Dispatcher 级别的不同操作,创建适用于每个级别的新的namedtuple常量。最初,Worker 节点只关心接受对其factors_of方法的调用,其允许的操作将反映这一点:

WORKER_OPERATIONS = namedtuple(
    'WORKER_OPERATIONS', ['factors_of',]
)
(
    factors_of='factors_of',
)

操作属性的 setter 方法对应的更改可以使用适当的namedtuple常量来控制接受的值(例如,以某种方式用WORKER_OPERATIONS替换_OPERATIONS,以适用于 Worker 节点的实现):

    def _set_operation(self, value:str) -> None:
# - Other operations would need to be added 
        if not value in _OPERATIONS:
            raise ValueError(
                '%s.operation expects a string value (one of '
                '"%s"), but was passed "%s" (%s)' % 
                (
                    self.__class__.__name__, 
                    '", "'.join(_OPERATIONS._fields), 
                    value, type(value).__name__
                )
            )
        self._operation = value

同样,这三个组件可能需要了解所有可能的origin值,以便能够适当地分配消息来源:

MESSAGE_ORIGINS = namedtuple(
    'MESSAGE_ORIGINS', ['orchestrator', 'worker', 'dispatcher']
)
(
    orchestrator='orchestrator',
    worker='worker',
    dispatcher='dispatcher',
)

任何单个守护进程的main方法基本上与ArtisanGatewayDaemon的实现方式保持不变。

在这种方法中,每个守护进程类(Worker 节点、Orchestrator 和 Dispatcher)只有少数几个类成员的不同变体,但由于它们的独特性,值得注意。大部分差异在于每个守护进程类的_handle_message方法中,每个都必须实现自己的实例方法,以将其映射到的操作。

Worker 节点

在前一节为假设的工作节点守护进程定义的所有操作都必须在类的_handle_message方法中处理——起初,这只是factors_of方法:

    def _handle_message(self, message:(DaemonMessage,)) -> None:
        self.info(
            '%s._handle_message called:' % self.__class__.__name__
        )
        target = message.data.get('target')
        self.debug('+- target ....... (%s) %s' % (
            type(target).__name__, target)
        )
        self.debug('+- operation .... (%s) %s' % (
            type(message.operation).__name__, message.operation)
        )
        if message.operation == WORKER_OPERATIONS.factors_of:
            self.factors_of(message)
        else:
            raise RuntimeError(
                '%s error: "%s" (%s) is not a recognized '
                'operation' % 
                (
                    self.__class__.__name__, message.operation, 
                    type(message.operation).__name__
                )
            )

factors_of方法的实现与本章开头定义的factors_of函数并无实质性不同,只是它必须将结果消息发送到调度程序的消息队列,而不是返回一个值:

    def factors_of(self, number):

        # ... code that generates the results

        # - Assuming that the configuration for RabbitMQSender 
        #   is handled elsewhere, we can just get a new instance
        sender = RabbitMQSender()
        outbound_message = DaemonMessage(
            operation=dispatch_results,
            origin=MESSAGE_ORIGINS.worker,
            data={
                'number':number,
                'factors':factors,
            },
            signing_key=self.signing_key
        )
        sender.send_message(outbound_message, self.dispatcher_queue)

工作节点守护进程需要在它们的preflightcleanup方法中通知编排者它们何时变为可用和不可用:

def preflight(self):
    """
Sends a message to the orchestrator to indicate that the instance is 
no longer available
"""
        # - Assuming that the configuration for RabbitMQSender 
        #   is handled elsewhere, we can just get a new instance
        sender = RabbitMQSender()
        outbound_message = DaemonMessage(
            operation=ORCHESTRATOR_OPERATIONS.register_worker,
            origin=MESSAGE_ORIGINS.worker,
            data={
                'worker_id':self.worker_id,
                'max_capacity':1,
            },
            signing_key=self.signing_key
        )
        sender.send_message(outbound_message, self.orchestrator_queue)

def cleanup(self):
    """
Sends a message to the orchestrator to indicate that the instance is 
no longer available
"""
        # - Assuming that the configuration for RabbitMQSender 
        #   is handled elsewhere, we can just get a new instance
        sender = RabbitMQSender()
        outbound_message = DaemonMessage(
            operation=DISPATCH_OPERATIONS.unregister_worker,
            origin=MESSAGE_ORIGINS.worker,
            data={
                'worker_id':self.worker_id,
            },
            signing_key=self.signing_key
        )
        sender.send_message(outbound_message, self.orchestrator_queue)

他们还必须实现这些方法使用的dispatcher_queueworker_idorchestrator_queue属性,提供工作节点的唯一标识符(可以简单地是一个随机的UUID)和共同的编排者和调度程序队列名称(可能来自一个对所有工作节点实例都通用的配置文件)。

编排者

编排者将关注注册、注销和脉冲操作(允许工作节点向编排者发送消息,基本上是在说“我还活着”):

ORCHESTRATOR_OPERATIONS = namedtuple(
    'ORCHESTRATOR_OPERATIONS', [
        'register_worker', 'unregister_worker', 'worker_pulse'
    ]
)
(
    register_worker='register_worker',
    unregister_worker='unregister_worker',
    worker_pulse='worker_pulse',
)

编排者的_handle_message必须将每个操作映射到适当的方法:

    def _handle_message(self, message:(DaemonMessage,)) -> None:
        self.info(
            '%s._handle_message called:' % self.__class__.__name__
        )

        # ...

        if message.operation == ORCHESTRATOR_OPERATIONS.register_worker:
            self.register_worker(message)
        elif message.operation == ORCHESTRATOR_OPERATIONS.unregister_worker:
            self.unregister_worker(message)
        elif message.operation == ORCHESTRATOR_OPERATIONS.worker_pulse:
            self.worker_pulse(message)
        else:
            raise RuntimeError(
                '%s error: "%s" (%s) is not a recognized '
                'operation' % 
                (
                    self.__class__.__name__, message.operation, 
                    type(message.operation).__name__
                )
            )

调度程序

最初,如果调度程序是独立进程而不是合并到编排者中,它将只关注调度结果操作:

DISPATCH_OPERATIONS = namedtuple(
    'DISPATCH_OPERATIONS', ['dispatch_results',]
)
(
    dispatch_results='dispatch_results',
)

它的_handle_message方法将相应地构建:

    def _handle_message(self, message:(DaemonMessage,)) -> None:
        self.info(
            '%s._handle_message called:' % self.__class__.__name__
        )

        # ...

        if message.operation == DISPATCH_OPERATIONS.dispatch_results:
            self.dispatch_results(message)
        else:
            raise RuntimeError(
                '%s error: "%s" (%s) is not a recognized '
                'operation' % 
                (
                    self.__class__.__name__, message.operation, 
                    type(message.operation).__name__
                )
            )

将 Python 与大规模集群计算框架集成

大规模的集群计算框架,为了尽可能与自定义的操作兼容,可能只接受两种不同的输入方式:作为命令行参数,或者使用标准输入,后者更常见于针对大数据操作的系统。无论哪种情况,允许自定义进程在集群环境中执行并扩展所需的是一个自包含的命令行可执行文件,通常将其数据返回到标准输出。

一个接受标准输入的最小脚本——无论是通过管道传递数据进入它,还是通过读取文件内容并使用——可以这样实现:

#!/usr/bin/env python
"""factors_stdin.py

A command-line-ready script that allows factors_of to be called with 

> {incoming list of numbers} | python factors_stdin.py

which executes factors_of against the provided numbers and prints the 
result FOR EACH NUMBER in the format

number:[factors-of-number]
"""

标准输入可以通过 Python 的sys模块作为sys.stdin获得。它是一个类似文件的对象,可以按行读取和迭代:

from sys import stdin

factors_of函数应该直接包含在脚本代码中,这样整个脚本就是完全自包含的,不需要任何自定义软件安装即可使用。为了使代码更短、更易于阅读,我们只是导入它:

from factors import factors_of

如果脚本直接执行——python factors_stdin.py——那么我们实际上会执行该进程,首先从stdin获取所有数字。它们可能作为多行输入,每行可能有多个数字,所以第一步是提取所有数字,这样我们就得到一个要处理的数字列表:

if __name__ == '__main__':
    # - Create a list of stdin lines - multi-line input is 
    #   common enough that it needs to be handled
    lines = [line.strip() for line in stdin]
    # - We need the numbers as individual values, though, so 
    #   build a list of them that we'll actually execute against
    numbers = []
    for line in lines:
        numbers += [n for n in line.split(' ') if n]

有了所有准备好的数字,我们可以对它们进行迭代,将输入中的每个值从字符串值转换为实际的int,并对它们进行处理。如果输入中的值无法转换为int,我们暂时将其跳过,尽管根据调用集群框架的不同,可能有特定的方法来处理——或至少记录——任何错误的值:

    for number in numbers:
        try:
            number = int(number)
        except Exception as error:
            pass
        else:
            # - We've got the number, so execute the function and 
            #   print the results
            print('%d:%s' % (number, factors_of(number)))

可以通过回显数字列表并将其传输到python factors_stdin.py来测试脚本。结果将被打印,每行一个结果,这将被调用程序接受为标准输出,准备传递给接受标准输入的其他进程:

如果源数字在一个文件中(在本章代码中为hugos_numbers.txt),那么它们可以同样轻松地使用,并生成相同的结果:

如果集群环境期望传递命令行参数,那么可以编写一个脚本来适应这一点。它从很大程度上与相同的代码开始:

#!/usr/bin/env python
"""factors_cli.py

A command-line-ready script that allows factors_of to be called with 

> python factors_cli.py number [number [number]] ...

which executes factors_of against the provided numbers and 
prints the results for each in the format

number:[factors-of-number]
"""

from factors import factors_of
from sys import argv

它的不同之处在于获取要处理的数字。由于它们作为命令行值传递,它们将成为argv列表的一部分(Python 的sys模块提供的另一个项目),在脚本名称之后。这个过程的平衡与基于stdin的脚本完全相同:

if __name__ == '__main__':
    # - Get the numbers from the arguments
    numbers = argv[1:]
    for number in numbers:
        try:
            number = int(number)
        except Exception as error:
            # - Errors should probably be logged in some fashion, 
            #   but the specifics may well vary across different
            #   systems, so for now we'll just pass, skipping anything 
            #   that can't be handled.
            pass
        else:
            # - We've got the number, so execute the function and 
            #   print the results
            print('%d:%s' % (number, factors_of(number)))

与之前的脚本一样,输出只是简单地打印到控制台,并且会被传递给任何其他进程作为标准输入。

Python、Hadoop 和 Spark

大规模集群计算框架中最常见或最受欢迎的可能是 Hadoop。Hadoop 是一组软件,提供了网络计算机上的集群计算能力,以及可以被视为网络可访问文件系统的分布式存储机制。

它提供的实用程序之一是 Hadoop Streaming(hadoop.apache.org/docs/r1.2.1/streaming.html),它允许使用任何可执行文件或脚本作为映射器和/或减速器来创建和执行 Map/Reduce 作业。至少对于可以使用 Streaming 的进程,Hadoop 的操作模型是以文件为中心的,因此在 Hadoop 下编写并执行的进程往往更多地属于我们之前讨论过的基于stdin的类别。

Apache Spark 是大规模集群计算框架领域的另一个选择。Spark 是一个分布式的通用框架,并且有一个 Python API(pysparkspark.apache.org/docs/2.2.0/api/python/pyspark.html)可用于使用pip进行安装,从而更直接地访问其功能。

总结

在本章中,我们已经涵盖了 Python 中多处理的所有基本排列(串行和并行,本地和远程/分布式),因为它适用于自定义 HPC 操作。将 Python 编写的进程集成到 Hadoop 等大规模集群计算系统中所需的基础知识非常基础——简单的可执行脚本——并且与这些系统的集成前景与系统本身一样多样。

posted @ 2024-05-04 21:29  绝不原创的飞龙  阅读(22)  评论(0编辑  收藏  举报