WebRTC 一对多视频广播实战:WebRTC-Experiment 的 Meeting 库架构、RTP 端口模型与信令接入指南

【免费下载链接】WebRTC-Experiment WebRTC, WebRTC and WebRTC. Everything here is all about WebRTC!! 【免费下载链接】WebRTC-Experiment 项目地址: https://gitcode.com/gh_mirrors/we/WebRTC-Experiment

本篇技术指南以 WebRTC-Experiment 仓库中的 one-to-many-video-broadcasting 实验为核心,深入讲解如何用约 450 行源码的 Meeting 库实现「一个主播、多个观众」的视频广播场景:包括一对多广播与单向广播的 RTP 端口模型差异、Meeting 公开 API 的完整用法(建会、入会、自定义 userid、信令通道替换、房间发现与离场处理),以及 Socket.io / Firebase 两种信令后端的接入原理。读完本文,你将能够基于仓库源码独立搭建自己的 WebRTC 一对多视频广播页面,并理解其底层多 PeerConnection 的建立与信令流转机制。

一对多广播的 RTP 端口模型:为什么 10 个用户会打开 40 个端口

WebRTC 本身不支持三方握手的组播语义,一对多广播在实现上必须为每个远端参与者建立一条独立的 PeerConnection(meeting.js 中通过 peers[message.userid] 哈希表为每个用户维护独立的 Offer/Answer 对象即可印证这一点)。因此,端口与带宽的开销会随着用户数线性增长。

原文档给出了一个非常直观的量化结论:如果 10 个用户加入你的广播房间,浏览器将打开 40 个 RTP 端口,其构成如下:

  1. 10 个 RTP 端口用于**出站(outgoing)**音频流;
  2. 10 个 RTP 端口用于出站视频流;
  3. 10 个 RTP 端口用于**入站(incoming)**音频流;
  4. 10 个 RTP 端口用于入站视频流。

即:一对多广播中,主播与每一位参与者之间都是双向的媒体连接——既把自己的音视频上行发给对方,也接收对方(同为广播场景)的音视频下行。这是它区别于单向广播的核心特征。

与单向广播(one-way broadcasting)的差异

以同样 10 个用户的会话为例,单向广播的端口开销是:

  • 主播端:10 个 RTP 端口用于出站音频 + 10 个 RTP 端口用于出站视频,合计 20 个出站 RTP 端口;
  • 每位参与者端:只有 2 个入站 RTP 端口(一路入站音频 + 一路入站视频),没有出站端口。

对照关系可以总结为下表:

角色单向广播(10 用户)一对多广播(10 用户)
主播端20 个出站 RTP 端口40 个 RTP 端口(出站+入站各 20)
每位参与者2 个入站 RTP 端口40 个 RTP 端口(出站+入站各 20)

仓库中的 broadcast 实验(见 broadcast/README.md)即单向广播的对应实现,其端口模型只有"主播 20 出站 / 参与者 2 入站";而本文主题的 one-to-many-video-broadcasting 之所以称为"one-to-many",正是因为它在每个参与者侧也保留了完整的双向媒体链路,代价是更高的资源消耗。

端口与带宽的实际含义

端口开销并不仅仅是数字游戏。仓库的 docs/RTP-usage.html 对 RTP 用量做了进一步说明(该文档标注写于 2013 年,且注明 Chrome 通常会将媒体端口捆绑/复用到一个 UDP 端口上,以下为**未捆绑(non-bundled)**媒体连接下的理论模型):

  • 一对一音视频场景下,每个 peer 最多打开 4 个 RTP 端口(出站/入站视频、出站/入站音频各一),按每个端口约 1 MB 带宽估算,每个 peer 约占用 4 MB;
  • 一对多/多对多场景下,RTP 端口数 ≈ 参与的 PeerConnection 数量 × 4;
  • 10 人视频会议(many-to-many)时,每人约 40 MB 带宽;
  • 可通过在 SDP 中注入 b=AS 属性控制单流带宽,例如:
// 移除已有带宽行
sdp = sdp.replace(/b=AS([^\r\n]+\r\n)/g, '');

// 音频带宽 50 kbps
sdp = sdp.replace(/a=mid:audio\r\n/g, 'a=mid:audio\r\nb=AS:50\r\n');

// 视频带宽 256 kbps
sdp = sdp.replace(/a=mid:video\r\n/g, 'a=mid:video\r\nb=AS:256\r\n');

