23、项目实战:视频会议系统(二):信令服务实现(房间管理+用户管理)、前端信令客户端封装

好,咱们接着上一章继续。上一章我们把项目架子搭起来了,也聊了聊信令服务在整个系统里的位置。这一章,咱们要动真格的了——把信令服务的核心逻辑写出来,再把前端那边的信令客户端封装好。

说实话,信令服务这部分,我当年第一次做的时候踩了不少坑。尤其是房间管理和用户状态同步,稍不留神就会出各种诡异的问题。今天我把这些经验都揉碎了讲给你听。

信令服务的核心职责

先理清楚,信令服务到底要干什么?说白了就三件事:

  • 房间管理:创建房间、加入房间、离开房间、销毁房间
  • 用户管理:用户上线、用户离线、用户列表同步
  • 消息转发:把SDP、ICE候选等信令消息,从一个人转发给另一个人

你想想看,如果没有信令服务,两个浏览器之间根本不知道对方在哪。WebRTC只负责媒体传输,但“怎么找到对方”这件事,得靠信令服务搞定。

房间管理实现

房间,本质上就是一个虚拟的会议室。每个房间有一个唯一ID,里面有一堆用户。我习惯用Map来存,简单直接。

// roomManager.js
class RoomManager {
  constructor() {
    this.rooms = new Map();
  }

  createRoom(roomId, creatorId) {
    if (this.rooms.has(roomId)) {
      throw new Error('房间已存在');
    }
    const room = {
      id: roomId,
      creator: creatorId,
      users: new Map(),
      createdAt: Date.now()
    };
    this.rooms.set(roomId, room);
    console.log(`[房间] 创建成功: ${roomId}`);
    return room;
  }

  joinRoom(roomId, user) {
    const room = this.rooms.get(roomId);
    if (!room) {
      throw new Error('房间不存在');
    }
    if (room.users.size >= 4) {
      throw new Error('房间已满员(最多4人)');
    }
    room.users.set(user.id, user);
    console.log(`[房间] ${user.name} 加入 ${roomId}`);
    return room;
  }

  leaveRoom(roomId, userId) {
    const room = this.rooms.get(roomId);
    if (!room) return;
    room.users.delete(userId);
    console.log(`[房间] ${userId} 离开 ${roomId}`);
    if (room.users.size === 0) {
      this.rooms.delete(roomId);
      console.log(`[房间] 已销毁: ${roomId}`);
    }
  }

  getRoom(roomId) {
    return this.rooms.get(roomId);
  }

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

module.exports = RoomManager;

关键设计点

  • 房间满员限制:我设了4人,实际项目中可以根据业务调整
  • 自动销毁:最后一个人离开时,房间自动清理,避免内存泄漏
  • 用户用Map存:方便按ID快速查找和删除

我的经验:曾经有个项目,房间销毁后没有及时清理定时器,导致服务端内存一直涨。后来加了个定时检查机制,超过30分钟没人说话的房间自动销毁。嗯,这个后面可以加。

用户管理实现

用户管理比房间管理稍微复杂一点。因为用户有状态:在线、离线、在房间中、不在房间中。而且用户断开连接时,要能自动清理。

// userManager.js
class UserManager {
  constructor() {
    this.users = new Map();
  }

  addUser(socketId, userInfo) {
    const user = {
      id: socketId,
      name: userInfo.name || '匿名用户',
      avatar: userInfo.avatar || '',
      roomId: null,
      joinedAt: Date.now(),
      isOnline: true
    };
    this.users.set(socketId, user);
    console.log(`[用户] 上线: ${user.name} (${socketId})`);
    return user;
  }

  removeUser(socketId) {
    const user = this.users.get(socketId);
    if (!user) return null;
    user.isOnline = false;
    this.users.delete(socketId);
    console.log(`[用户] 离线: ${user.name}`);
    return user;
  }

  setUserRoom(socketId, roomId) {
    const user = this.users.get(socketId);
    if (user) {
      user.roomId = roomId;
    }
  }

  getUser(socketId) {
    return this.users.get(socketId);
  }

  getOnlineUsers() {
    return Array.from(this.users.values()).filter(u => u.isOnline);
  }
}

module.exports = UserManager;

这里有个细节:用户ID用的是Socket.IO的socketId。为什么?因为每个WebSocket连接都有唯一ID,天然适合做用户标识。当然,如果你有用户系统,也可以用业务ID,但那样需要额外维护socketId和userId的映射关系。

注意:用户断开连接时,一定要同时从房间中移除。我曾经漏掉这一步,结果房间里有个“幽灵用户”,其他用户一直看到他在线,但永远连不上。排查了半天才发现是leaveRoom没调。

信令消息处理

房间和用户管理好了,接下来就是消息转发。这是信令服务的核心能力。

// signalingHandler.js
function setupSignalingHandlers(io, roomManager, userManager) {
  io.on('connection', (socket) => {
    console.log(`[连接] 新客户端: ${socket.id}`);

    // 用户上线
    socket.on('user:join', (userInfo) => {
      const user = userManager.addUser(socket.id, userInfo);
      socket.emit('user:joined', { id: socket.id, name: user.name });
    });

    // 创建房间
    socket.on('room:create', (roomId) => {
      try {
        const room = roomManager.createRoom(roomId, socket.id);
        socket.join(roomId);
        userManager.setUserRoom(socket.id, roomId);
        socket.emit('room:created', { roomId });
      } catch (err) {
        socket.emit('error', { message: err.message });
      }
    });

    // 加入房间
    socket.on('room:join', (roomId) => {
      try {
        const user = userManager.getUser(socket.id);
        const room = roomManager.joinRoom(roomId, {
          id: socket.id,
          name: user.name,
          avatar: user.avatar
        });
        socket.join(roomId);
        userManager.setUserRoom(socket.id, roomId);

        // 通知房间内其他用户
        socket.to(roomId).emit('user:entered', {
          userId: socket.id,
          name: user.name
        });

        // 返回当前用户列表
        const users = roomManager.getUsersInRoom(roomId);
        socket.emit('room:joined', { roomId, users });
      } catch (err) {
        socket.emit('error', { message: err.message });
      }
    });

    // 转发信令消息
    socket.on('signal:offer', (data) => {
      socket.to(data.targetId).emit('signal:offer', {
        from: socket.id,
        sdp: data.sdp
      });
    });

    socket.on('signal:answer', (data) => {
      socket.to(data.targetId).emit('signal:answer', {
        from: socket.id,
        sdp: data.sdp
      });
    });

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

    // 断开连接
    socket.on('disconnect', () => {
      const user = userManager.removeUser(socket.id);
      if (user && user.roomId) {
        roomManager.leaveRoom(user.roomId, socket.id);
        socket.to(user.roomId).emit('user:left', {
          userId: socket.id,
          name: user.name
        });
      }
      console.log(`[断开] ${socket.id}`);
    });
  });
}

消息流转图

信令服务消息流转图 客户端 A socketId: aaa 信令服务 Socket.IO Server 客户端 B socketId: bbb room:join user:entered signal:offer signal:offer signal:answer signal:answer 信令服务只负责转发,不解析SDP内容 所有信令消息都是点对点转发,不广播

前端信令客户端封装

服务端写好了,前端也得有个对应的客户端。我习惯封装成一个类,把Socket.IO的细节藏起来,对外只暴露几个简单的方法。

// SignalingClient.js
class SignalingClient {
  constructor(serverUrl) {
    this.socket = io(serverUrl);
    this.listeners = {};
    this.isConnected = false;

    this.socket.on('connect', () => {
      console.log('[信令] 已连接');
      this.isConnected = true;
    });

    this.socket.on('disconnect', () => {
      console.log('[信令] 已断开');
      this.isConnected = false;
    });
  }

  // 加入系统
  join(userInfo) {
    this.socket.emit('user:join', userInfo);
  }

  // 创建房间
  createRoom(roomId) {
    return new Promise((resolve, reject) => {
      this.socket.emit('room:create', roomId);
      this.socket.once('room:created', (data) => resolve(data));
      this.socket.once('error', (err) => reject(err));
    });
  }

  // 加入房间
  joinRoom(roomId) {
    return new Promise((resolve, reject) => {
      this.socket.emit('room:join', roomId);
      this.socket.once('room:joined', (data) => resolve(data));
      this.socket.once('error', (err) => reject(err));
    });
  }

  // 发送offer
  sendOffer(targetId, sdp) {
    this.socket.emit('signal:offer', { targetId, sdp });
  }

  // 发送answer
  sendAnswer(targetId, sdp) {
    this.socket.emit('signal:answer', { targetId, sdp });
  }

  // 发送ICE候选
  sendIceCandidate(targetId, candidate) {
    this.socket.emit('signal:ice-candidate', { targetId, candidate });
  }

  // 监听事件
  on(event, callback) {
    this.socket.on(event, callback);
  }

  // 断开连接
  disconnect() {
    this.socket.disconnect();
  }
}

封装的好处

  • 业务代码不用关心Socket.IO的细节,只需要调方法
  • 如果以后换WebSocket库,只需要改这一个文件
  • 可以统一处理连接状态、重连逻辑

使用示例

来看看前端怎么用这个客户端:

// app.js
const client = new SignalingClient('http://localhost:3000');

// 1. 加入系统
client.join({ name: '张三', avatar: '' });

// 2. 创建或加入房间
const roomId = 'room-001';
client.joinRoom(roomId).then((data) => {
  console.log('加入房间成功', data);
  // data.users 是当前房间的用户列表
  data.users.forEach(user => {
    if (user.id !== client.socket.id) {
      // 创建PeerConnection,发起offer
      createPeerConnection(user.id, true);
    }
  });
});

// 3. 监听新用户进入
client.on('user:entered', (data) => {
  console.log('新用户来了', data);
  createPeerConnection(data.userId, true);
});

// 4. 监听信令消息
client.on('signal:offer', (data) => {
  createPeerConnection(data.from, false);
  // 设置remoteSDP,生成answer
});

client.on('signal:answer', (data) => {
  // 设置remoteSDP
});

client.on('signal:ice-candidate', (data) => {
  // 添加ICE候选
});

常见坑

  • 信令消息的顺序问题:offer必须在answer之前,ICE候选可以在任何时候到达
  • 重复监听:每次on都会添加一个新监听器,记得用once或者清理
  • 连接断开后重连:用户需要重新joinRoom,服务端不会自动恢复状态

本章小结

这一章我们把信令服务的核心实现了。房间管理、用户管理、消息转发,这三块是视频会议系统的基石。前端封装也做好了,下一章就可以直接用这个客户端来建立PeerConnection了。

我个人觉得,信令服务这部分虽然代码不多,但设计上要花点心思。尤其是状态同步和异常处理,稍不注意就会出问题。建议你写完代码后,多模拟几个用户同时操作的情况,看看会不会出现状态不一致。

好,今天就到这里。代码都在项目里,你可以直接跑起来试试。有问题随时交流。


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