【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

一个 skill ,增加大模型前端的审美能力

上周,我让 AI 帮我做个落地页。 十分钟过去了,生成出来的东西—— 白色背景,紫色渐变,Inter 字体。 我直接关了。 你也遇到过吧? 用 AI 生前端,出来的东西都长一个样。 背景非白即黑,标题栏永远是紫色渐变,字体不是 Inter 就是 Roboto,配色永远是那套蓝绿红黄。 不是说不能用,但—— 太像 AI 了。 一眼看过去就是"机器生成",没有灵魂,没有个性。 直到昨天,我发现了一个东西。 Anthropic 官方出的一个 skill,叫 frontend-design。 让我再试一次。 这次不一样了 同样的提示词,同样的模型。 我只加了一句话: “使用 frontend-design skill” 结果呢?

By Ne0inhk
在 Cursor 中打造你的专属前端“AI 助手”:Agent Skills 实战指南 什么是 Agent Skills?

在 Cursor 中打造你的专属前端“AI 助手”:Agent Skills 实战指南 什么是 Agent Skills?

文章目录 * 一、什么是 Agent Skills? * 二、使用步骤 * 1.下载官方提供的agent-skills文档 * 2.cursor中使用 * 三、如何设计自己的skills * 四、实战:打造一个“生成标准 React 组件”的 Skill * 第一步:创建目录 * 第二步:编写 SKILL.md * 总结:为什么你应该开始用 Skills? 一、什么是 Agent Skills? 简单来说,Agent Skills 是一种标准化的方式,用来封装特定任务的知识和工作流。 如果说 MCP (Model Context Protocol) 是给 AI 装上了“手”(让它能连接数据库、Github)

By Ne0inhk
前端学习日记 - 前端函数防抖详解

前端学习日记 - 前端函数防抖详解

前端函数防抖详解 * 为什么使用防抖 * 函数防抖的应用场景 * 函数防抖原理与手写实现 * 原理 * 手写实现 * 使用 Lodash 的 \_.debounce * 完整示例:防抖搜索组件 * 结语 在现代 Web 应用中,函数防抖(debounce)是一种常见且高效的性能优化手段,用于限制高频事件触发下的函数调用次数,从而减少不必要的计算、网络请求或 DOM 操作。本文将从“为什么使用防抖”切入,介绍典型的应用场景,深入解析防抖原理,并给出从零实现到在实际项目中使用 Lodash 的完整代码示例,帮助你快速掌握前端防抖技术。 为什么使用防抖 函数防抖的核心思想是在连续触发的事件停止后,仅执行最后一次调用,以避免频繁触发带来的性能问题 ([MDN Web Docs][1])。 在不使用防抖的情况下,例如在 input 输入事件或 window.resize 事件中直接调用逻辑,页面可能会因短时间内大量调用而出现卡顿或请求风暴 ([GeeksforGeeks]

By Ne0inhk