3. WebRTC协议栈概览
信令,说白了就是让两个浏览器互相找到对方、商量好怎么通话的那套“暗号系统”。没有信令,WebRTC 的媒体流根本传不出去。我刚开始接触 WebRTC 时,以为 ICE 和 SDP 搞定一切就够了,结果发现信令才是整个链路的“大脑”——它不传音视频,但音视频能不能通,全看它。
3.1 信令到底在干什么?
信令的核心任务就三件:
- 会话控制:谁发起通话、谁挂断、谁静音,这些动作需要信令来协调。
- 媒体协商:交换 SDP(Session Description Protocol),告诉对方“我支持 H.264,分辨率 1280x720,你那边行不行?”
- 网络协商:交换 ICE Candidate,让双方知道“我的公网 IP 是 203.0.113.5,端口 3478,你试试连这个”。
你想想看,如果两个浏览器直接互发消息,没有中间人帮忙转达,那它们连对方的地址都不知道。所以信令必须走一个双方都能访问的“中间通道”。
关键认知:WebRTC 标准不规定信令协议,只规定信令需要传递的信息(SDP + ICE Candidate)。这意味着你可以用任何方式传——WebSocket、HTTP 轮询、甚至飞鸽传书(只要延迟能接受)。
3.2 常见信令协议对比
我在项目中用过三种主流方案,各有各的脾气。下面这张表是我自己整理的对比,你可以直接拿去用:
| 协议 | 传输方式 | 典型场景 | 优点 | 缺点 |
|---|---|---|---|---|
| WebSocket | 全双工 TCP | 浏览器 ↔ 服务器 | 低延迟、浏览器原生支持、简单 | 无标准消息格式,需自定协议 |
| SIP | UDP/TCP/TLS | VoIP 电话系统 | 成熟生态、与 PSTN 互通 | 信令头大、浏览器端需 JS-SIP 库 |
| XMPP | TCP/TLS | 即时通讯 + 通话 | 标准消息格式、支持 Presence | XML 冗余大、实时性不如 WebSocket |
我个人习惯:纯浏览器场景首选 WebSocket。为什么?因为浏览器里 WebSocket 是亲儿子,不需要额外库,一条连接搞定双向通信。SIP 和 XMPP 更适合需要对接传统电话网或 IM 系统的场景。
我的经验:有一次做视频客服系统,客户要求必须兼容老式 SIP 话机。我们不得不在服务器端做 SIP ↔ WebSocket 的协议转换桥。那段时间我每天都在跟 SIP 头字段搏斗……后来我学乖了:如果客户端全是浏览器,就别自找麻烦上 SIP。
3.3 信令状态机设计
信令状态机是保证通话不乱套的关键。没有状态机,你可能会收到一个“挂断”消息时,对方还没发起通话——那程序就懵了。
我设计过一个通用的信令状态机,核心状态就这几个:
- Idle:空闲状态,等待发起或接收通话。
- OfferSent:已发送 SDP Offer,等待 Answer。
- OfferReceived:已收到 SDP Offer,等待用户响应。
- Connecting:双方 SDP 已交换,ICE 正在连通。
- Connected:媒体流已建立,正常通话中。
- Terminated:通话结束,清理资源。
注意:状态机必须处理异常状态,比如“对方挂断后你又发了 Offer”。我曾经在项目中漏掉了这个分支,结果用户点了两次“挂断”按钮,状态直接卡死,再也打不了电话。后来我加了一个 Error 状态,任何非法转换都先进入 Error 再重置到 Idle。
下面是我画的一个信令状态机 SVG 图,展示了核心状态流转:
3.4 实战:用 WebSocket 实现信令
下面是一个极简的信令服务器示例(Node.js + ws 库),只做消息转发:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
// 房间管理:roomId -> Set<ws>
const rooms = new Map();
wss.on('connection', (ws) => {
let currentRoom = null;
ws.on('message', (data) => {
const msg = JSON.parse(data);
switch (msg.type) {
case 'join':
// 加入房间
currentRoom = msg.roomId;
if (!rooms.has(currentRoom)) {
rooms.set(currentRoom, new Set());
}
rooms.get(currentRoom).add(ws);
// 通知房间内其他用户
broadcast(currentRoom, { type: 'user-joined', userId: msg.userId }, ws);
break;
case 'offer':
case 'answer':
case 'ice-candidate':
// 转发给房间内其他用户
broadcast(currentRoom, msg, ws);
break;
case 'leave':
leaveRoom(ws, currentRoom);
break;
}
});
ws.on('close', () => {
leaveRoom(ws, currentRoom);
});
});
function broadcast(roomId, msg, sender) {
if (!rooms.has(roomId)) return;
rooms.get(roomId).forEach(client => {
if (client !== sender && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(msg));
}
});
}
function leaveRoom(ws, roomId) {
if (roomId && rooms.has(roomId)) {
rooms.get(roomId).delete(ws);
broadcast(roomId, { type: 'user-left' }, ws);
if (rooms.get(roomId).size === 0) {
rooms.delete(roomId);
}
}
}
避坑指南:我曾经在生产环境遇到过一个问题——信令消息顺序错乱。Offer 还没到,Answer 先到了。解决方案很简单:在客户端维护一个消息队列,按 SDP 协商的时序依次处理。另外,ICE Candidate 可以批量发送,不要每发现一个就发一条,否则信令通道会爆炸。
3.5 信令设计中的常见坑
- 信令与媒体通道混用:别把信令和媒体流放在同一个连接里。信令丢了可以重发,媒体丢了就卡顿。分开处理,各自优化。
- 状态不同步:客户端和服务器的状态机必须一致。我见过一个 bug:客户端显示“通话中”,服务器却认为已经挂断。后来我们加了一个心跳机制,每 5 秒同步一次状态。
- 安全考虑:信令通道必须加密(WSS/HTTPS),否则 SDP 和 ICE Candidate 被中间人截获,别人就能冒充你通话。
重要提醒:信令设计没有银弹。WebSocket 适合轻量级场景,SIP 适合电信级互通,XMPP 适合带 IM 的系统。选型时先问自己三个问题:客户端是什么?需要和外部系统互通吗?团队对哪种协议最熟?答案自然就有了。