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...

相关推荐
AlienZHOU10 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
李昊哲小课12 小时前
Spring Boot SSE 实时推送教程
spring boot·websocket·flux·sseemitter
Captaincc13 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师14 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen15 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒15 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow15 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81816 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007816 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明16 小时前
第 6 章 组件进阶
前端·vue.js