SpringBoot集成WebSocket实现后端向前端推送数据
这里最好了解一定 websocket 参考地址:https://developer.mozilla.org/zh-CN/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications
在此之前可以了解一下【轮询(Polling)、长轮询(Long Polling)、服务器发送事件(Server-Sent Events, SSE)、 WebSocket】
零、需求场景(了解可以直接跳过)
什么时候我们需要后端给前端主动推送数据?(直接通过询问AI获得一些常用场景,让我们快速了解到这个需求场景)
- 实时数据更新:当应用需要实时展示最新数据时,如实时聊天应用、股票行情、在线游戏状态更新、实时通知等。在这些场景下,前端页面需要即时反映后端数据的变化,而传统的HTTP请求-响应模式可能因为轮询(Polling)导致数据更新延迟或服务器资源浪费。
- 低延迟的交互体验:对于需要极低延迟的交互体验的应用,如在线游戏、实时协作编辑工具等,后端需要能够立即向前端推送数据更新,以保证用户操作的即时反馈。
- 长连接和事件驱动的应用:在需要维持长时间连接的应用中,如WebSocket连接,后端可以在有数据更新时直接通过这条连接推送给前端,而不需要前端不断发起请求。这适用于需要实时数据交换的场景,如实时地图应用、远程监控等。
- 服务器推送通知:在需要向用户发送推送通知的应用中,如新闻应用、社交媒体、邮件客户端等,后端可以在有新消息或更新时主动向前端推送通知,即使前端页面没有打开或处于非活动状态。
- 资源状态监控:在需要监控服务器或应用资源状态的应用中,如服务器监控工具、云管理平台等,后端可以实时向前端推送资源状态的变化,以便管理员能够及时了解并响应。
- 实时数据分析:在需要实时分析数据的场景中,如大数据分析平台、实时数据仪表盘等,后端可以将分析结果实时推送给前端,以便用户能够即时看到数据的变化趋势和分析结果。
这里看到第三点就可以补充一下,比如我们常见的扫码登录,用户通过扫码进行登录,比如我们将二维码转码为一个请求向服务端发起。这是我们的前端就处于一个等待状态,此时前端为了确定此次登录是否有效,要么就是后端可以直接给其反馈,要不就是前端不断重复发起请求向后端确定即轮询 。
一、构建项目
这里为了简单我们假定实现一个简单需求,在一个单页上通过一个表单添加数据,然后通过 WebSocket 进行数据推送更新。
(零)、数据库准备
一个简单的user表,除了id就是名称和积分。
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (`id` bigint(0) NOT NULL AUTO_INCREMENT,`nickname` varchar(18) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`grade` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1831327964131840002 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, '张三', '1000');
INSERT INTO `user` VALUES (1831327914701967362, '李四', '500');
INSERT INTO `user` VALUES (1831327964131840001, '王五', '1500');
(一)、前端工程
合理使用AI工具,对于不懂的代码位置可以通过AI了解。如果对vue熟悉可以自行改为vue代码或者直接AI修改,然后如果想要美化可以直接:http://datav.jiaminghi.com/guide/scrollRankingBoard.html 使用DataV 的排行轮播表。
效果(-_-)朴实无华:
代码:
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>单页排行榜测试</title><style>/*表单样式开始*/.flex-container {display: flex;flex-wrap: nowrap;background-color: DodgerBlue;height: 500px;}.form {background-color: #f1f1f1;width: 40%;margin: 10px;text-align: center;line-height: 75px;}.rankinglist {background-color: #f1f1f1;width: 60%;margin: 10px;text-align: center;line-height: 75px;}/*表单样式结束*//*排行榜样式开始*/table {width: 100%;margin: 0 auto;border-collapse: collapse;}th,td {padding: 8px;text-align: left;border-bottom: 1px solid #ddd;}th {background-color: #f2f2f2;}/*排行榜样式结束*/</style><script src="https://unpkg.com/axios/dist/axios.min.js"></script><!-- WebSocket --><!-- 引入 SockJS --><script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script><!-- 引入 Stomp.js --><script src="https://cdn.jsdelivr.net/npm/stompjs@latest/lib/stomp.min.js"></script></head><body><div class="flex-container"><div class="form"><!-- <form action="http://localhost:9999/rankinglists/add" method="POST" onsubmit="return false"> --><form onsubmit="return false">昵称:<input type="text" name="nickname" id="nickname"><br>得分:<input type="text" name="grade" id="grade"><br><input type="submit" value="提交" onclick="onclickSubmit()"></form></div><div class="rankinglist"><table id="rankingTable"><thead><tr><th>排名</th><th>昵称</th><th>得分</th></tr></thead><tbody></tbody></table></div></div><script>let isConnected = false;let stompClient = null;let socket = null;// 基地址const API_BASE_URL = "http://localhost:9999";const wsHost = "http://localhost:9999/websocket"const wsTopic = "/topic/receiver"//##################表单处理逻辑开始##################const sendData = async (data) => {try {const response = await axios({method: 'post',url: `${API_BASE_URL}/rankinglists/add`,data: data});console.log("查看返回的数据", response);if (response.data.code === 200) {}} catch (error) {console.error('用户数据添加失败', error);} finally {const nicknameInput = document.getElementById('nickname');const gradeInput = document.getElementById('grade');nicknameInput.value = '';gradeInput.value = '';}};// 按钮点击后操作function onclickSubmit() {const nicknameInput = document.getElementById('nickname');const gradeInput = document.getElementById('grade');const nickname = nicknameInput.value;const grade = gradeInput.value;// 检查输入是否为空if (!nickname || !grade) {alert('昵称和得分不能为空!');return;}// 将表单数据转换为JSON对象const formData = { nickname, grade };console.log("表单中的json数据: ", formData);// 发送数据sendData(formData);}//##################表单处理逻辑结束##################//##################排行榜处理逻辑开始##################const fetchRankingData = async () => {try {const response = await axios.get(`${API_BASE_URL}/rankinglists/info`);return response.data;} catch (error) {console.error('排行榜数据获取失败', error);return [];}};// 渲染排行榜数据const renderRankingList = (data) => {const tbody = document.querySelector('#rankingTable tbody');tbody.innerHTML = ''; // 清空现有数据console.log("看看传递过来的排行榜数据:", data);data.forEach((entry, index) => {const row = `<tr><td>${index + 1}</td><td>${entry.nickname}</td><td>${entry.grade}</td></tr>`;tbody.insertAdjacentHTML('beforeend', row);});};// 初始化数据加载document.addEventListener('DOMContentLoaded', async () => {const rankingData = await fetchRankingData();renderRankingList(rankingData);});//##################排行榜处理逻辑结束##################//##################websocket开始####################// 初始化SockJS连接的函数 function initSockJs() {// 创建一个新的SockJS客户端实例,连接到指定的WebSocket服务器地址(wsHost应该是一个变量,包含服务器的URL) socket = new SockJS(wsHost);console.log("查看一下:", socket); // 在控制台打印SockJS实例,用于调试 // 使用Stomp协议在SockJS连接上创建一个客户端 stompClient = Stomp.over(socket);// 尝试连接到WebSocket服务器 stompClient.connect({}, function (frame) {// 连接成功时的回调函数 console.log('WebSocket 连接成功:' + frame); // 打印连接成功的帧信息 isConnected = true; // 设置连接状态为已连接 subscribeToTopic(); // 订阅主题,开始接收消息 }, function (error) {// 连接失败时的回调函数 console.error('WebSocket 连接失败:' + error); // 打印连接失败的错误信息 });}// 订阅WebSocket服务器上的特定主题的函数 function subscribeToTopic() {// 使用Stomp客户端订阅一个主题(wsTopic应该是一个变量,包含要订阅的主题名) // 当收到来自该主题的消息时,会调用回调函数并传入消息对象 stompClient.subscribe(wsTopic, function (res) {// 打印从服务器接收到的消息体(未解析的JSON字符串) // 注意:如果消息体是JSON格式,你可能需要先使用JSON.parse()进行解析 console.log("查看一下后端推送过来的数据:" + res.body);data = JSON.parse(res.body);console.log("排行数据json转对象后: ", data);renderRankingList(data.rankinglist); //传递更新排行榜});}// 销毁SockJS连接和Stomp客户端的函数 function destroySockJs() {if (stompClient !== null) { // 检查Stomp客户端是否存在,以避免空引用错误 // 断开Stomp客户端连接 stompClient.disconnect(function () {console.log('WebSocket 断开成功!'); // 连接断开时打印信息 });// 如果SockJS连接(socket)仍然存在且未关闭,则尝试关闭它 if (socket && socket.readyState !== WebSocket.CLOSED) {socket.close(); // 关闭SockJS连接 }// 将Stomp客户端和SockJS连接设置为null,释放资源 stompClient = null;socket = null;isConnected = false; // 设置连接状态为未连接 }}// 初始化WebSocket连接 initSockJs();// 添加窗口关闭事件监听器,以确保在窗口关闭前正确断开WebSocket连接 window.addEventListener('beforeunload', destroySockJs);//##################websocket结束####################</script></body></html>
(二)、后端构建
分析前端页面和需求,本项目我们需要提供一个新增数据的接口和一个获取排行数据的接口,同时需要实现一个后端主动向前端推送数据的功能。先搞定前两个让数据显示出来。
0、pom依赖参考
<dependencies><!-- 数据源 --><dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId><version>1.2.6</version></dependency><!-- 持久层技术 --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.4.3</version></dependency><!-- 数据库 --><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId></dependency><!-- fastjson JSON与Java类的转换 --><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.76</version></dependency><!-- 依赖websocket --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency><!-- 简化类书写 --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.28</version></dependency><!-- springboot依赖 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId></dependency></dependencies>
1、yaml配置 (注意数据库库名、账号密码改为你自己的)
server:port: 9999spring:datasource:druid:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/rankinglist?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=trueusername: rootpassword: rootmybatis-plus:configuration:# 在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射# address_book --> AddressBookmap-underscore-to-camel-case: truelog-impl: org.apache.ibatis.logging.stdout.StdOutImplglobal-config:db-config:id-type: ASSIGN_ID #设置id根据数据库一样自增# mapper映射文件路径mapperLocations: classpath:mybatis/mapper/*.xml
2、控制器参考
项目最开始没有使用数据库,直接造数据测试的,所以保留了一些注释代码。
/*** @Package: com.yanjiali.controller* @Author: yanjiali* @Created: 2024/8/31 21:32* 操作排行榜的控制器* 127.0.0.1:9999/rankinglists/*/
@RestController
@RequestMapping("/rankinglists")
public class RankingListController {@Autowiredprivate UserService userService;//TODO 注意使用 APIFOX 时需要使用路径参数写法才能识别
// @GetMapping("/add/{nickname}/{grade}")
// public void addUserInfo(@PathVariable("nickname") String nickname,
// @PathVariable("grade") String grade) {/*** 添加一个用户信息* @param user*/@PostMapping("/add")public void addUserInfo(@RequestBody User user) {//TODO 校验数据System.out.println("收到了来自前端的消息:nickname = " + user.getNickname() + ", grade = " +user.getGrade());//TODO 操作数据库userService.save(user);}/*** 获取用户排行信息* @return*/@GetMapping("/info")public List<User> getAllUserInfo() {List<User> userList = userService.list();// //TODO 模式数据库获取数据
// User user1 = new User();
// user1.setNickname("张三");
// user1.setGrade("1000");
// userList.add(user1);
//
// User user2 = new User();
// user2.setNickname("李四");
// user2.setGrade("2000");
// userList.add(user2);
//
// User user3 = new User();
// user3.setNickname("王五");
// user3.setGrade("1500");
// userList.add(user3);if(CollectionUtils.isEmpty(userList)) {return null; //用户数据为空}userList.sort(new Comparator<User>() {@Overridepublic int compare(User o1, User o2) {//此处使用 Long.compare() 是考虑到如果这里的数值范围较大return Long.compare(Long.valueOf(o2.getGrade()), Long.valueOf(o1.getGrade()));}});return userList;}
}
3、说明
跨域处理:
@Configuration //表示这是一个配置类
public class WebMvcConfig extends WebMvcConfigurationSupport {/*** SpringBoot处理跨域** @param registry*/@Overridepublic void addCorsMappings(CorsRegistry registry) {// 设置允许跨域的路径registry.addMapping("/**")// 设置允许跨域请求的域名.allowedOriginPatterns("*") // 使用模式匹配// 是否允许cookie.allowCredentials(true) //TODO 此处注意,后续会改// 设置允许的请求方式.allowedMethods("GET", "POST", "DELETE", "PUT")// 设置允许的header属性.allowedHeaders("*")// 跨域允许时间.maxAge(3600);}
}
相信有了以上内容基本构建一个项目就可以了,这里我就不写实体类,mapper,service的代码了。至于是否需要统一返回给前端数据的结构就看个人意愿了,此处重点不在于此。
效果(可以测试添加数据接口是否可行):
(三)、WebSocket部分讲解
0、导入依赖 此处上面的依赖文件中已经有了这个关于websocket的依赖了
<!-- 依赖websocket --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
1、编写配置类
关于跨域问题(重点) 参考文章:https://cloud.tencent.com.cn/developer/article/1883429
// 前面提到的跨域配置的这里修改为false
allowCredentials(false) //TODO 此处注意,后续会改
websocket的配置类,这里直接使用配置类编写端点连接,同时定义了一个订阅地址。
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {@Overridepublic void registerStompEndpoints(StompEndpointRegistry registry) {// 注册一个 /notification 端点,前端通过这个端点进行连接registry.addEndpoint("/websocket").setAllowedOriginPatterns("*").withSockJS();}@Overridepublic void configureMessageBroker(MessageBrokerRegistry registry) {// 定义了一个客户端订阅地址的前缀信息,也就是客户端接收服务端发送消息的前缀信息registry.enableSimpleBroker("/topic");}
}
2、编写对应定义任务进行推送
我们先在 event 包(放在service包同级就是了)下编写下面接口、实现类
事件接口:
public interface Event {/*** 对应事件触发所调用的方法*/void handle();
}
事件实现类(可以放在impl包下):
@Component
public class RankingListUpdateEvent implements Event {@Autowiredprivate SimpMessagingTemplate wsTemplate;@Autowiredprivate UserService userService;/*** 触发事件所调用的方法*/@Overridepublic void handle() {//TODO 查询数据库List<User> userList = userService.list();JSONObject jsonObject = new JSONObject(); //格式处理一下,对应前端jsonObject.put("rankinglist", userList);//TODO 将查询到的数据发送给前端 (这里使用websocket)wsTemplate.convertAndSend("/topic/receiver", jsonObject);}
}
然后在 task 包下编写定义任务类(别忘了给启动类加上:@EnableScheduling 注解哦 【-_-】 )
@Component
public class RankingListUpdateTask {@Autowiredprivate RankingListUpdateEvent rankingListUpdateEvent;/*** 推送交易对信息 1s*/@Scheduled(fixedRate = 1000)public void pushMarkets() {rankingListUpdateEvent.handle();}
}
3、说明
这里这种事件,定时任务编写的方式是为了后续更好的扩展,并不是强制的,简单来说你直接写一个定时任务给前端发,不写这一个结构也没什么。
二、总结:
这里就是通过快速实现这个效果来到达理解的目的,并没有细讲,当你成功实现后添加数据就会自动更新排行数据,没错数据显示多了页面样式还有点不好看如:
不要在意这个细节,关键在于你能够知道我们想要实现这样一个效果应该怎么办,那么这个效果怎么用在其他地方你自然就知道。只是注意:
首先:去了解websocket基本知识,还有配置类为什么能够实现这个效果需要去了解。当然你突然冒出这样的想法也可以理解——我直接添加数据后再去调用一下获取排行数据的接口不就 -,哈哈哈哈。当然对于你添加后更新数据这样看似没有什么问题,但是首先如果我们希望没有做出某个行为(特指你不是第一次进入页面,点击按钮等)就可以更新呢?如你并没有更新数据或者你就没有权限修改(如一些只显示的场景——可视化大屏、股票市场趋势),你总不能一直刷新页面吧 -_- ,还有就是你觉得你发请求在获取响应 和 后端直接给你谁快?这里不细说。
其次:这个代码最好处理一下端点和订阅的写法,因为在一些系统中websocket除了推送,可能还承担聊天功能,所以细节一定有待考究。
最后:这里推荐使用一个开源产品tio:https://www.tiocloud.com/1/product/tio.html 当然也可以试试netty:https://netty.io/index.html 直接看文档看的困难就去找文章视频 [0_0]