最近需要搭建一個線上課堂的直播平臺,考慮到清晰度和延遲性,我們一致認為使用 WebRTC 最合適。
原因有兩點:首先是“點對點通訊”非常吸引我們,不需要中間伺服器,客戶端直連,通訊非常方便;再者是 WebRTC 瀏覽器原生支援,其他客戶端支援也很好,不像傳統直播用 flv.js 做相容,可以實現標準統一。
然而令我非常尷尬的是,社群看了好幾篇文章,理論架構寫了一堆,但沒一個能跑起來。WebRTC 裡面概念很新也很多,理解它的通訊流程
才是最關鍵,這點恰恰很少有描述。
於是我就自己搗鼓吧。搗鼓了幾天,可算是整明白了。下面我結合自己的實踐經驗,按照我理解的關鍵步驟,帶大家從應用場景的角度認識這個厲害的朋友 —— WebRTC
。
大綱預覽
本文介紹的內容包括以下方面:
- 什麼是 WebRTC?
- 獲取媒體流
- 對等連線流程
- 本地模擬通訊原始碼
- 區域網兩端通訊
- 一對多通訊
- 我想學更多
什麼是 WebRTC?
WebRTC (Web Real-Time Communications) 是一項實時通訊技術,它允許網路應用或者站點,在不借助中間媒介的情況下,建立瀏覽器之間點對點(Peer-to-Peer)的連線,實現視訊流和音訊流或者其他任意資料的傳輸。
簡單的說,就是 WebRTC 可以不借助媒體伺服器,通過瀏覽器與瀏覽器直接連線(點對點),即可實現音視訊傳輸。
如果你接觸過直播技術,你就會知道“沒有媒體伺服器”多麼令人驚訝。以往的直播技術大多是基於推流/拉流的邏輯實現的。要想做音視訊直播,則必須有一臺流媒體伺服器做為中間站做資料轉發。但是這種推拉流的方案有兩個問題:
- 較高的延遲
- 清晰度難以保證
因為兩端通訊都要先過伺服器,就好比本來是一條直路,你偏偏“繞了半個圈”,這樣肯定會花更多的時間,因此直播必然會有延遲,即使延遲再低也要 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)
}
注意:這個方法裡的程式碼順序非常重要,如果改了順序多半會連線失敗!
如果順利的話,此時已經連線成功。截圖如下:
我們用兩個 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 互換。
不過本篇不會詳解信令伺服器,我會單獨出一篇搭建信令伺服器的文章。現在我們用兩個變數 socketA
和 socketB
來表示 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
拉你入群,我們一同進步~