2、搭建信令服务器:Node.js环境搭建、Socket.IO入门、信令服务器核心逻辑、房间管理
好,咱们进入第二章。这一章要解决一个核心问题——WebRTC 的「信令」。
你想想看,WebRTC 本身只负责媒体数据的传输,但它不知道怎么找到对方。就像两个人打电话,你得先知道对方的号码,拨号,等对方接听——这个「拨号」的过程,就是信令。
WebRTC 规范里明确说了:信令协议由开发者自己实现。所以,咱们得自己搭一个信令服务器。
2.1 为什么需要信令服务器?
说白了,WebRTC 的 P2P 连接建立之前,双方需要交换三类信息:
- 会话描述(SDP):你的音视频编码能力、网络类型等
- ICE 候选者(ICE Candidates):你的 IP 地址和端口
- 房间控制消息:谁加入、谁离开、谁在说话
这些信息不能通过 P2P 传递——因为连接还没建立呢。所以需要一个中间人,也就是信令服务器。
核心理解:信令服务器只负责「牵线搭桥」,不传输音视频数据。一旦连接建立,它就可以功成身退了。
2.2 技术选型:为什么选 Node.js + Socket.IO?
我个人习惯用 Node.js 做信令服务器。原因很简单:
- JavaScript 全栈,前后端统一语言
- 事件驱动、非阻塞 I/O,天然适合处理大量长连接
- npm 生态丰富,Socket.IO 直接解决了 WebSocket 的兼容性和重连问题
Socket.IO 相比原生 WebSocket,多了房间(Room)机制、自动重连、事件广播。这些功能正好是信令服务器需要的。
我的经验:我在一个教育直播项目里用过原生 WebSocket,后来换成 Socket.IO。不是因为 WebSocket 不行,而是 Socket.IO 的「房间」概念太方便了——你不需要自己维护用户列表和广播逻辑。
2.3 环境搭建
先初始化项目:
mkdir signaling-server
cd signaling-server
npm init -y
npm install socket.io express
然后创建 server.js:
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: '*',
methods: ['GET', 'POST']
}
});
server.listen(3000, () => {
console.log('信令服务器运行在端口 3000');
});
嗯,这里要注意:生产环境不要用 origin: '*',要指定你的前端域名。我踩过这个坑——上线后被人扫到端口,直接拿来当代理服务器用了。
2.4 Socket.IO 入门:事件与房间
Socket.IO 的核心就两个概念:事件 和 房间。
事件就是消息类型。比如:
// 客户端发送
socket.emit('join-room', roomId);
// 服务端接收
socket.on('join-room', (roomId) => {
socket.join(roomId);
});
房间是 Socket.IO 内置的频道机制。一个 socket 可以加入多个房间,消息可以只发给某个房间里的所有人。
| 方法 | 说明 |
|---|---|
socket.join(roomId) |
加入房间 |
socket.leave(roomId) |
离开房间 |
io.to(roomId).emit() |
向房间内所有人发消息 |
socket.broadcast.to(roomId).emit() |
向房间内除自己外的人发消息 |
注意:io.to() 和 socket.broadcast.to() 的区别一定要搞清楚。前者包括自己,后者不包括。我在一个项目中用错了,导致自己收到自己的 SDP,然后死循环了……
2.5 信令服务器核心逻辑
信令服务器的核心就四个事件:
- join-room:用户加入房间
- offer:发起方发送 SDP Offer
- answer:接收方回复 SDP Answer
- ice-candidate:交换 ICE 候选者
来看完整实现:
io.on('connection', (socket) => {
console.log(`用户连接: ${socket.id}`);
// 1. 加入房间
socket.on('join-room', (roomId) => {
socket.join(roomId);
console.log(`${socket.id} 加入了房间 ${roomId}`);
// 通知房间内其他人:有新用户加入
socket.to(roomId).emit('user-joined', socket.id);
});
// 2. 转发 Offer
socket.on('offer', (data) => {
// data = { targetId, sdp }
socket.to(data.targetId).emit('offer', {
sdp: data.sdp,
senderId: socket.id
});
});
// 3. 转发 Answer
socket.on('answer', (data) => {
socket.to(data.targetId).emit('answer', {
sdp: data.sdp,
senderId: socket.id
});
});
// 4. 转发 ICE Candidate
socket.on('ice-candidate', (data) => {
socket.to(data.targetId).emit('ice-candidate', {
candidate: data.candidate,
senderId: socket.id
});
});
// 5. 断开连接
socket.on('disconnect', () => {
console.log(`${socket.id} 断开连接`);
// 通知房间内其他人
socket.broadcast.emit('user-left', socket.id);
});
});
你看,逻辑其实很简单——就是「转发」。信令服务器不解析 SDP,不修改 ICE 候选者,只做消息的搬运工。
关键原则:信令服务器只负责「谁发给谁」,不关心「发的是什么」。保持简单,保持可靠。
2.6 房间管理
上面的代码有个问题:没有记录房间里有哪些人。如果用户断开重连,或者需要获取当前房间成员列表,就麻烦了。
我建议用 Map 来管理房间:
const rooms = new Map();
function addUserToRoom(roomId, socketId) {
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add(socketId);
}
function removeUserFromRoom(roomId, socketId) {
const room = rooms.get(roomId);
if (room) {
room.delete(socketId);
if (room.size === 0) {
rooms.delete(roomId); // 房间空了就删除
}
}
}
function getRoomUsers(roomId) {
return rooms.get(roomId) || new Set();
}
然后在 join-room 和 disconnect 事件里调用:
socket.on('join-room', (roomId) => {
socket.join(roomId);
addUserToRoom(roomId, socket.id);
// 告诉新用户:当前房间有哪些人
const users = Array.from(getRoomUsers(roomId));
socket.emit('room-users', users);
// 告诉其他人:有新用户
socket.to(roomId).emit('user-joined', socket.id);
});
socket.on('disconnect', () => {
// 需要遍历所有房间,找到并移除
rooms.forEach((users, roomId) => {
if (users.has(socket.id)) {
removeUserFromRoom(roomId, socket.id);
socket.to(roomId).emit('user-left', socket.id);
}
});
});
避坑指南:我曾经在 disconnect 事件里只调用了 socket.leave(),但没更新 rooms Map。结果用户断开后,新用户加入时还能看到那个已经断开的用户 ID。所以一定要保持内存状态和 Socket.IO 的房间状态同步。
2.7 完整信令流程
咱们用一张图来梳理整个流程:
流程很清晰:
- 双方先加入同一个房间
- A 创建 Offer,发给服务器,服务器转发给 B
- B 创建 Answer,发给服务器,服务器转发给 A
- 双方交换 ICE 候选者,直到找到最佳路径
- P2P 连接建立,信令服务器功成身退
2.8 完整代码结构
最后,咱们的项目结构应该是这样的:
signaling-server/
├── node_modules/
├── package.json
├── server.js # 主入口
├── roomManager.js # 房间管理逻辑
└── .gitignore
roomManager.js 把房间管理独立出来,方便测试和维护:
class RoomManager {
constructor() {
this.rooms = new Map();
}
addUser(roomId, socketId) {
if (!this.rooms.has(roomId)) {
this.rooms.set(roomId, new Set());
}
this.rooms.get(roomId).add(socketId);
}
removeUser(roomId, socketId) {
const room = this.rooms.get(roomId);
if (room) {
room.delete(socketId);
if (room.size === 0) this.rooms.delete(roomId);
}
}
getUsers(roomId) {
return Array.from(this.rooms.get(roomId) || []);
}
removeUserFromAllRooms(socketId) {
this.rooms.forEach((users, roomId) => {
if (users.has(socketId)) {
this.removeUser(roomId, socketId);
}
});
}
}
module.exports = RoomManager;
我的建议:把房间管理独立出来,以后如果要加数据库持久化、或者做房间密码验证,改这一个文件就行。别把所有逻辑都塞到 server.js 里——我见过一个项目,server.js 两千行,改个 bug 要找半天。
好了,这一章的内容就到这。信令服务器是 WebRTC 应用的「神经系统」,虽然代码不多,但逻辑一定要清晰。下一章咱们会写前端代码,真正把视频通话跑起来。