WebRTC编程——使用信令通道

253 阅读35分钟

在本章中,你将构建一个视频通话应用程序的基础,该应用程序将使用 WebRTC 连接两个对等端,使它们能够实时相互传输视频(最终还包括音频)。

你将首先使用语义化的 HTML 和 CSS 构建一个界面,包括使用少量的 flexbox 来进行布局。其中一些工作可能会显得非常精确和详细。但目标是构建一个简洁、易访问的界面,并且能够在所有类型的设备上响应式显示。在构建完界面后,你将进行必要的工作,通过 JavaScript 将其与浏览器中发生的常规事件(如点击)关联起来。这些事件最终会连接到信令通道和其他 WebRTC 逻辑。因此,首先构建界面是必要的。

在界面构建完成后,我们将看看 WebRTC 的点对点架构,以及它与我们更熟悉的 HTTP 和 HTTPS 客户端-服务器架构的不同之处。

接下来,我们将参观一下建立 WebRTC 对等连接所需的一个关键技术:信令通道。这将以一个你已经从本书配套代码中下载的小型服务器的形式呈现。在深入了解信令通道之后,你将编写一些基础代码,用于在你的应用程序中同时连接多个对等端对。

准备一个基础的点对点界面

让我们从构建一个新的网页应用的基础步骤开始:建立一个可行的用户界面概念,用语义化的 HTML 来结构化它,并使用足够的 CSS 进行样式设计,使其在所有支持 Web 的设备上都能响应式显示。这将为使用 JavaScript 来整合应用程序的工作铺平道路。

这里的界面工作将非常精确,可能远超过你在专门介绍 Web 开发主题的书籍中所期望的内容。参与一些前端设计工作的好处在于,当你向某人(如团队负责人、经理或客户)推销某个新 API 或想法时,你有一些看起来不错的东西可以展示,这比你在展示之前说“想象一下这看起来不是垃圾”要更有效地推销这个想法。

但同时考虑到我们作为开发者对最终用户的责任也不算太早:前端开发涉及精确处理用户必须与之交互的界面。精确的界面设计也是可访问性的基石,这对于实时通信技术来说,与为 Web 开发的任何其他内容一样重要。因此,当我们专门为用户构建某些东西时,无论底层 API 将我们带入多深的技术细节,都必须牢记用户,这是一种职业责任。对于 WebRTC 来说,这个技术细节确实很深。

由于底层的 WebRTC API 深入且复杂,你会看到我们会花相当多的时间处理一些 JavaScript 的核心基础,包括代码组织和回调函数的行为。这样做有两个目的:首先,随着 JavaScript 作为一种语言的不断进步,保持所有开发者对其新特性的一致理解变得更加困难,尤其是在这些特性与 JavaScript 的主流功能相关时。其次,由于 WebRTC API 的设计,我认为如果能将它们与纯 JavaScript 的连接更加明确,你会发现处理 WebRTC 众多事件驱动的方法和回调函数会更加高效。如果你日常使用 JavaScript 的方式是通过一个比传统 JavaScript 提供的更高级别的 JavaScript 框架,这一点尤其如此。

设计点对点界面模式

在你开始为视频通话应用编写代码之前,先想一想你在使用点对点应用时常见的界面类型。它们的界面通常基于两种模式之一:呼叫模式或加入模式。呼叫模式是不对称的:某些点对点应用,如 FaceTime 或 Skype,甚至电话,都依赖于一个用户拨打电话,另一个用户接听。例如,FaceTime 的界面对两端用户在通话内外时看起来是一样的。但是,在通话建立时,呼叫者和接听者的界面是不同的。当被呼叫者决定接听时,两个界面恢复为对称状态,显示通话;如果被呼叫者拒绝接听(或干脆忽略),两个界面将恢复到呼叫前的状态。

其他点对点应用,如 Google Meet 或 Zoom,使用的是对称的加入模式:这种应用不设置呼叫者和接听者的通话,而是将通话视为一个已存在的事物,用户可以随时准备好加入。界面的状态仅取决于用户正在做什么:准备加入通话、加入后的参与通话或离开通话。

基于 WebRTC 的界面可以采用呼叫模式或加入模式中的任意一种。但加入模式简化了界面,因为不需要创建一个处理来电的屏幕,或者连接一堆按钮来接听或拒绝电话。取而代之的是,每个用户只需要一个按钮——“加入通话”,而这个按钮将呈现给每个对等端。在加入模式中,我们甚至不需要考虑谁在呼叫谁。从通话的对等端的角度来看,没有人关心谁先加入了通话。作为应用程序的设计者,我们也不必关心。

添加标题和按钮

考虑到加入模式,你可以开始构建 HTML 和 CSS 以构建和设计应用程序。不需要为呼叫者或接听者专门担心特殊的 HTML、CSS 或 JavaScript:通话中的每个人都是加入者,因此每个人都使用相同的代码。

