WebRTC 一对多视频广播实战:WebRTC-Experiment 的 Meeting 库架构、RTP 端口模型与信令接入指南
WebRTC 一对多视频广播实战:WebRTC-Experiment 的 Meeting 库架构、RTP 端口模型与信令接入指南
本篇技术指南以 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 端口,其构成如下:
- 10 个 RTP 端口用于**出站(outgoing)**音频流;
- 10 个 RTP 端口用于出站视频流;
- 10 个 RTP 端口用于**入站(incoming)**音频流;
- 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,则完全由你指定的通道接管消息收发。原文档列举了可用的信令通道类型:
- SIP-over-WebSockets;
- WebSocket over Node.js/PHP 等;
- Socket.io over Node.js 等;
- XMPP 等;
- 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 包含四个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
video | HTMLVideoElement | 已绑定媒体流的视频元素(可直接 append 到页面) |
stream | MediaStream | 对应的媒体流对象 |
userid | String | 该流来自哪个用户 |
type | String | 流类型,'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,确保视频元素创建即可播放。
浏览器支持与运行环境说明
原文档给出的浏览器支持范围如下:
| 浏览器 | 支持情况 |
|---|---|
| Firefox | Stable / Aurora / Nightly |
| Google Chrome | Stable / Canary / Beta / Dev |
| Android | Chrome 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(单向广播对照模型)。
更多推荐
所有评论(0)