張宇航,微醫雲服務團隊前端工程師,一個不文藝的處女座程序員。html
2020 年初突如其來的新冠肺炎疫情讓線下就醫渠道幾乎被切斷,在此背景下,微醫做爲數字健康行業的領軍者經過在線問診等形式快速解決了大量急需就醫人們的燃眉之急。而做爲微醫 Web 端在線問診中重要的一環-醫患之間的視頻問診正是應用了接下來說述的 WebRTC 技術。前端
WebRTC(Web Real-Time Communication)是 Google 在 2010 年以 6820 萬美圓收購 VoIP 軟件開發商 Global IP Solutions 的 GIPS 引擎,並更名爲「WebRTC」於 2011 年將其開源的旨在創建一個互聯網瀏覽器之間的音視頻和數據實時通訊的平臺。vue
那麼 WebRTC 能作些什麼呢?除了上述提到的醫療領域中的在線問診/遠程門診/遠程會診,還有時下較爲流行的電商互動直播解決方案、教育行業解決方案,除此以外,伴隨着 5G 的快速建設,WebRTC 也爲雲遊戲提供了很好的技術支撐。html5
下圖是來自WebRTC 官網的 WebRTC 總體架構圖node
從圖中不難看出,整個 WebRTC 架構設計大體能夠分爲如下 3 部分:git
要實現兩個不一樣網絡環境(具備麥克風、攝像頭設備)的客戶端(多是不一樣的 Web 瀏覽器或者手機 App)之間的實時音視頻通訊的難點在哪裏、須要解決哪些問題?程序員
對於問題 1:WebRTC 雖然支持端對端通訊,可是這並不意味着 WebRTC 再也不須要服務器。在點對點通訊的過程當中,雙方須要交換一些元數據好比媒體信息、網絡數據等等信息。咱們一般稱這一過程叫作:信令(signaling)。對應的服務器即信令服務器 (signaling server)。一般也有人將之稱爲房間服務器,由於它不只能夠交換彼此的媒體信息和網絡信息,一樣也能夠管理房間信息,好比通知彼此 who 加入了房間,who 離開了房間,告訴第三方房間人數是否已盡是否能夠加入房間。 爲了不出現冗餘,並最大限度地提升與已有技術的兼容性,WebRTC 標準並無規定信令方法和協議。在本文接下來的實踐章節會利用 Koa 和 Socket.io 技術實現一個信令服務器。github
對於問題 2:咱們首先要知道的是,不一樣瀏覽器對於音視頻的編解碼能力是不一樣的。好比: Peer-A 端支持 H26四、VP8 等多種編碼格式,而 Peer-B 端支持 H26四、VP9 等格式。爲了保證雙方均可以正確的編解碼,最簡單的辦法即取它們所都支持格式的交集-H264。在 WebRTC 中,有一個專門的協議,稱爲Session Description Protocol(SDP),能夠用於描述上述這類信息。所以參與音視頻通信的雙方想要了解對方支持的媒體格式,必需要交換 SDP 信息。而交換 SDP 的過程,一般稱之爲媒體協商。web
對於問題 3:其本質上就是網絡協商的過程:參與音視頻實時通訊的雙方要了解彼此的網絡狀況,這樣纔有可能找到一條相互通信的鏈路。理想的網絡狀況是每一個瀏覽器的電腦都有本身的私有公網 IP 地址,這樣的話就能夠直接進行點對點鏈接。但實際上出於網絡安全和 IPV4 地址不夠的考慮,咱們的電腦與電腦之間或大或小都是在某個局域網內,須要NAT(Network Address Translation, 網絡地址轉換)。在 WebRTC 中咱們使用 ICE 機制創建網絡鏈接。那麼何爲 ICE?vim
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 ,並經過信令服務器交換,進而創建了兩端的鏈接通道完成實時視頻語音通話。
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);
}
複製代碼
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 做爲建立點對點鏈接的 API,是咱們實現音視頻實時通訊的關鍵。(參考MDN 文檔)
在本文的實踐章節中主要運用到 RTCPeerConnection 的如下方法:
在上個章節的描述中能夠知道 P2P 通訊中最重要的一個環節就是交換媒體信息
從上圖不難發現,整個媒體協商過程能夠簡化爲三個步驟對應上述四個媒體協商方法:
通過上述三個步驟,則完成了 P2P 通訊過程當中的媒體協商部分,實際上在呼叫端以及接收端調用 setLocalDesccription 同時也開始了收集各端本身的網絡信息(candidate),而後各端經過監聽事件 onicecandidate 收集到各自的 candidate 並經過信令服務器傳送給對端,進而打通 P2P 通訊的網絡通道,並經過監聽 onaddstream 事件拿到對方的視頻流進而完成了整個視頻通話過程。
注意:若是隻是本地局域網測試則無需搭建 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
複製代碼
vim /usr/local/coturn/etc/turnserver.conf
listening-port=3478
external-ip=xxx.xxx // 你的主機公網 IP
user=xxx:xxx // 帳號: 密碼
realm=xxx.com // 你的域名
複製代碼
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;
複製代碼
在搭建好信令服務器後,結合流程圖,有如下步驟:
// 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',()=>{
// ...
});
//....
複製代碼
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 });
}
});
複製代碼
initPeerListen () {
// 收集本身的網絡信息併發送給對端
this.peer.onicecandidate = (event) => {
if (event.candidate) { socket.emit('addIceCandidate', { candidate: event.candidate, user: this.user }); }
};
// ....
}
複製代碼
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 });
});
複製代碼
socket.on('receiveAnsewer', (answer) => {
this.peer.setRemoteDescription(answer);
});
複製代碼
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實現的。