问题不是“粘包”,而是边界从未存在
TCP 向应用提供有序字节流。一次 send 对应的数据,接收端可能分几次拿到;多次发送的数据,也可能在一次 recv 中返回。所谓粘包、拆包只是应用观察到的现象,不是 TCP 破坏了数据。测试工具如果把“调用一次接收”等同于“收到一帧”,在本机短报文场景可能看似正常,到了延迟、代理转发或高并发环境就会随机失败。
本文讨论带固定头和长度字段的二进制协议。以分隔符结尾、固定长度或外层 TLS/WebSocket 封装的协议,需要采用对应的边界规则,不能直接照搬。
增量解析模型
接收循环只负责读取字节并追加到缓冲区;解析器负责判断缓冲区中是否已经包含完整帧。两者分离后,同一个解析器可以用离线报文、分片数据和真实套接字重复验证。
HEADER_SIZE = 6
MAX_BODY = 64 * 1024
def extract_frames(buffer: bytearray) -> list[bytes]:
frames: list[bytes] = []
while len(buffer) >= HEADER_SIZE:
if buffer[:2] != b"\xAA\x55":
del buffer[0]
continue
body_size = int.from_bytes(buffer[2:4], "big")
if body_size > MAX_BODY:
raise ValueError("declared body is too large")
frame_size = HEADER_SIZE + body_size
if len(buffer) < frame_size:
break
frames.append(bytes(buffer[:frame_size]))
del buffer[:frame_size]
return frames
解析器必须允许“暂时不够”,但不能无限等待。最大帧长用于阻止损坏长度字段拖住内存;同步字处理用于从噪声或截断帧之后恢复。是否允许自动重同步需要依据协议风险决定:诊断工具可以记录错误后继续,严格一致性测试通常应立即失败。
正常路径与异常路径
最小验证矩阵不应只包含一帧一次到达:
| 输入方式 | 期望结果 |
|---|---|
| 完整单帧 | 立即输出一帧 |
| 每次只输入一个字节 | 最后一个字节到达后输出 |
| 两帧合并输入 | 连续输出两帧 |
| 一帧加下一帧半个头 | 输出第一帧并保留尾部 |
| 长度超过上限 | 明确报错且不分配巨量内存 |
| 错误同步字后跟合法帧 | 按既定策略恢复或失败 |
| 连接关闭但仍有半帧 | 报告截断,而不是静默丢弃 |
分片测试应改变切分位置,而不仅是固定切成两段。对长度为 n 的报文,可遍历 1..n-1 的单切点,再补充多个随机切点。这样无需依赖网络抖动,也能稳定复现边界问题。
可观测性比“收到了”更重要
日志应区分读取事件与解析事件:读取时间、字节数、缓冲区剩余量、解析帧序号和错误位置分别记录。原始字节可以保存为十六进制,但公开案例必须使用模拟数据,并限制日志长度,避免把真实协议或敏感负载带入报告。
取舍与结论
recv_exactly 适合已知下一段长度的流程,但仍需处理提前断开;环形缓冲区适合高吞吐场景,普通测试工具先使用清晰的 bytearray 往往更易验证。关键不是选择最复杂的数据结构,而是把网络读取、边界恢复、字段校验和业务断言分成可独立测试的步骤。
只要测试中系统覆盖任意分片、合并、截断和非法长度,就不会再把一次接收误当成一个消息。这个解析边界也是“可配置协议帧解析与回放工具”项目的基础。