建立連線: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 層。如果發生錯誤並且我們已經忽略了最近的要約,我們也會忽略在嘗試新增候選項時可能發生的任何錯誤。

另見