在前端領域,WebRTC是一個相對小眾的技術;但對於在線教育而言,卻又是非常的核心。網上關於WebRTC的文章很多,本文將嘗試以WebRTC工作過程為脈絡進行介紹,讓讀者對這門技術有一個完整的概念。
WebRTC(Web Real-Time Communications) 是由谷歌開源並推進納入W3C標準的一項音視頻技術,旨在通過 點對點 的方式,在不借助中間媒介的情況下,實現瀏覽器之間的實時音視頻通信。
與Web世界經典的B/S架構最大的不同是,WebRTC的通信不經過服務器,而直接與客戶端連接,在節省服務器資源的同時,提高通信效率。為了做到這點,一個典型的WebRTC通信過程,包含四個步驟: 找到對方,進行協商,建立連接,開始通訊 。下面將分別闡述這四個步驟。
第一步:找到對方
雖然不需要經過服務器進行通信,但是在開始通信之前,必須知道對方的存在,這個時候就需要 信令服務器 。
信令服務器
所謂信令(signaling)服務器,是一個幫助雙方建立連接的「中間人」,WebRTC並沒有規定信令服務器的標準,意味著開發者可以用任何技術來實現,如 WebSocket 或 AJAX 。
發起WebRTC通信的兩端被稱為 對等端(Peer) ,成功建立的連接被稱為 PeerConnection ,一次WebRTC通信可包含多個 PeerConnection 。
<code>const
pc2 =new
RTCPeerConnection({...}); 複製代碼/<code>
在尋找對等端階段,信令服務器的工作一般是 標識與驗證參與者的身份 ,瀏覽器連接信令服務器併發送會話必須信息,如房間號、賬號信息等,由信令服務器找到可以通信的對等端並開始嘗試通信。
其實在整個WebRTC通信過程中,信令服務器都是一個非常重要的角色,除了上述作用,SDP交換、ICE連接等都離不開信令,後文將會提到。
第二步:進行協商
協商過程主要指 SDP交換 。
SDP協議
SDP(Session Description Protocol)指 會話描述協議 ,是一種通用的協議,使用範圍不僅限於WebRTC。主要用來描述多媒體會話,用途包括會話聲明、會話邀請、會話初始化等。
在WebRTC中,SDP主要用來描述:
- 設備支持的媒體能力,包括編解碼器等
- ICE候選地址
- 流媒體傳輸協議
SDP協議基於文本,格式非常簡單,它由多個行組成,每一行都為一下格式:
<code><
type
>=<
value
>/<code>
其中, type 表示屬性名, value 表示屬性值,具體格式與 type 有關。下面是一份典型的SDP協議樣例:
<code>v
=0
o
=alice2890844526
2890844526
IN IP4 host.anywhere.coms
=c
=IN IP4 host.anywhere.comt
=0
0
m
=audio49170
RTP/AVP0
a
=rtpmap:0
PCMU/8000
m
=video51372
RTP/AVP31
a
=rtpmap:31
H261/90000
m
=video53000
RTP/AVP32
a
=rtpmap:32
MPV/90000
/<code>
其中:
- v= 代表協議版本號
- o= 代表會話發起者,包括 username 、 sessionId 等
- s= 代表session名稱,為唯一字段
- c= 代表連接信息,包括網絡類型、地址類型、地址等
- t= 代表會話時間,包括開始/結束時間,均為 0 表示持久會話
- m= 代表媒體描述,包括媒體類型、端口、傳輸協議、媒體格式等
- a= 代表附加屬性,此處用於對媒體協議進行擴展
Plan B VS Unified Plan
在WebRTC發展過程中,SDP的語義(semantics)也發生了多次改變,目前使用最多的是 Plan B 和 Unified Plan 兩種。兩者均可在一個 PeerConnection 中表示多路媒體流,區別在於:
- Plan B :所有視頻流和所有音頻流各自放在一個 m= 值裡,用 ssrc 區分
- Unified Plan :每路流各自用一個 m= 值
目前最新發布的 WebRTC 1.0 採用的是 Unified Plan ,已被主流瀏覽器支持並默認開啟。Chrome瀏覽器支持通過以下API獲取當前使用的semantics:
<code>RTCPeerconnection
.getConfiguration
().sdpSemantics
; /<code>
協商過程
協商過程並不複雜,如下圖所示:
會話發起者通過 createOffer 創建一個 offer ,經過信令服務器發送到接收方,接收方調用 createAnswer 創建 answer
並返回給發送方,完成交換。
<code>const
pc1 =new
RTCPeerConnection();const
offer =await
pc1.createOffer(); pc1.setLocalDescription(offer); sendOffer(offer); onReveiveAnswer((
answer
) => { pc1.setRemoteDescription(answer); });const
pc2 =new
RTCPeerConnection(); onReveiveOffer((
offer
) => { pc2.setRemoteDescription(answer);const
answer =await
pc2.createAnswer(); pc2.setLocalDescription(answer); sendAnswer(answer); });/<code>
值得注意的是,隨著通信過程中雙方相關信息的變化,SDP交換可能會進行多次。
第三步:建立連接
現代互聯網環境非常複雜,我們的設備通常隱藏在層層網關後面,因此,要建立直接的連接,還需要知道雙方可用的連接地址,這個過程被稱為
NAT穿越 ,主要由 ICE服務器 完成,所以也稱為 ICE打洞 。ICE
ICE(Interactive Connectivity Establishment)服務器是獨立於通信雙方外的第三方服務器,其主要作用,是獲取設備的可用地址,供對等端進行連接,由 STUN(Session Traversal Utilities for NAT)服務器 來完成。每一個可用地址,都被稱為一個 ICE候選項(ICE Candidate) ,瀏覽器將從候選項中選出最合適的使用。其中,候選項的類型及優先級如下:
- 主機候選項 :通過設備網卡獲取,通常是內網地址,優先級最高
- 反射地址候選項 :由ICE服務器獲取,屬於設備在外網的地址,獲取過程比較複雜,可以簡單理解為:瀏覽器向服務器發送多個檢測請求,根據服務器的返回情況,來綜合判斷並獲知自身在公網中的地址
- 中繼候選項 :由ICE中繼服務器提供,前兩者都行不通之後的兜底選擇,優先級最低
新建 PeerConnection 時可指定ICE服務器地址,每次WebRTC找到一個可用的候選項,都會觸發一次 icecandidate 事件,此時可調用 addIceCandidate 方法來將候選項添加到通信中:
<code>const
pc =new
RTCPeerConnection({ iceServers: [ {"url"
:"stun:stun.l.google.com:19302"
}, {"url"
:"turn:[email protected]"
,"credential"
:"pass"
} ] }); pc.addEventListener('icecandidate'
,e
=> { pc.addIceCandidate(event.candidate); });/<code>
通過候選項建立的ICE連接,可以大致分為下圖兩種情況:
- 直接P2P的連接,為上述 1&2 兩種候選項的情況;
- 通過 TURN(Traversal Using Relays around NAT)中繼服務器 的連接,為上述第三種情況。
同樣的,由於網絡變動等原因,通信過程中的ICE打洞,同樣可能發生多次。
第四步:進行通信
WebRTC選擇了 UDP 作為底層傳輸協議。為什麼不選擇可靠性更強的 TCP ?原因主要有三個:
<code>UDP
TCP /<code>
而在 UDP 之上,WebRTC使用了再封裝的 RTP 與 RTCP 兩個協議:
- RTP(Realtime Transport Protocol) :實時傳輸協議,主要用來傳輸對實時性要求比較高的數據,比如音視頻數據
- RTCP(RTP Trasport Control Protocol) :RTP傳輸控制協議,顧名思義,主要用來監控數據傳輸的質量,並給予數據發送方反饋。
在實際通信過程中,兩種協議的數據收發會同時進行。
關鍵API
下面將以一個demo的代碼,來展示前端WebRTC中都用到了哪些API:
HTML
<code> ><
html
><
head
><
meta
charset
="utf-8"
><
meta
name
="viewport"
content
="width=device-width, user-scalable=yes, initial-scale=1, maximum-scale=1"
><
meta
name
="mobile-web-app-capable"
content
="yes"
><
meta
id
="theme-color"
name
="theme-color"
content
="#ffffff"
><
base
target
="_blank"
><
title
>WebRTCtitle
><
link
rel
="stylesheet"
href
="main.css"
/>head
><
body
><
div
id
="container"
><
video
id
="localVideo"
playsinline
autoplay
muted
>video
><
video
id
="remoteVideo"
playsinline
autoplay
>video
><
div
class
="box"
><
button
id
="startButton"
>Startbutton
><
button
id
="callButton"
>Callbutton
>div
>div
><
script
src
="https://webrtc.github.io/adapter/adapter-latest.js"
>script
><
script
src
="main.js"
async
>script
>body
>html
>/<code>
JS
<code> ;const
startButton =document
.getElementById('startButton'
);const
callButton =document
.getElementById('callButton'
); callButton.disabled =true
; startButton.addEventListener('click'
, start); callButton.addEventListener('click'
, call);const
localVideo =document
.getElementById('localVideo'
);const
remoteVideo =document
.getElementById('remoteVideo'
);let
localStream;let
pc1;let
pc2;const
offerOptions = {offerToReceiveAudio
:1
,offerToReceiveVideo
:1
};async
function
start
() { startButton.disabled =true
;const
stream =await
navigator.mediaDevices.getUserMedia({audio
:true
,video
:true
}); localVideo.srcObject = stream; localStream = stream; callButton.disabled =false
; }function
gotRemoteStream
(e
) {if
(remoteVideo.srcObject !== e.streams[0
]) { remoteVideo.srcObject = e.streams[0
];console
.log('pc2 received remote stream'
); setTimeout(()
=> { pc1.getStats(null
).then(stats
=>console
.log(stats)); },2000
) } }function
getName
(pc
) {return
(pc === pc1) ?'pc1'
:'pc2'
; }function
getOtherPc
(pc
) {return
(pc === pc1) ? pc2 : pc1; }async
function
call
() { callButton.disabled =true
; pc1 =new
RTCPeerConnection({sdpSemantics
:'unified-plan'
,iceServers
: [ {"url"
:"stun:stun.l.google.com:19302"
}, {"url"
:"turn:[email protected]"
,"credential"
:"pass"
} ] }); pc1.addEventListener('icecandidate'
, e => onIceCandidate(pc1, e)); pc2 =new
RTCPeerConnection(); pc2.addEventListener('icecandidate'
, e => onIceCandidate(pc2, e)); pc2.addEventListener('track'
, gotRemoteStream); localStream.getTracks().forEach(track
=> pc1.addTrack(track, localStream));const
offer =await
pc1.createOffer(offerOptions);await
onCreateOfferSuccess(offer); }async
function
onCreateOfferSuccess
(desc
) {await
pc1.setLocalDescription(desc);await
pc2.setRemoteDescription(desc);const
answer =await
pc2.createAnswer();await
onCreateAnswerSuccess(answer); }async
function
onCreateAnswerSuccess
(desc
) {await
pc2.setLocalDescription(desc);await
pc1.setRemoteDescription(desc); }async
function
onIceCandidate
(pc, event
) {try
{await
(getOtherPc(pc).addIceCandidate(event.candidate)); onAddIceCandidateSuccess(pc); }catch
(e) { onAddIceCandidateError(pc, e); }console
.log(`
${getName(pc)}
ICE candidate:\n${event.candidate ? event.candidate.candidate :
'(null)'
}`); }function
onAddIceCandidateSuccess
(pc
) {console
.log(`
${getName(pc)}
addIceCandidate success`); }function
onAddIceCandidateError
(pc, error
) {console
.log(`
${getName(pc)}
failed to add ICE Candidate:${error.toString()}
`); }/<code>
寫在最後
作為「概覽」,本文從比較淺的層次介紹了WebRTC技術,很多細節及原理性的內容,限於篇幅未作深入闡述。