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。