3、信令服务器搭建:WebSocket基础、Node.js信令服务器实现、房间管理逻辑、信令消息协议设计

信令服务器,说白了就是 WebRTC 通话的「总调度室」。没有它,两个浏览器之间连招呼都打不上。我记得刚接触 WebRTC 时,以为只要拿到媒体流就能直接 P2P 通信,结果发现连对方的 IP 地址都不知道——嗯,这就是信令要解决的问题。

3.1 WebSocket 基础:为什么选它?

WebRTC 本身不规定信令传输方式。你可以用 HTTP 轮询、长轮询、甚至用鸽子送信(开玩笑)。但实际项目中,WebSocket 几乎是唯一选择。为什么?

  • 全双工:服务器可以主动推消息给客户端,不用等客户端轮询。
  • 低延迟:建立连接后,消息走 TCP 长连接,省去 HTTP 握手开销。
  • 浏览器原生支持:WebSocket API 是 W3C 标准,不用额外库。

我在项目中遇到过用 Socket.IO 做信令的团队,后来发现多了一层自定义协议,调试时反而麻烦。我个人习惯直接用原生 WebSocket,简单可控。

小提示:WebSocket 的 URL 使用 ws:// 或 wss://(加密)。生产环境务必用 wss://,否则浏览器会报安全错误。

3.2 Node.js 信令服务器实现

我们用 Node.js 的 ws 库来实现。安装很简单:

npm install ws

一个最简的信令服务器长这样:

const WebSocket = require('ws');
const server = new WebSocket.Server({ port: 8080 });

server.on('connection', (ws) => {
  console.log('新客户端连接');

  ws.on('message', (message) => {
    const data = JSON.parse(message);
    // 处理信令消息
    handleSignaling(data, ws);
  });

  ws.on('close', () => {
    console.log('客户端断开');
    // 清理房间状态
  });
});

function handleSignaling(data, ws) {
  // 根据消息类型分发
  switch (data.type) {
    case 'join':
      // 加入房间逻辑
      break;
    case 'offer':
      // 转发 SDP offer
      break;
    case 'ice-candidate':
      // 转发 ICE 候选
      break;
    default:
      console.warn('未知消息类型:', data.type);
  }
}

你想想看,这个服务器本身不处理媒体流,它只做一件事:转发。把 A 的 SDP 和 ICE 候选转发给 B,把 B 的转发给 A。

注意:千万不要在信令服务器里做媒体数据的编解码或混音。信令服务器只负责控制信令,不做媒体处理。我曾经见过有人试图在信令服务器里做音频转码,结果延迟飙升到 2 秒以上。

3.3 房间管理逻辑

房间管理是信令服务器的核心。一个房间就是一个通话组,通常包含 2 个或更多参与者。我习惯用 Map 来管理:

// 房间数据结构
const rooms = new Map();

// 房间对象结构
class Room {
  constructor(roomId) {
    this.roomId = roomId;
    this.participants = new Map(); // userId -> WebSocket
    this.maxParticipants = 2;      // 默认双人通话
  }

  addParticipant(userId, ws) {
    if (this.participants.size >= this.maxParticipants) {
      return { success: false, error: '房间已满' };
    }
    this.participants.set(userId, ws);
    return { success: true };
  }

  removeParticipant(userId) {
    this.participants.delete(userId);
    if (this.participants.size === 0) {
      rooms.delete(this.roomId); // 房间空了就销毁
    }
  }

  broadcast(message, excludeUserId = null) {
    this.participants.forEach((ws, userId) => {
      if (userId !== excludeUserId && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify(message));
      }
    });
  }
}

房间管理的几个关键点:

  • 房间创建:第一个用户加入时自动创建。
  • 房间销毁:最后一个用户离开时自动清理。
  • 容量控制:双人通话限制 2 人,多人会议可以放宽。
  • 心跳检测:定期 ping/pong 检测客户端是否存活。
避坑指南:我曾经在生产环境中遇到一个问题:用户断网重连后,旧 WebSocket 连接还在房间里,导致新连接无法加入。解决方案是在用户重连时,先踢掉旧连接再允许新连接加入。

3.4 信令消息协议设计

信令协议就是客户端和服务器之间的「对话规则」。我设计了一套简洁的 JSON 协议:

消息类型 方向 说明 关键字段
join 客户端 → 服务器 请求加入房间 roomId, userId
joined 服务器 → 客户端 加入成功通知 roomId, participants
offer 客户端 → 服务器 → 对端 转发 SDP offer sdp, targetUserId
answer 客户端 → 服务器 → 对端 转发 SDP answer sdp, targetUserId
ice-candidate 客户端 → 服务器 → 对端 转发 ICE 候选 candidate, targetUserId
leave 客户端 → 服务器 离开房间 roomId
error 服务器 → 客户端 错误通知 code, message

消息格式示例:

// 客户端发送 offer
{
  "type": "offer",
  "roomId": "room-123",
  "targetUserId": "user-b",
  "sdp": {
    "type": "offer",
    "sdp": "v=0\r\no=- ..."
  }
}

// 服务器转发给目标用户
{
  "type": "offer",
  "fromUserId": "user-a",
  "sdp": {
    "type": "offer",
    "sdp": "v=0\r\no=- ..."
  }
}

协议设计原则:

  • 类型驱动:每个消息必须有 type 字段,服务器根据类型分发。
  • 目标明确:转发类消息必须指定 targetUserId,避免广播给所有人。
  • 错误可追溯:错误消息带 code 和 message,方便客户端定位问题。
  • 版本兼容:在消息中加入 version 字段,方便协议升级。
经验之谈:我建议在协议中加入 timestamp 字段,用于调试时追踪消息延迟。另外,ICE 候选可能很多(尤其是移动端),可以考虑批量发送,减少信令消息数量。

3.5 信令流程全景图

下面这张图展示了完整的信令交互流程:

WebRTC 信令交互流程 用户 A (发起方) 信令服务器 用户 B (接收方) 1. join(room-123) 2. user-joined(A) 3. offer(SDP) 4. offer(SDP) 5. answer(SDP) 6. answer(SDP) ICE 候选交换(双向,可能多次) 7. ice-candidate → 服务器 → 对端 P2P 连接建立,开始通话

流程总结:

  1. 加入房间:A 发送 join,服务器通知 B 有新用户。
  2. SDP 交换:A 创建 offer,经服务器转发给 B;B 创建 answer,经服务器转发给 A。
  3. ICE 候选交换:双方收集 ICE 候选,通过服务器转发给对方。
  4. P2P 连接建立:双方 ICE 协商成功,媒体流直连。
核心要点:信令服务器只做转发,不做媒体处理。SDP 和 ICE 候选的生成与解析完全在客户端完成。服务器一旦完成转发任务,后续媒体流就走 P2P 了,信令服务器不再参与。

3.6 实战中的坑与优化

最后分享几个我在实际项目中踩过的坑:

  • WebSocket 重连:客户端断网后要自动重连,重连后重新加入房间。我建议用指数退避策略,避免服务器被打爆。
  • 消息顺序:WebSocket 保证 TCP 顺序,但多个客户端之间的消息到达顺序不确定。比如 B 的 answer 可能比 A 的 ice-candidate 先到。客户端要做好状态机,忽略乱序消息。
  • 房间超时清理:如果用户异常断开(比如直接关浏览器),服务器可能收不到 close 事件。我习惯加一个定时器,定期清理长时间无心跳的房间。
  • 日志记录:每个信令消息都打日志,包括时间戳、消息类型、用户 ID。排查问题时,日志就是你的救命稻草。
重要:生产环境的信令服务器一定要做鉴权。最简单的做法是在 join 消息里带上 token,服务器验证通过后才允许加入房间。否则任何人都能乱入你的通话。