77-从0打造音视频直播系统

开篇词 | 5G的到来将会为音视频插上飞翔的翅膀

在浩如烟海的生命长河中，人类文明已经有几千年的历史。从远古时期，人类就梦想着有一天可以听到、看到千里之外的声音和景象，当时的人们认为只有神仙（千里眼和顺风耳）才能做到。

其实在这漫长的时间里，真正推动人类大跨度进步的时期只是最近几百年。到了第二次工业革命期间的 1876 年，贝尔电话的发明，使人类可以听到千里之外声音的梦想终于成真。

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

另一方面，从中国 3G 网络正式商用开始，移动网络也发生了翻天覆地的变化。从 3G 到 4G ，再到马上要落地的 5G，移动网络的带宽和质量越来越高，为音视频数据传输打下了坚实的基础。

我有幸于 2010 年初，参与研发了当时所在公司自主知识产权的音视频引擎系统，自此便一直从事音视频方面的研发工作，到现在已经将近十年的从业经验。

在此期间，我所开发的直播系统既可以支持上万人同时在线，又可以进行多人实时音视频互动，此外还可以与固话、MCU等硬件设备互联互通。

这套系统可以用于多种场景，如音视频会议、在线教育（大班课、小班课、1 对 1）、即时通讯等。

在开发直播系统期间，我既做过大负载高并发的音视频流媒体服务器的设计与开发，也做过客户端音视频引擎的设计与开发。经过这么多年的洗礼，可以说对音视频技术及未来的发展已了然于胸。

音频技术的现在与未来

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

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

WebRTC 有个特别有诱惑力的愿景：可以在浏览器上快速开发出各种音视频应用。其实，这一愿景已经实现。通过本专栏的学习，你就可以轻松实现浏览器之间的音视频通话，是不是很酷？

可以预见，未来音视频技术将会作为一种基础技术应用到更广泛的场景中。它可以与 AR/VR 结合，让你在远端体验虚拟与现实，如虚拟服装体验；也可以与人工智能结合用于提高服务质量，如用于教学上帮助老师提高教学质量；它还可以与物联网结合，用在自动驾驶、家庭办公等领域。

行业及学习痛点

随着各种音视频技术的完善、5G 的落地，以及各种音视频应用的出现，在未来两三年内，音视频必定会像当年移动互联网一样出现井喷的人才需求。

然而，音视频技术本身纷繁、复杂，需要很深的专业及技术背景。举个简单的例子，当进行音视频互动时，经常会听到自己的回音，这是什么原因引起的呢？再比如说，用户进入教室后经常看到老师的视频卡顿、花屏，这是什么原因造成的？你又该如何解决呢？如果你没有很深的背景知识，对音视频编解码原理不清楚，对音视频处理流程不了解的话，你是根本无法解决上述问题的。

另外，如果你想开发一款音视频产品，不仅需要有最基础的音视频知识（如音视频的编码、解码），往往还需要多层级的技术栈，涉及移动端开发、PC 端开发、各种协议规范、网络协议、socket 开发等。所以，要想成为一员合格的音视频开发工程师，你需要对各领域的知识都有一些掌握才行。

如何学习音视频技术

看到上面这些困难，是不是很多同学开始打退堂鼓了？

其实你不必担心。音视频技术虽然门槛高，但我所开的这个专栏是从 0 开始讲解音视的相关知识，一步一步，循序渐进：首先，让你学会如何使用浏览器相关 API 调用 WebRTC 实现 1 对 1 通话；然后，再逐步深入学习其他音视频知识。

因此，在本专栏中，我将通过下面三大主题来进行讲解，并且在整个的讲解过程中，我会采取循序渐进、各个击破的办法来介绍各个知识点，最终不仅让你掌握到音视频的核心知识，而且真正能依靠这些知识做出你想要的音视频产品。

主题一：WebRTC 1 对 1 通话

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

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

主题二：WebRTC 多人音视频实时通话

学完 WebRTC 1 对 1 通话这部分内容后，你对 WebRTC 就有了一定基础。接下来，我将为你介绍如何实现多人音视频实时互动。

在这个主题里，我会首先介绍几种多人音视频实时互动的架构，以及这几种架构的优劣。然后，重点讲解如何使用 SFU 架构实现多人音视频实时通话（SFU 是现在最流行的多人实时互动架构）。当你阅读完本部分知识后，就可以亲手实现多人音视频实时通话了。

主题三：支持上万人同时在线的直播系统

支持上万人同时在线的直播系统主要使用 CDN 技术，它是一种比较老的直播架构，使用的底层传输协议是 RTMP 和 HLS。

在本主题中，我会重点介绍 CDN 原理、RTMP、HLS 协议，以及如何使用各种播放器从 CDN 拉取媒体流。同样地，你阅读完本主题内容后，就会清楚地知道上万人同时在线直播的原理，并可以自己实现一套这样的直播系统。

下面就是这个专栏的目录，你可以快速了解下整个专栏的知识结构体系。

到这里，依然可能会有同学质疑说：“做音视频给人的感觉就是个小众市场呀！发展前景会好吗？”

我觉得这里我有必要再强调和说明下。那是过去，由于网络带宽的限制，以及音视频技术本身的一些原因，音视频技术无法被广泛地应用。但现在不一样了，随着近几年技术的发展，音视频已经越来越被广泛地应用于各行各业，比如平时常见的抖音、微信短视频、娱乐直播、教育直播、音视频会议等，已经逐渐渗透到我们工作和生活的各个角落。就连大热的 AI 技术也与音视频技术联系非常紧密，像现在的智能音箱、自动驾驶、人脸识别、表情识别等都离不开音视频技术。

另外，让人欣喜的是随着 Google 对 WebRTC 的推动，在浏览器下实现音视频应用变有极为简单，这大大降低了非音视频专业的开发人员学习成本，并且其应用前景也变得更加广阔。

可以说，这么有前景的技术非专业人员也可以很快学习和上手，而掌握了音视频技术的核心技术，一定可以让你在未来职场上获得丰厚的回报和满满的成就感。

5G 的时代马上到来，它将会为音视频插上飞翔的翅膀，让我们共同起飞吧！你准备好了吗？

01 | 原来通过浏览器访问摄像头这么容易

对于很多从事 JavaScript 开发的同学来说，基本都认为 JavaScript 是专门做页面控制的。如果用 JavaScript 做音视频处理，那真是很难想象的事儿。你可能首先想到的问题是：JavaScript 或者浏览器的性能跟得上吗？

而 Google 却不这么认为。 Google 就是要做一些常人无法想象，又难以理解的事情，否则它就不是 Google 了。

“浏览器 + WebRTC”就是 Google 给出的答案。2011 年，Google 创立了 WebRTC 项目，其愿景就是可以在浏览器之间快速地实现音视频通信。

随着 WebRTC 1.0 规范的推出，现在主流浏览器 Chrome、Firefox、Safari 以及 Edge 都已经支持了 WebRTC 库。换句话说，在这些浏览器之间进行实时音视频通信已经很成熟了。

下面我就通过讲解 JavaScript/ 浏览器访问电脑上的音视频设备，向你展示通过现代浏览器访问音视频设备是何其简单。

WebRTC 处理过程

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

WebRTC 1 对 1 音视频实时通话过程示意图

上面这幅图是整个 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）。其中，媒体流可以存放 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的定义，其格式如下：

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 标签播放出来。

<!DOCTYPE html>

<html>

    <head>

        <title>Realtime communication with WebRTC</title>

        <link rel="stylesheet", href="css/client.css" />

    </head>

    <body>

        <h1>Realtime communication with WebRTC </h1>

        <video autoplay playsinline></video>

        <script src="js/client.js"></script>

    </body>

</html>

为便于你更好地理解该部分的知识，上面这段代码中有两条代码我需要解释一下，一句是：

<video autoplay playsinline></video>

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

autoplay，表示当页面加载时可以自动播放视频；

playsinline，表示在 HTML5 页面内播放视频，而不是使用系统播放器播放视频。

另一句是:

<script src="js/client.js"></script>

它引入了外部的 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 时，将回音消除选项打开就可以了，一下子解决了世界难题。

最后，我还通过一个例子向你具体展示了视频采集后的效果。相信通过这些讲解和展示，你应该已经感受到目前浏览器的强大，以及它可以做更多、更有意思的音视频相关的事情了。

这里你也可以看一下我做出来的效果图（没有美颜）：

思考时间

上面我们一起学习了如何通过getUserMedia获取到音视频数据。而在真实的场景中，我们往往不但要获取到默认设备的音视频数据，还要能获取到某个指定的设备的音视频数据。比如，手机上一般都有两个摄像头——前置摄像头和后置摄像头。那么，你有没有办法采集到指定摄像头的视频数据呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

参考

getUserMedia API 控制设备的参数及其含义如下：

02 | 如何通过WebRTC进行音视频设备检测呢？

使用过音视频会议或在线教育等实时互动软件的同学都知道，在打开摄像头（Camera）或麦克风（Micphone）的时候，首先要对其进行检测，检测的内容包括：

电脑 / 手机上都有那些音视频设备？

我们选中的音视频设备是否可用？

以手机为例，它一般会包括前置摄像头和后置摄像头。我们可以根据自己的需要，选择打开不同的摄像头。当然，手机上不单有多个摄像头，麦克风的种类就更多了， 如：

系统内置麦克

外插的耳机

蓝牙耳机

……

以上这些设备，我们都可以通过手动或自动的方式打开它们。

那么，WebRTC 是否提供了相关的接口，以便我们查询自己机子上都有哪些音视频设备呢？答案是肯定的。

下面我们就来看看如何使用浏览器下 WebRTC API 来显示我们的音视频设备吧。

WebRTC 处理过程

在正式讲解之前，咱们先回顾一下 WebRTC 的整体处理过程图，以便让你清楚地知道咱们这篇文章在整个处理过程中的位置。

WebRTC 1 对 1 音视频实时通话过程示意图

上面这幅图与第一篇文章中的图几乎是一模一样的。其差别在于，我将图中两个音视频设备检测模块置红了。

这样，我们本文所讲的内容在整个 WebRTC 处理过程中的位置就一目了然了！

音视频设备的基本原理

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

1. 音频设备

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

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

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

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

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

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

2. 视频设备

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

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

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

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

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

YUV 格式还是蛮复杂的，它有好几种存储方式，需要用一整篇的文章来详述才行。所以，在这里我就不一一描述了，如果你想进一步了解其相关知识可以到网上搜索相关资料自行学习。

通过上面的讲解，现在你应该对音频设备与视频设备都有一个基本的认知了。

WebRTC 设备管理的基本概念

在讲解如何通过浏览器的 WebRTC API 获取音视频设备之前，咱们先了解几个 WebRTC 关于设备的基本概念。如果这些基本概念不清楚的话，就很难理解后面的知识。

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

MediaDeviceInfo，它表示的是每个输入 / 输出设备的信息。包含以下三个重要的属性：

deviceID，设备的唯一标识；

label，设备名称；

kind，设备种类，可用于识别出是音频设备还是视频设备，是输入设备还是输出设备。

需要注意的是，出于安全原因，除非用户已被授予访问媒体设备的权限（要想授予权限需要使用 HTTPS 请求），否则 label 字段始终为空。

另外，label 可以用作指纹识别机制的一部分，以识别是否是合法用户。对于这一点我们以后再专门讨论。

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 < 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() 方法就可以获取到媒体输入和输出设备列表，例如： 麦克风、相机、耳机等。是不是非常简单？

该函数返回的是一个 Promise 对象。我们只需要向它的 then 部分传入一个函数，就可以通过该函数获得所有的音视频设备信息了。

传入的函数有一个参数，它是一个 MediaDeviceInfo 类型的数组，用来存放 WebRTC 获取到的每一个音视频设备信息。

这样说可能有点抽象，还是让我们结合下面代码看一个具体的例子吧。

...

// 判断浏览器是否支持这些 API

if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {

  console.log("enumerateDevices() not supported.");

  return;

}

// 枚举 cameras and microphones.

navigator.mediaDevices.enumerateDevices()

.then(function(deviceInfos) {

  // 打印出每一个设备的信息

  deviceInfos.forEach(function(deviceInfo) {

    console.log(deviceInfo.kind + ": " + deviceInfo.label +

                " id = " + deviceInfo.deviceId);

  });

})

.catch(function(err) {

  console.log(err.name + ": " + err.message);

});

总结起来，上面的代码中做了以下几件事儿：

首先，判断浏览器是否支持 MediaDevice 接口（老版本浏览器可能不支持）。

如果支持，则调用navigator.mediaDevices.enumerateDevices()方法获取音视频设备列表，该方法会返回一个 Promise 对象。

如果返回 Promise 对象成功，则执行 then 中的函数。而then分支中的函数非常简单，它遍历每一个 MediaDeviceInfo，并将每个 MediaDeviceInfo 中的基本信息打印出来，也就是我们想要的每个音视频设备的基本信息。

但如果失败的话，则执行 catch 中的函数。

通过上面的介绍，你是不是觉得在浏览器上检测音视频设备非常简单呢？

设备检测

在获取到电脑 / 手机上的所有设备信息后，我们就可以对设备的可用性做真正的检测了。在我们的设备列表中，可以通过MediaDeviceInfo结构中的kind字段，将设备分类为音频设备或视频设备。

如果再细分的话，还可以通过 kind 字段再将音视设备分为输入设备和输出设备。如我们平时使用的耳机，从大的方面说它是一个音频设备，但它同时兼有音频输入设备和音频输出设备的功能。

对于区分出的音频设备和视频设备，每种不同种类的设备还会设置各自的默认设备。还是以耳机这个音频设备为例，将耳机插入电脑后，耳机就变成了音频的默认设备；将耳机拔出后，默认设备又切换成了系统的音频设备。

因此，在获取到所有的设备列表后，如果我们不指定某个具体设备，直接调用《01 | 原来通过浏览器访问摄像头这么容易》一文中所介绍的 getUserMedia API 来采集音视频数据时，它就会从设备列表中的默认设备上采集数据。当然，我们是可以通过 MediaDeviceInfo 中的 deviceID 字段来指定从哪个具体设备采集数据的，不过这就是后话了。

如果我们能从指定的设备上采集到音视频数据，那说明这个设备就是有效的设备。我们在排查设备问题的时候，就可以利用上面的方法，对每个设备都一项一项进行检测，即先排查视频设备，然后再排查音频设备。因此，需要调用两次 getUserMedia API 进行设备检测。

第一次，调用 getUserMedia API 只采集视频数据并将其展示出来。如果用户能看到自己的视频，说明视频设备是有效的；否则，设备无效，可以再次选择不同的视频设备进行重新检测。

第二次，如果用户视频检测通过了，再次调用 getUserMedia API 时，则只采集音频数据。由于音频数据不能直接展示，所以需要使用 JavaScript 中的 AudioContext 对象，将采集到的音频计算后，再将其绘制到页面上。这样，当用户看到音频数值的变化后，说明音频设备也是有效的。

通过以上步骤，我们就完成了对指定音视频设备的检测工作。

小结

在本文中，我主要向你介绍了如何通过浏览器中的 WebRTC 接口获取自己机子上的音视频设备列表。通过上面的描述你应该可以了解到，在浏览器下只需要调用 enumerateDevices 方法，就可以轻松获取设备信息了。

这个看似简单的接口，其实在 WebRTC 底层做了大量的工作。你可以思考这样一个 case，当我们在获取设备列表时，用户将设备从电脑上拔下来了，此时设备列表会发生怎样的变化呢？如果设备被拔出后，设备列表不发生变化，那么用户正好选择使用被拔下的设备，又会怎样呢？

你还可以顺着这个思路想下去，当我们正在使用某个视频或音频设备的时候，由于某种原因，设备失效了（设备坏了或被拔出了），这种情况下又该怎样处理呢？一般情况下，我们想到的可能是提示设备出错的消息。但如果电脑中还有其他设备可用呢？是不是可以自动切到另外一个可用的设备上呢？另外，假如有多个设备同时可用，此时该如何切换设备呢？这听上去是不是就很棘手？

但不用担心，我说的这些问题，WebRTC 已经处理得非常好，有兴趣的同学可以自行验证一下 WebRTC 的具体行为是怎样的。所以，从这一点我们也可以看出 WebRTC 所做出的努力。

思考时间

在获取音视频设备时，我们可以得到机子上（电脑 / 手机等）的所有音视频设备。每个设备都有 deviceID 字段，那么你可以想一想这个字段在真实场景中是如何通过 deviceID 进行设备切换的呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

03 | 如何使用浏览器给自己拍照呢？

在之前的文章中，我向你介绍了如何在浏览器中利用 WebRTC 采集音视频数据。那么，是否可以通过相同的技术进行拍照呢？没错，这是完全可行的。

现代的浏览器功能越来越强大，你不光可以通过它进行拍照，而且还可以对拍下来的照片进行各种滤镜处理。

为了实现上述功能，你需要了解并掌握以下三个知识点：

如何从采集到的视频中获取到图片？

如何将处理后的图片保存成文件？

如何对获取到的图片进行滤镜处理？

这三方面的知识点就是本文要交付的重点内容。下面我们先学习与之相关的基础知识和原理，然后再对这几个知识点逐一进行讲解，各个击破。

在 WebRTC 处理过程中的位置

在正式进入主题之前，咱们仍然按老规矩，看看本篇文章所介绍的内容在整个 WebRTC 处理过程中的位置。如下图所示：

WebRTC 1 对 1 音视频实时通话过程示意图

你可以看到，这张图与《01 | 原来通过浏览器访问摄像头这么容易》文章中的图一模一样。没错，咱们本篇文章所涉及的知识点仍然属于音视频数据采集的部分。

基础知识

在正式讲解如何进行拍照之前，你需要先了解非编码帧（解码帧）和编码帧这两个知识点，这会有利于你对后面拍照实现内容的理解。

1. 非编码帧

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

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

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

播放的视频帧之间的时间间隔是非常小的。如按每秒钟 20 帧的帧率计算，每帧之间的间隔是 50ms。

播放器播的是非编码帧（解码后的帧），这些非编码帧就是一幅幅独立的图像。

从摄像头里采集的帧或通过解码器解码后的帧都是非编码帧。非编码帧的格式一般是 YUV 格式或是 RGB 格式。关于 YUV 与 RGB 的相关知识，我在上一篇文章中已向你做过简要介绍，这里就不再赘述了。

2. 编码帧

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

I 帧：关键帧。压缩率低，可以单独解码成一幅完整的图像。

P 帧：参考帧。压缩率较高，解码时依赖于前面已解码的数据。

B 帧：前后参考帧。压缩率最高，解码时不光依赖前面已经解码的帧，而且还依赖它后面的 P 帧。换句话说就是，B 帧后面的 P 帧要优先于它进行解码，然后才能将 B 帧解码。

关于编码这块的内容，目前你只需了解上面这些知识即可。

通过上面的介绍，现在你应该已经清楚地知道了：从播放器里获取的视频帧一定是非编码帧。也就是说，拍照的过程其实是从连续播放的一幅幅画面中抽取正在显示的那张画面。

如何获取视频流

在获得照片之前，你首先要通过浏览器的 API 获取视频流，并通过 HTML5 的 <video> 标签将视频播放出来。实际上，这些知识我已经在《01 | 原来通过浏览器访问摄像头这么容易》中介绍过了，这里我就不做过多的描述了。咱们还是直接上代码吧。

HTML 部分代码如下：

<html>

<head>

  <title>WebRTC take picture</title>

</head>

<body>

   <video autoplay playsinline id="player">

   <script src="./js/client.js"></script>

</body>

</html>

上面这段代码很简单，就是定义了一个 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 中增加以下代码：

...

<button id="TakePhoto">Take</button>

...

<canvas id="picture"></canvas>

...

上面的 HTML 代码段，包括一个 <canvas> 标签和一个 <button> 标签。我们的设想是，当点击拍照按钮时，就可以从视频流中获取到一张当时正在显示的图片了。

显然，光有 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 中的 <video> 标签就是一个 video 元素，所以它可以当作是 drawImage 方法的第一个参数。这样就可以通过 Canvas 获取到照片了。

通过上面的方法，你就拍照成功了哈，是不是很简单？

如何保存照片

照片拍好后，如何将它保存到本地文件系统中呢? 浏览器同样给我们提供了非常方便的方法，让我们来看一下具体代码吧。

HTML 要先增加如下代码：

...

<div>

    <button id="save"> 保存 </button>

</div>

...

也就是当你点击保存这个 <button> 的时候，就可以将前面 Canvas 抓取的图片保存下来。不过，<button>只是触发一个事件，真正做事儿的是下面的 JavaScript 代码。具体逻辑如下：

...

function downLoad(url){

    var oA = document.createElement("a");

    oA.download = 'photo';// 设置下载的文件名，默认是'下载'

    oA.href = url;

    document.body.appendChild(oA);

    oA.click();

    oA.remove(); // 下载之后把创建的元素删除

}

...

document.querySelector("button#save").onclick = function (){

    downLoad(canvas.toDataURL("image/jpeg"););

}

....

在上面的代码中，当用户点击保存按钮时，会调用一个匿名函数。该函数的逻辑如下：

首先，通过 Canvas 的 toDataURL 方法获得图片的 URL 地址；

然后，将该 URL 地址当作参数传给 downLoad 函数；

最后，downLoad 函数做的事儿比较简单，就是创建一个<a>标签，当用户点击时就将图片下载下来。

通过上面的代码，你就可以通过浏览器为自己拍照，并同时将拍下来的照片保存到文件系统中了。

如何实现滤镜

从视频流中获取到照片后，你还可以通过滤镜为照片增加点特效，这样会让你的照片更加特别。

在浏览器中对于图片的滤镜处理是通过 CSS 来控制的。像前面一样，首先在 HTML 中增加 CSS 的滤镜代码如下：

...

<head>

    <style>

         .none {

              -webkit-filter: none;

         }

         .blur {

              -webkit-filter: blur(3px);

         }

         .grayscale {

              -webkit-filter: grayscale(1);

         }

         .invert {

              -webkit-filter: invert(1);

         }

         .sepia {

              -webkit-filter: sepia(1);

         }

    </style>

</head>

<body>

  ...

  <select id="filter">

      <option value="none">None</option>

      <option value="blur">blur</option>

      <option value="grayscale">Grayscale</option>

      <option value="invert">Invert</option>

      <option value="sepia">sepia</option>

  </select>

  ...

</body>

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

blur：模糊度

grayscale：灰度（黑白）

invert：反转

sepia：深褐色

并增加了一个 <select> 标签，以便让用户选择使用不同的滤镜。但最终的控制还是由下面的 JavaScript 脚本来做的，JavaScript 代码如下：

...

picture.className = filtersSelect.value;

...

没错，只需要这样简单的一行代码，你就可以将不同的滤镜应用于获取的照片上，是不是非常简单？学习完这些，你就可以很快实现你想要的滤镜效果了，赶快上手尝试下吧！

小结

本文我向你介绍了如何在浏览器上从视频流中抓取一幅图片，即照片。通过上面的介绍，你应该也了解到通过浏览器的 Canvas 来实现这个功能会特别简单。

在文章的最后，我还讲述了如何通过 CSS 对捕获到的图片做特效处理，当然这种特效是比较简单的实现。如果想实现更多更复杂的效果，就要用 WebGL 来实现了。而 WebGL 的知识相对比较复杂，甚至可以另起一个专栏专门来介绍这部分知识。所以，这里我就不做过多的讲解了，有需要的同学可以自行搜索资料学习。

思考时间

文章的最后是通过 CSS 对照片做了特效处理，但当你将这张照片保存下来，并单独打开后你会发现，之前在浏览器上设置的效果没有了，这是什么原因呢？你要如何来解决该问题呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

04 | 可以把采集到的音视频数据录制下来吗？

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

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

服务端录制：优点是不用担心客户因自身电脑问题造成录制失败（如磁盘空间不足），也不会因录制时抢占资源（CPU 占用率过高）而导致其他应用出现问题等；缺点是实现的复杂度很高。不过由于本文要重点讲解的是接下来的客户端录制，所以这里我就不再深入展开讲解了，你只需要知道有服务端录制这回事就行了，或者如果你感兴趣，也可以自行搜索学习。

客户端录制：优点是方便录制方（如老师）操控，并且所录制的视频清晰度高，实现相对简单。这里可以和服务端录制做个对比，一般客户端摄像头的分辨率都非常高的（如 1280x720），所以客户端录制可以录制出非常清晰的视频；但服务端录制要做到这点就很困难了，本地高清的视频在上传服务端时由于网络带宽不足，视频的分辨率很有可能会被自动缩小到了 640x360，这就导致用户回看时视频特别模糊，用户体验差。不过客户端录制也有很明显的缺点，其中最主要的缺点就是录制失败率高。因为客户端在进行录制时会开启第二路编码器，这样会特别耗 CPU。而 CPU 占用过高后，就很容易造成应用程序卡死。除此之外，它对内存、硬盘的要求也特别高。

这里需要再次说明的是，由于本专栏的文章是聚焦在 WebRTC 的使用上，所以我们只讲如何使用 WebRTC 库实现客户端录制音视频流。至于服务端录制的相关知识，由于与 WebRTC 库无关，所以我们就不做进一步讲解了。

在 WebRTC 处理过程中的位置

在正式进入主题之前，咱们依旧先来看一下本文在 WebRTC 处理过程中的位置。如下图所示：

WebRTC 1 对 1 音视频实时通话过程示意图

通过上图可以了解到，咱们本文所讲的内容就是音视频采集下面的录制功能。

基本原理

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

第一个，录制后音视频流的存储格式是什么呢？比如，是直接录制原始数据，还是录制成某种多媒体格式（如 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

Blob（Binary 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 | 原来通过浏览器访问摄像头这么容易》一文中，我已经讲过如何通过浏览器采集音视频数据，具体就是调用浏览器中的 getUserMedia 方法。所以，这里我就不再赘述了。

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

<html>

...

<body>

    ...

    <button id="record">Start Record</button>

    <button id="recplay" disabled>Play</button>

    <button id="download" disabled>Download</button>

    ...

</body>

</html>

上面的 HTML 代码片段定义了三个 button，一个用于开启录制，一个用于播放录制下来的内容，最后一个用于将录制的视频下载下来。然后我们再来看一下 JavaScript 控制部分的代码：

...

var buffer;

...

// 当该函数被触发后，将数据压入到 blob 中

