建立连接:WebRTC 完美协商模式

本文介绍了 WebRTC **完美协商**,描述了其工作原理和为什么它是两个对等体之间协商 WebRTC 连接的推荐方法,并提供了示例代码来演示该技术。

由于 WebRTC 没有规定在协商新对等连接期间信令的特定传输机制,因此它非常灵活。但是,尽管传输和信令消息通信方式灵活,但在可能的情况下,仍应遵循一种推荐的设计模式,称为完美协商。

在 WebRTC 兼容浏览器的首次部署之后,人们意识到协商过程中的某些部分比典型用例所需的要复杂。这是由于 API 的一些小问题以及需要防止的一些潜在竞争条件。这些问题已得到解决,使我们能够显着简化 WebRTC 协商过程。完美协商模式是自 WebRTC 早期以来协商改进方式的一个例子。

完美协商概念

完美协商使我们能够无缝地将协商过程与应用程序其余逻辑完全分离。协商本质上是一种非对称操作:一方需要充当“呼叫者”,而另一方对等体充当“被呼叫者”。完美协商模式通过将此差异分离到独立的协商逻辑中来消除这种差异,因此您的应用程序无需关心它是连接的哪一端。就您的应用程序而言,无论是呼叫还是接听电话,都没有区别。

完美协商最棒的一点是,呼叫者和被呼叫者使用相同的代码,因此无需重复或编写额外的协商代码。

完美协商通过为两个对等体分配他们在协商过程中扮演的角色来实现,该角色与 WebRTC 连接状态完全分开

  • **礼貌**对等体,它使用 ICE 回滚来防止与传入的提议发生冲突。礼貌对等体本质上是指一个对等体,它可能会发出提议,但如果从另一个对等体收到一个提议,则会回复“好吧,不用了,放弃我的提议,我将考虑你的提议”。
  • **无礼**对等体,它总是忽略与其自身提议发生冲突的传入提议。它从不道歉或放弃任何东西给礼貌对等体。任何时候发生冲突,无礼对等体都会获胜。

这样,两个对等体都确切地知道如果发送的提议之间发生冲突,应该发生什么。对错误条件的响应变得更加可预测。

您如何确定哪个对等体是礼貌的,哪个对等体是无礼的,通常由您决定。它可能很简单,例如将礼貌角色分配给第一个连接到信令服务器的对等体,或者您可以执行更复杂的处理方式,例如让对等体交换随机数,并将礼貌角色分配给获胜者。无论您如何做出决定,一旦这两个对等体分配了这些角色,它们就可以协同工作以管理信令,使其不会死锁,也不需要太多额外的代码来管理。

需要注意的是:在完美协商期间,呼叫者和被呼叫者的角色可能会切换。如果礼貌对等体是呼叫者,它发送了一个提议,但与无礼对等体发生了冲突,礼貌对等体将放弃其提议,而是回复它从无礼对等体收到的提议。通过这样做,礼貌对等体已从呼叫者切换为被呼叫者!

实现完美协商

让我们看一个实现完美协商模式的示例。代码假定已定义一个 SignalingChannel 类,该类用于与信令服务器通信。当然,您自己的代码可以使用您喜欢的任何信令技术。

请注意,这段代码对参与连接的两个对等体都相同。

创建信令和对等连接

首先,需要打开信令通道,并创建 RTCPeerConnection。这里列出的 STUN 服务器显然不是真实的服务器;您需要将 stun.myserver.tld 替换为真实 STUN 服务器的地址。

js
const config = {
  iceServers: [{ urls: "stun:stun.mystunserver.tld" }],
};

const signaler = new SignalingChannel();
const pc = new RTCPeerConnection(config);

此代码还使用类“selfview”和“remoteview”获取 <video> 元素;这些元素分别包含本地用户的自视图和来自远程对等体的传入流的视图。

连接到远程对等体

js
const constraints = { audio: true, video: true };
const selfVideo = document.querySelector("video.selfview");
const remoteVideo = document.querySelector("video.remoteview");

