音視訊通訊加餐 —— WebRTC一肝到底

楊成功發表於2022-03-09

最近需要搭建一個線上課堂的直播平臺,考慮到清晰度和延遲性,我們一致認為使用 WebRTC 最合適。

原因有兩點:首先是“點對點通訊”非常吸引我們,不需要中間伺服器,客戶端直連,通訊非常方便;再者是 WebRTC 瀏覽器原生支援,其他客戶端支援也很好,不像傳統直播用 flv.js 做相容,可以實現標準統一。

然而令我非常尷尬的是,社群看了好幾篇文章,理論架構寫了一堆,但沒一個能跑起來。WebRTC 裡面概念很新也很多,理解它的通訊流程才是最關鍵,這點恰恰很少有描述。

於是我就自己搗鼓吧。搗鼓了幾天,可算是整明白了。下面我結合自己的實踐經驗,按照我理解的關鍵步驟,帶大家從應用場景的角度認識這個厲害的朋友 —— WebRTC

線上預覽:本地通訊 Demo

大綱預覽

本文介紹的內容包括以下方面:

  • 什麼是 WebRTC?
  • 獲取媒體流
  • 對等連線流程
  • 本地模擬通訊原始碼
  • 區域網兩端通訊
  • 一對多通訊
  • 我想學更多

什麼是 WebRTC?

WebRTC (Web Real-Time Communications) 是一項實時通訊技術,它允許網路應用或者站點,在不借助中間媒介的情況下,建立瀏覽器之間點對點(Peer-to-Peer)的連線,實現視訊流和音訊流或者其他任意資料的傳輸。

簡單的說,就是 WebRTC 可以不借助媒體伺服器,通過瀏覽器與瀏覽器直接連線(點對點),即可實現音視訊傳輸。

如果你接觸過直播技術,你就會知道“沒有媒體伺服器”多麼令人驚訝。以往的直播技術大多是基於推流/拉流的邏輯實現的。要想做音視訊直播,則必須有一臺流媒體伺服器做為中間站做資料轉發。但是這種推拉流的方案有兩個問題:

  1. 較高的延遲
  2. 清晰度難以保證

因為兩端通訊都要先過伺服器,就好比本來是一條直路,你偏偏“繞了半個圈”,這樣肯定會花更多的時間,因此直播必然會有延遲,即使延遲再低也要 1s 以上。

清晰度高低的本質是資料量的大小。你想象一下,每天乘地鐵上班,早高峰人越多,進站的那條道就越容易堵,堵你就會走走停停,再加上還繞了路,是不是到公司就更晚了。

把這個例子聯絡到高清晰度的直播:因為資料量大就容易發生網路擁堵,擁堵就會導致播放卡頓,同時延遲性也會更高。

但是 WebRTC 就不一樣了,它不需要媒體伺服器,兩點一線直連,首先延遲性一定大大縮短。再者因為傳輸路線更短,所以清晰度高的資料流也更容易到達,相對來說不易擁堵,因此播放端不容易卡頓,這樣就兼顧了清晰度與延遲性。

當然 WebRTC 也是支援中間媒體伺服器的,有些場景下確實少不了伺服器轉發。我們這篇只探討點對點的模式,旨在幫助大家更容易的瞭解並上手 WebRTC。

獲取媒體流

點對點通訊的第一步,一定是發起端獲取媒體流。

常見的媒體裝置有三種:攝像機麥克風螢幕。其中攝像機和螢幕可以轉化為視訊流,而麥克風可轉化為音訊流。音視訊流結合起來就組成了常見的媒體流。

以 Chrome 瀏覽器為例,攝像頭和螢幕的視訊流獲取方式不一樣。對於攝像頭和麥克風,使用如下 API 獲取:

var stream = await navigator.mediaDevices.getUserMedia()

對於螢幕錄製,則會用另外一個 API。限制是這個 API 只能獲取視訊,不能獲取音訊:

var stream = await navigator.mediaDevices.getDisplayMedia()
注意:這裡我遇到過一個問題,編輯器裡提示 navigator.mediaDevices == undefined,原因是我的 typescript 版本小於 4.4,升級版本即可。

這兩個獲取媒體流的 API 有使用條件,必須滿足以下兩種情況之一:

  • 域名是 localhost
  • 協議是 https

