Skip to content

第26章 发货与报关——pi-protocol ​

本章导读: 源码路线第 ⑧ 站(远程层)。切到基础层的 pi-protocol 包,读 codec.ts、framing.ts 和 schemas.ts。这一站要拆开小a那句"对象转 JSON 丢过去不就完了"为什么行不通——字节流没有消息边界,得靠 schema、CBOR、frame 四层把它一层层兜住。

小a写了一个远程工具,第一步就是把消息发给对方。他敲下 socket.write(JSON.stringify(msg)),得意地回头:“这样不行吗?对象转成 JSON 丢过去不就完了?”

“字节流里没有‘一条消息’的边界。”老z摇头,“对端收到的是一串字节,它怎么知道你的 JSON 在哪结束?如果消息中途被拆成两个 chunk,它又怎么知道还没收完?远程会话不能直接把 TypeScript 对象交给 socket——我们从 pi-protocol 的 schema 开始跟读,直到增量字节成为已验证的消息。”

本章不讨论传输监听器或业务命令如何执行。源码证据均来自 pi-mono 提交 583f153d(packages/protocol/src/,包版本 0.83.0)。

四层链路 ​

“packages/protocol/src/codec.ts 的写入路径是:schema 验证 → CBOR 编码 → 长度帧;读取路径反过来:FrameDecoder 切出完整 payload,decodeCbor 解码,parseClientMessage 或 parseServerMessage 以 TypeBox Check 验证。”

text
schema → encodeCbor → 4 字节长度 frame
网络 chunk → FrameDecoder → decodeCbor → schema 校验 → 消息

比喻:发货与报关

发消息像发货:先验货(schema),再装箱(CBOR),再贴长度标签(frame);收货方先看标签切包,再拆箱,最后验货。任何一步不对,整批退回。

“schemas.ts 的严格对象禁用额外字段,并定义版本、命令、结果、snapshot 与 progress。codec.ts 还拒绝循环引用、非普通对象等不属于协议值的输入——因此 CBOR 可解码不等于协议可接受。”

小a追问:“四层为什么不能合成一层?schema 直接拒绝不就完了?”

“因为每一层挡的是不同类别的坏输入,合并就会留下空隙。”老z逐层点,“schema(TypeBox Check)挡的是‘结构对但字段错’——比如 command 拼错、revision 写成负数;isProtocolValue 挡的是‘运行时值形状不对’——循环引用、Map/Date 这类非普通对象,TypeBox 在序列化前看不到这些;CBOR 层挡的是‘字节编码畸形’——截断的 payload、非法 UTF-8、超深嵌套;frame 层挡的是‘边界丢失’——半帧、超长声明。如果只剩 schema,一个含循环引用的对象会在 CBOR 编码时炸掉,错误发生在字节生成阶段而非验证阶段,定位困难。”

“反过来,四层也各有代价。”老z补充,“每条消息要过两次验证(写入端 parse、读取端 parse)、一次 CBOR 编解码、一次 frame 读写。但跨进程/网络的输入是不可信输入——这套代价换来的是:畸形字节绝不会变成一条被业务代码执行的消息。”

“四层也不是唯一的分法,它是在两组对立需求之间取的位置。”老z补了一张取舍表,“省任何一层,都会把某一类错误推迟到更晚的阶段才暴露,只是换来的开销节省不同。”

替代方案省下的开销换来的新风险失败定位
只留 schemaCBOR 编解码、frame 读写循环引用等运行时形状错误延后到编码才暴露;字节流仍无消息边界编码阶段,晚且不准
schema + CBOR,无 frameframe 前缀读写对端只能“读完一条才知道结束”,半帧无法安全恢复读取端,跨 chunk 不可判定
schema + frame,无 CBORCBOR 层超深嵌套、超长单串直接进 parse,解码资源可能被畸形输入耗尽读取端 parse
四层全留(当前)无每条消息过两次验证、一次编解码、一次 frame 读写统一 ProtocolValidationError 边界

“配合调用链看,每个交接点只认自己上一层的产物。”老z又画了一张交接表,“上游给什么、下游要什么,写死在这张表里。”

