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.go 的 BaseSocket.ParseFromRingBuffer(真实签名与错误类型以源码为准)。下面为阅读指引,与逐行实现一致而非逐字拷贝:
- 先取
HeaderLen()(v0=12,v1=13),长度不足则(false, nil)等待更多数据。 - v1 时先
PeekByte校验version字节与配置一致。 - 用
PeekHeader12(off)读msgId/seqId/dataLen(大端),并与SocketConfig中的MaxMsgId、MaxDataLength比较。 - 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过大
每个连接上的典型循环是:
- 读头:未读长度小于
HeaderLen()则等待 - 读体:按
dataLen等待完整 body - 提交:交给业务;环上已解析区间由读路径
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):
- msgId 范围:与
MaxMsgId比较(防止极端值)。 - dataLen 上限:与
MaxDataLength比较(默认 1MB,DefaultMaxDataLength)。 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 本节要点
- 固定头:v0 为 12 字节;v1 为 13 字节(含
version),dataLen定界 body。 - 状态机:读够头 → 按
dataLen等体 → 完整则交付;不足则留在缓冲中等待下次读取。 - 实现锚点:
BaseSocket.ParseFromRingBuffer与SocketConfig上限、大端编码以zhenyi-base/znet/socket.go为准。 - 发送:
PreparePacket+net.Buffers/writev合并头与体,减少系统调用次数。
2.3 节说明承接解析的 RingBuffer 与跨段 PeekTwoSlices。