协议落地:消息结构定义与编解码

本章目标:定下 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 也只是再加一个命令字。

笔记加载中…