[rust]学习webrtc.rs 【1】 :webrtc握手流程
webrtc.rs 是一个RTC协议的实现,是著名的Pion(GOLANG 写的webrtc实现,是由google)的rust移植,并由纯rust实现。先来看看webrtc
参考和转自 https://zhuanlan.zhihu.com/p/624357784
概念
WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。
通讯流程的建立
首先,从概念可以看出,WebRTC 通讯过程不需要中间媒介(P2P)。但实现起来仍然存在以下几个问题:
- 如果需要通讯的两台设备的网络环境是局域网,该如何建立连接?
- 如果通讯的两台设备建立连接后,无法解析对方的音视频格式该如何?
这里需要明白一个概念,两者进行连接当然是需要通过 IP + 端口进行连接,毕竟只有通过 IP 才能找到对应的设备
第一个问题可以通过内网映射(NAT)的方式解决【ipv4已耗尽所以一定有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 服务。
搭建过程以及测试过程省略,可自行查找搭建教程并测试。 (测试地址:https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/)
信令服务器实现
这里就使用 ws 库简单的实现一个 WebSocket 服务,也可以使用其他框架。其他的业务等也可自行扩展(如房间管理等)。如下:
const WebSocket = require("ws"); const WebSocketServer = WebSocket.Server; const wss = new WebSocketServer({ port: 3001, }); // 生成唯一ID function createId() { let e = () => Math.floor((1 + Math.random()) * 65536) .toString(16) .substring(1); return `${e()}${e()}-${e()}-${e()}-${e()}-${e()}${e()}`; } // 存储连接的客户端 const people = {}; wss.on("connection", function (ws) { ws.on("message", function (message) { message = message.toString(); message = JSON.parse(message); switch (message.type) { case "connect": // 将连接的客户端存储起来 const sessionId = createId(); people[sessionId] = { sessionId, ws, }; ws.send( JSON.stringify({ type: "connect", data: sessionId, }) ); break; case "call": // 将 sdp 发给接收端,sessionId 为 接收端的 id const sdp = message.data.sdp; const sId = message.data.sessionId; if (people[sId]) { people[sId].ws.send( JSON.stringify({ type: "call", data: sdp, }) ); } break; case "answer": // 接收端将 sdp 发给发起端,sessionId 为 发起端的 id const answerSDP = message.data.sdp; const recevId = message.data.sessionId; if (people[recevId]) { people[recevId].ws.send( JSON.stringify({ type: "answer", data: answerSDP, }) ); } break; case "getAllClients": ws.send( JSON.stringify({ type: "getAllClients", data: Object.keys(people), }) ); break; } }); });
websocket 与客户端之间数据格式约定为 { type: xxx, data: xxx }。
上面代码做了以下事情:
1. 将连接的客户端存储起来,方便转发给指定的客户端(connect 类型);
2. call 类型为 发起端将 SDP (offer) 转发给接收端;
3. answer 类型为 接收端将 SDP (answer) 转发给发起端;
4. getAllClients 获取所有连接的客户端 ID。
转发网络信息与转发 SDP 是一样的,这里就没写了。因为如果 SDP 描述中已经有网络信息的描述,那么两端在将 SDP 设置到 peer (setLocalDescription, setRemoteDescription) 中时会自动解析相关的网络信息进行打洞。
客户端实现
客户端的代码就主要使用浏览器的 WebRTC API,以及 WebSocket API。主要代码如下:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>WebRTC Example</title> <script src="./ws.js"></script> </head> <body> <h1>WebRTC Example</h1> <video width="200" height="200" id="localVideo" autoplay></video> <video id="remoteVideo" autoplay></video> <br /> <button id="callButton">Call</button> <script> const peer = new RTCPeerConnection(); const ws = new WSS("ws://127.0.0.1:3001"); ws.send({ type: 'connect' }) let localVideoElement = document.querySelector('#localVideo'); let remoteVideoElement = document.querySelector('#remoteVideo'); let callButton = document.querySelector("#callButton"); // 获取本地音视频数据并将其添加到 peer 中 navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(stream => { // 将音视频设置到页面上 localVideoElement.srcObject = stream // 将音视频添加到 peer 中 stream.getTracks().forEach((track) => peer.addTrack(track, stream)); }) // 发起端点击call时创建offer并发送给接收端 callButton.onclick = () => { peer.createOffer().then(async offer => { await peer.setLocalDescription(offer) ws.send({ type: 'call', data: { sessionId: 'B', // 为了方便,这里写死 sdp: offer.sdp } }) }) } // 发起端收到answer sdp ws.subscribe('answer', async (data) => { const sdp = data.data await peer.setRemoteDescription({ type: 'answer', sdp }) }) // 接收端收到 offer sdp ws.subscribe('call', async (data) => { const sdp = data.data await peer.setRemoteDescription({ type: 'offer', sdp }) // 接收端创建answer并发送给发起端 peer.createAnswer().then(async answer => { await peer.setLocalDescription(answer) ws.send({ type: 'answer', data: { sdp:answer.sdp, sessionId: 'A' // 为了方便,这里写死 } }) }) }) peer.ontrack = (event) => { remoteVideo.srcObject = event.streams[0] } </script> </body> </html>
上面代码中的 ws.js 主要封装了 WebSocket API,主要 send 和 subscribe,send 为发送数据到服务器,subscribe 为监听相应的 type(这里就不展示了,不然太长了,明白就好)。
左边为 A 端,右边为 B 端;较小的为本端音视频,大的为远端音视频。
总结
由上面的代码可知,WebRTC 的连接以及传输都需要给 peer 提供相应的信息(音视频数据,SDP,ICE),音视频数据是需要传输的数据,而 SDP,ICE 则是两端协商需要的信息。
扩展
一对多/多对多通信
如果需要实现一对多或多对多通信,按照上面代码的方式,那么在有其他客户端加入时就需要实时创建一个新的 peer,对其进行协商。这种方式产生的架构叫 Mesh。Mesh 中,每个客户端都是可以直接连接到其他端点的。
与 Mesh 对应的架构是 MCU,MCU 则是有一个中央服务器,每个端都连接到此服务器。中央服务器将接收每个端点的数据,因此可以对数据进行处理,如合并、调整等。
总结
- 由于 WebRTC 建立的连接是 P2P,所以在局域网内的设备需要 NAT;
- 又由于 NAT 安全机制,所以 P2P 无法直接访问,就产生了 STUN/TURN;
- 又由于两端设备支持的编解码不一样,所以需要两端交换信息;
- STUN/TURN 穿越所产生的网络信息和设备支持的编解码信息需要交换协商,所以在连接前需要网络协商和媒体协商;
- 又由于 Mesh 结构的各种缺点,所以产生了 MCU 等。