协议落地:消息结构定义与编解码
本章目标:定下 im-go 的应用层协议——统一信封(Envelope)+ 命令字(cmd)+ 载荷(body),给出 JSON 与 protobuf 两套编解码和单测,让网关与逻辑层对同一串字节理解一致。
1. 命令字与信封
// pkg/protocol/cmd.go:命令字用字符串,抓包可读、便于灰度新增
const (
CmdLogin = "login" // 上行:登录绑定(第 04 章)
CmdHeartbeat = "heartbeat" // 双向:心跳
CmdSendMsg = "send_msg" // 上行:发消息(第 07 章)
CmdAck = "ack" // 双向:确认(第 10 章)
)
var ErrEmptyCmd, ErrTooLarge, ErrBadFrame = errors.New("protocol: empty cmd"), errors.New("protocol: payload too large"), errors.New("protocol: bad frame")
// pkg/protocol/envelope.go:所有帧的统一外壳
type Envelope struct {
Cmd string `json:"cmd"` // 命令字,决定 Body 怎么解
Seq uint64 `json:"seq,omitempty"` // 连接内自增序号,供 ACK 与去重使用
MsgID string `json:"msg_id,omitempty"` // 服务端生成的全局消息 ID(第 08 章)
ConvID string `json:"conv_id,omitempty"`
From uint64 `json:"from,omitempty"`
To uint64 `json:"to,omitempty"`
TS int64 `json:"ts"` // 毫秒时间戳,全链路统一
Body json.RawMessage `json:"body,omitempty"` // 载荷:各 cmd 自己二次解码
Code int `json:"code,omitempty"` // 0 成功,非 0 为错误码
}
// NewEnvelope 用强类型载荷构造信封,避免业务侧手写 map[string]any。
func NewEnvelope(cmd string, body any) (*Envelope, error) {
if cmd == "" {
return nil, ErrEmptyCmd
}
env := &Envelope{Cmd: cmd, TS: time.Now().UnixMilli()}
if body == nil {
return env, nil
}
raw, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal body of %s: %w", cmd, err)
}
env.Body = raw
return env, nil
}
2. 编解码:JSON 与 protobuf
// pkg/protocol/codec.go:网关与逻辑层共用同一份实现,两端才能理解一致
const MaxFrameSize = 32 * 1024
type JSONCodec struct{ MaxSize int } // 用 &JSONCodec{MaxSize: 32 << 10} 构造
func (c *JSONCodec) Encode(env *Envelope) ([]byte, error) {
if env == nil || env.Cmd == "" {
return nil, ErrEmptyCmd
}
raw, err := json.Marshal(env)
if err != nil {
return nil, fmt.Errorf("encode %s: %w", env.Cmd, err)
}
if len(raw) > c.MaxSize {
return nil, fmt.Errorf("%w: frame=%d limit=%d", ErrTooLarge, len(raw), c.MaxSize)
}
return raw, nil
}
func (c *JSONCodec) Decode(raw []byte) (*Envelope, error) {
if len(raw) > c.MaxSize { // 先判长度再反序列化,防超大帧吃内存
return nil, fmt.Errorf("%w: frame=%d limit=%d", ErrTooLarge, len(raw), c.MaxSize)
}
env := &Envelope{}
if err := json.Unmarshal(raw, env); err != nil {
return nil, fmt.Errorf("decode frame: %w", err)
}
if env.Cmd == "" || env.TS <= 0 {
return nil, ErrBadFrame // 命令字为空或时间戳不合理,都按坏帧处理
}
return env, nil
}
体积敏感时换 protobuf:proto/im.proto 定义同名 Envelope(Body 用 bytes 原样透传),生成物落到 pkg/pb,再照 JSONCodec 实现一组同名 Encode/Decode 方法即可。两种编码同时对外服务时,帧首字节放魔数(0x01 JSON、0x02 protobuf),解码按首字节分派。
3. 单测与验证
// pkg/protocol/codec_test.go
type sendMsgPayload struct{ Text string `json:"text"` }
func TestJSONCodecRoundTrip(t *testing.T) {
codec := &JSONCodec{MaxSize: 1024}
in, err := NewEnvelope(CmdSendMsg, sendMsgPayload{Text: "你好"})
if err != nil {
t.Fatalf("new envelope: %v", err)
}
in.Seq, in.ConvID = 7, "c_1_2"
raw, err := codec.Encode(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
out, err := codec.Decode(raw)
if err != nil {
t.Fatalf("decode: %v", err)
}
if out.Cmd != CmdSendMsg || out.Seq != 7 || string(out.Body) != `{"text":"你好"}` {
t.Fatalf("mismatch: %+v", out)
}
}
go test ./pkg/protocol/ -v
# 期望:--- PASS: TestJSONCodecRoundTrip (0.00s) ok github.com/example/im-go/pkg/protocol
常见坑
| 坑 | 后果 | 做法 |
|---|---|---|
载荷用 map[string]any | 数字全变 float64,ID 精度丢失 | 每个 cmd 定一个强类型结构体 |
| 秒与毫秒时间戳混用 | 排序错乱、消息时间倒退 | 全链路统一毫秒,字段名写 ts |
| 先反序列化再判长度 | 超大帧把内存打满 | 解码入口第一件事就是比长度 |
| protobuf 复用或改字段号 | 新旧客户端解析错位 | 字段号只新增不复用(第 16 章讲兼容) |
小结:协议落地的产物是「一个信封 + 一张命令字表 + 一组 Encode/Decode 实现」,外加往返单测与三个边界用例。信封稳定后,第 04 章就能拿 login 帧做鉴权,第 05 章把 send_msg 帧转成 gRPC 调用,第 10 章的 ACK 也只是再加一个命令字。