WebRTC(Web Real-Time Communication)是為了讓開發者在瀏覽器實現多媒體交換的技術,于2011年被加入W3C規范。當前的支持情況可以見下圖。

WebRTC的核心在于建立PeerConnection實現視頻流雙端鏈接,要想理解WebRTC的工作流程,有如下后端服務的概念需要理解:
- 信令(Signal)服務器
- TURN/STUN服務器
- 房間服務器
- ICE候選者
視頻流的傳輸不是純前端的工作(顯然),然而WebRTC的規范只規定了前端的部分,后端的信令傳輸不在WebRTC的范圍之內,可以隨開發者需求自行開發。
下圖展現了WebRTC的工作流程

信令服務器(圖中黃色部分)主要作用是連接建立前的中轉工作。需要自行用websocket實現。
STUN(Session Traversal Utilities for NAT,NAT會話穿越應用程序)允許位于NAT(或多重NAT)后的客戶端找出自己的公網地址,查出自己位于哪種類型的NAT之后以及NAT為某一個本地端口所綁定的Inte.NET端端口。這些信息被用來在兩個同時處于NAT路由器之后的主機之間創建UDP通信。該協議由RFC 5389定義。
TURN(Traversal Using Relay NAT,通過Relay方式穿越NAT),TURN應用模型通過分配TURNServer的地址和端口作為客戶端對外的接受地址和端口,即私網用戶發出的報文都要經過TURNServer進行Relay轉發。解決了STUN應用無法穿透對稱NAT(SymmetricNAT)以及類似的防火墻的缺陷。
當STUN無法直接建立P2P時,便可以用TURN進行中轉。
房間服務器 和RTC的建立并無直接關系。但考慮到不可能你的服務只能同時支持一對電腦鏈接,我們必須設置“房間”。在本項目中,我們的“房間”號碼就是投屏碼。在投屏碼投屏的應用邏輯中,被叫方(投屏屏幕)首先用投屏碼向房間服務器注冊,客戶端(請求投屏方)輸入正確的投屏碼后加入“房間”。自此,RTC之后的信令交換都只在這個“房間”內完成,使服務支持多對計算機互聯。在實際實現中,房間服務器和信令服務器可以由同一服務完成。
ICE(Interactive Connectivity Establishment,互動式連接建立)提供一種框架,使各種NAT穿透技術可以實現統一。該技術可以讓基于SIP的VoIP客戶端成功地穿透遠程用戶與網絡之間可能存在的各類防火墻。
具體建立流程描述如下:
1、在連接建立之前,雙方不知道彼此,因此都需要向信令服務器進行注冊。隨后,發起方創建PeerConnection,調用WebRTC的createOffer方法將SDP(Session Description Protocol,理解為自己的一個“描述”)傳輸給信令服務器,由信令服務器做中繼傳遞給被叫方。
2、被叫方收到Offer以后,調用createAnswer方法生成針對發起方Offer的響應。并通過信令服務器發回呼叫方。此時雙方均保存有兩個Description(對于呼叫方是自己的offer和對面的answer,對于接收方是對面的offer和自己的answer)
3、交換完Offer后需要進行ICE交換,ICE交換同樣也要利用信令服務器進行交換。在設置完雙方Description之后,發起方會自動向配置的STUN服務器請求自己的ip和端口,STUN服務器會返回可能可用的ICE-Candidate。發起方收到Candidate后需要將其通過信令發送到被叫方。被叫方設定成自己的ICE-Candidate。與此同時,被叫方也需要向STUN服務器發起ICE請求流程,把自己的ICE候選者發送給發起方。雙方經過多次“協商”后最終選定ICE的交集進行連接。這也就體現了雙方的“互動”。
4、交換完ICE候選者后,P2P的PeerConnection建立完成,就可以傳輸各種媒體信息了。在實際測試中ICE的交換并不一定在收到Answer后才觸發,是可以提前觸發的。
呼叫端的流程
0、加個按鈕吧!
輸入“投屏碼”,和屏幕端加入同一個“房間”,以便于進行信令交換!點擊按鈕后,運行如下代碼:也就是說,以下所有的代碼,都是在你點擊這個按鈕后運行的。
socket = io.connect("你的信令服務器地址");
socket.on("connect", function () {
socket.emit("CONNECT_TO_TV", {
username: "lgy",
projCode: store.projCode.toUpperCase(),
});
});
在這里,我們建立了socket鏈接,并告訴了服務器我們想加入的“房間”。connect事件在建立連接后自動觸發。(我的考慮是點擊“鏈接”按鈕再建立鏈接,而不是一直長連接著,這個鏈接專門用于RTC流程建立,也就是說點擊“鏈接”前,下文的過程都不會進行。只有點擊按鈕后,才會有以下的流程)
1、建立PeerConnection對象
const configuration = {
iceServers: [
{
urls: "turn:你的turn服務器地址,端口一般是3478",
username: "turn用戶名",
credential: "turn密碼",
},
{ urls: "stun:你的stun服務器地址,端口一般是3478" },
],
iceCandidatePoolSize: 2,
};
const peerConnection = new RTCPeerConnection(configuration);
建立RTCPeerConnection是應該傳入候選iceServer,其中turnServer由于協議規定,必須有username和credential字段,stunServer不需要身份驗證。詳情可以參考MDN RTCPeerConnection文檔
2、捕獲視頻流
const transferStream = await navigator.mediaDevices.getUserMedia({
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: Screensources[screenid].id,
minWidth: 640,
maxWidth: 1920,
minHeight: 360,
maxHeight: 1080,
},
},
});
在這里,我們利用getUserMedia獲取到了視頻流MediaStream對象,若要同時獲取音頻,可以再增加audio選項,詳情->getUserMedia
何時獲取視頻流?
請注意,您不必現在就獲取視頻流,getUserMedia()會返回一個Promise,因此這里采用了await的寫法,但是您最好提前聲明一個MediaStream對象,因為在Offer生成之前媒體流必須添加到PeerConnection中,詳見
https://stackoverflow.com/questions/17391750/remote-videostream-not-working-with-webrtc
3、創建Offer
// 重要!在生成offer前確保已添加視頻流,不然可能連接建立完成后無法觸發對面的onaddstream監聽器。
peerConnection.addStream(transferStream);
const offer = await peerConnection.createOffer({
offerToReceiveVideo: 1,
// 已過時,最好用RTCRtpTransceiver替代
});
await peerConnection.setLocalDescription(offer);
// 設置自己的Description
// 發送websocket到信令服務器
socket.emit("RTC_Client_Offer_To_Server", {
offer: offer,
});
關于addStream和offerToReceiveVideo
根據最新的規范,addStream和offerToReceiveVideo兩處已經過時,根據官方建議(
https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/addstream_event),還是采用最新的addTrack和RTCRtpTransceiver來替換為好。詳見下文
這里有幾點需要注意:
- 請在連接建立之前為peerConnection添加stream或tracks
- 請在調用createOffer時參數務必傳入offerToReceiveVideo或設置RTCRtpTransceiver。您可以console.log()您的offer查看,若您的描述十分的短(只有一兩行)大概率是沒有設置該參數,正常情況下offer應該有幾十行。而且沒有設置該參數會導致無法觸發ICE的收集工作,因而無法觸發onicecandidate事件(將在后文提到)。
若采用track的寫法,addStream應該這樣寫:
transferStream.getTracks().forEach(track => {
peerConnection.addTrack(track, transferStream);
});
你可能已經注意到我們在這里用了socket.emit這一個函數來發送Offer,這是Socket.IO的使用方法,在本項目中我使用了Node.js用做websocket鏈接,調用Socket.IO這個包。先不用管這是干嘛的,他就是向信令服務器發送了一個指令,要求傳送第二個參數(就是Offer)的內容。
4、注冊事件監聽器
首先注冊ICE監聽器。當Offer正常交換完成后,會自動觸發ICE的收集,收集過后的結果會觸發onicecandidate監聽器。我們要做的很簡單——拿到這個ICE收集結果,并通過類似的方式通過信令服務器傳遞給接收方。
peerConnection.onicecandidate = function (event) {
console.log(event);
if (event.candidate) {
socket.emit("RTC_Candidate_Exchange", {
iceCandidate: event.candidate,
});
}
};
// 或者你也可以用監聽器的寫法:
peerConnection.addEventListener('icecandidate', event => {
console.log(event);
if (event.candidate) {
socket.emit("RTC_Candidate_Exchange", {
iceCandidate: event.candidate,
});
}
})
websocket向信令服務器發送了RTC_Candidate_Exchange指令,并傳遞了從事件中獲取的ICE候選人信息。
接著注冊ICE接收器,當收到對面的ICE候選人信息時,我們要將它添加到自己的ICE候選人列表。
socket.on("RTC_Candidate_Exchange", async (message) => {
if (message.iceCandidate) {
try {
await peerConnection.addIceCandidate(message.iceCandidate);
} catch (e) {
console.error("Error adding received ice candidate", e);
}
}
});
當收到信令服務器主題為RTC_Candidate_Exchange的消息時,取出消息中的ICE候選者,使用addIceCandidate加入到自己的列表。
也不能忘記注冊Answer接收器——當對面收到了我們的Offer,把Answer發送過來時,添加到RemoteDescription中
socket.on("RTC_TV_Answer_To_Client", async (msg) => {
if (msg.answer) {
const remoteDesc = new RTCSessionDescription(msg.answer);
await peerConnection.setRemoteDescription(remoteDesc);
console.log("RTC TV answer received", peerConnection);
}
});
在這里,我們使用RTCSessionDescription包裹了Answer,并將它通過setRemoteDescription(注意最開始的Offer是setLocalDescription,不要搞混)方法加入到了peerConnection中。
事實上到這里必要的工作已經準備完成,但是你肯定想知道你的鏈接建立的狀態,因此我們再注冊一個狀態監聽器來反饋連接的狀態:
peerConnection.onconnectionstatechange = function (event) {
console.log(
"RTC Connection State Change :",
peerConnection.connectionState
);
};
被叫端的流程
前面提到,我們需要讓服務器加入以其投屏碼命名的“房間”以便信令交互。所以我們可以讓頁面生成投屏碼后向服務器發起Socket注冊。
// 發送注冊請求,可以攜帶你想要的數據。
socket.on("connect", function () {
socket.emit("TV_REGISTER");
});
// 注冊成功后服務器發起TV_REGISTER_SUCCESS事件并傳回生成的投屏碼
socket.on("TV_REGISTER_SUCCESS", function (config) {
that.code = config.projCode || "獲取投屏碼失敗";
console.log("Regist Successful, config:", config);
});
第一條“connect”是定義好的事件,將在socket建立成功后觸發。我在這里的思路是服務器生成投屏碼,再下發過去。當然也可以TV生成然后去服務器“報備”。(默默說一句其實我覺得客戶端生成好,要不然斷鏈以后服務端很可能返回另一個投屏碼,在網絡不好的環境下每次重連都是新的房間就沒辦法實現自動恢復了,打算之后有空改一下,生成以后存在localStorage里)
被叫端和呼叫端差不多,甚至更為簡單。大部分由注冊的監聽器來完成
首先我們需要創建RTCPeerConnection
peerConnection = new RTCPeerConnection(configuration);
我們需要收到呼叫端的Offer并創建Answer:
socket.on("RTC_Client_Offer_To_TV", async (data) => {
console.log("RTC_Client_Offer_To_TV");
if (data.offer) {
peerConnection.setRemoteDescription(
new RTCSessionDescription(data.offer)
);
// 是呼叫方的Offer,放Remote
// 創建Answer,并保存為Local
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
// 利用信令回復Answer
socket.emit("RTC_TV_Answer_To_Server", { answer: answer });
}
});
我們通過注冊一個socket事件,當服務器返回RTC_Client_Offer_To_TV事件時,提取出Offer并保存,生成Answer并發布RTC_TV_Answer_To_Server事件讓服務器轉發給發起端。
類似于呼叫方,注冊ICE事件監聽器和RTC狀態變化監聽器(見呼叫方代碼,一模一樣)
此外,我們需要將視頻流提取出來,并作為視頻源給到到頁面上的video元素中。
peerConnection.onaddstream = (event) => {
player = document.getElementById('video');
player.srcObject = event.stream;
}
// 若您在呼叫方使用Track而不是用Stram,則注冊這個
peerConnection.addEventListener('track', async (event) => {
player = document.getElementById('video');
player.srcObject = remoteStream;
remoteStream.addTrack(event.track, remoteStream);
});
至此,所有的客戶端和投屏端配置已經完成。接下來就要進行后端服務器開發了。我將會在以后的文章中寫如何建立websocket信令服務和如何部署TURN/STUN服務器并解釋TURN服務器的動態身份驗證機制。感謝閱讀。