Skip to content

Webrtc知识

1. 简介

音频技术的现在与未来

此后,音视频技术不断发展。一方面,视频压缩技术从 H261 到 H264,再到现在的 H265 及未来不久将出现的 AV1,视频压缩率越来越高;音频压缩技术也从电话使用的 G.711、G.722 等窄带音频压缩技术,发展到现代的 AAC、OPUS 等宽带音频压缩技术。

此后,音视频技术不断发展。一方面,视频压缩技术从 H261 到 H264,再到现在的 H265 及未来不久将出现的 AV1,视频压缩率越来越高;音频压缩技术也从电话使用的 G.711、G.722 等窄带音频压缩技术,发展到现代的 AAC、OPUS 等宽带音频压缩技术。

音频技术发展到今天,可以说已经非常成熟了,如我们上面所讲的,H264/H265、VP8/VP9 以及后面的 AV1 编解码器,解决了视频压缩率的问题;而 5G 的商用,解决了带宽的问题。这两个问题解决后,使得各行各业都开始使用音视频技术以实现更佳的用户体验,如音视频会议、在线教育、远程医疗、娱乐游戏等。

尤其是 2011 年 Google 推出 WebRTC 技术后,大大降低了音视频技术的门槛。有了 WebRTC,你就不必自己去实现回音消除算法了;有了 WebRTC ,你也不必自己去实现各种音视频的编解码器了;有了 WebRTC,你更不必去考虑跨平台的问题了。因此,可以说 WebRTC 的出现大大加速了音视频技术的应用与推广。

行业及学习痛点 随着各种音视频技术的完善、5G 的落地,以及各种音视频应用的出现,在未来两三年内,音视频必定会像当年移动互联网一样出现井喷的人才需求。 然而,音视频技术本身纷繁、复杂,需要很深的专业及技术背景。举个简单的例子,当进行音视频互动时,经常会听到自己的回音,这是什么原因引起的呢?再比如说,用户进入教室后经常看到老师的视频卡顿、花屏,这是什么原因造成的?你又该如何解决呢?如果你没有很深的背景知识,对音视频编解码原理不清楚,对音视频处理流程不了解的话,你是根本无法解决上述问题的。 另外,如果你想开发一款音视频产品,不仅需要有最基础的音视频知识(如音视频的编码、解码),往往还需要多层级的技术栈,涉及移动端开发、PC 端开发、各种协议规范、网络协议、socket 开发等。所以,要想成为一员合格的音视频开发工程师,你需要对各领域的知识都有一些掌握才行。

主题一:WebRTC 1 对 1 通话 通过本主题的学习,你最终将学会如何在浏览器间实现 1 对 1 通话。比如一个人在北京,另一个人在上海,他们打开浏览器进入同一个房间后,就可以进行音视频通话了。

本主题我精编了 22 篇文章,这 22 篇文章环环相扣,每篇文章会介绍一个主题,而每个主题的内容都是实现 WebRTC 1 对 1 通话的一部分。当你读完这 22 篇文章后,一个即学即用的 1 对 1 实时通话的例子就展现在你面前了。

主题二:WebRTC 多人音视频实时通话 学完 WebRTC 1 对 1 通话这部分内容后,你对 WebRTC 就有了一定基础。接下来,我将为你介绍如何实现多人音视频实时互动。 在这个主题里,我会首先介绍几种多人音视频实时互动的架构,以及这几种架构的优劣。然后,重点讲解如何使用 SFU 架构实现多人音视频实时通话(SFU 是现在最流行的多人实时互动架构)。当你阅读完本部分知识后,就可以亲手实现多人音视频实时通话了。

主题三:支持上万人同时在线的直播系统 支持上万人同时在线的直播系统主要使用 CDN 技术,它是一种比较老的直播架构,使用的底层传输协议是 RTMP 和 HLS。 在本主题中,我会重点介绍 CDN 原理、RTMP、HLS 协议,以及如何使用各种播放器从 CDN 拉取媒体流。同样地,你阅读完本主题内容后,就会清楚地知道上万人同时在线直播的原理,并可以自己实现一套这样的直播系统。

2. 入门

2.1 访问音视频设备

wenrtc处理流程

在正式讲解如何通过浏览器采集音视频数据之前,我先向你介绍一下 WebRTC 实现一对一音视频实时通话的整个处理过程。对这个过程的了解,可以帮助你在阅读文章时,能清楚明了地知道所阅读的这篇文章、所要学习的知识点在整个处理过程中的位置。

img

上面这幅图是整个 WebRTC 1 对 1 音视频实时通话的过程图。通过这幅图,你可以看出要实现 1 对 1 音视频实时通话其过程还是蛮复杂的。 这幅图从大的方面可以分为 4 部分,即两个 WebRTC 终端(上图中的两个大方框)、一个 Signal(信令)服务器和一个 STUN/TURN 服务器。

  • WebRTC 终端,负责音视频采集、编解码、NAT 穿越、音视频数据传输。
  • Signal 服务器,负责信令处理,如加入房间、离开房间、媒体协商消息的传递等。
  • STUN/TURN 服务器,负责获取 WebRTC 终端在公网的 IP 地址,以及 NAT 穿越失败后的数据中转。

接下来,我就向你描述一下 WebRTC 进行音视频通话的大体过程。

当一端(WebRTC 终端)进入房间之前,它首先会检测自己的设备是否可用。如果此时设备可用,则进行音视频数据采集,这也是本篇我们要介绍的重点内容。

采集到的数据一方面可以做预览,也就是让自己可以看到自己的视频;另一方面,可以将其录制下来保存成文件,等到视频通话结束后,上传到服务器让用户回看之前的内容。

在获取音视频数据就绪后,WebRTC 终端要发送 “加入” 信令到 Signal 服务器。Signal 服务器收到该消息后会创建房间。在另外一端,也要做同样的事情,只不过它不是创建房间,而是加入房间了。待第二个终端成功加入房间后,第一个用户会收到 “另一个用户已经加入成功” 的消息。

此时,第一个终端将创建 “媒体连接” 对象,即 RTCPeerConnection(该对象会在后面的文章中做详细介绍),并将采集到的音视频数据通过 RTCPeerConnection 对象进行编码,最终通过 P2P 传送给对端。

当然,在进行 P2P 穿越时很有可能失败。所以,当 P2P 穿越失败时,为了保障音视频数据仍然可以互通,则需要通过 TURN 服务器(TURN 服务会在后面文章中专门介绍)进行音视频数据中转。

这样,当音视频数据 “历尽千辛万苦” 来到对端后,对端首先将收到的音视频数据进行解码,最后再将其展示出来,这样就完成了一端到另一端的单通。如果双方要互通,那么,两方都要通过 RTCPeerConnection 对象传输自己一端的数据,并从另一端接收数据。

音视频采集

  • 摄像头。用于捕捉(采集)图像和视频。

  • 帧率。现在的摄像头功能已非常强大,一般情况下,一秒钟可以采集 30 张以上的图像,一些好的摄像头甚至可以采集 100 张以上。我们把摄像头一秒钟采集图像的次数,称为帧率。帧率越高,视频就越平滑流畅。然而,在直播系统中一般不会设置太高的帧率,因为帧率越高,占的网络带宽就越多。

  • 分辨率。摄像头除了可以设置帧率之外,还可以调整分辨率。我们常见的分辨率有 2K、1080P、720P、420P 等。分辨率越高图像就越清晰,但同时也带来一个问题,即占用的带宽也就越多。所以,在直播系统中,分辨率的高低与网络带宽有紧密的联系。也就是说,分辨率会跟据你的网络带宽进行动态调整。

  • 宽高比。分辨率一般分为两种宽高比,即 16:9 或 4:3。4:3 的宽高比是从黑白电视而来,而 16:9 的宽高比是从显示器而来。现在一般情况下都采用 16:9 的比例。

  • 麦克风。用于采集音频数据。它与视频一样,可以指定一秒内采样的次数,称为采样率。每个采样用几个 bit 表示,称为采样位深或采样大小。

  • 轨(Track)。WebRTC 中的“轨”借鉴了多媒体的概念。火车轨道的特性你应该非常清楚,两条轨永远不会相交。“轨”在多媒体中表达的就是每条轨数据都是独立的,不会与其他轨相交,如 MP4 中的音频轨、视频轨,它们在 MP4 文件中是被分别存储的。

  • 流(Stream)。可以理解为容器。在 WebRTC 中,“流”可以分为媒体流(MediaStream)和数据流(DataStream)。其中,媒体流可以存放 0 个或多个音频轨或视频轨;数据流可以存 0 个或多个数据轨。

    MediaDevices.getUserMedia() - Web API | MDN (mozilla.org)

音视频采集的API

getUserMedia

在浏览器中访问音视频设备非常简单,只要调用 getUserMedia 这个 API 即可。该 API 的基本格式如下:

js
var promise = navigator.mediaDevices.getUserMedia(constraints);

它返回一个 Promise 对象。

  • 如果 getUserMedia 调用成功,则可以通过 Promise 获得 MediaStream 对象,也就是说现在我们已经从音视频设备中获取到音视频数据了。
  • 如果调用失败,比如用户拒绝该 API 访问媒体设备(音频设备、视频设备),或者要访问的媒体设备不可用,则返回的 Promise 会得到 PermissionDeniedError 或 NotFoundError 等错误对象。

MediaStreamConstraints 参数

从上面的调用格式中可以看到,getUserMedia 方法有一个输入参数 constraints,其类型为 MediaStreamConstraints。它可以指定 MediaStream 中包含哪些类型的媒体轨(音频轨、视频轨),并且可为这些媒体轨设置一些限制。

js
dictionary MediaStreamConstraints {
             (boolean or MediaTrackConstraints) video = false,
             (boolean or MediaTrackConstraints) audio = false
};

从上面的代码中可以看出,该结构可以指定采集音频还是视频,或是同时对两者进行采集。 举个例子,比如你只想采集视频,则可以像下面这样定义 constraints:

js
const mediaStreamContrains = {
    video: true
};

或者,同时采集音视频

javascript

const mediaStreamContrains = {
    video: true,
    audio: true
};

其实,你还可以通过 MediaTrackConstraints 进一步对每一条媒体轨进行限制,比如下面的代码示例:

js
const mediaStreamContrains = {
    video: {
        frameRate: {min: 20},
        width: {min: 640, ideal: 1280},
        height: {min: 360, ideal: 720},
      aspectRatio: 16/9
    },
    audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true
    }
};

上面这个例子表示:视频的帧率最小 20 帧每秒;宽度最小是 640,理想的宽度是 1280;同样的,高度最小是 360,最理想高度是 720;此外宽高比是 16:9;对于音频则是开启回音消除、降噪以及自动增益功能。

如何在页面上展示视频呢?

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div>
    <video id="video" width="640" height="480" autoplay playsinline></video>
    <canvas id="canvas" width="640" height="480"></canvas>
</div>
<script>
    // 调取摄像头,到页面上展示
    var video = document.getElementById('video');
    if (navigator.mediaDevices.getUserMedia) {
        navigator.mediaDevices.getUserMedia({video: true})
            .then(function (stream) {
                video.srcObject = stream;
                video.play();
            })
            .catch(function (error) {
                console.log("发生错误!");
                console.log(error)
            });
    }

</script>
</body>
</html>

然后选择在浏览器打开,点击允许允许即可

image-20240831174111071

页面上就可以展示人像了

它是 HTML5 的视频标签,不仅可以播放多媒体文件,还可以用于播放采集到的数据。其参数含义如下:

  • autoplay,表示当页面加载时可以自动播放视频;
  • playsinline,表示在 HTML5 页面内播放视频,而不是使用系统播放器播放视频。

其输入参数为 MediaStream 对象,该对象中存放着 getUserMedia 方法采集到的音视频轨。我们将它作为视频源赋值给 HTML5 的 video 标签的 srcObject 属性。这样在 HTML 页面加载之后,就可以在该页面中看到摄像头采集到的视频数据了。

img

音视频设备检查

音频设备原理

既然说到音视频设备,那么我们再顺便介绍一下音视频设备的基本工作原理,对于这些设备工作原理的了解,会为你后续学习音视频相关知识提供很大的帮助。

音频有采样率和采样大小的概念,实际上这两个概念就与音频设备密不可分。

音频输入设备的主要工作是采集音频数据,而采集音频数据的本质就是模数转换(A/D),即将模似信号转换成数字信号。

模数转换使用的采集定理称为奈奎斯特定理,其内容如下:

在进行模拟 / 数字信号的转换过程中,当采样率大于信号中最高频率的 2 倍时,采样之后的数字信号就完整地保留了原始信号中的信息。

你也知道,人类听觉范围的频率是 20Hz~20kHz 之间。对于日常语音交流(像电话),8kHz 采样率就可以满足人们的需求。但为了追求高品质、高保真,你需要将音频输入设备的采样率设置在 40kHz 以上,这样才能完整地将原始信号保留下来。例如我们平时听的数字音乐,一般其采样率都是 44.1k、48k 等,以确保其音质的无损。

采集到的数据再经过量化、编码,最终形成数字信号,这就是音频设备所要完成的工作。在量化和编码的过程中,采样大小(保存每个采样的二进制位个数)决定了每个采样最大可以表示的范围。如果采样大小是 8 位,则它表示的最大值是就是,即 255;如果是 16 位,则其表示的最大数值是 65535。

视频设备

至于视频设备,则与音频输入设备很类似。当实物光通过镜头进行到摄像机后,它会通过视频设备的模数转换(A/D)模块,即光学传感器, 将光转换成数字信号,即 RGB(Red、Green、Blue)数据。

获得 RGB 数据后,还要通过 DSP(Digital Signal Processer)进行优化处理,如自动增强、白平衡、色彩饱和等都属于这一阶段要做的事情。

通过 DSP 优化处理后,你就得到了 24 位的真彩色图片。因为每一种颜色由 8 位组成,而一个像素由 RGB 三种颜色构成,所以一个像素就需要用 24 位表示,故称之为 24 位真彩色。

另外,此时获得的 RGB 图像只是临时数据。因最终的图像数据还要进行压缩、传输,而编码器一般使用的输入格式为 YUV I420,所以在摄像头内部还有一个专门的模块用于将 RGB 图像转为 YUV 格式的图像。

那什么是 YUV 呢?YUV 也是一种色彩编码方法,主要用于电视系统以及模拟视频领域。它将亮度信息(Y)与色彩信息(UV)分离,即使没有 UV 信息一样可以显示完整的图像,只不过是黑白的,这样的设计很好地解决了彩色电视机与黑白电视的兼容问题。

YUV 格式还是蛮复杂的,它有好几种存储方式,需要用一整篇的文章来详述才行。所以,在这里我就不一一描述了,如果你想进一步了解其相关知识可以到网上搜索相关资料自行学习。 通过上面的讲解,现在你应该对音频设备与视频设备都有一个基本的认知了。

API接口

MediaDevices,该接口提供了访问(连接到计算机上的)媒体设备(如摄像头、麦克风)以及截取屏幕的方法。实际上,它允许你访问任何硬件媒体设备。而咱们要获取可用的音视频设备列表,就是通过该接口中的方法来实现的。

MediaDeviceInfo,它表示的是每个输入 / 输出设备的信息。

包含以下三个重要的属性:

  • deviceID,设备的唯一标识;
  • label,设备名称;
  • kind,设备种类,可用于识别出是音频设备还是视频设备,是输入设备还是输出设备。

需要注意的是,出于安全原因,除非用户已被授予访问媒体设备的权限(要想授予权限需要使用 HTTPS 请求),否则 label 字段始终为空。 另外,label 可以用作指纹识别机制的一部分,以识别是否是合法用户。对于这一点我们以后再专门讨论。

有了上面这些基础知识,你就很容易理解下面的内容了。

首先,我们来看浏览器上 WebRTC 获取音视频设备列表的接口,其格式如下:

js
MediaDevices.enumerateDevices()

通过调用 MediaDevices 的 enumerateDevices() 方法就可以获取到媒体输入和输出设备列表,例如: 麦克风、相机、耳机等。是不是非常简单?

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="div1"></div>
<script>
    //判断浏览器是否支持这些 API
    if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
        console.log("enumerateDevices() not supported.");
    }
    // 使用MediaDevices 获取电脑中的输入/输出设备的信息
    navigator.mediaDevices.enumerateDevices().then(function (devices) {
        devices.forEach(function (device) {
            // 将内容显示在页面上
            document.getElementById("div1").innerHTML += "device.kind: "+ device.kind + " device.label : " + device.label + "     id = " + device.deviceId + "<br>";
            console.log(device.kind + ": " + device.label + " id = " + device.deviceId);
        });
    });

</script>
</body>
</html>

使用浏览器给自己拍照

好多人小时候应该都学过,在几张空白的纸上画同一个物体,并让物体之间稍有一些变化,然后连续快速地翻动这几张纸,它就形成了一个小动画。

音视频播放器就是利用这样的原理来播放音视频文件的。当你要播放某个视频文件时,播放器会按照一定的时间间隔连续地播放从音视频文件中解码后的视频帧,这样视频就动起来了。同理,播放从摄像头获取的视频帧也是如此,只不过从摄像头获取的本来就是非编码视频帧,所以就不需要解码了。

通过上面的描述,你应该能得到以下两点信息:

  • 播放的视频帧之间的时间间隔是非常小的。如按每秒钟 20 帧的帧率计算,每帧之间的间隔是 50ms。
  • 播放器播的是非编码帧(解码后的帧),这些非编码帧就是一幅幅独立的图像。

编码帧

相对于非编码帧,通过编码器(如 H264/H265、VP8/VP9)压缩后的帧称为编码帧。这里我们以 H264 为例,经过 H264 编码的帧包括以下三种类型。

  • I 帧:关键帧。压缩率低,可以单独解码成一幅完整的图像。
  • P 帧:参考帧。压缩率较高,解码时依赖于前面已解码的数据。
  • B 帧:前后参考帧。压缩率最高,解码时不光依赖前面已经解码的帧,而且还依赖它后面的 P 帧。换句话说就是,B 帧后面的 P 帧要优先于它进行解码,然后才能将 B 帧解码。
