不能直接用gob或encoding/binary,因为gob不跨语言、体积大且升级后旧客户端可能panic;encoding/binary太底层,不支持字符串长度前缀、结构体嵌套、字段对齐和版本兼容,需手动实现Marshal/Unmarshal以精确控制字段顺序、长度前缀、内存布局与向后兼容。

为什么不能直接用 gob 或 encoding/binary
因为 gob 是 Go 专属、不跨语言、体积大,且升级后旧客户端可能 panic;encoding/binary 又太底层——它只写单个值,不处理字符串长度前缀、结构体嵌套、字段对齐或版本兼容。你真正要的不是“能序列化”,而是“字段顺序可控、长度显式、内存布局确定、老数据能读新代码能降级”。
BinaryMarshaler 和 BinaryUnmarshaler 怎么写才不出错
这是最轻量、最可控的入口,但容易在边界输入上 panic。关键不是写完接口就完事,而是把校验逻辑前置:
- 开头必须检查
len(data)是否 ≥ 最小头长(比如版本号+长度字段共 3 字节),否则直接返回io.ErrUnexpectedEOF - 字符串/切片字段必须带长度前缀(如
uint16),且拷贝前要验证:if int(msgLen) > len(data)-headerLen { return errors.New("buffer overflow") } - 优先用
binary.Uvarint读变长整数,它自带长度安全检查;固定长度读取(如binary.Read(r, binary.BigEndian, &v))极易因 buffer 不足而 panic - 版本号写在开头 2 字节(
binary.BigEndian.PutUint16(buf[0:], 1)),后续用switch version分支处理,老分支不能删
字段顺序和内存对齐怎么手动控制
Go 编译器可能在 int32 后插入 padding,但二进制协议里不能有这玩意。你没法用 #[repr(packed)],只能绕过:
- 所有结构体必须用
struct{}显式定义,禁止匿名字段(会破坏字段顺序) - 不要直接
binary.Write整个 struct,而是逐字段写入:binary.LittleEndian.PutUint64(buf[0:], e.Timestamp)→buf[8] = e.Level→binary.LittleEndian.PutUint16(buf[9:], uint16(len(e.Msg)))→copy(buf[11:], e.Msg) - 字符串内容必须紧随其长度字段之后,不能依赖
[]byte字段自动展开——binary.Write对 slice 是写 header,不是内容
如何让新增字段不影响老客户端
靠“跳过未知字段”比靠“默认值填充”更可靠。协议设计上必须支持字段级演进:
立即学习“go语言免费学习笔记(深入)”;
- 头部加
Version uint8+FieldCount uint8,每个字段再包一层“类型 ID + 长度 + 值”,解析时遇到不认识的类型 ID 就跳过 - 更实用的是标记字段法:比如
Flags uint8的 bit0 表示是否存在TraceID,bit1 表示是否有Tags,新字段只在对应 bit 置位时才读取 - 永远不要删字段,只增;新增字段编号必须唯一且文档化;老客户端忽略新字段是常态,不是 bug
真正的难点不在写 Marshal,而在 Unmarshal 时对非法输入的防御粒度——一个没校验的 data[0] 访问,就能让服务在收到恶意包时直接 crash。


















