11┃音视频直播系统之 WebRTC 进行文本聊天并实时传输文件

一、RTCDataChannel

  • WebRTC 不但可以让你进行音视频通话,而且还可以用它传输普通的二进制数据,比如说可以利用它实现文本聊天、文件的传输等

  • WebRTC 的数据通道(RTCDataChannel)是专门用来传输除了音视频数据之外的任何数据,模仿了 WebSocket 的实现

  • RTCDataChannel 支持的数据类型也非常多,包括:字符串BlobArrayBuffer 以及 ArrayBufferView

  • WebRTC RTCDataChannel 使用的传输协议为 SCTP,即 Stream Control Transport Protocol

  • RTCDataChannel 既可以在可靠的、有序的模式下工作,也可在不可靠的、无序的模式下工作

  • 可靠有序模式(TCP 模式):在这种模式下,消息可以有序到达,但同时也带来了额外的开销,所以在这种模式下消息传输会比较慢

  • 不可靠无序模式(UDP 模式):在此种模式下,不保证消息可达,也不保证消息有序,但在这种模式下没有什么额外开销,所以它非常快

  • 部分可靠模式(SCTP 模式):在这种模式下,消息的可达性和有序性可以根据业务需求进行配置

  • RTCDataChannel 对象是由 RTCPeerConnection 对象创建,其中包含两个参数:

  • 第一个参数:是一个标签(字符串),相当于给 RTCDataChannel 起了一个名字

  • 第二个参数:是 options,包含很多配置,其中就可以设置上面说的模式,重试次数等

// 创建 RTCPeerConnection 对象 var pc = new RTCPeerConnection();  // 创建 RTCDataChannel 对象 var dc = pc.createDataChannel("dc", {     ordered: true // 保证到达顺序 });  // options参数详解, 前三项是经常使用的: // ordered:消息的传递是否有序 // maxPacketLifeTime:重传消息失败的最长时间 // maxRetransmits:重传消息失败的最大次数 // protocol:用户自定义的子协议, 默认为空 // negotiated:如果为 true,则会删除另一方数据通道的自动设置 // id:当 negotiated 为 true 时,允许你提供自己的 ID 与 channel 进行绑定  // dc的事件处理与 WebSocket 的事件处理非常相似 dc.onerror = (error) => {     // 出错的处理 }; dc.onopen = () => {     // 打开的处理 }; dc.onclose = () => {     // 关闭的处理 }; dc.onmessage = (event) => {     // 收到消息的处理     var msg = event.data; };

 

二、文本聊天

  • 点击 Start 按钮时,会调用 start方法获取视频流然后 调用 conn 方法

  • 然后调用 io.connect() 连接信令服务器,然后再根据信令服务器下发的消息做不同的处理

  • 数据的发送非常简单,当用户点击 Send 按钮后,文本数据就会通过 RTCDataChannel 传输到远端

  • 对于接收数据,则是通过 RTCDataChannel onmessage 事件实现的

  • RTCDataChannel 对象的创建要在媒体协商(offer/answer) 之前创建,否则 WebRTC 就会一直处于 connecting 状态,从而导致数据无法进行传输

  • RTCDataChannel 对象是可以双向传输数据的,所以接收与发送使用一个RTCDataChannel 对象即可,而不需要为发送和接收单独创建 RTCDataChannel 对象

