Appearance
源码设计索引:反复出现的设计模式
小a在代码走查时指着一处结构,很自信地说:“这是观察者模式吧?模块名叫 subscribe,肯定是。”
老z没反驳,只让他先画出调用关系。
小a画完,发现自己说早了——“subscribe”确实存在,但事件怎么发、谁来收、异常怎么传,不画出来根本说不清。他画的图里,Agent.subscribe 注册的监听器既被 loop 调用,也被 harness 调用,执行顺序还不固定。一个监听器返回的 Promise 没有 settle 之前,loop 不会推进到下一个事件。这哪是一个干净利落的“观察者”,分明是一个带时序依赖的异步事件分发链。
“模式是理解工具,不是贴标签。”老z说,“除非注释或命名明确表明,否则以下均写作‘可用某模式理解’。一个结构能不能叫某模式,不取决于它像不像教科书的图,而取决于它的收益和代价是否与该模式一致。本附录服务 Part II,在阅读源码后、需要命名某个结构和权衡时查阅。”
本附录导读: 这不是一份 GoF 模式教程。它从
pi-mono@583f153d的真实代码里,挑出九处可以用经典设计模式理解的结构,逐一拆解:模式定义是什么、源码里长什么样、用了它换来什么、付出了什么代价、误判它会导致什么后果、它和概念篇/源码篇的哪一章呼应。每个模式都配了真实代码节选和反面论证,不是观点清单。
模式是理解工具,不是贴标签
老z先纠正了小a一个误区。
“你是不是觉得,能叫出模式名就等于理解了代码?”
小a点头。
“反了。”老z说,“**模式名是理解的起点,不是终点。**叫它‘观察者’只告诉你‘这里有个发布订阅’,但真正的价值在后面三个问题:谁订阅、订阅后能改什么、异常怎么传播。这三个问题答不上来,模式名就是装饰。”
为什么不能倒过来:先命名后理解
小a不服气:“我先把名字定下来,再去看细节,不行吗?名字先给我一个锚点。”
“锚点会变成锚——把你钉死在错误的预期上。”老z说,“你看到 subscribe 就叫‘观察者’,于是你预期它是‘松耦合、互不干扰’的。但当你发现监听器的执行顺序会影响 pending writes 的时序时,你的预期和现实就打架了。这时候你会怎么做?要么强行把现实塞进‘观察者’的框里——‘它只是个不太标准的观察者’;要么怀疑自己看错了代码。两种反应都让你离真相更远。”
老z在纸上画了一条线:“正确的顺序是——先画事实,再选名字,最后核代价。”
判断顺序
- 画事实——写出调用关系和状态转移。谁调谁、传什么、异常怎么走、状态何时变。这一步逼你看清代码到底在做什么,而不是你觉得它在做什么。
- 选名字——从 GoF 二十三个模式里选一个不会夸大事实的名称。判断标准不是“像不像 UML 图”,而是“这个模式的核心约束,代码是否真的满足”。如果代码不满足模式的核心约束,宁可不命名,也不要硬贴。
- 核代价——对照教科书里该模式的已知代价,逐一检查代码是否承担了这些代价。如果某个代价在代码里没有被处理,那要么是 bug,要么是这个模式在这里并不完全适用——两种情况都要标注。
“三步都做完,模式名才是有用的。”老z说,“跳过第一步直接命名,你会把想象当事实;跳过第三步只看收益不看代价,你会把模式当成银弹。”
代价列才是契约
小a:“那代价列为什么那么重要?收益不才是用模式的理由吗?”
“收益告诉你为什么选它,代价告诉你它承诺了什么。”老z说,“比如发布者模式的标准承诺是‘订阅者都能收到更新’。但 pi-mono 里的 snapshot publisher,它的 revision 只是广播队列的观察序号——不保证跨网络分区的强一致,也不保证 exactly-once。如果你只看收益‘让 client 校正状态’,就会以为它能保证一致性;但代价列告诉你它不能。模式名是入口,代价列才是契约。”
这个原则贯穿整个附录。下面九个模式,每个都会同时列收益和代价——代价不是缺点,是你在使用这个模式时必须承担的真实成本。如果你不愿意承担某个代价,那就不应该用这个模式,而不是用了它再假装代价不存在。
九个模式一览
先给一张总览表,后面逐个展开。每个模式都有完整的案例推演,不只是表格里的一行。
| # | 结构 | 可用何种模式理解 | 源码位置 | 核心收益 | 核心代价 |
|---|---|---|---|---|---|
| ① | createProvider | 简单工厂 | packages/ai/src/models.ts:556 | 集中分派 Provider 创建 | 适配层差异随 Provider 增长 |
| ② | Provider 接口 | 策略 | packages/ai/src/models.ts:75 | 上层统一调用流入口 | 各策略错误语义必须一致 |
| ③ | transform-messages | 适配器 | packages/ai/src/api/transform-messages.ts | 屏蔽厂商消息形状 | 可能丢失厂商专有信息 |
| ④ | Agent.subscribe | 观察者(带时序约束) | packages/agent/src/agent.ts:243 | UI 与循环解耦 | 异步事件链更难追踪 |
| ⑤ | resolveProviderAuth | 责任链 | packages/ai/src/auth/resolve.ts:48 | 表达凭据优先级 | 诊断必须说明走到哪一步 |
| ⑥ | KeyedOperationQueue | 按 key 串行队列 | packages/agent/src/harness/session/keyed-operation-queue.ts | 同 key 操作有序 | 不是完整事务或命令模式 |
| ⑦ | withFileMutationQueue | 资源级并发控制 | packages/coding-agent/src/core/tools/file-mutation-queue.ts:32 | 同路径写入串行 | 不保护外部服务副作用 |
| ⑧ | ValidatedMessageDecoder | 管道/守卫 | packages/protocol/src/codec.ts:88 | 分层验证字节 | 失败后必须重建 decoder |
| ⑨ | ServerSnapshotPublisher | 发布者(版本化状态) | packages/server/src/snapshots.ts:21 | 让 client 校正状态 | revision 非强一致事务 |
跨章回扣: 这九个模式不是孤立的——①②③ 都在 pi-ai(源码篇第 19、20 章),④⑤⑥ 在 pi-agent-core(第 21、22 章),⑦在 coding-agent(第25章),⑧在 protocol(第26章),⑨在 server(第27章)。读到对应章节时,可以回来对照这个附录。
模式 ①:createProvider —— 简单工厂
模式定义
简单工厂(Simple Factory)的核心是:把对象的创建逻辑集中在一个函数里,调用方只传参数,不关心具体怎么 new。 GoF 的工厂方法(Factory Method)和抽象工厂(Abstract Factory)是它的进阶版,但 pi-mono 里用的是最朴素的形式——一个函数,根据输入返回一个配置好的对象。
源码里的真实代码
小a打开 packages/ai/src/models.ts,找到第 556 行:
ts
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
const baselineModels = input.models;
let dynamicModels: readonly Model<TApi>[] = [];
let inflightRefresh: Promise<void> | undefined;
const fetchModels = input.fetchModels;
// ... 合并 baseline 和 dynamic models、分派 API 实现 ...
return {
id: input.id,
name: input.name ?? input.id,
baseUrl: input.baseUrl,
headers: input.headers,
auth: input.auth,
getModels: currentModels,
refreshModels: fetchModels ? (context) => { /* ... */ } : undefined,
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
streamSimple: (model, context, options) => dispatch(model, (streams) => streams.streamSimple(model, context, options)),
};
}“看清楚它做了什么。”老z说,“它接收一个 CreateProviderOptions——里面有 id、models、api 实现、可选的 fetchModels。然后它做了一堆内部组装:合并 baseline 和动态 models、构建 dispatch 函数来按 model 的 api 字段选择流实现、管理 inflightRefresh 防止重复刷新。最后返回一个完整的 Provider 对象。”
“这跟 new Provider(...) 有什么区别?”小a问。
“区别在于创建逻辑的复杂度被封装了。”老z说,“如果你直接 new Provider,那合并 models、分派 API、管理 refresh 的逻辑就得散在调用方。createProvider 把这些都收进来,调用方只需要传一个描述对象,拿回来一个能用的 Provider。”
收益:集中分派,调用方简单
老z让小a看 packages/ai/src/providers/ 目录——anthropic、openai-responses、openai-completions、google,每家都是一个独立的 Provider 配置,但它们的创建都走同一个 createProvider。
“这意味着什么?”老z问。
“意味着加一个新 Provider,我只要准备一个 CreateProviderOptions,调 createProvider 就行。不用关心 dispatch 怎么分派、models 怎么合并。”小a说。
“对。这就是工厂的收益——**创建逻辑集中,调用方简单。**不管 Provider 内部多复杂,对外只是一个函数调用。”
代价:适配层差异随 Provider 增长
“但有代价。”老z说,“你看 dispatch 里面——它按 model.api 字段选择 streams。如果你加的 Provider 用了一个新的 API 类型,你不仅要写新的 stream 实现,还要确保 dispatch 能找到它。工厂只集中了创建,没有消灭差异——差异被推到了适配层。”
小a翻到 packages/ai/src/api/ 目录——果然,每种 API 类型都有自己的一套转换文件。
“所以加 Provider 的真实成本是:工厂这里加一个分支,但适配层要多写一整套消息转换、事件映射、错误处理。”老z说,“这个代价不会因为叫它‘工厂’就消失。很多人加 Provider 时只估了工厂那部分工作量,结果卡在适配层——这就是只看收益不看代价的后果。”
反面论证:误判后果
老z讲了一个他见过的真实案例:“有个团队把 createProvider 当成‘注册表’,以为加了 Provider 就自动可用。结果他们的新 Provider 用了一个 dispatch 里没有的 API 类型,运行时抛了 Provider has no API implementation for "xxx"。他们花了两天才定位到——因为工厂的创建成功了,错误延迟到了流调用时才暴露。”
教训:工厂成功 ≠ Provider 可用。
createProvider返回一个对象,但这个对象的stream方法在运行时才会检查 API 实现是否存在。创建时的成功只证明描述对象合法,不证明流能跑通。这就是工厂把复杂度推迟到运行时的代价——错误延迟暴露。
跨章回扣
这个模式在源码篇第19章:第19章 目录与户口——pi-ai详细展开——那一章跟读了 createProvider 如何被 ModelsImpl 调用、返回的 Provider 如何被 requireProvider 查找。如果你读完 19 章后对“Provider 是怎么来的”还有疑问,回到这里看工厂的完整内部逻辑。
模式 ②:Provider 接口 —— 策略
模式定义
策略模式(Strategy)的核心是:定义一个接口,让一组可互换的算法各自实现它,调用方不关心具体用的是哪个。 GoF 的原文说的是“封装可互换的算法族”,但在 pi-mono 里,它更像是一组可互换的服务边界——每家模型厂商都是一个策略,它们实现同一个 Provider 接口。
源码里的真实代码
小a翻到 models.ts 第 75 行:
ts
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly baseUrl?: string;
readonly headers?: Record<string, string>;
readonly auth: ProviderAuth;
getModels(): readonly Model<TApi>[];
refreshModels?(context: RefreshContext): Promise<void>;
stream(model: Model<TApi>, context: Context, options: StreamOptions): AssistantMessageEventStream;
streamSimple?(model: Model<TApi>, context: Context, options: SimpleStreamOptions): AssistantMessageEventStream;
}“这就是策略接口。”老z说,“不管你是 Anthropic、OpenAI 还是 Google,你都要实现这个接口。上层 ModelsImpl 调 provider.streamSimple(...) 时,它不关心背后是哪家厂商——它只关心返回的是 AssistantMessageEventStream。”
收益:上层统一调用
“这个收益和工厂是配对的。”老z说,“工厂负责创建策略实例,策略接口负责让上层不知道具体是哪个实例。两者合在一起,才实现了‘换 Provider 不改上层代码’。”
小a想了想:“那 streamSimple 后面那个问号是什么意思?”
“可选方法。”老z说,“stream 是必须实现的,streamSimple 是可选的。这意味着策略之间可以有差异——有些 Provider 不支持简化流接口。**策略模式的收益不是‘所有策略完全一样’,而是‘它们遵守同一个最小契约’。**可选方法就是最小契约和扩展能力的平衡点。”
代价:各策略错误语义必须一致
“策略模式最隐蔽的代价在这里。”老z指着 AssistantMessageEventStream 说,“所有 Provider 都返回这个类型,但它们内部的错误语义可能不同。Anthropic 的 SDK 在网络超时时抛一种异常,OpenAI 的 SDK 可能抛另一种。如果你在策略层不统一这些错误语义,上层就会面对一堆形状各异的异常——它以为自己只依赖一个接口,实际上依赖了 N 种错误格式。”
小a:“那怎么统一?”
“这就是 lazyStream 存在的理由之一。”老z打开 packages/ai/src/api/lazy.ts,“它把流的创建延迟到订阅时,统一了错误入口——不管哪家 Provider 的流创建失败了,错误都从同一个边界抛出。但即便如此,流内部的错误(比如生成中途断开)仍然取决于各 Provider 的适配器实现。策略接口统一了入口,没有统一内部——这是策略模式的固有代价。”
反面论证:把可选方法当必须方法
老z又讲了一个案例:“有个团队假设所有 Provider 都实现了 refreshModels,直接调了它,没做 null 检查。结果对接一个静态 Provider(不支持的)时,运行时报 refreshModels is not a function。策略接口的问号告诉了你它是可选的,但你把它当必须的——这就是不看契约只看名字的后果。”
教训:策略接口的可选方法是契约的一部分。
refreshModels?的问号不是建议,是约束——它告诉你“不能假设所有策略都实现了它”。把这个约束当装饰,就会在运行时撞上 undefined。
对比矩阵:工厂 vs 策略
小a被①和②搞混了:“工厂和策略,到底什么区别?”
老z画了一张表:
| 维度 | ① 简单工厂 createProvider | ② 策略 Provider 接口 |
|---|---|---|
| 解决什么问题 | 对象怎么创建 | 创建好的对象怎么被统一调用 |
| 谁变了 | 加 Provider 时改工厂的输入 | 加 Provider 时实现接口 |
| 调用方知道什么 | 知道有个工厂函数 | 不知道具体是哪个策略 |
| 错误暴露时机 | 创建时(描述对象不合法) | 运行时(流调用失败) |
| 和对方的关系 | 工厂产出策略实例 | 策略定义工厂返回的形状 |
“它们是配对的——工厂生产策略,策略定义工厂的产物形状。**单独看一个都会漏掉另一半。**这就是为什么很多书把工厂方法和策略模式放在相邻章节:它们在实际代码里几乎总是一起出现。”
跨章回扣
策略接口在源码篇第19章和第20章:第20章 先开票,后办事——pi-ai都被跟读——19 章看 Provider 接口怎么被 ModelsImpl 委派,20 章看策略返回的 AssistantMessageEventStream 怎么穿过适配层变成统一事件。两章合起来才是策略模式在 pi-mono 里的完整落地。
模式 ③:transform-messages —— 适配器
模式定义
适配器模式(Adapter)的核心是:把一个接口转换成另一个接口,让原本不兼容的类能一起工作。 GoF 的经典比喻是"电源适配器"——你的笔记本要两孔插座,墙上只有三孔,适配器把三孔转换成两孔。在 pi-mono 里,每家模型厂商的消息格式就是不同的"插座",transform-messages 就是那个转换器。
源码里的真实代码
小a打开 packages/ai/src/api/transform-messages.ts,第 64 行:
ts
export function transformMessages<TApi extends Api>(
messages: ReadonlyArray<AgentMessage>,
api: TApi,
// ...
): unknown /* 按 api 返回不同厂商的消息格式 */"它接收的是 AgentMessage——这是 pi-mono 内部的统一消息格式,有 role、content blocks、tool calls、tool results。"老z说,"输出的是 unknown——不是因为它真的什么都返回,而是因为不同 API 的消息形状完全不同。Anthropic 要 content blocks 数组,OpenAI Responses 要 input/output items,OpenAI Completions 要 role-based content strings。同一个 AgentMessage,转给三家就是三种不同的形状。"
收益:屏蔽外部形状
"适配器的收益就一句话:上层只管 AgentMessage,永远不用碰厂商格式。"老z说,"循环发出去的是 AgentMessage,收回来的也基于 AgentMessage 处理。厂商格式的复杂性被关在了 transform-messages 和对应的 adapter 里。换一家厂商,上层代码一行不用改。"
小a:"那损失了什么?"
代价:可能丢失厂商专有信息
"这就是适配器最隐蔽的代价。"老z说,"AgentMessage 是一个公共子集——它定义了所有厂商都支持的字段。但如果某家厂商有一个独有的字段,比如 OpenAI 的 annotations 或 Anthropic 的 citations,它们在转成 AgentMessage 时可能被丢弃,因为 AgentMessage 没有对应字段。"
"这就像翻译——"小a说,"中文的'缘分'翻成英文,怎么翻都丢东西。"
"对。适配器保证的是兼容性,不是无损性。"老z说,"你用了适配器,就必须接受信息损失。如果你的产品强依赖某个厂商的专有字段,那这个字段不能经过适配器——你得在适配器之外单独处理它。把'适配器能处理一切'当成前提,就会在某个版本升级后发现专有字段悄悄消失了。"
反面论证:双向适配的不对称性
老z又指出了一个常见误区:"transform-messages 是单向的——从 AgentMessage 转成厂商格式。但回来的时候(厂商响应转成 AgentMessage)是另一个函数。有人以为适配器是双向对称的,把转出去的函数也用来转回来,结果消息形状对不上。适配器的方向性是契约的一部分——转出和转入是两个独立的适配过程,各自有自己的信息损失。"
跨章回扣
适配器在源码篇第20章:第20章 先开票,后办事——pi-ai被详细跟读——那一章展示了 Anthropic 的 content_block_start / content_block_delta / content_block_stop 事件,怎么经过 adapter 变成统一的 text / tool_call / done 事件。如果你在那里对"事件格式为什么这么乱"感到困惑,回来这里看适配器的定义:乱的不是适配器,是各家厂商的原生格式;适配器的工作就是把这堆乱收拢成秩序。
模式 ④:Agent.subscribe —— 观察者(带时序约束)
模式定义
观察者模式(Observer)的核心是:一个对象维护一组依赖者,状态变化时通知它们。 经典实现是 addListener / notify——被观察者不知道也不关心监听者具体是谁,只负责广播。但 pi-mono 的 Agent.subscribe 有一个关键的不标准之处:**它等待监听器 settle。**这让它的观察者不是松耦合的,而是带时序约束的。
源码里的真实代码
小a已经画过调用关系图了,但老z让他回到源码再看一遍。packages/agent/src/agent.ts 第 243 行:
ts
private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise<void> | void>();
subscribe(listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}"看监听器的签名。"老z指着 (event: AgentEvent, signal: AbortSignal) => Promise<void> | void,"它可以返回 Promise——这意味着 Agent 会等待它 settle。这是和教科书观察者最大的不同。"
老z翻到第 523 行的 processEvents:
ts
private async processEvents(event: AgentEvent): Promise<void> {
switch (event.type) {
case "message_start":
this._state.streamingMessage = event.message;
break;
// ... 更新状态 ...
}
// 状态更新后,await 所有 listeners
await Promise.all([...this.listeners].map((listener) => listener(event, signal)));
}"看到那个 await Promise.all 了吗?"老z说,"Agent 在通知监听器后,会等它们全部 settle 才继续推进下一个事件。这就是时序约束——如果某个监听器(比如写 session 的 Harness)还没写完,loop 就不会发下一个事件。"
收益:UI 与循环解耦
"标准观察者的收益它仍然有——"老z说,"TUI 不需要直接调用 loop 的内部方法,只需要订阅事件。循环不需要知道 UI 怎么渲染,只需要广播事件。这确实是解耦。"
小a:"那它跟标准观察者到底差在哪?"
代价:异步事件链更难追踪
"差在时序依赖。"老z说,"标准观察者是'发了就不管'——fire and forget。但 pi-mono 的观察者是'发了要等'。这意味着一个监听器的性能问题会拖慢整个循环——如果 session 写入监听器每次花 200 毫秒,那每个事件都得多等 200 毫秒。更麻烦的是调试:一个 bug 可能藏在事件到达顺序里,而不是任何单个函数里。"
老z画了一个时序图:
text
loop 产生 message_end 事件
→ processEvents 更新内部状态
→ await listener A (Harness 写 session) ← 如果这里慢,loop 卡住
→ await listener B (TUI 刷新)
→ 所有 settle 后,loop 才推进到下一个事件"你要追的不是'谁调用了谁',而是**'事件按什么顺序被谁处理,哪个监听器卡住了'。这是异步事件链特有的调试难度——堆栈告诉你错误在哪,但时序告诉你为什么会到那里**。"
反面论证:误判后果
老z讲了一个他踩过的坑:"我早期以为监听器是 fire-and-forget,就在监听器里做了一个耗时的网络调用(上报 metrics)。结果整个 Agent 变慢了——因为 loop 在等我的监听器 settle。我以为我在旁路观察,实际上我在主路径上挂了一个阻塞点。"
教训:
subscribe的名字暗示'旁路观察',但await Promise.all让它变成了'主路径同步点'。 如果你需要真正的 fire-and-forget(比如日志上报),你不能直接在监听器里做耗时操作——要么用 fire-and-forget 的 promise(不 await),要么放到独立队列。把带时序约束的观察者当成标准观察者用,就是最典型的模式误判。
这个模式为什么不完全叫"观察者"
小a:"那它到底算不算观察者?"
"算,但要加限定词。"老z说,"它满足观察者的核心定义——一组依赖者,状态变化时通知。但它有一个标准观察者没有的约束——等待 settle。所以更准确的叫法是带时序约束的观察者或同步观察者。如果你只叫它'观察者'而不说约束,后来者就会以为它 fire-and-forget——然后踩我踩过的坑。"
对比矩阵:标准观察者 vs pi-mono 的观察者
| 维度 | GoF 标准观察者 | pi-mono 的 Agent.subscribe |
|---|---|---|
| 通知方式 | fire and forget | await 所有监听器 settle |
| 监听器阻塞影响 | 不影响被观察者 | 阻塞整个循环 |
| 调试方式 | 追调用栈 | 追事件时序 |
| 典型误判 | 监听器抛异常被吞 | 以为旁路实际在主路径 |
| 适合的场景 | UI 刷新、日志 | 需要保证监听器完成后才推进的场景 |
跨章回扣
这个模式在源码篇第21章:第21章 双层循环——pi-agent-core和第22章:第22章 调度台——pi-agent-core被跟读——21 章看 loop 怎么产生事件、processEvents 怎么通知,22 章看 Harness 怎么作为监听器写 session。如果你在 22 章对"Harness 怎么知道何时写 session"感到困惑,回来这里看观察者的时序约束——答案就在 await Promise.all。
模式 ⑤:resolveProviderAuth —— 责任链
模式定义
责任链模式(Chain of Responsibility)的核心是:让多个处理者排成一条链,请求沿着链传递,每个处理者决定自己处理还是传给下一个。 经典例子是审批流程——金额小于 1000 的经理批,1000 到 10000 的总监批,超过 10000 的 CEO 批。在 pi-mono 里,认证解析就是一条责任链:显式覆盖 → 存储的凭据 → 环境变量,每一级如果能解析出凭据就返回,不能就交给下一级。
源码里的真实代码
老z让小a看 packages/ai/src/auth/resolve.ts 第 48 行:
ts
export async function resolveProviderAuth(
provider: { id: string; auth: ProviderAuth },
credentials: CredentialStore,
authContext: AuthContext,
overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext;
// 第一级:显式 API key 覆盖
if (overrides?.apiKey !== undefined && provider.auth.apiKey) {
return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, {
type: "api_key", key: overrides.apiKey, env: overrides.env,
});
}
// 第二级:存储的凭据(OAuth 或 API key)
const stored = await readCredential(credentials, provider.id);
if (stored) {
if (stored.type === "oauth" && provider.auth.oauth) {
return resolveStoredOAuth(/* ... */);
}
if (stored.type === "api_key" && provider.auth.apiKey) {
return resolveApiKey(/* ... */);
}
return undefined;
}
// 第三级:环境变量(ambient)
return provider.auth.apiKey
? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined)
: undefined;
}"这条链有三节。"老z逐一指过去:
- 显式覆盖——如果调用方传了
overrides.apiKey,直接用它,不看后面的。- 存储的凭据——从凭据存储里读这个 Provider 的记录。如果找到 OAuth 记录,走 OAuth 刷新;如果找到 API key 记录,直接用。
- 环境变量——前两级都没找到时,尝试从环境变量解析。
"每一级的逻辑是:**如果我能解析出凭据,就返回;如果不能,让代码继续往下走到下一级。**这就是责任链——不是 if/else 互斥选择,而是有序的降级链。"
收益:表达凭据优先级
"责任链最大的好处是优先级是隐含在代码顺序里的。"老z说,"你读这段代码,从上到下就是优先级从高到低:显式覆盖最优先,存储的凭据其次,环境变量兜底。不需要看文档,不需要看配置文件,代码本身就告诉你优先级。"
小a:"那如果我想加一级呢?比如从密钥管理服务拉凭据?"
"在存储的凭据和环境变量之间,加一个 if——先尝试密钥服务,找不到再走环境变量。这就是责任链的可扩展性——加一级只改一个地方,不影响其他级。"
代价:诊断必须说明走到哪一步
"但责任链有一个固有代价——出错时,你要知道链走到了哪一步。"老z说,"如果用户报告'我的 Agent 连不上模型',问题可能在任何一级:显式覆盖的 key 写错了、存储的 OAuth token 过期了、环境变量没设。如果你只告诉用户'认证失败',他就无从下手。"
小a:"所以诊断信息很重要?"
"不是重要,是必须。"老z说,"责任链的每一级都应该在日志或诊断里说明'我尝试了什么、为什么没成功'。比如'尝试了显式 apiKey 覆盖:key 格式不合法'、'尝试了存储凭据:未找到 provider anthropic 的记录'、'尝试了环境变量:ANTHROPIC_API_KEY 未设置'。没有这些信息,责任链就变成了黑箱——用户只知道'没认证成功',但不知道是哪一级失败了。"
反面论证:把责任链当 if/else
老z讲了一个反面案例:"有个团队把 resolveProviderAuth 当成普通的 if/else 理解,于是在外面又包了一层:'如果环境变量有 key,就不调 resolveProviderAuth 了'。结果他们的外部检查和责任链的第一级逻辑重复了——用户传了显式覆盖,但外部检查先看到了环境变量,直接用了,绕过了优先级更高的显式覆盖。责任链的价值在于'让链自己管理优先级',你在链外面加判断,就破坏了链的优先级语义。"
教训:责任链的优先级是链内部的顺序,不是外部可以覆盖的。 如果你需要改优先级,改链内部的顺序,而不是在外面加判断。在外面加判断,等于平行建立了两条优先级链——它们一旦冲突,行为就不可预测。
跨章回扣
这个模式在源码篇第19章:第19章 目录与户口——pi-ai被提及——那一章讲 ModelsImpl.applyAuth 时,提到了认证合并调用了 resolveProviderAuth。如果你在 19 章对"认证怎么从多个来源合并"感到困惑,回来这里看责任链的三级结构——每一级就是一个来源,优先级在代码顺序里。
模式 ⑥:KeyedOperationQueue —— 按 key 串行队列
模式定义
这不是一个标准的 GoF 模式,但它解决的问题是经典的并发控制:对同一个 key 的操作必须串行执行,不同 key 的操作可以并发。 它介于"全局锁"(太粗,所有操作排队)和"无锁"(太危险,同 key 操作会冲突)之间。可以把它理解为按资源分组的轻量级事务序列化器。
源码里的真实代码
老z让小a看 packages/agent/src/harness/session/keyed-operation-queue.ts:
ts
export class KeyedOperationQueue<TKey> {
private readonly tails = new Map<TKey, Promise<void>>();
private readonly maxConcurrentOperations: number | undefined;
private activeOperations = 0;
private barrier: Promise<void> = Promise.resolve();
enqueue<T>(key: TKey, operation: () => Promise<T> | T): Promise<T> {
const previous = this.tails.get(key) ?? Promise.resolve();
const result = Promise.all([this.barrier, previous]).then(() => this.runOperation(operation));
const tail = result.then(() => undefined, () => undefined);
this.tails.set(key, tail);
void tail.then(() => {
if (this.tails.get(key) === tail) this.tails.delete(key);
});
return result;
}
}小a盯着这段代码看了很久。"它在干嘛?"
"它在做一件很巧妙的事。"老z一行行解释:
previous = this.tails.get(key)——拿到这个 key 当前排队的最后一个 Promise("尾巴")。Promise.all([this.barrier, previous])——等全局屏障和这个 key 的前序操作都完成。.then(() => this.runOperation(operation))——然后才执行新操作。this.tails.set(key, tail)——把新操作的"尾巴"记下来,给后续同 key 的操作等。tail.then(() => { if (this.tails.get(key) === tail) this.tails.delete(key); })——操作完成后清理"尾巴",防止 Map 无限增长。
"**关键是:同 key 的操作通过 previous 串成一条链——后一个等前一个。不同 key 的 previous 互不相干——它们可以并发。**这就是按 key 串行的全部奥秘。"
收益:同 key 有序,不同 key 并发
"这个收益在 JSONL 会话写入时特别重要。"老z说,"一个会话的多个写入操作共享同一个文件路径(同一个 key),它们必须串行——否则后写的可能覆盖先写的。但不同会话的写入(不同 key)可以并发——它们写的是不同文件,互不干扰。KeyedOperationQueue 正好实现了这个:同路径排队,跨路径并行。"
代价:不是完整事务或命令模式
"但它有几个代价你要知道。"老z说:
- 不是事务——如果操作 A 和操作 B 在同一个 key 上排队,A 成功了 B 失败了,B 不会回滚 A。队列保证的是执行顺序,不是原子性。
- 不是命令模式——队列里存的是 Promise,不是可撤销的命令对象。你无法"回放"或"撤销"已经执行的操作。
- 内存泄漏风险——如果同 key 的操作永远在排队(tail 永远不 settle),Map 里的 entry 就不会被清理。代码里的清理逻辑依赖
tail最终 settle,但如果 operation 永远 hang 住,清理就不会发生。
小a:"那 maxConcurrentOperations 是干什么的?"
"全局并发上限。"老z说,"即使不同 key 可以并发,也不能让 1000 个 key 同时执行——那会打爆资源。maxConcurrentOperations 通过 acquirePermit / releasePermit 控制全局同时运行的操作数。它是 key 级串行和全局并发上限的组合——两个维度的控制叠加在一起。"
反面论证:把串行当安全
老z讲了一个案例:"有人以为'同 key 串行'等于'写入安全',于是不做文件锁,直接靠队列保证两个进程写同一个 JSONL 不会冲突。但他们忘了——队列只在单进程内有效。两个独立的 pi 进程各自有自己的 KeyedOperationQueue 实例,它们的'同 key 串行'互相不可见。两个进程同时追加同一个 JSONL 文件,队列帮不了你——你需要文件锁或单写者模式。"
教训:队列是进程内的并发控制,不是跨进程的。
KeyedOperationQueue保证的是同一个 JS 进程里同 key 操作的顺序,它不知道也不管别的进程在做什么。把进程内的串行当跨进程安全,是并发 bug 最常见的来源之一。
跨章回扣
这个模式在源码篇第22章:第22章 调度台——pi-agent-core被提及——JSONL store 用 KeyedOperationQueue 控制会话写入。如果你在 22 章对"JSONL 追加会不会冲突"感到困惑,回来这里看队列的串行机制——同会话串行,跨会话并行。
模式 ⑦:withFileMutationQueue —— 资源级并发控制
模式定义
这个模式和 ⑥ 解决的问题是同源的——对同一个资源的操作必须串行。但它有两个关键差异:第一,它的 key 不是简单的字符串,而是文件的 realpath(解析后的真实路径);第二,它不需要显式创建一个队列对象,而是用模块级的 Map 自动管理。可以把它理解为**"同路径写入排队"的轻量锁**。
源码里的真实代码
小a打开 packages/coding-agent/src/core/tools/file-mutation-queue.ts,第 32 行:
ts
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
const registration = registrationQueue.then(async () => {
const key = await getMutationQueueKey(filePath);
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
let releaseNext!: () => void;
const nextQueue = new Promise<void>((resolveQueue) => { releaseNext = resolveQueue; });
const chainedQueue = currentQueue.then(() => nextQueue);
fileMutationQueues.set(key, chainedQueue);
return { key, currentQueue, chainedQueue, releaseNext };
});
// ...
await currentQueue;
try {
return await fn();
} finally {
releaseNext();
if (fileMutationQueues.get(key) === chainedQueue) {
fileMutationQueues.delete(key);
}
}
}"看 getMutationQueueKey 先。"老z指着第 19 行:
ts
async function getMutationQueueKey(filePath: string): Promise<string> {
const resolvedPath = resolve(filePath);
try {
return await realpath(resolvedPath);
} catch (error) {
if (isMissingPathError(error)) return resolvedPath;
throw error;
}
}"它先做 resolve(把相对路径变成绝对路径),再做 realpath(解析符号链接,得到真实路径)。**这是为了两个看起来不同、实际指向同一个文件的路径,能映射到同一个 key。**比如 ./a.ts 和 /workspace/a.ts 和 /workspace/link-to-a.ts(如果 link-to-a 是符号链接),这三个路径 realpath 后都是同一个真实路径——它们会被排队。"
小a:"那 finally 里的 releaseNext 和 delete 是干嘛的?"
"两个都是防止卡死和泄漏的关键。"老z说:
releaseNext()——nextQueue是一个手动控制的 Promise,releaseNext是它的 resolve 函数。执行完fn后调用releaseNext(),后续排队的操作才能开始。如果这里不放行,队列就会永久堵塞——这是finally而不是return里调用的原因,保证fn无论成功还是抛错都放行。delete——清理fileMutationQueues里的 entry。注意它检查fileMutationQueues.get(key) === chainedQueue——只有当这个 queue 还是链上最新的那个时才删,防止误删了后来注册的 queue。
收益:同路径写入串行,跨路径并行
"这个收益和 ⑥ 一样,但用在了工具层。"老z说,"Agent 可能同时发出两个写文件的工具调用——一个写 config.ts,一个写 README.md。withFileMutationQueue 保证它们对各自文件的写入串行(先写完整再写下一个),但两个文件之间并行。工具层不用为此设计复杂的锁协议,包一层函数就行。"
代价:不保护外部服务副作用
"但它有几个边界你要清楚。"老z说:
- 它只保护文件写入,不保护外部服务副作用。 如果 Agent 同时发两个 HTTP 请求(比如 POST 同一个 API),
withFileMutationQueue帮不了你——它只认识文件路径,不认识 URL。- 它是进程内的。 和 ⑥ 一样,两个独立的进程各自有自己的
fileMutationQueuesMap,跨进程写同一个文件仍然会冲突。- realpath 解析本身有代价。 每次调用都要做文件系统 I/O(
realpath),对高频小文件写入来说是额外开销。
反面论证:把"同路径串行"当"写入安全"
老z讲了一个反面案例:"有人看到这个函数名,以为'文件写入都排队了,很安全'。结果他们把外部工具的副作用(比如调一个外部 lint 服务)也包进来——但那个服务的状态不是文件系统能感知的。两个 lint 请求仍然并发发出。队列只对 filePath 生效,不对'这个文件相关的所有副作用'生效。"
教训:队列的边界是 key 的粒度,不是业务的粒度。
withFileMutationQueue(filePath, fn)排队的是对filePath的写入,不是fn的所有副作用。把队列当成"这段逻辑的整体锁",就会漏掉非文件的副作用。
对比矩阵:⑥ ⑦ 的区别
| 维度 | ⑥ KeyedOperationQueue | ⑦ withFileMutationQueue |
|---|---|---|
| 使用方式 | 显式创建队列对象 | 模块级 Map,函数式调用 |
| key 类型 | 调用方传入的任意 TKey | 文件路径(realpath 归一化) |
| 资源感知 | 不感知,纯逻辑串行 | 感知文件系统(symlink 解析) |
| 适用层 | 会话写入(pi-agent-core) | 工具写入(coding-agent) |
| 全局并发上限 | 有(maxConcurrentOperations) | 无 |
跨章回扣
这个模式在源码篇第25章:第25章 收银台排队——pi-coding-agent被详细跟读——那一章分析了 withFileMutationQueue 的 finally 释放、key 归一化和失败边界。如果你在 25 章对"两个写请求会不会互相覆盖"感到困惑,回来这里看完整的串行机制。
模式 ⑧:ValidatedMessageDecoder —— 管道/守卫
模式定义
管道模式(Pipeline)的核心是:把一个输入依次经过多个阶段处理,每个阶段做一件事,前一个的输出是后一个的输入。 在 pi-mono 的协议层,解码就是一个四段管道:FrameDecoder 切出完整帧 → decodeCbor 解码成 unknown → parseClientMessage 做 schema 校验 → 产出消息。ValidatedMessageDecoder 是这条管道的调度器,同时是一个守卫——任何一段失败,整条管道失效。
源码里的真实代码
小a打开 packages/protocol/src/codec.ts,第 88 行:
ts
class ValidatedMessageDecoder<T> {
private failed = false;
private readonly frames: FrameDecoder;
push(chunk: Uint8Array): T[] {
if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
try {
const messages: T[] = [];
for (const frame of this.frames.push(chunk)) {
messages.push(this.parse(decodeCbor(frame, { maxByteLength: this.maxFrameLength })));
}
return messages;
} catch (error) {
this.failed = true;
if (error instanceof ProtocolValidationError) throw error;
throw new ProtocolValidationError(`Invalid ${this.kind} protocol frame: ${boundedErrorMessage(error)}`);
}
}
end(): void {
if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
try {
this.frames.end();
} catch (error) {
this.failed = true;
throw new ProtocolValidationError(`Invalid ${this.kind} protocol framing: ...`);
}
}
}"看 push 里的循环。"老z说,"this.frames.push(chunk) 返回一批完整的帧,然后每个帧依次走 decodeCbor → this.parse。这是一个 chunk 可能包含多条消息的原因——一个网络 chunk 可能带着好几个完整的帧。"
小a:"那 failed 标志是干嘛的?"
"这就是守卫的核心。"老z说,"任何一段管道失败(坏帧、坏 CBOR、schema 不匹配),catch 把 failed 置为 true。之后所有 push 和 end 调用,第一行就抛 decoder has failed。失败不是可恢复的——一旦管道被污染,你必须重建 decoder,而不是继续喂字节。"
收益:分层验证,边界清晰
"管道的收益是每一层只做一件事,错误定位清晰。"老z说:
FrameDecoder只负责"从字节流切出完整帧"——它不关心帧内容是 CBOR 还是 JSON。decodeCbor只负责"把帧解码成 unknown 值"——它不关心这个值合不合法。parseClientMessage只负责"验证 unknown 是不是合法的 ClientMessage"——它不关心字节是怎么来的。
"一个坏消息进来,你从报错信息就能看出是哪一段失败:Invalid client protocol frame 是 frame 层,Invalid client protocol message 是 schema 层。这就是管道的调试优势——失败分段,定位也分段。"
代价:失败后必须重建 decoder
"但代价是失败是永久的。"老z说,"failed 一旦置 true,这个 decoder 就废了。你不能'跳过坏帧继续读'——因为一旦帧边界不可信,后面所有帧的边界都不可信。这是用更严格的失败语义换取更清楚的边界。"
小a:"那代价太狠了吧?一个坏字节就要重建?"
"对,但这恰恰是协议安全的设计。"老z说,"如果 decoder 能'跳过坏帧',恶意攻击者就能构造一个半坏的流,让 decoder 在不可信的状态下继续解析——这正是协议解析器最常见的漏洞来源。**宁可整体重建,也不在不可信边界上猜测恢复。**这在安全上叫 fail-closed(失败即关闭),而不是 fail-open(失败仍继续)。"
反面论证:把"失败后重建"当"重试一次就好"
老z讲了一个反面案例:"有人写了个重试逻辑:decoder 报错就 new 一个新的继续读。但他们忘了——**连接层的握手状态、协议版本协商、会话附着关系,全都建立在同一个 decoder 之上。**重建 decoder 不等于重建连接。如果你只重建 decoder 而不重建连接,新 decoder 会读到一个从中间开始的字节流——帧边界全是错的。decoder 失败的正确响应是重建整个协议状态(包括握手),不是只换 decoder。"
教训:管道失败的单位是整个协议会话,不是单个解码器。
ValidatedMessageDecoder的failed是连接级状态的一部分。把它当单次重试的触发器,就是只修了管道的一端,没修整条链路。
跨章回扣
这个模式在源码篇第26章:第26章 发货与报关——pi-protocol被完整跟读——那一章展示了 chunk → FrameDecoder → decodeCbor → schema 校验的完整链路,以及 failed 永久失效的语义。如果你在 26 章对"为什么一个坏帧要重建整个连接"感到困惑,回来这里看管道守卫的设计逻辑。
模式 ⑨:ServerSnapshotPublisher —— 发布者(版本化状态)
模式定义
这是观察者模式在远程场景的变体——发布者(Publisher)。观察者是"状态变化通知一组监听者",发布者是"状态变化生成一个带版本号的快照,广播给订阅者"。区别在于:观察者推送的是事件本身,发布者推送的是整个当前状态 + 一个递增的版本号。这解决了观察者模式在分布式场景的一个经典问题:事件可能丢失、乱序,但版本化快照可以校正。
源码里的真实代码
小a打开 packages/server/src/snapshots.ts,第 21 行:
ts
export class ServerSnapshotPublisher {
private revision = 0;
// ...
get(): Promise<ServerSnapshot> {
return {
serverId: this.options.serverId,
protocolVersion: PROTOCOL_VERSION,
revision: this.revision,
sessions: await this.options.listSessions(connection),
models: models ?? (await this.options.backend.listModels()),
};
}
private async performBroadcast(): Promise<void> {
const revision = ++this.revision;
const snapshot: ServerSnapshot = { ...current, revision };
const envelope: EventEnvelope = { type: "event", event: { type: "server_snapshot", snapshot } };
await this.options.sendMessage(connection, envelope);
}
}"看 performBroadcast。"老z说,"每次广播,revision 先自增,然后把整个快照连同新 revision 一起发出去。接收方拿到快照时,同时拿到了一个单调递增的版本号——它可以用这个版本号判断'我手上的状态是不是最新的'。"
收益:让 client 校正状态
"这个收益在远程场景特别重要。"老z说,"远程连接会丢事件、会乱序。假设客户端先收到 revision 5 的快照,又收到 revision 3 的快照——它应该用 3 覆盖 5 吗?不应该,因为 5 比 3 新。版本号让客户端能判断'哪个状态更新',从而校正掉乱序的旧快照。"
小a:"那 progress 呢?"
"progress 是另一类消息——它是增量活动,不是完整状态。"老z说,"schemas.ts 明确写了一句:progress 是 'normalized incremental activity',而 snapshot 才是权威状态。客户端应该把 progress 当作界面及时性信号('正在发生什么'),而用较新的 snapshot 校正完整状态('现在到底是什么状态')。这两者分工明确:progress 给你实时感,snapshot 给你权威性。"
代价:revision 非强一致事务
"但发布者的代价是——revision 只是广播队列的观察序号,不是跨网络分区的事务保证。"老z说,"如果你以为 revision 是事务序号,那你会犯一个致命错误:断线重连后,收到一个 revision 更高的 snapshot,就以为所有之前的操作都提交了。不对。 revision 只证明'服务端广播了多少次',不证明'每条命令都执行成功且被包含在快照里'。"
小a:"那到底能保证什么?"
"能保证观察一致性(你看到的状态不会倒退),不能保证操作语义(每条命令都恰好执行一次)。"老z说,"要保证后者,你需要业务级的 request ID、幂等设计或人工确认——这些都不在 snapshot publisher 的职责里。"
反面论证:把"revision 更高"当"命令已提交"
老z讲了一个反面案例:"有个客户端团队在断线重连后,看到 revision 从 10 跳到 12,就认为两次操作都成功了。结果发现其中一次操作在服务端执行到一半就断了——revision 是 12,但那个操作的部分副作用可能没进快照。**revision 是广播计数,不是事务提交号。**把两者混为一谈,就会在恢复时做出错误的业务决策(比如以为扣款成功而发货)。"
教训:版本号解决的是"看到的状态新不新",不是"做的事成没成"。 观察一致性(乐观显示)和操作语义(确定提交)是两件不同的事。发布者模式保证前者,不保证后者。
对比矩阵:观察者 vs 发布者
小a把 ④ 和 ⑨ 放在一起对比,老z给他画了张表:
| 维度 | ④ 观察者 Agent.subscribe | ⑨ 发布者 ServerSnapshotPublisher |
|---|---|---|
| 推送内容 | 单个事件 | 完整状态快照 + 版本号 |
| 消费场景 | 单进程内(loop → UI) | 跨网络(server → client) |
| 乱序问题 | 事件顺序由 await 保证 | 网络可能乱序,靠 revision 校正 |
| 失败恢复 | 监听器失败影响 loop | decoder/连接失败需重建 |
| 核心承诺 | 事件被处理 | 状态可被校正 |
"**观察者适合进程内、你信任事件顺序的场景;发布者适合跨网络、你必须防乱序的场景。**两者的核心区别不是'谁推送',而是'推送的是事件还是状态'。事件有生命周期(发生了就过去了),状态有版本(新的覆盖旧的)。"
跨章回扣
这个模式在源码篇第27章:第27章 黑板与便签——pi-server+pi-client被详细跟读——那一章展示了 performBroadcast 的 revision 自增、snapshot vs progress 的分工、以及断线后"重新连接和重新附着"的真实边界。如果你在 27 章对"重连后能不能信 snapshot"感到困惑,回来这里看发布者的版本化设计——revision 能校正显示,不能证明提交。
九个模式之外:别犯的命名错误
九个个案讲完了,但还有一类错误比"误判某个模式"更普遍——过度命名。小a把这些错误整理成一张清单,老z逐条点评。
过度命名陷阱全集
- 别把 TypeScript 接口一律称为模板方法——模板方法要求父类定义算法骨架、子类重写步骤;一个普通的
interface只是契约,没有算法骨架。- 别因模块名含
proxy就断言其实现完整 GoF 代理——代理模式要求访问控制(延迟加载、权限检查、远程代理);"中间多了一层"不等于代理。- 别把任何
if/switch都叫策略模式——策略要求一组可互换的算法实现同一接口;单纯的条件分支只是逻辑。- 别把回调称为命令模式——命令模式要求封装请求为对象(支持撤销、排队、日志);回调只是函数。
- 别把任何
Map都叫缓存模式——缓存模式要求有失效策略(TTL、LRU、容量上限);一个无限增长的 Map 是内存泄漏,不是缓存。- 别把任何队列都叫生产者消费者模式——该模式要求生产者和消费者解耦、速率不同步;一个简单的 FIFO 队列只是数据结构。
- 别把任何单例都叫单例模式——单例模式要求全局唯一 + 全局访问点 + 延迟初始化;一个模块级
const只是模块作用域。
小a看着清单问:"那这些错误有没有一个共同根源?"
"有。"老z说,"**它们的共同根源是'用一个名字代替理解'。**看到 proxy 就以为有代理的访问控制,看到 subscribe 就以为 fire-and-forget,看到 Map 就以为有缓存失效——名字给了你一个预期,但你跳过了验证预期的步骤。"
"那正确姿势是什么?"小a问。
正确的命名三步
- 先画事实——写出调用关系和状态转移。谁调谁、传什么、异常怎么走、状态何时变。
- 再选名字——从模式库里选一个不会夸大事实的名称。不满足模式核心约束的,宁可不命名。
- 后核代价——对照教科书里的已知代价,检查代码是否承担了这些代价。没被处理的代价要么是 bug,要么是模式不完全适用。
"三步都做完,模式名才是有用的;跳过任何一步,它就是装饰。"老z说,"你在 pi-mono 里走查了九个模式——工厂、策略、适配器、观察者、责任链、串行队列、并发控制、管道、发布者——每一个都有真实的代码、真实的收益、真实的代价。这些不是标签,是你理解代码的脚手架。"
收尾:模式是脚手架,不是终点
小a合上笔记本,长长舒了一口气:"所以模式到底有什么用?"
老z把九张对比矩阵叠在一起,推到他面前:"**模式是阅读源码的脚手架。**脚手架帮你搭起理解——看到 subscribe 你知道往观察者方向想,看到 createProvider 你知道往工厂方向想。但脚手架不是房子本身。你最终要验证的是代码的真实行为和代价。"
回到全书目标
模式不是装饰代码的标签,而是理解代码的起点。在 pi-mono 里走查这九个模式,你收获的不只是"这段代码像观察者",而是:
- 每个模式都有一个真实的代价列——观察者的时序约束、适配器的信息损失、责任链的诊断要求、管道的 fail-closed、发布者的非事务性。
- 每个代价都对应一个反面案例——把旁路观察当主路径、把进程内队列当跨进程锁、把 revision 当提交号。
- 每个模式都有跨章回扣——17/20 章看工厂和策略,19/22 章看观察者和队列,25 章看并发控制,26 章看管道,27 章看发布者。
"下次你再看一个陌生代码库,"老z说,"试着用这三步:先画调用关系,再问'它像哪个模式',最后核对代价列。你会发现——**代码不再是散的,而是一组带代价的决策。**这就是设计模式在工程里的真实用途:不是让你贴标签,而是让你看懂每个决策背后的权衡。"