交接点输入输出失败形态
业务对象 → parseunknown 值已验证的联合类型消息ProtocolValidationError
消息 → encodeCbor已验证消息无长度前缀的 payload超限时 CborError,再被包装
payload → encodeFrameUint8Array4 字节前缀 + payload超 uint32 或非 Uint8Array
frame → assertCompleteFrame完整 frame确认长度字段与配置一致FrameError
chunk → FrameDecoder任意字节块零到多个 payload超限、半帧、failed 状态
payload → decodeCborpayloadunknown 值CborError(截断、超深、非法编码)
unknown → parse解码值联合类型消息schema/结构失败

先验证对象,再把它编码 ​

小a:“那验证和编码的顺序呢?”

“先验证,后编码,最后再反向检查一次。”老z打开 codec.ts 的 parseClientMessage:

ts
export function parseClientMessage(value: unknown): ClientMessage {
	if (!isProtocolValue(value) || !Check(ClientMessageSchema, value)) {
		throw new ProtocolValidationError("Invalid client protocol message");
	}
	return value;
}

“isProtocolValue 是 schema 前的结构门槛:它递归接受 JSON 风格标量、数组和普通对象,拒绝循环引用与非普通对象。接着 TypeBox Check 验证联合类型、字段范围和 additionalProperties: false。两步不能互相替代:前者限制运行时值形状,后者确认它确实是某一种 client message。”

写入端 encodeProtocolMessage 则这样收尾:

ts
const validated = parse(value);
const maxFrameLength = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
const frame = encodeFrame(encodeCbor(validated, { maxByteLength: maxFrameLength }));
assertCompleteFrame(frame, { maxFrameLength });
return frame;

“先拒绝无效对象,再限制 CBOR 输出,追加 frame,最后反向检查 frame——把 CBOR 与 framing 的长度契约留在同一错误边界。异常会包装成 ProtocolValidationError,错误文本由 boundedErrorMessage 截断,避免对端构造超长错误污染日志。”

小a追问:“为什么编码完还要 assertCompleteFrame 再查一次?刚才不是 encodeFrame 自己拼的吗?”

“因为长度上限来自配置,而 CBOR 的实际输出长度是编码后才知道的。”老z回到 encodeProtocolMessage,“encodeCbor 接收 maxByteLength,但它只能在自己能感知的范围内拒绝——比如单个字符串超限。可一条消息由很多字段拼成,加总后可能超过 maxFrameLength 却没有任何单字段超限。assertCompleteFrame 在 frame 拼好后用解析出的长度字段再对一次配置上限,是编码后的兜底。两个检查的失败时机不同:CBOR 在生成字节时失败,frame 检查在字节已成帧后失败,但都进同一个 ProtocolValidationError 边界——调用方不需要区分。”

小a:“那 boundedErrorMessage 把错误截到 500 字符,会不会把有用的栈信息截掉?”

“会,但这是刻意的。”老z说,“错误文本会通过协议回传或写日志,对端是不可信的——如果它构造一条让 schema 报错的超长输入,不截断的话错误文本本身就成了放大攻击的载体。500 字符足够定位‘哪个字段、什么类型错误’,栈信息应该在本地日志里看,不该跟着错误 envelope 走。”

小a:“那 isProtocolValue 里那个 ancestors Set 是干嘛的?不是检查循环引用吗?”

“它同时是深度标记和循环检测。”老z指回 isProtocolValue 的实现,“它带着一个 ancestors 集合递归下钻:进入对象前 add,退出时在 finally 里 delete。同一对象如果在递归栈里再次出现,ancestors.has(value) 直接判假——既挡了 a.self = a 这种直接自引用,也挡了 a.x = b; b.y = a 这种间接环。还有一行容易被忽略:Object.getPrototypeOf(value) !== Object.prototype 判假,意味着 Date、Map、Set、class 实例这些‘长得像对象但不是普通对象’的输入全部在 schema 之前就被挡掉。注意 optionalProperty 只对对象的属性生效——数组元素里的 undefined 不会被放过,所以缺元素不会冒充缺字段。”

“所以验证不是一步,而是两段独立检查的组合。”老z补了一句,“isProtocolValue 保证‘值形状在运行时是对的’,Check 保证‘它确实匹配某一种消息联合成员’。前者看值本身,后者看 schema。写端读端走同一对检查,只是方向相反——读端多一道 CBOR 解码,写端多一道 frame 组装。整个写入路径画出来是这样。”

