從0到1打造一個 WebRTC 應用

林恒發表於2024-07-15

🧑‍💻 寫在開頭

點贊 + 收藏 === 學會🤣🤣🤣

前言

2020 年初突如其來的新冠肺炎疫情讓線下就醫渠道幾乎被切斷,在此背景下,微醫作為數字健康行業的領軍者透過線上問診等形式快速解決了大量急需就醫人們的燃眉之急。而作為微醫 Web 端線上問診中重要的一環-醫患之間的影片問診正是應用了接下來講述的 WebRTC 技術。

WebRTC 是什麼

WebRTC(Web Real-Time Communication)是 Google 在 2010 年以 6820 萬美元收購 VoIP 軟體開發商 Global IP Solutions 的 GIPS 引擎,並改名為“WebRTC”於 2011 年將其開源的旨在建立一個網際網路瀏覽器之間的音影片和資料實時通訊的平臺。

那麼 WebRTC 能做些什麼呢?除了上述提到的醫療領域中的線上問診/遠端門診/遠端會診,還有時下較為流行的電商互動直播解決方案、教育行業解決方案,除此之外,伴隨著 5G 的快速建設,WebRTC 也為雲遊戲提供了很好的技術支撐。

WebRTC 架構

下圖是來自WebRTC 官網的 WebRTC 整體架構圖

從圖中不難看出,整個 WebRTC 架構設計大致可以分為以下 3 部分:

  1. 紫色提供給 Web 前端開發使用的 API
  2. 藍色實線部分提供各大瀏覽器廠商使用的 API
  3. 藍色虛線部分包含 3 部分:音訊引擎、影片引擎、網路傳輸 (Transport)。都可以自定義實現

WebRTC 點對點通訊原理

要實現兩個不同網路環境(具有麥克風、攝像頭裝置)的客戶端(可能是不同的 Web 瀏覽器或者手機 App)之間的實時音影片通訊的難點在哪裡、需要解決哪些問題?

  1. 怎麼知道彼此的存在也就是如何發現對方?
  2. 彼此音影片編解碼能力如何溝通?
  3. 音影片資料如何傳輸,怎麼能讓對方看得自己?

對於問題 1:WebRTC 雖然支援端對端通訊,但是這並不意味著 WebRTC 不再需要伺服器。在點對點通訊的過程中,雙方需要交換一些後設資料比如媒體資訊、網路資料等等資訊。我們通常稱這一過程叫做:信令(signaling)。對應的伺服器即信令伺服器 (signaling server)。通常也有人將之稱為房間伺服器,因為它不僅可以交換彼此的媒體資訊和網路資訊,同樣也可以管理房間資訊,比如通知彼此 who 加入了房間,who 離開了房間,告訴第三方房間人數是否已滿是否可以加入房間。 為了避免出現冗餘,並最大限度地提高與已有技術的相容性,WebRTC 標準並沒有規定信令方法和協議。在本文接下來的實踐章節會利用 Koa 和 Socket.io 技術實現一個信令伺服器。

對於問題 2:我們首先要知道的是,不同瀏覽器對於音影片的編解碼能力是不同的。比如: Peer-A 端支援 H264、VP8 等多種編碼格式,而 Peer-B 端支援 H264、VP9 等格式。為了保證雙方都可以正確的編解碼,最簡單的辦法即取它們所都支援格式的交集-H264。在 WebRTC 中,有一個專門的協議,稱為Session Description Protocol(SDP),可以用於描述上述這類資訊。因此參與音影片通訊的雙方想要了解對方支援的媒體格式,必須要交換 SDP 資訊。而交換 SDP 的過程,通常稱之為媒體協商

對於問題 3:其本質上就是網路協商的過程:參與音影片實時通訊的雙方要了解彼此的網路情況,這樣才有可能找到一條相互通訊的鏈路。理想的網路情況是每個瀏覽器的電腦都有自己的私有公網 IP 地址,這樣的話就可以直接進行點對點連線。但實際上出於網路安全和 IPV4 地址不夠的考慮,我們的電腦與電腦之間或大或小都是在某個區域網內,需要NAT(Network Address Translation, 網路地址轉換)。在 WebRTC 中我們使用 ICE 機制建立網路連線。那麼何為 ICE?

ICE (Interactive Connecctivity Establishment, 互動式連線建立),ICE 不是一種協議,而是整合了 STUN 和 TURN 兩種協議的框架。其中STUN(Sesssion Traversal Utilities for NAT, NAT 會話穿越應用程式),它允許位於 NAT(或多重 NAT)後的客戶端找出自己對應的公網 IP 地址和埠,也就是俗稱的“打洞”。但是,如果 NAT 型別是對稱型的話,那麼就無法打洞成功。這時候 TURN 就派上用場了,TURN(Traversal USing Replays around NAT)是 STUN/RFC5389 的一個擴充協議在其基礎上新增了 Replay(中繼)功能,簡單來說其目的就是解決對稱 NAT 無法穿越的問題,在 STUN 分配公網 IP 失敗後,可以透過 TURN 伺服器請求公網 IP 地址作為中繼地址。

在 WebRTC 中有三種型別的 ICE 候選者,它們分別是:

  • 主機候選者
  • 反射候選者
  • 中繼候選者

主機候選者,表示的是本地區域網內的 IP 地址及埠。它是三個候選者中優先順序最高的,也就是說在 WebRTC 底層,首先會嘗試本地區域網內建立連線。

反射候選者,表示的是獲取 NAT 內主機的外網 IP 地址和埠。其優先順序低於 主機候選者。也就是說當 WebRTC 嘗試本地連線不通時,會嘗試透過反射候選者獲得的 IP 地址和埠進行連線。

中繼候選者,表示的是中繼伺服器的 IP 地址與埠,即透過伺服器中轉媒體資料。當 WebRTC 客戶端通訊雙方無法穿越 P2P NAT 時,為了保證雙方可以正常通訊,此時只能透過伺服器中轉來保證服務質量了。

從上圖我們可以看出,在非本地區域網內 WebRTC 透過 STUN server 獲得自己的外網 IP 和埠,然後透過信令伺服器與遠端的 WebRTC 交換網路資訊。之後雙方就可以嘗試建立 P2P 連線了。當 NAT 穿越不成功時,則會透過 Relay server (TURN)中轉。

值得一提的是,在 WebRTC 中網路資訊通常用candidate來描述,而上述圖中的 STUN server 和 Replay server 也都可以是同一個 server。在文末的實踐章節即是採用了整合了 STUN(打洞)和 TURN(中繼)功能的開源專案 coturn。

綜上對三個問題的解釋我們可以用下圖來說明 WebRTC 點對點通訊的基本原理:

簡而言之就是透過 WebRTC 提供的 API 獲取各端的媒體資訊 SDP 以及 網路資訊 candidate ,並透過信令伺服器交換,進而建立了兩端的連線通道完成實時影片語音通話。

WebRTC 幾個重要的 API

音影片採集 API

MediaDevices.getUserMedia()

const constraints = {
        video: true,
        audio: true
    
};
//   非安全模式(非https/localhost)下 navigator.mediaDevices 會返回 undefined
try {
        const stream = await navigator.mediaDevices.getUserMedia(constraints);
        document.querySelector('video').srcObject = stream;
    }   catch (error) {
        console.error(error);
    }

獲取音影片裝置輸入輸出列表

MediaDevices.enumerateDevices()

try {
        const devices = await navigator.mediaDevices.enumerateDevices();
        this.videoinputs = devices.filter(device => device.kind === 'videoinput');
        this.audiooutputs = devices.filter(device => device.kind === 'audiooutput');
        this.audioinputs = devices.filter(device => device.kind === 'audioinput');
      } catch (error) {
        console.error(error);
      }

RTCPeerConnection

