约翰·巴基普( John Barkiple)在Unsplash上的照片
WebRTC(Web 实时通信)是一个开源项目,它允许你在浏览器之间创建点对点连接。此连接可用于不同目的,主要目的是保证高质量和高性能的视频通话。本文是我们使用 WebRTC 创建此类视频聊天的系列文章的第三篇。
以下是本系列文章的链接:
1.来自网络摄像头和麦克风的数据流(访问用户设备以及数据流,并在浏览器上显示视频(带音频))
2.通过 WebSocket 建立连接(在两个用户之间通过 WebSocket 建立 P2P 连接)
3.建立 WebRTC 连接( 建立 WebRTC 连接,真正开启视频聊天)
4.查找联系人(调整 WebSocket 服务器和客户端代码,使用户只有输入相同代码才会建立彼此联系)
5.与 WebRTC 共享您的屏幕(将 RTCPeerConnection 对象的视频轨道替换为显示媒体流的视频轨道,使用户之间共享屏幕)
在第一篇文章中,我们在浏览器中访问了来自用户网络摄像头和麦克风的视频和音频流。在第二篇文章中,我们通过 WebSocket 启用了两个客户端的通信。我们将针对信令过程调整此示例。
在本文中,我们将真正开启视频聊天。
信令服务器
为了建立点对点连接,首先必须交换他们想要共享的媒体类型,告诉对方何时开始或停止通信,并且他们需要在网络上找到对方,这就是信令过程。
信令不是 WebRTC 规范的一部分。这意味着你必须注意交换建立和控制连接所需的消息。这也意味着你可以自由使用任何想要的通信机制。理论上,你可以为此使用电子邮件,但合理的解决方案是使用 WebSocket。这就是我们在上一篇文章中创建 WebSocket 服务器的原因,现在我们将对信号稍作调整。
信令机制不需要知道关于正在交换的消息的任何信息。我们简化了之前创建的 WebSocket 服务器,如需帮助使其在 Node 上运行,可点击此链接。
const http = require('http');
const server = require('websocket').server;
const httpServer = http.createServer(() => { });
httpServer.listen(1337, () => {
console.log('Server listening at port 1337');
});
const wsServer = new server({
httpServer,
});
let clients = [];
wsServer.on('request', request => {
const connection = request.accept();
const id = (Math.random() * 10000);
clients.push({ connection, id });
connection.on('message', message => {
console.log(message);
clients
.filter(client => client.id !== id)
.forEach(client => client.connection.send(message.utf8Data));
});
connection.on('close', () => {
clients = clients.filter(client => client.id !== id);
});
});
我们跟踪所有连接的客户。当客户端发送消息时,消息会发送给所有人。这不是最终版本,但足以建立 WebRTC 连接。在下一篇文章中,我们将对其进行改进,以允许用户找到他们想与之聊天的人,并且只与对方一人交流。
申请和回应
必须通过信令机制交换三种类型的消息:
- 媒体数据:想要共享哪种类型的媒体(仅音频或视频),具有哪些限制(例如质量)。
- 会话控制数据:打开和关闭通信。
- 网络数据:用户需要获取彼此的IP地址和端口,并检查是否可以建立对等连接。
假设我们称用户为 Alice 和 Bob。Alice 必须首先创建一个连接申请并将其发送给 Bob:
申请
- 如果还没有与 Bob 使用某个通信通道,Alice 应该加入其中一个(我们使用运行在端口 1337 上的 WebSocket 服务器)。
const signaling = new WebSocket('ws://127.0.0.1:1337');
- Alice在她的浏览器中创建一个RTCPeerConnection对象。它是一个 JavaScript 接口,是 WebRTC API 的一部分,代表本地浏览器和远程对等点之间的连接。
const peerConnection = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.test.com:19000' }],
});
传递给构造函数的参数包含 ICE 代理所需的服务器 URL。你可在稍后或点击此处了解更多信息。
- Alice 将她想要通过连接共享的轨道(音频和视频)添加到她的 RTCPeerConnection 对象。
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
stream.getTracks().forEach(track => peerConnection.addTrack(
track,
stream,
));
- Alice 创建一个SDP申请。SDP 代表会话描述协议。
const offer = await peerConnection.createOffer();
它是用于描述通信参数的格式,包含媒体描述和网络信息,具体如下:
v=0
o=alice 123456789 123456789 IN IP4 some-host.com
s=-
c=IN IP4 some-host.com
t=0 0
m=audio 49170 RTP/AVP 0
a=rtpmap:0 PCMU/8000
m=audio 49170 RTP/AVP 31
a=rtpmap:31 H261/90000
m=audio 49170 RTP/AVP 32
a=rtpmap:32 MPV/90000
- Alice 通过调用setLocalDescription() 将连接的本地描述设置为此 SDP 。
await peerConnection.setLocalDescription(offer);
- Alice 通过信令服务器将此申请发送给 Bob。
signaling.send(JSON.stringify({
message_type: MESSAGE_TYPE.SDP,
content: offer,
}));
回应
Bob 还必须连接到信令服务器,并且必须创建一个 RTCPeerConnection 对象。在 Alice 向他发送申请后,Bob 必须执行以下操作:
- Bob 收到 Alice 的提议并调用setRemoteDescription()将 其设置为 RTCPeerConnection 对象中的远程描述。
await peerConnection.setRemoteDescription(offerFromAlice);
- Bob 创建一个 SDP 回应,其中包含与 Alice 发送的 SDP 申请相同类型的信息。
const answer = await peerConnection.createAnswer();
- Bob 通过调用setLocalDescription() 将连接的本地描述设置为此 SDP 。
await peerConnection.setLocalDescription(answerFromBob);
- Bob 通过信令机制将此答复发送给 Alice。
signaling.send(JSON.stringify({
message_type: MESSAGE_TYPE.SDP,
content: answerFromBob,
}));
我们现在回到Alice这边。她收到 Bob 的回答并调用setRemoteDescription() 将其设置为她的 RTCPeerConnection 对象中的远程描述。
await peerConnection.setRemoteDescription(answerFromBob);
Alice 和 Bob 现在已经交换了媒体数据,并通知彼此他们想要开始视频聊天。他们现在必须共享网络信息以建立直接连接,这并不像听起来那么容易,但幸运的是 ICE 框架可以帮助我们。
ICE备案
由于历史上缺乏 IP 地址(IPv4 只有大约 40 亿个地址可用),用户通常隐藏在 NAT(网络地址转换)网关之后。ICE(交互式连接建立)框架允许对等方发现和通信其公共 IP 地址。这要归功于 STUN 服务器,我们将其 URL 作为 RTCPeerConnection 对象中的参数提供。由于对等方的网络配置,直接连接是不可能的,在这种情况下,连接必须通过中继服务器或 TURN 服务器进行,服务器也必须作为参数提供给 RTCPeerConnection。
const peerConnection = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.test.com:19000' },
{ urls: 'turn:turn:19001' },
],
});
ICE 代理会为我们处理这种探索和决策,检查直接连接的可能性,如果无法完成,则通过 TURN 服务器(如果已提供)建立连接。
Alice 和 Bob 只需要监听RTCPeerConnection 的事件icecandidate。每次找到 ICE 备案时都会触发它。然后,他们应该将备案发送给对方。
peerConnection.onicecandidate = (iceEvent) => {
signaling.send(JSON.stringify({
message_type: MESSAGE_TYPE.CANDIDATE,
content: iceEvent.candidate,
}));
};
当收到对方的备案时,Alice 和 Bob 应该将其传递给他们RTCPeerConnection 对象的 ICE 代理。
await peerConnection.addIceCandidate(content);
ICE 代理将负责协商并最终确定连接。如果你想了解有关 NAT 和 ICE 的更多详细信息,可以查看此篇文章。
建立连接后,开始通过连接交换轨道数据。你可以实现ontrack 事件处理程序来显示它们:
peerConnection.ontrack = (event) => { const video = document.getElementById('remote-view'); if (!video.srcObject) { video.srcObject = event.streams[0]; } };
客户代码
我们的页面看起来如下:
单击开始按钮后,你会在左上角看到自己,但在建立连接之前不会看到你的联系人。一旦成功后,你就可以开始聊天了。
在客户端应用程序的文件夹中,创建一个index.html 和一个style.css 文件并复制此代码。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>VideoChat</title>
<script src="index.js"></script>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="chat-room" style="display: none;">
<div id="videos">
<video id="self-view" autoplay ></video>
<video id="remote-view" autoplay></video>
</div>
</div>
<button id="start">Start Video Chat</button>
</body>
</html>
html, body {
min-height: 100% !important;
height: 100%;
margin: 0;
}
body {
background-color:#01284a;
padding: 8px;
}
button {
display: block;
margin: auto;
position: relative;
top: 40%;
padding: 10px;
font-size: 16px;
background-color: #000f1c;
border-radius: 3px;
color: white;
font-weight: bold;
cursor: pointer;
}
video {
width: 100%;
}
#videos {
display: grid;
grid-gap: 16px;
width: 100%;
grid-template-columns: 1fr 3fr;
}
#self-view {
grid-column: 1;
background-color: black;
}
#remote-view {
grid-column: 2;
background-color: black;
height: 95vh;
}
你还需要一个用于 JavaScript的index.js文件 。这是最后的index.js 文件:
(function () {
"use strict";
const MESSAGE_TYPE = {
SDP: 'SDP',
CANDIDATE: 'CANDIDATE',
}
document.addEventListener('click', async (event) => {
if (event.target.id === 'start') {
startChat();
}
});
const startChat = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
showChatRoom();
const signaling = new WebSocket('ws://127.0.0.1:1337');
const peerConnection = createPeerConnection(signaling);
addMessageHandler(signaling, peerConnection);
stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
document.getElementById('self-view').srcObject = stream;
} catch (err) {
console.error(err);
}
};
const createPeerConnection = (signaling) => {
const peerConnection = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.test.com:19000' }],
});
peerConnection.onnegotiationneeded = async () => {
await createAndSendOffer();
};
peerConnection.onicecandidate = (iceEvent) => {
if (iceEvent && iceEvent.candidate) {
signaling.send(JSON.stringify({
message_type: MESSAGE_TYPE.CANDIDATE,
content: iceEvent.candidate,
}));
}
};
peerConnection.ontrack = (event) => {
const video = document.getElementById('remote-view');
if (!video.srcObject) {
video.srcObject = event.streams[0];
}
};
return peerConnection;
};
const addMessageHandler = (signaling, peerConnection) => {
signaling.onmessage = async (message) => {
const data = JSON.parse(message.data);
if (!data) {
return;
}
const { message_type, content } = data;
try {
if (message_type === MESSAGE_TYPE.CANDIDATE && content) {
await peerConnection.addIceCandidate(content);
} else if (message_type === MESSAGE_TYPE.SDP) {
if (content.type === 'offer') {
await peerConnection.setRemoteDescription(content);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
signaling.send(JSON.stringify({
message_type: MESSAGE_TYPE.SDP,
content: answer,
}));
} else if (content.type === 'answer') {
await peerConnection.setRemoteDescription(content);
} else {
console.log('Unsupported SDP type.');
}
}
} catch (err) {
console.error(err);
}
};
};
const createAndSendOffer = async (signaling, peerConnection) => {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
signaling.send(JSON.stringify({ message_type: MESSAGE_TYPE.SDP, content: offer }));
};
const showChatRoom = () => {
document.getElementById('start').style.display = 'none';
document.getElementById('chat-room').style.display = 'block';
};
})();
首先,我们定义用户可以期望接收的消息类型。第一个是“SDP”,用于申请和回答。第二个是针对 ICE 备案的。
单击开始页面按钮,开始聊天,这就是我们在第 9 到 13 行所做的。
现在让我们看看startChat 函数。我们首先从摄像头和麦克风请求数据。这应该会触发来自浏览器的访问请求,你必须接受该请求才能继续操作:
一旦你选择接受,我们就会显示聊天室,显示视频元素并隐藏开始按钮。我们建立到 WebSocket 服务器的连接(第 20 行)并调用此连接信令。我们在createPeerConnection 函数中创建 RTCPeerConnection 对象。它提供了一个 STUN 服务器作为参数(它是一个假的服务器,你可以用公共 STUN 服务器替换它)并定义了我们讨论过的两个事件处理程序:onicecandidate 将 ICE 备案发送给对等方,ontrack 将接收到的轨道设置到我们的视频 HTML 元素中。它有一个额外的事件处理程序,它实际上是一个非常重要的事件处理程序:onnegationneeded .。当我们向连接添加轨道时会触发此事件,稍后发生需要重新协商的事情时会触发此事件,信令交换将真正开始。
回到startChat 函数,在创建了 RTCPeerConnection 对象之后,我们在addMessageHandler 函数中定义接收消息时要做什么*。* 如果我们收到备案,我们会像前面描述的那样将其交给 ICE 代理。如果我们收到一个申请,我们设置远程描述,创建一个回复,将答复保存为本地描述并将其发送给对等方。当我们收到答复时,我们只是将其设置为本地申请。
然后我们将我们的本地轨道设置为 RTCPeerConnection 对象,并将它们显示为对应的视频元素中。在对等连接对象上设置轨道将触发negogationneeded 事件,事件侦听器将调用createAndSendOffer 函数。
启动 WebSocket 服务器并在两个不同的选项卡中打开客户端。在两个页面上点击“开始”后,你可以与自己进行交流。
借助 WebRTC,我们现在已经建立了连接。我们的解决方案仍然不是最优的。我们处理了信令过程,但目前没有办法允许两个给定的用户进行仅两人的相互通话。连接是由前两个用户单击按钮建立的。在下一篇文章中,我们将通过调整我们的信令服务器来解决这个问题。
原文作者 Heloise Parein
原文链接https://levelup.gitconnected.com/establishing-the-webrtc-connection-videochat-with-javascript-step-3-48d4ae0e9ea4