在前端领域,WebRTC是一个相对小众的技术;但对于在线教育而言,却又是非常的核心。网上关于WebRTC的文章很多,本文将尝试以WebRTC工作过程为脉络进行介绍,让读者对这门技术有一个完整的概念。
WebRTC(Web Real-Time Communications) 是由谷歌开源并推进纳入W3C标准的一项音视频技术,旨在通过 点对点 的方式,在不借助中间媒介的情况下,实现浏览器之间的实时音视频通信。
与Web世界经典的B/S架构最大的不同是,WebRTC的通信不经过服务器,而直接与客户端连接,在节省服务器资源的同时,提高通信效率。为了做到这点,一个典型的WebRTC通信过程,包含四个步骤: 找到对方,进行协商,建立连接,开始通讯 。下面将分别阐述这四个步骤。
第一步:找到对方
虽然不需要经过服务器进行通信,但是在开始通信之前,必须知道对方的存在,这个时候就需要 信令服务器 。
信令服务器
所谓信令(signaling)服务器,是一个帮助双方建立连接的「中间人」,WebRTC并没有规定信令服务器的标准,意味着开发者可以用任何技术来实现,如 WebSocket 或 AJAX 。
发起WebRTC通信的两端被称为 对等端(Peer) ,成功建立的连接被称为 PeerConnection ,一次WebRTC通信可包含多个 PeerConnection 。
const pc2 = new RTCPeerConnection({...});
复制代码
在寻找对等端阶段,信令服务器的工作一般是 标识与验证参与者的身份 ,浏览器连接信令服务器并发送会话必须信息,如房间号、账号信息等,由信令服务器找到可以通信的对等端并开始尝试通信。
其实在整个WebRTC通信过程中,信令服务器都是一个非常重要的角色,除了上述作用,SDP交换、ICE连接等都离不开信令,后文将会提到。
第二步:进行协商
协商过程主要指 SDP交换 。
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
其中:
- 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:
// 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) ,浏览器将从候选项中选出最合适的使用。其中,候选项的类型及优先级如下:
- 主机候选项 :通过设备网卡获取,通常是内网地址,优先级最高
- 反射地址候选项 :由ICE服务器获取,属于设备在外网的地址,获取过程比较复杂,可以简单理解为:浏览器向服务器发送多个检测请求,根据服务器的返回情况,来综合判断并获知自身在公网中的地址
- 中继候选项 :由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连接,可以大致分为下图两种情况:
- 直接P2P的连接,为上述 1&2 两种候选项的情况;
- 通过 TURN(Traversal Using Relays around NAT)中继服务器 的连接,为上述第三种情况。
同样的,由于网络变动等原因,通信过程中的ICE打洞,同样可能发生多次。
第四步:进行通信
WebRTC选择了 UDP 作为底层传输协议。
而在 UDP 之上,WebRTC使用了再封装的 RTP 与 RTCP 两个协议:
- 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技术,很多细节及原理性的内容,限于篇幅未作深入阐述。
版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。