WebRTC 之通信—PeerConnections 與信令服務

音視頻開發老舅 發佈 2022-09-20T10:54:33.099008+00:00

本文為 RTC 系列第三篇,上一篇介紹了 RTCPeerConnection 在本地的連接過程,如果是和遠程用戶連接,SDP 和 ICE 的交換是需要經過網絡傳輸的,在 RTC 中,負責信息交換的伺服器稱為信令伺服器,它所做的事情就是在連接前傳輸信息。

本文為 RTC 系列第三篇,上一篇介紹了 RTCPeerConnection 在本地的連接過程,如果是和遠程用戶連接,SDP 和 ICE 的交換是需要經過網絡傳輸的,在 RTC 中,負責信息交換的伺服器稱為信令伺服器,它所做的事情就是在連接前傳輸信息。由於 SDP 和 ICE 都是簡單的字符串數據,因此信令服務只要能夠傳遞字符串數據即可,任何滿足要求的伺服器都可以作為信令伺服器,我們可以根據需要搭建自己的信令服務,可以使用 websocket,也可以使用 http、sip 等其他協議。

C++音視頻開發學習資料點擊莬費領取→音視頻開發(資料文檔+視頻教程+面試題)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)

server 實現:

因為使用 socket.io 處理雙向通信比較容易,因此本文以 socket.io 為例展示一個簡單的 P2P 信令服務的實現,使用其他協議也同理。

用戶想和其他用戶進行通話首先需要知道對方是誰,因此信令伺服器需要有一套類似房間的機制,具體形式取決於業務場景,可能是聊天室,可能是廣場,總之需要把網絡上的用戶連起來,我們這裡實現一個簡單的 user-list 系統:

io.on("connection", async (socket) => {
  const userList = [...(await io.allSockets())];
  io.emit("user-list", userList);
});

當用戶 A 向 B 發起連接時,A 只要把之前發給 B 的內容發給伺服器,並告訴伺服器發送目標是 B 即可,同樣 B 發消息也一樣,只需要把消息發給伺服器,指定目標給 A。對於伺服器而言只需要將內容轉發出去即可,完全不需要理解內容:

socket.on("message", (msg) => {
  const target = io.sockets.sockets.get(msg.target);
  target?.emit("message", { from: socket.id, data: msg.data, type: `on-${msg.type}` });
});

這樣就實現了一個 server,下面來看 client 端實現。

client 實現:

與之前的本地 demo 相比,客戶端最大的改變就是發送端和接收端是分離的,因此我們需要把兩分邏輯拆分開,現在再來看 SDP 交互流程:

  1. A createOffer offerA
  2. A setLocalDescription offerA
  3. A 發送 offerA 給 B
  4. B setRemoteDescription offerA
  5. B createAnswer AnswerB
  6. B setLocalDescription AnswerB
  7. B 發送 AnswerB 給 A
  8. A setRemoteDescription AnswerB

這裡 A 與 B 在兩個不同的頁面上,它們不會直接通信,中間加入信令伺服器,交互的流程就變成了這樣:

  • 發送方:
    • createOffer
    • setLocalDescription
    • 發送 offer 給 server
    • 接收 server 的 answer
    • setRemoteDescription
  • 接收方:
    • 接收 server 的 offer
    • setRemoteDescription
    • createAnswer
    • setLocalDescription
    • 發送 answer 給 server

我們先實現一個發送客戶端,由於發送端是主動發起方,因此需要知道發送目標,這裡使用一個簡單的 user-list 機制來作為演示,當點擊用戶列表中的某個用戶時向其進行發送:

【免費】FFmpeg/WebRTC/RTMP/NDK/Android音視頻流媒體高級開發-學習視頻教程-騰訊課堂

C++音視頻開發學習資料點擊莬費領取→音視頻開發(資料文檔+視頻教程+面試題)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)

userWrapper.addEventListener('click', async (e) => {
        targetId = e.target.innerText;
        if (targetId !== socket.id) {
                if (targetId) {
                        const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
                        localVideo.srcObject = localStream;
                        createPeerConnection();
                        localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
                }
        }
});

createPeerConnection 中創建一個 RTCPeerConnection,可以在 negotiationneeded 事件回調中創建 offer,在監聽到 icecandidate 時發送出去:

