8┃音影片直播系統之 WebRTC 信令系統實現以及通訊核心並實現影片通話

一、信令系統

  • 信令系統主要用來進行信令的交換

  • 在通訊雙方彼此連接、傳輸媒體數據之前,它們要通過信令伺服器交換一些資訊,如規範協商

  • 若 A 與 B 要進行音影片通訊,那麼 A 要知道 B 已經上線了,同樣,B 也要知道 A 在等著與它通訊呢

  • 只有雙方都知道彼此存在,才能由一方向另一方發起音影片通訊請求,並最終實現音影片通話

  • 客戶端程式碼如下:

  • 第一步:首先彈出一個輸入框,要求用戶寫入要加入的房間

  • 第二步:通過 io.connect() 建立與服務端的連接

  • 第三步:再根據 socket 返回的消息做不同的處理

<!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>信令系統</title>
</head>

<body>

</body>
<script src="/socket.io/socket.io.js"></script>
<script>
    var isInitiator;

    // 彈出一個輸入窗口
    room = prompt('Enter room name:');

    // 與服務端建立 socket 連接
    const socket = io.connect();

    // 如果房間不空,則發送 "create or join" 消息
    if (room !== '') {
        console.log('Joining room ' + room);
        socket.emit('create or join', room);
    }

    // 如果從服務端收到 "full" 消息
    socket.on('full', (room) => {
        console.log('Room ' + room + ' is full');
    });

    // 如果從服務端收到 "empty" 消息
    socket.on('empty', (room) => {
        isInitiator = true;
        console.log('Room ' + room + ' is empty');
    });

    // 如果從服務端收到 「join" 消息
    socket.on('join', (room) => {
        console.log('Making request to join room ' + room);
        console.log('You are the initiator!');
    });

    // 如果從服務端收到 「log" 消息
    socket.on('log', (array) => {
        console.log.apply(console, array);
    });
</script>

</html>
  • 服務端程式碼如下:

  • 需要通過 npm install socket.io 安裝socket模組

  • 需要通過 npm install node-static 安裝socket模組,使伺服器具有發布靜態文件的功能

  • 服務端偵聽 2022 這個埠,對不同的消息做相應的處理

const static = require('node-static');
const http = require('http');
const file = new (static.Server)();

const app = http.createServer(function (req, res) {
    file.serve(req, res);
}).listen(2022);

// 偵聽 2022
const io = require('socket.io').listen(app);

io.sockets.on('connection', (socket) => {
    // convenience function to log server messages to the client
    function log() {
        const array = ['>>> Message from server: '];
        for (var i = 0; i < arguments.length; i++) {
            array.push(arguments[i]);
        }
        socket.emit('log', array);
    }

    socket.on('message', (message) => {
        // 收到 message 時,進行廣播
        log('Got message:', message);
        // for a real app, would be room only (not broadcast)
        socket.broadcast.emit('message', message); // 在真實的應用中,應該只在房間內廣播
    });

    socket.on('create or join', (room) => {
        // 收到 「create or join」 消息
        var clientsInRoom = io.sockets.adapter.rooms[room];
        var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;

        log('Room ' + room + ' has ' + numClients + ' client(s)');
        log('Request to create or join room ' + room);

        if (numClients === 0) {
            // 如果房間里沒人
            socket.join(room);
            // 發送 "created" 消息
            socket.emit('created', room);
        } else if (numClients === 1) {
            // 如果房間里有一個人
            io.sockets.in(room).emit('join', room);
            socket.join(room);
            // 發送 「joined」消息
            socket.emit('joined', room);
        } else {
            // max two clients
            // 發送 "full" 消息
            socket.emit('full', room);
        }

        socket.emit('emit(): client ' + socket.id + ' joined room ' + room);
        socket.broadcast.emit('broadcast(): client ' + socket.id + ' joined room ' + room);
    });
});

 

二、RTCPeerConnection

  • RTCPeerConnection 類是在瀏覽器下使用 WebRTC 實現 1 對 1 實時互動音影片系統最核心的類

  • 它是WebRTC傳輸音影片和交換數據的API

  • RTCPeerConnection 就與普通的 socket 一樣,在通話的每一端都至少有一個RTCPeerConnection 對象。在 WebRTC 中它負責與各端建立連接,接收、發送音影片數據,並保障音影片的服務品質

 

三、實現影片通話

  • 為連接的每個端創建一個 RTCPeerConnection 對象,並且給 RTCPeerConnection 對象添加一個本地流,該流是從 getUserMedia() 獲取的

  • 獲取本地媒體描述資訊,即 SDP 資訊,並與對端進行交換

  • 獲得網路資訊,即 Candidate(IP 地址和埠),並與遠端進行交換

