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;
}



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