【Spring Boot开发实战手册】掌握Springboot开发技巧和窍门(十三)前端匹配界面、后端匹配WebSocket

【Spring Boot开发实战手册】掌握Springboot开发技巧和窍门(十三)前端匹配界面、后端匹配WebSocket

前言

在现代 Web 开发中,前端和后端的协作变得越来越重要,特别是在需要实时交互和数据更新的应用场景中。WebSocket 技术作为一种全双工通信协议,使得前端和后端之间的实时数据传输变得更加高效和稳定。本篇博客将会探讨如何设计和实现一个实时匹配系统,其中前端负责展示用户界面并与后端进行交互,而后端则通过 WebSocket 协议来处理数据通信。


前端

onMounted: 当组件被挂载的时候执行的函数
onUnmonted: 当组件被卸载的时候执行的函数
初步调试阶段,我们是将token传进user.id的
store/pk.js:

import ModuleUser from'./user'exportdefault{state:{socket:null,//ws链接opponent_username:"",opponent_photo:"",status:"matching",//matching表示匹配界面,playing表示对战界面},getters:{},mutations:{updateSocket(state,socket){ state.socket = socket;},updateOpponent(state,opponent){ state.opponent_username = opponent.username; state.opponent_photo = opponent.photo;},updateStatus(state,status){ state.status = status;}},actions:{},modules:{user: ModuleUser,}}

将pk引入store中

store/index.js

import{ createStore }from'vuex'import ModuleUser from'./user'import ModulePk from'./pk'exportdefaultcreateStore({state:{},getters:{},mutations:{},actions:{},modules:{user: ModuleUser,pk: ModulePk,}})

前端与后端建立连接
views/pk/PKIndex.vue

<template><PlayGround/></template><script>//import ContentBase from "@/components/ContentBase.vue"import PlayGround from"@/components/PlayGround.vue"import{ onMounted, onUnmounted }from"vue";import{ useStore }from"vuex"exportdefault{name:"PKindex",components:{// ContentBase, PlayGround,},setup(){const store =useStore();//字符串中有${}表达式操作的话要用``,不能用引号const socketUrl =`ws://127.0.0.1:3000/websocket/${store.state.user.id}/`;let socket =null;onMounted(()=>{//当当前页面打开时调用 socket =newWebSocket(socketUrl);//js自带的WebSocket() socket.onopen=()=>{//连接成功时调用的函数 console.log("connected!"); store.commit("updateSocket",socket);} socket.onmessage=msg=>{//前端接收到信息时调用的函数const data =JSON.parse(msg.data);//不同的框架数据定义的格式不一样 console.log(data);} socket.onclose=()=>{//关闭时调用的函数 console.log("disconnected!");}});onUnmounted(()=>{//当当前页面关闭时调用 socket.close();//卸载的时候断开连接});}}</script><style scoped></style>

至此,前端与后端就可以通过websocket互相连接了。

在这里插入图片描述


在这里插入图片描述

将token改成jwt验证

若使用userId建立ws连接,用户可伪装成任意用户,因此这是不安全的

const socketUrl =`ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;

添加ws的jwt验证,根据token判断用户是否存在
consumer/utils/JwtAuthenciation.java

packageorg.example.backend.consumer.utils;importio.jsonwebtoken.Claims;importorg.example.backend.utils.JwtUtil;publicclassJwtAuthentication{publicstaticIntegergetUserId(String token){int userId =-1;//-1表示不存在try{Claims claims =JwtUtil.parseJWT(token); userId =Integer.parseInt(claims.getSubject());}catch(Exception e){thrownewRuntimeException(e);}return userId;}}

修改后端
consumer/WebSocketServer.java

如果可以正常解析出jwt token的话表示登录成功,否则登录不成功,直接close

...@OnOpenpublicvoidonOpen(Session session,@PathParam("token")String token)throwsIOException{// 建立连接this.session = session;System.out.println("connected to websocket");int userId =Integer.parseInt(token);this.user = userMapper.selectById(userId);if(user !=null){ users.put(userId,this);}else{this.session.close();}}...

实现前端逻辑

对战界面和匹配界面的切换

views/pk/PKindexView.vue

<template><PlayGround v-if="$store.state.pk.status === 'playing'"/><MatchGround v-if="$store.state.pk.status === 'matching'"/></template>

创建匹配页面
components/MatchGround.vue

<template><div class="matchground"></div></template><script>exportdefault{}</script><style scoped> div.matchground {width: 60vw;height: 70vh;margin: 40px auto; background-color: lightblue;}</style>

设置此时的状态是匹配

在这里插入图片描述


最终效果:

在这里插入图片描述

匹配界面

用grid系统布局自己头像:对手头像= 6 : 6
逻辑很简单,只要点击匹配按钮,就向后端发送请求开始匹配。
components/MatchGround.vue

<template><div class="matchground"><div class="row"><div class="col-6"><div class="user-photo"><img :src="$store.state.user.photo" alt=""></div><div class="user-username">{{ $store.state.user.username }}</div></div><div class="col-6"><div class="user-photo"><img :src="$store.state.pk.opponent_photo" alt=""></div><div class="user-username">{{ $store.state.pk.opponent_username }}</div></div><div class="col-12" style="text-align: center; padding-top: 15vh;"><button @click="click_match_btn" type="button"class="btn btn-warning btn-lg">{{ match_btn_info }}</button></div></div></div></template><script>import{ ref }from'vue'import{ useStore }from'vuex';exportdefault{setup(){const store =useStore();let match_btn_info =ref("开始匹配");constclick_match_btn=()=>{if(match_btn_info.value ==="开始匹配"){ match_btn_info.value ="取消"; store.state.pk.socket.send(JSON.stringify({event:"start-matching",}));}else{ match_btn_info.value ="开始匹配"; store.state.pk.socket.send(JSON.stringify({event:"stop-matching",}));}}return{ match_btn_info, click_match_btn,}}}</script><style scoped> div.matchground {width: 60vw;height: 70vh;margin: 40px auto; background-color:rgba(50,50,50,0.5);} div.user-photo { text-align: center; padding-top: 10vh;} div.user-photo>img { border-radius:50%;width: 20vh;} div.user-username { text-align: center; font-size: 24px; font-weight:600;color: white; padding-top: 2vh;}</style>

整体结构:

  • 外层 div.matchground 作为容器。
  • 内部是 Bootstrap 栅格布局 row + col-6/col-12。

左侧用户信息:

  • img 显示用户头像,src 来自 Vuex 状态 store.state.user.photo。
  • div 显示用户名,{{ $store.state.user.username }}。

右侧对手信息:

  • 类似用户,显示对手头像和用户名,来自 store.state.pk.opponent_photo 和 store.state.pk.opponent_username。

按钮:

  • 居中显示,绑定点击事件 @click=“click_match_btn”。
  • 按钮文字使用 match_btn_info 变量绑定(响应式)。

点击事件逻辑:

  • 如果按钮显示“开始匹配”:
    • 改成“取消”。
    • 通过 WebSocket store.state.pk.socket.send 发事件 “start-matching”。
  • 如果按钮显示“取消”:
    • 改回“开始匹配”。
    • 发事件 “stop-matching”。
  • 返回模板使用:
    • return { match_btn_info, click_match_btn },模板可以直接使用这些变量和方法。

页面展示 两位玩家头像 + 名字。

  • 中间有 匹配按钮,点击会:
  • 改变按钮文字(“开始匹配” ↔ “取消”)。
  • 通过 WebSocket 发送匹配事件给后端。

使用 Vue 3 Composition API + Vuex + Bootstrap 栅格布局 实现

后端consumer/WebSocketServer.java

packageorg.example.backend.consumer;importcom.alibaba.fastjson.JSONObject;importorg.example.backend.consumer.utils.Game;importorg.example.backend.consumer.utils.JwtAuthentication;importorg.example.backend.mapper.UserMapper;importorg.example.backend.pojo.User;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.stereotype.Component;importjavax.websocket.*;importjavax.websocket.server.PathParam;importjavax.websocket.server.ServerEndpoint;importjava.io.IOException;importjava.util.Iterator;importjava.util.concurrent.ConcurrentHashMap;importjava.util.concurrent.CopyOnWriteArraySet;@Component@ServerEndpoint("/websocket/{token}")// 注意不要以'/'结尾publicclassWebSocketServer{finalprivatestaticConcurrentHashMap<Integer,WebSocketServer> users =newConcurrentHashMap<>();finalprivatestaticCopyOnWriteArraySet<User> matchpool =newCopyOnWriteArraySet<>();privateUser user;privateSession session =null;privatestaticUserMapper userMapper;@AutowiredpublicvoidsetUserMapper(UserMapper userMapper){WebSocketServer.userMapper = userMapper;}@OnOpenpublicvoidonOpen(Session session,@PathParam("token")String token)throwsIOException{this.session = session;System.out.println("connected!");Integer userId =JwtAuthentication.getUserId(token);this.user = userMapper.selectById(userId);if(this.user !=null){ users.put(userId,this);}else{this.session.close();}System.out.println(users);}@OnClosepublicvoidonClose(){System.out.println("disconnected!");if(this.user !=null){ users.remove(this.user.getId()); matchpool.remove(this.user);}}privatevoidstartMatching(){System.out.println("start matching!"); matchpool.add(this.user);while(matchpool.size()>=2){Iterator<User> it = matchpool.iterator();User a = it.next(), b = it.next(); matchpool.remove(a); matchpool.remove(b);Game game =newGame(13,14,20); game.createMap();JSONObject respA =newJSONObject(); respA.put("event","start-matching"); respA.put("opponent_username", b.getUsername()); respA.put("opponent_photo", b.getPhoto()); respA.put("gamemap", game.getG()); users.get(a.getId()).sendMessage(respA.toJSONString());JSONObject respB =newJSONObject(); respB.put("event","start-matching"); respB.put("opponent_username", a.getUsername()); respB.put("opponent_photo", a.getPhoto()); respB.put("gamemap", game.getG()); users.get(b.getId()).sendMessage(respB.toJSONString());}}privatevoidstopMatching(){System.out.println("stop matching"); matchpool.remove(this.user);}@OnMessagepublicvoidonMessage(String message,Session session){// 当做路由System.out.println("receive message!");JSONObject data =JSONObject.parseObject(message);String event = data.getString("event");if("start-matching".equals(event)){startMatching();}elseif("stop-matching".equals(event)){stopMatching();}}@OnErrorpublicvoidonError(Session session,Throwable error){ error.printStackTrace();}publicvoidsendMessage(String message){synchronized(this.session){try{this.session.getBasicRemote().sendText(message);}catch(IOException e){ e.printStackTrace();}}}}

@ServerEndpoint(“/websocket/{token}”):这是 WebSocket 的入口点,指定了 WebSocket 服务的 URL 路径。{token} 是一个路径参数,用来验证和识别用户的身份。
users:使用 ConcurrentHashMap 存储所有连接的用户,用户 ID 是键,WebSocketServer 实例是值。
matchpool:使用 CopyOnWriteArraySet 存储正在匹配的用户。CopyOnWriteArraySet 是线程安全的集合类。
user:当前 WebSocket 连接对应的用户信息。
session:Session 对象表示 WebSocket 连接的会话。

onOpen方法:

  • @OnOpen:WebSocket 连接建立时会调用这个方法。
  • session:WebSocket 会话。
  • @PathParam(“token”):从 URL 中提取用户的身份验证 token。
  • 使用 JwtAuthentication.getUserId(token) 从 token 获取用户 ID,然后查询数据库获取用户信息。
  • 如果用户存在,就将 WebSocketServer 实例与用户 ID 关联,并保存到 users 中;否则关闭连接。

onClose方法:

  • @OnClose:WebSocket 连接关闭时调用这个方法。
  • 如果用户存在,移除 users 和 matchpool 中该用户的信息。

游戏匹配逻辑startMatching方法:
启动匹配过程,将当前用户添加到 matchpool 中。
如果匹配池中有两个以上的用户(至少两个人可以开始匹配),则开始配对:

  • 从 matchpool 中取出两名用户。
  • 创建一个新的游戏实例,并生成地图。
  • 向这两名用户发送匹配成功的消息,消息内容包括对方用户名、头像和游戏地图。

stopMatching方法:

  • 停止匹配,将当前用户从 matchpool 中移除。

消息处理onMessage方法:

  • @OnMessage:当 WebSocket 收到消息时会调用此方法。
  • 解析收到的消息,根据 event 字段的值来决定是开始匹配还是停止匹配。

发送消息sendMessage方法:

  • 发送消息给当前 WebSocket 客户端。
  • 使用 synchronized 确保线程安全。

后端返回信息给前端后,在前端接受并处理信息
views/PKindex.vue

<template><PlayGround v-if="$store.state.pk.status === 'playing'"/><MatchGround v-if="$store.state.pk.status === 'matching'"/></template><script>import PlayGround from'@/components/PlayGround.vue';import MatchGround from'@/components/MatchGround.vue';import{ onMounted, onUnmounted }from'vue'import{ useStore }from'vuex'exportdefault{components:{ PlayGround, MatchGround,},setup(){const store =useStore();const socketUrl =`ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;let socket =null;onMounted(()=>{ store.commit("updateOpponent",{username:"我的对手",photo:"https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",}) socket =newWebSocket(socketUrl); socket.onopen=()=>{ console.log("connected!"); store.commit("updateSocket", socket);} socket.onmessage=msg=>{const data =JSON.parse(msg.data);if(data.event ==="start-matching"){// 匹配成功 store.commit("updateOpponent",{username: data.opponent_username,photo: data.opponent_photo,});setTimeout(()=>{ store.commit("updateStatus","playing");},2000); store.commit("updateGamemap", data.gamemap);}} socket.onclose=()=>{ console.log("disconnected!");}});onUnmounted(()=>{ socket.close(); store.commit("updateStatus","matching");})}}</script><style scoped></style>

const store = useStore(); 用于访问 Vuex 状态管理对象,获取和修改全局状态。
const socketUrl = ws://127.0.0.1:3000/websocket/${store.state.user.token}/; 构建 WebSocket 连接的 URL,使用当前用户的 token 来进行身份验证。
在 onMounted 生命周期钩子中:
使用 store.commit(“updateOpponent”, …) 设置默认的对手信息(用户名和头像)。
创建 WebSocket 实例并建立连接,连接成功时通过 socket.onopen 事件回调执行:
连接成功后,调用 store.commit(“updateSocket”, socket) 更新全局状态中的 WebSocket 实例。
通过 socket.onmessage 监听消息,接收从 WebSocket 服务器发送的数据:
如果 data.event 为 “start-matching”,表示匹配成功。
更新对手信息和游戏地图,并在 2 秒后通过 store.commit(“updateStatus”, “playing”) 更新状态为 “playing”,表示开始游戏。
通过 socket.onclose 监听 WebSocket 连接关闭事件。

在 onUnmounted 生命周期钩子中:
关闭 WebSocket 连接。
更新 pk.status 状态为 “matching”,表示重新回到匹配阶段。

基于 WebSocket 的游戏匹配系统,具体流程如下:

  • 当组件挂载时,通过 WebSocket 与服务器建立连接。
  • 在匹配阶段,用户与服务器实时交换数据,匹配成功后显示对手的信息并开始游戏。
  • 游戏过程中的状态(如对手信息和游戏地图)通过 Vuex 管理,并根据状态动态- 渲染不同的界面组件(MatchGround 或 PlayGround)。
  • 在组件卸载时关闭 WebSocket 连接,确保没有资源泄漏。

解决同步问题

首先要在后端创建一个Game类实现游戏流程,其实就是把之前在前端写的js全部翻译成Java就好了
consumer/utils/Game.java

packageorg.example.backend.consumer.utils;importjava.util.Random;publicclassGame{finalprivateInteger rows;finalprivateInteger cols;finalprivateInteger inner_walls_count;finalprivateint[][] g;finalprivatestaticint[] dx ={-1,0,1,0}, dy ={0,1,0,-1};publicGame(Integer rows,Integer cols,Integer inner_walls_count){this.rows = rows;this.cols = cols;this.inner_walls_count = inner_walls_count;this.g =newint[rows][cols];}publicint[][]getG(){return g;}privatebooleancheck_connectivity(int sx,int sy,int tx,int ty){if(sx == tx && sy == ty)returntrue; g[sx][sy]=1;for(int i =0; i <4; i ++){int x = sx + dx[i], y = sy + dy[i];if(x >=0&& x <this.rows && y >=0&& y <this.cols && g[x][y]==0){if(check_connectivity(x, y, tx, ty)){ g[sx][sy]=0;returntrue;}}} g[sx][sy]=0;returnfalse;}privatebooleandraw(){// 画地图for(int i =0; i <this.rows; i ++){for(int j =0; j <this.cols; j ++){ g[i][j]=0;}}for(int r =0; r <this.rows; r ++){ g[r][0]= g[r][this.cols -1]=1;}for(int c =0; c <this.cols; c ++){ g[0][c]= g[this.rows -1][c]=1;}Random random =newRandom();for(int i =0; i <this.inner_walls_count /2; i ++){for(int j =0; j <1000; j ++){int r = random.nextInt(this.rows);int c = random.nextInt(this.cols);if(g[r][c]==1|| g[this.rows -1- r][this.cols -1- c]==1)continue;if(r ==this.rows -2&& c ==1|| r ==1&& c ==this.cols -2)continue; g[r][c]= g[this.rows -1- r][this.cols -1- c]=1;break;}}returncheck_connectivity(this.rows -2,1,1,this.cols -2);}publicvoidcreateMap(){for(int i =0; i <1000; i ++){if(draw())break;}}}

Game 类主要用于生成一个带有边界和内部随机墙壁的连通游戏地图,使用了深度优先搜索(DFS)算法来检查地图的连通性。

功能概述:

  • 初始化地图的大小和墙壁数量。
  • 生成并检查地图的连通性。
  • 提供获取地图的方法以便其他类或模块使用。

在前端的pk.js中:

exportdefault{state:{status:"matching",// matching表示匹配界面,playing表示对战界面socket:null,opponent_username:"",opponent_photo:"",gamemap:null,},getters:{},mutations:{updateSocket(state, socket){ state.socket = socket;},updateOpponent(state, opponent){ state.opponent_username = opponent.username; state.opponent_photo = opponent.photo;},updateStatus(state, status){ state.status = status;},updateGamemap(state, gamemap){ state.gamemap = gamemap;}},actions:{},modules:{}}

status:当前游戏状态

  • “matching” → 正在匹配阶段(显示匹配界面)
  • “playing” → 游戏进行中(显示对战界面)

socket:存储 WebSocket 对象,用于实时通信。
opponent_username & opponent_photo:记录对手信息。
gamemap:存储游戏地图或游戏数据(例如双方棋盘或战斗场景等)。

updateSocket: 更新 WebSocket 对象
updateOpponent: 更新对手的用户名和头像
updateStatus: 更新游戏状态(matching ↔ playing)
updateGamemap: 更新游戏地图信息

展示结果:

在这里插入图片描述

总结

通过本文的介绍,您应该对如何使用 WebSocket 实现前端与后端的实时匹配有了一个清晰的理解。在开发过程中,前端和后端需要通过紧密的配合来确保实时数据的正确传输和处理。前端负责展示用户的操作界面并通过 WebSocket 与后端保持实时连接,后端则处理客户端的请求并返回实时数据。

Read more

MySQL 数据库基础入门:从概念到实战

MySQL 数据库基础入门:从概念到实战

🔥草莓熊Lotso:个人主页 ❄️个人专栏: 《C++知识分享》《Linux 入门到实践:零基础也能懂》 ✨生活是默默的坚持,毅力是永久的享受! 🎬 博主简介: 文章目录 * 前言: * 一. 数据库核心概念:为什么需要数据库? * 1.1 文件存储的痛点 * 1.2 数据库的定义与价值 * 1.3 服务器、数据库、表的关系 * 二. 主流数据库对比:为什么选择 MySQL? * 三. MySQL 安装与连接:从零开始配置 * 3.1 支持的操作系统 * 3.2 连接 MySQL 服务器 * 3.3 服务器管理(Windows) * 四. MySQL 实战:

By Ne0inhk
基于 Rust 与 DeepSeek V3.2 构建高性能插件化 LLM 应用框架深度解析

基于 Rust 与 DeepSeek V3.2 构建高性能插件化 LLM 应用框架深度解析

前言 随着大语言模型(LLM)技术的飞速迭代,应用开发范式正经历从"单一脚本调用"向"复杂系统工程"的转变。在构建企业级 LLM 应用时,开发者面临的核心挑战在于如何平衡系统的稳定性与灵活性:既要适配快速更迭的模型接口(如 DeepSeek V3.2),又要满足多样化的业务场景(如代码审计、日志分析、运维自动化)。 本文将深入剖析如何利用 Rust 语言强大的类型系统与所有权机制,结合 DeepSeek V3.2 强大的推理能力,构建一个高内聚、低耦合的插件化 LLM 应用框架。该架构通过定义清晰的 Trait 边界,实现了核心逻辑与业务实现的物理隔离,确保了系统的可扩展性与类型安全。 一、 架构设计理念与分层模型 传统的大模型应用往往将 API 调用、提示词工程(Prompt

By Ne0inhk
【2025 最新】 MySQL 数据库安装教程(超详细图文版):从下载到配置一步到位

【2025 最新】 MySQL 数据库安装教程(超详细图文版):从下载到配置一步到位

MySQL 作为开源关系型数据库的标杆,广泛应用于 Web 开发、数据分析等场景,是程序员必备的基础工具之一。本文针对 2025 年最新版本 MySQL(以 MySQL 8.4.7为例),详细讲解 Windows 10/11 系统下的下载、安装、配置全流程,同时涵盖常见问题排查,适合零基础新手快速上手。 一、安装前准备 1. 确认系统环境 * 操作系统:Windows 10(64 位)或 Windows 11(64 位) * 硬件要求:至少 2GB 内存,10GB 以上空闲磁盘空间 * 依赖环境:无需额外安装依赖(安装包自带必要组件) 2. 下载

By Ne0inhk
【MySQL数据库基础】(六)MySQL 表的约束详解:从基础到实战,拿捏数据合法性!

【MySQL数据库基础】(六)MySQL 表的约束详解:从基础到实战,拿捏数据合法性!

前言         在 MySQL 数据库开发中,我们总希望存入表中的数据是合法、规范、符合业务逻辑的。虽然数据类型能对字段做基础限制,但面对复杂的业务需求,仅靠数据类型远远不够。比如要求邮箱唯一、用户名不能为空、学生的班级必须是已存在的班级…… 这些需求都需要靠表的约束来实现。         表的约束是数据库保证数据完整性的核心手段,它能从业务逻辑层面过滤无效数据,避免脏数据进入数据库。今天这篇文章就带大家全面吃透 MySQL 中最常用的表约束,包括null/not null、default、comment、zerofill、primary key、auto_increment、unique key、foreign key,从基础概念到实操案例,手把手教你用约束拿捏数据合法性!下面就让我们正式开始吧! 一、为什么需要表的约束?         先看一个简单的例子:如果我们创建一个班级表,只定义字段和数据类型,不添加任何约束,会发生什么? -- 无约束的班级表 create table myclass( class_

By Ne0inhk