RTCPeerConnection 作為建立點對點連線的 API,是我們實現音影片實時通訊的關鍵。(參考MDN 文件

在本文的實踐章節中主要運用到 RTCPeerConnection 的以下方法:

媒體協商方法

  • createOffer
  • createAnswer
  • setLocalDesccription
  • setRemoteDesccription

重要事件

  • onicecandidate
  • onaddstream

在上個章節的描述中可以知道 P2P 通訊中最重要的一個環節就是交換媒體資訊

從上圖不難發現,整個媒體協商過程可以簡化為三個步驟對應上述四個媒體協商方法:

  • 呼叫端 Amy 建立 Offer(createOffer)並將 offer 訊息(內容是呼叫端 Amy 的 SDP 資訊)透過信令伺服器傳送給接收端 Bob,同時呼叫 setLocalDesccription 將含有本地 SDP 資訊的 Offer 儲存起來
  • 接收端 Bob 收到對端的 Offer 資訊後呼叫 setRemoteDesccription 方法將含有對端 SDP 資訊的 Offer 儲存起來,並建立 Answer(createAnswer)並將 Answer 訊息(內容是接收端 Bob 的 SDP 資訊)透過信令伺服器傳送給呼叫端 Amy
  • 呼叫端 Amy 收到對端的 Answer 資訊後呼叫 setRemoteDesccription 方法將含有對端 SDP 資訊的 Answer 儲存起來

經過上述三個步驟,則完成了 P2P 通訊過程中的媒體協商部分,實際上在呼叫端以及接收端呼叫 setLocalDesccription 同時也開始了收集各端自己的網路資訊(candidate),然後各端透過監聽事件 onicecandidate 收集到各自的 candidate 並透過信令伺服器傳送給對端,進而打通 P2P 通訊的網路通道,並透過監聽 onaddstream ���件拿到對方的影片流進而完成了整個視訊通話過程。

WebRTC 實踐

coturn 伺服器的搭建

注意:如果只是本地區域網測試則無需搭建 coturn 伺服器,如果需要外網訪問在搭建 coturn 伺服器之前你需要購買一臺雲主機以及繫結支援 https 訪問的域名。以下是筆者自己搭建測試 WebRTC 的網站: webrtc-demo

coturn 伺服器的搭建主要是為了解決 NAT 無法穿越的問題,其安裝也較為簡單:

1. git clone https://github.com/coturn/coturn.git
2. cd coturn/
3. ./configure --prefix=/usr/local/coturn
4. make -j 4
5. make install
// 生成 key
6. openssl req -x509 -newkey rsa:2048 -keyout /etc/turn_server_pkey.pem -out /etc/turn_server_cert.pem -days 99999 -nodes 

coturn 服務配置

vim /usr/local/coturn/etc/turnserver.conf

listening-port=3478
external-ip=xxx.xxx // 你的主機公網 IP
user=xxx:xxx // 賬號: 密碼
realm=xxx.com // 你的域名

啟動 coturn 服務

1. cd /usr/local/coturn/bin/

2. ./turnserver -c ../etc/turnserver.conf

// 注意:雲主機內的 TCP 和 UDP 的 3478 埠都要開啟

實踐程式碼

在編寫程式碼之前,結合上述章節 WebRTC 點對點通訊的基本原理,可以得出以下流程圖:

從圖中不難看出,假設 PeerA 為發起方,PeerB 為接收方要實現 WebRTC 點對點的實時音影片通訊,信令(Signal)伺服器是必要的,以管理房間資訊以及轉發網路資訊和媒體資訊的,在本文中是利用 koa 及 socket.io 搭建的信令伺服器:

// server 端 server.js
const Koa = require('koa');
const socket = require('socket.io');
const http = require('http');
const app = new Koa();
const httpServer = http.createServer(app.callback()).listen(3000, ()=>{});
socket(httpServer).on('connection', (sock)=>{
    // ....
});

// client 端 socket.js
import io from 'socket.io-client';
const socket = io.connect(window.location.origin);
export default socket;

在搭建好信令伺服器後,結合流程圖,有以下步驟:

  1. PeerA 和 PeerB 端分別連線信令伺服器,信令伺服器記錄房間資訊
// server 端 server.js
socket(httpServer).on('connection', (sock)=>{
    // 使用者離開房間
    sock.on('userLeave',()=>{
        // ...
    });
    // 檢查房間是否可加入
    sock.on('checkRoom',()=>{
        // ...
    });
    // ....
});
// client 端 Room.vue
import socket from '../utils/socket.js';

// 服務端告知使用者是否可加入房間
socket.on('checkRoomSuccess',()=>{
        // ...
});
// 服務端告知使用者成功加入房間
socket.on('joinRoomSuccess',()=>{
        // ...
});
//....
  1. A 端作為發起方向接收方 B 端發起影片邀請,在得到 B 同意影片請求後,雙方都會建立本地的 RTCPeerConnection,新增本地影片流,其中傳送方會建立 offer 設定本地 sdp 資訊描述,並透過信令伺服器將自己的 SDP 資訊傳送給對端
socket.on('answerVideo', async (user) => {
        VIDEO_VIEW.showInvideoModal();
        // 建立本地影片流資訊
        const localStream = await this.createLocalVideoStream();
        this.localStream = localStream;
        document.querySelector('#echat-local').srcObject = this.localStream;
        this.peer = new RTCPeerConnection();
        this.initPeerListen();
        this.peer.addStream(this.localStream);
        if (user.sockId === this.sockId) {
          // 接收方
        } else {
          // 傳送方 建立 offer
          const offer = await this.peer.createOffer(this.offerOption);
          await this.peer.setLocalDescription(offer);
          socket.emit('receiveOffer', { user: this.user, offer });
        }
 });

4.前面提起過其實在呼叫 setLocalDescription 的同時,也會開始收集自己端的網路資訊(candidate),如果在非區域網內或者網路“打洞”不成功,還會嘗試向 Stun/Turn 伺服器發起請求,也就是收集“中繼候選者”,因此在建立 RTCPeerConnection 我們還需要監聽 ICE 網路候選者的事件:
socket.on('receiveOffer', async (offer) => {
        await this.peer.setRemoteDescription(offer);
        const answer = await this.peer.createAnswer();
        await this.peer.setLocalDescription(answer);
        socket.emit('receiveAnsewer', { answer, user: this.user });
 });
  1. 當發起方 A 透過信令伺服器接收到接收方 B 的 answer 資訊後則也會呼叫 setRemoteDescription,這樣雙方就完成了 SDP 資訊的交換
socket.on('receiveAnsewer', (answer) => {
        this.peer.setRemoteDescription(answer);
      });
  1. 當雙方 SDP 資訊交換完成並且監聽 icecandidate 收集到網路候選者透過信令伺服器交換後,則會拿到彼此的影片流。
socket.on('addIceCandidate', async (candidate) => {
        await this.peer.addIceCandidate(candidate);
});
this.peer.onaddstream = (event) => {
        // 拿到對方的影片流
        document.querySelector('#remote-video').srcObject = event.stream;
};

  

總結

經過上個章節的6個步驟即可完成一次完整的 P2P 影片實時通話,程式碼可透過learn-webrtc下載,值得一提的是,程式碼中的 VIDEO_VIEW 是專注於影片UI層的JS SDK,包含了發起影片 Modal、接收影片 Modal、影片中 Modal,其是從微醫線上 Web 影片問診所使用的 JS SDK 抽離出來的。本文只是簡單地介紹了WebRTC P2P的通訊基本原理,事實上生產環境所使用的 SDK 不僅支援點對點通訊,還支援多人視訊通話,螢幕共享等功能這些都是基於WebRTC實現的。

如果對您有所幫助,歡迎您點個關注,我會定時更新技術文件,大家一起討論學習,一起進步。

從0到1打造一個 WebRTC 應用

相關文章