7、信令服务搭建(下):信令消息格式设计、房间管理逻辑(创建/加入/离开)、信令与RTCPeerConnection的联动

上一节我们把信令服务的架子搭起来了,WebSocket 也能跑了。但说实话,那只是个空壳子。真正的核心在于——消息怎么传、房间怎么管、以及信令怎么跟 RTCPeerConnection 配合起来。这三点搞不定,你的 WebRTC 应用就是个摆设。

我个人习惯,先把消息格式定死。为什么?因为前后端一旦开始联调,最怕的就是「你发的字段名跟我对不上」。我在项目中遇到过好几次,就因为一个字段名大小写不一致,排查了整整一下午。所以,先定规矩,再写代码。

7.1 信令消息格式设计

信令消息说白了就是一段 JSON。但这段 JSON 得有固定的结构,不然客户端和服务端都看不懂。我一般这样设计:

{
  "type": "join",        // 消息类型,必填
  "roomId": "room001",   // 房间 ID,必填
  "userId": "user_abc",  // 用户 ID,必填
  "payload": {           // 具体数据,根据 type 不同而变化
    "sdp": "...",
    "candidate": "..."
  },
  "timestamp": 1690000000000  // 时间戳,可选
}

嗯,这里要注意:type 字段是核心。它决定了这条消息要干什么。我常用的消息类型有这些:

消息类型 方向 说明
join 客户端 → 服务端 用户请求加入房间
joined 服务端 → 客户端 通知用户已成功加入
new_peer 服务端 → 客户端 通知房间内有新用户加入
offer 客户端 → 服务端 发送 SDP Offer
answer 客户端 → 服务端 发送 SDP Answer
ice_candidate 客户端 → 服务端 发送 ICE 候选者
leave 客户端 → 服务端 用户离开房间
peer_left 服务端 → 客户端 通知房间内有用户离开

你想想看,有了这个表格,前后端开发人员各拿一份,照着字段名写代码,基本不会出岔子。我曾经在一个项目里没做这个表,结果前端发的是 roomId,后端读的是 room_id,你说尴尬不尴尬?

小技巧: 我建议在服务端加一个消息校验函数。每次收到消息先校验字段完整性,不合法直接返回错误消息。这样能避免很多「消息发了但没反应」的诡异问题。

7.2 房间管理逻辑

房间管理,说白了就是维护一个「谁在哪个房间」的映射表。我一般用 Map 来实现,结构大概是这样的:

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

// 每个房间的结构
{
  "room001": {
    "users": [
      { "userId": "user_abc", "ws": WebSocket对象 },
      { "userId": "user_def", "ws": WebSocket对象 }
    ],
    "createdAt": 1690000000000
  }
}

为什么要把 WebSocket 对象也存进去?因为后面转发消息的时候,你得知道往哪个 socket 发。这个设计我踩过坑——一开始我只存了 userId,结果转发消息时还得查一遍 userId 到 socket 的映射,多了一层麻烦。

7.2.1 创建房间

创建房间的逻辑其实很简单。客户端发一个 join 消息,如果房间不存在,就新建一个。我习惯这样写:

function handleJoin(ws, message) {
  const { roomId, userId } = message;
  
  // 如果房间不存在,自动创建
  if (!rooms.has(roomId)) {
    rooms.set(roomId, {
      users: [],
      createdAt: Date.now()
    });
    console.log(`房间 ${roomId} 已创建`);
  }
  
  const room = rooms.get(roomId);
  
  // 检查用户是否已经在房间里
  const existingUser = room.users.find(u => u.userId === userId);
  if (existingUser) {
    // 更新 WebSocket 连接(可能是重连)
    existingUser.ws = ws;
  } else {
    room.users.push({ userId, ws });
  }
  
  // 通知用户加入成功
  ws.send(JSON.stringify({
    type: "joined",
    roomId,
    userId,
    payload: { users: room.users.map(u => u.userId) }
  }));
  
  // 通知房间内其他用户:有新伙伴来了
  broadcastToRoom(roomId, {
    type: "new_peer",
    roomId,
    payload: { userId }
  }, ws);  // 排除自己
}

这里有个细节:broadcastToRoom 的第三个参数是排除当前 socket。为什么?因为新加入的用户已经收到了 joined 消息,不需要再收到一次 new_peer。不然客户端逻辑会乱掉。

7.2.2 加入房间

加入房间的逻辑跟创建差不多,区别在于:如果房间不存在,是报错还是自动创建?这取决于你的业务场景。我做视频会议时,一般选择自动创建。但如果是预约会议,房间不存在就应该报错。

// 严格模式:房间不存在就报错
if (!rooms.has(roomId)) {
  ws.send(JSON.stringify({
    type: "error",
    payload: { message: "房间不存在" }
  }));
  return;
}
注意: 我曾经在线上环境遇到过一个问题:用户加入房间时,房间已经满了(比如限制最多4人)。这时候不能直接让他加入,而是应该返回一个「房间已满」的错误。所以房间管理最好加上最大人数限制。

