信令实现:房间管理、用户加入/离开通知、Offer/Answer信令交换、ICE Candidate信令传递

信令,说白了就是WebRTC的“幕后指挥官”。

很多人刚开始学WebRTC,总以为只要两端都打开摄像头,就能直接连上。嗯,现实可没那么简单。我在第一个WebRTC项目里就踩过这个坑——两端都跑起来了,但就是看不到对方的画面。折腾了半天才发现,信令服务器根本没搭好。

这一章,我们就来亲手实现一个完整的信令系统。它要管四件事:房间管理、用户进出通知、Offer/Answer交换、ICE Candidate传递。

核心要点:信令不是WebRTC协议的一部分,但它是WebRTC应用的“骨架”。没有信令,PeerConnection就是个空壳。

信令的整体架构

先看一张图,你就明白信令在整个系统里扮演什么角色了。

信令系统核心架构 信令服务器 WebSocket / HTTP 房间管理 用户通知 信令交换 ICE传递 客户端 A 创建Offer / 发送ICE 客户端 B 接收Answer / 接收ICE 媒体流 / 数据通道 信令服务器负责协调,不参与媒体数据传输 媒体流走P2P,信令走服务器中转

你看,信令服务器就像个“调度中心”。它不碰媒体数据,只负责传递控制信息。我个人习惯用WebSocket来实现,因为双向通信比HTTP轮询舒服多了。

房间管理

房间是什么?说白了就是一个“虚拟会议室”。用户进来、出去,都得通过房间来管理。

我在项目中遇到过一个问题:用户刷新页面后,房间里的其他人就看不到他了。原因很简单——房间状态只存在内存里,一刷新就丢了。后来我加了个简单的房间状态恢复机制,才搞定。

房间的数据结构

一个房间需要记录哪些信息?我一般这样设计:

字段 类型 说明
roomId string 房间唯一标识
users Map<string, User> 当前在线用户列表
createdAt number 房间创建时间戳
maxUsers number 最大人数限制(可选)

代码实现其实不复杂。用Node.js写个简单的房间管理器:

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

  createRoom(roomId) {
    if (this.rooms.has(roomId)) {
      throw new Error('房间已存在');
    }
    const room = {
      roomId,
      users: new Map(),
      createdAt: Date.now(),
      maxUsers: 10
    };
    this.rooms.set(roomId, room);
    return room;
  }

  joinRoom(roomId, user) {
    const room = this.rooms.get(roomId);
    if (!room) {
      throw new Error('房间不存在');
    }
    if (room.users.size >= room.maxUsers) {
      throw new Error('房间已满');
    }
    room.users.set(user.id, user);
    return room;
  }

  leaveRoom(roomId, userId) {
    const room = this.rooms.get(roomId);
    if (!room) return;
    room.users.delete(userId);
    if (room.users.size === 0) {
      this.rooms.delete(roomId);
    }
  }
}

小技巧:房间ID最好用UUID或者时间戳+随机数生成,别用自增ID。我曾经在生产环境遇到过用户猜房间号的问题,后来全改成UUID了。

用户加入/离开通知

用户加入或离开房间,其他人都得知道。这个通知机制,说白了就是“广播”。

你想想看,如果A加入房间,B和C都得收到通知。不然B还以为房间里就他一个人呢。

通知的消息格式

我习惯用统一的JSON格式:

{
  "type": "user-joined",  // 或 "user-left"
  "data": {
    "userId": "abc123",
    "userName": "张三",
    "roomId": "room-001",
    "timestamp": 1700000000000
  }
}

服务端收到用户加入请求后,做两件事:

  1. 把用户加到房间列表里
  2. 给房间里其他人发通知

代码大概长这样:

// WebSocket 服务端处理
ws.on('message', (data) => {
  const msg = JSON.parse(data);
  
  switch (msg.type) {
    case 'join-room':
      const room = roomManager.joinRoom(msg.roomId, msg.user);
      // 给自己回复成功
      ws.send(JSON.stringify({
        type: 'join-success',
        data: { roomId: msg.roomId, users: [...room.users.values()] }
      }));
      // 广播给其他人
      broadcastToRoom(msg.roomId, {
        type: 'user-joined',
        data: { userId: msg.user.id, userName: msg.user.name }
      }, ws); // 排除自己
      break;
      
    case 'leave-room':
      roomManager.leaveRoom(msg.roomId, msg.userId);
      broadcastToRoom(msg.roomId, {
        type: 'user-left',
        data: { userId: msg.userId }
      });
      break;
  }
});