async function start() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia(constraints);

    for (const track of stream.getTracks()) {
      pc.addTrack(track, stream);
    }
    selfVideo.srcObject = stream;
  } catch (err) {
    console.error(err);
  }
}

上面显示的 start() 函数可以由想要相互通信的两个端点中的任何一个调用。谁先调用并不重要;协商将正常进行。

这与旧的 WebRTC 连接建立代码没有什么不同。用户摄像头和麦克风通过调用 getUserMedia() 获得。然后,通过将结果媒体轨道传递到 RTCPeerConnectionaddTrack() 中,将它们添加到 RTCPeerConnection。最后,由常量 selfVideo 指示的自视图 <video> 元素的媒体源设置为摄像头和麦克风流,允许本地用户查看远程对等体看到的画面。

处理传入轨道

接下来,我们需要为 track 事件设置一个处理程序,以处理已协商由该对等连接接收的传入视频和音频轨道。为此,我们实现 RTCPeerConnectionontrack 事件处理程序。

js
pc.ontrack = ({ track, streams }) => {
  track.onunmute = () => {
    if (remoteVideo.srcObject) {
      return;
    }
    remoteVideo.srcObject = streams[0];
  };
};

track 事件发生时,此处理程序将执行。使用 结构化赋值,提取 RTCTrackEventtrackstreams 属性。前者是正在接收的视频轨道或音频轨道。后者是一个包含 MediaStream 对象的数组,每个对象表示包含此轨道的流(在极少数情况下,一个轨道可能同时属于多个流)。在本例中,它将始终包含一个流,位于索引 0 处,因为我们之前将一个流传递给 addTrack()

我们向轨道添加了一个取消静音事件处理程序,因为轨道将在开始接收数据包后取消静音。我们将剩余的接收代码放在那里。

如果我们已经从远程对等体接收视频(如果远程视图的 <video> 元素的 srcObject 属性已具有值,则表示我们已经接收视频),我们将不做任何操作。否则,我们将 srcObject 设置为 streams 数组中索引 0 处的流。

完美协商逻辑

现在,我们开始了解真正的完美协商逻辑,它完全独立于应用程序的其余部分。

处理 negotiationneeded 事件

首先,我们实现 RTCPeerConnection 事件处理程序 onnegotiationneeded 以获取本地描述并使用信令通道将其发送到远程对等方。

js
let makingOffer = false;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();
    signaler.send({ description: pc.localDescription });
  } catch (err) {
    console.error(err);
  } finally {
    makingOffer = false;
  }
};

请注意,没有参数的 setLocalDescription() 会根据当前的 signalingState 自动创建和设置适当的描述。设置的描述可能是对远程对等方最近的提议的答复, 如果没有协商正在进行,则为新创建的提议。在这里,它将始终是 offer,因为 negotiationneeded 事件仅在 stable 状态下触发。

我们将一个布尔变量 makingOffer 设置为 true,以标记我们正在准备提议。为避免竞态条件,稍后我们将使用此值而不是信令状态来确定是否正在处理提议,因为 signalingState 的值会异步更改,从而导致闪光机会。

提议创建、设置和发送后(或发生错误),makingOffer 将被重置回 false

处理传入的 ICE 候选

接下来,我们需要处理 RTCPeerConnection 事件 icecandidate,这是本地 ICE 层如何将候选传递给我们以便通过信令通道传递到远程对等方。

js
pc.onicecandidate = ({ candidate }) => signaler.send({ candidate });

这会获取此 ICE 事件的 candidate 成员,并将其传递给信令通道的 send() 方法,以便通过信令服务器发送到远程对等方。

处理信令通道上的传入消息

难题的最后一块是处理来自信令服务器的传入消息的代码。这里将其实现为信令通道对象上的 onmessage 事件处理程序。每次从信令服务器收到消息时,都会调用此方法。

js
let ignoreOffer = false;