如果不滿足,則 navigator.mediaDevices 的值就是 undefined

以上方法都有一個引數 constraints,這個引數是一個配置物件,稱為 媒體約束。這裡面最有用的是可以配置只獲取音訊或視訊,或者音視訊同時獲取。

比如我只要視訊,不要音訊,就可以這樣:

let stream = await navigator.mediaDevices.getDisplayMedia({
  audio: false,
  video: true
})

除了簡單的配置獲取視訊之外,還可以對視訊的清晰度,位元速率等涉及視訊質量相關的引數做配置。比如我需要獲取 1080p 的超清視訊,我就可以這樣配:

var stream = await navigator.mediaDevices.getDisplayMedia({
  audio: false,
  video: {
    width: 1920,
    height: 1080
  }
})

當然了,這裡配置視訊的解析度 1080p,並不代表實際獲取的視訊一定是 1080p。比如我的攝像頭是 720p 的,那即便我配置了 2k 的解析度,實際獲取的最多也是 720p,這個和硬體與網路有關係。

上面說了,媒體流是由音訊流和視訊流組成的。再說的嚴謹一點,一個媒體流(MediaStream)會包含多條媒體軌道(MediaStreamTrack),因此我們可以從媒體流中單獨獲取音訊和視訊軌道:

// 視訊軌道
let videoTracks = stream.getVideoTracks()
// 音訊軌道
let audioTracks = stream.getAudioTracks()
// 全部軌道
stream.getTracks()

單獨獲取軌道有什麼意義呢?比如上面的獲取螢幕的 API getDisplayMedia 無法獲取音訊,但是我們直播的時候既需要螢幕也需要聲音,此時就可以分別獲取音訊和視訊,然後組成一個新的媒體流。實現如下:

const getNewStream = async () => {
  var stream = new MediaStream()
  let audio_stm = await navigator.mediaDevices.getUserMedia({
    audio: true
  })
  let video_stm = await navigator.mediaDevices.getDisplayMedia({
    video: true
  })
  audio_stm.getAudioTracks().map(row => stream.addTrack(row))
  video_stm.getVideoTracks().map(row => stream.addTrack(row))
  return stream
}

對等連線流程

要說 WebRTC 有什麼不優雅的地方,首先要提的就是連線步驟複雜。很多同學就因為總是連線不成功,結果被成功勸退。

對等連線,也就是上面說的點對點連線,核心是由 RTCPeerConnection 函式實現。兩個瀏覽器之間點對點的連線和通訊,本質上是兩個 RTCPeerConnection 例項的連線和通訊。

RTCPeerConnection 建構函式建立的兩個例項,成功建立連線之後,可以傳輸視訊、音訊或任意二進位制資料(需要支援 RTCDataChannel API )。同時也提供了連線狀態監控,關閉連線的方法。不過兩點之間資料單向傳輸,只能由發起端向接收端傳遞。

我們現在根據核心 API,梳理一下具體連線步驟。

第一步:建立連線例項

首先建立兩個連線例項,這兩個例項就是互相通訊的雙方。

var peerA = new RTCPeerConnection()
var peerB = new RTCPeerConnection()
下文統一將發起直播的一端稱為 發起端,接收觀看直播的一端稱為 接收端

現在的這兩個連線例項都還沒有資料。假設 peerA 是發起端,peerB 是接收端,那麼 peerA 的那端就要像上一步一樣獲取到媒體流資料,然後新增到 peerA 例項,實現如下:

var stream = await navigator.mediaDevices.getUserMedia()
stream.getTracks().forEach(track => {
  peerA.addTrack(track, stream)
})

當 peerA 新增了媒體資料,那麼 peerB 必然會在後續連線的某個環節接收到媒體資料。因此還要為 peerB 設定監聽函式,獲取媒體資料:

peerB.ontrack = async event => {
  let [ remoteStream ] = event.streams
  console.log(remoteStream)
})

這裡要注意:必須 peerA 新增媒體資料之後,才能進行下一步! 否則後續環節中 peerB 的 ontrack 事件就不會觸發,也就不會拿到媒體流資料。

第二步:建立對等連線

新增資料之後,兩端就可以開始建立對等連線。

建立連線最重要的角色是 SDP(RTCSessionDescription),翻譯過來就是 會話描述。連線雙方需要各自建立一個 SDP,但是他們的 SDP 是不同的。發起端的 SDP 被稱為 offer,接收端的 SDP 被稱為 answer

其實兩端建立對等連線的本質就是互換 SDP,在互換的過程中相互驗證,驗證成功後兩端的連線才能成功。

現在我們為兩端建立 SDP。peerA 建立 offer,peerB 建立 answer:

var offer = await peerA.createOffer()
var answer = await peerB.createAnswer()

建立之後,首先接收端 peerB 要將 offset 設定為遠端描述,然後將 answer 設定為本地描述:

await peerB.setRemoteDescription(offer)
await peerB.setLocalDescription(answer)
注意:當 peerB.setRemoteDescription 執行之後,peerB.ontrack 事件就會觸發。當然前提是第一步為 peerA 新增了媒體資料。

這個很好理解。offer 是 peerA 建立的,相當於是連線的另一端,因此要設為“遠端描述”。answer 是自己建立的,自然要設定為“本地描述”。

同樣的邏輯,peerB 設定完成後,peerA 也要將 answer 設為遠端描述,offer 設定為本地描述。

await peerA.setRemoteDescription(answer)
await peerA.setLocalDescription(offer)

到這裡,互相交換 SDP 已完成。但是通訊還未結束,還差最後一步。

當 peerA 執行 setLocalDescription 函式時會觸發 onicecandidate 事件,我們需要定義這個事件,然後在裡面為 peerB 新增 candidate

peerA.onicecandidate = event => {
  if (event.candidate) {
    peerB.addIceCandidate(event.candidate)
  }
}

至此,端對端通訊才算是真正建立了!如果過程順利的話,此時 peerB 的 ontrack 事件內應該已經接收到媒體流資料了,你只需要將媒體資料渲染到一個 video 標籤上即可實現播放。

還要再提一次:這幾步看似簡單,實際順序非常重要,一步都不能出錯,否則就會連線失敗!如果你在實踐中遇到問題,一定再回頭檢查一下步驟有沒有出錯。

最後我們再為 peerA 新增狀態監聽事件,檢測連線是否成功:

peerA.onconnectionstatechange = event => {
  if (peerA.connectionState === 'connected') {
    console.log('對等連線成功!')
  }
  if (peerA.connectionState === 'disconnected') {
    console.log('連線已斷開!')
  }
}

本地模擬通訊原始碼

上一步我們梳理了點對點通訊的流程,其實主要程式碼也就這麼多。這一步我們再把這些知識點串起來,簡單實現一個本地模擬通訊的 Demo,執行起來讓大家看效果。

首先是頁面佈局,非常簡單。兩個 video 標籤,一個播放按鈕:

<div class="local-stream-page">
  <video autoplay controls muted id="elA"></video>
  <video autoplay controls muted id="elB"></video>
  <button onclick="onStart()">播放</button>
</div>

然後設定全域性變數:

var peerA = null
var peerB = null
var videoElA = document.getElementById('elA')
var videoElB = document.getElementById('elB')

按鈕繫結了一個 onStart 方法,在這個方法內獲取媒體資料:

const onStart = async () => {
  try {
    var stream = await navigator.mediaDevices.getUserMedia({
      audio: true,
      video: true
    })
    if (videoElA.current) {
      videoElA.current.srcObject = stream // 在 video 標籤上播放媒體流
    }
    peerInit(stream) // 初始化連線
  } catch (error) {
    console.log('error:', error)
  }
}

onStart 函式裡呼叫了 peerInit 方法,在這個方法內初始化連線:

const peerInit = stream => {
  // 1. 建立連線例項
  var peerA = new RTCPeerConnection()
  var peerB = new RTCPeerConnection()
  // 2. 新增視訊流軌道
  stream.getTracks().forEach(track => {
    peerA.addTrack(track, stream)
  })
  // 新增 candidate
  peerA.onicecandidate = event => {
    if (event.candidate) {
      peerB.addIceCandidate(event.candidate)
    }
  }
  // 檢測連線狀態
  peerA.onconnectionstatechange = event => {
    if (peerA.connectionState === 'connected') {
      console.log('對等連線成功!')
    }
  }
  // 監聽資料傳來
  peerB.ontrack = async event => {
    const [remoteStream] = event.streams
    videoElB.current.srcObject = remoteStream
  }
  // 互換sdp認證
  transSDP()
}

