WebRTC 音视频通信:信令、STUN/TURN 与媒体协商

FreeGuideOnline 最新 2026-06-12

认识 WebRTC:实时通信的核心技术

WebRTC(Web Real-Time Communication)是一套开源项目及标准,让浏览器和移动应用无需安装任何插件即可实现实时音视频通话和数据传输。它由三大核心 API 组成:MediaStream(获取摄像头和麦克风)、RTCPeerConnection(建立点对点连接并传输音视频流)以及 RTCDataChannel(传输任意数据)。本教程聚焦于 如何搭建一个完整的视频通话应用,并深入讲解信令、网络穿透(STUN/TURN)以及媒体协商这三个建立连接的必要环节。

你的第一个视频通话应用需要什么?

一个基础的 WebRTC 视频聊天应用包含两个客户端(通常是两个浏览器 Tab)和一个信令服务器。两个客户端通过 信令服务器 交换必要的信息(SDP 和 ICE 候选),从而建立直接的对等连接。对等连接通过 RTCPeerConnection 实现,它负责编码、传输音频和视频。

大致的通信流程如下:

  1. 获取本地媒体:调用 getUserMedia() 打开摄像头和麦克风。
  2. 创建对等连接:实例化 RTCPeerConnection 并添加本地媒体流。
  3. 创建并交换 SDP:一方创建 offer,另一方回复 answer,双方通过信令服务器交换 SDP 描述。
  4. 交换 ICE 候选:双方收集网络候选地址(ICE candidate),并通过信令服务器交换。
  5. 连接建立:一旦双方选择了可连通的候选对,音视频流便开始传输。

接下来,我们将逐步拆解这些步骤,并重点阐述幕后的三大支撑技术:信令、STUN/TURN 与媒体协商。


信令:协调通信的隐形信使

WebRTC 规范并没有定义信令的具体实现,它更像是一个抽象概念,负责在两个对等端之间 交换控制消息。这些消息用于搭建和关闭通信会话,主要包括三类信息:

  • 会话描述信息(SDP):描述媒体格式、编解码器、网络信息等。
  • 网络候选信息(ICE candidates):本机所有可能的网络地址。
  • 会话控制:加入房间、离开房间、错误通知等。

任何允许双方双向实时通信的技术都可以作为信令通道,例如 WebSocket、HTTP 长轮询、Google Cloud Messaging 等。WebSocket 是最常用的选择,因为它具有低延迟和全双工的特性。

用 WebSocket 实现一个简易信令服务器

以下是一个基于 Node.js 和 ws 库的极简信令服务器。它实现了房间管理,并将消息转发给房间内的另一名用户。

注意:此教程侧重前端逻辑,服务器代码仅作示例。

// 信令服务器 (Node.js + ws)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

const rooms = new Map();

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const data = JSON.parse(message);
    switch (data.type) {
      case 'join': {
        const roomId = data.room;
        if (!rooms.has(roomId)) rooms.set(roomId, new Set());
        const room = rooms.get(roomId);
        // 限制每个房间最多两人
        if (room.size >= 2) {
          ws.send(JSON.stringify({ type: 'full' }));
          return;
        }
        room.add(ws);
        ws.roomId = roomId;
        // 通知房间内其他人有新用户加入
        room.forEach(client => {
          if (client !== ws && client.readyState === WebSocket.OPEN) {
            client.send(JSON.stringify({ type: 'ready' }));
          }
        });
        break;
      }
      case 'offer':
      case 'answer':
      case 'candidate': {
        // 将 SDP 或 ICE candidate 转发给房间内另一个客户端
        const room = rooms.get(ws.roomId);
        if (room) {
          room.forEach(client => {
            if (client !== ws && client.readyState === WebSocket.OPEN) {
              client.send(JSON.stringify(data));
            }
          });
        }
        break;
      }
    }
  });

  ws.on('close', () => {
    if (ws.roomId && rooms.has(ws.roomId)) {
      const room = rooms.get(ws.roomId);
      room.delete(ws);
      if (room.size === 0) rooms.delete(ws.roomId);
    }
  });
});

关键点:信令服务器只做 “消息中转”,绝不参与媒体流的传输。所有媒体数据将在点对点连接建立后直接发送。


媒体协商:让两端说同一种语言

两个浏览器可能支持不同的编解码器(如 H.264、VP8、VP9)、不同的分辨率,甚至不同的网络条件。为了让视频和音频能正常通信,WebRTC 使用 会话描述协议(SDP) 来进行协商。这个过程称为 媒体协商,遵循 offer/answer 模型

SDP 是什么?

