1. 2.2 协议设计:固定头 + 变长体

2.1 的定界与分配问题基础上,本节说明 zhenyi-base 的默认线协议如何落到 socket.go 的解析与发送路径上。

1.1. 2.2.1 协议格式

zhenyi 使用固定头 + 变长体的协议格式:

┌──────────────────────────────────────────┐
│              Header (12 bytes)            │
├──────────┬──────────┬──────────┬───────────┤
│  msgId  │  seqId  │ dataLen  │           │
│  (4B)   │  (4B)   │  (4B)   │           │
├──────────┴──────────┴──────────┴───────────┤
│              Body (variable)              │
│                 data                      │
└──────────────────────────────────────────┘
字段 长度 类型 说明
msgId 4 字节 int32 消息 ID,业务自定义
seqId 4 字节 uint32 序列号,用于消息追踪
dataLen 4 字节 uint32 body 长度
data 可变 []byte 消息体,业务数据

总 header 长度:12 字节

1.2. 2.2.2 为什么这样设计?

1.2.1. 固定头的优势

固定头有两大优势:

1. 解析简单,O(1) 时间复杂度

// 解析固定头:直接读 12 字节
msgId := binary.BigEndian.Uint32(buf[0:4])
seqId := binary.BigEndian.Uint32(buf[4:8])
dataLen := binary.BigEndian.Uint32(buf[8:12])

不需要扫描分隔符,不需要解析变长编码。

2. 二进制友好

所有字段都是定长整数,大端序(BigEndian)传输,跨平台兼容。

1.2.2. 和其他方案的对比

方案 解析难度 效率 可读性
固定头 + 长度 O(1)
分隔符(如 \n O(N) 扫描
protobuf/variant 需要编解码
JSON 需要解析

固定头牺牲人类可读性,换取定长字段与简单状态机;在热点消息路径上,一般比逐字节扫分隔符或通用文本解码更省 CPU。

1.3. 2.2.3 代码实现

线协议解析在 zhenyi-base/znet/socket.goBaseSocket.ParseFromRingBuffer(真实签名与错误类型以源码为准)。下面为阅读指引,与逐行实现一致而非逐字拷贝:

  • 先取 HeaderLen()(v0=12,v1=13),长度不足则 (false, nil) 等待更多数据。
  • v1 时先 PeekByte 校验 version 字节与配置一致。
  • PeekHeader12(off)msgId/seqId/dataLen(大端),并与 SocketConfig 中的 MaxMsgIdMaxDataLength 比较。
  • body 可能跨 RingBuffer 边界时用 PeekTwoSlices 得到一段或两段切片;零拷贝前提下,业务须在下次向 RingBuffer 写入前消费完数据(见源码注释)。

1.4. 2.2.4 状态机:读头 → 读体

协议解析本质上是一个状态机(头部长度以 HeaderLen() 为准,v0=12、v1=13):

stateDiagram-v2
    [*] --> WaitHeader: 开始
    WaitHeader --> WaitHeader: 数据不够完整头
    WaitHeader --> WaitBody: 收到完整头
    WaitBody --> WaitHeader: 收到完整body
    WaitBody --> WaitHeader: 解析出错
    WaitBody --> WaitHeader: body过大

每个连接上的典型循环是:

  1. 读头:未读长度小于 HeaderLen() 则等待
  2. 读体:按 dataLen 等待完整 body
  3. 提交:交给业务;环上已解析区间由读路径 Discard(见 2.3 / 2.5

1.5. 2.2.5 版本兼容:v0 和 v1

zhenyi 支持两个协议版本:

版本 Header 长度 说明
v0 12 字节 msgId + seqId + dataLen
v1 13 字节 version(1) + msgId + seqId + dataLen

v1 在头部加了一个 version 字节,用于协议版本协商。

// v1:在 v0 的 12 字节头之前增加 1 字节 version(总头 13 字节,与 HeaderLen 一致)
┌──────────┬──────────┬──────────┬──────────┬───────────┐
│ version  │  msgId  │  seqId  │ dataLen  │   data    │
│  (1B)   │  (4B)   │  (4B)   │  (4B)    │ variable  │
└──────────┴──────────┴──────────┴──────────┴───────────┘

如何选用:v0 少 1 字节头开销;v1 多出的 version 便于在链路上先做版本判别或与 SocketConfig 对齐,链路与部署约定以项目配置为准。

1.6. 2.2.6 安全校验

ParseFromRingBuffer 中与 SocketConfig 对齐的校验要点(详见 zhenyi-base/znet/socket.go):

  1. msgId 范围:与 MaxMsgId 比较(防止极端值)。
  2. dataLen 上限:与 MaxDataLength 比较(默认 1MBDefaultMaxDataLength)。
  3. MaxHeaderLength:在配置与缓冲层使用;v0/v1 固定头本身为 12/13 字节,不要与「body 上限」混淆。

这些校验减轻畸形 framing、超大 body 带来的内存与解析风险。

1.7. 2.2.7 发送:writev 优化

接收用 RingBuffer 零拷贝,发送也有优化:

// PreparePacket 准备发送数据包
func (base *BaseSocket) PreparePacket(msg *NetMessage, headerBuf []byte) (int, []byte) {
    // 写入 12 字节头
    binary.BigEndian.PutUint32(headerBuf[0:4], uint32(msg.MsgId))
    binary.BigEndian.PutUint32(headerBuf[4:8], msg.SeqId)
    binary.BigEndian.PutUint32(headerBuf[8:12], uint32(len(msg.Data)))

    // 返回头和 body,调用方用 writev 一次发送
    return 12, msg.Data
}

// 调用方:
var header [12]byte
hdrLen, body := socket.PreparePacket(msg, header[:])
buffers := net.Buffers{header[:hdrLen], body}
buffers.WriteTo(conn)  // writev 系统调用

使用 net.Buffers + writev一次系统调用发送 header + body,减少系统调用次数。

1.8. 2.2.8 本节要点

  1. 固定头:v0 为 12 字节;v1 为 13 字节(含 version),dataLen 定界 body。
  2. 状态机:读够头 → 按 dataLen 等体 → 完整则交付;不足则留在缓冲中等待下次读取。
  3. 实现锚点BaseSocket.ParseFromRingBufferSocketConfig 上限、大端编码以 zhenyi-base/znet/socket.go 为准。
  4. 发送PreparePacket + net.Buffers / writev 合并头与体,减少系统调用次数。

2.3 节说明承接解析的 RingBuffer 与跨段 PeekTwoSlices

results matching ""

    No results matching ""