Skip to content

进阶

Canvas

介绍

Canvas 是 HTML5 标准中的一个新元素,你可以把它想像成一块“画布”,有了它你就可以在网页上绘制图像和动画了。在 HTML5 页面中可像使用其他元素一样使用 Canvas,如 Video 标签。为了能够在 Canvas 上绘图,浏览器为此提供了一整套 JavaScript API ,我们将在后面的代码中看到如何使用它们。

Canvas 最早由苹果公司开发的,用在 Mac OS X 的 WebKit 组件中。之后,各大主流的浏览器都对 Canvas 进行了支持,所以它也被纳入到了 HTML5 的规范中。

Canvas 支持 2D 和 3D 图像的绘制,并且应用领域非常广泛,基本上涵盖了 Web 图形 / 图像、视频、动画等领域。我们在网页中经常见到的统计图表、视频应用、网页游戏等等,都是由它来实现的。

基本概念和原理

2D 图像与 3D 图像

在真实世界中,我们看到的都是三维世界(3 Dimension),即立体图像。而在计算机系统中,显示的图像都是二维的,它是一个平面,有水平和垂直两个维度。下面这张图是 2D 与 3D 的对比图。

img

在计算机中,3D 图像是在 2D 图像的基础上通过增加一个深度来实现的,就像上图中的图 2 所示。当然,由于咱们的显示器是二维的,所以三维图像最终显示的时候要将 3D 图像转换成 2D 图像,然后才能在显示器上显示出来,这与像机拍照是同样的原理。

矢量图

所谓矢量图,实际上是一些“基本图元 + 计算公式”,图像是在展示时通过实时计算后绘制出来的。非矢量图是一张像素的二维数组,图像就是由这个二维数组中的每个像素拼出来的。

举几个简单的例子你就更清楚了:

  • 如果用矢量图表述一条线,那它实际上只需要存储两个点(起点和终点)的坐标就可以了。而对于非矢量图,则需要将这两点之间的所有像素都要绘制出来。
  • 如果用矢量图表述一个圆,则它只记录一个圆点和圆的半径即可。对于非矢量图,它会将圆上的每一个像素绘制出来。

通过上面两个例子,我想你已经很清楚矢量图与非矢量图之间的区别了。常见的 BITMAP、PNG、JPEG 这些类型的图形都属于非矢量图,它们都是通过绘制像素点构成的图形。也可以这么说,基本上我们日常生活中的图像都是非矢量图。

图形 / 图像渲染的基本原理

在讲解图形 / 图像渲染原理之前,你需要先知道什么是分辨率,实际上分辨率的概念非常简单,就是像素的宽高值,我想你应该是非常清楚了。

分辨率也是显示器的一个重要指标,现在显示器的分辨率越来越高,以前能达到 1080P 就觉得非常牛了(且价格不菲),但现在 2K 屏已经成为主流,甚至 4K 屏也不是特别稀奇的了。

比如,我的显示器的分辨率是 1920 * 1080,这里的 1920 和 1080 指的是显示器有 1920 列和 1080 行,也就是说我的显示器是由 1920 * 1080 个像素组成的阵列。每一个像素由 R、G、B 三种颜色组成,将这样成千上万的像素连接起来,就形成了一幅图像。

如果你想将一幅图在屏幕上显示出来,那么只需要将图形转换成像素图,这样就可以通过连接起来的像素将图像展示在显示器上。以上这个过程就是图像渲染的过程,它有一个专业术语,叫做光栅化

除了上面讲到的光栅化这个术语之外,你可能还会经常看到“软渲染”“硬件加速”等术语。实际上,它们指的是由谁进行光栅化,是 CPU ,还是 GPU?所谓“软渲染”是通过 CPU 将图形数据光栅化;而“硬件加速”则是指通过 GPU 进行光栅化处理。

其实,在 GPU 出现之前,图形处理都是通过 CPU 完成的。只不过随着计算机图形图像技术在各个行业的广泛应用,尤其是 3D 图形处理需要大量的计算,所以产生了 GPU 芯片,专门用于图形处理,以加快图像渲染的性能,并将 CPU 释放出来。

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

img

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

  • 应用程序处理的图形数据都是保存在 System Memory 中的,也就是我们经常所说的主内存中。需要硬件处理的时候,先将 System Memory 中的图形数据拷贝到 Graphic Memory 中。
  • 然后,通过 CPU 指令通知 GPU 去处理图形数据。
  • GPU 收到指令后,从 Graphic Memory 读取图形数据,然后进行坐标变换、着色等一系列复杂的运算后形成像素图,也就是 Video Frame,会存储在缓冲区中。
  • 视频控制器从 Video Frame 缓冲区中获取 Video Frame,并最终显示在显示器上。

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 里是如何表述的 ,其实现原理图如下所示:

img

在 WebKit 中,实现 Canvas 的逻辑还是非常复杂的,不过通过上面的原理图你可以了解到,它是由以下几个模块组成:

  • CanvasRenderingContext2D 类,用于对 2D 图形渲染。
  • WebGLRenderingContext 类,用于对 3D 图形渲染。
  • 2D 图形渲染的底层使用了 Google 开源的 Skia 库。
  • 3D 图形渲染的底层使用的是 OpenGL,不过在 Windows 上使用的却是 D3D。

Skia库

Skia 库最早是由 Skia 公司开发, 2005 年被 Google 收购。Google 于 2007 年将其开源。Skia 库是开源的 2D 图形库,由 C++ 语言实现,目前用在 Chrome、Android、Firefox 等产品。

Skia 支持基于 CPU 的软渲染,同时也支持基于 OpenGL 等库的硬件加速。这个库性能比较高,而且方便使用,可以大大提高开发者的效率。

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 规范。

使用

HTML5 新增了 <Canvas> 元素,你把它想像成一块画布就可以了,其使用方式如下:

html
<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 来进行视频 / 图像的渲染了。

Canvas 渲染上下文(Render Context)

在浏览器内部,Canvas 是使用状态机进行管理的。几乎 Canvas 每个属性的改变都会导致其内部的状态发生变化,而各种视频 / 图形的绘制都是在不同的状态下进行的。所以你若要在 Canvas 上绘制图形,就首先要知道 Canvas 的状态,而这个状态如何获取到呢?

Canvas 提供了一个非常方便的方法,即 getConetext 方法。也就是说,Canvas 的状态机是在它的上下文中存放着的。那么,现在我们来看一下 getContext API 的格式吧,其格式如下:

js
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 是可选的,一般情况下不使用,除非你想使用一些高级特性。

js
...
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 方法就可以将图形绘制出来了。

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

img

兼容性

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

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

html
   <canvas id="canvas_id1" width="150" height="150">
     The browser doesn't support the canvas tag.
   </canvas>

Google 的 Chrome 浏览器已经默认支持 WebRTC 库了,因此 Chrome 浏览器之间已经可以进行音视频实时通信了。更让人欣喜的是 Google 还开源了 WebRTC 源码,此举不仅惊艳,而且非常伟大。WebRTC 源码的开放,为音视频实时通信领域从业者、爱好者提供了非常好的研究和学习的机会。

虽然“浏览器 + WebRTC”为广大用户提供了诸多便利,但当你开发产品时会发现,在浏览器上调试媒体流还是非常困难的。因为媒体通信涉及到了多个层面的知识,而浏览器更擅长的是处理 HTML 页面和 JavaScript 脚本,所以如果用它来分析媒体流的收发情况或者网络情况,就显得很困难了。

为了解决这个问题,Google 在它的 Chrome 浏览器中支持了 WebRTC 的统计分析功能,只要在 Chrome 浏览器的地址栏输入 “chrome://webrtc-internals/ ”,你就可以看到浏览器中正在使用的 WebRTC 的各种统计分析数据了,而且这些数据都是以可视化统计图表的方式展现在你面前的,从而大大方便了你分析媒体流的效率。

浏览器中的 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” 字样的选项,这样相关的统计图表就会展示出来了。

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

img

img

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

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

html
...
<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 代码中的

在“WebRTC 统计信息图(二)”中,我在图的右侧用红框选中了 webrtc_internals.js,在该脚本的源码中,我们能够看到在 webrtc_internals.js 脚本中调用了 getContext('2d')API,代码如下:

js
var context = this.canvas_.getContext('2d');  //获得canvas上下文
context.fillstyle = BACKGROUND_COLOR; //设置填充颜色
context.fillRect(0, 0, width, heigth); //设置填充区域

文本聊天

简介

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

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

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

RTCDataChannel 介绍

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

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

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

img

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

  • 可靠有序模式(TCP 模式):在这种模式下,消息可以有序到达,但同时也带来了额外的开销,所以在这种模式下消息传输会比较慢。
  • 不可靠无序模式(UDP 模式):在此种模式下,不保证消息可达,也不保证消息有序,但在这种模式下没有什么额外开销,所以它非常快。
  • 部分可靠模式(SCTP 模式):在这种模式下,消息的可达性和有序性可以根据业务需求进行配置。

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

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

js
var pc = new RTCPeerConnection(); //创建 RTCPeerConnection 对象
var dc = pc.createDataChannel("dc", options); //创建 RTCDataChannel对象

从上面的代码中可以看到 RTCDataChannel 对象是由 RTCPeerConnection 对象创建的,在创建 RTCDataChannel 对象时有两个参数。

第一个参数是一个标签(字符串),相当于给 RTCDataChannel 起了一个名字; 第二个参数是 options,其形式如下:

js
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 时,对上面所描述的这些事件都要进行处理,所以就形成了下面这样的代码模板:

js
...
dc.onerror = (error)=> { //出错
  ...
};

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

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

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

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

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

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

img

当 A 向 B 发消息时,JavaScript 会从输入框中提取文本,然后通过 RTCDataChannel 发送出去。实际上,文本通过 RTCDataChannel 发送出去后,最终是经过 RTCPeerConnection 传送出去的。同理,B 向 A 发送文本数据时也是同样的流程。另一方面,当 B 收到 A 发送过来的文本数据后,也要通过 RTCDataChannel 对象来接收文本数据。

对于 RTCDataChannel 对象的创建主要有 In-band 协商和 Out-of-band 协商两种方式。

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 对象可以在需要时自动创建,不需要应用程序做额外的逻辑处理。

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 流的话,那么你的数据通道就不能正常工作了。

添加事件

为页面上的每个按钮添加 onclick 事件,具体如下面的示例代码所示:

js
  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 用于关闭连接释放资源。

创建连接

用户在页面上点击 Start 按钮时,会调用 connectServer 方法。具体代码如下:

js
  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。其代码如下:

js
  var servers = {'iceServers': [{
        'urls': 'turn:youdomain:3478',
        'credential': "passwd",
        'username': "username"
      }]
  };
 
  pc = new RTCPeerConnection(servers, pcConstraint);
  pc.onicecandidate = handleIceCandidate; //收集候选者
  pc.ondatachannel = onDataChannelAdded;  //当对接创建数据通道时会回调该方法。

通过上面的代码就将 RTCPeerConnection 对象创建好了。

创建RTCDataChannel

当用户点击 Call 按钮时,会创建 RTCDataChannel,并发送 offer。具体代码如下:

js
  dc = pc.createDataChannel('sendDataChannel',
      dataConstraint); //一端主动创建 RTCDataChannel
  
  ...
  dc.onmessage = receivedMessage; //当有文本数据来时,回调该函数。
   
  pc.createOffer(setLocalAndSendMessage,
        onCreateSessionDescriptionError); //创建offer,如果成功,则在 setLocalAndSendMessage 函数中将 SDP 发送给远端

当其中一方创建了 RTCDataChannel 且通信双方完成了媒体协商、交换了 SDP 之后,另一端收到发送端的消息,ondatachannel 事件就会被触发。此时就会调用它的回调函数 onDataChannelAdded ,通过 onDataChannelAdded 函数的参数 event 你就可以获取到另一端的 RTCDataChannel 对象了。具体如下所示:

js
  function onDataChannelAdded(event) {
      dc = event.channel;
      dc.onmessage = receivedMessage;
      ...
  }

至此,双方就可以通过 RTCDataChannel 对象进行双向通信了。

数据的发送

数据的发送非常简单,当用户点击 Send 按钮后,文本数据就会通过 RTCDataChannel 传输到远端。其代码如下:

js
  function sendData() {
      var data = dataChannelSend.value;
      dc.send(data);
  }