SDP 是一段文本,描述了一端希望建立的媒体会话的完整配置,它并非 WebRTC 独有。一个典型的 SDP 包含:

  • 媒体行:表示是音频还是视频。
  • 编解码器列表:按优先级排列。
  • 候选地址信息(早期 SDP 中会包含,但 WebRTC 通常用 ICE 候选单独发送)。
  • 安全参数:DTLS 指纹等。

完成一次 Offer / Answer 交换

假设有两个客户端:A 作为发起方B 作为接收方

  1. A 创建 Offer A 调用 RTCPeerConnectioncreateOffer() 生成本地 SDP(描述 A 的媒体能力和网络信息)。之后必须调用 setLocalDescription() 将这个 SDP 设置为“本地描述”。然后通过信令服务器将 SDP 发送给 B。

    // 客户端 A
    const pc = new RTCPeerConnection(configuration);
    // ... 添加本地媒体流到 pc
    
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    // 通过信令发送给 B
    signaling.send({ type: 'offer', sdp: pc.localDescription });
    
  2. B 处理 Offer 并创建 Answer B 收到 offer 后,调用 setRemoteDescription() 将收到的 SDP 设为“远端描述”。然后调用 createAnswer() 生成应答 SDP,并再次调用 setLocalDescription() 设置本地描述。最后将 answer 通过信令发送回 A。

    // 客户端 B
    pc.ondatachannel = (event) => { ... }; // 如果用到数据通道
    await pc.setRemoteDescription(new RTCSessionDescription(offer));
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    signaling.send({ type: 'answer', sdp: pc.localDescription });
    
  3. A 完成协商 A 收到 answer 后,调用 setRemoteDescription() 设置远端描述。至此,媒体格式协商完成,但连接尚未完全建立,还需要 ICE 候选交换。

    // A 收到 answer
    await pc.setRemoteDescription(new RTCSessionDescription(answer.sdp));
    

SDP 的内容通常是自动生成的,开发者无需手动解析或修改。但当需要强制使用特定编解码器或调整比特率时,可以通过修改 SDP 字符串来实现(高级用法)。


STUN 与 TURN:穿越网络的“地图”与“中继”

媒体协商后,两端知道了彼此的媒体能力,但还不知道 如何实际将数据包发送给对方。这个问题源自 NAT(网络地址转换)和防火墙的阻断。在真实网络中,绝大多数设备都处于私有 IP 地址之后。WebRTC 使用 ICE(交互式连接建立)框架 来解决这个连通性问题,而 ICE 依赖 STUNTURN 服务器。

为什么需要 STUN?

STUN(Session Traversal Utilities for NAT)服务器就像一个“回音壁”。客户端向公网上的 STUN 服务器发送一个请求,STUN 服务器会回复该请求的来源 IP 地址和端口。这样客户端就能获得自己的 公网地址(Server Reflexive Address)。获得这个地址后,客户端会通过信令将该地址作为一个 ICE 候选发送给对方。如果双方都能直接使用公网地址进行通信,就可以建立 直接连接

当直接连接失败:TURN 作为中继

在对称 NAT 或严格防火墙的情境下,即使知道公网地址也可能无法直接通信。这时就需要 TURN(Traversal Using Relays around NAT)服务器 来中介数据。TURN 服务器作为云端的中继,所有音视频流都会经过它进行转发,因此对带宽和服务器的开销都很高。TURN 通常与 STUN 部署在同一个服务上,合称 STUN/TURN 服务器。

ICE 候选的收集与交换

客户端调用 RTCPeerConnection 时配置 iceServers,浏览器会自动聚合所有可能的地址,生成三类 ICE 候选:

  • Host 候选:本机的物理网卡 IP 地址。
  • Srflx 候选(Server Reflexive):通过 STUN 获取的公网地址。
  • Relay 候选:TURN 服务器分配的转发地址。

候选收集是异步、逐步进行的。每当发现一个新的 ICE 候选,就会触发 onicecandidate 事件,开发者必须将该候选通过信令发送给对方;对方收到后调用 addIceCandidate() 将其加入对等连接。

// 本地候选收集
pc.onicecandidate = (event) => {
  if (event.candidate) {
    signaling.send({ type: 'candidate', candidate: event.candidate });
  }
};

// 收到远端候选
signaling.on('candidate', async (data) => {
  try {
    await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
  } catch (e) {
    console.error('添加 ICE 候选失败', e);
  }
});

RTCPeerConnection 内部维护一个状态机,不断测试所有可能的候选对(本地候选 + 远端候选)的连通性。一旦发现能够连通的对,就会建立连接,媒体流开始传输。

配置 TURN 和 STUN 服务器

在创建 RTCPeerConnection 时,通过 configuration 对象传入 ICE 服务器列表。你可以使用 Google 提供的免费 STUN 服务器进行测试,但生产环境必须使用自己的 STUN/TURN 服务(如 coturn、Twilio、Xirsys)。