初始化連線之後,在 transSDP 方法中互換 SDP 建立連線:

const transSDP = async () => {
  // 1. 建立 offer
  let offer = await peerA.createOffer()
  await peerB.setRemoteDescription(offer)
  // 2. 建立 answer
  let answer = await peerB.createAnswer()
  await peerB.setLocalDescription(answer)
  // 3. 傳送端設定 SDP
  await peerA.setLocalDescription(offer)
  await peerA.setRemoteDescription(answer)
}
注意:這個方法裡的程式碼順序非常重要,如果改了順序多半會連線失敗!

如果順利的話,此時已經連線成功。截圖如下:

image.png

我們用兩個 video 標籤和三個方法,實現了本地模擬通訊的 demo。其實 “本地模擬通訊” 就是模擬 peerA 和 peerB 通訊,把兩個客戶端放在了一個頁面上,當然實際情況不可能如此,這個 demo 只是幫助我們理清通訊流程。

Demo 完整程式碼我已經上傳 GitHub,需要查閱請看 這裡,拉程式碼直接開啟 index.html 即可看到效果。

接下來我們探索真實場景 —— 區域網如何通訊。

區域網兩端通訊

上一節實現了本地模擬通訊,在一個頁面模擬了兩個端連線。現在思考一下:如果 peerA 和 peerB 是一個區域網下的兩個客戶端,那麼本地模擬通訊的程式碼需要怎麼改呢?

本地模擬通訊我們用了 兩個標籤三個方法 來實現。如果分開的話,首先 peerA 和 peerB 兩個例項,以及各自繫結的事件,肯定是分開定義的,兩個 video 標籤也同理。然後獲取媒體流的 onStart 方法一定在發起端 peerA,也沒問題,但是互換 SDP 的 transSDP 方法此時就失效了。

為啥呢?比如在 peerA 端:

// peerA 端
let offer = await peerA.createOffer()
await peerA.setLocalDescription(offer)
await peerA.setRemoteDescription(answer)

這裡設定遠端描述用到了 answer,那麼 answer 從何而來?

本地模擬通訊我們是在同一個檔案裡定義變數,可以互相訪問。但是現在 peerB 在另一個客戶端,answer 也在 peerB 端,這樣的話就需要在 peerB 端創好 answer 之後,傳到 peerA 端。

相同的道理,peerA 端建立好 offer 之後,也要傳到 peerB 端。這樣就需要兩個客戶端遠端交換 SDP,這個過程被稱作 信令

沒錯,信令是遠端交換 SDP 的過程,並不是某種憑證。

兩個客戶端需要互相主動交換資料,那麼就需要一個伺服器提供連線與傳輸。而“主動交換”最適合的實現方案就是 WebSocket,因此我們需要基於 WebSocket 搭建一個 信令伺服器 來實現 SDP 互換。

不過本篇不會詳解信令伺服器,我會單獨出一篇搭建信令伺服器的文章。現在我們用兩個變數 socketAsocketB 來表示 peerA 和 peerB 兩端的 WebSocket 連線,然後改造對等連線的邏輯。

首先修改 peerA 端 SDP 的傳遞與接收程式碼:

// peerA 端
const transSDP = async () => {
  let offer = await peerA.createOffer()
  // 向 peerB 傳輸 offer
  socketA.send({ type: 'offer', data: offer })
  // 接收 peerB 傳來的 answer
  socketA.onmessage = async evt => {
    let { type, data } = evt.data
    if (type == 'answer') {
      await peerA.setLocalDescription(offer)
      await peerA.setRemoteDescription(data)
    }
  }
}

這個邏輯是發起端 peerA 建立 offer 之後,立即傳給 peerB 端。當 peerB 端執行完自己的程式碼並建立 answer 之後,再回傳給 peerA 端,此時 peerA 再設定自己的描述。

此外,還有 candidate 的部分也需要遠端傳遞:

// peerA 端
peerA.onicecandidate = event => {
  if (event.candidate) {
    socketA.send({ type: 'candid', data: event.candidate })
  }
}

