链接:https://www.cosmicpython.com/book/preface.html
文章首先探讨了为什么软件设计容易出错。
- 软件熵(Software Entropy): 随着时间的推移,初始整洁的代码往往会变得混乱,充满特殊情况(edge cases)和临时的“工具模块(util modules)”。
- 泥潭模式(Big Ball of Mud): 许多系统最终会演变成所有组件高度耦合的状态。改变一个部分往往会引发不可预知的连锁反应,导致维护极其困难。
- 混乱的特征: 业务逻辑与 I/O 操作(如发送邮件、数据库访问)混在一起,API 处理器直接包含领域知识,导致系统难以测试和扩展。
为了对抗这种混乱,作者强调了封装和抽象的重要性:
- 封装行为: 封装不仅仅是隐藏数据,更重要的是通过识别任务并将其分配给明确的对象或函数来“简化行为”。
- 表达力与可维护性: 使用抽象可以使代码更具表达力,也更容易进行单元测试,因为我们可以轻松地替换掉复杂的依赖项。
文章介绍了应对复杂性的经典方案——分层架构:
- UI 层: 网页、API 或命令行。
- 业务逻辑层: 包含业务规则和工作流。
- 数据库层: 负责数据持久化。
- 目标: 通过划分职责和定义调用规则,防止系统演变成“大泥坨”。
这是本书的核心思想之一:
- 定义:
- 高层模块不应依赖低层模块,二者都应依赖抽象。
- 抽象不应依赖细节,细节应依赖抽象。
- 高层 vs 低层: 高层模块是企业真正关心的“核心业务逻辑”(如患者管理、银行交易);低层模块则是“细节”(如数据库、文件系统、网络协议)。
- 重要性: 业务逻辑应该保持稳定,而技术细节(细节)往往更容易变化(如更换数据库或升级框架)。通过 DIP,我们可以保护核心业务逻辑不受基础设施变化的影响。
- Cosmos vs Chaos: 书名中的 "Cosmic"(宇宙)寓意着“秩序”,即通过模式将混乱(Chaos)转变为有序。
- 实战导向: 本书不只是谈论理论,而是通过一个具体的案例研究(订单分配系统),手把手地展示如何将领域驱动设计(DDD)和这些架构模式应用到 Python 项目中。
导言部分为全书奠定了基调:在 Python 变得越来越成熟和“企业级”的今天,开发者需要学习如何通过抽象、分层和依赖倒置等模式来管理业务复杂性,从而构建出更健壮、易测试且易于维护的系统。
- 领域 (Domain):指你试图解决的问题区域(例如:家具零售、物流调度)。
- 模型 (Model):是对现实世界流程的简化抽象,旨在捕捉有用的属性。
- 领域模型:是业务专家脑海中关于业务运作的“心理地图”。在软件开发中,它是我们试图用代码实现的业务逻辑核心。
作者强调了 通用语言 (Ubiquitous Language) 的重要性。
- 不要急于写代码:先与业务专家交谈,统一术语(如:
OrderLine,Batch,Allocation)。 - 编写单元测试作为文档:通过编写测试来描述业务规则。例如:“如果批次(Batch)的可用数量大于订单行(OrderLine)的数量,则可以进行分配”。
这一章重点介绍了两种描述领域对象的模式:
- 定义:由其持有的数据唯一标识的对象。如果没有标识符(如 ID),两个属性相同的对象就是同一个东西。
- 特性:不可变性 (Immutable)。在 Python 中通常使用
dataclasses(设置frozen=True)来实现。 - 示例:
OrderLine(orderid='oref', sku='RED-CHAIR', qty=10)。
- 定义:具有长期标识的对象。即使属性发生变化,它仍然是同一个实体(例如:一个人改了名字,但还是那个人)。
- 特性:通常拥有一个显式的 ID。在 Python 中需要实现
__eq__和__hash__来定义其唯一性。 - 示例:
Batch(ref='batch-001', sku='RED-CHAIR', qty=100)。即使库存变了,batch-001还是原来的那个批次。
- 有些业务逻辑不自然地属于某个特定的实体(比如
Batch或OrderLine)。 - 在这种情况下,可以使用领域服务函数。在 Python 中,这通常是一个位于
model.py模块中的普通函数,用于处理涉及多个对象的复杂逻辑。
这是本章乃至全书最重要的观点:
- 模型不应依赖于外部:领域模型代码应该是“纯粹”的。它不应该知道数据库、SQLAlchemy、Django 或者是 web 框架的存在。
- 为什么要这样做?
- 易于测试:可以在毫秒级运行数千个单元测试,无需数据库。
- 易于修改:业务规则的改变只涉及简单的 Python 代码,而不需要迁移数据库或修改复杂的 ORM 配置。
第一章告诉我们:先写业务逻辑,再考虑存储。 通过 TDD(测试驱动开发)和对业务术语的精准建模,我们可以构建出一个健壮、易测试且与具体技术实现无关的领域核心。
这篇文章是《Architecture Patterns with Python》(又称 Cosmic Python)的第二章,主要探讨了仓储模式(Repository Pattern)。
其核心目标是利用依赖倒置原则(DIP),将业务逻辑(领域模型)与底层基础设施(数据库/持久化)解耦。以下是该章节的详细总结:
在传统的开发模式中,业务代码往往直接依赖 ORM(如 SQLAlchemy 或 Django ORM)。这会导致:
- 模型污染:领域模型类必须继承自 ORM 基类,导致业务代码中充斥着数据库列定义、外键等细节。
- 难以测试:如果业务逻辑直接调用数据库,单元测试就会变慢且难以设置,因为必须运行数据库实例。
- 缺乏灵活性:一旦绑定了特定的 ORM 或数据库,以后想要更换存储方式(如从 SQL 换成 NoSQL 或 CSV)将极其困难。
作者提出,不应该让模型依赖 ORM,而应该反过来。
- 经典映射(Classical Mapping):在 SQLAlchemy 中,不要使用常用的声明式(Declarative)语法,而是定义独立的 Table 对象,并使用
mapper函数将这些表手动映射到纯 Python 类上。 - 结果:领域模型变成了“纯净”的 Python 对象(POPO),它完全不知道数据库的存在。
仓储模式是介于领域模型与数据库之间的一个抽象层。
- 概念抽象:它通过模拟一个内存中的对象集合来隐藏数据访问的复杂性。
- 核心接口:通常只包含简单的方法,如
add(entity)(添加对象)和get(id)(获取对象)。 - 优势:
- 持久化无感:业务逻辑只需要与 Repository 交互,不需要知道数据是存放在 PostgreSQL 还是内存字典里。
- 测试便利性:可以轻松创建一个
FakeRepository(基于内存列表),使单元测试不再依赖真实数据库,运行速度极快。
- 定义抽象基类:定义一个 Python 抽象基类(
AbstractRepository),规定必须实现的接口(如add和get)。 - 实现具体仓储:
SqlAlchemyRepository:使用真实的数据库连接。FakeRepository:仅用于测试,内部使用内存集合存储数据。
- 在业务逻辑中使用:业务函数不再接受数据库
session,而是接受一个AbstractRepository的实例。
作者坦言,虽然这种模式带来了极高的灵活性和可测试性,但也存在成本:
- 工作量增加:需要手动维护映射和仓储类,增加了代码量。
- 复杂度:对于简单的 CRUD(增删改查)应用,这种设计可能过度工程化。
- 适用场景:当业务逻辑非常复杂且需要频繁迭代时,这种架构投资的回报最高。
本章的精髓在于:Repository 模式让你的业务逻辑专注于“做什么”,而将“怎么存”的问题推迟或隐藏。 通过这种方式,你可以保持领域模型的纯净,并让系统在面对基础设施变化时更具弹性。
-
耦合(Coupling): 指组件之间的依赖关系。
-
局部耦合: 是好事,代表组件协同工作(高内聚)。
-
全局耦合: 是坏事,它会导致“牵一发而动全身”,增加维护成本。
-
抽象(Abstraction): 通过引入简单的接口或数据结构来隐藏复杂的细节。
-
抽象能减少系统间的连接点。如果系统 A 直接依赖系统 B,改变 B 就会影响 A;但如果 A 依赖一个抽象层,只要抽象不变,B 内部的改变就不会波及 A。
作者通过编写一个将两个文件夹同步的工具来演示抽象的过程。
- 需求:
- 如果源文件不在目标目录,则拷贝。
- 如果文件名不同但内容相同(通过哈希判断),则重命名。
- 如果目标目录有多余文件,则删除。
最初的代码将业务逻辑(决定哪个文件该移动)与I/O 操作(调用 os.walk, shutil.copy 等)混在一起。
- 问题: 单元测试非常痛苦。为了测试一个逻辑,必须在硬盘上创建真实的临时文件夹和文件,运行后再清理。这导致测试运行慢且代码难以维护。
为了解决上述问题,作者提出了将代码拆分为三个职责:
- 扫描文件系统:获取路径及其哈希值(I/O)。
- 决策逻辑:对比两组哈希/路径,确定需要执行哪些操作(纯逻辑/业务)。
- 执行操作:实际进行拷贝、移动或删除(I/O)。
- 输入抽象: 使用简单的 Python 字典
{hash: filename}来表示文件系统的状态,而不是直接在逻辑层操作磁盘。 - 输出抽象: 逻辑层不直接调用删除/拷贝函数,而是输出一组“指令列表”(如
("COPY", src, dst)),这本质上是一个简单的 DSL(领域专用语言)。
作者引入了 “函数式核心,命令式外壳”(Functional Core, Imperative Shell) 的概念:
- 函数式核心(determine_actions): 这是一个纯函数,输入是简单的字典,输出是操作列表。它没有任何 side effects(副作用),不涉及真实磁盘。
- 命令式外壳(sync): 负责收集输入(扫描磁盘)、调用核心逻辑、然后根据返回的指令执行输出(修改磁盘)。
- 极速测试: 现在的逻辑测试变成了简单的“输入字典,断言输出列表”。不再需要创建真实文件,测试运行速度提升百倍,且覆盖各种边缘情况(如重命名、空文件夹等)变得非常简单。
- 易于扩展: 如果想增加
--dry-run(只打印不执行)模式,或者改为同步到云端(S3),只需要更换“外壳”部分的 I/O 实现,而核心业务逻辑完全不需要改动。
在本章末尾,作者总结了寻找抽象的几个准则:
- 能否用熟悉的 Python 数据结构来表示复杂系统的状态?
- 能否将“做什么”与“怎么做”分开?
- 在哪里可以画出一道线(Seam),将业务逻辑与混乱的 I/O 隔离开?
一句话核心思想: 通过将“决策(业务逻辑)”从“执行(I/O 操作)”中剥离,并利用简单的数据结构进行对接,可以极大降低系统的复杂性并提高代码质量。
在这一章之前,代码通常直接在 Flask 视图函数中调用领域模型和存储库。这会导致以下问题:
- 职责过重:视图函数既要处理 HTTP 请求(解析 JSON、处理状态码),又要处理业务逻辑(调用领域模型),还要处理持久化(提交数据库)。
- 难以复用:如果你想通过 CLI(命令行)或定时任务调用同样的逻辑,必须重复编写这些代码。
- 测试困难:针对视图函数的测试(端到端测试)通常运行缓慢,且依赖完整的 Web 环境。
服务层(Service Layer)被定义为协调层或编排层。它的主要职责包括:
- 从存储库中获取数据。
- 验证领域模型的状态。
- 调用领域模型中的方法执行业务逻辑。
- 保存结果(通过提交 Unit of Work 或 Repository)。
口诀: 服务层负责“做什么”(编排),领域模型负责“怎么做”(具体业务规则)。
- 输入是原始类型 (Primitives):服务层函数的参数通常是简单的 Python 类型(如
str,int,dict),而不是领域对象。这样做是为了完全隔离外部层与领域层。 - 依赖倒置 (DIP):服务层依赖于存储库的抽象(如
AbstractRepository),而不是具体的数据库实现。 - 异常处理:将特定的领域异常(如
OutOfStock)在服务层捕获,或者让服务层根据领域反馈抛出更高级别的应用异常。
这是本章最著名的观点之一,关于如何更高效地进行单元测试:
- Low Gear (低速档/领域测试):直接对领域模型进行细粒度的测试。当业务规则复杂时,这非常有用。
- High Gear (高速档/服务层测试):编写针对服务层接口的测试,并使用 Fake Repository(伪存储库)。
- 优点:这类测试覆盖了从 API 到持久化的整个流程(Edge-to-Edge),且由于不触达真实数据库,运行速度极快。
- 重构利器:当你修改领域模型的内部结构时,只要服务层的接口没变,服务层测试就不需要改动。这解决了“测试代码比业务代码还难维护”的问题。
通过引入服务层,你的架构变得更加清晰:
- Flask 变薄了:它现在只负责解析请求并调用服务层函数,成了真正的“适配器”。
- 多入口支持:你可以轻松地为同一个业务逻辑添加 CLI 接口、消息队列消费者或测试驱动程序。
- 领域模型独立性:领域模型可以专注于复杂的业务规则,而不必担心数据库事务或 HTTP 协议。
在第四章结束时,作者也指出了一些现有的痛点:
- 事务管理仍然很乱:服务层代码中仍然充斥着
session.commit()这种属于基础设施的代码。 - 对 ORM 的依赖:虽然有了 Service Layer,但它往往还和特定的数据库会话对象耦合在一起。
- 注:这些问题会在第五章中得到完美解决。
第四章的核心思想是:通过增加一个抽象的“服务层”,将应用驱动程序与核心业务逻辑分开,并以此为基础建立高效的“边到边”测试体系,从而使代码更具可测试性和可维护性。
作者借用自行车换挡来描述 TDD 的两种节奏:
- 低档位 (Low Gear):当你开始新功能、遇到棘手逻辑或需要深入探索领域模型时,测试直接针对**领域对象(Domain Model)**编写。这能提供最细致的设计反馈(Design Feedback),像是在爬坡。
- 高档位 (High Gear):当领域模型趋于稳定,或在进行日常开发/重构时,测试应更多地针对**服务层(Service Layer)**编写。这样可以提高开发效率,且重构内部模型时不需要大规模修改测试。
作者展示了引入服务层后,测试分布的变化:
- 端到端测试 (E2E Tests):最少,只验证主流程和系统集成。
- 集成测试 (Integration Tests):负责验证数据库、ORM 等外部依赖。
- 单元测试 (Unit Tests):数量最多,但重点从“测试领域模型细节”转向了**“测试服务层 API”**。
直接针对领域模型写太多测试会导致过度耦合。
- 问题:如果你修改了领域模型的某个内部属性名,可能会导致几十个细粒度的领域层单元测试失败,即使业务逻辑没变。
- 解决建议:将大部分单元测试提升到服务层水平。服务层提供了一个相对稳定的 API,只要输入输出(业务意图)不变,内部领域模型如何重构,测试都能通过。
为了让服务层测试真正起到保护作用且不依赖具体实现,本章提出了几个技巧:
-
参数原始化 (Use Primitives):
-
服务层函数不应接收领域对象(如
OrderLine),而应接收原始类型(如orderid: str,sku: str,qty: int)。 -
这样测试调用服务时,就不需要先手动实例化复杂的领域对象。
-
完善服务层功能:
-
如果在测试服务层时,还需要手动用数据库/仓库去造数据(例如手动
repo.add(Batch(...))),说明服务层可能缺失了某些功能。 -
建议:增加类似
add_batch这样的服务函数。这样测试就可以完全通过调用服务 API 来准备数据和执行动作,实现测试与领域模型的物理隔离。
- 每个功能一个 E2E 测试:证明链路是通的,各组件已正确“胶合”。
- 大部分测试针对服务层:这是处理业务逻辑分支、边缘情况的最佳位置,通过 Fake 对象(如
FakeRepository)保证速度。 - 少量的领域模型测试:保留那些对核心复杂逻辑有高度反馈作用的测试。如果服务层测试已覆盖,可以毫不犹豫地删掉重复的低层测试。
- 错误处理也是功能:除了每个功能的 Happy Path,至少应有一个 E2E 测试验证错误流,而详细的错误分支应由服务层单元测试覆盖。
这一章强调了测试不是越多越好,而是要测在“接口处”。通过将服务层作为系统的主要测试入口,并配合使用原始类型参数,开发者可以获得一个既敏捷又稳固的架构,使得未来的重构变得更加容易。
Unit of Work (UoW) 是对“原子操作”概念的抽象。
- 仓储模式 抽象了数据的持久化存储(如何读写数据)。
- UoW 模式 抽象了事务的一致性(如何确保一组操作要么全部成功,要么全部失败)。
- 最终目标: 完全解耦服务层(Service Layer)与底层数据库逻辑,使服务层不再直接依赖数据库会话(如 SQLAlchemy Session)。
在没有 UoW 之前,API 层往往需要同时处理多个组件(Session、Repository、Service),导致逻辑散乱。引入 UoW 后:
- 单一入口: UoW 作为访问持久化层的唯一入口。
- 原子性保证: 确保在操作过程中如果发生错误,能够自动回滚,避免数据处于“半完成”的中间状态。
- 管理 Repository: UoW 负责实例化并持有 Repository,确保它们使用同一个数据库上下文。
该博客推崇使用 Python 的 with 语句来实现 UoW,这是一种非常符合 Python 习惯(Pythonic)的做法:
__enter__: 初始化数据库 Session,并准备好 Repositories。__exit__: 处理退出逻辑。如果代码正常执行则关闭 Session;如果中间抛出异常,则自动执行rollback()。commit(): 只有显式调用此方法时,数据才会被真正持久化。作者认为“默认回滚、显式提交”比“隐式自动提交”更安全。
- 抽象接口 (
AbstractUnitOfWork):定义了commit()、rollback()方法和batches(Repository) 属性。 - 具体实现 (
SqlAlchemyUnitOfWork):包装了 SQLAlchemy 的Session。 - 服务层调用示例:
def allocate(orderid, sku, qty, uow):
with uow:
batches = uow.batches.list()
# ... 执行业务逻辑 ...
uow.commit() # 显式提交这是本章的一个重要哲学点:
- 避免直接 Mock SQLAlchemy: 第三方库的 API 极其复杂,直接 Mock 会导致测试代码脆弱。
- Mock UoW: 通过创建一个
FakeUnitOfWork(内存中的简单类),你可以非常轻松地编写不依赖数据库的单元测试,同时保证测试的是你自己定义的简单接口。
| 优点 | 缺点 |
|---|---|
| 原子性: 保证操作的完整性。 | 额外抽象: 增加了一层代码复杂度。 |
| 简化 API: 服务层只需要关注业务逻辑。 | ORM 已自带: 如 SQLAlchemy Session 本身就实现了 UoW 模式,重复封装可能被视为多此一举。 |
| 易于测试: 方便替换为内存伪造对象。 |
UoW 模式通过将事务管理和Repository 持有封装在一个上下文管理器中,使得系统在面对异常时“默认安全”,并且极大地提高了业务逻辑的可测试性和纯净度。它是构建整洁架构(Clean Architecture)中连接服务层与持久化层的最后一块拼图。
- 不变性(Invariants): 指的是无论发生什么操作,业务规则中必须永远保持为“真”的条件。例如:库存不能为负数、已分配的数量不能超过批次总量。
- 挑战: 在多用户并发环境下,两个用户可能同时尝试操作同一批数据(如同时给同一个订单分配库存)。如果每个请求都独立通过验证并写入,可能会打破“不变性”,导致数据损坏。
聚合是一个边界,它将一组相关的实体(Entities)和值对象(Value Objects)视为一个单一的原子单位。
- 单一入口: 聚合只能通过一个被称为“聚合根”(Aggregate Root)的实体进行访问和修改。
- 强制约束: 聚合根负责维护其内部所有对象的一致性规则(不变性)。外部代码不能直接修改聚合内部的对象,必须调用聚合根的方法。
- 示例: 在书中,
Product(产品)被选为聚合根。它包含多个Batch(批次)对象。由于分配逻辑(哪些批次有货、怎么分配)依赖于对所有批次的整体评估,因此Product负责处理所有分配请求。
- 一致性边界 = 事务边界: 聚合的大小决定了数据库事务的范围。一个聚合内的所有更改应该在同一个数据库事务中完成。
- 不要过度设计: 聚合过大(包含太多对象)会导致性能下降和并发冲突;聚合过小则可能无法维护跨对象的一致性规则。
- 一条法则: 一个聚合对应一个存储库(Repository)。你不再为内部的子对象(如
Batch)创建 Repository,而是通过ProductRepository加载整个Product聚合。
为了防止并发冲突,本章讨论了两种主要策略:
- 悲观锁(Pessimistic Locking): 使用数据库的
SELECT FOR UPDATE。这会锁定行,直到事务结束。虽然安全,但会降低并发性能。 - 乐观并发控制(Optimistic Concurrency Control): * 给聚合增加一个 版本号(Version Number)。
- 每次更新时,检查数据库中的版本号是否与加载时一致。
- 如果版本号已变,说明有其他事务修改了数据,此时抛出异常并重试。这种方法在大多数情况下性能更好。
- Repository 模式的调整: Repository 现在负责加载和保存整个聚合。
- UoW (Unit of Work) 的配合: UoW 确保在业务处理结束时,对聚合的所有更改都能以原子的方式提交。
聚合模式的精髓在于简化系统的状态管理。通过定义清晰的边界,程序员只需要关注“在这个边界内,业务规则是否被破坏”,而不需要担心整个庞大数据库的一致性。
作者通过一个“库存不足时发送邮件通知采购团队”的例子,展示了如果直接将辅助功能耦合在核心代码中会带来的问题:
- 放在控制器(Flask App)里:导致 HTTP 层变得臃肿,难以进行单元测试。
- 放在模型(Model)里:破坏了领域模型的纯粹性,使模型依赖于基础设施(如邮件服务)。
- 放在服务层(Service Layer)里:导致服务函数(如
allocate)不仅要处理业务,还要处理通知逻辑,违反了 SRP(函数名变成了allocate_and_send_mail)。 - 经验法则:如果你在描述函数功能时不得不使用"然后"或"而且"这类词语,那很可能已经违反了 SRP。
领域事件是描述领域中发生过的事实的简单数据类(Dataclasses)。
- 特性:它们是纯数据结构,没有行为。
- 命名:使用领域语言(Ubiquitous Language),通常用过去时态,例如
OutOfStock。 - 触发方式:由聚合(Aggregate)记录事件。模型内部维护一个
self.events列表,当特定业务规则被触发时,将事件对象加入列表。
消息总线是一个简单的发布-订阅系统,负责将事件路由到对应的处理程序(Handlers)。
- 映射关系:它通常是一个字典,将“事件类型”映射到“处理程序列表”。
- 作用:它不关心事件的具体含义,只负责在看到事件时调用相应的函数。
文章探讨了如何将模型产生的事件传递给消息总线:
-
方案 A:服务层显式传递(最简单) 服务层在执行完业务逻辑并提交事务(UoW commit)后,显式地从模型中取出事件并交给消息总线处理。
- 缺点:每个服务函数都要手动写一段处理事件的代码,比较繁琐。
-
方案 B:服务层直接触发事件 服务层根据模型返回的结果直接创建并发布事件。
- 缺点:将本该属于领域层的逻辑(判断何时该触发事件)泄露到了服务层。
-
方案 C:Unit of Work (UoW) 自动处理(推荐) 这是最优雅的方案。修改 UoW 模式,使其在
commit()成功后,自动收集所有涉及的聚合(Aggregates)中的事件,并将其推送到消息总线。- 优点:服务层代码保持极简,不需要意识到事件的存在。
- 解耦:主流程(分配库存)与副作用(发送邮件)完全解耦。如果未来通知方式从邮件改为短信,只需修改消息总线的配置,无需改动核心业务代码。
- 处理跨聚合的一致性:当一个操作需要改变多个聚合时,可以通过事件实现“最终一致性”,避免长时间跨表锁定的事务。
- 测试友好:可以轻松编写单元测试来断言“当执行 X 操作时,模型是否产生了 Y 事件”,而不需要真的去 mock 邮件服务器。
本章的核心思想是将**“编排(Orchestration)”转变为“编舞(Choreography)”**:不再是由一个中心化函数下达所有指令,而是让各个组件通过“事件”这一信号,自发地完成各自的职责。这为后续章节讨论微服务集成和 CQRS 奠定了架构基础。
- 之前(第8章):API 显式调用服务层的函数(如
services.allocate),而消息总线和事件仅作为处理副作用(如发送邮件通知)的附加组件。 - 之后(第9章):一切(无论是外部 API 请求还是内部触发的副作用)都被重新抽象为事件(Events)。API 不再直接调用服务函数,而是将事件丢给消息总线,由消息总线分发给对应的事件处理器(Event Handlers)。
为了展示这种模式的威力,作者引入了一个复杂的现实世界需求:修改批次数量(Batch Quantity Changed)。
- 当系统中某批次货物的数量被调减,且调减后的数量少于已经分配出去的数量时,必须取消(Deallocate)受影响订单的分配。
- 随后,这些被取消的订单需要重新进入等待分配(AllocationRequired)的队列。
如果用传统方式,这个逻辑会混杂各种数据库事务和复杂的逻辑链。但如果通过消息总线:
BatchQuantityChanged事件触发调整数量的 Handler。- 数量不足时,Handler 内部修改状态并触发
AllocationRequired事件。 AllocationRequired事件被重新投入消息总线,自动复用原有的订单分配逻辑,完全无需重写分配核心代码。
- 将原本分散的原始参数(如
ref: str, sku: str, qty: int)封装为具体的事件类(如BatchCreated、AllocationRequired)。 - 将
services.py重命名为handlers.py。 - 所有 Handler 函数的签名保持一致:统一接收
event和uow(工作单元)两个参数。
关于解耦的思考(避免原始类型迷恋 - Primitive Obsession): 虽然把参数封装为事件类让外部与事件耦合了,但由于事件属于领域模型的一部分,且其在业务中相对稳定,这种耦合是健康的。同时,它提供了一个统一的系统入口,并且非常适合做输入参数校验。
为了消除消息总线和 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中拉取新事件由于系统入口变成了消息总线,单元测试和集成测试也随之改变。测试不再调用 services.xxx(),而是直接构造事件并调用 messagebus.handle(event, uow),然后断言最终的系统状态或 UoW 的提交状态。
在全面事件化的转换过程中,作者承认遇到了一个痛点:Web API 需要同步返回结果(例如分配订单后,API 需要返回分配到的批次 ID)。
但在纯粹的事件驱动架构中,消息总线通常是“发后即忘(Fire-and-Forget)”的。为此,本章引入了一个权衡性的“丑陋过渡方案”:让 messagebus.handle() 返回一个包含 Handler 执行结果的列表。(注:这将在后面的章节中通过 CQRS 和命令/事件分离来解决)。
| 优点 (Pros) | 缺点 (Cons) |
|---|---|
| 高度复用与单一职责:Handler 和 Service 合二为一,系统的每一部分都专注于处理一类特定的消息。 | Web 层的不可预测性:对于 Web 请求而言,把一切交给总线会让流程稍微变得不可控,无法一眼看清请求何时完全结束。 |
| 系统的输入结构化:所有进入系统的请求都变成了清晰的数据结构(Event 实体),便于维护。 | 数据结构冗余:领域模型、事件模型之间可能存在大量的字段重复,字段变更时的维护成本会增加。 |
| 系统复杂度的线性增长:应对极其复杂的业务链(如“改数量->取消分配->开新事务重新分配->发外部通知”)时,架构没有变复杂,只需要增加新的事件和 Handler 即可。 |
第 9 章的核心思想是:通过将整个应用改造为由消息总线驱动的“消息处理器”,实现了业务逻辑的极致解耦。当复杂的业务变化来临时,我们不需要去重构复杂的代码调用链路,而只需要像乐高积木一样,通过定义新的事件和处理器,就能让新旧业务完美串联并高效运行。
虽然命令和事件都是由简单数据结构(如 Python 的 dataclass)表示的消息,但它们传达的意图和处理规则完全不同:
| 特性 | 事件 (Events) | 命令 (Commands) |
|---|---|---|
| 命名语态 | 过去时(如 BatchCreated, OrderAllocated) |
祈使句/动词短语(如 CreateBatch, Allocate) |
| 意图表达 | 捕获已经发生的事实(事实) | 表达意图,期望系统去做某事(意图) |
| 接收方 | 广播给所有感兴趣的监听者(1 对多) | 发送给一个特定的接收方/处理器(1 对 1) |
| 错误处理 | 独立失败,不应该中断其他处理流 | noisy 失败(显式抛出异常),必须通知发送者 |
在代码层面上,原有的事件(如 AllocationRequired)被重构为了命令(如 commands.Allocate)。
引入命令后,消息总线(messagebus.handle)需要根据消息的类型(Event 还是 Command)分流到不同的辅助函数中:
- 规则:一个事件可以触发多个处理器。
- 错误处理:使用
try-except捕获所有异常并记录日志,但使用continue允许流程继续。一个事件处理失败不应该影响其他事件的执行,以实现系统的最终一致性。
- 规则:一个命令有且仅有一个对应的处理器。
- 错误处理:采用“快速失败”机制(Fail Fast)。如果执行中发生异常,处理器会记录日志并重新抛出异常(
raise),让错误冒泡到最外层(如 API 层),从而显式通知用户。
许多开发者会担心:“如果事件处理失败,导致系统处于不一致的状态怎么办?”
作者通过聚合(Aggregates)和工作单元(Unit of Work, UoW)的视角给出了答案:
- 核心原则:一个命令应当只修改单个聚合。聚合就是系统的“强一致性边界”。在这个边界内,UoW 确保了要么全部成功,要么全部失败,因此核心业务逻辑不会处于不一致的状态。
- 最终一致性:其他外围的记账、清理或通知工作(例如发送邮件)应当通过事件来驱动。我们不应该为了让事件处理器成功,而强求它与命令处在同一个事务中。
假设一个电商系统规定:当客户下第三单时,将其升级为 VIP 并发送祝贺邮件。
- 正确做法:命令处理器创建订单并提交事务(保证钱能收进来)。随后抛出
OrderCreated事件,由事件处理器去更新历史记录并触发CustomerBecameVIP事件,再由另一个处理器发邮件。 - 为什么解耦?:如果邮件服务器挂了,或者 VIP 计分系统有 Bug,我们不应该阻止用户下单付钱。通过将事务边界与业务步骤对齐,让不同的关注点独立失败,能极大提高系统的整体可靠性。
既然允许事件独立失败,系统就需要具备健壮的恢复机制:
- 结构化日志:由于命令和事件都是
dataclass,其打印出的字符串包含完整数据,发生错误时可以轻松将其复制到 Python Shell 中进行复现或手动重放。 - 自动重试(Tenacity 库):针对网络抖动、数据库死锁等瞬态故障(Transient Failures),可以在消息总线的
handle_event中引入重试机制。作者展示了如何使用tenacity库实现指数级退避重试(Exponential Backoff Retry)(例如最多重试3次)。
因为每个处理器都运行在独立的 UoW 中,每次重试都会从干净、一致的数据库状态开始,不会产生中间状态污染。
将系统拆分为命令与事件带来了以下利弊:
-
优点 (Pros):
-
清晰地区分了“哪些操作必须立刻成功(命令)”,以及“哪些操作可以稍后收拾(事件)”。
-
代码语义更明确(例如
CreateBatch比BatchCreated更符合用户发起请求时的真实意图)。 -
缺点 (Cons):
-
概念上的微小差异可能会引发团队内部关于“这到底是命令还是事件”的无休止争论(Bikeshedding)。
-
显式地接受了系统可能局部失败的现实,系统逻辑变得更加分布式,增加了理解成本,并对监控和日志提出了更高的要求。
在前几章中,应用内部已经实现了基于“消息总线(Message Bus)”的事件和命令驱动模式。但这些都发生在单个微服务内部。 本章的核心议题是:当外部世界发生变化(如供应商修改了到货数量),或者我们需要通知外部世界(如订单已分配,通知仓库发货)时,应该如何处理?
通过引入外部消息代理(Message Broker),让应用在外部也变成一个“消息处理器(Message Processor)”:
- 输入:从外部消息队列(如 Redis Pub/Sub)接收事件。
- 处理:内部消息总线将事件分发给对应的处理器(Handlers)并转换成领域操作。
- 输出:将处理结果以事件形式再次发布到外部消息队列。
作者首先警告了一种常见的微服务错误设计倾向:基于名词(Noun-based)划分服务。
例如,系统中有名词:Batches(批次)、Orders(订单)、Warehouse(仓库)。幼稚的做法是为每个名词建一个服务,并暴露标准的 CRUD HTTP API。
-
正向流程(Happy Path):用户下单
$\rightarrow$ Orders服务调用Batches服务预留库存$\rightarrow$ Batches调用Warehouse发货。 -
反向流程(异常处理):仓库发现货物损坏。
Warehouse服务调用Batches扣减库存$\rightarrow$ Batches触发重新分配$\rightarrow$ Batches调用Orders修改订单状态。
- 循环依赖:
Orders依赖Batches,而Batches反过来又依赖Orders。随着业务增加,服务间调用会变成一团乱麻。 - 时间耦合(Temporal Coupling):系统要求所有关联的服务在同一时间必须全部可用。如果
Batches挂了,整个下单接口直接报错,错误级联放大,降低了整体可用性。
作者引入了 Connascence(共生性 / 耦合度的一种度量) 的概念来解释为什么 RPC 模式很糟糕:
- 执行共生性(Connascence of Execution):多个组件必须知道正确的工作顺序才能成功。
- 时间共生性(Connascence of Timing):多个操作必须紧接着发生才能成功(如同步 HTTP 调用)。
- 名称共生性(Connascence of Name):这是我们追求的弱耦合。 多个组件只需要在“事件名称”和“字段名称”上达成一致即可。
解决方案:基于动词(Verbs)思考,利用异步消息实现时间解耦。 将“订单服务”和“批次服务”转变为“订购流程”和“分配流程”。服务之间不进行同步调用,而是通过发布领域事件,允许系统达到最终一致性(Eventual Consistency)。
为了演示外部集成,本书选用了轻量且通用的 Redis 发布/订阅(Pub/Sub)机制作为消息代理(生产环境也常用 Kafka 或 RabbitMQ)。
当外部更改了批次数量:
- Redis 收到外部事件
BatchQuantityChanged。 - 应用的 外部事件消费端(Event Consumer) 监听到该消息,将其塞入内部消息总线(Message Bus)。
- 内部总线触发对应的领域逻辑,如果受影响,会引发重新分配,并产生内部事件。
- 内部的
PublishHandler捕获到这些需要外发的事件(如Allocated),再将其发布回 Redis 的指定通道(如line_allocated)。
由于引入了异步和 Redis,测试需要做出调整。不能再像 HTTP 那样直接等待 Response 结果,而是需要:
- 向 API 添加测试数据。
- 创建一个 Redis 订阅者,监听
line_allocated通道。 - 向 Redis 发送
change_batch_quantity模拟外部事件。 - 异步轮询/重试(Retrying):在一定超时时间内,检查是否在订阅通道里收到了预期的重分配事件。
- 外部消费者(如
redis_eventconsumer.py): 启动一个独立进程/死循环,连接 Redis 订阅通道。收到消息后反序列化为领域层能识别的commands.ChangeBatchQuantity,然后调用内部messagebus.handle(cmd, uow)。 - 外部发布者(Event Handler):
在内部消息总线中注册一个通用的事件处理器。当内部领域模型抛出需要外发的事件(如
events.Allocated)时,该处理器负责将事件转化为 JSON,通过redis_client.publish()发送出去。
利用事件驱动集成微服务有得有失:
- 彻底避免了分布式大泥球,系统实现了解耦。
- 服务之间互相独立,更容易单独修改、扩展或增加新服务(例如增加一个邮件通知服务,只需订阅现有的 Redis 事件即可,无需改动核心业务代码)。
- 极大地增强了系统的弹性和容错性。
- 信息流变得隐蔽:由于全是异步事件触发,无法通过阅读单段代码直观看到整个业务的全貌(Martin Fowler 曾指出这会导致调试和修改变难)。
- 必须处理最终一致性:业务人员和技术人员都需要接受数据不是实时同步的事实。
- 引入分布式系统的硬核问题:需要额外考虑消息丢失、消息重复(需要保证幂等性)、消息乱序(Order)等网络及可靠性问题。
在前面的章节中,作者带我们构建了一个高度复杂的领域模型(Domain Model),引入了聚合(Aggregates)、仓储模式(Repository)和工作单元(Unit of Work, UoW)等概念。
- 领域模型是为了“写入”而生的:所有的复杂性(如一致性边界、业务规则校验、领域事件通知)都是为了确保系统在改变状态(Write)时数据是正确且合规的。
- 读取面临不同的业务特性:作者以 MADE.com 的家具电商系统为例:
- 高并发差异:高峰期一小时可能只有 100 个下单写入请求,但每秒钟会有 100 次商品详情页的读取请求。
- 一致性要求不同:分配库存(写入)必须严格一致,否则会超卖;但用户浏览商品(读取)时,数据即便延迟了几秒钟,用户也几乎无法察觉。
结论:在读取数据时,之前精心设计的领域模型、服务层和 UoW 反而成了“包袱”(Bloat)。读取操作不需要那些业务规则校验,直接、快速地拿到数据才是关键。
很多开发者对“读取非强一致性数据”感到焦虑。作者用两个现实场景打破了这种思维定势:
- 数据的时效性本质:当网页渲染完成的瞬间,它所展示的数据就已经成了“历史”。如果用户看了一眼商品去倒杯咖啡,回来再下单时可能已经没货了。因此,无论系统多么实时,在最终执行写入(如下单、分配)时,都必须再次校验当前状态。
- 现实物理世界的不可靠性:即便系统做到了绝对的强一致性,仓库管理员在搬运家具时也可能不小心把货砸碎。此时系统同样要通过业务流程(如退款或延迟发货)来处理异常。
既然数据不一致无法绝对避免,那么在读取端用“最终一致性”换取“高性能”和“高可扩展性”就是完全合理且划算的。
传统的 API 设计中,一个 POST /allocate 接口在完成库存分配(写入)后,会直接返回分配到的 Batch ID(读取)。这违反了 CQS 原则(一个函数要么改变状态,要么回答问题,不能同时做)。
作者首先在 API 层面作了优化,改为 Post/Redirect/Get 的逻辑:
POST /allocate:仅返回202 Accepted,代表系统接受了写入指令。GET /allocations/<orderid>:提供一个专用的只读端点来查询分配结果。
为了实现读取端点的逻辑,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]作者对比了另外两种似乎更“优雅”的选择:
- 方案 A:在 Repository 上加个查询方法。 这会导致 Repository 越来越臃肿,而且为了拼凑出前端需要的数据,可能会被迫在领域模型对象中加入本不需要的查询字段。
- 方案 B:使用 ORM(如 SQLAlchemy 表达式)。 编写复杂的 ORM 多表联查同样不容易,且极易触发 SELECT N+1 性能陷阱(例如循环读取聚合根下的子关联对象导致产生大量 SQL 查询)。
CQRS 的突破口:既然是只读,就应该抛弃领域模型,抛弃 ORM 对象的包装。直接执行原生 SQL 并将数据库行直接映射为 Python 字典(Dict),性能最高,且最灵活。
在 CQRS 架构中,测试只读视图推荐使用集成测试(Integration Test):
- 测试策略:在 Setup 阶段,通过系统的公共入口(Message Bus 触发 Command)来写入数据,然后调用只读 View 验证返回的字典是否正确。
- 好处:这确保了测试与底层的数据库表结构解耦。只要系统的写入逻辑和读取逻辑在业务上能对齐,测试就能通过。
随着业务继续发展,即便是直接查主库的原生 SQL 也会遇到瓶颈(比如大量复杂的 JOIN 严重拖慢数据库)。
这一章的终极演进是:为读取端创建完全独立的表(Read-Side Tables / Materialized Views)。
- 设计结构:这张表是专门为前端 UI 展示量身定制的。比如前端需要什么 JSON 格式,这张表就存成什么样,尽量做到
SELECT * FROM my_view WHERE id = ...就能单表返回。 - 数据同步机制:
- 写入端接收到 Command,修改了主库的聚合状态。
- 聚合根(Aggregate)触发并抛出一个领域事件(Domain Event)(例如
Allocated)。 - 专用的事件监听器(Event Handler / Projectionist)捕获该事件。
- 监听器直接更新读取端的专用表。
通过这种方式,读取操作彻底不查主表,从而实现了读写在数据存储层的彻底隔离。
| 维度 | 优势 (Pros) | 劣势 (Cons) |
|---|---|---|
| 读性能 | 极高。可针对查询定制数据结构,无 ORM 开销,极易做缓存或单表无 JOIN 查询。 | 产生了数据延迟(最终一致性),需要前端配合处理(如乐观UI)。 |
| 代码耦合 | 读写职责分离,领域模型只需专注于复杂的业务写入规则,不再受查询需求绑架。 | 架构复杂度显著增加。维护读写两套模型和数据同步管道需要更多工作量。 |
作者的最终建议: 不一定非要一步到位做到“独立的只读数据库”。可以根据复杂度采取渐进式演进:
- 初级 CQS:代码中分出
views.py,写原生 SQL 绕过领域模型。 - 高级 CQRS:当性能遭遇瓶颈时,再通过监听内部事件来驱动更新专门的只读表或物化视图。
在 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"(显式胜于隐式)。
如果我们将所有依赖(UoW、邮件发送、Redis 广播等)都改为显式声明,那么由谁来负责将这些依赖实例化并注入到各个处理器(Handlers)中呢?
如果让 Flask、Redis 消费者等各个入口点(Entrypoints)各自去处理,会导致大量的重复初始化代码,并且让消息总线(Message Bus)承担了额外的依赖传递职责,违反了单一职责原则 (SRP)。
为了解决这个问题,书中引入了 引导程序 (Bootstrap Script),在面向对象语言中也被称为 组合根 (Composition Root)。
引导程序的作用:
- 声明默认的生产依赖(如真实的数据库、真实的邮件服务),但在调用时允许重写(用于测试)。
- 执行应用启动时只需要运行一次的初始化工作(例如
orm.start_mappers()或配置日志)。 - 动态地将所有必要的依赖注入到命令/事件处理器(Handlers)中。
- 返回一个完全配置好的核心对象——消息总线(Message Bus)。
本章并没有推荐复杂的第三方 DI 框架,而是展示了如何在 Python 中用原生语法优雅地进行“手动依赖注入”。
如果 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)如果不喜欢闭包,也可以将 Handler 重构为类,利用构造函数 __init__ 接收依赖,利用 __call__ 使其实例可调用:
class AllocateHandler:
def __init__(self, uow: unit_of_work.AbstractUnitOfWork):
self.uow = uow
def __call__(self, cmd: commands.Allocate):
# 处理逻辑...为了避免为几十个 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() 函数逻辑:
- 初始化 ORM (
orm.start_mappers())。 - 构建依赖字典集合(包含
uow、send_mail、publish等)。 - 遍历
HANDLERS映射表,对所有的 Handler 调用inject_dependencies。 - 将 Message Bus 从一个静态模块重构为一个类,并将这些注入好依赖的 Handlers 在运行时(Runtime)作为参数传递给 Message Bus 实例。
为了给读者更具体的实战感,本章通过一个重构通知服务(Notifications)的例子,展示了如何标准地落地这一套模式:
- 定义抽象基类 (ABC):定义
AbstractNotifications,并声明一个抽象方法send(destination, message)。 - 编写生产环境的具体实现:编写
EmailNotifications(AbstractNotifications),内部实现真实的 SMTP 发送逻辑。 - 在引导程序中声明:在
bootstrap()的参数默认值中,使用notifications: AbstractNotifications = EmailNotifications()。 - 编写测试用的 Fake 实现:编写一个
FakeNotifications,内部用一个字典defaultdict(list)来记录发送过的消息,而不发送真实邮件。 - 在单元测试中重写:测试时,直接调用
bootstrap.bootstrap(notifications=FakeNotifications()),即可在完全不使用mock.patch的情况下,轻松验证 Handler 是否正确触发了通知。
本章是全书架构演进的重要一步。通过引入依赖注入与引导程序:
- 我们的各个处理器(Handlers)彻底变成了纯粹的领域逻辑协调者,不再对任何具体的基础设施产生隐式依赖。
- 测试变得极其干净,告别了繁琐、易碎且与实现细节高度耦合的猴子补丁(Monkeypatching)。
- 所有的组件组装、依赖声明和第三方库初始化都被收拢到了一个单一的入口——
bootstrap.py(组合根),整个应用的结构变得更加清晰和可预测。
许多开发者在读完书后会产生焦虑:“书里的模式很完美,但我面对的是一个庞大、混乱的 Django/SQLAlchemy‘大泥球’(Big Ball of Mud),根本无从下手。”
作者给出了非常务实的建议:
- 明确目标(解决什么问题): 不要为了架构而架构。先问自己:系统是太难扩展了?性能无法接受?还是有诡异的 Bug?带着具体问题去重构,才更容易向团队和业务方争取时间。
- 捆绑业务需求(Architecture Tax): 推动大规模重构很难,最佳时机是与新的重大功能(Feature)绑定。例如,系统要开拓新市场或上线新产品线,在预估 6 个月的项目中加入 3 周的“架构税”用来清理地基,业务方更容易接受。
章节通过一个实际案例(一个外包出去、经手多代开发者的复杂文档协作平台)展示了如何解耦:
旧系统往往逻辑混乱:Controller、Model、Manager、Helper 里到处都塞满了业务逻辑。
-
找出系统的“用例”(Use Cases): 根据用户界面或后台定时任务(如 Celery 任务),定义出具有祈使语气动词的函数或类(例如
Apply Billing Charges,Create Workspace)。 -
让用例函数负责编排(Orchestration): 每一个用例应当是一个原子的事务单元,负责:开启事务
$\rightarrow$ 获取数据$\rightarrow$ 验证前置条件$\rightarrow$ 更新领域模型$\rightarrow$ 持久化更改。 - 允许早期的代码复制: 提取服务层时,哪怕几个用例间有重复代码也没关系,“Ctrl+C / Ctrl+V”好过让用例之间深度嵌套、互相调用。先把 I/O(发邮件、写文件)和底层数据访问从领域模型中抽离到服务层中。
旧系统最容易犯的错误是拥有一个高度连接的对象图(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)。
当你把聚合拆开后,原先在一个长方法里连续修改多个对象的做法就行不通了。这时需要开始引入事件:
- 聚合发生改变后,在内部隐式挂载一个领域事件(Domain Event)。
- 在服务层/单元工作(UoW)提交时,将事件发布到消息总线(Message Bus)。
- 编写微小的、职责单一的事件处理器(Event Handlers)来处理后续的连带业务(例如:用户锁定后,异步触发文档归档)。
作者分享了技术评审人员对全书模式的一些尖锐提问:
- 必须一蹴而就吗?
- 不需要。 完全可以只做一点。即使你的底层依然是非常混乱的、与数据库强绑定的 Django ORM,先封装一层服务层(Service Layer)也是巨大的胜利,至少它能帮你梳理出清晰的业务边界。
- 提取用例会破坏大量现有代码怎么办?
- 采用绞杀者植物模式(Strangler Fig Pattern):不要在老代码里修修补补,而是直接在旁边写新的整洁代码,然后把路由或流量逐步切过去。
章节最后,一位参与试读的资深工程师(Harry)分享了自己的心路历程。他曾因无法在公司系统里完美落地书中的所有概念而感到沮丧,但最终释怀了:“现实不是教科书,不完美是常态。寻找一个小痛点,哪怕用不完美的方式去尝试落地,也是在朝着正确的方向前进。”
作者最后推荐了三本进阶必读书目,供读者继续深化:
- 《Clean Architectures in Python》 (Leonardo Giordani):当时极少数用 Python 讲整洁架构的经典。
- 《Enterprise Integration Patterns》 (Gregor Hohpe / Bobby Woolf):消息传递与异步事件驱动模式的圣经。
- 《Monolith to Microservices》 (Sam Newman):指导如何将单体“大泥球”逐步演进到微服务的实操指南。
本章的精髓在于:完美是优秀的敌人。 从“大泥球”走向整洁架构是一场持久的阵地战,首要任务是用服务层和UoW圈出业务边界,通过 ID 关联肢解臃肿的对象图,然后用事件连接各个聚合。
作者强调,不存在单一的“校验层”,校验应当根据其职责与性质拆分到系统的不同层级中:
- 入口/ API 层校验(Syntactic Validation / 语法与格式校验)
- 职责:确保传入的数据格式正确(如 JSON 结构有效、必填字段存在、邮箱格式符合规范、类型匹配)。
- 工具:推荐使用 Pydantic、Marshmallow、Cerberus 或 Schematics 等数据验证库。
- 定位:阻止“垃圾数据”进入系统内部,保护领域层免受低级格式错误的影响。
- 领域层校验(Domain Validation / 业务规则校验)
- 职责:确保数据符合业务逻辑与领域不变性(Invariants)(例如:订单数量不能超过库存、账户余额不能为负、特定状态下不能执行某种操作)。
- 定位:属于核心业务逻辑的一部分,必须包含在领域模型(Entity / Aggregate)或领域服务(Domain Service)中。
- 数据库/持久化层校验(Database Constraints / 数据库约束)
- 职责:作为最后的安全防线(例如:唯一性约束
UNIQUE、外键约束)。 - 定位:处理并发冲突和确保全局一致性(如防止用户名重复)。
附录详细探讨了在 Python 中实现校验的三种常见设计模式:
- 做法:在实体(Entity)或值对象(Value Object)的构造函数(
__init__)中直接抛出自定义业务异常(如InvalidOrderError)。 - 优点:简单直接,强行保证了对象在创建时就必须处于有效状态(Always-Valid Entity)。
- 缺点:每次只能抛出第一个发现的错误,无法一次性收集并返回多个校验错误。
- 做法:引入专门的校验对象或方法(如
validate()),将错误收集到一个列表中返回,而不是立即抛出异常。 - 适用场景:前端表单提交、复杂业务校验需要一次性高亮显示所有不合规字段的情况。
- 做法:借鉴“Parse, don't validate”思想,将原始数据解析为强类型的值对象(例如将
str解析为EmailAddress或Money)。 - 优势:只要对象存在,其类型本身就保证了数据的合法性,避免在代码各处重复进行
is_valid检查。
-
不要重复校验(DRY 原则的权衡):API 层只做格式与类型解析,不要把业务规则下沉或复制到 API 层;核心业务规则只写在领域层。
-
区分“格式错误”与“业务拒绝”:
-
格式错误(HTTP 400 Bad Request):客户端传参有问题。
-
业务拒绝(HTTP 422 / 400 或特定错误码):参数格式没问题,但违反了当前系统状态下的业务规则。
-
保持领域模型的纯粹性:领域层不应依赖外部的 Web 框架或第三方 Validation 库,使用纯 Python 逻辑或原生异常处理即可。