5、信令服务器搭建:Node.js + Socket.IO实现信令交换、房间管理、ICE候选交换
信令服务器,说白了就是WebRTC的"通信中枢"。没有它,两个浏览器之间根本没法建立连接。
我记得刚接触WebRTC那会儿,总觉得信令是个麻烦事——为什么不能直接P2P?后来才明白,浏览器之间需要先"握手",而这个握手过程必须通过一个中间人。这个中间人,就是信令服务器。
今天我们就用Node.js + Socket.IO,亲手搭一个信令服务器。它要干三件事:
- 信令交换——把offer、answer、candidate这些消息,从一个人传到另一个人
- 房间管理——让两个人能进同一个"房间",才能互相发现
- ICE候选交换——把网络地址信息传来传去
为什么选Socket.IO?
你可能想问:用原生WebSocket不行吗?当然可以。但我个人习惯用Socket.IO,原因很简单:
- 自带房间(Room)机制,省得自己维护连接池
- 断线重连是内置的,不用自己写心跳
- API比原生WebSocket友好太多
我在项目中遇到过几次原生WebSocket断线后状态丢失的问题,后来切到Socket.IO,世界清净了。
项目结构
先搭个架子,别一上来就写代码。我习惯这样组织:
signaling-server/
├── package.json
├── server.js # 主入口
├── rooms.js # 房间管理逻辑
└── handlers/
└── signaling.js # 信令处理
初始化项目
先装依赖:
npm init -y
npm install socket.io express
嗯,这里要注意——我加上了express,因为信令服务器通常还要提供静态文件服务(比如前端页面)。
核心架构
先看一张图,理清整体流程:
房间管理——核心逻辑
房间管理说白了就是:谁在哪个房间,房间里都有谁。我习惯用Map来存:
// rooms.js
const rooms = new Map();
function createRoom(roomId) {
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
console.log(`房间 ${roomId} 已创建`);
}
return rooms.get(roomId);
}
function joinRoom(roomId, socketId) {
const room = createRoom(roomId);
room.add(socketId);
console.log(`${socketId} 加入了房间 ${roomId}`);
return room.size; // 返回房间人数
}
function leaveRoom(roomId, socketId) {
const room = rooms.get(roomId);
if (room) {
room.delete(socketId);
if (room.size === 0) {
rooms.delete(roomId); // 没人了就销毁房间
console.log(`房间 ${roomId} 已销毁`);
}
}
}
function getRoomSize(roomId) {
const room = rooms.get(roomId);
return room ? room.size : 0;
}
module.exports = { createRoom, joinRoom, leaveRoom, getRoomSize };
💡 我的习惯:房间用Set存socketId,而不是存整个socket对象。这样内存占用小,而且序列化方便。
信令处理——消息转发
信令交换的核心就一句话:收到消息,找到目标,转发过去。
你看,WebRTC的信令消息其实就几种:
| 事件名 | 发送方 | 接收方 | 说明 |
|---|---|---|---|
| offer | 发起方 | 接收方 | SDP Offer,描述发起方的媒体能力 |
| answer | 接收方 | 发起方 | SDP Answer,回应Offer |
| ice-candidate | 任意方 | 对端 | ICE候选地址 |
| join | 客户端 | 服务器 | 请求加入房间 |
| leave | 客户端 | 服务器 | 离开房间 |
来看看具体实现:
// handlers/signaling.js
function setupSignalingHandlers(io, socket) {
// 加入房间
socket.on('join-room', (roomId) => {
const roomSize = joinRoom(roomId, socket.id);
socket.join(roomId);
if (roomSize === 1) {
// 第一个人,等待
socket.emit('waiting', '等待对方加入...');
} else if (roomSize === 2) {
// 第二个人来了,通知第一个人开始
socket.to(roomId).emit('ready', '对方已就绪');
socket.emit('ready', '已加入房间');
} else {
socket.emit('error', '房间已满');
}
});
// 转发 offer
socket.on('offer', (data) => {
const { roomId, sdp } = data;
socket.to(roomId).emit('offer', { sdp, from: socket.id });
});
// 转发 answer
socket.on('answer', (data) => {
const { roomId, sdp } = data;
socket.to(roomId).emit('answer', { sdp, from: socket.id });
});
// 转发 ICE candidate
socket.on('ice-candidate', (data) => {
const { roomId, candidate } = data;
socket.to(roomId).emit('ice-candidate', { candidate, from: socket.id });
});
// 断开连接
socket.on('disconnect', () => {
// 遍历所有房间,清理
for (const [roomId, members] of rooms.entries()) {
if (members.has(socket.id)) {
leaveRoom(roomId, socket.id);
socket.to(roomId).emit('peer-disconnected', '对方已断开');
break;
}
}
});
}
⚠️ 我曾经踩过的坑:一开始我直接用socket.broadcast.emit转发消息,结果消息发给了房间里所有人,包括自己。后来才意识到要用socket.to(roomId),它只发给房间里除了自己以外的其他人。
主服务器——组装起来
最后,把express和socket.io整合到一起:
// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const { setupSignalingHandlers } = require('./handlers/signaling');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: '*', // 开发阶段允许所有来源
methods: ['GET', 'POST']
}
});
// 静态文件服务(前端页面)
app.use(express.static('public'));
io.on('connection', (socket) => {
console.log(`新连接: ${socket.id}`);
setupSignalingHandlers(io, socket);
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`信令服务器运行在 http://localhost:${PORT}`);
});
客户端怎么用?
前端连接信令服务器的代码也很简单:
// 前端代码
const socket = io('http://localhost:3000');
// 加入房间
socket.emit('join-room', 'room-123');
// 监听对方就绪
socket.on('ready', () => {
console.log('对方已就绪,开始创建连接');
// 这里开始创建 RTCPeerConnection
});
// 收到 offer
socket.on('offer', async (data) => {
await peerConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
socket.emit('answer', { roomId: 'room-123', sdp: answer });
});
// 收到 ICE candidate
socket.on('ice-candidate', (data) => {
peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate));
});
几个关键点
- 房间容量:屏幕共享通常是1对1,所以房间限制2人。如果需要多人,可以用数组或Map存多个socketId
- 消息顺序:offer必须在answer之前,ICE candidate可以在任何时候到达。我建议客户端收到offer后再开始收集candidate
- 错误处理:网络断开、房间满、对方离开——这些都要有对应的处理逻辑
核心思想:信令服务器不参与媒体数据传输,它只负责"牵线"。一旦P2P连接建立,信令服务器就可以退居二线了。
嗯,到这里信令服务器就搭好了。你可能会觉得代码不多,确实——信令服务器的逻辑本身就不复杂。但它的重要性不容小觑,没有它,WebRTC根本跑不起来。
我在实际项目中,还会加上心跳检测、日志记录、连接统计等功能。不过作为基础版本,今天这些已经够用了。
试试看,把代码跑起来,然后用两个浏览器窗口打开页面,看看能不能成功交换消息。
公众号:蓝海资料掘金营,微信deep3321