源码篇第9章 发货与报关——pi-protocol

本章导读: 源码路线第 ⑧ 站(远程层)。切到基础层的
pi-protocol包,读codec.ts、framing.ts和protocol.ts。这一站要拆开小A那句“对象转 JSON 丢过去不就完了”为什么行不通:字节流没有消息边界,得靠 schema、CBOR、frame 四层把它一层层兜住。
小A写了一个远程工具,第一步就是把消息发给对方。他敲下 socket.write(JSON.stringify(msg)),得意地回头:“这样不行吗?对象转成 JSON 丢过去不就完了?”
“字节流里没有‘一条消息’的边界。”老Z摇头,“对端收到的是一串字节,它怎么知道你的 JSON 在哪结束?如果消息中途被拆成两个 chunk,它又怎么知道还没收完?远程会话不能直接把 TypeScript 对象交给 socket。我们从 pi-protocol 的 schema 开始跟读,直到增量字节成为已验证的消息。”
本章不讨论传输监听器或业务命令如何执行。源码证据均来自上游 pi 标签 v1.0.0(提交 a13d35a7,packages/protocol/src/,包版本 1.0.0)。
四层链路
“packages/protocol/src/codec.ts 的写入路径是:schema 验证 → CBOR 编码 → 长度帧;读取路径反过来:FrameDecoder 切出完整 payload,decodeCbor 解码,parseClientMessage 或 parseServerMessage 以 TypeBox Check 验证。”
schema → encodeCbor → 4 字节长度 frame
网络 chunk → FrameDecoder → decodeCbor → schema 校验 → 消息比喻:发货与报关
发消息像发货:先验货(schema),再装箱(CBOR),再贴长度标签(frame);收货方先看标签切包,再拆箱,最后验货。任何一步不对,本次解码或
push()不返回部分结果。先前调用已经交付的消息,协议层无法回滚;连接是否重建由上层恢复策略决定。
“protocol.ts 的严格对象禁用额外字段,PROTOCOL_VERSION 是 8。v1.0.0 的协议只定义信封,不再定义业务命令:客户端发 hello、request、cancel,服务端回 hello、hello_error、response、service_update 和 attachment。request 带一个 target,要么只有 serverId,要么再加 sessionId 和 attachmentId;真正的业务内容放在 call、result、update 里,schema 对它们只写 Type.Unknown()。codec.ts 还要求整条消息是合法 JSON 值,拒绝循环引用、非普通对象等输入,所以 CBOR 能解出来,不代表协议会接受。”
小A:“业务内容不验了?”
“协议层只保证它是 JSON 值,业务字段的含义由上层的服务自己验证。”老Z说,“上游提交 48dd1e2f 把实验性的 client/server 移植到了 pi-durable,协议因此退成一层通用 RPC 信封。这一章只读信封和字节,不读业务。”
小A追问:“四层为什么不能合成一层?schema 直接拒绝不就完了?”
“因为每一层挡的是不同类别的坏输入,合并就会留下空隙。”老Z逐层点,“schema(TypeBox Check)挡的是‘结构对但字段错’,比如 type 拼错、serverId 不是 UUID 格式;chord 的 isJsonValue 挡的是‘运行时值形状不对’,循环引用、Map/Date 这类非普通对象,TypeBox 在序列化前看不到这些;CBOR 层挡的是‘字节编码畸形’,截断的 payload、非法 UTF-8、超深嵌套;frame 层挡的是‘边界丢失’,半帧、超长声明。如果只剩 schema,一个含循环引用的对象会在 CBOR 编码时炸掉,错误发生在字节生成阶段而非验证阶段,定位困难。”
“反过来,四层也各有代价。”老Z说,“每条消息要过两次验证(写入端 parse、读取端 parse)、一次 CBOR 编解码、一次 frame 读写。但跨进程/网络的输入本来就不可信,这套代价换来的是:畸形字节绝不会变成一条被业务代码执行的消息。”
“四层也不是唯一的分法,它是在两组对立需求之间取的位置。”老Z把几种省法摆成一张表,“省任何一层,都会把某一类错误推迟到更晚的阶段才暴露,只是省下的开销不一样。”
| 替代方案 | 省下的开销 | 换来的新风险 | 失败定位 |
|---|---|---|---|
| 只留 schema | CBOR 编解码、frame 读写 | 循环引用等运行时形状错误延后到编码才暴露;字节流仍无消息边界 | 编码阶段,晚且不准 |
| schema + CBOR,无 frame | frame 前缀读写 | 对端只能“读完一条才知道结束”,半帧无法安全恢复 | 读取端,跨 chunk 不可判定 |
| schema + frame,无 CBOR | CBOR 层 | 超深嵌套、超长单串直接进 parse,解码资源可能被畸形输入耗尽 | 读取端 parse |
| 四层全留(当前) | 无 | 每条消息过两次验证、一次编解码、一次 frame 读写 | 统一 ProtocolValidationError 边界 |
“配合调用链看,每个交接点只认自己上一层的产物。”老Z又画了一张交接表,“上游给什么、下游要什么,写死在这张表里。”
| 交接点 | 输入 | 输出 | 失败形态 |
|---|---|---|---|
| 业务对象 → parse | unknown 值 | 已验证的联合类型消息 | ProtocolValidationError |
| 消息 → encodeCbor | 已验证消息 | 无长度前缀的 payload | 超限时 CborError,再被包装 |
| payload → encodeFrame | Uint8Array | 4 字节前缀 + payload | 超 uint32 或非 Uint8Array |
| chunk → FrameDecoder | 任意字节块 | 零到多个 payload | 超限、半帧、failed 状态 |
| payload → decodeCbor | payload | unknown 值 | CborError(截断、超深、非法编码) |
| unknown → parse | 解码值 | 联合类型消息 | schema/结构失败 |
先验证对象,再把它编码
小A抢答:“我猜是边编码边验,一趟过?”
“不对。先验证,后编码。”老Z打开 codec.ts 的 parseClientMessage:
export function parseClientMessage(value: unknown): ClientMessage {
if (!Check(ClientMessageSchema, value) || !isJsonValue(value)) {
throw new ProtocolValidationError("Invalid client protocol message");
}
return value;
}“TypeBox Check 先验证联合类型、字段范围和 additionalProperties: false;isJsonValue 来自 @earendil-works/chord,递归接受 JSON 标量、数组和普通对象,拒绝循环引用、非普通对象和非有限数。两步不能互相替代:信封里的 call、result、update 在 schema 里是 Unknown,只有 isJsonValue 会往里检查;反过来,isJsonValue 不知道这是不是某一种 client message。”
写入端 encodeProtocolMessage 则这样收尾:
const validated = parse(value);
try {
const maxFrameLength = options?.maxFrameLength ?? DEFAULT_MAX_FRAME_LENGTH;
return encodeFrame(encodeCbor(validated, { maxByteLength: maxFrameLength }));
} catch (error) { /* 包装成 ProtocolValidationError */ }“先拒绝无效对象,再限制 CBOR 输出,最后追加 frame。异常会包装成 ProtocolValidationError,错误文本由 boundedErrorMessage 截断,避免对端构造超长错误污染日志。”
小A盯住 encodeCbor 那行:“frame 长度上限是配置出来的,可 CBOR 的实际输出长度要编码完才知道,超了怎么办?”
“encodeCbor 收到的 maxByteLength 按 maxFrameLength 配置,约束 CBOR payload 字节;完整 frame 还要加上 4 字节长度头。它不只查单个字符串。”老Z说,“cbor/encoder.ts 的 writer 每次扩容前都检查累计需要的字节数,超过上限就抛 CborError。所以一条由很多小字段拼成、总量超限的消息,也在生成字节时就失败。旧版本在成帧后还有一道 assertCompleteFrame 复查,v1.0.0 把它从 framing.ts 删掉了;由此可以推断,写入侧的长度契约已经完全由 CBOR writer 承担。”
小A又抓到一个细节:“错误文本截到 500 字符,栈信息不全被截掉了吗?”
“会,但这是刻意的。”老Z说,“错误文本会通过协议回传或写日志,对端是不可信的,如果它构造一条让 schema 报错的超长输入,不截断的话,错误文本本身就成了放大攻击的载体。500 字符足够定位‘哪个字段、什么类型错误’,栈信息应该在本地日志里看,不该跟着错误 envelope 走。”
小A往回翻:“isJsonValue 里那个 ancestors Set 是干嘛的?不是查循环引用的吗?”
“它是循环检测。”老Z打开 packages/chord/src/json.ts,“check 带着一个 ancestors 集合递归下钻:进入数组或对象前 add,退出时在 finally 里 delete。同一对象如果在递归栈里再次出现,ancestors.has(value) 直接判假:既挡了 a.self = a 这种直接自引用,也挡了 a.x = b; b.y = a 这种间接环。还有几行容易被忽略:原型不是 Object.prototype 或 null 的对象判假,Date、Map、Set、class 实例都过不去;带 symbol 键、带 getter 的属性判假;数字必须是有限数;数组还要求没有空洞和额外属性。值为 undefined 的属性也会判假,所以‘可选字段’只能真的缺省,不能写成 undefined。”
“写端读端走的是同一对检查,方向相反:读端多一道 CBOR 解码,写端多一道 frame 组装。整个写入路径画出来是这样。”
对象值 ──Check──> 命中联合类型成员? ──isJsonValue──> 整体是 JSON 值?
│ │ │
│ 否(额外字段/类型错) │ 否(环/Map/Date/class)│ 是
▼ ▼ ▼
ProtocolValidationError ProtocolValidationError encodeCbor ──累计超限?──> CborError → ProtocolValidationError
│ 未超限
▼
encodeFrame ──> frame一段 chunk 如何成为消息
小A:“那网络 chunk 呢?一条消息可能拆成好几个包吧?”
“也可能一个包里好几条。FrameDecoder 因此保留未完成的尾部;完整 payload 才交给 CBOR decoder,CBOR 成功后才交给 parse。”老Z打开 ValidatedMessageDecoder.push:
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画了一张状态图:
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 层不负责拦它。”
| 阶段 | 输入/输出 | 正常语义 | 失败语义 |
|---|---|---|---|
| FrameDecoder | chunk → payload 列表 | 可跨 chunk 累积 | 超限或不完整尾帧报错 |
| decodeCbor | payload → unknown | 恢复 CBOR 值 | 非法编码或限制触发异常 |
| parseClient/ServerMessage | unknown → 联合类型 | 获得可用消息 | schema 或结构失败 |
| ValidatedMessageDecoder | chunk → 消息列表 | 按顺序产出零到多条 | 失败后永久 failed |
帧与解码器的边界
“framing.ts 使用无符号 32 位大端长度前缀,默认单帧上限是 16 * 1024 * 1024 字节,可配置但不得超过 uint32。解码器可跨多个网络 chunk 累积,也会拒绝不完整尾帧和超长帧。”
小A:“这个长度前缀带类型和校验吗?”
“不带。”老Z指着 encodeFrame:
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、非有限浮点数、超出安全整数范围的整数、非字符串的 map 键、重复的 map 键,以及 payload 末尾的多余字节。它接受的是一个受限子集,但并不检查编码是否最短,所以不能说它只接受 RFC 8949 的确定性编码。”
小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 边界的流上猜测恢复。代价是每条消息都要编码和校验,但跨进程/网络的输入不应因追求吞吐跳过验证。
“协议验证失败还要和业务错误区分开:response 里 ok: false 带的 error 是 { code, message },code 只要求非空字符串,具体取值由上层服务决定;版本不匹配则走 hello_error。这些都是已验证的 envelope,客户端可以展示并继续处理;坏 CBOR 或坏 schema 就不是可恢复的业务响应了。”
“这几类失败严重程度完全不同,摊开看更清楚。”老Z列了一张分类表:
| 失败类别 | 典型触发 | 解码器状态 | 调用方处置 |
|---|---|---|---|
| 帧级 | 超长声明、EOF 半帧、坏长度 | failed,连接终止 | 重建 decoder 与握手状态 |
| CBOR 级 | 截断 payload、非法 UTF-8、超深嵌套、tag/indefinite | failed,连接终止 | 重建 decoder 与握手状态 |
| schema 级 | type 拼错、额外字段、serverId 格式错 | failed,连接终止 | 重建 decoder 与握手状态 |
| 业务级 | ok: false 的 response、hello_error | 解码器正常 | 展示错误;是否保持连接由上层决定 |
小A:“那有没有办法只牺牲一帧,别把整条连接废掉?”
“有,但都得改协议。”老Z说,“一是给帧头加同步标记,坏帧后扫描下一个 magic 重新对齐;二是给每条请求带序号,丢帧后用超时重发兜底;三是不在 decoder 层判死,把坏 chunk 丢掉,把‘可疑帧’整体上报给上层。三个方向要么扩帧头,要么改会话语义,都超出当前协议的取舍范围。当前协议选的是‘错就断’,用最简单的方式保证不产出假消息,流控和纠错押在传输层,恢复押在重连上。”
小A不死心:“那让 decoder 跳过坏字节、从下一条帧接着读呢?”
“因为 frame 边界一丢,你就不知道坏字节延伸到哪里了。”老Z回到 FrameDecoder.fail,“它把所有状态清零,headerLength、payloadBlocks、expectedPayloadLength 全部重置,然后置 state = "failed"。设想一种‘跳过恢复’:收到一个声明 100 字节、实际只到 50 字节就断了的帧,decoder 跳过它继续读,可下一段字节到底是新帧的 header,还是坏帧的残余?刚才说过,长度前缀不带同步标记,没法判断,所以唯一安全的恢复点还是重建连接。”
“这个取舍放到不同流量上结果不一样。”老Z说,“请求、响应这类低频、高完整性的消息,付这个代价划算;service_update 这种可能高频的推送偏严格,但 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 一律抛错,不再接受输入。这是用更严格的语义换取更清楚的边界,实现不会在已失去帧边界的流上猜测恢复。之所以不能跳过坏字节,是因为长度前缀不带同步标记,坏帧延伸到哪里无法判定;要继续通信,由调用方按连接层策略重建协议状态。协议验证失败和业务错误也要分开:hello_error 和 ok: false 的 response 是已验证的 envelope,客户端可以展示;坏 CBOR 或坏 schema 则是连接级的致命错误。
分层是有代价的选择。每条消息要过两次验证、一次 CBOR 编解码、一次 frame 读写;帧格式(长度前缀 vs 换行 vs magic)、CBOR 上限(byte、container、depth 三轴独立)、验证拆分(Check 管信封 schema、isJsonValue 管运行时形状)、失败语义(错即断 vs 跳过恢复)四个决策点互相咬合。去掉任何一层,都会把某类坏输入推迟到更晚的阶段才暴露,换来的是开销和定位难度的上升。对低频、高完整性的会话协议这个取舍划算;对高频可丢包的流它偏严格,但 pi-protocol 把请求、响应和服务推送复用同一条帧流,统一严格语义让协议只有一种失败模型可理解。
源码走查
- 阅读
protocol.ts的ClientMessageSchema、ServerMessageSchema与PROTOCOL_VERSION,列出哪些字段是Unknown。 - 从
codec.ts的encodeClientMessage跟到encodeFrame,再从ClientMessageDecoder.push反向跟读。 - 在
framing.ts与cbor/decoder.ts标出长度和深度相关限制,并构造一个超长 frame 验证错误路径。 - 分两次
push一个 frame,确认第一次可返回空数组、第二次才产出消息。 - 向 decoder 送入非法 CBOR 后再次
push,确认失败状态不会继续消费字节。
