我使用 WebRTC 与你 - 使用 JavaScript 构建视频聊天
在最近的一个大学项目中,我们团队的任务是为我们的 iOS 和 Web 应用提供视频通话功能。市面上有很多承诺提供视频通话功能的解决方案,但只有少数是免费的,而且大多数只适用于单一平台。由于我们必须同时为 iOS和Web 构建这个功能,我们决定使用普通的 WebRTC,因为“应该没那么难吧 ¯\_(ツ)_/¯”
总结
我记得自己浏览过博客文章和教程,试图找到所需的最低限度步骤,最终甚至读完了Signal iOS 代码库。以下是开始使用 WebRTC 所需了解的基本要点(或者至少搜索一下你的项目中哪些地方不起作用):
- STUN 类似于
traceroute
:它收集您和 STUN 服务器之间的“跳数”;这些跳数被称为 ICE 候选 - ICE 候选对象基本上是
ip:port
成对的;您可以使用这些候选对象“联系”您的应用程序 - 你需要双工连接来在调用方之间交换数据。考虑使用 WebSocket 服务器,因为这是实现此目的最简单的方法
- 当一方“发现”ICE 候选时,通过 WebSocket/双工通道将其发送给另一方
- 获取设备的媒体曲目并将其添加到本地
RTCPeerConnection
- 在您的 上创建 WebRTC 报价
RTCPeerConnection
,并将其发送给对方 - 接收并使用该优惠,然后回复您的答案
如果这篇文章没有帮助你解决问题,或者你对 WebRTC 感兴趣,请继续阅读。我们先来了解一下什么是 WebRTC,然后再搭建一个小型视频聊天系统。
什么是 WebRTC?
我仅借用官方网站的“关于”部分:
WebRTC 是一个免费开放的项目,它通过简单的 API 为浏览器和移动应用程序提供实时通信 (RTC) 功能。WebRTC 组件已针对此目的进行了优化
。—— webrtc.org
简而言之,WebRTC 允许您构建使用点对点连接实时交换数据的应用程序。数据可以是音频、视频或任何您想要的数据。例如,Signal 通话是通过纯 WebRTC 进行的,并且由于其点对点的特性,大多数情况下无需通过第三方发送通话数据,例如像Skype 现在所做的那样。
眩晕
为了在两个呼叫方之间建立点对点连接,他们需要知道如何相互连接。这就是 STUN 的用武之地。如上所述,它类似于traceroute
。
在 JavaScript 中创建 WebRTC 客户端对象时,需要提供iceServerUrls
,它们本质上是 STUN 服务器的 URL。客户端会经过所有跳转,直到到达 STUN 服务器。以下序列图以简化的方式展示了它的工作原理:
候选人离 Alice 越“远”(到达她所需的跳数越多),其网络成本就越高。localhost:12345
比 更接近她public_ip:45678
,因此localhost
成本可能是 10,而public_ip
可能是 100。WebRTC 尝试建立具有最低网络成本的连接,以确保高带宽。
优惠、答案和轨迹
如果您想与朋友进行 FaceTime 通话,他们可能会有兴趣知道您如何呼叫他们,即他们想看看您是否仅使用音频或视频,或者甚至您是否根本没有使用 FaceTime 而只是通过座机呼叫他们。
WebRTC Offer与此类似:您可以指定在即将到来的连接中发送的内容。因此,当您 时peer.createOffer()
,它会检查哪些轨道(例如视频或音频)存在,并将其包含在 Offer 中。被叫方收到 Offer 后,它peer.createAnswer()
会指定自己的功能,例如是否同时发送音频和视频。
信号
WebRTC 的一个重要部分是在建立点对点连接之前交换信息。双方需要交换一个请求和答案,并且需要知道对方的 ICE 候选,否则他们将不知道该将音频和视频流发送到哪里。
这就是信令的作用所在:你需要将上述信息发送给双方。你可以使用任何方式来实现这一点,但最简单的方法是使用双工连接,例如 WebSockets 提供的连接。使用 WebSocket,只要信令服务器有更新,你就会收到“通知”。
典型的 WebRTC 握手如下所示:
首先,Alice 发出信号,她想打电话给 Bob,于是双方发起 WebRTC“握手”。他们都获取各自的ICE 候选,并通过信令服务器将其发送给对方。在某个时刻,Alice 创建一个 offer 并将其发送给 Bob。谁先创建offer并不重要(Alice 还是 Bob),但对方必须创建该offer的answer。由于 Alice 和 Bob 都知道如何联系对方以及需要发送哪些数据,因此点对点连接建立,他们可以开始对话。
构建它
现在我们知道了 WebRTC 的工作原理,接下来就“只需”构建它了。这篇文章将只关注如何使用 Web 客户端,如果评论区有人对 iOS 版本感兴趣,我会在另一篇文章中总结其中的陷阱。另外,我目前已经将 Web 客户端实现为一个 React hook useWebRTC
,我可能会为此专门写一篇文章。
服务器将使用 TypeScript,而 Web 应用将使用纯 JavaScript,这样就无需单独的构建过程。两者都只使用纯 WebSocket 和 WebRTC,没有什么特别之处。您可以在GitHub上找到本文的源代码。
服务器
我们将使用express
和express-ws
一堆其他库,您可以在package.json中找到它们。
WebSocket 通道
许多 WebSocket 库允许在通道 (channel)中发送数据。本质上, 通道 (channel) 只是消息中的一个字段(例如 like { channel: "foo", data: ... }
),允许服务器和应用程序区分消息所属的位置。
我们需要 5 个频道:
start_call
:表示应该开始通话webrtc_ice_candidate
:交换 ICE 候选人webrtc_offer
:发送 WebRTC 请求webrtc_answer
:发送 WebRTC 答案login
:让服务器知道你是谁
浏览器端的 WebSocket 实现缺乏发送用户身份的功能,例如,Authorization
无法在标头中添加包含令牌的信息。我们可以通过 WebSocket 的 URL 将令牌作为查询参数添加,但这意味着它会被记录到 Web 服务器上,并可能被缓存在浏览器中——我们不希望出现这种情况。
相反,我们将使用一个单独的login
通道,只发送我们的名字。这可以是令牌或其他任何东西,但为了简单起见,我们假设我们的名字足够安全且唯一。
由于我们使用 TypeScript,我们可以轻松地为消息定义接口,因此我们可以安全地交换消息而不必担心拼写错误:
interface LoginWebSocketMessage {
channel: "login";
name: string;
}
interface StartCallWebSocketMessage {
channel: "start_call";
otherPerson: string;
}
interface WebRTCIceCandidateWebSocketMessage {
channel: "webrtc_ice_candidate";
candidate: RTCIceCandidate;
otherPerson: string;
}
interface WebRTCOfferWebSocketMessage {
channel: "webrtc_offer";
offer: RTCSessionDescription;
otherPerson: string;
}
interface WebRTCAnswerWebSocketMessage {
channel: "webrtc_answer";
answer: RTCSessionDescription;
otherPerson: string;
}
// these 4 messages are related to the call itself, thus we can
// bundle them in this type union, maybe we need that later
type WebSocketCallMessage =
StartCallWebSocketMessage
| WebRTCIceCandidateWebSocketMessage
| WebRTCOfferWebSocketMessage
| WebRTCAnswerWebSocketMessage;
// our overall type union for websocket messages in our backend spans
// both login and call messages
type WebSocketMessage = LoginWebSocketMessage | WebSocketCallMessage;
由于我们在这里使用了联合类型,稍后我们可以通过检查属性来使用 TypeScript 编译器识别收到的消息类型channel
。如果是message.channel === "start_call"
,编译器就会推断该消息的类型一定是StartCallWebSocketMessage
。简洁。
公开 WebSocket
我们将使用express-ws
从我们的服务器公开 WebSocket,该服务器恰好是一个快速应用程序,通过以下方式提供服务http.createServer()
:
const app = express();
const server = createServer(app);
// serve our webapp from the public folder
app.use("/", express.static("public"));
const wsApp = expressWs(app, server).app;
// expose websocket under /ws
// handleSocketConnection is explained later
wsApp.ws("/ws", handleSocketConnection);
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(`server started on http://localhost:${port}`);
});
我们的应用程序现在将在端口 3000(或我们通过提供的任何端口PORT
)上运行,公开一个 WebSocket/ws
并从public
目录中为我们的 web 应用程序提供服务。
用户管理
由于视频通话通常需要 1 人以上参与,我们还需要跟踪当前连接的用户。为此,我们可以引入一个数组connectedUsers
,每当有人连接到 WebSocket 时,我们都会更新该数组:
interface User {
socket: WebSocket;
name: string;
}
let connectedUsers: User[] = [];
此外,为了方便起见,我们应该添加辅助函数来通过用户姓名或套接字来查找用户:
function findUserBySocket(socket: WebSocket): User | undefined {
return connectedUsers.find((user) => user.socket === socket);
}
function findUserByName(name: string): User | undefined {
return connectedUsers.find((user) => user.name === name);
}
在本文中,我们假设没有恶意行为者。因此,每当一个套接字连接上时,都表示有人试图尽快给其他人打电话。我们的handleSocketConnection
情况大致如下:
function handleSocketConnection(socket: WebSocket): void {
socket.addEventListener("message", (event) => {
const json = JSON.parse(event.data.toString());
// handleMessage will be explained later
handleMessage(socket, json);
});
socket.addEventListener("close", () => {
// remove the user from our user list
connectedUsers = connectedUsers.filter((user) => {
if (user.socket === socket) {
console.log(`${user.name} disconnected`);
return false;
}
return true;
});
});
}
WebSocket 消息可以是字符串或Buffer
s,所以我们需要先解析它们。如果是 s Buffer
,调用toString()
会将其转换为字符串。
转发消息
我们的信令服务器本质上是在调用双方之间转发消息,如上面的序列图所示。为此,我们可以创建另一个便捷函数forwardMessageToOtherPerson
,它将传入的消息发送到otherPerson
消息中指定的地址。为了方便调试,我们甚至可以将otherPerson
字段替换为发送原始消息的发送者:
function forwardMessageToOtherPerson(sender: User, message: WebSocketCallMessage): void {
const receiver = findUserByName(message.otherPerson);
if (!receiver) {
// in case this user doesn't exist, don't do anything
return;
}
const json = JSON.stringify({
...message,
otherPerson: sender.name,
});
receiver.socket.send(json);
}
在我们的 中handleMessage
,我们可以登录用户,并可能将他们的消息转发给其他人。请注意,所有与通话相关的消息都可以合并到default
语句中,但为了让日志记录更有意义,我明确地将每个频道都放在那里:
function handleMessage(socket: WebSocket, message: WebSocketMessage): void {
const sender = findUserBySocket(socket) || {
name: "[unknown]",
socket,
};
switch (message.channel) {
case "login":
console.log(`${message.name} joined`);
connectedUsers.push({ socket, name: message.name });
break;
case "start_call":
console.log(`${sender.name} started a call with ${message.otherPerson}`);
forwardMessageToOtherPerson(sender, message);
break;
case "webrtc_ice_candidate":
console.log(`received ice candidate from ${sender.name}`);
forwardMessageToOtherPerson(sender, message);
break;
case "webrtc_offer":
console.log(`received offer from ${sender.name}`);
forwardMessageToOtherPerson(sender, message);
break;
case "webrtc_answer":
console.log(`received answer from ${sender.name}`);
forwardMessageToOtherPerson(sender, message);
break;
default:
console.log("unknown message", message);
break;
}
}
服务器部分就是这样。当有人连接到套接字时,他们可以登录,并且一旦他们启动 WebRTC 握手,消息就会转发给他们正在呼叫的人。
Web 应用程序
index.html
该 Web 应用由和一个 JavaScript 文件组成web.js
。如上所示,它们均由public
应用目录提供。Web 应用最重要的部分是两个<video />
标签,用于显示本地和远程视频流。为了获得一致的视频源,autoplay
需要在视频上设置 ,否则它将卡在初始帧:
<!DOCTYPE html>
<html>
<body>
<button id="call-button">Call someone</button>
<div id="video-container">
<div id="videos">
<video id="remote-video" autoplay></video>
<video id="local-video" autoplay></video>
</div>
</div>
<script type="text/javascript" src="web.js"></script>
</body>
</html>
连接到信令服务器
我们的 WebSocket 与 Web 应用监听同一台服务器,因此我们可以利用location.host
包含主机名和端口的 来构建套接字 URL。连接后,我们需要登录,因为 WebSocket 不提供额外的身份验证方式:
// generates a username like "user42"
const randomUsername = `user${Math.floor(Math.random() * 100)}`;
const username = prompt("What's your name?", randomUsername);
const socketUrl = `ws://${location.host}/ws`;
const socket = new WebSocket(socketUrl);
// convenience method for sending json without calling JSON.stringify everytime
function sendMessageToSignallingServer(message) {
const json = JSON.stringify(message);
socket.send(json);
}
socket.addEventListener("open", () => {
console.log("websocket connected");
sendMessageToSignallingServer({
channel: "login",
name: username,
});
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data.toString());
handleMessage(message);
});
设置 WebRTC
现在,我们翘首以盼的 WebRTC 终于来了。JavaScript 中有一个RTCPeerConnection
类,可以用来创建 WebRTC 连接。我们需要提供用于 ICE 候选发现的服务器,例如stun.stunprotocol.org
:
const webrtc = new RTCPeerConnection({
iceServers: [
{
urls: [
"stun:stun.stunprotocol.org",
],
},
],
});
webrtc.addEventListener("icecandidate", (event) => {
if (!event.candidate) {
return;
}
// when we discover a candidate, send it to the other
// party through the signalling server
sendMessageToSignallingServer({
channel: "webrtc_ice_candidate",
candidate: event.candidate,
otherPerson,
});
});
发送和接收媒体轨道
视频通话在有视频的情况下效果最佳,所以我们需要以某种方式发送视频流。这时,用户媒体 API 就派上用场了,它提供了一个函数来检索用户的网络摄像头流。
navigator
.mediaDevices
.getUserMedia({ video: true })
.then((localStream) => {
// display our local video in the respective tag
const localVideo = document.getElementById("local-video");
localVideo.srcObject = localStream;
// our local stream can provide different tracks, e.g. audio and
// video. even though we're just using the video track, we should
// add all tracks to the webrtc connection
for (const track of localStream.getTracks()) {
webrtc.addTrack(track, localStream);
}
});
webrtc.addEventListener("track", (event) => {
// we received a media stream from the other person. as we're sure
// we're sending only video streams, we can safely use the first
// stream we got. by assigning it to srcObject, it'll be rendered
// in our video tag, just like a normal video
const remoteVideo = document.getElementById("remote-video");
remoteVideo.srcObject = event.streams[0];
});
执行 WebRTC 握手
我们的handleMessage
函数严格遵循上面的序列图:当 Bob 收到start_call
消息时,他会向信令服务器发送一个 WebRTC 请求。Alice 收到该请求后,会回复她的 WebRTC 应答,Bob 也会通过信令服务器收到该应答。完成后,双方交换 ICE 候选集。
WebRTC API 是围绕Promise
s 构建的,因此最简单的方法是声明一个async
函数并await
在其中:
// we'll need to have remember the other person we're calling,
// thus we'll store it in a global variable
let otherPerson;
async function handleMessage(message) {
switch (message.channel) {
case "start_call":
// done by Bob: create a webrtc offer for Alice
otherPerson = message.otherPerson;
console.log(`receiving call from ${otherPerson}`);
const offer = await webrtc.createOffer();
await webrtc.setLocalDescription(offer);
sendMessageToSignallingServer({
channel: "webrtc_offer",
offer,
otherPerson,
});
break;
case "webrtc_offer":
// done by Alice: react to Bob's webrtc offer
console.log("received webrtc offer");
// we might want to create a new RTCSessionDescription
// from the incoming offer, but as JavaScript doesn't
// care about types anyway, this works just fine:
await webrtc.setRemoteDescription(message.offer);
const answer = await webrtc.createAnswer();
await webrtc.setLocalDescription(answer);
sendMessageToSignallingServer({
channel: "webrtc_answer",
answer,
otherPerson,
});
break;
case "webrtc_answer":
// done by Bob: use Alice's webrtc answer
console.log("received webrtc answer");
await webrtc.setRemoteDescription(message.answer);
break;
case "webrtc_ice_candidate":
// done by both Alice and Bob: add the other one's
// ice candidates
console.log("received ice candidate");
// we could also "revive" this as a new RTCIceCandidate
await webrtc.addIceCandidate(message.candidate);
break;
default:
console.log("unknown message", message);
break;
}
}
通过按钮开始通话
我们目前还缺少的关键功能是如何通过“呼叫某人”按钮发起呼叫。我们需要做的就是start_call
向信令服务器发送一条消息,其他一切都将由 WebSocket 处理,并且handleMessage
:
const callButton = document.getElementById("call-button");
callButton.addEventListener("click", () => {
otherPerson = prompt("Who you gonna call?");
sendMessageToSignallingServer({
channel: "start_call",
otherPerson,
});
});
结论
如果我们同时在 Chrome 和 Safari 上打开该应用,就可以在不同的浏览器上调用我们自己的应用了。这太酷了!
除了调用之外,还有很多操作没有在这篇文章中涉及,例如清理连接,我可能会在以后的文章中介绍(例如使用 React Hooks 实现 WebRTC 和 WebSockets)。欢迎查看代码库,在那里你也可以追溯这篇文章中介绍的所有内容。感谢阅读!
鏂囩珷鏉ユ簮锛�https://dev.to/michaelneu/i-webrtc-you-building-a-video-chat-in-javascript-2j38