HTML躬行記(3)——WebRTC視訊通話

2022-10-31 12:01:51

  WebRTC 在建立對等(P2P)的連線之前,會先通過信令伺服器交換兩端的 SDP 和 ICE Candidate,取兩者的交集,決定最終的音視訊引數、傳輸協定、NAT 打洞方式等資訊。

  在完成媒體協商,並且兩端網路連通之後,就可以開始傳輸資料了。

  本文範例程式碼已上傳至 Github,有需要的可以隨意下載。

一、術語

  在實現一個簡單的視訊通話之前,還需要了解一些相關術語。

1)SDP

  SDP(Session Description Protocal)是一個描述對談後設資料(Session Metadata)、網路(Network)、流(Stream)、安全(Security)和服務質量(Qos,Grouping)的 WebRTC協定,下圖是 SDP 各語意和欄位之間的包含關係。

  換句話說,它就是一個用文字描述各端能力的協定,這些能力包括支援的音視訊編解碼器、傳輸協定、編解碼器引數(例如音訊通道數,取樣率等)等資訊。

  

  下面是一個典型的 SDP 資訊範例,其中 RTP(Real-time Transport Protocol)是一種網路協定,描述瞭如何以實時方式將各種媒體從一端傳輸到另一端。

=================對談描述======================
v=0
o=alice 2890844526 2890844526 IN IP4 host.anywhere.com
s=-
=================網路描述======================
c=IN IP4 host.anywhere.com
t=0 0
================音訊流描述=====================
m=audio 49170 RTP/AVP 0
a=rtpmap:0 PCMU/8000
================視訊流描述=====================
m=video 51372 RTP/AVP 31
a=rtpmap:31 H261/90000

2)ICE Candidate

  ICE 候選者描述了 WebRTC 能夠與遠端裝置通訊所需的協定、IP、埠、優先順序、候選者型別(包括 host、srflx 和 relay)等連線資訊。

  host 是本機候選者,srflx 是從 STUN 伺服器獲得的候選者,relay 是從 TURN 伺服器獲得的中繼候選者。

  在每一端都會提供許多候選者,例如有兩塊網路卡,那麼每塊網路卡的不同埠都是一個候選者。

  WebRTC 會按照優先順序倒序的進行連通性測試,當連通性測試成功後,通訊的雙方就建立起了連線。

3)NAT打洞

  在收集到候選者資訊後,WebRTC 會判斷兩端是否在同一個區域網中,若是,則可以直接建立連結。

  若不是,那麼 WebRTC 就會嘗試 NAT 打洞。WebRTC 將 NAT 分為 4 種型別:完全錐型、IP 限制型、埠限制型和對稱型。

  前文候選者型別中曾提到 STUN 和 TURN 兩種協定,接下來會對它們做簡單的說明。

  STUN(Session Traversal Utilities for NAT,NAT對談穿越應用程式)是一種網路協定,允許位於 NAT 後的使用者端找出自己的公網地址,當前 NAT 型別和 NAT 為某一個本地埠所繫結的公網埠。

  這些資訊讓兩個同時處於 NAT 路由器之後的主機之間建立 UDP 通訊,STUN 是一種 Client/Server 的協定,也是一種 Request/Response 的協定。

  下圖描繪了通過 STUN 伺服器獲取公網的 IP 地址,以及通過信令伺服器完成媒體協商的簡易過程。

  

  TURN(Traversal Using Relay NAT,通過 Relay 方式穿越 NAT),是一種資料傳輸協定,允許通過 TCP 或 UDP 穿透 NAT。

  TURN 也是一個 Client/Server 協定,其穿透方法與 STUN 類似,但終端必須在通訊開始前與 TURN 伺服器進行互動。

  下圖描繪了通過 TURN 伺服器實現 P2P 資料傳輸。

  

  CoTurn 是一款免費開源的 TURN 和 STUN 伺服器,可以到 GitHub 上下載原始碼編譯安裝。

