)
目录概述相关概念双端连接整体实现步骤概述文章代码实现注意点STUN和TURN服务器的搭建开发过程描述后端开发流程前端开发流程效果演示Gitee源码地址* *概述–文章描述使用WebRTC技术实现一对一音视频通话。 * 由于设备摄像头限制一台电脑作测试无法在开启的双端同时获取摄像头数据流导致一台电脑无法同时测试双端因此文章使用mp4音视频文件模拟摄像头音视频数据流输入。 *使用技术* 前端Vue3WebRTC相关APIaxios * 后端信令服务器实现SpringBootWebSocket 相关概念----*Peer-to-Peer (P2P) 连接WebRTC主要是基于 P2P 连接的这意味着通信是直接在两端的浏览器之间进行的而不需要经过中介服务器尽管可能会使用服务器来初始化和协调连接。这种方式降低了延迟并节省了带宽。 *SDP**Session Description Protocol描述媒体信息如音频、视频编码格式、传输协议等的协议。例如我们在双方构建连接时我们需要知道对方使用的音视频编解码格式以确保双方使用相同编解码格式。编解码格式就是定义在SDP信息中的其中之一的信息。 *ICE CandidateICE 候选是 WebRTC 在 P2P 连接过程中为寻找最佳传输路径如 STUN 或 TURN 服务器提供的一系列地址和端口。在双方构建连接时需要知道对方的公网IP****地址和端口以实现P2P连接Candidate信息中就包含自身的公网IP和端口。*STUNSession Traversal Utilities forNAT**服务器是 NAT 穿透的协议用来获取客户端的公网 IP 地址和端口。我们身处各种局域网中对方如果想要和我们构建P2P连接就必然要知道我们的公网IP和端口才能和我们连接上我们可以通过STUN服务器获取我们的公网IP和端口。 *TURNTraversal Using Relays aroundNAT**服务器**当 STUN 连接不可用时TURN 服务器作为中继服务器转发数据。当STUN服务器无法帮助我们获取公网IP和端口时我们就可以使用TURN服务器作为中转站传递音视频流数据。 *信令服务器上面介绍了媒体信息SDP和网络信息Candidate这些实际上可以称为信令我们如果想要与对端连接那么我们就需要知道对端的媒体信息和网络信息来构建连接信令服务器就是帮助我们实现两端的信息交换的。本文中信令服务器就是我们自己编写的SpringBoot后端来帮助两端互传连接信息。 双端连接整体实现步骤概述------------在大致知道了上面介绍的WebRTC基本概念之后我们以双端音视频互联的整体过程。假设存在A端发起端和B端接收端。1.创建RTC连接对象new RTCPeerConnection此对象存在构建连接时所需的API。2.A端和B端分别连接后端WebSocket信令服务器以为接下来信息互传奠定基础。3.A端创建媒体信息SDPcreateOffer保存到本地setLocalDescription将A端SDP信息通过WebSocket发送给B端。4.B端接收到A端的SDP信息设置为远端媒体信息setRemoteDescription然后B端创建应答媒体信息实际上就是B端的媒体信息SDPcreateAnswer保存到本地setLocalDescription并将B端创建的应答媒体信息SDP通过WebSocket发送给A端。5.A端收到B端发送的应答媒体信息SDP后保存为远端媒体信息setRemoteDescription。6. 至此A端和B端媒体信息SDP交换完毕。7.开始交换网络信息Candidate我们在创建RTC连接对象时步骤1监听网络信息的获取onicecandidate当我们调用setRemoteDescription函数设置了远端媒体信息之后会触发onicecandidate并给予condidate网络信息。8. 我们将监听到的网络信息candidate通过WebSocket发送给对端对端收到后将对方的网络信息配置上addIceCandidate以实现连接。9.当媒体信息SDP和网络信息Candidate互相交换并设置上之后就可以开始音视频流数据互传显示了。10.通过addTrack发送本地流数据通过ontrack监听对端音视频流数据的发送监听到就显示对端音视频。* *媒体协商和网络协商时序图:总结在视频互传之前重要的就是交换媒体SDP信息和网络Candidate信息媒体和网络协商当双方都获取到对方的媒体和网络信息之后。就能够成功构建连接并传递音视频数据了。文章代码实现注意点---------在最开始的概述中有提到本文提供的1对1音视频聊天代码示例中没有真实调用用户摄像头获取音视频流数据因为作者只有一台电脑为了可以更方便的在一台电脑上开启两端并测试因此使用了MP4音视频作为音视频流数据输入作为测试。这实际上并不会和真实开启摄像头获取音视频数据流有很大的区别。仅仅是获取流数据的方式不同罢了。在真实的场景下可以使用APIgetUserMedia去获取摄像头音视频流数据即可。 const stream await navigator.mediaDevices.getUserMedia({ video: true, audio: true });STUN和TURN服务器的搭建---------------为了能够获取到我们本地的公网IP和端口去和对端创建连接我们可以尝试去搭建STUN服务器和TURN中继服务器。注此步骤不是一定需要做因为Google给我们提供了一个免费公用的STUN服务器地址stun:stun.l.google.com:19302如果你发现用不了或需要搭建复杂的音视频通话应用还是推荐自己搭建一下STUN/TURN服务器。我们直接搭建开源的Coturn服务器即可因为Coturn 同时支持 TURN 和 STUN 协议。****下面会介绍在CentOS8中搭建Coturn服务器步骤**1.安装所需依赖包 yum install -y make gcc cc gcc-c wget openssl-devel libevent libevent-devel openssl2.yum直接一键下载安装 sudo yum install coturn # 验证安装安装程序结束后执行如下命令查看是否正确输出turnserver路径 which turnserver3.配置Coturn相关属性找到配置文件路径 find / -name turnserver.conf4.获取服务器内网IP和公网IP # 输入命令查看Ip ifconfig找到自己启用的网络下的内网IP公网IP就是你连接服务器的IP地址。5.使用openSSL生成cert和pkey配置的自签名证书 openssl req -x509 -newkey rsa:2048 -keyout /turn_server_pkey.pem -out /turn_server_cert.pem -days 999 -nodes 输入上面命令后填写一下证书的一些信息城市地区等随便填一下回车回车就行。 上面的/turn_server_pkey.pem和/turn_server_cert.pem 请自己设置好保存证书的路径上面默认放到了根路径下。6.编辑刚才找到的配置文件 将下面的配置部分修改后替换掉原配置文件的所有内容。 # 网卡名 relay-deviceeth0 #内网IP listening-ip172.24.52.189 listening-port3478 #内网IP加密访问配置 relay-ip172.24.52.189 tls-listening-port5349 # 外网IP external-ip自己的外网IP relay-threads500 #打开密码验证 lt-cred-mech cert/turn_server_cert.pem pkey/turn_server_pkey.pem min-port40000 max-port65535 #设置用户名和密码创建IceServer时使用 useruser:123456 # 外网IP绑定的域名 realm你自己IP绑定的域名 # 服务器名称用于OAuth认证默认和realm相同部分浏览器本段不设可能会引发cors错误。 server-name你自己IP绑定的域名 # 认证密码和前面设置的密码保持一致 cli-password1234567.开启端口访问7.1开启云服务器安全组端口开启4000-65535端口的原因外部客户端与 TURN 服务器的通信使用动态端口。通常操作系统会为每个连接分配一个临时端口通常是大于 1024 的端口而40000 到 65535 端口作为高端端口是常用的临时端口范围。因此为了确保 TURN 服务器能够处理大量的并发连接并为每个连接分配一个端口需要确保 TURN 服务器的端口范围足够大。7.2开启本地防火墙端口 #开放端口 firewall-cmd --zonepublic --add-port3478/udp --permanent firewall-cmd --zonepublic --add-port3478/tcp --permanent #重启防火墙 firewall-cmd --reload8.启动Coturn服务器 turnserver -o -a -f9.* 测试启动状态访问测试网站Trickle ICE开发过程描述------如下仅展示关键性代码解释说明具体代码请到文章最后获取Gitee源码地址。### 后端开发流程* websocket连接成功后维护用户连接信息并广播join消息。数据携带用户ID列表。 // 后端维护Session连接的数据结构 private final HashMapString, WebSocketSession userMap new HashMap();* 编写接收信息通用接口dto对象包含userIDtypedataJSON序列化字符串接口根据传入userId取出session给session发送消息对象。 ### 前端开发流程* 日志系统监听ice状态及日志打印。 * 创建随机ID连接ws。 * 协商函数协商前创建peerConnection对象并监听candidate当双方都连接成功后调用判断本地offerFlag状态如果为true创建offer设置本地并发送消息给对端。 // STUN 服务器 const iceServers [ { urls: “stun:stun.l.google.com:19302” // Google公开的STUN 服务器 }, { urls: “stun:自己的STUN服务器IP:3478” // 自己的Stun服务器 }, { urls: “turn:自己的TRUN服务器IP:3478”, // 自己的TURN服务器 username: “userName”, credential: “Password” } ]; // 创建RTC连接对象并监听和获取condidate信息 function createPeerConnection() { wlog(“开始创建PC对象…”) peerConnection new RTCPeerConnection(iceServers); wlog(“创建PC对象成功”) // 创建RTC连接对象后连接websocket initWebSocket(); // 监听网络信息ICE Candidate peerConnection.onicecandidate (event) { if (event.candidate) { candidateInfo event.candidate; wlog(“candidate信息变化…”); // 将candidate信息发送给远端 setTimeout((){ sendCandidate(event.candidate); }, 150) } }; // 监听远端音视频流 peerConnection.ontrack (event) { nextTick(() { wlog(“ 收到远端数据流 ”) if (!remoteVideo.value.srcObject) { remoteVideo.value.srcObject event.streams[0]; remoteVideo.value.play(); // 强制播放 } }); // remoteVideo.value.srcObject event.streams[0]; }; // 监听ice连接状态 peerConnection.oniceconnectionstatechange () { wlog(RTC连接状态改变${peerConnection.iceConnectionState}); }; // 添加本地音视频流到 PeerConnection localStream.getTracks().forEach(track { peerConnection.addTrack(track, localStream); }); }* candidate监听当监听到candidate后判断双方是否已连接如果已连接构造并发送candidate给对端。 * 解析消息处理器 * 解析jointype为join取出userId列表如果为一个代表仅自己在线标识为创建offer端日志打印相关信息如果有两个者取出对方ID保存代表双方都上线成功日志打印调用协商函数开始媒体协商和网络协商。 * 解析offertype为offer说明收到发起端offer将offer设置为远端信息然后创建answer设置到本地构建answer消息发送给对端。 * 解析answertype为answer说明收到接收端应答取出answer设置为远端消息。 * 解析candidatetype为candidate说明收到对端的网络信息取出设置到本地。 // 消息处理器 - 解析器 function handleSignalingMessage(message) { wlog(“收到ws消息开始解析…”) wlog(message) let parseMsg JSON.parse(message); wlog(解析结果${parseMsg}); if (parseMsg.type “join”) { joinHandle(parseMsg.data); } else if (parseMsg.type “offer”) { wlog(“收到发起端offer开始解析…”); offerHandle(parseMsg.data); } else if (parseMsg.type “answer”) { wlog(“收到接收端的answer开始解析…”); answerHandle(parseMsg.data); }else if(parseMsg.type “candidate”){ wlog(“收到远端candidate开始解析…”); candidateHandle(parseMsg.data); } } // 远端Candidate处理器 async function candidateHandle(candidate){ peerConnection.addIceCandidate(new RTCIceCandidate(JSON.parse(candidate))); wlog(“ 本端candidate设置完毕 ”); } // 接收端的answer处理 async function answerHandle(answer) { wlog(“将answer设置为远端信息”); peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(answer))); // 设置远端SDP } // 发起端offer处理器 async function offerHandle(offer) { wlog(“将发起端的offer设置为远端媒体信息”); await peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(offer))); wlog(“创建Answer 并设置到本地”); let answer await peerConnection.createAnswer() await peerConnection.setLocalDescription(answer); wlog(“发送answer给发起端”); // 构造answer消息发送给对端 let paramObj { userId: oppositeUserId, type: “answer”, data: JSON.stringify(answer) } // 执行发送 const res await axios.post(${BaseUrl}/rtcs/sendMessage, paramObj); } // 加入处理器 function joinHandle(userIds) { // 判断连接的用户个数 if (userIds.length 1 userIds[0] userId) { wlog(“标识为发起端等待对方加入房间…”) isRoomEmpty.value true; // 存在一个连接并且是自身标识我们是发起端 offerFlag true; } else if (userIds.length 1) { // 对方加入了 wlog(“对方已连接…”) isRoomEmpty.value false; // 取出对方ID for (let id of userIds) { if (id ! userId) { oppositeUserId id; } } wlog(对端ID: ${oppositeUserId}) // 开始交换SDP和Candidate swapVideoInfo() } }效果演示----初始状态发起端加入房间接收端加入房间Gitee源码地址---------源码地址点击访问Gitee项目源代码。