打开 www/basic-p2p/ 目录,找到其中的 index.html 文件,该文件已经链接了你将使用的 CSS 和 JavaScript 文件。首先,编写一个基本的标题,其中包含一级标题和一个用于加入通话的按钮元素。你可以将其写在 HTML 文件中的 <main> 元素内。该按钮将允许用户既能加入也能离开通话,但你应该最初将其设置为加入按钮。当应用程序首次加载时,用户需要加入通话:

demos/basic-p2p/index.html
<main id="interface">
  <header id="header" role="banner">
    <h1>Welcome</h1>
    <button class="join" type="button" id="call-button">Join Call</button>
  </header>
</main>

type="button" 属性看起来可能是多余的,但对于没有浏览器默认行为的按钮元素来说,最好包含它:在没有显式类型属性的情况下,<button> 默认为 type="submit",用于提交表单数据。但这个按钮并不是提交表单。

call-button 的唯一 ID 是有意通用的。它可以节省我们在 JavaScript 中切换不同的加入和离开按钮所需的大量工作。join 类将在 CSS 中作为一个很好的样式挂钩,稍后还可以用于 JavaScript 来确定按钮的状态。按钮上的“加入通话”文本应该为用户提供明确的提示。不久之后,一些 JavaScript 将在“加入通话”按钮被点击后,将按钮的类更改为 leave,并将其文本更改为“离开通话”。

添加视频元素:Self 和 Peer

在设置好基本的标题和按钮后,这个基本应用程序所需的其他 HTML 仅用于处理视频:一个元素用于显示用户自己的视频流,我们称之为 self;另一个元素用于显示远程用户的视频流,我们称之为 peer

处理点对点流媒体的许多有趣之处在于,你可以在标记中编写一些通常是大忌的内容。其中之一是设置多个视频元素同时播放(一个用于 self,一个用于 peer),并使用一些可能让你感到惊讶的属性,特别是如果你熟悉 HTML5 引入的 <video> 元素。如果你不熟悉,也没关系。我们将逐步讲解它们。

设置 Self 视频

这是设置 self 视频元素的方法,为方便起见,它的 ID 为 self

demos/basic-p2p/index.html
<video id="self"
  autoplay
  muted
  playsinline
  poster="img/placeholder.png">
</video>

<video> 元素设置了多个重要的属性。让我们简要探讨每个属性在实时流视频中的作用。

autoplay 属性

通常情况下,autoplay 属性是不被提倡使用的。用户通常希望控制视频的播放,尤其是伴随的音频。浏览器制造商帮助加强了用户控制,忽略 autoplay 属性,直到用户以某种方式与页面进行了交互。但是,对于流媒体视频,autoplay 是严格必要的:没有它,浏览器只会显示流媒体视频的第一帧。而且由于用户必须点击你之前构建的“加入通话”按钮,他们将在任何媒体开始流式传输之前与页面进行了交互——这几乎可以保证浏览器会遵循 autoplay 属性。

muted 属性

在下一章中,为了同一台设备的测试目的,我们将完全排除音频流。但为了确保将来启用音频的流不会引起可怕的反馈,self 视频采用了 muted 属性。这与静音麦克风以使其他人听不到你是不同的——这个主题我们将在后面的章节中讨论。这个属性所做的只是禁用 self 视频的音频。

playsinline 属性

最后一个布尔属性是 playsinline。该属性特别指示移动设备不要在流开始时启动全屏视频展示,这是 iOS 上的 Safari 和其他移动浏览器的默认行为。虽然全屏视频听起来很有吸引力,但它会遮挡页面上的其他内容,包括任何用户界面组件和 self 视频。设置 playsinline 后,peer 视频将根据你使用 CSS 放置的位置在页面上播放。

poster 属性

虽然不是严格必要,但可以使用 poster 属性引用一个占位符图像。在视频流开始之前,图像将显示在页面上,这是一种优雅的方式,让用户期望看到视频流。这对于测试目的也很有帮助:如果没有海报或在 CSS 中明确设置的尺寸和颜色,如果媒体权限或你将设置的 peer 连接出现问题,视频流可能会从看似无中生有的地方出现——或者根本不会出现。

设置 Peer 视频

peer 视频的标记几乎与 self 视频相同,唯一的区别是它的 ID 为 peer,并且省略了 muted 属性:

demos/basic-p2p/index.html
<video id="peer"
  autoplay
  playsinline
  poster="img/placeholder.png">
</video>

你可能已经注意到,这两个视频都没有 src 属性,也没有任何内部的 <source> 元素。这是有意为之的。流视频不涉及任何文件。在下一章中,你将使用 JavaScript 来为每个视频元素设置流源,使用 srcObject 属性,在 displayStream() 函数中完成这一操作。

最后,让我们将这两个 <video> 元素包裹在一个带有 ID 为 videos<article> 元素中。为了便于无障碍访问,应使用二级标题来标记流视频。我们将为标题添加一个类 preserve-access,稍后可以在 CSS 中引用。将所有内容整合在一起后,你的 HTML 文件应如下所示:

demos/basic-p2p/index.html
<main id="interface">
  <header id="header" role="banner">
    <h1>Welcome</h1>
    <button class="join" type="button" id="call-button">Join Call</button>
  </header>
  <article id="videos">
    <h2 class="preserve-access">Streaming Videos</h2>
    <video id="self"
      autoplay
      muted
      playsinline
      poster="img/placeholder.png">
    </video>
    <video id="peer"
      autoplay
      playsinline
      poster="img/placeholder.png">
    </video>
  </article>
</main>

这就是它:一个标题、一个按钮和两个视频元素——所有这些都包裹在语义化的分区元素中。接下来,让我们在 CSS 中为一切添加样式。

为核心应用元素设置样式

在 HTML 就位后,你可以开始编写一些 CSS,以展示一个可用的界面。我总是从 Eric Meyer 的 Reset CSS 开始工作。这里不需要重复那部分内容,但你会在这个应用的启动文件 screen.css 中看到它的一个压缩版本。

通常,最好从定义应用的基本排版属性开始,通常在 html 选择器上进行设置:

demos/basic-p2p/css/screen.css
html {
  font-family: "Lucida Grande", Arial, sans-serif;
  font-size: 18px;
  font-weight: bold;
  line-height: 22px;
}

这些文本设置可能看起来有点大和笨重,尤其是因为这些样式也意味着是移动优先的。但这是有意为之的:想想你在视频通话时脸部与屏幕的位置。我们大多数人都会稍微远离屏幕,以便摄像头至少能捕捉到我们的整个头部。而如果你的头部像我一样大得像个获奖的南瓜,你可能还得再往后退一点。由于用户的眼睛离屏幕更远,选择更大且易于看见的用户界面会更具可访问性——尤其是对于控件而言。

我们还可以添加一些基础的布局样式,将 box-sizing 设置为更直观的 border-box 值,并在界面和标题元素周围添加一些内边距:

demos/basic-p2p/css/screen.css
/* Layout */
* {
  box-sizing: border-box;
}
#interface {
  padding: 22px;
}
#header {
  margin-bottom: 11px;
}
#header > h1 {
  margin-bottom: 11px;
}

由于“Streaming Videos”标题旨在帮助低视力用户导航页面结构,我们可以使用一种可访问技术,将标题从视力正常的用户视野中隐藏起来,同时保持其在可访问性树中可用。我们将在后面的章节中在其他元素上重用这个类:

demos/basic-p2p/css/screen.css
.preserve-access {
  position: absolute;
  left: -20000px;
}

为按钮元素设置样式

<button> 元素是页面上唯一的交互式用户界面元素,比其他表单元素更容易设置样式。你可以在 button 元素选择器上为它设置一个基本外观,首先是继承页面上所有文本的字体样式,同时将光标设置为指针:

demos/basic-p2p/css/screen.css
button {
  font-family: inherit;
  font-size: inherit;
  font-weight: inherit;
  line-height: inherit;
  cursor: pointer;
  /* Box Styles */
  display: block;
  border: 0;
  border-radius: 3px;
  padding: 11px;
}

为了更好地控制按钮在布局中的位置,值得将其设置为块级显示。你还可以去掉浏览器绘制的默认边框,为按钮提供一个小的圆角边框半径。适量的内边距和边距——来源于页面的 22px 行高——为文本周围提供了一些空间,并抵消元素的边距。没有什么特别的花哨设计。(别担心——如果你对响应式设计很熟悉,并且觉得使用像素单位有点内疚,可以自行进行调整。或者在学习 WebRTC 时允许自己使用像素单位。)

按钮的 HTML 目前只有 join 类,但你仍然可以在 joinleave 类上为按钮设置一些非常基础的颜色:

demos/basic-p2p/css/screen.css
.join {
  background-color: green;
  color: white;
}
.leave {
  background-color: #CA0;
  color: black;
}

最后,让我们在 #call-button 选择器上设置一个宽度:

demos/basic-p2p/css/screen.css
#call-button {
  width: 143px; /* 6.5 typographic grid lines */
  margin-right: 11px;
}

143px 的宽度值经过调整,以便舒适地容纳“Join Call”和“Leave Call”的文本,同时也是基于页面的 22px 行高得出的。设置宽度的目的仅仅是确保当按钮的文本从“Join Call”变为“Leave Call”时,按钮不会改变布局。这在按钮独占一行时并不重要,但作为一个小的响应式处理,我们可以添加一个媒体查询,将 <header id="header"> 的内容显示为 flex 项目,所有内容都在同一行上:

