4、信令服务器搭建:信令的作用、WebSocket基础、Node.js搭建信令服务器、信令消息格式设计

WebRTC 的媒体流是点对点直连的,但建立这个连接的过程,双方必须通过一个中间人交换元数据。这个中间人就是信令服务器。

很多人刚开始学 WebRTC 时,会误以为信令是协议的一部分。其实不是。WebRTC 标准并没有规定信令该怎么传——你可以用 WebSocket、HTTP 轮询、甚至用鸽子送纸条。但实际项目中,WebSocket 是最主流的选择。我最早做的一个视频会议项目,信令用的就是原生 WebSocket,后来换成了 Socket.IO,因为重连机制太省心了。

核心认知: 信令服务器不参与媒体流传输,它只负责在 Peer 之间交换 SDP 和 ICE Candidate。一旦连接建立,信令服务器就可以休息了。

4.1 信令到底在做什么?

说白了,信令就是让两个浏览器互相知道对方的存在,并且告诉对方「我支持什么编解码器、我的网络地址是什么」。没有信令,两个 Peer 就像在黑暗里互相摸索,永远碰不到一起。

信令的核心任务有三个:

  • 交换 SDP(Session Description Protocol):描述媒体能力,比如支持 H.264 还是 VP8,音频采样率是多少,视频分辨率多大。
  • 交换 ICE Candidate:告诉对方自己的 IP 地址和端口,用于建立 P2P 连接。
  • 管理房间/会话:谁加入了、谁离开了、谁在说话,这些状态需要信令来同步。

我遇到过一个问题:两个用户在同一个 NAT 后面,ICE Candidate 拿到的都是内网 IP,结果连不上。后来发现是信令没有正确传递 srflx 类型的 Candidate。嗯,这个坑后面会细讲。

4.2 WebSocket 基础

WebSocket 是信令的传输层。它和 HTTP 不同,HTTP 是请求-响应模式,服务器不能主动推数据。而 WebSocket 是全双工的,服务器可以随时给客户端发消息。你想想看,如果 ICE Candidate 收集需要实时推送,用 HTTP 轮询得多浪费资源。

WebSocket 的连接过程很简单:

  1. 客户端发起 HTTP 升级请求(Upgrade: websocket)
  2. 服务器返回 101 Switching Protocols
  3. 连接建立,双方开始收发帧数据

帧数据可以是文本(UTF-8)或二进制。信令消息通常用 JSON 文本格式,因为可读性好,调试方便。我个人习惯在开发阶段用文本帧,上线前如果对性能有极致要求,可以切到二进制编码。

小技巧: WebSocket 的 ping/pong 帧可以用来做心跳检测。我曾经在线上环境发现连接莫名其妙断开,就是因为 NAT 超时把 WebSocket 连接给掐了。加上心跳后,问题就解决了。

4.3 Node.js 搭建信令服务器

Node.js 搭配 ws 库,搭建一个信令服务器只需要几十行代码。我选 Node.js 的原因很简单:事件驱动、非阻塞 I/O,天然适合处理大量并发连接。而且 JavaScript 全栈统一,前端后端用同一套数据结构,省心。

先安装依赖:

npm init -y
npm install ws uuid

然后写一个简单的信令服务器:

const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');

const wss = new WebSocket.Server({ port: 8080 });
const rooms = new Map();

wss.on('connection', (ws) => {
  const clientId = uuidv4();
  ws.clientId = clientId;

  ws.on('message', (data) => {
    const msg = JSON.parse(data);
    handleMessage(ws, msg);
  });

  ws.on('close', () => {
    leaveRoom(ws);
  });
});

function handleMessage(ws, msg) {
  switch (msg.type) {
    case 'join':
      joinRoom(ws, msg.roomId);
      break;
    case 'offer':
    case 'answer':
    case 'ice-candidate':
      forwardMessage(ws, msg);
      break;
    default:
      console.warn('Unknown message type:', msg.type);
  }
}

这段代码的核心逻辑是:收到消息后根据 type 字段做路由。join 是加入房间,offer/answer/ice-candidate 是转发给房间里的其他 Peer。

转发消息的代码也很直接:

function forwardMessage(sender, msg) {
  const room = rooms.get(msg.roomId);
  if (!room) return;

  room.forEach(client => {
    if (client !== sender && client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify({
        type: msg.type,
        sender: sender.clientId,
        data: msg.data
      }));
    }
  });
}

