6、信令机制:WebSocket信令实现、JSON消息协议设计、房间管理逻辑

聊到WebRTC,很多人第一反应是P2P、音视频编解码、NAT穿透这些硬核技术。但说实话,真正让WebRTC跑起来的,反而是那个看起来不那么「性感」的信令机制。

我刚开始做WebRTC项目时,踩过最大的坑就是信令。当时以为只要把SDP和ICE Candidate扔过去就行,结果连基本的连接都建立不起来。后来才明白——信令不是简单的消息转发,它是整个实时通信系统的中枢神经。

6.1 为什么需要信令?

WebRTC本身不定义信令协议。它只负责媒体传输,至于怎么找到对方、怎么交换连接信息,这些事它不管。

你想想看,两个浏览器要建立P2P连接,至少需要知道:

  • 对方在哪个房间?
  • 对方的SDP是什么?
  • 对方的ICE Candidate有哪些?

这些信息怎么传递?靠信令。

信令的核心职责:

  • 交换会话描述(SDP Offer/Answer)
  • 交换网络信息(ICE Candidate)
  • 管理房间和用户状态
  • 控制会话生命周期(加入、离开、踢出)

说白了,信令就是WebRTC的「红娘」——它不参与恋爱过程,但负责牵线搭桥。

6.2 WebSocket信令实现

信令传输可以用HTTP轮询、长轮询、甚至邮件(别笑,真有人这么干)。但实际项目中,WebSocket是绝对的主流

为什么?因为实时性。HTTP请求-响应模式太慢了,而且服务端没法主动推送。WebSocket是全双工的,服务端可以随时把消息推给客户端。

我记得第一次用WebSocket做信令时,犯了个低级错误——没做心跳检测。结果用户挂机一晚上,第二天连接全断了,但客户端还以为自己在线。嗯,这个坑我替你们踩过了。

6.2.1 服务端实现(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.send(JSON.stringify({
    type: 'welcome',
    userId: userId
  }));
  
  // 接收消息
  ws.on('message', (data) => {
    try {
      const msg = JSON.parse(data);
      handleMessage(userId, msg);
    } catch (e) {
      console.error('消息解析失败:', e);
    }
  });
  
  // 断开连接
  ws.on('close', () => {
    console.log(`用户 ${userId} 已断开`);
    clients.delete(userId);
    // 通知房间内其他用户
    leaveRoom(userId);
  });
  
  // 心跳检测
  ws.isAlive = true;
  ws.on('pong', () => {
    ws.isAlive = true;
  });
});

// 心跳定时器
const heartbeat = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      return ws.terminate();
    }
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

个人经验:心跳间隔建议设为30秒,超时时间设为10秒。太频繁浪费带宽,太慢检测不及时。我曾经设过60秒,结果用户网络闪断后要等一分钟才能恢复,体验极差。

6.2.2 客户端实现(浏览器端)

class SignalingClient {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.userId = null;
    this.listeners = {};
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      console.log('信令连接已建立');
      this.emit('connected');
    };
    
    this.ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      this.emit(msg.type, msg);
    };
    
    this.ws.onclose = () => {
      console.log('信令连接已断开');
      this.emit('disconnected');
      // 自动重连
      setTimeout(() => this.connect(), 3000);
    };
    
    this.ws.onerror = (err) => {
      console.error('信令连接错误:', err);
    };
  }
  
  send(type, payload) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({
        type: type,
        payload: payload
      }));
    }
  }
  
  on(event, callback) {
    this.listeners[event] = this.listeners[event] || [];
    this.listeners[event].push(callback);
  }
  
  emit(event, data) {
    const handlers = this.listeners[event] || [];
    handlers.forEach(cb => cb(data));
  }
}

6.3 JSON消息协议设计

信令消息的格式,我建议统一用JSON。简单、可读、跨语言。

但JSON也不是随便写的。我见过最离谱的项目,消息字段名全是拼音缩写,连文档都没有。后来维护的人直接崩溃。

下面是我个人习惯的一套消息协议设计:

消息类型 方向 说明
join 客户端→服务端 请求加入房间
joined 服务端→客户端 加入成功,返回房间信息
leave 客户端→服务端 离开房间
user_joined 服务端→客户端 通知有新用户加入
user_left 服务端→客户端 通知有用户离开
offer 客户端→客户端 发送SDP Offer
answer 客户端→客户端 发送SDP Answer
ice_candidate 客户端→客户端 交换ICE Candidate
error 服务端→客户端 错误信息

消息体结构统一为:

{
  "type": "offer",
  "from": "user_123",
  "to": "user_456",
  "roomId": "room_789",
  "payload": {
    "sdp": "v=0\r\no=- ...",
    "type": "offer"
  },
  "timestamp": 1700000000000
}

