使用 WebRTC、PeerJS、Vue 和 javascript vanilla cookies 不到 3 小时
今天的互联网基础设施依赖于数据中心,这会产生成本和环境影响。更具体地说,数据中心负责大约。占世界能源消耗的4%,而且还在不断增长。但是,如果没有每天按需狂欢,您将如何度过一天?
原文连接:https://levelup.gitconnected.com/multichat-a-secure-serverless-persistent-and-real-time-chat-proof-of-concept-22d3dddc0345
如果我们可以在不需要服务器的情况下通信/交换数据怎么办?如果个人数据可以存储在本地,远离大公司的数据中心,会怎样?
幸运的是,点对点连接是互联网的基础,有些人比我更早想到它;这导致了(除其他外)WebRTC的发展。在本文中,我将介绍 WebRTC 的优势,并通过简单、易用、安全、几乎无服务器、持久的实时聊天概念验证来展示其强大功能。然而,WebRTC 也有其自身的局限性;但这是另一个后来的故事。
我能看懂这篇文章吗?
在这篇文章中,我涵盖了大型全栈开发主题,这些主题需要一些互联网通信、javascript 数据和状态管理以及显然是 HTML/CSS 的知识。如果您没有全部,您将学到一些有用的提示和技巧!
挑战
该项目的目标是简化和展示浏览器中无服务器数据交换的强大功能、安全性/机密性和持久性。以下是我选择的标准的完整列表:
- 带用户名的基本聊天应用程序
- 任何用户都必须能够加入任何其他用户(在聊天室中)
- 安全性:数据交换必须加密
- 持久性:数据必须在整个会话中保存(用户名、聊天)
- 无服务器:数据必须在不需要服务器的情况下在对等点之间交换
- 简单性:任何人都必须能够在不了解任何东西的情况下使用这个项目
解决方案
我设计了最简单的解决方案来满足以前的要求,使用:
- WebRTC,用于安全、可靠和无服务器的数据交换
- Vue.js,用于快速和轻量级的动态渲染和状态管理
- PeerJS,用于简化 WebRTC 的开发
- MaterializeCSS,用于快速和现代的设计
- JavaScript localStorage,用于数据持久化
WebRTC 是 Google 的开源基础,用于浏览器中的对等通信,使用QUIC 协议(最新、快速且可靠的传输层 网络协议)。几乎所有像样的浏览器都支持它,并提供了一种在 2 个浏览器(对等)之间建立数据通道的方法,支持任何类型的数据,包括实时音频和视频。
PeerJS 提供了一种将客户端连接到服务器端点的方法,等待来自其他客户端的对等连接。建立连接后,两个对等方将通过 WebRTC 数据通道进行通信,只有他们可以从中读取/写入。
编码
让我们从创建网页的骨架开始:因为我们想要一个单页应用程序,我们将把所有的 CSS 代码和JS 代码写在一个 index.html 文件中。我们还需要在我们的 html 中导入每个依赖项。我们的身体将包含一个主要的#app div。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
<!DOCTYPE html> <html lang="en"> <head> <title>Serverless, secure & persistent multichat</title> <!-- To efficiently implement sockets --> <script src="https://unpkg.com/peerjs@1.2.0/dist/peerjs.min.js"></script> <!-- To easily manage dynamic rendering --> <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <!-- Integrate font styles --> <link href="https://fonts.googleapis.com/css2?family=Red+Hat+Display&display=swap" rel="stylesheet"> <!-- Material Design stylesheets --> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"> <script async src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> <style> </style> </head> <body> <div id="app"> </div> </body> <script> </script> </html> view rawindex.html hosted with ❤ by GitHub |
然后,我们需要为用户管理 2 个屏幕:登录屏幕和聊天屏幕(因为我们不希望用户在登录后更改其用户名)。为此,我选择在我的应用程序状态中存储一个屏幕变量,该值指示要显示的屏幕。
你不应该这样做!通常,我们为 2 个不同的屏幕中的每一个创建 2 个不同的组件,并使用 Vue 的路由器在它们之间切换。为了简单起见,我选择简单地创建一个组件来允许我直接访问我应用程序的所有状态!
因此,我们可以创建我们的 2 个屏幕,我们将它们放在#app 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
<div id="login" v-if="screen === 'login'"> <div class="container"> <h1>Enter your username</h1> <div class="row"> <form v-on:submit="submitLogin"> <div class="input-field col s8"> <input :disabled="loading" id="username" type="text" v-model="usernameInput" /> <label for="username">My username</label> </div> <button v-bind:class="{ disabled: loading }" class="btn-large waves-effect waves-light red col s4" type="submit"> Login<i class="material-icons right">login</i> </button> </form> </div> <p v-if="peerError" style="color: red">{{ peerError }}</p> </div> </div> <div id="chat" v-if="screen === 'chat'"> <div class="container"> <h1>Multichat</h1> <p>Your username: {{ usernameInput }}</p> </div> <div class="row"> <div id="users" class="col s12 m5"> <div class="row"> <form v-on:submit="submitConnection"> <div class="input-field col s8"> <input :disabled="loading" id="target_id" type="text" v-model="targetIdInput" /> <label for="target_id">Target username</label> </div> <button v-bind:class="{ disabled: loading }" class="btn-large waves-effect waves-light col s4" type="submit"> Connect<i class="material-icons right">login</i> </button> </form> <div class="col s12"> <em v-if="peerError" style="color: red">{{ peerError }}</em> </div> <div class="col s12"> <h4>Connected users</h4> <p><i class="material-icons">portrait</i>{{ usernameInput }}</p> <p v-for="peerId in peerIds"> <i class="material-icons">portrait</i>{{ getUsername(peerId) }} </p> </div> </div> </div> <div class="col s12 m7"> <h4>Chatbox</h4> <div id="chatbox"> <p v-for="chat in chats">{{ chat.sender }}: {{ chat.message }}</p> </div> <div class="row"> <form v-on:submit="submitChat"> <div class="input-field col s10"> <input id="chat_message" type="text" v-model="chatMessageInput" /> <label for="chat_message">Your message</label> </div> <button class="btn-floating btn-large waves-effect waves-light red" type="submit"> <i class="material-icons">send</i> </button> </form> </div> </div> </div> </div> |
如果忽略所有样式类和图标,您会发现我们的屏幕中使用了一些 Vue 变量和函数:
- screen:包含显示屏幕名称的变量
- usernameInput : 用户输入的用户名(登录后必须保持不变)
- peerError:发生错误时显示的消息
- loading:一个布尔值,指示应用程序是否正在加载
- targetIdInput:要连接到的目标用户名输入
- peerIds:我们连接到的对等 ID 列表
- chats:聊天对象列表(发件人,消息)
- chatMessageInput : 用户当前输入的聊天消息
- submitLogin:在登录表单提交时调用,并触发适当的 javascript 事件
- submitConnection:在提交连接表单时调用,以连接到目标客户端 ( targetIdInput )
- submitChat:发送聊天消息时调用(通过聊天表单提交)
有了所有这些变量和函数,我们就可以运行我们的应用程序了。让我们编写应用程序的实例化和渲染逻辑!首先,我们需要考虑它是如何工作的:
- 每个用户都有一个用户名,每个对等点(浏览器)都有一个 ID,用于在 PeerJS 使用的信令服务器上唯一标识它。用户名和对等 ID 会很方便。但是为了确保大规模的唯一性和可用性(信令服务器不仅由我们使用),我们必须为每个用户名添加一个唯一的前缀,以便我们获得几乎唯一的对等 ID。
为此,我们只需编写 2 个实用函数,我们可以使用它们将对等 ID 转换为用户名,反之亦然。我们将它们编写为 Vue 组件的方法,以便能够在渲染时使用它们。 - 我们想要持久的聊天,我们选择将数据保存为 cookie。不幸的是,cookie 只允许保存字符串。
多么巧合,JavaScript 本身就允许我们序列化和反序列化 JSON 对象!让我们从“聊天”cookie 中读取之前存储的聊天记录。最终,我们还可以使用 cookie 来存储我们之前使用的用户名,以便在我们的下一个会话中启动它(这就是下面所做的!) - 我们希望我们的聊天框在收到新聊天消息时自动向下滚动:每当聊天变量更新时,我们需要将聊天框滚动到最大位置。为此,我们使用 Vue 的观察者,将观察者命名为chats()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
const appPrefix = "secure-p2p-multichat-"; // the prefix we will preprend to usernames const oldChats = localStorage.getItem("chats"); const chats = oldChats ? JSON.parse(oldChats) : []; // oldChats may be undefined, which throws error if passed into JSON.parse const app = new Vue({ el: "#app", data: { screen: "login", // initialize at login screen usernameInput: localStorage.getItem("username"), // to load saved username peerError: "", loading: false, peer: {}, // initialize as empty object instead of undefined targetIdInput: "", peerIds: [], // connected to nobody at first connections: {}, // maps peerIds to their correspondig PeerJS's DataConnection objects chats, chatMessageInput: "" }, watch: { chats: function () { const chatbox = document.getElementById("chatbox"); if (chatbox) chatbox.scrollTop = 99999999; // to automatically scroll the chatbox to the most recent chat message } }, methods: { // util functions to convert username to peer ids and vice-versa getPeerId: username => appPrefix + username, getUsername: peerId => peerId ? peerId.slice(appPrefix.length) : "", submitLogin: function (event) { if (event) event.preventDefault(); // to prevent default behavior which is to POST to the same page if (this.usernameInput.length > 0 && !this.loading) { this.loading = true; // update status this.peerError = ""; // reset error status localStorage.setItem("username", this.usernameInput); // set username cookie to instanciate it at the next session this.createPeer(); } }, submitConnection: function (event) { event.preventDefault(); // to prevent default behavior which is to POST to the same page const peerId = this.getPeerId(this.targetIdInput); // the peer's id we want to connect to this.initiateConnection(peerId); }, receiveChat: function (chat) { this.chats.push(chat); localStorage.setItem("chats", JSON.stringify(this.chats)); }, submitChat: function (event) { event.preventDefault(); // to prevent default behavior which is to POST to the same page if (this.chatMessageInput.length > 0) { // the chat object's data const chat = { sender: this.usernameInput, message: this.chatMessageInput, timestamp: new Date().getTime() }; this.receiveChat(chat); // simulate receiving a chat // send chat object to connected users Object.values(this.connections).forEach(conn => { conn.send({ type: "chat", chat }); }); this.chatMessageInput = ""; // reset chat message input } } } }); |
快到了!
让我们通过实现应用程序的后台逻辑、实例化和连接对等点来完成这个简单的应用程序。但首先,让我们考虑一下:
- 我们必须跟踪我们拥有的每个活动连接并将它们映射到相应的远程对等 ID。
但是我们已经存储了活动连接的远程对等 ID!不是重装吗?
不幸的是,Vue 没有提供一种直接的方法来跟踪赋予 Vue 数据对象的任何新属性。我的选择是将远程对等 ID 映射到 Vue 数据对象 ( connections ) 中的相应连接,以及在另一个 Vue 数据数组 ( peerIds ) 中独立映射远程对等 ID(更新触发 Vue 组件渲染)。另一种选择是在每次添加连接时注册我们添加到连接的属性(使用Vue.set) ,这将触发组件渲染。 - 我们必须考虑同伴关系;为了简单起见,我们这里没有接受/拒绝连接。
让我们考虑聊天室A中的Alice和Everyone以及聊天室B中的Bob和Carole。一个连接过程就是:
- 聊天室A 的Alice向聊天室B的Bob发送一个报价,其中包含聊天室A中已连接用户的列表([所有人])
- Bob回答了Alice的提议,并从Bob未连接的聊天室A向Everyone发送提议,并在聊天室B中列出了已连接的用户([ Carole ])
- Alice现在连接到Bob并向Carole发送报价,其中包含Alice连接到的用户列表([ Bob, Everyone ])
- Everyone回答Bob的 offer,附上 Everyone连接到的用户列表([ Alice ])
- Carole回答了Alice的提议,并列出了Carole连接到的用户列表 ([ Bob ])
- Carole现在连接到Alice并向Everyone发送报价,其中包含Carole连接到的用户列表([ Alice, Bob ])
- 每个人都回答Carole的提议,并附上每个人都连接到的用户列表 ([ Alice, Bob ])
- Carole现在连接到每个人(字面意思)
- 最后,我们必须为我们的对等通信建立数据格式。
对于这个项目,只要说任何通信的数据必须包含类型属性就足够了。我们将根据此属性的值以不同方式查询我们的数据。
始终考虑 2 个通信端点之间的数据格式是一个有用的习惯,这样您就有了弹性的行为/解释。您不希望查询意外抛出错误的 API!另外:如果您需要来自同一端点的不同行为,也许您应该将端点拆分为 2 个不同的端点,每个端点都有自己的原子角色!
您会在下面找到我们的主要 Vue 组件中缺少的方法列表,这些方法允许完全实现上述逻辑:
- initiateConnection在提供连接时被调用。
它必须实现连接用户发送的初始列表(作为连接元数据)! - configureConnection在提供和应答连接时调用(用于注册数据和行为侦听器)
它必须使用收到的连接用户列表(作为连接元数据)实现连接启动! - createPeer还处理 Peer 配置,它实质上是在收到报价时实现自动应答(如果我们想实现接受/拒绝屏幕,我们将停用它)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
// we keep track of connections ourselves as suggested in peerjs's documentation addConnection: function (conn) { this.connections[conn.peer] = conn; this.updatePeerIds(); console.log(`Connected to ${conn.peer}!`); }, removeConnection: function (conn) { delete this.connections[conn.peer]; this.updatePeerIds(); }, updatePeerIds: function () { this.peerIds = Object.keys(this.connections); }, // called to properly configure connection's client listeners configureConnection: function (conn) { conn.on("data", data => { // if data is about connections (the list of peers sent when connected) if (data.type === "connections") { data.peerIds.forEach(peerId => { if (!this.connections[peerId]) { this.initiateConnection(peerId); } }); } else if (data.type === "chat") { this.receiveChat(data.chat); } // please note here that if data.type is undefined, this endpoint won't do anything! }); conn.on("close", () => this.removeConnection(conn)); conn.on("error", () => this.removeConnection(conn)); // if the caller joins have a call, we merge calls conn.metadata.peerIds.forEach(peerId => { if (!this.connections[peerId]) { this.initiateConnection(peerId); } }); }, // called to initiate a connection (by the caller) initiateConnection: function (peerId) { if (!this.peerIds.includes(peerId) && peerId !== this.peer.id) { this.loading = true; this.peerError = ""; console.log(`Connecting to ${peerId}...`); const options = { metadata: { // if the caller has peers, we send them to merge calls peerIds: this.peerIds }, serialization: "json" }; const conn = this.peer.connect(peerId, options); this.configureConnection(conn); conn.on("open", () => { this.addConnection(conn); if (this.getUsername(conn.peer) === this.targetIdInput) { this.targetIdInput = ""; this.loading = false; } }); } }, createPeer: function () { // options are useful in development to connect to local peerjs server this.peer = new Peer(this.getPeerId(this.usernameInput)/*, { host: 'localhost', port: 8080, path: 'app' }*/); // when peer is connected to signaling server this.peer.on("open", () => { this.screen = "chat"; // changing screen this.loading = false; this.peerError = ""; }); // error listener this.peer.on("error", error => { if (error.type === "peer-unavailable") { // if connection with new peer can't be established this.loading = false; this.peerError = `${this.targetIdInput} is unreachable!`; // custom error message this.targetIdInput = ""; } else if (error.type === "unavailable-id") { // if requested id (thus username) is already taken this.loading = false; this.peerError = `${this.usernameInput} is already taken!`; // custom error message } else this.peerError = error; // default error message }); // automatic answer and merge when peer receives a connection this.peer.on('connection', conn => { if (!this.peerIds.includes(conn.peer)) { this.configureConnection(conn); conn.on("open", () => { this.addConnection(conn); // send every connection previously established to offerer (to merge chat rooms) conn.send({ type: "connections", peerIds: this.peerIds }); }); } }); } |
所以我们已经构建了一个持久的实时聊天 web 应用程序,但是如果我们仍然需要一个信令服务器来建立连接,它是如何无服务器的呢?它如何真正安全?
WebRTC 当然不是无服务器的!
该解决方案的优势在于其简单性和安全性:
- 任何用户都可以使用用户名登录并加入它认识的任何其他朋友
- 任何用户都可以完全访问其聊天记录
- 数据交换是完全加密的,中间人无法解释(有关更多信息,请参阅WebRTC 安全性研究)
但是,类似的无服务器应用的很多方面还有待再次讨论和思考:
- 您不会使用将数据存储为 javascript cookie 的应用程序!
- 仍然需要服务器来建立对等连接(请参阅WebRTC:ICE 框架、STUN 和 TURN 服务器以了解有关原因的更多信息)。在我们的例子中,我们默认使用高级 PeerJS 信令服务器,与低级 STUN 服务器(我们可以使用,具有更多低级代码)相比
- 交换的数据只能由同行读取和写入,这就是为什么这个解决方案甚至不适合交换(例如)游戏数据(任何用户都可以作弊!)
这一点,以及 WebRTC 和更普遍的此类无服务器应用程序所具有的所有限制,留待以后再讲!