7、信令服务搭建(下):信令消息格式设计、房间管理逻辑(创建/加入/离开)、信令与RTCPeerConnection的联动
上一节我们把信令服务的架子搭起来了,WebSocket 也能跑了。但说实话,那只是个空壳子。真正的核心在于——消息怎么传、房间怎么管、以及信令怎么跟 RTCPeerConnection 配合起来。这三点搞不定,你的 WebRTC 应用就是个摆设。
我个人习惯,先把消息格式定死。为什么?因为前后端一旦开始联调,最怕的就是「你发的字段名跟我对不上」。我在项目中遇到过好几次,就因为一个字段名大小写不一致,排查了整整一下午。所以,先定规矩,再写代码。
7.1 信令消息格式设计
信令消息说白了就是一段 JSON。但这段 JSON 得有固定的结构,不然客户端和服务端都看不懂。我一般这样设计:
{
"type": "join", // 消息类型,必填
"roomId": "room001", // 房间 ID,必填
"userId": "user_abc", // 用户 ID,必填
"payload": { // 具体数据,根据 type 不同而变化
"sdp": "...",
"candidate": "..."
},
"timestamp": 1690000000000 // 时间戳,可选
}
嗯,这里要注意:type 字段是核心。它决定了这条消息要干什么。我常用的消息类型有这些:
| 消息类型 | 方向 | 说明 |
|---|---|---|
join |
客户端 → 服务端 | 用户请求加入房间 |
joined |
服务端 → 客户端 | 通知用户已成功加入 |
new_peer |
服务端 → 客户端 | 通知房间内有新用户加入 |
offer |
客户端 → 服务端 | 发送 SDP Offer |
answer |
客户端 → 服务端 | 发送 SDP Answer |
ice_candidate |
客户端 → 服务端 | 发送 ICE 候选者 |
leave |
客户端 → 服务端 | 用户离开房间 |
peer_left |
服务端 → 客户端 | 通知房间内有用户离开 |
你想想看,有了这个表格,前后端开发人员各拿一份,照着字段名写代码,基本不会出岔子。我曾经在一个项目里没做这个表,结果前端发的是 roomId,后端读的是 room_id,你说尴尬不尴尬?
7.2 房间管理逻辑
房间管理,说白了就是维护一个「谁在哪个房间」的映射表。我一般用 Map 来实现,结构大概是这样的:
// 房间数据结构
const rooms = new Map();
// 每个房间的结构
{
"room001": {
"users": [
{ "userId": "user_abc", "ws": WebSocket对象 },
{ "userId": "user_def", "ws": WebSocket对象 }
],
"createdAt": 1690000000000
}
}
为什么要把 WebSocket 对象也存进去?因为后面转发消息的时候,你得知道往哪个 socket 发。这个设计我踩过坑——一开始我只存了 userId,结果转发消息时还得查一遍 userId 到 socket 的映射,多了一层麻烦。
7.2.1 创建房间
创建房间的逻辑其实很简单。客户端发一个 join 消息,如果房间不存在,就新建一个。我习惯这样写:
function handleJoin(ws, message) {
const { roomId, userId } = message;
// 如果房间不存在,自动创建
if (!rooms.has(roomId)) {
rooms.set(roomId, {
users: [],
createdAt: Date.now()
});
console.log(`房间 ${roomId} 已创建`);
}
const room = rooms.get(roomId);
// 检查用户是否已经在房间里
const existingUser = room.users.find(u => u.userId === userId);
if (existingUser) {
// 更新 WebSocket 连接(可能是重连)
existingUser.ws = ws;
} else {
room.users.push({ userId, ws });
}
// 通知用户加入成功
ws.send(JSON.stringify({
type: "joined",
roomId,
userId,
payload: { users: room.users.map(u => u.userId) }
}));
// 通知房间内其他用户:有新伙伴来了
broadcastToRoom(roomId, {
type: "new_peer",
roomId,
payload: { userId }
}, ws); // 排除自己
}
这里有个细节:broadcastToRoom 的第三个参数是排除当前 socket。为什么?因为新加入的用户已经收到了 joined 消息,不需要再收到一次 new_peer。不然客户端逻辑会乱掉。
7.2.2 加入房间
加入房间的逻辑跟创建差不多,区别在于:如果房间不存在,是报错还是自动创建?这取决于你的业务场景。我做视频会议时,一般选择自动创建。但如果是预约会议,房间不存在就应该报错。
// 严格模式:房间不存在就报错
if (!rooms.has(roomId)) {
ws.send(JSON.stringify({
type: "error",
payload: { message: "房间不存在" }
}));
return;
}
7.2.3 离开房间
离开房间的逻辑相对简单,但有个坑:用户可能是正常离开,也可能是断网了。断网的情况,WebSocket 的 close 事件会触发。所以离开逻辑要同时处理主动离开和被动断开。
function handleLeave(ws, message) {
const { roomId, userId } = message;
const room = rooms.get(roomId);
if (!room) return;
// 从房间中移除用户
room.users = room.users.filter(u => u.userId !== userId);
// 如果房间没人了,清理房间
if (room.users.length === 0) {
rooms.delete(roomId);
console.log(`房间 ${roomId} 已销毁`);
return;
}
// 通知其他人
broadcastToRoom(roomId, {
type: "peer_left",
roomId,
payload: { userId }
});
}
// WebSocket 断开时也要处理
ws.on('close', () => {
// 查找用户所在的房间并执行离开逻辑
for (const [roomId, room] of rooms.entries()) {
const user = room.users.find(u => u.ws === ws);
if (user) {
handleLeave(ws, { roomId, userId: user.userId });
break;
}
}
});
嗯,这里要注意:清理房间这个操作容易被忽略。如果不清理,房间 Map 会越来越大,最后内存泄漏。我见过一个项目,上线跑了三天,房间 Map 里攒了几万个空房间,服务直接 OOM 了。
7.3 信令与 RTCPeerConnection 的联动
这才是今天的重头戏。信令服务说白了就是个「传话筒」,它不负责媒体数据的传输。真正的媒体流走的是 RTCPeerConnection。那信令怎么跟 RTCPeerConnection 联动呢?
我画了一张图,你看完就明白了:
流程其实不复杂。说白了就是:A 创建 Offer → 信令转发给 B → B 设置远端描述 → B 创建 Answer → 信令转发给 A → A 设置远端描述。然后双方交换 ICE 候选者,最终建立 P2P 连接。
代码层面,客户端的核心逻辑是这样的:
// 创建 RTCPeerConnection
const pc = new RTCPeerConnection(config);
// 当 ICE 候选者产生时,通过信令发送给对方
pc.onicecandidate = (event) => {
if (event.candidate) {
sendSignalingMessage({
type: "ice_candidate",
roomId,
userId,
payload: { candidate: event.candidate }
});
}
};
// 收到信令消息的处理
function handleSignalingMessage(message) {
switch (message.type) {
case "offer":
// 设置远端描述
pc.setRemoteDescription(new RTCSessionDescription(message.payload.sdp));
// 创建 Answer
pc.createAnswer().then(answer => {
pc.setLocalDescription(answer);
sendSignalingMessage({
type: "answer",
roomId,
userId,
payload: { sdp: answer }
});
});
break;
case "answer":
pc.setRemoteDescription(new RTCSessionDescription(message.payload.sdp));
break;
case "ice_candidate":
pc.addIceCandidate(new RTCIceCandidate(message.payload.candidate));
break;
}
}
我在项目中遇到过一个问题:ICE 候选者发送的时机。如果 Offer/Answer 还没交换完,ICE 候选者就发过去了,接收方会报错。为什么?因为接收方还没有设置远端描述,addIceCandidate 会失败。解决办法是:在 setRemoteDescription 完成之后再处理 ICE 候选者。或者更简单——把 ICE 候选者先缓存起来,等描述设置完再批量添加。
broadcastToRoom 一定要排除当前 socket。
嗯,到这里信令服务的核心逻辑就讲完了。消息格式、房间管理、联动流程,这三个点你搞清楚了,一个基本的 WebRTC 应用就能跑起来了。剩下的就是一些细节优化,比如重连机制、房间心跳、错误处理等,这些在实际项目中慢慢积累就好。
公众号:蓝海资料掘金营,微信deep3321