function handleDataAvailable(e){

        if(e && e.data && e.data.size > 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。 如果支持的话，再创建
MediaRecorder 对象，将音视频流录制成指定的媒体格式文件。

实际存储时，是通过 ondataavailable 事件操作的。每当 ondataavailable 事件触发时，就会调用 handleDataAvailable 函数。该函数的实现就特别简单了，直接将数据 push 到 buffer 中，实际在浏览器底层使用的是 Blob 对象。

另外，在开启录制时，可以设置一个毫秒级的时间片，这样录制的媒体数据会按照你设置的值分割成一个个单独的区块，否则默认的方式是录制一个非常大的整块内容。分成一块一块的区块会提高效率和可靠性，如果是一整块数据，随着时间的推移，数据块越来越大，读写效率就会变差，而且增加了写入文件的失败率。

2. 回放录制文件

通过上面的方法录制好内容后，又该如何进行回放呢？让我们来看一下代码吧！

在 HTML 中增加下面的代码：

...

<video id="recvideo"></video>

...

在 HTML 中只增加了一个 <video>标签，用于播放录制的内容。下面的 JavaScript 是将录制内容与<video>标签联接到一起：

 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，并通过 <video> 标签将录制的内容播放出来了。

3. 下载录制好的文件

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

btnDownload.onclick = ()=> {

        var blob = new Blob(buffer, {type: 'video/webm'});

        var url = window.URL.createObjectURL(blob);

        var a = document.createElement('a');

        a.href = url;

        a.style.display = 'none';

        a.download = 'aaa.webm';

        a.click();

}

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

小结

在直播系统中，一般会包括服务端录制和客户端录制，它们各有优劣，因此好点的直接系统会同时支持客户端录制与服务端录制。

通过上面的介绍，你应该已经了解了在浏览器下如何录制音视频流了，你只需要使用浏览器中的 MediaRecorder 对象，就可以很方便地录制本地或远程音视频流。

今天我们介绍的内容只能够实现一路视频和一路音视流的情况，但在实际的项目中还要考虑多路音视频流的情况，如有多人同时进行音视频互动的情况，在这种复杂场景下该如何将它们录制下来呢？即使录制下来又该如何进行回放呢？

所以，对于大多数采用客户端录制方案的公司，在录制时一般不是录制音视频数据，而是录制桌面加音频，这样就大大简化了客户端实现录制的复杂度了。我想你也一定知道这是为什么，没错就是因为在桌面上有其他参与人的视频，所以只要将桌面录制下来，所有的视频就一同被录制下来了哈！

思考时间

上面我已经介绍了很多录制音视频可能出现的问题。现在你思考一下，是否可以将多路音视频录制到同一个多媒体文件中呢？例如在多人的实时直播中，有三个人同时共享视频，是否可以将这三个人的视频写入到一个多媒体文件中呢（如 MP4）？这样的 MP4 在播放时会有什么问题吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

所做 Demo 的 GitHub 链接（有需要可以点这里）

05 | 原来浏览器还能抓取桌面？

无论是做音视频会议，还是做远程教育，共享桌面都是一个必备功能。如果说在 PC 或 Mac 端写个共享桌面程序你不会有太多感受，但通过浏览器也可以共享桌面是不是觉得就有些神奇了呢？

WebRTC 的愿景就是要让这些看似神奇的事情，不知不觉地发生在我们身边。

你可以想象一下，假如浏览器有了共享桌面功能，这会使得浏览器有更广阔的应用空间，一个最直接的例子就是我们可以直接通过浏览器进行远程办公、远程协助等工作，而不用再下载共享桌面的应用了，这大大提高了我们的工作效率。

在 WebRTC 处理过程中的位置

在正式进行主题之前，我们还是来看看本文在整个 WebRTC 处理过程中的位置，如下图所示：

WebRTC 处理过程图

没错，它仍然属于音视频采集的范畴，但是这次采集的不是音视频数据而是桌面。不过这也没什么关系，桌面也可以当作一种特殊的视频数据来看待。

共享桌面的基本原理

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

对于共享者，每秒钟抓取多次屏幕（可以是 3 次、5 次等），每次抓取的屏幕都与上一次抓取的屏幕做比较，取它们的差值，然后对差值进行压缩；如果是第一次抓屏或切幕的情况，即本次抓取的屏幕与上一次抓取屏幕的变化率超过 80% 时，就做全屏的帧内压缩，其过程与 JPEG 图像压缩类似（有兴趣的可以自行学习）。最后再将压缩后的数据通过传输模块传送到观看端；数据到达观看端后，再进行解码，这样即可还原出整幅图片并显示出来。

对于远程控制端，当用户通过鼠标点击共享桌面的某个位置时，会首先计算出鼠标实际点击的位置，然后将其作为参数，通过信令发送给共享端。共享端收到信令后，会模拟本地鼠标，即调用相关的 API，完成最终的操作。一般情况下，当操作完成后，共享端桌面也发生了一些变化，此时就又回到上面共享者的流程了，我就不再赘述了。

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

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

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

桌面数据：包括了桌面的抓取 (采集)、编码（压缩）、传输、解码和渲染。

信令控制：包括键盘事件、鼠标事件以及接收到这些事件消息后的相关处理等。

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

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

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

BitBlt：XP 系统下经常使用，在 vista 之后，开启 DWM 模式后，速度极慢。

Hook：一种黑客技术，实现稍复杂。

DirectX：由于 DirectX 9/10/11 之间差别比较大，容易出现兼容问题。最新的 WebRTC 都是使用的这种方式

GetWindowDC：可以通过它来抓取窗口。

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

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

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

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

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

因此本文的目标就是：你只需要学会和掌握浏览器提供的抓取屏幕的 API 就可以了。至于编码、传输、解码等相关知识，我会在后面的文章中陆续为你讲解。

如何共享桌面

学习完共享桌面相关的理论知识，接下来，就让我们实践起来，一起来学习如何通过浏览器来抓取桌面吧！

1. 抓取桌面

首先我们先来了解一下在浏览器下抓取桌面的 API 的基本格式：

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

这个 API 你看着是不是似曾相识？没错，它与前面《01 | 原来通过浏览器访问摄像头这么容易》一文中介绍的采集视频的 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;

}

...

通过上面的方法，就可以获得桌面数据了，让我们来看一下效果图吧：

Chrome 浏览器共享桌面图

2. 桌面的展示

桌面采集后，就可以通过 HTML 中的<video>标签将采集到的桌面展示出来，具体代码如下所示。

首先，在 HTML 中增加下面的代码，其中<video>标签用于播放抓取的桌面内容：

...

<video autoplay playsinline id="deskVideo"></video>

...

下面的 JavaScript 则将桌面内容与<video>标签联接到一起：

 ...

 var deskVideo = document.querySelect("video/deskVideo");

 ...

 function getDeskStream(stream){

        localStream = stream;

        deskVideo.srcObject = stream;

 }

 ...

在 JavaScript 中调用getDisplayMedia方法抓取桌面数据，当桌面数据被抓到之后，会触发 getDeskStream 函数。我们再在该函数中将获取到的 stream 与 video 标签联系起来，这样当数据获取到时就从播放器里显示出来了。

3. 录制桌面

录制本地桌面与《04 | 可以把采集到的音视频数据录制下来吗？》一文中所讲的录制本地视频的过程是一样的。首先通过getDisplayMedia方法获取到本地桌面数据，然后将该流当作参数传给 MediaRecorder 对象，并实现ondataavailable事件，最终将音视频流录制下来。

具体代码如下所示，我们先看一下 HTML 部分：

<html>

...

<body>

    ...

    <button id="record">Start Record</button>

    ...

</body>

</html>

上面的 HTML 代码片段定义了一个开启录制的button，当用户点击该 button 后，就触发下面的 JavaScript 代码：

...

var buffer;

...

function handleDataAvailable(e){

        if(e && e.data && e.data.size > 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 | 可以把采集到的音视频数据录制下来吗？》中都有讲解过，所以这里就不再赘述了！

小结

本文我向你讲解了如何通过浏览器提供的 API 来抓取桌面，并将它显示出来，以及如何通过前面所讲的MediaRecorder对象将桌面录制下来。

其实，真正的商用客户端录制并不是录制音视频流，而是录制桌面流。这样即使是多人互动的场景，在有多路视频流的情况下，录制桌面的同时就将桌面上显示的所有视频一起录制下来了，这样问题是不是一下就简单了？

比较遗憾的是，关于我们上述录制桌面的 API，目前很多浏览器支持得还不够好，只有 Chrome 浏览器相对比较完善。不过现在 WebRTC 1.0 规范已经出来了，相信在不久的将来，各浏览器都会实现这个 API 的。

思考时间

为什么使用视频的编码方式就容易出现桌面模糊的现象呢？有什么办法可以解决该问题吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

06 | WebRTC中的RTP及RTCP详解

可以毫不夸张地说，WebRTC 是一个 “宝库”，它里面有各种各样的 “好东西”。无论你从事什么行业，几乎都可以从它里边吸取能量。

在学习 WebRTC 时，你不光要学习如何使用它，还应该多去看它底层的代码，多去了解它都能做些什么，争取对它的原理和使用都了然于心。如此一来，当遇到某个恰当的时机，你就可以从 WebRTC 库中抽取一点“精髓”放到你自己的项目中，让你的项目大放异彩。

比如，你是搞音频的，你就可以从 WebRTC 中提取 3A（AEC、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 为了传输的可靠性，它是如何做的呢？简单总结起来就是“发送 -> 确认；超时 -> 重发”的反复过程。

举个例子，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 帧的数据量是非常大的，最少也要几十 K（I/P/B 帧的概念我在前面《03 | 如何使用浏览器给自己拍照呢?》的文章中有过介绍）。而以太网的最大传输单元是多少呢？ 1.5K，所以要传输一个 I 帧需要几十个包。并且这几十个包传到对端后，还要重新组装成 I 帧，这样才能进行解码还原出一幅幅的图像。如果是我们自己实现的话，要完成这样的过程，至少需要以下几个标识。

序号：用于标识传输包的序号，这样就可以知道这个包是第几个分片了。

起始标记：记录分帧的第一个 UDP 包。

结束标记：记录分帧的最后一个 UDP 包。

有了上面这几个标识字段，我们就可以在发送端进行拆包，在接收端将视频帧重新再组装起来了。

1. RTP 协议

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

RTP 协议规范图

如图所示，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 有两个最重要的报文：RR（Reciever Report）和 SR(Sender Report)。通过这两个报文的交换，各端就知道自己的网络质量到底如何了。

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

RTCP 协议规范图

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

V=2，指报文的版本。

P，表示填充位，如果该位置 1，则在 RTCP 报文的最后会有填充字节（内容是按字节对齐的）。

RC，全称 Report Count，指 RTCP 报文中接收报告的报文块个数。

PT=200，Payload 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 协议头：

RTCP 协议头：

RTCP PT 类型：

对于 205 和 206 两种不同的反馈消息，又在 RFC5104 中做了更详细的定义：

07 | 你竟然不知道SDP？它可是WebRTC的驱动核心！

在前面《01 | 原来通过浏览器访问摄像头这么容易》《04 | 可以把采集到的音视频数据录制下来吗？》等文章中，我向你讲解了 WebRTC 如何采集音视频数据，以及如何将它们录制成文件等相关内容。但那些知识不过是个“开胃菜”，WebRTC 真正核心的知识将从本文开始陆续向你展开。不过从本文开始，知识的难度会越来越高，你一定要做好心理准备。

说到 WebRTC 运转的核心，不同的人可能有不同的理解：有的人认为 WebRTC 的核心是音视频引擎，有的人认为是网络传输，而我则认为 WebRTC 之所以能很好地运转起来，完全是由 SDP 驱动的，因此SDP 才是 WebRTC 的核心。

掌握了这个核心，你就知道 WebRTC 都支持哪些编解码器、每次通话时都有哪些媒体（通话时有几路音频 / 视频）以及底层网络使用的是什么协议，也就是说你就相当于拿到了打开 WebRTC 大门的一把钥匙。

由此可见，SDP 在 WebRTC 中是何等重要。下面就让我们正式进入正题吧！

SDP 是什么

在正式讲解 SDP 之前，你首先要弄清楚 SDP 是什么？SDP（Session 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 处理过程图：

WebRTC 处理过程图

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

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

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

标准 SDP 规范

了解了 SDP 是什么，接下来我们看一下 SDP 规范。其实单论 SDP 规范它并不复杂，但 WebRTC 使用时又对其做了不少修改，所以当你初见完整的 WebRTC 的 SDP 时，可能会一脸茫然。

不过没关系，万事总有头。在本文中，我先带你了解 SDP 的标准规范，然后再一步步深入，相信通过本文的学习，最终你将能基本读懂 WebRTC 所产生的 SDP 信息，从而为后面学习 WebRTC 打下坚实的基础。

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

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

媒体类型

媒体格式

传输协议

传输的 IP 和端口

下面我们就以上这些知识逐一进行讲解。

1. SDP 的格式

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

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=<username> <session id> <version> <network type> <address type> <address>，该例子是对一个会话发起者的描述。其中，

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

<username>：用户名，当不关心用户名时，可以用 “－” 代替 ；

<session id> ：数字串，在整个会话中，必须是唯一的，建议使用 NTP 时间戳；

<version>：版本号，每次会话数据修改后，该版本值会递增；

<network type> ：网络类型，一般为“IN”，表示“internet”；

<address type>：地址类型，一般为 IP4；

<address>：IP 地址。

第三个，Session Name（必选）。例子：s=<session name>，该例子表示一个会话，在整个 SDP 中有且只有一个会话，也就是只有一个 s=。

第四个，t=（time the session is active，必选）。例子：t=<start time> <stop time>，该例子描述了会话的开始时间和结束时间。其中， <start time> 和 <stop time> 为 NTP 时间，单位是秒；当<start time>和<stop time>均为零时，表示持久会话。

（2）媒体描述

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

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

<media>：媒体类型，比如 audio/video 等；

<port>：端口；

<transport>：传输协议，有两种——RTP/AVP 和 UDP；

<fmt list>：媒体格式，即数据负载类型 (Payload Type) 列表。

第二个，a=*（zero or more media attribute lines，可选）。例子：a=<TYPE>或 a=<TYPE>:<VALUES>， 表示属性，用于进一步描述媒体信息；在例子中， 指属性的类型， a= 有两个特别的属性类型，即下面要介绍的 rtpmap 和 fmtp。

第三个，rtpmap（可选）。例子：a=rtpmap:<payload type> <encoding name>/<clock rate>[/<encodingparameters>]。

rtpmap 是 rtp 与 map 的结合，即 RTP 参数映射表。

<payload type> ：负载类型，对应 RTP 包中的音视频数据负载类型。

<encoding name>：编码器名称，如 VP8、VP9、OPUS 等。

<sample rate>：采样率，如音频的采样率频率 32000、48000 等。

<encodingparameters>：编码参数，如音频是否是双声道，默认为单声道。

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

fmtp，格式参数，即 format parameters；

<payload type> ，负载类型，同样对应 RTP 包中的音视频数据负载类型；

< format specific parameters>指具体参数。

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

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 规范做了一些调整，更详细的信息可以看这里，它将 SDP 按功能分成几大块：

Session Metadata，会话元数据

Network Description，网络描述

Stream Description，流描述

Security Descriptions，安全描述

Qos Grouping Descriptions， 服务质量描述

下面这张图清晰地表达了它们之间的关系：

WebRTC 使用的 SDP 结构图

通过上图我们可以看出，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 代表最小打包时长是 10ms，useinbandfec=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 中如何设置音视频的传输码率呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

08 | 有话好商量，论媒体协商

在《07 | 你竟然不知道 SDP？它可是 WebRTC 的驱动核心！》一文中，我向你详细介绍了标准 SDP 规范，以及 WebRTC 与标准 SDP 规范的一些不同，而本文我们将重点学习一下 WebRTC 究竟是如何使用 SDP 规范进行媒体协商的。

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

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

所以简单地说，媒体协商就是看看你的设备都支持那些编解码器，我的设备是否也支持？如果我的设备也支持，那么咱们双方就算协商成功了。

在 WebRTC 处理过程中的位置

在正式进入主题之前，我们还是来看看本文在整个 WebRTC 处理过程中的位置，如下图所示：

WebRTC 处理过程图

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

创建连接，指的是创建 RTCPeerConnection，它负责端与端之间彼此建立 P2P 连接。在后面 RTCPeerConnection 一节中，我们还会对其做进一步的介绍。

信令，指的是客户端通过信令服务器交换 SDP 信息。

所以从本文开始，我们就开始讲解 WebRTC 最核心的一部分知识了，下面就让我们开始吧。

WebRTC 中媒体协商的作用

在 WebRTC 1.0 规范中，在双方通信时，双方必须清楚彼此使用的编解码器是什么，也必须知道传输过来的音视频流的 SSRC（SSRC 的概念参见《06 | WebRTC 中的 RTP 及 RTCP 详解》一文）信息，如果连这些最基本的信息彼此都不清楚的话，那么双方是无法正常通信的。

举个例子，如果 WebRTC 不清楚对方使用的是哪种编码器编码的数据，比如到底是 H264，还是 VP8？那 WebRTC 就无法将这些数据包正常解码，还原成原来的音视频帧，这将导致音视频无法正常显示或播放。

同样的道理，如果 WebRTC 不知道对方发过来的音视频流的 SSRC 是多少，那么 WebRTC 就无法对该音视频流的合法性做验证，这也将导致你无法观看正常的音视频。因为对于无法验证的音视频流，WebRTC 在接收音视频包后会直接将其抛弃。

通过上面的描述，我想你已经非常清楚媒体协商的作用是什么了。没错，媒体协商的作用就是让双方找到共同支持的媒体能力，如双方都支持的编解码器，从而最终实现彼此之间的音视频通信。

那 WebRTC 是怎样进行媒体协商的呢？这就要用到《07 | 你竟然不知道 SDP？它可是 WebRTC 的驱动核心！》文章中讲解的 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 中，双方协商的整个过程如下图所示：

媒体协商过程图

首先，呼叫方创建 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 对象完成的吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

09 | 让我们揭开WebRTC建立连接的神秘面纱

在上一篇《08 | 有话好商量，论媒体协商》文章中，我向你介绍了 WebRTC 进行通信时，是如何进行媒体协商的，以及媒体协商的目的是什么。

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

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

换句话说，当同时存在多个有效连接时，它首先选择传输质量最好的线路，如能用内网连通就不用公网。另外，如果尝试了很多线路都连通不了，那么它还会使用服务端中继的方式让双方连通，总之，是“想尽办法，用尽手段”让双方连通。

对于传输的效率与连通率这一点，既是 WebRTC 的目标，也是 WebRTC 建立连接的基本策略。下面我们就来具体看一下 WebRTC 是如何达到这个目标的吧！

在 WebRTC 处理过程中的位置

下面这张图清晰地表达了本文所讲的内容在整个 WebRTC 处理过程中的位置。

WebRTC 处理过程图

图中的红色部分——连接的创建、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 映射后的外网地址和端口。如下图所示：

NAT 地址映射图

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

而一般情况下，主机都只有内网 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 类型的区别是什么呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

10 | WebRTC NAT穿越原理

在 WebRTC 中， NAT 穿越是非常重要的一部分内容，也是比较有深度、比较难以理解的一部分知识。当然，等你学完本文，并完全理解了这部分知识后，你也会特别有成就感！

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

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

如果从哲学的角度来讲，“世上的麻烦都是自己找的”，这句话还是蛮有道理的。

在 WebRTC 处理过程中的位置

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

WebRTC 处理过程图

NAT 的种类

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

经过大量研究，现在 NAT 基本上可以总结成 4 种类型：完全锥型、IP 限制锥型、端口限制锥型和对称型。

下面我们就对这 4 种类型的 NAT 做下详细介绍。

1. 完全锥型 NAT

完全锥型 NAT 图

完全锥型 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

IP 限制锥型 NAT 图

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. 端口限制锥型

端口限制锥型 NAT 图

端口限制锥型比 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

对称型 NAT 图

对称型 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 类型呢？

NAT 类型检测图

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

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

第一步，判断是否有 NAT 防护

主机向服务器 #1 的某个 IP 和端口发送一个请求，服务器 #1 收到请求后，会通过同样的 IP 和端口返回一个响应消息。

如果主机收不到服务器 #1 返回的消息，则说明用户的网络限制了 UDP 协议，直接退出。

如果能收到包，则判断返回的主机的外网 IP 地址是否与主机自身的 IP 地址一样。如果一样，说明主机就是一台拥有公网地址的主机；如果不一样，就跳到下面的步骤 6。

如果主机拥有公网 IP，则还需要进一步判断其防火墙类型。所以它会再向服务器 #1 发一次请求，此时，服务器 #1 从另外一个网卡的 IP 和不同端口返回响应消息。

如果主机能收到，说明它是一台没有防护的公网主机；如果收不到，则说明有对称型的防火墙保护着它。

继续分析第 3 步，如果返回的外网 IP 地址与主机自身 IP 不一致，说明主机是处于 NAT 的防护之下，此时就需要对主机的 NAT 防护类型做进一步探测。

第二步，探测 NAT 环境

在 NAT 环境下，主机向服务器 #1 发请求，服务器 #1 通过另一个网卡的 IP 和不同端口给主机返回响应消息。

如果此时主机可以收到响应消息，说明它是在一个完全锥型 NAT之下。如果收不到消息还需要再做进一步判断。

如果主机收不到消息，它向服务器 #2（也就是第二台服务器）发请求，服务器 #2 使用收到请求的 IP 地址和端口向主机返回消息。

主机收到消息后，判断从服务器 #2 获取的外网 IP 和端口与之前从服务器 #1 获取的外网 IP 和端口是否一致，如果不一致说明该主机是在对称型 NAT之下。

如果 IP 地址一样，则需要再次发送请求。此时主机向服务器 #1 再次发送请求，服务器 #1 使用同样的 IP 和不同的端口返回响应消息。

此时，如果主机可以收到响应消息说明是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 之间无法打洞成功呢？如果打洞失败，你又该如何让通信双方实现互联呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

11 | 如何通过Node.js实现一套最简单的信令系统？

通过前面几篇文章的讲解，我想现在你应该已经对 WebRTC 有了一个清楚的认知了。接下来的章节咱们就使用 WebRTC 逐步实现一套真实可用的 1 对 1 实时直播系统吧。

WebRTC 1.0 规范对 WebRTC 要实现的功能、API 等相关信息做了大量的约束，比如规范中定义了如何采集音视频数据、如何录制以及如何传输等。甚至更细的，还定义了都有哪些 API，以及这些 API 的作用是什么。但这些约束只针对于客户端，并没有对服务端做任何限制。

那 WebRTC 规范中为什么不对服务器也做约束呢？其实，这样做有以下三点好处。

第一点，可以集中精力将 WebRTC 库做好。WebRTC 的愿景是使浏览器能够方便地处理音视频相关的应用，规范中不限制服务端的事儿，可以使它更聚焦。

第二点，让用户更好地对接业务。你也清楚，信令服务器一般都与公司的业务有着密切的关系，每家公司的业务都各有特色，让它们按照自已的业务去实现信令服务器会更符合它们的要求。

第三点，能得到更多公司的支持。WebRTC 扩展了浏览器的基础设施及能力，而不涉及到具体的业务或产品，这样会更容易得到像苹果、微软这种大公司的支持，否则这些大公司之间就会产生抗衡。

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

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

在 WebRTC 处理过程中的位置

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

WebRTC 处理过程图

通过上面这幅图，你可以清楚地知道本文所讲的主要内容就是红色方框中的信令服务器部分。

WebRTC 信令服务器的作用

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

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

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

房间管理。即每个用户都要加入到一个具体的房间里，比如两个用户 A 与 B 要进行通话，那么它们必须加入到同一个房间里。

信令的交换。即在同一个房间里的用户之间可以相互发送信令。

信令服务器的实现

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

当然， Node.js 不仅可以让你用 JavaScript 控制服务器，它还为你留了拓展接口，这些拓展接口甚至可以让你使用 C/C++ 为它编写模块，这样 Node.js 的功能就更加强大了。

2. Node.js 的基本工作原理

Node.js 工作原理图

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 请求。其网络事件处理的过程就如下图所示：

Node.js 事件处理模型图

当有网络请求过来时，首先会被插入到一个事件处理队列中。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 之外，还要安装 NPM（Node Package Manager），也就是 Node.js 的包管理器，或叫包安装工具。它与 Ubuntu 下的 APT（Advanced 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 的信令服务器，通过它来构建信令服务器大大简化了信令服务器的实现复杂度，这主要是因为它内置了房间的概念。

Socket.io 与 Node.js 关系图

上图是 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 代码：

<!DOCTYPE html>

<html>

  <head>

    <title>WebRTC client</title>

  </head>

  <body>

    <script src='/socket.io/socket.io.js'></script>

    <script src='js/client.js'></script>

  </body>

</html>

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

下面是 client.js 的代码：

var isInitiator;

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

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

if (room !== '') { // 如果房间不空，则发送 "create or join" 消息

  console.log('Joining room ' + room);

  socket.emit('create or join', room);

}

socket.on('full', (room) => { // 如果从服务端收到 "full" 消息

  console.log('Room ' + room + ' is full');

});

socket.on('empty', (room) => { // 如果从服务端收到 "empty" 消息

  isInitiator = true;

  console.log('Room ' + room + ' is empty');

});

socket.on('join', (room) => { // 如果从服务端收到 “join" 消息

  console.log('Making request to join room ' + room);

  console.log('You are the initiator!');

});

socket.on('log', (array) => {

  console.log.apply(console, array);

});

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

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

服务器端代码，server.js 是这样的：

const static = require('node-static');

const http = require('http');

const file = new(static.Server)();

const app = http.createServer(function (req, res) {

  file.serve(req, res);

}).listen(2013);

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

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

  // convenience function to log server messages to the client

  function log(){

    const array = ['>>> Message from server: '];

    for (var i = 0; i < arguments.length; i++) {

      array.push(arguments[i]);

    }

      socket.emit('log', array);

  }

  socket.on('message', (message) => { // 收到 message 时，进行广播

    log('Got message:', message);

    // for a real app, would be room only (not broadcast)

    socket.broadcast.emit('message', message); // 在真实的应用中，应该只在房间内广播

  });

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

	var clientsInRoom = io.sockets.adapter.rooms[room];

    var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0; // 房间里的人数

    log('Room ' + room + ' has ' + numClients + ' client(s)');

    log('Request to create or join room ' + room);

    if (numClients === 0){ // 如果房间里没人

      socket.join(room);

      socket.emit('created', room); // 发送 "created" 消息

    } else if (numClients === 1) { // 如果房间里有一个人

	  io.sockets.in(room).emit('join', room);

      socket.join(room);

      socket.emit('joined', room); // 发送 “joined”消息

    } else { // max two clients

      socket.emit('full', room); // 发送 "full" 消息

    }

    socket.emit('emit(): client ' + socket.id +

      ' joined room ' + room);

    socket.broadcast.emit('broadcast(): client ' + socket.id +

      ' joined room ' + room);

  });

});

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

服务端侦听 2013 这个端口，对不同的消息做相应的处理：

服务器收到 message 消息时，它会直接进行广播，这样所有连接到该服务器的客户端都会收到广播的消息。

服务端收到“create or join”消息时，它会对房间里的人数进行统计，如果房间里没有人，则发送“created”消息；如果房间里有一个人，发送“join”消息和“joined”消息；如果超过两个人，则发送“full”消息。

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

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 引擎上，你知道它们的对应关系吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

12 | RTCPeerConnection：音视频实时通讯的核心

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

像我前面讲的很多文章，都是 RTCPeerConnection 类的一部分功能，如 ：

《06 | WebRTC 中的 RTP 及 RTCP 详解》讲的是底层网络数据传输协议与其控制协议。

《07 | 你竟然不知道 SDP？它可是 WebRTC 的驱动核心！》和《08 | 有话好商量，论媒体协商》讲的是 SDP 协议及使用 SDP 协议进行媒体协商的过程等。

《09 | 让我们揭开 WebRTC 建立连接的神秘面纱》讲的是 WebRTC 底层是如何建立连接的。

《10 | WebRTC NAT 穿越原理》介绍了在 WebRTC 底层进行 NAT 穿越的过程。

以上这些内容都是 RTCPeerConnection 类的功能。除了上述讲的这些内容之外，RTCPeerConnection 类还有许多其他的功能，我在后面的文章中还会向你逐一介绍。

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

在 WebRTC 处理过程中的位置

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

WebRTC 处理过程图

通过上面这张图，你可以看到本文所要讲述的内容就是两个端点之间是如何通过 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 地址，这样通信的双方才能彼此建立连接（信令服务器的实现可以参考上一篇文章）。

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

接下来我们就实操起来，一步一步实现通话吧！

1. 添加视频元素和控制按钮

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

除此之外，在该页面上还有三个<button>按钮：

start 按钮，用于打开本地视频；

call 按钮，用于与对方建立连接；

hangup 按钮，用于断开与对方的连接。

具体代码如下：

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

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

<div>

  <button id="startButton">start</button>

  <button id="callButton">call</button>

  <button id="hangupButton">hang up</button>

</div>

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 的方法如下：

...

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

...

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

<!DOCTYPE html>

<html>

<head>

  <title>Realtime communication with WebRTC</title>

  <link rel="stylesheet" href="css/main.css" />

</head>

<body>

  <h1>Realtime communication with WebRTC</h1>

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

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

  <div>

    <button id="startButton">Start</button>

    <button id="callButton">Call</button>

    <button id="hangupButton">Hang Up</button>

  </div>

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

  <script src="js/client.js"></script>

</body>

</html>

通过上面的操作，界面显示及各浏览器之间适配的问题就全部完成了。接下来我们来看一下 RTCPeerConnection 是如何工作的。

3. RTCPeerConnection 如何工作

为了讲清楚 RTCPeerConnection 是如何工作的，我们还是看一个具体的例子吧。

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

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

为连接的每个端创建一个 RTCPeerConnection 对象，并且给 RTCPeerConnection 对象添加一个本地流，该流是从 getUserMedia() 获取的；

获取本地媒体描述信息，即 SDP 信息，并与对端进行交换；

获得网络信息，即 Candidate（IP 地址和端口），并与远端进行交换。

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

（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 | 有话好商量，论媒体协商》一文中做了详细介绍，若记不清了，可以回看下，我们下面的实践中要用到。

并且前面我们也说了，在真实的应用场景中，各端获取的 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(() => {

      setLocalDescriptionSuccess(localPeerConnection);

    }).catch(setSessionDescriptionError);

  ...

  // 远端 pc 将 offer 保存起来

  remotePeerConnection.setRemoteDescription(description)

    .then(() => {

      setRemoteDescriptionSuccess(remotePeerConnection);

    }).catch(setSessionDescriptionError);

  ...

  // 远端 pc 创建 answer

  remotePeerConnection.createAnswer()

    .then(createdAnswer)

    .catch(setSessionDescriptionError);

}

// 当 answer 创建成功后，会回调该函数

function createdAnswer(description) {

  ...

  // 远端保存 answer

  remotePeerConnection.setLocalDescription(description)

    .then(() => {

      setLocalDescriptionSuccess(remotePeerConnection);

    }).catch(setSessionDescriptionError);

  // 本端 pc 保存 answer

  localPeerConnection.setRemoteDescription(description)

    .then(() => {

      setRemoteDescriptionSuccess(localPeerConnection);

    }).catch(setSessionDescriptionError);

}

（3）端与端建立连接

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

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

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

...

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(() => {

        handleConnectionSuccess(peerConnection);

      }).catch((error) => {

        handleConnectionFailure(peerConnection, error);

      });

    ...

  }

}

...

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

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

显示远端媒体流

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

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

...

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 对象。通过这个例子你就知道如何在一个页面内实现音视频流的发送与接收了。

思考时间

在上一篇文章中我向你讲解了如何通过 Node.js 搭建一套信令服务器，今天我又向你讲解了如何使用 RTCPeerConnection 对象，那你是否可以将二者结合起来实现一个简单的 1 对 1 系统了呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

所做 Demo 的 GitHub 链接（有需要可以点这里）

13 | 在WebRTC中如何控制传输速率呢？

在上一篇《12 | RTCPeerConnection：音视频实时通讯的核心》一文中，我向你介绍了 RTCPeerConnection 对象是如何在端与端之间建立连接的，以及音视频数据又是如何通过它进行传输的。而本文则更进一步，向你介绍如何使用 RTCPeerConnection 来控制音视频数据的传输速率。

通过 RTCPeerConnection 进行传输速率的控制实际上还是蛮简单的一件事儿，但在学习相关知识的时候，你不仅要能知其然，还要知其所以然。对于本文来讲，就是你不但要学习如何控制传输速率，同时还应该清楚为什么要对传输速率进行控制。

其实，之所以要进行传输速率的控制，是因为它会对音视频服务质量产生比较大的影响，对于音视频服务质量这部分知识，接下来我就与你一起做详细探讨。

在 WebRTC 处理过程中的位置

在此之前，我们依旧先来看看本文的内容在整个 WebRTC 处理过程中的位置。

WebRTC 处理过程图

通过上图你可以知道，本文所讲的内容仍然属于传输的范畴。

音视频服务质量

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

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

咱们来简单计算一下，根据经验值，帧率为 15 帧 / 秒、分辨率为 720P 的视频，每秒钟大约要产生 1.2～1.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 * 8（3 表示 R、G、B 三种颜色，8 表示将 Byte 换算成 bit），约等于 22Mb；而存成 YUV420P 格式则约等于 11Mb，即 1280 * 720 * 1.5 * 8。

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

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

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

到这里我已经向你介绍了，在 WebRTC 实时通信中对音视频质量产生影响的因素有哪些，以及这些因素对音视频服务质量产生影响的基本原理，了解这些原理会更有利于你判断音视频服务质量变差的原因，从而决定是否要使用控制传输速率的方法来解决音视频服务质量的问题。

那接下来我们言归正转，看一下在 WebRTC 中具体该如何控制传输速率。

传输速率的控制

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

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

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

了解了上面这些知识之后，下面我们就来看看在 WebRTC 下如何才能控制视频流的码率。具体操作如下面代码所示：

....

var vsender = null; // 定义 video sender 变量

var senders = pc.getSenders(); // 从 RTCPeerConnection 中获得所有的 sender

// 遍历每个 sender

senders.forEach( sender => {

  if(sender && sender.track.kind === 'video'){ // 找到视频的 sender

      vsender = sender;

  }

});

var parameters = vsender.getParameters(); // 取出视频 sender 的参数

if(!parameters.encodings){ // 判断参数里是否有 encoding 域

    return;

}

// 通过 在 encoding 中的 maxBitrate 可以限掉传输码率

parameters.encodings[0].maxBitrate = bw * 1000;

// 将调整好的码率重新设置回 sender 中去，这样设置的码率就起效果了。

vsender.setParameters(parameters)

       .then(()=>{

          console.log('Successed to set parameters!');

       }).catch(err => {

          console.error(err);

       })

...

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

通过上面的代码你还可以看出，在 WebRTC 中速率的控制是使用压缩码率的方法来控制的，而不是直接通过传输包的多少来控制的。从另外一个角度你也可以得到这样的结论，因为 maxBitrate 属性来自于 sender 的 encoding 对象，而 encoding 对象就是进行编码时使用的参数。

小结

通过本文的讲解，你应该可以看出在 WebRTC 中控制传输速率其实是非常简单的事情，只要向 RTCPeerConnection 中的 sender 设置一个最大码率就可以控制某一路流的传输速率了。

另外，在本文中我还向你简要介绍了物理链路的质量、带宽的大小、码率、分辨率和帧率这几个影响音视频服务质量的重要因素，并详细分析了每个因素是如何影响到音视频服务质量的。

这里需要注意的是，在 WebRTC 中，通过上述的方式只能对每一路音视频流进行码率的控制，而不能进行整体的统一控制。所以，如果你的应用同时存在多路音视频流，而你又想控制一个总的码率，就只能一路一路地控制了。

思考时间

实际上，在 WebRTC 中除了通过 sender 的 maxBitrate 控制码率外，还可以通过 SDP 来控制传输速率，你是否可以找到这个方法呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

14 | 如何打开/关闭音视频？

在实时互动直播系统中，打开 / 关闭音视频流是很常见的需求。作为一个直播用户，你至少会有下面几种需求：

将远端的声音静音。比如来了一个电话，此时，应该先将直播中远端的声音关掉，等接完电话再将远端的声音打开，否则电话的声音与直播远端的声音会同时播放出来。

将自己的声音静音。比如老板要找你谈话，这时你应该将直播中自己的声音静音，否则你与老板的一些私密谈话会被远端听到。比如被老板骂了，要是被远端听到可就尴尬了。

关闭远端的视频。这个与远端声音静音差不多，只不过将声音改为视频了。比如当机子性能比较差的时候，为了节省资源，你可能会选择将远端的视频关闭掉。不过这种情况不是很多。

关闭自己的视频。当你不想让对方看到自己的视频时，就可以选择关闭自己的视频。比如今天你的状态特别不好，你又特别在乎你的形象，此时你就可以选择关闭自己的视频。

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

基本逻辑

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

1. 将远端的声音静音

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

在播放端有两种方法，一种是不让播放器播出来，另一种是不给播放器喂数据，将收到的音频流直接丢弃。在播放端控制的优点是实现简单；缺点是虽然音频没有被使用，但它仍然占用网络带宽，造成带宽的浪费。

在发送端控制也可以细分成两种方法实现，即停止音频的采集和停止音频的发送。对于 1 对 1 实时直播系统来说，这两种方法的效果是一样的。但对于多对多来说，它们的效果就大相径庭了。因为停止采集音频后，所有接收该音频的用户都不能收到音频了，这显然与需求不符；而停止向某个用户发送音频流，则符合用户的需求。

2. 将自己的声音静音

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

3. 关闭远端的视频

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

不过它与“将远端的声音静音”也是有区别的，那就是：

从播放端控制只能使用不给播放器喂数据这一种方法，因为播放器不支持关闭视频播放的功能；

从发送端控制是通过停止向某个用户发送视频数据这一种方法来实现的。而另一个停止采集则不建议使用，因为这样一来，其他端就都看不到你的视频了。

4. 关闭自己的视频

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

代码实现

了解了以上如何打开 / 关闭音视频的基本逻辑后，下面我们就具体实操起来，来看一下如何通过代码实现上面那些功能吧！

1. 将远端的声音静音

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

（1）播放端控制：不播放声音

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

<HTML>

...

<video id=remote autoplay muted playsinline/>

...

</HTML>

当<video>标签设置了 muted 属性后，你会发现虽然将远端获取到音视频流赋值给 <video> 标签进行播放，但最后只有视频被显示出来了，声音不播放。这样也就达到你的预期了。

其实，要想让音频播放出来也很容易，只需写一行 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)=>{

      if (track.kind === 'audio') { // 判断 track 是类型

        // 从媒体流中移除音频流

        remoteStream.removeTrack(track);

      }

    });

    // 显示视频

	remoteVideo.srcObject = e.streams[0];

}

...

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

（3）发送端控制：不采集音频

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

...

// 获取本地音视频流

function gotStream(stream) {

	  localStream = stream;

	  localVideo.srcObject = stream;

}

// 获得采集音视频数据时限制条件

function getUserMediaConstraints() {

  var constraints =  {

    "audio": false,

    "video": {

        "width": {

            "min": "640",

            "max": "1280"

        },

        "height": {

            "min": "360",

            "max": "720"

        }

    }

  };

  return constraints;

}

...

// 采集音视频数据

function captureMedia() {

	  ...

	  if (localStream) {

	    localStream.getTracks().forEach(track => track.stop());

	  }

      ...

      // 采集音视频数据的 API

	  navigator.mediaDevices.getUserMedia(getUserMediaConstraints())

	    .then(gotStream)

	    .catch(e => {

	     ...

	    });

}

...

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

（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)=>{

      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。

小结

通过上面的介绍，我想你应该已经对直播系统中如何打开 / 关闭音视频有了非常系统而又清楚的认识。在你的产品中具体该使用哪种方法来实现音视频的打开与关闭，主要还得看你的产品需求。

如果想为用户节省流量，那就尽可能在远端进行控制。如果觉得流量不是特别重要的问题，为了实现简单，直接在本地处理也是没有任何问题的。

音视频的打开 / 关闭逻辑上虽并不太复杂，但在直播系统是却是必不可少的功能。另一方面，如果你这个逻辑不清楚的话，也就是不知道分为本地静音、远端静音等逻辑的话，在多人实时互动的场景中，实现这部分代码时就很容易被绕进去。所以这部分的知识你最好还是要提前搞清楚、学透彻。

思考时间

上面我向你介绍了打开 / 关闭音视频的各种情况和实现方法，但列得还并不全面，你还能想到其他可以打开 / 关闭音视频的方法吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

15 | WebRTC中的数据统计原来这么强大（上）

当你使用 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 都能统计哪些信息。

WebRTC 统计数据页面

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

接收到的音频轨信息，“…Track_receiver_5…”

接收到的视频轨信息，“…Track_receiver_6…”

发送的音频轨信息，“…Track_sender_5…”

发送的视频轨信息，“…Track_sender_6…”

……

从上面的描述中，你可以看到，这些统计信息基本上包括了与音视频相关的方方面面。通过这些信息，你就可以做各种各样的服务质量分析了。比如说通过接收到的音视频轨信息，你就能分析出你的网络丢包情况、传输速率等信息，从而判断出你的网络质量如何。

接下来，我们以接收到的视频轨信息和发送的视频轨信息为例，向你详细介绍一下这些信息中都包括了哪些内容。

首先我们来看接收到的视频轨信息，在浏览器上点开 “…receiver_6(track)” 时，你就可以看到类似于下面这张图的内容：

接收视频轨信息图

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

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

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

发送视频轨信息图

从这张图中你可以看到，在发送的视频轨信息中包括了 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 => {

        // 遍历每个 report

        reports.forEach( report => {

            // 将每个 report 的详细信息打印出来

            console.log(report);

        });

    }).catch( err=>{

    	console.error(err);

    });

);

...

在上面的代码中，通过调用 RTCPeerConnecction 对象的 getStats 方法，就可以获取到你想得到的所有统计信息了。

如果你在浏览器上执行上面的代码，它大体上会得到下面这样的结果：

从这个结果中你可以看到，每个 Report 对象包括以下三个字段：

id：对象的唯一标识，是一个字符串。

timestamp：时间戳，用来标识该条 Report 是什么时间产生的。

type：类型，是 RTCStatsType 类型，具体的类型可以到文章末尾查看参考中的内容。另外，在下一篇文章中我们还会对各种不同类型的 Report 做进一步的分析。

在上面这三个字段中，Type（类型）是最关键的。每种类型的 Report 都有它自己的格式定义，因此，当你要获取更详细的信息时，首先要判断出该 Report 的类型是什么，然后根据类型转成对应的 Report 对象，再将信息取出来。

咱们举个例子，inbound-rtp 类型的 Report 与 outbound-rtp 类型的 Report 就有着非常大的区别，通过下面这两张图的对比就能看得非常清楚了。

首先我们来看一下 inbound-rtp 类型的 Report：

接下来是 outbound-rtp 类型的 Report：

从两张图中可以非常清楚地看到，inbount-rtp 里描述的是与接收相关的统计信息，而 outbound-rtp 中描述的则是与发送相关的各种统计信息。

小结

通过上面的讲解，我想你应该可以通过 RTCPeerConnection 对象的 getStats 方法得到你想要的各种统计信息了。

当然，你还可以通过向 RTCPeerConnection 对象的 getStats 方法中设置参数或不设置参数来决定你要获得多少统计数据或哪些统计数据。

WebRTC 的这些统计信息对于真实的应用场景是非常重要的，音视频各种服务质量的好坏都是通过它们来决定的。其中最最关键的统计信息是与传输相关的信息，比如发了多少包、收了多少包、丢了多少包等，通过这些信息你很容易就能评估出用户在使用你的产品时是否出现了问题。

思考时间

现在到了思考时间，那本文你要思考的问题是，对于网络相关的统计信息是如何计算的呢？举个例子，如果 A 向 B 发了 1000 个包，A 怎么知道 B 有没有收到呢？你怎么知道它们之间有没有发生过丢包呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

参考

16 | WebRTC中的数据统计原来这么强大（下）

在上一篇文章中我向你介绍了 WebRTC 可以获得哪些统计信息，以及如何使用 RTCPeerConntction 对象的 getStats 方法获取想要的统计信息。

那本文我们在上一篇文章的基础之上，继续对 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 => { // 得到相关的报告

    reports.forEach( report => { // 遍历每个报告

      console.log(report);

    });

  }).catch( err=>{

    console.error(err);

  });

// 从 PC 上获得 sender 对象

var sender = pc.getSenders()[0];

...

// 调用 sender 的 getStats 方法