text
对象值 ──isProtocolValue──> 运行时形状对? ──Check──> 命中联合类型成员?
   │                            │                      │
   │ 否(环/Map/Date/class)     │ 否(额外字段/类型错)  │ 是
   ▼                            ▼                      ▼
ProtocolValidationError    ProtocolValidationError   encodeCbor ──encodeFrame──> frame
                                                       │
                                                       ▼
                                          assertCompleteFrame ──加总超限?──> ProtocolValidationError

一段 chunk 如何成为消息 ​

小a:“那网络 chunk 呢?一条消息可能拆成好几个包吧?”

“也可能一个包里好几条。FrameDecoder 因此保留未完成尾部;完整 payload 才交给 CBOR decoder,CBOR 成功后才交给 parse。”老z打开 ValidatedMessageDecoder.push:

ts
for (const frame of this.frames.push(chunk)) {
	messages.push(this.parse(decodeCbor(frame, { maxByteLength: this.maxFrameLength })));
}
return messages;

“一个 chunk 的完整 frame 都按顺序交给同一 parser;其中任一失败会落入 catch,而不是返回‘前几条成功’的部分数组。上层应把一次 push 当作批次边界。”

小a追问:“那半帧到底在 decoder 里怎么攒?”

“看 FrameDecoder.push 的状态机。”老z指回 framing.ts,“它有四个状态变量:headerLength(已收到的长度前缀字节数)、expectedPayloadLength(读完前缀后才知道要收多少 payload)、payloadLength(已收到的 payload 字节)、以及 payloadBlocks(按 64KB 块攒的 payload 数组)。一个 chunk 进来后,循环里先填 header——如果 chunk 只剩 2 字节而 header 还差 4 字节,就先填 2 字节、continue 等下一次 push。header 满了就读出 frameLength,校验是否超 maxFrameLength(超了直接 fail),然后进入 payload 攒取阶段。payload 也是一样:一个 chunk 可能既含上一条消息的尾、又含下一条消息的头,循环会把它们分别切给不同的 frame。”

“两个边界尤其要记。”老z补充,“第一,frameLength 一读出就立刻校验——在分配 payload 内存之前。否则对端声明一个 4GB 的长度,decoder 会先尝试分配再失败,内存已被吃掉。第二,end() 时如果 headerLength !== 0 或 expectedPayloadLength !== undefined,说明流结尾还卡在半帧,必须报 Truncated frame at end of stream——不能把半帧当正常 EOF 吞掉,否则调用方会以为消息收完了。”

“把 decoder 的推进画出来,就是两个串起来的循环。”老z画了一张状态图:

text
chunk ────────────────────────────────────────────────┐
  │                                                    │
  v                                                    │
[header 阶段] headerLength < 4 ──chunk 不够──> continue,等下次 push
  │ headerLength == 4
  v
  读 frameLength ──> 超 maxFrameLength ──> fail(发生在分配 payload 前)
  │ frameLength == 0 ──> 产出空 payload,回到 header 阶段
  │ frameLength > 0
  v
[payload 阶段] 按 64KB 块攒,直到 payloadLength == frameLength
  │
  v
  单块直接产出 / 多块合并 → 完整 payload → 回到 header 阶段
  │
  v
end() ── headerLength != 0 或 expectedPayloadLength 未清 ──> "Truncated frame at end of stream"

“注意 frameLength === 0 是一个显式分支——空 payload 帧被直接产出,而不是进入 payload 攒取后卡死。”老z补一句,“这不是常见路径,但 decoder 对零长度声明有明确处理,说明边界情况是逐个列举过的。至于空 payload 交给 decodeCbor 会怎样,那是 CBOR 层的事:第一字节都读不到,报截断——frame 层不负责拦它。”

阶段输入/输出正常语义失败语义
FrameDecoderchunk → payload 列表可跨 chunk 累积超限或不完整尾帧报错
decodeCborpayload → unknown恢复 CBOR 值非法编码或限制触发异常
parseClient/ServerMessageunknown → 联合类型获得可用消息schema 或结构失败
ValidatedMessageDecoderchunk → 消息列表按顺序产出零到多条失败后永久 failed

帧与解码器的边界 ​

“framing.ts 使用无符号 32 位大端长度前缀,默认单帧上限是 16 * 1024 * 1024 字节,可配置但不得超过 uint32。解码器可跨多个网络 chunk 累积,也会拒绝不完整尾帧和超长帧。”

