(译+注) WebRTC完美协商模式

1,669 阅读11分钟

食用指南:

  1. 如果不太了解WebRTC,可以先阅读从前端视频从0认识Web实时通讯
  2. 原文来自于MDN Establishing a connection: The WebRTC perfect negotiation pattern,本文是使用Google翻译,修正成通俗易懂语句,再加上自己的理解,难免会有错误,希望各位朋友可以在评论区指正;
  3. 如果是在中文中难以找到接近词时会保留英文。比如 A polite peerA impolite peer,直译就是有礼貌的对等不礼貌的对等,显然难以明白在表达什么,所以在用到的第一处会有注释,后面就保留原文;

术语表

  • 对等方:peers即p2p,对等方连接即p2p连接;
  • 信令:建立p2p连接,双方需要交换的信息,交换的通道可以是websocket、https请求、甚至是datachannel通道,信令通道没有明确的限制,一般为websocket连接,本文的代码示例为了简化,就直接在 一个页面中进行操作;
  • 完美协商:TODO:

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

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

因为WebRTC在新的对等连接的协商期间没有强制要求特定的信号传输机制,所以它非常灵活。然而,尽管信令消息的传输和通信具有这种灵活性,但仍然有一种推荐的设计模式,您应该尽可能遵循,称为完美协商。

浏览器开始支持WebRTC之后,人们意识到协商过程的某些部分比典型用例所需的要复杂。这是由于 API 存在少量问题以及一些需要防止的潜在竞争条件。这些问题已经得到解决,让我们大大简化了 WebRTC 协商。完美协商模式是自 WebRTC 早期以来协商改进方式的一个例子。

完美协商概念

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

完美协商的最大优点是调用方和被调用方都使用相同的代码,因此无需编写重复或以其他方式添加级别的协商代码。

// 注释开始

上面说了一通完美协商但是仍然不知道要表达什么,这个其实是需要了解p2p的连接过程以及信令状态的变化:

  1. p2p连接:

代码版:

(async () => {
    const peer1 = new RTCPeerConnection();
    const peer2 = new RTCPeerConnection();
    const offer = await peer1.createOffer();
    await peer1.setLocalDescription(offer);
    await peer2.setRemoteDescription(offer);
    const answer = await peer2.createAnswer();
    await peer2.setLocalDescription(asnwer);
    peer1.setRemoteDescription(answer);
})();

图片版:

image.png

以上流程简单可以归纳为:发起方创建offer,在本地设置后,发送给对方,对方选设置你的offer,然后创建对应的answer,本地设置后返回给发起方,发起方再设置answer,即可完成这一个流程。

发起方的信令变化:have-local-offer -> stable 被叫方的信令变化:have-remote-offer -> stable

  1. 信令状态:

image.png

从图中看到信令的状态有很多,而且有严格的流程控制最终才能到达stable的状态

如果说双方同时在本地生成了offer,然后同时发给对方,肯定会协商失败

image.png

所以完美协商正是一种可以解决这种问题的技术方案

// 注释结束

完美协商的工作原理是为两个对等方中的每一个分配一个在协商过程中扮演的角色(想不到代码里也有角色扮演-_-!!),该角色与 WebRTC 连接状态完全分开:

  • polite peer,如果将要设置的信令与本身信令状态有冲突,就会使用回滚,使用自身的信令状态变为stable后再继续设置对方发来的信令;
  • A impolite peer,如果将要设置的信令与本身信令状态有冲突,就会放弃设置对方的信令(以我为准);

这样,双都知道如果已发送的offer之间发生冲突会发生什么。对错误条件的响应变得更加可预测。

双方谁是polite peer和impolite peer没有限制,是可以随机的。

实施完美协商(代码实现)

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

请注意,此代码对于连接中涉及的两个对等方是相同的。

创建信令和对等连接

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

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

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

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

连接到远程对等点

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()。然后将生成的媒体轨道RTCPeerConnection通过将它们传递给addTrack(). 然后,最后,<video>selfVideo指显示自己的相机和麦克风流元素,允许本地用户看到其他对等点看到的内容。

