实战项目二:搭建多人视频会议的信令服务器(含完整代码)
好,咱们接着往下走。上一章我们把一对一通话的信令服务器搞定了,代码能跑、流程能通。但说实话,那个架构撑死了也就俩人聊。你想想看,如果来个三五个人同时开会,那套逻辑直接就崩了——房间管理、成员列表、媒体协商的转发,全得重写。
这一章,我们就来搞一个真正能用的多人视频会议信令服务器。我会带着你从零搭一个基于 Socket.IO 的 Node.js 服务,支持房间创建、成员进出、以及最重要的——多人之间的媒体协商转发。
核心目标: 一个信令服务器,支持 N 个用户同时在一个房间里进行视频通话。每个用户都能看到其他所有人的画面(Mesh 架构)。
多人会议的核心挑战
多人视频会议和一对一最大的区别在哪?我个人的理解是:信令的广播范围变了。
一对一的时候,A 发 SDP 给 B,B 回 Answer 给 A,完事。但多人场景下,一个新用户加入房间,他需要把自己的 Offer 发给房间里所有其他人。同时,房间里每个老用户也要把自己的 Offer 重新发给这个新人。这就是所谓的 “全对全”广播。
我在做第一个版本的时候,就踩过这个坑。我当时想当然地以为,新人加入后只需要发一次 Offer 就行。结果呢?老用户能看到新人,但新人看不到老用户。为什么?因为老用户的媒体流没有主动发给新人。嗯,这个细节很重要,后面代码里我会特别标注。
整体架构设计
我们先画一张图,把整个信令流程理清楚。这样你写代码的时候心里就有谱了。
说白了,信令服务器在这里扮演的就是一个消息路由器。它不负责媒体流的转发(那是 WebRTC 的 PeerConnection 干的事),它只负责把信令消息准确投递给房间里的每个人。
完整代码实现
好,我们直接上代码。这个服务器我分成了三个核心模块:房间管理、连接处理、信令转发。我会把每一块的逻辑都讲清楚。
1. 项目初始化与依赖
先建一个项目目录,装好依赖。我个人习惯用 yarn,但 npm 也一样。
mkdir video-conference-server
cd video-conference-server
npm init -y
npm install socket.io uuid
uuid 是用来给每个连接生成唯一 ID 的,方便我们追踪用户。
2. 房间管理模块
这个模块负责维护所有房间的状态。我把它单独抽出来,方便后面扩展。
// roomManager.js
const { v4: uuidv4 } = require('uuid');
class RoomManager {
constructor() {
this.rooms = new Map(); // key: roomId, value: { users: Map }
}
createRoom() {
const roomId = uuidv4().slice(0, 8);
this.rooms.set(roomId, { users: new Map() });
return roomId;
}
joinRoom(roomId, socketId, userData) {
const room = this.rooms.get(roomId);
if (!room) return false;
room.users.set(socketId, {
socketId,
...userData,
joinedAt: Date.now()
});
return true;
}
leaveRoom(roomId, socketId) {
const room = this.rooms.get(roomId);
if (!room) return;
room.users.delete(socketId);
if (room.users.size === 0) {
this.rooms.delete(roomId); // 没人了就销毁房间
}
}
getUsersInRoom(roomId) {
const room = this.rooms.get(roomId);
if (!room) return [];
return Array.from(room.users.values());
}
getUserCount(roomId) {
const room = this.rooms.get(roomId);
return room ? room.users.size : 0;
}
}
module.exports = RoomManager;
我的经验: 用 Map 而不是普通对象来存用户,是因为 Map 的遍历顺序是插入顺序,而且删除操作更高效。在多人会议中,用户频繁进出,Map 的性能优势很明显。
3. 信令服务器主逻辑
这是核心文件。我会把每个事件的处理逻辑都加上注释,方便你理解。
// server.js
const { Server } = require('socket.io');
const RoomManager = require('./roomManager');
const roomManager = new RoomManager();
const io = new Server({
cors: {
origin: '*',
methods: ['GET', 'POST']
}
});
io.on('connection', (socket) => {
console.log(`[连接] 用户 ${socket.id} 已连接`);
// ---------- 房间管理事件 ----------
socket.on('create-room', () => {
const roomId = roomManager.createRoom();
socket.join(roomId);
roomManager.joinRoom(roomId, socket.id, { displayName: `用户-${socket.id.slice(0, 4)}` });
socket.emit('room-created', { roomId });
console.log(`[房间] 用户 ${socket.id} 创建了房间 ${roomId}`);
});
socket.on('join-room', ({ roomId, displayName }) => {
const success = roomManager.joinRoom(roomId, socket.id, { displayName });
if (!success) {
socket.emit('error-message', { message: '房间不存在' });
return;
}
socket.join(roomId);
// 获取房间内现有用户列表
const existingUsers = roomManager.getUsersInRoom(roomId)
.filter(u => u.socketId !== socket.id);
// 告诉新人:房间里有哪些老用户
socket.emit('room-users', { users: existingUsers });
// 广播给房间其他人:有新用户加入
socket.to(roomId).emit('user-joined', {
user: {
socketId: socket.id,
displayName
}
});
console.log(`[房间] 用户 ${socket.id} 加入了房间 ${roomId}`);
});
// ---------- 信令转发事件 ----------
socket.on('offer', ({ to, offer }) => {
// 把 Offer 转发给目标用户
io.to(to).emit('offer', {
from: socket.id,
offer
});
});
socket.on('answer', ({ to, answer }) => {
io.to(to).emit('answer', {
from: socket.id,
answer
});
});
socket.on('ice-candidate', ({ to, candidate }) => {
io.to(to).emit('ice-candidate', {
from: socket.id,
candidate
});
});
// ---------- 断开连接 ----------
socket.on('disconnect', () => {
// 找到用户所在的房间
const rooms = Array.from(socket.rooms).filter(r => r !== socket.id);
rooms.forEach(roomId => {
roomManager.leaveRoom(roomId, socket.id);
// 广播给房间其他人
socket.to(roomId).emit('user-left', {
socketId: socket.id
});
});
console.log(`[断开] 用户 ${socket.id} 已断开`);
});
});
const PORT = process.env.PORT || 3000;
io.listen(PORT);
console.log(`信令服务器运行在端口 ${PORT}`);
注意: 上面的代码中,join-room 事件处理里,我特意把新人加入后没有自动触发 Offer。为什么?因为 Offer 应该由客户端来决定何时发送。服务器只负责转发,不负责决策。这是信令服务器的一个设计原则——保持无状态。
关键流程详解
我们用一个具体的场景来走一遍流程。假设房间里已经有 A 和 B 两个人,现在 C 要加入。
- C 发起
join-room:C 的客户端发送join-room事件,带上房间 ID 和昵称。 - 服务器处理:服务器把 C 加入房间,然后:
- 给 C 返回
room-users,里面包含 A 和 B 的信息。 - 给 A 和 B 广播
user-joined,告诉他们 C 来了。
- 给 C 返回
- A 和 B 发起 Offer:A 和 B 的客户端收到
user-joined后,各自创建一个 PeerConnection,然后发送offer给 C。 - C 回复 Answer:C 收到 A 和 B 的 Offer 后,分别回复
answer。 - ICE 交换:三方之间互相交换 ICE Candidate,直到连接建立。
这里有一个很容易忽略的点:C 不需要主动给 A 和 B 发 Offer。因为 A 和 B 在收到 user-joined 后,会主动发起 Offer 给 C。C 只需要被动回复即可。这样设计的好处是,避免了重复的 Offer 协商。
客户端配合示例
虽然我们主要讲服务器,但为了让你能测试,我简单写一段客户端配合的伪代码。你可以在浏览器控制台里跑。
// 客户端示例(浏览器中运行)
const socket = io('http://localhost:3000');
// 创建房间
socket.emit('create-room');
socket.on('room-created', ({ roomId }) => {
console.log('房间已创建:', roomId);
});
// 加入房间
socket.emit('join-room', { roomId: 'abc123', displayName: '张三' });
// 收到房间用户列表
socket.on('room-users', ({ users }) => {
users.forEach(user => {
// 为每个老用户创建一个 PeerConnection
createPeerConnection(user.socketId, true); // true 表示发起 Offer
});
});
// 收到新用户加入
socket.on('user-joined', ({ user }) => {
// 为新用户创建一个 PeerConnection
createPeerConnection(user.socketId, true);
});
// 收到 Offer
socket.on('offer', ({ from, offer }) => {
// 设置远程描述,并回复 Answer
pc.setRemoteDescription(offer);
pc.createAnswer().then(answer => {
pc.setLocalDescription(answer);
socket.emit('answer', { to: from, answer });
});
});
测试与验证
代码写完了,怎么测?我建议你开三个浏览器标签页,每个都连上同一个房间。然后打开浏览器的 chrome://webrtc-internals,看看 ICE 连接状态。
| 测试场景 | 预期结果 | 检查点 |
|---|---|---|
| 三人加入同一房间 | 每人看到其他两人 | 控制台打印用户列表 |
| 一人断开 | 其他两人收到 user-left | ICE 连接状态变为 disconnected |
| 重复加入相同房间 | 服务器拒绝或覆盖 | 检查 roomUsers 去重逻辑 |
| 房间无人后重建 | 新用户可正常创建房间 | 房间 ID 不重复 |
避坑指南: 我曾经在测试时发现,用户断开后房间没有及时清理,导致后来的人加入了一个“幽灵房间”。后来我加了一个定时检查,如果房间内所有用户的 socket 都断开了,就自动销毁房间。上面的代码里,leaveRoom 方法已经处理了这种情况。
扩展思考
这个信令服务器虽然能用,但离生产环境还有距离。比如:
- 房间密码保护:可以在
join-room时校验密码。 - 用户角色:主持人可以踢人、静音等。
- 房间持久化:把房间信息存到 Redis 里,支持多进程。
不过这些都属于锦上添花。核心的信令转发逻辑,就是上面那几十行代码。你把它吃透了,后面加什么功能都不怕。
好,这一章的内容就到这。代码你可以直接拿去用,跑起来试试。有问题的话,多看看控制台日志,信令服务器的调试基本就靠它了。
公众号:蓝海资料掘金营,微信 deep3321