Python 鲁棒编程(四)
原文:
annas-archive.org/md5/42e1aab1e8f4063de5f6437ba1b9efff译者:飞龙
第十六章:依赖关系
编写没有任何依赖关系的程序是困难的。函数依赖于其他函数,模块依赖于其他模块,程序依赖于其他程序。架构是分形的;无论你看的是哪个层次,你的代码都可以表示为某种盒子和箭头图,就像 图 16-1 中所示。无论是函数、类、模块、程序还是系统,你都可以画一个类似的图来表示代码中的依赖关系。
图 16-1. 盒子和箭头图
然而,如果你不积极管理你的依赖关系,很快就会陷入所谓的“意大利面条式代码”,使你的盒子和箭头图看起来像是 图 16-2。
图 16-2. 依赖关系的混乱纠结
在本章中,你将学习有关依赖关系以及如何控制它们的全部内容。你将学习不同类型的依赖关系,所有这些都应该用不同的技术来管理。你将学习如何绘制依赖关系图,以及如何解读是否拥有一个健康的系统。你将学习如何真正简化你的代码架构,这将帮助你管理复杂性并增加代码库的健壮性。
关系
依赖关系本质上是关系。当一段代码需要另一段代码以某种特定的方式运行时,我们称之为 依赖关系。你通常使用依赖关系来以某种方式重用代码。函数调用其他函数以重用行为。模块导入其他模块以重用该模块中定义的类型和函数。在大多数代码库中,从头开始写所有东西是没有意义的。重用代码库的其他部分,甚至是来自其他组织的代码,可能极大地有利。
当你重用代码时,你节省了时间。你不需要浪费精力编写代码;你可以直接调用或导入你需要的功能。此外,你依赖的任何代码很可能已经在其他地方使用过。这意味着已经进行了某种层次的测试,这应该减少 bug 的数量。如果代码是可以读取的,那就更好了。正如 Linus 法则(即 Linux 创建者 Linus Torvalds 的法则)所述:¹
“足够多的眼睛,所有的 bug 都会变得浅显易懂。”
换句话说,由于有很多人在查看代码,发现 bug 的可能性更高。这又是支持可读性导致可维护性的另一点。如果你的代码可读性好,其他开发者将更容易找到并修复其中的错误,帮助你的健壮性增强。
不过,这里有一个问题。说到依赖关系,没有免费的午餐。你创建的每一个依赖关系都会增加耦合度,或者说将两个实体绑定在一起。如果依赖关系以不兼容的方式发生变化,你的代码也需要相应变化。如果这种情况经常发生,你的健壮性将会受到影响;你将不断努力维持稳定性,因为你的依赖关系在变化。
依赖关系中还存在着一个人为因素。你依赖的每一行代码都是由活生生的人(甚至可能是一群人)维护的。这些维护者有他们自己的时间表、自己的截止日期以及他们对所开发代码的愿景。很可能这些都不会与你的时间表、截止日期和愿景相一致。代码被重复使用的次数越多,越不可能满足每个消费者的所有需求。当你的依赖与你的实现分歧时,你可以选择忍受困难,选择替代依赖(可能是你控制的一个),或者分叉它(并自行维护)。你的选择取决于具体情况,但无论哪种情况,健壮性都会受到影响。
任何在 2016 年工作的 JavaScript 开发者都能告诉你,“left-pad 事件”是如何使依赖关系出现问题的。由于政策分歧,一个开发者从包仓库中移除了一个名为 left-pad 的库,结果第二天,成千上万个项目突然崩溃,无法构建。许多大型项目(包括非常流行的 React 库)并不直接依赖于 left-pad,但通过它们自己的依赖关系间接地依赖于它。没错,依赖关系也有它们自己的依赖关系,当你依赖其他代码时,你也会得到它们。这个故事的寓意是:不要忘记人为因素及其相关工作流的成本。准备好你的任何依赖关系以最糟糕的方式发生变化,包括被移除。依赖关系是一种负担。必要的,但仍然是负担。
从安全的角度来看,依赖关系还会扩展攻击面。每一个依赖项(以及它们自己的依赖项)都有可能 compromise 你的系统。有一些专门的网站致力于跟踪安全漏洞,例如https://cve.mitre.org。通过关键字搜索“Python”,你可以看到今天存在多少漏洞,而且自然地,这些网站甚至无法计算尚未知晓的漏洞。如果你的组织维护的依赖存在安全问题,除非有专注于安全的个体不断审视你的所有代码,否则未知的漏洞可能随时存在于你的代码库中。
仔细平衡你对依赖关系的使用。你的代码天然地会有依赖关系,这是一件好事。关键在于如何聪明地管理它们。粗心大意会导致代码混乱不堪。要学会如何处理依赖关系,首先需要知道如何识别不同类型的依赖关系。
依赖关系的类型
我将依赖关系分为三类:物理、逻辑和时间性。每种都以不同的方式影响代码的健壮性。你必须能够识别它们,并知道它们何时出现问题。正确使用依赖关系可以使你的代码保持可扩展性而不致使其变得笨重。
物理依赖
当大多数开发者思考依赖关系时,他们想到的是物理依赖关系。物理依赖 是直接在代码中观察到的关系。函数调用函数,类型由其他类型组成,模块导入模块,类继承自其他类……这些都是物理依赖的例子。它们是静态的,意味着在运行时不会改变。
物理依赖关系是最容易理解的;即使是工具也可以查看代码库并映射出物理依赖关系(你将在几页后看到这一点)。它们在第一眼看起来就很容易阅读和理解,这对于代码的健壮性是一个胜利。当未来的维护者阅读或调试代码时,依赖链的解决方式变得非常明显;他们可以跟随导入或函数调用的路径到达链的末端。
图 16-3 着眼于一个名为PizzaMat的完全自动化披萨咖啡馆。特许经营者可以购买整个 PizzaMat 作为一个完整的模块,并在任何地方部署以获得即时(和美味的)披萨。PizzaMat 有几个不同的系统:制作披萨系统、控制付款和订购的系统,以及处理桌面管理(座位、加料和订单送达)的系统。
图 16-3. 一个自动化披萨咖啡馆
这三个系统中的每一个都与其他系统互动(这就是箭头所代表的)。顾客与付款/订购系统互动以订购他们的披萨。一旦完成,披萨制作者检查是否有新订单并开始制作披萨,桌面管理系统开始安排顾客的座位。一旦桌面管理服务得知披萨已完成,它会为桌子准备披萨并为顾客服务。如果出于任何原因顾客对披萨不满意,桌面管理系统会退还披萨,付款系统会发出退款。
每个依赖关系都是一个关系,只有这些系统共同工作,我们才有一个正常运作的披萨店。物理依赖关系对于理解大型系统至关重要;它们允许你将问题分解为较小的实体,并定义每个实体之间的交互。我可以拆分任何一个系统为模块,或者拆分任何一个模块为函数。我想要专注于这些关系如何影响可维护性。
假设这三个系统由三个不同的实体维护。您和您的团队负责披萨制作系统。您公司的另一个团队(位于不同建筑物内)负责桌子管理系统,而独立承包商则负责支付系统。您已参与推出新的披萨制作项目——斯特龙博利,为此进行了数周的工作,仔细协调变更。每个系统都需要调整以处理新的菜单项目。在无数个深夜(当然都是披萨驱动的),您已经准备好为客户进行重大更新。然而,一旦更新推出,错误报告就开始涌现。一系列不幸的事件引入了一个 bug,导致全球的披萨店崩溃。随着越来越多的系统上线,问题变得更加严重。管理层决定您需要尽快修复它。
花点时间想想您希望今晚过得如何。您想要在努力尝试联系所有其他团队并试图在三个系统中试图快速修复的情况下度过吗?您恰好知道承包商已经关闭了今晚的通知,而另一个团队在今天下班后对他们的发布庆祝活动有点过于投入。或者,您想看看代码,并意识到只需稍微修改几行代码就可以轻松从所有三个系统中移除斯特龙博利,而无需其他团队的参与?
依赖关系是一种单向关系。您受制于您的依赖关系。如果它们在您需要时不按您的要求执行,您几乎没有什么办法。请记住,依赖的另一端是真正的活生生的人,他们并不一定会在您要求时立即行动。您如何构建依赖关系将直接影响您如何维护系统。
在我们的斯特龙博利示例中,依赖关系是一个循环;任何一个变更都有可能影响其他两个系统。您需要考虑依赖关系的每一个方向,以及变更如何在系统中传播。对于 PizzaMat 来说,披萨制作设备的支持是我们的唯一真理;没有必要为不存在的披萨产品设置计费和桌子管理。然而,在上述示例中,所有三个系统都有它们自己的菜单项目副本。根据依赖关系的方向,披萨制造商可以移除斯特龙博利代码,但斯特龙博利仍然会出现在支付系统中。如何使其更具扩展性以避免这些依赖问题?
警告
大型架构变更的棘手之处在于正确答案始终取决于特定问题的背景。如果你要构建一个自动披萨制造机,可能会根据各种不同的因素和约束条件绘制依赖树。重要的是要关注你为何以你所画的依赖关系而不是确保它们与其他系统总是相同。
要开始,你可以构建你的系统,让所有菜单定义都在披萨制造系统中;毕竟,只有系统知道它能做什么和不能做什么。然后,定价系统可以向披萨制造者查询实际可用的项目。这样,如果需要紧急移除比萨卷,你可以在披萨制造系统中执行;定价系统不控制什么是可用和不可用的。通过倒置或反转依赖的方向,你可以将控制权还给披萨制造系统。如果我倒置这个依赖关系,依赖图看起来像图 16-4。
图 16-4. 更合理的依赖关系
现在披萨制造者决定可以点什么和不能点什么。这在限制所需更改方面可以大有裨益。如果披萨制造者需要停止支持某种原料在菜品中的使用,支付系统将自动跟随变化。这不仅在紧急情况下可以救你一命,而且在未来给你的业务更多灵活性。你已经增加了根据披萨制造者能够自动制作的内容,选择在支付系统中可选显示不同的菜品的功能,而无需与外部支付团队协调。
讨论主题
思考一下,如果把一个功能加入,使支付系统在披萨制造者缺少原料时不显示某些选项。考虑 16-3 图和 16-4 图中的系统。
作为一个额外的讨论主题,讨论表管理系统与支付系统之间的循环。如何打破这种循环?每种依赖方向的利弊是什么?
逻辑依赖
逻辑依赖 是指两个实体之间有关系,但在代码中没有直接的链接。这种依赖是抽象的;它包含了一层间接。这是一种只在运行时存在的依赖。在我们的披萨制造者示例中,我们有三个子系统相互作用。我们在图 16-3 中用箭头表示依赖关系。如果这些箭头是导入或函数调用,那么它们就是物理依赖。然而,可以在运行时连接这些子系统,而无需函数调用或导入。
假设子系统位于不同的计算机上,并通过 HTTP 进行通信。如果披萨制造商要使用requests库通过 HTTP 通知桌面管理服务何时制作披萨,它会看起来像这样:
def on_pizza_made(order: int, pizza: Pizza):
requests.post("table-management/pizza-made", {
"id": order,
"pizza": pizza.to_json()
})
物理依赖不再是从披萨制造商到我们的桌面管理系统,而是从披萨制造商到requests库。对于披萨制造商来说,它只需要一个 HTTP 端点,可以将数据作为 JSON 格式的 ID 和披萨数据发布到名为“/pizza-done”的端点,来自名为“table-management”的某个 Web 服务器。
现在,在现实中,你的披萨制造商仍然需要桌面管理服务才能工作。这就是逻辑依赖的作用。尽管没有直接依赖,但披萨制造商与桌面管理系统之间仍然存在关系。这种关系不会消失,它会从物理上转变为逻辑上的。
引入逻辑依赖的关键好处在于可替换性。当没有任何物理依赖于某个组件时,替换该组件就容易得多。以通过 HTTP 请求on_pizza_done的例子为例。你完全可以替换桌面管理服务,只要它遵循与原始服务相同的契约。如果这听起来很熟悉,那是因为这与你在第十二章学到的完全相同。子类型化,无论是通过鸭子类型、继承还是其他方式,都引入了逻辑依赖。调用代码在物理上依赖于基类,但使用哪个子类的逻辑依赖直到运行时才确定。
提高可替换性可以提升可维护性。记住,可维护的代码易于修改。如果你可以用最小的影响替换大量功能,那么你未来的维护者在做决策时将拥有极大的灵活性。如果某个特定的函数、类或子系统不符合你的需求,你可以直接替换它。易于删除的代码本质上易于修改。
但与任何事物一样,逻辑依赖是有代价的。每个逻辑依赖都间接引用某种关系。因为没有物理链接,工具很难识别逻辑依赖。你将无法创建一个漂亮的框和箭头图表来显示逻辑依赖。此外,开发者阅读你的代码时,逻辑依赖不会立即显现。通常情况下,代码阅读者会看到与某个抽象层的物理依赖,而忽视或直到运行时才解决逻辑依赖。
引入逻辑依赖的权衡之处在于增加了可维护性,通过增加可替换性和减少耦合性,但也因为使代码难以阅读和理解而降低了可维护性。抽象层次过多会像抽象层次过少一样容易造成混乱。并没有硬性规定适当的抽象层次数量;在特定场景下,你需要根据自己的最佳判断,确定是需要灵活性还是可读性。
一些逻辑依赖会创建一些无法通过工具检测到的关系,比如依赖于集合的特定顺序或依赖于类中特定字段的存在。发现这些依赖时,往往会让开发人员感到惊讶,因为在仔细检查之前很少有迹象表明它们存在。
我曾经在一个代码库中处理过存储网络接口的问题。有两段代码依赖于这些接口:一个用于性能统计,另一个用于与其他系统建立通信路径。问题在于它们对这些接口排序有不同的假设。这个系统多年来都运行良好,直到新增了新的网络接口。由于通信路径的工作方式,新的接口需要放在列表的前面。但是性能统计只能在这些接口放在列表后面时才能工作。由于隐藏的逻辑依赖,这两部分代码紧密耦合在一起(我从未想过增加通信路径会破坏性能统计)。
事后看来,修复很简单。我创建了一个函数,将通信路径期望的顺序映射到重新排序的列表中。性能统计系统随后依赖于这个新函数。然而,这并没有修复之前的 bug(也没有挽回我为解决性能统计问题而花费的时间)。每当你对代码中不显而易见的东西创建依赖时,找到一种方法使它显而易见。留下一串面包屑,最好是通过一个单独的代码路径(比如上面的中间函数)或类型。如果做不到这一点,留下一个注释。如果网络接口列表中有一个注释表明对特定顺序的依赖,我就不会为这段代码遭遇如此大的困扰了。
时间依赖
最后一种依赖类型是时间依赖性。这实际上是一种逻辑依赖的一种类型,但你处理它的方式略有不同。时间依赖性是一种由时间联系的依赖关系。每当有具体的操作顺序时,比如“面团必须在酱和奶酪之前铺开”或“订单必须在开始制作披萨之前付款”,你就有了时间依赖性。大多数时间依赖性都很直接;它们是你业务领域的自然部分。(反正没有面团,你怎么放披萨酱和奶酪呢?)这些不是会给你带来问题的时间依赖性。相反,问题出在那些不总是那么明显的时间依赖性上。
时间依赖性在你必须按特定顺序执行某些操作的情况下最容易出问题,但你却没有指示你需要这样做。想象一下,如果你的自动披萨制造机可以配置为两种模式:单披萨(用于高质量披萨)或大批量生产(用于便宜快捷披萨)。每当披萨制造机从单披萨切换到大批量生产时,就需要进行显式重新配置。如果这种重新配置没有进行,机器的安全系统会启动,并拒绝制作披萨,直到手动操作员覆盖发生。
当这个选项首次被引入时,开发人员在确保在任何对mass_produce的调用之前(比如:
pizza_maker.mass_produce(number_of_pizzas=50, type=PizzaType.CHEESE)
必须有一个检查:
if not pizza_maker.is_configured(ProductionType.MASS_PRODUCE):
pizza_maker.configure_for_mass_production()
pizza.maker.wait_for_reconfiguration()
开发人员在代码审查中认真寻找这段代码,并确保始终进行适当的检查。然而,随着时间的推移,开发人员在项目中的循环进出,团队对必要检查的知识逐渐减少。想象一下,一个新的自动披萨制造机型号面市,不需要重新配置(调用configure_for_mass_production不会对系统进行任何更改)。只熟悉这种新型号的开发人员可能永远不会考虑在这些情况下调用configure_for_mass_production。
现在,假设你是未来几年中的一名开发人员。比如说,你正在为披萨制造机编写新功能,而mass_produce函数正好适合你所需的用例。你怎么知道你需要为大批量生产进行显式检查,特别是对于旧型号呢?单元测试对你没有帮助,因为新功能的单元测试尚不存在。难道你真的想要等到集成测试失败(或者客户投诉)才发现你错过了这个检查吗?
这里有一些减少遗漏此类检查的策略:
依赖于你的类型系统
通过将特定类型的操作限制为特定类型,你可以防止混淆。想象一下,如果mass_produce只能从MassProductionPizzaMaker对象中调用。你可以编写函数调用,以确保在重新配置之后仅创建MassProductionPizzaMaker。你正在使用类型系统来防止出现错误(NewType在第四章中描述了类似的功能)。
嵌入先决条件更深
披萨制造商在使用前必须进行配置是一个先决条件。考虑通过将检查移动到mass_produce内部来将此作为mass_produce函数的先决条件。思考如何处理错误条件(例如抛出异常)。你将能够防止违反时间依赖性,但在运行时引入了不同的错误。你的具体用例将决定你认为哪种错误较小:违反时间依赖性还是处理新的错误情况。
留下线索
这不一定是捕获违反时间依赖性的策略。相反,如果所有其他努力都失败,它更像是提醒开发人员有关时间依赖性的最后努力。尝试在同一个文件中组织时间依赖性(理想情况下在彼此几行之内)。留下注释和文档,以便通知未来的开发人员这种联系。带有任何运气的话,那些未来的开发人员将看到这些线索,并知道这里存在时间依赖性。
在任何线性程序中,大多数行都对其前面的行有时间依赖性。这是正常的,你不需要针对每一种情况都采取减轻措施。相反,寻找可能仅在某些情况下应用的时间依赖关系(例如旧型号上的机器重新配置),或者如果忽略将会产生灾难性后果的时间依赖关系(例如在将用户输入字符串传递给数据库之前不对其进行净化)。权衡违反时间依赖性的成本与检测和减轻它的努力。这将取决于你的用例,但是当你减轻时间依赖性时,它可以在以后节省你大量的头痛。
可视化你的依赖关系
查找这些依赖关系并理解在哪里寻找潜在问题点可能是具有挑战性的。有时候,你需要更直观的表示方式。幸运的是,存在工具可以帮助你在视觉上理解你的依赖关系。
注意
对于以下许多示例,我将使用 GraphViz 库来显示图片。要安装它,请按照GraphViz 网站上的说明操作。
可视化包
很可能,你的代码使用了由 pip 安装的其他包。了解你依赖的所有包、它们的依赖关系及其依赖关系,依此类推,可能会有所帮助。
为此,我将安装两个包,pipdeptree和 GraphViz。pipdeptree是一个实用工具,可以告诉你各个包是如何相互交互的,而 GraphViz 则负责实际的可视化部分。在这个示例中,我将使用 mypy 代码库。我已经下载了 mypy 源代码,创建了一个虚拟环境,并从源代码安装了 mypy。²
从这个虚拟环境中,我已经安装了pipdeptree和 GraphViz:
pip install pipdeptree graphviz
现在我运行以下命令:
pipdeptree --graph-output png --exclude pipdeptree,graphviz > deps.png
你可以在图 16-5 中看到结果。
图 16-5. 可视化包
我将忽略 wheel、setuptools 和 pip 包,专注于 mypy。在这种情况下,我看到安装的确切版本是 mypy,以及直接依赖项(在本例中为 typed_ast 1.4.2、typing-extensions 3.7.4.3 和 mypy-extensions 0.4.3)。pipdeptree还指定了存在的版本约束条件(例如只允许 mypy-extensions 版本大于或等于 0.4.3,但小于 0.5.0)。借助这些工具,你可以方便地获得打包依赖项的图形化表示。对于依赖项众多的项目尤为有用,特别是如果你积极维护许多包的话。
可视化导入
可视化包是一个相当高层次的视图,所以深入了解会更有帮助。你如何找出模块级别的导入内容?另一个名为pydeps的工具非常适合这个任务。
要安装它,你可以:
pip install pydeps
安装后,你可以运行:
pydeps --show-deps <source code location> -T png -o deps.png
我对 mypy 运行了这个命令,并得到了一个非常复杂和密集的图。在打印出来会浪费纸张,所以我决定放大特定部分在图 16-6 中。
图 16-6. 可视化导入
即使在依赖图的这个小部分中,箭头混乱不堪。然而,你可以看到代码库的许多不同区域依赖于mypy.options,以及fastparse和errors模块。由于这些图的规模,我建议一次深入挖掘你的代码库中的较小子系统。
可视化函数调用
如果你想获取比导入图更多的信息,你可以看到哪些函数彼此调用。这被称为调用图。首先,我将查看一个静态调用图生成器。这些生成器查看你的源代码并确定哪些函数调用哪些函数;不执行任何代码。在这个例子中,我将使用库 pyan3,可以通过以下命令安装:
pip install pyan3
要运行 pyan3,你需要在命令行上执行以下操作:
pyan3 <Python files> --grouped --annotated --html > deps.html
当我在 mypy 内部的dmypy文件夹上运行此操作(我选择了一个子文件夹来限制所绘制信息的数量),我收到一个交互式 HTML 页面,可以让我探索这些依赖关系。 图 16-7 显示了工具中的一个片段。
图 16-7. 静态可视化函数调用
请注意,这仅跟踪物理依赖关系,因为逻辑依赖关系直到运行时才知晓。如果您想在运行时看到调用图,请与dynamic调用图生成器一起执行您的代码。出于这个目的,我喜欢使用内置的 Python 分析器。Profiler会在程序执行期间审计您所做的所有函数调用,并记录性能数据。此外,整个函数调用历史记录也将保存在概要文件中。让我们试一试。
我首先会构建概要文件(出于尺寸考虑,我正在对 mypy 中的一个测试文件进行性能分析):
python -m cProfile -o deps.profile mypy/test/testutil.py
然后我将概要文件转换为 GraphViz 能理解的文件:一个 dot 文件。
pip install gprof2dot
gprof2dot --format=pstats deps.profile -o deps.dot
最后,我将使用 GraphViz 将*.dot文件转换为.png*文件。
dot deps.dot -Tpng > deps.png
再次提醒,这会产生大量的框和箭头,因此 图 16-8 只是一个小截图,说明了调用图的一部分。
图 16-8. 动态可视化函数调用
您可以了解函数被调用的次数,以及在函数中花费了多少执行时间。这不仅是找出性能瓶颈的好方法,还有助于理解您的调用图。
解读您的依赖图
好了,你画了这么多漂亮的图表;你可以用它们做什么?当您以这种方式看到您的依赖关系时,您会很好地了解到您的可维护性热点在哪里。请记住,每个依赖关系都是代码变更的原因。每当代码库中的任何内容发生变化时,它可能通过物理和逻辑依赖关系向上传播,可能会影响大片代码。
在这种情况下,您需要考虑您正在更改的内容与依赖它们的内容之间的关系。考虑依赖于您的代码量,以及您自己依赖的代码量。如果有很多依赖进入,但没有依赖出去,这被称为高fan-in。相反,如果进入的依赖不多,但您依赖的实体数量很大,这被称为高fan-out。 图 16-9 阐明了扇入和扇出之间的差异。
图 16-9. 扇入与扇出的差异
你希望系统中具有高入度的实体成为依赖图的叶子节点,或者说在底部。你的代码库的大部分部分依赖于这些实体;你的每一个依赖关系都会影响到你的整个代码库。你还希望这些实体保持稳定,这意味着它们变化的频率应该较低。每次引入变化,都有可能由于大量的入度而影响到你的整个代码库。
另一方面,扩散实体应该位于依赖图的顶部。这里可能是你的大部分业务逻辑所在;随着业务的发展,它们将会变化。你的代码库中的这些部分可以承受更高的变更率;由于其相对较少的上游依赖关系,当行为变化时,它们的代码不太容易经常性地出现故障。
警告
改变扩散实体不会影响你代码库中的大部分假设,但我不能说它是否会破坏客户的假设。你希望外部行为保持向后兼容的程度是一个用户体验的问题,不在本书的讨论范围之内。
结语
依赖的存在并不决定你的代码有多健壮。关键在于你如何利用和管理这些依赖关系。依赖关系对于系统中合理的重用至关重要。你可以将代码分解为更小的块,并适当重新组织你的代码库。通过给你的依赖关系正确的方向性,你实际上可以增强代码的健壮性。通过增加可替换性和可扩展性,你可以使你的代码更易于维护。
但是,就像工程中的任何事物一样,总是有成本的。依赖关系是一种耦合;将代码库的不同部分链接在一起并进行更改可能会产生比你预期更广泛的影响。有不同类型的依赖关系,它们必须以不同的方式处理。物理依赖关系通过工具化是容易可视化的,但也在它们所施加的结构上是刚性的。逻辑依赖关系为你的代码库提供了扩展性,但它们的本质在运行时是隐藏的。时间依赖关系是以线性方式执行 Python 的必要部分,但当这些依赖关系变得不直观时,它们会带来大量未来的痛苦。
所有这些教训都假设你有可以依赖的代码片段。在下一章中,你将探索可组合的代码,或者将代码分解为更小的部分以便重用。你将学习如何组合对象、循环模式和函数,以将你的代码重新组织成新的用例。当你以可组合的代码来思考时,你将轻松地构建出新功能。你未来的维护者会感谢你。
¹ Eric S. Raymond. 大教堂与集市. Sebastopol, CA: O’Reilly Media, 2001.
² 创建虚拟环境是将您的依赖项与系统的 Python 安装隔离开来的好方法。
第十七章:可组合性
作为开发者,你面临的最大挑战之一是预测未来开发者将如何改变你的系统。业务会发展,今天的断言会成为未来的遗留系统。你如何支持这样的系统?你如何减少未来开发者在适应你的系统时所面临的阻力?你需要开发你的代码,使其能够在各种情况下运行。
在本章中,你将学习如何通过可组合性的思维方式来开发代码。当你以可组合性为重点编写代码时,你会创建小型、独立和可重复使用的代码。我会向你展示一个不具备可组合性的架构,以及它如何阻碍开发。然后你将学习如何以可组合性为考量来修复它。你将学会如何组合对象、函数和算法,使得你的代码库更具可扩展性。但首先,让我们看看可组合性如何提高可维护性。
可组合性
可组合性 侧重于构建具有最小相互依赖和少量业务逻辑嵌入的小组件。其目标是未来的开发者可以使用这些组件中的任何一个来构建他们自己的解决方案。通过使它们变小,你使它们更易于阅读和理解。通过减少依赖,你让未来的开发者不必担心拉取新代码所涉及的所有成本(例如你在第十六章中学到的成本)。通过保持组件基本免于业务逻辑,你允许你的代码解决新问题,即使这些新问题看起来与今天遇到的问题毫不相似。随着可组合组件数量的增加,开发者可以混合匹配你的代码,轻松创建全新的应用程序。专注于可组合性,使得代码更易于重用和扩展。
考虑厨房里的低调香料架。如果你要完全依靠香料混合物,比如南瓜派香料(肉桂、肉豆蔻、姜和丁香)或者五香粉(肉桂、茴香、八角、花椒和丁香),你会创造出什么样的餐点呢?你最终会主要制作那些以这些香料混合物为中心的食谱,比如南瓜派或五香鸡。虽然这些混合物使得制作专门的餐点非常容易,但是如果你需要制作只用单一成分的东西,比如肉桂丁香糖浆,你可以尝试用南瓜派香料或五香粉代替,并希望额外的成分不会产生冲突,或者你可以单独购买肉桂和丁香。
各种香料类似于小型、可组合的软件组件。你不知道未来可能要做什么菜,也不知道未来会有什么业务需求。专注于离散组件,你可以让合作者根据需要灵活使用,而不必尝试进行次优的替代或拉动其他组件。如果需要特定的组件混合(比如南瓜派香料),你可以自由地从这些组件构建应用。软件不像香料混合那样会过期;你可以既拥有蛋糕(或南瓜派),又能吃掉它。从小型、离散、可组合的软件构建专业应用程序,你会发现可以在下周或明年以全新的方式重复使用这些组件。
你在第二部分学习构建自己的类型时实际上已经见过可组合性。我建立了一系列小型的离散类型,可以在多个场景中重复使用。每种类型都为代码库中的概念词汇贡献了一部分。开发者可以使用这些类型来表示领域概念,同时也可以基于它们来定义新的概念。看一下来自第九章的一道汤的定义:
class ImperialMeasure(Enum):
TEASPOON = auto()
TABLESPOON = auto()
CUP = auto()
class Broth(Enum):
VEGETABLE = auto()
CHICKEN = auto()
BEEF = auto()
FISH = auto()
@dataclass(frozen=True)
# Ingredients added into the broth
class Ingredient:
name: str
amount: float = 1
units: ImperialMeasure = ImperialMeasure.CUP
@dataclass
class Recipe:
aromatics: set[Ingredient]
broth: Broth
vegetables: set[Ingredient]
meats: set[Ingredient]
starches: set[Ingredient]
garnishes: set[Ingredient]
time_to_cook: datetime.timedelta
我能够用Ingredient、Broth和ImperialMeasure对象创建一个Recipe。所有这些概念本可以嵌入到Recipe本身,但这会增加重复使用的难度(如果有人想使用ImperialMeasure,依赖Recipe会令人困惑)。通过保持每种类型的分离,我允许未来的维护者构建新的类型,比如与汤无关的概念,而无需寻找解开依赖的方法。
这是类型组合的一个例子,我创建了可以以新方式混合和匹配的离散类型。在本章中,我将关注 Python 中的其他常见组合类型,如组合功能、函数和算法。例如,在像图 17-1 中的三明治店的简单菜单。
图 17-1. 一个虚构的菜单
这个菜单是可组合性的另一个例子。顾客从菜单的第一部分选两个项目,再加一份配菜和一杯饮料。他们组合菜单的不同部分,以获得他们想要的完美午餐。如果这个菜单不可组合,你将不得不列出每个选项,以表示所有可能的组合(共有 1,120 种选择,这个菜单足以让大多数餐厅感到羞愧)。对于任何餐厅来说,这是不可行的;最好把菜单分解成可以拼接在一起的部分。
我希望你以同样的方式思考你的代码。代码不仅仅因为存在就变得可组合;你必须积极地以可组合性为设计目标。你希望看看你创建的类、函数和数据类型,问问自己如何编写它们,以便未来的开发人员可以重用它们。
考虑一个自动化厨房,创意地命名为 AutoKitchen,作为 Pat's Café的支柱。这是一个完全自动化的系统,能够制作菜单上的任何菜品。我希望能够轻松地向这个系统添加新的菜品;Pat's Café自豪地宣称拥有不断变化的菜单,开发人员厌倦了每次都要花费大量时间修改系统的大块内容。AutoKitchen 的设计如图 17-2 所示。
图 17-2. AutoKitchen 设计
这个设计相当简单明了。AutoKitchen 依赖于各种准备机制,称为准备者。每个准备者依赖于厨房元素,将成分转化为菜品组件(比如把碎牛肉变成煮熟的汉堡)。厨房元素,比如烤箱或烧烤架,被命令来烹饪各种成分;它们不知道具体使用的成分或生成的菜品组件。图 17-3 展示了一个特定准备者可能的样子。
这个设计是可扩展的,这是一件好事。添加新的三明治类型很简单,因为我不需要修改任何现有的三明治代码。然而,这并不太可组合。如果我想把盘子组件拿出来,为新的菜品重用它们(比如为 BLT 卷饼煮培根,或为芝士汉堡煮汤),我必须带着整个BLT 制造机或肉饼融化机。如果我这么做了,我还得带上一个面包机和一个数据库。这正是我想避免的。
图 17-3. 三明治准备者
现在,我想介绍一种新的汤:土豆、韭菜和培根。汤准备者已经知道如何处理从其他汤中得到的韭菜和土豆;现在我希望汤准备者知道如何制作培根。在修改汤准备者时,我有几个选项:引入对BLT 制造机的依赖,编写自己的培根处理代码,或找到一种方法单独重用培根处理部分,而不依赖于BLT 制造机。
第一种选择存在问题:如果我依赖于BLT 制造机,我需要依赖于它所有的物理依赖,比如面包机。汤准备者可能不想要所有这些包袱。第二种选择也不太好,因为现在我的代码库中存在培根处理的重复(一旦有两个,不要惊讶如果最终出现第三个)。唯一好的选择是找到一种方法将培根制作从BLT 制造机中分离出来。
然而,代码并不会仅仅因为你希望它可重复使用而变得可重复使用(虽然这样会很好)。你必须有意识地设计你的代码以实现可重复使用。你需要将其设计得小巧、独立,并且大部分独立于业务逻辑,以使其具有可组合性。而要做到这一点,你需要将策略与机制分开。
策略与机制
策略是你的业务逻辑,或者直接负责解决业务需求的代码。机制是提供实现策略的方法的代码片段。在前面的例子中,系统的策略是具体的菜谱。相反,如何制作这些菜谱的方式就是机制。
当你专注于使代码具有可组合性时,需要将策略与机制分开。机制通常是你想要重复使用的部分;如果它们与策略紧密耦合,就无法达到这个目的。这就是为什么一个依赖于BLT Maker的Soup Preparer没有意义的原因。这样会导致一个策略依赖于一个完全独立且无关的策略。
当你连接两个无关的策略时,你开始创建一个难以稍后打破的依赖关系。随着你连接更多的策略,你创建了一团乱麻的代码。你会得到一个纠缠不清的依赖关系,并且解脱任何一个依赖关系都变得困难。这就是为什么你需要意识到你的代码库中哪些部分是策略,哪些是机制的原因。
Python 中一个很好的策略与机制的例子是logging模块。策略定义了需要记录的内容及其记录位置;而机制允许你设置日志级别、过滤日志消息和格式化日志。
在实际操作中,任何模块都可以调用日志方法:
logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.DEBUG)
logger.warning("Family did not match any restaurants: Lookup code A1503")
logging模块并不关心记录的内容或日志消息的格式。logging模块只提供了日志记录的方法。任何使用的应用程序需要定义策略,或者需要记录的内容,来确定需要记录什么。将策略与机制分离使得logging模块可以重复使用。你可以轻松地扩展代码库的功能,而不需要带上大量的负担。这就是你在代码库中应该追求的机制模型。
在前面的咖啡馆例子中,我可以改变代码架构以分离出机制。我的目标是设计一个系统,使得制作任何菜品组件都是独立的,并且可以将这些组件组合在一起以创建菜谱。这将使我能够在系统间重复使用代码,并在创建新菜谱时具有灵活性。图 17-4 展示了一个更具可组合性的架构(注意出于空间考虑,我已省略了一些系统)。
图 17-4. 可组合架构
通过将特定的准备器拆分到它们自己的系统中,我既实现了可扩展性又实现了可组合性。不仅易于扩展新的菜肴,比如一个新的三明治,而且还可以轻松定义新的连接,比如让汤准备器重复使用培根准备代码。
当像这样拆分您的机制时,您会发现编写策略变得更加简单。没有任何机制与策略绑定,您可以开始以声明式的方式编写,或者简单地声明要做什么。看看以下土豆、韭葱和培根汤的定义:
import bacon_preparer
import veg_cheese_preparer
def make_potato_leek_and_bacon_soup():
bacon = bacon_preparer.make_bacon(slices=2)
potatoes = veg_cheese_preparer.cube_potatoes(grams=300)
leeks = veg_cheese_preparer.slice(ingredient=Vegetable.LEEKS, grams=250)
chopped_bacon = chop(bacon)
# the following methods are provided by soup preparer
add_chicken_stock()
add(potatoes)
add(leeks)
cook_for(minutes=30)
blend()
garnish(chopped_bacon)
garnish(Garnish.BLACK_PEPPER)
通过仅关注代码中的配方是什么,我不必被如何制作培根或切丁土豆等外部细节困扰。我将培根准备器和蔬菜/奶酪准备器与汤准备器组合在一起来定义新的配方。如果明天出现新的汤(或任何其他菜肴),定义它将同样简单,就像一系列线性指令一样。策略将比您的机制更经常更改;使其易于添加、修改或删除以满足您的业务需求。
讨论主题
您的代码库中哪些部分易于重用?哪些部分难以重用?您是否想重用代码的策略还是机制?讨论使您的代码更具组合性和可重用性的策略。
如果预见将来需要重用,请尝试使您的机制可组合。这将加速未来的开发,因为开发人员将能够真正重用您的代码而几乎没有任何条件。您正在增加灵活性和可重用性,这将使代码更易于维护。
然而,可组合性是有代价的。通过在更多文件中分散功能,您会降低可读性,并引入更多的移动部件,这意味着变更可能会产生负面影响的机会增加。寻找引入组合性的机会,但要注意使您的代码过于灵活,需要开发人员浏览整个代码库才能找出如何编写简单工作流的情况。
在较小的规模上进行组合
AutoKitchen 示例向您展示了如何组合不同的模块和子系统,但您也可以在较小的范围内应用组合原则。您可以编写可组合的函数和算法,使您能够轻松构建新的代码。
组合函数
本书的很大一部分关注面向对象的原则(如 SOLID 和基于类的设计),但学习其他软件范式同样重要。一个越来越受欢迎的范式是函数式编程(FP)。在 OOP 中,一等公民是对象,而 FP 则专注于纯函数。纯函数是一个其输出完全由输入决定的函数。给定一个纯函数和一组输入参数,无论全局状态或环境如何改变,它始终返回相同的输出。
使函数式编程如此吸引人的原因是纯函数比带有副作用的函数更容易组合。副作用是函数在其返回值之外执行的任何操作,例如记录消息、进行网络调用或变异变量。通过从函数中删除副作用,使它们更容易重用。没有隐藏的依赖关系或令人惊讶的结果;整个函数依赖于输入数据,并且唯一的可观察效果是返回的数据。
然而,当您尝试重用代码时,您必须将所有该代码的物理依赖项拉入(并在运行时提供逻辑依赖项,如果需要的话)。使用纯函数时,您在函数调用图之外没有任何物理依赖项。您不需要拉入具有复杂设置或全局变量的额外对象。FP 鼓励开发人员编写短小、单一目的的函数,这些函数本质上是可组合的。
开发人员习惯于将函数视为任何其他变量。他们创建高阶函数,这些函数接受其他函数作为参数,或者作为返回值返回其他函数。最简单的例子是接受一个函数并调用两次:
from typing import Callable
def do_twice(func: Callable, *args, **kwargs):
func(*args, **kwargs)
func(*args, **kwargs)
这并不是一个非常激动人心的例子,但它为组合函数的一些非常有趣的方式打开了大门。事实上,有一个专门用于高阶函数的 Python 模块:functools。大部分functools,以及您编写的任何函数组合,将以装饰器的形式存在。
装饰器
装饰器是接受另一个函数并包装它或指定必须在函数执行之前执行的行为的函数。它为您提供了一种组合函数的方式,而不需要函数体彼此了解。
装饰器是 Python 中包装函数的主要方法之一。我可以将do_twice函数重写为更通用的repeat函数,如下所示:
def repeat(func: Callable, times: int = 1) -> Callable:
''' this is a function that calls the wrapped function
a specified number of times
'''
def _wrapper(*args, **kwargs):
for _ in range(times):
func(*args, **kwargs)
return _wrapper
@repeat(times=3)
def say_hello():
print("Hello")
say_hello()
>>> "Hello"
"Hello"
"Hello"
再次,我将策略(重复说 hello)与机制(实际重复函数调用)分开。这是我可以在其他代码库中使用的机制,没有任何后果。我可以将此装饰器应用于代码库中的各种函数,例如一次为双层芝士汉堡制作两个汉堡饼或者为宴会活动批量生产特定订单。
当然,装饰器可以做的远不止简单重复函数调用。我最喜欢的一个装饰器之一来自backoff库。backoff帮助您定义重试逻辑,或者在代码的不确定部分重试时采取的操作。考虑早期的AutoKitchen需要将数据保存在数据库中。它将保存接受的订单、当前库存水平以及制作每道菜所花费的时间。
在其最简单的形式下,代码将如下所示:
# setting properties of self.*_db objects will
# update data in the database
def on_dish_ordered(dish: Dish):
dish_db[dish].count += 1
def save_inventory_counts(inventory):
for ingredient in inventory:
inventory_db[ingredient.name] = ingredient.count
def log_time_per_dish(dish: Dish, number_of_seconds: int):
dish_db[dish].time_spent.append(number_of_seconds)
每当你与数据库(或任何其他 I/O 请求)打交道时,都要做好处理错误的准备。数据库可能宕机,网络可能中断,可能与你输入的数据发生冲突,或者可能出现任何其他错误。不能总是指望这段代码无错误地执行。业务不希望代码在第一次出错时就放弃;这些操作应该在放弃之前重试一定次数或一定时间段。
我可以使用backoff.on_exception指定这些函数在抛出异常时应进行重试:
import backoff
import requests
from autokitchen.database import OperationException
# setting properties of self.*_db objects will
# update data in the database
@backoff.on_exception(backoff.expo,
OperationException,
max_tries=5)
def on_dish_ordered(dish: Dish):
self.dish_db[dish].count += 1
@backoff.on_exception(backoff.expo,
OperationException,
max_tries=5)
@backoff.on_exception(backoff.expo,
requests.exceptions.HTTPError,
max_time=60)
def save_inventory_counts(inventory):
for ingredient in inventory:
self.inventory_db[ingredient.name] = ingredient.count
@backoff.on_exception(backoff.expo,
OperationException,
max_time=60)
def log_time_per_dish(dish: Dish, number_of_seconds: int):
self.dish_db[dish].time_spent.append(number_of_seconds)
通过使用装饰器,我能够修改行为而不会干扰函数体。每个函数现在在特定异常被抛出时将呈指数级退避(每次重试间隔时间更长)。每个函数还有自己的条件,用于决定在完全放弃之前重试多长时间或多少次。我在代码中定义了策略,但将实际的如何操作(即机制)抽象到了backoff库中。
特别注意save_inventory_counts函数:
@backoff.on_exception(backoff.expo,
OperationException,
max_tries=5)
@backoff.on_exception(backoff.expo,
requests.exceptions.HTTPError,
max_time=60)
def save_inventory_counts(inventory):
# ...
我在这里定义了两个装饰器。在这种情况下,我将在OperationException出现时最多重试五次,在requests.exceptions.HTTPError出现时最多重试 60 秒。这就是组合性的体现;我可以混合和匹配完全不同的backoff装饰器来任意定义策略。
将机制直接写入函数与编写装饰器相比如何:
def save_inventory_counts(inventory):
retry = True
retry_counter = 0
time_to_sleep = 1
while retry:
try:
for ingredient in inventory:
self.inventory_db[ingredient.name] = ingredient.count
except OperationException:
retry_counter += 1
if retry_counter == 5:
retry = False
except requests.exception.HTTPError:
time.sleep(time_to_sleep)
time_to_sleep *= 2
if time_to_sleep > 60:
retry = False
处理重试机制所需的代码量最终会掩盖函数的实际意图。一眼看上去很难确定这个函数在做什么。此外,你需要在每个需要处理非确定性操作的函数中编写类似的重试逻辑。更容易的做法是组合装饰器来定义你的业务需求,避免在整个代码中重复繁琐的操作。
backoff 并非唯一有用的装饰器。还有一系列可组合的装饰器可以简化你的代码,例如用于保存函数结果的 functools.lru_cache,用于命令行应用的 click 库 中的 click.command,或用于限制函数执行时间的 timeout_decorator 库 中的 timeout_decorator.timeout。也不要害怕编写自己的装饰器。找到代码中结构相似的地方,寻找将机制抽象出来的方法。
组合算法
函数并不是你能进行小规模组合的唯一方式;你还可以组合算法。算法是解决问题所需的一系列定义步骤的描述,如对集合进行排序或比较文本片段。要使算法可组合,你需要再次将策略与机制分离。
考虑最后一节咖啡馆餐点的餐点推荐。假设算法如下:
Recommendation Algorithm #1
Look at all daily specials
Sort based on number of matching surplus ingredients
Select the meals with the highest number of surplus ingredients
Sort by proximity to last meal ordered
(proximity is defined by number of ingredients that match)
Take only results that are above 75% proximity
Return up to top 3 results
如果我用 for 循环来写这一切,可能看起来会像这样:
def recommend_meal(last_meal: Meal,
specials: list[Meal],
surplus: list[Ingredient]) -> list[Meal]:
highest_proximity = 0
for special in specials:
if (proximity := get_proximity(special, surplus)) > highest_proximity:
highest_proximity = proximity
grouped_by_surplus_matching = []
for special in specials:
if get_proximity(special, surplus) == highest_proximity:
grouped_by_surplus_matching.append(special)
filtered_meals = []
for meal in grouped_by_surplus_matching:
if get_proximity(meal, last_meal) > .75:
filtered_meals.append(meal)
sorted_meals = sorted(filtered_meals,
key=lambda meal: get_proximity(meal, last_meal),
reverse=True)
return sorted_meals[:3]
这并不是最漂亮的代码。如果我没有事先在文本中列出步骤,理解代码并确保没有错误会花费更长时间。现在,假设一个开发者来找你,告诉你说,不够多的客户选择了推荐,并且他们想尝试不同的算法。新的算法如下:
Recommendation Algorithm #2
Look at all meals available
Sort based on proximity to last meal
Select the meals with the highest proximity
Sort the meals by number of surplus ingredients
Take only results that are a special or have more than 3 surplus ingredients
Return up to top 5 results
问题在于,这位开发者希望对这些算法进行 A/B 测试(以及他们提出的任何其他算法)。通过 A/B 测试,他们希望 75% 的客户来自第一个算法的推荐,而 25% 的客户来自第二个算法的推荐。这样,他们可以测量新算法与旧算法的表现。这意味着你的代码库必须支持这两种算法(并且灵活支持将来的新算法)。你不希望看到你的代码库里布满丑陋的推荐算法方法。
你需要将可组合性原则应用到算法本身。复制粘贴 for 循环代码片段并进行微调并不是一个可行的答案。为了解决这个问题,你需要再次区分策略和机制。这将帮助你分解问题并改进代码库。
这次你的策略是算法的具体细节:你正在排序什么,如何进行筛选,以及最终选择什么。机制是描述我们如何塑造数据的迭代模式。事实上,在我上面的代码中,我已经使用了一个迭代机制:排序。与其手动排序(并迫使读者理解我在做什么),我使用了 sorted 方法。我指明了我想要排序的内容和排序的关键。但我真的不关心(也不期望读者关心)实际的排序算法。
如果我要比较这两种算法,我可以将机制分解如下(我将使用 <尖括号> 标记策略):
Look at <a list of meals>
Sort based on <initial sorting criteria>
Select the meals with the <grouping criteria>
Sort the meals by <secondary sorting criteria>
Take top results that match <selection criteria>
Return up to top <number> results
注意
itertools 模块 是一个基于迭代的可组合算法的绝佳源头。它展示了当你创建抽象机制时可以做些什么。
有了这些想法,并借助 itertools 模块的帮助,我将再次尝试编写推荐算法:
import itertools
def recommend_meal(policy: RecommendationPolicy) -> list[Meal]:
meals = policy.meals
sorted_meals = sorted(meals, key=policy.initial_sorting_criteria,
reverse=True)
grouped_meals = itertools.groupby(sorted_meals, key=policy.grouping_criteria)
_, top_grouped = next(grouped_meals)
secondary_sorted = sorted(top_grouped, key=policy.secondary_sorting_criteria,
reverse=True)
candidates = itertools.takewhile(policy.selection_criteria, secondary_sorted)
return list(candidates)[:policy.desired_number_of_recommendations]
然后,要将此算法用于实际操作,我要执行以下步骤:
# I've used named functions to increase readability in the following example
# instead of lambda functions
recommend_meal(RecommendationPolicy(
meals=get_specials(),
initial_sorting_criteria=get_proximity_to_surplus_ingredients,
grouping_criteria=get_proximity_to_surplus_ingredients,
secondary_sorting_criteria=get_proximity_to_last_meal,
selection_criteria=proximity_greater_than_75_percent,
desired_number_of_recommendations=3)
)
想象一下,能够在此动态调整算法是多么美好。我创建了一个不同的 RecommendationPolicy 并将其传递给 recommend_meal。通过将算法的策略与机制分离,我提供了许多好处。我使代码更易于阅读,更易于扩展,并且更加灵活。
结语
可组合的代码是可重用的代码。当您构建小型的、独立的工作单元时,您会发现它们很容易引入到新的上下文或程序中。要使您的代码可组合化,重点是分离策略和机制。无论您是在处理子系统、算法,甚至是函数,您会发现您的机制因为更大范围的重复使用而受益,策略也更容易修改。当您识别出可组合的代码时,您系统的健壮性将大大提高。
在接下来的章节中,您将学习如何在架构层面应用可扩展性和可组合性,使用基于事件的架构。基于事件的架构帮助您将代码解耦为信息的发布者和消费者。它们为您提供了一种在保留可扩展性的同时最小化依赖关系的方法。
第十八章:事件驱动架构
可扩展性在你的代码库的每个层次都非常重要。在代码层面,你利用可扩展性来使你的函数和类灵活。在抽象层面,你在代码库的架构中使用相同的原则。架构是塑造软件设计方式的高级指导方针和约束集。它是影响所有开发人员的愿景,包括过去、现在和未来。本章以及接下来的章节将展示两个示例,说明架构示例如何提高可维护性。你在本书的这部分中学到的一切都适用:良好的架构促进可扩展性,良好地管理依赖关系,并促进可组合性。
在本章中,你将学习有关事件驱动架构的知识。事件驱动架构围绕着事件或系统中的通知。它是解耦代码库不同部分的绝佳方式,同时还可以为新功能或性能扩展系统。事件驱动架构使你可以轻松引入新的变化,并带来最小的影响。首先,我想谈谈事件驱动架构所提供的灵活性。然后,我将介绍事件驱动架构的两种不同变体:简单事件和流式事件。虽然它们相似,但你会在稍微不同的场景中使用它们。
工作原理
当你专注于事件驱动架构时,你实际上是围绕着对刺激的反应。你一直在处理对刺激的反应,无论是从烤箱中取出烩菜还是在手机通知后从前门取货。在事件驱动架构中,你的代码被构建成了这种模型。你的刺激是某种事件的生产者。对这些事件的消费者就是对那个刺激的反应。事件只是从生产者传递到消费者的信息传输。Table 18-1 展示了一些常见的生产者-消费者对。
Table 18-1。日常事件及其消费者
| 生产者 | 消费者 |
|---|---|
| 厨房计时器响起 | 厨师从烤箱取出一份烩菜 |
| 烹饪员在菜做好时敲铃 | 服务员接过并上菜 |
| 闹钟响起 | 睡眠者醒来 |
| 机场最后一次登机通知 | 匆忙的家庭着急赶上他们的连接航班 |
事实上,你在编程时实际上一直在处理生产者和消费者。任何返回值的函数都是生产者,任何使用该返回值的代码片段都是消费者。观察:
def complete_order(order: Order):
package_order(order)
notify_customer_that_order_is_done(order)
notify_restaurant_that_order_is_done(order)
在这种情况下,complete_order以完成订单的形式产生信息。根据函数名称,客户和餐馆正在消耗订单完成的事实。生产者通知消费者存在直接的链接。事件驱动架构的目标是断开这种物理依赖关系。目标是解耦生产者和消费者。生产者不知道消费者,消费者也不知道生产者。这就是推动事件驱动架构灵活性的因素。
通过这种解耦,向系统添加新的功能变得非常容易。如果需要新的消费者,可以添加它们而不需要触及生产者。如果需要不同的生产者,也可以添加它们而不需要触及消费者。这种双向的可扩展性允许您在隔离的多个代码部分中实现重大变更。
发生在幕后的事情非常巧妙。生产者和消费者之间不存在任何依赖关系,它们都依赖于传输机制,如图 18-1 所示。传输机制只是两段代码之间传递数据的方式。
图 18-1. 生产者-消费者关系
缺点
因为生产者和消费者依赖于传输机制,它们必须就消息格式达成一致。在大多数事件驱动架构中,生产者和消费者都会就常见标识符和消息格式达成一致。这确实在两者之间创建了逻辑依赖关系,但并非物理依赖关系。如果任何一方以不兼容的方式更改标识符或消息格式,则方案将崩溃。而且像大多数逻辑依赖关系一样,很难通过检查将这些依赖关系连接在一起。请参阅第十六章了解如何缓解这些问题。
由于代码的分离,当出现问题时,您的类型检查器将无法提供太多帮助。如果一个消费者开始依赖错误的事件类型,类型检查器将不会标记它。在更改生产者或消费者的类型时要格外小心,因为您将不得不更新所有其他生产者-消费者以匹配。
事件驱动架构可能会增加调试的难度。当您在调试器中逐步执行代码时,您将到达生成事件的代码,但是当您进入传输机制时,通常会进入第三方代码。在最坏的情况下,实际传输事件的代码可能在不同的进程中运行,甚至在不同的机器上运行。您可能需要多个活动调试器(每个进程或系统一个)来正确调试事件驱动架构。
最后,当使用事件驱动架构时,错误处理变得稍微复杂一些。大多数生产者与它们的消费者解耦;当消费者抛出异常或返回错误时,往往不容易从生产者端处理。
作为一个思维实验,考虑一下如果一个生产者产生了一个事件,而五个消费者消费了它会发生什么。如果被通知的第三个消费者抛出异常,应该发生什么?其他消费者应该得到异常吗,还是应该停止执行?生产者应该知道任何错误条件吗,还是错误应该被吞噬?如果生产者接收到异常,如果不同的消费者产生不同的异常会发生什么?对于所有这些问题没有一个正确的答案;请咨询您用于事件驱动架构的工具,以更好地了解在这些情况下会发生什么。
尽管存在这些缺点,事件驱动架构在需要为系统提供急需的灵活性的情况下是值得的。未来的维护者可以在最小的影响下替换您的生产者或消费者。他们可以引入新的生产者和消费者以创建新功能。他们可以快速集成外部系统,为新的合作伙伴关系打开大门。而且最重要的是,他们正在处理小型、模块化的系统,这些系统易于独立测试和理解。
简单事件
事件导向架构的最简单情况是处理简单事件,比如在某些条件变化时采取行动或通知您。您的信息生产者发送事件,您的消费者接收并对事件采取行动。有两种典型的实现方式:使用或不使用消息代理。
使用消息代理
消息代理是一种特定的代码片段,用作数据传输。生产者会将称为消息的数据发布到消息代理上的特定主题。主题只是一个唯一的标识符,比如一个字符串。它可以是简单的,比如“orders”,或者复杂的,比如“sandwich order is finished”。它只是一个命名空间,用于区分一个消息通道与另一个。消费者使用相同的标识符订阅一个主题。消息代理然后将消息发送给所有订阅该主题的消费者。这种类型的系统也被称为发布/订阅,简称 pub/sub。图 18-2 展示了一个假设的 pub/sub 架构。
图 18-2. 一个假设的基于消息代理的架构
在本章中,我将设计为餐厅的自动无人机送餐服务通知系统。当顾客订单烹饪完成时,无人机系统立即启动,接收订单并将餐点送到正确的地址。此系统中有五个通知,我已将它们拆分成生产者-消费者在 Table 18-2 中。
表 18-2. 自动无人机送餐系统中的生产者和消费者
| 生产者 | 消费者 |
|---|---|
| 餐点已经烹饪完成 | 无人机已经通知进行取货 |
| 餐点已经烹饪完成 | 顾客已经收到餐点烹饪完成的通知 |
| 无人机正在途中 | 顾客已经收到关于预计到达时间的通知 |
| 无人机已经交付餐点 | 顾客已经收到交付通知 |
| 无人机已经交付餐点 | 餐厅已经收到交付通知 |
我不希望这些系统直接相互了解,因为处理顾客、无人机和餐厅的代码应保持独立(它们由不同的团队维护,我希望保持物理依赖低)。
首先,我将定义系统中存在的主题:餐点已经烹饪完成、无人机正在途中以及订单已经交付。
为了这个示例,我将使用 Python 库PyPubSub,这是用于单进程应用程序的发布-订阅 API。要使用它,您需要设置订阅主题的代码和发布到主题的其他代码。首先,您需要安装pypubsub:
pip install pypubsub
然后,要订阅该主题,您需要指定主题和要调用的函数:
from pubsub import pub
def notify_customer_that_meal_is_done(order: Order):
# ... snip ...
pub.subscribe(notify_customer_that_meal_is_done, "meal-done")
然后,要发布到这个主题,您需要执行以下操作:
from pubsub import pub
def complete_order(order: Order):
packge_order(order)
pub.publish("meal-done", order)
警告
订阅者在与发布者相同的线程中运行,这意味着任何阻塞 I/O,如等待读取套接字,将会阻塞发布者。这将影响所有其他订阅者,应避免这种情况发生。
这两段代码彼此之间没有任何关联;它们的全部依赖仅限于 PyPubSub 库以及在主题/消息数据上达成一致。这使得添加新的订阅者变得非常容易:
from pubsub import pub
def schedule_pick_up_for_meal(order: Order):
'''Schedule a drone pick-up'''
# ... snip ...
pub.subscribe(schedule_pick_up_for_meal, "meal-done")
您不能更容易扩展。通过定义存在于系统内的主题,您可以轻松创建新的生产者或消费者。随着系统的增长需求,您通过与现有消息系统的交互来扩展它。
PyPubSub 还提供了一些选项来帮助调试。您可以通过添加自己的功能来添加审计操作,例如创建新主题或发送消息。您可以添加错误处理程序来处理任何订阅者抛出的异常。您还可以设置订阅所有主题的订阅者。如果您想了解更多关于这些功能或 PyPubSub 中任何其他功能的信息,请查阅PyPubSub 文档。
注意
PyPubSub 用于单进程应用程序;你无法发布到运行在其他进程或系统中的代码。其他应用程序可以提供此功能,例如Kafka,Redis,或RabbitMQ。查阅每个工具的文档以了解如何在 Python 中使用它们。
观察者模式
如果你不想使用消息代理,你可以选择实现观察者模式。¹ 在观察者模式中,你的生产者包含一个观察者列表:这些在此场景中是消费者。观察者模式不需要单独的库来充当消息代理。
为了避免直接连接生产者和消费者,你需要将观察者的知识保持通用化。换句话说,将观察者的任何具体知识抽象出来。我将通过仅使用函数(类型注释为Callable)来做到这一点。以下是我将如何重写先前示例以使用观察者模式的方法:
def complete_order(order: Order, observers: list[Callable[Order]]):
package_order(order)
for observer_func in observers:
observer(order)
在这种情况下,生产者只知道调用以通知的函数列表。要添加新的观察者,你只需将它们添加到作为参数传递的列表中。此外,由于这只是函数调用,你的类型检查器将能够检测到当生产者或其观察者以不兼容的方式发生变化时,这是消息代理范式的巨大优势。这也更容易调试,因为你不需要在调试器中步进第三方消息代理代码。
上面的观察者模式确实有一些缺点。首先,你对出现的错误更加敏感。如果观察者抛出异常,生产者需要能够直接处理(或者使用一个辅助函数或类来处理包装在try…except中的通知)。其次,生产者到观察者的连接比消息代理范式更直接。在消息代理范式中,发布者和订阅者可以连接起来,而不管它们在代码库中的位置如何。
相反,观察者模式要求通知的调用者(在前面的情况下是complete_order)知道观察者。如果调用者不直接知道观察者,那么它的调用者需要传递观察者。这可能一直延续到调用栈深处,直到有一段代码直接了解观察者为止。如果发现自己通过多个函数传递观察者以到达调用栈深处的生产者,请考虑使用消息代理代替。
如果您想更深入地了解简单事件的事件驱动架构,我推荐 Harry Percival 和 Bob Gregory(O’Reilly)的书籍Architecture Patterns with Python,其第二部分完全是关于事件驱动架构的。
讨论主题
事件驱动架构如何提升代码库内的解耦性?观察者模式或消息代理哪一个更适合您的需求?
流式事件
在前面的部分中,简单事件被表示为满足某一条件时发生的离散事件。消息代理和观察者模式是处理简单事件的好方法。然而,一些系统处理永不停止的事件序列。事件以连续的数据流的形式流入系统。想象一下上一节中描述的无人机系统。考虑每个无人机传输的所有数据。可能包括位置数据、电池电量、当前速度、风数据、天气数据和当前负载重量。这些数据将定期传入,并且您需要一种处理方式。
在这类用例中,您不希望构建所有发布/订阅或观察者的样板代码;您需要一种与您的用例匹配的架构。您需要一个以事件为中心并为处理每个事件定义工作流的编程模型。这就是响应式编程的作用。
响应式编程是围绕事件流的一种架构风格。您将数据源定义为这些流的生产者,然后将多个观察者链接在一起。每个观察者在数据变化时得到通知,并定义一系列操作来处理数据流。响应式编程风格由ReactiveX推广。在本节中,我将使用 ReactiveX 的 Python 实现:RxPY。
我将使用pip安装 RxPy:
pip install rx
接下来,我需要定义一个数据流。在 RxPY 术语中,这被称为可观察对象。例如,我将使用一个硬编码的单个可观察对象进行示例,但实际上,您将从真实数据生成多个可观察对象。
import rx
# Each one of these is simulating an independent real-world event streaming in
observable = rx.of(
LocationData(x=3, y=12, z=40),
BatteryLevel(percent=95),
BatteryLevel(percent=94),
WindData(speed=15, direction=Direction.NORTH),
# ... snip 100s of events
BatteryLevel(percent=72),
CurrentWeight(grams=300)
)
此可观察对象是从不同类型事件的事件列表中生成的,用于无人机数据。
下一步需要定义每个事件的处理方法。一旦有可观察对象,观察者可以订阅它,类似于发布/订阅机制:
def handle_drone_data(value):
# ... snip handle drone data ...
observable.subscribe(handle_drone_data)
这看起来与普通的发布/订阅习语并没有太大不同。
管道运算符真正的魔力就在这里。RxPY 允许您将操作管道化或链接在一起,形成一个过滤器、转换和计算的管道。例如,我可以使用rx.pipe编写一个操作符管道来获取飞行器的平均重量:
import rx.operators
get_average_weight = observable.pipe(
rx.operators.filter(lambda data: isinstance(data, CurrentWeight)),
rx.operators.map(lambda cw: cw.grams),
rx.operators.average()
)
# save_average_weight does something with the final data
# (e.g. save to database, print to screen, etc.)
get_average_weight.subscribe(save_average_weight)
类似地,我可以编写一个管道链,跟踪无人机离开餐厅后的最大高度:
get_max_altitude = observable.pipe(
rx.operators.skip_while(is_close_to_restaurant),
rx.operators.filter(lambda data: isinstance(data, LocationData)),
rx.operators.map(lambda loc: loc.z),
rx.operators.max()
)
# save max altitude does something with the final data
# (e.g. save to database, print to screen, etc)
get_max_altitude.subscribe(save_max_altitude)
注意
Lambda 函数 只是一个没有名称的内联函数。它通常用于只使用一次的函数,你不希望将函数的定义放得离它的使用太远。
这是我们老朋友可组合性(如第十七章中所见)在帮助我们。我可以随心所欲地组合不同的操作符,以产生符合我的用例的数据流。RxPY 支持超过一百个内置操作符,以及定义自己操作符的框架。你甚至可以将一个管道的结果组合成其他程序部分可以观察的新事件流。这种可组合性,加上事件订阅的解耦特性,使你在编写代码时拥有极大的灵活性。此外,响应式编程鼓励不可变性,大大降低了出错的可能性。你可以连接新的管道,组合操作符,异步处理数据等等,这些都是响应式框架如 RxPY 能够做到的。
在独立环境中调试也变得容易了。虽然你不能轻易地通过调试器逐步进行 RxPY 的调试(你会陷入与操作和可观察对象相关的大量复杂代码中),但你可以步进到你传递给操作符的函数中。测试也非常简单。由于所有的函数都应该是不可变的,你可以单独测试它们中的任何一个。最终你会得到很多小而专用的函数,这些函数易于理解。
这种模型在围绕数据流的系统中表现出色,比如数据管道和抽取、转换、加载(ETL)系统。在以对 I/O 事件的反应为主的应用程序中,如服务器应用程序和 GUI 应用程序中,它也非常有用。如果响应式编程符合你的领域模型,我鼓励你阅读RxPY 文档。如果你想要更结构化的学习,我推荐视频课程Reactive Python for Data Science或书籍Hands-On Reactive Programming with Python: Event-Driven Development Unraveled with RxPY,作者是 Romain Picard(O’Reilly)。
总结思考
事件驱动架构非常强大。事件驱动架构允许你将信息的生产者和消费者分开。通过解耦这两者,你为系统引入了灵活性。你可以替换功能、在隔离环境中测试你的代码,或者通过引入新的生产者或消费者来扩展新功能。
设计事件驱动系统有许多方式。您可以选择在系统中继续使用简单事件和观察者模式来处理轻量级事件。随着规模扩大,您可能需要引入消息代理,例如使用 PyPubSub。甚至在跨进程或系统进行扩展时,您可能需要使用另一个库作为消息代理。最后,当您接近事件流时,您可以考虑使用响应式编程框架,如 RxPY。
在接下来的章节中,我将介绍一种不同类型的架构范例:插件架构。插件架构提供了与事件驱动架构类似的灵活性、可组合性和可扩展性,但方式完全不同。而事件驱动架构专注于事件,插件架构则专注于可插拔的实现单元。您将看到,插件架构如何为您提供丰富的选项,以构建一个易于维护的健壮代码库。
¹ 观察者模式首次被描述在《设计模式:可复用面向对象软件的基础》一书中,作者是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Addison-Wesley Professional)。这本书通常被称为“四人组(GoF)”书籍。
第十九章:可插拔的 Python
建立稳健代码库最大的挑战在于预测未来。你永远不可能完全猜透未来的开发者会做什么。最好的策略不是完全精准地预见,而是创建灵活性,使未来的合作者可以用最少的工作接入你的系统。在本章中,我将专注于创建可插拔代码。可插拔的代码允许你定义稍后提供的行为。你可以定义一个带有扩展点的框架,或者是系统中其他开发者将用来扩展功能的部分。
想象一台放在厨房台面上的搅拌机。你可以选择各种附件与你的搅拌机一起使用:揉面包的钩子、打蛋和奶油的打蛋器,以及通用搅拌的扁平搅拌器。每个附件都有特定的用途。很棒的是,你可以根据情况拆卸和安装钩子或刀片。你不需要为每个用例购买全新的搅拌机;你在需要时插入你需要的东西即可。
这就是可插拔 Python 的目标。当需要新功能时,你无需重建整个应用程序。你构建扩展或附件,它们可以轻松地连接到坚实的基础上。你选择你特定用例所需的功能,然后将其插入你的系统中。
在本书的大部分内容中,我一直在用各种自动食品制造器做例子。在本章中,我将执行各种合并并设计一个可以结合它们所有的系统。我想要构建一个可以接受我讲过的任何食谱并烹饪它们的系统。我称之为“终极厨房助手”(如果你认为这是个糟糕的名字,现在你知道为什么我不从事市场营销工作了)。
终极厨房助手包含了你在厨房工作所需的所有指示和装备。它知道如何切片、切块、炸、煎、烘烤、烧烤和混合任何食材。它附带了一些预制的食谱,但真正的魔力在于顾客可以购买现成的模块来扩展其功能(比如“意大利面条制作模块”,满足意大利菜的需求)。
问题在于,我不希望代码变得难以维护。有很多不同的菜需要做,我希望系统具有某种灵活性,而不是因为大量物理依赖而使系统变成意大利面条代码(尽管你的系统自己在厨房制作意大利面条非常鼓励!)。就像给搅拌机插上新附件一样,我希望开发者能够连接不同的附件来解决他们的用例。我甚至希望其他组织为终极厨房助手构建模块。我希望这个代码库具有可扩展性和可组合性。
我将用这个例子来说明三种不同的插入不同 Python 结构的方法。首先,我将专注于如何使用模板方法模式插入算法的特定部分。然后,我将讲解如何使用策略模式插入整个类。最后,我将向您介绍一个非常有用的库,stevedore,以在更大的架构规模上进行插件。所有这些技术都将帮助您为未来的开发人员提供所需的可扩展性。
模板方法模式
模板方法模式 是一种填充算法空白的设计模式。¹ 思想是你定义一个算法为一系列步骤,但强制调用者重写其中的一些步骤,如 图 19-1 所示。
图 19-1. 模板方法模式
终极厨房助手首先介绍的是一个披萨制作模块。虽然传统的酱和奶酪披萨很棒,但我希望终极厨房助手更加灵活。我希望它能处理各种类似披萨的实体,从黎巴嫩马努什到韩国烤牛肉披萨。为了制作这些类似披萨的菜肴中的任何一种,我希望机制执行一系列类似的步骤,但让开发人员调整某些操作,以制作他们自己风格的披萨。 图 19-2 描述了这样一个披萨制作算法。
图 19-2. 披萨制作算法
每个披萨将使用相同的基本步骤,但我希望能够调整某些步骤(准备配料、添加预烘烤配料和添加后烘烤配料)。我在应用模板方法模式时的目标是使这些步骤可插拔。
在最简单的情况下,我可以将函数传递给模板方法:
@dataclass
class PizzaCreationFunctions:
prepare_ingredients: Callable
add_pre_bake_toppings: Callable
add_post_bake_toppings: Callable
def create_pizza(pizza_creation_functions: PizzaCreationFunctions):
pizza_creation_functions.prepare_ingredients()
roll_out_pizza_base()
pizza_creation_functions.add_pre_bake_toppings()
bake_pizza()
pizza_creation_functions.add_post_bake_toppings()
现在,如果您想要制作披萨,您只需传入自己的函数:
pizza_creation_functions = PizzaCreationFunctions(
prepare_ingredients=mix_zaatar,
add_pre_bake_toppings=add_meat_and_halloumi,
add_post_bake_toppings=drizzle_olive_oil
)
create_pizza(pizza_creation_functions)
这对任何披萨来说都非常方便,现在或将来。随着新的披萨制作能力上线,开发人员需要将他们的新函数传递到模板方法中。这些开发人员可以插入披萨制作算法的特定部分,以满足他们的需求。他们根本不需要了解他们的用例;他们可以自由地扩展系统,而不会被改变旧代码所困扰。假设他们想要创建烤牛肉披萨。我只需传入一个新的 PizzaCreationFunctions,而不是改变 create_pizza:
pizza_creation_functions = PizzaCreationFunctions(
prepare_ingredients=cook_bulgogi,
add_pre_bake_toppings=add_bulgogi_toppings,
add_post_bake_toppings=garnish_with_scallions_and_sesame
)
create_pizza(pizza_creation_functions)
策略模式
模板方法模式非常适合交换算法中的某些部分,但如果您想要替换整个算法呢?对于这种情况,存在一个非常类似的设计模式:策略模式。
策略模式用于将整个算法插入到上下文中。² 对于最终的厨房助手,考虑专门从事 Tex-Mex 的模块(一种将美国西南部和墨西哥北部菜肴混合的美国地区菜肴)。可以从一组共同的食材制作出各种各样的菜肴;你可以通过不同方式混搭这些不同的配料。
例如,你会在大多数 Tex-Mex 菜单上找到以下配料:玉米或小麦面粉的玉米饼,豆类,碎牛肉,鸡肉,生菜,番茄,鳄梨酱,莎莎酱和奶酪。从这些配料中,你可以制作出塔科斯、弗劳塔斯、奇米昌加斯、恩奇拉达、塔科沙拉、玉米片、戈迪塔……种类繁多。我不希望系统限制所有不同的 Tex-Mex 菜肴;我希望不同的开发团队提供如何制作这些菜肴的信息。
要使用策略模式做到这一点,我需要定义最终的厨房助手所做的事情以及策略所做的事情。在这种情况下,最终的厨房助手应提供与配料交互的机制,但未来的开发人员可以自由添加新的 Tex-Mex 调配方案,如TexMexStrategy。
与任何设计为可扩展的代码一样,我需要确保我最终的厨房助手和 Tex-Mex 模块之间的交互符合前置条件和后置条件,即传递给 Tex-Mex 模块的内容以及输出的内容。
假设最终的厨房助手有编号的箱子用于放置食材。Tex-Mex 模块需要知道常见的 Tex-Mex 食材放在哪些箱子里,以便可以利用最终的厨房助手进行准备和烹饪。
@dataclass
class TexMexIngredients:
corn_tortilla_bin: int
flour_tortilla_bin: int
salsa_bin: int
ground_beef_bin: int
# ... snip ..
shredded_cheese_bin: int
def prepare_tex_mex_dish(tex_mex_recipe_maker: Callable[TexMexIngredients]);
tex_mex_ingredients = get_available_ingredients("Tex-Mex")
dish = tex_mex_recipe_maker(tex_mex_ingredients)
serve(dish)
函数prepare_tex_mex_dish收集配料,然后委托给实际的tex_mex_recipe_maker来创建要服务的菜肴。tex_mex_recipe_maker就是策略。这与模板方法模式非常相似,但通常只传递单个函数而不是一组函数。
未来的开发人员只需编写一个根据配料实际进行准备的函数。他们可以编写:
import tex_mex_module as tmm
def make_soft_taco(ingredients: TexMexIngredients) -> tmm.Dish:
tortilla = tmm.get_ingredient_from_bin(ingredients.flour_tortilla_bin)
beef = tmm.get_ingredient_from_bin(ingredients.ground_beef_bin)
dish = tmm.get_plate()
dish.lay_on_dish(tortilla)
tmm.season(beef, tmm.CHILE_POWDER_BLEND)
# ... snip
prepare_tex_mex_dish(make_soft_taco)
如果他们决定未来某个时候提供对不同菜肴的支持,他们只需编写一个新的函数:
def make_chimichanga(ingredients: TexMexIngredients):
# ... snip
开发人员可以随时随地继续定义函数。就像模板方法模式一样,他们可以在对原始代码影响最小的情况下插入新功能。
注意
与模板方法一样,我展示的实现与《四人组设计模式》中最初描述的有些不同。原始实现涉及包装单个方法的类和子类。在 Python 中,仅传递单个函数要简单得多。
插件架构
策略模式和模板方法模式非常适合插入小功能块:在这里是一个类或一个函数。然而,同样的模式也适用于你的架构。能够注入类、模块或子系统同样重要。一个名为stevedore的 Python 库是管理插件的一个非常有用的工具。
插件是可以在运行时动态加载的代码片段。代码可以扫描已安装的插件,选择合适的插件,并将责任委派给该插件。这是另一个可扩展性的例子;开发人员可以专注于特定的插件而不用触及核心代码库。
插件架构不仅具有可扩展性的优点:
-
您可以独立部署插件,而不影响核心,这使得您在推出更新时拥有更多的粒度。
-
第三方可以编写插件,而无需修改您的代码库。
-
插件可以在与核心代码库隔离的环境中开发,减少创建紧密耦合代码的可能性。
为了演示插件的工作原理,假设我想支持终极厨房助手的生态系统,用户可以单独购买和安装模块(例如上一节中的 Tex-Mex 模块)。每个模块为终极厨房助手提供一组食谱、特殊设备和食材存储。真正的好处在于,每个模块都可以与终极厨房助手核心分开开发;每个模块都是一个插件。
设计插件时的第一步是确定核心与各种插件之间的契约。问问自己核心平台提供了哪些服务,您期望插件提供什么。在终极厨房助手的情况下,Figure 19-3 展示了我将在接下来的示例中使用的契约。
图 19-3. 核心与插件之间的契约
我想将这个契约放入代码中,以便清楚地表达我对插件的期望:
from abc import abstractmethod
from typing import runtime_checkable, Protocol
from ultimate_kitchen_assistant import Amount, Dish, Ingredient, Recipe
@runtime_checkable
class UltimateKitchenAssistantModule(Protocol):
ingredients: list[Ingredient]
@abstractmethod
def get_recipes() -> list[Recipe]:
raise NotImplementedError
@abstractmethod
def prepare_dish(inventory: dict[Ingredient, Amount],
recipe: Recipe) -> Dish:
raise NotImplementedError
这就是插件的定义。要创建符合我的期望的插件,我只需创建一个从我的基类继承的类。
class PastaModule(UltimateKitchenAssistantModule):
def __init__(self):
self.ingredients = ["Linguine",
# ... snip ...
"Spaghetti" ]
def get_recipes(self) -> list[Recipe]:
# ... snip returning all possible recipes ...
def prepare_dish(self, inventory: dict[Ingredient, Amount],
recipe: Recipe) -> Dish:
# interact with Ultimate Kitchen Assistant to make recipe
# ... snip ...
一旦您创建了插件,您需要使用 stevedore 将其注册。stevedore 将插件与一个命名空间或将插件分组在一起的标识符进行匹配。它通过使用 Python 的入口点在运行时发现组件来实现这一点。³
您可以通过setuptools和setup.py注册插件。许多 Python 包使用setup.py来定义打包规则,其中之一就是入口点。在ultimate_kitchen_assistant的setup.py中,我将我的插件注册如下:
from setuptools import setup
setup(
name='ultimate_kitchen_assistant',
version='1.0',
#.... snip ....
entry_points={
'ultimate_kitchen_assistant.recipe_maker': [
'pasta_maker = ultimate_kitchen_assistant.pasta_maker:PastaModule',
'tex_mex = ultimate_kitchen_assistant.tex_mex:TexMexModule'
],
},
)
注意
如果你在链接插件时遇到问题,请查看 entry-point-inspector 包 获取调试帮助。
我正在将我的 PastaMaker 类(在 ultimate_kitchen_assistant.pasta_maker 包中)绑定到命名空间为 ultimate_kitchen_assistant.recipe_maker 的插件上。我还创建了另一个名为 TexMexModule 的假设性插件。
一旦插件被注册为入口点,你可以在运行时使用 stevedore 动态加载它们。例如,如果我想从所有插件中收集所有的菜谱,我可以编写以下代码:
import itertools
from stevedore import extension
from ultimate_kitchen_assisstant import Recipe
def get_all_recipes() -> list[Recipe]:
mgr = extension.ExtensionManager(
namespace='ultimate_kitchen_assistant.recipe_maker',
invoke_on_load=True,
)
def get_recipes(extension):
return extension.obj.get_recipes()
return list(itertools.chain(mgr.map(get_recipes)))
我使用 stevedore.extension.ExtensionManager 查找和加载命名空间为 ultimate_kitchen_assistant.recipe_maker 的所有插件。然后,我可以对找到的每个插件映射(或应用)一个函数以获取它们的菜谱。最后,我使用 itertools 将它们全部连接在一起。无论我设置了多少个插件,都可以用这段代码加载它们。
假设用户想要从意大利面机制造一些东西,比如“意式香肠意面”。所有调用代码需要做的就是请求一个名为 pasta_maker 的插件。我可以通过 stevedore.driver.DriverManager 加载特定的插件。
from stevedore import driver
def make_dish(recipe: Recipe, module_name: str) -> Dish:
mgr = driver.DriverManager(
namespace='ultimate_kitchen_assistant.recipe_maker',
name=module_name,
invoke_on_load=True,
)
return mgr.driver.prepare_dish(get_inventory(), recipe)
讨论主题
你的系统哪些部分可以使用插件架构?这如何使你的代码库受益?
stevedore 提供了一种很好的方式来解耦代码;将代码分离成插件使其保持灵活和可扩展。记住,可扩展程序的目标是限制对核心系统所需的修改次数。开发者可以独立创建插件,测试它们,并将其无缝集成到你的核心中。
我最喜欢的 stevedore 的部分是它实际上可以跨包工作。你可以在完全独立的 Python 包中编写插件,而不是核心包。只要插件使用相同的命名空间,stevedore 就可以把所有东西组合起来。stevedore 还有许多其他值得一探的功能,比如事件通知、通过多种方法启用插件以及自动生成插件文档。如果插件架构符合你的需求,我强烈建议多了解 stevedore。
警告
你实际上可以注册任何类作为插件,无论它是否可替换基类。因为代码被 stevedore 分离到一个抽象层中,你的类型检查器将无法检测到这一点。在使用插件之前,考虑在运行时检查接口以捕捉任何不匹配。
总结思考
创建可插拔的 Python 程序时,你赋予了合作者隔离新功能的能力,同时仍然可以轻松地将其集成到现有的代码库中。开发者可以使用模板方法模式(Template Method Pattern)插入现有算法,使用策略模式(Strategy Pattern)插入整个类或算法,或者使用 stevedore 插入整个子系统。当你想将插件跨离散的 Python 包中分布时,stevedore 尤为有用。
这结束了关于第 III 部分的内容,重点是可扩展性。编写可扩展的代码遵循开闭原则,使得您可以轻松添加代码而无需修改现有代码。事件驱动架构和插件架构是设计可扩展性的绝佳例子。所有这些架构模式都要求您了解依赖关系:物理、逻辑和时间依赖。当您找到减少物理依赖的方法时,您会发现您的代码变得可组合,并可以随意重新组合成新的组合形式。
本书的前三部分着重于可以使您的代码更易于维护和阅读,并减少错误发生的几率。然而,错误仍然可能会出现;它们是软件开发中不可避免的一部分。为了应对这一点,您需要使错误在进入生产环境之前易于检测。您将学会如何使用诸如 linter 和测试工具来实现这一点,详见第 IV 部分,构建安全网络。
¹ Erich Gamma, Richard Helm, Ralph E. Johnson, and John Vlissides. 设计模式:可复用面向对象软件的元素. 波士顿, MA: Addison-Wesley Professional, 1994.
² Erich Gamma, Richard Helm, Ralph E. Johnson, and John Vlissides. 设计模式:可复用面向对象软件的元素. 波士顿, MA: Addison-Wesley Professional, 1994.
³ 入口点在与 Python 包装互动方面可能会很复杂,但这超出了本书的范围。您可以在https://oreil.ly/bMyJS了解更多信息。
第四部分:构建安全网
欢迎来到本书的第四部分,这部分内容讲述了围绕代码库构建安全网的重要性。想象一下一个在高空中危险地平衡的走钢丝演员。无论表演者练习了多少次他们的表演,都总是会有一套安全措施以防万一。走钢丝的演员可以充满信心地表演,相信如果他们失足,一定会有东西来阻止他们坠落。你希望为你的合作者提供同样的信心和信任,让他们在你的代码库中工作。
即使你的代码完全没有错误,它能保持多久呢?每一次变更都带来风险。每一个新的开发人员进入代码库都需要时间才能完全理解其中的复杂性。客户会改变主意,要求完全与六个月前相反的东西。这都是软件开发生命周期的自然部分。
你的开发安全网是静态分析和测试的结合。关于测试以及如何编写好测试的话题已经有很多文章写过。在接下来的章节中,我将重点介绍为什么编写测试,如何决定编写哪些测试,以及如何使这些测试更有价值。我将超越简单的单元测试和集成测试,谈论更高级的测试技术,如验收测试、基于属性的测试和变异测试。
第二十章:静态分析
在进行测试之前,我首先想谈一下静态分析。静态分析 是一组工具,检查您的代码库,寻找潜在的错误或不一致之处。它是发现常见错误的重要工具。实际上,您已经在使用一个静态分析工具:mypy。Mypy(和其他类型检查器)检查您的代码库并找到类型错误。其他静态分析工具检查其他类型的错误。在本章中,我将介绍常见的用于代码检查、复杂度检查和安全扫描的静态分析工具。
代码检查
我将首先向您介绍的静态分析工具类别称为 代码检查器。代码检查器在您的代码库中搜索常见的编程错误和风格违规。它们的名称源自最初的代码检查器:一个名为 lint 的程序,用于检查 C 语言程序的常见错误。它会搜索“模糊”逻辑并尝试消除这种模糊(因此称为 linting)。在 Python 中,您最常遇到的代码检查器是 Pylint。Pylint 用于检查大量常见错误:
-
某些违反 PEP 8 Python 风格指南的风格违规
-
不可达的死代码(例如在返回语句之后的代码)
-
违反访问限制条件(例如类的私有或受保护成员)
-
未使用的变量和函数
-
类内的内聚性不足(在方法中没有使用 self,公共方法过多)
-
缺少文档,如文档字符串形式
-
常见的编程错误
这些错误类别中的许多是我们先前讨论过的内容,例如访问私有成员或函数需要成为自由函数而不是成员函数(如第十章讨论的)。像 Pylint 这样的代码检查工具将为您补充本书中学到的所有技术;如果您违反了我一直提倡的一些原则,代码检查工具将为您捕捉这些违规行为。
Pylint 在查找代码中一些常见错误方面也非常有用。考虑一个开发者添加将所有作者的食谱书籍添加到现有列表的代码:
def add_authors_cookbooks(author_name: str, cookbooks: list[str] = []) -> bool:
author = find_author(author_name)
if author is None:
assert False, "Author does not exist"
else:
for cookbook in author.get_cookbooks():
cookbooks.append(cookbook)
return True
这看起来无害,但是这段代码中有两个问题。请花几分钟看看您能否找到它们。
现在让我们看看 Pylint 能做什么。首先,我需要安装它:
pip install pylint
然后,我将对上述示例运行 Pylint:
pylint code_examples/chapter20/lint_example.py
************* Module lint_example
code_examples/chapter20/lint_example.py:11:0: W0102:
Dangerous default value [] as argument (dangerous-default-value)
code_examples/chapter20/lint_example.py:11:0: R1710:
Either all return statements in a function should return an expression,
or none of them should. (inconsistent-return-statements)
Pylint 在我的代码中标识出了两个问题(实际上找到了更多,比如缺少文档字符串,但为了本讨论的目的我已经省略了它们)。首先,存在一个危险的可变默认参数形式为[]。关于这种行为已经写了很多文章,但这对于错误,特别是对于新手来说,是一个常见的陷阱。
另一个错误更加微妙:不是所有分支都返回相同的类型。“等等!”你会说。“没关系,因为我断言,这会引发一个错误,而不是通过if语句(返回None)。然而,虽然assert语句很棒,但它们可以被关闭。当你给 Python 传递-O标志时,它会禁用所有assert语句。因此,当打开-O标志时,这个函数返回None。值得一提的是,mypy 并不会捕获这个错误,但是 Pylint 可以。更好的是,Pylint 在不到一秒钟的时间内找到了这些错误。
无论你是否犯下这些错误,或者你是否总是在代码审查中找到它们。在任何代码库中都有无数开发人员在工作,错误可能发生在任何地方。通过强制执行像 Pylint 这样的代码检查工具,你可以消除非常常见的可检测错误。有关内置检查器的完整列表,请参阅Pylint 文档。
编写你自己的 Pylint 插件
当你编写自己的插件时,真正的 Pylint 魔法开始发挥作用(有关插件架构的更多信息,请参阅第十九章)。Pylint 插件允许你编写自己的自定义检查器或规则。虽然内置检查器查找常见的 Python 错误,但你的自定义检查器可以查找你问题领域中的错误。
看一看远在第四章的代码片段:
ReadyToServeHotDog = NewType("ReadyToServeHotDog", HotDog)
def prepare_for_serving() -> ReadyToServeHotDog:
# snip preparation
return ReadyToServeHotDog(hotdog)
在第四章中,我提到过,为了使NewType生效,你需要确保只能通过blessed方法来构造它,或者强制执行与该类型相关的约束。当时,我的建议是使用注释来给代码读者一些提示。然而,使用 Pylint,你可以编写一个自定义检查器来查找违反这一期望的情况。
这是插件的完整内容。之后我会为你详细解释:
from typing import Optional
import astroid
from pylint.checkers import BaseChecker
from pylint.interfaces import IAstroidChecker
from pylint.lint.pylinter import PyLinter
class ServableHotDogChecker(BaseChecker):
__implements__ = IAstroidChecker
name = 'unverified-ready-to-serve-hotdog'
priority = -1
msgs = {
'W0001': (
'ReadyToServeHotDog created outside of hotdog.prepare_for_serving.',
'unverified-ready-to-serve-hotdog',
'Only create a ReadyToServeHotDog through hotdog.prepare_for_serving.'
),
}
def __init__(self, linter: Optional[PyLinter] = None):
super(ServableHotDogChecker, self).__init__(linter)
self._is_in_prepare_for_serving = False
def visit_functiondef(self, node: astroid.scoped_nodes.FunctionDef):
if (node.name == "prepare_for_serving" and
node.parent.name =="hotdog" and
isinstance(node.parent, astroid.scoped_nodes.Module)):
self._is_in_prepare_for_serving = True
def leave_functiondef(self, node: astroid.scoped_nodes.FunctionDef):
if (node.name == "prepare_for_serving" and
node.parent.name =="hotdog" and
isinstance(node.parent, astroid.scoped_nodes.Module)):
self._is_in_prepare_for_serving = False
def visit_call(self, node: astroid.node_classes.Call):
if node.func.name != 'ReadyToServeHotDog':
return
if self._is_in_prepare_for_serving:
return
self.add_message(
'unverified-ready-to-serve-hotdog', node=node,
)
def register(linter: PyLinter):
linter.register_checker(ServableHotDogChecker(linter))
这个代码检查器验证了当有人创建一个ReadyToServeHotDog时,它只能在一个名为prepare_for_serving的函数中完成,并且该函数必须位于名为hotdog的模块中。现在假设我创建了任何其他创建准备供应热狗的函数,如下所示:
def create_hot_dog() -> ReadyToServeHotDog:
hot_dog = HotDog()
return ReadyToServeHotDog(hot_dog)
我可以运行我的自定义 Pylint 检查器:
PYTHONPATH=code_examples/chapter20 pylint --load-plugins \
hotdog_checker code_examples/chapter20/hotdog.py
Pylint 确认现在服务“不可供应”的热狗是一个错误:
************* Module hotdog
code_examples/chapter20/hotdog.py:13:12: W0001:
ReadyToServeHotDog created outside of prepare_for_serving.
(unverified-ready-to-serve-hotdog)
这太棒了。现在我可以编写自动化工具,用来检查那些像我的 mypy 类型检查器无法甚至开始查找的错误。不要让你的想象力束缚你。使用 Pylint 可以捕捉任何你能想到的东西:业务逻辑约束违规、时间依赖性或者自定义样式指南。现在,让我们看看这个代码检查器是如何工作的,这样你就能够构建你自己的。
插件分解
写插件的第一件事是定义一个从pylint.checkers.BaseChecker继承的类:
import astroid
from pylint.checkers import BaseChecker
from pylint.interfaces import IAstroidChecker
class ReadyToServeHotDogChecker(BaseChecker):
__implements__ = IAstroidChecker
您还会注意到一些对astroid的引用。astroid库用于将 Python 文件解析为抽象语法树(AST),这为与 Python 源代码交互提供了一种便捷的结构化方式。很快您将看到这在哪些方面非常有用。
接下来,我定义插件的元数据。这提供了插件名称、显示给用户的消息以及一个标识符(unverified-ready-to-serve-hotdog),以便稍后引用。
name = 'unverified-ready-to-serve-hotdog'
priority = -1
msgs = {
'W0001': ( # this is an arbitrary number I've assigned as an identifier
'ReadyToServeHotDog created outside of hotdog.prepare_for_serving.',
'unverified-ready-to-serve-hotdog',
'Only create a ReadyToServeHotDog through hotdog.prepare_for_serving.'
),
}
接下来,我想跟踪我所在的函数,以便判断我是否在使用prepare_for_serving。这就是astroid库发挥作用的地方。如前所述,astroid库帮助 Pylint 检查器以 AST 的形式思考;您无需担心字符串解析。如果您想了解有关 AST 和 Python 解析的更多信息,可以查看astroid文档,但现在,您只需知道,如果在检查器中定义了特定函数,它们将在astroid解析代码时被调用。每个调用的函数都会传递一个node,代表代码的特定部分,例如表达式或类定义。
def __init__(self, linter: Optional[PyLinter] = None):
super(ReadyToServeHotDogChecker, self).__init__(linter)
self._is_in_prepare_for_serving = False
def visit_functiondef(self, node: astroid.scoped_nodes.FunctionDef):
if (node.name == "prepare_for_serving" and
node.parent.name =="hotdog" and
isinstance(node.parent, astroid.scoped_nodes.Module)):
self._is_in_prepare_for_serving = True
def leave_functiondef(self, node: astroid.scoped_nodes.FunctionDef):
if (node.name == "prepare_for_serving" and
node.parent.name =="hotdog" and
isinstance(node.parent, astroid.scoped_nodes.Module)):
self._is_in_prepare_for_serving = False
在这种情况下,我定义了一个构造函数来保存一个成员变量,以跟踪我是否在正确的函数中。我还定义了两个函数,visit_functiondef和leave_functiondef。visit_functiondef将在astroid解析函数定义时调用,而leave_functiondef在解析器停止解析函数定义时调用。因此,当解析器遇到函数时,我会检查该函数是否命名为prepare_for_serving,它位于名为hotdog的模块中。
现在我有一个成员变量来跟踪我是否在正确的函数中,我可以编写另一个astroid钩子,以便在每次调用函数时调用它(比如ReadyToServeHotDog(hot_dog))。
def visit_call(self, node: astroid.node_classes.Call):
if node.func.name != 'ReadyToServeHotDog':
return
if self._is_in_prepare_for_serving:
return
self.add_message(
'unverified-ready-to-serve-hotdog', node=node,
)
如果函数调用不是ReadyToServeHotDog,或者执行在prepare_serving中,这个检查器则不会发现问题并早早返回。如果函数调用是ReadyToServeHotDog,而执行不在prepare_serving中,检查器将失败并添加一条消息来指示unverified-ready-to-serve-hotdog检查失败。通过添加消息,Pylint 将把此信息传递给用户并标记为检查失败。
最后,我需要注册这个 linter:
def register(linter: PyLinter):
linter.register_checker(ReadyToServeHotDogChecker(linter))
就是这样了!使用大约 45 行 Python 代码,我定义了一个 Pylint 插件。这是一个简单的检查器,但是您可以无限想象您能做的事情。无论是内置的还是用户创建的 Pylint 检查器,对于查找错误都是无价的。
讨论主题
您在代码库中可以创建哪些检查器?您可以使用这些检查器捕捉哪些错误情况?
其他静态分析工具
类型检查器和代码检查器通常是人们在听到“静态分析”时首先想到的工具,但还有许多其他工具可以帮助你编写健壮的代码。每个工具都像是瑞士奶酪的一块。¹ 每块瑞士奶酪都有不同宽度或大小的孔洞,但当多块奶酪堆叠在一起时,几乎不可能有一个区域所有孔洞对齐,从而可以透过这个堆看到。
同样,你用来构建安全网络的每个工具都会忽略某些错误。类型检查器无法捕捉常见的编程错误,代码检查器无法检查安全违规,安全检查器无法捕捉复杂代码,等等。但是当这些工具堆叠在一起时,合法错误通过的可能性大大降低(对于那些通过的,那就是你需要测试的原因)。正如布鲁斯·麦克莱南所说,“设置一系列防御措施,这样如果一个错误没有被一个工具捕捉到,很可能会被另一个捕捉到。”²
复杂性检查器
本书大部分内容都集中在可读性和可维护性代码上。我谈到了复杂代码如何影响功能开发的速度。一个工具可以指示代码库中哪些部分具有高复杂性将会很好。不幸的是,复杂性是主观的,减少复杂性并不总是会减少错误。但我可以将复杂性度量视为启发式。启发式是提供答案但不保证是最优答案的东西。在这种情况下,问题是,“我代码中哪里可能有最多的 bug?”大多数情况下,会在复杂性高的代码中发现,但请记住这并非保证。
带麦卡比的圈复杂度
最流行的复杂性启发式之一被称为圈复杂度,最早由托马斯·麦卡比描述。³ 要测量代码的圈复杂度,你必须将代码视为控制流图,或者一个绘制出代码可以执行的不同路径的图形。图 20-1 展示了几个不同的例子。
图 20-1. 圈复杂度示例
图 20-1 的 A 部分展示了语句的线性流动,复杂度为一。如同 B 部分所示,没有 elif 语句的 if 语句有两条路径(if 或 else/跟随),因此复杂度为两。类似地,像 C 部分中的 while 循环,有两个不同的路径:循环继续或退出。随着代码变得更复杂,圈复杂度数字会变得更高。
您可以使用 Python 中的静态分析工具来测量圈复杂度,其名为mccabe。
我将用pip安装它:
pip install mccabe
为了测试它,我将在mccabe代码库本身上运行它,并标记任何圈复杂度大于或等于五的函数:
python -m mccabe --min 5 mccabe.py
192:4: 'PathGraphingAstVisitor._subgraph_parse' 5
273:0: 'get_code_complexity' 5
298:0: '_read' 5
315:0: 'main' 7
让我们来看看PathGraphingAstVisitor._subgraph_parse:
def _subgraph_parse(self, node, pathnode, extra_blocks):
"""parse the body and any `else` block of `if` and `for` statements"""
loose_ends = []
self.tail = pathnode
self.dispatch_list(node.body)
loose_ends.append(self.tail)
for extra in extra_blocks:
self.tail = pathnode
self.dispatch_list(extra.body)
loose_ends.append(self.tail)
if node.orelse:
self.tail = pathnode
self.dispatch_list(node.orelse)
loose_ends.append(self.tail)
else:
loose_ends.append(pathnode)
if pathnode:
bottom = PathNode("", look='point')
for le in loose_ends:
self.graph.connect(le, bottom)
self.tail = bottom
这个函数中发生了几件事情:各种条件分支、循环,甚至在if语句中嵌套了一个循环。每条路径都是独立的,需要进行测试。随着圈复杂度的增加,代码变得越来越难阅读和理解。圈复杂度没有一个魔法数字;您需要检查您的代码库并寻找一个合适的限制。
空白启发式
还有一种复杂度启发式方法我非常喜欢,比圈复杂度稍微简单一些来理解:空白检查。其思想如下:计算一个 Python 文件中有多少级缩进。高水平的缩进表示嵌套循环和分支,这可能表明代码复杂度高。
不幸的是,在撰写本文时还没有流行的工具来处理空白启发式。然而,编写这个检查器自己是很容易的:
def get_amount_of_preceding_whitespace(line: str) -> int:
# replace tabs with 4 spaces (and start tab/spaces flame-war)
tab_normalized_text = line.replace("\t", " ")
return len(tab_normalized_text) - len(tab_normalized_text.lstrip())
def get_average_whitespace(filename: str):
with open(filename) as file_to_check:
whitespace_count = [get_amount_of_preceding_whitespace(line)
for line in file_to_check
if line != ""]
average = sum(whitespace_count) / len(whitespace_count) / 4
print(f"Avg indentation level for {filename}: {average}")
注意
另一种可能的空白度量是每个函数的缩进“面积”,其中您总结所有缩进而不是对其进行平均。我将这留给读者自行实现。
和圈复杂度一样,空白复杂度也没有一个魔法数字可以检查。我鼓励你在你的代码库中试验,并确定适当的缩进量。
安全分析
安全性很难做到正确,并且几乎没有人因为防范漏洞而受到赞扬。相反,似乎是漏洞本身主导了新闻。每个月我都会听说另一起泄露或数据泄露。这些故障对公司来说无比昂贵,无论是因为监管罚款还是失去客户基础。
每个开发人员都需要高度关注他们代码库的安全性。您不希望听说您的代码库是新闻中最新大规模数据泄露的根本原因。幸运的是,有些静态分析工具可以防止常见的安全漏洞。
泄露的秘密
如果你想要被吓到,可以在你喜欢的代码托管工具中搜索文本AWS_SECRET_KEY,比如GitHub。你会惊讶地发现有多少人提交了像 AWS 访问密钥这样的秘密值。⁴
一旦秘密信息进入版本控制系统,尤其是公开托管的系统,要消除其痕迹非常困难。组织被迫撤销任何泄露的凭据,但他们必须比搜索密钥的大量黑客更快。为了防止这种情况发生,请使用专门查找泄漏秘密的静态分析工具,例如dodgy。如果您选择不使用预构建工具,请至少在代码库中执行文本搜索,以确保没有人泄露常见凭据。
安全漏洞检查
检查泄露凭据只是一件事,但更严重的安全漏洞怎么办?如何找到像 SQL 注入、任意代码执行或错误配置的网络设置等问题?当这些漏洞被利用时,会对您的安全配置造成重大损害。但就像本章中的其他问题一样,有一个静态分析工具可以处理这些问题:Bandit。
Bandit 检查常见的安全问题。您可以在Bandit 文档中找到完整的列表,但这里是 Bandit 寻找的缺陷类型的预览:
-
Flask 调试模式可能导致远程代码执行
-
发出不进行证书验证的 HTTPS 请求
-
潜在存在 SQL 注入风险的原始 SQL 语句
-
弱密码密钥生成
-
标记不受信任的数据影响代码路径,例如不安全的 YAML 加载
Bandit 检查了许多不同的潜在安全漏洞。我强烈建议对您的代码库运行它:
pip install bandit
bandit -r path/to/your/code
Bandit 还具有强大的插件系统,因此您可以使用自己的安全检查来增强缺陷检测。
警告
虽然以安全为导向的静态分析工具非常有用,但不要将它们作为唯一的防线。通过继续实施额外的安全实践(如进行审计、运行渗透测试和保护您的网络),来补充这些工具。
总结思考
尽早捕获错误可以节省时间和金钱。您的目标是在开发代码时发现错误。静态分析工具在这方面是您的好帮手。它们是在代码库中快速发现问题的廉价方式。有各种静态分析器可供选择:代码检查器、安全检查器和复杂性检查器。每种工具都有其自身的目的,并提供了一层防护。对于这些工具未能捕捉的错误,您可以通过使用插件系统来扩展静态分析工具。
虽然静态分析工具是您的第一道防线,但它们不是唯一的防线。本书的其余部分将专注于测试。下一章将专注于您的测试策略。我将详细介绍如何组织您的测试,以及围绕编写测试的最佳实践。您将学习如何编写测试金字塔,如何在测试中提出正确的问题,以及如何编写有效的开发者测试。
¹ J. Reason. “人为错误:模型与管理。” BMJ 320, 第 7237 期(2000 年):768–70. https://doi.org/10.1136/bmj.320.7237.768.
² Bruce MacLennan. “编程语言设计原理。” web.eecs.utk.edu,1998 年 9 月 10 日. https://oreil.ly/hrjdR.
³ T.J. McCabe. “一个复杂性度量。” IEEE 软件工程期刊 SE-2,第 4 期(1976 年 12 月):308–20. https://doi.org/10.1109/tse.1976.233837.
⁴ 这有现实世界的影响。在互联网上快速搜索会找到大量详细介绍这个问题的文章,比如 https://oreil.ly/gimse.
第二十一章:测试策略
测试是你可以在代码库周围建立的最重要的安全网之一。改变后,看到所有测试通过是非常令人欣慰的。然而,评估测试的最佳时间使用是具有挑战性的。测试过多会成为负担;你会花更多时间维护测试而非交付功能。测试过少会让潜在的灾难进入生产环境。
在本章中,我将请你专注于你的测试策略。我将分解不同类型的测试以及如何选择要编写的测试。我将关注 Python 在测试构建方面的最佳实践,然后我会结束一些特定于 Python 的常见测试策略。
定义你的测试策略
在你编写测试之前,你应该决定你的 测试策略 将是什么。测试策略是在测试软件以减少风险方面花费时间和精力的计划。这种策略将影响你编写什么类型的测试,如何编写它们以及你花费多少时间编写(和维护)它们。每个人的测试策略都会有所不同,但它们都会有类似的形式:关于你的系统及其如何计划回答这些问题的问题列表。例如,如果我正在编写一个卡路里计数应用程序,这将是我的测试策略的一部分:
Does my system function as expected?
Tests to write (automated - run daily):
Acceptance tests: Adding calories to the daily count
Acceptance tests: Resetting calories on daily boundaries
Acceptance tests: Aggregating calories over a time period
Unit tests: Corner Cases
Unit tests: Happy Path
Will this application be usable by a large user base?
Tests to write (automated - run weekly):
Interoperability tests: Phones (Apple, Android, etc.)
Interoperability tests: Tablets
Interoperability tests: Smart Fridge
Is it hard to use maliciously?
Tests to write: (ongoing audit by security engineer)
Security tests: Device Interactions
Security tests: Network Interactions
Security tests: Backend Vulnerability Scanning (automated)
... etc. ...
提示
不要将你的测试策略视为一次创建并永不修改的静态文档。在开发软件时,继续提问并讨论是否需要根据学到的知识进化你的策略。
这种测试策略将决定你在编写测试时的关注点。当你开始填写它时,你首先需要了解什么是测试以及为什么要编写它们。
什么是测试?
你应该理解为什么和为什么编写软件。回答这些问题将为编写测试确定目标。测试是验证代码执行 what 的一种方式,你编写测试是为了不会负面影响 why。软件产生价值。这就是全部。每个软件都有一定的附加值。Web 应用为广大人群提供重要服务。数据科学管道可能创建预测模型,帮助我们更好地理解世界中的模式。即使是恶意软件也有价值;执行攻击的人使用软件来实现目标(即使对受影响者有负面价值)。
这就是软件提供的内容,但是为什么要写软件呢?大多数人会说“钱”,我不想否认这一点,但也有其他原因。有时候软件是为了赚钱而写的,有时候是为了自我实现,有时候是为了广告(比如为开源项目做贡献以增强简历)。测试为这些系统提供了验证。它们远不止于捕捉错误或者让你在发布产品时有信心。
如果我为学习目的编写一些代码,那么我的为什么纯粹是为了自我实现,价值来自我学到了多少。如果我做错了事情,那也是一个学习机会;如果所有测试只是项目结束时的手工抽查,我也可以应付。然而,市场上为其他开发者提供工具的公司可能有完全不同的策略。这些公司的开发人员可能选择编写测试,以确保他们没有退化任何功能,从而避免公司失去客户(这会转化为利润损失)。每个项目都需要不同层次的测试。
所以,测试是什么?它是用来捕捉错误的东西吗?它是让你有信心发布产品的东西吗?是的,但真正的答案还要深入一些。测试回答了关于你的系统的问题。我希望你思考一下你写的软件。它的目的是什么?关于你构建的东西,你希望永远知道什么?对你重要的东西构成了你的测试策略。
当你问自己问题时,你真正在问自己的是哪些测试对你有价值:
-
我的应用程序能处理预测的负载吗?
-
我的代码是否满足客户的需求?
-
我的应用程序安全吗?
-
当客户向我的系统输入不良数据时会发生什么?
每一个问题都指向你可能需要编写的不同类型的测试。查看表 21-1,列出了常见问题和相应的测试类型。
表 21-1. 测试类型及其所回答的问题
| 测试类型 | 测试回答的问题 |
|---|---|
| 单元 | 单元(函数和类)是否如开发人员期望的那样工作? |
| 集成 | 系统的各个部分是否正确地拼接在一起? |
| 验收 | 系统是否符合最终用户的期望? |
| 负载 | 系统在重压下是否保持操作能力? |
| 安全性 | 系统是否能抵御特定的攻击和利用? |
| 可用性 | 系统是否直观易用? |
注意,表 21-1 没有提到确保软件没有 bug。正如 Edsger Djikstra 所说,“程序测试可以用来显示 bug 的存在,但永远不能证明其不存在!”¹ 测试回答了关于你的软件质量的问题。
质量 是一个模糊的、难以定义的术语,经常被人提及。这是一个难以把握的东西,但我更喜欢 Gerald Weinberg 的这句话:“质量是对某个人的价值。”² 我喜欢这句话多么开放式;你需要考虑到任何可能从你的系统中获得价值的人。不仅仅是你的直接客户,还有你客户的客户,你的运维团队,你的销售团队,你的同事等等。
一旦确定了谁从你的系统中获得价值,你需要在出现问题时衡量影响。对于每个未运行的测试,你失去了了解你是否正在交付价值的机会。如果未能交付该价值会有什么影响?对于核心业务需求,影响是相当大的。对于不在最终用户关键路径之外的功能,影响可能较小。了解你的影响,并将其与测试成本进行权衡。如果影响的成本高于测试的成本,写测试。如果低于测试成本,跳过编写测试,花时间做更有影响力的事情。
测试金字塔
几乎在任何测试书籍中,你都会碰到类似于 图 21-1 的图:一个“测试金字塔”。³
图 21-1. 测试金字塔
这个想法是你想要编写大量小型、孤立的单元测试。理论上这些测试成本较低,应该占据你测试的大部分,因此它们位于底部。你有较少的集成测试,这些成本较高,甚至更少的 UI 测试,这些成本非常高。从诞生之时起,开发者们就在多种方式上辩论测试金字塔,包括画线的位置、单元测试的有效性,甚至三角形的形状(我甚至见过倒置的三角形)。
事实上,标签是什么或者你如何分隔你的测试并不重要。你想要的是你的三角形看起来像 图 21-2,它侧重于价值与成本的比率。
图 21-2. 着眼于价值与成本的测试金字塔
写大量价值与成本比高的测试。无论是单元测试还是验收测试都无所谓。找到方法经常运行它们。让测试快速运行,这样开发者在提交之间多次运行它们,验证事情仍然正常工作。把你的不那么有价值、较慢或者成本较高的测试保留用于每次提交时的测试(或至少定期测试)。
你拥有的测试越多,你就会有越少的未知数。你拥有的未知数越少,你的代码库就会更加健壮。每次你进行更改时,你都有一个更大的安全网来检查任何回归。但是,如果测试变得过于昂贵,远远超过任何影响的成本,该怎么办?如果你觉得这些测试仍然值得,你需要找到一种方式来降低它们的成本。
测试成本包括三个方面:编写的初始成本、运行的成本以及维护的成本。测试至少需要运行一段时间,这将耗费资金。然而,减少这些成本通常成为优化练习,您可以寻找并行化测试或在开发人员机器上更频繁地运行测试的方式。您仍然需要减少编写的初始成本和维护测试的持续成本。幸运的是,迄今为止您所阅读的每一本书都直接适用于减少这些成本。您的测试代码与您代码库的其余部分一样重要,您需要确保它同样强大。选择正确的工具,正确组织您的测试用例,并确保您的测试清晰易读且易于维护。
讨论话题
评估系统中测试的成本。编写时间、运行时间或维护时间哪个占主导地位?您可以采取什么措施来降低这些成本?
降低测试成本
当您对比测试成本与价值时,您正在收集将帮助您优先考虑测试策略的信息。有些测试可能不值得运行,而有些则会成为您希望首先编写以最大化价值的测试。然而,有时候您可能会遇到这样的情况:有一个非常重要的测试,您希望编写,但编写和/或维护成本非常高。在这种情况下,找到一种方法来降低该测试的成本。编写和组织测试的方式对于使测试更便宜、更易于理解至关重要。
AAA 测试
与生产代码一样,专注于测试代码的可读性和可维护性。尽可能清晰地传达您的意图。如果测试读者能清楚地看到您试图测试的内容,他们会感谢您。在编写测试时,每个测试遵循相同的基本模式会有所帮助。
在测试中您将发现的最常见的模式之一是 3A 或 AAA 测试模式⁴。AAA 代表Arrange-Act-Assert。您将每个测试分为三个独立的代码块:一个用于设置预置条件(arrange),一个用于执行正在测试的操作(act),然后一个用于检查任何后置条件(assert)。您可能也会听说第四个 A,用于annihilate或清理代码。我将详细介绍每个步骤,讨论如何使您的测试更易于阅读和维护。
安排
安排步骤主要是设置系统处于准备测试的状态。这些被称为测试的前置条件。您设置任何依赖项或测试数据,以确保测试能够正确运行。
考虑以下测试:
def test_calorie_calculation():
# arrange (set up everything the test needs to run)
add_ingredient_to_database("Ground Beef", calories_per_pound=1500)
add_ingredient_to_database("Bacon", calories_per_pound=2400)
add_ingredient_to_database("Cheese", calories_per_pound=1800)
# ... snip 13 more ingredients
set_ingredients("Bacon Cheeseburger w/ Fries",
ingredients=["Ground Beef", "Bacon" ... ])
# act (the thing getting tested)
calories = get_calories("Bacon Cheeseburger w/ Fries")
# assert (verify some property about the program)
assert calories == 1200
#annihilate (cleanup any resources that were allocated)
cleanup_database()
首先,我向数据库添加食材,并将食材列表与名为“培根奶酪汉堡配薯条”的菜肴关联。然后,我查找汉堡的卡路里数量,检查它与已知值是否一致,并清理数据库。
看看在我实际进入测试本身之前有多少代码(get_calories 调用)。庞大的arrange块是一个警告信号。你将会有很多看起来非常相似的测试,你希望读者能够一目了然地知道它们之间的区别。
警告
大型的arrange块可能表示依赖关系的复杂设置。代码的任何使用者都可能需要以类似的方式设置这些依赖关系。退一步思考,问自己是否有更简单的方法来处理依赖关系,比如使用第 III 部分中描述的策略。
在前面的示例中,如果我必须在两个单独的测试中添加 15 种成分,但设置一个成分稍有不同以模拟替换,那么眼测这些测试的区别将会很困难。给这些测试起一个能够指示它们不同之处的详细名称是一个不错的方法,但这只能走得这么远。要在保持测试信息丰富与便于一目了然之间找到平衡点。
一致的前提条件与变化的前提条件
查看你的测试并问问自己哪些前提条件在一组测试中是相同的。通过函数提取这些条件并在每个测试中重复使用该函数。看看比较以下两个测试变得多么容易:
def test_calorie_calculation_bacon_cheeseburger():
add_base_ingredients_to_database()
add_ingredient_to_database("Bacon", calories_per_pound=2400)
st /etup_bacon_cheeseburger(bacon="Bacon")
calories = get_calories("Bacon Cheeseburger w/ Fries")
assert calories == 1200
cleanup_database()
def test_calorie_calculation_bacon_cheeseburger_with_substitution():
add_base_ingredients_to_database()
add_ingredient_to_database("Turkey Bacon", calories_per_pound=1700)
setup_bacon_cheeseburger(bacon="Turkey Bacon")
calories = get_calories("Bacon Cheeseburger w/ Fries")
assert calories == 1100
cleanup_database()
通过创建辅助函数(在本例中为 add_base_ingredients_to_database 和 setup_bacon_cheeseburger),你可以将所有不重要的测试样板代码减少,使开发人员能够专注于测试之间的差异。
使用测试框架特性来处理样板代码
大多数测试框架都提供了一种在测试前自动运行代码的方法。在内置的 unittest 模块中,你可以编写一个 setUp 函数在每个测试前运行。在 pytest 中,你可以通过 fixture 实现类似的功能。
在 pytest 中,fixture 是指定测试初始化和清理代码的一种方式。Fixture 提供了许多有用的功能,如定义对其他 fixture 的依赖(让 pytest 控制初始化顺序)和控制初始化,以便每个模块只初始化一次 fixture。在前面的示例中,我们可以为 test_database 使用一个 fixture:
import pytest
@pytest.fixture
def db_creation():
# ... snip set up local sqlite database
return database
@pytest.fixture
def test_database(db_creation):
# ... snip adding all ingredients and meals
return database
def test_calorie_calculation_bacon_cheeseburger(test_database):
test_database.add_ingredient("Bacon", calories_per_pound=2400)
setup_bacon_cheeseburger(bacon="Bacon")
calories = get_calories("Bacon Cheeseburger w/ Fries")
assert calories == 1200
test_database.cleanup()()
注意现在测试中有一个 test_database 参数。这就是 fixture 的工作原理;函数 test_database(以及 db_creation)会在测试之前调用。随着测试数量的增加,fixture 变得越来越有用。它们是可组合的,允许你将它们组合在一起,减少代码重复。通常情况下,我不会将它们用来抽象单个文件中的代码,但一旦初始化需要在多个文件中使用时,fixture 就是最佳选择。
模拟
Python 提供了鸭子类型(首次在第二章提到)作为其类型系统的一部分,这意味着只要它们遵循相同的契约,你可以很容易地将类型替换为另一个类型(如在第十二章中讨论的那样)。这意味着你可以完全不同地处理复杂的依赖关系:使用一个简单的模拟对象代替。mocked对象是看起来与生产对象完全相同(方法和字段),但提供了简化的数据。
提示
单元测试中经常使用模拟对象(Mocks),但随着测试变得不那么细粒度,它们的使用会减少。这是因为你尝试在更高层次测试系统的同时,你正在模拟的服务往往是测试的一部分。
例如,如果前面示例中的数据库设置非常复杂,有多个表和模式,可能不值得为每个测试设置,特别是如果测试共享一个数据库;你希望保持测试互相隔离。(稍后我将详细讨论这一点。)处理数据库的类可能如下所示:
class DatabaseHandler:
def __init__(self):
# ... snip complex setup
def add_ingredient(self, ingredient):
# ... snip complex queries
def get_calories_for_ingredient(self, ingredient):
# ... snip complex queries
而不是直接使用这个类,创建一个看起来像数据库处理程序的模拟类:
class MockDatabaseHandler
def __init__(self):
self.data = {
"Ground Beef": 1500,
"Bacon": 2400,
# ... snip ...
}
def add_ingredient(self, ingredient):
name, calories = ingredient
self.data[name] = calories
def get_calories_for_ingredient(self, ingredient):
return self.data[ingredient]
对于模拟对象,我只是使用一个简单的字典来存储我的数据。如何模拟你的数据将因情况而异,但如果你能找到一种方法用模拟对象替换真实对象,你可以显著降低设置的复杂性。
警告
有些人使用monkeypatching,即在运行时替换方法以注入模拟对象。适度使用这种方法是可以接受的,但如果你发现你的测试中充斥着 monkeypatching,这是一种反模式。这意味着你的不同模块之间有过于严格的物理依赖,应该考虑找到方法使你的系统更加模块化。(请参阅第三部分了解更多关于使代码可扩展性的想法。)
消灭
技术上,annihilate 阶段是你在测试中做的最后一件事,但我却在第二次讨论它。为什么呢?因为它与你的arrange步骤密切相关。无论你在arrange中设置了什么,如果它可能影响其他测试,都需要拆除。
你希望你的测试互相隔离;这样会使它们更易于维护。对于测试自动化写作者来说,最大的噩梦之一就是测试失败取决于它们运行的顺序(尤其是如果你有成千上万个测试)。这是测试彼此之间存在微妙依赖的明确迹象。在离开之前清理你的测试,并减少测试相互交互的可能性。以下是一些处理测试清理的策略。
不要使用共享资源
如果可以做到的话,测试之间不要共享任何东西。这并不总是可行的,但这应该是你的目标。如果没有测试共享任何资源,那么你就不需要清理任何东西。共享资源可以是 Python 中的(全局变量,类变量)或环境中的(数据库,文件访问,套接字池)。
使用上下文管理器
使用上下文管理器(在第十一章讨论)确保资源始终被清理。在我的上一个示例中,眼尖的读者可能已经注意到了一个错误:
def test_calorie_calculation_bacon_cheeseburger():
add_base_ingredients_to_database()
add_ingredient_to_database("Bacon", calories_per_pound=2400)
setup_bacon_cheeseburger(bacon="Bacon")
calories = get_calories("Bacon Cheeseburger w/ Fries")
assert calories == 1200
cleanup_database()
如果断言失败,则会引发异常,cleanup_database永远不会执行。更好的方法是通过上下文管理器强制使用:
def test_calorie_calculation_bacon_cheeseburger():
with construct_test_database() as db:
db.add_ingredient("Bacon", calories_per_pound=2400)
setup_bacon_cheeseburger(bacon="Bacon")
calories = get_calories("Bacon Cheeseburger w/ Fries")
assert calories == 1200
将清理代码放在上下文管理器中,这样你的测试编写者永远不必主动考虑它;它已经为他们完成了。
使用夹具
如果你正在使用pytest夹具,你可以像使用上下文管理器一样使用它们。你可以从夹具yield值,允许你在测试完成后返回到夹具的执行。观察:
import pytest
@pytest.fixture
def db_creation():
# ... snip set up local sqlite database
return database
@pytest.fixture
def test_database(db_creation):
# ... snip adding all ingredients and meals
try:
yield database
finally:
database.cleanup()
def test_calorie_calculation_bacon_cheeseburger(test_database):
test_database.add_ingredient("Bacon", calories_per_pound=2400)
setup_bacon_cheeseburger(bacon="Bacon")
calories = get_calories("Bacon Cheeseburger w/ Fries")
assert calories == 1200
注意现在test_database夹具如何产生数据库。当使用此函数的任何测试完成(无论是通过还是失败),数据库清理函数都将始终执行。
行动
act阶段是测试中最重要的部分。它体现了你要测试的实际操作。在前面的例子中,act阶段是获取特定菜品的卡路里。你不希望act阶段比一两行代码长。少即是多;通过保持此阶段小,可以减少读者理解测试的核心内容所需的时间。
有时,你希望在多个测试之间重复使用相同的act阶段。如果你发现自己想要编写相同的测试,但输入数据和断言略有不同,请考虑对测试参数化。测试参数化是一种在不同参数上运行相同测试的方法。这允许你编写table-driven测试,或以表格形式组织测试数据的方法。
这是使用参数化的get_calories测试:
@pytest.mark.parametrize(
"extra_ingredients,dish_name,expected_calories",
[
(["Bacon", 2400], "Bacon Cheeseburger", 900),
([], "Cobb Salad", 1000),
([], "Buffalo Wings", 800),
([], "Garlicky Brussels Sprouts", 200),
([], "Mashed Potatoes", 400)
]
)
def test_calorie_calculation_bacon_cheeseburger(extra_ingredients,
dish_name,
expected_calories,
test_database):
for ingredient in extra_ingredients:
test_database.add_ingredient(ingredient)
# assume this function can set up any dish
# alternatively, dish ingredients could be passed in as a test parameter
setup_dish_ingredients(dish_name)
calories = get_calories(dish_name)
assert calories == expected_calories
将参数定义为元组列表,每个测试用例一个。每个参数都作为参数传递给测试用例。pytest会自动对每组参数运行此测试。
参数化测试的好处是将许多测试用例压缩到一个函数中。测试的读者只需按照参数化表中列出的顺序查看表格,就可以理解预期的输入和输出是什么(科布沙拉应该有 1,000 卡路里,土豆泥应该有 400 卡路里,等等)。
警告
参数化是将测试数据与实际测试分离的好方法(类似于分离策略和机制,如第十七章所讨论的)。但是要小心。如果你让你的测试过于通用,那么确定它们在测试什么就会更加困难。如果可以的话,避免使用三四个以上的参数。
断言
在清理之前要做的最后一步是断言关于系统的某个属性为真。最好,在你的测试末尾应该有一个逻辑断言。如果你发现自己在一个测试中塞入了太多断言,要么是你的测试中有太多动作,要么是太多测试匹配到了一个。当一个测试有太多责任时,维护者很难调试软件。如果他们进行了一个导致测试失败的更改,你希望他们能快速找出问题所在。理想情况下,他们可以根据测试名称找出问题,但至少他们应该能打开测试,看上去大约 20 或 30 秒,就能意识到出了什么问题。如果有多个断言,就有多个原因会导致测试失败,维护者需要花时间来梳理它们。
这并不意味着你只能有一个assert语句;只要它们都涉及测试相同的属性,有几个assert语句也是可以的。同样要使你的断言详细,这样开发人员在出错时能得到有用的信息。在 Python 中,你可以提供一个文本消息,随着AssertionError一起传递,以帮助调试。
def test_calorie_calculation_bacon_cheeseburger(test_database):
test_database.add_ingredient("Bacon", calories_per_pound=2400)
setup_bacon_cheeseburger(bacon="Bacon")
calories = get_calories("Bacon Cheeseburger w/ Fries")
assert calories == 1200, "Incorrect calories for Bacon Cheeseburger w/ Fries"
pytest重新编写断言语句,这也提供了额外的调试消息级别。如果上述测试失败,返回给测试编写者的消息将是:
E AssertionError: Incorrect calories for Bacon Cheeseburger w/ Fries
E assert 1100 == 1200
对于更复杂的断言,构建一个断言库可以非常轻松地定义新的测试。这就像在你的代码库中建立词汇表一样;你希望测试代码中有多样的概念可以共享。为此,我推荐使用Hamcrest 匹配器。⁵
Hamcrest 匹配器是一种编写断言,以类似自然语言的方式阅读的方法。PyHamcrest库提供了常见的匹配器,帮助你编写你的断言。看看它如何使用自定义断言匹配器来使测试更加清晰:
from hamcrest import assert_that, matches_regexp, is_, empty, equal_to
def test_all_menu_items_are_alphanumeric():
menu = create_menu()
for item in menu:
assert_that(item, matches_regexp(r'[a-zA-Z0-9 ]'))
def test_getting_calories():
dish = "Bacon Cheeseburger w/ Fries"
calories = get_calories(dish)
assert_that(calories, is_(equal_to(1200)))
def test_no_restaurant_found_in_non_matching_areas():
city = "Huntsville, AL"
restaurants = find_owned_restaurants_in(city)
assert_that(restaurants, is_(empty()))
PyHamcrest的真正强大之处在于你可以定义自己的匹配器。⁶
from hamcrest.core.base_matcher import BaseMatcher
from hamcrest.core.helpers.hasmethod import hasmethod
def is_vegan(ingredient: str) -> bool:
return ingredient not in ["Beef Burger"]
class IsVegan(BaseMatcher):
def _matches(self, dish):
if not hasmethod(dish, "ingredients"):
return False
return all(is_vegan(ingredient) for ingredient in dish.ingredients())
def describe_to(self, description):
description.append_text("Expected dish to be vegan")
def describe_mismatch(self, dish, description):
message = f"the following ingredients are not vegan: "
message += ", ".join(ing for ing in dish.ingredients()
if not is_vegan(ing))
description.append_text(message)
def vegan():
return IsVegan()
from hamcrest import assert_that, is_
def test_vegan_substitution():
dish = create_dish("Hamburger and Fries")
dish.make_vegan()
assert_that(dish, is_(vegan()))
如果测试失败,你会得到以下错误:
def test_vegan_substitution():
dish = create_dish("Hamburger and Fries")
dish.make_vegan()
> assert_that(dish, is_(vegan()))
E AssertionError:
E Expected: Expected dish to be vegan
E but: the following ingredients are not vegan: Beef Burger
讨论主题
在你的测试中,你可以在哪里使用自定义匹配器?讨论在你的测试中共享的测试词汇会是什么,以及自定义匹配器如何提高可读性。
总结思考
就像走钢丝的安全网一样,测试在你工作时给予你安全感和信心。这不仅仅是找到错误。测试验证你构建的东西是否按照你的期望执行。它们给未来的合作者提供了更多的自由去做更多风险的改变;他们知道如果他们失败,测试会捕捉到他们。你会发现回归变得更加少见,你的代码库变得更容易工作。
然而,测试并非免费。编写、运行和维护测试都是有成本的。你需要谨慎地安排你的时间和精力。使用构建测试的知名模式来最小化成本:遵循 AAA 模式,保持每个阶段简短,并确保你的测试清晰易读。你的测试和你的代码库一样重要。要同样尊重它们,并使其健壮。
在下一章中,我将专注于验收测试。验收测试有不同于单元测试或集成测试的目的,你使用的一些模式将不同。
你将学习到验收测试如何引发对话,以及如何确保你的代码库为客户正确执行任务。它们是交付价值的代码库中的宝贵工具。
¹ Edsger W. Dijkstra。“关于结构化编程的注释。”荷兰,埃因霍温科技大学,数学系,1970 年。https://oreil.ly/NAhWf。
² Gerald M. Weinberg。《优质软件管理》。第 1 卷:《系统思维》。纽约,纽约:Dorset House Publishing,1992 年。
³ 这被称为测试金字塔,在迈克·科恩(Mike Cohn)的《成功实现敏捷》(Addison-Wesley Professional)中首次提出。Cohn 最初使用“Service”级别测试代替集成测试,但我看到更多的迭代使用“integration”测试作为中间层。
⁴ AAA 模式最早由 Bill Wake 在 2001 年命名。查看这篇博文了解更多信息。
⁵ Hamcrest 是“matchers”的字母重排。
⁶ 查看PyHamcrest 文档了解更多信息,如额外的匹配器或与测试框架的集成。