<!DOCTYPE html> <html lang="en">  <head>     <meta charset="UTF-8">     <meta http-equiv="X-UA-Compatible" content="IE=edge">     <meta name="viewport" content="width=device-width, initial-scale=1.0">     <title>Document</title>     <style>         .preview {             display: flex;         }          .remote {             margin-left: 20px;         }          .text_chat {             display: flex;         }          .text_chat textarea {             width: 350px;             height: 350px;         }          .send {             margin-top: 20px;         }     </style> </head>  <body>     <div>         <div>             <button onclick="start()">连接信令服务器</button>             <button onclick="leave()" disabled>断开连接</button>         </div>          <div class="preview">             <div>                 <h2>本地:</h2>                 <video id="localvideo" autoplay playsinline></video>             </div>             <div class="remote">                 <h2>远端:</h2>                 <video id="remotevideo" autoplay playsinline></video>             </div>         </div>         <!--文本聊天-->         <h2>聊天:</h2>         <div class="text_chat">             <div>                 <textarea id="chat" disabled></textarea>             </div>             <div class="remote">                 <textarea id="sendtext" disabled></textarea>             </div>         </div>         <div class="send">             <button onclick="send()" disabled>发送</button>         </div>     </div>     <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>     <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script> </body> <script>     'use strict'      var localVideo = document.querySelector('video#localvideo');     var remoteVideo = document.querySelector('video#remotevideo');      // 文本聊天     var chat = document.querySelector('textarea#chat');     var send_txt = document.querySelector('textarea#sendtext');      var localStream = null;      var roomid = '44444';     var socket = null;      var state = 'init';      var pc = null;     var dc = null;      function sendMessage(roomid, data) {         socket.emit('message', roomid, data);     }      function getAnswer(desc) {         pc.setLocalDescription(desc);         // 发送信息         socket.emit('message', roomid, desc);     }      function handleAnswerError(err) {         console.error('Failed to get Answer!', err);     }      //接收远端流通道     function call() {         if (state === 'joined_conn') {             if (pc) {                 var options = {                     offerToReceiveAudio: 1,                     offerToReceiveVideo: 1                 }                 pc.createOffer(options)                     .then(function (desc) {                         pc.setLocalDescription(desc);                         socket.emit('message', roomid, desc);                     })                     .catch(function (err) {                         console.error('Failed to get Offer!', err);                     });             }         }     }      //文本对方传过来的数据     function reveivemsg(e) {         var msg = e.data;         console.log('recreived msg is :' + e.data);         if (msg) {             chat.value += '->' + msg + 'rn';         } else {             console.error('recreived msg is null');         }     }      function dataChannelStateChange() {         var readyState = dc.readyState;         if (readyState === 'open') {             send_txt.disabled = false;             btnSend.disabled = false;         } else {             send_txt.disabled = true;             btnSend.disabled = true;         }     }      function dataChannelError(error) {         console.log("Data Channel Error:", error);     }      function conn() {         //1 触发socke连接         socket = io.connect();          //2 加入房间后的回调         socket.on('joined', (roomid, id) => {              state = 'joined';              createPeerConnection();              btnConn.disabled = true;             btnLeave.disabled = false;              console.log("reveive joined message:state=", state);         });         socket.on('otherjoin', (roomid, id) => {              if (state === 'joined_unbind') {                 createPeerConnection();             }              var dataChannelOptions = {                 ordered: true, //保证到达顺序             };             //文本聊天             dc = pc.createDataChannel('dataChannel', dataChannelOptions);             dc.onmessage = reveivemsg;             dc.onopen = dataChannelStateChange;             dc.onclose = dataChannelStateChange;             dc.onerror = dataChannelError;               state = 'joined_conn';              //媒体协商             call();             console.log("reveive otherjoin message:state=", state);         });         socket.on('full', (roomid, id) => {             console.log('receive full message ', roomid, id);              closePeerConnection();             closeLocalMedia();              state = 'leaved';              btnConn.disabled = false;             btnLeave.disabled = true;             console.log("reveive full message:state=", state);             alert("the room is full!");         });          socket.on('leaved', (roomid, id) => {              state = 'leaved';             socket.disconnect();             btnConn.disabled = false;             btnLeave.disabled = true;             console.log("reveive leaved message:state=", state);         });          socket.on('bye', (roomid, id) => {              state = 'joined_unbind';             closePeerConnection();             console.log("reveive bye message:state=", state);         });         socket.on('disconnect', (socket) => {             console.log('receive disconnect message!', roomid);             if (!(state === 'leaved')) {                 closePeerConnection();                 closeLocalMedia();             }             state = 'leaved';          });         socket.on('message', (roomid, id, data) => {             console.log(" message=====>", data);             //媒体协商             if (data) {                 if (data.type === 'offer') {                     pc.setRemoteDescription(new RTCSessionDescription(data));                     pc.createAnswer()                         .then(getAnswer)                         .catch(handleAnswerError);                 } else if (data.type === 'answer') {                     console.log("reveive client message=====>", data);                     pc.setRemoteDescription(new RTCSessionDescription(data));                 } else if (data.type === 'candidate') {                     var candidate = new RTCIceCandidate({                         sdpMLineIndex: data.label,                         candidate: data.candidate                     });                     pc.addIceCandidate(candidate);                  } else {                     console.error('the message is invalid!', data)                 }             }              console.log("reveive client message", roomid, id, data);         });          socket.emit('join', roomid);         return;     }      function start() {         if (!navigator.mediaDevices ||             !navigator.mediaDevices.getUserMedia) {             console.log("getUserMedia is not supported!")             return;         }          navigator.mediaDevices.getUserMedia({             video: true,             audio: false         })             .then(function (stream) {                 localStream = stream;                 localVideo.srcObject = localStream;                 conn();             })             .catch(function (err) {                 console.error("getUserMedia  error:", err);             })     }      function leave() {         if (socket) {             socket.emit('leave', roomid);         }          //释放资源         closePeerConnection();         closeLocalMedia();          btnConn.disabled = false;         btnLeave.disabled = true;     }      //关闭流通道     function closeLocalMedia() {         if (localStream && localStream.getTracks()) {             localStream.getTracks().forEach((track) => {                 track.stop();             });         }         localStream = null;     }      //关闭本地媒体流链接     function closePeerConnection() {         console.log('close RTCPeerConnection!');         if (pc) {             pc.close();             pc = null;         }     }      //创建本地流媒体链接     function createPeerConnection() {         console.log('create RTCPeerConnection!');         if (!pc) {             pc = new RTCPeerConnection({                 'iceServers': [{                     'urls': 'turn:127.0.0.1:8000',                     'credential': '123456',                     'username': 'autofelix'                 }]             });              pc.onicecandidate = (e) => {                 if (e.candidate) {                     sendMessage(roomid, {                         type: 'candidate',                         label: e.candidate.sdpMLineIndex,                         id: e.candidate.sdpMid,                         candidate: e.candidate.candidate                     });                 }             }              //文本聊天             pc.ondatachannel = e => {                 dc = e.channel;                 dc.onmessage = reveivemsg;                 dc.onopen = dataChannelStateChange;                 dc.onclose = dataChannelStateChange;                 dc.onerror = dataChannelError;             }              pc.ontrack = (e) => {                 remoteVideo.srcObject = e.streams[0];             }         }          if (pc === null || pc === undefined) {             console.error('pc is null or undefined!');             return;         }          if (localStream === null || localStream === undefined) {             console.error('localStream is null or undefined!');             return;         }          if (localStream) {             localStream.getTracks().forEach((track) => {                 pc.addTrack(track, localStream);             })         }     }      //发送文本     function send() {         var data = send_txt.value;         if (data) {             dc.send(data);         }         send_txt.value = "";         chat.value += '<-' + data + 'rn';     } </script>  </html>

 