<!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>實現影片通話</title>
</head>

<body>
    <video id="localVideo" playsinline autoplay muted></video>
    <video id="remoteVideo" playsinline autoplay></video>

    <div class="box">
        <button onclick="start()">Start</button>
        <button onclick="call()">Call</button>
        <button onclick="hangup()">Hang Up</button>
    </div>
</body>
<script>
    // 獲取元素
    var localVideo = document.getElementById('localVideo');
    var remoteVideo = document.getElementById('remoteVideo');

    // 定義全局變數
    var localStream;
    var pc1;
    var pc2;

    function start() {
        console.log('Requesting local stream');

        // 開始採集音影片
        navigator.mediaDevices.getUserMedia({ audio: true, video: true })
            .then(function (stream) {
                // 這個全局localStream是為了後面我們去添加流用的
                localStream = stream;

                // 兼容性監測
                if (window.URL) {
                    // 掛在數據在本地播放
                    localVideo.src = window.URL.createObjectURL(stream)
                } else {
                    localVideo.srcObject = stream
                }
            })
            .catch(function (e) {
                // 如果獲取影片失敗,在這裡進行錯誤處理 
                console.dir(e);
                alert(`getUserMedia() error: ${e.message}`);
            });
    }

    function call() {
        // 創建offerOption, 指定創建本地的媒體的時候,都包括哪些資訊
        // 可以有影片流和音頻流,因為我們這裡沒有採集音頻所以offerToReceiveAudio是0
        var offerOptions = {
            offerToReceiveAudio: 0,
            offerToReceiveVideo: 1
        }

        // 這裡的 RTCPeerConnection 可以有可選參數, 進行一些網路傳輸的配置
        // 由於是我們在本機內進行傳輸,所以在這裡我們就不需要設置參數, 所以它這裡就會使用本機host類型的candidate
        pc1 = new RTCPeerConnection();
        pc1.onicecandidate = (e) => {
            console.log('pc1 ICE candidate:', e.candidate);

            // 我們A調用者收到candidate之後,它會將這個candidate發送給這個信令伺服器
            // 那麼信令伺服器會中轉到這個B端,那麼這個B端會調用這個AddIceCandidate這個方法,將它存到對端的candidate List里去
            // 所以整個過程就是A拿到它所有的可行的通路然後都交給B,B形成一個列表
            // 那麼B所以可行的通路又交給A,A拿到它的可行列表,然後雙方進行這個連通性檢測
            // 那麼如果通過之後那就可以傳數據了,就是這樣一個過程
            // 所以我們收到這個candidate之後就要交給對方去處理,所以pc1要調用pc2的這個
            // 因為是本機這裡就沒有信令了,假設信令被傳回來了,這時候就給了pc2
            // pc2收到這個candidate之後就調用addIceCandidate方法,傳入的參數就是e.candidate
            pc2.addIceCandidate(e.candidate)
                .catch(function (e) {
                    console.log("Failed to call getUserMedia", e);
                });
        }

        pc1.iceconnectionstatechange = (e) => {
            console.log(`pc1 ICE state: ${pc.iceConnectionState}`);
            console.log('ICE state change event: ', e);
        }

        // 創建一個pc2這樣我們就創建了兩個連接
        pc2 = new RTCPeerConnection();

        // 對於pc2也是同樣道理,那它就交給p1
        pc2.onicecandidate = (e) => {
            console.log('pc2 ICE candidate:', e.candidate);

            // 所以它就調用pc1.addIceCandidate
            pc1.addIceCandidate(e.candidate)
                .catch(function (e) {
                    console.log("Failed to call getUserMedia", e);
                });
        }

        pc2.iceconnectionstatechange = (e) => {
            console.log(`pc2 ICE state: ${pc.iceConnectionState}`);
            console.log('ICE state change event: ', e);
        }

        // pc2是被調用方,被調用方是接收數據的,所以對於pc2它還有個ontrack事件
        // 當雙方通訊連接之後,當有流從對端過來的時候,會觸發這個onTrack事件
        pc2.ontrack = gotRemoteStream;

        // 將本地採集的數據添加到第一添加到第一個pc1 = new RTCPeerConnection()中去
        // 這樣在創建媒體協商的時候才知道我們有哪些媒體數據,這個順序不能亂,必須要先添加媒體數據再做後面的邏輯
        // 另外不能先做媒體協商然後在添加數據,因為你先做媒體協商的時候它知道你這裡沒有數據那麼在媒體協商的時候它就沒有媒體流
        // 就是說在協商的時候它知道你是沒有的,那麼它在底層就不設置這些接收資訊發收器,那麼這個時候即使你後面設置了媒體流傳給這個PeerConnection,它也不會進行傳輸的,所以我們要先添加流
        // 添加流也比較簡單,通過localStream調用getTracks就能調用到所有的軌道(音頻軌/影片軌)
        // 那對於每個軌道我們添加進去就完了,也就是forEach遍歷進去,每次循環都能拿到一個track
        // 當我們拿到這個track之後直接調用pc1.addTrack添加就好了,第一個參數就是track,第二個參數就是這個track所在的流localStream
        // 這樣就將本地所採集的音影片流添加到了pc1 這個PeerConnection
        localStream.getTracks().forEach((track) => {
            pc1.addTrack(track, localStream);
        });

        // 那麼這個時候我們就可以去創建這個pc1去媒體協商了
        // 媒體協商第一步就是創建createOffer
        pc1.createOffer(offerOptions)
            .then(function (desc) {
                // 當我們拿到這個描述資訊之後呢,還是回到我們當時協商的邏輯
                // 對於A來說它首先創建Offer,創建Offer之後它會調用setLocalDescription
                // 將它設置到這個PeerConnection當中去,那麼這個時候它會觸發底層的ICE的收集candidate的這個動作
                // 所以這裡要調用pc1.setLocalDescription這個時候處理完了它就會收集candidate
                // 這個處理完了之後按照正常邏輯它應該send desc to signal到信令伺服器
                pc1.setLocalDescription(desc);

                // 到了信令伺服器之後,信令伺服器會發給第二個人
                // 所以第二個人就會receive
                // 所以第二個人收到desc之後呢首先pc2要調用setRemoteDescription,這時候將desc設置成它的遠端
                pc2.setRemoteDescription(desc);

                // 設成遠端之後, pc2就要調用createAnswer
                pc2.createAnswer().then(function (desc) {
                    // 當遠端它得到這個Answer之後,它也要設置它的setLocalDescription
                    // 當它調用了setLocalDescription之後它也開始收集candidate了
                    pc2.setLocalDescription(desc);

                    // 完了之後它去進行send desc to signal與pc1進行交換,pc1會接收recieve desc from signal
                    // 那麼收到之後他就會設置這個pc1的setRemoteDescription
                    // 那麼經過這樣一個步驟整個協商就完成了
                    // 當所有協商完成之後,這些底層對candidate就收集完成了
                    // 收集完了進行交換形成對方的列表然後進行連接檢測
                    // 連接檢測完了之後就開始真正的數據發送過來了
                    pc1.setRemoteDescription(desc);
                })
                    .catch(function (e) {
                        console.log("Failed to call getUserMedia", e);
                    });
            })
            .catch(function (e) {
                console.log("Failed to call getUserMedia", e);
            });

    }

    // 當發送ontrack的時候也就是數據通過的時候, 將遠端的音影片流傳給了remoteVideo
    function gotRemoteStream(e) {
        if (remoteVideo.srcObject !== e.streams[0]) {
            remoteVideo.srcObject = e.streams[0];
        }
    }

    // 掛斷,將pc1和pc2分別關閉
    function hangup() {
        console.log('Ending call');
        pc1.close();
        pc2.close();
        pc1 = null;
        pc2 = null;
    }
</script>

</html>

 

四、影片通話流程詳解

  • 影片通話本是不同的端與端連接,上面的程式碼在同一個瀏覽器中模擬多端連接的情況,可以通過開兩個標籤頁,來模擬pc1端和pc2端

  • 所以大家會看到兩個影片是一摸一樣的,但是它的整個底層都是從本機自己IO的那個邏輯網卡轉過來的

  • 當調用 call 的時候就會調用雙方的 RTCPeerConnection

  • 當這個兩個 PeerConnection 創建完成之後,它們會作協商處理

  • 協商處理完成之後進行 Candidate 採集,也就是說有效地址的採集

  • 採集完了之後進行交換,然後形成這個Candidate pair再進行排序

  • 然後再進行連接性檢測,最終找到最有效的那個鏈路

  • 之後就將 localVideo 展示的這個數據通過 PeerConnection 傳送到另一端

  • 另一端收集到數據之後會觸發 onAddStream 或者 onTrack 就是說明我收到數據了,那當收到這個事件之後

  • 我們再將它設置到這個 remoteVideo 裡面去

  • 這樣遠端的這個 video 就展示出來了,顯示出我們本地採集的數據了