signaler.onmessage = async ({ data: { description, candidate } }) => {
  try {
    if (description) {
      const offerCollision =
        description.type === "offer" &&
        (makingOffer || pc.signalingState !== "stable");

      ignoreOffer = !polite && offerCollision;
      if (ignoreOffer) {
        return;
      }

      await pc.setRemoteDescription(description);
      if (description.type === "offer") {
        await pc.setLocalDescription();
        signaler.send({ description: pc.localDescription });
      }
    } else if (candidate) {
      try {
        await pc.addIceCandidate(candidate);
      } catch (err) {
        if (!ignoreOffer) {
          throw err;
        }
      }
    }
  } catch (err) {
    console.error(err);
  }
};

通过其 onmessage 事件处理程序从 SignalingChannel 收到传入消息后,会解构接收到的 JSON 对象以获取其中找到的 descriptioncandidate。如果传入消息具有 description,则它是另一个对等方发送的提议或答复。

另一方面,如果消息具有 candidate,则它是从远程对等方接收到的 ICE 候选,作为 trickle ICE 的一部分。该候选注定要通过将其传递到 addIceCandidate() 来传递到本地 ICE 层。

接收描述

如果我们收到了 description,我们准备对传入的提议或答复做出响应。首先,我们检查以确保我们处于可以接受提议的状态。如果连接的信令状态不是 stable,或者我们这端连接已开始创建自己的提议,那么我们需要注意提议冲突。

如果我们是无礼的对等方,并且我们收到了冲突的提议,我们将返回而不设置描述,而是将 ignoreOffer 设置为 true,以确保我们还忽略了另一方可能发送给我们的所有属于此提议的信令通道上的候选。这样做可以避免错误噪声,因为我们从未告知我们这端有关此提议的信息。

如果我们是礼貌的对等方,并且我们收到了冲突的提议,我们无需做任何特别的操作,因为我们现有的提议将在下一步自动回滚。

在确保我们想要接受提议后,我们通过调用 setRemoteDescription() 将远程描述设置为传入的提议。这使 WebRTC 知道另一个对等方的建议配置是什么。如果我们是礼貌的对等方,我们将放弃我们的提议并接受新的提议。

如果新设置的远程描述是提议,我们会要求 WebRTC 通过调用 RTCPeerConnection 方法 setLocalDescription()(不带参数)来选择适当的本地配置。这会导致 setLocalDescription() 自动生成对收到的提议的适当答复。然后,我们通过信令通道将答复发送回第一个对等方。

接收 ICE 候选

另一方面,如果收到的消息包含 ICE 候选,我们会通过调用 RTCPeerConnection 方法 addIceCandidate() 将其传递到本地 ICE 层。如果发生错误并且我们忽略了最近的提议,我们还忽略尝试添加候选时可能发生的任何错误。

使协商完美

如果您想知道是什么使完美协商如此完美,本节适合您。在这里,我们将查看对 WebRTC API 和最佳实践建议所做的每个更改,以使完美协商成为可能。

无闪光 setLocalDescription()

过去,negotiationneeded 事件很容易以一种易受闪光影响的方式处理——也就是说,它容易发生冲突,其中两个对等方都可能尝试同时发出提议,导致一个或另一个对等方出现错误并中止连接尝试。

旧方法

请考虑此 onnegotiationneeded 事件处理程序

js
pc.onnegotiationneeded = async () => {
  try {
    await pc.setLocalDescription(await pc.createOffer());
    signaler.send({ description: pc.localDescription });
  } catch (err) {
    console.error(err);
  }
};

因为 createOffer() 方法是异步的,需要一些时间才能完成,所以远程对等方可能尝试发送自己的提议,导致我们离开 stable 状态并进入 have-remote-offer 状态,这意味着我们现在正在等待对该提议的响应。但一旦它收到我们刚刚发送的提议,远程对等方也是如此。这使得两个对等方都处于连接尝试无法完成的状态。

使用更新的 API 进行完美协商

如部分 实现完美协商 所示,我们可以通过引入一个变量(这里称为 makingOffer)来消除此问题,我们使用该变量来指示我们正在发送提议,并利用更新的 setLocalDescription() 方法

