二进制协议设计

本页目录

二进制协议设计首先要定义接收方如何从字节中得到明确、有限且可验证的消息。紧凑编码只是其中一项;还要规定字段含义、重复处理、未知值、长度上限、状态转换和错误行为。文本也可以有严格 framing,二进制也可能比某段文本更大,不能用形式直接推导质量。

层次应明确的内容常见混淆
传输TCP 字节流、UDP 数据报或已有流协议把一次 read 当成一条消息
Framing消息从哪里开始、何时完整、最大多长只有 payload schema,没有消息边界
字段编码类型、字节序、长度和整数表示与内存结构直接等同
语义单位、范围、缺省、状态、错误字节能解析就认为请求可执行
演进未知字段、能力与关键扩展规则所有未知内容无条件忽略

Framing:长度前缀也需要完整契约

TCP 不保留应用写入边界。两次 send 可以在一次 read 中出现,一次 send 也可被多次 read 才读完。UDP 保留数据报边界,但仍要判断应用消息是否完整、是否允许分片或多条记录,以及接收缓冲是否导致截断。

方法优点必须补齐的规则
固定长度边界直接可算版本变化、填充和浪费
长度前缀支持任意 payload 字节长度是否含头、上限、溢出、截断
分隔符适合流式文本或记录转义、跨 read 匹配、最长记录
分块/结束帧无需预知总长块上限、结束状态、总量与超时

HTTP 头部的空行只界定头部结束,不独立决定整个响应 body 的长度;SMTP DATA 还依赖补点转义。不能把这些局部机制当成无需其他状态的通用 framing。

长度字段不是可信的内存分配指令。接收方应先验证固定头和长度上限,再逐步接收;对于长连接,还应限制未完成消息数量、总缓冲、组装时间和解压后的大小。限制单帧 1 MiB,仍不能防止一万个连接各保留半帧。

一个可推演的教学协议

下面定义一个仅用于说明解析边界的小协议,不是现有标准。它运行在可靠有序字节流上,使用 12 B 固定头和 TLV body;空 body 允许,所有多字节整数为大端。

教学协议:12 B 固定头 + 有界 TLV body Magic 2 B Version 1 B Flags 1 B Body length 4 B Request ID 4 B Type 1 B Length 2 B Value:Length B 所有多字节整数用大端;body length 不含固定头,TLV length 只计算 value。
字段定义
Magic两个字节 KB,用于辨认格式,不是认证
Version当前只接受 1,其他版本失败
Flags当前必须为 0,避免默默忽略未知关键语义
Body lengthuint32,单位字节,不含固定头,最大 4096
Request IDuint32,用于本连接内关联请求;不天然是业务幂等键
TLVuint8 type + uint16 length + value

TLV type 的最高位表示关键字段,低 7 位为字段 ID。本例已知 ID 1 为严格 UTF-8 名称,ID 2 为 4 B uint32 数量;已知 ID 不允许重复,其他可选字段跳过,未知关键字段拒绝。缺少已知字段在语法层允许,业务操作可另行要求必填。

这种“关键位”是本文协议主动定义的规则,不是所有 TLV 格式共有。读者只要知道长度就能跳过可选值,但不能因此理解其语义,更不能保证丢弃后仍可正确转发。

增量解析:等待、失败和正常完成分开

增量解析:数据不够是等待,格式非法是失败 等固定头 不足 12 B:保留 校验头与长度 先拒绝非法值,再等 body 解析完整 body 消费一帧,继续下一个 EOF:缓冲为空才正常结束;有残留就是截断 长度上限、连接总预算和组装超时共同限制资源;不要按声明长度直接无限分配。

下面的解析器限制 body 长度,并逐帧验证 TLV。它接收调用者提供的 chunk,要求上层以有界读取喂入,并负责连接级预算和超时;示例不是完整服务器。

import struct
HEADER = struct.Struct('>2sBBII')
MAX_BODY = 4096

def parse_tlvs(body):
    pos, values = 0, {}
    while pos < len(body):
        if len(body) - pos < 3:
            raise ValueError('truncated TLV header')
        kind, size = struct.unpack_from('>BH', body, pos)
        pos += 3
        if size > len(body) - pos:
            raise ValueError('TLV exceeds body')
        value = body[pos:pos + size]
        pos += size
        field_id = kind & 0x7f
        if field_id not in (1, 2):
            if kind & 0x80:
                raise ValueError('unknown critical field')
            continue
        if field_id in values:
            raise ValueError('duplicate known field')
        if field_id == 1:
            values[1] = value.decode('utf-8', errors='strict')
        else:
            if size != 4:
                raise ValueError('quantity must be uint32')
            values[2] = struct.unpack('>I', value)[0]
    return values

