WebRTC 相關介紹

WebRTC 相關介紹

ICE 交互式連接建立(Interactive Connectivity Establishment)

ICE 是 WebTRC 建立連接的通用模式,主要分爲以下知識點

  1. NAT
    由於當前使用的 IPV4 地址的長度限制只有32位,大多數終端都沒有一個可以在互聯網上可見的唯一 IPV4 地址。NAT 是作爲一種解決 IPv4 地址短缺以避免保留 IP 地址困難的方案,在 IP 數據包通過路由器或防火牆時重寫來源 IP 地址或目的 IP 地址。
  2. STUN
    爲了進行 P2P 通信,會話參與雙方都需要知道其對等端的 IP 地址和指定的 UDP 端口。因此,在 WebRTC 通信建立之前,需要進行一定數量的信息交換。
    每個對等端需要使用一個 STUN 服務器來探測他們的公共 IP 地址,這個 IP 在連接建立的時候會被 ICE 框架所引用。STUN 服務器是通常是可公開訪問的,WebRTC應用可以自由訪問。
  3. TURN
    TURN 服務指的是中繼型 NAT 遍歷服務器,其地址是一個公共 IP 地址,用於轉發數據包給對端瀏覽器。當2個對等端因爲 NAT 類型而無法建立連接時(當遇到對稱型NAT會導致打洞失敗),才需要使用中繼服務器。

在這裏插入圖片描述

在通過 ICE Server 建立交互之後 Caller 和 Callee 一般都會保留好幾個對端的 IP 地址信息(在 WebRTC 中稱爲 RTCIceCandidate),RTC 底層會使用其中的某些進行 P2P 連接嘗試,優先使用本地私有 IP 地址進行連接測試(測試使用的信令數據稱爲 SDP,WebTRC 中稱爲 RTCSessionDescription),如果測試雙方都能進行數據報通訊,則連接建立成功。( Relay 中繼模式最後一個進行嘗試)

ICE tries to find the best path to connect peers. It tries 
all possibilities in parallel and chooses the most efficient 
option that works. ICE first tries to make a connection 
using the host address obtained from a device's operating 
system and network card; if that fails (which it will for 
devices behind NATs) ICE obtains an external address using a 
STUN server, and if that fails, traffic is routed via a TURN 
relay server.

真實 P2P 模式

在這裏插入圖片描述

Relay 模式
在這裏插入圖片描述

注:RTCSessionDescription 和 RTCIceCandidate 數據的發送和接收不是 WebRTC 的標準,需要單獨建立其他信道來傳輸這些數據,雙方都收到這些數據時才進行連接性測試

下面給出一組 ICEServer 常見的數據結構,(注:turn 方式因爲使用中繼模式會導致 server 端產生流量費用,所有需要考慮權鑑等相關邏輯),可以免費使用 Google 提供的 stun server (stun:stun.l.google.com:19302)

[
  {
    "urls": [
      "turn:39.106.53.53:3478?transport=udp",
      "turn:39.106.53.53:3478?transport=tcp",
      "stun:39.106.53.53:3478"
    ],
    "credential": "UGVWnMLVH1RbBIW3BULcro1phJU=",
    "ttl": 86400,
    "username": "1540611754:jfdream_voip",
    "password": "UGVWnMLVH1RbBIW3BULcro1phJU="
  },
  {
    "urls": [
      "stun:stun.l.google.com:19302"
    ]
  }
]

WebRTC 架構

  1. MediaStream: 從客戶攝像頭或麥克風獲取的媒體流對象。
  2. RTCPeerConnection: 連接對象,用於連接建立,媒體流傳輸。
  3. RTCDataChannel: 數據傳輸通道。

在這裏插入圖片描述

WebRTC 的基本使用(本文檔使用 js 的方式進行說明)

通過 IM 建立呼叫邀請

雙方通過 IM 通道進行音視頻的呼叫和應答

###getUserMedia 獲取客戶端媒體信息


constraints = {audio:true,video:true}

navigator.getUserMedia(constraints, successCallback, errorCallback);


約束對象(constraints)

約束對象可以被設置在getUserMedia()和RTCPeerConnection的addStream方法中,這個約束對象是WebRTC用來指定接受什麼樣的流的,其中可以定義如下屬性:

video: 是否接受視頻流
audio:是否接受音頻流
MinWidth: 視頻流的最小寬度
MaxWidth:視頻流的最大寬度
MinHeight:視頻流的最小高度
MaxHiehgt:視頻流的最大高度
MinAspectRatio:視頻流的最小寬高比
MaxAspectRatio:視頻流的最大寬高比
MinFramerate:視頻流的最小幀速率
MaxFramerate:視頻流的最大幀速率

建立 RTCPeerConnection


/*  Represents the chosen SDP semantics for the RTCPeerConnection. 注意 2018 年開始 Google 默認會使用 RTCSdpSemanticsUnifiedPlan,並拋棄一些老舊的 RTCSdpSemanticsPlanB 相關方法,比如 pc 的 addStream,使用 addTrach 來替代
typedef NS_ENUM(NSInteger, RTCSdpSemantics) {
  RTCSdpSemanticsPlanB,
  RTCSdpSemanticsUnifiedPlan,
};
*/