二、信令伺服器

  通訊雙方彼此是不知道對方的,但是它們可以先與信令伺服器(Signal Server)連線,然後通過它來互傳資訊。

  可以將信令伺服器想象成一箇中間人,由他來安排兩端進入一個房間中,然後在房間中可以他們就能隨意的交換手上的情報了。

  本文會通過 Node.js 和 socket.io 實現一個簡單的信令伺服器,完成的功能僅僅是用於實驗,儲存在 server.js 檔案中。

  如果對 socket.io 不是很熟悉,可以參考我之前分享的一篇博文,對其有比較完整的說明。

1)HTTP 伺服器

  為了實現視訊通話的功能,需要先搭建一個簡易的 HTTP 伺服器,掛載靜態頁面。

  注意,在實際場景中,這塊可以在另一個專案中執行,本處只是為了方便演示。

const http = require('http');
const fs = require('fs');
const { Server } = require("socket.io");

// HTTP伺服器
const server = http.createServer((req, res) => {
  // 範例化 URL 類
  const url = new URL(req.url, 'http://localhost:1234');
  const { pathname } = url;
  // 路由
  if(pathname === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end(fs.readFileSync('./index.html'));
  }else if(pathname === '/socket.io.js') {
    res.writeHead(200, { 'Content-Type': 'application/javascript' });
    res.end(fs.readFileSync('./socket.io.js'));
  }else if(pathname === '/client.js') {
    res.writeHead(200, { 'Content-Type': 'application/javascript' });
    res.end(fs.readFileSync('./client.js'));
  }
});
// 監控埠
server.listen(1234);

  在上面的程式碼中,實現了最簡易的路由分發,當存取 http://localhost:1234 時,讀取 index.html 靜態頁面,結構如下所示。

<video id="localVideo"></video>
<button id="btn">開播</button>
<video id="remoteVideo" muted="muted"></video>
<script src="./socket.io.js"></script>
<script src="./client.js"></script>

  socket.io.js 是官方的 socket.io 庫,client.js 是使用者端的指令碼邏輯。

  在 remoteVideo 中附帶 muted 屬性是為了避免報錯:DOMException: The play() request was interrupted by a new load request。

  最後就可以通過 node server.js 命令,開啟 HTTP 伺服器。

2)長連線

  為了便於演示,指定了一個房間,當與信令伺服器連線時,預設就會被安排進 living room。

  並且只提供了一個 message 事件,這是交換各端資訊的關鍵程式碼,將一個使用者端傳送來的訊息中繼給其他各端。

const io = new Server(server);
const roomId = 'living room';
io.on('connection', (socket) => {
  // 指定房間
  socket.join(roomId);
  // 傳送訊息
  socket.on('message', (data) => {
    // 發訊息給房間內的其他人
    socket.to(roomId).emit('message', data);
  });
});

  因為預設是在本機演示,所以也不會安裝 CoTurn,有興趣的可以自行實現。

三、使用者端

  在之前的 HTML 結構中,可以看到兩個 video 元素和一個 button 元素。

const btn = document.getElementById('btn');   // 開播按鈕
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const size = 300;

  在兩個 video 元素中,第一個是接收原生的音視訊流,第二個是接收遠端的音視訊流。

1)媒體協商

  在下圖中,Alice 和 Bob 通過信令伺服器在交換 SDP 資訊。

  

  Alice 先呼叫 createOffer() 建立一個 Offer 型別的 SDP,然後呼叫 setLocalDescription() 設定本地描述。

  Bob 接收傳送過來的 Offer,呼叫 setRemoteDescription() 設定遠端描述。

  再呼叫 createAnswer() 建立一個 Answer 型別的 SDP,最後呼叫 setLocalDescription() 設定本地描述。

  而 Bob 也會接收 Answer 並呼叫 setRemoteDescription() 設定遠端描述。後面的程式碼會實現上述過程。

2)RTCPeerConnection

  在 WebRTC 中建立連線,需要先初始化 RTCPeerConnection 類,其建構函式可以接收 STUN/TURN 伺服器的設定資訊。