demos/basic-p2p/css/screen.css
@media screen and (min-width: 500px) {
  #header {
    display: flex;
    flex-direction: row-reverse;
    align-items: baseline;
    justify-content: flex-end;
  }
  #header > * {
    flex: 0 0 auto;
  }
  #header > h1 {
    margin-bottom: 0;
  }
}

如果你不熟悉 flexbox 及其属性,这里发生的事情是页面头部被设置为一个 flexbox,使用 display: flex。将 flex 项目(一级标题和按钮)的对齐方式设为 baseline,可以将每个元素的文本保持在同一条不可见的线上。

然后,为了将加入按钮移到标题的左侧,flex-direction: row-reverse 将头部项目的顺序翻转,而 justify-content: flex-end 在行方向反转的 flexbox 中,将 flex 项目对齐到 flexbox 的左侧。

子选择器 #header > * 设置了 flex 项目的行为(在本例中是 <h1><button>)。flex 的简写设置了 grow 和 shrink 值为零,这意味着两个元素都不会从其自动宽度进行扩展或收缩。对于一级标题来说,这将是其文本的宽度。对于按钮来说,这将是 #call-button 选择器上设置的 143px 宽度值。

结果是在宽度为 500 像素及以上的视口上,按钮和标题将如图所示位于同一行上。这将减少标题将视频元素推下视口的程度。说到视频元素,接下来让我们为它们设置样式。

image.png

为视频元素设置样式

作为嵌入内容,<video> 元素默认显示为内联元素,就像 <img/> 标签一样。Ethan Marcotte 通过 display: blockmax-width: 100% 的组合使得响应式图片广为人知【17】,我们也可以对视频元素采取同样的方法。设置 max-width 属性确保视频元素的宽度不会超过父元素的宽度,或者在这个小应用中,不会超过视口的宽度:

demos/basic-p2p/css/screen.css
/* Video Elements */

video {
  background-color: #DDD;
  display: block;
  max-width: 100%;
}

你可以将视频元素的背景颜色设置为非常浅的灰色,这样可以显示出视频元素的确切边界,并且与视频元素的 poster 属性设置的透明笑脸 PNG 图片相得益彰。

对于 peer 视频,可以按原样显示,但对 self 视频进行一些调整可以更明显地区分两个视频,这在你只有一台摄像头进行测试时尤为有用。此时,相同的流媒体视频图像会同时出现在两个视频元素中:

demos/basic-p2p/css/screen.css
#self {
  width: 50%;
  max-width: 320px;
  margin-bottom: 11px;
}

这将 self 视频的宽度减少到 50%,并在它和位于下方的 peer 视频之间留出半行高度的空间。

将所有这些 CSS 应用到页面后,你可以在浏览器中重新加载页面,查看按钮、标题和视频占位符的显示效果。

image.png

为呼叫按钮添加功能

现在,应用程序的结构和样式都已经设置好,是时候与 JavaScript 亲密接触了。我们将从专注于呼叫按钮的功能开始。

main.js 文件中,我们首先要做的事情是启用严格模式。这只需在文件顶部添加一个字符串即可:

demos/basic-p2p/js/main.js
'use strict';

启用严格模式是一个良好的编程习惯。严格模式通常会揭示你的 JavaScript 中的错误和不一致性,这些问题在没有严格模式时浏览器可能会忽略。一个严格的浏览器在你调试和追踪 JavaScript 错误时会成为真正的资产。如果你感兴趣,可以在 MDN 上了解更多关于严格模式的信息。

接下来我们处理呼叫按钮,这将在 JavaScript 文件的“用户界面设置”部分中以一行代码完成。你可以使用 querySelector() 方法在文档对象上选择 HTML 中的呼叫按钮。如果你以前没有使用过 querySelector(),需要知道它接受一个字符串,包含与 CSS 中书写的选择器相同的语法:

document.querySelector('#call-button');

在选择了 #call-button 元素后,你可以调用 addEventListener() 方法来响应按钮的点击事件。先从简单的开始,在控制台中报告按钮已被点击:

document.querySelector('#call-button')
  .addEventListener('click', function(event) {
    console.log('Call button clicked!');
  });

在浏览器中重新加载页面并打开 JavaScript 控制台,你应该会在控制台中看到“Call button clicked!”的显示。

将命名函数作为回调函数

虽然 querySelector 方法只接受一个参数——CSS 选择器 #call-button,但 addEventListener 方法需要两个必需参数。第一个参数是事件的名称 'click',第二个参数是一个匿名回调函数,也称为监听器。该函数是匿名的,因为它没有名字:function 关键字只是定义了一个即时函数。我喜欢将回调函数命名,并在 JavaScript 文件中为它们分配一个位置。在这种情况下,main.js 文件中有一个“用户界面函数和回调”区域。因此,第一个小改进是创建一个命名函数,并将其名称作为 addEventListener() 的第二个参数传入:

