跳到主要内容WebSocket 连接教程示例(Spring Boot + STOMP + SockJS + Vue) | 极客日志Java大前端java
WebSocket 连接教程示例(Spring Boot + STOMP + SockJS + Vue)
基于 Spring Boot 和 STOMP 协议的 WebSocket 服务实现。内容涵盖后端配置(依赖、消息代理、拦截器)、公共与私有频道认证机制、心跳优化及线程池设置,并提供了 Vue 3 前端集成示例(SockJS + @stomp/stompjs),实现了公共广播与点对点私信功能。
星云9 浏览 WebSocket 连接教程示例(Spring Boot + STOMP + SockJS + Vue)
一、整体架构介绍
本示例实现了一个基于 Spring Boot STOMP 协议的 WebSocket 服务,支持:
- 公共频道:无需认证,直接连接
- 私有频道:需要 HTTP 会话认证
- 心跳机制:保持连接活跃
- 线程池优化:高性能消息处理
二、后端 Spring Boot 配置
1. 核心依赖 (pom.xml)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
</dependency>
</dependencies>
2. WebSocket 主配置类
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketHandshakeInterceptor handshakeInterceptor;
private final WebSocketAuthInterceptor authInterceptor;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/public", "/private").setHeartbeatValue(new long[]{10000, 10000});
config.setApplicationDestinationPrefixes("/hanhan");
config.setUserDestinationPrefix("/user");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.taskExecutor()
.corePoolSize(10)
.maxPoolSize(20)
.queueCapacity(100)
.keepAliveSeconds(60);
registration.interceptors(authInterceptor);
}
@Override
public void configureClientOutboundChannel(ChannelRegistration registration) {
registration.taskExecutor()
.corePoolSize(10)
.maxPoolSize(20)
.queueCapacity(100)
.keepAliveSeconds(60);
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/hanhan")
.addInterceptors(handshakeInterceptor)
.setAllowedOriginPatterns("http://localhost:5173", "https://hanhanys.cpolar.cn")
.withSockJS()
.setDisconnectDelay(30 * 1000);
}
}
3. 握手拦截器(HandshakeInterceptor)
@Slf4j
@Component
public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
private static final String CONNECT_WS_PREFIX = "/ws/hanhan";
private static final String PUBLIC_WS_PREFIX = "/ws/public/";
private static final String PRIVATE_WS_PREFIX = "/ws/private/";
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) {
try {
if (!(request instanceof ServletServerHttpRequest)) {
log.warn("非 Servlet 请求,拒绝握手");
return false;
}
String requestPath = request.getURI().getPath();
log.info("WebSocket 握手请求路径:{}", requestPath);
if (requestPath.startsWith(CONNECT_WS_PREFIX)) {
String channelID = requestPath.substring(CONNECT_WS_PREFIX.length());
attributes.put("channelID", channelID);
attributes.put("channelType", "PUBLIC");
attributes.put("authentication", null);
log.info("公共连接端点:channelID={}", channelID);
return true;
}
if (requestPath.startsWith(PUBLIC_WS_PREFIX)) {
String channelID = requestPath.substring(PUBLIC_WS_PREFIX.length());
attributes.put("channelID", channelID);
attributes.put("channelType", "PUBLIC");
attributes.put("authentication", null);
log.info("公共频道连接:channelID={}", channelID);
return true;
}
if (requestPath.startsWith(PRIVATE_WS_PREFIX)) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
HttpServletRequest httpRequest = servletRequest.getServletRequest();
HttpSession httpSession = httpRequest.getSession(false);
if (httpSession == null) {
log.warn("私有频道需要认证,但无 HTTP 会话");
return false;
}
Authentication auth = (Authentication) httpSession.getAttribute("authentication");
if (auth == null || !auth.isAuthenticated()) {
log.warn("用户未认证或认证已过期");
return false;
}
String channelID = requestPath.substring(PRIVATE_WS_PREFIX.length());
attributes.put("channelID", channelID);
attributes.put("channelType", "PRIVATE");
attributes.put("authentication", auth);
log.info("私有频道握手成功:用户={}, channelID={}", auth.getName(), channelID);
return true;
}
log.warn("未知的 WebSocket 路径:{}", requestPath);
return false;
} catch (Exception e) {
log.error("握手过程异常:{}", e.getMessage(), e);
return false;
}
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
if (exception != null) {
log.error("握手后处理异常:{}", exception.getMessage());
}
}
}
4. 认证拦截器(ChannelInterceptor)
@Slf4j
@Component
public class WebSocketAuthInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor == null) {
return message;
}
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
Map<String, Object> sessionAttrs = accessor.getSessionAttributes();
if (sessionAttrs == null) {
log.warn("WebSocket 连接被拒绝:无 session 属性");
return null;
}
if ("PUBLIC".equals(sessionAttrs.get("channelType"))) {
String channelId = (String) sessionAttrs.get("channelID");
log.info("公共频道连接成功:channelID={}", channelId);
return message;
}
Authentication auth = (Authentication) sessionAttrs.get("authentication");
if (auth != null && auth.isAuthenticated()) {
SecurityContextHolder.getContext().setAuthentication(auth);
accessor.setUser(auth);
log.info("私有频道连接成功:用户={}", auth.getName());
return message;
}
log.warn("WebSocket 连接被拒绝:认证失败");
return null;
}
return message;
}
}
5. 消息控制器示例
@Controller
@Slf4j
public class WebSocketController {
@MessageMapping("/message")
@SendTo("/public/chat")
public ChatMessage handleMessage(ChatMessage message) {
log.info("收到消息:{}", message);
message.setTimestamp(new Date());
return message;
}
@MessageMapping("/private")
public void sendPrivateMessage(@Payload PrivateMessage message, @Header("simpSessionId") String sessionId) {
log.info("私密消息:from={}, to={}", message.getFrom(), message.getTo());
simpMessagingTemplate.convertAndSendToUser(
message.getTo(),
"/queue/private",
message
);
}
}
@Data
class ChatMessage {
private String from;
private String content;
private Date timestamp;
}
@Data
class PrivateMessage {
private String from;
private String to;
private String content;
}
三、前端实现(Vue 3 + SockJS + STOMP)
1. 安装依赖
npm install sockjs-client @stomp/stompjs
2. WebSocket 工具类
import SockJS from 'sockjs-client';
import { Client } from '@stomp/stompjs';
class WebSocketService {
constructor() {
this.stompClient = null;
this.subscriptions = new Map();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
}
connectToPublicChannel(channelId, onMessage) {
const socketUrl = 'http://localhost:8080/ws/hanhan';
const socket = new SockJS(socketUrl);
this.stompClient = new Client({
webSocketFactory: () => socket,
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
onConnect: (frame) => {
console.log('公共频道连接成功', frame);
const subscription = this.stompClient.subscribe(`/public/${channelId}`, (message) => {
const parsed = JSON.parse(message.body);
onMessage(parsed);
});
this.subscriptions.set(`public_${channelId}`, subscription);
},
onStompError: (frame) => {
console.error('STOMP 协议错误:', frame);
},
onWebSocketError: (event) => {
console.error('WebSocket 连接错误:', event);
this.handleReconnect();
}
});
this.stompClient.activate();
}
connectToPrivateChannel(channelId, token, onMessage) {
const socketUrl = `http://localhost:8080/ws/private/${channelId}`;
const socket = new SockJS(socketUrl, null, {
headers: {
'Authorization': `Bearer ${token}`
}
});
this.stompClient = new Client({
webSocketFactory: () => socket,
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
onConnect: (frame) => {
console.log('私有频道连接成功', frame);
const subscription = this.stompClient.subscribe(`/private/${channelId}`, (message) => {
const parsed = JSON.parse(message.body);
onMessage(parsed);
});
const userSubscription = this.stompClient.subscribe(`/user/queue/private`, (message) => {
const parsed = JSON.parse(message.body);
console.log('收到私信:', parsed);
});
this.subscriptions.set(`private_${channelId}`, subscription);
this.subscriptions.set('user_queue', userSubscription);
},
onDisconnect: () => {
console.log('WebSocket 连接断开');
}
});
this.stompClient.activate();
}
sendMessage(destination, payload) {
if (this.stompClient && this.stompClient.connected) {
this.stompClient.publish({
destination: `/hanhan${destination}`,
body: JSON.stringify(payload)
});
} else {
console.warn('WebSocket 未连接,无法发送消息');
}
}
sendPrivateMessage(toUser, content) {
this.sendMessage('/private', {
from: 'currentUser',
to: toUser,
content: content,
timestamp: new Date().toISOString()
});
}
unsubscribe(subscriptionKey) {
const subscription = this.subscriptions.get(subscriptionKey);
if (subscription) {
subscription.unsubscribe();
this.subscriptions.delete(subscriptionKey);
}
}
disconnect() {
if (this.stompClient) {
this.subscriptions.forEach(sub => sub.unsubscribe());
this.subscriptions.clear();
this.stompClient.deactivate();
this.stompClient = null;
console.log('WebSocket 已断开连接');
}
}
handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => {
if (this.stompClient) {
this.stompClient.activate();
}
}, 3000);
}
}
}
export default new WebSocketService();
3. Vue 组件使用示例
<template>
<div class="chat-room">
<div :class="['status', { connected: isConnected }]">
{{ isConnected ? '已连接' : '未连接' }}
</div>
<!-- 频道选择 -->
<div class="channel-selector">
<button @click="connectPublic('general')">连接公共聊天室</button>
<button @click="connectPrivate('private-room', userToken)">连接私有房间</button>
<button @click="disconnect" :disabled="!isConnected">断开连接</button>
</div>
<!-- 消息列表 -->
<div class="message-list">
<div v-for="(msg, index) in messages" :key="index" class="message">
<span class="sender">{{ msg.from }}:</span>
<span class="content">{{ msg.content }}</span>
<span class="time">{{ formatTime(msg.timestamp) }}</span>
</div>
</div>
<!-- 消息发送 -->
<div class="message-input">
<input v-model="inputMessage" @keyup.enter="sendMessage" placeholder="输入消息..." :disabled="!isConnected" />
<button @click="sendMessage" :disabled="!isConnected">发送</button>
</div>
<!-- 私信发送 -->
<div class="private-message">
<input v-model="privateTo" placeholder="目标用户" />
<input v-model="privateContent" placeholder="私信内容" />
<button @click="sendPrivate">发送私信</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import websocket from '@/utils/websocket';
const isConnected = ref(false);
const messages = ref([]);
const inputMessage = ref('');
const privateTo = ref('');
const privateContent = ref('');
const userToken = ref('your-auth-token'); // 从登录状态获取
// 连接公共频道
const connectPublic = (channelId) => {
websocket.connectToPublicChannel(channelId, (message) => {
messages.value.push(message);
console.log('收到公共消息:', message);
});
isConnected.value = true;
};
// 连接私有频道
const connectPrivate = (channelId, token) => {
websocket.connectToPrivateChannel(channelId, token, (message) => {
messages.value.push(message);
console.log('收到私有消息:', message);
});
isConnected.value = true;
};
// 发送公共消息
const sendMessage = () => {
if (!inputMessage.value.trim()) return;
const message = { from: '当前用户', content: inputMessage.value, type: 'public' };
websocket.sendMessage('/message', message);
inputMessage.value = '';
};
// 发送私信
const sendPrivate = () => {
if (!privateTo.value || !privateContent.value) return;
websocket.sendPrivateMessage(privateTo.value, privateContent.value);
privateContent.value = '';
};
// 断开连接
const disconnect = () => {
websocket.disconnect();
isConnected.value = false;
messages.value = [];
};
// 格式化时间
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString();
};
// 组件生命周期
onMounted(() => {
console.log('聊天室组件已加载');
});
onUnmounted(() => {
disconnect();
});
</script>
<style scoped>
.chat-room {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.status {
padding: 8px 16px;
border-radius: 4px;
margin-bottom: 20px;
background: #ff6b6b;
color: white;
}
.status.connected {
background: #51cf66;
}
.channel-selector {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.channel-selector button {
padding: 10px 20px;
background: #339af0;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.channel-selector button:disabled {
background: #ccc;
cursor: not-allowed;
}
.message-list {
height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
margin-bottom: 20px;
}
.message {
padding: 8px;
border-bottom: 1px solid #eee;
}
.message .sender {
font-weight: bold;
margin-right: 10px;
color: #339af0;
}
.message .time {
float: right;
color: #999;
font-size: 0.8em;
}
.message-input, .private-message {
display: flex;
gap: 10px;
margin-top: 10px;
}
input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 10px 20px;
background: #51cf66;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
4. 主应用入口
import { createApp } from 'vue';
import App from './App.vue';
const app = createApp(App);
app.config.globalProperties.$websocket = websocket;
app.mount('#app');