简介
WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。
通讯流程的建立
首先,从概念可以看出,WebRTC 通讯过程不需要中间媒介(P2P)。但实现起来仍然存在以下几个问题:
如果需要通讯的两台设备的网络环境是局域网,该如何建立连接?
如果通讯的两台设备建立连接后,无法解析对方的音视频格式该如何?
这里需要明白一个概念,两者进行连接当然是需要通过 IP + 端口进行连接,毕竟只有通过 IP 才能找到对应的设备
第一个问题可以通过内网映射(NAT)的方式解决; 第二个问题可以在连接之前先互相确定好双方支持的格式(一方支持 H264 和 VP8,另一方支持 H264 和 VP9,那么就可以在连接之前确定好双方的编解码格式是 H264 格式,此格式是双方都支持的)。
第二个问题需要在连接之前相互确定,那还没连接上,怎么相互确定呢?这时就可以通过一台中间的服务器来传递双方的信息了(双方 NAT 后的 IP 和端口也可以通过此服务器进行传递)。
所以基于以上问题,通讯过程可能如下:
用户 A 和 用户 B 通过 NAT 服务进行映射;
用户 A 和 用户 B 通过一台中间服务器进行交换各自支持的格式和映射后的 IP + 端口;
各自得知对方的信息后就可以开始连接通讯。
通讯流程
上面得知了一个简单粗糙的建立通讯的流程,但要知道更加细节的流程就需要明白以下几个概念:
媒体协商(SDP):两个用户在连接之前相互确定并交换双方支持的音视频格式的过程就是媒体协商。SDP 是描述信息的一种格式,其格式组成可自行查找了解;
网络协商(candidate):两个用户在 NAT 后交换各自的网络信息的过程就是网络协商。candidate 也是一种描述信息的一种格式,其格式组成可自行查找了解。
信令服务器:传递双方信息的服务器就是信令服务器,此服务其实就是 web 服务,其职责也不止传输媒体格式以及网络信息,还可传输业务信息。其传输信息的协议可是 HTTP 或 Socket 等。
STUN:STUN 是一种网络协议,其目的是进行 NAT 穿越。 内网进行 NAT 后进行 P2P 连接会有两个问题:
由于 NAT 的安全机制,NAT 会过滤掉一些外网主动发送到内网的报文,而 P2P 恰恰就需要主动发起访问;
NAT 后,会得到一个 IP + 端口的地址,而在进行 P2P 连接时并不知道这个地址,难道要用户手动填写吗。
所以 STUN 的作用就是能够检测网络中是否存在 NAT 设备,有就可以获取到 NAT 分配的 IP + 端口地址,然后建立一条可穿越 NAT 的 P2P 连接(这一过程就是打洞)。
TURN:TURN 是 STUN 协议的扩展协议,其目的是如果 STUN 在无法打通的情况下,能够正常进行连接,其原理是通过一个中继服务器进行数据转发,此服务器需要拥有独立的公网 IP。
TURN 很明显的一个问题就是其转发数据所产生的带宽费用需要由自己承担!
ICE:ICE(Interactive Connectivity Establishment),是一种用于实现网络连接的技术框架,用于在对等连接(如实时通信、P2P 文件共享等)中解决 NAT(Network Address Translation)和防火墙等网络障碍的问题。 ICE 是一种框架,可以通过使用多种技术(如 STUN、TURN、NAT 透明性检测等)来搜索可用的网络路径,并选择最优的路径建立连接,从而解决了 NAT 和防火墙等网络障碍的问题。 ICE 框架包含了以下几个步骤:
收集网络接口信息,包括本地 IP 地址、端口等;
通过 STUN 服务器获取公网 IP 地址和端口号;
通过 NAT 透明性检测来确定 NAT 类型和行为;
尝试直接连接对等端点;
如果直接连接失败,则使用 TURN 服务器作为中继节点进行连接。 也就是,ICE 更好的进行 NAT 穿越效果,从而提高实时通信的质量和效率。
明白以上概念后,那么就来看看更加详细的流程吧,先看下图:
根据上图,整体流程是:
用户 A 和用户 B 都需要先连接到信令服务器;
用户 A 和用户 B 都创建一个 PeerConnection(此时 WebRTC 会自动向 STUN/TURN 服务获取 candidate 信息, WebRTC 内置了 ICE);
用户 A 将本地音视频流添加到 PeerConnection 中(通过 getUserMedia 获取音视频流);
用户 A 作为发起方创建 offer(offer 中包含了 SDP 信息),并将获取的本地 SDP 信息添加到 PeerConnection 中(setLocalDescription),然后再通过信令服务器转发给用户 B;
用户 B 接收到用户 A 的 offer 后,将其添加到 PeerConnection 中(setRemoteDescription);
用户 B 将本地音视频流添加到 PeerConnection 中(通过 getUserMedia 获取音视频流);
用户 B 创建一个 Answer,并添加到 PeerConnection 中(setLocalDescription);
用户 B 通过信令服务器将 answer 转发给用户 A;
用户 A 接收到 answer 后将其添加到 PeerConnection 中;
用户 A 和 用户 B 都接收到了 candidate 信息后,都通过信令服务器转发给对方并添加到 PeerConnection 中(addIceCandidate);
媒体信息和网络信息交换完毕后,WebRTC 开始尝试建立 P2P 连接;
建立成功后,双方就可以通过 onTrack 获取数据并渲染到页面上。
上图是以用户 A 为发起方,用户 B 为接收方。
实现一对一实时通信
根据上面流程,我们需要:
STUN/TURN 服务
使用 coturn 搭建 STUN/TURN 服务
充当转发的服务(信令服务)
使用 node 的 第三方库实现 websocket 服务
coturn 搭建
coturn 是开源的服务器应用,完整实现了 STUN 和 TURN 协议。借助 coturn,我们可以快捷方便的搭建一个 STUN/TURN 服务。
重要概念
MediaStream
流媒体对象,音/视频数据的一种封装格式,挂载到 video 或 audio 标签上播放
RTCPeerConnection
会话控制,网络和媒体信息收发,作用类似 http 对象
SDP
主要用于两个会话实体之间的媒体协商,作用类似 http 中的配置项
结合下图类比会更容易理解:
代码实例
创建数据源
localStream 作为发送端本地预览画面:
// 创建数据源
const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
// 显示数据源,localVideo 是 html 中的 video 标签
localVideo.srcObject = localStream;
创建发送数据实例
// 本地实例
const pc1 = new RTCPeerConnection();
// 对端实例
const pc2 = new RTCPeerConnection();
配置实例
// 告诉对端,本端地址
pc1.addEventListener('icecandidate', async (e) => {
// 发送给对端
// 对端添加本端地址
if (e.candidate) {
await pc2.addIceCandidate(e.candidate);
}
});
pc2.addEventListener('icecandidate', async (e) => {
// 发送给本端
// 本端添加对端地址
if (e.candidate) {
await pc1.addIceCandidate(e.candidate);
}
});
// 创建本端SDP,告诉本端浏览器支持哪些能力
const offer = await pc1.createOffer();
pc1.setLocalDescription(offer);
// 创建远端SDP,告诉远端浏览器支持哪些能力
const answer = await pc2.createAnswer();
pc2.setLocalDescription(answer);
// 。。。。发送远端SDP给本端
// 接收远端sdp,告诉远端浏览器支持哪些能力
pc1.setRemoteDescription(answer);
// 接收客户端sdp,告诉远端浏览器支持哪些能力
pc2.setRemoteDescription(offer);
发送数据
localStream.getTracks().forEach(
(track) => pc1.addTrack(track, localStream)
);
完整精简版Typescript代码
const pc1 = new RTCPeerConnection();
pc1.addEventListener('icecandidate', async (e) => {
if (e.candidate) {
await pc2.addIceCandidate(e.candidate);
}
});
pc1.addEventListener('iceconnectionstatechange', (e) => {
console.log('pc1: iceconnectionstatechange', e);
});
const pc2 = new RTCPeerConnection();
pc2.addEventListener('icecandidate', async (e) => {
if (e.candidate) {
await pc1.addIceCandidate(e.candidate);
}
});
pc2.addEventListener('iceconnectionstatechange', (e) => {
console.log('pc2: iceconnectionstatechange', e);
});
pc2.addEventListener('track', (e) => {
if (e.streams.length > 0) {
remoteVideo.srcObject = e.streams[0];
}
});
const remoteVideo = document.querySelector('#remoteVideo') as HTMLVideoElement;
const localVideo = document.querySelector('#localVideo') as HTMLVideoElement;
async function pushStream(answer: RTCSessionDescriptionInit) {
pc1.setRemoteDescription(answer);
}
async function pullStream(offer: RTCSessionDescriptionInit): Promise<void> {
pc2.setRemoteDescription(offer);
const answer = await pc2.createAnswer();
pc2.setLocalDescription(answer);
console.warn('answer', answer);
pushStream(answer);
}
window.onload = async () => {
const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
localVideo.srcObject = localStream;
localStream.getTracks().forEach((track) => pc1.addTrack(track, localStream));
const offer = await pc1.createOffer();
pc1.setLocalDescription(offer);
console.warn('pc1 offer', offer);
pullStream(offer);
};