html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<video id="video" width="640" height="480" autoplay playsinline></video>
<canvas id="canvas" width="640" height="480"></canvas>
<button >点击拍照</button>
<script>
    // 获取摄像头信息,展示在页面。
    // 然后设置 按钮时间,点击按钮,进行拍照
    var video = document.querySelector('video');
    var canvas = document.querySelector('canvas');
    var button = document.querySelector('button');
    var context = canvas.getContext('2d');
    var constraints = {
        audio: false,
        video: true
    };
    navigator.mediaDevices.getUserMedia(constraints).then(function (stream) {
        video.srcObject = stream;
        video.play();
    }).catch(function (err) {
        console.log(err);
    });
    button.addEventListener('click', function () {
        context.drawImage(video, 0, 0, 640, 480);
    });
    button.addEventListener('click', function () {
        var data = canvas.toDataURL('image/png');
        var link = document.createElement('a');
        link.download = 'image.png';
        link.href = data;
        link.click();
    });
</script>
</body>
</html>

先获得 HTML 中的 Canvas 标签,并设置了 Canvas 的宽高; 然后调用 Canvas 上下文的 drawImage 方法,这样就可以从视频流中抓取当时正在显示的图片了。

这里最关键的点就是 drawImage 方法,其方法格式如下:

js
void ctx.drawImage(image, dx, dy, dWidth, dHeight);

image:可以是一幅图片,或 HTMLVideoElement。 dx, dy:图片起点的 x、y 坐标。 dWidth:图片的宽度。 dHeight:图片的高度。

从视频流中获取到照片后,你还可以通过滤镜为照片增加点特效,这样会让你的照片更加特别。 在浏览器中对于图片的滤镜处理是通过 CSS 来控制的。像前面一样,首先在 HTML 中增加 CSS 的滤镜代码如下:

html
...
<head>
    <style>
         .none {
              -webkit-filter: none;
         }

         .blur {
              -webkit-filter: blur(3px);
         }

         .grayscale {
              -webkit-filter: grayscale(1);
         }

         .invert {
              -webkit-filter: invert(1);
         }

         .sepia {
              -webkit-filter: sepia(1);
         }

    </style>
</head>
<body>
  ...
  <select id="filter">
      <option value="none">None</option>
      <option value="blur">blur</option>
      <option value="grayscale">Grayscale</option>
      <option value="invert">Invert</option>
      <option value="sepia">sepia</option>
  </select>
  ...
</body>

上面的 HTML 代码中定义了以下四种 CSS 滤镜。

  • blur:模糊度
  • grayscale:灰度(黑白)
  • invert:反转
  • sepia:深褐色

录制

在音视频会议、在线教育等系统中,录制是一个特别重要的功能。尤其是在线教育系统,基本上每一节课都要录制下来,以便学生可以随时回顾之前学习的内容。

实现录制功能有很多方式,一般分为服务端录制和客户端录制,具体使用哪种方式还要结合你自己的业务特点来选择。

  • 服务端录制:优点是不用担心客户因自身电脑问题造成录制失败(如磁盘空间不足),也不会因录制时抢占资源(CPU 占用率过高)而导致其他应用出现问题等;缺点是实现的复杂度很高。不过由于本文要重点讲解的是接下来的客户端录制,所以这里我就不再深入展开讲解了,你只需要知道有服务端录制这回事就行了,或者如果你感兴趣,也可以自行搜索学习。
  • 客户端录制:优点是方便录制方(如老师)操控,并且所录制的视频清晰度高,实现相对简单。这里可以和服务端录制做个对比,一般客户端摄像头的分辨率都非常高的(如 1280x720),所以客户端录制可以录制出非常清晰的视频;但服务端录制要做到这点就很困难了,本地高清的视频在上传服务端时由于网络带宽不足,视频的分辨率很有可能会被自动缩小到了 640x360,这就导致用户回看时视频特别模糊,用户体验差。不过客户端录制也有很明显的缺点,其中最主要的缺点就是录制失败率高。因为客户端在进行录制时会开启第二路编码器,这样会特别耗 CPU。而 CPU 占用过高后,就很容易造成应用程序卡死。除此之外,它对内存、硬盘的要求也特别高。

录制的基本原理还是蛮简单的,但要做好却很不容易,主要有以下三个重要的问题需要你搞清楚。

  • 第一个,录制后音视频流的存储格式是什么呢?比如,是直接录制原始数据,还是录制成某种多媒体格式(如 MP4 )?你可能会想,为什么要考虑存储格式问题呢?直接选择一个不就好了?但其实存储格式的选择对于录制后的回放至关重要,这里我先留个悬念不细说,等看到后面的内容你自然就会理解它的重要性与局限性了。
  • 第二个,录制下来的音视频流如何播放?是使用普通的播放器播放,还是使用私有播放器播呢?其实,这与你的业务息息相关。如果你的业务是多人互动类型,且回放时也要和直播时一样,那么你就必须使用私有播放器,因为普通播放器是不支持同时播放多路视频的。还有,如果你想让浏览器也能观看回放,那么就需要提供网页播放器。
  • 第三个,启动录制后多久可以回放呢?这个问题又分为以下三种情况。
    • 边录边看,即开始录制几分钟之后用户就可以观看了。比如,我们观看一些重大体育赛事时(如世界杯),一般都是正式开始一小段时间之后观众才能看到,以确保发生突发事件时可以做紧急处理。
    • 录制完立即回放,即当录制结束后,用户就可以回看录制了。比较常见的是一些技术比较好的教育公司的直播课,录制完成后就可以直接看回放了。
    • 录完后过一段时间可观看。大多数的直播系统都是录制完成后过一段时间才可以看回放,因为录制完成后还要对音视频做一些剪辑、转码,制作各种不同的清晰度的回放等等。

录制原始数据的优点是不用做过多的业务逻辑,来什么数据就录制什么数据,这样录制效率高,不容易出错;并且录制方法也很简单,可以将音频数据与视频数据分别存放到不同的二进制文件中。文件中的每一块数据可以由下面的结构体描述:

c++
struct data 
    int media_type; // 数据类型,0: 音频 1: 视频
    int64_t ts;     // timestamp,记录数据收到的时间
    int data_size;  // 数据大小
    char* data;     // 指定具体的数据
}media_data;

那直接录制成某种多媒体格式会怎么样呢?如果你对多媒体文件格式非常熟悉的话,应该知道 FLV 格式特别适合处理这种流式数据。因为 FLV 媒体文件本身就是流式的,你可以在 FLV 文件的任何位置进行读写操作,它都可以正常被处理。因此,如果你使用 FLV 的话,就不用像前面录制原始数据那样先将二制数据存储下来,之后再进行合流、转码那么麻烦了。

不仅如此,采用 FLV 媒体格式进行录制,你还可以再进一步优化,将直播的视频按 N 分钟为单位,录制成一段一段的 FLV,然后录完一段播一段,这样就实现了上面所讲的边录边看的效果了。

但 FLV 也不是万能的。如果你的场景比较复杂(如多人互动的场景),即同时存在多路视频,FLV 格式就无法满足你的需求了,因为 FLV 只能同时存在一路视频和一路音频,而不能同时存在多路视频这种情况。此时,最好的方法还是录制原始数据,然后通过实现私有播放器来达到用户的需求。

当然,即使是单视频的情况下,FLV 的方案看上去很完美,但实际情况也不一定像你想象的那样美好。因为将音视频流存成 FLV 文件的前提条件是音视频流是按顺序来的,而实际上,音视频数据经过 UDP 这种不可靠传输网络时,是无法保证音视频数据到达的先后顺序的。因此,在处理音视频录制时,你不仅要考虑录制的事情,还要自己做音视频数据排序的工作。除此之外,还有很多其他的工作需要处理,这里我就不一一列举了。

基础支持

在 JavaScript 中,有很多用于存储二进制数据的类型,这些类型包括:ArrayBuffer、ArrayBufferView 和 Blob。那这三者与我们今天要讲的录制音视频流有什么关系呢?

ArrayBuffer

ArrayBuffer 对象表示通用的、固定长度的二进制数据缓冲区。因此,你可以直接使用它存储图片、视频等内容。

但你并不能直接对 ArrayBuffer 对象进行访问,类似于 Java 语言中的抽象类,在物理内存中并不存在这样一个对象,必须使用其封装类进行实例化后才能进行访问。

也就是说, ArrayBuffer 只是描述有这样一块空间可以用来存放二进制数据,但在计算机的内存中并没有真正地为其分配空间。只有当具体类型化后,它才真正地存在于内存中。如下所示:

js
let buffer = new ArrayBuffer(16); // 创建一个长度为 16 的 buffer
let view = new Uint32Array(buffer);

或者

js
let buffer = new Uint8Array([255, 255, 255, 255]).buffer;
let dataView = new DataView(buffer);

在上面的例子中,一开始生成的 buffer 是不能被直接访问的。只有将 buffer 做为参数生成一个具体的类型的新对象时(如 Uint32Array 或 DataView),这个新生成的对象才能被访问。

ArrayBufferView

ArrayBufferView 并不是一个具体的类型,而是代表不同类型的 Array 的描述。这些类型包括:Int8Array、Uint8Array、DataView 等。也就是说 Int8Array、Uint8Array 等才是 JavaScript 在内存中真正可以分配的对象。

以 Int8Array 为例,当你对其实例化时,计算机就会在内存中为其分配一块空间,在该空间中的每一个元素都是 8 位的整数。再以 Uint8Array 为例,它表达的是在内存中分配一块每个元素大小为 8 位的无符号整数的空间。

通过这上面的描述,你现在应该知道 ArrayBuffer 与 ArrayBufferView 的区别了吧?ArrayBufferView 指的是 Int8Array、Uint8Array、DataView 等类型的总称,而这些类型都是使用 ArrayBuffer 类实现的,因此才统称他们为 ArrayBufferView。

Blob

Blob(Binary Large Object)是 JavaScript 的大型二进制对象类型,WebRTC 最终就是使用它将录制好的音视频流保存成多媒体文件的。而它的底层是由上面所讲的 ArrayBuffer 对象的封装类实现的,即 Int8Array、Uint8Array 等类型。

Blob 对象的格式如下:

js
var aBlob = new Blob( array, options );

其中,array 可以是 ArrayBuffer、ArrayBufferView、Blob、DOMString 等类型 ;option,用于指定存储成的媒体类型。 介绍完了这几个你需要了解的基本概念之后,接下来,我们书归正传,看看如何录制本地音视频。

API-MediaRecorder

js
var mediaRecorder = new MediaRecorder(stream[, options]);

其参数含义如下:

  • stream,通过 getUserMedia 获取的本地视频流或通过 RTCPeerConnection 获取的远程视频流。
  • options,可选项,指定视频格式、编解码器、码率等相关信息,如 mimeType: 'video/webm;codecs=vp8'。

MediaRecorder 对象还有一个特别重要的事件,即 ondataavailable 事件。当 MediaRecoder 捕获到数据时就会触发该事件。通过它,我们才能将音视频数据录制下来。

获取到音视频流后,你可以将该流当作参数传给 MediaRecorder 对象,并实现 ondataavailable 事件,最终将音视频流录制下来。具体代码如下所示,我们先看一下 HTML 部分:

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>视频录制</title>
</head>
<body>

<video id="video" width="640" height="480" autoplay playsinline style="border: 1px solid red"></video>
<br>
<video id="recvideo" width="640" height="480" autoplay playsinline style="border: 1px solid red"></video>
<button id="record">开始录制</button>
<button id="recplay" disabled>播放</button>
<button id="download" disabled>下载</button>

<div id="message"></div>

