node+SocketIo+redis实现多人聊天室

介绍

即时通讯技术就是客户端和服务器之间的双向通信,无需轮询多次请求,代表就是websocket,今天介绍的是基于websocket封装的一种功能更加健全的即时通讯框架Socket.io 官网:socket.io/zh-CN/, 来实现一个类似微信QQ这样的即时通讯的应用程序,当然只是简易版,功能也很简单,仅限多人通信。

效果图 :

技术介绍

前端:vue3 +Socket.IO 后端:node+express+Socket.IO 补充:Socket.IO是分别有服务端和客户端组成的, 由于Socket.IO存在一些版本兼容问题,所以这里建议就是前后端安装的版本都是同一个大版本下的,这样不容易出错。 附上兼容性表:

客户端安装

js 复制代码
npm install socket.io-client   //本文是4.x版本

客户端安装

js 复制代码
npm install socket.io   //本文是4.x版本

实现思路

其实整核心功能代码不多,主要分前端和后端功能,再特定时机借助SocketIO实现消息的监听和分发,后面实现的过程中会具体介绍SocketIO使用方法。

前端核心

  • 构建页面
  • 封装socketIO类
  • 初始化通信和消息监听发送

后端核心

  • 实例化聊天室命名空间
  • 封装聊天用户核心类开辟聊天通道
  • 中间件函数
  • 消息监听及发送
  • 聊天记录缓存

手搓代码

封装前端socketIO类

  1. 初始化
js 复制代码
import { io } from "socket.io-client";
import { ref } from "vue"; // 封装hook函数类,可直接引入页面使用响应式数据
class myWebSocket {
  //  传入两个参数,url和otp配置项(keyValue监听的表示默认值方法名)
  constructor({ url, otp = { message: true, keyValue: 'pushMsg' } }) {
    this.store = useStore();
    this.url = url || 'ws://localhost:8765' // 指定默认ws的地址
    this.socket = null // 实例化的ws对象
    this.messageArr = ref([])  // 接收服务端推送的消息
    this.otp = otp
  }
}
  1. 建立连接
js 复制代码
  //接收四个参数 namespace:命名空间名称,path:默认路径(这个一般不用改),options请求携带参数如token等,
  //callback 特定时期的回调函数
  createFn(namespace = '/admin', path = '/socket.io', options = {}, callback) {
    let token = "Bearer " + adminStorage.getSession("token");
    try {
      this.socket = io.connect(this.url + namespace, {
        path,
        // ......
        auth: { token }
      })
      // 连接开启
      this.socket.on("connect", () => {     
        // 监听默认方法 : 注册方法 on()来监听传入的默认方法名this.otp.keyValue
        this.socket.on(this.otp.keyValue, (data) => {
          this.messageArr.value = data //接收监听到的值,存入响应式变量中
        })
        if (typeof callback === 'function') callback(this.socket) //回调函数,一般用于其他事件的监听
      });
      this.socket.on("disconnect", (reason) => {
        // ...连接中断
        console.error('连接中断')
      });
      // 连接错误
      this.socket.on("connect_error", () => {
        console.error('连接错误')
      });
    } catch (error) {
     //  ......
    }
  }
  1. 发送消息
js 复制代码
  // 客户端主动发消息 emit()触发事件第一个参数是方法名,第二个参数是发送的数据
  sendFn(key = 'sendMsg', msg, callback) {
    if (!this.socket) {
      console.warn('WebSocket未连接 无法发消息')
      return
    }
    msg = msg || '' //消息内容
    this.socket.emit(key, {
      // ......
    })
    // ......
    if (typeof callback === 'function') callback(this.socket)
  }

前端基本构建

其实整体静态页面大致只分为两块,左侧用户列表和右侧通信窗口。

  1. 定义全局核心变量
js 复制代码
let message = ref(""); //右侧通信窗口发送消息双向绑定的值
let userList = ref([]);//左侧用户列表
let MessageList = ref([]); //右侧通信聊天通道记录列表
let user = adminStorage.getSession("userInfo");//获取当前登录人信息
let roomName = ref(user.id + "$" + user.loginName); //当前用户合成key(id+name)
// ...... 
// 实例化scoket类 传入默认监听方法名getRoomUser
const roomocket = new myWebSocket({
  otp: {
    message: false,
    keyValue: "getRoomUser",
  },
});

