不能直接用json.Marshal或gob.Encoder,因二者不支持压缩字段名、跳过零值、自定义时间格式及对接遗留二进制协议;标准库序列化器要么文本开销大(json),要么不跨语言且不兼容(gob),而encoding.BinaryMarshaler/BinaryUnmarshaler提供轻量可控的字节流接管能力。

为什么不能直接用 json.Marshal 或 gob.Encoder
因为业务需要压缩字段名、跳过零值、支持自定义时间格式,或者要对接遗留二进制协议——这时候标准库的序列化器要么太重,要么不兼容。Go 的接口设计恰好允许你完全接管编解码逻辑,而不用改结构体定义或引入反射黑魔法。
-
encoding.BinaryMarshaler和encoding.BinaryUnmarshaler是最轻量、最可控的入口,只管字节流,不碰类型系统 - 别试图在
MarshalJSON里做二进制编码,JSON 是文本协议,json.Marshal会强制转义、加引号、排序 key,和紧凑二进制目标冲突 - 如果协议含 magic number 或版本头,必须在
MarshalBinary开头写死,否则下游无法识别帧边界
怎么写一个带版本控制的 MarshalBinary
真实协议几乎都带版本字段,否则一升级就全挂。关键不是“加个 byte”,而是让版本变更不影响旧数据解析,且新代码能降级处理老版本。
- 版本号写在前 2 字节(
binary.BigEndian.PutUint16(buf[0:], 1)),比单字节更易扩展 - 用
switch version分支处理不同格式,老版本分支必须保留,不能删 - 字段顺序必须固定:先写长度(如 name 长度 uint8),再写内容;零值字段可跳过,但需在文档里明确“跳过即默认值”
- 示例片段:
func (u User) MarshalBinary() ([]byte, error) { buf := make([]byte, 2+1+len(u.Name)+8) // ver(2) + nameLen(1) + name + ts(8) binary.BigEndian.PutUint16(buf[0:], 1) buf[2] = uint8(len(u.Name)) copy(buf[3:], u.Name) binary.BigEndian.PutUint64(buf[3+len(u.Name):], uint64(u.Created.UnixMilli())) return buf, nil }
如何避免 UnmarshalBinary panic 在生产环境
网络收到的字节流永远不可信,UnmarshalBinary 必须当作边界输入处理——越界读、非法版本、长度溢出都会直接 panic,而 Go 的 panic 不会自动 recover。
- 开头校验长度:
if len(data) ,3 = 最小头长(2 版本 + 1 nameLen) - 用
binary.Uvarint替代PutUint64读取变长整数,它自带长度检查,不会越界 - name 长度字段是 uint8,但实际拷贝前要检查:
if int(nameLen) > len(data)-3 { return errors.New("name length exceeds buffer") } - 时间戳用
time.UnixMilli(int64(ts)),别用time.Unix(0, ts*1e6),后者在 ts 为负时行为异常
什么时候该用 gob 而不是手写编解码
手写二进制只适合协议稳定、字段少、性能敏感的场景。一旦结构体嵌套深、有 map/slice/接口字段,或需要跨语言互通,立刻退回到 gob 或 Protocol Buffers。
-
gob自动处理指针、nil slice、struct 嵌套,但输出不可读、不跨语言、不向前兼容(字段重命名=破坏性变更) - 如果只是临时进程间通信(如父子进程 pipe),
gob.NewEncoder(pipe).Encode(v)比手写安全十倍 - 别给
gob加自定义GobEncode——除非你真清楚GobDecode怎么匹配字节序和类型 ID,否则极易出现 “gob: type not found”
手写编解码最麻烦的从来不是写逻辑,而是维护协议文档和版本迁移表。每次改字段,都要同步更新 wire format 文档里的 offset 表,否则半年后没人记得 buf[17] 是用户等级还是积分余额。

