<script>
    let buffer = [];
    var mediaRecorder;
    const video = document.getElementById('video');
    const recordButton = document.getElementById('record');
    const recplayButton = document.getElementById('recplay');
    const downloadButton = document.getElementById('download');
    const recvideo=document.getElementById('recvideo');

    // 开始录制
    async function startRecord() {
        console.log('开始录制')
        buffer = [];
        var options = { mimeType: 'video/webm;codecs=vp8' };
        if (!MediaRecorder.isTypeSupported(options.mimeType)) {
            console.error(`${options.mimeType} is not supported!`);
            return;
        }
        const stream = await navigator.mediaDevices.getUserMedia({video: true, audio: true}); // 保存流以便后续使用
        window.stream=stream
        video.srcObject=stream
        try {
            mediaRecorder = new MediaRecorder(window.stream, options);
        } catch (e) {
            console.error('Failed to create MediaRecorder:', e);
        }
        mediaRecorder.ondataavailable = handleDataAvailable;
        mediaRecorder.start(10);
        recordButton.disabled = true;
        recplayButton.disabled = false;
        downloadButton.disabled = false;
    }

    // 处理数据可用事件
    function handleDataAvailable(e) {
        if (e.data && e.data.size > 0) {
            buffer.push(e.data);
        }
    }


    // 播放录制的视频
    recplayButton.onclick = () => {
        mediaRecorder.stop();
        var blob = new Blob(buffer, {type: 'video/webm'});
        recvideo.src = window.URL.createObjectURL(blob);
        recvideo.srcObject = null;
        recvideo.controls = true;
        recvideo.play();
    };

    // 下载录制的视频
    downloadButton.onclick = () => {
        stop();
        const blob = new Blob(buffer, { type: 'video/webm' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.style.display = 'none';
        a.download = 'recorded.webm';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
    };

    // 开始录制按钮点击事件
    recordButton.onclick = async () => {
        startRecord();
    };
</script>

</body>
</html>

回放录制文件

js
 var blob = new Blob(buffer, {type: 'video/webm'});
 recvideo.src = window.URL.createObjectURL(blob);
 recvideo.srcObject = null;
 recvideo.controls = true;
 recvideo.play();

下载录制好的文件

那如何将录制好的视频文件下载下来呢?代码如下:

js
btnDownload.onclick = ()=> {
        var blob = new Blob(buffer, {type: 'video/webm'});
        var url = window.URL.createObjectURL(blob);
        var a = document.createElement('a');

        a.href = url;
        a.style.display = 'none';
        a.download = 'aaa.webm';
        a.click();
}

将录制好的视频下载下来还是比较简单的,点击 download 按钮后,就会调用上面的代码。在该代码中,也是先创建一个 Blob 对象,并根据 Blob 对象创建 URL;然后再创建一个 标签,设置 A 标签的 href 和 download 属性。这样当用户点击该标签之后,录制好的文件就下载下来了。

共享桌面

桌面也可以当作一种特殊的视频数据来看待。

共享桌面的基本原理 共享桌面的基本原理其实非常简单,我们可以分“两头”来说明:

  • 对于共享者,每秒钟抓取多次屏幕(可以是 3 次、5 次等),每次抓取的屏幕都与上一次抓取的屏幕做比较,取它们的差值,然后对差值进行压缩;如果是第一次抓屏或切幕的情况,即本次抓取的屏幕与上一次抓取屏幕的变化率超过 80% 时,就做全屏的帧内压缩,其过程与 JPEG 图像压缩类似(有兴趣的可以自行学习)。最后再将压缩后的数据通过传输模块传送到观看端;数据到达观看端后,再进行解码,这样即可还原出整幅图片并显示出来。
  • 对于远程控制端,当用户通过鼠标点击共享桌面的某个位置时,会首先计算出鼠标实际点击的位置,然后将其作为参数,通过信令发送给共享端。共享端收到信令后,会模拟本地鼠标,即调用相关的 API,完成最终的操作。一般情况下,当操作完成后,共享端桌面也发生了一些变化,此时就又回到上面共享者的流程了,我就不再赘述了。

通过上面的描述,可以总结出共享桌面的处理过程为:抓屏、压缩编码、传输、解码、显示、控制这几步,你应该可以看出它与音视频的处理过程几乎是一模一样的。

对于共享桌面,很多人比较熟悉的可能是 RDP(Remote Desktop Protocal)协议,它是 Windows 系统下的共享桌面协议;还有一种更通用的远程桌面控制协议——VNC(Virtual Network Console),它可以实现在不同的操作系统上共享远程桌面,像 TeamViewer、RealVNC 都是使用的该协议。

以上的远程桌面协议一般分为桌面数据处理与信令控制两部分。

  • 桌面数据:包括了桌面的抓取 (采集)、编码(压缩)、传输、解码和渲染。
  • 信令控制:包括键盘事件、鼠标事件以及接收到这些事件消息后的相关处理等。

其实在 WebRTC 中也可以实现共享远程桌面的功能。但由于共享桌面与音视频处理的流程是类似的,且 WebRTC 的远程桌面又不需要远程控制,所以其处理过程使用了视频的方式,而非传统意义上的 RDP/VNC 等远程桌面协议。

下面我们就按顺序来具体分析一下,在桌面数据处理的各个环节中,WebRTC 使用的方式与 RDP/VNC 等真正的远程桌面协议的异同点吧。

第一个环节,共享端桌面数据的采集。WebRTC 对于桌面的采集与 RDP/VNC 使用的技术是相同的,都是利用各平台所提供的相关 API 进行桌面的抓取。以 Windows 为例,可以使用下列 API 进行桌面的抓取。

  • BitBlt:XP 系统下经常使用,在 vista 之后,开启 DWM 模式后,速度极慢。
  • Hook:一种黑客技术,实现稍复杂。
  • DirectX:由于 DirectX 9/10/11 之间差别比较大,容易出现兼容问题。
  • 最新的 WebRTC 都是使用的这种方式 GetWindowDC:可以通过它来抓取窗口。

第二个环节,共享端桌面数据的编码。WebRTC 对桌面的编码使用的是视频编码技术,即 H264/VP8 等;但 RDP/VNC 则不一样,它们使用的是图像压缩技术。使用视频编码技术的好处是压缩率高,而坏处是在网络不好的情况下会有模糊等问题。

第三个环节,传输。编码后的桌面数据会通过流媒体传输协议发送到观看端。对于 WebRTC 来说,当网络有问题时,数据是可以丢失的。但对于 RDP/VNC 来说,桌面数据一定不能丢失。

第四个环节,观看端解码。WebRTC 对收到的桌面数据通过视频解码技术解码,而 RDP/VNC 使用的是图像解码技术(可对比第二个环节)。

第五个环节,观看端渲染。一般会通过 OpenGL/D3D 等 GPU 进行渲染,这个 WebRTC 与 RDP/VNC 都是类似的。

通过以上的讲解,相信你应该已经对共享远程桌面有一个基本的认知了,并且也知道在浏览器下使用 WebRTC 共享远程桌面,你只需要会使用浏览器提供的 API 即可。

API

js
var promise = navigator.mediaDevices.getDisplayMedia(constraints);

二者唯一的区别就是:一个是 getDisaplayMedia,另一个是 getUserMedia。

这两个 API 都需要一个 constraints 参数来对采集的桌面 / 视频做一些限制。但需要注意的是,在采集视频时,参数 constraints 也是可以对音频做限制的,而在桌面采集的参数里却不能对音频进行限制了,也就是说,不能在采集桌面的同时采集音频。这一点要特别注意。

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<video id="video" width="640" height="480" autoplay playsinline></video>
<button id="start">开始记录</button>
<button id="download">下载</button>
<script>
    // 进行共享桌面,然后吧屏幕防止在video的标签中
    const video = document.getElementById('video');
    const startButton = document.getElementById('start');
    const downloadButton = document.getElementById('download');
    let buffer = [];
    // 添加开始记录事件,获取流文件,然后进行录制
    startButton.onclick = async () => {
        await getStream();
        startRecord();
    };

    function handleDataAvailable(e) {
        if (e && e.data && e.data.size > 0) {
            buffer.push(e.data);
        }
    }

    // 添加下载事件
    downloadButton.onclick = async () => {
        download();
    };
    async function download() {
        mediaRecorder.stop();
        const blob = new Blob(buffer, {type: 'video/webm'});
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.style.display = 'none';
        a.download = 'recorded.webm';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    // 获取流文件
    async function getStream() {
        const stream = await navigator.mediaDevices.getDisplayMedia({video: true});
        video.srcObject = stream;
        window.stream = stream;
    }

    async function startRecord() {
        //定义一个数组,用于缓存桌面数据,最终将数据存储到文件中
        buffer = [];
        const options = {
            mimeType: 'video/webm;codecs=vp8'
        };
        if (!MediaRecorder.isTypeSupported(options.mimeType)) {
            console.error(`${options.mimeType} is not supported!`);
            return;
        }
        try {
            mediaRecorder = new MediaRecorder(stream, options);
        } catch (e) {
            console.error('Failed to create MediaRecorder:', e);
        }
        //当捕获到桌面数据后,该事件触发
        mediaRecorder.ondataavailable = handleDataAvailable;
        mediaRecorder.start(10);
    }

</script>
</body>
</html>

当用户点击 Record 按钮的时候,就会调用 startRecord 函数。在该函数中首先判断浏览器是否支持指定的多媒体格式,如 webm。 如果支持的话,再创建 MediaRecorder 对象,将桌面流录制成指定的媒体格式文件。 当从 localStream 获取到数据后,会触发 ondataavailable 事件。也就是会调用 handleDataAvailable 方法,最终将数据存放到 Blob 中。

RTP

流媒体协议-RTP\RTCP协议简介_rtp协议-CSDN博客

WebRTC | 网络传输协议 RTP 和 RTCP_webrtc rtp-CSDN博客

WebRTC_UestcXiye的博客-CSDN博客

实时传送协议(RTP,Real-Time Transport Protocol)是一个为程序指定处理在单点或多点网络服务上传输多媒体数据的方式的英特网传输标准。

比如,你是搞音频的,你就可以从 WebRTC 中提取 3A(AEC、AGC、ANC)的算法用到自己的项目中,这些算法可是目前世界上最顶级处理音频的算法;如果你是搞网络的,网络带宽的评估、平滑处理、各种网络协议的实现在 WebRTC 中真是应有尽有,你完全可以从中抽取你想用的。

如果抛开 WebRTC,让你自己实现一套实时互动直播系统,在选择网络传输协议时,你会选择使用 UDP 协议还是 TCP 协议呢?

这个问题在 2011 年至 2012 年一直是一件困扰着我们整个团队的大事儿,因为当时在国内很少有用 UDP 作为底层传输协议的。UDP 虽然传输快,但不可靠,尤其是在用户的网络质量很差的情况下,基本无法保障音视频的服务质量。

当时能想到的解决方案是,如果采用 UDP 作为底层传输协议,那就使用 RUDP(可靠性 UDP),只有这样才能保障传输过程中不丢包。但有人提出反对意见,认为如果想不丢包,就该使用 TCP,因为 RUDP 可靠性做到极致就变成 TCP 了,那为什么不直接使用 TCP 呢?

面对这种情况,2019 年的你会做何种选择呢?UDP 还是 TCP?你能拿出让人真正信服的理由吗?

现在让我告诉你正确答案:必须使用 UDP,必须使用 UDP,必须使用 UDP,重要的事情说三遍。

为什么一定要使用 UDP 呢?关于这个问题,你可以反向思考下,假如使用 TCP 会怎样呢?在极端网络情况下,TCP 为了传输的可靠性,它是如何做的呢?简单总结起来就是“发送 -> 确认;超时 -> 重发”的反复过程。

举个例子,A 与 B 通讯,A 首先向 B 发送数据,并启动一个定时器。当 B 收到 A 的数据后,B 需要给 A 回一个 ACK(确认)消息,反复这样操作,数据就源源不断地从 A 流向了 B。如果因为某些原因,A 一直收不到 B 的确认消息会怎么办呢?当 A 的定时器超时后,A 将重发之前没有被确认的消息,并重新设置定时器。

在 TCP 协议中,为了避免重传次数过多,定时器的超时时间会按 2 的指数增长。也就是说,假设第一次设置的超时时间是 1 秒,那么第二次就是 2 秒,第三次是 4 秒……第七次是 64 秒。如果第七次之后仍然超时,则断开 TCP 连接。你可以计算一下,从第一次超时,到最后断开连接,这之间一共经历了 2 分 07 秒,是不是很恐怖?

如果遇到前面的情况,A 与 B 之间的连接断了,那还算是个不错的情况,因为还可以再重新建立连接。但如果在第七次重传后,A 收到了 B 的 ACK 消息,那么 A 与 B 之间的数据传输的延迟就达到 1 分钟以上。对于这样的延迟,实时互动的直播系统是根本无法接受的。

基于以上的原因,在实现实时互动直播系统的时候你必须使用 UDP 协议。

一般情况下,在实时互动直播系统传输音视频数据流时,我们并不直接将音视频数据流交给 UDP 传输,而是先给音视频数据加个 RTP 头,然后再交给 UDP 进行传输。为什么要这样做呢?

我们以视频帧为例,一个 I 帧的数据量是非常大的,最少也要几十 K(I/P/B 帧的概念。而以太网的最大传输单元是多少呢? 1.5K,所以要传输一个 I 帧需要几十个包。并且这几十个包传到对端后,还要重新组装成 I 帧,这样才能进行解码还原出一幅幅的图像。如果是我们自己实现的话,要完成这样的过程,至少需要以下几个标识。

  • 序号:用于标识传输包的序号,这样就可以知道这个包是第几个分片了。
  • 起始标记:记录分帧的第一个 UDP 包。
  • 结束标记:记录分帧的最后一个 UDP 包。

其实,这样的需求在很早之前就已经有了。因此,人们专门定义了一套规范,它就是 RTP 协议。下面让我们来详细看一下 RTP 协议吧。

RTP协议规范

img

如图所示,RTP 协议非常简单,我这里按字段的重要性从高往低的顺序讲解一下。

  • sequence number:序号,用于记录包的顺序。这与上面我们自己实现拆包、组包是同样的道理。
  • timestamp:时间戳,同一个帧的不同分片的时间戳是相同的。这样就省去了前面所讲的起始标记和结束标记。一定要记住,不同帧的时间戳肯定是不一样的。
  • PT:Payload Type,数据的负载类型。音频流的 PT 值与视频的 PT 值是不同的,通过它就可以知道这个包存放的是什么类型的数据。

这里,我并没有将 RTP 协议头中的所有字段的详细说明都列在这儿,如果你想了解所有字段的含义,可以到参考一节查看其他字段的含义。需要注意的是,这里没有将它们列出来并不代表它们不重要。恰恰相反,如果你想做音视频传输相关的工作,RTP 头中的每个字段的含义你都必须全部清楚。

知道了上面这些字段的含义后,下面我们还是来看一个具体的例子吧!假设你从网上接收到一组音视频数据,如下:

bash
...

{V=2,P=0,X=0,CC=0,M=0,PT:98,seq:13,ts:1122334455,ssrc=2345},
{V=2,P=0,X=0,CC=0,M=0,PT:111,seq:14,ts:1122334455,ssrc=888},
{V=2,P=0,X=0,CC=0,M=0,PT:98,seq:14,ts:1122334455,ssrc=2345},
...

假设 PT=98 是视频数据,PT=111 是音频数据,那么按照上面的规则你是不是很容易就能将视频帧组装起来呢?

RTCP协议

在使用 RTP 包传输数据时,难免会发生丢包、乱序、抖动等问题,下面我们来看一下使用的网络一般都会在什么情况下出现问题:

  • 网络线路质量问题引起丢包率高;
  • 传输的数据超过了带宽的负载引起的丢包问题;
  • 信号干扰(信号弱)引起的丢包问题;
  • 跨运营商引入的丢包问题 ;

WebRTC 对这些问题在底层都有相应的处理策略,但在处理这些问题之前,它首先要让各端都知道它们自己的网络质量到底是怎样的,这就是 RTCP 的作用。

RTCP 有两个最重要的报文:RR(Reciever Report)和 SR(Sender Report)。通过这两个报文的交换,各端就知道自己的网络质量到底如何了。

RTCP 支持的所有报文及其含义可以查看文章最后所附的参考一节。这里我们以 SR 报文为例,看看 SR 报文中都包括哪些信息。

img

下面我就简要说明一下该报文中字段的含义:

  • V=2,指报文的版本。
  • P,表示填充位,如果该位置 1,则在 RTCP 报文的最后会有填充字节(内容是按字节对齐的)。
  • RC,全称 Report Count,指 RTCP 报文中接收报告的报文块个数。 PT=200,Payload Type,也就是说 SR 的值为 200。 ……

与 RTP 协议头一样,上面只介绍了 RTCP 头字段的含义,至于其他每个字段的含义请查看参考一节。同样的,对于 RTCP 头中的每个字段也必须都非常清楚,只有这样以后你在看 WebRTC 带宽评估相关的代码时,才不至于晕头转向。

从上图中我们可以了解到,SR 报文分成三部分:HeaderSender infoReport block。在 NTP 时间戳之上的部分为 SR 报文的 Header 部分,SSRC_1 字段之上到 Header 之间的部分为 Sender info 部分,剩下的就是一个一个的 Report Block 了。那这每一部分是用于干什么的呢?

通过以上的分析,你可以发现 SR 报文并不仅是指发送方发了多少数据,它还报告了作为接收方,它接收到的数据的情况。当发送端收到对端的接收报告时,它就可以根据接收报告来评估它与对端之间的网络质量了,随后再根据网络质量做传输策略的调整。

SR 报文与 RR 报文无疑是 RTCP 协议中最重要的两个报文,不过 RTCP 中的其他报文也都非常重要的,如果你想学好 WebRTC ,那么 RTCP 中的每个报文你都必须掌握。 比如,RTCP 类型为 206、子类型为 4 的 FIR 报文,其含义是 Full Intra Request (FIR) Command,即完整帧请求命令。它起什么作用?又在什么时候使用呢?

该报文也是一个特别关键的报文,我为什么这么说呢?试想一下,在一个房间里有 3 个人进行音视频聊天,然后又有一个人加入到房间里,这时如果不做任何处理的话,那么第四个人进入到房间后,在一段时间内很难直接看到其他三个人的视频画面了,这是为什么呢?

原因就在于解码器在解码时有一个上下文。在该上下文中,必须先拿到一个 IDR 帧之后才能将其后面的 P 帧、B 帧进行解码。也就是说,在没有 IDR 帧的情况下,对于收到的 P 帧、B 帧解码器只能干瞪眼了。

如何解决这个问题呢?这就引出了 FIR 报文。当第四个人加入到房间后,它首先发送 FIR 报文,当其他端收到该报文后,便立即产生各自的 IDR 帧发送给新加入的人,这样当新加入的人拿到房间中其他的 IDR 帧后,它的解码器就会解码成功,于是其他人的画面也就一下子全部展示出来了。所以你说它是不是很重要呢?

SDP

在正式讲解 SDP 之前,你首先要弄清楚 SDP 是什么?SDP(Session Description Protocal)说直白点就是用文本描述的各端(PC 端、Mac 端、Android 端、iOS 端等)的能力。这里的能力指的是各端所支持的音频编解码器是什么,这些编解码器设定的参数是什么,使用的传输协议是什么,以及包括的音视频媒体是什么等等。

下面让我们来看一个真实的 SDP 片段吧!

bash
v=0
o=- 3409821183230872764 2 IN IP4 127.0.0.1
...
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
...
a=rtpmap:111 opus/48000/2
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000
...

如上面的 SDP 片段所示,该 SDP 中描述了一路音频流,即 m=audio,该音频支持的 Payload ( 即数据负载 ) 类型包括 111、103、104 等等。

在该 SDP 片段中又进一步对 111、103、104 等 Payload 类型做了更详细的描述,

a=rtpmap:111 opus/48000/2 表示 Payload 类型为 111 的数据是 OPUS 编码的音频数据,并且它的采样率是 48000,使用双声道。

以此类推,你也就可以知道 a=rtpmap:104 ISAC/32000 的含义是音频数据使用 ISAC 编码,采样频率是 32000,使用单声道。

下面是 1 对 1 WebRTC 处理过程图:

img

如上图所示,两个客户端 / 浏览器进行 1 对 1 通话时,首先要进行信令交互,而交互的一个重要信息就是 SDP 的交换。

交换 SDP 的目的是为了让对方知道彼此具有哪些能力,然后根据双方各自的能力进行协商,协商出大家认可的音视频编解码器、编解码器相关的参数(如音频通道数,采样率等)、传输协议等信息。

举个例子,A 与 B 进行通讯,它们先各自在 SDP 中记录自己支持的音频参数、视频参数、传输协议等信息,然后再将自己的 SDP 信息通过信令服务器发送给对方。当一方收到对端传来的 SDP 信息后,它会将接收到的 SDP 与自己的 SDP 进行比较,并取出它们之间的交集,这个交集就是它们协商的结果,也就是它们最终使用的音视频参数及传输协议了。

内容

标准 SDP 规范主要包括 SDP 描述格式和 SDP 结构,而 SDP 结构由会话描述和媒体信息描述两个部分组成。

其中,媒体信息描述是整个 SDP 规范中最重要的知识,它又包括了:

  • 媒体类型
  • 媒体格式
  • 传输协议
  • 传输的 IP 和端口

格式

SDP 是由多个type=value这样的表达式组成的。其中,是type一个字符,是value一个字符串。需要特别注意的是,“=” 两边是不能有空格的。如下所示:

undefined
v=0
o=- 7017624586836067756 2 IN IP4 127.0.0.1
s=-
t=0 0
...

SDP 由一个会话级描述(session level description)和多个媒体级描述(media level description)组成。

  • 会话级(session level)的作用域是整个会话,其位置是从 v= 行开始到第一个媒体描述为止。

  • 媒体级(media level)是对单个的媒体流进行描述,其位置是从 m= 行开始到下一个媒体描述(即下一个 m=)为止。

另外,除非媒体部分重新对会话级的值做定义,否则会话级的值就是各个媒体的缺省默认值。让我们看个例子吧。

bash
v=0
o=- 7017624586836067756 2 IN IP4 127.0.0.1
s=-
t=0 0

//下面 m= 开头的两行,是两个媒体流:一个音频,一个视频。
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 126
...
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 122 127 121 125 107 108 109 124 120 123 119 114 115 116
...

上面是一个特别简单的例子,每一行都是以一个字符开头,后面紧跟着等于号(=),等于号后面是一串字符。

从“v=”开始一直到“m=audio”,这之间的描述是会话级的;而后面的两个“m=”为媒体级。从中可以看出,在该 SDP 描述中有两个媒体流,一个是音频流,另一个是视频流。

结构

了解了 SDP 的格式,下面我们来看一下 SDP 的结构,它由会话描述和媒体描述两部分组成。

会话描述

会话描述的字段比较多,下面四个字段比较重要,我们来重点介绍一下。

第一个,v=(protocol version,必选)。例子:v=0 ,表示 SDP 的版本号,但不包括次版本号。

第二个,o=(owner/creator and session identifier,必选)。例子:o=,该例子

bash
<username> <session id> <version> <network type> <address type> <address>

是对一个会话发起者的描述。其中,

o= 表示的是对会话发起者的描述;

  • <username>:用户名,当不关心用户名时,可以用“-"代替;

  • <session id>:数字串,在整个会话中,必须是唯一的,建议使用 NTP 时间戳;

  • <version>:版本号,每次会话数据修改后,该版本值会递增;

  • <network type>:网络类型,一般为“IN”,表示“internet"

  • <address type>:地址类型,一般为 IP4:

  • <address>:Ip 地址。

第三个,Session Name(必选)。例子:s=<session name>

该例子表示一个会话,在整个 SDP 中有且只有一个会话,也就是只有一个 s=。

第四个,t=(time the session is active,必选)。例子:t=<start time>

该例子描述了会话的开始时间和结束时间。其中,<start time><stop time><stop time>为 NTP 时间,单位是秒;当<start time><stop time>均为零时 表示持久会话。

(2) 媒体描述

媒体描述的字段也不少,下面我们也重点介绍四个。

第一个,m=(media name and transport address,可选)。例子:m= <media> <port> <transport> <fmt list>,表示一个会话。在一个 SDP 中一般会有多个媒体描述。每个媒体描述以“m="开始到下一个"m=“结束。其中,

  • <media>:媒体类型,比如 audio/video 等;
  • <port>:端口:
  • <transport>:传输协议,有两种--RTP/AVP 和 UDP,
  • <fmt list>:媒体格式,即数据负载类型(Payload Type)列表,

第三个,rtpmap(可选)。例子:a=rtpmap:<payload type><encoding name>/<clock rate>[/<encodingparameters>]

  • rtpmap 是 rtp 与 map 的结合,即 RTP 参数映射表,
  • <payload type>:负载类型,对应 RTP 包中的音视频数据负载类型。
  • <encoding name>:编码器名称,如 VP8、VP9、OPUS 等。
  • <sample rate>:采样率,如音频的采样率频率 32000、48000 等
  • <encodingparameters>: 编码参数,如音频是否是双声道,默认为单声道,

第四个,fmtp。例子:a=fmtp:<payload type><format specific parameters>。 。

  • fmtp,格式参数,即 format parameters,
  • <payload type>,负载类型,同样对应 RTP 包中的音视频数据负载类型
  • < format specific parameters>指具体参数。

以上就是 SDP 规范的基本内容,了解了上面这些内容后,下面我们来看一下具体的例子 你就会对它有更清楚的认知了。

bash
v=0
o=- 4007659306182774937 2 IN IP4 127.0.0.1
s=-
t=0 0 
//以上表示会话描述
...
//下面的媒体描述,在媒体描述部分包括音频和视频两路媒体
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
...
a=rtpmap:111 opus/48000/2 //对RTP数据的描述
a=fmtp:111 minptime=10;useinbandfec=1 //对格式参数的描述
...
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000
...
//上面是音频媒体描述,下面是视频媒体描述
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 122 127 121 125 107 108 109 124 120 123 119 114 115 116
...
a=rtpmap:96 VP8/90000
...

从上面的例子中,你可以清楚地看到在这段 SDP 片段里包括会话信息与媒体信息。在媒体信息中又包括了音频流信息和视频流信息。

媒体协商

我们平时所说的协商你应该清楚是什么意思,说白了就是讨价还价。以买白菜为例,商家说 5 元一颗,买家说身上只有 4.5 元卖不卖?商家同意卖,这样一次协商就完成了。

而媒体协商也是这个意思,只不过它们讨价还价的不是一般商品,而是与媒体相关的能力。那媒体能力是什么呢?实际就是你的设备所支持的音视频编解码器、使用的传输协议、传输的速率是多少等信息。

img

通过这张图你可以了解到,本文所涉及的内容包括创建连接和信令两部分。

  • 创建连接,指的是创建 RTCPeerConnection,它负责端与端之间彼此建立 P2P 连接。在后面 RTCPeerConnection 一节中,我们还会对其做进一步的介绍。
  • 信令,指的是客户端通过信令服务器交换 SDP 信息。

媒体协商的作用就是让双方找到共同支持的媒体能力,如双方都支持的编解码器,从而最终实现彼此之间的音视频通信。

那 WebRTC 是怎样进行媒体协商的呢?

  • 首先,通信双方将它们各自的媒体信息,如编解码器、媒体流的 SSRC、传输协议、IP 地址和端口等,按 SDP 格式整理好。
  • 然后,通信双方通过信令服务器交换 SDP 信息,并待彼此拿到对方的 SDP 信息后,找出它们共同支持的媒体能力。
  • 最后,双方按照协商好的媒体能力开始音视频通信。

WebRTC 进行媒体协商的步骤基本如上所述。接下来,我们来看看 WebRTC 具体是如何操作的。

RTCPeerConnection

讲到媒体协商,我们就不得不介绍一下 RTCPeerConnection 类, 顾名思义,它表示的就是端与端之间建立的连接。

该类是整个 WebRTC 库中最关键的一个类,通过它创建出来的对象可以做很多事情,如 NAT 穿越、音视频数据的接收与发送,甚至它还可以用于非音视频数据的传输等等 。 而在这里我们之所以要介绍 RTCPeerConnection,最主要的原因是我们今天要讲的端到端之间的媒体协商,就是基于 RTCPeerConnection 对象实现的。

首先,我们来看一下如何创建一个 RTCPeerConnection 对象:

js
var pcConfig = null;
var pc = new RTCPeerConnection(pcConfig);

在 JavaScript 下创建 RTCPeerConnection 对象非常简单,如上所述,只要通过 new 关键字创建即可。

在创建 RTCPeerConnection 对象时,还可以给它传一个参数 pcConfig,该参数的结构非常复杂,这里我们先将其设置为 null,

媒体协商过程

在通讯双方都创建好 RTCPeerConnection 对象后,它们就可以开始进行媒体协商了。不过在进行媒体协商之前,有两个重要的概念,即 Offer 与 Answer ,你必须要弄清楚。

Offer 与 Answer 是什么呢?对于 1 对 1 通信的双方来说,我们称首先发送媒体协商消息的一方为呼叫方,而另一方则为被呼叫方。

Offer,在双方通讯时,呼叫方发送的 SDP 消息称为 Offer。 Answer,在双方通讯时,被呼叫方发送的 SDP 消息称为 Answer。

在 WebRTC 中,双方协商的整个过程如下图所示:

img

首先,呼叫方创建 Offer 类型的 SDP 消息。创建完成后,调用 setLocalDescriptoin 方法将该 Offer 保存到本地 Local 域,然后通过信令将 Offer 发送给被呼叫方。

被呼叫方收到 Offer 类型的 SDP 消息后,调用 setRemoteDescription 方法将 Offer 保存到它的 Remote 域。作为应答,被呼叫方要创建 Answer 类型的 SDP 消息,Answer 消息创建成功后,再调用 setLocalDescription 方法将 Answer 类型的 SDP 消息保存到本地的 Local 域。最后,被呼叫方将 Answer 消息通过信令发送给呼叫方。至此,被呼叫方的工作就完部完成了。

接下来是呼叫方的收尾工作,呼叫方收到 Answer 类型的消息后,调用 RTCPeerConnecton 对象的 setRemoteDescription 方法,将 Answer 保存到它的 Remote 域。

至此,整个媒体协商过程处理完毕。

当通讯双方拿到彼此的 SDP 信息后,就可以进行媒体协商了。媒体协商的具体过程是在 WebRTC 内部实现的,我们就不去细讲了。你只需要记住本地的 SDP 和远端的 SDP 都设置好后,协商就算成功了。

媒体协商的代码实现

了解了 WebRTC 的媒体协商过程之后,我们再看一下如何使用 JavaScript 代码来实现这一功能。浏览器提供了几个非常方便的 API,这些 API 是对底层 WebRTC API 的封装。如下所示:

  • createOffer ,创建 Offer;
  • createAnswer,创建 Answer;
  • setLocalDescription,设置本地 SDP 信息;
  • setRemoteDescription,设置远端的 SDP 信息。

接下来,我们就结合上述的协商过程对这几个重要的 API 做下详细的讲解。

呼叫方创建 Offer

当呼叫方发起呼叫之前,首先要创建 Offer 类型的 SDP 信息,即调用 RTCPeerConnection 的 createOffer() 方法。代码如下:

js
   function doCall() {
       console.log('Sending offer to peer');
       pc.createOffer(setLocalAndSendMessage, handleCreateOfferError);
  }

如果 createOffer 函数调用成功的话,浏览器会回调我们设置的 setLocalAndSendMessage 方法,你可以在 setLocalAndSendMessage 方法里获取到 RTCSessionDescription 类型的 SDP 信息;如果出错则会回调 handleCreateOfferError 方法。

最终,在 setLocalAndSendMessage 回调方法中,通过 setLocalDescription() 方法将本地 SDP 描述信息设置到 WebRTC 的 Local 域。然后通过信令通道将此会话描述发送给被呼叫方。代码如下所示:

js
  function setLocalAndSendMessage(sessionDescription) {
      pc.setLocalDescription(sessionDescription);
      sendMessage(sessionDescription);
  }

被呼叫方收到 Offer

被呼叫方收到 Offer 后,调用 setRemoteDescription 方法设置呼叫方发送给它的 Offer 作为远端描述。代码如下:

js
  socket.on('message', function(message) {
      ...
      } else if (message.type === 'offer') {
    
          pc.setRemoteDescription(new RTCSessionDescription(message));
          doAnswer();
      } else if (...) {
          ...
      }
      ....
  });

被呼叫方创建 Answer

然后,被呼叫方调用 RTCPeerConnection 对象的 createAnswer 方法,它会生成一个与远程会话兼容的本地会话,并最终将该会话描述发送给呼叫方。

js
 function doAnswer() {
     pc.createAnswer().then(
         setLocalAndSendMessage,
         onCreateSessionDescriptionError
     );
 }

呼叫方收到 Answer

当呼叫方得到被呼叫方的会话描述,即 SDP 时,调用 setRemoteDescription 方法,将收到的会话描述设置为一个远程会话。代码如下:

js
  socket.on('message', function(message) {
      ...
      } else if (message.type === 'answer') {
    
          pc.setRemoteDescription(new RTCSessionDescription(message));
      } else if (...) {
          ...
      }
      ....
  });

此时,媒体协商过程完成。紧接着在 WebRTC 底层会收集 Candidate,并进行连通性检测,最终在通话双方之间建立起一条链路来。

以上就是通信双方交换媒体能力信息的过程。 对于你来说,如果媒体协商这个逻辑没搞清楚的话,那么,你在编写音视频相关程序时很容易出现各种问题,最常见的就是音视之间不能互通。

另外,需要特别注意的是,通信双方链路的建立是在设置本地媒体能力,即调用 setLocalDescription 函数之后才进行的。

链接

在媒体协商过程中,如果双方能达成一致,也就是商量好了使用什么编解码器,确认了使用什么传输协议,那么接下来,WebRTC 就要建立连接,开始传输音视频数据了。

WebRTC 之间建立连接的过程是非常复杂的。之所以复杂,主要的原因在于它既要考虑传输的高效性,又要保证端与端之间的连通率。

img

图中的红色部分——连接的创建、STUN/TURN 以及 NAT 穿越,就是我们本文要讲的主要内容。

连接的基本原则

接下来,我将通过两个具体的场景,向你介绍一下 WebRTC 建立连接的基本原则。不过在讲解之前,我们先设置一些假设条件,这样会更有利于我们下面的描述:

  • 通信的双方我们称为 A 和 B;
  • A 为呼叫方,B 为被呼叫方;
  • C 为中继服务器,也称为 relay 服务器或 TURN 服务器。

场景一:双方处于同一网段内

A 与 B 进行通信,假设它们现在处于同一个办公区的同一个网段内。在这种情况下,A 与 B 有两种连通路径:

  • 一种是双方通过内网直接进行连接;
  • 另一种是通过公网,也就是通过公司的网关,从公网绕一圈后再进入公司实现双方的通信。

相较而言,显然第一种连接路径是最好的。 A 与 B 在内网连接就好了,谁会舍近求远呢? 但现实却并非如此简单,要想让 A 与 B 直接在内网连接,首先要解决的问题是: A 与 B 如何才能知道它们是在同一个网段内呢?

这个问题还真不好回答,也正是由于这个问题不太好解决,所以,现在有很多通信类产品在双方通信时,无论是否在同一个内网,它们都统一走了公网。不过,WebRTC 很好的解决了这个问题,后面我们可以看一下它是如何解决这个问题的。

场景二:双方处于不同点

A 与 B 进行通信,它们分别在不同的地点,比如一个在北京,一个在上海,此时 A 与 B 通信必须走公网。但走公网也有两条路径:

  • 一是通过 P2P 的方式双方直接建立连接;
  • 二是通过中继服务器进行中转,即 A 与 B 都先与 C 建立连接,当 A 向 B 发消息时, A 先将数据发给 C,然后 C 再转发给 B;同理, B 向 A 发消息时,B 先将消息发给 C,然后 C 再转给 A。

对于这两条路径你该如何选择呢?对于 WebRTC 来讲,它认为通过中继的方式会增加 A 与 B 之间传输的时长,所以它优先使用 P2P 方式;如果 P2P 方式不通,才会使用中继的方式。

通过上面两个场景的描述,我想你应该已经了解到 WebRTC 为了实现端与端之间连接的建立,做了非常多的工作。下面我们就来一起看看 WebRTC 建立连接的具体过程吧!

什么是 Candidate

在讲解 WebRTC 建立连接的过程之前,你有必要先了解一个基本概念,即 ICE Candidate (ICE 候选者)。它表示 WebRTC 与远端通信时使用的协议、IP 地址和端口,一般由以下字段组成:

  • 本地 IP 地址
  • 本地端口号
  • 候选者类型,
  • 包括 host、srflx 和 relay
  • 优先级
  • 传输协议
  • 访问服务的用户名 ……
bash
{
  IP: xxx.xxx.xxx.xxx,
  port: number,
  type: host/srflx/relay,
  priority: number,
  protocol: UDP/TCP,
  usernameFragment: string
  ...
}

其中,候选者类型中的 host 表示本机候选者,srflx 表示内网主机映射的外网的地址和端口,relay 表示中继候选者。

当 WebRTC 通信双方彼此要进行连接时,每一端都会提供许多候选者,比如你的主机有两块网卡,那么每块网卡的不同端口都是一个候选者。

WebRTC 会按照上面描述的格式对候选者进行排序,然后按优先级从高到低的顺序进行连通性测试,当连通性测试成功后,通信的双方就建立起了连接。

在众多候选者中,host 类型的候选者优先级是最高的。在 WebRTC 中,首先对 host 类型的候选者进行连通性检测,如果它们之间可以互通,则直接建立连接。其实,host 类型之间的连通性检测就是内网之间的连通性检测。WebRTC 就是通过这种方式巧妙地解决了大家认为很困难的问题。

同样的道理,如果 host 类型候选者之间无法建立连接,那么 WebRTC 则会尝试次优先级的候选者,即 srflx 类型的候选者。也就是尝试让通信双方直接通过 P2P 进行连接,如果连接成功就使用 P2P 传输数据;如果失败,就最后尝试使用 relay 方式建立连接。

通过上面的描述,你是不是觉得 WebRTC 在这里的设计相当精妙呢?当然在 WebRTC 看来,以上这些只不过是一些“皮毛”,在下一篇关于 NAT 穿越原理一文中,你还会看 WebRTC 在 NAT 穿越上的精彩处理。

收集 Candidate

了解了什么是 Candidate 之后,接下来,我们再来看一下端对端的连接是如何建立的吧。

实际上,端对端的建立更主要的工作是 Candidate 的收集。WebRTC 将 Candidate 分为三种类型:

  • host 类型,即本机内网的 IP 和端口;
  • srflx 类型, 即本机 NAT 映射后的外网的 IP 和端口;
  • relay 类型,即中继服务器的 IP 和端口。

其中,host 类型优先级最高,srflx 次之,relay 最低(前面我们已经说明过了)。

在以上三种 Candidate 类型中,host 类型的 Candidate 是最容易收集的,因为它们都是本机的 IP 地址和端口。对于 host 类型的 Candidate 这里就不做过多讲解了,下面我们主要讲解一下 srflx 和 relay 这两种类型的 Candidate 的收集。

STUN协议

srflx 类型的 Candidate 实际上就是内网地址和端口经 NAT 映射后的外网地址和端口。如下图所示:

img

你应该知道,如果主机没有公网地址,是无论如何都无法访问公网上的资源的。例如你要通过百度搜索一些信息,如果你的主机没有公网地址的话,百度搜索到的结果怎么传给你呢?

而一般情况下,主机都只有内网 IP 和端口,那它是如何访问外网资源的呢?实际上,在内网的网关上都有 NAT (Net Address Translation) 功能,NAT 的作用就是进行内外网的地址转换。这样当你要访问公网上的资源时,NAT 首先会将该主机的内网地址转换成外网地址,然后才会将请求发送给要访问的服务器;服务器处理好后将结果返回给主机的公网地址和端口,再通过 NAT 最终中转给内网的主机。

知道了上面的原理,你要想让内网主机获得它的外网 IP 地址也就好办了,只需要在公网上架设一台服务器,并向这台服务器发个请求说: “Hi!伙计,你看我是谁?”对方回: “你不是那 xxxx 吗?”这样你就可以知道自己的公网 IP 了,是不是很简单?

实际上,上面的描述已经被定义成了一套规范,即 RFC5389 ,也就是 STUN 协议,我们只要遵守这个协议就可以拿到自己的公网 IP 了。

这里我们举个例子,看看通过 STUN 协议,主机是如何获取到自己的外网 IP 地址和端口的。

  • 首先在外网搭建一个 STUN 服务器,现在比较流行的 STUN 服务器是 CoTURN,你可以到 GitHub 上自己下载源码编译安装。
  • 当 STUN 服务器安装好后,从内网主机发送一个 binding request 的 STUN 消息到 STUN 服务器。
  • STUN 服务器收到该请求后,会将请求的 IP 地址和端口填充到 binding response 消息中,然后顺原路将该消息返回给内网主机。
  • 此时,收到 binding response 消息的内网主机就可以解析 binding response 消息了,并可以从中得到自己的外网 IP 和端口。

TURN 协议

咱们言归正转,知道了内网主机如何通过 STUN 协议获取到 srflx 类型的候选者后,那么中继类型候选者,即 relay 型的 Candidate 又是如何获取的呢?下面我们就来看一下。

首先你要清楚,relay 型候选者的优先级与其他类型相比是最低的,但在其他候选者都无法连通的情况下,relay 候选者就成了最好的选择。因为它的连通率是所有候选者中连通率最高的。

其实,relay 型候选者的获取也是通过 STUN 协议完成的,只不过它使用的 STUN 消息类型与获取 srflx 型候选者的 STUN 消息的类型不一样而已。

RFC5766 的 TURN 协议描述了如何获取 relay 服务器(即 TURN 服务器)的 Candidate 过程。其中最主要的是 Allocation 指令。通过向 TURN 服务器发送 Allocation 指令,relay 服务就会在服务器端分配一个新的 relay 端口,用于中转 UDP 数据报。

NAT打洞

当收集到 Candidate 后,WebRTC 就开始按优先级顺序进行连通性检测了。它首先会判断两台主机是否处于同一个局域网内,如果双方确实是在同一局域网内,那么就直接在它们之间建立一条连接。

但如果两台主机不在同一个内网,WebRTC 将尝试 NAT 打洞,即 P2P 穿越。在 WebRTC 中,NAT 打洞是极其复杂的过程,它首先需要对 NAT 类型做判断,检测出其类型后,才能判断出是否可以打洞成功,只有存在打洞成功的可能性时才会真正尝试打洞。

WebRTC 将 NAT 分类为 4 种类型,

分别是:

  • 完全锥型 NAT
  • IP 限制型 NAT
  • 端口限制型 NAT
  • 对称型 NAT

而每种不同类型的 NAT 的详细介绍我们将在下一篇关于 NAT 穿越原理一文中进行讲解,现在你只要知道 NAT 分这 4 种类型就好了。另外,需要记住的是,对称型 NAT 与对称型 NAT 是无法进行 P2P 穿越的;而对称型 NAT 与端口限制型 NAT 也是无法进行 P2P 连接的。

ICE

了解了上面的知识后,你再来看 ICE 就比较简单了。其实 ICE 就是上面所讲的获取各种类型 Candidate 的过程,也就是:在本机收集所有的 host 类型的 Candidate,通过 STUN 协议收集 srflx 类型的 Candidate,使用 TURN 协议收集 relay 类型的 Candidate。 因此,有人说 ICE 就是包括了 STUN、TURN 协议的一套框架,从某种意义来说,这样描述也并不无道理。

NAT

NAT 随处可见,而它的出现主要是出于两个目的。

  • 第一个是解决 IPv4 地址不够用的问题。在 IPv6 短期内无法替换 IPv4 的情况下,如何能解决 IP 地址不够的问题呢?人们想到的办法是,让多台主机共用一个公网 IP 地址,然后在内部使用内网 IP 进行通信,这种方式大大减缓了 IPv4 地址不够用的问题。
  • 第二个是解决安全问题,也就是主机隐藏在内网,外面有 NAT 挡着,这样的话黑客就很难获取到该主机在公网的 IP 地址和端口,从而达到防护的作用。

不过凡事有利也有弊,NAT 的引入确实带来了好处,但同时也带来了坏处。如果没有 NAT,那么每台主机都可以有一个自己的公网 IP 地址,这样每台主机之间都可以相互连接。可以想象一下,如果是那种情况的话,互联网是不是会更加繁荣?因为有了公网 IP 地址后,大大降低了端与端之间网络连接的复杂度,我们也不用再费这么大力气在这里讲 NAT 穿越的原理了。

下面我们来看一下本文在 WebRTC 处理过程中所处的位置吧。通过下面这张图,你可以清楚地了解到本文我们主要讲解的是传输相关的内容。

img

随着人们对 NAT 使用的深入,NAT 的设置也越来越复杂。尤其是各种安全的需要,对 NAT 的复杂性起到了推波助澜的作用。

经过大量研究,现在 NAT 基本上可以总结成 4 种类型:完全锥型、IP 限制锥型、端口限制锥型和对称型。 下面我们就对这 4 种类型的 NAT 做下详细介绍。

完全锥型 NAT

img

完全锥型 NAT 的特点是,当 host 主机通过 NAT 访问外网的 B 主机时,就会在 NAT 上打个“洞”,所有知道这个“洞”的主机都可以通过它与内网主机上的侦听程序通信。

实际上,这里所谓的“打洞”就是在 NAT 上建立一个内外网的映射表。你可以将该映射表简单地认为是一个 4 元组,即:

json
{
  内网IP,
  内网端口,
  映射的外网IP,
  映射的外网端口
}

在 NAT 上有了这张映射表,所有发向这个“洞”的数据都会被 NAT 中转到内网的 host 主机。而在 host 主机上侦听其内网端口的应用程序就可以收到所有的数据了,是不是很神奇?

还是以上面那张图为例,如果 host 主机与 B 主机“打洞”成功,且 A 与 C 从 B 主机那里获得了 host 主机的外网 IP 及端口,那么 A 与 C 就可以向该 IP 和端口发数据,而 host 主机上侦听对应端口的应用程序就能收到它们发送的数据。

如果你在网上查找 NAT 穿越的相关资料,一定会发现大多数打洞都是使用的 UDP 协议。之所以会这样,是因为 UDP 是无连接协议,它没有连接状态的判断,也就是说只要你发送数据给它,它就能收到。而 TCP 协议就做不到这一点,它必须建立连接后,才能收发数据,因此大多数人都选用 UDP 作为打洞协议。

IP 限制锥型 NAT

img

IP 限制锥型要比完全锥型 NAT 严格得多,它主要的特点是,host 主机在 NAT 上“打洞”后,NAT 会对穿越洞口的 IP 地址做限制。只有登记的 IP 地址才可以通过,也就是说,只有 host 主机访问过的外网主机才能穿越 NAT。

而其他主机即使知道“洞”的位置,也不能与 host 主机通信,因为在通过 NAT 时,NAT 会检查 IP 地址,如果发现发来数据的 IP 地址没有登记,则直接将该数据包丢弃。

所以,IP 限制锥型 NAT 的映射表是一个 5 元组,即:

json
{
  内网IP,
  内网端口,
  映射的外网IP,
  映射的外网端口,
  被访问主机的IP
}

还是以上图为例,host 主机访问 B 主机,那么只有 B 主机发送的数据才能穿越 NAT,其他主机 A 和 C 即使从 B 主机那里获得了 host 主机的外网 IP 和端口,也无法穿越 NAT。因为 NAT 会对通过的每个包做检测,当检查发现发送者的 IP 地址与映射表中的“被访问主机的 IP”不一致,则直接将该数据包丢弃。

需要注意的是,IP 限制型 NAT 只限制 IP 地址,如果是同一主机的不同端口穿越 NAT 是没有任何问题的。

端口限制锥型

img

端口限制锥型比 IP 限制锥型 NAT 更加严格,它主要的特点是,不光在 NAT 上对打洞的 IP 地址做了限制,而且还对具体的端口做了限制。因此,端口限制型 NAT 的映射表是一个 6 元组,其格式如下:

json
{
  内网IP,
  内网端口,
  映射的外网IP,
  映射的外网端口,
  被访问主机的IP,
  被访问主机的端口
}

在该 6 元组中,不光包括了 host 主机内外网的映射关系,还包括了要访问的主机的 IP 地址及提供服务的应用程序的端口地址。

如上图所示,host 主机访问 B 主机的 p1 端口时,只有 B 主机的 p1 端口发送的消息才能穿越 NAT 与 host 主机通信。而其他主机,甚至 B 主机的 p2 端口都无法穿越 NAT。

从上面的情况你应该看出来了,从完全锥型 NAT 到端口限制型 NAT,一级比一级严格。但其实端口型 NAT 还不是最严格的,最严格的是接下来要讲解的对称型 NAT。

对称性NAT

img

对称型 NAT 是所有 NAT 类型中最严格的一种类型。通过上图你可以看到,host 主机访问 B 时它在 NAT 上打了一个“洞”,而这个“洞”只有 B 主机上提供服务的端口发送的数据才能穿越,这一点与端口限制型 NAT 是一致的。

但它与端口限制型 NAT 最大的不同在于,如果 host 主机访问 A 时,它会在 NAT 上重新开一个“洞”,而不会使用之前访问 B 时打开的“洞”。也就是说对称型 NAT 对每个连接都使用不同的端口,甚至更换 IP 地址,而端口限制型 NAT 的多个连接则使用同一个端口,这对称型 NAT 与端口限制型 NAT 最大的不同。上面的描述有点抽象,你要好好理解一下。 它的这种特性为 NAT 穿越造成了很多麻烦,尤其是对称型 NAT 碰到对称型 NAT,或对称型 NAT 遇到端口限制型 NAT 时,基本上双方是无法穿越成功的。

以上就是 NAT 的 4 种类型,通过对这 4 种 NAT 类型的了解,你就很容易理解 NAT 该如何穿越了。

NAT类型监测

img

上面这张图清楚地表达了主机进行 NAT 类型检测的流程。其中蓝框是几个重要的检测点,通过这几个检测点你就可以很容易地检测出上面介绍的 4 种不同类型的 NAT 了。

接下来,我们就对上面这张图做下详细的解释。这里需要注意的是,每台服务器都是双网卡的,而每个网卡都有一个自己的公网 IP 地址。

  1. 第一步,判断是否有 NAT 防护 主机向服务器 #1 的某个 IP 和端口发送一个请求,服务器 #1 收到请求后,会通过同样的 IP 和端口返回一个响应消息。
  2. 如果主机收不到服务器 #1 返回的消息,则说明用户的网络限制了 UDP 协议,直接退出。
  3. 如果能收到包,则判断返回的主机的外网 IP 地址是否与主机自身的 IP 地址一样。如果一样,说明主机就是一台拥有公网地址的主机;如果不一样,就跳到下面的步骤 6。
  4. 如果主机拥有公网 IP,则还需要进一步判断其防火墙类型。所以它会再向服务器 #1 发一次请求,此时,服务器 #1 从另外一个网卡的 IP 和不同端口返回响应消息。
  5. 如果主机能收到,说明它是一台没有防护的公网主机;如果收不到,则说明有对称型的防火墙保护着它。
  6. 继续分析第 3 步,如果返回的外网 IP 地址与主机自身 IP 不一致,说明主机是处于 NAT 的防护之下,此时就需要对主机的 NAT 防护类型做进一步探测。

第二步,探测 NAT 环境

  1. 在 NAT 环境下,主机向服务器 #1 发请求,服务器 #1 通过另一个网卡的 IP 和不同端口给主机返回响应消息。
  2. 如果此时主机可以收到响应消息,说明它是在一个完全锥型 NAT 之下。如果收不到消息还需要再做进一步判断。
  3. 如果主机收不到消息,它向服务器 #2(也就是第二台服务器)发请求,服务器 #2 使用收到请求的 IP 地址和端口向主机返回消息。
  4. 主机收到消息后,判断从服务器 #2 获取的外网 IP 和端口与之前从服务器 #1 获取的外网 IP 和端口是否一致,如果不一致说明该主机是在对称型 NAT 之下。
  5. 如果 IP 地址一样,则需要再次发送请求。此时主机向服务器 #1 再次发送请求,服务器 #1 使用同样的 IP 和不同的端口返回响应消息。
  6. 此时,如果主机可以收到响应消息说明是 IP 限制型 NAT,否则就为端口限制型 NAT。

至此,主机所在的 NAT 类型就被准确地判断出来了。有了主机的 NAT 类型你就很容易判断两个主机之间到底能不能成功地进行 NAT 穿越了。

构建信令系统

  • 第一点,可以集中精力将 WebRTC 库做好。WebRTC 的愿景是使浏览器能够方便地处理音视频相关的应用,规范中不限制服务端的事儿,可以使它更聚焦。
  • 第二点,让用户更好地对接业务。你也清楚,信令服务器一般都与公司的业务有着密切的关系,每家公司的业务都各有特色,让它们按照自已的业务去实现信令服务器会更符合它们的要求。
  • 第三点,能得到更多公司的支持。WebRTC 扩展了浏览器的基础设施及能力,而不涉及到具体的业务或产品,这样会更容易得到像苹果、微软这种大公司的支持,否则这些大公司之间就会产生抗衡。

当然,这样做也带来了一些坏处,最明显的一个就是增加了学习 WebRTC 的成本,因为你在学习 WebRTC 的时候,必须自己去实现信令服务器,否则你就没办法让 WebRTC 运转起来,这确实增加了不少学习成本。

不过有了本专栏,你就不用再担心这个问题了。接下来,我就向你讲解一下,如何实现一套最简单的 WebRTC 信令服务系统。有了这套信令服务系统,WebRTC 就能运转起来,这样你就能真正地体验到 WebRTC 的强大之处了。

在开始讲解 WebRTC 信令服务器之前,我们先来看一下本文在 WebRTC 处理过程中的位置。

img

WebRTC 信令服务器的作用

若想要实现 WebRTC 信令服务器,首先就要知道它在 WebRTC 1 对 1 通信中所起的作用。实际上它的功能是蛮简单的,就是进行信令的交换,但作用却十分关键。在通信双方彼此连接、传输媒体数据之前,它们要通过信令服务器交换一些信息,如媒体协商。

举个例子,假设 A 与 B 要进行音视频通信,那么 A 要知道 B 已经上线了,同样,B 也要知道 A 在等着与它通信呢。也就是说,只有双方都知道彼此存在,才能由一方向另一方发起音视频通信请求,并最终实现音视频通话。比如我们在《08 | 有话好商量,论媒体协商》一文中讲的媒体信息协商的过程就是这样一个非常典型的案例,双方的 SDP 信息生成后,要通过信令服务器进行交换,从而达到媒体协商的目的。

那在 WebRTC 信令服务器上要实现哪些功能,才能实现上述结果呢?我想至少要实现下面两个功能:

  • 房间管理。即每个用户都要加入到一个具体的房间里,比如两个用户 A 与 B 要进行通话,那么它们必须加入到同一个房间里。
  • 信令的交换。即在同一个房间里的用户之间可以相互发送信令。

为什么选择 Node.js?

要实现信令服务器,你可以使用 C/C++、Java 等语言一行一行从头开始编写代码,也可以以现有的、成熟的服务器为基础,做二次开发。具体使用哪种方式来实现,关键看你的服务器要实现什么功能,以及使用什么传输协议等信息来决策。

以我们要实现的信令服务器为例,因它只需要传输几个简单的信令,而这些信令既可以使用 TCP、 HTTP/HTTPS 传输,也可以用 WebSocket/WSS 协议传输,所以根据它使用的传输协议,你就可以很容易地想到,通过 Web 服务器(如 Nginx、Node.js)来构建我们的信令服务器是最理想、最省时的、且是最优的方案。

你可以根据自己的喜好选择不同的 Web 服务器(如 Apache、Nginx 或 Node.js)来实现,而今天我们选择的是 Node.js,所以接下来我们将要讲解的是如何使用 Node.js 来搭建信令服务器。

实际上,Apache、Nginx 和 Node.js 都是非常优秀、且成熟的 Web 服务器,其中 Nginx 可以说是性能最好的 Web 服务器了,但从未来的发展角度来说,Node.js 则会更有优势。

Node.js 的最大优点是可以使用 JavaScript 语言开发服务器程序。这样使得大量的前端同学可以无缝转到服务器开发,甚至有可能前后端使用同一套代码实现。对于使用 JavaScript 语言实现全栈开发这一点来说,我想无论是对于个人还是对于企业都是极大的诱惑。更可贵的是 Node.js 的生态链非常完整,有各种各样的功能库,你可以根据自己的需要通过安装工具(如 NPM)快速将它们引入到你的项目中,这极大地提高了 JavaScript 研发同学的开发效率。

Node.js 的核心是 V8(JavaScript)引擎,Node.js 通过它解析 JavaScript 脚本来达到控制服务器的目的。对于 JavaScript 同学来说,Node.js 的出现是革命性的,它不仅让 JavaScript 同学成为了全栈开发工程师,而且还让 JavaScript 开发同学的幸福指数飙升,真正地感受到了 JavaScript 无所不能的能力。对于我这样的开发“老鸟”来说,10 年前还不敢想象通过 JavaScript 来写服务器程序呢,现在它却已成为现实!

Node.js原理

img

Node.js 的工作原理如上图所示,其核心是 V8 引擎。通过该引擎,可以让 JavaScript 调用 C/C++ 方法或对象。反过来讲,通过它也可以让 C/C++ 访问 JavaScript 方法和变量。

Node.js 首先将 JavaScript 写好的应用程序交给 V8 引擎进行解析,V8 理解应用程序的语义后,再调用 Node.js 底层的 C/C++ API 将服务启动起来。所以 Node.js 的强大就在于 JavaScript 与 C/C++ 可以相互调用,从而达到使其能力可以无限扩展的效果。

我们以 Node.js 开发一个 HTTP 服务为例,Node.js 打开侦听的服务端口后,底层会调用 libuv 处理该端口的所有 HTTP 请求。其网络事件处理的过程就如下图所示:

img

当有网络请求过来时,首先会被插入到一个事件处理队列中。libuv 会监控该事件队列,当发现有事件时,先对请求做判断,如果是简单的请求,就直接返回响应了;如果是复杂请求,则从线程池中取一个线程进行异步处理。 线程处理完后,有两种可能:一种是已经处理完成,则向用户发送响应;另一种情况是还需要进一步处理,则再生成一个事件插入到事件队列中等待处理。事件处理就这样循环往复下去,永不停歇。

安装node

SocktIO使用

除了 Node.js 外,我们最终还要借助 Socket.io 来实现 WebRTC 信令服务器。Socket.io 特别适合用来开发 WebRTC 的信令服务器,通过它来构建信令服务器大大简化了信令服务器的实现复杂度,这主要是因为它内置了房间的概念。

img

上图是 Socket.io 与 Node.js 配合使用的逻辑关系图,其逻辑非常简单。Socket.io 分为服务端和客户端两部分。服务端由 Node.js 加载后侦听某个服务端口,客户端要想与服务端相连,首先要加载 Socket.io 的客户端库,然后调用 io.connect();即可与服务端连接上。

这里需要特别强调的是 Socket.io 消息的发送与接收。Socket.io 有很多种发送消息的方式,其中最常见的有下面几种,也是你必须要掌握的。

实现信令服务器

接下来我们来看一下,如何通过 Node.js 下的 Socket.io 来构建一个服务器。

首先是客户端代码,也就是在浏览器里执行的代码。以下是 index.html 代码:

html
<!DOCTYPE html>
<html>
  <head>
    <title>WebRTC client</title>
  </head>
  <body>
    <script src='/socket.io/socket.io.js'></script>
    <script src='js/client.js'></script>
  </body>
</html>

该代码十分简单,就是在 body 里引入了两段 JavaScript 代码。其中,socket.io.js 是用来与服务端建立 Socket 连接的;client.js 的作用是做一些业务逻辑,并最终通过 Socket 与服务端通讯。

下面是 client.js 的代码:

js
var isInitiator;

room = prompt('Enter room name:'); //弹出一个输入窗口

const socket = io.connect(); //与服务端建立socket连接

if (room !== '') { //如果房间不空,则发送 "create or join" 消息
  console.log('Joining room ' + room);
  socket.emit('create or join', room);
}

socket.on('full', (room) => { //如果从服务端收到 "full" 消息
  console.log('Room ' + room + ' is full');
});

socket.on('empty', (room) => { //如果从服务端收到 "empty" 消息
  isInitiator = true;
  console.log('Room ' + room + ' is empty');
});

socket.on('join', (room) => { //如果从服务端收到 “join" 消息
  console.log('Making request to join room ' + room);
  console.log('You are the initiator!');
});

socket.on('log', (array) => {
  console.log.apply(console, array);
});

在该代码中,首先弹出一个输入框,要求用户写入要加入的房间;然后,通过 io.connect() 建立与服务端的连接;最后再根据 socket 返回的消息做不同的处理,比如收到房间满或空的消息等。

以上是客户端(也就是在浏览器)中执行的代码。下面我们来看一下服务端的处理逻辑。

服务器端代码,server.js 是这样的:

json
const static = require('node-static');
const http = require('http');
const file = new(static.Server)();
const app = http.createServer(function (req, res) {
  file.serve(req, res);
}).listen(2013);

const io = require('socket.io').listen(app); //侦听 2013

io.sockets.on('connection', (socket) => {

  // convenience function to log server messages to the client
  function log(){ 
    const array = ['>>> Message from server: ']; 
    for (var i = 0; i < arguments.length; i++) {
      array.push(arguments[i]);
    } 
      socket.emit('log', array);
  }

  socket.on('message', (message) => { //收到message时,进行广播
    log('Got message:', message);
    // for a real app, would be room only (not broadcast)
    socket.broadcast.emit('message', message); //在真实的应用中,应该只在房间内广播
  });

  socket.on('create or join', (room) => { //收到 “create or join” 消息

  var clientsInRoom = io.sockets.adapter.rooms[room];
    var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0; //房间里的人数

    log('Room ' + room + ' has ' + numClients + ' client(s)');
    log('Request to create or join room ' + room);

    if (numClients === 0){ //如果房间里没人
      socket.join(room);
      socket.emit('created', room); //发送 "created" 消息
    } else if (numClients === 1) { //如果房间里有一个人
    io.sockets.in(room).emit('join', room);
      socket.join(room);
      socket.emit('joined', room); //发送 “joined”消息
    } else { // max two clients
      socket.emit('full', room); //发送 "full" 消息
    }
    socket.emit('emit(): client ' + socket.id +
      ' joined room ' + room);
    socket.broadcast.emit('broadcast(): client ' + socket.id +
      ' joined room ' + room);

  });

});

该段代码中,在服务端引入了 node-static 库,使服务器具有发布静态文件的功能。服务器具有此功能后,当客户端(浏览器)向服务端发起请求时,服务器通过该模块获得客户端(浏览器)运行的代码,也就是上面我们讲到的 index.html 和 client.js,下发给客户端(浏览器)。

服务端侦听 2013 这个端口,对不同的消息做相应的处理: 服务器收到 message 消息时,它会直接进行广播,这样所有连接到该服务器的客户端都会收到广播的消息。 服务端收到“create or join”消息时,它会对房间里的人数进行统计,如果房间里没有人,则发送“created”消息;如果房间里有一个人,发送“join”消息和“joined”消息;如果超过两个人,则发送“full”消息。

要运行该程序,需要使用 NPM 安装 socket.io 和node-static,安装方法如下:

bash
npm install socket.io
npm install node-static

启动服务

通过上面的步骤,你就使用“Socket.io + Node.js”实现了一个信令服务器。现在你还可以通过下面的命令将服务启动起来了:

bash
node server.js

如果你是在本机上搭建的服务,则可以在浏览器中输入“localhost:2013”,然后在浏览器中新建一个 tab ,在里边再次输入“localhost:2013”。这时,你就可以通过浏览器的控制台去看看发生了什么吧! 最后,再说一个快捷键小技巧吧,在 Chrome 下,你可以使用 Command-Option-J 或 Ctrl-Shift-J 的 DevTools 快速访问控制台。

RTCPeerConnection

RTCPeerConnection 类是在浏览器下使用 WebRTC 实现 1 对 1 实时互动音视频系统最核心的类。你可以认为它是一个总的接口类或者称它为聚合类,而该类中实现的很多功能都是由其他类具体实现的。

RTPPeerConnection 这个知识点是你掌握 WebRTC 开发的重中之重,抓住它你就抓住了学习 WebRTC 的钥匙(这里你一定要清楚,SDP 是掌握 WebRTC 运行机制的钥匙,而 RTCPeerConnection 是使用 WebRTC 的钥匙),这样可以让你很快学会 WebRTC 的使用。

还是老规矩,我们先来看一下本文在整个 WebRTC 处理过程中的位置。

img

通过上面这张图,你可以看到本文所要讲述的内容就是两个端点之间是如何通过 RTCPeerConnection 创建连接的。

传输要做那些事

可以想像一下,如果你自己要实现一套 1 对 1 的通话系统,你会怎么做呢?如果你有一些 socket 开发经验的话,我想你首先会想到在每一端创建一个 socket,然后通过该 socket 与对端相连。

当 socket 连接成功之后,你就可以通过 socket 向对端发送数据或者接收对端的数据了。这个过程是不是看起来还是蛮简单的呢?实际上,RTCPeerConnection 类的工作原理与 socket 基本是一样的,不过它的功能更强大,实现也更为复杂。因为它有很多细节需要处理,这里我们从“提问题”的角度出发,反向分析,你就知道 RTCPeerConnection 要处理哪些细节了。

  • 端与端之间要建立连接,但它们是如何知道彼此的外网地址呢?
  • 如果两台主机都是在 NAT 之后,它们又是如何穿越 NAT 进行连接的呢?
  • 如果 NAT 穿越不成功,又该如何保证双方之间的连通性呢?
  • 好不容易双方连通了,如果突然丢包了,该怎么办?
  • 如果传输过程中,传输的数据量过大,超过了网络带宽能够承受的负载,又该如何保障音视频的服务质量呢? 传输的音视频要时刻保持同步,这又该如何做到呢?
  • 数据在传输之前要进行音视频编码,而在接收之后又要做音视频解码,但 WebRTC 支持那么多编解码器,如 H264、 H265、 VP8、 VP9 等,它是如何选择的呢? ……

什么是 RTCPeerConnection

了解了传输都要做哪些事之后,你再理解什么是 RTCPeerConnection 就比较容易了。实际上,RTCPeerConnection 就与普通的 socket 一样,在通话的每一端都至少有一个 RTCPeerConnection 对象。在 WebRTC 中它负责与各端建立连接,接收、发送音视频数据,并保障音视频的服务质量。

在操作时,你完全可以把它当作一个 socket 来用,而且还是一个具有超强能力的“SOCKET”。至于它是如何保障端与端之间的连通性,如何保证音视频的服务质量,又如何确定使用的是哪个编解码器等问题,作为应用者的你可以不必关心,因为所有的这些问题都已经在 RTCPeerConnection 对象的底层实现好了。

因此,如果有人问你什么是 RTCPeerConnection?你可以简要地回答说: “它就是一个功能超强的 socket!”这一下就点出了 RTCPeerConnection 的本质。

实现通话

今天我们要实现的例子是在同一个页面中,使两个 RTCPeerConnection 对象之间建立连接。它没有什么实际价值,但却能很好地证明 RTCPeerConnection 是如何工作的。

这里需要特别强调一点,在音视频通话中,每一方只需要有一个 RTCPeerConnection 对象,用它来接收或发送音视频数据。然而在真实的场景中,为了实现端与端之间的通话,还需要利用信令服务器交换一些信息,比如交换双方的 IP 和 port 地址,这样通信的双方才能彼此建立连接(信令服务器的实现可以参考上一篇文章)。

而在本文的例子中,为了最大化地减少额外的工作量,所以我们选择在同一个页面中进行音视频的互通,这样就不需要开发、安装信令服务器了。不过这样也增加了一些理解的难度,所以在阅读下面的内容时,你一定要在脑子中想象:每一个 RTCPeerConnection 就是一个客户端,这样就比较容易理解后面的内容了。

添加视频元素和控制按钮

我们首先开发一个简单的显示界面,在该页面中有两个<video>标签,一个用于显示本地捕获的视频,另一个用于显示“远端”的视频。

除此之外,在该页面上还有三个按钮:

  • start 按钮,用于打开本地视频;
  • call 按钮,用于与对方建立连接;
  • hangup 按钮,用于断开与对方的连接。
js
<video id="localVideo" autoplay playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>

<div>
  <button id="startButton">start</button>
  <button id="callButton">call</button>
  <button id="hangupButton">hang up</button>
</div>

一般情况下,我都还会在显示页面中添加一个叫做 adapter.js 的脚本,它的作用是为各种浏览器都提供统一的、最新的 WebRTC API 接口。

在 WebRTC 1.0 规范没有发布之前,虽然各大浏览器厂商都在各自的浏览器上移植了 WebRTC,但你会发现它们最终实现的接口各不相同。这一问题直到 WebRTC 规范正式推出之后才有所改善,但很多用户依然使用老版本的浏览器,这就为使用 WebRTC 开发音视频应用增添了不少麻烦。

由于浏览器版本众多,而且用户基数大,可以预见各浏览器访问 WebRTC API 接口不统一的问题,在未来很长一段时间内会一直存在。但幸运的是,Google 很早之前就已经注意到了这个问题,因此开发了 adapter.js 这个适配器脚本,以弥补各浏览器 API 不统一的问题。

在页面中引入 adapter.js 的方法如下:

js
<script src="https://webrtc.github.io/adapter/adapter-latest.js </script>

修改后的 index.html 代码如下:

js
<!DOCTYPE html>
<html>

<head>
  <title>Realtime communication with WebRTC</title>
  <link rel="stylesheet" href="css/main.css" />
</head>

<body>
  <h1>Realtime communication with WebRTC</h1>

  <video id="localVideo" autoplay playsinline></video>
  <video id="remoteVideo" autoplay playsinline></video>

  <div>
    <button id="startButton">Start</button>
    <button id="callButton">Call</button>
    <button id="hangupButton">Hang Up</button>
  </div>

  <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
  <script src="js/client.js"></script>
</body>
</html>

如何工作

为了讲清楚 RTCPeerConnection 是如何工作的,我们还是看一个具体的例子吧。 假设 A 与 B 进行通信,那么对于每个端都要创建一个 RTCPeerConnection 对象,这样双方才可以通信,这个应该很好理解。但由于我们的例子中,通信双方是在同一个页面中(也就是说一个页面同时扮演 A 和 B 两个角色),所以在我们的 JavaScript 代码中会同时存在两个 RTCPeerConnection 对象,我们称它们为 pc1 和 pc2 好啦!这里你一定要注意,虽然 pc1 和 pc2 是在同一个页面中,但你一定要把 pc1 和 pc2 想像成两个端的连接对象,这样才便于对后面代码的理解。

在 WebRTC 端与端之间建立连接,包括三个任务:

  • 为连接的每个端创建一个 RTCPeerConnection 对象,并且给 RTCPeerConnection 对象添加一个本地流,该流是从 getUserMedia() 获取的;
  • 获取本地媒体描述信息,即 SDP 信息,并与对端进行交换;
  • 获得网络信息,即 Candidate(IP 地址和端口),并与远端进行交换。

下面我们就来详细看看代码是如何实现的。

(1)获取本地音视频流

你需要调用 getUserMedia() 获取到本地流,然后将它添加到对应的 RTCPeerConnecton 对象中,代码如下:

js
...
//创建 RTCPeerConnection 对象
let localPeerConnection = new RTCPeerConnection(servers);
...

//调用 getUserMedia API 获取音视频流
navigator.mediaDevices.getUserMedia(mediaStreamConstraints).
  then(gotLocalMediaStream).
  catch(handleLocalMediaStreamError);
  
//如果 getUserMedia 获得流,则会回调该函数
//在该函数中一方面要将获取的音视频流展示出来
//另一方面是保存到 localSteam
function gotLocalMediaStream(mediaStream) {
  ...
  localVideo.srcObject = mediaStream;
  localStream = mediaStream;
  ...

}

...

//将音视频流添加到 RTCPeerConnection 对象中
localPeerConnection.addStream(localStream);

...

(2)交换媒体描述信息

当 RTCPeerConnection 对象获得音视频流后,就可以开始与对端进行媒协体协商了。整个媒体协商的过程我已经在《08 | 有话好商量,论媒体协商》一文中做了详细介绍,若记不清了,可以回看下,我们下面的实践中要用到。

并且前面我们也说了,在真实的应用场景中,各端获取的 SDP 信息都要通过信令服务器进行交换,但在我们这个例子中为了减少代码的复杂度,直接在一个页面中实现了两个端,所以也就不需通过信令服务器交换信息了,只需要直接将一端获取的 offer 设置到另一端就好了,具体步骤可以大致描述为如下。

我们首先创建 offer 类型的 SDP 信息。A 调用 RTCPeerConnection 的 createOffer() 方法,得到 A 的本地会话描述,即 offer 类型的 SDP 信息:

js
...
localPeerConnection.createOffer(offerOptions)
  .then(createdOffer).catch(setSessionDescriptionError);
...

如果 createOffer 函数调用成功,会回调 createdOffer 方法,并在 createdOffer 方法中做以下几件事儿。

  • A 使用 setLocalDescription() 设置本地描述,然后将此会话描述发送给 B。
  • B 使用 setRemoteDescription() 设置 A 给它的描述作为远端描述。
  • 之后,B 调用 RTCPeerConnection 的 createAnswer() 方法获得它本地的媒体描述。然后,再调用 setLocalDescription 方法设置本地描述并将该媒体信息描述发给 A。
  • A 得到 B 的应答描述后,就调用 setRemoteDescription() 设置远程描述。

整个媒体信息交换和协商至此就完成了。具体代码如下:

js
//当创建 offer 成功后,会调用该函数
function createdOffer(description) {
  ...
  //将 offer 保存到本地
  localPeerConnection.setLocalDescription(description)
    .then(() => {
      setLocalDescriptionSuccess(localPeerConnection);
    }).catch(setSessionDescriptionError);

  ...
  //远端 pc 将 offer 保存起来
  remotePeerConnection.setRemoteDescription(description)
    .then(() => {
      setRemoteDescriptionSuccess(remotePeerConnection);
    }).catch(setSessionDescriptionError);

  ...
  //远端 pc 创建 answer
  remotePeerConnection.createAnswer()
    .then(createdAnswer)
    .catch(setSessionDescriptionError);
}

//当 answer 创建成功后,会回调该函数 
function createdAnswer(description) {
  ...
  //远端保存 answer
  remotePeerConnection.setLocalDescription(description)
    .then(() => {
      setLocalDescriptionSuccess(remotePeerConnection);
    }).catch(setSessionDescriptionError);

  //本端pc保存 answer
  localPeerConnection.setRemoteDescription(description)
    .then(() => {
      setRemoteDescriptionSuccess(localPeerConnection);
    }).catch(setSessionDescriptionError);
}

(3)端与端建立连接

在本地,当 A 调用 setLocalDescription 函数成功后,就开始收到网络信息了,即开始收集 ICE Candidate。

当 Candidate 被收集上来后,会触发 pc 的 icecandidate 事件,所以在代码中我们需要编写 icecandidate 事件的处理函数,即 onicecandidate,以便对收集到的 Candidate 进行处理。

为 RTCPeerConnection 对象添加 icecandidate 事件的方法如下:

js
...
localPeerConnection.onicecandidate= handleConnection(event); 
...

上面这段代码为 localPeerConnection 对象的 icecandidate 事件添加了一个处理函数,即 handleConnection。

当 Candidate 变为有效时,handleConnection 函数将被调用,具体代码如下:

js
...
function handleConnection(event) {
  
  //获取到触发 icecandidate 事件的 RTCPeerConnection 对象
  //获取到具体的Candidate
  const peerConnection = event.target;
  const iceCandidate = event.candidate;

  if (iceCandidate) {
     //创建 RTCIceCandidate 对象
    const newIceCandidate = new RTCIceCandidate(iceCandidate);
    //得到对端的RTCPeerConnection
    const otherPeer = getOtherPeer(peerConnection);

  //将本地获到的 Candidate 添加到远端的 RTCPeerConnection对象中
    otherPeer.addIceCandidate(newIceCandidate)
      .then(() => {
        handleConnectionSuccess(peerConnection);
      }).catch((error) => {
        handleConnectionFailure(peerConnection, error);
      });

    ...
  }
  
}
...

每次 handleConnection 函数被调用时,就说明 WebRTC 又收集到了一个新的 Candidate。在真实的场景中,每当获得一个新的 Candidate 后,就会通过信令服务器交换给对端,对端再调用 RTCPeerConnection 对象的 addIceCandidate() 方法将收到的 Candidate 保存起来,然后按照 Candidate 的优先级进行连通性检测。

如果 Candidate 连通性检测完成,那么端与端之间就建立了物理连接,这时媒体数据就可能通这个物理连接源源不断地传输了。

显示远端媒体流

通过 RTCPeerConnection 对象 A 与 B 双方建立连接后,本地的多媒体数据就被源源不断地传送到了远端。不过,远端虽然接收到了媒体数据,但音视频并不会显示或播放出来。以视频为例,不显示视频的原因是<video>标签还没有与 RTCPeerConnection 对象进行绑定,也就是说数据虽然到了,但播放器还没有拿到它。

下面我们就来看一下如何让 RTCPeerConnection 对象获得的媒体数据与 H5 的<video>标签绑定到一起。具体代码如下所示:

js
...

localPeerConnection.onaddstream = handleRemoteStreamAdded;
...

function handleRemoteStreamAdded(event) {
  console.log('Remote stream added.');
  remoteStream = event.stream;
  remoteVideo.srcObject = remoteStream;
}

...

上面代码的关键点是 addstream 事件。 在创建好 RTCPeerConnection 对象后,我们需要给 RTCPeerConnection 的 addstream 事件添加回调处理函数,即onaddstream 函数。也就是说,当有数据流到来的时候,浏览器会回调它,在我们的代码中设置的回调处理函数就是 handleRemoteStreamAdded 。

当远端有数据到达时,WebRTC 底层就会调用 addstream 事件的回调函数,即 handleRemoteStreamAdded。在 handleRemoteStreamAdded 函数的输入参数 event 中,包括了远端的音视频流,即 MediaStream 对象,此时将该对象赋值给 video 标签的 srcObject 字段,这样 video 就与 RTCPeerConnection 进行了绑定。

最终代码

html
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>Realtime communication with WebRTC</title>
    <style>
        video {
            max-width: 100%;
            width: 320px;
        }
    </style>
</head>

<body>
<h1>Realtime communication with WebRTC</h1>

<video id="localVideo" autoplay playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>

<div>
    <button id="startButton">开始</button>
    <button id="callButton">连接</button>
    <button id="hangupButton">挂断</button>
</div>

<script src="./adapter-latest.js"></script>
<script>
    // 获取音视频标签
    let localVideo = document.getElementById('localVideo');
    let remoteVideo = document.getElementById('remoteVideo');
    let localStream = null;
    let localPeerConnection = null;
    let remotePeerConnection = null;

    let startButton = document.getElementById('startButton');
    let callButton = document.getElementById('callButton');
    let hangupButton = document.getElementById('hangupButton');
    // 开局2个按钮为禁用状态
    callButton.disabled = true;
    hangupButton.disabled = true;

    // 点击开始按钮
    function start() {
        startButton.disabled = true;
        callButton.disabled = false;
        hangupButton.disabled = false;
        // 调用浏览器的 getUserMedia() 方法 获取音频流
        navigator.mediaDevices.getUserMedia({
            video: true,
        }).then(stream => {
            localVideo.srcObject = stream;
            localStream = stream;
        })
    }

    startButton.onclick = start;
    callButton.onclick = call;
    hangupButton.onclick = hangupAction;

    function call() {
        callButton.disabled = true;
        hangupButton.disabled = false;

        // Get local media stream tracks.
        const videoTracks = localStream.getVideoTracks();
        const audioTracks = localStream.getAudioTracks();

        // 创建一个 RTCPeerConnection 对象
        localPeerConnection = new RTCPeerConnection(null);
        remotePeerConnection = new RTCPeerConnection(null);

        localPeerConnection.addEventListener("icecandidate", handleConnection);
        localPeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange);

        remotePeerConnection.addEventListener('icecandidate', handleConnection);
        remotePeerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange);
        remotePeerConnection.addEventListener('addstream', gotRemoteMediaStream);


        // 将音视频添加到 RTCPeerConnection 对象中
        localPeerConnection.addStream(localStream);

        // 交换媒体描述信息
        localPeerConnection.createOffer({
            // offerToReceiveAudio: true,
            offerToReceiveVideo: true
        }).then(onCreateOfferSuccess).catch(error => console.log)

    }

    function hangupAction() {
        localPeerConnection.close();
        remotePeerConnection.close();
        localPeerConnection = null;
        remotePeerConnection = null;
        hangupButton.disabled = true;
        callButton.disabled = false;
        trace('Ending call.');
    }


    function handleConnection(event) {
        console.log("handleConnection")
        const peerConnection = event.target;
        const iceCandidate = event.candidate;

        if (iceCandidate) {
            const newIceCandidate = new RTCIceCandidate(iceCandidate);
            const otherPeer = getOtherPeer(peerConnection);
            otherPeer.addIceCandidate(newIceCandidate)
                .then(() => {
                    // 打印成功信息
                    handleConnectionSuccess(peerConnection);
                }).catch(error => {
                // 打印失败信息
                handleConnectionFailure(peerConnection, error);
            });
        }
    }

    // 处理连接候选人变化的时候
    function handleConnectionChange(event) {
        const peerConnection = event.target;
        console.log('ICE state change event: ', event);
        trace(`${getPeerName(peerConnection)} ICE state: ` +
            `${peerConnection.iceConnectionState}.`);
    }


    // 设置远程音视频流
    function gotRemoteMediaStream(event) {
        const mediaStream = event.stream;
        remoteVideo.srcObject = mediaStream;
        remoteStream = mediaStream;
        trace('Remote peer connection received remote stream.');
    }

    //
    function getOtherPeer(peerConnection) {
        return (peerConnection === localPeerConnection) ?
            remotePeerConnection : localPeerConnection;
    }

    // 当创建offer成功够,会调用该函数
    function onCreateOfferSuccess(desc) {
        console.log("Offer created")
        console.log(desc)
        // 将offer保存到本地
        localPeerConnection.setLocalDescription(desc)
            .then(() => {
                setLocalDescriptionSuccess(localPeerConnection);
            }).catch(error => console.log);
        // 设置远程连接信息
        remotePeerConnection.setRemoteDescription(desc)
            .then(() => {
                setRemoteDescriptionSuccess(remotePeerConnection);
            })

        // 创建answer
        remotePeerConnection.createAnswer()
            .then(createdAnswer)
            .catch(error => console.log)
    }

    // 获取当前链接的名字
    function getPeerName(peerConnection) {
        return (peerConnection === localPeerConnection) ?
            'localPeerConnection' : 'remotePeerConnection';
    }

    function createdAnswer(desc) {
        console.log("Answer created")
        console.log(desc)
        // 将answer保存到本地
        remotePeerConnection.setLocalDescription(desc)
            .then(() => {
                setLocalDescriptionSuccess(remotePeerConnection);
            }).catch(error => console.log);
        // 设置远程连接信息
        localPeerConnection.setRemoteDescription(desc)
            .then(() => {
                setRemoteDescriptionSuccess(localPeerConnection);
            })
    }


    // 设置描述成功信息
    function setDescriptionSuccess(peerConnection, functionName) {
        const peerName = getPeerName(peerConnection);
        trace(`${peerName} ${functionName} complete.`);
    }

    // 在控制台打印日志数据
    function trace(arg) {
        const now = (window.performance.now() / 1000).toFixed(3);
        console.log(`${now}: ${arg}`);
    }

    // 设置远程连接信息
    function setRemoteDescriptionSuccess(peerConnection) {
        setDescriptionSuccess(peerConnection, 'setRemoteDescription');
    }

    function setLocalDescriptionSuccess(peerConnection) {
        setDescriptionSuccess(peerConnection, 'setLocalDescription');
    }

    function handleConnectionSuccess(peerConnection) {
        trace(`${getPeerName(peerConnection)} addIceCandidate success.`);
    }

    function handleConnectionFailure(peerConnection, error) {
        trace(`${getPeerName(peerConnection)} failed to add ICE Candidate:\n` +
            `${error.toString()}.`);
    }