首先登录过后,去查询用户列表,默认查询的是当前系统的所有用户,默认查到的用户均是不在线的。

js 复制代码
// 登录
const login = async () => {
  let res = await getAdminUsers();
  if (res) {
    //建立连接、传入用户信息,(这个很关键 用户信息就是我们合成的key--roomName)
    roomocket.createFn("/room", "/socket.io", { user: roomName.value }, (socket) => {
      // 回调方法中,说明已经建立连接并且监听到了服务端返回的消息,此时返回的消息就是已经登录的用户人员信息(roomocket.messageArr.value),所以我们加以处理,配合getAdminUsers得到的用户数据使得在线标识 e.isOnline = true;
      // 然后就可以渲染列表在线和不在线的区别了
      // getUserList(roomocket.messageArr.value);
      // .........
    });
  }
};
// 获取所有用户列表
const getAdminUsers = async () => {
  try {
    let { data } = await userList_Api({
      count: 9999,
      page: 1,
    });
    let list = data.records.map((e) => {
      return {
        key: e.user_id + "$" + e.loginName, //合成的key表示 和roomName一样
        label: e.loginName,
        isOnline: false,
      };
    });
    return (userList.value = [...list]);
  } catch (e) {
    console.error(e);
  }
};

配合html结构看:

html 复制代码
 <el-menu background-color="#CCC" :default-active="activeIndex" @open="handleOpen" @select="handleSelect">
     <el-menu-item v-for="(item, index) in userList" :key="index" :index="item.key">
       <p :class="`${item.isOnline ? 'text_color' : 'text_color_is'}`">
         {{ item.label }}
         <span style="font-size: 12px; color: #ae9595" v-if="!item.isOnline">(不在线)</span>
       </p>
     </el-menu-item>
 </el-menu>
  1. 建立用户通道

当用户登录之后,接下来就是建立每个用户下的通信通道了。触发事件在于用户列表的选中事件handleSelect

js 复制代码
// 选中某个用户
const handleSelect = (key) => {
  // ......
  // 当前用户和发送用户合成唯一主键(这一步很关键 是整个用户通道的唯一key 由两个用户共同组成,即1001$admin&1002$test格式)
  let data = {};
  data.recordsID = activeIndex.value + "&" + roomName.value;
  data.sendName = roomName.value;
  // ......
  // 这里会触发checkedUser事件方法 通知服务端,建立对应的聊天通道,这里传入两个参数,一个是合成通道id还有一个是当前登录用户的合成key
  roomocket.sendFn("checkedUser", data, (socket) => {
    // 事件触发后,监听服务端返回对应的聊天通道存在的聊天记录,并渲染在窗口中
    socket.on("rowMsgList", (data) => {
      MessageList.value = [...data];
    });
  });
};
  1. 发送消息 发送消息比较简单,主要就是组成一条消息的主体信息,包括消息内容,当前发送人key,发送到的用户key
js 复制代码
const sendMsg = () => {
  let params = {};
  params.content = message.value; //内容信息
  params.myName = roomName.value; // 当然发送人key
  params.toName = activeIndex.value; // 发送给谁的key
  params.time = new Date().getTime();
  MessageList.value.push(params);
  //发送消息
  roomocket.sendFn("chatSend", params, (socket) => {
  // ......
  });
  message.value = ""; //消息输入框清空
};

服务端构建

前端核心就是以上几个步骤,相对比较简单。接下来就是服务端逻辑,其实核心代码也很少。

建立服务端socketio

通过官方文档,有多种建立方式,这里我们用的是node+express所以它的构建是这样的

js 复制代码
const express = require('express')
const app = express()
const { createServer } = require("http");
const server = createServer(app);
require('./socket/socket.js')(server); 
// ......
// 同时监听的必须也得是用server监听端口,否则监听不到
server.listen(3000);

具体构建其实是写在其他模块中的,通过传递实例好的server参数

