11┃音視訊直播系統之 WebRTC 進行文字聊天並實時傳輸檔案

autofelix發表於2022-05-20

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

 

相關文章