实战项目一:搭建一对一视频通话的信令服务器(含完整代码)
好,咱们直接进入正题。这一章,我们要亲手搭建一个能跑起来的一对一视频通话信令服务器。说实话,信令服务器是 WebRTC 里最容易被低估的环节。很多人觉得「不就是传个消息嘛」,结果一跑起来,要么连不上,要么连上了但媒体流死活不通。
我当年第一次做 WebRTC 项目时,就踩过这个坑。信令逻辑写得太随意,结果排查了整整两天,最后发现是信令消息的顺序没处理好。嗯,从那以后,我对信令服务器的设计就格外上心。
项目需求分析
先搞清楚我们要做什么。一对一视频通话的信令服务器,核心职责就两个:
- 转发信令消息:把 A 的 Offer、ICE Candidate 等消息传给 B,反过来也一样
- 管理房间状态:谁在房间里?谁加入了?谁离开了?
说白了,它就是个消息中转站 + 状态管理器。不需要处理媒体流,也不需要做复杂的业务逻辑。
核心原则:信令服务器只负责「传话」,不负责「说话」。媒体协商、连接建立这些事,交给浏览器端的 RTCPeerConnection 去处理。
技术选型
我个人习惯用 Node.js + WebSocket 来做信令服务器。原因很简单:
- Node.js 的异步 I/O 模型非常适合处理大量并发连接
- WebSocket 是全双工通信,天然适合信令这种双向实时交互场景
- 生态成熟,ws 库稳定可靠
你可能会问:用 HTTP 轮询行不行?技术上可以,但效率差太多。WebRTC 的信令交换需要低延迟,HTTP 的请求-响应模型会引入不必要的延迟。你想想看,ICE Candidate 可能几十个,每个都走 HTTP 请求,那画面太美我不敢看。
| 方案 | 延迟 | 复杂度 | 推荐度 |
|---|---|---|---|
| WebSocket | 低 | 低 | ⭐⭐⭐⭐⭐ |
| HTTP 轮询 | 高 | 中 | ⭐⭐ |
| SSE | 中 | 中 | ⭐⭐⭐ |
整体架构设计
先画一张图,让你对整体流程有个直观认识。
看到这张图你应该就明白了。信令服务器处在中间位置,负责转发消息。而真正的媒体流,是浏览器之间直连的,不经过服务器。这就是 WebRTC 的精髓——信令走服务器,媒体走 P2P。
完整代码实现
好,直接上代码。我会把完整的信令服务器代码贴出来,然后逐段讲解。
1. 项目初始化
// package.json
{
"name": "webrtc-signaling-server",
"version": "1.0.0",
"description": "一对一视频通话信令服务器",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ws": "^8.16.0"
}
}
安装依赖:
npm install
2. 信令服务器核心代码
// server.js
const WebSocket = require('ws');
const PORT = 8080;
const server = new WebSocket.Server({ port: PORT });
// 房间管理
// 结构:{ roomId: [ws1, ws2] }
const rooms = new Map();
console.log(`信令服务器已启动,端口: ${PORT}`);
server.on('connection', (ws) => {
console.log('新客户端连接');
let currentRoom = null;
let currentUserId = null;
// 处理收到的消息
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
handleMessage(ws, data);
} catch (e) {
console.error('消息解析失败:', e);
ws.send(JSON.stringify({
type: 'error',
message: '消息格式错误'
}));
}
});
// 处理断开连接
ws.on('close', () => {
console.log('客户端断开连接');
if (currentRoom) {
leaveRoom(ws, currentRoom, currentUserId);
}
});
// 消息路由
function handleMessage(ws, data) {
switch (data.type) {
case 'join':
handleJoin(ws, data);
break;
case 'offer':
case 'answer':
case 'ice-candidate':
forwardMessage(ws, data);
break;
case 'leave':
if (currentRoom) {
leaveRoom(ws, currentRoom, currentUserId);
}
break;
default:
ws.send(JSON.stringify({
type: 'error',
message: '未知消息类型'
}));
}
}
// 处理加入房间
function handleJoin(ws, data) {
const { roomId, userId } = data;
if (!roomId || !userId) {
ws.send(JSON.stringify({
type: 'error',
message: '缺少 roomId 或 userId'
}));
return;
}
// 检查房间是否已满
if (rooms.has(roomId) && rooms.get(roomId).length >= 2) {
ws.send(JSON.stringify({
type: 'error',
message: '房间已满,最多支持两人'
}));
return;
}
// 加入房间
if (!rooms.has(roomId)) {
rooms.set(roomId, []);
}
rooms.get(roomId).push(ws);
currentRoom = roomId;
currentUserId = userId;
console.log(`${userId} 加入房间 ${roomId}`);
// 通知加入者
ws.send(JSON.stringify({
type: 'joined',
roomId: roomId,
userId: userId
}));
// 如果房间已有两人,通知双方可以开始
if (rooms.get(roomId).length === 2) {
broadcastToRoom(roomId, {
type: 'ready',
roomId: roomId
});
}
}
// 转发消息给房间内的其他客户端
function forwardMessage(ws, data) {
if (!currentRoom) {
ws.send(JSON.stringify({
type: 'error',
message: '请先加入房间'
}));
return;
}
const clients = rooms.get(currentRoom);
if (!clients) return;
// 找到对方客户端并转发
clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
}
});
}
// 离开房间
function leaveRoom(ws, roomId, userId) {
const clients = rooms.get(roomId);
if (!clients) return;
// 从房间移除
const index = clients.indexOf(ws);
if (index !== -1) {
clients.splice(index, 1);
}
// 通知对方
broadcastToRoom(roomId, {
type: 'peer-left',
userId: userId
});
// 如果房间空了,删除房间
if (clients.length === 0) {
rooms.delete(roomId);
console.log(`房间 ${roomId} 已删除`);
}
currentRoom = null;
currentUserId = null;
}
// 向房间内所有客户端广播
function broadcastToRoom(roomId, data) {
const clients = rooms.get(roomId);
if (!clients) return;
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
}
});
}
});
我的经验:这段代码里,我最想强调的就是 forwardMessage 函数。我在项目中遇到过一个问题:客户端发来的 ICE Candidate 消息没有正确转发,导致双方始终无法建立连接。排查后发现是消息类型判断写错了。所以这里我用了白名单方式,只转发 offer、answer、ice-candidate 这三种消息,其他的一律拦截。
消息协议设计
信令服务器和客户端之间需要约定一套消息格式。我设计的是 JSON 格式,每个消息都包含 type 字段,用来区分消息类型。
| 消息类型 | 方向 | 说明 |
|---|---|---|
| join | 客户端 → 服务器 | 加入房间,需携带 roomId 和 userId |
| joined | 服务器 → 客户端 | 确认加入成功 |
| ready | 服务器 → 客户端 | 双方已就绪,可以开始协商 |
| offer | 客户端 ↔ 客户端 | SDP Offer,经服务器转发 |
| answer | 客户端 ↔ 客户端 | SDP Answer,经服务器转发 |
| ice-candidate | 客户端 ↔ 客户端 | ICE Candidate,经服务器转发 |
| peer-left | 服务器 → 客户端 | 对方已离开房间 |
| leave | 客户端 → 服务器 | 主动离开房间 |
| error | 服务器 → 客户端 | 错误信息 |
注意:消息类型的设计要尽量精简。我曾经见过有人把信令消息设计成几十种类型,结果维护起来非常痛苦。记住,信令服务器只做两件事:加入/离开房间,转发协商消息。其他的一概不管。
启动与测试
代码写完了,怎么跑起来?很简单:
node server.js
你会看到控制台输出:
信令服务器已启动,端口: 8080
这时候服务器就在 8080 端口上等着了。你可以用 WebSocket 客户端工具(比如 wscat)来测试:
# 终端1:模拟用户A
wscat -c ws://localhost:8080
> {"type":"join","roomId":"room1","userId":"Alice"}
# 终端2:模拟用户B
wscat -c ws://localhost:8080
> {"type":"join","roomId":"room1","userId":"Bob"}
当两个用户都加入后,服务器会广播 ready 消息,表示可以开始媒体协商了。
常见问题与避坑指南
我在实际项目中遇到过不少问题,挑几个典型的说说:
- 连接断开后房间状态没清理:客户端可能直接关闭页面,不会发 leave 消息。所以一定要在
close事件里做清理,否则房间会一直占用。 - 消息顺序问题:Offer 必须在 Answer 之前到达,ICE Candidate 可以在任何时候发送。如果顺序乱了,连接可能失败。我的做法是在客户端做状态机,确保消息按正确顺序发送。
- 端口被占用:如果 8080 端口被其他程序占用了,服务器会启动失败。可以换个端口,或者用
lsof -i :8080查一下谁在用。
核心要点回顾:
- 信令服务器用 Node.js + WebSocket 实现
- 核心功能:房间管理 + 消息转发
- 消息协议用 JSON 格式,type 字段区分类型
- 一定要处理客户端断开连接的清理工作
- 信令服务器不处理媒体流,只传信令消息
好了,这一章的内容就到这里。代码可以直接拿去用,但建议你亲手敲一遍。只有自己写过,才能真正理解信令服务器的工作原理。下一章我们会把前端代码也补上,让整个视频通话流程跑通。
公众号:蓝海资料掘金营,微信deep3321