原文:
zh.annas-archive.org/md5/7ADF76B4555941A3D7672888F1713C3A
译者:飞龙
前言
最终,本书的目的是阐明务实的软件工程原则以及它们如何应用于 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并注册,以便直接将文件发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packt.com
-
选择 SUPPORT 选项卡
-
单击“代码下载和勘误”
-
在搜索框中输入书名,然后按照屏幕上的说明进行操作
下载文件后,请确保使用最新版本的解压缩软件解压缩文件夹:
-
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,表示在网格方块中发现资源的可能性有多大。开发数据集已经包括了四个默认节点 - 在(0,0)、(0,1)、(1,0)和(1,1)处 - 没有值,如下所示:
系统已经定义了一些类来表示单个地图节点,并提供了一些函数来从它们所在的中央数据存储中提供对这些节点及其属性的基本访问:
常量、异常和各种目的的函数已经存在,如下:
-
node_resource_names
:这包含了系统关注的所有资源名称,并且可以被视为和处理为字符串列表:['gold','silver','copper']
-
NodeAlreadyExistsError
:如果尝试创建一个已经存在的MapNode
,将会引发异常 -
NonexistentNodeError
:如果请求一个不存在的MapNode
,将会引发异常 -
OutOfMapBoundsError
:如果请求一个不允许存在于地图区域的MapNode
,将会引发异常 -
create_node(x,y)
:创建并返回一个新的默认MapNode
,在此过程中将其注册到全局节点数据集中 -
get_node(x,y)
:在全局可用节点数据集中找到并返回指定(x,y)坐标位置的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),在这些情况下,可以非常详细 - 直到类的最小所需属性和方法成员。在敏捷方法论的需求分析过程中,这个层面的细节通常是流动的 - 或者至少不是最终确定的,并且将在第四章,方法、范例和实践中更详细地讨论。
在更高、更少细粒度的层面上,有几个系统模型视图在开发过程中特别引人关注,特别是关于更大的整体情况:
-
架构,逻辑和物理
-
业务流程和规则
-
数据结构和流动
-
进程间通信
-
系统范围/规模
架构,逻辑和物理
逻辑和物理架构规范的目标是分别定义和记录系统的逻辑和物理组件,以便清楚地阐明这些组件元素之间的关系。任何一种努力产生的成果都可以是文本文档或图表,它们都有各自的优点和缺点。
文本文档通常更容易产生,但除非有一些可以应用的架构文档标准,否则格式可能会因系统团队而异。这种差异可能会使得最终产物难以在原始团队之外被理解。如果开发人员在团队之间没有太多流动,或者新开发人员大量涌入团队,这可能并不是一个重大问题。还很难确保所有移动部件或它们之间的连接都得到充分考虑。
图表的主要优点是它们相对容易理解。如果图表具有明显的指示器或符号,可以明确指示,例如,一个组件是数据库服务,另一个是应用程序,那么它们之间的区别一目了然。图表也具有更容易为非技术观众理解的优势。
在这两种情况下,基于文本或基于图表的文档显然是最有用的,如果它们构造良好,并提供了系统的准确视图或模型。
逻辑架构
开发通常更关注系统的逻辑架构而不是物理架构。只要系统中实际代码的部署、连接和使用各种与逻辑组件相关的物理组件的机制已经就位,并且考虑到了任何物理架构约束,通常不需要更多的信息,因此从这个角度来看,任何给定组件的位置并不那么重要。这通常意味着物理架构的详细分解最多只是一个好东西,或者最多只是一个应该有的东西。这也假设所讨论的结构不是某种如此常见以至于需要对其进行文档化的东西。例如,在野外有许多遵循相同常见的三层结构的系统,请求-响应循环如下进行:
-
用户通过表示层发出请求
-
该请求被转交给应用层
-
应用程序从数据层检索所需的任何数据,可能在此过程中进行一些操作或聚合
-
应用层 生成响应并将其返回给表示层
-
表示层 将该响应返回给用户
以图表形式,该结构可能如下所示:
这种三层架构在 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 天冲刺启动活动:
-
故事和任务被设置在任务板上,无论是真实的还是虚拟的,都处于未开始状态,按优先级排序。
-
团队成员认领要处理的故事,从优先级最高的项目开始。如果有多个人在处理一个故事,他们会各自认领与之相关的任务。认领的故事会被移动到任务板上的进行中状态。
-
第 1 天-冲刺结束前的一天:开发和质量保证。
-
每日站立会议(可能在第一天被跳过)。
-
开发:
-
当任务完成时,它们的状态会在任务板上更新以表示完成。
-
当故事完成后,它们会在开发后移动到任务板上的下一个状态。这一列可能是开发完成,准备质量保证,或者根据团队结构合理的其他状态描述。
-
如果遇到障碍,他们会通知Scrum Master,负责促进解决阻塞问题。如果不能立即解决,被阻塞的故事或任务的状态应该在任务板上更新,并且开发人员继续处理他们能够处理的下一个任务或故事。
-
- 随着路障的解决,它们所阻碍的项目重新进入开发状态,并从那时起正常进展。没有什么可以说开发人员在解决了路障后必须继续处理该项目。
-
质量保证活动:
-
如果质量保证人员嵌入到开发团队中,他们的流程通常类似于开发活动,只是他们会从显示开发完成项目的任何列中开始测试一个故事。
-
测试一个故事应该至少包括该故事的验收标准。
-
测试可能会包括不属于验收标准的功能测试。
-
故事验收:如果有任何已完成但尚未被接受的故事,它们可以被相关利益相关者演示和接受或拒绝。被拒绝的项目可能会回到开发中或未开始状态,这取决于为什么被拒绝以及可以做什么来解决被拒绝的原因。
-
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.url
、page_reader.local_file
和page_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_store
和back_office
所使用,为不同的用户提供不同的功能。这种情况的最佳情况将包括以下内容: -
支持不同的 Python 解释器(可能作为单独的虚拟环境)在不同的包项目中
-
具有和管理项目间引用的能力(在这个例子中,
online_store
和back_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.request
,hms_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
函数期望一个name
和price
,还允许一个description
参数,但是这是可选的,默认为None
。函数本身只是将所有这些收集到一个格式化的字符串值中并返回它。price
参数应该是某种数字值,其他参数应该是字符串,如果它们存在的话。在这种情况下,根据参数名称,这些参数值的预期类型可能是显而易见的。
然而,价格参数可以是几种不同的数值类型中的任何一种,并且仍然可以运行——显然int
和float
值可以工作,因为代码可以无错误运行。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'
值对于将源代码组织在src
或lib
目录下的项目来说是典型的,通常与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
中的第一个目标:main
。main
目标又有test
和setup
目标作为先决条件目标指定,在继续执行自己的流程之前执行。如果执行make main
,将返回相同的结果。第二次和第三次运行,分别执行特定的目标make test
和make setup
。
因此,Make 是一个非常灵活和强大的工具。只要给定的构建过程步骤可以在命令行中执行,就可以将其纳入基于 Make 的构建中。如果不同的环境需要不同的流程(例如dev
,test
,stage
和live
),则可以设置与这些环境对应的 Make 目标,允许一个构建过程处理这些变化,只需执行make dev
,…
,make 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
的流程进行包装。 -
每个构建过程都将集成单元测试结果,以防止构建在任何单元测试失败时完成。
-
项目内的包结构尚未定义,但将随着开发的进行而逐渐展开。
-
每个项目都将拥有并使用自己独特的虚拟环境,以保持与每个项目相关的要求和依赖项分开。这可能需要一些构建过程的调整,但还有待观察。