WebRTC技术知识与应用(一)
一、什么是WebRTC技术
指web应用的实时通信技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC的技术已成为HTML5标准之一,WebRTC提供了视频会议的核心技术,包括音视频的采集、编解码、网络传输、显示等功能,并且还支持跨平台。现代主流浏览器已经支持WebRTC,WebRTC技术的应用不局限于浏览器,只要是实现了该规范的终端都可以使用。
WebRTC 有多种用途,它们与Media Capture 和 Streams API一起,为 Web 提供强大的多媒体功能,包括支持音频和视频会议、文件交换、屏幕共享、身份管理,以及与传统电话系统的接口,包括支持发送 DTMF(按键音)信号。无需任何特殊驱动程序或插件即可在对等点之间建立连接,而且通常无需任何中间服务器即可建立。以下是WebRtc的整体架构图,我们将根据这个张图展开来说说他们都是什么。
- 浏览器厂商API:这些API提供了对操作系统硬件声卡、显卡、网卡等驱动层的直接访问,Audio Capture/Render表示音频的采集和渲染,Video Capture表示视频采集,Network I/O表示网络IO,当然这是浏览器的底层实现,我们了解即可。
- 核心引擎:该层包含Voice Engine(音频引擎)、Video Engine(视频引擎)以及Transport(传输模块)三大模块,其中音频引擎包含了音频采集、音频编解码、音频优化(包括降噪、回声消除等)等一系列的音频处理功能。视频引擎包含了视频采集、视频编解码、图像处理等功能。传输模块提供了音视频数据传输、文本文件传输、图片传输等功能。
- 信令管理:该层提供了会话功能管理功能,可进行创建会话、管理会话、管理上下文环境等,涉及到各种协议,比如说信令服务器的SDP协议等,主要用于进行信令交互和管理 RTCPeerConnection的连接状态。
- WebRTC C++API:这一层是C++接口层,主要是供浏览器支持WebRTC规范而调用的API,主要作用就是把WebRTC的核心功能暴露出来,如设备管理,音视频流数据采集等,方便各个软件厂商(浏览器厂商)集成到自家应用中。
- WebAPI层:表示的是WebRTC开放给应用层开发人员的JavaScript API,开发者无需关心复杂的底层技术,只需了解webRTC的大致流程原理,调其API即可利用webRTC实现点对点的通讯功能。
二、WebRTC的通信原理
在两个浏览器实现点对点实时视频/语音通信需要解决如下几个问题,一是需要了解双方的媒体格式、分辨率、编码方式等;二是需要了解双方的网路,找到一条能够通信的链路;三是在双方正式连接前如何交换连接信息,表达连接愿望。为了解决这些问题,WebRTC运用了如下概念:
- 信令:信令是在两个设备之间发送控制信息以确定通信协议、信道、媒体编解码器和格式以及数据传输方法以及任何所需的路由信息的过程。信令期间需要交换的信息有三种基本类型,分别是控制消息(用于设置、打开、关闭通信通道并处理错误)、建立连接所需的信息(设备间能够彼此交谈所需的 IP 寻址和端口信息)、 媒体能力协商(交互双方可以理解哪些编解码器和媒体数据格式?这些都需要在 WebRTC 会议开始之前达成一致)。只有在信令成功完成之后,打开 WebRTC 对等连接的过程才真正开始。
- 媒体协商SDP:运用了一种特殊的协议SDP叫会话描述协议,参与实时音视频通信的双方在通信前必须先交换SDP信息,它只是一种信息格式的描述标准,本身不属于传输协议,但是可以被其他传输协议用来交换必要的媒体信息,用于两个会话实体之间的媒体协商,找到适合双方的媒体传输方式。
- 网络协商Candidate:在复杂的网络环境中,要在两端之间建立连接,必须有一个双方都可以访问的链路,此时需要交换双方的网络信息,找到共同的交集,这个过程成为网络协商,在WebRTC中用来描述网络信息的术语叫Candidate。
- 信令服务器Signal Server:信令过程需要交换信息,那这些是如何交换的呢?这就需要一个叫信令服务器的中介来完成,条件是通信双方都必须有一种方式能够与信令服务器通信,因此WebRTC规定任何能够进行网络信息交换的技术都可以用来实现信令服务器。在信令期间,信令服务器实际上不需要理解两个设备之间交换的数据或者对这些数据做任何处理。信令服务器本质上就是一个中继器:两端连接的公共点,两端都知道它们的信令数据可以通过这个点来传输,服务器不需要以任何方式对此信息做出反应。
- TURN/STUN中继器:要了解中继服务器是什么,需要先了解以下两种协议:
STUN协议:是一种网络协议(NAT的UDP简单穿越协议),它允许位于NAT(或多重NAT)后的客户端找出自己的公网地址,查出自己位于哪种类型的NAT之后以及NAT为某一个本地端口所绑定的Internet端口,这些信息被用来在两个同时处于NAT路由器之后的主机之间创建UDP通信。
TURN协议:即通过Relay方式穿越NAT,是STUN的拓展,TURN应用模型通过分配TURNServer的地址和端口作为客户端对外的接受地址和端口,即私网用户发出的报文都要经过TURNServer进行Relay转发,这种方式应用模型除了具有STUN方式的优点外,还解决了STUN应用无法穿透对称NAT以及类似的Firewall设备的缺陷。TURN的局限性在于所有报文都必须经过TURNServer转发,增大了包的延迟和丢包的可能性。
中继器其实就是在服务端架设的一个TURN或STUN协议服务器,客户端在发送数据时无法NAT穿越的时候将这个媒体流数据首先传给TURN服务,通过TURN服务中介然后转给其他的接收者,或者其他接收者也可以发送数据给这个TURN服务,TURN服务器再转给client端。因此,除非你能确保通信双方一定可以点对点通信,否则都需要配置STUN中继服务器,如果STUN还是无法保证一定可以点对点通信,就需要配置TURN服务器。
三、信令过程
为了开启一个 WebRTC 会话,以下事件需要依次发生:
- 每个 Peer 创建一个 RTCPeerConnection 对象,用来表示其 WebRTC 会话端。
- 每个 Peer 建立一个 icecandidate 事件的响应程序,用来在监测到该事件时将这些 candidates 通过信令通道发送给另一个 Peer。
- 每个 Peer 建立一个 track 事件的响应程序,这个事件会在远程 Peer 添加一个 track 到其 stream 上时被触发。这个响应程序应将 tracks 和其消受者联系起来,例如<video>元素。
- 呼叫者创建并与接收者共享一个唯一的标识符或某种令牌,使得它们之间的呼叫可以由信令服务器上的代码来识别。此标识符的确切内容和形式取决于您。
- 每个 Peer 连接到一个约定的信令服务器,如 WebSocket 服务器,他们都知道如何与之交换消息。
- 每个 Peer 告知信令服务器他们想加入同一 WebRTC 会话(由步骤 4 中建立的令牌标识)。
- 描述,候选地址等。
四、ICE技术
ICE的全称是Interactive Connectivity Establishment,即交互式连接建立。是一个允许你的浏览器和对端浏览器建立连接的框架。在实际的网络当中,有很多原因能导致简单的从 A 端到 B 端直连不能如愿完成。这需要绕过阻止建立连接的防火墙,给你的设备分配一个唯一可见的地址(通常情况下我们的大部分设备没有一个固定的公网地址),如果路由器不允许主机直连,还得通过一台服务器转发数据。ICE 通过同时使用NAT、STUN和TURN协议完成上述工作。
有时,在 WebRTC 会话的整个生命周期内,网络条件会发生变化。其中一个用户可能会从蜂窝网络转移到 WiFi 网络,或者网络可能会变得拥塞。当这种情况发生时,ICE 代理可以选择执行 ICE 重连。在这个过程中网络连接会进行重新协商,与执行初始 ICE 协商的方式完全相同,媒体继续流过原始网络连接直到新的开始运行,然后媒体转移到新的网络连接,旧的关闭。ICE 重连有全 ICE 重连和部分全 ICE 重连两个级别,全 ICE 重连会导致会话中的所有媒体流重新协商。 部分 ICE 重连允许 ICE 重新协商特定媒体流,而不是所有媒体流进行重新协商。
ICE整合了STUN和TURN协议,比如coturn开源项目就集成了STUN和TURN,可以使用它来搭建TURN服务器,通常来说作为TURN服务器的带宽应该比较大,且部署在公网。
五、WebRTC建立通话时序图
六、WebRTCAPI接口、属性、方法和事件(参考相关API)
- Navigator:一个浏览器全局对象,表示用户代理的状态和标识,包含了很多属性和方法,它允许脚本查询它和注册自己进行一些活动。在WebRtc中,最常用的是属性mediaDevices,封装了与媒体设备相关信息。
- MediaDevices:提供访问连接媒体输入的设备,如照相机和麦克风,以及屏幕共享等。它可以使你取得任何硬件资源的媒体数据。
- RTCPeerConnection:代表一个由本地计算机到远端的 WebRTC 连接。该接口提供了创建,保持,监控,关闭连接的方法的实现。
- RTCSessionDescription:描述连接的一端(或潜在连接)及其配置方式。
- RTCIceCandidate:代表可用于建立 RTCPeerConnection 的候选交互式连接建立 (ICE) 配置。
- RTCPeerConnectionIceEvent:代表一个由本地计算机到远端的 WebRTC 连接事件。
- RTCPeerConnectionIceErrorEvent:代表一个由本地计算机到远端的 WebRTC 连接错误时触发的事件。
- RTCCertificate:表示用于进行身份验证的证书。
- RTCRtpSender:提供了控制和获取有关如何对特定 MediaStreamTrack 进行编码并发送到远程对等体的详细信息的能力。
- RTCRtpReceiver:管理 RTCPeerConnection 上 MediaStreamTrack 的数据接收和解码。
- RTCRtpTransceiver:描述了RTCRtpSender和RTCRtpReceiver的永久配对,以及一些共享状态。
- RTCDtlsTransport:提供对有关数据报传输层安全性 (DTLS) 传输的信息的访问,RTCPeerConnection 的 RTP 和 RTCP 数据包通过其 RTCRtpSender 和 RTCRtpReceiver 对象发送和接收。
- RTCIceTransport:提供对有关发送和接收数据的 ICE 传输层的信息的访问。 如果需要访问有关连接的状态信息,这将特别有用。
- RTCTrackEvent:表示跟踪事件,当新的MediaStreamTrack被添加到RTCRtpReceiver(RTCPeerConnection的一部分)时触发。
- RTCSctpTransport:提供描述流控制传输协议 (SCTP) 传输的信息。这提供了有关传输限制的信息,但也提供了一种访问基础数据报传输层安全性 (DTLS) 传输的方法,通过该传输发送和接收 RTCPeerConnection 的所有数据通道的 SCTP 数据包。
- RTCDataChannel:表示可用于任意数据的双向对等传输的网络通道。每个数据通道都与一个RTCPeerConnection相关联。
- RTCDataChannelEvent:表示与特定 RTCDataChannel 相关的事件。
- RTCDTMFSender:提供了一种在 WebRTC RTCPeerConnection 上传输 DTMF 代码的机制。
- RTCDTMFToneChangeEvent:表示发送的事件,以指示 DTMF 音调已开始或完成播放。
- RTCStatsReport:获得统计信息报告。
- RTCErrorEvent:表示发送到WebRTC对象的错误事件。
七、Web网页操作媒体设备案例
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> </head> <title>获取本地设备输入流</title> <body> <div style="margin:50px auto;width:500px"> <div style="margin:50px auto;width:100%"> <button onclick="openCamera()">开启摄像头</button> <button onclick="closeCamera()">关闭摄像头</button> <button onclick="getPhoto()">拍照</button> <video controls autoplay></video> </div> <div style="margin:50px auto;width:100%"> <button onclick="openMicrophone()">开启麦克风</button> <button onclick="closeMicrophone()">关闭麦克风</button> <button onclick="setVoice()">设置音量</button> <audio controls autoplay></audio> </div> </div> </body> </html> <script type="text/javascript"> /* navigator表示用户代理的状态和标识,包含了很多属性和方法,它允许脚本查询它和注册自己进行一些活动。在WebRtc中,最常用的是属性mediaDevices,封装了与媒体设备相关信息。 mediaDevices是一个单例对象,包含getDisplayMedia()、getUserMedia()、enumerateDevices()等方法。通常,您只需直接使用此对象的成员。该对象可提供对相机和麦克风等媒体输入设备的连接访问,也包括屏幕共享。 mediaDevices.getUserMedia方法会提示用户给予使用媒体输入的许可权限,媒体输入会产生一个MediaStream,里面包含了请求的媒体类型的轨道。 mediaDevices.getUserMedia的参数constraints是一个包含了video 和 audio两个成员的MediaStreamConstraints 对象,用于说明请求的媒体类型。必须至少一个类型或者两个同时可以被指定。如果为某种媒体类型设置了 true ,得到的结果的流中就需要有此种类型的轨道,否则报错。 constraints参数的结构如下: { //audio: true, //表示是否获取该轨道 audio: { sampleRate:16000,//指定采样率 sampleSize:16,//每个采样点大小的位数,一般44.1khz和48khz采样位数都是16位,96khz和192khz采样位数都是24位 volume:0.8,//音量,从0(静音)到1(最大)取值 echoCancellation:true,//是否使用回声消除来尝试去除通过麦克风回传到扬声器的音频 autoGainControl:true,//是否要修改麦克风的输入音量 noiseSuppression:true,/是否尝试去除音频信号中的背景噪声 latency:0,//以秒为单位设置声音延迟 channelCount:2,//设置省道,规定单声道的时候为1,立体声的时候为2 }, video: { width: { min: 1024, ideal: 1280, max: 1920 }, //分辨率宽度的一些要求。 height: { min: 776, ideal: 720, max: 1080 }, //分辨率高度的一些要求。 facingMode: { exact: "environment" }, //指定优先摄像头,user表示前摄像头,environment表示后摄像头 frameRate: { ideal: 10, max: 15 }, //设定想要的帧率 deviceId: myPreferredCameraDeviceId, //通过id指定摄像头设备,mediaDevices.enumerateDevices()可以获取有关系统上可用的媒体输入和输出设备的信息数组。 } } MediaStream流可以包含一个视频轨道(来自硬件或者虚拟视频源,比如相机、视频采集设备和屏幕共享服务等等)、一个音频轨道(同样来自硬件或虚拟音频源,比如麦克风、A/D 转换器等等),也可能是其他轨道类型。 MediaStream对象包含了addTrack()、removeTrack()、clone()、getTracks()、getAudioTracks()、getVideoTracks()、getTrackById()等操作轨道(MediaStreamTrack)的方法及一些事件和属性。 MediaStreamTrack对象表示一段媒体源,比如音轨或视频,包含了stop()、clone()、getSettings()、getCapabilities()、applyConstraints()、getConstraints()等方法及一些事件和属性。可以调用stop()停止播放轨道对应的源,源与轨道将脱离关联。 */ var mediaDevices = navigator.mediaDevices; //开启摄像头,绑定视频流和音频流到video标签 function openCamera() { if (mediaDevices != null) { //获取视频和音频 mediaDevices.getUserMedia({ video: true, audio: true }).then(stream => { var video = document.querySelector("video"); video.srcObject = stream; }).catch(err => { alert(err); }); } else { alert("不支持WebRTC"); } } //关闭摄像头 function closeCamera() { var video = document.querySelector("video"); //getTracks()、getAudioTracks()、getVideoTracks()返回的是轨道的集合,如果有多个输入设备,则需要遍历这些轨道进行逐一关闭 video.srcObject.getTracks()[0].stop();//表示关闭所有轨道 video.srcObject.getAudioTracks()[0].stop();//表示关闭音频轨道 video.srcObject.getVideoTracks()[0].stop();//表示关闭视频轨道 } //拍照 function getPhoto() { var video = document.querySelector("video"); let track = video.srcObject.getVideoTracks()[0]; let imageCapture = new ImageCapture(track); //blob就是捕获到的照片,takePhoto()的参数{fillLightMode:auto, imageHeight:480,imageWidth:960,redEyeReduction:true}可以设定捕获的图像的参数 let blob = imageCapture.takePhoto(); } //开启麦克风,绑定音频流到audio标签 function openMicrophone() { if (mediaDevices != null) { //只获取音频 mediaDevices.getUserMedia({ video: false, audio: true }).then(stream => { var audio = document.querySelector("audio"); audio.srcObject = stream; }).catch(err => { alert(err); }); } else { alert("不支持WebRTC"); } } //关闭麦克风 function closeMicrophone() { var audio = document.querySelector("audio"); audio.srcObject.getTracks()[0].stop(); } //设置音量 function setVoice() { var audio = document.querySelector("audio"); //如果绑定了audio、video等标签元素,可以直接改变audio.volume的值来改变音量 audio.volume = 0.5; // 取值在0-1之间 //如果想要直接改变音频流stream的音量,需要借助Web Audio API 相关的接口来处理。 // Web Audio API 提供了一个强大而通用的系统来控制 Web 上的音频,允许开发人员选择音频源、向音频添加效果、创建音频可视化、应用空间效果(例如声像)等等。 } </script>
八、一个简单的WebRTC一对一视频通话实现过程
1、客户端页面index.html代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> </head> <title>WEBRTC客户端</title> <body> <div style="margin:100px auto;width:600px;"> <fieldset> <legend>操作</legend> <input type="text" value="" id="joinUser" style="width:100px" placeholder="用户名" /> <button onclick="joinbtn()" id="joinbtn">加入</button> <button onclick="leavebtn()" id="leavebtn" disabled>离开</button> <input type="text" value="" id="callUser" style="width:100px" placeholder="被邀请用户" /> <button onclick="offerbtn()" id="offerbtn" disabled>邀请</button> </fieldset> <fieldset> <legend>视频通话/文本消息</legend> <video id="localVideo" autoplay muted style="width:568px;height:300px;float: left; background-color:lightslategrey"> </video> <div style="width:563px;height:100px;"> <textarea value="" id="hisMsg" style="width:100%;height:100%"></textarea> </div> <video id="remoteVideo" autoplay style="width:200px;height:100px;background-color: darkgray; position: relative;top:-298px;left: 365px;"> </video> </fieldset> </div> </body> </html> <script type="text/javascript"> const SIGNAL_TYPE = { J0IN: "join", J0INED: "joined", LEAVE: "leave", LEAVEED: "leaved", OFFER: "offer", ANSWER: "answer", CANDIDATE: "candidate" }; var join_user = document.querySelector("#joinUser"); var call_user = document.querySelector("#callUser"); var msg_receiver = document.querySelector("#msgReceiver"); var send_msg = document.querySelector("#sendMsg"); var his_msg = document.querySelector("#hisMsg"); var local_video = document.querySelector("#localVideo"); var remote_video = document.querySelector("#remoteVideo"); var socket = null; var peer = null; var device = navigator.mediaDevices; var vedio_conf = { video: { width: 568, height: 300 }, audio: true }; var signalServerUrl = "ws://localhost:8088"; //var ice_conf = { "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] }; //使用Google公共stun服务器 var ice_conf = { "iceServers": [{ "url": "turn:101.34.79.190:3478", "username": "admin", "credential": "123456" }] }; //用户加入:创建一个socket连接,并绑定消息事件,用于进行信令传输 function joinbtn() { if (socket == null || socket.readyState == socket.CLOSED) { socket = getSocket((e) => { his_msg.value += "Socket已连接...\n"; document.querySelector("#joinbtn").disabled = true; document.querySelector("#leavebtn").disabled = false; document.querySelector("#offerbtn").disabled = false; socket.send(JSON.stringify({ signalType: SIGNAL_TYPE.J0IN, fromUser: join_user.value })); }, (e) => { his_msg.value += "Socket已关闭...\n"; document.querySelector("#joinbtn").disabled = false; document.querySelector("#leavebtn").disabled = true; document.querySelector("#offerbtn").disabled = true; }, (e) => { his_msg.value += "错误:" + e.message + "\n"; }, (e) => { his_msg.value += "收到消息:" + e.data + "\n"; var data = JSON.parse(e.data); switch (data.signalType) { case SIGNAL_TYPE.J0INED: handleJoined(data.fromUser); break; case SIGNAL_TYPE.OFFER: handleOffer(data.fromUser, data.offerSdp); break; case SIGNAL_TYPE.ANSWER: handleAnswer(data.answerSdp); break; case SIGNAL_TYPE.CANDIDATE: addCandidate(data.candidate); break; case SIGNAL_TYPE.LEAVEED: handleLeave(data.fromUser); break; default: his_msg.value += data.message + "\n"; break; } }); } } //用户离开:关闭与信令服务器建立的Socket连接、关闭通话 function leavebtn() { if (socket && socket.readyState == socket.OPEN) { socket.close(); } if (peer != null) { peer.close(); } socket = null; peer = null; local_video.srcObject.getTracks()[0].stop(); local_video.srcObject.getAudioTracks()[0].stop(); local_video.srcObject.getVideoTracks()[0].stop(); remote_video.srcObject.getTracks()[0].stop(); remote_video.srcObject.getAudioTracks()[0].stop(); remote_video.srcObject.getVideoTracks()[0].stop(); } //主动发起邀请 function offerbtn() { peer = new RTCPeerConnection(ice_conf); peer.onicecandidate = handleCandidate;//绑定candidate事件,当ICE服务器回传candidate候选者时触发 peer.ontrack = handleRemoteTrack;//获取对方媒体流的句柄,待对方添加媒体轨道后执行,即可通过此事件绑定远程媒体到HTML标签以显示 device.getUserMedia(vedio_conf).then(stream => { local_video.srcObject = stream; //将一个新的媒体音轨添加到一组音轨中,这些音轨将被传输给另一个对等点。 stream.getTracks().forEach(track => peer.addTrack(track, stream)); //创建一个offer,将产生一个offerSdp peer.createOffer().then(offerSdp => { //将createOffer生成offerSdp信息设置到peer中,执行该方法时,会向ICE服务器发送sdp信息,ICE服务器收到answerSDP和offerSDP信息后整合能双方能够通信的链路形成candidate候选者返回给请求方peer peer.setLocalDescription(offerSdp).then(() => { socket.send(JSON.stringify({ signalType: SIGNAL_TYPE.OFFER, fromUser: joinUser.value, toUser: call_user.value, offerSdp: JSON.stringify(peer.localDescription) })); }) }) }) } //处理被邀请 function handleOffer(user, offerSdp) { peer = new RTCPeerConnection(ice_conf); peer.ontrack = handleRemoteTrack;//获取对方媒体流的句柄,待对方添加媒体轨道后执行,即可通过此事件绑定远程媒体到HTML标签以显示 //将远程发来的offerSdp设置到peer中 peer.setRemoteDescription(JSON.parse(offerSdp)).then(() => { device.getUserMedia(vedio_conf).then(stream => { local_video.srcObject = stream; //将一个新的媒体音轨添加到一组音轨中,这些音轨将被传输给另一个对等点。 stream.getTracks().forEach(track => peer.addTrack(track, stream)); //创建一个Answer,将产生一个answerSdp peer.createAnswer().then(answerSdp => { //将createAnswer生成的answerSdp设置到peer中,执行该方法时,会向ICE服务器发送sdp信息,ICE服务器收到answerSDP和offerSDP信息后整合能双方能够通信的链路形成candidate候选者返回给请求方peer peer.setLocalDescription(answerSdp).then(() => { socket.send(JSON.stringify({ signalType: SIGNAL_TYPE.ANSWER, fromUser: joinUser.value, toUser: user, answerSdp: JSON.stringify(peer.localDescription) })); }) }) }) }) }; //处理对方应答:收到对方应答时将answerSdp添加到peer中 function handleAnswer(answerSdp) { peer.setRemoteDescription(JSON.parse(answerSdp)); }; //当ICE服务器回传candidate候选者时处理函数:将candidate转发到信令服务器,由信令服务器转发给被邀请者 function handleCandidate(e) { if (e.candidate) { socket.send(JSON.stringify({ signalType: SIGNAL_TYPE.CANDIDATE, fromUser: joinUser.value, toUser: call_user.value, candidate: JSON.stringify(e.candidate) })); } }; //触发peer.Ontrack事件时:绑定远程流到Video媒体标签 function handleRemoteTrack(e) { remote_video.srcObject = e.streams[0]; }; //收到信令服务器发来candidate时:将其添加到ICE候选 function addCandidate(candidate) { let obj = JSON.parse(candidate); peer.addIceCandidate(new RTCIceCandidate(obj)).then().catch(err => { console.warn("添加ICE错误:" + err) }); }; //有用户离开处理函数 function handleLeave(user) { his_msg.value += user + "离开了...\n"; }; //有用户加入处理函数 function handleJoined(user) { his_msg.value += user + "加入了...\n"; }; //创建socket,socket.readyState的状态有socket.OPEN(已连接)、socket.CLOSED(已关闭)、socket.CLOSING(正在关闭)、socket.CONNECTING(正在连接)。 function getSocket(onopen, onclose, onerror, onmessage) { let socket = new WebSocket(signalServerUrl); //当WebSocket创建成功时,触发onopen事件 socket.onopen = onopen; //当客户端收到服务端发送的关闭连接请求时,触发onclose事件 socket.onclose = onclose; //当客户端收到服务端发来的消息时,触发onmessage事件,参数e.data包含server传递过来的数据 socket.onmessage = onmessage; //如果出现连接、处理、接收、发送数据失败的时候触发onerror事件 socket.onerror = onerror; return socket; } </script>
2、信令服务器main.js代码:
var express = require("express"); var app = express(); var ws = require("nodejs-websocket"); var onlineUsers = new Map();//存储每一个在线用户的Socket const SIGNAL_TYPE = { J0IN: "join", J0INED: "joined", LEAVE: "leave", LEAVEED: "leaved", OFFER: "offer", ANSWER: "answer", CANDIDATE: "candidate", }; app.use(express.static("./public")); //指定要部署到服务器的文件或者文件夹 //创建http服务器,并监听端口,用于访问web页面 app.listen(8080, () => { console.info("http服务器启动成功..."); }); //监听socket端口,作为信令服务器通信使用。每次有一个新的连接进来都会为该连接创建一个表示该连接的conn对象。 ws.createServer(function (conn) { //当收到客户端发来的消息时触发 conn.on("text", function (str) { console.info("服务端收到消息:" + str); let data; try { data = JSON.parse(str); } catch (e) { console.info("数据格式错误:仅接收json格式字符串!"); data = {}; } switch (data.signalType) { case SIGNAL_TYPE.J0IN: handleJoin(conn, data); break; case SIGNAL_TYPE.LEAVE: handleLeave(conn, data); break; case SIGNAL_TYPE.OFFER: handleOffer(conn, data); break; case SIGNAL_TYPE.ANSWER: handleAnswer(conn, data); break; case SIGNAL_TYPE.CANDIDATE: handleCandidate(conn, data); break; default: conn.sendText( JSON.stringify({ signalType: "ERROR", message: "未知的命令类型:" + data.signalType, }) ); } }); //当关闭连接时触发 conn.on("close", function (e) { if (onlineUsers.has(conn.signName)) { onlineUsers.delete(conn.signName); broadCast( JSON.stringify({ signalType: SIGNAL_TYPE.LEAVEED, fromUser: conn.signName, message: conn.signName + "Leaved...", }) ); } console.info("close:" + e); }); //发生错误时触发 conn.on("error", function (err) { if (onlineUsers.has(conn.signName)) { onlineUsers.delete(conn.signName); } console.info("error:" + err); }); }).listen(8088, "0.0.0.0", function () { console.info("Socket端口8088监听成功..."); }); //Join处理函数 function handleJoin(conn, data) { conn.signName = data.fromUser; onlineUsers.set(data.fromUser, conn); broadCast( JSON.stringify({ signalType: SIGNAL_TYPE.J0INED, fromUser: data.fromUser, message: data.fromUser + "Join...", }) ); } //Leave处理函数 function handleLeave(conn, data) { if (onlineUsers.has(data.name)) { onlineUsers.get(data.name).close(); onlineUsers.delete(data.name); } broadCast( JSON.stringify({ signalType: SIGNAL_TYPE.LEAVEED, fromUser: data.fromUser, message: data.fromUser + "Leave...", }) ); } //Offer处理函数:信令服务器将请求端发送过来的offerSDP信息转发给被请求端 function handleOffer(conn, data) { if (onlineUsers.has(data.toUser)) { onlineUsers.get(data.toUser).sendText( JSON.stringify({ signalType: SIGNAL_TYPE.OFFER, fromUser: data.fromUser, offerSdp: data.offerSdp, message: data.fromUser + "Offer...", }) ); } else { conn.sendText( JSON.stringify({ signalType: "ERROR", message: data.toUser + "不在线...", }) ); } } //Answer处理函数:信令服务器将被请求端发送过来的answerSDP信息转发给请求端 function handleAnswer(conn, data) { if (onlineUsers.has(data.toUser)) { onlineUsers.get(data.toUser).sendText( JSON.stringify({ signalType: SIGNAL_TYPE.ANSWER, fromUser: data.fromUser, answerSdp: data.answerSdp, message: data.fromUser + "Answer...", }) ); } else { conn.sendText( JSON.stringify({ signalType: "ERROR", message: data.toUser + "不在线...", }) ); } } //Candidate处理函数 function handleCandidate(conn, data) { if (onlineUsers.has(data.toUser)) { onlineUsers.get(data.toUser).sendText( JSON.stringify({ signalType: SIGNAL_TYPE.CANDIDATE, fromUser: data.fromUser, candidate: data.candidate, message: data.fromUser + "Candidate...", }) ); } else { conn.sendText( JSON.stringify({ signalType: "ERROR", message: data.toUser + "不在线...", }) ); } } //广播,向所有在线的socket连接发送消息 function broadCast(str) { onlineUsers.forEach((conn, name) => { conn.sendText(str); }); }
3、搭建TURN服务器:使用docker方式简要安装coturn作为ice服务器。
docker run -d --name coturnserver --network=host -v /docker_filePath/coturn/turnserver.conf:/etc/coturn/turnserver.conf coturn/coturn
#===========这是turnserver.conf的基本配置===================
#与ifconfig 查到的网卡名称一致
relay-device=eth0
#内网IP
listening-ip=172.17.0.5
#内网IP
relay-ip=172.17.0.5
#公网IP
external-ip=xx.34.79.190
relay-threads=50
min-port=49152
max-port=65535
#用户名密码,创建IceServer时用
user=admin:123456
#一般与turnadmin创建用户时指定的realm一致
realm=xxx.com
#端口号
listening-port=3478
cli-password=123456
#证书
cert=/etc/turn_server_cert.pem
pkey=/etc/turn_server_key.pem
4、运行:项目结构如下,使用vscode打开项目,执行命令npm -i 安装模块完成后,执行命令node main.js 启动服务器,访问http://localhost:8080/即可显示和测试功能。(需要注意的是不能在同一台机器上测试,会导致媒体设备被占用而无法开启另一端媒体。)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)