使用WebRTC實現P2P視頻流
前言
網絡實時通信(WebRTC)是一個開源標準,允許網絡應用程序和網站之間的實時通信,而無需插件或額外的軟件安裝。它也可以作為iOS和安卓應用程序的庫,提供與標準相同的功能。
WebRTC適用于任何操作系統,可用于所有現代瀏覽器,包括谷歌Chrome、Mozilla火狐和Safari。使用WebRTC的一些主要項目包括谷歌會議和Hangouts、WhatsApp、亞馬遜Chime、臉書Messenger、Snapchat和Discord。
在本文中,我們將介紹WebRTC的主要用例之一:從一個系統到另一個系統的點對點(P2P)音頻和視頻流。此功能類似于Twitch等實時流媒體服務,但規模更小、更簡單。
要了解的核心WebRTC概念
在本節中,我將回顧您應該了解的五個基本概念,以了解使用WebRTC的Web應用程序的工作原理。這些概念包括點對點通信、Signal服務器和ICE協議。
點對點通信
在本指南中,我們將使用WebRTC的RTCPeerConnection對象,該對象主要涉及連接兩個應用程序并允許它們使用點對點協議進行通信。
在去中心化網絡中,對等通信是網絡中計算機系統(對等點)之間的直接鏈接,沒有中介(例如服務器)。雖然WebRTC不允許對等點在所有場景下直接相互通信,但它使用的ICE協議和Signal服務器允許類似的行為。您將在下面找到更多關于它們的信息。
Signal 服務器
對于WebRTC應用程序中的每一對要開始通信,它們必須執行“握手”,這是通過offer或answer完成的。一個對等點生成offer并與另一個對等點共享,另一個對等點生成answer并與第一個對等點共享。
為了使握手成功,每個對等點都必須有一種方法來共享他們的offer或answer。這就是Signal 服務器的用武之地。
Signal 服務器的主要目標是啟動對等點之間的通信。對等點使用信號服務器與另一個對等點共享其offer或answer,另一個可以使用Signal 服務器與第一個對等點共享其offer或answer。
ICE協議
在特定情況下,比如當所有涉及的設備都不在同一個本地網絡中時,WebRTC應用程序可能很難相互建立對等連接。這是因為除非對等點在同一個本地網絡中,否則它們之間的直接socket連接并不總是可能的。
當您想使用跨不同網絡的對等連接時,您需要使用交互式連通建立方式(ICE)協議。ICE協議用于在Internet上的對等點之間建立連接。ICE服務器使用該協議在對等點之間建立連接和中繼信息。
ICE協議包括用于NAT的會話遍歷實用程序(STUN)協議、圍繞NAT使用中繼的遍歷(TURN)協議或兩者的混合。
在本教程中,我們不會涵蓋ICE協議的實際方面,因為構建服務器、讓它工作和測試它所涉及的復雜性。然而,了解WebRTC應用程序的限制以及ICE協議在哪里可以解決這些限制是有幫助的。
WebRTC P2P視頻流入門
現在我們已經完成了所有這些,是時候開始復雜的工作了。在下一節中,我們將研究視頻流項目。當我們開始時,您可以在這里看到該項目的現場演示。
在我們開始之前,我有一個GitHub存儲庫 https://github.com/GhoulKingR/webrtc-project ,您可以克隆它以關注本文。此存儲庫有一個start-tutorial文件夾,按照您將在下一節中采取的步驟進行組織,以及每個步驟末尾的代碼副本。雖然不需要使用repo,但它很有幫助。
我們將在repo中處理的文件夾稱為start-tutorial。它包含三個文件夾:step-1、step-2和step-3。這三個文件夾對應于下一節中的步驟。
運行視頻流項目
現在,讓我們開始構建項目。我把這個過程分為三個步驟。我們將創建一個項目,我們可以在每個步驟中運行、測試和使用。
這些步驟包括:
- 網頁內的視頻流
- 使用BroadcastChannel在瀏覽器選項卡和窗口之間的流
- 使用 signal服務器在同一設備上的不同瀏覽器之間流。
網頁內的視頻流
在這一步中,我們只需要一個index.html文件。如果您在repo中工作,您可以使用start-tutorial/step-1/index.html文件。
現在,讓我們將此代碼粘貼到其中:
<body>
<video id="local" autoplay muted></video>
<video id="remote" autoplay></video>
<button onclick="start(this)">start video</button>
<button id="stream" onclick="stream(this)" disabled>stream video</button>
<script>
// get video elements
const local = document.querySelector("video#local");
const remote = document.querySelector("video#remote");
function start(e) {
e.disabled = true;
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then((stream) => {
local.srcObject = stream;
document.getElementById("stream").disabled = false; // enable the stream button
})
.catch(() => e.disabled = false);
}
function stream(e) {
// disable the stream button
e.disabled = true;
const config = {};
const localPeerConnection = new RTCPeerConnection(config); // local peer
const remotePeerConnection = new RTCPeerConnection(config); // remote peer
// if an icecandidate event is triggered in a peer add the ice candidate to the other peer
localPeerConnection.addEventListener("icecandidate", e => remotePeerConnection.addIceCandidate(e.candidate));
remotePeerConnection.addEventListener("icecandidate", e => localPeerConnection.addIceCandidate(e.candidate));
// if the remote peer detects a track in the connection, it forwards it to the remote video element
remotePeerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
// get camera and microphone source tracks and add it to the local peer
local.srcObject.getTracks()
.forEach(track => localPeerConnection.addTrack(track, local.srcObject));
// Start the handshake process
localPeerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
.then(async offer => {
await localPeerConnection.setLocalDescription(offer);
await remotePeerConnection.setRemoteDescription(offer);
console.log("Created offer");
})
.then(() => remotePeerConnection.createAnswer())
.then(async answer => {
await remotePeerConnection.setLocalDescription(answer);
await localPeerConnection.setRemoteDescription(answer);
console.log("Created answer");
});
}
</script>
</body>
它會給你一些看起來像這樣的展示:
圖片
現在,讓我們來看看這是怎么回事。
要構建項目,我們需要兩個視頻元素。我們將使用一個來捕獲用戶的相機和麥克風。之后,我們將使用WebRTC的RTCPeerConnection對象將此元素的音頻和視頻流饋送到另一個視頻元素:
<video id="local" autoplay muted></video>
<video id="remote" autoplay></video>
RTCPeerConnection對象是在Web瀏覽器或設備之間建立直接點對點連接的主要對象。
然后我們需要兩個按鈕。一個是激活用戶的網絡攝像頭和麥克風,另一個是將第一個視頻元素的內容流式傳輸到第二個:
<button onclick="start(this)">start video</button>
<button id="stream" onclick="stream(this)" disabled>stream video</button>
單擊時,"start video"按鈕運行start功能。單擊時,"stream video"按鈕運行stream功能。
我們首先看一下start函數:
function start(e) {
e.disabled = true;
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then((stream) => {
local.srcObject = stream;
document.getElementById("stream").disabled = false; // enable the stream button
})
.catch(() => e.disabled = false);
}
當start函數運行時,它首先使開始按鈕不可單擊。然后,它通過navigator.mediaDevices.getUserMedia方法請求用戶使用其網絡攝像頭和麥克風的權限。
如果用戶授予權限,start函數通過其srcObject字段將視頻和音頻流發送到第一個視頻元素,并啟用stream按鈕。如果從用戶那里獲得權限出現問題或用戶拒絕權限,該函數會再次單擊start按鈕。
現在,讓我們看一下stream函數:
function stream(e) {
// disable the stream button
e.disabled = true;
const config = {};
const localPeerConnection = new RTCPeerConnection(config); // local peer
const remotePeerConnection = new RTCPeerConnection(config); // remote peer
// if an icecandidate event is triggered in a peer add the ice candidate to the other peer
localPeerConnection.addEventListener("icecandidate", e => remotePeerConnection.addIceCandidate(e.candidate));
remotePeerConnection.addEventListener("icecandidate", e => localPeerConnection.addIceCandidate(e.candidate));
// if the remote peer receives track from the connection, it feeds them to the remote video element
remotePeerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
// get camera and microphone tracks then feed them to local peer
local.srcObject.getTracks()
.forEach(track => localPeerConnection.addTrack(track, local.srcObject));
// Start the handshake process
localPeerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
.then(async offer => {
await localPeerConnection.setLocalDescription(offer);
await remotePeerConnection.setRemoteDescription(offer);
console.log("Created offer");
})
.then(() => remotePeerConnection.createAnswer())
.then(async answer => {
await remotePeerConnection.setLocalDescription(answer);
await localPeerConnection.setRemoteDescription(answer);
console.log("Created answer");
});
}
我添加了注釋來概述stream函數中的過程,以幫助理解它。然而,握手過程(第21-32行)和ICE候選事件(第10行和第11行)是我們將更詳細討論的重要部分。
在握手過程中,每對都會根據對創建的offer和answer設置其本地和遠程描述:
- 生成offer的對將其本地描述設置為該offer,然后將offer的副本發送到第二對以設置為其遠程描述
- 同樣,生成answer的對將answer設置為其本地描述,并將副本發送到第一對以設置為其遠程描述
完成這個過程后,同行立即開始相互交流。
ICE候選是對等方的地址(IP、端口和其他相關信息)。RTCPeerConnection對象使用ICE候選來查找和相互通信。RTCPeerConnection對象中的icecandidate事件在對象生成ICE候選時觸發。
我們設置的事件偵聽器的目標是將ICE候選人從一個對等點傳遞到另一個對等點。
在瀏覽器選項卡和帶有BroadcastChannel的窗口之間
使用WebRTC設置點對點應用程序的挑戰之一是讓它跨不同的應用程序實例或網站工作。在本節中,我們將使用廣播頻道API允許我們的項目在單個網頁之外但在瀏覽器上下文中工作。
創建必要的文件
我們將從創建兩個文件開始,streamer.html和index.html。在repo中,這些文件位于start-tutorial/step-2文件夾中。streamer.html頁面允許用戶從他們的相機創建實時流,而index.html頁面將使用戶能夠觀看這些實時流。
現在,讓我們將這些代碼塊粘貼到文件中。然后,我們將更深入地研究它們。
首先,在streamer.html文件中,粘貼以下代碼:
<body>
<video id="local" autoplay muted></video>
<button onclick="start(this)">start video</button>
<button id="stream" onclick="stream(this)" disabled>stream video</button>
<script>
// get video elements
const local = document.querySelector("video#local");
let peerConnection;
const channel = new BroadcastChannel("stream-video");
channel.onmessage = e => {
if (e.data.type === "icecandidate") {
peerConnection?.addIceCandidate(e.data.candidate);
} else if (e.data.type === "answer") {
console.log("Received answer")
peerConnection?.setRemoteDescription(e.data);
}
}
// function to ask for camera and microphone permission
// and stream to #local video element
function start(e) {
e.disabled = true;
document.getElementById("stream").disabled = false; // enable the stream button
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then((stream) => local.srcObject = stream);
}
function stream(e) {
e.disabled = true;
const config = {};
peerConnection = new RTCPeerConnection(config); // local peer connection
// add ice candidate event listener
peerConnection.addEventListener("icecandidate", e => {
let candidate = null;
// prepare a candidate object that can be passed through browser channel
if (e.candidate !== null) {
candidate = {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
};
}
channel.postMessage({ type: "icecandidate", candidate });
});
// add media tracks to the peer connection
local.srcObject.getTracks()
.forEach(track => peerConnection.addTrack(track, local.srcObject));
// Create offer and send through the browser channel
peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
.then(async offer => {
await peerConnection.setLocalDescription(offer);
console.log("Created offer, sending...");
channel.postMessage({ type: "offer", sdp: offer.sdp });
});
}
</script>
</body>
然后,在index.html文件中,粘貼以下代碼:
<body>
<video id="remote" controls></video>
<script>
// get video elements
const remote = document.querySelector("video#remote");
let peerConnection;
const channel = new BroadcastChannel("stream-video");
channel.onmessage = e => {
if (e.data.type === "icecandidate") {
peerConnection?.addIceCandidate(e.data.candidate)
} else if (e.data.type === "offer") {
console.log("Received offer")
handleOffer(e.data)
}
}
function handleOffer(offer) {
const config = {};
peerConnection = new RTCPeerConnection(config);
peerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
peerConnection.addEventListener("icecandidate", e => {
let candidate = null;
if (e.candidate !== null) {
candidate = {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
}
}
channel.postMessage({ type: "icecandidate", candidate })
});
peerConnection.setRemoteDescription(offer)
.then(() => peerConnection.createAnswer())
.then(async answer => {
await peerConnection.setLocalDescription(answer);
console.log("Created answer, sending...")
channel.postMessage({
type: "answer",
sdp: answer.sdp,
});
});
}
</script>
</body>
在您的瀏覽器中,頁面的外觀和功能將類似于以下動畫:
圖片
streamer.html文件的詳細分解
現在,讓我們更詳細地探索這兩個頁面。我們將從streamer.html頁面開始。此頁面只需要一個視頻和兩個按鈕元素:
<video id="local" autoplay muted></video>
<button onclick="start(this)">start video</button>
<button id="stream" onclick="stream(this)" disabled>stream video</button>
"start video"按鈕的工作方式與上一步相同:它請求用戶允許使用他們的相機和麥克風,并將流提供給視頻元素。然后,"stream video"按鈕初始化對等連接并將視頻流提供給對等連接。
由于此步驟涉及兩個網頁,我們正在使用廣播頻道API。在我們的index.html和streamer.html文件中,我們必須在每個頁面上初始化一個具有相同名稱的BroadcastChannel對象,以允許它們進行通信。
BroadcastChannel對象允許您在具有相同URL來源的瀏覽上下文(例如窗口或選項卡)之間傳遞基本信息。
當你初始化一個BroadcastChannel對象時,你必須給它一個名字。你可以把這個名字想象成聊天室的名字。如果你用相同的名字初始化兩個BroadcastChannel對象,他們可以像在聊天室一樣互相交談。但是如果他們有不同的名字,他們就不能交流,因為他們不在同一個聊天室里。
我說“聊天室”是因為您可以擁有多個具有相同名稱的BroadcastChannel對象,并且它們都可以同時相互通信。
由于我們正在處理兩個頁面,每個頁面都有對等連接,我們必須使用BroadcastChannel對象在兩個頁面之間來回傳遞offer和answer。我們還必須將對等連接的ICE候選傳遞給另一個。所以,讓我們看看它是如何完成的。
這一切都從stream函數開始:
// streamer.html -> script element
function stream(e) {
e.disabled = true;
const config = {};
peerConnection = new RTCPeerConnection(config); // local peer connection
// add ice candidate event listener
peerConnection.addEventListener("icecandidate", e => {
let candidate = null;
// prepare a candidate object that can be passed through browser channel
if (e.candidate !== null) {
candidate = {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
};
}
channel.postMessage({ type: "icecandidate", candidate });
});
// add media tracks to the peer connection
local.srcObject.getTracks()
.forEach(track => peerConnection.addTrack(track, local.srcObject));
// Create offer and send through the browser channel
peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
.then(async offer => {
await peerConnection.setLocalDescription(offer);
console.log("Created offer, sending...");
channel.postMessage({ type: "offer", sdp: offer.sdp });
});
}
函數中有兩個區域與BrowserChannel對象交互。第一個是ICE候選事件偵聽器:
peerConnection.addEventListener("icecandidate", e => {
let candidate = null;
// prepare a candidate object that can be passed through browser channel
if (e.candidate !== null) {
candidate = {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
};
}
channel.postMessage({ type: "icecandidate", candidate });
});
另一種是生成offer后:
peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
.then(async offer => {
await peerConnection.setLocalDescription(offer);
console.log("Created offer, sending...");
channel.postMessage({ type: "offer", sdp: offer.sdp });
});
讓我們先看看ICE候選事件偵聽器。如果您將e.candidate對象直接傳遞給BroadcastChannel對象,您將在控制臺中收到DataCloneError: object can not be cloned錯誤消息。
發生此錯誤是因為BroadcastChannel對象無法直接處理e.candidate。您需要從e.candidate創建一個包含所需詳細信息的對象以發送到BroadcastChannel對象。我們必須做同樣的事情來發送offer。
您需要調用channel.postMessage方法向BroadcastChannel對象發送消息。調用此消息時,另一個網頁上的BroadcastChannel對象會觸發其onmessage事件偵聽器。從index.html頁面查看此代碼:
channel.onmessage = e => {
if (e.data.type === "icecandidate") {
peerConnection?.addIceCandidate(e.data.candidate)
} else if (e.data.type === "offer") {
console.log("Received offer")
handleOffer(e.data)
}
}
如您所見,我們有條件語句檢查進入BroadcastChannel對象的消息類型。消息的內容可以通過e.data讀取。e.data.type對應于我們通過channel.postMessage發送的對象的類型字段:
// from the ICE candidate event listener
channel.postMessage({ type: "icecandidate", candidate });
// from generating an offer
channel.postMessage({ type: "offer", sdp: offer.sdp });
現在,讓我們看一下處理收到的offer的index.html文件。
index.html文件的詳細分解
index.html文件以handleOffer函數開頭:
function handleOffer(offer) {
const config = {};
peerConnection = new RTCPeerConnection(config);
peerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
peerConnection.addEventListener("icecandidate", e => {
let candidate = null;
if (e.candidate !== null) {
candidate = {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
}
}
channel.postMessage({ type: "icecandidate", candidate })
});
peerConnection.setRemoteDescription(offer)
.then(() => peerConnection.createAnswer())
.then(async answer => {
await peerConnection.setLocalDescription(answer);
console.log("Created answer, sending...")
channel.postMessage({
type: "answer",
sdp: answer.sdp,
});
});
}
當觸發時,此方法創建對等連接并將其生成的任何ICE候選發送給另一個對等。然后,繼續握手過程,將流媒體的offer設置為其遠程描述,生成answer,將該answer設置為其本地描述,并使用BroadcastChannel對象將該answer發送給流媒體。
與index.html文件中的BroadcastChannel對象一樣,streamer.html文件中的BroadcastChannel對象需要一個onmessage事件偵聽器來接收ICE候選者并從index.html文件中回答:
channel.onmessage = e => {
if (e.data.type === "icecandidate") {
peerConnection?.addIceCandidate(e.data.candidate);
} else if (e.data.type === "answer") {
console.log("Received answer")
peerConnection?.setRemoteDescription(e.data);
}
}
如果您想知道為什么問號?在peerConnection之后,它告訴JavaScript運行時在peerConnection未定義時不要拋出錯誤。這在某種程度上是一個簡寫:
if (peerConnection) {
peerConnection.setRemoteDescription(e.data);
}
用我們的Signal服務器替換BroadcastChannel
BroadcastChannel僅限于瀏覽器上下文。在這一步中,我們將通過使用一個簡單的Signal服務器來克服這個限制,我們將使用Node. js構建它。與前面的步驟一樣,我將首先給您粘貼的代碼,然后解釋其中發生了什么。
那么,讓我們開始吧。這一步需要四個文件:index.html、streamer.html、signalserverclass.js和server/index.js。
我們將從signalserverclass.js文件開始:
class SignalServer {
constructor(channel) {
this.socket = new WebSocket("ws://localhost:80");
this.socket.addEventListener("open", () => {
this.postMessage({ type: "join-channel", channel });
});
this.socket.addEventListener("message", (e) => {
const object = JSON.parse(e.data);
if (object.type === "connection-established") console.log("connection established");
else if (object.type === "joined-channel") console.log("Joined channel: " + object.channel);
else this.onmessage({ data: object });
});
}
onmessage(e) {}
postMessage(data) {
this.socket.send( JSON.stringify(data) );
}
}
接下來,讓我們更新index.html和streamer.html文件。對這些文件的唯一更改是我們初始化BroadcastChannel對象和導入signalserverclass.js腳本的腳本標記。
這是更新的index.html文件:
<body>
<video id="remote" controls></video>
<script src="signalserverclass.js"></script> <!-- new change -->
<script>
const remote = document.querySelector("video#remote");
let peerConnection;
const channel = new SignalServer("stream-video"); // <- new change
channel.onmessage = e => {
if (e.data.type === "icecandidate") {
peerConnection?.addIceCandidate(e.data.candidate);
} else if (e.data.type === "offer") {
console.log("Received offer");
handleOffer(e.data);
}
}function handleOffer(offer) {
const config = {};
peerConnection = new RTCPeerConnection(config);
peerConnection.addEventListener("track", e => remote.srcObject = e.streams[0]);
peerConnection.addEventListener("icecandidate", e => {
let candidate = null;
if (e.candidate !== null) {
candidate = {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
};
}
channel.postMessage({ type: "icecandidate", candidate });
});
peerConnection.setRemoteDescription(offer)
.then(() => peerConnection.createAnswer())
.then(async answer => {
await peerConnection.setLocalDescription(answer);
console.log("Created answer, sending...");
channel.postMessage({
type: "answer",
sdp: answer.sdp,
});
});
}
</script>
</body>
這是更新后的streamer.html文件:
<body>
<video id="local" autoplay muted></video>
<button onclick="start(this)">start video</button>
<button id="stream" onclick="stream(this)" disabled>stream video</button>
<script src="signalserverclass.js"></script> <!-- new change -->
<script>
const local = document.querySelector("video#local");
let peerConnection;
const channel = new SignalServer("stream-video"); // <- new change
channel.onmessage = e => {
if (e.data.type === "icecandidate") {
peerConnection?.addIceCandidate(e.data.candidate);
} else if (e.data.type === "answer") {
console.log("Received answer");
peerConnection?.setRemoteDescription(e.data);
}
}
// function to ask for camera and microphone permission
// and stream to #local video element
function start(e) {
e.disabled = true;
document.getElementById("stream").disabled = false; // enable the stream button
navigator.mediaDevices.getUserMedia({ audio: true, video: true })
.then((stream) => local.srcObject = stream);
}
function stream(e) {
e.disabled = true;
const config = {};
peerConnection = new RTCPeerConnection(config); // local peer connection
peerConnection.addEventListener("icecandidate", e => {
let candidate = null;
if (e.candidate !== null) {
candidate = {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
};
}
channel.postMessage({ type: "icecandidate", candidate });
});
local.srcObject.getTracks()
.forEach(track => peerConnection.addTrack(track, local.srcObject));
peerConnection.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
.then(async offer => {
await peerConnection.setLocalDescription(offer);
console.log("Created offer, sending...");
channel.postMessage({ type: "offer", sdp: offer.sdp });
});
}
</script>
</body>
最后,這是server/index.js文件的內容:
const { WebSocketServer } = require("ws");
const channels = {};
const server = new WebSocketServer({ port: 80 });
server.on("connection", handleConnection);
function handleConnection(ws) {
console.log('New connection');
ws.send( JSON.stringify({ type: 'connection-established' }) );
let id;
let channel = "";
ws.on("error", () => console.log('websocket error'));
ws.on('message', message => {
const object = JSON.parse(message);
if (object.type === "join-channel") {
channel = object.channel;
if (channels[channel] === undefined) channels[channel] = [];
id = channels[channel].length || 0;
channels[channel].push(ws);
ws.send(JSON.stringify({type: 'joined-channel', channel}));
} else {
// forward the message to other channel memebers
channels[channel]?.filter((_, i) => i !== id).forEach((member) => {
member.send(message.toString());
});
}
});
ws.on('close', () => {
console.log('Client has disconnected!');
if (channel !== "") {
channels[channel] = channels[channel].filter((_, i) => i !== id);
}
});
}
圖片
要讓服務器運行,您需要在終端中打開server文件夾,將該文件夾初始化為Node項目,安裝ws包,然后運行index.js文件。這些步驟可以使用以下命令完成:
# initialize the project directory
npm init --y
# install the `ws` package
npm install ws
# run the `index.js` file
node index.js
現在,讓我們看看文件。為了減少在將BroadcastChannel對象構造函數與SignalServer構造函數交換后編輯代碼的需要,我嘗試讓SignalServer類模仿您使用BroadcastChannel所做的調用和事情-至少對于我們的用例:
class SignalServer {
constructor(channel) {
// what the constructor does
}
onmessage(e) {}
postMessage(data) {
// what postMessage does
}
}
此類有一個在初始化時加入通道的構造函數。它還有一個postMessage函數來允許發送消息和一個onmessage方法,當從另一個SignalServer對象接收到消息時調用該方法。
SignalServer類的另一個目的是抽象我們的后端進程。我們的信號服務器是一個WebSocket服務器,因為它允許我們在服務器和客戶端之間進行基于事件的雙向通信,這使得它成為構建信號服務器的首選。
SignalServer類從其構造函數開始其操作:
constructor(channel) {
this.socket = new WebSocket("ws://localhost:80");
this.socket.addEventListener("open", () => {
this.postMessage({ type: "join-channel", channel });
});
this.socket.addEventListener("message", (e) => {
const object = JSON.parse(e.data);
if (object.type === "connection-established") console.log("connection established");
else if (object.type === "joined-channel") console.log("Joined channel: " + object.channel);
else this.onmessage({ data: object });
});
}
它首先初始化與后端的連接。當連接變為活動狀態時,它會向服務器發送一個我們用作join-channel請求的對象:
this.socket.addEventListener("open", () => {
this.postMessage({ type: "join-channel", channel });
});
現在,讓我們看一下我們的WebSocket服務器:
const { WebSocketServer } = require("ws");
const channels = {};
const server = new WebSocketServer({ port: 80 });
server.on("connection", handleConnection);
function handleConnection(ws) {
// I cut out the details because it's not in focus right now
}
這是一個非常標準的WebSocket服務器。我們有服務器初始化和事件偵聽器,用于新客戶端連接到服務器時。唯一的新功能是channels變量,我們使用它來存儲每個SignalServer對象加入的通道。
如果一個通道不存在并且一個對象想要加入該通道,我們希望服務器創建一個空數組,其中WebSocket連接作為第一個元素。然后,我們將該數組存儲為channels對象中帶有通道名稱的字段。
您可以在下面的message事件偵聽器中看到這一點。代碼看起來有點復雜,但上面的解釋是對代碼作用的一般概述:
// ... first rest of the code
ws.on('message', message => {
const object = JSON.parse(message);
if (object.type === "join-channel") {
channel = object.channel;
if (channels[channel] === undefined) channels[channel] = [];
id = channels[channel].length || 0;
channels[channel].push(ws);
ws.send(JSON.stringify({type: 'joined-channel', channel}));
// ... other rest of the code
之后,事件偵聽器向SignalServer對象發送joined-channel消息,告訴它加入通道的請求成功。
至于事件偵聽器的其余部分,它將任何不是join-channel類型的消息發送到通道中的其他SignalServer對象:
// rest of the event listener
} else {
// forward the message to other channel memebers
channels[channel]?.filter((_, i) => i !== id).forEach((member) => {
member.send(message.toString());
});
}
});
在handleConnection函數中,id和channel變量分別存儲SignalServer objectWebSocket連接在通道中的位置和SignalServer objectWebSocket連接存儲在其中的通道名稱:
let id;
let channel = ""; 讓id;讓頻道="";
這些變量是在SignalServer對象加入通道時設置的。它們有助于將來自一個SignalServer對象的消息傳遞給通道中的其他對象,正如您在else塊中看到的那樣。當SignalServer對象因任何原因斷開連接時,它們也有助于從通道中刪除它們:
ws.on('close', () => {
console.log('Client has disconnected!');
if (channel !== "") {
channels[channel] = channels[channel].filter((_, i) => i !== id);
}
});
最后,回到signalserverclass.js文件中的SignalServer類。讓我們看一下從WebSocket服務器接收消息的部分:
this.socket.addEventListener("message", (e) => {
const object = JSON.parse(e.data);
if (object.type === "connection-established") console.log("connection established");
else if (object.type === "joined-channel") console.log("Joined channel: " + object.channel);
else this.onmessage({ data: object });
});
如果您查看WebSocket服務器的handleConnection函數,服務器直接發送到SignalServer對象的消息類型有兩種:joined-channel和connection-established。這兩種消息類型由此事件偵聽器直接處理。
結論
在本文中,我們介紹了如何使用WebRTC構建P2P視頻流應用程序——它的主要用例之一。
我們從在單個頁面中創建對等連接開始,以便簡單了解WebRTC應用程序是如何工作的,而無需擔心信令。然后,我們談到了使用廣播頻道API進行信令。最后,我們構建了自己的singal服務器。
關注我,變得更強。
原文:https://blog.logrocket.com/webrtc-video-streaming/
作者:Oduah Chigozie