Python 整洁的架构详解

在最后一章中,我们将重点介绍如何在整个系统的设计中将所有内容结合在一起。这更像是一个理论章节。鉴于该主题的性质,深入研究更低级的细节将过于复杂。此外,重点正是要避开这些细节,假设前面章节中探讨的所有原则都被吸收,并将重点放在大规模系统的设计上。

本章的主要目标如下:

本章探讨干净的代码是如何演变为干净的体系结构的,反之,干净的代码也是良好体系结构的基石。如果软件解决方案具有质量,那么它是有效的。体系结构需要通过实现质量属性(性能、可测试性、可维护性等)来实现这一点。但是代码还需要在每个组件上启用此功能。

第一部分首先探讨代码和体系结构之间的关系。

当我们考虑大系统的方面时,前面章节中强调的概念如何以稍微不同的形状出现。适用于更详细的设计以及代码的概念如何也适用于大型系统和体系结构,这有一个有趣的相似之处。

前几章中探讨的概念与单个应用程序相关,通常是一个项目,它可能是源代码管理版本系统(Git)的单个存储库(或几个)。这并不是说,这些设计思想只适用于代码,或者在考虑架构时没有用,原因有两个:代码是体系结构的基础,如果没有仔细编写,系统将失败,不管架构有多好的思考。

其次,前几章介绍的一些原则不仅适用于代码,而是设计思想。最清楚的例子来自设计模式。它们是高层抽象。有了它们,我们可以快速了解架构中的组件是如何出现的,而无需深入了解代码的细节。

但是大型企业系统通常由许多这样的应用程序组成,现在是时候开始考虑更大的设计了,以分布式系统的形式。

在接下来的部分中,我们将讨论本书中讨论过的主要主题,但现在是从整个系统的角度进行讨论。

软件架构是好的,如果它是有效的。在一个好的架构中,最常见的方面是所谓的质量属性(如可伸缩性、安全性、性能和持久性是最常见的)。这是有道理的;毕竟,您希望您的系统能够在不崩溃的情况下处理负载的增加,能够在不需要维护的情况下无限期地连续工作,并且能够扩展以支持新的需求。

但是架构的操作方面也使它变得清晰。可操作性、持续集成以及发布更改的容易程度等特性也会影响系统的整体质量。

关注点分离

在应用程序内部,有多个组件。它们的代码被划分为其他子组件,如模块或包,模块被划分为类或函数,类被划分为方法。在整本书中,重点一直是保持这些组件尽可能小,特别是在函数应该做一件事并且很小的情况下。

提出了几个理由来证明这一理由。小函数更易于理解、遵循和调试。它们也更容易测试。代码中的片段越小,就越容易为其编写单元测试。

对于每个应用程序的组件,我们需要不同的特性,主要是高内聚和低耦合。通过将组件划分为更小的单元,每个单元都有一个单独且定义明确的职责,我们实现了一个更易于管理更改的更好的结构。面对新的需求,将有一个正确的地方进行更改,代码的其余部分可能不会受到影响。

当我们谈论代码时,我们说组件是指这些内聚单元中的一个(例如,它可能是一个类)。在体系结构方面,组件意味着系统中可以作为工作单元处理的任何东西。组件这个术语本身非常模糊,所以在软件架构中没有一个普遍接受的定义,更具体地说,这意味着什么。工作单元的概念因项目而异。组件应该能够以自己的周期独立于系统的其余部分进行发布或部署。

对于 Python 项目,组件可以是包,但服务也可以是组件。请注意,在同一个类别下可以考虑两个不同的概念,它们具有不同的粒度级别。举个例子,我们在前几章中使用的事件系统可以被视为一个组件。他们是一个工作单位,有明确的目的(丰富从日志中识别的事件)。它们可以独立于其余部分进行部署(无论是作为 Python 包,或者,如果我们公开了它们的功能,则可以作为服务进行部署;稍后将详细介绍),它们是整个系统的一部分,而不是整个应用程序本身。

在前几章中的示例中,我们看到了惯用代码,我们还强调了良好设计对代码的重要性,具有单一、定义良好的职责的对象被隔离、正交,并且更易于维护。同样的标准适用于详细设计(函数、类、方法),也适用于软件体系结构的组件。

在考虑全局时,请记住良好的设计原则。

对于一个大型系统来说,仅仅是一个组件可能是不可取的。单片应用程序将充当真理的单一来源,负责系统中的一切,并将带来许多不期望的后果(更难隔离和识别更改,更难有效地测试,等等)。

同样,如果我们不小心,将所有内容放在一个地方,那么我们的代码将更难维护。如果应用程序的组件没有得到同样的关注,那么应用程序将遇到类似的问题。

在系统中创建内聚组件的想法可以有多个实现,这取决于我们需要的抽象级别。

一种选择是确定可能被多次重用的公共逻辑,并将其放在 Python 包中(我们将在本章后面讨论细节)。

另一种选择是在微服务体系结构中将应用程序分解为多个较小的服务。其思想是让组件具有单一且定义明确的职责,并通过使这些服务协作和交换信息来实现与单片应用程序相同的功能。

单片应用程序和微服务

上一节中最重要的思想是分离关注点的概念:不同的职责应该分布在不同的组件中。正如在我们的代码(更详细的设计级别)中,拥有一个知道一切的巨大对象是不好的,在我们的体系结构中,不应该只有一个组件拥有一切。

然而,有一个重要的区别。不同的组件并不一定意味着不同的服务。可以将应用程序划分为更小的 Python 包(我们将在本章后面介绍打包),并创建由许多依赖项组成的单个服务。

将责任划分为不同的服务是一个好主意,它有一些好处,但也有成本。

如果有代码需要跨其他几个服务重用,典型的响应是将其封装到一个微服务中,由公司中的许多其他服务调用。这不是重用代码的唯一方法。考虑将逻辑封装为库以由其他组件导入的可能性。当然,这只有在所有其他组件使用相同语言编写时才可行;否则,是的,微服务模式是剩下的唯一选项。

