文章目录
- 学习链接
- 后台代码
- 引入依赖
- application.yml
- WebSocketConfig
- PrivateController
- WebSocketService
- WebSocketEventListener
- CorsFilter
- 前端代码
- Room.vue
学习链接
WebSocket入门教程示例代码,代码地址已fork至本地gitee,原github代码地址,源老外的代码地址
- [WebSocket入门]手把手搭建WebSocket多人在线聊天室(SpringBoot+WebSocket)
- [WebSocket]第二章:WebSocket集群分布式改造——实现多人在线聊天室
- [WebSocket]使用WebSocket实现实时多人答题对战游戏
其它可参考
-
手把手搭建WebSocket多人在线聊天室(SpringBoot+WebSocket),这个比较详细,排版看上去比较舒服
-
springboot集成websocket小案例 bilibili视频
-
SpringBoot+STOMP 实现聊天室(单聊+多聊)及群发消息详解
-
springboot+websocket构建在线聊天室(群聊+单聊)
-
基于STOMP协议的WebSocket
-
spring websocket + stomp 实现广播通信和一对一通信 ,这个用法很详细
深入使用
-
SpringBoot——整合WebSocket(STOMP协议) 原创
-
SpringBoot——整合WebSocket(基于STOMP协议)
-
点对点通信
后续使用rabbimtmq作为消息代理实现时,参考的文章
- Docker容器添加映射端口的两种实现方法,因为需要rabbitmq需要开启rabbitmq_web_stomp插件、rabbitmq_web_stomp_examples插件,开启方式参考下面这个链接,然后需要在docker和主机之间开启端口映射
- Rabbitmq报错:Connection refused: no further information: /ip:61613,使用rabbitmq作为消息代理,需要让我们的服务连接到rabbitmq,并且mq要开启rabbitmq_web_stomp插件、rabbitmq_web_stomp_examples插件
后台代码
引入依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.timeless</groupId>
<artifactId>timeless-chat-websocket</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.4</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--<dependency>-->
<!-- <groupId>com.itheima</groupId>-->
<!-- <artifactId>pd-tools-swagger2</artifactId>-->
<!-- <version>1.0-SNAPSHOT</version>-->
<!--</dependency>-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- RabbitMQ Starter Dependency -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Following additional dependency is required for Full Featured STOMP Broker Relay -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-reactor-netty</artifactId>
</dependency>
</dependencies>
</project>
application.yml
server:
port: 8888
spring:
application:
name: timeless-chat-websocket
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/timeless_chat_websocket?serverTimeZone=UTC
username: root
password: root
mvc:
pathmatch:
# Springboot2.6以后将SpringMVC 默认路径匹配策略从AntPathMatcher 更改为PathPatternParser
#
matching-strategy: ANT_PATH_MATCHER
rabbitmq:
host: ${rabbitmq.host}
port: ${rabbitmq.port}
username: ${rabbitmq.username}
password: ${rabbitmq.password}
virtual-host: ${rabbitmq.virtual-host}
#pinda:
# swagger:
# enabled: true
# title: timeless文档
# base-package: com.timeless.controller
mybatis-plus:
configuration:
log-impl: com.timeless.utils.NoLog
WebSocketConfig
@Configuration
@Slf4j
@EnableWebSocketMessageBroker
@EnableConfigurationProperties(RabbitMQProperties.class)
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private RabbitMQProperties rabbitMQProperties;
public WebSocketConfig(RabbitMQProperties rabbitMQProperties) {
this.rabbitMQProperties = rabbitMQProperties;
log.info("连接rabbitmq, host: {}", rabbitMQProperties.getHost());
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
// 这个和客户端创建连接时的url有关,后面在客户端的代码中可以看到
.addEndpoint("/ws")
.addInterceptors(new HandshakeInterceptor() {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
log.info("客户端握手即将开始===================【开始】");
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
log.info("请求路径: {}", ((ServletServerHttpRequest) request).getServletRequest().getRequestURL());
log.info("校验请求头,以验证用户身份: {}", JsonUtil.obj2Json(servletRequest.getHeaders()));
HttpSession session = servletRequest.getServletRequest().getSession();
attributes.put("sessionId", session.getId());
return true;
}
log.info("客户端握手结束=================== 【失败】");
return false;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
log.info("客户端握手结束=================== 【成功】");
}
})
// .setAllowedOrigins("http://localhost:8080")
// 当传入*时, 使用该方法, 而不要使用setAllowedOrigins("*")
.setAllowedOriginPatterns("*")
.withSockJS()
;
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 1. 当客户端发送消息或订阅消息时,url路径开头如果是/app/xxx 时,会先解析stomp协议,然后路由到@controller的@MessageMapping("/xxx")的方法上执行。
// 如果不设置,客户端所有发送消息或订阅消息时、都将去匹配@messageMapping。所以最好还是配置上。
// 2. 这句表示客户端向服务端发送时的主题上面需要加"/app"作为前缀
registry.setApplicationDestinationPrefixes("/app");
// 1. 基于内存的消息代理
// 2. 声明消息中间件Broker的主题名称,当向这个主题下发送消息时(js: stompclient.send("/topic/target1",{},"hello")),订阅当前主题的客户端都可以收到消息。
// 注意:js 客户端如果发送时、直接是/topic/xxx,spring收到消息会直接发送给broker中。
// 点对点发送时:enableSimpleBroker 中要配置 /user才可以用: template.convertAndSendToUser("zhangsan","/aaa/hello","111"),否则收不到消息
// 3. 这句表示在topic和user这两个域上可以向客户端发消息
registry.enableSimpleBroker("/topic", "/user");
// 1. 点对点发送前缀
// 2. 这句表示给指定用户发送(一对一)的主题前缀是 /user
registry.setUserDestinationPrefix("/user");
// Use this for enabling a Full featured broker like RabbitMQ
/*
// 基于mq的消息代理
registry.enableStompBrokerRelay("/topic")
.setVirtualHost(rabbitMQProperties.getVirtualHost())
.setRelayHost(rabbitMQProperties.getHost())
.setRelayPort(61613)
.setClientLogin(rabbitMQProperties.getUsername())
.setClientPasscode(rabbitMQProperties.getPassword())
.setSystemLogin(rabbitMQProperties.getUsername())
.setSystemPasscode(rabbitMQProperties.getPassword())
.setSystemHeartbeatSendInterval(5000)
.setSystemHeartbeatReceiveInterval(5000);
*/
}
}
PrivateController
@Slf4j
@RestController
public class PrivateController {
@Autowired
private WebSocketService ws;
// 1. 这个注解其实就是用来定义接受客户端发送消息的url(不能是topic开头,如果是topic直接发送给broker了,要用/app/privateChat)
// 如果有返回值,则会将返回的内容转换成stomp协议格式发送给broker(主题名:/topic/privateChat)。如果要换主题名可使用@sendTo
// @SubscribeMapping注解和@messageMapping差不多,但不会再把内容发给broker,而是直接将内容响应给客户端,
@MessageMapping("/privateChat")
public void privateChat(PrivateMessage message) {
ws.sendChatMessage(message);
}
// 客户端向 /app/broadcastMsg 发送消息, 将会使用该方法处理,
// 并且因为此方法有返回值, 所以将结果又发送到/topic/broadcastMsg, 因此订阅了/topic/broadcastMsg的客户端将会收到此消息
@MessageMapping("/broadcastMsg")
@SendTo("/topic/broadcastMsg")
public BroadcastMessage broadcastMsg(@Payload BroadcastMessage message,
SimpMessageHeaderAccessor headerAccessor) {
// 理解为会话添加属性标识
headerAccessor.getSessionAttributes().put("extraInfo", message.getFromUsername());
message.setContent("广播消息>>> " + message.getContent());
return message;
}
// 客户端向 /app/userMsg 发送消息, 将会使用该方法处理,(谁请求, 则发送给谁, 不会发送给其它的用户)
// 并且因为此方法有返回值, 所以将结果又发送到 /user/{username}/singleUserMsg, 其中username
@MessageMapping("/userMsg")
// broadcast设置为false表示: 将消息只回给发送此消息的会话用户(1个用户可能有多个会话)
@SendToUser(value = "/singleUserMsg", broadcast = false) // ?????????????????????????????????????
public BroadcastMessage singleUserMsg(@Payload BroadcastMessage message,
SimpMessageHeaderAccessor headerAccessor) {
headerAccessor.getSessionAttributes().put("username", message.getFromUsername());
message.setContent("用户消息>>> " + message.getContent());
return message;
}
@Autowired
private SimpMessagingTemplate template;
// 广播推送消息
// (向此接口发送请求, 将会向所有的订阅了 /topic/broadcastMsg的客户端发送消息)
@RequestMapping("/sendTopicMessage")
public void sendTopicMessage(String content) {
template.convertAndSend("/topic/broadcastMsg", content);
}
// 点对点消息
@RequestMapping("/sendPointMessage")
// (向此接口发送请求, 将会向所有的订阅了 /user/{targetUsername}/singleUserMsg 的客户端发送消息。
// 这种方式调用的前提是需要registry.enableSimpleBroker("/topic", "/user");
// registry.setUserDestinationPrefix("/user");)
// 这2个同时配置了才能使用的
public void sendQueueMessage(String targetUsername, String content) {
this.template.convertAndSendToUser(targetUsername, "/singleUserMsg", content);
}
}
WebSocketService
@Service
public class WebSocketService {
@Autowired
private SimpMessagingTemplate template;
@Autowired
private PrivateMessageService privateMessageService;
/**
* 简单点对点聊天室
*/
public void sendChatMessage(PrivateMessage message) {
message.setMessage(message.getFromUsername() + " 发送:" + message.getMessage());
// 消息存储到数据库
boolean save = privateMessageService.save(message);
//可以看出template最大的灵活就是我们可以获取前端传来的参数来指定订阅地址, 前面参数是订阅地址,后面参数是消息信息
template.convertAndSend("/topic/ServerToClient.private." + message.getToUsername(), message);
if(!save){
throw new SystemException(AppHttpCodeEnum.SYSTEM_ERROR);
}
}
}
WebSocketEventListener
@Component
public class WebSocketEventListener {
@Autowired
private SimpMessageSendingOperations messagingTemplate;
public static AtomicInteger userNumber = new AtomicInteger(0);
@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
userNumber.incrementAndGet();
messagingTemplate.convertAndSend("/topic/ServerToClient.showUserNumber", userNumber);
System.out.println("我来了哦~");
}
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
userNumber.decrementAndGet();
messagingTemplate.convertAndSend("/topic/ServerToClient.showUserNumber", userNumber);
System.out.println("我走了哦~");
}
}
CorsFilter
@WebFilter
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "*");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Allow-Credentials", "true");
chain.doFilter(req, res);
}
}
前端代码
Room.vue
<template>
<div>
<h3 style="text-align: center">当前用户:{{ this.username }}</h3>
<h3 style="text-align: center">在线人数:{{ this.userNumber }}</h3>
<!-- <h3 style="text-align: center">在线用户:-->
<!-- <div v-for="user in usernameOnlineList" :key="user">{{ user }}</div>-->
<!-- </h3>-->
<div class="container">
<div class="left">
<h2 style="text-align: center">用户列表</h2>
<ul>
<li v-for="user in userList" :key="user.id" :class="{ selected: user.selected }" title="点击选择用户聊天">
<div class="user-info">
<span @click="selectUser(user)">
{{ user.toUsername }}
</span>
<!-- <div class="button-container">
<el-button
v-if="user.isFriend === 0"
type="primary"
size="mini"
@click="sendFriendRequest(user)"
>
申请加好友
</el-button>
<el-button
v-if="user.isFriend === 1"
type="success"
@click="sendMessage(user)"
>
好友
</el-button>
<el-button v-if="user.isFriend === 2" type="danger" disabled>
申请中
</el-button>
</div> -->
</div>
</li>
</ul>
</div>
<div class="right">
<div v-if="selectedUser">
<h2 style="text-align: center">
正在与{{ selectedUser.toUsername }}聊天
</h2>
</div>
<div v-if="selectedUser">
<ul>
<li v-for="message in messageList[username + selectedUser.toUsername]" :key="message.id">
{{ message }}
</li>
</ul>
</div>
<div v-if="selectedUser">
<div class="message-input">
<el-input v-model="selectedUserMessage.message" placeholder="请输入内容" @keyup.enter.native="sendMsg"></el-input>
<div class="button-container">
<el-button type="primary" @click="sendMsg">发送消息</el-button>
<el-button type="danger" @click="deleteAllMsgs">删除所有消息</el-button>
</div>
</div>
<div class="message-input">
<el-input v-model="broadcastMsgContent" placeholder="请输入广播消息内容" @keyup.enter.native="sendMsg"></el-input>
<div class="button-container">
<el-button type="primary" @click="sendBroadcastMsg">发送广播消息</el-button>
</div>
</div>
<div class="message-input">
<el-input v-model="userMsgContent" placeholder="请输入userMsg" @keyup.enter.native="sendMsg"></el-input>
<div class="button-container">
<el-button type="primary" @click="sendUserMsg">发送userMsg</el-button>
</div>
</div>
<div class="message-input">
<el-input v-model="toTopicMsgContent" placeholder="请输入ToTopicMsg" @keyup.enter.native="sendMsg"></el-input>
<div class="button-container">
<el-button type="primary" @click="sendToTopicMsg">发送ToTopicMsg</el-button>
</div>
</div>
</div>
</div>
</div>
<div>
<h1 class="bottom" style="text-align: center">好友申请</h1>
<h2 style="text-align: center; color: rgb(57, 29, 216)">
功能开发中......
</h2>
</div>
</div>
</template>
<script>
import { getAllUsers, listPrivateMessages, deleteAllMsg } from "@/api";
import SockJS from "sockjs-client";
import Stomp from "stompjs";
import { Message } from "element-ui";
export default {
name: "Room",
data() {
return {
userList: [],
groupList: [],
selectedUser: null,
message: "",
stompClient: null,
messageList: {}, // 使用对象来存储每个用户的聊天记录
username: "",
usernameOnlineList: [],
userNumber: 1,
selectedUserMessage: {
user: null,
message: "",
},
broadcastMsgContent: '',
userMsgContent: '',
toTopicMsgContent: '',
};
},
methods: {
listAllUsers() {
getAllUsers(this.username).then((response) => {
this.userNumber = ++response.data.userNumber;
this.userList = response.data.friends.filter(
(user) => user.toUsername !== this.username
);
});
},
selectUser(user) {
if (!this.messageList[this.username + user.toUsername]) {
console.log(2222222)
this.$set(this.messageList, this.username + user.toUsername, []);
}
// TODO 展示数据库中存在的信息,也就是聊天记录
listPrivateMessages(this.username, user.toUsername).then((response) => {
this.$set(this.messageList, this.username + user.toUsername, response.data);
});
this.selectedUser = user;
this.selectedUserMessage.user = user;
this.selectedUserMessage.message = ""; // 清空输入框内容
this.userList.forEach((u) => {
u.selected = false;
});
user.selected = true;
},
sendMsg() {
if (this.stompClient !== null && this.selectedUserMessage.message !== "") {
// 发送私聊消息给服务端
this.stompClient.send(
"/app/privateChat",
{},
JSON.stringify({
fromUsername: this.username,
message: this.selectedUserMessage.message,
toUsername: this.selectedUserMessage.user.toUsername,
})
);
this.messageList[this.username + this.selectedUserMessage.user.toUsername].push(
this.username + " 发送:" + this.selectedUserMessage.message
);
this.selectedUserMessage.message = ""; // 清空输入框内容
} else {
Message.info("请输入消息");
}
},
sendBroadcastMsg() {
if (this.stompClient !== null) {
// 发送私聊消息给服务端
this.stompClient.send(
"/app/broadcastMsg",
{},
JSON.stringify({
fromUsername: this.username,
content: this.broadcastMsgContent
})
);
}
},
sendUserMsg() {
if (this.stompClient !== null) {
// 发送私聊消息给服务端
this.stompClient.send(
"/app/userMsg",
{},
JSON.stringify({
fromUsername: this.username,
content: this.userMsgContent
})
);
}
},
// 客户端也可以发送 /topic/xx, 这样订阅了 /topic/xx的客户端也会收到消息
sendToTopicMsg() {
if (this.stompClient !== null) {
// 发送私聊消息给服务端
this.stompClient.send(
"/topic/broadcastMsg",
{},
JSON.stringify({
fromUsername: this.username,
content: this.toTopicMsgContent
})
);
}
},
deleteAllMsgs() {
if (this.messageList[this.username + this.selectedUserMessage.user.toUsername] == "") {
Message.error("当前没有聊天记录");
return;
}
deleteAllMsg(this.username, this.selectedUser.toUsername).then(
(response) => {
this.messageList[this.username + this.selectedUserMessage.user.toUsername] = [];
Message.success("删除成功");
}
);
},
connect() {
//建立连接对象(还未发起连接)
const socket = new SockJS("/api/ws");
// 获取 STOMP 子协议的客户端对象
this.stompClient = Stomp.over(socket);
window.stompClient = this.stompClient
// 向服务器发起websocket连接并发送CONNECT帧
this.stompClient.connect(
{},
(frame) => { // 连接成功时(服务器响应 CONNECTED 帧)的回调方法
console.log("建立连接: " + frame);
// 订阅当前个人用户消息
this.stompClient.subscribe(`/user/${this.username}/singleUserMsg`, (response) => {
console.log('收到当前点对点用户消息: ', response.body);
})
// 订阅当前个人用户消息2
this.stompClient.subscribe(`/user/singleUserMsg`, (response) => {
console.log('收到当前点对点用户消息2: ', response.body);
})
// 订阅广播消息
this.stompClient.subscribe(`/topic/broadcastMsg`, (response) => {
console.log('收到广播消息: ', response.body);
})
// 订阅 服务端发送给客户端 的私聊消息
//(疑问: 订阅的范围如何限制?当前用户应该不能订阅别的用户吧?
// 尝试: 每进来一个用户动态生成这个用户对应的一个标识, 然后, 这个用户订阅当前这个标识,
// 其它没用如果想发消息给这个用户, 后台先查询这个用户标识, 然后发消息给这个用户,
// 这样这个订阅路径就是动态的, 其它不是好友的用户就无法获取到这个动态生成的用户标识。)
this.stompClient.subscribe(
"/topic/ServerToClient.private." + this.username,
(result) => {
this.showContent(
JSON.parse(result.body).message,
JSON.parse(result.body).fromUsername,
JSON.parse(result.body).toUsername,
)
});
// 订阅 服务端发送给客户端删除所有聊天内容 的消息
this.stompClient.subscribe("/topic/ServerToClient.deleteMsg", (result) => {
const res = JSON.parse(result.body);
this.messageList[res.toUsername + res.fromUsername] = [];
});
// 订阅 服务端发送给客户端在线用户数量 的消息
this.stompClient.subscribe("/topic/ServerToClient.showUserNumber", (result) => {
this.userNumber = result.body;
});
});
},
disconnect() {
if (this.stompClient !== null) {
// 断开连接
this.stompClient.disconnect();
}
console.log("断开连接...");
},
showContent(body, from, to) {
// 处理接收到的消息
// 示例代码,根据实际需求进行修改
if (!this.messageList[to + from]) {
this.$set(this.messageList, to + from, []); // 初始化选定用户的聊天记录数组
}
this.messageList[to + from].push(body); // 将接收到的消息添加到选定用户的聊天记录数组
},
},
created() {
},
mounted() {
// 从sessionStorage中获取用户名
this.username = sessionStorage.getItem("username");
console.log('username', this.username);
if (!this.username) {
this.$router.push('/login')
return
}
this.connect();
this.listAllUsers();
// console.log(this.username);
},
beforeDestroy() {
this.disconnect();
},
};
</script>
<style scoped>
.container {
display: flex;
justify-content: space-between;
margin: 10px;
}
.left,
.middle,
.right {
flex: 0.5;
margin: 5px;
padding: 10px;
background-color: lightgray;
}
.right {
flex: 2;
}
.bottom {
margin-top: 20px;
text-align: center;
}
li {
cursor: pointer;
transition: color 0.3s ease;
}
li:hover {
color: blue;
}
li.selected {
color: blue;
font-weight: bold;
}
.send-button {
display: flex;
justify-content: flex-end;
}
.message-input {
display: flex;
align-items: center;
}
.button-container {
margin-left: 10px;
/* 调整间距大小 */
}
.message-container {
display: flex;
justify-content: flex-end;
}
.button-container {
display: flex;
justify-content: flex-end;
}
.user-info {
display: flex;
align-items: center;
}
.button-container {
margin-left: auto;
}
</style>