sender.getStats()

    .then(reports => { // 得到相关的报告

        reports.forEach(report =>{ // 遍历每个报告

            if(report.type === 'outbound-rtp'){ // 如果是 rtp 输出流

            ....

            }

        }

     );

 ...

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

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

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

RTCStatsReport

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

RTCStatsReport 的结构如下：

interface RTCStatsReport {

  readonly maplike<DOMString, object>;

};

即 RTCStatsReport 中有一个 Map，Map 中的 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 种子类型，你可以到文末的参考中去查看。实际上，这个表格在上一篇文章中我已经向你做过介绍了，这里再重新温习一下。

若你对具体细节很感兴趣的话，可以通过《WebRTC1.0 规范》去查看每个 RTCStats 的详细定义，相关链接在这里。

RTCP 交换统计信息

在上一篇文章中，我给你留了一道思考题，不知你是否已经找到答案了？实际上在 WebRTC 中，上面介绍的输入 / 输出 RTP 流报告中的统计数据都是通过 RTCP 协议中的 SR、RR 消息计算而来的。

关于 RTCP 以及 RTCP 中的 SR、 RR 等相关协议内容记不清的同学可以再重新回顾一下《 06 | WebRTC 中的 RTP 及 RTCP 详解》一文的内容。

在 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 中引入第三方库也非常简单，只要使用 <script> 就可以将第三方库引入进来了。具体代码如下：

<html>

  ...

  <body>

  ...

  <script src="js/client.js"></script>

  // 引入第三方库 graph.js

  <script src="js/third_party/graph.js"></script>

  ...

  </body>

</html>

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(() => {

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

    return;

  }

  // 从 pc 中获取发送者对象

  const sender = pc.getSenders()[0];

  if (!sender) {

    return;

  }

  sender.getStats().then(res => { // 获取到所有的 Report

    res.forEach(report => { // 遍历每个 Report

      let bytes;

      let packets;

      // 我们只对 outbound-rtp 型的 Report 做处理

      if (report.type === 'outbound-rtp') {

        if (report.isRemote) { // 只对本地的做处理

          return;

        }

        const now = report.timestamp;

        bytes = report.bytesSent; // 获取到发送的字节

        packets = report.packetsSent; // 获取到发送的包数

        // 因为计算的是每秒与上一秒的数据的对比，所以这里要做个判断

        // 如果是第一次就不进行绘制

        if (lastResult && lastResult.has(report.id)) {

          // 计算这一秒与上一秒之间发送数据的差值

          var mybytes= (bytes - lastResult.get(report.id).bytesSent);

          // 计算走过的时间，因为定时器是秒级的，而时间戳是豪秒级的

          var mytime = (now - lastResult.get(report.id).timestamp);

          const bitrate = 8 * mybytes / mytime * 1000; // 将数据转成比特位

          // 绘制点

          bitrateSeries.addPoint(now, bitrate);

          // 将会制的数据显示出来

          bitrateGraph.setDataSeries([bitrateSeries]);

          bitrateGraph.updateEndDate();// 更新时间

          // 下面是与包相关的绘制

          packetSeries.addPoint(now, packets -

                               lastResult.get(report.id).packetsSent);

          packetGraph.setDataSeries([packetSeries]);

          packetGraph.updateEndDate();

        }

      }

    });

    // 记录上一次的报告

    lastResult = res;

  });

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

...

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

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

3. 最终的结果

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

小结

在本文中，我首先向你介绍了除了可以通过 RTCPeerConnection 对象的 getStats 方法获取到各种统计信息之外，还可以通过 RTCRtpSender 或 RTCRtpReceiver 的 getStats 方法获得与传输相关的统计信息。WebRTC 对这些统计信息做了非常细致的分类，按类型可细分为 17 种，关于这 17 种类型你可以查看文末参考中的表格。

在文中我还向你重点介绍了编解码器、输入 RTP 流以及输出 RTP 流相关的统计信息。

除此之外，在文中我还向你介绍了网络传输相关的统计信息是如何获得的，即通过 RTCP 协议中的 SR 和 RR 消息进行交换而来的。实际上，对于 RTCP 的知识我在前面《06 | WebRTC 中的 RTP 及 RTCP 详解》一文中已经向你讲解过了，而本文所讲的内容则是 RTCP 协议的具体应用。

最后，我们通过使用第三方库 graph.js 与 getStats 方法结合，就可以将统计信息以图形的方式绘制出来，使你可以清晰地看出这些报告真正表达的意思。

思考时间

今天你要思考的问题是：当使用 RTCP 交换 SR/RR 信息时，如果 SR/RR 包丢失了，会不会影响数据的准确性呢？为什么呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

参考

例子代码地址，戳这里
第三方库地址，戳这里

17 | 如何使用Canvas绘制统计图表（上）？

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 的对比图。

2D 与 3D 对比图

在计算机中，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 释放出来。

下面我们看一下，现代计算机图形处理的基本原理，如下图所示：

图形处理原理图

图中 Graphic Memory、GPU、Video Controller 都是现代显卡的关键组成部分，具体处理过程大致可描述为如下。

应用程序处理的图形数据都是保存在 System Memory 中的，也就是我们经常所说的主内存中。需要硬件处理的时候，先将 System Memory 中的图形数据拷贝到 Graphic Memory 中。

然后，通过 CPU 指令通知 GPU 去处理图形数据。

GPU 收到指令后，从 Graphic Memory 读取图形数据，然后进行坐标变换、着色等一系列复杂的运算后形成像素图，也就是 Video Frame，会存储在缓冲区中。

视频控制器从 Video Frame 缓冲区中获取 Video Frame，并最终显示在显示器上。

4. Canvas 实现原理

目前苹果浏览器使用的渲染引擎是 WebKit，Chrome 使用的渲染引擎是基于 WebKit 修改后的 Blink，它们的基本原理都差不多，只是实现细节的差别。我们这里以 WebKit 为例，来看看它是如何进行图像渲染的。

无论是 WebKit 还是 Blink，它们都是通过 Canvas 元素对图像进行渲染。（对 WebKit 有兴趣的同学可以从 GitHub 上获取到其源码， GitHub 地址为：https://github.com/WebKit/webkit/tree/master/Source/WebCore/html/canvas ）

下面我们来看一下 HTML 中的 Canvas 在 WebKit 里是如何表述的 ，其实现原理图如下所示：

Canvas 实现原理图

在 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

OpenGL（Open 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 新增了 <Canvas> 元素，你把它想像成一块画布就可以了，其使用方式如下：

<canvas id="tutorial" width="300" height="150"></canvas>

在 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 = "rgb(200,0,0)"; // 设置颜色为红色

ctx_2d.fillRect (10, 10, 55, 50); // 设置矩型的大小

ctx_2d.fillStyle = "rgba(0, 0, 200, 0.5)"; // 设置颜色为蓝色，并且透明

ctx_2d.fillRect (30, 30, 55, 50); // 设置矩型大小

...

在上面代码中，首先通过 getElementById 获得了 Canvas 元素，然后通过 Canvas 元素获得了它的渲染上下文。因为在获取上下文时传入的参数为2d，所以可以知道是获取的 2D 图像渲染上下文。拿到上下文后，通过上下文的 fillRect 方法就可以将图形绘制出来了。

下面是这段代码最终的运行结果图：

2. 浏览器兼容性适配

由于 Canvas 是从 HTML5 之后才开始有的，所以比较老的浏览器是不支持 <Canvas> 标签的。不过，各浏览器在 Canvas 兼容性方面做得还是非常不错的。

对于支持 Canvas 的浏览器，直接会渲染画布中的图像或者动画；对于不支持 Canvas 的浏览器来说，你可以在 <Canvas> 和 <Canvas> 中间添加替换的的内容，这样浏览器会直接显示替换的内容。替换的内容可以是文字描述，也可以是静态图片。比如下面的代码：

   <canvas id="canvas_id1" width="150" height="150">

     The browser doesn't support the canvas tag.

   </canvas>

它表示在 IE8 执行上面的 HTML 页面，就会显示 The browser doesn't support the canvas tag.这句提示信息。

总结

图形图像相关的应用非常广泛，涉及的概念很多，技术也很复杂。在本文的开头，我向你讲解了 2D、3D 图形的基本概念和图形渲染的基本原理；后面简要地分析了 Canvas 在 WebKit 中的实现原理，并介绍了 Canvas 支持 3D 图形绘制所遵循的标准；最后，又说明了 Canvas 元素及其使用方法。

当然还有很多图形学的概念、原理在本文中并没有被提及或分析，比如，坐标系统、RGB 颜色模型、GPU 架构等等，由于篇幅原因，这里咱们就不做过多介绍了。如果你对这方面感兴趣的话，可以自行搜索相关内容并进一步学习。

至于 Canvas 在 WebRTC 中的具体应用，我会在下篇中再向你做详细介绍。

思考时间

今天你要思考的问题是： HTML5 中的视频播放除了可以使用 Video 标签播放之外，你还能想到其他可以使用的实现方式吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

18 | 如何使用Canvas绘制统计图表（下）？

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 地址，这是一个用于测试 WebRTC 的 URL 地址；

在每一个打开的页面中，点击“Connect Sig Server”按钮，此时每个页面都会出现两个视频窗口，这说明 WebRTC 视频通信已经建立成功了；

在 Chrome 浏览器中再打开一个 tab 页面（也就是第三个页面），输入 chrome://webrtc-internals/ 地址，此时，在这个页面中就会展示正在运行的 WebRTC 的各种统计信息，如媒体流统计信息、网络统计信息等；

你可以继续点开任意一个带有 “Stats graphs” 字样的选项，这样相关的统计图表就会展示出来了。

在这些统计图表中，你可以看到每一路音视频流的收发包、传输码率、带宽等信息。下面两张图展示的就是视频相关的统计信息。

WebRTC 统计信息图（一）

WebRTC 统计信息图（二）

在统计信息页面中，你可以点击鼠标右键，在弹出的菜单中选择“检查”，就会显示出该页面的 HTML 源码，也就是上面两张图中右半部分的内容。下面我们就对这个源码做一下简单的分析。

在 chrome://webrtc-internals/ 地址源码的 <head> 标签中，引用了 webrtc_internals.js 和 util.js 两个 JavaScript 脚本，代码如下所示：

...

<head>

    ...

    <script src="chrome://resources/js/util.js"></script>

    <script src="webrtc_internals.js"></script>

    ...

</head>

...

在这两个脚本中，最关键的是 webrtc_internals.js 脚本，因为所有统计信息的绘制都在 webrtc_internals.js 中完成的。

那这些图表是怎么绘制出来的呢？为了解开这个迷团，我们来观察一下“WebRTC 统计信息图（一）”这张图。在这张图中，左侧红框框中的信息表示的是 id 为“12756-1-bweforvideo-bweCompound-div”的 DIV，在这个 DIV 中绘制了一张发送视频带宽的图表。然后，我们再来看一下这张图表所对应的代码，也就是图中右侧红框框中的 HTML5 代码，从中我们可以知道，左侧的图表是由右侧的 HTML5 代码中的 <canvas> 标签绘制而成的。

在“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 的坐标系了解清楚了，你才能做后面的事儿。

Canvas 坐标系图

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 源码的镜像，在这个源码中，与绘图相关的代码主要是以下三个文件：

content/browser/resources/media/timeline_graph_view.js

content/browser/resources/media/data_series.js

content/browser/resources/media/stats_graph_helper.js

接下来，我们就对这个实现做一下简单的分析，由于功能不是很复杂，所以涉及的类也不多，通过下面这张类图就可以表示得很清楚：

绘制 WebRTC 统计图表的类图

下面我们就对上面这几个类之间的逻辑关系做一下梳理：

当我们在浏览器的 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 >= 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 < 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 绘制曲线的时候，经常会出现锯齿形，这种锯齿非常难看，有什么办法可以让曲线更平滑一些吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

19 | WebRTC能不能进行文本聊天呢？

WebRTC 不但可以让你进行音视频通话，而且还可以用它传输普通的二进制数据，比如说可以利用它实现文本聊天、文件的传输等等。

WebRTC 的数据通道（RTCDataChannel）是专门用来传输除了音视频数据之外的任何数据，所以它的应用非常广泛，如实时文字聊天、文件传输、远程桌面、游戏控制、P2P 加速等都是它的应用场景。

像文本聊天、文件传输这类应用，大多数人能想到的通常是通过服务器中转数据的方案，但 WebRTC 则优先使用的是P2P 方案，即两端之间直接传输数据，这样就大大减轻了服务器的压力。当然 WebRTC 也可以采用中继的方案，这就需要你根据自己的业务需要进行选择，非常灵活。

RTCDataChannel 介绍

RTCDataChannel 就是 WebRTC 中专门用来传输非音视频数据的类，它的设计模仿了 WebSocket 的实现，使用起来非常方便，关于这一点我将在下面的“RTCDataChannel 的事件” 部分向你做更详细的介绍。

另外，RTCDataChannel 支持的数据类型也非常多，包括：字符串、Blob、ArrayBuffer 以及 ArrayBufferView。

实际上，关于这几种类型的联系与区别我在前面《04 | 可以把采集到的音视频数据录制下来吗？》一文中已经向你做过详细的介绍，如果你现在记不清了，可以再去回顾一下。

WebRTC 的 RTCDataChannel 使用的传输协议为 SCTP，即 Stream Control Transport Protocol。下面图表表示的就是在 TCP、UDP 及 SCTP 等不同传输模式下，数据传输的可靠性、传递方式、流控等信息的对比：

RTCDataChannel 既可以在可靠的、有序的模式下工作，也可在不可靠的、无序的模式下工作，具体使用哪种模式可以根据用户的配置来决定。下面我们来看看它们之间的区别。

可靠有序模式（TCP 模式）：在这种模式下，消息可以有序到达，但同时也带来了额外的开销，所以在这种模式下消息传输会比较慢。

不可靠无序模式（UDP 模式）：在此种模式下，不保证消息可达，也不保证消息有序，但在这种模式下没有什么额外开销，所以它非常快。

部分可靠模式（SCTP 模式）：在这种模式下，消息的可达性和有序性可以根据业务需求进行配置。

那接下来我们就来看一下到底该如何配置 RTCDataChannle 对象吧。

配置 RTCDataChannel

在创建 RTCDataChannel 对象之前，首先要创建 RTCPeerConnection 对象，因为 RTCDataChannel 对象是由 RTCPeerConnection 对象生成的。有了 RTCPeerConnection 对象后，调用它的 createDataChannel 方法，就可以将 RTCDataChannel 创建出来了。具体操作如下：

...

var pc = new RTCPeerConnection(); // 创建 RTCPeerConnection 对象

var dc = pc.createDataChannel("dc", 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)=> { // 出错

	...

};

dc.onopen = ()=> {// 打开

	...

};

dc.onclose = () => {// 关闭

	...

};

dc.onmessage = (event)=>{// 收到消息

	...

};

...

所以在使用 RTCDataChannel 对象时，你只要按上面的模板逐一实现其逻辑就好了，是不是很简单？

实时文字聊天

有了上面的知识，下面我们就来看一个具体的例子，看看如何通过 RTCDataChannel 对象实现一个实时文字聊天应用。

你可以想像这样一个场景，在两台不同的 PC 上（一个称为 A，另一个称为 B），用户打开浏览器，在页面上显示两个 textarea，一个作为文本输入框，另一个作为聊天记录的显示框。如下图所示：

文本聊天图

当 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 中指定的 ID，ID 号必须一致）。

这种方法的优势是，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': "passwd",

	      'username': "username"

	    }]

  };

  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 += "<- " + msg + "\n";

      }

  };

以上就是文本聊天的大体逻辑。具体的代码你可以到（文末的）GitHub 链接上获取。

小结

本文我们结合具体的例子——实时文字聊天，向你详细介绍了如何使用 RTCDataChannel 进行非音视频数据的传输。

RTCDataChannel 的创建有两种方式，一种是默认的 In-band 协商方式，另一种是 Out-of-band 协商方式。在本文例子的实践部分，我们主要应用的是第一种方式。但一般情况下我更推荐使用第二种方式，因为它更高效、更简洁。

另外，在使用 RTCDataChannel 时，还有两点你需要注意：

RTCDataChannel 对象的创建要在媒体协商（offer/answer） 之前创建，否则 WebRTC 就会一直处于 connecting 状态，从而导致数据无法进行传输。

RTCDataChannel 对象是可以双向传输数据的，所以接收与发送使用一个 RTCDataChannel 对象即可，而不需要为发送和接收单独创建 RTCDataChannel 对象。

当然本文只是介绍了 RTCDataChannel 的“一种”具体应用，若你有兴趣还可以自行实践其他更有趣的实现。

思考时间

今天留给你的思考题是：SCTP 协议是运行在 TCP 协议之上还是 UDP 协议之上呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

参考

具体代码地址：https://github.com/avdance/webrtc_web/tree/master/19_chat/

20 | 原来WebRTC还可以实时传输文件？

在上一篇文章中我向你介绍了在 WebRTC 中如何传输非音视频数据，并通过实现一个 1 对 1 实时聊天的例子向你展示了如何使用 RTCDataChannel 对象进行文本数据的传输。

其实利用 WebRTC 的 RTCDataChannel 对象，不光可以实现 1 对 1 的实时聊天，你还可以利用它进行实时的文件传输。

实时文件传输是一个非常有用的工具，尤其是通过浏览器进行实时文件传输就更加有价值，因为它不会受到操作系统或开发语言的影响，所以你可以在任何不同的操作系统上进行文件的传输，非常方便。

举个例子，由于工作的需要，你可能经常需要在不同的操作系统间切来切去（一会儿在 Windows 系统上开发，一会儿在 Linux 系统上开发，一会儿又在 Mac 系统上开发），当你想将 Windows 上的文件传到 Linux 系统上时就特别不方便，但如果可以通过浏览器传输文件的话，那将会大大提高你的工作效率。

基本原理

在 WebRTC 中，实时文件的传输与实时文本消息传输的基本原理是一样的，都是使用 RTCDataChannel 对象进行传输。但它们之间还是有一些差别的，一方面是传输数据的类型不一样，另一方面是数据的大小不一样。下面我们就从这两方面来具体讨论一下。

在上一篇文章中，我曾向你介绍过 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("dc", options);

...

通过对比，你可以看到它们之间的不同是：在实时文件传输中创建 RTCDataChannel 对象带了 options 参数，而实时文本聊天中并没有带该参数。

在这个示例中之所以要带 options 参数，是因为在端与端之间传输文件时，必须要保证文件内容的有序和完整，所以在创建 RTCDataChannel 对象时，你需要给它设置一些参数，即需要设置 ordered 和 maxRetransmits 选项。当 ordered 设置为真后，就可以保证数据的有序到达；而 maxRetransmits 设置为 30，则保证在有丢包的情况下可以对丢包进行重传，并且最多尝试重传 30 次。

通过实践证明，如果你在创建 RTCDataChannel 对象时不设置这两个参数的话，那么在传输大文件（如 800M 大小的文件）时就很容易失败。而设置了这两个参数后，传输大文件时基本就没再失败过了，由此可见这两个参数的重要性了。

2. 通过 RTCDataChannel 对象接收数据

创建好 RTCDataChannel 对象后，你仍然要实现 RTCDataChannel 对象的 4 个重要事件（打开、关闭、接收到消息以及出错时接收到事件）的回调函数，代码如下：

dc.onerror = (error)=> {

  ...

};

dc.onopen = ()=> {

  ...

};

dc.onclose = () => {

  ...

};

dc.onmessage = (event)=>{

  ...

}

...

这四个事件的作用如下：

onerror，是指当发生连接失败时的处理逻辑；

onopen，是指当 datachannel 打开时的处理逻辑；

onclose，是指当 datachannel 关闭时的处理逻辑；

onmessage，是指当收到消息时的处理逻辑。

其中最重要的是 onmessage 事件，当有数据到达时就会触发该事件。那接下来，我们就看一下到底该如何实现这个事件处理函数，具体代码如下：

...

var receiveBuffer = []; // 存放数据的数组

var receiveSize = 0; // 数据大小

...