/** 
 *  User-Interface Setup 
 */

document.querySelector('#call-button')
  .addEventListener('click', handleCallButton);

/** 
 * User-Interface Functions and Callbacks 
 */
function handleCallButton(event) {
  console.log('Call button clicked! Named callback function active!');
}

handleCallButton 函数作为引用传递后,代码看起来会更简洁:我们只需查看事件 click 和响应该事件的描述性函数名称。更好的是,handleCallButton 的详细内容放在 JavaScript 文件的一个独立部分中。保持这种组织习惯将在文件包含更多信令和 WebRTC 代码时变得更为重要,因为这些代码几乎完全由事件及其回调函数驱动。

在浏览器中重新加载应用并再次点击按钮。控制台应该显示更长的消息,表明命名的回调函数已激活。

如果在点击之前就看到了控制台中的消息,可能是你不小心调用了函数,而不是按引用传递它:

document.querySelector('#call-button')
  .addEventListener('click', handleCallButton()); // 哎呀!

如果你在 handleCallButton 上包含了开括号和闭括号 (),传递给 addEventListener 的将不是回调函数的引用,而是回调函数在提前运行后的结果。如果发生了这种情况,请确保你只传递函数的名称:handleCallButton,不要加括号。然后刷新浏览器中的应用程序并重试。

处理回调函数返回的数据

addEventListener() 方法,像许多接受回调函数的方法一样,在事件触发时会将一块数据传递给回调函数。使用 addEventListener() 时,通常的做法是将这些数据传递给回调函数,命名为 event 或简单地命名为 e。你可以选择自己喜欢的命名,但请注意,本书中的代码通常使用 e 表示错误,而使用 event 表示所有事件。eventtarget 属性是一个 DOM 对象,表示被点击的元素。由于它是一个 DOM 对象,与我们使用 document.querySelector() 获得的对象类似,因此我们可以对它进行 DOM 操作,例如确定它的 ID:

function handleCallButton(event) {
  console.log('Button with ID', event.target.id, 'clicked!');
}

再次刷新并点击按钮,观察控制台中显示“Button with ID call-button clicked!”。

我们还可以对 event.target 做更多事情,使呼叫按钮具备完整的功能:

  • 将按钮的类从 join 改为 leave,反之亦然
  • 将按钮的文本从“Join Call”改为“Leave Call”,反之亦然

让我们设置一下。为了使代码更具可读性,将 event.target 的值保存在一个名为 call_button 的局部变量中:

demos/basic-p2p/js/main.js
function handleCallButton(event) {
  const call_button = event.target;
  if (call_button.className === 'join') {
    console.log('Joining the call...');
    call_button.className = 'leave';
    call_button.innerText = 'Leave Call';
  } else {
    console.log('Leaving the call...');
    call_button.className = 'join';
    call_button.innerText = 'Join Call';
  }
}

再次刷新你的应用程序。现在你可以反复点击按钮。它的文本会发生变化,并且由于你之前编写的 CSS,其外观也会随之改变。根据按钮的状态,控制台还会记录“Joining the call...”或“Leaving the call...”的信息。

非常好。视频通话应用程序界面的所有基础 HTML、CSS 和 JavaScript 现在都已就位。接下来,我们需要开始为信令通道添加逻辑。但在此之前,让我们稍作停顿,了解一下 WebRTC 在浏览器中的实现,并了解为什么 WebRTC 需要信令通道。

将 WebRTC 定位为前端技术

像“WebRTC:浏览器中的实时通信”这样一个气势恢宏的名称,听起来 WebRTC 规范似乎是一个新奇的基于服务器的技术。然而,作为网络开发人员,尤其是前端开发人员,我们以前从未见过浏览器做其他事情,而无非是与服务器通信,无论是请求静态 HTML 页面、处理表单提交的数据,还是通过 Fetch API 异步请求资源。传统的网络技术一直是 100% 依赖服务器的技术。前端开发人员通常只能在服务器机房的窗户上按住脸,透过玻璃看着里面那些酷炫的新玩具。

这种感觉并非完全错误:网络一直依赖于 HTTP 的客户端-服务器架构,其中客户端通常与浏览器同义。(网络标准文档通常称用户代理为客户端或浏览器。)客户端-服务器架构意味着即使是像基于文本的聊天应用程序这样简单的东西,也需要通过服务器将每条聊天消息从一个浏览器中继到接收浏览器。WebSockets 实现了持久连接和类似推送的行为,因此聊天消息看起来就像是瞬间从一个用户传递到另一个用户。但即使有了 WebSockets,网络的基本架构仍然没有改变,服务器仍然是中心枢纽。

但 WebRTC 并非如此。作为一种真正的点对点技术,WebRTC 在浏览器中的实现几乎消除了对中介服务器的需求。(当然,WebRTC 也有服务器、操作系统甚至智能设备的实现,但它们代表的是本书范围之外的不同用例。)一旦建立连接,WebRTC 可以让任意两个对等方通过加密的低延迟连接直接交换数据,包括流媒体,而数据不会经过任何网络服务器。

预览点对点连接协商

当然,事情有一点小麻烦:WebRTC 只有在连接建立后才是点对点的。RTC 点对点连接的建立需要一些工作,通常需要借助提供信令通道的服务器。

作为网络开发人员,我们习惯了 HTTP 的请求-响应连接模式:如果你有一个域名,甚至只有一个 IP 地址,你将其输入浏览器的地址栏,然后“砰”——连接建立,资源或错误信息响应请求被送达。故事结束了。在预览视频通话应用界面的工作时,你已经在使用小型本地 Web 服务器。随后对同一网站的请求与第一个请求的工作方式相同,但通常在浏览器和服务器之间没有持久连接,除非你处理的是像 WebSockets 或具备 keepalive 功能的 Web 服务器这样的高级内容。

通过 WebRTC 建立连接比请求-响应模式更为复杂。连接的两个对等方必须在连接建立之前协商连接的条款。与请求-响应模式不同,点对点连接是通过信令通道上的 offer-answer 模式协商的。

用一个比喻来说,建立点对点连接的 offer-answer 结构与两个朋友商定一起喝咖啡的过程没什么不同。除非他们有异常强大的心灵感应,否则两个朋友不会在同一天、同一时间、同一个咖啡店不约而同地出现。他们必须首先通过某种信令通道协商咖啡约会的细节:电子邮件、电话、短信,或其他常见的方式。即使真有强大的心灵感应,它在某种程度上也起到了信令通道的作用。

但我们还是以一个更常见的例子为准。一个朋友通过发送短信、打电话或在街上大喊来发起咖啡约会,打开了一个信令通道。发起的朋友提供了会面计划的细节,而另一个朋友做出了回应。两人通过共享的信令通道来回交流,直到他们达成共识,确定在特定的时间和地点见面。无论使用哪种信令通道(短信、电话、电子邮件),他们的对话通常如下:

  • 朋友A:我们下周二上午10点在 The Red Eye 见面喝咖啡怎么样?
  • 朋友B:我可以周二,但只能11点以后。
  • 朋友A:可以,但我12:30有另一个会议要去另一边的城市,所以我们还是在 Common Grounds 见吧。
  • 朋友B:好的!那我们周二11点在 Common Grounds 见。
  • 朋友A:听起来不错!

再次重申:所有这些协商都是通过信令通道进行的,它独立于目的地咖啡店。咖啡店本身不能作为信令通道,因为就像上面的交流中一样,甚至是哪家咖啡店的问题都可能需要协商。

在两个浏览器之间建立点对点连接的方式大致相同。至少一个浏览器将发起连接请求,另一个浏览器将返回某种响应。这个过程会通过信令通道继续进行,就像朋友们通过短信商定咖啡约会一样,直到浏览器们就如何打开点对点连接达成一致。

使用轻量级信令通道

浏览器缺少一个信令通道来协商连接。WebRTC 规范的编辑者甚至避免要求使用任何特定的信令技术:你需要自己提供。因此,为了准备建立 WebRTC 连接,我们首先需要选择并设置一个信令通道。

基于服务器的信令通道是最方便的选择。即使是一个非常基本的点对点应用程序,例如你现在正在编写的这个应用程序,也需要两个浏览器都访问一个指向某个网络服务器的 URL,以下载建立和支持通话所需的 HTML、CSS 和 JavaScript。既然网络服务器已经参与进来了,任何服务器端设置都可以用于将消息从一个浏览器传递到另一个浏览器。

从技术上讲,基于服务器的信令通道并不是绝对必要的。理论上,通过 WebRTC 连接的两个对等方可以通过电子邮件甚至手写,并通过无接触递送的方式传递浏览器的 offer 和 answer 来建立对等连接——就像前面提到的两个朋友可以通过旗语、罐头电话、秘密投递或其他各种不可能的信令通道来确定他们的咖啡约会一样。然而,服务器将提供最大的灵活性和更低的延迟。例如,你可以使用 WebSockets 用任何你选择的服务器端语言(如 PHP、Python、Ruby 等)编写自己的信令通道。

为了防止你被所有这些工作分心,我已经用 ExpressJS 编写了一个非常小的服务器,其中包括一个基于 Socket.IO 的信令通道。它已经包含在你下载的书本示例代码中。信令通道开箱即用。虽然你可能会发现尝试它很有价值,但在本书中你不会编写太多的服务器端代码。我们的重点是在浏览器中,因为那才是真正的重头戏。