而对于接收数据,则是通过 RTCDataChannel 的 onmessage 事件实现的。当该事件触发后,会调用 receivedMessage 方法。通过其参数就可以获取到对端发送的文本数据了。具体代码如下:

js
  function receivedMessage(e) {
      var msg = e.data;
      if (msg) {
          dataChannelReceive.value += "<- " + msg + "\n";
      } 
  };

文件传输

RTCDataChannel 对象的创建与在实时文本聊天中 RTCDataChannel 对象的创建基本是一致的,具体示例代码如下:

js
...
//创建 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 大小的文件)时就很容易失败。而设置了这两个参数后,传输大文件时基本就没再失败过了,由此可见这两个参数的重要性了。

通过 RTCDataChannel 对象接收数据

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

js
dc.onerror = (error)=> {
  ...
};

dc.onopen = ()=> {
  ...
};

dc.onclose = () => {
  ...
};

dc.onmessage = (event)=>{
  ...  
}
...

这四个事件的作用如下:

  • onerror,是指当发生连接失败时的处理逻辑;
  • onopen,是指当 datachannel 打开时的处理逻辑;
  • onclose,是指当 datachannel 关闭时的处理逻辑;
  • onmessage,是指当收到消息时的处理逻辑。

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

js
...
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 对象创建一个下载地址,这样接收端的用户就可以通过该地址获取到文件了。

文件的读取与发送

前面讲完了文件的接收,现在我们再来看一下文件的读取与发送。实际上这块逻辑也非常简单,代码如下:

js

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 函数的逻辑。

通过信令传递文件的基本信息

上面我已经将 RTCDataChannel 对象的创建、数据发送与接收的方法以及 JavaScript 对文件进行读取的操作向你做了详细的介绍。但还有一块儿重要的知识需要向你讲解,那就是:接收端是如何知道发送端所要传输的文件大小、类型以及文件名的呢?

js
...
//获取文件相关的信息
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 的格式将这些信息发给信令服务器。

信令服务器收到该消息后不做其他处理,直接转发到接收端。下面是接收端收到消息后的处理逻辑,代码如下:

js
...
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 时,说明对方已经将要传输文件的基本信息发送过来了。

数据传输的安全

非对称加密

目前对于数据的安全保护多采用非对称加密,这一方法在我们的日常生活中被广泛应用。那什么是非对称加密呢?下面我就向你简要介绍一下。 在非对称加密中有两个特别重要的概念,即公钥与私钥。它们起到什么作用呢?这里我们可以结合一个具体的例子来了解一下它们的用处。

有一个人叫小 K,他有一把特制的锁,以及两把特制的钥匙——公钥和私钥。这把锁有个非常有意思的特点,那就是:用公钥上了锁,只能用私钥打开;而用私钥上的锁,则只能公钥打开。

这下好了,小 K 正好交了几个异性笔友,他们在书信往来的时候,难免有一些“小秘密”不想让别人知道。因此,小 K 多造了几把公钥,给每个笔友一把,当笔友给他写好的书信用公钥上了锁之后,就只能由小 K 打开,因为只有小 K 有私钥(公钥上的锁只有私钥可以打开),这样就保证了书信内容的安全。

从这个例子中,你可以看到小 K 的笔友使用公钥对内容进行了加密,只有小 K 可以用自己手中的私钥进行解密,这种对同一把锁使用不同钥匙进行加密 / 解密的方式称为非对称加密,而对称加密则使用的是同一把钥匙,这是它们两者之间的区别。

数字签名

了解了非对称加密,接下来你就可以很容易理解什么是数字签名了。

首先我们来讲一下数字签名是解决什么问题的。实际上,数字签名并不是为了防止数据内容被盗取的,而是解决如何能证明数据内容没有窜改的问题。为了让你更好地理解这个问题,我们还是结合具体的例子来说明吧。

数字证书

实际上,在数字签名中我们是假设小 K 的朋友们手里的公钥都是真的公钥,如果这个假设条件成立的话,那整个流程运行就没有任何问题。但是否有可能她们手里的公钥是假的呢?这种情况还是存在很大可能性的。

那该如何避免这种情况发生呢?为了解决这个问题,数字证书就应运而生了。

小 K 的朋友们为了防止自己手里的公钥被冒充或是假的,就让小 K 去“公证处”(即证书授权中心)对他的公钥进行公证。“公证处”用自己的私钥对小 K 的公钥、身份证、地址、电话等信息做了加密,并生成了一个证书。

这样小 K 的朋友们就可以通过“公证处”的公钥及小 K 在“公证处”生成的证书拿到小 K 的公钥了,从此再也不怕公钥被假冒了。

到这里,从非对称加密,到数字签名,再到数字证书就形成了一整套安全机制。在这个安全机制的保护下,就没人可以非法获得你的隐私数据了。

X509

了解了互联网的整套安全机制之后,接下来我们再来看一下真实的证书都包括哪些内容。这里我们以 X509 为例。X509 是一种最通用的公钥证书格式。它是由国际电信联盟(ITU-T)制定的国际标准,主要包含了以下内容:

  • 版本号,目前的版本是 3。
  • 证书持有人的公钥、算法(指明密钥属于哪种密码系统)的标识符和其他相关的密钥参数。
  • 证书的序列号,是由 CA 给予每一个证书分配的唯一的数字型编号。 ……

webrtc保证数据安全机制

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

但这里有一个问题,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 交换公钥时被窃取的问题解决后,是不是双方通信就安全了呢?

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

c++
...
a=ice-ufrag:khLS
a=ice-pwd:cxLzteJaJBou3DspNaPsJhlQ
a=fingerprint:sha-256 FA:14:42:3B:C7:97:1B:E8:AE:0C2:71:03:05:05:16:8F:B9:C7:98:E9:60:43:4B:5B:2C:28:EE:5C:8F3
...

SDP 交换完成后,A 与 B 都获取到了对方的 ice-ufrag、ice-pwd 和 fingerprint 信息,有了这些信息后,就可验证对方是否是一个合法用户了。

其中, ice-ufrag 和 ice-pwd 是用户名和密码。当 A 与 B 建立连接时,A 要带着它的用户名和密码过来,此时 B 端就可以通过验证 A 带来的用户名和密码与 SDP 中的用户名和密码是否一致的,来判断 A 是否是一个合法用户了。