</script>
</body>
</html>

控制音视频传输

像上面所说的,虽然通过 RTCPeerConnection 在端与端之间建立连接后,音视频数据可以互通了,但你还应对传输速率有所控制。之所以要对传输速率进行控制,主要是为了提高音视频服务质量。

举个简单的例子,假设你的带宽是 1Mbps,你想与你的朋友进行音视频通话,使用的视频分辨率为 720P,帧率是 15 帧 / 秒,你觉得你们通话时的音视频质量会好吗?

咱们来简单计算一下,根据经验值,帧率为 15 帧 / 秒、分辨率为 720P 的视频,每秒钟大约要产生 1.2~1.5Mbps 的流量。由此可知,你和你朋友在 1M 带宽的网络上进行通话,那通话质量一定会很差。因为你的“马路”就那么宽,却要跑超出它宽度的数据,这样超出带宽的数据会被直接丢弃掉,从而造成大量视频帧无法解码,所以最终效果一定会很差。

由此可知,如果你不对音视频传输速率进行限制的话,它一定会对音视频服务质量产生严重的影响。除了传输速率,还有哪些因素会对音视频质量产生影响呢?下面我从网络质量和数据两个方面列举了一些对音视频服务质量产生影响的因素:

  • 网络质量,包括物理链路的质量、带宽的大小、传输速率的控制等;
  • 数据,包括音视频压缩码率、分辨率大小、帧率等。

