实战项目二:搭建多人视频会议的信令服务器(含完整代码)

好,咱们接着往下走。上一章我们把一对一通话的信令服务器搞定了,代码能跑、流程能通。但说实话,那个架构撑死了也就俩人聊。你想想看,如果来个三五个人同时开会,那套逻辑直接就崩了——房间管理、成员列表、媒体协商的转发,全得重写。

这一章,我们就来搞一个真正能用的多人视频会议信令服务器。我会带着你从零搭一个基于 Socket.IO 的 Node.js 服务,支持房间创建、成员进出、以及最重要的——多人之间的媒体协商转发

核心目标: 一个信令服务器,支持 N 个用户同时在一个房间里进行视频通话。每个用户都能看到其他所有人的画面(Mesh 架构)。

多人会议的核心挑战

多人视频会议和一对一最大的区别在哪?我个人的理解是:信令的广播范围变了

一对一的时候,A 发 SDP 给 B,B 回 Answer 给 A,完事。但多人场景下,一个新用户加入房间,他需要把自己的 Offer 发给房间里所有其他人。同时,房间里每个老用户也要把自己的 Offer 重新发给这个新人。这就是所谓的 “全对全”广播

我在做第一个版本的时候,就踩过这个坑。我当时想当然地以为,新人加入后只需要发一次 Offer 就行。结果呢?老用户能看到新人,但新人看不到老用户。为什么?因为老用户的媒体流没有主动发给新人。嗯,这个细节很重要,后面代码里我会特别标注。

整体架构设计

我们先画一张图,把整个信令流程理清楚。这样你写代码的时候心里就有谱了。

多人视频会议信令流程(Mesh 架构) 信令服务器 用户 A 用户 B 用户 C 信令消息流转 ① A 加入房间 → 广播 "user-joined" ② B 收到后,发送 Offer 给 A ③ A 回复 Answer 给 B ④ 新人加入时,所有老用户都要发 Offer 广播

说白了,信令服务器在这里扮演的就是一个消息路由器。它不负责媒体流的转发(那是 WebRTC 的 PeerConnection 干的事),它只负责把信令消息准确投递给房间里的每个人。

完整代码实现

好,我们直接上代码。这个服务器我分成了三个核心模块:房间管理连接处理信令转发。我会把每一块的逻辑都讲清楚。

1. 项目初始化与依赖

先建一个项目目录,装好依赖。我个人习惯用 yarn,但 npm 也一样。

mkdir video-conference-server
cd video-conference-server
npm init -y
npm install socket.io uuid

uuid 是用来给每个连接生成唯一 ID 的,方便我们追踪用户。

2. 房间管理模块

这个模块负责维护所有房间的状态。我把它单独抽出来,方便后面扩展。

// roomManager.js
const { v4: uuidv4 } = require('uuid');

class RoomManager {
  constructor() {
    this.rooms = new Map(); // key: roomId, value: { users: Map }
  }

  createRoom() {
    const roomId = uuidv4().slice(0, 8);
    this.rooms.set(roomId, { users: new Map() });
    return roomId;
  }

  joinRoom(roomId, socketId, userData) {
    const room = this.rooms.get(roomId);
    if (!room) return false;

    room.users.set(socketId, {
      socketId,
      ...userData,
      joinedAt: Date.now()
    });
    return true;
  }

  leaveRoom(roomId, socketId) {
    const room = this.rooms.get(roomId);
    if (!room) return;

    room.users.delete(socketId);
    if (room.users.size === 0) {
      this.rooms.delete(roomId); // 没人了就销毁房间
    }
  }

  getUsersInRoom(roomId) {
    const room = this.rooms.get(roomId);
    if (!room) return [];
    return Array.from(room.users.values());
  }

  getUserCount(roomId) {
    const room = this.rooms.get(roomId);
    return room ? room.users.size : 0;
  }
}

module.exports = RoomManager;

我的经验: 用 Map 而不是普通对象来存用户,是因为 Map 的遍历顺序是插入顺序,而且删除操作更高效。在多人会议中,用户频繁进出,Map 的性能优势很明显。

3. 信令服务器主逻辑

这是核心文件。我会把每个事件的处理逻辑都加上注释,方便你理解。

// server.js
const { Server } = require('socket.io');
const RoomManager = require('./roomManager');

const roomManager = new RoomManager();

const io = new Server({
  cors: {
    origin: '*',
    methods: ['GET', 'POST']
  }
});

