23、项目实战:视频会议系统(二):信令服务实现(房间管理+用户管理)、前端信令客户端封装
好,咱们接着上一章继续。上一章我们把项目架子搭起来了,也聊了聊信令服务在整个系统里的位置。这一章,咱们要动真格的了——把信令服务的核心逻辑写出来,再把前端那边的信令客户端封装好。
说实话,信令服务这部分,我当年第一次做的时候踩了不少坑。尤其是房间管理和用户状态同步,稍不留神就会出各种诡异的问题。今天我把这些经验都揉碎了讲给你听。
信令服务的核心职责
先理清楚,信令服务到底要干什么?说白了就三件事:
- 房间管理:创建房间、加入房间、离开房间、销毁房间
- 用户管理:用户上线、用户离线、用户列表同步
- 消息转发:把SDP、ICE候选等信令消息,从一个人转发给另一个人
你想想看,如果没有信令服务,两个浏览器之间根本不知道对方在哪。WebRTC只负责媒体传输,但“怎么找到对方”这件事,得靠信令服务搞定。
房间管理实现
房间,本质上就是一个虚拟的会议室。每个房间有一个唯一ID,里面有一堆用户。我习惯用Map来存,简单直接。
// roomManager.js
class RoomManager {
constructor() {
this.rooms = new Map();
}
createRoom(roomId, creatorId) {
if (this.rooms.has(roomId)) {
throw new Error('房间已存在');
}
const room = {
id: roomId,
creator: creatorId,
users: new Map(),
createdAt: Date.now()
};
this.rooms.set(roomId, room);
console.log(`[房间] 创建成功: ${roomId}`);
return room;
}
joinRoom(roomId, user) {
const room = this.rooms.get(roomId);
if (!room) {
throw new Error('房间不存在');
}
if (room.users.size >= 4) {
throw new Error('房间已满员(最多4人)');
}
room.users.set(user.id, user);
console.log(`[房间] ${user.name} 加入 ${roomId}`);
return room;
}
leaveRoom(roomId, userId) {
const room = this.rooms.get(roomId);
if (!room) return;
room.users.delete(userId);
console.log(`[房间] ${userId} 离开 ${roomId}`);
if (room.users.size === 0) {
this.rooms.delete(roomId);
console.log(`[房间] 已销毁: ${roomId}`);
}
}
getRoom(roomId) {
return this.rooms.get(roomId);
}
getUsersInRoom(roomId) {
const room = this.rooms.get(roomId);
return room ? Array.from(room.users.values()) : [];
}
}
module.exports = RoomManager;
关键设计点:
- 房间满员限制:我设了4人,实际项目中可以根据业务调整
- 自动销毁:最后一个人离开时,房间自动清理,避免内存泄漏
- 用户用Map存:方便按ID快速查找和删除
我的经验:曾经有个项目,房间销毁后没有及时清理定时器,导致服务端内存一直涨。后来加了个定时检查机制,超过30分钟没人说话的房间自动销毁。嗯,这个后面可以加。
用户管理实现
用户管理比房间管理稍微复杂一点。因为用户有状态:在线、离线、在房间中、不在房间中。而且用户断开连接时,要能自动清理。
// userManager.js
class UserManager {
constructor() {
this.users = new Map();
}
addUser(socketId, userInfo) {
const user = {
id: socketId,
name: userInfo.name || '匿名用户',
avatar: userInfo.avatar || '',
roomId: null,
joinedAt: Date.now(),
isOnline: true
};
this.users.set(socketId, user);
console.log(`[用户] 上线: ${user.name} (${socketId})`);
return user;
}
removeUser(socketId) {
const user = this.users.get(socketId);
if (!user) return null;
user.isOnline = false;
this.users.delete(socketId);
console.log(`[用户] 离线: ${user.name}`);
return user;
}
setUserRoom(socketId, roomId) {
const user = this.users.get(socketId);
if (user) {
user.roomId = roomId;
}
}
getUser(socketId) {
return this.users.get(socketId);
}
getOnlineUsers() {
return Array.from(this.users.values()).filter(u => u.isOnline);
}
}
module.exports = UserManager;
这里有个细节:用户ID用的是Socket.IO的socketId。为什么?因为每个WebSocket连接都有唯一ID,天然适合做用户标识。当然,如果你有用户系统,也可以用业务ID,但那样需要额外维护socketId和userId的映射关系。
注意:用户断开连接时,一定要同时从房间中移除。我曾经漏掉这一步,结果房间里有个“幽灵用户”,其他用户一直看到他在线,但永远连不上。排查了半天才发现是leaveRoom没调。
信令消息处理
房间和用户管理好了,接下来就是消息转发。这是信令服务的核心能力。
// signalingHandler.js
function setupSignalingHandlers(io, roomManager, userManager) {
io.on('connection', (socket) => {
console.log(`[连接] 新客户端: ${socket.id}`);
// 用户上线
socket.on('user:join', (userInfo) => {
const user = userManager.addUser(socket.id, userInfo);
socket.emit('user:joined', { id: socket.id, name: user.name });
});
// 创建房间
socket.on('room:create', (roomId) => {
try {
const room = roomManager.createRoom(roomId, socket.id);
socket.join(roomId);
userManager.setUserRoom(socket.id, roomId);
socket.emit('room:created', { roomId });
} catch (err) {
socket.emit('error', { message: err.message });
}
});
// 加入房间
socket.on('room:join', (roomId) => {
try {
const user = userManager.getUser(socket.id);
const room = roomManager.joinRoom(roomId, {
id: socket.id,
name: user.name,
avatar: user.avatar
});
socket.join(roomId);
userManager.setUserRoom(socket.id, roomId);
// 通知房间内其他用户
socket.to(roomId).emit('user:entered', {
userId: socket.id,
name: user.name
});
// 返回当前用户列表
const users = roomManager.getUsersInRoom(roomId);
socket.emit('room:joined', { roomId, users });
} catch (err) {
socket.emit('error', { message: err.message });
}
});
// 转发信令消息
socket.on('signal:offer', (data) => {
socket.to(data.targetId).emit('signal:offer', {
from: socket.id,
sdp: data.sdp
});
});
socket.on('signal:answer', (data) => {
socket.to(data.targetId).emit('signal:answer', {
from: socket.id,
sdp: data.sdp
});
});
socket.on('signal:ice-candidate', (data) => {
socket.to(data.targetId).emit('signal:ice-candidate', {
from: socket.id,
candidate: data.candidate
});
});
// 断开连接
socket.on('disconnect', () => {
const user = userManager.removeUser(socket.id);
if (user && user.roomId) {
roomManager.leaveRoom(user.roomId, socket.id);
socket.to(user.roomId).emit('user:left', {
userId: socket.id,
name: user.name
});
}
console.log(`[断开] ${socket.id}`);
});
});
}
消息流转图:
前端信令客户端封装
服务端写好了,前端也得有个对应的客户端。我习惯封装成一个类,把Socket.IO的细节藏起来,对外只暴露几个简单的方法。
// SignalingClient.js
class SignalingClient {
constructor(serverUrl) {
this.socket = io(serverUrl);
this.listeners = {};
this.isConnected = false;
this.socket.on('connect', () => {
console.log('[信令] 已连接');
this.isConnected = true;
});
this.socket.on('disconnect', () => {
console.log('[信令] 已断开');
this.isConnected = false;
});
}
// 加入系统
join(userInfo) {
this.socket.emit('user:join', userInfo);
}
// 创建房间
createRoom(roomId) {
return new Promise((resolve, reject) => {
this.socket.emit('room:create', roomId);
this.socket.once('room:created', (data) => resolve(data));
this.socket.once('error', (err) => reject(err));
});
}
// 加入房间
joinRoom(roomId) {
return new Promise((resolve, reject) => {
this.socket.emit('room:join', roomId);
this.socket.once('room:joined', (data) => resolve(data));
this.socket.once('error', (err) => reject(err));
});
}
// 发送offer
sendOffer(targetId, sdp) {
this.socket.emit('signal:offer', { targetId, sdp });
}
// 发送answer
sendAnswer(targetId, sdp) {
this.socket.emit('signal:answer', { targetId, sdp });
}
// 发送ICE候选
sendIceCandidate(targetId, candidate) {
this.socket.emit('signal:ice-candidate', { targetId, candidate });
}
// 监听事件
on(event, callback) {
this.socket.on(event, callback);
}
// 断开连接
disconnect() {
this.socket.disconnect();
}
}
封装的好处:
- 业务代码不用关心Socket.IO的细节,只需要调方法
- 如果以后换WebSocket库,只需要改这一个文件
- 可以统一处理连接状态、重连逻辑
使用示例
来看看前端怎么用这个客户端:
// app.js
const client = new SignalingClient('http://localhost:3000');
// 1. 加入系统
client.join({ name: '张三', avatar: '' });
// 2. 创建或加入房间
const roomId = 'room-001';
client.joinRoom(roomId).then((data) => {
console.log('加入房间成功', data);
// data.users 是当前房间的用户列表
data.users.forEach(user => {
if (user.id !== client.socket.id) {
// 创建PeerConnection,发起offer
createPeerConnection(user.id, true);
}
});
});
// 3. 监听新用户进入
client.on('user:entered', (data) => {
console.log('新用户来了', data);
createPeerConnection(data.userId, true);
});
// 4. 监听信令消息
client.on('signal:offer', (data) => {
createPeerConnection(data.from, false);
// 设置remoteSDP,生成answer
});
client.on('signal:answer', (data) => {
// 设置remoteSDP
});
client.on('signal:ice-candidate', (data) => {
// 添加ICE候选
});
常见坑:
- 信令消息的顺序问题:offer必须在answer之前,ICE候选可以在任何时候到达
- 重复监听:每次on都会添加一个新监听器,记得用once或者清理
- 连接断开后重连:用户需要重新joinRoom,服务端不会自动恢复状态
本章小结
这一章我们把信令服务的核心实现了。房间管理、用户管理、消息转发,这三块是视频会议系统的基石。前端封装也做好了,下一章就可以直接用这个客户端来建立PeerConnection了。
我个人觉得,信令服务这部分虽然代码不多,但设计上要花点心思。尤其是状态同步和异常处理,稍不注意就会出问题。建议你写完代码后,多模拟几个用户同时操作的情况,看看会不会出现状态不一致。
好,今天就到这里。代码都在项目里,你可以直接跑起来试试。有问题随时交流。