js
let makingOffer = false;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();
    signaler.send({ description: pc.localDescription });
  } catch (err) {
    console.error(err);
  } finally {
    makingOffer = false;
  }
};

我们在调用 setLocalDescription() 之前立即设置 makingOffer,以锁定避免干扰发送此提议,并且我们不会将其清除回 false,直到提议已发送到信令服务器(或发生错误,阻止提议被发出)。这样,我们避免了提议冲突的风险。

setRemoteDescription() 中的自动回滚

完美协商的关键组成部分是礼貌对等方的概念,如果它在等待对已发送提议的答复时收到提议,它总是会回滚自身。以前,触发回滚涉及手动检查回滚条件并手动触发回滚,方法是将本地描述设置为类型为 rollback 的描述,如下所示

js
await pc.setLocalDescription({ type: "rollback" });

这样做会将本地对等方从其之前所处的任何状态返回到 stable signalingState 状态。由于对等方只有在 stable 状态下才能接受提议,因此对等方已放弃其提议,并已准备好接收来自远程(无礼)对等方的提议。但是,正如我们将在稍后看到的,这种方法存在问题。

使用旧 API 进行完美协商

使用之前的 API 在完美协商期间实现传入协商消息将类似于以下内容

js
signaler.onmessage = async ({ data: { description, candidate } }) => {
  try {
    if (description) {
      if (description.type === "offer" && pc.signalingState !== "stable") {
        if (!polite) {
          return;
        }

        await Promise.all([
          pc.setLocalDescription({ type: "rollback" }),
          pc.setRemoteDescription(description),
        ]);
      } else {
        await pc.setRemoteDescription(description);
      }

      if (description.type === "offer") {
        await pc.setLocalDescription(await pc.createAnswer());
        signaler.send({ description: pc.localDescription });
      }
    } else if (candidate) {
      try {
        await pc.addIceCandidate(candidate);
      } catch (err) {
        if (!ignoreOffer) {
          throw err;
        }
      }
    }
  } catch (err) {
    console.error(err);
  }
};

由于回滚通过将更改推迟到下一次协商(将在当前协商完成后立即开始)来工作,因此礼貌对等方需要知道何时需要丢弃收到的提议,如果它当前正在等待对已发送提议的答复。

代码检查消息是否为提议,如果是,则检查本地信令状态是否为 stable。如果它不是稳定的,并且 本地对等方是礼貌的,我们需要触发回滚,以便我们可以用新收到的提议替换传出的提议。并且必须在继续处理收到的提议之前完成这两项操作。

由于没有单个“回滚并使用此提议”操作,因此在礼貌对等方上执行此更改需要两个步骤,在 Promise.all() 的上下文中执行,用于确保这两个语句在继续处理收到的提议之前完全执行。第一个语句触发回滚,第二个语句将远程描述设置为收到的描述,从而完成用新收到的提议替换先前发送的提议的过程。礼貌对等方现在已成为被调用方,而不是调用方。

从无礼对等方接收到的所有其他描述都按正常方式处理,方法是将它们传递到 setRemoteDescription()

最后,我们通过调用 setLocalDescription() 来处理收到的提议,以将我们的本地描述设置为 createAnswer() 返回的描述。然后,它使用信令通道发送到礼貌对等方。

如果传入消息是 ICE 候选而不是 SDP 描述,则通过将其传递到 RTCPeerConnection 方法 addIceCandidate() 将其传递到 ICE 层。如果在这里发生错误,并且我们没有因为在冲突期间是无礼的对等方而刚刚丢弃提议,我们 throw 错误,以便调用方可以处理它。否则,我们将放弃错误,忽略它,因为在这种情况下无关紧要。

使用更新的 API 进行完美协商

更新后的代码利用了以下事实:您现在可以调用 setLocalDescription() 而不带参数,以便它为您完成正确的事情,以及 setRemoteDescription() 会在必要时自动回滚。这使我们无需使用 Promise 来保持时间顺序,因为回滚成为 setRemoteDescription() 调用的基本上是原子的一部分。

js
let ignoreOffer = false;