小a:“这个长度前缀带类型和校验吗?”

“不带。”老z指着 encodeFrame:

ts
const length = payload.byteLength;
frame[0] = length >>> 24;
frame[1] = length >>> 16;
frame[2] = length >>> 8;
frame[3] = length;

“这是大端 uint32 长度——不携带类型、版本或校验和。类型和版本由 schema 处理,完整性由 transport 处理。cbor/decoder.ts 的解码选项限制字节长度与嵌套结构,防止一条畸形输入无限占用内存或递归深度。具体上限应以该文件的选项与调用参数核对,不能把默认 frame 上限误称为所有 CBOR 结构的唯一上限。”

小a:“那 CBOR 自己有哪些上限?”

“cbor/options.ts 给了三个独立默认。”老z逐一念,“maxByteLength 默认 16MB(与 frame 上限对齐),挡总字节和单个字节/文本串;maxContainerLength 默认 1,000,000,挡数组元素数和 map 条目数;maxDepth 默认 64(可配到 512),挡嵌套深度。三者独立——一条 100KB 的消息可以有 100 万个元素的扁平数组触发 container 上限,也可以有 70 层嵌套的小对象触发 depth 上限,都不会因为总字节没超 16MB 就放过。decodeCbor 还拒绝 indefinite-length、tag(major type 6)、break marker、非有限浮点数——只接受 RFC 8949 的确定性编码子集。”

“两个边界尤其关键:声明长度超过配置上限应在分配完整 payload 前失败;end() 时缓存还有半帧必须报 framing error,不能伪装为正常 EOF。”

小a:“为什么选长度前缀,而不是像 JSON lines 那样用换行分隔,或者干脆每个帧前面加 magic 字节?”

“这是传输协议的经典选择题。”老z说,“换行分隔看起来简单,但消息内容里出现换行就必须转义,而且边界其实是‘读到换行为止’——长度无法事先校验,整行还得先攒进内存。magic bytes 加长度能帮助坏帧之后重新同步,但 magic 要占用字节,而且当前协议没选它——是裸的 4 字节长度前缀,不带任何同步标记。这正是 decoder 不能跳过坏字节恢复的根本原因:没有标记可扫,就无法在下一条帧的起点重新对齐。这个选择让‘重建连接’成了唯一安全的恢复点,也让长度前缀换来了 O(1) 的边界判定和分配前校验——代价是 4 字节开销和‘错一帧即全错’。”

失败会使解码器失效 ​

小a:“那如果某个 chunk 是坏的,后面又来好的字节呢?”

“ValidatedMessageDecoder.push 捕获帧、CBOR 或 schema 异常后设定 failed = true,以后再 push 会抛出‘decoder has failed’。错误再被包装为 ProtocolValidationError,且错误文本被截断。调用方不能在同一损坏字节流上假设自动恢复——通常应关闭连接并重新建立协议状态。”

要点:更严格的失败语义

failed 设计会丢弃后续也许正确的字节,调用方必须重建 decoder 和握手状态。收益是不会在已失去 frame 边界的流上猜测恢复。代价是每条消息都要编码和校验——但跨进程/网络的输入不应因追求吞吐跳过验证。

“协议验证失败还要与业务错误区分:ProtocolErrorSchema 的 version、busy、not_found 等是已验证 envelope,客户端可展示并继续连接;坏 CBOR 或坏 schema 则不是可恢复业务响应。”

“先把这几类失败摊开看,严重程度完全不同。”老z列了一张分类表:

失败类别典型触发解码器状态调用方处置
帧级超长声明、EOF 半帧、坏长度failed,连接终止重建 decoder 与握手状态
CBOR 级截断 payload、非法 UTF-8、超深嵌套、tag/indefinitefailed,连接终止重建 decoder 与握手状态
schema 级command 拼错、额外字段、revision 负数failed,连接终止重建 decoder 与握手状态
业务级version、busy、not_found、session_locked解码器正常展示错误并保持连接

小a:“那有没有办法只牺牲一帧,别把整条连接废掉?”

“有,但都要改协议。”老z说,“一是给帧头加同步标记,坏帧后扫描下一个 magic 重新对齐;二是给每条请求带序号,丢帧后用超时重发兜底;三是不在 decoder 层判死,而是把坏 chunk 丢弃、把‘可疑帧’整体上报给上层。三个方向要么扩帧头,要么改会话语义,都超出当前协议的取舍范围。当前协议选的是‘错就断’——用最简单的方式保证不产出假消息,把流控和纠错押在传输层,把恢复押在重连上。”

