4. 信令传输:信令服务器的作用、WebSocket信令实现、Offer/Answer的信令流程
聊到WebRTC,很多人第一反应是P2P、是音视频编解码。但有个东西绕不过去——信令。说白了,信令就是让两个浏览器“对上话”的那套流程。没有信令,你连对方在哪都不知道,还谈什么媒体协商?
我刚开始做WebRTC时,踩过最大的坑就是低估了信令的复杂度。总觉得“不就是传个字符串嘛”,结果项目上线第一天,用户反馈连不上——嗯,后来发现是信令服务器没处理好并发。从那以后,我对信令这块就格外上心。
4.1 信令服务器的作用
信令服务器在WebRTC架构里,扮演的是“中间人”的角色。它不参与媒体数据的传输,但负责帮两个Peer建立连接。
具体来说,信令服务器要干这几件事:
- 用户管理:维护在线用户列表,处理登录/登出
- 房间管理:创建房间、加入房间、离开房间
- 消息转发:把A的SDP Offer转发给B,把B的Answer转发给A
- ICE候选转发:交换双方的ICE候选地址
核心要点:信令服务器只负责“传话”,不负责“说话”。它不生成SDP,也不做媒体协商。真正的协商发生在两个Peer之间。
你可能会问:为什么WebRTC不把信令也标准化?其实原因很简单——信令跟业务场景强相关。有的场景需要房间机制,有的需要点对点直连,有的需要结合用户认证系统。标准化反而限制了灵活性。
4.2 WebSocket信令实现
实现信令传输,最常用的方案就是WebSocket。为什么选它?因为WebSocket是全双工的,服务器可以主动推消息给客户端。这在信令场景下太重要了——你想,当B加入房间时,服务器得主动通知A:“嘿,有人来了,准备协商吧”。
下面是我个人习惯用的信令消息结构:
// 信令消息格式
{
"type": "offer" | "answer" | "candidate" | "join" | "leave",
"from": "user_id",
"to": "user_id",
"data": {
// 根据type不同,data内容不同
// offer: { sdp: "..." }
// answer: { sdp: "..." }
// candidate: { candidate: "...", sdpMid: "...", sdpMLineIndex: 0 }
}
}
服务器端的实现其实不复杂。我建议用Node.js + ws库,轻量又好用。核心逻辑就三步:
- 客户端连接上来,注册用户ID
- 收到消息,根据type字段做路由
- 转发给目标用户
来看一个简化版的信令服务器代码:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
// 存储所有连接
const clients = new Map();
wss.on('connection', (ws) => {
ws.on('message', (message) => {
const msg = JSON.parse(message);
switch(msg.type) {
case 'register':
// 注册用户
clients.set(msg.from, ws);
break;
case 'offer':
case 'answer':
case 'candidate':
// 转发给目标用户
const target = clients.get(msg.to);
if (target) {
target.send(JSON.stringify(msg));
}
break;
case 'leave':
clients.delete(msg.from);
break;
}
});
ws.on('close', () => {
// 清理断开的连接
for (let [id, conn] of clients) {
if (conn === ws) {
clients.delete(id);
break;
}
}
});
});
经验之谈:我在项目中遇到过一个问题——用户刷新页面后,旧的WebSocket连接还没断开,新的连接又建立了。结果信令消息发给了旧连接,用户收不到。解决方案是:每次注册时,先检查该用户是否已有连接,有的话先关闭旧的。
4.3 Offer/Answer的信令流程
Offer/Answer是WebRTC媒体协商的核心机制。整个流程可以用一张图说清楚:
整个流程其实就四个步骤:
- A创建Offer:A调用createOffer(),生成SDP,通过信令服务器发给B
- B收到Offer:B调用setRemoteDescription(),把A的SDP设为自己的远端描述
- B创建Answer:B调用createAnswer(),生成自己的SDP,通过信令服务器回给A
- A收到Answer:A调用setRemoteDescription(),把B的SDP设为自己的远端描述
你以为这就完了?还没呢。在Offer/Answer交换的同时,ICE候选也在跑。ICE候选是告诉对方“我能用哪些IP和端口连你”。这个过程是并行的,而且可能持续好几轮。
注意:我曾经遇到过一个坑——Offer和Answer的SDP里都包含了ICE候选信息。但有些浏览器实现会把ICE候选放在SDP里一起发,有些则分开发。如果你只处理了SDP交换,没处理独立的ICE候选消息,那连接大概率会失败。
来看客户端怎么实现这个流程:
// Peer A: 发起方
async function startCall() {
const pc = new RTCPeerConnection(config);
// 创建Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 通过信令服务器发送Offer
ws.send(JSON.stringify({
type: 'offer',
from: 'userA',
to: 'userB',
data: { sdp: offer.sdp }
}));
// 监听ICE候选
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'candidate',
from: 'userA',
to: 'userB',
data: event.candidate
}));
}
};
}
// Peer B: 接收方
async function handleOffer(msg) {
const pc = new RTCPeerConnection(config);
// 设置远端描述
await pc.setRemoteDescription({
type: 'offer',
sdp: msg.data.sdp
});
// 创建Answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 发送Answer
ws.send(JSON.stringify({
type: 'answer',
from: 'userB',
to: 'userA',
data: { sdp: answer.sdp }
}));
// 监听ICE候选
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'candidate',
from: 'userB',
to: 'userA',
data: event.candidate
}));
}
};
}
这里有个细节要注意——setLocalDescription和setRemoteDescription的顺序不能乱。A必须先setLocalDescription再发Offer,B必须先setRemoteDescription再createAnswer。顺序错了,SDP就不完整,协商会失败。
避坑指南:我建议在setLocalDescription之后,等onicecandidate触发一次再发Offer。因为有些浏览器在setLocalDescription之后会立即生成ICE候选,如果你发Offer太快,ICE候选可能还没生成完。稳妥的做法是:收集到第一个ICE候选后再发Offer,或者用trickle ICE机制边收集边发。
最后总结一下信令传输的关键点:
| 环节 | 关键动作 | 注意事项 |
|---|---|---|
| 信令服务器 | 消息路由、用户管理 | 处理好连接断开、重连场景 |
| WebSocket实现 | 全双工通信、消息转发 | 消息格式要统一,做好异常处理 |
| Offer/Answer | createOffer → setLocalDescription → 发送 → setRemoteDescription → createAnswer | 顺序不能乱,ICE候选要同时处理 |
信令传输这部分,说白了就是“传话”的活。但传话传不好,后面所有工作都白搭。我见过太多项目,媒体协商逻辑写得天花乱坠,结果信令服务器一崩,全完蛋。所以,先把信令搞稳了,再谈别的。