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,因为信令服务器通常还要提供静态文件服务(比如前端页面)。

核心架构

先看一张图,理清整体流程:

信令服务器核心架构 信令交换 offer / answer / candidate 房间管理 创建 / 加入 / 离开 ICE候选交换 candidate 转发 Socket.IO 事件路由 & 消息转发 客户端 A 发起方 (Offer) 客户端 B 接收方 (Answer) P2P 连接 (最终) 信令服务器只负责"牵线",不参与媒体数据传输

房间管理——核心逻辑

房间管理说白了就是:谁在哪个房间,房间里都有谁。我习惯用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