介绍
即时通讯技术就是客户端和服务器之间的双向通信,无需轮询多次请求,代表就是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类
- 初始化
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
}
}
- 建立连接
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) {
// ......
}
}
- 发送消息
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)
}
前端基本构建
其实整体静态页面大致只分为两块,左侧用户列表和右侧通信窗口。
- 定义全局核心变量
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>
- 建立用户通道
当用户登录之后,接下来就是建立每个用户下的通信通道了。触发事件在于用户列表的选中事件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];
});
});
};
- 发送消息 发送消息比较简单,主要就是组成一条消息的主体信息,包括消息内容,当前发送人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()
}
核心消息事件
与客户端相对应的有事件触发就有监听.
- 建立连接,返回用户登录信息
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)
}
- 建立用户通道 当客户端选择某个用户,进入用户通道时,会向服务端发起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, [])
}
})
- 接收消息 当两个人相互通信时,一方发送一方通知接收
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...