24、信令服务器:WebSocket信令实现、房间管理、用户加入/离开通知

好,咱们今天来聊聊信令服务器。说实话,很多做WebRTC的同学,一开始都把精力花在音视频流上,觉得信令就是个传消息的,没什么技术含量。我以前也这么想,直到有一次线上环境出了个诡异的bug——用户A能看到B,但B看不到A,查了半天才发现是信令消息的顺序乱了。嗯,从那以后,我再也不敢小看信令服务器了。

信令服务器说白了就是WebRTC的“调度中心”。它不传音视频数据,但它负责让两个浏览器“认识”对方。没有它,你的摄像头和麦克风再好也白搭。

24.1 为什么需要信令服务器?

WebRTC的P2P连接,有个很尴尬的问题:两个浏览器在公网上,怎么找到对方?你想想看,你家电脑的IP是私有的,对方也是私有的,中间隔着NAT、防火墙。这时候就需要一个“中间人”来交换信息。

这个中间人就是信令服务器。它主要干三件事:

  • 交换SDP:告诉对方你支持什么编码、什么分辨率
  • 交换ICE候选:告诉对方你的公网IP和端口
  • 管理房间:谁进来了、谁出去了、房间里都有谁

我个人的习惯是,把信令服务器和业务服务器分开。信令只做信令的事,别掺和业务逻辑。这样出了问题,排查起来也快。

24.2 WebSocket信令实现

WebSocket是信令传输最常用的方式。为什么?因为它全双工、低延迟,而且浏览器原生支持。我见过有人用HTTP轮询做信令,结果延迟高得离谱,用户体验极差。

下面是一个简单的WebSocket信令服务器实现,我用Node.js + ws库来写:

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

// 存储所有连接
const clients = new Map();

wss.on('connection', (ws, req) => {
  const userId = generateUserId();
  clients.set(userId, ws);
  
  console.log(`用户 ${userId} 连接成功`);

  // 处理收到的消息
  ws.on('message', (data) => {
    const message = JSON.parse(data);
    handleMessage(userId, message);
  });

  // 处理断开连接
  ws.on('close', () => {
    console.log(`用户 ${userId} 断开连接`);
    clients.delete(userId);
    // 通知房间内其他用户
    notifyRoomLeave(userId);
  });
});

function handleMessage(userId, message) {
  switch (message.type) {
    case 'join':
      handleJoin(userId, message.roomId);
      break;
    case 'offer':
      forwardMessage(userId, message.targetId, message);
      break;
    case 'answer':
      forwardMessage(userId, message.targetId, message);
      break;
    case 'ice-candidate':
      forwardMessage(userId, message.targetId, message);
      break;
    default:
      console.warn('未知消息类型:', message.type);
  }
}
小提示:我建议在WebSocket连接建立后,先发一个“心跳包”。我曾经遇到过用户网络不稳定,连接断了但服务端没感知,导致房间里有“幽灵用户”。心跳包每5秒发一次,连续3次没收到就主动断开。

24.3 房间管理

房间管理是信令服务器的核心功能。说白了,就是维护一个“谁在哪个房间”的映射表。我见过有人用全局变量存,也有人用Redis。小项目用内存就够了,但如果是生产环境,我建议用Redis,方便横向扩展。

下面是一个房间管理的数据结构:

// 房间数据结构
const rooms = new Map();

// 创建房间
function createRoom(roomId) {
  if (!rooms.has(roomId)) {
    rooms.set(roomId, {
      id: roomId,
      users: new Map(),  // userId -> { ws, joinTime }
      createdAt: Date.now()
    });
    console.log(`房间 ${roomId} 创建成功`);
  }
  return rooms.get(roomId);
}

// 加入房间
function joinRoom(userId, roomId, ws) {
  let room = rooms.get(roomId);
  if (!room) {
    room = createRoom(roomId);
  }
  
  // 检查房间是否已满(假设最多4人)
  if (room.users.size >= 4) {
    ws.send(JSON.stringify({
      type: 'error',
      message: '房间已满'
    }));
    return false;
  }
  
  room.users.set(userId, { ws, joinTime: Date.now() });
  console.log(`用户 ${userId} 加入房间 ${roomId}`);
  return true;
}

// 离开房间
function leaveRoom(userId, roomId) {
  const room = rooms.get(roomId);
  if (!room) return;
  
  room.users.delete(userId);
  console.log(`用户 ${userId} 离开房间 ${roomId}`);
  
  // 如果房间没人了,就销毁
  if (room.users.size === 0) {
    rooms.delete(roomId);
    console.log(`房间 ${roomId} 已销毁`);
  }
}
注意:房间ID一定要做合法性校验。我曾经遇到过有人传了个空字符串当房间ID,结果所有用户都进了同一个“空房间”,乱成一锅粥。建议房间ID用UUID或者时间戳+随机数生成。

