Skip to content

Repository files navigation

《Python的架构模式》学习笔记

链接:https://www.cosmicpython.com/book/preface.html

导言

1. 软件系统的“混乱”现状

文章首先探讨了为什么软件设计容易出错。

  • 软件熵(Software Entropy): 随着时间的推移,初始整洁的代码往往会变得混乱,充满特殊情况(edge cases)和临时的“工具模块(util modules)”。
  • 泥潭模式(Big Ball of Mud): 许多系统最终会演变成所有组件高度耦合的状态。改变一个部分往往会引发不可预知的连锁反应,导致维护极其困难。
  • 混乱的特征: 业务逻辑与 I/O 操作(如发送邮件、数据库访问)混在一起,API 处理器直接包含领域知识,导致系统难以测试和扩展。

2. 封装与抽象(Encapsulation and Abstraction)

为了对抗这种混乱,作者强调了封装和抽象的重要性:

  • 封装行为: 封装不仅仅是隐藏数据,更重要的是通过识别任务并将其分配给明确的对象或函数来“简化行为”。
  • 表达力与可维护性: 使用抽象可以使代码更具表达力,也更容易进行单元测试,因为我们可以轻松地替换掉复杂的依赖项。

3. 分层架构(Layered Architecture)

文章介绍了应对复杂性的经典方案——分层架构:

  • UI 层: 网页、API 或命令行。
  • 业务逻辑层: 包含业务规则和工作流。
  • 数据库层: 负责数据持久化。
  • 目标: 通过划分职责和定义调用规则,防止系统演变成“大泥坨”。

4. 依赖倒置原则(Dependency Inversion Principle, DIP)

这是本书的核心思想之一:

  • 定义:
  1. 高层模块不应依赖低层模块,二者都应依赖抽象。
  2. 抽象不应依赖细节,细节应依赖抽象。
  • 高层 vs 低层: 高层模块是企业真正关心的“核心业务逻辑”(如患者管理、银行交易);低层模块则是“细节”(如数据库、文件系统、网络协议)。
  • 重要性: 业务逻辑应该保持稳定,而技术细节(细节)往往更容易变化(如更换数据库或升级框架)。通过 DIP,我们可以保护核心业务逻辑不受基础设施变化的影响。

5. 本书的定位

  • Cosmos vs Chaos: 书名中的 "Cosmic"(宇宙)寓意着“秩序”,即通过模式将混乱(Chaos)转变为有序。
  • 实战导向: 本书不只是谈论理论,而是通过一个具体的案例研究(订单分配系统),手把手地展示如何将领域驱动设计(DDD)和这些架构模式应用到 Python 项目中。

总结

导言部分为全书奠定了基调:在 Python 变得越来越成熟和“企业级”的今天,开发者需要学习如何通过抽象、分层和依赖倒置等模式来管理业务复杂性,从而构建出更健壮、易测试且易于维护的系统。

第一章:领域建模(Domain Modeling)

1. 什么是领域模型? (What Is a Domain Model?)

  • 领域 (Domain):指你试图解决的问题区域(例如:家具零售、物流调度)。
  • 模型 (Model):是对现实世界流程的简化抽象,旨在捕捉有用的属性。
  • 领域模型:是业务专家脑海中关于业务运作的“心理地图”。在软件开发中,它是我们试图用代码实现的业务逻辑核心。

2. 探索领域语言 (Exploring the Domain Language)

作者强调了 通用语言 (Ubiquitous Language) 的重要性。

  • 不要急于写代码:先与业务专家交谈,统一术语(如:OrderLine, Batch, Allocation)。
  • 编写单元测试作为文档:通过编写测试来描述业务规则。例如:“如果批次(Batch)的可用数量大于订单行(OrderLine)的数量,则可以进行分配”。

3. 核心模式:实体与值对象 (Entities and Value Objects)

这一章重点介绍了两种描述领域对象的模式:

值对象 (Value Objects)

  • 定义:由其持有的数据唯一标识的对象。如果没有标识符(如 ID),两个属性相同的对象就是同一个东西。
  • 特性不可变性 (Immutable)。在 Python 中通常使用 dataclasses(设置 frozen=True)来实现。
  • 示例OrderLine(orderid='oref', sku='RED-CHAIR', qty=10)

实体 (Entities)

  • 定义:具有长期标识的对象。即使属性发生变化,它仍然是同一个实体(例如:一个人改了名字,但还是那个人)。
  • 特性:通常拥有一个显式的 ID。在 Python 中需要实现 __eq____hash__ 来定义其唯一性。
  • 示例Batch(ref='batch-001', sku='RED-CHAIR', qty=100)。即使库存变了,batch-001 还是原来的那个批次。

4. 领域服务 (Domain Services)

  • 有些业务逻辑不自然地属于某个特定的实体(比如 BatchOrderLine)。
  • 在这种情况下,可以使用领域服务函数。在 Python 中,这通常是一个位于 model.py 模块中的普通函数,用于处理涉及多个对象的复杂逻辑。

5. 核心思想:剥离基础设施 (Decoupling Infrastructure)

这是本章乃至全书最重要的观点:

  • 模型不应依赖于外部:领域模型代码应该是“纯粹”的。它不应该知道数据库、SQLAlchemy、Django 或者是 web 框架的存在。
  • 为什么要这样做?
  1. 易于测试:可以在毫秒级运行数千个单元测试,无需数据库。
  2. 易于修改:业务规则的改变只涉及简单的 Python 代码,而不需要迁移数据库或修改复杂的 ORM 配置。

总结

第一章告诉我们:先写业务逻辑,再考虑存储。 通过 TDD(测试驱动开发)和对业务术语的精准建模,我们可以构建出一个健壮、易测试且与具体技术实现无关的领域核心。

这篇文章是《Architecture Patterns with Python》(又称 Cosmic Python)的第二章,主要探讨了仓储模式(Repository Pattern)

其核心目标是利用依赖倒置原则(DIP),将业务逻辑(领域模型)与底层基础设施(数据库/持久化)解耦。以下是该章节的详细总结:

第二章 仓储模式(Repository Pattern)

1. 核心动机:解除耦合

在传统的开发模式中,业务代码往往直接依赖 ORM(如 SQLAlchemy 或 Django ORM)。这会导致:

  • 模型污染:领域模型类必须继承自 ORM 基类,导致业务代码中充斥着数据库列定义、外键等细节。
  • 难以测试:如果业务逻辑直接调用数据库,单元测试就会变慢且难以设置,因为必须运行数据库实例。
  • 缺乏灵活性:一旦绑定了特定的 ORM 或数据库,以后想要更换存储方式(如从 SQL 换成 NoSQL 或 CSV)将极其困难。

2. 让 ORM 依赖模型(依赖倒置)

作者提出,不应该让模型依赖 ORM,而应该反过来。

  • 经典映射(Classical Mapping):在 SQLAlchemy 中,不要使用常用的声明式(Declarative)语法,而是定义独立的 Table 对象,并使用 mapper 函数将这些表手动映射到纯 Python 类上。
  • 结果:领域模型变成了“纯净”的 Python 对象(POPO),它完全不知道数据库的存在。

3. 什么是仓储模式?

仓储模式是介于领域模型数据库之间的一个抽象层。

  • 概念抽象:它通过模拟一个内存中的对象集合来隐藏数据访问的复杂性。
  • 核心接口:通常只包含简单的方法,如 add(entity)(添加对象)和 get(id)(获取对象)。
  • 优势
  • 持久化无感:业务逻辑只需要与 Repository 交互,不需要知道数据是存放在 PostgreSQL 还是内存字典里。
  • 测试便利性:可以轻松创建一个 FakeRepository(基于内存列表),使单元测试不再依赖真实数据库,运行速度极快。