物理链路质量

物理链路质量包括三个方面,即丢包、延迟和抖动。下面我们来看看它们是怎样影响服务质量的吧!

  • 丢包。这个比较好理解,如果物理链路不好,经常出现丢包,这样就会造成接收端无法组包、解码,从而对音视频服务质量产生影响。
  • 延迟。指通信双方在传输数据时,数据在物理链路上花费的时间比较长。对于实时通信来说,200ms 以内的延迟是最好的,这样通话双方的感觉就像是在面对面谈话;如果延迟是在 500 ms 以内,通话双方的体验也还不错,有点像打电话的感觉;如果延迟达到 800ms,还能接受,但有明显的迟滞现像;但如果延迟超过 1 秒,那就不是实时通话了!
  • 抖动。指的是数据一会儿快、一会儿慢,很不稳定。如果不加处理的话,你看到的视频效果就是一会儿快播了、一会儿又慢动作,给人一种眩晕的感觉,时间长了会非常难受。不过对于 WebRTC 来讲,它通过内部的 JitterBuffer(可以简单地理解为一块缓冲区)就能很好地解决该问题。

宽带大小

带宽大小指的是每秒钟可以传输多少数据。比如 1M 带宽,它表达的是每秒钟可以传输 1M 个 bit 位,换算成字节就是 1Mbps/8 = 128KBps,也就是说 1M 带宽实际每秒钟只能传输 128K 个 Byte。

带宽大小指的是每秒钟可以传输多少数据。比如 1M 带宽,它表达的是每秒钟可以传输 1M 个 bit 位,换算成字节就是 1Mbps/8 = 128KBps,也就是说 1M 带宽实际每秒钟只能传输 128K 个 Byte。

当带宽固定的情况下,如何才能让数据传输得更快呢?答案是充分利用带宽。这句话有点抽象,它实际的含义是把带宽尽量占满,但千万别超出带宽的限制。这里还是以 1M 带宽为例,如果每秒都传输 1M 的数据,这样传输数据的速度才是最快,多了、少了都不行。每秒传输的数据少了,就相当于有 100 辆车,本来每次可以走 10 辆,10 趟就走完了,可你却让它一次走 1 辆,这样肯定慢;而每秒传输多了,就会发生网络拥塞,就像每天上下班堵车一样,你说它还能快吗?

传输速率

在实时通信中,与传输速率相关的有两个码率:音视频压缩码率和传输控制码率。

音视频压缩码率指的是单位时间内音视频被压缩后的数据大小,或者你可以简单地理解为压缩后每秒的采样率。它与视频的清晰度是成反比的,也就是压缩码率越高,清晰度越低。我们可以做个简单的对比,你应该清楚音视频编码被称为有损压缩,所谓的有损压缩就是数据被压缩后,就无法再还原回原来的样子;而与有损压缩对应的是无损压缩,它是指数据解压后还能还原回来,像我们日常中用到的 Zip、RAR、GZ 等这些压缩文件都是无损压缩。对于有损压缩,你设备的压缩码率越高,它的损失也就越大,解码后的视频与原视频的差别就越大。

