所需依赖如下:
前端:Vue2 + socket.io-client 4.7.2 + vue-socket.io 3.0.10
后端:SpringBoot + netty-socketio 2.0.3
1.导入socket.io配置
在 ruoyi-admin 模块下的 application.yml 加入socketio配置
读取配置并初始化Socket.IO
在 ruoyi-framework 模块下新建 socket 包
在socket包下新建配置文件 SocketIoConfig
package com.ruoyi.framework.socket;
import com.corundumstudio.socketio.SocketConfig;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.annotation.SpringAnnotationScanner;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Optional;
/**
* socket.io配置文件
*/
@Component
public class SocketIoConfig {
@Value("${socketio.host}")
private String host;
@Value("${socketio.port}")
private Integer port;
@Value("${socketio.bossCount}")
private int bossCount;
@Value("${socketio.workCount}")
private int workCount;
@Value("${socketio.allowCustomRequests}")
private boolean allowCustomRequests;
@Value("${socketio.upgradeTimeout}")
private int upgradeTimeout;
@Value("${socketio.pingTimeout}")
private int pingTimeout;
@Value("${socketio.pingInterval}")
private int pingInterval;
@Bean
public SocketIOServer socketIOServer() {
SocketConfig socketConfig = new SocketConfig();
socketConfig.setTcpNoDelay(true);
socketConfig.setSoLinger(0);
com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
config.setSocketConfig(socketConfig);
config.setHostname(host);
config.setPort(port);
config.setBossThreads(bossCount);
config.setWorkerThreads(workCount);
config.setAllowCustomRequests(allowCustomRequests);
config.setUpgradeTimeout(upgradeTimeout);
config.setPingTimeout(pingTimeout);
config.setPingInterval(pingInterval);
//服务端
final SocketIOServer server = new SocketIOServer(config);
return server;
}
//这个对象是用来扫描socketio的注解,比如 @OnConnect、@OnEvent
@Bean
public SpringAnnotationScanner springAnnotationScanner() {
return new SpringAnnotationScanner(socketIOServer());
}
}
2.编写消息推送逻辑并启动服务
在socket包下新建接口 SocketIOService 以及实现类 SocketIOServiceImpl
package com.ruoyi.framework.socket;
public interface SocketIOService {
// 启动服务
void start() throws Exception;
// 停止服务
void stop();
}
package com.ruoyi.framework.socket;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import com.ruoyi.common.core.domain.entity.SysUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.corundumstudio.socketio.SocketIOClient;
import com.corundumstudio.socketio.SocketIOServer;
/**
* SocketIO 服务实现
* @author jokerpan
*/
@Service(value = "socketIOService")
public class SocketIOServiceImpl implements SocketIOService {
private static final Logger log = LoggerFactory.getLogger(SocketIOServiceImpl.class);
// 用来存已连接的客户端
private static Map<String, SocketIOClient> clientMap = new ConcurrentHashMap<>();
// 保存用户自定义事件名称
private static String SAVE_USER_EVENT = "save"; //保存用户
private static String CLEAR_USER_EVENT = "clear"; //清除用户
private static String BROADCAST = "broadcast"; //广播
private static String SEND_MESSAGE_EVENT = "sendMessage"; //发送消息
@Autowired
private SocketIOServer socketIOServer;
/**
* Spring IOC容器创建之后,在加载SocketIOServiceImpl Bean之后启动
* @throws Exception
*/
@PostConstruct
private void autoStartup() throws Exception {
start();
}
/**
* Spring IOC容器在销毁SocketIOServiceImpl Bean之前关闭,避免重启项目服务端口占用问题
* @throws Exception
*/
@PreDestroy
private void autoStop() throws Exception {
stop();
}
@Override
public void start() {
// 监听客户端连接
socketIOServer.addConnectListener(client -> {
log.info("======socket连接成功!======");
});
// 监听客户端断开连接
socketIOServer.addDisconnectListener(client -> {
log.info("======socket连接关闭!======");
});
/**
* 监听客户端自定义事件
*/
//保存用户
socketIOServer.addEventListener(SAVE_USER_EVENT, String.class, (client, data, ackSender) -> {
// 客户端推送`自定义`事件时,onData接受数据,这里是string类型的json数据,还可以为Byte[],object其他类型
String userId = data;
clientMap.put(userId, client);
log.debug("------ID为:【" + userId + "】的用户加入连接------");
});
//清除用户
socketIOServer.addEventListener(CLEAR_USER_EVENT, String.class, (client, data, ackSender) -> {
String userId = data;
if (clientMap.get(userId) != null) clientMap.remove(userId);
log.debug("------ID为:【" + userId + "】的用户断开连接------");
});
//广播消息
// socketIOServer.addEventListener(BROADCAST, String.class, (client, data, ackSender) -> {
// String msg = data;
// //全体在线用户推送
// socketIOServer.getBroadcastOperations().sendEvent("message", msg);
// });
socketIOServer.addEventListener(SEND_MESSAGE_EVENT, SysUser.class, (client, data, ackSender) -> {
SysUser user = data;
SocketIOClient socketIOClient = clientMap.get(user.getUserId().toString());
if (socketIOClient != null) {
socketIOClient.sendEvent("message", user.getMsg());
log.debug("触发消息推送");
}else {
log.debug("推送用户不在线!");
}
});
socketIOServer.start();
}
@Override
public void stop() {
if (socketIOServer != null) {
socketIOServer.stop();
socketIOServer = null;
}
}
}
这里我写了三个自定义的事件
save(在前端用户登录后用来保存在线用户,保存在 clintMap 中, key值为userId)
clear(在用户退出登录后清除其在线信息)
sendMessage(前端触发此事件之后通过传回的userId去判断用户是否在线,然后获取其中的socketIOClient, 通过 socketIOClient 发送到对应的在线用户)
3.前端逻辑实现
在 main.js 文件中注册socket并全局监听事件
import Vue from 'vue'
import Cookies from 'js-cookie'
import Element from 'element-ui'
import './assets/styles/element-variables.scss'
import KrPrintDesigner from "kr-print-designer"
import "kr-print-designer/lib/kr-print-designer.css"
import '@/assets/styles/index.scss' // global css
import '@/assets/styles/ruoyi.scss' // ruoyi css
import App from './App'
import store from './store'
import router from './router'
import directive from './directive' // directive
import plugins from './plugins' // plugins
import { download } from '@/utils/request'
import './assets/icons' // icon
import './permission' // permission control
import { getDicts } from "@/api/system/dict/data";
import { getConfigKey } from "@/api/system/config";
import { parseTime, resetForm, addDateRange, selectDictLabel, selectDictLabels, handleTree } from "@/utils/ruoyi";
import VueSocketIO from 'vue-socket.io';
import SocketIO from "socket.io-client";
// 分页组件
import Pagination from "@/components/Pagination";
// 自定义表格工具组件
import RightToolbar from "@/components/RightToolbar"
// 富文本组件
import Editor from "@/components/Editor"
// 文件上传组件
import FileUpload from "@/components/FileUpload"
// 图片上传组件
import ImageUpload from "@/components/ImageUpload"
// 图片预览组件
import ImagePreview from "@/components/ImagePreview"
// 字典标签组件
import DictTag from '@/components/DictTag'
// 头部标签组件
import VueMeta from 'vue-meta'
// 字典数据组件
import DictData from '@/components/DictData'
// 全局方法挂载
Vue.prototype.getDicts = getDicts
Vue.prototype.getConfigKey = getConfigKey
Vue.prototype.parseTime = parseTime
Vue.prototype.resetForm = resetForm
Vue.prototype.addDateRange = addDateRange
Vue.prototype.selectDictLabel = selectDictLabel
Vue.prototype.selectDictLabels = selectDictLabels
Vue.prototype.download = download
Vue.prototype.handleTree = handleTree
Vue.prototype.sLoading = sLoading
// 全局组件挂载
Vue.component('DictTag', DictTag)
Vue.component('Pagination', Pagination)
Vue.component('RightToolbar', RightToolbar)
Vue.component('Editor', Editor)
Vue.component('FileUpload', FileUpload)
Vue.component('ImageUpload', ImageUpload)
Vue.component('ImagePreview', ImagePreview)
Vue.use(directive)
Vue.use(plugins)
Vue.use(VueMeta)
Vue.use(KrPrintDesigner)
DictData.install()
Vue.use(Element, {
size: Cookies.get('size') || 'medium' // set element-ui default size
})
Vue.config.productionTip = false
// socket 连接参数
const socketOptions = {
autoConnect: false, // 自动连接 = true
}
// 注册SocketIO
Vue.use(
new VueSocketIO({
debug: true , // debug调试,生产建议关闭
connection: SocketIO("192.168.2.68:9999", socketOptions),
store,
})
)
let vue = new Vue({
el: '#app',
//这里为全局监听socket事件消息
sockets: {
connecting() {
console.log('正在连接')
},
disconnect() {
console.log("Socket 断开");
},
connect_failed() {
console.log('连接失败')
},
error() {
console.log("Socket 连接错误!!!")
},
connect() {
console.log('====== Socket 连接成功!======')
}
},
router,
store,
render: h => h(App)
})
export default vue
这里记得最后 export default vue 要把vue暴露出去,后面要用
在 src/store/modules/user.js 文件中的获取用户信息及登出方法中处理连接逻辑
import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
import vue from "@/main";
import store from '@/store/index';
const user = {
state: {
token: getToken(),
id: '',
name: '',
avatar: '',
roles: [],
permissions: []
},
mutations: {
SET_TOKEN: (state, token) => {
state.token = token
},
SET_ID: (state, id) => {
state.id = id
},
SET_NAME: (state, name) => {
state.name = name
},
SET_AVATAR: (state, avatar) => {
state.avatar = avatar
},
SET_ROLES: (state, roles) => {
state.roles = roles
},
SET_PERMISSIONS: (state, permissions) => {
state.permissions = permissions
}
},
actions: {
// 登录
Login({ commit }, userInfo) {
const username = userInfo.username.trim()
const password = userInfo.password
const code = userInfo.code
const uuid = userInfo.uuid
return new Promise((resolve, reject) => {
login(username, password, code, uuid).then(res => {
setToken(res.token)
commit('SET_TOKEN', res.token)
resolve()
}).catch(error => {
reject(error)
})
})
},
// 获取用户信息
GetInfo({ commit, state }) {
return new Promise((resolve, reject) => {
getInfo().then(res => {
const user = res.user
const avatar = (user.avatar == "" || user.avatar == null) ? require("@/assets/images/profile.jpg") : process.env.VUE_APP_BASE_API + user.avatar;
if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组
commit('SET_ROLES', res.roles)
commit('SET_PERMISSIONS', res.permissions)
} else {
commit('SET_ROLES', ['ROLE_DEFAULT'])
}
commit('SET_ID', user.userId)
commit('SET_NAME', user.userName)
commit('SET_AVATAR', avatar)
//TODO 获取用户信息时检查socket连接状态并进行连接
if (!vue.$socket.connected) {
vue.$socket.connect();
vue.$socket.emit('save', user.userId);
}
resolve(res)
}).catch(error => {
reject(error)
})
})
},
// 退出系统
LogOut({ commit, state }) {
return new Promise((resolve, reject) => {
logout(state.token).then(() => {
commit('SET_TOKEN', '')
commit('SET_ROLES', [])
commit('SET_PERMISSIONS', [])
//TODO 用户退出登录后清除用户并关闭连接
vue.$socket.emit('clear', store.state.user.id);
vue.$socket.close();
removeToken()
resolve()
}).catch(error => {
reject(error)
})
})
},
// 前端 登出
FedLogOut({ commit }) {
return new Promise(resolve => {
commit('SET_TOKEN', '')
removeToken()
resolve()
})
}
}
}
export default user
这里就可以直接引入之前暴露出去的 vue 来访问其中 $socket
最后在 App.vue 中监听自定义事件获取到消息就搞定了
<template>
<div id="app">
<router-view />
<theme-picker />
</div>
</template>
<script>
import ThemePicker from "@/components/ThemePicker";
export default {
name: "App",
components: { ThemePicker },
metaInfo() {
return {
title: this.$store.state.settings.dynamicTitle && this.$store.state.settings.title,
titleTemplate: title => {
return title ? `${title} - ${process.env.VUE_APP_TITLE}` : process.env.VUE_APP_TITLE
}
}
},
sockets: {
//监控接收消息自定义事件
message: data => {
console.log("APP接收到消息:" + data);
}
}
};
</script>
<style scoped>
#app .theme-picker {
display: none;
}
</style>