但在我们开始在浏览器中工作之前,你将更好地理解你即将编写的用于信令通道的代码,如果我们先快速浏览一下信令通道本身。这条信令通道非常简洁而且功能简单:它所做的只是管理几个事件并在两端之间传递消息。它不知道 WebRTC 或我们可能在做的任何其他事情。尽可能将 WebRTC 的知识保留在浏览器中及其之间。你的信令通道功能越少,将来将你的应用程序迁移到另一个信令通道就越容易。

服务器的信令通道组件不到十行代码,所有代码都可以在这里重现。如你所见,有时我会逐行解释如何设置一段代码。其他时候,我会在解释之前先展示整个内容,比如这里:

server.js
const namespaces = io.of(/^/[0-9]{7}$/);

namespaces.on('connect', function(socket) {

  const namespace = socket.nsp;

  socket.broadcast.emit('connected peer');

  socket.on('signal', function(data) {
    socket.broadcast.emit('signal', data);
  });

  socket.on('disconnect', function() {
    namespace.emit('disconnected peer');
  });

});

信令通道的两个特点为我们将在浏览器中编写的代码定下了基调:一组事件和一个精确的七位数命名空间。让我们先来看看这些事件。

探索信令通道的事件

信令通道处理七个事件:三个它监听的事件——connectsignaldisconnect,以及四个它发出的事件——connected peersignaldisconnected peer,再加上一个 Socket.IO 自动发出的 connect 事件。

当一个客户端连接时,信令通道将向其他已连接的客户端广播 connected peer 事件。当一个客户端发送信号时,信令通道将重广播 signal 事件及其数据,本质上充当了一个中继器。最后,当一个客户端断开连接时,信令通道将发出 disconnected peer 事件。

这涵盖了一个基本的信令通道需要做的所有事情:监听连接、监听和重复信号、监听断开连接。我们在浏览器中编写的代码将触发或响应这些事件中的每一个。

命名信令通道

你正在构建的视频通话应用程序旨在扩展:它将允许多个独立的对等方对彼此进行同时连接。这与 Zoom 或 Google Meet 的工作原理没有什么不同。当你使用 Zoom 时,你和你想要交谈的人必须共享一个唯一的 URL,例如 https://fake-example.zoom.us/j/72072139453(通过某个其他信令通道)。你也将构建类似的唯一 URL。

server.js 中信令通道的开头几行代码使用正则表达式来验证命名空间的结构,这类似于 Zoom 中的会议代码。通过共享命名空间,一对对等方可以在命名空间 /0000001 上协商他们的连接,而另一对可以同时在 /0000002 上连接。他们的信号永远不会混淆。

这些都是很容易猜测的模式,所以我们将在浏览器中编写一个函数来测试或生成一个与服务器预期的七位数模式匹配的随机数。我们将通过将命名空间附加为 URL 上的哈希,使其便于用户共享,类似于 https://localhost/basic-p2p/#1234567。(在本书后面,我们将使用服务器生成使用 URL 路径而非哈希的命名空间。)

main.js 文件的最底部,你会发现一个用于实用函数的部分。这里我编写了一个名为 prepareNamespace() 的函数,它接受两个参数。hash 参数将处理一个现有的哈希值,可能由浏览器从 window.location.hash 报告。第二个参数 set_location 是一个布尔值(truefalse),用于在 window.location.hash 上设置准备好的命名空间。通读整个函数定义,然后我们再一起解析。

接下来是详细解析和正则表达式的讲解,最后将命名空间应用到用户界面中。信令通道和 WebRTC 是构建点对点通信应用程序的核心部分,而这个章节提供了深入理解这些技术的基础。

image.png

这些调整基本上都是表面的,只改变了 URL 和页面的外观。现在让我们使用 namespace 变量来连接到信令通道。

连接到信令通道

要连接到信令通道,你需要先完成一些准备步骤。首先,返回 index.html 文件,并在加载 main.js 文件的 <script> 标签上方再添加一个 <script> 标签,指向由 Socket.IO 自动提供的 socket.io.js 文件:

demos/basic-p2p/index.html
<script src="/socket.io/socket.io.js"></script>
<script src="js/main.js"></script>
</body>
</html>

main.js 文件依赖于 socket.io.js 文件的内容,也就是 Socket.IO 客户端,因此一定要确保将指向 main.js<script> 标签放在 HTML 中的第二个位置。

回到 main.js 文件的 “信令通道设置” 部分,让我们连接到信令通道,并在 connect 事件上附加一些代码,以便在连接成功时在浏览器控制台中输出一条消息。你可以声明一个 sc 变量,用来保存通过 Socket.IO 的 io 对象的 connect 方法返回的带命名空间的信令通道:

/** 
 *  Signaling-Channel Setup
 */

