「前端實時音視頻系列」WebRTC入門概覽

在前端領域,WebRTC是一個相對小眾的技術;但對於在線教育而言,卻又是非常的核心。網上關於WebRTC的文章很多,本文將嘗試以WebRTC工作過程為脈絡進行介紹,讓讀者對這門技術有一個完整的概念。

WebRTC(Web Real-Time Communications) 是由谷歌開源並推進納入W3C標準的一項音視頻技術,旨在通過 點對點 的方式,在不借助中間媒介的情況下,實現瀏覽器之間的實時音視頻通信。

與Web世界經典的B/S架構最大的不同是,WebRTC的通信不經過服務器,而直接與客戶端連接,在節省服務器資源的同時,提高通信效率。為了做到這點,一個典型的WebRTC通信過程,包含四個步驟: 找到對方,進行協商,建立連接,開始通訊 。下面將分別闡述這四個步驟。

第一步:找到對方

雖然不需要經過服務器進行通信,但是在開始通信之前,必須知道對方的存在,這個時候就需要 信令服務器

信令服務器

所謂信令(signaling)服務器,是一個幫助雙方建立連接的「中間人」,WebRTC並沒有規定信令服務器的標準,意味著開發者可以用任何技術來實現,如 WebSocket 或 AJAX 。

「前端實時音視頻系列」WebRTC入門概覽

發起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

=alice

2890844526

2890844526

IN IP4 host.anywhere.com

s

=

c

=IN IP4 host.anywhere.com

t

=

0

0

m

=audio

49170

RTP/AVP

0

a

=rtpmap:

0

PCMU/

8000

m

=video

51372

RTP/AVP

31

a

=rtpmap:

31

H261/

90000

m

=video

53000

RTP/AVP

32

a

=rtpmap:

32

MPV/

90000

/<code>

其中:

  1. v= 代表協議版本號
  2. o= 代表會話發起者,包括 username 、 sessionId 等
  3. s= 代表session名稱,為唯一字段
  4. c= 代表連接信息,包括網絡類型、地址類型、地址等
  5. t= 代表會話時間,包括開始/結束時間,均為 0 表示持久會話
  6. m= 代表媒體描述,包括媒體類型、端口、傳輸協議、媒體格式等
  7. 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>

協商過程

協商過程並不複雜,如下圖所示:

「前端實時音視頻系列」WebRTC入門概覽

會話發起者通過 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) ,瀏覽器將從候選項中選出最合適的使用。其中,候選項的類型及優先級如下:

  1. 主機候選項 :通過設備網卡獲取,通常是內網地址,優先級最高
  2. 反射地址候選項 :由ICE服務器獲取,屬於設備在外網的地址,獲取過程比較複雜,可以簡單理解為:瀏覽器向服務器發送多個檢測請求,根據服務器的返回情況,來綜合判斷並獲知自身在公網中的地址
  3. 中繼候選項 :由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連接,可以大致分為下圖兩種情況:

「前端實時音視頻系列」WebRTC入門概覽

  1. 直接P2P的連接,為上述 1&2 兩種候選項的情況;
  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

>

WebRTC

title

>

<

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"

>

Start

button

>

<

button

id

=

"callButton"

>

Call

button

>

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技術,很多細節及原理性的內容,限於篇幅未作深入闡述。


分享到:


相關文章: