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?