微服务架构具有完全解耦的优势:不同的服务可以用不同的语言或框架编写,甚至可以独立部署。它们也可以单独测试。这是有代价的。他们还需要一份强有力的合同,让客户知道如何与该服务交互,并且他们还分别受服务水平协议SLA)和服务水平目标SLO的约束。

它们也会增加延迟:必须调用外部服务来获取数据(无论是通过 HTTP 还是 gRPC)会对总体性能造成影响。

由较少服务组成的应用程序更为严格,无法独立部署。它甚至可能更加脆弱,因为它可能成为单一的失败点。另一方面,它可能更高效(因为我们避免了昂贵的 I/O 调用),而且我们仍然可以通过使用 Python 包实现组件的良好分离。

本节的内容是思考创建新服务或使用 Python 包之间的正确架构风格。

抽象

这就是封装再次出现的地方。当涉及到我们的系统时(就像我们在代码方面所做的那样),我们希望从领域问题的角度来讨论,并尽可能隐藏实现细节。

与代码必须具有表达性(几乎达到自文档化的程度)并具有揭示基本问题解决方案的正确抽象(最小化意外复杂性)一样,体系结构应该告诉我们系统是关于什么的。诸如用于在磁盘上持久化数据的解决方案、所选的 web 框架、用于连接到外部代理的库以及系统之间的交互等细节并不相关。相关的是系统的功能。尖叫式架构(尖叫)等概念反映了这一理念。

第 4 章中解释的依赖项倒置原则DIP实体原则在这方面有很大帮助;我们不想依赖具体的实现,而是依赖抽象。在代码中,我们将抽象(或接口)放在边界、依赖项和应用程序中我们无法控制且将来可能会更改的部分上。我们这样做是因为我们想反转依赖项,让它们适应我们的代码(通过遵守接口),而不是相反。

创建抽象和反转依赖项是很好的实践,但它们还不够。我们希望我们的整个应用程序独立于我们无法控制的事物。这比仅仅用对象抽象更进一步,我们需要抽象的层次。

这对于详细设计来说是一个微妙但重要的区别。例如,在 DIP 中,建议创建一个可以通过标准库中的abc模块实现的接口。因为 Python 使用 duck 类型,虽然使用抽象类可能会有所帮助,但它不是强制性的,因为我们可以轻松地对常规对象实现相同的效果,只要它们符合所需的接口。

Python 的动态类型特性允许我们有这些选择。当从架构的角度思考时,根本没有这样的东西。下面的示例将更清楚地说明,我们需要完全抽象依赖项,Python 没有任何特性可以为我们做到这一点。

有些人可能会说:“嗯,对象关系映射器ORM)是数据库的一个很好的抽象,不是吗?”不。ORM 本身是一个依赖项,因此不受我们的控制。最好在 ORM 的 API 和我们的应用程序之间创建一个中间层,即适配器。

这意味着我们不只是用 ORM 抽象数据库;我们使用我们在上面创建的抽象层来定义属于我们自己领域的对象。如果这个抽象恰好在下面使用了 ORM,那就是巧合;域层(我们的业务逻辑所在的地方)不应该关心它。

拥有我们自己的抽象让我们能够更灵活地控制应用程序。我们甚至可能在以后决定根本不需要 ORM(比方说,因为我们希望对正在使用的数据库引擎有更多的控制权),如果我们将应用程序与特定的 ORM(或一般的任何库)相结合,将来就很难改变这一点。其思想是将应用程序的核心与我们无法控制的外部依赖性隔离开来。

然后,应用程序导入该组件,并使用该层提供的实体,但不能反过来使用。抽象层不应该知道我们应用程序的逻辑;更确切的是,数据库应该对应用程序本身一无所知。如果是这样,数据库将与我们的应用程序耦合。目标是反转依赖关系,该层提供一个 API,每个想要连接的存储组件都必须符合该 API。这就是六边形结构(十六进制)的概念。

在下一节中,我们将分析具体的工具,这些工具将帮助我们创建在体系结构中使用的组件。

我们现在有一个大系统,我们需要扩展它。它还必须是可维护的。在这一点上,关注点不仅是技术上的,而且是组织上的。这意味着它不仅仅是管理软件存储库;每个存储库很可能属于一个应用程序,并且由拥有该部分系统的团队进行维护。

这就要求我们记住一个大系统是如何划分为不同的组件的。这可以分为许多阶段,从一个非常简单的方法(比如创建 Python 包)到微服务体系结构中更复杂的场景。

当涉及到不同的语言时,情况可能更加复杂,但在本章中,我们将假设它们都是 Python 项目。

这些组件需要交互,团队也需要交互。唯一可以大规模运作的方法是,如果所有部分都同意一个接口,一个合同。

包装

Python 包是分发软件和以更通用的方式重用代码的便捷方式。已经构建的包可以发布到工件库(比如公司的内部 PyPi 服务器),需要它们的其他应用程序将从那里下载它们。

这种方法背后的动机有很多因素,主要是关于重用代码,以及实现概念完整性。