configuration = {{sdpSemantics: 0}}
雙方建立 pc 連接實列
pc1 = new RTCPeerConnection(configuration);
pc2 = new RTCPeerConnection(configuration);

監聽 IceCandidate 變化回調,並將 ice 相關信息通過 IM 發給對方


 pc1.addEventListener('icecandidate', e => onIceCandidate(pc1, e));

CreateOffer 設置本地 SDP信息(SessionDescription)併發送給被呼叫方


發送方: 
const offer = await pc1.createOffer(offerOptions);
await onCreateOfferSuccess(offer);

接收方:
const answer = await pc2.createAnswer();
    await onCreateAnswerSuccess(answer);

共同處理:雙方都需要設置本地 SDP 和遠程 SDP 信息,爲 PC 配置連接相關信息
function onCreateOfferSuccess(desc){
    await pc.setLocalDescription(desc);
        onSetLocalSuccess(pc);
    await pc.setRemoteDescription(desc);
    onSetRemoteSuccess(pc);
}

// 詳細描述
通過offer和answer交換SDP描述符:

甲和乙各自建立一個PC實例
甲通過PC所提供的createOffer()方法建立一個包含甲的SDP描述符的offer信令
甲通過PC所提供的setLocalDescription()方法,將甲的SDP描述符交給甲的PC實例
甲將offer信令通過服務器(普通IM,HTTP,XMPP)發送給乙
乙將甲的offer信令中所包含的的SDP描述符提取出來,通過PC所提供的setRemoteDescription()方法交給乙的PC實例
乙通過PC所提供的createAnswer()方法建立一個包含乙的SDP描述符answer信令
乙通過PC所提供的setLocalDescription()方法,將乙的SDP描述符交給乙的PC實例
乙將answer信令通過服務器發送給甲
甲接收到乙的answer信令後,將其中乙的SDP描述符提取出來,調用setRemoteDescripttion()方法交給甲自己的PC實例

通過上述方法即可建立 P2P 連接(有的時候 icecandiate 數據也要發送給對端)

數據通道建立 RCTDataChannel

dc = pc.createDataChannel('')

建立 PC 通道的時候也可以配置是否打開數據通道,方便後期起他非媒體數據的傳輸

WebRTC 優缺點

  1. 缺乏服務端相關方案的設計和部署。
  2. 傳輸質量難以保證。WebRTC的傳輸設計基於P2P,難以保障傳輸質量,在跨區域,跨運營商時數據丟失特別嚴重,一般情況下需要同事考慮 Relay 和 P2P 兩種方案,合理選擇最優
  3. WebRTC 從設計上就是單對端,雖然也有多對多的優化,但是問題依然很多,音視頻會議需要自行考慮服務端架構,音視頻會議常見的 server 端架構(SFU-Selective Forwarding Unit,MCU-Multi-point Control Unit,P2P Mesh)
  4. 設備端適配,如回聲、錄音失敗等問題層出不窮。這一點在安卓設備上尤爲突出。由於安卓設備廠商衆多,每個廠商都會在標準的安卓框架上進行定製化,導致很多可用性問題(訪問麥克風失敗)和質量問題(如回聲、嘯叫)。

SFU (開源 jitsi 支持 sfu 和 mcu)
在這裏插入圖片描述

MCU(MCU 有專業硬件廠商提供解決方案,也有開源如:kurento)
在這裏插入圖片描述

P2P Mesh(超過三人或三人以上通話時基本不可使用)
在這裏插入圖片描述

代碼示例

demo: https://webrtc.github.io/samples/src/content/peerconnection/pc1/

sdp: https://www.ietf.org/archive/id/draft-nandakumar-rtcweb-sdp-08.txt


/*
 *  Copyright (c) 2015 The WebRTC project authors. All Rights Reserved.
 *
 *  Use of this source code is governed by a BSD-style license
 *  that can be found in the LICENSE file in the root of the source
 *  tree.
 */

'use strict';

const startButton = document.getElementById('startButton');
const callButton = document.getElementById('callButton');
const hangupButton = document.getElementById('hangupButton');
callButton.disabled = true;
hangupButton.disabled = true;
startButton.addEventListener('click', start);
callButton.addEventListener('click', call);
hangupButton.addEventListener('click', hangup);

let startTime;
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');

localVideo.addEventListener('loadedmetadata', function() {
  console.log(`Local video videoWidth: ${this.videoWidth}px,  videoHeight: ${this.videoHeight}px`);
});

remoteVideo.addEventListener('loadedmetadata', function() {
  console.log(`Remote video videoWidth: ${this.videoWidth}px,  videoHeight: ${this.videoHeight}px`);
});

remoteVideo.addEventListener('resize', () => {
  console.log(`Remote video size changed to ${remoteVideo.videoWidth}x${remoteVideo.videoHeight}`);
  // We'll use the first onsize callback as an indication that video has started
  // playing out.
  if (startTime) {
    const elapsedTime = window.performance.now() - startTime;
    console.log('Setup time: ' + elapsedTime.toFixed(3) + 'ms');
    startTime = null;
  }
});