peerB 端稍有不同,必須是接收到 offer 並設定為遠端描述之後,才可以建立 answer,建立之後再發給 peerA 端,同時也要接收 candidate 資料:

// peerB 端,接收 peerA 傳來的 offer
socketB.onmessage = async evt => {
  let { type, data } = evt.data
  if (type == 'offer') {
    await peerB.setRemoteDescription(data)
    let answer = await peerB.createAnswer()
    await peerB.setLocalDescription(answer)
    // 向 peerA 傳輸 answer
    socketB.send({ type: 'answer', data: answer })
  }
  if (type == 'candid') {
    peerB.addIceCandidate(data)
  }
}

這樣兩端通過遠端互傳資料的方式,就實現了區域網內兩個客戶端的連線通訊。

總結一下,兩個客戶端監聽對方的 WebSocket 傳送訊息,然後接收對方的 SDP,互相設定為遠端描述。接收端還要獲取 candidate 資料,這樣“信令”這個過程就跑通了。

一對多通訊

前面我們講的,不管是本地模擬通訊,還是區域網兩端通訊,都屬於“一對一”通訊。

然而在很多場景下,比如線上教育班級直播課,一個老師可能要面對 20 個學生,這是典型的一對多場景。但是 WebRTC 只支援點對點通訊,也就是一個客戶端只能與一個客戶端建立連線,那這種情況該怎麼辦呢?

記不記得前面說過:兩個客戶端之間點對點的連線和通訊,本質上是兩個 RTCPeerConnection 例項的連線和通訊。

那我們變通一下,比如現在接收端可能是 peerB,peerC,peerD 等等好幾個客戶端,建立連線的邏輯與之前的一樣不用變。那麼發起端能否從“一個連線例項”擴充套件到“多個連線例項”呢?

也就是說,發起端雖然是一個客戶端,但是不是可以同時建立多個 RTCPeerConnection 例項。這樣的話,一對一連線的本質沒有變,只不過把多個連線例項放到了一個客戶端,每個例項再與其他接收端連線,變相的實現了一對多通訊。

具體思路是:發起端維護一個連線例項的陣列,當一個接收端請求建立連線時,發起端新建一個連線例項與這個接收端通訊,連線成功後,再將這個例項 push 到陣列裡面。當連線斷開時,則會從陣列裡刪掉這個例項。

這種方式我親測有效,下面我們對發起端的程式碼改造。其中型別為 join 的訊息,表示連線端請求連線。

// 發起端
var offer = null
var Peers = [] // 連線例項陣列

// 接收端請求連線,傳來標識id
const newPeer = async id => {
  // 1. 建立連線
  let peer = new RTCPeerConnection()
  // 2. 新增視訊流軌道
  stream.getTracks().forEach(track => {
    peer.addTrack(track, stream)
  })
  // 3. 建立並傳遞 SDP
  offer = await peerA.createOffer()
  socketA.send({ type: 'offer', data: { id, offer } })
  // 5. 儲存連線
  Peers.push({ id, peer })
}

// 監聽接收端的資訊
socketA.onmessage = async evt => {
  let { type, data } = evt.data
  // 接收端請求連線
  if (type == 'join') {
    newPeer(data)
  }
  if (type == 'answer') {
    let index = Peers.findIndex(row => row.id == data.id)
    if (index >= 0) {
      await Peers[index].peer.setLocalDescription(offer)
      await Peers[index].peer.setRemoteDescription(data.answer)
    }
  }
}

這個就是核心邏輯了,其實不難,思路理順了就很簡單。

因為信令伺服器我們還沒有詳細介紹,實際的一對多通訊需要信令伺服器參與,所以這裡我只介紹下實現思路和核心程式碼。更詳細的實現,我會在下一篇介紹信令伺服器的文章再次實戰一對多通訊,到時候完整原始碼一併奉上。

我想學更多

為了更好的保護原創,之後的文章我會首發微信公眾號 前端砍柴人。這個公眾號只做原創,每週至少一篇高質量文章,方向是前端工程與架構,Node.js 邊界探索,一體化開發與應用交付等實踐與思考。

除此之外,我還建了一個微信群,專門提供對這個方向感興趣的同學交流與學習。如果你也感興趣,歡迎加我微信  ruidoc  拉你入群,我們一同進步~

相關文章