本文是针对小白的 WebRTC 快速入门课,如果你还之前还不了解 WebRTC,希望你能认真阅读本文,实现对 WebRTC 的零的突破 。如果感兴趣,不妨动动手指跟着我一起实践下。
WebRTC(Web Real-Time Communications)是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC 包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。
IM 即时通信,就是通过文字聊天、语音消息发送、文件传输等方式通信,考虑的是可靠性;
RTC 实时通信:音视频通话、电话会议,考虑的是低延时。
2011 年开始, Google 先后收购 GIPS 和 On2,组成 GIPS 音视频引擎 + VPx 系列视频编解码器,并将其代码开源,WebRTC 项目应运而生。
2012 年,Google 将 WebRTC 集成到 Chrome 浏览器中。于是我们就可以愉快的在浏览器之间进行音视频通信。
当前除了 IE 之外的浏览器都已支持 WebRTC。
WebRTC 的能力使其适用于各种实时通信场景:
在了解 WebRTC 通信过程前,我们需要先来了解下 WebRTC 的组成部分,这可以帮助我们快速建立 WebRTC 的知识体系。
WebRTC 主要由三部分组成:浏览器 API、音视频引擎和网络 IO。
用于采集摄像头和麦克风生成媒体流,并处理音视频通信相关的编码、解码、传输过程,可以使用以下 API 在浏览器中创建实时通信应用程序。
有了 WebRTC,我们可以很方便的实现音视频通信;而如果没有 WebRTC 的情况下,我们想要实现音视频通信,就需要去了解音视频编码器相关技术。
WebRTC 内置了强大的音视频引擎,可以对媒体流进行编解码、回声消除、降噪、防止视频抖动等处理,我们使用者大可不用去关心如何实现 。主要使用的音视频编解码器有:
还有像回声消除AEC(Acoustic Echo Chancellor)、背景噪音抑制ANS(Automatic Noise Suppression)和Jitter buffer用来防止视频抖动,这些问题在 WebRTC 中也提供了非常成熟、稳定的算法,并且提供图像增加处理,例如美颜,贴图,滤镜处理等。
WebRTC 传输层用的是 UDP 协议,因为音视频传输对及时性要求更高,如果使用 TCP 当传输层协议的话,如果发生丢包的情况下,因为 TCP 的可靠性,就会尝试重连,如果第七次之后仍然超时,则断开 TCP 连接。而如果第七次收到消息,那么传输的延迟就会达到 2 分钟。在延迟高的情况下,想做到正常的实时通讯显然是不可能的,此时 TCP 的可靠性反而成了弊端。
而 UDP 则正好相反,它只负责有消息就传输,不管有没有收到,这里从底层来看是满足 WebRTC 的需求的,所以 WebRTC 是采用 UDP 来当它的传输层协议的。
这里主要用到以下几种协议/技术:
除了以上三部分,WebRTC 还需要一个信令服务做会话管理,但 WebRTC 规范里没有包含信令协议,需要自行实现。
基于以上,我们来思考下 WebRTC 实现一对一通信需要哪些基本条件?
通信过程如下:
第 6 步在建立连接进行 P2P 穿越时很有可能失败。当 P2P 穿越失败时,为了保障音视频数据仍然可以互通,则需要通过 TURN 服务器进行音视频数据中转。后面会讲到 TURN 服务是什么,以及如何搭建 TURN 服务。
接下来,我们按照通信过程,来一一讲解每一步要做的事情。
采集音视频数据是 WebRTC 通信的前提,我们可以使用浏览器提供的 getUserMedia API 进行音视频采集。
const constraints = { video: true, audio: true }
const localStream = navigator.mediaDevices.getUserMedia(constraints)
getUserMedia 接受参数constraints用于指定 MediaStream 中包含哪些类型的媒体轨(音频轨、视频轨),并对媒体轨做设置(如设置视频的宽高、帧率等)。
返回一个 promise 对象,成功后会获得流媒体对象 MediaStream(包含从音视频设备中获取的音视频数据); 使用 getUserMedia 时,浏览器会询问用户,开启音频和视频权限。如果用户拒绝或无权限时,则返回 error。
通过getUserMedia成功回调拿到媒体流之后,通过将媒体流挂载到videoDOM.srcObject即可显示在页面上。
效果如下(帅照 自动马赛克):
用于表示每个媒体输入/输出设备的信息,包含以下 4 个属性:
可以在浏览器控制台直接输入
navigator.mediaDevices.enumerateDevices()返回如下所示:
该接口提供访问连接媒体输入的设备(如摄像头、麦克风)以及获取屏幕共享等方法。而我们需要获取可用的音视频设备列表,就是通过该接口中的方法来实现的,如前面提到的getUserMedia方法。
方法:
jvar enumeratorPromise = navigator.mediaDevices.enumerateDevices()
var promise = navigator.mediaDevices.getDisplayMedia(constraints)
WebRTC 相关的 API 需要 Https(或者 localhost)环境支持,因为在浏览器上通过 HTTP 请求下来的 JavaScript 脚本是不允话访问音视频设备的,只有通过 HTTPS 请求的脚本才能访问音视频设备。
信令可以简单理解为消息,在协调通讯的过程中,为了建立一个 webRTC 的通讯过程,在通信双方彼此连接、传输媒体数据之前,它们要通过信令服务器交换一些信息,如加入房间、离开房间及媒体协商等,而这个过程在 webRTC 里面是没有实现的,需要自己搭建信令服务。
可以使用 Socket.io 来实现 WebRTC 信令服务器,Socket.io 已经内置了房间的概念,所以非常适合用于信令服务器的创建。
以下使用 Socket.io 的过程中需要用到的知识点:
// 如 发送message消息
const username = 'xx'
const message = 'hello'
// 发送消息
socket.emit('message', username, message)
// 接受消息
socket.on('message', (username, message) => {})
socket.to(room).emit()
socket.broadcast.emit()
搭建信令服务器过程如下:
let app = express()
let http_server = http.createServer(app)
http_server.listen(80)
let io = new IO(http_server, {
path: '/',
cors: {
origin: '*',
},
})
http_server.on('listening')
socket = io('http://localhost:80', {
query: { username, room },
}).connect()
// 监听连接
io.on('connection', (socket) => {
const { query } = socket.handshake
// 获取socket连接参数 username和room
const { username, room } = query
...
})
jsocket.on('message', (room, data) => {
socket.to(room).emit('message', room, data)
})
socket.on('leave', (room, username) => {
socket.leave(room)
socket.emit('leave', room, socket.id)
})
最后,看一下效果如下:
顺便看一下日志信息:
RTCPeerConnection是一个由本地计算机到远端的 WebRTC 连接,该接口提供创建,保持,监控,关闭连接的方法的实现,可以简单理解为功能强大的 socket 连接。
通过new RTCPeerConnection即可创建一个 RTCPeerConnection 对象,此对象主要负责与各端建立连接(NAT 穿越),接收、发送音视频数据,并保障音视频的服务质量,接下来要说的端到端之间的媒体协商,也是基于 RTCPeerConnection 对象来实现的。
至于它是如何保障端与端之间的连通性,如何保证音视频的服务质量,又如何确定使用的是哪个编解码器等问题,作为应用者的我们大可不必关心,因为所有的这些问题都已经在 RTCPeerConnection 对象的底层实现好了 。
const localPc = new RTCPeerConnection(rtcConfig)
// 将音视频流添加到 RTCPeerConnection 对象中
localStream.getTracks().forEach((track) => {
localPc.addTrack(track, localStream)
})
在第一步获取音视频流后,需要将流添加到创建的 RTCPeerConnection 对象中,当 RTCPeerConnection 对象获得音视频流后,就可以开始与对端进行媒协体协商。
媒体协商的作用是找到双方共同支持的媒体能力,如双方各自支持的编解码器,音频的参数采样率,采样大小,声道数、视频的参数分辨率,帧率等等。
就好比两人相亲,通过介绍人男的知道了女的身高、颜值、身材,女的理解了男的家庭、财富、地位,然后找到你们的共同点“穷”,你俩觉得“哇竟然这么合适”,赶紧见面深入交流一下 。
上述说到的这些音频/视频的信息都会在SDP(Session Description Protocal:即使用文本描述各端的“能力”) 中进行描述。
一对一的媒体协商大致如下:首先自己在 SDP 中记录自己支持的音频/视频参数和传输协议,然后进行信令交互,交互的过程会同时传递 SDP 信息,另一方接收后与自己的 SDP 信息比对,并取出它们之间的交集,这个交集就是它们协商的结果,也就是它们最终使用的音视频参数及传输协议。
一对一通信中,发起方发送的 SDP 称为Offer(提议),接收方发送的 SDP 称为Answer(应答)。
每端保持两个描述:描述本身的本地描述LocalDescription,描述呼叫的远端的远程描述RemoteDescription。
当通信双方 RTCPeerConnection 对象创建完成后,就可以进行媒体协商了,大致过程如下:
更详细的步骤请参考 MDN 中对会话描述讲解。
通过 MDN 先了解下我们需要用到的 API:
// 配置
export const rtcConfig = null
const localPc = new RTCPeerConnection(rtcConfig)
let offer = await localPc.createOffer()
// 保存为本地描述
await localPc.setLocalDescription(offer)
// 通过信令服务器发送到对端
socket.emit('offer', offer)
socket.on('offer', offer) => {
// 将 Offer 保存为远程描述;
remotePc = new RTCPeerConnection(rtcConfig)
await remotePc.setRemoteDescription(offer)
let remoteAnswer = await remotePc.createAnswer()
await remotePc.setLocalDescription(remoteAnswer)
socket.emit('answer', remoteAnswer)
});
// 4. 发起方接收到 Answer 类型的 SDP 后保存到远程描述,此时发起方也已知道连接双方的配置;
socket.on('answer', answer) => {
// 将 Answer 保存为远程描述;
await localPc.setRemoteDescription(answer);
});
至此,媒体协商结束,紧接着在 WebRTC 底层会收集Candidate,并进行连通性检测,最终在通话双方之间建立起一条链路来。
媒体协商结束后,双端统一了传输协议、编解码器等,此时就需要建立连接开始音视频通信了。
但 WebRTC 既要保持音视频通信的质量,又要保证联通性。所有,当同时存在多个有效连接时,它首先选择传输质量最好的线路,如能用内网连通就不用公网,优先 P2P 传输,如果 P2P 不通才会选择中继服务器(relay),因为中继方式会增加双端传输的时长。
第五步最后,我们提到了媒体协商结束后,开始收集 Candidate,那么我们来了解下什么是 Candidate、以及它的作用是什么?
ICE Candidate(ICE 候选者):表示 WebRTC 与远端通信时使用的协议、IP 地址和端口,结构如下:
{
address: xxx.xxx.xxx.xxx, // 本地IP地址
port: number, // 本地端口号
type: 'host/srflx/relay', // 候选者类型
priority: number, // 优先级
protocol: 'udp/tcp', // 传输协议
usernameFragment: string // 访问服务的用户名
...
}
WebRTC 在进行连接测试后时,通信双端会提供众多候选者,然后按照优先级进行连通性测试,测试成功就会建立连接。
候选者 Candidate 类型,即 type 分为三种类型:
我们已经了解了 Candidate 的三种类型以及各自的优先级,那么我们看下双端是如何收集 Candidate 的。
host 类型的 Candidate 是最好收集的,就是本机的 ip 地址 和端口。
srflx 类型的 Candidate 就是内网通过 NAT(Net Address Translation,作用是进行内外网的地址转换,位于内网的网关上)映射后的外网地址。
如:访问百度时 NAT 会将主机内网地址转换为外网地址,发送请求到百度的服务器,服务器返回到公网地址和端口,在通过 NAT 转到内网的主机上。
那 WebRTC 是怎么处理 NAT 的呢?
没错,就是我们上面提到的 STUN 和 TURN。
全称 Session Traversal Utilities for NAT(NAT 会话穿越应用程序),是一种网络协议,它允许位于 NAT 后的客户端找出自己的公网地址,也就是遵守这个协议就可以拿到自己的公网 IP。
STUN 服务可以直接使用 google 提供的免费服务 stun.l.google.com:19302,或者自己搭建。
全称 Traversal Using Relays around NAT(使用中继穿透 NAT),STUN 的中继扩展。简单的说,TURN 与 STUN 的共同点都是通过修改应用层中的私网地址达到 NAT 穿透的效果,异同点是 TURN 是通过两方通讯的“中间人”方式实现穿透。
上面提到的 relay 服务就是通过 TURN 协议实现的,所以 relay 服务器和 TURN 服务器是同一个意思,都是中继服务器。
relay 类型的 Candidate 获取是通过 TURN 协议完成,它的连通率是所有候选者中连通率最高的,优先级也是最低的。
WebRTC 首会先使用 STUN 服务器去找出自己的 NAT 环境,然后试图找出打“洞”的方式,最后试图创建点对点连接。 当它尝试过不同的穿透方式都失败之后,为保证通信成功率会启用 TURN 服务器进行中转,此时所有的流量都会通过 TURN 服务器。这时如果 TURN 服务器配置不好或带宽不够时,通信质量就会变差。
重点:STUN 服务器是用来获取外网地址进行 P2P;而 TURN 服务器是在 P2P 失败时进行转发的
NAT 解决了 IPv4 地址不够用的情况,但因为有了 NAT,端与端之间的网络连接变得复杂,也就需要 NAT 穿越等技术。
收集完 Candidate 后,WebRTC 就按照优先级顺序进行连通性检测。如果双方位于同一个局域网,就会直接建立连接,如果不在同一个局域网内,WebRTC 就会尝试 NAT 打洞,即 P2P 穿越了。
全称 Interactive Connectivity Establishment(交互式连通建立方式),ICE 协议通过一系列的技术(如 STUN、TURN 服务器)帮助通信双方发现和协商可用的公共网络地址,从而实现 NAT 穿越,也就是上面说的获取所有候选者类型的过程,即:在本机收集所有的 host 类型的 Candidate,通过 STUN 协议收集 srflx 类型的 Candidate,使用 TURN 协议收集 relay 类型的 Candidate。
当 Candidate 被收集之后,会触发icecandidate事件,所以需要在代码中监听此事件,以对收集到的 Candidate 做处理。
localPc.onicecandidate = function (event) {
// 回调时,将自己candidate发给对方,对方可以直接addIceCandidate(candidate)添加可以获取流
if (event.candidate) socket.emit('candidate', event.candidate)
}
打印出的 Candidate 如下所示:
与我们上面提到的 Candidate 结构一致,其中type字段为host,即本机候选者。
对端接收到发送的 candidate 后,再调用 RTCPeerConnection 对象的addIceCandidate()方法将收到的 Candidate 保存起 来,然后按照 Candidate 的优先级进行连通性检测。
await remotePc.addIceCandidate(candidate)
如果 Candidate 连通性检测完成,那么端与端之间就建立了物理连接,这时媒体数据就可能通这个物理连接源源不断地传输了 Ï。
通信双方通过 RTCPeerConnection 建立连接后,本地的音视频数据源源不断的传输,要想在远端展示出来,就需要将 RTCPeerConnection 对象与
当远端创建好 RTCPeerConnection 对象后,会为 RTCPeerConnection 绑定ontrack事件,当有音视频数据流到来时,输入参数 event 中包含了远端的音视频流,即 MediaStream 对象,此时将此对象赋值给
remotePc.ontrack = (e) => {
video.srcObject = e.streams[0]
video.oncanplay = () => video.play()
}
至此,一个完整的 WebRTC 通信过程就结束了。
希望各位jy能有所收获。
本文主要是针对小白的 WebRTC 扫盲教程,接下来会详细讲解一对一的音视频聊天,多人聊天,以及使用 Livekit 快速搭建多人音视频聊天系统。