Python 软件架构(一)
原文:
zh.annas-archive.org/md5/E8EC0BA674FAF6D2B8F974FE76F20D30译者:飞龙
前言
软件架构,或者为特定软件应用程序创建蓝图设计,绝非易事。软件架构中最大的两个挑战是保持架构与需求的同步,首先是随着需求的揭示或演变,其次是随着实现的构建和演变。
充满了示例和用例,本指南直接帮助您成为成功的软件架构师所需的一切。本书将帮助您了解 Python 的方方面面,以便您可以在 Python 中设计和构建高度可扩展、健壮、清晰和高性能的应用程序。
本书涵盖内容
第一章,“软件架构原则”,介绍了软件架构的主题,简要介绍了架构质量属性及其背后的一般原则。这将使您能够在软件架构原则和基本属性方面建立坚实的基础。
第二章,“编写可修改和可读的代码”,涵盖了开发架构质量属性,即可修改性和可读性。它将帮助您了解可维护性的架构质量属性以及在 Python 中编写代码的策略,以测试您的应用程序。
第三章,“可测试性-编写可测试代码”,帮助您了解可测试性的架构质量属性以及如何为可测试性设计 Python 应用程序。您还将了解可测试性和软件测试的各个方面,以及 Python 中可用的不同库和模块,以编写可测试的应用程序。
第四章,“良好的性能是值得的!”,涵盖了编写 Python 代码的性能方面。您将了解性能作为架构质量属性的知识,以及何时进行性能优化。您将学习在软件开发生命周期中何时进行性能优化。
第五章,“编写可扩展的应用程序”,讨论了编写可扩展应用程序的重要性。它讨论了实现应用程序可扩展性的不同方法,并讨论了使用 Python 的可扩展性技术。您还将了解可扩展性的理论方面和行业中的最佳实践。
第六章,“安全-编写安全代码”,讨论了架构的安全方面,并教授了编写安全应用程序的最佳实践和技术。您将了解要注意的不同安全问题,并学会在 Python 中构建从根本上安全的应用程序。
第七章,“Python 中的设计模式”,从实用程序员的角度概述了 Python 中的设计模式,并简要介绍了每种模式的理论背景。您将了解 Python 中对实用程序员实际有用的设计模式。
第八章,“Python 架构模式- Pythonic 方法”,从高层次的角度介绍了 Python 中的现代架构模式,同时提供了 Python 库和框架的示例,以实现这些模式的方法来解决高级架构问题。
第九章,“使用 Python 部署应用程序-使用 Python 进行 Devops”,涵盖了使用正确的方式在远程环境或云上轻松部署代码的方面。
第十章,“调试技术”,涵盖了 Python 代码的一些调试技术——从最简单的、策略性放置的打印语句到日志记录和系统调用跟踪,这对程序员非常有用,也有助于系统架构师指导他的团队。
本书所需内容
要运行本书中显示的大多数代码示例,您需要在系统上安装 Python 3。其他先决条件在相应的实例中提到。
本书适合谁
本书适用于有经验的 Python 开发人员,他们希望成为企业级应用程序的架构师,或者希望利用 Python 创建应用程序的有效蓝图的软件架构师。
约定
在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些示例和它们的含义解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们可以通过使用include指令来包含其他上下文。”
代码块设置如下:
class PrototypeFactory(Borg):
""" A Prototype factory/registry class """
def __init__(self):
""" Initializer """
self._registry = {}
def register(self, instance):
""" Register a given instance """
self._registry[instance.__class__] = instance
def clone(self, klass):
""" Return clone given class """
instance = self._registry.get(klass)
if instance == None:
print('Error:',klass,'not registered')
else:
return instance.clone()
当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都写成如下形式:
>>> import hash_stream
>>> hash_stream.hash_stream(open('hash_stream.py'))
'30fbc7890bc950a0be4eaa60e1fee9a1'
新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种形式出现在文本中:“单击下一步按钮会将您移至下一个屏幕。”
注意
警告或重要说明会出现在这样的框中。
提示
提示和技巧会以这种形式出现。
第一章:软件架构原则
这是一本关于 Python 的书。与此同时,它也是一本关于软件架构及其在软件开发生命周期中涉及的各种属性的书。
为了让你理解并结合这两个方面,这对于从本书中获得最大价值是至关重要的,重要的是要掌握软件架构的基本原理,与之相关的主题和概念,以及软件架构的各种质量属性。
许多软件工程师在组织中担任高级角色时,经常对软件设计和架构的定义以及它们在构建可测试、可维护、可扩展、安全和功能性软件中的作用有着非常不同的解释。
尽管该领域有大量的文献,无论是传统的书籍形式还是互联网上,但我们中的从业者往往对这些非常重要的概念产生混淆的印象。这往往是由于学习技术而不是学习技术在构建系统中的基本设计和架构原则方面所带来的压力。这在软件开发组织中是一种常见做法,其中交付可工作的代码的压力往往压倒和掩盖了其他一切。
像本书这样的一本书,努力超越中间路径,将软件开发中与其架构质量属性相关的颇为晦涩的方面与使用编程语言、库和框架构建软件的平凡细节联系起来——在本例中,使用 Python 及其开发者生态系统。
这个开场章节的作用是揭开这些概念的神秘面纱,并以非常清晰的方式向读者解释,为他准备好理解本书其余部分的内容。希望到本书结束时,这些概念及其实际细节将对读者构成一个连贯的知识体系。
我们将立即开始这条道路,将这一章大致分为以下几个部分:
-
定义软件架构
-
软件架构与设计
-
软件架构的方面
-
软件架构的特点
-
为什么软件架构很重要?
-
系统与企业架构
-
架构质量属性
-
可修改性
-
可测试性
-
可扩展性/性能
-
安全性
-
可部署性
定义软件架构
关于这个主题的文献中有各种各样的软件架构定义。一个简单的定义如下:
软件架构是对软件系统的子系统或组件以及它们之间的关系的描述。
以下是来自IEEE技术的软件密集型系统架构描述的推荐实践的更正式定义:
“架构是一个系统在其组件中体现的基本组织,它们相互之间的关系,以及与环境的关系,以及指导其设计和演变的原则。”
如果花一些时间在网上搜索,可能会找到无数关于软件架构的定义。措辞可能不同,但所有的定义都指的是软件架构的一些核心、基本方面。
软件架构与设计
在作者的经验中,系统的软件架构与其设计的问题似乎经常出现在在线和离线论坛中。因此,让我们花一点时间来理解这一方面。
尽管这两个术语有时可以互换使用,但架构与设计的粗略区别可以总结如下:
-
架构涉及系统中的描述结构和交互的更高层次。它涉及那些需要对系统的骨架做出决策的问题,不仅涉及其功能,还涉及其组织、技术、业务和质量属性。
-
设计是关于系统的部分或组件的组织以及涉及制作系统的子系统。这里的问题通常更接近于代码或相关模块,比如:
-
将代码分割成哪些模块?如何组织它们?
-
将不同的功能分配给哪些类(或模块)?
-
我应该为类“C”使用哪种设计模式?
-
我的对象在运行时如何交互?传递了哪些消息,如何组织这种交互?
软件架构是关于整个系统的设计,而软件设计大多是关于细节,通常是关于构成这些子系统的各种子系统和组件的实现级别。
换句话说,设计这个词在两种情境中都出现了,不过前者的抽象程度和范围要比后者高得多。
对于软件架构和设计,即架构模式和设计模式,都有丰富的知识体系可供参考。我们将在本书的后续章节中讨论这两个主题。
软件架构的特点
在正式的 IEEE 定义和之前给出的相当不正式的定义中,我们发现了一些共同的、反复出现的主题。为了进一步讨论软件架构,理解它们是很重要的:
-
系统:系统是以特定方式组织的组件集合,以实现特定的功能。软件系统是这种软件组件的集合。系统通常可以分为子系统。
-
结构:结构是根据指导原则或原则组合或组织在一起的一组元素。这些元素可以是软件或硬件系统。根据观察者的情境,软件架构可以展示不同级别的结构。
-
环境:软件系统构建的上下文或环境,对其架构有直接影响。这些上下文可以是技术、业务、专业、运营等。
-
利益相关者:任何对系统及其成功感兴趣或关注的人或人群。利益相关者的例子包括架构师、开发团队、客户、项目经理、营销团队等。
现在您已经了解了软件架构的一些核心方面,让我们简要列出一些其特点。
软件架构的特点
所有软件架构都具有一组共同的特征。让我们在这里看一些最重要的特征。
架构定义了一个结构
系统的架构最好表示为系统的结构细节。实践者通常会将系统架构绘制为结构组件或类图,以表示子系统之间的关系。
例如,以下架构图描述了一个应用程序的后端,该应用程序从分层数据库系统中读取数据,使用 ETL 过程加载:
示例架构图显示系统结构
结构提供了对架构的洞察,并为分析架构提供了独特的视角,以便考虑其质量属性。
以下是一些示例:
-
运行时结构,即运行时创建的对象以及它们的交互,通常决定了部署架构。部署架构与可伸缩性、性能、安全性和互操作性等质量属性密切相关。
-
模块结构,即代码如何分解和组织成模块和包以进行任务分解,通常直接影响系统的可维护性和可修改性(可扩展性)。解释如下:
-
代码的组织方式旨在可扩展性,通常会将父类放在单独定义良好的包中,并配有适当的文档和配置,这样外部模块就可以轻松地进行扩展,而无需解决太多的依赖关系。
-
依赖于外部或第三方开发人员(库、框架等)的代码通常会提供设置或部署步骤,手动或自动地从外部来源获取这些依赖项。这样的代码还会提供文档(README、INSTALL 等),清楚地记录这些步骤。
架构选择了一组核心元素
良好定义的架构清楚地捕捉了构建系统核心功能所需的一组核心结构元素,并对系统产生持久影响。它并不打算记录系统的每个组件的所有内容。
例如,描述用户与用于浏览网页的 Web 服务器进行交互的架构师(典型的客户端/服务器架构)主要关注两个组件:用户的浏览器(客户端)和远程 Web 服务器(服务器),它们构成了系统的核心元素。
系统可能有其他组件,例如从服务器到客户端的路径上有多个缓存代理,或者服务器上有一个远程缓存,可以加快网页传送速度。然而,这不是架构描述的重点。
架构捕捉早期设计决策
这是先前描述的特征的必然结果。帮助架构师专注于系统的一些核心元素(及其相互作用)的决策是对系统的早期设计决策的结果。因此,这些决策由于其初始权重在系统的进一步发展中起着重要作用。
例如,架构师可能在仔细分析系统需求后做出以下早期设计决策:
-
系统将仅部署在 Linux 64 位服务器上,因为这满足了客户的要求和性能约束。
-
系统将使用 HTTP 作为实现后端 API 的协议
-
系统将尝试使用 HTTPS 来传输从后端到前端的敏感数据的 API,使用 2048 位或更高的加密证书
-
系统的编程语言将是 Python 用于后端,Python 或 Ruby 用于前端
注意
第一个决定在很大程度上冻结了系统的部署选择,限定了特定操作系统和系统架构。接下来的两个决定在实现后端 API 方面具有很大的影响。最后一个决定冻结了系统的编程语言选择。
早期的设计决策需要在仔细分析需求并将其与约束进行匹配后做出,例如组织、技术、人员和时间约束。
架构管理利益相关者的需求
系统的设计和构建最终是为了满足利益相关者的要求。然而,由于这些要求往往是矛盾的,因此不可能完全满足每个利益相关者的要求。以下是一些例子:
-
市场团队关心拥有功能齐全的软件应用程序,而开发团队关心在添加许多功能时的功能蔓延和性能问题。
-
系统架构师关注使用最新技术将其部署扩展到云端,而项目经理关注这种技术部署对其预算的影响。最终用户关注正确的功能、性能、安全性、可用性和可靠性,而开发组织(架构师、开发团队和经理)关注在保持项目进度和预算范围内交付所有这些质量的同时。
-
一个好的架构尽力平衡这些要求,通过权衡,提供具有良好质量属性的系统,同时保持人力和资源成本在限制范围内。
-
架构还为利益相关者提供了一个共同的语言,使他们能够通过表达这些约束来有效地进行沟通,并帮助架构师朝着最能满足这些要求和它们的权衡的架构前进。
架构影响组织结构
系统结构描述的架构往往直接映射到构建这些系统的团队的结构。
例如,一个架构可能有一个数据访问层,描述了一组读写大量数据的服务——这样的系统自然会被分配给数据库团队,他们已经具备所需的技能。
由于系统的架构是对自上而下结构的最佳描述,因此它经常被用作任务分解结构的基础。因此,软件架构往往直接影响构建它的组织结构:
搜索 Web 应用程序的系统架构
以下图表显示了构建此应用程序的团队结构的映射:
架构受其环境的影响
环境对架构必须运行的外部约束或限制。在文献中,这些通常被称为上下文中的架构 [参考:Bass,Kazman]。以下是一些例子:
-
质量属性要求:在现代 Web 应用程序中,很常见地将应用程序的可扩展性和可用性要求作为早期的技术约束,并在架构中加以捕捉。这是从业务角度看的技术背景的一个例子。
-
标准符合:在一些组织中,软件通常有一大套管理标准,特别是在银行、保险和医疗保健领域,这些标准被添加到架构的早期约束中。这是一个外部技术背景的例子。
-
组织约束:通常可以看到,那些具有某种架构风格经验或一组团队在某些编程环境中操作的组织(J2EE 是一个很好的例子),更倾向于采用类似的架构来减少成本,并确保由于当前对这些架构和相关技能的投资而提高生产力。这是一个内部业务背景的例子。
-
专业背景:除了这些外部背景之外,架构师对系统架构的选择大多是根据他独特经验的选择。架构师通常会继续在新项目中使用他过去取得最大成功的一套架构选择。
架构选择也源自个人的教育和专业培训,以及来自专业同行的影响。
架构记录了系统
每个系统都有一个架构,无论它是否被正式记录。然而,适当记录的架构可以作为系统的有效文档。由于架构捕获了系统的初始需求、约束和利益相关者的权衡,适当记录它是一个很好的做法。文档可以作为后续培训的基础。它还有助于持续的利益相关者沟通,并根据不断变化的需求进行架构的后续迭代。
记录架构的最简单方法是为系统的不同方面和组织架构创建图表,例如组件架构、部署架构、通信架构以及团队或企业架构。
可以早期捕获的其他数据包括系统需求、约束、早期设计决策以及这些决策的基本原理。
架构通常符合一种模式
大多数架构符合一定的在实践中取得了很大成功的风格。这些被称为架构模式。此类模式的示例包括客户端-服务器、管道和过滤器、基于数据的架构等。当架构师选择现有模式时,他可以参考和重用许多与这些模式相关的现有用例和示例。在现代架构中,架构师的工作归结为混合和匹配现有的这些可用模式集来解决手头的问题。
例如,以下图表显示了客户端-服务器架构的示例:
客户端-服务器架构示例
以下图表描述了另一种常见的架构模式,即管道和过滤器架构,用于处理数据流:
管道和过滤器架构示例
我们将在本书的后面看到架构模式的示例。
软件架构的重要性
到目前为止,我们已经讨论了软件架构的基本原则,并且也看到了一些特征。当然,这些部分假定了软件架构是重要的,并且是软件开发过程中的关键步骤。
现在是时候扮演魔鬼的辩护人,回顾软件架构并提出一些关于它的存在性问题,如下所示:
-
为什么软件架构?
-
为什么软件架构很重要?
-
为什么不建立一个没有正式软件架构的系统?
让我们来看看软件架构提供的关键见解,这些见解在非正式的软件开发过程中将会缺失。我们只关注以下表中系统的技术或开发方面:
| 方面 | 洞察/影响 | 示例 |
|---|---|---|
| 架构选择要为系统优化的质量属性。 | 诸如可伸缩性、可用性、可修改性、安全性等系统方面取决于在选择架构时的早期决策和权衡。通常你会在一个属性和另一个属性之间进行权衡。 | 一个优化了可伸缩性的系统必须使用分散式架构来开发,其中元素之间没有紧密耦合。例如:微服务、代理。 |
| 架构有助于早期原型设计。 | 定义架构允许开发组织尝试并构建早期原型,这可以为系统的行为提供宝贵的见解,而无需自上而下地构建完整的系统。 | 许多组织快速构建服务的原型——通常是仅构建这些服务的外部 API 并模拟其余行为。这允许进行早期集成测试,并及早解决架构中的交互问题。 |
| 架构允许系统逐个构建组件。 | 有一个明确定义的架构可以重复使用和组装现有的、现成的组件,以实现功能,而不必从头开始实现所有内容。 | 提供服务的现成构件的库或框架。例如:Django/RoR 等 Web 应用框架,以及 Celery 等任务分发框架。 |
| 架构有助于管理系统的变更。 | 架构允许架构师以受影响的组件和未受影响的组件来界定系统的变更。这有助于在实现新功能、性能修复等时将系统变更最小化。例如,如果架构实施正确,对系统的数据库读取进行性能修复只需要对数据库和数据访问层(DAL)进行更改,根本不需要触及应用程序代码。例如,这就是大多数现代 Web 框架的构建方式。 |
还有一些与系统的业务背景相关的其他方面,架构为此提供了宝贵的见解。然而,由于这本书主要讨论软件架构的技术方面,我们将讨论限制在前表中给出的内容。
现在,让我们来探讨第二个问题:
为什么不建立一个没有正式软件架构的系统呢?
如果您迄今为止一直在认真地跟随这些论点,那么很容易看出答案。然而,可以总结为以下几个陈述:
-
每个系统都有一个架构,无论是否有文档记录
-
记录架构使其正式化,使其能够在利益相关者之间共享,从而使变更管理和迭代开发成为可能
-
当您有一个明确定义和记录的正式架构时,所有其他软件架构的好处和特征都可以被利用
-
您可能仍然能够在没有正式架构的情况下工作和构建一个功能性的系统,但这不会产生一个可扩展和可修改的系统,很可能会产生一个与原始要求相去甚远的一组质量属性的系统
系统与企业架构
您可能已经听说过架构师这个术语。在软件行业中,以下角色或头衔对架构师来说是相当常见的:
-
技术架构师
-
安全架构师
-
信息架构师
-
基础架构架构师
您可能也听说过系统架构师这个术语,也许还有企业架构师,也可能是解决方案架构师。有趣的问题是:这些人做什么?
让我们试着找到这个问题的答案。
企业架构师审视组织的整体业务和组织战略,并应用架构原则和实践指导组织通过业务、信息、流程和技术变化,以执行他们的战略。企业架构师通常更关注战略,而较少关注技术。其他架构师角色负责自己的子系统和流程。例如:
- 技术架构师:技术架构师关注组织中使用的核心技术(硬件/软件/网络)。安全架构师创建或调整应用程序中使用的安全策略,以适应组织的信息安全目标。信息架构师提出架构解决方案,使信息能够以有利于组织业务目标的方式在应用程序之间可用。
这些特定的建筑角色都关注自己的系统和子系统。因此,这些角色中的每一个都是系统架构师角色。
这些架构师帮助企业架构师了解他们负责的每个业务领域的细节,这有助于企业架构师获取有助于制定业务和组织战略的信息。
-
系统架构师:系统架构师通常更注重技术,较少关注战略。在一些面向服务的软件组织中,通常会有解决方案架构师,他将不同的系统结合起来为特定客户创建解决方案。在这种情况下,不同的架构师角色通常会根据组织的规模以及项目的特定时间和成本要求进行合并。
-
解决方案架构师:解决方案架构师通常处于战略与技术关注以及组织与项目范围之间的中间位置。
以下示意图描述了组织中不同层次的技术、应用、数据、人员、流程和业务,并清晰地展示了架构师角色的关注领域:
企业与系统架构师
让我们稍微讨论一下前面的图表,以了解它所呈现的情况。
系统架构师位于图表的左下方,关注企业的系统组件。他的关注点是驱动企业的应用程序、它们的数据以及驱动应用程序的硬件和软件堆栈。
另一方面,企业架构师位于顶部,从顶层视角看待企业,包括业务目标和人员,而不仅仅是支撑组织的基础系统。业务流程的垂直堆栈将支撑组织的技术组件与其人员和业务组件连接起来。这些流程是由企业架构师与其他利益相关者讨论定义的。
既然你已经了解了企业和系统架构背后的图景,让我们来看一些正式的定义:
"企业架构是定义组织结构和行为的概念蓝图。它确定了组织结构、流程、人员和信息流如何与其核心目标对齐,以有效实现当前和未来的目标。"
"系统架构是系统的基本组织,由其结构和行为视图表示。结构由系统的组件确定,行为由它们之间的关系以及与外部系统的互动确定。"
企业架构师关注的是组织中不同元素及其相互作用如何调整以有效实现组织目标。在这项工作中,他不仅需要组织中的技术架构师的支持,还需要项目经理和人力资源专业人员等组织管理人员的支持。
另一方面,系统架构师关注核心系统架构如何映射到软件和硬件架构,以及人与系统组件的各种细节交互。他的关注点永远不会超出系统及其交互所定义的范围。
以下图表描述了我们迄今讨论的不同架构师角色的不同关注领域和范围:
软件组织中各种架构师角色的范围和重点
架构质量属性
现在让我们关注本书其余部分的主题——架构质量属性。
在前面的部分中,我们讨论了架构如何平衡和优化利益相关者的需求。我们还看到了一些相互矛盾的利益相关者需求的例子,架构师通过选择必要的权衡来平衡这些需求。
术语质量属性已被用来宽泛地定义架构为之做出权衡的一些方面。现在是正式定义什么是架构质量属性的时候了:
"质量属性是系统的可度量和可测试的属性,可用于评估系统在其规定环境中相对于其非功能方面的性能"
有许多方面符合架构质量属性的一般定义。然而,在本书的其余部分,我们将专注于以下质量属性:
-
可修改性
-
可测试性
-
可扩展性和性能
-
可用性
-
安全性
-
可部署性
可修改性
许多研究表明,典型软件系统的成本约 80%发生在初始开发和部署之后。这显示了可修改性对系统初始架构的重要性。
可修改性可以定义为对系统进行更改的容易程度,以及系统调整到变化的灵活性。这是一个重要的质量属性,因为几乎每个软件系统在其生命周期中都会发生变化——修复问题,添加新功能,进行性能改进等。
从架构师的角度来看,对可修改性的兴趣在于以下方面:
-
难度:对系统进行更改的容易程度
-
成本:对进行更改所需的时间和资源而言
-
风险:与对系统进行更改相关的任何风险
现在,我们在这里谈论的是什么样的变化?是对代码的改动,对部署的改动,还是对整个架构的改动?
答案是:它可以在任何级别。
从架构的角度来看,这些变化通常可以在以下三个级别进行捕捉:
-
本地:本地变化只影响特定元素。该元素可以是代码的一部分,如函数、类、模块,或者是配置元素,如 XML 或 JSON 文件。变化不会级联到任何相邻元素或系统的其余部分。本地变化是最容易进行的,也是最不冒险的。这些变化通常可以通过本地单元测试快速验证。
-
非本地:这些变化涉及多个元素。以下是一些例子:
-
修改数据库模式,然后需要在应用程序代码中表示该模式的模型类中进行级联。
-
在 JSON 文件中添加一个新的配置参数,然后需要由解析器解析文件和/或使用参数的应用程序进行处理。
非本地变化比本地变化更难进行,需要仔细分析,并在可能的情况下进行集成测试,以避免代码回归。
- 全局:这些变化要么涉及自顶向下的架构变化,要么涉及全局级别的元素变化,这些变化会级联到软件系统的重要部分。以下是一些例子:
-
将系统架构从 RESTful 更改为基于消息传递(SOAP、XML-RPC 等)的 Web 服务
-
将 Web 应用程序控制器从 Django 更改为基于 Angular-js 的组件
-
性能变化要求所有数据在前端预加载,以避免在线新闻应用程序中的任何内联模型 API 调用
这些变化是最具风险的,也是最昂贵的,涉及资源、时间和金钱。架构师需要仔细审查变化可能带来的不同情景,并让他的团队通过集成测试对其进行建模。在这类大规模变化中,模拟可以非常有用。
以下表格显示了不同系统可修改性水平的成本和风险之间的关系:
| 等级 | 成本 | 风险 |
|---|---|---|
| 本地 | 低 | 低 |
| 非本地 | 中 | 中 |
| 全局 | 高 | 高 |
代码级别的可修改性也与其可读性直接相关:
“代码越可读,就越容易修改。代码的可修改性与其可读性成反比。”
可修改性方面也与代码的可维护性相关。代码模块中元素耦合度非常紧密的话,修改的可能性就会比元素耦合度较松的模块小得多——这就是可修改性的耦合方面。
同样,一个类或模块如果没有清晰地定义其角色和责任,就会比另一个定义了明确责任和功能的类或模块更难修改。这个方面被称为软件模块的内聚性。
以下表格显示了假设模块 A 的内聚性、耦合和可修改性之间的关系。假设耦合是从这个模块到另一个模块 B:
| 内聚性 | 耦合 | 可修改性 |
|---|---|---|
| 低 | 高 | 低 |
| 低 | 低 | 中 |
| 高 | 高 | 中 |
| 高 | 低 | 高 |
从前面的表格可以清楚地看出,内聚性更高,耦合更低是代码模块可修改性的最佳情况。
影响可修改性的其他因素如下:
-
模块的大小(代码行数):大小增加时,可修改性减少。
-
在模块上工作的团队成员数量:通常,当更多的团队成员在模块上工作时,模块变得不太可修改,因为合并和维护统一的代码基础变得更加复杂。
-
模块的外部第三方依赖:外部第三方依赖的数量越多,修改模块就越困难。这可以被视为模块耦合方面的延伸。
-
错误使用模块 API:如果有其他模块使用模块的私有数据而不是(正确地)使用其公共 API,那么修改模块就会更加困难。在组织中确保模块的正确使用标准以避免这种情况非常重要。这可以被视为紧密耦合的极端情况。
可测试性
可测试性指的是软件系统通过测试展示其故障的程度。可测试性也可以被视为软件系统隐藏其故障程度的程度——系统越可测试,就越难以隐藏其故障。
可测试性也与软件系统行为的可预测性相关。系统越可预测,就越允许可重复的测试,并且可以基于一组输入数据或标准开发标准测试套件。不可预测的系统很难进行任何形式的测试,或者在极端情况下根本无法测试。
在软件测试中,通常通过发送一组已知输入来控制系统的行为,然后观察系统的一组已知输出。这两者结合起来形成一个测试用例。一个测试套件或测试工具通常包括许多这样的测试用例。
测试断言是用于在测试用例的输出与给定输入的预期输出不匹配时使测试用例失败的技术。这些断言通常在测试执行阶段的特定步骤手动编码,以检查测试用例的不同步骤的数据值:
简单单元测试用例函数 f('X') = 'Y'的代表性流程图
前面的图表显示了一个代表性流程图的例子,用于可测试函数**“f”,输入为“X”,预期输出为“Y”**。
为了在故障发生时重新创建会话或状态,通常使用记录/回放策略。这使用专门的软件(如 Selenium),记录导致特定故障的所有用户操作,并将其保存为测试用例。通过使用相同的软件重放测试用例来再现测试,该软件尝试模拟相同的测试用例;这是通过重复相同的 UI 操作集和顺序来完成的。
可测试性也与代码的复杂性有关,与可修改性非常相似。当系统的部分可以被隔离并独立于系统的其余部分工作时,系统变得更具可测试性。换句话说,耦合度低的系统比耦合度高的系统更具可测试性。
测试的另一个方面与前面提到的可预测性有关,即减少非确定性。在编写测试套件时,我们需要将要测试的元素与系统的其他部分隔离开来,这些部分往往表现出不可预测的行为,以便测试元素的行为变得可预测。
一个例子是多线程系统,它响应系统其他部分引发的事件。整个系统可能相当不可预测,不适合重复测试。相反,需要将事件子系统分离出来,并可能模拟其行为,以便可以控制这些输入,并且接收事件的子系统变得可预测,因此可测试。
以下示意图解释了系统的可测试性和可预测性与其组件之间的耦合和内聚之间的关系:
系统的可测试性和可预测性与耦合和内聚的关系
可伸缩性
现代 Web 应用程序都是关于扩展的。如果您是现代软件组织的一部分,很可能您已经听说过或者正在开发一款为云端编写的应用程序,它能够根据需求弹性扩展。
系统的可伸缩性是指其在保持性能在可接受范围内的情况下,能够容纳不断增加的工作负载的能力。
在软件系统的背景下,可伸缩性通常分为两类,如下所示:
- 水平可伸缩性:水平可伸缩性意味着通过向软件系统添加更多计算节点来扩展/缩减系统。过去十年中集群计算的进步催生了商业水平可伸缩的弹性系统作为 Web 服务的出现。一个著名的例子是亚马逊网络服务。在水平可伸缩系统中,通常数据和/或计算是在单元或节点上进行的,通常是在称为虚拟专用服务器(VPS)的商品系统上运行的虚拟机。通过向系统添加 n 个或更多节点,通常由负载均衡器进行前端处理,可实现“n”倍的可伸缩性。扩展意味着通过添加更多节点来扩展可伸缩性,而缩减意味着通过移除现有节点来减少可伸缩性。
显示水平扩展 Web 应用程序服务器的示例部署架构
-
垂直可伸缩性:垂直可伸缩性涉及向系统中的单个节点添加或移除资源。通常是通过向集群中的单个虚拟服务器添加或移除 CPU 或 RAM(内存)来实现的。前者称为扩展,后者称为缩减。另一种扩展是增加系统中现有软件进程的容量,通常是通过增加可用于应用程序的进程或线程数量来实现的。一些例子如下:
-
通过增加其工作进程的数量来增加 Nginx 服务器进程的容量
-
通过增加其最大连接数来增加 PostgreSQL 服务器的容量
性能
系统的性能与其可伸缩性相关。系统的性能可以定义如下:
“计算机系统的性能是系统使用给定的计算资源单位所完成的工作量。工作/单位比率越高,性能越高。”
用于衡量性能的计算资源单位可以是以下之一:
-
响应时间:函数或任何执行单元在实时(用户时间)和时钟时间(CPU 时间)方面执行所需的时间。
-
延迟:系统获取刺激并提供响应所需的时间。一个例子是 Web 应用程序的请求-响应循环完成所需的时间,从最终用户的角度来衡量。
-
吞吐量:系统处理信息的速率。性能更高的系统通常具有更高的吞吐量,相应地具有更高的可伸缩性。一个例子是电子商务网站的吞吐量,以每分钟完成的交易数量来衡量。
性能与可伸缩性密切相关,特别是纵向可伸缩性。一个在内存管理方面表现出色的系统将通过添加更多 RAM 轻松实现纵向扩展。
同样,具有多线程工作负载特性并且针对多核 CPU 进行了最佳编写的系统,将通过添加更多 CPU 核来扩展。
水平可伸缩性被认为与系统在其自己的计算节点内的性能没有直接联系。然而,如果系统以一种不利用网络的方式编写,从而产生网络延迟问题,它可能会在水平方面有效地扩展,因为在网络延迟上花费的时间会抵消通过分发工作获得的可伸缩性增益。
一些动态编程语言,如 Python,在纵向扩展时存在内置的可伸缩性问题。例如,Python(CPython)的全局解释器锁(GIL)阻止它通过多个线程充分利用可用的 CPU 核进行计算。
可用性
可用性是指软件系统在需要时执行其操作的准备性质。
系统的可用性与其可靠性密切相关。系统越可靠,可用性就越高。
另一个修改可用性的因素是系统从故障中恢复的能力。一个系统可能非常可靠,但如果系统无法从其子系统的完全或部分故障中恢复,那么它可能无法保证可用性。这一方面被称为恢复。
系统的可用性可以定义如下:
“系统的可用性是系统在随机调用或调用时完全可操作状态的程度。”
在数学上,这可以表示如下:
可用性 = MTBF / (MTBF + MTTR)
看一下前面公式中使用的以下术语:
-
MTBF:平均故障间隔时间
-
MTTR:平均修复时间
这通常被称为系统的任务可执行率。
可用性的技术与恢复技术密切相关。这是因为系统永远无法 100%可用。相反,需要计划故障和从故障中恢复的策略,这直接决定了可用性。这些技术可以分类如下:
-
故障检测:检测故障并采取行动的能力有助于避免系统或系统部分完全不可用的情况。故障检测通常涉及监视、心跳和 ping/echo 消息等步骤,这些消息被发送到系统中的节点,并测量响应以计算节点是活着的、死了的还是正在失败的。
-
故障恢复:一旦检测到故障,下一步是准备系统从故障中恢复,并使其达到可以被认为是可用的状态。这里通常使用的策略包括热备份/冷备份(主/备份冗余)、回滚、优雅降级和重试。
-
故障预防:这种方法使用主动方法来预见和防止故障发生,以便系统没有机会进行恢复。
系统的可用性与其数据的一致性密切相关,根据 CAP 定理,系统在网络分区的情况下在一致性和可用性之间存在理论上的限制。CAP 定理指出系统可以选择在一致性和可用性之间进行权衡,通常导致两种类型的系统,即 CP(一致性和网络故障容忍)和 AP(可用性和网络故障容忍)。
可用性还与系统的可扩展策略、性能指标和安全性相关。例如,高度横向扩展的系统将具有非常高的可用性,因为它允许负载均衡器快速确定非活动节点并将其从配置中移除。
一个试图扩展的系统可能需要仔细监控其性能指标。即使系统所在的节点完全可用,如果软件进程受到系统资源(如 CPU 时间或内存)的挤压,系统可能会出现可用性问题。这就是性能测量变得至关重要的地方,系统的负载因子需要被监控和优化。
随着 Web 应用程序和分布式计算的日益流行,安全也是影响可用性的一个方面。恶意黑客可能对您的服务器发动远程拒绝服务攻击,如果系统没有针对这种攻击做出防范,可能导致系统变得不可用或只部分可用。
安全
在软件领域,安全可以定义为系统避免未经授权访问对其数据和逻辑造成损害的能力,同时继续向其他经过适当认证的系统和角色提供服务。
安全危机或攻击发生在系统被有意破坏,以获取非法访问、损害其服务、复制或修改其数据,或拒绝合法用户访问的情况下。
在现代软件系统中,用户与具有对系统不同部分独占权限的特定角色相关联。例如,具有数据库的典型 Web 应用程序可能定义以下角色:
-
用户:系统的最终用户,具有登录和访问自己私人数据的权限
-
dbadmin:数据库管理员,可以查看、修改或删除所有数据库数据
-
报告:报告管理员,只对处理报告生成的数据库和代码部分具有管理员权限
-
管理员:超级用户,对整个系统具有编辑权限
通过用户角色分配系统控制的方式称为访问控制。访问控制通过将用户角色与某些系统特权关联起来,从而将实际用户登录与这些特权授予的权限分离开来。
这个原则是安全的授权技术。
安全的另一个方面是与交易相关的,每个人都必须验证对方的真实身份。公钥加密、消息签名等是常用的技术。例如,当您用您的 GPG 或 PGP 密钥签署电子邮件时,您正在验证自己——发送此消息的人确实是我,A 先生——给您在电子邮件另一端的朋友 B。这个原则是安全的认证技术。
安全的其他方面如下:
-
完整性:这些技术用于确保数据或信息在传输到最终用户的过程中没有被篡改。例如消息哈希、CRC 校验和等。
-
来源:这些技术用于向最终接收者保证数据的来源与其所宣称的完全相同。这些技术包括 SPF、Sender-ID(用于电子邮件)、使用 SSL 的网站的公钥证书和链等。
-
真实性:这些是将消息的完整性和来源结合在一起的技术。这确保了消息的作者不能否认消息的内容以及其来源(他/她自己)。这通常使用数字证书机制。
部署性
部署性是软件质量属性之一,但并非对软件至关重要。然而,在本书中,我们对这一方面感兴趣,因为它在 Python 编程语言的生态系统的许多方面以及对程序员的实用性中起着关键作用。
部署性是指软件从开发环境到生产环境的易用程度。这更多地取决于技术环境、模块结构和构建系统所使用的编程运行时/语言的功能,与系统的实际逻辑或代码无关。
以下是一些影响部署性的因素:
-
模块结构:如果您的系统将代码组织成明确定义的模块/项目,将系统分隔成易于部署的子单元,那么部署将更加容易。另一方面,如果代码组织成单体项目,只需进行一次设置步骤,那么将很难将代码部署到多节点集群中。
-
生产环境与开发环境的对比:拥有与开发环境结构非常相似的生产环境可以使部署变得简单。当环境相似时,开发人员/Devops 团队使用的相同一组脚本和工具链可以用于将系统部署到开发服务器以及生产服务器,只需进行少量更改—主要是在配置方面。
-
开发生态系统支持:拥有成熟的工具链支持系统运行时,可以自动建立和满足依赖关系的配置,可以增加部署性。像 Python 这样的编程语言在其开发生态系统中拥有丰富的支持,为 Devops 专业人员提供了丰富的工具。
-
标准化配置:保持开发和生产环境的配置结构(文件、数据库表等)相同是一个好主意。实际对象或文件名可以不同,但如果配置结构在两个环境中差异很大,部署性会降低,因为需要额外的工作来将环境的配置映射到其结构。
-
标准化基础设施:众所周知,将部署保持在同质化或标准化的基础设施集上极大地有助于部署性。例如,如果您将前端应用程序标准化为在 4GB RAM、基于 Debian 的 64 位 Linux VPS 上运行,那么很容易自动化这些节点的部署—可以使用脚本,也可以使用提供商如亚马逊的弹性计算方法—并在开发和生产环境中保持一组标准脚本。另一方面,如果您的生产部署包括异构基础设施,比如混合使用 Windows 和 Linux 服务器,容量和资源规格各不相同,那么对于每种类型的基础设施,工作量通常会增加,从而降低部署性。
-
容器的使用:容器软件的使用,由 Docker 和 Vagrant 等技术的出现所推广,已成为在服务器上部署软件的最新趋势。使用容器可以使您标准化软件,并通过减少启动/停止节点所需的开销,使部署变得更加容易,因为容器不会带来完整虚拟机的开销。这是一个值得关注的有趣趋势。
总结
在本章中,我们了解了软件架构。我们看到了软件架构的不同方面,并了解到每个架构都包括一个系统,该系统在其利益相关者的环境中运作。我们简要地看了软件架构与软件设计的区别。
我们继续研究了软件架构的各种特征,比如软件架构如何定义结构、选择核心元素并连接利益相关者。
接着,我们讨论了软件架构对组织的重要性,以及为软件系统定义正式软件架构的好处。
接下来讨论了组织中架构师的不同角色。我们看到了系统架构师在组织中扮演的各种角色,以及企业架构师的关注重点与系统架构师的不同。战略和技术广度与技术深度的关注重点通过图示得到了澄清。
然后,我们讨论了本书主题的要素——架构质量属性。我们定义了质量属性是什么,然后详细讨论了可修改性、可测试性、可扩展性/性能、安全性和可部署性等质量属性。在讨论这些属性的细节时,我们讨论了它们的定义、技术以及它们之间的关系。
有了本章作为基础,我们现在准备好去探讨这些质量属性,然后详细讨论使用 Python 编程语言实现它们的各种策略和技术。这构成了本书的其余部分。
在下一章中,我们将从本章讨论的第一个质量属性开始,即可修改性及其相关属性可读性。
第二章:编写可修改和可读的代码
在第一章中,我们讨论了软件架构的各个方面,并介绍了涉及的术语的一些定义。我们看了架构师应该关注的软件架构的不同方面。在章末,我们讨论了在构建系统时架构师应该关注的各种架构质量属性。我们详细讨论了每个属性,看了一些定义,以及在构建系统以实现这些属性时应该牢记的各种关注点。
从本章开始,我们将逐一关注这些质量属性,并逐章详细讨论它们。我们将深入研究一个属性,比如它的各种因素、实现它的技术、在编程时要牢记的方面等等。由于本书的重点是 Python 及其生态系统,我们还将查看 Python 为实现和维护这些质量属性提供的各种代码示例和第三方软件支持。
本章重点关注可修改性的质量属性。
什么是可修改性?
可修改性的架构质量属性可以定义为:
可修改性是指系统可以轻松进行更改的程度,以及系统适应这些更改的灵活性。
我们在第一章中讨论了可修改性的各个方面,如内聚、耦合等。在本章中,我们将通过一些示例更深入地挖掘这些方面。然而,在深入研究之前,看一看可修改性如何与其他与之相关的质量属性相互关联可能是个好主意。
与可修改性相关的方面
我们已经在上一章中看到了可修改性的一些方面。让我们进一步讨论一下,并看一看与可修改性密切相关的一些相关质量属性:
-
可读性:可读性可以定义为程序逻辑能够被跟随和理解的轻松程度。可读的软件是以特定风格编写的代码,遵循通常采用的编程语言的指南,并且其逻辑以简洁、清晰的方式使用语言提供的特性。
-
模块化:模块化意味着软件系统是以良好封装的模块编写的,这些模块执行非常具体、有文档记录的功能。换句话说,模块化代码为系统的其余部分提供了程序员友好的 API。可修改性与可重用性密切相关。
-
可重用性:这衡量了软件系统的各个部分(包括代码、工具、设计等)可以在系统的其他部分中零或很少修改地重复使用的数量。一个好的设计会从一开始就强调可重用性。可重用性体现在软件开发的 DRY 原则中。
-
可维护性:软件的可维护性是指系统可以被其预期的利益相关者轻松高效地更新并保持在有用状态的程度。可维护性是一个度量标准,包括可修改性、可读性、模块化和可测试性的方面。
在本章中,我们将深入研究可读性和可重用性/模块化方面。我们将从 Python 编程语言的背景下逐一查看这些方面。我们将首先从可读性开始。
理解可读性
软件系统的可读性与其可修改性密切相关。写得好、有文档记录的代码,遵循编程语言的标准或采用的实践,往往会产生简单、简洁的代码,易于阅读和修改。
可读性不仅与遵循良好的编码指南相关,而且还与逻辑的清晰程度、代码使用语言的标准特性的程度、函数的模块化程度等相关。
实际上,我们可以总结可读性的不同方面如下:
-
写得好:如果一段代码使用简单的语法,使用语言的常见特性和习语,逻辑清晰简洁,并且有意义地使用变量、函数和类/模块名称,那么它就是写得好的。
-
文档良好:文档通常指的是代码中的内联注释。一段文档良好的代码告诉它做了什么,它的输入参数(如果有的话)是什么,它的返回值(如果有的话)是什么,以及详细的逻辑或算法。它还记录了运行代码所需的任何外部库或 API 使用和配置,无论是内联还是在单独的文件中。
-
格式良好:大多数编程语言,特别是通过分布但紧密结合的编程社区在互联网上开发的开源语言,往往有良好的文档化风格指南。遵循这些缩进和格式等方面的指南的代码,往往比不遵循的代码更易读。
一般来说,不遵循这些指南的代码在可读性方面会有所欠缺。
可读性的缺乏影响了代码的可修改性,因此,维护代码的成本不断增加,主要是资源方面——主要是人力和时间——以保持系统处于有用状态。
Python 和可读性
Python 是一种从头开始设计用于可读性的语言。借用一句著名的 Python 禅语。
可读性很重要
提示
Python 的禅是影响 Python 编程语言设计的 20 个原则,其中 19 个已经被写下来。你可以通过打开 Python 解释器提示符并输入以下内容来查看 Python 的禅:
>>>import this
Python 作为一种语言,强调可读性。它通过清晰、简洁的关键字实现了这一点,这些关键字模仿了它们的英语语言对应词,使用最少的运算符,并遵循以下哲学:
应该有一种——最好只有一种——明显的方法来做到这一点。
例如,在 Python 中迭代一个序列并打印它的索引的一种方法如下:
for idx in range(len(seq)):
item = seq[idx]
print(idx, '=>', item)
然而,在 Python 中更常见的习惯是使用enumerate()辅助函数来进行迭代,它为序列中的每个项目返回一个两元组(idx,item):
for idx, item in enumerate(seq):
print(idx, '=>', item)
在许多编程语言中,如 C++、Java 或 Ruby,第一个版本将被认为与第二个版本一样好。然而,在 Python 中,有一些写代码的习惯,它们比其他一些更符合语言的原则——Python 的禅。
在这种情况下,第二个版本更接近 Python 程序员解决问题的方式。第一种方式被认为不如第二种方式 Pythonic。
当你与 Python 社区互动时,你会经常遇到“Pythonic”这个词。它意味着代码不仅解决了问题,而且遵循了 Python 社区通常遵循的约定和习惯,并且以其预期的方式使用了语言。
注意
Pythonic 的定义是主观的,但你可以把它看作是 Python 代码遵循 Python 禅的方式,或者一般来说,遵循社区采用的众所周知的惯用编程实践。
Python,根据其设计原则和清晰的语法,使得编写可读代码变得容易。然而,对于从其他更为拘谨和不太符合惯用法的语言(比如 C++或 Java)迁移到 Python 的程序员来说,以一种不太符合 Python 习惯的方式编写 Python 代码是一个常见的陷阱。例如,第一个循环的版本更可能是由从这些语言迁移到 Python 的人编写,而不是已经在 Python 中编码了一段时间的人。
对于 Python 程序员来说,早期了解这一方面是很重要的,这样你在逐渐熟悉语言的过程中就更有可能编写符合习惯或 Python 风格的代码。如果你熟悉其编码原则和习惯用法,长期来看你可以更有效地使用 Python。
可读性-反模式
总的来说,Python 鼓励并便于编写可读代码。然而,当然,说任何用 Python 编写的代码都非常可读是非常不现实的。即使具有所有可读性的 DNA,Python 也有其公平份额的难以阅读、编写不佳或难以阅读的代码,这可以通过花一些时间浏览一些在网络上用 Python 编写的公开开源代码来明显看出。
在编程语言中有一些实践往往会产生难以阅读或难以阅读的代码。这些可以被认为是反模式,不仅在 Python 编程中是一种祸害,而且在任何编程语言中都是如此:
- 几乎没有注释的代码:缺乏代码注释通常是产生难以阅读的代码的主要原因。往往程序员并没有很好地记录他们的想法,这导致了特定实现方式的难以理解。当另一个程序员或同一个程序员几个月后(这种情况经常发生!)阅读相同的代码时,很难弄清为什么采用了特定的实现方式。这使得很难推理出替代方法的利弊。
这也使得在修改代码时做出决策(也许是为了修复客户问题)变得困难,并且一般来说,会影响长期的代码可修改性。代码的注释通常是编写代码的程序员的纪律和严谨的指标,也是组织强制执行这些实践的指标。
- 违反语言最佳实践的代码:编程语言的最佳实践通常是由开发者社区多年使用该语言的经验和高效反馈所演变而来的。它们捕捉了将编程语言有效地用于解决问题的最佳方式,通常捕捉了使用该语言的习惯用法和常见模式。
例如,在 Python 中,禅可以被认为是其最佳实践和社区采用的常见编程习惯的闪亮火炬。
通常,那些经验不足或从其他编程语言或环境迁移而来的程序员往往会产生不符合这些实践的代码,因此最终编写出了可读性较低的代码。
-
编程反模式:有许多编码或编程反模式,往往会产生难以阅读,因此难以维护的代码。以下是一些众所周知的反模式:
-
意大利面代码:没有可辨识的结构或控制流的代码片段。通常是通过遵循复杂逻辑、大量无条件跳转和无结构的异常处理、设计不良的并发结构等方式产生的。
-
大泥球:一个系统,其中的代码片段没有整体结构或目标。大泥球通常由许多意大利面代码片段组成,通常是代码被多人修改多次,几乎没有文档的迹象。
-
复制粘贴编程:通常在组织中产生,其中交付速度优先于深思熟虑的设计,复制/粘贴编码会产生长而重复的代码块,基本上一遍又一遍地做同样的事情,只是进行了微小的修改。这导致代码膨胀,并且从长远来看,代码变得难以维护。
类似的反模式是模仿式编程,程序员一遍又一遍地遵循相同的设计或编程模式,而不考虑它是否适合特定的场景或问题。
- 自我编程:自我编程是指程序员——通常是经验丰富的程序员——更喜欢他个人的风格,而不是文档化的最佳实践或组织的编码风格。这有时会产生晦涩难懂的代码,对其他人——通常是年轻或经验较少的程序员来说,阅读起来困难。一个例子是倾向于在 Python 中使用函数式编程构造将所有东西写成一行的倾向。
通过在组织中采用结构化编程的实践,并强制执行编码准则和最佳实践,可以避免编码反模式。
以下是一些特定于 Python 的反模式:
- 混合缩进:Python 使用缩进来分隔代码块,因为它缺少像 C/C++或 Java 这样的语言中分隔代码块的大括号或其他语法结构。然而,在 Python 中缩进代码时需要小心。一个常见的反模式是人们在他们的 Python 代码中混合使用制表符(
\t字符)和空格。可以通过使用总是使用制表符或空格来缩进代码的编辑器来解决这个问题。
Python 自带内置模块,如tabnanny,可用于检查代码的缩进问题。
- 混合字符串文字类型:Python 提供了三种不同的创建字符串文字的方式:使用单引号(
')、双引号(")或 Python 自己特殊的三引号('''或""")。在同一段代码或功能单元中混合这三种文字类型的代码会变得更难阅读。
与之相关的字符串滥用是程序员在他们的 Python 代码中使用三引号字符串来进行内联注释,而不是使用#字符来为他们的注释添加前缀。
- 过度使用函数式构造:Python 作为一种混合范式语言,通过其 lambda 关键字和
map()、reduce()和filter()函数提供对函数式编程的支持。然而,有时,经验丰富的程序员或从函数式编程背景转到 Python 的程序员会过度使用这些构造,产生过于晦涩的代码,因此对其他程序员来说难以阅读。
可读性技巧
现在我们对提高 Python 代码的可读性的方法有了很好的了解,让我们看看我们可以采用的方法来改善 Python 代码的可读性。
记录你的代码
改善代码可读性的一个简单有效的方法是记录它的功能。文档对于代码的可读性和长期可修改性非常重要。
代码文档可以分为以下几类:
-
内联文档:程序员通过使用代码注释、函数文档、模块文档等作为代码本身的一部分来记录他的代码。这是最有效和有用的代码文档类型。
-
外部文档:这些是捕获在单独文件中的附加文档,通常记录代码的使用方式、代码更改、安装步骤、部署等方面。例如,
README、INSTALL或CHANGELOG,通常在遵循 GNU 构建原则的开源项目中找到。 -
用户手册:这些是正式文件,通常由专门的人或团队编写,使用图片和通常面向系统用户的文本。这种文档通常在软件项目结束时准备和交付,当产品稳定并准备发货时。我们在这里的讨论中不关心这种类型的文档。
Python 是一种从头开始设计的智能内联代码文档的语言。在 Python 中,内联文档可以在以下级别完成:
- 代码注释:这是与代码一起的内联文本,以井号(
#)字符为前缀。它们可以在代码内部自由使用,解释代码的每个步骤。
这是一个例子:
# This loop performs a network fetch of the URL, retrying upto 3
# times in case of errors. In case the URL cant be fetched,
# an error is returned.
# Initialize all state
count, ntries, result, error = 0, 3, None, None
while count < ntries:
try:
# NOTE: We are using an explicit timeout of 30s here
result = requests.get(url, timeout=30)
except Exception as error:
print('Caught exception', error, 'trying again after a while')
# increment count
count += 1
# sleep 1 second every time
time.sleep(1)
if result == None:
print("Error, could not fetch URL",url)
# Return a tuple of (<return code>, <lasterror>)
return (2, error)
# Return data of URL
return result.content
即使在可能被认为是多余的地方,也要大量使用注释。我们稍后将看一些关于在代码中添加注释的一般规则。
- 函数文档字符串:Python 提供了一种简单的方法,通过在函数定义的下方使用字符串文字来记录函数的功能。这可以通过使用三种风格的字符串文字之一来完成。
这是一个例子:
def fetch_url(url, ntries=3, timeout=30):
" Fetch a given url and return its contents "
# This loop performs a network fetch of the URL, retrying
# upto
# 3 times in case of errors. In case the URL cant be
# fetched,
# an error is returned.
# Initialize all state
count, result, error = 0, None, None
while count < ntries:
try:
result = requests.get(url, timeout=timeout)
except Exception as error:
print('Caught exception', error, 'trying again after a while')
# increment count
count += 1
# sleep 1 second every time
time.sleep(1)
if result == None:
print("Error, could not fetch URL",url)
# Return a tuple of (<return code>, <lasterror>)
return (2, error)
# Return data of URL
return result.content
函数文档字符串是一行,其中写着获取给定 URL 并返回其内容。然而,尽管它很有用,但使用范围有限,因为它只说明函数的功能,而不解释其参数。这里是一个改进版本:
def fetch_url(url, ntries=3, timeout=30):
""" Fetch a given url and return its contents.
@params
url - The URL to be fetched.
ntries - The maximum number of retries.
timeout - Timout per call in seconds.
@returns
On success - Contents of URL.
On failure - (error_code, last_error)
"""
# This loop performs a network fetch of the URL,
# retrying upto
# 'ntries' times in case of errors. In case the URL
# cant be
# fetched, an error is returned.
# Initialize all state
count, result, error = 0, None, None
while count < ntries:
try:
result = requests.get(url, timeout=timeout)
except Exception as error:
print('Caught exception', error, 'trying again after a while')
# increment count
count += 1
# sleep 1 second every time
time.sleep(1)
if result == None:
print("Error, could not fetch URL",url)
# Return a tuple of (<return code>, <lasterror>)
return (2, error)
# Return data of the URL
return result.content
在前面的代码中,函数的使用对于计划导入其定义并在其代码中使用的程序员来说变得更加清晰。请注意,这种扩展文档通常会跨越多行,因此,始终使用三引号与函数文档字符串是一个好主意。
- 类文档字符串:这些与函数文档字符串的工作方式相同,只是它们直接为类提供文档。这是在定义类的关键字下方提供的。
这是一个例子:
class UrlFetcher(object):
""" Implements the steps of fetching a URL.
Main methods:
fetch - Fetches the URL.
get - Return the URLs data.
"""
def __init__(self, url, timeout=30, ntries=3, headers={}):
""" Initializer.
@params
url - URL to fetch.
timeout - Timeout per connection (seconds).
ntries - Max number of retries.
headers - Optional request headers.
"""
self.url = url
self.timeout = timeout
self.ntries = retries
self.headers = headers
# Enapsulated result object
self.result = result
def fetch(self):
""" Fetch the URL and save the result """
# This loop performs a network fetch of the URL,
# retrying
# upto 'ntries' times in case of errors.
count, result, error = 0, None, None
while count < self.ntries:
try:
result = requests.get(self.url,
timeout=self.timeout,
headers = self.headers)
except Exception as error:
print('Caught exception', error, 'trying again after a while')
# increment count
count += 1
# sleep 1 second every time
time.sleep(1)
if result != None:
# Save result
self.result = result
def get(self):
""" Return the data for the URL """
if self.result != None:
return self.result.content
查看类文档字符串如何定义类的一些主要方法。这是一个非常有用的做法,因为它在顶层为程序员提供了有用的信息,而无需去检查每个函数的文档。
- 模块文档字符串:模块文档字符串在模块级别捕获信息,通常是关于模块功能的信息以及模块的每个成员(函数、类和其他)的一些详细信息。语法与类或函数文档字符串相同。这些信息通常在模块代码的开头捕获。
如果模块文档还可以捕获模块的任何特定外部依赖项,如果它们不是非常明显的话,例如,导入一个不太常用的第三方包:
"""
urlhelper - Utility classes and functions to work with URLs.
Members:
# UrlFetcher - A class which encapsulates action of
# fetching
content of a URL.
# get_web_url - Converts URLs so they can be used on the
# web.
# get_domain - Returns the domain (site) of the URL.
"""
import urllib
def get_domain(url):
""" Return the domain name (site) for the URL"""
urlp = urllib.parse.urlparse(url)
return urlp.netloc
def get_web_url(url, default='http'):
""" Make a URL useful for fetch requests
- Prefix network scheme in front of it if not present already
"""
urlp = urllib.parse.urlparse(url)
if urlp.scheme == '' and urlp.netloc == '':
# No scheme, prefix default
return default + '://' + url
return url
class UrlFetcher(object):
""" Implements the steps of fetching a URL.
Main methods:
fetch - Fetches the URL.
get - Return the URLs data.
"""
def __init__(self, url, timeout=30, ntries=3, headers={}):
""" Initializer.
@params
url - URL to fetch.
timeout - Timeout per connection (seconds).
ntries - Max number of retries.
headers - Optional request headers.
"""
self.url = url
self.timeout = timeout
self.ntries = retries
self.headers = headers
# Enapsulated result object
self.result = result
def fetch(self):
""" Fetch the URL and save the result """
# This loop performs a network fetch of the URL, retrying
# upto 'ntries' times in case of errors.
count, result, error = 0, None, None
while count < self.ntries:
try:
result = requests.get(self.url,
timeout=self.timeout,
headers = self.headers)
except Exception as error:
print('Caught exception', error, 'trying again after a while')
# increment count
count += 1
# sleep 1 second every time
time.sleep(1)
if result != None:
# Save result
self.result = result
def get(self):
""" Return the data for the URL """
if self.result != None:
return self.result.content
遵循编码和风格指南
大多数编程语言都有一个相对知名的编码和/或风格指南。这些要么是作为惯例多年使用而形成的,要么是作为该编程语言在线社区讨论的结果。C/C++是前者的一个很好的例子,Python 是后者的一个很好的例子。
公司通常会制定自己的指南,大多数情况下是通过采用现有的标准指南,并根据公司自己的特定开发环境和要求进行定制。
对于 Python,Python 编程社区发布了一套清晰的编码风格指南。这个指南被称为 PEP-8,可以在线作为 Python 增强提案(PEP)文档的一部分找到。
注意
您可以在以下网址找到 PEP-8:
www.python.org/dev/peps/pep-0008/。
PEP-8 首次创建于 2001 年,自那时以来已经经历了多次修订。主要作者是 Python 的创始人 Guido Van Rossum,Barry Warsaw 和 Nick Coghlan 提供了输入。
PEP-8 是通过调整 Guido 的原始Python 风格指南并加入 Barry 的风格指南而创建的。
我们不会在本书中深入讨论 PEP-8,因为本节的目标不是教你 PEP-8。然而,我们将讨论 PEP-8 的基本原则,并列出一些其主要建议。
PEP-8 的基本原则可以总结如下:
-
代码被阅读的次数比被编写的次数要多。因此,提供一个准则会使代码更易读,并使其在整个 Python 代码的全谱上保持一致。
-
项目内的一致性很重要。但是,在一个模块或包内的一致性更重要。在代码单元内(如类或函数)的一致性是最重要的。
-
知道何时忽略一个准则。例如,如果采用该准则使您的代码变得不太可读,破坏了周围的代码,或者破坏了代码的向后兼容性,那么可能会发生这种情况。学习示例,并选择最好的。
-
如果一个准则对您的组织不直接适用或有用,那么自定义它。如果您对某个准则有任何疑问,请向乐于助人的 Python 社区寻求澄清。
我们不会在这里详细介绍 PEP-8 准则。有兴趣的读者可以参考在线文档,使用这里提供的 URL。
审查和重构代码
代码需要维护。在生产中使用的未维护的代码可能会成为一个问题,如果不定期处理,可能会变成一个噩梦。
定期安排代码审查非常有助于保持代码的可读性和良好的健康,有助于可修改性和可维护性。在生产中对系统或应用程序至关重要的代码往往会随着时间的推移得到许多快速修复,因为它被定制或增强以适应不同的用例或为问题打补丁。观察到程序员通常不会记录这些快速修复(称为“补丁”或“热修复”),因为时间要求通常会加速立即测试和部署,而不是遵循良好的工程实践,如文档和遵循准则!
随着时间的推移,这样的补丁可能会积累,从而导致代码膨胀,并为团队创造巨大的未来工程债务,这可能会成为一项昂贵的事务。解决方案是定期审查。
审查应该由熟悉应用程序的工程师进行,但不一定要在同一段代码上工作。这给了代码一个新鲜的视角,通常有助于发现原始作者可能忽视的错误。最好让经验丰富的开发人员对大的更改进行审查。
这可以与代码的一般重构结合起来,以改进实现,减少耦合,或增加内聚。
注释代码
我们即将结束对代码可读性的讨论,现在是介绍一些编写代码注释时要遵循的一般经验法则的好时机。这些可以列举如下:
- 注释应该是描述性的,并解释代码。一个简单重复函数名称显而易见的注释并不是很有用。
这是一个例子。以下两个代码都展示了相同的均方根(RMS)速度计算实现,但第二个版本比第一个版本有一个更有用的docstring:
def rms(varray=[]):
""" RMS velocity """
squares = map(lambda x: x*x, varray)
return pow(sum(squares), 0.5)
def rms(varray=[]):
""" Root mean squared velocity. Returns
square root of sum of squares of velocities """
squares = map(lambda x: x*x, varray)
return pow(sum(squares), 0.5)
- 代码注释应该写在我们正在评论的代码块中,而不是像下面这样:
# This code calculates the sum of squares of velocities
squares = map(lambda x: x*x, varray)
前一个版本比下一个版本更清晰,下一个版本使用了代码下面的注释,因为它符合从上到下的自然阅读顺序。
squares = map(lambda x: x*x, varray)
# The above code calculates the sum of squares of velocities
- 尽量少使用内联注释。这是因为很容易将其混淆为代码本身的一部分,特别是如果分隔注释字符被意外删除,导致错误:
# Not good !
squares = map(lambda x: x*x, varray) # Calculate squares of velocities
- 尽量避免多余的、增加很少价值的注释:
# The following code iterates through odd numbers
for num in nums:
# Skip if number is odd
if num % 2 == 0: continue
在最后一段代码中,第二条评论增加了很少的价值,可以省略。
可修改性的基本原则-内聚性和耦合性
现在让我们回到代码可修改性的主题,并讨论影响代码可修改性的两个基本方面,即内聚性和耦合。
我们已经在第一章中简要讨论了这些概念。让我们在这里进行快速回顾。
内聚指的是模块的责任之间的紧密关联程度。执行特定任务或一组相关任务的模块具有高内聚性。如果一个模块在没有考虑核心功能的情况下堆积了大量功能,那么它的内聚性就会很低。
耦合是模块 A 和 B 的功能相关程度。如果两个模块的功能在代码级别(在函数或方法调用方面)有很强的重叠,那么它们就是强耦合的。对模块 A 的任何更改可能需要对模块 B 进行更改。
强耦合对可修改性总是具有禁止作用,因为它增加了维护代码库的成本。
旨在提高可修改性的代码应该追求高内聚性和低耦合性。
我们将在以下各小节中通过一些例子分析内聚和耦合。
衡量内聚和耦合
让我们来看一个简单的例子,有两个模块,以找出如何定量地衡量耦合和内聚。以下是模块 A 的代码,据称实现了对一系列(数组)数字进行操作的函数:
"" Module A (a.py) – Implement functions that operate on series of numbers """
def squares(narray):
""" Return array of squares of numbers """
return pow_n(array, 2)
def cubes(narray):
""" Return array of cubes of numbers """
return pow_n(narray, 3)
def pow_n(narray, n):
""" Return array of numbers raised to arbitrary power n each """
return [pow(x, n) for x in narray]
def frequency(string, word):
""" Find the frequency of occurrences of word in string
as percentage """
word_l = word.lower()
string_l = string.lower()
# Words in string
words = string_l.split()
count = w.count(word_l)
# Return frequency as percentage
return 100.0*count/len(words)
接下来是模块 B 的列表。
""" Module B (b.py) – Implement functions provide some statistical methods """
import a
def rms(narray):
""" Return root mean square of array of numbers"""
return pow(sum(a.squares(narray)), 0.5)
def mean(array):
""" Return mean of an array of numbers """
return 1.0*sum(array)/len(array)
def variance(array):
""" Return variance of an array of numbers """
# Square of variation from mean
avg = mean(array)
array_d = [(x – avg) for x in array]
variance = sum(a.squares(array_d))
return variance
def standard_deviation(array):
""" Return standard deviation of an array of numbers """
# S.D is square root of variance
return pow(variance(array), 0.5)
让我们对模块 A 和 B 中的函数进行分析。以下是报告:
| 模块 | 核心功能 | 无关功能 | 函数依赖 |
|---|---|---|---|
| B | 4 | 0 | 3 x 1 = 3 |
| A | 3 | 1 | 0 |
这有四个函数,可以解释如下:
-
模块 B 有四个函数,所有这些函数都涉及核心功能。在这个模块中没有与核心功能无关的函数。模块 B 的内聚性为 100%。
-
模块 A 有四个函数,其中三个与其核心功能相关,但最后一个(frequency)不相关。这使得模块 A 的内聚性约为
75%。 -
模块 B 的三个函数依赖于模块 A 中的一个函数,即 square。这使得模块 B 与模块 A 强耦合。从模块 B 到 A 的函数级耦合为75%。
-
模块 A 不依赖于模块 B 的任何功能。模块 A 将独立于模块 B 工作。从模块 A 到 B 的耦合为零。
现在让我们看看如何改进模块 A 的内聚性。在这种情况下,简单地删除最后一个实际上不属于那里的函数就可以了。它可以完全删除或移动到另一个模块。
以下是重写后的模块 A 代码,现在在责任方面具有 100%的内聚性:
""" Module A (a.py) – Implement functions that operate on series of numbers """
def squares(narray):
""" Return array of squares of numbers """
return pow_n(array, 2)
def cubes(narray):
""" Return array of cubes of numbers """
return pow_n(narray, 3)
def pow_n(narray, n):
""" Return array of numbers raised to arbitrary power n each """
return [pow(x, n) for x in narray]
现在让我们分析从模块 B 到 A 的耦合质量,并查看与 A 中的代码相关的 B 代码的可修改性风险因素,如下所示:
-
B 中的三个函数仅依赖于模块 A 中的一个函数。
-
该函数名为 squares,它接受一个数组并返回每个成员的平方。
-
函数签名(API)很简单,因此将来更改函数签名的可能性较小。
-
系统中没有双向耦合。依赖仅来自 B 到 A 的方向。
换句话说,尽管从 B 到 A 存在强耦合,但这是良好的耦合,并且不会以任何方式影响系统的可修改性。
现在让我们看另一个例子。
衡量内聚和耦合 - 字符串和文本处理
现在让我们考虑一个不同的用例,一个涉及大量字符串和文本处理的函数的例子:
""" Module A (a.py) – Provides string processing functions """
import b
def ntimes(string, char):
""" Return number of times character 'char'
occurs in string """
return string.count(char)
def common_words(text1, text2):
""" Return common words across text1 and text2"""
# A text is a collection of strings split using newlines
strings1 = text1.split("\n")
strings2 = text2.split("\n")
common = []
for string1 in strings1:
for string2 in strings2:
common += b.common(string1, string2)
# Drop duplicates
return list(set(common))
接下来是模块 B 的列表,如下所示:
""" Module B (b.py) – Provides text processing functions to user """
import a
def common(string1, string2):
""" Return common words across strings1 1 & 2 """
s1 = set(string1.lower().split())
s2 = set(string2.lower().split())
return s1.intersection(s2)
def common_words(text1, text2):
""" Return common words across two input files """
lines1 = open(filename1).read()
lines2 = open(filename2).read()
return a.common_words(lines1, lines2)
让我们来看一下这些模块的耦合和内聚分析,如下表所示:
| 模块 | 核心功能 | 无关功能 | 函数依赖 |
|---|---|---|---|
| B | 2 | 0 | 1 x 1 = 1 |
| A | 2 | 0 | 1 x 1 = 1 |
以下是表中这些数字的解释:
-
模块 A 和 B 各有两个函数,每个函数都处理核心功能。模块 A 和 B 都具有*100%*的内聚。
-
模块 A 的一个函数依赖于模块 B 的一个函数。同样,模块 B 的一个函数依赖于模块 A 的一个函数。从 A 到 B 有强耦合,从 B 到 A 也是如此。换句话说,耦合是双向的。
两个模块之间的双向耦合会使它们的可修改性之间产生非常强烈的联系。模块 A 的任何更改都会迅速影响模块 B 的行为,反之亦然。换句话说,这是不好的耦合。
探索可修改性的策略
现在我们已经看到了一些好的和坏的耦合和内聚的例子,让我们来看看软件设计师或架构师可以使用的策略和方法,以减少这些方面对可修改性的影响,从而改进软件系统的可修改性。
提供明确的接口
一个模块应该标记一组函数、类或方法作为其提供给外部代码的接口。这可以被视为该模块的 API,从中导出。使用此 API 的任何外部代码都将成为该模块的客户端。
模块认为是其内部功能的方法或函数,不构成其 API 的,应该明确地作为模块的私有部分,或者应该被记录为这样的部分。
在 Python 中,函数或类方法没有提供变量访问范围,可以通过约定来实现,例如在函数名前加上单下划线或双下划线,从而向潜在客户表明这些函数是内部函数,不应该从外部引用。
减少双向依赖
如前面的例子所示,如果耦合方向是单向的,那么两个软件模块之间的耦合是可以管理的。然而,双向耦合会在模块之间创建非常强的联系,这可能会使模块的使用复杂化,并增加其维护成本。
在像 Python 这样使用基于引用的垃圾收集的语言中,这也可能为变量和对象创建难以理解的引用循环,从而使它们的垃圾收集变得困难。
通过重构代码的方式打破双向依赖,使一个模块始终使用另一个模块,而不是反之。换句话说,将所有相关函数封装在同一个模块中。
以下是我们之前例子中的模块 A 和 B,重写以打破它们的双向依赖:
""" Module A (a.py) – Provides string processing functions """
def ntimes(string, char):
""" Return number of times character 'char'
occurs in string """
return string.count(char)
def common(string1, string2):
""" Return common words across strings1 1 & 2 """
s1 = set(string1.lower().split())
s2 = set(string2.lower().split())
return s1.intersection(s2)
def common_words(text1, text2):
""" Return common words across text1 and text2"""
# A text is a collection of strings split using newlines
strings1 = text1.split("\n")
strings2 = text2.split("\n")
common_w = []
for string1 in strings1:
for string2 in strings2:
common_w += common(string1, string2)
return list(set(common_w))
接下来是模块 B 的清单。
""" Module B (b.py) – Provides text processing functions to user """
import a
def common_words(filename1, filename2):
""" Return common words across two input files """
lines1 = open(filename1).read()
lines2 = open(filename2).read()
return a.common_words(lines1, lines2)
我们通过简单地将模块 B 中从两个字符串中选择共同单词的函数common移动到模块 A 来实现这一点。这是改进可修改性的重构的一个例子。
抽象共同服务
使用抽象共同函数和方法的辅助模块可以减少两个模块之间的耦合,并增加它们的内聚。例如,在第一个例子中,模块 A 充当了模块 B 的辅助模块。在第二个例子中,重构后,模块 A 也充当了模块 B 的辅助模块。
辅助模块可以被视为中介或调解者,它们为其他模块提供共同服务,以便依赖代码都在一个地方进行抽象,避免重复。它们还可以通过将不需要或不相关的函数移动到合适的辅助模块来帮助模块增加它们的内聚。
使用继承技术
当我们发现类中出现相似的代码或功能时,可能是时候对其进行重构,以创建类层次结构,以便通过继承共享公共代码。
让我们看下面的例子:
""" Module textrank - Rank text files in order of degree of a specific word frequency. """
import operator
class TextRank(object):
""" Accept text files as inputs and rank them in
terms of how much a word occurs in them """
def __init__(self, word, *filenames):
self.word = word.strip().lower()
self.filenames = filenames
def rank(self):
""" Rank the files. A tuple is returned with
(filename, #occur) in decreasing order of
occurences """
occurs = []
for fpath in self.filenames:
data = open(fpath).read()
words = map(lambda x: x.lower().strip(), data.split())
# Filter empty words
count = words.count(self.word)
occurs.append((fpath, count))
# Return in sorted order
return sorted(occurs, key=operator.itemgetter(1), reverse=True)
这里是另一个模块urlrank,它在 URL 上执行相同的功能:
""" Module urlrank - Rank URLs in order of degree of a specific word frequency """
import operator
import operator
import requests
class UrlRank(object):
""" Accept URLs as inputs and rank them in
terms of how much a word occurs in them """
def __init__(self, word, *urls):
self.word = word.strip().lower()
self.urls = urls
def rank(self):
""" Rank the URLs. A tuple is returned with
(url, #occur) in decreasing order of
occurences """
occurs = []
for url in self.urls:
data = requests.get(url).content
words = map(lambda x: x.lower().strip(), data.split())
# Filter empty words
count = words.count(self.word)
occurs.append((url, count))
# Return in sorted order
return sorted(occurs, key=operator.itemgetter(1), reverse=True)
这两个模块都执行类似的功能,即根据给定关键字在一组输入数据中出现的频率对其进行排名。随着时间的推移,这些类可能会开发出许多相似的功能,组织可能会出现大量重复的代码,降低了可修改性。
我们可以使用继承来帮助我们在父类中抽象出通用逻辑。这里是名为RankBase的父类,通过将所有通用代码移动到自身来实现这一点:
""" Module rankbase - Logic for ranking text using degree of word frequency """
import operator
class RankBase(object):
""" Accept text data as inputs and rank them in
terms of how much a word occurs in them """
def __init__(self, word):
self.word = word.strip().lower()
def rank(self, *texts):
""" Rank input data. A tuple is returned with
(idx, #occur) in decreasing order of
occurences """
occurs = {}
for idx,text in enumerate(texts):
# print text
words = map(lambda x: x.lower().strip(), text.split())
count = words.count(self.word)
occurs[idx] = count
# Return dictionary
return occurs
def sort(self, occurs):
""" Return the ranking data in sorted order """
return sorted(occurs, key=operator.itemgetter(1), reverse=True)
现在我们已经重写了textrank和urlrank模块,以利用父类中的逻辑:
""" Module textrank - Rank text files in order of degree of a specific word frequency. """
import operator
from rankbase import RankBase
class TextRank(object):
""" Accept text files as inputs and rank them in
terms of how much a word occurs in them """
def __init__(self, word, *filenames):
self.word = word.strip().lower()
self.filenames = filenames
def rank(self):
""" Rank the files. A tuple is returned with
(filename, #occur) in decreasing order of
occurences """
texts = map(lambda x: open(x).read(), self.filenames)
occurs = super(TextRank, self).rank(*texts)
# Convert to filename list
occurs = [(self.filenames[x],y) for x,y in occurs.items()]
return self.sort(occurs)
这是urlrank模块的修改列表:
""" Module urlrank - Rank URLs in order of degree of a specific word frequency """
import requests
from rankbase import RankBase
class UrlRank(RankBase):
""" Accept URLs as inputs and rank them in
terms of how much a word occurs in them """
def __init__(self, word, *urls):
self.word = word.strip().lower()
self.urls = urls
def rank(self):
""" Rank the URLs. A tuple is returned with
(url, #occur) in decreasing order of
occurences"""
texts = map(lambda x: requests.get(x).content, self.urls)
# Rank using a call to parent class's 'rank' method
occurs = super(UrlRank, self).rank(*texts)
# Convert to URLs list
occurs = [(self.urls[x],y) for x,y in occurs.items()]
return self.sort(occurs)
重构不仅减少了每个模块中代码的大小,还通过将通用代码抽象到父模块/类中,从而改善了类的可修改性,这可以独立开发。
使用后期绑定技术
后期绑定是指尽可能晚地将值绑定到代码执行顺序中的参数的做法。后期绑定允许程序员推迟影响代码执行的因素,从而推迟执行结果和代码性能,通过使用多种技术。
以下是一些可以使用的后期绑定技术:
-
插件机制:这种技术使用在运行时解析的值来加载插件,执行特定的依赖代码,而不是静态地将模块绑定在一起,这会增加耦合。插件可以是 Python 模块,其名称在运行时的计算中获取,也可以是从数据库查询或配置文件中加载的 ID 或变量名称。
-
经纪人/注册表查找服务:某些服务可以完全推迟到经纪人,经纪人根据需要从注册表中查找服务名称,并动态调用并返回结果。例如,货币兑换服务可以接受特定货币转换作为输入(例如 USDINR),并在运行时动态查找和配置服务,因此系统在任何时候只需要相同的代码执行。由于系统上没有依赖于输入的代码,系统不会受到任何变化的影响,因此它被推迟到外部服务。
-
通知服务:发布/订阅机制在对象值发生变化或事件发布时通知订阅者,对于将系统与易变参数及其值解耦非常有用。这些系统不会在内部跟踪这些变量/对象的变化,而是将客户端仅绑定到外部 API,该 API 仅通知客户端值的变化。
-
部署时间绑定:通过将变量值与名称或 ID 关联到配置文件中,我们可以将对象/变量绑定推迟到部署时间。软件系统在启动时通过加载配置文件绑定值,然后调用创建适当对象的代码路径。
这种方法可以与工厂等面向对象模式结合使用,工厂可以在运行时根据名称或 ID 创建所需的对象,从而使依赖于这些对象的客户端免受任何内部更改的影响,增加了它们的可修改性。
- 使用创建型模式:创建型设计模式(如工厂或生成器)将对象的创建任务与创建细节抽象出来,非常适合客户端模块的关注点分离,这些模块不希望在创建依赖对象的代码发生更改时修改它们的代码。
这些方法与部署/配置时间或动态绑定(使用查找服务)相结合,可以极大地增加系统的灵活性,并帮助其可修改性。
我们将在本书的后面章节中看一些 Python 模式的例子。
度量标准-静态分析工具
静态代码分析工具可以提供关于代码静态属性的丰富摘要信息,可以提供有关代码复杂性和可修改性/可读性等方面的见解。
Python 有很多第三方工具支持,可以帮助衡量 Python 代码的静态方面,比如:
-
遵守编码规范,如 PEP-8
-
像 McCabe 度量这样的代码复杂度指标
-
代码中的错误,如语法错误、缩进问题、缺少导入、变量覆盖等
-
代码中的逻辑问题
-
代码异味
以下是 Python 生态系统中一些最流行的工具,可以进行静态分析:
-
Pylint:Pylint 是 Python 代码的静态检查器,可以检测一系列的编码错误、代码异味和风格错误。Pylint 使用接近 PEP-8 的风格。较新版本的 Pylint 还提供有关代码复杂性的统计信息,并可以打印报告。Pylint 要求在检查代码之前执行代码。您可以参考
pylint.org链接。 -
Pyflakes:Pyflakes 是一个比 Pylint 更新的项目。它与 Pylint 的不同之处在于,在检查代码错误之前,它不需要执行代码。Pyflakes 不检查编码风格错误,只在代码中执行逻辑检查。您可以参考
launchpad.net/pyflakes链接。 -
McCabe:这是一个检查并打印代码 McCabe 复杂度报告的脚本。您可以参考
pypi.python.org/pypi/mccabe链接。 -
Pycodestyle:Pycodestyle 是一个检查 Python 代码是否符合 PEP-8 指南的工具。这个工具以前被称为 PEP-8。请参考
github.com/PyCQA/pycodestyle链接。 -
Flake8:Flake8 是 Pyflakes、McCabe 和 pycodestyle 工具的包装器,可以执行一些检查,包括这些工具提供的检查。请参考
gitlab.com/pycqa/flake8/链接。
什么是代码异味?
代码异味是代码中更深层次问题的表面症状。它们通常表明设计上存在问题,可能会导致将来的错误或对特定代码段的开发产生负面影响。
代码异味本身并不是错误,而是指示代码解决问题的方法不正确,并且应该通过重构来修复的模式。
一些常见的代码异味包括:
在类级别:
-
God Object:一个试图做太多事情的类。简而言之,这个类缺乏任何形式的内聚性。
-
Constant Class:一个仅仅是常量集合的类,被其他地方使用,因此理想情况下不应该存在于这里。
-
Refused Bequest:一个不遵守基类合同的类,因此违反了继承的替换原则。
-
Freeloader:一个函数太少的类,几乎什么都不做,价值很小。
-
Feature Envy:一个过度依赖另一个类方法的类,表明耦合度很高。
在方法/函数级别:
-
Long method:一个变得太大和复杂的方法或函数。
-
Parameter creep:函数或方法的参数太多。这使得函数的可调用性和可测试性变得困难。
-
圈复杂度:具有太多分支或循环的函数或方法,这会导致复杂的逻辑难以跟踪,并可能导致微妙的错误。这样的函数应该被重构并拆分为多个函数,或者重写逻辑以避免过多的分支。
-
过长或过短的标识符:使用过长或过短的变量名的函数,使得它们的用途无法从它们的名称中清楚地看出。对函数名称也适用相同的规则。
与代码异味相关的反模式是设计异味,这些是系统设计中的表面症状,表明架构中存在更深层次的问题。
圈复杂度 – McCabe 度量
圈复杂度是计算机程序复杂性的一种度量。它被计算为程序源代码从开始到结束的线性独立路径的数量。
对于没有任何分支的代码片段,例如接下来给出的代码,圈复杂度将为1,因为代码只有一条路径。
""" Module power.py """
def power(x, y):
""" Return power of x to y """
return x^y
具有一个分支的代码片段,如下面的代码,复杂度将为 2:
""" Module factorial.py """
def factorial(n):
""" Return factorial of n """
if n == 0:
return 1
else:
return n*factorial(n-1)
使用代码的控制图作为度量标准的圈复杂度是由 Thomas J. McCabe 于 1976 年开发的。因此,它也被称为 McCabe 复杂度或 McCabe 指数。
为了测量度量标准,控制图可以被描绘为一个有向图,其中节点表示程序的块,边表示从一个块到另一个块的控制流。
关于程序的控制图,McCabe 复杂度可以表示如下:
M = E - N + 2P
其中,
E => 图中的边数
N => 图中的节点数
P => 图中的连通分量数
在 Python 中,可以使用由 Ned Batcheldor 编写的mccabe包来测量程序的圈复杂度。它可以作为独立模块使用,也可以作为 Flake8 或 Pylint 等程序的插件使用。
例如,这里是我们如何测量前面给出的两个代码片段的圈复杂度:
一些示例 Python 程序的 McCabe 度量
参数–min告诉mccabe模块从给定的 McCabe 指数开始测量和报告。
测试度量标准
现在让我们尝试一下前面提到的一些工具,并在一个示例模块上使用它们,以找出这些工具报告了什么样的信息。
注意
以下各节的目的不是教授读者如何使用这些工具或它们的命令行选项——这些可以通过工具的文档来学习。相反,目的是探索这些工具在代码的样式、逻辑和其他问题方面提供的深度和丰富信息。
为了进行这项测试,使用了以下人为构造的模块示例。它故意写有许多编码错误、样式错误和编码异味。
由于我们使用的工具按行号列出错误,因此代码已经被呈现为带有编号的行,以便轻松地将工具的输出追溯到代码:
1 """
2 Module metrictest.py
3
4 Metric example - Module which is used as a testbed for static checkers.
5 This is a mix of different functions and classes doing different things.
6
7 """
8 import random
9
10 def fn(x, y):
11 """ A function which performs a sum """
12 return x + y
13
14 def find_optimal_route_to_my_office_from_home(start_time,
15 expected_time,
16 favorite_route='SBS1K',
17 favorite_option='bus'):
18
19 # If I am very late, always drive.
20 d = (expected_time – start_time).total_seconds()/60.0
21
22 if d<=30:
23 return 'car'
24
25 # If d>30 but <45, first drive then take metro
26 if d>30 and d<45:
27 return ('car', 'metro')
28
29 # If d>45 there are a combination of options
30 if d>45:
31 if d<60:
32 # First volvo,then connecting bus
33 return ('bus:335E','bus:connector')
34 elif d>80:
35 # Might as well go by normal bus
36 return random.choice(('bus:330','bus:331',':'.join((favorite_option,
37 favorite_route))))
38 elif d>90:
39 # Relax and choose favorite route
40 return ':'.join((favorite_option,
41 favorite_route))
42
43
44 class C(object):
45 """ A class which does almost nothing """
46
47 def __init__(self, x,y):
48 self.x = x
49 self.y = y
50
51 def f(self):
52 pass
53
54 def g(self, x, y):
55
56 if self.x>x:
57 return self.x+self.y
58 elif x>self.x:
59 return x+ self.y
60
61 class D(C):
62 """ D class """
63
64 def __init__(self, x):
65 self.x = x
66
67 def f(self, x,y):
68 if x>y:
69 return x-y
70 else:
71 return x+y
72
73 def g(self, y):
74
75 if self.x>y:
76 return self.x+y
77 else:
78 return y-self.x
运行静态检查器
让我们看看 Pylint 对我们相当可怕的测试代码的看法。
注意
Pylint 打印了许多样式错误,但由于这个示例的目的是专注于逻辑问题和代码异味,因此日志只显示了从这些报告开始。
$ pylint –reports=n metrictest.py
以下是两个截图中捕获的详细输出:
图 2. 测量测试程序的 Pylint 输出(第 1 页)
再看另一张截图:
测量测试程序的 Pylint 输出(第 2 页)
让我们专注于 Pylint 报告的最后 10-20 行非常有趣的部分,跳过早期的样式和约定警告。
以下是分类为表格的错误。为了保持表格的简洁,我们已跳过了类似的情况:
| 错误 | 出现次数 | 说明 | 代码异味类型 |
|---|---|---|---|
| 无效的函数名称 | 函数fn | 名称fn太短,无法解释函数的作用 | 标识符太短 |
| 无效的变量名称 | 函数f的变量x,y | 名称x,y太短,无法指示变量代表什么 | 标识符太短 |
| 无效的函数名称 | 函数名称find_optimal_route_to_my_office_from_home | 函数名称太长 | 标识符太长 |
| 无效的变量名称 | 函数find_optimal的变量d | 名称d太短,无法指示变量代表什么 | 标识符太短 |
| 无效的类名 | 类C | 名称C不表明类的任何信息 | 标识符太短 |
| 无效的方法名称 | 类C:方法f | 名称f太短,无法解释其作用 | 标识符太短 |
无效的__init__方法 | 类D:方法__init__ | 不调用基类的__init__ | 与基类的合同违约 |
f的参数在类D中与类C不同 | 类D:方法f | 方法签名与基类的签名不符 | 拒绝继承 |
g的参数在类D中与类C不同 | 类D:方法g | 方法签名与基类的签名不符 | 拒绝继承 |
正如您所看到的,Pylint 检测到了许多代码异味,我们在上一节中讨论过。其中一些最有趣的是它如何检测到荒谬的长函数名称,以及子类 D 如何在其__init__和其他方法中违反了与基类C的合同。
让我们看看flake8对我们的代码有什么看法。我们将运行它以报告错误计数的统计和摘要:
$ flake8 --statistics --count metrictest.py
图 4。度量测试程序的 flake8 静态检查输出
正如您所期望的那样,这个工具大部分是按照 PEP-8 约定编写的,报告的错误都是样式和约定错误。这些错误对于提高代码的可读性并使其更接近 PEP-8 的样式指南是有用的。
注意
通过将选项“-show-pep8”传递给 Flake8,您可以获取有关 PEP-8 测试的更多信息。
现在是检查我们的代码复杂性的好时机。首先,我们将直接使用mccabe,然后通过 Flake8 调用它:
度量测试程序的 mccabe 复杂度
正如预期的那样,办公室路线函数的复杂性太高,因为它有太多的分支和子分支。
由于flake8打印了太多的样式错误,我们将专门搜索复杂性的报告:
由 flake8 报告的度量测试程序的 mccabe 复杂度
正如预期的那样,Flake8 报告了函数find_optimal_route_to_my_office_from_home的复杂性太高。
注意
还有一种方法可以从 Pylint 作为插件运行mccabe,但由于涉及一些配置步骤,我们将不在这里介绍。
最后一步,让我们在代码上运行pyflakes:
pyflakes 对度量测试代码的静态分析输出
没有输出!因此,Pyflakes 在代码中找不到任何问题。原因是 PyFlakes 是一个基本的检查器,除了明显的语法和逻辑错误、未使用的导入、缺少变量名称和类似的问题外,它不报告任何其他问题。
让我们在我们的代码中添加一些错误,然后重新运行 Pyflakes。以下是带有行号的调整后的代码:
1 """
2 Module metrictest.py
3
4 Metric example - Module which is used as a testbed for static checkers.
5 This is a mix of different functions and classes doing different things.
6
7 """
8 import sys
9
10 def fn(x, y):
11 """ A function which performs a sum """
12 return x + y
13
14 def find_optimal_route_to_my_office_from_home(start_time,
15 expected_time,
16 favorite_route='SBS1K',
17 favorite_option='bus'):
18
19 # If I am very late, always drive.
20 d = (expected_time – start_time).total_seconds()/60.0
21
22 if d<=30:
23 return 'car'
24
25 # If d>30 but <45, first drive then take metro
26 if d>30 and d<45:
27 return ('car', 'metro')
28
29 # If d>45 there are a combination of options
30 if d>45:
31 if d<60:
32 # First volvo,then connecting bus
33 return ('bus:335E','bus:connector')
34 elif d>80:
35 # Might as well go by normal bus
36 return random.choice(('bus:330','bus:331',':'.join((favorite_option,
37 favorite_route))))
38 elif d>90:
39 # Relax and choose favorite route
40 return ':'.join((favorite_option,
41 favorite_route))
42
43
44 class C(object):
45 """ A class which does almost nothing """
46
47 def __init__(self, x,y):
48 self.x = x
49 self.y = y
50
51 def f(self):
52 pass
53
54 def g(self, x, y):
55
56 if self.x>x:
57 return self.x+self.y
58 elif x>self.x:
59 return x+ self.y
60
61 class D(C):
62 """ D class """
63
64 def __init__(self, x):
65 self.x = x
66
67 def f(self, x,y):
68 if x>y:
69 return x-y
70 else:
71 return x+y
72
73 def g(self, y):
74
75 if self.x>y:
76 return self.x+y
77 else:
78 return y-self.x
79
80 def myfunc(a, b):
81 if a>b:
82 return c
83 else:
84 return a
看一下以下输出:
图 8。修改后的度量测试代码的 pyflakes 静态分析输出
Pyflakes 现在返回一些有用的信息,例如缺少名称(random)、未使用的导入(sys)和新引入函数myfunc中的未定义名称(变量c)。因此,它对代码进行了一些有用的静态分析。例如,有关缺少和未定义名称的信息对于修复前面代码中的明显错误很有用。
提示
在编写代码后,运行 Pylint 和/或 Pyflakes 对代码进行报告和查找逻辑和语法错误是个好主意。要运行 Pylint 仅报告错误,使用-E 选项。要运行 Pyflakes,只需按照前面的示例。
重构代码
现在我们已经看到静态工具如何用于报告我们 Python 代码中的各种错误和问题,让我们做一个简单的重构练习。我们将以我们编写不好的度量测试模块作为用例(它的第一个版本),并执行一些重构步骤。
在进行重构时,我们将遵循以下大致指南:
-
首先修复复杂代码:这将大量代码放在一边,因为通常情况下,当复杂的代码被重构时,我们最终会减少代码行数。这将全面改善代码质量,并减少代码异味。您可能会在这里创建新的函数或类,因此最好先执行此步骤。
-
现在对代码进行分析:在这一步运行复杂性检查器是个好主意,看看代码的整体复杂性——类/模块或函数——是否有所减少。如果没有,再次迭代。
-
接下来修复代码异味:接下来修复任何与代码异味有关的问题——类、函数或模块。这将使您的代码变得更好,并且还会改善整体语义。
-
运行检查器:现在在代码上运行 Pylint 等检查器,并获取有关代码异味的报告。理想情况下,它们应该接近零,或者与原始值相比大大减少。
-
修复低悬果:最后修复低悬果,如代码风格和约定错误。这是因为在重构过程中,试图减少复杂性和代码异味时,通常会引入或删除大量代码。因此,在较早的阶段尝试改进编码约定是没有意义的。
-
使用工具进行最终检查:您可以运行 Pylint 检查代码异味,运行 Flake8 检查 PEP-8 约定,运行 Pyflakes 捕获逻辑、语法和缺少变量问题。
这是一个逐步演示,使用下一节中的方法修复我们编写不好的度量测试模块的过程。
重构代码-修复复杂性
大部分复杂性在 office route 函数中,所以让我们尝试修复它。这是重写的版本(仅显示该函数):
def find_optimal_route_to_my_office_from_home(start_time,
expected_time,
favorite_route='SBS1K',
favorite_option='bus'):
# If I am very late, always drive.
d = (expected_time - start_time).total_seconds()/60.0
if d<=30:
return 'car'
elif d<45:
return ('car', 'metro')
elif d<60:
# First volvo,then connecting bus
return ('bus:335E','bus:connector')
elif d>80:
# Might as well go by normal bus
return random.choice(('bus:330','bus:331',':'.join((favorite_option,
favorite_route))))
# Relax and choose favorite route
return ':'.join((favorite_option, favorite_route))
在前面的重写中,我们摆脱了多余的 if.. else 条件。现在让我们检查一下复杂性:
重构步骤#1 后度量测试程序的 mccabe 度量
我们已经将复杂性从7降低到5。我们能做得更好吗?
在以下代码片段中,代码被重写为使用值范围作为键,相应的返回值作为值。这大大简化了我们的代码。此外,之前的默认返回最终永远不会被选中,所以现在它被移除了,从而消除了一个分支,并减少了一个复杂性。代码变得简单多了:
deffind_optimal_route_to_my_office_from_home(start_time,
expected_time,
favorite_route='SBS1K',
favorite_option='bus'):
# If I am very late, always drive.
d = (expected_time – start_time).total_seconds()/60.0
options = { range(0,30): 'car',
range(30, 45): ('car','metro'),
range(45, 60): ('bus:335E','bus:connector') }
if d<80:
# Pick the range it falls into
for drange in options:
if d in drange:
return drange[d]
# Might as well go by normal bus
return random.choice(('bus:330','bus:331',':'.join((favorite_option, favorite_route))))
重构步骤#2 后度量测试程序的 mccabe 度量
该函数的复杂性现在降低到4,这是可以管理的。
重构代码-修复代码异味
下一步是修复代码异味。幸运的是,我们有一个非常好的列表来自上一次分析,所以这并不太困难。大多数情况下,我们需要更改函数名称、变量名称,并且还需要从子类到基类修复合同问题。
这是所有修复的代码:
""" Module metrictest.py - testing static quality metrics of Python code """
import random
def sum_fn(xnum, ynum):
""" A function which performs a sum """
return xnum + ynum
def find_optimal_route(start_time,
expected_time,
favorite_route='SBS1K',
favorite_option='bus'):
""" Find optimal route for me to go from home to office """
# Time difference in minutes - inputs must be datetime instances
tdiff = (expected_time - start_time).total_seconds()/60.0
options = {range(0, 30): 'car',
range(30, 45): ('car', 'metro'),
range(45, 60): ('bus:335E', 'bus:connector')}
if tdiff < 80:
# Pick the range it falls into
for drange in options:
if tdiff in drange:
return drange[tdiff]
# Might as well go by normal bus
return random.choice(('bus:330', 'bus:331', ':'.join((favorite_option,
favorite_route))))
class MiscClassC(object):
""" A miscellaneous class with some utility methods """
def __init__(self, xnum, ynum):
self.xnum = xnum
self.ynum = ynum
def compare_and_sum(self, xnum=0, ynum=0):
""" Compare local and argument variables
and perform some sums """
if self.xnum > xnum:
return self.xnum + self.ynum
else:
return xnum + self.ynum
class MiscClassD(MiscClassC):
""" Sub-class of MiscClassC overriding some methods """
def __init__(self, xnum, ynum=0):
super(MiscClassD, self).__init__(xnum, ynum)
def some_func(self, xnum, ynum):
""" A function which does summing """
if xnum > ynum:
return xnum - ynum
else:
return xnum + ynum
def compare_and_sum(self, xnum=0, ynum=0):
""" Compare local and argument variables
and perform some sums """
if self.xnum > ynum:
return self.xnum + ynum
else:
return ynum - self.xnum
让我们在这段代码上运行 Pylint,看看这次它输出了什么:
重构后的度量测试程序的 Pylint 输出
您会发现代码异味的数量已经减少到接近零,除了缺少public方法的投诉,以及类MiscClassD的方法some_func可以是一个函数的洞察,因为它不使用类的任何属性。
注意
我们已经使用选项–reports=n调用了 Pylint,以避免 Pylint 打印其摘要报告,因为这样会使整个输出太长而无法在此处显示。可以通过调用 Pylint 而不带任何参数来启用这些报告。
重构代码-修复样式和编码问题
现在我们已经解决了主要的代码问题,下一步是修复代码风格和约定错误。然而,为了缩短步骤数量和在本书中打印的代码量,这已经与最后一步合并,正如您可能从 Pylint 的输出中猜到的那样。
除了一些空白警告之外,所有问题都已解决。
这完成了我们的重构练习。
摘要
在本章中,我们看了修改性的架构质量属性及其各个方面。我们详细讨论了可读性,包括可读性反模式以及一些编码反模式。在讨论过程中,我们了解到 Python 从根本上就是一种为了可读性而编写的语言。
我们看了改进代码可读性的各种技术,并花了一些时间讨论了代码注释的各个方面,并在函数、类和模块级别上看了 Python 的文档字符串。我们还看了 PEP-8,这是 Python 的官方编码约定指南,并了解到持续重构代码对于保持其可修改性并在长期内减少维护成本是很重要的。
然后,我们看了一些代码注释的经验法则,并讨论了代码的修改性基本原理,即代码的耦合性和内聚性。我们用一些例子看了不同情况下的耦合和内聚。然后我们讨论了改进代码修改性的策略,比如提供明确的接口或 API、避免双向依赖、将常见服务抽象到辅助模块中,以及使用继承技术。我们看了一个例子,通过继承重构了一个类层次结构,以抽象出共同的代码并改进系统的修改性。
最后,我们列出了提供 Python 静态代码度量的不同工具,如 PyLint、Flake8、PyFlakes 等。我们通过一些例子了解了 McCabe 圈复杂度。我们还了解了代码异味是什么,并进行了重构练习,以逐步改进代码的质量。
在下一章中,我们将讨论软件架构的另一个重要质量属性,即可测试性。