Skip to content

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 个1000x64类型字节:字符串 + 长度 4
第 2 个1080x6c"l"
第 3 个1050x69"i"
第 4 个1150x73"s"
第 5 个1160x74"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),服务端校验并回 ServerHelloServerHelloError(见 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