除此之外,fingerprint 也是验证合法性的关键一步,它是存放公钥证书的指纹(或叫信息摘要),在通过 ice-ufrag 和 ice-pwd 验证用户的合法性之余,还要对它发送的证书做验证,看看证书在传输的过程中是否被窜改了。

通过上面的描述你就可以知道 WebRTC 在数据安全方面做了非常多的努力了。下面的序列图就清楚地表述了我上面所讲述的内容。

img

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

  • 首先通过信令服务器交换 SDP 信息,也就是进行媒体协商。在 SDP 中记录了用户的用户名、密码和指纹,有了这些信息就可以对用户的身份进行确认了。
  • 紧接着,A 通过 STUN 协议(底层使用 UDP 协议)进行身份认证。如果 STUN 消息中的用户名和密码与交换的 SDP 中的用户名和密码一致,则说明是合法用户。
  • 确认用户为合法用户后,则需要进行 DTLS 协商,交换公钥证书并协商密码相关的信息。同时还要通过 fingerprint 对证书进行验证,确认其没有在传输中被窜改。
  • 最后,再使用协商后的密码信息和公钥对数据进行加密,开始传输音视频数据。

前面我们说了 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 协议中,最关键是的它的握手协议,正如下图所展示的这样:

img

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

DTLS 的握手过程如下:

  • 首先 DTLS 协议采用 C/S 模式进行通信,其中发起请求的一端为客户端,接收请求的为服务端。
  • 客户端向服务端发送 ClientHello 消息,服务端收到请求后,回 ServerHello 消息,并将自己的证书发送给客户端,同时请求客户端证书。
  • 客户端收到证书后,将自己的证书发给服务端,并让服务端确认加密算法。
  • 服务端确认加密算法后,发送 Finished 消息,至此握手结束。

使用

安装cron

coturn 的编译安装与部署还是比较简单的。首先我们来看看如何编译安装 coturn,其步骤如下:

接下来是布署,coturn 的配置文件中有很多选项,不过这些选项大部分都可以不用,或采用默认值,因此我们只需要修改 4 项内容就可以将 coturn 服务配置好了,这 4 项配置如下:

img

不过需要注意的是,我们系统采集音视频数据的时间点与以前不一样了,以前是在浏览器显示页面时就开始采集了,而现在则是在用户点击“Connect Sig Server”按钮时才开始采集音视频数据。

创建 RTCPeerConnection

信令系统建立好后,后面的逻辑都是围绕着信令系统建立起来的,RTCPeerConnection 对象也不例外。

在客户端,用户要想与远端通话,首先要发送 join 消息,也就是要先进入房间。此时,如果服务器判定用户是合法的,则会给客户端回 joined 消息。

客户端收到 joined 消息后,就要创建 RTCPeerConnection 对象了,也就是要建立一条与远端通话的音视频数据传输通道。

下面,我们就结合示例代码来看一下 RTCPeerConnection 是如何建立的。

js
...
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 对象将音视频数据传输出去。绑定的步骤如下:

js
function bindTracks(){
  ...
  //add all track into peer connection
  localStream.getTracks().forEach((track)=>{
    pc.addTrack(track, localStream); //将track与peerconnection绑定
  });
}

在上面的代码中,从 localStream 中遍历所有的 track,然后添加到 RTCPeerConnection 对象中就好了。

按照上面的步骤,音视频数据就可以被采集到了,RTCPeerConnection 对象也创建好了,通过信令服务器也可以将各端的 Candidate 交换完成了。

此时在 WebRTC 的底层就会进行连通性检测,它首先判断通信的双方是否在同一个局域网内,如果在同一个局域网内,则让双方直接进行连接;如果不在同一局域网内,则尝试用 P2P 连接,如果仍然不成功,则使用 TURN 服务进行数据中继。

一旦数据连通后,数据就从一端源源不断地传到了远端,此时远端只需要将数据与播放器对接,就可以看到对端的视频、听到对方的声音了。

在浏览器上实现这一点非常容易,当数据流过来的时候会触发 RTCPeerConnection 对象的 ontrack 事件,只要我们侦听该事件,并在回调函数中将收到的 track 与<video>标签绑定到一起就好了,代码如下:

js
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 属性,这就可以了。

多人音视频实时通话

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 不对音视频进行混流,收到某个终端共享的音视频流后,就直接将该音视频流转发给房间内的其他终端。它实际上就是一个音视频路由转发器。

Mesh 方案

1 对 1 通信模型下,两个终端可以互相连接,那么我们是否可以让多个终端互相连接起来,从而实现多人通信呢?理论上这是完全可行的。Mesh 方案的结构如下图所示:

img

在上图中,B1、B2、B3、B4 分别表示 4 个浏览器,它们之间两两相连,同时还分别与 STUN/TURN 服务器进行连接(此时的 STUN/TURN 服务器不能进行数据中转,否则情况会变得非常复杂),这样就形成了一个网格拓扑结构。

当某个浏览器想要共享它的音视频流时,它会将共享的媒体流分别发送给其他 3 个浏览器,这样就实现了多人通信。这种结构的优势有如下:

  • 不需要服务器中转数据,STUN/TUTN 只是负责 NAT 穿越,这样利用现有 WebRTC 通信模型就可以实现,而不需要开发媒体服务器。
  • 充分利用了客户端的带宽资源。
  • 节省了服务器资源,由于服务器带宽往往是专线,价格昂贵,这种方案可以很好地控制成本。

当然,有优势自然也有不足之处,主要表现如下:

共享端共享媒体流的时候,需要给每一个参与人都转发一份媒体流,这样对上行带宽的占用很大。参与人越多,占用的带宽就越大。除此之外,对 CPU、Memory 等资源也是极大的考验。一般来说,客户端的机器资源、带宽资源往往是有限的,资源占用和参与人数是线性相关的。这样导致多人通信的规模非常有限,通过实践来看,这种方案在超过 4 个人时,就会有非常大的问题。 另一方面,在多人通信时,如果有部分人不能实现 NAT 穿越,但还想让这些人与其他人互通,就显得很麻烦,需要做出更多的可靠性设计。

MCU 方案

MCU 主要的处理逻辑是:接收每个共享端的音视频流,经过解码、与其他解码后的音视频进行混流、重新编码,之后再将混好的音视频流发送给房间里的所有人。

MCU 技术在视频会议领域出现得非常早,目前技术也非常成熟,主要用在硬件视频会议领域。不过我们今天讨论的是软件 MCU,它与硬件 MCU 的模型是一致的,只不过一个是通过硬件实现的,另一个是通过软件实现的罢了。MCU 方案的模型是一个星形结构,如下图所示:

img

我们来假设一个条件,B1 与 B2 同时共享音视频流,它们首先将流推送给 MCU 服务器,MCU 服务器收到两路流后,分别将两路流进行解码,之后将解码后的两路流进行混流,然后再编码,编码后的流数据再分发给 B3 和 B4。

对于 B1 来说,因为它是其中的一个共享者,所以 MCU 给它推的是没有混合它的共享流的媒体流,在这个例子中就是直接推 B2 的流给它。同理,对于 B2 来说 MCU 给它发的是 B1 的共享流。但如果有更多的人共享音视频流,那情况就更加复杂。

MCU 主要的处理逻辑如下图所示:

img

这个处理过程如下所示:

  1. 接收共享端发送的音视频流。
  2. 将接收到的音视频流进行解码。
  3. 对于视频流,要进行重新布局,混合处理。
  4. 对于音频流,要进行混音、重采样处理。
  5. 将混合后的音视频进行重新编码。
  6. 发送给接收客户端。

那 MCU 的优势有哪些呢?大致可总结为如下几点:

  • 技术非常成熟,在硬件视频会议中应用非常广泛。
  • 作为音视频网关,通过解码、再编码可以屏蔽不同编解码设备的差异化,满足更多客户的集成需求,提升用户体验和产品竞争力。
  • 将多路视频混合成一路,所有参与人看到的是相同的画面,客户体验非常好。

同样,MCU 也有一些不足,主要表现为:

重新解码、编码、混流,需要大量的运算,对 CPU 资源的消耗很大。 重新解码、编码、混流还会带来延迟。 由于机器资源耗费很大,所以 MCU 所提供的容量有限,一般十几路视频就是上限了。

SFU 方案

SFU 像是一个媒体流路由器,接收终端的音视频流,根据需要转发给其他终端。SFU 在音视频会议中应用非常广泛,尤其是 WebRTC 普及以后。支持 WebRTC 多方通信的媒体服务器基本都是 SFU 结构。SFU 的拓扑机构和功能模型如下图:

img

在上图中,B1、B2、B3、B4 分别代表 4 个浏览器,每一个浏览器都会共享一路流发给 SFU,SFU 会将每一路流转发给共享者之外的 3 个浏览器。

下面这张图是从 SFU 服务器的角度展示的功能示意图:

img

相比 MCU,SFU 在结构上显得简单很多,只是接收流然后转发给其他人。然而,这个简单结构也给音视频传输带来了很多便利。比如,SFU 可以根据终端下行网络状况做一些流控,可以根据当前带宽情况、网络延时情况,选择性地丢弃一些媒体数据,保证通信的连续性。

目前许多 SFU 实现都支持 SVC 模式和 Simulcast 模式,用于适配 WiFi、4G 等不同网络状况,以及 Phone、Pad、PC 等不同终端设备。

SFU 的优势有哪些呢?可总结为如下:

  • 由于是数据包直接转发,不需要编码、解码,对 CPU 资源消耗很小。
  • 直接转发也极大地降低了延迟,提高了实时性。
  • 带来了很大的灵活性,能够更好地适应不同的网络状况和终端类型。

同样,SFU 有优势,也有不足,主要表现为:

  • 由于是数据包直接转发,参与人观看多路视频的时候可能会出现不同步;
  • 相同的视频流,不同的参与人看到的画面也可能不一致。
  • 参与人同时观看多路视频,在多路视频窗口显示、渲染等会带来很多麻烦,尤其对多人实时通信进行录制,多路流也会带来很多回放的困难。总之,整体在通用性、一致性方面比较差。

另外,在上面介绍 SFU 方案时,我们还提到了视频的 Simulcast 模式和 SVC 模式,下面我就这两个知识点再向你做一下讲解,来看一下这两种视频的处理模式对 SFU 架构来说都带来了哪些好处。

Simulcast 模式

所谓 Simulcast 模式就是指视频的共享者可以同时向 SFU 发送多路不同分辨率的视频流(一般为三路,如 1080P、720P、360P)。而 SFU 可以将接收到的三路流根据各终端的情况而选择其中某一路发送出去。例如,由于 PC 端网络特别好,给 PC 端发送 1080P 分辨率的视频;而移动网络较差,就给 Phone 发送 360P 分辨率的视频。

Simulcast 模式对移动端的终端类型非常有用,它可以灵活而又智能地适应不同的网络环境。下图就是 Simulcast 模式的示意图:

img

SVC 模式

SVC 是可伸缩的视频编码模式。与 Simulcast 模式的同时传多路流不同,SVC 模式是在视频编码时做“手脚”。

它在视频编码时将视频分成多层——核心层、中间层和扩展层。上层依赖于底层,而且越上层越清晰,越底层越模糊。在带宽不好的情况下,可以只传输底层,即核心层,在带宽充足的情况下,可以将三层全部传输过去。

如下图所示,PC1 共享的是一路视频流,编码使用 SVC 分为三层发送给 SFU。SFU 根据接收端的情况,发现 PC2 网络状况不错,于是将 0、1、2 三层都发给 PC2;发现 Phone 网络不好,则只将 0 层发给 Phone。这样就可以适应不同的网络环境和终端类型了。

img

常见的流媒体服务器

当然,你也可以自己实现 SFU 流媒体服务器,但自已实现流媒体服务器困难还是蛮多的,它里面至少要涉及到 DTLS 协议、ICE 协议、SRTP/SRTCP 协议等,光理解这些协议就要花不少的时间,更何况要实现它了。

Licode

Licode 既可以用作 SFU 类型的流媒体服务器,也可以用作 MCU 类型的流媒体服务器。一般情况下,它都被用于 SFU 类型的流媒体服务器。 Licode 不仅仅是一个流媒体通信服务器,而且还是一个包括了媒体通信层、业务层、用户管理等功能的完整系统,并且该系统还支持分布式部署。 Licode 是由 C++ 和 Node.js 语言实现。其中,媒体通信部分由 C++ 语言实现,而信令控制、用户管理、房间管理用 Node.js 实现。它的源码地址为:https://github.com/lynckia/licode 。下面这张图是 Licode 的整体架构图:

img

GStreamer

  • 使用语言:C
  • 特色:GStreamer是一个强大的跨平台多媒体框架,支持多种音频、视频处理、过滤、编码、解码、封装和传输功能。
  • 性能指标:高度模块化设计,可以灵活处理各种媒体任务,广泛应用于音频视频处理和流媒体服务。
  • 链接地址:https://gstreamer.freedesktop.org/

FFmpeg

  • 使用语言:C
  • 特色:FFmpeg是一个全面的音频/视频处理工具集,包含了大量的编解码器、格式解析器、过滤器和其它工具,能够完成转码、封装、解封装、抓取和流处理等工作。
  • 性能指标:强大而高效的编解码能力和广泛的支持格式使其成为流媒体服务中的关键组件。
  • 链接地址:https://ffmpeg.org/
  • 基本使用方法:通常通过命令行工具调用,也可以作为库嵌入到应用程序中,通过API函数进行音频视频处理。

Kurento Media Server (KMS)

  • 使用语言:C++ 和 JavaScrip
  • 特色:KMS是一个开源的媒体服务器,提供了媒体处理功能,如录制、流传输、混音、转码等,同时支持WebRTC。
  • 性能指标:用于实时通信和媒体处理,适合构建视频会议、直播等应用。
  • 链接地址:https://www.kurento.org/
  • 基本使用方法:通过WebSocket接口与KMS通信,通过Kurento Client API控制服务器端的媒体元素。

nginx-rtmp-module

  • 使用语言:C
  • 特色:这是一个针对Nginx服务器的RTMP模块,允许通过Nginx服务器实现流媒体推拉流服务。
  • 性能指标:借助Nginx的高性能和稳定性,支持RTMP协议的直播和点播服务。
  • 链接地址:https://github.com/arut/nginx-rtmp-module
  • 基本使用方法:在Nginx配置文件中启用和配置该模块,然后通过RTMP协议推送和拉取媒体流。

SRS

  • 使用语言:C++
  • 特色:SRS是一款高性能、简单易用的开源RTMP服务器,支持多种流媒体协议,如RTMP、HLS、HDS等。
  • 性能指标:设计简洁高效,适合大规模流媒体应用场景。
  • 链接地址:https://github.com/ossrs/srs
  • 基本使用方法:下载源代码编译安装,然后通过配置文件设定流媒体服务的各项参数,并启动服务器服务。

Pion WebRTC

使用语言:Go

特色:Pion WebRTC是一个完全用Go编写的WebRTC库,支持创建实时通信应用,包括音频、视频和数据流传输。

性能指标:充分利用Go语言的并发特性,实现高效率、低延迟的实时通信。

链接地址:https://github.com/pion/webrtc

基本使用方法:通过导入Pion WebRTC库,在应用程序中创建PeerConnection实例,设置本地和远程描述符,然后实现音视频流的发送和接收。

Jitsi Videobridge

  • 使用语言:Java
  • 特色:Jitsi Videobridge是一个基于WebRTC的Selective Forwarding Unit (SFU),专为大规模视频会议设计,可以减少网络带宽消耗。
  • 性能指标:支持大量并发用户进行视频会议,有效提高视频质量和降低延迟。
  • 链接地址:https://jitsi.org/jitsi-videobridge/
  • 基本使用方法:配合Jitsi Meet或其他WebRTC客户端使用,通过XMPP信令系统控制视频桥接服务。

MediaSoup

  • 使用语言:C++
  • 特色:MediaSoup是一个高性能的WebRTC SFU(Selective Forwarding Unit),用于构建大规模实时通信应用。
  • 性能指标:支持低延迟、高并发的实时音视频通信,具有完善的API接口。
  • 链接地址:https://mediasoup.org/
  • 基本使用方法:通过Node.js API与MediaSoup服务器进行交互,实现客户端与服务器间的WebRTC信号交换和媒体流转发。

Ant Media Server

  • 使用语言:Java
  • 特色:Ant Media Server是一个企业级的实时流媒体服务器,支持WebRTC、RTMP、HLS等多种协议,提供低延迟、大规模并发的能力,适合实时通信、直播、视频会议等场景。
  • 链接地址:https://antmedia.io/
  • 基本使用方法:通过下载安装包安装并配置服务器,随后可以通过HTTP API或管理控制台进行管理和部署流媒体服务。

Flussonic Media Server

  • 使用语言:C++
  • 特色:Flussonic Media Server是一个强大的流媒体服务器,支持多种视频和音频格式,提供实时转码、录制、存储、安全传输等功能,广泛应用于IPTV、OTT、监控等领域。
  • 链接地址:https://flussonic.com/
  • 基本使用方法:按照官方指南安装服务器后,通过配置文件和管理界面设置流媒体服务的各种参数。

Harmonic ProMedia Suite

使用语言:C++

特色:Harmonic ProMedia Suite(部分模块可能开源)提供了一系列流媒体处理解决方案,包括转码、打包、存储、分发等,支持多种协议和格式,适合构建大型视频内容管理系统。

链接地址:https://www.harmonicinc.com/products/pro-media-suite

注意:Harmonic的产品组合中有开源和闭源的部分,对于开源部分,请查阅其官方文档以获取更多信息。

coturn

使用语言:C

特色:coturn是一个开源的STUN/TURN服务器,用于协助WebRTC服务穿透NAT设备,确保两个终端之间的直接通讯,是构建实时通信应用的重要基础设施。

链接地址:https://github.com/coturn/coturn

基本使用方法:安装coturn服务器后,配置STUN和TURN服务,以便在WebRTC会话中作为中间人转发媒体流。

ZLMediaKit

使用语言:C++

特色:ZLMediaKit是一个高性能的实时流媒体服务器框架,支持RTSP/RTMP/HLS/HTTP-FLV/WebSocket-FLV等多种协议,可以实现音视频采集、处理、分发等功能。

链接地址:https://gitcode.net/ZLMediaKit/ZLMediaKit

基本使用方法:下载源码编译安装,根据项目文档配置和运行服务器。

Red5

使用语言:Java

特色:Red5是一个开源的Flash流媒体服务器,支持RTMP协议,可以实现视频直播、点播、实时聊天和录制等功能。

链接地址:http://red5.org/

基本使用方法:下载并配置Red5服务器,通过简单的Java类和接口进行扩展,可以创建自定义的应用程序以满足特定需求。

CherryPy Video Streaming

使用语言:Python

特色:CherryPy是一个面向对象的Python Web框架,可以用来构建流媒体服务端,尤其是基于HTTP协议的视频流服务,例如使用MJPEG或HLS协议。

链接地址:https://cherrypy.org/

基本使用方法:使用CherryPy编写Web应用,结合Python的视频处理库(如opencv-python)处理视频流,然后通过HTTP响应返回给客户端。

Nginx HLS Module

使用语言:C

特色:尽管Nginx本身并不直接提供流媒体服务,但通过安装Nginx HTTP Live Streaming (HLS)模块,可以将Nginx作为一个HLS视频分发服务器。

链接地址:https://github.com/arut/nginx-rtmp-module/wiki/Directives#hls

基本使用方法:在Nginx服务器上安装并配置rtmp模块,通过rtmp协议接收流,然后自动转码并生成HLS片段供客户端播放。

GStreamer Plugins for Streaming

使用语言:C

特色:GStreamer虽然主要是一个媒体框架,但它也包含了丰富的插件库,可以用作流媒体服务端的基础。通过组合不同的GStreamer插件,可以实现各种流媒体服务功能。

链接地址:https://gstreamer.freedesktop.org/

基本使用方法:构建GStreamer管道,将输入的媒体流通过适当的编码、封装等处理后,通过HTTP、RTSP等协议推送到网络。

多人聊天

img

从图中可以看出,WebRTC 客户端在多方通信中的基本处理逻辑是这样的:

  • 首先创建 RTCPeerConnection 对象,用于与服务端传输音视频数据;

  • 紧接着客户端与服务端建立 WebSocket 连接,建立好之后,双方就可以进行信令通信了;

  • 当用户发送 join 消息给服务器,并成功加入到房间之后,客户端就可以调用 getUserMedia 进行音视频数据的采集了;

  • 数据采集到之后,要与之前创建好的 RTCPeerConnection 进行绑定,然后才能通过 RTCPeerConnection 实例创建 Offer/Answer 消息,并与服务器端进行媒体协商;

  • 媒体协商完成后,客户端就可以将音视频数据源源不断发送给 Medooze 服务器了;

  • 当有其他 WebRTC 客户端进入到房间后,它们的音视频流也会通过 RTCPeerConnection 传送给早已加入到房

    里的 WebRTC 终端,此时 WebRTC 终端会收到 onRemoteTrack 消息,然后创建 HTML5 的<video>标签将它们显示出来就好了。

除此之外,在上图中,你应该还注意到一个 update 信令,这个信令在多方通信中是至关重要的。下面我们就来讲解一下 update 信令的作用。

在进行多方通信时,第一个 WebRTC 客户端已经加入到房间里了,接着第二个用户开始加入。当第二个用户加入时,它可以获得第一个用户共享的音视频流,但对于第一个用户来说,它是否能获得第二个用户的音视频流呢?显然获取不到。获取不到的原因是什么呢?因为第一个用户与服务器之间进行媒体协商时,它还不知道有第二个用户,这样当第二个用户进来时,如果不与服务器重新进行媒体协商的话,它是不知道房间里已经有其他人共享了音视频流的。

那如何解决这个问题呢?解决的办法就是每当有新的用户进来之后,就通过 update 信令通知已经在房间内的所有用户,让它们重新与服务器进行媒体协商。重新协商后,所有老用户就可以收到了新用户的视频流了。

img

万人同时在线的直播系统

从技术角度来讲,映客、斗鱼这类娱乐直播与在线教育、音视频会议直播有着非常大的区别。在线教育、音视频会议这类直播属于实时互动直播,主要考虑传输的实时性,因此一般使用 UDP 作为底层传输协议;而娱乐直播对实时性要求不高,更多关注的是画面的质量、音视频是否卡顿等问题,所以一般采用 TCP 作为传输协议。我们称前者为实时互动直播,后者为传统直播。

本专栏的前两个模块都是介绍实时互动直播的,而从今天开始我们会讲解传统直播技术。

传统直播技术使用的传输协议是 RTMP 和 HLS。其中,RTMP 是由 Adobe 公司开发的,虽然不是国际标准,但也算是工业标准,在 PC 占有很大的市场;而 HLS 是由苹果公司开发的,主要用在它的 iOS 平台,不过 Android 3 以后的平台也是默认支持 HLS 协议的。

传统直播基本架构

商业级直播系统的规模、结构是非常复杂的,除了最核心的音视频直播外,还包括用户管理、认证系统、直播间管理、打赏、红包、私信等很多功能,不过这些更多的是一些业务逻辑,在本文中我们不会对它们进行讲解,而是聚焦在最核心的音视频直播技术上。 我们先来看一下传统直播的基本架构图,如下图所示:

接下来,我们先看一下传统音视频直播系统的基本架构,让你对传统直播架构的“内幕”有一个初步的了解。

img

从图中可以看出,传统直播架构由直播客户端、信令服务器和 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 云服务。

img

而每个运营商云服务内部包括了多个节点,按功能分为 3 类。

源节点,用于接收用户推送的媒体流。 主干结点,起到媒体数据快速传递的作用,比如与其他运营商传送媒体流。 过缘节点,用于用户来主动接流。一般边缘节点的数量众多,但机子的性能比较低,它会被布署到各地级市,主要解决网络最后一公里的问题。

接下来,我们简要描述一下 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 协议细节的文章,本文就不再详述了。

优势

RTMP 协议在苹果公司宣布其产品不支持 RTMP 协议,且推出 HLS 技术来替代 RTMP 协议的“打压”下,已停止更新。但协议停止更新后,这么多年仍然屹立不倒,说明该协议肯定有它独特的优势。那有哪些呢?

  • RTMP 协议底层依赖于 TCP 协议,不会出现丢包、乱序等问题,因此音视频业务质量有很好的保障。
  • 使用简单,技术成熟。有现成的 RTMP 协议库实现,如 FFmpeg 项目中的 librtmp 库,用户使用起来非常方便。而且 RTMP 协议在直播领域应用多年,技术已经相当成熟。
  • 市场占有率高。在日常的工作或生活中,我们或多或少都会用到 RTMP 协议。如常用的 FLV 文件,实际上就是在 RTMP 消息数据的最前面加了 FLV 文件头。
  • 相较于 HLS 协议,它的实时性要高很多。

劣势

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 理论上至少会有一个切片的延迟。

劣势

HLS 是为了解决 RTMP 协议中存在的一些问题而设计的,所以,它自然有自己的优势。主要体现在以下几方面:

  • RTMP 协议没有使用标准的 HTTP 接口传输数据,在一些有访问限制的网络环境下,比如企业网防火墙,是没法访问外网的,因为企业内部一般只允许 80/443 端口可以访问外网。而 HLS 使用的是 HTTP 协议传输数据,所以 HLS 协议天然就解决了这个问题。
  • HLS 协议本身实现了码率自适应,不同带宽的设备可以自动切换到最适合自己码率的视频进行播放。
  • 浏览器天然支持 HLS 协议,而 RTMP 协议需要安装 Flash 插件才能播放 RTMP 流。

HLS 最主要的问题就是实时性差。由于 HLS 往往采用 10s 的切片,所以最小也要有 10s 的延迟,一般是 20~30s 的延迟,有时甚至更差。 HLS 之所以能达到 20~30s 的延迟,主要是由于 HLS 的实现机制造成的。HLS 使用的是 HTTP 短连接,且 HTTP 是基于 TCP 的,所以这就意味着 HLS 需要不断地与服务器建立连接。TCP 每次建立连接时都要进行三次握手,而断开连接时,也要进行四次挥手,基于以上这些复杂的原因,就造成了 HLS 延迟比较久的局面。

如何选择

分析完 RTMP 和 HLS 各自的优势和劣势后,接下来我们就结合项目实践,对这二者的使用场景做一些建议。 流媒体接入,也就是推流,应该使用 RTMP 协议。 流媒体系统内部分发使用 RTMP 协议。因为内网系统网络状况好,使用 RTMP 更能发挥它的高效本领。 在 PC 上,尽量使用 RTMP 协议,因为 PC 基本都安装了 Flash 播放器,直播效果要好很多。 移动端的网页播放器最好使用 HLS 协议。 iOS 要使用 HLS 协议,因为不支持 RTMP 协议。 点播系统最好使用 HLS 协议。因为点播没有实时互动需求,延迟大一些是可以接受的,并且可以在浏览器上直接观看。

1对多直播系统的必备协议

  • HLS 是苹果开发的协议,苹果产品原生支持此协议;
  • HLS 是基于 HTTP 的,可以不受防火墙限制,所以它的连通性会更好;
  • HLS 还能根据客户的网络带宽情况进行自适应码率的调整,这对于很多用户来说是非常有吸引力的。

基于以上原因,我们有必要从 HLS 直播架构、FFmpeg 生成 HLS 切片、HLS m3u8 格式和 HLS TS 格式这四个方面对 HLS 协议的细节做一下介绍。

HLS 直播架构

下面我们来看一下 HLS 直播系统的架构图,如下所示:

img

我们在上一篇文章中讲过,传统直播系统大致分为三部分:直播客户端、信令服务和 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 的命令如下:

bash
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 文件内容展示一下,以便让你有个感观的认识。内容如下:

bash
#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 开头,如上面示例中的内容所示。

  • 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 数据流示意图如下所示:

img

我们再进一步细化,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 数据流的格式,如下图所示:

img

这是 TS Header 各个字段的详细说明,图中数字表示长度,如果数字后面带有 bytes ,单位就是 bytes;否则,单位都是 bit。 TS Header 分为 8 个字段,下面我们分别解释一下:

img下面我们就对这些常用的字段一一做下解释,当然也还有很多不常用的字段,我们这里就不列出来了,如有需求,可参考 ISO-IEC 13818-1 2.4.3.7 节。

PES Packet 作为 TS 数据流的 Payload,也有自己的 Header,如下图所示:

img

另外,PTS(Presentation Tmestamp) 字段总共包含了 40 bit,高 4 个 bit 固定取值是 0010;剩下的 36 个 bit 分三部分,分别是:3 bit+1 bit 标记位;15 bit+1 bit 标记位;15 bit+1 bit 标记位。 通过以上的描述我们就将 HLS 协议中最重要的 TS 数据流向你介绍清楚了。

FLV格式

我们先来看一下 FLV 的文件格式,如下图所示:

img

这是我阅读FLV 格式规范文档后,总结出的 FLV 文件格式结构图。从图中我们可以看出,FLV 文件格式由 FLV Header 和 FLV Body 两部分组成。其中,FLV Header 由 9 个字节组成,Body 由 Pre TagSize 和 Tag 组成。

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。详细的含义可以参考下面张图表:

img

这张图表清晰地表达了 FLV Header 中每个域所占的字节以及该域的具体含义。另外,如果你使用的是 Windows 系统,就可以安装 FlvAnalyzer 工具,该工具是一款功能非常强大的 FLV 文件分析工具。使用它打开任何一个 FLV 文件,就可以看到该文件的 FLV 格式。

FLV body

从“FLV 文件格式结构图”我们可以看出,FLV Body 是由多个 Previous TagSize 和 Tag 组成的。其含义如下图表所示,其中 PreviousTagSize 占 4 个字节,表示前一个 Tag 的大小。这里需要注意的是,第一个 Previous TagSize 比较特殊,由于它前面没有 Tag 数据,所以它的值必须为 0。

img

接下来我们再来看一下 FLV 中的 Tag,从 FLV 文件格式结构图中我们可以看到 Tag 由两部分组成,即 Tag Header 和 Tag Data 。 Tag Header 各字段的含义如下图所示:

img

  • 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 协议中截取的协议头:

img

因此,我们可以说 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 文件相较于其他多媒体文件特别适合于在录制中使用。

媒体服务器有两个功能: 推流功能,可以让客户端通过 RTMP 协议将音视频流推送到媒体服务器上; 拉流功能,可以让客户端从媒体服务器上拉取 RTMP/HLS 流。

使用flv.js播放

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 文件转成 ISO BMFF(MP4 片段)片段,然后通过浏览器的 Media Source Extensions 将 MP4 片段播放出来。具体的处理过程如下图所示:

img

从上图我们可以看出,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.js

triton-inference-server/server: The Triton Inference Server provides an optimized cloud and edge inferencing solution. (github.com)