自研二进制 RPC 协议(TLV + 长度前缀)

本节目标

TLV(Type-Length-Value):每个字段 = 类型(1B) + 长度(4B) + 值(NB)。自描述、易扩展、好解析。

// 运行环境:Node.js 14+
// 保存为 aj-l16.js,执行:node aj-l16.js
// 编码一个 RPC 请求:method + params
function encodeTLV(type, value) {
  const vbuf = Buffer.from(value)
  const head = Buffer.alloc(5)
  head[0] = type
  head.writeUInt32BE(vbuf.length, 1)
  return Buffer.concat([head, vbuf])
}
function decodeTLV(buf) {
  const type = buf[0]
  const len = buf.readUInt32BE(1)
  const value = buf.slice(5, 5 + len).toString()
  return { type, value, rest: buf.slice(5 + len) }
}

// 组装一条"调用"消息:多个 TLV 字段顺序拼起来
function buildCall(method, params) {
  return Buffer.concat([
    encodeTLV(1, method),        // type=1 方法名
    encodeTLV(2, params)         // type=2 参数
  ])
}

// 调用示例
const msg = buildCall('add', 'a=1&b=2')
let p = msg
const fields = {}
while (p.length) { const f = decodeTLV(p); fields[f.type] = f.value; p = f.rest }
console.log('解析出的调用:', fields) // { '1':'add', '2':'a=1&b=2' }

// 再套一层长度前缀(接 TCP 传输,呼应 aj-l4)
function frame(msg) {
  const h = Buffer.alloc(4); h.writeUInt32BE(msg.length, 0)
  return Buffer.concat([h, msg])
}
console.log('整帧字节数:', frame(msg).length)

实战意义:真实 RPC(gRPC 的 protobuf、Thrift)都是"结构化 + 长度/分隔 + 二进制"的思路。你今天写的 TLV + 长度前缀,已具备一个最小可用私有协议骨架。

名词解释

课后练习

  1. TLV 相比"固定位置字段"有什么优势?
    • 答案:TLV 自描述、顺序/可选字段灵活,新增类型旧解析器可跳过不认识的 type;固定位置字段一旦改布局旧程序全崩。
  2. 为什么这里还要再套一层"长度前缀"帧?
    • 答案:TLV 解决"单个字段自描述",但一条消息可能含多个 TLV 且要经 TCP 传输;长度前缀帧让接收方先知道"整条消息多长",解决粘包(呼应 aj-l4)。

总结

自研 RPC 协议,是把整门课所有底层积木拼成一座可用建筑的高潮。你会发现,所谓"远程调用"并没有想象中神秘——它不过是把"方法名 + 参数"按 TLV 结构化、再套一层长度前缀帧经 TCP 发出去,对面按同样的规则拆回来。TLV 的"类型-长度-值"自描述设计,让你新增字段也不用怕旧客户端崩溃,这正是工业协议(protobuf、Thrift)的通用哲学。我特别想强调"分层"思维:TLV 管"字段怎么组织",长度前缀管"消息边界在哪",两者各司其职、互不耦合——这是所有稳健网络协议的共性。走到这里,你回头看 aj-l1 的 HTTP 解析、aj-l4 的拆包、aj-l7 的二进制抠数据,会发现它们本质是同一套"定义边界 + 按结构读字节"的能力。恭喜,你已经具备手写一个最小私有协议、甚至读懂 gRPC 帧格式的底层功力。