一、RTCDataChannel
-
WebRTC
不但可以让你进行音视频通话,而且还可以用它传输普通的二进制数据,比如说可以利用它实现文本聊天、文件的传输等 -
WebRTC
的数据通道(RTCDataChannel)
是专门用来传输除了音视频数据之外的任何数据,模仿了WebSocket
的实现 -
RTCDataChannel
支持的数据类型也非常多,包括:字符串
、Blob
、ArrayBuffer
以及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'; } }