ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
&emsp;&emsp;WebRTC 在创建点对点(P2P)的连接之前,会先通过信令服务器交换两端的 SDP 和 ICE Candidate,取两者的交集,决定最终的音视频参数、传输协议、NAT 打洞方式等信息。 &emsp;&emsp;在完成媒体协商,并且两端网络连通之后,就可以开始传输数据了。 &emsp;&emsp;本文示例代码已上传至[Github](https://github.com/pwstrick/webrtc),有需要的可以随意下载。 ## 一、术语 &emsp;&emsp;在实现一个简单的视频通话之前,还需要了解一些相关术语。 **1)SDP** &emsp;&emsp;SDP(Session Description Protocal)是一个描述会话元数据(Session Metadata)、网络(Network)、流(Stream)、安全(Security)和服务质量(Qos,Grouping)的[WebRTC协议](https://www.ietf.org/archive/id/draft-nandakumar-rtcweb-sdp-08.txt),下图是 SDP 各语义和字段之间的包含关系。 &emsp;&emsp;换句话说,它就是一个用文本描述各端能力的协议,这些能力包括支持的音视频编解码器、传输协议、编解码器参数(例如音频通道数,采样率等)等信息。 :-: ![](https://img.kancloud.cn/33/39/3339b2be05120ff6b96954e74fb9d3ad_1140x1934.png =600x) &emsp;&emsp;下面是一个典型的[SDP](https://developer.mozilla.org/zh-CN/docs/Glossary/SDP)信息示例,其中 RTP(Real-time Transport Protocol)是一种网络协议,描述了如何以实时方式将各种媒体从一端传输到另一端。 ~~~ =================会话描述====================== v=0 o=alice 2890844526 2890844526 IN IP4 host.anywhere.com s=- =================网络描述====================== c=IN IP4 host.anywhere.com t=0 0 ================音频流描述===================== m=audio 49170 RTP/AVP 0 a=rtpmap:0 PCMU/8000 ================视频流描述===================== m=video 51372 RTP/AVP 31 a=rtpmap:31 H261/90000 ~~~ &emsp;&emsp;RTP 协议的作用是让数据包有序,它是应用层传输协议的一种,与 HTTP/HTTPS 同级。 &emsp;&emsp;除了 RTP 之外,还有一种 RTCP 协议,与 RTP 处于同一级,并且可对其做丢包控制。 **2)ICE Candidate** &emsp;&emsp;ICE 候选者描述了 WebRTC 能够与远程设备通信所需的协议、IP、端口、优先级、候选者类型(包括 host、srflx 和 relay)等连接信息。 &emsp;&emsp;host 是本机候选者,srflx 是从 STUN 服务器获得的候选者,relay 是从 TURN 服务器获得的中继候选者。 &emsp;&emsp;在每一端都会提供许多候选者,例如有两块网卡,那么每块网卡的不同端口都是一个候选者。 &emsp;&emsp;WebRTC 会按照优先级倒序的进行连通性测试,当连通性测试成功后,通信的双方就建立起了连接。 **3)NAT打洞** &emsp;&emsp;在收集到候选者信息后,WebRTC 会判断两端是否在同一个局域网中,若是,则可以直接建立链接。 &emsp;&emsp;若不是,那么 WebRTC 就会尝试 NAT 打洞。WebRTC 将 NAT 分为 4 种类型:完全锥型、IP 限制型、端口限制型和对称型。 &emsp;&emsp;前文候选者类型中曾提到 STUN 和 TURN 两种协议,接下来会对它们做简单的说明。 &emsp;&emsp;STUN(Session Traversal Utilities for NAT,NAT会话穿越应用程序)是一种网络协议,允许位于 NAT 后的客户端找出自己的公网地址,当前 NAT 类型和 NAT 为某一个本地端口所绑定的公网端口。 &emsp;&emsp;这些信息让两个同时处于 NAT 路由器之后的主机之间建立 UDP 通信,STUN 是一种 Client/Server 的协议,也是一种 Request/Response 的协议。 &emsp;&emsp;下图描绘了通过 STUN 服务器获取公网的 IP 地址,以及通过信令服务器完成媒体协商的简易过程。 :-: ![](https://img.kancloud.cn/71/45/7145c9f8d8f4c39caf7fb5b4ce98d8b1_651x619.jpeg =600x) &emsp;&emsp;TURN(Traversal Using Relay NAT,通过 Relay 方式穿越 NAT),是一种数据传输协议,允许通过 TCP 或 UDP 穿透 NAT。 &emsp;&emsp;TURN 也是一个 Client/Server 协议,其穿透方法与 STUN 类似,但终端必须在通讯开始前与 TURN 服务器进行交互。 &emsp;&emsp;下图描绘了通过 TURN 服务器实现 P2P 数据传输。 :-: ![](https://img.kancloud.cn/dd/ea/ddea4f9ca4f0d4067e805a52b4cc455f_2684x1574.jpeg =800x) &emsp;&emsp;CoTurn 是一款免费开源的 TURN 和 STUN 服务器,可以到[GitHub](https://github.com/coturn/coturn)上下载源码编译安装。 ## 二、信令服务器 &emsp;&emsp;通信双方彼此是不知道对方的,但是它们可以先与信令服务器(Signal Server)连接,然后通过它来互传信息。 &emsp;&emsp;可以将信令服务器想象成一个中间人,由他来安排两端进入一个房间中,然后在房间中可以他们就能随意的交换手上的情报了。 &emsp;&emsp;本文会通过 Node.js 和[socket.io](https://socket.io/)实现一个简单的信令服务器,完成的功能仅仅是用于实验,保存在 server.js 文件中。 &emsp;&emsp;如果对 socket.io 不是很熟悉,可以参考我之前分享的一篇[博文](https://www.cnblogs.com/strick/p/16358972.html),对其有比较完整的说明。 **1)HTTP 服务器** &emsp;&emsp;为了实现视频通话的功能,需要先搭建一个简易的 HTTP 服务器,挂载静态页面。 &emsp;&emsp;注意,在实际场景中,这块可以在另一个项目中执行,本处只是为了方便演示。 ~~~ const http = require('http'); const fs = require('fs'); const { Server } = require("socket.io"); // HTTP服务器 const server = http.createServer((req, res) => { // 实例化 URL 类 const url = new URL(req.url, 'http://localhost:1234'); const { pathname } = url; // 路由 if(pathname === '/') { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(fs.readFileSync('./index.html')); }else if(pathname === '/socket.io.js') { res.writeHead(200, { 'Content-Type': 'application/javascript' }); res.end(fs.readFileSync('./socket.io.js')); }else if(pathname === '/client.js') { res.writeHead(200, { 'Content-Type': 'application/javascript' }); res.end(fs.readFileSync('./client.js')); } }); // 监控端口 server.listen(1234); ~~~ &emsp;&emsp;在上面的代码中,实现了最简易的路由分发,当访问 http://localhost:1234 时,读取 index.html 静态页面,结构如下所示。 ~~~html <video id="localVideo"></video> <button id="btn">开播</button> <video id="remoteVideo" muted="muted"></video> <script src="./socket.io.js"></script> <script src="./client.js"></script> ~~~ &emsp;&emsp;socket.io.js 是官方的 socket.io 库,client.js 是客户端的脚本逻辑。 &emsp;&emsp;在 remoteVideo 中附带 muted 属性是为了避免[报错](https://stackoverflow.com/questions/49930680/how-to-handle-uncaught-in-promise-domexception-play-failed-because-the-use):DOMException: The play() request was interrupted by a new load request。 &emsp;&emsp;最后就可以通过 node server.js 命令,开启 HTTP 服务器。 **2)长连接** &emsp;&emsp;为了便于演示,指定了一个房间,当与信令服务器连接时,默认就会被安排进 living room。 &emsp;&emsp;并且只提供了一个 message 事件,这是交换各端信息的关键代码,将一个客户端发送来的消息中继给其他各端。 ~~~ const io = new Server(server); const roomId = 'living room'; io.on('connection', (socket) => { // 指定房间 socket.join(roomId); // 发送消息 socket.on('message', (data) => { // 发消息给房间内的其他人 socket.to(roomId).emit('message', data); }); }); ~~~ &emsp;&emsp;因为默认是在本机演示,所以也不会安装 CoTurn,有兴趣的可以自行实现。 ## 三、客户端 &emsp;&emsp;在之前的 HTML 结构中,可以看到两个 video 元素和一个 button 元素。 ~~~ const btn = document.getElementById('btn'); // 开播按钮 const localVideo = document.getElementById('localVideo'); const remoteVideo = document.getElementById('remoteVideo'); const size = 300; ~~~ &emsp;&emsp;在两个 video 元素中,第一个是接收本地的音视频流,第二个是接收远端的音视频流。 **1)媒体协商** &emsp;&emsp;在下图中,Alice 和 Bob 通过信令服务器在交换 SDP 信息。 :-: ![](https://img.kancloud.cn/2b/68/2b68d0bbb2ddcb07c187a48d551da591_1058x370.png =600x) &emsp;&emsp;Alice 先调用[createOffer()](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/createOffer)创建一个 Offer 类型的 SDP,然后调用[setLocalDescription()](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription)配置本地描述。 &emsp;&emsp;Bob 接收发送过来的 Offer,调用[setRemoteDescription()](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/setRemoteDescription)配置远端描述。 &emsp;&emsp;再调用[createAnswer()](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createAnswer)创建一个 Answer 类型的 SDP,最后调用 setLocalDescription() 配置本地描述。 &emsp;&emsp;而 Bob 也会接收 Answer 并调用 setRemoteDescription() 配置远端描述。后面的代码会实现上述过程。 **2)RTCPeerConnection** &emsp;&emsp;在 WebRTC 中创建连接,需要先初始化[RTCPeerConnection](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection)类,其构造函数可以接收 STUN/TURN 服务器的配置信息。 ~~~ // STUN/TURN Servers const pcConfig = { // 'iceServers': [{ // 'urls': '', // 'credential': "", // 'username': "" // }] }; // 实例化 RTCPeerConnection const pc = new RTCPeerConnection(pcConfig); ~~~ &emsp;&emsp;然后注册 icecandidate 事件,将本机的网络信息发送给信令服务器,sendMessage() 函数后面会介绍。 ~~~ pc.onicecandidate = function(e) { if(!e.candidate) { return; } // 发送 ICE Candidate sendMessage({ type: 'candidate', label: e.candidate.sdpMLineIndex, id: e.candidate.sdpMid, candidate: e.candidate.candidate }); }; ~~~ &emsp;&emsp;最后注册 track 事件,接收远端的音视频流。 ~~~ pc.ontrack = function(e) { remoteVideo.srcObject = e.streams[0]; remoteVideo.play(); }; ~~~ **3)长连接** &emsp;&emsp;在客户端中,已经引入了 socket.io 库,所以只需要调用 io() 函数就能建立长连接。 &emsp;&emsp;sendMessage() 函数就是发送信息给服务器的 message 事件。 ~~~ const socket = io("http://localhost:1234"); // 发送消息 function sendMessage(data){ socket.emit('message', data); } ~~~ &emsp;&emsp;本地也有个 message 事件,会接收从服务端发送来的消息,其实就是那些转发的消息。 &emsp;&emsp;data 对象有个 type 属性,可创建和接收远端的 Answer 类型的 SDP 信息,以及接收远端的 ICE 候选者信息。 ~~~ socket.on("message", function (data) { switch (data.type) { case "offer": // 配置远端描述 pc.setRemoteDescription(new RTCSessionDescription(data)); // 创建 Answer 类型的 SDP 信息 pc.createAnswer().then((desc) => { pc.setLocalDescription(desc); sendMessage(desc); }); break; case "answer": // 接收远端的 Answer 类型的 SDP 信息 pc.setRemoteDescription(new RTCSessionDescription(data)); break; case "candidate": // 实例化 RTCIceCandidate const candidate = new RTCIceCandidate({ sdpMLineIndex: data.label, candidate: data.candidate }); pc.addIceCandidate(candidate); break; } }); ~~~ &emsp;&emsp;在代码中,用[RTCSessionDescription](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCSessionDescription)描述 SDP 信息,用[RTCIceCandidate](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate)描述 ICE 候选者信息。 **4)开播** &emsp;&emsp;为开播按钮注册点击事件,在事件中,首先通过 getUserMedia() 获取本地的音视频流。 ~~~ btn.addEventListener("click", function (e) { // 获取音视频流 navigator.mediaDevices .getUserMedia({ video: { width: size, height: size }, audio: true }) .then((stream) => { localVideo.srcObject = stream; localStream = stream; // 将 Track 与 RTCPeerConnection 绑定 stream.getTracks().forEach((track) => { pc.addTrack(track, stream); }); // 创建 Offer 类型的 SDP 信息 pc.createOffer({ offerToRecieveAudio: 1, offerToRecieveVideo: 1 }).then((desc) => { // 配置本地描述 pc.setLocalDescription(desc); // 发送 Offer 类型的 SDP 信息 sendMessage(desc); }); localVideo.play(); }); btn.disabled = true; }); ~~~ &emsp;&emsp;然后在 then() 方法中,让 localVideo 接收音视频流,并且将 Track 与 RTCPeerConnection 绑定。 &emsp;&emsp;这一步很关键,没有这一步就无法将音视频流推给远端。 &emsp;&emsp;然后创建 Offer 类型的 SDP 信息,配置本地描述,并通过信令服务器发送给远端。 &emsp;&emsp;接着可以在两个浏览器(例如 Chrome 和 Edge)中分别访问 http://localhost:1234,在一个浏览器中点击开播,如下图所示。 :-: ![](https://img.kancloud.cn/e9/62/e962803c60b6f47658bf1bdaee042dfd_1368x666.png =600x) &emsp;&emsp;在另一个浏览器的 remoteVideo 中,就可以看到推送过来的画面。 :-: ![](https://img.kancloud.cn/78/e7/78e79040f8e4dde6e29d3b9ec228cf1b_1368x666.png =600x) &emsp;&emsp;下面用一张时序图来完整的描述整个连接过程,具体内容不再赘述。 :-: ![](https://img.kancloud.cn/3f/83/3f83595949cefee5bea3117071b5d4f6_836x957.png) 参考资料: [What is WebRTC and How to Setup STUN/TURN Server for WebRTC Communication?](https://medium.com/av-transcode/what-is-webrtc-and-how-to-setup-stun-turn-server-for-webrtc-communication-63314728b9d0) [WebRTC音视频传输基础:NAT穿透](https://blog.jianchihu.net/webrtc-av-transport-basis-nat-traversal.html) ***** > 原文出处: [博客园-HTML躬行记](https://www.cnblogs.com/strick/category/1770829.html) [知乎专栏-HTML躬行记](https://zhuanlan.zhihu.com/c_1250826149041238016) 已建立一个微信前端交流群,如要进群,请先加微信号freedom20180706或扫描下面的二维码,请求中需注明“看云加群”,在通过请求后就会把你拉进来。还搜集整理了一套[面试资料](https://github.com/pwstrick/daily),欢迎阅读。 ![](https://box.kancloud.cn/2e1f8ecf9512ecdd2fcaae8250e7d48a_430x430.jpg =200x200) 推荐一款前端监控脚本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不仅能监控前端的错误、通信、打印等行为,还能计算各类性能参数,包括 FMP、LCP、FP 等。