Python-架构整洁指南-全-

Python 架构整洁指南(全)

原文:zh.annas-archive.org/md5/41fdff12fc743ab0d78e5926364772f1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Clean Architecture 在现代软件开发中变得越来越重要,尤其是在应用程序日益复杂且团队需要长期维护它们的情况下。虽然架构原则通常以抽象术语讨论,但本书通过实际的 Python 实现,使 Clean Architecture 生动起来,展示了这些概念如何改变你的开发方法。

Python 的多功能性使其成为应用 Clean Architecture 原则的绝佳语言。其动态特性和广泛的生态系统使快速开发成为可能,但同样的优势也可能导致随着应用程序的发展而变得复杂、难以维护的代码库。Clean Architecture 提供了平衡 Python 的灵活性与结构化、可维护设计的框架。

在本书中,我们将探讨如何将 Clean Architecture 模式应用于 Python 项目,创建不仅功能性强,而且可测试、可维护和适应变化的系统。以任务管理应用程序作为我们的示例,我们将从头开始构建一个完整的系统,展示适当的架构边界如何创建能够随着时间的推移优雅演变的软件。

无论你是构建新系统还是维护现有系统,本书中描述的原则和实践将帮助你创建更健壮和灵活的 Python 应用程序。你将学习如何将核心业务逻辑与外部关注点分离,在系统组件之间创建清晰的接口,并实施能够使你的软件适应不断变化需求的模式。

在本书中,我们将涵盖以下主要主题:

  • 理解 Clean Architecture 的基本原理并在 Python 应用程序中应用 SOLID 原则

  • 通过类型提示增强 Python,以加强架构边界和接口

  • 构建健壮的领域模型和应用层,这些层封装了独立于外部关注点的业务逻辑

  • 通过控制器、演示者和适配器在架构层之间创建清晰的接口

  • 在保持架构完整性的同时,与框架和外部系统集成

  • 在实际场景中应用 Clean Architecture:测试、Web 接口、可观察性和遗留系统转型

这些主题共同构成了构建能够经受时间考验的 Python 应用程序的综合方法。到本书结束时,你将具备理论理解和实践技能,能够在自己的项目中实施 Clean Architecture,创建更易于维护、可测试和适应变化的系统。

本书面向的对象

这本书是为希望创建更可维护、可测试和可适应应用程序的 Python 开发者而写的。它非常适合具有 Python 经验的中级或更高水平的开发者,他们希望提高自己的架构技能。如果您在处理抗拒变化的代码库、经历了复杂的依赖关系或只是想编写更好的 Python 代码时遇到困难,这本书将为您提供克服这些挑战的实际策略和模式。

几个角色会发现这些材料有价值:

  • 软件架构师寻求在 Python 项目中实施干净、可维护的系统设计

  • 技术负责人负责指导开发团队和建立编码标准

  • 后端开发者正在开发需要随时间演变的复杂应用程序

  • DevOps 工程师寻求创建更多可测试、可观察的 Python 服务

虽然初学者可以从所介绍的概念中受益,但了解 Python 和面向对象编程原则将有助于您从这些材料中获得最大收益。技术负责人、架构师和高级开发者将发现对于在团队环境中实施 Clean Architecture 和指导架构决策有价值的见解。

本书涵盖的内容

第一章Clean Architecture 基础:转变 Python 开发,介绍了 Clean Architecture 的基础概念,并解释了为什么这些原则对 Python 开发者很重要。它建立了核心架构层,并探讨了 Clean Architecture 如何改变 Python 开发实践。

第二章SOLID 基础:构建健壮的 Python 应用程序,探讨了 SOLID 原则如何为 Clean Architecture 提供基础。通过实际的 Python 示例,您将学习如何实现单一职责、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。

第三章类型增强 Python:强化 Clean Architecture,展示了 Python 的类型提示如何增强架构边界。您将探索类型如何改进接口定义、支持依赖倒置并使架构验证的工具更好。

第四章领域驱动设计:构建核心业务逻辑,专注于构建健壮的领域模型。您将学习如何识别和建模实体、值对象和领域服务,同时确保它们保持对外部关注点的独立性。

第五章应用层:协调用例,涵盖了实现协调领域对象以完成特定任务的用例的实施。您将在保持适当的关注点分离的同时,创建领域和外部层之间的干净接口。

第六章接口适配器层:控制器和表示器,探讨了如何创建将外部请求转换为控制器,以及格式化领域数据的表示器。你将在应用程序核心和交付机制之间构建清晰的边界。

第七章框架和驱动层:外部接口,展示了如何在保持核心业务逻辑独立的同时集成外部框架和基础设施。你将实现数据库适配器、Web 框架和尊重清洁架构边界的第三方服务。

第八章使用清洁架构实现测试模式,提供了跨架构边界进行全面测试的策略。你将为领域对象创建单元测试,为用例创建集成测试,以及验证系统行为的端到端测试。

第九章添加 Web UI:清洁架构的接口灵活性,展示了如何为你的清洁架构应用程序实现 Web 接口。你将构建一个基于 Flask 的 Web 接口,演示清洁架构如何在不干扰现有功能的情况下添加新接口。

第十章实现可观察性:监控和验证,涵盖了在保持清洁架构边界的同时添加日志记录、监控和架构验证的策略。你将实现横切关注点,而不会损害架构的完整性。

第十一章从遗留代码到清洁架构:重构 Python 以提高可维护性,提供了逐步改造遗留 Python 应用程序的实际方法。你将学习增量重构技术,这些技术可以在保持系统稳定性的同时改进架构。

第十二章你的清洁架构之旅:下一步,探讨了如何在不同系统类型和组织环境中应用清洁架构。你将发现关于架构领导力、社区建设和平衡实用主义与架构原则的策略。

为了充分利用本书

假设对 Python 编程有基本的了解,包括对类、继承和组合等面向对象概念的了解。对 Web 开发概念的了解将有助于后续章节,但不是必需的。

本书涵盖的软件/硬件 操作系统要求
Python 3.13 或更高版本 Windows、macOS 或 Linux

如果你使用的是本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库获取代码。这样做将有助于你避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

所有示例的完整代码都可在本书 GitHub 仓库中找到,网址为github.com/PacktPublishing/Clean-Architecture-with-Python。此外,对于相关章节,你将找到与该章节相对应的功能实现我们的任务管理应用程序。这允许你在本书的演变过程中运行、测试和探索应用程序的工作版本。每一章都是基于前一章的,因此建议按顺序阅读本书,尽管经验丰富的开发者可能会选择关注与其当前挑战相关的特定章节。

我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781836642893

使用的约定

本书使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 X/Twitter 用户名。以下是一个示例:“project_id参数来自 URL 本身(/projects/<project_id>/tasks/new),而表单字段包含任务详情。”

代码块应如下设置:

# cli_main.py
def main() -> int:
    """Main entry point for the CLI application."""
    app = create_application(
        notification_service=NotificationRecorder(),
        task_presenter=CliTaskPresenter(),
        project_presenter=CliProjectPresenter(),
    )
    cli = ClickCli(app)
    return cli.run() 

任何命令行输入或输出都应如下编写:

> python web_main.py
 * Serving Flask app 'todo_app.infrastructure.web.app'
 * Debug mode: on
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 954-447-204
127.0.0.1 - - [05/Feb/2025 13:58:57] "GET / HTTP/1.1" 200 - 

粗体: 表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。例如:“边界上下文是定义特定领域模型适用的概念边界。”

警告或重要提示如下所示。

技巧和窍门如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 请通过电子邮件[feedback@packtpub.com]发送反馈,并在邮件主题中提及本书的标题。如果你对本书的任何方面有疑问,请通过电子邮件[questions@packtpub.com]联系我们。

勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们非常感谢你能向我们报告。请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表格。

盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过版权@packtpub.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com/.

分享您的想法

一旦您阅读了《使用 Python 的清洁架构》,我们很乐意听到您的想法!扫描下面的二维码,直接进入此书的亚马逊评论页面并分享您的反馈。

packt.link/r/183664289X

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢随时随地阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不会就此停止,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接:

图片

packt.link/free-ebook/9781836642893

  1. 提交您的购买证明。

  2. 就这些!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。

分享您的想法

一旦您阅读了《使用 Python 的清洁架构》,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

第一部分

Python 清洁架构基础

本部分在 Python 环境中确立了清洁架构的基本原则和模式。您将全面了解核心架构概念、SOLID 原则以及 Python 的类型系统如何增强您的架构实现。这些章节提供了构建本书其余部分的基础知识,为您在个人 Python 项目中实施清洁架构奠定成功基础。

本书本部分包括以下章节:

  • 第一章, Python 清洁架构基础:转型 Python 开发

  • 第二章, SOLID 基础:构建健壮的 Python 应用程序

  • 第三章, 类型增强的 Python:强化清洁架构

第一章:清洁架构要素:转型 Python 开发

作为 Python 开发者,我们应用最佳实践,如编写干净的函数、使用描述性变量名和追求模块化。然而,随着我们的应用程序增长,我们常常难以在规模上保持这种清晰性和适应性。Python 的简洁性和多功能性使其在从 Web 开发到数据科学等众多项目中变得流行,但这些优势在应用程序变得更加复杂时可能成为挑战。我们发现自己在缺乏一个总体规划,一个指导我们决策并保持项目在演变过程中可维护的总体架构。这就是清洁架构发挥作用的地方,它提供了一种构建 Python 应用程序的结构化方法,平衡了规划和敏捷性,为我们提供了可持续、大规模开发的架构指导。

清洁架构,由罗伯特·C·马丁于 2012 年提出(blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html),将数十年的软件设计智慧综合成一套连贯的原则。它解决了软件开发中的一些持续挑战,如管理复杂性和适应变化。通过将清洁架构原则应用于 Python 项目,开发者可以创建不仅功能性强,而且随着时间的推移可维护、可测试和可适应的系统。

在本章中,我们将探讨清洁架构的本质及其与 Python 开发的关联。我们将研究清洁架构原则如何与 Python 的简洁性和可读性哲学相一致,从而创造一种自然协同效应,增强 Python 的优势。您将了解清洁架构如何帮助您构建易于理解、修改和扩展的 Python 应用程序,即使它们在复杂性增加的情况下也是如此。

到本章结束时,您将了解清洁架构原则及其对 Python 开发的潜在益处。您将了解这种方法如何解决软件开发中的常见挑战,尤其是在 Python 项目规模和复杂性增长时。这种对清洁架构的基础理解将是我们深入探讨其在 Python 中的实现和最佳实践的基础。

在本章中,我们将探讨以下主要主题:

  • 为什么在 Python 中使用清洁架构:平衡规划和敏捷性的好处

  • 什么是清洁架构?

  • 清洁架构与 Python:天作之合

技术要求

本章中的代码片段仅用于演示目的,展示了一些章节中提到的主题和实践的应用。未来的章节将包含更复杂的代码示例,并注明具体要求。所有章节的代码都可以在本书配套的 GitHub 仓库中找到:github.com/PacktPublishing/Clean-Architecture-with-Python

为什么 Python 中的 Clean Architecture:平衡规划和敏捷的好处

在本节中,我们将探讨 Python 开发中规划和敏捷之间的关键平衡,以及 Clean Architecture 如何帮助实现这种平衡。我们将研究现代 Python 应用程序日益增加的复杂性带来的挑战,以及当今快节奏商业环境中敏捷性的必要性。然后,我们将讨论规划和灵活性之间的权衡,以及架构思维如何为管理这些权衡提供一个框架。最后,我们将探讨架构在管理复杂性和为长期成功奠定基础中的作用。通过这些讨论,你将深入了解为什么 Clean Architecture 对于努力创建可维护、可适应和高效应用的 Python 开发者尤其有价值。

让我们从研究现代 Python 开发面临的复杂挑战开始。

现代 Python 开发中的复杂性挑战

随着 Python 的流行度飙升,用它构建的应用程序规模和复杂性也在增加。从网络服务到数据科学管道,Python 项目正在变得更大、更复杂。这种增长带来了重大的挑战,每个 Python 开发者都必须应对。

系统的日益复杂使得它们更难理解、修改和维护。这种复杂性可能会严重限制你添加新功能或应对变化需求的能力。复杂 Python 系统的维护负担可能会压垮开发团队,减缓进步和创新。即使是大型复杂系统中的微小变化也可能产生深远的影响,使得修改变得昂贵且风险高。

考虑一个虚构的大型基于 Python 的电子商务网站:PyShop。业务决定实施一个看似简单的功能:在订单中添加包装选项。然而,这个简单的添加很快就会演变成一个复杂的项目:

  • 订单处理模块需要更新以包含包装选项

  • 库存系统需要修改以跟踪包装材料

  • 定价引擎需要调整以计算额外成本

  • 用户界面(UI)必须更新以展示包装选项

  • 配送系统需要更改以包含包装说明

原本估计为两周的任务延长成了多个月的项目。每一次变更都可能影响其他系统部分:订单处理的调整会影响报告,库存的变更会影响供应链管理,而用户界面修改需要广泛的用户体验测试。

这个例子突出了在复杂系统中相互关联的模块如何将简单的功能添加转变为一个重大的任务,强调了需要一个允许进行更独立变更和更容易测试过程的架构。

此外,随着 Python 项目的增长,开发者经常在抽象上遇到困难,这是 Clean Architecture 帮助解决的问题的一个关键方面。没有适当的指导,代码库可能会出现极端情况:要么变成难以理解和修改的深度嵌套类层次结构的混乱,要么退化成缺乏任何有意义抽象的“万能”类。在前一种情况下,开发者可能会创建过于复杂的继承结构以最大化代码重用,导致一个脆弱的系统,其中一处的变化会在其他地方产生不可预见的后果。在后一种情况下,缺乏抽象会导致庞大、难以驾驭的类和代码重复泛滥,几乎不可能保持一致性或进行系统性的变更。这两种情况都导致代码库难以理解、维护和扩展,这正是精心规划的架构有助于预防的问题。

此外,在当今快速发展的技术环境中,复杂且紧密耦合的系统难以利用新技术。这种限制可能会严重影响你在技术敏捷性至关重要的领域保持竞争力的能力。

敏捷性强制

在我们快节奏的商业环境中,敏捷性不仅仅是一个优势——它是一个必需品。随着每家公司本质上都成为一家科技公司,快速交付的压力从未如此之高。Python 的简单性和广泛的生态系统使其成为快速开发的绝佳选择。

然而,可持续的敏捷性不仅仅需要初始速度,它需要支持持续演化的架构决策。这就像建造一辆高性能的赛车:如果没有适当的设计基础,最初令人印象深刻的加速很快就会受到糟糕的操控和维护挑战的限制。

在快速发展的 Python 应用中,这一原则变得尤为明显。如果没有一个统一的架构,快速添加功能可能会造成错综复杂的依赖关系网。一开始灵活的代码库,几个月后可能会变得僵化且脆弱。开发者发现自己花更多的时间去解析现有代码,而不是编写新功能。当新代码应该添加在哪里或如何与现有组件交互并不立即清晰时,在压力下的开发者可能会做出仓促的决定,导致次优实现并引入错误。这些快速修复进一步复杂化了代码库,使得未来的更改更加困难。初始速度变得不可持续,不是因为速度本身,而是因为缺乏一个坚固的架构基础,这个基础可以引导快速变化并为新功能的集成提供清晰的路径。

需求经常变化,往往不可预测。您的 Python 项目需要以允许轻松适应这些变化的方式进行结构化。这种适应性对于软件开发的长远成功至关重要。

寻找规划与敏捷之间的平衡:规划-敏捷权衡

在规划与敏捷之间找到正确的平衡在 Python 开发中至关重要。正如 Dave Thomas 明智地说:“一开始就进行大规模设计是愚蠢的。一开始就不进行设计则更愚蠢。”关键在于找到既能提供结构又能提供灵活性的中间地带。

良好的架构可以帮助您推迟决策。它为您提供了灵活性,可以在拥有更多信息时将决策推迟到更晚的阶段,以便做出正确的选择。这种方法在 Python 开发中尤其有价值,因为该语言的灵活性有时会导致决策瘫痪。

在 Python 开发中引入架构思维意味着从一开始就考虑项目的长期结构,而不进行过度设计。这是关于创建一个指导开发同时又能适应变化的框架。

架构在管理复杂性中的作用

有效的架构是您在 Python 系统中管理复杂性的最佳工具。良好的架构通过提供清晰的结构和关注点分离SoC)来简化复杂的系统。构建新系统的第一步之一是确定如何划分它,将因相同原因而改变的事物放在一起,将因不同原因而改变的事物分开。

考虑两个面向媒体公司的基于 Python 的内容管理系统CMS),它们都被赋予了实施一个新的人工智能内容标记功能。在一个设计良好的系统中,这个功能被实现为一个独立的模块,具有清晰的接口。它通过定义良好的 API 与现有的内容创建和搜索模块无缝集成。开发者可以独立构建和测试 AI 标记服务,然后以最小的干扰将其连接到内容数据库和 UI。相反,在一个结构不佳的系统中,添加这个功能需要在整个堆栈中进行更改——从数据库模式到前端代码——导致意外的错误和性能问题。在良好架构的系统中所花费的冲刺时间,在结构不佳的系统中可能变成几个月的重构项目,这展示了深思熟虑的初始架构如何显著提高开发效率和系统适应性。

你在早期做出的架构决策将对 Python 项目的长期开发成本和灵活性产生深远影响。一个设计良好的系统可以显著降低随时间变化的成本,使你的团队能够更快地响应新的需求或技术变化。

准备清洁架构

当我们转向讨论清洁架构时,重要的是要理解它为 Python 项目提供了一个平衡规划和敏捷的系统方法。架构原则为你提供了管理并减少 Python 系统中复杂性的强大工具。

在其核心,清洁架构是关于在 Python 应用程序中进行战略性的 SoC(分离关注点)。它提倡一种结构,其中基本业务逻辑被隔离于外部因素,如 UI(用户界面)、数据库和第三方集成。这种分离在应用程序的不同部分之间创建了清晰的边界,每个部分都有其自己的职责。通过这样做,清洁架构允许你的核心业务规则保持纯净,不受输入/输出I/O)机制或数据管理系统DMS)的实现细节的影响。

通过理解这些挑战和原则,你将更好地准备去欣赏清洁架构(Clean Architecture)能为你的 Python 项目带来的好处。在接下来的章节中,我们将深入探讨清洁架构是什么,以及它如何具体应用于 Python 开发,为你提供对抗复杂性并降低软件系统变更成本的工具。

什么是清洁架构?

在探索了在 Python 开发中管理复杂性的挑战以及平衡规划与敏捷性的需求之后,本节的目标是为你提供一个关于 Clean Architecture 的高级概述。我们将快速连续地介绍几个关键概念和原则,以提供广泛的理解。如果你不能立即掌握所有细节,请不要担心。这仅仅是我们的旅程的开始。在接下来的章节中,我们将对这些主题进行深入探讨,其中我们将深入研究实际的 Python 实现和现实世界场景。

Clean Architecture 综合了许多来自先前架构风格的想法,但它围绕一个基本概念构建:将软件元素分离成环级别,并有一个严格的规则,即代码依赖只能从外部级别向内移动。这个原则正式称为依赖规则,它是 Clean Architecture 最关键方面之一。依赖规则指出,源代码依赖必须仅指向内部,即指向更高层次的策略。内圈必须对外圈一无所知,而外圈必须依赖并适应内圈。这确保了外部元素(如数据库、UI 或框架)的变化不会影响核心业务逻辑。目标是创建不仅功能强大,而且随着时间的推移可维护和可适应的软件系统。为了说明这一点,让我们考虑一个简单的用于图书馆管理系统的 Python 应用程序:

  1. 在核心部分,我们有Book类,代表基本的数据结构。

  2. 向外扩展,我们有BookInventory类,它管理书籍的操作。

  3. 在外环中,我们有BookInterface类,它处理与书籍相关的用户交互。

在这个结构中,Book类对BookInventoryBookInterface类一无所知。BookInventory类可能会使用Book类,但不知道关于接口的信息。这种分离确保了核心逻辑不受外部关注的影响。

关键的是,这种结构允许我们修改或甚至替换外部层,而不会影响内部层。例如,我们可以通过修改BookInterface类将 UI 从命令行界面(CLI)更改为 Web 界面,而不需要修改BookBookInventory类。这种灵活性是 Clean Architecture 方法的关键优势。

这种结构旨在产生体现我们之前介绍的关键原则的系统:

  • SoC(分离关注点)

  • 外部细节的独立性

  • 可测试性可维护性

让我们进一步探讨 Clean Architecture 如何实现这些目标以及它为软件开发带来的好处。

洋葱架构概念

让我们可视化之前提到的环形层级,并添加另一个层级细节,以说明每个环的目的。清洁架构通常被描绘成一系列同心圆,就像洋葱一样。每个圆代表软件的不同层,我们讨论的依赖规则确保依赖只在这些边界内向内流动。核心层包含业务逻辑(实体),而外部层包含接口和实现细节(见图 1.1):

图 1.1:清洁架构:一系列同心层

图 1.1:清洁架构:一系列同心层

图 1.1展示了从内部核心业务逻辑向外到外部接口的分离:

  • 实体:在中心是实体,它们封装企业级业务规则。在这个上下文中,实体是产品的主要名词,即使没有软件也存在的基本业务对象。例如,在电子商务系统中,实体可能包括客户产品订单。在任务管理应用程序中,它们可能是用户任务项目。这些实体包含关于这些对象如何行为和交互的最基本、最通用的规则。

  • 用例:下一层包含用例,它们协调数据在实体之间流动。用例代表系统被使用的特定方式。它本质上是对系统在特定场景下应该如何行为的描述。例如,在任务管理应用程序中,用例可能包括创建新任务完成任务分配任务。用例包含特定于应用程序的业务规则,并控制如何以及何时使用实体来实现应用程序的目标。

  • 接口适配器:进一步向外,我们发现接口适配器,它们在用例和外部机构之间转换数据。这一层充当内部层(实体和用例)和外部层之间的一组翻译器。它可能包括处理 HTTP 请求的控制器、格式化数据以供显示的演示者以及转换数据以进行持久化的网关。在 Python Web 应用程序中,这可能包括处理路由和请求处理的视图函数或类。这一层的关键点是它允许我们与框架解耦。

  • 框架和驱动程序:最外层包含框架和驱动程序,其中驻留着外部机构。我们所说的驱动程序是指用于运行系统但不是业务逻辑核心的具体工具、框架和交付机制。在 Python 环境中,可能包括以下示例:

    • Web 框架,如 Django 或 Flask

    • 数据库驱动程序,例如用于 PostgreSQL 的psycopg2或用于 MongoDB 的pymongo

    • 用于发送电子邮件(例如,smtplib)或处理支付等任务的外部库

    • 如果你在构建桌面或移动应用程序(例如,PyQt),则包含 UI 框架

    • 系统工具,用于执行诸如日志记录或配置管理之类的任务

最外层是最不稳定的,因为它是我们与外部世界互动的地方,也是技术最有可能随时间变化的地方。通过将其与我们的核心业务逻辑分开,我们可以更容易地更换这些外部工具,而不会影响我们应用程序的核心。

清洁架构的这种分层结构促进了 SoC(分离关注点),为软件系统建立了一个清晰的组织框架。现在我们已经了解了清洁架构的基本结构,让我们进一步探讨其更广泛的好处。

清洁架构的好处

清洁架构的一个主要优点是它专注于保护和隔离你的核心业务逻辑,即代表你业务基础的领域对象。虽然外部细节,如 Web 框架和持久化引擎,来来去去,但对你业务真正的价值在于在设计实现这些核心领域对象上投入的时间。清洁架构认识到这一点,并提供了一种结构,可以隔离这些关键组件,使其免受外部技术的波动性影响。

这种架构方法保护了你在领域逻辑上的投资,使其免于需要从一个给定的框架或技术迁移。例如,如果你正在使用的框架从开源模式迁移到专有模式,清洁架构允许你替换它,而无需重写你的核心业务逻辑。这种分离显著降低了随时间变化的风险和成本,使你的系统更容易随着需求的变化或需要适应新技术而进化。本质上,清洁架构确保了应用程序中最有价值且最稳定的部分,即你的业务逻辑,不受外部技术和框架经常动荡世界的影响。

另一个关键好处是增强了应用程序所有层的可测试性。核心业务逻辑与外部细节的独立性使得编写全面的单元测试变得容易得多。你可以在隔离的情况下测试业务规则,无需启动数据库或 Web 服务器或构建复杂的模拟。这导致了更彻底的测试,从而产生了更健壮的软件。这也鼓励开发者编写更多的测试,因为这个过程变得简单直接。

清洁架构还在技术选择上提供了灵活性。因为应用程序的核心不依赖于外部框架或工具,所以你可以根据需要自由替换这些元素。这在技术快速发展的世界中尤其有价值,因为今天流行的框架明天可能就过时了。同样,你可能从 CLI(命令行界面)用于内部使用开始,然后添加 Web 界面以实现更广泛的可访问性,所有这些都不需要改变你的核心业务规则代码。你的核心业务逻辑保持稳定,同时你有灵活性在出现时在外层采用新技术。最后,清洁架构促进了长期的发展敏捷性,并导致了罗伯特·C·马丁所说的尖叫架构(blog.cleancoder.com/uncle-bob/2011/09/30/Screaming-Architecture.html)。它关注于分离关注点和管理依赖,结果是一个更容易理解和修改的代码库。尖叫架构的概念表明,当你查看系统的结构时,它应该大声喊出其目的和用例,而不是其框架或工具。例如,你的架构应该大声喊出在线书店,而不是Django 应用程序。这种清晰、以目的为导向的结构使得新团队成员能够快速理解系统的意图并做出贡献。架构本身成为了一种文档形式,一眼就能揭示系统的核心目的和功能。这种清晰性和灵活性在长期内转化为开发速度的提高,即使系统变得更加复杂。它还确保了你的系统始终专注于其核心业务逻辑,而不是被特定的技术实现所束缚。

清洁架构的背景

要充分欣赏清洁架构的价值,了解它在更广泛的软件开发实践和方法论背景中的位置是很重要的。

清洁架构代表了从传统分层架构的演变。虽然它建立在层概念的基础上,但它更加强调 SoC(关注点分离),并且比传统架构更严格地执行依赖规则。与传统分层架构不同,其中底层通常依赖于持久性或基础设施问题,清洁架构保持内部层纯净且专注于业务逻辑。这种关注点的转变使得架构具有更大的灵活性和对变化的适应性。

Clean Architecture 补充了现代开发实践,如 Agile 和 DevOps。它通过促进 持续交付CD)和更容易应对变化而很好地与 Agile 方法论相吻合。清晰的 SoC 支持迭代开发,并使得根据不断变化的需求修改或扩展功能变得更加容易。在 DevOps 方面,Clean Architecture 通过使系统更易于测试和模块化来支持诸如 持续集成和部署CI/CD)等实践。组件之间的清晰边界还可以帮助跨团队扩展开发,因为不同的团队可以以最小的干扰在不同的层或组件上工作。

总结来说,Clean Architecture 为构建可扩展、可维护且能够适应变化的软件系统提供了一种强大的方法。通过关注 SoC 并管理依赖关系,它提供了一种能够经受时间考验和适应技术及业务需求变化的压力的结构。随着我们进入下一节,我们将探讨这些原则如何特别适合 Python 开发实践。

Clean Architecture 和 Python:天作之合

在我们探讨了 Clean Architecture 的原则和好处之后,你可能想知道这些概念与 Python 开发之间的契合度如何。在本节中,我们将发现 Clean Architecture 和 Python 具有天然的亲和力,使得 Python 成为实现 Clean Architecture 原则的绝佳语言。

Python 的哲学,体现在 Python 的禅意peps.python.org/pep-0020/) 中,与 Clean Architecture 原则惊人地吻合。两者都强调简单性、可读性和良好结构代码的重要性。Python 专注于创建清晰、可维护和可适应的代码,为实施 Clean Architecture 提供了坚实的基础。随着我们深入本节,我们将探讨如何利用 Python 语言特性来创建符合 Clean Architecture 原则的健壮、可维护的系统。

在 Python 中实现 Clean Architecture

Python 的动态特性,结合其对 面向对象编程OOP)和函数式编程范式的强大支持,使得开发者能够以比许多其他语言更少的样板代码和更高的清晰度来实现 Clean Architecture 的概念。

关于代码示例的说明

在本书中,你会在我们的代码示例中注意到类型注解(例如,def function(parameter: type) -> return_type))。这些类型提示增强了代码的清晰度,并有助于强制执行 Clean Architecture 边界。我们将在 第三章 中深入探讨这一强大功能。

清洁架构的一个关键原则是依赖抽象而不是具体实现。这一原则直接支持我们之前讨论的依赖规则:依赖关系应仅指向内部。让我们看看如何使用 Python 的抽象基类ABCs)在实践中实现这一点。

考虑以下示例,它模拟了一个通知系统:

from abc import ABC, abstractmethod
class Notifier(ABC):
    @abstractmethod
    def send_notification(self, message: str) -> None:
        pass
class EmailNotifier(Notifier):
    def send_notification(self, message: str) -> None:
        print(f"Sending email: {message}")
class SMSNotifier(Notifier):
    def send_notification(self, message: str) -> None:
        print(f"Sending SMS: {message}")
class NotificationService:
    def __init__(self, notifier: Notifier):
        self.notifier = notifier
    def notify(self, message: str) -> None:
        self.notifier.send_notification(message)
# Usage
email_notifier = EmailNotifier()
email_service = NotificationService(email_notifier)
email_service.notify("Hello via email") 

这个例子展示了使用 Python 的 ABCs 实现清洁架构的关键概念:

  1. ABCNotifier类是一个 ABC,定义了一个所有通知类都必须遵循的接口。这代表了我们清洁架构结构中的内环。

  2. 抽象方法Notifier中的send_notification方法用@abstractmethod标记,强制在子类中实现。

  3. 具体实现EmailNotifierSMSNotifier是外环中的具体类。它们继承自Notifier并提供特定的实现。

  4. 依赖倒置NotificationService类依赖于抽象的Notifier类,而不是具体实现。这遵循了依赖规则,因为抽象的Notifier类(内环)不依赖于具体的通知类(外环)。我们将在下一章更深入地探讨依赖倒置。

这种结构体现了我们讨论过的清洁架构原则:

  • 它尊重依赖规则:抽象的Notifier类(内环)对具体的通知类或NotificationService类(外环)一无所知

  • 它允许轻松扩展:我们可以在不修改NotificationService类的情况下添加新的通知类型(例如PushNotifier

  • 它促进灵活性和可维护性:核心业务逻辑(发送通知)与实现细节(如何发送通知)分离

通过这种方式组织我们的代码,我们创建了一个既灵活又易于维护的系统,同时遵循清洁架构的基本原则。抽象的Notifier类代表我们的核心业务规则,而具体的通知类和NotificationService类代表更易变的外层。这种分离使我们能够轻松地交换或添加新的通知方法,而不会影响我们应用程序的核心逻辑。

因此,我们已经看到了一个简单的 ABC 示例,但这是 Python 真正发光的地方。我们可以不使用类层次结构来实现相同的清洁架构原则,而是依赖 Python 对鸭子类型en.wikipedia.org/wiki/Duck_typing)的支持。这种灵活性是 Python 的强大之处,允许开发者选择最适合他们项目需求的方法,同时仍然遵循清洁架构原则。

鸭式编程是一种编程概念,其中对象的适用性由某些方法或属性的存在来决定,而不是其显式类型。这个名字来源于谚语,“如果它像鸭子走路,像鸭子嘎嘎叫,那么它一定是一只鸭子。”在鸭式编程中,我们不在乎对象类型;我们关心的是它是否能做我们需要它做的事情。

这种方法与清洁架构强调的抽象和接口很好地对齐。如果你希望避免僵化的类层次结构,Python 3.8 中引入的Protocol功能(peps.python.org/pep-0544/)提供了两全其美的解决方案:鸭式编程与类型提示。以下是一个使用协议实现相同通知系统的示例:

from typing import Protocol
class Notifier(Protocol):
    def send_notification(self, message: str) -> None:
        ...
class EmailNotifier: # Note: no explicit inheritance
    def send_notification(self, message: str) -> None:
        print(f"Sending email: {message}")
class SMSNotifier: # Note: no explicit inheritance
    def send_notification(self, message: str) -> None:
        print(f"Sending SMS: {message}")
class NotificationService:
    # Still able to use type hinting
    def __init__(self, notifier: Notifier):
        self.notifier = notifier
    def notify(self, message: str) -> None:
        self.notifier.send_notification(message)
# Usage
sms_notifier = SMSNotifier()
sms_service = NotificationService(sms_notifier)
sms_service.notify("Hello via SMS") 

这个例子演示了与之前相同的通知系统,但使用 Python 的Protocol功能而不是 ABC。让我们分析一下关键差异及其对清洁架构的影响:

  • 协议与 ABC 的比较Notifier类现在是一个Protocol类,而不是ABC类。它定义了一个结构化子类型接口,而不是要求显式继承。

  • 隐式遵从EmailNotifierSMSNotifier类并没有显式地从Notifier类继承,但它们通过实现send_notification方法来符合其接口。

  • 带类型提示的鸭式编程:这种方法结合了 Python 的鸭式编程灵活性以及静态类型检查的好处,与清洁架构强调的松耦合对齐。

  • 具体实现NotificationService类仍然依赖于抽象的Notifier协议,而不是具体实现,遵循清洁架构原则。

这种基于协议的方法提供了灵活的 Pythonic 清洁架构概念的实现,在类型安全与减少类层次结构刚性之间取得平衡。它展示了如何将清洁架构原则与 Python 的哲学相结合,促进可适应和可维护的代码。

我们强烈推荐在实现清洁架构时使用通过 ABC 或协议进行的类型提示。与简单隐式接口不进行类型提示的方法相比,这种方法提供了显著的优势:

  • 提高代码可读性

  • 增强 IDE 支持并提前错误检测

  • 更好地与清洁架构目标对齐

在本书的剩余部分,我们将主要在我们的示例中使用 ABC,因为它们在现有的 Python 代码库中应用得更广泛。然而,讨论的原则同样适用于基于协议的实现,读者可以根据个人喜好将示例调整为使用协议。

实际示例:在 Python 项目中一瞥清洁架构

为了说明我们讨论的概念,让我们检查 Clean Architecture Python 项目的结构。这种结构体现了我们讨论过的原则,并展示了它们如何转化为实际的文件组织。在这里我们将保持高层次;后面的章节将详细介绍真实世界的例子:

图 1.2:Clean Architecture Python Web 应用程序的潜在布局

图 1.2:Clean Architecture Python Web 应用程序的潜在布局

图 1.2中的这种文件结构展示了我们讨论过的 Clean Architecture 原则:

  1. SoC:每个目录代表应用程序的一个独立层,与我们在图 1.1中看到的同心圆相一致。

  2. 依赖规则:该结构强制执行我们之前讨论过的依赖规则。如果我们调查内层(entitiesuse_cases),我们将不会看到来自外部层的任何导入。

  3. 实体层entities目录包含核心业务对象,如user.py。这些是我们 Clean Architecture 图中的中心,对外层没有依赖。

  4. 用例层use_cases目录包含应用程序特定的业务规则。它依赖于实体,但独立于外部层。

  5. 接口适配层interfaces 目录包含控制器、演示者和网关。这些适配器在用例和外部机构(如 Web 框架或数据库)之间转换数据。

  6. 框架层:最外层的frameworks目录包含外部接口的实现,如数据库对象关系映射器(ORMs)或 Web 框架。

  7. 简单测试tests目录结构与应用程序结构相匹配,允许在所有级别进行全面的测试。

这种结构支持我们讨论过的 Clean Architecture 的关键好处:

  • 可维护性:对frameworks目录中外部组件的更改不会影响实体和用例中的核心业务逻辑。

  • 灵活性:我们可以轻松地替换frameworks目录中的数据库或 Web 框架,而不需要触及业务逻辑。

  • 可测试性:清晰的分离允许轻松地对核心组件进行单元测试,并对接口进行集成测试。

记得我们关于抽象的讨论吗?interfaces目录是我们实现我们讨论的 ABCs 或协议的地方。例如,user_repository.py可能定义一个抽象的UserRepository类,然后在frameworks/database/orm.py文件中具体实现。

这种结构也促进了我们之前提到的总体规划。它为新代码提供清晰的路线图,帮助开发者即使在项目增长和演变过程中也能做出一致的决策。

通过以这种方式组织我们的 Python 项目,我们正在为长期成功打下基础,创建一个不仅功能性强而且可维护、灵活且与 Clean Architecture 原则一致的代码库。

Python 特定的考虑因素和潜在陷阱

虽然 Clean Architecture 和 Python 非常兼容,但在 Python 项目中实施这些原则时,有一些重要的考虑因素需要注意。在本书中,我们将引导您缓解这些担忧,提供实用的解决方案和最佳实践。

平衡 Python 代码与架构原则

Python 的 batteries included 哲学和广泛的标准库有时会诱使开发者为了方便而绕过架构边界。然而,保持干净的架构通常涉及在标准库函数周围创建抽象,以维护 SoC。例如,在您的用例中,您可以考虑创建一个用于发送通知的抽象层,而不是直接使用 Python 的 smtplib 库。

随着我们通过本书的进展,我们将展示创建抽象的努力如何在可维护性、灵活性和可测试性方面带来回报。您将看到对 Clean Architecture 原则的初始投资带来了显著的长远利益。

Python 的导入便捷性有时会导致混乱的依赖结构,因为所有包实际上都是公开的。我们将向您展示如何保持对依赖规则的警觉,确保内层不依赖于外层。在 第二章 中,我们将探讨技术和工具,以帮助您在 Python 项目中维护干净的依赖结构。

在 Python 项目中扩展 Clean Architecture

应根据您 Python 项目的规模和复杂性来定制 Clean Architecture 原则的应用。

例如,在小项目或快速原型中,拥有一个简单、单一架构是完全可行的。然而,即使在这些情况下,以深思熟虑、模块化的方式构建也可以为未来的增长奠定基础。您可能从以下简单结构开始:

图 1.3:快速原型 Python 项目

图 1.3:快速原型 Python 项目

在这个小型项目中,您仍然可以通过以下方式应用 Clean Architecture 原则:

  • 将业务逻辑(实体和用例)与 views.py 中的展示逻辑分开,放在 models.py

  • 使用 依赖注入DI)使组件更模块化和可测试

  • 在模块之间定义清晰的接口

随着您的项目增长,您可以逐步向更全面的 Clean Architecture 结构发展。这种演变可能包括以下内容:

  1. 将核心业务逻辑(实体和用例)分离到它们自己的模块中

  2. 引入接口以抽象化框架特定的代码

  3. 将测试组织与架构层对齐

本书采用动手实践的方法,从基本应用程序和清洁架构原则的实用应用开始。随着我们的进展,示例应用程序的复杂性将增加,展示如何随着代码库的增长而演进清洁架构方法。

清洁架构是一个连续体,而不是一个二元选择。我们将探讨的模式代表了一个全面的实现,旨在展示清洁架构的全部功能,但在实践中,你可能会选择只实现那些为你特定环境提供明确价值的模式。一个小型 API 可能从清洁控制器模式中受益,而不需要完整的展示器抽象,而数据处理脚本可能采用领域实体,而完全跳过接口适配器。你将学会如何明智地应用这些原则,避免在小项目中过度设计,同时在大型系统中充分利用清洁架构的全部力量。关键是理解每个模式提供的内容,以便你可以就哪些架构边界对你的项目最重要做出明智的决定。

适当利用 Python 的动态特性

虽然 Python 的动态特性很强大,但如果使用不当也可能导致问题。第三章致力于 Python 动态特性的各个方面,包括鸭子类型、类型提示的使用以及像协议这样的新特性。到本章结束时,你将有一个坚实的基础,了解如何最好地利用这些语言特性来支持清洁架构方法,平衡 Python 的灵活性与架构的严谨性。

测试注意事项

这本书,就像清洁架构本身一样,强烈推崇测试的使用。测试本质上是你应用程序代码的一等客户,使用代码库并对结果进行断言。适用于你的主要代码库的相同架构考虑因素也适用于你的 Python 测试。

我们将指导你编写尊重架构边界的测试。你将学会识别你的测试何时表明你的架构中存在潜在问题,例如当它们需要过多的设置或模拟时。在每个章节的代码示例的测试用例中,最终在第第八章中,我们将深入探讨这些概念,展示如何使用测试不仅用于验证,而且作为维护和改进你的架构的工具。

通过意识到这些考虑因素和潜在陷阱,并遵循本书中提供的指导,你可以创建既清洁又实用的 Python 系统,利用清洁架构和 Python 的优势。记住,关键是深思熟虑地应用这些原则,始终着眼于创建可维护、可测试和灵活的 Python 代码。

摘要

在本章中,我们从高层次介绍了清晰架构及其与 Python 开发的关联。通过探讨软件架构的演变,从瀑布到敏捷,突出了在管理复杂性、适应变化和维护长期生产力方面存在的持续挑战。

我们介绍了清晰架构的核心原则:

  • 关注点分离(SoC)

  • 外部细节的独立性

  • 可测试性和可维护性

我们考察了清晰架构的一般结构,从核心实体和用例到外层的接口适配器、框架和驱动器,强调了这种结构如何促进可维护性和灵活性。我们讨论了清晰架构的好处,包括提高适应性、增强可测试性和长期开发敏捷性,以及它是如何与现代开发实践如敏捷和 DevOps 相辅相成的。

此外,我们探讨了清晰架构与 Python 之间的自然契合度,讨论了如何利用 Python 的特性有效地实现清晰架构。我们还强调了 Python 特定的考虑因素和潜在陷阱,强调需要在 Python 代码的优雅性与架构原则之间取得平衡,并适应不同规模的项目。

在本章中,我们介绍了清晰架构的主要目标,并探讨了它与 Python 开发的自然契合度。我们看到了如何利用 Python 的特性,如 ABCs 和协议,来实现清晰架构原则,为创建可维护和灵活的软件系统奠定基础。

在下一章中,我们将在此基础上深入探讨 SOLID 原则。这些原则是清晰架构的基石,将通过实际的 Python 示例进行深入探讨,展示它们如何有助于构建健壮和可扩展的应用程序设计。

进一步阅读

要了解更多关于本章涵盖的主题,请查看以下资源:

  • 《清晰架构:软件结构和设计的工匠指南》由罗伯特·C·马丁所著。这本书全面地审视了清晰架构的起源。

  • 《领域驱动设计:软件核心的复杂性处理》由埃里克·埃文斯所著。虽然这本书并非专门针对清晰架构,但它提供了关于围绕业务领域设计软件的宝贵见解。

  • 《敏捷软件开发:原则、模式和实践》由罗伯特·C·马丁所著。这本书涵盖了在敏捷开发背景下支撑清晰架构的许多原则。

  • 《程序员修炼之道:从小工到专家》由安德鲁·亨特和戴维·托马斯所著。这本经典书籍提供了与清晰架构原则相吻合的软件设计和开发实用建议。

第二章:SOLID 基础:构建稳健的 Python 应用程序

在上一章中,我们探讨了清洁架构,这是一种构建可维护、灵活和可扩展的 Python 应用程序的强大方法。我们学习了它是如何将关注点分离到不同的层中,从核心业务逻辑到外部接口,促进独立性和可测试性。现在,我们将更深入地探讨构成清洁架构基础的一系列原则。这些被称为SOLID 原则

缩写 SOLID(en.wikipedia.org/wiki/SOLID)代表了面向对象编程和设计的五个关键原则。当正确应用时,这些原则有助于开发者创建更易于理解、灵活和可维护的软件结构。在本章中,我们将深入探讨这些原则的每个方面,重点关注它们在 Python 中的应用以及它们如何支持我们之前讨论的清洁架构目标。

到本章结束时,你将清楚地理解以下方面:

  • 单一职责原则SRP)及其在创建专注、可维护的代码中的作用

  • 开闭原则OCP)及其如何使构建可扩展的系统成为可能

  • 接口隔离原则ISP)及其在 Python 的鸭子类型中的应用

  • 环境

  • 里氏替换原则LSP)及其在设计稳健抽象中的重要性

  • 依赖倒置原则DIP)及其在支持清洁架构依赖规则中的关键作用

我们将通过 Python 开发的视角来审视每个原则,提供实际示例和最佳实践。你将学习如何应用这些原则,以便能够编写更干净、更易于维护的 Python 代码,为在你的项目中实施清洁架构打下坚实的基础。

技术要求

本章和本书其余部分提供的代码示例均使用 Python 3.13 进行测试。为了简洁,章节中的代码示例可能只部分实现。所有示例的完整版本可以在本书配套的 GitHub 仓库github.com/PacktPublishing/Clean-Architecture-with-Python中找到。

关于我们将如何介绍 SOLID 原则的顺序的说明

虽然 SOLID 原则传统上按照其首字母缩写的顺序介绍,但本书采用了一种更战略性的顺序。我们将从 SRP 开始,然后是 OCP,接着是 ISP,然后是 LSP,最后是 DIP。每个部分的开始将详细说明其主题与之前主题之间的关系。这种顺序从编写干净的、模块化的代码到设计灵活、可维护的系统,直接支持清洁架构的目标。

精心打造专注、可维护的代码:单一职责的力量

在软件设计的层次结构中,我们顶部有高级架构,然后是组件、模块、类,最后是函数。SOLID 原则主要在模块级别起作用,为创建结构良好、易于维护的代码提供指导。这些模块级实践构成了良好架构的基础,包括 Clean Architecture。通过应用 SOLID 原则,我们可以创建松散耦合、高度内聚的组件,这些组件更容易进行测试、修改和扩展。这些品质是 Clean Architecture 的基本属性。

理解单一职责

单一职责原则(SRP)指出,每个软件模块应该只有一个且仅有一个变更的理由(blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html)。

初看,单一职责的概念可能看起来很简单。然而,在实践中,定义和实现它可能具有挑战性。让我们考虑一个简单的例子来说明这个原则。

让我们考虑一个旨在在社交媒体应用程序中作为实体的User类。回想一下,Clean Architecture 中的实体应该代表具有最一般规则的核心业务对象:

class User:
    def __init__(self, user_id: str, username: str, email: str):
        self.user_id = user_id
        self.username = username
        self.email = email
        self.posts = []
    def create_post(self, content: str) -> dict:
        post = {
            "id": len(self.posts) + 1,
            "content": content,
            "likes": 0         }
        self.posts.append(post)
        return post
    def get_timeline(self) -> list:
        # Fetch and return the user's timeline
        # This might involve complex logic to fetch and
        # sort posts from followed users
        pass
    def update_profile(self, new_username: str = None,
                       new_email: str = None):
        if new_username:
            self.username = new_username
        if new_email:
            self.email = new_email 

初始时,这个User类可能看起来合理。它封装了用户数据,并为社交媒体应用程序中的常见用户操作提供了方法。然而,尽管旨在成为一个实体,但它与在第一章中引入的 Clean Architecture 概念有显著偏差。记住,实体应该代表封装最一般和最高级规则的核心业务对象,独立于特定的应用行为或外部关注点。我们当前的User类通过承担过多的职责违反了这些原则:

  • 用户数据管理(处理user_idusernameemail

  • 创建后和管理工作

  • 时间线生成

  • 个人资料更新

这种结构将核心用户数据与应用特定的行为相结合,违反了 SRP 和实体概念。随着产品的增长,这个类可能会成为瓶颈,导致开发挑战、合并冲突和意外的副作用。

在识别和分离职责时,请考虑以下建议:

  • 寻找对类数据的不同子集进行操作的多个方法组

  • 考虑不同类型的变更或需求会影响哪些方面

  • 使用变更理由启发式方法:如果你能想到一个类变更的多个理由,考虑将其拆分

让我们重构我们的User类,使其遵循 SRP 和实体概念:

class User:
    def __init__(self, user_id: str, username: str, email: str):
        self.user_id = user_id
        self.username = username
        self.email = email
class PostManager:
    def create_post(self, user: User, content: str):
        post = {
            "id": self.generate_post_id(),
            "user_id": user.user_id,
            "content": content,
            "likes": 0
        }
        # Logic to save the post
        return post
    def generate_post_id(self):
        # Logic to generate a unique post ID
        pass
class TimelineService:
    def get_timeline(self, user: User) -> list:
        # Fetch and return the user's timeline
        # This might involve complex logic to fetch and
        # sort posts from followed users
        pass
class ProfileManager:
    def update_profile(self, user: User, new_username: str = None,
                       new_email: str = None):
        if new_username:
            user.username = new_username
        if new_email:
            user.email = new_email
        # Additional logic for profile updates,
        # like triggering email verification 

这个重构版本不仅更紧密地遵循 SRP,而且与 Clean Architecture 中的实体概念相一致。让我们分析这些更改及其影响:

  • User: 现在简化到其本质,用户类真正体现了一个实体。它封装了最通用和最高级的规则,独立于特定应用行为。它只有一个职责:管理核心用户数据。

  • PostManager: 这承担了创建和管理帖子的专注职责。

  • TimelineService: 这处理时间线生成逻辑的独立部分。

  • ProfileManager: 这管理个人资料更新,进一步减少 User 类的职责。

这些类现在都有一个清晰、专注的角色,遵循 SRP 并促进关注点的分离。这次重构带来了几个好处:

  • 提高可维护性和可测试性:每个类都有一个单一、明确的目的,这使得它更容易理解、修改和独立测试

  • 更大的灵活性和减少耦合:我们可以扩展或修改系统的一个方面,而不会影响其他方面,这使得我们的代码库更能适应变化

这种模块化和灵活的设计与清洁架构原则相吻合,为我们的系统不同组件之间创造了清晰的边界。虽然对于小型应用程序来说可能有些过度,但它为更可维护和可扩展的系统奠定了基础。

记住,我们中的大多数人都在开发我们希望成功的应用程序。随着成功而来的是功能请求、转型和扩展挑战。从一开始就通过明智地应用 SRP 为这种增长做准备,可以在以后节省大量的重构工作,创建一个随着系统发展既灵活又可理解的架构。

SRP 和测试

具有单一职责的类通常更容易测试,因为它们有更少的依赖和边缘情况。这促进了可测试系统的创建,这是清洁架构的一个关键原则。例如,测试 PostManager 变得简单:

import unittest
from post_manager import PostManager
from user import User
class TestPostManager(unittest.TestCase):
    def test_create_post(self):
        user = User("123", "testuser", "test@example.com")
        post_manager = PostManager()
        post = post_manager.create_post(user, "Hello, world!")

        self.assertEqual(post["user_id"], "123")
        self.assertEqual(post["content"], "Hello, world!")
        self.assertEqual(post["likes"], 0)
        self.assertIn("id", post) 

这个测试案例展示了 SRP 为单元测试带来的清晰性。在这里,具有单一职责的 PostManager 可以在隔离状态下轻松测试,无需复杂的设置或模拟。我们可以直接验证创建帖子后的所有基本方面。这种测试的简单性是 SRP 的直接好处,并与清洁架构原则相符。随着系统的日益复杂,独立测试单个职责的能力变得至关重要。它使我们能够保持高代码质量,早期发现问题,并随着系统的发展,与测试套件一起进化,确保每个专注组件的正确性,而无需依赖复杂的集成测试。

平衡 SRP

虽然 SRP 是一个强大的原则,但重要的是不要将其推向极端。过度应用 SRP 可能导致大量微小类和函数的爆炸式增长,这可能会使整个系统更难以理解和导航。错误地将 SRP 理解为“应该只做一件事的类或模块”可能会导致创建过多的微小类。这个原则是关于单一变更原因,而不是严格关于单一执行动作。

关键是要找到一个平衡点,其中每个代码单元(无论是类、函数还是模块)都有一个清晰、一致的目的,而不会变得过于细分以至于整体结构变得碎片化。记住,SRP 的目标是使你的代码更易于维护和理解。如果拆分一个类或函数使整个系统更难以理解,可能不是正确的做法。运用你的判断力,并始终考虑你特定应用的上下文。

总结来说,SRP 为创建可维护和灵活的代码提供了一个强大的基础。通过确保每个模块或类都有一个单一、明确的目的,我们可以为易于理解、修改和扩展的系统奠定基础。正如我们所看到的,关键是找到适合你特定上下文的正确平衡,以避免过度复杂的类或微小、碎片化组件的爆炸式增长。

这种平衡应用的原则贯穿于整洁架构的始终。我们探索的每个模式和抽象层都提供了特定的好处,如提高可测试性、易于维护和增强灵活性,但也增加了复杂性。随着你阅读本书的进展,请通过你具体的需求来审视每个模式。一个构建最小可行产品(MVP)的初创公司可能会推迟一些抽象,直到增长需求出现,而一个企业级系统可能从第一天起就受益于完整的架构方法。以单一职责原则(SRP)作为我们的起点,我们现在准备探索开放封闭原则(OCP)是如何在这个基础上构建的。

构建可扩展系统:在 Python 中拥抱开放封闭设计

在探索了 SRP 及其在创建专注、可维护的类中的作用后,我们将把注意力转向稳健软件设计的另一个关键方面:可扩展性。开放封闭原则(OCP)由伯特兰·迈耶于 1988 年提出(en.wikipedia.org/wiki/Open%E2%80%93closed_principle),它建立在 SRP 的基础上。它指导我们创建所谓的“对扩展开放但对修改封闭”的系统。这意味着我们应该能够添加新功能而不改变现有代码,本质上是通过新代码扩展我们的系统行为,而不是修改现有的内容。

OCP 是我们 SOLID 原则工具箱中的一个强大工具,因为它与 SRP 协同工作,创建模块化、灵活的代码。它解决了软件开发中一个常见的挑战:如何在不改变现有、经过测试的代码的情况下添加新功能或行为。通过遵循 OCP,我们可以设计我们的 Python 类和模块,以便它们可以轻松扩展,降低在添加新功能时引入错误的风险。

在 Clean Architecture 的背景下,OCP 在创建能够随着时间的推移适应变化的系统中起着至关重要的作用。它支持创建稳定的核心业务逻辑,在添加新功能或适应新技术时保持不变。当我们探索 Python 中的 OCP 时,我们将看到它如何有助于构建符合 Clean Architecture 原则的易于维护、可扩展的应用程序。

让我们通过一个不同形状面积计算器的实际例子来探讨这个原则。考虑以下初始实现:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
class Circle:
    def __init__(self, radius):
        self.radius = radius
class AreaCalculator:
    def calculate_area(self, shape):
        if isinstance(shape, Rectangle):
            return shape.width * shape.height
        elif isinstance(shape, Circle):
            return 3.14 * shape.radius ** 2
        else:
            raise ValueError("Unsupported shape")
# Usage
rectangle = Rectangle(5, 4)
circle = Circle(3)
calculator = AreaCalculator()
print(f"Rectangle area: {calculator.calculate_area(rectangle)}")
print(f"Circle area: {calculator.calculate_area(circle)}") 

这里,我们有一个简单的AreaCalculator类,它可以计算矩形和圆的面积。然而,这种设计违反了 OCP 原则。如果我们想添加对新形状的支持,例如三角形,我们就需要修改AreaCalculator类的calculate_area方法。这种修改可能会在现有正常工作的代码中引入错误。

为了遵循 OCP 原则,我们需要重构我们的代码,以便在不修改现有的AreaCalculator类的情况下添加新的形状。以下是我们可以如何重构这段代码以实现 OCP 原则:

from abc import ABC, abstractmethod
import math
class Shape(ABC):
    @abstractmethod
    def area(self):
        pass
class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height
class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2
class AreaCalculator:
    def calculate_area(self, shape: Shape):
        return shape.area()
# Usage
rectangle = Rectangle(5, 4)
circle = Circle(3)
calculator = AreaCalculator()
print(f"Rectangle area: {calculator.calculate_area(rectangle)}")
print(f"Circle area: {calculator.calculate_area(circle)}")
# Adding a new shape without modifying AreaCalculator
class Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height

    def area(self):
        return 0.5 * self.base * self.height
triangle = Triangle(6, 4)
print(f"Triangle area: {calculator.calculate_area(triangle)}") 

在这个重构版本中,我们为了遵循 OCP 的概念进行了几个关键更改:

  • 我们引入了一个具有area方法的抽象Shape类。这作为所有形状必须实现的接口。

  • 每个具体形状(RectangleCircle和现在的Triangle)都从Shape继承,并实现了自己的area方法。

  • AreaCalculator类现在依赖于抽象的Shape类,而不是具体的实现。它对任何接收到的形状对象调用area方法,而无需知道具体的形状类型。

  • 我们现在可以添加新的形状(如Triangle),而不需要修改AreaCalculator类。系统对扩展是开放的,但对修改是封闭的。

这种重构设计展示了 OCP 的实际应用,同时也保持了遵循 SRP。让我们检查关键方面:

  • 对扩展开放:我们可以添加新的形状(如Triangle),而不需要修改现有代码。每个形状类都有单一的责任,即定义其属性并计算其自身的面积。

  • 对修改封闭:在添加新形状时,核心的AreaCalculator类保持不变,这展示了对其修改的封闭性。

  • 多态性:通过使用抽象的Shape类,我们可以统一处理不同的形状对象。这使得AreaCalculator可以通过一个公共接口与任何形状一起工作,而无需了解具体的实现。

这种设计完美符合 Clean Architecture 的目标:

  • 可扩展性:在不干扰现有已测试代码的情况下,可以满足新的需求(如添加形状)

  • 核心逻辑的隔离:每个形状的面积计算受到外部变化的保护

  • 可测试性:职责的清晰分离促进了直接的单元测试

通过结合 OCP(开闭原则)和 SRP,我们为构建更大、更复杂的系统奠定了基础,这些系统可以在不变得脆弱的情况下进行演化。这个例子虽然很小,但展示了如何在 Python 中有效地应用 Clean Architecture 原则来创建组织良好、可维护且能够适应变化需求的系统。

ISP:根据客户端定制接口

随着我们深入探讨 SOLID 原则,我们已经看到 SRP 如何促进专注的类和 OCP 如何实现可扩展性。现在,我们将把注意力转向这些类向世界暴露的接口。接口分离原则(ISP)(en.wikipedia.org/wiki/Interface_segregation_principle)指导我们创建精简、目的特定的接口,以满足其客户端的精确需求。这个原则对于开发灵活、模块化的 Python 代码至关重要,这些代码易于理解且易于维护。

ISP 不仅建立在 SRP(单一职责原则)引入的单一职责概念之上,而且在接口级别上应用它。它主张设计专注于特定任务的接口,而不是试图包含过多职责的接口。这种方法导致更灵活、可维护的系统,因为客户端只依赖于它们实际使用的那些方法。

为了说明 ISP 的重要性以及它与过度承担职责的类之间的关系,让我们考虑一个多媒体播放器系统的例子:

from abc import ABC, abstractmethod
class MultimediaPlayer(ABC):
    @abstractmethod
    def play_media(self, file: str) -> None:
        pass
    @abstractmethod
    def stop_media(self) -> None:
        pass
    @abstractmethod
    def display_lyrics(self, file: str) -> None:
        pass
    @abstractmethod
    def apply_video_filter(self, filter: str) -> None:
        pass
class MusicPlayer(MultimediaPlayer):
    def play_media(self, file: str) -> None:
        # Implementation for playing music
        print(f"Playing music: {file}")
    def stop_media(self) -> None:
        # Implementation for stopping music
        print("Stopping music")
    def display_lyrics(self, file: str) -> None:
        # Implementation for displaying lyrics
        print(f"Displaying lyrics for: {file}")
    def apply_video_filter(self, filter: str) -> None:
        # This method doesn't make sense for a MusicPlayer
        raise NotImplementedError(
            "MusicPlayer does not support video filters")
class VideoPlayer(MultimediaPlayer):
    # Implementation for video player
    ... 

这种设计违反了 ISP,试图做太多(方法过多)。让我们检查由此产生的问题:

  • 不必要的方法和混乱的 API:在这里,MusicPlayer被迫实现apply_video_filter,这对于一个仅播放音频的播放器来说是没有意义的。这导致尴尬的实现和潜在的运行时错误。此外,MusicPlayer类的用户在接口中看到apply_video_filter这样的方法,这可能导致对类实际能做什么的困惑。这种缺乏清晰性使得类更难正确使用,并增加了误用的风险。

  • 缺乏模块化:接口不允许轻松创建专门的播放器。例如,我们无法轻松创建一个仅显示歌词的显示,而不需要实现媒体播放方法。这种僵化的结构限制了可扩展性和重用性,使得适应新的需求或用例变得困难。

  • 增加维护负担:如果我们以后想向MultimediaPlayer接口添加更多视频特定功能,每次都需要更新所有实现类,即使这些功能对其中一些类并不相关。这使得系统更难进化,并增加了在更改时引入错误的风险。

这些问题展示了违反 ISP 如何导致代码不灵活、混乱且难以维护。通过解决这些问题,我们可以创建一个更模块化、灵活且易于使用的架构。

让我们重构这个设计,使其符合 ISP:

from abc import ABC, abstractmethod
class MediaPlayable(ABC):
    @abstractmethod
    def play_media(self, file: str) -> None:
        pass
    @abstractmethod
    def stop_media(self) -> None:
        pass
class LyricsDisplayable(ABC):
    @abstractmethod
    def display_lyrics(self, file: str) -> None:
        pass
class VideoFilterable(ABC):
    @abstractmethod
    def apply_video_filter(self, filter: str) -> None:
        pass
class MusicPlayer(MediaPlayable, LyricsDisplayable):
    def play_media(self, file: str) -> None:
        print(f"Playing music: {file}")
    def stop_media(self) -> None:
        print("Stopping music")
    def display_lyrics(self, file: str) -> None:
        print(f"Displaying lyrics for: {file}")
class VideoPlayer(MediaPlayable, VideoFilterable):
    def play_media(self, file: str) -> None:
        print(f"Playing video: {file}")
    def stop_media(self) -> None:
        print("Stopping video")
    def apply_video_filter(self, filter: str) -> None:
        print(f"Applying video filter: {filter}")
class BasicAudioPlayer(MediaPlayable):
    def play_media(self, file: str) -> None:
        print(f"Playing audio: {file}")
    def stop_media(self) -> None:
        print("Stopping audio") 

在这个重构设计中,我们利用 Python 的抽象基类ABCs)创建了一组专注的接口。这种方法允许我们为不同的功能定义清晰的合同,而无需强迫类实现它们不需要的方法。通过将原始的单个接口分解成更小、更具体的接口,我们创建了一个符合 ISP 的灵活结构。让我们来检查这个重构设计的关键组件:

  • MediaPlayable:这个接口专注于播放和停止媒体,这是所有媒体播放器共享的核心功能

  • LyricsDisplayable:通过将歌词显示分离到其自己的接口中,我们确保了不支持歌词的类(如VideoPlayer)不会被强迫实现不必要的方法

  • VideoFilterable:这个接口隔离了视频特定功能,防止仅支持音频的播放器实现不相关的功能

现在具体的类(MusicPlayerVideoPlayerBasicAudioPlayer)仅实现与其功能相关的接口。这种设计使得创建和使用不同类型的媒体播放器变得简单。例如,MusicPlayer可以播放媒体并显示歌词,而BasicAudioPlayer只需实现媒体播放功能。这种灵活性使得通过组合相关接口创建新类型的播放器变得简单,无需承担实现不必要方法的负担。

让我们总结 ISP 的整体好处:

  • 降低耦合:类只依赖于它们实际使用的功能

  • 提高可维护性:对某一方面的更改(例如,视频过滤)不会影响无关的类

  • 增强灵活性:我们可以轻松地通过组合相关接口创建新类型的播放器

  • 更好的可测试性:我们可以通过将测试集中在特定功能上来更容易地模拟接口

这种由 ISP 驱动的设计通过创建与特定用例一致的清晰、专注的接口来支持 Clean Architecture。回想一下第一章,在 Clean Architecture 中,用例代表特定于应用程序的业务规则,描述系统如何以及何时使用实体来实现其目标。ISP 通过允许我们为每个用例定义精确的接口来促进这一点。例如,LyricsDisplayable 接口直接支持显示歌词用例,而不会给其他播放器类型带来负担。这种方法允许构建更模块化的系统,其中组件可以独立发展,使得实现新的用例或修改现有用例而不会影响系统的不相关部分变得更加容易。因此,我们的应用程序可以更容易地适应变化的需求,同时保持其核心业务逻辑的完整性。

总结来说,ISP 通过鼓励设计专注、具体的接口,引导我们创建更灵活、可维护的系统。通过将 ISP 与 SRP 和 OCP 结合使用,我们可以创建易于理解、测试和扩展的 Python 代码。ISP 帮助我们避免类试图做太多的陷阱,正如 OCP 帮助我们避免类试图成为太多事物的陷阱。这些原则共同支持了 Clean Architecture 的目标,帮助我们创建能够适应变化需求同时保持清晰、模块化结构的系统。

从僵化到灵活:在 Python 中重新思考继承和接口

在我们探讨了单一职责、开闭和接口分离原则之后,我们为创建模块化、可扩展和专注的代码奠定了基础。这些原则指导我们构建类和接口的结构,使其更易于维护和适应。现在,我们将注意力转向 Liskov 替换原则(LSP),它补充并加强了我们所讨论的原则。

虽然 SRP 引导我们创建专注、内聚的类,OCP 允许我们在不修改现有组件的情况下扩展我们的代码,而 ISP 促进创建特定、客户定制的接口。LSP 确保我们的抽象是良好形成的,并且我们的组件确实是可互换的。这一原则对于通过确保我们的继承层次结构具有可预测的行为来创建健壮、灵活的系统至关重要。

在 Clean Architecture 的背景下,LSP 在支持 OCP 承诺的灵活性和 SRP 及 ISP 鼓励的专注设计方面发挥着至关重要的作用。当我们深入研究 LSP 时,我们将看到它是如何与其他 SOLID 原则协同工作,以创建一个不仅模块化,而且可靠且易于使用和扩展的系统。

理解 LSP

Liskov 替换原则(LSP)是由 Barbara Liskov 在 1987 年提出的(en.wikipedia.org/wiki/Liskov_substitution_principle),为创建行为可预测且直观的继承层次结构提供了指导。在其核心,LSP 是关于在整个继承层次结构中维护基类合约的完整性。

这里是 LSP 告诉我们的内容:

  • 基类定义了一个用户可以依赖的合约。这个合约由一系列行为和属性组成。

  • 子类不应更改或破坏这个合约。它们必须遵守基类做出的承诺。

  • 子类可以扩展或细化合约,使其更具体,但它们不能减少或违反原始合约。

换句话说,如果我们有一个基类,那么它的任何子类都应该能够代替这个基类,而不会破坏程序或违反基类设定的期望。

这个原则有几个关键原因:

  • 可预测性:当遵循 LSP 时,基类的用户可以信任所有派生类将以与基类一致的方式行为。这使得系统更具可预测性,更容易推理。

  • 灵活性:LSP 允许我们有效地使用多态。我们可以编写与基类一起工作的代码,并相信它将正确地与任何子类一起工作。

  • 可扩展性:通过确保子类遵守基类合约,我们创建了一个更容易扩展的系统。可以添加新的子类,而不用担心会破坏依赖于基类的现有代码。

然而,遵循 LSP(里氏替换原则)并不总是直截了当的。它需要我们仔细思考如何建模我们的对象及其关系。让我们通过一个例子来看看违反 LSP 可能导致的问题,以及我们如何重构我们的代码来遵循这一原则。

刻板层次结构的陷阱

考虑一个用于管理不同车辆类型及其燃料消耗的系统:

class Vehicle:
    def __init__(self, fuel_capacity: float):
        self._fuel_capacity = fuel_capacity
        self._fuel_level = fuel_capacity
    def fuel_level(self) -> float:
        return self._fuel_level
    def consume_fuel(self, distance: float) -> None:
        # Assume 10 km per liter for simplicity:
        fuel_consumed = distance / 10
        if self._fuel_level - fuel_consumed < 0:
            raise ValueError("Not enough fuel to cover the distance")
        self._fuel_level -= fuel_consumed 

这个Vehicle类代表了一个典型的基于燃料的车辆。现在,让我们引入一个继承自VehicleElectricCar类:

class ElectricCar(Vehicle):
    def __init__(self, battery_capacity: float):
        super().__init__(battery_capacity)
    def consume_fuel(self, distance: float) -> None:
        # Assume 5 km per kWh for simplicity:
        energy_consumed = distance / 5
        if self._fuel_level - energy_consumed < 0:
            raise ValueError("Not enough charge to cover the distance")
        self._fuel_level -= energy_consumed 

初看之下,这可能会被认为是一个合理的做法。然而,它导致了一些问题:

  • 它违反了 LSP,因为ElectricCar不能替代Vehicle而不引起不正确的行为

  • ElectricCar类改变了燃料消耗的含义,违反了Vehicle类建立的合约

  • 它创建了一个脆弱的设计,其中与Vehicle一起工作的函数可能会在ElectricCar上默默地产生不正确的结果

为了说明这一点,考虑以下函数:

def drive_vehicle(vehicle: Vehicle, distance: float) -> None:
    initial_fuel = vehicle.fuel_level()
    vehicle.consume_fuel(distance)
    fuel_consumed = initial_fuel - vehicle.fuel_level()
    print(f"Fuel consumed: {fuel_consumed:.2f} liters")
# Usage
car = Vehicle(50)  # 50 liter tank
drive_vehicle(car, 100)  # Works fine
electric_car = ElectricCar(50)  # 50 kWh battery
drive_vehicle(electric_car, 100) # This prints incorrect fuel consumption 

这个drive_vehicle函数对Vehicle类工作正常,但对ElectricCar类产生误导性的输出。这些问题源于将ElectricCar强制与Vehicle建立继承关系,尽管电动汽车的能量消耗与基于燃料的车辆不同。这是在建模“是”关系时过于字面化的一种常见陷阱。

采用 LSP(里氏替换原则)的灵活性

让我们重构它,使其符合 LSP(里氏替换原则)。我们首先定义一个电源的抽象基类:

from abc import ABC, abstractmethod
class PowerSource(ABC):
    def __init__(self, capacity: float):
        self._capacity = capacity
        self._level = capacity
    def level(self) -> float:
        return self._level
    @abstractmethod
    def consume(self, distance: float) -> float:
        pass 

现在,我们可以为不同类型的电源创建具体的实现:

class FuelTank(PowerSource):
    def consume(self, distance: float) -> float:
        # Assume 10 km per liter for simplicity:
        fuel_consumed = distance / 10
        if self._level - fuel_consumed < 0:
            raise ValueError("Not enough fuel to cover the distance")
        self._level -= fuel_consumed
        return fuel_consumed
class Battery(PowerSource):
    def consume(self, distance: float) -> float:
        # Assume 5 km per kWh for simplicity:
        energy_consumed = distance / 5
        if self._level – energy_consumed < 0:
            raise ValueError("Not enough charge to cover the distance")
        self._level -= energy_consumed
        return energy_consumed 

定义了这些电源后,我们可以创建一个更灵活的Vehicle类:

class Vehicle:
    def __init__(self, power_source: PowerSource):
        self._power_source = power_source
    def power_level(self) -> float:
        return self._power_source.level()
    def drive(self, distance: float) -> float:
        return self._power_source.consume(distance) 

最后,我们可以更新我们的drive_vehicle函数,使其与这个新设计兼容:

def drive_vehicle(vehicle: Vehicle, distance: float) -> None:
    try:
        energy_consumed = vehicle.drive(distance)
        print(f"Energy consumed: {energy_consumed:.2f} units")
    except ValueError as e:
        print(f"Unable to complete journey: {e}")
# Usage
fuel_car = Vehicle(FuelTank(50))  # 50 liter tank
drive_vehicle(fuel_car, 100)  # Prints: Energy consumed: 10.00 units
electric_car = Vehicle(Battery(50))  # 50 kWh battery
drive_vehicle(electric_car, 100)  # Prints: Energy consumed: 20.00 units 

这个重构的设计展示了 LSP(里氏替换原则)的实际应用。关键变化是引入了抽象和关注点的分离。我们将电源的概念与车辆本身解耦,允许不同类型的电源可以互换使用。这种抽象是通过PowerSource基类实现的,它为所有类型的能源源定义了一个通用接口。

让我们分解这个新设计的核心组件:

  • PowerSource”抽象基类:这定义了一个所有电源都必须满足的levelconsume方法的合同。它为不同类型的能源源建立了通用接口。

  • 具体实现(FuelTankBattery:这些类从PowerSource继承,并提供了consume方法的具体实现。关键的是,它们维护了由PowerSource定义的行为合同。

  • Vehicle”类:这个类依赖于抽象的PowerSource基类,而不是具体的实现。这种对 LSP(里氏替换原则)的遵守允许任何PowerSource的子类可以互换使用,而不会影响Vehicle的行为。

  • drive_vehicle”函数:这个函数展示了 LSP(里氏替换原则)如何实现多态。它可以与任何Vehicle类一起工作,无论其具体的电源是什么,而无需修改。

LSP(里氏替换原则)对这个设计的影响是多方面的。它确保了行为一致性,允许所有电源都能被Vehicle类统一处理。这种多态灵活性使得像drive_vehicle这样的函数可以与任何车辆类型一起工作,而无需了解具体的实现细节。该设计提高了可扩展性,因为可以通过实现PowerSource接口来添加新的电源(如氢燃料电池),而无需更改现有代码。它还通过允许我们创建用于测试Vehicle行为的模拟电源来增强可测试性。

通过遵循 LSP(里氏替换原则),我们创建了一个灵活的系统,其中核心业务逻辑免受特定电源实现变化的影响。这种分离是 Clean Architecture 的关键方面,它促进了长期稳定性,同时使扩展变得容易。LSP 与其他 SOLID 原则和谐共存:它通过确保接口具有明确的目的而建立在 SRP(单一职责原则)之上,通过允许在不修改的情况下进行扩展而支持 OCP(开闭原则),并通过促进专注的、可替换的接口来补充 ISP(接口隔离原则)。

这种一致性增强了模块化,这是 Clean Architecture 的基石。像 FuelTankBattery 这样的组件可以在不影响系统其他部分的情况下进行交换,使我们的应用程序能够以最小的干扰进行演变。清晰的接口,如 PowerSource,使系统更容易理解和导航,无论开发者是新加入项目还是几个月后返回,都可作为指南。

总之,LSP 指导我们创建既灵活又可靠的层次结构。通过确保派生类可以真正替换其基类,我们构建了更健壮、可扩展的 Python 应用程序,与 Clean Architecture 的目标保持一致。当我们继续探索 DIP 时,请记住 LSP 如何与其他 SOLID 原则一起形成一个强大的工具包,用于创建持久的应用程序。

为了灵活性而解耦:在 Python 中反转依赖关系

在探索 LSP 及其在创建健壮抽象中的作用后,我们看到了它如何有助于我们 Python 代码的灵活性和可维护性。现在让我们将注意力转向 SOLID 谜题的最后一部分:依赖倒置原则(DIP)。

DIP(依赖倒置原则)作为 SOLID 原则的基石,将我们之前原则中探讨的概念联系起来并加强。它为我们系统不同组件之间的关系提供了一个强大的结构化机制,进一步增强了我们在通过 SOLID 的旅程中构建的灵活性和可维护性。

虽然 LSP 确保我们的抽象结构良好且可替换,但 DIP 关注这些抽象应该如何相互关联。它指导我们创建一个结构,其中高级模块不依赖于低级模块,但两者都依赖于抽象。这种传统依赖结构的反转对于在 Python 中实现 Clean Architecture 至关重要,因为它允许我们创建真正解耦且能够适应变化的系统。

当我们深入研究 DIP(依赖倒置原则)时,我们将看到它如何提供一种实现依赖规则的实际方法,这是我们在 第一章 中介绍的 Clean Architecture 的基石。回忆一下,依赖规则指出源代码依赖应仅指向内部,内部圈包含高级策略,外部圈包含实现细节。DIP 提供了一种具体策略来遵守这一规则,使我们能够构建代码结构,使高级模块独立于低级模块。让我们探讨如何反转我们的依赖关系,以创建更灵活、可维护的 Python 系统,真正体现 Clean Architecture 的原则并尊重依赖规则。

理解 DIP

在我们深入 DIP 的复杂性之前,让我们明确在软件设计上下文中我们所说的 依赖于 是什么意思。考虑这个简单的代码示例:

class A:
    def __init__(self):
        self.b = B()
class B:
    def __init__(self):
        pass 

在这种情况下,我们说 A 依赖于 B。这种依赖关系体现在 A 知道 B。这在创建 B 实例的行中很明显:self.b = B()。然而,BA 一无所知。我们通常用从 A 指向 B 的箭头来表示这种依赖关系,如图 图 2.1 所示:

图 2.1:A 依赖于 B

图 2.1:A 依赖于 B

这个简单的例子为理解 DIP 旨在解决的问题奠定了基础。在许多软件系统中,高级模块(包含核心业务逻辑)通常依赖于低级模块(处理特定细节或实现)。这可能导致难以修改和维护的不灵活设计。

为了说明这一点,让我们考虑一个更具体的例子,它涉及一个依赖于底层细节的 UserEntity 类:

class UserEntity:
    def __init__(self, user_id: str):
        self.user_id = user_id
        # Direct dependency on a low-level module:
        self.database = MySQLDatabase()
    def save(self):
        self.database.insert("users", {"id": self.user_id})
class MySQLDatabase:
    def insert(self, table: str, data: dict):
        print(f"Inserting {data} into {table} table in MySQL") 

在这个例子中,UserEntity 直接依赖于低级模块 MySQLDatabase。现在,假设我们收到一个支持多个数据库系统的功能请求。按照当前的设计,我们需要修改 UserEntity 以适应这一变化,这违反了 OCP(开闭原则),并可能将错误引入我们的核心业务逻辑。以下是一些与这种设计相关的问题:

  • UserEntity 类与 MySQLDatabase 紧密耦合,这使得未来更改数据库系统变得困难

  • 测试 UserEntity 变得具有挑战性,因为我们不能轻易地用一个模拟数据库来替换测试目的

  • 核心业务逻辑(UserEntity)被基础设施关注点(数据库操作)所污染

使用 DIP 修复设计

由罗伯特·C·马丁(en.wikipedia.org/wiki/Dependency_inversion_principle)提出的 DIP,为前面例子中看到的问题提供了一个解决方案。它陈述如下:

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

  • 抽象不应当依赖于细节。细节应当依赖于抽象。

这两个要点从根本上改变了我们构建代码的方式。我们不再让高层和低层模块之间有直接的依赖关系,而是引入了两者都依赖的抽象。然而,DIP 的关键洞察力实际上在于什么被反转了:

  • 传统上,低层模块定义抽象,高层模块使用这些抽象。

  • 根据 DIP,高层模块定义抽象,低层模块实现这些抽象

这种抽象所有权的反转是 DIP 名称的由来,而不仅仅是模块间依赖方向的简单反转。现在,高层模块控制抽象,而低层模块则遵循它。这种控制权的转变使得高层模块可以独立于低层实现细节,从而促进系统设计的灵活性和可维护性。

让我们看看这是如何改变我们的依赖图的:

图 2.2:A 和 B 依赖于一个接口

图 2.2:A 和 B 依赖于一个接口

图 2.2所示,依赖现在指向抽象,反转了传统的流向。高层模块(A)和低层模块(B)都依赖于一个抽象(接口),而不是直接依赖于对方。这种变化是深刻的:A不再知道B;而是知道一个类似B的东西将遵守的契约。

这种从具体知识到抽象契约的转变具有深远的影响:

  • 解耦 和灵活性:现在,AB的具体实现细节解耦,只知道它必须满足的契约。这使我们能够轻松地交换或升级组件,而不会影响系统的其余部分,使其更能适应未来的需求。

  • 提高可测试性:我们可以创建实现接口的模拟对象,用于测试目的,这样我们就可以在无需复杂设置的情况下单独测试组件。

  • 清晰性和封装性:接口清楚地定义了组件之间的交互,使代码更具自文档性。实现上的变化被限制在内部,减少了系统中的连锁反应。

  • 设计由契约驱动:这种方法鼓励以接口而不是具体实现来思考,从而促进设计更优、更模块化的系统,这些系统更容易理解和维护。

通过遵循 DIP,我们不仅仅是改变依赖的方向;我们从根本上改变了系统不同部分之间的交互方式。这创造了一个更松散耦合、灵活且可维护的架构,能够更好地经受时间的考验和不断变化的需求。

为了使我们的UserEntity代码与 DIP(依赖倒置原则)保持一致,我们需要引入一个高层和低层模块都可以依赖的抽象。这种抽象通常以接口的形式出现。让我们重构我们的代码,使其遵循 DIP:

from abc import ABC, abstractmethod
class DatabaseInterface(ABC):
    @abstractmethod
    def insert(self, table: str, data: dict):
        pass
class UserEntity:
    def __init__(self, user_id: str, database: DatabaseInterface):
        self.user_id = user_id
        self.database = database
    def save(self):
        self.database.insert("users", {"id": self.user_id})
class MySQLDatabase(DatabaseInterface):
    def insert(self, table: str, data: dict):
        print(f"Inserting {data} into {table} table in MySQL")
class PostgreSQLDatabase(DatabaseInterface):
    def insert(self, table: str, data: dict):
        print(f"Inserting {data} into {table} table in PostgreSQL")
# Usage
mysql_db = MySQLDatabase()
user = UserEntity("123", mysql_db)
user.save()
postgres_db = PostgreSQLDatabase()
another_user = UserEntity("456", postgres_db)
another_user.save() 

在这个重构版本中,我们做了以下操作:

  • 我们引入了一个抽象(DatabaseInterface),它既被高级模块(UserEntity)也被低级模块(MySQLDatabasePostgreSQLDatabase)所依赖。

  • UserEntity类不再创建其数据库依赖,而是通过构造函数接收它。这种技术被称为依赖注入,它是实现 DIP 的关键实践。

  • 我们可以通过创建实现DatabaseInterface的新类来轻松添加对新数据库系统的支持。

图 2.3 表示这些组件之间新关系的状态:

图 2.3:UserEntity 与具体存储类解耦

图 2.3:UserEntity 与具体存储类解耦

此图表的重要性体现在几个关键方面:

  • 反转依赖和抽象作为契约UserEntity类依赖于DatabaseInterface抽象,而不是具体的实现。该接口作为任何数据库实现的契约。

  • 关注点分离UserEntity类与特定的数据库操作解耦,只知道DatabaseInterface中定义的抽象操作。

  • 可扩展性和灵活性:这种设计使我们能够轻松添加新的数据库实现,并在它们之间进行交换,而不会影响UserEntity

通过应用 DIP,我们创建了一个灵活、可维护的系统,其中我们的核心业务逻辑(UserEntity)免受外部细节(数据库实现)变化的影响。这种分离是 Clean Architecture 的基石,它促进了长期系统的稳定性和适应性。前面的图表显示了多个实现(MySQLDatabasePostgreSQLDatabase)如何共存,展示了这种基于抽象方法的力量。我们可以轻松添加更多实现,例如OracleDatabaseMongoDBAdapter,而无需修改UserEntity,进一步说明了 DIP 的可扩展性优势。

DIP 对测试的影响

正如我们通过其他 SOLID 原则所看到的,依赖注入的使用显著有助于测试。我们现在可以轻松地为测试创建一个模拟数据库UserEntity

class MockDatabase(DatabaseInterface):
    def __init__(self):
        self.inserted_data = []
    def insert(self, table: str, data: dict):
        self.inserted_data.append((table, data))
# In a test
mock_db = MockDatabase()
user = UserEntity("test_user", mock_db)
user.save()
assert mock_db.inserted_data == [("users", {"id": "test_user"})] 

这种轻松替换依赖的能力使我们的代码更容易测试,允许我们在与任何实际数据库实现隔离的情况下验证UserEntity的行为。

DIP 在 SOLID 和 Clean Architecture 中的上下文

DIP 在 SOLID 原则和 Clean Architecture 中都起着基石的作用。它通过使接口定义与实现分离,并支持系统行为的轻松扩展,补充了其他 SOLID 原则。在 Clean Architecture 中,DIP 对于实施依赖规则至关重要,它允许内部层定义外部层必须遵守的接口。这种反转将业务逻辑与实现细节分离,创建出更灵活、可维护和可测试的系统,与 Clean Architecture 的目标完美契合。

摘要

在本章中,我们探讨了 SOLID 原则及其在 Python 中的应用,以创建干净、可维护和灵活的架构。我们学习了每个原则如何有助于稳健的软件设计:

  • SRP 用于创建专注、一致的类

  • OCP 用于在不修改行为的情况下扩展行为

  • LSP 用于确保良好的、可替换的抽象

  • ISP 用于设计针对特定客户端的接口

  • DIP 用于构建依赖关系以最大化灵活性

这些原则对于开发能够随着需求变化而演变的 Python 应用程序至关重要,可以抵抗软件熵,并在系统复杂性增加时保持清晰。它们是 Clean Architecture 的基础,使我们能够创建更模块化、可测试和可适应的代码。

在下一章中,我们将探讨如何利用 Python 的类型系统进一步增强我们的 Clean Architecture 设计的稳健性和清晰度。您将看到类型提示如何加强我们刚刚覆盖的几个 SOLID 原则:为 ISP 创建更明确的接口,为 DIP 定义更清晰的契约,以及使 LSP 的可替换性更加明显。这些类型功能将帮助我们创建更易于维护和自文档化的代码,同时保持 Python 的灵活性。

进一步阅读

第三章:类型增强的 Python:加强清洁架构

第二章中,我们探讨了 SOLID 原则及其在 Python 中的应用,为可维护和灵活的代码奠定了基础。在此基础上,我们现在转向 Python 中的一个强大功能:类型提示

虽然 Python 的动态类型提供了灵活性,但它有时会导致复杂项目中的意外错误。类型提示提供了一个解决方案,结合了动态类型的好处和静态类型检查的健壮性。

本章探讨了类型提示如何增强清洁架构的实现,使代码更具自文档性和更少错误。我们将看到类型提示如何支持 SOLID 原则,特别是在创建清晰的接口和加强依赖倒置原则方面。

我们将从类型意识在 Python 动态环境中的作用开始,然后深入探讨 Python 类型系统的实际方面。最后,我们将探索用于早期问题检测的自动静态类型检查工具。

到本章结束时,你将了解如何在 Python 项目中有效地使用类型提示,编写更健壮、可维护且与清洁架构原则一致的代码。随着我们进入后续章节构建复杂、可扩展的系统,这种知识将至关重要。

在本章中,我们将涵盖以下主要主题:

  • 理解 Python 动态环境中的类型意识

  • 利用 Python 的类型系统

  • 利用自动静态类型检查工具

让我们开始探索 Python 类型提示及其在加强清洁架构实现中的作用。

技术要求

本章和本书其余部分提供的代码示例均使用 Python 3.13 进行测试。所有示例均可在本书配套的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Clean-Architecture-with-Python。本章还提到了 Visual Studio CodeVS Code)。VS Code 可从 code.visualstudio.com/download 下载。

理解 Python 动态环境中的类型意识

要充分欣赏 Python 的类型系统,区分动态类型语言(如 Python)和静态类型语言(如 Java 或 C++)非常重要。在静态类型语言中,变量在编译时有一个固定的类型。Python 作为一种动态类型语言,允许变量在运行时改变类型,提供了极大的灵活性,但也引入了潜在的挑战。这种动态类型在实现 Clean Architecture 时既是祝福也是挑战。虽然它提供了灵活性和快速开发,但也可能导致接口不明确和隐藏的依赖,这些问题正是 Clean Architecture 旨在解决的。在本节中,我们将探讨如何通过类型意识来增强我们的 Clean Architecture 实现,而不会牺牲 Python 的动态特性。

Python 中打字的发展

Python 对类型的方法随着时间的推移发生了显著变化。虽然最初是一种纯动态类型语言,但 Python 通过在 Python 3.5(2015 年)中添加类型提示语法(PEP 484)引入了可选的静态类型。这种引入是由 Python 应用程序日益增长的复杂性所推动的,特别是在大规模项目中,Clean Architecture 原则最有益。

通过 PEP 484 对类型提示的标准化标志着 Python 发展的一个重要里程碑,为向 Python 代码添加类型信息提供了一种统一的方法。它为 Python 生态系统中静态类型检查的更广泛采用以及各种工具和 IDE 的开发铺平了道路,这些工具和 IDE 利用了这种类型提示信息。

Python 对类型提示的方法是动态语言中更广泛趋势的一部分。例如,JavaScript 看到了 TypeScript 的兴起,它是 JavaScript 的一个类型超集,编译成纯 JavaScript。虽然 Python 和 TypeScript 都旨在将静态类型的好处带给动态语言,但它们的方法不同:

  • 集成:Python 的类型提示是内置于语言本身的,而 TypeScript 是一种独立语言,它编译成 JavaScript

  • 可选性:Python 的类型提示完全是可选的,可以逐步采用,而 TypeScript 则更严格地执行类型检查

TypeScript 在 JavaScript 生态系统中的成功进一步验证了在动态语言中添加类型信息的价值。Python 的类型提示和 TypeScript 都展示了如何将动态类型灵活性结合到静态类型健壮性中,从而实现更易于维护和可扩展的代码库。

Python 中类型提示的演变是由几个重要因素驱动的。它显著提高了代码的可读性,并作为一种自我文档化的形式,使开发者更容易理解变量和函数的预期用途。这种增强的清晰度在维护 Clean Architecture 的关注点分离方面特别有价值。类型提示还使集成开发环境(IDE)和工具支持更好,促进了更准确的代码补全和错误检测。当处理复杂架构时,这种改进的工具支持至关重要,它帮助开发者更有效地在不同层和组件之间导航。

此外,类型提示使重构和维护大型代码库变得容易得多。在 Clean Architecture 的背景下,我们努力创建能够适应变化的系统,这种好处尤其显著。

类型提示在大型重构努力中充当安全网,有助于确保系统某一部分的更改不会无意中破坏另一部分的接口或期望。

对于我们实现 Clean Architecture 来说,最重要的是,类型提示允许我们在开发过程的早期阶段捕捉到某些类型的错误。通过通过类型注解明确我们的意图,我们可以在设计时间或静态分析期间识别潜在问题,而不是在运行时遇到它们。这种早期错误检测与 Clean Architecture 创建健壮、可维护系统的目标完美契合。

在以下章节深入探讨类型提示的具体细节时,请记住,这些功能是增强我们 Python 实现 Clean Architecture 的工具。它们提供了一种使我们的架构边界更明确、代码更自我文档化的方法,同时保留了使 Python 成为软件开发如此强大语言的灵活性和表达性。

动态类型与类型提示的比较

要理解类型提示在 Python 中的重要性,区分 Python 的基本动态类型和类型提示的作用至关重要。这两个概念服务于不同的目的,并在开发过程的不同阶段运行。

动态类型

在像 Python 这样的动态类型语言中,变量可以持有任何类型的值,并且这些类型可以在运行时改变。这种灵活性是 Python 的核心特性。让我们来看一个例子:

x = 5        # x is an integer
x = "hello"  # Now x is a string 

这种灵活性允许快速开发和表达性代码,但如果不小心管理,可能会导致运行时错误。考虑以下示例:

def add_numbers(a, b):
    return a + b
# Works fine, result is 8:
result = add_numbers(5, 3)
# Raises TypeError: unsupported operand type(s) for +: 'int' and 'str':  
result = add_numbers(5, "3") 

在这种情况下,add_numbers 函数在给定两个整数时按预期工作,但在给定一个整数和一个字符串时引发 TypeError。这种错误仅在运行时发生,如果在应用程序的关键部分发生或未通过测试过程捕获,可能会成为问题。

类型提示

类型提示允许开发者除了返回值外,还可以用它们期望的类型来注释变量和函数参数。关于类型提示,让我们重新审视我们简单的加法函数:

def add_numbers(a: int, b: int) -> int:
    return a + b
# Works fine, result is 8:
result = add_numbers(5, 3)
# IDE or type checker would flag this as an error:
result = add_numbers(5, "3") 

让我们分解这个函数中使用的类型提示语法:

  • a: intb: int:这些注解表明ab都预期为整数。冒号(:)用于将参数名与其类型分开。

  • -> int: 函数参数列表之后的这个箭头符号指定了返回类型。在这种情况下,它表示add_numbers函数预期返回一个整数。

这些类型注解提供了关于函数预期输入和输出的清晰信息,使代码更具自文档性和易于理解。

关于类型提示的关键点包括以下内容:

  • 它们不会影响 Python 的运行时行为。Python 仍然是动态类型的。

  • 它们作为文档,使代码意图更加清晰。

  • 它们使静态分析工具能够在运行时之前捕获潜在的与类型相关的错误。

  • 它们提高了 IDE 对代码补全和重构的支持。

类型提示解锁了静态分析工具在运行时之前捕获潜在错误的能力。虽然 Python 本身提供了类型提示的语法,但它不会在运行时强制执行。Python 解释器将类型提示视为装饰性元数据。是像mypypyright这样的第三方工具执行实际的静态类型检查。这些工具在不执行代码的情况下分析你的代码,使用类型提示推断和检查整个代码库中的类型。它们可以作为独立命令运行,集成到 IDE 中以提供实时反馈,或纳入持续集成管道,允许在开发的不同阶段进行类型检查。

在本章的利用自动化静态类型检查工具部分,我们将更深入地探讨如何使用这些工具,在开发工作流程的关键点上对整个代码库进行静态类型检查。

清洁架构中的类型意识

类型提示的引入对于 Clean Architecture 尤其相关。在前一章中,我们讨论了清晰接口和依赖倒置的重要性。类型提示可以在实现这些目标中发挥关键作用,使我们的架构边界更加明确且易于维护。

考虑一下类型提示如何增强我们在第二章中引入的Shape示例,这里使用类型提示的更完整利用:

from abc import ABC, abstractmethod
import math
class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        pass
class Rectangle(Shape):
    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height
    def area(self) -> float:
        return self.width * self.height
class Circle(Shape):
    def __init__(self, radius: float) -> None:
        self.radius = radius
    def area(self) -> float:
        return math.pi * self.radius ** 2
class AreaCalculator:
    def calculate_area(self, shape: Shape) -> float:
        return shape.area() 

让我们更仔细地看看这个例子:

  • Shape类中的area方法被注解为返回一个float,清楚地传达了所有形状实现预期的返回类型。

  • RectangleCircle类指定它们的构造函数期望float参数并返回None。这个-> None注解明确指出构造函数不返回任何值,这在 Python 中是隐含的,但通过类型提示变得明确。

  • RectangleCircle中的具体area方法被注解为返回float,遵循在Shape抽象基类中定义的合同。

  • AreaCalculator类明确指出其calculate_area方法期望一个Shape对象作为参数并返回一个float

这些类型提示使接口更明确,有助于维护清洁架构组件之间的边界。需要注意的是,这些类型提示在运行时并不强制执行任何事情。相反,它们作为文档,并使静态分析工具能够在执行前捕获潜在的类型错误。

在清洁架构的上下文中,它们提供了几个好处:

  • 清晰的接口:类型提示使架构不同层之间的合同明确。在我们的例子中,很明显任何Shape都必须有一个返回floatarea方法。

  • 依赖反转:它们通过明确定义高层模块所依赖的抽象接口来帮助执行依赖规则。《AreaCalculator》依赖于抽象的Shape,而不是具体的实现。

  • 可测试性:类型提示使创建和使用符合预期接口的模拟对象变得更容易。对于测试,我们可以轻松创建一个符合记录接口要求的模拟Shape

  • 可维护性:随着项目的增长,类型提示作为活文档,使开发者更容易理解和修改代码。它们提供了对方法参数和返回值预期类型的即时洞察。

通过这种方式利用类型提示,我们创建了一个更健壮的清洁架构实现。明确记录的接口和清晰的依赖关系使我们的代码更具自文档性,并通过静态分析在早期捕获类型相关的问题。随着我们构建更复杂的系统,这些好处会累积,导致代码库更容易理解、修改和扩展。在下一节中,我们将探讨在将类型提示集成到您的清洁架构设计中时需要考虑的一些挑战和注意事项。

挑战和考虑因素

在您的 Python 项目中利用类型提示时,重要的是要意识到几个关键考虑因素:

  • 它们并不取代对适当测试和错误处理的必要性

  • 对于新接触静态类型概念的开发者来说,存在一个学习曲线。

  • 计划将其纳入团队的开发工作流程和持续集成,持续部署CI/CD)管道对于成功采用至关重要。

在接下来的章节和本书的剩余部分,我们将深入探讨如何利用这些特性来创建更健壮、可维护和自文档化的 Clean Architecture 实现。我们将看到类型意识如何帮助我们创建更清晰的架构层边界,使我们的依赖关系更明确,并在开发过程中早期捕捉潜在问题。

记住,目标不是将 Python 转变为静态类型语言,而是将类型意识作为一个工具来增强我们的 Clean Architecture 设计。到本章结束时,你将牢固地理解如何平衡 Python 的动态特性与 Clean Architecture 实现中类型意识的好处。

利用 Python 的类型系统

在 Clean Architecture 的领域内,强大类型系统的角色远远超出了简单的错误预防。它作为表达和强制架构边界的强大工具,支持诸如抽象、多态和依赖倒置等关键原则。当有效地利用时,Python 的类型系统成为实现这些关键概念的无价资产。

当我们开始考虑 Python 类型系统的更高级特性时,我们将看到它们如何显著增强我们的 Clean Architecture 实现。这些特性允许我们在应用程序的不同层之间创建更表达性和精确的接口,从而产生不仅更健壮,而且更自文档化和可维护的代码。

在本节中,我们将探讨一系列类型特性,从类型别名和联合类型到字面量和 TypedDict 类型。然后我们将看到这些如何应用于支持 Clean Architecture 设计中的 SOLID 原则。到本节结束时,你将全面理解如何使用 Python 的类型系统来创建更干净、更可维护的架构边界。

我们将从基本类型提示的回顾开始,然后深入探讨更高级的特性,最后我们将看到这些特性如何在 Clean Architecture 的背景下支持 SOLID 原则。

基本类型提示:从简单类型到容器

我们已经看到了如何为简单类型使用基本类型提示。让我们快速回顾一下语法:

  • 对于整数:count: int

  • 对于浮点数:price: float

  • 对于字符串:name: str

  • 对于布尔值:is_active: bool

  • 对于函数注解:遵循 def function_name(parameter: type) -> return_type: 模式

现在,让我们看看如何使用类型提示与容器类型,如列表和字典一起使用:

def process_order(items: list[str],
                  quantities: list[int]) -> dict[str, int]:
    return {item: quantity for item,
            quantity in zip(items, quantities)}
# Usage
order = process_order(['apple', 'banana', 'orange'], [2, 3, 1])
print(order)
# Output: {'apple': 2, 'banana': 3, 'orange': 1} 

让我们更仔细地看看这个例子:

  • list[str] 表示项目应该是一个字符串列表

  • list[int] 表示数量应该是一个整数列表

  • -> dict[str, int] 告诉我们该函数返回一个具有字符串键和整数值的字典

这些类型提示提供了关于输入和输出数据预期结构的清晰信息,这在 Clean Architecture 中尤其有价值,因为在 Clean Architecture 中,我们经常处理在不同应用层之间传递的复杂数据结构。

为什么我有时会看到 list 而有时会看到 List 在 Python 代码中?

你可能会注意到,一些 Python 代码使用list(小写)而其他代码使用List(大写)进行类型注解。这是因为 Python 3.9 引入了对内置泛型类型的支持。在此之前,你需要从typing包中导入List占位符类型。对于 Python 3.9+的代码,你可以简单地使用内置的集合名称,如listdict

在 Clean Architecture 中,这种类型提示在定义应用不同层之间的接口时特别有用。它们为领域层、用例和外部接口之间的数据传递提供了清晰的合同,有助于保持清晰的边界并降低数据不一致的风险。

随着我们继续前进,我们将看到更高级的类型功能如何进一步增强我们表达复杂关系和约束的能力,支持健壮的 Python Clean Architecture 实现。

Sequence:集合类型的灵活性

typing模块中的Sequence类型提示是一个强大的工具,可以以与 SOLID 原则(特别是 Liskov 替换原则和开放-封闭原则)良好对齐的方式表达集合。

下面是一个演示其使用的例子:

from typing import Sequence
def calculate_total(items: Sequence[float]) -> float:
    return sum(items)
# Usage
print(calculate_total([1.0, 2.0, 3.0]))  # Works with list
print(calculate_total((4.0, 5.0, 6.0)))  # Also works with tuple 

使用Sequence而不是特定的类型,如ListTuple,具有几个优点:

  • Liskov 替换原则Sequence允许函数与任何序列类型(列表、元组和自定义序列类)一起工作,而不会破坏合同

  • 开放-封闭原则calculate_total函数对扩展是开放的(它可以与新的序列类型一起工作),但对修改是封闭的(我们不需要更改函数以支持新类型)

  • 接口隔离原则:通过使用Sequence,我们只要求所需的最小接口(元素迭代),而不是承诺使用可能不必要的特定集合类型

在 Clean Architecture 中,Sequence类型提示在各个层之间都非常有价值。在用例层,它简化了实体或值对象的集合处理。在接口适配器层,它实现了与各种集合类型协同工作的灵活 API。在领域层,Sequence表达了需要集合的需求,但没有指定其实施,保持了关注点的分离。这种多功能性使Sequence成为在 Python 中创建适应性强且可维护的 Clean Architecture 实现的强大工具。

联合和可选类型

在 Clean Architecture 中,我们经常需要处理多个可能类型或可选值,尤其是在层之间的边界。联合类型可选类型非常适合这些场景:

from typing import Union, Optional
def process_input(data: Union[str, int]) -> str:
    return str(data)
def find_user(user_id: Optional[int] = None) -> Optional[str]:
    if user_id is None:
        return None
    # ... logic to find user ...
    return "User found"
# Usage
result1 = process_input("Hello")  # Works with str
result2 = process_input(42)       # Works with int
user = find_user()                # Optional parameter 

Union 类型允许一个值是几种类型之一,而 OptionalUnion[Some_Type, None] 的简写。这些结构在 Clean Architecture 中创建灵活的层之间接口的同时保持类型安全性特别有用。

应注意,在 Python 3.10+ 中,联合语法被简化为简洁的管道字符(|)的文本使用:

def process_input(data: Union[str, int]) -> str: 

前一行可以简化为以下形式:

def process_input(data: str | int) -> str: 

文字类型

文字类型允许我们指定变量可以取的确切值。这在 Clean Architecture 中强制在接口边界上执行特定值时特别有用:

from typing import Literal
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR"]
def set_log_level(level: LogLevel) -> None:
    print(f"Setting log level to {level}")
# Usage
set_log_level("DEBUG")  # Valid
set_log_level("CRITICAL")  # Type checker would flag this as an error 

Literal 类型有助于创建更精确的接口,减少无效数据通过系统的可能性。这与 Clean Architecture 强调的层之间清晰的边界和契约相吻合。

类型别名

类型别名 有助于简化复杂的类型注解,使我们的代码更易于阅读和维护。这在 Clean Architecture 中处理复杂的领域模型或数据传输对象时特别有用。

考虑以下示例:

# Type aliases
UserDict = dict[str, str]
UserList = list[UserDict]
def process_users(users: UserList) -> None:
    for user in users:
        print(f"Processing user: {user['name']}")
# Usage
users: UserList = [{"name": "Alice"}, {"name": "Bob"}]
process_users(users) 

让我们更仔细地看看这段代码:

  • UserDictdict[str, str] 的类型别名,表示具有字符串键和值的用户对象

  • UserListlist[UserDict] 的类型别名,表示用户字典的列表

类型别名提供了更易于阅读的复杂类型名称,提高了代码的清晰度,而没有创建新类型。它们使我们能够编写既具有表达性又与 Clean Architecture 原则一致、促进关注点分离、可维护性和清晰性的代码。

NewType

NewType 创建独特的类型,提供额外的类型安全性。这在 Clean Architecture 中定义清晰的领域概念时非常有价值:

from typing import NewType
UserId = NewType('UserId', int)
ProductId = NewType('ProductId', int)
def process_order(user_id: UserId,
                  product_id: ProductId) -> None:
    print(f"Processing order for User {user_id} and Product {product_id}")
# Usage
user_id = UserId(1)
product_id = ProductId(1)  # Same underlying int, but distinct type
process_order(user_id, product_id)
# This would raise a type error:
# process_order(product_id, user_id) 

NewType 创建独特的类型,这些类型被静态类型检查器识别,防止类似但概念上不同的值的意外混合。这有助于在开发早期阶段捕获潜在的错误,并增强 Clean Architecture 实现的整体类型安全性。

类型别名和 NewType 都很好地与 Clean Architecture 原则相吻合,通过提高代码可读性,确保层之间的类型安全性,并清晰地定义领域概念。这导致在 Python 中实现更具表达性、类型安全和可维护的 Clean Architecture。

任意类型

任意类型是一个特殊的类型提示,它本质上告诉类型检查器允许任何类型。当您想表示一个变量可以是任何类型,或者当您处理类型真正未知或可能广泛变化的代码时,会用到它。我们可以在以下通用日志示例中看到它的用法:

from typing import Any
def log_data(data: Any) -> None:
    print(f"Logged: {data}")
# Usage
log_data("A string")
log_data(42)
log_data({"key": "value"}) 

在 Clean Architecture 中,我们通常力求尽可能具体地指定类型,尤其是在层边界处。Any类型应被视为最后的手段,通常表明需要重构或更具体的类型定义。它最适用于与外部系统接口,那里的类型真正未知或高度可变。在你的代码内部,将Any的使用视为重构代码到使用特定类型而不是使用通用的Any类型的信号。

这些高级类型功能为在 Python 中实现 Clean Architecture 提供了强大的工具。它们允许我们在应用程序的不同层之间创建更具有表达性、精确性和自文档化的接口。随着我们继续前进,我们将探讨这些功能如何应用于支持 Clean Architecture 设计中的 SOLID 原则。

利用自动化静态类型检查工具

如我们所探讨的,Python 的类型系统和它在 Clean Architecture 中的好处,了解如何在实践中有效地应用这些类型提示至关重要。Python 作为一个动态类型语言,在运行时不强制执行类型检查。这就是自动化静态类型检查工具发挥作用的地方,它弥合了 Python 的动态特性和静态类型的好处之间的差距。这种方法提供了几个关键的好处:

  • 早期错误检测:在运行时之前捕获类型相关的问题,降低生产中出现 bug 的可能性

  • 提高代码质量:在整个项目中强制使用类型的一致性,从而产生更健壮和自文档化的代码

  • 增强重构:在更大规模的代码更改中更有信心,因为类型检查器可以识别许多需要更新的地方

  • 更好的 IDE 支持:在你的开发环境中启用更精确的代码补全、导航和重构工具

这些好处在 Clean Architecture 的实现中尤其有价值,在保持层之间的清晰边界和确保数据流的正确性方面至关重要。

在本节中,我们将重点介绍如何利用这些自动化工具来强制类型一致性、早期捕获错误并提高整体开发体验。我们将使用mypy的命令行界面(CLI),但随后将使用另一个工具作为 VS Code IDE 的扩展。

mypy 命令行界面

Mypy 是一个强大的静态类型检查器,可以直接从命令行运行。这使得它很容易集成到你的开发工作流程和部署管道中。让我们一步步了解如何使用mypy并解释其输出。

首先,你需要安装mypy。由于它是一个 Python 模块,你可以使用pip轻松安装它:

$ pip install mypy 

安装完成后,你可以使用mypy检查你的 Python 文件中的类型错误。让我们看看一个简单的例子。假设你有一个名为user_service.py的 Python 文件,其内容如下:

def get_user(user_id: int) -> dict:
    # Simulating user retrieval
    return {"id": user_id,
            "name": "John Doe",
            "email": "john@example.com"}
def send_email(user: dict, subject: str) -> None:
    print(f"Sending email to {user['email']} with subject: {subject}")
# Usage
user = get_user("123")
send_email(user, "Welcome!") 

要使用mypy检查此文件,请运行以下命令:

$ mypy user_service.py
user_service.py:9: error: Argument 1 to "get_user" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file) 

让我们分析一下mypy告诉我们什么:

  • 它识别出发生错误的文件(user_service.py)和行号(9

  • 它描述了错误:我们向 get_user 函数传递了一个字符串("123"),但该函数期望一个整数

  • 它将错误分类为 [arg-type] 问题,表明存在参数类型问题

此输出非常有价值。它捕捉到可能导致运行时错误的类型不匹配,使我们能够在代码执行之前修复它。

我们可以通过将 user = get_user("123") 更改为 user = get_user(123) 然后重新运行 mypy 来纠正错误:

$ mypy user_service.py
Success: no issues found in 1 source file 

现在,mypy 没有报告任何问题,确认我们的类型注解与我们使用函数的方式一致。

配置 mypy

虽然 mypy 默认工作良好,但您可以使用配置文件自定义其行为。这对于大型项目或您想逐步采用类型检查时特别有用。

在您的项目根目录中创建一个名为 mypy.ini 的文件:

[mypy]
ignore_missing_imports = True
strict_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_return_any = True
warn_unreachable = True 

此配置执行以下操作:

  • 忽略缺失的导入,这在处理没有类型存根的第三方库时非常有用

  • 启用对 Optional 类型的严格检查

  • 警告关于冗余的类型转换和未使用的 type: ignore 注释

  • 当一个函数隐式返回 Any 时发出警告

  • 警告不可达的代码

使用此配置,mypy 将提供更全面的检查,帮助您在 Clean Architecture 实现中捕捉到更广泛的潜在问题。

通过定期将 mypy 作为开发过程的一部分运行,您可以早期捕捉到与类型相关的问题,确保您的 Clean Architecture 层能够正确交互并保持其预期的边界。

mypy 的配置选项非常广泛,可以根据您特定项目的需求进行定制。有关可用选项及其描述的完整列表,请参阅官方 mypy 文档mypy.readthedocs.io/en/stable/config_file.html

部署管道中的 Mypy

mypy 集成到您的部署管道中是确保项目类型一致性至关重要的步骤,尤其是在 Clean Architecture 的背景下,保持层之间的清晰边界至关重要。

虽然具体实现细节可能因您选择的 CI/CD 工具而异,但基本概念保持不变:在部署代码之前,将 mypy 作为自动化检查的一部分运行。鉴于 mypy 通过简单的 CLI 运行,将其纳入大多数部署管道相对简单。

例如,您可能在这些情况下运行 mypy 检查:

  • 在每次提交推送之后

  • 作为拉取请求验证的一部分

  • 在合并到主分支之前

  • 在部署到预发布或生产环境之前

这种方法有助于在开发过程中早期捕捉到与类型相关的问题,降低类型错误进入生产的风险。

例如,以下是一个简单的 GitHub Actions 工作流程,它运行mypy然后是单元测试:

name: Python Type Check and Test
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: '3.13'
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install mypy pytest
    - name: Run mypy
      run: mypy .
    - name: Run tests
      run: pytest 

此工作流程执行以下操作:

  • 在推送或拉取请求事件时触发

  • 设置 Python 环境

  • 安装必要的依赖项(包括mypypytest

  • 在整个项目中运行mypy

  • 运行项目的单元测试

通过在部署管道中包含mypy,您确保所有代码更改在集成之前都经过类型检查,有助于维护 Clean Architecture 实现的完整性。

记住,虽然此示例使用 GitHub Actions,但原则适用于任何 CI/CD 工具。关键是运行 mypy 作为您自动化检查的一部分,利用其 CLI 来无缝集成到现有的部署流程中。

在 IDE 中使用类型提示以改善开发体验

虽然拥有带有类型检查的部署管道对于维护代码质量至关重要,但最有效的方法是在编写代码时实时捕捉类型问题。这种即时反馈允许开发者立即解决类型不一致,减少在开发后期修复问题的时间和精力。

现代集成开发环境(IDEs)已经采纳了这种方法,利用类型提示来提供带有即时类型检查反馈的增强编码体验。虽然这种功能在大多数流行的 Python IDEs 中都有提供,但我们将重点关注 VS Code,因为它被广泛使用,并且对 Python 的支持非常强大。

在 VS Code 中,Pylance扩展已成为 Python 类型检查的首选工具。Pylance 使用pyright作为其类型检查引擎,无缝集成到 VS Code 中,提供实时类型检查以及其他显著改善 Python 开发体验的高级功能。

在 VS Code 中安装了 Pylance 后,开发者会立即收到有关任何类型问题的视觉提示:

图 3.1:安装了 Pylance 扩展的 VS Code

图 3.1:安装了 Pylance 扩展的 VS Code

图 3.1中,我们看到在 IDE 编辑器中,期望整数的地方使用了字符串,并且对问题进行了精确的解释。

这种实时反馈与我们在 Clean Architecture 实现中集成的类型提示产生了强大的协同作用。它允许开发者在编码时维护严格的类型一致性,而不是仅仅依赖开发后的检查。

您可以从 VS Code 市场(marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance)安装 Pylance 扩展,同时阅读更多关于其功能和配置的信息。

额外的类型检查功能

虽然实时反馈和部署管道检查至关重要,但还有一些额外的功能可以增强您的类型检查工作流程。

VS Code 中的“问题”标签页

VS Code 提供了一个问题标签页,它汇总了你的代码中的所有问题,包括由 Pylance 检测到的类型错误。此标签页提供了项目类型不一致的全面概述。

图 3.2:VS Code 问题标签页

图 3.2:VS Code 问题标签页

图 3.2中,我们看到的是我们之前内联看到的类型检查的汇总。开发者可以使用此标签页作为提交代码前的最终检查,确保没有遗漏任何类型问题。

Git 预提交钩子

Git 支持预提交钩子,允许你在每次提交前自动运行检查。你可以配置这些钩子来运行mypy和单元测试,防止引入类型错误或破坏现有功能的提交。

有关设置 Git 钩子的更多信息,请参阅官方 Git 文档:git-scm.com/book/en/v2/Customizing-Git-Git-Hooks

通过将这些附加功能纳入你的工作流程,你在开发过程中创建了多个类型检查层。这种全面的方法有助于维护 Clean Architecture 实现的完整性,从编写代码到提交更改的每个阶段都能捕捉到类型不一致。

渐进式采用策略

在 Python 项目中引入静态类型检查有时会遇到阻力,尤其是来自习惯于 Python 动态特性的开发者。为了确保平稳过渡,与团队协作并清楚地传达类型提示的理由和好处至关重要。

这里有一个逐步采用策略:

  1. 举行团队会议,讨论并制定一个纳入类型检查的计划。

  2. 实施一项政策,要求所有新代码都必须有类型提示。

  3. 通过配置mypy以忽略特定模块或包来最小化初始干扰。这可以在mypy配置文件中完成:

    [mypy.unwanted_module]
    ignore_errors = True
    [mypy.some_package.*]
    ignore_errors = True 
    
  4. 创建计划维护任务,逐步向现有代码添加类型提示,优先考虑关键路径。

通过采用这些工具和策略,你可以显著提高你的 Clean Architecture 实现的健壮性和可维护性。最有效的方法是在各个阶段进行检查:IDE 中的实时反馈、预提交钩子和部署管道中的验证。这种多层次策略确保了早期错误检测,增强了代码导航,并在整个开发周期中保持类型检查的一致性。最终,这种全面的方法导致更可靠、可维护和可扩展的 Python 应用程序,充分利用 Python 类型系统在 Clean Architecture 项目中的力量。

摘要

在本章中,我们探讨了 Python 动态环境中的类型意识及其在加强 Clean Architecture 实现中的作用。我们学习了如何利用 Python 的类型系统和类型提示来创建更健壮、自文档化的代码,并发现了自动化静态类型检查工具在早期捕获错误的价值。

您掌握了在函数、类和变量中实现类型提示的技能,提高了代码的清晰度和可靠性。您还学习了如何设置和使用静态类型检查工具,如 mypy,以验证项目中的类型一致性。这些技能对于在 Python 中创建可维护和可扩展的 Clean Architecture 实现至关重要,可以提升代码质量并与 Clean Architecture 原则保持一致。

在下一章中,领域驱动设计:构建核心业务逻辑,我们将基于增强类型的 Python 和来自 第二章 的 SOLID 原则进行探讨。我们将探索 Clean Architecture 的领域层,学习如何建模和实现独立于外部关注的核心业务逻辑。以个人任务管理应用为例,我们将应用类型意识技术和 SOLID 原则来创建一个健壮、结构良好的领域模型,为真正清洁和可维护的架构奠定基础。

进一步阅读

第二部分

在 Python 中实现整洁架构

在本部分,我们将从理论理解转向实际应用,探讨整洁架构的核心层以及它们如何在完整系统中协同工作。你将学习如何构建封装业务逻辑的领域模型,实现编排领域操作的应用用例,创建在不同层之间进行转换的接口适配器,与外部框架集成,并构建全面的测试策略。通过我们的任务管理系统示例,你将看到这些组件如何形成一个统一、可维护的架构。

本书本部分包括以下章节:

  • 第四章领域驱动设计:构建核心业务逻辑

  • 第五章应用层:编排用例

  • 第六章接口适配器层:控制器和展示者

  • 第七章框架和驱动层:外部接口

  • 第八章使用整洁架构实现测试模式

第四章:领域驱动设计:构建核心业务逻辑

在前面的章节中,我们为理解清洁架构及其原则奠定了基础。我们探讨了指导稳健软件设计的 SOLID 原则,并学习了如何利用 Python 的类型系统创建更易于维护的代码。现在,我们将注意力转向清洁架构的最内层:实体层,也常被称为领域层

实体层代表了我们的应用程序的核心,封装了基本业务概念和规则。这一层独立于外部关注点,并构成了我们其余清洁架构的基础。通过关注这个核心,我们确保我们的应用程序无论使用的外部技术或框架如何,都始终忠于其基本目的。

在本章中,我们将深入探讨实体层的实现,使用领域驱动设计(DDD)原则。我们将使用一个个人任务管理应用程序作为我们的持续示例,展示如何在 Python 中建模和实现核心业务概念。您将学习如何识别和建模领域实体,保持关注点的清晰分离,并为我们的清洁架构实现创建一个坚实的基础。到本章结束时,您将了解如何创建体现核心概念和业务规则的实体,为构建在此坚实基础之上的层奠定舞台。

在本章中,我们将涵盖以下主要内容:

  • 使用 DDD 原则识别和建模核心实体

  • 在 Python 中实现实体

  • 高级领域概念

  • 确保实体层的独立性

技术要求

本章及本书其余部分提供的代码示例均使用 Python 3.13 进行测试。所有示例均可在本书配套的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Clean-Architecture-with-Python

使用 DDD 识别和建模领域层

第一章中,我们强调了实体层在清洁架构中的关键重要性。这一层构成了您软件的核心,封装了核心业务逻辑和规则。DDD 提供了一种系统化的方法来有效地建模这个关键组件。

领域驱动设计(DDD)提供了工具和技术来识别、建模和实现我们实体层的基本组件,弥合业务现实与软件设计之间的差距。通过在我们的清洁架构框架内应用 DDD 原则,我们创建了一个领域模型,它不仅准确反映了业务需求,而且为灵活、可维护的软件系统提供了一个坚实的基础。

将 DDD 与清洁架构集成的主要好处包括以下内容:

  • 与业务需求的一致性

  • 开发者与领域专家之间的沟通改进

  • 提高灵活性和可维护性

  • 通过清晰的边界和接口实现自然可扩展性

在本章中,我们将使用个人任务管理系统作为我们的示例来阐述这些概念。这个实际例子将帮助我们使 DDD 的抽象概念在可联系的现实场景中得到具体化。

理解 DDD

在确立了领域层在 Clean Architecture 中的重要性之后,我们现在转向 DDD 实现这一层具体的技术。由埃里克·埃文斯于 2003 年提出(en.wikipedia.org/wiki/Domain-driven_design),DDD 提供了具体的实践,帮助我们将业务需求转化为健壮的领域模型。

虽然 Clean Architecture 告诉我们领域实体应该是我们系统核心,但 DDD 提供了如何做:在本章中我们将探讨的特定建模技术,如实体值对象领域服务。这些实践帮助我们创建领域模型,不仅执行业务规则,而且通过代码清晰地传达其意图。当 Clean Architecture 提供了组织代码层的结构蓝图时,DDD 提供了实现核心业务逻辑的有效战术模式。

在其核心,DDD 强调技术专家和领域专家之间的紧密合作。这种合作旨在:

  1. 建立对领域的共同理解

  2. 创建一个准确反映领域复杂性的模型

  3. 在代码中实现此模型,保持其完整性和表达性

通过在我们的 Clean Architecture 方法中采用 DDD 原则,我们获得几个关键的好处:

  • 与业务需求对齐:我们的软件成为业务领域的真实反映,使其更具价值,并随着业务需求的变化更容易适应

  • 改进沟通:DDD 在开发人员和领域专家之间建立了一种共同语言,减少了误解并提高了整体项目的凝聚力

  • 灵活性和可维护性:一个设计良好的领域模型本质上更加灵活且易于维护,因为它围绕核心业务概念构建,而不是技术约束

  • 可扩展性:DDD 对边界上下文(在领域建模的核心概念部分有所涉及)和系统不同部分之间清晰接口的关注,自然地导致更可扩展的架构

通过将 DDD 原则与 Clean Architecture 相结合,我们锻造了一种强大的软件开发方法论,这种方法论与业务需求紧密一致,同时保持技术灵活性。DDD 提供了工具和技术来有效地建模我们系统的核心——实体层,这在 Clean Architecture 中是核心的,并且独立于外部关注点。这种协同作用确保我们的领域层真正封装了关键的业务概念和规则,支持创建灵活、可维护且对技术变化具有弹性的系统。当我们深入研究 DDD 概念并将它们应用于我们的任务管理系统时,我们将从分析业务需求这一关键步骤开始。

分析业务需求

应用 DDD 原则的第一步是彻底分析业务需求。这个过程不仅涉及列出功能,还需要深入核心概念、工作流程和规则,这些规则支配着领域。

对于我们的任务管理系统,我们需要考虑以下问题:

  • 什么是定义任务独特性的因素?

  • 任务优先级如何影响其在系统中的行为?

  • 哪些规则支配着任务在不同状态之间的转换?

  • 任务列表或项目如何与单个任务相关联?

  • 当任务的截止日期过去后,任务会发生什么?

这些类型的问题帮助我们理解我们领域的根本方面。例如,我们可能会确定一个任务可以通过一个全局唯一 ID 来唯一标识,并且其优先级可以影响其在任务列表中的位置。我们可能会定义规则,例如“一个完成的任务在没有首先重新打开的情况下不能被移回进行中状态。”

重要的是要注意,在这个 DDD 阶段,我们并没有编写任何代码。作为一个开发者,你可能会感到立即开始实施这些概念的冲动。然而,要抵制这种诱惑。DDD 的力量在于在编写任何代码之前彻底理解和建模领域。这种对领域分析的前期投资将在未来带来更稳健、灵活和准确的软件模型。

领域建模的核心概念

DDD 为有效地建模我们的领域提供了几个关键概念。其中最重要的是通用语言的概念,这是一种由开发者和领域专家共同拥有的、严谨的共同词汇。这种语言在代码、测试和对话中始终如一地使用,有助于防止误解并确保模型与业务领域保持一致。

在我们的任务管理系统中,这种语言包括以下术语:

  • 任务:需要完成的单元工作

  • 项目:一系列相关的任务

  • 截止日期:任务完成的最后期限

  • 优先级:任务的重要性级别(例如

  • 状态:任务当前的状态(例如 待办进行中完成

在建立了这种普遍语言之后,让我们探索 DDD 的基本结构概念,这将帮助我们实现我们的领域模型:

图 4.1:Clean Architecture 层和 DDD 概念

图 4.1:Clean Architecture 层和 DDD 概念

图 4.1所示,Clean Architecture 将实体层置于我们系统的核心,而 DDD 提供了填充这一层的具体组件(实体、值对象和域服务)。现在让我们来回顾一下:

  • 实体:这些是由其身份定义的对象,即使其属性发生变化,其身份也会持续存在。一个Order即使其状态从待处理变为已发货,也仍然是同一个Order。在 Clean Architecture 中,这些核心业务对象体现了系统中心的最高稳定规则。

  • 值对象:这些是由其属性定义而不是身份的不可变对象。具有相同货币和金额的两个Money对象被认为是相等的。它们封装了连贯的行为,无需唯一标识,增加了领域表达性,同时减少了复杂性。

  • 域服务:这些代表无状态的运算,它们不属于单个实体或值对象。它们处理跨越多个对象的领域逻辑,例如根据订单的项目和客户的地理位置计算运费。

这些建模组件构成了我们在 Clean Architecture 中实体层的基石。虽然 DDD 为我们提供了词汇和技巧,以基于业务现实来识别和建模这些组件,但 Clean Architecture 为我们提供了在代码库中组织它们的框架,确保它们独立于外部关注点。随着我们在 Python 中实现这些概念,这种互补关系将变得更加清晰。

建模任务管理领域

让我们将 DDD 的核心概念应用于我们的任务管理系统,将理论概念转化为我们领域模型的实际组件。

任务管理应用程序实体和值对象

我们的系统有两个主要实体:

  • 任务:代表一个工作单元的核心实体,尽管属性发生变化(例如,状态转换),但其身份持续存在

  • 用户:代表管理任务的系统用户,也具有持久性身份

我们还有几个重要的值对象:

  • 任务状态:一个枚举(例如,待办进行中完成),表示任务的状态

  • 优先级:表示任务的重要性(例如,

  • 截止日期:表示到期日期和时间,封装了相关的行为,如逾期检查

这些值对象增强了我们模型的表达能力。例如,一个任务有一个任务状态,而不是一个简单的字符串,它携带更多的语义意义和潜在的行为。

任务管理应用程序域服务

不属于单个实体或值对象的复杂操作作为域服务实现:

  • 任务优先级计算器:根据各种因素计算任务的优先级

  • 提醒服务:管理任务提醒的创建和发送

这些服务使我们的实体和价值对象保持专注和一致。

利用边界上下文

边界上下文是定义特定领域模型适用的概念边界。它们封装领域细节,确保模型一致性,并通过定义良好的接口进行交互。这与 Clean Architecture 对清晰组件边界的强调相一致,有助于模块化和可维护的系统设计。

我们可以在系统中识别出三个不同的边界上下文:

  • 任务管理:核心上下文,处理与任务相关的操作

  • 用户账户管理:处理与用户相关的操作

  • 通知:管理生成和向用户发送通知

这些上下文在我们的系统中创建了清晰的边界,允许独立开发同时实现必要的交互。

图 4.2:我们的任务管理应用的三种潜在边界上下文

图 4.2:我们的任务管理应用的三种潜在边界上下文

此模型构成了我们 Clean Architecture 设计的核心,实体和价值对象位于我们的实体层中心。我们的通用语言确保代码准确反映领域概念;领域服务包含复杂的多对象逻辑,边界上下文在更高层次上管理系统复杂性。

在下一节中,我们将使用 Python 实现这个概念模型,创建封装基本业务规则的丰富领域实体。

在 Python 中实现实体

使用基于 DDD 原理的概念化领域模型,我们现在转向这些概念在 Python 中的实际实现。本节将专注于创建封装基本业务规则的丰富领域实体,为我们的 Clean Architecture 实现奠定基础。

Python 实体介绍

在建立了我们对 DDD 中实体的理解之后,让我们探讨如何在 Python 中有效地实现它们。我们的实现将侧重于创建具有唯一标识符和封装业务逻辑的方法的类,将 DDD 概念转化为实际的 Python 代码。

关键实现考虑因素包括以下内容:

  • 标识符:使用 Python 的通用唯一标识符(UUID)系统实现唯一标识符

  • 可变性:利用 Python 的面向对象特性来管理状态变化

  • 生命周期:通过 Python 类方法管理对象的创建、修改和删除

  • 业务规则:使用 Python 的类型系统和类方法来强制执行业务规则

    Python 数据类的介绍

    在我们的实现中,我们将使用 Python 3.7 中引入的数据类。数据类是一种简洁的方式来创建主要存储数据但也可以具有行为的类。它们自动生成几个特殊方法,如__init__()__repr__()__eq__(),减少了样板代码。

    数据类的关键优势包括以下内容:

    • 减少样板代码:自动生成常见方法

    • 清晰性:清楚地表达了数据结构

    • 不可变性选项:可以创建不可变对象,与 DDD 原则中的值对象保持一致

    • 默认值:轻松指定属性的默认值

    数据类与 Clean Architecture 原则相吻合,通过促进清晰、专注的实体来封装数据和行为。它们帮助我们创建易于理解、维护和测试的实体。

    有关数据类的更多信息,请参阅官方 Python 文档:docs.python.org/3/library/dataclasses.html

现在,让我们看看我们如何使用数据类来实现我们的Entity基类:

from dataclasses import dataclass, field
from uuid import UUID, uuid4
@dataclass
class Entity:
    # Automatically generates a unique UUID for the 'id' field;
    # excluded from the __init__ method
    id: UUID = field(default_factory=uuid4, init=False)
    def __eq__(self, other: object) -> bool:
        if not isinstance(other, type(self)):
            return NotImplemented
        return self.id == other.id
    def __hash__(self) -> int:
        return hash(self.id) 

这个Entity基类为所有我们的实体提供了一个基础,确保它们有一个唯一的标识符和适当的相等性和散列行为。

确保 Python 中类的正确相等性

正如我们在Entity基类中看到的,我们已经实现了__eq____hash__方法,以确保适当的身份和相等性检查。这对于实体至关重要,因为具有相同属性但不同 ID 的两个任务应被视为不同的实体。

创建领域实体

现在,让我们实现我们的核心领域实体:Task实体。这个实体将封装与我们的任务管理系统相关的任务的基本概念和规则。

实现 Task 实体

首先,让我们看看我们的Task实体的基本结构:

from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Task(Entity):
    title: str
    description: str
    due_date: Optional[Deadline] = None
    priority: Priority = Priority.MEDIUM
    status: TaskStatus = field(default=TaskStatus.TODO, init=False) 

这个Task实体封装了我们系统中任务的核心理念。让我们逐一分析每个属性:

  • title:一个字符串,表示任务的名称或简要描述

  • description:对任务所包含内容的更详细说明

  • due_date:一个可选的Deadline对象,表示任务应完成的日期

  • priority:表示任务的优先级,默认为MEDIUM

  • status:表示任务的当前状态,默认为TODO

现在,让我们实现我们的值对象:

from enum import Enum
from dataclasses import dataclass
from datetime import datetime, timedelta
class TaskStatus(Enum):
    TODO = "TODO"
    IN_PROGRESS = "IN_PROGRESS"
    DONE = "DONE"
class Priority(Enum):
    LOW = 1
    MEDIUM = 2
    HIGH = 3
# frozen=True makes this immutable as it should be for a Value Object
@dataclass(frozen=True)
class Deadline:
    due_date: datetime
    def __post_init__(self):
        if self.due_date < datetime.now(timezone.utc):
            raise ValueError("Deadline cannot be in the past")
    def is_overdue(self) -> bool:
        return datetime.now(timezone.utc) > self.due_date
    def time_remaining(self) -> timedelta:
        return max(
            timedelta(0),
            self.due_date - datetime.now(timezone.utc)
        )
    def is_approaching(
        self, warning_threshold: timedelta = timedelta(days=1)
    ) -> bool:
        return timedelta(0) < self.time_remaining() <= warning_threshold 

这些值对象有助于约束任务状态、优先级和截止日期的可能值,确保数据完整性并提供这些属性的语义意义。

这里有一些使用这些值对象的Task实体的示例:

# Create a new task
task = Task(
    title="Complete project proposal",
    description="Draft and review the proposal for the 
                 new client project",
    priority=Priority.HIGH
)
# Check task properties
print(task.title)     # "Complete project proposal"
print(task.priority)  # Priority.HIGH
print(task.status)    # TaskStatus.TODO 

在建立我们的核心Task实体结构和其支持值对象之后,让我们探索如何通过纳入管理任务行为和保持数据一致性的业务规则来增强这些基础。

在实体中封装业务规则

在实现领域实体时,强制执行业务规则至关重要,以确保实体始终保持在有效状态。业务规则,通常称为不变性,是领域中对实体定义的基本要素。实体应封装直接适用于它们的业务规则。

让我们在Task实体中添加一些基本业务规则:

@dataclass
class Task(Entity):
    # ... previous attributes ...
    def start(self) -> None:
        if self.status != TaskStatus.TODO:
            raise ValueError(
                "Only tasks with 'TODO' status can be started")
        self.status = TaskStatus.IN_PROGRESS
    def complete(self) -> None:
        if self.status == TaskStatus.DONE:
            raise ValueError("Task is already completed")
        self.status = TaskStatus.DONE
    def is_overdue(self) -> bool:
        return self.due_date is not None and self.due_date.is_overdue() 

现在,让我们探索这些业务规则在实际中的工作方式。以下示例演示了Task实体如何强制执行其不变性并保持其内部一致性:

from datetime import datetime, timedelta
# Create a task
task = Task(
    title="Complete project proposal",
    description="Draft and review the proposal for the 
                 new client project",
    due_date=Deadline(datetime.now(timezone.utc) + timedelta(days=7)),
    priority=Priority.HIGH
)
# Start the task
task.start()
print(task.status)  # TaskStatus.IN_PROGRESS
# Complete the task
task.complete()
print(task.status)  # TaskStatus.DONE
# Try to start a completed task
try:
    task.start()  # This will raise a ValueError
except ValueError as e:
    print(str(e))  # "Only tasks with 'TODO' status can be started"
# Check if the task is overdue
print(task.is_overdue())  # False 

这些方法强制执行以下业务规则:

  • 只有在任务处于TODO状态时,才能开始任务

  • 完成的任务不能再次完成

  • 任务根据其截止日期知道是否已过期

通过将这些规则封装在实体中,我们确保Task实体始终遵循我们领域的核心业务规则,无论它在应用程序中的使用方式如何。

区分实体级规则和领域级规则

虽然我们实现的规则适用于Task实体,但并非所有业务规则都属于实体级别。例如,考虑以下规则:“用户一次不能有超过五个高优先级任务。”这个规则涉及多个任务和可能的用户设置,因此它不属于Task实体。

这些规则更适合在领域服务或应用层用例中实现。我们将在本章后面的实现领域服务部分探讨如何实现这些高级规则。

通过以这种方式构建我们的实体,我们保持了实体特定规则和更广泛领域规则之间的清晰分离,遵循 Clean Architecture 原则,并保持我们的实体专注且易于维护。

Clean Architecture 中的值对象

在概念上介绍了值对象之后,让我们来检查它们在我们任务管理系统中的具体实现。我们已经创建了几个关键值对象:

  • TaskStatus:表示任务的当前状态(例如,待办进行中完成

  • Priority:表示任务的优先级(例如,

  • Deadline:表示任务的截止日期和时间,具有额外的行为,例如检查是否已过期

除了已经讨论的概念性好处之外,我们的实现展示了在 Clean Architecture 中的具体优势:

  • 不可变性:一旦创建,其状态不能更改。这有助于防止错误并使我们的代码更容易推理。

  • 基于属性的相等性:具有相同属性的值对象被认为是相等的,而具有唯一身份的实体则不同。

  • 封装领域概念:它们将领域思想作为我们代码中的第一类公民,提高了表达性。

  • 防止原始依赖:它们用具有语义意义和类型安全性的方式替换了用于表示领域概念的原始类型。

  • 简化测试:值对象易于创建和使用于测试中,提高了我们系统的可测试性。

考虑使用字符串表示任务状态与使用 TaskStatus 枚举之间的区别:

# Using string (problematic)
task = Task("Complete project", "The important project")
task.status = "Finished"  # Allowed, but invalid
print(task.status == "done")  # False, case-sensitive
# Using TaskStatus enum (robust)
task = Task("Complete project", "The important project")
task.status = TaskStatus.DONE  # Type-safe
print(task.status == TaskStatus.DONE)  # True, no case issues 

Python 对轻量级值对象(如枚举)的支持以及现代 IDE 功能增强了开发者的体验,使得实现真正反映领域模型的 Clean Architecture 更加容易。

实现领域服务

虽然许多业务规则可以封装在实体和值对象中,但有些规则或操作涉及多个实体或复杂的逻辑,这些逻辑并不自然地适合于单个实体。对于这些情况,我们可以将所需的逻辑封装到领域服务中。让我们实现一个简单的 TaskPriorityCalculator 服务:

class TaskPriorityCalculator:
    @staticmethod
    def calculate_priority(task: Task) -> Priority:
        if task.is_overdue():
            return Priority.HIGH
        elif (
            task.due_date and task.due_date.time_remaining() <=
            timedelta(days=2)
        ):
            return Priority.MEDIUM
        else:
            return Priority.LOW 

这个领域服务封装了根据任务截止日期计算任务优先级的逻辑。这是一个无状态的运算,不属于任何特定实体,但仍然是我们的领域逻辑的重要组成部分。

通过以这种方式实现我们的领域模型,我们创建了一个丰富、表达性强的 Python 类集合,准确地代表了我们的任务管理领域。这些类封装了基本业务规则,确保我们的核心领域逻辑保持一致性和良好的组织结构。

在当前状态下,我们的应用程序可能组织如下(完整代码可在 GitHub 上找到:github.com/PacktPublishing/Clean-Architecture-with-Python):

图 4.3:实现领域组件的待办事项应用结构

图 4.3:实现领域组件的待办事项应用结构

在下一节中,我们将探索更多高级领域概念,在此基础上构建一个全面的领域模型,充分利用 DDD 在我们的 Clean Architecture 实现中的力量。

使用聚合和工厂增强领域模型

在确立了我们的核心实体、值对象和领域服务之后,我们现在将注意力转向更高级的领域概念。这些概念将帮助我们创建一个更健壮和灵活的领域模型,进一步增强我们的 Clean Architecture 实现。

DDD 模式

DDD 提供了几个高级模式,可以帮助我们管理领域模型的复杂性并保持一致性。让我们探索一些这些模式以及它们如何应用于我们的任务管理系统。

聚合

聚合是 DDD 中一个关键的模型,用于维护一致性并在领域内定义事务边界。聚合是一组被视为单一数据变更单元的领域对象。每个聚合都有一个根和一个边界。根是聚合中包含的单一、特定实体,边界定义了聚合内部的内容。

在我们的任务管理系统中,一个自然的聚合将是一个包含多个任务的工程。让我们来实现这个:

# TodoApp/todo_app/domain/entities/project.py
from dataclasses import dataclass, field
from typing import Optional
from uuid import UUID
@dataclass
class Project(Entity):
    name: str
    description: str = ""
    _tasks: dict[UUID, Task] = field(default_factory=dict, init=False)
    def add_task(self, task: Task) -> None:
        self._tasks[task.id] = task
    def remove_task(self, task_id: UUID) -> None:
        self._tasks.pop(task_id, None)
    def get_task(self, task_id: UUID) -> Optional[Task]:
        return self._tasks.get(task_id)
    @property
    def tasks(self) -> list[Task]:
        return list(self._tasks.values()) 

在这个实现中,Project作为聚合根。它封装了维护聚合一致性的操作,例如添加、删除或获取任务。

Project的使用方式如下:

from datetime import datetime
# Project usage
project = Project("Website Redesign")
task1 = Task(
    title="Design homepage",
    description="Create new homepage layout",
    due_date=Deadline(datetime(2023, 12, 31)),
    priority=Priority.HIGH,
)
task2 = Task(
    title="Implement login",
    description="Add user authentication",
    due_date=Deadline(datetime(2023, 11, 30)),
    priority=Priority.MEDIUM,
)
project.add_task(task1)
project.add_task(task2)
print(f"Project: {project.name}")
print(f"Number of tasks: {len(project.tasks)}")
print(f"First task: {project.tasks[0].title}") 

关于这个聚合的关键点如下:

  • 封装Project控制对其任务的访问。外部代码不能直接修改任务集合。

  • 一致性add_taskremove_task等方法确保聚合保持一致状态。

  • 身份:虽然单个Task实体有其自己的全局身份(UUIDs),但在Project的上下文中,它们也通过其与项目的关联来识别。这意味着Project除了使用全局 ID 外,还可以使用项目特定的概念(如顺序或位置)来管理任务。

  • 事务边界:任何影响列表中多个任务的操作(如标记所有任务为完成)都应该通过Project来完成,以确保一致性。

  • 不变性Project可以强制执行适用于整个集合的不变性。例如,我们可以添加一个方法来确保列表中的两个任务没有相同的标题。

使用这样的聚合可以帮助我们通过将相关的实体和值对象分组到统一的单元中来管理复杂的领域。这不仅简化了我们的领域模型,还有助于保持数据完整性和一致性。

在设计聚合时,考虑性能影响是很重要的。聚合应该设计得尽可能小,同时保持一致性。在我们的例子中,如果项目变得过大,我们可能需要考虑分页或懒加载策略来访问任务。

通过将项目实现为一个聚合,我们创建了一个强大的抽象,它封装了管理多个任务的复杂性。这与 Clean Architecture 原则完美契合,因为它允许我们以清晰、封装的方式表达复杂的领域规则和关系。

工厂模式

在传统的面向对象编程中,工厂模式常用于封装对象创建逻辑。然而,现代 Python 特性在很多情况下减少了独立工厂的需求。让我们探讨 Python 的语言特性如何处理对象创建,以及何时工厂可能仍然有用。

数据类和对象创建

我们的Task实体,作为dataclass类型实现,已经提供了一种干净且高效的方式来创建对象:

@dataclass
class Task(Entity):
    title: str
    description: str
    due_date: Optional[Deadline] = None
    priority: Priority = Priority.MEDIUM
    status: TaskStatus = field(default=TaskStatus.TODO, init=False) 

这个dataclass定义自动生成一个__init__方法,处理传统工厂可能做的大部分工作。它设置默认值,管理可选参数,并确保类型一致性(当使用类型检查器时)。

使用 Python 特性扩展对象创建

对于更复杂的初始化场景,Python 提供了一些惯用的方法:

  • 类方法作为替代构造函数
@dataclass
class Task(Entity):
    # ... existing attributes ...
    @classmethod
    def create_urgent_task(cls, title: str, description: str,
                           due_date: Deadline):
        return cls(title, description, due_date, Priority.HIGH) 
  • 使用dataclass__post_init__特性进行复杂初始化
@dataclass
class Task(Entity):
    # ... existing attributes ...

    def __post_init__(self):
        if not self.title.strip():
            raise ValueError("Task title cannot be empty")
        if len(self.description) > 500:
            raise ValueError(
                "Task description cannot exceed 500 characters") 

这些方法允许进行更复杂的对象创建逻辑,同时保持数据类的优势。

当传统工厂可能仍然适用时

尽管有这些 Python 特性,但在某些场景下,独立的工厂可能仍然是有益的:

  • 复杂对象图:当创建一个对象需要与其他对象建立关系或执行复杂计算时

  • 依赖注入:当创建过程需要外部依赖项,而这些依赖项您希望与实体本身保持分离时

  • 多态创建:当您需要根据运行时条件创建不同的子类时

这里有一个可能适合使用工厂的例子:

class TaskFactory:
    def __init__(self, user_service, project_repository):
        self.user_service = user_service
        self.project_repository = project_repository
    def create_task_in_project(self, title: str, description: str,
                               project_id: UUID, assignee_id: UUID):
        project = self.project_repository.get_by_id(project_id)
        assignee = self.user_service.get_user(assignee_id)
        task = Task(title, description)
        task.project = project
        task.assignee = assignee

        if project.is_high_priority() and assignee.is_manager():
            task.priority = Priority.HIGH
        project.add_task(task)
        return task 

在这种情况下,工厂封装了在项目上下文中创建任务的复杂逻辑,包括依赖于项目和用户状态的业务规则。

通过理解这些模式及其应用时机,我们可以创建一个更具表达性和可维护性的域模型,同时利用 Python 的优势,并符合整洁架构原则。

确保域独立性

域层独立性是整洁架构的基石,直接关联到我们在第一章中首次介绍的依赖规则。这个规则指出,依赖项应仅指向域层内部,这对于保持我们核心业务逻辑的纯净性和灵活性至关重要。在本节中,我们将探讨此规则的实践应用以及确保域独立性的策略。

实践中的依赖规则

让我们通过一些示例来考察依赖规则如何应用于我们的任务管理系统,这些示例突出了常见的违规行为及其纠正方法。

示例 1

带有数据库依赖的任务实体:

@dataclass
class TaskWithDatabase:
    title: str
    description: str
    db: DbConnection  # This violates the Dependency Rule
    due_date: Optional[Deadline] = None
    priority: Priority = Priority.MEDIUM
    status: TaskStatus = field(default=TaskStatus.TODO, init=False)
    def mark_as_complete(self):
        self.status = TaskStatus.DONE
        self.db.update(self) # This violates the Dependency Rule 

在这个例子中,TaskWithDatabase类通过直接依赖于数据库连接违反了依赖规则。db属性和mark_as_complete中的update调用将外部关注点引入我们的域实体。

示例 2

带有 UI 依赖的项目聚合:

@dataclass
class ProjectWithUI(Entity):
    name: str
    ui: UiComponent  # Violates the Dependency Rule
    description: str = ""
    _tasks: dict[UUID, Task] = field(default_factory=dict, init=False)
    def add_task(self, task: Task):
        self._tasks[task.id] = task
        self.ui.refresh()  # Violates the Dependency Rule 

在这里,ProjectWithUI错误地依赖于 UI 组件,将展示关注点与域逻辑混合在一起。

这些示例不仅违反了依赖规则,还违反了 SOLID 原则中的单一职责原则SRP)。TaskWithDatabase类负责任务管理和数据库操作,而ProjectWithUI则处理项目管理和 UI 更新。这些违规行为损害了我们的域层的独立性和专注性,使其更难以灵活、测试和维护。

通过消除这些外部依赖并遵循 SRP(单一职责原则),我们创建了专注于核心业务概念和规则的纯领域实体。这种方法确保了我们的领域层成为应用程序的稳定核心,不受外部系统、数据库或用户界面变化的影响。

在下一节中,我们将探讨避免外部依赖并保持领域层纯洁性的策略。

避免外部依赖

为了保持我们领域层的纯洁性和独立性,我们需要警惕避免对外部框架、数据库或 UI 组件的依赖。一个关键策略是使用外部关注点的抽象。让我们看看这在我们的任务管理系统中的实际应用。

首先,让我们在领域层定义一个抽象的 TaskRepository

# In the Domain layer :
# (e.g., todo_app/domain/repositories/task_repository.py)
from abc import ABC, abstractmethod
from todo_app.domain.entities.task import Task
class TaskRepository(ABC):
    @abstractmethod
    def save(self, task: Task):
        pass
    @abstractmethod
    def get(self, task_id: str) -> Task:
        pass 

这个抽象类定义了任务持久化的契约,而不指定任何实现细节。它属于领域层,代表任何任务存储机制必须实现的接口。

现在,让我们看看领域服务如何使用这个存储库:

# In the Domain layer (e.g., todo_app/domain/services/task_service.py)
from todo_app.domain.entities.task import Task
from todo_app.domain.repositories.task_repository import TaskRepository
class TaskService:
    def __init__(self, task_repository: TaskRepository):
        self.task_repository = task_repository
    def create_task(self, title: str, description: str) -> Task:
        task = Task(title, description)
        self.task_repository.save(task)
        return task
    def mark_task_as_complete(self, task_id: str) -> Task:
        task = self.task_repository.get(task_id)
        task.complete()
        self.task_repository.save(task)
        return task 

这个 TaskService 展示了领域逻辑如何与持久化抽象交互,而不了解实际的存储机制。

TaskRepository 的具体实现将位于外层,例如基础设施层:

# In an outer layer
# .../infrastructure/persistence/sqlite_task_repository.py
from todo_app.domain.entities.task import Task
from todo_app.domain.repositories.task_repository import TaskRepository
class SQLiteTaskRepository(TaskRepository):
    def __init__(self, db_connection):
        self.db = db_connection
    def save(self, task: Task):
        # Implementation details...
        pass
    def get(self, task_id: str) -> Task:
        # Implementation details...
        pass 

这种结构展示了依赖规则的实际应用:

  • 领域层(TaskRepositoryTaskService)定义和使用抽象,而不了解具体的实现

  • 基础设施层(SQLiteTaskRepository)实现了领域层定义的抽象

  • 依赖关系的流向是向内的;基础设施层依赖于领域层的抽象,反之则不然

  • 我们的领域层保持独立于特定的数据库技术或其他外部关注点

  • 我们可以轻松地将 SQLite 替换为另一个数据库或存储机制,而无需修改领域层

通过遵循依赖规则,我们确保我们的领域层成为应用程序的稳定核心,不受外部系统或技术变化的影响。这种分离使我们能够独立地发展系统的不同部分,从而简化测试、维护和对变化需求的适应。

例如,如果我们决定从 SQLite 切换到 PostgreSQL,我们只需在基础设施层创建一个新的 PostgreSQLTaskRepository,实现 TaskRepository 接口。领域层,包括我们的 TaskService,将保持不变。

这种构建代码的方法不仅保持了领域层的纯洁性,还提供了对未来更改的灵活性和测试的便利性,这些都是清洁架构的关键优势。

领域层独立性和可测试性

领域层的独立性显著增强了可测试性。通过将领域逻辑与基础设施关注点分离,我们可以轻松地对核心业务规则进行单元测试,而无需复杂的设置或外部依赖。

当我们的领域层独立时,我们可以做以下事情:

  • 编写快速运行的单元测试,无需数据库设置或网络连接

  • 在隔离状态下测试我们的业务逻辑,无需担心 UI 或持久化层的复杂性

  • 对于任何外部依赖,使用简单的存根或模拟,将我们的测试重点放在业务逻辑本身

这种独立性使得我们的测试更加可靠,运行速度更快,且更容易维护。我们将在第八章中更深入地探讨测试。

向更纯净的领域模型重构

维护一个纯净的领域模型是一个持续的过程,需要警觉和定期的重构。随着我们对领域理解的不断深入以及我们在开发中面临实际约束,我们的初始实现可能会偏离理想状态。这是软件开发过程中的一个自然部分。关键是我们必须保持勤奋,审查和改进我们的领域模型,认识到它们对我们应用程序的基础重要性。

两个关键因素推动了重构的需求:

  • 演进领域理解:随着我们与利益相关者合作并深入了解业务领域,我们经常发现我们的初始模型需要调整以更好地反映现实。

  • 实际妥协:有时,为了满足截止日期或在工作现有约束下,我们可能需要做出妥协,将非领域关注点引入我们的模型。虽然这些妥协在短期内可能是必要的,但重要的是要重新审视并解决它们,以保持我们应用程序的长期健康。

让我们探讨一些维护和重构以实现更纯净领域模型的战略:

  • 定期进行代码审查:重点关注识别任何违反依赖规则或引入非领域关注点的情况。

  • 持续重构:随着你对领域理解的不断演进,持续重构你的领域模型以更好地反映这种理解。

  • 警惕框架:抵制在领域层使用方便的框架功能的诱惑。短期内的开发速度提升往往会导致长期的可维护性和灵活性方面的痛苦。

  • 使用领域驱动设计(DDD)模式:如实体、值对象和聚合等模式有助于保持你的领域模型专注且纯净。

  • 优先考虑显式性而非隐式性:避免隐含调用外部服务的魔法行为。使依赖和行为显式化。

以下是一个维护领域纯净性的重构示例:

from dataclasses import dataclass, field
from typing import Optional
# Before refactoring
@dataclass
class Task(Entity):
    title: str
    description: str
    due_date: Optional[Deadline] = None
    priority: Priority = Priority.MEDIUM
    status: TaskStatus = field(default=TaskStatus.TODO, init=False)
    def mark_as_complete(self):
        self.status = TaskStatus.DONE
        # Sending an email notification - this violates domain purity
        self.send_completion_email()
    def send_completion_email(self):
        # Code to send an email notification
        print(f"Sending email: Task '{self.title}' has been completed.") 

在先前的版本中,有迹象表明Task实体可能实现了过多的行为,违反了 SOLID 原则中的 SRP(单一职责原则)。

这是重构后的版本:

# After refactoring
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Task(Entity):
    title: str
    description: str
    due_date: Optional[Deadline] = None
    priority: Priority = Priority.MEDIUM
    status: TaskStatus = field(default=TaskStatus.TODO, init=False)
    def mark_as_complete(self):
        self.status = TaskStatus.DONE
        # No email sending here;
        # this is now the responsibility of an outer layer
class TaskCompleteNotifier(ABC):
    @abstractmethod
    def notify_completion(self, task):
        pass
# This would be implemented in an outer layer
class EmailTaskCompleteNotifier(TaskCompleteNotifier):
    def notify_completion(self, task):
        print(f"Sending email: Task '{task.title}' has been completed.") 

在重构版本中,我们做了以下几件事情:

  • 我们已经从Task实体中移除了send_completion_email方法。发送通知不是任务的核心职责,而应该在更外层处理。

  • 我们引入了一个抽象的TaskCompleteNotifier类。这个类的实际实现(例如,发送电子邮件)将在外层完成。这允许我们在领域模型中保持关于任务完成的通知概念,而不包括通知如何发生的细节。

这些更改保持了我们的领域模型纯净,并专注于核心业务概念和规则。现在Task实体只关注任务是什么以及其基本行为,而不是如何发送电子邮件或与系统时钟交互。

这个例子演示了我们可以如何重构我们的领域模型,以去除非领域关注点,并使其更易于测试和维护。它还展示了我们可以如何使用抽象(如TaskCompleteNotifier)来表示领域概念,而不在我们的领域层中包含实现细节。

通过定期审查和重构我们的领域模型,我们确保它始终是我们业务领域的真实反映,不受外部因素的干扰。这个持续的过程对于维护我们整洁架构的实施完整性和应用程序的长期可维护性至关重要。

记住,目标不是一开始就追求完美,而是持续改进。每次重构步骤都让我们更接近一个更干净、更易于表达的领域模型,它为我们的整个应用程序提供了一个坚实的基础。

总之,保持领域概念与外部框架和系统的独立性对于有效的整洁架构至关重要。通过使用如TaskRepository接口这样的抽象,并遵守依赖规则,我们确保我们的领域层专注于核心业务逻辑。这种方法在领域和外部关注之间创造了清晰的边界,允许在不影响核心业务规则的情况下进行基础设施更改。通过依赖倒置和仔细的接口设计,我们创建了一个强大、灵活的基础,它可以适应不断变化的需求,同时保持我们核心领域模型的完整性。

摘要

在这一章中,我们深入探讨了整洁架构的核心:实体层,也称为领域层。我们探讨了如何使用 DDD 原则来识别、建模和实现核心业务概念。

我们从分析业务需求和为我们的任务管理系统定义一个通用语言开始。然后我们检查了关键领域驱动设计(DDD)概念,如实体、值对象和边界上下文,并看到它们如何与整洁架构原则相一致。

接下来,我们在 Python 中实现了这些概念,创建了丰富的领域实体,如 Task,以及值对象,如 PriorityDeadline。我们在这些实体中封装业务规则,确保它们在更广泛的应用中使用时保持其完整性。

最后,我们专注于确保实体层的独立性,探索避免外部依赖并保持核心领域逻辑与基础设施关注点之间清晰边界的策略。

通过应用这些原则,我们为我们的清洁架构实现创建了一个稳健的基础。这个实体层(专注于业务逻辑且不受外部关注)将作为我们应用构建的稳定核心。

在下一章中,我们将探讨应用层,我们将看到如何编排我们的领域对象以实现特定的用例,同时保持我们在实体层中建立的关注点分离。

进一步阅读

第五章:应用层:编排用例

第四章中,我们开发了任务管理系统的领域层,并实现了封装核心业务规则的实体、值对象和领域服务。虽然这为我们提供了一个坚实的基础,但仅有业务规则并不能构成一个可用的应用程序。我们需要一种协调这些领域对象以满足创建任务、管理项目和处理通知等用户需求的方法。这正是应用层发挥作用的地方。

应用层在我们干净的架构交响乐中扮演着指挥家的角色。它协调领域对象和外部服务以完成特定的用例,同时保持我们业务规则与外部世界之间的严格边界。通过正确实现这一层,我们创建的应用不仅功能强大,而且易于维护和适应变化。

在本章中,我们将以任务管理系统为例,探讨如何实现有效的应用层。我们将看到如何创建编排领域对象的用例,同时保持清晰的架构边界。您将学习如何实现请求和响应模型,以明确定义用例边界,以及如何管理对外部服务的依赖,而不会损害架构的完整性。

在本章中,我们将涵盖以下主要主题:

  • 理解应用层的角色

  • 实现用例交互器

  • 定义请求和响应模型

  • 保持对外部关注点的分离

技术要求

本章和本书其余部分展示的代码示例均使用 Python 3.13 进行测试。为了简洁,除了缺少日志语句外,本章的一些代码示例仅部分实现。所有示例的完整版本可以在本书配套的 GitHub 仓库github.com/PacktPublishing/Clean-Architecture-with-Python中找到。

理解应用层的角色

应用层作为一个薄层,协调我们的领域对象和服务以完成有意义的用户任务。虽然我们的领域模型提供了构建块,如任务、项目、截止日期,但将这些部件组装成有用功能的正是应用层。

应用层还承担另一个关键功能:信息隐藏。在第四章中,我们看到了领域实体如何隐藏它们的内部状态和实现细节。应用层将这一原则扩展到架构边界,从领域隐藏基础设施细节,从外部接口隐藏领域复杂性。这种有意的隐藏信息使得创建端口、适配器和请求/响应模型所付出的额外努力变得值得。通过通过精心设计的接口仅暴露必要的信息,我们创建了一个组件可以独立进化但又能无缝协作的系统。

图 5.1:应用层和任务管理

图 5.1:应用层和任务管理

图 5.1中,我们说明了应用层如何在 Clean Architecture 的同心层中定位。它充当领域层(我们的核心业务实体如任务和项目所在)和系统外层之间的调解者。通过封装编排领域实体的用例,应用层维护了依赖规则:外层依赖于内层,内层不受外层变化的影响。

应用层有几个不同的职责:

  • 用例编排

    • 协调领域对象以完成用户任务

    • 管理操作序列

    • 确保业务规则得到正确应用

  • 错误处理和验证

    • 在输入到达领域对象之前进行验证

    • 捕获和翻译领域错误

    • 提供一致的错误响应

  • 事务管理

    • 确保在需要时操作是原子的

    • 维护数据一致性

    • 处理失败时的回滚

  • 边界转换

    • 将外部数据格式转换为领域格式

    • 将领域对象转换为外部展示格式

    • 管理跨边界通信

这些责任共同工作,创建一个健壮的编排层,在保持清晰的边界的同时确保可靠的应用行为。

使用结果类型的错误处理

在深入我们的实现模式之前,理解我们应用层的一个基本概念至关重要:结果类型的使用。这个模式构成了我们错误处理策略的骨干,提供了对成功和失败的明确处理,而不是仅仅依赖于异常。这种方法提供了几个好处:

  • 在函数签名中使成功/失败路径明确

  • 在整个应用中提供一致的错误处理

  • 通过翻译领域错误来维护清晰的架构边界

  • 提高可测试性和错误处理的可预测性

首先,我们定义一个标准化的Error类来表示所有应用层错误:

class ErrorCode(Enum):
    NOT_FOUND = "NOT_FOUND"
    VALIDATION_ERROR = "VALIDATION_ERROR"
    # Add other error codes as needed
@dataclass(frozen=True)
class Error:
    """Standardized error information"""
    code: ErrorCode
    message: str
    details: Optional[dict[str, Any]] = None
    @classmethod
    def not_found(cls, entity: str, entity_id: str) -> Self:
        return cls(
            code=ErrorCode.NOT_FOUND,
            message=f"{entity} with id {entity_id} not found"
        )

    @classmethod
    def validation_error(cls, message: str) -> Self:
        return cls(
            code=ErrorCode.VALIDATION_ERROR,
            message=message
        ) 

接下来,我们定义一个Result类,它封装了成功值或错误:

@dataclass(frozen=True)
class Result:
    """Represents success or failure of a use case execution"""
    value: Any = None 
    error: Optional[Error] = None

    @property
    def is_success(self) -> bool:
        return self.error is None
    @classmethod
    def success(cls, value: Any) -> Self:
        return cls(value=value)

    @classmethod
    def failure(cls, error: Error) -> Self:
        return cls(error=error) 

使用结果类型可以干净地协调领域操作,如下面的使用示例所示:

try:
    project = find_project(project_id)
    task = create_task(task_details)
    project.add_task(task)
    notify_stakeholders(task)
    return Result.success(TaskResponse.from_entity(task))
except ProjectNotFoundError:
    return Result.failure(Error.not_found("Project", str(project_id)))
except ValidationError as e:
    return Result.failure(Error.validation_error(str(e))) 

上面的使用示例展示了结果模式的一些关键优势:

  • 清晰的错误路径:注意错误情况是如何通过Result.failure()统一处理的,无论底层错误类型如何,都提供了一个一致的接口。

  • 显式领域转换:从领域特定错误(ProjectNotFoundError)到应用级错误的转换在边界处干净地发生。

  • 自包含上下文Result对象封装了结果和任何错误上下文,使得函数的行为从其返回值完全清晰。

  • 测试清晰度:该示例通过检查结果的状态而不是尝试捕获异常,使得测试成功和失败情况变得容易。

Clean Architecture 中的错误处理边界

在实现应用层的错误处理时,我们明确地只捕获和转换预期的领域和业务错误为结果。因此,我们没有与预期错误配对的except Exception:子句。这种分离保持了清晰的架构边界。如全局错误处理等问题仍保留在外层。

应用层模式

要了解应用层如何管理其职责,让我们考察数据是如何通过我们的架构流动的:

图 5.2:Clean Architecture 中的请求/响应流程

图 5.2:Clean Architecture 中的请求/响应流程

图 5.2所示,该流程演示了几个关键模式协同工作。一个请求通过接口适配器层进入,并由我们应用层的数据传输对象DTOs)处理,这些对象验证并转换输入为领域可以处理的格式。用例随后协调领域操作,与这些验证后的输入一起与领域对象交互,并通过端口与外部服务协调。用例返回的结果封装了成功(带有响应 DTO)或失败(带有错误),接口适配器层可以将其直接映射到适当的 HTTP 响应。现在不必担心理解图 5.2中的所有离散组件;我们将在本章的其余部分详细讨论它们。

这种编排的交互依赖于三个基础模式协同工作,以保持清晰的架构边界:

  • 用例交互器:这些作为主要协调者,在管理事务和协调领域对象的同时,实现特定的业务操作。它们确保每个操作都集中精力,其执行是一致的。

  • 接口边界:在我们的应用层及其依赖的服务之间建立清晰的合同。

  • 依赖反转:通过这些边界实现灵活的实现和简单的测试,确保我们的核心业务逻辑与外部关注点解耦。

初始时,我们的用例将使用简单的参数并返回基本的数据结构。随着我们的应用程序增长,我们将引入更复杂的模式来处理跨越我们架构边界的跨层数据。这种进化帮助我们保持层之间的清晰分离,同时使我们的代码能够适应变化。

这些模式与我们在第二章中探讨的 SOLID 原则自然一致。用例通过将每个操作集中在特定的目标上体现了单一职责原则。接口定义通过定义专注的、客户端特定的合同支持接口分离。

为进化做准备

应用程序很少保持静态——成功者不可避免地会扩大范围和复杂性。最初可能只是一个简单的任务管理系统,可能需要进化以支持多个团队,与各种外部服务集成,或处理复杂的流程自动化。我们探索的应用层模式以最小的摩擦实现了这种进化。

让我们通过实际场景来考察我们的任务管理系统如何成长:

  • 用例可扩展性

    • 将任务通知从电子邮件扩展到包括 Slack 或类似的通信平台

    • 将单个用例,如分配任务设置截止日期,组合成更高级的操作,例如冲刺计划

  • 清理依赖

    • 从本地文件存储附件开始,然后无缝添加通过相同界面的 S3 支持

    • 在不修改用例代码的情况下,将数据库引擎从 SQLite 切换到 PostgreSQL

  • 一致的边界

    • 在新的 API 版本(v1 与 v2)之间处理请求对象中的数据转换,同时重用相同的底层用例代码

    • 为不同的客户端(移动、Web、CLI)实现不同的响应转换器,同时共享相同的核心业务逻辑

这个架构基础让我们有信心地进化我们的系统。当营销团队要求 Salesforce 集成,或者当合规性要求审计日志时,这些功能可以添加而不会破坏现有功能或损害架构完整性。

在下一节中,我们将探讨如何在 Python 中实现这些概念,创建遵守清洁架构原则的健壮用例交互器。

实现用例交互器

在探讨了应用层的理论基础之后,我们现在转向实际实施。用例交互器是实现特定业务规则的实体类。术语交互器强调了它们在与系统各个部分交互和协调中的作用。虽然领域层定义了业务规则是什么,但交互器定义了如何以及何时根据特定的应用需求应用这些规则。在 Python 中,我们可以以既清晰又富有表现力的方式实现这些交互器。

使用案例的结构化

一个设计良好的用例交互器在协调领域对象的同时,保持清晰的架构边界。让我们看看这是如何实现的:

@dataclass(frozen=True)
class CompleteTaskUseCase:
    """Use case for marking a task as complete and notifying 
    stakeholders"""
    task_repository: TaskRepository
def execute(
        self,
        task_id: UUID,
        completion_notes: Optional[str] = None
    ) -> Result:
... 

首先,从用例的外部结构来看,我们可以看到一些关键组件。依赖接口被注入,并且类有一个公开的execute方法,该方法返回一个Result对象。

接下来,让我们检查execute方法:

def execute(
        self,
        task_id: UUID,
        completion_notes: Optional[str] = None
    ) -> Result:
        try:
            # Input validation
            task = self.task_repository.get(task_id)
            task.complete(
                notes=completion_notes
            )
            self.task_repository.save(task)

            # Return simplified task data
            return Result.success({
                "id": str(task.id),
                "status": "completed",
                "completion_date": task.completed_at.isoformat()
            })

        except TaskNotFoundError:
            return Result.failure(Error.not_found("Task", str(task_id)))
        except ValidationError as e:
            return Result.failure(Error.validation_error(str(e))) 

在这里,我们可以看到业务规则在Task领域对象上的编排,以实现用例的离散目标:完成任务。

此实现体现了几个关键架构原则:

  • 封装:用例类为特定的业务操作提供了一个清晰的边界。

  • 接口定义execute方法通过使用结果类型提供了一个清晰、专注的接口。结果模式确保我们的接口中成功和失败路径都是明确的,使错误处理成为一等关注点。

  • 错误处理:领域错误被捕获并转换为应用级错误。

  • 依赖注入:依赖项通过构造函数传入,遵循在第二章中引入的依赖倒置原则。

在这些原则中,依赖注入值得特别注意,因为它使我们的架构灵活性成为可能。

依赖注入

之前我们看到了依赖注入如何帮助在我们的用例中保持清晰的架构边界。让我们通过检查如何结构化我们的接口来进一步探讨这一点,以最大化依赖注入的好处,同时确保我们的用例保持灵活和可测试。在 Python 中,我们可以使用抽象基类优雅地实现这一点:

class TaskRepository(ABC):
    """Repository interface defined by the Application Layer"""
    @abstractmethod
    def get(self, task_id: UUID) -> Task:
        """Retrieve a task by its ID"""
        pass

    @abstractmethod
    def save(self, task: Task) -> None:
        """Save a task to the repository"""
        pass

    @abstractmethod
    def delete(self, task_id: UUID) -> None:
        """Delete a task from the repository"""
        pass
class NotificationService(ABC):
    """Service interface for sending notifications"""
    @abstractmethod
    def notify_task_assigned(self, task_id: UUID) -> None:
        """Notify when a task is assigned"""
        pass
    @abstractmethod
    def notify_task_completed(self, task: Task) -> None:
        """Notify when a task is completed"""
        Pass 

通过在应用层定义这些接口,我们加强了我们的架构边界,同时为外部层提供了清晰的实现合同。这种方法提供了超越基本依赖注入的几个高级好处:

  • 接口定义精确表达了应用层所需的内容,不多也不少

  • 抽象方法通过清晰的方法签名和文档字符串来记录预期的行为

  • 应用层在保持对其依赖项控制的同时,保持对其实现的独立性

  • 测试实现可以专注于每个用例的确切需求

遵守此合同的实体实现可能具有如下形式:

class MongoDbTaskRepository(TaskRepository):
    """MongoDB implementation of the TaskRepository interface"""
    def __init__(self, client: MongoClient):
        self.client = client
        self.db = client.task_management
        self.tasks = self.db.tasks

    def get(self, task_id: UUID) -> Task:
        """Retrieve a task by its ID"""
        document = self.tasks.find_one({"_id": str(task_id)})
        if not document:
            raise TaskNotFoundError(task_id)
        # ... remainder of method implementation

    # Other interface methods implemented ... 

此示例演示了外层如何实现由我们的应用层定义的接口,同时处理数据持久化的具体细节,并遵守业务逻辑预期的合同。

处理复杂操作

实际应用场景通常涉及多个步骤和潜在的故障点。让我们看看如何在保持清晰架构原则的同时管理这种复杂性。考虑一个需要协调多个任务的项目完成场景。

CompleteProjectUseCase遵循我们建立的模式:

@dataclass(frozen=True)
class CompleteProjectUseCase:
    project_repository: ProjectRepository
    task_repository: TaskRepository
    notification_service: NotificationService
    def execute(
        self,
        project_id: UUID,
        completion_notes: Optional[str] = None
    ) -> Result:
        ... 

现在,让我们来检查它的execute方法:

def execute(
        self,
        project_id: UUID,
        completion_notes: Optional[str] = None
    ) -> Result:
        try:
            # Validate project exists
            project = self.project_repository.get(project_id)

            # Complete all outstanding tasks
            for task in project.incomplete_tasks:
                task.complete()
                self.task_repository.save(task)
                self.notification_service.notify_task_completed(task)
            # Complete the project itself
            project.mark_completed(
                notes=completion_notes
            )
            self.project_repository.save(project)

            return Result.success({
                "id": str(project.id),
                "status": project.status,
                "completion_date": project.completed_at,
                "task_count": len(project.tasks),
                "completion_notes": project.completion_notes,
            })

        except ProjectNotFoundError:
            return Result.failure(Error.not_found(
                "Project", str(project_id)))
        except ValidationError as e:
            return Result.failure(Error.validation_error(str(e))) 

此实现展示了管理复杂性的几种模式:

  • 协调操作:用例将多个相关操作作为一个单一逻辑单元管理:

    • 完成所有未完成的任务

    • 更新项目状态

    • 通知利益相关者

  • 错误管理:用例提供了全面的错误处理:

    • 捕获并转换特定领域的错误。

    • 考虑每个操作的潜在失败情况。在更复杂的示例中,如果项目更新或保存失败,可能会看到Task保存的回滚。

    • 错误响应一致且信息丰富。

  • 明确依赖:所需服务被明确定义:

    • 定义数据访问的存储库

    • 提供外部通信的通告服务

    • 注入依赖以实现灵活性和测试

  • 输入验证:在处理之前验证参数:

    • 检查所需 ID 是否存在

    • 适当处理可选参数

    • 执行领域规则

  • 事务完整性:对任务和项目的更改被作为一个整体操作处理:

    • 通过仅捕获起始状态并在我们的某个语句失败时回滚,代码示例可以扩展以支持真正的事务性。请参阅书中随附的 GitHub 存储库中的CompleteProjectUseCase代码示例。

通过在应用层持续应用这些模式,我们创建了一个健壮的系统,它优雅地处理复杂操作,同时保持清晰的架构边界和关注点的清晰分离。

定义请求和响应模型

在上一节中,我们的用例直接与原始类型和字典交互。虽然这种方法对于简单情况可能有效,但随着我们应用的扩展,我们需要更多结构化的方式来处理跨越架构边界的跨数据。请求和响应模型服务于这个目的,提供专门的数据传输对象(DTOs),以处理外部层和我们的应用核心之间的数据转换。基于我们之前引入的信息隐藏原则,这些模型将这一概念扩展到架构边界,具体来说,保护我们的领域逻辑免受外部格式细节的影响,同时屏蔽外部接口免受领域实现细节的影响。这种相互的边界保护对于不同接口以不同速度发展尤为重要。

请求模型

请求模型在数据到达我们的应用层用例之前捕获和验证传入的数据。它们为输入数据提供清晰的架构,并执行初步验证:

@dataclass(frozen=True)
class CompleteProjectRequest:
    """Data structure for project completion requests"""
    project_id: str  # From API (will be converted to UUID)
    completion_notes: Optional[str] = None
    def __post_init__(self) -> None:
        """Validate request data"""
        if not self.project_id.strip():
            raise ValidationError("Project ID is required")
        if self.completion_notes and len(self.completion_notes) > 1000:
            raise ValidationError(
                "Completion notes cannot exceed 1000 characters")
    def to_execution_params(self) -> dict:
        """Convert validated request data to use case parameters"""
        return {
            'project_id': UUID(self.project_id),
            'completion_notes': self.completion_notes
        } 

请求模型通过建立外部层和内部层之间清晰的边界,服务于多个架构目的。通过输入验证和to_execution_params方法,它们确保用例仅关注业务逻辑。验证步骤可以提前捕获格式错误的数据,而to_execution_params将 API 友好的格式(如字符串 ID)转换为我们的业务逻辑期望的正确领域类型(如 UUID)。

这种转换能力特别强大,因为它:

  • 保持用例简洁和专注,仅与领域类型交互

  • 将数据转换逻辑集中在一个单一、可预测的位置

  • 允许 API 格式演变而不影响核心业务逻辑

  • 通过提供清晰的格式边界来提高可测试性

当数据通过请求模型流过并到达我们的用例时,它已经被验证并转换为我们的领域逻辑期望的精确格式。这保持了清洁架构的关注点分离,确保外部层实现细节(如 HTTP 请求中 ID 的格式)永远不会泄露到我们的核心业务规则中。

响应模型

响应模型负责将领域对象转换为适合外部消费的结构。它们通过明确控制暴露的领域数据及其格式来维护我们清晰的架构边界:

@dataclass(frozen=True)
class CompleteProjectResponse:
    """Data structure for project completion responses"""
    id: str
    status: str
    completion_date: str
    task_count: int
    completion_notes: Optional[str]
    @classmethod
    def from_entity(cls,
                    project: Project,
                    user_service: UserService
    ) -> 'CompleteProjectResponse':
        """Create response from domain entities"""
        return cls(
            id=str(project.id),
            status=project.status,
            completion_date=project.completed_at,
            task_count=len(project.tasks),
            completion_notes=project.completion_notes,
        ) 

虽然to_execution_params将请求模型中的传入数据转换为符合领域期望的格式,但from_entity通过将领域对象转换为适合跨越边界到适配器层的格式来处理输出旅程。这种对称模式意味着我们的用例可以仅与领域对象交互,同时输入和输出都会自动适应外部需求。

from_entity方法具有几个关键用途:

  • 保护领域对象免受外部层的暴露

  • 精确控制暴露的数据以及数据格式(例如,将 UUID 转换回字符串)

  • 为所有外部接口提供一致的序列化点

  • 允许计算或派生字段(如task_count),而无需修改域对象

  • 包括在基本实体中不存在的不包含计算或聚合数据

  • 通过省略大量无关数据来优化性能

  • 包括特定操作元数据

让我们回顾一下CompleteProjectUseCase的演变版本,以展示请求模型、域逻辑和响应模型是如何协同工作的:

@dataclass(frozen=True)
class CompleteProjectUseCase:
    project_repository: ProjectRepository
    task_repository: TaskRepository
    notification_service: NotificationService
    # Using CompleteProjectRequest vs discreet parameters
    def execute(self, request: CompleteProjectRequest) -> Result:
        try:
            params = request.to_execution_params()
            project = self.project_repository.get(params["project_id"])
            project.mark_completed(notes=params["completion_notes"])
            # Complete all outstanding tasks
            # ... Truncated for brevity 
            self.project_repository.save(project)
            # using CompleteProjectResponse vs handbuilt dict
            response = CompleteProjectResponse.from_entity(project)
            return Result.success(response)
        except ProjectNotFoundError:
            return Result.failure(
                Error.not_found("Project", str(params["project_id"]))
            )
        except ValidationError as e:
            return Result.failure(Error.validation_error(str(e))) 

此示例演示了我们的用例如何纯粹专注于编排域逻辑,而请求和响应模型则处理在架构边界处必要的转换。用例接收一个已经验证的请求,在其执行过程中使用适当的域类型,并返回一个包含在Result对象中的响应模型,该对象可以被任何外层实现消费。

在接口适配器层,这些响应模型可以被各种组件消费,包括处理 HTTP 请求的控制器、命令行界面命令处理器或消息队列处理器。每个适配器都可以根据其特定的传输机制适当地转换响应数据,将其转换为 HTTP 上的 JSON、控制台输出或所需的消息有效负载。

保持与外部服务的分离

虽然请求和响应模型处理 API 表面的数据转换,但我们的应用程序还必须与外部服务(如电子邮件系统、文件存储和第三方 API)交互。应用程序层通过端口与这些服务保持分离——端口定义了应用程序所需的确切功能,而不指定实现细节。在我们的任务管理系统,外部服务可能包括:

  • 用于发送通知的电子邮件服务(例如 SendGrid 或 AWS SES)

  • 用于附件的文件存储系统(例如 AWS S3 或 Google Cloud Storage)

  • 认证服务(例如 Auth0 或 Okta)

  • 日历集成服务(例如 Google 日历或 Microsoft Outlook)

  • 外部消息系统(例如 Slack 或 Microsoft Teams)

虽然请求/响应模型和端口都旨在维护清晰的架构边界,但它们处理系统与外部世界交互的不同方面。请求/响应模型处理 API 边界的数据转换,遵循所有用例之间的一致接口(例如,from_entityto_execution_params),以确保统一的数据处理。

相比之下,端口定义了应用程序层依赖的服务接口,每个端口都是专门设计来表示特定外部服务功能的。这种双重方法确保我们的核心业务逻辑独立于数据格式细节和外部实现的具体细节。

接口边界

端口允许应用层精确指定它需要从外部服务中获取哪些功能,而不需要与特定实现绑定。让我们看看这些边界机制是如何协同工作的:

# Port: Defines capability needed by Application Layer
class NotificationPort(ABC):

    @abstractmethod
    def notify_task_completed(self, task: Task) -> None:
        """Notify when a task is completed"""
        pass
    # other capabilities as needed 

此接口体现了在架构边界处的信息隐藏。它只揭示了应用层需要的操作,而隐藏了所有实现细节——通知是通过电子邮件、短信还是其他机制发送,对我们核心业务逻辑来说完全隐藏。

然后,在每一个用例中,我们可能会像这样利用定义的端口:

@dataclass
class SetTaskPriorityUseCase:
    task_repository: TaskRepository
    notification_service: NotificationPort # Depends on 
                                           # capability interface
    def execute(self, request: SetTaskPriorityRequest) -> Result:
        try:
            params = request.to_execution_params()

            task = self.task_repository.get(params['task_id'])
            task.priority = params['priority']

            self.task_repository.save(task)

            if task.priority == Priority.HIGH:
                self.notification_service.notify_task_high_priority(task)

            return Result.success(TaskResponse.from_entity(task))
        except ValidationError as e:
            return Result.failure(Error.validation_error(str(e))) 

这种方法展示了我们的边界机制的不同角色:

  • 请求/响应模型处理 API 边界处的数据转换

  • 端口定义了用例需要的服务能力

  • 应用层使用这两个依赖关系来保持清晰的分离,同时协调整体流程

你可能还记得在我们之前的例子中,在处理复杂操作部分,我们引用了一个具体的NotificationService;在这里,我们通过定义一个抽象接口或端口(NotificationPort)来成熟我们的设计。这种从实现到接口的转变更好地符合依赖规则,并提供了更清晰的架构边界。

通过仅依赖于抽象能力接口而不是具体实现,我们的用例在两个方向上保持了信息隐藏:用例对通知实现细节一无所知,而通知服务对用例内部(除通过接口提供的参数外)一无所知。

现在,我们可以探讨如何有效地管理这些边界帮助我们控制的对外部依赖。

支持不断变化的服务需求

随着系统的演变,我们需要允许我们添加新功能并适应不断变化的服务实现的模式。让我们看看管理这种演变的关键模式。

支持可选集成

随着应用程序的增长,我们经常希望将某些服务集成设置为可选或特定于环境。可选服务模式有助于管理这一点:

@dataclass(frozen=True)
class TaskManagementUseCase:
    task_repository: TaskRepository
    notification_service: NotificationPort
    _optional_services: dict[str, Any] = field(default_factory=dict)
    def register_service(self, name: str, service: Any) -> None:
        """Register an optional service"""
        self._optional_services[name] = service

    def complete_task(self, task_id: UUID) -> Result:
        try:
            task = self.task_repository.get(task_id)
            task.complete()
            self.task_repository.save(task)

            # Required notification
            self.notification_service.notify_task_completed(task)

            # Optional integrations
            if analytics := self._optional_services.get('analytics'):
                analytics.track_task_completion(task.id)
            if audit := self._optional_services.get('audit'):
                audit.log_task_completion(task.id)

            return Result.success(TaskResponse.from_entity(task))
        except ValidationError as e:
            return Result.failure(Error.validation_error(str(e))) 

这种方法提供了几个优点:

  • 通过主要的task_repositorynotification_service依赖关系,核心业务操作保持专注和稳定

  • 可以使用灵活的_optional_services字典在不修改现有代码的情况下添加新功能

  • 可选服务可以通过register_service方法根据部署需求进行配置

  • 测试保持简单,因为依赖关系在构造函数中是明确的,可选服务与核心需求明显分离

使用字典存储可选服务并结合条件执行(例如,if analytics := self._optional_services.get('analytics'))为优雅地处理可能或可能不在任何给定部署中存在的功能提供了一个干净的模式。

适应服务变更

当与第三方服务集成或管理系统升级时,我们经常需要在不同的接口之间切换。适配器模式帮助我们管理这一点:

class ModernNotificationService:
    """Third-party service with a different interface"""
    def send_notification(self, payload: dict) -> None:
        # Modern service implementation
        pass
class ModernNotificationAdapter(NotificationPort):
    """Adapts modern notification service to work with our interface"""
    def __init__(self, modern_service: ModernNotificationService):
        self._service = modern_service

    def notify_task_completed(self, task: Task) -> None:
        self._service.send_notification({
            "type": "TASK_COMPLETED",
            "taskId": str(task.id)
        }) 

适配器模式在几个场景中特别有价值:

  • 与第三方服务集成ModernNotificationService可以被包装而不修改其接口

  • 管理系统升级:适配器的转换层(send_notification到特定的通知方法)隔离了服务实现中的变化

  • 支持多种实现:不同的服务可以被适配到相同的NotificationPort接口

  • 在服务版本之间过渡notify_task_completed中结构化有效载荷的映射允许协议演变,同时保持向后兼容性

通过结合使用这些模式,我们可以创建能够优雅地处理可选功能和不断变化的服务实现,同时保持清晰架构边界的系统。

摘要

在本章中,我们探讨了 Clean Architecture 的应用层,重点关注它是如何编排领域对象并与外部服务协调以满足用户需求的。我们学习了如何实现使用案例,在保持清晰架构边界的同时提供有意义的函数。

通过我们的任务管理系统示例,我们发现了如何创建使用案例交互器,在尊重在第一章中引入的依赖规则的同时协调领域对象。我们基于第二章中的 SOLID 原则和第三章中的类型感知模式创建了健壮、可维护的实现。我们的使用案例有效地编排了我们在第四章中开发的领域对象和服务,展示了 Clean Architecture 层如何和谐地协同工作。

我们实现了几个关键模式和概念:

  • 用于编排领域操作的使用案例交互器

  • 创建清晰边界的请求和响应模型

  • 维护架构分离的错误处理模式

  • 保持外部关注点隔离的接口定义

这些实现展示了如何在处理现实世界需求的同时保持我们架构的完整性。我们看到了适当的边界如何使我们的应用程序能够进化并适应不断变化的需求,而不会损害其核心设计。

第六章中,我们将探讨我们的清晰边界如何使创建有效的适配器成为可能,这些适配器可以在我们的应用层和外部世界之间进行转换。我们将看到我们与请求/响应模型和端口建立的模式如何自然地扩展到实现控制器、网关和演示者。

进一步阅读

  • 《构建微服务:设计细粒度系统》由山姆·纽曼所著。尽管本书专注于微服务,但其关于服务边界、服务间通信和数据处理的章节为创建应用层中定义明确的边界提供了宝贵的见解,并且这些内容同样适用于单体应用。

  • 《六边形架构》由阿利斯泰尔·科克本所著(alistair.cockburn.us/hexagonal-architecture/)。这篇文章解释了端口和适配器(或六边形架构)模式,该模式与清洁架构原则高度互补。它提供了对管理依赖和边界转换的清晰理解,这些是实施应用层的关键。

第六章:接口适配器层:控制器和展示者

在第四章和第五章中,我们构建了任务管理系统的基础——代表我们业务概念的领域实体以及编排它们的用例。应用层的请求/响应模型处理用例和领域对象之间的转换,确保我们的核心业务规则保持纯净和专注。然而,这些用例与外部世界(如 Web 界面或命令行工具)之间仍然存在差距。这就是接口适配器层的作用所在。

接口适配器层作为我们应用程序核心和外部关注点之间的翻译者。它转换外部机构方便的数据格式和我们的用例期望的数据格式。通过精心设计的控制器和展示者,这一层维护了保持我们的核心业务规则隔离和可维护性的架构边界。

在本章中,我们将探讨如何在 Python 中实现接口适配器层,了解它是如何维护清洁架构的依赖规则的。我们将学习控制器如何协调外部输入与我们的用例,以及展示者如何将领域数据转换为各种输出需求。

到本章结束时,你将了解如何创建一个灵活的接口适配器层,它既能保护你的核心业务逻辑,又能支持多个接口。你将实现清洁架构的边界,使你的系统更易于维护和适应变化。

在本章中,我们将涵盖以下主要主题:

  • 设计接口适配器层

  • 在 Python 中实现控制器

  • 通过接口适配器强制边界

  • 构建用于数据格式化的展示者

技术要求

本章和本书其余部分提供的代码示例均使用 Python 3.13 进行测试。为了简洁,章节中的代码示例可能只部分实现。所有示例的完整版本可以在本书配套的 GitHub 仓库github.com/PacktPublishing/Clean-Architecture-with-Python中找到。

设计接口适配器层

在清洁架构中,每一层都在维护关注点分离方面发挥着特定的作用。正如我们在前面的章节中看到的,领域层封装了我们的核心业务规则,而应用层则编排用例。但我们如何将这些纯业务导向的层与用户界面、数据库和外部服务的实际需求相连接呢?这就是接口适配器层的作用。

图 6.1:接口适配器层的主要组件

图 6.1:接口适配器层的主要组件

在下一节中,我们将深入探讨接口适配器层的作用,并展示该层在我们任务管理应用程序中的示例。

接口适配器层在整洁架构中的作用

接口适配器层充当了我们应用核心与外部细节(如 Web 框架或命令行界面)之间的一组翻译器。这一层至关重要,因为它允许我们在保持整洁架构边界的同时,使外部关注点能够进行实际交互。通过位于应用层和外部接口之间,它确保:

  • 我们的核心业务逻辑保持纯净和专注

  • 外部关注点不能渗透到内部层

  • 外部接口的更改不会影响我们的核心逻辑

  • 多个接口可以一致地与我们的系统交互

该层的主导原则是依赖规则:依赖关系必须指向核心业务规则。接口适配器层通过确保所有翻译保持适当的架构边界来严格执行此规则。

接口适配器层的职责

当我们深入研究整洁架构的接口适配器层时,理解其核心职责至关重要。正如翻译者必须精通他们所使用的两种语言一样,这一层必须理解我们应用核心的精确语言和外部接口的多种方言。这些职责形成了通过我们系统传递的两个截然不同但互补的数据流,每个都需要仔细处理以保持我们的架构边界。

图 6.2:通过接口适配器层的双向数据流

图 6.2:通过接口适配器层的双向数据流

图 6.2中,我们看到接口适配器层管理着我们的应用核心与外部关注点之间的双向数据流:

  • 入站数据流:

    • 将外部请求转换为应用特定格式

    • 确保数据满足应用要求

    • 与用例协调以执行操作

  • 出站数据流:

    • 将应用结果转换为外部消费格式

    • 提供适合接口的数据格式

    • 在核心逻辑和外部接口之间保持分离

这些责任构成了我们接下来要检查的具体组件的基础。

接口适配器层与应用层边界

当首次与整洁架构合作时,人们常常会想知道接口适配器层与应用层之间的数据转换区别。毕竟,这两个层似乎都处理数据转换。然而,在我们的架构中,这两个层起着根本不同的作用,理解这些差异对于保持我们系统中的整洁边界至关重要。

尽管接口适配器层和应用层都处理数据转换,但它们服务于不同的目的,并保持不同的边界:

  • 应用层:

    • 在领域实体和用例特定格式之间进行转换

    • 专注于业务规则协调

    • 与特定领域的类型和结构一起工作

  • 接口适配器层

    • 在用例格式和外部接口需求之间进行转换

    • 专注于外部接口协调

    • 与特定接口的格式和原始类型一起工作

这种清晰的分离确保了我们的系统在核心业务逻辑和外部接口之间保持了强大的边界。

关键组件及其关系

在理解接口适配器层的责任和边界之后,我们现在可以检查实现这些概念的具体组件。这些组件像一个精心编排的团队一样协同工作,每个组件都在维护我们的架构边界的同时,使系统交互变得实用。虽然我们将在本章后面探索详细的实现,但了解这些组件如何协作为我们清洁架构设计提供了基本背景。

接口适配器层通过三个关键组件实现其责任:

  • 控制器处理输入流,作为外部请求进入我们系统的入口点。它们确保进入我们应用程序核心的数据符合我们系统的要求,同时保护用例免受外部关注。

  • 演示者管理输出流,将用例结果转换为适合外部消费的格式。接口适配器层定义了演示者接口,确立了用例和具体演示者实现都必须遵循的合同。

  • 视图模型作为演示者和视图之间的数据载体,仅包含原始类型和简单的数据结构。这种简单性确保了视图可以轻松消费格式化数据,同时保持清晰的架构边界。

这些组件在一个精心编排的流程中相互作用,始终遵守依赖规则:

  1. 外部请求通过控制器流动

  2. 控制器与用例协调

  3. 用例通过定义的接口返回结果

  4. 演示者将结果格式化为视图模型

  5. 视图消费格式化数据

这场精心策划的交互确保了我们的系统在保持实用性和可维护性的同时,维持了清晰的边界。

接口设计原则

在设计接口适配器层的接口时,我们必须在清晰的架构边界和实用的实现关注之间取得平衡。正如我们在第五章中看到的请求/响应模型,仔细的接口设计能够实现流畅的数据流,同时保持层之间的适当分离。本层指导接口设计的原则帮助我们实现这种平衡,同时遵守清洁架构的核心原则。

三个关键原则塑造了我们的接口设计:

  • 依赖规则在所有设计决策中占优先地位。所有依赖都必须指向用例和实体。这意味着我们的接口适配器依赖于应用程序接口(如我们在第五章中看到的CreateTaskUseCase),但应用程序永远不会依赖于我们的适配器。这一规则确保外部接口的变化不会影响我们的核心业务逻辑。

  • 单一职责原则指导组件边界。每个适配器处理一种特定的转换类型:控制器处理输入验证和转换,而展示者管理输出格式。这种分离使得我们的系统更容易维护和修改。例如,TaskController专注于验证和转换与任务相关的输入,而TaskPresenter仅处理任务数据的显示格式。

  • 接口分离原则确保我们的接口保持专注和一致。我们不是创建大型、单一接口,而是设计小型、目的特定的接口,以满足不同的客户端需求。例如,我们可能不会有一个单一的TaskOperations接口,而是为任务创建、完成和查询分别设计接口。这种粒度提供了灵活性,并使我们的系统更能适应变化。

通过遵循这些原则,我们创建了有效地连接我们干净、专注的核心业务逻辑和外部接口实际需求的接口。在我们接下来的具体实现探讨中,我们将看到这些原则如何指导我们的设计决策,并导致代码更加易于维护。

在 Python 中实现控制器

在建立了接口适配器层的理论基础之后,我们现在转向使用 Python 的实际实现。Python 的语言特性为实施 Clean Architecture 的控制器模式提供了几个优雅的机制。通过数据类、抽象基类(ABCs)和类型提示,我们可以在保持 Python 风格的同时创建清晰且易于维护的接口边界。

虽然 Clean Architecture 提供了一套原则和模式,但它并没有规定一个严格的实现方法。在我们继续前进的过程中,请记住,这代表了对 Clean Architecture 原则的一种可能实现;关键是保持清晰的边界和关注点的分离,无论具体的实现细节如何。

控制器职责和模式

正如我们在检查接口适配器层组件时所见,Clean Architecture 中的控制器有一组专注的职责:它们接受来自外部来源的输入,验证和转换该输入为用例期望的格式,协调用例执行,并适当地处理结果。

让我们考察一个具体实现,以展示这些原则:

@dataclass
class TaskController:
    create_use_case: CreateTaskUseCase
    # ... additional use cases as needed
    presenter: TaskPresenter
    def handle_create(
        self,
        title: str,
        description: str
    ) -> OperationResult[TaskViewModel]:
        try:
            request = CreateTaskRequest(
                title=title,
                description=description
            )
            result = self.create_use_case.execute(request)
            if result.is_success:
                view_model = self.presenter.present_task(result.value)
                return OperationResult.succeed(view_model)
            error_vm = self.presenter.present_error(
                result.error.message,
                str(result.error.code.name)
            )
            return OperationResult.fail(error_vm.message, error_vm.code)
        except ValueError as e:
            error_vm = self.presenter.present_error(
                str(e), "VALIDATION_ERROR")
            return OperationResult.fail(error_vm.message, error_vm.code) 

这个控制器演示了几个关键的清洁架构原则。首先,注意它只依赖于注入的依赖项:用例和展示者都是在其他地方构建的,并通过构造函数注入引入控制器。

为了理解为什么这种依赖注入模式如此重要,考虑以下反例:

# Anti-example: Tightly coupled controller
class TightlyCoupledTaskController:
    def __init__(self):
        # Direct instantiation creates tight coupling
        self.use_case = TaskUseCase(SqliteTaskRepository())
        self.presenter = CliTaskPresenter()

    def handle_create(self, title: str, description: str):
        # Implementation details...
        pass 

这个反例或反例演示了几个问题:

  • 直接实例化具体类会导致紧密耦合

  • 控制器对实现细节了解过多

  • 由于依赖关系无法替换,测试变得困难

  • 实现的变化迫使控制器发生变化

回到我们的清洁实现,handle_create方法展示了控制器核心职责的实际操作。它首先从外部世界接受原始类型(titledescription字符串)——保持接口简单且与框架无关。然后,这些输入被转换成一个合适的请求对象,在它到达我们的用例之前进行验证和格式化。

为了简洁起见,我们只展示了handle_create的实现,但在实践中,这个控制器会有额外的用例注入(如complete_use_caseset_priority_use_case等)和相应的处理方法实现。这种依赖注入和处理实现的模式在所有控制器操作中保持一致。

控制器的错误处理策略特别值得注意。它在验证错误到达用例之前捕获它们,并处理用例执行的成功和失败结果。在所有情况下,它都使用展示者适当地格式化响应,以便外部消费,并将它们包装在OperationResult中,使成功和失败情况明确。这种模式建立在我们在第五章中引入的结果类型之上,增加了对特定接口格式化的视图模型支持。我们将在构建用于数据格式化的展示者中更详细地讨论OperationResult的使用。

这种关注点的清晰分离确保我们的业务逻辑不知道它是如何被调用的,同时为外部客户端提供了一个强大且易于维护的接口。

在控制器中与请求模型协同工作

在我们之前对TaskController的审查中,我们看到了CreateTaskRequest,在第五章对应用层的覆盖中也有所提及。现在让我们更仔细地研究控制器如何与这些请求模型协同工作,以保持外部输入和我们的用例之间的清晰边界:

@dataclass(frozen=True)
class CreateTaskRequest:
    """Request data for creating a new task."""
    title: str
    description: str
    due_date: Optional[str] = None
    priority: Optional[str] = None
    def to_execution_params(self) -> dict:
        """Convert request data to use case parameters."""
        params = {
            "title": self.title.strip(),
            "description": self.description.strip(),
        }
        if self.priority:
            params["priority"] = Priority[self.priority.upper()]
        return params 

虽然应用层定义了这些请求模型,但控制器负责它们的正确实例化和使用。控制器确保在使用用例执行之前进行输入验证:

# In TaskController
try:
    request = CreateTaskRequest(title=title, description=description)
    # Request is now validated and properly formatted
    result = self.create_use_case.execute(request)
except ValueError as e:
    # Handle validation errors before they reach use cases
    return OperationResult.fail(str(e), "VALIDATION_ERROR") 

这种分离确保我们的用例始终只接收经过适当验证和格式化的数据,在保持清洁的架构边界的同时提供强大的输入处理。

维护控制器独立性

我们接口适配器层的有效性在很大程度上取决于在控制器与外部和内部关注点之间保持适当的隔离。

让我们更仔细地看看我们的TaskController是如何实现这种独立性的:

@dataclass
class TaskController:
    create_use_case: CreateTaskUseCase  # Application layer interface
    presenter: TaskPresenter            # Interface layer abstraction 

这种简单的依赖结构展示了几个关键原则。首先,控制器只依赖于抽象;它对用例或展示者的具体实现一无所知。

让我们花点时间澄清一下我们所说的 Python 中的抽象。正如我们很快就会看到的,TaskPresenter遵循经典的接口模式,使用 Python 的 ABC 建立正式的接口契约。对于像CreateTaskUseCase这样的用例,我们利用 Python 的鸭子类型,因为每个用例只需要一个具有定义参数和返回类型的execute方法,任何提供此方法的类都满足了接口契约,无需 ABC 的正式性。

在定义接口方面的这种灵活性是 Python 的强项之一。当我们需要强制执行复杂的契约或依赖于鸭子类型来简化接口时,我们可以选择正式的 ABC 接口。这是开发者选择他们偏好的风格。两种方法都维护了 Clean Architecture 的依赖原则,同时保持 Python 的惯用性。

进行心理盘点,注意我们的控制器中缺少了什么:

  • 没有 Web 框架导入或装饰器

  • 没有数据库或存储问题

  • 没有直接实例化依赖项

  • 对具体视图实现没有了解

这种谨慎的隔离意味着我们的控制器可以被任何交付机制使用——无论是 Web API、命令行界面(CLI)还是消息队列消费者。考虑一下当我们违反这种隔离会发生什么:

# Anti-example: Controller with framework coupling
class WebTaskController:
    def __init__(self, app: FastAPI):
        self.app = app
        self.use_case = CreateTaskUseCase()  # Direct instantiation too!

    async def handle_create(self, request: Request):
        try:
            data = await request.json()
            # Controller now tightly coupled to FastAPI
            return JSONResponse(status_code=201, content={"task": result})
        except ValidationError as e:
            raise HTTPException(status_code=400, detail=str(e)) 

这个反例通过以下方式违反了我们的隔离原则:

  • 导入并依赖于特定的 Web 框架

  • 处理 HTTP 特定问题

  • 将框架错误处理与业务逻辑混合

关于如何公开我们的控制器功能的决定属于框架层。在第7 章中,我们将看到如何创建适当的框架特定适配器,这些适配器封装了我们的清洁控制器实现。这允许我们在利用 FastAPI、Click(用于命令行)或消息队列库等框架的完整功能的同时,保持清洁的架构边界。

我们控制器所依赖的接口展示了清洁架构对边界的细致关注:由应用层定义的使用案例接口建立了我们的内向依赖,而我们在接口适配器层中定义的表示器接口则让我们能够控制外向数据流。这种仔细的接口安排确保我们在保持系统灵活性和适应性的同时,维护了依赖规则。

通过接口适配器强制执行边界

虽然我们对控制器的审查展示了如何处理传入的请求,但清洁架构的接口边界要求我们仔细关注双向的数据流。在本节中,我们将探讨在整个系统中保持清洁边界的模式,特别是关注对成功和失败情况的明确处理。这些模式补充了我们的控制器和表示器,同时确保所有跨边界通信都保持清晰和可维护。

边界处的显式成功/失败模式

在我们的架构边界处,我们需要清晰、一致的方式来处理成功操作和失败情况。操作可能因多种原因而失败——无效输入、业务规则违规或系统错误——每种类型的失败可能需要通过外部接口进行不同的处理。同样,成功的操作需要以适合请求它们的接口的格式提供其结果。我们已经在前面展示的控制器示例中看到了这种机制的应用。

class TaskController:

    def handle_create(
        self,
        title: str,
        description: str
    ) -> OperationResult[TaskViewModel]: 

OperationResult 模式通过提供一种标准化的方式来处理成功和失败情况,来满足这些需求。此模式确保我们的接口适配器始终明确地传达结果,使得错误情况不可能被忽略,并为成功场景提供清晰的架构:

@dataclass
class OperationResult(Generic[T]):
    """Represents the outcome of controller operations."""
    _success: Optional[T] = None
    _error: Optional[ErrorViewModel] = None
    @classmethod
    def succeed(cls, value: T) -> 'OperationResult[T]':
        """Create a successful result with the given view model."""
        return cls(_success=value)
    @classmethod
    def fail(cls, message: str,
             code: Optional[str] = None) -> 'OperationResult[T]':
        """Create a failed result with error details."""
        return cls(_error=ErrorViewModel(message, code)) 

注意类是如何定义为 OperationResult(Generic[T]) 的。这意味着我们的类可以与任何类型 T 一起工作。当我们实例化类时,我们将 T 替换为特定的类型——例如,当我们编写 OperationResult[TaskViewModel] 时,我们是在说:这个操作要么成功返回一个 TaskViewModel ,要么失败返回一个错误 (ErrorViewModel)。这种类型安全性有助于早期捕获潜在的错误,同时使我们的代码意图更加清晰。

这种对结果的明确处理为我们将在接口适配器中看到的清洁边界跨越提供了一个基础。当我们进入查看数据转换模式时,我们将看到这种对成功和失败处理的清晰度如何帮助保持清洁的架构边界,同时实现实用的功能。

如果我们查看一些应用程序代码(位于框架层),我们会看到如何利用这个 OperationResult 来驱动应用程序流程:

# pseudo-code example of a CLI app working with a OperationResult
result = app.task_controller.handle_create(title, description) 
if result.is_success:
    task = result.success
    print(f"{task.status_display} [{task.priority_display}] {task.title}")
    return 0
print(result.error.message, fg='red', err=True)
    return 1 

清洁的数据转换流程

当数据穿过我们的架构边界时,它经历了几个转换。理解这些转换流程有助于我们在确保系统可维护的同时保持清晰的边界:

# Example transformation flow in TaskController
def handle_create(
    self, title: str, description: str
) -> OperationResult[TaskViewModel]:
    try:
        # 1\. External input to request model
        request = CreateTaskRequest(title=title, description=description)

        # 2\. Request model to domain operations
        result = self.use_case.execute(request)

        if result.is_success:
            # 3\. Domain result to view model
            view_model = self.presenter.present_task(result.value)
            return OperationResult.succeed(view_model)

        # 4\. Error handling and formatting
        error_vm = self.presenter.present_error(
            result.error.message,
            str(result.error.code.name)
        )
        return OperationResult.fail(error_vm.message, error_vm.code)

    except ValueError as e:
        # 5\. Validation error handling
        error_vm = self.presenter.present_error(
            str(e), "VALIDATION_ERROR")
        return OperationResult.fail(error_vm.message, error_vm.code) 

此示例显示了一个完整的转换链:

  1. 外部输入验证和转换

  2. 使用域类型执行用例

  3. 成功案例转换为视图模型

  4. 错误情况处理和格式化

  5. 验证错误处理

此链中的每一步都保持清晰的边界,同时确保数据在层之间正确移动。

接口适配器和架构边界

虽然我们一直关注控制器和展示者作为关键接口适配器,但并非每一层之间的交互都需要适配器。理解何时需要适配器有助于在不引入不必要复杂性的情况下维护清晰的架构。

# Defined in Application layer
class TaskRepository(ABC):
    @abstractmethod
    def get(self, task_id: UUID) -> Task:
        """Retrieve a task by its ID."""
        pass
# Implemented directly in Infrastructure layer
class SqliteTaskRepository(TaskRepository):
    def get(self, task_id: UUID) -> Task:
        # Direct implementation of interface
        pass 

这里不需要适配器,因为:

  • 应用层定义了所需的精确接口

  • 实现可以直接满足此接口

  • 不需要数据格式转换

  • 依赖规则无需适配

这与必须处理不同外部格式和协议的控制器和展示者不同。在决定是否需要适配器时,关键问题是:这种交互需要在层之间进行格式转换吗?如果外层可以直接与内层定义的接口工作,那么在接口层可能不需要适配器。

这种区分有助于我们在避免不必要抽象的同时维护清晰架构的原则。通过理解何时需要适配器,我们可以创建更可维护的系统,尊重架构边界而不使我们的设计过于复杂。

为数据格式化构建展示者

在本章中,我们一直将展示者作为接口适配器层的关键组件进行引用。现在我们将详细检查它们,看看它们是如何在准备域数据以供外部消费的同时保持清晰的架构边界。

展示者补充了我们的控制器,处理数据的输出流,就像控制器管理输入请求一样。通过实现谦逊对象模式,展示者帮助我们创建更可测试和可维护的系统,同时保持视图简单和专注。

理解谦逊对象模式

谦逊对象模式解决了清晰架构中一个常见的挑战:如何在保持清晰架构边界的同时处理展示逻辑,展示逻辑通常难以进行单元测试。

“谦逊对象”这个术语来自使组件尽可能简单且逻辑尽可能简单的策略。在展示环境中,这意味着创建一个极其基本的视图,它除了显示预格式化数据之外不做任何事情。视图通过设计变得“谦逊”,包含最少的智能。

例如,一个谦逊的视图可能是:

  • 简单的 HTML 模板渲染预格式化数据

  • 一个仅显示传递的 props 的 React 组件

  • 一个打印格式化字符串的 CLI 显示函数

这种模式在两个组件之间分割责任:

  • 一个谦逊的视图,包含最少且难以测试的逻辑

  • 包含所有演示逻辑的演示者,以易于测试的形式

考虑我们的任务管理系统如何在 CLI 中显示任务信息:

# The "humble" view - simple, minimal logic, hard to test
def display_task(task_vm: TaskViewModel):
    print(f"{task_vm.status_display} [{task_vm.priority_display}]
          {task_vm.title}")
    if task_vm.due_date_display:
        print(f"Due: {task_vm.due_date_display}") 

所有格式化决策——如何显示状态、优先级级别、日期——都存在于我们的演示者中,而不是视图模型(TaskViewModel)本身。这种分离带来了几个好处:

  • 视图保持简单,专注于显示

  • 演示逻辑保持可测试

  • 业务规则保持与显示关注点的隔离

  • 多个接口可以共享格式化逻辑

值得注意的是,对演示者的强调可以根据您的具体需求而变化。如果您正在构建一个将数据提供给 JavaScript 前端 Python API,您可能需要最少的演示逻辑。然而,在像 Django 或 Flask 这样的全栈 Python 应用程序中,强大的演示者有助于保持业务逻辑和显示关注点之间的清晰分离。了解这个模式可以让您根据您的具体情况做出明智的决定。

定义演示者接口

清洁架构的成功在很大程度上依赖于在架构边界上定义良好的接口。对于演示者,这些接口为将领域数据转换为演示准备格式建立了清晰的合同:

class TaskPresenter(ABC):
    """Abstract base presenter for task-related output."""

    @abstractmethod
    def present_task(self, task_response: TaskResponse) -> TaskViewModel:
        """Convert task response to view model."""
        pass

    @abstractmethod
    def present_error(self, error_msg: str,
                      code: Optional[str] = None) -> ErrorViewModel:
        """Format error message for display."""
        pass 

这个接口定义在我们的接口适配器层中,它服务于几个关键目的:

  • 为任务演示建立清晰的合同

  • 启用多个接口实现

  • 通过保持领域逻辑对演示细节的无知来维护依赖规则

  • 通过清晰的抽象使测试更容易

注意到接口如何使用领域特定类型(TaskResponse)作为输入,但返回视图特定类型(TaskViewModel)。这种边界跨越是我们将领域概念转换为演示友好格式的位置。

与视图模型一起工作

视图模型作为演示者和视图之间的数据载体,确保演示逻辑和显示关注点之间的清晰分离。它们以任何视图实现都可以轻松消费的方式封装格式化数据:

@dataclass(frozen=True)
class TaskViewModel:
    """View-specific representation of a task."""
    id: str
    title: str
    description: str
    status_display: str      # Pre-formatted for display
    priority_display: str    # Pre-formatted for display
    due_date_display: Optional[str]  # Pre-formatted for display
    project_display: Optional[str]   # Pre-formatted project context
    completion_info: Optional[str]   # Pre-formatted completion details 

几个关键原则指导我们的视图模型设计:

  • 只使用原始类型(字符串、数字、布尔值)

  • 预格式化所有显示文本

  • 不要对显示机制做任何假设

  • 注意到frozen=True如何保持不可变

  • 只包含显示所需的数据

这种简单性确保我们的视图真正地谦逊——它们只需要读取和显示这些预格式化的值,无需了解领域概念或格式化规则。

实现具体的演示者

在定义了我们的演示者接口和视图模型之后,我们可以为特定的接口需求实现具体的演示者。这些具体的演示者是在框架和驱动层实现的,但我们在这里提前展示一下以供参考。让我们检查一个针对 CLI 的特定演示者实现:

class CliTaskPresenter(TaskPresenter):
    """CLI-specific task presenter."""
    def present_task(self, task_response: TaskResponse) -> TaskViewModel:
        """Format task for CLI display."""
        return TaskViewModel(
            id=str(task_response.id),
            title=task_response.title,
            description=task_response.description,
            status_display=self._format_status(task_response.status),
            priority_display=self._format_priority(
                task_response.priority),
            due_date_display=self._format_due_date(
                task_response.due_date),
            project_display=self._format_project(
                task_response.project_id),
            completion_info=self._format_completion_info(
                task_response.completion_date,
                task_response.completion_notes
            )
        ) 

present_task方法将我们的领域特定TaskResponse转换为视图友好的TaskViewModel。为了支持这种转换,展示器实现了几个私有格式化方法,这些方法处理数据的特定方面:

class CliTaskPresenter(TaskPresenter):  # continuing from above
    def _format_due_date(self, due_date: Optional[datetime]) -> str:
        """Format due date, indicating if task is overdue."""
        if not due_date:
            return "No due date"
        is_overdue = due_date < datetime.now(timezone.utc)
        date_str = due_date.strftime("%Y-%m-%d")
        return (
            f"OVERDUE - Due: {date_str}"
            if is_overdue else f"Due: {date_str}"
        )
    def present_error(self, error_msg: str,
                      code: Optional[str] = None) -> ErrorViewModel:
        """Format error message for CLI display."""
        return ErrorViewModel(message=error_msg, code=code) 

这种实现展示了几个关键的清洁架构原则:

  • 所有格式化逻辑都存在于展示器中,而不是视图中

  • 领域概念(如TaskStatus)被转换为显示字符串

  • 错误处理与成功案例保持一致

  • 接口特定的格式化(在本例中为命令行界面)保持隔离

展示器的格式化方法保持高度可测试性:我们可以验证逾期任务是否被正确标记,日期是否正确格式化,以及错误消息是否保持一致性。这种可测试性与直接测试 UI 组件形成鲜明对比,展示了谦逊对象模式的关键优势。

实现灵活性

如果你正在构建一个主要服务于 JavaScript 前端 JSON 的 API,你可能需要最少的展示逻辑。当需要复杂的格式化或支持多种接口类型时,展示器模式变得最有价值。

第七章中,我们将看到不同的接口(命令行界面、Web 或 API)如何在共享这一共同架构的同时实现它们自己的展示器。这种灵活性展示了清洁架构对边界的细致关注如何使系统进化而不会损害核心业务逻辑。

通过我们对控制器和展示器的探索,我们现在已经为我们的任务管理系统实现了完整的接口适配器层。让我们花点时间回顾我们的架构进展,通过检查我们在第四章到第六章中构建的结构:

图 6.3:所有层都就位时的文件夹结构

图 6.3:所有层都就位时的文件夹结构

这种结构反映了清洁架构的同心层。我们建立的领域层,在第四章中,保持纯粹和专注于业务规则。应用层,在第五章中添加,协调这些领域对象以完成特定用例。现在,随着我们的接口适配器层的实现,我们已经实现了控制器和展示器,它们在核心业务逻辑和外部关注点之间进行转换,同时保持清晰的边界,并使与我们的系统进行实际交互成为可能。请参阅随附的 GitHub 仓库(github.com/PacktPublishing/Clean-Architecture-with-Python),以获取本书中使用的任务管理应用程序示例的更广泛的代码示例。

摘要

在本章中,我们探讨了清洁架构的接口适配器层,实现了控制器和展示者,它们在保持清洁边界的同时,允许与外部系统进行实用交互。我们学习了控制器如何处理传入的请求,将外部输入转换为我们的用例可以处理的格式,同时展示者将领域数据转换为视图友好的格式。

以我们的任务管理系统为例,我们看到了如何实现控制器,使其独立于特定的输入源,以及展示者如何将格式化逻辑与视图实现细节分离。我们基于第5 章中的结果模式,引入了OperationResult,以便在架构边界处进行显式的成功和失败处理。谦逊的对象模式向我们展示了如何保持展示逻辑和视图之间的清洁分离,从而提高可测试性和可维护性。

第七章中,我们将探讨如何实现特定接口,这些接口消费我们的控制器和展示者。你将学习如何创建命令行和 Web 界面,这些界面与我们的系统交互,同时保持我们已建立的清洁边界。

进一步阅读

第七章:框架和驱动层:外部接口

框架和驱动层代表了清洁架构的最外层环,在这里我们的应用程序与真实世界相遇。在前面的章节中,我们从领域实体到用例,再到协调它们之间的接口适配器,构建了我们任务管理系统的核心。现在我们将看到清洁架构如何帮助我们与外部框架、数据库和服务集成,同时保持我们的核心业务逻辑纯净和安全。

通过实际实施,我们将探讨清洁架构对边界的细致关注如何使我们的应用程序能够与各种框架和外部服务协同工作,而不会对其产生依赖。我们将看到我们的任务管理系统如何利用外部能力——从用户界面到数据存储和通知。本章展示了清洁架构的原则如何转化为现实世界的实施。通过实际示例,您将看到清洁架构如何帮助管理外部集成的复杂性,同时保持您的核心业务逻辑集中和可维护。

到本章结束时,您将了解如何有效地实现框架和驱动层,在集成外部依赖的同时保持架构的完整性。您将能够将这些模式应用到自己的项目中,确保随着外部需求的变化,您的应用程序保持灵活和可维护。

在本章中,我们将涵盖以下主要主题:

  • 理解框架和驱动层

  • 创建 UI 框架适配器

  • 组件组织和边界

  • 实现数据库适配器

  • 集成外部服务

技术要求

本章及本书其余部分展示的代码示例均使用 Python 3.13 进行测试。为了简洁,章节中的代码示例可能只部分实现。所有示例的完整版本可以在本书配套的 GitHub 仓库github.com/PacktPublishing/Clean-Architecture-with-Python中找到。如果你选择在集成外部服务部分运行电子邮件驱动程序示例,你需要在app.sendgrid.com注册一个免费的 SendGrid 开发者账户。

理解框架和驱动层

每个重要的软件应用程序最终都必须与真实世界交互。数据库需要查询,文件需要读取,用户需要接口。在清洁架构中,这些基本但易变的交互通过框架和驱动层进行管理。这一层的独特位置和责任使其既强大又可能对我们的架构目标构成潜在危险。

在清洁架构中的位置

图 7.1:框架和驱动层及其主要组件

图 7.1:框架和驱动层的主要组件

框架和驱动层在架构边缘的位置并非偶然;它代表了 Clean Architecture 所说的我们系统的细节。这些细节虽然对于应用程序的正常运行至关重要,但应该与我们的核心业务逻辑保持分离。这种分离创建了一个保护边界,通常只包含对最外层的更改。然而,当新的需求确实需要修改核心业务规则时,Clean Architecture 提供了明确的路径,通过每一层系统地实施这些更改,确保我们的系统优雅地进化,而不损害其架构完整性。

让我们考察关于框架和驱动层在 Clean Architecture 中位置的几个关键原则:

外部边界:作为最外层,它处理与外部世界的所有交互:

  • 用户界面(命令行界面(CLI)、网页、API 端点)

  • 数据库系统(如 SQLite 的驱动程序或如 SQLAlchemy 的框架)

  • 外部服务和 API

  • 文件系统和设备交互

依赖方向:遵循 Clean Architecture 的基本规则,所有依赖都指向内部。我们的框架和驱动依赖于内部层接口,但从不反过来:

  • 数据库适配器实现了由应用层定义的存储库接口

  • 网页控制器使用接口适配器层的接口

  • 外部服务客户端适应来自应用层的内部抽象

实现细节:此层包含 Clean Architecture 认为的细节,即特定技术选择,这些选择应该是可互换的:

  • SQLite 或 PostgreSQL 之间的选择

  • 使用 Click 与 Typer 进行 CLI 实现

  • 选择 SendGrid 或 AWS SES 进行电子邮件通知

这种战略定位提供了几个关键好处:

  • 框架独立性:核心业务逻辑对特定的框架选择一无所知

  • 易于测试:外部依赖可以被测试替身替换

  • 灵活的进化:实现细节可以更改,而不会影响内部层

  • 清晰的边界:显式接口定义了外部关注点如何与我们的系统交互

对于我们的任务管理系统,这意味着无论我们是在实现命令行界面、将任务存储在文件中,还是通过电子邮件服务发送通知,所有这些实现细节都生活在这个最外层,同时尊重内部层定义的接口。

接下来,我们将探讨框架和驱动之间的区别,帮助我们理解如何有效地实现每种类型的外部依赖。

框架与驱动:理解区别

虽然 framework 和 drivers 都位于 Clean Architecture 的最外层,但它们在集成复杂性方面存在显著差异。这种区别源于它们与我们探索的 第五章和第六章 中的层如何交互。

框架是全面的软件平台,它们强加自己的架构和控制流程:

  • 类似 Flask 或 FastAPI 的 Web 框架

  • 类似 Click 或 Typer 的 CLI 框架

  • 对象关系建模(ORM)框架,如 SQLAlchemy

类似 Click(我们将为我们的 CLI 实现它)这样的框架需要完整的接口适配器层组件,以保持清晰的架构边界:

  • 将框架特定的请求转换为用例输入的控制器

  • 为框架消费格式化领域数据的演示者

  • 为框架显示结构数据的视图模型

相比之下,驱动程序是更简单的组件,它们提供低级服务而不强加自己的结构或流程。例如,包括数据库驱动程序、文件系统访问组件和外部 API 客户端。与框架不同,驱动程序不规定你的应用程序如何工作,它们只是提供你根据需要适应的能力。

这些驱动程序通过端口与我们的应用程序交互——这是我们首次在第第五章中介绍的抽象接口。我们在那一章中看到了两个关键端口示例:

  • 用于持久化操作的存储库接口,如TaskRepository

  • 用于外部通知的服务接口,如NotificationPort

根据第五章中建立的模式,驱动程序通常只需要两个组件:

  • 在应用层定义的端口(如TaskRepository

  • 在框架和驱动程序层的一个具体实现

在以下示例中,我们可以看到代码中的区别。首先,我们来看一个框架示例:

# Framework example - requires multiple adapter components
@app.route("/tasks", methods=["POST"])
def create_task():
    """Framework requires full Interface Adapters stack"""
    result = task_controller.handle_create(  # Controller from Ch.6
        title=request.json["title"],
        description=request.json["description"]
    )
    return task_presenter.present(result)    # Presenter from Ch.6 

注意框架示例需要控制器来转换请求和演示者来格式化响应。

接下来,我们来看一个驱动程序的示例:

# Driver example - only needs interface and implementation
class SQLiteTaskRepository(TaskRepository):  # Interface from Ch.5
    """Driver needs only basic interface implementation"""
    def save(self, task: Task) -> None:
        self.connection.execute(
            "INSERT INTO tasks (id, title) VALUES (?, ?)",
            (str(task.id), task.title)
        ) 

在这里,我们看到 SQLite 驱动程序直接通过基本的保存操作实现了存储库接口。

这种架构区分帮助我们为每种外部依赖实现适当的集成策略,同时保持 Clean Architecture 的依赖规则。这些分离提供了即时的实际好处:当你的数据库驱动程序出现安全漏洞时,修复只需要更新外层实现。当业务需求改变任务优先级时,这些变化仍然被隔离在你的领域逻辑中。这些不是理论上的好处,而是随着系统增长而累积的日常优势。

应用程序组合

在探讨了框架和驱动程序之间的区别之后,我们现在转向一个关键问题:这些组件是如何组合成一个统一的应用程序,同时保持清晰的架构边界的?这引出了应用程序组合的概念,即我们系统组件的系统组装。

在 Clean Architecture 中,应用程序组合作为协调点,我们的精心分离的组件联合起来形成一个工作系统。想想看,就像组装一个复杂的机器。每个部分都必须精确地配合在一起,但组装过程本身不应改变单个组件的工作方式。

Clean Architecture 应用程序的组合涉及三个关键方面共同工作:

配置管理:

  • 管理特定环境的设置

  • 控制框架和驱动程序选择

  • 保持设置和业务逻辑之间的分离

  • 允许开发、测试和生产使用不同的配置

组件工厂:

  • 创建正确配置的接口实现

  • 管理依赖生命周期

  • 处理初始化序列

  • 在对象创建过程中保持 Clean Architecture 的依赖规则

主应用程序入口点:

  • 协调启动序列

  • 处理顶级错误条件

  • 保持启动和业务操作之间的清晰分离

  • 作为依赖组装的组合根

让我们看看这些方面在实际中是如何协同工作的:

图 7.2:Clean Architecture 组合流程图,显示配置、组合根和框架适配器

图 7.2:Clean Architecture 组合流程图,显示配置、组合根和框架适配器

我们的任务管理系统以特定方式实现了这些组合模式,以展示其实际价值:

  • 配置机制提供环境感知设置,驱动实现选择,例如在内存存储或基于文件的存储之间进行选择

  • 组合根通过 main.pyApplication 类协调组件的组装,同时保持清晰的架构边界

  • 框架适配器通过以下方式将我们的用户界面连接到核心应用程序:

    • 将 UI 请求转换为用例输入的控制器

    • 将领域数据格式化为显示的演示者

    • 清晰的分离,允许多个接口共享核心组件

这种架构方法提供了几个关键好处:

  • 通过基于工厂的组件创建实现实施灵活性

  • 通过明确定义的边界实现关注点的清晰分离

  • 通过组件隔离实现易于测试

  • 简单添加新功能,而不会破坏现有代码

这些优势贯穿于我们的实现之中。在接下来的章节中,我们将详细检查从图 7.2中的每个基础设施组件。我们将从配置管理到框架适配器进行覆盖,展示它们如何通过具体的模式和代码示例在实际中协同工作。

外层 Clean Architecture 模式

我们所探讨的模式为整合外部关注点提供了明确的策略,同时保护我们的核心业务逻辑。当我们开始实现任务管理系统中的特定组件时,这些模式将以独特的方式协同工作,以维持架构边界。

考虑这些模式在实际中的组合:一个网络请求到达我们系统的边缘,触发一系列清晰的架构交互。框架适配器将请求转换为我们的内部格式,而端口允许数据库和通知操作,而不暴露其实现细节。所有这些编排都通过我们的组合根发生,确保每个组件都接收到其正确配置的依赖项。

在本章剩余部分深入探讨这些主题时,我们将实现任务管理系统的部分功能,以看到这些模式在实际中的应用——从 CLI 适配器转换用户命令到存储库实现管理持久性。每个实现不仅展示了单个模式,还展示了它们如何合作以维持 Clean Architecture 的核心原则,同时提供实用的功能。

创建 UI 框架适配器

当集成用户界面框架时,Clean Architecture 的关注点分离变得特别有价值。UI 框架往往既易变又有偏见,因此隔离它们对我们核心业务逻辑的影响至关重要。在本节中,我们将探讨如何实现框架适配器,在保持清晰的边界的同时提供实用的用户界面。

实际中的框架适配器

让我们先看看我们正在构建的内容。我们的任务管理系统需要一个用户界面,使用户能够有效地管理项目和任务。图 7.3展示了我们的命令行界面中的一个典型交互屏幕:

图 7.3:CLI 应用程序中的任务编辑界面

图 7.3:CLI 应用程序中的任务编辑界面

此界面展示了我们系统的一些关键方面:

  • 清晰显示任务详情和状态

  • 简单的编号菜单用于常见操作

  • 领域概念的格式一致(状态、优先级)

  • 在不同视图之间直观导航

虽然这个界面对用户来说看起来很简单,但其实现需要在架构边界之间进行仔细的协调。显示的每一项信息和可用的每一个操作都代表着通过我们清洁架构层的数据流。图 7.4 展示了单个操作——创建项目——如何穿过这些边界:

图 7.4:创建项目的整个请求/响应流程

图 7.4:创建项目的整个请求/响应流程

这个序列图揭示了几个重要的模式:

  • CLI 适配器将用户输入转换为正确结构的请求

  • 这些请求通过我们定义良好的架构边界流过

  • 每一层执行其特定的职责(验证、业务逻辑等)

  • 响应通过层流回,并适当地转换为显示

通过理解数据如何通过我们的架构边界流动,让我们检查我们如何组织实现此流程的组件。

组件组织和边界

正如我们在 图 7.2 中看到的,我们的应用程序组合建立了一个清晰的架构,其中每个组件都有特定的职责。在这个系统的边缘,框架适配器必须处理外部框架和我们的清洁架构之间的数据转换,同时协调用户交互。

查看 图 7.4,我们可以看到我们的 CLI 适配器位于一个关键架构边界上。我们选择了 Click,这是一个流行的 Python 框架,用于构建命令行界面,用于我们的 CLI 实现。适配器必须在 Click 的框架特定模式和我们的应用程序的清洁接口之间进行转换,同时管理用户输入和结果的显示。

让我们检查核心适配器结构:

class ClickCli:
    def __init__(self, app: Application):
        self.app = app
        self.current_projects = []  # Cached list of projects for display
    def run(self) -> int:
        """Entry point for running the Click CLI application"""
        try:
            while True:
                self._display_projects()
                self._handle_selection()
        except KeyboardInterrupt:
            click.echo("\nGoodbye!", err=True)
            return 0
    # ... additional methods 

这个高级结构展示了几个关键的清洁架构原则:

依赖注入:

  • 适配器通过构造函数注入接收其应用实例

  • 这通过保持适配器对内部层的依赖来维护依赖规则

  • 适配器中不直接实例化应用程序组件

框架隔离:

  • Click 特定的代码保留在适配器内

  • 应用实例为我们提供了核心业务逻辑的清洁接口

  • 框架相关的问题,如用户交互和显示缓存,保持在边缘

让我们检查 ClickCli 的一个处理器方法,看看这些组件是如何一起工作以创建 图 7.3 中显示的界面:

def _display_task_menu(self, task_id: str) -> None:
    """Display and handle task menu."""
    result = self.app.task_controller.handle_get(task_id)
    if not result.is_success:
        click.secho(result.error.message, fg="red", err=True)
        return
    task = result.success
    click.clear()
    click.echo("\nTASK DETAILS")
    click.echo("=" * 40)
    click.echo(f"Title:       {task.title}")
    click.echo(f"Description: {task.description}")
    click.echo(f"Status:      {task.status_display}")
    click.echo(f"Priority:    {task.priority_display}") 

task 菜单处理器显示了我们的架构边界在工作:

  • 业务操作如图 7.4 所示通过控制器流过

  • 应用实例屏蔽了适配器对核心业务逻辑细节的了解

  • 框架特定的代码(Click 命令)保持在边缘

  • 错误处理保持了层之间的清洁分离

通过这种实现风格,我们在提供实用的用户界面的同时保持清晰的边界。这个基础使我们能够干净地实现处理用户交互和业务操作的具体功能。

现在让我们探索适配器如何处理特定的用户命令和交互。

实现用户交互

在构建 CLI 时,我们需要将用户操作转换为业务操作,同时保持干净的架构边界。这包括处理命令输入、显示结果和管理用户在系统中的导航。

让我们考察ClickCli适配器类如何处理典型的交互流程:

def _handle_selection(self) -> None:
    """Handle project/task selection."""
    selection = click.prompt(
        "\nSelect a project or task (e.g., '1' or '1.a')",
        type=str,
        show_default=False
    ).strip().lower()
    if selection == "np":
        self._create_new_project()
        return
    try:
        if "." in selection:
            project_num, task_letter = selection.split(".")
            self._handle_task_selection(int(project_num),
                                        task_letter)
        else:  # Project selection
            self._handle_project_selection(int(selection))
    except (ValueError, IndexError):
        click.secho(
            "Invalid selection. Use '1' for project or '1.a' for task.",
            fg="red",
            err=True,
        ) 

这个selection处理程序展示了在尊重干净架构边界的同时管理用户交互的几个关键模式:

  • 输入解析:

    • 在处理之前验证和标准化用户输入

    • 为无效选择提供清晰的反馈

    • 将输入处理关注点保持在框架边界

  • 命令路由:

    • 将用户选择映射到适当的处理方法

    • 在输入处理和业务逻辑之间保持干净的分离

    • 使用一致的模式处理不同类型的选择

如果我们跟随_create_new_project处理程序,我们看到与应用层之间的交互:

def _create_new_project(self) -> None:
    """Create a new project."""
    name = click.prompt("Project name", type=str)
    description = click.prompt("Description (optional)",
                               type=str, default="")
    result = self.app.project_controller.handle_create(
        name, description)
    if not result.is_success:
        click.secho(result.error.message,
                    fg="red", err=True) 

此实现展示了框架和驱动器、应用层之间的干净转换:

  • 使用 Click 的提示进行框架特定的输入收集

  • 直接将业务操作委托给应用程序控制器

  • 尊重架构边界的干净错误处理

这种对架构边界的细致关注帮助我们保持用户界面和业务逻辑之间的清晰分离,同时仍然提供一致的用户体验。无论是处理输入还是显示输出,每个组件都在 Clean Architecture 的同心层中保持其特定的职责。

通过实现获得领域洞察

在实现 CLI 界面时,我们开始通过实际的用户交互模式发现关于我们的领域模型的洞察。最初,我们的领域模型将项目分配视为任务的可选属性,为用户组织工作提供了灵活性。然而,在我们实现了用户界面后,这种灵活性暴露出摩擦的来源。

应该指出的是,干净的架构边界保护我们免受实现细节变化的影响,这些变化会通过我们的系统传播,例如交换数据库或 UI 框架。然而,这个发现代表的是不同的事情。

我们发现的是关于我们的领域模型的基本洞察,需要通过我们的层进行系统性的改变。这展示了 Clean Architecture 如何引导我们适当地处理这两种类型的改变——在边缘隔离技术实现,同时在需要时提供清晰的核心领域模型演变的路径。

UI 实现表明,要求用户在处理项目或独立任务之间进行选择,造成了不必要的复杂性。用户必须为每个任务明确决定项目分配,界面需要为与项目关联的独立任务进行特殊处理。这增加了用户的认知负担和开发者的实现复杂性。

这一认识使我们得出一个重要的领域洞察:在我们的用户心智模型中,任务本质上属于项目。与其将项目分配视为可选的,我们通过确保所有任务都属于一个项目,以一个邮箱项目作为默认容器来组织未明确组织的任务,可以简化我们的领域模型和用户界面。

用户界面的开发通常是我们领域模型的关键测试场,揭示了在初始设计期间可能不明显的信息。让我们借此机会展示我们的清洁架构边界如何确保我们能够系统地实施这些发现,同时保持框架关注点与核心业务逻辑之间的分离。

实施领域洞察:任务-项目关系

让我们检查反映我们在领域中对任务自然属于项目的理解所需的关键代码更改。我们将从领域层开始实施这一洞察,向外扩展,使用一个邮箱项目作为支持这种自然组织的实用机制:

# 1\. Domain Layer: Add ProjectType and update entities
class ProjectType(Enum):
    REGULAR = "REGULAR"
    INBOX = "INBOX"
@dataclass
class Project(Entity):
    name: str
    description: str = ""
    project_type: ProjectType = field(default=ProjectType.REGULAR)
    @classmethod
    def create_inbox(cls) -> "Project":
        return cls(
            name="INBOX",
            description="Default project for unassigned tasks",
            project_type=ProjectType.INBOX
        )
@dataclass
class Task(Entity):
    title: str
    description: str
    project_id: UUID  # No longer optional 

这些领域层的变化为我们邮箱模式奠定了基础。通过引入ProjectType并更新我们的实体,我们强制执行了业务规则,即所有任务都必须属于一个项目,而create_inbox工厂方法确保了邮箱项目的创建一致性。请注意,Task实体现在需要一个project_id,这反映了我们精炼的领域模型。

然后这些变化会传递到我们的应用层:

# 2\. Application Layer: Update repository interface and use cases
class ProjectRepository(ABC):
    @abstractmethod
    def get_inbox(self) -> Project:
        """Get the INBOX project."""
        pass
@dataclass
class CreateTaskUseCase:
    task_repository: TaskRepository
    project_repository: ProjectRepository

    def execute(self, request: CreateTaskRequest) -> Result:
        try:
            params = request.to_execution_params()
            project_id = params.get("project_id")
            if not project_id:
                project_id = self.project_repository.get_inbox().id
            # ... remainder of implementation 

应用层的变化展示了 Clean Architecture 如何处理跨层需求。ProjectRepository接口获得了邮箱特定的功能,而CreateTaskUseCase通过在未指定明确项目时自动将任务分配给邮箱项目来强制执行我们新的业务规则。这保持了我们的业务规则集中和一致。此外,ProjectResponse模型将添加project_type字段,而TaskResponse模型将使project_id字段成为必需。

由于这些变化,我们的框架适配器简化了:

def _create_task(self) -> None:
    """Handle task creation command."""
    title = click.prompt("Task title", type=str)
    description = click.prompt("Description", type=str)

    # Project selection is optional - defaults to Inbox
    if click.confirm("Assign to a specific project?", default=False):
        project_id = self._select_project()

    result = self.app.task_controller.handle_create(
        title=title,
        description=description,
        project_id=project_id  # Inbox handling in use case
    ) 

与此同时,适配器专注于用户交互,而不是管理带有或没有项目的任务的复杂条件逻辑。确保任务-项目关联的业务规则由用例处理,展示了 Clean Architecture 如何通过关注点的分离导致更简单、更专注的组件。视图模型同样简化,不再需要处理没有项目的任务的情况。

这种实现展示了 Clean Architecture 对变化的系统化方法:

  • 领域变化建立新的不变业务规则

  • 应用程序层适应以强制执行这些规则

  • 框架适配器简化为反映更清晰的模型

  • 每一层保持其特定的责任

通过遵循 Clean Architecture 的边界,我们在保持关注点分离的同时,实现了我们的领域洞察,并提高了用户体验和代码组织。在一个结构较松散的代码库中,业务规则可能散布在 UI 组件和数据访问代码中,这样的基本变化可能需要搜索多个组件以确保行为一致。Clean Architecture 的清晰边界帮助我们避免这些重构挑战。正如我们将在下一节中看到的,这些相同的原理指导我们的数据库适配器实现,这是我们的框架和驱动器层中的另一个关键组件。

实现数据库适配器

在 Clean Architecture 中实现数据库适配器提供了一个清晰的例子,说明了驱动器集成与框架集成之间的差异。如前所述,驱动器需要比框架更简单的适配模式,通常只需要在应用程序层中有一个接口,在这个最外层有一个具体实现。

存储库接口实现

回想一下第五章,我们的应用程序层定义了存储库接口,这些接口为任何具体实现建立了明确的合同。这些接口确保我们的核心业务逻辑保持独立于存储细节:

class TaskRepository(ABC):
    """Repository interface for Task entity persistence."""

    @abstractmethod
    def get(self, task_id: UUID) -> Task:
        """Retrieve a task by its ID."""
        pass
    @abstractmethod
    def save(self, task: Task) -> None:
        """Save a task to the repository."""
        pass
    # ... remaining methods of interface 

让我们用一个内存存储库来实现这个接口。虽然将数据存储在内存中对于生产系统来说可能看起来不切实际,但这种实现提供了几个优点。最值得注意的是,它提供了一个轻量级、快速的实现,非常适合测试——我们将在第八章中更全面地探讨这一点,当我们讨论 Clean Architecture 的测试模式时。

class InMemoryTaskRepository(TaskRepository):
    """In-memory implementation of TaskRepository."""
    def __init__(self) -> None:
        self._tasks: Dict[UUID, Task] = {}
    def get(self, task_id: UUID) -> Task:
        """Retrieve a task by ID."""
        if task := self._tasks.get(task_id):
            return task
        raise TaskNotFoundError(task_id)
    def save(self, task: Task) -> None:
        """Save a task."""
        self._tasks[task.id] = task
    # additional interface method implementations 

这种实现展示了几个关键的 Clean Architecture 原则。注意它:

  • 实现由我们的应用程序层定义的接口

  • 保持存储和业务逻辑之间的清晰分离

  • 处理特定领域的错误(TaskNotFoundError

  • 完全隐藏实现细节(字典存储)供客户端使用

虽然简单,但这种模式为我们所有存储库实现提供了基础。无论是在内存中存储数据、文件还是数据库,由于我们清晰的架构边界,核心交互模式始终保持一致。

例如,以下是如何实现基于文件的存储:

class FileTaskRepository(TaskRepository):
    """JSON file-based implementation of TaskRepository."""
    def __init__(self, data_dir: Path):
        self.tasks_file = data_dir / "tasks.json"
        self._ensure_file_exists()
    def get(self, task_id: UUID) -> Task:
        """Retrieve a task by ID."""
        tasks = self._load_tasks()
        for task_data in tasks:
            if UUID(task_data["id"]) == task_id:
                return self._dict_to_task(task_data)
        raise TaskNotFoundError(task_id)
    def save(self, task: Task) -> None:
        """Save a task."""
        # ... remainder of implementation 

这种实现展示了 Clean Architecture 基于接口方法的强大之处:

  • 相同的接口适应了非常不同的存储策略

  • 核心业务逻辑完全不了解存储细节

  • 实现复杂性(如 JSON 序列化)保持在最外层隔离

  • 错误处理在所有实现中保持一致

我们的领域代码可以透明地与任何实现一起工作:

# Works identically with either repository
task = repository.get(task_id)
task.complete()
repository.save(task) 

这种灵活性不仅限于这两种实现。无论我们后来添加 SQLite、PostgreSQL 还是云存储,我们干净的接口都能确保核心业务逻辑不会改变。

管理存储库实例化

图 7.2所示,配置管理在我们的应用程序组合中扮演着关键角色。其主要职责之一是指导选择和创建适当的存储库实现。我们的Config类提供了一种管理这些决策的干净方式:

class Config:
    @classmethod
    def get_repository_type(cls) -> RepositoryType:
        repo_type_str = os.getenv(
            "TODO_REPOSITORY_TYPE",
            cls.DEFAULT_REPOSITORY_TYPE.value
        )
        try:
            return RepositoryType(repo_type_str.lower())
        except ValueError:
            raise ValueError(f"Invalid repository type: {repo_type_str}") 

我们现在在处理实际实例化我们存储库的工厂实现中利用这种配置能力。这种我们在应用程序组合讨论中看到的工厂模式,提供了一种创建正确配置的存储库实例的干净方式:

def create_repositories() -> Tuple[TaskRepository, ProjectRepository]:
    repo_type = Config.get_repository_type()
    if repo_type == RepositoryType.FILE:
        data_dir = Config.get_data_directory()
        task_repo = FileTaskRepository(data_dir)
        project_repo = FileProjectRepository(data_dir)
        project_repo.set_task_repository(task_repo)
        return task_repo, project_repo
    elif repo_type == RepositoryType.MEMORY:
        task_repo = InMemoryTaskRepository()
        project_repo = InMemoryProjectRepository()
        project_repo.set_task_repository(task_repo)
        return task_repo, project_repo
    else:
        raise ValueError(f"Invalid repository type: {repo_type}") 

这个工厂展示了几个关键的清洁架构模式在实际操作中的运用。配置通过Config.get_repository_type()驱动实现选择,而创建复杂性被封装在特定类型的初始化块中。注意project_repo.set_task_repository(task_repo)如何一致地处理实现中的依赖注入。工厂返回抽象存储库接口,将实现细节隐藏于客户端。这些模式结合在一起,创建了一个强大的系统来管理存储库的生命周期,同时保持清晰的架构边界。

在我们建立了存储库创建模式之后,让我们来审视这些组件如何在我们架构边界之间进行编排,形成一个完整的系统。

组件编排概述

我们已经涵盖了配置类、工厂模式和组合原则——所有这些共同工作来管理存储库的创建。

让我们退后一步,审视整个画面。图 7.5关注于我们从图 7.2中看到的架构概述,详细展示了配置和组合根组件如何跨越我们的架构边界进行交互:

图 7.5:框架和驱动层与接口适配层之间的组件交互

图 7.5:框架和驱动层与接口适配层之间的组件交互

图 7.5所示,我们的组合流程从main.py开始,它启动应用程序创建过程。create_application函数作为我们的主要工厂,与配置管理和组件工厂协调,组装一个完全配置的Application类实例。每个组件在协同创建一个统一系统的同时,保持清晰的边界:

  • Config提供环境感知的设置,驱动实现选择

  • 组件工厂方法(create_repositories)处理端口实例化和关系的复杂性

  • create_application 协调整体组件组装

  • Application位于我们的框架和驱动层,与接口适配器层的控制器协调,为框架适配器提供访问核心业务逻辑的权限

这种谨慎的编排展示了 Clean Architecture 在管理复杂系统组成方面的力量。虽然每个组件都有明确、集中的职责,但它们共同工作,创建一个灵活、可维护的系统,同时尊重架构边界。在下一节中,我们将检查外部服务集成,更详细地了解Application类和main.py如何在运行时将这些组件组合在一起。

集成外部服务

虽然数据库存储我们的应用程序状态,但外部服务通过发送通知、处理支付或集成第三方 API,使我们的应用程序能够与更广泛的世界交互。像数据库一样,这些服务代表必要但易变的依赖关系,必须谨慎管理以保持干净的架构边界。

Clean Architecture 中的外部服务

回想一下第五章,我们的应用层定义了端口,这些端口指定了我们的核心应用如何与外部服务交互。NotificationPort接口展示了这种方法:

class NotificationPort(ABC):
    """Interface for sending notifications about task events."""

    @abstractmethod
    def notify_task_completed(self, task: Task) -> None:
        """Notify when a task is completed."""
        pass
    @abstractmethod
    def notify_task_high_priority(self, task: Task) -> None:
        """Notify when a task is set to high priority."""
        pass 

此接口,定义在我们的应用层,展示了几个关键的 Clean Architecture 原则:

  • 核心应用指定了它需要的确切通知能力

  • 没有实现细节泄漏到接口中

  • 接口纯粹关注业务操作

  • 错误处理在此级别保持抽象

让我们看看任务完成通知是如何穿过我们的架构边界的:

图 7.6:通过架构层的通知流程

图 7.6:通过架构层的通知流程

此序列展示了 Clean Architecture 对依赖关系的谨慎管理:

  • 用例只知道抽象的NotificationPort

  • 具体的 SendGrid 实现位于我们系统的边缘

  • 业务逻辑完全不了解电子邮件实现细节

  • 特定服务集成(SendGrid)在架构边界处干净利落地发生

SendGrid 集成

在定义了我们的通知端口接口后,让我们使用SendGrid实现电子邮件通知——这是一个基于云的电子邮件服务,提供发送交易性电子邮件的 API。通过使用 SendGrid 实现我们的通知端口,我们将展示 Clean Architecture 如何帮助我们集成第三方服务,同时保持干净的架构边界:

class SendGridNotifier(NotificationPort):
    def __init__(self) -> None:
        self.api_key = Config.get_sendgrid_api_key()
        self.notification_email = Config.get_notification_email()
        self._init_sg_client()
    def notify_task_completed(self, task: Task) -> None:
        """Send email notification for completed task if configured."""
        if not (self.client and self.notification_email):
            return 
        try:
            message = Mail(
                from_email=self.notification_email,
                to_emails=self.notification_email,
                subject=f"Task Completed: {task.title}",
                plain_text_content=f"Task '{task.title}' has been 
                                     completed."
            )
            self.client.send(message)
        except Exception as e:
            # Log error but don't disrupt business operations
            # ... 

我们的 SendGrid 实现,就像我们之前的存储库实现一样,依赖于配置管理来处理特定服务的设置。基于我们在存储库配置中建立的模式,我们的Config类扩展以支持通知设置:

class Config:
    """Application configuration."""
    # Previous repository settings omitted...

    @classmethod
    def get_sendgrid_api_key(cls) -> str:
        """Get the SendGrid API key."""
        return os.getenv("TODO_SENDGRID_API_KEY", "")
    @classmethod
    def get_notification_email(cls) -> str:
        """Get the notification recipient email."""
        return os.getenv("TODO_NOTIFICATION_EMAIL", "")
    # ... remainder of implementation 

让我们看看这如何适应我们的任务完成工作流程。回想一下第五章中的CompleteTaskUseCase,它协调任务完成与通知:

@dataclass
class CompleteTaskUseCase:
    task_repository: TaskRepository
    notification_service: NotificationPort  
    def execute(self, request: CompleteTaskRequest) -> Result:
        try:
            task = self.task_repository.get(request.task_id)
            task.complete(notes=request.completion_notes)
            self.task_repository.save(task)
            self.notification_service.notify_task_completed(task)
            # ... remainder of implementation 

通过使用 SendGrid 实现NotificationPort,我们展示了清晰架构边界的关键好处:添加电子邮件通知只需在系统的边缘进行更改。由于我们的应用程序层定义了NotificationPort接口,并且我们的用例只依赖于这个抽象,实现 SendGrid 通知不需要更改我们的核心业务逻辑。只需添加SendGridNotifier实现及其相关的组合根连接即可。这说明了清晰架构如何使我们能够集成强大的外部服务,同时保持我们的核心应用程序完全不变。

应用程序引导

正如我们在组件编排讨论中看到的,组合根汇集了我们所有的框架和驱动层组件,同时保持了清晰的架构边界。让我们进一步检查这种组合的实现,从我们的Application容器类开始。

Application容器类持有所有必需的应用程序组件作为字段:

@dataclass
class Application:
    """Container which wires together all components."""
    task_repository: TaskRepository
    project_repository: ProjectRepository
    notification_service: NotificationPort
    task_presenter: TaskPresenter
    project_presenter: ProjectPresenter 

然后在我们的实现中,我们利用__post_init__方法构建这些组件:

def __post_init__(self):
    """Wire up use cases and controllers."""
    # Configure task use cases
    self.create_task_use_case = CreateTaskUseCase(
      self.task_repository, self.project_repository)
    self.complete_task_use_case = CompleteTaskUseCase(
        self.task_repository, self.notification_service
    )
    self.get_task_use_case = GetTaskUseCase(self.task_repository)
    self.delete_task_use_case = DeleteTaskUseCase(self.task_repository)
    self.update_task_use_case = UpdateTaskUseCase(
        self.task_repository, self.notification_service
    )
    # Wire up task controller
    self.task_controller = TaskController(
        create_use_case=self.create_task_use_case,
        complete_use_case=self.complete_task_use_case,
        update_use_case=self.update_task_use_case,
        delete_use_case=self.delete_task_use_case,
        get_use_case=self.get_task_use_case,
        presenter=self.task_presenter,
    )
    # ... construction of Project use cases and controller 

Application类为我们组件之间的关系提供了结构,但我们仍然需要一个方法来创建正确配置的实例以注入到Application容器类中。这由我们的create_application工厂方法处理:

def create_application(
    notification_service: NotificationPort,
    task_presenter: TaskPresenter,
    project_presenter: ProjectPresenter,
) -> "Application":
    """ Factory function for the Application container. """
    # Call the factory methods
    task_repository, project_repository = create_repositories()
    notification_service = create_notification_service()
    return Application(
        task_repository=task_repository,
        project_repository=project_repository,
        notification_service=notification_service,
        task_presenter=task_presenter,
        project_presenter=project_presenter,
    ) 

这个工厂函数展示了清晰架构的依赖管理在实际中的应用:

  • 方法参数(notification_servicetask_presenterproject_presenter)接受抽象接口而不是具体实现

  • 端口组件是通过工厂方法创建的:create_repositoriescreate_notification_service方法

  • 所有这些组件都在最终的Application类实例化过程中汇集在一起,其中每个依赖项都得到了适当的配置和注入

create_application工厂方法和Application类之间的分离展示了清晰架构对关注点分离的重视。容器专注于组件关系,而工厂处理创建细节。

最后,我们的main.py脚本作为我们的组合根的顶端,这是所有组件在应用程序启动时实例化和连接在一起的唯一位置:

def main() -> int:
    """Main entry point for the CLI application."""
    try:
        # Create application with dependencies
        app = create_application(
            notification_service=NotificationRecorder(),
            task_presenter=CliTaskPresenter(),
            project_presenter=CliProjectPresenter(),
        )
        # Create and run CLI implementation
        cli = ClickCli(app)
        return cli.run()

    except KeyboardInterrupt:
        print("\nGoodbye!")
        return 0
    except Exception as e:
        print(f"Error: {str(e)}", file=sys.stderr)
        return 1
if __name__ == "__main__":
    sys.exit(main()) 

这个引导过程展示了如何通过 Clean Architecture 将本章中我们探索的所有组件整合在一起。注意create_application调用如何组装我们的核心组件,而ClickCli(app)初始化我们的框架适配器。这种分离是重要的:我们可以用使用相同create_application工厂但初始化不同框架适配器(如 FastAPI 或 Flask)而不是 Click CLI 的 Web 应用程序入口点来替换这个特定于 CLI 的主程序。

错误处理策略也值得注意。顶级try/except块管理系统边界的优雅关闭(KeyboardInterrupt)和意外错误,通过返回码提供了一种干净的退出策略。在整个创作过程中,清晰的架构边界保持完好:由create_application组装的业务逻辑对我们的 CLI 实现一无所知,而ClickCli适配器仅与我们的Application容器提供的抽象进行交互。

我们与存储库一起建立的组合模式自然扩展到所有我们的框架和驱动器层组件,创建了一个尊重清晰架构边界的统一系统,同时提供实用的功能。

让我们通过承认最终结果来结束本节:一个集成了本章中我们探索的所有组件的功能性 CLI。

图 7.7:任务管理应用程序的起始 CLI

图 7.7:任务管理应用程序的起始 CLI

图 7.7所示,我们的 Clean Architecture 实现使用户能够通过直观的界面管理项目和任务,收件箱项目展示了我们的架构选择如何支持自然的工作流程模式。

UI 能够无缝地显示项目、任务、其状态和优先级,同时处理用户交互,这展示了 Clean Architecture 如何使我们能够创建实用、用户友好的应用程序,同时不牺牲架构完整性。从项目名称到任务优先级,每条显示的信息都通过我们精心定义的架构边界流动,证明了 Clean Architecture 的原则可以转化为现实世界的功能。

摘要

在本章中,我们探讨了 Clean Architecture 的框架和驱动器层,展示了如何在保持清晰架构边界的同时整合外部关注点。通过我们的任务管理系统实现,我们看到了如何有效地管理框架、数据库和外部服务,同时保持我们的核心业务逻辑纯净和安全。

我们实现了几个关键模式,展示了 Clean Architecture 的实用好处:

  • 清晰地将 UI 关注点与业务逻辑分离的框架适配器

  • 展示接口灵活性的数据库实现

  • 保持核心独立性的外部服务集成

  • 随着系统需求演变的配置管理

这些实现展示了清洁架构的双重优势:在边缘隔离实现细节,同时为领域模型演进提供清晰的路径。我们两次看到了这一点。首先,在实现像 SendGrid 这样的外部服务时,我们没有触及我们的核心业务逻辑。其次,在演进我们的领域模型的任务-项目关系时,这需要在各个层级上进行系统性的变更。从存储库到框架适配器,对架构边界的细致关注帮助我们创建了一个可维护的系统,该系统可以适应这两种类型的变更。

第八章中,我们将探讨这些清晰的边界如何使我们的系统所有层级的全面测试策略成为可能。

进一步阅读

依赖注入器——Python 的依赖注入框架 (python-dependency-injector.ets-labs.org/)。对于更复杂的项目,你可以考虑使用依赖注入框架来管理我们在Application类中完成的工作。

第八章:使用清洁架构实现测试模式

在前面的章节中,我们通过仔细实现清洁架构的每一层,从纯领域实体到框架无关的接口,构建了一个任务管理系统。对于许多开发者来说,测试可能感觉令人压倒,这是一个随着系统发展而日益复杂的必要负担。清洁架构提供了一个不同的视角,提供了一种结构化的方法,使测试变得可管理和有意义。

现在我们已经处理了清洁架构的所有层,让我们退后一步,看看这种架构方法如何改变我们的测试实践。通过尊重清洁架构的边界和依赖规则,我们创建的系统天生就是可测试的。每一层的明确责任和显式接口不仅指导我们测试什么,还指导我们如何有效地测试。

在本章中,你将了解清洁架构的显式边界如何通过专注的单元和集成测试实现全面的测试覆盖率。通过实际示例,你会发现清洁架构的关注点分离如何让我们彻底验证系统行为,同时保持测试的可维护性。我们将看到定义良好的接口和依赖规则如何自然地导致既作为验证工具又作为架构护栏的测试套件。

到本章结束时,你将能够创建专注、可维护且能够早期捕捉问题的测试套件。将测试从负担转变为维护架构完整性的强大工具。在这个过程中,我们将探讨以下主题:

  • 清洁架构中测试的基础

  • 构建可测试组件:一种测试驱动的方法

  • 横跨架构边界的测试

  • 高级测试模式,适用于清洁系统

技术要求

本章和本书其余部分展示的代码示例均使用 Python 3.13 进行测试。为了简洁起见,本章中的大多数代码示例仅部分实现。所有示例的完整版本可以在本书配套的 GitHub 仓库github.com/PacktPublishing/Clean-Architecture-with-Python中找到。

清洁架构中测试的基础

清洁架构中精心构建的层和显式依赖关系不仅使我们的系统更易于维护,而且从根本上改变了我们对待测试的方式。许多团队面对复杂的代码库和模糊的边界时,会退而求其次,通过 Selenium 或无头浏览器等工具进行端到端测试。虽然这些测试可以提供信心,确保关键用户工作流程正常工作,但它们通常速度慢、脆弱,在发生故障时提供反馈较差。此外,在这样系统中设置全面的单元和集成测试可能会感到令人压倒。当所有东西都紧密耦合时,你甚至从哪里开始?

清晰的架构提供了一个不同的视角。我们不必主要依赖端到端测试,而可以通过尊重架构边界的专注、可维护的测试来建立对系统的信心。而不是与复杂的依赖和设置作斗争,我们发现我们的架构边界为构建有效的测试套件提供了自然的指导。

测试对于维护健康的软件系统至关重要。通过测试,我们验证我们的代码按预期工作,及早捕捉回归问题,并确保我们的架构边界保持完整。清晰的架构和依赖规则使得我们能够在系统的每个层级编写专注且易于维护的测试。

图 8.1:测试金字塔,展示了理想测试类型的分布

图 8.1:测试金字塔,展示了理想测试类型的分布

图 8.1 中所示的测试金字塔展示了在设计良好的系统中测试类型的理想分布。宽阔的基础由快速单元测试组成,这些测试在开发过程中验证单个组件的隔离,提供快速的反馈。向上移动,集成测试验证组件之间的交互,同时执行速度仍然相对较快。在顶部,少量端到端测试验证关键用户工作流程,尽管这些测试通常运行较慢,在发生故障时提供的反馈也不够精确。

这种架构方法通过其定义良好的接口和组件隔离,自然地实现了最优的测试分布。我们的核心业务逻辑,在领域和应用层中隔离,可以通过专注的单元测试轻松验证,而无需外部依赖。接口适配器为集成测试提供了清晰的边界,使我们能够在不测试整个工作流程的情况下验证组件交互。这种架构的清晰性意味着我们可以主要通过快速、专注的测试来建立对系统的信心。虽然通过用户界面进行端到端测试有其位置,但清晰的架构使我们能够仅通过专注的单元和集成测试来建立对系统的信心。

在本章中,我们将使用pytest,Python 的标准测试框架,来展示这些测试模式。通过利用清晰的架构边界,我们将看到pytest的简单方法如何帮助我们构建全面的测试覆盖,而无需复杂的测试框架或浏览器自动化工具。尽管清晰的架构测试优势适用于任何工具选择,但使用单一、成熟的框架让我们能够专注于架构原则,而不是测试语法。

Clean Architecture 比简单方法需要更多的初始设置,包括额外的接口和层分离,这可能对小应用程序来说似乎是不必要的。然而,这种前期投资将测试从复杂的技术挑战转变为直接的验证。紧密耦合的替代方案可能最初看起来更快,但很快就需要协调数据库和外部服务来测试基本功能。我们建立的架构纪律创造了本质上可测试的系统,使团队能够通过专注的单元测试而不是缓慢、脆弱的端到端测试来建立信心。团队可以选择性地采用这些模式,但了解测试的好处有助于指导这些架构决策。

测试作为架构反馈

测试不过是代码的客户。如果我们发现测试难以编写或需要复杂的设置,这通常意味着我们的生产代码需要改进。正如依赖规则指导我们的生产代码组织一样,它同样为有效的测试设计提供了信息。当测试变得尴尬或脆弱时,这通常表明我们违反了架构边界或混合了应该保持分离的关注点。

这种架构反馈循环是 Clean Architecture 最有价值的测试好处之一。明确的边界和接口自然地与各种测试方法相匹配,包括测试驱动开发TDD)。无论你是先写测试还是后写实现,Clean Architecture 的层都引导我们走向更好的设计:如果编写测试感觉尴尬,通常表明需要架构边界。如果测试设置变得复杂,这表明我们耦合了应该保持分离的关注点。这些信号作为早期警告,帮助我们识别和纠正架构违规,在它们在我们代码库中根深蒂固之前。

对于由于设置复杂或边界不明确而不愿采用全面单元测试的团队,Clean Architecture 提供了一条清晰的路径。每一层定义了明确的接口和依赖关系,提供了关于应该测试什么以及如何保持隔离的明确指导。在本章的剩余部分,我们将通过为任务管理系统中的每个架构层实现专注的测试来展示这些好处,展示 Clean Architecture 的边界如何自然地引导我们走向可维护的测试套件。

从测试复杂性到清晰的边界

许多开发者都在为缺乏清晰架构边界的代码库进行测试时感到困扰。在业务逻辑、持久性和展示关注点紧密耦合的系统中,即使是简单的测试也变成了复杂的技术挑战。考虑一个直接连接到数据库并在创建时发送通知的任务实体。测试其基本属性需要设置和管理这些外部依赖。这种关注点的耦合使得测试变得缓慢、脆弱且难以维护。团队通常会通过减少单元和集成测试,转而进行端到端测试来做出回应,尽管端到端测试很有价值,但在开发过程中却无法提供所需的快速反馈。

清洁架构通过在组件之间建立清晰的边界来改变这一景观。我们不再需要必须协调多个纠缠在一起的测试,而是可以专注于特定的职责:

  • 领域实体和业务规则可以在隔离状态下进行测试

  • 通过显式接口可以验证用例编排

  • 基础设施问题在系统边界处保持清晰分离

层次结构在实际开发工作中增强了开发工作流程。每个架构边界都提供了自然的指导:

  • 将错误隔离到特定的组件或交互中

  • 添加专注于捕获边缘情况的测试

  • 逐步构建全面的覆盖范围

这种清晰度显著提高了开发工作流程。当报告错误时,这种分层组织直接引导我们到适当的测试范围。领域逻辑问题可以在单元测试中重现,而集成问题有明确的边界可以检查。这种自然组织意味着随着我们维护和调试系统,我们的测试覆盖范围会自然地提高。每个已解决的问题都会导致针对特定行为的集中测试,逐步构建一个全面的测试套件,在它们达到生产之前捕捉边缘情况。

在接下来的章节中,我们将探讨这些测试模式在我们任务管理系统中的具体实现。您将看到清洁架构的边界如何使每种类型的测试更加专注和易于维护,从我们的领域层单元测试开始,逐步过渡到我们外部接口的集成测试。

测试清洁组件:实践中的单元测试

让我们看看清洁架构如何将单元测试从理论转化为实践。考虑一个简单的测试目标:验证新任务默认为中等优先级。在一个未与清洁架构范式对齐的代码库中,许多开发者都遇到过这样的类,其中简单的领域逻辑与基础设施纠缠在一起:

class Task(Entity):
    """Anti-pattern: Domain entity with
    direct infrastructure dependencies."""
    def __init__(self, title: str, description: str):
        self.title = title
        self.description = description
        # Direct database dependency:
        self.db = Database() 
        # Direct notification dependency:
        self.notifier = NotificationService() 
        self.priority = Priority.MEDIUM
        # Save to database and notify on creation
        self.id = self.db.save_task(self.as_dict())
        self.notifier(f"Task {self.id} created") 

这段紧密耦合的代码迫使我们进行复杂的设置来测试关于我们的Task实体的简单业务规则:

def test_new_task_priority_antipattern():
    """An anti-pattern mixing infrastructure concerns
    with simple domain logic."""
    # Complex setup just to test a default value
    db_connection = create_database_connection()
    notification_service = create_notification_service()
    # Just creating a task hits the database and notification service
    task = Task(
        title="Test task",
        description="Test description"
    )
    # Even checking a simple property requires a database query
    saved_task = task.db.get_task(task.id)
    assert saved_task['priority'] == Priority.MEDIUM 

这个测试虽然功能正常,但表现出几个常见问题。它需要复杂的设置,包括数据库和服务,仅为了验证一个简单的领域规则。当它失败时,原因可能是任何东西:

  • 是否存在数据库连接问题?

  • 通知服务是否未能初始化?

  • 或者,实际上是我们优先级默认逻辑存在问题?

在测试甚至基本属性方面的这种复杂性突出了为什么许多开发者认为测试是繁琐的,并且通常不值得付出努力

Clean Architecture 的边界通过保持我们的领域逻辑纯净和专注来消除这些问题。对于遵循 Clean Architecture 方法的代码,我们可以以显著清晰的方式测试这个相同的业务规则:

@dataclass
class Task:
    """Clean Architecture: Pure domain entity."""
    title: str
    description: str
    project_id: UUID
    priority: Priority = Priority.MEDIUM
def test_new_task_priority():
    """Clean test focused purely on domain logic."""
    task = Task(
        title="Test task",
        description="Test description",
        project_id=UUID('12345678-1234-5678-1234-567812345678')
    )
    assert task.priority == Priority.MEDIUM 

这种差异非常明显。通过保持我们的领域实体专注于业务规则:

  • 我们的测试验证了确切的一件事;新任务默认为中等优先级

  • 设置只需要我们测试所需的数据

  • 如果测试失败,只有一个可能的原因

  • 测试立即运行,没有外部依赖

这种关注点的清晰分离展示了 Clean Architecture 的关键测试优势之一:以最小设置和最大清晰度验证业务规则。Clean Architecture 的边界为构建全面的测试覆盖率提供了一个自然的进展。在本节中,我们将实现专注且可维护的测试,以验证行为同时尊重这些架构边界。我们将从测试领域实体的最简单情况开始,逐步向外扩展到我们的架构层。

测试领域实体

在深入具体测试之前,让我们确立一个在整个测试过程中都会为我们服务的模式。由 Bill Wake(xp123.com/3a-arrange-act-assert/)最初提出的** Arrange-Act-Assert** (AAA) 模式,为组织与 Clean Architecture 边界自然对齐的测试提供了一个清晰的框架:

  • Arrange:设置测试条件和测试数据

  • Act:执行正在被测试的行为

  • Assert:验证预期的结果

当测试领域实体时,这个模式变得特别优雅,因为 Clean Architecture 将我们的核心业务逻辑与外部关注点隔离开。考虑我们如何测试Task实体的完成行为:

def test_task_completion_captures_completion_time():
    """Test that completing a task records the completion timestamp."""
    # Arrange
    task = Task(
        title="Test task",
        description="Test description",
        project_id=UUID('12345678-1234-5678-1234-567812345678'),
    )

    # Act
    task.complete()

    # Assert
    assert task.completed_at is not None
    assert (datetime.now() - task.completed_at) < timedelta(seconds=1) 

这个测试展示了 Clean Architecture 中领域实体测试的本质。我们只需要做的是:

  1. 设置初始状态(一个具有所需属性的新任务)

  2. 执行一个动作(完成任务)

  3. 验证最终状态(记录了完成时间)

领域测试的清晰度来自 Clean Architecture 的关注点分离。我们不需要:

  • 设置或管理数据库连接

  • 配置通知服务

  • 处理身份验证或授权

  • 管理外部系统状态

我们正在测试纯净的业务逻辑:当任务完成时,它应该记录何时发生。这种关注点使我们的测试快速、可靠且易于阅读。如果测试失败,只有一个可能的原因,我们的完成逻辑没有正确工作。

这种对纯业务规则的专注是 Clean Architecture 为测试带来的关键好处之一。通过将我们的领域逻辑从基础设施关注点中隔离出来,我们可以通过简单、专注的测试来验证行为,这些测试作为我们业务规则的活文档。接下来,我们将看到这种测试的清晰性是如何随着我们从内部域层向外扩展而持续存在的。

Python 中的测试替身工具

在我们开始使用用例测试之前,让我们了解 Python 如何帮助我们创建测试替身,这些测试替身作为测试组件的依赖替换。在测试具有依赖关系的代码时,我们通常需要一种方法来用我们可以控制的模拟版本替换真实实现(如数据库或外部服务)。与pytest无缝集成的 Python 的unittest.mock库提供了创建这些测试替身的有力工具:

from unittest.mock import Mock
# Create a mock object that records calls and can return preset values
mock_repo = Mock()
# Configure the response we want
mock_repo.get.return_value = some_task
# Call will return some_task
mock_repo.get(123)
# Verify the call happened exactly once
mock_repo.get.assert_called_once()
# Mocks track all interaction details
# Shows what arguments were passed
print(mock_repo.get.call_args)
# Shows how many times it was called
print(mock_repo.get.call_count) 

这些模拟在测试中起到两个关键作用:

  • 它们让我们控制依赖项的行为(例如,确保存储库始终返回特定的任务)

  • 它们让我们验证我们的代码如何与这些依赖项交互(例如,确保我们恰好调用了一次save()

测试用例编排

随着我们从域层向外扩展,我们自然会遇到对我们系统其他组件的依赖。例如,一个任务完成用例需要既有用于持久化更改的存储库,又有用于通知利益相关者的通知服务。然而,Clean Architecture 通过接口进行抽象强调,将这些依赖从潜在的测试难题转变为直接的实现细节。

正如这些抽象允许我们将存储库的实现从基于文件的存储切换到 SQLite 而无需更改任何依赖代码一样,它们使我们能够在测试期间用测试替身替换实际实现。我们的用例依赖于抽象接口,如TaskRepositoryNotificationPort,而不是具体实现。这意味着我们可以在完全不修改用例代码的情况下为测试提供模拟实现。用例既不知道也不关心它是在与真实的 SQLite 存储库还是与测试替身一起工作。

让我们来看看我们如何使用模拟来独立测试我们的用例:

def test_successful_task_completion():
    """Test task completion using mock dependencies."""
    # Arrange
    task = Task(
        title="Test task",
        description="Test description",
        project_id=UUID('12345678-1234-5678-1234-567812345678'),
    )
    task_repo = Mock()
    task_repo.get.return_value = task
    notification_service = Mock()

    use_case = CompleteTaskUseCase(
        task_repository=task_repo,
        notification_service=notification_service
    )
    request = CompleteTaskRequest(task_id=str(task.id)) 

安排阶段展示了适当的单元测试隔离。我们模拟了存储库和通知服务,以确保我们正在独立测试用例的编排逻辑。这种设置保证了我们的测试不会受到数据库问题、网络问题或其他外部因素的影响。

测试流程通过不同的模拟验证来验证我们的用例的编排责任:

 # Act
    result = use_case.execute(request)
    # Assert
    assert result.is_success
    task_repo.save.assert_called_once_with(task)
    notification_service
      .notify_task_completed
      .assert_called_once_with(task) 

注意测试的断言如何专注于编排而不是业务逻辑。我们验证我们的用例协调正确的操作顺序,同时将那些操作的实现细节留给我们的测试替身。这种模式随着我们的用例变得更加复杂而自然扩展。无论是协调多个存储库、处理通知还是管理事务,Clean Architecture 的显式接口让我们可以通过集中的测试来验证复杂的工作流程。

在下一节中,我们将看到如何测试接口适配器引入了在系统边界处验证数据转换的新模式。

测试接口适配器

当我们转向接口适配器层时,我们的测试重点转向验证外部格式和我们的应用程序核心之间的正确转换。控制器和演示者充当这些翻译者,就像我们在之前层中的单元测试一样,我们希望模拟这一层之外的所有内容。我们不希望数据库连接、文件系统,甚至用例实现影响我们对转换逻辑的测试。Clean Architecture 的显式接口使这一点变得简单。我们可以模拟我们的用例,并专注于验证适配器在跨越系统边界时是否正确转换数据。

让我们看看我们如何测试控制器将外部字符串 ID 转换为我们的领域期望的 UUID 的责任。当 Web 或 CLI 客户端调用我们的系统时,他们通常会提供 ID 作为字符串。然而,我们的领域内部却使用 UUID。控制器必须处理这种转换:

def test_controller_converts_string_id_to_uuid():
    """Test that controller properly converts
    string IDs to UUIDs for use cases."""
    # Arrange
    task_id = "123e4567-e89b-12d3-a456-426614174000"
    complete_use_case = Mock()
    complete_use_case.execute.return_value = Result.success(
        TaskResponse.from_entity(
            Task(
                title="Test Task",
                description="Test Description",
                project_id=UUID('12345678-1234-5678-1234-567812345678')
            )
        )
    )
    presenter = Mock(spec=TaskPresenter)

    controller = TaskController(
        complete_use_case=complete_use_case,
        presenter=presenter,
    ) 

安排阶段设置我们的测试场景。我们提供一个任务 ID 作为字符串(就像客户端一样)并创建一个配置为返回成功结果的模拟用例。当创建我们的演示者模拟时,我们使用spec=TaskPresenter来创建一个严格的模拟,该模拟了解我们的演示者接口:

# Without spec, any method can be called
loose_mock = Mock()
loose_mock.non_existent_method()  # Works, but could hide bugs
# With spec, mock enforces the interface
strict_mock = Mock(spec=TaskPresenter)
strict_mock.non_existent_method()  # Raises AttributeError 

这种额外的类型安全在接口适配器层特别有价值,因为维护正确的接口边界至关重要。通过使用spec,我们确保我们的测试不仅捕捉到行为问题,还捕捉到违反契约的问题。

在我们的测试替身正确配置以强制执行接口边界后,我们可以验证控制器的转换逻辑:

 # Act
    controller.handle_complete(task_id=task_id)
    # Assert
    complete_use_case.execute.assert_called_once()
    called_request = complete_use_case.execute.call_args[0][0]
    assert isinstance(called_request.task_id, UUID) 

当我们调用handle_complete时,控制器应该:

  1. 从客户端获取字符串形式的任务 ID

  2. 将其转换为UUID

  3. 为用例创建一个正确格式化的请求

  4. 将此请求传递给用例的execute方法

我们通过以下断言验证此流程:

  • 确认用例被调用了一次

  • 提取传递给用例的请求

  • 验证该请求中的task_id现在是一个UUID,而不是一个字符串

这个测试确保控制器履行其核心责任:将外部数据格式转换为领域期望的类型。如果控制器未能将字符串标识符转换为UUID,则在检查called_request.task_id的类型时测试将失败。

同样,我们可以测试展示者以确保它们适当地格式化领域数据以供外部消费。让我们专注于一个特定的责任:将任务完成日期格式化为人类可读的字符串以供 CLI 使用。这种看似简单的转换是接口适配器角色的完美示例:

def test_presenter_formats_completion_date():
    """Test that presenter formats dates according to
    interface requirements."""
    # Arrange
    completion_time = datetime(2024, 1, 15, 14, 30, tzinfo=timezone.utc)
    task = Task(
        title="Test Task",
        description="Test Description",
        project_id=UUID('12345678-1234-5678-1234-567812345678')
    )
    task.complete()
    # Override completion time for deterministic testing
    task.completed_at = completion_time
    task_response = TaskResponse.from_entity(task)
    presenter = CliTaskPresenter() 

这个测试展示了 Clean Architecture 分层方法如何简化测试。由于我们的领域实体没有外部依赖,我们可以在测试中轻松创建和操作它们。我们不需要担心实际中完成时间是如何设置的。Task实体的内在业务规则将防止任何非法状态(如在一个未完成的任务上设置完成时间)。这种隔离使我们的展示者测试变得简单且可靠。

 # Act
    view_model = presenter.present_task(task_response)
    # Assert
    expected_format = "2024-01-15 14:30"
    assert expected_format in view_model.completion_info 

这个测试流程展示了 Clean Architecture 的显式边界如何使接口适配器测试变得简单直接。我们专注于验证数据格式化,而不涉及持久性、业务规则或其他我们单元测试已经验证的关注点。每个适配器都有一个明确且单一的责任,我们可以对其进行隔离测试。

虽然测试单个格式化关注点很有价值,但我们的展示者通常需要同时处理多个显示方面。让我们看看 Clean Architecture 的关注点分离如何帮助我们以清晰、系统的方法测试综合视图模型的创建:

def test_presenter_provides_complete_view_model():
    """Test presenter creates properly formatted view model
    with all display fields."""
    # Arrange
    task = Task(
        title="Important Task",
        description="Testing view model creation",
        project_id=UUID('12345678-1234-5678-1234-567812345678'),
        priority=Priority.HIGH
    )
    task.complete()  # Set status to DONE
    task_response = TaskResponse.from_entity(task)
    presenter = CliTaskPresenter()
      # Act
    view_model = presenter.present_task(task_response)

    # Assert
    assert view_model.title == "Important Task"
    assert view_model.status_display == "[DONE]"
    assert view_model.priority_display == "HIGH PRIORITY"
    assert isinstance(view_model.completion_info, str) 

这个测试验证了我们的展示者如何将多个领域状态方面转换为便于显示的格式。Clean Architecture 关注点的分离意味着我们可以验证所有我们的展示逻辑(状态指示器、优先级格式化和完成信息),而不会与业务规则或基础设施关注点纠缠。

通过为测试单个层建立这些模式,我们现在可以探索 Clean Architecture 如何帮助我们测试跨越架构边界的交互。

测试跨越架构边界

由于我们的单元测试通过显式接口彻底验证了业务规则和编排逻辑,我们的集成测试可以非常具有战略意义。我们的单元测试使用模拟来验证组件在隔离状态下的行为,而这些集成测试则确认我们的具体实现能够正确地一起工作。我们不是测试所有组件组合的每一种可能性,而是关注关键的边界跨越,特别是涉及持久性或外部服务的基础设施。

考虑这如何改变我们的测试方法。在我们的单元测试中,我们模拟了仓库以验证用例正确协调任务创建和项目分配。现在我们将测试我们的实际FileTaskRepositoryFileProjectRepository实现,在持久化到磁盘时是否维护这些关系。

让我们看看如何测试我们的文件系统持久化边界——这是集成测试提供价值超出单元测试覆盖范围的领域之一:

@pytest.fixture
def repository(tmp_path): # tmp_path is a pytest builtin for temp dirs
    """Create repository using temporary directory."""
    return FileTaskRepository(data_dir=tmp_path)
def test_repo_handles_project_task_relationships(tmp_path):
    # Arrange
    task_repo = FileTaskRepository(tmp_path)
    project_repo = FileProjectRepository(tmp_path)
    project_repo.set_task_repository(task_repo)

    # Create project and tasks through the repository
    project = Project(name="Test Project",
                      description="Testing relationships")
    project_repo.save(project)
    task = Task(title="Test Task",
                description="Testing relationships",
                project_id=project.id)
    task_repo.save(task) 

这个测试设置演示了一个关键的集成点,即我们创建实际的仓库,通过文件系统存储进行协调。我们的单元测试已经使用模拟验证了业务规则,因此这个测试纯粹关注于验证我们的基础设施层是否正确维护这些关系。

 # Act - Load project with its tasks
    loaded_project = project_repo.get(project.id)
    # Assert
    assert len(loaded_project.tasks) == 1
    assert loaded_project.tasks[0].title == "Test Task" 

测试验证了我们在单元测试中无法捕捉到的行为:

  • 项目可以从磁盘加载其关联的任务

  • 任务-项目关系在序列化后仍然存在

当处理跨越多个操作的架构保证时,这种仓库协调变得尤为重要。其中一个保证是我们的收件箱项目,这是在第七章中做出的关键基础设施级决策,以确保所有任务都有一个组织性的家。

另一个关键集成点是验证我们的ProjectRepository实现是否遵守这个收件箱保证。虽然我们的单元测试验证了围绕使用收件箱的业务规则(如防止其删除或完成),但我们的集成测试需要验证基础设施层正确维护这个特殊项目的存在:

def test_repository_automatically_creates_inbox(tmp_path):
    """Test that project repository maintains inbox project
    across instantiations."""
    # Arrange - Create initial repository and verify Inbox exists
    initial_repo = FileProjectRepository(tmp_path)
    initial_inbox = initial_repo.get_inbox()
    assert initial_inbox.name == "INBOX"
    assert initial_inbox.project_type == ProjectType.INBOX
    # Act - Create new repository instance pointing to same directory
    new_repo = FileProjectRepository(tmp_path)

    # Assert - New instance maintains same Inbox
    persisted_inbox = new_repo.get_inbox()
    assert persisted_inbox.id == initial_inbox.id
    assert persisted_inbox.project_type == ProjectType.INBOX 

这个测试验证了由于使用了模拟仓库,我们的单元测试无法捕捉到的行为。我们的具体仓库实现负责收件箱的初始化和持久化。通过创建两个指向同一数据目录的独立仓库实例,我们确认:

  • 仓库在首次使用时自动创建收件箱

  • 收件箱的特殊性质(其类型和 ID)正确持久化

  • 后续的仓库实例识别并维护这个相同的收件箱

这个专注的集成测试验证了一个基本的架构保证,它使我们的任务组织模式成为可能。我们不是测试每个可能的收件箱操作,而是验证使这些操作成为可能的核心基础设施行为。

在验证了我们的仓库实现和基础设施保证之后,让我们看看 Clean Architecture 如何使我们在用例级别进行专注的集成测试。考虑我们的任务创建用例。虽然我们的单元测试使用模拟仓库验证了其业务逻辑,但我们应确认它在使用真实持久化时能正确工作。Clean Architecture 的明确边界让我们能够有策略地进行测试,测试真实持久化同时模拟非持久化关注点,如通知:

def test_task_creation_with_persistence(tmp_path):
    """Test task creation use case with real persistence."""
    # Arrange
    task_repo = FileTaskRepository(tmp_path)
    project_repo = FileProjectRepository(tmp_path)
    project_repo.set_task_repository(task_repo)

    use_case = CreateTaskUseCase(
        task_repository=task_repo,
        project_repository=project_repo,
        notification_service=Mock()  # Still mock non-persistence concerns
    ) 

在此测试设置中,我们使用真实的存储库来验证持久性行为,同时模拟通知,因为它们与此集成测试不相关。

 # Act
    result = use_case.execute(CreateTaskRequest(
        title="Test Task",
        description="Integration test"
    ))
    # Assert - Task was persisted
    assert result.is_success
    created_task = task_repo.get(UUID(result.value.id))
    assert created_task.project_id == project_repo.get_inbox().id 

此测试验证我们的用例是否正确地与真实持久性一起编排任务创建:

  • 任务已正确保存到磁盘

  • 如预期,任务被分配到收件箱

  • 我们可以通过存储库检索持久化的任务

通过保持通知模拟,我们保持测试的专注性,同时仍然验证关键持久性行为。这种涉及测试特定边界的实际实现(同时模拟其他边界)的战略集成测试方法,展示了清洁架构如何帮助我们创建全面的测试覆盖范围,而不增加不必要的复杂性。

这些集成测试展示了清洁架构的明确边界如何使多组件关注点的专注、有效测试成为可能。我们不必依赖触及每个系统组件的端到端测试,而可以通过验证存储库协调、基础设施级别的保证和使用案例持久性来战略性地测试特定的边界,同时模拟辅助关注点。

当在您的清洁架构系统中实现集成测试时:

  • 让架构边界指导需要集成测试的内容

  • 仅对正在验证的边界进行实际实现测试

  • 信任你的业务规则单元测试覆盖率

  • 让每个测试都专注于特定的集成关注点

在下一节中,我们将探讨有助于在系统变得更加复杂时保持测试清晰性的测试模式。

测试维护的工具和模式

虽然“清洁架构”的边界有助于我们编写专注的测试,但维护一个全面的测试套件也带来了自己的挑战。随着我们的任务管理系统不断增长,我们的测试也在增加。新的业务规则需要额外的测试用例,基础设施变更需要更新验证,简单的修改可能会影响多个测试文件。如果没有仔细的组织,我们可能会花费更多的时间来管理测试,而不是改进我们的系统。

当测试失败时,我们需要快速了解哪个架构边界被违反。当业务规则发生变化时,我们应该能够系统地更新测试,而不是在多个文件中搜索。当添加新的测试用例时,我们希望利用现有的测试基础设施,而不是重复设置代码。

Python 的测试生态系统,特别是pytest,提供了与清洁架构目标自然对齐的强大工具。我们将探讨如何:

  • 在保持测试代码干净和专注的同时验证多个场景

  • 组织测试固定装置以尊重架构边界

  • 利用使维护更简单的测试工具

  • 捕捉可能违反我们架构完整性的微妙问题

通过实际示例,我们将看到这些模式如何帮助我们在不增加维护负担的情况下保持全面的测试覆盖率,让我们用更少的代码验证更多场景,同时保持测试尽可能干净,与我们的架构保持一致。

结构化测试文件

清洁架构的明确边界为我们提供了测试文件的天然组织结构。无论您的团队选择按类型(单元/集成)组织测试还是将它们放在一起,内部结构应与您的应用程序架构保持一致。一个示例测试目录结构可能如下所示:

tests/
    domain/
        entities/
            test_task.py
            test_project.py
        value_objects/
            test_deadline.py
    application/
        use_cases/
            test_task_use_cases.py
    # ... Remaining tests by layer 

这种组织方式通过文件系统边界强化了清洁架构的依赖规则。tests/domain 中的测试不需要从 applicationinterfaces 中导入任何内容,而 tests/interfaces 中的测试可以与所有层的组件一起工作,就像它们的实际生产对应物一样。这种结构对齐也提供了对潜在架构违规的早期警告。如果我们发现自己想要将存储库导入到领域实体测试中,尴尬的导入路径表明我们可能违反了清洁架构的依赖规则。

参数化测试以实现全面覆盖

在跨越架构边界进行测试时,我们经常需要在不同的条件下验证类似的行为。考虑我们的任务创建用例。我们需要测试多个输入组合下的项目分配、优先级设置和截止日期验证。为每个场景编写单独的测试方法会导致代码重复且维护困难。当业务规则发生变化时,我们需要更新多个测试,而不是单一的真实来源。

pytestparametrize 装饰器改变了我们处理这些场景的方式。我们不必重复测试代码,而是可以定义数据变体来测试我们的架构边界:

@pytest.mark.parametrize(
    "request_data,expected_behavior",
    [
        # Basic task creation - defaults to INBOX project
        (
            {
                "title": "Test Task",
                "description": "Basic creation"
            },
            {
                "project_type": ProjectType.INBOX,
                "priority": Priority.MEDIUM
            }
        ),
        # Explicit project assignment
        (
            {
                "title": "Project Task",
                "description": "With project",
                "project_id": "project-uuid"
            },
            {
                "project_type": ProjectType.REGULAR,
                "priority": Priority.MEDIUM
            }
        ),
        # High priority task
        # ... data for task
    ],
    ids=["basic-task", "project-task", "priority-task"]
) 

然后在上述 parametrize 装饰器之后的测试方法中,测试将针对参数列表中的每个项目运行一次:

def test_task_creation_scenarios(request_data, expected_behavior):
    """Test task creation use case handles various
    input scenarios correctly."""
    # Arrange
    task_repo = Mock(spec=TaskRepository)
    project_repo = FileProjectRepository(tmp_path) 
    # Real project repo for INBOX

    use_case = CreateTaskUseCase(
        task_repository=task_repo,
        project_repository=project_repo
    )

    # Act
    result = use_case.execute(CreateTaskRequest(**request_data))

    # Assert
    assert result.is_success
    created_task = result.value
    if expected_behavior["project_type"] == ProjectType.INBOX:
        assert UUID(created_task.project_id) == (
            project_repo.get_inbox().id
        )
    assert created_task.priority == expected_behavior["priority"] 

此测试展示了参数化测试的几个关键好处。装饰器将每个测试用例的 request_dataexpected_behavior 注入到我们的测试方法中,其中 request_data 代表系统边缘的输入,而 expected_behavior 定义了我们的预期领域规则。这种分离让我们可以声明式地定义测试场景,同时保持验证逻辑干净且专注。

ids 参数使测试失败更有意义:不是 test_task_creation_scenarios[0] 失败,我们看到 test_task_creation_scenarios[basic-task] 失败,立即突出显示哪个场景需要关注。

当使用参数化测试时,将相关场景分组并提供清晰的场景标识符是最佳实践。这种方法在测试数据变化的同时保持测试逻辑的专注,帮助我们在不牺牲测试清晰度的情况下保持全面的覆盖率。

在组织好我们的测试场景后,让我们探讨pytest的固定装置系统如何帮助我们管理跨架构边界的测试依赖。

组织测试固定装置

在我们的测试示例中,我们使用了pytest固定装置来管理测试依赖,从提供干净的任务实体到配置模拟存储库。虽然这些单独的固定装置满足了我们的即时测试需求,但随着测试套件的增长,管理跨架构边界的测试设置变得越来越复杂。每一层都有自己的设置需求:领域测试需要干净的实体实例,用例测试需要正确配置的存储库和服务,而接口测试需要格式化的请求数据。

pytest的固定装置系统,尤其是与它的conftest.py文件结合使用,帮助我们跨测试层次结构扩展这种固定装置模式,同时保持 Clean Architecture 的边界。通过将固定装置放置在适当的测试目录中,我们确保每个测试都能获得它确切需要的,而不需要额外的依赖:

# tests/conftest.py - Root fixtures available to all tests
@pytest.fixture
def sample_task_data():
    """Provide basic task attributes for testing."""
    return {
        "title": "Test Task",
        "description": "Sample task for testing",
        "project_id": UUID('12345678-1234-5678-1234-567812345678'),
    }
# tests/domain/conftest.py - Domain layer fixtures
@pytest.fixture
def domain_task(sample_task_data):
    """Provide a clean Task entity for domain tests."""
    return Task(**sample_task_data)
# tests/application/conftest.py - Application layer fixtures
@pytest.fixture
def mock_task_repository(domain_task):
    """Provide a pre-configured mock repository."""
    repo = Mock(spec=TaskRepository)
    repo.get.return_value = domain_task
    return repo 

这种组织自然通过我们的测试结构强制执行 Clean Architecture 的依赖规则。需要同时使用领域实体和存储库的测试必须位于应用层或更高层,因为它依赖于这两层的固定装置。同样,仅使用领域实体的测试可以确信它没有意外地依赖于基础设施问题。

固定装置本身尊重我们的架构边界:

# tests/interfaces/conftest.py - Interface layer fixtures
@pytest.fixture
def task_controller(mock_task_repository, mock_notification_port):
    """Provide a properly configured TaskController."""
    return TaskController(
        create_use_case=CreateTaskUseCase(
            task_repository=mock_task_repository,
            project_repository=Mock(spec=ProjectRepository),
            notification_service=mock_notification_port
        ),
        presenter=Mock(spec=TaskPresenter)
    )
@pytest.fixture
def task_request_json():
    """Provide sample request data as it would come from clients."""
    return {
        "title": "Test Task",
        "description": "Testing task creation",
        "priority": "HIGH"
    } 

当在架构边界使用固定装置时,结构它们以匹配您的生产依赖注入。例如,为了验证我们的控制器是否正确地将外部请求转换为用例操作:

def test_controller_handles_task_creation(
    task_controller,
    task_request_json,
    mock_task_repository
):
    """Test task creation through controller layer."""
    result = task_controller.handle_create(**task_request_json)

    assert result.is_success
    mock_task_repository.save.assert_called_once() 

这种基于固定装置的方法在几个实际方面都有回报:

  • 测试保持关注行为而不是设置。我们的测试验证控制器责任,而不需要设置代码使测试方法杂乱。

  • 常见的测试配置是可重用的。同一个task_controller测试固定装置可以支持多个控制器测试场景。

  • 依赖关系是明确的。测试的参数清楚地显示了我们在处理哪些组件。

  • 组件初始化的更改只需在固定装置中更新,而不需要在每个测试中更新。

接下来,让我们考察这些模式如何与测试工具结合使用,以捕捉微妙的架构违规。

测试工具和技术

即使有组织良好的测试和固定装置,某些测试场景也面临着独特的挑战。一些测试在孤立的情况下可以通过,但由于隐藏的时间或状态依赖关系而失败,而其他测试可能掩盖了仅在特定条件下才会暴露的架构违规。让我们探讨一些实用的工具,这些工具有助于在尊重我们的架构边界的同时维护测试的可靠性。从控制测试中的时间到暴露隐藏的状态依赖关系,再到大规模管理测试套件执行,这些工具帮助我们捕捉到在系统深处根深蒂固的微妙架构违规。

测试中的时间管理

测试截止日期计算或基于时间的通知需要仔细处理时间。在我们的任务管理系统中,我们有几个时间敏感的功能。任务可能会逾期,截止日期临近时触发通知,完成任务会记录完成时间。如果不控制时间来测试这些功能,就会变得有问题。想象一下测试一个任务在其截止日期后逾期的情况。我们可能需要等待实际时间的流逝(使测试变得缓慢且不可靠),或者操纵系统时间(可能影响其他测试)。更糟糕的是,基于时间的测试可能会根据它们在一天中的运行时间而通过或失败。

freezegun库通过允许我们在测试中控制时间而不修改我们的领域逻辑来解决这些问题。首先,安装库:

pip install freezegun 

freezegun库提供了一个上下文管理器,允许我们在其作用域内为代码运行设置特定的时间点。任何在freeze_time():块内的代码都将看到时间在那个时刻被冻结,而块外的代码将继续使用正常时间。这让我们能够在我们的领域实体继续使用真实的datetime对象的同时创建精确的测试场景:

from freezegun import freeze_time
def test_task_deadline_approaching():
    """Test deadline notifications respect time boundaries."""
    # Arrange
    with freeze_time("2024-01-14 12:00:00"):
        task = Task(
            title="Time-sensitive task",
            description="Testing deadlines",
            project_id=UUID('12345678-1234-5678-1234-567812345678'),
            due_date=Deadline(datetime(
                2024, 1, 15, 12, 0, tzinfo=timezone.utc))
        )

    notification_service = Mock(spec=NotificationPort)
    use_case = CheckDeadlinesUseCase(
        task_repository=Mock(spec=TaskRepository),
        notification_service=notification_service,
        warning_threshold=timedelta(days=1)
    ) 

在这个测试安排中,我们在 1 月 14 日中午冻结时间,以创建一个 24 小时后到期的任务。这为我们测试截止日期计算提供了精确的初始状态。我们的领域实体继续使用标准的datetime对象,保持了 Clean Architecture 的关注点分离。只有对当前时间的感知受到影响:

 # Act
    with freeze_time("2024-01-14 13:00:00"):
        result = use_case.execute()
    # Assert
    assert result.is_success
    notification_service.notify_task_deadline_approaching.assert_called_once() 

将时间向前推进一小时,我们可以验证我们的截止日期通知系统是否正确地识别了在警告阈值内的任务。测试立即运行,同时模拟了一个在现实世界中需要数小时才能验证的场景。我们的实体和用例保持对它们在模拟时间中运行的无知,维护了清晰的架构边界,同时使彻底测试时间相关行为成为可能。

这种模式将时间相关的逻辑保留在我们的领域内,同时使其可测试。我们的实体和用例使用真实的datetime对象工作,但我们的测试可以验证它们在特定时间点的行为。

暴露状态依赖

依赖于隐藏状态或执行顺序的测试可能会掩盖架构上的违规,尤其是在全局状态方面。在 Clean Architecture 中,每个组件应该是自包含的,依赖关系应通过接口显式传递。然而,微妙的全局状态可能会悄悄进入。以我们的任务管理系统中的通知服务为例:它可能维护一个内部队列,用于存储待处理的通知,这些通知可以在测试之间传递。一个验证高优先级任务通知的测试在单独运行时可能通过,但在运行填充此队列的测试之后可能会失败。或者,我们的项目仓库可能为了性能缓存任务数量,导致测试根据其他测试是否操作了这个缓存而通过或失败。

这些隐藏的状态依赖不仅使测试不可靠,而且通常表明存在架构违规,即维护应在我们接口中明确的状态的组件。最好尽可能早地暴露这些问题,因此强烈建议采用随机顺序运行测试的做法。使用 pytest 可以通过首先安装 pytest-random-order 来实现:

pip install pytest-random-order 

然后配置它以在每次测试时运行:

# pytest.ini
[pytest]
addopts = --random-order 

当测试以随机顺序运行时,隐藏的状态依赖会通过测试失败迅速显现。一旦测试依赖于全局状态或执行顺序,它就会不可预测地失败。这是一个明确的信号,表明我们需要调查我们的架构边界。当发生此类失败时,插件会提供一个种子值,让您能够重现确切的测试执行顺序:

pytest --random-order-seed=123456 

您可以根据需要多次以种子指定的顺序运行测试,以确定失败的根本原因。

加速测试执行

随着您的测试目录增长,执行时间可能成为一个重大问题。最初快速运行的测试套件现在需要几分钟才能运行。在我们的任务管理系统里,我们已经对所有层进行了全面的覆盖,包括领域实体、用例、接口适配器和基础设施。按顺序运行所有这些测试,尤其是涉及文件系统操作或基于时间的行为的测试,可能会在开发反馈循环中造成明显的延迟。

快速的测试执行对于维护架构完整性至关重要。长时间运行的测试套件会阻碍开发过程中的频繁验证,增加了架构违规可能被忽视的风险。pytest-xdist 提供了并行化测试执行的同时保持测试完整性的工具。首先使用 pip 安装插件:

pip install pytest-xdist 

在您的 pytest.ini 文件中配置并行执行:

# pytest.ini
[pytest]
addopts = --random-order -n auto  # Combine random order with parallel execution 

对于任何无法在单个并行化组中运行的测试场景(例如,共享已知全局状态或资源的测试),pytest-xdist 提供了几个工具:

  • 使用 @pytest.mark.serial 标记必须按顺序运行的测试

  • 使用 @pytest.mark.resource_group('global-cache') 配置资源范围,以确保使用相同资源的测试一起运行

-n auto 标志自动利用可用的 CPU 核心,尽管如果需要,您可以指定一个确切的数量,例如 -n 4。这种方法使我们能够保持快速的测试执行,同时尊重我们的架构边界约束。关键的测试,用于验证我们的清洁架构原则,运行得足够快,可以成为每个开发周期的组成部分,有助于早期发现架构违规。

摘要

在本章中,我们探讨了清洁架构的原则如何直接转化为有效的测试实践。我们学习了如何通过架构边界自然引导我们的测试策略,明确测试的内容和如何构建这些测试。通过我们的任务管理系统,我们看到了清洁架构如何使我们能够专注于测试,而无需过度依赖端到端测试,同时保持我们的系统具有适应性和可持续性。

我们实施了几个关键的测试模式,展示了清洁架构的好处:

  • 利用清洁架构的自然边界进行专注验证的单元测试

  • 验证特定架构层之间行为的集成测试

  • 构建可维护测试套件的工具和模式

最重要的是,我们看到了清洁架构对依赖和接口的细致关注如何使我们的测试更加专注和易于维护。通过组织我们的测试以尊重架构边界,从文件结构到测试夹具,我们创建了与我们的系统一起优雅增长的测试套件。

第九章中,我们将探讨如何将清洁架构的原则应用于 Web 界面设计,展示我们如何通过关注架构边界来添加完整的基于 Flask 的 Web 界面到我们的任务管理系统,同时对核心应用程序进行最小改动。这个实际演示将突出清洁架构关注点分离如何使我们能够在维护现有 CLI 的同时无缝引入新的用户界面。

进一步阅读

  • 软件测试指南 (martinfowler.com/testing/). 收集了马丁·福勒博客上的所有测试文章。

  • 拒绝更多端到端测试 (testing.googleblog.com/2015/04/just-say-no-to-more-end-to-end-tests.html). 一篇由谷歌测试团队撰写的博客,论述过度依赖端到端测试可能导致软件开发中复杂性增加、易出错和反馈延迟,并提倡采取平衡的方法,强调单元测试和集成测试的重要性。

  • 使用 pytest 进行 Python 测试* (pytest.org/). 本章所使用的测试工具的官方pytest文档,提供了关于测试工具的详细信息。

  • 测试驱动开发 (www.oreilly.com/library/view/test-driven-development/0321146530/). Kent Beck,TDD 的先驱之一,所著的 TDD 指南。本书为理解 TDD 如何改进软件设计以及它如何与清洁架构等架构模式自然对齐提供了坚实的基础。

第三部分

在 Python 中应用清洁架构

最后一部分展示了如何在现实场景和不同环境中应用清洁架构原则。您将实现一个完整的 Web 界面,在保持架构完整性的同时添加可观察性功能,通过增量重构改造遗留系统,并将清洁架构适应不同系统类型和组织环境。这些章节提供了将清洁架构扩展到我们的示例应用之外,以应对复杂现实挑战的实用指导。

本书本部分包括以下章节:

  • 第九章添加 Web UI:清洁架构的界面灵活性

  • 第十章实现可观察性:监控和验证

  • 第十一章从遗留系统到清洁:重构 Python 以提高可维护性

  • 第十二章您的清洁架构之旅:下一步

第九章:添加 Web UI:纯净架构的接口灵活性

在前面的章节中,我们通过任务管理系统建立了纯净架构的基础模式。我们构建了领域实体,实现了用例,并创建了一个命令行界面(CLI),展示了纯净架构的边界如何使我们的核心业务逻辑和用户界面之间的分离变得清晰。虽然 CLI 提供了一个功能接口,但许多应用程序需要基于 Web 的访问。这为我们展示了纯净架构的原则如何在不损害架构完整性的情况下实现接口进化提供了极好的机会。

通过我们的任务管理系统,我们将展示纯净架构的关键优势之一:在不修改现有代码的情况下添加新接口的能力。因为我们的领域逻辑、用例和控制器都是基于适当的架构边界构建的,添加一个网络接口就变成了一项纯粹的增加性练习。不需要对现有组件进行重构。这个使得添加网络用户界面变得简单的相同原则,也使得长期维护多个接口成为可能,因为每个接口都可以独立进化,同时共享相同的强大核心。

到本章结束时,你将了解如何在保持架构边界的同时实现额外的接口。你将能够将这些模式应用到自己的项目中,确保你的应用程序在接口需求进化时保持适应性。

本章,我们将涵盖以下主要主题:

  • 在纯净架构中理解接口灵活性

  • 纯净架构中的 Web 展示模式

  • 将 Flask 与纯净架构集成

技术要求

本章和本书其余部分展示的代码示例已在 Python 3.13 上进行了测试。为了简洁,本章中的大多数代码示例仅部分实现。所有示例的完整版本可以在本书配套的 GitHub 存储库github.com/PacktPublishing/Clean-Architecture-with-Python中找到。

在纯净架构中理解接口灵活性

我们的任务管理系统 CLI(在第七章中实现),展示了纯净架构在核心业务逻辑和用户界面之间的谨慎分离。这种分离不仅是一种良好的实践,而且是为我们将在本章中完成的具体任务进行的战略准备:在保留现有功能的同时添加一个全新的用户界面。

理解我们的 Web 实现

为了实现我们的网页界面,我们将使用Flask——一个轻量级且灵活的 Python 网页框架。Flask 的明确请求处理和直接的应用程序结构使其非常适合展示清洁架构的边界。其最小核心和广泛的可选扩展生态系统与清洁架构对显式依赖的偏好相吻合。虽然我们将探索的图案同样适用于 Django、FastAPI 或其他网页框架,但 Flask 的简单性有助于我们将重点放在架构原则而不是框架特定功能上。

通过基于浏览器的界面,用户现在可以使用熟悉的、由网页特定功能增强的工作流程来管理他们的项目和任务。当用户访问应用程序时,他们会看到一个干净、分层的视图,展示他们的项目和相关的任务:

图 9.1:Web UI 列表页面,显示项目和它们相关的任务

图 9.1:Web UI 列表页面,显示项目和它们相关的任务

网页界面通过即时视觉反馈和直观的导航增强了我们现有的任务管理功能。用户可以创建新的任务,更新它们的状态,并在项目中组织它们。界面将我们的现有业务逻辑适配到网页惯例,使用标准模式,如表单提交用于任务创建和闪存消息用于用户反馈。

为了在保持我们的架构边界的同时实现此界面,我们的网页实现被组织成不同的组件:

图 9.2:Web UI 实现的相关文件

图 9.2:Web UI 实现的相关文件

这种结构展示了清洁架构在实际操作中的关注点分离。在我们的适配器和接口(interfaces)层,网页展示者知道如何通过创建 HTML 友好的字符串和为模板结构化数据来格式化数据以供网页显示,但完全不了解 Flask 或任何特定的网页框架。这些展示者可以同样好地与 Django、FastAPI 或任何其他网页框架一起工作。

这种分离与没有明确架构边界的应用程序形成鲜明对比。在一个结构较松散的应用程序中,对添加网页界面的请求往往会触发代码库中的连锁反应。业务逻辑与展示关注点的混合需要大量的重构。嵌入在展示逻辑中的数据库查询需要重新构建。即使是像为网页显示格式化日期这样的看似简单的更改,也可能需要在多个组件中进行修改。在极端情况下,团队发现自己实际上在重写他们的应用程序以适应新的界面。

相比之下,我们的任务管理系统将 Web 界面视为纯粹的增加性变化。不需要修改现有代码:既不是我们的业务规则,也不是我们的用例,甚至不是我们的 CLI。这种在不干扰现有功能的情况下添加主要功能的能力,展示了 Clean Architecture 在系统演变中的实际价值。

框架特定的代码位于它所属的位置——在我们的框架和驱动层中的infrastructure/web目录内。在这里,Flask 特定的关注点,如路由处理、模板配置和 HTTP 会话管理,保持在我们系统边缘的隔离。这种分离意味着我们可以在不触及我们的接口适配器或核心业务逻辑的情况下切换 Web 框架。

并行接口实现

在深入研究我们的 Web 实现细节之前,让我们检查我们的 CLI 和 Web 界面如何在 Clean Architecture 系统中共存。虽然这些界面通过非常不同的机制(命令行与 HTTP)为用户提供服务,但它们共享相同的核心组件并遵循相同的建筑模式。

图 9.3:请求流比较

图 9.3:请求流比较

此图说明了我们的架构如何在支持多个接口的同时保持清晰的边界:

  • CLI通过 Click 命令处理器转换命令行输入

  • Web 界面通过 Flask 路由处理器处理 HTTP 请求

  • 共享核心包含我们的任务控制器、用例和实体

Clean Architecture 通过严格的依赖规则实现了这种共存。两个接口处理器连接到同一个任务控制器,但核心组件完全不知道它们是如何被使用的。这种隔离意味着我们的核心业务逻辑可以专注于任务创建规则,而每个接口处理其特定的关注点,无论是解析命令行参数还是处理表单提交。

为了实现这种分离,我们通过应用程序容器使用实用的依赖注入方法:

# todo_app/infrastructure/configuration/container.py
@dataclass
class Application:
    """Container which wires together all components."""
    task_repository: TaskRepository
    project_repository: ProjectRepository
    notification_service: NotificationPort
    task_presenter: TaskPresenter
    project_presenter: ProjectPresenter 

注意每个组件是如何使用抽象接口(TaskRepositoryNotificationPort等)声明的。这使每个接口实现能够提供其自己的特定依赖,而我们的应用程序核心仍然不知道它将接收的具体实现。应用程序工厂展示了这种灵活性在实际中的工作方式。

我们的应用程序工厂实现了 Clean Architecture 的组成根模式,它作为唯一的点,将我们的接口无关的核心与接口特定的实现组合在一起。工厂展示了两个关键的建筑原则:

# todo_app/infrastructure/configuration/container.py
def create_application(
    notification_service: NotificationPort,
    task_presenter: TaskPresenter,
    project_presenter: ProjectPresenter,
) -> "Application":
    """Factory function for the Application container."""
    task_repository, project_repository = create_repositories()

    return Application(
        task_repository=task_repository,
        project_repository=project_repository,
        notification_service=notification_service,
        task_presenter=task_presenter,
        project_presenter=project_presenter,
    ) 

首先,工厂展示了清洁架构的依赖倒置原则在实际中的应用:接口特定的组件(展示者)作为参数传递,而核心基础设施(存储库)是内部构建的。这种分离意味着接口实现可以提供自己的展示者,而工厂确保一切都能正确连接到我们的共享业务核心。

其次,工厂作为组合根,是抽象接口与其具体实现相遇的唯一点。

我们的 CLI 应用程序展示了这种对不同接口的适应性。在应用程序边界,我们将共享的核心与 CLI 特定的组件连接起来:

# cli_main.py
def main() -> int:
    """Main entry point for the CLI application."""
    app = create_application(
        notification_service=NotificationRecorder(),
        task_presenter=CliTaskPresenter(),
        project_presenter=CliProjectPresenter(),
    )
    cli = ClickCli(app)
    return cli.run() 

注意main()如何通过提供接口特定的实现(CliTaskPresenterCliProjectPresenter)到我们的通用应用程序容器来配置 CLI 特定的应用程序实例。然后ClickCli类包装这个核心应用程序,处理命令行交互与我们的应用程序接口无关的操作。围绕我们的核心应用程序包装接口特定代码的模式是清洁架构的基本实践,我们将在 Web 实现中看到其镜像。

通过以这种方式设置我们的应用程序,我们建立了一个清晰的模式,说明新的接口如何连接到我们的核心应用程序。要添加我们的 Web 接口,我们需要实现类似的角色,但针对 Web 特定的关注点:

  • 展示层:为 HTML 模板实现WebTaskPresenter

  • 请求处理:处理表单提交和 URL 参数

  • 会话状态:管理请求之间的持久性

  • 用户反馈:实现 Web 特定的错误展示

关键的洞见在于所有与接口相关的关注点都保持在我们的系统边缘。每个接口处理其独特的需求,例如 Web 会话管理或 CLI 参数解析,而我们的核心业务逻辑保持专注且清晰。

在下一节中,我们将探讨特定于 Web 界面的展示模式,看看这些保持了 CLI 实现清洁的相同原则如何指导我们创建可维护的特定于 Web 的组件。

常见的接口边界违规

清洁架构的有效性取决于在层之间保持清晰的边界。一个常见的违规行为是当开发者允许接口特定的格式渗透到控制器中,从而产生错误的方向依赖。考虑以下反模式:

# Anti-pattern: Interface-specific logic in controller
def handle_create(self, request_data: dict) -> dict:
    """DON'T: Mixing CLI formatting in controller."""
    try:
        result = self.create_use_case.execute(request_data)
        if result.is_success:
            # Wrong: CLI-specific formatting doesn't belong here
            return {
                "message": click.style(
                    f"Created task: {result.value.title}",
                    fg="green"
                )
            }
    except ValueError as e:
        # Wrong: CLI-specific error formatting
        return {"error": click.style(str(e), fg="red")} 

这种实现方式在微妙但重要的方式上违反了清洁架构的依赖规则。控制器位于我们的接口适配器层,直接引用 Click(一个应该限制在我们最外层框架的框架)。这造成了一个问题耦合,因为我们的控制器现在既依赖于应用层(内部)又依赖于框架层(外部),违反了清洁架构的基本规则,即依赖关系应该只指向内部。除了架构违规之外,这种耦合还有实际后果:我们无法重用这个控制器来处理我们的 Web 界面,而且即使更新到 Click 的新版本,也需要在我们的接口适配器层进行更改。

相反,我们的任务管理系统正确地将所有格式化问题委托给特定于接口的展示者。注意我们的控制器只依赖于抽象的展示者接口。它不知道它是在与命令行界面(CLI)、Web 界面还是任何其他具体的展示者实现工作:

# Correct: Interface-agnostic controller
def handle_create(self, title: str, description: str) -> OperationResult:
    """DO: Keep controllers interface-agnostic."""
    try:
        request = CreateTaskRequest(title=title, description=description)
        result = self.create_use_case.execute(request)
        if result.is_success:
            view_model = self.presenter.present_task(result.value)
            return OperationResult.succeed(view_model)
        error_vm = self.presenter.present_error(
            result.error.message, str(result.error.code.name)
        )
        return OperationResult.fail(error_vm.message, error_vm.code)
    except ValueError as e:
        error_vm = self.presenter.present_error(
            str(e), "VALIDATION_ERROR")
        return OperationResult.fail(error_vm.message, error_vm.code) 

这种修正后的实现展示了几个清洁架构的原则:

  • 控制器接受简单类型(str)而不是特定于框架的结构

  • 错误处理产生无框架的OperationResult实例

  • 所有格式化都委托给抽象的presenter接口

  • 控制器始终专注于协调用例和展示层之间的工作

这种方法带来了显著的实际效益。在我们的清洁实现中,框架更改只会影响最外层。我们可以通过简单地实现新的适配器来替换 Click,而不必触及我们的控制器、用例或领域逻辑。相同的控制器可以以相同的方式处理请求,无论这些请求是从我们的 CLI、Web 界面还是我们可能添加的任何未来界面发起。

接口适配器层充当保护边界,在领域核心和外部接口之间转换数据。这个架构边界使我们能够在不破坏现有组件的情况下添加 Web 界面。我们的领域实体专注于业务规则,而特定于接口的关注点则被适当地隔离在系统边缘。

既然我们已经确定了清洁架构的边界如何使接口具有灵活性,那么让我们来检查为 Web 界面所需的特定展示模式以及它们如何保持相同的架构原则。

清洁架构中的 Web 展示模式

在确立了 Clean Architecture 如何实现界面灵活性之后,我们现在转向网络展示所需的具体模式。虽然我们的 CLI 直接格式化数据以供控制台输出,但网络界面必须处理更复杂的展示需求:为 HTML 模板格式化数据、跨多个请求管理状态,以及通过表单验证和闪存消息(在页面顶部显示的临时通知横幅,如图 9.1中显示的绿色成功消息)提供用户反馈。本节将探讨这些特定于网络的挑战,并展示 Clean Architecture 的边界如何指导我们的实现选择。

我们将检查特定于网络的演示者如何格式化域数据以供 HTML 显示,确保我们的模板接收适当结构化的信息。我们将看到如何跨请求的状态管理可以尊重 Clean Architecture 的边界,以及如何表单处理可以保持网络关注点和业务规则之间的分离。通过这些模式,我们将展示尽管网络界面复杂,但它们可以干净地与我们的现有架构集成。

实现特定于网络的演示者

为了连接我们的域逻辑和网络展示需求,我们需要理解网络约定的演示者。为了了解我们的网络演示者应该如何工作,让我们首先检查第七章中的 CLI 演示者。注意它如何封装所有 CLI 特定的格式化决策(括号中的状态、彩色优先级)同时通过TaskViewModel保持一个干净的界面。这种将域对象转换为适合界面的视图模型的模式将指导我们的网络实现:

# CLI Presenter from *Chapter 7*
def present_task(self, task_response: TaskResponse) -> TaskViewModel:
    """Format task for CLI display."""
    return TaskViewModel(
        id=task_response.id,
        title=task_response.title,
        # CLI-specific bracketed format:
        status_display=f"[{task_response.status.value}]", 
        # CLI-specific coloring:
        priority_display=self._format_priority(task_response.priority)
    ) 

我们的网络演示者遵循相同的模式,但适应 HTML 显示的格式化:

class WebTaskPresenter(TaskPresenter):
    def present_task(self, task_response: TaskResponse) -> TaskViewModel:
        """Format task for web display."""
        return TaskViewModel(
            id=task_response.id,
            title=task_response.title,
            description=task_response.description,
            status_display=task_response.status.value,
            priority_display=task_response.priority.name,
            due_date_display=self._format_due_date(
                task_response.due_date),
            project_display=task_response.project_id,
            completion_info=self._format_completion_info(
                task_response.completion_date,
                task_response.completion_notes
            ),
        ) 

注意WebTaskPresenter类如何提供针对网络显示需求额外的字段和格式:HTML 友好的状态值、浏览器显示的日期格式,以及用于模板渲染的结构化完成信息。这种实现展示了 Clean Architecture 的演示者如何作为域概念和展示需求之间的系统化翻译层:

  • 将域对象转换为适合界面的格式,同时保留其业务意义

  • 将所有展示决策集中在一个单一、可测试的组件中

  • 使每个接口能够根据其特定需求适应域数据

  • 在域逻辑和展示关注点之间保持清晰的分离

演示者不仅格式化数据;它作为域概念在界面中呈现的权威解释者。考虑我们的日期格式化方法:

def _format_due_date(self, due_date: Optional[datetime]) -> str:
    """Format due date for web display."""
    if not due_date:
        return ""
    is_overdue = due_date < datetime.now(timezone.utc)
    date_str = due_date.strftime("%Y-%m-%d")
    return f"Overdue: {date_str}" if is_overdue else date_str 

_format_due_date方法封装了所有与日期相关的格式化决策:时区处理、日期格式字符串和逾期状态检查。通过将这些决策包含在演讲者中,我们确保我们的领域实体保持专注于业务规则(何时完成任务)同时,展示关注点(如何显示到期日期)保持在适当的架构层。

这个翻译层允许我们的模板保持简单,同时仍然提供丰富、上下文相关的信息:

<span class="badge
    {% if 'overdue' in task.due_date_display %}bg-danger
    {%else %}bg-info
    {% endif %}">
    {{ task.due_date_display }}
</span> 

模板展示了 Clean Architecture 在操作中的关注点分离:它纯粹关注 HTML 结构和基于预格式化值的样式决策。所有业务逻辑(datetime比较)和数据格式化都保留在适当的架构层。模板只是简单地调整演讲者的输出以进行视觉显示,使用简单的字符串检查来应用适当的 CSS 类。

正如在第第八章中一样,我们可以通过集中的单元测试来验证这个格式化逻辑。这个测试展示了 Clean Architecture 关注点分离的关键好处:我们可以独立验证我们的展示逻辑,而不需要任何 Web 框架依赖。通过直接针对演讲者进行测试,我们可以确保我们的日期格式化逻辑正确无误,而无需设置完整的 Web 环境。测试纯粹关注从领域数据到展示格式的转换:

def test_web_presenter_formats_overdue_date():
    """Test that presenter properly formats overdue dates."""
    # Arrange
    past_date = datetime.now(timezone.utc) - timedelta(days=1)
    task_response = TaskResponse(
        id="123",
        title="Test Task",
        description="Test Description",
        status=TaskStatus.TODO,
        priority=Priority.MEDIUM,
        project_id="456",
        due_date=past_date
    )
    presenter = WebTaskPresenter()
    # Act
    view_model = presenter.present_task(task_response)
    # Assert
    assert "Overdue" in view_model.due_date_display
    assert past_date.strftime("%Y-%m-%d") in view_model.due_date_display 

这个测试展示了 Clean Architecture 的关注点分离如何使我们能够精确验证我们的 Web 格式化逻辑。我们可以测试复杂的场景,如逾期日期,而无需任何 Web 框架设置。相同的模式适用于未来日期:

def test_web_presenter_formats_future_date():
    """Test that presenter properly formats future dates."""
    # Arrange
    future_date = datetime.now(timezone.utc) + timedelta(days=1)
    task_response = TaskResponse(
        id="123",
        title="Test Task",
        description="Test Description",
        status=TaskStatus.TODO,
        priority=Priority.MEDIUM,
        project_id="456",
        due_date=future_date
    )
    presenter = WebTaskPresenter()
    # Act
    view_model = presenter.present_task(task_response)
    # Assert
    assert "Overdue" not in view_model.due_date_display
    assert future_date.strftime("%Y-%m-%d") in view_model.due_date_display 

这个互补的测试确保我们的演讲者适当地处理未来日期,完成了我们对日期格式化逻辑的验证。与之前的测试一起,我们确认了“逾期”指示器的存在和不存在,所有这些都不需要接触任何 Web 框架代码。

这些测试突出了 Clean Architecture 的演讲者模式的优点。我们的格式化逻辑可以在不复杂的网络设置下得到验证。无需 Flask 测试客户端、模拟数据库或 HTML 解析。日期格式的更改可以快速、精确地测试,同时我们的模板仍然专注于显示问题。

这种模式跨越了所有领域概念,从任务状态到优先级级别,确保业务对象到展示格式的持续转换。我们系统中的任何模板都可以显示任务到期日期,而无需知道这些日期是如何格式化的。更重要的是,随着我们的格式化逻辑随着时区支持或新的显示格式等添加而发展,我们只需要更新演讲者和其测试。我们的模板、控制器和领域逻辑保持不变。

演讲者与基于模板的格式化

熟悉现代 Web 框架(如 React、Vue)或 Flask/Django 中的模板导向模式的开发者可能会质疑我们将格式化逻辑分离到展示器中的做法。许多应用程序直接在模板中嵌入格式化:

<!-- Common pattern in many web frameworks -->
<span class="badge {% if task.due_date < now() %}bg-danger{% else %}bg-info{% endif %}">
    {{ task.due_date.strftime("%Y-%m-%d") }}
    {% if task.due_date < now() %}(Overdue){% endif %}
</span> 

虽然这种模式很普遍,但它模糊了展示决策和显示结构之间的界限。在清洁架构中,我们将格式化视为属于接口适配器层的翻译关注点,而不是模板本身。

即使在与模板导向框架一起工作时,清洁架构的原则仍然可以通过以下方式指导实现决策:

  • 认识到业务决策是如何泄漏到模板中的

  • 将格式化逻辑提取到专用组件中

  • 将模板纯粹视为显示结构

基本架构原则保持不变:在层之间保持清晰的边界。无论是通过我们的显式展示器模式还是通过模板辅助程序和组件来实现,目标都是确保在它们达到最外层的显示层之前,领域概念得到适当的转换。

管理 Web 特定的状态

会话数据和表单状态为维护清洁架构的边界带来了独特的挑战。让我们看看我们的系统是如何在保持核心领域逻辑纯净的同时处理这些特定的 Web 关注点的。考虑以下反模式,其中领域实体直接访问 Web 会话数据:

# Anti-pattern: Domain entity accessing web state
class Task:
    def complete(self, web_app_contatiner):
        # Wrong: Task shouldn't know about web sessions
        self.completed_by = web_app_contatiner.user.id
        self.completed_at = datetime.now() 

这展示了将 Web 关注点混合到领域实体中如何创造多个维护挑战:

  • 测试需要模拟基本的领域逻辑的 Web 会话数据

  • 添加新接口意味着更新实体代码,而不仅仅是添加适配器

  • 会话处理错误可能会在整个领域层中传播

  • 实体行为变得依赖于 Web 框架的实现细节

我们的 Flask 路由处理程序充当了架构边界,在这里管理 Web 特定的关注点。它们将 HTTP 概念转换为领域无关的操作,同时保持 Web 状态管理在其所属的位置:

# todo_app/infrastructure/web/routes.py
@bp.route("/")
def index():
    """List all projects with their tasks."""
    app = current_app.config["APP_CONTAINER"]
    show_completed = (
        request.args.get("show_completed", "false")
        .lower() == "true"
    )
    result = app.project_controller.handle_list()
    if not result.is_success:
        error = project_presenter.present_error(result.error.message)
        flash(error.message, "error")
        return redirect(url_for("todo.index"))
    return render_template(
        "index.html",
        projects=result.success,
        show_completed=show_completed
    ) 

此处理程序展示了清洁架构边界管理的实际应用。在我们系统的这个外围边缘,路由捕获并处理 Web 特定的状态,如show_completed偏好,将 HTTP 概念转换为领域无关的操作。而不是允许领域实体直接访问会话数据,处理程序在传递给我们的核心业务逻辑之前只提取必要的信息。Web 特定的关注点,如通过闪存消息的用户反馈和模板渲染,保持在这一外围层,而我们的领域逻辑则专注于其核心职责。

表单处理和验证

在 Web 应用程序中,表单提交提出了一个架构挑战。一个常见的反模式是在模板、控制器和领域实体之间分散验证逻辑,这使得维护和演进验证规则变得困难。让我们看看 Clean Architecture 如何指导我们适当地处理表单,以一个简单的项目创建表单为例:

# todo_app/infrastructure/web/routes.py
@bp.route("/projects/new", methods=["GET", "POST"])
def new_project():
    """Create a new project."""
    if request.method == "POST":
        name = request.form["name"]
        app = current_app.config["APP_CONTAINER"]
        result = app.project_controller.handle_create(name)
        if not result.is_success:
            error = project_presenter.present_error(result.error.message)
            flash(error.message, "error")
            return redirect(url_for("todo.index"))
        project = result.success
        flash(f'Project "{project.name}" created successfully', "success")
        return redirect(url_for("todo.index"))
    return render_template("project_form.html") 

路由处理程序展示了 Clean Architecture 的验证流程:

  1. 路由提取 Web 特定的输入:

    • URL 参数(project_id

    • 表单字段(request.form["title"] 等)

    • 具有默认值的可选字段(due_date

  2. 任务控制器接收标准的 Python 类型:

    • 文本字段使用字符串

    • 对于空的可选字段使用 None

    • 来自 URL 的 project_id

  3. 领域验证通过既定的层进行:

    • 实体中的业务规则

    • 用例协调

    • 通过我们的 Result 类型返回的结果

  4. Web 特定响应:

    • 使用 flash 消息的成功重定向

    • 通过 flash 消息和重定向进行错误处理

同步客户端和领域验证

虽然我们的领域验证提供了最终的真实来源,但现代 Web 应用程序通常需要立即的用户反馈。Flask 提供了像 WTForms 这样的机制,可以在视图层中反映领域验证规则,从而实现响应式 UX 而不重复验证逻辑。关键是确保这些视图层验证只是我们核心领域规则的外层包装,而不是引入并行验证逻辑。

这种分离确保我们的验证规则留在它们所属的领域逻辑中,而 Web 层则专注于收集输入和呈现反馈。

将 Flask 与 Clean Architecture 集成

在确立了我们的展示模式和状态管理方法之后,我们现在转向将 Flask 实际集成到我们的 Clean Architecture 系统中。在 理解 Clean Architecture 中的接口灵活性 中看到的先前应用程序容器结构的基础上,我们将关注 Web 接口的 Flask 特定方面:

  • 配置 Flask 的应用程序工厂模式

  • 管理 Flask 特定的设置和依赖项

  • 将 Flask 路由连接到我们的核心应用程序逻辑

这就是我们的 Flask 应用程序工厂如何与我们的现有架构集成:

# todo_app/infrastructure/web/app.py
def create_web_app(app_container: Application) -> Flask:
    """Create and configure Flask application."""
    flask_app = Flask(__name__)
    # Change this in production:
    flask_app.config["SECRET_KEY"] = "dev" 
    # Store container in config:
    flask_app.config["APP_CONTAINER"] = app_container 
    # Register blueprints
    from . import routes
    flask_app.register_blueprint(routes.bp)
    return flask_app 

让我们检查这个设置的要点组件。如图 图 9.4 所示,web_main.py 作为我们应用程序的入口点,通过 Flask 协调创建和配置我们的业务逻辑(应用程序容器)和 Web 接口(Web 容器)。应用程序容器持有我们的核心业务逻辑,而 Web 容器则管理 Flask 特定的关注点,如路由和模板。

图 9.4:显示容器关系的 Flask 应用程序引导

图 9.4:显示容器关系的 Flask 应用程序引导

这种结构在几个关键方面遵循 Clean Architecture 的原则:

  • 将 Flask 特定代码隔离在 Web 容器中

  • 维持我们的核心应用程序容器的独立性,不受 Web 关注的影响

  • 通过定义良好的接口,在容器之间启用清晰的通信路径

在这些容器正确配置和连接后,我们就可以实现我们的路由和模板了。这些组件将建立在已建立的展示模式之上,展示 Clean Architecture 如何使我们能够创建一个功能齐全的 Web 界面,同时保持清晰的架构边界。

实现路由和模板

在本章早期,我们从数据流的角度检查了路由:它们如何代表我们系统的入口点,以及如何将 HTTP 请求转换为我们的核心领域。现在让我们更仔细地看看它们的实现,以了解它们如何在提供特定于 Web 的功能的同时维护 Clean Architecture 的边界。

正如我们的 CLI 实现将命令行参数转换为用例输入一样,我们的 Web 路由将 HTTP 请求转换为我们的核心应用程序可以理解的操作。虽然交付机制不同(HTTP 请求而不是命令行参数),但架构模式保持不变:外部输入通过我们的接口适配器流动,然后到达我们的应用程序核心。

考虑我们的 CLI 如何处理任务创建:

# todo_app/infrastructure/cli/click_cli_app.py
def _create_task(self):
    """CLI task creation."""
    title = click.prompt("Task title", type=str)
    description = click.prompt("Description", type=str)
    result = self.app.task_controller.handle_create(
        title=title,
        description=description
    ) 

我们的 Web 路由实现了与 CLI 相同的架构模式,尽管是为 HTTP 的请求-响应周期进行了调整。正如 CLI 处理程序将命令行参数转换为领域操作一样,这个路由处理程序作为 HTTP 概念和我们的领域逻辑之间的清晰边界:

@bp.route("/projects/<project_id>/tasks/new", methods=["GET", "POST"])
def new_task(project_id):
    """Create a new task in a project."""
    if request.method == "POST":
        app = current_app.config["APP_CONTAINER"]
        result = app.task_controller.handle_create(
            project_id=project_id,
            title=request.form["title"],
            description=request.form["description"],
            priority=request.form["priority"],
            due_date=(
                request.form["due_date"]
                if request.form["due_date"] else None
            ),
        )
        if not result.is_success:
            error = task_presenter.present_error(result.error.message)
            flash(error.message, "error")
            return redirect(url_for("todo.index"))
        task = result.success
        flash(f'Task "{task.title}" created successfully', "success")
        return redirect(url_for("todo.index"))
    return render_template("task_form.html", project_id=project_id) 

注意这两种实现:

  • 以接口特定的方式收集输入(CLI 提示与表单数据)

  • 将该输入转换为我们的控制器标准参数

  • 适当地处理它们的界面中的成功和错误响应(CLI 输出与 HTTP 重定向)

这种一致的模式展示了 Clean Architecture 如何使多个接口成为可能,同时保持我们的核心应用程序专注于业务逻辑。

路由处理不仅限于简单的表单处理。project_id参数来自 URL 本身(/projects/<project_id>/tasks/new),而表单字段包含任务详情。我们的 Clean Architecture 层自然地处理这一点:

  • 路由层管理所有 Web 特定内容:

    • 提取 URL 参数

    • 表单数据收集

    • 用于用户反馈的闪存消息(在重定向后显示的临时 UI 消息)

    • 模板选择和渲染

  • 控制器层处理:

    • 将 URL 和表单数据合并为单一操作

    • 协调适当的用例

    • 返回我们的 Web 层可以解释的结果

模板代表我们 Clean Architecture 系统的最外层,作为领域概念与用户界面之间的最终转换点。虽然我们的展示者处理领域数据到视图模型的逻辑转换,但模板专注于数据的视觉表示:

{% extends 'base.html' %}
{% block content %}
    {% for project in projects %}
    <div class="card mb-4">
        <div class="card-header">
            <h2 class="card-title h5 mb-0">{{ project.name }}</h2>
        </div>
        <!-- Template focuses purely on structure and display -->
    </div>
    {% endfor %}
{% endblock %} 

此模板展示了我们在实际操作中如何清晰地区分关注点。它仅与我们的演示者提供的ProjectViewModel一起工作。注意它如何简单地引用project.name,而不需要了解这些数据是如何检索或处理的。模板对存储库、用例或甚至 HTTP 层都没有意识,而是专注于以用户友好的格式渲染提供的视图模型。这反映了我们的 CLI 演示者如何格式化数据以供控制台输出,每个接口只处理其特定的显示要求。

这种分离意味着我们可以完全重新设计我们的模板,无论是更改布局、添加新的 UI 组件,甚至切换模板引擎,而无需触及我们的核心应用程序逻辑。

运行你的 Clean Architecture Web 应用程序

在实现了我们的 Web 界面组件之后,让我们来看看如何引导我们的 Clean Architecture 应用程序。web_main.py脚本作为我们的组合根——抽象接口与具体实现相遇的唯一点。此入口点协调组件的创建和连接,同时保持 Clean Architecture 的依赖规则:

def main():
    """Create and run the Flask web application."""
    app_container = create_application(
        notification_service=create_notification_service(),
        task_presenter=WebTaskPresenter(),
        project_presenter=WebProjectPresenter(),
    )
    web_app = create_web_app(app_container)
    web_app.run(debug=True)
if __name__ == "__main__":
    main() 

依赖倒置原则允许通过环境变量在运行时配置具体实现。正如我们的 CLI 应用程序可以在不更改代码的情况下切换组件一样,我们的 Web 界面保持了这种灵活性:

# Repository Configuration
export TODO_REPOSITORY_TYPE="memory"  # or "file"
export TODO_DATA_DIR="repo_data"      # used with file repository
# Optional: Email Notification Configuration
export TODO_SENDGRID_API_KEY="your_api_key"
export TODO_NOTIFICATION_EMAIL="recipient@example.com" 

这种配置灵活性展示了 Clean Architecture 的关键优势:能够轻松切换组件。例如,将TODO_REPOSITORY_TYPE从“memory”更改为“file”,可以切换我们的整个存储实现,而无需进行任何代码更改。使我们能够添加 Web 界面的相同模式也使得以下操作成为可能:

  • 添加新的存储后端(如 PostgreSQL 或 MongoDB)

  • 实现额外的通知服务

  • 创建新的接口(例如桌面或移动应用)

  • 支持替代认证方法

这些增强功能都可以独立实现和测试,然后通过我们的清洁架构边界进行集成。这种能力使开发团队能够在保持系统稳定性的同时,对新功能和新技术进行实验。而不是冒险的“大爆炸”代码部署,团队可以在 Clean Architecture 的保护边界内,逐步通过添加和测试新组件来演进他们的应用程序。

要启动 Web 应用程序,运行主脚本:

> python web_main.py
 * Serving Flask app 'todo_app.infrastructure.web.app'
 * Debug mode: on
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 954-447-204
127.0.0.1 - - [05/Feb/2025 13:58:57] "GET / HTTP/1.1" 200 - 

在你的浏览器中访问http://127.0.0.1:5000将呈现一个 Web 界面,虽然其形式与我们的 CLI 截然不同,但它运行在完全相同的核心组件上。我们的 CLI 解释命令行参数,而我们的 Web 界面现在处理表单提交和 URL 参数。之前响应 CLI 命令的任务创建用例现在处理 HTTP POST 请求:

图 9.5:显示 Web 特定输入处理的任务创建表单

图 9.5:任务创建表单显示 Web 特定的输入处理

这种双重性展示了 Clean Architecture 的实际应用。我们的简单命令行应用程序现在与完整的 Web 界面共存,包括表单、动态更新和视觉反馈。这两个接口独立运行但共享相同的内核组件。之前处理 CLI 命令的相同任务创建用例现在无缝处理 Web 表单提交。我们的存储库维护一致的数据,无论哪个接口创建或更新记录。错误处理自然适应,对于 CLI 用户是命令行错误消息,对于 Web 用户是闪存消息和表单验证。

这些不仅仅是两个使用类似代码的独立应用程序:它们是访问相同应用程序核心的两个接口,每个接口都以适合其环境的方式展示其功能。团队成员可以通过 CLI 创建任务,而另一位成员可以通过 Web 界面更新它,这两个操作都通过相同的用例和存储库进行,展示了 Clean Architecture 边界规则的实用力量。

摘要

我们从 CLI 到 Web 界面的转变突出了 Clean Architecture 在使系统演进而不损害架构完整性方面的力量。这种能力不仅限于 Web 界面,而是一个更广泛的原则:精心设计的架构边界创建的系统可以适应不断变化的接口需求,同时保持稳定的内核。

我们所探讨的这些模式为未来的系统演进提供了一个模板。这些模式从特定于接口的演示者到系统边界的状态管理。无论是添加移动界面、API 端点还是全新的交互模型,这些相同的原理确保我们的核心业务逻辑保持专注和保护。

这种灵活性并不会牺牲可维护性。通过保持我们的领域实体专注于业务规则,以及我们的用例与纯领域概念协同工作,我们创建了一个系统,其中每个层都可以独立演进。新的接口需求可以通过额外的适配器来满足,而我们的核心业务逻辑保持稳定且未受影响。

第十章中,我们将探讨如何向 Clean Architecture 系统添加日志记录和监控,确保我们的应用程序在生产环境中保持可观察性和可维护性。

进一步阅读

第十章:实现可观察性:监控和验证

在前面的章节中,我们通过任务管理系统确立了 Clean Architecture 的核心原则。我们构建了领域实体,实现了用例,并创建了 CLI 和网络接口,展示了 Clean Architecture 的边界如何使我们的核心业务逻辑与外部关注点之间实现清晰的分离。虽然这些边界使我们的系统更易于维护,但它们还服务于另一个关键目的。它们使我们的系统更可观察,其架构完整性更可验证。

通过我们的任务管理系统,我们将展示如何通过 Clean Architecture 将系统可观察性从横切关注点转变为结构化能力。由于我们的系统是用清晰的架构层和明确的接口构建的,因此监控成为我们现有结构的自然扩展。这种简化监控的组织结构同样也使得持续验证成为可能,有助于确保我们的系统在演变过程中保持其架构完整性。

到本章结束时,你将了解如何在 Clean Architecture 系统中实现有效的可观察性,以及如何验证架构边界在时间上保持完整。你将学习检测和预防架构漂移的实用技术,帮助确保你的系统即使在需求团队演变的情况下也能保持其清晰的架构结构。

在本章中,我们将涵盖以下主要主题:

  • 理解 Clean Architecture 中的可观察性

  • 实现跨边界仪表化

  • 通过监控维护架构完整性

技术要求

本章和本书其余部分展示的代码示例均使用 Python 3.13 进行测试。为了简洁,本章中的大多数代码示例仅部分实现。所有示例的完整版本可以在本书配套的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Clean-Architecture-with-Python

理解 Clean Architecture 中的可观察性边界

Clean Architecture 的明确层边界提供了系统观察的自然点,这是许多团队忽视的显著优势。虽然分层架构可能会引入复杂性,但这些有助于管理依赖关系的相同划分也使得系统监控和可观察性成为可能。让我们首先探讨 Clean Architecture 的基本原则如何为更好的系统仪表化创造机会,为我们稍后将要探讨的实际实现奠定基础。通过理解这些概念,你将看到 Clean Architecture 如何使系统不仅更易于维护,而且更可观察。

Clean Architecture 中的自然观察点

清洁架构的分层结构自然地创造了系统观察的战略点。在探索这些观察点之前,让我们了解我们在软件系统中所说的可观察性是什么。现代可观察性结合了日志、指标和请求跟踪,以提供系统行为的完整视图。在传统系统中,这些关注点跨越所有组件,实现全面的监控通常成为解决错综复杂依赖关系的练习。

清洁架构通过在每个层转换处提供一致的观察点,将这种复杂性转化为清晰性。考虑信息如何通过我们的任务管理系统流动:当用户通过网页界面创建任务时,我们可以观察请求如何穿过我们的架构层,从最初的 HTTP 处理,通过业务操作,到最终的持久化。每个层边界都提供了特定的洞察:

  • 我们的网页界面跟踪传入请求及其转换。

  • 用例监控业务操作及其结果。

  • 领域实体捕获状态变化和业务规则应用。

  • 基础设施组件测量资源利用和外部交互。

这种系统化方法确保我们能够了解系统行为的每一个关键方面,同时在技术和业务关注点之间保持清晰的分离。这不仅改变了监控方式,也改变了我们对系统维护的整体方法。在调查问题或分析性能时,我们知道确切的位置去寻找相关信息。正如我们将在以下章节中看到的,这种相同的结构化方法不仅使监控成为可能,也为验证我们的架构完整性提供了基础。

理解清洁架构中的可观察性

在看到清洁架构如何提供自然的观察点后,让我们探索如何在实践中有效地利用这些点。虽然前面的章节侧重于建立核心架构原则,但现实世界的系统从一开始就需要可观察性。早期的仪表化证明至关重要。没有它,调试变得更加困难,性能问题无法被发现,理解不同环境下的系统行为几乎成为不可能。

考虑这在我们的任务管理系统中的应用。图 10.1展示了看似简单的任务完成操作如何涉及多个架构转换,每个转换都提供了不同的可观察性需求:

图 10.1:带有观察点的任务完成流程

图 10.1:带有观察点的任务完成流程

图表展示了监控关注点如何自然地与我们的架构层对齐。在每次转换中,我们捕捉系统行为的特定方面,从外部边界的技术指标到核心层中的业务操作。这种系统性的方法确保我们在尊重 Clean Architecture 的关注点分离的同时,保持全面的可见性。

分层监控方法提供了明显的优势。在调查问题时,我们可以精确地追踪系统中的操作。如果客户报告间歇性任务完成失败,我们可以从 Web 请求通过业务逻辑追踪操作,以确定问题出在哪里。由于我们知道哪个层处理操作的每个方面,性能瓶颈变得更容易定位。

每一层都贡献它最擅长的。Web 接口跟踪请求处理,用例监控业务操作,而基础设施捕获技术指标。通过尊重这些自然划分,我们保持业务和技术关注点之间的清晰分离,同时确保全面了解系统行为。

这些监控原则直接转化为实现模式。在我们的任务管理系统,我们将使用 Python 的标准日志框架来实现这种分层可观察性。我们将看到 Clean Architecture 的边界如何引导我们走向简单而有效的监控解决方案,在保持架构完整性的同时,提供系统所需的洞察力。

实现跨边界监控

让我们将对 Clean Architecture 可观察性优势的理解转化为实际实施。现代 Web 框架,如 Flask,提供了它们自己的日志基础设施,这可能诱使开发者将业务操作与框架特定日志紧密耦合。我们将看到如何有效地与这些框架机制合作,同时保持我们的核心业务逻辑框架独立。通过精心实施结构化日志和请求跟踪,我们将展示保持 Clean Architecture 边界的同时,提供全面系统可观察性的模式。

避免日志中的框架耦合

正如我们提到的,Web 框架通常提供它们自己的日志基础设施。例如,Flask 鼓励直接使用其应用程序日志记录器 (app.logger):

@app.route('/tasks/new', methods=['POST'])
def create_task():
    task = create_task_from_request(request.form)
    # Framework-specific logging:
    app.logger.info('Created task %s', task.id) 
    return redirect(url_for('index')) 

虽然这种方法很方便,但它会在我们的业务操作和框架特定日志之间创建问题性的耦合。使用 Flask 的 app.logger 需要使 Flask 应用程序对象在整个代码库中可访问,这是 Clean Architecture 的依赖规则的一个严重违反。内层需要向框架层伸出援手,仅仅是为了执行日志记录,这正是不想看到的对外依赖。

相反,清洁架构引导我们走向框架无关的日志,它尊重架构边界。考虑我们的任务创建用例应该如何记录操作:

# todo_app/application/use_cases/task_use_cases.py
import logging
logger = logging.getLogger(__name__)
@dataclass
class CreateTaskUseCase:
    task_repository: TaskRepository
    project_repository: ProjectRepository
    def execute(self, request: CreateTaskRequest) -> Result:
        try:
            logger.info(
                "Creating new task",
                extra={"context": {
                    "title": request.title,
                    "project_id": request.project_id
                }},
            )
            # ... implementation continues ... 

这种方法提供了几个清洁架构的好处:

  • 用例对日志实现细节一无所知

  • 日志语句自然地记录业务操作

  • 我们可以更改日志基础设施,而无需修改业务逻辑

  • 框架特定的日志保持在系统边缘,它应该属于的地方

让我们系统地实现这种清洁日志方法,从正确分离框架和应用日志关注点开始。

实现结构化日志模式

正如我们所看到的,清洁架构要求基础设施关注点,包括日志实现细节,保持在外层隔离。

对于我们的实现,我们选择了结构化 JSON 日志。这是一种常见的做法,它使精确的日志处理和分析成为可能。每个日志条目都成为一个具有一致字段的 JSON 对象,这使得程序化搜索、过滤和分析日志数据变得更加容易。虽然我们将演示 JSON 格式化,但我们建立的模式也可以同样适用于其他日志格式:你可以调整格式化器的实现,而不必触及内层代码。

我们组织我们的日志基础设施以保持清洁的架构边界:

图 10.2:框架和驱动层中的日志文件

图 10.2:框架和驱动层中的日志文件

这种组织将日志配置放在它应该属于的地方:在框架和驱动层。框架日志(access.log)和应用日志(app.log)之间的分离展示了我们如何在日志输出中保持清晰的边界。

这种分离服务于两个关键的清洁架构目标:

  • 关注点分离:每一层记录它最擅长的内容。Flask 以其标准格式处理 HTTP 请求日志,而我们的应用程序以结构化 JSON 捕获业务操作。这种清洁的分离意味着每种类型的日志可以独立发展,使用适合其目的的格式和字段。

  • 框架独立性:我们的核心应用程序日志对 Flask 或任何其他 Web 框架一无所知。我们可以切换到不同的框架,甚至添加新的接口,如 REST API,而我们的业务操作日志保持不变。

我们需要一个方法来格式化我们的应用程序日志,它支持结构化数据,同时保持对任何框架意见的独立性。我们的JsonFormatter处理这个责任:

# todo_app/infrastructure/logging/config.py
class JsonFormatter(logging.Formatter):
    """Formats log records as JSON."""
    def __init__(self, app_context: str):
        super().__init__()
        self.app_context = app_context
        # Custom encoder handles datetime, UUID, sets, and exceptions
        self.encoder = JsonLogEncoder()
    def format(self, record: logging.LogRecord) -> str:
        """Format log record as JSON."""
        log_data = {
            "timestamp": datetime.now(timezone.utc),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "app_context": self.app_context,
        }
        # `extra` in the log statement, places `context`
        # on the LogRecord so seek and extract
        context = {}
        for key, value in record.__dict__.items():
            if key == "context":
                context = value
                break
        if context:
            log_data["context"] = context
        return self.encoder.encode(log_data) 

格式化器将所有 JSON 格式化逻辑封装在一个组件中,展示了单一责任原则的实际应用。每个日志条目都包含基本上下文,如时间戳和日志级别,同时完全不了解 Web 框架或其他外部问题。

由于 Python 的日志机制直接将额外的参数键附加到 LogRecord 实例,我们使用一个专门的 context 命名空间来防止与 LogRecord 的内置属性(如 nameargs)冲突。这种简单的命名空间策略让我们能够安全地包含结构化数据在每个日志消息中。

在我们的格式化器处理单个日志消息的结构后,我们现在需要配置这些消息如何通过我们的系统流动。此配置确定哪些日志流向何方,保持我们在框架和应用程序日志之间的清晰分离。为了清晰起见,我们将使用 Python 记录器的 dictConfig 来建立这些路径,从我们的格式化器开始:

# todo_app/infrastructure/logging/config.py
def configure_logging(app_context: Literal["CLI", "WEB"]) -> None:
    """Configure application logging with sensible defaults."""
    log_dir = Path("logs")
    log_dir.mkdir(exist_ok=True)
    config = {
        "formatters": {
            "json": {"()": JsonFormatter, "app_context": app_context},
            "standard": {"format": "%(message)s"},
        },
        ... 

在这里,我们定义了两种格式化器:我们的自定义 JSON 格式化器用于应用程序日志,以及一个简单的格式用于框架日志。这种分离让每种类型的日志保持其适当的结构。

接下来,我们配置处理程序,将日志引导到适当的目的地:

...
        },
        "handlers" = {
            "app_file": {
                "class": "logging.FileHandler",
                "filename": log_dir / "app.log",
                "formatter": "json",
            },
            "access_file": {
                "class": "logging.FileHandler",
                "filename": log_dir / "access.log",
                "formatter": "standard",
            },
        },
        ... 

每个处理程序将日志目的地与其适当的格式化器连接起来,保持我们在框架和应用程序关注点之间的清晰分离。

最后,我们通过记录器配置将所有东西连接在一起:

...
        },   
        "loggers" = {
            # Application logger
            "todo_app": {
                "handlers": ["app_file"],
                "level": "INFO",
            },
            # Flask's werkzeug logger
            "werkzeug": {
                "handlers": ["access_file"],
                "level": "INFO",
                "propagate": False,
            },
        },
    } // end configure_logging() 

todo_app 记录器通过我们的 JSON 格式化器捕获所有应用程序级别的操作,并将它们写入 app.log 文件。同时,Flask 内置的 Werkzeug 记录器保持不变,以标准格式记录 HTTP 请求到 access.log 文件。通过保持这些日志流分离,我们保持了框架和业务关注点之间的清晰边界。

此配置在应用程序启动的早期阶段被激活:

# web_main.py
def main():
    """Configure logging early"""
    configure_logging(app_context="WEB")
    # ... 

在这里,我们看到 Web 应用程序的主要文件;CLI 将与 app_context="CLI" 之外相同。

最重要的是,此配置意味着我们应用程序中的任何代码都可以简单地使用 Python 的标准日志模块,而无需了解 JSON 格式化、文件处理程序或任何其他实现细节。这些关注点仍然被适当地包含在我们的基础设施层中。

在我们的日志基础设施到位后,让我们看看 Clean Architecture 的关注点分离如何转化为实际的好处。我们的任务创建用例展示了业务操作如何在没有了解框架具体细节的情况下被清晰地记录:

import logging
logger = logging.getLogger(__name__)
@dataclass
class CreateTaskUseCase:
    task_repository: TaskRepository
    project_repository: ProjectRepository
    def execute(self, request: CreateTaskRequest) -> Result:
        try:
            logger.info(
                "Creating new task",
                extra={"title": request.title,
                       "project_id": request.project_id},
            )
            # ... task creation logic ...
            logger.info(
                "Task created successfully",
                extra={"context":{
                    "task_id": str(task.id),
                    "project_id": str(project_id),
                    "priority": task.priority.name}}
            ) 

当我们运行应用程序时,我们在控制台看到以下内容:

图片 B31577_10_3

虽然我们为了开发方便选择了在控制台显示两种日志流,但每种类型的日志都正确地分离到指定的文件中:

计算机代码的特写  描述由系统自动生成

如果我们查看格式化的 创建新任务 日志语句,我们会看到日志语句的 context 属性被注入:

{
  "timestamp": "2025-02-22T20:10:03.800373+00:00",
  "level": "INFO",
  "logger":
  "todo_app.application.use_cases.task_use_cases",
  "message": "Creating new task",
  "app_context": "WEB",
  "trace_id": "19d386aa-5537-45ac-9da6-3a0ce8717660",
  "context": {
    "title": "New Task",
    "project_id": "e587f1d5-5f6e-4da5-8d6b-155b39bbe8a9"
  }
} 

通过这次实现,我们看到了清晰架构如何引导我们找到针对常见基础设施问题的实用解决方案。通过在我们的最外层隔离日志配置,我们使系统的每个部分都能适当地记录日志,同时保持适当的架构边界。框架日志和业务操作保持清晰分离,但两者共同构成了对系统行为的全面视图。

构建跨边界可观察性

在整本书中,我们看到了清晰架构的明确边界如何提供关键的好处,从隔离业务逻辑和保持可测试性到实现接口灵活性和框架独立性。然而,这些保持我们的系统可维护的相同边界,也可能使得追踪操作在通过我们的层流动时变得具有挑战性。

虽然结构化日志提供了对单个操作的洞察,但要跨这些架构边界跟踪请求需要额外的基础设施。让我们扩展我们的任务管理系统,以实现跨边界跟踪,同时保持这些清晰的分离。

考虑当用户通过我们的 Web 界面创建任务时会发生什么,这是一个跨越多个架构边界的操作:

  1. 一个 Web 请求到达我们的 Flask 路由处理器

  2. 请求通过我们的任务控制器流动

  3. 控制器调用我们的用例

  4. 用例与存储库协调

  5. 最后,结果通过这些层流回

如果这些事件之间没有关联,调试和监控将变得具有挑战性。我们的解决方案简单但强大:我们将为每个请求生成一个唯一的标识符(跟踪 ID),并将此 ID 包含在所有与该请求相关的日志语句中。这使我们能够跟踪请求在我们系统所有层中的旅程,从最初的 Web 请求到数据库操作,然后再返回。

为了实现这种跟踪,我们需要做的是:

  1. 创建infrastructure/logging/trace.py来管理跟踪 ID 的生成和存储

  2. infrastructure/logging/config.py中扩展我们的日志配置,以包括跟踪 ID 在日志格式中

  3. infrastructure/web/middleware.py中添加 Flask 中间件以设置传入请求的跟踪 ID

由于我们根据清晰架构原则构建了我们的日志基础设施,因此不需要对应用程序代码进行任何更改。跟踪 ID 将自动通过我们现有的日志调用流动。

在我们的方法规划出来后,让我们从基础开始:跟踪 ID 管理本身。这个基础设施,虽然完全存在于我们的外层,将使我们在所有架构边界之间具有可见性:

# todo_app/infrastructure/logging/trace.py
# Thread-safe context variable to hold trace ID
trace_id_var: ContextVar[Optional[str]] = ContextVar("trace_id", 
                                                     default=None)
def get_trace_id() -> str:
    """Get current trace ID or generate new one if not set."""
    current = trace_id_var.get()
    if current is None:
        current = str(uuid4())
        trace_id_var.set(current)
    return current
def set_trace_id(trace_id: Optional[str] = None) -> str:
    """Set trace ID for current context."""
    new_id = trace_id or str(uuid4())
    trace_id_var.set(new_id)
    return new_id 

set_trace_id 函数为我们的系统中每个请求建立一个唯一的标识符。虽然它接受一个可选的现有 ID 参数(主要用于测试或特殊集成),但在正常操作中,每个请求都会收到一个新的 UUID。这确保了我们的系统中每个操作都可以独立追踪,无论它是否源自我们的 CLI、web UI 或其他入口点。

为什么 使用 ContextVar

我们使用 Python 的 ContextVar,因为它提供了跨异步边界的安全存储。虽然具体的实现机制对 Clean Architecture 不是至关重要,但选择正确的工具有助于保持清晰的边界。有关上下文变量的更多详细信息,请参阅 Python 的文档:docs.python.org/3/library/contextvars.html

在设置好跟踪 ID 管理之后,我们接下来需要确保我们的日志配置包括日志格式中的跟踪 ID:

# todo_app/infrastructure/logging/config.py
def configure_logging(app_context: Literal["CLI", "WEB"]) -> None:
    config = {
        "formatters": {
            "json": {"()": JsonFormatter, "app_context": app_context},
            "standard": {
                "format": "%(asctime)s [%(trace_id)s] %(message)s",
                "datefmt": "%Y-%m-%d %H:%M:%S"
            },
        },
        # ... rest of configuration
    } 

我们的日志配置确保无论日志格式如何,都会将跟踪 ID 包含在每个日志消息中。对于框架日志,我们使用 Python 内置的日志模式语法 (%(trace_id)s) 将跟踪 ID 添加到标准格式中。我们的 JSON 格式化程序会自动在结构化输出中包含跟踪 ID。这种一致性意味着我们可以跨所有日志源跟踪操作,同时每个日志流保持其适当的格式。

最后,我们的 web 中间件确保每个请求都获得一个跟踪 ID:

# todo_app/infrastructure/web/middleware.py
def trace_requests(flask_app):
    """Add trace ID to all requests."""
    @flask_app.before_request
    def before_request():
        trace_id = request.headers.get("X-Trace-ID") or None
        # pull trace id from globals
        g.trace_id = set_trace_id(trace_id)
    @flask_app.after_request
    def after_request(response):
        response.headers["X-Trace-ID"] = g.trace_id
        return response 

此中间件确保每个 web 请求都收到一个唯一的跟踪 ID。虽然它可以通过 X-Trace-ID 标头接受现有的 ID(对测试很有用),但它通常为每个请求生成一个新的 UUID

要激活此跟踪,我们在创建 Flask 应用程序时集成中间件:

# todo_app/infrastructure/web/app.py
def create_web_app(app_container: Application) -> Flask:
    """Create and configure Flask application."""
    flask_app = Flask(__name__)
    flask_app.config["SECRET_KEY"] = "dev"
    flask_app.config["APP_CONTAINER"] = app_container
    # Add trace ID middleware
    trace_requests(flask_app)
    # ... 

回想一下,web_main.py 调用 create_web_app,因此此设置确保通过我们的系统流动的每个请求都得到追踪。然后,在整个请求处理过程中,此 ID 都是可用的,并包含在响应头中,用于调试目的。跟踪 ID 连接与处理该特定请求相关的所有日志条目,从初始接收到最后响应。

计算机代码的特写  描述由系统自动生成

通过我们的系统每个请求都被分配一个唯一的跟踪 ID,这使得我们能够跨架构边界跟踪特定的操作。如上图所示,跟踪 ID abc-123-xyz 出现在框架和应用程序日志中,连接与该单个任务创建请求相关的所有事件。这种跟踪使我们能够确切了解在任意给定请求期间发生了什么,从初始 HTTP 处理到业务操作再到最终响应。

我们的日志和跟踪实现展示了 Clean Architecture 的边界如何使系统具有全面的可观察性,同时不损害架构原则。然而,实现这些模式只是挑战的一半;我们还必须确保这些边界在我们系统演变过程中保持完整。接下来,我们将探讨如何通过自动检查和适应性函数积极验证我们的架构完整性。

通过适应性函数验证架构完整性

随着系统的演变,保持架构完整性变得越来越具有挑战性。即使承诺遵循 Clean Architecture 原则的团队也可能无意中引入改变,从而损害他们系统精心设计的边界。这种风险导致架构师开发了适应性函数,这些是自动测试,用于验证架构原则是否正确实施,并检测随着时间的推移是否偏离了这些原则。

Neal Ford、Rebecca Parsons 和 Patrick Kua 在他们所著的《构建可演化架构》一书中提出的架构适应性函数的概念,提供了一种维护架构完整性的系统方法。正如单元测试验证代码行为一样,适应性函数验证架构特征。通过在开发早期阶段(称为左移)检测违规行为,这些测试有助于团队以自动化的方式维护 Clean Architecture 的原则。

虽然存在全面的架构验证框架,但 Python 使我们能够利用语言的内置功能以更简单、更实际的方式实施有效的验证。通过我们的架构验证方法,我们将关注两个关键方面:确保我们的源结构保持 Clean Architecture 的分层组织,并检测任何违反基本依赖规则的行为,该规则要求依赖关系只能向内流动。这些互补的检查有助于团队在系统演变过程中保持架构完整性。

验证层结构

让我们先定义我们期望的架构结构。虽然每个团队对 Clean Architecture 的具体实现可能略有不同,但显式分层组织的核心原则保持不变。我们可以在一个简单的配置中捕捉我们的特定解释:

class ArchitectureConfig:
    """Defines Clean Architecture structure and rules."""

    # Ordered from innermost to outermost layer
    LAYER_HIERARCHY = [
        "domain",
        "application",
        "interfaces",
        "infrastructure"
    ] 

此配置作为我们的架构合同,通过定义我们期望我们的代码库目录如何组织。您的团队可能选择不同的层名称或添加额外的组织规则,但原则保持不变:Clean Architecture 需要明确的、定义良好的层以及明确的职责。

在我们的结构定义完成后,我们可以实施验证测试,以确保我们的代码库保持这种组织结构:

def test_source_folders(self):
    """Verify todo_app contains only Clean Architecture layer folders."""
    src_path = Path("todo_app")
    folders = {f.name for f in src_path.iterdir() if f.is_dir()}

    # All layer folders must exist
    for layer in ArchitectureConfig.LAYER_HIERARCHY:
        self.assertIn(
            layer,
            folders,
            f"Missing {layer} layer folder"
        )

    # No unexpected folders
    unexpected = folders - set(ArchitectureConfig.LAYER_HIERARCHY)
    self.assertEqual(
        unexpected,
        set(),
        f"Source should only contain Clean Architecture layers.\n"
        f"Unexpected folders found: {unexpected}"
    ) 

这个简单的检查强制执行了 Clean Architecture 的一个基本原则:我们的源代码必须明确组织到定义良好的层中。ArchitectureConfig类允许我们根据您的特定偏好自定义这些测试。我们特别检查todo_app中的顶级文件夹,确保它们符合我们预期的架构结构。这并不是关于这些文件夹的内容(我们将通过依赖检查来处理这一点),而是验证我们 Clean Architecture 实现的组织基础。

考虑一个常见的场景:一个团队正在向任务管理系统添加电子邮件通知功能。一位新开发者,还不熟悉 Clean Architecture,在根级别创建了一个新的通知文件夹:

文本的特写  自动生成的描述

这个看似无辜的组织选择标志着架构漂移的开始。通知代码应该位于基础设施层,因为它是一个外部关注点。通过创建一个新的顶级文件夹,我们:

  • 关于通知相关代码属于哪里的混淆

  • 开始绕过 Clean Architecture 的明确分层

  • 当开发者不确定适当的放置位置时,为创建新的顶级文件夹设定先例

我们简单的结构检查可以早期捕捉到这一点(如果测试在开发者的机器上运行,实际上是在几秒钟内):

❯ pytest tests/architecture
========== test session starts ==================
tests/architecture/test_source_structure.py F
E    AssertionError: Items in the first set but not the second:
E    'notifications' : Source should only contain Clean Architecture layers.
E    Unexpected folders found: {'notifications'} 

这个警告提示新开发者正确地将通知代码集成到基础设施层:

图片

这些简单的结构检查可以在架构漂移损害系统可维护性之前捕捉到它。然而,适当的结构只是 Clean Architecture 要求的一部分。我们还必须确保这些层之间的依赖关系流向正确的方向。让我们看看如何验证 Clean Architecture 的基本依赖规则。

强制执行依赖规则

在验证了层结构之后,我们必须确保这些层根据 Clean Architecture 的原则正确交互。其中最基本的是依赖规则,它规定依赖关系必须只指向更中心的层。即使是对这个规则的小小违反也可能损害我们精心构建的架构完整性。

在我们的结构验证基础上,让我们看看如何检测依赖规则的违规行为。这个规则对于保持关注点的清晰分离至关重要,但在开发过程中可能会被微妙地违反。

我们的依赖规则验证采取直接的方法,检查 Python 导入语句以确保它们只通过我们的架构层向内流动。尽管存在更复杂的静态分析工具(见进一步阅读),但这种简单的实现可以捕捉到最常见的违规行为:

def test_domain_layer_dependencies(self):
    """Verify domain layer has no outward dependencies."""
    domain_path = Path("todo_app/domain")
    violations = []

    for py_file in domain_path.rglob("*.py"):
        with open(py_file) as f:
            tree = ast.parse(f.read())

        for node in ast.walk(tree):
            if isinstance(node, ast.Import) or isinstance(
               node, ast.ImportFrom
            ):
                module = node.names[0].name
                if module.startswith("todo_app."):
                    layer = module.split(".")[1]
                    if layer in [
                        "infrastructure",
                        "interfaces",
                        "application"
                    ]:
                        violations.append(
                            f"{py_file.relative_to(domain_path)}: "
                            f"Domain layer cannot import from "
                            f"{layer} layer"
                        )
    self.assertEqual(
        violations,
        [],
        "\nDependency Rule Violations:\n" + "\n".join(violations)
    ) 

这个测试实现利用 Python 内置的ast模块分析我们的领域层代码中的导入语句。它是通过以下方式工作的:

  1. 递归地找到领域层中的所有 Python 文件

  2. 将每个文件解析为抽象语法树(AST)

  3. 遍历 AST 以找到导入和 ImportFrom 节点

  4. 检查每个导入以确保它不引用外部层

虽然可以进行更复杂的静态分析,但这种专注的检查有效地捕捉了最关键的依赖违规行为,这些违规行为可能会损害我们核心领域层的独立性。

考虑一个现实场景:一位开发者正在实现任务完成通知。他们注意到基础设施层中的 NotificationService 已经有了他们需要的逻辑。他们没有遵循清洁架构的模式,而是采取了一个捷径,违反了我们的基本依赖规则:

# todo_app/domain/entities/task.py
# Dependency Rule Violation!
from todo_app.infrastructure.notifications.recorder import NotificationRecorder
class Task:
    def complete(self):
        self.status = TaskStatus.DONE
        self.completed_at = datetime.now()

        # Direct dependency on infrastructure –
        # violates Clean Architecture
        notification = NotificationRecorder()
        notification.notify_task_completed(self) 

这种变化可能看起来很无害,因为它完成了工作。然而,它创造了清洁架构禁止的外向依赖。我们的领域实体现在直接依赖于一个基础设施组件,这意味着:

  • 没有使用 NotificationService,任务实体将无法进行测试

  • 我们不能在不修改领域代码的情况下更改通知实现

  • 我们为将基础设施关注点与领域逻辑混合创造了先例

我们的依赖性检查在测试期间立即捕捉到这种违规行为:

❯ pytest tests/architecture
====================== test session starts ==========
...
E    'entities/task.py: Domain layer cannot import from infrastructure layer'
E    Dependency Rule Violations:
E    entities/task.py: Domain layer cannot import from infrastructure layer
====================== 2 passed in 0.01s ============ 

错误信息清楚地标识:

  • 包含违规行为的文件

  • 哪个架构规则被违反

  • 如何修复它(领域层不能从基础设施导入)

这些简单但强大的验证有助于团队在系统演变过程中保持与清洁架构原则的一致性。虽然我们专注于两项基本检查(结构组织和依赖规则),但团队可以将这种方法扩展到验证其他架构特征:

  • 接口一致性:验证接口适配器是否正确实现了其声明的合约

  • 存储库实现:确认存储库实现是否正确扩展了它们的抽象基类

  • 层特定规则:为每个层添加自定义规则,说明如何构建和公开其组件

关键是从专注、高影响力的检查开始,验证您最重要的架构边界。然后,您可以随着架构的发展而发展这些适应性函数,在系统增长时添加对新模式和约束的验证。

通过早期捕捉结构和依赖违规,我们防止了在快速开发过程中可能发生的架构边界的逐渐侵蚀。虽然这些检查不能取代架构理解,但当违反架构规则时,它们提供即时的、可操作的反馈,从而帮助团队构建和保持干净、可维护的系统。

摘要

在本章中,我们探讨了 Clean Architecture 的明确边界如何使我们的系统能够进行系统性的监控和验证。通过我们的任务管理系统,我们展示了如何在保持架构完整性的同时实现有效的可观察性。我们看到了 Clean Architecture 如何将监控从横切关注点转变为我们系统结构的自然部分。

我们实现了几个关键的观察性模式,展示了 Clean Architecture 的好处:

  • 适用于框架无关的日志记录,在尊重架构边界的同时,实现全面的系统可见性

  • 跨边界请求跟踪,保持技术关注点和业务关注点之间的清晰分离

  • 自动化的架构验证,帮助团队在系统演变过程中维护 Clean Architecture 的原则

最重要的是,我们看到了 Clean Architecture 对边界的精心关注如何使我们的系统不仅易于维护,而且易于观察和验证。通过根据 Clean Architecture 原则组织我们的日志和监控基础设施,我们创建了易于理解、调试和维护的系统。

第十一章中,我们将探讨如何将 Clean Architecture 的原则应用于现有系统,展示这些相同的边界和模式如何指导将遗留代码库转换为干净、可维护的架构。

进一步阅读

第十一章:从遗留系统到清洁:重构 Python 以提高可维护性

虽然前几章通过绿地开发演示了清洁架构原则,但现实世界中的系统往往面临不同的挑战。在时间压力下构建或在架构最佳实践建立之前构建的现有应用程序,通常违反清洁架构的基本原则。它们的领域逻辑与框架纠缠在一起,业务规则与基础设施关注点混合,依赖关系流向四面八方。然而,这些系统通常满足关键业务需求,不能简单地被替换。

通过我们对清洁架构转型的探索,我们将发现如何系统地演变遗留系统,同时保持其商业价值。我们将看到清洁架构的明确边界和依赖规则如何提供清晰的指导,以改善现有系统,即使在现实世界的约束下。您将学习如何识别架构违规,逐步建立清洁边界,并在转型过程中保持系统稳定性。

到本章结束时,您将了解如何通过分阶段实施将清洁架构原则应用于遗留系统。您将能够通过清洁架构的视角评估现有系统,并实施尊重业务约束的同时保持系统稳定性的有界转型。

在本章中,我们将涵盖以下主要主题:

  • 评估和规划架构转型

  • 渐进式清洁架构实现

技术要求

本章和本书其余部分提供的代码示例均使用 Python 3.13 进行测试。为了简洁,本章中的大多数代码示例仅部分实现。所有示例的完整版本可以在本书配套的 GitHub 仓库github.com/PacktPublishing/Clean-Architecture-with-Python中找到。

评估和规划架构转型

在复杂应用程序中提高可维护性和降低风险需要一种系统性的架构演变方法。具有纠缠依赖关系和模糊责任的应用程序消耗了不成比例的维护工作量。原本只需几天就能完成的特性添加扩展到几周;错误修复触发意外的、持续的故障;开发者入职变得痛苦缓慢。这些症状不仅反映了技术问题;它们还直接影响了业务,需要解决。

在前面的章节中,我们看到了清洁架构如何通过清晰的边界和显式的依赖关系自然地最小化维护负担。现在,我们可以应用相同的架构视角来评估现有系统,确定违规发生的地方以及如何系统地解决它们。这并不意味着一次性将理想的清洁架构强加于遗留系统,而是采取一种平衡的、渐进的方法,在尊重商业约束的同时,逐步改进系统。

通过将遗留代码通过清洁架构原则进行分析,我们可以揭示自然系统边界,等待建立,领域概念准备隔离,接口渴望出现。这种评估构成了我们转型策略的基础,指导关于改变什么、何时改变以及如何在整个过程中最小化风险的决策。随着每次增量改进,我们减少维护负担和与未来变化相关的稳定性,创造了超越技术改进的可衡量商业价值。

通过清洁架构视角进行评估

将现有系统转换为符合清洁架构原则的系统,首先需要评估其当前状态。这种评估并非关于记录每个细节,而是旨在识别关键架构违规并评估其商业影响。由于全面转型会引入不可接受的风险,我们需要一种平衡的方法,既提供足够的信息来指导利益相关者的讨论,又能够实现有意义的进展。这种谨慎的评估为在获得初始利益相关者支持后进行更深入的协作分析奠定了基础。

进行初步架构分析

在与利益相关者接触之前,我们需要进行一次有针对性的初步架构分析,重点是识别可以有效地传达给非技术受众的关键技术问题。这种初步评估并非详尽无遗,但提供了足够的洞察力,以业务相关的术语说明架构问题。

焦点的初步分析可能包括:

  • 架构清单:识别主要组件及其交互,创建一个无需记录每个细节的基准理解。

  • 依赖映射:绘制高级依赖流程图,揭示最成问题的循环依赖和违反清洁架构原则的框架耦合。

  • 框架渗透评估:聚焦于框架代码显著渗透业务逻辑的例子,重点关注对维护或灵活性有可见影响的区域。

  • 领域逻辑分散:识别一些清晰的例子,其中业务规则在代码库中分散,特别是那些影响功能频繁变化的部分。

例如,在分析一个 Python 电子商务系统时,我们可能会发现 Django 模型包含关键的业务规则,验证逻辑在多个视图中重复,支付处理代码直接引用了原生数据库查询。这种初步分析提供了非技术利益相关者可以理解的实例:当我们需要更改定价方式时,我们目前必须修改三个不同模块中的七个不同地方的代码

这种分析作为一种沟通工具,被翻译成业务影响术语,如缩短上市时间、提高错误率以及降低应对变化需求的能力。在开始转型之前,用业务术语来界定架构问题,我们为利益相关者的支持以及适当的资源分配奠定了基础。

这种初步的架构评估是转型的起点,而不是详尽的蓝图。专注于识别足够的具体违规行为,以便通过有说服力的例子来吸引利益相关者,这些例子可以说明业务影响。在这一阶段,抵制绘制每个关系的诱惑。在随后的协作领域分析中,你的理解将大大加深。目标是收集足够的证据来支持转型的案例,同时为与利益相关者进行更深入的探索做好准备。

建立利益相关者的一致性

在完成初步的架构分析和确定关键问题后,下一步是将这些发现传达给利益相关者,并确保对转型获得初步的支持。这次初步接触不是为了获得对特定变更的最终批准,而是旨在建立对架构问题的共同认识,并建立对更协作的发现过程的支持。从我们的分析中获得的认识现在必须被翻译成与不同利益相关者群体产生共鸣的业务影响术语,为随后的更深入协作分析奠定基础。

第一步是涉及正确的利益相关者:

  • 工程团队,他们理解技术细节和实施限制

  • 产品负责人,他们能够阐述业务优先级并验证架构变更的价值

  • 运维人员,他们管理系统部署和可靠性问题

  • 最终用户,他们可以分享与系统稳定性和功能交付相关的痛点

利益相关者参与的范围应直接对应于计划转型的规模。较小的重构可能只需要与你的直接团队协调,而系统级的架构改造可能需要从 CTO 或工程副总裁那里获得参与。

一旦利益相关者就共享的转型愿景达成一致,下一步关键步骤是建立基线测量,这将跟踪进度并展示价值。这些指标创造了问责制,并在转型旅程的整个过程中提供了改进的明确证据:

  • 维护指标:修复错误所花费的时间,功能交付的领先时间

  • 质量指标:缺陷率,测试覆盖率,静态分析得分

  • 团队效率:开发者入职时间,部署频率

  • 业务成果:客户满意度,功能采用率

这些指标在整个转型过程中发挥着多重作用。最初,它们证明了努力的合理性并有助于获得领导层的支持。随着工作的推进,它们验证了有效性并突出了需要调整的领域。它们还帮助定义了转型中“完成”的含义,认识到目标是可持续的改进而不是架构的完美。最重要的是,指标将技术改进转化为业务价值语言,创建了一个反馈循环,使转型与技术和业务优先事项保持一致。

深入的领域分析

业务领域自然会随着时间的推移而演变,使架构转型成为重新调整系统以适应当前业务需求的一个理想机会。在获得初始利益相关者支持后,下一步是通过协作领域发现技术深化我们的理解。这一阶段将我们的技术洞察与业务领域知识联系起来,确定有意义的边界,并通过积极参与巩固利益相关者的支持。在我们初步分析侧重于技术问题时,协作发现将这些建议与不断发展的业务需求联系起来,确保转型后的系统不仅具有更好的架构,而且更好地满足当前需求。

几种协作方法可以帮助弥合技术理解与领域专业知识之间的差距:

在这些方法中,事件风暴在清洁架构转型中特别有价值。它通过促进研讨会将利益相关者聚集在一起,以验证领域理解和识别架构边界。参与者使用共享建模空间上的彩色便签,创建业务流程的可视化时间线。颜色编码有意映射到清洁架构层:橙色领域事件代表架构中心的实体,蓝色命令与应用层中的用例对齐,紫色业务规则反映了独立于外部关注点的领域规则。典型的领域事件包括订单已放置,而命令可能包括如处理支付等操作。这种视觉方法使架构边界对所有利益相关者都变得具体,有助于在改造遗留系统时识别自然分离点。虽然具体的颜色方案可能在团队之间有所不同,但保持一致的视觉语言最为重要。

图 11.1:电子商务系统的事件风暴可视化,展示了领域事件、命令、演员和潜在的边界上下文

图 11.1:电子商务系统的事件风暴可视化,展示了领域事件、命令、演员和潜在的边界上下文

这种协作方法直接基于第四章中的领域建模原则,将它们应用于发现现有系统中的边界。现在,实体、值对象和聚合的概念有助于识别遗留系统应该分离但未分离的内容。例如,一个事件风暴会议可能会揭示订单处理领域包含如订单已放置支付已批准库存已预留已创建发货等独特事件。务必将业务关注点分离成独立的用例,而不是由一个庞大的订单控制器处理。

结果生成的视觉工件作为强大的沟通工具,帮助利益相关者看到架构边界如何转化为业务效益,如更快的交付或减少错误。这种共享语言通常揭示出仅通过技术分析无法发现的见解,例如订单和支付处理具有不同的变化模式,这表明了自然的分离点。通过利益相关者的协作确定了这些边界后,我们可以从发现转向行动,将见解转化为优先级路线图,以改进架构。

创建分阶段实施路线图

在确定了基于业务价值的架构边界并进行了优先级排序后,现在的重点转向战术执行规划。改造遗留系统不仅仅是知道要改变什么,而是将工作组织成可管理的、低风险的增量,在保持系统稳定性的同时逐步改进架构。

有效的转型规划需要将工作分解为具有明确交付成果的独立阶段。而不是用大量的重构工作压倒团队,分阶段实施创建自然检查点以验证进度、收集反馈并根据需要调整方向。

图 11.2:干净的架构转型阶段,展示了从基础到优化的进展

图 11.2:干净的架构转型阶段,展示了从基础到优化的进展

基础阶段建立核心领域概念和抽象,这些抽象是后续工作的基石。这通常从创建与现有实现并行的干净实体模型和定义仓库和服务接口开始。通过从这些核心元素开始,团队在最小化对运行系统初始更改的同时,确立了一个清晰的架构目标。

当基础形成时,接口阶段专注于实现适配器,以连接干净的核和外部关注点。这包括构建与现有数据库协同工作的仓库实现,创建第三方集成的服务适配器,以及开发在框架和领域之间进行转换的控制器。这些适配器在新兴的干净架构周围创建了一个保护层。

集成阶段逐步将现有功能迁移到新架构。团队用仓库实现替换直接数据库访问,用领域服务替换硬编码的业务规则,并通过适当的适配器将新组件与遗留系统集成。这一阶段通常按功能或领域逐步进行,允许进行可控的增量更改。

最后,优化阶段基于实际经验对架构进行精炼和增强。团队在仓库实现中解决性能考虑因素,扩展测试覆盖率,并改进错误处理和弹性模式。这一阶段认识到目标架构不是一蹴而就的,而是通过持续精炼实现的。

在整个分阶段方法中,之前建立的基线指标在验证进度和传达转型影响方面发挥着至关重要的作用。通过在每次转型阶段之前、期间和之后跟踪维护时间、缺陷率和功能交付速度等指标,团队可以展示可衡量的改进,并根据实际结果而不是假设调整他们的方法。这些指标还有助于团队确定何时达到可接受的架构改进水平,使组织能够在架构精炼和持续业务需求之间取得平衡。

执行转型工作的方法

架构转型的执行复杂性需要仔细的物流规划,而不仅仅是技术方面。团队必须决定如何将工作组织与持续的特性开发和维护相结合。以下几种方法值得考虑:

  • 专门的转型迭代将特定的冲刺周期专门用于架构工作。这种方法为复杂的重构提供了专注的时间,但可能会延迟特性交付。它适用于需要重大更改但可以在一两个迭代内完成的组件。

  • 并行转型轨道创建专注于架构改进的专门团队,同时其他团队继续特性开发。这种方法保持了交付速度,但需要仔细协调以防止冲突。它特别适用于将跨越多个季度的更大系统。

  • 基于机会的转型将架构改进与相关领域的特性工作相结合。随着新特性触及某个组件,团队将其重构为 Clean Architecture。这种方法最小化了孤立重构的风险,但使进度依赖于特性优先级,可能会导致转型不均衡。

最成功的转型通常根据业务优先级和团队结构结合这些方法。关键组件可能需要专门的努力,而变化较少的区域可以通过基于机会的转型进行演变。关键是明确规划每个组件的转型方式,而不是假设一种一刀切的方法。

导航飞行中的转型过程

在转型期间,系统将暂时包含旧的和新的架构方法混合。对这些过渡状态的仔细规划对于保持系统稳定性至关重要。对于每个正在转型的组件,计划应解决:

  • 并行操作策略:旧实现和新实现将如何共存

  • 验证方法:确认功能等效性的方法

  • 切换标准:切换到新实现的明确条件

  • 回滚程序:出现问题时采取的安全机制

在这些过渡期间,全面的测试策略是必不可少的。回归测试套件验证新实现是否保持了现有功能,而接口兼容性测试确保转型后的组件能够正确地与更广泛系统集成。特性标志提供了一个有效的切换机制,允许团队为特定用户或场景选择性地启用新实现,同时保持出现问题时能够立即回滚的能力。

重要的是要认识到,虽然本节概述了转型规划的一般方法,但每个遗留系统都基于其规模、复杂性、技术堆栈和业务约束而具有独特的挑战。系统的工作规模将在不同系统之间有显著差异,团队应将这些指南适应到他们特定的环境中。对特定于你的技术堆栈或领域的技术的进一步研究将帮助你根据你的需求调整这种方法。关键是保持实用心态,将 Clean Architecture 原则作为指南而不是僵化的规定。

在一个全面转型计划中,既解决了技术变化也解决了其实施物流,团队有很好的位置开始实际转型工作。接下来的章节将探讨实施这些计划的具体技术,从建立核心领域边界开始,逐步重构到 Clean Architecture。

逐步 Clean Architecture 实施

在完成评估并建立转型策略后,我们现在转向实际实施。本节演示了如何通过精心分阶段改进,逐步将遗留系统转化为具有最大架构价值的系统。我们不会试图全面覆盖转型过程,因为这需要一本自己的书,我们将突出战略重构模式,这些模式在保持系统稳定性的同时,逐步建立 Clean Architecture 的边界。

以下示例,来自一个订单处理系统而非我们之前的任务管理应用,展示了如何以实用方式将 Clean Architecture 原则应用于遗留代码。每个实现阶段都建立在之前的基础上,逐渐从复杂的依赖关系转向清晰的关注点分离,从建立领域边界到创建连接新旧架构的接口。

初始系统分析

在这个假设场景中,你发现自己负责一个经过数年演变的订单处理子系统。最初是一个简单的 Flask 应用程序,用于管理客户订单,后来发展到包括支付处理和基本订单履行。虽然功能上完整,但代码库显示出显著的技术债务,包括复杂的依赖关系、模糊的责任和架构不一致,使得即使是简单的更改也变得风险和耗时。

团队面临反复出现的问题,这些问题突出了架构问题:对订单计算逻辑的简单更改需要在三个不同的文件中进行修改;添加新的支付方式需要三周而不是三天;每次部署都伴随着意外副作用的风险。最值得注意的是,新开发人员需要数月时间才能变得高效,在做出更改时,经常在看似无关的领域破坏功能。

在本章第一部分描述的初步架构分析和领域发现阶段的基础上,我们已确定在转型过程中需要解决的关键架构问题。让我们首先通过 Clean Architecture 的视角来审视系统的当前状态,识别需要加强的具体违规和架构边界。

让我们检查一个处理订单创建的文件,这是系统功能的核心部分,也是我们转型努力的理想候选:

# order_system/app.py
from flask import Flask, request, jsonify
import sqlite3
import requests
app = Flask(__name__)
def get_db_connection():
    conn = sqlite3.connect('orders.db')
    conn.row_factory = sqlite3.Row
    return conn
@app.route('/orders', methods=['POST'])
def create_order():
    data = request.get_json()
    # Input validation mixed with business logic
    if not data or not 'customer_id' in data or not 'items' in data:
        return jsonify({'error': 'Missing required fields'}), 400

    # Direct database access in route handler
    conn = get_db_connection() 

该文件的开头已经揭示了几个架构问题。路由处理程序直接导入 SQLite 并请求,建立了对这些特定实现的硬依赖。get_db_connection函数直接连接到特定数据库,没有抽象层。这些结构选择违反了 Clean Architecture 的依赖规则,允许外层关注点(Web 框架、数据库)渗透到业务逻辑。

继续向下查看create_order函数,让我们看看路由处理程序是如何处理订单的:

# def create_order(): <continued>
    # Business logic mixed with data access
    total_price = 0
    for item in data['items']:
        # Inventory check via direct database query
        product = conn.execute('SELECT * FROM products WHERE id = ?',
                              (item['product_id'],)).fetchone()
        if not product or product['stock'] < item['quantity']:
            conn.close()
            return jsonify({
                'error': f'Product {item["product_id"]} out of stock'
            }), 400

        # Price calculation mixed with HTTP response preparation
        price = product['price'] * item['quantity']
        total_price += price

    # External payment service call directly in route handler
    payment_result = requests.post(
        'https://payment-gateway.example.com/process',
        json={
            'customer_id': data['customer_id'],
            'amount': total_price,
            'currency': 'USD'
        }
    ) 

这一部分演示了几个 Clean Architecture 的违规。核心业务逻辑,如库存检查和价格计算,直接与数据库访问混合。支付处理逻辑直接对外部服务进行 HTTP 调用,创建了一个难以测试或更改的硬依赖。这些实现细节应该隐藏在接口后面,符合 Clean Architecture 原则,而不是直接暴露在业务逻辑中。

最后,在create_order函数中结束,我们完成了订单处理:

# def create_order(): <continued>
    if payment_result.status_code != 200:
        conn.close()
        return jsonify({'error': 'Payment failed'}), 400
    # Order creation directly in route handler
    order_id = conn.execute(
        'INSERT INTO orders (customer_id, total_price, status) '
        'VALUES (?, ?, ?)',
        (
            data['customer_id'],
            total_price, 'PAID'
        )
    ).lastrowid

    # Order items creation and inventory update
    for item in data['items']:
        conn.execute(
            'INSERT INTO order_items (order_id, product_id, '
            'quantity, price) VALUES (?, ?, ?, ?)',
            (order_id, item['product_id'], item['quantity'], price)
        )
        conn.execute( # Update inventory
            'UPDATE products SET stock = stock - ? WHERE id = ?',
            (item['quantity'], item['product_id'])
        )
    conn.commit()
    conn.close()
    return jsonify({'order_id': order_id, 'status': 'success'}), 201 

代码分析揭示了整个处理程序中的基本架构问题。直接 SQL 语句与业务逻辑、HTTP 响应和外部服务调用交织在一起,所有这些都挤在一个单一函数中,没有关注点的分离。这种结构违反了我们第二章中讨论的单一职责原则,使得更改极具风险,因为一个区域的修改经常会影响看似无关的功能。

系统缺乏我们在第四章中建立的丰富领域模型,因为订单和产品仅作为数据库记录和字典存在,而不是具有封装行为和业务规则的正确实体。

图 11.3:当前订单处理程序中的纠缠责任

图 11.3:当前订单处理程序中的纠缠责任

图 11.3展示了单个 Flask 路由处理程序如何包含多个应该根据 Clean Architecture 原则分离的责任。业务逻辑直接连接到基础设施关注点,如数据库连接和外部 API,违反了我们第一章中探讨的依赖规则。

根据我们的分析,我们已确定在转型中需要解决的关键架构问题:

  • 边界违规:路由处理程序跨越多个架构边界,混合了 Web、业务逻辑和基础设施关注点

  • 缺少领域模型:我们需要建立适当的领域实体,如订单和产品,作为我们系统的核心

  • 依赖反转需求:应使用来自第二章的原则替换直接的基础设施依赖

  • 接口分离要求:在架构层之间保持清晰的接口将有助于维护适当的边界

在我们的订单创建过程中存在关键架构问题;我们可以看到一个在没有架构指导的情况下演化的系统。业务逻辑、数据访问和外部服务紧密耦合,没有明确的关注点边界。系统可以工作,但其结构使得维护、扩展或测试变得越来越困难。

通过对当前系统的理解,我们现在准备开始我们的转型之旅。我们将在下一节中建立一个干净的领域模型,在逐步重构向 Clean Architecture 过渡的过程中创建适当的层间边界。

阶段 1:建立领域边界

在分析了我们的遗留系统之后,我们开始通过建立一个干净的领域模型来启动我们的转型,这个模型将作为我们的架构基础。从领域层开始提供了一个稳定的内核,我们可以围绕它逐步重建系统的外部层。

在我们的订单处理系统中,我们需要从数据库查询和控制器逻辑中提取隐藏的领域概念。我们系统中最重要的实体似乎如下:

  • 订单:中心业务实体

  • 客户:下订单的买家

  • 产品:被购买的项目

  • 订单项:订单和产品之间的关联

让我们从实现Order实体及其相关的值对象开始:

# order_system/domain/entities/order.py
class OrderStatus(Enum):
    CREATED = "CREATED"
    PAID = "PAID"
    FULFILLING = "FULFILLING"
    SHIPPED = "SHIPPED"
    DELIVERED = "DELIVERED"
    CANCELED = "CANCELED"
@dataclass
class OrderItem:
    product_id: UUID
    quantity: int
    price: float

    @property
    def total_price(self) -> float:
        return self.price * self.quantity 

在这里,我们定义了一个OrderStatus枚举来替换之前在代码中使用的字符串常量。我们还创建了一个OrderItem值对象来表示订单和产品之间的关系。这种方法与我们探索的值对象模式一致,创建了代表重要领域概念的不可变对象。

现在让我们来实现Order实体本身:

@dataclass
class Order:
    customer_id: UUID
    items: List[OrderItem] = field(default_factory=list)
    id: UUID = field(default_factory=uuid4)
    status: OrderStatus = OrderStatus.CREATED
    created_at: datetime = field(default_factory=lambda: datetime.now())
    updated_at: Optional[datetime] = None

    @property
    def total_price(self) -> float:
        return sum(item.total_price for item in self.items)

    def add_item(self, item: OrderItem) -> None:
        self.items.append(item)
        self.updated_at = datetime.now()

    def mark_as_paid(self) -> None:
        if self.status != OrderStatus.CREATED:
            raise ValueError(
                f"Cannot mark as paid: order is {self.status.value}"
            )
        self.status = OrderStatus.PAID
        self.updated_at = datetime.now() 

我们的Order实体现在正确地封装了之前在代码库中分散的核心业务概念。我们实现了强制执行业务规则的方法,例如在标记订单为已支付时验证状态转换。这些验证之前被埋藏在控制器逻辑中,但现在位于实体本身的适当位置。

我们需要创建剩余的领域实体以完成我们的核心模型:

# order_system/domain/entities/product.py
@dataclass
class Product:
    name: str
    price: float
    stock: int
    id: UUID = field(default_factory=uuid4)

    def decrease_stock(self, quantity: int) -> None:
        if quantity <= 0:
            raise ValueError("Quantity must be positive")
        if quantity > self.stock:
            raise ValueError(
                f"Insufficient stock: requested {quantity}, "
                f"available {self.stock}")
        self.stock -= quantity 

Product 实体现在封装了之前分散在控制器方法中的库存管理逻辑。它强制执行诸如防止库存为负或过度提取等业务规则。这是告诉,不要询问原则的一个例子,有助于维护领域完整性。

在定义了我们的核心领域实体之后,我们需要为支持服务和存储库创建抽象。遵循依赖倒置原则,我们将定义领域需要的接口,而不与特定实现耦合:

# order_system/domain/repositories/order_repository.py
from order_system.domain.entities.order import Order
class OrderRepository(ABC):
    @abstractmethod
    def save(self, order: Order) -> None:
        """Save an order to the repository"""
        pass

    @abstractmethod
    def get_by_id(self, order_id: UUID) -> Optional[Order]:
        """Retrieve an order by its ID"""
        pass

    @abstractmethod
    def get_by_customer(self, customer_id: UUID) -> List[Order]:
        """Retrieve all orders for a customer"""
        pass 

这个抽象的OrderRepository定义了我们的领域层需要的操作,而不指定它们是如何实现的。我们将为ProductRepository和其他必要的存储库创建类似的接口。这些抽象是 Clean Architecture 的关键元素,因为它们允许我们的领域层保持对特定持久机制的不依赖。

如果你还记得前几章中的任务管理系统,我们建立了类似的存储库接口,如第五章中的TaskRepository。两者遵循相同的模式:定义领域组件需要的抽象方法,而不指定实现细节。这种一致性展示了 Clean Architecture 的原则如何应用于不同的领域和应用,创建了一个维护适当边界的可靠模式。

接下来,让我们定义外部操作如支付和通知的服务接口:

# order_system/domain/services/payment_service.py
from order_system.domain.entities.order import Order
@dataclass
class PaymentResult:
    success: bool
    error_message: Optional[str] = None
class PaymentService(ABC):
    @abstractmethod
    def process_payment(self, order: Order) -> PaymentResult:
        """Process payment for an order"""
        pass 

在定义了这些核心领域组件之后,我们为我们的系统创建了一个干净的基础。之前分散在控制器和实用函数中的业务规则和概念现在有了结构良好的领域模型中的适当归宿。这种转型提供了几个直接的好处:

  • 业务规则集中化:如不能将非 CREATED 订单标记为 PAID之类的规则现在在领域模型中明确定义

  • 提高可测试性:领域实体和服务可以在不要求数据库连接或 Web 框架的情况下独立测试

  • 更清晰的边界:核心业务概念和基础设施关注之间的分离现在是明确的

  • 更丰富的领域模型:我们已经从贫血数据库记录转变为具有行为的丰富领域模型

让我们花点时间来回顾这个新的领域层:

图 11.4:新建立的具有清晰边界的领域模型

图 11.4:新建立的具有清晰边界的领域模型

此图说明了我们的第一个主要转型步骤:建立一个具有清晰边界的适当领域层。我们创建了实体、值对象和服务接口,它们封装了我们的核心业务概念和规则。与图 11.2相比,我们可以看到在解开之前在我们遗留控制器实现中混合的责任方面取得了重大进展。

增量集成策略

在现实世界的转型中,一个常见的陷阱是在集成之前孤立地尝试实现整个 Clean Architecture。这种“大爆炸”发布方法引入了重大风险,因为到集成发生时,生产系统可能已经发生了重大变化,创造了复杂的合并冲突和意外的行为变化。

为了减轻这种风险,可以采用几种增量集成策略:

  • 适配器模式:创建适配器,将旧组件和新领域实体连接起来,允许它们在运行系统中共存。这使渐进式采用成为可能,而不会破坏现有功能。

  • 并行实现:使用 Clean Architecture 在旧代码旁边实现新功能,使用功能标志控制哪个实现处理请求。如果出现问题,这提供了一个简单的回滚机制。

  • Strangler Fig 模式:在保持相同外部接口的同时,逐步替换旧应用程序的部分,逐渐取代旧实现,直到它可以安全地被移除(martinfowler.com/bliki/StranglerFigApplication.html)。

  • 影子模式:通过使用代理来复制所有请求,在运行代码旁边运行新的实现。这给新实现提供了处理其请求副本的机会,我们比较输出与旧系统。这验证了行为而不影响用户。

在整个增量转型过程中,全面的回归测试绝对至关重要。在做出任何架构更改之前,建立一个彻底的测试套件,以捕获现有系统行为。这些测试具有多重目的:

  • 他们验证重构没有破坏现有功能

  • 他们记录当前系统行为以供参考

  • 他们向利益相关者提供信心,表明转型正在安全进行

正如我们在第八章中讨论的那样,测试在架构转型期间提供了关键的安全网。对于我们的订单处理系统,我们将在开始转型之前建立端到端测试,以验证完整的订单流程,然后随着我们建立干净的架构边界,补充更多更细粒度的测试。

通过采用这些增量策略并优先考虑回归测试,我们可以在保持稳定性和继续交付业务价值的同时转型我们的系统。在下一节中,我们将开始实施上述的生产集成方法,通过实现接口适配器层来构建我们的领域模型。

第二阶段:接口层实现

在建立了领域实体和接口之后,我们现在面临一个关键的过渡挑战:将这个清洁基础与我们的现有代码库集成。与绿色场开发不同,转型需要我们逐步演进我们的系统,同时保持持续运行。接口层为我们提供了第一个连接新旧架构的机会。

确定转型边界

我们转型的第一步是确定可行的接缝,在这些接缝中我们可以引入清洁接口而不过度破坏现有系统。回顾我们的遗留控制器,订单创建过程作为一个自然的边界脱颖而出:

# order_system/app.py
@app.route('/orders', methods=['POST'])
def create_order():
    data = request.get_json()

    # Input validation mixed with business logic
    if not data or not 'customer_id' in data or not 'items' in data:
        return jsonify({'error': 'Missing required fields'}), 400

    # Direct database access in route handler
    conn = get_db_connection()

    # Business logic implementation
    # ... existing implementation ...

    return jsonify({'order_id': order_id, 'status': 'success'}), 201 

这个控制器方法代表了一个自包含的工作流程,具有清晰的输入和输出,使其成为我们初始转型的理想候选。在修改此代码之前,我们需要建立全面的测试覆盖率,以捕捉其当前的行为。这些测试将在重构期间作为我们的安全网,确保我们在改进架构的同时保持功能:

# test_order_creation.py
def test_create_order_success():
    # Setup test data and expected results
    response = client.post('/orders', json={
        'customer_id': '12345',
        'items': [{'product_id': '789', 'quantity': 2}]
    })

    # Verify status code and response structure
    assert response.status_code == 201
    assert 'order_id' in response.json

    # Verify database state - order was created with correct values
    conn = get_db_connection()
    order = conn.execute('SELECT * FROM orders WHERE id = ?',
                        (response.json['order_id'],)).fetchone()
    assert order['status'] == 'PAID'
# Additional order creation test scenarios ... 

在测试到位后,我们可以开始实施接口层组件,这些组件将连接我们的清洁领域模型和现有基础设施。

实施仓库适配器

我们的第一步是创建满足我们的清洁领域接口同时与现有数据库模式交互的仓库适配器。这个关键组件连接了我们的领域实体和遗留基础设施。

# order_system/infrastructure/repositories/sqlite_order_repository.py
class SQLiteOrderRepository(OrderRepository):
    # ... truncated implementation

    def save(self, order: Order) -> None:
        conn = sqlite3.connect(self.db_path)
        try:
            cursor = conn.cursor()
            # Check if order exists and perform insert or update
            if self._order_exists(conn, order.id):
                # ... SQL update operation ...
            else:
                # ... SQL insert operation ...

                # ... SQL operations for order items ...
            conn.commit()
        except Exception as e:
            conn.rollback()
            raise RepositoryError(f"Failed to save order: {str(e)}")
        finally:
            conn.close() 

这个仓库适配器在我们的转型策略中扮演着至关重要的角色。您可能还记得,在第六章中,我们为我们的任务管理系统引入了类似的仓库实现。与这些示例类似,这个适配器实现了我们的清洁OrderRepository接口(来自阶段 1),同时处理我们现有数据库模式的细节。适配器在领域实体和数据库记录之间进行转换,管理我们丰富的领域模型和平坦的关系结构之间的阻抗不匹配。

我们还将实现一个类似的SQLiteProductRepository,它遵循相同的模式,实现清洁领域接口同时与现有数据库模式交互。这些仓库实现处理所有数据库访问细节、连接管理和错误处理,为我们的其余架构提供干净的接口。

此外,我们还将实现支付处理等外部服务的适配器。这些服务适配器将遵循相同的模式,实现我们的清洁领域接口,同时封装外部服务交互的细节。为了简洁,我们在此不展示这些实现,但完整的代码可在本书的 GitHub 仓库中找到。

在这些基础设施适配器到位后,我们现在在我们的干净领域模型和遗留基础设施之间有一个桥梁。这使得我们能够实现与适当领域实体一起工作的用例,同时通过接口而不是直接与具体实现交互,无缝地与现有的数据库和外部服务进行交互。

构建干净的用例

现在我们有了连接到我们现有基础设施的存储库和服务适配器,我们可以实现编排我们业务逻辑的用例。在第五章中,我们确立用例作为特定于应用的业务规则,协调领域实体以满足特定的用户需求。遵循这个模式,让我们看看将取代我们混乱的遗留实现的订单创建用例:

# order_system/application/use_cases/create_order.py
@dataclass
class CreateOrderRequest:
    customer_id: UUID
    items: List[Dict[str, Any]]
@dataclass
class CreateOrderUseCase:
    order_repository: OrderRepository
    product_repository: ProductRepository
    payment_service: PaymentService

    def execute(self, request: CreateOrderRequest) -> Order:
        # Create order entity with basic information
        order = Order(customer_id=request.customer_id)

        # Add items to order, checking inventory
        for item_data in request.items:
            product_id = UUID(item_data['product_id'])
            quantity = item_data['quantity']

            # ... inventory validation logic ...

            # Update inventory
            product.decrease_stock(quantity)
            self.product_repository.update(product) 

我们用例中的execute方法首先创建一个Order实体,并向其中添加项目,在此过程中检查库存的可用性。注意它如何与适当的领域实体而不是原始数据库记录一起工作。

现在我们来检查execute方法的剩余部分:

# order_system/application/use_cases/create_order.py
    # def execute <continued>

        # Process payment
        payment_result = self.payment_service.process_payment(order)
        if not payment_result.success:
            raise ValueError(
                f"Payment failed: {payment_result.error_message}"
            )
        # Mark order as paid and save
        order.mark_as_paid()
        self.order_repository.save(order)

        return order 

execute方法的下半部分通过处理支付处理、更新订单状态和保存完成的订单来继续订单创建过程。

这个用例展示了清洁架构在分离关注点方面的实际应用。它通过以下方式编排订单创建过程:

  1. 创建一个包含基本信息的Order实体

  2. 向订单中添加项目,检查库存

  3. 处理支付

  4. 更新订单状态并保存

每一步都通过定义良好的接口与领域模型进行交互,而不了解底层基础设施。用例依赖于抽象的OrderRepositoryProductRepositoryPaymentService接口,而不是具体实现。

注意现在业务规则在这个用例中是如何明确和集中的。库存检查、支付处理和订单状态管理都通过一个干净、有序的过程进行,而不是分散在控制器方法和实用函数中。这种清晰性使得代码更易于维护和适应变化的需求。

实现干净的控制器

在我们的存储库和用例到位后,我们现在实现控制器,这些控制器连接我们的 Web 框架和应用核心。正如我们在第六章中确立的,控制器在架构的边界处充当翻译层,将外部请求格式转换为用例可以处理的输入。这些控制器保持了应用核心和交付机制之间的分离,确保 Web 特定的关注点不会渗透到我们的清洁架构中:

# order_system/interfaces/controllers/order_controller.py
@dataclass
class OrderController:
    create_use_case: CreateOrderUseCase

    def handle_create_order(
        self, request_data: Dict[str, Any]
    ) -> Dict[str, Any]:
        try:
            # Transform web request to domain request format
            customer_id = UUID(request_data['customer_id'])
            items = request_data['items']

            request = CreateOrderRequest(
                customer_id=customer_id,
                items=items
            )

            # Execute use case
            order = self.create_use_case.execute(request)

            # Transform domain response to web response format
            return {
                'order_id': str(order.id),
                'status': order.status.value
            }
        except ValueError as e:
            # ... exception logic 

这个控制器展示了清洁架构边界在工作中的情况,作为外部请求和我们的领域操作之间的转换层。这个控制器的核心是一行代码order = self.create_use_case.execute(request),它代表了我们的接口层和应用核心之间的关键边界。注意控制器没有引用 Flask、HTTP 状态码或 JSON 格式化。这些特定于 Web 的关注点在框架边界处处理,保持我们的应用逻辑和交付机制之间的清晰分离。这种框架独立性使得我们的控制器能够专注于其核心责任,将外部请求转换为领域操作,并将结果转换回适合调用者的格式。

第 3 阶段:集成策略:连接遗留和清洁实现

现在是关键步骤:将我们的清洁实现与现有系统集成。而不是立即替换整个遗留路由处理程序,我们将修改它,使用适配器模式委托给我们的清洁控制器:

# Modified route in order_system/app.py
@app.route('/orders', methods=['POST'])
def create_order():
    data = request.get_json()

    # Basic input validation remains in the route handler
    if not data or not 'customer_id' in data or not 'items' in data:
        return jsonify({'error': 'Missing required fields'}), 400

    try:
        # Feature flag to control which implementation handles the request
        if app.config.get('USE_CLEAN_ARCHITECTURE', False):
            # Use the clean implementation
            result = order_controller.handle_create_order(data)
            return jsonify(result), 201
        else:
            # ... original implementation remains here ...
    except ValidationError as e:
        return jsonify({'error': str(e)}), 400
    except SystemError:
        return jsonify({'error': 'Internal server error'}), 500 

这次修改的关键部分是功能标志条件。当USE_CLEAN_ARCHITECTURE被启用时,我们将订单处理委托给我们的新控制器,然后调用清洁用例。这创建了一个受控的路径进入我们的清洁架构实现,而不会干扰现有的代码路径。功能标志为我们提供了一个简单的机制,可以在实现之间切换,无论是全局的还是针对特定请求的。

这个修改后的路由处理程序展示了几个关键转型模式:

  • 功能标志控制:我们使用配置设置来确定哪个实现过程处理请求,这使我们能够逐步过渡流量。

  • 一致的接口:两种实现产生相同的响应格式,确保从用户的角度看无缝过渡。

  • 渐进式迁移:遗留代码保持完全功能,作为清洁实现出现问题时的一种回退。

  • 异常转换:我们在框架边界将领域特定异常映射到适当的 HTTP 响应。

当与特定框架(如 Flask)集成时,我们必须在系统边界处关注框架特定的细节。在 Flask 的情况下,我们需要配置我们的依赖注入容器,注册我们的清洁架构组件,并建立功能标志机制。我们创建一个中央配置点,实例化所有必要的组件(存储库、服务、用例和控制器),并根据清洁架构的依赖规则将它们连接起来。这个配置发生在应用程序启动时,将所有特定于框架的初始化代码保持在系统的边缘,这是它应该所在的位置。我们在我们的任务管理应用程序中看到了这一点在第七章中的实际应用。

渐进式转型方法

在这个转型过程中,全面的测试绝对是必不可少的。我们利用我们的回归测试套件来确保重构没有破坏现有的功能。这些测试验证了遗留实现和我们的新 Clean Architecture 组件,提供了信心,即转型保持了功能一致性。

在进行下一步之前,我们会对转型的每一步进行仔细验证。我们不会继续前进,直到我们已经验证我们的更改保持了系统行为和稳定性。这种增量方法最小化了风险,并允许我们在转型过程中持续交付价值。

在高层次上,我们的方法与 Strangler Fig 模式(martinfowler.com/bliki/StranglerFigApplication.html)相一致,其中我们逐步替换遗留应用程序的部分,同时保持相同的外部接口。这种方法通过允许增量验证并在需要时回滚来最小化风险。

图 11.5:当前系统架构,显示并行实现

图 11.5:当前系统架构,显示并行实现

图 11.5展示了我们当前的架构状态,系统中同时存在遗留和干净实现。遗留组件代表直接将业务逻辑与基础设施关注点混合在一起的混乱、无结构的代码。相比之下,Clean Architecture 实现显示了适当的关注点分离,具有不同的层和定义良好的接口。

通过这种增量实施方法,我们在转型之旅中取得了重大进展:

  1. 我们建立了一个干净的领域模型,具有适当的实体和值对象

  2. 我们实现了仓库适配器,它们连接我们的领域模型和现有的数据库

  3. 我们创建了使用我们的领域模型编排业务逻辑的用例

  4. 我们构建了控制器,它们可以在网络请求和我们的领域语言之间进行转换

  5. 我们使用适配器模式将我们的干净实现与遗留代码集成

通过这种增量实施方法,我们展示了如何使用 Clean Architecture 原则将遗留系统进行转型,同时在整个过程中保持系统稳定性和功能。

第 4 阶段:优化阶段

虽然我们的示例主要关注基础、接口和集成阶段,但完整的转型最终将包括一个优化阶段。这个最终阶段通常涉及性能调整、扩展测试覆盖范围以及基于实际使用情况改进的错误处理模式。

而不是提供这个阶段的详细示例,我们将指出,优化应以相同的增量心态来对待。团队应优先考虑能带来最大商业价值的优化,随着干净实现的稳定证明,逐步移除特性标志,并最终完全退役遗留代码路径。

优化阶段承认,架构转型不是一个一次性努力,而是一个持续精炼的过程,它平衡了技术卓越与业务优先级。团队应定义明确的指标,以确定何时已达到“足够好”,避免陷入无尽完美主义的陷阱。

摘要

在本章中,我们探讨了如何通过系统性的转型将 Clean Architecture 原则应用于遗留系统。我们首先通过 Clean Architecture 的视角评估现有系统,识别架构违规,并创建了一个分阶段的转型方法。

我们通过将技术债务转化为业务影响术语,并通过协作技术如事件风暴来收集更深入的领域理解,建立了一个构建利益相关者一致性的框架。这种协作方法直接指导了我们的分阶段实施计划,将我们的架构决策建立在业务优先级的基础上。

通过我们的订单处理示例,我们展示了一种渐进式实施方法,在建立清晰的架构边界的同时保持系统稳定性。我们首先从领域层开始,创建了适当的实体和价值对象,它们封装了代码库中之前分散的业务规则。然后我们实现了仓库接口,保护我们的领域免受基础设施细节的影响,接着是使用案例,它们协调业务操作。

接口适配层在我们干净的实现和遗留代码之间架起了一座桥梁,通过特性标志和适配器模式实现增量采用。这种分阶段的方法允许我们在最小化风险的同时验证我们的转型,展示了如何将 Clean Architecture 实际应用于现实世界系统。

通过遵循这些转型模式,您可以系统地提高现有系统的架构质量,降低维护成本,提高适应性,同时继续提供商业价值。这种方法体现了 Clean Architecture 的核心原则,同时认识到不断发展的生产系统的实际限制。

进一步阅读

第十二章:你的 Clean Architecture 之旅:下一步

随着我们探索的结束,是时候超越我们的任务管理实现,转向 Clean Architecture 原则的更广泛应用了。在整个旅程中,我们看到了 Clean Architecture 如何创建出适应性强、易于维护且对变化具有弹性的系统。现在,我们将探讨这些相同的原理如何应用于不同的架构环境中,以及你如何在你的团队和组织中领导这一应用。

Clean Architecture 不是一个僵化的公式,而是一套灵活的原则,可以适应各种系统类型和组织环境。这些原则的真正力量不是在盲目遵循时显现,而是在深思熟虑地应用于解决你系统面临的特定挑战时。

在本章的最后,我们将从三个角度来审视 Clean Architecture:作为一个超越特定实现的统一整体,作为一个适应不同架构风格的灵活方法,以及作为技术领导的基础。这些视角将帮助你有效地在你的独特环境中应用 Clean Architecture 原则。

在本章中,我们将涵盖以下主要主题:

  • 回顾 Clean Architecture:整体视角

  • 在不同系统类型中适应 Clean Architecture

  • 架构领导和社区参与

回顾 Clean Architecture:整体视角

在与任务管理系统相伴的整个旅程中,我们逐步构建了一个全面的 Clean Architecture 实现。每一章都是在前一章的基础上建立的,增加了新的层次和能力,同时保持了核心的架构原则。当我们从高层次、整体的角度回顾 Clean Architecture 时,让我们来看看是什么使得这种架构方法如此强大和适应性强。

通过架构层的旅程

我们的旅程始于 SOLID 原则和类型增强的 Python,为可维护、可适应的代码奠定了基础。然后,我们通过架构层从内到外进行移动:从封装核心业务概念的领域实体,到协调业务操作的使用案例,再到在核心和外部关注点之间进行转换的接口适配器,最后到连接我们的系统与外部世界的框架。

使这种分层方法强大的不仅仅是它提供的关注点分离,还有它如何通过定义良好的接口实现层之间的受控通信。在整个实现过程中,我们看到了这些架构边界如何创建一个既灵活又能够适应变化的系统。当我们添加了网络界面在第九章时,我们的核心业务逻辑保持未受影响。当我们实现了可观察性在第十章时,我们的监控能力与现有组件干净地集成,而没有干扰它们的职责。

这种架构的弹性源于我们对依赖规则的一致应用。这确保了依赖始终指向更稳定的抽象。通过通过接口和依赖注入反转传统依赖,我们创建了一个系统,其中外部变化不会影响我们的核心业务逻辑。虽然我们将在本章后面探讨一些可能需要灵活应用此规则的现实情况,但基本原理已经为我们服务得很好。这种保护并非理论上的;我们通过在多个接口和存储机制上的实际实现来证明了这一点。

Python 与 Clean Architecture 的天然契合

Python 已被证明是实现 Clean Architecture 的理想语言。其动态特性与类型提示的结合为我们提供了灵活性与结构之间的完美平衡。在整个实现过程中,我们利用了与 Clean Architecture 原则自然吻合的 Python 特定功能:

  • 鸭子类型允许我们创建关注行为而非严格继承层次结构的灵活接口

  • 类型提示在架构边界提供清晰性,同时不牺牲 Python 的动态特性

  • 抽象基类协议在层之间建立了清晰的合同

  • 数据类简化了实体实现,同时保持了适当的封装

Python 的简单哲学与 Clean Architecture 对清晰性的强调之间的这种协同作用,创建了既可维护又具有表现力的系统。Python 的可读性自然与 Clean Architecture 的目标相一致,即使系统意图清晰,同时其灵活性使得能够不使用过多的样板代码来实现架构模式。

我们旅程中最有价值的见解可能是,Clean Architecture 并非关于僵化的结构规则,而是关于创建组件可以独立进化但又能协同工作的系统。我们确立的边界不仅分离了关注点,而且积极管理不同上下文需求之间的转换,确保每一层都能专注于其特定的责任。

当我们探索 Clean Architecture 在任务管理示例之外的更广泛应用时,请记住,我们已实施的模式和原则是您架构工具箱中的工具。虽然具体结构可能因上下文而异,但关注点分离、依赖倒置和清晰的边界等核心原则在多种系统类型中仍然很有价值。在这本书中,我们已经展示了这些原则的全面实现,以展示其全部潜力,但团队应选择对其特定上下文和约束最有价值的边界和抽象。

在不同系统类型中适应 Clean Architecture

通过我们的任务管理系统实现,Clean Architecture 已经证明了其价值。现在让我们探索这些相同的原理如何适应不同的架构上下文。而不是僵化地应用模式,我们将关注 Clean Architecture 的核心原则——依赖规则、清晰的边界和关注点分离——如何根据这些专业领域进行调整,同时保持架构的完整性。

API 系统中的 Clean Architecture

与我们的任务管理应用程序相比,纯 API 系统呈现了一种基本的架构转变。在我们之前对任务应用程序的实现中,我们通过控制器和请求/响应模型创建了一个内部 API,但这些只被我们完全控制的表示层(CLI 和 Web UI)消费。这给了我们很大的自由度来修改这些接口,因为我们能够同时更新交互的双方。

API-first 系统通过直接将这些接口暴露给外部客户端(我们不一定控制)来移除了这个安全网。这就像是我们从任务管理系统取出了控制器和请求/响应模型,并将它们公开,允许其他开发者构建直接依赖于其结构和行为的应用程序。

这种转变从根本上改变了我们必须如何处理我们的架构边界。考虑以下来自我们任务管理系统的例子:

# Task management Request model - internal only
class CreateTaskRequest:
    """Data structure for task creation requests."""
    title: str
    description: str
    project_id: Optional[str] = None

    def to_execution_params(self) -> dict:
        """Convert validated request data to use case parameters."""
        return {
            'title': self.title.strip(),
            'project_id': UUID(self.project_id)
            if self.project_id else None,
            'description': self.description.strip()
        } 

在我们的任务管理系统中,这个模型被安全地隐藏在我们的表示层后面。当我们需要更改它以更好地与领域演变保持一致时,我们只需更新我们的 CLI 或 Web UI 以匹配。外部系统不受影响,因为它们与我们的表示层交互,而不是直接与这些模型交互。

然而,在以 API 为第一的系统里,这些模型直接暴露为公共契约:

# API Request DTO - now a public contract
class CreateTaskRequest:
    title: str
    description: str
    project_id: Optional[str] = None 

注意到 API 系统版本的CreateTaskRequest类看起来更简单。to_execution_params方法特别缺失。这种差异反映了 UI 中心和 API 系统之间的基本区别。在我们的原始任务管理应用程序中,这个方法处理了用户界面格式和领域概念之间的复杂转换。它需要处理表单数据,处理字符串到 UUID 的转换,并在领域处理开始之前进行验证。

在 API 系统中,许多这些展示方面的关注点完全消失。客户端处理 UI 渲染和初始输入格式化,提交的数据已经根据我们的 API 合约进行了结构化。这把责任从我们的系统转移出去,使得请求模型可以专注于定义有效输入的结构,而不是转换它们。API 合约和领域对象之间的实际转换仍然发生,但通常是通过 API 框架提供的更简单、更标准化的机制来实现的。

清洁架构的接口适配器层正是在这个具有挑战性的背景下证明了其价值。在这个层中,控制器继续履行其基本的转换角色,但具有针对 API 上下文的具体适应。他们现在进行一项关键的平衡行为,保持将领域与外部关注点隔离的基本责任,同时确保外部消费者 API 合约的稳定性。

在 API 系统中,这些外部关注点的性质发生了显著变化。而不是管理像表单处理或模板渲染这样的展示细节,控制器现在专注于维护边界,以确保:

  • 我们的领域模型可以适应不断变化的企业需求,而不会破坏 API 合约

  • 我们可以在不重构整个领域的情况下对我们的 API 合约进行版本控制

  • 我们可以为不同的客户端需求提供多个接口变体,同时共享相同的核心逻辑

同时,外层的框架和驱动层也适应了这种以 API 为中心的上下文。它不再管理多个展示技术,如 CLI 和 Web 界面,现在它专注于处理 HTTP 协议、请求路由和内容协商。这一最外层继续处理框架特定的关注点,但更加关注 API 交付机制而不是用户界面技术。

在适当的架构边界内,纯 API 系统利用了我们在整本书中应用的基本清洁架构原则。关注点的分离、依赖反转和显式接口在这个上下文中同样有效,尽管有不同的重点。所有层继续其基本角色,现在适应了公共 API 合约的独特要求。

现代 API 框架提供了专门的工具来支持这些架构模式,提供可以简化实现同时保持清晰边界的功能。让我们来看看这些框架如何补充我们的 Clean Architecture 方法。

FastAPI 的框架考虑

正如我们在第九章中利用 Flask 为我们的任务管理网络界面提供支持一样,Python 生态系统提供了用于构建 API 的专用框架。FastAPI 是一个流行的例子,因其性能、自动文档生成和强类型集成而获得了显著的吸引力。

虽然 Flask 专注于通用网络开发,包括模板渲染和会话管理,但 FastAPI 专注于构建高性能 API,并具有自动 OpenAPI 文档功能。Pydantic 是 FastAPI 的核心组件,它通过 Python 类型注解提供数据验证、序列化和文档,从概念上类似于我们在任务管理实现中使用的 dataclasses,但具有额外的验证功能。

API 系统通常利用这些专用框架,这为我们提供了一个有趣的架构决策,即它们在我们 Clean Architecture 实现中的作用。它们提供的强大验证、序列化和文档功能为我们提供了一个机会,与我们的原始任务管理实现相比,可以简化我们的架构。

请求和响应模型中的数据处理变得更加流畅。在我们的原始任务管理系统,我们创建了具有手动验证的独立请求和响应模型,以管理层之间的边界:

# Task management - manual validation
class CreateTaskRequest:
    """Request data for creating a new task."""
    title: str
    description: str

    def __post_init__(self):
        if not self.title.strip():
            raise ValueError("Title cannot be empty")

    def to_execution_params(self) -> dict:
        return {"title": self.title.strip(),
                "description": self.description.strip()} 

这种手动验证方法需要在我们的任务管理系统中进行显式的检查和转换方法。相比之下,Pydantic 直接将这些功能集成到模型定义中:

# FastAPI/Pydantic - automatic validation
from pydantic import BaseModel, Field
class CreateTaskRequest(BaseModel):
    title: str = Field(..., min_length=1)
    description: str 

在这里 CreateTaskRequest 扩展了 Pydantic 的 BaseModel。这一变化不仅消除了验证样板代码,而且通过字段约束(如 min_length=1)自动处理验证。

当使用此模型与 FastAPI 结合时,验证会自动发生:

# How validation works with FastAPI/Pydantic
@app.post("/tasks/")
def create_task(task_data: CreateTaskRequest):
    # FastAPI has already validated all fields
    # Invalid requests are rejected with 422 Unprocessable Entity

    result = task_controller.handle_create(
        title=task_data.title,
        description=task_data.description
    )
    return result.success 

假设客户端发送了无效数据,例如一个空标题:

{
  "title": "",
  "description": "Test description"
} 

FastAPI 会自动响应验证错误:

{
  "detail": [
    {
      "loc": ["body", "title"],
      "msg": "ensure this value has at least 1 characters",
      "type": "value_error.any_str.min_length",
      "ctx": {"limit_value": 1}
    }
  ]
} 

这种验证发生在你的路由处理程序执行之前,消除了手动验证代码的需要。

这种声明式方法显著减少了我们在任务管理系统中所需的样板代码。然而,它提出了一个重要的架构问题:我们应该允许第三方库 Pydantic 渗透到我们的内部层吗?Clean Architecture 的依赖规则警告我们不要这样做。

为了保持对 Clean Architecture 原则的严格遵循,我们需要这样做:

# Pure Clean Architecture approach with FastAPI
@app.post("/tasks/")
def create_task(task_data: CreateTaskRequest):  # Using Pydantic here is fine - we're in the Frameworks layer
    # Transform the Pydantic model to our internal domain model
    # to avoid letting Pydantic penetrate inner layers
    request = InternalCreateTaskRequest(
        title=task_data.title.strip(),
        description=task_data.description.strip()
    )

    # Pass our internal model to the controller
    result = task_controller.handle_create(request)
    return result.success 

这种方法保持了 Clean Architecture 的依赖规则,但引入了显著的重复。我们需要做的是:

  • 定义用于外部验证的 Pydantic 模型(FastAPI 层)

  • 为我们的应用层定义几乎相同的内部模型

  • 在这些并行模型之间创建转换

  • 随着 API 的发展,维护这两种模型类型

这种重复会违反不要重复自己DRY)原则,并会引入维护负担,要求在需求变更时同步更新这两组模型。

一个务实的替代方案是将 Pydantic 视为 Python 核心能力的稳定扩展,而不是一个易变的第三方库。它的广泛采用、稳定性和专注的目的使其不太可能经历破坏性的变化,这将对我们的领域逻辑产生重大影响。

最终,每个团队都必须根据他们特定的背景权衡这些考虑:

  • 严格的架构纯洁性对你的项目目标有多重要?

  • 在你的特定领域,重复模型的维护成本是多少?

  • 相关的外部依赖有多稳定和成熟?

  • 这个决定为其他架构边界设定了什么先例?

没有普遍正确的答案。一些团队将优先考虑严格遵循清洁架构原则,接受额外的维护负担以确保关注点的完全分离。其他团队将为了特定且有充分理由的情况(如 Pydantic)做出权衡,将其视为类似于 Python 标准库的基础依赖项。

关键在于明确做出这个决定,并在你的架构决策记录中记录下来,确保团队理解这个推理。无论你选择严格的分离还是务实的折衷,最重要的是这个决定是有意的、一致的,并且与你的项目具体需求和约束相一致。这种明确的决策即使在实际考虑导致对规则的受控例外时也能保持架构的完整性。

在 FastAPI 中应用清洁架构

为了说明这些架构原则如何转化为 API 系统,让我们看看使用 FastAPI 的简洁实现。这个例子演示了我们在 Flask 中使用的相同的清洁架构模式如何在 API 环境中应用:

# Framework layer (infrastructure/api/routes.py)
@app.post("/tasks/", response_model=TaskResponse, status_code=201)
def create_task(task_data: CreateTaskRequest):
    """Create a new task."""
    # The controller handles translation between API and domain
    result = task_controller.handle_create(
        title=task_data.title,
        description=task_data.description,
        project_id=task_data.project_id
    )

    if not result.is_success:
        # Error handling at the framework boundary
        raise HTTPException(status_code=400, detail=result.error.message)

    return result.success  # Automatic serialization to TaskResponse 

这个路由处理程序遵循与我们的 Flask 路由相同的清洁架构原则,来自第九章,但进行了 API 特定的调整。两种实现:

  1. 将框架特定的代码保留在系统边缘

  2. 将业务操作委托给控制器

  3. 在外部和内部格式之间进行转换

  4. 在适当的边界处理错误

主要的区别在于框架如何处理请求处理和响应格式化。在 Flask 中,路由处理程序提取表单数据并渲染模板,而在 FastAPI 中,路由处理程序利用 Pydantic 模型进行验证和序列化。然而,在这两种情况下,架构边界仍然保持完整。路由处理程序充当框架和我们的应用程序核心之间的薄适配器。

这种在不同接口类型之间的统一性展示了清洁架构的适应性。无论是实现 Web UI、CLI 还是 API,相同的架构原则指导我们的设计决策。每种接口类型都带来其自身的特定关注点和优化,但保持业务逻辑独立于交付机制的基本模式保持不变。

事件驱动架构和清洁架构

事件驱动架构代表了从我们的任务管理系统请求/响应模型到另一种范式转变。虽然我们的原始任务管理应用程序处理直接命令,如“创建任务”或“完成任务”,但事件驱动系统则是对事件做出反应——已经发生的事实,如“任务创建”或“截止日期临近”。

这种交互模式的根本性变化引入了新的架构挑战,清洁架构恰好能够解决这些挑战。虽然对事件驱动架构的全面探索需要一本自己的书,但我们将关注如何在此背景下应用清洁架构原则,突出关键模式和考虑因素,以保持事件驱动系统中的架构边界。

事件驱动架构的核心概念

在事件驱动系统中,中心组织原则是事件,这是一个系统要么生成要么消费的重要发生。事件驱动范式引入了几个在任务管理系统中所不具备的架构元素:

  • 事件生产者在发生重要状态变化时生成事件

  • 事件消费者通过执行适当的操作对事件做出反应

  • 消息代理促进生产者和消费者之间可靠的事件交付

  • 事件存储用于维护事件历史记录,以便回放和审计

这些元素创建了新的架构边界,必须在保持清洁架构的依赖规则和关注点分离的同时进行管理。

将清洁架构应用于事件驱动系统

当将清洁架构应用于事件驱动系统时,领域层基本保持不变,我们的业务实体和核心规则保持相同。主要的调整主要发生在应用和接口层。

图 12.1:事件驱动系统的组件

图 12.1:事件驱动系统的组件

事件驱动系统中的应用层通常会演变,包括:

  • 事件处理器对传入的事件做出反应,类似于用例,但由事件触发而不是直接命令

  • 事件生成器在发生重要状态变化时产生领域事件

接口适配器层转变为包括:

  • 事件序列化器在领域事件和消息代理使用的消息格式之间进行转换

  • 消息代理适配器从应用程序核心抽象出特定的消息技术

在我们的任务管理背景下,事件驱动的实现可能会对诸如 TaskCreatedDeadlineApproachingProjectCompleted 等事件做出反应。这些事件将通过系统流动,触发适当的处理逻辑,同时保持清洁架构的边界。

领域事件在清洁架构中作为一等公民

在事件驱动清洁架构中最显著的适应之一是将领域事件提升为架构中的一等公民。在我们的原始任务管理系统,事件可能存在隐式地,比如当任务完成时触发通知,但它们并不是核心架构组件。

在事件驱动架构中,领域事件成为明确的、命名的对象,代表有意义的业务事件。这些事件不仅仅是消息;它们是您通用语言和领域模型的一部分。它们以业务术语捕捉发生的事情,作为边界上下文之间的通信机制,同时保持清洁架构的边界。

让我们看看清洁架构如何通过提供清晰的边界和责任来帮助驯服事件驱动系统的复杂性。以下反模式演示了没有这些边界会发生什么:

# Anti-pattern: Domain entity directly publishing events
class Task:
    def complete(self, user_id: UUID):
        self.status = TaskStatus.DONE
        self.completed_at = datetime.now()
        self.completed_by = user_id

        # Direct dependency on messaging system –
        # violates Clean Architecture
        kafka_producer = KafkaProducer(bootstrap_servers='kafka:9092')
        event_data = {
            "task_id": str(self.id),
            "completed_by": str(user_id),
            "completed_at": self.completed_at.isoformat()
        }
        kafka_producer.send(
            'task_events',
            json.dumps(event_data).encode()
        ) 

这种反模式违反了清洁架构原则,因为它直接将领域实体与基础设施关注点(Kafka 消息传递)耦合。这使得领域层依赖于外部技术,损害了可测试性和灵活性。

清洁的实现方式确保了所有架构层之间关注点的适当分离。让我们逐个检查每一层。

首先,领域实体仅专注于业务逻辑,没有意识到事件发布的存在:

# Clean domain entity - no messaging dependencies
class Task:
    def complete(self, user_id: UUID) -> None:
        if self.status == TaskStatus.DONE:
            raise ValueError("Task is already completed")
        self.status = TaskStatus.DONE
        self.completed_at = datetime.now()
        self.completed_by = user_id 

注意 Task 实体如何仅处理任务完成的业务逻辑。它执行其状态变化和验证,但没有关于事件或消息的知识。这保持了纯领域逻辑,可以独立进行测试。

转到应用层,用例负责协调领域操作和事件创建:

# Application layer handles event creation
@dataclass
class CompleteTaskUseCase:
    task_repository: TaskRepository
    # Abstract interface, not implementation:
    event_publisher: EventPublisher

    def execute(self, task_id: UUID, user_id: UUID) -> Result:
        try:
            task = self.task_repository.get_by_id(task_id)
            task.complete(user_id)
            self.task_repository.save(task)

            # Create domain event and publish through abstract interface
            event = TaskCompletedEvent.from_task(task, user_id)
            self.event_publisher.publish(event)

            return Result.success(task)
        except ValueError as e:
            return Result.failure(Error(str(e))) 

用例协调多个操作:检索任务、执行领域操作、持久化更新后的状态,并发布事件。关键的是,它只依赖于抽象的 EventPublisher 接口,而不是任何特定实现。

最后,在接口适配器层,像 KafkaEventPublisher 类这样的具体实现将处理事件交付的技术细节。类似于我们在前面的章节中如何实现 SQLiteTaskRepository 类的抽象 TaskRepository 接口,这些事件发布者实现抽象的 EventPublisher 接口,同时封装所有消息特定的细节。这保持了清洁架构在保持基础设施实现在外层,而应用程序核心仅与抽象交互的一致模式。

这种清洁的实现为事件驱动系统提供了几个关键好处:

  • 可测试性:可以在不使用消息代理或事件基础设施的情况下测试领域逻辑

  • 灵活性:可以更改消息技术,而无需修改领域或应用逻辑

  • 清晰性:事件流通过明确的边界变得明确且可追踪

  • 进化:可以添加新的事件类型和处理程序,而不会破坏现有组件

此外,在更广泛的意义上,清洁架构为我们系统中每个与事件相关的关注点提供了明确的指导。领域事件在领域层找到其自然家园,作为表示重要业务事件的价值对象。事件发布逻辑位于应用层,作为用例协调的一部分,而事件序列化属于接口适配器层,它在领域概念和技术格式之间进行转换。最后,所有消息基础设施都保留在最外层的框架和驱动器层,确保这些技术细节完全与核心业务逻辑隔离。这种清晰的分离为事件驱动系统的潜在复杂性带来了秩序,同时使这种架构风格所需的特定交互模式得以实现。

通过保持这些清洁的分离,尽管事件驱动系统具有固有的复杂性,但它们变得更加易于管理。领域模型始终关注业务概念,应用层协调操作和事件流,外层处理技术问题而不污染核心。

这证明了清洁架构对不同架构风格的适应性。无论是构建请求/响应 API 还是事件驱动的反应式系统,核心原则始终保持一致,保持业务逻辑纯净并从技术关注点中隔离出来,同时使每种风格所需的特定交互模式得以实现。

架构领导和社区参与

在整本书中,我们一直专注于在 Python 中实现清洁架构的技术实现。仅凭技术知识是不够的,以创造持久的架构影响。成功的架构采用需要领导力、沟通和社区建设。

清洁架构不仅仅是一组技术模式;它是一种挑战传统软件设计方法的哲学。有效地实施它通常需要组织变革、团队对齐和文化转变。随着您掌握清洁架构的技术方面,您影响这些更广泛因素的能力变得越来越重要。

在本节中,我们将探讨如何领导架构变革,为更广泛的社区做出贡献,并在您的组织中建立可持续的架构实践。这些技能将补充您的技术知识,使您能够超越个人实现,产生持久的影响。

领导架构变革

架构领导很少伴随着正式的权力。无论你是高级开发者、技术负责人还是架构师,实施清洁架构通常需要影响跨团队和部门的决策。这种基于影响力的领导既带来挑战也带来机遇。

构建清洁架构的案例

领导架构变革的第一步是提出清洁架构原则的令人信服的案例。正如我们在第十一章中讨论遗留系统转型时探索的那样,这需要将技术益处转化为利益相关者关心的商业价值:

清洁架构的益处 商业价值
关注点分离 初始投资后更快的功能交付
清晰的边界 减少回归问题,更稳定的发布
框架独立性 更长的系统生命周期,减少重写必要性
可测试性 更高的质量,更少的生产事件

在向不同的利益相关者介绍清洁架构时,调整你的信息以适应他们的具体关注点:

  • 对于产品经理来说,强调架构清晰度如何支持初始投资后的快速功能迭代

  • 对于工程经理来说,强调清洁架构如何提高可维护性并减少技术债务

  • 对于开发者来说,关注清晰的边界如何简化工作并减少意外的副作用

  • 对于高管来说,将技术益处转化为市场时间缩短和适应市场驱动因素变化的能力等商业指标

记住,清洁架构代表了一个重大的投资。在强调长期益处的同时,诚实地讨论前期成本。来自你组织的具体例子,例如变得难以维护的先前项目,可以使你的案例比抽象原则更有说服力。

从小做起:典范的力量

尝试一次性在整个组织中实施清洁架构很少会成功。相反,通过小型、可见的成功来展示其价值:

  • 确定一个界限清晰的组件,其中清洁架构可以提供明显的益处

  • 通过适当的关注点分离和清晰的边界彻底实施

  • 记录过程和结果以与他人分享

  • 通过开发速度、缺陷率或入职时间等指标衡量改进

这些示例除了仅仅展示架构概念之外,还有多重作用。通过展示清洁架构的实际应用,它们提供了抽象讨论无法比拟的实质性证据。它们还创建了有价值的参考实现,其他团队可以研究和适应他们自己的环境。随着你成功实施这些示例,你将在组织内部建立架构领导者的信誉,从而对未来决策产生更大的影响力。也许最重要的是,这些实现为通过协作工作和代码审查指导他人学习架构原则创造了自然的机会,在整个组织中传播知识。

示例方法在绿地项目和现有系统中都有效。虽然从头开始构建新的应用程序提供了最干净的实现路径,但大多数组织都有大量的现有代码库,这些代码库不能立即替换。在这些环境中,你可能会使用清洁架构原则在现有系统中实现一个新功能,明确地将领域逻辑与框架关注点分开。由于这个组件比其他组件更容易测试、扩展和维护,它成为更广泛采用的有力论据。这种有针对性的方法展示了清洁架构的价值,而不需要全面系统改造,为渐进式改进创造了动力。

克服对架构变更的阻力

架构变更通常会遇到阻力,这通常遵循可预测的模式。了解这些常见的反对意见有助于你有效地应对它们。

“太抽象了”:人们常常难以看到架构原则如何应用于他们的日常工作。这些概念可能看起来是理论性的,与实际的编码任务脱节。通过创建使用贵组织实际代码的具体示例来解决这个问题。展示清洁架构原则如何解决团队遇到的具体问题,将抽象概念转化为他们可以立即识别的实质性改进。

“开销太大”:团队经常认为架构纪律的前期成本与即时收益相比过高。对于那些专注于短期交付的人来说,额外的接口和分离可能看起来是不必要的。通过展示通过指标和以前项目的例子来证明长期效率收益,来反驳这种看法。分享架构投资如何降低维护成本并加速后期阶段的功能开发的案例。

“我们没有时间”:交付压力不断推动团队寻求便利的解决方案,而不是架构改进。这种时间限制通常是真实的,而不仅仅是借口。承认这一现实,同时展示架构边界实际上在初始投资之后如何加速开发。从小的、渐进的改进开始,这些改进能带来即时的好处,而不会破坏关键的最后期限。

“这在这里行不通”:组织往往认为他们的问题与像 Clean Architecture 这样的既定方法格格不入。这种例外主义源于对内部复杂性和挑战的深入了解。通过确定可以成功应用原则的小区域来解决这个问题,证明 Clean Architecture 可以适应你的特定环境。这些有针对性的成功逐渐克服了“不是这里发明的”的阻力。

最重要的是,认识到阻力通常来自有效的担忧,而不是简单的固执。仔细倾听具体的反对意见,承认其合法性,并直接解决它们,而不是将其置之不理。

平衡实用主义和原则

在前面的章节中,我们强调 Clean Architecture 是一套原则,而不是僵化的规则。正如我们在本章前面讨论 API 首先系统和事件驱动架构时所指出的,实际实施通常需要针对特定环境进行深思熟虑的调整。这种灵活性在领导架构变革时尤为重要。坚持在任何情况下都要求架构纯洁性的教条方法通常都会失败,而完全不一致的方法则不会提供任何架构上的好处。

中庸之道,原则上的实用主义,提供了最佳的成功机会:

  • 保持对不应妥协的核心原则的清晰认识

  • 认识到可能需要作出实际妥协的领域

  • 记录文档架构决策及其理由,包括妥协

  • 明确界定不同标准适用的范围

例如,你可能会在核心业务逻辑中严格保持领域和基础设施之间的分离,而在不那么关键的区域接受更多的耦合。或者,你可能会在领域层接受对稳定库的受控依赖,同时严格禁止框架依赖。

这些架构边界和决策应明确记录和传达,理想情况下是通过架构决策记录ADRs)来捕捉决策及其背景。这种文档构建了共享的理解,并防止随着时间的推移团队变化导致的架构漂移。以下是一个简洁的 ADR 模板,用于记录 Clean Architecture 决策:

# ADR-001: Use of Pydantic Models in Domain Layer
## Status
Accepted
## Context
Our API-first system requires extensive validation and serialization. Implementing these capabilities manually would require significant effort and potentially introduce bugs. Pydantic provides robust validation, serialization, and documentation through type annotations.
## Decision
We will allow Pydantic models in our domain layer, treating it as a stable extension to Python's type system rather than a volatile third-party dependency.
## Consequences
* Positive: Reduced boilerplate, improved validation, better documentation
* Positive: Consistent validation across system boundaries
* Negative: Creates dependency on external library in inner layers
* Negative: May complicate testing of domain entities
## Compliance
When using Pydantic in domain entities:
* Keep models focused on data structure, not behavior
* Avoid Pydantic-specific features that don't relate to validation
* Include comprehensive tests to verify domain rules still apply 

关于创建有效 ADR 的更多信息,请参阅 ADR GitHub 组织:adr.github.io/

此示例演示了如何通过 ADR(架构决策记录)形式化架构决策,特别是在允许某些依赖进入内层等实用妥协方面。模板展示了如何以结构化格式记录上下文、决策和后果,这有助于未来的开发者不仅理解已做出的决策,还理解决策的原因。

你可以通过结合技术知识和领导技能来成功地领导你组织的架构变革:提出有说服力的案例、创建典范、应对阻力,并在原则与实用主义之间取得平衡。这种基于影响力的领导将 Clean Architecture 的影响扩展到个人实现之外,以创造持久的组织变革。

缩小实现差距

尽管 Clean Architecture 广受欢迎且普遍认知,但在理论理解和实际应用之间仍存在显著的差距。许多开发者熟悉这些概念,但在现实世界的代码库中难以有效应用。这种实现差距既是一个挑战,也是架构领导者的一次机会。

贡献 Clean Architecture 示例

作为架构领导者,你可以做出的最有价值的贡献之一就是与更广泛的社区分享你的实际应用实现。这并不一定意味着开源整个应用程序,而是创建其他人可以从中学习的示例、模式和参考。除了帮助他人之外,这个过程——教授和记录你的实现方法——为你提供了显著的个人益处。向他人解释架构概念可以验证你自己的理解,并经常揭示你知识中的微妙差距。正如俗话所说,教学相长。当你清晰地阐述 Clean Architecture 原则,使他人能够理解时,你巩固并加深了对这些概念的理解。

考虑通过以下方式做出贡献:

  • 开源参考实现,展示特定领域的 Clean Architecture

  • 文章或博客文章,解释你如何将 Clean Architecture 应用于解决实际问题

  • 模板或入门套件,为 Python 的 Clean Architecture 提供基础

  • 代码片段,展示如何处理特定的架构挑战

  • 架构模式库,为常见问题提供可重用解决方案

这些贡献有助于弥合理论与实践之间的差距,使 Clean Architecture 对更广泛的开发社区更加易于接近。它们还确立了你在架构设计方面的思想领袖地位,创造了进一步影响和学习的机会。

在创建这些示例时,关注那些最容易被误解或难以实施的部分:

  • 维护适当抽象的存储库模式实现

  • 有效地协调领域操作的使用案例设计

  • 清晰地在各层之间进行转换的接口适配器

  • 支持测试和灵活性的依赖注入方法

  • 架构层之间的边界维护

通过具体的代码示例解决这些具体挑战,您可以显著加快他人的清洁架构采用。

从多个视角学习

在贡献自己的实现的同时,同样重要的是从他人那里学习。清洁架构,就像任何架构方法一样,随着从业者将其应用于新的领域和技术而不断演变。通过与不同的视角互动,您可以完善您的理解和方法。

通过以下方式寻求不同的观点:

  • 阅读其他语言的实现,以识别语言无关的模式

  • 检查对清洁架构的不同解释,以了解权衡

  • 参与架构论坛和讨论,听取不同的经验

  • 研究相关的架构风格,如六边形架构或洋葱架构

  • 指导他人并接受指导,因为教学可以加强理解,而从经验丰富的从业者那里学习可以加速成长

记住,这本书代表了 Python 中清洁架构的一个视角。其他同样有效的途径也存在,而正确的实现往往取决于特定的环境和限制。对这些不同视角的开放态度可以加强您的架构思维,并使原则的应用更加细腻。

通过对更广泛社区的贡献和学习,您有助于缩小实施差距,同时继续自己的架构成长。这种双向参与创造了一个良性循环,既推进了个人对清洁架构原则的理解,也推进了集体理解。

建立您的架构社区

虽然个人的建筑领导力很强大,但持续的架构卓越通常需要社区的支持。无论是在您的组织内部还是在更广泛的开发生态系统中建立架构社区,都能创造个人努力无法比拟的动力。

创建实践社区

在组织中,实践社区为架构学习和一致性提供了强大的结构。这些自愿的跨团队小组将致力于架构卓越的开发者聚集在一起,分享知识、制定标准并解决共同问题。

要建立一个架构实践社区:

  • 非正式地开始,通过午餐学习或讨论小组来衡量兴趣

  • 明确一个以架构学习和改进为中心的明确目标

  • 建立定期的接触点,如每周会议或每月深度探讨

  • 轮换领导权,以包括不同的观点并分担工作量

  • 产生有形的输出,如指南、模式或参考实现

这些社区服务于多个目的:

  • 它们为架构讨论创造了空间,没有立即交付的压力

  • 它们在团队之间建立共享的词汇和理解

  • 它们识别并解决常见的架构挑战

  • 它们为经验较少的开发者提供指导机会

最重要的是,它们将架构知识传播到个人专家之外,即使在团队成员随时间变化的情况下,也能创造组织弹性和连续性。

通过在您的组织中建立实践社区,您创建了一个生态系统,它超越了个人努力,维持了建筑卓越性。这种社区方法将清洁架构从个人兴趣转变为组织能力,确保我们在本书中探讨的好处可以跨团队扩展并持久存在。

清洁架构的持久影响不仅来自技术实现,还来自围绕它形成的社区和文化。通过领导架构变革、关闭实施差距和建立可持续社区,您将清洁架构的好处扩展到个人系统之外,从而在软件设计和构建方面创造持久的积极变化。

摘要

在本章的最后,我们将清洁架构的视野从我们的任务管理系统扩展到其更广泛的应用和适应。

我们回顾了我们的清洁架构之旅,看到定义良好的架构层如何创建灵活、有弹性的系统。Python 的特性,如鸭子类型、类型提示和抽象基类,使我们能够构建无需过多样板代码的可维护系统。

然后,我们探讨了针对不同系统类型的清洁架构适应。在 API 首选系统中,像 FastAPI 这样的框架增强了实现,同时要求对架构边界做出深思熟虑的决定。对于事件驱动架构,清洁架构为事件流带来秩序,同时保持纯业务逻辑。

我们还讨论了架构领导和社区参与,探讨了倡导清洁架构、应对阻力以及建立能够维持长期架构卓越性的实践社区的战略。

当您结束本书并继续您的清洁架构之旅时,请记住,我们探讨的原则是深思熟虑后应用的工具,而不是盲目遵循的教条。依赖规则、清晰的边界和关注点的分离为创建随着需求演变而保持适应性和可维护性的系统提供了基础。您应用这些原则的方式应反映您的具体环境、限制和目标。

清洁架构的真正力量在于其创建系统的能力,其中业务逻辑保持清晰和专注,无论技术或交付机制如何变化。通过建立适当的架构边界并保持尊重它们的纪律,您创建的系统不仅今天能工作,而且可以优雅地适应明天的挑战。

感谢您与我一起探索使用 Python 的 Clean Architecture。我希望本书中分享的模式、原则和实践能够帮助您创建经得起时间考验的系统。

进一步阅读

Packt_Logo_New1.png

packtpub.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及领先的工具,帮助您规划个人发展并推进您的职业生涯。更多信息,请访问我们的网站。

为什么订阅?

  • 通过来自 4,000 多名行业专业人士的实用电子书和视频,减少学习时间,增加编码时间

  • 通过为您量身定制的技能计划提高您的学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松访问关键信息

  • 复制粘贴、打印和收藏内容

www.packtpub.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

您可能还会喜欢的其他书籍

如果您喜欢这本书,您可能对 Packt 的其他书籍也感兴趣:

9781835882948.jpg

《Python 编程,第四版

Fabrizio Romano, Heinrich Kruger

ISBN: 978-1-83588-294-8

  • 在 Windows、Mac 和 Linux 上安装和设置 Python

  • 编写优雅、可重用和高效的代码

  • 避免常见的陷阱,如重复和过度设计

  • 适当地使用函数式和面向对象编程方法

  • 使用 FastAPI 构建 API 并编写 CLI 应用程序

  • 理解数据持久性和加密,以构建安全的应用程序

  • 使用 Python 的内置数据结构高效地操作数据

  • 将您的应用程序打包并通过 Python 包索引 (PyPI) 进行分发

  • 使用 Python 解决竞技编程问题

《Python 清洁代码 第二版》

Mariano Anaya

ISBN: 978-1-80056-021-5

  • 通过利用自动工具设置高效的开发环境

  • 利用 Python 的魔法方法编写更好的代码,将复杂性抽象出来并封装细节

  • 使用 Python 的独特功能,如描述符,创建高级面向对象设计

  • 通过使用面向对象设计的软件工程原则创建强大的抽象来消除重复的代码

  • 使用装饰器和描述符创建特定于 Python 的解决方案

  • 在单元测试的帮助下有效地重构代码

  • 以干净的代码库为基础,构建坚实的架构基础

《Python 面向对象编程 第四版》

Steven F. Lott, Dusty Phillips

ISBN: 978-1-80107-726-2

  • 通过创建类和定义方法在 Python 中实现对象

  • 通过继承扩展类功能

  • 使用异常来干净地处理异常情况

  • 理解何时使用面向对象功能,更重要的是,何时不使用它们

  • 发现几个广泛使用的设计模式以及它们在 Python 中的实现方式

  • 揭示单元和集成测试的简单性,并了解为什么它们如此重要

  • 学习如何静态类型检查动态代码

  • 通过 asyncio 理解并发以及它是如何加快程序速度的

Packt 正在寻找像你这样的作者

如果你对成为 Packt 的作者感兴趣,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了 《使用 Python 的清洁架构》,我们非常想听听你的想法!扫描下面的二维码直接进入这本书的亚马逊评论页面,分享你的反馈或在你购买的地方留下评论。

packt.link/r/183664289X

你的评论对我们和整个技术社区都很重要,并将帮助我们确保我们提供高质量的内容。

posted @ 2025-09-20 21:33  绝不原创的飞龙  阅读(43)  评论(0)    收藏  举报