避坑指南:千万不要把SDP和ICE Candidate放在同一个消息里发送。我曾经这么干过,结果ICE Candidate还没收集完,SDP就已经发出去了,导致连接失败。正确的做法是:先发SDP,等对方回复Answer后,再开始交换ICE Candidate。

6.4 房间管理逻辑

房间管理是信令服务的核心。没有房间,用户就是一盘散沙。

我设计房间管理时,一般遵循这几个原则:

  • 房间即会话:一个房间代表一次通话或会议
  • 用户即连接:一个用户对应一个WebSocket连接
  • 角色区分:至少区分创建者和参与者
  • 状态同步:房间内所有用户的状态要实时同步

6.4.1 房间数据结构

class Room {
  constructor(roomId, creatorId) {
    this.roomId = roomId;
    this.creatorId = creatorId;
    this.users = new Map();  // userId -> userInfo
    this.createdAt = Date.now();
    this.maxUsers = 10;
  }
  
  addUser(userId, userInfo) {
    if (this.users.size >= this.maxUsers) {
      throw new Error('房间已满');
    }
    this.users.set(userId, {
      ...userInfo,
      joinedAt: Date.now()
    });
  }
  
  removeUser(userId) {
    this.users.delete(userId);
    // 如果房间空了,可以销毁
    if (this.users.size === 0) {
      return true;  // 表示可以销毁
    }
    return false;
  }
  
  getUserIds() {
    return Array.from(this.users.keys());
  }
  
  getUsers() {
    return Array.from(this.users.values());
  }
}

6.4.2 房间管理服务

class RoomManager {
  constructor() {
    this.rooms = new Map();  // roomId -> Room
  }
  
  createRoom(creatorId) {
    const roomId = generateRoomId();
    const room = new Room(roomId, creatorId);
    this.rooms.set(roomId, room);
    return room;
  }
  
  joinRoom(roomId, userId, userInfo) {
    const room = this.rooms.get(roomId);
    if (!room) {
      throw new Error('房间不存在');
    }
    room.addUser(userId, userInfo);
    return room;
  }
  
  leaveRoom(roomId, userId) {
    const room = this.rooms.get(roomId);
    if (!room) return;
    
    const shouldDestroy = room.removeUser(userId);
    if (shouldDestroy) {
      this.rooms.delete(roomId);
      console.log(`房间 ${roomId} 已销毁`);
    }
  }
  
  getRoom(roomId) {
    return this.rooms.get(roomId);
  }
  
  listRooms() {
    return Array.from(this.rooms.values()).map(room => ({
      roomId: room.roomId,
      userCount: room.users.size,
      createdAt: room.createdAt
    }));
  }
}

6.4.3 房间生命周期

一个房间的完整生命周期是这样的:

  1. 创建:用户A调用createRoom,服务端生成roomId并返回
  2. 加入:用户B通过roomId加入,服务端通知A有新用户
  3. 通话:双方交换SDP和ICE,建立P2P连接
  4. 离开:任一用户离开,服务端通知另一方
  5. 销毁:所有用户离开后,房间自动销毁

关键点:房间销毁的时机要把握好。我见过一个项目,用户离开后房间还保留着,结果第二天回来发现房间还在,但里面的连接全断了。正确的做法是:所有用户离开后立即销毁,或者设置一个超时时间(比如5分钟)后自动清理。

6.5 信令流程全景图

下面这张图展示了完整的信令交互流程。我把它画出来,是因为当年我自己学的时候,光看文字根本理不清顺序。

WebRTC 信令交互流程 用户A (发起方) 信令服务器 用户B (接收方) ① createRoom ② roomCreated ③ joinRoom ④ userJoined ⑤ offer (SDP) ⑥ forward offer ⑦ answer (SDP) ⑧ forward answer ⑨ ICE Candidate ⑩ forward ICE ⑪ ICE Candidate ⑫ forward ICE P2P 连接建立成功

你看,整个流程其实不复杂。但顺序很重要——必须先交换SDP,再交换ICE Candidate。顺序搞反了,连接就建立不起来。

6.6 实际项目中的坑

最后分享几个我在项目中踩过的坑,希望能帮你少走弯路:

  • 消息顺序问题:WebSocket虽然保证有序,但多个用户同时发消息时,服务端处理顺序可能乱。建议给每条消息加个序列号。
  • 重连状态恢复:用户断线重连后,要能恢复之前的房间状态。我一般会在服务端缓存最近的消息,重连后重新推送。
  • 房间容量控制:别让一个房间挤进太多人。一对一通话最多2人,多人会议建议控制在10人以内,否则信令压力会很大。
  • 安全性:信令消息一定要做鉴权。我见过有人直接遍历roomId,进别人的房间偷听。嗯,这不是段子,是真事。

我的建议:刚开始做信令时,先用最简单的方案——一个WebSocket连接,一个房间管理类,一套JSON协议。别一上来就搞分布式、消息队列。等用户量上来了,再考虑优化。先跑通,再跑快。


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