This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
<audio id="audio" title="01 | 原来通过浏览器访问摄像头这么容易" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/20/5a/20a3b85bed43e9bbec6ca0bf1da5b05a.mp3"></audio>
对于很多从事 JavaScript 开发的同学来说,基本都认为 JavaScript 是专门做页面控制的。如果用 JavaScript 做音视频处理那真是很难想象的事儿。你可能首先想到的问题是JavaScript或者浏览器的性能跟得上吗
而 Google 却不这么认为。 Google 就是要做一些常人无法想象,又难以理解的事情,否则它就不是 Google 了。
“**浏览器 + WebRTC**”就是 Google 给出的答案。2011年Google 创立了 WebRTC 项目,其愿景就是可以在浏览器之间快速地实现音视频通信。
随着 WebRTC 1.0 规范的推出现在主流浏览器Chrome、Firefox、Safari以及 Edge 都已经支持了WebRTC库。换句话说**在这些浏览器之间进行实时音视频通信已经很成熟了**。
下面我就通过讲解JavaScript/浏览器访问电脑上的音视频设备,向你展示通过现代浏览器访问音视频设备是何其简单。
## WebRTC处理过程
在正式讲解如何通过浏览器采集音视频数据之前,我先向你介绍一下 WebRTC 实现一对一音视频实时通话的整个处理过程。对这个过程的了解,可以帮助你在阅读文章时,能清楚明了地知道所阅读的这篇文章、所要学习的知识点在整个处理过程中的位置。
<img src="https://static001.geekbang.org/resource/image/c5/a0/c536a1dd0ed50008d2ada594e052d6a0.png" alt="">
上面这幅图是整个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 对象传输自己一端的数据,并从另一端接收数据。
以上,就是这幅图大体所描述的含义。而本文要重点介绍的内容就是 WebRTC 终端中的音视频采集部分。
## 音视频采集基本概念
在正式介绍 JavaScript 采集音视频数据的 API 之前你还需要了解一些基本概念。这些概念虽然都不难理解但在后面讲解API 时都会用到它们,很是重要,所以在这里我还是给你着重汇总和强调下。
- **摄像头**。用于捕捉(采集)图像和视频。
- **帧率**。现在的摄像头功能已非常强大,一般情况下,一秒钟可以采集 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。其中[媒体流](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia)可以存放0个或多个音频轨或视频轨数据流可以存0个或多个数据轨。
## 音视频采集
有了上面这些基本概念,你就可以很容易理解后面所要讲的内容了。接下来,就让我们来具体看看在浏览器下采集音视频的 API 格式以及如何控制音视频的采集吧。
### 1. getUserMedia 方法
在浏览器中访问音视频设备非常简单,只要调用**getUserMedia**这个 API 即可。该 API 的基本格式如下:
```
var promise = navigator.mediaDevices.getUserMedia(constraints);
```
它返回一个**Promise**对象。
- 如果**getUserMedia**调用成功,则可以通过 Promise 获得**MediaStream**对象,也就是说现在我们已经从音视频设备中获取到音视频数据了。
- 如果调用失败,比如用户拒绝该 API 访问媒体设备(音频设备、视频设备),或者要访问的媒体设备不可用,则返回的 Promise 会得到 PermissionDeniedError 或 NotFoundError 等错误对象。
### 2. MediaStreamConstraints 参数
从上面的调用格式中可以看到,**getUserMedia**方法有一个输入参数**constraints**,其类型为 **MediaStreamConstraints**。它可以指定**MediaStream**中包含哪些类型的媒体轨(音频轨、视频轨),并且可为这些媒体轨设置一些限制。
下面我们就来详细看一下它包括哪些限制这里我引用一下WebRTC 1.0 规范对 [MediaStreamConstraints](https://w3c.github.io/mediacapture-main/getusermedia.html#mediastreamconstraints)的定义,其格式如下:
```
dictionary MediaStreamConstraints {
(boolean or MediaTrackConstraints) video = false,
(boolean or MediaTrackConstraints) audio = false
};
```
从上面的代码中可以看出,该结构可以指定采集音频还是视频,或是同时对两者进行采集。
举个例子,比如你只想采集视频,则可以像下面这样定义 constraints
```
const mediaStreamContrains = {
video: true
};
```
或者,同时采集音视和视频:
```
const mediaStreamContrains = {
video: true,
audio: true
};
```
其实,你还可以通过 MediaTrackConstraints 进一步对每一条媒体轨进行限制,比如下面的代码示例:
```
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对于音频则是开启回音消除、降噪以及自动增益功能。
除了上面介绍的这些参数来控制摄像头和麦克风外,当然还有其他一些参数可以设置,更详细的参数信息,可以跳到下面的参考部分。
通过上面的这些方式就可以很方便地控制音视频的设备了,是不是非常简单?
## 如何使用 getUserMedia API
接下来,我们看一下如何使用上面介绍的 API 来采集视频数据吧。
下面的HTML代码非常简单它引入一段JavaScript代码用于捕获音视频数据然后将采集到的音视频数据通过 video 标签播放出来。
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Realtime communication with WebRTC&lt;/title&gt;
&lt;link rel=&quot;stylesheet&quot;, href=&quot;css/client.css&quot; /&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Realtime communication with WebRTC &lt;/h1&gt;
&lt;video autoplay playsinline&gt;&lt;/video&gt;
&lt;script src=&quot;js/client.js&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
为便于你更好地理解该部分的知识,上面这段代码中有两条代码我需要解释一下,一句是:
```
&lt;video autoplay playsinline&gt;&lt;/video&gt;
```
它是HTML5的视频标签不仅可以播放多媒体文件还可以用于播放采集到的数据。其参数含义如下
- **autoplay**,表示当页面加载时可以自动播放视频;
- **playsinline**表示在HTML5页面内播放视频而不是使用系统播放器播放视频。
另一句是:
```
&lt;script src=&quot;js/client.js&quot;&gt;&lt;/script&gt;
```
它引入了外部的 JavaScript 代码,起到的作用就是获取视频数据。具体代码如下:
```
'use strict';
const mediaStreamContrains = {
video: true
};
const localVideo = document.querySelector('video');
function gotLocalMediaStream(mediaStream){
localVideo.srcObject = mediaStream;
}
function handleLocalMediaStreamError(error){
console.log('navigator.getUserMedia error: ', error);
}
navigator.mediaDevices.getUserMedia(mediaStreamContrains).then(
gotLocalMediaStream
).catch(
handleLocalMediaStreamError
);
```
通过上面的代码,我们就可以采集到视频数据并将它展示在页面上了,很简单吧!接下来,我们来大体看一下它的逻辑。
JavaScript 代码中首先执行**getUserMedia()**方法,该方法会请求访问 Camera。如果是第一次请求 Camera浏览器会向用户弹出提示窗口让用户决定是否可以访问摄像头。如果用户允许访问且设备可用则调用 gotLocalMediaStream 方法。
在gotLocalMediaStream方法中其输入参数为**MediaStream**对象,该对象中存放着**getUserMedia**方法采集到的音视频**轨**。我们将它作为视频源赋值给 HTML5 的 video 标签的 srcObject 属性。这样在 HTML 页面加载之后,就可以在该页面中看到摄像头采集到的视频数据了。
在这个例子中,**getUserMedia**方法的输入参数**mediaStreamContraints**限定了只采集视频数据。同样的,你也可以采集音频数据或同时采集音频和视频数据。
## 小结
在 WebRTC 中,**MediaTrack**和**MediaStream**这两个概念特别重要,后续学习 WebRTC的过程中我们会反复用到所以在这最开始你就要理解透这两个概念。举个例子如果你想在一个房间里同时共享视频、共享音频、共享桌面该怎么做呢如果你对 MediaTrack 和 MediaStream 真正理解了就会觉得WebRTC处理这种情况太简单了。
另外,在本文中我重点介绍了**getUserMedia**这个API**它是 WebRTC 几个核心API之一你必须熟练掌握它**。因为通过它,你可以对音视频设备做各种各样的控制,例如,是采集音频,还是采集视频?视频的分辨率是多少?帧率是多少?音频的采样率是多少?
当然特别关键的一点是可以通过该API开启**回音消除**。回音消除问题是所有做实时互动直播系统最难解决的问题之一。对于 JavaScript 开发同学来说现在只需要调用该API时将回音消除选项打开就可以了一下子解决了世界难题。
最后,我还通过一个例子向你具体展示了视频采集后的效果。相信通过这些讲解和展示,你应该已经感受到目前浏览器的强大,以及它可以做更多、更有意思的音视频相关的事情了。
这里你也可以看一下我做出来的效果图(没有美颜):
<img src="https://static001.geekbang.org/resource/image/2a/4f/2a063c7520647f37e3a97a3558fff34f.png" alt="">
## 思考时间
上面我们一起学习了如何通过**getUserMedia**获取到音视频数据。而在真实的场景中,我们往往不但要获取到默认设备的音视频数据,还要能获取到**某个指定的设备**的音视频数据。比如,手机上一般都有两个摄像头——前置摄像头和后置摄像头。那么,你有没有办法采集到指定摄像头的视频数据呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
另外为便于你更好地学习我还做了个Demo放在了[GitHub上有需要可点这里](https://github.com/avdance/webrtc_web/tree/master/01_mediastream)。
## 参考
getUserMedia API 控制设备的参数及其含义如下:
<img src="https://static001.geekbang.org/resource/image/f3/8a/f3d578d13b4c21c83b161dae348b8c8a.png" alt="">

View File

@@ -0,0 +1,202 @@
<audio id="audio" title="02 | 如何通过WebRTC进行音视频设备检测呢" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/ad/e875371b21b089b3f37fa3151a2e73ad.mp3"></audio>
使用过音视频会议或在线教育等实时互动软件的同学都知道在打开摄像头Camera或麦克风Micphone的时候首先要对其进行检测检测的内容包括
- 电脑/手机上都有那些音视频设备?
- 我们选中的音视频设备是否可用?
以手机为例,它一般会包括前置摄像头和后置摄像头。我们可以根据自己的需要,选择打开不同的摄像头。当然,手机上不单有多个摄像头,麦克风的种类就更多了, 如:
- 系统内置麦克
- 外插的耳机
- 蓝牙耳机
- ……
以上这些设备,我们都可以通过手动或自动的方式打开它们。
那么WebRTC是否提供了相关的接口以便我们查询自己机子上都有哪些音视频设备呢答案是肯定的。
下面我们就来看看如何使用浏览器下 WebRTC API 来显示我们的音视频设备吧。
## WebRTC处理过程
在正式讲解之前咱们先回顾一下WebRTC 的整体处理过程图,以便让你清楚地知道咱们这篇文章在整个处理过程中的位置。
<img src="https://static001.geekbang.org/resource/image/e9/1c/e9a2fd3adee1568e4171addce5b64a1c.png" alt="">
上面这幅图与第一篇文章中的图几乎是一模一样的。其差别在于,我将图中两个**音视频设备检测模块**置红了。
这样,我们本文所讲的内容在整个 WebRTC 处理过程中的位置就一目了然了!
## 音视频设备的基本原理
既然说到音视频设备,那么我们再顺便介绍一下音视频设备的基本工作原理,对于这些设备工作原理的了解,会为你后续学习音视频相关知识提供很大的帮助。
### 1. 音频设备
音频有**采样率**和**采样大小**的概念,实际上这两个概念就与音频设备密不可分。
音频输入设备的主要工作是采集音频数据而采集音频数据的本质就是模数转换A/D即将模似信号转换成数字信号。
模数转换使用的采集定理称为**奈奎斯特定理**,其内容如下:
>
在进行模拟/数字信号的转换过程中当采样率大于信号中最高频率的2倍时采样之后的数字信号就完整地保留了原始信号中的信息。
你也知道,人类听觉范围的频率是 20Hz20kHz 之间。对于日常语音交流像电话8kHz 采样率就可以满足人们的需求。但为了追求高品质、高保真,你需要将音频输入设备的采样率设置在 40kHz 以上这样才能完整地将原始信号保留下来。例如我们平时听的数字音乐一般其采样率都是44.1k、48k等以确保其音质的无损。
采集到的数据再经过量化、编码,最终形成数字信号,这就是音频设备所要完成的工作。在量化和编码的过程中,采样大小(保存每个采样的二进制位个数)决定了每个采样最大可以表示的范围。如果采样大小是 8 位则它表示的最大值是就是2<sup>8</sup> -1即 255如果是 16 位,则其表示的最大数值是 65535。
### 2. 视频设备
至于视频设备则与音频输入设备很类似。当实物光通过镜头进行到摄像机后它会通过视频设备的模数转换A/D模块即光学传感器 将光转换成数字信号,即 RGBRed、Green、Blue数据。
获得 RGB 数据后,还要通过 DSPDigital Signal Processer进行优化处理如自动增强、白平衡、色彩饱和等都属于这一阶段要做的事情。
通过 DSP 优化处理后你就得到了24位的真彩色图片。因为每一种颜色由 8 位组成而一个像素由RGB三种颜色构成所以一个像素就需要用24位表示故称之为**24位真彩色**。
另外,此时获得的 RGB 图像只是临时数据。因最终的图像数据还要进行压缩、传输,而编码器一般使用的输入格式为 YUV I420所以在摄像头内部还有一个专门的模块用于将 RGB 图像转为 YUV 格式的图像。
那什么是YUV呢YUV也是一种色彩编码方法主要用于电视系统以及模拟视频领域。它将亮度信息Y与色彩信息UV分离即使没有UV信息一样可以显示完整的图像只不过是黑白的这样的设计很好地解决了彩色电视机与黑白电视的兼容问题。
YUV格式还是蛮复杂的它有好几种存储方式需要用一整篇的文章来详述才行。所以在这里我就不一一描述了如果你想进一步了解其相关知识可以到网上搜索相关资料自行学习。
通过上面的讲解,现在你应该对音频设备与视频设备都有一个基本的认知了。
## WebRTC设备管理的基本概念
在讲解如何通过浏览器的 WebRTC API 获取音视频设备之前咱们先了解几个WebRTC 关于设备的基本概念。如果这些基本概念不清楚的话,就很难理解后面的知识。
[MediaDevices](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices),该接口提供了访问(连接到计算机上的)媒体设备(如摄像头、麦克风)以及截取屏幕的方法。实际上,它允许你访问任何硬件媒体设备。而**咱们要获取可用的音视频设备列表,就是通过该接口中的方法来实现的**。
[MediaDeviceInfo](https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo),它表示的是每个输入/输出设备的信息。包含以下三个重要的属性:
- **deviceID**,设备的**唯一标识**
- **label**,设备**名称**
- **kind**,设备**种类**,可用于识别出是音频设备还是视频设备,是输入设备还是输出设备。
需要注意的是,出于安全原因,**除非用户已被授予访问媒体设备的权限要想授予权限需要使用HTTPS请求否则label字段始终为空**。
**另外label 可以用作指纹识别机制的一部分,以识别是否是合法用户**。对于这一点我们以后再专门讨论。
[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)它是一种JavaScript异步处理机制。其思想是首先执行指定的业务逻辑而不管逻辑的对错然后再根据结果做具体的操作如果成功了做些什么失败了做些什么。结合下面的例子可以让你对 **Promise** 有个清楚的认识,生成 **Promise** 对象时,首先会执行 **function** 函数中的逻辑,该函数会根据随机数生成 timeOut然后定时地对 timeOut 做出判断:
- 如果 timeOut 小于1则调用 resolve 方法。resolve 又会调用 **Promise****then** 部分传入的函数。
- 如果 timeOut 大于等于1则调用 reject 方法。reject 则会调用 **Promise****catch** 部分传入的函数。
```
new Promise(function (resolve, reject) {
console.log('start new Promise...');
//产生随机值
var timeOut = Math.random() * 2;
console.log('set timeout to: ' + timeOut + ' seconds.');
//设置一个定时器函数,根据随机值触发该函数执行
setTimeout(function () {
if (timeOut &lt; 1) {
console.log('call resolve()...');
resolve('200 OK');
}
else {
console.log('call reject()...');
reject('timeout in ' + timeOut + ' seconds.');
}
}, timeOut * 1000);
}).then(function (r) {
console.log('Done: ' + r);
}).catch(function (reason) {
console.log('Failed: ' + reason);
});
```
## 获取音视频设备列表
有了上面这些基础知识你就很容易理解下面的内容了。首先我们来看浏览器上WebRTC 获取音视频设备列表的接口,其格式如下:
```
MediaDevices.enumerateDevices()
```
通过调用 **MediaDevices** 的 [enumerateDevices()](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices) 方法就可以获取到媒体输入和输出设备列表,例如: 麦克风、相机、耳机等。是不是非常简单?
该函数返回的是一个 **Promise** 对象。我们只需要向它的 **then** 部分传入一个函数,就可以通过该函数获得所有的音视频设备信息了。
传入的函数有一个参数,它是一个 **MediaDeviceInfo** 类型的数组,用来存放 WebRTC 获取到的每一个音视频设备信息。
这样说可能有点抽象,还是让我们结合下面代码看一个具体的例子吧。
```
...
//判断浏览器是否支持这些 API
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
console.log(&quot;enumerateDevices() not supported.&quot;);
return;
}
// 枚举 cameras and microphones.
navigator.mediaDevices.enumerateDevices()
.then(function(deviceInfos) {
//打印出每一个设备的信息
deviceInfos.forEach(function(deviceInfo) {
console.log(deviceInfo.kind + &quot;: &quot; + deviceInfo.label +
&quot; id = &quot; + deviceInfo.deviceId);
});
})
.catch(function(err) {
console.log(err.name + &quot;: &quot; + err.message);
});
```
总结起来,上面的代码中做了以下几件事儿:
- 首先,判断浏览器是否支持 MediaDevice 接口(老版本浏览器可能不支持)。
- 如果支持,则调用`navigator.mediaDevices.enumerateDevices()`方法获取音视频设备列表,该方法会返回一个 **Promise** 对象。
- 如果返回 **Promise** 对象成功则执行then中的函数。而**then**分支中的函数非常简单它遍历每一个MediaDeviceInfo并将每个 MediaDeviceInfo 中的基本信息打印出来,也就是我们想要的每个音视频设备的基本信息。
- 但如果失败的话,则执行 **catch** 中的函数。
通过上面的介绍,你是不是觉得在浏览器上检测音视频设备非常简单呢?
## 设备检测
在获取到电脑/手机上的所有设备信息后,我们就可以对设备的可用性做真正的检测了。在我们的设备列表中,可以通过[MediaDeviceInfo](https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo)结构中的**kind**字段,将设备分类为音频设备或视频设备。
如果再细分的话,还可以通过 kind 字段再将音视设备分为**输入设备**和**输出设备**。如我们平时使用的耳机,从大的方面说它是一个音频设备,但它同时兼有音频输入设备和音频输出设备的功能。
对于区分出的音频设备和视频设备,每种不同种类的设备还会设置各自的**默认设备**。还是以耳机这个音频设备为例,将耳机插入电脑后,耳机就变成了音频的默认设备;将耳机拔出后,默认设备又切换成了系统的音频设备。
因此,在获取到所有的设备列表后,如果我们不指定某个具体设备,直接调用[《01 | 原来通过浏览器访问摄像头这么容易》](https://time.geekbang.org/column/article/107948)一文中所介绍的 getUserMedia API 来采集音视频数据时,它就会从设备列表中的默认设备上采集数据。当然,我们是可以通过 MediaDeviceInfo 中的 deviceID 字段来指定从哪个具体设备采集数据的,不过这就是后话了。
如果我们能从指定的设备上采集到音视频数据,那说明这个设备就是有效的设备。我们在排查设备问题的时候,就可以利用上面的方法,对每个设备都一项一项进行检测,即**先排查视频设备,然后再排查音频设备**。因此,需要**调用两次 getUserMedia API 进行设备检测**。
第一次,调用 getUserMedia API 只采集视频数据并将其展示出来。如果用户能看到自己的视频,说明视频设备是有效的;否则,设备无效,可以再次选择不同的视频设备进行重新检测。
第二次,如果用户视频检测通过了,再次调用 getUserMedia API 时则只采集音频数据。由于音频数据不能直接展示所以需要使用JavaScript中的 AudioContext 对象,将采集到的音频计算后,再将其绘制到页面上。这样,当用户看到音频数值的变化后,说明音频设备也是有效的。
通过以上步骤,我们就完成了对指定音视频设备的检测工作。
## 小结
在本文中我主要向你介绍了如何通过浏览器中的WebRTC接口获取自己机子上的音视频设备列表。通过上面的描述你应该可以了解到在浏览器下只需要调用 enumerateDevices 方法,就可以轻松获取设备信息了。
这个看似简单的接口其实在WebRTC底层做了大量的工作。你可以思考这样一个case当我们在获取设备列表时用户将设备从电脑上拔下来了此时设备列表会发生怎样的变化呢如果设备被拔出后设备列表不发生变化那么用户正好选择使用被拔下的设备又会怎样呢
你还可以顺着这个思路想下去,当我们正在使用某个视频或音频设备的时候,由于某种原因,设备失效了(设备坏了或被拔出了),这种情况下又该怎样处理呢?一般情况下,我们想到的可能是提示设备出错的消息。但如果电脑中还有其他设备可用呢?是不是可以自动切到另外一个可用的设备上呢?另外,假如有多个设备同时可用,此时该如何切换设备呢?这听上去是不是就很棘手?
但不用担心我说的这些问题WebRTC已经处理得非常好有兴趣的同学可以自行验证一下 WebRTC的具体行为是怎样的。所以从这一点我们也可以看出 WebRTC 所做出的努力。
## 思考时间
在获取音视频设备时,我们可以得到机子上(电脑/手机等)的所有音视频设备。每个设备都有 deviceID 字段,那么你可以想一想这个字段在真实场景中是如何通过 deviceID进行设备切换的呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
[所做Demo的GitHub链接有需要可以点这里](https://github.com/avdance/webrtc_web/tree/master/02_device/webserver)

View File

@@ -0,0 +1,286 @@
<audio id="audio" title="03 | 如何使用浏览器给自己拍照呢?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/7c/6695e7bc114de236a23942693233777c.mp3"></audio>
在之前的文章中,我向你介绍了如何在浏览器中利用 WebRTC 采集音视频数据。那么,是否可以通过相同的技术进行拍照呢?没错,这是完全可行的。
现代的浏览器功能越来越强大,你不光可以通过它进行拍照,而且还可以对拍下来的照片进行各种滤镜处理。
为了实现上述功能,你需要了解并掌握以下三个知识点:
- 如何从采集到的视频中获取到图片?
- 如何将处理后的图片保存成文件?
- 如何对获取到的图片进行滤镜处理?
这三方面的知识点就是本文要交付的重点内容。下面我们先学习与之相关的基础知识和原理,然后再对这几个知识点逐一进行讲解,各个击破。
## 在WebRTC处理过程中的位置
在正式进入主题之前,咱们仍然按老规矩,看看本篇文章所介绍的内容在整个 WebRTC 处理过程中的位置。如下图所示:
<img src="https://static001.geekbang.org/resource/image/c5/a0/c536a1dd0ed50008d2ada594e052d6a0.png" alt="">
你可以看到,这张图与[《01 | 原来通过浏览器访问摄像头这么容易》](https://time.geekbang.org/column/article/107948)文章中的图一模一样。没错,咱们本篇文章所涉及的知识点仍然属于音视频数据采集的部分。
## 基础知识
在正式讲解如何进行**拍照**之前,你需要先了解非编码帧(解码帧)和编码帧这两个知识点,这会有利于你对后面拍照实现内容的理解。
### 1. 非编码帧
好多人小时候应该都学过,在几张空白的纸上画同一个物体,并让物体之间稍有一些变化,然后连续快速地翻动这几张纸,它就形成了一个小**动画**。
**音视频播放器**就是利用这样的原理来播放音视频文件的。当你要播放某个视频文件时,播放器会按照一定的时间间隔连续地播放从音视频文件中**解码后的视频帧**,这样视频就动起来了。同理,播放从摄像头获取的视频帧也是如此,只不过从摄像头获取的本来就是**非编码视频帧**,所以就不需要解码了。
通过上面的描述,你应该能得到以下两点信息:
- 播放的视频帧之间的时间间隔是非常小的。如按每秒钟20帧的帧率计算每帧之间的间隔是50ms。
- 播放器播的是**非编码帧(解码后的帧)**,这些非编码帧就是一幅幅独立的图像。
从摄像头里采集的帧或通过解码器解码后的帧都是**非编码帧**。非编码帧的格式一般是YUV 格式或是 RGB 格式。关于 YUV 与 RGB 的相关知识,我在[上一篇文章](https://time.geekbang.org/column/article/108491)中已向你做过简要介绍,这里就不再赘述了。
### 2. 编码帧
相对于**非编码帧**,通过编码器(如 H264/H265、VP8/VP9压缩后的帧称为**编码帧**。这里我们以 H264 为例经过H264编码的帧包括以下三种类型。
- **I 帧**:关键帧。压缩率低,可以单独解码成一幅完整的图像。
- **P 帧**:参考帧。压缩率较高,解码时依赖于前面已解码的数据。
- **B 帧**前后参考帧。压缩率最高解码时不光依赖前面已经解码的帧而且还依赖它后面的P帧。换句话说就是**B帧后面的P帧要优先于它进行解码然后才能将 B 帧解码**。
关于编码这块的内容,目前你只需了解上面这些知识即可。
通过上面的介绍,现在你应该已经清楚地知道了:**从播放器里获取的视频帧一定是非编码帧。也就是说,拍照的过程其实是从连续播放的一幅幅画面中抽取正在显示的那张画面**。
## 如何获取视频流
在获得照片之前你首先要通过浏览器的API获取视频流并通过 HTML5 的 `&lt;video&gt;` 标签将视频播放出来。实际上,这些知识我已经在[《01 | 原来通过浏览器访问摄像头这么容易》](https://time.geekbang.org/column/article/107948)中介绍过了,这里我就不做过多的描述了。咱们还是直接上代码吧。
HTML 部分代码如下:
```
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;WebRTC take picture&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;video autoplay playsinline id=&quot;player&quot;&gt;
&lt;script src=&quot;./js/client.js&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
上面这段代码很简单,就是定义了一个 video 标签,用于播放从摄像头获取到的视频流。另外,它还引入了一段 JavaScript 脚本:
```
'use strict'
//获取HTML页面中的video标签
var videoplay = document.querySelector('video#player');
//播放视频流
function gotMediaStream(stream){
videoplay.srcObject = stream;
}
function handleError(err){
console.log('getUserMedia error:', err);
}
//对采集的数据做一些限制
var constraints = {
video : {
width: 1280,
height: 720,
frameRate:15,
},
audio : false
}
//采集音视频数据流
navigator.mediaDevices.getUserMedia(constraints)
.then(gotMediaStream)
.catch(handleError);
```
在这段脚本中,我们调用了之前所讲的**getUserMedia**方法,该方法会打开摄像头,并通过它采集音视频流。然后再将采集到的视频流赋值给 HTML 中定义的**video**标签的**srcObject**字段,这样**video**标签就可以从摄像头源源不断地获得视频帧,并将它播放出来了。
以上这些内容,你应该都非常熟悉了。下面的关键点是,获取到视频流后如何从中获取正在显示的视频帧或图片呢?现在就让我们来解决这个问题吧!
## 如何拍照
实际上,浏览器提供了一个非常强大的对象,称为**Canvas**。你可以把它想像成一块画布,既可以在这块画布上画上点、线或各种图形,也可以将一幅画直接绘制到上面。
在浏览器中Canvas 的功能非常强大,可以处理很多图表方面的事情,对于这部分知识我们后面还会做详细的介绍。而这里你只需关注它获取图片这一个知识点。
我们还是通过代码来讲解,这样更一目了然。首先,在 HTML 中增加以下代码:
```
...
&lt;button id=&quot;TakePhoto&quot;&gt;Take&lt;/button&gt;
...
&lt;canvas id=&quot;picture&quot;&gt;&lt;/canvas&gt;
...
```
上面的HTML代码段包括一个 `&lt;canvas&gt;` 标签和一个 `&lt;button&gt;` 标签。我们的设想是,当点击拍照按钮时,就可以从视频流中获取到一张当时正在显示的图片了。
显然,光有 HTML 部分肯定是不行的,还需要下面的 JavaScript 脚本进行控制。增加 JavaScript 代码如下:
```
...
var picture = document.querySelector('canvas#picture');
picture.width = 640;
picture.height = 480;
...
picture.getContext('2d').drawImage(videoplay, 0, 0, picture.width, picture.height);
...
```
在上面的JavaScript代码中首先获得 HTML 中的 Canvas 标签,并设置了 Canvas 的宽高; 然后调用 Canvas 上下文的 drawImage 方法,这样就可以从视频流中抓取当时正在显示的图片了。
这里最关键的点就是 **drawImage** 方法,其方法格式如下:
```
void ctx.drawImage(image, dx, dy, dWidth, dHeight);
```
- image可以是一幅图片或HTMLVideoElement。
- dx, dy图片起点的 x、y 坐标。
- dWidth图片的宽度。
- dHeight图片的高度。
该方法的第一个参数特别重要它既可以是一幅图片也可以是一个Video 元素。而 HTML 中的 `&lt;video&gt;` 标签就是一个 video 元素,所以它可以当作是 drawImage 方法的第一个参数。这样就可以通过 Canvas 获取到照片了。
通过上面的方法,你就拍照成功了哈,是不是很简单?
## 如何保存照片
照片拍好后,如何将它保存到本地文件系统中呢? 浏览器同样给我们提供了非常方便的方法,让我们来看一下具体代码吧。
HTML 要先增加如下代码:
```
...
&lt;div&gt;
&lt;button id=&quot;save&quot;&gt;保存&lt;/button&gt;
&lt;/div&gt;
...
```
也就是当你点击保存这个 `&lt;button&gt;` 的时候,就可以将前面 Canvas 抓取的图片保存下来。不过,`&lt;button&gt;`只是触发一个事件,真正做事儿的是下面的 JavaScript 代码。具体逻辑如下:
```
...
function downLoad(url){
var oA = document.createElement(&quot;a&quot;);
oA.download = 'photo';// 设置下载的文件名,默认是'下载'
oA.href = url;
document.body.appendChild(oA);
oA.click();
oA.remove(); // 下载之后把创建的元素删除
}
...
document.querySelector(&quot;button#save&quot;).onclick = function (){
downLoad(canvas.toDataURL(&quot;image/jpeg&quot;));
}
....
```
在上面的代码中,当用户点击**保存**按钮时,会调用一个匿名函数。该函数的逻辑如下:
- 首先,通过 Canvas 的 toDataURL 方法获得图片的 URL 地址;
- 然后将该URL地址当作参数传给 downLoad 函数;
- 最后downLoad 函数做的事儿比较简单,就是创建一个`&lt;a&gt;`标签,当用户点击时就将图片下载下来。
通过上面的代码,你就可以通过浏览器为自己拍照,并同时将拍下来的照片保存到文件系统中了。
## 如何实现滤镜
从视频流中获取到照片后,你还可以通过滤镜为照片增加点特效,这样会让你的照片更加特别。
在浏览器中对于图片的滤镜处理是通过 CSS 来控制的。像前面一样,首先在 HTML 中增加 CSS 的滤镜代码如下:
```
...
&lt;head&gt;
&lt;style&gt;
.none {
-webkit-filter: none;
}
.blur {
-webkit-filter: blur(3px);
}
.grayscale {
-webkit-filter: grayscale(1);
}
.invert {
-webkit-filter: invert(1);
}
.sepia {
-webkit-filter: sepia(1);
}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
...
&lt;select id=&quot;filter&quot;&gt;
&lt;option value=&quot;none&quot;&gt;None&lt;/option&gt;
&lt;option value=&quot;blur&quot;&gt;blur&lt;/option&gt;
&lt;option value=&quot;grayscale&quot;&gt;Grayscale&lt;/option&gt;
&lt;option value=&quot;invert&quot;&gt;Invert&lt;/option&gt;
&lt;option value=&quot;sepia&quot;&gt;sepia&lt;/option&gt;
&lt;/select&gt;
...
&lt;/body&gt;
```
上面的 HTML 代码中定义了以下四种CSS滤镜。
- blur模糊度
- grayscale灰度黑白
- invert反转
- sepia深褐色
并增加了一个 `&lt;select&gt;` 标签,以便让用户选择使用不同的滤镜。但最终的控制还是由下面的 JavaScript 脚本来做的JavaScript 代码如下:
```
...
picture.className = filtersSelect.value;
...
```
没错,只需要这样简单的一行代码,你就可以将不同的滤镜应用于获取的照片上,是不是非常简单?学习完这些,你就可以很快实现你想要的滤镜效果了,赶快上手尝试下吧!
## 小结
本文我向你介绍了如何在浏览器上从视频流中抓取一幅图片,即**照片**。通过上面的介绍,你应该也了解到通过浏览器的 Canvas 来实现这个功能会特别简单。
在文章的最后,我还讲述了如何通过 CSS 对捕获到的图片做特效处理,当然这种特效是比较简单的实现。如果想实现更多更复杂的效果,就要用 WebGL来实现了。而WebGL的知识相对比较复杂甚至可以另起一个专栏专门来介绍这部分知识。所以这里我就不做过多的讲解了有需要的同学可以自行搜索资料学习。
## 思考时间
文章的最后是通过CSS对照片做了特效处理但当你将这张照片保存下来并单独打开后你会发现之前在浏览器上设置的效果没有了这是什么原因呢你要如何来解决该问题呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
[所做Demo的GitHub链接有需要可以点这里](https://github.com/avdance/webrtc_web/tree/master/03_takephoto)

View File

@@ -0,0 +1,279 @@
<audio id="audio" title="04 | 可以把采集到的音视频数据录制下来吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8e/b4/8e36b51007af12c72d7c26df687256b4.mp3"></audio>
在音视频会议、在线教育等系统中,**录制**是一个特别重要的功能。尤其是在线教育系统,基本上每一节课都要录制下来,以便学生可以随时回顾之前学习的内容。
实现录制功能有很多方式,一般分为**服务端录制**和**客户端录制**,具体使用哪种方式还要结合你自己的业务特点来选择。
<li>
**服务端录制**优点是不用担心客户因自身电脑问题造成录制失败如磁盘空间不足也不会因录制时抢占资源CPU占用率过高而导致其他应用出现问题等缺点是实现的复杂度很高。不过由于本文要重点讲解的是接下来的客户端录制所以这里我就不再深入展开讲解了你只需要知道有服务端录制这回事就行了或者如果你感兴趣也可以自行搜索学习。
</li>
<li>
**客户端录制**优点是方便录制方如老师操控并且所录制的视频清晰度高实现相对简单。这里可以和服务端录制做个对比一般客户端摄像头的分辨率都非常高的如1280x720所以客户端录制可以录制出非常清晰的视频但服务端录制要做到这点就很困难了本地高清的视频在上传服务端时由于网络带宽不足视频的分辨率很有可能会被自动缩小到了640x360这就导致用户回看时视频特别模糊用户体验差。不过客户端录制也有很明显的缺点其中最主要的缺点就是录制失败率高。因为客户端在进行录制时会开启第二路编码器这样会特别耗CPU。而CPU占用过高后就很容易造成应用程序卡死。除此之外它对内存、硬盘的要求也特别高。
</li>
这里需要再次说明的是,由于本专栏的文章是**聚焦在 WebRTC 的使用上**,所以我们只讲**如何使用 WebRTC 库实现客户端录制音视频流**。至于服务端录制的相关知识,由于与 WebRTC库无关所以我们就不做进一步讲解了。
## 在WebRTC处理过程中的位置
在正式进入主题之前,咱们依旧先来看一下本文在 WebRTC 处理过程中的位置。如下图所示:
<img src="https://static001.geekbang.org/resource/image/a6/43/a6026c3e5a50450d94839f5bc6ca5043.png" alt="">
通过上图可以了解到,咱们本文所讲的内容就是**音视频采集**下面的**录制功能**。
## 基本原理
录制的基本原理还是蛮简单的,但要做好却很不容易,主要有以下三个重要的问题需要你搞清楚。
**第一个,录制后音视频流的存储格式是什么呢**?比如,是直接录制原始数据,还是录制成某种多媒体格式(如 MP4 )?你可能会想,为什么要考虑存储格式问题呢?直接选择一个不就好了?但其实**存储格式的选择对于录制后的回放至关重要**,这里我先留个悬念不细说,等看到后面的内容你自然就会理解它的重要性与局限性了。
**第二个,录制下来的音视频流如何播放**?是使用普通的播放器播放,还是使用私有播放器播呢?其实,这与你的业务息息相关。如果你的业务是多人互动类型,且回放时也要和直播时一样,那么你就必须使用私有播放器,因为普通播放器是不支持同时播放多路视频的。还有,如果你想让浏览器也能观看回放,那么就需要提供网页播放器。
**第三个,启动录制后多久可以回放呢**?这个问题又分为以下三种情况。
- 边录边看,即开始录制几分钟之后用户就可以观看了。比如,我们观看一些重大体育赛事时(如世界杯),一般都是正式开始一小段时间之后观众才能看到,以确保发生突发事件时可以做紧急处理。
- 录制完立即回放,即当录制结束后,用户就可以回看录制了。比较常见的是一些技术比较好的教育公司的直播课,录制完成后就可以直接看回放了。
- 录完后过一段时间可观看。大多数的直播系统都是录制完成后过一段时间才可以看回放,因为录制完成后还要对音视频做一些剪辑、转码,制作各种不同的清晰度的回放等等。
接下来,咱们就针对上面的问题详细分析一下。我们先从存储格式的重要性和局限性切入,也正好解答下前面所留的悬念。
录制原始数据的优点是不用做过多的业务逻辑,来什么数据就录制什么数据,这样录制效率高,不容易出错;并且录制方法也很简单,可以将音频数据与视频数据分别存放到不同的二进制文件中。文件中的每一块数据可以由下面的结构体描述:
```
struct data
int media_type; // 数据类型0: 音频 1: 视频
int64_t ts; // timestamp记录数据收到的时间
int data_size; // 数据大小
char* data; // 指定具体的数据
}media_data;
```
当录制结束后再将录制好的音视频二进制文件整合成某种多媒体文件如FLV、MP4等。
但它的弊端是,录制完成后用户要等待一段时间才能看到录制的视频。因为在录制完成后,它还要做音视频合流、输出多媒体文件等工作。
那直接录制成某种多媒体格式会怎么样呢?如果你对多媒体文件格式非常熟悉的话,应该知道 **FLV** 格式特别适合处理这种流式数据。因为FLV媒体文件本身就是流式的你可以在FLV文件的任何位置进行读写操作它都可以正常被处理。因此如果你使用 FLV 的话,就不用像前面录制原始数据那样先将二制数据存储下来,之后再进行合流、转码那么麻烦了。
不仅如此,采用 FLV 媒体格式进行录制,你还可以再进一步优化,将直播的视频按 **N** 分钟为单位,录制成一段一段的 FLV然后录完一段播一段这样就实现了上面所讲的**边录边看**的效果了。
但FLV也不是万能的。如果你的场景比较复杂如多人互动的场景即同时存在多路视频FLV 格式就无法满足你的需求了,因为 FLV 只能同时存在一路视频和一路音频,而不能同时存在多路视频这种情况。此时,最好的方法还是录制原始数据,然后通过实现私有播放器来达到用户的需求。
当然即使是单视频的情况下FLV的方案看上去很完美但实际情况也不一定像你想象的那样美好。因为将音视频流存成 FLV 文件的前提条件是音视频流是按顺序来的,而实际上,音视频数据经过 UDP 这种不可靠传输网络时,是无法保证音视频数据到达的先后顺序的。因此,在处理音视频录制时,你不仅要考虑录制的事情,还要自己做音视频数据排序的工作。除此之外,还有很多其他的工作需要处理,这里我就不一一列举了。
不过,好在 WebRTC 已经处理好了一切。有了 WebRTC 你不用再考虑将音视频保存成什么媒体格式的问题;有了 WebRTC 你不用再考虑网络丢包的问题有了WebRTC你不用再考虑音视频数据乱序的问题……这一系列恼人的问题WebRTC 都帮你搞定了。下面就让我们赶紧应用起来,看一下 WebRTC 是如何进行客户端录制的吧!
## 基础知识
为便于你更好地理解,在学习如何使用 WebRTC 实现客户端录制之前,你还需要先了解一些基础知识。
在 JavaScript 中有很多用于存储二进制数据的类型这些类型包括ArrayBuffer、ArrayBufferView和Blob。那这三者与我们今天要讲的录制音视频流有什么关系呢
**WebRTC 录制音视频流之后,最终是通过 Blob 对象将数据保存成多媒体文件的**;而 Blob 又与ArrayBuffer有着很密切的关系。那ArryaBuffer 与 ArrayBufferView 又有什么联系呢接下来我们就了解一下这3种二进制数据类型以及它们之间的关系吧。
### 1. ArrayBuffer
ArrayBuffer 对象表示通用的、固定长度的二进制数据缓冲区。因此,你可以直接使用它存储图片、视频等内容。
但你并不能直接对 ArrayBuffer 对象进行访问,类似于 Java 语言中的抽象类,在物理内存中并不存在这样一个对象,必须使用其**封装类**进行实例化后才能进行访问。
也就是说, ArrayBuffer 只是描述有这样一块空间可以用来存放二进制数据,但在计算机的内存中并没有真正地为其分配空间。只有当具体类型化后,它才真正地存在于内存中。如下所示:
```
let buffer = new ArrayBuffer(16); // 创建一个长度为 16 的 buffer
let view = new Uint32Array(buffer);
```
```
let buffer = new Uint8Array([255, 255, 255, 255]).buffer;
let dataView = new DataView(buffer);
```
在上面的例子中一开始生成的buffer是不能被直接访问的。只有将buffer做为参数生成一个具体的类型的新对象时如 Uint32Array 或 DataView这个新生成的对象才能被访问。
### 2. ArrayBufferView
ArrayBufferView 并不是一个具体的类型而是代表不同类型的Array的描述。这些类型包括Int8Array、Uint8Array、DataView等。也就是说Int8Array、Uint8Array等才是JavaScript在内存中真正可以分配的对象。
以 Int8Array 为例当你对其实例化时计算机就会在内存中为其分配一块空间在该空间中的每一个元素都是8位的整数。再以Uint8Array为例它表达的是在内存中分配一块每个元素大小为8位的无符号整数的空间。
通过这上面的描述,你现在应该知道 ArrayBuffer 与 ArrayBufferView 的区别了吧ArrayBufferView 指的是 Int8Array、Uint8Array、DataView等类型的总称而这些类型都是使用 ArrayBuffer 类实现的,因此才统称他们为 ArrayBufferView。
### 3. Blob
BlobBinary Large Object是 JavaScript 的大型二进制对象类型WebRTC 最终就是使用它将录制好的音视频流保存成多媒体文件的。而它的底层是由上面所讲的 ArrayBuffer 对象的封装类实现的,即 Int8Array、Uint8Array等类型。
Blob 对象的格式如下:
```
var aBlob = new Blob( array, options );
```
其中array 可以是**ArrayBuffer、ArrayBufferView、Blob、DOMString**等类型 option用于指定存储成的媒体类型。
介绍完了这几个你需要了解的基本概念之后,接下来,我们书归正传,看看如何录制本地音视频。
## 如何录制本地音视频
WebRTC 为我们提供了一个非常方便的类,即 **MediaRecorder**。创建 MediaRecorder 对象的格式如下:
```
var mediaRecorder = new MediaRecorder(stream[, options]);
```
其参数含义如下:
- stream通过 getUserMedia 获取的本地视频流或通过 RTCPeerConnection 获取的远程视频流。
- options可选项指定视频格式、编解码器、码率等相关信息`mimeType: 'video/webm;codecs=vp8'`
MediaRecorder 对象还有一个特别重要的事件,即 **ondataavailable** 事件。当MediaRecoder捕获到数据时就会触发该事件。通过它我们才能将音视频数据录制下来。
有了上面这些知识,接下来,我们看一下具体该如何使用上面的对象来录制音视频流吧!
### 1. 录制音视频流
首先是获取本地音视频流。在[《01 | 原来通过浏览器访问摄像头这么容易》](https://time.geekbang.org/column/article/107948)一文中,我已经讲过如何通过浏览器采集音视频数据,具体就是调用浏览器中的 **getUserMedia** 方法。所以,这里我就不再赘述了。
获取到音视频流后,你可以将该流当作参数传给 MediaRecorder 对象,并实现 **ondataavailable** 事件,最终将音视频流录制下来。具体代码如下所示,我们先看一下 HTML 部分:
```
&lt;html&gt;
...
&lt;body&gt;
...
&lt;button id=&quot;record&quot;&gt;Start Record&lt;/button&gt;
&lt;button id=&quot;recplay&quot; disabled&gt;Play&lt;/button&gt;
&lt;button id=&quot;download&quot; disabled&gt;Download&lt;/button&gt;
...
&lt;/body&gt;
&lt;/html&gt;
```
上面的 HTML 代码片段定义了三个 **button**,一个用于开启录制,一个用于播放录制下来的内容,最后一个用于将录制的视频下载下来。然后我们再来看一下 JavaScript 控制部分的代码:
```
...
var buffer;
...
//当该函数被触发后将数据压入到blob中
function handleDataAvailable(e){
if(e &amp;&amp; e.data &amp;&amp; e.data.size &gt; 0){
buffer.push(e.data);
}
}
function startRecord(){
buffer = [];
//设置录制下来的多媒体格式
var options = {
mimeType: 'video/webm;codecs=vp8'
}
//判断浏览器是否支持录制
if(!MediaRecorder.isTypeSupported(options.mimeType)){
console.error(`${options.mimeType} is not supported!`);
return;
}
try{
//创建录制对象
mediaRecorder = new MediaRecorder(window.stream, options);
}catch(e){
console.error('Failed to create MediaRecorder:', e);
return;
}
//当有音视频数据来了之后触发该事件
mediaRecorder.ondataavailable = handleDataAvailable;
//开始录制
mediaRecorder.start(10);
}
...
```
当你点击 Record 按钮的时候,就会调用 **startRecord** 函数。在该函数中首先判断浏览器是否支持指定的多媒体格式,如 webm。 如果支持的话,再创建<br>
MediaRecorder 对象,将音视频流录制成指定的媒体格式文件。
实际存储时,是通过 ondataavailable 事件操作的。每当ondataavailable事件触发时就会调用handleDataAvailable函数。该函数的实现就特别简单了直接将数据 push 到 buffer 中实际在浏览器底层使用的是Blob对象。
另外,在开启录制时,可以设置一个毫秒级的时间片,这样录制的媒体数据会按照你设置的值分割成一个个单独的区块,否则默认的方式是录制一个非常大的整块内容。分成一块一块的区块会提高效率和可靠性,如果是一整块数据,随着时间的推移,数据块越来越大,读写效率就会变差,而且增加了写入文件的失败率。
### 2. 回放录制文件
通过上面的方法录制好内容后,又该如何进行回放呢?让我们来看一下代码吧!
在 HTML 中增加下面的代码:
```
...
&lt;video id=&quot;recvideo&quot;&gt;&lt;/video&gt;
...
```
在HTML中只增加了一个 `&lt;video&gt;`标签,用于播放录制的内容。下面的 JavaScript 是将录制内容与`&lt;video&gt;`标签联接到一起:
```
var blob = new Blob(buffer, {type: 'video/webm'});
recvideo.src = window.URL.createObjectURL(blob);
recvideo.srcObject = null;
recvideo.controls = true;
recvideo.play();
```
在上面的代码中,首先根据 buffer 生成 Blob 对象;然后,根据 Blob 对象生成 URL并通过 `&lt;video&gt;` 标签将录制的内容播放出来了。
### 3. 下载录制好的文件
那如何将录制好的视频文件下载下来呢?代码如下:
```
btnDownload.onclick = ()=&gt; {
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然后再创建一个 `&lt;A&gt;` 标签,设置 A 标签的 href 和 download属性。这样当用户点击该标签之后录制好的文件就下载下来了。
## 小结
在直播系统中,一般会包括服务端录制和客户端录制,它们各有优劣,因此好点的直播系统会同时支持客户端录制与服务端录制。
通过上面的介绍,你应该已经了解了在浏览器下如何录制音视频流了,你只需要使用浏览器中的 MediaRecorder 对象,就可以很方便地录制本地或远程音视频流。
今天我们介绍的内容只能够实现一路视频和一路音视流的情况,但在实际的项目中还要考虑多路音视频流的情况,如有多人同时进行音视频互动的情况,在这种复杂场景下该如何将它们录制下来呢?即使录制下来又该如何进行回放呢?
所以,对于大多数采用客户端录制方案的公司,在录制时一般不是录制音视频数据,而是录制桌面加音频,这样就大大简化了客户端实现录制的复杂度了。我想你也一定知道这是为什么,没错就是因为在桌面上有其他参与人的视频,所以只要将桌面录制下来,所有的视频就一同被录制下来了哈!
## 思考时间
上面我已经介绍了很多录制音视频可能出现的问题。现在你思考一下,是否可以将多路音视频录制到同一个多媒体文件中呢?例如在多人的实时直播中,有三个人同时共享视频,是否可以将这三个人的视频写入到一个多媒体文件中呢(如 MP4这样的MP4在播放时会有什么问题吗
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
[所做Demo的GitHub链接有需要可以点这里](https://github.com/avdance/webrtc_web/tree/master/04_recorder/recorder)

View File

@@ -0,0 +1,229 @@
<audio id="audio" title="05 | 原来浏览器还能抓取桌面?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/72/0b2aeb3d73cfbe9195a46fd433c25072.mp3"></audio>
无论是做音视频会议,还是做远程教育,**共享桌面**都是一个必备功能。如果说在 PC 或 Mac 端写个共享桌面程序你不会有太多感受,但通过浏览器也可以共享桌面是不是觉得就有些神奇了呢?
WebRTC的愿景就是要让这些看似神奇的事情不知不觉地发生在我们身边。
你可以想象一下,假如浏览器有了共享桌面功能,这会使得浏览器有更广阔的应用空间,一个最直接的例子就是我们可以直接通过浏览器进行远程办公、远程协助等工作,而不用再下载共享桌面的应用了,这大大提高了我们的工作效率。
## 在WebRTC 处理过程中的位置
在正式进行主题之前,我们还是来看看本文在整个 WebRTC 处理过程中的位置,如下图所示:
<img src="https://static001.geekbang.org/resource/image/c5/a0/c536a1dd0ed50008d2ada594e052d6a0.png" alt="">
没错,它仍然属于音视频采集的范畴,但是这次采集的不是音视频数据而是桌面。不过这也没什么关系,**桌面也可以当作一种特殊的视频数据来看待**。
## 共享桌面的基本原理
共享桌面的基本原理其实非常简单,我们可以分“两头”来说明:
- 对于**共享者**每秒钟抓取多次屏幕可以是3次、5次等每次抓取的屏幕都与上一次抓取的屏幕做比较取它们的差值然后对差值进行压缩如果是第一次抓屏或切幕的情况即本次抓取的屏幕与上一次抓取屏幕的变化率超过80%时就做全屏的帧内压缩其过程与JPEG图像压缩类似有兴趣的可以自行学习。最后再将压缩后的数据通过传输模块传送到观看端数据到达观看端后再进行解码这样即可还原出整幅图片并显示出来。
- 对于**远程控制端**,当用户通过鼠标点击共享桌面的某个位置时,会首先计算出鼠标实际点击的位置,然后将其作为参数,通过信令发送给共享端。共享端收到信令后,会模拟本地鼠标,即调用相关的 API完成最终的操作。一般情况下当操作完成后共享端桌面也发生了一些变化此时就又回到上面共享者的流程了我就不再赘述了。
通过上面的描述,可以总结出共享桌面的处理过程为:**抓屏、压缩编码、传输、解码、显示、控制**这几步,你应该可以看出它与音视频的处理过程几乎是一模一样的。
对于共享桌面,很多人比较熟悉的可能是**RDPRemote Desktop Protocal协议**,它是 Windows 系统下的共享桌面协议;还有一种更通用的远程桌面控制协议——**VNCVirtual 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就可以了**。至于编码、传输、解码等相关知识,我会在后面的文章中陆续为你讲解。
## 如何共享桌面
学习完共享桌面相关的理论知识,接下来,就让我们实践起来,一起来学习如何通过浏览器来抓取桌面吧!
### 1. 抓取桌面
首先我们先来了解一下在浏览器下抓取桌面的API的基本格式
```
var promise = navigator.mediaDevices.getDisplayMedia(constraints);
```
这个API你看着是不是似曾相识没错它与前面[《01 | 原来通过浏览器访问摄像头这么容易》](https://time.geekbang.org/column/article/107948)一文中介绍的采集视频的 API 基本上是一样的,我们可以再看一下采集视频的 API 的样子:
```
var promise = navigator.mediaDevices.getUserMedia(constraints);
```
二者唯一的区别就是:一个是**getDisaplayMedia**,另一个是**getUserMedia**。
这两个API都需要一个**constraints**参数来对采集的桌面/视频做一些限制。但需要注意的是,在采集视频时,参数**constraints**也是可以对音频做限制的,而在桌面采集的参数里却不能对音频进行限制了,也就是说,不能在采集桌面的同时采集音频。**这一点要特别注意**。
下面我们就来看一下**如何通过 getDisplayMedia API 来采集桌面**
```
...
//得到桌面数据流
function getDeskStream(stream){
localStream = stream;
}
//抓取桌面
function shareDesktop(){
//只有在 PC 下才能抓取桌面
if(IsPC()){
//开始捕获桌面数据
navigator.mediaDevices.getDisplayMedia({video: true})
.then(getDeskStream)
.catch(handleError);
return true;
}
return false;
}
...
```
通过上面的方法,就可以获得桌面数据了,让我们来看一下效果图吧:
<img src="https://static001.geekbang.org/resource/image/ad/c2/ad24096591c33c5049fcc275491597c2.png" alt="">
### 2. 桌面的展示
桌面采集后,就可以通过 HTML 中的`&lt;video&gt;`标签将采集到的桌面展示出来,具体代码如下所示。
首先,在 HTML 中增加下面的代码,其中`&lt;video&gt;`标签用于播放抓取的桌面内容:
```
...
&lt;video autoplay playsinline id=&quot;deskVideo&quot;&gt;&lt;/video&gt;
...
```
下面的 JavaScript 则将桌面内容与`&lt;video&gt;`标签联接到一起:
```
...
var deskVideo = document.querySelect(&quot;video/deskVideo&quot;);
...
function getDeskStream(stream){
localStream = stream;
deskVideo.srcObject = stream;
}
...
```
在 JavaScript中调用**getDisplayMedia**方法抓取桌面数据,当桌面数据被抓到之后,会触发 getDeskStream 函数。我们再在该函数中将获取到的 stream 与video 标签联系起来,这样当数据获取到时就从播放器里显示出来了。
### 3. 录制桌面
录制本地桌面与[《04 | 可以把采集到的音视频数据录制下来吗?》](https://time.geekbang.org/column/article/109105)一文中所讲的录制本地视频的过程是一样的。首先通过**getDisplayMedia**方法获取到本地桌面数据,然后将该流当作参数传给 MediaRecorder 对象,并实现**ondataavailable**事件,最终将音视频流录制下来。
具体代码如下所示,我们先看一下 HTML 部分:
```
&lt;html&gt;
...
&lt;body&gt;
...
&lt;button id=&quot;record&quot;&gt;Start Record&lt;/button&gt;
...
&lt;/body&gt;
&lt;/html&gt;
```
上面的 HTML 代码片段定义了一个开启录制的**button**,当用户点击该 button 后就触发下面的JavaScript 代码:
```
...
var buffer;
...
function handleDataAvailable(e){
if(e &amp;&amp; e.data &amp;&amp; e.data.size &gt; 0){
buffer.push(e.data);
}
}
function startRecord(){
//定义一个数组,用于缓存桌面数据,最终将数据存储到文件中
buffer = [];
var options = {
mimeType: 'video/webm;codecs=vp8'
}
if(!MediaRecorder.isTypeSupported(options.mimeType)){
console.error(`${options.mimeType} is not supported!`);
return;
}
try{
//创建录制对象,用于将桌面数据录制下来
mediaRecorder = new MediaRecorder(localStream, options);
}catch(e){
console.error('Failed to create MediaRecorder:', e);
return;
}
//当捕获到桌面数据后,该事件触发
mediaRecorder.ondataavailable = handleDataAvailable;
mediaRecorder.start(10);
}
...
```
当用户点击**Record**按钮的时候,就会调用**startRecord**函数。在该函数中首先判断浏览器是否支持指定的多媒体格式如webm。 如果支持的话,再创建**MediaRecorder**对象,将桌面流录制成指定的媒体格式文件。
当从localStream获取到数据后会触发**ondataavailable**事件。也就是会调用 handleDataAvailable 方法最终将数据存放到Blob中。
至于将Blob保存成文件就比较容易了我们在前面的文章[《04 | 可以把采集到的音视频数据录制下来吗?》](https://time.geekbang.org/column/article/109105)中都有讲解过,所以这里就不再赘述了!
## 小结
本文我向你讲解了如何通过浏览器提供的 API 来抓取桌面,并将它显示出来,以及如何通过前面所讲的**MediaRecorder**对象将桌面录制下来。
其实,真正的商用客户端录制并不是录制音视频流,而是录制桌面流。这样即使是多人互动的场景,在有多路视频流的情况下,录制桌面的同时就将桌面上显示的所有视频一起录制下来了,这样问题是不是一下就简单了?
比较遗憾的是关于我们上述录制桌面的API目前很多浏览器支持得还不够好只有 Chrome 浏览器相对比较完善。不过现在WebRTC 1.0规范已经出来了相信在不久的将来各浏览器都会实现这个API的。
## 思考时间
为什么使用视频的编码方式就容易出现桌面模糊的现象呢?有什么办法可以解决该问题吗?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
[所做Demo的GitHub链接有需要可以点这里](https://github.com/avdance/webrtc_web/tree/master/05_desktop/recorder)

View File

@@ -0,0 +1,158 @@
<audio id="audio" title="06 | WebRTC中的RTP及RTCP详解" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/be/bc/be80fd25a444283071c6a416c3cee1bc.mp3"></audio>
可以毫不夸张地说WebRTC 是一个 “**宝库**”,它里面有各种各样的 “**好东西**”。无论你从事什么行业,几乎都可以从它里边吸取能量。
在学习 WebRTC 时,你不光要学习如何使用它,还应该多去看它底层的代码,多去了解它都能做些什么,争取对它的原理和使用都了然于心。如此一来,当遇到某个恰当的时机,你就可以从 WebRTC 库中抽取一点“精髓”放到你自己的项目中,让你的项目大放异彩。
比如,你是搞音频的,你就可以从 WebRTC 中提取 3AAEC、AGC、ANC的算法用到自己的项目中这些算法可是目前世界上最顶级处理音频的算法如果你是搞网络的网络带宽的评估、平滑处理、各种网络协议的实现在WebRTC中真是应有尽有你完全可以从中抽取你想用的。
鉴于WebRTC的强大“光环”所以本文我将向你讲解学习WebRTC时你不得不知道的几个与网络相关的基本知识让你在前期就能夯实基础。
## UDP还是TCP
如果抛开 WebRTC让你自己实现一套**实时互动**直播系统,在选择网络传输协议时,你会选择使用**UDP**协议还是**TCP**协议呢?
这个问题在2011年至2012年一直是一件困扰着我们整个团队的大事儿因为当时在国内很少有用 UDP 作为底层传输协议的。UDP虽然传输快但不可靠尤其是在用户的网络质量很差的情况下基本无法保障音视频的服务质量。
当时能想到的解决方案是,如果采用 UDP 作为底层传输协议,那就使用 RUDP可靠性 UDP只有这样才能保障传输过程中不丢包。但有人提出反对意见认为如果想不丢包就该使用 TCP因为 RUDP 可靠性做到极致就变成TCP了那为什么不直接使用 TCP 呢?
面对这种情况2019年的你会做何种选择呢UDP还是TCP你能拿出让人真正信服的理由吗
现在让我告诉你正确答案:**必须使用UDP必须使用UDP必须使用UDP**,重要的事情说三遍。
为什么一定要使用UDP呢关于这个问题你可以反向思考下假如使用TCP会怎样呢在极端网络情况下TCP 为了传输的可靠性,它是如何做的呢?简单总结起来就是**“发送-&gt;确认;超时-&gt;重发”的反复过程**。
举个例子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协议**。
## RTP/RTCP
一般情况下,在实时互动直播系统传输音视频数据流时,我们并不直接将音视频数据流交给 UDP传输而是先给音视频数据加个 **RTP 头**,然后再交给 UDP 进行传输。为什么要这样做呢?
我们以视频帧为例,一个 I 帧的数据量是非常大的最少也要几十KI/P/B帧的概念我在前面[《03 | 如何使用浏览器给自己拍照呢?》](https://time.geekbang.org/column/article/109065)的文章中有过介绍)。而以太网的最大传输单元是多少呢? 1.5K所以要传输一个I帧需要几十个包。并且这几十个包传到对端后还要重新组装成I帧这样才能进行解码还原出一幅幅的图像。如果是我们自己实现的话要完成这样的过程至少需要以下几个标识。
- **序号**:用于标识传输包的序号,这样就可以知道这个包是第几个分片了。
- **起始标记**记录分帧的第一个UDP包。
- **结束标记**记录分帧的最后一个UDP包。
有了上面这几个标识字段,我们就可以在发送端进行拆包,在接收端将视频帧重新再组装起来了。
### 1. RTP 协议
其实,这样的需求在很早之前就已经有了。因此,人们专门定义了一套规范,它就是**RTP**协议。下面让我们来详细看一下 RTP 协议吧。
<img src="https://static001.geekbang.org/resource/image/ae/89/aec03cf4e1b76296c3e21ebbc54a2289.png" alt="">
如图所示RTP 协议非常简单,我这里按字段的重要性从高往低的顺序讲解一下。
- **sequence number**:序号,用于记录包的顺序。这与上面我们自己实现拆包、组包是同样的道理。
- **timestamp**:时间戳,同一个帧的不同分片的时间戳是相同的。这样就省去了前面所讲的**起始标记**和**结束标记**。一定要记住,**不同帧的时间戳肯定是不一样的**。
- **PT**Payload Type数据的负载类型。音频流的 PT 值与视频的 PT 值是不同的,通过它就可以知道这个包存放的是什么类型的数据。
- ……
这里我并没有将RTP协议头中的所有字段的详细说明都列在这儿如果你想了解所有字段的含义可以到参考一节查看其他字段的含义。需要注意的是这里没有将它们列出来并不代表它们不重要。恰恰相反**如果你想做音视频传输相关的工作RTP头中的每个字段的含义你都必须全部清楚**。
知道了上面这些字段的含义后,下面我们还是来看一个具体的例子吧!假设你从网上接收到一组音视频数据,如下:
```
...
{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},
{V=2,P=0,X=0,CC=0,M=0,PT:111,seq:15,ts:1122334455,ssrc=888},
{V=2,P=0,X=0,CC=0,M=0,PT:98,seq:15,ts:1122334455,ssrc=2345},
{V=2,P=0,X=0,CC=0,M=0,PT:111,seq:16,ts:1122334455,ssrc=888},
{V=2,P=0,X=0,CC=0,M=0,PT:98,seq:16,ts:1122334455,ssrc=2345},
{V=2,P=0,X=0,CC=0,M=0,PT:111,seq:17,ts:1122334455,ssrc=888},
{V=2,P=0,X=0,CC=0,M=0,PT:98,seq:17,ts:1122334455,ssrc=2345},
{V=2,P=0,X=0,CC=0,M=0,PT:111,seq:18,ts:1122334455,ssrc=888},
{V=2,P=0,X=0,CC=0,M=0,PT:98,seq:18,ts:1122334455,ssrc=2345},
{V=2,P=0,X=0,CC=0,M=0,PT:111,seq:19,ts:1122334455,ssrc=888},
{V=2,P=0,X=0,CC=0,M=0,PT:98,seq:19,ts:1122334455,ssrc=2345},
{V=2,P=0,X=0,CC=0,M=0,PT:111,seq:20,ts:1122334455,ssrc=888},
{V=2,P=0,X=0,CC=0,M=1,PT:98,seq:20,ts:1122334455,ssrc=2345},
...
```
假设 PT=98 是视频数据PT=111 是音频数据,那么按照上面的规则你是不是很容易就能将视频帧组装起来呢?
### 2. RTCP 协议
在使用 RTP 包传输数据时,难免会发生丢包、乱序、抖动等问题,下面我们来看一下使用的网络一般都会在什么情况下出现问题:
- 网络线路质量问题引起丢包率高;
- 传输的数据超过了带宽的负载引起的丢包问题;
- 信号干扰(信号弱)引起的丢包问题;
- 跨运营商引入的丢包问题;
- ……
WebRTC 对这些问题在底层都有相应的处理策略,但在处理这些问题之前,它首先要让各端都知道它们自己的网络质量到底是怎样的,**这就是 RTCP 的作用**。
**RTCP 有两个最重要的报文RRReciever Report和 SR(Sender Report)。通过这两个报文的交换,各端就知道自己的网络质量到底如何了。**
RTCP 支持的所有报文及其含义可以查看文章最后所附的参考一节。这里我们以**SR报文**为例,看看 SR 报文中都包括哪些信息。
<img src="https://static001.geekbang.org/resource/image/ae/f3/ae1b83a0255d05dd70285f0a26fb23f3.png" alt="">
下面我就简要说明一下该报文中字段的含义:
- V=2指报文的版本。
- P表示填充位如果该位置1则在 RTCP 报文的最后会有填充字节(内容是按字节对齐的)。
- RC全称Report Count指RTCP 报文中接收报告的报文块个数。
- PT=200Payload Type也就是说SR 的值为 200。
- ……
与RTP协议头一样上面只介绍了RTCP头字段的含义至于其他每个字段的含义请查看参考一节。同样的对于 RTCP 头中的每个字段也必须都非常清楚,只有这样以后你在看 WebRTC 带宽评估相关的代码时,才不至于晕头转向。
从上图中我们可以了解到SR报文分成三部分**Header、Sender info**和**Report block**。在NTP 时间戳之上的部分为 SR 报文的 **Header** 部分SSRC_1字段之上到 Header之间的部分为 **Sender info** 部分剩下的就是一个一个的Report Block了。那这每一部分是用于干什么的呢
- Header 部分用于标识该报文的类型比如是SR还是RR。
- Sender info 部分用于指明作为发送方,到底发了多少包。
- Report block 部分指明发送方作为接收方时,它从各个 SSRC 接收包的情况。
通过以上的分析,你可以发现**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帧后它的解码器就会解码成功于是其他人的画面也就一下子全部展示出来了。所以你说它是不是很重要呢
## 小结
通过上面的介绍想必你应该已经对 RTP/ RTCP 有了比较深刻的认识了。实际上,在 WebRTC 中还可以对 RTCP 做许多精确的控制,比如是否支持 FIR、NACK以及SR和RR 的发送间隔等这些都是可以控制的这些我会在下一篇SDP 相关的文章中再向你做更详细的介绍。
在 WebRTC 中, RTP/RTCP 只是众多协议中的比较重要的两个,还有 SRTP/SRTCP、DTLS、STUN/TURN协议等。有些知识我会在后面的文章中给你介绍还有一些并不会讲到但没有讲到的并不代表它不重要只是与我们讲的主题稍远被裁掉而已对于这些知识就需要你自己去研究学习了。
## 思考时间
RTCP 与 RTP 协议相辅相成RTCP 会将 RTP 的丢包情况及时反馈给发送方,但如果是报告反馈情况的 RTCP 数据包丢失了会怎样呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
## 参考
RTP协议头<br>
<img src="https://static001.geekbang.org/resource/image/e2/8f/e21ea8be9c0d13638a6af38423640d8f.png" alt="">
RTCP 协议头:<br>
<img src="https://static001.geekbang.org/resource/image/1e/04/1e772dd266c0899799dad777339adc04.png" alt="">
RTCP PT 类型:<br>
<img src="https://static001.geekbang.org/resource/image/cd/78/cd6ccdd0d30541d9b59fd5ff5d216178.png" alt="">
对于 205 和 206 两种不同的反馈消息,又在 RFC5104 中做了更详细的定义:<br>
<img src="https://static001.geekbang.org/resource/image/f4/ee/f4aeb8f448798523960756678c35fbee.png" alt="">

View File

@@ -0,0 +1,314 @@
<audio id="audio" title="07 | 你竟然不知道SDP它可是WebRTC的驱动核心" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/b2/d4f942d99ec548a1f0a2d9163350ecb2.mp3"></audio>
在前面[《01 | 原来通过浏览器访问摄像头这么容易》](https://time.geekbang.org/column/article/107948)[《04 | 可以把采集到的音视频数据录制下来吗?》](https://time.geekbang.org/column/article/109105)等文章中,我向你讲解了 WebRTC 如何采集音视频数据,以及如何将它们录制成文件等相关内容。但那些知识不过是个“**开胃菜**”WebRTC 真正核心的知识将从本文开始陆续向你展开。不过从本文开始,知识的难度会越来越高,你一定要做好心理准备。
说到 WebRTC 运转的核心,不同的人可能有不同的理解:有的人认为 WebRTC 的核心是音视频引擎有的人认为是网络传输而我则认为WebRTC之所以能很好地运转起来完全是由SDP驱动的因此**SDP才是WebRTC的核心**。
掌握了这个核心你就知道WebRTC都支持哪些编解码器、每次通话时都有哪些媒体通话时有几路音频/视频)以及底层网络使用的是什么协议,也就是说你就相当于拿到了打开 WebRTC 大门的一把钥匙。
由此可见SDP 在 WebRTC 中是何等重要。下面就让我们正式进入正题吧!
## SDP 是什么
在正式讲解 SDP 之前你首先要弄清楚SDP是什么SDPSession Description Protocal说直白点就是用文本描述的各端PC端、Mac端、Android端、iOS端等的**能力**。这里的**能力**指的是各端所支持的音频编解码器是什么,这些编解码器设定的参数是什么,使用的传输协议是什么,以及包括的音视频媒体是什么等等。
下面让我们来看一个真实的 SDP 片段吧!
```
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使用单声道。
## 交换SDP信息
下面是1对1 WebRTC处理过程图
<img src="https://static001.geekbang.org/resource/image/f7/57/f750b35eb95e84238d640cb76dcaf457.png" alt="">
如上图所示,两个客户端/浏览器进行1对1通话时首先要进行信令交互而**交互的一个重要信息就是 SDP 的交换**。
交换 SDP 的目的是为了让对方知道彼此具有哪些**能力**,然后根据双方各自的能力进行协商,协商出大家认可的音视频编解码器、编解码器相关的参数(如音频通道数,采样率等)、传输协议等信息。
举个例子A 与 B 进行通讯,它们先各自在 SDP 中记录自己支持的音频参数、视频参数、传输协议等信息然后再将自己的SDP信息通过信令服务器发送给对方。当一方收到对端传来的SDP信息后它会将接收到的 SDP 与自己的SDP进行比较并取出它们之间的交集这个交集就是它们协商的结果也就是它们最终使用的音视频参数及传输协议了。
## 标准 SDP 规范
了解了 SDP 是什么,接下来我们看一下 SDP 规范。其实单论 SDP 规范它并不复杂,但 WebRTC 使用时又对其做了不少修改,所以当你初见完整的 WebRTC 的 SDP 时,可能会一脸茫然。
不过没关系,万事总有头。在本文中,我先带你了解 SDP 的标准规范,然后再一步步深入,相信通过本文的学习,最终你将能基本读懂 WebRTC 所产生的 SDP 信息,从而为后面学习 WebRTC打下坚实的基础。
[标准SDP规范](https://tools.ietf.org/html/rfc4566#page-24)主要包括**SDP描述格式**和**SDP结构**,而 SDP 结构由**会话描述**和**媒体信息描述**两个部分组成。
其中,媒体信息描述是整个 SDP 规范中最重要的知识,它又包括了:
- 媒体类型
- 媒体格式
- 传输协议
- 传输的IP和端口
下面我们就以上这些知识逐一进行讲解。
### 1. SDP的格式
SDP是由多个 `&lt;type&gt;=&lt;value&gt;` 这样的表达式组成的。其中,`&lt;type&gt;`是一个字符,`&lt;value&gt;`是一个字符串。需要特别注意的是,**“=” 两边是不能有空格的**。如下所示:
```
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=)为止**。
另外,除非媒体部分重新对会话级的值做定义,否则会话级的值就是各个媒体的缺省默认值。让我们看个例子吧。
```
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描述中有两个媒体流一个是音频流另一个是视频流。
### 2. SDP的结构
了解了 SDP 的格式,下面我们来看一下 SDP 的结构,它由**会话描述**和**媒体描述**两部分组成。
**1会话描述**
会话描述的字段比较多,下面四个字段比较重要,我们来重点介绍一下。
**第一个v=protocol version必选**。例子v=0 表示SDP的版本号但不包括次版本号。
**第二个o=owner/creator and session identifier必选**。例子:`o=&lt;username&gt; &lt;session id&gt; &lt;version&gt; &lt;network type&gt; &lt;address type&gt; &lt;address&gt;`,该例子是对一个会话发起者的描述。其中,
- o= 表示的是对会话发起者的描述;
- `&lt;username&gt;`:用户名,当不关心用户名时,可以用 “-” 代替
- `&lt;session id&gt;` :数字串,在整个会话中,必须是唯一的,建议使用 NTP 时间戳;
- `&lt;version&gt;`:版本号,每次会话数据修改后,该版本值会递增;
- `&lt;network type&gt;` 网络类型一般为“IN”表示“internet”
- `&lt;address type&gt;`地址类型一般为IP4
- `&lt;address&gt;`IP 地址。
**第三个Session Name必选**。例子:`s=&lt;session name&gt;`该例子表示一个会话在整个SDP中有且只有一个会话也就是只有一个 s=。
**第四个t=time the session is active必选**。例子:`t=&lt;start time&gt; &lt;stop time&gt;`,该例子描述了会话的开始时间和结束时间。其中, `&lt;start time&gt;``&lt;stop time&gt;` 为NTP时间单位是秒`&lt;start time&gt;``&lt;stop time&gt;`均为零时,表示持久会话。
**2媒体描述**
媒体描述的字段也不少,下面我们也重点介绍四个。
**第一个m=media name and transport address可选**。例子:`m=&lt;media&gt; &lt;port&gt; &lt;transport&gt; &lt;fmt list&gt;`表示一个会话。在一个SDP中一般会有多个媒体描述。每个媒体描述以“m=”开始到下一个“m=”结束。其中,
- `&lt;media&gt;`媒体类型比如audio/video等
- `&lt;port&gt;`:端口;
- `&lt;transport&gt;`传输协议有两种——RTP/AVP和UDP
- `&lt;fmt list&gt;`:媒体格式,即数据负载类型(Payload Type)列表。
**第二个a=*zero or more media attribute lines可选**。例子:`a=&lt;TYPE&gt;或 a=&lt;TYPE&gt;:&lt;VALUES&gt;` 表示属性,用于进一步描述媒体信息;在例子中,<type> 指属性的类型, a=有两个特别的属性类型即下面要介绍的rtpmap 和 fmtp。</type>
**第三个rtpmap可选**。例子:`a=rtpmap:&lt;payload type&gt; &lt;encoding name&gt;/&lt;clock rate&gt;[/&lt;encodingparameters&gt;]`
- rtpmap 是 rtp 与 map 的结合,即 RTP 参数映射表。
- `&lt;payload type&gt;` 负载类型对应RTP包中的音视频数据负载类型。
- `&lt;encoding name&gt;`:编码器名称,如 VP8、VP9、OPUS等。
- `&lt;sample rate&gt;`:采样率,如音频的采样率频率 32000、48000等。
- `&lt;encodingparameters&gt;`:编码参数,如音频是否是双声道,默认为单声道。
**第四个fmtp**。例子:`a=fmtp:&lt;payload type&gt; &lt;format specific parameters&gt;`
- fmtp格式参数即 format parameters
- `&lt;payload type&gt;` 负载类型同样对应RTP包中的音视频数据负载类型
- `&lt; format specific parameters&gt;`指具体参数。
以上就是 SDP 规范的基本内容,了解了上面这些内容后,下面我们来看一下具体的例子,你就会对它有更清楚的认知了。
```
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 片段里包括**会话信息**与**媒体信息**。在媒体信息中又包括了**音频流信息**和**视频流信息**。
在音频流和视频流信息中,通过 rtpmap 属性对它们做了进一步的说明。如音频流支持 OPUS和 ISAC 编码OPUS 编码的采样率是 48000双声道而 ISAC 编码的采样率可以是 16000 或 32000 它们都是单声道。视频流支持 VP8采样率是 90000 。
## WebRTC 中的 SDP
WebRTC对标准 SDP 规范做了一些调整,更详细的信息可以看[这里](https://www.ietf.org/archive/id/draft-nandakumar-rtcweb-sdp-08.txt)它将SDP按功能分成几大块
- Session Metadata会话元数据
- Network Description网络描述
- Stream Description流描述
- Security Descriptions安全描述
- Qos Grouping Descriptions 服务质量描述
下面这张图清晰地表达了它们之间的关系:
<img src="https://static001.geekbang.org/resource/image/21/fa/216e0bc9ccfcb5dd3593f11e8b857dfa.png" alt=""><img src="https://static001.geekbang.org/resource/image/60/ce/60ac066baf39e92f4d9a3627cfe007ce.png" alt="">
通过上图我们可以看出WebRTC 按功能将 SDP 划分成了五部分即会话元数据、网络描述、流描述、安全描述以及服务质量描述。WebRTC SDP 中的会话元数据Session Metadata其实就是 SDP 标准规范中的**会话层描述****流描述、网络描述**与SDP 标准规范中的**媒体层描述**是一致的;而**安全描述**与**服务质量描述**都是新增的一些属性描述。下图我们来看一个具体的例子:
```
...
//=======安全描述============
a=ice-ufrag:1uEe //进入连通性检测的用户名
a=ice-pwd:RQe+y7SOLQJET+duNJ+Qbk7z//密码,这两个是用于连通性检测的凭证
a=fingerprint:sha-256 35:6F:40:3D:F6:9B:BA:5B:F6:2A:7F:65:59:60:6D:6B:F9:C7:AE:46:44:B4:E4:73:F8:60:67:4D:58:E2:EB:9C //DTLS 指纹认证,以识别是否是合法用户
...
//========服务质量描述=========
a=rtcp-mux
a=rtcp-rsize
a=rtpmap:96 VP8/90000
a=rtcp-fb:96 goog-remb //使用 google 的带宽评估算法
a=rtcp-fb:96 transport-cc //启动防拥塞
a=rtcp-fb:96 ccm fir //解码出错,请求关键帧
a=rtcp-fb:96 nack //启用丢包重传功能
a=rtcp-fb:96 nack pli //与fir 类似
...
```
上面的 SDP 片段是摘取的 WebRTC SDP 中的安全描述与服务质量描述,这两块描述在标准 SDP 规范中没有明确定义,它更多属于 WebRTC 业务的范畴。
其中,安全描述起到两方面的作用,一方面是进行网络连通性检测时,对用户身份进行认证;另一方面是收发数据时,对用户身份的认证,以免受到对方的攻击。从中可以看出 WebRTC 对安全有多重视了
服务质量描述指明启动哪些功能以保证音视频的质量,如启动带宽评估,当用户发送数据量太大超过评估的带宽时,要及时减少数据包的发送;启动防拥塞功能,当预测到要发生拥塞时,通过降低流量的方式防止拥塞的发生等等,这些都属于服务质量描述的范畴。
**为便于你更好地理解和使用 SDP接下来我再分享一个真实的例子。**
下面这段 SDP 是我从一个真实的 1对1 场景中截取出来的 WebRTC SDP 的片段。我在这段 SDP 上做了详细的注释通过上面知识的学习现在你应该也可以看懂这段SDP的内容了。
```
//=============会话描述====================
v=0
o=- 7017624586836067756 2 IN IP4 127.0.0.1
s=-
t=0 0
...
//================媒体描述=================
//================音频媒体=================
/*
* 音频使用端口1024收发数据
* UDP/TLS/RTP/SAVPF 表示使用 dtls/srtp 协议对数据加密传输
* 111、103 ... 表示本会话音频数据的 Payload Type
*/
m=audio 1024 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 126
//==============网络描述==================
//指明接收或者发送音频使用的IP地址由于WebRTC使用ICE传输这个被忽略。
c=IN IP4 0.0.0.0
//用来设置rtcp地址和端口WebRTC不使用
a=rtcp:9 IN IP4 0.0.0.0
...
//==============音频安全描述================
//ICE协商过程中的安全验证信息
a=ice-ufrag:khLS
a=ice-pwd:cxLzteJaJBou3DspNaPsJhlQ
a=fingerprint:sha-256 FA:14:42:3B:C7:97:1B:E8:AE:0C2:71:03:05:05:16:8F:B9:C7:98:E9:60:43:4B:5B:2C:28:EE:5C:8F3:17
...
//==============音频流媒体描述================
a=rtpmap:111 opus/48000/2
//minptime代表最小打包时长是10msuseinbandfec=1代表使用opus编码内置fec特性
a=fmtp:111 minptime=10;useinbandfec=1
...
a=rtpmap:103 ISAC/16000
a=rtpmap:104 ISAC/32000
a=rtpmap:9 G722/8000
...
//=================视频媒体=================
m=video 9 UDP/TLS/RTP/SAVPF 100 101 107 116 117 96 97 99 98
...
//=================网络描述=================
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
...
//=================视频安全描述=================
a=ice-ufrag:khLS
a=ice-pwd:cxLzteJaJBou3DspNaPsJhlQ
a=fingerprint:sha-256 FA:14:42:3B:C7:97:1B:E8:AE:0C2:71:03:05:05:16:8F:B9:C7:98:E9:60:43:4B:5B:2C:28:EE:5C:8F3:17
...
//================视频流描述===============
a=mid:video
...
a=rtpmap:100 VP8/90000
//================服务质量描述===============
a=rtcp-fb:100 ccm fir
a=rtcp-fb:100 nack //支持丢包重传参考rfc4585
a=rtcp-fb:100 nack pli
a=rtcp-fb:100 goog-remb //支持使用rtcp包来控制发送方的码流
a=rtcp-fb:100 transport-cc
...
```
从上面这段SDP中你应该可以总结出**SDP 是由一个会话层和多个媒体层组成的而对于每个媒体层WebRTC 又将其细划为四部分,即媒体流、网络描述、安全描述和服务质量描述**。
并且在上面的例子中有两个媒体层——音频媒体层和视频媒体层,而对于每个媒体层,也都有对应的媒体流描述、网络描述、安全描述及服务质量描述,是不是非常清晰?
## 小结
本文为你详细描述了 SDP 的标准规范以及 WebRTC 对SDP规范的修改为便于你理解还通过一系列实际的例子向你展示了在 WebRTC 中使用的 SDP 是什么样子。
总结起来就是SDP 是由一个会话层与多个媒体层组成,每个媒体层又分为媒体流描述、网络描述、安全描述和服务质量描述,而每种描述下面又是一堆细节的知识点。
当然通过本篇文章你也许不能一下将SDP的所有细节都了解清楚。但有了这个基础之后通过后面不断地积累最终你将在大脑中形成一个SDP的知识图谱到那时你再看 SDP 时就游刃有余了。
## 思考时间
学习完上面的正文后,现在请你思考一下,在 SDP 中如何设置音视频的传输码率呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,195 @@
<audio id="audio" title="08 | 有话好商量,论媒体协商" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/79/8ab9272820d5f29870f0a2a5765a2b79.mp3"></audio>
在[《07 | 你竟然不知道SDP它可是WebRTC的驱动核心](https://time.geekbang.org/column/article/111337)一文中,我向你详细介绍了标准 SDP 规范,以及 WebRTC 与标准 SDP 规范的一些不同,而本文我们将重点学习一下 WebRTC 究竟是如何使用 SDP 规范进行**媒体协商**的。
我们平时所说的**协商**你应该清楚是什么意思,说白了就是讨价还价。以买白菜为例,商家说 5元一颗买家说身上只有 4.5元卖不卖?商家同意卖,这样一次协商就完成了。
而**媒体协商**也是这个意思,只不过它们讨价还价的不是一般商品,而是与媒体相关的能力。那**媒体能力**是什么呢?实际就是你的设备所支持的音视频编解码器、使用的传输协议、传输的速率是多少等信息。
所以简单地说,**媒体协商**就是看看你的设备都支持那些编解码器,我的设备是否也支持?如果我的设备也支持,那么咱们双方就算协商成功了。
## 在WebRTC 处理过程中的位置
在正式进入主题之前,我们还是来看看本文在整个 WebRTC 处理过程中的位置,如下图所示:
<img src="https://static001.geekbang.org/resource/image/f5/de/f5a65fd87dc667af6761ba7e25abe1de.png" alt="">
通过这张图你可以了解到,本文所涉及的内容包括**创建连接**和**信令**两部分。
- 创建连接指的是创建RTCPeerConnection它负责端与端之间彼此建立 P2P 连接。在后面 RTCPeerConnection 一节中,我们还会对其做进一步的介绍。
- 信令,指的是客户端通过信令服务器交换 SDP 信息。
所以从本文开始,我们就开始讲解 WebRTC 最核心的一部分知识了,下面就让我们开始吧。
## WebRTC中媒体协商的作用
在 WebRTC 1.0 规范中,在双方通信时,双方必须清楚彼此使用的编解码器是什么,也必须知道传输过来的音视频流的 SSRCSSRC的概念参见[《06 | WebRTC中的RTP及RTCP详解》](https://time.geekbang.org/column/article/109999)一文)信息,如果连这些最基本的信息彼此都不清楚的话,那么双方是无法正常通信的。
举个例子如果WebRTC不清楚对方使用的是哪种编码器编码的数据比如到底是H264还是VP8那WebRTC就无法将这些数据包正常解码还原成原来的音视频帧这将导致音视频无法正常显示或播放。
同样的道理如果WebRTC不知道对方发过来的音视频流的 SSRC 是多少那么WebRTC就无法对该音视频流的合法性做验证这也将导致你无法观看正常的音视频。因为对于无法验证的音视频流WebRTC在接收音视频包后会直接将其抛弃。
通过上面的描述,我想你已经非常清楚媒体协商的作用是什么了。没错,**媒体协商的作用就是让双方找到共同支持的媒体能力**,如双方都支持的编解码器,从而**最终实现彼此之间的音视频通信**。
那 WebRTC 是怎样进行媒体协商的呢?这就要用到[《07 | 你竟然不知道SDP它可是WebRTC的驱动核心](https://time.geekbang.org/column/article/111337)文章中讲解的 SDP了。
- 首先通信双方将它们各自的媒体信息如编解码器、媒体流的SSRC、传输协议、IP地址和端口等按 SDP 格式整理好。
- 然后通信双方通过信令服务器交换SDP信息并待彼此拿到对方的 SDP 信息后,找出它们共同支持的媒体能力。
- 最后,双方按照协商好的媒体能力开始音视频通信。
WebRTC 进行媒体协商的步骤基本如上所述。接下来,我们来看看 WebRTC 具体是如何操作的。
## RTCPeerConnection
讲到媒体协商,我们就不得不介绍一下 RTCPeerConnection 类, 顾名思义,它表示的就是端与端之间建立的**连接**。
该类是整个 WebRTC 库中**最关键**的一个类通过它创建出来的对象可以做很多事情如NAT穿越、音视频数据的接收与发送甚至它还可以用于非音视频数据的传输等等 。
而在这里我们之所以要介绍RTCPeerConnection最主要的原因是**我们今天要讲的端到端之间的媒体协商,就是基于 RTCPeerConnection 对象实现的**。
首先我们来看一下如何创建一个RTCPeerConnection对象
```
...
var pcConfig = null;
var pc = new RTCPeerConnection(pcConfig);
...
```
在JavaScript下创建RTCPeerConnection对象非常简单如上所述只要通过 new 关键字创建即可。
在创建 RTCPeerConnection 对象时,还可以给它传一个参数**pcConfig**该参数的结构非常复杂这里我们先将其设置为null后面在《12 | RTCPeerConnection音视频实时通讯的核心》一文中我再对其做更详尽的描述。
有了RTCPeerConnection对象接下来让我们再来看看端与端之间是如何进行媒体协商的吧
## 媒体协商的过程
**在通讯双方都创建好 RTCPeerConnection 对象后**,它们就可以开始进行媒体协商了。不过在进行媒体协商之前,有两个重要的概念,即 **Offer****Answer** ,你必须要弄清楚。
Offer 与 Answer 是什么呢对于1对1 通信的双方来说,我们称首先发送媒体协商消息的一方为**呼叫方**,而另一方则为**被呼叫方**。
- Offer在双方通讯时呼叫方发送的 SDP 消息称为 Offer。
- Answer在双方通讯时被呼叫方发送的 SDP 消息称为 Answer。
在WebRTC中双方协商的整个过程如下图所示
<img src="https://static001.geekbang.org/resource/image/55/29/55971e410ce15be231b3f5fab0881e29.png" alt="">
首先,呼叫方创建 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 做下详细的讲解。
### 1. 呼叫方创建Offer
当呼叫方发起呼叫之前,首先要创建 Offer 类型的 SDP 信息,即调用 **RTCPeerConnection** 的 createOffer() 方法。代码如下:
```
function doCall() {
console.log('Sending offer to peer');
pc.createOffer(setLocalAndSendMessage, handleCreateOfferError);
}
```
如果createOffer函数调用成功的话浏览器会回调我们设置的 setLocalAndSendMessage 方法,你可以在 setLocalAndSendMessage 方法里获取到 **RTCSessionDescription 类型的 SDP 信息**;如果出错则会回调 handleCreateOfferError 方法。
最终,在 setLocalAndSendMessage 回调方法中,通过 **setLocalDescription()** 方法将本地SDP描述信息设置到 WebRTC 的Local 域。然后通过信令通道将此会话描述发送给被呼叫方。代码如下所示:
```
function setLocalAndSendMessage(sessionDescription) {
pc.setLocalDescription(sessionDescription);
sendMessage(sessionDescription);
}
```
### 2. 被呼叫方收到Offer
被呼叫方收到 Offer 后,调用 **setRemoteDescription** 方法设置呼叫方发送给它的Offer作为远端描述。代码如下
```
socket.on('message', function(message) {
...
} else if (message.type === 'offer') {
pc.setRemoteDescription(new RTCSessionDescription(message));
doAnswer();
} else if (...) {
...
}
....
});
```
### 3. 被呼叫方创建Answer
然后,被呼叫方调用 RTCPeerConnection 对象的 createAnswer 方法,它会生成一个与远程会话兼容的本地会话,并最终将该会话描述发送给呼叫方。
```
function doAnswer() {
pc.createAnswer().then(
setLocalAndSendMessage,
onCreateSessionDescriptionError
);
}
```
### 4. 呼叫方收到Answer
当呼叫方得到被呼叫方的会话描述,即 SDP 时,调用 setRemoteDescription方法将收到的会话描述设置为一个远程会话。代码如下
```
socket.on('message', function(message) {
...
} else if (message.type === 'answer') {
pc.setRemoteDescription(new RTCSessionDescription(message));
} else if (...) {
...
}
....
});
```
此时,媒体协商过程完成。紧接着在 WebRTC 底层会收集 Candidate并进行连通性检测最终在通话双方之间建立起一条链路来。
以上就是通信双方交换媒体能力信息的过程。 对于你来说,如果媒体协商这个逻辑没搞清楚的话,那么,你在编写音视频相关程序时很容易出现各种问题,最常见的就是音视之间不能互通。
另外,**需要特别注意的是,通信双方链路的建立是在设置本地媒体能力,即调用 setLocalDescription 函数之后才进行的**。
## 小结
在本文中,我向你详细介绍了 WebRTC 进行媒体协商的过程,这个过程是你必须牢记在脑子里的。如果对这块不熟悉的话,后面你在真正使用 WebRTC 开发音视频应用程序时就会遇到各种困难,如音视频不通、单通等情况。
另外,本文还向你简要介绍了 RTCPeerConnection 对象,它是 WebRTC 的核心 API媒体协商的具体操作都是通过该对象来完成的。对于该对象我会在后面的文章中做更详尽的解答。
RTCPeerConnection 除了会在端与端之间建立连接、传输音视频数据外,还要进行两次绑定:一次是与媒体源进行绑定,以解决数据从哪里来的问题;另外一次是与输出进行绑定,以解决接收到的音视频数据显示/播放的问题。
## 思考时间
在 WebRTC中SDP 消息的交换是使用 RTCPeerConnection 对象完成的吗?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,177 @@
<audio id="audio" title="09 | 让我们揭开WebRTC建立连接的神秘面纱" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/b8/b7ff922d81efdcaef3bec6f31bc365b8.mp3"></audio>
在上一篇[《08 | 有话好商量,论媒体协商》](https://time.geekbang.org/column/article/111675)文章中我向你介绍了WebRTC进行通信时是如何进行媒体协商的以及媒体协商的目的是什么。
在媒体协商过程中如果双方能达成一致也就是商量好了使用什么编解码器确认了使用什么传输协议那么接下来WebRTC就要建立连接开始传输音视频数据了。
WebRTC之间建立连接的过程是非常复杂的。之所以复杂主要的原因在于它既要考虑传输的**高效性**,又要保证端与端之间的**连通率**。
换句话说,当同时存在多个有效连接时,它首先选择传输质量最好的线路,如能用内网连通就不用公网。另外,如果尝试了很多线路都连通不了,那么它还会使用服务端中继的方式让双方连通,总之,是“想尽办法,用尽手段”让双方连通。
对于**传输的效率与连通率**这一点既是WebRTC的目标也是 WebRTC 建立连接的基本策略。下面我们就来具体看一下 WebRTC 是如何达到这个目标的吧!
## 在WebRTC 处理过程中的位置
下面这张图清晰地表达了本文所讲的内容在整个WebRTC处理过程中的位置。
<img src="https://static001.geekbang.org/resource/image/fb/ff/fbce1bba75073ab721cca628b0a61cff.png" alt="">
图中的红色部分——连接的创建、STUN/TURN以及 NAT 穿越,就是我们本文要讲的主要内容。
## 连接建立的基本原则
接下来,我将通过两个具体的场景,向你介绍一下 WebRTC 建立连接的基本原则。不过在讲解之前,我们先设置一些假设条件,这样会更有利于我们下面的描述:
- 通信的双方我们称为 A 和 B
- A 为呼叫方B 为被呼叫方;
- C 为中继服务器,也称为 relay 服务器或 TURN服务器。
### 1. 场景一:双方处于同一网段内
A 与 B 进行通信假设它们现在处于同一个办公区的同一个网段内。在这种情况下A 与 B 有两种连通路径:
- 一种是双方通过内网直接进行连接;
- 另一种是通过公网,也就是通过公司的网关,从公网绕一圈后再进入公司实现双方的通信。
相较而言,显然第一种连接路径是最好的。 A 与 B 在内网连接就好了,谁会舍近求远呢?
但现实却并非如此简单,要想让 A 与 B 直接在内网连接,首先要解决的问题是: A 与 B 如何才能知道它们是在同一个网段内呢?
这个问题还真不好回答也正是由于这个问题不太好解决所以现在有很多通信类产品在双方通信时无论是否在同一个内网它们都统一走了公网。不过WebRTC很好的解决了这个问题后面我们可以看一下它是如何解决这个问题的。
### 2. 场景二:双方处于不同点
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
- 优先级
- 传输协议
- 访问服务的用户名
- ……
如果用一个结构表示,那么它就如下面所示的样子:
```
{
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的收集。
### 1. STUN协议
srflx 类型的 Candidate 实际上就是内网地址和端口**经 NAT 映射**后的外网地址和端口。如下图所示:
<img src="https://static001.geekbang.org/resource/image/b0/ca/b04a0cd49c3e93e1cc536566404affca.png" alt="">
你应该知道,如果主机没有公网地址,是无论如何都无法访问公网上的资源的。例如你要通过百度搜索一些信息,如果你的主机没有公网地址的话,百度搜索到的结果怎么传给你呢?
而一般情况下主机都只有内网IP和端口那它是如何访问外网资源的呢实际上在内网的网关上都有NAT (Net Address Transport) 功能,**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和端口。
### 2. TURN协议
>
这里需要说明一点relay服务是通过TURN协议实现的。所以我们经常说的relay服务器或TURN服务器它们是同一个意思都是指中继服务器。
咱们言归正转,知道了内网主机如何通过 STUN 协议获取到srflx类型的候选者后那么中继类型候选者即 relay型的 Candidate 又是如何获取的呢?下面我们就来看一下。
首先你要清楚,**relay型候选者的优先级与其他类型相比是最低的**但在其他候选者都无法连通的情况下relay候选者就成了最好的选择。因为**它的连通率是所有候选者中连通率最高的**。
其实relay型候选者的获取也是通过 STUN 协议完成的只不过它使用的STUN消息类型与获取 srflx 型候选者的 STUN 消息的类型不一样而已。
RFC5766的 TURN 协议描述了如何获取 relay服务器即TURN 服务器)的 Candidate过程。其中最主要的是 Allocation指令。通过向 TURN 服务器发送 Allocation 指令relay服务就会在服务器端分配一个新的relay端口用于中转UDP数据报。
不过这里我只是简要描述了下,如果你对这块感兴趣的话,可以直接查看 RFC5766 以了解更多的细节。
## NAT打洞/P2P穿越
当收集到 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 协议的一套框架,从某种意义来说,这样描述也并不无道理。
## 小结
通过上面的讲解,我想你现在已经基本了解 WebRTC 端对端建立连接的基本过程。在WebRTC中它首先会尝试NAT穿越即尝试端到端直连。如果能够穿越成功那双方就通过直连的方式传输数据这是最高效的。但如果 NAT 穿越失败为了保障通信双方的连通性WebRTC会使用中继方式当然使用这种方式传输效率会低一些。
在整个过程中WebRTC使用**优先级**的方法去建立连接即局域网内的优先级最高其次是NAT穿越再次是通过中继服务器进行中转这样就巧妙地实现了“既要高效传输又能保证连通率”这个目标。
当然,即使 WebRTC 处理得这样好但还有不够完美的地方。举个例子对于同一级别多个Candidate的情况WebRTC 就无法从中选出哪个 Candidate 更优了,它现在的做法是,在同一级别的 Candidate 中,谁排在前面就先用谁进行连接。
## 思考时间
若你查阅相关资料一定会发现Candidate的类型是四种而不是三种多了一种 prflx 类型,那么 prflx 类型与 srflx 类型的区别是什么呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,156 @@
<audio id="audio" title="10 | WebRTC NAT穿越原理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a7/d3/a7b46a59c17d3f2366f3b9a7af0a78d3.mp3"></audio>
在 WebRTC 中, NAT 穿越是非常重要的一部分内容,也是比较有深度、比较难以理解的一部分知识。当然,等你学完本文,并完全理解了这部分知识后,你也会特别有成就感!
在我们真实的网络环境中NAT 随处可见,而它的出现主要是出于两个目的。**第一个是解决 IPv4 地址不够用的问题**。在 IPv6 短期内无法替换 IPv4 的情况下,如何能解决 IP 地址不够的问题呢?人们想到的办法是,让多台主机共用一个公网 IP 地址,然后在内部使用内网 IP 进行通信,这种方式大大减缓了 IPv4 地址不够用的问题。**第二个是解决安全问题**也就是主机隐藏在内网外面有NAT挡着这样的话黑客就很难获取到该主机在公网的IP地址和端口从而达到防护的作用。
不过凡事有利也有弊NAT 的引入确实带来了好处但同时也带来了坏处。如果没有NAT那么每台主机都可以有一个自己的公网IP地址这样每台主机之间都可以相互连接。可以想象一下如果是那种情况的话互联网是不是会更加繁荣因为有了公网IP地址后大大降低了端与端之间网络连接的复杂度我们也不用再费这么大力气在这里讲 NAT 穿越的原理了。
如果从哲学的角度来讲,“世上的麻烦都是自己找的”,这句话还是蛮有道理的。
## 在WebRTC处理过程中的位置
下面我们来看一下本文在 WebRTC 处理过程中所处的位置吧。通过下面这张图,你可以清楚地了解到本文我们主要讲解的是传输相关的内容。
<img src="https://static001.geekbang.org/resource/image/38/b9/3885178d5747a92ef95819fd0fe618b9.png" alt="">
## NAT 的种类
随着人们对 NAT 使用的深入NAT 的设置也越来越复杂。尤其是各种安全的需要,对 NAT 的复杂性起到了推波助澜的作用。
经过大量研究现在NAT基本上可以总结成 4 种类型:**完全锥型、IP限制锥型、端口限制锥型和对称型**。
下面我们就对这 4 种类型的 NAT 做下详细介绍。
### 1. 完全锥型 NAT
<img src="https://static001.geekbang.org/resource/image/88/af/8836f91edfcc9a2420e3fd11098f95af.png" alt="">
完全锥型 NAT 的特点是,当 host 主机通过 NAT 访问外网的 B 主机时,就会在 NAT 上打个“洞”,所有知道这个“洞”的主机都可以通过它与内网主机上的侦听程序通信。
实际上,这里**所谓的“打洞”就是在 NAT 上建立一个内外网的映射表**。你可以将该映射表简单地认为是一个 4 元组,即:
```
{
内网IP
内网端口,
映射的外网IP
映射的外网端口
}
```
在 NAT 上有了这张映射表,所有发向这个“洞”的数据都会被 NAT 中转到内网的 host 主机。而在 host 主机上侦听其内网端口的应用程序就可以收到所有的数据了,是不是很神奇?
还是以上面那张图为例,如果 host 主机与 B 主机“打洞”成功,且 A 与 C 从 B主机那里获得了 host 主机的外网IP及端口那么A与C就可以向该IP和端口发数据而 host 主机上侦听对应端口的应用程序就能收到它们发送的数据。
如果你在网上查找NAT穿越的相关资料一定会发现大多数打洞都是使用的 UDP 协议。之所以会这样,是因为**UDP是无连接协议**,它没有连接状态的判断,也就是说只要你发送数据给它,它就能收到。而 TCP 协议就做不到这一点,它必须建立连接后,才能收发数据,因此大多数人都选用 UDP 作为打洞协议。
### 2. IP限制锥型 NAT
<img src="https://static001.geekbang.org/resource/image/63/8a/6358816cf33831f22338cb26016d028a.png" alt="">
IP限制锥型要比完全锥型NAT严格得多它主要的特点是host 主机在 NAT 上“打洞”后NAT 会对穿越洞口的 IP 地址做限制。只有登记的 IP 地址才可以通过,也就是说,**只有 host 主机访问过的外网主机才能穿越 NAT**。
而其他主机即使知道“洞”的位置,也不能与 host 主机通信,因为在通过 NAT 时NAT 会检查IP地址如果发现发来数据的 IP 地址没有登记,则直接将该数据包丢弃。
所以IP 限制锥型 NAT 的映射表是一个 5 元组,即:
```
{
内网IP
内网端口,
映射的外网IP
映射的外网端口,
被访问主机的IP
}
```
还是以上图为例host 主机访问 B 主机,那么只有 B 主机发送的数据才能穿越NAT其他主机 A 和 C 即使从 B 主机那里获得了 host 主机的外网IP和端口也无法穿越 NAT。因为 NAT 会对通过的每个包做检测当检查发现发送者的IP地址与映射表中的“被访问主机的IP”不一致则直接将该数据包丢弃。
需要注意的是,**IP限制型NAT只限制IP地址**如果是同一主机的不同端口穿越NAT是没有任何问题的。
### 3. 端口限制锥型
<img src="https://static001.geekbang.org/resource/image/d6/0b/d6490bff17fdad51271266cef074920b.png" alt="">
端口限制锥型比IP限制锥型NAT更加严格它主要的特点是不光在 NAT 上对打洞的 IP 地址做了限制而且还对具体的端口做了限制。因此端口限制型NAT的映射表是一个 6 元组,其格式如下:
```
{
内网IP
内网端口,
映射的外网IP
映射的外网端口,
被访问主机的IP,
被访问主机的端口
}
```
在该 6 元组中,不光包括了 host 主机内外网的映射关系,还包括了**要访问的主机的 IP 地址及提供服务的应用程序的端口地址**。
如上图所示host 主机访问 B 主机的 p1 端口时,只有 B 主机的 p1 端口发送的消息才能穿越 NAT 与 host 主机通信。而其他主机,甚至 B 主机的 p2 端口都无法穿越 NAT。
从上面的情况你应该看出来了从完全锥型NAT到端口限制型NAT一级比一级严格。但其实端口型NAT还不是最严格的最严格的是接下来要讲解的对称型NAT。
### 4. 对称型NAT
<img src="https://static001.geekbang.org/resource/image/a8/7c/a80a2b1c98b8becce0c99e979fa3ba7c.png" alt="">
**对称型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 类型检测
通过上面的介绍相信你会很容易判断出NAT是哪种类型但对于每一台主机来说它怎么知道自己是哪种NAT类型呢
<img src="https://static001.geekbang.org/resource/image/b1/c3/b112ac2cd7e557d86dd5669e2858f6c3.png" alt="">
上面这张图清楚地表达了主机进行 NAT 类型检测的流程。其中蓝框是几个重要的检测点通过这几个检测点你就可以很容易地检测出上面介绍的4种不同类型的 NAT了。
接下来,我们就对上面这张图做下详细的解释。**这里需要注意的是每台服务器都是双网卡的而每个网卡都有一个自己的公网IP地址**。
### 第一步判断是否有NAT防护
1. 主机向服务器#1的某个IP和端口发送一个请求,服务器#1收到请求后会通过同样的IP 和端口返回一个响应消息。
1. 如果主机收不到服务器#1返回的消息,则说明用户的网络**限制了UDP协议直接退出**。
1. 如果能收到包则判断返回的主机的外网IP地址是否与主机自身的IP地址一样。如果一样说明主机就是一台**拥有公网地址的主机**如果不一样就跳到下面的步骤6。
1. 如果主机拥有公网IP则还需要进一步判断其防火墙类型。所以它会再向服务器#1发一次请求,此时,服务器#1从另外一个网卡的IP和不同端口返回响应消息
1. 如果主机能收到,说明它是一台没有防护的公网主机;如果收不到,则说明有**对称型的防火墙**保护着它。
1. 继续分析第3步如果返回的外网IP地址与主机自身IP不一致说明主机是处于 NAT 的防护之下,此时就需要对主机的 NAT防护类型做进一步探测。
### 第二步探测NAT环境
1. 在 NAT 环境下,主机向服务器#1发请求,服务器#1通过另一个网卡的IP和不同端口给主机返回响应消息
1. 如果此时主机可以收到响应消息,说明它是在一个**完全锥型NAT**之下。如果收不到消息还需要再做进一步判断。
1. 如果主机收不到消息,它向服务器#2(也就是第二台服务器)发请求,服务器#2使用收到请求的IP地址和端口向主机返回消息
1. 主机收到消息后,判断从服务器#2获取的外网IP和端口与之前从服务器#1获取的外网IP和端口是否一致,如果不一致说明该主机是在**对称型NAT**之下。
1. 如果IP地址一样则需要再次发送请求。此时主机向服务器#1再次发送请求,服务器#1使用同样的IP和不同的端口返回响应消息
1. 此时,如果主机可以收到响应消息说明是**IP 限制型 NAT**,否则就为**端口限制型NAT**。
至此,主机所在的 NAT 类型就被准确地判断出来了。有了主机的 NAT 类型你就很容易判断两个主机之间到底能不能成功地进行 NAT 穿越了。
再后面的事件就变得比较容易了,当你知道了 NAT 类型后如何进行NAT 穿越也就水到渠成了呢!
## 小结
通过上面的介绍,我想你应该已经对 NAT 的 4种类型了然于胸了。理解了NAT的4种类型同时又清楚了主机如何去判断自己的 NAT 类型之后,你应该自己就可以想清楚不同 NAT 类型之间是如何进行 NAT 穿越的了。
了解了NAT 穿越的理论知识,你就很容易理解 WebRTC 底层是如何进行音视频数据传输了吧WebRTC中媒体协商完成之后就会对Candidate pair进行连通性检测其中非常重要的一项工作就是进行 NAT 穿越。
它首先通过上面描述的方法进行 NAT 类型检测,当检测到双方理论上是可以通过 NAT 穿越时,就开始真正的 NAT 穿越工作,如果最终真的穿越成功了,通信双方就通过该连接将音视频数据源源不断地发送给对方。最终,你就可以看到音视频了。
## 思考时间
为什么对称型 NAT 与对称型 NAT 之间以及对称型 NAT 与端口限制型NAT 之间无法打洞成功呢?如果打洞失败,你又该如何让通信双方实现互联呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,356 @@
<audio id="audio" title="11 | 如何通过Node.js实现一套最简单的信令系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/19/1d9c0d9974f1cd7a0044c174edc14119.mp3"></audio>
通过前面几篇文章的讲解,我想现在你应该已经对 WebRTC 有了一个清楚的认知了。接下来的章节咱们就使用 WebRTC 逐步实现一套真实可用的 1 对 1 实时直播系统吧。
WebRTC 1.0 规范对 WebRTC 要实现的功能、API 等相关信息做了大量的约束,比如规范中定义了如何采集音视频数据、如何录制以及如何传输等。甚至更细的,还定义了都有哪些 API以及这些API 的作用是什么。但这些约束只针对于客户端,并没有对服务端做任何限制。
那WebRTC规范中为什么不对服务器也做约束呢其实这样做有以下三点好处。
- **第一点,可以集中精力将 WebRTC 库做好**。WebRTC的愿景是使浏览器能够方便地处理音视频相关的应用规范中不限制服务端的事儿可以使它更聚焦。
- **第二点,让用户更好地对接业务**。你也清楚,信令服务器一般都与公司的业务有着密切的关系,每家公司的业务都各有特色,让它们按照自已的业务去实现信令服务器会更符合它们的要求。
- **第三点,能得到更多公司的支持**。WebRTC扩展了浏览器的基础设施及能力而不涉及到具体的业务或产品这样会更容易得到像苹果、微软这种大公司的支持否则这些大公司之间就会产生抗衡。
当然,这样做也带来了一些坏处,最明显的一个就是增加了学习 WebRTC 的成本因为你在学习WebRTC的时候必须**自己去实现信令服务器**,否则你就没办法让 WebRTC 运转起来,这确实增加了不少学习成本。
不过有了本专栏,你就不用再担心这个问题了。接下来,我就向你讲解一下,**如何实现一套最简单的 WebRTC 信令服务系统**。有了这套信令服务系统WebRTC就能运转起来这样你就能真正地体验到 WebRTC 的强大之处了。
## 在WebRTC 处理过程中的位置
在开始讲解 WebRTC 信令服务器之前,我们先来看一下本文在 WebRTC 处理过程中的位置。
<img src="https://static001.geekbang.org/resource/image/a7/3e/a7489c842514a707501afd1953d82a3e.png" alt="">
通过上面这幅图,你可以清楚地知道本文所讲的主要内容就是红色方框中的**信令服务器**部分。
## WebRTC信令服务器的作用
你若想要实现 WebRTC 信令服务器,首先就要知道它在 WebRTC 1 对 1 通信中所起的作用。实际上它的功能是蛮简单的,就是**进行信令的交换,但作用却十分关键。在通信双方彼此连接、传输媒体数据之前,它们要通过信令服务器交换一些信息,如媒体协商**。
举个例子,假设 A 与 B 要进行音视频通信,那么 A 要知道 B 已经上线了同样B 也要知道 A 在等着与它通信呢。也就是说,**只有双方都知道彼此存在,才能由一方向另一方发起音视频通信请求,并最终实现音视频通话**。比如我们在[《08 | 有话好商量,论媒体协商》](https://time.geekbang.org/column/article/111675)一文中讲的媒体信息协商的过程就是这样一个非常典型的案例,双方的 SDP 信息生成后,要通过信令服务器进行交换,从而达到媒体协商的目的。
那在 WebRTC 信令服务器上要实现哪些功能,才能实现上述结果呢?我想至少要实现下面两个功能:
1. **房间管理**。即每个用户都要加入到一个具体的房间里,比如两个用户 A 与 B 要进行通话,那么它们必须加入到同一个房间里。
1. **信令的交换**。即在同一个房间里的用户之间可以相互发送信令。
## 信令服务器的实现
了解了 WebRTC 信令服务器的作用并且还知道了信令服务器要实现的功能接下来我们就操练起来看看如何实现信令服务器吧我将从下面5个方面来向你逐步讲解如何实现一个信令服务器。
### 1. 为什么选择 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 的核心是V8JavaScript引擎Node.js通过它解析JavaScript 脚本来达到控制服务器的目的。对于 JavaScript 同学来说Node.js的出现是革命性的它不仅让 JavaScript 同学成为了全栈开发工程师而且还让JavaScript开发同学的幸福指数飙升真正地感受到了 JavaScript 无所不能的能力。对于我这样的开发“老鸟”来说10年前还不敢想象通过 JavaScript 来写服务器程序呢,现在它却已成为现实!
当然, Node.js 不仅可以让你用 JavaScript 控制服务器,它还为你留了拓展接口,这些拓展接口甚至可以让你使用 C/C++ 为它编写模块,这样 Node.js 的功能就更加强大了。
### 2. Node.js的基本工作原理
<img src="https://static001.geekbang.org/resource/image/38/3f/380311a689cf4adc1dff6fdb91aa8b3f.png" alt="">
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 src="https://static001.geekbang.org/resource/image/fe/6b/fe606ed87d72b94c4e65da9d6148b26b.png" alt="">
当有网络请求过来时首先会被插入到一个事件处理队列中。libuv会监控该事件队列当发现有事件时先对请求做判断如果是简单的请求就直接返回响应了如果是复杂请求则从线程池中取一个线程进行异步处理。
线程处理完后,有两种可能:一种是已经处理完成,则向用户发送响应;另一种情况是还需要进一步处理,则再生成一个事件插入到事件队列中等待处理。事件处理就这样循环往复下去,永不停歇。
### 3. 安装与使用 Node.js
了解了 Node.js的基本原理后接下来我们还是要脚踏实地来看看具体如何安装、使用Node.js。
**1安装Node.js**
不同环境下安装 Node.js的方法也不一样不过都很简单。
在Ubuntu系统下执行
```
apt install nodejs
```
或在Mac 系统下执行:
```
brew install nodejs
```
通过以上步骤Node.js 很快就安装好了(我这里安装的 Node.js版本为v8.10.0)。
**2安装 NPM**
除了安装 Node.js 之外还要安装NPMNode Package Manager也就是 Node.js 的包管理器,或叫包安装工具。它与 Ubuntu 下的 APTAdvanced Package Tool命令或 Mac 系统下的 BREW 命令类似,是专门用来管理各种依赖库的。
以Linux为例在 APT 没有出现之前在Linux上安装软件是件特别麻烦的事儿比如要安装一个编辑器其基本步骤有如下:
- 先将这个工具(编辑器)的源码下载下来;
- 执行./configure 生成Makefile 文件;
- 执行 make 命令对源码进行编译;
- 如果编译成功,执行 `make install` 将其安装到指定目录下;
- 如果编译过程中发现还需要其他库则要对依赖库执行前面的4步也就是先将依赖库安装好然后再来安装该工具。
由这你可以看出以前在Linux下安装个程序或工具是多么麻烦。
不过 Linux 有了 APT工具后一切都变得简单了。你只要执行`apt install xxx`一条命令就好了,它会帮你完成上面的一堆操作。
对于 Node.js的安装包也是如此**NPM就是相当于 Linux 下的 APT 工具它的出现大大提高了JavaScript 开发人员的工作效率**。
下面我们就来看一下如何安装 NPM 工具实际上NPM 的安装就像前面安装 Node.js 一样简单。
在Ubuntu下执行
```
apt install npm
```
或在Mac下执行
```
brew install npm
```
### 4. Socket.io的使用
除了 Node.js 外我们最终还要借助Socket.io来实现 WebRTC 信令服务器。Socket.io特别适合用来开发WebRTC的信令服务器通过它来构建信令服务器大大简化了信令服务器的实现复杂度这主要是因为它内置了**房间**的概念。
<img src="https://static001.geekbang.org/resource/image/d1/41/d172dc2cfab792759dbe2c1bc098ce41.png" alt="">
上图是 Socket.io 与 Node.js配合使用的逻辑关系图其逻辑非常简单。Socket.io 分为服务端和客户端两部分。服务端由 Node.js加载后侦听某个服务端口客户端要想与服务端相连首先要加载 Socket.io 的客户端库,然后调用 `io.connect();`即可与服务端连接上。
这里需要特别强调的是 Socket.io 消息的发送与接收。Socket.io 有很多种发送消息的方式,其中最常见的有下面几种,也是你必须要掌握的。
- 给本次连接发消息
```
socket.emit()
```
- 给某个房间内所有人发消息
```
io.in(room).emit()
```
- 除本连接外,给某个房间内所有人发消息
```
socket.to(room).emit()
```
- 除本连接外,给所有人发消息
```
socket.broadcast.emit()
```
你也可以看看下面的例子其中S 表示服务器C 表示客户端,它们是发送消息与接收消息的比对。
- 发送 command 命令
```
S: socket.emit('cmd);
C: socket.on('cmd',function(){...});
```
- 发送了一个 command 命令,带 data 数据
```
S: socket.emit('action', data);
C: socket.on('action',function(data){...});
```
- 发送了command命令还有两个数据
```
S: socket.emit(action,arg1,arg2);
C: socket.on('action',function(arg1,arg2){...});
```
有了以上这些知识,你就可以实现信令数据通讯了。
### 5. 实现信令服务器
接下来我们来看一下,如何通过 Node.js下的 Socket.io 来构建一个服务器。
**首先是客户端代码,也就是在浏览器里执行的代码**。以下是index.html代码
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;WebRTC client&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;script src='/socket.io/socket.io.js'&gt;&lt;/script&gt;
&lt;script src='js/client.js'&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
该代码十分简单就是在body里引入了两段 JavaScript 代码。其中socket.io.js 是用来与服务端建立 Socket 连接的client.js 的作用是做一些业务逻辑,并最终通过 Socket 与服务端通讯。
下面是client.js的代码
```
var isInitiator;
room = prompt('Enter room name:'); //弹出一个输入窗口
const socket = io.connect(); //与服务端建立socket连接
if (room !== '') { //如果房间不空,则发送 &quot;create or join&quot; 消息
console.log('Joining room ' + room);
socket.emit('create or join', room);
}
socket.on('full', (room) =&gt; { //如果从服务端收到 &quot;full&quot; 消息
console.log('Room ' + room + ' is full');
});
socket.on('empty', (room) =&gt; { //如果从服务端收到 &quot;empty&quot; 消息
isInitiator = true;
console.log('Room ' + room + ' is empty');
});
socket.on('join', (room) =&gt; { //如果从服务端收到 “join&quot; 消息
console.log('Making request to join room ' + room);
console.log('You are the initiator!');
});
socket.on('log', (array) =&gt; {
console.log.apply(console, array);
});
```
在该代码中,首先弹出一个输入框,要求用户写入要加入的房间;然后,通过 io.connect() 建立与服务端的连接最后再根据socket返回的消息做不同的处理比如收到房间满或空的消息等。
**以上是客户端(也就是在浏览器)中执行的代码。下面我们来看一下服务端的处理逻辑。**
服务器端代码server.js是这样的
```
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) =&gt; {
// convenience function to log server messages to the client
function log(){
const array = ['&gt;&gt;&gt; Message from server: '];
for (var i = 0; i &lt; arguments.length; i++) {
array.push(arguments[i]);
}
socket.emit('log', array);
}
socket.on('message', (message) =&gt; { //收到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) =&gt; { //收到 “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); //发送 &quot;created&quot; 消息
} 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); //发送 &quot;full&quot; 消息
}
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](https://github.com/cloudhead/node-static),安装方法如下:
```
npm install socket.io
npm install node-static
```
## 启动服务器并测试
通过上面的步骤你就使用“Socket.io + Node.js”实现了一个信令服务器。现在你还可以通过下面的命令将服务启动起来了
```
node server.js
```
如果你是在本机上搭建的服务则可以在浏览器中输入“localhost:2013”然后在浏览器中新建一个tab 在里边再次输入“localhost:2013”。这时你就可以通过浏览器的控制台去看看发生了什么吧
最后再说一个快捷键小技巧吧在Chrome下你可以使用Command-Option-J或Ctrl-Shift-J的DevTools快速访问控制台。
## 小结
在本文中,我们介绍了 Node.js 的工作原理、Node.js的安装与布署以及如何使用“Sokcet.io + Node.js”实现 WebRTC 信令消息服务器。Socket.io 由于有房间的概念所以与WebRTC非常匹配因此用它开发WebRTC信令服务器就会大大地减少工作量。
另外本文中的例子虽说很简单但在后面的文章中我会以这个例子为基础在其上面不断增加一些功能这样最终你就会看到一个完整的Demo程序。所以你现在还是要学习好这每一个知识点打好基础积跬步才能至千里。
## 思考时间
文中所讲的JavaScript代码需要运行在两个不同的 V8 引擎上,你知道它们的对应关系吗?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
[所做Demo的GitHub链接有需要可以点这里](https://github.com/avdance/webrtc_web/tree/master/11_signal)

View File

@@ -0,0 +1,347 @@
<audio id="audio" title="12 | RTCPeerConnection音视频实时通讯的核心" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/ee/f241def49f86e98fa1adc497349ee5ee.mp3"></audio>
RTCPeerConnection 类是在浏览器下使用 WebRTC 实现1对1实时互动音视频系统最核心的类。你可以认为它是一个总的接口类或者称它为聚合类而该类中实现的很多功能都是由其他类具体实现的。
像我前面讲的很多文章,都是 RTCPeerConnection 类的一部分功能,如
- [《06 | WebRTC中的RTP及RTCP详解》](https://time.geekbang.org/column/article/109999)讲的是底层网络数据传输协议与其控制协议。
- [《07 | 你竟然不知道SDP它可是WebRTC的驱动核心](https://time.geekbang.org/column/article/111337)和[《08 | 有话好商量,论媒体协商》](https://time.geekbang.org/column/article/111675)讲的是 SDP 协议及使用SDP协议进行媒体协商的过程等。
- [《09 | 让我们揭开WebRTC建立连接的神秘面纱》](https://time.geekbang.org/column/article/112325)讲的是 WebRTC 底层是如何建立连接的。
- [《10 | WebRTC NAT穿越原理》](https://time.geekbang.org/column/article/113560)介绍了在 WebRTC 底层进行 NAT 穿越的过程。
以上这些内容都是 RTCPeerConnection 类的功能。除了上述讲的这些内容之外RTCPeerConnection类还有许多其他的功能我在后面的文章中还会向你逐一介绍。
RTPPeerConnection 这个知识点是你掌握 WebRTC 开发的重中之重,抓住它你就抓住了学习 WebRTC 的钥匙(这里你一定要清楚,**SDP 是掌握WebRTC运行机制的钥匙而RTCPeerConnection是使用 WebRTC 的钥匙**),这样可以让你很快学会 WebRTC 的使用。
## 在WebRTC处理过程中的位置
还是老规矩,我们先来看一下本文在整个 WebRTC处理过程中的位置。
<img src="https://static001.geekbang.org/resource/image/0b/c5/0b56e03ae1ba9bf71da6511e8ffe9bc5.png" alt="">
通过上面这张图,你可以看到本文所要讲述的内容就是两个端点之间是如何通过 RTCPeerConnection 创建连接的。
## 传输要做哪些事儿
可以想像一下如果你自己要实现一套1对1的通话系统你会怎么做呢如果你有一些socket开发经验的话我想你首先会想到在每一端创建一个 socket然后通过该 socket 与对端相连。
当 socket 连接成功之后,你就可以通过 socket 向对端发送数据或者接收对端的数据了。这个过程是不是看起来还是蛮简单的呢?实际上,**RTCPeerConnection 类的工作原理与socket 基本是一样的**,不过它的功能更强大,实现也更为复杂。因为它有很多细节需要处理,这里我们从“提问题”的角度出发,反向分析,你就知道 RTCPeerConnection 要处理哪些细节了。
- 端与端之间要建立连接,但它们是如何知道彼此的外网地址呢?
- 如果两台主机都是在 NAT 之后,它们又是如何穿越 NAT 进行连接的呢?
- 如果 NAT 穿越不成功,又该如何保证双方之间的连通性呢?
- 好不容易双方连通了,如果突然丢包了,该怎么办?
- 如果传输过程中,传输的数据量过大,超过了网络带宽能够承受的负载,又该如何保障音视频的服务质量呢?
- 传输的音视频要时刻保持同步,这又该如何做到呢?
- 数据在传输之前要进行音视频编码而在接收之后又要做音视频解码但WebRTC支持那么多编解码器如 H264、 H265、 VP8、 VP9等它是如何选择的呢
- ……
如果不是篇幅有限我可以一直问下去哈从中你也可以看出RTCPeerConnection要处理多少事情了。通过这些描述我想你也清楚这就是原理与真正的实际工作之间还差着十万八千里的距离呢。不过原理的好处是可以帮你简化问题方便发现问题的本质。
## 什么是RTCPeerConnection
了解了传输都要做哪些事之后,你再理解什么是 RTCPeerConnection 就比较容易了。实际上RTCPeerConnection 就与普通的 socket 一样,在通话的每一端都至少有一个 RTCPeerConnection 对象。在 WebRTC 中它负责与各端建立连接,接收、发送音视频数据,并保障音视频的服务质量。
在操作时,你完全可以把它当作一个 socket 来用,而且还是一个具有超强能力的“**SOCKET**”。至于它是如何保障端与端之间的连通性,如何保证音视频的服务质量,又如何确定使用的是哪个编解码器等问题,作为应用者的你可以不必关心,因为所有的这些问题都已经在 RTCPeerConnection 对象的底层实现好了。
因此,如果有人问你什么是 RTCPeerConnection你可以简要地回答说: “**它就是一个功能超强的 socket**!”这一下就点出了 RTCPeerConnection 的本质。
## 实现通话
今天我们要实现的例子是在同一个页面中使两个RTCPeerConnection对象之间建立连接。它没有什么实际价值但却能很好地证明RTCPeerConnection是如何工作的。
这里需要特别强调一点,在音视频通话中,每一方只需要有一个 RTCPeerConnection对象用它来接收或发送音视频数据。然而在真实的场景中为了实现端与端之间的通话还需要利用信令服务器交换一些信息比如交换双方的 IP 和 port 地址,这样通信的双方才能彼此建立连接(信令服务器的实现可以参考[上一篇文章](https://time.geekbang.org/column/article/114179))。
而在本文的例子中,为了最大化地减少额外的工作量,所以我们选择在同一个页面中进行音视频的互通,这样就不需要开发、安装信令服务器了。不过这样也增加了一些理解的难度,所以在阅读下面的内容时,你一定要在脑子中想象:**每一个 RTCPeerConnection 就是一个客户端**,这样就比较容易理解后面的内容了。
接下来我们就实操起来,一步一步实现通话吧!
### 1. 添加视频元素和控制按钮
我们首先开发一个简单的显示界面,在该页面中有两个`&lt;video&gt;`标签,一个用于显示本地捕获的视频,另一个用于显示“远端”的视频。
除此之外,在该页面上还有三个`&lt;button&gt;`按钮:
- start 按钮,用于打开本地视频;
- call 按钮,用于与对方建立连接;
- hangup 按钮,用于断开与对方的连接。
具体代码如下:
```
&lt;video id=&quot;localVideo&quot; autoplay playsinline&gt;&lt;/video&gt;
&lt;video id=&quot;remoteVideo&quot; autoplay playsinline&gt;&lt;/video&gt;
&lt;div&gt;
&lt;button id=&quot;startButton&quot;&gt;start&lt;/button&gt;
&lt;button id=&quot;callButton&quot;&gt;call&lt;/button&gt;
&lt;button id=&quot;hangupButton&quot;&gt;hang up&lt;/button&gt;
&lt;/div&gt;
```
### 2. 适配各种浏览器
一般情况下,我都还会在显示页面中添加一个叫做 **adapter.js** 的脚本,它的作用是为各种浏览器都提供统一的、最新的 WebRTC API 接口。
在 WebRTC 1.0 规范没有发布之前,虽然各大浏览器厂商都在各自的浏览器上移植了 WebRTC但你会发现它们最终实现的接口各不相同。这一问题直到 WebRTC 规范正式推出之后才有所改善,但很多用户依然使用老版本的浏览器,这就为使用 WebRTC 开发音视频应用增添了不少麻烦。
由于浏览器版本众多,而且用户基数大,可以预见各浏览器访问 WebRTC API 接口不统一的问题在未来很长一段时间内会一直存在。但幸运的是Google 很早之前就已经注意到了这个问题因此开发了adapter.js 这个适配器脚本以弥补各浏览器API 不统一的问题。
当然,你也可以自己做这件事儿,只不过适配各种浏览器版本真的是一件令人头疼的事儿,这会是一个不小的挑战。而 adapter.js 正好解决了这个痛点。随着adapter.js的发展它为了解决各种各样复杂的问题也从一小段很简单的JavaScript代码逐渐变得越来越庞大、复杂。但对于adapter.js这种即稳定、又成熟且性能不错的库我一向是奉行“拿来主义”哈
在页面中引入 adapter.js 的方法如下:
```
...
&lt;script src=&quot;https://webrtc.github.io/adapter/adapter-latest.js &lt;/script&gt;
...
```
修改后的 index.html 代码如下:
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Realtime communication with WebRTC&lt;/title&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;css/main.css&quot; /&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Realtime communication with WebRTC&lt;/h1&gt;
&lt;video id=&quot;localVideo&quot; autoplay playsinline&gt;&lt;/video&gt;
&lt;video id=&quot;remoteVideo&quot; autoplay playsinline&gt;&lt;/video&gt;
&lt;div&gt;
&lt;button id=&quot;startButton&quot;&gt;Start&lt;/button&gt;
&lt;button id=&quot;callButton&quot;&gt;Call&lt;/button&gt;
&lt;button id=&quot;hangupButton&quot;&gt;Hang Up&lt;/button&gt;
&lt;/div&gt;
&lt;script src=&quot;https://webrtc.github.io/adapter/adapter-latest.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;js/client.js&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
```
通过上面的操作,界面显示及各浏览器之间适配的问题就全部完成了。接下来我们来看一下 RTCPeerConnection是如何工作的。
### 3. RTCPeerConnection如何工作
为了讲清楚RTCPeerConnection是如何工作的我们还是看一个具体的例子吧。
假设 A 与 B 进行通信,那么对于每个端都要创建一个 RTCPeerConnection 对象,这样双方才可以通信,这个应该很好理解。但由于我们的例子中,通信双方是在同一个页面中(也就是说一个页面同时扮演 A 和 B 两个角色所以在我们的JavaScript代码中会同时存在两个 RTCPeerConnection 对象,我们称它们为 pc1 和 pc2好啦这里你一定要注意虽然pc1 和 pc2 是在同一个页面中,但你一定要把 pc1 和 pc2 想像成两个端的连接对象,这样才便于对后面代码的理解。
在 WebRTC 端与端之间建立连接,包括三个任务:
- 为连接的每个端创建一个RTCPeerConnection对象并且给 RTCPeerConnection 对象添加一个本地流该流是从getUserMedia()获取的;
- 获取本地媒体描述信息,即 SDP 信息,并与对端进行交换;
- 获得网络信息,即 CandidateIP地址和端口并与远端进行交换。
下面我们就来详细看看代码是如何实现的。
**1获取本地音视频流**
你需要调用 getUserMedia() 获取到本地流,然后将它添加到对应的 RTCPeerConnecton对象中代码如下
```
...
//创建 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 | 有话好商量,论媒体协商》](https://time.geekbang.org/column/article/111675)一文中做了详细介绍,若记不清了,可以回看下,我们下面的实践中要用到。
并且前面我们也说了,在真实的应用场景中,各端获取的 SDP 信息都要通过信令服务器进行交换,但在我们这个例子中为了减少代码的复杂度,直接在一个页面中实现了两个端,所以也就不需通过信令服务器交换信息了,只需要直接将一端获取的 offer 设置到另一端就好了,具体步骤可以大致描述为如下。
我们首先创建 offer 类型的 SDP信息。A 调用 RTCPeerConnection 的 createOffer() 方法,得到 A 的本地会话描述,即 offer 类型的 SDP 信息:
```
...
localPeerConnection.createOffer(offerOptions)
.then(createdOffer).catch(setSessionDescriptionError);
...
```
如果 createOffer 函数调用成功,会回调 createdOffer 方法,并在 createdOffer 方法中做以下几件事儿。
- A 使用setLocalDescription()设置本地描述然后将此会话描述发送给B。B 使用setRemoteDescription() 设置 A 给它的描述作为远端描述。
- 之后B 调用 RTCPeerConnection 的 createAnswer() 方法获得它本地的媒体描述。然后,再调用 setLocalDescription 方法设置本地描述并将该媒体信息描述发给A。
- A得到 B 的应答描述后,就调用 setRemoteDescription()设置远程描述。
整个媒体信息交换和协商至此就完成了。具体代码如下:
```
//当创建 offer 成功后,会调用该函数
function createdOffer(description) {
...
//将 offer 保存到本地
localPeerConnection.setLocalDescription(description)
.then(() =&gt; {
setLocalDescriptionSuccess(localPeerConnection);
}).catch(setSessionDescriptionError);
...
//远端 pc 将 offer 保存起来
remotePeerConnection.setRemoteDescription(description)
.then(() =&gt; {
setRemoteDescriptionSuccess(remotePeerConnection);
}).catch(setSessionDescriptionError);
...
//远端 pc 创建 answer
remotePeerConnection.createAnswer()
.then(createdAnswer)
.catch(setSessionDescriptionError);
}
//当 answer 创建成功后,会回调该函数
function createdAnswer(description) {
...
//远端保存 answer
remotePeerConnection.setLocalDescription(description)
.then(() =&gt; {
setLocalDescriptionSuccess(remotePeerConnection);
}).catch(setSessionDescriptionError);
//本端pc保存 answer
localPeerConnection.setRemoteDescription(description)
.then(() =&gt; {
setRemoteDescriptionSuccess(localPeerConnection);
}).catch(setSessionDescriptionError);
}
```
**3端与端建立连接**
在本地,当 A 调用 setLocalDescription 函数成功后,就开始收到网络信息了,即开始收集 ICE Candidate。
当 Candidate 被收集上来后,会触发 pc 的 **icecandidate** 事件,所以在代码中我们需要编写 icecandidate 事件的处理函数即onicecandidate以便对收集到的 Candidate 进行处理。
为 RTCPeerConnection 对象添加 icecandidate 事件的方法如下:
```
...
localPeerConnection.onicecandidate= handleConnection(event);
...
```
上面这段代码为 localPeerConnection 对象的 icecandidate 事件添加了一个处理函数,即 handleConnection。
当Candidate变为有效时handleConnection 函数将被调用,具体代码如下:
```
...
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(() =&gt; {
handleConnectionSuccess(peerConnection);
}).catch((error) =&gt; {
handleConnectionFailure(peerConnection, error);
});
...
}
}
...
```
每次 handleConnection 函数被调用时,就说明 WebRTC 又收集到了一个新的 Candidate。在真实的场景中每当获得一个新的 Candidate 后,就会通过信令服务器交换给对端,对端再调用 RTCPeerConnection 对象的 addIceCandidate()方法将收到的 Candidate 保存起来然后按照Candidate的优先级进行连通性检测。
如果 Candidate 连通性检测完成,那么端与端之间就建立了物理连接,这时媒体数据就可能通这个物理连接源源不断地传输了。
## 显示远端媒体流
通过 RTCPeerConnection 对象A与B双方建立连接后本地的多媒体数据就被源源不断地传送到了远端。不过远端虽然接收到了媒体数据但音视频并不会显示或播放出来。以视频为例不显示视频的原因是`&lt;video&gt;`标签还没有与 RTCPeerConnection 对象进行绑定,也就是说数据虽然到了,但播放器还没有拿到它。
下面我们就来看一下如何让RTCPeerConnection对象获得的媒体数据与 H5 的`&lt;video&gt;`标签绑定到一起。具体代码如下所示:
```
...
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 进行了绑定。
至此video 就能从 RTCPeerConnection 获取到视频数据,并最终将其显示出来了。
## 小结
在文中我向你详细介绍了RTCPeerConnection类当你从不同的角度去观察它时你会对它有不同的认知如果你从使用的角度看会觉得RTCPeerConnection是一个接口类如果你从功能的角度看它又是一个功能聚合类。这就是真实的 RTCPeerConnection类。
在使用 RTCPeerConnection时你可以把它当作一个功能超强的 socket 使用。在它的底层,它做了很多很细致的工作,而在应用层,你不必关心这些细节,只要学会如何使用它,就可以在浏览器上轻松实现你对音视频处理的想法。
在本文的后半段,我还通过一个具体的例子向你讲解了如何使用 RTCPeerConnection 对象。通过这个例子你就知道如何在一个页面内实现音视频流的发送与接收了。
## 思考时间
在[上一篇文章](https://time.geekbang.org/column/article/114179)中我向你讲解了如何通过 Node.js搭建一套信令服务器今天我又向你讲解了如何使用 RTCPeerConnection 对象,那你是否可以将二者结合起来实现一个简单的 1对1 系统了呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
[所做Demo的GitHub链接有需要可以点这里](https://github.com/avdance/webrtc_web/tree/master/12_peerconnection)

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="13 | 在WebRTC中如何控制传输速率呢" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7d/09/7d0ef03b4e281386f928c140ee080309.mp3"></audio>
在上一篇[《12 | RTCPeerConnection音视频实时通讯的核心》](https://time.geekbang.org/column/article/116324)一文中我向你介绍了RTCPeerConnection 对象是如何在端与端之间建立连接的,以及音视频数据又是如何通过它进行传输的。而本文则更进一步,向你介绍如何使用 RTCPeerConnection 来控制音视频数据的传输速率。
通过 RTCPeerConnection 进行传输速率的控制实际上还是蛮简单的一件事儿,但在学习相关知识的时候,你不仅要能知其然,还要知其所以然。对于本文来讲,就是你不但要学习如何控制传输速率,同时还应该清楚为什么要对传输速率进行控制。
其实,之所以要进行传输速率的控制,是因为它会对**音视频服务质量**产生比较大的影响,对于音视频服务质量这部分知识,接下来我就与你一起做详细探讨。
## 在WebRTC处理过程中的位置
在此之前,我们依旧先来看看本文的内容在整个 WebRTC 处理过程中的位置。
<img src="https://static001.geekbang.org/resource/image/0b/c5/0b56e03ae1ba9bf71da6511e8ffe9bc5.png" alt="">
通过上图你可以知道,本文所讲的内容仍然属于**传输**的范畴。
## 音视频服务质量
像上面所说的,虽然通过 RTCPeerConnection 在端与端之间建立连接后,音视频数据可以互通了,但你还应对传输速率有所控制。之所以要对传输速率进行控制,主要是为了提高音视频服务质量。
举个简单的例子,假设你的带宽是 1Mbps你想与你的朋友进行音视频通话使用的视频分辨率为720P帧率是15帧/秒,你觉得你们通话时的音视频质量会好吗?
咱们来简单计算一下根据经验值帧率为15帧/秒、分辨率为720P的视频每秒钟大约要产生1.21.5Mbps的流量。由此可知,你和你朋友在 1M 带宽的网络上进行通话,那通话质量一定会很差。因为你的“马路”就那么宽,却要跑超出它宽度的数据,这样超出带宽的数据会被直接丢弃掉,从而造成大量视频帧无法解码,所以最终效果一定会很差。
由此可知,如果你不对音视频传输速率进行限制的话,它一定会对音视频服务质量产生严重的影响。除了传输速率,还有哪些因素会对音视频质量产生影响呢?下面我从网络质量和数据两个方面列举了一些对音视频服务质量产生影响的因素:
- **网络质量**,包括物理链路的质量、带宽的大小、传输速率的控制等;
- **数据**,包括音视频压缩码率、分辨率大小、帧率等。
以上这些因素都会对音视频的服务质量产生影响。有这么多因素会对音视频服务质量产生影响,那在真实的场景中,你该怎么去区分它们呢?或者说怎么判断服务质量是不是由于传输速率问题引起的呢?
要想判断出是哪些因素引起的音视频服务质量变差,你就必须要知道这些因素的基本原理,下面我们就简要地对这些因素做些介绍。
### 1. 物理链路质量
物理链路质量包括三个方面,即丢包、延迟和抖动。下面我们来看看它们是怎样影响服务质量的吧!
- **丢包**。这个比较好理解,如果物理链路不好,经常出现丢包,这样就会造成接收端无法组包、解码,从而对音视频服务质量产生影响。
- **延迟**。指通信双方在传输数据时数据在物理链路上花费的时间比较长。对于实时通信来说200ms 以内的延迟是最好的这样通话双方的感觉就像是在面对面谈话如果延迟是在500 ms 以内,通话双方的体验也还不错,有点像打电话的感觉;如果延迟达到 800ms还能接受但有明显的迟滞现像但如果延迟超过 1秒那就不是实时通话了
- **抖动**。指的是数据一会儿快、一会儿慢,很不稳定。如果不加处理的话,你看到的视频效果就是一会儿快播了、一会儿又慢动作,给人一种眩晕的感觉,时间长了会非常难受。不过对于 WebRTC来讲它通过内部的 JitterBuffer可以简单地理解为一块缓冲区就能很好地解决该问题。
### 2. 带宽大小
带宽大小指的是每秒钟可以传输多少数据。比如 1M 带宽它表达的是每秒钟可以传输1M个 bit 位,换算成字节就是 1Mbps/8 = 128KBps也就是说 1M 带宽实际每秒钟只能传输 128K个Byte。
当带宽固定的情况下,如何才能让数据传输得更快呢?**答案是充分利用带宽**。这句话有点抽象,它实际的含义是**把带宽尽量占满,但千万别超出带宽的限制**。这里还是以 1M 带宽为例,如果每秒都传输 1M 的数据,这样传输数据的速度才是最快,多了、少了都不行。每秒传输的数据少了,就相当于有 100 辆车,本来每次可以走 10 辆10趟就走完了可你却让它一次走1辆这样肯定慢而每秒传输多了就会发生网络拥塞就像每天上下班堵车一样你说它还能快吗
### 3. 传输速率
在实时通信中,与传输速率相关的有两个码率:音视频压缩码率和传输控制码率。
**音视频压缩码率**指的是单位时间内音视频被压缩后的数据大小,或者你可以简单地理解为压缩后每秒的采样率。它与视频的清晰度是成反比的,也就是**压缩码率越高,清晰度越低**。我们可以做个简单的对比,你应该清楚音视频编码被称为**有损压缩**,所谓的有损压缩就是数据被压缩后,就无法再还原回原来的样子;而与有损压缩对应的是**无损压缩**,它是指数据解压后还能还原回来,像我们日常中用到的 Zip、RAR、GZ等这些压缩文件都是无损压缩。对于有损压缩你设备的压缩码率越高它的损失也就越大解码后的视频与原视频的差别就越大。
**传输码率**是指对网络传输速度的控制。举个例子,假设你发送的每个网络包都是 1500 字节,如果每秒钟发 100个包它的传输码率是多少呢即 100*1.5K = 150K 字节,再换算成带宽的话就是 150KB * 8 = 1.2M。但如果你的带宽是 1M那每秒钟发 100 个包肯定是多了,这个时候就要控制发包的速度,把它控制在 1M 以内,并尽量地接近 1M这样数据传输的速度才是最快的。
当然,如果你的压缩码率本来就很小,比如每秒钟只有 500kbps而你的带宽是 1Mbps那你还有必要对传输码率进行控制吗换句话说一条马路可以一起跑 10辆车但你现在只有 3辆显然你就没必要再控制同时发车的数量了。
### 4. 分辨率与帧率
你应该很清楚,视频的**分辨率**越高视频就越清晰但同时它的数据量也就越大。我们还是来简单计算一下对于1帧未压缩过的视频帧如果它的分辨率是 1280 * 720存储成 RGB 格式,则这一帧的数据为 1280 * 720 * 3 * 83表示 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直接减少了一半。
但了解音视频压缩原理的同学应该知道,通过减少帧率来控制码率的效果可能并不明显,因为在传输数据之前是要将原始音视频数据进行压缩的,在同一个 GOPGroup Of Picture除了 I/IDR帧外B 帧和 P帧的数据量是非常小的。因此**减少帧率的方式就没有降低分辨率方式效果明显了**。
到这里我已经向你介绍了在WebRTC实时通信中对音视频质量产生影响的因素有哪些以及这些因素对音视频服务质量产生影响的基本原理了解这些原理会更有利于你判断音视频服务质量变差的原因从而决定是否要使用控制传输速率的方法来解决音视频服务质量的问题。
那接下来我们言归正转,看一下在 WebRTC 中具体该如何控制传输速率。
## 传输速率的控制
通过上面的介绍,我想你现在应该很清楚,**可以通过以下两种方式来控制传输速率**。第一种是通过**压缩码率**这种“曲线救国”的方式进行控制;第二种则是更直接的方式,通过控制**传输速度**来控制速率。
第二种方式虽说很直接,但是也存在一些弊端。假设你有 10M 的数据要发送而传输的速度却被限制为5kbps那它就只能一点一点地传。**需要注意的是由于WebRTC是实时传输当它发现音视频数据的延迟太大且数据又不能及时发出去时它会采用主动丢数据的方法以达到实时传输的要求**。
所以说控制传输速率虽然有两种方式但实际上WebRTC只允许我们使用第一种压缩码率的方式来主动控制速率而第二种方式是它在底层自己控制的为了保障实时性一旦数据无法及时发送出去的话就会进行主动丢包。
了解了上面这些知识之后下面我们就来看看在WebRTC下如何才能控制视频流的码率。具体操作如下面代码所示
```
....
var vsender = null; //定义 video sender 变量
var senders = pc.getSenders(); //从RTCPeerConnection中获得所有的sender
//遍历每个sender
senders.forEach( sender =&gt; {
if(sender &amp;&amp; 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(()=&gt;{
console.log('Successed to set parameters!');
}).catch(err =&gt; {
console.error(err);
})
...
```
上面的代码中,首先从 RTCPeerConnection中获取视频的发送者即kind为 video的sender然后取出 sender 中的 parameters 对象,其中的 maxBitrate 属性就是用于控制传输码率的将你期望的最大码率设置好后再将parameters 对象设置回去这样WebRTC就可以控制某路流的码率大小了。
通过上面的代码你还可以看出,在 WebRTC 中速率的控制是使用压缩码率的方法来控制的而不是直接通过传输包的多少来控制的。从另外一个角度你也可以得到这样的结论因为maxBitrate属性来自于 sender 的 encoding 对象,而 encoding 对象就是进行编码时使用的参数。
## 小结
通过本文的讲解,你应该可以看出在 WebRTC 中控制传输速率其实是非常简单的事情,只要向 RTCPeerConnection 中的 sender 设置一个最大码率就可以控制某一路流的传输速率了。
另外,在本文中我还向你简要介绍了物理链路的质量、带宽的大小、码率、分辨率和帧率这几个影响音视频服务质量的重要因素,并详细分析了每个因素是如何影响到音视频服务质量的。
这里需要注意的是在WebRTC中通过上述的方式只能对每一路音视频流进行码率的控制而不能进行整体的统一控制。所以如果你的应用同时存在多路音视频流而你又想控制一个总的码率就只能一路一路地控制了。
## 思考时间
实际上在WebRTC中除了通过 sender 的 maxBitrate 控制码率外,还可以通过 SDP 来控制传输速率,你是否可以找到这个方法呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,228 @@
<audio id="audio" title="14 | 如何打开/关闭音视频?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/7e/97404cbb7aa06ac6e0eb12390327bc7e.mp3"></audio>
在实时互动直播系统中,打开/关闭音视频流是很常见的需求。作为一个直播用户,你至少会有下面几种需求:
- **将远端的声音静音**。比如来了一个电话,此时,应该先将直播中远端的声音关掉,等接完电话再将远端的声音打开,否则电话的声音与直播远端的声音会同时播放出来。
- **将自己的声音静音**。比如老板要找你谈话,这时你应该将直播中自己的声音静音,否则你与老板的一些私密谈话会被远端听到。比如被老板骂了,要是被远端听到可就尴尬了。
- **关闭远端的视频**。这个与远端声音静音差不多,只不过将声音改为视频了。比如当机子性能比较差的时候,为了节省资源,你可能会选择将远端的视频关闭掉。不过这种情况不是很多。
- **关闭自己的视频**。当你不想让对方看到自己的视频时,就可以选择关闭自己的视频。比如今天你的状态特别不好,你又特别在乎你的形象,此时你就可以选择关闭自己的视频。
这几个功能是实时互动直播中的必备功能。因此,在开发实时互动直播系统时一定要将这些功能添加到你的系统中,那该如何实现它们呢?
## 基本逻辑
针对上面的问题,本节我们就讨论一下如何才能实现这几个功能。下面我们就按需求分别对这几个功能做详细的分析。
### 1. 将远端的声音静音
要实现这个功能,你可以通过在**播放端控制**和**发送端控制**两种方式实现。
- 在播放端有两种方法,一种是**不让播放器播出来**,另一种是**不给播放器喂数据,将收到的音频流直接丢弃**。在播放端控制的优点是实现简单;缺点是虽然音频没有被使用,但它仍然占用网络带宽,造成带宽的浪费。
- 在发送端控制也可以细分成两种方法实现,即**停止音频的采集**和**停止音频的发送**。对于1对1实时直播系统来说这两种方法的效果是一样的。但对于多对多来说它们的效果就大相径庭了。因为停止采集音频后所有接收该音频的用户都不能收到音频了这显然与需求不符而停止向某个用户发送音频流则符合用户的需求。
### 2. 将自己的声音静音
无论是1对1实时互动还是多人实时互动它的含义都是一样的就是所有人都不能听到“我”的声音。因此你只需**停止对本端音频数据的采集**就可以达到这个效果。
### 3. 关闭远端的视频
它与将“远端的声音静音”是类似的,要实现这个功能也是分为从播放端控制和从发送端控制两种方式。
不过它与“将远端的声音静音”也是有区别的,那就是:
- 从播放端控制**只能使用不给播放器喂数据这一种方法**,因为播放器不支持关闭视频播放的功能;
- 从发送端控制是**通过停止向某个用户发送视频数据这一种方法**来实现的。而另一个停止采集则不建议使用,因为这样一来,其他端就都看不到你的视频了。
### 4. 关闭自己的视频
其逻辑与“将自己的声音静音”相似。但你不应该关闭视频的采集,而应该通过**关闭所有视频流的发送**来实现该需求。之所以要这样,是因为视频还有本地预览,只要视频设备可用,本地预览就应该一直存在。所以,“关闭自己的视频”与“将自己的声音静音”的实现是不一样的。
## 代码实现
了解了以上如何打开/关闭音视频的基本逻辑后,下面我们就具体实操起来,来看一下如何通过代码实现上面那些功能吧!
### 1. 将远端的声音静音
前面在分析基本逻辑时,我们讲过这个功能可以从播放端控制和发送端控制这两种方式来实现,而每种方式又对应两种方法,所以一共有四种方法。下面我们就来逐个分析和实现。
**1播放端控制不播放声音**
在播放端控制的代码特别简单,你只需要在 `&lt;video&gt;` 标签中设置 **muted** 即可,代码如下:
```
&lt;HTML&gt;
...
&lt;video id=remote autoplay muted playsinline/&gt;
...
&lt;/HTML&gt;
```
`&lt;video&gt;`标签设置了muted 属性后,你会发现虽然将远端获取到音视频流赋值给 `&lt;video&gt;` 标签进行播放,但最后只有视频被显示出来了,声音不播放。这样也就达到你的预期了。
其实,要想让音频播放出来也很容易,只需写一行 JavaScript 代码,将 muted 属性设置为假即可。
```
...
var remotevideo = document.querySelector('video#remote');
remotevideo.muted = false;
...
```
**2播放端控制丢掉音频流**
当然在播放端还有另外一种办法实现远端的静音,即在收到远端的音视频流后,将远端的 AudioTrack 不添加到要展示的MediaStream中也就是让媒体流中不包含音频流这样也可以起到静音远端的作用。具体代码如下
```
...
var remoteVideo = document.querySelector('video#remote');
...
{
//创建与远端连接的对象
pc = new RTCPeerConnection(pcConfig);
...
//当有远端流过来时,触发该事件
pc.ontrack = getRemoteStream;
...
}
...
function getRemoteStream(e){
//得到远端的音视频流
remoteStream = e.streams[0];
//找到所有的音频流
remoteStream.getAudioTracks().forEach((track)=&gt;{
if (track.kind === 'audio') { //判断 track 是类型
//从媒体流中移除音频流
remoteStream.removeTrack(track);
}
});
//显示视频
remoteVideo.srcObject = e.streams[0];
}
...
```
在上述代码中,实现了 ontrack 事件的处理函数。在该函数中首先保存远端传来的音视频流,然后将其中的音频轨去掉,最后将流赋值给`&lt;video&gt;`标签的 srcOjbect 域,这样播放器就只能播放视频了。
**3发送端控制不采集音频**
通过远端不采集音频的方法也可以达静音的效果。那如何才能让远端知道你想让它静音呢?这就要通过信令通知了。本地想让远端静音时,首先向信令服务器发送一条静音指令,然后信令服务器向远端转发该指令,远端收到指令后就执行下面的代码:
```
...
//获取本地音视频流
function gotStream(stream) {
localStream = stream;
localVideo.srcObject = stream;
}
//获得采集音视频数据时限制条件
function getUserMediaConstraints() {
var constraints = {
&quot;audio&quot;: false,
&quot;video&quot;: {
&quot;width&quot;: {
&quot;min&quot;: &quot;640&quot;,
&quot;max&quot;: &quot;1280&quot;
},
&quot;height&quot;: {
&quot;min&quot;: &quot;360&quot;,
&quot;max&quot;: &quot;720&quot;
}
}
};
return constraints;
}
...
//采集音视频数据
function captureMedia() {
...
if (localStream) {
localStream.getTracks().forEach(track =&gt; track.stop());
}
...
//采集音视频数据的 API
navigator.mediaDevices.getUserMedia(getUserMediaConstraints())
.then(gotStream)
.catch(e =&gt; {
...
});
}
...
```
上面的代码非常简单captureMedia 函数用于采集音视频数据,在它里面实际是调用的浏览器 API getUserMedia 进行具体操作的。由于这里强调的是不采集音频数据,所以你可以看到在 getUserMediaConstraints 函数中,将音频关掉了,所以最后获取到的流中只有视频数据。
**4发送端控制关闭通道**
通过远端关闭通道的方式也可以达到静音的效果。与方法3不采集音频类似本地想让远端静音时向信令服务器发送一条静音指令信令服务器进行转发远端收到指令后执行下面的代码
```
...
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)=&gt;{
if(track.kink !== 'audio') {
pc.addTrack(track, localStream);
}
});
}
...
```
在上面的代码中在getUserMedia函数的回调函数中获得本地媒体流然后在将其与 RTCPeerConnection 对象进行绑定时对track做判断如果是音频就不进行绑定关闭了通道这样对方就收不到音频数据了从而达到远端静音的效果。
以上就是将远端静音的四种方法,接下来我们再来看看如何将自己的声音静音。
### 2. 将自己的声音静音
将自己的声音静音只需要在采集时停止对音频数据进行采集就可以了。它与上面“将远端声音静音”中的方法3不采集音频是一样的只需将 constraints中的 auido 属性设置为 false 就好了。这里我就不再赘述了。
### 3. 关闭远端的视频
在前面讲解基本逻辑时我们分析过关闭远端的视频有两种方法一种是在显示端不将视频数据给video标签来达到不显示视频的效果另一种是控制远端不发送数据。
实际上这两种方式与将远端声音静音中的方法2和方法4是一样的只不过在做类型判断时需要将 audio 修改为 video 就好了。因此,这里我也不再进一步介绍了。
### 4. 关闭本地视频
最后一个是关闭本地视频,因不同的需求有不同的实现,一般情况下由于还涉及到本地视频的预览,所以在关闭本地视频时不是直接在采集的时候就不采集视频数据,而是不将视频数据与 RTCPeerConnection 对象进行绑定。具体的代码参考“将远端声音静音”中的方法4。
## 小结
通过上面的介绍,我想你应该已经对直播系统中如何打开/关闭音视频有了非常系统而又清楚的认识。在你的产品中具体该使用哪种方法来实现音视频的打开与关闭,主要还得看你的产品需求。
如果想为用户节省流量,那就尽可能在远端进行控制。如果觉得流量不是特别重要的问题,为了实现简单,直接在本地处理也是没有任何问题的。
音视频的打开/关闭逻辑上虽并不太复杂,但在直播系统是却是必不可少的功能。另一方面,如果你这个逻辑不清楚的话,也就是不知道分为本地静音、远端静音等逻辑的话,在多人实时互动的场景中,实现这部分代码时就很容易被绕进去。所以这部分的知识你最好还是要提前搞清楚、学透彻。
## 思考时间
上面我向你介绍了打开/关闭音视频的各种情况和实现方法,但列得还并不全面,你还能想到其他可以打开/关闭音视频的方法吗?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,150 @@
<audio id="audio" title="15 | WebRTC中的数据统计原来这么强大" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9e/d8/9e33bea6ae5a1e14799c0ffcc40ab7d8.mp3"></audio>
当你使用WebRTC实现1对1通话后还有一个非常重要的工作需要做那就是**实现数据监控**。数据监控对于 WebRTC 来讲就像是人的眼睛有了它你就可以随时了解WebRTC客户端的运转情况。
在WebRTC中可以监控很多方面的数据比如收了多少包、发了多少包、丢了多少包以及每路流的流量是多少这几个是我们平常最关心的。除此之外WebRTC还能监控目前收到几路流、发送了几路流、视频的宽/高、帧率等这些信息。
有了这些信息,你就可以**评估出目前用户使用的音视频产品的服务质量是好还是坏了**。当发现用户的音视频服务质量比较差时,尤其是网络带宽不足时,可以通过降低视频分辨率、减少视频帧率、关闭视频等策略来调整你的网络状况。
当然,还可以给用户一些友好的提示信息,以增强用户更好的体验。比如说,当发现丢包率较高时,可以给用户一个提示信息,如 “你目前的网络质量较差,建议…”。
如果确实是网络质量较差时,还可以在底层做切换网络链路的尝试,从而使服务质量能有所改善!
## WebRTC都能统计哪些数据
鉴于这些统计信息的重要性,那接下来我们来看一下,在 WebRTC 中都能监控到哪些统计信息。
在WebRTC中可以统计到的信息特别多按类型大体分为以下几种
- inbound-rtp
- outbound-rtp
- data-channel
- ……
至于其他更多的类型你可以到文章末尾查看参考中的内容。对于每一种类型WebRTC 都有非常详细的规范,关于这一点我会在下篇文章中再做详细的介绍。
实际上,要查看 WebRTC 的统计数据,你不需要另外再开发一行代码,**只要在 Chrome 浏览器下输入“chrome://webrtc-internals”这个 URL 就可以看到所有的统计信息了**。但它有一个前提条件,就是你必须有页面创建了 RTCPeerConnection 对象之后,才可以通过这个 URL 地址查看相关内容。因为在 Chrome 内部会记录每个存活的 **RTCPeerConnection** 对象,通过上面的访问地址,就可以从 Chrome 中取出其中的具体内容。
下面这张图就是从获取的统计数据页面中截取的,从中你就可以看出 WebRTC 都能统计哪些信息。
<img src="https://static001.geekbang.org/resource/image/e4/32/e4f935f496ba580e10f272f7f8b16932.png" alt="">
从这张图中,你可以看到它统计到了以下信息:
- 接收到的音频轨信息“…Track_receiver_5…”
- 接收到的视频轨信息“…Track_receiver_6…”
- 发送的音频轨信息“…Track_sender_5…”
- 发送的视频轨信息“…Track_sender_6…”
- ……
从上面的描述中,你可以看到,这些统计信息基本上包括了与音视频相关的方方面面。通过这些信息,你就可以做各种各样的服务质量分析了。比如说通过**接收到的音视频轨信息**,你就能分析出你的网络丢包情况、传输速率等信息,从而判断出你的网络质量如何。
接下来,我们以**接收到的视频轨信息**和**发送的视频轨信息**为例,向你详细介绍一下这些信息中都包括了哪些内容。
首先我们来看**接收到的视频轨信息**,在浏览器上点开 “…receiver_6(track)” 时,你就可以看到类似于下面这张图的内容:
<img src="https://static001.geekbang.org/resource/image/0f/45/0f20677f3744c5d4ea2ac3fd53c1a045.png" alt="">
>
这里需要注意的是,这张图只是截取了接收到的视频轨信息的部分内容。
大体上,通过该图你就可以看到这路视频轨总共收了多少数据包、多少字节的数据,以及每秒钟接收了多少包、多少字节的数据。除此之外,你还可以看到视频从开始直播到截图时丢包的总数,以及丢包率。当然,这里面还有很多其他信息,只是由于截图的原因,就不全部展示出来了。
了解了**接收到的视频轨信息**的内容后,我们再来看看**发送的视频轨信息**。它与**接收到的视频轨信息**描述的内容基本相同,只不过方向是相反的,一个是接收数据,另一个是发送数据。它的信息如下图所示:
<img src="https://static001.geekbang.org/resource/image/8d/b8/8dd767c80d45efebd31e45bb6b23a1b8.png" alt="">
从这张图中你可以看到,在**发送的视频轨信息**中包括了 WebRTC 发送的总字节数、总包数、每秒钟发送的字节数和包数,以及重传的包数和字节数等信息。
通过上面的讲解,我相信你已经知道 WebRTC 在信息统计方面做的还是非常细、非常全面的。
由于篇幅的原因这里我就不将所有的内容进行一一讲解了你可以自己在Chrome中输入“chrome://webrtc-internals”来查看更详细的信息。
## 如何获取统计数据
既然浏览器可以看到这么多与WebRTC相关的统计数据那么你可能不禁要问这些信息是从哪儿来的呢是否有相应的 API 也能够获取到这些信息呢?
当然有! WebRTC 提供了一个非常强大的 API**getStats()** 。通过该 API 你就可以获得上面讲述的所有信息了。
我们来看一下 getStats 的基本格式,大致如下:
```
promise = rtcPeerConnection.getStats(selector)
```
关于该 API这里有几点需要向你说明
- getStats API 是 RTCPeerConnecton 对象的方法,用于获取各种统计信息。
- 该方法的 selector 参数是可选的。如果为 null则收集的是RTCPeerConnection 对象所有相关的统计信息;当然,你也可以给它设置一个 MediaStreamTrack 类型的参数,这样它就只收集对应 track 相关的统计信息了。
- 该函数返回 Promise 对象,如果你给 Promise 对象的 then 分支设置一个回调函数,那当 getStats 方法调用成功后,就能通过该回调函数来获取想要的统计信息了。
- 获取到的统计信息以 RTCStatsReport 类型返回。
下面咱们看一下具体的代码,这样你会有更深刻的感悟:
```
...
//获得速个连接的统计信息
pc.getStats().then(
//在一个连接中有很多 report
reports =&gt; {
//遍历每个 report
reports.forEach( report =&gt; {
//将每个 report 的详细信息打印出来
console.log(report);
});
}).catch( err=&gt;{
console.error(err);
});
);
...
```
在上面的代码中,通过调用 RTCPeerConnecction 对象的 getStats 方法,就可以获取到你想得到的所有统计信息了。
如果你在浏览器上执行上面的代码,它大体上会得到下面这样的结果:
<img src="https://static001.geekbang.org/resource/image/e4/d6/e4b2635bb99e4f24004c6ba30824f6d6.png" alt="">
从这个结果中你可以看到,每个 Report 对象包括以下三个字段:
- id对象的唯一标识是一个字符串。
- timestamp时间戳用来标识该条Report是什么时间产生的。
- type类型是 RTCStatsType 类型,具体的类型可以到文章末尾查看参考中的内容。另外,在下一篇文章中我们还会对各种不同类型的 Report 做进一步的分析。
在上面这三个字段中Type类型是最关键的。每种类型的 Report 都有它自己的格式定义,因此,当你要获取更详细的信息时,首先要判断出该 Report 的类型是什么然后根据类型转成对应的Report对象再将信息取出来。
咱们举个例子inbound-rtp 类型的 Report 与 outbound-rtp 类型的 Report 就有着非常大的区别,通过下面这两张图的对比就能看得非常清楚了。
首先我们来看一下 inbound-rtp 类型的 Report
<img src="https://static001.geekbang.org/resource/image/f8/e8/f8e7956f6fc333f6dab76a945cb1bbe8.png" alt="">
接下来是outbound-rtp类型的 Report
<img src="https://static001.geekbang.org/resource/image/83/71/83ca4f0fc8c999d6745212cacfd78471.png" alt="">
从两张图中可以非常清楚地看到inbount-rtp里描述的是与接收相关的统计信息而outbound-rtp 中描述的则是与发送相关的各种统计信息。
## 小结
通过上面的讲解,我想你应该可以通过 RTCPeerConnection 对象的 getStats 方法得到你想要的各种统计信息了。
当然,你还可以通过向 RTCPeerConnection 对象的 getStats 方法中设置参数或不设置参数来决定你要获得多少统计数据或哪些统计数据。
WebRTC的这些统计信息对于真实的应用场景是非常重要的音视频各种服务质量的好坏都是通过它们来决定的。其中最最关键的统计信息是**与传输相关**的信息,比如发了多少包、收了多少包、丢了多少包等,通过这些信息你很容易就能评估出用户在使用你的产品时是否出现了问题。
## 思考时间
现在到了思考时间,那本文你要思考的问题是,对于网络相关的统计信息是如何计算的呢?举个例子,如果 A 向 B 发了 1000 个包A 怎么知道 B 有没有收到呢?你怎么知道它们之间有没有发生过丢包呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
## 参考
<img src="https://static001.geekbang.org/resource/image/72/93/72b638952a9e9d0440e9efdb4e2f4493.png" alt="">

View File

@@ -0,0 +1,340 @@
<audio id="audio" title="16 | WebRTC中的数据统计原来这么强大" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/1a/ba37559429fc21ba35f97f104af1b51a.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/118885)中我向你介绍了 WebRTC 可以获得哪些统计信息,以及如何使用 RTCPeerConntction 对象的 getStats 方法获取想要的统计信息。
那本文我们在[上一篇文章](https://time.geekbang.org/column/article/118885)的基础之上,继续对 WebRTC 中的统计信息做进一步的讨论,了解它更为详细的内容。
## 再论 getStats
现在你已经非常清楚,通过 RTCPeerConnection 对象的 getStats 方法可以很轻松地获取到各种统计信息,比如发了多少包、收了多少包、丢了多少包,等等。但实际上对于收发包这块儿的统计还可以从其他方法获取到,即通过 **RTCRtpSender 的 getStats 方法和 RTCRtpReceiver 的 getStats 方法也能获取收发包的统计信息**
也就是说,除了 RTCPeerConnection 对象有 getStats 方法外RTCRtpSender 和 RTCRtpReceiver 对象也有 getStats 方法只不过它们只能获取到与传输相关的统计信息而RTCPeerConnection还可以获取到其他更多的统计信息。
下面我们就来看一下它们三者之间的区别:
- RTCPeerConnection 对象的 getStats 方法获取的是**所有的统计信息**,除了收发包的统计信息外,还有候选者、证书、编解码器等其他类型的统计信息。
- RTCRtpSender对象的 getStats 方法只统计**与发送相关**的统计信息。
- RTCRtpReceiver对象的 getStats 方法则只统计**与接收相关**的统计信息。
通过上面的描述,我想你已经非常清楚 RTCPeerConnection 中的 getStats 方法是获取到所有的统计信息,而 RTCRtpSender 和 RTCRtpReceiver 对象中的 getStats 方法则分别统计的是发包、收包的统计信息。所以RTCPeerConnection 对象中的统计信息与 RTCRtpSender 和 RTCRtpReceiver 对象中的统计信息是**整体与局部**的关系。
下面咱们通过一段示例代码来详细看看它们之间的不同:
```
...
var pc = new RTCPeerConnection(null);
...
pc.getStats()
.then( reports =&gt; { //得到相关的报告
reports.forEach( report =&gt; { //遍历每个报告
console.log(report);
});
}).catch( err=&gt;{
console.error(err);
});
//从 PC 上获得 sender 对象
var sender = pc.getSenders()[0];
...
//调用sender的 getStats 方法
sender.getStats()
.then(reports =&gt; { //得到相关的报告
reports.forEach(report =&gt;{ //遍历每个报告
if(report.type === 'outbound-rtp'){ //如果是rtp输出流
....
}
}
);
...
```
在上面的代码中生成了两段统计信息,一段是通过 RTCPeerConnection 对象的 getStats 方法获取到的,其结果如下:
<img src="https://static001.geekbang.org/resource/image/5c/61/5c6cdea557a8a3ec0208a2915d6a5461.png" alt="">
另一段是通过 RTCRtpSender 对象的 getStats 方法获取到的,其结果如下:
<img src="https://static001.geekbang.org/resource/image/21/24/212a2a9124f8b643755ee63a5bafca24.png" alt="">
通过对上面两幅图的对比你可以发现RTCPeerConnection 对象的 getStats 方法获取到的统计信息明显要比 RTCRtpSender 对象的 getStats 方法获取到的信息多得多。这也证明了我们上面的结论,即 RTCPeerConnection 对象的 getStas 方法获取到的信息与 RTCRtpSender 对象的 getStats 方法获取的信息之间是**整体与局部**的关系。
## RTCStatsReport
我们通过 getStats API 可以获取到WebRTC各个层面的统计信息它的返回值的类型是RTCStatsReport。
RTCStatsReport的结构如下
```
interface RTCStatsReport {
readonly maplike&lt;DOMString, object&gt;;
};
```
即 RTCStatsReport 中有一个MapMap中的key是一个字符串object是 RTCStats 的继承类。
RTCStats作为基类它包括以下三个字段。
- id对象的唯一标识是一个字符串。
- timestamp时间戳用来标识该条Report是什么时间产生的。
- type类型是 RTCStatsType 类型它是各种类型Report的基类。
而继承自 RTCStats 的子类就特别多了,下面我挑选其中的一些子类向你做下介绍。
**第一种,编解码器相关**的统计信息即RTCCodecStats。其类型定义如下
```
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。其类型定义如下
```
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。其类型定义如下
```
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 种子类型,你可以到文末的参考中去查看。实际上,这个表格在[上一篇文章](https://time.geekbang.org/column/article/118885)中我已经向你做过介绍了,这里再重新温习一下。
若你对具体细节很感兴趣的话可以通过《WebRTC1.0规范》去查看每个 RTCStats 的详细定义,[相关链接在这里](https://w3c.github.io/webrtc-stats/#rtctatstype-*)。
## RTCP 交换统计信息
在[上一篇文章](https://time.geekbang.org/column/article/118885)中我给你留了一道思考题不知你是否已经找到答案了实际上在WebRTC中上面介绍的输入/输出RTP流报告中的统计数据都是通过 RTCP 协议中的 SR、RR 消息计算而来的。
关于 RTCP 以及 RTCP 中的 SR、 RR 等相关协议内容记不清的同学可以再重新回顾一下[《 06 | WebRTC中的RTP及RTCP详解》](https://time.geekbang.org/column/article/109999)一文的内容。
在RTCP协议中SR 是发送方发的记录的是RTP流从发送到现在一共发了多少包、发送了多少字节数据以及丢包率是多少。RR是接收方发的记录的是RTP流从接收到现在一共收了多少包、多少字节的数据等。
通过 SR、RR 的不断交换,在通讯的双方就很容易计算出每秒钟的传输速率、丢包率等统计信息了。
**在使用 RTCP 交换信息时有一个主要原则,就是 RTCP 信息包在整个数据流的传输中占带宽的百分比不应超过 5%**。也就是说你的媒体包发送得越多RTCP信息包发送得也就越多。你的媒体包发得少RTCP包也会相应减少它们是一个联动关系。
## 绘制图形
通过 getStats 方法我们现在可以获取到各种类型的统计数据了,而且在上面的 **RTCP交换统计信息**中,我们也知道了 WebRTC 底层是如何获取到传输相关的统计数据的了,那么接下来我们再来看一下如何利用 RTCStatsReport 中的信息来绘制出各种分析图形,从而使监控的数据更加直观地展示出来。
在本文的例子中,我们以绘制每秒钟发送的比特率和每秒钟发送的包数为例,向你展示如何将 RTCStats 信息转化为图形。
要将 Report 转化为图形大体上分为以下几个步骤:
- 引入第三方库 graph.js
- 启动一个定时器,每秒钟绘制一次图形;
- 在定时器的回调函数中,读取 RTCStats 统计信息,转化为可量化参数,并将其传给 graph.js进行绘制。
了解了上面的步骤后,下来我们就来实操一下吧!
第三方库 graph.js 是由 WebRTC 项目组开发的,是专门用于绘制各种图形的,它底层是通过 Canvas 来实现的。这个库非常短小,只有 600 多行代码,使用起来也非常方便,在下面的代码中会对它的使用做详细的介绍。
另外,该库的代码链接我已经放到了文章的末尾,供你参考。
### 1. 引入第三方库
在 JavaScript 中引入第三方库也非常简单,只要使用 `&lt;script&gt;` 就可以将第三方库引入进来了。具体代码如下:
```
&lt;html&gt;
...
&lt;body&gt;
...
&lt;script src=&quot;js/client.js&quot;&gt;&lt;/script&gt;
//引入第三方库 graph.js
&lt;script src=&quot;js/third_party/graph.js&quot;&gt;&lt;/script&gt;
...
&lt;/body&gt;
&lt;/html&gt;
```
### 2. client.js 代码的实现
client.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(() =&gt; {
if (!pc) { //如果 pc 没有创建直接返回
return;
}
//从 pc 中获取发送者对象
const sender = pc.getSenders()[0];
if (!sender) {
return;
}
sender.getStats().then(res =&gt; { //获取到所有的 Report
res.forEach(report =&gt; { //遍历每个 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 &amp;&amp; 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); //每秒钟触发一次
...
```
在该代码中最重要的是3289行的代码因为这其中实现了一个定时器——每秒钟执行一次。每次定时器被触发时都会调用sender 的 getStats 方法获取与传输相关的统计信息。
然后对获取到的 RTCStats 类型做判断,只取 RTCStats 类型为 outbound-rtp 的统计信息。最后将本次统计信息的数据与上一次信息的数据做差值,从而得到它们之间的增量,并将增量绘制出来。
### 3. 最终的结果
当运行上面的代码时,会绘制出下面的结果,这样看起来就一目了然了。通过这张图你可以看到,当时发送端的码率为 1.5Mbps的带宽每秒差不多发送小200个数据包。
<img src="https://static001.geekbang.org/resource/image/47/ca/4766d46db9b8c3aaece83a403f0e07ca.png" alt="">
## 小结
在本文中,我首先向你介绍了除了可以通过 RTCPeerConnection 对象的 getStats 方法获取到各种统计信息之外,还可以通过 RTCRtpSender 或 RTCRtpReceiver 的 getStats 方法获得与传输相关的统计信息。WebRTC对这些统计信息做了非常细致的分类按类型可细分为 17 种,关于这 17 种类型你可以查看文末参考中的表格。
在文中我还向你重点介绍了**编解码器、输入RTP流**以及**输出RTP流**相关的统计信息。
除此之外,在文中我还向你介绍了**网络传输**相关的统计信息是如何获得的,即通过 RTCP 协议中的 SR 和 RR 消息进行交换而来的。实际上,对于 RTCP 的知识我在前面[《06 | WebRTC中的RTP及RTCP详解》](https://time.geekbang.org/column/article/109999)一文中已经向你讲解过了,而本文所讲的内容则是 RTCP 协议的具体应用。
最后,我们通过使用第三方库 graph.js 与 getStats 方法结合,就可以将统计信息以图形的方式绘制出来,使你可以清晰地看出这些报告真正表达的意思。
## 思考时间
今天你要思考的问题是:当使用 RTCP 交换 SR/RR 信息时,如果 SR/RR包丢失了会不会影响数据的准确性呢为什么呢
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
## 参考
<img src="https://static001.geekbang.org/resource/image/72/93/72b638952a9e9d0440e9efdb4e2f4493.png" alt="">
[例子代码地址,戳这里](https://github.com/avdance/webrtc_web/tree/master/16_getstat/getstats)<br>
[第三方库地址,戳这里](https://github.com/avdance/webrtc_web/tree/master/16_getstat/getstats/js/third_party)

View File

@@ -0,0 +1,181 @@
<audio id="audio" title="17 | 如何使用Canvas绘制统计图表" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/e8/c2f35ed9afb2d068fbd1e0c2096f69e8.mp3"></audio>
**Canvas** 是 HTML5 标准中的一个新元素你可以把它想像成一块“画布”有了它你就可以在网页上绘制图像和动画了。在HTML5页面中可像使用其他元素一样使用Canvas如Video标签。为了能够在 Canvas 上绘图,浏览器为此提供了一整套 JavaScript API ,我们将在后面的代码中看到如何使用它们。
实际上,早期要想在浏览器的网页上实现动画效果,需要用到 Flash 技术。但通过Flash实现动画非常繁琐你必须专门去学习Flash相关的知识。而 Canvas 则完全不用,它就是一个简单的标签,通过几行 JavaScript 代码你就可以很容易地控制它。
Canvas 最早由苹果公司开发的,用在 Mac OS X 的 WebKit 组件中。之后,各大主流的浏览器都对 Canvas 进行了支持,所以它也被纳入到了 HTML5的规范中。
Canvas 支持 2D 和 3D 图像的绘制,并且应用领域非常广泛,基本上涵盖了 Web 图形/图像、视频、动画等领域。我们在网页中经常见到的统计图表、视频应用、网页游戏等等,都是由它来实现的。
## 基本概念和原理
在深入讲解 Canvas 之前,我们先来了解一些基础概念和原理。对于这些基础概念的理解,将会为你学习后面的知识点打下坚实的基础。
### 1. 2D图像与3D图像
在真实世界中我们看到的都是三维世界3 Dimension即立体图像。而在计算机系统中显示的图像都是二维的它是一个平面有**水平**和**垂直**两个维度。下面这张图是 2D 与 3D 的对比图。
<img src="https://static001.geekbang.org/resource/image/e2/dc/e2fb3529f54ca6ec99ff8780584211dc.png" alt="">
在计算机中3D图像是在2D图像的基础上通过增加一个**深度**来实现的就像上图中的图2所示。当然由于咱们的显示器是二维的所以三维图像最终显示的时候要将3D图像转换成 2D 图像,然后才能在显示器上显示出来,这与像机拍照是同样的原理。
### 2. 矢量图
所谓矢量图,实际上是一些“基本图元+计算公式”,图像是在展示时通过实时计算后**绘制出来**的。非矢量图是一张像素的二维数组,图像就是由这个二维数组中的**每个像素拼出来**的。
举几个简单的例子你就更清楚了:
- 如果用矢量图表述一条线,那它实际上只需要存储两个点(起点和终点)的坐标就可以了。而对于非矢量图,则需要将这两点之间的所有像素都要绘制出来。
- 如果用矢量图表述一个圆,则它只记录一个圆点和圆的半径即可。对于非矢量图,它会将圆上的每一个像素绘制出来。
通过上面两个例子我想你已经很清楚矢量图与非矢量图之间的区别了。常见的BITMAP、PNG、JPEG这些类型的图形都属于非矢量图它们都是通过绘制像素点构成的图形。也可以这么说基本上我们日常生活中的图像都是非矢量图。
### 3. 图形/图像渲染的基本原理
在讲解图形/图像渲染原理之前,你需要先知道什么是分辨率,实际上**分辨率**的概念非常简单,就是像素的宽高值,我想你应该是非常清楚了。
**分辨率**也是显示器的一个重要指标,现在显示器的**分辨率**越来越高,以前能达到 1080P 就觉得非常牛了(且价格不菲),但现在 2K 屏已经成为主流甚至4K屏也不是特别稀奇的了。
比如,我的显示器的分辨率是 1920 * 1080这里的 1920 和 1080 指的是显示器有 1920 列和1080 行,也就是说我的显示器是由 1920 * 1080 个像素组成的阵列。每一个像素由R、G、B 三种颜色组成,将这样成千上万的像素连接起来,就形成了一幅图像。
如果你想将一幅图在屏幕上显示出来,那么只需要将图形转换成像素图,这样就可以通过连接起来的像素将图像展示在显示器上。以上这个过程就是图像渲染的过程,它有一个专业术语,叫做**光栅化**。
除了上面讲到的光栅化这个术语之外,你可能还会经常看到“软渲染”“硬件加速”等术语。实际上,它们指的是由谁进行光栅化,是 CPU 还是GPU所谓“**软渲染**”是通过CPU将图形数据光栅化而“**硬件加速**”则是指通过 GPU 进行光栅化处理。
其实,在 GPU 出现之前,图形处理都是通过 CPU 完成的。只不过随着计算机图形图像技术在各个行业的广泛应用,尤其是 3D 图形处理需要大量的计算,所以产生了 GPU 芯片,专门用于图形处理,以加快图像渲染的性能,并将 CPU 释放出来。
下面我们看一下,现代计算机图形处理的基本原理,如下图所示:
<img src="https://static001.geekbang.org/resource/image/7d/4f/7dbc00b6a307b316eb4458bdd98ca94f.png" alt="">
图中 Graphic Memory、GPU、Video Controller 都是现代显卡的关键组成部分,具体处理过程大致可描述为如下。
- 应用程序处理的图形数据都是保存在 System Memory中的也就是我们经常所说的主内存中。需要硬件处理的时候先将 System Memory 中的图形数据拷贝到 Graphic Memory中。
- 然后,通过 CPU 指令通知 GPU 去处理图形数据。
- GPU收到指令后从 Graphic Memory 读取图形数据,然后进行坐标变换、着色等一系列复杂的运算后形成像素图,也就是 Video Frame会存储在缓冲区中。
- 视频控制器从 Video Frame 缓冲区中获取 Video Frame并最终显示在显示器上。
### 4. Canvas 实现原理
目前苹果浏览器使用的渲染引擎是WebKitChrome使用的渲染引擎是基于WebKit修改后的Blink它们的基本原理都差不多只是实现细节的差别。我们这里以 WebKit 为例,来看看它是如何进行图像渲染的。
无论是WebKit 还是 Blink它们都是通过 Canvas 元素对图像进行渲染。(对 WebKit 有兴趣的同学可以从 GitHub 上获取到其源码, GitHub 地址为:[https://github.com/WebKit/webkit/tree/master/Source/WebCore/html/canvas](https://github.com/WebKit/webkit/tree/master/Source/WebCore/html/canvas)
下面我们来看一下 HTML 中的 Canvas 在 WebKit 里是如何表述的 ,其实现原理图如下所示:
<img src="https://static001.geekbang.org/resource/image/2b/14/2bfe9f60285b0c726858b3fc345cc914.png" alt="">
在 WebKit 中,实现 Canvas 的逻辑还是非常复杂的,不过通过上面的原理图你可以了解到,它是由以下几个模块组成:
- CanvasRenderingContext2D类用于对 2D 图形渲染。
- WebGLRenderingContext 类,用于对 3D 图形渲染。
- 2D 图形渲染的底层使用了 Google 开源的 Skia 库。
- 3D 图形渲染的底层使用的是 OpenGL不过在 Windows 上使用的却是 D3D。
### 5. Skia 库
Skia 库最早是由 Skia 公司开发, 2005 年被 Google 收购。Google 于 2007 年将其开源。**Skia 库是开源的 2D 图形库**,由 C++ 语言实现,目前用在 Chrome、Android、Firefox 等产品。
Skia 支持基于 CPU 的软渲染,同时也支持基于 OpenGL等库的硬件加速。这个库性能比较高而且方便使用可以大大提高开发者的效率。
你目前只需要对这个库有一些简单的了解就行,如果在你的工作中需要对视频/图像进行渲染,再对其进行更详细的了解。
### 6. OpenGL VS WebGL
**OpenGLOpen Graphic Library是一个应用编程接口用于 2D/3D 图形处理**。最早是由 Silicon Graphics 公司提出的,目前是由 Khronos Group 维护。
OpenGL 虽说是一个 API但其实并没有代码实现只是定义了接口规范。至于具体实现则是由各个显卡厂家提供的。
OpenGL ES 规范是基于 OpenGL 规范的,主要用于嵌入式平台。目前 Android 系统上的视频/图像渲染都是使用的OpenGL ES。当然以前iOS也是使用 OpenGL ES 做视频/图像渲染不过现在已经改为使用自家的Metal了。
**WebGL 是用于在 HTML 页面上绘制 3D 图像的规范**。它是基于 OpenGL 规范的。比如WebGL 1.0 规范是基于 OpenGL 2.0 规范WebGL 2.0 规范基于 OpenGL 3.0 规范。
## Canvas 的使用
HTML5 新增了 `&lt;Canvas&gt;` 元素,你把它想像成一块画布就可以了,其使用方式如下:
```
&lt;canvas id=&quot;tutorial&quot; width=&quot;300&quot; height=&quot;150&quot;&gt;&lt;/canvas&gt;
```
在 HTML 中像上面一样增加 Canvas 元素后,就可以在其上面绘制各种图形了。 在 Canvas 中有 width 和 height 两个重要的属性,你可以通过它们设置 Canvas 的大小。
当然,你也可以不给 Canvas 设置任何宽高值在没有给Canvas 指定宽高值的情况下width 和 height 的默认值是 300 和 150 像素。
另外Canvas 元素本身并没有绘制功能,但你可以通过 Canvas 获取到它的渲染上下文,然后再通过 Canvas 的渲染上下文,你就可以使用底层的 OpenGL 或 Skia 来进行视频/图像的渲染了。
### 1. Canvas 渲染上下文Render Context
在浏览器内部Canvas是使用状态机进行管理的。几乎 Canvas 每个属性的改变都会导致其内部的状态发生变化,而各种视频/图形的绘制都是在不同的状态下进行的。所以你若要在 Canvas 上绘制图形,就首先要知道 Canvas 的状态,而这个状态如何获取到呢?
Canvas 提供了一个非常方便的方法,即 **getConetext 方法**。也就是说Canvas 的状态机是在它的上下文中存放着的。那么,现在我们来看一下 getContext API 的格式吧,其格式如下:
```
var ctx = canvas.getContext(contextType);
var ctx = canvas.getContext(contextType, contextAttributes);
```
其中,参数 contextType 是必填参数,其类型是一个 **DOMString** 类型的字符串,取值有如下:
- 2d将会返回 CanvasRenderingContext2D 类型的对象实例,这是一个 2D 图像渲染上下文对象,底层使用 Skia 渲染库。
- webgl返回 WebGLRenderingContext 类型的对象实例,这是一个 3D 图像渲染上下文对象,使用 WebGL 1.0 规范,是基于 OpenGL 2.0 规范的。
- webgl2返回 WebGL2RenderingContext 类型的对象实例,也是一个 3D 图像渲染上下文对象使用WebGL 2.0 规范,是基于 OpenGL 3.0 规范的。
- bitmaprenderer返回 ImageBitmapRenderingContext 类型的对象实例。
而参数 contextAttributes 是可选的,一般情况下不使用,除非你想使用一些高级特性。
下面我们就来看一下具体的例子,来看看如何通过 Canvas 元素绘制一些图形,代码如下:
```
...
let canvas = document.getElementById('canvas_id1'); //从HTML获取到Canvas
let ctx_2d = canvas.getContext('2d'); //得到 Canvas的渲染上下文
ctx_2d.fillStyle = &quot;rgb(200,0,0)&quot;; //设置颜色为红色
ctx_2d.fillRect (10, 10, 55, 50); //设置矩型的大小
ctx_2d.fillStyle = &quot;rgba(0, 0, 200, 0.5)&quot;; //设置颜色为蓝色,并且透明
ctx_2d.fillRect (30, 30, 55, 50); //设置矩型大小
...
```
在上面代码中,首先通过 getElementById 获得了 Canvas 元素,然后通过 Canvas 元素获得了它的渲染上下文。因为在获取上下文时传入的参数为**2d**,所以可以知道是获取的 2D 图像渲染上下文。拿到上下文后,通过上下文的 fillRect 方法就可以将图形绘制出来了。
下面是这段代码最终的运行结果图:
<img src="https://static001.geekbang.org/resource/image/2a/ba/2a6bf60dc69202e58626c6e23419d2ba.png" alt="">
### 2. 浏览器兼容性适配
由于 Canvas 是从 HTML5 之后才开始有的,所以比较老的浏览器是不支持 `&lt;Canvas&gt;` 标签的。不过,各浏览器在 Canvas 兼容性方面做得还是非常不错的。
对于支持 Canvas 的浏览器,直接会渲染画布中的图像或者动画;对于不支持 Canvas 的浏览器来说,你可以在 `&lt;Canvas&gt;``&lt;Canvas&gt;` 中间添加替换的的内容,这样浏览器会直接显示替换的内容。替换的内容可以是文字描述,也可以是静态图片。比如下面的代码:
```
&lt;canvas id=&quot;canvas_id1&quot; width=&quot;150&quot; height=&quot;150&quot;&gt;
The browser doesn't support the canvas tag.
&lt;/canvas&gt;
```
它表示在 IE8 执行上面的 HTML 页面,就会显示 `The browser doesn't support the canvas tag.`这句提示信息。
## 总结
图形图像相关的应用非常广泛,涉及的概念很多,技术也很复杂。在本文的开头,我向你讲解了 2D、3D 图形的基本概念和图形渲染的基本原理;后面简要地分析了 Canvas 在 WebKit 中的实现原理,并介绍了 Canvas 支持 3D 图形绘制所遵循的标准;最后,又说明了 Canvas 元素及其使用方法。
当然还有很多图形学的概念、原理在本文中并没有被提及或分析比如坐标系统、RGB 颜色模型、GPU 架构等等,由于篇幅原因,这里咱们就不做过多介绍了。如果你对这方面感兴趣的话,可以自行搜索相关内容并进一步学习。
至于 Canvas 在 WebRTC 中的具体应用,我会在下篇中再向你做详细介绍。
## 思考时间
今天你要思考的问题是: HTML5 中的视频播放除了可以使用 Video 标签播放之外,你还能想到其他可以使用的实现方式吗?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,254 @@
<audio id="audio" title="18 | 如何使用Canvas绘制统计图表" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/cf/3543c89e2982e5ceccdc5a2abd516ecf.mp3"></audio>
Google 的 Chrome 浏览器已经默认支持 WebRTC 库了,因此 Chrome浏览器之间已经可以进行音视频实时通信了。更让人欣喜的是Google还开源了 WebRTC 源码此举不仅惊艳而且非常伟大。WebRTC源码的开放为音视频实时通信领域从业者、爱好者提供了非常好的研究和学习的机会。
虽然“浏览器 + WebRTC”为广大用户提供了诸多便利但当你开发产品时会发现在浏览器上调试**媒体流**还是非常困难的。因为媒体通信涉及到了多个层面的知识,而浏览器更擅长的是处理 HTML 页面和 JavaScript 脚本,所以如果用它来分析媒体流的收发情况或者网络情况,就显得很困难了。
为了解决这个问题Google在它的 Chrome 浏览器中支持了 WebRTC 的统计分析功能,只要**在 Chrome 浏览器的地址栏输入 “chrome://webrtc-internals/ ”**,你就可以看到浏览器中正在使用的 WebRTC 的各种统计分析数据了,而且这些数据都是以可视化统计图表的方式展现在你面前的,从而大大方便了你分析媒体流的效率。
实际上关于WebRTC统计方面的内容我在前面《WebRTC中的数据统计原来这么强大》的两篇文章中已经做了详细的介绍。而今天我们要讲的主要内容是**如何使用 Canvas 进行图表的绘制**。
## 浏览器中的 WebRTC 统计图表
下面我们先通过一个实际的例子感受一下在Chome浏览器中是如何通过统计图表来展现 WebRTC 的统计信息的。要想看到这个图表,你需按以下步骤操作:
- 在 Chrome 浏览器中同时打开两个 tab 页面;
- 在两个 tab 页面中输入 [https://learningrtc.cn/getstats/index.html](https://learningrtc.cn/getstats/index.html) 地址,这是一个用于测试 WebRTC 的 URL 地址;
- 在每一个打开的页面中点击“Connect Sig Server”按钮此时每个页面都会出现两个视频窗口这说明 WebRTC 视频通信已经建立成功了;
- 在 Chrome 浏览器中再打开一个 tab 页面(也就是第三个页面),输入 chrome://webrtc-internals/ 地址,此时,在这个页面中就会展示正在运行的 WebRTC 的各种统计信息,如媒体流统计信息、网络统计信息等;
- 你可以继续点开任意一个带有 “**Stats graphs**” 字样的选项,这样相关的统计图表就会展示出来了。
在这些统计图表中,你可以看到每一路音视频流的收发包、传输码率、带宽等信息。下面两张图展示的就是视频相关的统计信息。
<img src="https://static001.geekbang.org/resource/image/34/b6/3497128fc47911e5efe66344e4f0e0b6.png" alt="">
<img src="https://static001.geekbang.org/resource/image/66/c7/66368aa298f04b6831b9a85221ac84c7.png" alt="">
在统计信息页面中,你可以点击鼠标右键,在弹出的菜单中选择“**检查**”,就会显示出该页面的 HTML 源码,也就是上面两张图中右半部分的内容。下面我们就对这个源码做一下简单的分析。
在 chrome://webrtc-internals/ 地址源码的 `&lt;head&gt;` 标签中,引用了 webrtc_internals.js 和 util.js 两个 JavaScript 脚本,代码如下所示:
```
...
&lt;head&gt;
...
&lt;script src=&quot;chrome://resources/js/util.js&quot;&gt;&lt;/script&gt;
&lt;script src=&quot;webrtc_internals.js&quot;&gt;&lt;/script&gt;
...
&lt;/head&gt;
...
```
在这两个脚本中,最关键的是 webrtc_internals.js 脚本,因为**所有统计信息的绘制都在 webrtc_internals.js 中完成的**。
那这些图表是怎么绘制出来的呢为了解开这个迷团我们来观察一下“WebRTC 统计信息图(一)”这张图。在这张图中,左侧红框框中的信息表示的是 id 为“**12756-1-bweforvideo-bweCompound-div**”的 DIV在这个 DIV 中绘制了一张**发送视频带宽**的图表。然后,我们再来看一下这张图表所对应的代码,也就是图中右侧红框框中的 HTML5 代码,从中我们可以知道,**左侧的图表是由右侧的 HTML5 代码中的 `&lt;canvas&gt;` 标签绘制而成的**。
在“WebRTC 统计信息图(二)”中,我在图的右侧用红框选中了 webrtc_internals.js在该脚本的源码中我们能够看到在 webrtc_internals.js 脚本中调用了 `getContext('2d')`API代码如下
```
...
var context = this.canvas_.getContext('2d'); //获得canvas上下文
context.fillstyle = BACKGROUND_COLOR; //设置填充颜色
context.fillRect(0, 0, width, heigth); //设置填充区域
...
```
上面的这段代码,首先通过 Canvas 获得它的上下文然后通过Canvas 上下文来设置 Canvas 的背景颜色和区域大小。通过这段代码,我们还可以得出一个结论:**WebRTC中的各种统计图表都是通过 Canvas 来绘制的**。
了解了上面的这些信息后,下面我们就来分析一下 WebRTC 是如何通过 Canvas 来绘制图表的。
## 使用 Canvas 绘制图形
Canvas 可以绘制矩形、路径、圆弧等基本几何图形,通过这些基本图形的组合,可以绘制出其他更加复杂的图形。
**除了绘制各种图形外Canvas 还可以对图形进行颜色填充和边框涂色。而对图形的操作如旋转、伸缩、位置变换等也是Canvas 必备的功能。**
下面我们就来学习一下如何通过 Canvas 来绘制统计图表。
### 1. 坐标系
通过上面的描述我想你应该已经知道了Canvas 的功能还是非常强大的通过它可以绘制各种复杂的图形。不过在使用Canvas绘制各种复杂的图形之前你必须要先了解 Canvas 的坐标系。只有对 Canvas 的坐标系了解清楚了,你才能做后面的事儿。
<img src="https://static001.geekbang.org/resource/image/70/08/707eaf21421000125b347130f9c05208.png" alt="">
Canvas 坐标系的原点在画布的左上角X坐标从左向右增长Y 坐标是从上到下增长。因为 Canvas 绘制的是像素图,所以你可以把上图坐标系中的小方格理解为一个个像素。
另外Canvas 坐标系是可以改变的。你既可以从坐标(0,0)点来绘图,也可以改成相对(x,y)点来绘图,这是通过接口 translate(x,y) 来实现的。也就是说,当你想改变坐标系的原点时,可以通过调用 translate(x,y) 这个 API 将原点设置为(x,y)点,这样你后面所有图形的绘制都是基于(x,y)这个坐标点的相对坐标了。坐标原点变换,主要是为了处理上的方便,它往往需要配合 save()/restore() 等 API来完成。
### 2. 绘制 WebRTC 图表
关于绘制 WebRTC 统计图表的实现,可以参考在 GitHub 上的[Chrome 源码](https://github.com/chromium/chromium)的镜像,在这个源码中,与绘图相关的代码主要是以下三个文件:
```
content/browser/resources/media/timeline_graph_view.js
content/browser/resources/media/data_series.js
content/browser/resources/media/stats_graph_helper.js
```
接下来,我们就对这个实现做一下简单的分析,由于功能不是很复杂,所以涉及的类也不多,通过下面这张类图就可以表示得很清楚:
<img src="https://static001.geekbang.org/resource/image/b1/48/b1540738ac5503c014fa34d617c2d048.png" alt="">
<img src="https://static001.geekbang.org/resource/image/dd/4a/dd4169d3c50bdfc6d77d460297fc704a.png" alt="">
下面我们就对上面这几个类之间的逻辑关系做一下梳理:
- 当我们在浏览器的tab页中输入 chrome://webrtc-internals/ 打开 WebRTC 的统计页面后,该页面首先会通过 WebRTC 的 RTCPeerConnection 对象的 getStats API 获取 WebRTC 的各种统计数据,然后按照不同的分类,将数据转成一个的 **DataPoint**
- chrome://webrtc-internals/ 会启动一个定时器,每秒触发一次,这样就可以每秒调用一次 getStats 方法获取统计数据,然后生成各种 **DataPoint** 对象。随着时间的推移这些DataPoint就可以连成一条线了。
- 每种 DataPoint 都被保存到 **TimelineDataSeries** 对象中。该对象是按时间排序的数据集。当然,在该数据集中所有的 DataPoint 都属于一类,举个例子,每秒钟发送端带宽属于一类,每秒钟发送的数据包个数属于另一类。
- **TimelineGraphView**对象表示的是一个以时间为主线的图表在这个图表中可以绘制多条曲线。实际上我们可以将每秒钟发送端的带宽与每秒钟发送的数据包数放在同一个TimelineGraphView中显示。但在我们的例子中每个图表只绘制了一种曲线。
- 数据准备好后,最终图形的绘制是通过 **Graph** 对象完成的它才是在Canvas中绘制图形真正的“**负责人**”。
下面咱们再从代码的角度来看一下它们具体是如何实现的吧!
**首先是 DataPoint 类**,它非常简单,定义如下:
```
function DataPoint(time, value) {
this.time = time; //数据产生的时间,以毫秒为单位
this.value = value; //数值
}
```
这个类没有什么需要特别讲的,就是记录了一个**数据产生的时间和数值**。
**其次是 TimelineDataSeries 类**,你可以结合下面的示例代码来看看它都包括哪些内容。
```
...
// 按时间顺序的列表
this.dataPoints_ = [];
// 画点的默认颜色
this.color_ = 'red';
// 是否真正的绘制图形
this.isVisible_ = true;
...
```
在该类中主要包括上面三个属性:
- dataPoints_ 用于存放 DataPoint 类型数据的数组,在实际场景中就是每秒生成的 DataPoint的数组。
- color_表示在绘制时以什么颜色画点。
- isVisible_指示数据是否真的要在图表中展示出来。
通过以上三个属性,你就可以非常清楚地知道这个类的作用是存放 DataPoint 类型数据的集合,并指明了画点时的颜色。
**再次是 TimelineGraphView 类**,它的重要属性如下所示:
```
...
//获得canvas
this.canvas_ = document.getElementById(canvasId);
// 开始时间
this.startTime_ = 0;
// 终止时间
this.endTime_ = 1;
//graph 用于绘图
this.graph_ = null;
// 横向刻度,每毫秒一个像素,默认刻度是一秒
this.scale_ = 1000;
// 初始化开启滚动条
this.updateScrollbarRange_(true);
...
```
从上面的代码中,你可以看到 TimelineGraphView 类可以获得 Canvas 元素,并保存了要绘制图形的开始时间和结束时间等信息。
其中还有一个特别关键的属性,即 graph_ 属性TimelineGraphView对象最终会使用 Graph 对象绘制图形。
**最后一个是 Graph 类**,它的重要属性定义如下:
```
...
//TimelineDataSeries 对象数组
this.dataSeries_ = [];
// Cached properties of the graph, set in layout.
this.width_ = 0; //图表的宽度
this.height_ = 0; //图表的高度
...
```
通过上面的属性你可以知道Graph 对象里存放了 TimelineDataSeries 对象数组也就是说可以在一张图表中绘制多条不同的曲线。除此之外Graph中还定义了图表的宽度和高度。
**那Graph是如何通过Canvas绘制图形的呢**?我们来看一下它的具体实现,代码如下:
```
repaint: function () {
...
let width = this.canvas_.width;
let height = this.canvas_.height;
let context = this.canvas_.getContext('2d');
//清空 Canvas
context.fillStyle = BACKGROUND_COLOR;
context.fillRect(0, 0, width, height);
...
if (this.graph_) {
...
// 画线
this.graph_.drawLines(context);
...
}
...
}
drawLines: function (context) {
...
// 编译每个 TimelineDataSeries 对象,实际只有一个
for (let i = this.dataSeries_.length - 1; i &gt;= 0; --i) {
//从 TimelineDataSeries 对象中取出值
let values = this.getValues(this.dataSeries_[i]);
if (!values) {
continue;
}
//取出画线的颜色
context.strokeStyle = this.dataSeries_[i].getColor();
context.beginPath();
for (let x = 0; x &lt; values.length; ++x) {
//连线
context.lineTo(
x, bottom - Math.round((values[x] - this.min_) * scale));
}
context.stroke();
}
}
```
在Graph中首先调用 repaint 函数进行绘图在该函数中通过Canvas元素获取到了 Canvas 的上下文,然后通过该上下文将 Canvas 清空,最后调用 drawLines 函数进行连线。
在 drawLines 函数中,它从 dataSeries 数组中取DataPoint 数据,之后调用 context.lineTo 方法将所有的点进行连接,这样就可以将 WebRTC 的统计信息通过图表的方式展示出来了。
通过上面的分析,你应该可以看出使用 HTML5 中的 Canvas 标签来绘制图表还是蛮简单的!
## 总结
本文通过一个例子,向你讲述了 WebRTC 的统计信息图表是如何通过 Canvas 来实现的。还分析了在 Chrome 浏览器中的 chrome://webrtc-internals 统计页面中各种统计信息图表实现的基本逻辑。
另外你还需要知道Chrome浏览器中提供的统计信息的功能非常强大除了本文介绍的可视化图表外更重要的是你需要理解统计数据的含义这样才更有利于分析产品中遇到的问题。对于这些数据的含义我在之前的文章中已经向你做了介绍不清楚的同学可以再回顾一下之前的内容。
所以,你如果有时间也有兴趣的话,可以对 WebRTC 做进一步了解,你也可参考本文提供的 Chrome 源码部分以及相关资料,做进一步学习。
## 思考时间
我们通过 Canvas 绘制曲线的时候,经常会出现锯齿形,这种锯齿非常难看,有什么办法可以让曲线更平滑一些吗?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,286 @@
<audio id="audio" title="19 | WebRTC能不能进行文本聊天呢" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/6a/b37ed0c56869e017d6cb04d2c44ecc6a.mp3"></audio>
WebRTC不但可以让你进行音视频通话而且还可以用它传输普通的二进制数据比如说可以利用它实现文本聊天、文件的传输等等。
WebRTC的**数据通道RTCDataChannel**是专门用来传输除了音视频数据之外的任何数据所以它的应用非常广泛如实时文字聊天、文件传输、远程桌面、游戏控制、P2P加速等都是它的应用场景。
像文本聊天、文件传输这类应用,大多数人能想到的通常是通过服务器中转数据的方案,但 WebRTC 则优先使用的是**P2P方案即两端之间直接传输数据**这样就大大减轻了服务器的压力。当然WebRTC也可以采用中继的方案这就需要你根据自己的业务需要进行选择非常灵活。
## RTCDataChannel 介绍
RTCDataChannel 就是 WebRTC 中专门用来传输非音视频数据的类它的设计模仿了WebSocket 的实现使用起来非常方便关于这一点我将在下面的“RTCDataChannel 的事件” 部分向你做更详细的介绍。
另外RTCDataChannel 支持的数据类型也非常多包括字符串、Blob、ArrayBuffer 以及 ArrayBufferView。
实际上,关于这几种类型的联系与区别我在前面[《04 | 可以把采集到的音视频数据录制下来吗?》](https://time.geekbang.org/column/article/109105)一文中已经向你做过详细的介绍,如果你现在记不清了,可以再去回顾一下。
WebRTC 的 RTCDataChannel 使用的传输协议为 SCTP即 Stream Control Transport Protocol。下面图表表示的就是在 TCP、UDP 及 SCTP等不同传输模式下数据传输的可靠性、传递方式、流控等信息的对比
<img src="https://static001.geekbang.org/resource/image/33/83/33b5d2000a04b0a49b85f8b676727b83.png" alt="">
RTCDataChannel 既可以在可靠的、有序的模式下工作,也可在不可靠的、无序的模式下工作,具体使用哪种模式可以根据用户的配置来决定。下面我们来看看它们之间的区别。
- **可靠有序模式TCP 模式)**:在这种模式下,消息可以有序到达,但同时也带来了额外的开销,所以在这种模式下**消息传输会比较慢**。
- **不可靠无序模式UDP 模式)**:在此种模式下,不保证消息可达,也不保证消息有序,但在这种模式下没有什么额外开销,所以它**非常快**。
- **部分可靠模式SCTP 模式)**:在这种模式下,消息的可达性和有序性可以根据业务需求进行配置。
那接下来我们就来看一下到底该如何配置 RTCDataChannle 对象吧。
## 配置 RTCDataChannel
在创建 RTCDataChannel 对象之前,首先要创建 RTCPeerConnection 对象,因为 **RTCDataChannel 对象是由 RTCPeerConnection 对象生成的**。有了 RTCPeerConnection 对象后,调用它的 createDataChannel 方法,就可以将 RTCDataChannel 创建出来了。具体操作如下:
```
...
var pc = new RTCPeerConnection(); //创建 RTCPeerConnection 对象
var dc = pc.createDataChannel(&quot;dc&quot;, options); //创建 RTCDataChannel对象
...
```
从上面的代码中可以看到 RTCDataChannel 对象是由 RTCPeerConnection 对象创建的,在创建 RTCDataChannel 对象时有两个参数。
- 第一个参数是一个标签(字符串),相当于给 RTCDataChannel 起了一个名字;
- 第二个参数是 options其形式如下
```
var options = {
ordered: false,
maxPacketLifeTime: 3000
};
```
其实**options**可以指定很多选项,比如像上面所设置的,指定了创建的 RTCDataChannel 是否是有序的,以及最大的存活时间。
下面我就向你详细介绍一下 options 所支持的选项。
- **ordered**:消息的传递是否有序。
- **maxPacketLifeTime**:重传消息失败的最长时间。也就是说超过这个时间后,即使消息重传失败了也不再进行重传了。
- **maxRetransmits**:重传消息失败的最大次数。
- **protocol**:用户自定义的子协议,也就是说可以根据用户自己的业务需求而定义的私有协议,默认为空。
- **negotiated**如果为true则会删除另一方数据通道的自动设置。这也意味着你可以通过自己的方式在另一侧创建具有相同ID的数据通道。
- **id**当negotiated为true时允许你提供自己的ID与channel进行绑定。
在上面的选项中,前三项是经常使用的,也是你要重点搞清楚的。不过需要特别说明的是, **maxRetransmits 与 maxPacketLifeTime 是互斥的**,也就是说这两者不能同时存在,只能二选一。
## RTCDataChannel 的事件
RTCDataChannel 的事件处理与 WebSocket 的事件处理非常相似RTCDataChannel 在打开、关闭、接收到消息以及出错时都会有接收到事件。
而当你在使用 RTCDataChannel 时,对上面所描述的这些事件都要进行处理,所以就形成了下面这样的代码模板:
```
...
dc.onerror = (error)=&gt; { //出错
...
};
dc.onopen = ()=&gt; {//打开
...
};
dc.onclose = () =&gt; {//关闭
...
};
dc.onmessage = (event)=&gt;{//收到消息
...
};
...
```
所以在使用 RTCDataChannel 对象时,你只要按上面的模板逐一实现其逻辑就好了,是不是很简单?
## 实时文字聊天
有了上面的知识,下面我们就来**看一个具体的例子,看看如何通过 RTCDataChannel 对象实现一个实时文字聊天应用**。
你可以想像这样一个场景,在两台不同的 PC 上(一个称为 A另一个称为B用户打开浏览器在页面上显示两个textarea一个作为文本输入框另一个作为聊天记录的显示框。如下图所示
<img src="https://static001.geekbang.org/resource/image/de/f6/de14364397f293a96695e5f2677650f6.png" alt="">
当A向B发消息时JavaScript会从输入框中提取文本然后通过RTCDataChannel发送出去。实际上文本通过 RTCDataChannel 发送出去后,最终是经过 RTCPeerConnection 传送出去的。同理B向A发送文本数据时也是同样的流程。另一方面当B收到A发送过来的文本数据后也要通过RTCDataChannel对象来接收文本数据。
对于 RTCDataChannel 对象的创建主要有**In-band协商和Out-of-band协商**两种方式。
### 1. In-band 协商方式
此方式是默认方式。那什么是 In-band 协商方式呢?假设通信双方中的一方调用 **createDataChannel** 创建 RTCDataChannel 对象时,将 options 参数中的 **negotiated **字段设置为false则通信的另一方就可以通过它的 RTCPeerConnection 对象的 **ondatachannel** 事件来得到与对方通信的 RTCDataChannel 对象了,这种方式就是 In-band 协商方式。
那In-band 协商方式到底是如何工作的呢?下面我们就来详细描述一下。
- A端调用 createDataChannel 创建 RTCDataChannel 对象。
- A端与B端交换 SDP即进行媒体协商offer/answer
- 媒体协商完成之后双方连接就建立成功了。此时A端就可以向B端发送消息了。
- 当B端收到A端发的消息后B端的 ondatachannel 事件被触发B端的处理程序就可以从该事件的参数中获得与A端通信的 RTCDataChannel 对象。需要注意的是该对象与A端创建的 RTCDataChannel 具有相同的属性。
- 此时双方的 RTCDataChannel 对象就可以进行双向通信了。
该方法的**优势是 RTCDataChannel 对象可以在需要时自动创建,不需要应用程序做额外的逻辑处理**。
### 2. Out-of-band 协商方式
RTCDataChannel 对象还能使用 Out-of-band 协商方式创建,这种方式不再是一端调用 createDataChannel另一端监听 ondatachannel 事件,从而实现双方的数据通信;而是两端都调用 createDataChannel 方法创建 RTCDataChannel 对象,再通过 ID 绑定来实现双方的数据通信。具体步骤如下:
- A端调用 createDataChannel({negotiated: true, id: 0}) 方法;
- B也调用 createDataChannel({negotiated: true, id: 0}) 方法;
- 双方交换 SDP 即进行媒体协商( offer/answer
- 一旦双方连接建立起来,数据通道可以被立即使用,它们是通过 ID 进行匹配的这里的ID就是上面 options 中指定的IDID 号必须一致)。
这种方法的优势是B端不需等待有消息发送来再创建RTCDataChannel对象所以双方发送数据时不用考虑顺序问题即谁都可以先发数据这是与In-band方式的最大不同这也**使得应用代码变得简单**,因为你不需要处理 ondatachannel 事件了。
另外,需要注意的是,你选的 ID 不能是任意值。ID值是从0开始计数的也就是说你第一次创建的 RTCDataChannel 对象的 ID 是0第二个是 1依次类推。所以这些ID只能与WebRTC实现协商的SCTP流数量一样如果你使用的 ID 太大了,而又没有那么多的 SCTP 流的话,那么你的数据通道就不能正常工作了。
## 具体例子
了解完相关理论后,接下来我们就实践起来,结合具体例子将这些理论应用起来。
在本文的例子中,我们使用的是 In-band 协商方式来创建 RTCDataChannel 对象。下面我们就来一步一步操作,看看一个文本聊天应用是如何实现的。
### 1. 添加事件
为页面上的每个按钮添加 **onclick 事件**,具体如下面的示例代码所示:
```
var startButton = document.querySelector('button#startButton');
var callButton = document.querySelector('button#callButton');
var sendButton = document.querySelector('button#sendButton');
var closeButton = document.querySelector('button#closeButton');
startButton.onclick = connectServer; //createConnection;
callButton.onclick = call;
sendButton.onclick = sendData;
closeButton.onclick = closeDataChannels;
```
在这个段代码中定义了 4 个 button其中 Start 按钮用于与信令服务器建立连接Call 用于创建 RTCDataChannel 对象Send 用于发送文本数据Close用于关闭连接释放资源。
### 2. 创建连接
用户在页面上点击 **Start** 按钮时,会调用 **connectServer** 方法。具体代码如下:
```
function connectServer(){
socket = io.connect(); //与服务器建立连接
...
socket.on('created', function(room) { //第一个用户加入后收到的消息
createConnection();
});
socket.on('joined', function(room) { //第二个用户加入后收到的消息
createConnection();
});
...
}
```
从代码中可以看到connectServer 函数首先调用 `io.connect()` 连接信令服务器,然后再根据信令服务器下发的消息做不同的处理。
>
需要注意的是,在本例中我们使用了 socket.io 库与信令服务器建立连接。
如果消息是 **created****joined**,则调用 createConnection 创建 RTCPeerConnection。其代码如下
```
var servers = {'iceServers': [{
'urls': 'turn:youdomain:3478',
'credential': &quot;passwd&quot;,
'username': &quot;username&quot;
}]
};
pc = new RTCPeerConnection(servers, pcConstraint);
pc.onicecandidate = handleIceCandidate; //收集候选者
pc.ondatachannel = onDataChannelAdded; //当对接创建数据通道时会回调该方法。
```
通过上面的代码就将 RTCPeerConnection对象创建好了。
### 3. 创建 RTCDataChannel
当用户点击 Call 按钮时会创建RTCDataChannel并发送 offer。具体代码如下
```
dc = pc.createDataChannel('sendDataChannel',
dataConstraint); //一端主动创建 RTCDataChannel
...
dc.onmessage = receivedMessage; //当有文本数据来时,回调该函数。
pc.createOffer(setLocalAndSendMessage,
onCreateSessionDescriptionError); //创建offer如果成功则在 setLocalAndSendMessage 函数中将 SDP 发送给远端
```
当其中一方创建了 RTCDataChannel 且通信双方完成了媒体协商、交换了 SDP 之后另一端收到发送端的消息ondatachannel 事件就会被触发。此时就会调用它的回调函数onDataChannelAdded ,通过 onDataChannelAdded 函数的参数 event 你就可以获取到另一端的 RTCDataChannel 对象了。具体如下所示:
```
function onDataChannelAdded(event) {
dc = event.channel;
dc.onmessage = receivedMessage;
...
}
```
至此,双方就可以通过 RTCDataChannel 对象进行双向通信了。
### 4. 数据的发送与接收
数据的发送非常简单,当用户点击 Send 按钮后,文本数据就会通过 RTCDataChannel 传输到远端。其代码如下:
```
function sendData() {
var data = dataChannelSend.value;
dc.send(data);
}
```
而对于接收数据,则是通过 RTCDataChannel 的 onmessage 事件实现的。当该事件触发后,会调用 receivedMessage 方法。通过其参数就可以获取到对端发送的文本数据了。具体代码如下:
```
function receivedMessage(e) {
var msg = e.data;
if (msg) {
dataChannelReceive.value += &quot;&lt;- &quot; + msg + &quot;\n&quot;;
}
};
```
以上就是文本聊天的大体逻辑。具体的代码你可以到(文末的)[GitHub链接](https://github.com/avdance/webrtc_web/tree/master/19_chat/)上获取。
## 小结
本文我们结合具体的例子——实时文字聊天,向你详细介绍了如何使用 RTCDataChannel 进行非音视频数据的传输。
RTCDataChannel的创建有两种方式一种是默认的In-band协商方式另一种是Out-of-band协商方式。在本文例子的实践部分我们主要应用的是第一种方式。但一般情况下我更推荐使用第二种方式因为它更高效、更简洁。
另外,在使用 RTCDataChannel 时,还有两点你需要注意:
1. RTCDataChannel对象的创建要在媒体协商offer/answer 之前创建,否则 WebRTC 就会一直处于 connecting 状态,从而导致数据无法进行传输。
1. RTCDataChannel对象是可以双向传输数据的所以接收与发送使用一个 RTCDataChannel对象即可而不需要为发送和接收单独创建 RTCDataChannel 对象。
当然本文只是介绍了RTCDataChannel的“一种”具体应用若你有兴趣还可以自行实践其他更有趣的实现。
## 思考时间
今天留给你的思考题是SCTP 协议是运行在 TCP 协议之上还是 UDP 协议之上呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
## 参考
具体代码地址:[https://github.com/avdance/webrtc_web/tree/master/19_chat/](https://github.com/avdance/webrtc_web/tree/master/19_chat/)

View File

@@ -0,0 +1,242 @@
<audio id="audio" title="20 | 原来WebRTC还可以实时传输文件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/4b/147616a845aa2ab845f9a625b60fa94b.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/127360)中我向你介绍了在 WebRTC 中如何传输非音视频数据并通过实现一个1对1实时聊天的例子向你展示了如何使用RTCDataChannel 对象进行文本数据的传输。
其实利用 WebRTC 的 RTCDataChannel 对象,不光可以实现 1 对 1 的实时聊天,你还可以利用它进行**实时的文件传输**。
实时文件传输是一个非常有用的工具,尤其是通过浏览器进行实时文件传输就更加有价值,因为**它不会受到操作系统或开发语言的影响**,所以你可以在任何不同的操作系统上进行文件的传输,非常方便。
举个例子,由于工作的需要,你可能经常需要在不同的操作系统间切来切去(一会儿在 Windows 系统上开发,一会儿在 Linux 系统上开发,一会儿又在 Mac 系统上开发),当你想将 Windows上的文件传到 Linux系统上时就特别不方便但如果可以通过浏览器传输文件的话那将会大大提高你的工作效率。
## 基本原理
在WebRTC中**实时文件的传输与实时文本消息传输的基本原理是一样的,都是使用 RTCDataChannel 对象进行传输**。但它们之间还是有一些差别的,一方面是**传输数据的类型**不一样,另一方面是**数据的大小**不一样。下面我们就从这两方面来具体讨论一下。
在[上一篇文章](https://time.geekbang.org/column/article/127360)中,我曾向你介绍过 RTCDataChannel 对象支持很多数据类型包括字符串、Blob、ArrayBuffer 以及 ArrayBufferView由于文本聊天发送的数据都比较小而且都是普通文本所以在实时文本聊天中只需要使用**字符串**类型的数据就可以了。但文件的传输就不一样了,它一般都是以二进制的方式传输数据的,所以更多的是使用 **Blob 和 ArrayBuffer** 类型。
相对于文本消息来说,文件的大小要大得多,因此在传输文件过程中失败的概率也会大很多。虽然 WebRTC 为了达到传输的可靠性,对非音视频数据可以选择将数据通道配置成可靠的、有序的传输,但在真实的场景中还是有可能因为各种原因导致文件传输过程中半途被中止,比如说,在文件传输的过程中网线掉了或者电脑没电了等情况发生时。
为了解决这个问题,一般情况下我们都会采用**断点续传**的方式进行文件的传输。那么如何进行断点续传呢?这就要用到信令服务器了。比较简单的方式是将一个大文件划分成固定大小的“块”,然后按“块”进行传输,并且每传输完一“块”数据,接收端就发一个通知消息告知发送端该块数据它已经接收到了。发送端收到该消息后,就会更新被传输文件的描述信息,这样一旦文件传输过程中发生中断,**下次启动时还可以从上次发生断点的地方继续传输**。
## 文件传输的具体实现
了解完实时文件传输的基本原理后,接下来我们就来看一下它的具体实现吧!
利用 WebRTC 的 RTCDataChannel对象进行实时文件的传输与实时文本消息的传输在代码逻辑上是很相似的。接下来我们就按照其实施步骤创建RTCDataChannel 对象、接收数据、文件读取与发送、传递文件信息)做下详细讲解。
### 1. RTCDataChannel 对象的创建
RTCDataChannel 对象的创建与在实时文本聊天中 RTCDataChannel对象的创建基本是一致的具体示例代码如下
```
...
//创建 RTCDataChannel 对象的选项
var options = {
ordered: true,
maxRetransmits : 30
};
//创建 RTCPeerConnection 对象
var pc = new RTCPeerConnection();
//创建 RTCDataChannel 对象
var dc = pc.createDataChannel(&quot;dc&quot;, options);
...
```
通过对比,你可以看到它们之间的不同是:在实时文件传输中创建 RTCDataChannel 对象带了 options 参数,而实时文本聊天中并没有带该参数。
在这个示例中之所以要带 options 参数,是因为**在端与端之间传输文件时,必须要保证文件内容的有序和完整**,所以在创建 RTCDataChannel 对象时,你需要给它设置一些参数,即需要设置 **ordered****maxRetransmits** 选项。当 ordered 设置为真后,就可以保证数据的有序到达;而 maxRetransmits 设置为30则保证在有丢包的情况下可以对丢包进行重传并且最多尝试重传 30 次。
通过实践证明,如果你在创建 RTCDataChannel 对象时不设置这两个参数的话,那么在传输大文件(如 800M 大小的文件)时就很容易失败。而设置了这两个参数后,传输大文件时基本就没再失败过了,由此可见这两个参数的重要性了。
### 2. 通过 RTCDataChannel 对象接收数据
创建好 RTCDataChannel 对象后,你仍然要实现 RTCDataChannel 对象的 4 个重要事件(打开、关闭、接收到消息以及出错时接收到事件)的回调函数,代码如下:
```
dc.onerror = (error)=&gt; {
...
};
dc.onopen = ()=&gt; {
...
};
dc.onclose = () =&gt; {
...
};
dc.onmessage = (event)=&gt;{
...
}
...
```
这四个事件的作用如下:
- **onerror**,是指当发生连接失败时的处理逻辑;
- **onopen**,是指当 datachannel 打开时的处理逻辑;
- **onclose**,是指当 datachannel 关闭时的处理逻辑;
- **onmessage**,是指当收到消息时的处理逻辑。
其中最重要的是 **onmessage 事件**,当有数据到达时就会触发该事件。那接下来,我们就看一下到底该如何实现这个事件处理函数,具体代码如下:
```
...
var receiveBuffer = []; //存放数据的数组
var receiveSize = 0; //数据大小
...
onmessage = (event) =&gt; {
//每次事件被触发时,说明有数据来了,将收到的数据放到数组中
receiveBuffer.push(event.data);
//更新已经收到的数据的长度
receivedSize += event.data.byteLength;
//如果接收到的字节数与文件大小相同,则创建文件
if (receivedSize === fileSize) { //fileSize 是通过信令传过来的
//创建文件
var received = new Blob(receiveBuffer, {type: 'application/octet-stream'});
//将buffer和 size 清空,为下一次传文件做准备
receiveBuffer = [];
receiveSize = 0;
//生成下载地址
downloadAnchor.href = URL.createObjectURL(received);
downloadAnchor.download = fileName;
downloadAnchor.textContent =
`Click to download '${fileName}' (${fileSize} bytes)`;
downloadAnchor.style.display = 'block';
}
}
```
上面这段代码的逻辑还是非常简单的每当该函数被调用时说明被传输文件的一部分数据已经到达了。这时你只需要简单地将收到的这块数据push到receiveBuffer数组中即可。
当文件的所有数据都收到后,即`receivedSize === fileSize`条件成立时你就可以以receiveBuffer[] 数组为参数创建一个Blob对象了。紧接着再给这个 Blob 对象创建一个下载地址,这样接收端的用户就可以通过该地址获取到文件了。
### 3. 文件的读取与发送
前面讲完了文件的接收,现在我们再来看一下文件的读取与发送。实际上这块逻辑也非常简单,代码如下:
```
...
function sendData(){
var offset = 0; //偏移量
var chunkSize = 16384; //每次传输的块大小
var file = fileInput.files[0]; //要传输的文件它是通过HTML中的file获取的
...
//创建fileReader来读取文件
fileReader = new FileReader();
...
fileReader.onload = e =&gt; { //当数据被加载时触发该事件
...
dc.send(e.target.result); //发送数据
offset += e.target.result.byteLength; //更改已读数据的偏移量
...
if (offset &lt; file.size) { //如果文件没有被读完
readSlice(offset); // 读取数据
}
}
var readSlice = o =&gt; {
const slice = file.slice(offset, o + chunkSize); //计算数据位置
fileReader.readAsArrayBuffer(slice); //读取 16K 数据
};
readSlice(0); //开始读取数据
}
...
```
在这段示例代码中,数据的读取是通过 sendData 函数实现的。在该函数中,使用 FileReader 对象每次从文件中读取 16K 的数据,然后再调用 RTCDataChannel 对象的 send 方法将其发送出去。
这段代码中有**两个关键点**:一是 sendData 整个函数的执行是 readSlice(0) 开始的;二是 FileReader 对象的 onload 事件是在有数据被读入到FileReader的缓冲区之后才触发的。掌握了这两个关键点你就非常容易理解 sendData 函数的逻辑了。
那该怎么理解这两个关键点呢?实际上, sendData 函数在创建了 FileReader 对象之后,下面的代码都不会执行,直到调用 readSlice(0) 才开始从文件中读取数据当数据被读到FileReader对象的缓冲区之后就会触发onload事件从而开始执行onload事件的回调函数。而在这个回调函数中是一个循环不断地从文件中读取数据、发送数据直到读到文件结束为止。以上就是 sendData 函数的逻辑。
### 4. 通过信令传递文件的基本信息
上面我已经将 RTCDataChannel 对象的创建、数据发送与接收的方法以及JavaScript对文件进行读取的操作向你做了详细的介绍。但还有一块儿重要的知识需要向你讲解那就是接收端是如何知道发送端所要传输的文件大小、类型以及文件名的呢
其实这个问题也不麻烦,解决的办法就是在传输文件之前,发送端先通过信令服务器将要传输文件的基本信息发送给接收端。
那我们就来看一下发送端是如何处理的,具体代码如下:
>
需要说明的是在本例中JavaScript 脚本是通过 socket.io 库与信令服务器通信的。
```
...
//获取文件相关的信息
fileName = file.name;
fileSize = file.size;
fileType = file.type;
lastModifyTime = file.lastModified;
//向信令服务器发送消息
sendMessage(roomid,
{
//将文件信息以 JSON 格式发磅
type: 'fileinfo',
name: file.name,
size: file.size,
filetype: file.type,
lastmodify: file.lastModified
}
);
```
在本段代码中,发送端首先获得被传输文件的基本信息,如文件名、文件类型、文件大小等,然后再通过 socket.io 以 JSON 的格式将这些信息发给信令服务器。
信令服务器收到该消息后不做其他处理,直接转发到接收端。下面是接收端收到消息后的处理逻辑,代码如下:
```
...
socket.on('message', (roomid, data) =&gt; {
...
//如果是 fileinfo 类型的消息
if(data.hasOwnProperty('type') &amp;&amp; data.type === 'fileinfo'){
//读出文件的基本信息
fileName = data.name;
fileType = data.filetype;
fileSize = data.size;
lastModifyTime = data.lastModify;
...
}
...
});
...
```
在接收端的 socket.io一直在侦听 message 消息,当收到 message 消息且类型type为 fileinfo时说明对方已经将要传输文件的基本信息发送过来了。
接收到文件的基本信息后,接收端的处理逻辑也非常简单,只需要将传过来的基本信息保存起来就好了,等整个文件传输完成后,再根据这些信息生成对应的文件,这样接收端就可以将传输的文件拿到手了。
## 小结
本文我首先向你介绍了使用 RTCDataChannel 对象进行文件传输的基本工作原理,实际上它与实时文本消息的用法基本相同,只不过由于文件传输对数据的有序性和完整性有特别的要求,所以你在创建 RTCDataChannel 对象时一定要将其 options 选项中的 ordered 和 maxRetransmits 设置好。
之后,我又向你详细讲解了如何实现一个端到端文件的传输,其整个实现主要包括四个步骤,即 RTCDataChannel 对象的创建、数据的发送、数据的接收以及文件基本信息的传输。
在实际的应用中,由于某些特殊原因,文件在传输过程中还是会被中断掉,如网络连接断了、传输被人为暂停了等等。对于这类情况,你可以使用断点续传的方法来恢复被中断的文件传输。这个并不麻烦,如果你感兴趣,可以自行学习和研究下。
## 思考时间
在上面描述中我们有提到,当文件在传输过程中被中断后,可以通过断点续传的方式恢复被中断的文件传输,这种情况一般是在浏览器页面没有被关闭的情况下才可以做到。但如果页面被关闭了,或者说浏览器被关了,是否还有办法恢复被中断的传输?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,121 @@
<audio id="audio" title="21 | 如何保证数据传输的安全(上)?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/c1/357132681a854edb2a09a2cb492f26c1.mp3"></audio>
数据安全越来越受到人们的重视,尤其是一些敏感数据,如重要的视频、音频等。在实现音视频通信的过程中,如果在网络上传输的音视频数据是未加密的,那么黑客就可以利用 Wireshark 等工具将它们录制下来,并很容易地将它们播放出来并泄漏出去。
如果这些音视频的内容涉及到股票交易或者其他一些更敏感的内容的话,很可能会造成不可挽回的损失。
对于浏览器更是如此,在全球至少有几亿用户在使用浏览器,这么大的用户量,如果通过浏览器进行音视频传输时,没有对音视频数据进行安全保护的话,那将会产生灾难性的后果。
既然数据安全这么重要,那接下来我将带你了解一下数据安全方面的相关概念。只有将这些基本概念搞清楚了,你才知道 WebRTC 是如何对数据进行防护的。
## 非对称加密
目前对于数据的安全保护多采用非对称加密,这一方法在我们的日常生活中被广泛应用。那**什么是非对称加密呢**?下面我就向你简要介绍一下。
在非对称加密中有两个特别重要的概念,即**公钥**与**私钥**。它们起到什么作用呢?这里我们可以结合一个具体的例子来了解一下它们的用处。
有一个人叫小K他有一把特制的锁以及两把特制的钥匙——公钥和私钥。这把锁有个非常有意思的特点那就是**用公钥上了锁,只能用私钥打开;而用私钥上的锁,则只能公钥打开**。
这下好了小K正好交了几个异性笔友他们在书信往来的时候难免有一些“小秘密”不想让别人知道。因此小K多造了几把公钥给每个笔友一把当笔友给他写好的书信用公钥上了锁之后就只能由小K打开因为只有小K有私钥公钥上的锁只有私钥可以打开这样就保证了书信内容的安全。
从这个例子中你可以看到小K的笔友使用公钥对内容进行了加密只有小K可以用自己手中的私钥进行解密这种对同一把锁使用不同钥匙进行加密/解密的方式称为**非对称加密**,而**对称加密**则使用的是同一把钥匙,这是它们两者之间的区别。
## 数字签名
了解了非对称加密,接下来你就可以很容易理解什么是**数字签名**了。
首先我们来讲一下数字签名是解决什么问题的。实际上,数字签名并不是为了防止数据内容被盗取的,而是解决如何能证明数据内容没有窜改的问题。为了让你更好地理解这个问题,我们还是结合具体的例子来说明吧。
还是以前面的小K为例他觉得自己与多个异性交往太累了并且看破红尘决定出家了。于是他写了一封公开信告诉他的异性朋友这个决定。但小K的朋友们认为这太不可思议了她们就猜测会不会是其他人冒充小K写的这封信呢
那小K该如何证明这封公开信就是他自己写的呢他想到了一个办法将信中的内容做个Hash值只要是同样的内容就会产生同样的Hash值并用他的私钥将这个Hash值进行了加密。这样他的异性朋友就可以通过她们各自手里的公钥进行解密然后将解密后的Hash值与自己计算的公开信的Hash值做对比这里假设她们都是技术高手哈发现 Hash 值是一样的于是确认这封信真的是小K写的了。
数字签名实际上就是上面这样一个过程。在互联网上,很多信息都是公开的,但如何能证明这些公开的信息确实是发布人所写的呢?就是使用**数字签名**。
## 数字证书
实际上在数字签名中我们是假设小K的朋友们手里的公钥都是真的公钥如果这个假设条件成立的话那整个流程运行就没有任何问题。但是否有可能她们手里的公钥是假的呢这种情况还是存在很大可能性的。
那该如何避免这种情况发生呢?为了解决这个问题,**数字证书**就应运而生了。
小K的朋友们为了防止自己手里的公钥被冒充或是假的就让小K去“公证处”即证书授权中心对他的公钥进行公证。“公证处”用自己的私钥对小K的公钥、身份证、地址、电话等信息做了加密并生成了一个证书。
这样小K的朋友们就可以通过“公证处”的公钥及小K在“公证处”生成的证书拿到小K的公钥了从此再也不怕公钥被假冒了。
到这里,从非对称加密,到数字签名,再到数字证书就形成了一整套安全机制。在这个安全机制的保护下,就没人可以非法获得你的隐私数据了。
## X509
了解了互联网的整套安全机制之后接下来我们再来看一下真实的证书都包括哪些内容。这里我们以X509为例。**X509是一种最通用的公钥证书格式**。它是由国际电信联盟ITU-T制定的国际标准主要包含了以下内容
- **版本号**目前的版本是3。
- **证书持有人的公钥、算法(指明密钥属于哪种密码系统)的标识符和其他相关的密钥参数**。
- **证书的序列号**是由CA给予每一个证书分配的唯一的数字型编号。
- ……
从中你可以看到最关键的一点是通过X509证书你可以拿到“**证书持有人公钥**”,有了这个公钥你就可以对发布人发布的信息进行确认了。
在真实的场景中,你一般不会去直接处理数字证书,而是通过 OpenSSL 库进行处理,该库的功能特别强大,是专门用于处理数据安全的一套基础库,在下一篇文章中我们会对它做专门介绍。
## 5种常见的加密算法及作用
介绍完数字签名、数字证书等相关概念后,下面我们再来学习一下几种常见的加密算法及其作用,了解它们的作用,对你后面学习 WebRTC 的数据安全有非常重要的意义。
### 1. MD5算法
MD5算法使用的是**哈希函数**它一般用于对一段信息产生信息摘要以防止数据内容被窜改。实际上MD5不能算是一种加密算法而应该算作一种摘要算法。无论你输入多长的数据MD5算法都会输出长度为128bits 的位串。
### 2. SHA1算法
介绍完 MD5 后,我们再来看看 SHA1 算法。SHA1 和 MD5 的功能很类似,但相较而言,它**比 MD5 的安全性更强**。SHA1 算法会产生 160 位的消息摘要,一般应用于检查文件完整性以及数字签名等场景。
### 3. HMAC算法
HMACHash-based Message Authentication Code使用 MD5、SHA1 算法,对密钥和输入消息进行操作,输出消息摘要。
HMAC 对发送方和接收方的 key 进行计算,并生成消息摘要,而其他人由于没有发送方或接收方的 key所以无法计算出正确的哈希值从而防止数据被窜改。
### 4. RSA算法
RSA是1977年由三位数学家Rivest、Shamir 和 Adleman 设计的一种算法。RSA 就是以三位数学家名字的首字母组成的。RSA 算法是目前**最流行的非对称加密算法**。
### 5. ECC 算法
ECC也是一种**非对称加密算法**,它的安全性比 RSA 更高,不过性能要差一些。
以上我们简要介绍了5种常见的加密算法对于这些算法的具体实现不是本文的重点本文的重点是让你对这些数据加密、安全等概念有一些了解因为以后你研究 WebRTC 数据安全相关的源码时就会用到这些概念。
所以,弄清楚以上这些数据安全相关的概念,对你后面深入学习 WebRTC 具有重要的意义。
## 小结
在本文中,我向你介绍了非对称加密、公钥/私钥、数字签名、数字证书以及目前最通用的证书格式 X509在文章的最后我还向你简要介绍了几种最常见的加密算法及作用。
由于数据安全涉及到的知识点特别多,所以本文更多的是向你介绍为了达到数据安全都用到了哪些方法,以及这些方法是因何而来的。
只有理解了这其中的道理,你在后面才能更好地理解 WebRTC 在数据安全方面的一些做法。其实,在学习任何新知识之前,我都很建议你按照这个思路去学习,也就是先了解其背后的原理或道理,然后再学习它的具体实现,这样可以让你的学习效率事半功倍。
而且依我的经验来看,越是比较难以理解或难以学习的知识,比如像 WebRTC 这种,就越应该按照这种方法来学习。倒是对于一些比较浅显的知识,反而通过实战和摸索的方式更有效果。
以上是我学习 WebRTC 的一点心得,你可以参考下,希望对你能有所帮助!
## 思考时间
今天你要思考的问题是WebRTC中使用的数据安全机制与 HTTPS 使用的安全机制是否是一样的呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
## 参考
以下是 X509 证书的详细内容,你可以了解下。
- 版本号目前的版本是3。
- 证书持有人的公钥、算法(指明密钥属于哪种密码系统)的标识符和其他相关的密钥参数。
- 证书的序列号:证书颁发机构给每一个证书分配的唯一的数字型编号。
- 主题信息:证书持有人唯一的标识符。
- 证书的有效期:证书的起始时间以及终止时间。
- 认证机构:证书的发布者。
- 发布者的数字签名:这是“认证中心”私钥生成的签名,以确保这个证书在发放之后没有被窜改过。
- 签名算法标识符:用来指定签署证书时所使用的签名算法。

View File

@@ -0,0 +1,261 @@
<audio id="audio" title="22 | 如何保证数据传输的安全(下)?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/9d/91abdfbd75d250c84cbc79ad53774d9d.mp3"></audio>
在[上一篇文章](https://time.geekbang.org/column/article/128405)中,我向你介绍了保证数据传输安全的一些**基本概念**,如非对称加密、数字证书等等。那本文我们就结合这些基本概念,来一起看看 **WebRTC 是如何保障数据安全的**
WebRTC的一个重要应用就是在浏览器上实现音视频实时通信正如[上一篇文章](https://time.geekbang.org/column/article/128405)中所讲到的,在浏览器上由于用户量巨大,所以对于音视频的通信必须要有一套机制来保证音视频数据的安全性。
实际上,在浏览器上对于音视频数据的安全最终还是由 WebRTC 库自己保证的。它是通过使用 SDP、STUN、DTLS、SRTP等几个协议的结合来达到数据安全的。
## WebRTC 数据安全机制
为了保障音频数据的安全WebRTC使用了一整套机制来进行保护下面我们就来看一下 WebRTC 是如何保障数据安全的吧!
我们来假设一个场景, A 与 B 通信,为了保障数据的安全,我们只需要在 A 端发送数据前将要发送的数据进行加密B端收到数据之后再进行解密就好了这样看起来保证数据的安全还是蛮简单的事情。
但这里有一个问题,**B端是如何知道 A 端使用的哪种加密算法进行加密的呢**?另外,加密算法还分对称加密和非对称加密,我们应该选择哪个呢?实际上在[上一篇文章](https://time.geekbang.org/column/article/128405)中我已经向你做了介绍,对于加密来说,使用非对称加密是最安全的,因此**选择非对称加密是必然的选择**。
既然选择非对称加密,那么 A 端与 B 端就要交换各自的公钥,这样当 A 端使用私钥加密时B端才能用 A 的公钥进行解密。同样的道理B 端使用自己的私钥进行加密时A 端可以使用 B 端的公钥进行解密。
按照上面的描述,你会发现其逻辑上有个安全漏洞,即 A 与 B 交换公钥时并没有进行任何防护。黑客完全可以用各种手段在A与B 交换公钥时获取到这些公钥,这样他们就可以轻而易举地将传输的音视频数据进行解密了。
为了解决这个问题WebRTC引入了 **DTLS**Datagram Transport Layer Security至于DTLS 的实现细节,你暂时不用关心,后面我们会对它做详细的讲解。你现在只要知道**通过 DTLS 协议就可以有效地解决 A 与 B 之间交换公钥时可能被窃取的问题**就好了。
A 与 B 交换公钥时被窃取的问题解决后,是不是双方通信就安全了呢?
我们再来想像一个场景,还是 A 与 B 通信,但此时 B 并不是真正的 B ,而是冒充的,这样 A 与 B 通信时冒充的B 就获得了 A 的重要信息。其实这种情况更多发生在会议系统或在线教育的小班课中,此时会议中有多人进行互动,如果黑客进入了会议中,他只需听别人说话,自己不发言,这样就将关键的信息窃取走了。所以现在的问题又来了,我们该**如何辨别对方的身份是否合法呢**
看到辨别身份的问题是不是似曾相识?在[上一篇文章](https://time.geekbang.org/column/article/128405)中我向你介绍过通过数字签名的方式可以防止内容被窜改。WebRTC 也是使用的这种方式,它首先通过信令服务器交换 SDPSDP 信息中包括了以下几个重要信息:
```
...
a=ice-ufrag:khLS
a=ice-pwd:cxLzteJaJBou3DspNaPsJhlQ
a=fingerprint:sha-256 FA:14:42:3B:C7:97:1B:E8:AE:0C2:71:03:05:05:16:8F:B9:C7:98:E9:60:43:4B:5B:2C:28:EE:5C:8F3
...
```
SDP 交换完成后A与B 都获取到了对方的 ice-ufrag、ice-pwd 和 fingerprint 信息,有了这些信息后,就可验证对方是否是一个合法用户了。
其中, **ice-ufrag 和 ice-pwd 是用户名和密码**。当 A 与B 建立连接时A要带着它的用户名和密码过来此时 B 端就可以通过验证 A 带来的用户名和密码与SDP中的用户名和密码是否一致的来判断 A 是否是一个合法用户了。
除此之外,**fingerprint**也是验证合法性的关键一步,它是存放公钥证书的**指纹(或叫信息摘要)**,在通过 ice-ufrag 和 ice-pwd 验证用户的合法性之余,还要对它发送的证书做验证,看看证书在传输的过程中是否被窜改了。
通过上面的描述你就可以知道 WebRTC 在数据安全方面做了非常多的努力了。下面的序列图就清楚地表述了我上面所讲述的内容。
<img src="https://static001.geekbang.org/resource/image/f4/8b/f4c9a00f7b6af630d9a23e913dec9f8b.png" alt="">
从这张图中你可以看到, A 与 B 在传输数据之前,需要经历如下几个步骤。
- 首先通过信令服务器交换 SDP信息也就是进行媒体协商。在SDP中记录了用户的用户名、密码和指纹有了这些信息就可以对用户的身份进行确认了。
- 紧接着A 通过 STUN 协议(底层使用 UDP 协议)进行身份认证。如果 STUN 消息中的用户名和密码与交换的SDP中的用户名和密码一致则说明是合法用户。
- 确认用户为合法用户后,则需要进行 DTLS 协商,交换公钥证书并协商密码相关的信息。同时还要通过 fingerprint 对证书进行验证,确认其没有在传输中被窜改。
- 最后,再使用协商后的密码信息和公钥对数据进行加密,开始传输音视频数据。
以上就是 WebRTC 保证数据安全的整套机制。
前面我们说了WebRTC是通过使用DTLS、SRTP等几个协议的结合来达到数据安全的那接下来我们就来分别看一下这几个协议是如何实现的。
## DTLS 协议
说到网络上的数据安全你可能首先想到的是 HTTPS你也可以简单地将 HTTPS 理解为“HTTP 协议 + 数据加密”当然实际上它要复杂得多。HTTPS 的底层最初是使用 SSLSecure Sockets Layer安全套接层协议对数据加密。当SSL更新到3.0时IETF对SSL 3.0进行了标准化并增加了一些新的功能不过基本与SSL 3.0没什么区别标准化后的SSL更名为TLS 1.0Transport Layer Security安全传输层协议所以可以说TLS 1.0 就是SSL的3.1版本。
TLS 协议由**TLS 记录协议**和**TLS握手协议**组成:
- TLS 记录协议,用于数据的加密、数据完整性检测等;
- TLS 握手协议,主要用于密钥的交换与身份的确认。
由于 TLS底层是基于 TCP 协议的,而 WebRTC 音视频数据的传输主要基于 UDP 协议因此WebRTC 对数据的保护无法直接使用 TLS 协议。但 TLS 协议在数据安全方面做得确实非常完善,所以人们就想到是否可以将 TLS 协议移植到 UDP 协议上呢? 因此 DTLS 就应运而生了。
所以你可以认为**DTLS 就是运行在 UDP 协议之上的简化版本的 TLS**,它使用的安全机制与 TLS 几乎一模一样。
在 DTLS 协议中,最关键是的它的**握手协议**,正如下图所展示的这样:
<img src="https://static001.geekbang.org/resource/image/da/48/dac8f31eebe4f35b018aa457eb957a48.png" alt="">
在 WebRTC 中为了更有效地保护音视频数据,所以需要使用 DTLS 协议交换公钥证书,并确认使用的密码算法,这个过程在 DTLS 协议中称为**握手协议**。
DTLS 的握手过程如下:
- 首先 DTLS 协议采用 C/S 模式进行通信,其中发起请求的一端为客户端,接收请求的为服务端。
- 客户端向服务端发送 ClientHello 消息,服务端收到请求后,回 ServerHello 消息,并将自己的证书发送给客户端,同时请求客户端证书。
- 客户端收到证书后,将自己的证书发给服务端,并让服务端确认加密算法。
- 服务端确认加密算法后,发送 Finished 消息,至此握手结束。
DTLS 握手结束之后,通信双方就可以开始相互发送音视频数据了。
## OpenSSL库
讲到数据安全就不得不提 OpenSSL 库通过它的名字你也基本可以知道它是做什么的。OpenSSL 是一个开源的 SSL 实现正如我们上面说到的SSL 是 TLS 早期的名字实际上OpenSSL 实现了整个 TLS 协议。
不仅如此OpenSSL 还实现了 DTLS 协议,由于其代码开源,实现得又特别高效,所以现在大部分需要数据安全的应用程序基本上都是使用 OpenSSL 来实现的。
关于OpenSSL库有以下几个基本概念你一定要清楚。
- SSL_CTXSSL 上下文,主要指明你要使用的 SSL 版本是多少。
- SSL代表一个 SSL 连接,你可以把它看作是一个句柄,一般还要与一个具体的 socket 进行绑定。
- SSL_Write用于向 SSL 连接写数据。
- SSL_Read用于从 SSL 连接读数据。
那OpenSSL到底该如何使用呢其实整体还是蛮简单的下面我们就来看一下如何使用 OpenSSL具体步骤可阐述为如下。
**第一步,初始化 SSL**。在这一步调用`SSL_library_init()`初始化 OpenSSL 库,然后加载 OpenSSL 支持的所有算法,以及相关的错误信息。
```
SSL_library_init()
OpenSSL_add_all_algorithms() /* 载入Openssl所支持的算法 */
SSL_load_error_strings() /* 载入Openssl的相关错误信息 */
```
**第二步,创建 SSL 上下文**。在这一步可以指定使用的 SSL 协议是哪个版本的。
```
SSL_CTX * ctx = SSL_CTX_new(SSLv23_server_method())
```
**第三步,加载证书**。如下所示,该函数的第一个参数是 SSL 上下文,第二个参数是要加载的证书,第三个参数为证书类型。
```
SSL_CTX_use_certificate_file(ctx, certificate.crt, SSL_FILETYPE_PEM)
```
**第四步,加载私钥**。在这一步可以先将私钥加载进来,然后再检测私钥是否正确。
```
SSL_CTX_use_PrivateKey_file(ctx, prikey.pem, SSL_FILETYPE_PEM)
SSL_CTX_check_private_key(ctx) /*检查私钥是否正确*/
```
**第五步,建立 SSL 并与 Socket 绑定**。在这一步,首先通过 SSL 上下文创建 SSL 对象;然后,将 SSL 对象与已经创建好的 socket 进行绑定;最后是建立 SSL 连接。
```
SSL *ssl = SSL_new(ctx) /* 创建 SSL 用于通信 */
SSL_set_fd(ssl, socket_fd) /* 与 socket 绑定 */
SSL_accept(ssl) /* 建立 SSL 连接 */
```
**第六步,使用 SSL 进行数据通信**。主要通过 SSL_write 和 SSL_read 发送和接收数据。
```
SSL_write(ssl, buf, strlen(buf)) /* 向 ssl 发数据消息通过SSL加密 */
SSL_read(ssl, buf, MAXBUF) /* 从 ssl 接收消息 */
```
**第七步,释放资源**。当 SSL 使用完后,需要将占用的资源全部释放掉,怎么实现呢?首先将 SSL 连接关掉,然后释放 SSL 对象,最后释放 SSL 上下文。
```
SSL_shutdown(ssl) /* 关闭 SSL 连接 */
SSL_free(ssl) /* 释放 SSL */
SSL_CTX_free(ctx) /* 释放 CTX */
```
以上这七步就是使用 OpenSSL 的基本步骤。为了更好地理解和应用OpenSSL你熟悉完这每一个步骤后最好可以自己再将它们串联起来形成自己的知识体系。
## SRTP/SRTCP协议
在[《06 | WebRTC中的RTP及RTCP详解》](https://time.geekbang.org/column/article/109999)一文中我向你详细介绍了 RTP/RTCP 协议通过该文你可以了解到RTP/RTCP 协议并没有对它的负载数据进行任何保护。因此,如果你通过抓包工具,如 Wireshark将音视频数据抓取到后通过该工具就可以直接将音视频流播放出来这是非常恐怖的事情。
在WebRTC中为了防止这类事情发生没有直接使用 RTP/RTCP 协议,而是使用了 SRTP/SRTCP 协议 ,即安全的 RTP/RTCP 协议。
**WebRTC 使用了非常有名的 libsrtp 库将原来的 RTP/RTCP 协议数据转换成 SRTP/SRTCP协议数据**。libsrtp 的使用非常简单,具体步骤可总结为如下。
**第一步,初始化 libsrtp。**
```
srtp_init();
```
**第二步,创建 Session**。创建 Session 要略微复杂一些,这过程中需要指定创建的策略,比如使用哪种算法进行内容的完整性检测,解码时的公钥是什么,等等。
```
...
srtp_policy_t policy;
// Set all policy fields to 0.
std::memset(&amp;policy, 0, sizeof(srtp_policy_t)); //清空结构体
//指定用哪种算法进行内容的完整性检测
switch (profile){
case Profile::AES_CM_128_HMAC_SHA1_80:
srtp_crypto_policy_set_aes_cm_128_hmac_sha1_80(&amp;policy.rtp); srtp_crypto_policy_set_aes_cm_128_hmac_sha1_80(&amp;policy.rtcp);
break;
case Profile::AES_CM_128_HMAC_SHA1_32:
srtp_crypto_policy_set_aes_cm_128_hmac_sha1_32(&amp;policy.rtp);
srtp_crypto_policy_set_aes_cm_128_hmac_sha1_80(&amp;policy.rtcp); // NOTE: Must be 80 for RTCP!.
break;
default:
...
}
policy.ssrc.value = 0;
policy.key = key; //指定解码时的公钥
// Required for sending RTP retransmission without RTX.
policy.allow_repeat_tx = 1; //使用RTX进行RTP包重传
policy.window_size = 1024; //窗口缓冲区大小
policy.next = nullptr;
// Set the SRTP session.
srtp_err_status_t err = srtp_create(&amp;this-&gt;session, &amp;policy);
...
```
**第三步对RTP包加密**。如下面示例代码所示:
```
srtp_err_status_t err = srtp_protect(this-&gt;session,
(void*)EncryptBuffer,
reinterpret_cast&lt;int*&gt;(len));
```
第一个参数是Session第二个参数是要加密的数据第三个参数是被加密数据的长度。需要注意的是加密后的数据也存放在EncryptBuffer即输入数据与输出数据共用同一块内容。
**第四步,对 SRTP 包解密**。这同上面的第三步类似,只不过操作相反,这里变成了解密。
```
srtp_err_status_t err = srtp_unprotect(this-&gt;session,
(void*)data,
reinterpret_cast&lt;int*&gt;(len));
```
**第五步,也就是最后一步,是释放资源**
```
srtp_shutdown()
```
以上这五步就是使用 libsrtp 对 RTP 数据加密/解密的基本步骤。
## 小结
本文首先向你介绍了 WebRTC 为了保护音视频数据的安全性,提供的是怎样一整套安全机制,然后又对这一套安全机制中的几个重点概念做了详细讲解,如 DTLS 协议、OpenSSL 库的使用以及如何将 RTP/RTCP 数据转成 SRTP/SRTCP 数据。
在介绍 DTLS 协议时,我们重点讲解了它是如何进行握手操作的,这里你只需要了解 DTLS 握手的基本原理即可,不用对细节进行特别的追究,因为握手的所有操作实际上都是由 OpenSSL 库实现的,你只需要调用它的 API即可。
然后,我们又详细介绍了使用 OpenSSL 库的基本步骤,一共分成七大步。但这里所讲的七大步只是使用 OpenSSL 的一个基本步骤,对于 OpenSSL 的 DTLS 协议握手的 API 我这里并没有进行讲解但有了这个基础我相信你自己一定可能自行学习OpenSSL 的其他相关知识了。
最后,我们还讲解了 libsrtp 库的使用,这个库使用起来非常简单,关键点还是你对[上一篇文章](https://time.geekbang.org/column/article/128405)中安全相关的概念是否清楚如果清楚的话就很容易理解libsrtp 的使用方法了。所以说,那些安全相关的概念虽很基础,但真的很重要。
通过上面的描述,我想现在你应该已经对 WebRTC 如何保护数据的安全有了一个清楚的认知了吧!
## 思考时间
今天留给你的思考题是RTP 包被加密为 SRTP包这个过程是对整个包进行的加密还是对RTP包的Payload部分加的密
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,237 @@
<audio id="audio" title="23 | 实战演练通过WebRTC实现一个1对1音视频实时直播系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/89/3f95cc2626c73e76680800a5a6cf8689.mp3"></audio>
在前面的 22 篇文章中,我分步骤向你介绍了如何在浏览器上实现一套简单的直播系统。比如,如何采集音视频数据,如何在同一个浏览器上进行音视频通话,如何传输非音视频数据,等等。
但这些知识点都是一个个独立的,并没有形成一个完整的系统,那么本篇文章我们就将所有这些知识点串联起来,做成一个完整的系统。当这套系统搭建完成之后,你就可以在浏览器上实现从北京到上海这种远距离的实时通信了。
接下来我们就来详述这套系统的实现步骤运行环境的搭建Web 服务器的实现信令系统的实现TURN 服务器的搭建RTCPeerConnection的创建音视频数据的采集、传输、渲染与播放。通过这部分内容的讲解再配合我们之前的文章相信你一定可以快速搭建出这样一套系统来。
## 运行环境搭建
要实现这套系统,运行环境是特别关键的,其中有三个条件是必须要满足的:**云主机、2M以上的带宽**和**HTTPS证书**。
为什么需要这三个条件呢?下面我们就来详细看一下。
**云主机**就不必多说了,要实现远距离的音视频通信,就得进行信令的交换和数据的中转,这些都需要我们有一台云主机。换句话说,有了云主机后,通信的双方才可以进行信令的交换;在 P2P 不通的情况下,通过服务器中转的方式才能保障端与端之间的连通性。
另外在实现这套系统时对云主机的性能要求并不高2核的 CPU、1G/2G 的内存就够了。但对**带宽**的高求就比较高了,至少要 2M 以上的带宽,而且是越多越好。
需要 2M 以上带宽的原因我们之前就分析过了,对于一路 720P 的视频,一般情况下需要 1.2M 的带宽,因此在两人通信时,如果都走服务器中转的话就需要 2.4M 的带宽了。所以为了保证双方通信顺畅,带宽是必须要能跟得上的。
第三个必要条件是HTTPS 证书。那为什么需要这个条件呢?出于安全的原因,浏览器要求我们使用 HTTPS 协议从服务器请求 JavaScript 脚本,只有通过 HTTPS请求的脚本才能访问音视频设备。这是因为通过 HTTPS 访问的服务都是通过安全机构认证过的,这样就保证了音视频设备被访问的安全性。而 HTTP 协议就做不到这一点(因为没有任何安全机构对它们做认证),为了保证用户不会在自己不知情的情况下被偷拍或被录音,**因此在浏览器上通过HTTP 请求下来的 JavaScript 脚本是不允话访问音视频设备的**。
我曾见过很多同学抱怨使用 HTTP 请求的 JavaScript 脚本不能访问音视频设备的问题,其实这就是因为他们没有理解隐私安全,也没有站在用户的角度去思考问题。
最后,额外说明一下我个人的一些关于云主机的使用心得吧,云主机的操作系统最好选择使用 **Ubuntu并且版本越新越好**这是因为在Ubuntu上边安装依赖库比较方便。另外在国内申请域名时还要进行备案而备案时间需要 10 天以上,非常繁琐。所以如果你特别着急的话,可以购买国外的云主机和域名,一般可以免费使用一年,并且在国外购买域名不需要进行备案,这样可以节省不少时间。
## 实现Web服务器
对于这部分内容我们在[《11 | 如何通过Node.js实现一套最简单的信令系统](https://time.geekbang.org/column/article/114179)一文中已经向你做过介绍了,但没有讲 HTTPS 的部分。实际上,在 Node.js 中 HTTPS Server 的实现与 HTTP Server 的实现是类似的,我们来看一下具体的代码:
```
var https = require('https');
var express = require('express');
var serveIndex = require('serve-index');
...
//使用express实现WEB服务
var app = express();
app.use(serveIndex('./public'));
app.use(express.static('./public'));
//HTTPS 证书和密钥文件
var options = {
key : fs.readFileSync('./cert/1557605_www.learningrtc.cn.key'),
cert: fs.readFileSync('./cert/1557605_www.learningrtc.cn.pem')
}
//https server
var https_server = https.createServer(options, app);
var io = socketIo.listen(https_server);
https_server.listen(443, '0.0.0.0');
...
```
上面的代码中引入了 express 库,它的功能非常强大,用它来实现 Web 服务器非常方便。上面的代码同时还引入 HTTPS 服务,并让 Web 服务运行于 HTTPS 之上,这样 HTTPS的 Web 服务器就实现好了。
有了 HTTPS 的 Web 服务器之后,我们就可以将客户端代码(如 HTML、CSS、JavaScript 放到Web服务的 public 目录下,这样在通过域名访问时,浏览器就将客户端代码下载下来,并渲染到浏览器上,然后我们就可以从浏览器上看到用户界面了。
## 最简单的信令系统
信令是系统的“灵魂”。在我们要实现的这个直播系统中,由谁来发起呼叫、什么时间发 SDP 等各种操作都是由信令控制的。
我们这个直播系统的信令相对还是比较简单的,其客户端和服务端的信令可大致总结为如下几种。
**客户端命令**
- join用户加入房间。
- leave用户离开房间。
- message端到端命令offer、answer、candidate
**服务端命令**
- joined用户已加入。
- leaved用户已离开。
- other_joined其他用户已加入。
- bye其他用户已离开。
- full房间已满。
那这些信令之间是怎样一种关系呢?在什么情况下该发送怎样的信令呢?要回答这个问题我们就要看一下信令状态机了。
### 信令状态机
我们这个系统的信令是由一个信令状态机来管理的,在不同的状态下,需要发送不同的信令。当收到服务端或对端的信令后,状态也会随之发生改变,下面我们来看一下这个状态的变化图吧。
<img src="https://static001.geekbang.org/resource/image/92/ed/9242bd0a57710831022a72b080eb52ed.png" alt="">
在初始时,客户端处于 init/leaved 状态。在 init/leaved 状态下,用户只能发送 join 消息。服务端收到 join 消息后,会返回 joined 消息。此时,客户端会更新为 joined 状态。
在 joined 状态下,客户端有多种选择,收到不同的消息会切到不同的状态。
- 如果用户离开房间,那客户端又回到了初始状态,即 init/leaved 状态。
- 如果客户端收到 second user join 消息,则切换到 joined_conn 状态。在这种状态下,两个用户就可以进行通话了。
- 如果客户端收到 second user leave 消息,则切换到 joined_unbind 状态。其实 joined_unbind 状态与 joined 状态基本是一致的。
如果客户端处于 joined_conn 状态,当它收到 second user leave 消息时,会转成 joined_unbind 状态。
如果客户端是 joined_unbind 状态,当它收到 second user join 消息时,会切到 joined_conn 状态。
通过上面的状态图,我们就可以清楚地知道在什么状态下应该发什么信令;或者说,发什么样的信令,信令状态就会发生怎样的变化了。
### 信令系统的实现
在我们这个系统中信令的实现还是通过“Node + socket.io”来实现的。上面我们已经详细介绍了信令的状态是如何变化的再结合[《11 | 如何通过Node.js实现一套最简单的信令系统](https://time.geekbang.org/column/article/114179)一文中介绍的如何通过 socket.io 实现信令服务器,这样你就可以很轻松地将信令系统实现出来了。
这里我就不将详细的代码列出来了,在文章的最后我会将本文的示例代码展示出来,你可以参考它来实现。
## TURN服务器的搭建
在之前的文章中,有一个重要的知识点一直没介绍,那就是 TURN 服务。它有两个作用,一是提供 STUN 服务,客户端可以通过 STUN 服务获取自己的外网地址;二是提供数据中继服务。
当通信的双方无法通过 P2P 进行数据传输时,为了保证数据的连通性,我们就必须使用中继的方式让通信双方的数据可以互通,如下图所示:
<img src="https://static001.geekbang.org/resource/image/e3/68/e32cd7ea6aa175c8a64739d9c95a3968.png" alt="">
目前最著名的 TURN 服务器是由 Google 发起的开源项目 coturn它的源码和项目描述可以在 GitHub 上找到,地址为:[https://github.com/coturn/coturn。](https://github.com/coturn/coturn%E3%80%82)
coturn的编译安装与部署还是比较简单的。首先我们来看看如何编译安装coturn其步骤如下
- 从 [https://github.com/coturn/coturn](https://github.com/coturn/coturn) 下载 coturn 代码;
- 执行 `./configure --prefix=/usr/local/coturn` 生成 Makefile
- 执行 `make` 来编译 coturn
- 执行 `sudo make install` 进行安装。
接下来是布署coturn 的配置文件中有很多选项不过这些选项大部分都可以不用或采用默认值因此我们只需要修改4项内容就可以将coturn 服务配置好了这4项配置如下
<img src="https://static001.geekbang.org/resource/image/eb/b3/ebdd9a607e4dee9e7016285281e33ab3.png" alt="">
这里需要注意的是在网上能看到很多讲解coturn配置的文章但其中大部分配置都是有问题的所以建议你按照本文所讲的步骤进行配置避免浪费你宝贵的时间。
## 音视频数据的采集
音视频数据采集的过程我们已经在[《01 | 原来通过浏览器访问摄像头这么容易》](https://time.geekbang.org/column/article/107948)一文中做过详细介绍了,**通过 getUserMedia 就可以获取到**,就不再赘述了。
不过需要注意的是我们系统采集音视频数据的时间点与以前不一样了以前是在浏览器显示页面时就开始采集了而现在则是在用户点击“Connect Sig Server”按钮时才开始采集音视频数据。
## 创建 RTCPeerConnection
信令系统建立好后后面的逻辑都是围绕着信令系统建立起来的RTCPeerConnection 对象也不例外。
在客户端,用户要想与远端通话,首先要发送 join 消息,也就是要先进入房间。此时,如果服务器判定用户是合法的,则会给客户端回 joined 消息。
客户端收到 joined 消息后,就要创建 RTCPeerConnection 对象了,也就是要建立一条与远端通话的音视频数据传输通道。
下面,我们就结合示例代码来看一下 RTCPeerConnection 是如何建立的。
```
...
var pcConfig = {
'iceServers': [{ //指定 ICE 服务器信令
'urls': 'turn:stun.al.learningrtc.cn:3478', //turn服务器地址
'credential': &quot;passwd&quot;, //turn服务器密码你要用自己的
'username': &quot;username&quot; //turn服务器用户名你要用自己的
}]
};
...
function createPeerConnection(){
if(!pc){
pc = new RTCPeerConnection(pcConfig); //创建peerconnection对象
...
pc.ontrack = getRemoteStream; //当远端的track到来时会触发该事件
}else {
console.log('the pc have be created!');
}
return;
}
...
```
上面这段代码需要注意的是创建 RTCPeerConnection 对象时的 pcConfig 参数,在该参数中我们设置了 TURN 服务器地址、用户名和密码,这样当 RTCPeerConnection 通过 P2P 建立连接失败时就会使用TURN 服务器进行数据中继。
RTCPeerConnection 对象创建好后,我们要将前面获取的音视频数据与它绑定到一起,这样才能通过 RTCPeerConnection 对象将音视频数据传输出去。绑定的步骤如下:
```
function bindTracks(){
...
//add all track into peer connection
localStream.getTracks().forEach((track)=&gt;{
pc.addTrack(track, localStream); //将track与peerconnection绑定
});
}
```
在上面的代码中,从 localStream 中遍历所有的track然后添加到 RTCPeerConnection 对象中就好了。
以上工作完成后,接下来最重要的一步就是**媒体协商**了,对于媒体协商的过程我们已经在[《08 | 有话好商量,论媒体协商》](https://time.geekbang.org/column/article/111675)一文中做过详细分析了。具体的逻辑我就不再重复了,不过需要注意的是,在本文中媒体协商的消息都是通过信令进行交互的,具体的实现可以参考文末的示例代码。
## 音视频的渲染与播放
按照上面的步骤音视频数据就可以被采集到了RTCPeerConnection 对象也创建好了,通过信令服务器也可以将各端的 Candidate 交换完成了。
此时在WebRTC 的底层就会进行连通性检测,它首先判断通信的双方是否在同一个局域网内,如果在同一个局域网内,则让双方直接进行连接;如果不在同一局域网内,则尝试用 P2P 连接,如果仍然不成功,则使用 TURN 服务进行数据中继。
一旦数据连通后,数据就从一端源源不断地传到了远端,此时远端只需要将数据与播放器对接,就可以看到对端的视频、听到对方的声音了。
在浏览器上实现这一点非常容易,当数据流过来的时候会触发 RTCPeerConnection对象的 ontrack 事件,只要我们侦听该事件,并在回调函数中将收到的 track 与`&lt;video&gt;`标签绑定到一起就好了,代码如下:
```
var remoteVideo = document.querySelector('video#remotevideo');
...
function getRemoteStream(e){ //事件处理函数
remoteStream = e.streams[0]; //保存远端的流
remoteVideo.srcObject = e.streams[0]; //与HTML中的视频标签绑定
}
...
pc = new RTCPeerConnection(pcConfig);
...
pc.ontrack = getRemoteStrea //当远端的track过来时触发该事件
...
```
通过上面的代码,我们可以看到,当 ontrack 事件触发时,会调用 getRemoteStream 函数,该函数从参数 e 中取出stream 赋值给 remoteVideo`&lt;video&gt;`标签)的 srcObject 属性,这就可以了。
## 小结
以上就是浏览器端实现1对1实时通话的整体逻辑。在本文中我通过对运行环境的搭建、Web 服务器的实现、信令系统的实现、TURN 服务器的搭建、RTCPeerConnection的创建、音视频数据的采集、传输、渲染与播放这几个主题的介绍向你完整地讲解了在浏览器上实现1对1实时音视频通话的过程。
在这几个主题中运行环境的搭建、信令系统的实现、TURN 服务器的搭建是之前没有讲过的知识,其他的知识点我在前面的文章中都有向你详细介绍过。如果你不记得或者觉得生疏了,可以再复习一下前面的文章。
总之,通过本文的“串联”,你就可以实现一个真正的 1 对 1 的实时音视频直播系统了。赶紧操练起来吧!
## 思考时间
你是否可以将以前所学的所有功能都集成到一起,形成一套相对完整的直播系统呢?
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
[所做Demo的GitHub链接有需要可以点这里](https://github.com/avdance/webrtc_web/tree/master/23_living)