三、文件传输

  • 实时文件的传输与实时文本消息传输的基本原理是一样的,都是使用 RTCDataChannel 对象进行传输

  • 它们的区别一方面是传输数据的类型不一样,另一方面是数据的大小不一样

  • 在传输文件的时候,必须要保证文件传输的有序性和完整性,所以需要设置 ordered 和 maxRetransmits 选项

  • 发送数据如下:

// 创建 RTCDataChannel 对象的选项 var options = {     ordered: true,     maxRetransmits: 30 // 最多尝试重传 30 次 };  // 创建 RTCPeerConnection 对象 var pc = new RTCPeerConnection();  // 方法一:通过通道发送 sendChannel = pc.createDataChannel(name, options); sendChannel.addEventListener('open', onSendChannelStateChange); //打开之后才可以传输数据  sendChannel.addEventListener('close', onSendChannelStateChange); sendChannel.send(JSON.stringify({     // 将文件信息以 JSON 格式发磅     type: 'fileinfo',     name: file.name,     size: file.size,     filetype: file.type,     lastmodify: file.lastModified }));  // 方法二:通过arraybuffer发送 var offset = 0; // 偏移量 var chunkSize = 16384; // 每次传输的块大小 var file = fileInput.files[0]; // 要传输的文件,它是通过 HTML 中的 file 获取的  // 创建 fileReader 来读取文件 fileReader = new FileReader();  // 当数据被加载时触发该事件 fileReader.onload = e => {     // 发送数据     dc.send(e.target.result);     offset += e.target.result.byteLength; // 更改已读数据的偏移量      if (offset < file.size) { // 如果文件没有被读完         readSlice(offset); // 读取数据     } }  var readSlice = o => {     const slice = file.slice(offset, o + chunkSize); // 计算数据位置     fileReader.readAsArrayBuffer(slice); // 读取 16K 数据 }; readSlice(0); // 开始读取数据
  • 接收数据如下:

  • 当有数据到达时就会触发该事件就会触发 onmessage 事件

  • 只需要简单地将收到的这块数据 push 到 receiveBuffer 数组中即可

var receiveBuffer = []; // 存放数据的数组 var receiveSize = 0; // 数据大小  onmessage = (event) => {     // 每次事件被触发时,说明有数据来了,将收到的数据放到数组中     receiveBuffer.push(event.data);     // 更新已经收到的数据的长度     receivedSize += event.data.byteLength;     // 如果接收到的字节数与文件大小相同,则创建文件     if (receivedSize === fileSize) { //fileSize 是通过信令传过来的         // 创建文件         var received = new Blob(receiveBuffer, { type: 'application/octet-stream' });         // 将 buffer 和 size 清空,为下一次传文件做准备         receiveBuffer = [];         receiveSize = 0;         // 生成下载地址         downloadAnchor.href = URL.createObjectURL(received);         downloadAnchor.download = fileName;         downloadAnchor.textContent = `Click to download '${fileName}' (${fileSize} bytes)`;         downloadAnchor.style.display = 'block';     } }

 

发表评论

相关文章