掘金 后端 ( ) • 2024-04-17 16:34

元芳们!!! 过了这一章就要选择搭配基础IM服务的业务系统啦,原来打算搞个O2O到家业务来着。现在需要换一个方向了,你们什么推荐吗?

在构建一个基于Fastify和TypeScript的基础即时消息(IM)服务时,维护用户状态和进行有效的鉴权操作是至关重要的。这不仅涉及到用户的连接管理,还包括用户的活跃状态监控、房间成员管理等多个方面。虽然Fastify提供了启用客户端跟踪的选项(clientTracking: true),但这个基础功能往往不能满足更复杂和个性化的业务需求。因此,我们在前面的章节中引入了一个自定义的rooms变量来维护用户连接状态,以实现更灵活的用户状态管理。

// 存储聊天室的对象,每个聊天室包含一组已连接的客户端(ExtendedWebSocket)。
const rooms: { [key: string]: Set<ExtendedWebSocket> } = {};

在本章中,我们将继续深入讨论如何有效维护用户连接状态的问题。我们将探讨维护用户状态的重要性,以及为什么在某些场景下,我们需要超越Fastify提供的基础功能,自行实现更加复杂的用户状态管理逻辑。此外,我们还将介绍UserManager类的设计理念和实现思路,这个类是我们自定义的一个工具,旨在提供一个中心化的用户状态管理机制,它不仅能够管理用户的连接状态,还能够处理用户的活跃状态监控、房间成员管理等功能。

这样的设计和实现,使我们能够构建一个高效、稳定并且可扩展的即时消息服务,满足现代IM服务对实时性、准确性和性能的高要求。

为什么要单独自行维护用户状态?

在即时通讯系统中,用户状态的管理是核心功能之一。用户状态不仅包括用户的在线或离线状态,还包括用户的活跃度、所在房间等信息。这些信息对于实现高效率的消息分发、房间管理以及提供丰富的用户体验至关重要。以下是几个关键原因,说明为什么我们需要单独自行维护用户状态:

  1. 实时性: 即时消息系统要求能够实时地处理用户的加入、活动和退出等操作。通过维护用户状态,系统可以快速响应用户的行为变化。

  2. 准确性: 用户状态的准确性直接影响到消息的正确分发。例如,只有正确识别用户当前所在房间,才能将消息正确地发送给该房间的所有成员。

  3. 扩展性: 当系统需要引入新的功能时(如用户权限管理、房间特权等),一个灵活设计的用户状态管理系统可以更容易地扩展以支持这些新特性。

  4. 性能优化: 通过有效管理用户状态,可以减少不必要的数据库查询,例如,避免频繁查询用户的在线状态或所属房间等。

UserManager的设计理念和思路

UserManager类的设计目标是提供一个中心化的用户状态管理机制,它不仅管理用户的连接状态,还负责房间成员的管理。以下是UserManager的几个关键设计理念和思路:

单例模式

UserManager采用单例模式设计,确保整个应用中只有一个用户状态管理器的实例。这样可以避免状态不一致的问题,同时也方便在不同的模块中访问和操作用户状态。

心跳机制

心跳机制是维护用户连接状态的关键。通过定期发送心跳消息,并检查心跳响应,UserManager能够识别并处理不活跃的连接。这不仅有助于释放资源,还能提高系统的稳定性和响应速度。

房间和用户的关系管理

UserManager通过维护一个用户ID到WebSocket连接的映射,以及房间标识符到用户ID集合的映射,有效地管理了用户与房间之间的关系。这种设计使得向特定房间广播消息或管理房间成员变得简单高效。

动态管理

用户的加入、活动和退出是动态的。UserManager提供了一系列方法(如addUserjoinRoomleaveRoom等),使得动态地管理用户状态和房间成员关系成为可能。

扩展性和解耦

UserManager的设计充分考虑了扩展性和解耦。通过将用户状态管理逻辑集中在UserManager中,其他模块(如消息处理模块)可以通过简单的接口与UserManager交互,而无需关心其内部实现细节。这样不仅简化了系统的设计,还为将来可能的功能扩展提供了便利。

综上所述,UserManager的设计充分考虑了即时消息服务的核心需求,通过有效的用户状态管理,为构建高效、稳定和可扩展的即时通讯服务提供了坚实的基础。

UserManager类实现

结合UserManager的设计理念和实现代码,我们可以逐步分析其实现过程,这将帮助我们理解如何将设计理念转化为具体的代码实现。UserManager类主要负责用户状态的管理,包括用户的连接、心跳检测、房间管理等功能。以下是根据设计理念反推的实现过程:

