WebRTC(Web Real-Time Communication)是為了讓開(kāi)發(fā)者在瀏覽器實(shí)現(xiàn)多媒體交換的技術(shù),于2011年被加入W3C規(guī)范。當(dāng)前的支持情況可以見(jiàn)下圖。

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

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