// 数据带宽 1638400 kbps
sdp = sdp.replace(/a=mid:data\r\n/g, 'a=mid:data\r\nb=AS:1638400\r\n');

因此在实际部署一对多广播时,建议结合 b=AS 对音视频码率做上限约束,避免多路 PeerConnection 叠加导致主播端 CPU 与上行带宽被迅速耗尽。

快速上手:两步接入 Meeting 库

第一步:引入库文件

在页面 <head> 中引入 meeting.js(仓库内对应文件为 one-to-many-video-broadcasting/meeting.js):

<script src="https://www.webrtc-experiment.com/one-to-many-video-broadcasting/meeting.js"></script>

注意:meeting.js 是面向 2013 年 WebRTC 旧 API(navigator.getUserMedia、RTCPeerConnection、createObjectURL 等)编写的,源码开头即做了浏览器前缀适配:

var RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
var RTCSessionDescription = window.RTCSessionDescription || window.mozRTCSessionDescription;
var RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate;
navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia;
window.URL = window.URL || window.webkitURL;

var isFirefox = !!navigator.mozGetUserMedia;
var isChrome = !!navigator.webkitGetUserMedia;

在当今主流浏览器上,这些 API 已被 navigator.mediaDevices.getUserMedia 和标准 RTCPeerConnection 取代,若直接照搬源码需要自行做一次现代化适配(例如用 video.srcObject = stream 替代 createObjectURL 方案,meeting.js 在 Firefox 分支上使用的 mozSrcObject 正是 srcObject 的前身)。

第二步:创建 Meeting 实例并接入页面

var meeting = new Meeting('meeting-unique-id');

// 本地或远端流到达时回调
meeting.onaddstream = function(e) {
    // e.type == 'local'  ---- 本地媒体流
    // e.type == 'remote' --- 远端媒体流
    document.body.appendChild(e.video);
};

// 检查预先创建的会议室(自动加入或搜索已存在会话)
meeting.check();

document.getElementById('setup-new-meeting').onclick = function() {
    meeting.setup('meeting room name');
};

核心调用链在源码中清晰可见(meeting.js 的 Meeting 构造函数):

  • meeting.setup(roomid):先通过 captureUserMedia 采集本地音视频(约束固定为 {audio: true, video: true}),成功后初始化 Signaler 并调用 signaler.broadcast({roomid: roomid || self.channel}),以固定 3 秒间隔周期性地广播 {roomid, broadcasting: true} 心跳,让新用户能发现该房间;
  • meeting.check():等价于 initSignaler(),启动信令监听,用于探测已存在的会议房间;
  • meeting.meet(room):同样先采集媒体流,然后发送 participationRequest 加入指定房间。

自定义 userid:让每个参与者拥有可识别的身份

默认情况下,Meeting 会为每个用户生成一个随机 token(源码中 getToken() 通过 (Math.random() * new Date().getTime()).toString(36).replace(/\./g, '') 生成)。若希望参与者拥有可读、稳定的业务身份(例如聊天室昵称),直接赋值即可:

meeting.userid = 'username';

该值会被 Signaler 用作信令中的 userid 字段,并在收到他人消息时用来过滤回环消息(源码中 if (message.userid != userid) 的判断),同时 onuserleft 回调也是基于该 userid 做视频元素的定位与移除。

自定义信令通道:告别固定后端,任意信令皆可接入

这是本实验最有价值的扩展点。Meeting 的信令层完全可插拔:如果不做任何覆盖,库默认使用 Firebase;一旦覆盖了 openSignalingChannel,则完全由你指定的通道接管消息收发。原文档列举了可用的信令通道类型:

  1. SIP-over-WebSockets;
  2. WebSocket over Node.js/PHP 等;
  3. Socket.io over Node.js 等;
  4. XMPP 等;
  5. XHR-POST-ing。

使用 Socket.io 覆盖默认信令

meeting.openSignalingChannel = function(callback) {
    return io.connect().on('message', callback);
};

对应的 Socket.io over Node.js 服务端代码(完整可运行版本见 socketio-over-nodejs/server.js,核心消息转发逻辑如下):

io.sockets.on('connection', function (socket) {
    socket.on('message', function (data) {
        socket.broadcast.emit('message', data);
    });
});

