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

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

由于 WebRTC 在协商新的对等连接时没有规定特定的信令传输机制,因此它具有高度的灵活性。然而,尽管在信令消息的传输和通信方面具有这种灵活性,但在可能的情况下,你仍然应该遵循一种推荐的设计模式,称为完美协商。

在首批支持 WebRTC 的浏览器部署后,人们意识到,对于典型用例而言,协商过程的某些部分比它们需要的要复杂。这是由于 API 存在少量问题以及一些需要避免的潜在竞态条件。这些问题后来都得到了解决,使我们能够显著简化 WebRTC 协商。完美协商模式就是自 WebRTC 早期以来协商方式得到改进的一个例子。

完美协商的概念

完美协商使得将协商过程与应用程序的其余逻辑无缝且完全地分离成为可能。协商本质上是一种非对称操作:一方需要作为“呼叫方”,而另一方则是“被叫方”。完美协商模式通过将这种差异分离到独立的协商逻辑中来消除这种差异,这样你的应用程序就不需要关心它位于连接的哪一端。就你的应用程序而言,你是呼出还是接听呼叫都没有区别。

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

完美协商的工作方式是为两个对等体各自分配一个在协商过程中的角色,这个角色与 WebRTC 连接状态完全分离。

  • 一个礼让的(polite)对等体,它使用 ICE 回滚来防止与传入的要约(offer)发生冲突。一个礼让的对等体,本质上是可以发出要约,但当收到另一个对等体的要约时,它会回应“好吧,没关系,我撤销我的要约,转而考虑你的要约”。
  • 一个不礼让的(impolite)对等体,它总是忽略与自己要约冲突的传入要约。它从不道歉,也不对礼让的对等体做出任何让步。任何时候发生冲突,总是不礼让的对等体获胜。

通过这种方式,两个对等体都清楚地知道,如果已发送的要约之间发生冲突,应该怎么做。对错误情况的响应变得更加可预测。

如何确定哪个对等体是礼让的,哪个是不礼让的,通常取决于你。可以简单地将礼让的角色分配给第一个连接到信令服务器的对等体,也可以做一些更复杂的事情,比如让对等体交换随机数,并将礼让的角色分配给获胜者。无论你如何决定,一旦这些角色分配给两个对等体,它们就可以协同工作来管理信令,从而避免死锁,并且不需要大量额外的代码来管理。

需要记住的重要一点是:在完美协商期间,呼叫方和被叫方的角色可以切换。如果礼让的对等体是呼叫方,它发送了一个要约,但与不礼让的对等体发生冲突,那么礼让的对等体就会放弃自己的要约,转而回应它从不礼让的对等体收到的要约。通过这样做,礼让的对等体就从呼叫方变成了被叫方!

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

请注意,这段代码对于连接中涉及的两个对等体是完全相同的。

创建信令和对等连接

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

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

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

这段代码还通过类名“self-view”和“remote-view”获取 <video> 元素;它们将分别包含本地用户的自视图和来自远端对等体的传入流视图。

连接到远端对等体

js
const constraints = { audio: true, video: true };
const selfVideo = document.querySelector("video.self-view");
const remoteVideo = document.querySelector("video.remote-view");

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() 获取用户的摄像头和麦克风。然后,将得到的媒体轨道通过传入 addTrack() 方法添加到 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()

我们为轨道添加一个 unmute 事件处理程序,因为一旦轨道开始接收数据包,它就会变为非静音状态。我们将接收代码的其余部分放在那里。

如果我们已经有来自远端对等体的视频(可以通过检查远端视图 <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,以标记我们正在准备一个要约。我们在调用 setLocalDescription() 之前立即设置 makingOffer,以防止干扰此要约的发送,并且直到要约已发送到信令服务器(或发生错误导致无法发出要约)后才将其清除回 false。为了避免竞态,我们稍后将使用这个值而不是信令状态来判断是否正在处理要约,因为 signalingState 的值是异步变化的,这可能会引入出站呼叫和入站呼叫的冲突(“glare”)。

处理传入的 ICE 候选项

接下来,我们需要处理 RTCPeerConnectionicecandidate 事件,本地 ICE 层通过此事件将候选项传递给我们,以便通过信令通道发送给远端对等体。

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

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

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

最后一块拼图是处理来自信令服务器的传入消息的代码。这里实现为信令通道对象上的一个 onmessage 事件处理程序。每当有消息从信令服务器到达时,此方法就会被调用。

js
let ignoreOffer = false;
let isSettingRemoteAnswerPending = false;

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

      ignoreOffer = !polite && offerCollision;
      if (ignoreOffer) {
        return;
      }
      isSettingRemoteAnswerPending = description.type === "answer";
      await pc.setRemoteDescription(description);
      isSettingRemoteAnswerPending = false;
      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);
  }
};

当通过 SignalingChannelonmessage 事件处理程序收到传入消息时,会对接收到的 JSON 对象进行解构,以获取其中的 descriptioncandidate。如果传入消息有 description,它要么是另一方发送的要约,要么是应答。

另一方面,如果消息有 candidate,它就是作为涓流 ICE(trickle ICE)的一部分从远端对等体收到的 ICE 候选项。该候选项将通过传入 addIceCandidate() 传递到本地 ICE 层。

收到描述时

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

如果我们是不礼让的对等体,并且收到了一个冲突的要约,我们会直接返回而不设置描述,并将 ignoreOffer 设置为 true,以确保我们也忽略对方可能在信令通道上发送的属于此要约的所有候选项。这样做可以避免错误噪音,因为我们从未告知我们这边有关此要约的信息。

如果我们是礼让的对等体,并且收到了一个冲突的要约,我们不需要做任何特殊处理,因为我们现有的要约将在下一步中自动回滚。

在确保我们想要接受该要约后,我们通过调用 setRemoteDescription() 将远端描述设置为传入的要约。这让 WebRTC 知道对方对等体提议的配置。如果我们是礼让的对等体,我们将放弃我们的要约并接受新的要约。

如果新设置的远端描述是一个要约,我们通过调用不带参数的 RTCPeerConnection 方法 setLocalDescription() 来请求 WebRTC 选择一个合适的本地配置。这会使 setLocalDescription() 自动生成一个合适的应答来回应收到的要约。然后我们通过信令通道将应答发回给第一个对等体。

收到 ICE 候选项时

另一方面,如果收到的消息包含一个 ICE 候选项,我们通过调用 RTCPeerConnectionaddIceCandidate() 方法将其传递给本地 ICE 层。如果发生错误并且我们已经忽略了最近的要约,我们也会忽略在尝试添加候选项时可能发生的任何错误。

另见