介绍
在企业后台、物联网、告警通知等场景中,后端经常需要主动向前端推送实时数据。相比传统轮询,WebSocket + STOMP 是目前更高效、更主流的方案,无需前端频繁请求,后端可主动推送,大幅提升性能和用户体验。
一、STOMP 是什么?
STOMP 是基于 WebSocket 的简单文本消息协议,核心作用是提供标准的订阅/发布(Pub/Sub)模式。它运行在 WebSocket 长连接之上,相当于给“连接通道”定了一套通信规则,让前端可以订阅指定主题,后端向主题发送消息时,所有订阅该主题的前端会自动接收消息,无需手动管理连接和消息分发逻辑。
二、方案优势
- 长连接特性,低延迟、轻量高效,避免传统轮询的资源浪费;
- 自带广播、单点推送、主题订阅能力,无需手动开发分发逻辑;
- SpringBoot 官方原生支持,Vue3 生态适配成熟,集成成本低;
- 无需手动维护前端连接状态,框架自动管理,降低开发和维护成本。
三、整体实现流程
1. 后端(SpringBoot3)实现
开启 STOMP 消息代理,配置连接端点和消息主题规则,暴露前端可连接的地址,同时提供消息推送接口,可向指定主题或单个用户推送消息,无需关注前端连接细节。
2. 前端(Vue3)实现
基于 Vue3 环境,通过 STOMP 相关依赖建立与后端的长连接,无需手动处理 WebSocket 底层逻辑;连接成功后,订阅后端定义的消息主题,即可实时接收后端推送的消息;同时支持主动向后端发送消息,满足双向通信需求。
前端核心逻辑简洁:初始化连接 → 连接成功后订阅指定主题 → 监听并处理接收的实时消息 → 断开连接时释放资源,全程无需关注连接管理和消息分发,框架自动完成。
四、适用场景
该方案广泛应用于企业级开发,尤其适合以下场景:
- 管理后台实时通知、系统告警推送(如设备异常、审批提醒);
- 物联网平台(如智慧农业、工业监控)的设备状态、实时数据推送;
- 聊天室、系统公告广播、多端数据同步等场景。
后端
添加依赖
<!-- Spring Boot WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
修改原授权校验
修改 SecurityConfig 类, 放行 WebSocket 校验,添加下面代码:
// 放行 WebSocket 端点(SockJS 和 STOMP)
.requestMatchers("/ws/**").permitAll()
.requestMatchers("/ws").permitAll()
添加websocket授权校验
添加 WebSocketAuthInterceptor 类,校验逻辑与过滤器类似,校验token,内容如下:
package com.smartagriculture.config;
import com.smartagriculture.utils.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
/**
* WebSocket 认证拦截器
* 在客户端连接时验证 JWT token
*/
@Slf4j
@Component
public class WebSocketAuthInterceptor implements ChannelInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
// 从连接头中获取 token
String token = accessor.getFirstNativeHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
try {
// 验证 token
if (jwtUtil.validateToken(token)) {
String username = jwtUtil.getUsernameFromToken(token);
accessor.setUser(() -> username);
log.info("WebSocket 连接认证成功,用户: {}", username);
} else {
log.warn("WebSocket 连接认证失败:无效的 token");
throw new RuntimeException("无效的 token");
}
} catch (Exception e) {
log.error("WebSocket 连接认证失败: {}", e.getMessage());
throw new RuntimeException("Token 验证失败");
}
} else {
log.warn("WebSocket 连接缺少 Authorization 头");
throw new RuntimeException("缺少认证信息");
}
}
return message;
}
}
添加websocket配置类
创建 WebSocketConfig 类,内容如下:
package com.smartagriculture.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* WebSocket 配置类(使用 STOMP 协议)
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Autowired
private WebSocketAuthInterceptor webSocketAuthInterceptor;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 启用简单消息代理,用于广播消息
config.enableSimpleBroker("/topic");
// 配置应用前缀,用于处理客户端发送的消息
config.setApplicationDestinationPrefixes("/app");
// 配置用户前缀,用于一对一消息
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 注册 STOMP 端点,只使用原生 WebSocket
registry.addEndpoint("/ws")
.setAllowedOrigins("*");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// 添加认证拦截器
registration.interceptors(webSocketAuthInterceptor);
}
}
前端
安装依赖
npm install @stomp/stompjs
提示:不要使用 sockjs-client,与vite不兼容,运行可行,打包报错
添加服务
创建文件 src/services/websocket.js 内容如下:
/**
* WebSocket 服务封装
* 使用原生 WebSocket + STOMP.js 与后端通信
*/
import { Stomp } from '@stomp/stompjs';
class WebSocketService {
constructor() {
this.stompClient = null;
this.subscriptions = new Map();
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 3000;
this.connectionPromise = null;
this.messageQueue = [];
}
/**
* 获取 WebSocket 连接 URL
*/
getWebSocketUrl() {
// 使用与 API 相同的基础 URL,但替换为 WebSocket 端点
const baseURL = 'http://localhost:9999';
// 将 http 改为 ws,https 改为 wss
const wsURL = baseURL.replace('http://', 'ws://').replace('https://', 'wss://');
return `${wsURL}/ws`;
}
/**
* 获取 JWT Token
*/
getToken() {
return localStorage.getItem('token') || '';
}
/**
* 建立 WebSocket 连接
* @returns {Promise} 连接成功的 Promise
*/
connect() {
if (this.connectionPromise) {
return this.connectionPromise;
}
if (this.isConnected && this.stompClient) {
return Promise.resolve();
}
this.connectionPromise = new Promise((resolve, reject) => {
const token = this.getToken();
if (!token) {
reject(new Error('未找到认证 token'));
return;
}
const wsUrl = this.getWebSocketUrl();
const socket = new WebSocket(wsUrl);
this.stompClient = Stomp.over(socket);
// 配置 STOMP 客户端
this.stompClient.configure({
reconnectDelay: this.reconnectDelay,
maxReconnectAttempts: this.maxReconnectAttempts,
onStompError: (frame) => {
console.error('STOMP 错误:', frame);
},
onWebSocketError: (error) => {
console.error('WebSocket 错误:', error);
this.isConnected = false;
this.connectionPromise = null;
reject(new Error('WebSocket 连接错误'));
},
onDisconnect: () => {
console.log('WebSocket 连接断开');
this.isConnected = false;
this.stompClient = null;
this.connectionPromise = null;
}
});
// 连接到 STOMP 服务器
this.stompClient.connect(
{ 'Authorization': `Bearer ${token}` },
() => {
console.log('STOMP 连接成功');
console.log('当前连接状态:', this.stompClient.connected);
// 连接成功后的处理
console.log('WebSocket 连接成功');
this.isConnected = true;
this.reconnectAttempts = 0;
this.connectionPromise = null;
// 处理队列中的消息
this.flushMessageQueue();
resolve();
},
(error) => {
console.error('STOMP 连接失败:', error);
this.isConnected = false;
this.connectionPromise = null;
reject(new Error('STOMP 连接失败'));
}
);
});
return this.connectionPromise;
}
/**
* 断开 WebSocket 连接
*/
disconnect() {
if (this.stompClient) {
this.stompClient.disconnect(() => {
console.log('STOMP 连接已断开');
});
this.stompClient = null;
}
this.subscriptions.clear();
this.isConnected = false;
this.connectionPromise = null;
this.messageQueue = [];
console.log('WebSocket 连接已断开');
}
/**
* 订阅主题
* @param {string} topic 订阅主题,如 '/topic/device/realtime/123'
* @param {function} callback 收到消息时的回调函数
* @returns {string} 订阅ID,用于取消订阅
*/
subscribe(topic, callback) {
if (!topic || typeof callback !== 'function') {
console.error('订阅参数无效');
return null;
}
// 如果已经订阅过,先取消
if (this.subscriptions.has(topic)) {
this.unsubscribe(topic);
}
if (this.isConnected && this.stompClient) {
const subscription = this.stompClient.subscribe(topic, (message) => {
try {
const data = JSON.parse(message.body);
callback(data);
} catch (error) {
console.error('解析消息失败:', error);
}
});
this.subscriptions.set(topic, subscription);
console.log(`订阅成功: ${topic}`);
return topic;
} else {
// 加入消息队列
this.messageQueue.push({ type: 'SUBSCRIBE', topic, callback });
console.log(`订阅请求加入队列 (等待连接): ${topic}`);
// 尝试连接
if (!this.connectionPromise) {
this.connect().catch(error => {
console.error('连接失败,订阅失败:', error);
});
}
return topic;
}
}
/**
* 取消订阅
* @param {string} topic 订阅主题
*/
unsubscribe(topic) {
if (this.subscriptions.has(topic)) {
const subscription = this.subscriptions.get(topic);
if (subscription) {
subscription.unsubscribe();
console.log(`取消订阅: ${topic}`);
}
this.subscriptions.delete(topic);
}
}
/**
* 发送消息
* @param {string} topic 发送主题
* @param {object} data 消息数据
*/
send(topic, data = {}) {
if (this.isConnected && this.stompClient) {
this.stompClient.send(topic, {}, JSON.stringify(data));
console.log(`发送消息到: ${topic}`, data);
} else {
// 加入消息队列
this.messageQueue.push({ type: 'SEND', topic, data });
console.log(`消息加入队列 (等待连接): ${topic}`, data);
// 尝试连接
if (!this.connectionPromise) {
this.connect().catch(error => {
console.error('连接失败,消息发送失败:', error);
});
}
}
}
/**
* 处理队列中的消息
*/
flushMessageQueue() {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
if (this.isConnected && this.stompClient) {
if (message.type === 'SUBSCRIBE') {
this.subscribe(message.topic, message.callback);
} else if (message.type === 'SEND') {
this.send(message.topic, message.data);
}
}
}
}
/**
* 请求设备历史数据
* @param {number} deviceId 设备ID
* @param {number} limit 每页数量
* @param {number} offset 偏移量
*/
requestDeviceHistory(deviceId, limit = 100, offset = 0) {
const topic = `/app/device/history/${deviceId}`;
this.send(topic, { limit, offset });
}
/**
* 订阅设备历史数据响应
* @param {function} callback 收到数据时的回调函数
*/
subscribeDeviceHistory(callback) {
return this.subscribe('/user/queue/device/history', callback);
}
/**
* 订阅设备实时数据
* @param {number} deviceId 设备ID
* @param {function} callback 收到数据时的回调函数
*/
subscribeDeviceRealtime(deviceId, callback) {
return this.subscribe(`/topic/device/realtime/${deviceId}`, callback);
}
}
// 导出单例实例
export const websocketService = new WebSocketService();
export default websocketService;
修改页面
在需要调用 websocket 的页面上加下面代码:
import websocketService from '../services/websocket'
// 初始化WebSocket连接
async initWebSocketConnection() {
if (!this.selectedDevice || !this.selectedDevice.id) return
console.log('开始初始化WebSocket连接,设备ID:', this.selectedDevice.id)
try {
// 建立连接
console.log('正在建立WebSocket连接...')
await websocketService.connect()
this.isConnected = true
console.log('WebSocket连接建立成功')
// 订阅实时数据
console.log('正在订阅设备实时数据,主题:', `/topic/device/realtime/${this.selectedDevice.id}`)
this.subscriptionId = websocketService.subscribeDeviceRealtime(this.selectedDevice.id, (data) => {
this.handleRealtimeData(data)
})
console.log('订阅成功,订阅ID:', this.subscriptionId)
// 初始获取一次数据(可选,确保页面加载时有数据)
this.fetchInitialData()
} catch (error) {
console.error('WebSocket 连接失败:', error)
this.isConnected = false
}
},
// 断开WebSocket连接
disconnectWebSocket() {
if (this.subscriptionId) {
websocketService.unsubscribe(this.subscriptionId)
this.subscriptionId = null
}
websocketService.disconnect()
this.isConnected = false
},
// 处理实时数据
handleRealtimeData(data) {
console.log('收到实时数据:', data)
if (data) {
this.$emit('update:deviceData', data)
}
},