7.2.3 离开房间

离开房间的逻辑相对简单,但有个坑:用户可能是正常离开,也可能是断网了。断网的情况,WebSocket 的 close 事件会触发。所以离开逻辑要同时处理主动离开和被动断开。

function handleLeave(ws, message) {
  const { roomId, userId } = message;
  const room = rooms.get(roomId);
  if (!room) return;
  
  // 从房间中移除用户
  room.users = room.users.filter(u => u.userId !== userId);
  
  // 如果房间没人了,清理房间
  if (room.users.length === 0) {
    rooms.delete(roomId);
    console.log(`房间 ${roomId} 已销毁`);
    return;
  }
  
  // 通知其他人
  broadcastToRoom(roomId, {
    type: "peer_left",
    roomId,
    payload: { userId }
  });
}

// WebSocket 断开时也要处理
ws.on('close', () => {
  // 查找用户所在的房间并执行离开逻辑
  for (const [roomId, room] of rooms.entries()) {
    const user = room.users.find(u => u.ws === ws);
    if (user) {
      handleLeave(ws, { roomId, userId: user.userId });
      break;
    }
  }
});

嗯,这里要注意:清理房间这个操作容易被忽略。如果不清理,房间 Map 会越来越大,最后内存泄漏。我见过一个项目,上线跑了三天,房间 Map 里攒了几万个空房间,服务直接 OOM 了。

7.3 信令与 RTCPeerConnection 的联动

这才是今天的重头戏。信令服务说白了就是个「传话筒」,它不负责媒体数据的传输。真正的媒体流走的是 RTCPeerConnection。那信令怎么跟 RTCPeerConnection 联动呢?

我画了一张图,你看完就明白了:

信令与 RTCPeerConnection 联动流程 用户A (发起方) 信令服务 用户B (接收方) ① 创建 Offer createOffer() type: "offer" ② 转发 Offer type: "offer" ③ 设置远端描述 setRemoteDescription() ④ 创建 Answer createAnswer() type: "answer" ⑤ 转发 Answer type: "answer" ⑥ 设置远端描述 setRemoteDescription() ICE 候选者交换 (type: "ice_candidate")

流程其实不复杂。说白了就是:A 创建 Offer → 信令转发给 B → B 设置远端描述 → B 创建 Answer → 信令转发给 A → A 设置远端描述。然后双方交换 ICE 候选者,最终建立 P2P 连接。

代码层面,客户端的核心逻辑是这样的:

// 创建 RTCPeerConnection
const pc = new RTCPeerConnection(config);

// 当 ICE 候选者产生时,通过信令发送给对方
pc.onicecandidate = (event) => {
  if (event.candidate) {
    sendSignalingMessage({
      type: "ice_candidate",
      roomId,
      userId,
      payload: { candidate: event.candidate }
    });
  }
};

// 收到信令消息的处理
function handleSignalingMessage(message) {
  switch (message.type) {
    case "offer":
      // 设置远端描述
      pc.setRemoteDescription(new RTCSessionDescription(message.payload.sdp));
      // 创建 Answer
      pc.createAnswer().then(answer => {
        pc.setLocalDescription(answer);
        sendSignalingMessage({
          type: "answer",
          roomId,
          userId,
          payload: { sdp: answer }
        });
      });
      break;
      
    case "answer":
      pc.setRemoteDescription(new RTCSessionDescription(message.payload.sdp));
      break;
      
    case "ice_candidate":
      pc.addIceCandidate(new RTCIceCandidate(message.payload.candidate));
      break;
  }
}
核心要点: 信令服务只负责转发,不处理 SDP 和 ICE 候选者。所有媒体协商的逻辑都在客户端的 RTCPeerConnection 中完成。信令服务就是个「邮递员」,把消息从 A 送到 B,再从 B 送回 A。

我在项目中遇到过一个问题:ICE 候选者发送的时机。如果 Offer/Answer 还没交换完,ICE 候选者就发过去了,接收方会报错。为什么?因为接收方还没有设置远端描述,addIceCandidate 会失败。解决办法是:在 setRemoteDescription 完成之后再处理 ICE 候选者。或者更简单——把 ICE 候选者先缓存起来,等描述设置完再批量添加。

避坑指南: 我曾经在信令转发时忘记排除发送者自己,结果 A 发的 Offer 又转回给了 A,A 把自己的 Offer 当成远端描述设置了,直接报错。所以 broadcastToRoom 一定要排除当前 socket。

嗯,到这里信令服务的核心逻辑就讲完了。消息格式、房间管理、联动流程,这三个点你搞清楚了,一个基本的 WebRTC 应用就能跑起来了。剩下的就是一些细节优化,比如重连机制、房间心跳、错误处理等,这些在实际项目中慢慢积累就好。


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