const configuration = {
  iceServers: [
    {
      urls: 'stun:stun.l.google.com:19302'   // 仅用于开发的免费 STUN
    },
    {
      urls: 'turn:your-turn-server.com:3478',
      username: 'user',
      credential: 'pass'
    }
  ]
};
const pc = new RTCPeerConnection(configuration);

必须提供 TURN 服务器,否则在较复杂的网络环境中(如移动端、企业网络)通话成功率将大幅降低。


动手实践:从零构建一个视频通话页面

综合上述原理,我们使用纯 HTML/JavaScript 构建一个可运行的双人视频通话 Demo。你需要先启动前面的信令服务器,然后在两个浏览器标签页中打开此页面。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>WebRTC 视频通话</title>
</head>
<body>
  <video id="localVideo" autoplay playsinline muted></video>
  <video id="remoteVideo" autoplay playsinline></video>
  <button id="startBtn">开始通话</button>

  <script>
    const localVideo = document.getElementById('localVideo');
    const remoteVideo = document.getElementById('remoteVideo');
    const startBtn = document.getElementById('startBtn');

    const signaling = new WebSocket('ws://localhost:8080');
    const roomId = prompt('输入房间号', 'demo');
    let pc;

    const configuration = {
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
    };

    signaling.onmessage = async (e) => {
      const data = JSON.parse(e.data);
      if (data.type === 'ready') {
        // 作为发起方创建 offer
        await createOffer();
      } else if (data.type === 'offer') {
        await handleOffer(data);
      } else if (data.type === 'answer') {
        await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
      } else if (data.type === 'candidate') {
        await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
      }
    };

    async function start() {
      // 获取本地媒体
      const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
      localVideo.srcObject = stream;

      // 创建对等连接
      pc = new RTCPeerConnection(configuration);

      // 将本地流添加到连接中
      stream.getTracks().forEach(track => pc.addTrack(track, stream));

      // 显示远端流
      pc.ontrack = (event) => {
        remoteVideo.srcObject = event.streams[0];
      };

      // 处理 ICE 候选
      pc.onicecandidate = (event) => {
        if (event.candidate) {
          signaling.send(JSON.stringify({ type: 'candidate', candidate: event.candidate }));
        }
      };

      // 加入房间
      signaling.send(JSON.stringify({ type: 'join', room: roomId }));
      startBtn.disabled = true;
    }

    async function createOffer() {
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      signaling.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription }));
    }

    async function handleOffer(offer) {
      await pc.setRemoteDescription(new RTCSessionDescription(offer.sdp));
      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);
      signaling.send(JSON.stringify({ type: 'answer', sdp: pc.localDescription }));
    }

    startBtn.onclick = start;
  </script>
</body>
</html>

操作步骤

  1. 运行信令服务器:保存前面的 Node.js 服务器代码为 server.js,执行 node server.js
  2. 打开页面:在浏览器中打开上述 HTML,输入相同的房间号,进入等待状态。
  3. 分角色测试:在第一个标签页点击“开始通话”,它会加入房间。打开第二个标签页,输入相同房间号并点击按钮,两者就会自动交换 SDP 和 ICE 候选,并显示本地及远端画面。

常见问题与最佳实践

1. 为什么画面黑屏或没有声音?

  • 检查浏览器控制台,确认 getUserMedia 权限是否被允许。
  • 确保两个页面使用的房间号相同,且信令服务器正常连接。
  • 查看 ICE 连接状态:pc.oniceconnectionstatechange 监听,若状态变为 failed,通常需要添加 TURN 服务器。

2. 生产环境需要注意什么?

  • 必须部署信令服务器:使用可靠的消息队列和状态管理。
  • 使用完整的 TURN 服务器,如 coturn,并配置长期凭证或 REST API 认证。
  • 强制 HTTPSgetUserMedia 和许多 WebRTC 功能只能在安全上下文(HTTPS 或 localhost)下运行。
  • 处理重连和异常:监听 iceconnectionstatechangeconnectionstatechange 事件,实现断线重连逻辑。

3. 如何调试 WebRTC?

  • 在 Chrome 地址栏输入 chrome://webrtc-internals 查看详细的连接状态、SDP 交换记录、ICE 候选列表和统计信息。

总结

你已经从零理解了 WebRTC 视频通话背后的三大支撑:信令负责会话控制与消息交换,SDP 媒体协商让两端就编解码和媒体格式达成一致,STUN/TURN 与 ICE 则确保数据包能够穿透复杂的网络。通过本教程提供的示例,你可以立即在本地跑通一个点对点视频聊天应用,并以此为起点,进一步扩展多人会议、屏幕共享、云端录制等功能。