信令协议设计:消息类型与JSON格式

好,咱们接着聊信令协议的设计。说实话,这部分是整个信令服务器的「灵魂」。你想想看,WebRTC 的媒体数据是走 P2P 的,但建立连接前的「握手」全靠信令。如果信令协议设计得乱七八糟,后面调试起来会非常痛苦。

我在早期的一个项目中,就吃过信令格式不统一的亏。当时团队里有人用 snake_case,有人用 camelCase,结果解析的时候各种踩坑。后来我痛定思痛,定了一套规范,再也没出过类似问题。

信令消息类型定义

我们先明确一下,信令服务器需要处理哪些消息。说白了,就是客户端之间要交换哪些信息才能把 P2P 连接建起来。

我个人习惯把消息类型分为两大类:媒体协商类房间管理类。下面这张图可以帮你快速理解整体流程:

信令消息类型与交互流程 客户端 A 信令服务器 客户端 B join(room1) 广播: user_joined offer(sdp) 转发 offer answer(sdp) 转发 answer candidate(ice) 转发 candidate leave 广播: user_left 客户端→服务器 服务器转发/广播 客户端→服务器

核心消息类型详解

我们一个一个来看。每种消息都有它存在的意义,缺一不可。

1. join —— 加入房间

这是客户端进入房间的第一步。客户端发送 join 消息,告诉服务器「我要进哪个房间」。服务器收到后,会把客户端加入房间列表,并通知房间里其他人。

关键点:join 消息里必须包含房间 ID 和用户标识。我个人习惯用 userId 而不是 username,因为 userId 更稳定,不会因为改名而出问题。

2. leave —— 离开房间

客户端主动断开连接时发送 leave。注意,如果客户端直接断网(比如浏览器崩溃),服务器应该通过 WebSocket 的 close 事件来检测,然后自动触发 leave 逻辑。

我曾经踩过的坑:早期我设计时只处理了主动 leave,没处理异常断开。结果用户浏览器崩溃后,房间里还残留着他的「幽灵」状态,其他用户一直尝试连接一个不存在的 peer。后来我加了心跳检测和超时清理,才算彻底解决。

3. offer —— 发起媒体协商

offer 消息携带的是发起方的 SDP(Session Description Protocol)。说白了,就是告诉对方「我支持哪些编解码器、我的网络情况如何」。这是 WebRTC 连接建立的起点。

4. answer —— 响应媒体协商

answer 是接收方对 offer 的回应。它同样携带 SDP,但内容是接收方根据自己的能力做出的选择。比如发起方说「我支持 H.264 和 VP8」,接收方可能回答「我用 H.264」。

5. candidate —— ICE 候选者交换

candidate 消息携带的是 ICE 候选者信息。嗯,这里要注意,candidate 可能不止一个。实际上,一个客户端可能会发现多个候选者(本地 IP、公网 IP、TURN 服务器地址等),每个都要通过信令服务器转发给对方。

小技巧:candidate 的传输顺序不重要,但一定要全部传完。我建议在客户端收集完所有候选者后再统一发送,而不是收集一个发一个。这样可以减少信令服务器的转发压力。

JSON 消息格式设计

格式统一是信令协议设计的重中之重。我推荐使用以下结构:

{
  "type": "消息类型",
  "roomId": "房间ID",
  "userId": "用户ID",
  "payload": {
    // 具体数据
  },
  "timestamp": 1699000000000
}

为什么这么设计?我给你拆解一下:

  • type:必填字段,标识消息类型。取值就是上面说的那五种:join、leave、offer、answer、candidate。
  • roomId:必填字段。服务器需要知道这条消息属于哪个房间。
  • userId:必填字段。标识发送者是谁。
  • payload:可选字段,存放具体数据。不同类型的消息,payload 结构不同。
  • timestamp:可选字段,但强烈建议加上。调试时非常有用,可以追踪消息延迟。

各消息类型的 payload 设计

不同类型的消息,payload 里装的东西不一样。我们来看具体设计:

消息类型 payload 字段 说明
join 无(或可选 displayName) 加入房间,可附带显示名称
leave 离开房间,无需额外数据
offer sdp: string 发起方的 SDP 字符串
answer sdp: string 接收方的 SDP 字符串
candidate candidate: string, sdpMid: string, sdpMLineIndex: number ICE 候选者信息

来看几个完整的示例:

join 消息示例

{
  "type": "join",
  "roomId": "room-001",
  "userId": "user-alice",
  "payload": {
    "displayName": "Alice"
  },
  "timestamp": 1699000000000
}

offer 消息示例

{
  "type": "offer",
  "roomId": "room-001",
  "userId": "user-alice",
  "payload": {
    "sdp": "v=0\r\no=- 123456 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n..."
  },
  "timestamp": 1699000001000
}

candidate 消息示例

{
  "type": "candidate",
  "roomId": "room-001",
  "userId": "user-alice",
  "payload": {
    "candidate": "candidate:1 1 UDP 2122252543 192.168.1.100 54321 typ host",
    "sdpMid": "0",
    "sdpMLineIndex": 0
  },
  "timestamp": 1699000002000
}

服务器广播消息设计

除了客户端之间的点对点消息,服务器还需要广播一些系统消息。比如当有人加入或离开房间时,服务器要通知房间里所有人。

广播消息的格式和客户端消息保持一致,只是 type 字段会有所不同:

{
  "type": "user_joined",
  "roomId": "room-001",
  "userId": "user-alice",
  "payload": {
    "displayName": "Alice"
  },
  "timestamp": 1699000000000
}

核心原则:所有消息格式必须统一。不管是客户端发的,还是服务器广播的,都遵循同一个 JSON 结构。这样客户端解析时只需要一套代码,省心省力。

设计时的几个注意事项

最后,分享几个我在实际项目中总结的经验:

  • 字段命名要一致:别一会儿用 userId,一会儿用 user_id。我统一用 camelCase,因为 JavaScript 解析起来最自然。
  • 预留扩展字段:payload 设计成对象而不是直接放字符串,就是为了以后扩展。比如 offer 消息以后可能加个 codec 优先级字段,直接往 payload 里加就行。
  • 消息大小要控制:SDP 字符串可能很长,但别超过 WebSocket 的消息大小限制。一般来说 64KB 以内是安全的。
  • 加个消息 ID:虽然上面没列出来,但我建议在顶层加个 messageId 字段。这样客户端可以做去重处理,防止消息重复执行。

好了,信令协议的设计就聊到这里。这套 JSON 格式我在多个项目中验证过,稳定可靠。你直接拿去用,基本不会出大问题。

公众号:蓝海资料掘金营,微信deep3321