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等协议推送到网络。

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