signaler.onmessage = async ({ data: { description, candidate } }) => {
  try {
    if (description) {
      const offerCollision =
        description.type === "offer" &&
        (makingOffer || pc.signalingState !== "stable");

      ignoreOffer = !polite && offerCollision;
      if (ignoreOffer) {
        return;
      }

      await pc.setRemoteDescription(description);
      if (description.type === "offer") {
        await pc.setLocalDescription();
        signaler.send({ description: pc.localDescription });
      }
    } else if (candidate) {
      try {
        await pc.addIceCandidate(candidate);
      } catch (err) {
        if (!ignoreOffer) {
          throw err;
        }
      }
    }
  } catch (err) {
    console.error(err);
  }
};

虽然代码大小的差异很小,并且复杂性也没有减少多少,但代码更可靠得多。让我们深入了解代码以查看它是如何工作的。

接收描述

在修改后的代码中,如果收到的消息是 SDP description,我们会检查它是否是在我们尝试传输提议时收到的。如果收到的消息是 offer并且 本地对等方是无礼的对等方,并且 正在发生冲突,我们会忽略提议,因为我们希望继续尝试使用正在发送的提议。这就是无礼对等方的行为。

在任何其他情况下,我们将尝试改为处理传入消息。这从将远程描述设置为收到的 description 开始,方法是将其传递到 setRemoteDescription()。这无论我们是处理提议还是答复都有效,因为回滚将根据需要自动执行。

在这一点上,如果收到的消息是 offer,我们会使用 setLocalDescription() 创建和设置适当的本地描述,然后我们将其通过信令服务器发送到远程对等方。

接收 ICE 候选

另一方面,如果收到的消息是 ICE 候选——由 JSON 对象包含 candidate 成员来指示——我们会通过调用 RTCPeerConnection 方法 addIceCandidate() 将其传递到本地 ICE 层。与以前一样,如果我们刚刚丢弃了提议,则会忽略错误。

添加了显式的 restartIce() 方法

以前用于在处理 ICE 重启 事件时触发 negotiationneeded 的技术存在重大缺陷。这些缺陷使得在协商期间安全可靠地触发重启变得困难。完美的协商改进通过在 RTCPeerConnection 中添加新的 restartIce() 方法解决了这个问题。

旧方法

过去,如果您遇到 ICE 错误并且需要重新开始协商,您可能会执行以下操作

js
pc.onnegotiationneeded = async (options) => {
  await pc.setLocalDescription(await pc.createOffer(options));
  signaler.send({ description: pc.localDescription });
};
pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === "failed") {
    pc.onnegotiationneeded({ iceRestart: true });
  }
};

这存在一些可靠性问题和明显的错误(例如,如果 iceconnectionstatechange 事件在信号状态不是 stable 时触发,则会失败),但除了创建并发送将 iceRestart 选项设置为 true 的提议之外,您实际上无法请求 ICE 重启。因此,发送重启请求需要直接调用 negotiationneeded 事件的处理程序。在最好的情况下,正确操作非常棘手,而且很容易出错,因此错误很常见。

使用 restartIce()

现在,您可以使用 restartIce() 来更清晰地执行此操作

js
let makingOffer = false;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();
    signaler.send({ description: pc.localDescription });
  } catch (err) {
    console.error(err);
  } finally {
    makingOffer = false;
  }
};
pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === "failed") {
    pc.restartIce();
  }
};

使用这种改进的技术,不再直接调用带有触发 ICE 重启选项的 onnegotiationneeded,而是 failedICE 连接状态 会调用 restartIce()restartIce() 会指示 ICE 层自动将 iceRestart 标志添加到下一个发送的 ICE 消息中。问题解决!

在 pranswer 状态下不再支持回滚

最突出的 API 更改是,您不再可以在 have-remote-pranswerhave-local-pranswer 状态下回滚。幸运的是,使用完美协商时,根本不需要这样做,因为导致这种情况的原因会在回滚变得必要之前被捕获并阻止。

因此,尝试在其中一个 pranswer 状态下触发回滚现在会抛出 InvalidStateError 错误。

另请参阅