WebRTC 直播时代

sunfire999 3年前
   <p>WebRTC 全称为: Web Real-Time Communication 。它是为了解决 Web 端无法捕获音视频的能力,并且提供了 peer-to-peer(就是浏览器间)的视频交互。实际上,细分看来,它包含三个部分:</p>    <ul>     <li>MediaStream:捕获音视频流</li>     <li>RTCPeerConnection:传输音视频流(一般用在 peer-to-peer 的场景)</li>     <li>RTCDataChannel: 用来上传音视频二进制数据(一般用到流的上传)</li>    </ul>    <p>但通常,peer-to-peer 的场景实际上应用不大。对比与去年火起来的 直播 业务,这应该才是 WebRTC 常常应用到的地方。那么对应于 Web 直播来说,我们通常需要两个端:</p>    <ul>     <li>主播端:录制并上传视频</li>     <li>观众端:下载并观看视频</li>    </ul>    <p>这里,我就不谈观众端了,后面另写一篇文章介绍(因为,这是在是太多了)。这里,主要谈一下会用到 WebRTC 的主播端。 简化一下,主播端应用技术简单可以分为:录制视频,上传视频。大家先记住这两个目标,后面我们会通过 WebRTC 来实现这两个目标。</p>    <h2>WebRTC 基本了解</h2>    <p>WebRTC 主要由两个组织来制定。</p>    <ul>     <li>Web Real-Time Communications (WEBRTC) W3C 组织:定义浏览器 API</li>     <li>Real-Time Communication in Web-browsers (RTCWEB) IETF 标准组织:定义其所需的协议,数据,安全性等手段。</li>    </ul>    <p>当然,我们初级目标是先关心基本浏览器定义的 API 是啥?以及怎么使用? 然后,后期目标是学习期内部的相关协议,数据格式等。这样循序渐进来,比较适合我们的学习。</p>    <p>WebRTC 对于音视频的处理,主要是交给 Audio/Vidoe Engineering 处理的。处理过程为:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/980248341e005d5a347bd40fc4ecd344.jpg"></p>    <ul>     <li>音频:通过物理设备进行捕获。然后开始进行 降噪 , 消除回音 , 抖动/丢包隐藏 , 编码 。</li>     <li>视频:通过物理设备进行捕获。然后开始进行 图像增强 , 同步 , 抖动/丢包隐藏 , 编码 。</li>    </ul>    <p>最后通过 mediaStream Object 暴露给上层 API 使用。也就是说 mediaStream 是连接 WebRTC API 和底层物理流的中间层。所以,为了下面更好的理解,这里我们先对 mediaStream 做一些简单的介绍。</p>    <h3>MediaStream</h3>    <p>MS(MediaStream)是作为一个辅助对象存在的。它承载了音视频流的筛选,录制权限的获取等。MS 由两部分构成: MediaStreamTrack 和 MediaStream。</p>    <ul>     <li>MediaStreamTrack 代表一种单类型数据流。如果你用过 会声会影 的话,应该对 轨道 这个词不陌生。通俗来讲,你可以认为两者就是等价的。</li>     <li>MediaStream 是一个完整的音视频流。它可以包含 >=0 个 MediaStreamTrack 。它主要的作用就是确保几个轨道是同时播放的。例如,声音需要和视频画面同步。</li>    </ul>    <p>这里,我们不说太深,讲讲基本的 MediaStream 对象即可。通常,我们使用实例化一个 MS 对象,就可以得到一个对象。</p>    <pre>  // 里面还需要传递 track,或者其他 stream 作为参数。  // 这里只为演示方便  let ms = new MediaStream();</pre>    <p>我们可以看一下 ms 上面带有哪些对象属性:</p>    <ul>     <li>active[boolean]:表示当前 ms 是否是活跃状态(就是可播放状态)。</li>     <li>id[String]: 对当前的 ms 进行唯一标识。例如:“f61641ec-ee78-4317-9415-58acac066a4d”</li>     <li>onactive: 当 active 为 true 时,触发该事件</li>     <li>onaddtrack: 当有新的 track 添加时,触发该事件</li>     <li>oninactive: 当 active 为 false 时,触发该事件</li>     <li>onremovetrack: 当有 track 移除时,触发该事件</li>    </ul>    <p>它的原型链上还挂在了其他方法,我挑几个重要的说一下。</p>    <ul>     <li>clone(): 对当前的 ms 流克隆一份。该方法通常用于对该 ms 流有操作时,常常会用到。</li>    </ul>    <p>前面说了,MS 还可以其他筛选的作用,那么它是如何做到的呢? 在 MS 中,还有一个重要的概念叫做: Constraints 。它是用来规范当前采集的数据是否符合需要。因为,我们采集视频时,不同的设备有不同的参数设置。常用的为:</p>    <pre>  {      "audio": true,  // 是否捕获音频      "video": {  // 视频相关设置          "width": {              "min": "381", // 当前视频的最小宽度              "max": "640"           },          "height": {              "min": "200", // 最小高度              "max": "480"          },          "frameRate": {              "min": "28", // 最小帧率               "max": "10"          }      }  }</pre>    <p>那我怎么知道我的设备支持的哪些属性的调优呢? 这里,可以直接使用 navigator.mediaDevices.getSupportedConstraints() 来获取可以调优的相关属性。不过,这一般是对 video 进行设置。了解了 MS 之后,我们就要开始真正接触 WebRTC 的相关 API。我们先来看一下 WebRTC 基本API。</p>    <p>WebRTC 的常用 API 如下,不过由于浏览器的缘故,需要加上对应的 prefix:</p>    <pre>  W3C Standard           Chrome                   Firefox  --------------------------------------------------------------  getUserMedia           webkitGetUserMedia       mozGetUserMedia  RTCPeerConnection      webkitRTCPeerConnection  RTCPeerConnection  RTCSessionDescription  RTCSessionDescription    RTCSessionDescription  RTCIceCandidate        RTCIceCandidate          RTCIceCandidate</pre>    <p>不过,你可以简单的使用下列的方法来解决。不过嫌麻烦的可以使用 adapter.js 来弥补</p>    <pre>  navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia</pre>    <p>这里,我们循序渐进的来学习。如果想进行视频的相关交互,首先应该是捕获音视频。</p>    <h2>捕获音视频</h2>    <p>在 WebRTC 中捕获音视频,只需要使用到一个 API,即, getUserMedia() 。代码其实很简单:</p>    <pre>  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);</pre>    <p>详细 demo 可以参考: WebRTC 。不过,上面的写法比较古老,如果使用 Promise 来的话,getUserMedia 可以写为:</p>    <pre>  navigator.mediaDevices.getUserMedia(constraints).      then(successCallback).catch(errorCallback);</pre>    <p>上面的注释大概已经说清楚基本的内容。需要提醒的是,你在捕获视频的同时,一定要清楚自己需要捕获的相关参数。</p>    <p>有了自己的视频之后,那如何与其他人共享这个视频呢?(可以理解为直播的方式) 在 WebRTC 中,提供了 RTCPeerConnection 的方式,来帮助我们快速建立起连接。不过,这仅仅只是建立起 peer-to-peer 的中间一环。这里包含了一些复杂的过程和额外的协议,我们一步一步的来看下。</p>    <h2>WebRTC 基本内容</h2>    <p>WebRTC 利用的是 UDP 方式来进行传输视频包。这样做的好处是延迟性低,不用过度关注包的顺序。不过,UDP 仅仅只是作为一个传输层协议而已。WebRTC 还需要解决很多问题</p>    <ol>     <li>遍历 NATs 层,找到指定的 peer</li>     <li>双方进行基本信息的协商以便双方都能正常播放视频</li>     <li>在传输时,还需要保证信息安全性</li>    </ol>    <p>整个架构如下:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/38d6addbea780635294b7c1f703975a1.jpg"></p>    <p>上面那些协议,例如,ICE/STUN/TURN 等,我们后面会慢慢讲解。先来看一下,两者是如何进行信息协商的,通常这一阶段,我们叫做 signaling 。</p>    <h3>signaling 任务</h3>    <p>signaling 实际上是一个协商过程。因为,两端进不进行 WebRTC 视频交流之间,需要知道一些基本信息。</p>    <ul>     <li>打开/关闭连接的指令</li>     <li>视频信息,比如解码器,解码器的设置,带宽,以及视频的格式等。</li>     <li>关键数据,相当于 HTTPS 中的 master key 用来确保安全连接。</li>     <li>网关信息,比如双方的 IP,port</li>    </ul>    <p>不过,signaling 这个过程并不是写死的,即,不管你用哪种协议,只要能确保安全即可。为什么呢?因为,不同的应用有着其本身最适合的协商方法。比如:</p>    <ul>     <li>单网关协议(SIP/Jingle/ISUP)适用于呼叫机制(VoIP,voice over IP)。</li>     <li>自定义协议</li>     <li>多网关协议</li>    </ul>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/5e6e9cacf9b103fddf4607d8dbfe0e69.jpg"></p>    <p>我们自己也可以模拟出一个 signaling 通道。它的原理就是将信息进行传输而已,通常为了方便,我们可以直接使用 <a href="/misc/goto?guid=4958331662353341543" rel="nofollow,noindex">socket.io</a> 来建立 room 提供信息交流的通道。</p>    <h2>PeerConnection 的建立</h2>    <p>假定,我们现在已经通过 socket.io 建立起了一个信息交流的通道。那么我们接下来就可以进入 RTCPeerConnection 一节,进行连接的建立。我们首先应该利用 signaling 进行基本信息的交换。那这些信息有哪些呢? WebRTC 已经在底层帮我们做了这些事情-- Session Description Protocol (SDP) 。我们利用 signaling 传递相关的 SDP,来确保双方都能正确匹配,底层引擎会自动解析 SDP (是 JSEP 帮的忙),而不需要我们手动进行解析,突然感觉世界好美妙。。。我们来看一下怎么传递。</p>    <pre>  // 利用已经创建好的通道。  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() { ... }</pre>    <p>那 SDP 的具体格式是啥呢? 看一下格式就 ok,这不用过多了解:</p>    <pre>  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  ...</pre>    <p>上面的过程,就是 peer-to-peer 的协商流程。这里有两个基本的概念, offer , answer 。</p>    <ul>     <li>offer: 主播端向其他用户提供其本省视频直播的基本信息</li>     <li>answer: 用户端反馈给主播端,检查能否正常播放</li>    </ul>    <p>具体过程为:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/8c22b27bf6a0c6dcb933c191bf63fb38.png"></p>    <ol>     <li>主播端通过 createOffer 生成 SDP 描述</li>     <li>主播通过 setLocalDescription,设置本地的描述信息</li>     <li>主播将 offer SDP 发送给用户</li>     <li>用户通过 setRemoteDescription,设置远端的描述信息</li>     <li>用户通过 createAnswer 创建出自己的 SDP 描述</li>     <li>用户通过 setLocalDescription,设置本地的描述信息</li>     <li>用户将 anwser SDP 发送给主播</li>     <li>主播通过 setRemoteDescription,设置远端的描述信息。</li>    </ol>    <p>不过,上面只是简单确立了两端的连接信息而已,还没有涉及到视频信息的传输,也就是说 UDP 传输。UDP 传输本来就是一个非常让人蛋疼的活,如果是 client-server 的模型话还好,直接传就可以了,但这偏偏是 peer-to-peer 的模型。想想,你现在是要把你的电脑当做一个服务器使用,中间还需要经历如果突破防火墙,如果找到端口,如何跨网段进行?所以,这里我们就需要额外的协议,即,STUN/TURN/ICE ,来帮助我们完成这样的传输任务。</p>    <h2>NAT/STUN/TURN/ICE</h2>    <p>在 UDP 传输中,我们不可避免的会遇见 NAT (Network address translator)服务器。即,它主要是将其它网段的消息传递给它负责网段内的机器。不过,我们的 UDP 包在传递时,一般只会带上 NAT 的 host 。如果,此时你没有目标机器的 entry 的话,那么该次 UDP 包将不会被转发成功。不过,如果你是 client-server 的形式的话,就不会遇见这样的问题。但,这里我们是 peer-to-peer 的方式进行传输,无法避免的会遇见这样的问题。</p>    <p> </p>    <p>为了解决这样的问题,我们就需要建立 end-to-end 的连接。那办法是什么呢?很简单,就是在中间设立一个 server 用来保留目标机器在 NAT 中的 entry 。常用协议有 STUN, TURN 和 ICE 。那他们有什么区别吗?</p>    <ul>     <li>STUN:作为最基本的 NAT traversal 服务器,保留指定机器的 entry</li>     <li>TURN:当 STUN 出错的时候,作为重试服务器的存在。</li>     <li>ICE:在众多 STUN + TURN 服务器中,选择最有效的传递通道。</li>    </ul>    <p>所以,上面三者通常是结合在一起使用的。它们在 PeerConnection 中的角色如下图:</p>    <p style="text-align: center;"><img src="https://simg.open-open.com/show/f480dc31f93b862c67a66f117251ce76.jpg"></p>    <p>如果,涉及到 ICE 的话,我们在实例化 Peer Connection 时,还需要预先设置好指定的 STUN/TRUN 服务器。</p>    <pre>  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);         });    }  }  ...</pre>    <p>在 ICE 处理中,里面还分为 iceGatheringState 和 iceConnectionState 。在代码中反应的就是:</p>    <pre>  pc.onicecandidate = function(e) {      evt.target.iceGatheringState;      pc.iceGatheringState          };    pc.oniceconnectionstatechange = function(e) {      evt.target.iceConnectionState;      pc.iceConnectionState;    };</pre>    <p>当然,起主要作用的还是 onicecandidate 。</p>    <ul>     <li>iceGatheringState: 用来检测本地 candidate 的状态。其有以下三种状态:      <ul>       <li>new: 该 candidate 刚刚被创建</li>       <li>gathering: ICE 正在收集本地的 candidate</li>       <li>complete: ICE 完成本地 candidate 的收集</li>      </ul> </li>     <li>iceConnectionState: 用来检测远端 candidate 的状态。远端的状态比较复杂,一共有 7 种: new/checking/connected/completed/failed/disconnected/closed</li>    </ul>    <p>不过,这里为了更好的讲解 WebRTC 建立连接的基本过程。我们使用单页的连接来模拟一下。现在假设,有两个用户,一个是 pc1,一个是 pc2。pc1 捕获视频,然后,pc2 建立与 pc1 的连接,完成伪直播的效果。直接看代码吧:</p>    <pre>  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    );  }</pre>    <p>看上面的代码,大家估计有点迷茫,来点实的 。在查看该网页的时候,可以打开控制台观察具体进行的流程。会发现一个现象,即, onaddstream 会在 SDP 协商还未完成之前就已经开始,这也是,该 API 设计的一些不合理之处,所以, W3C 已经将该 API 移除标准 。不过,对于目前来说,问题不大,因为仅仅只是作为演示使用。整个流程我们一步一步来讲解下。</p>    <ol>     <li>pc1 createOffer start</li>     <li>pc1 setLocalDescription start // pc1 的 SDP</li>     <li>pc2 setRemoteDescription start // pc1 的 SDP</li>     <li>pc2 createAnswer start</li>     <li>pc1 setLocalDescription complete // pc1 的 SDP</li>     <li>pc2 setRemoteDescription complete // pc1 的 SDP</li>     <li>pc2 setLocalDescription start // pc2 的 SDP</li>     <li>pc1 setRemoteDescription start // pc2 的 SDP</li>     <li>pc2 received remote stream,此时,接收端已经可以播放视频。接着,触发 pc2 的 onaddstream 监听事件。获得远端的 video stream,注意此时 pc2 的 SDP 协商还未完成。</li>     <li>此时,本地的 pc1 candidate 的状态已经改变,触发 pc1 onicecandidate。开始通过 pc2.addIceCandidate 方法将 pc1 添加进去。</li>     <li>pc2 setLocalDescription complete // pc2 的 SDP</li>     <li>pc1 setRemoteDescription complete // pc2 的 SDP</li>     <li>pc1 addIceCandidate success。pc1 添加成功</li>     <li>触发 oniceconnectionstatechange 检查 pc1 远端 candidate 的状态。当为 completed 状态时,则会触发 pc2 onicecandidate 事件。</li>     <li>pc2 addIceCandidate success。</li>    </ol>    <p>此外,还有另外一个概念, RTCDataChannel 我这里就不过多涉及了。</p>    <p> </p>    <p>来自:https://www.villainhr.com/page/2017/02/20/WebRTC 直播时代</p>    <p> </p>