小a追问:“为什么不让 decoder 尝试跳过坏字节、从下一条帧恢复?”

“因为一旦 frame 边界丢了,你不知道坏字节延伸到哪里。”老z回到 FrameDecoder.fail,“它把所有状态清零——headerLength、payloadBlocks、expectedPayloadLength 全部重置,然后置 state = "failed"。设想一种‘跳过恢复’:收到一个声明 100 字节但实际只有 50 字节就断了的帧,decoder 跳过它继续读——可下一段字节到底是新帧的 header,还是坏帧的残余?没有额外同步标记(比如 magic bytes)就无法判断。当前协议的长度前缀不带同步标记,所以唯一安全的恢复点是重建连接。”

“这套语义的取舍很清楚。”老z总结,“收益是边界明确——decoder 不会在已污染的流上产假消息;代价是单字节错就废掉整条连接。对低频、高完整性的会话协议(命令、快照、进度)这个取舍划算;对高频可丢包的流(比如纯 progress 事件)可能太严格,但 pi-protocol 把它们复用同一条帧流,所以统一采用严格语义。”

“end() 也有同样的 failed 语义。”老z补一句,“ValidatedMessageDecoder.end() 在 FrameDecoder 已经 failed 时会再抛一次‘decoder has failed’,而不是静默返回——这样调用方不会误以为流正常结束。”

小结 ​

远程通信不能直接把对象丢给 socket,因为字节流里没有"一条消息"的天然边界。pi-protocol 用四层分层来重建这个边界:schema 定义哪些对象是协议可接受的,严格禁止额外字段;CBOR 把验证过的对象编码成紧凑的字节;frame 用四字节大端长度前缀切分字节流;validated decoder 把这几层重新串起来,从 chunk 到 frame 到 CBOR 到消息。每一层都有独立的失败语义:超长帧在分配前就被拒,半帧在 EOF 时报错而非伪装成正常结束,CBOR 的嵌套深度有上限以防畸形输入耗尽内存。一个 chunk 可能横跨多条消息,也可能只含半条,decoder 保留未完成的尾部,只把完整的 frame 交给后续处理。

这套设计最值得记住的是它的失败语义:一旦 decoder 在帧、CBOR 或 schema 任一层失败,它就进入永久 failed 状态,之后的 push 一律抛错。这是用更严格的语义换取更清楚的边界——不在已失去帧边界的流上猜测恢复,而是要求调用方重建 decoder 和连接状态。之所以不能跳过坏字节恢复,是因为长度前缀不带同步标记:坏帧延伸到哪里无法判定,唯一安全的对齐点是重建连接。协议验证失败和业务错误也要分开:版本不匹配、busy、not_found 这些是已验证的 envelope,客户端可以展示并保持连接;坏 CBOR 或坏 schema 则是连接级的致命错误。

分层是有代价的选择。每条消息要过两次验证、一次 CBOR 编解码、一次 frame 读写;四个决策点——帧格式(长度前缀 vs 换行 vs magic)、CBOR 上限(byte、container、depth 三轴独立)、验证拆分(isProtocolValue 管运行时形状、Check 管 schema)、失败语义(错即断 vs 跳过恢复)——互相咬合。去掉任何一层都会把某类坏输入推迟到更晚的阶段才暴露,换来的是开销和定位难度的上升。对低频、高完整性的会话协议这个取舍划算;对高频可丢包的流它偏严格,但 pi-protocol 把快照、进度、命令复用同一条帧流,统一严格语义让协议只有一种失败模型可理解。

源码走查 ​

  1. 阅读 schemas.ts 的 ClientMessageSchema、ServerMessageSchema 与 PROTOCOL_VERSION。
  2. 从 codec.ts 的 encodeClientMessage 跟到 encodeFrame,再从 ClientMessageDecoder.push 反向跟读。
  3. 在 framing.ts 与 cbor/decoder.ts 标出长度和深度相关限制,并构造一个超长 frame 验证错误路径。
  4. 分两次 push 一个 frame,确认第一次可返回空数组、第二次才产出消息。
  5. 向 decoder 送入非法 CBOR 后再次 push,确认失败状态不会继续消费字节。