// STUN/TURN Servers
const pcConfig = {
//   'iceServers': [{
//     'urls': '',
//     'credential': "",
//     'username': ""
//   }]
};
// 範例化 RTCPeerConnection
const pc = new RTCPeerConnection(pcConfig);

  然後註冊 icecandidate 事件,將本機的網路資訊傳送給信令伺服器,sendMessage() 函數後面會介紹。

pc.onicecandidate = function(e) {
  if(!e.candidate) {
    return;
  }
  // 傳送 ICE Candidate
  sendMessage({
    type: 'candidate',
    label: e.candidate.sdpMLineIndex, 
    id: e.candidate.sdpMid, 
    candidate: e.candidate.candidate
  });
};

  最後註冊 track 事件,接收遠端的音視訊流。

pc.ontrack = function(e) {
  remoteVideo.srcObject = e.streams[0];
  remoteVideo.play();
};

3)長連線

  在使用者端中,已經引入了 socket.io 庫,所以只需要呼叫 io() 函數就能建立長連線。

  sendMessage() 函數就是傳送資訊給伺服器的 message 事件。

const socket = io("http://localhost:1234");
// 傳送訊息
function sendMessage(data){
  socket.emit('message', data);
}

  本地也有個 message 事件,會接收從伺服器端傳送來的訊息,其實就是那些轉發的訊息。

  data 物件有個 type 屬性,可建立和接收遠端的 Answer 型別的 SDP 資訊,以及接收遠端的 ICE 候選者資訊。

socket.on("message", function (data) {
  switch (data.type) {
    case "offer":
      // 設定遠端描述
      pc.setRemoteDescription(new RTCSessionDescription(data));
      // 建立 Answer 型別的 SDP 資訊
      pc.createAnswer().then((desc) => {
        pc.setLocalDescription(desc);
        sendMessage(desc);
      });
      break;
    case "answer":
      // 接收遠端的 Answer 型別的 SDP 資訊
      pc.setRemoteDescription(new RTCSessionDescription(data));
      break;
    case "candidate":
      // 範例化 RTCIceCandidate
      const candidate = new RTCIceCandidate({
        sdpMLineIndex: data.label,
        candidate: data.candidate
      });
      pc.addIceCandidate(candidate);
      break;
  }
});

  在程式碼中,用 RTCSessionDescription 描述 SDP 資訊,用 RTCIceCandidate 描述 ICE 候選者資訊。

4)開播

  為開播按鈕註冊點選事件,在事件中,首先通過 getUserMedia() 獲取原生的音視訊流。

btn.addEventListener("click", function (e) {
  // 獲取音視訊流
  navigator.mediaDevices
    .getUserMedia({
      video: {
        width: size,
        height: size
      },
      audio: true
    })
    .then((stream) => {
      localVideo.srcObject = stream;
      localStream = stream;
      // 將 Track 與 RTCPeerConnection 繫結
      stream.getTracks().forEach((track) => {
        pc.addTrack(track, stream);
      });
      // 建立 Offer 型別的 SDP 資訊
      pc.createOffer({
        offerToRecieveAudio: 1,
        offerToRecieveVideo: 1
      }).then((desc) => {
        // 設定本地描述
        pc.setLocalDescription(desc);
        // 傳送 Offer 型別的 SDP 資訊
        sendMessage(desc);
      });
      localVideo.play();
    });
  btn.disabled = true;
});

  然後在 then() 方法中,讓 localVideo 接收音視訊流,並且將 Track 與 RTCPeerConnection 繫結。

  這一步很關鍵,沒有這一步就無法將音視訊流推給遠端。

  然後建立 Offer 型別的 SDP 資訊,設定本地描述,並通過信令伺服器傳送給遠端。

  接著可以在兩個瀏覽器(例如 Chrome 和 Edge)中分別存取 http://localhost:1234,在一個瀏覽器中點選開播,如下圖所示。

  

  在另一個瀏覽器的 remoteVideo 中,就可以看到推播過來的畫面。

  

  下面用一張時序圖來完整的描述整個連線過程,具體內容不再贅述。

  

 

 

參考資料:

What is WebRTC and How to Setup STUN/TURN Server for WebRTC Communication?

WebRTC音視訊傳輸基礎:NAT穿透