webrtc-可编程的实时音视频API
上文分享了三个视频传输协议,在分享的最后提到了一个技术就是WebRTC,在此准备分享一下对该技术的一些理解以及使用。
一、什么是WebRTC
WebRTC是“网络实时通信”(Web Real-Time Communication)的缩写。它最初是为了解决浏览器上视频通话而提出的,即两个浏览器之间直接进行视频和音频的通信,不经过服务器。后来发展到除了音频和视频,还可以传输文字和其他数据。在实时通信中,音视频的采集和处理是一个很复杂的过程。比如音视频流的编解码、降噪和回声消除等,但是在 WebRTC 中,这一切都交由浏览器的底层封装来完成。我们可以直接拿到优化后的媒体流,然后将其输出到本地屏幕和扬声器,或者转发给其对等端。
虽然其名为WebRTC,但是实际上它不光支持Web之间的音视频通讯,还支持Android以及IOS端,此外由于该项目是开源的,我们也可以通过编译C++代码,从而达到全平台的互通。
所以,我们可以在不需要任何第三方插件的情况下,实现一个浏览器到浏览器的点对点(P2P)连接,从而进行音视频实时通信。当然,WebRTC 提供了一些 API 供我们使用,在实时音视频通信的过程中,我们主要用到以下三个:
getUserMedia 获取音频和视频(MediaStream)
RTCPeerConnection 进行音频和视频通信
RTCDataChannel 进行任意数据的通信
不过,虽然浏览器给我们解决了大部分音视频处理问题,但是从浏览器请求音频和视频时,我们还是需要特别注意流的大小和质量。因为即便硬件能够捕获高清质量流,CPU 和带宽也不一定可以跟上,这也是我们在建立多个对等连接时,不得不考虑的问题。
二、API的使用
接下来,我们通过分析上文提到的 API,来逐步弄懂 WebRTC 实时通信实现的流程。
1、getUserMedia
getUserMedia 这个 API 大家可能并不陌生,因为常见的 H5 录音等功能就需要用到它,主要就是用来获取设备的媒体流(即 MediaStream)。它可以接受一个约束对象 constraints 作为参数,用来指定需要获取到什么样的媒体流。
let constraints = { video: true, audio: true } navigator.mediaDevices.getUserMedia(constraints) .then(gotLocalMediaStream) .catch((err) => { console.log('getUserMedia 错误', err); //创建点对点连接对象 });
对于 constraints 约束对象,我们可以用来指定一些和媒体流有关的属性。比如指定是否获取某种流、指定视频流的宽高、帧率以及理想值,对于移动设备来说,还可以指定获取前摄像头,或者后置摄像头。更多相关设置可查看MDN
我们简单看一下获取到的 MediaStream:
可以看到它有很多属性,我们只需要了解一下就好,更多信息可以查看 MDN。
* id [String]: 对当前的 MS 进行唯一标识。所以每次刷新浏览器或是重新获取 MS,id 都会变动。 * active [boolean]: 表示当前 MS 是否是活跃状态(就是是否可以播放)。 * onactive: 当 active 为 true 时,触发该事件。
这里也可以通过 getAudioTracks()、getVideoTracks() 来查看获取到的流的某些信息,更多信息查看 MDN。
2、RTCPeerConnection
RTCPeerConnection 作为创建点对点连接的 API,是我们实现音视频实时通信的关键。在点对点通信的过程中,需要交换一系列信息,通常这一过程叫做 — 信令(signaling)。在信令阶段需要完成的任务:
为每个连接端创建一个 RTCPeerConnection,并添加本地媒体流。
获取并交换本地和远程描述:SDP 格式的本地媒体元数据。
获取并交换网络信息:潜在的连接端点称为 ICE 候选者。
我们虽然把 WebRTC 称之为点对点的连接,但并不意味着,实现过程中不需要服务器的参与。因为在点对点的信道建立起来之前,二者之间是没有办法通信的。这也就意味着,在信令阶段,我们需要一个通信服务来帮助我们建立起这个连接。WebRTC 本身没有指定信令服务,所以我们可以使用但不限于使用websocket、socket、xhr等来做信令交换所需的服务。
为了完成媒体流信息的交换,我们用 RTCPeerConnection
的 createOffer()
方法生成一个 Offer,它是以 SDP(Session Description Protocol,会话描述协议)格式传送的。对方收到 Offer 后,应该生成一个 Answer 并发回,这个 Answer 同样是 SDP 格式的。通信的双方通过调用setLocalDescription()
方法,把自己生成的 SDP 设置成本地描述;通过调用setRemoteDescription()
方法,把对方发给自己的 SDP 设置成远程描述。以上的这个过程,被统称为JSEP(JavaScript Session Establishment Protocol,JavaScript 会话建立协议)
在交换SDP后,webrtc就开始真正的连接来传输音视频数据。这个建立连接的过程相当复杂,原因是webrtc既要保证高效的传输性,又要保证稳定的连通性。由于浏览器客户端之间所处的位置往往是相当复杂的,可能处于同一个内网段内,也可能处于两个不同的位置,所处的NAT网关也可能很复杂。因此需要一种机制找到一条传输质量最优的道路,而WebRTC正具备这种能力。
首先简单了解以下三个概念。
ICE Canidate(ICE 候选者):包含远端通信时使用的协议、IP 地址和端口、候选者类型等信息。
STUN/TURN:STUN实现P2P型连接,TRUN实现中继型连接。两者实现均有标准协议。
NAT穿越:NAT即网络地址转换,由于客户端并不能分配到公网IP,需要内网IP与公网IP端口做映射才能与外网通信。而NAT穿越就是位于层层Nat网关背后的客户端之间发现对方并建立连接。
ICE连接大致的原理及步骤如下:
发起收集ICE Canidate任务。
本机能收集host类型(内网IP端口)的candidate。
通过STUN服务器收集srflx类型(NAT映射到外网的IP端口)的candiate。
通过TUN服务器收集relay类型的(中继服务器的 IP 和端口)的candidate。
开始尝试NAT穿越,按照host类型、srflx类型、relay类型的优先级去连接。
以上,WebRTC便能找到一条传输质量最优的连接道路。 当然实际情况并不是这么简单,整个过程包含着更复杂的底层细节。我们通过下图来说明WebRTC的处理步骤。
初始化RTCPeerConnection:
const config = { // ICE配置 iceServers: [ { urls: "stun:stun.l.google.com:19302" }, // 无需密码的 { urls: "stun:zwight.cn:3478" }, { urls: 'turn:zwight.cn:3478', username:"admin", credential:"123456" } ], }; const createConnection = () => { peerConnection = new RTCPeerConnection(config) if (localStream) { // 视频轨道 const videoTracks = localStream.getVideoTracks(); // 音频轨道 const audioTracks = localStream.getAudioTracks(); // 判断视频轨道是否有值 if (videoTracks.length > 0) { console.log(`使用的设备为: ${videoTracks[0].label}.`); } // 判断音频轨道是否有值 if (audioTracks.length > 0) { console.log(`使用的设备为: ${audioTracks[0].label}.`); } localStream.getTracks().forEach((track:any) => { peerConnection.addTrack(track, localStream) }) } // 监听返回的 Candidate peerConnection.addEventListener('icecandidate', handleConnection); // 监听 ICE 状态变化 peerConnection.addEventListener('iceconnectionstatechange', handleConnectionChange) //拿到流的时候调用 peerConnection.addEventListener('track', gotRemoteMediaStream); } // 3.端与端建立连接 const handleConnection = (event:any) => { // 获取到触发 icecandidate 事件的 RTCPeerConnection 对象 // 获取到具体的Candidate console.log("handleConnection", event) const icecandidate = event.candidate; if (icecandidate) { socket.send(JSON.stringify({ 'userId': userId, 'remoteId': remoteId, 'message': { type: 'icecandidate', icecandidate: icecandidate } })); } } // 4.显示远端媒体流 const gotRemoteMediaStream = (event: any) => { console.log('remote 开始接受远端流', event) if (event.streams[0]) { remoteVideo.srcObject = event.streams[0]; } }
创建连接:
//准备连接 startHandle().then(() => { //发送给远端开启请求 socket.send(JSON.stringify({ 'userId': userId, 'remoteId': remoteId, 'message': {'type': 'connect'}})) }) const onmessage = (e: any) => { // websokcet heartCheck.restart(); //简单心跳,拿到任何消息都说明当前连接是正常的 const json = JSON.parse(e.data) const description = json.message; console.log('message', json) if(description.type === 'ping') return; setRemoteId(json.userId) const remoteId = json.userId; switch (description.type) { case 'connect': // eslint-disable-next-line no-restricted-globals if(confirm(remoteId + '请求连接!')){ //准备连接 startHandle().then(() => { socket.send(JSON.stringify({ 'userId': userId, 'remoteId': remoteId, 'message': {'type': 'start'} })); }) } break; case 'start': //同意连接之后开始连接 startConnection(remoteId) break; case 'offer': peerConnection.setRemoteDescription(new RTCSessionDescription(description)).catch((err: any) => { console.log('local 设置远端描述信息错误', err); }); peerConnection.createAnswer().then((answer: any) => { peerConnection.setLocalDescription(answer).then(() => { console.log('设置本地answer成功!'); }).catch((err: any) => { console.error('设置本地answer失败', err); }); socket.send(JSON.stringify({ 'userId': userId, 'remoteId': remoteId, 'message': answer })); }).catch((e: any) => { console.error(e) }); break; case 'icecandidate': // 创建 RTCIceCandidate 对象 let newIceCandidate = new RTCIceCandidate(description.icecandidate); // 将本地获得的 Candidate 添加到远端的 RTCPeerConnection 对象中 peerConnection.addIceCandidate(newIceCandidate).then(() => { console.log(`addIceCandidate 成功`); }).catch((error: any) => { console.log(`addIceCandidate 错误:\n${error.toString()}.`); }); break; case 'answer': peerConnection.setRemoteDescription(new RTCSessionDescription(description)).then(() => { console.log('设置remote answer成功!'); }).catch((err: any) => { console.log('设置remote answer错误', err); }); break; default: break; } } //创建发起方会话描述对象(createOffer),设置本地SDP(setLocalDescription),并通过信令服务器发送到对等端,以启动与远程对等端的新WebRTC连接。 const startConnection = (remoteId: any) => { setConnected(true); // 发送offer peerConnection.createOffer().then((description: any) => { console.log(`本地创建offer返回的sdp:\n${description.sdp}`) // 将 offer 保存到本地 peerConnection.setLocalDescription(description).then(() => { console.log('local 设置本地描述信息成功'); // 本地设置描述并将它发送给远端 socket.send(JSON.stringify({ 'userId': userId, 'remoteId': remoteId, 'message': description })); }).catch((err: any) => { console.log('local 设置本地描述信息错误', err) }); }).catch((err: any) => { console.log('createdOffer 错误', err); }); }
以上为本实例部分主要代码,主要使用了websokcet作为信令交换服务器的连接桥梁,WebRTC在内网中无需依赖STURN中继服务器即可实现端到端的连接。详细代码请转这里
3、RTCDataChannel
RTCDataChannel的作用是在点对点之间,传播任意数据。它的API与WebSockets的API相同,RTCPeerConnection他进行强大和灵活的端到端通讯。通讯直接发生在浏览器之间.所以RTCDataChannel在使用了TURN中继服务来应对网络穿透失败的情况下还可以比WebSocket更快。
var peerConnection = new webkitRTCPeerConnection(servers, {optional: [{RtpDataChannels: true}]}); peerConnection.ondatachannel = function(e) { receiveChannel = e.channel; receiveChannel.onmessage = function(event){ console.log(event) }; }; sendChannel = peerConnection.createDataChannel("sendDataChannel", {reliable: false}); sendChannel.send(data);
三、WebRTC发展和总结
2010年5月,谷歌以6820万美元收购GIPS。当然谷歌不光收购了GIPS,还收购了On2,得到了VPx系列视频编解码器。于是在2011年,webrtc项目诞生,融合了GIPS的音视频引擎、VPx视频编解码器,P2P穿洞技术等,而且开源。
经历了6年的时间,2017 年 11 月 2 日 ,W3C WebRTC 1.0 草案正式定稿,webrtc加入W3C大家族。随后,各大浏览器厂商跟紧支持。
2021 年 1 月 26 日,W3C(万维网联盟) 和 IETF (互联网工程任务组) 同时宣布 WebRTC(Web Real-Time Communications,Web 实时通信) 现发布为正式标准。
由于WebRTC的传输是基于公共互联网,而公共互联网并不是为了实时通信而设计的,因此在网络协议、跨区域带宽、跨运营商、用户设备、网络架构、文档支持等方面都会对WebRTC的开发有牵制,从而会导致实时音视频等传输质量没办法得到有效的保证。因此,可以说如果 WebRTC 直接拿过来商用的话,几乎是不太可能的,当下普遍的解决方案是自研,根据自身的业务场景进行二次定制开发,或者更简单一点使用第三方 SDK。
本文示例源码库: webrtc