24.4 用户加入/离开通知

用户加入或离开房间时,需要通知房间内的其他用户。这个逻辑看似简单,但实现不好容易出问题。我见过最典型的bug是:用户A离开后,用户B收到通知,但用户B的UI没有及时更新,导致B还在跟空气说话。

通知的实现其实就两步:

  1. 构造通知消息
  2. 遍历房间内其他用户的WebSocket连接,发送消息
// 通知用户加入
function notifyRoomJoin(newUserId, roomId) {
  const room = rooms.get(roomId);
  if (!room) return;
  
  const joinMessage = {
    type: 'user-joined',
    userId: newUserId,
    roomId: roomId,
    userCount: room.users.size
  };
  
  // 遍历房间内所有用户
  room.users.forEach((user, userId) => {
    if (userId !== newUserId) {
      user.ws.send(JSON.stringify(joinMessage));
    }
  });
  
  // 给新用户发送当前房间用户列表
  const userList = Array.from(room.users.keys());
  const newUserWs = room.users.get(newUserId).ws;
  newUserWs.send(JSON.stringify({
    type: 'room-info',
    roomId: roomId,
    users: userList,
    userCount: userList.length
  }));
}

// 通知用户离开
function notifyRoomLeave(userId, roomId) {
  const room = rooms.get(roomId);
  if (!room) return;
  
  const leaveMessage = {
    type: 'user-left',
    userId: userId,
    roomId: roomId,
    userCount: room.users.size - 1  // 注意:此时用户还没从房间移除
  };
  
  room.users.forEach((user, uid) => {
    if (uid !== userId) {
      user.ws.send(JSON.stringify(leaveMessage));
    }
  });
}
关键点:通知的顺序很重要。我的做法是:先通知其他人“有人来了”,再给新人发房间信息。这样能保证新人收到信息时,其他人已经准备好了。反过来,离开时先通知其他人,再清理房间数据。

24.5 信令消息协议设计

信令消息的格式,我建议统一用JSON。虽然有人用Protobuf,但JSON调试起来方便,而且浏览器原生支持。下面是我常用的消息协议:

消息类型 方向 说明 关键字段
join 客户端→服务端 请求加入房间 roomId
leave 客户端→服务端 请求离开房间 roomId
offer 客户端→服务端→客户端 转发SDP Offer targetId, sdp
answer 客户端→服务端→客户端 转发SDP Answer targetId, sdp
ice-candidate 客户端→服务端→客户端 转发ICE候选 targetId, candidate
user-joined 服务端→客户端 通知有新用户加入 userId, roomId, userCount
user-left 服务端→客户端 通知有用户离开 userId, roomId, userCount
room-info 服务端→客户端 发送房间当前状态 roomId, users[], userCount

这个协议看起来简单,但够用了。我见过有人把协议设计得特别复杂,什么消息优先级、消息确认、重传机制都加上去了。其实没必要,WebSocket本身已经保证了消息的有序性和可靠性。你想想看,信令消息丢了,大不了重新连接一次,又不是传音视频流。

24.6 信令服务器的整体流程

为了让你更直观地理解整个信令流程,我画了一张图:

WebRTC信令流程 浏览器A 用户ID: userA 信令服务器 房间管理 + 消息转发 浏览器B 用户ID: userB ① join(room1) ② join(room1) ③ user-joined(userB) ④ room-info(users: A,B) ⑤ offer(sdpA) ⑥ offer(sdpA) ⑦ answer(sdpB) ⑧ answer(sdpB) ⑨ ice-candidate ⑩ ice-candidate ⑪ ice-candidate ⑫ ice-candidate P2P连接建立成功,开始音视频通信 浏览器→服务器 服务器→浏览器

这张图展示了完整的信令流程。从加入房间,到交换SDP,再到交换ICE候选,最后建立P2P连接。每一步都离不开信令服务器的转发。

24.7 避坑指南

最后,分享几个我踩过的坑:

  • 消息顺序问题:我曾经遇到过offer还没到,answer先到了的情况。解决方案是在客户端加一个状态机,只有收到offer后才能发answer。
  • 连接断开重连:用户网络闪断,WebSocket重连后,房间信息需要重新同步。我建议在重连成功后,客户端主动发一个sync请求,服务端把房间状态全量发回去。
  • 多房间并发:一个用户同时加入多个房间?虽然不常见,但最好在服务端做限制,一个用户同一时间只能在一个房间里。
  • 日志记录:信令服务器的日志一定要详细。我每次排查问题,第一件事就是看信令日志。谁什么时候加入了哪个房间,发了什么消息,一目了然。

好了,信令服务器的核心内容就这些。说白了,它就是个消息中转站,但要做好也不容易。下一节我们会聊更具体的东西,先把今天的内容消化掉。


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