io.on('connection', (socket) => {
  console.log(`[连接] 用户 ${socket.id} 已连接`);

  // ---------- 房间管理事件 ----------
  socket.on('create-room', () => {
    const roomId = roomManager.createRoom();
    socket.join(roomId);
    roomManager.joinRoom(roomId, socket.id, { displayName: `用户-${socket.id.slice(0, 4)}` });
    
    socket.emit('room-created', { roomId });
    console.log(`[房间] 用户 ${socket.id} 创建了房间 ${roomId}`);
  });

  socket.on('join-room', ({ roomId, displayName }) => {
    const success = roomManager.joinRoom(roomId, socket.id, { displayName });
    if (!success) {
      socket.emit('error-message', { message: '房间不存在' });
      return;
    }

    socket.join(roomId);
    
    // 获取房间内现有用户列表
    const existingUsers = roomManager.getUsersInRoom(roomId)
      .filter(u => u.socketId !== socket.id);

    // 告诉新人:房间里有哪些老用户
    socket.emit('room-users', { users: existingUsers });

    // 广播给房间其他人:有新用户加入
    socket.to(roomId).emit('user-joined', {
      user: {
        socketId: socket.id,
        displayName
      }
    });

    console.log(`[房间] 用户 ${socket.id} 加入了房间 ${roomId}`);
  });

  // ---------- 信令转发事件 ----------
  socket.on('offer', ({ to, offer }) => {
    // 把 Offer 转发给目标用户
    io.to(to).emit('offer', {
      from: socket.id,
      offer
    });
  });

  socket.on('answer', ({ to, answer }) => {
    io.to(to).emit('answer', {
      from: socket.id,
      answer
    });
  });

  socket.on('ice-candidate', ({ to, candidate }) => {
    io.to(to).emit('ice-candidate', {
      from: socket.id,
      candidate
    });
  });

  // ---------- 断开连接 ----------
  socket.on('disconnect', () => {
    // 找到用户所在的房间
    const rooms = Array.from(socket.rooms).filter(r => r !== socket.id);
    rooms.forEach(roomId => {
      roomManager.leaveRoom(roomId, socket.id);
      // 广播给房间其他人
      socket.to(roomId).emit('user-left', {
        socketId: socket.id
      });
    });
    console.log(`[断开] 用户 ${socket.id} 已断开`);
  });
});

const PORT = process.env.PORT || 3000;
io.listen(PORT);
console.log(`信令服务器运行在端口 ${PORT}`);

注意: 上面的代码中,join-room 事件处理里,我特意把新人加入后没有自动触发 Offer。为什么?因为 Offer 应该由客户端来决定何时发送。服务器只负责转发,不负责决策。这是信令服务器的一个设计原则——保持无状态。

关键流程详解

我们用一个具体的场景来走一遍流程。假设房间里已经有 A 和 B 两个人,现在 C 要加入。

  1. C 发起 join-room:C 的客户端发送 join-room 事件,带上房间 ID 和昵称。
  2. 服务器处理:服务器把 C 加入房间,然后:
    • 给 C 返回 room-users,里面包含 A 和 B 的信息。
    • 给 A 和 B 广播 user-joined,告诉他们 C 来了。
  3. A 和 B 发起 Offer:A 和 B 的客户端收到 user-joined 后,各自创建一个 PeerConnection,然后发送 offer 给 C。
  4. C 回复 Answer:C 收到 A 和 B 的 Offer 后,分别回复 answer
  5. ICE 交换:三方之间互相交换 ICE Candidate,直到连接建立。

这里有一个很容易忽略的点:C 不需要主动给 A 和 B 发 Offer。因为 A 和 B 在收到 user-joined 后,会主动发起 Offer 给 C。C 只需要被动回复即可。这样设计的好处是,避免了重复的 Offer 协商。

客户端配合示例

虽然我们主要讲服务器,但为了让你能测试,我简单写一段客户端配合的伪代码。你可以在浏览器控制台里跑。

// 客户端示例(浏览器中运行)
const socket = io('http://localhost:3000');

// 创建房间
socket.emit('create-room');
socket.on('room-created', ({ roomId }) => {
  console.log('房间已创建:', roomId);
});

// 加入房间
socket.emit('join-room', { roomId: 'abc123', displayName: '张三' });

// 收到房间用户列表
socket.on('room-users', ({ users }) => {
  users.forEach(user => {
    // 为每个老用户创建一个 PeerConnection
    createPeerConnection(user.socketId, true); // true 表示发起 Offer
  });
});

// 收到新用户加入
socket.on('user-joined', ({ user }) => {
  // 为新用户创建一个 PeerConnection
  createPeerConnection(user.socketId, true);
});

// 收到 Offer
socket.on('offer', ({ from, offer }) => {
  // 设置远程描述,并回复 Answer
  pc.setRemoteDescription(offer);
  pc.createAnswer().then(answer => {
    pc.setLocalDescription(answer);
    socket.emit('answer', { to: from, answer });
  });
});

测试与验证

代码写完了,怎么测?我建议你开三个浏览器标签页,每个都连上同一个房间。然后打开浏览器的 chrome://webrtc-internals,看看 ICE 连接状态。

测试场景 预期结果 检查点
三人加入同一房间 每人看到其他两人 控制台打印用户列表
一人断开 其他两人收到 user-left ICE 连接状态变为 disconnected
重复加入相同房间 服务器拒绝或覆盖 检查 roomUsers 去重逻辑
房间无人后重建 新用户可正常创建房间 房间 ID 不重复

避坑指南: 我曾经在测试时发现,用户断开后房间没有及时清理,导致后来的人加入了一个“幽灵房间”。后来我加了一个定时检查,如果房间内所有用户的 socket 都断开了,就自动销毁房间。上面的代码里,leaveRoom 方法已经处理了这种情况。

扩展思考

这个信令服务器虽然能用,但离生产环境还有距离。比如:

  • 房间密码保护:可以在 join-room 时校验密码。
  • 用户角色:主持人可以踢人、静音等。
  • 房间持久化:把房间信息存到 Redis 里,支持多进程。

不过这些都属于锦上添花。核心的信令转发逻辑,就是上面那几十行代码。你把它吃透了,后面加什么功能都不怕。

好,这一章的内容就到这。代码你可以直接拿去用,跑起来试试。有问题的话,多看看控制台日志,信令服务器的调试基本就靠它了。


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