4. 具体实现步骤

  1. 定义抽象基类:定义一个 Python 抽象基类(AbstractRepository),规定必须实现的接口(如 addget)。
  2. 实现具体仓储
  • SqlAlchemyRepository:使用真实的数据库连接。
  • FakeRepository:仅用于测试,内部使用内存集合存储数据。
  1. 在业务逻辑中使用:业务函数不再接受数据库 session,而是接受一个 AbstractRepository 的实例。

5. 权衡与选择(Trade-offs)

作者坦言,虽然这种模式带来了极高的灵活性和可测试性,但也存在成本:

  • 工作量增加:需要手动维护映射和仓储类,增加了代码量。
  • 复杂度:对于简单的 CRUD(增删改查)应用,这种设计可能过度工程化。
  • 适用场景:当业务逻辑非常复杂且需要频繁迭代时,这种架构投资的回报最高。

总结

本章的精髓在于:Repository 模式让你的业务逻辑专注于“做什么”,而将“怎么存”的问题推迟或隐藏。 通过这种方式,你可以保持领域模型的纯净,并让系统在面对基础设施变化时更具弹性。

第三章 插曲:关于耦合与抽象

1. 核心概念:耦合与抽象

  • 耦合(Coupling): 指组件之间的依赖关系。

  • 局部耦合: 是好事,代表组件协同工作(高内聚)。

  • 全局耦合: 是坏事,它会导致“牵一发而动全身”,增加维护成本。

  • 抽象(Abstraction): 通过引入简单的接口或数据结构来隐藏复杂的细节。

  • 抽象能减少系统间的连接点。如果系统 A 直接依赖系统 B,改变 B 就会影响 A;但如果 A 依赖一个抽象层,只要抽象不变,B 内部的改变就不会波及 A。

2. 案例分析:文件同步工具(File Sync Tool)

作者通过编写一个将两个文件夹同步的工具来演示抽象的过程。

  • 需求:
  1. 如果源文件不在目标目录,则拷贝。
  2. 如果文件名不同但内容相同(通过哈希判断),则重命名。
  3. 如果目标目录有多余文件,则删除。

初始版本(紧耦合):

最初的代码将业务逻辑(决定哪个文件该移动)与I/O 操作(调用 os.walk, shutil.copy 等)混在一起。

  • 问题: 单元测试非常痛苦。为了测试一个逻辑,必须在硬盘上创建真实的临时文件夹和文件,运行后再清理。这导致测试运行慢且代码难以维护。

3. 如何选择正确的抽象?

为了解决上述问题,作者提出了将代码拆分为三个职责:

  1. 扫描文件系统:获取路径及其哈希值(I/O)。
  2. 决策逻辑:对比两组哈希/路径,确定需要执行哪些操作(纯逻辑/业务)。
  3. 执行操作:实际进行拷贝、移动或删除(I/O)。

关键抽象:

  • 输入抽象: 使用简单的 Python 字典 {hash: filename} 来表示文件系统的状态,而不是直接在逻辑层操作磁盘。
  • 输出抽象: 逻辑层不直接调用删除/拷贝函数,而是输出一组“指令列表”(如 ("COPY", src, dst)),这本质上是一个简单的 DSL(领域专用语言)。

4. 实施抽象:函数式核心(Functional Core)

作者引入了 “函数式核心,命令式外壳”(Functional Core, Imperative Shell) 的概念:

  • 函数式核心(determine_actions): 这是一个纯函数,输入是简单的字典,输出是操作列表。它没有任何 side effects(副作用),不涉及真实磁盘。
  • 命令式外壳(sync): 负责收集输入(扫描磁盘)、调用核心逻辑、然后根据返回的指令执行输出(修改磁盘)。

5. 抽象的好处

  1. 极速测试: 现在的逻辑测试变成了简单的“输入字典,断言输出列表”。不再需要创建真实文件,测试运行速度提升百倍,且覆盖各种边缘情况(如重命名、空文件夹等)变得非常简单。
  2. 易于扩展: 如果想增加 --dry-run(只打印不执行)模式,或者改为同步到云端(S3),只需要更换“外壳”部分的 I/O 实现,而核心业务逻辑完全不需要改动。

6. 总结与启发(Heuristics)

在本章末尾,作者总结了寻找抽象的几个准则:

  • 能否用熟悉的 Python 数据结构来表示复杂系统的状态?
  • 能否将“做什么”与“怎么做”分开?
  • 在哪里可以画出一道线(Seam),将业务逻辑与混乱的 I/O 隔离开?

一句话核心思想: 通过将“决策(业务逻辑)”从“执行(I/O 操作)”中剥离,并利用简单的数据结构进行对接,可以极大降低系统的复杂性并提高代码质量。

第四章 我们的首个应用案例:Flask API 与服务层

1. 核心问题:为什么需要服务层?

在这一章之前,代码通常直接在 Flask 视图函数中调用领域模型和存储库。这会导致以下问题:

  • 职责过重:视图函数既要处理 HTTP 请求(解析 JSON、处理状态码),又要处理业务逻辑(调用领域模型),还要处理持久化(提交数据库)。
  • 难以复用:如果你想通过 CLI(命令行)或定时任务调用同样的逻辑,必须重复编写这些代码。
  • 测试困难:针对视图函数的测试(端到端测试)通常运行缓慢,且依赖完整的 Web 环境。