传输码率是指对网络传输速度的控制。举个例子,假设你发送的每个网络包都是 1500 字节,如果每秒钟发 100 个包,它的传输码率是多少呢?即 100*1.5K = 150K 字节,再换算成带宽的话就是 150KB * 8 = 1.2M。但如果你的带宽是 1M,那每秒钟发 100 个包肯定是多了,这个时候就要控制发包的速度,把它控制在 1M 以内,并尽量地接近 1M,这样数据传输的速度才是最快的。

当然,如果你的压缩码率本来就很小,比如每秒钟只有 500kbps,而你的带宽是 1Mbps,那你还有必要对传输码率进行控制吗?换句话说,一条马路可以一起跑 10 辆车,但你现在只有 3 辆,显然你就没必要再控制同时发车的数量了。

分辨率和帧率

你应该很清楚,视频的分辨率越高,视频就越清晰,但同时它的数据量也就越大。我们还是来简单计算一下,对于 1 帧未压缩过的视频帧,如果它的分辨率是 1280 * 720,存储成 RGB 格式,则这一帧的数据为 1280 * 720 * 3 * 8(3 表示 R、G、B 三种颜色,8 表示将 Byte 换算成 bit),约等于 22Mb;而存成 YUV420P 格式则约等于 11Mb,即 1280 * 720 * 1.5 * 8。

按照上面的公式计算,如果你把视频的分辨率降到 640 * 360,则这一帧的数据就降到了原来的 1/4,这个效果还是非常明显的。所以,如果你想降低码率,最直接的办法就是降分辨率

当然,对帧率的控制也一样可以起到一定的效果。比如原来采集的视频是 30 帧 / 秒,还以分辨率是 1280 * 720 为例,之前 1 帧的数据是 22M,那 30 帧就是 22 * 30=660Mb。但如果改为 15 帧 / 秒,则数据就变成了 330Mb,直接减少了一半。

但了解音视频压缩原理的同学应该知道,通过减少帧率来控制码率的效果可能并不明显,因为在传输数据之前是要将原始音视频数据进行压缩的,在同一个 GOP(Group Of Picture)中,除了 I/IDR 帧外,B 帧和 P 帧的数据量是非常小的。因此,减少帧率的方式就没有降低分辨率方式效果明显了。

传输速率的控制

通过上面的介绍,我想你现在应该很清楚,可以通过以下两种方式来控制传输速率。第一种是通过压缩码率这种“曲线救国”的方式进行控制;第二种则是更直接的方式,通过控制传输速度来控制速率。

第二种方式虽说很直接,但是也存在一些弊端。假设你有 10M 的数据要发送,而传输的速度却被限制为 5kbps,那它就只能一点一点地传。需要注意的是,由于 WebRTC 是实时传输,当它发现音视频数据的延迟太大,且数据又不能及时发出去时,它会采用主动丢数据的方法,以达到实时传输的要求。

所以说控制传输速率虽然有两种方式,但实际上,WebRTC 只允许我们使用第一种压缩码率的方式来主动控制速率,而第二种方式是它在底层自己控制的,为了保障实时性,一旦数据无法及时发送出去的话就会进行主动丢包。

代码

js
....

var vsender = null; //定义 video sender 变量
var senders = pc.getSenders(); //从RTCPeerConnection中获得所有的sender

//遍历每个sender
senders.forEach( sender => {
  if(sender && sender.track.kind === 'video'){ //找到视频的 sender
      vsender = sender; 
  }
});

var parameters = vsender.getParameters(); //取出视频 sender 的参数
if(!parameters.encodings){ //判断参数里是否有encoding域
    return;
}

//通过 在encoding中的 maxBitrate 可以限掉传输码率
parameters.encodings[0].maxBitrate = bw * 1000;

//将调整好的码率重新设置回sender中去,这样设置的码率就起效果了。
vsender.setParameters(parameters) 
       .then(()=>{
          console.log('Successed to set parameters!');
       }).catch(err => {
          console.error(err);
       })

...

上面的代码中,首先从 RTCPeerConnection 中获取视频的发送者,即 kind 为 video 的 sender;然后取出 sender 中的 parameters 对象,其中的 maxBitrate 属性就是用于控制传输码率的;将你期望的最大码率设置好后,再将 parameters 对象设置回去,这样 WebRTC 就可以控制某路流的码率大小了。

打开/关闭声音

  • 将远端的声音静音。比如来了一个电话,此时,应该先将直播中远端的声音关掉,等接完电话再将远端的声音打开,否则电话的声音与直播远端的声音会同时播放出来。
  • 将自己的声音静音。比如老板要找你谈话,这时你应该将直播中自己的声音静音,否则你与老板的一些私密谈话会被远端听到。比如被老板骂了,要是被远端听到可就尴尬了。
  • 关闭远端的视频。这个与远端声音静音差不多,只不过将声音改为视频了。比如当机子性能比较差的时候,为了节省资源,你可能会选择将远端的视频关闭掉。不过这种情况不是很多。
  • 关闭自己的视频。当你不想让对方看到自己的视频时,就可以选择关闭自己的视频。比如今天你的状态特别不好,你又特别在乎你的形象,此时你就可以选择关闭自己的视频。

这几个功能是实时互动直播中的必备功能。因此,在开发实时互动直播系统时一定要将这些功能添加到你的系统中,那该如何实现它们呢?

针对上面的问题,本节我们就讨论一下如何才能实现这几个功能。下面我们就按需求分别对这几个功能做详细的分析。

将远端的声音静音

要实现这个功能,你可以通过在播放端控制发送端控制两种方式实现。

  • 在播放端有两种方法,一种是不让播放器播出来,另一种是不给播放器喂数据,将收到的音频流直接丢弃。在播放端控制的优点是实现简单;缺点是虽然音频没有被使用,但它仍然占用网络带宽,造成带宽的浪费。
  • 在发送端控制也可以细分成两种方法实现,即停止音频的采集和停止音频的发送。对于 1 对 1 实时直播系统来说,这两种方法的效果是一样的。但对于多对多来说,它们的效果就大相径庭了。因为停止采集音频后,所有接收该音频的用户都不能收到音频了,这显然与需求不符;而停止向某个用户发送音频流,则符合用户的需求。

将自己的声音静音

无论是 1 对 1 实时互动,还是多人实时互动,它的含义都是一样的,就是所有人都不能听到“我”的声音。因此,你只需停止对本端音频数据的采集就可以达到这个效果。

关闭远端的视频

它与将“远端的声音静音”是类似的,要实现这个功能也是分为从播放端控制和从发送端控制两种方式。 不过它与“将远端的声音静音”也是有区别的,

那就是:

  • 从播放端控制只能使用不给播放器喂数据这一种方法,因为播放器不支持关闭视频播放的功能;
  • 从发送端控制是通过停止向某个用户发送视频数据这一种方法来实现的。而另一个停止采集则不建议使用,因为这样一来,其他端就都看不到你的视频了。

关闭自己的视频

其逻辑与“将自己的声音静音”相似。但你不应该关闭视频的采集,而应该通过关闭所有视频流的发送来实现该需求。之所以要这样,是因为视频还有本地预览,只要视频设备可用,本地预览就应该一直存在。所以,“关闭自己的视频”与“将自己的声音静音”的实现是不一样的。

代码

前面在分析基本逻辑时,我们讲过这个功能可以从播放端控制和发送端控制这两种方式来实现,而每种方式又对应两种方法,所以一共有四种方法。下面我们就来逐个分析和实现。

在播放端控制的代码特别简单,你只需要在 <video> 标签中设置 muted 即可,代码如下:

js
<HTML>
...
<video id=remote autoplay muted playsinline/>
...
</HTML>

<video>标签设置了 muted 属性后,你会发现虽然将远端获取到音视频流赋值给 <video> 标签进行播放,但最后只有视频被显示出来了,声音不播放。这样也就达到你的预期了。 其实,要想让音频播放出来也很容易,只需写一行 JavaScript 代码,将 muted 属性设置为假即可。

js
var remotevideo = document.querySelector('video#remote');
remotevideo.muted = false;

播放端控制:丢掉音频流

当然在播放端还有另外一种办法实现远端的静音,即在收到远端的音视频流后,将远端的 AudioTrack 不添加到要展示的 MediaStream 中,也就是让媒体流中不包含音频流,这样也可以起到静音远端的作用。具体代码如下:

js

var remoteVideo = document.querySelector('video#remote');
...
{
    //创建与远端连接的对象
    pc = new RTCPeerConnection(pcConfig);
  ...
    //当有远端流过来时,触发该事件
    pc.ontrack = getRemoteStream;
    ...
}
...

function getRemoteStream(e){
    //得到远端的音视频流
  remoteStream = e.streams[0];
    //找到所有的音频流
    remoteStream.getAudioTracks().forEach((track)=>{
      if (track.kind === 'audio') { //判断 track 是类型
        //从媒体流中移除音频流    
        remoteStream.removeTrack(track);
      }
    }); 
    //显示视频 
  remoteVideo.srcObject = e.streams[0];
}

在上述代码中,实现了 ontrack 事件的处理函数。在该函数中首先保存远端传来的音视频流,然后将其中的音频轨去掉,最后将流赋值给<video>标签的 srcOjbect 域,这样播放器就只能播放视频了。

发送端控制:不采集音频

通过远端不采集音频的方法也可以达静音的效果。那如何才能让远端知道你想让它静音呢?这就要通过信令通知了。本地想让远端静音时,首先向信令服务器发送一条静音指令,然后信令服务器向远端转发该指令,远端收到指令后就执行下面的代码:

js


//获取本地音视频流
function gotStream(stream) {
    localStream = stream;
    localVideo.srcObject = stream;
}

//获得采集音视频数据时限制条件
function getUserMediaConstraints() {
  
  var constraints =  { 
    "audio": false,
    "video": {
        "width": {
            "min": "640",
            "max": "1280"
        },
        "height": {
            "min": "360",
            "max": "720"
        }
    }
  };
  
  return constraints;
}

...
//采集音视频数据
function captureMedia() {
    ...
    if (localStream) {
      localStream.getTracks().forEach(track => track.stop());
    }
      ...
      //采集音视频数据的 API
    navigator.mediaDevices.getUserMedia(getUserMediaConstraints())
      .then(gotStream)
      .catch(e => {
       ...
      });
}

