本文已参与「新人创作礼」活动,一起开启掘金创作之路。
前言:
最近在完成大三的一个课程期末设计,独立完成做了一个博客社区,主要技术栈是:
前端:next.js + mobx + ts + antd;
后台管理系统:vue3.0 + pinia + ts + elementUI
后端:next.js + ts + 阿里云oss存储 + redis
开发的时候遇到了很多坑,后面会通过文章的方式总结自己在开发过程中踩到的坑以及一些小经验。
由于博客社区嘛,像咱们大掘金平台,是有系统消息、点赞消息啥的等信息推送,然后我也想要做一个横幅(就是系统维护中,或者是出了什么问题,想要用户感知到,所以就想做一个系统消息推送),一开始通过websocket来实现,但是next.js这个框架似乎不太支持,而是支持了一个封装websocket的socket.io这个第三方依赖。所以就使用了socket.io来进行消息推送和在线人数统计的操作,希望大家喜欢~
前面的文章大家也可以看看~:
本系列第一篇:juejin.cn/post/709946…
一、需求
如果要完成下面这样的操作:
(其实感觉就有点像微信聊天一样)
右边的B用户,给左边的A用户评论进行回复,左边的A用户可以实时的收到评论消息。
如果要完成这个操作的话,我们需要做到:
- 给指定的用户发送信息(而不是单纯的广播信息)
- 如何保证用户在线、离线的时候都可以收到信息
- 如果用户当前在线的情况要怎么处理(这个简单)
- 如果用户不在线的话,怎么保障用户下一次上线的时候可以收到信息呢? 下面我们依次的来解决问题~
二、redis数据结构设计
首先我们定义一个场景。如何让用户A给用户B单独发信息,我们用socket.io实现的话,可以这样定义事件 用户A:
socket.emit('sendMessage', 'b同学你好呀')
A发送了一个事件,并带上了要发送的内容,我们不可能直接让B来接受这个事件,因为A同学在初始化socket的时候,通过socket = io('http://localhost:3000') 初始化socket,这个是和服务器进行连接的,A同学emit的事件只有在和服务器连接的这条socket里面才可以接受到,B同学不卡宴直接socket.on('sendMessage', data)来获取A同学的连接,所以就需要服务器做一个中转。
服务器next.js的server.js代码中,这样写:
// 参考medium的代码
const app = require('express')()
const server = require('http').Server(app)
const io = require('socket.io')(server, {cors:true})
const next = require('next')
const Redis = require('ioredis')
const redis = new Redis(6379)
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;
const nextApp = next({ dev, hostname, port })
const nextHandler = nextApp.getRequestHandler()
let socketPort = 3000
io.on('connect', socket => {
socket.on('clientOnline', async (userId) => {
console.log('用户上线了')
if (userId) {
await redis.sadd('s_online_user' , userId)
}
})
socket.on('disconnect', async (data) => {
console.log('用户关闭了', socket.id)
const userId = await redis.hget('user_socketUserId', socket.id)
await redis.srem('s_online_user' , userId)
})
socket.on('clientConnect', async (userId) => {
// 存在redis的 key value数据结构中
await redis.hset('user_socketId', userId, socket.id)
await redis.hset('user_socketUserId', socket.id, userId)
})
socket.on('sendMessage', async (message) => {
const { userId, content, fromUserId } = message
})
})
nextApp.prepare().then(() => {
app.get('*', (req, res) => {
return nextHandler(req, res)
})
app.post('*', (req, res) => {
return nextHandler(req, res)
})
server.listen(socketPort, err => {
if (err) throw err
console.log(`socket io ready on http://localhost:${port}`)
})
})
说明:也就是在server.js中通过socket.on('sendMessage', message)监听senMessage事件。
但是我们拿到了userId,服务器并不知道要给谁发送信息 服务器只能通过socket.id来给指定的用户发送信息,并不认识userId,所以在上面代码中,我们添加了一段下面的代码
await redis.hset('user_socketId', userId, socket.id)
也就是在redis中添加了一个hash表,key是userId,value是socket.id 这样的话,我们就可以通过下面的代码来根据userId来获取到用户的socket.id,有了socket.id之后我们就可以给指定的用户推送消息了
// 参考medium的代码
const app = require('express')()
const server = require('http').Server(app)
const io = require('socket.io')(server, {cors:true})
const next = require('next')
const Redis = require('ioredis')
const redis = new Redis(6379)
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;
const nextApp = next({ dev, hostname, port })
const nextHandler = nextApp.getRequestHandler()
let socketPort = 3000
io.on('connect', socket => {
socket.on('clientOnline', async (userId) => {
console.log('用户上线了')
if (userId) {
await redis.sadd('s_online_user' , userId)
}
})
socket.on('disconnect', async (data) => {
console.log('用户关闭了', socket.id)
const userId = await redis.hget('user_socketUserId', socket.id)
await redis.srem('s_online_user' , userId)
})
socket.on('clientConnect', async (userId) => {
// 存在redis的 key value数据结构中
await redis.hset('user_socketId', userId, socket.id)
await redis.hset('user_socketUserId', socket.id, userId)
})
socket.on('sendMessage', async (message) => {
const { userId, content, fromUserId } = message
// 获取这个用户在redis中的socketId
const socketId = await redis.hget('user_socketId', userId)
})
})
nextApp.prepare().then(() => {
app.get('*', (req, res) => {
return nextHandler(req, res)
})
app.post('*', (req, res) => {
return nextHandler(req, res)
})
server.listen(socketPort, err => {
if (err) throw err
console.log(`socket io ready on http://localhost:${port}`)
})
})
这样就完成了通过userId获取用户的socket.id操作了
但是我们后面还要用到redis,就是当用户离线的时候,我们需要临时存储一下这些信息(但是最好既要写在redis也要写入数据库里面,下面我主要是通过redis,但是真实的其实我还是写到了数据库里面的)
下面直接给出,用户离线的时候进行的操作:
socket.on('sendMessage', async (message) => {
const { userId, content, fromUserId } = message
// 获取这个用户在redis中的socketId
const socketId = await redis.hget('user_socketId', userId)
// ... 省略代码
if (用户离线) {
await redis.hset('h_user_message:' + userId, new Date().getTime() + ':' + fromUserId, content)
}
})
说明:
await redis.hset('h_user_message:' + userId, new Date().getTime() + ':' + fromUserId, content)
这段代码中hash哈希表名是一个'h_user_message:' + userId 用来标识不同的用户 并且为了存储同一个fromUser的不同回复内容,比如A同学给B同学发送了三个消息,那么我们哈希表里面就要有三个消息,但是A同学的标识还是同一个,所以我们就把哈希表的key通过时间戳+fromUserId的方式进行拼接
问题就来到了,“如何判断用户当前是否在线呢?“
三、判断用户是否在线
我们通过userId拿到了用户的socket.id之后,就可以判断用户是否在线了
socket.on('sendMessage', async (message) => {
const { userId, content, fromUserId } = message
// 获取这个用户在redis中的socketId
const socketId = await redis.hget('user_socketId', userId)
if (io.sockets.sockets.get(socketId) != undefined) {
// 正在连接,给用户发这个信息
console.log('该用户连接中')
// TODO:给用户发送信息
} else {
console.log('该用户离线中')
// 离线状态,把这个信息写入到redis的hashMap里面
// TODO 这个content后面可以放fromUser的详细信息和内容的信息信息用JSON.stringify就行
await redis.hset('h_user_message:' + userId, new Date().getTime() + ':' + fromUserId, content)
}
})
说明:在io.socket.sockts里面包含了当前服务器连接了哪些socket,是一个Map结构,key是socketId,value是这个该socketId对应的socket连接对象,所以在服务器中就可以通过socket.id找到对应的socket连接对象了,不管连接的数量有多少,map的哈希表查询都是O(1)的复杂度 不能直接通过 io.sockets.sockets[socket.id]的方式,哈希表的话,要通过get方法,如果获取到的是undefined证明当前用户socket连接断开了,也就是离线的状态了
- 用户离线就把发送的消息暂存在redis中
- 用户在线的话,直接通过socket实时的发送最新消息就行
四、用户在线时处理
其实我们通过:
io.sockets.sockets.get(socketId)
这样就获取到了对应用户连接socket服务器socket对象,直接调用这个socket对象的emit方法就可以进行给指定用户发送消息了!
socket.on('sendMessage', async (message) => {
const { userId, content, fromUserId } = message
// 获取这个用户在redis中的socketId
const socketId = await redis.hget('user_socketId', userId)
if (io.sockets.sockets.get(socketId) != undefined) {
// 正在连接,给用户发这个信息
console.log('该用户连接中')
io.sockets.sockets.get(socketId).emit('message', content);
} else {
console.log('该用户离线中')
// 离线状态,把这个信息写入到redis的hashMap里面
// TODO 这个content后面可以放fromUser的详细信息和内容的信息信息用JSON.stringify就行
await redis.hset('h_user_message:' + userId, new Date().getTime() + ':' + fromUserId, content)
}
})
在next.js用户前端就可以通过监听message事件来获取到发送的content了
五、用户离线时处理
离线操作前面已经说啦,其实就是存储在redis里面,用户前端首次进入页面的时候就请求查看redis里面是否为空,如果是空的话就把所有的content拿出来,然后再把这个hash哈希表清空就行
下一节会讲解如何进行离线操作!敬请期待~
六、下一节内容-最终节
《模仿掘金的消息页面,socket.io和redis实现 实时推送用户信息模块(评论信息、点赞信息、关注信息、系统消息的前端和后端完整实现过程》
下面是最终效果: