2、自定义消息协议设计

协议设计这事儿,说简单也简单,说复杂也复杂。我刚开始做消息中间件的时候,总觉得随便定个格式就能用。结果呢?线上出了几次兼容性问题,改得我头皮发麻。后来我学乖了——协议设计,一定要先想清楚原则。

协议设计原则

我个人习惯把协议设计原则归纳为四点。你想想看,一个消息协议要在不同语言、不同系统之间跑来跑去,没点规矩怎么行?

  • 明确性:消息的边界必须清晰。不能出现「这个字节到底属于哪条消息」的歧义。
  • 可扩展性:版本升级时,老客户端不能崩。我在项目中遇到过,加了个字段,老服务直接解析失败——血的教训。
  • 高效性:序列化后的体积要小,解析速度要快。尤其是高频交易场景,多一个字节都是成本。
  • 跨语言友好:你的服务可能是Java写的,下游是Go,再下游是Python。协议必须大家都能理解。

核心原则就一句话:向前兼容,向后兼容,解析无歧义。

消息头与消息体结构

消息协议一般分两部分:消息头和消息体。头里放元信息,体里放业务数据。这个思路其实跟HTTP很像——你想想看,HTTP的Header和Body是不是也是这个道理?

我常用的消息头结构长这样:

// 消息头(固定长度,16字节)
+----------------+----------------+----------------+----------------+
|   Magic Number |   Version      |   Message Type |   Payload Len  |
|    (2 bytes)   |    (1 byte)    |    (1 byte)    |    (4 bytes)   |
+----------------+----------------+----------------+----------------+
|   Sequence ID  |   Timestamp    |   Reserved     |   Checksum     |
|    (4 bytes)   |    (4 bytes)   |    (2 bytes)   |    (2 bytes)   |
+----------------+----------------+----------------+----------------+

解释一下每个字段的作用:

字段 长度 说明
Magic Number 2字节 魔数,用于快速识别协议。我习惯用 0xABCD
Version 1字节 协议版本号。升级时靠这个做兼容
Message Type 1字节 消息类型,比如0x01表示心跳,0x02表示业务消息
Payload Len 4字节 消息体长度。没有这个,你都不知道该读多少字节
Sequence ID 4字节 序列号,用于请求-响应配对
Timestamp 4字节 时间戳,毫秒级
Reserved 2字节 保留字段,给未来扩展用
Checksum 2字节 校验和,防止数据在传输中被篡改

小技巧:Reserved字段一定要留。我曾经觉得「反正用不上」,结果后来加了个加密标志位,不得不改协议版本——麻烦得很。

消息体就简单了,放业务数据。可以是JSON字符串,也可以是Protobuf二进制。具体用哪个,看你的场景。

序列化与反序列化:JSON vs Protobuf

这是老生常谈的话题了。我两种都用过,说说我的感受。

JSON

  • 优点:人类可读,调试方便。你curl一下就能看到返回内容。
  • 缺点:体积大,解析慢。没有强类型约束,容易出幺蛾子。
  • 适用场景:对外API、调试阶段、低频通信。

Protobuf

  • 优点:体积小(比JSON小3-5倍),解析快,有强类型约束。
  • 缺点:二进制不可读,调试需要工具。版本管理要小心。
  • 适用场景:内部服务间通信、高频场景、移动端。

举个例子,同样的数据:

// JSON 格式
{
  "user_id": 12345,
  "name": "张三",
  "age": 28,
  "tags": ["vip", "active"]
}

// Protobuf 定义
message UserInfo {
  int32 user_id = 1;
  string name = 2;
  int32 age = 3;
  repeated string tags = 4;
}

JSON序列化后大概80字节,Protobuf只要30字节左右。差距就在这里。

注意:Protobuf的字段编号(=1, =2, =3...)一旦发布就不能改。我见过有人把字段1改成字段2,结果新旧版本解析全乱套了。

消息类型与路由键设计

消息类型决定了「这条消息是干嘛的」。路由键决定了「这条消息该发给谁」。这两个东西配合好了,你的系统才能灵活扩展。

我一般这样设计消息类型:

// 消息类型枚举
enum MessageType {
  HEARTBEAT      = 0x01;  // 心跳
  REQUEST        = 0x02;  // 请求
  RESPONSE       = 0x03;  // 响应
  NOTIFICATION   = 0x04;  // 通知
  ERROR          = 0x05;  // 错误
}

路由键的设计,我推荐用层级结构。比如:

// 路由键格式:服务名.模块名.操作名
user.profile.get
user.profile.update
order.create
order.pay.callback

这样做的好处是,消费者可以用通配符订阅。比如 user.*.* 就能收到所有用户相关的消息。RabbitMQ和Kafka都支持这种模式。

我曾经在一个项目里,路由键设计成扁平的字符串,比如 getUserProfile、updateUserProfile。后来服务多了,根本没法做批量订阅。改路由键又涉及一堆消费者——嗯,那段时间加班加得挺狠的。

知识体系总览

下面这张图,把本章的核心逻辑串起来了。你可以对照着看,心里有个谱。

自定义消息协议设计核心逻辑 协议设计原则 消息头结构 消息体结构 序列化方式 明确性 · 可扩展性 高效性 · 跨语言 Magic · Version Type · Length Seq · Checksum 业务数据 JSON / Protobuf JSON:可读 · 体积大 Protobuf:高效 · 强类型 消息类型 + 路由键设计 user.profile.get · order.create

说白了,消息协议设计就是一套「约定」。你定好了规则,大家按规矩办事,系统才能跑得稳。我见过太多项目,前期图省事随便搞搞,后期改协议改到崩溃——嗯,别问我怎么知道的。

我的建议:先画好协议结构图,再写代码。哪怕多花半天时间,也比上线后出问题强。

可可精品资料库