[标签]软件开发,互联网
前端应用可否实现音视频聊天?
答案是:能!
借助WebRTC
不只能作到音视频聊天,还能实现点对点文件传输。javascript
WebRTC是什么?
WebRTC
(Web Real-Time Communication
)是一项实时通信技术,它容许网络应用或者站点,在不借助中间媒介的状况下,创建浏览器之间点对点(Peer-to-Peer
)的链接,实现视频流音频流或者其余任意数据的传输。
WebRTC
包含的这些标准使用户在无需安装任何插件或者第三方的软件的状况下,建立点对点(Peer-to-Peer
)的数据分享和视频会议成为可能。前端
WebRTC的前世此生
1990年,Gobal IP Solutions
成立于瑞典斯德哥尔摩(如下简称GIPS
),这是一家VoIP
软件开发商,提供了能够说是世界上最好的语音引擎。 Skype、腾讯 QQ、WebEx、Vidyo 等都使用了它的音频处理引擎,包含了受专利保护的回声消除算法,适应网络抖动和丢包的低延迟算法,以及先进的音频编解码器。Google 在 Gtalk 中也使用了 GIPS 的受权。java
2010年5月,Google以6820万美圆收购了GIPS
,并将其源代码开源。加上在 2010 年收购的 On2
获取到的 VPx
系列视频编解码器,WebRTC
开源项目应运而生,即 GIPS
音视频引擎 + 替换掉 H.264
的 VPx
。 随后,Google 又将在 Gtalk
中用于 P2P
打洞的开源项目 libjingle
融合进了 WebRTC
。程序员
2012年1月,谷经把WebRTC
集成到了Chrome浏览器中。web
因此目前 WebRTC
提供了在 Web、iOS、Android、Mac、Windows、Linux 在内的全部平台的 API,保证了 API 在全平台的一致性。算法
使用 WebRTC
的好处主要有如下几个方面typescript
- 无偿使用
GIPS
先进的音视频引擎,在此以前都须要付费受权(固然,WebRTC
因为开源,因此相较于GIPS
阉割了一部份内容,致使性能没有GIPS
那么优异)。
- 因为音视频传输是基于点对点传输的,因此实现简单的 1 对 1 通话场景,只须要较少的服务器资源,借助免费的
STUN/TURN
服务器能够大大节约成本开销。
- 开发 Web 版本的应用很是方便,使用简单的 JS 接口,无需安装任何插件,便可实现音视频互通。
当前浏览器支持状况
- 桌面PC端
- Microsoft Edge
- Google Chrome 23
- Mozilla Firefox 22
- Opera 18
- Safari 11
- Android端
- Google Chrome 28(从版本29开始默认开启)
- Mozilla Firefox 24
- Opera Mobile 12
- Google Chrome OS
- Firefox OS
- iOS 11
- Blackberry 10内置浏览器
附一张浏览器版本支持图以下: 数据库
WebRTC技术组成
底层技术
- 图像引擎 (
VideoEngine
)
- VP8编解码
- jitter buffer: 动态抖动缓冲
- Image enhancements: 图像增益
- 声音引擎 (
VoiceEngine
)
- iSAC/iLBC/Opus等编解码
- NetEQ语音信号处理
- 回声消除和降噪
- 会话管理 (
Session Management
)
iSAC
音效压缩
VP8
Google自家WebM项目的影片编解码器
- APIs (Native C++ API, Web API)
WebRTC的底层实现很是复杂,附一张架构图以下: 数组
几个重要API
WebRTC
虽然底层实现极其复杂,可是面向开发者的API仍是很是简洁的,主要分为三方面:浏览器
- Network Stream API
MediaStream
媒体数据流
MediaStreamTrack
媒体源
- RTCPeerConnection
RTCPeerConnection
容许用户在两个浏览器之间直接通信。
RTCIceCandidate
ICE协议的候选者
RTCIceServe
r
- Peer-to-peer Data API
RTCDataChannel
:数据通道接口,表示一个在两个节点之间的双向的数据通道。
技术点介绍
Network Stream API 网络流媒体接口
主要有两个API:MediaStream
与MediaStremTrack
:
MediaStreamTrack
表明一种单类型数据流,一个MediaStreamTrack
表明一条媒体轨道,VideoTrack
或AudioTrack
。这给咱们提供了混合不一样轨道实现多种特效的可能性。
MediaStream
一个完整的音视频流,它能够包含多个MediaStreamTrack
对象,它的主要做用是协同多个媒体轨道同时播放,也就是咱们常说的音画同步。
MediaStream
咱们要经过浏览器实现音视频通话,首先须要访问音/视频设备,这很简单:
const constraints = {
audio: {
echoCancellation: true,
noiseSuppression: false
},
video: true
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
document.getElementById('#video').srcObject = stream;
复制代码
调用navigator.mediaDevices.getUserMedia
方法便可获得流媒体对象MediaStream
。
constraints
是一个约束配置,它是用来规范当前采集的数据是否符合须要。
由于,在采集视频时,不一样的设备有不一样的参数设置,更优的设备可使用更高的参数(分辨率、帧率等)。
经常使用的配置以下所示:
{
"audio": {
echoCancellation: boolean, noiseSuppression: boolean
}, // 是否捕获音频
"video": { // 视频相关设置
"width": {
"min": "381", // 当前视频的最小宽度
"max": "640" },
"height": { "min": "200", // 最小高度 "max": "480" },
"frameRate": {
"min": "28", // 最小帧率
"max": "10" }
}
}
有些机器只具有麦克风,没有摄像头怎么办呢?这时若是设置video: true
就会抛出异常:Requested device not found
。
所以,咱们须要检测设备是否具有语音/视频的可行性,经过navigator.mediaDevices.enumerateDevices
能够枚举出全部的媒体设备,格式以下:
{
deviceId: "2c6e3e1b727b1442f905459e4cd47902988ccac6dff4361ae657cf44c4f3f55c"
groupId: "781470b0bba090c6eb2b26eaa2e643e65a08e37f252f406d31da4d90cc3952e9"
kind: "audioinput"
label: "默认 - 麦克风"
},
{
deviceId: "fe4d04d603512966a729e1313067574f462dbdc579d6ddaad7ad4460089239e1"
groupId: "48c2f2d3191adf2d1d371d23d2219e228268ffbcfa4ba7dfe67a5855c81e6f13"
kind: "videoinput"
label: "默认 - 摄像头"
}
以此能够做为判断设备是否具有语音/视频通话能力的依据。
MediaStreamTrack
上面咱们得到了MediaStream
对象,经过它咱们能够拿到音频/视频的MediaStreamTrack
:
const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
复制代码
能够看到,MediaStream
中的视频轨与音频轨分为了两个数组。
固然,咱们也能够单独操做MediaStreamTrack
对象:
videoTracks[0].stop();
复制代码
它还提供了以下属性和方法,便于咱们操做单个轨道数据:
enabled: boolean;
readonly id: string;
readonly isolated: boolean;
readonly kind: string;
readonly label: string;
readonly muted: boolean;
onended: ((this: MediaStreamTrack, ev: Event) => any) | null;
onisolationchange: ((this: MediaStreamTrack, ev: Event) => any) | null;
onmute: ((this: MediaStreamTrack, ev: Event) => any) | null;
onunmute: ((this: MediaStreamTrack, ev: Event) => any) | null;
readonly readyState: MediaStreamTrackState;
applyConstraints(constraints?: MediaTrackConstraints): Promise<void>;
clone(): MediaStreamTrack;
getCapabilities(): MediaTrackCapabilities;
getConstraints(): MediaTrackConstraints;
getSettings(): MediaTrackSettings;
stop(): void;
RTCPeerConnection WebRTC链接
前面一节,咱们成功的拿到了MediaStream
流媒体对象,可是仍然仅限于本地查看。
如何将流媒体与对方互相交换(实现音视频通话)?
答案是咱们必须创建点对点链接(peer-to-peer),这就是RTCPeerConnection
要作的事情。
WebRTC
区别于传统音视频通话的特色即是两台机器之间直接建立点对点链接,无须经过服务器直接交换音视频流数据!
建立点对点链接是 WebRTC
中最难的点( RTCPeerConnection
内部作了不少工做来简化咱们的使用),许多文章都用了很是大的篇幅去阐述它的原理。
我接下来会试图用几张图来尽量的解释下。
但在这以前,我先得提一个概念:信令服务器
两台公网上的设备要互相知道对方是谁,须要有一个中间方去协商交换它们的信息。这就比如媒人作媒同样,互不相识的一男一女,须要一个认识他们俩的媒人去搭桥牵线,让他们可以喜结连理。
信令服务器干的就是这个事儿,下面几张图中的云朵就是信令服务器。
好了,咱们开始解释原理。
点对点链接原理
1. 最理想的状况
要在两台设备之间建立点对点链接,最理想的状况是双方都具备公网IP:
如上图所示,两台设备互相知晓对方的公网IP,只要经过信令服务器交换一下信息即可以建立点对点链接了。
然而,这种状况出现的几率小到几乎能够忽略不计,由于公网IP实在太稀少了。
2. 两台设备都在NAT/防火墙后面
先解释一下上图中的NAT是什么
这是由于IPV4引发的,咱们上网极可能会处在一个NAT设备(无线路由器之类)以后。 NAT设备会在IP封包经过设备时修改源/目的IP地址。对于家用路由器来讲, 使用的是网络地址端口转换(NAPT), 它不只改IP, 还修改TCP和UDP协议的端口号, 这样就能让内网中的设备共用同一个外网IP。咱们的设备常常是处在NAT设备的后面, 好比在大学里的校园网, 查一下本身分配到的IP, 实际上是内网IP, 代表咱们在NAT设备后面, 若是咱们在寝室再接个路由器, 那么咱们发出的数据包会多通过一次NAT。
NAT会形成一个很棘手的问题,那就是内网穿透。
NAT有一个机制,全部外界对内网发送的请求,到达NAT的时候,都会被NAT屏蔽,这样若是咱们处于一个NAT设备后面,咱们将没法获得任何外界的数据。
可是这种机制有一个解决方案:就是若是咱们A主动往B发送一条信息,这样A就在本身的NAT上打了一个通往B的洞。这样A的这条消息到达B的NAT的时候,虽然被丢掉了,可是若是B这个时候在给A发信息,到达A的NAT的时候,就能够从A以前打的那个洞中,发送给到A手上了。
简单来说,就是若是A和B要进行通讯,那么得事先A发一条信息给B,B发一条信息给A。这样提早在各自的NAT上打了对方的洞,这样下一次A和B之间就能够进行通讯了。
NAT网络分为四种类型
- 彻底锥型NAT
彻底锥型NAT的特色是:当host主机经过NAT访问外网的B主机时,就会在NAT上打个“洞”,全部知道这个洞的主机均可以经过它与内网主机上的侦听程序通讯。 这个所谓的“洞”就是一张内外网的映射表,简单表示成一个4元组以下:
{
内网IP,
内网端口,
映射的外网IP,
映射的外网端口
}
复制代码
有了这个“洞”的数据,A主机与C主机都能经过这个洞与host通讯了。
- IP限制锥型NAT
IP限制锥型要比彻底锥型NAT严格得多,它的主要特色是:host主机在NAT上打“洞”后,NAT会对穿越洞口的IP地址作限制。只有登记的IP地址才能够经过,也就是说,只有host主机访问过的外网主机才能穿越NAT。其它主机即便知道了这个“洞”也不能与host通讯,由于NAT会检查IP地址。 简单表示成一个5元组以下:
{
内网IP,
内网端口,
映射的外网IP,
映射的外网端口,
被访问主机的IP
}
- 端口限制锥型NAT
端口限制锥型比IP限制锥型NAT更加严格,它的主要特色是:不光在NAT上对打“洞”的IP地址作了限制,还对具体的端口作了限制。 简单表示成一个6元组以下:
{
内网IP,
内网端口,
映射的外网IP,
映射的外网端口,
被访问主机的IP,
被访问主机的端口
}
如上图所示,只有B主机的P1端口才能穿越NAT与host通讯,P2端口都没法穿越。
- 对称型NAT
对称型NAT是全部NAT类型中最严格的一种类型,它也是IP+端口限制,但它与端口限制锥型不一样之处在于:host与A主机和B主机通讯时会打两个不一样的“洞”,每访问一个新的主机时,它都会从新开一个“洞”(使用不一样的端口,甚至更换IP地址),而端口限制锥型多个链接使用的是同一个端口。
因此,当对称型NAT碰到对称型NAT或对称型NAT遇到端口限制型NAT时,基本上双方是没法穿透成功的。
NAT解释到这里就差很少了,有须要的童鞋能够查找相关资料去详细了解一下,这里不做过多的阐述。
回到刚刚的话题,WebRTC
会怎么处理NAT呢?答案是STUN/TURN
。
STUN
(Session Traversal Utilities for NAT,NAT会话穿越应用程序)是一种网络协议,它容许位于NAT(或多重NAT)后的客户端找出本身的公网地址,查出本身位于哪一种类型的NAT以后以及NAT为某一个本地端口所绑定的Internet端端口。
WebRTC
首会先使用STUN
服务器去找出本身的NAT环境,而后试图找出打“洞”的方式,最后试图建立点对点链接。
STUN
服务器能够直接使用google提供的免费服务stun.l.google.com:19302
。
STUN/TURN
服务均可以本身搭建。
当它尝试过不一样的穿透方式都失败以后,为保证通讯成功率会启用TURN
服务器进行中转,此时全部的流量都会经过TURN
服务器。这时若是TURN
服务器配置很差或带宽不够时,通讯质量基本上不好。
RTCPeerConnection的使用
上面解释了点对点链接的原理,那么具有穿透成功的条件以后,咱们要正式使用RTCPeerConnection
建立链接了:
const pc = new RTCPeerConnection({ iceServers: [
{
'url': 'stun:turn.mywebrtc.com'
},
{
'url': 'turn:turn.mywebrtc.com',
'credential': 'siEFid93lsd1nF129C4o',
'username': 'webrtcuser'
}
]
});
pc.onicecandidate = candidate => sendEvent(Events.Candidate, { candidate });
stream.getTracks().forEach(track => {
pc.addTrack(track, stream);
});
const answer = await pc.createAnswer();
pc.setLocalDescription(answer);
sendEvent(Events.Answer, { answer });
const offer = await pc.createOffer(options);
pc.setLocalDescription(offer);
sendEvent(Events.Offer, { offer });
- 实例化一个
RTCPeerConnection
对象,须要提供iceServers
属性,提供STUN/TURN
服务地址;
- 监听
onicecandidate
事件,当本地与对方offer/answer
握手以后,icecandidate
时会被通知到,再经过信令服务器将candidate
信息发送给对方;
pc.addTrack(track, stream);
绑定媒体轨道;
- 收到
offer
发送answer
/ 收到answer
发送offer
用时序图表示可能更便于理解:
- Peer A实例化一个
RTCPeerConnection
对象,并监听onicecandidate
事件;
- Peer A建立
offer
createOffer
并经过信令服务器发送给Peer B;
- Peer B收到
offer
后setRemoteSDP
,建立answer
createAnswer
并经过信令服务器发送给Peer A;
- Peer A收到
answer
后setRemoteSDP
;
- Peer A与Peer B互相处理完
offer/answer
,icecandidate
被通知到,Peer A与Peer B交换candidate
信息;
- 链接创建完成!
推送MediaStream
当链接创建完成以后,RTCPeerConnection
会将本地的tracks
轨道推送给对方,这就是为何要pc.addTrack(track, stream);
了
注意 pc.addTrack(track, stream);
的第二个参数stream
很是重要,若是不给,对方拿到的结果streams
会是空数组!
当对方接收到tracks
推送时,会通知回调函数pc.ontrack
,能够从event
对象中拿到远程流媒体对象:
pc.ontrack = event => {
this.remoteMediaStream = event.streams[0];
};
复制代码
当互相拿到对方的流媒体对象时,语音/视频通话就成功了,将流赋给<video />
标签便可。
WebRTC
的规范这几年变更比较大,网上有不少文章和demo都有点旧了,有些API已经废弃,好比如今的ontrack
之前是onaddstream
,如今的addTrack
取代了以前的addStream
。若是你们看到addStream
或onaddstream
没必要担忧,使用TypeScript
的好处就是在这方面它总能给你最新的代码提示。
综上所述,RTCPeerConnection
主要负责穿透并创建链接,而且它还会自动推流。
但它的能力可不止于此,它还有一个能力:RTCDataChannel
。
RTCDataChannel
标题里除了实时语音/视频聊天,还提到了文件传输,这即是RTCDataChannel
的功能。
利用它,能传输string
、ArrayBuffer
、Blob
(目前仅FireFox支持)类型的数据。
因此,传输文本和文件不在话下。它的使用和MediaStream
同样,都须要依附RTCPeerConnection
,这也能理解,离开了链接,不管是流媒体仍是文件都传输不了吧?
RTCPeerConnection
提供了一个方法用来建立RTCDataChannel
:
const dataChannel = pc.createDataChannel(label: string);
复制代码
查阅官方API文档,label
只是一个描述,不要求惟一,也没有实际意义。
假设使用Peer A的RTCPeerConnection
建立了RTCDataChannel
,那么Peer B也须要建立RTCDataChannel
吗?
答案是:不用!
Peer B只须要监听RTCPeerConnection
的ondatachannel
事件便可,当Peer A建立RTCDataChannel
成功后,Peer B的RTCPeerConnection
会收到通知,并触发ondatachannel
事件传入Peer A的RTCDataChannel
对象。
解释有点绕,直接看代码:
const dataChannel = pc_a.createDataChannel('message');
pc_b.ondatachannel = event => {
const dataChannel = event.channel;
};
固然,这一切的一切取决于Peer A与Peer B的RTCPeerConnection
成功握手,因此说它才是最重要的,也是最难理解的。
那么接下来,如何发送消息呢?
发送文本消息是很是简单的:
dataChannel.send('hello , I am Peer A');
dataChannel.onmessage = event => {
console.log(event.data);
};
真的很是简单。
可是接下来咱们要传输文件,就略微麻烦一些了……FireFox倒还好,支持直接发送Blob
类型数据。
据我测试,FireFox发送的Blob
对象,Chrome也能接收到(虽然它发送不了,会直接抛出异常)。
对于Chrome而言,要发送文件,只能选择ArrayBuffer
类型,而发送的时候须要进行分块传输,通常1024 byte
为1块。 单个文件还好,若是是多个文件同时传输,接收方就须要判断每次接收的块是属于哪一个文件的。
因此我选择了将对象{ fileId, data: chunk }
转成字符串传输,接收方收到消息解码后能经过fileId
还原到相应的文件上。
const fileReader = new FileReader();
let currentChunk = 0;
const readNextChunk = () => {
const start = chunkLength * currentChunk;
const end = Math.min(transfer.totalSize, start + chunkLength);
fileReader.readAsArrayBuffer(transfer.file.slice(start, end));
};
fileReader.onload = () => {
const raw = fileReader.result as ArrayBuffer;
transfer.transferedSize += raw.byteLength;
transfer.progress = transfer.transferedSize / transfer.totalSize;
this.channel.sendMessage({
fileId,
data: arrayBuffer2String(raw)
});
currentChunk++;
if(chunkLength * currentChunk < transfer.totalSize) {
readNextChunk();
} else {
transfer.status = TransferStatus.Complete;
};
};
readNextChunk();
dataChannel.onmessage = (event: MessageEvent, raw: string) => {
const result = JSON.parse(raw) as { fileId: string, data: string };
const { fileId, data } = result;
const buffer = string2ArrayBuffer(data);
const transfer = this.receiveFileQueue.find(f => f.id === fileId);
transfer.status = TransferStatus.Transfering;
transfer.data.push(buffer);
transfer.transferedSize += buffer.byteLength;
transfer.progress = transfer.transferedSize / transfer.totalSize;
if (transfer.transferedSize === transfer.totalSize) {
transfer.status = TransferStatus.Complete;
}
}
复制代码
实际效果以下图所示:
由于接收到文件后,我设计的交互是让用户本身选择是否下载或删除,因此我是将接收到的文件二进制数据直接存储在内存变量上,这也致使了一个问题:文件越多或者大文件(1M以上)就会占用内存致使浏览器卡顿。
关于这个问题有解决方案,就是使用IndexedDB
将文件存储到浏览器的本地数据库中去,它使用的是本地文件系统。不过懒得折腾了,毕竟这只是个WebRTC
的demo呢。
结束语
好吧,基本上讲完了,内容比较多,能坚持看到这里的童鞋已经很不容易了。
WebRTC
本就是一个比较小众的技术,多数程序员平日都用不到它,甚至发布多年至今也不知道它的存在(我身边90%的同事都不知道它的存在)。
我是2017年在一家在线客服软件公司就任时了解到这项技术的,当时惊呆了,手机浏览器和PC浏览器居然能在不借助任何插件的状况下实现语音视频通话!
工做上没用到这个技术,因此一直也没去细细研究。直到最近心血来潮才用它写了个demo,说白了仍是太懒致使的……
转自:https://juejin.im/post/5dcb652cf265da4d194864a3