WebRTC 点对点直播

yunjinwang 2019-06-21

摘自: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 处理的。处理过程为:

WebRTC 点对点直播

  • 音频:通过物理设备进行捕获。然后开始进行降噪消除回音抖动/丢包隐藏编码

  • 视频:通过物理设备进行捕获。然后开始进行图像增强同步抖动/丢包隐藏编码

最后通过 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 还需要解决很多问题

  1. 遍历 NATs 层,找到指定的 peer

  2. 双方进行基本信息的协商以便双方都能正常播放视频

  3. 在传输时,还需要保证信息安全性

整个架构如下:

WebRTC 点对点直播

上面那些协议,例如,ICE/STUN/TURN 等,我们后面会慢慢讲解。先来看一下,两者是如何进行信息协商的,通常这一阶段,我们叫做 signaling

signaling 任务

signaling 实际上是一个协商过程。因为,两端进不进行 WebRTC 视频交流之间,需要知道一些基本信息。

  • 打开/关闭连接的指令

  • 视频信息,比如解码器,解码器的设置,带宽,以及视频的格式等。

  • 关键数据,相当于 HTTPS 中的 master key 用来确保安全连接。

  • 网关信息,比如双方的 IP,port

不过,signaling 这个过程并不是写死的,即,不管你用哪种协议,只要能确保安全即可。为什么呢?因为,不同的应用有着其本身最适合的协商方法。比如:

  • 单网关协议(SIP/Jingle/ISUP)适用于呼叫机制(VoIP,voice over IP)。

  • 自定义协议

  • 多网关协议

WebRTC 点对点直播

我们自己也可以模拟出一个 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 的协商流程。这里有两个基本的概念,offeranswer

  • offer: 主播端向其他用户提供其本省视频直播的基本信息

  • answer: 用户端反馈给主播端,检查能否正常播放

具体过程为:

WebRTC 点对点直播

  1. 主播端通过 createOffer 生成 SDP 描述

  2. 主播通过 setLocalDescription,设置本地的描述信息

  3. 主播将 offer SDP 发送给用户

  4. 用户通过 setRemoteDescription,设置远端的描述信息

  5. 用户通过 createAnswer 创建出自己的 SDP 描述

  6. 用户通过 setLocalDescription,设置本地的描述信息

  7. 用户将 anwser SDP 发送给主播

  8. 主播通过 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 的方式进行传输,无法避免的会遇见这样的问题。

WebRTC 点对点直播

为了解决这样的问题,我们就需要建立 end-to-end 的连接。那办法是什么呢?很简单,就是在中间设立一个 server 用来保留目标机器在 NAT 中的 entry。常用协议有 STUN, TURN 和 ICE。那他们有什么区别吗?

  • STUN:作为最基本的 NAT traversal 服务器,保留指定机器的 entry

  • TURN:当 STUN 出错的时候,作为重试服务器的存在。

  • ICE:在众多 STUN + TURN 服务器中,选择最有效的传递通道。

所以,上面三者通常是结合在一起使用的。它们在 PeerConnection 中的角色如下图:

WebRTC 点对点直播

如果,涉及到 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 处理中,里面还分为 iceGatheringStateiceConnectionState。在代码中反应的就是:

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 移除标准。不过,对于目前来说,问题不大,因为仅仅只是作为演示使用。整个流程我们一步一步来讲解下。

  1. pc1 createOffer start

  2. pc1 setLocalDescription start // pc1 的 SDP

  3. pc2 setRemoteDescription start // pc1 的 SDP

  4. pc2 createAnswer start

  5. pc1 setLocalDescription complete // pc1 的 SDP

  6. pc2 setRemoteDescription complete // pc1 的 SDP

  7. pc2 setLocalDescription start // pc2 的 SDP

  8. pc1 setRemoteDescription start // pc2 的 SDP

  9. pc2 received remote stream,此时,接收端已经可以播放视频。接着,触发 pc2 的 onaddstream 监听事件。获得远端的 video stream,注意此时 pc2 的 SDP 协商还未完成。

  10. 此时,本地的 pc1 candidate 的状态已经改变,触发 pc1 onicecandidate。开始通过 pc2.addIceCandidate 方法将 pc1 添加进去。

  11. pc2 setLocalDescription complete // pc2 的 SDP

  12. pc1 setRemoteDescription complete // pc2 的 SDP

  13. pc1 addIceCandidate success。pc1 添加成功

  14. 触发 oniceconnectionstatechange 检查 pc1 远端 candidate 的状态。当为 completed 状态时,则会触发 pc2 onicecandidate 事件。

  15. pc2 addIceCandidate success。

此外,还有另外一个概念,RTCDataChannel 我这里就不过多涉及了。如果有兴趣的可以参阅 webrtc,web 性能优化 进行深入的学习。

相关推荐