注意:广播的时候一定要排除发送者自己。不然A加入房间,A自己也会收到“user-joined”通知,这就尴尬了。

Offer/Answer信令交换

这是WebRTC连接的核心步骤。A创建Offer,B回复Answer,中间全靠信令服务器传话。

我记得第一次实现这个流程时,犯了个低级错误——把Offer和Answer搞混了。结果两边都等着对方发Offer,谁也连不上。后来我养成了一个习惯:在消息里加个明确的role字段。

交换流程

标准的SDP交换流程是这样的:

  1. A创建PeerConnection,生成Offer(SDP)
  2. A把Offer通过信令服务器发给B
  3. B收到Offer,设置远端描述,创建Answer
  4. B把Answer通过信令服务器发回给A
  5. A收到Answer,设置远端描述

客户端代码示例:

// 发起方(A)
async function createOffer(peerConnection, ws) {
  const offer = await peerConnection.createOffer();
  await peerConnection.setLocalDescription(offer);
  
  ws.send(JSON.stringify({
    type: 'offer',
    data: {
      sdp: offer.sdp,
      type: offer.type,
      from: myUserId,
      to: targetUserId
    }
  }));
}

// 接收方(B)
async function handleOffer(offer, peerConnection, ws) {
  await peerConnection.setRemoteDescription(
    new RTCSessionDescription(offer)
  );
  
  const answer = await peerConnection.createAnswer();
  await peerConnection.setLocalDescription(answer);
  
  ws.send(JSON.stringify({
    type: 'answer',
    data: {
      sdp: answer.sdp,
      type: answer.type,
      from: myUserId,
      to: offer.from
    }
  }));
}

关键点:setLocalDescription和setRemoteDescription的顺序不能乱。先设本地,再设远端?不对。发起方是先设本地(Offer),再设远端(Answer)。接收方是先设远端(Offer),再设本地(Answer)。

ICE Candidate信令传递

SDP交换完了,接下来就是ICE Candidate的传递。ICE Candidate说白了就是“网络路径候选”。

为什么会需要这个?因为A和B可能在不同的网络环境里。有的在NAT后面,有的在公司防火墙里。ICE就是帮他们找到一条能通的路。

Candidate的传递方式

每个PeerConnection在收集Candidate时,会触发icecandidate事件。我们需要把这些Candidate发给对方。

// 收集并发送ICE Candidate
peerConnection.onicecandidate = (event) => {
  if (event.candidate) {
    ws.send(JSON.stringify({
      type: 'ice-candidate',
      data: {
        candidate: event.candidate.candidate,
        sdpMid: event.candidate.sdpMid,
        sdpMLineIndex: event.candidate.sdpMLineIndex,
        from: myUserId,
        to: targetUserId
      }
    }));
  }
};

// 接收ICE Candidate
function handleIceCandidate(candidateData, peerConnection) {
  const candidate = new RTCIceCandidate({
    candidate: candidateData.candidate,
    sdpMid: candidateData.sdpMid,
    sdpMLineIndex: candidateData.sdpMLineIndex
  });
  peerConnection.addIceCandidate(candidate);
}

经验之谈:ICE Candidate的传递可以“抢跑”。也就是说,你可以在收到Offer/Answer之前就开始发Candidate。但要注意,对方可能还没创建PeerConnection,所以最好等收到Offer/Answer后再处理。我一般会在客户端缓存一下,等PeerConnection准备好了再批量添加。

完整的信令消息类型

把上面这些整合起来,我们的信令系统需要支持这些消息类型:

消息类型 方向 说明
join-room 客户端 → 服务器 请求加入房间
leave-room 客户端 → 服务器 离开房间
user-joined 服务器 → 客户端 通知有新用户加入
user-left 服务器 → 客户端 通知有用户离开
offer 客户端 → 服务器 → 客户端 传递SDP Offer
answer 客户端 → 服务器 → 客户端 传递SDP Answer
ice-candidate 客户端 → 服务器 → 客户端 传递ICE Candidate

嗯,到这里,一个完整的信令系统就搭好了。你想想看,其实核心逻辑并不复杂——就是“转发”。服务器不干别的,就是把A的消息转给B,把B的消息转给A。

我在实际项目中,还会加一些额外的功能,比如心跳检测、断线重连、房间密码保护。但这些都是在基础信令之上做的扩展。先把今天讲的这四块吃透,后面的都好说。


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