一、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 + '\r\n';
} 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 + '\r\n';
}
</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';
}
}