let localStream;
let pc1;
let pc2;
const offerOptions = {
  offerToReceiveAudio: 1,
  offerToReceiveVideo: 1
};

function getName(pc) {
  return (pc === pc1) ? 'pc1' : 'pc2';
}

function getOtherPc(pc) {
  return (pc === pc1) ? pc2 : pc1;
}

async function start() {
  console.log('Requesting local stream');
  startButton.disabled = true;
  try {
    const stream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
    console.log('Received local stream');
    localVideo.srcObject = stream;
    localStream = stream;
    callButton.disabled = false;
  } catch (e) {
    alert(`getUserMedia() error: ${e.name}`);
  }
}

function getSelectedSdpSemantics() {
  const sdpSemanticsSelect = document.querySelector('#sdpSemantics');
  const option = sdpSemanticsSelect.options[sdpSemanticsSelect.selectedIndex];
  return option.value === '' ? {} : {sdpSemantics: option.value};
}

async function call() {
  callButton.disabled = true;
  hangupButton.disabled = false;
  console.log('Starting call');
  startTime = window.performance.now();
  const videoTracks = localStream.getVideoTracks();
  const audioTracks = localStream.getAudioTracks();
  if (videoTracks.length > 0) {
    console.log(`Using video device: ${videoTracks[0].label}`);
  }
  if (audioTracks.length > 0) {
    console.log(`Using audio device: ${audioTracks[0].label}`);
  }
  const configuration = getSelectedSdpSemantics();
  console.log('RTCPeerConnection configuration:', configuration);
  pc1 = new RTCPeerConnection(configuration);
  console.log('Created local peer connection object pc1');
  pc1.addEventListener('icecandidate', e => onIceCandidate(pc1, e));
  pc2 = new RTCPeerConnection(configuration);
  console.log('Created remote peer connection object pc2');
  pc2.addEventListener('icecandidate', e => onIceCandidate(pc2, e));
  pc1.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc1, e));
  pc2.addEventListener('iceconnectionstatechange', e => onIceStateChange(pc2, e));
  pc2.addEventListener('track', gotRemoteStream);

  localStream.getTracks().forEach(track => pc1.addTrack(track, localStream));
  console.log('Added local stream to pc1');

  try {
    console.log('pc1 createOffer start');
    const offer = await pc1.createOffer(offerOptions);
    await onCreateOfferSuccess(offer);
  } catch (e) {
    onCreateSessionDescriptionError(e);
  }
}

function onCreateSessionDescriptionError(error) {
  console.log(`Failed to create session description: ${error.toString()}`);
}

async function onCreateOfferSuccess(desc) {
  console.log(`Offer from pc1\n${desc.sdp}`);
  console.log('pc1 setLocalDescription start');
  try {
    await pc1.setLocalDescription(desc);
    onSetLocalSuccess(pc1);
  } catch (e) {
    onSetSessionDescriptionError();
  }

  console.log('pc2 setRemoteDescription start');
  try {
    await pc2.setRemoteDescription(desc);
    onSetRemoteSuccess(pc2);
  } catch (e) {
    onSetSessionDescriptionError();
  }

  console.log('pc2 createAnswer start');
  // Since the 'remote' side has no media stream we need
  // to pass in the right constraints in order for it to
  // accept the incoming offer of audio and video.
  try {
    const answer = await pc2.createAnswer();
    await onCreateAnswerSuccess(answer);
  } catch (e) {
    onCreateSessionDescriptionError(e);
  }
}

function onSetLocalSuccess(pc) {
  console.log(`${getName(pc)} setLocalDescription complete`);
}

function onSetRemoteSuccess(pc) {
  console.log(`${getName(pc)} setRemoteDescription complete`);
}

function onSetSessionDescriptionError(error) {
  console.log(`Failed to set session description: ${error.toString()}`);
}

function gotRemoteStream(e) {
  if (remoteVideo.srcObject !== e.streams[0]) {
    remoteVideo.srcObject = e.streams[0];
    console.log('pc2 received remote stream');
  }
}

async function onCreateAnswerSuccess(desc) {
  console.log(`Answer from pc2:\n${desc.sdp}`);
  console.log('pc2 setLocalDescription start');
  try {
    await pc2.setLocalDescription(desc);
    onSetLocalSuccess(pc2);
  } catch (e) {
    onSetSessionDescriptionError(e);
  }
  console.log('pc1 setRemoteDescription start');
  try {
    await pc1.setRemoteDescription(desc);
    onSetRemoteSuccess(pc1);
  } catch (e) {
    onSetSessionDescriptionError(e);
  }
}

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()}`);
}

function onIceStateChange(pc, event) {
  if (pc) {
    console.log(`${getName(pc)} ICE state: ${pc.iceConnectionState}`);
    console.log('ICE state change event: ', event);
  }
}

function hangup() {
  console.log('Ending call');
  pc1.close();
  pc2.close();
  pc1 = null;
  pc2 = null;
  hangupButton.disabled = true;
  callButton.disabled = false;
}



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章