class Decoder:
    def __init__(self):
        self.buffer = bytearray()

    def feed(self, chunk):
        self.buffer.extend(chunk)
        messages = []
        while len(self.buffer) >= HEADER.size:
            magic, version, flags, size, request_id = HEADER.unpack_from(self.buffer)
            if magic != b'KB' or version != 1 or flags != 0 or size > MAX_BODY:
                raise ValueError('invalid header')
            end = HEADER.size + size
            if len(self.buffer) < end:
                break
            fields = parse_tlvs(bytes(self.buffer[HEADER.size:end]))
            del self.buffer[:end]
            messages.append((request_id, fields))
        return messages

    def eof(self):
        if self.buffer:
            raise ValueError('truncated frame')

def tlv(kind, value):
    return struct.pack('>BH', kind, len(value)) + value

def frame(body, request_id=7):
    return HEADER.pack(b'KB', 1, 0, len(body), request_id) + body

body = tlv(1, '温度'.encode()) + tlv(2, struct.pack('>I', 300))
wire = frame(body)
assert len(body) == 16 and len(wire) == 28
expected = [(7, {1: '温度', 2: 300})]
# 穷举每一个二分读取边界,包括空读取与完整读取。
for split in range(len(wire) + 1):
    decoder = Decoder()
    assert decoder.feed(wire[:split]) + decoder.feed(wire[split:]) == expected
    decoder.eof()
# 两帧粘在一起也必须逐帧消费。
assert Decoder().feed(wire + wire) == expected + expected
assert Decoder().feed(frame(body + tlv(3, b'optional'))) == expected

bad = [
    frame(body + tlv(0x83, b'critical')),
    frame(body + tlv(1, b'duplicate')),
    frame(tlv(1, b'\xff')),
    frame(tlv(2, b'\x01')),
    frame(b'\x01\x00\x04x'),
    HEADER.pack(b'KB', 1, 0, MAX_BODY + 1, 7),
    wire[:-1],
]
for candidate in bad:
    decoder = Decoder()
    try:
        decoder.feed(candidate)
        decoder.eof()
    except ValueError:
        pass
    else:
        raise AssertionError('invalid input accepted')
print('28 B 消息:所有二分边界、粘帧、可选字段和 7 类拒绝通过')

解析错误后,本例要求调用者终止该流的解码,不继续猜测下一个 KB 是不是帧头,因为这个字节序列也可能合法出现在 payload 内。若确实需要噪声中的重新同步,应另行设计同步、完整性与恢复规则。

生产实现还需考虑逐字节移动缓冲的成本、异常前已解析消息如何交付、内存所有权和异步取消。零拷贝并不免除生命周期约束:保留一小段 view 可能使整个大缓冲都无法释放。

收到半个头,是等待还是报错?

在11字节处切换EOF,等待会变成截断;在17字节处第一帧已经完整,即使第二帧尚未到达也可交付。再切换超长声明,到12字节就应拒绝,而不是等待4097字节把错误坐实。

正在呈现知识画面
收到半个头,是等待还是报错?

字节不足在流未结束时可以等待,EOF后则是截断。长度超上限一旦头完整就拒绝,不能等待巨量body或先分配。

整数编码:Protobuf varint 与 QUIC varint 不同

编码边界规则数值 300
uint32 大端固定 4 B,高字节在前00 00 01 2c
uint32 小端固定 4 B,低字节在前2c 01 00 00
Protobuf 无符号 varint每字节 7 位值,最高位表示后续字节ac 02
QUIC 变长整数首字节最高 2 位决定总长 1/2/4/8 B41 2c

Protobuf varint 的低位组先出现,uint64 最长 10 B;QUIC 在长度标记之外按网络字节序编码,最大 62 位。QUIC 的一般整数编码允许某些非最短表示,但具体字段如 frame type 另有最短编码要求,不能通用化成“所有 varint 都必须最短”。QUIC §16

def uvarint(n):
    if not 0 <= n < 1 << 64:
        raise ValueError('not uint64')
    result = bytearray()
    while n >= 128:
        result.append((n & 127) | 128)
        n >>= 7
    result.append(n)
    return bytes(result)

def quic_integer(n):
    for length, bits, prefix in [(1, 6, 0), (2, 14, 1), (4, 30, 2), (8, 62, 3)]:
        if 0 <= n < 1 << bits:
            return (n | (prefix << (8 * length - 2))).to_bytes(length, 'big')
    raise ValueError('not uint62')

assert uvarint(300).hex() == 'ac02'
assert quic_integer(300).hex() == '412c'
assert len(uvarint(127)) == 1 and len(uvarint(128)) == 2
assert len(quic_integer(63)) == 1 and len(quic_integer(64)) == 2
assert len(uvarint(2**64 - 1)) == 10
assert len(quic_integer(2**62 - 1)) == 8
# ZigZag 将小绝对值的有符号数映射到小无符号数。
zigzag = lambda n: 2*n if n >= 0 else -2*n-1
assert [zigzag(n) for n in [0, -1, 1, -2]] == [0, 1, 2, 3]
assert uvarint(zigzag(-2)) == b'\x03'
print('两类 varint、边界和 ZigZag 映射通过')

ZigZag 是值映射,之后才用 varint 编码。Protobuf 的 sint32/sint64 使用它;int32/int64 的负数采用另一规则,负数可能占 10 B。不能仅因为它们的 wire type 都是 0 就互换声明。

同一个300,为什么两个varint完全不同?

先在63/64附近观察QUIC增加到2字节,再在127/128附近观察Protobuf的变化;它们不是同一套“最高位表示长度”规则。移动到300,对照ac02与412c中的位组顺序。

正在呈现知识画面
同一个300,为什么两个varint完全不同?

编码的组顺序与长度信号不同。Protobuf按低7位组先发,QUIC先声明总长并保留大端数值顺序。

Protobuf:wire type 给边界,schema 给语义

syntax = "proto3";
message Person {
  string name = 1;
  uint32 age = 2;
}

name="Li"、age=300 的一种编码是 0a 02 4c 69 10 ac 02:字段 1 的 tag 为 (1 << 3) | 2 = 0a,随后长度 2 和 UTF-8;字段 2 的 tag 为 10,随后 varint ac 02。字段名不出现在 wire 中,年龄单位与约束也不由 tag 自己解释。

Wire type边界可能的语义
0varint 结束位int、uint、sint、bool、enum
18 Bfixed64、double 等
2长度前缀string、bytes、嵌套消息、packed repeated
54 Bfixed32、float 等
3 / 4旧 group 起止已弃用的 group 表达

因此 Protobuf 并不是每个字段都显式携带独立 length 的统一三段 TLV。fixed32/fixed64 等使用小端,与 TCP/IP 头的常见大端不同;变长整数则应按自己的位组规则理解。Protobuf Encoding

未知字段的保留、字段存在性与 JSON 转换行为要依据具体 runtime 和接口。把二进制消息读成对象,再手工只复制已知字段,可能丢失原本可保留的未知内容;wire 可兼容不等于所有中间处理都无损。Proto3 未知字段

字节序、规范化和完整性分别决定

新协议应明文规定字段宽度、符号、字节序、浮点表示、文本编码和单位,而不是“选 CPU native 就好”。直接发送 C struct 会引入 padding、对齐和 ABI 差异;CPU 允许未对齐访问,也不代表另一端的语言和硬件同样允许。

签名、哈希和缓存键还要求定义究竟绑定原始字节还是规范化后的语义。两个对象等价,不代表它们的序列化字节唯一。若需要唯一表示,应明确字段顺序、重复键、浮点 NaN、数字最短编码等规则;CBOR 的确定性编码就是将这些要求显式写出的例子,而非“所有 CBOR 都天然规范化”。CBOR 确定性编码

CRC 或校验和主要发现适用故障模型下的意外损坏,不能证明报文来自可信对端;认证需要 MAC、AEAD 或签名及密钥信任。压缩也要规定解压上限,原始帧不大不等于展开后资源开销很小。

设计完成的判据是失败输入也有确定行为

输入或状态应规定的结果
半个头、半个 body等待;EOF 或超时后明确失败
超长声明、整数溢出在分配或加法溢出前拒绝
未知可选/关键字段按明确规则跳过或失败
重复字段拒绝、合并或优先规则必须唯一
不合法 UTF-8、超深嵌套明确接受范围和预算
不在当前状态允许的消息协议错误或规定的恢复
重试同一业务请求由业务幂等契约决定效果,不靠 framing 保证

优先测试截断、拼接、边界长度、未知关键字段、重复值和跨语言向量,再做模糊测试与资源预算测试。容错应是明文定义的互操作行为,不是各实现猜着接受不同的输入。Maintaining Robust Protocols