处理接收的tracks

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

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

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

完美协商逻辑

现在我们进入真正完美协商逻辑,它的功能完全独立于应用程序的其余部分。

处理协商需要的事件

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

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;
  }
};

注:onnegotiationneeded是pc的某些状态变化,然后回调需要协商,可能给pc添加了一个轨道或者添加了一个datachannel

请注意,setLocalDescription()不带参数会根据当前signalingState. 集合描述要么是对来自远程对等方的最新offer的answer,要么是新创建的offer(如果没有进行协商)。在这里,它将始终是 an offer,因为协商需要的事件仅在stable状态下触发。

我们设置了一个布尔变量,makingOffertrue标记我们正在准备offer。为避免竞争,我们稍后将使用此值而不是信号状态来确定是否正在处理要约,因为值signalingState异步更改,从而引入了炫目的机会。

创建、设置和发送offer(或发生错误)后,makingOffer将重新设置为false

处理 ICE candidate

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

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

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

处理信令消息

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

let ignoreOffer = false;

signaler.onmessage = async ({ data: { description, candidate } }) => {
  try {
    if (description) {
      // 如果对端发过来的描述类型为offer前提下,如果本地正在生成offer,或者本地的信令状态不为stable,就认为是信令冲突
      const offerCollision = (description.type == "offer") &&
                             (makingOffer || pc.signalingState != "stable");

      ignoreOffer = !polite && offerCollision;
      if (ignoreOffer) {
        // 如果是impolite方,并且信令冲突,那么不管三七二十一,直接不处理
        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);
  }
}

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

另一方面,如果消息具有candidate,则它是从远程对等方接收的 ICE 候选,作为trickle ICE(trickle属于candidate相关的知识,这里不做过多的介绍)一部分。候选人注定要通过将其传递到addIceCandidate().

收到description(offer或者answer)后

如果我们收到description,我们准备回应传入的offeranswer。首先,我们检查以确保我们处于可以接受offer的状态。如果连接的信令状态不是stable或者如果我们的连接端开始了自己的offer过程,那么我们需要注意offer冲突。

如果我们是impolite peer,并且我们收到了一个冲突的offer,我们将在不设置描述的情况下返回,而是设置ignoreOffertrue以确保我们也忽略另一方可能在属于此offer的信令通道上发送给我们的所有candidate。

如果我们是polite peer,并且我们收到了一个冲突的offer,我们不需要做任何特别的事情,因为我们现有的offer将在下一步自动回滚。

确保我们要接受offer后,我们通过调用 将远程描述设置为传入的offer setRemoteDescription()。这让 WebRTC 知道其他对等方的建议配置是什么。如果我们是polite peer,我们将放弃我们的提议并接受新提议。

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

完善代码

如果您好奇是什么让完美的谈判变得如此完美,本节适合您。在这里,我们将查看对 WebRTC API 所做的每项更改以及最佳实践建议,以使完美协商成为可能。

无参数调用setLocalDescription()

过去,negotiationneeded事件很容易以一种容易有实参调用setLocalDescription方式处理——也就是说,它很容易发生冲突,在这种情况下,双方最终可能会试图同时发offer,导致一个或另一个同行收到错误并中止连接尝试。

旧的方式
// bad case
pc.onnegotiationneeded = async () => {
  try {
    await pc.setLocalDescription(await pc.createOffer());
    signaler.send({description: pc.localDescription});
  } catch(err) {
    console.error(err);
  }
};

由于该createOffer()方法是异步的并且需要一些时间才能完成,因此远程对等方可能会在一段时间内尝试发送自己的offer,从而导致我们离开stable状态并进入have-remote-offer状态,这意味着我们现在正在等待对offer。但是一旦它收到我们刚刚发送的offer,远程对等方也是如此。这使双方处于无法完成连接尝试的状态。

使用新的调用方式

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

// good case
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;
  }
};

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

最终实现

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);
  }
}