onmessage = (event) => {

  // 每次事件被触发时，说明有数据来了，将收到的数据放到数组中

  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 => { // 当数据被加载时触发该事件

    ...

    dc.send(e.target.result); // 发送数据

    offset += e.target.result.byteLength; // 更改已读数据的偏移量

    ...

    if (offset < file.size) { // 如果文件没有被读完

      readSlice(offset); // 读取数据

    }

  }

  var readSlice = o => {

    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) => {

  ...

  // 如果是 fileinfo 类型的消息

  if(data.hasOwnProperty('type') && 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 对象的创建、数据的发送、数据的接收以及文件基本信息的传输。

在实际的应用中，由于某些特殊原因，文件在传输过程中还是会被中断掉，如网络连接断了、传输被人为暂停了等等。对于这类情况，你可以使用断点续传的方法来恢复被中断的文件传输。这个并不麻烦，如果你感兴趣，可以自行学习和研究下。

思考时间

在上面描述中我们有提到，当文件在传输过程中被中断后，可以通过断点续传的方式恢复被中断的文件传输，这种情况一般是在浏览器页面没有被关闭的情况下才可以做到。但如果页面被关闭了，或者说浏览器被关了，是否还有办法恢复被中断的传输？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

21 | 如何保证数据传输的安全（上）？

数据安全越来越受到人们的重视，尤其是一些敏感数据，如重要的视频、音频等。在实现音视频通信的过程中，如果在网络上传输的音视频数据是未加密的，那么黑客就可以利用 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 算法

HMAC（Hash-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。

证书持有人的公钥、算法（指明密钥属于哪种密码系统）的标识符和其他相关的密钥参数。

证书的序列号：证书颁发机构给每一个证书分配的唯一的数字型编号。

主题信息：证书持有人唯一的标识符。

证书的有效期：证书的起始时间以及终止时间。

认证机构：证书的发布者。

发布者的数字签名：这是“认证中心”私钥生成的签名，以确保这个证书在发放之后没有被窜改过。

签名算法标识符：用来指定签署证书时所使用的签名算法。

22 | 如何保证数据传输的安全（下）？

在上一篇文章中，我向你介绍了保证数据传输安全的一些基本概念，如非对称加密、数字证书等等。那本文我们就结合这些基本概念，来一起看看 WebRTC 是如何保障数据安全的。

WebRTC 的一个重要应用就是在浏览器上实现音视频实时通信，正如上一篇文章中所讲到的，在浏览器上由于用户量巨大，所以对于音视频的通信必须要有一套机制来保证音视频数据的安全性。

实际上，在浏览器上对于音视频数据的安全最终还是由 WebRTC 库自己保证的。它是通过使用 SDP、STUN、DTLS、SRTP 等几个协议的结合来达到数据安全的。

WebRTC 数据安全机制

为了保障音频数据的安全，WebRTC 使用了一整套机制来进行保护，下面我们就来看一下 WebRTC 是如何保障数据安全的吧！

我们来假设一个场景， A 与 B 通信，为了保障数据的安全，我们只需要在 A 端发送数据前将要发送的数据进行加密，B 端收到数据之后再进行解密就好了，这样看起来保证数据的安全还是蛮简单的事情。

但这里有一个问题，B 端是如何知道 A 端使用的哪种加密算法进行加密的呢？另外，加密算法还分对称加密和非对称加密，我们应该选择哪个呢？实际上在上一篇文章中我已经向你做了介绍，对于加密来说，使用非对称加密是最安全的，因此选择非对称加密是必然的选择。

既然选择非对称加密，那么 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 的重要信息。其实这种情况更多发生在会议系统或在线教育的小班课中，此时会议中有多人进行互动，如果黑客进入了会议中，他只需听别人说话，自己不发言，这样就将关键的信息窃取走了。所以现在的问题又来了，我们该如何辨别对方的身份是否合法呢？

看到辨别身份的问题是不是似曾相识？在上一篇文章中我向你介绍过通过数字签名的方式可以防止内容被窜改。WebRTC 也是使用的这种方式，它首先通过信令服务器交换 SDP，SDP 信息中包括了以下几个重要信息：

...

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 在数据安全方面做了非常多少努力了。下面的序列图就清楚地表述了我上面所讲述的内容。

WebRTC 安全序列图

从这张图中你可以看到， A 与 B 在传输数据之前，需要经历如下几个步骤。

首先通过信令服务器交换 SDP 信息，也就是进行媒体协商。在 SDP 中记录了用户的用户名、密码和指纹，有了这些信息就可以对用户的身份进行确认了。

紧接着，A 通过 STUN 协议（底层使用 UDP 协议）进行身份认证。如果 STUN 消息中的用户名和密码与交换的 SDP 中的用户名和密码一致，则说明是合法用户。

确认用户为合法用户后，则需要进行 DTLS 协商，交换公钥证书并协商密码相关的信息。同时还要通过 fingerprint 对证书进行验证，确认其没有在传输中被窜改。

最后，再使用协商后的密码信息和公钥对数据进行加密，开始传输音视频数据。

以上就是 WebRTC 保证数据安全的整套机制。

前面我们说了 WebRTC 是通过使用 DTLS、SRTP 等几个协议的结合来达到数据安全的，那接下来我们就来分别看一下这几个协议是如何实现的。

DTLS 协议

说到网络上的数据安全你可能首先想到的是 HTTPS，你也可以简单地将 HTTPS 理解为“HTTP 协议 + 数据加密”，当然实际上它要复杂得多。HTTPS 的底层最初是使用 SSL（Secure Sockets Layer，安全套接层）协议对数据加密。当 SSL 更新到 3.0 时，IETF 对 SSL 3.0 进行了标准化，并增加了一些新的功能，不过基本与 SSL 3.0 没什么区别，标准化后的 SSL 更名为 TLS 1.0（Transport 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 协议中，最关键是的它的握手协议，正如下图所展示的这样：

DTLS 握手协议示意图

在 WebRTC 中为了更有效地保护音视频数据，所以需要使用 DTLS 协议交换公钥证书，并确认使用的密码算法，这个过程在 DTLS 协议中称为握手协议。

DTLS 的握手过程如下：

首先 DTLS 协议采用 C/S 模式进行通信，其中发起请求的一端为客户端，接收请求的为服务端。

客户端向服务端发送 ClientHello 消息，服务端收到请求后，回 ServerHello 消息，并将自己的证书发送给客户端，同时请求客户端证书。

客户端收到证书后，将自己的证书发给服务端，并让服务端确认加密算法。

服务端确认加密算法后，发送 Finished 消息，至此握手结束。

DTLS 握手结束之后，通信双方就可以开始相互发送音视频数据了。

OpenSSL 库

讲到数据安全就不得不提 OpenSSL 库，通过它的名字你也基本可以知道它是做什么的。OpenSSL 是一个开源的 SSL 实现，正如我们上面说到的，SSL 是 TLS 早期的名字，实际上 OpenSSL 实现了整个 TLS 协议。

不仅如此，OpenSSL 还实现了 DTLS 协议，由于其代码开源，实现得又特别高效，所以现在大部分需要数据安全的应用程序基本上都是使用 OpenSSL 来实现的。

关于 OpenSSL 库，有以下几个基本概念你一定要清楚。

SSL_CTX：SSL 上下文，主要指明你要使用的 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 详解》一文中我向你详细介绍了 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(&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(&policy.rtp);          srtp_crypto_policy_set_aes_cm_128_hmac_sha1_80(&policy.rtcp);

    break;

  case Profile::AES_CM_128_HMAC_SHA1_32:

    srtp_crypto_policy_set_aes_cm_128_hmac_sha1_32(&policy.rtp);

    srtp_crypto_policy_set_aes_cm_128_hmac_sha1_80(&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(&this->session, &policy);

...

第三步，对 RTP 包加密。如下面示例代码所示：

srtp_err_status_t err = srtp_protect(this->session,

                                    (void*)EncryptBuffer,

                                    reinterpret_cast<int*>(len));

第一个参数是 Session，第二个参数是要加密的数据，第三个参数是被加密数据的长度。需要注意的是，加密后的数据也存放在 EncryptBuffer，即输入数据与输出数据共用同一块内容。

第四步，对 SRTP 包解密。这同上面的第三步类似，只不过操作相反，这里变成了解密。

srtp_err_status_t err = srtp_unprotect(this->session,

                                      (void*)data,

                                      reinterpret_cast<int*>(len));

第五步，也就是最后一步，是释放资源。

srtp_shutdown()；

以上这五步就是使用 libsrtp 对 RTP 数据加密 / 解密的基本步骤。

小结

本文首先向你介绍了 WebRTC 为了保护音视频数据的安全性，提供的是怎样一整套安全机制，然后又对这一套安全机制中的几个重点概念做了详细讲解，如 DTLS 协议、OpenSSL 库的使用以及如何将 RTP/RTCP 数据转成 SRTP/SRTCP 数据。

在介绍 DTLS 协议时，我们重点讲解了它是如何进行握手操作的，这里你只需要了解 DTLS 握手的基本原理即可，不用对细节进行特别的追究，因为握手的所有操作实际上都是由 OpenSSL 库实现的，你只需要调用它的 API 即可。

然后，我们又详细介绍了使用 OpenSSL 库的基本步骤，一共分成七大步。但这里所讲的七大步只是使用 OpenSSL 的一个基本步骤，对于 OpenSSL 的 DTLS 协议握手的 API 我这里并没有进行讲解，但有了这个基础我相信你自己一定可能自行学习 OpenSSL 的其他相关知识了。

最后，我们还讲解了 libsrtp 库的使用，这个库使用起来非常简单，关键点还是你对上一篇文章中安全相关的概念是否清楚，如果清楚的话，就很容易理解 libsrtp 的使用方法了。所以说，那些安全相关的概念虽很基础，但真的很重要。

通过上面的描述，我想现在你应该已经对 WebRTC 如何保护数据的安全有了一个清楚的认知了吧！

思考时间

今天留给你的思考题是：RTP 包被加密为 SRTP 包，这个过程是对整个包进行的加密，还是对 RTP 包的 Payload 部分加的密？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

23 | 实战演练：通过WebRTC实现一个1对1音视频实时直播系统

在前面的 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 的部分。实际上，在 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，房间已满。

那这些信令之间是怎样一种关系呢？在什么情况下该发送怎样的信令呢？要回答这个问题我们就要看一下信令状态机了。

信令状态机

我们这个系统的信令是由一个信令状态机来管理的，在不同的状态下，需要发送不同的信令。当收到服务端或对端的信令后，状态也会随之发生改变，下面我们来看一下这个状态的变化图吧。

信令状态机图

在初始时，客户端处于 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 实现一套最简单的信令系统？》一文中介绍的如何通过 socket.io 实现信令服务器，这样你就可以很轻松地将信令系统实现出来了。

这里我就不将详细的代码列出来了，在文章的最后我会将本文的示例代码展示出来，你可以参考它来实现。

TURN 服务器的搭建

在之前的文章中，有一个重要的知识点一直没介绍，那就是 TURN 服务。它有两个作用，一是提供 STUN 服务，客户端可以通过 STUN 服务获取自己的外网地址；二是提供数据中继服务。

当通信的双方无法通过 P2P 进行数据传输时，为了保证数据的连通性，我们就必须使用中继的方式让通信双方的数据可以互通，如下图所示：

WebRTC 处理过程图

目前最著名的 TURN 服务器是由 Google 发起的开源项目 coturn，它的源码和项目描述可以在 GitHub 上找到，地址为：https://github.com/coturn/coturn。

coturn 的编译安装与部署还是比较简单的。首先我们来看看如何编译安装 coturn，其步骤如下：

从 https://github.com/coturn/coturn 下载 coturn 代码；

执行 ./configure --prefix=/usr/local/coturn 生成 Makefile；

执行 make 来编译 coturn；

执行 sudo make install 进行安装。

接下来是布署，coturn 的配置文件中有很多选项，不过这些选项大部分都可以不用，或采用默认值，因此我们只需要修改 4 项内容就可以将 coturn 服务配置好了，这 4 项配置如下：

这里需要注意的是，在网上能看到很多讲解 coturn 配置的文章，但其中大部分配置都是有问题的，所以建议你按照本文所讲的步骤进行配置，避免浪费你宝贵的时间。

音视频数据的采集

音视频数据采集的过程我们已经在《01 | 原来通过浏览器访问摄像头这么容易》一文中做过详细介绍了，通过 getUserMedia 就可以获取到，就不再赘述了。

不过需要注意的是，我们系统采集音视频数据的时间点与以前不一样了，以前是在浏览器显示页面时就开始采集了，而现在则是在用户点击“Connect Sig Server”按钮时才开始采集音视频数据。

创建 RTCPeerConnection

信令系统建立好后，后面的逻辑都是围绕着信令系统建立起来的，RTCPeerConnection 对象也不例外。

在客户端，用户要想与远端通话，首先要发送 join 消息，也就是要先进入房间。此时，如果服务器判定用户是合法的，则会给客户端回 joined 消息。

客户端收到 joined 消息后，就要创建 RTCPeerConnection 对象了，也就是要建立一条与远端通话的音视频数据传输通道。

下面，我们就结合示例代码来看一下 RTCPeerConnection 是如何建立的。

...

var pcConfig = {

  'iceServers': [{ // 指定 ICE 服务器信令

    'urls': 'turn:stun.al.learningrtc.cn:3478', //turn 服务器地址

    'credential': "passwd", //turn 服务器密码，你要用自己的

    'username': "username"  //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)=>{

    pc.addTrack(track, localStream); // 将 track 与 peerconnection 绑定

  });

}

在上面的代码中，从 localStream 中遍历所有的 track，然后添加到 RTCPeerConnection 对象中就好了。

以上工作完成后，接下来最重要的一步就是媒体协商了，对于媒体协商的过程我们已经在《08 | 有话好商量，论媒体协商》一文中做过详细分析了。具体的逻辑我就不再重复了，不过需要注意的是，在本文中媒体协商的消息都是通过信令进行交互的，具体的实现可以参考文末的示例代码。

音视频的渲染与播放

按照上面的步骤，音视频数据就可以被采集到了，RTCPeerConnection 对象也创建好了，通过信令服务器也可以将各端的 Candidate 交换完成了。

此时在 WebRTC 的底层就会进行连通性检测，它首先判断通信的双方是否在同一个局域网内，如果在同一个局域网内，则让双方直接进行连接；如果不在同一局域网内，则尝试用 P2P 连接，如果仍然不成功，则使用 TURN 服务进行数据中继。

一旦数据连通后，数据就从一端源源不断地传到了远端，此时远端只需要将数据与播放器对接，就可以看到对端的视频、听到对方的声音了。

在浏览器上实现这一点非常容易，当数据流过来的时候会触发 RTCPeerConnection 对象的 ontrack 事件，只要我们侦听该事件，并在回调函数中将收到的 track 与<video>标签绑定到一起就好了，代码如下：

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（<video>标签）的 srcObject 属性，这就可以了。

小结

以上就是浏览器端实现 1 对 1 实时通话的整体逻辑。在本文中，我通过对运行环境的搭建、Web 服务器的实现、信令系统的实现、TURN 服务器的搭建、RTCPeerConnection 的创建、音视频数据的采集、传输、渲染与播放这几个主题的介绍，向你完整地讲解了在浏览器上实现 1 对 1 实时音视频通话的过程。

在这几个主题中，运行环境的搭建、信令系统的实现、TURN 服务器的搭建是之前没有讲过的知识，其他的知识点我在前面的文章中都有向你详细介绍过。如果你不记得或者觉得生疏了，可以再复习一下前面的文章。

总之，通过本文的“串联”，你就可以实现一个真正的 1 对 1 的实时音视频直播系统了。赶紧操练起来吧！

思考时间

你是否可以将以前所学的所有功能都集成到一起，形成一套相对完整的直播系统呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

所做 Demo 的 GitHub 链接（有需要可以点这里）

24 | 多人音视频实时通讯是怎样的架构？

在前面的章节里，我们通过大量的篇幅介绍了 WebRTC 在浏览器上对实时通信的各种支持。WebRTC 本身提供的是 1 对 1 的通信模型，在 STUN/TURN 的辅助下，如果能实现 NAT 穿越，那么两个浏览器是可以直接进行媒体数据交换的；如果不能实现 NAT 穿越，那么只能通过 TURN 服务器进行数据转发的方式实现通信。目前来看，Google 开源的用于学习和研究的项目基本都是基于 STUN/TURN 的 1 对 1 通信。

如果你想要通过 WebRTC 实现多对多通信，该如何做呢？其实，基于 WebRTC 的多对多实时通信的开源项目也有很多，综合来看，多方通信架构无外乎以下三种方案。

Mesh 方案，即多个终端之间两两进行连接，形成一个网状结构。比如 A、B、C 三个终端进行多对多通信，当 A 想要共享媒体（比如音频、视频）时，它需要分别向 B 和 C 发送数据。同样的道理，B 想要共享媒体，就需要分别向 A、C 发送数据，依次类推。这种方案对各终端的带宽要求比较高。

MCU（Multipoint Conferencing Unit）方案，该方案由一个服务器和多个终端组成一个星形结构。各终端将自己要共享的音视频流发送给服务器，服务器端会将在同一个房间中的所有终端的音视频流进行混合，最终生成一个混合后的音视频流再发给各个终端，这样各终端就可以看到 / 听到其他终端的音视频了。实际上服务器端就是一个音视频混合器，这种方案服务器的压力会非常大。

SFU（Selective Forwarding Unit）方案，该方案也是由一个服务器和多个终端组成，但与 MCU 不同的是，SFU 不对音视频进行混流，收到某个终端共享的音视频流后，就直接将该音视频流转发给房间内的其他终端。它实际上就是一个音视频路由转发器。

了解了上面几种通信架构后，接下来，我们分别论述一下这几种架构。

1 对 1 通信

不过在讲解多对多架构模型之前，咱们得先回顾一下之前讲的 WebRTC 1 对 1 通信的模型，因为只有在 1 对 1 通信模型非常清楚的情况下，你才能知道多对多通信模型的复杂度到底体现在哪儿。

在 1 对 1 通信中，WebRTC 首先尝试两个终端之间是否可以通过 P2P 直接进行通信，如果无法直接通信的话，则会通过 STUN/TURN 服务器进行中转，如下图：

WebRTC 1 对 1 音视频实时通话示意图

上面这张图咱们已经见过无数次了，相信你对它已经十分熟悉了。实际上，1 对 1 通信模型设计的主要目标是尽量让两个终端进行直联，这样即可以节省服务器的资源，又可以提高音视频的服务质量。

如果端与端之间可以直接通信，那么上图中的 STUN/TURN 服务器的作用就只是用于各终端收集 reflx 类型的 Candidate 。而如果端与端之间无法直接进行通信的话，那么 STUN/TURN 服务器就变成了中继服务器，可以利用它进行端与端之间数据的中转。

Mesh 方案

1 对 1 通信模型下，两个终端可以互相连接，那么我们是否可以让多个终端互相连接起来，从而实现多人通信呢？理论上这是完全可行的。Mesh 方案的结构如下图所示：

Mesh 方案架构图

在上图中，B1、B2、B3、B4 分别表示 4 个浏览器，它们之间两两相连，同时还分别与 STUN/TURN 服务器进行连接（此时的 STUN/TURN 服务器不能进行数据中转，否则情况会变得非常复杂），这样就形成了一个网格拓扑结构。

当某个浏览器想要共享它的音视频流时，它会将共享的媒体流分别发送给其他 3 个浏览器，这样就实现了多人通信。这种结构的优势有如下：

不需要服务器中转数据，STUN/TUTN 只是负责 NAT 穿越，这样利用现有 WebRTC 通信模型就可以实现，而不需要开发媒体服务器。

充分利用了客户端的带宽资源。

节省了服务器资源，由于服务器带宽往往是专线，价格昂贵，这种方案可以很好地控制成本。

当然，有优势自然也有不足之处，主要表现如下：

共享端共享媒体流的时候，需要给每一个参与人都转发一份媒体流，这样对上行带宽的占用很大。参与人越多，占用的带宽就越大。除此之外，对 CPU、Memory 等资源也是极大的考验。一般来说，客户端的机器资源、带宽资源往往是有限的，资源占用和参与人数是线性相关的。这样导致多人通信的规模非常有限，通过实践来看，这种方案在超过 4 个人时，就会有非常大的问题。

另一方面，在多人通信时，如果有部分人不能实现 NAT 穿越，但还想让这些人与其他人互通，就显得很麻烦，需要做出更多的可靠性设计。

MCU 方案

MCU 主要的处理逻辑是：接收每个共享端的音视频流，经过解码、与其他解码后的音视频进行混流、重新编码，之后再将混好的音视频流发送给房间里的所有人。

MCU 技术在视频会议领域出现得非常早，目前技术也非常成熟，主要用在硬件视频会议领域。不过我们今天讨论的是软件 MCU，它与硬件 MCU 的模型是一致的，只不过一个是通过硬件实现的，另一个是通过软件实现的罢了。MCU 方案的模型是一个星形结构，如下图所示：

MCU 方案架构图

我们来假设一个条件，B1 与 B2 同时共享音视频流，它们首先将流推送给 MCU 服务器，MCU 服务器收到两路流后，分别将两路流进行解码，之后将解码后的两路流进行混流，然后再编码，编码后的流数据再分发给 B3 和 B4。

对于 B1 来说，因为它是其中的一个共享者，所以 MCU 给它推的是没有混合它的共享流的媒体流，在这个例子中就是直接推 B2 的流给它。同理，对于 B2 来说 MCU 给它发的是 B1 的共享流。但如果有更多的人共享音视频流，那情况就更加复杂。

MCU 主要的处理逻辑如下图所示：

MCU 处理逻辑图

这个处理过程如下所示：

接收共享端发送的音视频流。

将接收到的音视频流进行解码。

对于视频流，要进行重新布局，混合处理。

对于音频流，要进行混音、重采样处理。

将混合后的音视频进行重新编码。

发送给接收客户端。

那 MCU 的优势有哪些呢？大致可总结为如下几点：

技术非常成熟，在硬件视频会议中应用非常广泛。

作为音视频网关，通过解码、再编码可以屏蔽不同编解码设备的差异化，满足更多客户的集成需求，提升用户体验和产品竞争力。

将多路视频混合成一路，所有参与人看到的是相同的画面，客户体验非常好。

同样，MCU 也有一些不足，主要表现为：

重新解码、编码、混流，需要大量的运算，对 CPU 资源的消耗很大。

重新解码、编码、混流还会带来延迟。

由于机器资源耗费很大，所以 MCU 所提供的容量有限，一般十几路视频就是上限了。

SFU 方案

SFU 像是一个媒体流路由器，接收终端的音视频流，根据需要转发给其他终端。SFU 在音视频会议中应用非常广泛，尤其是 WebRTC 普及以后。支持 WebRTC 多方通信的媒体服务器基本都是 SFU 结构。SFU 的拓扑机构和功能模型如下图：

SFU 方案架构图

在上图中，B1、B2、B3、B4 分别代表 4 个浏览器，每一个浏览器都会共享一路流发给 SFU，SFU 会将每一路流转发给共享者之外的 3 个浏览器。

下面这张图是从 SFU 服务器的角度展示的功能示意图：

SFU 功能示意图

相比 MCU，SFU 在结构上显得简单很多，只是接收流然后转发给其他人。然而，这个简单结构也给音视频传输带来了很多便利。比如，SFU 可以根据终端下行网络状况做一些流控，可以根据当前带宽情况、网络延时情况，选择性地丢弃一些媒体数据，保证通信的连续性。

目前许多 SFU 实现都支持 SVC 模式和 Simulcast 模式，用于适配 WiFi、4G 等不同网络状况，以及 Phone、Pad、PC 等不同终端设备。

SFU 的优势有哪些呢？可总结为如下：

由于是数据包直接转发，不需要编码、解码，对 CPU 资源消耗很小。

直接转发也极大地降低了延迟，提高了实时性。

带来了很大的灵活性，能够更好地适应不同的网络状况和终端类型。

同样，SFU 有优势，也有不足，主要表现为：

由于是数据包直接转发，参与人观看多路视频的时候可能会出现不同步；相同的视频流，不同的参与人看到的画面也可能不一致。

参与人同时观看多路视频，在多路视频窗口显示、渲染等会带来很多麻烦，尤其对多人实时通信进行录制，多路流也会带来很多回放的困难。总之，整体在通用性、一致性方面比较差。

通过上面的分析和比较，我相信你已经对这三种多对多音视频通信架构有了一个非常清晰的认知了。综合它们各自的优劣情况，我们可以得出 ，SFU 是三种架构方案中优势最明显而劣势又相对较少的一种架构方案。

无论是从灵活性上，还是音视频的服务质量、负载情况等方面上，相较其他两种方案，SFU 都有明显的优势，因此这种方案也被大多数厂商广泛采用。

另外，在上面介绍 SFU 方案时，我们还提到了视频的 Simulcast 模式和 SVC 模式，下面我就这两个知识点再向你做一下讲解，来看一下这两种视频的处理模式对 SFU 架构来说都带来了哪些好处。

1. Simulcast 模式

所谓Simulcast 模式就是指视频的共享者可以同时向 SFU 发送多路不同分辨率的视频流（一般为三路，如 1080P、720P、360P）。而 SFU 可以将接收到的三路流根据各终端的情况而选择其中某一路发送出去。例如，由于 PC 端网络特别好，给 PC 端发送 1080P 分辨率的视频；而移动网络较差，就给 Phone 发送 360P 分辨率的视频。

Simulcast 模式对移动端的终端类型非常有用，它可以灵活而又智能地适应不同的网络环境。下图就是 Simulcast 模式的示意图：

Simulcast 模式示意图

2. SVC 模式

SVC 是可伸缩的视频编码模式。与 Simulcast 模式的同时传多路流不同，SVC 模式是在视频编码时做“手脚”。

它在视频编码时将视频分成多层——核心层、中间层和扩展层。上层依赖于底层，而且越上层越清晰，越底层越模糊。在带宽不好的情况下，可以只传输底层，即核心层，在带宽充足的情况下，可以将三层全部传输过去。

如下图所示，PC1 共享的是一路视频流，编码使用 SVC 分为三层发送给 SFU。SFU 根据接收端的情况，发现 PC2 网络状况不错，于是将 0、1、2 三层都发给 PC2；发现 Phone 网络不好，则只将 0 层发给 Phone。这样就可以适应不同的网络环境和终端类型了。

SVC 模式示意图

小结

本文我向你介绍了三种多方通信的架构，分别是 Mesh、MCU 和 SFU。

整体来看，由于各方面限制，Mesh 架构在真实的应用场景中几乎没有人使用，一般刚学习 WebRTC 的同学才会考虑使用这种架构来实现多方通信。

MCU 架构是非常成熟的技术，在硬件视频会议中应用非常广泛。像很多做音视频会议的公司之前都会购买一套 MCU 设备，这样一套设备价格不菲，最低都要 50 万，但随着互联网的发展以及音视频技术越来越成熟，硬件 MCU 已经逐步被淘汰。

当然现在也还有公司在使用软 MCU，比较有名的项目是 FreeSWITCH，但正如我们前面所分析的那样，由于 MCU 要进行解码、混流、重新编码的操作，这些操作对 CPU 消耗是巨大的。如果用硬件 MCU 还好，但软 MCU 这个劣势就很明显了，所以真正使用软 MCU 架构方案的公司也不多。

SFU 是最近几年流行的新架构，目前 WebRTC 多方通信媒体服务器都是 SFU 架构。从上面的介绍中你也可以了解到 SFU 这种架构非常灵活，性能也非常高，再配上视频的 Simulcast 模式或 SVC 模式，则使它更加如虎添翼，因此各个公司目前基本上都使用该方案。

思考时间

今天我留给你的思考题是：在 SFU 方案中，该如何将多路音视频流录制下来并进行回放呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

25 | 那些常见的流媒体服务器，你该选择谁？

在上一篇文章中，我们详细讨论了三种类型（Mesh、MCU 和 SFU）的多方通信架构，并从中知道SFU 方案是目前最优的一种多方通信架构方案，而且这种方案目前已经有很多开源的实现了，比较流行的开源项目包括 Licode、Janus-gateway、MediaSoup、Medooze 等。

当然，你也可以自己实现 SFU 流媒体服务器，但自已实现流媒体服务器困难还是蛮多的，它里面至少要涉及到 DTLS 协议、ICE 协议、SRTP/SRTCP 协议等，光理解这些协议就要花不少的时间，更何况要实现它了。

而使用开源的流媒体服务器就可以大大地减少你的工作量，但这也带来一个问题：这么多开源流媒体服务器，究竟该选哪一个呢？这就涉及到一个选型的问题，你必须要对上面提到的开源项目有个清楚的认知，知道它们各自的优劣点，才能选出更适合的那款流媒体服务器。

接下来我们就按顺序把这几个开源实现向你做下介绍，从而让你了解它们的优缺点，以便选出更适合你的那款流媒体服务器。

Licode

Licode 既可以用作 SFU 类型的流媒体服务器，也可以用作 MCU 类型的流媒体服务器。一般情况下，它都被用于 SFU 类型的流媒体服务器。

Licode 不仅仅是一个流媒体通信服务器，而且还是一个包括了媒体通信层、业务层、用户管理等功能的完整系统，并且该系统还支持分布式部署。

Licode 是由 C++ 和 Node.js 语言实现。其中，媒体通信部分由 C++ 语言实现，而信令控制、用户管理、房间管理用 Node.js 实现。它的源码地址为：https://github.com/lynckia/licode 。下面这张图是 Licode 的整体架构图：

Licode 架构图

通过这张图你可以看出，Licode 从功能层面来讲分成三部分，即 Nuve 、ErizoController 和 ErizoAgent 三部分，它们之间通过消息队列进行通信。

Nuve 是一个 Web 服务，用于管理用户、房间、产生 token 以及房间的均衡负载等相关工作。它使用 MangoDB 存储房间和 token 信息，但不存储用户信息。

ErizoController，用于管理控制，信令和非音视频数据都通过它接收。它通过消息队列与 Nuve 进行通信，也就是说 Nuve 可以通过消息队列对 ErizoController 进行控制。

ErizoAgent，用于音视频流媒体数据的传输，可以分布式布署。ErizoAgent 与 ErizoController 的通信也是通过消息队列，信令消息通过 ErizoController 接收到后，再通过消息队列发给 ErizoAgent，从而实现对 ErizoAgent 进行控制。

通过上面的描述，你可以知道 Licode 不仅仅是一个 SFU 流媒体服务器，它还包括了与流媒体相关的业务管理系统、信令系统、流媒体服务器以及客户端 SDK 等等，可以说它是一个比较完善的产品。

如果你使用 Licode 作为流媒体服务器，基本上不需要做二次开发了，所以这样一套系统对于没有音视频积累的公司和个人具有非常大的诱惑力。目前 Intel CS 项目就是在 Licode 基础上研发出来的，已经为不少公司提供了服务。

但 Licode 也有以下一些缺点：

在 Linux 下目前只支持 Ubuntu 14.04 版本，在其他版本上很难编译通过。

Licode 不仅包括了 SFU，而且包括了 MCU，所以它的代码结构比较重，学习和掌握它要花不少的时间。

Licode 的性能一般， 如果你把流媒体服务器的性能排在第一位的话，那么 Licode 就不是特别理想的 SFU 流媒体服务器了。

Janus-gateway

Janus 是一个非常有名的 WebRTC 流媒体服务器，它是以 Linux 风格编写的服务程序，采用 C 语言实现，支持 Linux/MacOS 下编译、部署，但不支持 Windows 环境。

它是一个开源项目，其源码的编译、安装非常简单，只要按 GitHub 上的说明操作即可。源码及编译手册的地址为：https://github.com/meetecho/janus-gateway 。

Janus 的部署也十分简单，具体步骤详见文档，地址为：https://janus.conf.meetecho.com/docs/deploy.html 。

下面我们来看一下 Janus 的整体架构，其架构如下图所示：

Janus 架构图

从上面的架构图中，你可以看出 Janus 分为两层，即应用层和传输层。

插件层又称为应用层，每个应用都是一个插件，可以根据用户的需要动态地加载或卸载掉某个应用。插件式架构方案是非常棒的一种设计方案，灵活、易扩展、容错性强，尤其适用于业务比较复杂的业务，但缺点是实现复杂，成本比较高。

在 Janus 中默认支持的插件包括以下几个。

SIP：这个插件使得 Janus 成了 SIP 用户的代理，从而允许 WebRTC 终端在 SIP 服务器（如 Asterisk）上注册，并向 SIP 服务器发送或接收音视频流。

TextRoom：该插件使用 DataChannel 实现了一个文本聊天室应用。

Streaming：它允许 WebRTC 终端观看 / 收听由其他工具生成的预先录制的文件或媒体。

VideoRoom：它实现了视频会议的 SFU 服务，实际就是一个音 / 视频路由器。

VideoCall：这是一个简单的视频呼叫的应用，允许两个 WebRTC 终端相互通信，它与 WebRTC 官网的例子相似（https://apprtc.appspot.com），不同点是这个插件要经过服务端进行音视频流中转，而 WebRTC 官网的例子走的是 P2P 直连。

RecordPlay：该插件有两个功能，一是将发送给 WebRTC 的数据录制下来，二是可以通过 WebRTC 进行回放。

传输层包括媒体数据传输和信令传输。媒体数据传输层主要实现了 WebRTC 中需要有流媒体协议及其相关协议，如 DTLS 协议、ICE 协议、SDP 协议、RTP 协议、SRTP 协议、SCTP 协议等。

信令传输层用于处理 Janus 的各种信令，它支持的传输协议包括 HTTP/HTTPS、WebSocket/WebSockets、NanoMsg、MQTT、PfUnix、RabbitMQ。不过需要注意的是，有些协议是可以通过编译选项来控制是否安装的，也就是说这些协议并不是默认全部安装的。另外，Janus 所有信令的格式都是采用 Json 格式。

通过上面的分析，你可以知道，Janus 整体架构采用了插件的方案，这种架构方案非常优秀，用户可以根据自己的需要非常方便地在上面编写自己的应用程序。而且它目前支持的功能非常多，比如支持 SIP、 RTSP、音视频文件播放、录制等等，所以在与其他系统的融合性上有非常大的优势。另外，它底层的代码是由 C 语言编写的，性能也非常强劲。Janus 的开发、部署手册也非常完善，因此它是一个非常棒的开源项目。

如果说 Janus 有什么缺点的话，那就是它的架构设计比较复杂，对于初学者来说难度较大。

Mediasoup

Mediasoup 是推出时间不长的 WebRTC 流媒体服务器开源库，其地址为：https://github.com/versatica/mediasoup/ 。

Mediasoup 由应用层和数据处理层组成。应用层是通过 Node.js 实现的；数据处理层由 C++ 语言实现，包括 DTLS 协议实现、ICE 协议实现、SRTP/SRTCP 协议实现、路由转发等。

下面我们来看一下 Mediasoup 的架构图，如下所示：

Mediasoup 架构图

Mediasoup 把每个实例称为一个 Worker，在 Worker 内部有多个 Router，每个 Router 相当于一个房间。在每个房间里可以有多个用户或称为参与人，每个参与人在 Mediasoup 中由一个 Transport 代理。换句话说，对于房间（Router）来说，Transport 就相当于一个用户。

Transport 有三种类型，即 WebRtcTransport、PlainRtpTransport 和 PipeTransport。

WebRtcTransport 用于与 WebRTC 类型的客户端进行连接，如浏览器。

PlainRtpTransport 用于与传统的 RTP 类型的客户端连接，通过该 Transport 可以播放多媒体文件、FFmpeg 的推流等。

PipeTransport 用于 Router 之间的连接，也就是一个房间中的音视频流通过 PipeTransport 传到另一个房间。

在每个 Transport 中可以包括多个 Producer 和 Consumer。

Producer 表示媒体流的共享者，它又分为两种类型，即音频的共享者和视频的共享者。

Consumer 表示媒体流的消费者，它也分为两种类型，即音频的消费者和视频的消费者。

Mediasoup 的实现逻辑非常清晰，它不关心上层应用该如何做，只关心底层数据的传输，并将它做到极致。

Mediasoup 底层使用 C++ 开发，使用 libuv 作为其异步 IO 事件处理库，所以保证了其性能的高效性。同时它支持了几乎所有 WebRTC 为了实时传输做的各种优化，所以说它是一个特别优秀的 WebRTC SFU 流媒体服务器。

它与 Janus 相比，它更聚焦于数据传输的实时性、高效性、简洁性，而 Janus 相比 Mediasoup 做的事儿更多，架构和逻辑也更加复杂。所以对于想学习 WebRTC 流媒体服务器源码的同学来说，Mediasoup 是一个非常不错的项目。

另外，对于开发能力比较强的公司来说，根据自己的业务需要在 Mediasoup 上做二次开发也是非常值得推荐的技术方案。

Medooze

Medooze 是一款综合流媒体服务器，它不仅支持 WebRTC 协议栈，还支持很多其他协议，如 RTP、RTMP 等。其源码地址为：https://github.com/medooze/media-server 。

下面我们来看一下 Medooze 的架构图：

Medooze 架构图

从大的方面来讲，Medooze 支持 RTP/RTCP、SRTP/SRCP 等相关协议，从而可以实现与 WebRTC 终端进行互联。除此之外，Medooze 还可以接入 RTP 流、RTMP 流等，因此你可以使用 GStreamer/FFmpeg 向 Medooze 推流，这样进入到同一个房间的其他 WebRTC 终端就可以看到 / 听到由 GStream/FFmpeg 推送上来的音视频流了。另外，Medooze 还支持录制功能，即上图中的 Recorder 模块的作用，可以通过它将房间内的音视频流录制下来，以便后期回放。

为了提高多方通信的质量，Medooze 在音视频的内容上以及网络传输的质量上都做了大量优化。关于这些细节我们这里就不展开了，因为在后面的文章中我们还会对 Medooze 作进一步的讲解。

以上我们介绍的是 Medooze 的核心层，下面我们再来看看 Medooze 的控制逻辑层。Medooze 的控制逻辑层是通过 Node.js 实现的，Medooze 通过 Node.js 对外提供了完整的控制逻辑操作相关的 API，通过这些 API 你可以很容易的控制 Medooze 的行为了。

通过上面的介绍，我们可以知道 Medooze 与 Mediasoup 相比，两者在核心层实现的功能都差不多，但 Medooze 的功能更强大，包括了录制、推 RTMP 流、播放 FLV 文件等相关的操作，而 Mediasoup 则没有这些功能。

不过 Medooze 也有一些缺点，尽管 Medooze 也是 C++ 开发的流媒体服务务器，使用了异步 IO 事件处理机制，但它使用的异步 IO 事件处理的 API 是 poll，poll 在处理异步 IO 事件时，与 Linux 下最强劲的异步 IO 事件 API epoll 相比要逊色不少，这导致它在接收 / 发送音视频包时性能比 Mediasoup 要稍差一些。

如何选择 SFU

上面我们对 Licode、Janus、Mediasoup、Medooze 这几个开源的 WebRTC 流媒体服务器做了比较，从中你应该对它们的架构和特性有了一个基本了解。整体上看这些流媒体服务器各有优缺点，对于我们来说到底该选择那个项目作为我们的流媒体服务器呢？

其实，对流媒体服务器的选择是一个 Balance，没有最好，只有最合适。每个开源实现都有其各自的特点，都可以应用到实际产品中，只不过作为开发人员都有自己独特的技术背景，你需要根据自身特点以及项目特点选一个最合适的。接下来，我就介绍一下我是如何对这些开源项目进行评判和选择的。

在选择之前我会像上面一样对要选择的项目有个大体的了解，然后通过以下几个方面来对项目进行打分，从而选出最适合我的项目。

首先是实现语言。在一个团队中肯定会选择一种大家都比较熟悉的语言作为项目开发的语言，所以我们在选择开源项目时，就要选择使用这种语言开发的开源项目。比如阿里系基本都用 Java 语言进行开发，所以它们在选择开源项目时，基本都会选择 Java 开发的开源项目；而做音视频流媒体服务的开发人员，为了追求性能，所以一般都选择 C/C++ 语言开发的开源项目。

Meooze、Mediasoup、Licode 这三个流媒体服务器的媒体通信部分都是由 C++ 实现的，而控制逻辑是通过 Node.js 实现，因此如果你是 C++ 开发人员，且有 JavaScript 技术背景，那么你就应该在这三种流媒体服务器之间选择，因为这样更容易入门。而 Janus-gateway 是完全通过 C 语言实现的，服务部署是传统的 Linux 风格，因此如果你是 Linux/C 开发者，则应该选择 Janus 作为你的流媒体服务器。

其次是系统特点。像 Licode 是一个完整的系统，支持分布式集群部署，所以系统相对复杂，学习周期要长一些。它可以直接布署在生产环境，但是二次开发的灵活性不够。Janus-gateway 是一个独立的服务，支持的信令协议很丰富，而且支持插件开发，易扩展，对于 Linux/C 背景的开发者是很不错的选择。Medooze 和 Mediasoup 都是流媒体服务器库，对于需要将流媒体服务器集成到自己产品中的开发者来说，应该选择它们。

另外，从性能方面讲，Licode、Meooze、Mediasoup、Janus-gateway 单台服务都可以支持 500 方参会人，所以它们的性能都还是不错的。相对来说，Licode 的性能与其他流媒体服务器相比要低一些；Medooze 由于没有使用 epoll 来处理异步 IO 事件，所以性能也受到一些影响。不过总的来说，它们在 500 方的容量下，视频质量都可以得到很好的保证，延迟在 100ms 左右。

小结

本文我向你介绍了 Licode、Meooze、Mediasoup、Janus-gateway 四个开源 SFU 的架构及其特点。

从 SFU 模型来看，它们的实现基本都类似的，只是概念叫法不同，它们之间的差异主要体现在系统对外提供的接口上。对于想要研究 SFU 基本原理的开发者来说，只要选择其中一个项目仔细研读即可。但我个人还是建议初学者选择 Mediasoup 作为研究 SFU 原理的对象，因为它结构简单，代码量少，很容易入门。当然如果你是一个纯 C 技术背景的开发者，可以选择 Janus-gateway 作为研究对象。

如果说还需要研究录制回放、MCU 的开发者，可以去研究 Medooze。Medooze 结构比较完整，功能齐全，代码量也不大。而如果你想要让自己的媒体服务器支持集群部署，可以参考 Licode。

思考时间

如果让你自己实现一个 SFU 流媒体服务器，你会怎么设计？你认为都有哪些问题是实现 SFU 的关键呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

26 | 为什么编译Medooze Server这么难？

在上一篇文章中，我们对 Licode、Janus、Mediasoup 以及 Medooze 四个 WebRTC 开源流媒体服务器的实现进行对比，对于想研究音视频多方会议、录制回放、直播等流媒体技术的开发人员来说，Medooze 是很好的选择。因为它支持所有这些功能，通过它的源码及其 Demo 就可以对 Medooze 进行深入学习了。

从本文开始，在接下来的四篇文章我会向你详细讲述 Medooze 的架构、实现以及应用。而本文主要介绍 Medooze 是如何编译和构建的。

也许你会觉得 Linux 下的程序编译有什么可讲的呢，直接在 Linux 系统下执行一下 build 命令不就行了，还需要专门理解系统的编译过程和构建工具的使用吗？实际上，根据我多年的工作经验来看，理解项目的编译过程、熟悉各种构建工具的使用是非常有必要的。下面我就举几个例子，通过这几个例子，我想你就会对它们有一个深刻感悟了。

第一个例子，伴随着技术的飞速发展，构建工具也有了很大的变化，从最早的手动编写 Makefile，逐渐过渡到使用 Autotools、CMake、GYP 等构建工具来生成 Makefile，这些构建工具可以大大提高你的工作效率。比如，通过 Andorid Studio 创建 JNI 程序时，在 Android Studio 底层会使用 CMake 来构建项目，你会发现使用 CMake 构建 JNI 非常方便。然而像 Chrome 浏览器这种大型项目也用 CMake 构建就很不合适了，因为 Chrome 浏览器的代码量巨大，使用 CMake 构建它会花费特别长的时间（好几个小时），为了解决这个问题 Chrome 团队自己开发了一个新的构建工具，即 GYP，来构建这种大型项目，从而大大提高了构建的效率。由此可见，不同的项目使用不同的构建工具对开发效率的提升是巨大的。

第二个例子，当你研究某个开源项目的源代码时，如果你对 Makefile 语法非常熟悉的话，它对你研读项目的源码会起到事半功倍的效果。因为通过 Makefile 你可以了解到项目中引入了哪些第三方库、源文件编译的先后顺序是怎样的、都定义了那些宏等信息，这些信息都可以帮助你更好地理解源码中的实现逻辑。

第三个例子，对于某些开源项目，很多时候其构建文档并不完整，因此在构建过程中经常会出现各种失败，而这种失败会让人不知所措。但如果你对 Makefile 特别熟悉的话，你就可以去阅读它里面的内容，很多时候通过这种方法就可以很快找到编译失败的真正原因。

通过上面的描述，我相信你应该已经知道理解项目的构建过程，熟悉各种构建工具的使用对开发人员有多重要了。

那接下来我们言归正传，来看看该如何编译 Media-server-node 。

Media-server-node 项目

Media-server-node 是一个 Node.js 项目，其目录结构如下图所示：

目录结构图

通过上图你可以看到，在 Media-server-node 项目中，用红框框出来的四个目录比较重要。其中src 目录中存放的是 C++ 语言实现的 Native 代码，用于生成 JavaScript 脚本可以调用的 C++ 接口，从而使 JavaScript 编写的业务层代码可以调用 Medooze 核心层的 C++ 代码；lib 目录里存放的是在 Node.js 端执行的，控制 Medooze 业务逻辑的 JavaScript 代码；external 目录中存放的是 Medooze 使用的第三方库，如 MP4v2 和 SRTP；media-server 目录是一个比较重要的目录，它里面存放的是 Medooze 的核心代码，即 C++ 实现的流媒体服务器代码。

这里需要注意的是，Media-server-node 中的 media-server 目录是一个独立的项目，它是通过外部链接引用到 Media-server-node 项目中的。它既可以随 Media-server-node 一起编译，也可以自己单独编译。对于这一点我们后面还会做详细介绍。

1. 构建 Media-server-node 项目

构建 Media-server-node 项目非常简单，只需要执行下面的命令即可构建成功：

npm install medooze-media-server --save

实际上，在构建 Media-server-node 项目时，重点是对上面三个 C/C++ 目录（即 external、media-server、src）中的 Native 代码的构建。项目构建时会调用 node-gyp 命令将三个目录中的 C++ 代码编译成 Node.js 的 Native 插件，以供 JavaScript 脚本使用。

另外，Media-server-node 目录中的 binding.gyp 文件就是供 node-gyp 使用来构建 C++ Navtie 代码的规则文件。在执行上面的构建命令时，底层会调用 node-gyp 命令，node-gyp 以 binding.gyp 为输入，然后根据 binding.gyp 中的规则对 C++ Native 代码进行构建的。

下面我们就来看看，node-gyp 是如构建 C++ Native 代码的吧。

2. node-gyp & GYP

首先我们来了解一下 node-gyp，node-gyp 是一个由 Node.js 执行的脚本程序，是专门用于生成在 Node.js 中运行的 C/C++ Native 插件的工具。

实际上，你可以简单地将 node-gyp 认为是 gyp 和 Make/Ninja 工具的集合，当构建某个项目时，在底层 node-gyp 先使用 gyp 工具根据 binding.gyp 中的规则生成 Makefile 或 build.ninja 文件，然后再调用 Make/Ninja 命令进行构建，编译出 Native 插件。

那上面说的 GYP 又是什么呢？GYP（Generate Your Projects）是 Chromium 团队使用 Python 语言开发的构建工具，它以 .gyp 为输入文件，可以产生不同平台的 IDE 工程文件，如 VS 工程；或产生各种编译文件，如 Makefile 。

通过上面的描述你可以知道，存在着两个层面的规则文件，.gyp 是由 GYP 工具使用的项目规则文件，Makefile/build.ninja 是由 Make/Ninja 使用的编译规则文件，我们称它为编译文件。另外，Make/Ninja 命令相对更底层一些，它们执行时会直接调用编译器（如 GCC/G++）对源码进行编译。而 gyp 命令更偏项目管理一些，它是为了产生各种工程或 Makefile/build.ninja 文件而存在的。

有很多同学对 node-gyp、GYP、Make、Ninja 这些工具是什么关系分不清，通过上面的讲解你应该就清楚它们之间的区别了。下面这张图将它们之间的关系描述得更加清晰：

node-gyp、GYP 关系图

通过上图我们可以看到 gyp 命令是将 binding.gyp 文件生成 Makefile 文件，然后交给 make，最终将 Native 插件编译出来的。

了解了 node-gyp 和 GYP 之间的关系之后，我们再来了解一下 GYP 规则的语法。首先我们要知道 GYP 规则文件是以 JSON 格式存储的。另外，在 GYP 的规则文件中，它按有效范围将规则分为了两大类，即全局规则和局部规则。下面我们就以 binding.gyp 为例，看看它是如何使用这些规则的。

所谓全局规则就是指这些规则在整个文件内有效的规则，我们看一下代码吧：

代码中的 variables 用于在 GYP 规则文件中定义全局变量，这些全局变量被定义好后，就可以在规则文件中的任何地方被引用了。

GYP 中除了variables外，还定义了其他几个全局规则，具体的内容可以查看文末的参考一节，这里就不一一列出了。

在规则文件中最重要的规则要数 target 了，它属于局部规则，在 binding.gyp 文件中的 target 描述如下所示：

"targets":

[

  {

    "target_name": "medooze-media-server",

    "type": "static_library",

    "cflags": // 设置编译器编译参数

    [

      ...

      "-O3",

      "-g",

      ...

    ],

    "ldflags" : [" -lpthread -lresolv"], // 设置编译器连接参数

    "include_dirs" : // 项目需要的头文件目录

    [

      '/usr/include/nodejs/',

      "<!(node -e \"require('nan')\")"

    ],

    "link_settings":

    {

     'libraries': ["-lpthread -lpthread -lresolv"] // 链接时使用的第三方库

    },

    "sources": // 所有需要编译的源码文件

    [

      "src/media-server_wrap.cxx",

      ...

    ],

    "dependencies":[ // 指定依赖的其他的 target

      ...

.

    ],

    "conditions" : [ // 编译条件

       ["target_arch=='ia32'", {

         ...

       }],

       ...

       ['OS=="linux"',{

         ...

       }],

    ],

  }

]

下面我就向你详细讲解一下 target 中每个规则的作用和含义。

target_name ，是 target 的名字，在一个 .gyp 文件中，名字必须是唯一的，这里是 medooze-media-server。当使用 GYP 生成工程文件时，如 VS 工程或 XCode 工程，target_name 就是各工程文件的名字。

type，指明了生成的 target 的类型，你可以设置为以下几种类型：executable 表示要生成可执行程序，在 Windows 中就是 exe 文件；static_library 表示要生成静态库，生成的文件为 *.a 或者是*.lib 后辍的文件；shared_library 表示要生成共享库，也就是文件后辍名为.so 或者 .dll 的文件；none，表示为无类型，该类型留作特殊用途使用。

……

由于篇幅的原因，其他规则就不在这里一一列举了，如果你对它们感兴趣的话可以查看后面的参考一节。

通过上面的描述可以知道，在调用 npm 构建 Media-server-node 项目时，在它的内部会调用 node-gyp 命令，该命令以 binding.gyp 为输入文件，按照该文件中的规则生成 Makefile 或 build.ninja 文件，最后使用 Make/Ninja 命令来编译 C/C++ 的 Native 代码。这就是 node-gyp 执行编译的基本过程。

单独构建 media-server 项目

media-server 是 Medooze 流媒体服务器部分的实现，它用 C++ 实现。由于采用了 C++17 语法，所以需要使用较高版本 GCC 编译器。接下来我们就来看看该如何单独构建 Medooze 的 media-server 项目。

我的构建环境如下，操作系统 Ubuntu18.04 ，编译器版本 GCC 7.3.0 。

1. 安装依赖库

由于 Medooze 不仅支持 SFU，而且还支持 MCU 功能，所以它依赖音视频的编解码库和 FFmpeg 库。除此之外，为了与浏览器对接，它还依赖 libssl 库。因此，在构建 media-server 之前我们需要先将这些依赖库安装好。

安装依赖库的命令如下：

sudo apt-get install libxmlrpc-c++8-dev

sudo apt-get install libgsm1-dev

sudo apt-get install libspeex-dev

sudo apt-get install libopus-dev

sudo apt-get install libavresample-dev

sudo apt-get install libx264-dev

sudo apt-get install libvpx-dev

sudo apt-get install libswscale-dev

sudo apt-get install libavformat-dev

sudo apt-get install libmp4v2-dev

sudo apt-get install libgcrypt11-dev

sudo apt-get install libssl1.0-dev

安装好上面的依赖库后，我们就可以从 GitHub 上获取 media-server 项目的代码进行编译了。media-server 项目的源代码地址为：https://github.com/medooze/media-server.git 。

需要注意是的，获取代码时，你需要切换到 optimications 分支。我最开始使用的是 master 分支，但在这个分支上会遇到问题，切换到 optimications 分支后就没有任何问题了。

media-server 代码下载好后，进入到该项目的目录中，然后修改它的配置文件 config.mk，修改的内容如下所示：

...

SRCDIR = /home/xxx/media-server // 注意，media-server 的完整路径，后面不能有空格

...

通过上面的修改，我们就将编译 media-server 的准备工作完成了，接下来的事情就比较简单了。

2. 编译 common_audio

在开始编译 media-server 之前，我们还要先将 common_audio 模块编译出来，因为 common_audio 是一个公共模块，media-server 依赖于它。

common_audio 模块是通过 Ninja 编译的，因此我们还需要先安装 ninja-build 工具，等 ninja-build 工具安装好后，执行下面的命令就可以将 common_audio 编译好了。

sudo apt-get install ninja-build

cd media-server/ext/out/Release

ninja

3. 编译 media-server

有了 common_audio 模块之后，编译 media-server 就简单多了，只需要执行下面的命令行即可：

cd media-server

make

至此，media-server 就编译好了。需要注意的是，目前 medooze 的 master 分支代码提交比较频繁，经常会出现编译失败的问题，所以建议你选择稳定的分支进行编译。

另外，如果你在编译过程中遇到错误，可以按照编译的提示信息进行处理，一般都能将问题解决掉。如果是依赖库缺失的问题，则还需要像上面一样用 apt install 命令将需要的库安装好，再重新对 media-server 进行编译。

4. Ninja

在上面的描述中我们看到了 Ninja 命令，它又是干什么的呢？Ninja 与 Make 工具处于同一级别，它们都是从规则文件中读取内容，然后按照规则调用编译工具如 GCC/G++、CLANG/CLANG++ 编译源码文件，只不过 Make 的编译文件是 Makefile，而 Ninja 的编译文件是 build.ninja。

Ninja 是在 Chrome 浏览器的研发过程中开发出来的，与 GYP 一起被研发出来。最早 Chrome 也选择 Make 作为构建工具，然而随着 Chrome 浏览器源码的增长，通过 Make 构建 Chrome 的时间越来越长，尤其在项目中有超过 30000 个源文件的大型项目中，Make 工具构建项目的时长已经无法让人忍受了。因此 Chrome 工程师开发了一个新的构建工具，即 Ninja。Ninja 的设计哲学是简单、快，因此，它的出现大地的缩短了大型项目的构建时间。

Ninja 规则文件的文件名以 .ninja 结尾，所以如果我们在项目中看到 .ninja 的文件就可以判断出该项目的构建需要使用 Ninja 工具。Ninja 规则文件一般不需要手写，而是通过生产工具自动生成，像 GYP、CMake 都可以生成 Ninja 规则文件。

Ninja 的使用非常简单，只要将 Ninja 安装好后，进入到项目目录中，然后运行 Ninja 命令即可。Ninja 运行时默认会在当前目录中查找 build.ninja 文件，如果找到该文件的话，它就按照 build.ninja 中的规则来编译所有的已修改的文件。当然你也可以通过参数指定某个具体的 Ninja 规则文件，如：

ninja -f xxxxx.ninja

这样，它就可以按 xxxxx.ninja 文件中的规则对源码文件进行编译。

小结

本文我们以 Media-server-node 为例，首先向你介绍了如何通过 node-gyp 命令构建 Node.js 的 Native 插件。通过该讲解，你应该对 Node.js 有了更深的认知，它不光可以让你用 JavaScript 脚本编写服务器程序，而且还可以编写 C/C++ 的 Native 插件来增强 Node.js 的功能。

另外，我们还介绍了 GYP 规则文件，并以 media-server-node 项目的构建为例，详细讲解了 GYP 文件的一些重要规则及其作用。

通过本文的学习，我相信你已经对 node-gyp、GYP、Make、Ninja 等工具有了比较清楚的了解，这不仅可以帮你解决 Medooze 编译经常失败的问题，而且还会对你分析 Medooze 源码起到非常大的帮助作用，因为代码中很多宏的定义以及一些逻辑分支的走向都需要通过 Medooze 的编译过程进行追踪。所以学习好本文对后面的学习有着至关重要的作用。

思考时间

知道了 node-gyp、GYP、Make/Ninja 的关系，那你能说说 CMake 在项目编译中的作用以及与 make 的关系吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

参考

GYP 规则含义表：

27 | 让我们一起探索Medooze的具体实现吧（上）

在咱们专栏的第一模块，我向你介绍了如何使用 WebRTC 进行实现音视频互动。随着 Google 对 WebRTC 的大力推广，目前主流的浏览器都支持了 WebRTC。WebRTC 最主要的功能就是提供端对端的音视频通信，其可以借助 STUN/TURN 服务器完成 NAT 穿越，实现两个端点之间的直接通信。

1 对 1 的实时通信无疑是 WebRTC 最主要的应用场景，但你是否想过 WebRTC 可以通过浏览器实现多人音视频会议呢？更进一步，你是否想过 WebRTC 可以实现一些直播平台中上万人甚至几十万人同时在线的场景呢？

如果你对上面的问题感兴趣，那本文将让你对上面问题有个全面的了解，接下来就让我们一起开启本次神秘之旅吧！

流媒体服务器 Medooze

正如我们在《25 | 那些常见的流媒体服务器，你该选择谁？》一文中介绍的，要实现多个浏览器之间的实时音视频通信（比如音视频会议），那么就一定需要一个支持 WebRTC 协议的流媒体服务器。目前，有很多开源的项目支持 WebRTC 协议， Medooze 就是其中之一。

Medooze 的功能十分强大，通过它你既可以实现 SFU 类型的流媒体服务器，也可以实现 MCU 类型的流媒体服务器。而且，它还支持多种媒体流接入，也就是说，你除了可以通过浏览器（WebRTC）向 Medooze 分享音视频流之外，还可以使用 FFmpeg/VLC 等工具将 RTMP 协议的音视频流推送给 Meoodze。更加神奇的是，无论是 WebRTC 分享的音视频流还是 RTMP 格式的音视频流通过 Medooze 转发后，浏览器就可以将 FFmpeg/VLC 推的流显示出来，而 VLC 也可以将 WebRTC 分享的音视频显示出来，这就是 Medooze 的强大之处。

接下来，我们将从 Medooze 的 SFU 模型、录制回放模型和推流模型这三个方面向你全面、详细地介绍 Medooze。

1. Medooze 的 SFU 模型

SFU 模型的概念我们已经在《24 | 多人音视频实时通讯是怎样的架构？》一文中做过介绍了，这里就不再赘述了！下面这张图是 Medooze 实现 SFU 服务器的大体结构图：

Medooze SFU 模型结构图

图的最外层标有 Browser 的模块表示浏览器，在本图中就是表示基于浏览器的 WebRTC 终端。

图的中间标有 Medooze 的模块是服务器，实现了 SFU 功能。其中 DTLSICETransport 既是传输层（控制 socket 收发包），也是一个容器，在该容器中可以放 IncomingStream 和 OutgoingStream。

IncomingStream，代表某客户端共享的音视频流。

OutgoingStream，代表某个客户端需要观看的音视频流。

你也可以把它们直接当作是两个队列，IncomingStream 是输入队列，OutgoingStream 是输出队列。

有了上面这些概念，那我们来看看在 Medooze 中从一端进来的音视频流是怎么中转给其他人的。

首先，Browser 1 推送音视频流给 Medooze，Medooze 通过 DTLSICETransport 接收音视频数据包，然后将它们输送到 IncomingStream 中。

然后，Medooze 再将此流分发给 Browser 2、Browser 3、Browser 4 的 OutgoingStream。

最后，Medooze 将 OutgoingStream 中的音视频流通过 DTLSICETransport 传输给浏览器客户端，这样 Browser 2、Browser 3、Browser 4 就都能看到 Browser 1 共享的音视频流了。

同理，Browser 3 推送的音视频流也是按照上面的步骤操作，最终分发到其他端，其他端也就看 / 听到 Browser 3 的音视频了。

通过以上步骤就实现了多人之间的音视频互动。

2. 录制回放模型

下图是 Medooze 的录制回放模型：

录制回放模型

这张图是一个典型的音视频会议中服务器端录制的模型。下面我们就来看看它运转的过程：

Browser 1 推送音视频流到 Medooze，Medooze 使用 IncomingStream 代表这一路流。

Medooze 将 IncomingStream 流分发给参会人 Browser 2 的 OutgoingStream，并同时将流分发给 Recorder。

Recorder 是一个录制模块，将实时 RTP 流转换成 MP4 格式，并保存到磁盘。

通过以上步骤，Medooze 就完成了会议中实时录制的功能。

如果，用户想回看录制好的多媒体文件，那么只需要按以下步骤操作即可：

首先，告诉 Medooze 的 Player 模块，之前录制好的 MP4 文件存放在哪个路径下。

接着，Player 从磁盘读取 MP4 文件，并转换成 RTP 流推送给 IncomingStream。

然后，Medooze 从 IncomingStream 获取 RTP 流， 并分发给 Browser 3 的 OutgoingStream。

最后，再经过 DTLSICETransport 模块传给浏览器。

这样就可以看到之前录制好的音视频了。

3. 推流模型

下面是 Medooze 的推流模型：

推流模型

通过前面的讲解，再理解这个模型就非常简单了。下面让我们来看一下推流的场景吧：

首先，用 VLC 播放器推送一路 RTP 流到 Medooze。

然后，Medooze 将此流分发给 Browser 2、Browser 3、Browser 4。

没错，Medooze 推流的场景就是这么简单，只需要通过 FFMPEG/VLC 向 Medooze 的某个房间推一路 RTP 流，这样加入到该房间的其他用户就可以看到推送的这路音视频流了。

学习了上面的知识后，你是不是对 Medooze 的基本功能以及运行流程已经了然于胸了呢？现在是不是迫切地想知道 Medooze 内部是如何实现这些功能的？那么下面我们就来分析一下 Medooze 的架构。

Medooze 架构分析

由于 Medooze 是一个复杂的流媒体系统，所以在它的开源目录下面有很多子项目来支撑这个系统。其中一个核心项目是 media-server，它是 C++ 语言实现的，主要是以 API 的形式提供诸多流媒体功能，比如 RTMP 流、RTP 流、录制、回放、WebRTC 的支持等。另外一个项目是 media-server-node，此项目是 Node.js 实现的，将 media-server 提供的 C++ API 包装成 JavaScript 接口，以利于通过 Node.js 进行服务器业务控制逻辑的快速开发。

接下来，我们就以 media-server-node 作为切入点，对此服务展开分析。

1. 源码目录结构

media-server-node 是基于 Node.js 的一个项目，在实际开发过程中，只需要执行下面的命令就可以将 media-server-node 编译安装到你的系统上。

npm install media-server-node --save

这里我们对 media-server-node 源码目录结构做一个简要的说明，一方面是给你一个大概的介绍，另一方面也做一个铺垫，便于我们后续的分析。

通过对上面源码目录结构的分析，你可以看出它的结构还是蛮清晰的，当你要分析每一块的具体内容时，直接到相应的目录下查看具体实现就好了。

目录结构介绍完后，我们再来看看 Medooze 暴露的几个简单接口吧！

2. 对外接口介绍

media-server-node 采用了 JavaScript ES6 的语法，各个模块都是导出的，所以每一个模块都可以供应用层调用。这里主要讲解一下 MediaServer 模块暴露的接口：

如果你对 JavaScript 非常精通的话，你可能会觉得这几个接口实在太简单了，但 Medooze 设计这些接口的理念就是尽量简单，这也符合我们定义接口的原则。对于这些接口的使用我们会在后面的文章中做讲解，现在你只需要知道这几个接口的作用是什么就好了。

3. Medooze 结构

讲到 Medooze 结构，也许稍微有点儿抽象、枯燥，不是那么有意思，不过只要你多一点点耐心按照我的思路走，一定可以将其理解的。

在正式讲解 Medooze 结构之前，我们还需要介绍一个知识点：在 Node.js 中，JavaScript 与 C++ 是如何相互调用的呢？知道其原理对你理解 Medooze 的运行机制有非常大的帮助，所以这里我做一些简单的介绍。JavaScript 与 C++ 调用关系图如下：

JavaScript 与 C++ 调用关系图

我们都知道 Node.js 解析 JavaScript 脚本使用的是 Google V8 引擎，实际上 V8 引擎就是连接 JavaScript 与 C/C++ 的一个纽带，你也可以把它理解为一个代理。

在 Node.js 中，我们可以根据自己的需要，按 V8 引擎的规范为它增加 C/C++ 模块，这样 Node.js 就可以将我们写好的 C++ 模块注册到 V8 引擎中了。注册成功之后，V8 引擎就知道我们这个模块都提供了哪些服务（API）。

此时，JavaScript 就可以通过 V8 引擎调用 C/C++ 编写的接口了。接口执行完后，再将执行的结果返回给 JavaScript。以上就是 JavaScript 与 C/C++ 相互调用的基本原理。

了解了上面的原理后，咱们言归正传，还是回到我们上面讲的 Medooze 整体结构这块知识上来。从大的方面讲， Medooze 结构可以分为三大部分，分别是:

media server node，主要是 ICE 协议层的实现，以及对 Media server Native API 的封装。

SWIG node addon，主要是实现了 Node.js JavaScript 和 C++ Native 之间的融合，即可以让 JavaScript 直接调用 C/C++ 实现的接口。

media server，是 Medooze 流媒体服务器代码的实现，比如 WebRTC 协议栈、网络传输、编解码的实现等，都是在该模块实现的。

让我们看一下 Medooze 整体结构中三个核心组件的作用，以及它们之间的相互依赖关系。如下图所示：

组件关系图

从这张图我们可以知道，Medooze 的整体结构也是蛮复杂的，但若仔细观察，其实也不是很难理解。下面我们就来详细描述一下这张图吧！

图中标有 WebRTC 的组件实现了 WebRTC 协议栈，它是用于实现多方音视频会议的。

Recorder 相关的组件是实现录制的，这里录制格式是 MP4 格式。

Player 相关组件是实现录制回放的，可以将 MP4 文件推送到各个浏览器，当然是通过 WebRTC 协议了。

Streamer 相关组件是接收 RTP 流的，你可以通过 VLC 客户端推送 RTP 流给 Medooze，Medooze 然后再推送给各个浏览器。

接下来，针对这些组件我们再做一个简要说明，不过这里我们就只介绍 Media server node 和 Media server 两大组件。由于 SWIG node addon 是一个 C++ Wrapper，主要用于 C/C++ 与 JavaScript 脚本接口之间的转换，所以这里就不对它进行说明了。

需要注意的是，上图中的每一个组件基本是一个 Node.js 模块或者是一个 C++ 类，也有极个别组件是代表了几个类或者模块，比如 SDP 其实是代表整个 SDP 协议的实现模块。

Media server node 组件中每个模块的说明如下：

了解了 Media server node 组件中的模块后，接下来我们再来看看 Media server 组件中每个模块的作用，说明如下：

什么是 SWIG

SWIG（Simplified Wrapper and Interface Generator ），是一个将 C/C++ 接口转换成其他脚本语言（如 JavaScript、Ruby、Perl、Tcl 和 Python）的接口转换器，可以将你想暴露的 C/C++ API 描述在一个后缀是 *.i 的文件里面，然后通过 SWIG 编译器就可以生成对应脚本语言的 Wrapper 程序了，这样对应的脚本语言（如 JavaScript）就可以直接调用 Wrapper 中的 API 了。

生成的代码可以在脚本语言的解释器平台上执行，从而实现了脚本语言调用 C/C++ 程序 API 的目的。SWIG 只是代码生成工具，它不需要依赖任何平台和库。

至于更详细的信息，你可以到 SWIG 的官网查看，地址在这里：http://www.swig.org/ 。

小结

本文讲解了 Medooze 的 SFU 模型、录制回放模型、推流模型等内容，并且介绍了 Medooze 的整体结构和核心组件的功能等。通过以上内容的讲解，你应该对 WebRTC 服务器有一个初步认知了。

除此之外，文章中也提到了 WebRTC 流媒体服务器的传输需要基于 DTLS-SRTP 协议以及 ICE 等相关识。这些知识在后面的文章中我们还会做更详细的介绍。

思考时间

通过上面的描述，你可以看到 Medooze 是作为一个模块加入到 Node.js 中来提供流媒体服务器能力的，那么这种方式会不会因为 Node.js 的性能而影响到 Medooze 流媒体服务的性能呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

28 | 让我们一起探索Medooze的具体实现吧（下）

在上一篇文章中，我向你介绍了 Medooze 的 SFU 模型、录制回放模型以及推流模型，并且还向你展示了 Medooze 的架构，以及 Medooze 核心组件的基本功能等相关方面的知识。通过这些内容，你现在应该已经对 Medooze 有了一个初步了解了。

不过，那些知识我们是从静态的角度讲解的 Medooze ，而本文我们再从动态的角度来分析一下它。讲解的主要内容包括：WebRTC 终端是如何与 Meooze 建立连接的、数据流是如何流转的，以及 Medooze 是如何进行异步 I/O 事件处理的。

异步 I/O 事件模型

异步 I/O 事件是什么概念呢？你可以把它理解为一个引擎或动力源，这里你可以类比汽车系统来更好地理解这个概念。

汽车的动力源是发动机，发动机供油后会产生动力，然后通过传动系统带动行车系统。同时，发动机会驱动发电机产生电能给蓄电池充电。汽车启动的时候，蓄电池会驱动起动机进行点火。

这里的汽车系统就是一种事件驱动模型，发动机是事件源，驱动整车运行。实际上，Medooze 的异步 I/O 事件模型与汽车系统的模型是很类似的。那接下来，我们就看一下 Medooze 中的异步 I/O 事件驱动机制：

Medooze 事件驱动机制图

上面这张图就是 Modooze 异步 I/O 事件处理机制的整体模型图，通过这张图你可以发现 poll 就是整个系统的核心。如果说 EventLoop 是 Medooze 的发动机，那么 EventLoop 中的 poll 就是汽缸，是动力的发源地。

下面我就向你介绍一下 poll 的基本工作原理。在 Linux 系统中，无论是对磁盘的操作还是对网络的操作，它们都统一使用文件描述符 fd 来表示，一个 fd 既可以是一个文件句柄，也可以是一个网络 socket 的句柄。换句话说，我们只要拿到这个句柄就可以对文件或 socket 进行操作了，比如往 fd 中写数据或者从 fd 中读数据。

对于 poll 来说，它可以对一组 fd 进行监听，监听这些 fd 是否有事件发生，实际上就是监听 fd 是否可写数据和读数据。当然，具体是监听读事件还是写事件或其他什么事件，是需要你告诉 poll 的，这样 poll 一旦收到对应的事件时，就会通知你该事件已经来了，你可以做你想要做的事儿了。

那具体该怎么做呢？我们将要监听的 fd 设置好监听的事件后，交给 poll，同时还可以给它设置一个超时时间，poll 就开始工作了。当有事件发生的时候，poll 的调用线程就会被唤醒，poll 重新得到执行，并返回执行结果。此时，我们可以根据 poll 的返回值，做出相应的处理了。

需要说明的是，poll API 本身是一个同步系统调用，当没有要监听的事件（I/O 事件 和 timer 事件）发生的时候，poll 的调用线程就会被系统阻塞住，并让它处于休眠状态，而它处理的 fd 是异步调用，这两者一定不要弄混了。

poll 的功能类似 select，其 API 原型如下：

include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

其各个参数及返回值含义如下表所示：

其中第一个fds ，它表示的是监听的 pollfd 类型的数组，pollfd 类型结构如下：

struct pollfd {

  int   fd;         / file descriptor /

  short events;     / requested events /

  short revents;    / returned events /

};

fd 是要监听的文件描述符。

events 是一个输入参数，表明需要监听的事件集合。

revents 是输出参数，表明函数返回后，在此 fd 上发生的事件的集合。

下表是常见事件的说明：

了解了 poll API 各参数和相关事件的含义之后，接下来我们看一下当 poll 睡眠时，在哪些情况下是可以被唤醒的。poll 被唤醒的两个条件：

某个文件描述符有事件发生时；

超时事件发生。

以上就是异步 I/O 事件处理的原理以及 poll API 的讲解与使用。接下来我们再来看看 EventLoop 模块中其他几个类的功能及其说明：

pipe，好比是火花塞，用来唤醒 poll 的。比如 poll 线程被阻塞，我们又想让它随时可以唤起，就可以通过让 poll 监听一个 pipe 来实现。

Listener， 数据包处理接口。只要实现了 Listener 接口，当 fd 接收到数据包以后，就可以调用此接口来处理。

SendBuffer，Queue 数据包发送队列。当有数据包需要发送给客户端的时候，首先会发送到 EventLoop 的发送队列，然后 EventLoop 就会根据 fd 的 POLLOUT 事件进行分发。

Task Queue， 异步任务队列。当有逻辑需要 EventLoop 线程执行的的时候，通过调用 Async 函数将异步任务放入异步任务队列中，然后通过 Signal 唤醒 poll 去处理此异步任务。

TimerImpl Queue， 定时器队列。需要定时器功能的时候，可以调用 CreateTimer/CancelTimer 去创建 / 取消一个定时器。

RTPBundleTransport，实现了 Listener 接口，所有接收到的 UDP 数据包都由此类处理。

ICE 连接建立过程

了解了 Medooze 的异步 I/O 事件处理机制后，接下来我们看一下 ICE 连接的建立。实际上，传输层各个组件的基本功能，我们在上篇文章中都已一一介绍过了，不过那都是静态的，本文我们从动态的角度，进一步了解一下 DTLS 连接的建立过程。

DTLS 连接建立过程图

当某个参与人（Participator ）加入房间后，需要建立一个 DTLS 连接使客户端与 Medooze 可以进行通信，上图描述了各个对象实例之间的先后调用关系。其具体连接建立过程说明如下（步骤较多，建议对照上图来理解和学习）：

当有参与人（Participator）加入时，如果此时房间内只有一个人，则该 Participator 对象会调用 MediaServer::createEndpoint 接口，来创建一个接入点。

MediaServer 对象会创建一个 Endpoint 实例。

Endpoint 对象会创建一个 Native RTPBundleTransport 实例。

Native RTPBundleTransport 实例创建好后，Endpoint 再调用 RTPBundleTransport 实例的 init() 方法对 RTPBundleTransport 实例进行初始化。

在 RTPBundleTransport 初始化过程中，会创建 UDP socket，动态绑定一个端口。这个动态端口的范围，用户是可以指定的。

RTPBundleTransport 中包含了一个 EventLoop 实例，所以会创建事件循环实例，初始化 pipe。

EvenLoop 会创建一个事件循环线程。

事件循环线程中会执行 poll，进入事件循环逻辑。此时，Endpoint 的初始过程算是完成了。

Participator 需要创建一个 DTLS 连接，DTLS 连接是由 Endpoint::createTransport 函数来创建的。

Endpoint 创建一个 Transport 实例，此实例是 Native 的一个 wrapper。

Transport 调用 RTPBundleTransport::AddICETransport 接口，准备创建 Native DTLS 实例。

RTPBundleTransport 创建一个 DTLSICETransport 实例。

DTLSICETransport 聚合了 DTLSConnection 实例，对其进行初始化。主要工作就是证书的生成、证书的应用、ssl 连接实例的创建，以及 DTLS 的握手过程。

DTLSICETransport 的初始化工作，包括 SRTPSession 的初始化和 DataChannel 的初始化。

经历这么一个过程，ICE 连接就建立好了，剩下就是媒体流的转发了。

SFU 数据包收发过程

当终端和 Medooze 建立好 DTLSICETransport 连接以后，对于需要共享媒体的终端来说，Medooze 会在服务端为它建立一个与之对应的 IncomingStream 实例，这样终端发送来的音视频流就进入到 IncomingStream 中。

如果其他终端想观看共享终端的媒体流时，Medooze 会为观看终端创建一个 OutgoingStream 实例，并且将此实例与共享者的 IncomingStream 实例绑定到一起，这样从 IncomingStream 中收到的音视频流就被源源不断输送给了 OutgoingStream。然后 OutgoingStream 又会将音视频流通过 DTLSICETransport 对象发送给观看终端，这样就实现了媒体流的转发。

RTP 数据包在 Medooze 中的详细流转过程大致如下图所示：

SFU 数据转发图

图中带有 in 标签的绿色线条表示共享者数据包流入的方向，带有 out 标签的深红色线条表示经过 Medooze 分发以后转发给观看者的数据流出方向，带有 protect_rtp/unprotect_rtp 标签的红色线条表示 RTP 数据包的加密、解码过程，带有 dtls 标签的紫色线条表示 DTLS 协议的协商过程以及 DTLS 消息的相关处理逻辑。

上面的内容有以下四点需要你注意一下：

如果采用 DTLS 协议进行网络传输，那么在连接建立的过程中会有一个 DTLS 握手的过程，这也是需要数据包收发处理的。所以，我们上图中的数据包流转不仅是音视频数据，还包括了 DTLS 握手数据包。

DTLSConnection 是专门处理 DTLS 协议握手过程的，由于协议复杂，这里就不做详细介绍了。

SRTPSession 是对音视频数据进行加密、解密的，这是 API 的调用，所以图里并没有流入、流出的过程说明。

RTPLostPackets 主要是进行网络丢包统计。

接下来，我们就对数据包的流转过程做一个简要的说明（步骤较多，建议对照上图来理解和学习）：

共享终端的音视频数据包到达 Medooze 服务器以后，是被 EventLoop 模块接收，接收到数据包不做处理，直接传给 RTPBundleTransport 模块。需要说明的是，数据包的收发最终都是落入到了 EventLoop 模块，因为数据包的收发都需要处理网络事件。

前面讲过，RTPBundleTransport 模块就像一个容器，管理着所有参会终端的网络连接实例。当数据包到达 RTPBundleTransport 模块以后，它会根据数据包的来源，将数据包分发给相应的 ICERemoteCandidate 实例。ICERemoteCandidate 是针对每一个通信终端建立的一个逻辑连接。同样，收发也都需要经过 RTPBundleTransport 模块，因为此模块绑定了 UDP socket。

ICERemoteCandidate 会对数据包的类型进行检查，如果是 DTLS 数据包，就会转给 DTLSConnection 模块，处理 DTLS 协商过程；如果是音视频数据，那么会转给 DTLSICETransport 模块。

在 DTLSICETransport 处理模块中，它首先会调用 SRTPSession 的 unprotect_rtp 函数进行解密；然后，调用 RTPLostPackets 的 API 处理丢包逻辑；最后，将数据包传给 RTPIncomingSourceGroup 模块。

RTPIncomingSourceGroup 会遍历所有订阅此流的观看终端，然后将数据流转发给相应的 RTPOutgoingSourceGroup 实例。

RTPOutgoingSourceGroup 模块对于下行流的处理，是通过 RTPStreamTransponder 模块进行 RTCP 相关逻辑处理，然后传给 DTLSICETransport 模块。

DTLSICETransport 模块会调用 SRTPSession 模块的 protect_rtp 函数，对下行数据包进行加密，然后传给 RTPBundleTransport 模块。

RTPBundleTransport 模块没有任何逻辑处理，直接将数据包传给了 EventLoop 模块。EventLoop 是提供了一个下行发送队列，其实是将数据保存到发送队列中。

EventLoop 从发送队列中获取待发送的数据包并发送给观看端。

小结

本文我们重点介绍了事件驱动机制，它像人的心脏一样，在现代服务器中起着至关重要的作用。 Medooze 服务器中的具体实现模块就是 EventLoop 模块。通过本文的学习，我相信你对事件驱动机制有了一定的了解。接下来，就需要结合代码做进一步的学习，最好能进行相应的项目实践，这样对你继续研读 Medooze 源码会有很大的帮助。

后半部分，我们还分别介绍了 DTLS 连接建立过程和数据包的流转过程。通过本文以及上一篇文章的学习，我相信你对 Medooze 的基础架构以及 SFU 的实现细节已经有了深刻的理解。

思考时间

今天你的思考题是：异步 I/O 事件中，什么进候会触发写事件？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

29 | 如何使用Medooze 实现多方视频会议？

前面我们通过两篇文章详细介绍了 Medooze 的实现逻辑，我相信你现在已经对 Medooze 有了非常深刻的认知。通过对 Medooze 实现原理和架构的了解，我们可以知道 Medooze 支持的功能非常多，如录制回放、推流、SFU 等，其中最主要的功能是 SFU 的实现。

实际上，我们要实现的多方音视频会议就是由 SFU 来实现的，或者你可以认为 Medooz 实现的 SFU 就是一个最简单的音视频会议系统。Medooze 提供的 SFU Demo 的地址为：https://github.com/medooze/sfu 。

由于 SFU 是一个 Node.js 项目，所以在环境部署、源码分析的时候，需要你有 JavaScript 基础，也需要掌握 NPM 和 Node 的常用命令，比如 npm install 等。接下来，我们主要从以下几方面向你介绍一下多方音视频会议（SFU）项目：

搭建多方音视频会议系统，让你感受一下多方通信；

分析多方音视频会议实现的架构；

分析 Medooze API 的具体使用；

分析多方音视频会议的入会流程。

多方音视频会议环境的搭建

多方音视频会议（SFU）项目是纯 Node.js 实现的，可以说在目前所有的操作系统上都可以运行。然而，由于该系统依赖 medooze-media-server 项目，而该项目仅支持 macOS X 和 Linux 两种操作系统，所以 SFU 项目也只能在 macOS X 和 Linux 这两种操作系统下运行了。

本文我们所搭建的这个 Demo 的实验环境是在 Ubuntu 18.04 系统上，所以接下来的实验步骤和实验结论都是基于 Ubuntu 18.04 得出的，如果你在其他操作系统下搭建可能结果会略有不同，但具体步骤都是一模一样的。

下面我们就来分析下这个环境搭建的具体步骤。

第一步，打开 Linux 控制终端，下载 SFU 代码，命令如下：

git clone https://github.com/medooze/sfu.git

第二步，安装 SFU 依赖的所有包：

cd sfu

npm install

第三步，在 sfu 目录下生成自签名证书文件：

openssl req -sha256 -days 3650 -newkey rsa:1024 -nodes -new -x509 -keyout server.key -out server.cert

第四步，在 sfu 目录下启动服务：

node index.js IP

上面命令中的 IP 是测试机器的 IP 地址，如果用内网测试，你可以将它设置为 127.0.0.1。如果你想用外网访问，则可以指定你的云主机的外网 IP 地址。另外需要注意的是，该服务启动后默认监听的端口是 8084，所以你在访问该服务时要指定端口为 8084。

通过上面的步骤，我们就将 Medooze 的 SFU 服务器搭建好了，下面我们来测试一下。

打开浏览器，在地址栏中输入以下地址就可以打开 Medooze SFU Demo 的测试界面了：

https://IP:8084/index.html

在这个地址中需要特别注意的是，必须使用 HTTPS 协议，否则浏览器无法访问本地音视频设备。此外，这个地址中的 IP 指的是 SFU 服务的主机的 IP 地址，可以是内网地址，也可以是外网地址。

当该地址执行后，会弹出登录界面，如下所示：

Medooze SFU 登录界面图

这是一个多人视频会议登录界面。关于登录界面，这里我们做一个简单的说明。

界面中的 Room Id 和 Name 可以随机生成，也可以自己指定。如果需要随机生成，点击后面的 RANDOM 按钮即可。

几个参会人若想要进入同一个房间，那么他们的 Room Id 必须相同。

在 Audio Device 列出了所有麦克风，可供参会人选择。

在 Audio Device 下面，有一个音量进度条，它可以测试麦克风是否正常工作。

一切准备就绪后，点击右下角的“READY!”按钮，就可以加入房间了。此时，在浏览器左下角显示了自己的本地视频。

由于这是基于浏览器的音视频应用，具有很好的跨平台性，因此你可选择 PC 浏览器、手机浏览器一起测试。

上面我们就将 Medooze 的音视频会议系统的 Demo 环境搭建好了，并且你可以进行多人的音视频互动了。接下来我们就分析一下这个音视频会议系统（SFU） Demo 是如何实现的吧。

SFU 目录结构

首先我们来分析看一下 Medooze 的 SFU Demo 的目录结构，以及每个子目录中代码的作用，为我们后面的分析做好准备。

如上图所示，在 SFU Demo 目录下的文件非常少，我们来看一下每个文件或目录的具体作用吧！

了解了 SFU Demo 的目录结构及其作用后，我们再来看一下 SFU Demo 的架构是什么样子。

SFU 架构

下图就是 Medooze SFU Demo 的架构图，你可以参考下：

SFU 架构图

从图中你可以看出，SFU 架构包括了浏览器客户端和SFU 服务端两部分。而 SFU 服务端又包括两部分功能：WWW 服务、WebSocket 信令及业务管理。

接下来我们就来分析一下服务端的处理逻辑。

Node.js 中的 Web 服务器（WWW）是在 index.js 文件中实现的。当我们执行node index.js IP命令时，该命令启动了一个 HTTPS 服务，监听的端口是 8084。当 HTTPS 启动后，我们就可以从它的 WWW 服务获取到客户端代码了。需要说明的是，HTTPS 服务依赖 server.key 和 server.crt 文件，这两个文件需要我们提前生成好（生成证书的命令在上面 SFU 环境搭建一节已经向你做了说明）。

至于 WebSocket 服务及业务管理部分，逻辑会稍微复杂些。服务端启动 WebSocket 服务后，可以通过它来接收客户端发来的信令，同时也可以通过它向客户端发送通知消息；除了对信令的处理外，SFU 的业务逻辑也是在这部分实现的，如 Room 和 Participator 实例的创建、参会人进入 / 离开房间等。

这里需要你注意的是，WebSocket 服务和业务管理的代码也是在 index.js 中实现的。换句话说，在服务端的 index.js 中，实现了 WWW 服务、WebSocket 服务和 SFU 业务管理三块逻辑，这样“大杂烩”的代码实现也只是在 Demo 中才能这么写了。另外，index.js 的实现依赖了 websocket 和 transaction-manager 两个包。

分析完服务端的处理逻辑后，接下来我们再来看看客户端。浏览器首先从 Medooze SFU 的 WWW 服务上获得 sfu.js 代码，在浏览器上运行 sfu.js 就可以与 Medooze SFU 建立连接，并进行多方音视频通信了。在 sfu.js 中，主要实现了房间的登录、音频设备选择、采集音视频数据、共享音视频数据等功能。实际上，客户端的实现逻辑就是使用咱们专栏第一模块所介绍的知识，如 RTCPeerConnection 的建立、获取音视频流、媒体协商等。

此外，Medooze SFU 还实现了几个信令，如 join 用于加入会议、update 用于通知客户端重新进行媒体协商等等，这几个信令还是非常简单的，不过却非常重要。尤其是在阅读 Medooze SFU 代码时，可以按照信令的流转来梳理 SFU 的代码逻辑，这样会大大提高你的工作效率。

以上就是 Medooze SFU 的基本架构，接下来我们看一下 SFU 的逻辑处理。

SFU 逻辑处理

SFU 的实现很简单。在服务端有两个重要的类，即Room 和 Participator。它们分别抽象了多人会议中的房间和参会人。为便于你理解，我画了一个简单类图，如下所示：

SFU 类图

从图中可以看到，index.js 中定义了一个全局对象 Rooms，保存多个房间（Room）实例。

Room 类是对房间的抽象：

Endpoint 属性是 Medooze Endpoint 类型；

Participators 属性是一个 Map 类型，保存本房间中所有 Participator 实例；

该类还有两个重要方法，createParticipator 用于创建参会人实例，getStreams 获取所有参会人共享的媒体流。

Participator 类是对参会人的抽象：

IncomingStreams 属性是 Map 类型，保存自己共享的媒体流，元素属性类型是 Medooze::IncomingStream；

OutgoingStreams 属性是 Map 类型，保存自己观看的媒体流，元素属性类型是 Medooze::OutgoingStream；

transport 属性是 Medooze::Transport 类型，表示 DTLS 传输；

init() 方法，初始化 transport 实例等；

publishStream() 方法用于发布自己共享的流，创建 IncomingStream；

addStream() 方法用于观看其他参会人共享的流，创建 OutgoingStream 实例，并且 attachTo 到共享人的 IncomingStream 中。

入会流程

这里我们可以结合客户端和服务器的逻辑，分析一下参会人入会的大致流程，如下图所示：

SFU 时序图

图中深粉部分是客户端实现，在 www/js/sfu.js 中；绿色部分是服务器实现，在 index.js 中；橘色是在 medooze 中实现。那具体的流程是怎样的呢？

首先，浏览器从 Node.js 服务器获取到 www/index.html 页面及引用的 JavaScript 脚本，然后执行 connect 方法。在 connect 方法中使用 WebSocket 与 SFU 服务（index.js）建立连接。

服务端收到 WebSocket 连接请求后，从 URL 中解析到 roomid 参数。然后，根据该参数判断房间是否在之前已经创建过。如果该房间已创建，则什么都不做；如果没创建，则尝试创建 Room 实例。

对于客户端来说，WebSocket 连接建立成功后，客户端会发送 join 信令到服务端。而服务端收到 join 信令后，创建 Participator（参与人）实例，并且调用它的 init 方法进行初始化。

之后，Participator 获取 Room 中所有已共享的媒体流，并调用它的 addStream 方法，这样它就可以订阅 Room 中所有的媒体流了。订阅成功后，媒体流的数据就被源源不断地发送给 Participator 对应的终端了。

上面的逻辑完成之后，服务端会通知客户端 join 已经成功。此时，客户端也可以共享它的音视频流给服务器了。

此外，在客户端将自己的媒体流推送给服务端之前，服务器会调用 publishStream 方法在服务端创建 IncomingStream 实例，这样当客户端的数据到达服务器端后，就可以将数据交给 IncomingStream 暂存起来，以备后面分发给其他终端。

通过上面的步骤，新加入的客户端就可以看到会议中其他人分享的音视频流了，同时还可以将自己的音视频推送到服务器了。但此时，其他参会人还看不到新加入用户的音视频流，因为还有关键的一步没有完成。

这关键的一步是，当新参会人一切准备就绪后，服务端要发送广播消息（update） 通知所有已经在会中的其他人，有新用户进来了，需要重新进行媒体协商。只有重新媒体协商之后，其他参会人才能看到新入会人的音视频流。

以上步骤就是一个新参会者加入会议的大致过程，同时也介绍了这个过程中其他已经在会中的参与人需要如何处理。

小结

本文我们首先介绍了通过 Medooze SFU 如何搭建最简单的音视频会议系统，该系统搭建好后就可以进行多人音视频互动的“实验”了。

然后，我们对 Medooze 的 SFU 项目的逻辑做了详细介绍，分析了 Medooze SFU 的基本架构、逻辑处理、入会流程等。相信通过本文的学习，你对 Medooze 的使用和实现原理都会有更深刻的认识。

当然，如果你要想在自己的产品中更好地应用 Medooze，还需要掌握更多的细节，只有这样才能做到心中有数。比如：如何能够承担百万用户级的负载？如何为不同的的地区、不同的运营商的用户提供优质的服务？这些都是你需要进一步研究和学习的。

思考时间

今天留给你的思考题：一个多人存在的会议中，又有一个人加入进来，其他参会人是如何看到后加入人的视频流的呢？你清楚其中的原理吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

30 | 实战演练：通过WebRTC实现多人音视频实时互动直播系统

关于通过 WebRTC 实现多人音视频实时互动的实战，其实我们在上一篇文章中已经向你做过详细介绍了，其中包括如何编译 Medooze 源码、如何将编译出的 Medooze SFU 进行布署，以及如何去使用等相关的内容。

那么今天我们再从另外一个角度来总结一下 Medooze 是如何实现多人音视频互动的。

下面我们就从以下三个方面向你做一下介绍：

首先是多人音视频会议整体结构的讲解，这会让你从整体上了解利用 Medooze 搭建的流媒体服务器与 WebRTC 客户端是如何结合到一起运转的；

其次再对 WebRTC 客户端进行说明，你将知道无论是 Medooze 还是使用其他的流媒体服务器，对于客户端来讲它的处理流程基本是不变的；

最后是 Medooze 服务器的总结，让你了解 Medooze 各模块是如何协调工作的，实际上这部分知识我们在之前的文章中已经做过介绍了，但为了让你对它有更深刻的认识，这里我们还会从另外一个角度再重新剖析。

Medooze 整体架构

文中接下来这张图清晰地展示了 Medooze 是如何实现多方通信的，你可以先参考下：

Medooze 多方通信整体结构图

从这张图你可以看到，它主要分成两大部分：服务端和客户端。下面我们就从这两个方面向你详细描述一下 Medooze 实现多方通信的过程。

首先，我们来看一下服务端都提供了哪些功能。实际上，服务端主要提供了三方面的功能：

提供了 HTTP/HTTPS 的 WWW 服务。也就是说，我们将浏览器执行的客户端代码（JavaScript）放在 Node.js 的发布目录下，当用户想通过浏览器进行多方通信时，首先会向 Node.js 发起请求，将客户端 JavaScript 代码下载下来。然后，再与 Node.js 中的 WebSocket 服务连接。

提供了 WebSocket 服务，主要用于信令通信。在我们这个系统中涉及到的信令并不多，有 join、update 以及 WebRTC 需要交换的 Offer/ Answer、Candidate 等几个简单的信令。对于这几个信令的具体作用，我们会在本文的后半部分（WebRTC 客户端）向你做详细介绍。

提供了音视频流数据转发的能力。这块逻辑是使用 C++ 语言实现的，包括了 WebRTC 协议栈的实现、数据流的转发、最大带宽评估、防拥塞控制等功能。

接下来，我们就从下载客户端代码开始，详述一下 WebRTC 客户端与 Medooze 交互的整个过程。

当进行多方通信时，用户首先要向 Node.js 发起 HTTP/HTTPS 请求，然后从 Node.js 服务器上获取浏览器可以运行的 JavaScript 代码。浏览器获取到客户端代码后交由浏览器的 V8 引擎进行解析，然后调用 WebRTC 库相关的 API，从本地摄像头和麦克风采集音视频数据，以便后面将它们分享到 Medooze 服务器上，这就是我们上图中用红圈标出的第 1 步。

当然，在正式分享音视频数据之前，客户端的 JavaScript 还要与 Node.js 之间建立 WebSocket 连接，也就是上图中用红圈标出的第 2 步。在 Medooze 多方通信实现过程中，WebSocket Server 是由 Medooze 的 JavaScript 脚本来实现的，当浏览器中的客户端与 Node.js 中的 WebSocket 服务建立好连接后，客户端就会发送 join 信令到服务端。

服务端收到 join 信令后，会在它的管理模块中查找 join 要加入的房间是否存在，如果该房间已经存在，则将该用户加入到房间内；如果不存在，则在它的管理系统中创建一个新的房间，而后再将该用户加入到房间里。成功加入房间后，服务端会调用 media-server-node 中的 createEndpoint 接口，最终在 C++ 层打开一个 UDP 端口为传输音视频数据做准备。

当前面这些准备工作就绪后，客户端与服务端开始通过 WebSokcet 通道交换 SDP 信息，包括非常多的内容，比如双方使用的编解码器、WebRTC 协议栈、Candidate 等，除此之外，还包括 DTLS-ICE 相关的信息，通过这些信息客户端与服务器之间就可以建立起数据连接了。

接下来，我们再简要描述一下 WebRTC 客户端与服务端之间是如何建立起 DTLS-ICE 连接的。

在双方交换了各自的 SDP 信息后，客户端就拿到了连接服务器的用户名和密码，即 ice-ufrag 和 ice-passwd 两个字段。其中， ice-ufrag 是用户名，ice-passwd 是连接的密码。当客户端与服务端建立连接时，它要通过 STUN 协议向 Medooze server 发送 Request 消息，并带上 ice-ufrag 和 ice-passwd 这两个字段。 Medooze 服务端收到 STUN Request 消息后，将连接的用户名和密码与自己保存的用户名和密码做比较，如果一致，则说明该连接是有效的，这样客户端与服务端连接就建立起来了，并且后面所有从该客户端发到服务端的 UDP 数据包一律放行。

连接建立好后，为了保证数据安全，需要在客户端与服务器之间建立安全机制。这个安全机制就是我们在专栏第一模块分析“如何保障数据传输的安全”时所介绍的证书和公钥的交换。这一步执行完成之后，通信的双方就可以彼此传输音视频数据了。以上就是上图中红圈标出的第 3 步。

服务器在收到客户端发来的音视频数据后，并不能直接使用，还需要使用 SRTP 协议将加密后的数据进行解密，然后才能做后面的逻辑处理。

在音视频数据传输的过程中，通信双方一直对传输的网络进行着监控。因为网络是变化的，可能刚刚网络质量还特别好，过一会儿就变得很差了。所以，无论是 Medooze 还是其他的流媒体服务器，对于网络质量的监控都是一项必备功能。监控的方法也比较简单，就是通过 RTCP 协议每隔一段时间就上报一次数据。上报的内容包括收了多少包、丢了多少包、延迟是多少等这些基本信息。有了这些基本信息后，在服务端就可以评估出目前网络的带宽是多少了。

以上的描述是其中一个参会人加入到房间里的情况，而对于多人来说，每个加入到会议里的人，都要做上面相同的逻辑。

以上就是 Medooze 多方通信整体结构的描述，接下来我们再分别从客户端和服务端的角度详述它们都做了哪些事儿吧。

WebRTC 客户端

对 WebRTC 客户端来讲，无论是 1 对 1 通信还是多方通信，区别并不大。主要的逻辑，像采集音视频数据、创建 RTCPeerConnection、媒体协商等，都是一样的。只有在展示的时候稍有差别，在 1 对 1 通信中，只需要显示两个视频——本地视频和远端视频，而多方通信时则要展示多个视频。可以说这是 1 对 1 通信与多方通信在客户端的唯一差别了。

文中接下来这张图是我总结的 WebRTC 客户端在多方通信中的流程图，你可以参考下：

WebRTC 客户端流程图

从图中可以看出，WebRTC 客户端在多方通信中的基本处理逻辑是这样的：

首先创建 RTCPeerConnection 对象，用于与服务端传输音视频数据；

紧接着客户端与服务端建立 WebSocket 连接，建立好之后，双方就可以进行信令通信了；

当用户发送 join 消息给服务器，并成功加入到房间之后，客户端就可以调用 getUserMedia 进行音视频数据的采集了；

数据采集到之后，要与之前创建好的 RTCPeerConnection 进行绑定，然后才能通过 RTCPeerConnection 实例创建 Offer/Answer 消息，并与服务器端进行媒体协商；

媒体协商完成后，客户端就可以将音视频数据源源不断发送给 Medooze 服务器了；

当有其他 WebRTC 客户端进入到房间后，它们的音视频流也会通过 RTCPeerConnection 传送给早已加入到房间里的 WebRTC 终端，此时 WebRTC 终端会收到 onRemoteTrack 消息，然后创建 HTML5 的<video>标签将它们显示出来就好了。

除此之外，在上图中，你应该还注意到一个 update 信令，这个信令在多方通信中是至关重要的。下面我们就来讲解一下 update 信令的作用。

在进行多方通信时，第一个 WebRTC 客户端已经加入到房间里了，接着第二个用户开始加入。当第二个用户加入时，它可以获得第一个用户共享的音视频流，但对于第一个用户来说，它是否能获得第二个用户的音视频流呢？显然获取不到。获取不到的原因是什么呢？因为第一个用户与服务器之间进行媒体协商时，它还不知道有第二个用户，这样当第二个用户进来时，如果不与服务器重新进行媒体协商的话，它是不知道房间里已经有其他人共享了音视频流的。

那如何解决这个问题呢？解决的办法就是每当有新的用户进来之后，就通过 update 信令通知已经在房间内的所有用户，让它们重新与服务器进行媒体协商。重新协商后，所有老用户就可以收到了新用户的视频流了。

以上就是客户端的基本处理逻辑。通过上面的讲解你可以了解到，无论是多人互动还是 1 对 1 通信，对于客户端来说基本逻辑都差不多。

WebRTC 客户端处理流程讲完后，接下来我们再来看看服务端。

服务端

文中接下来这张图是我总结的服务端架构图，你可以参考下：

服务端架构图

如上图所示，从大的方面来讲，服务端由三部分组成，即 WebSocket 服务、SFU 逻辑处理以及媒体服务器。如果再细化的话，媒体服务器又是由 media-server-node 和 media-server 组成。

IncomingStream 和 OutgoingStream 是 media-server 的核心。就像之前的文章向你介绍的，你可以将 IncomingStream 和 OutgoingStream 看成是两个队列，其中 IncomingStream 用于存储接收到的数据，而 OutgoingStream 用于存放要发送的数据。

在一个会议中，每个参会人一般只有一个 IncomingStream 实例用于接收该参会人共享的音视频流。但却可以有多个 OutgoingStream 实例，这每个 OutgoingStream 实例表示你订阅的其他参会人共享的音视频流。如下图所示，Browser2 和 Browser4 都在两路 OutgoingStream 实例。

IncomingStream/OutgoingStream 示意图

上面这张图，我们在《27 | 让我们一起探索 Medooze 的具体实现吧（上）》一文中已经向你做过详细描述了，这里就不再赘述了。如果你记不清了，可以再去重温一下这篇文章。

通过 Medooze Server 架构图，你可以看到它有两个网络接入点：一个是信令接入点，也就是就是WebSocket 服务；另一个是音视频数据接入点，即 Endpoint。下面我们就对这两个接入点做一下介绍。

WebSocket 服务做的事件比较简单，就是用来收发信令。像上面“WebRTC 客户端”部分所介绍的那样，在 Medooze 的 SFU Demo 中，它定义的信令消息特别少，只有 join、update 以及 WebRTC 用于媒体协商的 Offer/Answer 等几个消息。

而每个 Endpoint 实际上就是一个 <IP，port> 对，它使用的是 UDP 协议。对 Medooze 服务端的每个房间，都会为之创建一个独立的 Endpoint，用来接收 / 发送音视频数据。Endpoint 接收的数据要复杂很多，除了音视频数据外，还有 STUN 协议消息、DTLS 协议消息、RTCP 协议消息等。所以如果你想将 Medooze 流媒体服务器理解透彻，就要花大量的时间先将上面这些协议搞清楚、弄明白。说实话，这可不是一朝一夕的事儿。

另外，为了使服务器在处理网络 IO 时更高效，Medooze 使用异步 IO 的方式来管理 EndPoint。也就是使用的 poll API 对 Endpoint(socket) 做处理。关于这一点我们在《28 | 让我们一起探索 Medooze 的具体实现吧（下）》一文中已经做了全面的介绍，如果记不清了，你可以再重新回顾一下那篇文章。

当然 Medooze 使用 poll 来处理异步 IO 事件性能还是低了点，其实更好的方式是使用 epoll API，这个 API 在 Linux 下是最高效的；或者可以使用开源的异步 IO 事件处理库，如 libevent/libuv 等，因为它们底层也是使用的 epoll。

接下来，我们再来看一下服务端通过 Endpoint 从客户端收到数据包后的处理逻辑是怎样的。

像前面我们介绍过的，从 Endpoint 收到的数据包包括 STUN 消息、DTLS 消息、RTCP/SRTCP 消息、音视数据等多种类型。所以 Medooze 后面的处理逻辑非常复杂，涉及到很多细节，这需要你一点一点去理解。但大体的脉落还是比较清晰的，如下图所示：

数据包时序图

上图就是 Medooze 处理各种数据包的时序图。客户端首先发 STUN Request 包，服务端收到该请求包后，对客户端的身份做验证，检查该客户端是否是一个合法用户，之后给客户端回 Response 消息。

如果客户端是合法用户，则发送 DTLS 消息与服务端进行 DTLS “握手”。在“握手”过程中要交换双方的安全证书和公钥等相关信息，也就是时序图中的 Client Hello、Server Hello、Finished 等消息。

证书交换完成后，服务端和客户端都获得了协商好的加密算法等信息，此时就可以传输音视频数据包了。服务端在收到数据包后还要调用 SRTP 库进行解密，也就是将 SRTP 消息解密为 RTP 消息，最终获取到用户的 RTP 音视频消息后，就可以进行后面的操作了。

以上就是 Medooze 流媒体服务器大致的处理逻辑。

小结

本文我们从 Medooze 的整体结构、WebRTC 客户端处理流程以及 Medooze 流媒体服务器三个方面向你详细介绍了 WebRTC 是如何实现多人实时互动直播的。这篇文章是一个总结性的文章，其主要目的是带你梳理一下我们前面所讲的知识点，通过本文你会对 Medooze 实现多方通信有一个全局的理解。

另外，Medooze 实现多方通信涉及到很多细节，需要你花大量的时间去阅读代码才可以将它们弄明白、搞清楚，而我们专栏中的几篇文章就是给你讲清楚 Medooze 在实现多方通信时的主脉络是什么，以便让你更加容易地去理解 Medooze 的代码实现。

在阅读 Medooze 源码时，建议你先按照上一篇文章中的步骤将 Medooze Demo 环境搭建出来，然后再以 Medooze 分析的几篇文章为主线对 Medooze 的源码进行阅读和实验，这样你很快就可以将 Medooze 的细节搞清楚了。

至此，我们专栏的第二模块“WebRTC 多人音视频实时通话”就讲解完了。

思考时间

今天我留给你的思考题是：Medooze 为什么要使用 STUN/DTLS 等协议？可以不用吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

31 | 一对多直播系统RTMP/HLS，你该选哪个？

近年来，随着智能手机的普及以及移动互联网的飞速发展，音视频技术在各个行业得到了广泛的应用。尤其是娱乐直播在前几年着实火了一把，像映客、斗鱼这类以展示才艺为主的直播产品非常受大家的欢迎。

从技术角度来讲，映客、斗鱼这类娱乐直播与在线教育、音视频会议直播有着非常大的区别。在线教育、音视频会议这类直播属于实时互动直播，主要考虑传输的实时性，因此一般使用 UDP 作为底层传输协议；而娱乐直播对实时性要求不高，更多关注的是画面的质量、音视频是否卡顿等问题，所以一般采用 TCP 作为传输协议。我们称前者为实时互动直播，后者为传统直播。

本专栏的前两个模块都是介绍实时互动直播的，而从今天开始我们会讲解传统直播技术。

传统直播技术使用的传输协议是 RTMP 和 HLS。其中，RTMP 是由 Adobe 公司开发的，虽然不是国际标准，但也算是工业标准，在 PC 占有很大的市场；而 HLS 是由苹果公司开发的，主要用在它的 iOS 平台，不过 Android 3 以后的平台也是默认支持 HLS 协议的。

接下来，我们先看一下传统音视频直播系统的基本架构，让你对传统直播架构的“内幕”有一个初步的了解。

传统直播基本架构

商业级直播系统的规模、结构是非常复杂的，除了最核心的音视频直播外，还包括用户管理、认证系统、直播间管理、打赏、红包、私信等很多功能，不过这些更多的是一些业务逻辑，在本文中我们不会对它们进行讲解，而是聚焦在最核心的音视频直播技术上。

我们先来看一下传统直播的基本架构图，如下图所示：

传统直播基本架构图

从图中可以看出，传输直播架构由直播客户端、信令服务器和 CDN 网络这三部分组成。下面我们就来一一分析下这每个模块的功能以及它们彼此之间的联系。

直播客户端主要包括音视频数据的采集、编码、推流、拉流、解码与播放这几个功能。

但实际上，这几个功能并不是放在同一个客户端中实现的。为什么呢？因为作为主播来说，他不需要看到观众的视频或听到观众的声音；而作为观众来讲，他们与主播之间是通过文字进行互动的，因此也不需要分享自己的音视频。

所以用过快手、映客等这类产品的同学都知道，客户端按用途可分为两类，一类是主播使用的客户端，包括音视频数据采集、编码和推流功能；另一类是观众使用的客户端，包括拉流、解码与渲染（播放）功能。

对于主播客户端来说，它可以从 PC 或移动端设备的摄像头、麦克风采集数据，然后对采集到的音视频数据进行编码，最后将编码后的音视频数据按 RTMP 协议推送给 CDN 源节点（RTMP 接入服务器）。

对于观众客户端来说，它首先从直播管理系统中获取到房间的流媒体地址，然后通过 RTMP 协议从边缘节点拉取音视频数据，并对获取到的音视频数据进行解码，最后进行视频的渲染与音频的播放。

通过上面的描述，看上去主播端与观众端的开发工作量差不多。但实际上，观众端的开发工作量要小得多。其原因是，观众端要实现的就是一个播放器功能，而目前开源界有两个比较有名而又成熟的开源项目 Ijkplayer 和 VLC，所以只要将这两个开源项目中的一个集成到你自己的项目中，基本上就完成了观众端的所有开发工作。

信令服务器，主要用于接收信令，并根据信令处理一些和业务相关的逻辑，如创建房间、加入房间、离开房间、送礼物、文字聊天等。

实际上，这部分功能并不是很复杂，但有一点需要你特别注意，那就是聊天消息的处理。我们来举个例子，在一个有 10000 人同时在线的房间里，如果其中一个用户发送了文字消息，那么服务端收到该消息之后就要给 10000 人转发。如果主播说“请能听到我声音的人回复 1”，那这时 10000 人同时发消息，服务端要转发多少条呢？要转发 10000 * 10000 = 1 亿条消息。这对于任何一台服务器来说，都会产生灾难性的后果。所以，在开发直播系统的信令服务器时，一定要关注和防止消息的洪泛。

CDN 网络，主要用于媒体数据的分发。它内部的实现非常复杂，我们姑且先把它当作是一个黑盒子，然后只需要知道传给它的媒体数据可以很快传送给全世界每一个角落。换句话说，你在全世界各地，只要接入了 CDN 网络，你都可以快速看到你想看到的“节目”了。

介绍完直播客户端、信令服务器和 CDN 网络之后，我们再来来看看主播到底是如何将自己的音视频媒体流进行分享的。

主播客户端在分享自己的音视频媒体流之前，首先要向信令服务器发送“创建房间”的信令（红圈标出的步骤 1）；信令服务器收到该信令后，给主播客户端返回一个推流地址（CDN 网络源站地址）；此时，主播客户端就可以通过音视频设备进行音视频数据的采集和编码，生成 RTMP 消息，最终将媒体流推送给 CDN 网络（红圈标出的步骤 2）。

无论主播端使用的是 PC 机还是移动端，其推流步骤都是一样的，所以从上面的架构图中我们也可以看出步骤 3、步骤 4 与步骤 1、步骤 2 是一致的。

当观众端想看某个房间里的节目时，首先也要向信令服务器发消息，不过发送的可不是 “创建房间” 消息了，而是 “加入房间”，也就是步骤 5；服务端收到该信令后，会根据用户所在地区，分配一个与它最接近的“CDN 边缘节点”；观众客户端收到该地址后，就可以从该地址拉取媒体流了，即步骤 6。

需要注意的是，在传统直播系统中，一般推流都使用的 RTMP 协议，而拉流可以选择使用 RTMP 协议或者 HLS 协议。

以上就是传统的直播架构，下面我们再来简要介绍一下 CDN 网络。

CDN 网络的实现

CDN 网络的构造十分复杂（如下图所示），一般情况下，它先在各运营商内构建云服务，然后再将不同运营商的云服务通过光纤连接起来，从而实现跨运营商的全网 CDN 云服务。

CDN 网络

而每个运营商云服务内部包括了多个节点，按功能分为 3 类。

源节点，用于接收用户推送的媒体流。

主干结点，起到媒体数据快速传递的作用，比如与其他运营商传送媒体流。

过缘节点，用于用户来主动接流。一般边缘节点的数量众多，但机子的性能比较低，它会被布署到各地级市，主要解决网络最后一公里的问题。

接下来，我们简要描述一下 CDN 网络的处理流程。

当一个主播想将自己的音视频共享出去的时候，首先通过直播系统的信令服务器获取到可以推送媒体流的 CDN 源节点。CDN 网络从源节点接收到媒体数据后，会主动向各个主干结点传送流媒体数据，这样主干结点就将媒体数据缓存起来了。当然这个缓冲区的大小是有限的，随着时间流逝，缓冲区中的数据也在不断更替中。

当有观众想看某个主播的节目时，会从直播系统的信令服务器获取离自己最近的 CDN 边缘节点，然后到这个边缘节点去拉流。由于他是第一个在该节点拉流的用户，因此该 CDN 边缘节点还没有用户想到的媒体流，怎么办呢？那就向主干结点发送请求。主干结点收到请求后，从自己的缓冲区中取出数据流源源不断地发给边缘节点，这时边缘节点再将媒体数据发给观众。

当第二个观众再次到该 CDN 边缘节点接流时，该节点发现该流已经在自己的缓存里了，就不再向主干结点请求，直接将媒体流下发下去了。因此，观众在使用 CDN 网络时会发现，第一个观众在接流时需要花很长时间才能将流拉下来，可是后来的用户很快就将流拉下来进行播放了。

以上就是 CDN 网络的基本原理，接下来我们再来看看 RTMP 协议与 HLS 协议的比较。

RTMP 介绍

RTMP，全称 Real Time Messaging Protocol ，即实时消息协议。但它实际上并不能做到真正的实时，一般情况最少都会有几秒到几十秒的延迟，底层是基于 TCP 协议的。

RTMP 的传输格式为 RTMP Chunk Format，媒体流数据的传输和 RTMP 控制消息的传输都是基于此格式的。

需要注意的是，在使用 RTMP 协议传输数据之前，RTMP 也像 TCP 协议一样，先进行三次握手才能将连接建立起来。当 RTMP 连接建立起来后，你可以通过 RTMP 协议的控制消息为通信的双方设置传输窗口的大小（缓冲区大小）、传输数据块的大小等。具体细节可以参考文档 rtmp_specification_1.0，或者参考下一篇专门介绍 RTMP 协议细节的文章，本文就不再详述了。

1. 优势

RTMP 协议在苹果公司宣布其产品不支持 RTMP 协议，且推出 HLS 技术来替代 RTMP 协议的“打压”下，已停止更新。但协议停止更新后，这么多年仍然屹立不倒，说明该协议肯定有它独特的优势。那有哪些呢？

RTMP 协议底层依赖于 TCP 协议，不会出现丢包、乱序等问题，因此音视频业务质量有很好的保障。

使用简单，技术成熟。有现成的 RTMP 协议库实现，如 FFmpeg 项目中的 librtmp 库，用户使用起来非常方便。而且 RTMP 协议在直播领域应用多年，技术已经相当成熟。

市场占有率高。在日常的工作或生活中，我们或多或少都会用到 RTMP 协议。如常用的 FLV 文件，实际上就是在 RTMP 消息数据的最前面加了 FLV 文件头。

相较于 HLS 协议，它的实时性要高很多。

2. 劣势

RTMP 有优势，也有劣势。在 RTMP 的众多劣势中，我认为最为关键的有两条。

苹果公司的 iOS 不支持 RTMP 协议，按苹果官方的说法， RTMP 协议在安全方面有重要缺陷。

在苹果的公司的压力下，Adobe 已经停止对 RTMP 协议的更新了。

可以看出 RTMP 协议已经失去了未来，只是由于目前没有更好的协议可以直接代替它，所以它还能“苟延残喘”存活几年。但它最终一定会消亡，这是毋庸置疑的。

HLS 介绍

HLS，全称 HTTP Live Streaming，是苹果公司实现的基于 HTTP 的流媒体传输协议。它可以支持流媒体的直播和点播，主要应用在 iOS 系统和 HTML5 网页播放器中。

HLS 的基本原理非常简单，它是将多媒体文件或直接流进行切片，形成一堆的 ts 文件和 m3u8 索引文件并保存到磁盘。

当播放器获取 HLS 流时，它首先根据时间戳，通过 HTTP 服务，从 m3u8 索引文件获取最新的 ts 视频文件切片地址，然后再通过 HTTP 协议将它们下载并缓存起来。当播放器播放 HLS 流时，播放线程会从缓冲区中读出数据并进行播放。

通过上面的描述我们可以知道，HLS 协议的本质就是通过 HTTP 下载文件，然后将下载的切片缓存起来。由于切片文件都非常小，所以可以实现边下载边播的效果。HLS 规范规定，播放器至少下载一个 ts 切片才能播放，所以 HLS 理论上至少会有一个切片的延迟。

1. 优势

HLS 是为了解决 RTMP 协议中存在的一些问题而设计的，所以，它自然有自己的优势。主要体现在以下几方面：

RTMP 协议没有使用标准的 HTTP 接口传输数据，在一些有访问限制的网络环境下，比如企业网防火墙，是没法访问外网的，因为企业内部一般只允许 80/443 端口可以访问外网。而 HLS 使用的是 HTTP 协议传输数据，所以 HLS 协议天然就解决了这个问题。

HLS 协议本身实现了码率自适应，不同带宽的设备可以自动切换到最适合自己码率的视频进行播放。

浏览器天然支持 HLS 协议，而 RTMP 协议需要安装 Flash 插件才能播放 RTMP 流。

2. 不足

HLS 最主要的问题就是实时性差。由于 HLS 往往采用 10s 的切片，所以最小也要有 10s 的延迟，一般是 20～30s 的延迟，有时甚至更差。

HLS 之所以能达到 20～30s 的延迟，主要是由于 HLS 的实现机制造成的。HLS 使用的是 HTTP 短连接，且 HTTP 是基于 TCP 的，所以这就意味着 HLS 需要不断地与服务器建立连接。TCP 每次建立连接时都要进行三次握手，而断开连接时，也要进行四次挥手，基于以上这些复杂的原因，就造成了 HLS 延迟比较久的局面。

如何选择 RTMP 和 HLS

分析完 RTMP 和 HLS 各自的优势和劣势后，接下来我们就结合项目实践，对这二者的使用场景做一些建议。

流媒体接入，也就是推流，应该使用 RTMP 协议。

流媒体系统内部分发使用 RTMP 协议。因为内网系统网络状况好，使用 RTMP 更能发挥它的高效本领。

在 PC 上，尽量使用 RTMP 协议，因为 PC 基本都安装了 Flash 播放器，直播效果要好很多。

移动端的网页播放器最好使用 HLS 协议。

iOS 要使用 HLS 协议，因为不支持 RTMP 协议。

点播系统最好使用 HLS 协议。因为点播没有实时互动需求，延迟大一些是可以接受的，并且可以在浏览器上直接观看。

小结

本文我们首先详细介绍了传统直播系统的架构，并展示了主播端与观众端是如何通过该系统进行直播的。

然后我们又对 RTMP 协议和 HLS 协议进行了比较，给出了一些使用建议，这些只能说是一些经验，具体实践时还得根据产品实际需求来考虑。比如，有些产品只需要网页播放，那就考虑 HLS 协议；有些产品很简单，是私有部署，不需要考虑互联网需求，那直接用 RTMP 协议即可。所以说，如何选择还是得根据产品需求来定。

总体来看，随着人们对实时性、互动性的要求越来越高，人们对传统直播技术的需求越来越少。本专栏前两个模块中介绍的 WebRTC 技术正是为了解决人们对实时性、互动性需求而提出的新技术。所以，以 WebRTC 为代表的实时直播系统必然会成为未来的主流。另一方面，随着 5G 的落地与推广，带宽问题会得到极大的解决，因此人们在享受实时性、互动性便利的同时，音视频服务质量也会得到极大的改善，这将加速传统的基于 RTMP /HLS 的直播技术的淘汰。

当然，基于 RTMP/HLS 的直播技术的淘汰不是一蹴而就的，因为在大负载方面，使用 CDN 技术的传统直播目前还有一定优势，所以短期内可以考虑 WebRTC 的实时直播技术与使用 RTMP 协议为代表的传统直播技术相结合的方案，既可以满足用户对于实时性、互动性的需求，又可以满足大负载的需求，这也是各大厂积极推动的一套方案。

思考时间

我们前面讲过 WebRTC 采用 UDP/RTP 进行媒体数据传输，而 RTMP 却使用了 TCP 协议，那它为什么不使用 UDP 协议呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

32 | HLS：实现一对多直播系统的必备协议

在上一篇文章中 ，我们对 RTMP 协议和 HLS 协议的优势与劣势进行了比较。从比较的结果我们可以看出，RTMP 作为传统的直播传输技术在实时性方面要比 HLS 好很多，所以它还是有一定优势的。

不过，随着 Chrome 浏览器宣布不再对 Flash 插件提供支持、Adobe 公司停止对 RTMP 协议更新以及苹果公司声称 iOS 上不允许使用 RTMP 协议等一系列事件的发生，我们可以断定 RTMP 协议已失去了未来。

而 HLS 协议则恰恰相反，它在未来会有更广阔的应用前景。我们可以通过以下几点来得到这个结论：

HLS 是苹果开发的协议，苹果产品原生支持此协议；

HLS 是基于 HTTP 的，可以不受防火墙限制，所以它的连通性会更好；

HLS 还能根据客户的网络带宽情况进行自适应码率的调整，这对于很多用户来说是非常有吸引力的。

基于以上原因，我们有必要从 HLS 直播架构、FFmpeg 生成 HLS 切片、HLS m3u8 格式和 HLS TS 格式这四个方面对 HLS 协议的细节做一下介绍。

HLS 直播架构

下面我们来看一下 HLS 直播系统的架构图，如下所示：

HLS 直播架构图

我们在上一篇文章中讲过，传统直播系统大致分为三部分：直播客户端、信令服务和 CDN 网络，使用 HLS 协议也是如此。只不过在我们这里为了简化流程，去掉了信令服务系统。

如上图所示，客户端采集媒体数据后，通过 RTMP 协议将音视频流推送给 CDN 网络的源节点（接入节点）。源节点收到音视频流后，再通过 Convert 服务器将 RTMP 流切割为 HLS 切片文件，即 .ts 文件。同时生成与之对应的 m3u8 文件，即 HLS 播放列表文件。

切割后的 HLS 分片文件（.ts 文件）和 HLS 列表文件（.m3u8 文件）经 CDN 网络转发后，客户端就可以从离自己最近的 CDN 边缘节点拉取 HLS 媒体流了。

在拉取 HLS 媒体流时，客户端首先通过 HLS 协议将 m3u8 索引文件下载下来，然后按索引文件中的顺序，将 .ts 文件一片一片下载下来，然后一边播放一边缓冲。此时，你就可以在 PC、手机、平板等设备上观看直播节目了。

对于使用 HLS 协议的直播系统来说，最重要的一步就是切片。源节点服务器收到音视频流后，先要数据缓冲起来，保证到达帧的所有分片都已收到之后，才会将它们切片成 TS 流。

为了便于分析，本文是通过 FFmpeg 工具将 MP4 文件切割成 HLS 格式的文件切片。但不管选择使用哪一种切割文件的方法或工具，生成的切片和索引文件的格式都是一致的。

勿在浮沙筑高台，为了让你在工作中做到得心应手、心中有数，接下来就让我们一起探索 HLS 协议的一些具体细节吧。

FFmpeg 生成 HLS 切片

这里我们是通过 FFmpeg 工具将一个 MP4 文件转换为 HLS 切片和索引文件的。所以，你需要预先准备一个 MP4 文件，并且下载好 FFmpeg 工具。你可以从FFmpeg 官网下载二进制包，也可以通过下载源码自行编译出 FFmpeg 工具。FFmpeg 用于将 MP4 切片成 HLS 的命令如下：

ffmpeg -i test.mp4 -c copy -start_number 0 -hls_time 10 -hls_list_size 0 -hls_segment_filename test%03d.ts index.m3u8

该命令参数说明如下：

-i ，输入文件选项，可以是磁盘文件，也可以是媒体设备。

-c copy，表示只是进行封装格式的转换。不需要将多媒体文件中的音视频数据重新进行编码。

-start_number，表示 .ts 文件的起始编号，这里设置从 0 开始。当然，你也可以设置其他数字。

-hls_time，表示每个 .ts 文件的最大时长，单位是秒。这里设置的是 10s，表示每个切片文件的时长，为 10 秒。当然，由于没有进行重新编码，所以这个时长并不准确。

-hls_list_size，表示播放列表文件的长度，0 表示不对播放列表文件的大小进行限制。

-hls_segment_filename，表示指定 TS 文件的名称。

index.m3u8，表示索引文件名称。

执行完这条命令后，在当前路径下会生成一系列 .ts 文件和 index.m3u8 文件。下面，我们再分别分析一下 .m3u8 文件格式和 .ts 文件格式。

m3u8 格式分析

正如前面讲到，HLS 必须要有一个 .m3u8 的索引文件 。它是一个播放列表文件，文件的编码必须是 UTF-8 格式。这里我们将前面生成的 .m3u8 文件内容展示一下，以便让你有个感观的认识。内容如下：

#EXTM3U

#EXT-X-VERSION:3         // 版本信息

#EXT-X-TARGETDURATION:11 // 每个分片的目标时长

#EXT-X-MEDIA-SEQUENCE:0  // 分片起始编号

#EXTINF:10.922578,       // 分片实际时长

test000.ts               // 分片文件

#EXTINF:9.929578,        // 第二个分片实际时长

test001.ts               // 第二个分片文件

...

这里截取了分片列表文件开头部分的内容，可以看出文件内容要么是以#字母开头，要么就是没有#字母。关于文件格式规范，RFC8216 草案第四节有详细的说明，你可以到那里查看详细的内容。

RFC8216 规定，.m3u8 文件内容以#字母开头的行是注释和 TAG，其中 TAG 必须是#EXT 开头，如上面示例中的内容所示。

接下来，我们对这几个 TAG 做一下说明：

EXTM3U 表示文件是第一个扩展的 M3U8 文件，此 TAG 必须放在索引文件的第一行。

EXT-X-VERSION: n 表示索引文件支持的版本号，后面的数字 n 是版本号数字。需要注意的是，一个索引文件只能有一行版本号 TAG，否则播放器会解析报错。

EXT-X-TARGETDURATION: s 表示 .ts 切片的最大时长，单位是秒（s）。

EXT-X-MEDIA-SEQUENCE: number 表示第一个 .ts 切片文件的编号。若不设置此项，就是默认从 0 开始的。

EXTINF: duration, title 表示 .ts 文件的时长和文件名称。文件时长不能超过#EXT-X-TARGETDURATION中设置的最大时长，并且时长的单位应该采用浮点数来提高精度。

TS 格式分析

TS 流最早应用于数字电视领域，其格式非常复杂，包含的配置信息表多达十几个。TS 流中的视频格式是 MPEG2 TS ，格式标准是在 ISO-IEC 13818-1 中定义的。

苹果推出的 HLS 协议对 MPEG2 规范中的 TS 流做了精减，只保留了两个最基本的配置表 PAT 和 PMT，再加上音视频数据流就形成了现在的 HLS 协议。也就是说， HLS 协议是由 PAT + PMT + TS 数据流组成的。其中，TS 数据中的视频数据采用 H264 编码，而音频数据采用 AAC/MP3 编码。TS 数据流示意图如下所示：

TS 数据流示意图

我们再进一步细化，TS 数据流由 TS Header 和 TS Payload 组成。其中，TS Header 占 4 字节，TS Payload 占 184 字节，即 TS 数据流总长度是 188 字节。

TS Payload 又由 PES Header 和 PES Payload 组成。其中，PES Payload 是真正的音视频流，也称为 ES 流。

PES（Packet Elementary Stream）是将 ES 流增加 PES Header 后形成的数据包。

ES（Elementary Stream），中文可以翻译成基流，是编码后的音视频数据。

下面我们就来分析一下 TS 数据流的格式，如下图所示：

TS 文件格式图

这是 TS Header 各个字段的详细说明，图中数字表示长度，如果数字后面带有 bytes ，单位就是 bytes；否则，单位都是 bit。

TS Header 分为 8 个字段，下面我们分别解释一下：

PES Packet 作为 TS 数据流的 Payload，也有自己的 Header，如下图所示：

PES 结构图

下面我们就对这些常用的字段一一做下解释，当然也还有很多不常用的字段，我们这里就不列出来了，如有需求，可参考 ISO-IEC 13818-1 2.4.3.7 节。

PES Header 长度是 6 字节，字段说明如下：

另外，PTS（Presentation Tmestamp） 字段总共包含了 40 bit，高 4 个 bit 固定取值是 0010；剩下的 36 个 bit 分三部分，分别是：3 bit+1 bit 标记位；15 bit+1 bit 标记位；15 bit+1 bit 标记位。

通过以上的描述我们就将 HLS 协议中最重要的 TS 数据流向你介绍清楚了。

小结

本文我们首先讲述了采用 HLS 协议的直播架构。实际上该直播架构与我们上文中介绍的直播架构是一致的，只不过我们这里为了强调 HLS 协议对之前的直播架构做了简化。同时，我们还通过该直播架构模型向你介绍了传统直播系统中，从用户推流到服务端切片、再到用户拉流的基本过程。

随后，我们借助 FFmpeg 工具向你讲解了如何将 MP4 文件转换成 HLS 文件，并向你展示了 .m3u8 文件的基本结构。最后还重点介绍了 TS 数据流的格式。

通过本文的学习，我相信你应该已经对 HLS 协议有了非常清楚的认知了。实际上，作为应用级的开发人员来说，你并不需要了解到文中所介绍的那么细，只需要对 HLS 协议有个基本的理解就可以了。因为目前有很多不错的开源库已经完成了大部分的工作，你只需要将这些开源库使用好即可。

比如在播放 HLS 流时，就有很多开源库可用。在移动端可以使用 Ijkplayer，在浏览器上可以使用 video.js，在 PC 端可以使用 VLC。而服务端的 HLS 切片则是由 CDN 网络完成的，你只需要向 CDN 网络推流就可以了，CDN 网络会直接将上传的流进行 HLS 切片。而在 CDN 网络内部，它就是使用我们上面所介绍的 FFmpeg 开源库编译好的工具来完成切片工作的。

思考时间

每个 TS 格式数据包是 188 字节长，不够 188 字节就需要用 Padding 填充，那为什么要限制成 188 字节呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

33 | FLV：适合录制的多媒体格式

虽然苹果拒绝使用 RTMP 协议并推出了自己的 HLS 技术，但大多数用户仍然还是使用 RTMP 协议作为传统直播系统的传输协议。在 Adobe 宣布不再对 RTMP 技术进行支持的情况下，仍然还有这么多用户在使用它，说明 RTMP 协议具有其他协议不可比拟的优势。

这里我们做个对比，你就知道 RTMP 协议的优势在哪里了。

首先，与 HLS 技术相比，RTMP 协议在传输时延上要比 HLS 小得多。主要原因在于 HLS 是基于切片（几秒钟视频的小文件）、然后缓存的技术，这种技术从原理上就比直接进行数据传输慢很多，事实也证明了这一点。

其次，相对于 RTP 协议，RTMP 底层是基于 TCP 协议的，所以它不用考虑数据丢包、乱序、网络抖动等问题，极大地减少了开发人员的工作量；而使用 RTP 协议，网络质量的保障都需要自己来完成。

最后，与现在越来越火的 WebRTC 技术相比，RTMP 也有它自己的优势。虽然在实时传输方面 WebRTC 甩 RTMP 技术几条街，但对于实时性要求并没有那么高的传统直播来说，RTMP 协议在音视频的服务质量上普遍要好于 WebRTC 的音视频服务质量。

这下你知道 RTMP 协议为什么会存活这么久了吧。那说了这么多，RTMP 协议与 FLV 又有什么关系呢？实际上，FLV 文件与 RTMP 之间是“近亲”关系，甚至比“近亲”还要近，亲得就好像是“一个人”似的。

FLV 文件格式

我们先来看一下 FLV 的文件格式，如下图所示：

FLV 文件格式结构图

这是我阅读FLV 格式规范文档后，总结出的 FLV 文件格式结构图。从图中我们可以看出，FLV 文件格式由 FLV Header 和 FLV Body 两部分组成。其中，FLV Header 由 9 个字节组成，Body 由 Pre TagSize 和 Tag 组成。

为使你对它们有一个更清楚的认知，下面我们就来详细介绍一下 FLV Header 和 FLV Body。

1. FLV Header

它由 9 个字节组成：3 个字节的 “F”“L”“V”字母，用于标记该文件是 FLV 文件；1 个字节的 Version，指明使用的 FLV 文件格式的版本；1 个字节的 Type 标识，用于表明该 FLV 文件中是否包括音频或视频；4 个字节的 FLV Header 长度，由于 FLV 文件格式头是固定 9 个字节，所以这个字段设置得有点多余。

Type 标识（TypeFlag）又可以细分为： 1bit 用于标识 FLV 文件中是否有音频数据；1bit 标识 FLV 文件中是否有视频数据；如果两个 bit 位同时置 1，说明该 FLV 文件中既有音频数据又有视频数据，这也是通常情况下 FLV Header 的设置；除了两个 bit 的音视频数据标识外，其他位都是预留位，必须全部置 0。详细的含义可以参考下面张图表：

这张图表清晰地表达了 FLV Header 中每个域所占的字节以及该域的具体含义。另外，如果你使用的是 Windows 系统，就可以安装 FlvAnalyzer 工具，该工具是一款功能非常强大的 FLV 文件分析工具。使用它打开任何一个 FLV 文件，就可以看到该文件的 FLV 格式。

2. FLV Body

从“FLV 文件格式结构图”我们可以看出，FLV Body 是由多个 Previous TagSize 和 Tag 组成的。其含义如下图表所示，其中 PreviousTagSize 占 4 个字节，表示前一个 Tag 的大小。这里需要注意的是，第一个 Previous TagSize 比较特殊，由于它前面没有 Tag 数据，所以它的值必须为 0。

接下来我们再来看一下 FLV 中的 Tag，从 FLV 文件格式结构图中我们可以看到 Tag 由两部分组成，即 Tag Header 和 Tag Data 。

Tag Header 各字段的含义如下图所示：

TagType，占 1 个字节，表示该 Tag 的类型，可以是音频、视频和脚本。如果类型为音频，说明这个 Tag 存放的是音频数据；如果类型是视频，说明存放的是视频数据。

DataSize，占 3 个字节，表示音频 / 视频数据的长度。

Timestamp 和扩展 Timestamp，一共占 4 个字节，表示数据生成时的时间戳。

StreamID，占 3 个字节，总是为 0。

而 Tag Data 中存放的数据，是根据 TagType 中的类型不同而有所区别的。也就是说，假如 TagType 指定的是音频，那么 Tag Data 中存放的就是音频数据；如果 TagType 指定的是视频，则 Tag Data 中存放的就是视频数据。

另外，无论 TagData 中存放的是音频数据还是视频数据，它们都是由 Header 和 Data 组成。也就是说，如果该 Tag 是一个音频 Tag ，那么它的数据就是由“AudioHeader + AudioData”组成；如果是一个视频 Tag，则它的数据是由“VideoHeader + VideoData”组成。

特别有意思的一点是，如果你翻看RTMP 协议，查看它的 6.1.1 小节，你会发现它定义的 RTMP Message Header 与 Tag Header 是一模一样的。下图是我从 RTMP 协议中截取的协议头：

因此，我们可以说FLV 文件就是由“FLV Header + RTMP 数据”构成的。这也揭开了 FLV 与 RTMP 之间的关系秘密，即 FLV 是在 RTMP 数据之上加了一层“马甲”。

为什么 FLV 适合录制

通过上面的描述你会发现，FLV 文件是一个流式的文件格式。该文件中的数据部分是由多个 “PreviousTagSize + Tag”组成的。这样的文件结构有一个天然的好处，就是你可以将音视频数据随时添加到 FLV 文件的末尾，而不会破坏文件的整体结构。

在众多的媒体文件格式中，只有 FLV 具有这样的特点。像 MP4、MOV 等媒体文件格式都是结构化的，也就是说音频数据与视频数据是单独存放的。当服务端接收到音视频数据后，如果不通过 MP4 的文件头，你根本就找不到音频或视频数据存放的位置。

正是由于 FLV 是流式的文件格式，所以它特别适合在音视频录制中使用。这里我们举个例子，在一个在线教育的场景中，老师进入教室后开启了录制功能，服务端收到信令后将接收到的音视数据写入到 FLV 文件。在上课期间，老师是可以随时将录制关掉的，老师关闭录制时，FLV 文件也就结束了。当老师再次开启录制时，一个新的 FLV 文件被创建，然后将收到的音视频数据录制到新的 FLV 文件中。 这样当一节课结束后，可能会产生多个 FLV 文件，然后在收到课结束的消息后，录制程序需要将这几个 FLV 文件进行合并，由于 FLV 文件是基于流的格式，所以合并起来也特别方便，只需要按时间段将后面的 FLV 直接写到前面 FLV 文件的末尾即可。

使用 FLV 进行视频回放也特别方便，将生成好的 FLV 直接推送到 CDN 云服务，在 CDN 云服务会将 FLV 文件转成 HLS 切片，这样用户就可以根据自己的终端选择使用 FLV 或 HLS 协议回放录制好的视频。

而对于回放实时性要求比较高的业务，还可以将 FLV 按 3～5 分钟进行切片，这样就可以在直播几分钟后看到录制好的内容了。

另外，FLV 相较 MP4 等多媒体文件，它的文件头是固定的，音视频数据可以随着时间的推移随时写入到文件的末尾；而 MP4 之类的文件，文件头是随着数据的增长而增长的，并且体积大，处理时间长。因此， FLV 文件相较于其他多媒体文件特别适合于在录制中使用。

小结

本文首先向你详细介绍了 FLV 文件格式，通过对该文件格式的讲解，你可以发现 FLV 与 RTMP 协议的关系十分密切，在 RTMP 数据之上加个 FLV 头就成了 FLV 文件。

在本文的后半段，我们还讲解了 FLV 用在服务端录制上的好处。由于它是基于流式的文件存储结构，可以随时将音视频数据写入到 FLV 文件的末尾，且文件头不会因文件数据的大小而发生变化，所以不管是在录制时，还是进行回放时，相较于 MP4 之类的多媒体格式，FLV 都更有优势。

虽然 FLV 协议比较简单，但如果我们自己去实现 FLV 格式分析的话，还是要做很多的工作。庆幸的是，目前已经有很多非常不错的处理 FLV 文件格式的开源库了，如 FLVParser。我们只需要拿来直接使用即可。同时也有很多不错的 FLV 分析工具，比如前面提及的 FlvAnalyzer 就是一款功能非常强大的 FLV 文件格式分析工具，通过这些工具可以让我们更快速地理解 FLV 协议。

RTMP 与 FLV 就像是同一个人，只不过穿的“衣服”不同而已，所以在 RTMP 与 FLV 相互转换时也很容易。比如，我们想将 FLV 以 RTMP 方式进行推流就非常简单，而且还有一个专门的 RTMP 库，即 librtmp，可以帮我们完成底层转换的处理逻辑。

思考时间

你知道有哪些有名的能够播放 FLV 格式文件的开源播放器吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

34 | 如何使用Nginx搭建最简单的直播服务器？

在前面三篇文章中，我们介绍了传统直播系统架构、HLS 协议、RTMP 协议相关的知识，那今天我们就来具体实操一下，根据前面所学到的知识搭建出一套最简单的音视频直播系统。

今天我们要搭建的这套直播系统相较于《31 | 一对多直播系统 RTMP/HLS，你该选哪个？》一文中介绍的直播系统要简单得多。该系统不包括客户端、没有 CDN 分发，只包括最基本的推流、转发及拉流功能。虽然它简单了一点，但麻雀虽小五脏俱全，通过这样一个实战操作，我们就可以将前面讲解的理论与实际结合到一起了。

当然，作为一个直播系统来说，客户端是必不可少的。但由于时间和篇幅的原因，我们只能借助一些现成的或者开源的客户端对我们的直播系统进行测试了，所以客户端的界面可能会简陋一些。也正因为如此，我才没有将它们算作咱们这个直播实验平台之中。

实际上，我们完全可以以这个直播系统实验平台为原型，逐步地将一些功能添加进去，这样很快就可以构建出一套商业可用的传统直播系统了。

直播系统架构

在正式开始实战之前，我们先来简要介绍一下这个直播系统的架构，如下图所示：

最简单的直播系统

这个直播架构非常简单，由两部分组成，即媒体服务器和客户端。

媒体服务器有两个功能：

推流功能，可以让客户端通过 RTMP 协议将音视频流推送到媒体服务器上；

拉流功能，可以让客户端从媒体服务器上拉取 RTMP/HLS 流。

实际上，这个架构与我们前面介绍的传统直播架构相比是有变化的，减少了信令服务器，同时将 CDN 网络变成了一台流媒体服务器。但理解了整个直播架构的原理后，我们就可以快速地将这个简单的直播架构恢复成一个正式的、可商用的直播系统。

那对于我们这个简化的直播系统来说，如何实现架构中的媒体服务器呢？

这里我们使用了目前最流行的 Nginx 来实现它。之所以选用 Nginx 主要出于以下两方面的原因：

Nginx 的性能是很优越。在众多的 Web 服务器中，Nginx 之所以能够脱颖而出，就是因为它性能优越。阅读过 Nginx 代码的同学应该知道，Nginx 在异步 IO 事件处理上的优化几乎做到了出神入化的地步。其架构设计也极其巧妙，不光如此，它将所有使用到的库都重新再造，对 CPU、内存的优化都做到了极致。

Nginx 有现成的实现 RTMP 推拉流的模块。只需要在 Nginx 安装一个 RTMP 处理模块，它就立马变成了我们想要的流媒体服务器了，操作非常简单。

接下来我们就具体实操一下，看看如何实现上面所说的流媒体服务器。

搭建流媒体服务端

在搭建直播平台之前，我们首先要有一台 Linux/Mac 系统作为 RTMP 流媒体服务器的宿主机，然后再按下列步骤来搭建实验环境。

从Nginx官方网站上下载最新发布的 Nginx 源码nginx-1.17.4，地址如下：http://nginx.org/download/nginx-1.17.4.tar.gz 。我们可以使用 wget 命令将其下载下来，命令如下：

wget -c http://nginx.org/download/nginx-1.17.4.tar.gz

通过上面的命令就可以将 Nginx 源码下载下来了。当然，我们还需要将它进行解压缩。命令如下：

tar -zvxf nginx-1.17.4.tar.gz

要想搭建 RTMP 流媒体服务器，除了需要 Nginx 之外，还需要另外两个库，即 Nginx RTMP Module 和 OpenSSL。

1. RTMP Module

Nginx RTMP Module 是 Nginx 的一个插件。它的功能非常强大：

支持 RTMP/HLS/MPEG-DASH 协议的音视频直播；

支持 FLV/MP4 文件的视频点播功能；

支持以推拉流方式进行数据流的中继；

支持将音视频流录制成多个 FLV 文件；

……

Nginx RTMP Module 模块的源码地址为：https://github.com/arut/nginx-rtmp-module.git 。你可以使用下面的命令进行下载，存放的位置最好与 Nginx 源码目录并行。这样就可以将 Nginx RTMP Module 模块下载下来了。

git clone https://github.com/arut/nginx-rtmp-module.git

2. OpenSSL

OpenSSL 的功能和作用我们这里就不多讲了，在第一个模块的 《22 | 如何保证数据传输的安全（下）？》一文中我们已经对它做过介绍了，记不清的同学可以再去回顾一下。

对于我们的 RTMP 流媒体服务器来说，也需要 OpenSSL 的支持。因此，我们需要将 OpenSSL 的源码一并下载下来，以便在编译 Nginx 和 Nginx RTMP Module 时可以顺利编译通过。

OpenSSL 的源码地址为：https://www.openssl.org/source/openssl-1.1.1.tar.gz （有可能需要 VPN）。我们仍然使用 wget 对其进行下载，命令如下：

wget -c https://www.openssl.org/source/openssl-1.1.1.tar.gz --no-check-certificate

通过上面的命令就可以将 OpenSSL 源码下载下来了。同样，我们也需要将下载后的 OpenSSL 进行解压缩，命令如下：

tar -zvxf openssl-1.1.1.tar.gz

3. 编译 OpenSSL & Nginx

通过上面的描述，我们就将所有需要的源码都准备好了，下面我们开始编译 Nginx。在编译 Nginx 之前，我们首先要将 OpenSSL 库编译并安装好，其编译安装过程有如下两个步骤。

第一步，生在 Makefile 文件。

cd openssl-1.1.1

./config

第二步，编译并安装 OpenSSL 库。

make && sudo make install

经过上面的步骤就将 OpenSSL 库安装好了。下面我们开始编译带 RTMP Module 功能的 Nginx，只需要执行下面的命令即可，命令执行完成后，就会生成 Nginx 的 Makefile 文件。

cd nginx-1.17.4

./configure --prefix=/usr/local/nginx --add-module=../nginx-rtmp-module --with-http_ssl_module --with-debug

下面我们对这里的参数做个介绍。

prefix：指定将编译好的 Nginx 安装到哪个目录下。

add-module：指明在生成 Nginx Makefile 的同时，也将 nginx-rtmp-module 模块的编译命令添加到 Makefile 中。

http_ssl_module：指定 Ngnix 服务器支持 SSL 功能。

with-debug：输出 debug 信息，由于我们要做的是实验环境，所以输出点信息是没问题的。

需要注意的是，在编译 Nginx 时可能还需要其他基础库，我们只需要根据执行 configure 命令时提示的信息将它们安装到宿主机上就好了。

Nginx 的 Makefile 生成好后，我们就可以进行编译与安装了。具体命令如下：

make && sudo make install

这里需要强调的是，上面命令中的 && 符号表示前面的 Make 命令执行成功之后，才可以执行后面的命令；sudo 是指以 root 身份执行后面的 make install 命令。之所以要以 root 身份安装编译好文件是因为我们安装的目录只有 root 才有权限访问。

当上面的命令执行完成后，编译好的 Nginx 将被安装到 /usr/local/nginx 目录下。此时，我们距离成功只差最后一步了。

4. 配置 Nginx

上面将 Nginx 安装好后，我们还需要对它进行配置，以实现 RTMP 流媒体服务器。Nginx 的配置文件在/usr/local/nginx/conf/ 目录下，配置文件为 nginx.conf。在 nginx.conf 文件中增加以下配置信息:

...

events {

  ...

}

#RTMP 服务

rtmp {

  server{

	# 指定服务端口

	listen 1935;     //RTMP 协议使用的默认端口

	chunk_size 4000; //RTMP 分块大小

	# 指定 RTMP 流应用

	application live // 推送地址

	{

	   live on;      // 打开直播流

	   allow play all;

	}

    # 指定 HLS 流应用

    application hls {

        live on;     // 打开直播流

        hls on;      // 打开 HLS

        hls_path /tmp/hls;

    }

  }

}

http {

  ...

  location /hls {

      # Serve HLS fragments

      types {

          application/vnd.apple.mpegurl m3u8;

          video/mp2t ts;

      }

      root /tmp;

      add_header Cache-Control no-cache;

  }

  ...

}

...

我们来对上面的配置信息做一下简单的描述，整体上可以将这个配置文件分成三大块：

events，用来指明 Nginx 中最大的 worker 线程的数量；

rtmp，用来指明 RTMP 协议服务；

http，用来指明 HTTP/HTTPS 协议服务。

其中最关键的是 RTMP 协议的配置。我们可以看到，在 rtmp 域中定义了一个 server，表明该 Nginx 可以提供 RTMP 服务，而在 server 中又包括了两个 application ，一个用于 RTMP 协议直播，另一个用于 HLS 协议的直播。

通过上面的配置，我们就将 RTMP 流媒体服务器配置好了。接下来我们可以通过下面的命令将配置好的流媒体服务器启动起来提供服务了：

 /usr/local/nginx/sbin/nginx

至此，我们的 RTMP 流媒体服务器就算搭建好了。我们可以在 Linux 系统下执行下面的命令来查看 1935 端口是否已经打开：

netstat -ntpl | grep 1935

音视频共享与观看

RTMP 流媒体服务器配置好后，下面我们来看一下该如何向 RTMP 流媒体服务器推流，又如何从该流媒体服器上拉 RTMP 流或 HLS 流。

1. 音视频共享

向 RTMP 服务器推流有好几种方式，最简单是可以使用 FFmpeg 工具向 RTMP 流媒体服务器推流，具体的命令如下：

ffmpeg -re -i xxx.mp4 -c copy -f flv rtmp://IP/live/stream

通过这个命令就可以将一个多媒体文件推送给 RTMP 流媒体服务器了。下面我们来解释一下这命令中各参数的含义：

-re，代表按视频的帧率发送数据，否则 FFmpeg 会按最高的速率发送数据；

-i ，表示输入文件；

-c copy，表示不对输入文件进行重新编码；

-f flv，表示推流时按 RTMP 协议推流；

rtmp://……，表示推流的地址。

另外，关于推流地址 rtmp://IP/live/stream 有三点需要你注意。

第一点，推流时一定要使用 rtmp:// 开头，而不是 http://，有很多刚入门的同学在这块总容易出错。

第二点，IP 后面的子路径 live 是在 Nginx.conf 中配置的 application 名字。所以，在 Nginx 配置文件中你写的是什么名字，这里就写什么名字。

第三点，live 子路径后面的 test 是可以任意填写的流名。你可以把它当作一个房间号来看待，在拉流时你要指定同样的名字才可以看到该节目。

2. 观看

RTMP 的推流工具介绍完后，现在我们再来看看如何观看推送的音视频流。你可以使用下面这几个工具来观看“节目”。

Flash 客户端。你可以在 IE 浏览器上使用下面的 Flash 客户端进行观看，地址为：http://bbs.chinaffmpeg.com/1.swf 。需要注意的是，目前最新的 Chrome 浏览器已经不支持 Flash 客户端了。

VLC。在 PC 机上可以使用 VLC 播放器观看，操作步骤是点击右侧的openmedia->网络->输入rtmp://IP/live/test 。

FFplay。它是由 FFmpeg 实现的播放器，可以说是目前世界上最牛的播放器了。使用 FFplay 的具体命令如下：ffplay rtmp://host/live/test。

当然拉流也是一样的，你也可以利用 librtmp 库自己实现一个观看端。

小结

在本文我们全面介绍了如何通过 Nginx 搭建一套最简单的 RTMP/HLS 流媒体服务器。这套流媒体服务器功能还是很齐全的，基本上涵盖了传统流媒体服务器方方面面的知识。

我们还介绍了如何搭建流媒体服务器的原型，以及多种推流、拉流的测试工具。这些测试工具都极具价值，并且大多数都是开源项目，如果你能力比较强的话，可以直接阅读它们的代码。理解它们的代码实现，对于你实现自己的直播推 / 拉流工具有非常大的益处。

另一方面，虽然可以通过 Nginx 实现传统直播系统的原型，但如果你想将它转化为商业用途的话，那还有非常远的距离。因为商用的直播系统需要对流媒体服务器做各种性能优化，对它的宿主机的操作系统做优化，对它的传输网络做优化，等等。除此之外，一个商业的直播系统是由多个子系统构成的，如日志系统、计费系统、监控告警、质量检测系统、云存储系统、工单系统等。

通过本文你可以知道直播原理与实际应用之间的距离并不遥远。直播的原理搞清楚后，你只需要几个小时就可以将传统直播流媒体服务器的实验环境搭建出来。但应用与商用之间却还有一条鸿沟，这条鸿沟是由无数的细节构成的，而这些细节除了音视频直播的知识外，还有算法、操作系统、网络协议等方面的知识。所以说，知识是相通的，道路是曲折的。

思考时间

那除了 Nginx 之外，你还知道其他开源的 RTMP 流媒体服务器吗？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

35 | 如何构建云端一对多直播系统？

在上一篇文章中，我们介绍了如何通过 Nginx 搭建传统直播系统的实验环境，知道了理论是如何与实际结合到一起的。但同时我们也提到，要想通过 Nginx 来实现商用的直播系统还有很长的路要走。

那么今天，我们就来介绍一下快速构建商用直播系统的方案。要想实现真正商用的直播系统，我们必须踩在巨人肩膀上，这个“巨人”就是 CDN 网络。

可以这么说，现在 99% 的传统直播系统都是在 CDN 网络的基础上搭建出来的。因此，我们本文主要介绍的内容就是如何通过 CDN 网络实现商用的直播系统。我们平时所说的“万人直播”就是使用的这种技术。

万人直播的原理

我们首先来看一下万人直播架构图，如下所示：

万人直播架构图

通过该图我们可以看到，它与上一篇文章所介绍的直播架构是非常类拟的，包括了共享端、观看端和 CDN 网络。唯一的变化就是把之前架构中的流媒体服务器换成了现在的 CDN 网络。

对于共享端和观看端来说，使用 CDN 网络与使用流媒体服务器没什么区别。它们都是将流推送给指定的流媒体服务器，然后从指定的播放地址拉流，并最终进行展示这样一个过程。

关于 CDN 网络的基本知识，我们在《31 | 一对多直播系统 RTMP/HLS，你该选哪个？》一文中已经向你做过详细介绍了，它是由源节点、主干结点和边缘节点组成。它们的具体作用我这里就不做赘述了，记不清的同学可以回顾一下那篇文章。

当共享端将媒体流分享到 CDN 网络后，媒体流在 CDN 内部的流转过程是十分复杂的。现在我们只要简单理解为，CDN 网络通过主干结点将流推送到各边缘节点，观看端就近接入到 CDN 网的边缘节点就可以获取到它想观看的“节目”了。

观看端就近接入到 CDN 的边缘节点，使得它与 CDN 节点之间能够使用最好的网络线路传输音视频数据，从而达到非常好的音视频服务质量。

CDN 就近接入

CDN 网络为了保证用户的音视频服务质量，它会让观众在观看节目时，就近接入到距离最近的 CDN 边缘节点。

咱们来举个例子，一名主播使用上海电信的网络分享它的“节目”，那么使用北京联通网络的观众想看该节目时，如何能得到更好的用户体验呢？换句话说，是观众从上海电信的节点上拉取到的音视频流的服务质量好呢，还是从北京联通的节点上拉取到的音视频流的服务质量好呢？我想答案是不言而喻的！

那么，CDN 网络是如何让用户就近接入到北京联通的边缘节点上的呢？这里的关键点就是 DNS 解析。

1. DNS 解析原理

要想解开 CDN 让用户就近接入的秘密，我们就必须要了解 DNS 的解析原理。下面这张图就是 DNS 解析原理的示意图：

DNS 解析原理示意图

接下来，我们就对这张图做一下解析，看看 DNS 是如何工作的。假设你要访问 learningrtc.cn 这个网址，浏览器收到你的命令后，会向本地 DNS（如 Google 的 DNS 服务器 8.8.8.8） 的 53 端口（TCP/UDP 都使用同样的端口）发送域名解析请求。

本地 DNS 收到该请求后，首先查看自己的缓存中是否已经有该域名的记录了。如果有，则直接返回该域名的 IP 地址。如果没有，它会按下列步骤查询域名所对应的 IP 地址。

步骤 1：本地 DNS 向根域名发送查询请求。

步骤 2：根域名一般都会说“我不知道”，但可以再问问 .net 顶级域名的服务地址。

步骤 3：本地 DNS 获取到 .net 顶级域名服务地址后，就向顶级域名服务发送查询域名 IP 地址的请求。

步骤 4：如果顶级域名也不知道要查询域名的 IP 地址的话，它会给本地 DNS 返回二级域名服务地址。

通过这样的步骤，经过本地 DNS 一级一级的查询，最终才能确定浏览器访问域名的 IP 地址是多少。

2. CDN 就近接入原理

当用户访问 CDN 网络时，它首先通过智能 DNS 获取与用户距离最近的边缘节点，如下图所示：

智能 DNS 示意图

那么智能 DNS 是如何找到距离用户最近的 CDN 边缘节点的呢？

智能 DNS 与传统 DNS 解析略有不同。传统 DNS 不判断访问者来源，当有多个 IP 地址可用时，DNS 会随机选择其中一个 IP 地址返回给访问者。而智能 DNS 会判断访问者的来源，也就是说，它会对访问者的 IP 地址做判断，对不同的访问者它会返回不同的 IP 地址。智能 DNS 可以根据用户的 IP 地址找到它所在的地区、使用的运营商等。通过这些信息，它就可以让访问者在访问服务的时候，获得最优的 CDN 边缘节点，从而提升服务的质量。

CDN 的使用

了解了上面的原理后，接下来我们就来具体实操一下，看看如何通过 CDN 网络实现万人直播。

1. 开通 CDN 业务

在使用 CDN 业务之前，我们需要先在所使用的云服务厂商那里开通 CDN 服务。各云厂商开通 CDN 服务的步骤可能略有不同，但大同小异，我们只需要在云厂商提供的管理界面进行开通即可。

云厂商的 CDN 服务可以用于多种业务，如图片加速、文件加速、点播、直播等等。而我们这里是将它用于直播系统，所以在开通 CDN 的时候，一定要选对业务类型，这样它才能真正为我们提供服务。

另外，一般情况下 CDN 业务开通后还不能直接使用，还需要对账户进行充值。在管理界面生成推 / 拉流地址时，如果账户中没有钱，它就不给你产生推拉流地址。必竟天下没有免费的午餐，这一点是需要你特别注意的。

2. 申请域名

要想通过 CDN 来实现万人直播，除了开通 CDN 业务之外，还必须要有自己的域名。比如，我们分享了一个直播间地址给观众，观众只要访问该地址就可收听 / 观看我们的节目了，那这个地址一定是我们自己的域名构成的。

申请域名的方法非常简单，你可以到任何一家云服务厂商去购买，如阿里、腾讯、亚马逊或 Google 等等。域名的价格有高有低，因为我们只是做实验使用，所以购买最便宜的即可。

另外，需要注意的是，如果你是购买的国内域名，则还需要进行备案，域名在没有备案成功之前是无法使用的。备案的主要目的是为了确认你要通过该域名做些什么事情。

3. 域名解析

域名申请好并备案成功后，接下来我们就可以对域名进行配置了。域名的配置主要分为两步：一是对 CDN 直播域名的配置，二是在域名管理中对前面配置好的直播域名进行映射。

首先，我们来看一下如何对 CDN 直播域名进行配置：打开直播控制台的域名管理，点击添加域名，在弹出的添加域名界面中，添加上我们的直播域名即可。比如我的域名为 learningrtc.cn，因此我可以将直播域名写成 pushcdn.learningrtc.cn。

通过上面步骤添加好直播域名后，云厂商的直播管理系统会给我们返回真正的直播域名。比如我添加的 pushcdn.learningrtc.cn 直播域名，其实它真正的 CDN 域名地址为 pushcdn.learningrtc.cn.w.alikunlun.net。也就是说 pushcdn.learningrtc.cn 只不过是一个伪域名。

通过上面的描述我们可以知道，要想让用户访问在 pushcdn.learningrtc.cn 域名时，转到 CDN 真正的直播域名 pushcdn.learningrtc.cn.w.alikunlun.net，那么我们还需要在云厂商的域名管理系统中配置一条 DNS CNAME 的记录。这样，在 DNS 解析时就会进行跳转，从而解析出真正提供服务的 IP 地址。

4. 生成推 / 拉流地址

域名配置好之后，我们就可能生成推 / 拉流地址了。在云厂商的直播管理界面中，专门有用于生成推 / 拉流地址的操作界面。

其操作非常简单，首先是选择要使用的直播域名，该域名就是我们上面配置好的域名，然后填入你的 Application 名字，最后是填入直播流（房间）名字。这些配置信息填好后，点击生成按钮，就可以生成直播的推流地址和拉流（播放）地址了。

通过上面的描述我们可以知道，在 CDN 网络的内部应该也是使用的 Nginx 或与 Nginx 相类似的流媒体服务器。因为从它的配置上看，与我们在上一篇文章中所介绍的配置是一致的。

下图是生成推流与拉流地址的拼接格式：

5. 推流与播放

配置好 CDN 的推 / 拉流地址后，接下来我们就可以进行测试了。

相关的推流工具有 FFmpeg、OBS、自己通过 librtmp 库实现的推流客户端等；拉流工具包括 VLC、FFplay、Flash、flv.js 、video.js 等。因为这些工具我们在上一篇文章中已经介绍过了，所以这里就不再赘述了。

需要特别强调的是， OBS 这个推流工具是非常专业的推流工具，并且各个操作系统上都有对应的 OBS 客户端。比如在实际的测试中，我们可能会发现使用 FFmpeg 推流时，声音的质量非常差，但如果使用 OBS 工具推流的话，音质就非常好了。

小结

本文我们首先介绍了 CDN 实现万人直播的架构。通过该架构你会发现，它与我们上一篇文章介绍的架构没有什么区别，只是将 RTMP 流媒体服务器换成了 CDN 网络而已，而这正是本质的差别。

之所以 CDN 网络可以支持万人直播，就是因为在 CDN 网络内部做了大量的工作，如网络、操作系统、CPU 内存等的优化。正是通过这点点滴滴的优化才能做到上万人、甚至几十万人同时在线观看节目的效果。

之后，我又详细向你介绍 DNS 解析原理以及 CDN 就近接入原理。了解 DNS 解析原理对于你理解 CDN 是至关重要的，只有对它理解清楚了之后，你才能理解智能 DNS；而理解了智能 DNS 后，你才可以理解用户是如何实现就近接入 CDN 边缘节点的。

中国的互联网分家为北联通、南电信之后，网络环境越来越复杂。各大运营商之间、各地区之间的自我保护导致我们在跨运营商、跨地区传输数据时会遇到各种各样的网络状况，如主动丢包、延迟增大、传输抖动等等。解决这个问题的最好办法就是“就近接入”，让用户在自己地区所在的运营商接入网络，而跨运营商、跨地区的数据传输交由 CDN 网络使用专用网进行传输，这样就可以让用户享受到更好的音视频服务质量了。

在文章的最后，我们还讲述了如何开通 CDN 服务，以及如何使用 CDN 网络。这部分的知识可能有点琐碎，但却是我们实践 CDN 网络必不可少的一步，所以搞清楚这部分内容依然很重要。

思考时间

你知道配置 DNS 记录时它都有哪些类型吗？各不同类型的含义是什么呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

36 | 如何使用 flv.js 播放 FLV 多媒体文件呢？

flv.js 是由 bilibili 公司开源的项目。它可以解析 FLV 文件，从中取出音视频数据并转成 BMFF 片段（一种 MP4 格式），然后交给 HTML5 的<video>标签进行播放。通过这种方式，使得浏览器在不借助 Flash 的情况下也可以播放 FLV 文件了。

目前，各大浏览器厂商默认都是禁止使用 Flash 插件的。之前常见的 Flash 直播方案，到现在已经遇到极大的挑战。因为它需要用户在浏览器上主动开启 Flash 选项之后才可以正常使用，这种用户体验是非常糟糕的，而 flv.js 的出现则彻底解决了这个问题。

flv.js 是由 JavaScript 语言开发的，该播放器的最大优势是，即使不安装 Flash 插件也可以在浏览器上播放 FLV 文件。虽说 Adobe 公司早已不再为 Flash 提供支持了，但 FLV 多媒体文件格式不会就此而消亡。因此，在没有 Flash 的时代里，能实现在浏览器上播放 FLV 文件就是 flv.js 项目的最大意义。

flv.js 基本工作原理

flv.js 的工作原理非常简单，它首先将 FLV 文件转成 ISO BMFF（MP4 片段）片段，然后通过浏览器的 Media Source Extensions 将 MP4 片段播放出来。具体的处理过程如下图所示：

flv.js 架构图

从上图我们可以看出，flv.js 播放器首先通过 Fetch Stream Loader 模块从云端获取 FLV 数据；之后由 IO Controller 模块控制数据的加载；数据加载好后，调用 FLV Demux 将 FLV 文件进行解封装，得到音视频数据；最后，将音视频数据交由 MP4 Remux 模块，重新对音视频数据封装成 MP4 格式。

将封装好的 MP4 片段交由浏览器的 Media Source Extensions 处理后，最终我们就可以看到视频并听到声音了。所以总体来说，flv.js 最主要的工作是做了媒体格式的转封装工作，具体的播放工作则是由浏览器来完成的。下面我们就对架构图中的每个模块分别做一下说明。

首先我们来看一下 flv.js 播放器，它包括以下四部分：

Fetch Stream Loader，指通过 URL 从互联网获取 HTTP-FLV 媒体流。其主要工作就是通过 HTTP 协议下载媒体数据，然后将下载后的数据交给 IO Controller。

IO Controller ，一个控制模块，负责数据的加载、管理等工作。它会将接收到的数据传给 FLV Demux。

FLV Demux ，主要的工作是去掉 FLV 文件头、TAG 头等，拿到 H264/AAC 的裸流。关于 FLV 文件格式，你可以参考《33 | FLV：适合录制的多媒体格式》 一文。

MP4 Remux ，它的工作是将 H264/AAC 裸流加上 MP4 头，采用的多媒体格式协议是 BMFF。它会将封装好格式的文件传给浏览器的 Data Source 对象。

经过以上四步，flv.js 就完成了自己的使命。

接下来的工作就是浏览器需要做的了，那我们再看一下浏览器各模块的主要作用。

Data Source，用来接收媒体数据的对象，收到媒体数据后传给 Demux 模块。

Demux，解封装模块，作用是去掉 MP4 头，获取 H264/AAC 裸流。

Video Decoder，视频解码模块，将压缩的视频数据解码，变成可显示的帧格式。

Audio Decoder，音频解码模块，将压缩的音频数据解码，变成可播放的格式。

Video Renderer，视频渲染模块，用于显示视频。

Audio Renderer，音频播放模块，用于播放音频。

Video Display，视频、图形显示设备的抽象对象。

Sound Card，声卡设备的抽象对象。

从上面的过程可以看出，flv.js 主要的工作就是进行了 FLV 格式到 MP4 格式的转换。之所以这样，是因为 flv.js 是通过 HTML5 的 <video> 标签播放视频，而此标签支持的是 MP4 格式。

关于 <video> 标签

<video> 标签是 HTML5 新支持的元素，用于在浏览器中播放音视频流。实际上，我们在介绍 WebRTC 知识的时候就已经对它做过一些介绍了。今天我们再来简单重温一下这部分知识。

<video> 标签的使用与其他 HTML5 标签的使用是一样的，我们这里就不多讲了，主要讲一下<video>标签支持的几个属性：

autoplay，如果设置该属性，则视频在就绪后就马上播放。

src，要播放的视频的 URL。

srcObject，用于播放实时音视频流，该属性与 src 互斥。

……

<video>标签支持的属性比较多，你可以查看文末参考一节，那里将<video>标签的所有属性都做了详细说明。关于这些属性，我建议你写一个简单的页面分别测试一下，以加深你对它们的认知。

另外，对于该标签的测试有两点是需要你注意的：

媒体文件建议用 MP4 文件，你系统的本地文件就可以用来测试<video>标签。

在测试 autoplay 属性时候，如果发现没有效果，建议加上 muted 属性，浏览器保证静音状态是能 autoplay 的。

使用 flv.js

首先，我们需要将 flv.js 源码下载下来。主要有两种方式：一种是通过 git clone 命令从 GitHub 上拉取最新的代码；另一种是通过 NPM 命令从资源库中获取 flv.js 源码。这里我们采用的是第二种方式，具体命令如下：

npm install --save flv.js

源码下载下来后，我们还要将 flv.js 的依赖包下载下来，通过 gulp 工具将 flv.js 进行打包、压缩。具体步骤如下：

npm install          # 安装 flv.js 依赖的包

npm install -g gulp  # 安装 gulp 构建工具

gulp release         # 打包、压缩 工程 js 文件

其中，第 1 条命令的作用是下载 flv.js 的依赖包，第 2 条命令是安装 JavaScript 打包工具 gulp，第 3 条命令是使用 gulp 对 flv.js 进行构建。

需要注意的是，在执行 gulp release命令 时，有可能会遇到如下的错误：

gulp release[4228]: src\node_contextify.cc:633: Assertion `args[1]->IsString()' failed.

这时可以通过安装 Node.js 的 natives 模块来解决该问题，安装 natives 的命令如下：

npm install natives

通过上面的步骤，我们就可以构建出 flv.min.js 文件了。 该文件就是我们在项目中需要引用的 flv.js 文件。

接下来，我们就展示一下该如何使用 flv.js。你可以在本地创建一个 HTML 文件，如 play_flv.html，其代码如下：

<!-- 引入 flv.js 库 -->

<script src="flv.min.js"></script>

<!-- 设置 video 标签 -->

<video id="flv_file" controls autoplay>

  You Browser doesn't support video tag

</video>

<script>

    // 通过 JavaScript 脚本创建 FLV Player

    if (flvjs.isSupported()) {

        var videoElement = document.getElementById('flv_file');

        var flvPlayer = flvjs.createPlayer({

            type: 'flv',

            url: 'http://localhost:8000/live/test.flv'

        });

        flvPlayer.attachMediaElement(videoElement);

        flvPlayer.load();

        flvPlayer.play();

    }

</script>

上面代码的逻辑非常简单，它主要做了三件事：第一是在 HTML 中引入了 flv.js 库；第二是设置了一个 <video> 标签，用于播放 FLV 文件；第三是一段 JavaScript 代码片段，用于创建 FLV Player 对象，并将这与上面定义的<video>绑定到一起，实现对 <video>标签的控制。

flv.js 的实现架构以及暴露的 API 接口在 flv.js/docs 目录下面有一个简单的介绍，你可以参考一下。flv.js 暴露的接口不多，但由于没有暴露接口的任何文档，所以你只能直接查看源码去了解每个接口的具体作用与含义。幸运的是，flv.js 架构设计得非常合理，代码实现也非常优秀，所以对于 JavaScript 开发人员来说，查看 flv.js 源码不是太难的事儿。

小结

本文我们首先介绍了 flv.js 播放器的基本工作原理，通过该原理我们可以知道，它主要的工作就是将 FLV 文件格式转换成 MP4 文件格式。

紧接着我们通过一个例子向你介绍了如何使用 flv.js 。flv.js 的使用还是比较简单的，你只要按文中的步骤就可以很快写出一个利用 flv.js 播放 FLV 文件的 Demo 出来。

另外需要注意的是，我们本文对 flv.js 的测试需要用于流媒体服务器，而流媒体服务器的搭建我们在前两篇文件中已经向你做过介绍了。你既可以使用 CDN 搭建流媒体服务器，也可以使用 Nginx 在自己的本机搭建流媒体服器。其具体过程这里我就不再重复了。

至于推流工具，前面的文章中我们也详细介绍过，你可以任选 FFmpeg 或 OBS 作为你的推流工作，具体的操作方式请参考前面的文章。

总体来说，flv.js 是一个非常优秀的 Web 开源播放器，它的代码写得非常漂亮，可读性也高。但它的文档相对匮乏，所以我们在开发过程中如果遇到一些问题，建议直接查看其源码。

从本专栏的第三个模块开始到现在我们已经介绍了 FLV 文件格式、HLS 协议、流媒体服务器，今天我们又学习了 flv.js 播放器，至此我们就可以用 CDN 做流媒体服务转发、用 OBS 进行推流、用 flv.js 播放，构建出自己的直播系统了。

思考时间

今天留给你的思考题是：flv.js 中用到的 BMFF 格式是什么样的呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

参考

<video> 标签属性

37 | 如何使用 video.js 播放多媒体文件？

在上一篇文章中，我们介绍了 flv.js 播放器。那今天我们再来介绍另一款非常有名的 JavaScript 播放器——video.js。

我们首先来比较一下这两款播放器，看看它们之间有什么不同？在我看来，flv.js 更聚焦在多媒体格式方面，其主要是将 FLV 格式转换为 MP4 格式，而对于播放器的音量控制、进度条、菜单等 UI 展示部分没有做特别的处理。而 video.js 对音量控制、进度条、菜单等 UI 相关逻辑做了统一处理，对媒体播放部分设计了一个插件框架，可以集成不同媒体格式的播放器进去。所以相比较而言，video.js 更像是一款完整的播放器。

video.js 对大多数的浏览器做了兼容。它设计了自己的播放器 UI，接管了浏览器默认的<video>标签，提供了统一的 HTML5/CSS 皮肤。因此，通过 video.js 实现的播放器，在大多数浏览器上运行时都是统一的风格和操作样式，这极大地提高了我们的开发效率。

除了上面介绍的特点外，video.js 还有以下优势：

开源、免费的。不管你是学习、研究，还是产品应用，video.js 都是不错的选择。

轻量。浏览器 UI 的展现全部是通过 HTML5/CSS 完成，没有图片的依赖。

完善的 API 接口文档，让你容易理解和使用。

统一的 UI 设计，不管在哪个浏览器，你都看不出任何差异。

皮肤可以任意更换，很灵活。

开放灵活的插件式设计，让你可以集成各种媒体格式的播放器。

支持多种文字语言，如中文、英文等。

既然有这么多优势，那接下来我们就来详细讲解一下 video.js 的相关内容吧。

video.js 的架构

HTML5 为媒体播放新增了很多新的元素，比如<audio>、<video>、<source>等，这些内置标签基本上可以满足我们日常的需求。而 video.js 把这些组件统统都实现了一遍，其主要目的是为了适配不同浏览器的差异，为各浏览器提供统一的 UI 展示和个性化定制。

接下来，我们来看看 video.js 都包含了哪些主要组件，如下图所示：

video.js 架构图

通过该图可以看到，video.js 主要包括对多种文字语言支持、CSS 样式定制、控件部分、媒体内嵌元素部分和外部插件五大部分。下面我们来简要介绍下这每一部分的相关信息。

第一部分是 Language。它在 video.js/language 目录下面，支持多种文字语言切换。

第二部分是 CSS 样式。video.js 的 CSS 样式是可以更换的，支持个性化定制。

第三部分是 Component。Component 是 video.js 中 UI 控件的抽象类，在 Component 中封装了 HTML 元素。Control Bar、Menu、Slider、Tech 都是继承自 Component，叫做子组件，子组件也可以有子组件，这样就形成了一棵树。这样设计的目的就是将播放器相关控件模拟成 DOM 树模型。下面是子组件的功能：

Control Bar，播放器的控制模块。调节音量、播放进度拖动等都由该模块完成。

Menu，播放器右键菜单的实现。

Slider，滚动条控件。可以是垂直滚动条，也可以是水平滚动条。音量滚动条、进度滚动条都是它的子类。

Tech，是 Technique 的缩写，表示采用的播放器技术。其实它就是为播放器插件提供抽象接口。video.js 默认使用的是 HTML5 播放器。

第四部分是 EventTarget。HTML5 除了提供了 <audio>、<video>、<source>这些可见元素，还包括了 Media Source 的概念。像 AudioTrack、VideoTrack、TextTrack、Track 都继承自 Media Source，video.js 把它们也都进行了抽象，这些对象都统一实现了 EventTarget 接口。这几个 Track 的作用如下：

AudioTrack，音频轨，也是音频媒体源。

VideoTrack，视频轨，也是视频媒体源。

TextTrack，文字轨，也是文字媒体源。比如给视频添加字幕就可以使用它，对应于 <track> 标签。

Track ，媒体源的公共抽象。你一定要将它与 <track> 区分开来，这两个不是同一回事。这一点需要你注意一下。

第五部分是插件。video.js 支持播放器插件开发，目前已经有很多插件实现了。在上图中我们只列举了 3 个插件：

HTTP Streaming，可以播放 HLS 协议、DASH 协议的媒体流。

Flash，用于播放 RTMP 媒体流。但目前各大浏览器默认都是禁止使用 Flash 的。经测试，Chrome 浏览器和 IE 新版本浏览器都已不能使用 Flash 播放 RTMP 流了。

YouTube，是企业定制插件。

video.js 安装部署

video.js 的文档非常详细，包括了安装、部署、API 说明、FAQ 等，只要按照文档手册的步骤进行安装部署就可以了。使用 video.js 主要有以下三种方式。

第一种方式，通过源码安装部署。

首先，从 GitHub 下载源码。命令如下：

git clone https://github.com/videojs/video.js.git

然后，安装 video.js 依赖的文件。命令如下：

cd video.js

npm install

最后，构建 video.js。运行命令如下：

npm run-script build

通过以上三步，在 video.js/dist 目录下面就会生成 video.min.js、video.min.css 、语言、字体等相关文件，你只需要将它们引入到你的 JavaScript 工程中即可。

第二种方式，从 npm 仓库获取。

npm install video.js

第三种方式，通过 CDN 直接下载官方构建好的文件。

<link href="https://unpkg.com/video.js/dist/video-js.min.css" rel="stylesheet">

<script src="https://unpkg.com/video.js/dist/video.min.js"></script>

总之，你可以根据自己的喜好，选择其中一种方式使用 video.js。这里需要说明一下，本文后续的例子都是采用的第三种方式。

video.js 播放 MP4

接下来我们就来实战一下，使用 video.js 播放一个本地 MP4 文件，具体代码如下：

// 引入 video.js 库

<link href="https://unpkg.com/video.js/dist/video-js.min.css" rel="stylesheet">

<script src="https://unpkg.com/video.js/dist/video.min.js"></script>

// 使用 video 标签描述 MP4 文件

<video

    id="local_mp4"

    class="video-js"

    controls

    preload="auto"

    poster="//vjs.zencdn.net/v/oceans.png"

    data-setup='{}'>

    <source src="d:/test.mp4" type="video/mp4"></source>

</video>

下面我们来对上面的代码片段做下简要说明：

<link> 标签，从 CDN 获取 video.js 的 CSS 文件。

<script> 标签，从 CDN 获取 video.js 文件。注意，这种方式获取的是最新发布的版本。

<video> 标签，对于 video.js 来讲，需要设置 id 属性。

data-setup 属性，是 video.js 特有的，此标签用于播放器的自动加载。这里我们的代码中传入的是一个空的 json 对象。你也可以参考官网文档选项参数，按照需求传入配置参数对象。

<source> 标签，指定播放 URL 和媒体类型。我们的代码中指定的是本地磁盘 MP4 文件，test.mp4 是预先准备好的文件，类型是 video/mp4。

我们可以预先准备好一个 MP4 文件， 然后再将上面的代码片段拷贝在一个 HTML 文件中，比如play_local_mp4.html，最后在浏览器中打开，就可以看到播放画面了。

本地流媒体服务器搭建

接下来，我们再来介绍一下如何使用 video.js 播放 HLS。对于推流工具我们可以使用 FFmpeg 或 OBS 进行推流，对于这些工具体的使用，我在前面的文章中都有向你做过详细介绍，所以这里就不再赘述了。

对于流媒体服务器，你可以使用 Nginx 在你的本机上搭建流媒体服务器，也可以通过前面文章所介绍的 CDN 网络做为流媒体服务器。对于实验来说，它们都是可行的方案。

对于流媒体服务器的搭建的过程，前面我们已经通过两篇文章做了详细介绍。这里同样也不再进行赘述了。

video.js 播放 HLS

在使用 video.js 播放 HLS 媒体流之前，我们需要先创建一个 HTML5 文件，如 play_hls.html，在 HTML5 文件中的内容如下：

// 引入 video.js 库

<link href="https://unpkg.com/video.js/dist/video-js.min.css" rel="stylesheet">

<script src="https://unpkg.com/video.js/dist/video.min.js"></script>

// 设置 video 标签

<video id="my-hls-player" class="video-js">

  <source src="http://localhost:8000/live/test/index.m3u8" type="application/x-mpegURL"></source>

</video>

<script>

// 创建 HLS 播放器实例

var player = videojs('my-hls-player', {

  controls:true,

  autoplay:true,

  preload:'auto'

});

player.ready(function(){

  console.log('my-hls-player ready...');

});

</script>

video.js 的官方文档对如何使用 video.js 描述得非常详细，你可以自行查阅使用手册。这里我们只简要分析一下代码片段中用到的接口：

从官方指定的 CDN 获取 video.min.js 和 video-js.min.css 文件。需要注意的是，从 video.js 7 开始，HLS 插件默认包含在 video.js 里了。此时，我们就不需要再单独引入 HLS 插件了。

<video> 标签中需要指定 ID，这个 ID 是可以随便起的，我们这里设置的是 my-hls-player。 <video>标签的 CSS 样式采用的是官方提供的默认样式 video-js。当然，你也可以定制自己喜欢的样式。

<source> 标签中的 src 属性，指定了 m3u8 播放地址。我们这里设置的地址是 http://localhost:8000/live/test/index.m3u8 。需要注意的是，type 属性必须是 application/x-mpegURL。

在代码的最后实现了 player 实例的 ready 回调函数口，这样当播放器加载完成后触发 ready 事件时，player 的 ready 函数就会被调用。

现在我们就来测试一下吧。通过 FFmpeg 工具向地址 rtmp://IP/live/test 推流，而 HLS 协议媒体流的播放地址为 http://IP:port/live/test/index.m3u8，再通过浏览器打开 play_hls.html 页面，就可以看到 HLS 协议的画面了。

小结

本文我们首先对 video.js 的架构做了简要介绍，了解了 video.js 的几个重要组成模块，以及这些模块的主要作用。 紧接着，我们展示了如何通过 video.js 播放本地 MP4 文件。最后，我们还介绍了如何通过 video.js 播放 HLS 直播流。

可以说 video.js 是目前在浏览器上最好用、最著名的开源流媒体播放器。它的功能非常强大，既可以处理多种多媒体格式，如 MP4、FLV 等；又可以支持多种传输协议，如 HLS、 RTMP。因此，它是播放音视频直播媒体流必不可少的播放工具。

在本文中，我们为了简单，只在本地建了一个使用 video.js 的 Demo。但在真实的直播系统中，我们应该实现一个直播客户端，在直播客户端中引入 video.js 来播放直播流。此外，该直播客户端还应该通过 WWW 服务发布。当用户想进入房间观看节目时，首先从 WWW 服务上下载客户端，在浏览器将其加载后，通过信令服务获取到直播地址，并最终拉取直播流进行展示。这样就将 video.js 播放器与我们前面讲的万人直播系统组合到一起，最终实现可商用的直播系统了。

思考时间

今天留给你的思考题是：video.js 是如何通过 data-setup 属性完成自动加载的呢？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

38 | 实战推演：带你实现一个支持万人同时在线的直播系统

本文我们将第三模块所讲的知识做一次梳理，让你在整体上了解“万人直播”到底是怎样实现的。我们将从万人直播的整体架构、主播客户端架构、观众客户端架构和流量统计这四个方面向你讲述万人直播的构建。

通过这几个方面的介绍，我想你就知道了一个真正的“万人直播”是如何构建起来的。

万人直播架构

下面这张万人直播架构图与《31 | 一对多直播系统 RTMP/HLS，你该选哪个？》一文中介绍的直播架构图很类似，它们之间最大的不同在于真正的万人直播系统中并不会只使用一家 CDN 网络，而是接入多家 CDN 网络。在使用它们时，你可以按照一定的比例将“节目”分配到不同的 CDN 网络上。

万人直播架构图

多家 CDN 网络的管理一般是由信令服务器控制的。当要接入某家 CDN 网络时，你可以通过信令服务器的注册界面进行注册。在注册界面中，一般要求填入 CDN 厂商的名字、分配比例、CDN API 操作地址等信息。

比如，当某个主播要分享一个节目时，信令服务器首先根据分配比例决定使用哪个 CDN 网络，然后获得该 CDN 网络的 API 操作地址。获取到 CDN 网络的 API 操作地址后，它就可以调用该 CDN 网络提供的 HTTP/HTTPS API 进行操作了，创建域名、生成直播地址、生成拉流地址等等。有了这些地址后，就可以将音视频流推送到 CDN 网络，观众端获取到观看地址后就可以“观看”节目了。

除了接入多家 CDN 网络之外，其他功能与我们之前文章中介绍的是一样的，这里我就不再赘述了。

主播客户端架构

了解了万人直播架构后，接下来我们再看一下主播端的架构。之前我们介绍过，如果在 PC 端进行直播的话，直接使用 OBS 工具就可以了，这个工具是一个非常专业的 PC 端推流工具，功能非常强大，很多主播都使用该工具进行推流。

但如果是移动端的话，就需要我们自己来实现了。主播端的架构如下图所示，按层级分为三层，即传输层、编码与展示层和采集层。

推流客户端架构图

下面我们对各层级的模块分别做下说明。

传输层，包括了信令处理和 RTMP 数据传输。信令处理与业务逻辑关系紧密，比如创建房间、获得推流地址、聊天、送礼物等都属于信令处理的范畴。RTMP 数据传输就更简单了，当从信令模块获取到推流地址后，就可以将编码后的音视频数据通过 RTMP 协议推送给 CDN 网络了。

编码与展示层，该层包括的功能比较多，包括音频检测、音频编码、视频编码、视频预览以及视频美颜。接下来，我们再对这每个功能模块做一下说明。

音频检测，主要用于在直播开始前检测音频设备是否可用。

音频编码，用于将音频采集模块采集的 PCM 数据进行压缩编码。常见的音频编码有 AAC、MP3、Opus 等。一般情况下使用 AAC 编码方式。另外，编码也分为两种，一种是软编，一种是硬编。所谓软编，就是指通过 CPU 执行压缩算法进行编码，会对 CPU 造成很大损耗；硬编，是指通过 GPU 或固定的集成电路进行数据的压缩，它可以将 CPU 资源节省出来。

视频编码，用于将视频采集模块采集的 YUV 数据进行压缩编码。常见的视频编码有 H264/H265、VP8/VP9 等。大多数情况下，我们都使用 H264 对视频进行编码。视频编码同样分为软编和硬编。在移动端，一般我建议使用硬件编码，这样可以大大节省 CPU 资源。

视频预览，将采集的视频展示出来，以便让主播看到从移动端采集到的视频数据的效果。由于视频帧每秒要刷新很多次，并且视频的数据都比较大，所以在处理视频渲染时如果没有处理好，会引起性能问题，如手机反应迟顿、发烫等问题。一般情况下，我们都会使用 OpenGL ES/ Metal 对视频渲染进行加速，从而解决反应迟顿、发烫等问题。

视频美颜，美颜目前可以说是娱乐直播的必备功能，尤其是对于美女主播就显得更为重要。如美白、瘦脸、长腿等这些功能是美女主播最喜欢用的功能。这种特效通常也是通过 OpenGL/Metal 来实现的。当然，随着技术的发展，现在一些 AR 技术也逐渐应用于各种视频效果中，如雨滴、飘雪等等。

采集层，包括音频采集和视频采集。这两个功能模块非常简单，一个用于采集音频数据，另一个用于采集视频数据。它们都是与硬件打交道。

除了上面这些内容外，在主播端还会有数据统计模块。比如什么时候创建房间、什么时候开始推流、推流时音视频的码率是多少等等，这些信息都是由数据统计模块记录的。有了这个模块之后，一是便于我们分析问题，二是便于我们进行计费核算，在与各 CDN 厂商结算费用时会非常有用。

观众端架构

介绍完主播端的功能后，接下来我们再来看看观众端（如下图）。观众端与主播端是很类似的，但实际上我们自己并不会将每个模块都亲手实现，因为对于观众端来说，大部分模块都属于播放器的功能。因此，我们在实现观众端的时候，只需要实现信令处理模块、数据统计模块，并将播放器集成进来就可以了。

观众端架构图

对于移动端，我们一般都使用 Ijkplayer 作为观众端播放器。 Ijkplayer 是由 bilibili 公司开源的项目，地址为：https://github.com/bilibili/ijkplayer.git 。它既可以用于 Android 端又可以用于 iOS 端，使用起来非常方便。

实际上，Ijkplayer 就是 FFmpeg 中 FFplay 的变种，它们的基本逻辑是一致的，只不过 Ijkplayer 更容易移植到移动端上。另外作为 iOS 端，它自带的 player 非常成熟，你也可以直接使用它自己的 player 来实现观众端。

在 PC 端你可以集成 VLC，它与 Ijkplayer 一样，也是非常优秀的一款播放器。该播放器的集成也非常简单，你在网上可以找到非常多的资料，所以这里我就不对它做更多的介绍了。

当然，Web 端也是必不可少的，你可以使用我们前面文章中介绍的 flv.js 或 video.js 作为 Web 端的播放器就可以了。

流量统计

下面我们再简要介绍一下流量统计。当在我们的直播系统中接入 CDN 网络时，一个非常重要的工作就是费用结算。一般情况下，都是按流量进行结算的。也就是说，CDN 厂商会根据我们或我们的用户使用了多少 CDN 流量进行费用的结算，所以我们一定要知道自己用了多少流量。

另一方面，除了流量，我们还要监控我们的服务质量，比如是否出现了卡顿？每小时卡顿了多少次？引起卡顿的原因是什么？是否发生过分辨率切换？缓冲区还有多少数据没有播放出来？这些都与服务质量有着密切的关系。

那么接下来我们就来讲讲该如何采集数据，以达到监控服务质量和费用结算的目的。根据我的实际经验，以下信息是你在每个用户使用 CDN 网络时必须要记录下来的信息：

视频分辨率、帧率、码率相关信息

开启播放时间

关闭播放时间

暂停播放时间

恢复播放时间

缓冲区为 0 的时间

拖放时间以及拖放到的时间

分辨率切换的时间

……

通过以上这些信息，你就可以将用户使用的流量精确地计算出来了，这样再与 CDN 厂商核对数据时，你心里就有谱了。

举个例子，当我们想统计一个用户使用了多少流量时，最简单的公式是（关闭播放时间 - 开启播放时间）* 码率，通过这个公式，你就可以计算出结果了；如果中间有暂停，则计算公式就变成了（（暂停播放时间 - 开启播放时间）+ (关闭播放时间 - 恢复播放时间)）* 码率；如果是多次暂停和恢复，那公式就更加复杂了，你需要按照上面的方法一步一步去推导就可以了。

另外，由于统计数据是一个特别精细的活儿，把这件事儿做好不容易，而且又涉及到费用问题，所以在后端统计时一定要细致，否则当一个节目观看人数众多时，就很容易出现比较大的偏差。

通过上面的介绍，我想在你脑海中一定有了一个统计流量的雏形了，你可以在这个雏形的基础上不断完善你的算法，就可以实现商用的统计系统了。

小结

本文我们从四个方面向你全面介绍了传统直播系统是如何实现的。首先讲解了传统直播系统的架构，该架构与我们前面介绍的架构是类似的，最重要的差别是可以接入多个 CDN 网络。在真正商用的系统中，这个 CDN 网络都是按一定比例分配资源，当有某个 CDN 网络出现问题时，还可以进行 CDN 网络的切换。然后我们又详细阐述了主播客户端与观众客户端是如何实现的。最后，还讲述了流量统计模块，因为它关系到统计计费与服务质量，所以是直播系统中必不可少的一个模块。

目前国内的 CDN 厂商特别多，如阿里、腾讯、金山等，当然也还有很多老牌的 CDN 厂商，如蓝汛、网宿等，它们的质量具体如何还需要你多进行测试。从我个人经验来讲，阿里无论价格还是质量都还不错。

思考时间

今天留给你的思考题：如何判断用户在播放音视频流时是否出现过卡顿？

欢迎在留言区与我分享你的想法，也欢迎你在留言区记录你的思考过程。感谢阅读，如果你觉得这篇文章对你有帮助的话，也欢迎把它分享给更多的朋友。

结束语 | 路漫漫其修远兮，吾将上下而求索

时间过得真快，转眼本专栏就进入了尾声，不得不说这是一段让人难以忘怀的“旅程”。在这段“旅程”中，我也力求对每一篇文章都能做到“精雕细琢”，将要讲的内容说清楚、写透彻。

音视频直播技术是一个非常小众的领域。一方面，音视频相关知识的学习成本确实要比学习其他知识高出很多，需要你花大量的时间去理解、去积累；另一方面，音视频相关技术还普及得不够，对于大多数研发同学来说，可能还不知道有哪些好用的音视频库，更不知道利用这些音视频开源库能做些什么有意思的产品。

但我敢肯定，随着 5G 的到来，音视频直播技术会越来越受到人们的重视。你不光可以用它来实现娱乐直播、在线教育、音视频会议，还可以将它应用于实时传输、娱乐互动、远程协同等方面，比如在线 K 歌、远程相亲、远程办工等等。可以说音视频直播技术是 5G 落地后的最主要的场景，目前阿里、腾讯、今日头条等大厂都纷纷举办音视频技术的高峰论坛，并花大价钱网络这方面的人才，就是为了在即将到来的 5G 时代抢得先机，为下一个 10 年做好准备。

而本专栏的一个主要目的就是向你普及音视频直播技术知识，教你使用 WebRTC 在浏览器之间实现 1 对 1 通信、共享桌面；不光如此，通过使用 Medooze 这类 SFU 流媒体服务器，你可以非常容易地实现多人互动；更让人欣喜的是你还可以将实时互动系统与传统的直播系统相结合，构造出既可以进行实时通信又可以万人观看的直播系统。这些功能在几年前还是不可想象的事儿。

本专栏就是按照上面的顺序向你逐一展开讲解的。首先，我们讲解了如何通过 WebRTC 实现 1 对 1 实时音视频通话，并以 1 对 1 实时通信为核心，向你介绍了其中用到的基本知识和概念，如 NAT 穿越、媒体协商等。

接着，在 1 对 1 实时通话的基础上，我们又进行了升级改造，增加了 SFU 流媒体服务器，就可以实现多对多实时通话了。在这部分内容中我们重点介绍了 Medooze 流媒体服务器，讲解了 Medooze 的架构设置，并通过类图和时序图对 Medooze 做了全面剖析。

最后就是传统音视频直播系统的构建。对于传统直播技术来说，最主要的是使用了 CDN 网络作为媒体流的转发中介，使我们的直播系统可以支持上万人或几万人、几十万人同时在线。

通过这些知识的学习，我想你至少应该知道 WebRTC 在音视频实时通话的作用，并清楚传统直播与实时互动直播的区别。事实上，你还应该知道如何通过 WebRTC 实现 1 对 1 实时通话、如何实现多人互动，以及该如何实现一套传统直播系统。

当然，本专栏只是学习音视频相关知识的一个开始，而音视频知识纷繁复杂，不可能通过一个专栏就将所有的音视频知识全部学通。对于在学习本专栏之前从没有接触过音视频技术的同学来说，还要学习一些音视频相关的其他知识，如 H264 的基本工作原理、YUV 数据格式、如何通过 FFmpeg 进行音视频的编解码、音视频该如何进行同步、移动端 /PC 端使用 WebRTC 与浏览器的区别、如何实现 SFU 流媒体服器等等，以上这些都是你进一步需要重点学习的知识。

总之，5G 的到来必然会使音视频成为一门引领时代的技术，而各大厂商早已为此谋篇开局，招集人才。而人才的奇缺，也必会让这些人才获得高额的回报。但学习音视频知识绝不是一朝一夕的事儿，你必须要脚踏实地一步一步来，只有这样，你才有可能成为各大厂真正需要的“人才”。 正所谓，路漫漫其修远兮，吾将上下而求索！