本文為 RTC 系列第三篇,上一篇介紹了 RTCPeerConnection 在本地的連接過程,如果是和遠程用戶連接,SDP 和 ICE 的交換是需要經過網絡傳輸的,在 RTC 中,負責信息交換的伺服器稱為信令伺服器,它所做的事情就是在連接前傳輸信息。由於 SDP 和 ICE 都是簡單的字符串數據,因此信令服務只要能夠傳遞字符串數據即可,任何滿足要求的伺服器都可以作為信令伺服器,我們可以根據需要搭建自己的信令服務,可以使用 websocket,也可以使用 http、sip 等其他協議。
C++音視頻開發學習資料:點擊莬費領取→音視頻開發(資料文檔+視頻教程+面試題)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)
server 實現:
因為使用 socket.io 處理雙向通信比較容易,因此本文以 socket.io 為例展示一個簡單的 P2P 信令服務的實現,使用其他協議也同理。
用戶想和其他用戶進行通話首先需要知道對方是誰,因此信令伺服器需要有一套類似房間的機制,具體形式取決於業務場景,可能是聊天室,可能是廣場,總之需要把網絡上的用戶連起來,我們這裡實現一個簡單的 user-list 系統:
io.on("connection", async (socket) => {
const userList = [...(await io.allSockets())];
io.emit("user-list", userList);
});
當用戶 A 向 B 發起連接時,A 只要把之前發給 B 的內容發給伺服器,並告訴伺服器發送目標是 B 即可,同樣 B 發消息也一樣,只需要把消息發給伺服器,指定目標給 A。對於伺服器而言只需要將內容轉發出去即可,完全不需要理解內容:
socket.on("message", (msg) => {
const target = io.sockets.sockets.get(msg.target);
target?.emit("message", { from: socket.id, data: msg.data, type: `on-${msg.type}` });
});
這樣就實現了一個 server,下面來看 client 端實現。
client 實現:
與之前的本地 demo 相比,客戶端最大的改變就是發送端和接收端是分離的,因此我們需要把兩分邏輯拆分開,現在再來看 SDP 交互流程:
- A createOffer offerA
- A setLocalDescription offerA
- A 發送 offerA 給 B
- B setRemoteDescription offerA
- B createAnswer AnswerB
- B setLocalDescription AnswerB
- B 發送 AnswerB 給 A
- A setRemoteDescription AnswerB
這裡 A 與 B 在兩個不同的頁面上,它們不會直接通信,中間加入信令伺服器,交互的流程就變成了這樣:
- 發送方:
- createOffer
- setLocalDescription
- 發送 offer 給 server
- 接收 server 的 answer
- setRemoteDescription
- 接收方:
- 接收 server 的 offer
- setRemoteDescription
- createAnswer
- setLocalDescription
- 發送 answer 給 server
我們先實現一個發送客戶端,由於發送端是主動發起方,因此需要知道發送目標,這裡使用一個簡單的 user-list 機制來作為演示,當點擊用戶列表中的某個用戶時向其進行發送:
【免費】FFmpeg/WebRTC/RTMP/NDK/Android音視頻流媒體高級開發-學習視頻教程-騰訊課堂
C++音視頻開發學習資料:點擊莬費領取→音視頻開發(資料文檔+視頻教程+面試題)(FFmpeg+WebRTC+RTMP+RTSP+HLS+RTP)
userWrapper.addEventListener('click', async (e) => {
targetId = e.target.innerText;
if (targetId !== socket.id) {
if (targetId) {
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideo.srcObject = localStream;
createPeerConnection();
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
}
}
});
createPeerConnection 中創建一個 RTCPeerConnection,可以在 negotiationneeded 事件回調中創建 offer,在監聽到 icecandidate 時發送出去:
function createPeerConnection() {
pc = new RTCPeerConnection(pcConfig);
pc.addEventListener('negotiationneeded', async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('message', {
type: 'offer',
target: targetId,
data: { offer }
});
});
}
之後就是等待回復了,這裡監聽 server 的數據:
case 'on-answer':
await pc.setRemoteDescription(data.answer);
break;
之後再看接收端的邏輯,接收端的數據源來自信令伺服器,這裡從監聽 server 開始:
case 'on-offer':
targetId = from;
createPeerConnection();
await pc.setRemoteDescription(data.offer);
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localVideo.srcObject = localStream;
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('message', {
type: 'answer',
target: targetId,
data: { answer },
});
break;
其中 createPeerConnection 邏輯與發送端是一致的,這裡不會觸發 negotiationneeded,不需要創建 offer。之後數據轉發回發送端正常處理,SDP 交換的流程就結束了。
SDP 之後進行 ICE 交換,ICE 邏輯很簡單,不需要數據握手,只要在收到時發出去,收到時設置到 PeerConnection 上即可:
pc.addEventListener('icecandidate', e => {
if (e.candidate) {
socket.emit('message', {
type: 'candidate',
target: targetId,
data: { candidate: e.candidate }
});
}
});
case 'on-candidate':
await pc.addIceCandidate(data.candidate);
break;
這樣就建立好連接了,想要獲取遠端流直接監聽 track 就可以:
pc.addEventListener('track', e => {
if (remoteVideo.srcObject !== e.streams[0]) {
remoteVideo.srcObject = e.streams[0];
}
});
以上就是一個簡單的 WebRTC P2P 系統的實現,整個過程中,只有連接時需要信令伺服器的參與,連接成功後,音視頻的發送是通過 addTrack 完成的,這個過程不需要信令伺服器參與。
但是上面的 P2P demo 有一個問題,它只能在同一內網環境工作,這就涉及到 P2P 中另一個知識點:打洞和穿越。
stun/TURN
由於 NAT 的特性,外網主機無法和內網主機直接建立連接。這時需要在我自己的 NAT 設備上先打一個洞,外網主機穿越這個洞來與內網用戶建立連接。
我們可以使用 STUN/TURN 服務來實現內網穿透的能力,在創建 RTCPeerConnection 時可以通過參數傳入 STUN/TURN 信息:
const config = {
'iceServers': [
{ 'urls': 'STUN:stun.l.google.com:19302' },
{ "urls": "turn:numb.viagenie.ca", "username": "webrtc@live.com", "credential": "muazkh" }
]
};
new RTCPeerConnection(config);
STUN/TURN 伺服器的搭建過程在此不展開了,感興趣可以閱讀 coturn 項目。
多人場景架構
上面的是一對一場景下的實現方案,在實際應用中還有多人音視頻的需求,處理多人場景有以下幾種方案:
- P2P:P2P(Point To Point)即點對點,在一對一會話場景我們已經見過了,多人會話和單人會話一樣,每兩個人都要建立 P2P 連接。
- 客戶端:n 人的會話,推流 n - 1 拉流 n - 1
- 優點:不需要伺服器感知視頻流
- 缺點:人數越多客戶端壓力越大,無法處理太多人的場景
- MCU:MCU(Multi-point Control Unit)中文為多點控制單元,有一個中心伺服器,用戶的所有流都直接推到這個伺服器上,由伺服器將多路數據流合成一路,客戶端拉這一路流即可。
- 客戶端:n 人的會話,推流 1 拉流 1
- 優點:節省客戶端資源,適用於廣播業務場景
- 缺點:流在服務端已經確定,不能定製,無法滿足靈活定製的業務場景
- SFU:SFU(Selective Forwarding Unit) 中文為選擇性轉發單元,中心伺服器只負責流的轉發,由客戶端根據需要選擇拉流。
- 客戶端:n 人的會話,推流 1 拉流 n
- 優點:可以滿足複雜多遍的需求場景
- 缺點:拉流過多時客戶端性能有影響,需要根據實際業務情況進行平衡
多人音視頻會話有不同的業務形態,實際應用中需要根據具體的場景選擇合適的架構方案。