这里有个细节:转发时要把 sender 带上,这样接收方才知道消息是谁发的。我见过有人忘了加 sender,结果 Peer 收到 offer 后不知道回复给谁。

注意: 生产环境一定要做房间容量限制和身份认证。我曾经在测试时忘了加限制,结果 200 个人挤进一个房间,信令服务器直接 OOM 了。

4.4 信令消息格式设计

信令消息的格式决定了整个系统的可扩展性和调试效率。我推荐用 JSON 格式,结构清晰,前后端通用。

一个标准的信令消息包含三个字段:

字段 类型 说明
type string 消息类型,如 join、offer、answer、ice-candidate
roomId string 房间标识,用于消息路由
data object 具体负载,如 SDP 或 ICE Candidate

具体消息示例:

// 加入房间
{ "type": "join", "roomId": "room-123", "data": { "username": "alice" } }

// 发送 Offer(SDP)
{
  "type": "offer",
  "roomId": "room-123",
  "data": {
    "sdp": "v=0\no=- 123456 2 IN IP4 127.0.0.1\n...",
    "type": "offer"
  }
}

// 发送 ICE Candidate
{
  "type": "ice-candidate",
  "roomId": "room-123",
  "data": {
    "candidate": "candidate:1 1 UDP 2122252543 192.168.1.100 54321 typ host",
    "sdpMid": "0",
    "sdpMLineIndex": 0
  }
}

我习惯在 type 字段上用动词命名,比如 join、leave、offer、answer。这样语义清晰,路由逻辑也简单。有人喜欢用数字枚举,但我觉得可读性太差,调试时还得翻文档。

经验之谈: 信令消息一定要加时间戳和消息 ID。我在排查线上问题时,经常需要根据时间戳还原事件顺序。没有时间戳,你根本不知道是 offer 先到还是 answer 先到。

4.5 信令流程全景图

下面这张图展示了完整的信令交互流程。从客户端加入房间,到交换 SDP 和 ICE Candidate,最后建立 P2P 连接。

信令交互流程 Peer A (发起方) 信令服务器 Peer B (接收方) 1. join(room-123) 2. 广播新成员 创建 Offer (SDP) 3. offer(SDP) 4. 转发 offer 创建 Answer (SDP) 5. answer(SDP) 6. 转发 answer 收集 ICE Candidate 7. ice-candidate 8. 转发 candidate 9. ice-candidate (反向) 10. 转发 candidate P2P 连接建立 P2P 连接建立 媒体流直连 (SRTP/SCTP) 信令服务器只在连接建立前工作,P2P 建立后不再参与媒体传输

从图中可以看到,信令服务器在整个流程中扮演的是「邮递员」角色。它不修改消息内容,只负责转发。一旦双方交换完 SDP 和 ICE Candidate,P2P 连接建立后,信令服务器就可以退出了。

4.6 避坑指南

信令服务器看起来简单,但实际项目中踩过的坑不少。我列几个常见的:

  • 消息顺序问题:ICE Candidate 可能在 SDP 交换完成之前就到达。接收方需要缓存这些 Candidate,等 setRemoteDescription 之后再添加。
  • 重复消息:WebSocket 重连后,客户端可能会重复发送 join 消息。服务器要做幂等处理,避免重复创建房间。
  • 心跳超时:NAT 设备会关闭长时间空闲的 UDP 映射。WebSocket 连接也会受影响。建议每 30 秒发一次 ping。
  • 消息大小限制:SDP 内容可能很大(尤其是包含多个视频轨道时)。WebSocket 默认帧大小限制是 64KB,如果 SDP 超过这个值,需要分片发送。
我曾经踩过的坑: 有一次线上环境,用户反馈视频连不上。排查了半天,发现是信令服务器把 SDP 里的换行符给过滤掉了。因为 JSON 序列化时没做转义处理,导致 SDP 解析失败。从那以后,我每次都会在信令服务器上加日志,打印原始消息内容。

信令服务器是整个 WebRTC 系统的「神经系统」。它不负责媒体传输,但它的稳定性直接决定了连接的成功率。我建议你在开发阶段就把信令服务器的日志、监控、重连机制都做好,不然后面排查问题会非常痛苦。