如何使用 WebSockets 和 Socket.IO 在 Typescript 和 React 中构建聊天应用程序。
示例代码:https://download.youkuaiyun.com/download/hefeng_aspnet/91148610
介绍
在上一篇文章中,我积累了一些 WebSocket 的使用经验,并使用该ws库构建了一个简单的聊天应用。这次,我想更深入地学习,花更多时间扩展我的 WebSocket 知识和实践经验。
在该项目结束时,我确定了下次想要涵盖的几个领域,因此这次的目标是:
• 构建一个更复杂的、有用户和房间的聊天应用程序
• 使用socket.io而不是ws
• 通过确认来确认消息传递
• 提高可靠性并防止(或缓解)断开 WebSocket 连接
所有代码都可以在此示例上找到,让我们深入了解一下。
CRUD 位
在开始使用 Socket.IO 实现 WebSocket 之前,我需要构建一个功能更强大的 CRUD 应用程序。和上一个项目一样,我使用这个pg库来处理我的 PostgreSQL 数据库并express创建 API。
创建了 4 个表—— rooms、messages、room_members和users。用户可以创建(拥有)多个房间,也可以加入多个房间(这些房间不一定是他们的)。一个房间可以包含多条消息,每条消息都由用户创建。加入房间会在表中插入一行,离开房间则会删除该行。
为用户创建了自定义(注册)和登录的路由。密码使用bcrypt哈希值加密并存储。我使用以下方式管理用户令牌jsonwebtoken:登录时对令牌进行签名,并在必要时进行验证。这可能不是最安全的,但对项目来说已经足够了——我可能会在未来的项目中考虑安全性和密码管理🤔。
在 Express 中,创建房间、发送消息以及加入或离开房间的路由都受到 auth 中间件的保护。auth 中间件会从请求头中抓取 token 并检查其是否有效——如果无效,则会将用户弹出到登录页面。
前端使用 React 和一些自定义样式的组件——回想起来,使用像 chakra、radix 或 tailwind 这样的设计库可能会更容易。
一切都基于 Yarn 工作区,在一个 Monorepo 中完成——我从上一个 WebSockets 项目中就开始使用这种模式,我非常喜欢它。对于像这样的小型全栈应用来说,能够用一个命令同时运行服务器和客户端,并且无需切换窗口即可在包文件夹之间移动,这真是太棒了。
这次,我发现我有一些类型想在前端和后端都使用——比如Messages和Rooms。我没有在客户端和服务器中复制它们,而是将它们拉到一个common包中,这样就可以从两者访问它们,因此我只需要维护一组类型。
前端最棘手的部分是如何正确获取已认证的上下文。我已经很久没从零开始搭建这个应用了,所以花了比预期更长的时间才让它正常工作 😫
Socket.IO 入门
一如既往地从文档入手,它有一个非常实用的新手教程,设置起来也非常简单。与之前的 项目类似ws,服务器会向所有连接的客户端广播一条新的聊天消息。
这次的不同之处,或者说更复杂的是,每个聊天室都有一个单独的套接字“通道”。如果没有这种过滤机制,广播的消息就会发送到每个客户端,无论它们位于哪个聊天室——这对于聊天应用来说是一个大问题!
上次,我让客户端通过 WebSocket 将每条消息发送到服务器。然后服务器会存储消息,并广播给已连接的客户端。
后来我了解到,这是一种有点奇怪的方法,更传统的方法是让客户端通过简单的 POST 调用发送消息,然后让服务器保存该消息并通过 WebSockets 将其分发给连接的客户端。
Socket.IO在服务器端
服务器使用socket.io导入到包中的我的index.ts文件中的包server。
将expressapp传递到createServer从http包导入的方法中,然后将该服务器传递到new Server()从socket.io包导入的调用中:
import express, { Request, Response } from 'express';
import { Client } from 'pg';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
const app = express();
const db = new Client(); // pg
const server = createServer(app); // http
const io = new Server(server, {
cors: { origin: '*', methods: ['GET', 'POST'] },
});
async function clientConnect() {
await db.connect();
console.log('Postgres database connected');
}
app.use(express.json());
app.use(cors());
// routes...
void clientConnect();
server.listen(4000, () => console.log('Server started on port 4000'));
我将在本文过程中逐步构建 WebSocket 功能。最初,我们只监听默认connection事件disconnect:
io.on('connection', (socket) => {
console.log('a user connected');
socket.on('disconnect', () => console.log('user disconnected'));
});
服务器chat message在创建每条消息时都会创建一个事件。该事件在消息创建处理程序内部执行,即消息成功添加到数据库后。
// create message handler
app.post('/messages', auth, async (req: Request, res: Response) => {
try {
const message = await addMessage({ db }, req.body as MessageInput);
if (!message) throw new Error('Message creation failed');
// emit event to send message data to connected clients
io.to(message.roomId).emit('chat message', message);
res.status(201).send(message);
} catch (err) {
console.error(err)
res.status(500).send();
}
});
emit 事件使用该to方法将广播范围限定为仅连接到正确房间 ID 的客户端。然后,emit 方法发送事件名称chat message和消息对象。请注意,无需发送JSON.stringify()消息(然后在客户端解析),因为 Socket.IO 能够处理发送完整对象。
chat message我们将监听前端调用的事件,以便我们可以捕获通过 WebSocket 发送的消息并将其显示给用户。
添加socket.io-client到前端
首先,将此代码添加到我的index.tsx文件中:
import { io } from 'socket.io-client';
export const socket = io('ws://localhost:4000');
socket.on('connect', () => {
console.log('WebSocket connected');
});
接下来,在 Room 组件中,我们可以设置chat message来自服务器的事件的监听器:
export const Room = () => {
const [messages, setMessages] = useState<Message[]>([]);
// etc
socket.on('chat message', (msg: Message) => {
setMessages([...messages, msg]);
});
return (
// components
)
}
socket然后,从文件导入,index.tsx在名为的事件上chat message,新消息将传播到消息状态(以列表形式显示给用户)。
加入和离开房间
该项目要做到的关键之一是按房间分离消息,以便用户通过所在房间的 WebSocket 接收正确的消息。
为了实现这一点,有必要让用户“加入”和“离开”房间,从而订阅/取消订阅特定的“房间”(Socket.IO 也称之为“房间”)。
在客户端中,我们join room从“创建”和“加入”处理函数中触发一个事件,并leave room从“离开”房间处理函数中触发一个事件。
// from the create and join room handlers
socket.emit('join room', roomId);
// from the leave room handler
socket.emit('leave room', roomId);
然后在服务器端,我为这些加入和离开事件添加了事件监听器,并在套接字上调用加入和离开方法,传入房间 ID(我们在to上面广播聊天消息的方法中使用了它):
io.on('connection', (socket) => {
console.log('a user connected');
socket.on('join room', async (roomId: string) => {
await socket.join(roomId);
});
socket.on('join room', async (roomId: string) => {
await socket.leave(roomId);
});
socket.on('disconnect', () => console.log('user disconnected'));
});
确保消息传递
与上一个项目相比,我想在这个项目中添加的功能之一ws是某种消息确认功能,以及消息发送失败时的重试逻辑。这些功能需要手动构建,ws但 Socket.IO 已经内置了部分功能。
文档:
Socket.IO 的交付保证无论使用哪种底层传输方式,都能保证消息的顺序性。默认情况下,Socket.IO 提供“最多一次”的交付保证。
我们还可以对发送的消息(从服务器到客户端以及从客户端到服务器)实现重试。
Socket.IO 事件可以添加可选的回调函数,当另一端收到消息时会触发该回调函数。下图显示,对于从客户端发送的事件(例如加入或离开房间),我们可以从服务器获得确认。对于从服务器发送的事件(例如聊天消息),我们可以在收到后从客户端获得确认。
客户端到服务器事件
对于加入和离开房间事件,我们可以进行一些更改来添加逻辑。在客户端index.tsx,我们可以在ackTimeout实例化时将(重试间隔时间)和重试次数添加到io配置中。
export const socket = io('ws://localhost:4000', {
ackTimeout: 10000, // in ms
retries: 3,
});
您不必使用内置重试,并且可以按照教程文档中所示构建自己的逻辑。
在服务器端index.ts,我们需要添加回调函数并返回一些内容到前端 - 我选择在这里发送状态消息,但它可以是任何内容。
io.on('connection', (socket) => {
console.log('a user connected');
socket.on('join room', async (roomId: string, callback: ({ status }: EventResponse) => void) => {
await socket.join(roomId);
callback({ status: 'room join acknowledged' });
});
socket.on('leave room', async (roomId: string, callback: ({ status }: EventResponse) => void) => {
await socket.leave(roomId);
callback({ status: 'room leave acknowledged' });
});
socket.on('disconnect', () => console.log('user disconnected'));
});
服务器到客户端事件
为了从服务器发送事件,必须使用此功能手动创建逻辑重试:
import { Message } from '@ws-chat/common/src';
import { Server } from 'socket.io';
const RETRY_INTERVAL = 5000;
const MAX_RETRIES = 3;
export const emitWithRetry = async (
io: Server,
roomId: string,
event: string,
data: Message,
retries = MAX_RETRIES,
) => {
return new Promise<void>((resolve, reject) => {
let attempts = 0;
const sendEvent = () => {
attempts++;
io.to(roomId).emit(event, data, (ack: boolean) => {
if (ack) {
console.log(`Acknowledgment received for event: ${event}`);
resolve();
} else if (attempts < retries) {
console.log(`No acknowledgment received for event: ${event}, retrying... (${attempts})`);
setTimeout(sendEvent, RETRY_INTERVAL);
} else {
console.log(`Failed to deliver event: ${event} after ${retries} attempts`);
reject(new Error(`Failed to deliver event: ${event} after ${retries} attempts`));
}
});
};
sendEvent();
});
};
然后我用对新函数的调用替换了旧的 emit 事件:
// create message handler
app.post('/messages', auth, async (req: Request, res: Response) => {
try {
const message = await addMessage({ db }, req.body as MessageInput);
if (!message) throw new Error('Message creation failed');
// emit event to send message data to connected clients
// io.to(message.roomId).emit('chat message', message); old
await emitWithRetry(io, message.roomId, 'chat message', message);
res.status(201).send(message);
} catch (err) {
console.error(err)
res.status(500).send();
}
});
最后,我们需要在前端寻找确认函数,并在收到消息后发回响应:
export const Room = () => {
const [messages, setMessages] = useState<Message[]>([]);
// etc
socket.on('chat message', (msg: Message, ack: (response: boolean) => void) => {
setMessages([...messages, msg]);
if (ack) ack(true);
});
return (
// components
)
}
处理断开连接
Socket.IO 内置了实用的状态恢复工具,它会临时存储服务器发送的所有事件,并在客户端重新连接时尝试恢复客户端的状态——恢复其房间并发送任何错过的事件。该功能在服务器端的配置中启用:
const io = new Server(server, {
connectionStateRecovery: {}, // add this!
cors: { origin: '*', methods: ['GET', 'POST'] },
});
它默认不启用,因为它并不总是有效(例如,在突然崩溃或重启的情况下客户端状态可能不会被保存)并且它不能很好地扩展。
Socket.IO 的教程介绍了重新连接时同步客户端状态的步骤,即让客户端跟踪它处理的最后一个事件,并让服务器发送缺失的部分(另一种选择是让服务器发送整个状态)。
但是,在我的项目中,房间组件会在页面加载时获取所有消息,因此无需通过 WebSocket 发送所有消息。
总结
这个项目比上一个项目花费的时间长得多ws,但我发现使用 Socket.IO 更容易。他们的库更丰富,文档也更易理解,还内置了一些更实用的功能。
一如既往,总有更多可以做的……Socket.IO 的教程里有关于水平扩展的文档,可以处理向跨多个服务器的客户端发送消息。另外,目前所有代码都硬编码到 localhost,只能在本地运行,所以我也想研究一下如何部署这些代码。
如果您喜欢此文章,请收藏、点赞、评论,谢谢,祝您快乐每一天。