WebRTC 在創建點對點(P2P)的連接之前,會先通過信令服務器交換兩端的 SDP 和 ICE Candidate,取兩者的交集,決定最終的音視頻參數、傳輸協議、NAT 打洞方式等信息。
  在完成媒體協商,并且兩端網絡連通之后,就可以開始傳輸數據了。
  本文示例代碼已上傳至[Github](https://github.com/pwstrick/webrtc),有需要的可以隨意下載。
## 一、術語
  在實現一個簡單的視頻通話之前,還需要了解一些相關術語。
**1)SDP**
  SDP(Session Description Protocal)是一個描述會話元數據(Session Metadata)、網絡(Network)、流(Stream)、安全(Security)和服務質量(Qos,Grouping)的[WebRTC協議](https://www.ietf.org/archive/id/draft-nandakumar-rtcweb-sdp-08.txt),下圖是 SDP 各語義和字段之間的包含關系。
  換句話說,它就是一個用文本描述各端能力的協議,這些能力包括支持的音視頻編解碼器、傳輸協議、編解碼器參數(例如音頻通道數,采樣率等)等信息。
:-: 
  下面是一個典型的[SDP](https://developer.mozilla.org/zh-CN/docs/Glossary/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
~~~
  RTP 協議的作用是讓數據包有序,它是應用層傳輸協議的一種,與 HTTP/HTTPS 同級。
  除了 RTP 之外,還有一種 RTCP 協議,與 RTP 處于同一級,并且可對其做丟包控制。
**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](https://github.com/coturn/coturn)上下載源碼編譯安裝。
## 二、信令服務器
  通信雙方彼此是不知道對方的,但是它們可以先與信令服務器(Signal Server)連接,然后通過它來互傳信息。
  可以將信令服務器想象成一個中間人,由他來安排兩端進入一個房間中,然后在房間中可以他們就能隨意的交換手上的情報了。
  本文會通過 Node.js 和[socket.io](https://socket.io/)實現一個簡單的信令服務器,完成的功能僅僅是用于實驗,保存在 server.js 文件中。
  如果對 socket.io 不是很熟悉,可以參考我之前分享的一篇[博文](https://www.cnblogs.com/strick/p/16358972.html),對其有比較完整的說明。
**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 靜態頁面,結構如下所示。
~~~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 屬性是為了避免[報錯](https://stackoverflow.com/questions/49930680/how-to-handle-uncaught-in-promise-domexception-play-failed-because-the-use):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()](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/createOffer)創建一個 Offer 類型的 SDP,然后調用[setLocalDescription()](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription)配置本地描述。
  Bob 接收發送過來的 Offer,調用[setRemoteDescription()](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/setRemoteDescription)配置遠端描述。
  再調用[createAnswer()](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createAnswer)創建一個 Answer 類型的 SDP,最后調用 setLocalDescription() 配置本地描述。
  而 Bob 也會接收 Answer 并調用 setRemoteDescription() 配置遠端描述。后面的代碼會實現上述過程。
**2)RTCPeerConnection**
  在 WebRTC 中創建連接,需要先初始化[RTCPeerConnection](https://developer.mozilla.org/zh-CN/docs/Web/API/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](https://developer.mozilla.org/zh-CN/docs/Web/API/RTCSessionDescription)描述 SDP 信息,用[RTCIceCandidate](https://developer.mozilla.org/en-US/docs/Web/API/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?](https://medium.com/av-transcode/what-is-webrtc-and-how-to-setup-stun-turn-server-for-webrtc-communication-63314728b9d0)
[WebRTC音視頻傳輸基礎:NAT穿透](https://blog.jianchihu.net/webrtc-av-transport-basis-nat-traversal.html)
*****
> 原文出處:
[博客園-HTML躬行記](https://www.cnblogs.com/strick/category/1770829.html)
[知乎專欄-HTML躬行記](https://zhuanlan.zhihu.com/c_1250826149041238016)
已建立一個微信前端交流群,如要進群,請先加微信號freedom20180706或掃描下面的二維碼,請求中需注明“看云加群”,在通過請求后就會把你拉進來。還搜集整理了一套[面試資料](https://github.com/pwstrick/daily),歡迎閱讀。

推薦一款前端監控腳本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不僅能監控前端的錯誤、通信、打印等行為,還能計算各類性能參數,包括 FMP、LCP、FP 等。
- ES6
- 1、let和const
- 2、擴展運算符和剩余參數
- 3、解構
- 4、模板字面量
- 5、對象字面量的擴展
- 6、Symbol
- 7、代碼模塊化
- 8、數字
- 9、字符串
- 10、正則表達式
- 11、對象
- 12、數組
- 13、類型化數組
- 14、函數
- 15、箭頭函數和尾調用優化
- 16、Set
- 17、Map
- 18、迭代器
- 19、生成器
- 20、類
- 21、類的繼承
- 22、Promise
- 23、Promise的靜態方法和應用
- 24、代理和反射
- HTML
- 1、SVG
- 2、WebRTC基礎實踐
- 3、WebRTC視頻通話
- 4、Web音視頻基礎
- CSS進階
- 1、CSS基礎拾遺
- 2、偽類和偽元素
- 3、CSS屬性拾遺
- 4、浮動形狀
- 5、漸變
- 6、濾鏡
- 7、合成
- 8、裁剪和遮罩
- 9、網格布局
- 10、CSS方法論
- 11、管理后臺響應式改造
- React
- 1、函數式編程
- 2、JSX
- 3、組件
- 4、生命周期
- 5、React和DOM
- 6、事件
- 7、表單
- 8、樣式
- 9、組件通信
- 10、高階組件
- 11、Redux基礎
- 12、Redux中間件
- 13、React Router
- 14、測試框架
- 15、React Hooks
- 16、React源碼分析
- 利器
- 1、npm
- 2、Babel
- 3、webpack基礎
- 4、webpack進階
- 5、Git
- 6、Fiddler
- 7、自制腳手架
- 8、VSCode插件研發
- 9、WebView中的頁面調試方法
- Vue.js
- 1、數據綁定
- 2、指令
- 3、樣式和表單
- 4、組件
- 5、組件通信
- 6、內容分發
- 7、渲染函數和JSX
- 8、Vue Router
- 9、Vuex
- TypeScript
- 1、數據類型
- 2、接口
- 3、類
- 4、泛型
- 5、類型兼容性
- 6、高級類型
- 7、命名空間
- 8、裝飾器
- Node.js
- 1、Buffer、流和EventEmitter
- 2、文件系統和網絡
- 3、命令行工具
- 4、自建前端監控系統
- 5、定時任務的調試
- 6、自制短鏈系統
- 7、定時任務的進化史
- 8、通用接口
- 9、微前端實踐
- 10、接口日志查詢
- 11、E2E測試
- 12、BFF
- 13、MySQL歸檔
- 14、壓力測試
- 15、活動規則引擎
- 16、活動配置化
- 17、UmiJS版本升級
- 18、半吊子的可視化搭建系統
- 19、KOA源碼分析(上)
- 20、KOA源碼分析(下)
- 21、花10分鐘入門Node.js
- 22、Node環境升級日志
- 23、Worker threads
- 24、低代碼
- 25、Web自動化測試
- 26、接口攔截和頁面回放實驗
- 27、接口管理
- 28、Cypress自動化測試實踐
- 29、基于Electron的開播助手
- Node.js精進
- 1、模塊化
- 2、異步編程
- 3、流
- 4、事件觸發器
- 5、HTTP
- 6、文件
- 7、日志
- 8、錯誤處理
- 9、性能監控(上)
- 10、性能監控(下)
- 11、Socket.IO
- 12、ElasticSearch
- 監控系統
- 1、SDK
- 2、存儲和分析
- 3、性能監控
- 4、內存泄漏
- 5、小程序
- 6、較長的白屏時間
- 7、頁面奔潰
- 8、shin-monitor源碼分析
- 前端性能精進
- 1、優化方法論之測量
- 2、優化方法論之分析
- 3、瀏覽器之圖像
- 4、瀏覽器之呈現
- 5、瀏覽器之JavaScript
- 6、網絡
- 7、構建
- 前端體驗優化
- 1、概述
- 2、基建
- 3、后端
- 4、數據
- 5、后臺
- Web優化
- 1、CSS優化
- 2、JavaScript優化
- 3、圖像和網絡
- 4、用戶體驗和工具
- 5、網站優化
- 6、優化閉環實踐
- 數據結構與算法
- 1、鏈表
- 2、棧、隊列、散列表和位運算
- 3、二叉樹
- 4、二分查找
- 5、回溯算法
- 6、貪心算法
- 7、分治算法
- 8、動態規劃
- 程序員之路
- 大學
- 2011年
- 2012年
- 2013年
- 2014年
- 項目反思
- 前端基礎學習分享
- 2015年
- 再一次項目反思
- 然并卵
- PC網站CSS分享
- 2016年
- 制造自己的榫卯
- PrimusUI
- 2017年
- 工匠精神
- 2018年
- 2019年
- 前端學習之路分享
- 2020年
- 2021年
- 2022年
- 2023年
- 2024年
- 日志
- 2020