Skip to content

Appendix 04 / 附录

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

小A和老Z用放大镜比较几种无标签的机制模型,只把结构相同的模型归到一起。

小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在纸上画了一条线:“正确的顺序是:先画事实,再选名字,最后核代价。”

判断顺序

  1. 画事实:写出调用关系和状态转移。谁调谁、传什么、异常怎么走、状态何时变。这一步逼你看清代码到底在做什么,而不是你觉得它在做什么。
  2. 选名字:从 GoF 二十三个模式里选一个不会夸大事实的名称。判断标准不是“像不像 UML 图”,而是“这个模式的核心约束,代码是否真的满足”。代码不满足模式的核心约束,宁可不命名,也不要硬贴。
  3. 核代价:对照教科书里该模式的已知代价,逐一检查代码是否承担了这些代价。某个代价在代码里没有被处理,那要么是 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:266UI 与循环解耦异步事件链更难追踪
⑤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 行:

ts
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 行:

ts
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 行:

ts
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 行:

ts
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 行(上游行号,下同):

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翻到第 565 行的 processEvents:

ts
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把时序画了出来:

text
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。链本身在内部函数里:

ts
// 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逐一指过去:

  1. 显式覆盖:如果调用方传了 overrides.apiKey,直接用它,不看后面的。
  2. 存储的凭据:从凭据存储里读这个 Provider 的记录。找到 OAuth 记录,走 OAuth 刷新;找到 API key 记录,直接用。
  3. 环境变量:前两级都没找到时,尝试从环境变量解析。

“每一级的逻辑是:能解析出凭据就返回,不能就让代码继续往下走到下一级。”老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 行:

ts
// 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 行:

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指着第 16 行:

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。
  • 它是进程内的。和 ⑥ 一样,两个独立的进程各有自己的 fileMutationQueues Map,跨进程写同一个文件仍然会冲突。
  • 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 行:

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 可能带着好几个完整的帧,所以 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,找到订阅分支:

ts
// 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说,“还是那三步:先画调用关系,再问它像哪个模式,最后核对代价列。你会发现代码不再是散的,而是一组带代价的决策。设计模式在工程里的真实用途,就是让你看懂每个决策背后的权衡。”