【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

OpenClaw 技术指南:从入门到精通 2026 年最火开源 AI 助手全攻略 上篇

OpenClaw 技术指南:从入门到精通 2026 年最火开源 AI 助手全攻略 上篇

基于 OpenClaw 2026.3.2 源码深度解析,2026 年 3 月最新版 目录 1. 第一章:认识 OpenClaw 2. 第二章:安装部署 3. 第三章:基础配置 4. 第四章:工作区与记忆系统 5. 第五章:AI 模型配置 第一章:认识 OpenClaw 1.1 什么是 OpenClaw? OpenClaw 是一个自托管、多通道 AI 网关,由 Peter Steinberger(PSPDFKit 创始人)开发。它能将你熟悉的聊天工具(WhatsApp、Telegram、飞书、

主流 AI IDE 之一的 OpenCode 介绍

主流 AI IDE 之一的 OpenCode 介绍

一、OpenCode 是什么简介         OpenCode 是一款开源、免费的 AI 编程助手工具(不包含服务端大模型),支持在终端(TUI)、桌面应用和 IDE 中使用,可替代 Claude Code、Cursor 等商业工具客户端。OpenCode 是一款开源的 AI 编程智能体,它能在终端、桌面应用或主流 IDE 中帮助你理解代码库、编写功能、重构代码和修复 Bug,从而大幅提升开发效率 1。截至目前(2026年02月01号),它拥有超过 80,000 个 GitHub 星标和每月超过 150 万开发者使用,是目前最受欢迎的开源 AI 编程工具之一。 1.1 核心特点         • 100% 开源:

【开源发布】MCP Document Reader:让你的 AI 助手真正读懂需求文档!

【个人主页:玄同765】 大语言模型(LLM)开发工程师|中国传媒大学·数字媒体技术(智能交互与游戏设计) 深耕领域:大语言模型开发 / RAG知识库 / AI Agent落地 / 模型微调 技术栈:Python / LangChain/RAG(Dify+Redis+Milvus)| SQL/NumPy | FastAPI+Docker ️ 工程能力:专注模型工程化部署、知识库构建与优化,擅长全流程解决方案        「让AI交互更智能,让技术落地更高效」 欢迎技术探讨/项目合作! 关注我,解锁大模型与智能交互的无限可能! 前言:为什么 AI 总是“读不动”你的文件? 【好消息】MCP Document Converter 已正式入驻 MCP 官方 Server 列表,

Unity+AI 用一句话制作完整小游戏:飞翔的牛马【AI纯添加-0手工代码】

Unity+AI 用一句话制作完整小游戏:飞翔的牛马【AI纯添加-0手工代码】

* 📢前言 * 🎮Unity+AI 用一句话制作完整小游戏:飞翔的牛马【AI纯添加-0手工代码】 * 一、准备工作 * 1.1 软件安装 * 1.2 使用Unity添加一个工程 * 二、需求描述 * 三、AI制作 * 四、问题反馈 * 五、游玩体验 * 六、图片素材填充 * 七、最终效果 * 八、心得体会 * 💡总结 📢前言 * 之前写过文章介绍怎样使用UnityMCP+Claude进行游戏辅助开发。 * 本文将使用Unity引擎+Claude制作一款 AI纯添加 - 0手工代码 的小游戏:飞翔的牛马。 * 切实上手体验一下 不用自己手敲任何代码 和 不在游戏引擎中进行任何游戏操作 来制作一款完整的小游戏。 🎮Unity+AI 用一句话制作完整小游戏:飞翔的牛马【AI纯添加-0手工代码】 一、