2. 服务层的作用 (The Service Layer's Job)

服务层(Service Layer)被定义为协调层编排层。它的主要职责包括:

  • 从存储库中获取数据
  • 验证领域模型的状态
  • 调用领域模型中的方法执行业务逻辑
  • 保存结果(通过提交 Unit of Work 或 Repository)。

口诀: 服务层负责“做什么”(编排),领域模型负责“怎么做”(具体业务规则)。

3. 关键实现细节

  • 输入是原始类型 (Primitives):服务层函数的参数通常是简单的 Python 类型(如 str, int, dict),而不是领域对象。这样做是为了完全隔离外部层与领域层。
  • 依赖倒置 (DIP):服务层依赖于存储库的抽象(如 AbstractRepository),而不是具体的数据库实现。
  • 异常处理:将特定的领域异常(如 OutOfStock)在服务层捕获,或者让服务层根据领域反馈抛出更高级别的应用异常。

4. 测试策略的转变:High Gear vs Low Gear

这是本章最著名的观点之一,关于如何更高效地进行单元测试:

  • Low Gear (低速档/领域测试):直接对领域模型进行细粒度的测试。当业务规则复杂时,这非常有用。
  • High Gear (高速档/服务层测试):编写针对服务层接口的测试,并使用 Fake Repository(伪存储库)。
  • 优点:这类测试覆盖了从 API 到持久化的整个流程(Edge-to-Edge),且由于不触达真实数据库,运行速度极快。
  • 重构利器:当你修改领域模型的内部结构时,只要服务层的接口没变,服务层测试就不需要改动。这解决了“测试代码比业务代码还难维护”的问题。

5. 解耦的好处

通过引入服务层,你的架构变得更加清晰:

  1. Flask 变薄了:它现在只负责解析请求并调用服务层函数,成了真正的“适配器”。
  2. 多入口支持:你可以轻松地为同一个业务逻辑添加 CLI 接口、消息队列消费者或测试驱动程序。
  3. 领域模型独立性:领域模型可以专注于复杂的业务规则,而不必担心数据库事务或 HTTP 协议。

6. 该章节的不足(后续章节会解决)

在第四章结束时,作者也指出了一些现有的痛点:

  • 事务管理仍然很乱:服务层代码中仍然充斥着 session.commit() 这种属于基础设施的代码。
  • 对 ORM 的依赖:虽然有了 Service Layer,但它往往还和特定的数据库会话对象耦合在一起。
  • 注:这些问题会在第五章中得到完美解决。

总结

第四章的核心思想是:通过增加一个抽象的“服务层”,将应用驱动程序与核心业务逻辑分开,并以此为基础建立高效的“边到边”测试体系,从而使代码更具可测试性和可维护性。

第五章 高档位与低档位 TDD

1. 核心比喻:高档位 vs 低档位 (High Gear vs Low Gear)

作者借用自行车换挡来描述 TDD 的两种节奏:

  • 低档位 (Low Gear):当你开始新功能、遇到棘手逻辑或需要深入探索领域模型时,测试直接针对**领域对象(Domain Model)**编写。这能提供最细致的设计反馈(Design Feedback),像是在爬坡。
  • 高档位 (High Gear):当领域模型趋于稳定,或在进行日常开发/重构时,测试应更多地针对**服务层(Service Layer)**编写。这样可以提高开发效率,且重构内部模型时不需要大规模修改测试。

2. 测试金字塔的演进

作者展示了引入服务层后,测试分布的变化:

  • 端到端测试 (E2E Tests):最少,只验证主流程和系统集成。
  • 集成测试 (Integration Tests):负责验证数据库、ORM 等外部依赖。
  • 单元测试 (Unit Tests):数量最多,但重点从“测试领域模型细节”转向了**“测试服务层 API”**。

3. 为什么要将测试移至服务层?

直接针对领域模型写太多测试会导致过度耦合

  • 问题:如果你修改了领域模型的某个内部属性名,可能会导致几十个细粒度的领域层单元测试失败,即使业务逻辑没变。
  • 解决建议:将大部分单元测试提升到服务层水平。服务层提供了一个相对稳定的 API,只要输入输出(业务意图)不变,内部领域模型如何重构,测试都能通过。

4. 实现完全解耦的关键点

为了让服务层测试真正起到保护作用且不依赖具体实现,本章提出了几个技巧:

  • 参数原始化 (Use Primitives)

  • 服务层函数不应接收领域对象(如 OrderLine),而应接收原始类型(如 orderid: str, sku: str, qty: int)。

  • 这样测试调用服务时,就不需要先手动实例化复杂的领域对象。

  • 完善服务层功能

  • 如果在测试服务层时,还需要手动用数据库/仓库去造数据(例如手动 repo.add(Batch(...))),说明服务层可能缺失了某些功能。

  • 建议:增加类似 add_batch 这样的服务函数。这样测试就可以完全通过调用服务 API 来准备数据和执行动作,实现测试与领域模型的物理隔离。

5. 测试编写准则总结

  • 每个功能一个 E2E 测试:证明链路是通的,各组件已正确“胶合”。
  • 大部分测试针对服务层:这是处理业务逻辑分支、边缘情况的最佳位置,通过 Fake 对象(如 FakeRepository)保证速度。
  • 少量的领域模型测试:保留那些对核心复杂逻辑有高度反馈作用的测试。如果服务层测试已覆盖,可以毫不犹豫地删掉重复的低层测试。
  • 错误处理也是功能:除了每个功能的 Happy Path,至少应有一个 E2E 测试验证错误流,而详细的错误分支应由服务层单元测试覆盖。

总结

这一章强调了测试不是越多越好,而是要测在“接口处”。通过将服务层作为系统的主要测试入口,并配合使用原始类型参数,开发者可以获得一个既敏捷又稳固的架构,使得未来的重构变得更加容易。

第六章:工作单元

1. 核心定义与目的

Unit of Work (UoW) 是对“原子操作”概念的抽象。

  • 仓储模式 抽象了数据的持久化存储(如何读写数据)。
  • UoW 模式 抽象了事务的一致性(如何确保一组操作要么全部成功,要么全部失败)。
  • 最终目标: 完全解耦服务层(Service Layer)与底层数据库逻辑,使服务层不再直接依赖数据库会话(如 SQLAlchemy Session)。

2. 为什么需要 UoW?

在没有 UoW 之前,API 层往往需要同时处理多个组件(Session、Repository、Service),导致逻辑散乱。引入 UoW 后:

  • 单一入口: UoW 作为访问持久化层的唯一入口。
  • 原子性保证: 确保在操作过程中如果发生错误,能够自动回滚,避免数据处于“半完成”的中间状态。
  • 管理 Repository: UoW 负责实例化并持有 Repository,确保它们使用同一个数据库上下文。

3. 在 Python 中的实现:上下文管理器(Context Manager)

该博客推崇使用 Python 的 with 语句来实现 UoW,这是一种非常符合 Python 习惯(Pythonic)的做法:

  • __enter__ 初始化数据库 Session,并准备好 Repositories。
  • __exit__ 处理退出逻辑。如果代码正常执行则关闭 Session;如果中间抛出异常,则自动执行 rollback()
  • commit() 只有显式调用此方法时,数据才会被真正持久化。作者认为“默认回滚、显式提交”比“隐式自动提交”更安全。

4. 代码结构示例

  • 抽象接口 (AbstractUnitOfWork):定义了 commit()rollback() 方法和 batches (Repository) 属性。
  • 具体实现 (SqlAlchemyUnitOfWork):包装了 SQLAlchemy 的 Session
  • 服务层调用示例:
def allocate(orderid, sku, qty, uow):
    with uow:
        batches = uow.batches.list()
        # ... 执行业务逻辑 ...
        uow.commit()  # 显式提交

5. 测试优势:不模拟不属于你的东西(Don't Mock What You Don't Own)

这是本章的一个重要哲学点:

  • 避免直接 Mock SQLAlchemy: 第三方库的 API 极其复杂,直接 Mock 会导致测试代码脆弱。
  • Mock UoW: 通过创建一个 FakeUnitOfWork(内存中的简单类),你可以非常轻松地编写不依赖数据库的单元测试,同时保证测试的是你自己定义的简单接口。

6. 模式总结与权衡

优点 缺点
原子性: 保证操作的完整性。 额外抽象: 增加了一层代码复杂度。
简化 API: 服务层只需要关注业务逻辑。 ORM 已自带: 如 SQLAlchemy Session 本身就实现了 UoW 模式,重复封装可能被视为多此一举。
易于测试: 方便替换为内存伪造对象。

核心结论

UoW 模式通过将事务管理Repository 持有封装在一个上下文管理器中,使得系统在面对异常时“默认安全”,并且极大地提高了业务逻辑的可测试性和纯净度。它是构建整洁架构(Clean Architecture)中连接服务层与持久化层的最后一块拼图。

第七章:聚合与一致性边界

1. 核心问题:不变性(Invariants)与并发

  • 不变性(Invariants): 指的是无论发生什么操作,业务规则中必须永远保持为“真”的条件。例如:库存不能为负数、已分配的数量不能超过批次总量。
  • 挑战: 在多用户并发环境下,两个用户可能同时尝试操作同一批数据(如同时给同一个订单分配库存)。如果每个请求都独立通过验证并写入,可能会打破“不变性”,导致数据损坏。

2. 什么是聚合(Aggregate)?

聚合是一个边界,它将一组相关的实体(Entities)和值对象(Value Objects)视为一个单一的原子单位。

  • 单一入口: 聚合只能通过一个被称为“聚合根”(Aggregate Root)的实体进行访问和修改。
  • 强制约束: 聚合根负责维护其内部所有对象的一致性规则(不变性)。外部代码不能直接修改聚合内部的对象,必须调用聚合根的方法。
  • 示例: 在书中,Product(产品)被选为聚合根。它包含多个 Batch(批次)对象。由于分配逻辑(哪些批次有货、怎么分配)依赖于对所有批次的整体评估,因此 Product 负责处理所有分配请求。

3. 选择聚合的原则

  • 一致性边界 = 事务边界: 聚合的大小决定了数据库事务的范围。一个聚合内的所有更改应该在同一个数据库事务中完成。
  • 不要过度设计: 聚合过大(包含太多对象)会导致性能下降和并发冲突;聚合过小则可能无法维护跨对象的一致性规则。
  • 一条法则: 一个聚合对应一个存储库(Repository)。你不再为内部的子对象(如 Batch)创建 Repository,而是通过 ProductRepository 加载整个 Product 聚合。

4. 解决并发:乐观锁与版本号

为了防止并发冲突,本章讨论了两种主要策略:

  • 悲观锁(Pessimistic Locking): 使用数据库的 SELECT FOR UPDATE。这会锁定行,直到事务结束。虽然安全,但会降低并发性能。
  • 乐观并发控制(Optimistic Concurrency Control): * 给聚合增加一个 版本号(Version Number)
  • 每次更新时,检查数据库中的版本号是否与加载时一致。
  • 如果版本号已变,说明有其他事务修改了数据,此时抛出异常并重试。这种方法在大多数情况下性能更好。

5. 架构上的变化

  • Repository 模式的调整: Repository 现在负责加载和保存整个聚合。
  • UoW (Unit of Work) 的配合: UoW 确保在业务处理结束时,对聚合的所有更改都能以原子的方式提交。

总结

聚合模式的精髓在于简化系统的状态管理。通过定义清晰的边界,程序员只需要关注“在这个边界内,业务规则是否被破坏”,而不需要担心整个庞大数据库的一致性。

第八章:事件与消息总线

1. 核心问题:单一职责原则(SRP)的挑战

作者通过一个“库存不足时发送邮件通知采购团队”的例子,展示了如果直接将辅助功能耦合在核心代码中会带来的问题:

  • 放在控制器(Flask App)里:导致 HTTP 层变得臃肿,难以进行单元测试。
  • 放在模型(Model)里:破坏了领域模型的纯粹性,使模型依赖于基础设施(如邮件服务)。
  • 放在服务层(Service Layer)里:导致服务函数(如 allocate)不仅要处理业务,还要处理通知逻辑,违反了 SRP(函数名变成了 allocate_and_send_mail)。
  • 经验法则:如果你在描述函数功能时不得不使用"然后"或"而且"这类词语,那很可能已经违反了 SRP。

2. 核心模式一:领域事件 (Domain Events)

领域事件是描述领域中发生过的事实的简单数据类(Dataclasses)。

  • 特性:它们是纯数据结构,没有行为。
  • 命名:使用领域语言(Ubiquitous Language),通常用过去时态,例如 OutOfStock
  • 触发方式:由聚合(Aggregate)记录事件。模型内部维护一个 self.events 列表,当特定业务规则被触发时,将事件对象加入列表。

3. 核心模式二:消息总线 (Message Bus)

消息总线是一个简单的发布-订阅系统,负责将事件路由到对应的处理程序(Handlers)。

  • 映射关系:它通常是一个字典,将“事件类型”映射到“处理程序列表”。
  • 作用:它不关心事件的具体含义,只负责在看到事件时调用相应的函数。

4. 架构演进的三种实现方案

文章探讨了如何将模型产生的事件传递给消息总线:

  • 方案 A:服务层显式传递(最简单) 服务层在执行完业务逻辑并提交事务(UoW commit)后,显式地从模型中取出事件并交给消息总线处理。

    • 缺点:每个服务函数都要手动写一段处理事件的代码,比较繁琐。
  • 方案 B:服务层直接触发事件 服务层根据模型返回的结果直接创建并发布事件。

    • 缺点:将本该属于领域层的逻辑(判断何时该触发事件)泄露到了服务层。
  • 方案 C:Unit of Work (UoW) 自动处理(推荐) 这是最优雅的方案。修改 UoW 模式,使其在 commit() 成功后,自动收集所有涉及的聚合(Aggregates)中的事件,并将其推送到消息总线。

    • 优点:服务层代码保持极简,不需要意识到事件的存在。

5. 事件驱动架构的价值

  • 解耦:主流程(分配库存)与副作用(发送邮件)完全解耦。如果未来通知方式从邮件改为短信,只需修改消息总线的配置,无需改动核心业务代码。
  • 处理跨聚合的一致性:当一个操作需要改变多个聚合时,可以通过事件实现“最终一致性”,避免长时间跨表锁定的事务。
  • 测试友好:可以轻松编写单元测试来断言“当执行 X 操作时,模型是否产生了 Y 事件”,而不需要真的去 mock 邮件服务器。

总结

本章的核心思想是将**“编排(Orchestration)”转变为“编舞(Choreography)”**:不再是由一个中心化函数下达所有指令,而是让各个组件通过“事件”这一信号,自发地完成各自的职责。这为后续章节讨论微服务集成和 CQRS 奠定了架构基础。

第 9 章 深入探索消息总线

1. 核心转变:从“服务层”到“消息处理器”

  • 之前(第8章):API 显式调用服务层的函数(如 services.allocate),而消息总线和事件仅作为处理副作用(如发送邮件通知)的附加组件。
  • 之后(第9章):一切(无论是外部 API 请求还是内部触发的副作用)都被重新抽象为事件(Events)。API 不再直接调用服务函数,而是将事件丢给消息总线,由消息总线分发给对应的事件处理器(Event Handlers)

2. 引入新业务需求:引出架构变革

为了展示这种模式的威力,作者引入了一个复杂的现实世界需求:修改批次数量(Batch Quantity Changed)

  • 当系统中某批次货物的数量被调减,且调减后的数量少于已经分配出去的数量时,必须取消(Deallocate)受影响订单的分配。
  • 随后,这些被取消的订单需要重新进入等待分配(AllocationRequired)的队列。

如果用传统方式,这个逻辑会混杂各种数据库事务和复杂的逻辑链。但如果通过消息总线:

  1. BatchQuantityChanged 事件触发调整数量的 Handler。
  2. 数量不足时,Handler 内部修改状态并触发 AllocationRequired 事件。
  3. AllocationRequired 事件被重新投入消息总线,自动复用原有的订单分配逻辑,完全无需重写分配核心代码。

3. 重构的关键步骤

步骤 A:将服务函数重构为消息处理器(Handlers)

  • 将原本分散的原始参数(如 ref: str, sku: str, qty: int)封装为具体的事件类(如 BatchCreatedAllocationRequired)。
  • services.py 重命名为 handlers.py
  • 所有 Handler 函数的签名保持一致:统一接收 eventuow(工作单元)两个参数。

关于解耦的思考(避免原始类型迷恋 - Primitive Obsession): 虽然把参数封装为事件类让外部与事件耦合了,但由于事件属于领域模型的一部分,且其在业务中相对稳定,这种耦合是健康的。同时,它提供了一个统一的系统入口,并且非常适合做输入参数校验。

步骤 B:消息总线接管工作单元(UoW)的事件收集

为了消除消息总线和 UoW 之间的循环依赖,作者将职责调整为单向依赖

  • 之前:UoW 在 commit 时,自己主动调用 messagebus.handle() 发布事件。
  • 之后:消息总线(Message Bus)采用“拉取(Pull)”模式。消息总线维护一个事件队列(Queue),在执行完一个 Handler 后,主动通过 uow.collect_new_events() 从 UoW 中把新产生的事件拉取出来并追加到队列中。
# 改造后的消息总线核心逻辑
def handle(event: events.Event, uow: unit_of_work.AbstractUnitOfWork):
    queue = [event]
    while queue:
        event = queue.pop(0)
        for handler in HANDLERS[type(event)]:
            handler(event, uow=uow)
            queue.extend(uow.collect_new_events())  # 从UoW中拉取新事件

步骤 C:测试全面事件化

由于系统入口变成了消息总线,单元测试和集成测试也随之改变。测试不再调用 services.xxx(),而是直接构造事件并调用 messagebus.handle(event, uow),然后断言最终的系统状态或 UoW 的提交状态。

4. 权衡:临时妥协(Ugly Hack)

在全面事件化的转换过程中,作者承认遇到了一个痛点:Web API 需要同步返回结果(例如分配订单后,API 需要返回分配到的批次 ID)。 但在纯粹的事件驱动架构中,消息总线通常是“发后即忘(Fire-and-Forget)”的。为此,本章引入了一个权衡性的“丑陋过渡方案”:让 messagebus.handle() 返回一个包含 Handler 执行结果的列表。(注:这将在后面的章节中通过 CQRS 和命令/事件分离来解决)


5. 架构的优缺点对比(Trade-offs)

优点 (Pros) 缺点 (Cons)
高度复用与单一职责:Handler 和 Service 合二为一,系统的每一部分都专注于处理一类特定的消息。 Web 层的不可预测性:对于 Web 请求而言,把一切交给总线会让流程稍微变得不可控,无法一眼看清请求何时完全结束。
系统的输入结构化:所有进入系统的请求都变成了清晰的数据结构(Event 实体),便于维护。 数据结构冗余:领域模型、事件模型之间可能存在大量的字段重复,字段变更时的维护成本会增加。
系统复杂度的线性增长:应对极其复杂的业务链(如“改数量->取消分配->开新事务重新分配->发外部通知”)时,架构没有变复杂,只需要增加新的事件和 Handler 即可。

总结

第 9 章的核心思想是:通过将整个应用改造为由消息总线驱动的“消息处理器”,实现了业务逻辑的极致解耦。当复杂的业务变化来临时,我们不需要去重构复杂的代码调用链路,而只需要像乐高积木一样,通过定义新的事件和处理器,就能让新旧业务完美串联并高效运行。

第10章 命令与命令处理程序

1. 命令(Commands)与事件(Events)的区别

虽然命令和事件都是由简单数据结构(如 Python 的 dataclass)表示的消息,但它们传达的意图和处理规则完全不同:

特性 事件 (Events) 命令 (Commands)
命名语态 过去时(如 BatchCreated, OrderAllocated 祈使句/动词短语(如 CreateBatch, Allocate
意图表达 捕获已经发生的事实(事实) 表达意图,期望系统去做某事(意图)
接收方 广播给所有感兴趣的监听者(1 对多) 发送给一个特定的接收方/处理器(1 对 1)
错误处理 独立失败,不应该中断其他处理流 noisy 失败(显式抛出异常),必须通知发送者

在代码层面上,原有的事件(如 AllocationRequired)被重构为了命令(如 commands.Allocate)。


2. 消息总线(Message Bus)的分流处理

引入命令后,消息总线(messagebus.handle)需要根据消息的类型(Event 还是 Command)分流到不同的辅助函数中:

2.1 事件处理器(handle_event

  • 规则:一个事件可以触发多个处理器。
  • 错误处理:使用 try-except 捕获所有异常并记录日志,但使用 continue 允许流程继续。一个事件处理失败不应该影响其他事件的执行,以实现系统的最终一致性。

2.2 命令处理器(handle_command

  • 规则:一个命令有且仅有一个对应的处理器。
  • 错误处理:采用“快速失败”机制(Fail Fast)。如果执行中发生异常,处理器会记录日志并重新抛出异常(raise,让错误冒泡到最外层(如 API 层),从而显式通知用户。

3. 深入探讨:事件、命令与错误处理

许多开发者会担心:“如果事件处理失败,导致系统处于不一致的状态怎么办?”

作者通过聚合(Aggregates)工作单元(Unit of Work, UoW)的视角给出了答案:

  • 核心原则:一个命令应当只修改单个聚合。聚合就是系统的“强一致性边界”。在这个边界内,UoW 确保了要么全部成功,要么全部失败,因此核心业务逻辑不会处于不一致的状态。
  • 最终一致性:其他外围的记账、清理或通知工作(例如发送邮件)应当通过事件来驱动。我们不应该为了让事件处理器成功,而强求它与命令处在同一个事务中。

案例分析(VIP 客户系统):

假设一个电商系统规定:当客户下第三单时,将其升级为 VIP 并发送祝贺邮件。

  • 正确做法:命令处理器创建订单并提交事务(保证钱能收进来)。随后抛出 OrderCreated 事件,由事件处理器去更新历史记录并触发 CustomerBecameVIP 事件,再由另一个处理器发邮件。
  • 为什么解耦?:如果邮件服务器挂了,或者 VIP 计分系统有 Bug,我们不应该阻止用户下单付钱。通过将事务边界与业务步骤对齐,让不同的关注点独立失败,能极大提高系统的整体可靠性。

4. 错误恢复与同步重试

既然允许事件独立失败,系统就需要具备健壮的恢复机制:

  1. 结构化日志:由于命令和事件都是 dataclass,其打印出的字符串包含完整数据,发生错误时可以轻松将其复制到 Python Shell 中进行复现或手动重放。
  2. 自动重试(Tenacity 库):针对网络抖动、数据库死锁等瞬态故障(Transient Failures),可以在消息总线的 handle_event 中引入重试机制。作者展示了如何使用 tenacity 库实现指数级退避重试(Exponential Backoff Retry)(例如最多重试3次)。

因为每个处理器都运行在独立的 UoW 中,每次重试都会从干净、一致的数据库状态开始,不会产生中间状态污染。


5. 优缺点权衡(Wrap-Up)

将系统拆分为命令与事件带来了以下利弊:

  • 优点 (Pros)

  • 清晰地区分了“哪些操作必须立刻成功(命令)”,以及“哪些操作可以稍后收拾(事件)”。

  • 代码语义更明确(例如 CreateBatchBatchCreated 更符合用户发起请求时的真实意图)。

  • 缺点 (Cons)

  • 概念上的微小差异可能会引发团队内部关于“这到底是命令还是事件”的无休止争论(Bikeshedding)。

  • 显式地接受了系统可能局部失败的现实,系统逻辑变得更加分布式,增加了理解成本,并对监控和日志提出了更高的要求。

第11章 事件驱动架构:利用事件集成微服务

一、 核心背景与演进

在前几章中,应用内部已经实现了基于“消息总线(Message Bus)”的事件和命令驱动模式。但这些都发生在单个微服务内部。 本章的核心议题是:当外部世界发生变化(如供应商修改了到货数量),或者我们需要通知外部世界(如订单已分配,通知仓库发货)时,应该如何处理?

通过引入外部消息代理(Message Broker),让应用在外部也变成一个“消息处理器(Message Processor)”:

  1. 输入:从外部消息队列(如 Redis Pub/Sub)接收事件。
  2. 处理:内部消息总线将事件分发给对应的处理器(Handlers)并转换成领域操作。
  3. 输出:将处理结果以事件形式再次发布到外部消息队列。

二、 坏架构的反面教材:分布式大泥球(Distributed Ball of Mud)

作者首先警告了一种常见的微服务错误设计倾向:基于名词(Noun-based)划分服务。

1. 基于名词的设计与 RPC

例如,系统中有名词:Batches(批次)Orders(订单)Warehouse(仓库)。幼稚的做法是为每个名词建一个服务,并暴露标准的 CRUD HTTP API。

  • 正向流程(Happy Path):用户下单 $\rightarrow$ Orders 服务调用 Batches 服务预留库存 $\rightarrow$ Batches 调用 Warehouse 发货。
  • 反向流程(异常处理):仓库发现货物损坏。Warehouse 服务调用 Batches 扣减库存 $\rightarrow$ Batches 触发重新分配 $\rightarrow$ Batches 调用 Orders 修改订单状态。

2. 由此带来的问题:

  • 循环依赖Orders 依赖 Batches,而 Batches 反过来又依赖 Orders。随着业务增加,服务间调用会变成一团乱麻。
  • 时间耦合(Temporal Coupling):系统要求所有关联的服务在同一时间必须全部可用。如果 Batches 挂了,整个下单接口直接报错,错误级联放大,降低了整体可用性。

三、 理论升华:共生性(Connascence)

作者引入了 Connascence(共生性 / 耦合度的一种度量) 的概念来解释为什么 RPC 模式很糟糕:

  • 执行共生性(Connascence of Execution):多个组件必须知道正确的工作顺序才能成功。
  • 时间共生性(Connascence of Timing):多个操作必须紧接着发生才能成功(如同步 HTTP 调用)。
  • 名称共生性(Connascence of Name)这是我们追求的弱耦合。 多个组件只需要在“事件名称”和“字段名称”上达成一致即可。

解决方案:基于动词(Verbs)思考,利用异步消息实现时间解耦。 将“订单服务”和“批次服务”转变为“订购流程”和“分配流程”。服务之间不进行同步调用,而是通过发布领域事件,允许系统达到最终一致性(Eventual Consistency)


四、 架构落地:使用 Redis Pub/Sub 作为消息代理

为了演示外部集成,本书选用了轻量且通用的 Redis 发布/订阅(Pub/Sub)机制作为消息代理(生产环境也常用 Kafka 或 RabbitMQ)。

1. 整体序列流向

当外部更改了批次数量:

  1. Redis 收到外部事件 BatchQuantityChanged
  2. 应用的 外部事件消费端(Event Consumer) 监听到该消息,将其塞入内部消息总线(Message Bus)
  3. 内部总线触发对应的领域逻辑,如果受影响,会引发重新分配,并产生内部事件。
  4. 内部的 PublishHandler 捕获到这些需要外发的事件(如 Allocated),再将其发布回 Redis 的指定通道(如 line_allocated)。

2. 测试驱动开发(TDD)下的端到端测试

由于引入了异步和 Redis,测试需要做出调整。不能再像 HTTP 那样直接等待 Response 结果,而是需要:

  1. 向 API 添加测试数据。
  2. 创建一个 Redis 订阅者,监听 line_allocated 通道。
  3. 向 Redis 发送 change_batch_quantity 模拟外部事件。
  4. 异步轮询/重试(Retrying):在一定超时时间内,检查是否在订阅通道里收到了预期的重分配事件。

五、 关键代码实现架构

  1. 外部消费者(如 redis_eventconsumer.py: 启动一个独立进程/死循环,连接 Redis 订阅通道。收到消息后反序列化为领域层能识别的 commands.ChangeBatchQuantity,然后调用内部 messagebus.handle(cmd, uow)
  2. 外部发布者(Event Handler): 在内部消息总线中注册一个通用的事件处理器。当内部领域模型抛出需要外发的事件(如 events.Allocated)时,该处理器负责将事件转化为 JSON,通过 redis_client.publish() 发送出去。

六、 模式总结与权衡(Trade-offs)

利用事件驱动集成微服务有得有失:

优点(Pros):

  • 彻底避免了分布式大泥球,系统实现了解耦。
  • 服务之间互相独立,更容易单独修改、扩展或增加新服务(例如增加一个邮件通知服务,只需订阅现有的 Redis 事件即可,无需改动核心业务代码)。
  • 极大地增强了系统的弹性和容错性。

缺点(Cons):

  • 信息流变得隐蔽:由于全是异步事件触发,无法通过阅读单段代码直观看到整个业务的全貌(Martin Fowler 曾指出这会导致调试和修改变难)。
  • 必须处理最终一致性:业务人员和技术人员都需要接受数据不是实时同步的事实。
  • 引入分布式系统的硬核问题:需要额外考虑消息丢失、消息重复(需要保证幂等性)、消息乱序(Order)等网络及可靠性问题。

第12章 命令-查询职责分离(CQRS)

一、 核心背景:为什么需要 CQRS?

在前面的章节中,作者带我们构建了一个高度复杂的领域模型(Domain Model),引入了聚合(Aggregates)、仓储模式(Repository)和工作单元(Unit of Work, UoW)等概念。

  • 领域模型是为了“写入”而生的:所有的复杂性(如一致性边界、业务规则校验、领域事件通知)都是为了确保系统在改变状态(Write)时数据是正确且合规的。
  • 读取面临不同的业务特性:作者以 MADE.com 的家具电商系统为例:
  • 高并发差异:高峰期一小时可能只有 100 个下单写入请求,但每秒钟会有 100 次商品详情页的读取请求。
  • 一致性要求不同:分配库存(写入)必须严格一致,否则会超卖;但用户浏览商品(读取)时,数据即便延迟了几秒钟,用户也几乎无法察觉。

结论:在读取数据时,之前精心设计的领域模型、服务层和 UoW 反而成了“包袱”(Bloat)。读取操作不需要那些业务规则校验,直接、快速地拿到数据才是关键。


二、 最终一致性(Eventual Consistency)的辩证

很多开发者对“读取非强一致性数据”感到焦虑。作者用两个现实场景打破了这种思维定势:

  1. 数据的时效性本质:当网页渲染完成的瞬间,它所展示的数据就已经成了“历史”。如果用户看了一眼商品去倒杯咖啡,回来再下单时可能已经没货了。因此,无论系统多么实时,在最终执行写入(如下单、分配)时,都必须再次校验当前状态
  2. 现实物理世界的不可靠性:即便系统做到了绝对的强一致性,仓库管理员在搬运家具时也可能不小心把货砸碎。此时系统同样要通过业务流程(如退款或延迟发货)来处理异常。

既然数据不一致无法绝对避免,那么在读取端用“最终一致性”换取“高性能”和“高可扩展性”就是完全合理且划算的。


三、 从 CQS 到 CQRS:代码层面的演进

1. CQS(命令查询分离)在 API 层的体现

传统的 API 设计中,一个 POST /allocate 接口在完成库存分配(写入)后,会直接返回分配到的 Batch ID(读取)。这违反了 CQS 原则(一个函数要么改变状态,要么回答问题,不能同时做)。

作者首先在 API 层面作了优化,改为 Post/Redirect/Get 的逻辑:

  • POST /allocate:仅返回 202 Accepted,代表系统接受了写入指令。
  • GET /allocations/<orderid>:提供一个专用的只读端点来查询分配结果。

2. “不环保”但高效的只读视图(Views)

为了实现读取端点的逻辑,Harry(书中的质疑者角色)倾向于继续复用现有的 Repository。但作者展示了直接使用原生 SQL(Raw SQL)的写法:

# src/allocation/views.py
def allocations(orderid: str, uow):
    with uow:
        results = uow.session.execute(
            """
            SELECT ol.sku, b.reference 
            FROM allocations AS a
            JOIN batches AS b ON a.batch_id = b.id
            JOIN order_lines AS ol ON a.orderline_id = ol.id
            WHERE ol.orderid = :orderid
            """,
            dict(orderid=orderid),
        )
        return [{"sku": sku, "batchref": batchref} for sku, batchref in results]

3. 为什么不用 Repository 和 ORM 做查询?

作者对比了另外两种似乎更“优雅”的选择:

  • 方案 A:在 Repository 上加个查询方法。 这会导致 Repository 越来越臃肿,而且为了拼凑出前端需要的数据,可能会被迫在领域模型对象中加入本不需要的查询字段。
  • 方案 B:使用 ORM(如 SQLAlchemy 表达式)。 编写复杂的 ORM 多表联查同样不容易,且极易触发 SELECT N+1 性能陷阱(例如循环读取聚合根下的子关联对象导致产生大量 SQL 查询)。

CQRS 的突破口:既然是只读,就应该抛弃领域模型,抛弃 ORM 对象的包装。直接执行原生 SQL 并将数据库行直接映射为 Python 字典(Dict),性能最高,且最灵活。


四、 测试 CQRS 视图

在 CQRS 架构中,测试只读视图推荐使用集成测试(Integration Test)

  • 测试策略:在 Setup 阶段,通过系统的公共入口(Message Bus 触发 Command)来写入数据,然后调用只读 View 验证返回的字典是否正确。
  • 好处:这确保了测试与底层的数据库表结构解耦。只要系统的写入逻辑和读取逻辑在业务上能对齐,测试就能通过。

五、 进阶:物化视图与完全隔离(Read-Side Tables)

随着业务继续发展,即便是直接查主库的原生 SQL 也会遇到瓶颈(比如大量复杂的 JOIN 严重拖慢数据库)。

这一章的终极演进是:为读取端创建完全独立的表(Read-Side Tables / Materialized Views)

  • 设计结构:这张表是专门为前端 UI 展示量身定制的。比如前端需要什么 JSON 格式,这张表就存成什么样,尽量做到 SELECT * FROM my_view WHERE id = ... 就能单表返回。
  • 数据同步机制
  1. 写入端接收到 Command,修改了主库的聚合状态。
  2. 聚合根(Aggregate)触发并抛出一个领域事件(Domain Event)(例如 Allocated)。
  3. 专用的事件监听器(Event Handler / Projectionist)捕获该事件。
  4. 监听器直接更新读取端的专用表。

通过这种方式,读取操作彻底不查主表,从而实现了读写在数据存储层的彻底隔离。


总结:CQRS 的优缺点与适用场景

维度 优势 (Pros) 劣势 (Cons)
读性能 极高。可针对查询定制数据结构,无 ORM 开销,极易做缓存或单表无 JOIN 查询。 产生了数据延迟(最终一致性),需要前端配合处理(如乐观UI)。
代码耦合 读写职责分离,领域模型只需专注于复杂的业务写入规则,不再受查询需求绑架。 架构复杂度显著增加。维护读写两套模型和数据同步管道需要更多工作量。

作者的最终建议: 不一定非要一步到位做到“独立的只读数据库”。可以根据复杂度采取渐进式演进:

  1. 初级 CQS:代码中分出 views.py,写原生 SQL 绕过领域模型。
  2. 高级 CQRS:当性能遭遇瓶颈时,再通过监听内部事件来驱动更新专门的只读表或物化视图。

第13章 依赖注入(及启动流程)

1. 痛点:隐式依赖与显式依赖的权衡 (Implicit vs. Explicit Dependencies)

在 Python 社区中,许多人对依赖注入持怀疑态度。传统的 Python 做法是通过 import 隐式地 引入外部依赖(例如邮件发送服务、Redis 客户端),并在测试时通过 mock.patch 进行猴子补丁(Monkeypatching)。

隐式依赖(传统 Python 做法)的弊端:

  • 重构困难:如果把 import email 改为 from email import send_mail,可能需要修改数十个相关的测试 Mock。测试代码与具体的实现细节紧密耦合。
  • 样板代码(Boilerplate)泛滥:当越来越多测试需要拦截副作用(如发送真实邮件)时,测试代码中会充斥着大量的 with mock.patch(...)

显式依赖(符合依赖倒置原则 DIP)的优势:

  • 函数或类明确声明其需要的依赖(例如,处理函数显示声明它需要一个 uow 或是 send_mail 函数)。
  • 测试时极其方便,只需直接传入一个 Mock 或是 Fake 对象(如 FakeUnitOfWork),不再需要任何 mock.patch
  • 遵循了 Python 之禅:"Explicit is better than implicit"(显式胜于隐式)

2. 引入引导程序 (Bootstrapper / Composition Root)

如果我们将所有依赖(UoW、邮件发送、Redis 广播等)都改为显式声明,那么由谁来负责将这些依赖实例化并注入到各个处理器(Handlers)中呢?

如果让 Flask、Redis 消费者等各个入口点(Entrypoints)各自去处理,会导致大量的重复初始化代码,并且让消息总线(Message Bus)承担了额外的依赖传递职责,违反了单一职责原则 (SRP)

为了解决这个问题,书中引入了 引导程序 (Bootstrap Script),在面向对象语言中也被称为 组合根 (Composition Root)

引导程序的作用:

  1. 声明默认的生产依赖(如真实的数据库、真实的邮件服务),但在调用时允许重写(用于测试)。
  2. 执行应用启动时只需要运行一次的初始化工作(例如 orm.start_mappers() 或配置日志)。
  3. 动态地将所有必要的依赖注入到命令/事件处理器(Handlers)中。
  4. 返回一个完全配置好的核心对象——消息总线(Message Bus)

3. 实现依赖注入的两种手动方式 (Manual DI)

本章并没有推荐复杂的第三方 DI 框架,而是展示了如何在 Python 中用原生语法优雅地进行“手动依赖注入”。

方式 A:基于函数(闭包与偏函数 functools.partial

如果 Handler 是一个纯函数,我们可以利用偏函数将依赖预先绑定进去,生成一个只需要接收消息(Message/Command)的新函数:

import functools


# 原始 Handler 声明了 uow 依赖
def allocate(cmd: commands.Allocate, uow: unit_of_work.AbstractUnitOfWork): ...


# 引导程序通过偏函数注入真实的 uow
allocate_composed = functools.partial(allocate, uow=uow)

# 运行时,Message Bus 只需要调用 allocate_composed(cmd)

方式 B:基于类(实现 __call__ 方法)

如果不喜欢闭包,也可以将 Handler 重构为类,利用构造函数 __init__ 接收依赖,利用 __call__ 使其实例可调用:

class AllocateHandler:
    def __init__(self, uow: unit_of_work.AbstractUnitOfWork):
        self.uow = uow

    def __call__(self, cmd: commands.Allocate):
        # 处理逻辑...

4. 引导脚本的最终实现:动态自省 (Inspection)

为了避免为几十个 Handler 逐一手动编写绑定代码,作者利用 Python 的 inspect 模块实现了一个具有“魔法”但极简的自动化注入函数:

import inspect


def inject_dependencies(handler, dependencies):
    # 检查 Handler 函数或类的签名
    params = inspect.signature(handler).parameters
    # 根据参数名字匹配已有的依赖
    deps = {name: dep for name, dep in dependencies.items() if name in params}
    # 返回一个闭包,运行时自动传入依赖的 kwargs
    return lambda message: handler(message, **deps)

完整的 bootstrap() 函数逻辑:

  1. 初始化 ORM (orm.start_mappers())。
  2. 构建依赖字典集合(包含 uowsend_mailpublish 等)。
  3. 遍历 HANDLERS 映射表,对所有的 Handler 调用 inject_dependencies
  4. 将 Message Bus 从一个静态模块重构为一个类,并将这些注入好依赖的 Handlers 在运行时(Runtime)作为参数传递给 Message Bus 实例。

5. 如何“正确地”构建一个外部适配器

为了给读者更具体的实战感,本章通过一个重构通知服务(Notifications)的例子,展示了如何标准地落地这一套模式:

  1. 定义抽象基类 (ABC):定义 AbstractNotifications,并声明一个抽象方法 send(destination, message)
  2. 编写生产环境的具体实现:编写 EmailNotifications(AbstractNotifications),内部实现真实的 SMTP 发送逻辑。
  3. 在引导程序中声明:在 bootstrap() 的参数默认值中,使用 notifications: AbstractNotifications = EmailNotifications()
  4. 编写测试用的 Fake 实现:编写一个 FakeNotifications,内部用一个字典 defaultdict(list) 来记录发送过的消息,而不发送真实邮件。
  5. 在单元测试中重写:测试时,直接调用 bootstrap.bootstrap(notifications=FakeNotifications()),即可在完全不使用 mock.patch 的情况下,轻松验证 Handler 是否正确触发了通知。

总结

本章是全书架构演进的重要一步。通过引入依赖注入引导程序

  • 我们的各个处理器(Handlers)彻底变成了纯粹的领域逻辑协调者,不再对任何具体的基础设施产生隐式依赖。
  • 测试变得极其干净,告别了繁琐、易碎且与实现细节高度耦合的猴子补丁(Monkeypatching)。
  • 所有的组件组装、依赖声明和第三方库初始化都被收拢到了一个单一的入口——bootstrap.py(组合根),整个应用的结构变得更加清晰和可预测。

尾声

一、 核心痛点与切入心态

许多开发者在读完书后会产生焦虑:“书里的模式很完美,但我面对的是一个庞大、混乱的 Django/SQLAlchemy‘大泥球’(Big Ball of Mud),根本无从下手。”

作者给出了非常务实的建议:

  1. 明确目标(解决什么问题): 不要为了架构而架构。先问自己:系统是太难扩展了?性能无法接受?还是有诡异的 Bug?带着具体问题去重构,才更容易向团队和业务方争取时间。
  2. 捆绑业务需求(Architecture Tax): 推动大规模重构很难,最佳时机是与新的重大功能(Feature)绑定。例如,系统要开拓新市场或上线新产品线,在预估 6 个月的项目中加入 3 周的“架构税”用来清理地基,业务方更容易接受。

二、 逐步拆解旧系统的三大步骤

章节通过一个实际案例(一个外包出去、经手多代开发者的复杂文档协作平台)展示了如何解耦:

步骤 1:分离交织的职责——建立服务层(Service Layer / Command Handlers)

旧系统往往逻辑混乱:Controller、Model、Manager、Helper 里到处都塞满了业务逻辑。

  • 找出系统的“用例”(Use Cases): 根据用户界面或后台定时任务(如 Celery 任务),定义出具有祈使语气动词的函数或类(例如 Apply Billing ChargesCreate Workspace)。
  • 让用例函数负责编排(Orchestration): 每一个用例应当是一个原子的事务单元,负责:开启事务 $\rightarrow$ 获取数据 $\rightarrow$ 验证前置条件 $\rightarrow$ 更新领域模型 $\rightarrow$ 持久化更改
  • 允许早期的代码复制: 提取服务层时,哪怕几个用例间有重复代码也没关系,“Ctrl+C / Ctrl+V”好过让用例之间深度嵌套、互相调用。先把 I/O(发邮件、写文件)和底层数据访问从领域模型中抽离到服务层中。

步骤 2:识别聚合与限界上下文(Aggregates & Bounded Contexts)

旧系统最容易犯的错误是拥有一个高度连接的对象图(Object Graph)。例如,你可以通过 ORM 不断链式点出属性:user.account.workspaces[0].documents.versions[1].owner...。这种设计虽然在 Django/SQLAlchemy 中很方便,但会导致:

  • 极其严重的 SELECT N+1 性能问题
  • 事务边界不清晰,改一个地方导致大范围锁表。
  • 破除直接对象引用,改用 ID 关联: 把大而全的模型拆解为独立的聚合(Aggregates)。聚合之间不再保持强内存引用,而是只记录彼此的 id(例如 Document 不再包含 Workspace 对象,而是只记录 workspace_id: int)。
  • 一个用例只更新一个聚合: 每次事务只通过 Repository 捞出一个聚合,修改它,触发事件,然后结束。如果需要跨聚合的数据流转,引入最终一致性(Event-driven Eventual Consistency)

步骤 3:引入事件驱动与消息总线(Message Bus)

当你把聚合拆开后,原先在一个长方法里连续修改多个对象的做法就行不通了。这时需要开始引入事件:

  • 聚合发生改变后,在内部隐式挂载一个领域事件(Domain Event)。
  • 在服务层/单元工作(UoW)提交时,将事件发布到消息总线(Message Bus)。
  • 编写微小的、职责单一的事件处理器(Event Handlers)来处理后续的连带业务(例如:用户锁定后,异步触发文档归档)。

三、 常见问题解答与技术评审反馈

作者分享了技术评审人员对全书模式的一些尖锐提问:

  1. 必须一蹴而就吗?
  • 不需要。 完全可以只做一点。即使你的底层依然是非常混乱的、与数据库强绑定的 Django ORM,先封装一层服务层(Service Layer)也是巨大的胜利,至少它能帮你梳理出清晰的业务边界。
  1. 提取用例会破坏大量现有代码怎么办?
  • 采用绞杀者植物模式(Strangler Fig Pattern):不要在老代码里修修补补,而是直接在旁边写新的整洁代码,然后把路由或流量逐步切过去。

四、 读者寄语与延伸阅读

章节最后,一位参与试读的资深工程师(Harry)分享了自己的心路历程。他曾因无法在公司系统里完美落地书中的所有概念而感到沮丧,但最终释怀了:“现实不是教科书,不完美是常态。寻找一个小痛点,哪怕用不完美的方式去尝试落地,也是在朝着正确的方向前进。”

作者最后推荐了三本进阶必读书目,供读者继续深化:

  1. 《Clean Architectures in Python》 (Leonardo Giordani):当时极少数用 Python 讲整洁架构的经典。
  2. 《Enterprise Integration Patterns》 (Gregor Hohpe / Bobby Woolf):消息传递与异步事件驱动模式的圣经。
  3. 《Monolith to Microservices》 (Sam Newman):指导如何将单体“大泥球”逐步演进到微服务的实操指南。

总结

本章的精髓在于:完美是优秀的敌人。 从“大泥球”走向整洁架构是一场持久的阵地战,首要任务是用服务层UoW圈出业务边界,通过 ID 关联肢解臃肿的对象图,然后用事件连接各个聚合。

附录E:业务校验逻辑

一、核心观点:校验逻辑的层级划分

作者强调,不存在单一的“校验层”,校验应当根据其职责与性质拆分到系统的不同层级中:

  1. 入口/ API 层校验(Syntactic Validation / 语法与格式校验)
  • 职责:确保传入的数据格式正确(如 JSON 结构有效、必填字段存在、邮箱格式符合规范、类型匹配)。
  • 工具:推荐使用 Pydantic、Marshmallow、Cerberus 或 Schematics 等数据验证库。
  • 定位:阻止“垃圾数据”进入系统内部,保护领域层免受低级格式错误的影响。
  1. 领域层校验(Domain Validation / 业务规则校验)
  • 职责:确保数据符合业务逻辑与领域不变性(Invariants)(例如:订单数量不能超过库存、账户余额不能为负、特定状态下不能执行某种操作)。
  • 定位:属于核心业务逻辑的一部分,必须包含在领域模型(Entity / Aggregate)或领域服务(Domain Service)中。
  1. 数据库/持久化层校验(Database Constraints / 数据库约束)
  • 职责:作为最后的安全防线(例如:唯一性约束 UNIQUE、外键约束)。
  • 定位:处理并发冲突和确保全局一致性(如防止用户名重复)。

二、三种具体的校验策略与模式

附录详细探讨了在 Python 中实现校验的三种常见设计模式:

1. 内联校验 / 异常机制(Validation in Constructors & Raising Exceptions)

  • 做法:在实体(Entity)或值对象(Value Object)的构造函数(__init__)中直接抛出自定义业务异常(如 InvalidOrderError)。
  • 优点:简单直接,强行保证了对象在创建时就必须处于有效状态(Always-Valid Entity)。
  • 缺点:每次只能抛出第一个发现的错误,无法一次性收集并返回多个校验错误。

2. 校验器模式 / 收集错误(Validator Objects / Accumulating Errors)

  • 做法:引入专门的校验对象或方法(如 validate()),将错误收集到一个列表中返回,而不是立即抛出异常。
  • 适用场景:前端表单提交、复杂业务校验需要一次性高亮显示所有不合规字段的情况。

3. 利用类型系统与值对象(Value Objects for Parsing, Not Validating)

  • 做法:借鉴“Parse, don't validate”思想,将原始数据解析为强类型的值对象(例如将 str 解析为 EmailAddressMoney)。
  • 优势:只要对象存在,其类型本身就保证了数据的合法性,避免在代码各处重复进行 is_valid 检查。

三、关键建议与最佳实践

  • 不要重复校验(DRY 原则的权衡):API 层只做格式与类型解析,不要把业务规则下沉或复制到 API 层;核心业务规则只写在领域层。

  • 区分“格式错误”与“业务拒绝”

  • 格式错误(HTTP 400 Bad Request):客户端传参有问题。

  • 业务拒绝(HTTP 422 / 400 或特定错误码):参数格式没问题,但违反了当前系统状态下的业务规则。

  • 保持领域模型的纯粹性:领域层不应依赖外部的 Web 框架或第三方 Validation 库,使用纯 Python 逻辑或原生异常处理即可。

About

《Architecture Patterns with Python》/ Cosmic Python 学习笔记

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages