midwayjs基础后台管理系统(七)即时通讯、使用SocketIO开发聊天功能

2023年 7月 19日 62.1k 0

github仓库地址

使用技术

midwayjs + typeorm + redis

现有功能

  • 登录注册、验证码
  • 用户管理
  • 角色管理
  • 权限管理
  • 文件模块、excel导入导出
  • swagger
  • 聊天功能

具体使用

克隆代码到本地

git clone https://github.com/vangleer/es-midway-admin.git

启动后端

需提前安装mysql和redis,导入默认数据 src/entity/init.sql(默认路由、配置等)

注意:没安装 redis 需要把任务队列相关功能注释

cd es-midway-admin
yarn
yarn dev

启动前端

cd web
yarn
yarn dev

访问 http://localhost:3001/

web.png

midwayjs 项目引入 SocketIO

midwayjs官方介绍

Socket.io 是一个业界常用库,可用于在浏览器和服务器之间进行实时,双向和基于事件的通信。

安装依赖

npm i @midwayjs/socketio@3 --save
## 客户端可选
npm i @types/socket.io-client socket.io-client --save-dev

开启组件

import { Configuration } from '@midwayjs/core'
import * as socketio from '@midwayjs/socketio'

@Configuration({
  imports: [socketio],
  // ...
})
export class MainConfiguration {
  async onReady() {
    // ...
  }
}

Socket.io 实现了两种 Transports(传输方式)

第一种是 HTTP 长轮询。HTTP Get 请求用于 long-running(长连接),Post 请求用于 short-running(短连接)。

第二种是 WebSocket 协议,直接基于 WebSocket Connection 实现。它在服务器和客户端之间提供了双向且低延迟的通信通道。

我们这里使用的是第二种方式 WebSocket,需要添加一下配置

// src/config/config.default.ts

import { MidwayConfig, MidwayAppInfo } from '@midwayjs/core'
export default (appInfo: MidwayAppInfo) => {
  return {
    // ...
    socketIO: {
      transports: ['websocket'],
      // cors: { // 长轮询开启跨域
      //   origin: '*'
      // }
    }
  } as MidwayConfig
}

目录结构

下面是 Socket.io 项目的基础目录结构,和传统应用类似,我们创建了 socket 目录,用户存放 Soscket.io 业务的服务代码。

.
├── package.json
├── src
│   ├── configuration.ts          ## 入口配置文件
│   ├── interface.ts
│   └── socket                      ## socket.io 服务的文件
│       └── hello.ts
├── test
├── bootstrap.js                  ## 服务启动入口
└── tsconfig.json

基本使用

  • 服务端
// src/socket/hello.ts

import { WSController, OnWSConnection, Inject, OnWSMessage, WSEmit } from '@midwayjs/core'
import { Context } from '@midwayjs/socketio'

@WSController('/hello')
export class HelloSocketController {

  @Inject()
  ctx: Context

  /**
   * 监听连接
   */
  @OnWSConnection()
  async onConnectionMethod() {
    console.log('on client connect', this.ctx.id)
  }

  /**
   * 接收消息
   */
  @OnWSMessage('words')
  @WSEmit('words')
  async gotMessage(data) {
    console.log('client data: ', data)
    const message = 'hello world from server'
    this.ctx.broadcast.emit('words', message)
    return message
  }
}

Socket.io 是通过事件的监听方式来获取数据。Midway 提供了 @OnWSMessage() 装饰器来格式化接收到的事件,每次客户端发送事件,被修饰的方法都将被执行。

当获取到数据之后,通过业务逻辑处理数据,然后将结果返回给客户端,返回的时候,我们也是通过另一个事件发送给客户端。

通过 @WSEmit 装饰器来将方法的返回值返回给客户端。

  • 客户端写法
import { onMounted, ref } from 'vue'
import { io, Socket } from 'socket.io-client'

const socket = ref(null)

onMounted(() => {
  // 建立websocket连接
  socket.value = io('ws://127.0.0.1:7001/hello', { transports: ['websocket'] })
  // 连接成功回调
  socket.value.on('connect', () => {
    console.log('client connected')
    socket.value?.emit('words', 'hello world from client')
  })

  // 监听消息
  socket.value.on('words', (data) => {
    console.log(data)
  })
})

上面是 Socket.io 在midwayjs中的基本使用,更详细用法请参考官方教程,接下来我们使用 Socket.io 来实现一个简单的聊天系统

聊天服务端

chat 表设计

chat 表包含字段有 发送人id、接收人id、发送内容、消息类型 (0:文字 1:图片 2:文件)、状态 (0:未读 1:已读)

新建 src/entity/chat.ts

import { Entity, Column } from 'typeorm'
import { BaseEntity } from './base'

@Entity('chat')
export class Chat extends BaseEntity {
  @Column({ comment: '发送人id', type: 'int' })
  fromUserId: number
  @Column({ comment: '接收人id', type: 'int' })
  toUserId: number

  @Column({ comment: '发送内容', length: 100 })
  content: string

  @Column({ comment: '消息类型 0:文字 1:图片 2:文件', default: 0, type: 'tinyint'  })
  type: string

  @Column({ comment: '状态 0:未读 1:已读', default: 0, type: 'tinyint'  })
  status: string
}

chat 业务实现如下,新建 src/service/chat.ts

import { Provide, Inject } from '@midwayjs/core'
import { InjectEntityModel } from '@midwayjs/typeorm'
import { Not, Repository } from 'typeorm'
import { BaseService } from './base'
import { Chat } from '../entity/chat'
import { User } from '../entity/user'
import { Context } from '@midwayjs/web'
@Provide()
export class ChatService extends BaseService {
  @InjectEntityModel(Chat)
  entity: Repository

  @InjectEntityModel(User)
  userEntity: Repository

  @Inject()
  ctx: Context

  /**
   * 获取和当前聊天对象的聊天记录
   * @param data 用户id和聊天对象id
   * @returns
   */
  async chatList(data) {
    const { fromUserId, toUserId } = data
    // 查询的是 我和你或者你和我的所有聊天记录
    const list = await this.entity
      .createQueryBuilder()
      .where('(fromUserId = :fromUserId AND toUserId = :toUserId) OR (fromUserId = :toUserId AND toUserId = :fromUserId)', { fromUserId, toUserId })
      .getMany()

    return list
  }

  /**
   * 获取用户列表(通讯录)
   * @param userId
   * @returns
   */
  async getUserList(userId) {
    const userList = await this.userEntity.find({ where: { id: Not(userId) } })

    const list = []
    // 遍历查询每个用户最新一条记录
    for (let i = 0; i < userList.length; i++) {
      const user = userList[i]
      const message = await this.entity
        .createQueryBuilder()
        .where('fromUserId = :userId OR toUserId = :userId', { userId: user.id })
        .orderBy('createTime', 'DESC')
        .getOne() || {}

      list.push({ ...user, message })
    }

    return list
  }
}

ChatService 实现了两个方法 chatListgetUserList

  • chatList 获取和当前聊天对象的聊天记录,例如我想和张三聊天,那么这个方法会返回我和张三的所有聊天记录

04.png

  • getUserList 获取用户列表(类似通讯录列表),这里获取的是user表里除自己以外所有用户并查询各自最新的一条聊天记录

03.png

chat 控制层,新建 src/socket/chat.ts

import { WSController, OnWSConnection, Inject, OnWSMessage, WSEmit } from '@midwayjs/core'
import { Context } from '@midwayjs/socketio'
import { ChatService } from '../service/chat'
@WSController()
export class HelloSocketController {
  @Inject()
  ctx: Context;

  @Inject()
  service: ChatService
  @OnWSConnection()
  async onConnectionMethod() {
    console.log('on client connect')
  }

  /**
   * 聊天发送记录
   */
  @OnWSMessage('chat')
  @WSEmit('chat')
  async gotMessage(data) {
    const { fromUserId, toUserId, content } = data
    if (!fromUserId || !toUserId) return []

    const chatInfo = { fromUserId, toUserId, content }
    await this.service.add(chatInfo)

    // 使用聊天双方的id建立事件名称
    const ids = [fromUserId, toUserId]
    ids.sort((a, b) => a - b)
    const topic = `${ids[0]}-chat-${ids[1]}`

    // 发送给除了发送者聊天用户
    this.ctx.broadcast.emit(topic, chatInfo)

    // 这里返回的是给发送者
    return chatInfo
  }

  /**
   * 聊天者和聊天对象的记录
   */
  @OnWSMessage('chatList')
  @WSEmit('chatList')
  async chatList(data) {
    const list = await this.service.chatList(data)
    return list
  }

  /**
   * 聊天对象列表(通讯录)
   */
  @OnWSMessage('userList')
  @WSEmit('userList')
  async getUserList(data) {
    const list = await this.service.getUserList(data.userId)
    return list
  }
}

主要实现了三个方法

  • chatListgetUserList 方法返回的是上面业务层的聊天记录列表和通讯录列表

  • gotMessage 方法的功能是接收/发送消息

  • 接收 发送者id,发送对象id,发送内容
  • 将数据存到数据库中
  • 用两者的id生成发送事件名称(也可以使用其他规则),这样只有聊天双方能接收到对应的消息

05.png

前端相关实现

出于篇幅的原因,布局代码和样式就不贴出来了,大家可以到github仓库获取,这里只涉及逻辑实现

  • 安装 socket.io 客户端
npm i socket.io-client
  • web/src/components/im/IM.vue 的逻辑部分
import { ref, onMounted, shallowRef, computed, onBeforeUnmount, nextTick } from 'vue'
import { useUserStore } from '@/store'
import { io, Socket } from 'socket.io-client'
import { dayjs, ScrollbarInstance } from 'element-plus'
const userStore = useUserStore()
const message = ref('')
const list = ref([])
const userList = ref([])
const chatUser = ref({})
const socket = shallowRef(null)
const chatTopic = ref('')
const infoRef = ref(null)
const innerRef = ref(null)

const messageList = computed(() => {
  return (list.value || []).map(item => {
    return {
      ...item,
      fromUserName: userStore.username,
      toUserName: chatUser.value.username,
      self: +item.fromUserId === userStore.userid
    }
  })
})

/**
 * 点击发送
 */
function handleSendMessage() {
  if (!message.value && !message.value.trim()) return
  socket.value?.emit('chat', {
    fromUserId: userStore.userid,
    toUserId: chatUser.value.id,
    content: message.value
  })
  message.value = ''
}
/**
 * 点击聊天列表
 * @param item
 */
function handleUserClick(item) {
  chatUser.value = item
  getChatList()
}

/**
 * 获取左侧列表
 */
async function getUserList() {
  socket.value?.emit('userList', { userId: userStore.userid }, (data) => {
    userList.value = data
    // 默认与列表的第一位朋友聊天
    chatUser.value = userList.value[0]
    getChatList()
  })
}

// 接收发送的消息
function onChatMessage (data) {
  list.value.push(data)
  setScroll()
}

/**
 * 获取和当前朋友的聊天记录
 */
function getChatList() {
  if (socket.value) {
    socket.value.emit('chatList', {
      fromUserId: userStore.userid,
      toUserId: chatUser.value.id
    }, (data) => {
      list.value = data || []
      setScroll()
    })

    // 使用聊天双方的id建立事件名称,需要和后端一致
    const ids = [userStore.userid, chatUser.value.id]
    ids.sort((a, b) => a - b)
    const topic = `${ids[0]}-chat-${ids[1]}`

    // 取消前一次的监听
    socket.value.off(chatTopic.value, onChatMessage)
    // 重新监听
    socket.value.on(topic, onChatMessage)
    chatTopic.value = topic
  } else {
    list.value = []
  }
}

function setScroll() {
  nextTick(() => {
    infoRef.value?.setScrollTop(innerRef.value?.clientHeight || 9999)
  })
}
onMounted(() => {
  // 建立websocket连接
  socket.value = io('ws://127.0.0.1:7001', { transports: ['websocket'] })
  // 连接后获取左侧列表
  socket.value.on('connect', () => getUserList())

  // 监听自己的发送
  socket.value.on('chat', onChatMessage)
})

onBeforeUnmount(() => {
  socket.value?.close()
})

几个注意点

  • onMounted 中与服务的建立连接 transports 需要和客户端保持一致,如果服务端的 @WSController() 装饰器加了namespace,客户端也需要加上

  • getChatList 方法中,由于每次切换聊天对象都需要重新定义接收消息的topic,需要把前一次的监听移除

  • 最后

    以上只是聊天功能的简单模板,有许多细节的地方并没有涉及,例如已读未读状态,发送图片和文件等。可以根据具体的需求扩展

    相关文章

    Oracle如何使用授予和撤销权限的语法和示例
    Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
    下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
    社区版oceanbase安装
    Oracle 导出CSV工具-sqluldr2
    ETL数据集成丨快速将MySQL数据迁移至Doris数据库

    发布评论