这里,我们将讨论打包可在存储库中发布的 Python 项目的基础知识。默认存储库可能是 PyPi(位于Python 包索引https://pypi.org/ ),但也可以是内部的;或自定义设置将使用相同的基本设置。

我们将模拟我们已经创建了一个小型库,我们将以它为例来回顾需要考虑的要点。

除了所有可用的开源库之外,有时我们可能还需要一些额外的功能,也许我们的应用程序反复使用某个特定的习惯用法,或者非常依赖某个函数或机制,并且团队已经为这些特定的需要设计了更好的函数。为了更有效地工作,我们可以将这个抽象放到一个库中,并鼓励所有团队成员使用它提供的习惯用法,因为这样做将有助于避免错误和减少 bug。

当您拥有一个服务和该服务的客户端库时,通常会出现这种情况。您不希望客户端直接调用您的 API,因此,您可以为它们提供一个客户端库。该库的代码将被包装到 Python 包中,并通过内部包管理系统分发。

潜在地,有无数的例子可以适合这种情况。也许应用程序需要提取大量的.tar.gz文件(以特定格式),并且在过去遇到过恶意文件的安全问题,最终导致路径遍历攻击。

作为一种缓解措施,安全地抽象自定义文件格式的功能被放在一个库中,该库封装了默认文件格式并添加了一些额外的检查。这听起来是个好主意。

或者可能有一个配置文件必须以特定的格式写入或解析,这需要按照顺序执行许多步骤;同样,创建一个 helper 函数来包装它,并在所有需要它的项目中使用它,这是一项很好的投资,不仅因为它节省了大量的代码重复,而且还因为它使出错更加困难。

这样做的好处不仅是符合 DRY 原则(避免代码重复,鼓励重用),而且抽象功能代表了应该如何做的单一参考点,从而有助于实现概念完整性。

通常,库的最小布局如下所示:

├── Makefile
├── README.rst
├── setup.py
├── src
│   └── apptool
│   ├── common.py
│   ├── __init__.py
│   └── parse.py
└── tests
    ├── integration
    └── unit 

重要的部分是setup.py文件,其中包含包的定义。在此文件中,指定了项目的所有重要定义(其需求、依赖项、名称、描述等)。

src下的apptool目录是我们正在处理的库的名称。这是一个典型的 Python 项目,因此我们将需要的所有文件都放在这里。

setup.py文件的一个示例可以是:

from setuptools import find_packages, setup
with open("README.rst", "r") as longdesc:
    long_description = longdesc.read()
setup(
    name="apptool",
    description="Description of the intention of the package",
    long_description=long_description,
    author="Dev team",
    version="0.1.0",
    packages=find_packages(where="src/"),
    package_dir={"": "src"},
) 

这个最小的示例包含项目的关键元素。setup函数中的name参数用于提供包在存储库中的名称(在这个名称下,我们运行命令来安装它;在本例中,它是pip install apptool。不严格要求它与项目目录(src/apptool的名称匹配,但强烈推荐它,这样对用户更方便。

在本例中,由于两个名称都匹配,因此更容易看到pip install apptool和代码from apptool import myutil之间的关系。但后者对应于src/目录下的名称,前者对应于setup.py文件中指定的名称。

版本对于保持不同版本的发布非常重要,然后指定包。通过使用find_packages()函数,我们自动发现属于包的所有内容,在本例中是在src/目录下。在此目录下搜索有助于避免混淆项目范围以外的文件,例如,意外释放测试或项目的结构损坏。

通过运行以下命令构建包,假设包在安装了依赖项的虚拟环境中运行:

python –m venv env
source env/bin/activate
$VIRTUAL_ENV/bin/pip install -U pip wheel
$VIRTUAL_ENV/bin/python setup.py sdist bdist_wheel 

这将把工件放在dist/目录中,以后可以从那里发布到 PyPi 或公司的内部包存储库。

打包 Python 项目的关键点是:

  • 测试并验证安装是否独立于平台,并且不依赖于任何本地设置(这可以通过将源文件放在src/目录下实现)。这意味着构造的包不应依赖于本地计算机上的文件,并且在装运时(或在自定义目录结构中)不可用。
  • 确保单元测试不是作为正在构建的包的一部分发布的。这是为了生产。在将在生产环境中运行的 Docker 映像中,您不需要严格不需要的额外文件(例如,夹具)。
  • 项目严格要求运行的独立依赖项与开发人员要求的不同。
  • 最好为最需要的命令创建入口点。

setup.py文件支持多种其他参数和配置,可能受到更复杂的影响。如果我们的软件包需要安装几个操作系统库,那么最好在setup.py文件中编写一些逻辑来编译和构建所需的扩展。这样,如果出现问题,它将在安装过程的早期失败,如果包提供了有用的错误消息,用户将能够更快地修复依赖项并继续。

安装这样的依赖关系代表了使应用程序无处不在、易于任何开发人员运行的另一个困难步骤,而不管他们选择什么平台。克服此障碍的最佳方法是通过创建 Docker 图像来抽象平台,我们将在下一节中讨论。

管理依赖项

在描述我们将如何利用 Docker 容器交付我们的应用程序之前,重要的是先看一看一个软件配置管理SCM)问题,即:我们如何列出应用程序的依赖项,以便它们可以重复?

请记住,软件中的问题可能不仅仅来自我们的代码。外部依赖关系也会影响最终交付。在任何时候,您都希望知道交付的软件包及其版本的完整列表。这称为基线。

这个想法是,如果在任何时候依赖关系给我们的软件带来了问题,您都希望能够快速找到它。更重要的是,您还希望您的构建是可重复的:如果其他所有内容都保持不变,那么新构建应该产生与上一个构建完全相同的构件。

软件通过开发管道交付生产。这在第一个环境中开始,然后在其上运行测试(集成、验收等),然后通过持续集成和持续部署,它在管道的不同阶段中移动(例如,如果您有一个 beta 测试环境,或者在最终达到生产之前进行预生产)。

Docker 擅长确保完全相同的图像沿着管道移动,但不能保证如果您再次通过管道运行相同版本的代码(比如相同的git commit,您将获得相同的结果。这项工作就在我们身上,这也是我们在本节中探索的内容。

假设我们的 web 包的setup.py文件如下所示:

from setuptools import find_packages, setup

with open("README.rst", "r") as longdesc:
    long_description = longdesc.read()
install_requires = ["sanic>=20,<21"]
setup(
    name="web",
    description="Library with helpers for the web-related functionality",
    long_description=long_description,
    author="Dev team",
    version="0.1.0",
    packages=find_packages(where="src/"),
    package_dir={"": "src"},
    install_requires=install_requires,
) 

在本例中,只有一个依赖项(在install_requires参数中声明),它控制一个版本间隔。这通常是一个很好的实践:我们希望至少使用包的特定版本,但我们也对不超过下一个主版本感兴趣(因为主版本可以进行向后不兼容的更改)。

我们这样设置版本是因为我们对更新依赖项感兴趣(有工具,如Dependabot-https://dependabot.com/ -自动检测我们的依赖项何时有新版本,并可以打开新的pull请求),但我们仍然想知道在任何给定时间安装的确切版本。

不仅如此,我们还希望跟踪完整的依赖关系树,这意味着还应该列出可传递的依赖关系。

一种方法是使用 pip 工具(https://github.com/jazzband/pip-tools 和编制requirements.txt文件。

想法是使用此工具从setup.py文件生成需求文件,如下所示:

pip-compile setup.py 

这将生成一个requirements.txt文件,我们将在Dockerfile中使用该文件来安装依赖项。

始终从requirements.txt文件在Dockerfile中安装依赖项,以便从版本控制的角度确定构建。

列出需求的文件应置于版本控制之下,每当我们想要升级依赖项时,我们都会使用–U标志再次运行该命令,并跟踪需求文件的新版本。

列出所有依赖项不仅有利于重复性,而且还可以增加清晰度。如果您使用了许多依赖项,那么可能会发生与版本的冲突,如果我们知道哪个包导入哪个库(以及在哪个版本上),这将更容易发现。但再一次,这只是问题的一部分。在处理依赖关系时,我们需要考虑更多的因素。

管理依赖关系时的其他注意事项

默认情况下,在安装依赖项时,pip将使用来自互联网的公共存储库(https://pypi.org/ )。也可以从其他索引甚至版本控制系统进行安装。

这有一些问题和局限性。首先,您将取决于这些服务的可用性。还有一个警告是,您将无法在公共存储库中发布内部软件包(其中包含您公司的知识产权)。最后,还有一个问题,我们不能确定一些作者在保证工件版本的准确性和安全性方面有多可靠(例如,一些作者可能希望以相同的版本号重新发布不同版本的代码,这显然是错误的,是不允许的,但所有系统都有缺陷).我不记得 Python 中有这样的问题,但我记得几年前 JavaScript 社区发生过这种情况,当时有人从 NPM 注册表(REGISTER01)中删除了一个包,并且通过取消发布此库,许多其他构建都失败了。即使 PyPi 不允许这样做,我们也不希望受到其他人的善意(或恶意)的摆布。

解决方案很简单:您的公司必须有一个用于依赖项的内部服务器,并且所有构建都必须以该内部存储库为目标。无论这是如何实现的(内部部署、云计算、使用开源工具或外包给提供商),其想法都是必须将新的、需要的依赖项添加到此存储库中,这也是发布内部包的地方。

确保此内部存储库得到更新,并将所有存储库配置为在新版本的依赖项可用时接收升级。请记住,这也是另一种形式的技术债务。这有几个原因。正如我们在前几章中所讨论的,技术债务不仅仅是写得糟糕的代码。当新技术可用时,您将错过这些功能,这意味着您可能会更好地利用可用的技术。更重要的是,软件包可能存在随着时间推移而被发现的安全漏洞,因此您需要升级以确保您的软件已修补。

拥有过时版本的依赖关系是另一种形式的技术债务。养成使用依赖项的最新版本的习惯。

在升级依赖项之前不要浪费太多时间,因为等待的时间越多,就越难赶上。毕竟,这就是持续集成的全部要点:您希望以增量的方式持续集成更改(包括新的依赖项),前提是您拥有作为构建的一部分运行的自动化测试,并充当回归的安全网。

配置一个工具,该工具自动发送对依赖项新版本的请求,并配置对它们的自动安全检查。

此工作流应该只需要最少的工作。其想法是,您可以使用一系列版本配置项目的setup.py文件,并拥有需求文件。当有新版本可用时,您为存储库配置的工具将重建需求文件,该文件将列出所有软件包及其新版本(将显示在工具打开的pull请求的差异中)。如果构建是绿色的,并且pull请求显示的差异没有可疑之处,那么您可以继续进行merge,相信持续集成会发现问题。另一方面,如果构建失败,则需要您的干预进行调整。

工件版本

在稳定性和尖端软件之间有一个权衡。拥有最新版本通常是积极的,因为这意味着我们只需升级就可以获得最新的功能和 bug 修复。此时新版本不会带来不兼容的更改(缺点)。因此,软件以具有明确含义的版本进行管理。

当我们建立一系列期望的版本时,我们希望得到升级,但同时不要过于激进,破坏应用程序。

如果我们只升级依赖项并编写新版本的需求文件,那么我们应该发布新版本的工件(毕竟,我们提供的是新的,因此是不同的)。这可以是一个小版本或微版本,但重要的是,当我们发布自己的定制工件时,我们必须遵守我们期望从第三方库获得的相同规则。

Python 中的一个很好的参考是 PEP-440(https://www.python.org/dev/peps/pep-0440/ ),它描述了如何为我们的库设置setup.py文件中的版本号。

在下一节中,我们将研究另一种技术,它也将帮助我们创建组件来交付代码。

码头集装箱

本章专门讨论架构,因此术语容器指的是完全不同于 Python 容器(具有__contains__方法的对象),在第 2 章Python 代码中进行了探讨。容器是一个进程,它在操作系统中的某个组下运行,并带有某些限制和隔离因素。具体来说,我们指的是Docker容器,它允许将应用程序(服务或流程)作为独立组件进行管理。

容器代表了交付软件的另一种方式。创建考虑到上一节中考虑的 Python 包更适合于库或框架,它们的目标是重用代码,并利用收集特定逻辑的单一位置。

对于容器,目标不是创建库,而是创建应用程序(大多数情况下)。然而,应用程序或平台并不一定意味着整个服务。构建容器的想法是创建表示服务的小组件,这些组件具有小而清晰的用途。

在本节中,我们将在讨论容器时提到 Docker,并将探讨如何为 Python 项目创建 Docker 映像和容器的基础知识。请记住,这不是将应用程序启动到容器中的唯一技术,而且它完全独立于 Python。

Docker 容器需要运行一个映像,该映像是从其他基础映像创建的。但是我们创建的图像本身可以作为其他容器的基础图像。我们希望在应用程序中有一个可以跨多个容器共享的公共基础的情况下这样做。一个潜在的用途是创建一个基本映像,以我们在上一节中描述的方式安装一个(或多个)包,以及它的所有依赖项,包括操作系统级别的依赖项。正如第 9 章通用设计模式中所述,我们创建的包不仅可以依赖于其他 Python 库,还可以依赖于特定平台(特定操作系统)以及预安装在该操作系统中的特定库,没有它,软件包将无法安装,并将失败。

容器是一个很好的可移植工具。它们可以帮助我们确保我们的应用程序具有规范的运行方式,还可以大大简化开发过程(跨环境复制场景、复制测试、加入新的团队成员等等)。

Docker 有助于避免平台相关问题。我们的想法是将 Python 应用程序打包为 Docker 容器映像,这将有助于本地开发和测试,以及在生产中启动我们的软件。

通常,在过去,由于 Python 的性质,很难部署它。因为它是一种解释语言,所以您编写的代码将由生产主机上的 Python 虚拟机运行。因此,您需要确保目标平台将具有您期望的解释器版本。此外,依赖项的打包也很困难:这是通过将所有内容打包到虚拟环境并运行它来完成的。如果您有依赖于平台的细节,并且您的一些依赖项使用了 C 扩展,那么事情就会变得更加困难。在这里,我甚至不是在谈论 Windows 或 Linux;有时,甚至不同版本的 Linux(基于 Debian 和基于 Red Hat)运行代码所需的 C 库版本也不同,因此测试应用程序并确保其正常运行的唯一真正方法是使用虚拟机,并根据正确的体系结构编译所有内容。在现代应用中,这些痛苦大部分应该消失。现在,您的根目录中将有一个Dockerfile,以及构建该应用程序的说明。您的应用程序也可以在 Docker 中运行,从而在生产环境中交付。

正如包是我们重用代码和统一标准的方式一样,容器代表我们创建应用程序的不同服务的方式。它们满足架构的关注点分离SoC原则背后的标准。每个服务都是另一种组件,它将封装一组独立于应用程序其余部分的功能。这些容器的设计应确保它们有利于可维护性。如果职责明确划分,则服务的更改不应影响应用程序的任何其他部分。

在下一节中,我们将介绍如何从 Python 项目创建 Docker 容器的基础知识。

用例

作为一个示例,我们可以如何组织应用程序的组件,以及前面的概念在实践中如何工作,我们给出以下简单示例。

用例是有一个交付食物的应用程序,该应用程序有一个特定的服务,用于跟踪每个交付在其不同阶段的状态。我们将只关注这个特定的服务,而不管应用程序的其余部分如何显示。该服务必须非常简单——一个 RESTAPI,当被问及特定订单的状态时,它将返回一个带有描述性消息的 JSON 响应。

我们将假设关于每个特定订单的信息存储在数据库中,但是这个细节应该根本不重要。

目前,我们的服务有两个主要问题:获取关于特定订单的信息(从可能存储该订单的任何地方),并以有用的方式向客户机提供该信息(在本例中,以 JSON 格式提供结果,作为 web 服务公开)。

由于应用程序必须是可维护和可扩展的,我们希望尽可能隐藏这两个问题,并将重点放在主要逻辑上。因此,这两个细节被抽象并封装到具有核心逻辑的主应用程序将使用的 Python 包中,如图 10.1所示:

图 10.1:一个服务应用程序(名为“Web 服务”),它使用两个 Python 包,其中一个连接到数据库。

在下面的部分中,我们简要地展示了代码可能会如何出现,主要是在包方面,以及如何从这些包中创建服务,以便最终看到我们可以推断出什么结论。

代码

本例中创建 Python 包的想法是为了说明如何制作抽象和隔离的组件,以便有效地工作。实际上,并不需要这些 Python 包;我们可以创建正确的抽象作为“交付服务”项目的一部分,并且,在保留正确隔离的同时,它将毫无问题地工作。

当存在将要重复的逻辑并且预期将在许多其他应用程序(将从这些包导入)中使用时,创建包更有意义,因为我们希望支持代码重用。在这种特殊情况下,没有这样的需求,因此可能超出了设计范围,但这种区别仍然使“可插拔体系结构”或组件的概念更加清晰,这实际上是一个包装器,抽象了我们不想处理的技术细节,更不用说依赖于它了。

storage包负责检索所需的数据,并以适合业务规则的方便格式将其呈现给下一层(交付服务)。主应用程序现在应该知道这些数据的来源、格式等。这就是为什么我们在两者之间有这样一个抽象,所以应用程序不直接使用行或 ORM 实体,而是使用一些可行的东西。

域模型

以下定义适用于业务规则类。请注意,它们是纯业务对象,不受任何特定内容的约束。它们不是 ORM 的模型,也不是外部框架的对象,等等。应用程序应该使用这些对象(或具有相同条件的对象)。

在每种情况下,docstring 根据业务规则记录每个类的用途:

from typing import Union
class DispatchedOrder:
    """An order that was just created and notified to start its delivery."""
    status = "dispatched"
    def __init__(self, when):
        self._when = when
    def message(self) -> dict:
        return {
            "status": self.status,
            "msg": "Order was dispatched on {0}".format(
                self._when.isoformat()
            ),
        }
class OrderInTransit:
    """An order that is currently being sent to the customer."""
    status = "in transit"
    def __init__(self, current_location):
        self._current_location = current_location
    def message(self) -> dict:
        return {
            "status": self.status,
            "msg": "The order is in progress (current location: {})".format(
                self._current_location
            ),
        }
class OrderDelivered:
    """An order that was already delivered to the customer."""
    status = "delivered"
    def __init__(self, delivered_at):
        self._delivered_at = delivered_at
    def message(self) -> dict:
        return {
            "status": self.status,
            "msg": "Order delivered on {0}".format(
                self._delivered_at.isoformat()
            ),
        }
class DeliveryOrder:
    def __init__(
        self,
        delivery_id: str,
        status: Union[DispatchedOrder, OrderInTransit, OrderDelivered],
    ) -> None:
        self._delivery_id = delivery_id
        self._status = status
    def message(self) -> dict:
        return {"id": self._delivery_id, **self._status.message()} 

从这段代码中,我们已经可以了解应用程序的外观。我们希望有一个DeliveryOrder对象,它有自己的状态(作为内部合作者),一旦有了这个状态,我们将调用它的message()方法将此信息返回给用户。

从应用程序调用

这就是这些对象在应用程序中的使用方式。请注意,这是如何依赖于前面的包(webstorage),而不是相反的方式:

from storage import DBClient, DeliveryStatusQuery, OrderNotFoundError
from web import NotFound, View, app, register_route
class DeliveryView(View):
    async def _get(self, request, delivery_id: int):
        dsq = DeliveryStatusQuery(int(delivery_id), await DBClient())
        try:
            result = await dsq.get()
        except OrderNotFoundError as e:
             raise NotFound(str(e)) from e
        return result.message()
register_route(DeliveryView, "/status/<delivery_id:int>") 

在上一节中,显示了domain对象,这里显示了应用程序的代码。我们不是错过了什么吗?当然,但这是我们现在真正需要知道的吗?不一定。

storageweb包中的代码被故意省略了(尽管我们非常鼓励读者查看它,本书的存储库包含了完整的示例)。此外,这是故意的,选择这些包的名称是为了不透露任何技术细节-storageweb

再次查看上一个清单中的代码。你能说出正在使用哪些框架吗?它是否说明数据是否来自文本文件、数据库(如果是,是什么类型的?SQL?NoSQL?)或其他服务(例如 web)?假设它来自关系数据库。关于如何检索这些信息(手动 SQL 查询?通过 ORM?)有什么线索吗?

网络呢?我们能猜出使用了什么框架吗?

事实上,我们无法回答这些问题中的任何一个可能是一个好迹象。这些都是细节,细节应该被封装起来。我们不能回答这些问题,除非我们看一下这些包里面有什么。

回答前面的问题还有另一种方式,它以问题本身的形式出现:为什么我们需要知道这一点?查看代码,我们可以看到有一个DeliveryOrder,它使用交付的标识符创建,并且它有一个get()方法,该方法返回一个表示交付状态的对象。如果所有这些信息都是正确的,那就是我们应该关心的。怎么做有什么区别?

我们创建的抽象使代码具有声明性。在声明式编程中,我们声明我们想要解决的问题,而不是我们想要如何解决它。它与命令式相反,在命令式中,我们必须使所有需要的步骤都显式化,以便获得某些东西(例如,连接到数据库,运行此查询,解析结果,将其加载到此对象中,等等)。在本例中,我们声明只想知道由某个标识符给出的传递状态。

这些包负责处理细节,并以方便的格式表示应用程序所需的内容,即上一节中介绍的对象。我们只需要知道,storage包包含一个对象,给定传递的 ID 和存储客户机(为了简单起见,这个依赖项被注入到这个示例中,但也可能有其他替代项),它将检索DeliveryOrder,然后我们可以请求它编写消息。

这种体系结构提供了方便,并且更容易适应变化,因为它保护业务逻辑的核心不受可能变化的外部因素的影响。

想象一下,我们想要改变检索信息的方式。那有多难?应用程序依赖于 API,如以下 API:

dsq = DeliveryStatusQuery(int(delivery_id), await DBClient()) 

因此,这只是改变get()方法的工作方式,使其适应新的实现细节。我们所需要的就是这个新对象返回DeliveryOrderget()方法,就这些了。我们可以更改查询、ORM、数据库等等,而且在所有情况下,应用程序中的代码都不需要更改!

适配器

尽管如此,如果不查看包中的代码,我们可以得出结论,它们可以作为应用程序技术细节的接口。

事实上,由于我们是从高层次的角度来看应用程序的,不需要看代码,我们可以想象在这些包中一定有适配器设计模式的实现(在第 9 章通用设计模式中介绍)。其中一个或多个对象正在使外部实现适应应用程序定义的 API。这样,想要使用应用程序的依赖项必须符合 API,并且必须制作一个适配器。

不过,在应用程序的代码中有一条与此适配器相关的线索。请注意视图是如何构造的。它继承自一个名为View的类,该类来自我们的web包。我们可以推断,这个View反过来是一个从可能使用的某个 web 框架派生的类,通过继承创建适配器。需要注意的重要一点是,一旦这样做了,唯一重要的对象就是我们的View类,因为在某种程度上,我们正在创建自己的框架,它基于对现有框架的修改(但是,修改框架意味着只需修改适配器,而不是整个应用程序)。

从下一节开始,我们将了解这些服务在内部是什么样子的。

服务

为了创建服务,我们将在 Docker 容器中启动 Python 应用程序。从基本映像开始,容器必须安装应用程序运行的依赖项,该应用程序在操作系统级别也有依赖项。

这实际上是一种选择,因为它取决于依赖项的使用方式。如果我们使用的包需要在安装时编译操作系统上的其他库,我们可以通过为库的平台构建一个轮子并直接安装来避免这种情况。如果在运行时需要这些库,那么就别无选择,只能使它们成为容器映像的一部分。

现在,我们将讨论准备在 Docker 容器中运行 Python 应用程序的多种方法之一。这是将 Python 项目打包到容器中的众多备选方案之一。首先,我们看一下目录的结构:

├── Dockerfile
├── libs
│   ├── README.rst
│   ├── storage
│   └── web
├── Makefile
├── README.rst
├── setup.py
└── statusweb
    ├── __init__.py
    └── service.py 

libs目录可以忽略,因为它只是放置依赖项的位置(此处显示的目的是在setup.py文件中引用依赖项时记住它们,但它们可以放置在不同的存储库中,并通过pip远程安装)。

我们在Makefile中有一些助手命令,然后是setup.py文件,应用程序本身在statusweb目录中。打包应用程序和库之间的一个共同区别是,后者在setup.py文件中指定它们的依赖项,而前者有一个requirements.txt文件,依赖项通过pip install -r requirements.txt安装在该文件中。通常,我们会在Dockerfile中这样做,但为了使事情更简单,在这个特定示例中,我们假设从setup.py文件中获取依赖项就足够了。这是因为除此之外,在处理依赖关系时还需要考虑更多的因素,例如冻结软件包的版本、跟踪间接依赖关系、使用额外的工具(如pipenv)以及更多超出本章范围的主题。此外,为了保持一致性,还习惯于从requirements.txt读取setup.py文件。

现在我们有了setup.py文件的内容,其中说明了应用程序的一些细节:

from setuptools import find_packages, setup
with open("README.rst", "r") as longdesc:
    long_description = longdesc.read()
install_requires = ["web==0.1.0", "storage==0.1.0"]
setup(
    name="delistatus",
    description="Check the status of a delivery order",
    long_description=long_description,
    author="Dev team",
    version="0.1.0",
    packages=find_packages(),
    install_requires=install_requires,
    entry_points={
        "console_scripts": [
            "status-service = statusweb.service:main",
        ],
    },
) 

我们注意到的第一件事是应用程序声明了它的依赖项,即我们创建并放置在libs/下的包,即webstorage,抽象并适应一些外部组件。反过来,这些包将具有依赖性,因此我们必须确保容器在创建映像时安装所有必需的库,以便它们能够成功安装,然后再安装此包。

我们注意到的第二件事是传递给setup函数的entry_points关键字参数的定义。这不是严格强制的,但是创建一个入口点是个好主意。当包安装在虚拟环境中时,它将共享此目录及其所有依赖项。虚拟环境是具有给定项目依赖项的目录结构。它有许多子目录,但最重要的是:

  • <virtual-env-root>/lib/<python-version>/site-packages
  • <virtual-env-root>/bin

第一个包含该虚拟环境中安装的所有库。如果我们要用这个项目创建一个虚拟环境,那么这个目录将包含webstorage包,以及它的所有依赖项,再加上一些额外的基本包和当前项目本身。

第二个,/bin/包含虚拟环境处于活动状态时可用的二进制文件和命令。默认情况下,它只是 Python 的版本,pip和其他一些基本命令。当我们创建控制台入口点时,一个具有该声明名称的二进制文件被放置在那里,因此,当环境处于活动状态时,我们可以运行该命令。调用此命令时,它将运行在虚拟环境的所有上下文中指定的函数。这意味着我们可以直接调用它,而不必担心虚拟环境是否处于活动状态,或者依赖项是否安装在当前运行的路径中。

定义如下:

"status-service = statusweb.service:main" 

等号的左侧声明入口点的名称。在这种情况下,我们将有一个名为status-service的命令可用。右侧声明该命令应如何运行。它需要定义函数的包,后面跟着函数名:.在这种情况下,它将运行statusweb/service.py中声明的main函数。

接下来是Dockerfile的定义:

FROM python:3.9-slim-buster
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        python-dev \
        gcc \
        musl-dev \
        make \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ADD . /app
RUN pip install /app/libs/web /app/libs/storage
RUN pip install /app
EXPOSE 8080
CMD ["/usr/local/bin/status-service"] 

该映像基于安装了 Python 的轻量级 Linux 映像构建,然后安装操作系统依赖项,以便部署我们的库。根据前面的考虑,这个Dockerfile只是复制库,但它也可以相应地从requirements.txt文件安装。在所有的pip install命令准备就绪后,它将复制工作目录中的应用程序,Docker 的入口点(不要与 Python 的入口点混淆,CMD命令)调用包的入口点,我们在其中放置启动进程的函数。对于本地开发,我们仍然会使用Dockerfiledocker-compose.yml文件,其中包含所有服务(包括数据库等依赖项)、基础映像以及它们的链接和互连方式的定义。

现在容器已经运行,我们可以启动它并对其运行一个小测试,以了解其工作原理:

$ curl http://localhost:5000/status/1
{"id":1,"status":"dispatched","msg":"Order was dispatched on 2018-08-01T22:25:12+00:00"} 

让我们从下一节开始,分析到目前为止看到的代码的体系结构特征。

分析

从之前的实施中可以得出许多结论。虽然这似乎是一个好方法,但也有好处带来的缺点;毕竟,没有一个架构或实现是完美的。这意味着像这样的解决方案不可能适用于所有情况,因此它在很大程度上取决于项目、团队、组织等的环境。

诚然,解决方案的主要思想是尽可能多地抽象细节,正如我们将看到的,有些部分无法完全抽象,而且层之间的契约也意味着抽象漏洞。

毕竟,技术总是悄然而入。例如,如果我们要将实现从 REST 服务更改为通过 GraphQL 为数据提供服务,我们必须调整应用程序服务器的配置和构建方式,但是,我们仍然应该能够拥有与前面的结构非常相似的结构。即使我们想做一个更彻底的改变,以便将我们的服务转换为 gRPC 服务器,我们当然会被迫修改一些粘合代码,但我们仍然应该能够尽可能多地使用我们的包。所需的更改应保持在最低限度。

依赖流

请注意,依赖项只朝一个方向流动,因为它们更靠近业务规则所在的内核。这可以通过查看import语句来追溯。例如,应用程序从存储中导入它所需的一切,而这一点在任何方面都不是颠倒的。

打破这条规则会产生耦合。现在代码的排列方式意味着应用程序和存储之间的依赖性很弱。API 是这样的,我们需要一个带有get()方法的对象,任何想要连接到应用程序的存储都需要根据本规范实现这个对象。因此,依赖关系是反向的,这取决于每个存储来实现这个接口,以便根据应用程序的期望创建一个对象。

局限性

并不是所有的东西都可以抽象出来。在某些情况下,这根本不可能,而在另一些情况下,这可能并不方便。让我们从便利性方面开始。

在本例中,有一个选择的 web 框架适配器,用于向应用程序提供一个干净的 API。在更复杂的情况下,这样的改变可能是不可能的。即使有了这种抽象,库的某些部分仍然对应用程序可见。完全脱离 web 框架并不完全是一个问题,因为我们迟早会需要它的一些特性或技术细节。

这里重要的不是适配器,而是尽可能隐藏技术细节的想法。这意味着,在应用程序代码列表中显示的最好的东西不是我们的 web 框架版本和实际版本之间有一个适配器,而是在可见代码的任何部分都没有提到后者。该服务已经明确表示web只是一个依赖项(一个正在导入的细节),并透露了它应该做的事情背后的意图。目标是揭示意图(如代码中所示),并尽可能推迟细节。

至于什么东西不能被隔离,这些是最接近代码的元素。在这种情况下,web 应用程序以异步方式使用在它们内部操作的对象。这是一个我们无法回避的硬约束。诚然,storage包中的任何内容都可以更改、重构和修改,但无论这些修改是什么,它仍然需要保留接口,这包括异步接口。

可测试性

同样,与代码非常相似,体系结构可以从将各个部分划分为更小的组件中获益。依赖关系现在被独立的组件隔离和控制,这一事实为我们的主应用程序提供了一个更清晰的设计,现在我们更容易忽略边界来集中精力测试应用程序的核心。

例如,我们可以为依赖项创建补丁,编写更简单的单元测试(它们不需要数据库),或者启动整个 web 服务。使用纯domain对象意味着更容易理解代码和单元测试。甚至适配器也不需要那么多测试,因为它们的逻辑应该非常简单。

记住第 8 章单元测试和重构中提到的软件测试金字塔。我们希望有大量的单元测试,然后是更少的组件测试,最后是更少的集成测试。将我们的体系结构划分为不同的组件对于组件测试有很大的帮助:我们可以模拟我们的依赖关系,并单独测试一些组件。

这既便宜又快,但这并不意味着我们不应该进行集成测试。为了确保我们的最终应用程序按预期工作,我们需要进行集成测试,这些测试将使我们的体系结构的所有组件(无论是微服务还是软件包)协同工作。

意图揭示

意图揭示对于我们的代码来说是一个至关重要的概念,每个名字都必须被明智地选择,清楚地传达它应该做什么。每个函数都应该讲述一个故事。我们应该保持函数简短、关注点分离、依赖项分离,并在代码的每个部分为抽象赋予正确的含义。

好的架构应该揭示它所包含的系统的意图。它不应该提及构建它的工具;这些都是细节,正如我们详细讨论的,细节应该隐藏和封装。

良好的软件设计原则适用于所有级别。与我们希望编写可读代码的方式相同,我们需要记住代码的意图揭示方面,体系结构还必须表达它试图解决的问题的意图。

所有这些想法都是相互关联的。为了确保我们的体系结构是根据领域问题定义的,同样的意图也会导致我们尽可能多地抽象细节,创建抽象层,反转依赖关系和独立关注点。

当涉及到重用代码时,Python 包是一个非常好且灵活的选择。在决定创建包时,凝聚力和单一责任原则等标准是最重要的考虑因素。为了使组件具有内聚性和较少的责任,微服务的概念开始发挥作用,为此,我们已经了解了如何从打包的 Python 应用程序开始在 Docker 容器中部署服务。

与软件工程中的一切一样,也有限制和例外。不可能总是像我们希望的那样抽象事物,或者完全隔离依赖关系。有时,遵守本书中解释的原则是不可能的(或不实际的)。但这可能是读者应该从书中得到的最好的建议——它们只是原则,而不是法律。如果从一个框架中抽象出来是不可能的,或者是不实用的,那么它就不应该是一个问题。还记得整本书中引用的 Python 的禅宗本身吗?实用性胜过纯洁性

以下是您可以参考的信息列表:

这本书的内容是一个参考,一个可能的方法来实现一个软件解决方案,遵循所提到的标准。通过实例说明了这些标准,并给出了每个决策的基本原理。读者很可能不同意示例中采用的方法。

事实上,我鼓励你不同意:观点越多,辩论就越丰富。但不管有什么意见,重要的是要明确,这里提出的绝不是一个强有力的指示,必须严格遵守。恰恰相反;这是一种展示解决方案和一系列想法的方式,您可能会发现这些想法很有帮助。

正如一开始介绍的,本书的目的不是给你可以直接应用的食谱或公式,而是培养你的批判性思维。习语和句法特征来来往往;它们随着时间的推移而变化。但是想法和核心软件概念仍然存在。通过这些工具和提供的示例,您应该更好地理解干净代码的含义。

我真诚地希望这本书帮助你成为一个比你开始之前更好的开发人员,我祝愿你在你的项目中取得成功。

分享你的经验

感谢您抽出时间阅读本书。如果你喜欢这本书,帮助别人找到它。在处留下评论 https://www.amazon.com/dp/1800560214

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

从0开始学微服务 -〔胡忠想〕

透视HTTP协议 -〔罗剑锋(Chrono)〕

RPC实战与核心原理 -〔何小锋〕

职场求生攻略 -〔臧萌〕

手把手带你写一个Web框架 -〔叶剑峰〕

说透低代码 -〔陈旭〕

超级访谈:对话汤峥嵘 -〔汤峥嵘〕

B端产品经理入门课 -〔董小圣〕

超级访谈:对话道哥 -〔吴翰清(道哥)〕