从源码看,openSignalingChannel 覆盖后的信令协议约定是:

  • 收到消息后先 JSON.parse;
  • 若 message.userid != userid 才继续处理,否则丢弃(避免处理自己发出的消息);
  • 若 message.leaving 为真,触发 root.onuserleft(message.userid);
  • 否则交给 signaler.onmessage(message) 进入 SDP/ICE/参与请求的分发流程。

返回的 socket 对象需要具备 send(JSON.stringify(data)) 能力,发送时自动附带 data.userid = userid。

使用 Firebase 做零后端信令

如果不想自己维护信令服务器,设置一行即可:

// "chat" 是你的 firebase 应用 id
meeting.firebase = 'chat';

默认实现(源码 Signaler 构造函数中)会创建 https://<firebase 或 'signaling'>.firebaseIO.com/<channel> 节点:

  • 监听 child_added 事件收取消息;
  • 收到后立即 snap.ref().remove() 删除数据,模拟 Socket.io 的即时消费语义;
  • 发送时通过 socket.push(data) 写入 Firebase;
  • 广播者退出时通过 socket.onDisconnect().remove() 清理服务端残留数据。

注意使用 Firebase 方案前必须引入 Firebase 客户端库,否则源码会直接抛出错误:You must link <https://cdn.firebase.com/v0/firebase.js> file.(这是 2013 年版本的 Firebase API,现已演进为 Firebase Realtime Database / Firestore SDK,接入时需要做版本迁移)。

参考:openSignalingChannel 的通用覆盖模板

仓库根目录的 Signaling.md 给出了适用于任意信令的通用覆盖范式——用 onMessageCallbacks 哈希表按 channel 注册回调,再返回带 send 方法的对象:

var onMessageCallbacks = {};
var socketio = io.connect('http://localhost:8888/');

socketio.on('message', function(data) {
    if (data.sender == connection.userid) return;
    if (onMessageCallbacks[data.channel]) {
        onMessageCallbacksdata.channel;
    }
});

connection.openSignalingChannel = function (config) {
    var channel = config.channel || this.channel;
    onMessageCallbacks[channel] = config.onmessage;

    if (config.onopen) setTimeout(config.onopen, 1000);
    return {
        send: function (message) {
            socketio.emit('message', {
                sender: connection.userid,
                channel: channel,
                message: message
            });
        },
        channel: channel
    };
};

参考:仓库内真实页面的信令接线

仓库自带的演示页面 one-to-many-video-broadcasting/index.html 展示了真实环境中的完整接线:它以 URL hash 作为会议通道,连接远程 Socket.io 信令服务器后通过 socket.on('message', callback) 覆盖 openSignalingChannel,同时设置 meeting.firebase = 'rtcweb' 作为备用,再挂载 onaddstream / onuserleft / setup 逻辑。这可以当作一个"信令通道 + 页面 UI"的参考模板。

手动加入房间:onmeeting 与 meet 的使用

默认行为下,Signaler.onmessage 在收到 {roomid, broadcasting} 广播消息时会自动触发默认的 onmeeting 回调(源码默认实现只允许首次加入:if (self.detectedRoom) return; self.detectedRoom = true; self.meet(room);)。若想改为手动选择加入,覆盖 onmeeting 并把房间信息渲染成可点击列表即可:

meeting.onmeeting = function(room) {
    var li = document.createElement('li');
    li.setAttribute('user-id', room.userid);
    li.setAttribute('room-id', room.roomid);
    li.onclick = function() {
        var room = {
            userid: this.getAttribute('user-id'),
            roomid: this.getAttribute('room-id')
        };
        meeting.meet(room);
    };
};

说明:

  • onmeeting 对每个新发现的会议房间调用一次;
  • 房间对象包含 userid(广播者身份)与 roomid(房间名)两个关键字段,二者正是 meeting.meet() 所需的 {to: room.userid, roomid: room.roomid} 参数来源;
  • meet 方法会采集本地媒体流并向目标广播者发送 participationRequest,广播者收到后创建 Offer 回传,从而完成连接。

从源码角度,一次完整的加入流程是:参与者 join() 发送 {participationRequest: true, to: userid} → 广播者 onmessage 命中参与请求后 Offer.createOffer 并 onsdp 回传 Offer → 参与者 onsdp 用 Answer.createAnswer 应答 → 双方通过 onice 交换 ICE candidate,期间未就绪的 candidate 会被暂存在 candidates 哈希表中,待 peer 建立后统一补发(meeting.js 中 onice 的分支逻辑)。

处理用户离场:onuserleft

