4、消息协议设计:JSON消息格式定义、消息类型枚举、消息序列化与反序列化
消息协议,说白了就是设备之间沟通的「共同语言」。多屏互动系统里,手机、平板、电视、投影仪要能互相理解,就得有一套大家都遵守的规矩。我个人习惯,先把消息格式定死,再谈别的。
4.1 为什么选 JSON?
你可能要问,为什么不直接用二进制?嗯,这里有个取舍。二进制确实省带宽,但调试起来太痛苦了。我记得有一次排查一个投屏问题,抓包出来全是乱码,折腾了一整天才发现是字节序搞反了。
JSON 的好处很明显:
- 人类可读 —— 出问题了,打开日志一眼就能看出哪不对
- 跨语言 —— C++、Java、Python、JavaScript 都能解析
- 生态成熟 —— 几乎每种语言都有现成的库
当然,JSON 也有缺点,比如数据冗余大、解析慢。但在多屏互动这种场景下,消息量不大,JSON 完全够用。真要追求极致性能,可以后续再优化成 Protocol Buffers。
4.2 消息格式定义
我设计消息格式时,遵循一个原则:头尾固定,中间灵活。每条消息都包含一个固定结构的头部,和一个可变的数据体。
核心消息结构:
{
"version": "1.0",
"msgId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "SCREEN_CAST_START",
"timestamp": 1699000000123,
"source": "phone_01",
"target": "tv_living_room",
"payload": {
// 具体业务数据
}
}
每个字段的含义:
| 字段 | 类型 | 说明 |
|---|---|---|
| version | string | 协议版本号,用于兼容升级 |
| msgId | string (UUID) | 全局唯一消息ID,用于去重和追踪 |
| type | string | 消息类型枚举值 |
| timestamp | number (毫秒) | 消息生成时间戳 |
| source | string | 发送方设备ID |
| target | string | 接收方设备ID(广播时可为空) |
| payload | object | 业务数据体 |
小技巧:msgId 一定要用 UUID,别用自增ID。我曾经遇到过两个设备同时发消息,自增ID冲突导致消息丢失,排查了好久才发现是ID重复了。
4.3 消息类型枚举
消息类型我用枚举来管理,这样代码里不会出现魔法字符串。我习惯把类型分成几大类:
// 设备发现与连接
DEVICE_DISCOVER = "DEVICE_DISCOVER" // 发现设备
DEVICE_JOIN = "DEVICE_JOIN" // 设备加入
DEVICE_LEAVE = "DEVICE_LEAVE" // 设备离开
DEVICE_HEARTBEAT = "DEVICE_HEARTBEAT" // 心跳
// 投屏控制
SCREEN_CAST_START = "SCREEN_CAST_START" // 开始投屏
SCREEN_CAST_STOP = "SCREEN_CAST_STOP" // 停止投屏
SCREEN_CAST_PAUSE = "SCREEN_CAST_PAUSE" // 暂停投屏
SCREEN_CAST_RESUME = "SCREEN_CAST_RESUME" // 恢复投屏
// 媒体控制
MEDIA_PLAY = "MEDIA_PLAY" // 播放
MEDIA_PAUSE = "MEDIA_PAUSE" // 暂停
MEDIA_SEEK = "MEDIA_SEEK" // 跳转
MEDIA_VOLUME = "MEDIA_VOLUME" // 音量
// 系统控制
SYSTEM_REBOOT = "SYSTEM_REBOOT" // 重启
SYSTEM_UPDATE = "SYSTEM_UPDATE" // 更新
SYSTEM_ERROR = "SYSTEM_ERROR" // 错误报告
你可能会问,为什么不用数字枚举?数字确实省空间,但可读性太差了。日志里看到 type: 3,你还得去查表才知道是什么意思。字符串虽然长一点,但一眼就能看懂。
4.4 消息序列化与反序列化
序列化就是把对象转成 JSON 字符串,反序列化就是反过来。听起来简单,但坑不少。
4.4.1 序列化实现
我用 JavaScript 举个例子,其他语言思路一样:
class Message {
constructor(type, payload, source, target) {
this.version = "1.0";
this.msgId = this.generateUUID();
this.type = type;
this.timestamp = Date.now();
this.source = source;
this.target = target || "";
this.payload = payload;
}
generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// 序列化
serialize() {
return JSON.stringify(this);
}
// 反序列化(静态方法)
static deserialize(jsonStr) {
try {
const obj = JSON.parse(jsonStr);
// 校验必填字段
if (!obj.type || !obj.msgId) {
throw new Error("消息格式错误:缺少必填字段");
}
return obj;
} catch (e) {
console.error("反序列化失败:", e.message);
return null;
}
}
}
// 使用示例
const msg = new Message(
"SCREEN_CAST_START",
{ resolution: "1920x1080", fps: 60 },
"phone_01",
"tv_living_room"
);
const jsonStr = msg.serialize();
console.log(jsonStr);
// 输出: {"version":"1.0","msgId":"a1b2...","type":"SCREEN_CAST_START",...}
const parsed = Message.deserialize(jsonStr);
console.log(parsed.type); // SCREEN_CAST_START
注意:反序列化一定要加 try-catch。网络传输中什么情况都可能发生,收到一段损坏的 JSON 是常有的事。不加异常处理,整个程序可能直接崩溃。
4.4.2 反序列化的校验
我曾经在项目里吃过亏:反序列化时没做校验,结果收到一条缺少 type 字段的消息,程序直接抛空指针异常。从那以后,我每次反序列化都会做三层校验:
- 格式校验 —— 是不是合法的 JSON
- 必填字段校验 —— type、msgId、version 不能为空
- 业务逻辑校验 —— type 值是否在枚举范围内
function validateMessage(obj) {
// 第一层:格式校验
if (typeof obj !== 'object' || obj === null) {
return { valid: false, reason: "消息格式错误" };
}
// 第二层:必填字段
const required = ['version', 'msgId', 'type', 'timestamp'];
for (const field of required) {
if (obj[field] === undefined || obj[field] === null) {
return { valid: false, reason: `缺少必填字段: ${field}` };
}
}
// 第三层:类型枚举校验
const validTypes = [
'DEVICE_DISCOVER', 'DEVICE_JOIN', 'DEVICE_LEAVE',
'SCREEN_CAST_START', 'SCREEN_CAST_STOP',
'MEDIA_PLAY', 'MEDIA_PAUSE'
// ... 完整列表
];
if (!validTypes.includes(obj.type)) {
return { valid: false, reason: `未知消息类型: ${obj.type}` };
}
return { valid: true };
}
4.5 消息协议的整体流程
说了这么多,咱们用一张图把整个流程串起来:
4.6 避坑指南
最后,分享几个我踩过的坑:
坑1:时间戳精度
我曾经用秒级时间戳,结果两个消息在同一秒内发出,导致顺序错乱。后来统一改成毫秒级,问题解决。
坑2:字段命名风格
团队里有人用 camelCase,有人用 snake_case,解析的时候各种报错。我建议统一用 camelCase,因为 JavaScript 和 Java 都默认支持。
坑3:超大 payload
有一次投屏传了一张 4K 截图,JSON 字符串直接 10MB+,网络传输超时。后来我规定:payload 超过 1MB 的数据,先压缩再传输,或者改用分片传输。