4、信令服务器搭建:信令的作用、WebSocket基础、Node.js搭建信令服务器、信令消息格式设计
WebRTC 的媒体流是点对点直连的,但建立这个连接的过程,双方必须通过一个中间人交换元数据。这个中间人就是信令服务器。
很多人刚开始学 WebRTC 时,会误以为信令是协议的一部分。其实不是。WebRTC 标准并没有规定信令该怎么传——你可以用 WebSocket、HTTP 轮询、甚至用鸽子送纸条。但实际项目中,WebSocket 是最主流的选择。我最早做的一个视频会议项目,信令用的就是原生 WebSocket,后来换成了 Socket.IO,因为重连机制太省心了。
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 的连接过程很简单:
- 客户端发起 HTTP 升级请求(Upgrade: websocket)
- 服务器返回 101 Switching Protocols
- 连接建立,双方开始收发帧数据
帧数据可以是文本(UTF-8)或二进制。信令消息通常用 JSON 文本格式,因为可读性好,调试方便。我个人习惯在开发阶段用文本帧,上线前如果对性能有极致要求,可以切到二进制编码。
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 后不知道回复给谁。
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。这样语义清晰,路由逻辑也简单。有人喜欢用数字枚举,但我觉得可读性太差,调试时还得翻文档。
4.5 信令流程全景图
下面这张图展示了完整的信令交互流程。从客户端加入房间,到交换 SDP 和 ICE Candidate,最后建立 P2P 连接。
从图中可以看到,信令服务器在整个流程中扮演的是「邮递员」角色。它不修改消息内容,只负责转发。一旦双方交换完 SDP 和 ICE Candidate,P2P 连接建立后,信令服务器就可以退出了。
4.6 避坑指南
信令服务器看起来简单,但实际项目中踩过的坑不少。我列几个常见的:
- 消息顺序问题:ICE Candidate 可能在 SDP 交换完成之前就到达。接收方需要缓存这些 Candidate,等 setRemoteDescription 之后再添加。
- 重复消息:WebSocket 重连后,客户端可能会重复发送 join 消息。服务器要做幂等处理,避免重复创建房间。
- 心跳超时:NAT 设备会关闭长时间空闲的 UDP 映射。WebSocket 连接也会受影响。建议每 30 秒发一次 ping。
- 消息大小限制:SDP 内容可能很大(尤其是包含多个视频轨道时)。WebSocket 默认帧大小限制是 64KB,如果 SDP 超过这个值,需要分片发送。
信令服务器是整个 WebRTC 系统的「神经系统」。它不负责媒体传输,但它的稳定性直接决定了连接的成功率。我建议你在开发阶段就把信令服务器的日志、监控、重连机制都做好,不然后面排查问题会非常痛苦。