使用 Socket.IO 和 TypeScript 由 WebSockets 驱动的聊天应用程序

如何使用 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,只能在本地运行,所以我也想研究一下如何部署这些代码。

如果您喜欢此文章,请收藏、点赞、评论,谢谢,祝您快乐每一天。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

csdn_aspnet

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值