Skip to main content

私有消息 - 第二部分

¥Private messaging - Part II

本指南分为四个不同的部分:

¥This guide has four distinct parts:

这是 第一部分 结束时的情况:

¥Here's where we were at the end of the 1st part:

Chat

目前交换私有消息是基于 socket.id 属性,该属性运行良好,但在这里存在问题,因为该 ID 仅对当前 Socket.IO 会话有效,并且每次客户端和服务器之间的底层连接被切断时都会发生变化。

¥Exchanging private messages is currently based on the socket.id attribute, which works well but is problematic here because this ID is only valid for the current Socket.IO session and will change every time the low-level connection between the client and the server is severed.

因此,每次用户重新连接时,都会创建一个新用户:

¥So, every time the user reconnects, a new user will be created:

Duplicate users

这是...没那么好。让我们解决这个问题!

¥Which is... not that great. Let's fix this!

安装

¥Installation

让我们检查一下第二部分的分支:

¥Let's checkout the branch for part II:

git checkout examples/private-messaging-part-2

你应该在当前目录中看到以下内容:

¥Here's what you should see in the current directory:

├── babel.config.js
├── package.json
├── public
│ ├── favicon.ico
│ ├── fonts
│ │ └── Lato-Regular.ttf
│ └── index.html
├── README.md
├── server
│ ├── index.js (updated)
│ ├── package.json
│ └── sessionStore.js (created)
└── src
├── App.vue (updated)
├── components
│ ├── Chat.vue (updated)
│ ├── MessagePanel.vue
│ ├── SelectUsername.vue
│ ├── StatusIcon.vue
│ └── User.vue
├── main.js
└── socket.js

完整的差异可以在 此处 中找到。

¥The complete diff can be found here.

工作原理

¥How it works

持久会话 ID

¥Persistent session ID

在服务器端(server/index.js),我们创建两个随机值:

¥On the server-side (server/index.js), we create two random values:

  • 会话 ID,私有,将用于在重新连接时对用户进行身份验证

    ¥a session ID, private, which will be used to authenticate the user upon reconnection

  • 用户 ID,public,将用作交换消息的标识符

    ¥a user ID, public, which will be used as an identifier to exchange messages

io.use((socket, next) => {
const sessionID = socket.handshake.auth.sessionID;
if (sessionID) {
// find existing session
const session = sessionStore.findSession(sessionID);
if (session) {
socket.sessionID = sessionID;
socket.userID = session.userID;
socket.username = session.username;
return next();
}
}
const username = socket.handshake.auth.username;
if (!username) {
return next(new Error("invalid username"));
}
// create new session
socket.sessionID = randomId();
socket.userID = randomId();
socket.username = username;
next();
});

然后,会话详细信息将发送给用户:

¥The session details are then sent to the user:

io.on("connection", (socket) => {
// ...
socket.emit("session", {
sessionID: socket.sessionID,
userID: socket.userID,
});
// ...
});

在客户端(src/App.vue),我们将会话 ID 存储在 localStorage 中:

¥On the client-side (src/App.vue), we store the session ID in the localStorage:

socket.on("session", ({ sessionID, userID }) => {
// attach the session ID to the next reconnection attempts
socket.auth = { sessionID };
// store it in the localStorage
localStorage.setItem("sessionID", sessionID);
// save the ID of the user
socket.userID = userID;
});

实际上,有几种可能的实现方式:

¥Actually, there were several possible implementations:

  • 根本没有存储空间:重新连接将保留会话,但刷新页面将丢失会话

    ¥no storage at all: reconnection will preserve the session, but refreshing the page will lose it

  • sessionStorage:重新连接并刷新页面将保留会话

    ¥sessionStorage: reconnection & refreshing the page will preserve the session

  • localStorage:重新连接和刷新页面将保留会话+此会话将在浏览器选项卡之间共享

    ¥localStorage: reconnection & refreshing the page will preserve the session + this session will be shared across the browser tabs

在这里,我们选择了 localStorage 选项,因此你的所有选项卡都将链接到相同的会话 ID,这意味着:

¥Here, we chose the localStorage option, so all your tabs will be linked to the same session ID, which means that:

  • 你可以和自己聊天(耶!)

    ¥you can chat with yourself (yay!)

  • 你现在需要使用另一个浏览器(或浏览器的私有模式)来创建另一个对等点

    ¥you now need to use another browser (or the private mode of your browser) to create another peer

最后,我们在应用启动时获取会话 ID:

¥And finally, we fetch the session ID on application startup:

created() {
const sessionID = localStorage.getItem("sessionID");

if (sessionID) {
this.usernameAlreadySelected = true;
socket.auth = { sessionID };
socket.connect();
}
// ...
}

你现在应该能够刷新选项卡而不会丢失会话:

¥You should now be able to refresh your tab without losing your session:

Persistent sessions

在服务器端,会话保存在内存存储中 (server/sessionStore.js):

¥On the server-side, the session is saved in an in-memory store (server/sessionStore.js):

class InMemorySessionStore extends SessionStore {
constructor() {
super();
this.sessions = new Map();
}

findSession(id) {
return this.sessions.get(id);
}

saveSession(id, session) {
this.sessions.set(id, session);
}

findAllSessions() {
return [...this.sessions.values()];
}
}

同样,这仅适用于单个 Socket.IO 服务器,我们将在本指南的第四部分中再次讨论这一点。

¥Again, this will only work with a single Socket.IO server, we'll come back to this in the 4th part of this guide.

私有消息(已更新)

¥Private messaging (updated)

现在私信是基于服务器端生成的 userID,所以我们需要做两件事:

¥The private messaging is now based on the userID which is generated on the server-side, so we need to do two things:

  • 使 Socket 实例加入关联的房间:

    ¥make the Socket instance join the associated room:

io.on("connection", (socket) => {
// ...
socket.join(socket.userID);
// ...
});
  • 更新转发处理程序:

    ¥update the forwarding handler:

io.on("connection", (socket) => {
// ...
socket.on("private message", ({ content, to }) => {
socket.to(to).to(socket.userID).emit("private message", {
content,
from: socket.userID,
to,
});
});
// ...
});

发生的情况如下:

¥Here's what happens:

Private messaging

对于 socket.to(to).to(socket.userID).emit(...),我们在接收者和发送者(不包括给定的 Socket 实例)中广播 房间

¥With socket.to(to).to(socket.userID).emit(...), we broadcast in both the recipient and the sender (excluding the given Socket instance) rooms.

所以现在我们有:

¥So now we have:

Chat (v2)

断开连接处理程序

¥Disconnection handler

在服务器端,Socket 实例触发两个特殊事件:disconnectingdisconnect

¥On the server-side, the Socket instance emits two special events: disconnecting and disconnect

我们需要更新 "disconnect" 处理程序,因为会话现在可以跨选项卡共享:

¥We need to update our "disconnect" handler, because the session can now be shared across tabs:

io.on("connection", (socket) => {
// ...
socket.on("disconnect", async () => {
const matchingSockets = await io.in(socket.userID).allSockets();
const isDisconnected = matchingSockets.size === 0;
if (isDisconnected) {
// notify other users
socket.broadcast.emit("user disconnected", socket.userID);
// update the connection status of the session
sessionStore.saveSession(socket.sessionID, {
userID: socket.userID,
username: socket.username,
connected: false,
});
}
});
});

allSockets() 方法返回一个 Set,其中包含给定房间中所有 Socket 实例的 ID。

¥The allSockets() method returns a Set containing the ID of all Socket instances that are in the given room.

注意:我们也可以使用 io.of("/").sockets 对象,就像第一部分一样,但是 allSockets() 方法也适用于多个 Socket.IO 服务器,这在扩展时非常有用。

¥Note: we could also have used the io.of("/").sockets object, like in part I, but the allSockets() method also works with multiple Socket.IO servers, which will be useful when scaling up.

文档:allSockets() 方法

¥Documentation: allSockets() method

审查

¥Review

好吧,所以……我们现在拥有的更好,但还有另一个问题:这些消息实际上并未保留在服务器上。因此,当用户重新加载页面时,它会丢失所有现有对话。

¥OK, so… what we have now is better, but there is yet another issue: the messages are not actually persisted on the server. As a consequence, when the user reloads the page, it loses all its existing conversations.

例如,可以通过将消息保存在浏览器的 localStorage 中来修复此问题,但还有另一个更烦人的影响:

¥This could be fixed for example by saving the messages in the localStorage of the browser, but there is another more annoying repercussion:

  • 当发送方断开连接时,它发送的所有数据包都是 buffered,直到重新连接(这在大多数情况下都很好)

    ¥when the sender gets disconnected, all the packets it sends are buffered until reconnection (which is great, in most cases)

Chat with sender that gets disconnected
  • 但是当接收者断开连接时,数据包就会丢失,因为给定房间中没有监听 Socket 实例

    ¥but when the recipient gets disconnected, the packets are lost, since there is no listening Socket instance in the given room

Chat with recipient that gets disconnected

我们将尝试在本指南的 第三部分 中解决此问题。

¥We will try to fix this in the 3rd part of this guide.

谢谢阅读!

¥Thanks for reading!