上面的代码非常简单,captureMedia 函数用于采集音视频数据,在它里面实际是调用的浏览器 API getUserMedia 进行具体操作的。由于这里强调的是不采集音频数据,所以你可以看到在 getUserMediaConstraints 函数中,将音频关掉了,所以最后获取到的流中只有视频数据。

发送端控制:关闭通道

通过远端关闭通道的方式也可以达到静音的效果。与方法 3 不采集音频类似,本地想让远端静音时,向信令服务器发送一条静音指令,信令服务器进行转发,远端收到指令后执行下面的代码:

js
  ...
  var localStream = null;
  
  //创建peerconnection对象
  var pc = new RTCPeerConnection(server);
  ...
  
  //获得流
  function gotStream(stream){
    localStream = stream;
  }
  ...
  
  //peerconnection 与 track 进行绑定 
  function bindTrack() {
    //add all track into peer connection
    localStream.getTracks().forEach((track)=>{
      if(track.kink !== 'audio') {
        pc.addTrack(track, localStream);
      }
    });
  }
  
  ...

在上面的代码中,在 getUserMedia 函数的回调函数中获得本地媒体流,然后在将其与 RTCPeerConnection 对象进行绑定时,对 track 做判断,如果是音频就不进行绑定,关闭了通道,这样对方就收不到音频数据了,从而达到远端静音的效果。

将自己的声音静音

将自己的声音静音只需要在采集时停止对音频数据进行采集就可以了。它与上面“将远端声音静音”中的方法 3(不采集音频)是一样的,只需将 constraints 中的 auido 属性设置为 false 就好了。这里我就不再赘述了。

关闭远端的视频 在前面讲解基本逻辑时,我们分析过关闭远端的视频有两种方法,一种是在显示端不将视频数据给 video 标签来达到不显示视频的效果,另一种是控制远端不发送数据。 实际上这两种方式与将远端声音静音中的方法 2 和方法 4 是一样的,只不过在做类型判断时,需要将 ‘audio’ 修改为 ‘video’ 就好了。因此,这里我也不再进一步介绍了。

关闭本地视频

最后一个是关闭本地视频,因不同的需求有不同的实现,一般情况下由于还涉及到本地视频的预览,所以在关闭本地视频时不是直接在采集的时候就不采集视频数据,而是不将视频数据与 RTCPeerConnection 对象进行绑定。具体的代码参考“将远端声音静音”中的方法 4。

Webrtc统计

能够统计哪些信息

在 WebRTC 中可以监控很多方面的数据,比如收了多少包、发了多少包、丢了多少包,以及每路流的流量是多少,这几个是我们平常最关心的。除此之外,WebRTC 还能监控目前收到几路流、发送了几路流、视频的宽 / 高、帧率等这些信息。

有了这些信息,你就可以评估出目前用户使用的音视频产品的服务质量是好还是坏了。当发现用户的音视频服务质量比较差时,尤其是网络带宽不足时,可以通过降低视频分辨率、减少视频帧率、关闭视频等策略来调整你的网络状况。

鉴于这些统计信息的重要性,那接下来我们来看一下,在 WebRTC 中都能监控到哪些统计信息。 在 WebRTC 中可以统计到的信息特别多,按类型大体分为以下几种:

  • inbound-rtp
  • outbound-rtp
  • data-channel

实际上,要查看 WebRTC 的统计数据,你不需要另外再开发一行代码,只要在 Chrome 浏览器下输入“chrome://webrtc-internals”这个 URL 就可以看到所有的统计信息了。但它有一个前提条件,就是你必须有页面创建了 RTCPeerConnection 对象之后,才可以通过这个 URL 地址查看相关内容。因为在 Chrome 内部会记录每个存活的 RTCPeerConnection 对象,通过上面的访问地址,就可以从 Chrome 中取出其中的具体内容。

从这张图中,你可以看到它统计到了以下信息:

  • 接收到的音频轨信息,“…Track_receiver_5…”
  • 接收到的视频轨信息,“…Track_receiver_6…”
  • 发送的音频轨信息,“…Track_sender_5…”
  • 发送的视频轨信息,“…Track_sender_6…”
  • ……

接下来,我们以接收到的视频轨信息和发送的视频轨信息为例,向你详细介绍一下这些信息中都包括了哪些内容。 首先我们来看接收到的视频轨信息,在浏览器上点开 “…receiver_6(track)” 时,你就可以看到类似于下面这张图的内容:

img

这里需要注意的是,这张图只是截取了接收到的视频轨信息的部分内容。

大体上,通过该图你就可以看到这路视频轨总共收了多少数据包、多少字节的数据,以及每秒钟接收了多少包、多少字节的数据。除此之外,你还可以看到视频从开始直播到截图时丢包的总数,以及丢包率。当然,这里面还有很多其他信息,只是由于截图的原因,就不全部展示出来了。

了解了接收到的视频轨信息的内容后,我们再来看看发送的视频轨信息。它与接收到的视频轨信息描述的内容基本相同,只不过方向是相反的,一个是接收数据,另一个是发送数据。它的信息如下图所示:

img

如何获取统计数据API

当然有! WebRTC 提供了一个非常强大的 API,即 getStats() 。通过该 API 你就可以获得上面讲述的所有信息了。 我们来看一下 getStats 的基本格式,大致如下:

js
promise = rtcPeerConnection.getStats(selector)

关于该 API,这里有几点需要向你说明:

  • getStats API 是 RTCPeerConnecton 对象的方法,用于获取各种统计信息。
  • 该方法的 selector 参数是可选的。如果为 null,则收集的是 RTCPeerConnection 对象所有相关的统计信息;当然,你也可以给它设置一个 MediaStreamTrack 类型的参数,这样它就只收集对应 track 相关的统计信息了。
  • 该函数返回 Promise 对象,如果你给 Promise 对象的 then 分支设置一个回调函数,那当 getStats 方法调用成功后,就能通过该回调函数来获取想要的统计信息了。
  • 获取到的统计信息以 RTCStatsReport 类型返回。
js
...
//获得速个连接的统计信息
pc.getStats().then( 
    //在一个连接中有很多 report
    reports => {
        //遍历每个 report
        reports.forEach( report => {
            //将每个 report 的详细信息打印出来
            console.log(report);
        });

    }).catch( err=>{
      console.error(err);
    });
);

在上面的代码中,通过调用 RTCPeerConnecction 对象的 getStats 方法,就可以获取到你想得到的所有统计信息了。 如果你在浏览器上执行上面的代码,它大体上会得到下面这样的结果:

img

  • id:对象的唯一标识,是一个字符串。
  • timestamp:时间戳,用来标识该条 Report 是什么时间产生的。
  • type:类型,是 RTCStatsType 类型,具体的类型可以到文章末尾查看参考中的内容。另外,在下一篇文章中我们还会对各种不同类型的 Report 做进一步的分析。

在上面这三个字段中,Type(类型)是最关键的。每种类型的 Report 都有它自己的格式定义,因此,当你要获取更详细的信息时,首先要判断出该 Report 的类型是什么,然后根据类型转成对应的 Report 对象,再将信息取出来。 咱们举个例子,inbound-rtp 类型的 Report 与 outbound-rtp 类型的 Report 就有着非常大的区别,通过下面这两张图的对比就能看得非常清楚了。 首先我们来看一下 inbound-rtp 类型的 Report:

img

类型消息

img

现在你已经非常清楚,通过 RTCPeerConnection 对象的 getStats 方法可以很轻松地获取到各种统计信息,比如发了多少包、收了多少包、丢了多少包,等等。但实际上对于收发包这块儿的统计还可以从其他方法获取到,即通过 RTCRtpSender 的 getStats 方法和 RTCRtpReceiver 的 getStats 方法也能获取收发包的统计信息。

也就是说,除了 RTCPeerConnection 对象有 getStats 方法外,RTCRtpSender 和 RTCRtpReceiver 对象也有 getStats 方法,只不过它们只能获取到与传输相关的统计信息,而 RTCPeerConnection 还可以获取到其他更多的统计信息。

下面我们就来看一下它们三者之间的区别:

  • RTCPeerConnection 对象的 getStats 方法获取的是所有的统计信息,除了收发包的统计信息外,还有候选者、证书、编解码器等其他类型的统计信息。
  • RTCRtpSender 对象的 getStats 方法只统计与发送相关的统计信息。
  • RTCRtpReceiver 对象的 getStats 方法则只统计与接收相关的统计信息。

下面咱们通过一段示例代码来详细看看它们之间的不同:

js

var pc = new RTCPeerConnection(null);


pc.getStats()
  .then( reports => { //得到相关的报告
    reports.forEach( report => { //遍历每个报告
      console.log(report);
    });
  }).catch( err=>{
    console.error(err);
  });

//从 PC 上获得 sender 对象
var sender = pc.getSenders()[0];

...

//调用sender的 getStats 方法    
sender.getStats()
    .then(reports => { //得到相关的报告
        reports.forEach(report =>{ //遍历每个报告
            if(report.type === 'outbound-rtp'){ //如果是rtp输出流
            ....
            }
        }
     );

在上面的代码中生成了两段统计信息,一段是通过 RTCPeerConnection 对象的 getStats 方法获取到的,其结果如下:

img

另一段是通过 RTCRtpSender 对象的 getStats 方法获取到的,其结果如下:

img

通过对上面两幅图的对比你可以发现,RTCPeerConnection 对象的 getStats 方法获取到的统计信息明显要比 RTCRtpSender 对象的 getStats 方法获取到的信息多得多。这也证明了我们上面的结论,即 RTCPeerConnection 对象的 getStas 方法获取到的信息与 RTCRtpSender 对象的 getStats 方法获取的信息之间是整体与局部的关系。

RTCStatsReport

我们通过 getStats API 可以获取到 WebRTC 各个层面的统计信息,它的返回值的类型是 RTCStatsReport。

RTCStatsReport 的结构如下:

js
interface RTCStatsReport {
  readonly maplike<DOMString, object>;
};

即 RTCStatsReport 中有一个 Map,Map 中的 key 是一个字符串,object 是 RTCStats 的继承类。

RTCStats 作为基类,它包括以下三个字段。

  • id:对象的唯一标识,是一个字符串。
  • timestamp:时间戳,用来标识该条 Report 是什么时间产生的。
  • type:类型,是 RTCStatsType 类型,它是各种类型 Report 的基类。

而继承自 RTCStats 的子类就特别多了,下面我挑选其中的一些子类向你做下介绍。

第一种,编解码器相关的统计信息,即 RTCCodecStats。其类型定义如下:

js
dictionary RTCCodecStats : RTCStats {
             unsigned long payloadType; //数据负载类型
             RTCCodecType  codecType;   //编解码类型
             DOMString     transportId; //传输ID
             DOMString     mimeType;    
             unsigned long clockRate;   //采样时钟频率
             unsigned long channels;    //声道数,主要用于音频
             DOMString     sdpFmtpLine; 
             DOMString     implementation;
};

通过 RTCCodecStats 类型的统计信息,你就可以知道现在直播过程中都支持哪些类型的编解码器,如 AAC、OPUS、H264、VP8/VP9 等等。

第二种,输入 RTP 流相关的统计信息,即 RTCInboundRtpStreamStats。其类型定义如下:

js
dictionary RTCInboundRtpStreamStats : RTCReceivedRtpStreamStats {
            ...
             unsigned long        frameWidth;     //帧宽度
             unsigned long        frameHeight;    //帧高度
             double               framesPerSecond;//每秒帧数
             ...
             unsigned long long   bytesReceived;  //接收到的字节数
             .... 
             unsigned long        packetsDuplicated; //重复的包数
             ...
             unsigned long        nackCount;         //丢包数
             .... 
             double               jitterBufferDelay; //缓冲区延迟
             .... 
             unsigned long        framesReceived;    //接收的帧数
             unsigned long        framesDropped;     //丢掉的帧数
             ...
            };

通过 RTCInboundRtpStreamStats 类型的统计信息,你就可以从中取出接收到字节数、包数、丢包数等信息了。

第三种,输出 RTP 流相关的统计信息,即 RTCOutboundRtpStreamStats。其类型定义如下:

js
dictionary RTCOutboundRtpStreamStats : RTCSentRtpStreamStats {
             ...
             unsigned long long   retransmittedPacketsSent; //重传包数
             unsigned long long   retransmittedBytesSent; //重传字节数
             double               targetBitrate;  //目标码率
             ...
.             
             unsigned long        frameWidth;  //帧的宽度
             unsigned long        frameHeight; //帧的高度
             double               framesPerSecond; //每秒帧数
             unsigned long        framesSent; //发送的总帧数
             ...
             unsigned long        nackCount; //丢包数
             .... 
};

通过 RTCOutboundRtpStreamStats 类型的统计信息,你就可以从中得到目标码率、每秒发送的帧数、发送的总帧数等内容了。

在 WebRTC 1.0 规范中,一共定义了 17 种 RTCStats 类型的子类,这里我们就不一一进行说明了。关于这 17 种子类型,你可以到文末的参考中去查看。实际上,这个表格在上一篇文章中我已经向你做过介绍了,这里再重新温习一下。

RTCP 交换统计信息

实际上在 WebRTC 中,上面介绍的输入 / 输出 RTP 流报告中的统计数据都是通过 RTCP 协议中的 SR、RR 消息计算而来的。

在 RTCP 协议中,SR 是发送方发的,记录的是 RTP 流从发送到现在一共发了多少包、发送了多少字节数据,以及丢包率是多少。RR 是接收方发的,记录的是 RTP 流从接收到现在一共收了多少包、多少字节的数据等。

通过 SR、RR 的不断交换,在通讯的双方就很容易计算出每秒钟的传输速率、丢包率等统计信息了。

在使用 RTCP 交换信息时有一个主要原则,就是 RTCP 信息包在整个数据流的传输中占带宽的百分比不应超过 5%。也就是说你的媒体包发送得越多,RTCP 信息包发送得也就越多。你的媒体包发得少,RTCP 包也会相应减少,它们是一个联动关系。

绘制图形

通过 getStats 方法我们现在可以获取到各种类型的统计数据了,而且在上面的 RTCP 交换统计信息中,我们也知道了 WebRTC 底层是如何获取到传输相关的统计数据的了,那么接下来我们再来看一下如何利用 RTCStatsReport 中的信息来绘制出各种分析图形,从而使监控的数据更加直观地展示出来。

要将 Report 转化为图形大体上分为以下几个步骤:

  • 引入第三方库 graph.js;
  • 启动一个定时器,每秒钟绘制一次图形;
  • 在定时器的回调函数中,读取 RTCStats 统计信息,转化为可量化参数,并将其传给 graph.js 进行绘制。

第三方库 graph.js 是由 WebRTC 项目组开发的,是专门用于绘制各种图形的,它底层是通过 Canvas 来实现的。这个库非常短小,只有 600 多行代码,使用起来也非常方便,在下面的代码中会对它的使用做详细的介绍。

引入第三方库

在 JavaScript 中引入第三方库也非常简单,只要使用

js
<html>
  ...
  <body>
  ...
  <script src="js/client.js"></script>
  
  //引入第三方库 graph.js
  <script src="js/third_party/graph.js"></script>
  ...
  </body>
</html>

client.js

js
...

var pc = null;

//定义绘制比特率图形相关的变量
var bitrateGraph;
var bitrateSeries;

//定义绘制发送包图形相关的变理
var packetGraph;
var packetSeries;
...

pc = new RTCPeerConnection(null);

...

//bitrateSeries用于绘制点
bitrateSeries = new TimelineDataSeries();
//bitrateGraph用于将bitrateSeries绘制的点展示出来
bitrateGraph = new TimelineGraphView('bitrateGraph', 'bitrateCanvas');
bitrateGraph.updateEndDate(); //绘制时间轴

//与上面一样,只不是用于绘制包相关的图
packetSeries = new TimelineDataSeries();
packetGraph = new TimelineGraphView('packetGraph', 'packetCanvas');
packetGraph.updateEndDate();

...

//每秒钟获取一次 Report,并更新图形
window.setInterval(() => {

  if (!pc) { //如果 pc 没有创建直接返回
    return;
  }

  //从 pc 中获取发送者对象
  const sender = pc.getSenders()[0];
  if (!sender) {
    return;
  }

  sender.getStats().then(res => { //获取到所有的 Report
    res.forEach(report => { //遍历每个 Report
      let bytes;
      let packets;

      //我们只对 outbound-rtp 型的 Report 做处理
      if (report.type === 'outbound-rtp') { 
        if (report.isRemote) { //只对本地的做处理
          return;
        }

        const now = report.timestamp;
        bytes = report.bytesSent; //获取到发送的字节
        packets = report.packetsSent; //获取到发送的包数

        //因为计算的是每秒与上一秒的数据的对比,所以这里要做个判断
        //如果是第一次就不进行绘制
        if (lastResult && lastResult.has(report.id)) {
          
          //计算这一秒与上一秒之间发送数据的差值
          var mybytes= (bytes - lastResult.get(report.id).bytesSent);
          //计算走过的时间,因为定时器是秒级的,而时间戳是豪秒级的
          var mytime = (now - lastResult.get(report.id).timestamp);
          const bitrate = 8 * mybytes / mytime * 1000; //将数据转成比特位

          //绘制点
          bitrateSeries.addPoint(now, bitrate);
          //将会制的数据显示出来
          bitrateGraph.setDataSeries([bitrateSeries]);
          bitrateGraph.updateEndDate();//更新时间

          //下面是与包相关的绘制
          packetSeries.addPoint(now, packets -
                               lastResult.get(report.id).packetsSent);
          packetGraph.setDataSeries([packetSeries]);
          packetGraph.updateEndDate();
        }
      }
    });

    //记录上一次的报告
    lastResult = res;

  });

}, 1000); //每秒钟触发一次

...

在该代码中,最重要的是 32~89 行的代码,因为这其中实现了一个定时器——每秒钟执行一次。每次定时器被触发时,都会调用 sender 的 getStats 方法获取与传输相关的统计信息。

然后对获取到的 RTCStats 类型做判断,只取 RTCStats 类型为 outbound-rtp 的统计信息。最后将本次统计信息的数据与上一次信息的数据做差值,从而得到它们之间的增量,并将增量绘制出来。

最终结果

当运行上面的代码时,会绘制出下面的结果,这样看起来就一目了然了。通过这张图你可以看到,当时发送端的码率为 1.5Mbps 的带宽,每秒差不多发送小 200 个数据包。

img

3. 进阶