4. 信令传输:信令服务器的作用、WebSocket信令实现、Offer/Answer的信令流程

聊到WebRTC,很多人第一反应是P2P、是音视频编解码。但有个东西绕不过去——信令。说白了,信令就是让两个浏览器“对上话”的那套流程。没有信令,你连对方在哪都不知道,还谈什么媒体协商?

我刚开始做WebRTC时,踩过最大的坑就是低估了信令的复杂度。总觉得“不就是传个字符串嘛”,结果项目上线第一天,用户反馈连不上——嗯,后来发现是信令服务器没处理好并发。从那以后,我对信令这块就格外上心。

4.1 信令服务器的作用

信令服务器在WebRTC架构里,扮演的是“中间人”的角色。它不参与媒体数据的传输,但负责帮两个Peer建立连接。

具体来说,信令服务器要干这几件事:

  • 用户管理:维护在线用户列表,处理登录/登出
  • 房间管理:创建房间、加入房间、离开房间
  • 消息转发:把A的SDP Offer转发给B,把B的Answer转发给A
  • ICE候选转发:交换双方的ICE候选地址

核心要点:信令服务器只负责“传话”,不负责“说话”。它不生成SDP,也不做媒体协商。真正的协商发生在两个Peer之间。

你可能会问:为什么WebRTC不把信令也标准化?其实原因很简单——信令跟业务场景强相关。有的场景需要房间机制,有的需要点对点直连,有的需要结合用户认证系统。标准化反而限制了灵活性。

4.2 WebSocket信令实现

实现信令传输,最常用的方案就是WebSocket。为什么选它?因为WebSocket是全双工的,服务器可以主动推消息给客户端。这在信令场景下太重要了——你想,当B加入房间时,服务器得主动通知A:“嘿,有人来了,准备协商吧”。

下面是我个人习惯用的信令消息结构:

// 信令消息格式
{
  "type": "offer" | "answer" | "candidate" | "join" | "leave",
  "from": "user_id",
  "to": "user_id",
  "data": {
    // 根据type不同,data内容不同
    // offer: { sdp: "..." }
    // answer: { sdp: "..." }
    // candidate: { candidate: "...", sdpMid: "...", sdpMLineIndex: 0 }
  }
}

服务器端的实现其实不复杂。我建议用Node.js + ws库,轻量又好用。核心逻辑就三步:

  1. 客户端连接上来,注册用户ID
  2. 收到消息,根据type字段做路由
  3. 转发给目标用户

来看一个简化版的信令服务器代码:

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

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

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const msg = JSON.parse(message);
    
    switch(msg.type) {
      case 'register':
        // 注册用户
        clients.set(msg.from, ws);
        break;
        
      case 'offer':
      case 'answer':
      case 'candidate':
        // 转发给目标用户
        const target = clients.get(msg.to);
        if (target) {
          target.send(JSON.stringify(msg));
        }
        break;
        
      case 'leave':
        clients.delete(msg.from);
        break;
    }
  });
  
  ws.on('close', () => {
    // 清理断开的连接
    for (let [id, conn] of clients) {
      if (conn === ws) {
        clients.delete(id);
        break;
      }
    }
  });
});

经验之谈:我在项目中遇到过一个问题——用户刷新页面后,旧的WebSocket连接还没断开,新的连接又建立了。结果信令消息发给了旧连接,用户收不到。解决方案是:每次注册时,先检查该用户是否已有连接,有的话先关闭旧的。

4.3 Offer/Answer的信令流程

Offer/Answer是WebRTC媒体协商的核心机制。整个流程可以用一张图说清楚:

Offer/Answer 信令流程 Peer A (发起方) 信令服务器 Peer B (接收方) ① 创建Offer createOffer() Offer(SDP) 转发Offer ③ 创建Answer createAnswer() Answer(SDP) 转发Answer ICE候选交换(持续进行) 双方通过信令服务器交换ICE候选地址,直到找到可用路径 ✅ P2P连接建立,媒体开始传输

整个流程其实就四个步骤:

  1. A创建Offer:A调用createOffer(),生成SDP,通过信令服务器发给B
  2. B收到Offer:B调用setRemoteDescription(),把A的SDP设为自己的远端描述
  3. B创建Answer:B调用createAnswer(),生成自己的SDP,通过信令服务器回给A
  4. A收到Answer:A调用setRemoteDescription(),把B的SDP设为自己的远端描述

你以为这就完了?还没呢。在Offer/Answer交换的同时,ICE候选也在跑。ICE候选是告诉对方“我能用哪些IP和端口连你”。这个过程是并行的,而且可能持续好几轮。

注意:我曾经遇到过一个坑——Offer和Answer的SDP里都包含了ICE候选信息。但有些浏览器实现会把ICE候选放在SDP里一起发,有些则分开发。如果你只处理了SDP交换,没处理独立的ICE候选消息,那连接大概率会失败。

来看客户端怎么实现这个流程:

// Peer A: 发起方
async function startCall() {
  const pc = new RTCPeerConnection(config);
  
  // 创建Offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  
  // 通过信令服务器发送Offer
  ws.send(JSON.stringify({
    type: 'offer',
    from: 'userA',
    to: 'userB',
    data: { sdp: offer.sdp }
  }));
  
  // 监听ICE候选
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      ws.send(JSON.stringify({
        type: 'candidate',
        from: 'userA',
        to: 'userB',
        data: event.candidate
      }));
    }
  };
}

// Peer B: 接收方
async function handleOffer(msg) {
  const pc = new RTCPeerConnection(config);
  
  // 设置远端描述
  await pc.setRemoteDescription({
    type: 'offer',
    sdp: msg.data.sdp
  });
  
  // 创建Answer
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  
  // 发送Answer
  ws.send(JSON.stringify({
    type: 'answer',
    from: 'userB',
    to: 'userA',
    data: { sdp: answer.sdp }
  }));
  
  // 监听ICE候选
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      ws.send(JSON.stringify({
        type: 'candidate',
        from: 'userB',
        to: 'userA',
        data: event.candidate
      }));
    }
  };
}

这里有个细节要注意——setLocalDescription和setRemoteDescription的顺序不能乱。A必须先setLocalDescription再发Offer,B必须先setRemoteDescription再createAnswer。顺序错了,SDP就不完整,协商会失败。

避坑指南:我建议在setLocalDescription之后,等onicecandidate触发一次再发Offer。因为有些浏览器在setLocalDescription之后会立即生成ICE候选,如果你发Offer太快,ICE候选可能还没生成完。稳妥的做法是:收集到第一个ICE候选后再发Offer,或者用trickle ICE机制边收集边发。

最后总结一下信令传输的关键点:

环节 关键动作 注意事项
信令服务器 消息路由、用户管理 处理好连接断开、重连场景
WebSocket实现 全双工通信、消息转发 消息格式要统一,做好异常处理
Offer/Answer createOffer → setLocalDescription → 发送 → setRemoteDescription → createAnswer 顺序不能乱,ICE候选要同时处理

信令传输这部分,说白了就是“传话”的活。但传话传不好,后面所有工作都白搭。我见过太多项目,媒体协商逻辑写得天花乱坠,结果信令服务器一崩,全完蛋。所以,先把信令搞稳了,再谈别的。

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