单例模式的实现

为了确保UserManager在应用中只有一个实例,我们采用了单例模式。这通过在UserManager类内部维护一个静态的实例instance并提供一个静态的getInstance方法来实现。这样,无论何时获取UserManager的实例,都确保是同一个实例,从而保证了状态的一致性。

/**
 * 用户管理器类,用于管理用户和房间的关系。
 */
class UserManager {
  private static instance: UserManager

  /**
   * 私有构造函数,防止外部直接实例化。
   */
  private constructor () {}

  /**
   * 获取UserManager的单例实例。
   * @returns UserManager的单例。
   */
  public static getInstance (): UserManager {
    if (!UserManager.instance) {
      UserManager.instance = new UserManager()
    }
    return UserManager.instance
  }
}

房间和用户的关系管理

UserManager通过两个Map来维护用户和房间的关系:一个是用户ID到WebSocket连接的映射(users),另一个是房间标识符到用户ID集合的映射(rooms)。这样的设计使得管理用户与房间之间的关系变得简洁明了。例如,当用户加入房间时,我们更新这两个Map来反映这一变化。

/**
 * 用户管理器类,用于管理用户和房间的关系。
 */
class UserManager {
  private static instance: UserManager
  private users: Map<string, ExtendedWebSocket> = new Map()
  private rooms: Map<string, Set<string>> = new Map()

  /**
   * 私有构造函数,防止外部直接实例化。
   */
  private constructor () {
    this.users = new Map()
    this.rooms = new Map()
  }

  /**
   * 获取UserManager的单例实例。
   * @returns UserManager的单例。
   */
  public static getInstance (): UserManager {
    if (!UserManager.instance) {
      UserManager.instance = new UserManager()
    }
    return UserManager.instance
  }

动态管理

UserManager提供了一系列方法来动态管理用户和房间的状态,如addUserjoinRoomleaveRoomsendMessageToRoom等。这些方法允许应用在运行时根据实际情况添加或移除用户,以及管理用户所在的房间。


/**
 * 用户管理器类,用于管理用户和房间的关系。
 */
class UserManager {
  private static instance: UserManager
  private users: Map<string, ExtendedWebSocket> = new Map()
  private rooms: Map<string, Set<string>> = new Map()

  // ……其他代码

  /**
   * 添加一个用户。
   * @param socket 用户的WebSocket连接。
   * @param userId 用户的唯一标识符。
   */
  addUser (socket: ExtendedWebSocket, userId: string): void {
    const now = new Date()
    socket.connectedAt = now
    socket.lastHeartbeatSent = now
    socket.lastHeartbeatReceived = now
    socket.userId = userId
    this.users.set(userId, socket)
    // 向加入的用户发送欢迎消息
    socket.send(
      JSON.stringify({
        message: `你好,你已经加入房间 ${socket.room}`
      })
    )
  }

  /**
   * 将用户加入到指定的房间。
   * @param userId 用户的唯一标识符。
   * @param room 房间的标识符。
   */
  joinRoom (userId: string, room: string): void {
    if (!this.rooms.has(room)) {
      this.rooms.set(room, new Set())
    }
    this.rooms.get(room)?.add(userId)
    const userSocket = this.users.get(userId)
    if (userSocket) {
      if (!userSocket.rooms) {
        userSocket.rooms = new Set()
      }
      userSocket.rooms.add(room)
    }
  }

  /**
   * 将用户从指定的房间中移除。
   * @param userId 用户的唯一标识符。
   * @param room 房间的标识符。
   */
  leaveRoom (userId: string, room: string): void {
    this.rooms.get(room)?.delete(userId)
    const userSocket = this.users.get(userId)
    userSocket?.rooms?.delete(room)
    if (this.rooms.get(room)?.size === 0) {
      this.rooms.delete(room)
    }
  }

  /**
   * 向指定房间的所有用户发送消息。
   * @param room 房间的标识符。
   * @param message 要发送的消息内容。
   */
  sendMessageToRoom (room: string, message: string): void {
    const userIds = this.rooms.get(room)
    if (!userIds) return
    userIds.forEach((userId) => {
      const userSocket = this.users.get(userId)
      if (userSocket && userSocket.readyState === WebSocket.OPEN) {
        userSocket.send(message)
      }
    })
  }

  /**
   * 移除一个用户及其所有房间的关联。
   * @param userId 用户的唯一标识符。
   */
  removeUser (userId: string): void {
    const userSocket = this.users.get(userId)
    userSocket?.rooms?.forEach((room) => {
      this.leaveRoom(userId, room)
    })
    this.users.delete(userId)
  }
}

心跳机制的实现

心跳机制是通过定期发送心跳消息并检测响应来维护用户连接状态的关键方法。在UserManager中,我们通过设置一个定时器,在指定的心跳间隔时间后发送心跳消息,并检查用户的响应状态。如果在两倍心跳间隔时间内没有收到用户的心跳响应,则认为该连接不活跃,进而关闭连接并移除用户。


/**
 * 用户管理器类,用于管理用户和房间的关系。
 */
class UserManager {
  private static instance: UserManager
  private users: Map<string, ExtendedWebSocket> = new Map()
  private rooms: Map<string, Set<string>> = new Map()
  private heartbeatInterval: number = 10 // 心跳间隔时间设置为10秒  // 新增

  /**
   * 私有构造函数,防止外部直接实例化。
   */
  private constructor () {
    this.users = new Map()
    this.rooms = new Map()
    setInterval(() => this.sendHeartbeat(), this.heartbeatInterval * 1000) // 新增
  }

  // ……其他代码
  /**
   * 定期向所有用户发送心跳消息。
   * 如果用户在两倍心跳间隔时间内没有响应,将关闭该用户的连接并移除该用户。
   */
  private sendHeartbeat () {
    const now = new Date()
    const heartbeatMessage = JSON.stringify({
      type: 'heartbeat',
      message: 'ping'
    })

    // 遍历所有用户
    this.users.forEach((socket, userId) => {
      // 检查是否收到过心跳响应,并且自上次发送心跳以来是否已超过两倍心跳间隔时间
      if (
        socket.lastHeartbeatReceived &&
        socket.lastHeartbeatSent &&
        now.getTime() - socket.lastHeartbeatReceived.getTime() >
          this.heartbeatInterval * 2000 // 注意:这里的时间单位应该是毫秒
      ) {
        console.warn(`${userId} is not responsive. Closing connection.`)
        socket.close() // 关闭连接
        this.removeUser(userId) // 移除用户的逻辑
      } else if (socket.readyState === WebSocket.OPEN) {
        socket.lastHeartbeatSent = now // 更新发送心跳的时间
        socket.send(heartbeatMessage) // 向用户发送心跳消息
      }
    })
  }

  /**
   * 处理从用户接收到的心跳响应。
   * 更新用户的最后心跳接收时间。
   *
   * @param socket 用户的WebSocket连接对象。
   */
  public handleHeartbeat (socket: ExtendedWebSocket) {
    socket.lastHeartbeatReceived = new Date() // 更新收到心跳的时间
  }
}

扩展性和解耦

通过将用户状态管理的逻辑封装在UserManager中,其他模块(如消息处理模块)可以通过UserManager提供的公共接口与之交互,而无需关心其内部实现细节。这种设计不仅简化了系统架构,还为将来可能的功能扩展提供了便利。

const userManager = UserManager.getInstance();

通过上述分析,我们可以看到UserManager的实现过程是如何体现其设计理念的:单例模式确保了状态的一致性,心跳机制维护了用户连接状态,房间和用户的关系管理提供了高效的消息分发机制,动态管理使得系统能够灵活响应用户行为,扩展性和解耦则保证了系统的可维护性和可扩展性。

UserManager完整代码

// UserManager.ts
import WebSocket from 'ws'
import { ExtendedWebSocket } from './ws'

/**
 * 用户管理器类,用于管理用户和房间的关系。
 */
class UserManager {
  private static instance: UserManager
  private users: Map<string, ExtendedWebSocket> = new Map()
  private rooms: Map<string, Set<string>> = new Map()
  private heartbeatInterval: number = 10 // 心跳间隔时间设置为10秒

  /**
   * 私有构造函数,防止外部直接实例化。
   */
  private constructor () {
    this.users = new Map()
    this.rooms = new Map()
    setInterval(() => this.sendHeartbeat(), this.heartbeatInterval * 1000)
  }

  /**
   * 获取UserManager的单例实例。
   * @returns UserManager的单例。
   */
  public static getInstance (): UserManager {
    if (!UserManager.instance) {
      UserManager.instance = new UserManager()
    }
    return UserManager.instance
  }

  /**
   * 添加一个用户。
   * @param socket 用户的WebSocket连接。
   * @param userId 用户的唯一标识符。
   */
  addUser (socket: ExtendedWebSocket, userId: string): void {
    const now = new Date()
    socket.connectedAt = now
    socket.lastHeartbeatSent = now
    socket.lastHeartbeatReceived = now
    socket.userId = userId
    this.users.set(userId, socket)
    // 向加入的用户发送欢迎消息
    socket.send(
      JSON.stringify({
        message: `你好,你已经加入房间 ${socket.room}`
      })
    )
  }

  /**
   * 将用户加入到指定的房间。
   * @param userId 用户的唯一标识符。
   * @param room 房间的标识符。
   */
  joinRoom (userId: string, room: string): void {
    if (!this.rooms.has(room)) {
      this.rooms.set(room, new Set())
    }
    this.rooms.get(room)?.add(userId)
    const userSocket = this.users.get(userId)
    if (userSocket) {
      if (!userSocket.rooms) {
        userSocket.rooms = new Set()
      }
      userSocket.rooms.add(room)
    }
  }

  /**
   * 将用户从指定的房间中移除。
   * @param userId 用户的唯一标识符。
   * @param room 房间的标识符。
   */
  leaveRoom (userId: string, room: string): void {
    this.rooms.get(room)?.delete(userId)
    const userSocket = this.users.get(userId)
    userSocket?.rooms?.delete(room)
    if (this.rooms.get(room)?.size === 0) {
      this.rooms.delete(room)
    }
  }

  /**
   * 向指定房间的所有用户发送消息。
   * @param room 房间的标识符。
   * @param message 要发送的消息内容。
   */
  sendMessageToRoom (room: string, message: string): void {
    const userIds = this.rooms.get(room)
    if (!userIds) return
    userIds.forEach((userId) => {
      const userSocket = this.users.get(userId)
      if (userSocket && userSocket.readyState === WebSocket.OPEN) {
        userSocket.send(message)
      }
    })
  }

  /**
   * 移除一个用户及其所有房间的关联。
   * @param userId 用户的唯一标识符。
   */
  removeUser (userId: string): void {
    const userSocket = this.users.get(userId)
    userSocket?.rooms?.forEach((room) => {
      this.leaveRoom(userId, room)
    })
    this.users.delete(userId)
  }
  /**
   * 定期向所有用户发送心跳消息。
   * 如果用户在两倍心跳间隔时间内没有响应,将关闭该用户的连接并移除该用户。
   */
  private sendHeartbeat () {
    const now = new Date()
    const heartbeatMessage = JSON.stringify({
      type: 'heartbeat',
      message: 'ping'
    })

    // 遍历所有用户
    this.users.forEach((socket, userId) => {
      // 检查是否收到过心跳响应,并且自上次发送心跳以来是否已超过两倍心跳间隔时间
      if (
        socket.lastHeartbeatReceived &&
        socket.lastHeartbeatSent &&
        now.getTime() - socket.lastHeartbeatReceived.getTime() >
          this.heartbeatInterval * 2000 // 注意:这里的时间单位应该是毫秒
      ) {
        console.warn(`${userId} is not responsive. Closing connection.`)
        socket.close() // 关闭连接
        this.removeUser(userId) // 移除用户的逻辑
      } else if (socket.readyState === WebSocket.OPEN) {
        socket.lastHeartbeatSent = now // 更新发送心跳的时间
        socket.send(heartbeatMessage) // 向用户发送心跳消息
      }
    })
  }

  /**
   * 处理从用户接收到的心跳响应。
   * 更新用户的最后心跳接收时间。
   *
   * @param socket 用户的WebSocket连接对象。
   */
  public handleHeartbeat (socket: ExtendedWebSocket) {
    socket.lastHeartbeatReceived = new Date() // 更新收到心跳的时间
  }
}
const userManager = UserManager.getInstance()
export { userManager }
export default UserManager

更新项目结构

这里要把原来写在messageHandler中的一坨代码拆分到他们应该在的处理器中

首先是加入和退出joinLeaveHandler.ts

import UserManager from '../UserManager'
import { ExtendedWebSocket, IJoinMessage } from '../ws'

const userManager = UserManager.getInstance()

/**
 * 处理用户加入聊天室的请求。将用户添加到聊天室并通知其他用户。
 *
 * @param {ExtendedWebSocket} socket - 加入用户的 WebSocket 连接。
 * @param {IJoinMessage} joinMsg - 包含房间和用户ID的加入消息。
 */
export const handleJoin = (
  socket: ExtendedWebSocket,
  joinMsg: IJoinMessage
) => {
  // 鉴权代码占位

  // 在 socket 上记录用户的房间和用户ID
  socket.room = joinMsg.room
  socket.userId = joinMsg.userId

  // 添加用户
  userManager.addUser(socket, joinMsg.userId)
  // 将用户加入到聊天室
  userManager.joinRoom(joinMsg.userId, joinMsg.room)
  // 通知聊天室内的其他用户有新用户加入
  // 注意:这里我们使用 userManager.sendMessageToRoom 方法来发送消息给房间内的所有用户
  userManager.sendMessageToRoom(
    joinMsg.room,
    JSON.stringify({
      message: `欢迎 用户 ${joinMsg.userId} 加入房间 ${joinMsg.room}`
    })
  )
}

/**
 * 处理 WebSocket 连接关闭,将用户从其所在的聊天室中移除。
 *
 * @param {ExtendedWebSocket} socket - 正在关闭的 WebSocket 连接。
 */
export const handleClose = (socket: ExtendedWebSocket) => {
  if (socket.userId) userManager.removeUser(socket.userId)
}

心跳 heartbeatHandler.ts

import logger from '@/logger'
import { userManager } from '../UserManager'
import { ExtendedWebSocket } from '../ws'

// 处理客户端心跳响应
export const handleHeartbeat = (socket: ExtendedWebSocket) => {
  userManager.handleHeartbeat(socket)
  logger.info(`Heartbeat received from ${socket.userId} in room ${socket.room}`)
}

消息处理 messageHandler.ts

import { authorizeGroupMessaging } from '../auth'
import { userManager } from '../UserManager'
import { ExtendedWebSocket, IMessageMessage, ISystemMessage } from '../ws'
import logger from '@/logger'

/**
 * 处理传入的聊天消息,将消息广播给同一聊天室内的所有用户。
 *
 * @param {ExtendedWebSocket} socket - 发送消息用户的 WebSocket 连接。
 * @param {IMessageMessage} messageMsg - 包含房间、用户ID和内容的消息对象。
 */
export const handleMessage = async (
  socket: ExtendedWebSocket,
  messageMsg: IMessageMessage
) => {
  // 广播鉴权占位
  
  userManager.sendMessageToRoom(messageMsg.room, JSON.stringify(messageMsg))
}

initWebSocket.ts中的代码是不用变的

当前目录

/bailing
├── package-lock.json          # 锁定安装时的包的版本,确保一致性
├── package.json               # 定义项目依赖和脚本的npm配置文件
├── src                        # 项目的源代码
│   ├── config                 # 配置文件目录
│   │   ├── config.d.ts        # 配置的TypeScript声明文件,用于类型安全
│   │   └── index.ts           # 配置文件的实现,负责加载和导出配置
│   ├── errors                 # 错误处理相关的目录
│   │   ├── custom-error.ts    # 自定义错误类,用于创建统一的错误响应
│   │   └── index.ts           # 错误处理的入口文件,可能用于汇总和导出错误类
│   ├── index.ts               # 应用入口文件,用于启动服务器和其他初始化设置
│   ├── logger.ts              # 日志配置文件,定义日志记录方式和配置
│   ├── plugins                # Fastify插件目录
│   │   └── error-handler-plugin.ts # 错误处理插件,用于全局错误处理
│   ├── router                 # 路由目录,用于定义API路由和处理逻辑
│   ├── service                # 服务层目录,用于实现业务逻辑和数据处理
│   ├── webserver.ts           # Fastify服务器设置和启动逻辑
│   └── websockets             # WebSocket处理逻辑目录
│       ├── UserManager.ts     # 用户管理器,负责用户状态维护和鉴权
│       ├── handlers           # WebSocket事件处理器
│       │   ├── heartbeatHandler.ts # 心跳处理器,用于维护连接状态
│       │   ├── joinLeaveHandler.ts # 加入和退出处理器,用于处理用户加入和退出房间的逻辑
│       │   └── messageHandler.ts   # 消息处理器,用于处理WebSocket消息的逻辑
│       ├── index.ts           # WebSocket的设置和初始化逻辑
│       └── ws.d.ts            # 用于存放WebSocket相关的类型定义
├── tree-out.txt               # 项目结构输出文件,可能用于文档或记录
└── tsconfig.json              # TypeScript的编译配置文件

写在最后

写到这里基本上还没怎么改过HTML5那篇文章的代码,不过后面鉴权部分开始要逐步改动了。如果时间充足倒是打算直接上个业务系统

元芳,你们怎么看?