const namespace = prepareNamespace(window.location.hash, true);

const sc = io.connect('/' + namespace);

sc.on('connect', function() {
  console.log('Successfully connected to the signaling channel!');
});

如果你的浏览器的 JavaScript 控制台还没打开,现在可以打开它。重新加载页面,你应该会在控制台中看到一条成功消息:“Successfully connected to the signaling channel!”

目前,任何使用你的应用的用户只需在浏览器中加载页面,就会自动连接到信令通道。但这并不是我们想要的行为:信令通道的 connect 事件最终会启动建立 WebRTC 通话的过程。为了让用户能够更好地控制这个过程,让我们重写代码,以便用户在点击我们辛苦设置的“加入通话”按钮之前不会连接到信令通道。

手动连接信令通道

还记得你在“编写命名回调函数”中设置的 handleCallButton() 函数吗?现在让我们在其中调用另外两个函数:joinCall()leaveCall()。这些函数的定义可以放在 handleCallButton 函数定义的下方:

/** 
 *  User-Interface Functions and Callbacks
 */

function handleCallButton(event) {
  const call_button = event.target;
  if (call_button.className === 'join') {
    console.log('Joining the call...');
    call_button.className = 'leave';
    call_button.innerText = 'Leave Call';
    joinCall();
  } else {
    console.log('Leaving the call...');
    call_button.className = 'join';
    call_button.innerText = 'Join Call';
    leaveCall();
  }
}

function joinCall() {
  sc.open();
}

function leaveCall() {
  sc.close();
}

我们将会在 joinCallleaveCall 函数中添加更多内容,但现在它们的职责仅限于打开和关闭 Socket.IO 信令通道,后者提供了 open()close() 方法。我们只需在 sc 对象上调用它们。

既然你的 JavaScript 已经设置为手动打开和关闭与信令服务器的连接,你需要通过传递一个设置 autoConnectfalse 的选项对象来修改信令通道的配置:

const sc = io.connect('/' + namespace, { autoConnect: false });

再次在浏览器中重新加载页面。你应该不会在 JavaScript 控制台中看到“Successfully connected to the signaling server!”的消息,直到你点击“加入通话”按钮为止。如果你再多次点击按钮,加入和离开通话,每次点击“加入通话”时你都会看到成功消息。很棒!

由于信令通道由 Socket.IO 驱动,它有一个 active 属性,在信令服务器连接时返回 true,否则返回 false。如果你愿意,可以点击“加入通话”按钮,然后在浏览器的 JavaScript 控制台中输入 sc.active 并按下回车。它应该会返回 true。点击“离开通话”按钮,再次输入 sc.active,它应该会返回 false。这是一种简单的方法,可以验证你的按钮确实在打开和关闭信令通道。

为剩余的信令回调准备占位符

还有一个任务需要完成,然后我们就为在下一章中编写完整的信令通道代码和 WebRTC 连接逻辑做好了一切准备。在 JavaScript 文件的“信令通道函数和回调”部分,让我们编写一个包装函数 registerScCallbacks(),用于注册信令通道的四个事件(connectconnected peerdisconnected peersignal),并在下面添加它们的占位符函数定义。

你可以将 connect 事件上记录“Successfully connected...”的匿名回调改写为一个命名函数,以防将来还要在该事件上做更多的操作(剧透:将来确实会有)。把这些内容结合起来,你的信令回调函数将如下所示:

/** 
 *  Signaling-Channel Functions and Callbacks
 */

function registerScCallbacks() {
  sc.on('connect', handleScConnect);
  sc.on('connected peer', handleScConnectedPeer);
  sc.on('disconnected peer', handleScDisconnectedPeer);
  sc.on('signal', handleScSignal);
}

function handleScConnect() {
  console.log('Successfully connected to the signaling server!');
}

function handleScConnectedPeer() {
}

function handleScDisconnectedPeer() {
}

function handleScSignal() {
}

这些函数定义当然还会有很多修改,但现在它们已经足够避免浏览器抱怨缺少引用。最后一件事是调用 registerScCallbacks() 包装函数,使其完成你的“信令通道设置”部分:

/** 
 *  Signaling-Channel Setup
 */

const namespace = prepareNamespace(window.location.hash, true);

const sc = io.connect('/' + namespace, { autoConnect: false });

registerScCallbacks();

后续步骤

现在,你已经为你的点对点视频通话应用准备好了几个关键的基础部分。你已经构建了一个由优秀的语义 HTML 和现代 CSS 组成的用户界面,并编写了一些重要的响应式设计功能,这对于使你的应用能够在所有类型的 Web 设备上进行访问至关重要。你还连接到了信令通道,并为触发和响应信令通道事件创建了一组占位函数。在下一章中,你将通过编写这些函数来完成应用,并在 WebRTC 中建立你的第一个点对点连接。