WebRTC 点对点直播
摘自: villainhr
WebRTC 全称为: Web Real-Time Communication
。它是为了解决 Web 端无法捕获音视频的能力,并且提供了 peer-to-peer(就是浏览器间)的视频交互。实际上,细分看来,它包含三个部分:
-
MediaStream:捕获音视频流
-
RTCPeerConnection:传输音视频流(一般用在 peer-to-peer 的场景)
-
RTCDataChannel: 用来上传音视频二进制数据(一般用到流的上传)
但通常,peer-to-peer 的场景实际上应用不大。对比与去年火起来的 直播
业务,这应该才是 WebRTC 常常应用到的地方。那么对应于 Web 直播来说,我们通常需要两个端:
-
主播端:录制并上传视频
-
观众端:下载并观看视频
这里,我就不谈观众端了,后面另写一篇文章介绍(因为,这是在是太多了)。这里,主要谈一下会用到 WebRTC 的主播端。
简化一下,主播端应用技术简单可以分为:录制视频,上传视频。大家先记住这两个目标,后面我们会通过 WebRTC 来实现这两个目标。
WebRTC 基本了解
WebRTC 主要由两个组织来制定。
-
Web Real-Time Communications (WEBRTC) W3C 组织:定义浏览器 API
-
Real-Time Communication in Web-browsers (RTCWEB) IETF 标准组织:定义其所需的协议,数据,安全性等手段。
当然,我们初级目标是先关心基本浏览器定义的 API 是啥?以及怎么使用?
然后,后期目标是学习期内部的相关协议,数据格式等。这样循序渐进来,比较适合我们的学习。
WebRTC 对于音视频的处理,主要是交给 Audio/Vidoe Engineering 处理的。处理过程为:
-
音频:通过物理设备进行捕获。然后开始进行
降噪
,消除回音
,抖动/丢包隐藏
,编码
。 -
视频:通过物理设备进行捕获。然后开始进行
图像增强
,同步
,抖动/丢包隐藏
,编码
。
最后通过 mediaStream Object 暴露给上层 API 使用。也就是说 mediaStream 是连接 WebRTC API 和底层物理流的中间层。所以,为了下面更好的理解,这里我们先对 mediaStream 做一些简单的介绍。
MediaStream
MS(MediaStream)是作为一个辅助对象存在的。它承载了音视频流的筛选,录制权限的获取等。MS 由两部分构成: MediaStreamTrack 和 MediaStream。
-
MediaStreamTrack 代表一种单类型数据流。如果你用过
会声会影
的话,应该对轨道
这个词不陌生。通俗来讲,你可以认为两者就是等价的。 -
MediaStream 是一个完整的音视频流。它可以包含 >=0 个
MediaStreamTrack
。它主要的作用就是确保几个轨道是同时播放的。例如,声音需要和视频画面同步。
这里,我们不说太深,讲讲基本的 MediaStream
对象即可。通常,我们使用实例化一个 MS 对象,就可以得到一个对象。
// 里面还需要传递 track,或者其他 stream 作为参数。
// 这里只为演示方便
let ms = new MediaStream();
我们可以看一下 ms
上面带有哪些对象属性:
-
active[boolean]:表示当前 ms 是否是活跃状态(就是可播放状态)。
-
id[String]: 对当前的 ms 进行唯一标识。例如:"f61641ec-ee78-4317-9415-58acac066a4d"
-
onactive: 当 active 为 true 时,触发该事件
-
onaddtrack: 当有新的 track 添加时,触发该事件
-
oninactive: 当 active 为 false 时,触发该事件
-
onremovetrack: 当有 track 移除时,触发该事件
它的原型链上还挂在了其他方法,我挑几个重要的说一下。
-
clone(): 对当前的 ms 流克隆一份。该方法通常用于对该 ms 流有操作时,常常会用到。
前面说了,MS 还可以其他筛选的作用,那么它是如何做到的呢?
在 MS 中,还有一个重要的概念叫做: Constraints
。它是用来规范当前采集的数据是否符合需要。因为,我们采集视频时,不同的设备有不同的参数设置。常用的为:
{
"audio": true, // 是否捕获音频
"video": { // 视频相关设置
"width": {
"min": "381", // 当前视频的最小宽度
"max": "640"
},
"height": {
"min": "200", // 最小高度
"max": "480"
},
"frameRate": {
"min": "28", // 最小帧率
"max": "10"
}
}
}
那我怎么知道我的设备支持的哪些属性的调优呢?
这里,可以直接使用 navigator.mediaDevices.getSupportedConstraints()
来获取可以调优的相关属性。不过,这一般是对 video 进行设置。了解了 MS 之后,我们就要开始真正接触 WebRTC 的相关 API。我们先来看一下 WebRTC 基本API。
WebRTC 的常用 API 如下,不过由于浏览器的缘故,需要加上对应的 prefix:
W3C Standard Chrome Firefox
--------------------------------------------------------------
getUserMedia webkitGetUserMedia mozGetUserMedia
RTCPeerConnection webkitRTCPeerConnection RTCPeerConnection
RTCSessionDescription RTCSessionDescription RTCSessionDescription
RTCIceCandidate RTCIceCandidate RTCIceCandidate
不过,你可以简单的使用下列的方法来解决。不过嫌麻烦的可以使用 adapter.js 来弥补
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia
这里,我们循序渐进的来学习。如果想进行视频的相关交互,首先应该是捕获音视频。
捕获音视频
在 WebRTC 中捕获音视频,只需要使用到一个 API,即, getUserMedia()
。代码其实很简单:
navigator.getUserMedia = navigator.getUserMedia ||
navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
var constraints = { // 设置捕获的音视频设置
audio: false,
video: true
};
var video = document.querySelector('video');
function successCallback(stream) {
window.stream = stream; // 这就是上面提到的 mediaStream 实例
if (window.URL) {
video.src = window.URL.createObjectURL(stream); // 用来创建 video 可以播放的 src
} else {
video.src = stream;
}
}
function errorCallback(error) {
console.log('navigator.getUserMedia error: ', error);
}
// 这是 getUserMedia 的基本格式
navigator.getUserMedia(constraints, successCallback, errorCallback);
详细 demo 可以参考: WebRTC。不过,上面的写法比较古老,如果使用 Promise 来的话,getUserMedia 可以写为:
navigator.mediaDevices.getUserMedia(constraints).
then(successCallback).catch(errorCallback);
上面的注释大概已经说清楚基本的内容。需要提醒的是,你在捕获视频的同时,一定要清楚自己需要捕获的相关参数。
有了自己的视频之后,那如何与其他人共享这个视频呢?(可以理解为直播的方式)
在 WebRTC 中,提供了 RTCPeerConnection
的方式,来帮助我们快速建立起连接。不过,这仅仅只是建立起 peer-to-peer 的中间一环。这里包含了一些复杂的过程和额外的协议,我们一步一步的来看下。
WebRTC 基本内容
WebRTC 利用的是 UDP 方式来进行传输视频包。这样做的好处是延迟性低,不用过度关注包的顺序。不过,UDP 仅仅只是作为一个传输层协议而已。WebRTC 还需要解决很多问题
-
遍历 NATs 层,找到指定的 peer
-
双方进行基本信息的协商以便双方都能正常播放视频
-
在传输时,还需要保证信息安全性
整个架构如下:
上面那些协议,例如,ICE/STUN/TURN 等,我们后面会慢慢讲解。先来看一下,两者是如何进行信息协商的,通常这一阶段,我们叫做 signaling
。
signaling 任务
signaling 实际上是一个协商过程。因为,两端进不进行 WebRTC 视频交流之间,需要知道一些基本信息。
-
打开/关闭连接的指令
-
视频信息,比如解码器,解码器的设置,带宽,以及视频的格式等。
-
关键数据,相当于 HTTPS 中的
master key
用来确保安全连接。 -
网关信息,比如双方的 IP,port
不过,signaling 这个过程并不是写死的,即,不管你用哪种协议,只要能确保安全即可。为什么呢?因为,不同的应用有着其本身最适合的协商方法。比如:
-
单网关协议(SIP/Jingle/ISUP)适用于呼叫机制(VoIP,voice over IP)。
-
自定义协议
-
多网关协议
我们自己也可以模拟出一个 signaling 通道。它的原理就是将信息进行传输而已,通常为了方便,我们可以直接使用 socket.io 来建立 room
提供信息交流的通道。
PeerConnection 的建立
假定,我们现在已经通过 socket.io
建立起了一个信息交流的通道。那么我们接下来就可以进入 RTCPeerConnection
一节,进行连接的建立。我们首先应该利用 signaling
进行基本信息的交换。那这些信息有哪些呢?
WebRTC 已经在底层帮我们做了这些事情-- Session Description Protocol (SDP)
。我们利用 signaling
传递相关的 SDP,来确保双方都能正确匹配,底层引擎会自动解析 SDP (是 JSEP 帮的忙),而不需要我们手动进行解析,突然感觉世界好美妙。。。我们来看一下怎么传递。
// 利用已经创建好的通道。
var signalingChannel = new SignalingChannel();
// 正式进入 RTC connection。这相当于创建了一个 peer 端。
var pc = new RTCPeerConnection({});
navigator.getUserMedia({ "audio": true })
.then(gotStream).catch(logError);
function gotStream(stream) {
pc.addStream(stream);
// 通过 createOffer 来生成本地的 SDP
pc.createOffer(function(offer) {
pc.setLocalDescription(offer);
signalingChannel.send(offer.sdp);
});
}
function logError() { ... }
那 SDP 的具体格式是啥呢?
看一下格式就 ok,这不用过多了解:
v=0
o=- 1029325693179593971 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE audio video
a=msid-semantic: WMS
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:nHtT
a=ice-pwd:cuwglAha5fBmGljFXWntH1VN
a=fingerprint:sha-256 24:63:EB:DD:18:1B:BB:5E:B3:E8:C5:D7:92:F7:0B:44:EC:22:96:63:64:76:1A:56:64:DE:6B:CE:85:C6:64:78
a=setup:active
a=mid:audio
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=inactive
a=rtcp-mux
...
上面的过程,就是 peer-to-peer 的协商流程。这里有两个基本的概念, offer
, answer
。
-
offer: 主播端向其他用户提供其本省视频直播的基本信息
-
answer: 用户端反馈给主播端,检查能否正常播放
具体过程为:
-
主播端通过 createOffer 生成 SDP 描述
-
主播通过 setLocalDescription,设置本地的描述信息
-
主播将 offer SDP 发送给用户
-
用户通过 setRemoteDescription,设置远端的描述信息
-
用户通过 createAnswer 创建出自己的 SDP 描述
-
用户通过 setLocalDescription,设置本地的描述信息
-
用户将 anwser SDP 发送给主播
-
主播通过 setRemoteDescription,设置远端的描述信息。
不过,上面只是简单确立了两端的连接信息而已,还没有涉及到视频信息的传输,也就是说 UDP 传输。UDP 传输本来就是一个非常让人蛋疼的活,如果是 client-server 的模型话还好,直接传就可以了,但这偏偏是 peer-to-peer 的模型。想想,你现在是要把你的电脑当做一个服务器使用,中间还需要经历如果突破防火墙,如果找到端口,如何跨网段进行?所以,这里我们就需要额外的协议,即,STUN/TURN/ICE ,来帮助我们完成这样的传输任务。
NAT/STUN/TURN/ICE
在 UDP 传输中,我们不可避免的会遇见 NAT
(Network address translator)服务器。即,它主要是将其它网段的消息传递给它负责网段内的机器。不过,我们的 UDP 包在传递时,一般只会带上 NAT 的 host
。如果,此时你没有目标机器的 entry
的话,那么该次 UDP 包将不会被转发成功。不过,如果你是 client-server 的形式的话,就不会遇见这样的问题。但,这里我们是 peer-to-peer 的方式进行传输,无法避免的会遇见这样的问题。
为了解决这样的问题,我们就需要建立 end-to-end 的连接。那办法是什么呢?很简单,就是在中间设立一个 server
用来保留目标机器在 NAT 中的 entry
。常用协议有 STUN, TURN 和 ICE
。那他们有什么区别吗?
-
STUN:作为最基本的
NAT traversal
服务器,保留指定机器的entry
-
TURN:当 STUN 出错的时候,作为重试服务器的存在。
-
ICE:在众多 STUN + TURN 服务器中,选择最有效的传递通道。
所以,上面三者通常是结合在一起使用的。它们在 PeerConnection 中的角色如下图:
如果,涉及到 ICE 的话,我们在实例化 Peer Connection 时,还需要预先设置好指定的 STUN/TRUN 服务器。
var ice = {"iceServers": [
{"url": "stun:stun.l.google.com:19302"},
// TURN 一般需要自己去定义
{
'url': 'turn:192.158.29.39:3478?transport=udp',
'credential': 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
'username': '28224511:1379330808'
},
{
'url': 'turn:192.158.29.39:3478?transport=tcp',
'credential': 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
'username': '28224511:1379330808'
}
]};
var signalingChannel = new SignalingChannel();
var pc = new RTCPeerConnection(ice); // 在实例化 Peer Connection 时完成。
navigator.getUserMedia({ "audio": true }, gotStream, logError);
function gotStream(stream) {
pc.addStream(stream); // 将流添加到 connection 中。
pc.createOffer(function(offer) {
pc.setLocalDescription(offer);
});
}
// 通过 ICE,监听是否有用户连接
pc.onicecandidate = function(evt) {
if (evt.target.iceGatheringState == "complete") {
local.createOffer(function(offer) {
console.log("Offer with ICE candidates: " + offer.sdp);
signalingChannel.send(offer.sdp);
});
}
}
...
在 ICE 处理中,里面还分为 iceGatheringState
和 iceConnectionState
。在代码中反应的就是:
pc.onicecandidate = function(e) {
evt.target.iceGatheringState;
pc.iceGatheringState
};
pc.oniceconnectionstatechange = function(e) {
evt.target.iceConnectionState;
pc.iceConnectionState;
};
当然,起主要作用的还是 onicecandidate
。
-
iceGatheringState: 用来检测本地 candidate 的状态。其有以下三种状态:
-
new: 该 candidate 刚刚被创建
-
gathering: ICE 正在收集本地的 candidate
-
complete: ICE 完成本地 candidate 的收集
-
-
iceConnectionState: 用来检测远端 candidate 的状态。远端的状态比较复杂,一共有 7 种: new/checking/connected/completed/failed/disconnected/closed
不过,这里为了更好的讲解 WebRTC 建立连接的基本过程。我们使用单页的连接来模拟一下。现在假设,有两个用户,一个是 pc1,一个是 pc2。pc1 捕获视频,然后,pc2 建立与 pc1 的连接,完成伪直播的效果。直接看代码吧:
var servers = null;
// Add pc1 to global scope so it's accessible from the browser console
window.pc1 = pc1 = new RTCPeerConnection(servers);
// 监听是否有新的 candidate 加入
pc1.onicecandidate = function(e) {
onIceCandidate(pc1, e);
};
// Add pc2 to global scope so it's accessible from the browser console
window.pc2 = pc2 = new RTCPeerConnection(servers);
pc2.onicecandidate = function(e) {
onIceCandidate(pc2, e);
};
pc1.oniceconnectionstatechange = function(e) {
onIceStateChange(pc1, e);
};
pc2.oniceconnectionstatechange = function(e) {
onIceStateChange(pc2, e);
};
// 一旦 candidate 添加成功,则将 stream 播放
pc2.onaddstream = gotRemoteStream;
// pc1 作为播放端,先将 stream 加入到 Connection 当中。
pc1.addStream(localStream);
pc1.createOffer(
offerOptions
).then(
onCreateOfferSuccess,
error
);
function onCreateOfferSuccess(desc) {
// desc 就是 sdp 的数据
pc1.setLocalDescription(desc).then(
function() {
onSetLocalSuccess(pc1);
},
onSetSessionDescriptionError
);
trace('pc2 setRemoteDescription start');
// 省去了 offer 的发送通道
pc2.setRemoteDescription(desc).then(
function() {
onSetRemoteSuccess(pc2);
},
onSetSessionDescriptionError
);
trace('pc2 createAnswer start');
pc2.createAnswer().then(
onCreateAnswerSuccess,
onCreateSessionDescriptionError
);
}
看上面的代码,大家估计有点迷茫,来点实的,大家可以参考 单页直播。在查看该网页的时候,可以打开控制台观察具体进行的流程。会发现一个现象,即, onaddstream
会在 SDP
协商还未完成之前就已经开始,这也是,该 API 设计的一些不合理之处,所以, W3C 已经将该 API 移除标准。不过,对于目前来说,问题不大,因为仅仅只是作为演示使用。整个流程我们一步一步来讲解下。
-
pc1 createOffer start
-
pc1 setLocalDescription start // pc1 的 SDP
-
pc2 setRemoteDescription start // pc1 的 SDP
-
pc2 createAnswer start
-
pc1 setLocalDescription complete // pc1 的 SDP
-
pc2 setRemoteDescription complete // pc1 的 SDP
-
pc2 setLocalDescription start // pc2 的 SDP
-
pc1 setRemoteDescription start // pc2 的 SDP
-
pc2 received remote stream,此时,接收端已经可以播放视频。接着,触发 pc2 的 onaddstream 监听事件。获得远端的 video stream,注意此时 pc2 的 SDP 协商还未完成。
-
此时,本地的 pc1 candidate 的状态已经改变,触发 pc1 onicecandidate。开始通过
pc2.addIceCandidate
方法将 pc1 添加进去。 -
pc2 setLocalDescription complete // pc2 的 SDP
-
pc1 setRemoteDescription complete // pc2 的 SDP
-
pc1 addIceCandidate success。pc1 添加成功
-
触发
oniceconnectionstatechange
检查 pc1 远端 candidate 的状态。当为completed
状态时,则会触发 pc2onicecandidate
事件。 -
pc2 addIceCandidate success。
此外,还有另外一个概念, RTCDataChannel
我这里就不过多涉及了。如果有兴趣的可以参阅 webrtc, web 性能优化 进行深入的学习。