js 复制代码
const { Server } = require("socket.io");
const { onlineUser } = require('./UserUtil.js'); //核心工具类
module.exports = (server) => {
  const io = new Server(server, {
    // path(字符串):捕获webSocket连接的路径名,默认为(/ http://socket.io)。
    cors: {
      //解决跨域问题
      origin: "*",
      methods: ["GET", "POST"],
    },
  });

  let onlineUserList = new onlineUser();
  // ......
  require('./room/room.js')(io, onlineUserList);// 由于这里是单独一个模块所有拆分成了多个命名空间去实现,可以不参考这种写法
};

中间件函数

上面初始化建立服务端连接成功之后,就可以开始通信了。首先在我们的room.js中,第一步是中间件校验token权限,这个是官方提供的中间件,我们自己封装内部规则。

js 复制代码
const checkToken = require("../checkToken");
module.exports = (io, onlineUserList) => {
  const roomNamespace = io.of("/admin"); //实例化命名空间(可以不参考)
  roomNamespace.on("connection", async (socket) => {
     // 校验token中间件
    roomNamespace.use(checkToken);
    // ......
  });
};

// checkToken.js
const redis = require('../utils/redis')
const { verToken } = require('../utils/token')
module.exports = (socket, next) => {
  // 获取token参数
  const token = socket.handshake.auth.token
  // 逻辑判断
  // ......
  // 最后 next 或者抛出错误
  return next()
}

核心消息事件

与客户端相对应的有事件触发就有监听.

  1. 建立连接,返回用户登录信息
js 复制代码
 //鉴权成功后
 let { room } = socket.request._query //获取当前连接用户的用户名 也就是客户端合成的id+name的key
// 调用工具类中的方法addUser,添加到map集合中,每次连接都是生成一个通信专属的socket.id,我们把他当做key,每个用户的key我们当做map集合中的value进行一个在线用户列表的存储。
 onlineUserList.addUser(socket.id, room) // 添加新加入连接的用户
 await getMessage(onlineUserList, socket) // 客户端连接成功后的回调
//  ......
//  ......
 const getMessage = async (onlineUserList, socket) => {
  //当有用户登录时候,给所有在线用户更新用户列表
  let userList = getOnlineUser(onlineUserList.userListMap) //获取在线用户的map集合格式化处理返回给客户端
  // broadcast: 除自己以外广播消息
  socket.broadcast.emit('getRoomUser', userList)
}
  1. 建立用户通道 当客户端选择某个用户,进入用户通道时,会向服务端发起checkedUser事件
js 复制代码
    // 选中某个用户进入消息通道
    websocket.onMessage('checkedUser', (data) => {
      //客户端传入的2个参数一个是合成通道唯一id的recordsID,还有一个是发送人的id--sendName
      let { recordsID, sendName } = data.message
      const sendMSG = () => {
        //获取当前通道下的聊天记录
        let resList = onlineUserList.hasMsgRid(recordsID)
        if (resList.length > 0) {
          // 获取当前用户对应的socketid
          let anotherid = getSocketId(onlineUserList, sendName)
          // 通过socketid发送给指定人消息
          if (anotherid) roomNamespace.to(anotherid).emit('rowMsgList', resList)
        }
      }
      // 检查是否存在二人通信list
      if (onlineUserList.hasMsgRid(recordsID)) {
         //如果已经存在通道,那么读取这个通道之前保存的用户聊天信息,然后发送给指定人
        sendMSG()     
      } else {
        //  如果是第一次进入,那么会建立一个新的key value集合 默认值是空
        onlineUserList.addMsgList(recordsID, [])
      }
    })
  1. 接收消息 当两个人相互通信时,一方发送一方通知接收
js 复制代码
    websocket.onMessage('chatSend', (data) => {
      let msg = data.message
      let recordsID = msg.toName + '&' + msg.myName //生成聊天用户通道列表唯一主键id
      // 把当前信息存入对应用户通道中去
      onlineUserList.addMsgList(recordsID, msg)
      //获取发送到的用户通信id
      let anotherid = getSocketId(onlineUserList, msg.toName)
      // 如果用户存在,则发送消息
      if (anotherid) {
        roomNamespace.to(anotherid).emit('chatRes', msg)
      }
    })

聊天记录缓存

由于现在阶段的所有通道聊天记录是存在缓存中,重启刷新后数据都会丢失,为了持久化缓存聊天记录,所以这里采用redis进行消息记录的缓存。那么什么时候进行缓存最合适呢。就是当用户通道上的2个用户都离线的时候进行缓存,下次通道进入的时候直接读取缓存中的记录当初始值即可。

js 复制代码
// 断开链接
 websocket.disconnect(async (id) => {
   // 监听断线
   //删除在线玩家列表中已经断线的用户==>离线通道聊天记录进入redis缓存
   if (onlineUserList.userListMap.has(id)) {
     let getOutUser = onlineUserList.userListMap.get(id) //根据socket Id 获取退出用户的key
     onlineUserList.userListMap.delete(id) //删除在线用户map集合列表
     await getMsgCache(getOutUser, onlineUserList)//进入缓存方法
     await getMessage(onlineUserList, socket) //通知更新在线用户信息列表
   } 
 });

那么如何判断是否聊天通道双方都下线了呢,这时候这个聊天用户通道集合的key就很关键了,还记得之前定义的格式么,它由两个用户共同组成,即1001admin&1002test格式,分别代表的是发送给的用户和当前用户组成的,也就是说只有判断这两个用户都离线了,这个条件才能进入缓存。当然也有特殊情况就是用户自己给自己发消息,这个情况只要命中就直接进入缓存。下面看具体处理方法:

js 复制代码
const getMsgCache = (user, onlineUserList) => {
  let msgMap = onlineUserList.msgListMap //记录聊天记录的map集合
  let tempStack = onlineUserList.tempStack //临时存放离线人员的数组
  if (msgMap.size === 0) return false //如果聊天通道 是空的 说明没有产生任何记录,返回即可
  if (tempStack.length > 0) {  // 临时存放离线人员的数组。如果是空数组,说明没有下线,或者已经处理完所有下线人员
    let key
    let f = tempStack.find((e, index) => {
      if (e.rowKey === user) {
        key = index
        return e
      }
    })
    if (f) {
      tempStack.splice(key, 1) //清除当前缓存数组中的用户
      let mapKey = f.mapKey
      if (onlineUserList.hasMsgRid(mapKey)) {
        let res = onlineUserList.hasMsgRid(mapKey)
        redis.setRedisValue(mapKey, JSON.stringify(res))
      }
      msgMap.delete(mapKey)       
      // 此时通道双方均下线,清空暂存,进入缓存
    } else {
      mapFilter()
    }
  } else {
    // 首次处理下线人员
    mapFilter()
  }
  function mapFilter() {
    for (let [e, value] of msgMap.entries()) {
      let list = e.split('&')
      let index = list.indexOf(user) //因为聊天通道存在,用户必然存在不可能为-1
      if (list[0] === list[1]) {
        // 单通道 自己给自己留言情况 直接存入redis
        if (value.length > 0) {
          redis.setRedisValue(e, JSON.stringify(value))
        }
        msgMap.delete(e)
      } else {
        // &符号两边用户不同,即存在2个用户,这里把没有下线的另外一个用户(即用户通道中另一个在线的)存入暂存数组中,方便下次离线进入时候的匹配。同时传入通道的key,方便查找数据。
        let ind = index === 0 ? 1 : 0
        tempStack.push({
          rowKey: list[ind],
          mapKey: e
        })
      }
    }
  }
}

那么缓存之后,还记得一开始的建立通道的方法里面,可以进一步优化,直接读取缓存,而不用设置空初始值了

js 复制代码
    websocket.onMessage('checkedUser', (data) => {
      //......
      // 检查是否存在二人通信list
      if (onlineUserList.hasMsgRid(recordsID)) {
          //...... 
      } else {
        let data
        redis.getRedisValue(recordsID).then(r => {
          data = r ? JSON.parse(r) : [] 
          onlineUserList.addMsgList(recordsID, data)
          sendMSG() 
        })
      }
    })

结尾

以上便是核心代码和思路,当然存在许多不足和值得优化的地方,这里附上源码地址,可供参考gitee.com/xiG_index/v...

相关推荐
zqx_714 分钟前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己31 分钟前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称1 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色1 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2341 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河2 小时前
CSS总结
前端·css
BigYe程普2 小时前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
余生H2 小时前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍2 小时前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai2 小时前
网站开发的发展(后端路由/前后端分离/前端路由)
前端