Spring WebSocket 与 STOMP 协议结合实现私聊私信功能
目录
- 后端
- pom.xml
- Config配置类
- Controller类
- DTO
- 前端
- 安装相关依赖
- websocketService.js接口
- javascript
- html
- CSS
- 效果展示
- 简单测试连接:
- 报错解决方法
- 1、vue3 使用SockJS报错 ReferenceError: global is not defined
- 功能补充拓展
- 1. 安全性和身份验证
- 2. 异常处理
- 3. 消息广播的功能
- 4. 配置 WebSocket 消息缓存和负载均衡
- 5. 客户端连接管理
- 6. WebSocket 消息格式和编码
- 总结
- 后面将继续完善,待更新...
后端
pom.xml
<!-- Spring Boot WebSocket-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Spring Boot 数据库支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Thymeleaf(如果你使用了模板) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Spring Boot Web 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Config配置类
注意:允许源根据自己项目修改
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
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;
@Configuration
@EnableWebSocketMessageBroker
@Slf4j
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/queue", "/topic","/user");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
// 在 WebSocket 握手时,我们可以通过 URL 参数或者 HTTP headers 传递用户身份信息。
// .addInterceptors(new MyHandshakeInterceptor())// 添加拦截器
.setAllowedOrigins("http://127.0.0.1:8889", "http://localhost:8889", "http://localhost:8888", "http://127.0.0.1:8888", "http://localhost:8000","http://localhost:8890","http://127.0.0.1:8890")
.withSockJS(); // 添加 SockJS 支持
}
}
Controller类
import com.tianwen.mapper.UserMessagesMapper;
import com.tianwen.user.dtos.MessageDTO;
import com.tianwen.user.pojos.Messages;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import java.time.LocalDateTime;
@Controller
public class ChatController {
@Autowired
private UserMessagesMapper userMessagesMapper;
@Autowired
private SimpMessagingTemplate messagingTemplate; // 注入消息模板,用于发送消息到指定目的地
@MessageMapping("/chat.sendMessage")//这个注解用来监听来自前端的 WebSocket 消息,路径是 /app/chat.sendMessage,当前端发送消息到这个路径时,sendMessage 方法会被触发。
// @SendTo("/topic/messages")//这个注解表明处理完消息后,返回的消息将广播给订阅了 /topic/messages 路径的所有客户端。
public MessageDTO sendMessage(MessageDTO messageDTO) throws Exception {
System.out.println("接收到的message:"+messageDTO);
// 1. 可以在这里进行私信存储到数据库操作
Messages messages = new Messages();
messages.setSenderId(messageDTO.getSenderId());
messages.setReceiverId(messageDTO.getReceiverId());
messages.setContent(messageDTO.getContent());
messages.setCreateTime(LocalDateTime.now());
// 2. 保存私信消息(插入操作)
if (userMessagesMapper.insert(messages) <= 0) {
// 如果插入失败,可以返回错误或做其他处理
return null;
}
// 3. 实时将消息转发给接收者
String receiverIdStr = String.valueOf(messageDTO.getReceiverId()); // 将 receiverId 转换为 String
String receiverDestination = "/user/" + receiverIdStr + "/queue/messages";
//通过 SimpMessagingTemplate 的 convertAndSendToUser 方法,将消息实时推送给接收者。
//推送的目标是 /user/{receiverId}/queue/messages,该路径是给特定用户的私有消息队列。
messagingTemplate.convertAndSendToUser(receiverIdStr, receiverDestination, messageDTO);
return messageDTO;
}
}
DTO
import lombok.Data;
@Data
public class MessageDTO {
private Integer senderId;
private Integer receiverId;
private String content;
}
前端
安装相关依赖
npm install sockjs-client@latest
npm install @stomp/stompjs sockjs-client
npm install global
npm i --save-dev @types/sockjs-client
websocketService.js接口
注意:服务器地址根据自己的修改(application.yml)
// websocketService.js
// Stomp.js:用于处理 STOMP 协议,它在 WebSocket 基础上实现了消息订阅、发送等功能。
import { Stomp } from "@stomp/stompjs";
//SockJS:是一个用于实现 WebSocket 的库,它为 WebSocket 提供了回退机制(例如 HTTP 长轮询等),确保在不同浏览器和网络环境下的兼容性。
import SockJS from "sockjs-client/dist/sockjs.min.js";
export default {
connect(onMessageReceived) {
//使用 SockJS 和 Stomp 创建一个 WebSocket 客户端,连接到后端的 WebSocket 服务 http://localhost:8000/ws。
const socket = new SockJS("http://localhost:8000/ws"); // 使用 SockJS 连接
const stompClient = Stomp.over(socket);
const userId = JSON.parse(localStorage.getItem("userId"));
stompClient.connect({}, () => {
console.log("本人消息队列ID:", userId);
//在连接成功后,通过 stompClient.subscribe 订阅 /topic/messages,接收从后端广播过来的消息。
// stompClient.subscribe("/topic/messages", (messageOutput) => {
// onMessageReceived(JSON.parse(messageOutput.body));
// });
// 订阅当前用户的私有消息队列
stompClient.subscribe(
"/user/" + userId + "/queue/messages",
(messageOutput) => {
onMessageReceived(JSON.parse(messageOutput.body)); // 处理接收到的私聊消息
}
);
// 订阅当前用户的私有消息队列;
// stompClient.subscribe(
// "/user/" + userId + "/queue/messages",
// function (messageOutput) {
// const message = JSON.parse(messageOutput.body);
// console.log("接收到私信:", message);
// }
// );
});
// 连接到 WebSocket 后,订阅用户消息
// stompClient.connect({}, function (frame) {
// // 获取当前用户ID
// const userId = getCurrentUserId(); // 假设这个方法能够获取当前用户的ID
// // 订阅接收者的消息队列
// stompClient.subscribe(
// "/user/" + userId + "/queue/messages",
// function (messageOutput) {
// const message = JSON.parse(messageOutput.body);
// console.log("接收到私信:", message);
// }
// );
// });
},
sendMessage(message) {
console.log("发送消息接口1:", message);
const socket = new SockJS("http://localhost:8000/ws"); // 使用 SockJS 连接
const stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
//当用户输入消息时,通过 stompClient.send 方法将消息发送到 /app/chat.sendMessage,这个路径会将消息推送到后端进行处理。
stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(message));
});
},
};
javascript
<script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import { onBeforeUnmount, ref, shallowRef, onMounted } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import type { IToolbarConfig, IEditorConfig } from "@wangeditor/editor";
const editorRef = shallowRef();
import websocketService from "@/api/websocketService.js";
import {
ref,
onMounted,
} from "vue";
import websocketService from "@/api/websocketService.js";
const receiverIdAnswer = ref();
const sendPrivateMessage = () => {
dialogVisible.value = true;
};
const sendPrivateMessage = async (userId) => {
receiverIdAnswer.value = userId;
dialogVisible.value = true;
const response = await getAuthorDetailsByUserId(userId);
console.log("response", userId);
privateMessagesUser.value = response.data;
console.log("privateMessagesUser", privateMessagesUser.value);
};
const dialogVisible = ref(false);
interface Message {
id: string;
senderId: string;
receiverId: string;
content: string;
}
const messages = ref<Message[]>([]); // 明确指定消息数组的类型
const newMessage = ref("");
onMounted(() => {
websocketService.connect((message: Message) => {
// 明确指定回调函数的参数类型
messages.value.push(message);
});
});
const sendMessage = () => {
if (newMessage.value.trim()) {
const message: Message = {
// 明确声明消息类型
id: Date.now().toString(), // 使用当前时间戳作为唯一 ID
senderId: userInfo.value.id, // Example sender
receiverId: receiverIdAnswer.value, // Example receiver
content: newMessage.value,
};
websocketService.sendMessage(message);
newMessage.value = "";
}
};
const editorConfig: Partial<IEditorConfig> = {
placeholder: "请输入...",
MENU_CONF: {},
};
const handleCreated = (editor) => {
editorRef.value = editor;
};
// 排除富文本的菜单项
const toolbarConfigPrivateMessages: Partial<IToolbarConfig> = {
// toolbar 配置
excludeKeys: [
"headerSelect",
"blockquote",
"|",
"bold",
"underline",
"italic",
"group-more-style", // 排除菜单组,写菜单组 key 的值即可
"color",
"bgColor",
"|",
"fontSize",
"fontFamily",
"lineHeight",
"bulletedList",
"numberedList",
"todo",
"group-justify",
"group-indent",
"insertLink",
"group-video",
"insertTable",
"codeBlock",
"divider",
"undo",
"redo",
"fullScreen",
],
};
</script>
html
<!-- 私信聊天框 -->
<el-dialog v-model="dialogVisible">
<template #title>
<div style="text-align: center; font-weight: bold">
{{ privateMessagesUser.username }}
</div>
<hr class="line" />
</template>
<div class="chat-container">
<div class="messages" ref="messagesContainer">
<div
v-for="message in messages"
:key="message.id"
:class="{
'my-message': message.senderId === userInfo.id,
'other-message': message.senderId !== userInfo.id,
}"
>
<div style="display: flex; flex-direction: row">
<div>
<el-image
:src="userInfo.avatarUrl"
style="width: 45px; border-radius: 50%"
></el-image>
</div>
<div style="margin-top: 5px; margin-left: 10px">
<div>
<strong>{{ userInfo.username }}</strong>
</div>
<div
class="message-bubble message-green"
v-html="message.content"
></div>
</div>
</div>
</div>
</div>
</div>
<div style="border: 1px solid #ccc">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
:defaultConfig="toolbarConfigPrivateMessages"
mode="default"
/>
<Editor
style="height: 200px; overflow-y: hidden"
v-model="newMessage"
@keyup.enter="sendMessage"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleCreated"
/>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="sendMessage">发送</el-button>
</template>
</el-dialog>
CSS
<style scoped>
/* 私信样式 */
/* 标题居中 */
/* .private-message-dialog {
} */
.line {
border-top: 1px solid #ccc; /* 直线的样式,可以修改颜色 */
}
/* 聊天框滚动 */
.chat-container {
display: flex;
flex-direction: column;
height: 300px;
overflow-y: auto;
box-sizing: border-box; /* 让 padding 和 border 包含在宽度和高度内 */
}
.chat-container > * {
width: 100%; /* 确保所有子元素不会超出容器宽度 */
box-sizing: border-box; /* 确保子元素的宽度计算不受 padding 和 border 影响 */
}
/* 消息容器 */
.messages {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
max-height: 250px;
overflow-y: auto;
}
/* 发送方和接收方的消息样式 */
.my-message {
/* text-align: right; */
border-radius: 10px;
height: auto;
/* padding: 5px 10px; */
}
.other-message {
/* text-align: left; */
border-radius: 10px;
/* padding: 5px 10px; */
}
.message-bubble {
/* max-width: 70%; */
padding: 0px 10px;
border-radius: 10px;
/* height: 30px; */
/* margin: 0px 0px 0px 0px; */
/* word-wrap: break-word; */
/* line-height: 1.4; */
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.message-green {
background-color: #57c457; /* 微信消息绿色 */
color: white;
align-self: flex-end; /* 让气泡靠右显示 */
}
</style>
效果展示
简单测试连接:
报错解决方法
1、vue3 使用SockJS报错 ReferenceError: global is not defined
解:
import SockJS from “sockjs-client”;
修改为:
import SockJS from “sockjs-client/dist/sockjs.min.js”;
并安装依赖
npm i --save-dev @types/sockjs-client
功能补充拓展
以下是一些可能的补充和优化,确保 WebSocket 能够顺利运行并且高效处理消息。
1. 安全性和身份验证
如果你的 WebSocket 服务需要进行身份验证(如用户登录),你可以考虑在 WebSocket 握手时验证用户身份。你可以在 WebSocketConfig
中添加一个 HandshakeInterceptor
来拦截握手请求,获取 HTTP header 或 URL 参数中的用户信息,确保只有经过身份验证的用户能够连接。
例如:
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.addInterceptors(new MyHandshakeInterceptor()) // 添加拦截器
.setAllowedOrigins("http://localhost:8889")
.withSockJS();
}
其中 MyHandshakeInterceptor
可以用来在 WebSocket 握手期间传递用户信息。
2. 异常处理
在 WebSocket 消息处理过程中,你可能会遇到一些异常,如数据库操作失败或消息传输失败等。在控制器中,你可以捕获这些异常并返回相应的错误消息,确保系统更加健壮。
例如:
@MessageMapping("/chat.sendMessage")
public MessageDTO sendMessage(MessageDTO messageDTO) {
try {
// 消息处理逻辑
} catch (Exception e) {
log.error("发送消息失败", e);
return new MessageDTO("error", "消息发送失败");
}
}
3. 消息广播的功能
目前,你的代码实现了将消息发送到指定用户的功能(私信)。如果你希望实现群聊功能或全局广播,可以进一步扩展 @SendTo
注解。这个注解的使用使得你可以将处理后的消息发送给所有订阅某个特定主题的客户端。
例如,广播消息给所有订阅 /topic/messages
的客户端:
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/messages")
public MessageDTO sendMessage(MessageDTO messageDTO) {
// 处理消息逻辑
return messageDTO; // 返回的消息会广播给所有订阅 /topic/messages 的客户端
}
4. 配置 WebSocket 消息缓存和负载均衡
如果你的系统需要处理大量的 WebSocket 连接,可能会面临性能和可扩展性的问题。在这种情况下,考虑使用 Redis 等消息队列作为消息代理,可以通过 @EnableWebSocketMessageBroker
配置远程消息代理。
例如,通过 Redis 实现消息广播:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/queue", "/topic", "/user");
config.setApplicationDestinationPrefixes("/app");
// 使用 Redis 消息代理
config.setBrokerRegistry()
.setApplicationDestinationPrefixes("/app")
.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("localhost")
.setRelayPort(61613); // 配置Redis(如ActiveMQ)等消息中间件
}
}
5. 客户端连接管理
如果你希望在某个用户断开连接时进行一些清理工作(例如清理会话、推送通知等),你可以使用 @OnDisconnect
注解来捕获断开连接事件。
@MessageMapping("/chat.disconnect")
public void handleDisconnect(SessionDisconnectEvent event) {
// 用户断开连接时执行的逻辑
log.info("用户断开连接,sessionId: " + event.getSessionId());
}
6. WebSocket 消息格式和编码
确保你的客户端和服务端使用相同的消息格式。你可能需要为 WebSocket 消息提供适当的序列化和反序列化器,以确保消息能够正确地从客户端传输到服务端,以及从服务端传输回客户端。
例如,使用 Jackson 或其他库将 Java 对象序列化为 JSON 格式:
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/messages")
public MessageDTO sendMessage(MessageDTO messageDTO) throws Exception {
// 消息传输过程中确保使用适当的 JSON 格式
return messageDTO;
}
总结
大体上,已经完成了 WebSocket 的配置和处理消息的核心部分,剩下的步骤主要是根据具体业务需求做扩展,如身份验证、消息缓存、广播支持等。如果你的应用规模较大,可能还需要考虑负载均衡和消息队列等高可用性的设计。