源码设计索引:反复出现的设计模式

小A在代码走查时指着一处结构,很自信地说:“这是观察者模式吧?模块名叫 subscribe,肯定是。”
老Z没反驳,只让他先画出调用关系。
小A画完,发现自己说早了:“subscribe”确实存在,可事件怎么发、谁来收、异常怎么传,不画出来根本说不清。他画的图里,Agent.subscribe 注册的监听器有写会话的,也有刷新界面的,按注册先后一个接一个被调用;一个监听器返回的 Promise 没有 settle 之前,下一个监听器不会被调用,loop 也不会推进到下一个事件。这哪是一个干净利落的“观察者”,分明是一条带时序依赖的异步事件分发链。
“模式是理解工具,不是贴标签。”老Z说,“除非注释或命名明确表明,否则以下均写作‘可用某模式理解’。一个结构能不能叫某模式,不取决于它像不像教科书的图,而取决于它的收益和代价是否与该模式一致。本附录服务 Part II,在阅读源码后、需要命名某个结构和权衡时查阅。”
本附录导读: 它不是 GoF 模式教程,而是从
pi@v1.0.0(提交a13d35a7)的真实代码里挑出九处可以用经典设计模式理解的结构,逐一拆解:模式定义是什么、源码里长什么样、用了它换来什么、付出了什么代价、误判它会导致什么后果、它和概念篇/源码篇的哪一章呼应。每个模式都配真实代码节选和反面论证。
模式是理解工具,不是贴标签
老Z先纠正了小A一个误区:“你是不是觉得,能叫出模式名就等于理解了代码?”
小A点头。
“反了。”老Z说,“模式名只是理解的起点。叫它‘观察者’,只说明这里有个发布订阅;真正的价值在后面三个问题:谁订阅、订阅后能改什么、异常怎么传播。这三个问题答不上来,模式名就是装饰。”
为什么不能倒过来:先命名后理解
小A不服气:“我先把名字定下来,再去看细节,不行吗?名字先给我一个锚点。”
“锚点会变成锚,把你钉死在错误的预期上。”老Z说,“你看到 subscribe 就叫‘观察者’,于是你预期它是‘松耦合、互不干扰’的。等你发现监听器的执行顺序会影响会话写入的时序,预期和现实就打架了。这时候你会怎么做?要么强行把现实塞进‘观察者’的框里,说一句‘它只是个不太标准的观察者’;要么怀疑自己看错了代码。两种反应都让你离真相更远。”
老Z在纸上画了一条线:“正确的顺序是:先画事实,再选名字,最后核代价。”
判断顺序
- 画事实:写出调用关系和状态转移。谁调谁、传什么、异常怎么走、状态何时变。这一步逼你看清代码到底在做什么,而不是你觉得它在做什么。
- 选名字:从 GoF 二十三个模式里选一个不会夸大事实的名称。判断标准不是“像不像 UML 图”,而是“这个模式的核心约束,代码是否真的满足”。代码不满足模式的核心约束,宁可不命名,也不要硬贴。
- 核代价:对照教科书里该模式的已知代价,逐一检查代码是否承担了这些代价。某个代价在代码里没有被处理,那要么是 bug,要么是这个模式在这里并不完全适用,两种情况都要标注。
“三步都做完,模式名才是有用的。”老Z说,“跳过第一步直接命名,你会把想象当事实;跳过第三步只看收益不看代价,你会把模式当成银弹。”
代价列才是契约
小A追问:“那代价列为什么那么重要?收益不才是用模式的理由吗?”
“收益告诉你为什么选它,代价告诉你它承诺了什么。”老Z说,“发布者模式在这个例子里的目标是让订阅者收到更新;但 pi-mono 的服务订阅只反映服务端某一刻的状态,不保证跨网络分区的强一致,也不保证 exactly-once。只看收益‘让 client 从完整状态开始’,你会以为它保证一致性;代价列告诉你它不能。模式名是入口,代价列才是契约。”
这个原则贯穿整个附录。下面九个模式,每个都同时列收益和代价。代价不是缺点,是使用这个模式时必须承担的真实成本:不愿意承担某个代价,就不应该用这个模式,而不是用了它再假装代价不存在。
九个模式一览
先给一张总览表,后面逐个展开,每个模式都有完整的案例推演。
| # | 结构 | 可用何种模式理解 | 源码位置 | 核心收益 | 核心代价 |
|---|---|---|---|---|---|
| ① | createProvider | 简单工厂 | packages/ai/src/models.ts:1034 | 集中分派 Provider 创建 | 适配层差异随 Provider 增长 |
| ② | Provider 接口 | 策略 | packages/ai/src/models.ts:144 | 上层统一调用流入口 | 各策略错误语义必须一致 |
| ③ | 各 API 的 convert*Messages | 适配器 | packages/ai/src/api/openai-responses-shared.ts:145 等 | 屏蔽厂商消息形状 | 可能丢失厂商专有信息 |
| ④ | Agent.subscribe | 观察者(带时序约束) | packages/agent/src/agent.ts:266 | UI 与循环解耦 | 异步事件链更难追踪 |
| ⑤ | resolveProviderAuth | 责任链 | packages/ai/src/auth/resolve.ts:33 | 表达凭据优先级 | 诊断必须说明走到哪一步 |
| ⑥ | SessionRouter.runForClient | 按 key 串行队列 | packages/server/src/session-router.ts:146 | 同一连接的会话操作有序 | 不是完整事务或命令模式 |
| ⑦ | withFileMutationQueue | 资源级并发控制 | packages/coding-agent/src/core/tools/file-mutation-queue.ts:32 | 同路径写入串行 | 不保护外部服务副作用 |
| ⑧ | ValidatedMessageDecoder | 管道/守卫 | packages/protocol/src/codec.ts:65 | 分层验证字节 | 失败后必须重建 decoder |
| ⑨ | 服务订阅(handleRequest 的订阅分支) | 发布者(快照加增量) | packages/server/src/server.ts:306 | 让 client 从快照开始同步 | 快照不证明命令已提交 |
跨章回扣: 这九个模式不是孤立的:①②③⑤ 都在 pi-ai(源码篇第 19、源码篇第3章),④ 在 pi-agent-core(源码篇第4章),⑦在 coding-agent(源码篇第8章),⑧在 protocol(源码篇第9章),⑥⑨在 server(源码篇第10章)。行号均为上游
v1.0.0的行号;本书仓库pi/快照加了注释,行号会靠后。读到对应章节时,可以回来对照这个附录。
模式 ①:createProvider —— 简单工厂
模式定义
简单工厂(Simple Factory)的核心是把对象的创建逻辑集中在一个函数里,调用方只传参数,不关心具体怎么 new。GoF 的工厂方法(Factory Method)和抽象工厂(Abstract Factory)是它的进阶版,pi-mono 里用的是最朴素的形式:一个函数,根据输入返回一个配置好的对象。
源码里的真实代码
小A打开 packages/ai/src/models.ts,找到第 1034 行:
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
const single = input.api && typeof (input.api as ProviderStreams).stream === "function" ? /* 单一实现 */ : undefined;
const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
// images、classifiers 同理;三者一个都没有就 throw
// ... 合并 baseline 和 dynamic models、构建 dispatch ...
return {
id: input.id,
name: input.name ?? input.id,
auth: input.auth,
getModels: () => currentModels().filter((model) => isModelType(model, "chat")),
getAllModels: currentModels,
refreshModels: fetchModels ? async (context) => { /* 先恢复缓存,再联网刷新,经 context.publish 发布 */ } : 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 实现,v1.0.0 还可以有图像和分类器实现,外加可选的 fetchModels,接着做一串内部组装:合并 baseline 和动态 models,构建 dispatch 函数按 model 的 api 字段选择流实现,把刷新结果交给 ModelsImpl 按代号发布。最后返回一个完整的 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说。
“对,这就是工厂的收益:创建逻辑集中,调用方简单。”老Z说,“不管 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 实现是否存在。创建时的成功只证明描述对象合法,不证明流能跑通。工厂把复杂度推迟到运行时,代价就是错误延迟暴露。
跨章回扣
这个模式在源码篇源码篇第2章 目录与户口——pi-ai详细展开,那一章跟读了 createProvider 如何被 ModelsImpl 调用、返回的 Provider 如何被 requireProvider 查找。如果你读完 源码篇第2章后对“Provider 是怎么来的”还有疑问,回到这里看工厂的完整内部逻辑。
模式 ②:Provider 接口 —— 策略
模式定义
策略模式(Strategy)的核心是定义一个接口,让一组可互换的算法各自实现它,调用方不关心具体用的是哪个。GoF 原文说“封装可互换的算法族”,pi-mono 里它更像一组可互换的服务边界:每家模型厂商都是一个策略,实现同一个 Provider 接口。
源码里的真实代码
小A翻到 models.ts 第 144 行:
export interface Provider<TApi extends Api = Api> {
readonly id: string;
readonly name: string;
readonly auth: ProviderAuth;
getModels(): readonly Model<TApi>[];
getAllModels?(): readonly ProviderModel<TApi>[];
refreshModels?(context: RefreshModelsContext): Promise<void>;
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
stream<T extends TApi>(model: Model<T>, context: TranscriptContext, options?: ApiStreamOptions<T>): AssistantMessageEventStream;
streamSimple(model: Model<TApi>, context: TranscriptContext, options?: SimpleStreamOptions): AssistantMessageEventStream;
generateImages?(/* ... */): Promise<AssistantImages>;
classify?(/* ... */): Promise<ClassifierResult>;
// 另有 baseUrl、headers、fetchDeferred、cancelDeferred、filterAllModels 等,节选略
}“这就是策略接口。”老Z说,“不管你是 Anthropic、OpenAI 还是 Google,你都要实现这个接口。上层 ModelsImpl 调 provider.streamSimple(...) 时,它不关心背后是哪家厂商,只关心返回的是 AssistantMessageEventStream。”
收益:上层统一调用
“这个收益和工厂是配对的。”老Z说,“工厂负责创建策略实例,策略接口负责让上层不知道具体是哪个实例。两者合在一起,才实现了‘换 Provider 不改上层代码’。”
小A数了数问号:“refreshModels、generateImages、classify 都带问号,stream 和 streamSimple 不带。”
“对。”老Z说,“两个流入口每个策略都必须实现;刷新目录、生成图像、分类这些能力可以没有,策略之间允许有差异。策略模式的收益不是所有策略完全一样,而是它们遵守同一个最小契约,可选方法就是这个最小契约和扩展能力的平衡点。”
代价:各策略错误语义必须一致
“策略模式最隐蔽的代价在这里。”老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 时实现接口 |
| 调用方知道什么 | 知道有个工厂函数 | 不知道具体是哪个策略 |
| 错误暴露时机 | 创建时(描述对象不合法) | 运行时(流调用失败) |
| 和对方的关系 | 工厂产出策略实例 | 策略定义工厂返回的形状 |
“它们是配对的:工厂生产策略,策略定义工厂返回的形状,单独看一个都会漏掉另一半。”老Z说,“很多书把工厂方法和策略放在相邻章节,就是因为它们在实际代码里几乎总是一起出现。”
跨章回扣
策略接口在源码篇源码篇第2章和源码篇第3章 先开票,后办事——pi-ai都被跟读,源码篇第2章看 Provider 接口怎么被 ModelsImpl 委派,源码篇第3章看策略返回的 AssistantMessageEventStream 怎么穿过适配层变成统一事件。两章合起来,才是策略模式在 pi-mono 里的完整落地。
模式 ③:各 API 的 convert*Messages —— 适配器
模式定义
适配器模式(Adapter)的核心是把一个接口转换成另一个接口,让原本不兼容的类能一起工作。GoF 的经典比喻是“电源适配器”:你的笔记本要两孔插座,墙上只有三孔,适配器把三孔转换成两孔。pi-mono 里,每家模型厂商的消息格式就是不同的“插座”,每个 API 实现文件里的消息转换函数就是那个转换器。
源码里的真实代码
小A先打开 packages/ai/src/api/transform-messages.ts,第 64 行:
export function transformMessages<TApi extends Api>(
messages: Message[],
model: Model<TApi>,
normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
): Message[] {
// 降级目标模型不支持的图片、处理 thinking 块、规范化 tool call ID ……
}“先别把它当适配器。”老Z说,“它进出都是 pi-ai 自己的 Message[],做的是跨厂商的预处理:目标模型不支持图片就降级,规范化 tool call ID,因为 OpenAI Responses 的 ID 可能有四百多个字符,Anthropic 只接受 64 个字符以内的字母数字。真正把统一消息转成厂商形状的,是每个 API 文件自己的转换函数。”
他又打开 packages/ai/src/api/openai-responses-shared.ts 第 145 行:
export function convertResponsesMessages<TApi extends Api>(
model: Model<TApi>,
context: TranscriptContext,
allowedToolCallProviders: ReadonlySet<string>,
options?: ConvertResponsesMessagesOptions,
): ResponseInput {
// 先 resolveTranscript,再 transformMessages,然后逐条转成 Responses 的 input items
}“anthropic-messages.ts 里对应的是 convertMessages,输出 Anthropic 的 content blocks。”老Z说,“同一组 Message,转给 Anthropic 是 content blocks 数组,转给 OpenAI Responses 是 input items,转给 Chat Completions 是按 role 组织的消息。每个转换函数开头都先调 transformMessages 做共同的预处理。”
收益:屏蔽外部形状
“适配器的收益就一句话:上层只管统一的 Message,永远不用碰厂商格式。”老Z说,“循环把 AgentMessage 经 convertToLlm 变成 Message 发出去,收回来的也是统一的 AssistantMessage,厂商格式的复杂性被关在各个 API 文件里。换一家厂商,上层代码一行不用改。”
小A想到另一面:“那损失了什么?”
代价:可能丢失厂商专有信息
“这就是适配器最隐蔽的代价。”老Z说,“统一的 Message 是个公共子集,只定义所有厂商都支持的字段。某家厂商有独有字段,比如 OpenAI 的 annotations 或 Anthropic 的 citations,转成统一消息时可能就被丢掉了,因为统一类型里没有对应字段。”
“这就像翻译。”小A接过话,“中文的‘缘分’翻成英文,怎么翻都丢东西。”
“对,适配器保证的是兼容性,不是无损性。”老Z说,“用了适配器就得接受信息损失。产品强依赖某个厂商的专有字段,这个字段就不能经过适配器,得在适配器之外单独处理。把‘适配器能处理一切’当成前提,某个版本升级后你就会发现专有字段悄悄消失了。”
反面论证:双向适配的不对称性
老Z又指出一个常见误区:“convert*Messages 是单向的,从统一消息转成厂商格式;回来的时候,厂商的流事件转成统一的 AssistantMessage,走的是另一套代码,例如 processResponsesStream。有人以为适配器双向对称,把转出去的函数拿来转回来,结果消息形状对不上。适配器的方向性是契约的一部分:转出和转入是两个独立的适配过程,各有各的信息损失。”
跨章回扣
适配器在源码篇源码篇第3章 先开票,后办事——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 第 266 行(上游行号,下同):
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翻到第 565 行的 processEvents:
private async processEvents(event: AgentEvent): Promise<void> {
switch (event.type) {
case "message_start":
this._state.streamingMessage = event.message;
break;
// ... 更新状态 ...
}
// 状态更新后,按注册顺序逐个 await listener
for (const listener of this.listeners) {
await listener(event, signal);
}
}“看到那个 for 循环里的 await 了吗?”老Z说,“Agent 按注册顺序一个一个通知监听器,前一个 settle 了才叫下一个,全部 settle 才推进到下一个事件。这就是时序约束:写 session 的 AgentSession 还没写完,loop 就不会发下一个事件。”
收益:UI 与循环解耦
“标准观察者的收益它仍然有。”老Z说,“TUI 不需要直接调用 loop 的内部方法,订阅事件就行;循环不需要知道 UI 怎么渲染,广播事件就行。这确实是解耦。”
“那它跟标准观察者到底差在哪?”小A追问。
代价:异步事件链更难追踪
“差在时序依赖。”老Z说,“标准观察者发了就不管,fire and forget;pi-mono 的观察者发了要等。一个监听器的性能问题会拖慢整个循环,session 写入监听器每次花 200 毫秒,每个事件就得多等 200 毫秒。调试也更麻烦:一个 bug 可能藏在事件到达顺序里,而不在任何单个函数里。”
老Z把时序画了出来:
loop 产生 message_end 事件
→ processEvents 更新内部状态
→ await listener A (AgentSession 写 session) ← 如果这里慢,loop 卡住
→ await listener B (TUI 刷新)
→ 所有 settle 后,loop 才推进到下一个事件“你要追的不是谁调用了谁,而是事件按什么顺序被谁处理、哪个监听器卡住了。这是异步事件链特有的调试难度:堆栈告诉你错误在哪,时序告诉你为什么会到那里。”
反面论证:误判后果
这事老Z自己踩过:“我早期以为监听器是 fire-and-forget,就在监听器里做了一个耗时的网络调用(上报 metrics)。结果整个 Agent 变慢了,loop 在等我的监听器 settle。我以为自己在旁路观察,实际上是在主路径上挂了一个阻塞点。”
教训:
subscribe的名字暗示‘旁路观察’,逐个await却让它成了‘主路径同步点’。需要真正的 fire-and-forget(比如日志上报),就不能在监听器里直接做耗时操作:要么用不 await 的 promise,要么放到独立队列。
这个模式为什么不完全叫“观察者”
小A还是不甘心:“那它到底算不算观察者?”
“算,但要加限定词。”老Z说,“核心定义它满足,只是多了等待 settle 这个约束,更准的叫法是带时序约束的观察者,或者叫同步观察者。只叫‘观察者’不说约束,后来的人会以为它 fire-and-forget,接着踩我踩过的坑。”
对比矩阵:标准观察者 vs pi-mono 的观察者
| 维度 | GoF 标准观察者 | pi-mono 的 Agent.subscribe |
|---|---|---|
| 通知方式 | fire and forget | 按注册顺序逐个 await |
| 监听器阻塞影响 | 不影响被观察者 | 阻塞整个循环 |
| 调试方式 | 追调用栈 | 追事件时序 |
| 典型误判 | 监听器抛异常被吞 | 以为旁路实际在主路径 |
| 适合的场景 | UI 刷新、日志 | 需要保证监听器完成后才推进的场景 |
跨章回扣
这个模式在源码篇源码篇第4章 双层循环——pi-agent-core和源码篇第5章 调度台——pi-coding-agent被跟读,源码篇第4章看 loop 怎么产生事件、processEvents 怎么通知,源码篇第5章看 AgentSession 怎么作为监听器写 session。“AgentSession 怎么知道何时写 session”,答案已经在上面拆过了:就在逐个 await 的时序约束里。
模式 ⑤:resolveProviderAuth —— 责任链
模式定义
责任链模式(Chain of Responsibility)的核心是让多个处理者排成一条链,请求沿着链传递,每个处理者决定自己处理还是传给下一个。经典例子是审批流程:金额小于 1000 的经理批,1000 到 10000 的总监批,超过 10000 的 CEO 批。pi-mono 里,认证解析就是一条责任链:显式覆盖 → 存储的凭据 → 环境变量,每一级能解析出凭据就返回,不能就交给下一级。
源码里的真实代码
老Z把 packages/ai/src/auth/resolve.ts 第 33 行指给小A看。v1.0.0 的公开函数只做一件事:给解析过程套上 abort signal,再交给内部的 resolveProviderAuthWithSignal。链本身在内部函数里:
// packages/ai/src/auth/resolve.ts,resolveProviderAuthWithSignal(v1.0.0,节选;signal 参数从略)
async function resolveProviderAuthWithSignal(
provider: { id: string; auth: ProviderAuth },
credentials: CredentialStore,
authContext: AuthContext,
overrides: AuthResolutionOverrides | undefined,
signal: AbortSignal,
): Promise<AuthResult | undefined> {
signal.throwIfAborted();
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,
}, signal);
}
// 第二级:存储的凭据(OAuth 或 API key)
const stored = await readCredential(credentials, provider.id, signal);
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, signal)
: undefined;
}“这条链有三节。”老Z逐一指过去:
- 显式覆盖:如果调用方传了
overrides.apiKey,直接用它,不看后面的。- 存储的凭据:从凭据存储里读这个 Provider 的记录。找到 OAuth 记录,走 OAuth 刷新;找到 API key 记录,直接用。
- 环境变量:前两级都没找到时,尝试从环境变量解析。
“每一级的逻辑是:能解析出凭据就返回,不能就让代码继续往下走到下一级。”老Z说,“这就是责任链,不是 if/else 那种互斥选择,是一条有序的降级链。”
收益:表达凭据优先级
“责任链最大的好处,是优先级就写在代码顺序里。”老Z说,“这段代码从上到下就是优先级从高到低:显式覆盖最优先,存储的凭据其次,环境变量兜底。不用翻文档也不用翻配置,代码本身就告诉你优先级。”
小A顺着往下想:“那如果我想加一级呢?比如从密钥管理服务拉凭据?”
“加在存储的凭据和环境变量之间,一个 if 的事:先尝试密钥服务,找不到再走环境变量。责任链的可扩展性就在这里,加一级只改一个地方,不影响其他级。”
代价:诊断必须说明走到哪一步
“但责任链有一个固有代价:出错时,你得知道链走到了哪一步。”老Z说,“用户报告‘我的 Agent 连不上模型’,问题可能在任何一级:显式覆盖的 key 写错了、存储的 OAuth token 过期了、环境变量没设。你只告诉用户‘认证失败’,他就无从下手。”
“所以诊断信息很重要?”
“必须。”老Z说,“每一级都该在日志或诊断里说明自己尝试了什么、为什么没成功,比如‘尝试了显式 apiKey 覆盖:key 格式不合法’、‘尝试了存储凭据:未找到 provider anthropic 的记录’、‘尝试了环境变量:ANTHROPIC_API_KEY 未设置’。少了这些,责任链就成了黑箱,用户只知道没认证成功,不知道该去查哪一级。”
反面论证:把责任链当 if/else
老Z见过另一处翻车:“有个团队把 resolveProviderAuth 当成普通的 if/else 理解,于是在外面又包了一层:‘如果环境变量有 key,就不调 resolveProviderAuth 了’。结果外部检查和链的第一级逻辑重复了:用户传了显式覆盖,外部检查却先看到环境变量,直接用了,绕过了优先级更高的显式覆盖。责任链的价值在于让链自己管理优先级,你在链外面加判断,就破坏了链的优先级语义。”
教训: 责任链的优先级只由链内部的顺序决定,外部判断覆盖不了它。要改优先级,就改链内部的顺序。在链外另加判断,等于平行建立了两条优先级链,一旦冲突,行为就不可预测。
跨章回扣
这个模式在源码篇源码篇第2章 目录与户口——pi-ai被提及,那一章讲 ModelsImpl.applyAuth 时,提到认证合并调用了 resolveProviderAuth。如果你在 源码篇第2章对“认证怎么从多个来源合并”感到困惑,回来这里看责任链的三级结构:每一级就是一个来源,优先级在代码顺序里。
模式 ⑥:SessionRouter.runForClient —— 按 key 串行队列
模式定义
这不是一个标准的 GoF 模式,但它解决的问题是经典的并发控制:对同一个 key 的操作必须串行执行,不同 key 的操作可以并发。它介于“全局锁”(太粗,所有操作排队)和“无锁”(太危险,同 key 操作会冲突)之间,可以理解为按资源分组的轻量级序列化器。
旧版本的 pi-agent-core 有一个通用的 KeyedOperationQueue,v1.0.0 随 harness 一起删掉了。同样的写法在 packages/server/src/session-router.ts 里还能看到,key 是一条远程连接。
源码里的真实代码
老Z调出 session-router.ts 第 146 行:
// packages/server/src/session-router.ts,SessionRouter.runForClient(v1.0.0)
private runForClient<T>(client: object, operation: () => Promise<T>): Promise<T> {
const previous = this.clientOperations.get(client) ?? Promise.resolve();
const result = previous.catch(() => {}).then(operation);
const tail = result.then(
() => undefined,
() => undefined,
);
this.clientOperations.set(client, tail);
void tail.finally(() => {
if (this.clientOperations.get(client) === tail) this.clientOperations.delete(client);
});
return result;
}小A盯着这段代码看了很久。“它在干嘛?”
“它在做一件很巧妙的事。”老Z一行行解释:
previous = this.clientOperations.get(client):拿到这条连接当前排队的最后一个 Promise(“尾巴”)。previous.catch(() => {}).then(operation):等前序操作结束,不管它成功还是失败,再执行新操作。tail = result.then(...):新的“尾巴”永远 resolve,前一个操作失败不会让后面的操作跟着失败。this.clientOperations.set(client, tail):把新尾巴记下来,给这条连接后续的操作等。tail.finally(...):操作完成后,如果自己还是最后一个尾巴就清理掉,防止 Map 无限增长。
“谁走这条队列?”小A问。
“附着、解除附着、断线清理,以及每一次会话服务调用的‘准入’。”老Z说,“attachClient、detachClient、disconnect、executeServiceCall 都经过 runForClient。所以同一条连接上,‘先附着到会话 A,再发会话请求’一定按这个顺序发生;不同连接之间互不等待。”
收益:同 key 有序,不同 key 并发
“这个收益在附着切换时特别重要。”老Z说,“一条连接可能在附着到新会话的同时,又发来一个旧会话的请求。如果两件事并发,请求可能拿着一个正在被释放的附着去执行。串成一条链之后,请求要么在切换之前完成准入,要么在切换之后被 requireAttachment 拒绝,不会落在中间态。不同连接各走各的链,可以并发。”
代价:不是完整事务或命令模式
“但它有几个代价你要知道。”老Z说:
- 不是事务:同一条连接上,操作 A 成功了 B 失败了,B 不会回滚 A。队列保证的是执行顺序,不是原子性。
- 不是命令模式:队列里存的是 Promise,不是可撤销的命令对象,你无法“回放”或“撤销”已经执行的操作。
- 只管准入,不管执行完:
executeServiceCall在队列里做的是startServiceCall,它取到附着、发起调用就返回,调用本身在队列外继续跑。真正等调用跑完的是释放附着前的Promise.allSettled(attachment.operations)。读的时候要分清这两层。- 没有全局上限:它只按连接串行,不限制所有连接同时运行的操作数。
反面论证:把串行当安全
老Z又翻出一个案例:“有人以为‘同 key 串行’等于‘跨进程安全’,于是在两个独立的 server 进程里托管同一个会话,指望各自的队列保证顺序。他们忘了队列只在单进程内有效:两个进程各有自己的 SessionRouter,各自的‘同 key 串行’互相不可见。跨进程的顺序要靠宿主层的单写者设计或锁,这个队列帮不了你。”
教训: 这种 Promise 链的串行只在同一个 JS 进程内生效:同 key 操作的顺序它管得住,别的进程它不知道也不管。把进程内的串行当跨进程安全,是并发 bug 最常见的来源之一。
跨章回扣
这个模式在源码篇源码篇第10章 黑板与便签——pi-server+pi-client的附着一节出现。会话文件的写入则没有用队列:源码篇第5章 调度台——pi-coding-agent读过 SessionManager._persist,它是同步追加,顺序来自 Agent 逐个 await 监听器,正好是模式 ④ 的时序约束。
模式 ⑦:withFileMutationQueue —— 资源级并发控制
模式定义
这个模式和 ⑥ 解决的问题是同源的:对同一个资源的操作必须串行。差异有两处:它的 key 不是一个连接对象,而是文件的 realpath(解析后的真实路径);它的队列是模块级的 Map,整个进程共享,不挂在某个实例上。可以把它理解为“同路径写入排队”的轻量锁。
源码里的真实代码
小A打开 packages/coding-agent/src/core/tools/file-mutation-queue.ts,第 32 行:
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指着第 16 行:
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,对高频小文件写入来说是额外开销。
反面论证:把“同路径串行”当“写入安全”
老Z想起类似的误读:“有人看到这个函数名,以为‘文件写入都排队了,很安全’。结果他们把外部工具的副作用(比如调一个外部 lint 服务)也包进来,可那个服务的状态不是文件系统能感知的,两个 lint 请求仍然并发发出。队列只对 filePath 生效,不对这个文件相关的所有副作用生效。”
教训:
withFileMutationQueue(filePath, fn)只给对filePath的写入排队,fn的其他副作用都在队列边界之外。把队列当成“这段逻辑的整体锁”,就会漏掉非文件的副作用。
对比矩阵:⑥ ⑦ 的区别
| 维度 | ⑥ SessionRouter.runForClient | ⑦ withFileMutationQueue |
|---|---|---|
| 使用方式 | 实例上的私有 Map,路由器内部调用 | 模块级 Map,函数式调用 |
| key 类型 | 连接对象 | 文件路径(realpath 归一化) |
| 资源感知 | 不感知,纯逻辑串行 | 感知文件系统(symlink 解析) |
| 适用层 | 远程会话附着与准入(pi-server) | 工具写入(coding-agent) |
| key 登记 | 同步取尾巴,无竞态 | key 要异步 realpath,另用 registrationQueue 串行登记 |
跨章回扣
这个模式在源码篇源码篇第8章 收银台排队——pi-coding-agent被详细跟读,那一章分析了 withFileMutationQueue 的 finally 释放、key 归一化和失败边界。“两个写请求会不会互相覆盖”,本节的串行机制就是完整答案。
模式 ⑧:ValidatedMessageDecoder —— 管道/守卫
模式定义
管道模式(Pipeline)的核心是把一个输入依次经过多个阶段处理,每个阶段做一件事,前一个的输出是后一个的输入。pi-mono 的协议层里,解码就是一个四段管道:FrameDecoder 切出完整帧 → decodeCbor 解码成 unknown → parseClientMessage 做 schema 校验 → 产出消息。ValidatedMessageDecoder 是这条管道的调度器,同时是一个守卫,任何一段失败,整条管道失效。
源码里的真实代码
小A打开 packages/protocol/src/codec.ts,第 65 行:
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 可能带着好几个完整的帧,所以 push 一次能产出多条消息。”
小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 要是能‘跳过坏帧’,恶意攻击者就能构造一个半坏的流,让它在不可信状态下继续解析,这正是协议解析器最常见的漏洞来源。宁可整体重建,也不在不可信边界上猜测恢复。这在安全上叫 fail-closed(失败即关闭),而不是 fail-open(失败仍继续)。”
反面论证:把“失败后重建”当“重试一次就好”
老Z再举一个:“有人写了个重试逻辑:decoder 报错就 new 一个新的继续读。他们忘了连接层的握手状态、协议版本协商、会话附着关系,全都建立在同一个 decoder 之上,重建 decoder 不等于重建连接。只重建 decoder 不重建连接,新 decoder 会读到一个从中间开始的字节流,帧边界全是错的。decoder 失败的正确响应是重建整个协议状态(包括握手),不是只换 decoder。”
教训:
ValidatedMessageDecoder的failed首先是 decoder 实例状态;该实例不可复用,单换一个 decoder 不足以救回连接。是否重建连接和协议状态,由上层恢复策略决定。把它当单次重试的触发器,等于只修了管道的一端,没修整条链路。
跨章回扣
这个模式在源码篇源码篇第9章 发货与报关——pi-protocol被完整跟读,那一章展示了 chunk → FrameDecoder → decodeCbor → schema 校验的完整链路,以及 failed 永久失效的语义。如果你在 源码篇第9章对“为什么一个坏帧要重建整个连接”感到困惑,回来这里看管道守卫的设计逻辑。
模式 ⑨:服务订阅 —— 发布者(快照加增量)
模式定义
观察者模式在远程场景有个变体,叫发布者(Publisher)。观察者是状态变化时通知一组监听者;发布者先给订阅者一份完整的当前状态,再持续推送增量。订阅者手里始终有一份可以独立使用的状态,增量只是在它上面修改。这解决了观察者模式在跨网络场景的一个经典问题:订阅者是半路加入的,光有事件流拼不出完整状态。
旧版本的 pi-server 用一个带自增 revision 的快照发布器做这件事;v1.0.0 改成了 chord 的服务订阅,快照随订阅请求的响应返回,之后用 service_update 推增量。
源码里的真实代码
小A打开 packages/server/src/server.ts 第 306 行的 handleRequest,找到订阅分支:
// packages/server/src/server.ts,handleRequest 的订阅分支(v1.0.0,节选)
const subscribing = control?.type === "subscribe" ? control : undefined;
const pendingUpdates: { readonly update: ServiceProviderUpdate }[] = [];
let subscriptionReady = subscribing === undefined;
const publish = async (subscriptionId, update) => {
if (subscribing !== undefined && subscriptionId === subscribing.subscriptionId && !subscriptionReady) {
pendingUpdates.push({ update });
return;
}
await this.sendServiceUpdate(state, subscriptionId, update);
};
// ……调用服务,拿到 result(订阅时就是快照)……
const stateEncoder = createServiceStateEncoder();
result = stateEncoder.encodeSnapshot(parseServiceSubscriptionSnapshot(result));
state.serviceStateEncoders.set(subscribing.subscriptionId, stateEncoder);
await this.sendMessage(state, { type: "response", id: envelope.id, ok: true, result });
while (pendingUpdates.length > 0) { /* 按顺序补发 */ }
subscriptionReady = true;“看 pendingUpdates。”老Z说,“服务在交出快照之前就可能发布更新。server 先把它们攒着,等快照随 response 发出去,再按顺序补发。每个订阅有自己的 ServiceStateEncoder,快照和后续更新都经它编码,更新是相对这份快照的增量。”
收益:让 client 从一份完整状态开始
“这个收益在远程场景特别重要。”老Z说,“客户端可能随时连进来,也可能断线后重新订阅。它不需要知道之前发生过什么,订阅时拿到的快照就是完整状态;之后的每条 service_update 都能对上这份快照。”
“那客户端那边呢?”小A问。
“也有两道闸。”老Z说,“client.ts 的 subscribeService 在快照解码之前先存住原始更新,快照解码后再按顺序解码它们;解码好的更新还要等调用方 start() 才投递。两端合起来保证:先快照,后增量,顺序不乱。”
代价:快照不证明命令已提交
“但发布者的代价是:快照只告诉你‘现在是什么状态’,不告诉你‘你发过的命令有没有执行’。”老Z说,“断线重连后重新订阅,拿到一份新快照,就以为之前所有的操作都提交了。不对。快照反映的是服务端此刻的状态,断线前那条请求可能执行了一半,也可能根本没到。”
“那到底能保证什么?”小A追问到底。
“能保证你看到的是服务端某一刻的完整状态,并且之后的增量按顺序叠在它上面;不能保证每条命令恰好执行一次。”老Z说,“要保证后者,需要业务级的 request ID、幂等设计或人工确认,这些都不在订阅机制的职责里。”
反面论证:把“快照里有”当“命令已提交”
老Z最后讲了一个客户端团队的案例:“他们在断线重连后,看到新快照里订单状态变了,就认为自己那次‘确认’请求成功了。后来发现状态是另一个客户端改的,他们自己的请求在断线时根本没到服务端。快照是状态,不是回执,把两者混为一谈,恢复时就会做出错误的业务决策,比如以为扣款成功而发货。”
教训: 快照只回答“现在是什么状态”,你的请求有没有提交它证明不了。观察一致性(显示正确)和操作语义(确定提交)要分开对待,发布者模式只保证前者。
对比矩阵:观察者 vs 发布者
小A把 ④ 和 ⑨ 放在一起对比,老Z给他画了张表:
| 维度 | ④ 观察者 Agent.subscribe | ⑨ 发布者 服务订阅 |
|---|---|---|
| 推送内容 | 单个事件 | 先完整快照,后增量 |
| 消费场景 | 单进程内(loop → UI) | 跨网络(server → client) |
| 顺序保证 | 逐个 await 监听器 | 两端缓冲,保证先快照后增量 |
| 失败恢复 | 监听器失败影响 loop | 更新解码失败即断开,重连后重新订阅 |
| 核心承诺 | 事件被处理 | 订阅者有一份完整、可续的状态 |
“观察者适合进程内、你信任事件顺序的场景;发布者适合跨网络、订阅者随时可能加入的场景。”老Z收尾,“核心区别不在谁推送,在推送的是事件还是状态:事件发生了就过去;状态有起点,增量才有意义。”
跨章回扣
这个模式在源码篇源码篇第10章 黑板与便签——pi-server+pi-client被详细跟读,那一章展示了订阅快照与 service_update 的分工,以及断线后“重新连接、重新附着、重新订阅”的真实边界。“重连后能不能信快照”要看你信它做什么:显示当前状态可以,证明提交不行。
九个模式之外:别犯的命名错误
九个个案讲完了,还有一类错误比误判某个模式更普遍:过度命名。小A把这类错误整理成一张清单,老Z逐条点评。
过度命名陷阱全集
- 别把 TypeScript 接口一律称为模板方法:模板方法要求父类定义算法骨架、子类重写步骤,一个普通的
interface只是契约,没有算法骨架。- 别因模块名含
proxy就断言其实现完整 GoF 代理:代理模式要求访问控制(延迟加载、权限检查、远程代理),“中间多了一层”不等于代理。- 别把任何
if/switch都叫策略模式:策略要求一组可互换的算法实现同一接口,单纯的条件分支只是逻辑。- 别把回调称为命令模式:命令模式要求封装请求为对象(支持撤销、排队、日志),回调只是函数。
- 别把任何
Map都叫缓存模式:缓存模式要求有失效策略(TTL、LRU、容量上限),一个无限增长的 Map 是内存泄漏,不是缓存。- 别把任何队列都叫生产者消费者模式:该模式要求生产者和消费者解耦、速率不同步,一个简单的 FIFO 队列只是数据结构。
- 别把任何单例都叫单例模式:单例模式要求全局唯一 + 全局访问点 + 延迟初始化,一个模块级
const只是模块作用域。
小A看着清单问:“那这些错误有没有一个共同根源?”
“有,根源都是拿一个名字代替理解。”老Z说,“看到 proxy 就以为有代理的访问控制,看到 subscribe 就以为 fire-and-forget,看到 Map 就以为有缓存失效。名字给了你一个预期,你却跳过了验证预期的步骤。”
“那正确姿势是什么?”小A问。
正确的命名三步
还是开头那条线:先画事实,再选名字,后核代价。选名字时挑不会夸大事实的,代码不满足模式核心约束的宁可不命名;核代价时对照教科书里的已知代价逐一检查,没被处理的代价要么是 bug,要么是模式不完全适用。
“你在 pi-mono 里走查了九个模式:工厂、策略、适配器、观察者、责任链、串行队列、并发控制、管道、发布者。”老Z说,“每一个都有真实的代码、真实的收益、真实的代价。”
收尾:模式是脚手架,不是终点
小A合上笔记本,长长舒了一口气:“所以模式到底有什么用?”
“用处你刚在九个个案里都见过了。”老Z把九张对比矩阵叠在一起,推到他面前,“看到 subscribe 你知道往观察者方向想,看到 createProvider 你知道往工厂方向想,这就是脚手架的作用。但脚手架不是房子,代码的真实行为和代价,最终还得你自己验证。”
回到全书目标
在 pi-mono 里走查这九个模式,你带走的不只是“这段代码像观察者”这样的判断,还有:
- 每个模式都有一列真实的代价:观察者的时序约束、适配器的信息损失、责任链的诊断要求、管道的 fail-closed、发布者的非事务性。
- 每个代价都对应一个反面案例:把旁路观察当主路径、把进程内队列当跨进程锁、把快照当提交回执。
- 每个模式都有跨章回扣:源码篇第2章看工厂,19/源码篇第3章看策略,21/源码篇第5章看观察者,源码篇第10章看队列与发布者,源码篇第8章看并发控制,源码篇第9章看管道。
“下次再看一个陌生代码库,”老Z说,“还是那三步:先画调用关系,再问它像哪个模式,最后核对代价列。你会发现代码不再是散的,而是一组带代价的决策。设计模式在工程里的真实用途,就是让你看懂每个决策背后的权衡。”
