学习链接
Spring WebSocket整合Stomp源码详解 PDF版本
Spring & SpringBoot官方文档资料
- spring5.1.9官方文档关于websocket的介绍
- spring5.3.29官方文档关于websocket的介绍
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协议)
-
点对点通信
补充学习
-
【Springboot WebSocket STOMP使用 1】Springboot最小化配置启用STOMP,并实现浏览器JS通信
-
【Springboot WebSocket STOMP使用 2】STOMP使用@SendToUser实现用户个人请求-响应
-
WebSocket的那些事(4-Spring中的STOMP支持详解),前面还有3篇
-
SpringBoot Websocket Stomp 实现单设备登录(顶号) ①
-
[WebSocket]之上层协议STOMP
-
SpringBoot 集成 STOMP 实现一对一聊天的两种方法
-
SpringBoot 配置基于 wss 和 STOMP 的 WebSocket
-
STOMP 客户端 API 整理
-
Spring websocket+Stomp+SockJS 实现实时通信 详解
-
spring+socket+stomp 实现消息推送
-
Spring Websocket+Stomp 防踩坑实战
-
【Springboot实例】WebSocket即时聊天室设计与实现
-
Stomp on Spring WebSocket项目源码分析
-
(二) WebSocket客户端/服务端代码
-
Springboot项目整合WebSocket源码分析
-
Spring websocket+Stomp+SockJS 实现实时通信 详解
后续使用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插件
示例1
pom.xml
<?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);
}
/**
* 问候信息处理
* <p>{@link MessageMapping}方法的返回值会被转发到Broker对应的主题中</p>
* <p>比如向/app/greetings发送的消息,其响应会被转发到/topic/greetings主题中</p>
*/
@MessageMapping("/greetings")
public String greetings(String content) {
return String.format("Server response: %s", content);
}
// 客户端向 /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;
}
@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"); 里面指定/user的前缀时才能使用的
// (但觉得这并不是真正意义上的点对点,因为只要有客户端订阅了这个/user/{targetUsername}/singleUserMsg主题, 就能收到这个主题下的消息,
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
也可以实现ApplicationListener<T>接口,泛型T,即为感兴趣的事件类型。支持如下事件监听:文章来源:https://www.toymoban.com/news/detail-661330.html
- SessionConnectedEvent
- SessionConnectEvent
- SessionDisconnectEvent
- SessionSubscribeEvent
- SessionUnsubscribeEvent
@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);
}
}
package.json
{
"name": "timeless-chat-websocket-front",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --open",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.17.1",
"core-js": "^3.8.3",
"element-ui": "^2.15.3",
"net": "^1.0.2",
"nprogress": "^0.2.0",
"sockjs-client": "^1.6.1",
"stompjs": "^2.3.3",
"vue": "^2.6.14",
"vue-router": "^3.5.3"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"vue-template-compiler": "^2.6.14"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
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="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 class="message-input">
<el-input v-model="greetingsMsgContent" placeholder="请输入greetings消息" @keyup.enter.native="sendMsg"></el-input>
<div class="button-container">
<el-button type="primary" @click="sendGreetingsMsg">发送GreetingsMsg</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: '',
greetingsMsgContent:'',
};
},
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
})
);
}
},
// 客户端也可以发送 /topic/xx, 这样订阅了 /topic/xx的客户端也会收到消息
sendToTopicMsg() {
if (this.stompClient !== null) {
// 发送私聊消息给服务端
this.stompClient.send(
"/topic/broadcastMsg",
{},
JSON.stringify({
fromUsername: this.username,
content: this.toTopicMsgContent
})
);
}
},
// 客户端发送/app/greetings消息, 将会被@MessageMapping处理, 验证方法的返回值将会被发送到/topic/greetings主题
sendGreetingsMsg() {
if (this.stompClient !== null) {
// 发送私聊消息给服务端
this.stompClient.send(
"/app/greetings",
{},
JSON.stringify({
fromUsername: this.username,
content: this.greetingsMsgContent,
})
);
}
},
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);
})
// 订阅/topic/greetings消息
this.stompClient.subscribe(`/topic/greetings`, (response) => {
console.log('收到greetings消息: ', 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>
示例2
引入依赖
<?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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.4</version>
</parent>
<groupId>com.zzhua</groupId>
<artifactId>ws-demo2</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>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</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>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
</dependencies>
</project>
WebsocketMessageBrokerConfig
@Configuration
@EnableWebSocketMessageBroker
public class WebsocketMessageBrokerConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/websocket") // WebSocket握手端口
.addInterceptors(new HttpSessionHandshakeInterceptor())
.addInterceptors(new HandshakeInterceptor() {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
// 此处可作认证, 因为只有握手成功之后, 才会建立websocket连接
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
})
.setHandshakeHandler(new DefaultHandshakeHandler(){ // 设置默认的握手处理器(可重写其中的方法,比如determineUser)
@Override
protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
// 此处可作认证, 因为只有握手成功之后, 才会建立websocket连接
// 这里可以返回Principal对象, Principal#
return super.determineUser(request, wsHandler, attributes);
}
})
.setAllowedOriginPatterns("*") // 设置跨域
.withSockJS(); // 开启SockJS回退机制
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
// 拦截器配置
registration
.interceptors(new UserAuthenticationChannelInterceptor());
}
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
// 这里我们设置入站消息最大为8K
registry
.setMessageSizeLimit(8 * 1024);
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry
.setApplicationDestinationPrefixes("/app") // 发送到服务端目的地前缀
.enableSimpleBroker("/topic");// 开启简单消息代理,指定消息订阅前缀
}
}
StompAuthenticatedUser
@Data
@AllArgsConstructor
@NoArgsConstructor
public class StompAuthenticatedUser implements Principal {
/**
* 用户唯一ID
*/
private String userId;
/**
* 用户昵称
*/
private String nickName;
/**
* 用于指定用户消息推送的标识
* @return
*/
@Override
public String getName() {
return this.userId;
}
}
UserAuthenticationChannelInterceptor
@Slf4j
public class UserAuthenticationChannelInterceptor implements ChannelInterceptor {
private static final String USER_ID = "User-ID";
private static final String USER_NAME = "User-Name";
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
// 如果是连接请求,记录userId
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
// 此处可以拿到stomp客户端请求连接时,传入的请求头
String userID = accessor.getFirstNativeHeader(USER_ID);
String username = accessor.getFirstNativeHeader(USER_NAME);
log.info("Stomp User-Related headers found, userID: {}, username:{}", userID, username);
/*
为什么保存认证信息使用 setUser 方法?
- 该方法表示会话的拥有者,即存储该会话拥有者信息。
每次建立连接都会创建一个 WebSocketSession 会话信息类,在该会话进行消息传递每次都会把 SessionId ,SessionAttributes 和
Principal(即我们setUser()保存的信息) 赋值到 Message 中,而 Principal 就是专门存储身份认证信息的。
- SessionId: 初始随机分配的,用于确定唯一的会话
SessionAttributes: 用于给 WebSocketSession 设置一些额外记录属性,结构是 Map
Principal: 用于设置 WebSocketSession 的身份认证信息
*/
accessor.setUser(new StompAuthenticatedUser(userID, username));
// 如果没有权限, 则直接在此处抛出异常即可
}
// 此处也可以根据stomp客户端不同的命令作权限控制, 比如是否有权限订阅某个路径
return message;
}
@Override
public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
log.info("ChannelInterceptor#postSend: {}", message);
}
@Override
public void afterSendCompletion(Message<?> message, MessageChannel channel, boolean sent, Exception ex) {
log.info("ChannelInterceptor#afterSendCompletion: {}", message);
}
@Override
public boolean preReceive(MessageChannel channel) {
log.info("ChannelInterceptor#preReceive: {}", channel);
return true;
}
@Override
public Message<?> postReceive(Message<?> message, MessageChannel channel) {
log.info("ChannelInterceptor#preReceive: {}", message);
return message;
}
@Override
public void afterReceiveCompletion(Message<?> message, MessageChannel channel, Exception ex) {
log.info("ChannelInterceptor#afterReceiveCompletion: {}", message);
}
}
StompSessionEventListener
@Slf4j
@Component
public class StompSessionEventListener implements ApplicationListener<AbstractSubProtocolEvent> {
@Autowired
private SimpMessagingTemplate simpMessagingTemplate;
@Override
public void onApplicationEvent(AbstractSubProtocolEvent event) {
log.debug("监听到事件: {}", event);
if (event instanceof SessionConnectEvent) {
handleSessionConnect((SessionConnectEvent)event);
} else if (event instanceof SessionConnectedEvent) {
handleSessionConnected((SessionConnectedEvent)event);
} else if (event instanceof SessionDisconnectEvent) {
handleSessionDisconnect((SessionDisconnectEvent)event);
} else if (event instanceof SessionSubscribeEvent) {
handleSessionSubscribe((SessionSubscribeEvent)event);
} else if (event instanceof SessionUnsubscribeEvent) {
handleSessionUnSubscribe((SessionUnsubscribeEvent)event);
}
}
private void handleSessionUnSubscribe(SessionUnsubscribeEvent event) {
log.info("{}取消订阅{}", event.getUser(),StompHeaderAccessor.wrap(event.getMessage()).getDestination());
}
private void handleSessionSubscribe(SessionSubscribeEvent event) {
log.info("{}订阅了{}", event.getUser(), StompHeaderAccessor.wrap(event.getMessage()).getDestination());
}
private void handleSessionDisconnect(SessionDisconnectEvent event) {
log.info("{}下线了", event.getUser());
simpMessagingTemplate.convertAndSend(
"/topic/chat/group",
new WebSocketMsgVO(event.getUser() + "下线了")
);
}
private void handleSessionConnected(SessionConnectedEvent event) {
log.info("{}上线了", event.getUser());
simpMessagingTemplate.convertAndSend(
"/topic/chat/group",
new WebSocketMsgVO(event.getUser() + "上线了")
);
}
private void handleSessionConnect(SessionConnectEvent event) {
log.info("{}请求建立stomp连接", event.getUser());
}
}
ChatController
@Slf4j
@Controller
@EnableScheduling
@RequiredArgsConstructor
public class ChatController {
private final SimpUserRegistry simpUserRegistry;
private final SimpMessagingTemplate simpMessagingTemplate;
@GetMapping("/page/chat")
public ModelAndView turnToChatPage() {
return new ModelAndView("chat");
}
/**
* 群聊消息处理
* 这里我们通过@SendTo注解指定消息目的地为"/topic/chat/group",如果不加该注解则会自动发送到"/topic" + "/chat/group"
* @param webSocketMsgDTO 请求参数,消息处理器会自动将JSON字符串转换为对象
* @return 消息内容,方法返回值将会广播给所有订阅"/topic/chat/group"的客户端
*/
@MessageMapping("/chat/group")
@SendTo("/topic/chat/group")
public WebSocketMsgVO groupChat(WebSocketMsgDTO webSocketMsgDTO) {
log.info("Group chat message received: {}", JSONObject.toJSONString(webSocketMsgDTO));
String content = String.format("来自[%s]的群聊消息: %s", webSocketMsgDTO.getName(), webSocketMsgDTO.getContent());
return WebSocketMsgVO.builder().content(content).build();
}
/**
* 私聊消息处理(客户端向/app/chat/private发送消息, 会触发该方法, 该方法的返回值将只会发送给当前用户自己)
* 这里我们通过@SendToUser注解指定消息目的地为"/topic/chat/private",发送目的地默认会拼接上"/user/"前缀
* 实际发送目的地为"/user/topic/chat/private"
* @param webSocketMsgDTO 请求参数,消息处理器会自动将JSON字符串转换为对象
* @return 消息内容,方法返回值将会基于SessionID单播给指定用户
*/
@MessageMapping("/chat/private")
@SendToUser("/topic/chat/private")
public WebSocketMsgVO privateChat(WebSocketMsgDTO webSocketMsgDTO,
Principal principal,
Message message,
MessageHeaderAccessor messageHeaderAccessor) {
log.info("Private chat message received: {}, ", JSONObject.toJSONString(webSocketMsgDTO), principal);
log.info("Private chat message principal:{}", principal);
log.info("Private chat message message:{}", message);
log.info("Private chat message messageHeaderAccessor:{}", messageHeaderAccessor);
StompHeaderAccessor stompHeaderAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (stompHeaderAccessor != null) {
log.info("Private chat message sessionId:{}", stompHeaderAccessor.getSessionId());
}
log.info("Private chat message sessionId:{}", StompHeaderAccessor.wrap(message).getSessionId());
String content = "私聊消息回复:" + webSocketMsgDTO.getContent();
return WebSocketMsgVO.builder().content(content).build();
}
/**
* 后台发送消息给指定的用户
* 条件: 1. 客户端订阅stompClient.subscribe(`/user/topic/chat/toPrivate`,message=>{...})
* 2. 请求http://localhost:9090/toPrivateChat?userId=1693137128824&content=halo111接口即可
* (其中, userId为UserAuthenticationChannelInterceptor 在连接时记录的userId)
*/
@GetMapping("/toPrivateChat")
@ResponseBody
public String toPrivateChat(String userId,String content) {
messagingTemplate.convertAndSendToUser(userId, "/topic/chat/toPrivate", content);
return "ok";
}
/**
* 发送(广播)消息到/topic/chat/group
*/
@GetMapping("/toPublicChat")
@ResponseBody
public String toPublicChat(String content) {
messagingTemplate.convertAndSend("/topic/chat/group",new WebSocketMsgVO(content));
return "ok";
}
/**
* 定时消息推送,这里我们会列举所有在线的用户,然后单播给指定用户。
* 通过SimpMessagingTemplate实例可以在任何地方推送消息。
*/
@Scheduled(fixedRate = 10 * 1000)
public void pushMessageAtFixedRate() {
log.info("当前在线人数: {}", simpUserRegistry.getUserCount());
if (simpUserRegistry.getUserCount() <= 0) {
return;
}
// 这里的Principal为StompAuthenticatedUser实例
Set<StompAuthenticatedUser> users = simpUserRegistry.getUsers().stream()
.map(simpUser -> StompAuthenticatedUser.class.cast(simpUser.getPrincipal()))
.collect(Collectors.toSet());
users.forEach(authenticatedUser -> {
String userId = authenticatedUser.getUserId();
String nickName = authenticatedUser.getNickName();
WebSocketMsgVO webSocketMsgVO = new WebSocketMsgVO();
webSocketMsgVO.setContent(String.format("定时推送的私聊消息, 接收人: %s, 时间: %s", nickName, LocalDateTime.now()));
log.info("开始推送消息给指定用户, userId: {}, 消息内容:{}", userId, JSONObject.toJSONString(webSocketMsgVO));
simpMessagingTemplate.convertAndSendToUser(userId, "/topic/chat/push", webSocketMsgVO);
});
}
}
WebSocketMsgDTO
@Data
public class WebSocketMsgDTO {
private String name;
private String content;
}
WebSocketMsgVO
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMsgVO {
private String content;
}
chat.html
引入下面3个js文件(可以使用cdn,此处我把它们下载到了本地,放在了resources/static/js下)文章来源地址https://www.toymoban.com/news/detail-661330.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>greeting</title>
<!--<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script>-->
<!--<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>-->
<!--<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.4/jquery.min.js"></script>-->
<script src="/js/sockjs.min.js"></script>
<script src="/js/stomp.min.js"></script>
<script src="/js/jquery.min.js"></script>
<style>
#mainWrapper {
width: 600px;
margin: auto;
}
</style>
</head>
<body>
<div id="mainWrapper">
<div>
<label for="username" style="margin-right: 5px">姓名:</label><input id="username" type="text"/>
</div>
<div id="msgWrapper">
<p style="vertical-align: top">发送的消息:</p>
<textarea id="msgSent" style="width: 600px;height: 100px"></textarea>
<p style="vertical-align: top">收到的群聊消息:</p>
<textarea id="groupMsgReceived" style="width: 600px;height: 100px"></textarea>
<p style="vertical-align: top">收到的私聊消息:</p>
<textarea id="privateMsgReceived" style="width: 600px;height: 200px"></textarea>
</div>
<div style="margin-top: 5px;">
<button onclick="connect()">连接</button>
<button onclick="sendGroupMessage()">发送群聊消息</button>
<button onclick="sendPrivateMessage()">发送私聊消息</button>
<button onclick="disconnect()">断开连接</button>
</div>
</div>
<script type="text/javascript">
$(() => {
$('#msgSent').val('');
$("#groupMsgReceived").val('');
$("#privateMsgReceived").val('');
});
let stompClient = null;
// 连接服务器
const connect = () => {
const header = {"User-ID": new Date().getTime().toString(), "User-Name": $('#username').val()};
const ws = new SockJS('http://localhost:8080/websocket');
stompClient = Stomp.over(ws);
// 连接时, 传入请求头
stompClient.connect(header, () => subscribeTopic());
}
// 订阅主题
const subscribeTopic = () => {
alert("连接成功!");
// 订阅广播消息
stompClient.subscribe('/topic/chat/group', function (message) {
console.log(`Group message received : ${message.body}`);
const resp = JSON.parse(message.body);
const previousMsg = $("#groupMsgReceived").val();
$("#groupMsgReceived").val(`${previousMsg}${resp.content}\n`);
}
);
// 订阅单播消息
stompClient.subscribe('/user/topic/chat/private', message => {
console.log(`Private message received : ${message.body}`);
const resp = JSON.parse(message.body);
const previousMsg = $("#privateMsgReceived").val();
$("#privateMsgReceived").val(`${previousMsg}${resp.content}\n`);
}
);
// 订阅定时推送的单播消息
stompClient.subscribe(`/user/topic/chat/push`, message => {
console.log(`Private message received : ${message.body}`);
const resp = JSON.parse(message.body);
const previousMsg = $("#privateMsgReceived").val();
$("#privateMsgReceived").val(`${previousMsg}${resp.content}\n`);
}
);
// 订阅发送到当前用户的消息
stompClient.subscribe(`/user/topic/chat/toPrivate`, message => {
console.log(`ToPrivate message received : ${message.body}`);
console.log(message.body)
}
);
};
// 断连
const disconnect = () => {
stompClient.disconnect(() => {
$("#msgReceived").val('Disconnected from WebSocket server');
});
}
// 发送群聊消息
const sendGroupMessage = () => {
const msg = {name: $('#username').val(), content: $('#msgSent').val()};
stompClient.send('/app/chat/group', {}, JSON.stringify(msg));
}
// 发送私聊消息
const sendPrivateMessage = () => {
const msg = {name: $('#username').val(), content: $('#msgSent').val()};
stompClient.send('/app/chat/private', {}, JSON.stringify(msg));
}
</script>
</body>
</html>
StompApp 启动类
@SpringBootApplication
public class StompApp {
public static void main(String[] args) {
SpringApplication.run(StompApp.class, args);
}
}
application.yml配置文件
server:
port: 8080
spring:
thymeleaf:
enabled: true
prefix: classpath:/templates/
cache: false
到了这里,关于websocket + stomp + sockjs学习的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!