参与者退出时,unloadHandler(注册于 window.onbeforeunload、F5 按键以及非 _blank 链接的点击)会向信令通道发送 {leaving: true} 消息;对方收到后触发 onuserleft。典型用法是按 userid 移除对应视频元素:

// 如果某人离开,直接移除其视频
meeting.onuserleft = function(userid) {
    var video = document.getElementById(userid);
    if (video) video.parentNode.removeChild(video);
};

该回调能正常工作的前提是:远端视频元素以 userid 作为 id(源码 options.onaddstream 中 video.id = _userid),因此只要 onaddstream 时把视频挂到 DOM,onuserleft 就能精确清理对应节点。

onaddstream 回调的完整参数说明

onaddstream 对本地流与远端流都会触发,回调参数 e 包含四个字段:

字段类型说明
videoHTMLVideoElement已绑定媒体流的视频元素(可直接 append 到页面)
streamMediaStream对应的媒体流对象
useridString该流来自哪个用户
typeString流类型,'local' 表示本地流,'remote' 表示远端流
meeting.onaddstream = function(e) {
    // e.type == 'local'  ---- 本地媒体流
    // e.type == 'remote' --- 远端媒体流
    document.body.appendChild(e.video);
};

源码细节补充:

  • 本地流(captureUserMedia 内):视频元素 id = 'self',type = 'local',userid = 'self';
  • 远端流(options.onaddstream 内):video.id = _userid,type = 'remote',并且只有在检测到远端流真正开始流动后才触发 onaddstream(源码通过轮询 video.readyState / paused / currentTime,移动端则直接放行),避免空流或未就绪的 video 元素被过早挂载;
  • 两个分支都设置了 autoplay 与 controls,确保视频元素创建即可播放。

浏览器支持与运行环境说明

原文档给出的浏览器支持范围如下:

浏览器支持情况
FirefoxStable / Aurora / Nightly
Google ChromeStable / Canary / Beta / Dev
AndroidChrome Beta

需要强调的运行前提与限制:

  • 本实验写于 2013 年,源码依赖 navigator.getUserMedia(带前缀的 mozGetUserMedia / webkitGetUserMedia)、createObjectURL、RTCPeerConnection 等旧 API,在现代浏览器上直接运行前需要按上文所述做 API 现代化适配(如改用 navigator.mediaDevices.getUserMedia 与 video.srcObject);
  • 演示页 one-to-many-video-broadcasting/index.html 还依赖外部 Socket.io 服务器地址与 Firebase(rtcweb 应用 id),这些外部服务已无法保证长期可用,自行部署时应替换为你自己的信令后端;
  • 采集摄像头/麦克风必须运行在 HTTPS 或 localhost 环境下(浏览器安全策略),仓库根目录的 server.js 可配合本目录文件启动本地服务;
  • 一对多广播的资源开销随参与者数量线性增长,docs/RTP-usage.html 中 40 MB(10 用户、未捆绑端口)的带宽模型是极端上限,实际部署建议使用 b=AS 限流并使用支持端口捆绑(BUNDLE)的现代浏览器大幅降低端口占用。

总结

one-to-many-video-broadcasting 用极简的 API 展示了 WebRTC 一对多广播的完整闭环:new Meeting(channel) 建会、setup / meet / check 控制房间生命周期、openSignalingChannel 与 firebase 双通道信令、onaddstream / onmeeting / onuserleft 三个回调覆盖流、发现与离场三类事件。其底层 Signaler + Offer / Answer 对象分工明确——Signaler 负责信令分发与 peer 记账,Offer/Answer 封装了 createOffer / createAnswer / setRemoteDescription / addIceCandidate 等完整 SDP 与 ICE 流程——这种"公开 API 极简、内部职责清晰"的设计,加上端口模型与信令协议在 README 中的详实交代,使它至今仍是学习 WebRTC 一对多拓扑与自建信令层的一份高质量参考实现。若需继续深入,可对照阅读仓库的 Signaling.md(信令覆盖通用范式)、socketio-over-nodejs/server.js(信令服务器完整实现)与 broadcast/README.md(单向广播对照模型)。

【免费下载链接】WebRTC-Experiment WebRTC, WebRTC and WebRTC. Everything here is all about WebRTC!! 【免费下载链接】WebRTC-Experiment 项目地址: https://gitcode.com/gh_mirrors/we/WebRTC-Experiment

Logo

邀请您加入社区

更多推荐