function createPeerConnection() {
    pc = new RTCPeerConnection(pcConfig);
    pc.addEventListener('negotiationneeded', async () => {
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    socket.emit('message', {
        type: 'offer',
        target: targetId,
        data: { offer }
    });
  });
}

之後就是等待回復了,這裡監聽 server 的數據:

case 'on-answer':
        await pc.setRemoteDescription(data.answer);
        break;

之後再看接收端的邏輯,接收端的數據源來自信令伺服器,這裡從監聽 server 開始:

case 'on-offer':
        targetId = from;
        createPeerConnection();
        await pc.setRemoteDescription(data.offer);
        const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
        localVideo.srcObject = localStream;
        localStream.getTracks().forEach(track => {
                pc.addTrack(track, localStream);
        });
        const answer = await pc.createAnswer();
        await pc.setLocalDescription(answer);
        socket.emit('message', {
                type: 'answer',
                target: targetId,
                data: { answer },
        });
        break;

其中 createPeerConnection 邏輯與發送端是一致的,這裡不會觸發 negotiationneeded,不需要創建 offer。之後數據轉發回發送端正常處理,SDP 交換的流程就結束了。

SDP 之後進行 ICE 交換,ICE 邏輯很簡單,不需要數據握手,只要在收到時發出去,收到時設置到 PeerConnection 上即可:

pc.addEventListener('icecandidate', e => {
    if (e.candidate) {
        socket.emit('message', {
            type: 'candidate',
            target: targetId,
            data: { candidate: e.candidate }
        });
    }
});
case 'on-candidate':
        await pc.addIceCandidate(data.candidate);
        break;

這樣就建立好連接了,想要獲取遠端流直接監聽 track 就可以:

pc.addEventListener('track', e => {
        if (remoteVideo.srcObject !== e.streams[0]) {
                remoteVideo.srcObject = e.streams[0];
        }
});

以上就是一個簡單的 WebRTC P2P 系統的實現,整個過程中,只有連接時需要信令伺服器的參與,連接成功後,音視頻的發送是通過 addTrack 完成的,這個過程不需要信令伺服器參與。

但是上面的 P2P demo 有一個問題,它只能在同一內網環境工作,這就涉及到 P2P 中另一個知識點:打洞和穿越。

stun/TURN

由於 NAT 的特性,外網主機無法和內網主機直接建立連接。這時需要在我自己的 NAT 設備上先打一個洞,外網主機穿越這個洞來與內網用戶建立連接。

我們可以使用 STUN/TURN 服務來實現內網穿透的能力,在創建 RTCPeerConnection 時可以通過參數傳入 STUN/TURN 信息:

const config = {
        'iceServers': [
                { 'urls': 'STUN:stun.l.google.com:19302' },
                { "urls": "turn:numb.viagenie.ca", "username": "webrtc@live.com", "credential": "muazkh" }
        ]
};
new RTCPeerConnection(config);

STUN/TURN 伺服器的搭建過程在此不展開了,感興趣可以閱讀 coturn 項目。

多人場景架構

上面的是一對一場景下的實現方案,在實際應用中還有多人音視頻的需求,處理多人場景有以下幾種方案:

  • P2P:P2P(Point To Point)即點對點,在一對一會話場景我們已經見過了,多人會話和單人會話一樣,每兩個人都要建立 P2P 連接。
    • 客戶端:n 人的會話,推流 n - 1 拉流 n - 1
    • 優點:不需要伺服器感知視頻流
    • 缺點:人數越多客戶端壓力越大,無法處理太多人的場景
  • MCU:MCU(Multi-point Control Unit)中文為多點控制單元,有一個中心伺服器,用戶的所有流都直接推到這個伺服器上,由伺服器將多路數據流合成一路,客戶端拉這一路流即可。
    • 客戶端:n 人的會話,推流 1 拉流 1
    • 優點:節省客戶端資源,適用於廣播業務場景
    • 缺點:流在服務端已經確定,不能定製,無法滿足靈活定製的業務場景
  • SFU:SFU(Selective Forwarding Unit) 中文為選擇性轉發單元,中心伺服器只負責流的轉發,由客戶端根據需要選擇拉流。
    • 客戶端:n 人的會話,推流 1 拉流 n
    • 優點:可以滿足複雜多遍的需求場景
    • 缺點:拉流過多時客戶端性能有影響,需要根據實際業務情況進行平衡

多人音視頻會話有不同的業務形態,實際應用中需要根據具體的場景選擇合適的架構方案。

關鍵字: