信令实现:房间管理、用户加入/离开通知、Offer/Answer信令交换、ICE Candidate信令传递
信令,说白了就是WebRTC的“幕后指挥官”。
很多人刚开始学WebRTC,总以为只要两端都打开摄像头,就能直接连上。嗯,现实可没那么简单。我在第一个WebRTC项目里就踩过这个坑——两端都跑起来了,但就是看不到对方的画面。折腾了半天才发现,信令服务器根本没搭好。
这一章,我们就来亲手实现一个完整的信令系统。它要管四件事:房间管理、用户进出通知、Offer/Answer交换、ICE Candidate传递。
核心要点:信令不是WebRTC协议的一部分,但它是WebRTC应用的“骨架”。没有信令,PeerConnection就是个空壳。
信令的整体架构
先看一张图,你就明白信令在整个系统里扮演什么角色了。
你看,信令服务器就像个“调度中心”。它不碰媒体数据,只负责传递控制信息。我个人习惯用WebSocket来实现,因为双向通信比HTTP轮询舒服多了。
房间管理
房间是什么?说白了就是一个“虚拟会议室”。用户进来、出去,都得通过房间来管理。
我在项目中遇到过一个问题:用户刷新页面后,房间里的其他人就看不到他了。原因很简单——房间状态只存在内存里,一刷新就丢了。后来我加了个简单的房间状态恢复机制,才搞定。
房间的数据结构
一个房间需要记录哪些信息?我一般这样设计:
| 字段 | 类型 | 说明 |
|---|---|---|
| roomId | string | 房间唯一标识 |
| users | Map<string, User> | 当前在线用户列表 |
| createdAt | number | 房间创建时间戳 |
| maxUsers | number | 最大人数限制(可选) |
代码实现其实不复杂。用Node.js写个简单的房间管理器:
class RoomManager {
constructor() {
this.rooms = new Map();
}
createRoom(roomId) {
if (this.rooms.has(roomId)) {
throw new Error('房间已存在');
}
const room = {
roomId,
users: new Map(),
createdAt: Date.now(),
maxUsers: 10
};
this.rooms.set(roomId, room);
return room;
}
joinRoom(roomId, user) {
const room = this.rooms.get(roomId);
if (!room) {
throw new Error('房间不存在');
}
if (room.users.size >= room.maxUsers) {
throw new Error('房间已满');
}
room.users.set(user.id, user);
return room;
}
leaveRoom(roomId, userId) {
const room = this.rooms.get(roomId);
if (!room) return;
room.users.delete(userId);
if (room.users.size === 0) {
this.rooms.delete(roomId);
}
}
}
小技巧:房间ID最好用UUID或者时间戳+随机数生成,别用自增ID。我曾经在生产环境遇到过用户猜房间号的问题,后来全改成UUID了。
用户加入/离开通知
用户加入或离开房间,其他人都得知道。这个通知机制,说白了就是“广播”。
你想想看,如果A加入房间,B和C都得收到通知。不然B还以为房间里就他一个人呢。
通知的消息格式
我习惯用统一的JSON格式:
{
"type": "user-joined", // 或 "user-left"
"data": {
"userId": "abc123",
"userName": "张三",
"roomId": "room-001",
"timestamp": 1700000000000
}
}
服务端收到用户加入请求后,做两件事:
- 把用户加到房间列表里
- 给房间里其他人发通知
代码大概长这样:
// WebSocket 服务端处理
ws.on('message', (data) => {
const msg = JSON.parse(data);
switch (msg.type) {
case 'join-room':
const room = roomManager.joinRoom(msg.roomId, msg.user);
// 给自己回复成功
ws.send(JSON.stringify({
type: 'join-success',
data: { roomId: msg.roomId, users: [...room.users.values()] }
}));
// 广播给其他人
broadcastToRoom(msg.roomId, {
type: 'user-joined',
data: { userId: msg.user.id, userName: msg.user.name }
}, ws); // 排除自己
break;
case 'leave-room':
roomManager.leaveRoom(msg.roomId, msg.userId);
broadcastToRoom(msg.roomId, {
type: 'user-left',
data: { userId: msg.userId }
});
break;
}
});
注意:广播的时候一定要排除发送者自己。不然A加入房间,A自己也会收到“user-joined”通知,这就尴尬了。
Offer/Answer信令交换
这是WebRTC连接的核心步骤。A创建Offer,B回复Answer,中间全靠信令服务器传话。
我记得第一次实现这个流程时,犯了个低级错误——把Offer和Answer搞混了。结果两边都等着对方发Offer,谁也连不上。后来我养成了一个习惯:在消息里加个明确的role字段。
交换流程
标准的SDP交换流程是这样的:
- A创建PeerConnection,生成Offer(SDP)
- A把Offer通过信令服务器发给B
- B收到Offer,设置远端描述,创建Answer
- B把Answer通过信令服务器发回给A
- A收到Answer,设置远端描述
客户端代码示例:
// 发起方(A)
async function createOffer(peerConnection, ws) {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
ws.send(JSON.stringify({
type: 'offer',
data: {
sdp: offer.sdp,
type: offer.type,
from: myUserId,
to: targetUserId
}
}));
}
// 接收方(B)
async function handleOffer(offer, peerConnection, ws) {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(offer)
);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
ws.send(JSON.stringify({
type: 'answer',
data: {
sdp: answer.sdp,
type: answer.type,
from: myUserId,
to: offer.from
}
}));
}
关键点:setLocalDescription和setRemoteDescription的顺序不能乱。先设本地,再设远端?不对。发起方是先设本地(Offer),再设远端(Answer)。接收方是先设远端(Offer),再设本地(Answer)。
ICE Candidate信令传递
SDP交换完了,接下来就是ICE Candidate的传递。ICE Candidate说白了就是“网络路径候选”。
为什么会需要这个?因为A和B可能在不同的网络环境里。有的在NAT后面,有的在公司防火墙里。ICE就是帮他们找到一条能通的路。
Candidate的传递方式
每个PeerConnection在收集Candidate时,会触发icecandidate事件。我们需要把这些Candidate发给对方。
// 收集并发送ICE Candidate
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
ws.send(JSON.stringify({
type: 'ice-candidate',
data: {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
from: myUserId,
to: targetUserId
}
}));
}
};
// 接收ICE Candidate
function handleIceCandidate(candidateData, peerConnection) {
const candidate = new RTCIceCandidate({
candidate: candidateData.candidate,
sdpMid: candidateData.sdpMid,
sdpMLineIndex: candidateData.sdpMLineIndex
});
peerConnection.addIceCandidate(candidate);
}
经验之谈:ICE Candidate的传递可以“抢跑”。也就是说,你可以在收到Offer/Answer之前就开始发Candidate。但要注意,对方可能还没创建PeerConnection,所以最好等收到Offer/Answer后再处理。我一般会在客户端缓存一下,等PeerConnection准备好了再批量添加。
完整的信令消息类型
把上面这些整合起来,我们的信令系统需要支持这些消息类型:
| 消息类型 | 方向 | 说明 |
|---|---|---|
| join-room | 客户端 → 服务器 | 请求加入房间 |
| leave-room | 客户端 → 服务器 | 离开房间 |
| user-joined | 服务器 → 客户端 | 通知有新用户加入 |
| user-left | 服务器 → 客户端 | 通知有用户离开 |
| offer | 客户端 → 服务器 → 客户端 | 传递SDP Offer |
| answer | 客户端 → 服务器 → 客户端 | 传递SDP Answer |
| ice-candidate | 客户端 → 服务器 → 客户端 | 传递ICE Candidate |
嗯,到这里,一个完整的信令系统就搭好了。你想想看,其实核心逻辑并不复杂——就是“转发”。服务器不干别的,就是把A的消息转给B,把B的消息转给A。
我在实际项目中,还会加一些额外的功能,比如心跳检测、断线重连、房间密码保护。但这些都是在基础信令之上做的扩展。先把今天讲的这四块吃透,后面的都好说。
公众号:蓝海资料掘金营,微信deep3321