18、WebSocket信令:信令流程设计、房间管理、信令消息格式

聊到WebRTC,很多人第一反应是P2P、是音视频流。但说实话,真正让WebRTC跑起来的,是信令。没有信令,两个浏览器就是两个孤岛,谁也找不到谁。

我刚开始做WebRTC的时候,也犯过这个错误——花了大把时间调编码参数、折腾NAT穿透,结果发现连最基本的“谁邀请谁”都没想清楚。嗯,信令才是整个系统的粘合剂。

信令的核心作用

信令说白了就是“握手”。它负责三件事:

  • 交换媒体能力:你支持什么编码?我支持什么编码?咱俩得先对齐一下。
  • 交换网络信息:你的IP和端口是多少?我的Candidate有哪些?
  • 控制会话状态:谁加入、谁离开、谁静音了。

WebRTC标准里并没有规定信令必须用什么协议。你可以用WebSocket、HTTP轮询、甚至用鸽子送信(理论上)。但实际项目中,WebSocket几乎是唯一的选择。为什么?因为它双向、实时、低延迟。你想想看,如果每次交换SDP都要发个HTTP请求,那延迟得多感人。

核心观点:信令不是WebRTC标准的一部分,但它是WebRTC应用必不可少的一部分。选对信令方案,项目就成功了一半。

信令流程设计

一个典型的WebRTC信令流程长什么样?我画了一张图,你看完就明白了。

WebRTC 信令流程 发起方 A 信令服务器 接收方 B ① 创建房间 返回 roomId ② 加入房间(roomId) 通知有新成员 ③ 发送Offer(SDP) 转发Offer ④ 返回Answer(SDP) 转发Answer ⑤ ICE Candidate交换(双向)

流程其实不复杂。我拆解一下:

  1. 创建房间:发起方A告诉服务器,我要开个房间。服务器返回一个roomId。
  2. 加入房间:接收方B拿着roomId加入。服务器通知A:有人来了。
  3. 交换Offer/Answer:A创建Offer(包含SDP),通过服务器转发给B。B创建Answer,再转发回来。
  4. ICE Candidate交换:双方各自收集网络Candidate,通过信令通道交换。这一步是双向的,而且可能来回很多次。

我的经验:ICE Candidate交换不要等SDP交换完才开始。我习惯在创建Offer/Answer的同时就开始收集Candidate,然后边收集边发送。这样能节省至少几百毫秒的连接建立时间。

房间管理

房间管理是信令服务器的核心功能。说白了,就是维护一个“谁在哪个房间”的映射表。

我见过很多新手直接把房间信息存在内存里,用个Map搞定。小规模没问题,但一旦服务重启,所有房间就丢了。我曾经在一个项目中吃过这个亏——线上服务重启,几十个正在进行的会议全部断连,用户直接炸了。

所以,房间管理要考虑这几个维度:

房间生命周期

阶段 操作 说明
创建 生成唯一roomId,设置最大人数 我习惯用UUID+时间戳,避免冲突
加入 校验roomId是否存在,人数是否满 满了就返回错误码,别让用户傻等
离开 从房间移除用户,通知其他人 如果房间空了,可以延迟销毁
销毁 清理房间资源 建议加个超时机制,防止僵尸房间

房间数据结构

我一般这样设计房间的数据结构:

{
  "roomId": "room_abc123",
  "createdAt": 1700000000000,
  "maxMembers": 4,
  "members": {
    "user_001": {
      "userId": "user_001",
      "userName": "Alice",
      "joinedAt": 1700000001000,
      "mediaStatus": {
        "audio": true,
        "video": true
      }
    },
    "user_002": {
      "userId": "user_002",
      "userName": "Bob",
      "joinedAt": 1700000002000,
      "mediaStatus": {
        "audio": true,
        "video": false
      }
    }
  }
}

注意:不要把敏感信息(如IP地址、ICE Candidate)直接暴露在房间信息里。这些应该通过信令消息单独传递,而且只发给目标用户。

信令消息格式

信令消息的格式,我建议统一用JSON。简单、可读、好扩展。别搞什么二进制协议,除非你的场景对带宽极度敏感。

我总结了一套消息格式,经过多个项目验证,你可以直接拿来用:

基础消息结构

{
  "type": "message_type",
  "from": "user_id",
  "to": "user_id | room_id",
  "payload": {},
  "timestamp": 1700000000000,
  "msgId": "msg_unique_id"
}

每个字段的含义:

  • type:消息类型,后面会详细列举
  • from:发送方ID
  • to:接收方ID或房间ID。如果是房间广播,填roomId
  • payload:具体数据,根据type不同而变化
  • timestamp:时间戳,用于消息排序和去重
  • msgId:消息唯一ID,防止重复处理

消息类型定义

类型 方向 说明
join client → server 加入房间请求
joined server → client 加入成功通知
new_peer server → client 有新成员加入,通知房间内其他人
offer client → server → client 转发SDP Offer
answer client → server → client 转发SDP Answer
ice_candidate client → server → client 转发ICE Candidate
leave client → server 离开房间
peer_left server → client 通知其他人有人离开
media_status client → server → client 通知音视频状态变化(静音/取消静音)
error server → client 错误信息

几个关键消息的payload示例

offer 消息

{
  "type": "offer",
  "from": "user_001",
  "to": "user_002",
  "payload": {
    "sdp": "v=0\r\no=- ...",
    "type": "offer"
  },
  "timestamp": 1700000000000,
  "msgId": "msg_001"
}

ice_candidate 消息

{
  "type": "ice_candidate",
  "from": "user_001",
  "to": "user_002",
  "payload": {
    "candidate": "candidate:1 1 UDP 2122252543 192.168.1.1 54321 typ host",
    "sdpMid": "0",
    "sdpMLineIndex": 0
  },
  "timestamp": 1700000000000,
  "msgId": "msg_002"
}

避坑指南:我曾经遇到过一个问题——ICE Candidate发得太快,对方还没设置好远端SDP就收到了。结果Candidate被丢弃了。解决方案很简单:在信令服务器端做个缓冲,等对方设置完SDP后再转发Candidate。或者客户端收到Candidate后先缓存,等setRemoteDescription完成后再处理。

信令服务器的实现要点

最后聊几个实现上的关键点:

  • 连接管理:每个WebSocket连接对应一个用户。断开时要及时清理房间成员信息。
  • 消息路由:根据to字段判断是单播还是广播。单播直接转发给目标用户,广播则遍历房间内所有成员。
  • 心跳机制:WebSocket可能意外断开。我一般每30秒发一次ping,60秒没收到pong就判定断开。
  • 消息去重:网络抖动可能导致消息重复。用msgId做去重,服务端维护一个最近消息ID的缓存。
  • 错误处理:用户加入不存在的房间、房间已满、消息格式错误……都要返回明确的错误码和描述。

信令这东西,看起来简单,但细节决定成败。我见过太多项目因为信令设计不合理,导致连接不稳定、消息丢失、状态混乱。花点时间把信令流程和消息格式设计好,后面会省很多事。


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