Python-架构模式-全-

Python 架构模式(全)

原文:Architecture Patterns with Python

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

原文:Preface

译者:飞龙

协议:CC BY-NC-SA 4.0

也许你会想知道我们是谁,为什么要写这本书。

在 Harry 的最后一本书Test-Driven Development with Python(O'Reilly)中,他发现自己对架构提出了一堆问题,比如,如何构建应用程序的最佳结构,以便易于测试?更具体地说,如何确保核心业务逻辑由单元测试覆盖,并最小化需要的集成和端到端测试的数量?他模糊地提到了“六边形架构”、“端口和适配器”和“功能核心,命令式外壳”,但如果他诚实的话,他不得不承认这些并不是他真正理解或实践过的东西。

然后他很幸运地遇到了 Bob,他对所有这些问题都有答案。

Bob 最终成为了一名软件架构师,因为他所在团队中没有其他人在做这件事。结果他发现自己在这方面表现得相当糟糕,但很幸运地遇到了 Ian Cooper,后者教会了他关于编写和思考代码的新方法。

管理复杂性,解决业务问题

我们都在 MADE.com 工作,这是一家在欧洲在线销售家具的电子商务公司;在那里,我们应用这本书中的技术来构建模拟现实世界业务问题的分布式系统。我们的示例领域是 Bob 为 MADE 建立的第一个系统,这本书试图记录下我们在加入我们团队的新程序员时需要教授的所有东西

MADE.com 在全球范围内运营货运合作伙伴和制造商的供应链。为了降低成本,我们努力优化库存的交付到我们的仓库,以便我们不会有未售出的商品堆积在这里。

理想情况下,你想要购买的沙发将在你决定购买的当天到达港口,然后我们会直接将其运送到你的家,而不会存放。当货物需要三个月才能通过集装箱船到达时,把握时机是一个棘手的平衡行为。在这个过程中,货物会损坏或水损,风暴会导致意外延迟,物流合作伙伴会处理不当,文件会丢失,客户会改变主意并修改订单,等等。

我们通过构建智能软件来解决这些问题,这些软件代表了现实世界中发生的各种操作,以便我们尽可能地自动化业务的大部分内容。

为什么选择 Python?

如果你正在阅读这本书,我们可能不需要说服你 Python 有多么棒,所以真正的问题是“为什么Python社区需要这样一本书?”答案与 Python 的流行和成熟度有关:尽管 Python 可能是世界上增长最快的编程语言,接近绝对流行榜的顶端,但它才刚刚开始解决 C#和 Java 世界多年来一直在处理的那种问题。初创公司变成真正的企业;Web 应用程序和脚本自动化正在成为(小声说)企业 软件

在 Python 世界中,我们经常引用 Python 之禅:“应该有一种——最好只有一种——明显的方法来做到这一点。”¹不幸的是,随着项目规模的增长,做事情的最明显方式并不总是帮助你管理复杂性和不断变化的需求的方式。

我们在这本书中讨论的所有技术和模式都不是新的,但它们在 Python 世界中大多是新的。这本书也不是领域驱动设计等领域的经典著作的替代品,比如 Eric Evans 的Domain-Driven Design或 Martin Fowler 的Patterns of Enterprise Application Architecture(Addison-Wesley Professional 出版)——我们经常参考并鼓励你去阅读。

但是,文献中所有经典的代码示例往往都是用 Java 或 C++/#编写的,如果您是 Python 人,并且很长时间没有使用过这两种语言(或者根本没有使用过),那么这些代码清单可能会相当困难。这就是为什么另一本经典文本《重构》(Addison-Wesley Professional)的最新版本是用 JavaScript 编写的原因。

TDD、DDD 和事件驱动架构

按照声名显赫的顺序,我们知道三种管理复杂性的工具:

  1. 测试驱动开发(TDD)帮助我们构建正确的代码,并使我们能够进行重构或添加新功能,而不用担心回归。但是,如何确保测试尽可能快地运行?我们如何确保从快速、无依赖的单元测试中获得尽可能多的覆盖和反馈,并且具有最少数量的较慢、不稳定的端到端测试?

  2. 领域驱动设计(DDD)要求我们将努力集中在构建业务领域的良好模型上,但我们如何确保我们的模型不受基础设施问题的影响,不会变得难以更改?

  3. 松散耦合(微)服务通过消息集成(有时称为反应式微服务)是管理多个应用程序或业务领域复杂性的一种成熟解决方案。但如何使它们与 Python 世界中已建立的工具(如 Flask、Django、Celery 等)相适应并不总是显而易见。

注意

如果您不使用(或对)微服务不感兴趣,也不要被吓到。我们讨论的大多数模式,包括大部分事件驱动架构材料,在单体架构中也是完全适用的。

我们的目标是介绍几种经典的架构模式,并展示它们如何支持 TDD、DDD 和事件驱动服务。我们希望它能作为在 Python 中实现它们的参考,并且人们可以将其用作进一步研究这一领域的第一步。

谁应该阅读本书

以下是我们对您的一些假设,亲爱的读者:

  • 您曾接触过一些相当复杂的 Python 应用程序。

  • 您已经看到了尝试管理这种复杂性所带来的一些痛苦。

  • 您不一定了解 DDD 或任何经典应用程序架构模式。

我们围绕一个示例应用程序来探讨架构模式,逐章构建它。我们在工作中使用 TDD,所以我们倾向于先展示测试清单,然后是实现。如果您不习惯先测试工作,开始时可能会感到有点奇怪,但我们希望您很快就会习惯在看到代码“被使用”(即从外部)之前先看到它是如何在内部构建的。

我们使用一些特定的 Python 框架和技术,包括 Flask、SQLAlchemy 和 pytest,以及 Docker 和 Redis。如果您已经熟悉它们,那不会有什么坏处,但我们认为这并非必需。本书的主要目标之一是构建一个特定技术选择变成次要实现细节的架构。

您将学到的简要概述

本书分为两部分;以下是我们将涵盖的主题以及它们所在的章节。

第一部分,构建支持领域建模的架构

领域建模和 DDD(第 1 章和第 7 章)

在某个层面上,每个人都学到了一个教训,即复杂的业务问题需要在代码中反映出来,以领域模型的形式。但为什么总是似乎如此难以做到而不陷入基础设施问题、web 框架或其他问题?在第一章中,我们对领域建模和 DDD 进行了广泛概述,并展示了如何开始一个没有外部依赖和快速单元测试的模型。后来,我们回到 DDD 模式,讨论如何选择正确的聚合,以及这个选择与数据完整性问题的关系。

仓储、服务层和工作单元模式(第 2、4 和 5 章)

在这三章中,我们提出了三种密切相关且相互加强的模式,以支持我们保持模型不受外部依赖的野心。我们在持久存储周围构建了一个抽象层,并建立了一个服务层来定义系统的入口点并捕获主要用例。我们展示了这一层如何使得构建系统的薄入口变得容易,无论是 Flask API 还是 CLI。

关于测试和抽象的一些想法(第 3 和 6 章)

在介绍第一个抽象(仓储模式)之后,我们有机会进行一般性讨论,讨论如何选择抽象,以及它们在选择我们的软件如何耦合在一起方面的作用。在介绍服务层模式之后,我们稍微谈了一下如何实现测试金字塔,以及在最高可能的抽象级别编写单元测试。

第二部分,事件驱动架构

事件驱动架构(第 8-11 章)

我们介绍了另外三种相互加强的模式:领域事件、消息总线和处理程序模式。领域事件是捕获系统某些交互触发其他交互的一种方式。我们使用消息总线允许动作触发事件并调用适当的处理程序。我们继续讨论事件如何作为微服务架构中服务之间集成的模式。最后,我们区分了命令事件。我们的应用现在基本上是一个消息处理系统。

命令查询职责分离(第十二章)

我们提供了一个命令查询职责分离的示例,有和没有事件。

依赖注入(第十三章)

我们整理了显式和隐式的依赖关系,并实现了一个简单的依赖注入框架。

额外内容

我怎样才能从这里到那里?(后记)

当你展示一个简单的例子,从头开始实现架构模式总是看起来很容易,但你们中的许多人可能会想知道如何将这些原则应用到现有软件中。我们将在后记中提供一些指引和一些进一步阅读的链接。

示例代码和编码过程

你正在阅读一本书,但你可能会同意我们的观点,即了解代码的最佳方式是编写代码。我们大部分知识都是通过与他人配对、与他们一起编写代码并通过实践学习而得到的,我们希望在这本书中尽可能多地为你重新创造那种经验。

因此,我们围绕一个单一的示例项目构建了这本书(尽管我们有时会加入其他示例)。随着章节的进展,我们将逐步构建这个项目,就好像你和我们一起配对,我们在每一步都解释我们在做什么以及为什么。

但要真正掌握这些模式,你需要动手编写代码,了解它的工作原理。你可以在 GitHub 上找到所有的代码;每个章节都有自己的分支。你也可以在 GitHub 上找到分支列表

这里有三种你可以跟着书本编码的方式:

  • 创建自己的存储库,并尝试按照书中的示例构建应用程序,并偶尔查看我们的存储库以获取提示。但是,需要警告一句:如果您已经阅读了哈里的上一本书,并且跟着书本编码,您会发现这本书需要您更多地自己解决问题;您可能需要在 GitHub 上的工作版本上依赖得更多。

  • 尝试将每个模式逐章应用到您自己的(最好是小型/玩具)项目中,看看是否可以让它适用于您的用例。这是高风险/高回报(除了高努力之外!)。让事情适应您的项目的具体情况可能需要相当多的工作,但另一方面,您可能会学到最多。

  • 在每一章中,我们都会概述一个“读者练习”,并指向一个 GitHub 位置,您可以在其中下载一些部分完成的代码,该代码缺少一些部分需要您自己编写。

特别是如果您打算在自己的项目中应用其中一些模式,通过简单的示例进行实践是一个安全的练习方式。

提示

至少在阅读每一章时,从我们的存储库中进行git checkout。能够随时跳转并查看代码在实际工作应用中的上下文将有助于解答许多问题,并使一切更加真实。您将在每章的开头找到如何执行此操作的说明。

许可证

该代码(以及书的在线版本)根据知识共享 CC BY-NC-ND 许可证许可,这意味着您可以自由复制和与任何人分享,只要您进行归因,且用于非商业目的。如果您想重复使用本书中的任何内容,并且对许可证有任何疑虑,请联系 O'Reilly,邮箱为permissions@oreilly.com

印刷版的许可证不同,请参阅版权页。

本书使用的约定

本书中使用以下排版约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽

用于程序清单,以及在段萂中用于引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

等宽粗体

显示用户应该按字面输入的命令或其他文本。

等宽斜体

显示应该由用户提供的值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素表示警告或注意。

O'Reilly 在线学习

注意

40 多年来,O'Reilly Media已经提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章、会议和我们的在线学习平台分享他们的知识和专长。O'Reilly 的在线学习平台为您提供按需访问实时培训课程、深入学习路径、交互式编码环境以及来自 O'Reilly 和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问http://oreilly.com

如何联系 O'Reilly

请将有关本书的评论和问题发送给出版商:

  • O'Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(在美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为这本书建立了一个网页,列出勘误、示例和任何额外信息。您可以在https://oreil.ly/architecture-patterns-python上访问这个页面。

bookquestions@oreilly.com发邮件,评论或提出关于这本书的技术问题。

有关我们的图书、课程、会议和新闻的更多信息,请访问我们的网站http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

对于我们的技术审阅者 David Seddon,Ed Jung 和 Hynek Schlawack:我们绝对配不上你们。你们都非常专注、尽责和严谨。你们每个人都非常聪明,你们不同的观点对彼此都非常有用和互补。我们由衷地感谢你们。

还要特别感谢我们的早期读者,感谢他们的评论和建议:Ian Cooper,Abdullah Ariff,Jonathan Meier,Gil Gonçalves,Matthieu Choplin,Ben Judson,James Gregory,Łukasz Lechowicz,Clinton Roy,Vitorino Araújo,Susan Goodbody,Josh Harwood,Daniel Butler,Liu Haibin,Jimmy Davies,Ignacio Vergara Kausel,Gaia Canestrani,Renne Rocha,pedroabi,Ashia Zawaduk,Jostein Leira,Brandon Rhodes 等等;如果我们在这个名单上漏掉了你,我们向你道歉。

特别感谢我们的编辑 Corbin Collins,他的温和督促和对读者的不懈支持。同样特别感谢制作人员 Katherine Tozer,Sharon Wilkey,Ellen Troutman-Zaig 和 Rebecca Demarest,感谢你们的专业精神和对细节的关注。这本书因为有了你们而得到了极大的改善。

书中仍然存在的任何错误都是我们自己的。

¹ python -c "import this"

引言

原文:Introduction

译者:飞龙

协议:CC BY-NC-SA 4.0

为什么我们的设计会出错?

当你听到混乱这个词时,你会想到什么?也许你会想到喧闹的股票交易所,或者早上的厨房——一切都混乱不堪。当你想到秩序这个词时,也许你会想到一个空旷的房间,宁静而平静。然而,对于科学家来说,混乱的特征是同质性(相同),而秩序的特征是复杂性(不同)。

例如,一个精心照料的花园是一个高度有序的系统。园丁用小路和篱笆定义边界,并标出花坛或菜园。随着时间的推移,花园会不断发展,变得更加丰富和茂密;但如果没有刻意的努力,花园就会变得狂野。杂草和草会扼杀其他植物,覆盖小路,最终每个部分看起来都一样——野生和无管理。

软件系统也倾向于混乱。当我们开始构建一个新系统时,我们有很大的想法,认为我们的代码会整洁有序,但随着时间的推移,我们发现它积累了垃圾和边缘情况,最终变成了令人困惑的混乱的经理类和工具模块。我们发现我们明智地分层的架构已经像过于湿润的杂果布丁一样崩溃了。混乱的软件系统的特征是功能的相同性:具有领域知识并发送电子邮件和执行日志记录的 API 处理程序;“业务逻辑”类不进行计算但执行 I/O;以及一切与一切耦合,以至于改变系统的任何部分都充满了危险。这是如此普遍,以至于软件工程师有自己的术语来描述混乱:大泥球反模式(图 P-1)。

apwp 0001

图 P-1. 真实的依赖关系图(来源:“企业依赖:大毛线球” by Alex Papadimoulis)
提示

软件的自然状态就像你的花园的自然状态一样,都是一团大泥巴。阻止崩溃需要能量和方向。

幸运的是,避免创建一团大泥巴的技术并不复杂。

封装和抽象

封装和抽象是我们作为程序员本能地使用的工具,即使我们并不都使用这些确切的词语。让我们稍微停留一下,因为它们是本书中不断出现的背景主题。

术语封装涵盖了两个密切相关的概念:简化行为和隐藏数据。在这个讨论中,我们使用的是第一个意义。我们通过识别代码中需要完成的任务,并将该任务交给一个明确定义的对象或函数来封装行为。我们称该对象或函数为抽象

看一下以下两个 Python 代码片段:

使用 urllib 进行搜索

import json
from urllib.request import urlopen
from urllib.parse import urlencode

params = dict(q='Sausages', format='json')
handle = urlopen('http://api.duckduckgo.com' + '?' + urlencode(params))
raw_text = handle.read().decode('utf8')
parsed = json.loads(raw_text)

results = parsed['RelatedTopics']
for r in results:
    if 'Text' in r:
        print(r['FirstURL'] + ' - ' + r['Text'])

使用 requests 进行搜索

import requests

params = dict(q='Sausages', format='json')
parsed = requests.get('http://api.duckduckgo.com/', params=params).json()

results = parsed['RelatedTopics']
for r in results:
    if 'Text' in r:
        print(r['FirstURL'] + ' - ' + r['Text'])

这两个代码清单都做同样的事情:它们提交表单编码的值到一个 URL,以便使用搜索引擎 API。但第二个更容易阅读和理解,因为它在更高的抽象级别上操作。

我们可以进一步迈出这一步,通过识别和命名我们希望代码为我们执行的任务,并使用更高级别的抽象来明确地执行它:

使用 duckduckgo 模块进行搜索

import duckduckgo
for r in duckduckgo.query('Sausages').results:
    print(r.url + ' - ' + r.text)

通过使用抽象来封装行为是使代码更具表现力、更易于测试和更易于维护的强大工具。

注意

在面向对象(OO)世界的文献中,这种方法的经典特征之一被称为责任驱动设计;它使用角色责任这些词,而不是任务。主要观点是以行为的方式思考代码,而不是以数据或算法的方式。¹

本书中的大多数模式都涉及选择抽象,因此您将在每一章中看到很多例子。此外,第三章专门讨论了选择抽象的一些一般启发法。

分层

封装和抽象通过隐藏细节和保护数据的一致性来帮助我们,但我们还需要注意对象和函数之间的交互。当一个函数、模块或对象使用另一个时,我们说一个依赖于另一个。这些依赖形成一种网络或图。

在一个巨大的泥球中,依赖关系失控(正如您在图 P-1 中看到的)。改变图的一个节点变得困难,因为它有可能影响系统的许多其他部分。分层架构是解决这个问题的一种方式。在分层架构中,我们将代码分成离散的类别或角色,并引入关于哪些代码类别可以相互调用的规则。

最常见的例子之一是图 P-2 中显示的三层架构

apwp 0002

图 P-2。分层架构
[ditaa,apwp_0002]
+----------------------------------------------------+
|                Presentation Layer                  |
+----------------------------------------------------+
                          |
                          V
+----------------------------------------------------+
|                 Business Logic                     |
+----------------------------------------------------+
                          |
                          V
+----------------------------------------------------+
|                  Database Layer                    |
+----------------------------------------------------+

分层架构可能是构建业务软件最常见的模式。在这个模型中,我们有用户界面组件,可以是网页、API 或命令行;这些用户界面组件与包含我们的业务规则和工作流程的业务逻辑层进行通信;最后,我们有一个负责存储和检索数据的数据库层。

在本书的其余部分,我们将通过遵循一个简单的原则系统地将这个模型颠倒过来。

依赖反转原则

您可能已经熟悉依赖反转原则(DIP),因为它是 SOLID 中的D。²

不幸的是,我们无法像我们为封装所做的那样使用三个小的代码清单来说明 DIP。然而,第 I 部分的整个内容本质上是一个在整个应用程序中实现 DIP 的示例,因此您将得到大量具体的例子。

与此同时,我们可以谈谈 DIP 的正式定义:

  1. 高级模块不应该依赖于低级模块。两者都应该依赖于抽象。

  2. 抽象不应该依赖于细节。相反,细节应该依赖于抽象。

但这意味着什么呢?让我们一点一点来看。

高级模块是您的组织真正关心的代码。也许您在制药公司工作,您的高级模块处理患者和试验。也许您在银行工作,您的高级模块管理交易和交易所。软件系统的高级模块是处理我们真实世界概念的函数、类和包。

相比之下,低级模块是您的组织不关心的代码。您的人力资源部门不太可能对文件系统或网络套接字感到兴奋。您不经常与财务团队讨论 SMTP、HTTP 或 AMQP。对于我们的非技术利益相关者来说,这些低级概念并不有趣或相关。他们关心的只是高级概念是否正常工作。如果工资按时发放,您的业务不太可能关心这是一个 cron 作业还是在 Kubernetes 上运行的临时函数。

依赖于并不一定意味着导入调用,而是一个更一般的想法,即一个模块知道需要另一个模块。

我们已经提到了抽象:它们是简化的接口,封装了行为,就像我们的 duckduckgo 模块封装了搜索引擎的 API 一样。

计算机科学中的所有问题都可以通过增加另一个间接层来解决。

——大卫·惠勒

因此,DIP 的第一部分表示我们的业务代码不应该依赖于技术细节;相反,两者都应该使用抽象。

为什么?大体上,因为我们希望能够独立地对它们进行更改。高级模块应该易于根据业务需求进行更改。低级模块(细节)在实践中通常更难更改:想想重构以更改函数名称与定义、测试和部署数据库迁移以更改列名称之间的区别。我们不希望业务逻辑的更改因为与低级基础设施细节紧密耦合而变慢。但同样地,当需要时,重要的是能够更改基础设施细节(例如考虑分片数据库),而不需要对业务层进行更改。在它们之间添加一个抽象(著名的额外间接层)允许它们更独立地进行更改。

第二部分更加神秘。“抽象不应该依赖于细节”似乎很清楚,但“细节应该依赖于抽象”很难想象。我们怎么可能有一个不依赖于它所抽象的细节的抽象呢?到了第四章,我们将有一个具体的例子,这应该会让这一切更加清晰一些。

我们所有业务逻辑的归宿:领域模型

但在我们能够将我们的三层架构颠倒过来之前,我们需要更多地讨论中间层:高级模块或业务逻辑。我们设计出错的最常见原因之一是业务逻辑分散在应用程序的各个层中,使得很难识别、理解和更改。

第一章展示了如何使用领域模型模式构建业务层。第一部分中的其余模式展示了我们如何通过选择正确的抽象并持续应用 DIP 来保持领域模型易于更改并且不受低级关注的影响。

¹ 如果你遇到过类-责任-协作(CRC)卡,它们指的是同样的事情:思考责任可以帮助你决定如何分割事物。

² SOLID 是 Robert C. Martin 关于面向对象设计的五个原则的首字母缩写:单一职责、开放封闭、里氏替换、接口隔离和依赖反转。参见 Samuel Oloruntoba 的文章“S.O.L.I.D: The First 5 Principles of Object-Oriented Design”

第一部分:构建支持领域建模的架构

原文:Part 1: Building an Architecture to Support Domain Modeling

译者:飞龙

协议:CC BY-NC-SA 4.0

大多数开发人员从未见过领域模型,只见过数据模型。

——Cyrille Martraire, DDD EU 2017

我们与关于架构的开发人员交谈时,他们常常有一种隐隐的感觉,觉得事情本可以更好。他们经常试图拯救一些出了问题的系统,并试图将一些结构重新放入一团混乱之中。他们知道他们的业务逻辑不应该分散在各个地方,但他们不知道如何解决。

我们发现许多开发人员在被要求设计一个新系统时,会立即开始构建数据库模式,将对象模型视为事后补充。这就是问题的根源。相反,行为应该首先驱动我们的存储需求。毕竟,我们的客户不关心数据模型。他们关心系统做什么;否则他们就会使用电子表格。

本书的第一部分介绍了如何通过 TDD 构建丰富的对象模型(在第一章中),然后我们将展示如何将该模型与技术关注点解耦。我们展示了如何构建与持久性无关的代码,以及如何围绕我们的领域创建稳定的 API,以便我们可以进行积极的重构。

为此,我们介绍了四个关键的设计模式:

如果你想知道我们的目标是什么,请看一下图 I-1,但如果现在还一头雾水也不要担心!我们会在本书的这一部分逐一介绍图中的每个框。

apwp p101

图 I-1:我们应用程序的组件图在第 I 部分结束时

我们还抽出一点时间来谈论耦合和抽象,并用一个简单的例子来说明我们选择抽象的方式及原因。

三个附录进一步探讨了第一部分的内容:

  • 附录 B 是我们示例代码的基础设施的描述:我们如何构建和运行 Docker 镜像,我们在哪里管理配置信息,以及我们如何运行不同类型的测试。

  • 附录 C 是一种“见证成败”的内容,展示了如何轻松地更换我们整个基础架构——Flask API、ORM 和 Postgres——以完全不同的 I/O 模型,涉及 CLI 和 CSV。

  • 最后,附录 D 可能会引起兴趣,如果你想知道使用 Django 而不是 Flask 和 SQLAlchemy 时这些模式会是什么样子。

第一章:领域建模

原文:1: Domain Modeling

译者:飞龙

协议:CC BY-NC-SA 4.0

本章将探讨如何用代码对业务流程进行建模,以一种与 TDD 高度兼容的方式。我们将讨论领域建模的重要性,并将介绍一些建模领域的关键模式:实体、值对象和领域服务。

图 1-1 是我们领域模型模式的一个简单的视觉占位符。在本章中,我们将填写一些细节,随着我们继续其他章节,我们将围绕领域模型构建东西,但您应该始终能够在核心找到这些小形状。

apwp 0101

图 1-1:我们领域模型的一个占位符插图

什么是领域模型?

介绍中,我们使用了术语业务逻辑层来描述三层架构的中心层。在本书的其余部分,我们将使用术语领域模型。这是 DDD 社区的一个术语,更能准确地捕捉我们的意思(有关 DDD 的更多信息,请参见下一个侧边栏)。

领域是说您正在尝试解决的问题的一种花哨的说法。您的作者目前为一家家具在线零售商工作。根据您所谈论的系统,领域可能是采购和采购、产品设计或物流和交付。大多数程序员都在努力改进或自动化业务流程;领域是支持这些流程的一系列活动。

模型是捕捉有用属性的过程或现象的地图。人类在脑海中制作事物的模型非常擅长。例如,当有人向您扔球时,您能够几乎下意识地预测其运动,因为您对物体在空间中移动的方式有一个模型。您的模型并不完美。人类对物体在接近光速或真空中的行为有着糟糕的直觉,因为我们的模型从未设计来涵盖这些情况。这并不意味着模型是错误的,但这确实意味着一些预测超出了其领域。

领域模型是业务所有者对其业务的心智地图。所有的商业人士都有这些心智地图——这是人类思考复杂流程的方式。

当他们在这些地图上导航时,您可以通过他们使用商业用语来判断。术语在协作处理复杂系统的人群中自然产生。

想象一下,您,我们不幸的读者,突然被传送到光年之外的外星飞船上,与您的朋友和家人一起,不得不从头开始弄清楚如何回家。

在最初的几天里,您可能只是随机按按钮,但很快您会学会哪些按钮做什么,这样您就可以给彼此指示。“按下闪烁的小玩意旁边的红色按钮,然后把雷达小玩意旁边的大杠杆扔过去”,您可能会说。

在几周内,您会变得更加精确,因为您采用了用于描述船舶功能的词汇:“增加货舱三的氧气水平”或“打开小推进器”。几个月后,您将采用整个复杂流程的语言:“开始着陆序列”或“准备跃迁”。这个过程会很自然地发生,而不需要任何正式的努力来建立共享词汇表。

因此,在日常商业世界中也是如此。商业利益相关者使用的术语代表了对领域模型的精炼理解,复杂的想法和流程被简化为一个词或短语。

当我们听到我们的业务利益相关者使用陌生的词汇,或者以特定方式使用术语时,我们应该倾听以理解更深层的含义,并将他们辛苦获得的经验编码到我们的软件中。

在本书中,我们将使用一个真实的领域模型,具体来说是我们目前的雇主的模型。MADE.com 是一家成功的家具零售商。我们从世界各地的制造商那里采购家具,并在整个欧洲销售。

当您购买沙发或咖啡桌时,我们必须想出如何最好地将您的商品从波兰、中国或越南运送到您的客厅。

在高层次上,我们有独立的系统负责购买库存、向客户销售库存和向客户发货。中间的一个系统需要通过将库存分配给客户的订单来协调这个过程;参见图 1-2。

apwp 0102

图 1-2:分配服务的上下文图
[plantuml, apwp_0102]
@startuml Allocation Context Diagram
!include images/C4_Context.puml

System(systema, "Allocation", "Allocates stock to customer orders")

Person(customer, "Customer", "Wants to buy furniture")
Person(buyer, "Buying Team", "Needs to purchase furniture from suppliers")

System(procurement, "Purchasing", "Manages workflow for buying stock from suppliers")
System(ecom, "E-commerce", "Sells goods online")
System(warehouse, "Warehouse", "Manages workflow for shipping goods to customers.")

Rel(buyer, procurement, "Uses")
Rel(procurement, systema, "Notifies about shipments")
Rel(customer, ecom, "Buys from")
Rel(ecom, systema, "Asks for stock levels")
Rel(ecom, systema, "Notifies about orders")
Rel_R(systema, warehouse, "Sends instructions to")
Rel_U(warehouse, customer, "Dispatches goods to")

@enduml

为了本书的目的,我们想象业务决定实施一种令人兴奋的新的库存分配方式。到目前为止,业务一直根据仓库中实际可用的库存和交货时间来展示库存和交货时间。如果仓库用完了,产品就被列为“缺货”,直到下一批从制造商那里到货。

这里的创新是:如果我们有一个系统可以跟踪我们所有的货物运输及其到达时间,我们就可以将这些货物视为真实库存和我们库存的一部分,只是交货时间稍长一些。更少的商品将显示为缺货,我们将销售更多商品,业务可以通过在国内仓库保持较低的库存来节省成本。

但是分配订单不再是在仓库系统中减少单个数量的琐事。我们需要一个更复杂的分配机制。是时候进行一些领域建模了。

探索领域语言

理解领域模型需要时间、耐心和便利贴。我们与业务专家进行了初步对话,并就领域模型的第一个最小版本的术语表和一些规则达成一致。在可能的情况下,我们要求提供具体的例子来说明每条规则。

我们确保用业务行话(在 DDD 术语中称为普遍语言)来表达这些规则。我们为我们的对象选择了易于讨论的可记忆的标识符,以便更容易地讨论示例。

“分配的一些注释”显示了我们在与领域专家讨论分配时可能做的一些注释。

领域模型的单元测试

我们不会在这本书中向您展示 TDD 的工作原理,但我们想向您展示我们如何从这次业务对话中构建模型。

我们的第一个测试可能如下所示:

分配的第一个测试(test_batches.py

def test_allocating_to_a_batch_reduces_the_available_quantity():
    batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today())
    line = OrderLine('order-ref', "SMALL-TABLE", 2)

    batch.allocate(line)

    assert batch.available_quantity == 18

我们的单元测试的名称描述了我们希望从系统中看到的行为,我们使用的类和变量的名称取自业务行话。我们可以向非技术同事展示这段代码,他们会同意这正确地描述了系统的行为。

这是一个满足我们要求的领域模型:

批次的领域模型的初步版本(model.py

@dataclass(frozen=True)  #(1) (2)
class OrderLine:
    orderid: str
    sku: str
    qty: int


class Batch:
    def __init__(self, ref: str, sku: str, qty: int, eta: Optional[date]):  #(2)
        self.reference = ref
        self.sku = sku
        self.eta = eta
        self.available_quantity = qty

    def allocate(self, line: OrderLine):  #(3)
        self.available_quantity -= line.qty

OrderLine是一个没有行为的不可变数据类。²

我们在大多数代码清单中不显示导入,以保持其整洁。我们希望您能猜到这是通过from dataclasses import dataclass导入的;同样,typing.Optionaldatetime.date也是如此。如果您想要进行双重检查,可以在其分支中查看每个章节的完整工作代码(例如,chapter_01_domain_model)。

类型提示在 Python 世界仍然是一个有争议的问题。对于领域模型,它们有时可以帮助澄清或记录预期的参数是什么,而且使用 IDE 的人通常会对它们表示感激。您可能会认为在可读性方面付出的代价太高。

我们的实现在这里是微不足道的:Batch只是包装了一个整数available_quantity,并在分配时减少该值。我们写了相当多的代码来从另一个数字中减去一个数字,但我们认为精确建模我们的领域将会得到回报。³

让我们写一些新的失败测试:

测试我们可以分配什么的逻辑(test_batches.py

def make_batch_and_line(sku, batch_qty, line_qty):
    return (
        Batch("batch-001", sku, batch_qty, eta=date.today()),
        OrderLine("order-123", sku, line_qty)
    )

def test_can_allocate_if_available_greater_than_required():
    large_batch, small_line = make_batch_and_line("ELEGANT-LAMP", 20, 2)
    assert large_batch.can_allocate(small_line)

def test_cannot_allocate_if_available_smaller_than_required():
    small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20)
    assert small_batch.can_allocate(large_line) is False

def test_can_allocate_if_available_equal_to_required():
    batch, line = make_batch_and_line("ELEGANT-LAMP", 2, 2)
    assert batch.can_allocate(line)

def test_cannot_allocate_if_skus_do_not_match():
    batch = Batch("batch-001", "UNCOMFORTABLE-CHAIR", 100, eta=None)
    different_sku_line = OrderLine("order-123", "EXPENSIVE-TOASTER", 10)
    assert batch.can_allocate(different_sku_line) is False

这里没有太多意外。我们重构了我们的测试套件,以便我们不再重复相同的代码行来为相同的 SKU 创建批次和行;我们为一个新方法can_allocate编写了四个简单的测试。再次注意,我们使用的名称与我们的领域专家的语言相呼应,并且我们商定的示例直接写入了代码。

我们也可以直接实现这一点,通过编写Batchcan_allocate方法:

模型中的一个新方法(model.py

    def can_allocate(self, line: OrderLine) -> bool:
        return self.sku == line.sku and self.available_quantity >= line.qty

到目前为止,我们可以通过增加和减少Batch.available_quantity来管理实现,但是当我们进入deallocate()测试时,我们将被迫采用更智能的解决方案:

这个测试将需要一个更智能的模型(test_batches.py

def test_can_only_deallocate_allocated_lines():
    batch, unallocated_line = make_batch_and_line("DECORATIVE-TRINKET", 20, 2)
    batch.deallocate(unallocated_line)
    assert batch.available_quantity == 20

在这个测试中,我们断言从批次中取消分配一行,除非批次先前分配了该行,否则不会产生任何影响。为了使其工作,我们的Batch需要了解哪些行已经被分配。让我们来看看实现:

领域模型现在跟踪分配(model.py

class Batch:
    def __init__(
        self, ref: str, sku: str, qty: int, eta: Optional[date]
    ):
        self.reference = ref
        self.sku = sku
        self.eta = eta
        self._purchased_quantity = qty
        self._allocations = set()  # type: Set[OrderLine]

    def allocate(self, line: OrderLine):
        if self.can_allocate(line):
            self._allocations.add(line)

    def deallocate(self, line: OrderLine):
        if line in self._allocations:
            self._allocations.remove(line)

    @property
    def allocated_quantity(self) -> int:
        return sum(line.qty for line in self._allocations)

    @property
    def available_quantity(self) -> int:
        return self._purchased_quantity - self.allocated_quantity

    def can_allocate(self, line: OrderLine) -> bool:
        return self.sku == line.sku and self.available_quantity >= line.qty

图 1-3 显示了 UML 中的模型。

apwp 0103

图 1-3. 我们的 UML 模型
[plantuml, apwp_0103, config=plantuml.cfg]

left to right direction
hide empty members

class Batch {
    reference
    sku
    eta
    _purchased_quantity
    _allocations
}

class OrderLine {
    orderid
    sku
    qty
}

Batch::_allocations o-- OrderLine

现在我们有了进展!批次现在跟踪一组已分配的OrderLine对象。当我们分配时,如果我们有足够的可用数量,我们只需添加到集合中。我们的available_quantity现在是一个计算属性:购买数量减去分配数量。

是的,我们还可以做很多事情。令人不安的是,allocate()deallocate()都可能悄悄失败,但我们已经掌握了基础知识。

顺便说一句,使用._allocations的集合使我们能够简单地处理最后一个测试,因为集合中的项目是唯一的:

最后的批次测试!(test_batches.py

def test_allocation_is_idempotent():
    batch, line = make_batch_and_line("ANGULAR-DESK", 20, 2)
    batch.allocate(line)
    batch.allocate(line)
    assert batch.available_quantity == 18

目前,可以说领域模型太琐碎,不值得费心去做 DDD(甚至是面向对象!)。在现实生活中,会出现任意数量的业务规则和边缘情况:客户可以要求在特定未来日期交付,这意味着我们可能不想将它们分配给最早的批次。一些 SKU 不在批次中,而是直接从供应商那里按需订购,因此它们具有不同的逻辑。根据客户的位置,我们只能分配给其地区内的一部分仓库和货运,除了一些 SKU,如果我们在本地区域缺货,我们可以从不同地区的仓库交付。等等。现实世界中的真实企业知道如何比我们在页面上展示的更快地增加复杂性!

但是,将这个简单的领域模型作为更复杂东西的占位符,我们将在本书的其余部分扩展我们简单的领域模型,并将其插入到 API 和数据库以及电子表格的真实世界中。我们将看到,严格遵守封装和谨慎分层的原则将帮助我们避免一团泥。

数据类非常适合值对象

在先前的代码列表中,我们大量使用了line,但是什么是 line?在我们的业务语言中,一个订单有多个项目,每行都有一个 SKU 和数量。我们可以想象,一个包含订单信息的简单 YAML 文件可能如下所示:

订单信息作为 YAML

Order_reference: 12345
Lines:
  - sku: RED-CHAIR
    qty: 25
  - sku: BLU-CHAIR
    qty: 25
  - sku: GRN-CHAIR
    qty: 25

请注意,订单具有唯一标识它的引用,而线路没有。(即使我们将订单引用添加到OrderLine类中,它也不是唯一标识线路本身的东西。)

每当我们有一个具有数据但没有身份的业务概念时,我们通常选择使用价值对象模式来表示它。价值对象是任何由其持有的数据唯一标识的领域对象;我们通常使它们是不可变的:

OrderLine 是一个价值对象

@dataclass(frozen=True)
class OrderLine:
    orderid: OrderReference
    sku: ProductReference
    qty: Quantity

数据类(或命名元组)给我们带来的一个好处是值相等,这是说“具有相同orderidskuqty的两行是相等的”这种花哨的方式。

价值对象的更多示例

from dataclasses import dataclass
from typing import NamedTuple
from collections import namedtuple

@dataclass(frozen=True)
class Name:
    first_name: str
    surname: str

class Money(NamedTuple):
    currency: str
    value: int

Line = namedtuple('Line', ['sku', 'qty'])

def test_equality():
    assert Money('gbp', 10) == Money('gbp', 10)
    assert Name('Harry', 'Percival') != Name('Bob', 'Gregory')
    assert Line('RED-CHAIR', 5) == Line('RED-CHAIR', 5)

这些价值对象与我们对其值如何工作的现实世界直觉相匹配。讨论的是哪张10 英镑钞票并不重要,因为它们都有相同的价值。同样,如果名字的名和姓都匹配,两个名字是相等的;如果客户订单、产品代码和数量相同,两行是等价的。不过,我们仍然可以在价值对象上有复杂的行为。事实上,在值上支持操作是很常见的;例如,数学运算符:

使用价值对象进行数学运算

fiver = Money('gbp', 5)
tenner = Money('gbp', 10)

def can_add_money_values_for_the_same_currency():
    assert fiver + fiver == tenner

def can_subtract_money_values():
    assert tenner - fiver == fiver

def adding_different_currencies_fails():
    with pytest.raises(ValueError):
        Money('usd', 10) + Money('gbp', 10)

def can_multiply_money_by_a_number():
    assert fiver * 5 == Money('gbp', 25)

def multiplying_two_money_values_is_an_error():
    with pytest.raises(TypeError):
        tenner * fiver

价值对象和实体

订单行通过其订单 ID、SKU 和数量唯一标识;如果我们更改其中一个值,现在我们有了一个新的行。这就是价值对象的定义:任何仅由其数据标识并且没有长期身份的对象。不过,批次呢?那由一个引用标识的。

我们使用术语实体来描述具有长期身份的领域对象。在上一页中,我们介绍了Name类作为一个价值对象。如果我们把哈利·珀西瓦尔的名字改变一个字母,我们就得到了新的Name对象巴里·珀西瓦尔。

哈利·珀西瓦尔显然不等于巴里·珀西瓦尔:

名字本身是不能改变的...

def test_name_equality():
    assert Name("Harry", "Percival") != Name("Barry", "Percival")

但是作为的哈利呢?人们确实会改变他们的名字,婚姻状况,甚至性别,但我们仍然认为他们是同一个个体。这是因为人类,与名字不同,具有持久的身份

但一个人可以!

class Person:

    def __init__(self, name: Name):
        self.name = name

def test_barry_is_harry():
    harry = Person(Name("Harry", "Percival"))
    barry = harry

    barry.name = Name("Barry", "Percival")

    assert harry is barry and barry is harry

实体,与值不同,具有身份相等。我们可以改变它们的值,它们仍然可以被识别为同一件事物。在我们的例子中,批次是实体。我们可以为批次分配线路,或更改我们期望它到达的日期,它仍然是同一个实体。

我们通常通过在实体上实现相等运算符来在代码中明确表示这一点:

实现相等运算符(model.py

class Batch:
    ...

    def __eq__(self, other):
        if not isinstance(other, Batch):
            return False
        return other.reference == self.reference

    def __hash__(self):
        return hash(self.reference)

Python 的__eq__魔术方法定义了类在==运算符下的行为。⁵

对于实体和价值对象,思考__hash__的工作方式也很重要。这是 Python 用来控制对象在添加到集合或用作字典键时的行为的魔术方法;你可以在Python 文档中找到更多信息。

对于价值对象,哈希应该基于所有值属性,并且我们应该确保对象是不可变的。通过在数据类上指定@frozen=True,我们可以免费获得这一点。

对于实体,最简单的选择是说哈希是None,这意味着对象是不可哈希的,不能用于集合中。如果出于某种原因,你决定确实想要使用集合或字典操作与实体,哈希应该基于定义实体在一段时间内的唯一身份的属性(如.reference)。你还应该尝试以某种方式使那个属性只读。

警告

这是一个棘手的领域;你不应该修改__hash__而不修改__eq__。如果你不确定自己在做什么,建议进一步阅读。我们的技术审阅员 Hynek Schlawack 的“Python Hashes and Equality”是一个很好的起点。

并非所有的东西都必须是一个对象:领域服务函数

我们已经制作了一个表示批次的模型,但我们实际上需要做的是针对代表我们所有库存的特定一组批次分配订单行。

有时,这只是一种事情。

——Eric Evans,领域驱动设计

Evans 讨论了领域服务操作的概念,这些操作在实体或值对象中没有自然的归属地。⁶ 分配订单行的东西,给定一组批次,听起来很像一个函数,我们可以利用 Python 是一种多范式语言的事实,只需将其变成一个函数。

让我们看看如何测试驱动这样一个函数:

测试我们的领域服务(test_allocate.py

def test_prefers_current_stock_batches_to_shipments():
    in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
    shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
    line = OrderLine("oref", "RETRO-CLOCK", 10)

    allocate(line, [in_stock_batch, shipment_batch])

    assert in_stock_batch.available_quantity == 90
    assert shipment_batch.available_quantity == 100

def test_prefers_earlier_batches():
    earliest = Batch("speedy-batch", "MINIMALIST-SPOON", 100, eta=today)
    medium = Batch("normal-batch", "MINIMALIST-SPOON", 100, eta=tomorrow)
    latest = Batch("slow-batch", "MINIMALIST-SPOON", 100, eta=later)
    line = OrderLine("order1", "MINIMALIST-SPOON", 10)

    allocate(line, [medium, earliest, latest])

    assert earliest.available_quantity == 90
    assert medium.available_quantity == 100
    assert latest.available_quantity == 100

def test_returns_allocated_batch_ref():
    in_stock_batch = Batch("in-stock-batch-ref", "HIGHBROW-POSTER", 100, eta=None)
    shipment_batch = Batch("shipment-batch-ref", "HIGHBROW-POSTER", 100, eta=tomorrow)
    line = OrderLine("oref", "HIGHBROW-POSTER", 10)
    allocation = allocate(line, [in_stock_batch, shipment_batch])
    assert allocation == in_stock_batch.reference

我们的服务可能看起来像这样:

我们的领域服务的独立函数(model.py

def allocate(line: OrderLine, batches: List[Batch]) -> str:
    batch = next(
        b for b in sorted(batches) if b.can_allocate(line)
    )
    batch.allocate(line)
    return batch.reference

Python 的魔术方法让我们可以使用我们的模型与惯用的 Python

你可能会喜欢或不喜欢在前面的代码中使用next(),但我们非常确定你会同意在我们的批次列表上使用sorted()是很好的,符合 Python 的惯用法。

为了使其工作,我们在我们的领域模型上实现__gt__

魔术方法可以表达领域语义(model.py

class Batch:
    ...

    def __gt__(self, other):
        if self.eta is None:
            return False
        if other.eta is None:
            return True
        return self.eta > other.eta

太棒了。

异常也可以表达领域概念

我们还有一个最后的概念要涵盖:异常也可以用来表达领域概念。在与领域专家的对话中,我们了解到订单无法分配的可能性,因为我们缺货,我们可以通过使用领域异常来捕获这一点:

测试缺货异常(test_allocate.py

def test_raises_out_of_stock_exception_if_cannot_allocate():
    batch = Batch('batch1', 'SMALL-FORK', 10, eta=today)
    allocate(OrderLine('order1', 'SMALL-FORK', 10), [batch])

    with pytest.raises(OutOfStock, match='SMALL-FORK'):
        allocate(OrderLine('order2', 'SMALL-FORK', 1), [batch])

我们不会让你对实现感到厌烦,但需要注意的主要事情是,我们在通用语言中命名我们的异常,就像我们对实体、值对象和服务一样:

引发领域异常(model.py

class OutOfStock(Exception):
    pass

def allocate(line: OrderLine, batches: List[Batch]) -> str:
    try:
        batch = next(
        ...
    except StopIteration:
        raise OutOfStock(f'Out of stock for sku {line.sku}')

图 1-4 是我们最终达到的视觉表示。

apwp 0104

图 1-4:本章结束时的我们的领域模型

现在可能就够了!我们有一个可以用于我们的第一个用例的域服务。但首先我们需要一个数据库...

¹ DDD 并非起源于领域建模。Eric Evans 提到了 2002 年 Rebecca Wirfs-Brock 和 Alan McKean 的书Object Design(Addison-Wesley Professional),该书介绍了责任驱动设计,而 DDD 是处理领域的特殊情况。但即使如此,这也太晚了,OO 爱好者会告诉你要更进一步地回溯到 Ivar Jacobson 和 Grady Booch;这个术语自上世纪 80 年代中期就已经存在了。

² 在以前的 Python 版本中,我们可能会使用一个命名元组。您还可以查看 Hynek Schlawack 的优秀的attrs

³ 或者也许你认为代码还不够?OrderLine中的 SKU 与Batch.sku匹配的某种检查呢?我们在附录 E 中保存了一些关于验证的想法。

⁴ 这太糟糕了。请,拜托,不要这样做。 ——Harry

__eq__方法的发音是“dunder-EQ”。至少有些人是这么说的。

⁶ 领域服务与服务层中的服务不是同一回事,尽管它们经常密切相关。领域服务代表了一个业务概念或流程,而服务层服务代表了应用程序的用例。通常,服务层将调用领域服务。

第二章:仓储模式

原文:2: Repository Pattern

译者:飞龙

协议:CC BY-NC-SA 4.0

是时候兑现我们使用依赖倒置原则来将核心逻辑与基础设施问题解耦的承诺了。

我们将引入仓储模式,这是对数据存储的简化抽象,允许我们将模型层与数据层解耦。我们将通过一个具体的例子来展示这种简化的抽象如何通过隐藏数据库的复杂性使我们的系统更具可测试性。

图 2-1 显示了我们将要构建的一个小预览:一个Repository对象,位于我们的领域模型和数据库之间。

apwp 0201

图 2-1:仓储模式之前和之后
提示

本章的代码在 GitHub 的 chapter_02_repository 分支中。

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_02_repository
# or to code along, checkout the previous chapter:
git checkout chapter_01_domain_model

持久化我们的领域模型

在第一章中,我们构建了一个简单的领域模型,可以将订单分配给库存批次。我们很容易对这段代码编写测试,因为没有任何依赖或基础设施需要设置。如果我们需要运行数据库或 API 并创建测试数据,我们的测试将更难编写和维护。

不幸的是,我们总有一天需要把我们完美的小模型交到用户手中,并应对电子表格、Web 浏览器和竞争条件的现实世界。在接下来的几章中,我们将看看如何将我们理想化的领域模型连接到外部状态。

我们期望以敏捷的方式工作,因此我们的优先任务是尽快实现最小可行产品。在我们的情况下,这将是一个 Web API。在一个真实的项目中,你可能会直接进行一些端到端的测试,并开始插入一个 Web 框架,从外到内进行测试驱动。

但是我们知道,无论如何,我们都需要某种形式的持久存储,这是一本教科书,所以我们可以允许自己多一点自下而上的开发,并开始考虑存储和数据库。

一些伪代码:我们需要什么?

当我们构建我们的第一个 API 端点时,我们知道我们将会有一些看起来更或多少像以下的代码。

我们的第一个 API 端点将是什么样子

@flask.route.gubbins
def allocate_endpoint():
    # extract order line from request
    line = OrderLine(request.params, ...)
    # load all batches from the DB
    batches = ...
    # call our domain service
    allocate(line, batches)
    # then save the allocation back to the database somehow
    return 201
注意

我们使用 Flask 是因为它很轻量,但你不需要是 Flask 用户才能理解这本书。事实上,我们将向你展示如何使你选择的框架成为一个细节。

我们需要一种方法从数据库中检索批次信息,并从中实例化我们的领域模型对象,我们还需要一种将它们保存回数据库的方法。

什么?哦,“gubbins”是一个英国词,意思是“东西”。你可以忽略它。这是伪代码,好吗?

应用 DIP 到数据访问

介绍中提到的,分层架构是一种常见的系统结构方法,该系统具有 UI、一些逻辑和数据库(见图 2-2)。

apwp 0202

图 2-2:分层架构

Django 的模型-视图-模板结构是密切相关的,就像模型-视图-控制器(MVC)一样。无论如何,目标是保持各层分离(这是一件好事),并且使每一层仅依赖于其下面的一层。

但是我们希望我们的领域模型完全没有任何依赖。我们不希望基础设施问题渗入我们的领域模型,从而减慢我们的单元测试或我们进行更改的能力。

相反,正如在介绍中讨论的那样,我们将把我们的模型视为“内部”,并将依赖项向内流动;这就是人们有时称之为洋葱架构的东西(见图 2-3)。

apwp 0203

图 2-3:洋葱架构
[ditaa, apwp_0203]
+------------------------+
|   Presentation Layer   |
+------------------------+
           |
           V
+--------------------------------------------------+
|                  Domain Model                    |
+--------------------------------------------------+
                                        ^
                                        |
                             +---------------------+
                             |    Database Layer   |
                             +---------------------+

提醒:我们的模型

让我们回顾一下我们的领域模型(见图 2-4):分配是将OrderLine链接到Batch的概念。我们将分配存储为我们Batch对象的集合。

apwp 0103

图 2-4:我们的模型

让我们看看如何将其转换为关系数据库。

“正常”的 ORM 方式:模型依赖于 ORM

如今,您的团队成员不太可能手工编写自己的 SQL 查询。相反,您几乎肯定是在基于模型对象生成 SQL 的某种框架上使用。

这些框架被称为“对象关系映射器”(ORMs),因为它们存在的目的是弥合对象和领域建模世界与数据库和关系代数世界之间的概念差距。

ORM 给我们最重要的东西是“持久性无知”:即我们的精巧领域模型不需要知道如何加载或持久化数据。这有助于保持我们的领域不受特定数据库技术的直接依赖。³

但是,如果您遵循典型的 SQLAlchemy 教程,最终会得到类似于这样的东西:

SQLAlchemy 的“声明性”语法,模型依赖于 ORM(orm.py

from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship

Base = declarative_base()

class Order(Base):
    id = Column(Integer, primary_key=True)

class OrderLine(Base):
    id = Column(Integer, primary_key=True)
    sku = Column(String(250))
    qty = Integer(String(250))
    order_id = Column(Integer, ForeignKey('order.id'))
    order = relationship(Order)

class Allocation(Base):
    ...

您无需了解 SQLAlchemy 就能看到我们的原始模型现在充满了对 ORM 的依赖,并且看起来非常丑陋。我们真的能说这个模型对数据库一无所知吗?当我们的模型属性直接耦合到数据库列时,它怎么能与存储问题分离?

反转依赖关系:ORM 依赖于模型

幸运的是,这并不是使用 SQLAlchemy 的唯一方式。另一种方法是分别定义架构,并定义一个显式的映射器,用于在架构和我们的领域模型之间进行转换,SQLAlchemy 称之为经典映射

使用 SQLAlchemy Table 对象显式 ORM 映射(orm.py

from sqlalchemy.orm import mapper, relationship

import model  #(1)


metadata = MetaData()

order_lines = Table(  #(2)
    "order_lines",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("sku", String(255)),
    Column("qty", Integer, nullable=False),
    Column("orderid", String(255)),
)

...

def start_mappers():
    lines_mapper = mapper(model.OrderLine, order_lines)  #(3)

ORM 导入(或“依赖”或“了解”)领域模型,而不是相反。

我们通过使用 SQLAlchemy 的抽象来定义我们的数据库表和列。⁴

当我们调用mapper函数时,SQLAlchemy 会通过其魔术将我们的领域模型类绑定到我们定义的各种表上。

最终结果将是,如果我们调用start_mappers,我们将能够轻松地从数据库加载和保存领域模型实例。但如果我们从未调用该函数,我们的领域模型类将幸福地不知道数据库的存在。

这为我们带来了 SQLAlchemy 的所有好处,包括能够使用alembic进行迁移,并且能够透明地使用我们的领域类进行查询,我们将会看到。

当您首次尝试构建 ORM 配置时,编写测试可能会很有用,如以下示例所示:

直接测试 ORM(一次性测试)(test_orm.py

def test_orderline_mapper_can_load_lines(session):  #(1)
    session.execute(
        "INSERT INTO order_lines (orderid, sku, qty) VALUES "
        '("order1", "RED-CHAIR", 12),'
        '("order1", "RED-TABLE", 13),'
        '("order2", "BLUE-LIPSTICK", 14)'
    )
    expected = [
        model.OrderLine("order1", "RED-CHAIR", 12),
        model.OrderLine("order1", "RED-TABLE", 13),
        model.OrderLine("order2", "BLUE-LIPSTICK", 14),
    ]
    assert session.query(model.OrderLine).all() == expected


def test_orderline_mapper_can_save_lines(session):
    new_line = model.OrderLine("order1", "DECORATIVE-WIDGET", 12)
    session.add(new_line)
    session.commit()

    rows = list(session.execute('SELECT orderid, sku, qty FROM "order_lines"'))
    assert rows == [("order1", "DECORATIVE-WIDGET", 12)]

如果您还没有使用 pytest,需要解释此测试的session参数。您无需担心 pytest 或其固定装置的细节,但简单的解释是,您可以将测试的常见依赖项定义为“固定装置”,pytest 将通过查看其函数参数将它们注入到需要它们的测试中。在这种情况下,它是一个 SQLAlchemy 数据库会话。

您可能不会保留这些测试,因为很快您将看到,一旦您采取了反转 ORM 和领域模型的步骤,实现另一个称为存储库模式的抽象只是一个小的额外步骤,这将更容易编写测试,并将为以后的测试提供一个简单的接口。

但我们已经实现了我们颠倒传统依赖的目标:领域模型保持“纯粹”并且不受基础设施问题的影响。我们可以放弃 SQLAlchemy 并使用不同的 ORM,或者完全不同的持久性系统,领域模型根本不需要改变。

根据您在领域模型中所做的工作,特别是如果您偏离 OO 范式,您可能会发现越来越难以使 ORM 产生您需要的确切行为,并且您可能需要修改您的领域模型。⁵就像经常发生的架构决策一样,您需要考虑权衡。正如 Python 之禅所说:“实用性胜过纯粹!”

不过,此时我们的 API 端点可能看起来像下面这样,并且我们可以让它正常工作:

在我们的 API 端点直接使用 SQLAlchemy

@flask.route.gubbins
def allocate_endpoint():
    session = start_session()

    # extract order line from request
    line = OrderLine(
        request.json['orderid'],
        request.json['sku'],
        request.json['qty'],
    )

    # load all batches from the DB
    batches = session.query(Batch).all()

    # call our domain service
    allocate(line, batches)

    # save the allocation back to the database
    session.commit()

    return 201

引入存储库模式

存储库模式是对持久性存储的抽象。它通过假装我们所有的数据都在内存中来隐藏数据访问的无聊细节。

如果我们的笔记本电脑有无限的内存,我们就不需要笨拙的数据库了。相反,我们可以随时使用我们的对象。那会是什么样子?

你必须从某处获取你的数据

import all_my_data

def create_a_batch():
    batch = Batch(...)
    all_my_data.batches.add(batch)

def modify_a_batch(batch_id, new_quantity):
    batch = all_my_data.batches.get(batch_id)
    batch.change_initial_quantity(new_quantity)

尽管我们的对象在内存中,但我们需要将它们放在某个地方,以便我们可以再次找到它们。我们的内存中的数据可以让我们添加新对象,就像列表或集合一样。因为对象在内存中,我们永远不需要调用.save()方法;我们只需获取我们关心的对象并在内存中修改它。

抽象中的存储库

最简单的存储库只有两种方法:add()用于将新项目放入存储库,get()用于返回先前添加的项目。⁶我们严格遵守在我们的领域和服务层中使用这些方法进行数据访问。这种自我施加的简单性阻止了我们将领域模型与数据库耦合。

这是我们存储库的抽象基类(ABC)会是什么样子:

最简单的存储库(repository.py

class AbstractRepository(abc.ABC):
    @abc.abstractmethod  #(1)
    def add(self, batch: model.Batch):
        raise NotImplementedError  #(2)

    @abc.abstractmethod
    def get(self, reference) -> model.Batch:
        raise NotImplementedError

Python 提示:@abc.abstractmethod是 Python 中使抽象基类实际“工作”的少数几件事之一。Python 将拒绝让您实例化未实现其父类中定义的所有abstractmethods的类。⁷

raise NotImplementedError很好,但既不是必要的也不是充分的。实际上,如果您真的想要,您的抽象方法可以具有子类可以调用的真实行为。

这是一种权衡吗?

你知道他们说经济学家知道一切的价格,但对任何价值一无所知吗?嗯,程序员知道一切的好处,但对任何权衡一无所知。

——Rich Hickey

每当我们在本书中引入一个架构模式时,我们总是会问:“我们从中得到了什么?以及我们付出了什么代价?”

通常情况下,我们至少会引入一个额外的抽象层,尽管我们可能希望它会减少整体复杂性,但它确实会增加局部复杂性,并且在移动部件的原始数量和持续维护方面会有成本。

存储库模式可能是本书中最容易的选择之一,尽管如果您已经在走领域驱动设计和依赖反转的路线。就我们的代码而言,我们实际上只是将 SQLAlchemy 抽象(session.query(Batch))替换为我们设计的另一个抽象(batches_repo.get)。

每当我们添加一个新的领域对象想要检索时,我们将不得不在我们的存储库类中写入几行代码,但作为回报,我们得到了一个简单的抽象层,我们可以控制。存储库模式将使我们能够轻松地对存储方式进行根本性的更改(参见附录 C),正如我们将看到的,它很容易为单元测试伪造出来。

此外,存储库模式在 DDD 世界中是如此常见,以至于,如果你与从 Java 和 C#世界转到 Python 的程序员合作,他们可能会认识它。图 2-5 说明了这种模式。

apwp 0205

图 2-5:存储库模式
[ditaa, apwp_0205]
  +-----------------------------+
  |      Application Layer      |
  +-----------------------------+
                 |^
                 ||          /------------------\
                 ||----------|   Domain Model   |
                 ||          |      Objects     |
                 ||          \------------------/
                 V|
  +------------------------------+
  |          Repository          |
  +------------------------------+
                 |
                 V
  +------------------------------+
  |        Database Layer        |
  +------------------------------+

与往常一样,我们从测试开始。这可能被归类为集成测试,因为我们正在检查我们的代码(存储库)是否与数据库正确集成;因此,测试往往会在我们自己的代码上混合原始 SQL 调用和断言。

提示

与之前的 ORM 测试不同,这些测试是长期留在代码库中的好选择,特别是如果领域模型的任何部分意味着对象关系映射是非平凡的。

保存对象的存储库测试(test_repository.py

def test_repository_can_save_a_batch(session):
    batch = model.Batch("batch1", "RUSTY-SOAPDISH", 100, eta=None)

    repo = repository.SqlAlchemyRepository(session)
    repo.add(batch)  #(1)
    session.commit()  #(2)

    rows = session.execute(  #(3)
        'SELECT reference, sku, _purchased_quantity, eta FROM "batches"'
    )
    assert list(rows) == [("batch1", "RUSTY-SOAPDISH", 100, None)]

repo.add()是这里测试的方法。

我们将.commit()放在存储库之外,并将其作为调用者的责任。这样做有利有弊;当我们到达第六章时,我们的一些原因将变得更加清晰。

我们使用原始 SQL 来验证已保存正确的数据。

下一个测试涉及检索批次和分配,所以它更复杂:

检索复杂对象的存储库测试(test_repository.py

def insert_order_line(session):
    session.execute(  #(1)
        "INSERT INTO order_lines (orderid, sku, qty)"
        ' VALUES ("order1", "GENERIC-SOFA", 12)'
    )
    [[orderline_id]] = session.execute(
        "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku",
        dict(orderid="order1", sku="GENERIC-SOFA"),
    )
    return orderline_id


def insert_batch(session, batch_id):  #(2)
    ...

def test_repository_can_retrieve_a_batch_with_allocations(session):
    orderline_id = insert_order_line(session)
    batch1_id = insert_batch(session, "batch1")
    insert_batch(session, "batch2")
    insert_allocation(session, orderline_id, batch1_id)  #(2)

    repo = repository.SqlAlchemyRepository(session)
    retrieved = repo.get("batch1")

    expected = model.Batch("batch1", "GENERIC-SOFA", 100, eta=None)
    assert retrieved == expected  # Batch.__eq__ only compares reference  #(3)
    assert retrieved.sku == expected.sku  #(4)
    assert retrieved._purchased_quantity == expected._purchased_quantity
    assert retrieved._allocations == {  #(4)
        model.OrderLine("order1", "GENERIC-SOFA", 12),
    }

这些测试是读取方面的,因此原始 SQL 正在准备数据以供repo.get()读取。

我们将不详细介绍insert_batchinsert_allocation;重点是创建一对批次,并且对我们感兴趣的批次,有一个现有的订单行分配给它。

这就是我们在这里验证的。第一个assert ==检查类型是否匹配,并且引用是否相同(因为你记得,Batch是一个实体,我们为它有一个自定义的*eq*)。

因此,我们还明确检查它的主要属性,包括._allocations,它是OrderLine值对象的 Python 集合。

无论你是否费心为每个模型编写测试都是一个判断调用。一旦你为创建/修改/保存测试了一个类,你可能会很高兴地继续做其他类的最小往返测试,甚至什么都不做,如果它们都遵循相似的模式。在我们的情况下,设置._allocations集的 ORM 配置有点复杂,所以它值得一个特定的测试。

你最终会得到这样的东西:

典型的存储库(repository.py

class SqlAlchemyRepository(AbstractRepository):

    def __init__(self, session):
        self.session = session

    def add(self, batch):
        self.session.add(batch)

    def get(self, reference):
        return self.session.query(model.Batch).filter_by(reference=reference).one()

    def list(self):
        return self.session.query(model.Batch).all()

现在我们的 Flask 端点可能看起来像下面这样:

直接在 API 端点中使用我们的存储库

@flask.route.gubbins
def allocate_endpoint():
    batches = SqlAlchemyRepository.list()
    lines = [
        OrderLine(l['orderid'], l['sku'], l['qty'])
         for l in request.params...
    ]
    allocate(lines, batches)
    session.commit()
    return 201

为测试构建一个假存储库现在变得轻而易举!

这是存储库模式最大的好处之一:

使用集合的简单假存储库(repository.py

class FakeRepository(AbstractRepository):

    def __init__(self, batches):
        self._batches = set(batches)

    def add(self, batch):
        self._batches.add(batch)

    def get(self, reference):
        return next(b for b in self._batches if b.reference == reference)

    def list(self):
        return list(self._batches)

因为它是一个围绕set的简单包装器,所有方法都是一行代码。

在测试中使用假存储库真的很容易,而且我们有一个简单易用且易于理解的抽象:

假存储库的示例用法(test_api.py

fake_repo = FakeRepository([batch1, batch2, batch3])

你将在下一章中看到这个假存储库的实际应用。

提示

为你的抽象构建假对象是获得设计反馈的一个绝佳方式:如果很难伪造,那么这个抽象可能太复杂了。

什么是端口,什么是适配器,在 Python 中?

我们不想在这里过多地纠缠术语,因为我们想要专注于依赖反转,您使用的技术的具体细节并不太重要。此外,我们知道不同的人使用略有不同的定义。

端口和适配器来自 OO 世界,我们坚持的定义是端口是我们的应用程序与我们希望抽象的任何东西之间的接口适配器是该接口或抽象背后的实现

现在 Python 本身没有接口,因此虽然通常很容易识别适配器,但定义端口可能更难。如果您使用抽象基类,那就是端口。如果没有,端口就是您的适配器符合并且您的核心应用程序期望的鸭子类型——使用的函数和方法名称,以及它们的参数名称和类型。

具体来说,在本章中,AbstractRepository是端口,SqlAlchemyRepositoryFakeRepository是适配器。

总结

牢记 Rich Hickey 的话,在每一章中,我们总结介绍的每种架构模式的成本和收益。我们想要明确的是,我们并不是说每个应用程序都需要以这种方式构建;只有在应用程序和领域的复杂性使得值得投入时间和精力来添加这些额外的间接层时,才会这样。

考虑到这一点,表 2-1 显示了存储库模式和我们的持久性无关模型的一些优缺点。

表 2-1. 存储库模式和持久性无知:权衡

优点 缺点
我们在持久存储和我们的领域模型之间有一个简单的接口。 ORM 已经为您购买了一些解耦。更改外键可能很困难,但如果有必要,应该很容易在 MySQL 和 Postgres 之间进行切换。
很容易为单元测试制作存储库的虚假版本,或者交换不同的存储解决方案,因为我们已经完全将模型与基础设施问题解耦。 手动维护 ORM 映射需要额外的工作和额外的代码。
在考虑持久性之前编写领域模型有助于我们专注于手头的业务问题。如果我们想要彻底改变我们的方法,我们可以在模型中做到这一点,而无需担心外键或迁移直到以后。 任何额外的间接层都会增加维护成本,并为以前从未见过存储库模式的 Python 程序员增加“WTF 因素”。
我们的数据库模式非常简单,因为我们完全控制了如何将对象映射到表。

图 2-6 显示了基本的论点:是的,对于简单情况,解耦的领域模型比简单的 ORM/ActiveRecord 模式更难工作。⁸

提示

如果您的应用程序只是一个简单的围绕数据库的 CRUD(创建-读取-更新-删除)包装器,那么您不需要领域模型或存储库。

但是,领域越复杂,从基础设施问题中解放自己的投资将在进行更改方面产生更大的回报。

apwp 0206

图 2-6. 领域模型权衡的图表
[ditaa, apwp_0206]

Cost of Changes

     ^                         /
     |      ActiveRecord/ORM |
     |                         |                             ----/
     |                        /                         ----/
     |                        |                    ----/
     |                       /                ----/
     |                       |           ----/  Domain model w/ Repository pattern
     |                      /       ----/
     |                      |  ----/
     |                    ----/
     |               ----/ /
     |          ----/     /
     |     ----/        -/
     |----/          --/
     |           ---/
     |       ----/
     |------/
     |
     +--------------------------------------------------------------->
                      Complexity of business domain/logic

我们的示例代码并不复杂,无法给出图表右侧的更多提示,但提示已经存在。例如,想象一下,如果有一天我们决定要将分配更改为存在于OrderLine而不是Batch对象上:如果我们正在使用 Django,我们必须在运行任何测试之前定义并思考数据库迁移。因为我们的模型只是普通的 Python 对象,所以我们可以将set()更改为一个新属性,而无需考虑数据库直到以后。

你可能会想,我们如何实例化这些存储库,是虚拟的还是真实的?我们的 Flask 应用实际上会是什么样子?在下一个激动人心的部分中,服务层模式中会有答案。

但首先,让我们稍作偏离一下。

¹ 我想我们的意思是“不依赖有状态的依赖关系”。依赖于辅助库是可以的;依赖 ORM 或 Web 框架则不行。

² Mark Seemann 在这个主题上有一篇优秀的博客文章

³ 从这个意义上讲,使用 ORM 已经是 DIP 的一个例子。我们不依赖硬编码的 SQL,而是依赖于 ORM 这种抽象。但对我们来说还不够——至少在这本书中不够!

⁴ 即使在我们不使用 ORM 的项目中,我们经常会在 Python 中使用 SQLAlchemy 和 Alembic 来声明性地创建模式,并管理迁移、连接和会话。

⁵ 向 SQLAlchemy 的维护者表示感谢,特别是向 Mike Bayer 表示感谢。

⁶ 你可能会想,“那listdeleteupdate呢?”然而,在理想的世界中,我们一次修改一个模型对象,删除通常是以软删除的方式处理——即batch.cancel()。最后,更新由工作单元模式处理,你将在第六章中看到。

⁷ 要真正享受 ABCs(尽管它们可能如此),请运行诸如pylintmypy之类的辅助工具。

⁸ 图表灵感来自 Rob Vens 的一篇名为“全局复杂性,本地简单性”的帖子。

第三章:简短插曲:耦合和抽象

原文:3: A Brief Interlude: On Coupling and Abstractions

译者:飞龙

协议:CC BY-NC-SA 4.0

请允许我们在抽象主题上稍作偏离,亲爱的读者。我们已经谈论了抽象很多。例如,存储库模式是对永久存储的抽象。但是什么样的抽象才是好的?我们从抽象中想要什么?它们与测试有什么关系?

提示

本章的代码在 GitHub 的 chapter_03_abstractions 分支中链接

git clone https://github.com/cosmicpython/code.git
git checkout chapter_03_abstractions

本书的一个关键主题,隐藏在花哨的模式中,是我们可以使用简单的抽象来隐藏混乱的细节。当我们写代码时,可以自由地玩耍,或者在一个 kata 中,我们可以自由地玩耍,大幅度地重构。然而,在大规模系统中,我们受到系统其他地方做出的决定的限制。

当我们因为担心破坏组件 B 而无法更改组件 A 时,我们说这些组件已经耦合。在本地,耦合是一件好事:这表明我们的代码正在一起工作,每个组件都在支持其他组件,所有这些组件都像手表的齿轮一样完美地配合在一起。在行话中,我们说当耦合元素之间存在高内聚时,这是有效的。

总体上,耦合是一种麻烦事:它增加了更改代码的风险和成本,有时甚至到了我们感到无法进行任何更改的地步。这就是“泥球”模式的问题:随着应用程序的增长,如果我们无法阻止没有内聚力的元素之间的耦合,那么耦合会超线性地增加,直到我们无法有效地更改我们的系统。

我们可以通过将细节抽象化(图 3-1)来减少系统内的耦合程度。

apwp 0301

图 3-1:耦合很多
[ditaa,apwp_0301]
+--------+      +--------+
| System | ---> | System |
|   A    | ---> |   B    |
|        | ---> |        |
|        | ---> |        |
|        | ---> |        |
+--------+      +--------+

apwp 0302

图 3-2:耦合减少
[ditaa,apwp_0302]
+--------+                           +--------+
| System |      /-------------\      | System |
|   A    | ---> |             | ---> |   B    |
|        | ---> | Abstraction | ---> |        |
|        |      |             | ---> |        |
|        |      \-------------/      |        |
+--------+                           +--------+

在这两个图中,我们有一对子系统,其中一个依赖于另一个。在图 3-1 中,两者之间存在高度耦合;箭头的数量表示两者之间有很多种依赖关系。如果我们需要更改系统 B,很可能这种更改会传播到系统 A。

然而,在图 3-2 中,我们通过插入一个新的、更简单的抽象来减少耦合程度。因为它更简单,系统 A 对抽象的依赖种类更少。抽象用于保护我们免受变化的影响,它隐藏了系统 B 的复杂细节——我们可以在不更改左侧箭头的情况下更改右侧的箭头。

抽象状态辅助测试

让我们看一个例子。想象一下,我们想要编写代码来同步两个文件目录,我们将其称为目标

  • 如果源中存在文件,但目标中不存在文件,则复制文件。

  • 如果源文件存在,但与目标文件名不同,则将目标文件重命名为匹配的文件。

  • 如果目标中存在文件,但源中不存在文件,则删除它。

我们的第一个和第三个要求都很简单:我们只需比较两个路径列表。但是我们的第二个要求就比较棘手了。为了检测重命名,我们将不得不检查文件的内容。为此,我们可以使用 MD5 或 SHA-1 等哈希函数。从文件生成 SHA-1 哈希的代码非常简单:

对文件进行哈希(sync.py

BLOCKSIZE = 65536

def hash_file(path):
    hasher = hashlib.sha1()
    with path.open("rb") as file:
        buf = file.read(BLOCKSIZE)
        while buf:
            hasher.update(buf)
            buf = file.read(BLOCKSIZE)
    return hasher.hexdigest()

现在我们需要编写决定要做什么的部分——商业逻辑,如果你愿意的话。

当我们必须从第一原则解决问题时,通常我们会尝试编写一个简单的实现,然后朝着更好的设计进行重构。我们将在整本书中使用这种方法,因为这是我们在现实世界中编写代码的方式:从问题的最小部分开始解决方案,然后迭代地使解决方案更加丰富和更好设计。

我们的第一个 hackish 方法看起来是这样的:

基本同步算法(sync.py

import hashlib
import os
import shutil
from pathlib import Path

def sync(source, dest):
    # Walk the source folder and build a dict of filenames and their hashes
    source_hashes = {}
    for folder, _, files in os.walk(source):
        for fn in files:
            source_hashes[hash_file(Path(folder) / fn)] = fn

    seen = set()  # Keep track of the files we've found in the target

    # Walk the target folder and get the filenames and hashes
    for folder, _, files in os.walk(dest):
        for fn in files:
            dest_path = Path(folder) / fn
            dest_hash = hash_file(dest_path)
            seen.add(dest_hash)

            # if there's a file in target that's not in source, delete it
            if dest_hash not in source_hashes:
                dest_path.remove()

            # if there's a file in target that has a different path in source,
            # move it to the correct path
            elif dest_hash in source_hashes and fn != source_hashes[dest_hash]:
                shutil.move(dest_path, Path(folder) / source_hashes[dest_hash])

    # for every file that appears in source but not target, copy the file to
    # the target
    for src_hash, fn in source_hashes.items():
        if src_hash not in seen:
            shutil.copy(Path(source) / fn, Path(dest) / fn)

太棒了!我们有一些代码,它看起来不错,但在我们在硬盘上运行它之前,也许我们应该测试一下。我们如何测试这种类型的东西?

一些端到端测试(test_sync.py

def test_when_a_file_exists_in_the_source_but_not_the_destination():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "I am a very useful file"
        (Path(source) / 'my-file').write_text(content)

        sync(source, dest)

        expected_path = Path(dest) /  'my-file'
        assert expected_path.exists()
        assert expected_path.read_text() == content

    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)

def test_when_a_file_has_been_renamed_in_the_source():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "I am a file that was renamed"
        source_path = Path(source) / 'source-filename'
        old_dest_path = Path(dest) / 'dest-filename'
        expected_dest_path = Path(dest) / 'source-filename'
        source_path.write_text(content)
        old_dest_path.write_text(content)

        sync(source, dest)

        assert old_dest_path.exists() is False
        assert expected_dest_path.read_text() == content

    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)

哇哦,为了两个简单的情况需要做很多设置!问题在于我们的领域逻辑,“找出两个目录之间的差异”,与 I/O 代码紧密耦合。我们无法在不调用pathlibshutilhashlib模块的情况下运行我们的差异算法。

问题是,即使在当前的要求下,我们还没有编写足够的测试:当前的实现存在一些错误(例如shutil.move()是错误的)。获得良好的覆盖率并揭示这些错误意味着编写更多的测试,但如果它们都像前面的测试一样难以管理,那将会变得非常痛苦。

除此之外,我们的代码并不是很可扩展。想象一下,试图实现一个--dry-run标志,让我们的代码只是打印出它将要做的事情,而不是实际去做。或者,如果我们想要同步到远程服务器或云存储呢?

我们的高级代码与低级细节耦合在一起,这让生活变得困难。随着我们考虑的场景变得更加复杂,我们的测试将变得更加难以管理。我们肯定可以重构这些测试(例如,一些清理工作可以放入 pytest fixtures 中),但只要我们进行文件系统操作,它们就会保持缓慢,并且难以阅读和编写。

选择正确的抽象

我们能做些什么来重写我们的代码,使其更具可测试性?

首先,我们需要考虑我们的代码需要文件系统提供什么。通过阅读代码,我们可以看到三个不同的事情正在发生。我们可以将这些视为代码具有的三个不同责任

  1. 我们通过使用os.walk来询问文件系统,并为一系列路径确定哈希值。这在源和目标情况下都是相似的。

  2. 我们决定文件是新的、重命名的还是多余的。

  3. 我们复制、移动或删除文件以匹配源。

记住,我们希望为这些责任中的每一个找到简化的抽象。这将让我们隐藏混乱的细节,以便我们可以专注于有趣的逻辑。²

注意

在本章中,我们通过识别需要完成的各个任务并将每个任务分配给一个明确定义的执行者,按照类似于“duckduckgo”示例的方式,将一些混乱的代码重构为更具可测试性的结构。

对于步骤 1 和步骤 2,我们已经直观地开始使用一个抽象,即哈希到路径的字典。您可能已经在想,“为什么不为目标文件夹构建一个字典,然后我们只需比较两个字典?”这似乎是一种很好的抽象文件系统当前状态的方式:

source_files = {'hash1': 'path1', 'hash2': 'path2'}
dest_files = {'hash1': 'path1', 'hash2': 'pathX'}

那么,我们如何从第 2 步移动到第 3 步呢?我们如何将实际的移动/复制/删除文件系统交互抽象出来?

我们将在这里应用一个技巧,我们将在本书的后面大规模地使用。我们将想要做什么如何做分开。我们将使我们的程序输出一个看起来像这样的命令列表:

("COPY", "sourcepath", "destpath"),
("MOVE", "old", "new"),

现在我们可以编写测试,只使用两个文件系统字典作为输入,并且我们期望输出表示操作的字符串元组列表。

我们不是说,“给定这个实际的文件系统,当我运行我的函数时,检查发生了什么操作”,而是说,“给定这个文件系统的抽象,会发生什么文件系统操作的抽象?”

我们的测试中简化了输入和输出(test_sync.py

    def test_when_a_file_exists_in_the_source_but_not_the_destination():
        src_hashes = {'hash1': 'fn1'}
        dst_hashes = {}
        expected_actions = [('COPY', '/src/fn1', '/dst/fn1')]
        ...

    def test_when_a_file_has_been_renamed_in_the_source():
        src_hashes = {'hash1': 'fn1'}
        dst_hashes = {'hash1': 'fn2'}
        expected_actions == [('MOVE', '/dst/fn2', '/dst/fn1')]
        ...

实现我们选择的抽象

这一切都很好,但我们实际上如何编写这些新测试,以及如何改变我们的实现使其正常工作?

我们的目标是隔离系统的聪明部分,并且能够彻底测试它,而不需要设置真实的文件系统。我们将创建一个“核心”代码,它不依赖外部状态,然后看看当我们从外部世界输入时它如何响应(这种方法被 Gary Bernhardt 称为Functional Core, Imperative Shell,或 FCIS)。

让我们从将代码分离出有状态的部分和逻辑开始。

我们的顶层函数几乎不包含任何逻辑;它只是一系列命令式的步骤:收集输入,调用我们的逻辑,应用输出:

将我们的代码分成三部分(sync.py

def sync(source, dest):
    # imperative shell step 1, gather inputs
    source_hashes = read_paths_and_hashes(source)  #(1)
    dest_hashes = read_paths_and_hashes(dest)  #(1)

    # step 2: call functional core
    actions = determine_actions(source_hashes, dest_hashes, source, dest)  #(2)

    # imperative shell step 3, apply outputs
    for action, *paths in actions:
        if action == "COPY":
            shutil.copyfile(*paths)
        if action == "MOVE":
            shutil.move(*paths)
        if action == "DELETE":
            os.remove(paths[0])

这是我们分解的第一个函数,read_paths_and_hashes(),它隔离了我们应用程序的 I/O 部分。

这是我们切出功能核心,业务逻辑的地方。

构建路径和哈希字典的代码现在非常容易编写:

只执行 I/O 的函数(sync.py

def read_paths_and_hashes(root):
    hashes = {}
    for folder, _, files in os.walk(root):
        for fn in files:
            hashes[hash_file(Path(folder) / fn)] = fn
    return hashes

determine_actions()函数将是我们业务逻辑的核心,它说:“给定这两组哈希和文件名,我们应该复制/移动/删除什么?”。它接受简单的数据结构并返回简单的数据结构:

只执行业务逻辑的函数(sync.py

def determine_actions(src_hashes, dst_hashes, src_folder, dst_folder):
    for sha, filename in src_hashes.items():
        if sha not in dst_hashes:
            sourcepath = Path(src_folder) / filename
            destpath = Path(dst_folder) / filename
            yield 'copy', sourcepath, destpath

        elif dst_hashes[sha] != filename:
            olddestpath = Path(dst_folder) / dst_hashes[sha]
            newdestpath = Path(dst_folder) / filename
            yield 'move', olddestpath, newdestpath

    for sha, filename in dst_hashes.items():
        if sha not in src_hashes:
            yield 'delete', dst_folder / filename

我们的测试现在直接作用于determine_actions()函数:

更好看的测试(test_sync.py

def test_when_a_file_exists_in_the_source_but_not_the_destination():
    src_hashes = {'hash1': 'fn1'}
    dst_hashes = {}
    actions = determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst'))
    assert list(actions) == [('copy', Path('/src/fn1'), Path('/dst/fn1'))]
...

def test_when_a_file_has_been_renamed_in_the_source():
    src_hashes = {'hash1': 'fn1'}
    dst_hashes = {'hash1': 'fn2'}
    actions = determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst'))
    assert list(actions) == [('move', Path('/dst/fn2'), Path('/dst/fn1'))]

因为我们已经将程序的逻辑——识别更改的代码——与 I/O 的低级细节分离开来,我们可以轻松测试我们代码的核心部分。

通过这种方法,我们已经从测试我们的主入口函数sync(),转而测试更低级的函数determine_actions()。您可能会认为这没问题,因为sync()现在非常简单。或者您可能决定保留一些集成/验收测试来测试sync()。但还有另一种选择,那就是修改sync()函数,使其既可以进行单元测试可以进行端到端测试;这是 Bob 称之为边缘到边缘测试的方法。

使用伪造和依赖注入进行边缘到边缘测试

当我们开始编写新系统时,我们经常首先关注核心逻辑,通过直接的单元测试驱动它。不过,某个时候,我们希望一起测试系统的更大块。

我们可以回到端到端测试,但这些仍然像以前一样难以编写和维护。相反,我们经常编写调用整个系统但伪造 I/O 的测试,有点边缘到边缘*:

显式依赖项(sync.py

def sync(source, dest, filesystem=FileSystem()):  #(1)
    source_hashes = filesystem.read(source)  #(2)
    dest_hashes = filesystem.read(dest)  #(2)

    for sha, filename in source_hashes.items():
        if sha not in dest_hashes:
            sourcepath = Path(source) / filename
            destpath = Path(dest) / filename
            filesystem.copy(sourcepath, destpath)  #(3)

        elif dest_hashes[sha] != filename:
            olddestpath = Path(dest) / dest_hashes[sha]
            newdestpath = Path(dest) / filename
            filesystem.move(olddestpath, newdestpath)  #(3)

    for sha, filename in dest_hashes.items():
        if sha not in source_hashes:
            filesystem.delete(dest / filename)  #(3)

我们的顶层函数现在公开了两个新的依赖项,一个reader和一个filesystem

我们调用reader来生成我们的文件字典。

我们调用filesystem来应用我们检测到的更改。

提示

虽然我们正在使用依赖注入,但没有必要定义抽象基类或任何明确的接口。在本书中,我们经常展示 ABCs,因为我们希望它们能帮助您理解抽象是什么,但它们并不是必需的。Python 的动态特性意味着我们总是可以依赖鸭子类型。

使用 DI 的测试

class FakeFilesystem:
    def __init__(self, path_hashes):  #(1)
        self.path_hashes = path_hashes
        self.actions = []  #(2)

    def read(self, path):
        return self.path_hashes[path]  #(1)

    def copy(self, source, dest):
        self.actions.append(('COPY', source, dest))  #(2)

    def move(self, source, dest):
        self.actions.append(('MOVE', source, dest))  #(2)

    def delete(self, dest):
        self.actions.append(('DELETE', dest))  #(2)

Bob 非常喜欢使用列表来构建简单的测试替身,尽管他的同事们会生气。这意味着我们可以编写像assert *foo* not in database这样的测试。

我们FakeFileSystem中的每个方法都只是将一些内容附加到列表中,以便我们以后可以检查它。这是间谍对象的一个例子。

这种方法的优势在于我们的测试作用于与我们的生产代码使用的完全相同的函数。缺点是我们必须明确地表达我们的有状态组件并传递它们。Ruby on Rails 的创始人 David Heinemeier Hansson 曾经著名地将这描述为“测试诱导的设计损害”。

无论哪种情况,我们现在都可以着手修复实现中的所有错误;为所有边缘情况列举测试现在变得更容易了。

为什么不只是补丁?

在这一点上,你可能会挠头想,“为什么你不只是使用mock.patch,省点力气呢?”

我们在本书和我们的生产代码中都避免使用模拟。我们不会卷入圣战,但我们的直觉是,模拟框架,特别是 monkeypatching,是一种代码异味。

相反,我们喜欢清楚地确定代码库中的责任,并将这些责任分离成小而专注的对象,这些对象易于用测试替身替换。

注意

你可以在第八章中看到一个例子,我们在那里使用mock.patch()来替换一个发送电子邮件的模块,但最终我们在第十三章中用显式的依赖注入替换了它。

我们对我们的偏好有三个密切相关的原因:

  • 消除你正在使用的依赖关系,可以对代码进行单元测试,但这对改进设计没有任何帮助。使用mock.patch不会让你的代码与--dry-run标志一起工作,也不会帮助你针对 FTP 服务器运行。为此,你需要引入抽象。

  • 使用模拟测试的测试倾向于更多地与代码库的实现细节耦合。这是因为模拟测试验证了事物之间的交互:我们是否用正确的参数调用了shutil.copy?根据我们的经验,代码和测试之间的这种耦合倾向于使测试更加脆弱。

  • 过度使用模拟会导致复杂的测试套件,无法解释代码。

注意

为可测试性而设计实际上意味着为可扩展性而设计。我们为了更清晰的设计而进行了一些复杂性的折衷,这样可以容纳新的用例。

我们首先将 TDD 视为一种设计实践,其次是一种测试实践。测试作为我们设计选择的记录,并在我们长时间离开代码后为我们解释系统的作用。

使用太多模拟的测试会被隐藏在设置代码中,这些设置代码隐藏了我们关心的故事。

Steve Freeman 在他的演讲“测试驱动开发”中有一个很好的过度模拟测试的例子。你还应该看看这个 PyCon 演讲,“Mocking and Patching Pitfalls”,由我们尊敬的技术审阅人员 Ed Jung 进行,其中也涉及了模拟和其替代方案。还有,我们推荐的演讲,不要错过 Brandon Rhodes 关于“提升你的 I/O”的讲解,他用另一个简单的例子很好地涵盖了我们正在讨论的问题。

提示

在本章中,我们花了很多时间用单元测试替换端到端测试。这并不意味着我们认为你永远不应该使用端到端测试!在本书中,我们展示了一些技术,让你尽可能地获得一个体面的测试金字塔,并且尽可能少地需要端到端测试来获得信心。继续阅读“总结:不同类型测试的经验法则”以获取更多细节。

总结

我们将在本书中一再看到这个想法:通过简化业务逻辑和混乱的 I/O 之间的接口,我们可以使我们的系统更容易测试和维护。找到合适的抽象是棘手的,但以下是一些启发和问题,你可以问问自己:

  • 我可以选择一个熟悉的 Python 数据结构来表示混乱系统的状态,然后尝试想象一个可以返回该状态的单个函数吗?

  • 我在哪里可以在我的系统中划出一条线,在哪里可以开辟一个接缝来放置这个抽象?

  • 将事物划分为具有不同责任的组件的合理方式是什么?我可以将隐含的概念变得明确吗?

  • 有哪些依赖关系,什么是核心业务逻辑?

练习使不完美减少!现在回到我们的常规编程…

¹ 代码卡塔是一个小的、独立的编程挑战,通常用于练习 TDD。请参阅 Peter Provost 的《Kata—The Only Way to Learn TDD》。

² 如果你习惯以接口的方式思考,那么这就是我们在这里尝试定义的内容。

³ 这并不是说我们认为伦敦学派的人是错的。一些非常聪明的人就是这样工作的。只是我们不习惯这种方式。

第四章:我们的第一个用例:Flask API 和服务层

原文:4: Our First Use Case: Flask API and Service Layer

译者:飞龙

协议:CC BY-NC-SA 4.0

回到我们的分配项目!图 4-1 显示了我们在第二章结束时达到的点,该章节涵盖了存储库模式。

apwp 0401

图 4-1:之前:我们通过与存储库和领域模型交谈来驱动我们的应用程序

在本章中,我们将讨论编排逻辑、业务逻辑和接口代码之间的区别,并介绍服务层模式来处理编排我们的工作流程并定义我们系统的用例。

我们还将讨论测试:通过将服务层与我们对数据库的存储库抽象结合起来,我们能够编写快速测试,不仅测试我们的领域模型,还测试整个用例的工作流程。

图 4-2 显示我们的目标:我们将添加一个 Flask API,它将与服务层交互,服务层将作为我们领域模型的入口。因为我们的服务层依赖于AbstractRepository,我们可以使用FakeRepository对其进行单元测试,但在生产代码中使用SqlAlchemyRepository

apwp 0402

图 4-2:服务层将成为我们应用程序的主要入口

在我们的图表中,我们使用的约定是用粗体文本/线条(如果您正在阅读数字版本,则为黄色/橙色)突出显示新组件。

提示

本章的代码位于 GitHub 上的 chapter_04_service_layer 分支中(https://oreil.ly/TBRuy):

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_04_service_layer
# or to code along, checkout Chapter 2:
git checkout chapter_02_repository

将我们的应用程序连接到现实世界

像任何一个优秀的敏捷团队一样,我们正在努力尝试推出一个 MVP 并让用户开始收集反馈意见。我们已经拥有了我们的领域模型的核心和我们需要分配订单的领域服务,以及永久存储的存储库接口。

让我们尽快将所有的部分组合在一起,然后重构以实现更清晰的架构。这是我们的计划:

  1. 使用 Flask 在我们的allocate领域服务前面放置一个 API 端点。连接数据库会话和我们的存储库。使用端到端测试和一些快速而肮脏的 SQL 来准备测试数据进行测试。

  2. 重构出一个可以作为抽象来捕捉用例的服务层,并将其放置在 Flask 和我们的领域模型之间。构建一些服务层测试,并展示它们如何使用FakeRepository

  3. 尝试使用不同类型的参数来调用我们的服务层函数;显示使用原始数据类型允许服务层的客户端(我们的测试和我们的 Flask API)与模型层解耦。

第一个端到端测试

没有人对什么算作端到端(E2E)测试与功能测试、验收测试、集成测试、单元测试之间的术语辩论感兴趣。不同的项目需要不同的测试组合,我们已经看到完全成功的项目将事情分成“快速测试”和“慢速测试”。

目前,我们希望编写一个或两个测试,这些测试将运行一个“真实”的 API 端点(使用 HTTP)并与真实数据库交互。让我们称它们为端到端测试,因为这是最直观的名称之一。

以下是第一次尝试:

第一个 API 测试(test_api.py

@pytest.mark.usefixtures("restart_api")
def test_api_returns_allocation(add_stock):
    sku, othersku = random_sku(), random_sku("other")  #(1)
    earlybatch = random_batchref(1)
    laterbatch = random_batchref(2)
    otherbatch = random_batchref(3)
    add_stock(  #(2)
        [
            (laterbatch, sku, 100, "2011-01-02"),
            (earlybatch, sku, 100, "2011-01-01"),
            (otherbatch, othersku, 100, None),
        ]
    )
    data = {"orderid": random_orderid(), "sku": sku, "qty": 3}
    url = config.get_api_url()  #(3)

    r = requests.post(f"{url}/allocate", json=data)

    assert r.status_code == 201
    assert r.json()["batchref"] == earlybatch

random_sku()random_batchref()等都是使用uuid模块生成随机字符的小助手函数。因为我们现在正在运行实际的数据库,这是防止各种测试和运行相互干扰的一种方法。

add_stock是一个辅助装置,只是隐藏了使用 SQL 手动插入行的细节。我们将在本章后面展示更好的方法。

config.py是一个模块,我们在其中保存配置信息。

每个人以不同的方式解决这些问题,但您需要一种方法来启动 Flask,可能是在容器中,并与 Postgres 数据库进行通信。如果您想了解我们是如何做到的,请查看附录 B。

直接实现

以最明显的方式实现,您可能会得到这样的东西:

Flask 应用程序的第一次尝试(flask_app.py

from flask import Flask, jsonify, request
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

import config
import model
import orm
import repository

orm.start_mappers()
get_session = sessionmaker(bind=create_engine(config.get_postgres_uri()))
app = Flask(__name__)

@app.route("/allocate", methods=['POST'])
def allocate_endpoint():
    session = get_session()
    batches = repository.SqlAlchemyRepository(session).list()
    line = model.OrderLine(
        request.json['orderid'],
        request.json['sku'],
        request.json['qty'],
    )

    batchref = model.allocate(line, batches)

    return jsonify({'batchref': batchref}), 201

到目前为止,一切都很好。你可能会认为,“不需要太多你们的‘架构宇航员’废话,鲍勃和哈里。”

但等一下——没有提交。我们实际上没有将我们的分配保存到数据库中。现在我们需要第二个测试,要么检查数据库状态之后(不太黑盒),要么检查我们是否无法分配第二行,如果第一行应该已经耗尽了批次:

测试分配被持久化(test_api.py

@pytest.mark.usefixtures('restart_api')
def test_allocations_are_persisted(add_stock):
    sku = random_sku()
    batch1, batch2 = random_batchref(1), random_batchref(2)
    order1, order2 = random_orderid(1), random_orderid(2)
    add_stock([
        (batch1, sku, 10, '2011-01-01'),
        (batch2, sku, 10, '2011-01-02'),
    ])
    line1 = {'orderid': order1, 'sku': sku, 'qty': 10}
    line2 = {'orderid': order2, 'sku': sku, 'qty': 10}
    url = config.get_api_url()

    # first order uses up all stock in batch 1
    r = requests.post(f'{url}/allocate', json=line1)
    assert r.status_code == 201
    assert r.json()['batchref'] == batch1

    # second order should go to batch 2
    r = requests.post(f'{url}/allocate', json=line2)
    assert r.status_code == 201
    assert r.json()['batchref'] == batch2

不是那么可爱,但这将迫使我们添加提交。

需要数据库检查的错误条件

不过,如果我们继续这样做,事情会变得越来越丑陋。

假设我们想要添加一些错误处理。如果领域引发了一个错误,对于一个缺货的 SKU 呢?或者甚至不存在的 SKU 呢?这不是领域甚至知道的事情,也不应该是。这更像是我们应该在调用领域服务之前在数据库层实现的一个理智检查。

现在我们正在看另外两个端到端测试:

在 E2E 层进行更多的测试(test_api.py

@pytest.mark.usefixtures("restart_api")
def test_400_message_for_out_of_stock(add_stock):  #(1)
    sku, small_batch, large_order = random_sku(), random_batchref(), random_orderid()
    add_stock(
        [(small_batch, sku, 10, "2011-01-01"),]
    )
    data = {"orderid": large_order, "sku": sku, "qty": 20}
    url = config.get_api_url()
    r = requests.post(f"{url}/allocate", json=data)
    assert r.status_code == 400
    assert r.json()["message"] == f"Out of stock for sku {sku}"


@pytest.mark.usefixtures("restart_api")
def test_400_message_for_invalid_sku():  #(2)
    unknown_sku, orderid = random_sku(), random_orderid()
    data = {"orderid": orderid, "sku": unknown_sku, "qty": 20}
    url = config.get_api_url()
    r = requests.post(f"{url}/allocate", json=data)
    assert r.status_code == 400
    assert r.json()["message"] == f"Invalid sku {unknown_sku}"

在第一个测试中,我们试图分配比我们库存中有的单位更多。

在第二个测试中,SKU 不存在(因为我们从未调用add_stock),因此在我们的应用程序看来是无效的。

当然,我们也可以在 Flask 应用程序中实现它:

Flask 应用程序开始变得混乱(flask_app.py

def is_valid_sku(sku, batches):
    return sku in {b.sku for b in batches}

@app.route("/allocate", methods=['POST'])
def allocate_endpoint():
    session = get_session()
    batches = repository.SqlAlchemyRepository(session).list()
    line = model.OrderLine(
        request.json['orderid'],
        request.json['sku'],
        request.json['qty'],
    )

    if not is_valid_sku(line.sku, batches):
        return jsonify({'message': f'Invalid sku {line.sku}'}), 400

    try:
        batchref = model.allocate(line, batches)
    except model.OutOfStock as e:
        return jsonify({'message': str(e)}), 400

    session.commit()
    return jsonify({'batchref': batchref}), 201

但是我们的 Flask 应用程序开始变得有些笨重。我们的 E2E 测试数量开始失控,很快我们将以倒置的测试金字塔(或者鲍勃喜欢称之为“冰淇淋锥模型”)结束。

引入服务层,并使用 FakeRepository 进行单元测试

如果我们看看我们的 Flask 应用程序在做什么,我们会发现有很多我们可能称之为编排的东西——从我们的存储库中获取东西,根据数据库状态验证我们的输入,处理错误,并在正常情况下进行提交。这些大部分事情与拥有 Web API 端点无关(例如,如果您正在构建 CLI,您会需要它们;请参阅附录 C),它们不是真正需要通过端到端测试来测试的东西。

有时将服务层拆分出来是有意义的,有时被称为编排层用例层

您还记得我们在第三章中准备的FakeRepository吗?

我们的假存储库,一个内存中的批次集合(test_services.py

class FakeRepository(repository.AbstractRepository):

    def __init__(self, batches):
        self._batches = set(batches)

    def add(self, batch):
        self._batches.add(batch)

    def get(self, reference):
        return next(b for b in self._batches if b.reference == reference)

    def list(self):
        return list(self._batches)

这就是它将会派上用场的地方;它让我们可以使用快速的单元测试来测试我们的服务层:

在服务层使用伪造进行单元测试(test_services.py

def test_returns_allocation():
    line = model.OrderLine("o1", "COMPLICATED-LAMP", 10)
    batch = model.Batch("b1", "COMPLICATED-LAMP", 100, eta=None)
    repo = FakeRepository([batch])  #(1)

    result = services.allocate(line, repo, FakeSession())  #(2) (3)
    assert result == "b1"


def test_error_for_invalid_sku():
    line = model.OrderLine("o1", "NONEXISTENTSKU", 10)
    batch = model.Batch("b1", "AREALSKU", 100, eta=None)
    repo = FakeRepository([batch])  #(1)

    with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"):
        services.allocate(line, repo, FakeSession())  #(2) (3)

FakeRepository保存了我们测试中将使用的Batch对象。

我们的服务模块(services.py)将定义一个allocate()服务层函数。它将位于 API 层中的allocate_endpoint()函数和领域模型中的allocate()领域服务函数之间。¹

我们还需要一个FakeSession来伪造数据库会话,如下面的代码片段所示。

一个假的数据库会话(test_services.py

class FakeSession():
    committed = False

    def commit(self):
        self.committed = True

这个假的会话只是一个临时解决方案。我们将摆脱它,并很快让事情变得更好,在第六章。但与此同时,假的.commit()让我们从 E2E 层迁移了第三个测试:

服务层的第二个测试(test_services.py

def test_commits():
    line = model.OrderLine('o1', 'OMINOUS-MIRROR', 10)
    batch = model.Batch('b1', 'OMINOUS-MIRROR', 100, eta=None)
    repo = FakeRepository([batch])
    session = FakeSession()

    services.allocate(line, repo, session)
    assert session.committed is True

典型的服务函数

我们将编写一个类似于以下内容的服务函数:

基本分配服务(services.py

class InvalidSku(Exception):
    pass


def is_valid_sku(sku, batches):
    return sku in {b.sku for b in batches}


def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:
    batches = repo.list()  #(1)
    if not is_valid_sku(line.sku, batches):  #(2)
        raise InvalidSku(f"Invalid sku {line.sku}")
    batchref = model.allocate(line, batches)  #(3)
    session.commit()  #(4)
    return batchref

典型的服务层函数有类似的步骤:

我们从存储库中提取一些对象。

我们对请求针对当前世界的状态进行一些检查或断言。

我们调用一个领域服务。

如果一切顺利,我们保存/更新我们已经改变的状态。

目前,最后一步有点不尽如人意,因为我们的服务层与数据库层紧密耦合。我们将在第六章中通过工作单元模式改进这一点。

但服务层的基本内容都在那里,我们的 Flask 应用现在看起来更加清晰:

Flask 应用委托给服务层(flask_app.py

@app.route("/allocate", methods=["POST"])
def allocate_endpoint():
    session = get_session()  #(1)
    repo = repository.SqlAlchemyRepository(session)  #(1)
    line = model.OrderLine(
        request.json["orderid"], request.json["sku"], request.json["qty"],  #(2)
    )

    try:
        batchref = services.allocate(line, repo, session)  #(2)
    except (model.OutOfStock, services.InvalidSku) as e:
        return {"message": str(e)}, 400  #(3)

    return {"batchref": batchref}, 201  #(3)

我们实例化一个数据库会话和一些存储库对象。

我们从 Web 请求中提取用户的命令,并将其传递给领域服务。

我们返回一些带有适当状态代码的 JSON 响应。

Flask 应用的职责只是标准的网络内容:每个请求的会话管理,解析 POST 参数中的信息,响应状态码和 JSON。所有的编排逻辑都在用例/服务层中,领域逻辑保留在领域中。

最后,我们可以自信地将我们的 E2E 测试简化为只有两个,一个是快乐路径,一个是不快乐路径:

只有快乐和不快乐路径的 E2E 测试(test_api.py

@pytest.mark.usefixtures('restart_api')
def test_happy_path_returns_201_and_allocated_batch(add_stock):
    sku, othersku = random_sku(), random_sku('other')
    earlybatch = random_batchref(1)
    laterbatch = random_batchref(2)
    otherbatch = random_batchref(3)
    add_stock([
        (laterbatch, sku, 100, '2011-01-02'),
        (earlybatch, sku, 100, '2011-01-01'),
        (otherbatch, othersku, 100, None),
    ])
    data = {'orderid': random_orderid(), 'sku': sku, 'qty': 3}
    url = config.get_api_url()
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 201
    assert r.json()['batchref'] == earlybatch

@pytest.mark.usefixtures('restart_api')
def test_unhappy_path_returns_400_and_error_message():
    unknown_sku, orderid = random_sku(), random_orderid()
    data = {'orderid': orderid, 'sku': unknown_sku, 'qty': 20}
    url = config.get_api_url()
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 400
    assert r.json()['message'] == f'Invalid sku {unknown_sku}'

我们成功地将我们的测试分成了两个广泛的类别:关于网络内容的测试,我们实现端到端;关于编排内容的测试,我们可以针对内存中的服务层进行测试。

为什么一切都叫服务?

此时,你们中的一些人可能正在思考领域服务和服务层之间的确切区别是什么。

很抱歉——我们没有选择这些名称,否则我们会有更酷更友好的方式来谈论这些事情。

在本章中,我们使用了两个称为service的东西。第一个是应用服务(我们的服务层)。它的工作是处理来自外部世界的请求,并编排一个操作。我们的意思是服务层通过遵循一系列简单的步骤驱动应用程序:

  • 从数据库获取一些数据

  • 更新领域模型

  • 持久化任何更改

这是系统中每个操作都必须进行的无聊工作,将其与业务逻辑分开有助于保持事物的整洁。

第二种类型的服务是领域服务。这是指属于领域模型但不自然地位于有状态实体或值对象内部的逻辑部分的名称。例如,如果您正在构建一个购物车应用程序,您可能会选择将税收规则构建为领域服务。计算税收是与更新购物车分开的工作,它是模型的重要部分,但似乎不应该为该工作创建一个持久化实体。而是一个无状态的 TaxCalculator 类或calculate_tax函数可以完成这项工作。

将事物放入文件夹中以查看它们的归属

随着我们的应用程序变得更大,我们需要不断整理我们的目录结构。我们项目的布局为我们提供了关于每个文件中可能包含的对象类型的有用提示。

这是我们可以组织事物的一种方式:

一些子文件夹

.
├── config.py
├── domain ① 
│   ├── __init__.py
│   └── model.py
├── service_layer ② 
│   ├── __init__.py
│   └── services.py
├── adapters ③ 
│   ├── __init__.py
│   ├── orm.py
│   └── repository.py
├── entrypoints ④ 
│   ├── __init__.py
│   └── flask_app.py
└── tests
    ├── __init__.py
    ├── conftest.py
    ├── unit
    │   ├── test_allocate.py
    │   ├── test_batches.py
    │   └── test_services.py
    ├── integration
    │   ├── test_orm.py
    │   └── test_repository.py
    └── e2e
        └── test_api.py

让我们为我们的领域模型创建一个文件夹。目前只有一个文件,但对于更复杂的应用程序,您可能会为每个类创建一个文件;您可能会为EntityValueObjectAggregate创建帮助父类,并且您可能会添加一个exceptions.py用于领域层异常,以及如您将在第二部分中看到的commands.pyevents.py

我们将区分服务层。目前只有一个名为services.py的文件用于我们的服务层函数。您可以在这里添加服务层异常,并且正如您将在第五章中看到的那样,我们将添加unit_of_work.py

适配器是对端口和适配器术语的一种称呼。这将填充任何其他关于外部 I/O 的抽象(例如redis_client.py)。严格来说,您可以将这些称为secondary适配器或driven适配器,有时也称为inward-facing适配器。

入口点是我们驱动应用程序的地方。在官方端口和适配器术语中,这些也是适配器,并被称为primarydrivingoutward-facing适配器。

端口呢?您可能还记得,它们是适配器实现的抽象接口。我们倾向于将它们与实现它们的适配器放在同一个文件中。

总结

添加服务层确实为我们带来了很多好处:

  • 我们的 Flask API 端点变得非常轻量且易于编写:它们唯一的责任就是做“网络事务”,比如解析 JSON 并为 happy 或 unhappy 情况生成正确的 HTTP 代码。

  • 我们为我们的领域定义了一个清晰的 API,一组用例或入口点,可以被任何适配器使用,而无需了解我们的领域模型类的任何信息 - 无论是 API、CLI(参见附录 C),还是测试!它们也是我们领域的适配器。

  • 通过使用服务层,我们可以以“高速”编写测试,从而使我们可以自由地以任何我们认为合适的方式重构领域模型。只要我们仍然可以提供相同的用例,我们就可以尝试新的设计,而无需重写大量的测试。

  • 我们的测试金字塔看起来不错 - 我们大部分的测试都是快速的单元测试,只有最少量的 E2E 和集成测试。

DIP 的实际应用

图 4-3 显示了我们服务层的依赖关系:领域模型和AbstractRepository(在端口和适配器术语中称为端口)。

当我们运行测试时,图 4-4 显示了我们如何使用FakeRepository(适配器)来实现抽象依赖。

当我们实际运行我们的应用程序时,我们会在图 4-5 中显示的“真实”依赖项中进行交换。

apwp 0403

图 4-3. 服务层的抽象依赖
[ditaa, apwp_0403]
        +-----------------------------+
        |         Service Layer       |
        +-----------------------------+
           |                   |
           |                   | depends on abstraction
           V                   V
+------------------+     +--------------------+
|   Domain Model   |     | AbstractRepository |
|                  |     |       (Port)       |
+------------------+     +--------------------+

apwp 0404

图 4-4. 测试提供了抽象依赖的实现
[ditaa, apwp_0404]
        +-----------------------------+
        |           Tests             |-------------\
        +-----------------------------+             |
                       |                            |
                       V                            |
        +-----------------------------+             |
        |         Service Layer       |    provides |
        +-----------------------------+             |
           |                     |                  |
           V                     V                  |
+------------------+     +--------------------+     |
|   Domain Model   |     | AbstractRepository |     |
+------------------+     +--------------------+     |
                                    ^               |
                         implements |               |
                                    |               |
                         +----------------------+   |
                         |    FakeRepository    |<--/
                         |      (in-memory)     |
                         +----------------------+

apwp 0405

图 4-5. 运行时的依赖关系
[ditaa, apwp_0405]
       +--------------------------------+
       | Flask API (Presentation Layer) |-----------\
       +--------------------------------+           |
                       |                            |
                       V                            |
        +-----------------------------+             |
        |         Service Layer       |             |
        +-----------------------------+             |
           |                     |                  |
           V                     V                  |
+------------------+     +--------------------+     |
|   Domain Model   |     | AbstractRepository |     |
+------------------+     +--------------------+     |
              ^                     ^               |
              |                     |               |
       gets   |          +----------------------+   |
       model  |          | SqlAlchemyRepository |<--/
   definitions|          +----------------------+
       from   |                | uses
              |                V
           +-----------------------+
           |          ORM          |
           | (another abstraction) |
           +-----------------------+
                       |
                       | talks to
                       V
           +------------------------+
           |       Database         |
           +------------------------+

太棒了。

让我们暂停一下,看看表 4-1,在其中我们考虑是否要有一个服务层的利弊。

表 4-1. 服务层:权衡

优点 缺点
我们有一个捕获应用程序所有用例的单一位置。 如果你的应用程序纯粹是一个 Web 应用程序,你的控制器/视图函数可以是捕获所有用例的唯一位置。
我们将聪明的领域逻辑放在了一个 API 后面,这使我们可以自由地进行重构。 这是另一种抽象层。
我们已经清晰地将“与 HTTP 通信的东西”与“与分配通信的东西”分开。 将太多的逻辑放入服务层可能会导致贫血领域反模式。最好在发现编排逻辑渗入控制器后引入这一层。
与存储库模式和FakeRepository结合使用时,我们有了一种很好的方法来以比领域层更高的级别编写测试;我们可以在不需要使用集成测试的情况下测试我们的工作流程(详见第五章以获取更多关于此的阐述)。 通过简单地将逻辑从控制器推到模型层,你可以获得丰富领域模型带来的许多好处,而无需在中间添加额外的层(即“胖模型,瘦控制器”)。

但仍然有一些需要整理的不便之处:

  • 服务层仍然与领域紧密耦合,因为其 API 是以OrderLine对象表示的。在第五章中,我们将解决这个问题,并讨论服务层如何实现更高效的 TDD。

  • 服务层与session对象紧密耦合。在第六章中,我们将介绍另一种与存储库和服务层模式紧密配合的模式,即工作单元模式,一切都会非常美好。你会看到的!

¹ 服务层服务和领域服务的名称确实非常相似。我们稍后会解决这个问题,详见“为什么一切都被称为服务?”。

第五章:高档和低档的 TDD

原文:5: TDD in High Gear and Low Gear

译者:飞龙

协议:CC BY-NC-SA 4.0

我们引入了服务层来捕获我们从工作应用程序中需要的一些额外的编排责任。服务层帮助我们清晰地定义我们的用例以及每个用例的工作流程:我们需要从我们的存储库中获取什么,我们应该进行什么预检和当前状态验证,以及我们最终保存了什么。

但目前,我们的许多单元测试是在更低的级别上操作,直接作用于模型。在本章中,我们将讨论将这些测试提升到服务层级别涉及的权衡以及一些更一般的测试准则。

我们的测试金字塔看起来怎么样?

让我们看看将这一举措转向使用服务层及其自己的服务层测试对我们的测试金字塔有何影响:

测试类型计数

$ grep -c test_ test_*.py
tests/unit/test_allocate.py:4
tests/unit/test_batches.py:8
tests/unit/test_services.py:3

tests/integration/test_orm.py:6
tests/integration/test_repository.py:2

tests/e2e/test_api.py:2

不错!我们有 15 个单元测试,8 个集成测试,只有 2 个端到端测试。这已经是一个看起来很健康的测试金字塔了。

领域层测试应该移动到服务层吗?

让我们看看如果我们再进一步会发生什么。由于我们可以针对服务层测试我们的软件,我们实际上不再需要对领域模型进行测试。相反,我们可以将第一章中的所有领域级测试重写为服务层的术语:

在服务层重写领域测试(tests/unit/test_services.py

# domain-layer test:
def test_prefers_current_stock_batches_to_shipments():
    in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
    shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
    line = OrderLine("oref", "RETRO-CLOCK", 10)

    allocate(line, [in_stock_batch, shipment_batch])

    assert in_stock_batch.available_quantity == 90
    assert shipment_batch.available_quantity == 100

# service-layer test:
def test_prefers_warehouse_batches_to_shipments():
    in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
    shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
    repo = FakeRepository([in_stock_batch, shipment_batch])
    session = FakeSession()

    line = OrderLine('oref', "RETRO-CLOCK", 10)

    services.allocate(line, repo, session)

    assert in_stock_batch.available_quantity == 90
    assert shipment_batch.available_quantity == 100

为什么我们要这样做呢?

测试应该帮助我们无畏地改变我们的系统,但通常我们看到团队对其领域模型编写了太多测试。当他们来改变他们的代码库时,这会导致问题,并发现他们需要更新数十甚至数百个单元测试。

如果停下来思考自动化测试的目的,这是有道理的。我们使用测试来强制系统的某个属性在我们工作时不会改变。我们使用测试来检查 API 是否继续返回 200,数据库会话是否继续提交,以及订单是否仍在分配。

如果我们意外更改了其中一个行为,我们的测试将会失败。另一方面,如果我们想要更改代码的设计,任何直接依赖于该代码的测试也将失败。

随着我们深入了解本书,您将看到服务层如何形成我们系统的 API,我们可以以多种方式驱动它。针对此 API 进行测试可以减少我们在重构领域模型时需要更改的代码量。如果我们限制自己只针对服务层进行测试,我们将没有任何直接与模型对象上的“私有”方法或属性交互的测试,这使我们更自由地对其进行重构。

提示

我们在测试中放入的每一行代码都像是一团胶水,将系统保持在特定的形状中。我们拥有的低级别测试越多,改变事物就会越困难。

关于决定编写何种测试

您可能会问自己,“那我应该重写所有的单元测试吗?针对领域模型编写测试是错误的吗?” 要回答这些问题,重要的是要理解耦合和设计反馈之间的权衡(见图 5-1)。

apwp 0501

图 5-1:测试谱
[ditaa, apwp_0501]
| Low feedback                                                   High feedback |
| Low barrier to change                                 High barrier to change |
| High system coverage                                        Focused coverage |
|                                                                              |
| <---------                                                       ----------> |
|                                                                              |
| API Tests                  Service-Layer Tests                  Domain Tests |

极限编程(XP)敦促我们“倾听代码”。当我们编写测试时,我们可能会发现代码很难使用或注意到代码味道。这是我们进行重构并重新考虑设计的触发器。

然而,只有当我们与目标代码密切合作时才能获得这种反馈。针对 HTTP API 的测试对我们的对象的细粒度设计毫无帮助,因为它处于更高的抽象级别。

另一方面,我们可以重写整个应用程序,只要我们不更改 URL 或请求格式,我们的 HTTP 测试就会继续通过。这使我们有信心进行大规模的更改,比如更改数据库架构,不会破坏我们的代码。

在另一端,我们在第一章中编写的测试帮助我们充分了解我们需要的对象。测试引导我们设计出一个合理的、符合领域语言的设计。当我们的测试以领域语言阅读时,我们感到我们的代码与我们对问题解决的直觉相匹配。

因为测试是用领域语言编写的,它们充当我们模型的活文档。新团队成员可以阅读这些测试,快速了解系统的工作原理以及核心概念的相互关系。

我们经常通过在这个级别编写测试来“勾勒”新的行为,以查看代码可能的外观。然而,当我们想要改进代码的设计时,我们将需要替换或删除这些测试,因为它们与特定实现紧密耦合。

高档和低档

大多数情况下,当我们添加新功能或修复错误时,我们不需要对领域模型进行广泛的更改。在这些情况下,我们更喜欢针对服务编写测试,因为耦合度较低,覆盖范围较高。

例如,当编写add_stock函数或cancel_order功能时,我们可以通过针对服务层编写测试来更快地进行工作,并减少耦合。

当开始一个新项目或遇到一个特别棘手的问题时,我们会回到对领域模型编写测试,这样我们就能更好地得到反馈和我们意图的可执行文档。

我们使用的比喻是换挡。在开始旅程时,自行车需要处于低档位,以克服惯性。一旦我们开始跑,我们可以通过换到高档位来更快、更有效地行驶;但如果我们突然遇到陡峭的山坡或被危险迫使减速,我们再次降到低档位,直到我们可以再次加速。

将服务层测试与领域完全解耦

我们在服务层测试中仍然直接依赖于领域,因为我们使用领域对象来设置我们的测试数据并调用我们的服务层函数。

为了使服务层与领域完全解耦,我们需要重写其 API,以基本类型的形式工作。

我们的服务层目前接受一个OrderLine领域对象:

之前:allocate 接受一个领域对象(service_layer/services.py

def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:

如果它的参数都是基本类型,它会是什么样子?

之后:allocate 接受字符串和整数(service_layer/services.py

def allocate(
        orderid: str, sku: str, qty: int, repo: AbstractRepository, session
) -> str:

我们也用这些术语重写了测试:

测试现在在函数调用中使用基本类型(tests/unit/test_services.py

def test_returns_allocation():
    batch = model.Batch("batch1", "COMPLICATED-LAMP", 100, eta=None)
    repo = FakeRepository([batch])

    result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession())
    assert result == "batch1"

但是我们的测试仍然依赖于领域,因为我们仍然手动实例化Batch对象。因此,如果有一天我们决定大规模重构我们的Batch模型的工作方式,我们将不得不更改一堆测试。

缓解:将所有领域依赖项保留在固定装置函数中

我们至少可以将其抽象为测试中的一个辅助函数或固定装置。以下是一种你可以做到这一点的方式,即在FakeRepository上添加一个工厂函数:

固定装置的工厂函数是一种可能性(tests/unit/test_services.py

class FakeRepository(set):

    @staticmethod
    def for_batch(ref, sku, qty, eta=None):
        return FakeRepository([
            model.Batch(ref, sku, qty, eta),
        ])

    ...

def test_returns_allocation():
    repo = FakeRepository.for_batch("batch1", "COMPLICATED-LAMP", 100, eta=None)
    result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession())
    assert result == "batch1"

至少这将把我们所有测试对领域的依赖放在一个地方。

添加一个缺失的服务

不过,我们可以再进一步。如果我们有一个添加库存的服务,我们可以使用它,并使我们的服务层测试完全按照服务层的官方用例来表达,消除对领域的所有依赖:

测试新的 add_batch 服务(tests/unit/test_services.py

def test_add_batch():
    repo, session = FakeRepository([]), FakeSession()
    services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session)
    assert repo.get("b1") is not None
    assert session.committed
提示

一般来说,如果你发现自己需要在服务层测试中直接进行领域层操作,这可能表明你的服务层是不完整的。

而实现只是两行代码:

为 add_batch 添加一个新服务(service_layer/services.py

def add_batch(
        ref: str, sku: str, qty: int, eta: Optional[date],
        repo: AbstractRepository, session,
):
    repo.add(model.Batch(ref, sku, qty, eta))
    session.commit()

def allocate(
        orderid: str, sku: str, qty: int, repo: AbstractRepository, session
) -> str:
    ...
注意

你应该仅仅因为它有助于从你的测试中移除依赖而写一个新的服务吗?可能不。但在这种情况下,我们几乎肯定会在某一天需要一个add_batch服务。

现在我们可以纯粹地用服务本身来重写所有我们的服务层测试,只使用原语,而不依赖于模型:

服务测试现在只使用服务(tests/unit/test_services.py

def test_allocate_returns_allocation():
    repo, session = FakeRepository([]), FakeSession()
    services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, repo, session)
    result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, session)
    assert result == "batch1"

def test_allocate_errors_for_invalid_sku():
    repo, session = FakeRepository([]), FakeSession()
    services.add_batch("b1", "AREALSKU", 100, None, repo, session)

    with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"):
        services.allocate("o1", "NONEXISTENTSKU", 10, repo, FakeSession())

这真是一个很好的地方。我们的服务层测试只依赖于服务层本身,让我们完全自由地根据需要重构模型。

将改进带入端到端测试

就像添加add_batch帮助我们将服务层测试与模型解耦一样,添加一个 API 端点来添加批次将消除对丑陋的add_stock装置的需求,我们的端到端测试可以摆脱那些硬编码的 SQL 查询和对数据库的直接依赖。

由于我们的服务函数,添加端点很容易,只需要一点 JSON 处理和一个函数调用:

用于添加批次的 API(entrypoints/flask_app.py

@app.route("/add_batch", methods=['POST'])
def add_batch():
    session = get_session()
    repo = repository.SqlAlchemyRepository(session)
    eta = request.json['eta']
    if eta is not None:
        eta = datetime.fromisoformat(eta).date()
    services.add_batch(
        request.json['ref'], request.json['sku'], request.json['qty'], eta,
        repo, session
    )
    return 'OK', 201
注意

你是否在想,向/add_batch发 POST 请求?这不太符合 RESTful!你是对的。我们很随意,但如果你想让它更符合 RESTful,也许是向/batches发 POST 请求,那就尽管去做吧!因为 Flask 是一个薄适配器,所以很容易。参见下一个侧边栏。

而且我们在 conftest.py 中的硬编码 SQL 查询被一些 API 调用所取代,这意味着 API 测试除了 API 之外没有任何依赖,这也很好:

API 测试现在可以添加自己的批次(tests/e2e/test_api.py

def post_to_add_batch(ref, sku, qty, eta):
    url = config.get_api_url()
    r = requests.post(
        f'{url}/add_batch',
        json={'ref': ref, 'sku': sku, 'qty': qty, 'eta': eta}
    )
    assert r.status_code == 201

@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api')
def test_happy_path_returns_201_and_allocated_batch():
    sku, othersku = random_sku(), random_sku('other')
    earlybatch = random_batchref(1)
    laterbatch = random_batchref(2)
    otherbatch = random_batchref(3)
    post_to_add_batch(laterbatch, sku, 100, '2011-01-02')
    post_to_add_batch(earlybatch, sku, 100, '2011-01-01')
    post_to_add_batch(otherbatch, othersku, 100, None)
    data = {'orderid': random_orderid(), 'sku': sku, 'qty': 3}
    url = config.get_api_url()
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 201
    assert r.json()['batchref'] == earlybatch

总结

一旦你有了一个服务层,你真的可以将大部分的测试覆盖移到单元测试,并且建立一个健康的测试金字塔。

一些事情将会帮助你:

  • 用原语而不是领域对象来表达你的服务层。

  • 在理想的世界里,你将拥有所有你需要的服务,能够完全针对服务层进行测试,而不是通过存储库或数据库来修改状态。这在你的端到端测试中也会得到回报。

进入下一章!

¹ 关于在更高层编写测试的一个有效担忧是,对于更复杂的用例,可能会导致组合爆炸。在这些情况下,降级到各种协作领域对象的低级单元测试可能会有用。但也参见第八章和“可选:使用虚假消息总线单独测试事件处理程序”。

第六章:工作单元模式

原文:6: Unit of Work Pattern

译者:飞龙

协议:CC BY-NC-SA 4.0

在本章中,我们将介绍将存储库和服务层模式联系在一起的最后一块拼图:工作单元模式。

如果存储库模式是我们对持久存储概念的抽象,那么工作单元(UoW)模式就是我们对原子操作概念的抽象。它将允许我们最终完全将服务层与数据层解耦。

图 6-1 显示,目前我们的基础设施层之间存在大量的通信:API 直接与数据库层交互以启动会话,它与存储库层交互以初始化SQLAlchemyRepository,并且它与服务层交互以请求分配。

提示

本章的代码位于 GitHub 的 chapter_06_uow 分支中(https://oreil.ly/MoWdZ)

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_06_uow
# or to code along, checkout Chapter 4:
git checkout chapter_04_service_layer

apwp 0601

图 6-1:没有 UoW:API 直接与三层交互

图 6-2 显示了我们的目标状态。Flask API 现在只执行两件事:初始化工作单元,并调用服务。服务与 UoW 合作(我们喜欢认为 UoW 是服务层的一部分),但服务函数本身或 Flask 现在都不需要直接与数据库交互。

我们将使用 Python 语法的一个可爱的部分,即上下文管理器。

apwp 0602

图 6-2:使用 UoW:UoW 现在管理数据库状态

工作单元与存储库合作

让我们看看工作单元(或 UoW,我们发音为“you-wow”)的实际操作。完成后,服务层将如何看起来:

工作单元在操作中的预览(src/allocation/service_layer/services.py

def allocate(
    orderid: str, sku: str, qty: int,
    uow: unit_of_work.AbstractUnitOfWork,
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:  #(1)
        batches = uow.batches.list()  #(2)
        ...
        batchref = model.allocate(line, batches)
        uow.commit()  #(3)

我们将启动 UoW 作为上下文管理器。

uow.batches是批处理存储库,因此 UoW 为我们提供了对永久存储的访问。

完成后,我们使用 UoW 提交或回滚我们的工作。

UoW 充当了我们持久存储的单一入口点,并跟踪了加载的对象和最新状态。¹

这给了我们三个有用的东西:

  • 一个稳定的数据库快照,以便我们使用的对象在操作中途不会发生更改

  • 一种一次性持久化所有更改的方法,因此如果出现问题,我们不会处于不一致的状态

  • 对我们的持久化问题提供了一个简单的 API 和一个方便的获取存储库的地方

通过集成测试驱动 UoW

这是我们的 UOW 集成测试:

UoW 的基本“往返”测试(tests/integration/test_uow.py

def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory):
    session = session_factory()
    insert_batch(session, "batch1", "HIPSTER-WORKBENCH", 100, None)
    session.commit()

    uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)  #(1)
    with uow:
        batch = uow.batches.get(reference="batch1")  #(2)
        line = model.OrderLine("o1", "HIPSTER-WORKBENCH", 10)
        batch.allocate(line)
        uow.commit()  #(3)

    batchref = get_allocated_batch_ref(session, "o1", "HIPSTER-WORKBENCH")
    assert batchref == "batch1"

我们通过使用自定义会话工厂来初始化 UoW,并获得一个uow对象来在我们的with块中使用。

UoW 通过uow.batches为我们提供对批处理存储库的访问。

当完成时,我们调用commit()

对于好奇的人,insert_batchget_allocated_batch_ref辅助程序如下:

执行 SQL 操作的辅助程序(tests/integration/test_uow.py

def insert_batch(session, ref, sku, qty, eta):
    session.execute(
        'INSERT INTO batches (reference, sku, _purchased_quantity, eta)'
        ' VALUES (:ref, :sku, :qty, :eta)',
        dict(ref=ref, sku=sku, qty=qty, eta=eta)
    )

def get_allocated_batch_ref(session, orderid, sku):
    [[orderlineid]] = session.execute(
        'SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku',
        dict(orderid=orderid, sku=sku)
    )
    [[batchref]] = session.execute(
        'SELECT b.reference FROM allocations JOIN batches AS b ON batch_id = b.id'
        ' WHERE orderline_id=:orderlineid',
        dict(orderlineid=orderlineid)
    )
    return batchref

工作单元及其上下文管理器

在我们的测试中,我们隐式地定义了 UoW 需要执行的接口。让我们通过使用抽象基类来明确定义:

抽象 UoW 上下文管理器(src/allocation/service_layer/unit_of_work.py

lass AbstractUnitOfWork(abc.ABC):
    batches: repository.AbstractRepository  #(1)

    def __exit__(self, *args):  #(2)
        self.rollback()  #(4)

    @abc.abstractmethod
    def commit(self):  #(3)
        raise NotImplementedError

    @abc.abstractmethod
    def rollback(self):  #(4)
        raise NotImplementedError

UoW 提供了一个名为.batches的属性,它将为我们提供对批处理存储库的访问。

如果您从未见过上下文管理器,__enter____exit__是我们进入with块和退出with块时执行的两个魔术方法。它们是我们的设置和拆卸阶段。

当我们准备好时,我们将调用这个方法来显式提交我们的工作。

如果我们不提交,或者通过引发错误退出上下文管理器,我们将执行rollback。(如果调用了commit(),则rollback不起作用。继续阅读更多关于此的讨论。)

真正的工作单元使用 SQLAlchemy 会话

我们具体实现的主要内容是数据库会话:

真正的 SQLAlchemy UoW(src/allocation/service_layer/unit_of_work.py

DEFAULT_SESSION_FACTORY = sessionmaker(  #(1)
    bind=create_engine(
        config.get_postgres_uri(),
    )
)


class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
    def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
        self.session_factory = session_factory  #(1)

    def __enter__(self):
        self.session = self.session_factory()  # type: Session  #(2)
        self.batches = repository.SqlAlchemyRepository(self.session)  #(2)
        return super().__enter__()

    def __exit__(self, *args):
        super().__exit__(*args)
        self.session.close()  #(3)

    def commit(self):  #(4)
        self.session.commit()

    def rollback(self):  #(4)
        self.session.rollback()

该模块定义了一个默认的会话工厂,将连接到 Postgres,但我们允许在集成测试中进行覆盖,以便我们可以改用 SQLite。

__enter__方法负责启动数据库会话并实例化一个可以使用该会话的真实存储库。

我们在退出时关闭会话。

最后,我们提供使用我们的数据库会话的具体commit()rollback()方法。

用于测试的假工作单元

这是我们在服务层测试中如何使用假 UoW 的方式:

假 UoW(tests/unit/test_services.py

class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):
    def __init__(self):
        self.batches = FakeRepository([])  #(1)
        self.committed = False  #(2)

    def commit(self):
        self.committed = True  #(2)

    def rollback(self):
        pass


def test_add_batch():
    uow = FakeUnitOfWork()  #(3)
    services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow)  #(3)
    assert uow.batches.get("b1") is not None
    assert uow.committed


def test_allocate_returns_allocation():
    uow = FakeUnitOfWork()  #(3)
    services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow)  #(3)
    result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow)  #(3)
    assert result == "batch1"

FakeUnitOfWorkFakeRepository紧密耦合,就像真正的UnitofWorkRepository类一样。这没关系,因为我们认识到这些对象是协作者。

注意与FakeSession中的假commit()函数的相似性(现在我们可以摆脱它)。但这是一个重大的改进,因为我们现在是在模拟我们编写的代码,而不是第三方代码。有些人说,“不要模拟你不拥有的东西”。

在我们的测试中,我们可以实例化一个 UoW 并将其传递给我们的服务层,而不是传递存储库和会话。这要简单得多。

在服务层使用 UoW

我们的新服务层看起来是这样的:

使用 UoW 的服务层(src/allocation/service_layer/services.py

def add_batch(
    ref: str, sku: str, qty: int, eta: Optional[date],
    uow: unit_of_work.AbstractUnitOfWork,  #(1)
):
    with uow:
        uow.batches.add(model.Batch(ref, sku, qty, eta))
        uow.commit()


def allocate(
    orderid: str, sku: str, qty: int,
    uow: unit_of_work.AbstractUnitOfWork,  #(1)
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        batches = uow.batches.list()
        if not is_valid_sku(line.sku, batches):
            raise InvalidSku(f"Invalid sku {line.sku}")
        batchref = model.allocate(line, batches)
        uow.commit()
    return batchref

我们的服务层现在只有一个依赖,再次依赖于抽象 UoW。

显式测试提交/回滚行为

为了确信提交/回滚行为有效,我们编写了一些测试:

回滚行为的集成测试(tests/integration/test_uow.py

def test_rolls_back_uncommitted_work_by_default(session_factory):
    uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
    with uow:
        insert_batch(uow.session, 'batch1', 'MEDIUM-PLINTH', 100, None)

    new_session = session_factory()
    rows = list(new_session.execute('SELECT * FROM "batches"'))
    assert rows == []

def test_rolls_back_on_error(session_factory):
    class MyException(Exception):
        pass

    uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
    with pytest.raises(MyException):
        with uow:
            insert_batch(uow.session, 'batch1', 'LARGE-FORK', 100, None)
            raise MyException()

    new_session = session_factory()
    rows = list(new_session.execute('SELECT * FROM "batches"'))
    assert rows == []
提示

我们没有在这里展示它,但值得测试一些更“晦涩”的数据库行为,比如事务,针对“真实”的数据库,也就是相同的引擎。目前,我们使用 SQLite 代替 Postgres,但在第七章中,我们将一些测试切换到使用真实数据库。我们的 UoW 类使这变得很方便!

显式提交与隐式提交

现在我们简要地偏离一下实现 UoW 模式的不同方式。

我们可以想象 UoW 的一个稍微不同的版本,默认情况下进行提交,只有在发现异常时才进行回滚:

具有隐式提交的 UoW…(src/allocation/unit_of_work.py

class AbstractUnitOfWork(abc.ABC):

    def __enter__(self):
        return self

    def __exit__(self, exn_type, exn_value, traceback):
        if exn_type is None:
            self.commit()  #(1)
        else:
            self.rollback()  #(2)

在正常情况下,我们应该有一个隐式提交吗?

并且只在异常时回滚?

这将允许我们节省一行代码,并从客户端代码中删除显式提交:

...将为我们节省一行代码(src/allocation/service_layer/services.py

def add_batch(ref: str, sku: str, qty: int, eta: Optional[date], uow):
    with uow:
        uow.batches.add(model.Batch(ref, sku, qty, eta))
        # uow.commit()

这是一个判断调用,但我们倾向于要求显式提交,这样我们就必须选择何时刷新状态。

虽然我们使用了额外的一行代码,但这使得软件默认情况下是安全的。默认行为是不改变任何东西。反过来,这使得我们的代码更容易推理,因为只有一条代码路径会导致系统的更改:完全成功和显式提交。任何其他代码路径,任何异常,任何从 UoW 范围的早期退出都会导致安全状态。

同样,我们更喜欢默认情况下回滚,因为这样更容易理解;这将回滚到上次提交,所以要么用户进行了提交,要么我们取消他们的更改。严厉但简单。

示例:使用 UoW 将多个操作分组为一个原子单元

以下是一些示例,展示了 UoW 模式的使用。您可以看到它如何导致对代码块何时发生在一起的简单推理。

示例 1:重新分配

假设我们想要能够取消分配然后重新分配订单:

重新分配服务功能

def reallocate(
    line: OrderLine,
    uow: AbstractUnitOfWork,
) -> str:
    with uow:
        batch = uow.batches.get(sku=line.sku)
        if batch is None:
            raise InvalidSku(f'Invalid sku {line.sku}')
        batch.deallocate(line)  #(1)
        allocate(line)  #(2)
        uow.commit()

如果deallocate()失败,显然我们不想调用allocate()

如果allocate()失败,我们可能不想实际提交deallocate()

示例 2:更改批量数量

我们的航运公司给我们打电话说,一个集装箱门打开了,我们的沙发有一半掉进了印度洋。糟糕!

更改数量

def change_batch_quantity(
    batchref: str, new_qty: int,
    uow: AbstractUnitOfWork,
):
    with uow:
        batch = uow.batches.get(reference=batchref)
        batch.change_purchased_quantity(new_qty)
        while batch.available_quantity < 0:
            line = batch.deallocate_one()  #(1)
        uow.commit()

在这里,我们可能需要取消分配任意数量的行。如果在任何阶段出现故障,我们可能不想提交任何更改。

整理集成测试

现在我们有三组测试,基本上都指向数据库:test_orm.pytest_repository.pytest_uow.py。我们应该放弃其中的任何一个吗?

└── tests
    ├── conftest.py
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_orm.py
    │   ├── test_repository.py
    │   └── test_uow.py
    ├── pytest.ini
    └── unit
        ├── test_allocate.py
        ├── test_batches.py
        └── test_services.py

如果您认为测试长期内不会增加价值,那么您应该随时放弃测试。我们会说 test_orm.py 主要是帮助我们学习 SQLAlchemy 的工具,所以我们长期不需要它,特别是如果它正在做的主要事情已经在 test_repository.py 中涵盖了。最后一个测试,您可能会保留,但我们当然可以看到只保持在最高可能的抽象级别上的论点(就像我们为单元测试所做的那样)。

提示

这是来自第五章的另一个教训的例子:随着我们构建更好的抽象,我们可以将我们的测试运行到它们所针对的抽象,这样我们就可以自由地更改底层细节。* *# 总结

希望我们已经说服了您,单位工作模式是有用的,并且上下文管理器是一种非常好的 Pythonic 方式,可以将代码可视化地分组到我们希望以原子方式发生的块中。

这种模式非常有用,事实上,SQLAlchemy 已经使用了 UoW,形式为Session对象。SQLAlchemy 中的Session对象是您的应用程序从数据库加载数据的方式。

每次从数据库加载新实体时,会话开始跟踪对实体的更改,当会话刷新时,所有更改都会一起持久化。如果 SQLAlchemy 已经实现了我们想要的模式,为什么我们要努力抽象出 SQLAlchemy 会话呢?

Table 6-1 讨论了一些权衡。

表 6-1. 单元工作模式:权衡

优点 缺点
我们对原子操作的概念有一个很好的抽象,上下文管理器使得很容易直观地看到哪些代码块被原子地分组在一起。 您的 ORM 可能已经围绕原子性有一些完全合适的抽象。SQLAlchemy 甚至有上下文管理器。只需传递一个会话就可以走得很远。
我们可以明确控制事务何时开始和结束,我们的应用程序默认以一种安全的方式失败。我们永远不必担心某个操作部分提交。 我们让它看起来很容易,但您必须仔细考虑回滚、多线程和嵌套事务等问题。也许只需坚持 Django 或 Flask-SQLAlchemy 给您的东西,就可以让您的生活更简单。
这是一个很好的地方,可以将所有的存储库放在一起,这样客户端代码就可以访问它们。
正如您将在后面的章节中看到的那样,原子性不仅仅是关于事务;它可以帮助我们处理事件和消息总线。

首先,会话 API 非常丰富,支持我们在领域中不需要或不需要的操作。我们的UnitOfWork简化了会话到其基本核心:它可以启动、提交或丢弃。

另外,我们使用UnitOfWork来访问我们的Repository对象。这是一个很好的开发人员可用性的技巧,我们无法使用普通的 SQLAlchemySession来实现。

最后,我们再次受到依赖反转原则的激励:我们的服务层依赖于一个薄抽象,并且我们在系统的外围附加一个具体的实现。这与 SQLAlchemy 自己的建议非常契合:

保持会话的生命周期(通常也是事务)分离和外部化。最全面的方法,建议用于更实质性的应用程序,将尽可能将会话、事务和异常管理的细节与执行其工作的程序的细节分开。

——SQLALchemy“会话基础”文档

¹ 您可能已经遇到过使用“合作者”一词来描述一起实现目标的对象。工作单元和存储库是对象建模意义上的合作者的绝佳示例。在责任驱动设计中,以其角色协作的对象群集被称为“对象邻域”,在我们的专业意见中,这是非常可爱的。

第七章:聚合和一致性边界

原文:7: Aggregates and Consistency Boundaries

译者:飞龙

协议:CC BY-NC-SA 4.0

在本章中,我们想重新审视我们的领域模型,讨论不变量和约束,并看看我们的领域对象如何在概念上和持久存储中保持自己的内部一致性。我们将讨论一致性边界的概念,并展示如何明确地做出这一点可以帮助我们构建高性能软件,而不会影响可维护性。

图 7-1 显示了我们的目标:我们将引入一个名为Product的新模型对象来包装多个批次,并且我们将使旧的allocate()领域服务作为Product的方法可用。

apwp 0701

图 7-1. 添加产品聚合

为什么?让我们找出原因。

提示

本章的代码在GitHub的 appendix_csvs 分支中:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout appendix_csvs
# or to code along, checkout the previous chapter:
git checkout chapter_06_uow

为什么不只在电子表格中运行所有东西?

领域模型的意义是什么?我们试图解决的根本问题是什么?

我们难道不能只在电子表格中运行所有东西吗?我们的许多用户会对此感到高兴。业务用户喜欢电子表格,因为它们简单、熟悉,但又非常强大。

事实上,大量的业务流程确实是通过手动在电子邮件中来回发送电子表格来操作的。这种“CSV 通过 SMTP”架构具有较低的初始复杂性,但往往不容易扩展,因为很难应用逻辑和保持一致性。

谁有权查看特定字段?谁有权更新它?当我们尝试订购-350 把椅子或者 1000 万张桌子时会发生什么?员工可以有负薪水吗?

这些是系统的约束条件。我们编写的许多领域逻辑存在的目的是为了强制执行这些约束条件,以维护系统的不变量。不变量是每当我们完成一个操作时必须为真的事物。

不变量、约束和一致性

这两个词在某种程度上是可以互换的,但约束是限制我们的模型可能进入的可能状态的规则,而不变量更精确地定义为始终为真的条件。

如果我们正在编写酒店预订系统,我们可能会有一个约束,即不允许双重预订。这支持了一个不变量,即一个房间在同一天晚上不能有多个预订。

当然,有时我们可能需要暂时违反规则。也许我们需要因为贵宾预订而重新安排房间。当我们在内存中移动预订时,我们可能会被双重预订,但我们的领域模型应该确保,当我们完成时,我们最终处于一个一致的状态,其中不变量得到满足。如果我们找不到一种方法来容纳所有客人,我们应该引发错误并拒绝完成操作。

让我们从我们的业务需求中看一些具体的例子;我们将从这个开始:

订单行一次只能分配给一个批次。

——业务

这是一个强加不变量的业务规则。不变量是订单行必须分配给零个或一个批次,但绝不能超过一个。我们需要确保我们的代码永远不会意外地对同一行调用Batch.allocate()两个不同的批次,并且目前没有任何东西明确阻止我们这样做。

不变量、并发和锁

让我们再看看我们的另一个业务规则:

如果可用数量小于订单行的数量,我们就不能分配给批次。

——业务

这里的约束是我们不能分配超过批次可用数量的库存,因此我们永远不会通过将两个客户分配给同一个实际垫子而超卖库存。每当我们更新系统的状态时,我们的代码需要确保我们不会破坏不变量,即可用数量必须大于或等于零。

在单线程、单用户的应用程序中,我们相对容易地维护这个不变量。我们可以一次分配一行库存,并在没有库存可用时引发错误。

当我们引入并发的概念时,这就变得更加困难。突然间,我们可能同时为多个订单行分配库存。甚至可能在处理对批次本身的更改的同时分配订单行。

通常,我们通过在数据库表上应用来解决这个问题。这可以防止在同一行或同一表上同时发生两个操作。

当我们开始考虑扩展我们的应用程序时,我们意识到我们针对所有可用批次分配行的模型可能无法扩展。如果我们每小时处理数万个订单,以及数十万个订单行,我们无法为每一个订单行在整个batches表上持有锁定——至少会出现死锁或性能问题。

什么是聚合?

好吧,如果我们每次想要分配一个订单行都不能锁定整个数据库,那我们应该做什么呢?我们希望保护系统的不变量,但又允许最大程度的并发。维护我们的不变量不可避免地意味着防止并发写入;如果多个用户可以同时分配DEADLY-SPOON,我们就有可能过度分配。

另一方面,我们可以同时分配DEADLY-SPOONFLIMSY-DESK。同时分配两种产品是安全的,因为它们没有共同的不变量。我们不需要它们彼此一致。

聚合模式是来自 DDD 社区的设计模式,它帮助我们解决这种紧张关系。聚合只是一个包含其他领域对象的领域对象,它让我们将整个集合视为一个单一单位。

修改聚合内部对象的唯一方法是加载整个对象,并在聚合本身上调用方法。

随着模型变得更加复杂,实体和值对象之间相互引用,形成了一个纠缠的图形,很难跟踪谁可以修改什么。特别是当我们在模型中有集合(我们的批次是一个集合)时,提名一些实体作为修改其相关对象的唯一入口是一个好主意。如果您提名一些对象负责其他对象的一致性,系统在概念上会更简单,更容易理解。

例如,如果我们正在构建一个购物网站,购物车可能是一个很好的聚合:它是一组商品,我们可以将其视为一个单一单位。重要的是,我们希望从数据存储中以单个块加载整个购物篮。我们不希望两个请求同时修改购物篮,否则我们就有可能出现奇怪的并发错误。相反,我们希望每次对购物篮的更改都在单个数据库事务中运行。

我们不希望在一个事务中修改多个购物篮,因为没有用例需要同时更改几个客户的购物篮。每个购物篮都是一个单一的一致性边界,负责维护自己的不变量。

聚合是一组相关对象的集合,我们将其视为数据更改的单元。

——埃里克·埃文斯,《领域驱动设计》蓝皮书

根据埃里克·埃文斯(Eric Evans)的说法,我们的聚合有一个根实体(购物车),它封装了对商品的访问。每个商品都有自己的身份,但系统的其他部分将始终将购物车视为一个不可分割的整体。

提示

就像我们有时使用*_leading_underscores*来标记方法或函数为“私有”一样,您可以将聚合视为我们模型的“公共”类,而其他实体和值对象则为“私有”。

选择聚合

我们的系统应该使用哪个聚合?选择有点随意,但很重要。聚合将是我们确保每个操作都以一致状态结束的边界。这有助于我们推理我们的软件并防止奇怪的竞争问题。我们希望在一小部分对象周围划定边界——越小越好,以提高性能——这些对象必须彼此保持一致,并且我们需要给这个边界一个好名字。

我们在幕后操作的对象是Batch。我们如何称呼一组批次?我们应该如何将系统中的所有批次划分为一致性的离散岛屿?

我们可以使用Shipment作为我们的边界。每个发货包含多个批次,它们同时运送到我们的仓库。或者我们可以使用Warehouse作为我们的边界:每个仓库包含许多批次,同时对所有库存进行计数可能是有意义的。

然而,这两个概念都不能满足我们。即使它们在同一个仓库或同一批次中,我们也应该能够同时分配DEADLY-SPOONsFLIMSY-DESKs。这些概念的粒度不对。

当我们分配订单行时,我们只对具有与订单行相同 SKU 的批次感兴趣。类似GlobalSkuStock的概念可能有效:给定 SKU 的所有批次的集合。

然而,这是一个笨重的名称,所以在通过SkuStockStockProductStock等进行一些讨论后,我们决定简单地称其为Product——毕竟,这是我们在第一章中探索领域语言时遇到的第一个概念。

因此,计划是这样的:当我们想要分配订单行时,我们不再使用图 7-2,在那里我们查找世界上所有的Batch对象并将它们传递给allocate()领域服务...

apwp 0702

图 7-2.之前:使用领域服务对所有批次进行分配
[plantuml, apwp_0702, config=plantuml.cfg]
@startuml

hide empty members

package "Service Layer" as services {
    class "allocate()" as allocate {
    }
    hide allocate circle
    hide allocate members
}

package "Domain Model" as domain_model {

  class Batch {
  }

  class "allocate()" as allocate_domain_service {
  }
    hide allocate_domain_service circle
    hide allocate_domain_service members
}

package repositories {

  class BatchRepository {
    list()
  }

}

allocate -> BatchRepository: list all batches
allocate --> allocate_domain_service: allocate(orderline, batches)

@enduml

...我们将转向图 7-3 的世界,在那里有一个特定 SKU 的新Product对象,它将负责所有该 SKU的批次,并且我们可以在其上调用.allocate()方法。

apwp 0703

图 7-3.之后:要求 Product 根据其批次进行分配
[plantuml, apwp_0703, config=plantuml.cfg]
@startuml

hide empty members

package "Service Layer" as services {
    class "allocate()" as allocate {
    }
}

hide allocate circle
hide allocate members

package "Domain Model" as domain_model {

  class Product {
    allocate()
  }

  class Batch {
  }
}

package repositories {

  class ProductRepository {
    get()
  }

}

allocate -> ProductRepository: get me the product for this sku
allocate --> Product: product.allocate(orderline)
Product o- Batch: has

@enduml

让我们看看代码形式是什么样子的:

我们选择的聚合,Product (src/allocation/domain/model.py)

class Product:
    def __init__(self, sku: str, batches: List[Batch]):
        self.sku = sku  #(1)
        self.batches = batches  #(2)

    def allocate(self, line: OrderLine) -> str:  #(3)
        try:
            batch = next(b for b in sorted(self.batches) if b.can_allocate(line))
            batch.allocate(line)
            return batch.reference
        except StopIteration:
            raise OutOfStock(f"Out of stock for sku {line.sku}")

Product的主要标识符是sku

我们的Product类持有对该 SKU 的一组batches的引用。

最后,我们可以将allocate()领域服务移动到Product聚合的方法上。

这个Product看起来可能不像你期望的Product模型。没有价格,没有描述,没有尺寸。我们的分配服务不关心这些东西。这就是有界上下文的力量;一个应用中的产品概念可能与另一个应用中的产品概念非常不同。请参阅以下侧边栏以进行更多讨论。

一个聚合=一个存储库

一旦您定义了某些实体为聚合,我们需要应用一个规则,即它们是唯一对外界可访问的实体。换句话说,我们允许的唯一存储库应该是返回聚合的存储库。

存储库只返回聚合是我们强制执行聚合是进入我们领域模型的唯一方式的主要地方。要小心不要违反它!

在我们的案例中,我们将从BatchRepository切换到ProductRepository

我们的新 UoW 和存储库(unit_of_work.py 和 repository.py)

class AbstractUnitOfWork(abc.ABC):
    products: repository.AbstractProductRepository

...

class AbstractProductRepository(abc.ABC):

    @abc.abstractmethod
    def add(self, product):
        ...

    @abc.abstractmethod
    def get(self, sku) -> model.Product:
        ...

ORM 层将需要一些调整,以便正确的批次自动加载并与Product对象关联。好处是,存储库模式意味着我们不必担心这个问题。我们可以只使用我们的FakeRepository,然后将新模型传递到我们的服务层,看看它作为其主要入口点的Product是什么样子:

服务层(src/allocation/service_layer/services.py

def add_batch(
        ref: str, sku: str, qty: int, eta: Optional[date],
        uow: unit_of_work.AbstractUnitOfWork
):
    with uow:
        product = uow.products.get(sku=sku)
        if product is None:
            product = model.Product(sku, batches=[])
            uow.products.add(product)
        product.batches.append(model.Batch(ref, sku, qty, eta))
        uow.commit()

def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f'Invalid sku {line.sku}')
        batchref = product.allocate(line)
        uow.commit()
    return batchref

性能如何?

我们已经多次提到,我们正在使用聚合进行建模,因为我们希望拥有高性能的软件,但是在这里,我们加载了所有批次,而我们只需要一个。你可能会认为这是低效的,但我们在这里感到舒适的原因有几个。

首先,我们有意地对我们的数据进行建模,以便我们可以对数据库进行单个查询来读取,并进行单个更新以保存我们的更改。这往往比发出许多临时查询的系统性能要好得多。在不以这种方式建模的系统中,我们经常发现随着软件的发展,事务变得越来越长,越来越复杂。

其次,我们的数据结构是最小的,每行包括一些字符串和整数。我们可以在几毫秒内轻松加载数十甚至数百个批次。

第三,我们预计每种产品一次只有大约 20 个批次左右。一旦批次用完,我们可以从我们的计算中剔除它。这意味着我们获取的数据量不应该随着时间的推移而失控。

如果我们确实预计某种产品会有成千上万个活跃的批次,我们将有几个选择。首先,我们可以对产品中的批次使用延迟加载。从我们代码的角度来看,没有任何变化,但在后台,SQLAlchemy 会为我们分页数据。这将导致更多的请求,每个请求获取更少的行。因为我们只需要找到一个足够容量的批次来满足我们的订单,这可能效果很好。

如果一切都失败了,我们只需寻找一个不同的聚合。也许我们可以按地区或仓库拆分批次。也许我们可以围绕装运概念重新设计我们的数据访问策略。聚合模式旨在帮助管理一些围绕一致性和性能的技术约束。并没有一个正确的聚合,如果发现我们的边界导致性能问题,我们应该放心改变我们的想法。

使用版本号进行乐观并发

我们有了我们的新聚合,所以我们解决了选择一个对象负责一致性边界的概念问题。现在让我们花点时间谈谈如何在数据库级别强制执行数据完整性。

注意

这一部分包含了许多实现细节;例如,其中一些是特定于 Postgres 的。但更一般地,我们展示了一种管理并发问题的方法,但这只是一种方法。这一领域的实际要求在项目之间变化很大。你不应该期望能够将代码从这里复制粘贴到生产环境中。

我们不想在整个batches表上持有锁,但是我们如何实现仅在特定 SKU 的行上持有锁?

一个解决方案是在Product模型上有一个单一属性,作为整个状态变化完成的标记,并将其用作并发工作者可以争夺的单一资源。如果两个事务同时读取batches的世界状态,并且都想要更新allocations表,我们强制两者也尝试更新products表中的version_number,以便只有一个可以获胜,世界保持一致。

图 7-4 说明了两个并发事务同时进行读取操作,因此它们看到的是一个具有,例如,version=3Product。它们都调用Product.allocate()来修改状态。但我们设置了数据库完整性规则,只允许其中一个使用commit提交带有version=4的新Product,而另一个更新将被拒绝。

提示

版本号只是实现乐观锁定的一种方式。你可以通过将 Postgres 事务隔离级别设置为SERIALIZABLE来实现相同的效果,但这通常会带来严重的性能成本。版本号还可以使隐含的概念变得明确。

apwp 0704

图 7-4:序列图:两个事务尝试并发更新Product
[plantuml, apwp_0704, config=plantuml.cfg]
@startuml

entity Model
collections Transaction1
collections Transaction2
database Database

Transaction1 -> Database: get product
Database -> Transaction1: Product(version=3)
Transaction2 -> Database: get product
Database -> Transaction2: Product(version=3)
Transaction1 -> Model: Product.allocate()
Model -> Transaction1: Product(version=4)
Transaction2 -> Model: Product.allocate()
Model -> Transaction2: Product(version=4)
Transaction1 -> Database: commit Product(version=4)
Database -[#green]> Transaction1: OK
Transaction2 -> Database: commit Product(version=4)
Database -[#red]>x Transaction2: Error! version is already 4

@enduml

版本号的实现选项

基本上有三种实现版本号的选项:

  1. version_number存在于领域中;我们将其添加到Product构造函数中,Product.allocate()负责递增它。

  2. 服务层可以做到!版本号并不严格是一个领域问题,所以我们的服务层可以假设当前版本号是由存储库附加到Product上的,并且服务层在执行commit()之前会递增它。

  3. 由于这可以说是一个基础设施问题,UoW 和存储库可以通过魔法来做到这一点。存储库可以访问它检索的任何产品的版本号,当 UoW 提交时,它可以递增它所知道的任何产品的版本号,假设它们已经更改。

选项 3 并不理想,因为没有真正的方法可以做到这一点,而不必假设所有产品都已更改,所以我们将在不必要的时候递增版本号。¹

选项 2 涉及在服务层和领域层之间混合变更状态的责任,因此也有点混乱。

因此,最终,即使版本号不一定是一个领域关注的问题,你可能会决定最干净的权衡是将它们放在领域中:

我们选择的聚合,Product(src/allocation/domain/model.py

class Product:
    def __init__(self, sku: str, batches: List[Batch], version_number: int = 0):  #(1)
        self.sku = sku
        self.batches = batches
        self.version_number = version_number  #(1)

    def allocate(self, line: OrderLine) -> str:
        try:
            batch = next(b for b in sorted(self.batches) if b.can_allocate(line))
            batch.allocate(line)
            self.version_number += 1  #(1)
            return batch.reference
        except StopIteration:
            raise OutOfStock(f"Out of stock for sku {line.sku}")

就是这样!

提示

如果你对版本号这个业务感到困惑,也许记住号码并不重要会有所帮助。重要的是,每当我们对Product聚合进行更改时,Product数据库行都会被修改。版本号是一种简单的、人类可理解的方式来模拟每次写入时都会发生变化的事物,但它也可以是每次都是一个随机 UUID。

测试我们的数据完整性规则

现在要确保我们可以得到我们想要的行为:如果我们有两个并发尝试针对同一个Product进行分配,其中一个应该失败,因为它们不能同时更新版本号。

首先,让我们使用一个执行分配然后显式休眠的函数来模拟“慢”事务:²

time.sleep 可以复制并发行为(tests/integration/test_uow.py

def try_to_allocate(orderid, sku, exceptions):
    line = model.OrderLine(orderid, sku, 10)
    try:
        with unit_of_work.SqlAlchemyUnitOfWork() as uow:
            product = uow.products.get(sku=sku)
            product.allocate(line)
            time.sleep(0.2)
            uow.commit()
    except Exception as e:
        print(traceback.format_exc())
        exceptions.append(e)

然后我们的测试使用线程并发两次调用这个慢分配:

并发行为的集成测试(tests/integration/test_uow.py

def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory):
    sku, batch = random_sku(), random_batchref()
    session = postgres_session_factory()
    insert_batch(session, batch, sku, 100, eta=None, product_version=1)
    session.commit()

    order1, order2 = random_orderid(1), random_orderid(2)
    exceptions = []  # type: List[Exception]
    try_to_allocate_order1 = lambda: try_to_allocate(order1, sku, exceptions)
    try_to_allocate_order2 = lambda: try_to_allocate(order2, sku, exceptions)
    thread1 = threading.Thread(target=try_to_allocate_order1)  #(1)
    thread2 = threading.Thread(target=try_to_allocate_order2)  #(1)
    thread1.start()
    thread2.start()
    thread1.join()
    thread2.join()

    [[version]] = session.execute(
        "SELECT version_number FROM products WHERE sku=:sku",
        dict(sku=sku),
    )
    assert version == 2  #(2)
    [exception] = exceptions
    assert "could not serialize access due to concurrent update" in str(exception)  #(3)

    orders = session.execute(
        "SELECT orderid FROM allocations"
        " JOIN batches ON allocations.batch_id = batches.id"
        " JOIN order_lines ON allocations.orderline_id = order_lines.id"
        " WHERE order_lines.sku=:sku",
        dict(sku=sku),
    )
    assert orders.rowcount == 1  #(4)
    with unit_of_work.SqlAlchemyUnitOfWork() as uow:
        uow.session.execute("select 1")

我们启动两个线程,它们将可靠地产生我们想要的并发行为:read1, read2, write1, write2

我们断言版本号只被递增了一次。

我们也可以检查特定的异常,如果我们愿意的话。

然后我们再次检查,只有一个分配已经完成。

通过使用数据库事务隔离级别来强制执行并发规则

为了使测试通过,我们可以在会话中设置事务隔离级别:

为会话设置隔离级别 (src/allocation/service_layer/unit_of_work.py)

DEFAULT_SESSION_FACTORY = sessionmaker(bind=create_engine(
    config.get_postgres_uri(),
    isolation_level="REPEATABLE READ",
))
提示

事务隔离级别是棘手的东西,所以值得花时间了解Postgres 文档。³

悲观并发控制示例:SELECT FOR UPDATE

有多种方法可以解决这个问题,但我们将展示一种方法。SELECT FOR UPDATE会产生不同的行为;两个并发事务将不被允许同时对相同的行进行读取。

SELECT FOR UPDATE是一种选择行作为锁定的方法(尽管这些行不一定是你要更新的行)。如果两个事务同时尝试SELECT FOR UPDATE一行,一个会获胜,另一个会等待直到锁定被释放。因此,这是一种悲观并发控制的例子。

以下是您可以使用 SQLAlchemy DSL 在查询时指定FOR UPDATE的方法:

SQLAlchemy with_for_update (src/allocation/adapters/repository.py)

    def get(self, sku):
        return self.session.query(model.Product) \
                           .filter_by(sku=sku) \
                           .with_for_update() \
                           .first()

这将改变并发模式

read1, read2, write1, write2(fail)

read1, write1, read2, write2(succeed)

有些人将这称为“读取-修改-写入”故障模式。阅读“PostgreSQL 反模式:读取-修改-写入周期”以获得一个很好的概述。

我们真的没有时间讨论REPEATABLE READSELECT FOR UPDATE之间的所有权衡,或者乐观与悲观锁定。但是,如果你有一个像我们展示的那样的测试,你可以指定你想要的行为并查看它是如何改变的。你也可以使用测试作为执行一些性能实验的基础。

总结

围绕并发控制的具体选择根据业务情况和存储技术选择而有很大的不同,但我们想把这一章重新带回到聚合的概念上:我们明确地将一个对象建模为我们模型的某个子集的主要入口点,并负责强制执行适用于所有这些对象的不变量和业务规则。

选择正确的聚合是关键,这是一个你可能随时间重新考虑的决定。你可以在多本 DDD 书籍中了解更多。我们还推荐 Vaughn Vernon(“红皮书”作者)的这三篇关于有效聚合设计的在线论文。

表 7-1 对实现聚合模式的权衡有一些想法。

表 7-1. 聚合:权衡

优点 缺点
Python 可能没有“官方”的公共和私有方法,但我们有下划线约定,因为通常有用的是尝试指示什么是“内部”使用和什么是“外部代码”使用。选择聚合只是更高一级的:它让你决定你的领域模型类中哪些是公共的,哪些不是。 对于新开发人员来说,又是一个新概念。解释实体与值对象已经是一种心理负担;现在又出现了第三种领域模型对象?
围绕显式一致性边界建模我们的操作有助于避免 ORM 的性能问题。 严格遵守我们一次只修改一个聚合的规则是一个很大的心理转变。
将聚合单独负责对其子模型的状态更改使系统更容易理解,并使其更容易控制不变量。 处理聚合之间的最终一致性可能会很复杂。

第一部分总结

你还记得图 7-5 吗?这是我们在第一部分开始时展示的图表,预览我们的方向的。

apwp 0705

图 7-5:第一部分结束时我们应用的组件图

这就是我们在第一部分结束时所处的位置。我们取得了什么成就?我们看到了如何构建一个领域模型,通过一组高级单元测试来验证。我们的测试是活的文档:它们描述了我们系统的行为——我们与业务利益相关者达成一致的规则——以易于阅读的代码形式。当我们的业务需求发生变化时,我们有信心我们的测试将帮助我们证明新功能,当新的开发人员加入项目时,他们可以阅读我们的测试来理解事物是如何工作的。

我们已经解耦了系统的基础部分,如数据库和 API 处理程序,以便我们可以将它们插入到我们应用程序的外部。这有助于我们保持我们的代码库组织良好,并阻止我们构建一个大泥球。

通过应用依赖反转原则,并使用端口和适配器启发的模式,如存储库和工作单元,我们已经使得在高档和低档都可以进行 TDD,并保持一个健康的测试金字塔。我们可以对我们的系统进行端到端的测试,对集成和端到端测试的需求保持最低限度。

最后,我们谈到了一致性边界的概念。我们不希望在进行更改时锁定整个系统,因此我们必须选择哪些部分彼此一致。

对于一个小系统来说,这就是你需要去尝试领域驱动设计理念的一切。现在你有了工具来构建与数据库无关的领域模型,代表了你的业务专家的共享语言。万岁!

注意

冒着重复的风险,我们一再强调每个模式都有成本。每一层间接性都会在我们的代码中产生复杂性和重复,并且对于从未见过这些模式的程序员来说会很困惑。如果你的应用本质上只是一个简单的 CRUD 包装器,围绕数据库,未来也不太可能成为其他东西,你不需要这些模式。继续使用 Django,省去很多麻烦。

在第二部分中,我们将放大并讨论一个更大的话题:如果聚合是我们的边界,我们只能一次更新一个,那么我们如何建模跨一致性边界的过程呢?

¹也许我们可以通过 ORM/SQLAlchemy 魔术告诉我们对象何时是脏的,但在通用情况下,这将如何工作——例如对于CsvRepository

²time.sleep()在我们的用例中效果很好,但它并不是再现并发错误最可靠或高效的方式。考虑使用信号量或类似的同步原语,在线程之间共享,以获得更好的行为保证。

³如果你没有使用 Postgres,你需要阅读不同的文档。令人恼火的是,不同的数据库都有相当不同的定义。例如,Oracle 的SERIALIZABLE等同于 Postgres 的REPEATABLE READ

第二部分:事件驱动架构

原文:Part 2: Event-Driven Architecture

译者:飞龙

协议:CC BY-NC-SA 4.0

很抱歉我很久以前为这个主题创造了“对象”这个术语,因为它让很多人关注了次要的想法。

重要的想法是“消息传递”……设计出伟大且可扩展的系统的关键更多地在于设计其模块之间的通信方式,而不是它们的内部属性和行为应该是什么。

——艾伦·凯

能够编写一个领域模型来管理一个业务流程的一小部分是非常好的,但当我们需要编写许多模型时会发生什么?在现实世界中,我们的应用程序位于一个组织中,并且需要与系统的其他部分交换信息。您可能还记得我们在图 II-1 中显示的上下文图。

面对这个要求,许多团队会选择通过 HTTP API 集成的微服务。但如果他们不小心,最终会产生最混乱的分布式大泥球。

在第二部分中,我们将展示如何将第一部分的技术扩展到分布式系统。我们将放大看看如何通过异步消息传递来组合一个系统的许多小组件之间的交互。

我们将看到我们的服务层和工作单元模式如何允许我们重新配置我们的应用程序以作为异步消息处理器运行,以及事件驱动系统如何帮助我们将聚合和应用程序相互解耦。

apwp 0102

图 II-1:但所有这些系统究竟如何相互通信呢?

我们将研究以下模式和技术:

领域事件

触发跨一致性边界的工作流。

消息总线

提供了一个统一的方式从任何端点调用用例。

CQRS

分离读和写避免了事件驱动架构中的尴尬妥协,并实现了性能和可扩展性的改进。

此外,我们将添加一个依赖注入框架。这与事件驱动架构本身无关,但它整理了许多松散的尾巴。

第八章:事件和消息总线

原文:8: Events and the Message Bus

译者:飞龙

协议:CC BY-NC-SA 4.0

到目前为止,我们已经花了很多时间和精力解决一个我们本可以很容易用 Django 解决的简单问题。你可能会问,增加的可测试性和表现力是否真的值得所有的努力。

然而,在实践中,我们发现搞乱我们代码库的并不是明显的功能,而是边缘的混乱。它是报告、权限和涉及无数对象的工作流。

我们的例子将是一个典型的通知要求:当我们因为库存不足而无法分配订单时,我们应该通知采购团队。他们会去解决问题,购买更多的库存,一切都会好起来。

对于第一个版本,我们的产品所有者说我们可以通过电子邮件发送警报。

让我们看看当我们需要插入一些构成我们系统很大一部分的平凡事物时,我们的架构是如何保持的。

我们将首先做最简单、最迅速的事情,并讨论为什么正是这种决定导致了我们的大泥球。

然后我们将展示如何使用领域事件模式将副作用与我们的用例分离,并如何使用简单的消息总线模式来触发基于这些事件的行为。我们将展示一些创建这些事件的选项以及如何将它们传递给消息总线,最后我们将展示如何修改工作单元模式以优雅地将这两者连接在一起,正如图 8-1 中预览的那样。

apwp 0801

图 8-1:事件在系统中流动
提示

本章的代码在 GitHub 的 chapter_08_events_and_message_bus 分支中(https://oreil.ly/M-JuL)

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_08_events_and_message_bus
# or to code along, checkout the previous chapter:
git checkout chapter_07_aggregate

避免搞乱

所以。当我们库存不足时发送电子邮件提醒。当我们有新的要求,比如真的与核心领域无关的要求时,很容易开始将这些东西倒入我们的网络控制器中。

首先,让我们避免把我们的网络控制器搞乱

作为一次性的黑客,这可能还可以:

只是把它放在端点上——会有什么问题吗?(src/allocation/entrypoints/flask_app.py)

@app.route("/allocate", methods=['POST'])
def allocate_endpoint():
    line = model.OrderLine(
        request.json['orderid'],
        request.json['sku'],
        request.json['qty'],
    )
    try:
        uow = unit_of_work.SqlAlchemyUnitOfWork()
        batchref = services.allocate(line, uow)
    except (model.OutOfStock, services.InvalidSku) as e:
        send_mail(
            'out of stock',
            'stock_admin@made.com',
            f'{line.orderid} - {line.sku}'
        )
        return jsonify({'message': str(e)}), 400

    return jsonify({'batchref': batchref}), 201

...但很容易看出,我们很快就会因为这样修补东西而陷入混乱。发送电子邮件不是我们的 HTTP 层的工作,我们希望能够对这个新功能进行单元测试。

还有,让我们不要搞乱我们的模型

假设我们不想把这段代码放到我们的网络控制器中,因为我们希望它们尽可能薄,我们可以考虑把它放在源头,即模型中:

我们模型中的发送电子邮件代码也不够好(src/allocation/domain/model.py)

    def allocate(self, line: OrderLine) -> str:
        try:
            batch = next(
                b for b in sorted(self.batches) if b.can_allocate(line)
            )
            #...
        except StopIteration:
            email.send_mail('stock@made.com', f'Out of stock for {line.sku}')
            raise OutOfStock(f'Out of stock for sku {line.sku}')

但这甚至更糟!我们不希望我们的模型对email.send_mail这样的基础设施问题有任何依赖。

这个发送电子邮件的东西是不受欢迎的混乱,破坏了我们系统的干净流程。我们希望的是保持我们的领域模型专注于规则“你不能分配比实际可用的东西更多”。

领域模型的工作是知道我们的库存不足,但发送警报的责任属于其他地方。我们应该能够打开或关闭此功能,或者切换到短信通知,而无需更改我们领域模型的规则。

或者服务层!

要求“尝试分配一些库存,并在失败时发送电子邮件”是工作流编排的一个例子:这是系统必须遵循的一组步骤,以实现一个目标。

我们编写了一个服务层来为我们管理编排,但即使在这里,这个功能也感觉不合适:

而在服务层,它是不合适的(src/allocation/service_layer/services.py)

def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f'Invalid sku {line.sku}')
        try:
            batchref = product.allocate(line)
            uow.commit()
            return batchref
        except model.OutOfStock:
            email.send_mail('stock@made.com', f'Out of stock for {line.sku}')
            raise

捕获异常并重新引发它?这可能更糟,但肯定会让我们不开心。为什么这么难找到一个合适的家来放置这段代码呢?

单一责任原则

实际上,这是单一责任原则(SRP)的违反。¹我们的用例是分配。我们的端点、服务函数和领域方法都称为allocate,而不是allocate_and_send_mail_if_out_of_stock

提示

经验法则:如果你不能在不使用“然后”或“和”这样的词语描述你的函数做什么,你可能会违反 SRP。

SRP 的一个表述是每个类只应该有一个改变的原因。当我们从电子邮件切换到短信时,我们不应该更新我们的allocate()函数,因为这显然是一个单独的责任。

为了解决这个问题,我们将把编排分成单独的步骤,这样不同的关注点就不会纠缠在一起。²领域模型的工作是知道我们的库存不足,但发送警报的责任属于其他地方。我们应该能够随时打开或关闭此功能,或者切换到短信通知,而无需更改我们领域模型的规则。

我们也希望保持服务层不受实现细节的影响。我们希望将依赖倒置原则应用于通知,使我们的服务层依赖于一个抽象,就像我们通过使用工作单元来避免依赖于数据库一样。

全体乘客上车!

我们要介绍的模式是领域事件消息总线。我们可以以几种方式实现它们,所以我们将展示一些方式,然后选择我们最喜欢的方式。

模型记录事件

首先,我们的模型不再关心电子邮件,而是负责记录事件——关于已发生事情的事实。我们将使用消息总线来响应事件并调用新的操作。

事件是简单的数据类

事件是一种值对象。事件没有任何行为,因为它们是纯数据结构。我们总是用领域的语言命名事件,并将其视为我们领域模型的一部分。

我们可以将它们存储在model.py中,但我们可能会将它们保留在它们自己的文件中(现在可能是考虑重构出一个名为domain的目录的好时机,这样我们就有domain/model.pydomain/events.py):

事件类(src/allocation/domain/events.py

from dataclasses import dataclass


class Event:  #(1)
    pass


@dataclass
class OutOfStock(Event):  #(2)
    sku: str

一旦我们有了一些事件,我们会发现有一个可以存储共同属性的父类很有用。它对于我们消息总线中的类型提示也很有用,您很快就会看到。

dataclasses对于领域事件也很好。

模型引发事件

当我们的领域模型记录发生的事实时,我们说它引发了一个事件。

从外部看,它将是这样的;如果我们要求Product分配但无法分配,它应该引发一个事件:

测试我们的聚合以引发事件(tests/unit/test_product.py

def test_records_out_of_stock_event_if_cannot_allocate():
    batch = Batch("batch1", "SMALL-FORK", 10, eta=today)
    product = Product(sku="SMALL-FORK", batches=[batch])
    product.allocate(OrderLine("order1", "SMALL-FORK", 10))

    allocation = product.allocate(OrderLine("order2", "SMALL-FORK", 1))
    assert product.events[-1] == events.OutOfStock(sku="SMALL-FORK")  #(1)
    assert allocation is None

我们的聚合将公开一个名为.events的新属性,其中包含关于发生了什么事情的事实列表,以Event对象的形式。

模型在内部的样子如下:

模型引发领域事件(src/allocation/domain/model.py

class Product:
    def __init__(self, sku: str, batches: List[Batch], version_number: int = 0):
        self.sku = sku
        self.batches = batches
        self.version_number = version_number
        self.events = []  # type: List[events.Event]  #(1)

    def allocate(self, line: OrderLine) -> str:
        try:
            #...
        except StopIteration:
            self.events.append(events.OutOfStock(line.sku))  #(2)
            # raise OutOfStock(f"Out of stock for sku {line.sku}")  #(3)
            return None

这是我们新的.events属性的用法。

我们不是直接调用一些发送电子邮件的代码,而是在事件发生的地方记录这些事件,只使用领域的语言。

我们还将停止为缺货情况引发异常。事件将执行异常的工作。

注意

实际上,我们正在解决到目前为止我们一直存在的代码异味,即我们一直在使用异常进行控制流。一般来说,如果你正在实现领域事件,不要引发异常来描述相同的领域概念。正如你将在稍后处理工作单元模式中处理事件时所看到的,必须同时考虑事件和异常是令人困惑的。

消息总线将事件映射到处理程序

消息总线基本上是说:“当我看到这个事件时,我应该调用以下处理程序函数。”换句话说,这是一个简单的发布-订阅系统。处理程序订阅接收事件,我们将其发布到总线上。听起来比实际困难,我们通常用字典来实现它:

简单消息总线(src/allocation/service_layer/messagebus.py

def handle(event: events.Event):
    for handler in HANDLERS[type(event)]:
        handler(event)

def send_out_of_stock_notification(event: events.OutOfStock):
    email.send_mail(
        'stock@made.com',
        f'Out of stock for {event.sku}',
    )

HANDLERS = {
    events.OutOfStock: [send_out_of_stock_notification],

}  # type: Dict[Type[events.Event], List[Callable]]
注意

请注意,实现的消息总线并不会给我们并发性,因为一次只有一个处理程序会运行。我们的目标不是支持并行线程,而是在概念上分离任务,并尽可能使每个 UoW 尽可能小。这有助于我们理解代码库,因为每个用例的运行“配方”都写在一个地方。请参阅以下侧边栏。

选项 1:服务层从模型中获取事件并将其放在消息总线上

我们的领域模型会触发事件,我们的消息总线会在事件发生时调用正确的处理程序。现在我们需要的是连接这两者。我们需要某种方式来捕捉模型中的事件并将它们传递给消息总线——发布步骤。

最简单的方法是在我们的服务层中添加一些代码:

具有显式消息总线的服务层(src/allocation/service_layer/services.py

from . import messagebus
...

def allocate(
    orderid: str, sku: str, qty: int,
    uow: unit_of_work.AbstractUnitOfWork,
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f"Invalid sku {line.sku}")
        try:  #(1)
            batchref = product.allocate(line)
            uow.commit()
            return batchref
        finally:  #(1)
            messagebus.handle(product.events)  #(2)

我们保留了我们丑陋的早期实现中的try/finally(我们还没有摆脱所有异常,只是OutOfStock)。

但现在,服务层不再直接依赖于电子邮件基础设施,而是负责将模型中的事件传递到消息总线。

这已经避免了我们在天真的实现中遇到的一些丑陋,我们有几个系统都是这样工作的,其中服务层明确地从聚合中收集事件并将其传递给消息总线。

选项 2:服务层引发自己的事件

我们使用的另一种变体是让服务层负责直接创建和触发事件,而不是由领域模型触发:

服务层直接调用 messagebus.handle(src/allocation/service_layer/services.py

def allocate(
    orderid: str, sku: str, qty: int,
    uow: unit_of_work.AbstractUnitOfWork,
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f"Invalid sku {line.sku}")
        batchref = product.allocate(line)
        uow.commit() #(1)

        if batchref is None:
            messagebus.handle(events.OutOfStock(line.sku))
        return batchref

与以前一样,即使我们无法分配,我们也会提交,因为这样代码更简单,更容易理解:除非出现问题,我们总是提交。在我们没有做任何更改时提交是安全的,并且保持代码整洁。

同样,我们的生产应用程序以这种方式实现了该模式。对你来说,适合你的方式将取决于你面临的特定权衡,但我们想向你展示我们认为最优雅的解决方案,即让工作单元负责收集和引发事件。

选项 3:UoW 将事件发布到消息总线

UoW 已经有了try/finally,并且它知道当前正在使用的所有聚合,因为它提供对存储库的访问。因此,这是一个很好的地方来发现事件并将它们传递给消息总线:

UoW 遇到消息总线(src/allocation/service_layer/unit_of_work.py

class AbstractUnitOfWork(abc.ABC):
    ...

    def commit(self):
        self._commit()  #(1)
        self.publish_events()  #(2)

    def publish_events(self):  #(2)
        for product in self.products.seen:  #(3)
            while product.events:
                event = product.events.pop(0)
                messagebus.handle(event)

    @abc.abstractmethod
    def _commit(self):
        raise NotImplementedError

...

class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
    ...

    def _commit(self):  #(1)
        self.session.commit()

我们将更改我们的提交方法,要求子类需要一个私有的._commit()方法。

提交后,我们遍历存储库所见的所有对象,并将它们的事件传递给消息总线。

这依赖于存储库跟踪已使用新属性.seen加载的聚合,正如您将在下一个清单中看到的那样。

注意

您是否想知道如果其中一个处理程序失败会发生什么?我们将在第十章中详细讨论错误处理。

存储库跟踪通过它的聚合(src/allocation/adapters/repository.py

class AbstractRepository(abc.ABC):
    def __init__(self):
        self.seen = set()  # type: Set[model.Product]  #(1)

    def add(self, product: model.Product):  #(2)
        self._add(product)
        self.seen.add(product)

    def get(self, sku) -> model.Product:  #(3)
        product = self._get(sku)
        if product:
            self.seen.add(product)
        return product

    @abc.abstractmethod
    def _add(self, product: model.Product):  #(2)
        raise NotImplementedError

    @abc.abstractmethod  #(3)
    def _get(self, sku) -> model.Product:
        raise NotImplementedError


class SqlAlchemyRepository(AbstractRepository):
    def __init__(self, session):
        super().__init__()
        self.session = session

    def _add(self, product):  #(2)
        self.session.add(product)

    def _get(self, sku):  #(3)
        return self.session.query(model.Product).filter_by(sku=sku).first()

为了使 UoW 能够发布新事件,它需要能够询问存储库在此会话期间使用了哪些Product对象。我们使用一个名为.seenset来存储它们。这意味着我们的实现需要调用super().__init__()

add()方法将事物添加到.seen,现在需要子类实现._add()

同样,.get()委托给一个._get()函数,由子类实现,以捕获已看到的对象。

注意

使用*._underscorey()*方法和子类化绝对不是您可以实现这些模式的唯一方式。在本章中尝试一下读者练习,并尝试一些替代方法。

在这种方式下,UoW 和存储库协作,自动跟踪实时对象并处理它们的事件后,服务层可以完全摆脱事件处理方面的问题:

服务层再次清洁(src/allocation/service_layer/services.py

def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f'Invalid sku {line.sku}')
        batchref = product.allocate(line)
        uow.commit()
        return batchref

我们还必须记住在服务层更改伪造品并在正确的位置调用super(),并实现下划线方法,但更改是最小的:

需要调整服务层伪造(tests/unit/test_services.py

class FakeRepository(repository.AbstractRepository):

    def __init__(self, products):
        super().__init__()
        self._products = set(products)

    def _add(self, product):
        self._products.add(product)

    def _get(self, sku):
        return next((p for p in self._products if p.sku == sku), None)

...

class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):
    ...

    def _commit(self):
        self.committed = True

您可能开始担心维护这些伪造品将成为一项维护负担。毫无疑问,这是一项工作,但根据我们的经验,这并不是很多工作。一旦您的项目启动并运行,存储库和 UoW 抽象的接口实际上并不会有太大变化。如果您使用 ABCs,它们将在事情变得不同步时提醒您。

总结

领域事件为我们提供了一种处理系统中工作流程的方式。我们经常发现,听取我们的领域专家的意见,他们以因果或时间方式表达需求,例如,“当我们尝试分配库存但没有可用时,我们应该向采购团队发送电子邮件。”

“当 X 时,然后 Y”这几个神奇的词经常告诉我们关于我们可以在系统中具体化的事件。在我们的模型中将事件作为第一类事物对我们有助于使我们的代码更具可测试性和可观察性,并有助于隔离关注点。

和表 8-1 显示了我们认为的权衡。

表 8-1. 领域事件:权衡

优点 缺点
当我们必须对请求做出多个动作的响应时,消息总线为我们提供了一种很好的分离责任的方式。 消息总线是一个额外的需要理解的东西;我们的实现中,工作单元为我们引发事件是巧妙但也是神奇的。当我们调用commit时,我们并不明显地知道我们还将去发送电子邮件给人们。
事件处理程序与“核心”应用程序逻辑很好地解耦,这样以后更改它们的实现就变得很容易。 更重要的是,隐藏的事件处理代码执行同步,这意味着直到所有事件的处理程序完成为止,您的服务层函数才能完成。这可能会在您的 Web 端点中引起意外的性能问题(添加异步处理是可能的,但会使事情变得更加混乱)。
领域事件是模拟现实世界的一种好方法,我们可以在与利益相关者建模时将其作为我们的业务语言的一部分使用。 更一般地说,基于事件驱动的工作流可能会令人困惑,因为在事物被分割到多个处理程序链之后,系统中就没有一个单一的地方可以理解请求将如何被满足。
你还会面临事件处理程序之间的循环依赖和无限循环的可能性。

事件不仅仅用于发送电子邮件。在第七章中,我们花了很多时间说服你应该定义聚合,或者我们保证一致性的边界。人们经常问,“如果我需要在一个请求的过程中更改多个聚合,我该怎么办?”现在我们有了回答这个问题所需的工具。

如果我们有两个可以在事务上隔离的事物(例如,订单和产品),那么我们可以通过使用事件使它们最终一致。当订单被取消时,我们应该找到为其分配的产品并移除这些分配。

在第九章中,我们将更详细地研究这个想法,因为我们将使用我们的新消息总线构建一个更复杂的工作流。

¹这个原则是SOLID中的S

²我们的技术审阅员 Ed Jung 喜欢说,从命令式到基于事件的流程控制的转变将以前的编排变成了编舞

第九章:深入研究消息总线

原文:9: Going to Town on the Message Bus

译者:飞龙

协议:CC BY-NC-SA 4.0

在本章中,我们将开始使事件对我们应用程序的内部结构更加重要。我们将从图 9-1 中的当前状态开始,其中事件是一个可选的副作用…

apwp 0901

图 9-1:之前:消息总线是一个可选的附加组件

…到图 9-2 中的情况,所有内容都通过消息总线进行,我们的应用程序已经从根本上转变为消息处理器。

apwp 0902

图 9-2:消息总线现在是服务层的主要入口点
提示

本章的代码在 GitHub 的 chapter_09_all_messagebus 分支中查看

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_09_all_messagebus
# or to code along, checkout the previous chapter:
git checkout chapter_08_events_and_message_bus

一个新需求引领我们走向新的架构

Rich Hickey 谈到了定位软件,指的是长时间运行,管理真实世界流程的软件。例子包括仓库管理系统、物流调度器和工资系统。

这个软件很难编写,因为在现实世界的物理对象和不可靠的人类中经常发生意外。例如:

  • 在盘点期间,我们发现三个SPRINGY-MATTRESS被漏水的屋顶水损坏了。

  • 一批RELIABLE-FORK缺少所需的文件,并被海关扣押了几周。随后有三个RELIABLE-FORK未能通过安全测试并被销毁。

  • 全球缎带短缺意味着我们无法制造下一批SPARKLY-BOOKCASE

在这些情况下,我们了解到当批次已经在系统中时需要更改批次数量。也许有人在清单中弄错了数字,或者也许有些沙发从卡车上掉下来。在与业务的对话后,¹我们将情况建模为图 9-3 中的情况。

apwp 0903

图 9-3:批次数量更改意味着取消分配和重新分配
[ditaa, apwp_0903]
+----------+    /----\      +------------+       +--------------------+
| Batch    |--> |RULE| -->  | Deallocate | ----> | AllocationRequired |
| Quantity |    \----/      +------------+-+     +--------------------+-+
| Changed  |                  | Deallocate | ----> | AllocationRequired |
+----------+                  +------------+-+     +--------------------+-+
                                | Deallocate | ----> | AllocationRequired |
                                +------------+       +--------------------+

我们将称之为BatchQuantityChanged的事件应该导致我们改变批次的数量,是的,但也要应用业务规则:如果新数量降到已分配的总数以下,我们需要从该批次取消分配这些订单。然后每个订单将需要新的分配,我们可以将其捕获为AllocationRequired事件。

也许你已经预料到我们的内部消息总线和事件可以帮助实现这一要求。我们可以定义一个名为change_batch_quantity的服务,它知道如何调整批次数量,还知道如何取消分配任何多余的订单行,然后每个取消分配可以发出一个AllocationRequired事件,可以在单独的事务中转发给现有的allocate服务。再一次,我们的消息总线帮助我们执行单一责任原则,并且它允许我们对事务和数据完整性做出选择。

想象一种架构变化:一切都将成为事件处理程序

但在我们着手之前,想想我们要走向何方。我们的系统有两种流程:

  • 由服务层函数处理的 API 调用

  • 内部事件(可能作为服务层函数的副作用引发)及其处理程序(反过来调用服务层函数)

如果一切都是事件处理程序,会不会更容易?如果我们重新思考我们的 API 调用作为捕获事件,服务层函数也可以成为事件处理程序,我们不再需要区分内部和外部事件处理程序:

  • services.allocate()可能是AllocationRequired事件的处理程序,并且可以发出Allocated事件作为其输出。

  • services.add_batch()可能是BatchCreated事件的处理程序。²

我们的新需求将符合相同的模式:

  • 名为BatchQuantityChanged的事件可以调用名为change_batch_quantity()的处理程序。

  • 它可能引发的新AllocationRequired事件也可以传递给services.allocate(),因此从 API 中产生的全新分配和内部由取消分配触发的重新分配之间没有概念上的区别。

听起来有点多?让我们逐渐朝着这个方向努力。我们将遵循预备重构工作流程,又称“使变化变得容易;然后进行容易的变化”:

  1. 我们将我们的服务层重构为事件处理程序。我们可以习惯于事件是描述系统输入的方式。特别是,现有的services.allocate()函数将成为名为AllocationRequired的事件的处理程序。

  2. 我们构建了一个端到端测试,将BatchQuantityChanged事件放入系统,并查找输出的Allocated事件。

  3. 我们的实现在概念上将非常简单:BatchQuantityChanged事件的新处理程序,其实现将发出AllocationRequired事件,然后将由 API 使用的分配的确切相同的处理程序处理。

在此过程中,我们将对消息总线和 UoW 进行小的调整,将将新事件放入消息总线的责任移到消息总线本身。

将服务函数重构为消息处理程序

我们首先定义了两个事件,捕捉我们当前的 API 输入——AllocationRequiredBatchCreated

BatchCreated 和 AllocationRequired 事件(src/allocation/domain/events.py

@dataclass
class BatchCreated(Event):
    ref: str
    sku: str
    qty: int
    eta: Optional[date] = None

...

@dataclass
class AllocationRequired(Event):
    orderid: str
    sku: str
    qty: int

然后我们将services.py重命名为handlers.py;我们添加了send_out_of_stock_notification的现有消息处理程序;最重要的是,我们更改了所有处理程序,使它们具有相同的输入,即事件和 UoW:

处理程序和服务是相同的东西(src/allocation/service_layer/handlers.py

def add_batch(
        event: events.BatchCreated, uow: unit_of_work.AbstractUnitOfWork
):
    with uow:
        product = uow.products.get(sku=event.sku)
        ...

def allocate(
        event: events.AllocationRequired, uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(event.orderid, event.sku, event.qty)
    ...

def send_out_of_stock_notification(
        event: events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork,
):
    email.send(
        'stock@made.com',
        f'Out of stock for {event.sku}',
    )

这个变化可能更清晰地显示为一个差异:

从服务更改为处理程序(src/allocation/service_layer/handlers.py

 def add_batch(
-        ref: str, sku: str, qty: int, eta: Optional[date],
-        uow: unit_of_work.AbstractUnitOfWork
+        event: events.BatchCreated, uow: unit_of_work.AbstractUnitOfWork
 ):
     with uow:
-        product = uow.products.get(sku=sku)
+        product = uow.products.get(sku=event.sku)
     ...

 def allocate(
-        orderid: str, sku: str, qty: int,
-        uow: unit_of_work.AbstractUnitOfWork
+        event: events.AllocationRequired, uow: unit_of_work.AbstractUnitOfWork
 ) -> str:
-    line = OrderLine(orderid, sku, qty)
+    line = OrderLine(event.orderid, event.sku, event.qty)
     ...

+
+def send_out_of_stock_notification(
+        event: events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork,
+):
+    email.send(
     ...

在此过程中,我们使我们的服务层 API 更加结构化和一致。它曾经是一堆原语,现在使用了定义明确的对象(见下面的侧边栏)。

消息总线现在从 UoW 收集事件

我们的事件处理程序现在需要一个 UoW。此外,随着我们的消息总线变得更加核心于我们的应用程序,明智的做法是明确地负责收集和处理新事件。到目前为止,UoW 和消息总线之间存在一种循环依赖,因此这将使其成为单向:

处理接受 UoW 并管理队列(src/allocation/service_layer/messagebus.py

def handle(
    event: events.Event,
    uow: unit_of_work.AbstractUnitOfWork,  #(1)
):
    queue = [event]  #(2)
    while queue:
        event = queue.pop(0)  #(3)
        for handler in HANDLERS[type(event)]:  #(3)
            handler(event, uow=uow)  #(4)
            queue.extend(uow.collect_new_events())  #(5)

每次启动时,消息总线现在都会传递 UoW。

当我们开始处理我们的第一个事件时,我们启动一个队列。

我们从队列的前面弹出事件并调用它们的处理程序(HANDLERS字典没有改变;它仍将事件类型映射到处理程序函数)。

消息总线将 UoW 传递给每个处理程序。

每个处理程序完成后,我们收集生成的任何新事件,并将它们添加到队列中。

unit_of_work.py中,publish_events()变成了一个不太活跃的方法,collect_new_events()

UoW 不再直接将事件放入总线(src/allocation/service_layer/unit_of_work.py

-from . import messagebus  #(1)


 class AbstractUnitOfWork(abc.ABC):
@@ -22,13 +21,11 @@ class AbstractUnitOfWork(abc.ABC):

     def commit(self):
         self._commit()
-        self.publish_events()  #(2)

-    def publish_events(self):
+    def collect_new_events(self):
         for product in self.products.seen:
             while product.events:
-                event = product.events.pop(0)
-                messagebus.handle(event)
+                yield product.events.pop(0)  #(3)

unit_of_work模块现在不再依赖于messagebus

我们不再自动在提交时publish_events。消息总线现在跟踪事件队列。

而 UoW 不再主动将事件放在消息总线上;它只是使它们可用。

我们的测试也都是以事件为基础编写的

我们的测试现在通过创建事件并将它们放在消息总线上来操作,而不是直接调用服务层函数:

处理程序测试使用事件(tests/unit/test_handlers.py

 class TestAddBatch:

     def test_for_new_product(self):
         uow = FakeUnitOfWork()
-        services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow)
+        messagebus.handle(
+            events.BatchCreated("b1", "CRUNCHY-ARMCHAIR", 100, None), uow
+        )
         assert uow.products.get("CRUNCHY-ARMCHAIR") is not None
         assert uow.committed

...

 class TestAllocate:

     def test_returns_allocation(self):
         uow = FakeUnitOfWork()
-        services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow)
-        result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow)
+        messagebus.handle(
+            events.BatchCreated("batch1", "COMPLICATED-LAMP", 100, None), uow
+        )
+        result = messagebus.handle(
+            events.AllocationRequired("o1", "COMPLICATED-LAMP", 10), uow
+        )
         assert result == "batch1"

临时丑陋的黑客:消息总线必须返回结果

我们的 API 和服务层目前希望在调用我们的allocate()处理程序时知道分配的批次参考。这意味着我们需要在我们的消息总线上进行临时修改,让它返回事件:

消息总线返回结果(src/allocation/service_layer/messagebus.py

 def handle(event: events.Event, uow: unit_of_work.AbstractUnitOfWork):
+    results = []
     queue = [event]
     while queue:
         event = queue.pop(0)
         for handler in HANDLERS[type(event)]:
-            handler(event, uow=uow)
+            results.append(handler(event, uow=uow))
             queue.extend(uow.collect_new_events())
+    return results

这是因为我们在系统中混合了读取和写入责任。我们将在第十二章中回来修复这个瑕疵。

修改我们的 API 以与事件配合使用

Flask 将消息总线更改为差异(src/allocation/entrypoints/flask_app.py

 @app.route("/allocate", methods=["POST"])
 def allocate_endpoint():
     try:
-        batchref = services.allocate(
-            request.json["orderid"],  #(1)
-            request.json["sku"],
-            request.json["qty"],
-            unit_of_work.SqlAlchemyUnitOfWork(),
+        event = events.AllocationRequired(  #(2)
+            request.json["orderid"], request.json["sku"], request.json["qty"]
         )
+        results = messagebus.handle(event, unit_of_work.SqlAlchemyUnitOfWork())  #(3)
+        batchref = results.pop(0)
     except InvalidSku as e:

而不是使用从请求 JSON 中提取的一堆基元调用服务层…

我们实例化一个事件。

然后我们将其传递给消息总线。

我们应该回到一个完全功能的应用程序,但现在是完全事件驱动的:

  • 曾经是服务层函数现在是事件处理程序。

  • 这使它们与我们的领域模型引发的内部事件处理的函数相同。

  • 我们使用事件作为捕获系统输入的数据结构,以及内部工作包的交接。

  • 整个应用现在最好被描述为消息处理器,或者如果您愿意的话,是事件处理器。我们将在下一章中讨论区别。

实施我们的新需求

我们已经完成了重构阶段。让我们看看我们是否真的“使变化变得容易”。让我们实施我们的新需求,如图 9-4 所示:我们将接收一些新的BatchQuantityChanged事件作为我们的输入,并将它们传递给一个处理程序,然后该处理程序可能会发出一些AllocationRequired事件,然后这些事件将返回到我们现有的重新分配处理程序。

apwp 0904

图 9-4:重新分配流程的序列图
[plantuml, apwp_0904, config=plantuml.cfg]
@startuml
API -> MessageBus : BatchQuantityChanged event

group BatchQuantityChanged Handler + Unit of Work 1
    MessageBus -> Domain_Model : change batch quantity
    Domain_Model -> MessageBus : emit AllocationRequired event(s)
end

group AllocationRequired Handler + Unit of Work 2 (or more)
    MessageBus -> Domain_Model : allocate
end

@enduml
警告

当您将这些事情分开到两个工作单元时,您现在有两个数据库事务,因此您会面临完整性问题:可能会发生某些事情,导致第一个事务完成,但第二个事务没有完成。您需要考虑这是否可以接受,以及是否需要注意它何时发生并采取措施。有关更多讨论,请参见“Footguns”

我们的新事件

告诉我们批次数量已更改的事件很简单;它只需要批次参考和新数量:

新事件(src/allocation/domain/events.py

@dataclass
class BatchQuantityChanged(Event):
    ref: str
    qty: int

通过测试驱动新的处理程序

遵循第四章中学到的教训,我们可以以“高速”运行,并以事件为基础的最高抽象级别编写我们的单元测试。它们可能看起来像这样:

处理程序测试用于 change_batch_quantity(tests/unit/test_handlers.py

class TestChangeBatchQuantity:
    def test_changes_available_quantity(self):
        uow = FakeUnitOfWork()
        messagebus.handle(
            events.BatchCreated("batch1", "ADORABLE-SETTEE", 100, None), uow
        )
        [batch] = uow.products.get(sku="ADORABLE-SETTEE").batches
        assert batch.available_quantity == 100  #(1)

        messagebus.handle(events.BatchQuantityChanged("batch1", 50), uow)

        assert batch.available_quantity == 50  #(1)

    def test_reallocates_if_necessary(self):
        uow = FakeUnitOfWork()
        event_history = [
            events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None),
            events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, date.today()),
            events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20),
            events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20),
        ]
        for e in event_history:
            messagebus.handle(e, uow)
        [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches
        assert batch1.available_quantity == 10
        assert batch2.available_quantity == 50

        messagebus.handle(events.BatchQuantityChanged("batch1", 25), uow)

        # order1 or order2 will be deallocated, so we'll have 25 - 20
        assert batch1.available_quantity == 5  #(2)
        # and 20 will be reallocated to the next batch
        assert batch2.available_quantity == 30  #(2)

简单情况将非常容易实现;我们只需修改数量。

但是,如果我们尝试将数量更改为少于已分配的数量,我们将需要至少取消分配一个订单,并且我们期望重新分配到一个新批次。

实施

我们的新处理程序非常简单:

处理程序委托给模型层(src/allocation/service_layer/handlers.py

def change_batch_quantity(
        event: events.BatchQuantityChanged, uow: unit_of_work.AbstractUnitOfWork
):
    with uow:
        product = uow.products.get_by_batchref(batchref=event.ref)
        product.change_batch_quantity(ref=event.ref, qty=event.qty)
        uow.commit()

我们意识到我们将需要在我们的存储库上有一个新的查询类型:

我们的存储库上有一个新的查询类型(src/allocation/adapters/repository.py

class AbstractRepository(abc.ABC):
    ...

    def get(self, sku) -> model.Product:
        ...

    def get_by_batchref(self, batchref) -> model.Product:
        product = self._get_by_batchref(batchref)
        if product:
            self.seen.add(product)
        return product

    @abc.abstractmethod
    def _add(self, product: model.Product):
        raise NotImplementedError

    @abc.abstractmethod
    def _get(self, sku) -> model.Product:
        raise NotImplementedError

    @abc.abstractmethod
    def _get_by_batchref(self, batchref) -> model.Product:
        raise NotImplementedError
    ...

class SqlAlchemyRepository(AbstractRepository):
    ...

    def _get(self, sku):
        return self.session.query(model.Product).filter_by(sku=sku).first()

    def _get_by_batchref(self, batchref):
        return self.session.query(model.Product).join(model.Batch).filter(
            orm.batches.c.reference == batchref,
        ).first()

还有我们的FakeRepository

也更新了虚假存储库(tests/unit/test_handlers.py

class FakeRepository(repository.AbstractRepository):
    ...

    def _get(self, sku):
        return next((p for p in self._products if p.sku == sku), None)

    def _get_by_batchref(self, batchref):
        return next((
            p for p in self._products for b in p.batches
            if b.reference == batchref
        ), None)
注意

我们正在向我们的存储库添加一个查询,以使这个用例更容易实现。只要我们的查询返回单个聚合,我们就不会违反任何规则。如果你发现自己在存储库上编写复杂的查询,你可能需要考虑不同的设计。特别是像get_most_popular_productsfind_products_by_order_id这样的方法肯定会触发我们的警觉。第十一章和结语中有一些关于管理复杂查询的提示。

领域模型上的新方法

我们向模型添加了新的方法,该方法在内部进行数量更改和取消分配,并发布新事件。我们还修改了现有的分配函数以发布事件:

我们的模型发展以满足新的需求(src/allocation/domain/model.py

class Product:
    ...

    def change_batch_quantity(self, ref: str, qty: int):
        batch = next(b for b in self.batches if b.reference == ref)
        batch._purchased_quantity = qty
        while batch.available_quantity < 0:
            line = batch.deallocate_one()
            self.events.append(
                events.AllocationRequired(line.orderid, line.sku, line.qty)
            )
...

class Batch:
    ...

    def deallocate_one(self) -> OrderLine:
        return self._allocations.pop()

我们连接了我们的新处理程序:

消息总线增长(src/allocation/service_layer/messagebus.py

HANDLERS = {
    events.BatchCreated: [handlers.add_batch],
    events.BatchQuantityChanged: [handlers.change_batch_quantity],
    events.AllocationRequired: [handlers.allocate],
    events.OutOfStock: [handlers.send_out_of_stock_notification],

}  # type: Dict[Type[events.Event], List[Callable]]

而且我们的新需求已经完全实现了。

可选:使用虚假消息总线孤立地对事件处理程序进行单元测试

我们对重新分配工作流的主要测试是边缘到边缘(参见“测试驱动新处理程序”中的示例代码)。它使用真实的消息总线,并测试整个流程,其中BatchQuantityChanged事件处理程序触发取消分配,并发出新的AllocationRequired事件,然后由它们自己的处理程序处理。一个测试覆盖了一系列多个事件和处理程序。

根据您的事件链的复杂性,您可能会决定要对一些处理程序进行孤立测试。您可以使用“虚假”消息总线来实现这一点。

在我们的情况下,我们实际上通过修改FakeUnitOfWork上的publish_events()方法来进行干预,并将其与真实消息总线解耦,而是让它记录它看到的事件:

在 UoW 中实现的虚假消息总线(tests/unit/test_handlers.py

class FakeUnitOfWorkWithFakeMessageBus(FakeUnitOfWork):

    def __init__(self):
        super().__init__()
        self.events_published = []  # type: List[events.Event]

    def publish_events(self):
        for product in self.products.seen:
            while product.events:
                self.events_published.append(product.events.pop(0))

现在当我们使用FakeUnitOfWorkWithFakeMessageBus调用messagebus.handle()时,它只运行该事件的处理程序。因此,我们可以编写更加孤立的单元测试:不再检查所有的副作用,而是只检查BatchQuantityChanged是否导致已分配的总量下降到以下AllocationRequired

在孤立环境中测试重新分配(tests/unit/test_handlers.py

def test_reallocates_if_necessary_isolated():
    uow = FakeUnitOfWorkWithFakeMessageBus()

    # test setup as before
    event_history = [
        events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None),
        events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, date.today()),
        events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20),
        events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20),
    ]
    for e in event_history:
        messagebus.handle(e, uow)
    [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches
    assert batch1.available_quantity == 10
    assert batch2.available_quantity == 50

    messagebus.handle(events.BatchQuantityChanged("batch1", 25), uow)

    # assert on new events emitted rather than downstream side-effects
    [reallocation_event] = uow.events_published
    assert isinstance(reallocation_event, events.AllocationRequired)
    assert reallocation_event.orderid in {'order1', 'order2'}
    assert reallocation_event.sku == 'INDIFFERENT-TABLE'

是否要这样做取决于您的事件链的复杂性。我们建议,首先进行边缘到边缘的测试,只有在必要时才使用这种方法。

总结

让我们回顾一下我们取得了什么成就,并思考为什么我们这样做。

我们取得了什么成就?

事件是简单的数据类,定义了我们系统中的输入和内部消息的数据结构。从 DDD 的角度来看,这是非常强大的,因为事件通常在业务语言中表达得非常好(如果你还没有了解事件风暴,请查阅)。

处理程序是我们对事件做出反应的方式。它们可以调用我们的模型或调用外部服务。如果需要,我们可以为单个事件定义多个处理程序。处理程序还可以触发其他事件。这使我们可以非常细粒度地控制处理程序的操作,并真正遵守 SRP。

我们取得了什么成就?

我们使用这些架构模式的持续目标是尝试使我们应用程序的复杂性增长速度比其大小慢。当我们全力投入消息总线时,我们总是在架构复杂性方面付出代价(参见表 9-1),但我们为自己购买了一种可以处理几乎任意复杂需求的模式,而无需对我们做事情的方式进行任何进一步的概念或架构变化。

在这里,我们增加了一个相当复杂的用例(更改数量,取消分配,启动新事务,重新分配,发布外部通知),但在架构上,复杂性没有成本。我们增加了新的事件,新的处理程序和一个新的外部适配器(用于电子邮件),所有这些都是我们架构中现有的事物类别,我们知道如何理解和推理,并且很容易向新手解释。我们的各个部分各自有一个工作,它们以明确定义的方式相互连接,没有意外的副作用。

表 9-1. 整个应用程序是一个消息总线:权衡

优点 缺点
处理程序和服务是相同的,所以这更简单。 消息总线从 Web 的角度来看仍然是一种稍微不可预测的方式。您事先不知道什么时候会结束。
我们有一个很好的数据结构,用于系统的输入。 模型对象和事件之间将存在字段和结构的重复,这将产生维护成本。向一个对象添加字段通常意味着至少向另一个对象添加字段。

现在,您可能会想知道,那些BatchQuantityChanged事件将从哪里产生?答案将在几章后揭晓。但首先,让我们谈谈事件与命令

¹ 基于事件的建模如此受欢迎,以至于出现了一种称为事件风暴的实践,用于促进基于事件的需求收集和领域模型阐述。

² 如果您对事件驱动架构有所了解,您可能会想,“其中一些事件听起来更像命令!” 请耐心等待!我们试图一次引入一个概念。在下一章中,我们将介绍命令和事件之间的区别。

³ 本章中的“简单”实现基本上使用messagebus.py模块本身来实现单例模式。

第十章:命令和命令处理程序

原文:10: Commands and Command Handler

译者:飞龙

协议:CC BY-NC-SA 4.0

在上一章中,我们谈到使用事件作为表示系统输入的一种方式,并将我们的应用程序转变为一个消息处理机器。

为了实现这一点,我们将所有的用例函数转换为事件处理程序。当 API 接收到一个创建新批次的 POST 请求时,它构建一个新的BatchCreated事件,并将其处理为内部事件。这可能感觉反直觉。毕竟,批次还没有被创建;这就是我们调用 API 的原因。我们将通过引入命令并展示它们如何通过相同的消息总线处理,但规则略有不同来解决这个概念上的问题。

提示

本章的代码在 GitHub 的 chapter_10_commands 分支中oreil.ly/U_VGa

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_10_commands
# or to code along, checkout the previous chapter:
git checkout chapter_09_all_messagebus

命令和事件

与事件一样,命令是一种消息类型——由系统的一部分发送给另一部分的指令。我们通常用愚蠢的数据结构表示命令,并且可以以与事件相同的方式处理它们。

然而,命令和事件之间的差异是重要的。

命令由一个参与者发送给另一个特定的参与者,并期望作为结果发生特定的事情。当我们向 API 处理程序发布表单时,我们正在发送一个命令。我们用祈使句动词短语命名命令,比如“分配库存”或“延迟发货”。

命令捕获意图。它们表达了我们希望系统执行某些操作的愿望。因此,当它们失败时,发送者需要接收错误信息。

事件由一个参与者广播给所有感兴趣的监听器。当我们发布BatchQuantityChanged时,我们不知道谁会接收到它。我们用过去时动词短语命名事件,比如“订单分配给库存”或“发货延迟”。

我们经常使用事件来传播关于成功命令的知识。

事件捕获过去发生的事情的事实。由于我们不知道谁在处理事件,发送者不应关心接收者成功与否。表 10-1 总结了差异。

表 10-1。事件与命令的差异

事件 命令
命名 过去式 祈使句
错误处理 独立失败 失败时有噪音
发送至 所有监听器 一个接收者

我们的系统现在有什么样的命令?

提取一些命令(src/allocation/domain/commands.py

class Command:
    pass


@dataclass
class Allocate(Command):  #(1)
    orderid: str
    sku: str
    qty: int


@dataclass
class CreateBatch(Command):  #(2)
    ref: str
    sku: str
    qty: int
    eta: Optional[date] = None


@dataclass
class ChangeBatchQuantity(Command):  #(3)
    ref: str
    qty: int

commands.Allocate将替换events.AllocationRequired

commands.CreateBatch将替换events.BatchCreated

commands.ChangeBatchQuantity将替换events.BatchQuantityChanged

异常处理的差异

只是改变名称和动词是很好的,但这不会改变我们系统的行为。我们希望以类似的方式处理事件和命令,但不完全相同。让我们看看我们的消息总线如何改变:

不同的调度事件和命令(src/allocation/service_layer/messagebus.py

Message = Union[commands.Command, events.Event]


def handle(  #(1)
    message: Message,
    uow: unit_of_work.AbstractUnitOfWork,
):
    results = []
    queue = [message]
    while queue:
        message = queue.pop(0)
        if isinstance(message, events.Event):
            handle_event(message, queue, uow)  #(2)
        elif isinstance(message, commands.Command):
            cmd_result = handle_command(message, queue, uow)  #(2)
            results.append(cmd_result)
        else:
            raise Exception(f"{message} was not an Event or Command")
    return results

它仍然有一个主要的handle()入口,接受一个message,它可以是一个命令或一个事件。

我们将事件和命令分派给两个不同的辅助函数,如下所示。

这是我们处理事件的方式:

事件不能中断流程(src/allocation/service_layer/messagebus.py

def handle_event(
    event: events.Event,
    queue: List[Message],
    uow: unit_of_work.AbstractUnitOfWork,
):
    for handler in EVENT_HANDLERS[type(event)]:  #(1)
        try:
            logger.debug("handling event %s with handler %s", event, handler)
            handler(event, uow=uow)
            queue.extend(uow.collect_new_events())
        except Exception:
            logger.exception("Exception handling event %s", event)
            continue  #(2)

事件发送到一个分发器,可以将每个事件委派给多个处理程序。

它捕获并记录错误,但不允许它们中断消息处理。

这是我们处理命令的方式:

命令重新引发异常(src/allocation/service_layer/messagebus.py

def handle_command(
    command: commands.Command,
    queue: List[Message],
    uow: unit_of_work.AbstractUnitOfWork,
):
    logger.debug("handling command %s", command)
    try:
        handler = COMMAND_HANDLERS[type(command)]  #(1)
        result = handler(command, uow=uow)
        queue.extend(uow.collect_new_events())
        return result  #(3)
    except Exception:
        logger.exception("Exception handling command %s", command)
        raise  #(2)

命令调度程序期望每个命令只有一个处理程序。

如果引发任何错误,它们会快速失败并上升。

return result只是临时的;如“临时的丑陋的黑客:消息总线必须返回结果”中所述,这是一个临时的黑客,允许消息总线返回 API 使用的批次引用。我们将在第十二章中修复这个问题。

我们还将单个HANDLERS字典更改为命令和事件的不同字典。根据我们的约定,命令只能有一个处理程序:

新处理程序字典(src/allocation/service_layer/messagebus.py

EVENT_HANDLERS = {
    events.OutOfStock: [handlers.send_out_of_stock_notification],
}  # type: Dict[Type[events.Event], List[Callable]]

COMMAND_HANDLERS = {
    commands.Allocate: handlers.allocate,
    commands.CreateBatch: handlers.add_batch,
    commands.ChangeBatchQuantity: handlers.change_batch_quantity,
}  # type: Dict[Type[commands.Command], Callable]

讨论:事件、命令和错误处理

许多开发人员在这一点感到不舒服,并问:“当事件处理失败时会发生什么?我应该如何确保系统处于一致状态?”如果我们在messagebus.handle处理一半的事件之前由于内存不足错误而终止进程,我们如何减轻因丢失消息而引起的问题?

让我们从最坏的情况开始:我们未能处理事件,系统处于不一致状态。会导致这种情况的是什么样的错误?在我们的系统中,当只完成了一半的操作时,我们经常会陷入不一致状态。

例如,我们可以为客户的订单分配三个单位的DESIRABLE_BEANBAG,但在某种程度上未能减少剩余库存量。这将导致不一致的状态:三个单位的库存既被分配可用,这取决于你如何看待它。后来,我们可能会将这些相同的沙发床分配给另一个客户,给客户支持带来麻烦。

然而,在我们的分配服务中,我们已经采取了措施来防止发生这种情况。我们已经仔细确定了作为一致性边界的聚合,并且我们引入了一个UoW来管理对聚合的更新的原子成功或失败。

例如,当我们为订单分配库存时,我们的一致性边界是Product聚合。这意味着我们不能意外地过度分配:要么特定订单行分配给产品,要么不分配——没有不一致状态的余地。

根据定义,我们不需要立即使两个聚合保持一致,因此如果我们未能处理事件并仅更新单个聚合,我们的系统仍然可以最终保持一致。我们不应违反系统的任何约束。

有了这个例子,我们可以更好地理解将消息分割为命令和事件的原因。当用户想要让系统执行某些操作时,我们将他们的请求表示为命令。该命令应修改单个聚合,并且要么完全成功,要么完全失败。我们需要做的任何其他簿记,清理和通知都可以通过事件来进行。我们不需要事件处理程序成功才能使命令成功。

让我们看另一个例子(来自不同的、虚构的项目)来看看为什么不行。

假设我们正在构建一个销售昂贵奢侈品的电子商务网站。我们的营销部门希望奖励重复访问的客户。他们在第三次购买后将客户标记为 VIP,并且这将使他们有资格获得优先处理和特别优惠。我们对这个故事的验收标准如下:

Given a customer with two orders in their history,
When the customer places a third order,
Then they should be flagged as a VIP.

When a customer first becomes a VIP
Then we should send them an email to congratulate them

使用我们在本书中已经讨论过的技术,我们决定要构建一个新的History聚合,记录订单并在满足规则时引发领域事件。我们将按照以下结构编写代码:

VIP 客户(另一个项目的示例代码)

class History:  # Aggregate

    def __init__(self, customer_id: int):
        self.orders = set()  # Set[HistoryEntry]
        self.customer_id = customer_id

    def record_order(self, order_id: str, order_amount: int): #(1)
        entry = HistoryEntry(order_id, order_amount)

        if entry in self.orders:
            return

        self.orders.add(entry)

        if len(self.orders) == 3:
            self.events.append(
                CustomerBecameVIP(self.customer_id)
            )


def create_order_from_basket(uow, cmd: CreateOrder): #(2)
    with uow:
        order = Order.from_basket(cmd.customer_id, cmd.basket_items)
        uow.orders.add(order)
        uow.commit()  # raises OrderCreated


def update_customer_history(uow, event: OrderCreated): #(3)
    with uow:
        history = uow.order_history.get(event.customer_id)
        history.record_order(event.order_id, event.order_amount)
        uow.commit()  # raises CustomerBecameVIP


def congratulate_vip_customer(uow, event: CustomerBecameVip): #(4)
    with uow:
        customer = uow.customers.get(event.customer_id)
        email.send(
            customer.email_address,
            f'Congratulations {customer.first_name}!'
        )

History聚合捕获了指示客户何时成为 VIP 的规则。这使我们能够在未来规则变得更加复杂时处理变化。

我们的第一个处理程序为客户创建订单,并引发领域事件OrderCreated

我们的第二个处理程序更新History对象,记录已创建订单。

最后,当客户成为 VIP 时,我们会给他们发送一封电子邮件。

使用这段代码,我们可以对事件驱动系统中的错误处理有一些直觉。

在我们当前的实现中,我们在将状态持久化到数据库之后才引发关于聚合的事件。如果我们在持久化之前引发这些事件,并同时提交所有的更改,会怎样呢?这样,我们就可以确保所有的工作都已完成。这样不是更安全吗?

然而,如果电子邮件服务器稍微过载会发生什么呢?如果所有工作都必须同时完成,繁忙的电子邮件服务器可能会阻止我们接受订单的付款。

如果History聚合的实现中存在错误,会发生什么?难道因为我们无法识别您为 VIP 而放弃收取您的钱吗?

通过分离这些关注点,我们使得事情可以独立失败,这提高了系统的整体可靠性。这段代码中必须完成的部分只有创建订单的命令处理程序。这是客户关心的唯一部分,也是我们的业务利益相关者应该优先考虑的部分。

请注意,我们故意将事务边界与业务流程的开始和结束对齐。代码中使用的名称与我们的业务利益相关者使用的行话相匹配,我们编写的处理程序与我们的自然语言验收标准的步骤相匹配。名称和结构的一致性帮助我们推理系统在变得越来越大和复杂时的情况。

同步恢复错误

希望我们已经说服您,事件可以独立于引发它们的命令而失败。那么,当错误不可避免地发生时,我们应该怎么做才能确保我们能够从错误中恢复呢?

我们首先需要知道错误发生的时间,通常我们依赖日志来做到这一点。

让我们再次看看我们消息总线中的handle_event方法:

当前处理函数(src/allocation/service_layer/messagebus.py

def handle_event(
    event: events.Event,
    queue: List[Message],
    uow: unit_of_work.AbstractUnitOfWork
):
    for handler in EVENT_HANDLERS[type(event)]:
        try:
            logger.debug('handling event %s with handler %s', event, handler)
            handler(event, uow=uow)
            queue.extend(uow.collect_new_events())
        except Exception:
            logger.exception('Exception handling event %s', event)
            continue

当我们在系统中处理消息时,我们首先要做的是写入日志记录我们即将要做的事情。对于CustomerBecameVIP用例,日志可能如下所示:

Handling event CustomerBecameVIP(customer_id=12345)
with handler <function congratulate_vip_customer at 0x10ebc9a60>

因为我们选择使用数据类来表示我们的消息类型,我们可以得到一个整洁打印的摘要,其中包含了我们可以复制并粘贴到 Python shell 中以重新创建对象的传入数据。

当发生错误时,我们可以使用记录的数据来在单元测试中重现问题,或者将消息重新播放到系统中。

手动重放对于需要在重新处理事件之前修复错误的情况非常有效,但我们的系统将始终经历一定程度的瞬态故障。这包括网络故障、表死锁以及部署导致的短暂停机等情况。

对于大多数情况,我们可以通过再次尝试来优雅地恢复。正如谚语所说:“如果一开始你没有成功,就用指数增长的等待时间重试操作。”

带重试的处理(src/allocation/service_layer/messagebus.py

from tenacity import Retrying, RetryError, stop_after_attempt, wait_exponential #(1)

...

def handle_event(
    event: events.Event,
    queue: List[Message],
    uow: unit_of_work.AbstractUnitOfWork,
):
    for handler in EVENT_HANDLERS[type(event)]:
        try:
            for attempt in Retrying(  #(2)
                stop=stop_after_attempt(3),
                wait=wait_exponential()
            ):

                with attempt:
                    logger.debug("handling event %s with handler %s", event, handler)
                    handler(event, uow=uow)
                    queue.extend(uow.collect_new_events())
        except RetryError as retry_failure:
            logger.error(
                "Failed to handle event %s times, giving up!",
                retry_failure.last_attempt.attempt_number
            )
            continue

Tenacity 是一个实现重试常见模式的 Python 库。

在这里,我们配置我们的消息总线,最多重试三次,在尝试之间等待的时间会指数增长。

重试可能会失败的操作可能是改善软件弹性的最佳方法。再次,工作单元和命令处理程序模式意味着每次尝试都从一致的状态开始,并且不会留下半成品。

警告

在某个时候,无论tenacity如何,我们都必须放弃尝试处理消息。构建可靠的分布式消息系统很困难,我们必须略过一些棘手的部分。在结语中有更多参考资料的指针。

总结

在本书中,我们决定在介绍命令的概念之前先介绍事件的概念,但其他指南通常是相反的。通过为系统可以响应的请求命名并为它们提供自己的数据结构,是一件非常基本的事情。有时你会看到人们使用“命令处理程序”模式来描述我们在事件、命令和消息总线中所做的事情。

表 10-2 讨论了在你加入之前应该考虑的一些事情。

表 10-2。分割命令和事件:权衡利弊

优点 缺点
将命令和事件区分对待有助于我们理解哪些事情必须成功,哪些事情可以稍后整理。 命令和事件之间的语义差异可能是微妙的。对于这些差异可能会有很多争论。
CreateBatch绝对比BatchCreated更清晰。我们明确了用户的意图,而明确比隐含更好,对吧? 我们明确地邀请失败。我们知道有时会出现问题,我们选择通过使失败变得更小更隔离来处理这些问题。这可能会使系统更难以理解,并需要更好的监控。

在第十一章中,我们将讨论使用事件作为集成模式。

第十一章:事件驱动架构:使用事件集成微服务

原文:11: Event-Driven Architecture: Using Events to Integrate Microservices

译者:飞龙

协议:CC BY-NC-SA 4.0

在前一章中,我们实际上从未讨论过我们将如何接收“批量数量更改”事件,或者如何通知外部世界有关重新分配的情况。

我们有一个带有 Web API 的微服务,但是如何与其他系统进行通信呢?如果,比如说,发货延迟或数量被修改,我们将如何知道?我们将如何告诉仓库系统已经分配了订单并需要发送给客户?

在本章中,我们想展示事件隐喻如何扩展到涵盖我们处理系统中的传入和传出消息的方式。在内部,我们应用的核心现在是一个消息处理器。让我们跟进,使其在外部也成为一个消息处理器。如图 11-1 所示,我们的应用将通过外部消息总线(我们将使用 Redis pub/sub 队列作为示例)从外部来源接收事件,并将其输出以事件的形式发布回去。

apwp 1101

图 11-1:我们的应用是一个消息处理器
提示

本章的代码在 GitHub 的 chapter_11_external_events 分支中。GitHub 链接

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_11_external_events
# or to code along, checkout the previous chapter:
git checkout chapter_10_commands

分布式泥球和名词思维

在我们深入讨论之前,让我们谈谈其他选择。我们经常与试图构建微服务架构的工程师交谈。通常,他们正在从现有应用程序迁移,并且他们的第一反应是将系统拆分为名词

到目前为止,我们在系统中引入了哪些名词?嗯,我们有库存批次、订单、产品和客户。因此,对系统进行天真的尝试可能看起来像图 11-2(请注意,我们将系统命名为一个名词,Batches,而不是Allocation)。

apwp 1102

图 11-2:基于名词的服务的上下文图
[plantuml, apwp_1102, config=plantuml.cfg]
@startuml Batches Context Diagram
!include images/C4_Context.puml

System(batches, "Batches", "Knows about available stock")
Person(customer, "Customer", "Wants to buy furniture")
System(orders, "Orders", "Knows about customer orders")
System(warehouse, "Warehouse", "Knows about shipping instructions")

Rel_R(customer, orders, "Places order with")
Rel_D(orders, batches, "Reserves stock with")
Rel_D(batches, warehouse, "Sends instructions to")

@enduml

我们系统中的每个“东西”都有一个关联的服务,它公开了一个 HTTP API。

让我们通过图 11-3 中的一个示例顺畅流程来工作:我们的用户访问网站,可以从库存中选择产品。当他们将商品添加到购物篮时,我们将为他们保留一些库存。当订单完成时,我们确认预订,这会导致我们向仓库发送发货指示。我们还可以说,如果这是客户的第三个订单,我们希望更新客户记录以将其标记为 VIP。

apwp 1103

图 11-3:命令流程 1
[plantuml, apwp_1103, config=plantuml.cfg]
@startuml

actor Customer
entity Orders
entity Batches
entity Warehouse
database CRM

== Reservation ==

  Customer -> Orders: Add product to basket
  Orders -> Batches: Reserve stock

== Purchase ==

  Customer -> Orders: Place order
  activate Orders
  Orders -> Batches: Confirm reservation
  Batches -> Warehouse: Dispatch goods
  Orders -> CRM: Update customer record
  deactivate Orders

@enduml

我们可以将这些步骤中的每一个都视为我们系统中的一个命令:ReserveStockConfirmReservationDispatchGoodsMakeCustomerVIP等等。

这种架构风格,即我们为每个数据库表创建一个微服务,并将我们的 HTTP API 视为贫血模型的 CRUD 接口,是人们最常见的初始服务设计方法。

这对于非常简单的系统来说是很好的,但很快就会变成一个分布式的泥球。

为了理解原因,让我们考虑另一种情况。有时,当库存到达仓库时,我们发现货物在运输过程中受潮。我们无法出售受潮的沙发,因此我们不得不将它们丢弃并向合作伙伴请求更多库存。我们还需要更新我们的库存模型,这可能意味着我们需要重新分配客户的订单。

这个逻辑应该放在哪里?

嗯,仓库系统知道库存已经受损,所以也许它应该拥有这个过程,就像图 11-4 中所示的那样。

apwp 1104

图 11-4:命令流程 2
[plantuml, apwp_1104, config=plantuml.cfg]
@startuml

actor w as "Warehouse worker"
entity Warehouse
entity Batches
entity Orders
database CRM

  w -> Warehouse: Report stock damage
  activate Warehouse
  Warehouse -> Batches: Decrease available stock
  Batches -> Batches: Reallocate orders
  Batches -> Orders: Update order status
  Orders -> CRM: Update order history
  deactivate Warehouse

@enduml

这种方法也可以,但现在我们的依赖图是一团糟。为了分配库存,订单服务驱动批次系统,批次系统驱动仓库;但为了处理仓库的问题,我们的仓库系统驱动批次,批次驱动订单。

将这种情况乘以我们需要提供的所有其他工作流程,你就会看到服务很快会变得混乱。

分布式系统中的错误处理

“事情会出错”是软件工程的普遍规律。当我们的请求失败时,我们的系统会发生什么?假设我们在为三个MISBEGOTTEN-RUG下订单后发生网络错误,如图 11-5 所示。

我们有两个选择:我们可以无论如何下订单并将其保留未分配,或者我们可以拒绝接受订单,因为无法保证分配。我们的批次服务的失败状态已经上升,并影响了我们订单服务的可靠性。

当两件事必须一起改变时,我们说它们是耦合的。我们可以将这种失败级联看作一种时间耦合:系统的每个部分都必须同时工作才能使系统的任何部分工作。随着系统变得越来越大,某个部分受损的可能性呈指数增长。

apwp 1105

图 11-5:带错误的命令流
[plantuml, apwp_1105, config=plantuml.cfg]
@startuml

actor Customer
entity Orders
entity Batches

Customer -> Orders: Place order
Orders -[#red]x Batches: Confirm reservation
hnote right: network error
Orders --> Customer: ???

@enduml

另一种选择:使用异步消息进行时间解耦

我们如何获得适当的耦合?我们已经看到了部分答案,即我们应该从动词的角度思考,而不是名词。我们的领域模型是关于建模业务流程的。它不是关于一个静态事物的静态数据模型;它是一个动词的模型。

因此,我们不是考虑订单系统和批次系统,而是考虑下订单系统和分配系统,等等。

当我们以这种方式分离事物时,更容易看清哪个系统应该负责什么。在考虑顺序时,我们真的希望确保当我们下订单时,订单已经下了。其他事情可以稍后发生,只要它发生了。

注意

如果这听起来很熟悉,那就对了!分离责任是我们设计聚合和命令时经历的相同过程。

像聚合一样,微服务应该是一致性边界。在两个服务之间,我们可以接受最终一致性,这意味着我们不需要依赖同步调用。每个服务都接受来自外部世界的命令,并引发事件来记录结果。其他服务可以监听这些事件来触发工作流程的下一步。

为了避免分布式泥球反模式,我们不想使用临时耦合的 HTTP API 调用,而是想要使用异步消息传递来集成我们的系统。我们希望我们的BatchQuantityChanged消息作为来自上游系统的外部消息传入,并且我们希望我们的系统发布Allocated事件供下游系统监听。

为什么这样做更好?首先,因为事情可以独立失败,处理降级行为更容易:如果分配系统出现问题,我们仍然可以接受订单。

其次,我们正在减少系统之间的耦合强度。如果我们需要改变操作顺序或者在流程中引入新步骤,我们可以在本地进行。

使用 Redis Pub/Sub 频道进行集成

让我们看看它将如何具体运作。我们需要某种方式将一个系统的事件传递到另一个系统,就像我们的消息总线一样,但是针对服务。这种基础设施通常被称为消息代理。消息代理的作用是接收发布者的消息并将其传递给订阅者。

在 MADE.com,我们使用Event Store;Kafka 或 RabbitMQ 也是有效的替代方案。基于 Redis pub/sub 频道的轻量级解决方案也可以很好地工作,因为 Redis 对大多数人来说更加熟悉,所以我们决定在本书中使用它。

注意

我们忽略了选择正确的消息平台涉及的复杂性。像消息排序、故障处理和幂等性等问题都需要仔细考虑。有关一些建议,请参见“Footguns”

我们的新流程将如下所示图 11-6:Redis 提供了BatchQuantityChanged事件,它启动了整个流程,并且我们的Allocated事件最终再次发布到 Redis。

apwp 1106

图 11-6:重新分配流程的序列图
[plantuml, apwp_1106, config=plantuml.cfg]

@startuml

Redis -> MessageBus : BatchQuantityChanged event

group BatchQuantityChanged Handler + Unit of Work 1
    MessageBus -> Domain_Model : change batch quantity
    Domain_Model -> MessageBus : emit Allocate command(s)
end

group Allocate Handler + Unit of Work 2 (or more)
    MessageBus -> Domain_Model : allocate
    Domain_Model -> MessageBus : emit Allocated event(s)
end

MessageBus -> Redis : publish to line_allocated channel
@enduml

使用端到端测试来测试驱动所有内容

以下是我们可能如何开始端到端测试。我们可以使用我们现有的 API 创建批次,然后我们将测试入站和出站消息:

我们的发布/订阅模型的端到端测试(tests/e2e/test_external_events.py

def test_change_batch_quantity_leading_to_reallocation():
    # start with two batches and an order allocated to one of them  #(1)
    orderid, sku = random_orderid(), random_sku()
    earlier_batch, later_batch = random_batchref("old"), random_batchref("newer")
    api_client.post_to_add_batch(earlier_batch, sku, qty=10, eta="2011-01-01")  #(2)
    api_client.post_to_add_batch(later_batch, sku, qty=10, eta="2011-01-02")
    response = api_client.post_to_allocate(orderid, sku, 10)  #(2)
    assert response.json()["batchref"] == earlier_batch

    subscription = redis_client.subscribe_to("line_allocated")  #(3)

    # change quantity on allocated batch so it's less than our order  #(1)
    redis_client.publish_message(  #(3)
        "change_batch_quantity",
        {"batchref": earlier_batch, "qty": 5},
    )

    # wait until we see a message saying the order has been reallocated  #(1)
    messages = []
    for attempt in Retrying(stop=stop_after_delay(3), reraise=True):  #(4)
        with attempt:
            message = subscription.get_message(timeout=1)
            if message:
                messages.append(message)
                print(messages)
            data = json.loads(messages[-1]["data"])
            assert data["orderid"] == orderid
            assert data["batchref"] == later_batch

您可以从注释中阅读此测试中正在进行的操作的故事:我们希望向系统发送一个事件,导致订单行被重新分配,并且我们也看到该重新分配作为一个事件出现在 Redis 中。

api_client是一个小助手,我们对其进行了重构,以便在两种测试类型之间共享;它包装了我们对requests.post的调用。

redis_client是另一个小测试助手,其详细信息并不重要;它的工作是能够从各种 Redis 频道发送和接收消息。我们将使用一个名为change_batch_quantity的频道来发送我们的更改批次数量的请求,并且我们将监听另一个名为line_allocated的频道,以寻找预期的重新分配。

由于系统测试的异步性质,我们需要再次使用tenacity库添加重试循环 - 首先,因为我们的新的line_allocated消息可能需要一些时间才能到达,但也因为它不会是该频道上唯一的消息。

Redis 是我们消息总线周围的另一个薄适配器

我们的 Redis 发布/订阅监听器(我们称其为事件消费者)非常类似于 Flask:它将外部世界转换为我们的事件:

简单的 Redis 消息监听器(src/allocation/entrypoints/redis_eventconsumer.py

r = redis.Redis(**config.get_redis_host_and_port())


def main():
    orm.start_mappers()
    pubsub = r.pubsub(ignore_subscribe_messages=True)
    pubsub.subscribe("change_batch_quantity")  #(1)

    for m in pubsub.listen():
        handle_change_batch_quantity(m)


def handle_change_batch_quantity(m):
    logging.debug("handling %s", m)
    data = json.loads(m["data"])  #(2)
    cmd = commands.ChangeBatchQuantity(ref=data["batchref"], qty=data["qty"])  #(2)
    messagebus.handle(cmd, uow=unit_of_work.SqlAlchemyUnitOfWork())

main()在加载时订阅了change_batch_quantity频道。

我们作为系统的入口点的主要工作是反序列化 JSON,将其转换为Command,并将其传递到服务层 - 就像 Flask 适配器一样。

我们还构建了一个新的下游适配器来执行相反的工作 - 将领域事件转换为公共事件:

简单的 Redis 消息发布者(src/allocation/adapters/redis_eventpublisher.py

r = redis.Redis(**config.get_redis_host_and_port())


def publish(channel, event: events.Event):  #(1)
    logging.debug("publishing: channel=%s, event=%s", channel, event)
    r.publish(channel, json.dumps(asdict(event)))

我们在这里采用了一个硬编码的频道,但您也可以存储事件类/名称与适当频道之间的映射,从而允许一个或多个消息类型发送到不同的频道。

我们的新出站事件

Allocated事件将如下所示:

新事件(src/allocation/domain/events.py

@dataclass
class Allocated(Event):
    orderid: str
    sku: str
    qty: int
    batchref: str

它捕获了我们需要了解的有关分配的一切内容:订单行的详细信息,以及它被分配到哪个批次。

我们将其添加到我们模型的allocate()方法中(自然地先添加了一个测试):

产品分配()发出新事件来记录发生了什么(src/allocation/domain/model.py

class Product:
    ...
    def allocate(self, line: OrderLine) -> str:
        ...

            batch.allocate(line)
            self.version_number += 1
            self.events.append(events.Allocated(
                orderid=line.orderid, sku=line.sku, qty=line.qty,
                batchref=batch.reference,
            ))
            return batch.reference

ChangeBatchQuantity的处理程序已经存在,所以我们需要添加的是一个处理程序,用于发布出站事件:

消息总线增长(src/allocation/service_layer/messagebus.py

HANDLERS = {
    events.Allocated: [handlers.publish_allocated_event],
    events.OutOfStock: [handlers.send_out_of_stock_notification],
}  # type: Dict[Type[events.Event], List[Callable]]

发布事件使用我们从 Redis 包装器中的辅助函数:

发布到 Redis(src/allocation/service_layer/handlers.py

def publish_allocated_event(
        event: events.Allocated, uow: unit_of_work.AbstractUnitOfWork,
):
    redis_eventpublisher.publish('line_allocated', event)

内部与外部事件

清楚地保持内部和外部事件之间的区别是个好主意。一些事件可能来自外部,一些事件可能会升级并在外部发布,但并非所有事件都会这样。如果你涉足事件溯源(尽管这是另一本书的主题),这一点尤为重要。

提示

出站事件是重要的应用验证的地方之一。参见附录 E 了解一些验证哲学和示例。

总结

事件可以来自外部,但也可以在外部发布——我们的publish处理程序将事件转换为 Redis 通道上的消息。我们使用事件与外部世界交流。这种时间解耦为我们的应用集成带来了很大的灵活性,但是,像往常一样,这是有代价的。

事件通知很好,因为它意味着低耦合,并且设置起来相当简单。然而,如果真的有一个在各种事件通知上运行的逻辑流,这可能会成为问题……很难看到这样的流,因为它在任何程序文本中都不是显式的……这可能会使调试和修改变得困难。

——Martin Fowler,“你所说的‘事件驱动’是什么意思”

表 11-1 显示了一些需要考虑的权衡。

表 11-1. 基于事件的微服务集成:权衡

优点 缺点
避免分布式的大泥球。 信息的整体流更难以看到。
服务是解耦的:更容易更改单个服务并添加新服务。 最终一致性是一个新的概念来处理。
消息可靠性和至少一次与至多一次交付的选择需要深思熟虑。

更一般地,如果你从同步消息传递模型转移到异步模型,你也会遇到一系列与消息可靠性和最终一致性有关的问题。继续阅读“Footguns”

第十二章:命令-查询责任分离(CQRS)

原文:12: Command-Query Responsibility Segregation (CQRS)

译者:飞龙

协议:CC BY-NC-SA 4.0

在本章中,我们将从一个相当无争议的观点开始:读取(查询)和写入(命令)是不同的,因此它们应该被不同对待(或者说它们的责任应该被分开,如果你愿意的话)。然后我们将尽可能地推动这一观点。

如果你像哈利一样,起初这一切都会显得极端,但希望我们能够证明这并不是完全不合理。

图 12-1 显示了我们可能会达到的地方。

提示

本章的代码位于 GitHub 的 chapter_12_cqrs 分支中(https://oreil.ly/YbWGT)。

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_12_cqrs
# or to code along, checkout the previous chapter:
git checkout chapter_11_external_events

不过,首先,为什么要费这个劲呢?

apwp 1201

图 12-1. 将读取与写入分开

领域模型是用于写入的

在本书中,我们花了很多时间讨论如何构建强制执行我们领域规则的软件。这些规则或约束对于每个应用程序都是不同的,它们构成了我们系统的有趣核心。

在本书中,我们已经明确规定了“你不能分配超过可用库存的库存”,以及“每个订单行都分配给一个批次”等隐含约束。

我们在书的开头将这些规则写成了单元测试:

我们的基本领域测试(tests/unit/test_batches.py

def test_allocating_to_a_batch_reduces_the_available_quantity():
    batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today())
    line = OrderLine('order-ref', "SMALL-TABLE", 2)

    batch.allocate(line)

    assert batch.available_quantity == 18

...

def test_cannot_allocate_if_available_smaller_than_required():
    small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20)
    assert small_batch.can_allocate(large_line) is False

为了正确应用这些规则,我们需要确保操作是一致的,因此我们引入了工作单元聚合等模式,这些模式帮助我们提交小块工作。

为了在这些小块之间传达变化,我们引入了领域事件模式,这样我们就可以编写规则,比如“当库存损坏或丢失时,调整批次上的可用数量,并在必要时重新分配订单。”

所有这些复杂性存在是为了在我们改变系统状态时强制执行规则。我们已经构建了一套灵活的工具来编写数据。

那么读取呢?

大多数用户不会购买你的家具

在 MADE.com,我们有一个与分配服务非常相似的系统。在繁忙的一天,我们可能每小时处理一百个订单,并且我们有一个庞大的系统来为这些订单分配库存。

然而,在同一忙碌的一天,我们可能每有一百次产品浏览。每当有人访问产品页面或产品列表页面时,我们都需要弄清产品是否仍有库存以及我们需要多长时间才能将其交付。

领域是相同的——我们关心库存批次、它们的到货日期以及仍然可用的数量——但访问模式却大不相同。例如,我们的客户不会注意到查询是否过时几秒钟,但如果我们的分配服务不一致,我们将搞乱他们的订单。我们可以利用这种差异,通过使我们的读取最终一致来使它们的性能更好。

我们可以将这些要求看作是系统的两个部分:读取端和写入端,如表 12-1 所示。

对于写入方面,我们的精密领域架构模式帮助我们随着时间的推移发展我们的系统,但到目前为止我们建立的复杂性对于读取数据并没有带来任何好处。服务层、工作单元和聪明的领域模型只是多余的。

表 12-1. 读取与写入

读取端 写入端
行为 简单读取 复杂业务逻辑
可缓存性 高度可缓存 不可缓存
一致性 可能过时 必须具有事务一致性

Post/Redirect/Get 和 CQS

如果你从事 Web 开发,你可能熟悉 Post/Redirect/Get 模式。在这种技术中,Web 端点接受 HTTP POST 并响应重定向以查看结果。例如,我们可能接受 POST 到/batches来创建一个新批次,并将用户重定向到/batches/123来查看他们新创建的批次。

这种方法修复了当用户在浏览器中刷新结果页面或尝试将结果页面加为书签时出现的问题。在刷新的情况下,它可能导致我们的用户重复提交数据,从而购买两张沙发,而他们只需要一张。在书签的情况下,我们的不幸顾客将在尝试获取 POST 端点时得到一个损坏的页面。

这两个问题都是因为我们在响应写操作时返回数据。Post/Redirect/Get 通过将操作的读取和写入阶段分开来规避了这个问题。

这种技术是命令查询分离(CQS)的一个简单示例。在 CQS 中,我们遵循一个简单的规则:函数应该要么修改状态,要么回答问题,但不能两者兼而有之。这使得软件更容易推理:我们应该始终能够询问,“灯亮了吗?”而不用去拨动开关。

注意

在构建 API 时,我们可以通过返回 201 Created 或 202 Accepted,并在 Location 标头中包含新资源的 URI 来应用相同的设计技术。这里重要的不是我们使用的状态代码,而是将工作逻辑上分为写入阶段和查询阶段。

正如你将看到的,我们可以使用 CQS 原则使我们的系统更快、更可扩展,但首先,让我们修复现有代码中的 CQS 违规。很久以前,我们引入了一个allocate端点,它接受一个订单并调用我们的服务层来分配一些库存。在调用结束时,我们返回一个 200 OK 和批次 ID。这导致了一些丑陋的设计缺陷,以便我们可以获得我们需要的数据。让我们将其更改为返回一个简单的 OK 消息,并提供一个新的只读端点来检索分配状态:

API 测试在 POST 之后进行 GET(tests/e2e/test_api.py

@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api')
def test_happy_path_returns_202_and_batch_is_allocated():
    orderid = random_orderid()
    sku, othersku = random_sku(), random_sku('other')
    earlybatch = random_batchref(1)
    laterbatch = random_batchref(2)
    otherbatch = random_batchref(3)
    api_client.post_to_add_batch(laterbatch, sku, 100, '2011-01-02')
    api_client.post_to_add_batch(earlybatch, sku, 100, '2011-01-01')
    api_client.post_to_add_batch(otherbatch, othersku, 100, None)

    r = api_client.post_to_allocate(orderid, sku, qty=3)
    assert r.status_code == 202

    r = api_client.get_allocation(orderid)
    assert r.ok
    assert r.json() == [
        {'sku': sku, 'batchref': earlybatch},
    ]

@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api')
def test_unhappy_path_returns_400_and_error_message():
    unknown_sku, orderid = random_sku(), random_orderid()
    r = api_client.post_to_allocate(
        orderid, unknown_sku, qty=20, expect_success=False,
    )
    assert r.status_code == 400
    assert r.json()['message'] == f'Invalid sku {unknown_sku}'

    r = api_client.get_allocation(orderid)
    assert r.status_code == 404

好的,Flask 应用可能是什么样子?

用于查看分配的端点 (src/allocation/entrypoints/flask_app.py)

from allocation import views
...

@app.route("/allocations/<orderid>", methods=["GET"])
def allocations_view_endpoint(orderid):
    uow = unit_of_work.SqlAlchemyUnitOfWork()
    result = views.allocations(orderid, uow)  #(1)
    if not result:
        return "not found", 404
    return jsonify(result), 200

好吧,views.py,可以,我们可以将只读内容放在那里,它将是一个真正的views.py,不像 Django 的那样,它知道如何构建我们数据的只读视图…

坚持住你的午餐,伙计们

嗯,我们可能只需向我们现有的存储库对象添加一个列表方法:

Views do…raw SQL? (src/allocation/views.py)

from allocation.service_layer import unit_of_work

def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork):
    with uow:
        results = list(uow.session.execute(
            'SELECT ol.sku, b.reference'
            ' FROM allocations AS a'
            ' JOIN batches AS b ON a.batch_id = b.id'
            ' JOIN order_lines AS ol ON a.orderline_id = ol.id'
            ' WHERE ol.orderid = :orderid',
            dict(orderid=orderid)
        ))
    return [{'sku': sku, 'batchref': batchref} for sku, batchref in results]

“对不起?原始 SQL?”

如果你像哈里第一次遇到这种模式一样,你会想知道鲍勃到底在抽什么烟。我们现在自己手动编写 SQL,并直接将数据库行转换为字典?在构建一个漂亮的领域模型时,我们付出了那么多的努力?存储库模式呢?它不是应该是我们围绕数据库的抽象吗?为什么我们不重用它?

好吧,让我们先探索这个看似更简单的替代方案,看看它在实践中是什么样子。

我们仍将保留我们的视图在一个单独的views.py模块中;在应用程序中强制执行读取和写入之间的明确区分仍然是一个好主意。我们应用命令查询分离,很容易看出哪些代码修改了状态(事件处理程序),哪些代码只是检索只读状态(视图)。

提示

将只读视图与修改状态的命令和事件处理程序分离出来可能是一个好主意,即使你不想完全实现 CQRS。

测试 CQRS 视图

在探索各种选项之前,让我们先谈谈测试。无论你决定采用哪种方法,你可能至少需要一个集成测试。就像这样:

用于视图的集成测试 (tests/integration/test_views.py)

def test_allocations_view(sqlite_session_factory):
    uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory)
    messagebus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None), uow)  #(1)
    messagebus.handle(commands.CreateBatch("sku2batch", "sku2", 50, today), uow)
    messagebus.handle(commands.Allocate("order1", "sku1", 20), uow)
    messagebus.handle(commands.Allocate("order1", "sku2", 20), uow)
    # add a spurious batch and order to make sure we're getting the right ones
    messagebus.handle(commands.CreateBatch("sku1batch-later", "sku1", 50, today), uow)
    messagebus.handle(commands.Allocate("otherorder", "sku1", 30), uow)
    messagebus.handle(commands.Allocate("otherorder", "sku2", 10), uow)

    assert views.allocations("order1", uow) == [
        {"sku": "sku1", "batchref": "sku1batch"},
        {"sku": "sku2", "batchref": "sku2batch"},
    ]

我们通过使用应用程序的公共入口点——消息总线来设置集成测试的环境。这样可以使我们的测试与任何关于如何存储事物的实现/基础设施细节解耦。

“显而易见”的替代方案 1:使用现有存储库

我们如何向我们的products存储库添加一个辅助方法呢?

一个使用存储库的简单视图(src/allocation/views.py

from allocation import unit_of_work

def allocations(orderid: str, uow: unit_of_work.AbstractUnitOfWork):
    with uow:
        products = uow.products.for_order(orderid=orderid)  #(1)
        batches = [b for p in products for b in p.batches]  #(2)
        return [
            {'sku': b.sku, 'batchref': b.reference}
            for b in batches
            if orderid in b.orderids  #(3)
        ]

我们的存储库返回Product对象,我们需要为给定订单中的 SKU 找到所有产品,因此我们将在存储库上构建一个名为.for_order()的新辅助方法。

现在我们有产品,但实际上我们想要批次引用,因此我们使用列表推导式获取所有可能的批次。

我们再次进行过滤,以获取我们特定订单的批次。这又依赖于我们的Batch对象能够告诉我们它已分配了哪些订单 ID。

我们使用.orderid属性来实现最后一个:

我们模型上一个可以说是不必要的属性(src/allocation/domain/model.py

class Batch:
    ...

    @property
    def orderids(self):
        return {l.orderid for l in self._allocations}

您可以开始看到,重用我们现有的存储库和领域模型类并不像您可能认为的那样简单。我们不得不向两者都添加新的辅助方法,并且我们在 Python 中进行了大量的循环和过滤,而这些工作在数据库中可以更有效地完成。

所以是的,好的一面是我们在重用现有的抽象,但坏的一面是,这一切都感觉非常笨拙。

您的领域模型并非针对读操作进行了优化

我们在这里看到的是,领域模型主要设计用于写操作,而我们对读取的需求在概念上通常是完全不同的。

这是下巴抚摸式架构师对 CQRS 的辩解。正如我们之前所说,领域模型不是数据模型——我们试图捕捉业务的运作方式:工作流程,状态变化规则,交换的消息;对系统如何对外部事件和用户输入做出反应的关注。这些大部分内容对只读操作来说是完全无关紧要的

提示

这种对 CQRS 的辩解与对领域模型模式的辩解有关。如果您正在构建一个简单的 CRUD 应用程序,读取和写入将是密切相关的,因此您不需要领域模型或 CQRS。但是,您的领域越复杂,您就越有可能需要两者。

为了做出一个肤浅的观点,您的领域类将有多个修改状态的方法,而您对只读操作不需要其中任何一个。

随着领域模型的复杂性增加,您将发现自己需要做出越来越多关于如何构建该模型的选择,这使得它对于读操作变得越来越笨拙。

“显而易见”的备选方案 2:使用 ORM

您可能会想,好吧,如果我们的存储库很笨拙,与Products一起工作也很笨拙,那么至少我可以使用我的 ORM 并与Batches一起工作。这就是它的用途!

一个使用 ORM 的简单视图(src/allocation/views.py

from allocation import unit_of_work, model

def allocations(orderid: str, uow: unit_of_work.AbstractUnitOfWork):
    with uow:
        batches = uow.session.query(model.Batch).join(
            model.OrderLine, model.Batch._allocations
        ).filter(
            model.OrderLine.orderid == orderid
        )
        return [
            {'sku': b.sku, 'batchref': b.batchref}
            for b in batches
        ]

但是,与代码示例中的原始 SQL 版本相比,这样写或理解起来是否实际上更容易呢?在上面看起来可能还不错,但我们可以告诉您,这花了好几次尝试,以及大量查阅 SQLAlchemy 文档。SQL 就是 SQL。

但 ORM 也可能使我们面临性能问题。

SELECT N+1 和其他性能考虑

所谓的SELECT N+1问题是 ORM 的常见性能问题:当检索对象列表时,您的 ORM 通常会执行初始查询,例如,获取它所需对象的所有 ID,然后为每个对象发出单独的查询以检索它们的属性。如果您的对象上有任何外键关系,这种情况尤其可能发生。

注意

公平地说,我们应该说 SQLAlchemy 在避免SELECT N+1问题方面做得相当不错。它在前面的示例中没有显示出来,而且当处理连接的对象时,您可以显式请求急切加载以避免这种问题。

除了SELECT N+1,您可能还有其他原因希望将状态更改的持久化方式与检索当前状态的方式解耦。一组完全规范化的关系表是确保写操作永远不会导致数据损坏的好方法。但是,使用大量连接来检索数据可能会很慢。在这种情况下,通常会添加一些非规范化的视图,构建读取副本,甚至添加缓存层。

完全跳进大白鲨的时间

在这一点上:我们是否已经说服您,我们的原始 SQL 版本并不像最初看起来那么奇怪?也许我们是夸大其词了?等着瞧吧。

因此,无论合理与否,那个硬编码的 SQL 查询都相当丑陋,对吧?如果我们让它更好……

一个更好的查询(src/allocation/views.py

def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork):
    with uow:
        results = list(uow.session.execute(
            'SELECT sku, batchref FROM allocations_view WHERE orderid = :orderid',
            dict(orderid=orderid)
        ))
        ...

…通过保持一个完全独立的、非规范化的数据存储来构建我们的视图模型

嘿嘿嘿,没有外键,只有字符串,YOLO(src/allocation/adapters/orm.py

allocations_view = Table(
    'allocations_view', metadata,
    Column('orderid', String(255)),
    Column('sku', String(255)),
    Column('batchref', String(255)),
)

好吧,更好看的 SQL 查询不会成为任何事情的理由,但是一旦您达到了使用索引的极限,构建一个针对读操作进行优化的非规范化数据的副本并不罕见。

即使使用调整良好的索引,关系数据库在执行连接时会使用大量 CPU。最快的查询将始终是SELECT * from *mytable* WHERE *key* = :*value*

然而,这种方法不仅仅是为了提高速度,而是为了扩展规模。当我们向关系数据库写入数据时,我们需要确保我们在更改的行上获得锁定,以免出现一致性问题。

如果多个客户端同时更改数据,我们将出现奇怪的竞争条件。然而,当我们读取数据时,可以同时执行的客户端数量是没有限制的。因此,只读存储可以进行水平扩展。

提示

由于读取副本可能不一致,我们可以拥有无限数量的读取副本。如果您正在努力扩展具有复杂数据存储的系统,请问您是否可以构建一个更简单的读模型。

保持读模型的最新状态是挑战!数据库视图(实体化或其他)和触发器是常见的解决方案,但这将限制您在数据库中的操作。我们想向您展示如何重用我们的事件驱动架构。

使用事件处理程序更新读模型表

我们在Allocated事件上添加了第二个处理程序:

Allocated 事件获得了一个新的处理程序(src/allocation/service_layer/messagebus.py

EVENT_HANDLERS = {
    events.Allocated: [
        handlers.publish_allocated_event,
        handlers.add_allocation_to_read_model
    ],

我们的更新视图模型代码如下:

更新分配(src/allocation/service_layer/handlers.py

def add_allocation_to_read_model(
        event: events.Allocated, uow: unit_of_work.SqlAlchemyUnitOfWork,
):
    with uow:
        uow.session.execute(
            'INSERT INTO allocations_view (orderid, sku, batchref)'
            ' VALUES (:orderid, :sku, :batchref)',
            dict(orderid=event.orderid, sku=event.sku, batchref=event.batchref)
        )
        uow.commit()

信不信由你,这基本上会起作用!而且它将与我们的其他选项的完全相同的集成测试一起工作。

好吧,您还需要处理Deallocated

读模型更新的第二个监听器

events.Deallocated: [
    handlers.remove_allocation_from_read_model,
    handlers.reallocate
],

...

def remove_allocation_from_read_model(
        event: events.Deallocated, uow: unit_of_work.SqlAlchemyUnitOfWork,
):
    with uow:
        uow.session.execute(
            'DELETE FROM allocations_view '
            ' WHERE orderid = :orderid AND sku = :sku',

图 12-2 显示了两个请求之间的流程。

apwp 1202

图 12-2. 读模型的序列图
[plantuml, apwp_1202, config=plantuml.cfg]
@startuml
actor User order 1
boundary Flask order 2
participant MessageBus order 3
participant "Domain Model" as Domain order 4
participant View order 9
database DB order 10

User -> Flask: POST to allocate Endpoint
Flask -> MessageBus : Allocate Command

group UoW/transaction 1
    MessageBus -> Domain : allocate()
    MessageBus -> DB: commit write model
end

group UoW/transaction 2
    Domain -> MessageBus : raise Allocated event(s)
    MessageBus -> DB : update view model
end

Flask -> User: 202 OK

User -> Flask: GET allocations endpoint
Flask -> View: get allocations
View -> DB: SELECT on view model
DB -> View: some allocations
View -> Flask: some allocations
Flask -> User: some allocations

@enduml

在图 12-2 中,您可以看到在 POST/write 操作中有两个事务,一个用于更新写模型,另一个用于更新读模型,GET/read 操作可以使用。

更改我们的读模型实现很容易

让我们通过看看我们的事件驱动模型在实际中带来的灵活性,来看看如果我们决定使用完全独立的存储引擎 Redis 来实现读模型会发生什么。

只需观看:

处理程序更新 Redis 读模型(src/allocation/service_layer/handlers.py

def add_allocation_to_read_model(event: events.Allocated, _):
    redis_eventpublisher.update_readmodel(event.orderid, event.sku, event.batchref)

def remove_allocation_from_read_model(event: events.Deallocated, _):
    redis_eventpublisher.update_readmodel(event.orderid, event.sku, None)

我们 Redis 模块中的辅助程序只有一行代码:

Redis 读模型读取和更新(src/allocation/adapters/redis_eventpublisher.py

def update_readmodel(orderid, sku, batchref):
    r.hset(orderid, sku, batchref)

def get_readmodel(orderid):
    return r.hgetall(orderid)

(也许现在redis_eventpublisher.py的名称有点不准确,但您明白我的意思。)

而且视图本身也略有变化,以适应其新的后端:

适应 Redis 的视图(src/allocation/views.py

def allocations(orderid):
    batches = redis_eventpublisher.get_readmodel(orderid)
    return [
        {'batchref': b.decode(), 'sku': s.decode()}
        for s, b in batches.items()
    ]

而且,我们之前编写的完全相同的集成测试仍然通过,因为它们是以与实现解耦的抽象级别编写的:设置将消息放入消息总线,断言针对我们的视图。

提示

如果您决定需要,事件处理程序是管理对读模型的更新的好方法。它们还可以轻松地在以后更改该读模型的实现。

总结

表 12-2 提出了我们各种选项的一些利弊。

事实上,MADE.com 的分配服务确实使用了“全面的”CQRS,在 Redis 中存储了一个读模型,甚至还提供了由 Varnish 提供的第二层缓存。但其用例与我们在这里展示的情况相当不同。对于我们正在构建的分配服务,似乎不太可能需要使用单独的读模型和事件处理程序进行更新。

但随着您的领域模型变得更加丰富和复杂,简化的读模型变得更加引人注目。

表 12-2。各种视图模型选项的权衡

选项 优点 缺点
只使用存储库 简单、一致的方法。 预期在复杂的查询模式中出现性能问题。
使用 ORM 自定义查询 允许重用 DB 配置和模型定义。 添加另一种具有自己怪癖和语法的查询语言。
使用手动编写的 SQL 通过标准查询语法可以对性能进行精细控制。 必须对手动编写的查询和 ORM 定义进行数据库模式更改。高度规范化的模式可能仍然存在性能限制。
使用事件创建单独的读取存储 只读副本易于扩展。在数据更改时可以构建视图,以使查询尽可能简单。 复杂的技术。哈里将永远怀疑你的品味和动机。

通常,您的读操作将作用于与写模型相同的概念对象,因此可以使用 ORM,在存储库中添加一些读取方法,并对读取操作使用领域模型类非常好

在我们的书例中,读操作涉及的概念实体与我们的领域模型非常不同。分配服务以单个 SKU 的“批次”为单位思考,但用户关心的是整个订单的分配,包括多个 SKU,因此使用 ORM 最终有点尴尬。我们可能会倾向于选择我们在本章开头展示的原始 SQL 视图。

在这一点上,让我们继续进入我们的最后一章。

第十三章:依赖注入(和引导)

原文:13: Dependency Injection (and Bootstrapping)

译者:飞龙

协议:CC BY-NC-SA 4.0

依赖注入(DI)在 Python 世界中备受怀疑。迄今为止,我们在本书的示例代码中一直很好地没有使用它!

在本章中,我们将探讨代码中的一些痛点,这些痛点导致我们考虑使用 DI,并提出一些如何实现它的选项,让您选择最符合 Python 风格的方式。

我们还将向我们的架构添加一个名为bootstrap.py的新组件;它将负责依赖注入,以及我们经常需要的一些其他初始化工作。我们将解释为什么这种东西在面向对象语言中被称为组合根,以及为什么引导脚本对我们的目的来说是完全合适的。

图 13-1 显示了我们的应用程序在没有引导程序的情况下的样子:入口点做了很多初始化和传递我们的主要依赖项 UoW。

提示

如果您还没有这样做,建议在继续本章之前阅读第三章,特别是功能与面向对象依赖管理的讨论。

apwp 1301

图 13-1:没有引导:入口点做了很多事情
提示

本章的代码位于 GitHub 上的 chapter_13_dependency_injection 分支中(https://oreil.ly/-B7e6)

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_13_dependency_injection
# or to code along, checkout the previous chapter:
git checkout chapter_12_cqrs

图 13-2 显示了我们的引导程序接管了这些责任。

apwp 1302

图 13-2:引导程序在一个地方处理所有这些

隐式依赖与显式依赖

根据您特定的大脑类型,您可能在心中略感不安。让我们把它公开化。我们向您展示了两种管理依赖项并对其进行测试的方式。

对于我们的数据库依赖,我们建立了一个明确的依赖关系框架,并提供了易于在测试中覆盖的选项。我们的主处理程序函数声明了对 UoW 的明确依赖:

我们的处理程序对 UoW 有明确的依赖(src/allocation/service_layer/handlers.py

def allocate(
        cmd: commands.Allocate, uow: unit_of_work.AbstractUnitOfWork
):

这使得在我们的服务层测试中轻松替换虚假 UoW 成为可能:

针对虚假 UoW 的服务层测试:(tests/unit/test_services.py

    uow = FakeUnitOfWork()
    messagebus.handle([...], uow)

UoW 本身声明了对会话工厂的明确依赖:

UoW 依赖于会话工厂(src/allocation/service_layer/unit_of_work.py

class SqlAlchemyUnitOfWork(AbstractUnitOfWork):

    def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
        self.session_factory = session_factory
        ...

我们利用它在我们的集成测试中,有时可以使用 SQLite 而不是 Postgres:

针对不同数据库的集成测试(tests/integration/test_uow.py

def test_rolls_back_uncommitted_work_by_default(sqlite_session_factory):
    uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory)  #(1)

集成测试将默认的 Postgres session_factory替换为 SQLite。

显式依赖完全奇怪和 Java 风格吗?

如果您习惯于 Python 中通常发生的事情,您可能会觉得这有点奇怪。标准做法是通过简单导入隐式声明我们的依赖,然后如果我们需要在测试中更改它,我们可以进行 monkeypatch,这在动态语言中是正确和正确的:

电子邮件发送作为正常的基于导入的依赖(src/allocation/service_layer/handlers.py

from allocation.adapters import email, redis_eventpublisher  #(1)
...

def send_out_of_stock_notification(
    event: events.OutOfStock,
    uow: unit_of_work.AbstractUnitOfWork,
):
    email.send(  #(2)
        "stock@made.com",
        f"Out of stock for {event.sku}",
    )

硬编码导入

直接调用特定的电子邮件发送器

为了我们的测试,为什么要用不必要的参数污染我们的应用程序代码?mock.patch使得 monkeypatch 变得简单而容易:

模拟点补丁,谢谢 Michael Foord(tests/unit/test_handlers.py

    with mock.patch("allocation.adapters.email.send") as mock_send_mail:
        ...

问题在于,我们让它看起来很容易,因为我们的玩具示例不发送真正的电子邮件(email.send_mail只是print),但在现实生活中,您最终将不得不为每个可能引起缺货通知的测试调用mock.patch。如果您曾经在大量使用模拟以防止不需要的副作用的代码库上工作过,您将知道那些模拟的样板代码有多讨厌。

您会知道模拟将我们紧密耦合到实现。通过选择对email.send_mail进行 monkeypatch,我们将绑定到执行import email,如果我们想要执行from email import send_mail,一个微不足道的重构,我们将不得不更改所有我们的模拟。

因此这是一个权衡。是的,严格来说,声明显式依赖是不必要的,并且使用它们会使我们的应用代码稍微更复杂。但作为回报,我们将获得更容易编写和管理的测试。

此外,声明显式依赖是依赖倒置原则的一个例子 - 而不是对特定细节的(隐式)依赖,我们对抽象有一个(显式)依赖:

显式胜于隐式。

——Python 之禅

显式依赖更抽象(src/allocation/service_layer/handlers.py

def send_out_of_stock_notification(
        event: events.OutOfStock, send_mail: Callable,
):
    send_mail(
        'stock@made.com',
        f'Out of stock for {event.sku}',
    )

但是,如果我们改为显式声明所有这些依赖关系,谁会注入它们,以及如何注入?到目前为止,我们真的只是在传递 UoW:我们的测试使用FakeUnitOfWork,而 Flask 和 Redis 事件消费者入口使用真正的 UoW,并且消息总线将它们传递给我们的命令处理程序。如果我们添加真实和假的电子邮件类,谁会创建它们并传递它们?

这对于 Flask、Redis 和我们的测试来说是额外的(重复的)累赘。此外,将所有传递依赖项到正确处理程序的责任都放在消息总线上,感觉像是违反了 SRP。

相反,我们将寻找一种称为组合根(对我们来说是引导脚本)的模式,¹,我们将进行一些“手动 DI”(无需框架的依赖注入)。请参阅图 13-3。²

apwp 1303

图 13-3:入口点和消息总线之间的引导程序

准备处理程序:使用闭包和部分函数进行手动 DI

将具有依赖关系的函数转换为一个准备好以后使用这些依赖项已注入的函数的方法之一是使用闭包或部分函数将函数与其依赖项组合起来:

使用闭包或部分函数进行 DI 的示例

# existing allocate function, with abstract uow dependency
def allocate(
    cmd: commands.Allocate,
    uow: unit_of_work.AbstractUnitOfWork,
):
    line = OrderLine(cmd.orderid, cmd.sku, cmd.qty)
    with uow:
        ...

# bootstrap script prepares actual UoW

def bootstrap(..):
    uow = unit_of_work.SqlAlchemyUnitOfWork()

    # prepare a version of the allocate fn with UoW dependency captured in a closure
    allocate_composed = lambda cmd: allocate(cmd, uow)

    # or, equivalently (this gets you a nicer stack trace)
    def allocate_composed(cmd):
        return allocate(cmd, uow)

    # alternatively with a partial
    import functools
    allocate_composed = functools.partial(allocate, uow=uow)  #(1)

# later at runtime, we can call the partial function, and it will have
# the UoW already bound
allocate_composed(cmd)

闭包(lambda 或命名函数)和functools.partial之间的区别在于前者使用变量的延迟绑定,如果任何依赖项是可变的,这可能会导致混淆。

这里是send_out_of_stock_notification()处理程序的相同模式,它具有不同的依赖项:

另一个闭包和部分函数的例子

def send_out_of_stock_notification(
        event: events.OutOfStock, send_mail: Callable,
):
    send_mail(
        'stock@made.com',
        ...

# prepare a version of the send_out_of_stock_notification with dependencies
sosn_composed  = lambda event: send_out_of_stock_notification(event, email.send_mail)

...
# later, at runtime:
sosn_composed(event)  # will have email.send_mail already injected in

使用类的替代方法

对于那些有一些函数式编程经验的人来说,闭包和部分函数会让人感到熟悉。这里有一个使用类的替代方法,可能会吸引其他人。尽管如此,它需要将所有我们的处理程序函数重写为类:

使用类进行 DI

# we replace the old `def allocate(cmd, uow)` with:

class AllocateHandler:
    def __init__(self, uow: unit_of_work.AbstractUnitOfWork):  #(2)
        self.uow = uow

    def __call__(self, cmd: commands.Allocate):  #(1)
        line = OrderLine(cmd.orderid, cmd.sku, cmd.qty)
        with self.uow:
            # rest of handler method as before
            ...

# bootstrap script prepares actual UoW
uow = unit_of_work.SqlAlchemyUnitOfWork()

# then prepares a version of the allocate fn with dependencies already injected
allocate = AllocateHandler(uow)

...
# later at runtime, we can call the handler instance, and it will have
# the UoW already injected
allocate(cmd)

该类旨在生成可调用函数,因此它具有*call*方法。

但我们使用init来声明它需要的依赖项。如果您曾经制作过基于类的描述符,或者接受参数的基于类的上下文管理器,这种事情会让您感到熟悉。

使用您和您的团队感觉更舒适的那个。

引导脚本

我们希望我们的引导脚本执行以下操作:

  1. 声明默认依赖项,但允许我们覆盖它们

  2. 做我们需要启动应用程序的“init”工作

  3. 将所有依赖项注入到我们的处理程序中

  4. 给我们返回应用程序的核心对象,消息总线

这是第一步:

一个引导函数(src/allocation/bootstrap.py

def bootstrap(
    start_orm: bool = True,  #(1)
    uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(),  #(2)
    send_mail: Callable = email.send,
    publish: Callable = redis_eventpublisher.publish,
) -> messagebus.MessageBus:

    if start_orm:
        orm.start_mappers()  #(1)

    dependencies = {"uow": uow, "send_mail": send_mail, "publish": publish}
    injected_event_handlers = {  #(3)
        event_type: [
            inject_dependencies(handler, dependencies)
            for handler in event_handlers
        ]
        for event_type, event_handlers in handlers.EVENT_HANDLERS.items()
    }
    injected_command_handlers = {  #(3)
        command_type: inject_dependencies(handler, dependencies)
        for command_type, handler in handlers.COMMAND_HANDLERS.items()
    }

    return messagebus.MessageBus(  #(4)
        uow=uow,
        event_handlers=injected_event_handlers,
        command_handlers=injected_command_handlers,
    )

orm.start_mappers()是我们需要在应用程序开始时执行一次的初始化工作的示例。我们还看到一些设置logging模块的事情。

我们可以使用参数默认值来定义正常/生产默认值。将它们放在一个地方很好,但有时依赖项在构建时会产生一些副作用,这种情况下,您可能更喜欢将它们默认为None

我们通过使用一个名为inject_dependencies()的函数来构建我们注入的处理程序映射的版本,接下来我们将展示它。

我们返回一个配置好的消息总线,可以立即使用。

这是我们通过检查将依赖项注入处理程序函数的方法:

通过检查函数签名进行 DI(src/allocation/bootstrap.py

def inject_dependencies(handler, dependencies):
    params = inspect.signature(handler).parameters  #(1)
    deps = {
        name: dependency
        for name, dependency in dependencies.items()  #(2)
        if name in params
    }
    return lambda message: handler(message, **deps)  #(3)

我们检查我们的命令/事件处理程序的参数。

我们按名称将它们与我们的依赖项匹配。

我们将它们作为 kwargs 注入以产生一个 partial。

消息总线在运行时被赋予处理程序

我们的消息总线将不再是静态的;它需要已经注入的处理程序。因此,我们将其从模块转换为可配置的类:

MessageBus 作为一个类(src/allocation/service_layer/messagebus.py

class MessageBus:  #(1)
    def __init__(
        self,
        uow: unit_of_work.AbstractUnitOfWork,
        event_handlers: Dict[Type[events.Event], List[Callable]],  #(2)
        command_handlers: Dict[Type[commands.Command], Callable],  #(2)
    ):
        self.uow = uow
        self.event_handlers = event_handlers
        self.command_handlers = command_handlers

    def handle(self, message: Message):  #(3)
        self.queue = [message]  #(4)
        while self.queue:
            message = self.queue.pop(0)
            if isinstance(message, events.Event):
                self.handle_event(message)
            elif isinstance(message, commands.Command):
                self.handle_command(message)
            else:
                raise Exception(f"{message} was not an Event or Command")

消息总线变成了一个类...

...它已经注入了依赖项。

主要的handle()函数基本相同,只是一些属性和方法移到了self上。

像这样使用self.queue是不线程安全的,如果您使用线程可能会有问题,因为我们已经将总线实例在 Flask 应用程序上下文中全局化了。这是需要注意的问题。

总线中还有什么变化?

事件和命令处理逻辑保持不变(src/allocation/service_layer/messagebus.py

    def handle_event(self, event: events.Event):
        for handler in self.event_handlers[type(event)]:  #(1)
            try:
                logger.debug("handling event %s with handler %s", event, handler)
                handler(event)  #(2)
                self.queue.extend(self.uow.collect_new_events())
            except Exception:
                logger.exception("Exception handling event %s", event)
                continue

    def handle_command(self, command: commands.Command):
        logger.debug("handling command %s", command)
        try:
            handler = self.command_handlers[type(command)]  #(1)
            handler(command)  #(2)
            self.queue.extend(self.uow.collect_new_events())
        except Exception:
            logger.exception("Exception handling command %s", command)
            raise

handle_eventhandle_command基本相同,但是不再索引静态的EVENT_HANDLERSCOMMAND_HANDLERS字典,而是使用self上的版本。

我们不再将 UoW 传递给处理程序,我们期望处理程序已经具有了所有它们的依赖项,因此它们只需要一个参数,即特定的事件或命令。

在我们的入口点中使用 Bootstrap

在我们应用程序的入口点中,我们现在只需调用bootstrap.bootstrap(),就可以得到一个准备就绪的消息总线,而不是配置 UoW 和其他内容:

Flask 调用 bootstrap(src/allocation/entrypoints/flask_app.py

-from allocation import views
+from allocation import bootstrap, views

 app = Flask(__name__)
-orm.start_mappers()  #(1)
+bus = bootstrap.bootstrap()


 @app.route("/add_batch", methods=["POST"])
@@ -19,8 +16,7 @@ def add_batch():
     cmd = commands.CreateBatch(
         request.json["ref"], request.json["sku"], request.json["qty"], eta
     )
-    uow = unit_of_work.SqlAlchemyUnitOfWork()  #(2)
-    messagebus.handle(cmd, uow)
+    bus.handle(cmd)  #(3)
     return "OK", 201

我们不再需要调用start_orm();启动脚本的初始化阶段会处理这个问题。

我们不再需要显式构建特定类型的 UoW;启动脚本的默认值会处理这个问题。

现在我们的消息总线是一个特定的实例,而不是全局模块。³

在我们的测试中初始化 DI

在测试中,我们可以使用bootstrap.bootstrap()并覆盖默认值以获得自定义消息总线。以下是集成测试中的一个示例:

覆盖引导默认值(tests/integration/test_views.py

@pytest.fixture
def sqlite_bus(sqlite_session_factory):
    bus = bootstrap.bootstrap(
        start_orm=True,  #(1)
        uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory),  #(2)
        send_mail=lambda *args: None,  #(3)
        publish=lambda *args: None,  #(3)
    )
    yield bus
    clear_mappers()


def test_allocations_view(sqlite_bus):
    sqlite_bus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None))
    sqlite_bus.handle(commands.CreateBatch("sku2batch", "sku2", 50, today))
    ...
    assert views.allocations("order1", sqlite_bus.uow) == [
        {"sku": "sku1", "batchref": "sku1batch"},
        {"sku": "sku2", "batchref": "sku2batch"},
    ]

我们确实希望启动 ORM…

…因为我们将使用真实的 UoW,尽管使用的是内存数据库。

但我们不需要发送电子邮件或发布,所以我们将它们设置为 noops。

在我们的单元测试中,相反,我们可以重用我们的FakeUnitOfWork

在单元测试中引导(tests/unit/test_handlers.py

def bootstrap_test_app():
    return bootstrap.bootstrap(
        start_orm=False,  #(1)
        uow=FakeUnitOfWork(),  #(2)
        send_mail=lambda *args: None,  #(3)
        publish=lambda *args: None,  #(3)
    )

不需要启动 ORM…

…因为虚假的 UoW 不使用它。

我们也想伪造我们的电子邮件和 Redis 适配器。

这样就消除了一些重复,并且我们将一堆设置和合理的默认值移到了一个地方。

“正确”构建适配器的示例

为了真正了解它是如何工作的,让我们通过一个示例来演示如何“正确”构建适配器并进行依赖注入。

目前,我们有两种类型的依赖关系:

两种类型的依赖关系(src/allocation/service_layer/messagebus.py

    uow: unit_of_work.AbstractUnitOfWork,  #(1)
    send_mail: Callable,  #(2)
    publish: Callable,  #(2)

UoW 有一个抽象基类。这是声明和管理外部依赖关系的重量级选项。当依赖关系相对复杂时,我们将使用它。

我们的电子邮件发送器和发布/订阅发布者被定义为函数。这对于简单的依赖关系完全有效。

以下是我们在工作中发现自己注入的一些东西:

  • 一个 S3 文件系统客户端

  • 一个键/值存储客户端

  • 一个requests会话对象

其中大多数将具有更复杂的 API,无法将其捕获为单个函数:读取和写入,GET 和 POST 等等。

尽管它很简单,但让我们以send_mail为例,讨论如何定义更复杂的依赖关系。

定义抽象和具体实现

我们将想象一个更通用的通知 API。可能是电子邮件,可能是短信,也可能是 Slack 帖子。

一个 ABC 和一个具体的实现(src/allocation/adapters/notifications.py

class AbstractNotifications(abc.ABC):

    @abc.abstractmethod
    def send(self, destination, message):
        raise NotImplementedError

...

class EmailNotifications(AbstractNotifications):

    def __init__(self, smtp_host=DEFAULT_HOST, port=DEFAULT_PORT):
        self.server = smtplib.SMTP(smtp_host, port=port)
        self.server.noop()

    def send(self, destination, message):
        msg = f'Subject: allocation service notification\n{message}'
        self.server.sendmail(
            from_addr='allocations@example.com',
            to_addrs=[destination],
            msg=msg
        )

我们在引导脚本中更改了依赖项:

消息总线中的通知(src/allocation/bootstrap.py

 def bootstrap(
     start_orm: bool = True,
     uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(),
-    send_mail: Callable = email.send,
+    notifications: AbstractNotifications = EmailNotifications(),
     publish: Callable = redis_eventpublisher.publish,
 ) -> messagebus.MessageBus:

为您的测试创建一个虚假版本

我们通过并为单元测试定义一个虚假版本:

虚假通知(tests/unit/test_handlers.py

class FakeNotifications(notifications.AbstractNotifications):

    def __init__(self):
        self.sent = defaultdict(list)  # type: Dict[str, List[str]]

    def send(self, destination, message):
        self.sent[destination].append(message)
...

并在我们的测试中使用它:

测试稍作更改(tests/unit/test_handlers.py

    def test_sends_email_on_out_of_stock_error(self):
        fake_notifs = FakeNotifications()
        bus = bootstrap.bootstrap(
            start_orm=False,
            uow=FakeUnitOfWork(),
            notifications=fake_notifs,
            publish=lambda *args: None,
        )
        bus.handle(commands.CreateBatch("b1", "POPULAR-CURTAINS", 9, None))
        bus.handle(commands.Allocate("o1", "POPULAR-CURTAINS", 10))
        assert fake_notifs.sent['stock@made.com'] == [
            f"Out of stock for POPULAR-CURTAINS",
        ]

弄清楚如何集成测试真实的东西

现在我们测试真实的东西,通常使用端到端或集成测试。我们在 Docker 开发环境中使用MailHog作为真实的电子邮件服务器:

具有真实虚假电子邮件服务器的 Docker-compose 配置(docker-compose.yml)

version: "3"

services:

  redis_pubsub:
    build:
      context: .
      dockerfile: Dockerfile
    image: allocation-image
    ...

  api:
    image: allocation-image
    ...

  postgres:
    image: postgres:9.6
    ...

  redis:
    image: redis:alpine
    ...

  mailhog:
    image: mailhog/mailhog
    ports:
      - "11025:1025"
      - "18025:8025"

在我们的集成测试中,我们使用真正的EmailNotifications类,与 Docker 集群中的 MailHog 服务器通信:

电子邮件的集成测试(tests/integration/test_email.py

@pytest.fixture
def bus(sqlite_session_factory):
    bus = bootstrap.bootstrap(
        start_orm=True,
        uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory),
        notifications=notifications.EmailNotifications(),  #(1)
        publish=lambda *args: None,
    )
    yield bus
    clear_mappers()


def get_email_from_mailhog(sku):  #(2)
    host, port = map(config.get_email_host_and_port().get, ["host", "http_port"])
    all_emails = requests.get(f"http://{host}:{port}/api/v2/messages").json()
    return next(m for m in all_emails["items"] if sku in str(m))


def test_out_of_stock_email(bus):
    sku = random_sku()
    bus.handle(commands.CreateBatch("batch1", sku, 9, None))  #(3)
    bus.handle(commands.Allocate("order1", sku, 10))
    email = get_email_from_mailhog(sku)
    assert email["Raw"]["From"] == "allocations@example.com"  #(4)
    assert email["Raw"]["To"] == ["stock@made.com"]
    assert f"Out of stock for {sku}" in email["Raw"]["Data"]

我们使用我们的引导程序构建一个与真实通知类交互的消息总线。

我们弄清楚如何从我们的“真实”电子邮件服务器中获取电子邮件。

我们使用总线来进行测试设置。

出乎意料的是,这实际上非常顺利地完成了!

就是这样。

总结

一旦你有了多个适配器,除非你进行某种依赖注入,否则手动传递依赖关系会让你感到很痛苦。

设置依赖注入只是在启动应用程序时需要做的许多典型设置/初始化活动之一。将所有这些放在一个引导脚本中通常是一个好主意。

引导脚本也是一个很好的地方,可以为适配器提供合理的默认配置,并且作为一个单一的地方,可以用虚假的适配器覆盖测试。

如果你发现自己需要在多个级别进行 DI,例如,如果你有一系列组件的链式依赖需要 DI,那么依赖注入框架可能会很有用。

本章还提供了一个实际示例,将隐式/简单的依赖关系改变为“适当”的适配器,将 ABC 分离出来,定义其真实和虚假的实现,并思考集成测试。

这些是我们想要覆盖的最后几个模式,这将我们带到了第二部分的结尾。在结语中,我们将尝试为您提供一些在现实世界中应用这些技术的指导。

¹ 因为 Python 不是一个“纯”面向对象的语言,Python 开发人员并不一定习惯于需要将一组对象组合成一个工作应用程序的概念。我们只是选择我们的入口点,然后从上到下运行代码。

² Mark Seemann 将这称为Pure DI,有时也称为Vanilla DI

³ 但是,如果有意义的话,它仍然是flask_app模块范围内的全局变量。如果你想要使用 Flask 测试客户端而不是像我们一样使用 Docker 来测试你的 Flask 应用程序,这可能会导致问题。如果你遇到这种情况,值得研究Flask 应用程序工厂

结语

原文:Epilogue: Epilogue

译者:飞龙

协议:CC BY-NC-SA 4.0

现在怎么办?

哇!在这本书中,我们涵盖了很多内容,对于我们的大多数读者来说,所有这些想法都是新的。考虑到这一点,我们不能希望让您成为这些技术的专家。我们真正能做的只是向您展示大致的想法,并提供足够的代码让您可以开始从头写东西。

我们在这本书中展示的代码并不是经过严格测试的生产代码:它是一组乐高积木,你可以用它来建造你的第一个房子、太空飞船和摩天大楼。

这给我们留下了两项重要任务。我们想讨论如何在现有系统中真正应用这些想法,并且我们需要警告您有些我们不得不跳过的事情。我们已经给了您一整套新的自毁方式,所以我们应该讨论一些基本的枪支安全知识。

我怎样才能从这里到那里?

很多人可能会想到这样的问题:

“好的鲍勃和哈里,这一切都很好,如果我有机会被聘用来开发一个全新的服务,我知道该怎么做。但与此同时,我在这里面对我的 Django 泥球,我看不到任何办法可以达到你们的干净、完美、无瑕、简单的模型。从这里没有办法。”

我们明白你的想法。一旦您已经构建了一个大球泥,就很难知道如何开始改进。实际上,我们需要一步一步地解决问题。

首先要明确的是:您要解决什么问题?软件是否太难更改?性能是否令人无法接受?是否有奇怪的、无法解释的错误?

有一个明确的目标将有助于您优先处理需要完成的工作,并且重要的是,向团队的其他成员传达做这些工作的原因。企业倾向于对技术债务和重构采取务实的方法,只要工程师能够就修复问题提出合理的论据。

提示

对系统进行复杂的更改通常更容易推销,如果将其与功能工作联系起来。也许您正在推出新产品或将您的服务开放给新市场?这是在修复基础设施上花费工程资源的正确时机。在一个需要交付的六个月项目中,更容易争取三周的清理工作。鲍勃称之为架构税

分离纠缠的责任

在书的开头,我们说大球泥的主要特征是同质性:系统的每个部分看起来都一样,因为我们没有明确每个组件的责任。为了解决这个问题,我们需要开始分离责任并引入明确的边界。我们可以做的第一件事之一是开始构建一个服务层(图 E-1)。

apwp ep01

图 E-1. 协作系统的领域
[plantuml, apwp_ep01, config=plantuml.cfg]
@startuml
hide empty members
Workspace *- Folder : contains
Account *- Workspace : owns
Account *-- Package : has
User *-- Account : manages
Workspace *-- User : has members
User *-- Document : owns
Folder *-- Document : contains
Document *- Version: has
User *-- Version: authors
@enduml

这就是鲍勃第一次学会如何分解泥球的系统,而且这是一个难题。逻辑无处不在——在网页中,在经理对象中,在辅助程序中,在我们编写的用于抽象经理和辅助程序的庞大服务类中,以及在我们编写的用于分解服务的复杂命令对象中。

如果您正在处理已经到达这一步的系统,情况可能会让人感到绝望,但开始清理一个长满杂草的花园永远不会太晚。最终,我们雇了一个知道自己在做什么的架构师,他帮助我们重新控制了局面。

首先要做的是弄清楚系统的用例。如果您有用户界面,它执行了哪些操作?如果您有后端处理组件,也许每个 cron 作业或 Celery 作业都是一个单独的用例。您的每个用例都需要有一个命令式的名称:例如,应用计费费用、清理废弃账户或提高采购订单。

在我们的情况下,大多数用例都是经理类的一部分,名称如创建工作空间或删除文档版本。每个用例都是从 Web 前端调用的。

我们的目标是为每个支持的操作创建一个单独的函数或类,用于编排要执行的工作。每个用例应执行以下操作:

  • 如有需要,启动自己的数据库事务

  • 获取所需的任何数据

  • 检查任何前提条件(请参阅[附录 E]中的确保模式(app05.xhtml#appendix_validation))

  • 更新领域模型

  • 持久化任何更改

每个用例应作为一个原子单元成功或失败。您可能需要从另一个用例中调用一个用例。没问题;只需做个记录,并尽量避免长时间运行的数据库事务。

注意

我们遇到的最大问题之一是管理器方法调用其他管理器方法,并且数据访问可以发生在模型对象本身。很难在不跨越整个代码库进行寻宝之旅的情况下理解每个操作的含义。将所有逻辑汇总到一个方法中,并使用 UoW 来控制我们的事务,使系统更容易理解。

提示

如果在用例函数中存在重复,也没关系。我们不是要编写完美的代码;我们只是试图提取一些有意义的层。在几个地方重复一些代码要比让用例函数在长链中相互调用要好。

这是一个很好的机会,可以将任何数据访问或编排代码从领域模型中提取出来,并放入用例中。我们还应该尝试将 I/O 问题(例如发送电子邮件、写文件)从领域模型中提取出来,并放入用例函数中。我们应用第三章中关于抽象的技术,以便在执行 I/O 时保持我们的处理程序可单元测试。

这些用例函数主要涉及日志记录、数据访问和错误处理。完成此步骤后,您将了解程序实际执行的操作,并且有一种方法来确保每个操作都有明确定义的开始和结束。我们将迈出一步,朝着构建纯领域模型迈进。

阅读 Michael C. Feathers 的《与遗留代码有效地工作》(Prentice Hall)以获取有关对遗留代码进行测试和开始分离责任的指导。

识别聚合和有界上下文

在我们的案例研究中,代码库的一部分问题是对象图高度连接。每个帐户都有许多工作空间,每个工作空间都有许多成员,所有这些成员都有自己的帐户。每个工作空间包含许多文档,每个文档都有许多版本。

你无法在类图中表达事物的全部恐怖。首先,实际上并没有一个与用户相关的单个帐户。相反,有一个奇怪的规则要求您通过工作空间枚举与用户关联的所有帐户,并选择创建日期最早的帐户。

系统中的每个对象都是继承层次结构的一部分,其中包括SecureObjectVersion。这种继承层次结构直接在数据库模式中进行了镜像,因此每个查询都必须跨越 10 个不同的表进行连接,并查看鉴别器列,以便确定正在处理的对象的类型。

代码库使得可以像这样“点”穿过这些对象:

user.account.workspaces[0].documents.versions[1].owner.account.settings[0];

使用 Django ORM 或 SQLAlchemy 构建系统很容易,但应该避免。尽管这很方便,但很难理解性能,因为每个属性可能触发对数据库的查找。

提示

聚合是一致性边界。一般来说,每个用例应一次只更新一个聚合。一个处理程序从存储库中获取一个聚合,修改其状态,并引发任何作为结果发生的事件。如果您需要来自系统其他部分的数据,完全可以使用读取模型,但要避免在单个事务中更新多个聚合。当我们选择将代码分离为不同的聚合时,我们明确选择使它们最终一致

一堆操作需要我们以这种方式循环遍历对象,例如:

# Lock a user's workspaces for nonpayment

def lock_account(user):
    for workspace in user.account.workspaces:
        workspace.archive()

甚至可以递归遍历文件夹和文档的集合:

def lock_documents_in_folder(folder):

    for doc in folder.documents:
         doc.archive()

     for child in folder.children:
         lock_documents_in_folder(child)

这些操作严重影响了性能,但修复它们意味着放弃我们的单个对象图。相反,我们开始识别聚合并打破对象之间的直接链接。

注意

我们在第十二章中谈到了臭名昭著的SELECT N+1问题,以及在查询数据和命令数据时可能选择使用不同的技术。

大多数情况下,我们通过用标识符替换直接引用来实现这一点。

在聚合之前:

apwp ep02

[plantuml, apwp_ep02, config=plantuml.cfg]
@startuml
hide empty members

class Document {

  add_version ()

  workspace: Workspace
  parent: Folder

  versions: List[DocumentVersion]

}

class DocumentVersion {

  title : str
  version_number: int

  document: Document

}

class Account {
  add_package ()

  owner : User
  packages : List[BillingPackage]
  workspaces: List[Workspace]
}

class BillingPackage {
}

class Workspace {

  add_member(member: User)

  account: Account
  owner: User
  members: List[User]

}

class Folder {
  parent: Workspace
  children: List[Folder]

  copy_to(target: Folder)
  add_document(document: Document)
}

class User {
  account: Account
}

Account --> Workspace
Account --> BillingPackage
Account --> User
Workspace --> User
Workspace --> Folder
Workspace --> Account
Folder --> Folder
Folder --> Document
Folder --> Workspace
Folder --> User
Document --> DocumentVersion
Document --> Folder
Document --> User
DocumentVersion --> Document
DocumentVersion --> User
User --> Account

@enduml

建模后:

apwp ep03

[plantuml, apwp_ep03, config=plantuml.cfg]
@startuml
hide empty members

frame Document {

  class Document {

    add_version ()

    workspace_id: int
    parent_folder: int

    versions: List[DocumentVersion]

  }

  class DocumentVersion {

    title : str
    version_number: int

  }
}

frame Account {

  class Account {
    add_package ()

    owner : int
    packages : List[BillingPackage]
  }

  class BillingPackage {
  }

}

frame Workspace {
   class Workspace {

     add_member(member: int)

     account_id: int
     owner: int
     members: List[int]

   }
}

frame Folder {

  class Folder {
    workspace_id : int
    children: List[int]

    copy_to(target: int)
  }

}

Document o-- DocumentVersion
Account o-- BillingPackage

@enduml
提示

双向链接通常表明您的聚合不正确。在我们的原始代码中,Document知道其包含的Folder,而Folder有一组Documents。这使得遍历对象图很容易,但阻止我们正确思考我们需要的一致性边界。我们通过使用引用来拆分聚合。在新模型中,Document引用其parent_folder,但无法直接访问Folder

如果我们需要读取数据,我们会避免编写复杂的循环和转换,并尝试用直接的 SQL 替换它们。例如,我们的一个屏幕是文件夹和文档的树形视图。

这个屏幕在数据库上非常重,因为它依赖于触发延迟加载的 ORM 的嵌套for循环。

提示

我们在第十一章中使用了相同的技术,用一个简单的 SQL 查询替换了对 ORM 对象的嵌套循环。这是 CQRS 方法的第一步。

经过长时间的思考,我们用一个又大又丑的存储过程替换了 ORM 代码。代码看起来很糟糕,但速度要快得多,并有助于打破FolderDocument之间的联系。

当我们需要写入数据时,我们逐个更改单个聚合,并引入消息总线来处理事件。例如,在新模型中,当我们锁定一个账户时,我们可以首先查询所有受影响的工作空间。通过SELECT *id* FROM *workspace* WHERE *account_id* = ?

然后我们可以为每个工作空间提出一个新的命令:

for workspace_id in workspaces:
    bus.handle(LockWorkspace(workspace_id))

通过 Strangler 模式实现微服务的事件驱动方法

Strangler Fig模式涉及在旧系统的边缘创建一个新系统,同时保持其运行。逐渐拦截和替换旧功能,直到旧系统完全无事可做,可以关闭。

在构建可用性服务时,我们使用了一种称为事件拦截的技术,将功能从一个地方移动到另一个地方。这是一个三步过程:

  1. 引发事件来表示系统中发生的变化。

  2. 构建一个消耗这些事件并使用它们构建自己领域模型的第二个系统。

  3. 用新的系统替换旧的系统。

我们使用事件拦截从图 E-2 移动…

apwp ep04

图 E-2。之前:基于 XML-RPC 的强大的双向耦合
[plantuml, apwp_ep04, config=plantuml.cfg]
@startuml E-Commerce Context
!include images/C4_Context.puml

Person_Ext(customer, "Customer", "Wants to buy furniture")

System(fulfilment, "Fulfilment System", "Manages order fulfilment and logistics")
System(ecom, "E-commerce website", "Allows customers to buy furniture")

Rel(customer, ecom, "Uses")
Rel(fulfilment, ecom, "Updates stock and orders", "xml-rpc")
Rel(ecom, fulfilment, "Sends orders", "xml-rpc")

@enduml

到图 E-3。

apwp ep05

图 E-3。之后:与异步事件的松散耦合(您可以在 cosmicpython.com 找到此图的高分辨率版本)
[plantuml, apwp_ep05, config=plantuml.cfg]
@startuml E-Commerce Context
!include images/C4_Context.puml

Person_Ext(customer, "Customer", "Wants to buy furniture")

System(av, "Availability Service", "Calculates stock availability")
System(fulfilment, "Fulfilment System", "Manages order fulfilment and logistics")
System(ecom, "E-commerce website", "Allows customers to buy furniture")

Rel(customer, ecom, "Uses")
Rel(customer, av, "Uses")
Rel(fulfilment, av, "Publishes batch_created", "events")
Rel(av, ecom, "Publishes out_of_stock", "events")
Rel(ecom, fulfilment, "Sends orders", "xml-rpc")

@enduml

实际上,这是一个长达几个月的项目。我们的第一步是编写一个可以表示批次、装运和产品的领域模型。我们使用 TDD 构建了一个玩具系统,可以回答一个问题:“如果我想要 N 个单位的 HAZARDOUS_RUG,它们需要多长时间才能被交付?”

提示

在部署事件驱动系统时,从“walking skeleton”开始。部署一个只记录其输入的系统迫使我们解决所有基础设施问题,并开始在生产中工作。

一旦我们有了一个可行的领域模型,我们就转而构建一些基础设施。我们的第一个生产部署是一个可以接收batch_created事件并记录其 JSON 表示的小型系统。这是事件驱动架构的“Hello World”。它迫使我们部署消息总线,连接生产者和消费者,构建部署管道,并编写一个简单的消息处理程序。

有了部署管道、我们需要的基础设施和一个基本的领域模型,我们就开始了。几个月后,我们投入生产并为真正的客户提供服务。

说服利益相关者尝试新事物

如果你正在考虑从一个庞大的泥球中切割出一个新系统,你可能正在遭受可靠性、性能、可维护性或三者同时出现的问题。深层次的、棘手的问题需要采取激烈的措施!

我们建议首先进行领域建模。在许多庞大的系统中,工程师、产品所有者和客户不再使用相同的语言交流。业务利益相关者用抽象的、流程为中心的术语谈论系统,而开发人员被迫谈论系统在其野生和混乱状态下的实际存在。

弄清楚如何对领域进行建模是一个复杂的任务,这是许多不错的书籍的主题。我们喜欢使用诸如事件风暴和 CRC 建模之类的互动技术,因为人类擅长通过玩耍来合作。事件建模是另一种技术,它将工程师和产品所有者聚集在一起,以命令、查询和事件的方式来理解系统。

提示

查看www.eventmodeling.orgwww.eventstorming.org,了解一些关于使用事件进行系统可视化建模的很好的指南。

目标是能够通过使用相同的通用语言来谈论系统,这样你就可以就复杂性所在达成一致。

我们发现将领域问题视为 TDD kata 非常有价值。例如,我们为可用性服务编写的第一行代码是批处理和订单行模型。你可以将其视为午餐时间的研讨会,或者作为项目开始时的一个突发事件。一旦你能够证明建模的价值,就更容易为优化项目结构提出论点。

我们的技术审阅者提出的问题,我们无法融入散文中

以下是我们在起草过程中听到的一些问题,我们无法在书中其他地方找到一个好地方来解决:

我需要一次做完所有这些吗?我可以一次只做一点吗?

不,你绝对可以逐步采用这些技术。如果你有一个现有的系统,我们建议建立一个服务层,试图将编排保持在一个地方。一旦你有了这个,将逻辑推入模型并将边缘关注点(如验证或错误处理)推入入口点就容易得多。

即使你仍然有一个庞大混乱的 Django ORM,也值得拥有一个服务层,因为这是开始理解操作边界的一种方式。

提取用例将破坏我现有的大量代码;它太混乱了

只需复制粘贴。在短期内造成更多的重复是可以的。把这看作一个多步过程。你的代码现在处于糟糕的状态,所以将其复制粘贴到一个新的地方,然后使新代码变得干净整洁。

一旦你做到了这一点,你可以用新代码替换旧代码的使用,最终删除混乱。修复庞大的代码库是一个混乱而痛苦的过程。不要指望事情会立即变得更好,如果你的应用程序的某些部分保持混乱,也不要担心。

我需要做 CQRS 吗?那听起来很奇怪。我不能只是使用存储库吗?

当然可以!我们在这本书中提出的技术旨在让你的生活变得更轻松。它们不是一种用来惩罚自己的苦行修行。

在我们的第一个案例研究系统中,我们有很多视图构建器对象,它们使用存储库来获取数据,然后执行一些转换以返回愚蠢的读取模型。优点是,当您遇到性能问题时,很容易重写视图构建器以使用自定义查询或原始 SQL。

用例在一个更大的系统中如何交互?一个调用另一个会有问题吗?

这可能是一个临时步骤。同样,在第一个案例研究中,我们有一些处理程序需要调用其他处理程序。然而,这会变得非常混乱,最好的方法是使用消息总线来分离这些关注点。

通常,您的系统将有一个单一的消息总线实现和一堆以特定聚合或一组聚合为中心的子域。当您的用例完成时,它可以引发一个事件,然后其他地方的处理程序可以运行。

如果一个用例使用多个存储库/聚合,这是一种代码异味吗?如果是,为什么?

聚合是一致性边界,所以如果你的用例需要在同一个事务中原子地更新两个聚合,那么严格来说你的一致性边界是错误的。理想情况下,你应该考虑将其移动到一个新的聚合中,该聚合将同时更改所有你想要更改的内容。

如果您实际上只更新一个聚合并使用其他聚合进行只读访问,那么这是可以的,尽管您可以考虑构建一个读取/视图模型来获取这些数据——如果每个用例只有一个聚合,这样做会使事情变得更清晰。

如果你确实需要修改两个聚合,但这两个操作不必在同一个事务/UoW 中,那么考虑将工作拆分成两个不同的处理程序,并使用领域事件在两者之间传递信息。您可以在Vaughn Vernon 的这些聚合设计论文中阅读更多内容。

如果我有一个只读但业务逻辑复杂的系统呢?

视图模型中可能包含复杂的逻辑。在本书中,我们鼓励您将读取模型和写入模型分开,因为它们具有不同的一致性和吞吐量要求。大多数情况下,我们可以对读取使用更简单的逻辑,但并非总是如此。特别是,权限和授权模型可能会给我们的读取端增加很多复杂性。

我们编写了需要进行广泛单元测试的视图模型的系统。在这些系统中,我们将视图构建器视图获取器分开,如图 E-4 所示。

apwp ep06

图 E-4. 视图构建器和视图获取器(您可以在 cosmicpython.com 找到此图的高分辨率版本)
[plantuml, apwp_ep06, config=plantuml.cfg]
@startuml View Fetcher Component Diagram
!include images/C4_Component.puml

LAYOUT_LEFT_RIGHT

ComponentDb(db, "Database", "RDBMS")
Component(fetch, "View Fetcher", "Reads data from db, returning list of tuples or dicts")
Component(build, "View Builder", "Filters and maps tuples")
Component(api, "API", "Handles HTTP and serialization concerns")

Rel(fetch, db, "Read data from")
Rel(build, fetch, "Invokes")
Rel(api, build, "Invokes")

@enduml
  • 这使得通过提供模拟数据(例如,字典列表)来测试视图构建器变得很容易。“Fancy CQRS”与事件处理程序实际上是一种在写入时运行我们复杂的视图逻辑的方法,以便我们在读取时避免运行它。

我需要构建微服务来做这些事情吗?

天哪,不!这些技术早在十年前就出现了微服务。聚合、领域事件和依赖反转是控制大型系统复杂性的方法。恰好当您构建了一组用例和业务流程模型时,将其移动到自己的服务相对容易,但这并不是必需的。

我正在使用 Django。我还能做到这一点吗?

我们为您准备了整个附录:附录 D!

脚枪

好的,所以我们给了你一堆新玩具来玩。这是详细说明。Harry 和 Bob 不建议您将我们的代码复制粘贴到生产系统中,并在 Redis pub/sub 上重建您的自动交易平台。出于简洁和简单起见,我们对许多棘手的主题进行了手波。在尝试这个之前,这是我们认为您应该知道的一些事情的清单。

可靠的消息传递很困难

Redis pub/sub 不可靠,不应作为通用消息工具使用。我们选择它是因为它熟悉且易于运行。在 MADE,我们将 Event Store 作为我们的消息工具,但我们也有 RabbitMQ 和 Amazon EventBridge 的经验。

Tyler Treat 在他的网站bravenewgeek.com上有一些优秀的博客文章;您至少应该阅读“您无法实现精确一次交付”“您想要的是您不想要的:理解分布式消息传递中的权衡”

我们明确选择了可以独立失败的小型、专注的交易

在第八章中,我们更新了我们的流程,以便释放订单行和重新分配行发生在两个单独的工作单元中。您将需要监控以了解这些事务失败的时间,并使用工具重放事件。使用交易日志作为您的消息代理(例如 Kafka 或 EventStore)可以使其中一些变得更容易。您还可以查看Outbox 模式

我们没有讨论幂等性

我们还没有认真考虑处理程序重试时会发生什么。在实践中,您将希望使处理程序幂等,这样重复调用它们不会对状态进行重复更改。这是构建可靠性的关键技术,因为它使我们能够在事件失败时安全地重试事件。

关于幂等消息处理有很多好的材料,可以从“如何确保在最终一致的 DDD/CQRS 应用程序中的幂等性”“消息传递中的(不)可靠性”开始阅读。

您的事件将需要随时间改变其模式

您需要找到一种方式来记录您的事件并与消费者共享模式。我们喜欢使用 JSON 模式和 markdown,因为它简单易懂,但也有其他先前的技术。Greg Young 写了一整本关于随时间管理事件驱动系统的书籍:事件驱动系统中的版本控制(Leanpub)。

更多必读书籍

我们还想推荐一些书籍,以帮助您更好地理解:

  • Leonardo Giordani(Leanpub)在 2019 年出版的《Python 中的干净架构》是 Python 应用架构的少数几本先前的书籍之一。

  • Gregor Hohpe 和 Bobby Woolf(Addison-Wesley Professional)的企业集成模式是消息模式的一个很好的起点。

  • Sam Newman 的从单体到微服务(O'Reilly)和 Newman 的第一本书构建微服务(O'Reilly)。Strangler Fig 模式被提及为一个喜欢的模式,还有许多其他模式。如果您正在考虑转向微服务,这些都是值得一看的,它们也对集成模式和异步消息传递的考虑非常有帮助。

总结

哇!这是很多警告和阅读建议;我们希望我们没有完全吓到您。我们撰写本书的目标是为您提供足够的知识和直觉,让您能够开始为自己构建一些东西。我们很乐意听听您的进展以及您在自己系统中使用这些技术时遇到的问题,所以为什么不通过www.cosmicpython.com与我们联系呢?

附录 A:摘要图和表

原文:Appendix A: Summary Diagram and Table

译者:飞龙

协议:CC BY-NC-SA 4.0

这是我们在书的最后看到的架构:

显示所有组件的图表:flask+事件消费者,服务层,适配器,领域等

表 A-1 总结了每个模式及其功能。

表 A-1. 我们的架构组件及其功能

组件 描述
领域 定义业务逻辑。
实体 一个领域对象,其属性可能会改变,但随着时间的推移具有可识别的身份。
值对象 一个不可变的领域对象,其属性完全定义它。它可以与其他相同的对象互换。
聚合 一组相关对象,我们将其视为数据更改的一个单元。定义和强制一致性边界。
事件 代表发生的事情。
命令 代表系统应执行的作业。
服务层 定义系统应执行的作业并协调不同的组件。
处理程序 接收命令或事件并执行需要发生的操作。
工作单元 围绕数据完整性的抽象。每个工作单元代表一个原子更新。使存储库可用。跟踪检索到的聚合上的新事件。
消息总线(内部) 通过将命令和事件路由到适当的处理程序来处理命令和事件。
适配器(次要) 接口的具体实现,从我们的系统到外部世界(I/O)。
存储库 围绕持久存储的抽象。每个聚合都有自己的存储库。
事件发布者 将事件推送到外部消息总线上。
入口点(主要适配器) 将外部输入转换为对服务层的调用。
Web 接收 Web 请求并将其转换为命令,将其传递到内部消息总线。
事件消费者 从外部消息总线读取事件并将其转换为命令,将其传递到内部消息总线。
N/A 外部消息总线(消息代理)

附录 B:模板项目结构

原文:Appendix B: A Template Project Structure

译者:飞龙

协议:CC BY-NC-SA 4.0

在第四章周围,我们从只在一个文件夹中拥有所有内容转移到了更有结构的树形结构,并且我们认为可能会对梳理各个部分感兴趣。

提示

本附录的代码位于 GitHub 上的 appendix_project_structure 分支中(https://oreil.ly/1rDRC)

git clone https://github.com/cosmicpython/code.git
cd code
git checkout appendix_project_structure

基本的文件夹结构如下:

项目树

.
├── Dockerfile  (1)
├── Makefile  (2)
├── README.md
├── docker-compose.yml  (1)
├── license.txt
├── mypy.ini
├── requirements.txt
├── src  (3)
│   ├── allocation
│   │   ├── __init__.py
│   │   ├── adapters
│   │   │   ├── __init__.py
│   │   │   ├── orm.py
│   │   │   └── repository.py
│   │   ├── config.py
│   │   ├── domain
│   │   │   ├── __init__.py
│   │   │   └── model.py
│   │   ├── entrypoints
│   │   │   ├── __init__.py
│   │   │   └── flask_app.py
│   │   └── service_layer
│   │       ├── __init__.py
│   │       └── services.py
│   └── setup.py  (3)
└── tests  (4)
    ├── conftest.py  (4)
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_orm.py
    │   └── test_repository.py
    ├── pytest.ini  (4)
    └── unit
        ├── test_allocate.py
        ├── test_batches.py
        └── test_services.py

我们的docker-compose.yml和我们的Dockerfile是运行我们的应用程序的容器的主要配置部分,它们也可以运行测试(用于 CI)。一个更复杂的项目可能会有几个 Dockerfile,尽管我们发现最小化镜像数量通常是一个好主意。¹

Makefile提供了开发人员(或 CI 服务器)在其正常工作流程中可能想要运行的所有典型命令的入口点:make buildmake test等。² 这是可选的。您可以直接使用docker-composepytest,但是如果没有其他选择,将所有“常用命令”列在某个地方是很好的,而且与文档不同,Makefile 是代码,因此不太容易过时。

我们应用程序的所有源代码,包括领域模型、Flask 应用程序和基础设施代码,都位于src内的 Python 包中,³我们使用pip install -esetup.py文件进行安装。这使得导入变得容易。目前,此模块内的结构完全是平面的,但对于更复杂的项目,您可以期望增加一个包含domain_model/infrastructure/services/api/的文件夹层次结构。

测试位于它们自己的文件夹中。子文件夹区分不同的测试类型,并允许您分别运行它们。我们可以在主测试文件夹中保留共享的固定装置(conftest.py),并在需要时嵌套更具体的固定装置。这也是保留pytest.ini的地方。

提示

pytest 文档在测试布局和可导入性方面非常好。

让我们更详细地看一下这些文件和概念。

环境变量、12 因素和配置,内部和外部容器

我们在这里要解决的基本问题是,我们需要不同的配置设置,用于以下情况:

  • 直接从您自己的开发机器运行代码或测试,可能是从 Docker 容器的映射端口进行通信

  • 在容器本身上运行,使用“真实”端口和主机名

  • 不同的容器环境(开发、暂存、生产等)

通过12 因素宣言建议的环境变量配置将解决这个问题,但具体来说,我们如何在我们的代码和容器中实现它呢?

Config.py

每当我们的应用程序代码需要访问某些配置时,它将从一个名为config.py的文件中获取。以下是我们应用程序中的一些示例:

示例配置函数(src/allocation/config.py

import os


def get_postgres_uri():  #(1)
    host = os.environ.get("DB_HOST", "localhost")  #(2)
    port = 54321 if host == "localhost" else 5432
    password = os.environ.get("DB_PASSWORD", "abc123")
    user, db_name = "allocation", "allocation"
    return f"postgresql://{user}:{password}@{host}:{port}/{db_name}"


def get_api_url():
    host = os.environ.get("API_HOST", "localhost")
    port = 5005 if host == "localhost" else 80
    return f"http://{host}:{port}"

我们使用函数来获取当前的配置,而不是在导入时可用的常量,因为这样可以让客户端代码修改os.environ

config.py还定义了一些默认设置,设计用于在从开发人员的本地机器运行代码时工作。⁴

一个名为environ-config的优雅 Python 包值得一看,如果您厌倦了手动编写基于环境的配置函数。

提示

不要让这个配置模块成为一个充满了与配置只有模糊关系的东西的倾倒场所,然后在各个地方都导入它。保持事物不可变,并且只通过环境变量进行修改。如果您决定使用引导脚本,您可以将其作为导入配置的唯一位置(除了测试)。

Docker-Compose 和容器配置

我们使用一个轻量级的 Docker 容器编排工具叫做docker-compose。它的主要配置是通过一个 YAML 文件(叹气):⁵

docker-compose配置文件(docker-compose.yml)

version: "3"
services:

  app:  #(1)
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      - postgres
    environment:  #(3)
      - DB_HOST=postgres  (4)
      - DB_PASSWORD=abc123
      - API_HOST=app
      - PYTHONDONTWRITEBYTECODE=1  #(5)
    volumes:  #(6)
      - ./src:/src
      - ./tests:/tests
    ports:
      - "5005:80"  (7)


  postgres:
    image: postgres:9.6  #(2)
    environment:
      - POSTGRES_USER=allocation
      - POSTGRES_PASSWORD=abc123
    ports:
      - "54321:5432"

docker-compose文件中,我们定义了我们应用程序所需的不同services(容器)。通常一个主要的镜像包含了我们所有的代码,我们可以使用它来运行我们的 API,我们的测试,或者任何其他需要访问领域模型的服务。

您可能会有其他基础设施服务,包括数据库。在生产环境中,您可能不会使用容器;您可能会使用云提供商,但是docker-compose为我们提供了一种在开发或 CI 中生成类似服务的方式。

environment部分允许您为容器设置环境变量,主机名和端口,从 Docker 集群内部看到。如果您有足够多的容器,这些信息开始在这些部分中重复,您可以改用environment_file。我们通常称为container.env

在集群内,docker-compose设置了网络,使得容器可以通过其服务名称命名的主机名相互访问。

专业提示:如果您将卷挂载到本地开发机器和容器之间共享源文件夹,PYTHONDONTWRITEBYTECODE环境变量告诉 Python 不要写入.pyc文件,这将使您免受在本地文件系统上到处都是数百万个根文件的困扰,删除起来很烦人,并且会导致奇怪的 Python 编译器错误。

6

将我们的源代码和测试代码作为volumes挂载意味着我们不需要在每次代码更改时重新构建我们的容器。

7

ports部分允许我们将容器内部的端口暴露到外部世界⁶——这些对应于我们在config.py中设置的默认端口。

注意

在 Docker 内部,其他容器可以通过其服务名称命名的主机名访问。在 Docker 外部,它们可以在localhost上访问,在ports部分定义的端口上。

将您的源代码安装为包

我们所有的应用程序代码(除了测试,实际上)都存放在src文件夹内:

src 文件夹

├── src
│   ├── allocation (1) 
│   │   ├── config.py
│   │   └── ...
│   └── setup.py (2)

子文件夹定义了顶级模块名称。您可以有多个。

setup.py是您需要使其可通过 pip 安装的文件,下面会展示。

src/setup.py中的三行可安装的 pip 模块

from setuptools import setup

setup(
    name='allocation',
    version='0.1',
    packages=['allocation'],
)

这就是您需要的全部。packages=指定要安装为顶级模块的子文件夹的名称。name条目只是装饰性的,但是是必需的。对于一个永远不会真正进入 PyPI 的包,它会很好。⁷

Dockerfile

Dockerfile 将会是非常特定于项目的,但是这里有一些您期望看到的关键阶段:

我们的 Dockerfile(Dockerfile)

FROM python:3.9-slim-buster

(1)
# RUN apt install gcc libpq (no longer needed bc we use psycopg2-binary)

(2)
COPY requirements.txt /tmp/
RUN pip install -r /tmp/requirements.txt

(3)
RUN mkdir -p /src
COPY src/ /src/
RUN pip install -e /src
COPY tests/ /tests/

(4)
WORKDIR /src
ENV FLASK_APP=allocation/entrypoints/flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1
CMD flask run --host=0.0.0.0 --port=80

安装系统级依赖

安装我们的 Python 依赖项(您可能希望将开发和生产依赖项分开;为简单起见,我们没有这样做)

复制和安装我们的源代码

可选配置默认启动命令(您可能经常需要从命令行覆盖这个)

提示

需要注意的一件事是,我们按照它们可能发生变化的频率安装东西的顺序。这使我们能够最大程度地重用 Docker 构建缓存。我无法告诉你这个教训背后有多少痛苦和挫折。有关此问题以及更多 Python Dockerfile 改进提示,请查看“可生产使用的 Docker 打包”

测试

我们的测试与其他所有内容一起保存,如下所示:

测试文件夹树

└── tests
    ├── conftest.py
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_orm.py
    │   └── test_repository.py
    ├── pytest.ini
    └── unit
        ├── test_allocate.py
        ├── test_batches.py
        └── test_services.py

这里没有特别聪明的地方,只是一些不同测试类型的分离,您可能希望单独运行,以及一些用于常见固定装置、配置等的文件。

在测试文件夹中没有src文件夹或setup.py,因为我们通常不需要使测试可通过 pip 安装,但如果您在导入路径方面遇到困难,您可能会发现它有所帮助。

总结

这些是我们的基本构建模块:

  • src文件夹中的源代码,可以使用setup.py进行 pip 安装

  • 一些 Docker 配置,用于尽可能模拟生产环境的本地集群

  • 通过环境变量进行配置,集中在一个名为config.py的 Python 文件中,其中默认值允许事情在容器外运行

  • 一个用于有用的命令行命令的 Makefile

我们怀疑没有人会得到完全与我们相同的解决方案,但我们希望你在这里找到一些灵感。

¹ 有时将图像分离用于生产和测试是一个好主意,但我们倾向于发现进一步尝试为不同类型的应用程序代码(例如,Web API 与发布/订阅客户端)分离不值得麻烦;在复杂性和更长的重建/CI 时间方面的成本太高。你的情况可能有所不同。

² 一个纯 Python 的 Makefile 替代方案是Invoke,值得一试,如果你的团队每个人都懂 Python(或者至少比 Bash 更懂)。

³ Hynek Schlawack 的“测试和打包”提供了有关src文件夹的更多信息。

⁴ 这为我们提供了一个“只要可能就能工作”的本地开发设置。你可能更喜欢在缺少环境变量时严格失败,特别是如果任何默认值在生产中可能不安全。

⁵ Harry 对 YAML 有点厌倦。它无处不在,但他永远记不住语法或应该如何缩进。

⁶ 在 CI 服务器上,您可能无法可靠地暴露任意端口,但这只是本地开发的便利。您可以找到使这些端口映射可选的方法(例如,使用docker-compose.override.yml)。

⁷ 有关更多setup.py提示,请参阅 Hynek 的这篇关于打包的文章

附录 C:更换基础设施:使用 CSV 做所有事情

原文:Appendix C: Swapping Out the Infrastructure: Do Everything with CSVs

译者:飞龙

协议:CC BY-NC-SA 4.0

本附录旨在简要说明 Repository、UnitOfWork 和 Service Layer 模式的好处。它旨在从第六章中延伸出来。

就在我们完成构建 Flask API 并准备发布时,业务部门来找我们,道歉地说他们还没有准备好使用我们的 API,并询问我们是否可以构建一个仅从几个 CSV 中读取批次和订单并输出第三个 CSV 的东西。

通常这是一种可能会让团队咒骂、唾弃并为他们的回忆做笔记的事情。但我们不会!哦不,我们已经确保我们的基础设施问题与我们的领域模型和服务层很好地解耦。切换到 CSV 将只是简单地编写一些新的RepositoryUnitOfWork类,然后我们就能重用领域层和服务层的所有逻辑。

这是一个 E2E 测试,向您展示 CSV 的流入和流出:

第一个 CSV 测试(tests/e2e/test_csv.py

def test_cli_app_reads_csvs_with_batches_and_orders_and_outputs_allocations(
        make_csv
):
    sku1, sku2 = random_ref('s1'), random_ref('s2')
    batch1, batch2, batch3 = random_ref('b1'), random_ref('b2'), random_ref('b3')
    order_ref = random_ref('o')
    make_csv('batches.csv', [
        ['ref', 'sku', 'qty', 'eta'],
        [batch1, sku1, 100, ''],
        [batch2, sku2, 100, '2011-01-01'],
        [batch3, sku2, 100, '2011-01-02'],
    ])
    orders_csv = make_csv('orders.csv', [
        ['orderid', 'sku', 'qty'],
        [order_ref, sku1, 3],
        [order_ref, sku2, 12],
    ])

    run_cli_script(orders_csv.parent)

    expected_output_csv = orders_csv.parent / 'allocations.csv'
    with open(expected_output_csv) as f:
        rows = list(csv.reader(f))
    assert rows == [
        ['orderid', 'sku', 'qty', 'batchref'],
        [order_ref, sku1, '3', batch1],
        [order_ref, sku2, '12', batch2],
    ]

毫无思考地实现并不考虑存储库和所有那些花哨东西,你可能会从这样的东西开始:

我们的 CSV 读取器/写入器的第一个版本(src/bin/allocate-from-csv)

#!/usr/bin/env python
import csv
import sys
from datetime import datetime
from pathlib import Path

from allocation import model

def load_batches(batches_path):
    batches = []
    with batches_path.open() as inf:
        reader = csv.DictReader(inf)
        for row in reader:
            if row['eta']:
                eta = datetime.strptime(row['eta'], '%Y-%m-%d').date()
            else:
                eta = None
            batches.append(model.Batch(
                ref=row['ref'],
                sku=row['sku'],
                qty=int(row['qty']),
                eta=eta
            ))
    return batches

def main(folder):
    batches_path = Path(folder) / 'batches.csv'
    orders_path = Path(folder) / 'orders.csv'
    allocations_path = Path(folder) / 'allocations.csv'

    batches = load_batches(batches_path)

    with orders_path.open() as inf, allocations_path.open('w') as outf:
        reader = csv.DictReader(inf)
        writer = csv.writer(outf)
        writer.writerow(['orderid', 'sku', 'batchref'])
        for row in reader:
            orderid, sku = row['orderid'], row['sku']
            qty = int(row['qty'])
            line = model.OrderLine(orderid, sku, qty)
            batchref = model.allocate(line, batches)
            writer.writerow([line.orderid, line.sku, batchref])

if __name__ == '__main__':
    main(sys.argv[1])

看起来还不错!而且我们正在重用我们的领域模型对象和领域服务。

但这不会起作用。现有的分配也需要成为我们永久 CSV 存储的一部分。我们可以编写第二个测试来迫使我们改进事情:

另一个,带有现有分配(tests/e2e/test_csv.py

def test_cli_app_also_reads_existing_allocations_and_can_append_to_them(
        make_csv
):
    sku = random_ref('s')
    batch1, batch2 = random_ref('b1'), random_ref('b2')
    old_order, new_order = random_ref('o1'), random_ref('o2')
    make_csv('batches.csv', [
        ['ref', 'sku', 'qty', 'eta'],
        [batch1, sku, 10, '2011-01-01'],
        [batch2, sku, 10, '2011-01-02'],
    ])
    make_csv('allocations.csv', [
        ['orderid', 'sku', 'qty', 'batchref'],
        [old_order, sku, 10, batch1],
    ])
    orders_csv = make_csv('orders.csv', [
        ['orderid', 'sku', 'qty'],
        [new_order, sku, 7],
    ])

    run_cli_script(orders_csv.parent)

    expected_output_csv = orders_csv.parent / 'allocations.csv'
    with open(expected_output_csv) as f:
        rows = list(csv.reader(f))
    assert rows == [
        ['orderid', 'sku', 'qty', 'batchref'],
        [old_order, sku, '10', batch1],
        [new_order, sku, '7', batch2],
    ]

我们可以继续对load_batches函数进行修改并添加额外的行,以及一种跟踪和保存新分配的方式,但我们已经有了一个可以做到这一点的模型!它叫做我们的 Repository 和 UnitOfWork 模式。

我们需要做的(“我们需要做的”)只是重新实现相同的抽象,但是以 CSV 作为基础,而不是数据库。正如您将看到的,这确实相对简单。

为 CSV 实现 Repository 和 UnitOfWork

一个基于 CSV 的存储库可能看起来像这样。它将磁盘上读取 CSV 的所有逻辑抽象出来,包括它必须读取两个不同的 CSV(一个用于批次,一个用于分配),并且它给我们提供了熟悉的.list() API,这提供了一个领域对象的内存集合的幻觉:

使用 CSV 作为存储机制的存储库(src/allocation/service_layer/csv_uow.py

class CsvRepository(repository.AbstractRepository):

    def __init__(self, folder):
        self._batches_path = Path(folder) / 'batches.csv'
        self._allocations_path = Path(folder) / 'allocations.csv'
        self._batches = {}  # type: Dict[str, model.Batch]
        self._load()

    def get(self, reference):
        return self._batches.get(reference)

    def add(self, batch):
        self._batches[batch.reference] = batch

    def _load(self):
        with self._batches_path.open() as f:
            reader = csv.DictReader(f)
            for row in reader:
                ref, sku = row['ref'], row['sku']
                qty = int(row['qty'])
                if row['eta']:
                    eta = datetime.strptime(row['eta'], '%Y-%m-%d').date()
                else:
                    eta = None
                self._batches[ref] = model.Batch(
                    ref=ref, sku=sku, qty=qty, eta=eta
                )
        if self._allocations_path.exists() is False:
            return
        with self._allocations_path.open() as f:
            reader = csv.DictReader(f)
            for row in reader:
                batchref, orderid, sku = row['batchref'], row['orderid'], row['sku']
                qty = int(row['qty'])
                line = model.OrderLine(orderid, sku, qty)
                batch = self._batches[batchref]
                batch._allocations.add(line)

    def list(self):
        return list(self._batches.values())

这是一个用于 CSV 的 UoW 会是什么样子的示例:

用于 CSV 的 UoW:commit = csv.writer(src/allocation/service_layer/csv_uow.py

class CsvUnitOfWork(unit_of_work.AbstractUnitOfWork):

    def __init__(self, folder):
        self.batches = CsvRepository(folder)

    def commit(self):
        with self.batches._allocations_path.open('w') as f:
            writer = csv.writer(f)
            writer.writerow(['orderid', 'sku', 'qty', 'batchref'])
            for batch in self.batches.list():
                for line in batch._allocations:
                    writer.writerow(
                        [line.orderid, line.sku, line.qty, batch.reference]
                    )

    def rollback(self):
        pass

一旦我们做到了,我们用于读取和写入批次和分配到 CSV 的 CLI 应用程序将被简化为它应该有的东西——一些用于读取订单行的代码,以及一些调用我们现有服务层的代码:

用九行代码实现 CSV 的分配(src/bin/allocate-from-csv)

def main(folder):
    orders_path = Path(folder) / 'orders.csv'
    uow = csv_uow.CsvUnitOfWork(folder)
    with orders_path.open() as f:
        reader = csv.DictReader(f)
        for row in reader:
            orderid, sku = row['orderid'], row['sku']
            qty = int(row['qty'])
            services.allocate(orderid, sku, qty, uow)

哒哒!现在你们是不是印象深刻了

Much love,

Bob and Harry

附录 D:使用 Django 的存储库和工作单元模式

原文:Appendix D: Repository and Unit of Work Patterns with Django

译者:飞龙

协议:CC BY-NC-SA 4.0

假设你想使用 Django 而不是 SQLAlchemy 和 Flask。事情可能会是什么样子?首先要选择安装的位置。我们将其放在与我们的主要分配代码相邻的一个单独的包中:

├── src
│   ├── allocation
│   │   ├── __init__.py
│   │   ├── adapters
│   │   │   ├── __init__.py
...
│   ├── djangoproject
│   │   ├── alloc
│   │   │   ├── __init__.py
│   │   │   ├── apps.py
│   │   │   ├── migrations
│   │   │   │   ├── 0001_initial.py
│   │   │   │   └── __init__.py
│   │   │   ├── models.py
│   │   │   └── views.py
│   │   ├── django_project
│   │   │   ├── __init__.py
│   │   │   ├── settings.py
│   │   │   ├── urls.py
│   │   │   └── wsgi.py
│   │   └── manage.py
│   └── setup.py
└── tests
    ├── conftest.py
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_repository.py
...
提示

这个附录的代码在 GitHub 的 appendix_django 分支中GitHub

git clone https://github.com/cosmicpython/code.git
cd code
git checkout appendix_django

使用 Django 的存储库模式

我们使用了一个名为pytest-django的插件来帮助管理测试数据库。

重写第一个存储库测试是一个最小的改变 - 只是用 Django ORM/QuerySet 语言重写了一些原始 SQL:

第一个存储库测试适应(tests/integration/test_repository.py

from djangoproject.alloc import models as django_models

@pytest.mark.django_db
def test_repository_can_save_a_batch():
    batch = model.Batch("batch1", "RUSTY-SOAPDISH", 100, eta=date(2011, 12, 25))

    repo = repository.DjangoRepository()
    repo.add(batch)

    [saved_batch] = django_models.Batch.objects.all()
    assert saved_batch.reference == batch.reference
    assert saved_batch.sku == batch.sku
    assert saved_batch.qty == batch._purchased_quantity
    assert saved_batch.eta == batch.eta

第二个测试涉及的内容更多,因为它有分配,但它仍然由熟悉的 Django 代码组成:

第二个存储库测试更复杂(tests/integration/test_repository.py

@pytest.mark.django_db
def test_repository_can_retrieve_a_batch_with_allocations():
    sku = "PONY-STATUE"
    d_line = django_models.OrderLine.objects.create(orderid="order1", sku=sku, qty=12)
    d_b1 = django_models.Batch.objects.create(
    reference="batch1", sku=sku, qty=100, eta=None
)
    d_b2 = django_models.Batch.objects.create(
    reference="batch2", sku=sku, qty=100, eta=None
)
    django_models.Allocation.objects.create(line=d_line, batch=d_batch1)

    repo = repository.DjangoRepository()
    retrieved = repo.get("batch1")

    expected = model.Batch("batch1", sku, 100, eta=None)
    assert retrieved == expected  # Batch.__eq__ only compares reference
    assert retrieved.sku == expected.sku
    assert retrieved._purchased_quantity == expected._purchased_quantity
    assert retrieved._allocations == {
        model.OrderLine("order1", sku, 12),
    }

这是实际存储库的最终外观:

一个 Django 存储库(src/allocation/adapters/repository.py

class DjangoRepository(AbstractRepository):

    def add(self, batch):
        super().add(batch)
        self.update(batch)

    def update(self, batch):
        django_models.Batch.update_from_domain(batch)

    def _get(self, reference):
        return django_models.Batch.objects.filter(
            reference=reference
        ).first().to_domain()

    def list(self):
        return [b.to_domain() for b in django_models.Batch.objects.all()]

你可以看到,实现依赖于 Django 模型具有一些自定义方法,用于转换到我们的领域模型和从领域模型转换。¹

Django ORM 类上的自定义方法,用于转换到/从我们的领域模型

这些自定义方法看起来像这样:

Django ORM 与领域模型转换的自定义方法(src/djangoproject/alloc/models.py

from django.db import models
from allocation.domain import model as domain_model


class Batch(models.Model):
    reference = models.CharField(max_length=255)
    sku = models.CharField(max_length=255)
    qty = models.IntegerField()
    eta = models.DateField(blank=True, null=True)

    @staticmethod
    def update_from_domain(batch: domain_model.Batch):
        try:
            b = Batch.objects.get(reference=batch.reference)  #(1)
        except Batch.DoesNotExist:
            b = Batch(reference=batch.reference)  #(1)
        b.sku = batch.sku
        b.qty = batch._purchased_quantity
        b.eta = batch.eta  #(2)
        b.save()
        b.allocation_set.set(
            Allocation.from_domain(l, b)  #(3)
            for l in batch._allocations
        )

    def to_domain(self) -> domain_model.Batch:
        b = domain_model.Batch(
            ref=self.reference, sku=self.sku, qty=self.qty, eta=self.eta
        )
        b._allocations = set(
            a.line.to_domain()
            for a in self.allocation_set.all()
        )
        return b


class OrderLine(models.Model):
    #...

对于值对象,objects.get_or_create 可以工作,但对于实体,你可能需要一个显式的 try-get/except 来处理 upsert。²

我们在这里展示了最复杂的例子。如果你决定这样做,请注意会有样板代码!幸运的是,它并不是非常复杂的样板代码。

关系也需要一些谨慎的自定义处理。

注意

就像在第二章中一样,我们使用了依赖反转。ORM(Django)依赖于模型,而不是相反。

使用 Django 的工作单元模式

测试并没有太大改变:

适应的 UoW 测试(tests/integration/test_uow.py

def insert_batch(ref, sku, qty, eta):  #(1)
    django_models.Batch.objects.create(reference=ref, sku=sku, qty=qty, eta=eta)


def get_allocated_batch_ref(orderid, sku):  #(1)
    return django_models.Allocation.objects.get(
        line__orderid=orderid, line__sku=sku
    ).batch.reference


@pytest.mark.django_db(transaction=True)
def test_uow_can_retrieve_a_batch_and_allocate_to_it():
    insert_batch("batch1", "HIPSTER-WORKBENCH", 100, None)

    uow = unit_of_work.DjangoUnitOfWork()
    with uow:
        batch = uow.batches.get(reference="batch1")
        line = model.OrderLine("o1", "HIPSTER-WORKBENCH", 10)
        batch.allocate(line)
        uow.commit()

    batchref = get_allocated_batch_ref("o1", "HIPSTER-WORKBENCH")
    assert batchref == "batch1"


@pytest.mark.django_db(transaction=True)  #(2)
def test_rolls_back_uncommitted_work_by_default():
    ...

@pytest.mark.django_db(transaction=True)  #(2)
def test_rolls_back_on_error():
    ...

因为在这些测试中有一些小的辅助函数,所以实际的测试主体基本上与 SQLAlchemy 时一样。

pytest-django mark.django_db(transaction=True) 是必须的,用于测试我们的自定义事务/回滚行为。

实现相当简单,尽管我尝试了几次才找到 Django 事务魔法的调用:

适用于 Django 的 UoW(src/allocation/service_layer/unit_of_work.py

class DjangoUnitOfWork(AbstractUnitOfWork):
    def __enter__(self):
        self.batches = repository.DjangoRepository()
        transaction.set_autocommit(False)  #(1)
        return super().__enter__()

    def __exit__(self, *args):
        super().__exit__(*args)
        transaction.set_autocommit(True)

    def commit(self):
        for batch in self.batches.seen:  #(3)
            self.batches.update(batch)  #(3)
        transaction.commit()  #(2)

    def rollback(self):
        transaction.rollback()  #(2)

set_autocommit(False) 是告诉 Django 停止自动立即提交每个 ORM 操作,并开始一个事务的最佳方法。

然后我们使用显式回滚和提交。

一个困难:因为与 SQLAlchemy 不同,我们不是在领域模型实例本身上进行检测,commit() 命令需要显式地通过每个存储库触及的所有对象,并手动将它们更新回 ORM。

API:Django 视图是适配器

Django 的views.py文件最终几乎与旧的flask_app.py相同,因为我们的架构意味着它是围绕我们的服务层(顺便说一句,服务层没有改变)的一个非常薄的包装器:

Flask app → Django views (src/djangoproject/alloc/views.py)

os.environ['DJANGO_SETTINGS_MODULE'] = 'djangoproject.django_project.settings'
django.setup()

@csrf_exempt
def add_batch(request):
    data = json.loads(request.body)
    eta = data['eta']
    if eta is not None:
        eta = datetime.fromisoformat(eta).date()
    services.add_batch(
        data['ref'], data['sku'], data['qty'], eta,
        unit_of_work.DjangoUnitOfWork(),
    )
    return HttpResponse('OK', status=201)

@csrf_exempt
def allocate(request):
    data = json.loads(request.body)
    try:
        batchref = services.allocate(
            data['orderid'],
            data['sku'],
            data['qty'],
            unit_of_work.DjangoUnitOfWork(),
        )
    except (model.OutOfStock, services.InvalidSku) as e:
        return JsonResponse({'message': str(e)}, status=400)

    return JsonResponse({'batchref': batchref}, status=201)

为什么这么难?

好吧,它可以工作,但感觉比 Flask/SQLAlchemy 要费力。为什么呢?

在低级别上的主要原因是因为 Django 的 ORM 工作方式不同。我们没有 SQLAlchemy 经典映射器的等价物,因此我们的ActiveRecord和领域模型不能是同一个对象。相反,我们必须在存储库后面构建一个手动翻译层。这是更多的工作(尽管一旦完成,持续的维护负担不应该太高)。

由于 Django 与数据库紧密耦合,您必须使用诸如pytest-django之类的辅助工具,并从代码的第一行开始仔细考虑测试数据库的使用方式,这是我们在纯领域模型开始时不必考虑的。

但在更高的层面上,Django 之所以如此出色的原因是,它的设计围绕着使构建具有最少样板的 CRUD 应用程序变得容易的最佳点。但我们的整本书的主要内容是关于当您的应用程序不再是一个简单的 CRUD 应用程序时该怎么办。

在那一点上,Django 开始妨碍而不是帮助。像 Django 管理这样的东西,在您开始时非常棒,但如果您的应用程序的整个目的是围绕状态更改的工作流程构建一套复杂的规则和建模,那么它们就会变得非常危险。Django 管理绕过了所有这些。

如果您已经有 Django,该怎么办

那么,如果您想将本书中的一些模式应用于 Django 应用程序,您应该怎么做呢?我们建议如下:

  • 存储库和工作单元模式将需要相当多的工作。它们在短期内将为您带来的主要好处是更快的单元测试,因此请评估在您的情况下是否值得这种好处。在长期内,它们将使您的应用程序与 Django 和数据库解耦,因此,如果您预计希望迁移到其中任何一个,存储库和 UoW 是一个好主意。

  • 如果您在views.py中看到很多重复,可能会对服务层模式感兴趣。这是一种很好的方式,可以让您将用例与 Web 端点分开思考。

  • 您仍然可以在 Django 模型中进行 DDD 和领域建模,尽管它们与数据库紧密耦合;您可能会因迁移而放慢速度,但这不应该是致命的。因此,只要您的应用程序不太复杂,测试速度不太慢,您可能会从fat models方法中获益:尽可能将大部分逻辑下推到模型中,并应用实体、值对象和聚合等模式。但是,请参见以下警告。

话虽如此,Django 社区中的一些人发现,fat models方法本身也会遇到可扩展性问题,特别是在管理应用程序之间的相互依赖方面。在这些情况下,将业务逻辑或领域层提取出来,放置在视图和表单以及models.py之间,可以尽可能地保持其最小化。

途中的步骤

假设您正在开发一个 Django 项目,您不确定是否会变得足够复杂,以至于需要我们推荐的模式,但您仍然希望采取一些步骤,以使您的生活在中期更轻松,并且如果以后要迁移到我们的一些模式中,也更轻松。考虑以下内容:

  • 我们听到的一个建议是从第一天开始在每个 Django 应用程序中放置一个logic.py。这为您提供了一个放置业务逻辑的地方,并使您的表单、视图和模型免于业务逻辑。这可以成为迈向完全解耦的领域模型和/或服务层的垫脚石。

  • 业务逻辑层可能开始使用 Django 模型对象,只有在以后才会完全脱离框架,并在纯 Python 数据结构上工作。

  • 对于读取方面,您可以通过将读取放入一个地方来获得 CQRS 的一些好处,避免在各个地方散布 ORM 调用。

  • 在为读取和领域逻辑分离模块时,值得脱离 Django 应用程序层次结构。业务问题将贯穿其中。

注意

我们想要向 David Seddon 和 Ashia Zawaduk 致敬,因为他们讨论了附录中的一些想法。他们尽力阻止我们在我们没有足够个人经验的话题上说出任何愚蠢的话,但他们可能失败了。

有关处理现有应用程序的更多想法和实际经验,请参阅附录。

DRY-Python 项目的人们构建了一个名为 mappers 的工具,看起来可能有助于最小化这种事情的样板文件。

@mr-bo-jangles建议您可以使用update_or_create,但这超出了我们的 Django-fu。

附录 E:验证

原文:Appendix E: Validation

译者:飞龙

协议:CC BY-NC-SA 4.0

每当我们教授和讨论这些技术时,一个反复出现的问题是“我应该在哪里进行验证?这属于我的业务逻辑在领域模型中,还是属于基础设施问题?”

与任何架构问题一样,答案是:这取决于情况!

最重要的考虑因素是我们希望保持我们的代码良好分离,以便系统的每个部分都很简单。我们不希望用无关的细节来混淆我们的代码。

验证到底是什么?

当人们使用验证这个词时,他们通常指的是一种过程,通过这种过程测试操作的输入,以确保它们符合某些标准。符合标准的输入被认为是有效的,而不符合标准的输入被认为是无效的。

如果输入无效,则操作无法继续,但应该以某种错误退出。换句话说,验证是关于创建前提条件。我们发现将我们的前提条件分为三个子类型:语法、语义和语用是有用的。

验证语法

在语言学中,语言的语法是指控制语法句子结构的规则集。例如,在英语中,“Allocate three units of TASTELESS-LAMP to order twenty-seven”是语法正确的,而短语“hat hat hat hat hat hat wibble”则不是。我们可以将语法正确的句子描述为格式良好

这如何映射到我们的应用程序?以下是一些语法规则的示例:

  • 一个Allocate命令必须有一个订单 ID、一个 SKU 和一个数量。

  • 数量是一个正整数。

  • SKU 是一个字符串。

这些是关于传入数据的形状和结构的规则。一个没有 SKU 或订单 ID 的Allocate命令不是一个有效的消息。这相当于短语“Allocate three to.”

我们倾向于在系统的边缘验证这些规则。我们的经验法则是,消息处理程序应始终只接收格式良好且包含所有必需信息的消息。

一种选择是将验证逻辑放在消息类型本身上:

消息类上的验证(src/allocation/commands.py

from schema import And, Schema, Use


@dataclass
class Allocate(Command):

    _schema = Schema({  #(1)
        'orderid': int,
         sku: str,
         qty: And(Use(int), lambda n: n > 0)
     }, ignore_extra_keys=True)

    orderid: str
    sku: str
    qty: int

    @classmethod
    def from_json(cls, data):  #(2)
        data = json.loads(data)
        return cls(**_schema.validate(data))

schema让我们以一种好的声明方式描述消息的结构和验证。

from_json方法将字符串读取为 JSON,并将其转换为我们的消息类型。

不过这可能会变得重复,因为我们需要两次指定我们的字段,所以我们可能希望引入一个辅助库,可以统一验证和声明我们的消息类型:

带有模式的命令工厂(src/allocation/commands.py

def command(name, **fields):  #(1)
    schema = Schema(And(Use(json.loads), fields), ignore_extra_keys=True)
    cls = make_dataclass(name, fields.keys())  #(2)
    cls.from_json = lambda s: cls(**schema.validate(s))  #(3)
    return cls

def greater_than_zero(x):
    return x > 0

quantity = And(Use(int), greater_than_zero)  #(4)

Allocate = command(  #(5)
    orderid=int,
    sku=str,
    qty=quantity
)

AddStock = command(
    sku=str,
    qty=quantity

command函数接受一个消息名称,以及消息有效负载字段的 kwargs,其中 kwarg 的名称是字段的名称,值是解析器。

我们使用数据类模块的make_dataclass函数动态创建我们的消息类型。

我们将from_json方法打补丁到我们的动态数据类上。

我们可以创建可重用的解析器来解析数量、SKU 等,以保持代码的 DRY。

声明消息类型变成了一行代码。

这是以失去数据类上的类型为代价的,所以要考虑这种权衡。

Postel's Law 和宽容读者模式

Postel's law,或者鲁棒性原则,告诉我们,“在接受时要宽容,在发出时要保守。”我们认为这在与其他系统集成的情境中特别适用。这里的想法是,当我们向其他系统发送消息时,我们应该严格要求,但在接收他人消息时尽可能宽容。

例如,我们的系统可以验证 SKU 的格式。我们一直在使用像UNFORGIVING-CUSHIONMISBEGOTTEN-POUFFE这样的虚构 SKU。这遵循一个简单的模式:由破折号分隔的两个单词,其中第二个单词是产品类型,第一个单词是形容词。

开发人员喜欢验证消息中的这种内容,并拒绝任何看起来像无效 SKU 的内容。当某个无政府主义者发布名为COMFY-CHAISE-LONGUE的产品或供应商出现问题导致CHEAP-CARPET-2的发货时,这将在后续过程中造成可怕的问题。

实际上,作为分配系统,SKU 的格式与我们无关。我们只需要一个标识符,所以我们可以简单地将其描述为一个字符串。这意味着采购系统可以随时更改格式,而我们不会在意。

同样的原则适用于订单号、客户电话号码等。在大多数情况下,我们可以忽略字符串的内部结构。

同样,开发人员喜欢使用 JSON Schema 等工具验证传入消息,或构建验证传入消息并在系统之间共享的库。这同样无法通过健壮性测试。

例如,假设采购系统向ChangeBatchQuantity消息添加了记录更改原因和负责更改的用户电子邮件的新字段。

由于这些字段对分配服务并不重要,我们应该简单地忽略它们。我们可以通过传递关键字参数ignore_extra_keys=True来在schema库中实现这一点。

这种模式,即我们仅提取我们关心的字段并对它们进行最小的验证,就是宽容读者模式。

提示

尽量少进行验证。只读取您需要的字段,不要过度指定它们的内容。这将有助于您的系统在其他系统随着时间的变化而保持健壮。抵制在系统之间共享消息定义的诱惑:相反,使定义您所依赖的数据变得容易。有关更多信息,请参阅 Martin Fowler 的文章Tolerant Reader pattern

在边缘验证

我们曾经说过,我们希望避免在我们的代码中充斥着无关的细节。特别是,我们不希望在我们的领域模型内部进行防御性编码。相反,我们希望确保在我们的领域模型或用例处理程序看到它们之前,已知请求是有效的。这有助于我们的代码在长期内保持干净和可维护。我们有时将其称为在系统边缘进行验证

除了保持您的代码干净并且没有无休止的检查和断言之外,要记住,系统中漫游的无效数据就像是一颗定时炸弹;它越深入,造成的破坏就越大,而您可以用来应对它的工具就越少。

在第八章中,我们说消息总线是一个很好的放置横切关注点的地方,验证就是一个完美的例子。以下是我们如何改变我们的总线来执行验证的方式:

验证

class MessageBus:

    def handle_message(self, name: str, body: str):
        try:
            message_type = next(mt for mt in EVENT_HANDLERS if mt.__name__ == name)
            message = message_type.from_json(body)
            self.handle([message])
        except StopIteration:
            raise KeyError(f"Unknown message name {name}")
        except ValidationError as e:
            logging.error(
                f'invalid message of type {name}\n'
                f'{body}\n'
                f'{e}'
            )
            raise e

以下是我们如何从我们的 Flask API 端点使用该方法:

API 在处理 Redis 消息时出现验证错误(src/allocation/flask_app.py

@app.route("/change_quantity", methods=['POST'])
def change_batch_quantity():
    try:
        bus.handle_message('ChangeBatchQuantity', request.body)
    except ValidationError as e:
        return bad_request(e)
    except exceptions.InvalidSku as e:
        return jsonify({'message': str(e)}), 400

def bad_request(e: ValidationError):
    return e.code, 400

以下是我们如何将其插入到我们的异步消息处理器中:

处理 Redis 消息时出现验证错误(src/allocation/redis_pubsub.py

def handle_change_batch_quantity(m, bus: messagebus.MessageBus):
    try:
        bus.handle_message('ChangeBatchQuantity', m)
    except ValidationError:
       print('Skipping invalid message')
    except exceptions.InvalidSku as e:
        print(f'Unable to change stock for missing sku {e}')

请注意,我们的入口点仅关注如何从外部世界获取消息以及如何报告成功或失败。我们的消息总线负责验证我们的请求并将其路由到正确的处理程序,而我们的处理程序则专注于用例的逻辑。

提示

当您收到无效的消息时,通常除了记录错误并继续之外,你几乎无能为力。在 MADE,我们使用指标来计算系统接收的消息数量,以及其中有多少成功处理、跳过或无效。如果我们看到坏消息数量的激增,我们的监控工具会向我们发出警报。

验证语义

虽然语法涉及消息的结构,语义是对消息中含义的研究。句子“Undo no dogs from ellipsis four”在语法上是有效的,并且与句子“Allocate one teapot to order five”具有相同的结构,但它是毫无意义的。

我们可以将这个 JSON 块解读为一个“分配”命令,但无法成功执行它,因为它是无意义的

一个毫无意义的消息

{
  "orderid": "superman",
  "sku": "zygote",
  "qty": -1
}

我们倾向于在消息处理程序层验证语义关注点,采用一种基于合同的编程:

前提条件(src/allocation/ensure.py

"""
This module contains preconditions that we apply to our handlers.
"""

class MessageUnprocessable(Exception):  #(1)

    def __init__(self, message):
        self.message = message

class ProductNotFound(MessageUnprocessable):  #(2)
    """"
    This exception is raised when we try to perform an action on a product
    that doesn't exist in our database.
    """"

    def __init__(self, message):
        super().__init__(message)
        self.sku = message.sku

def product_exists(event, uow):  #(3)
    product = uow.products.get(event.sku)
    if product is None:
        raise ProductNotFound(event)

我们使用一个通用的错误基类,表示消息无效。

为此问题使用特定的错误类型使得更容易报告和处理错误。例如,将ProductNotFound映射到 Flask 中的 404 很容易。

product_exists是一个前提条件。如果条件为False,我们会引发一个错误。

这样可以使我们的服务层的主要逻辑保持清晰和声明式:

在服务中确保调用(src/allocation/services.py

# services.py

from allocation import ensure

def allocate(event, uow):
    line = mode.OrderLine(event.orderid, event.sku, event.qty)
    with uow:
        ensure.product_exists(uow, event)

        product = uow.products.get(line.sku)
        product.allocate(line)
        uow.commit()

我们可以扩展这个技术,以确保我们幂等地应用消息。例如,我们希望确保我们不会多次插入一批库存。

如果我们被要求创建一个已经存在的批次,我们将记录一个警告并继续下一个消息:

为可忽略的事件引发 SkipMessage 异常(src/allocation/services.py

class SkipMessage (Exception):
    """"
 This exception is raised when a message can't be processed, but there's no
 incorrect behavior. For example, we might receive the same message multiple
 times, or we might receive a message that is now out of date.
 """"

    def __init__(self, reason):
        self.reason = reason

def batch_is_new(self, event, uow):
    batch = uow.batches.get(event.batchid)
    if batch is not None:
        raise SkipMessage(f"Batch with id {event.batchid} already exists")

引入SkipMessage异常让我们以一种通用的方式处理这些情况在我们的消息总线中:

公交车现在知道如何跳过(src/allocation/messagebus.py

class MessageBus:

    def handle_message(self, message):
        try:
           ...
       except SkipMessage as e:
           logging.warn(f"Skipping message {message.id} because {e.reason}")

这里需要注意一些陷阱。首先,我们需要确保我们使用的是与用例的主要逻辑相同的 UoW。否则,我们会让自己遭受恼人的并发错误。

其次,我们应该尽量避免将所有业务逻辑都放入这些前提条件检查中。作为一个经验法则,如果一个规则可以在我们的领域模型内进行测试,那么它应该在领域模型中进行测试。

验证语用学

语用学是研究我们如何在语境中理解语言的学科。在解析消息并理解其含义之后,我们仍然需要在上下文中处理它。例如,如果你在拉取请求上收到一条评论说“我认为这非常勇敢”,这可能意味着评论者钦佩你的勇气,除非他们是英国人,在这种情况下,他们试图告诉你你正在做的事情是非常冒险的,只有愚蠢的人才会尝试。上下文是一切。

提示

一旦在系统边缘验证了命令的语法和语义,领域就是其余验证的地方。验证语用学通常是业务规则的核心部分。

在软件术语中,操作的语用学通常由领域模型管理。当我们收到像“allocate three million units of SCARCE-CLOCK to order 76543”这样的消息时,消息在语法上有效且语义上有效,但我们无法遵守,因为我们没有库存可用。

posted @ 2025-11-18 09:33  绝不原创的飞龙  阅读(18)  评论(0)    收藏  举报