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 信令服务器核心逻辑

信令服务器的核心就四个事件:

  1. join-room:用户加入房间
  2. offer:发起方发送 SDP Offer
  3. answer:接收方回复 SDP Answer
  4. 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-roomdisconnect 事件里调用:

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(发起方) 信令服务器 用户 B(接收方) join-room join-room offer (SDP) 转发 offer answer (SDP) 转发 answer ice-candidate 转发 candidate P2P 连接建立,信令服务器不再参与

流程很清晰:

  1. 双方先加入同一个房间
  2. A 创建 Offer,发给服务器,服务器转发给 B
  3. B 创建 Answer,发给服务器,服务器转发给 A
  4. 双方交换 ICE 候选者,直到找到最佳路径
  5. 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 应用的「神经系统」,虽然代码不多,但逻辑一定要清晰。下一章咱们会写前端代码,真正把视频通话跑起来。


公众号:蓝海资料掘金营,微信 deep3321