Appearance
3.2 CBOR 编解码
定帧之后,payload 里的字节用什么格式表达"消息对象"?pi 选择 CBOR(Concise Binary Object Representation,精简二进制对象表示)。这一节用大白话 + 具体字节讲清楚:一个 JS 对象到底怎么变成字节、又怎么变回来。
先理解:对象是怎么"变成字节"的
程序里,消息是 JS 对象:
ts
{ command: "list" }但网络只能传 0 和 1(字节)。把对象变成字节的过程叫序列化,反过来叫反序列化。CBOR 就是一套"如何把对象编码成字节、再解码回来"的规则。
类比:对象是"写在纸上的字",字节是"摩斯电码"。CBOR 规定了"每个字怎么翻译成电码"。
一个最小例子:"list" 怎么编码
CBOR 的每条数据都从一个"类型字节"开头。这个字节的高 3 位表示"是什么类型",低 5 位表示"长度"。
以字符串 "list"(4 个字符)为例,它的类型字节是 0b01100100(= 十进制 100):
text
0b01100100
⇑ ⇑
3位 5位
高 低
类型 长度- 高 3 位
011表示"这是文本字符串"。 - 低 5 位
00100表示"长度是 4"。
| 位置 | 二进制值 | 含义 |
|---|---|---|
| 高 3 位 | 011 | 类型 = 文本字符串 |
| 低 5 位 | 00100 | 长度 = 4 |
0b01100100 的三个等价写法(二进制 / 十六进制 / 十进制)都是同一个字节 100:
0b01100100 = 0x64 = 100 (第一个字节)
后面跟 4 个字节:0x6c 0x69 0x73 0x74 ("l" "i" "s" "t" 的 ASCII)所以 "list" 这个字符串在 CBOR 里就是 5 个字节:
| 字节 | 值 | 含义 |
|---|---|---|
| 第 1 个 | 100(0x64) | 类型字节:字符串 + 长度 4 |
| 第 2 个 | 108(0x6c) | "l" |
| 第 3 个 | 105(0x69) | "i" |
| 第 4 个 | 115(0x73) | "s" |
| 第 5 个 | 116(0x74) | "t" |
高 3 位的含义(major type):
| 高 3 位 | 类型 |
|---|---|
000 | 无符号整数 |
001 | 负整数 |
010 | 字节串 |
011 | 文本字符串 |
100 | 数组 |
101 | 对象(map) |
110 | 标签 |
111 | 浮点/简单值 |
对照看:
"list"是文本字符串 → 高 3 位011;{ "command": "list" }是对象 → 高 3 位101。两者不同,因为一个是字符串、一个是对象。
初学者只要记住:第一个字节就告诉解码器"后面是什么类型、有多长"。这是 CBOR 自描述的基础。
再举个对象例子:{ "command": "list" }
对象(map,1 个键值对)
字节1 = 0b101_00001 (高3位=对象,低5位=1 个键值对)
然后依次编码每个键、每个值:
键 = "command" → 100 99 111 109 109 97 110 100
值 = "list" → 100 108 105 115 116解码器读第一个字节
0b10100001,就知道"这是个对象,有 1 对键值",然后按顺序读出键和值,拼回{ command: "list" }。
传输编码:三层管道
codec.ts 把"Schema 校验 + CBOR 编码 + 定帧"串成一个函数:
ts
// codec.ts:60 —— 通用编码管道
function encodeProtocolMessage(value, parse, kind, options) {
const validated = parse(value); // 1. Schema 校验(先确认合法)
const frame = encodeFrame(
encodeCbor(validated, { maxByteLength }), // 2. CBOR 编码(对象→字节)
);
assertCompleteFrame(frame, { maxFrameLength }); // 3. 定帧(加 4 字节长度头)
return frame;
}
export function encodeClientMessage(message, options) {
return encodeProtocolMessage(message, parseClientMessage, "client", options);
}为什么先校验再编码? 保证"发出去的一定是合法消息",避免把脏数据编码发出去。
传输解码:逆向 + 增量
解码是编码的逆过程,且是增量的(codec.ts:88):
ts
class ValidatedMessageDecoder<T> {
push(chunk: Uint8Array): T[] {
const messages: T[] = [];
for (const frame of this.frames.push(chunk)) { // 1. 定帧切出完整帧
messages.push(
this.parse(decodeCbor(frame, {...})) // 2. CBOR 解码(字节→对象)+ 3. Schema 校验
);
}
return messages;
}
}ClientMessageDecoder / ServerMessageDecoder 是它的两个实例(对应客户端/服务端消息)。任何 push 进来的字节,吐出的都是已校验的协议对象。
协议版本握手
协议有版本号(codec.ts:170):
ts
export function isSupportedProtocolVersion(version: number): version is typeof PROTOCOL_VERSION {
return Number.isInteger(version) && version === PROTOCOL_VERSION;
}握手时客户端发 ClientHello(带 version),服务端校验并回 ServerHello 或 ServerHelloError(见 4.1)。
一次完整编解码
ts
import { encodeClientMessage, ClientMessageDecoder } from "@earendil-works/pi-protocol";
const msg = { type: "request", id: "1", request: { command: "list" } };
const bytes = encodeClientMessage(msg); // 字节数组(含长度头 + CBOR)
console.log(bytes); // 一堆数字,看不懂没关系
const decoder = new ClientMessageDecoder();
const out = decoder.push(bytes); // [ { type: "request", ... } ] 又变回对象
console.log(out[0].request.command); // "list"关键认知:你拿到的是对象,编解码器负责把它变成字节再变回来。你永远不用手写字节。
错误处理约定
- 编码前
parse校验失败 → 抛ProtocolValidationError。 - 解码时遇到非法帧 → decoder 进入
failed状态,后续 push 抛错,防止流入脏数据。 - 错误消息都会被截断到 500 字符以内(
boundedErrorMessage),避免把内部错误细节泄漏到协议里。
小结
- CBOR 是协议的"内容格式",定帧是"边界",Schema 是"合法性"。
- 对象 → 字节:每个值以"类型字节"开头(高 3 位类型 + 低 5 位长度),自描述、解回来不用额外信息。
- 编码 = 校验 → CBOR → 定帧;解码 = 切帧 → CBOR → 校验。
- 解码器是增量状态机,吐出已校验对象。
- 有版本号,首次握手即校验。
真实源码位置
- 编码管道:
packages/protocol/src/codec.ts:60-86 - 解码器:
packages/protocol/src/codec.ts:88-160 - 版本握手:
packages/protocol/src/codec.ts:170 - CBOR 实现:
packages/protocol/src/cbor/index.ts
动手:用 pi-principles 的定帧感受"对象变字节"
工程化项目里没有完整 CBOR,但你可以用 pi-principles/play/protocol.ts 感受"对象 → 字节 → 对象"的流程(定帧 + 一个简化的文本编码):
bash
cd /Users/xiaoming/mjw/ai/pi-agent/pi-agent-docs/pi-principles
bun play/protocol.ts面试角度:为什么这样编解码
Q1:为什么用 CBOR 而不是 JSON? 因为 CBOR 是二进制紧凑格式,比 JSON 文本更省字节;且能保留类型(数字、字节串、map),不像 JSON 会丢类型信息。对频繁传输的会话协议,体积和类型保真是实打实的收益。(纯教学 Demo 里用 JSON 只是为了可读性,原理一致。)
Q2:为什么编码是"先校验 → CBOR → 定帧",解码是"切帧 → CBOR → 校验"? 因为数据要先合法再传输 / 先还原再确认合法。编码端:先 Schema 校验保证"发出去的一定是合法消息",再编码定帧。解码端:先切出帧、解出对象,再校验"它是不是合法协议消息"。两端都确保"合法消息才能进出"。
Q3:为什么解码出错后 decoder 会进入 failed 状态,之后所有 push 都抛错? 因为一旦遇到非法帧,字节流的边界就不可信了,后续字节可能"错位"导致误判。进入 failed 状态是"fail-fast":立即停止,避免用脏状态继续解析出错误结果。这比"尝试恢复"更安全。
Q4:为什么要有协议版本号并在握手时校验? 因为协议会演进,客户端和服务端版本可能不匹配。握手时校验版本,能及早发现"版本不兼容"并返回 hello_error,而不是后续解析出莫名的错误。这符合"失败要尽早、要明确"的原则。
下一步:3.3 消息 Schema。