【前端實時音視頻】WebRTC入門概覽

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

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

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

第一步:找到對方

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

信令服務器

所謂信令(signaling)服務器,是一個幫助雙方創建鏈接的「中間人」,WebRTC並無規定信令服務器的標準,意味着開發者能夠用任何技術來實現,如WebSocketAJAX
前端

發起WebRTC通訊的兩端被稱爲對等端(Peer),成功創建的鏈接被稱爲PeerConnection,一次WebRTC通訊可包含多個PeerConnectionjava

const pc2 = new RTCPeerConnection({...});

在尋找對等端階段,信令服務器的工做通常是標識與驗證參與者的身份,瀏覽器鏈接信令服務器併發送會話必須信息,如房間號、帳號信息等,由信令服務器找到能夠通訊的對等端並開始嘗試通訊。git

其實在整個WebRTC通訊過程當中,信令服務器都是一個很是重要的角色,除了上述做用,SDP交換、ICE鏈接等都離不開信令,後文將會提到。github

第二步:進行協商

協商過程主要指SDP交換web

SDP協議

SDP(Session Description Protocol)指會話描述協議,是一種通用的協議,使用範圍不只限於WebRTC。主要用來描述多媒體會話,用途包括會話聲明、會話邀請、會話初始化等。瀏覽器

在WebRTC中,SDP主要用來描述:服務器

  • 設備支持的媒體能力,包括編解碼器等
  • ICE候選地址
  • 流媒體傳輸協議

SDP協議基於文本,格式很是簡單,它由多個行組成,每一行都爲一下格式:

<type>=<value>

其中,type表示屬性名,value表示屬性值,具體格式與type有關。下面是一份典型的SDP協議樣例:

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

其中:

  1. v=表明協議版本號
  2. o=表明會話發起者,包括usernamesessionId
  3. s=表明session名稱,爲惟一字段
  4. c=表明鏈接信息,包括網絡類型、地址類型、地址等
  5. t=表明會話時間,包括開始/結束時間,均爲0表示持久會話
  6. m=表明媒體描述,包括媒體類型、端口、傳輸協議、媒體格式等
  7. a=表明附加屬性,此處用於對媒體協議進行擴展

Plan B VS Unified Plan

在WebRTC發展過程當中,SDP的語義(semantics)也發生了屢次改變,目前使用最多的是Plan BUnified Plan兩種。二者都可在一個PeerConnection中表示多路媒體流,區別在於:

  • Plan B:全部視頻流和全部音頻流各自放在一個m=值裏,用ssrc區分
  • Unified Plan:每路流各自用一個m=

目前最新發布的 WebRTC 1.0 採用的是Unified Plan,已被主流瀏覽器支持並默認開啓。Chrome瀏覽器支持經過如下API獲取當前使用的semantics:

// Chrome
RTCPeerconnection.getConfiguration().sdpSemantics; // 'unified-plan' or 'plan b'

協商過程

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

會話發起者經過createOffer建立一個offer,通過信令服務器發送到接收方,接收方調用createAnswer建立answer並返回給發送方,完成交換。

// 發送方,sendOffer/onReveiveAnswer爲僞方法
const pc1 = new RTCPeerConnection();
const offer = await pc1.createOffer();
pc1.setLocalDescription(offer);
sendOffer(offer);

onReveiveAnswer((answer) => {
  pc1.setRemoteDescription(answer);
});

// 接收方,sendAnswer/onReveiveOffer爲僞方法
const pc2 = new RTCPeerConnection();
onReveiveOffer((offer) => {
  pc2.setRemoteDescription(answer);
  const answer = await pc2.createAnswer();
  pc2.setLocalDescription(answer);
  sendAnswer(answer);
});

值得注意的是,隨着通訊過程當中雙方相關信息的變化,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方法來將候選項添加到通訊中:

const pc = new RTCPeerConnection({
  iceServers: [
    { "url": "stun:stun.l.google.com:19302" },
    { "url": "turn:user@turnserver.com", "credential": "pass" }
  ] // 配置ICE服務器
}); 
pc.addEventListener('icecandidate', e => {
  pc.addIceCandidate(event.candidate);
});

經過候選項創建的ICE鏈接,能夠大體分爲下圖兩種狀況:

  1. 直接P2P的鏈接,爲上述 1&2 兩種候選項的狀況;
  2. 經過TURN(Traversal Using Relays around NAT)中繼服務器的鏈接,爲上述第三種狀況。

一樣的,因爲網絡變更等緣由,通訊過程當中的ICE打洞,一樣可能發生屢次。

第四步:進行通訊

WebRTC選擇了UDP做爲底層傳輸協議。爲何不選擇可靠性更強的TCP?緣由主要有三個:

  1. UDP協議無鏈接,資源消耗小,速度快
  2. 傳輸過程當中少許的數據損失影響不大
  3. TCP協議的超時重連機制會形成很是明顯的延遲

而在UDP之上,WebRTC使用了再封裝的RTPRTCP兩個協議:

  • RTP(Realtime Transport Protocol):實時傳輸協議,主要用來傳輸對實時性要求比較高的數據,好比音視頻數據
  • RTCP(RTP Trasport Control Protocol):RTP傳輸控制協議,顧名思義,主要用來監控數據傳輸的質量,並給予數據發送方反饋。

在實際通訊過程當中,兩種協議的數據收發會同時進行。

關鍵API

下面將以一個demo的代碼,來展現前端WebRTC中都用到了哪些API:

HTML

<!DOCTYPE html>
<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>

JS

'use strict';

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', // 指定使用 unified plan
    iceServers: [
        { "url": "stun:stun.l.google.com:19302" },
        { "url": "turn:user@turnserver.com", "credential": "pass" }
    ] // 配置ICE服務器
  }); 
  pc1.addEventListener('icecandidate', e => onIceCandidate(pc1, e)); // 監聽ice候選項事件

  /**
   * 建立應答鏈接
   */
  pc2 = new RTCPeerConnection();

  pc2.addEventListener('icecandidate', e => onIceCandidate(pc2, e));
  pc2.addEventListener('track', gotRemoteStream);

  /**
   * 添加本地媒體流
   */
  localStream.getTracks().forEach(track => pc1.addTrack(track, localStream));

  /**
   * pc1 createOffer
   */
  const offer = await pc1.createOffer(offerOptions); // 建立offer
  await onCreateOfferSuccess(offer);
}

async function onCreateOfferSuccess(desc) {
  /**
   * pc1 設置本地sdp
   */
  await pc1.setLocalDescription(desc);

  
  /******* 如下以pc2爲對方,來模擬收到offer的場景 *******/

  /**
   * pc2 設置遠程sdp
   */
  await pc2.setRemoteDescription(desc);
  
  /**
   * pc2 createAnswer
   */
  const answer = await pc2.createAnswer(); // 建立answer
  await onCreateAnswerSuccess(answer);
}

async function onCreateAnswerSuccess(desc) {
  /**
   * pc2 設置本地sdp
   */
  await pc2.setLocalDescription(desc);

  /**
   * pc1 設置遠程sdp
   */
  await pc1.setRemoteDescription(desc);
}

async function onIceCandidate(pc, event) {
  try {
    await (getOtherPc(pc).addIceCandidate(event.candidate)); // 設置ice候選項
    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()}`);
}

寫在最後

做爲「概覽」,本文從比較淺的層次介紹了WebRTC技術,不少細節及原理性的內容,限於篇幅未做深刻闡述。筆者也是剛接觸幾個月,若有謬誤,還請告知。

在實際業務中,對WebRTC的使用並不是簡單的P2P通訊,後面將另開話題,來聊聊在線教育業務在實時音視頻中是如何使用WebRTC,又是如何實現百萬級同時在線上課的。

相關文章
相關標籤/搜索