SpringBoot学习笔记-实现微服务:匹配系统(上)

news/2025/2/12 1:03:41/

笔记内容转载自 AcWing 的 SpringBoot 框架课讲义,课程链接:AcWing SpringBoot 框架课。

CONTENTS

  • 1. 配置WebSocket
  • 2. 前后端WebSocket通信
    • 2.1 WS通信的建立
    • 2.2 加入JWT验证
  • 3. 前后端匹配业务
    • 3.1 实现前端页面
    • 3.2 实现前后端交互逻辑
    • 3.3 同步游戏地图

我们的游戏之后是两名玩家对战,因此需要实现联机功能,在这之前还需要实现一个匹配系统,能够匹配分数相近的玩家进行对战。

想要进行匹配就至少要有两个客户端,当两个客户端都向服务器发送匹配请求后并不会马上得到返回结果,一般会等待一段时间,这个时间是未知的,因此这个匹配是一个异步的过程,对于这种异步的过程或者是计算量比较大的过程我们都会用一个额外的服务来操作。

那么这个额外的用于匹配的服务可以称为 Matching System,这是另外一个程序(进程),当后端服务器接收到前端的请求后就会将请求发送给 Matching System,这个匹配系统维护了一堆用户的集合,它会不断地去匹配分数最接近的用户,当匹配成功一组用户后就会将结果返回给后端服务器,再由后端将匹配结果立即返回给对应的前端。这种服务就被称为微服务,可以用 Spring Cloud 实现。

用以前的 HTTP 请求很难达到这种效果,之前我们是在客户端向后端发送请求,且后端在短时间内就会返回结果,HTTP 请求只能满足这种一问一答式的服务。而我们现在需要实现的效果是客户端发送请求后不知道经过多长时间后端才会返回结果,对于这种情况需要使用 WebSocket 协议(WS),该协议不仅支持客户端向服务器发送请求,也支持服务器向客户端发送请求。

在前端向服务器发送请求后,服务器会维护好一个 WS 链接,这个链接其实就是一个 WebSocketServer 类的实例,所有和这个链接相关的信息都会存到这个类中。

1. 配置WebSocket

我们之前每次刷新网页就会随机生成游戏地图,该过程是在浏览器本地执行的,当我们要实现匹配功能时,地图就不能由两名玩家各自的客户端生成,否则就基本不可能完全一样了。

当匹配成功后应该由服务器端创建一个 Game 任务,将游戏放到该任务下执行,统一生成地图,且判断移动或者输赢等逻辑之后也应该移到后端来执行。

生成好地图后服务器就将地图传给两名玩家的前端,然后等待玩家的键盘输入或者是 Bot 代码的输入,Bot 代码的输入也属于一个微服务。

首先我们先在 pom.xml 文件中添加以下依赖:

  • spring-boot-starter-websocket
  • fastjson

接着在 config 包下创建 WebSocketConfig 配置类:

package com.kob.backend.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;@Configuration
public class WebSocketConfig {@Beanpublic ServerEndpointExporter serverEndpointExporter() {return new ServerEndpointExporter();}
}

然后我们创建一个 consumer 包,在其中创建 WebSocketServer 类:

package com.kob.backend.consumer;import org.springframework.stereotype.Component;import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;@Component
@ServerEndpoint("/websocket/{token}")  // 注意不要以'/'结尾
public class WebSocketServer {@OnOpenpublic void onOpen(Session session, @PathParam("token") String token) {// 建立链接}@OnClosepublic void onClose() {// 关闭链接}@OnMessagepublic void onMessage(String message, Session session) {// 从Client接收消息}@OnErrorpublic void onError(Session session, Throwable error) {error.printStackTrace();}
}

之前我们配置的 Spring Security 设置了屏蔽除了授权之外的其他所有链接,因此我们需要在 SecurityConfig 类中放行一下 WebSocket 的链接:

package com.kob.backend.config;import com.kob.backend.config.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {  // AuthenticationManager用于处理身份验证return super.authenticationManagerBean();}@Overrideprotected void configure(HttpSecurity http) throws Exception {  // 配置HttpSecurityhttp.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests().antMatchers("/user/account/login/", "/user/account/register/").permitAll()  // 需要公开的链接在这边写即可.antMatchers(HttpMethod.OPTIONS).permitAll().anyRequest().authenticated();http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);}@Overridepublic void configure(WebSecurity web) throws Exception {web.ignoring().antMatchers("/websocket/**");}
}

如果是使用新版的配置而不是使用 WebSecurityConfigurerAdapter 可以按以下方式配置:

package com.kob.backend.config;import com.kob.backend.config.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@Configuration
@EnableWebSecurity
public class SecurityConfig {@Autowiredprivate JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Beanpublic AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests().antMatchers("/user/account/login/", "/user/account/register/").permitAll().antMatchers(HttpMethod.OPTIONS).permitAll().anyRequest().authenticated();http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);return http.build();}@Beanpublic WebSecurityCustomizer webSecurityCustomizer(){return (web) -> web.ignoring().antMatchers("/websocket/**");}
}

2. 前后端WebSocket通信

2.1 WS通信的建立

WebSocket 不属于单例模式(同一个时间每个类只能有一个实例,我们每建一个 WS 链接都会新创建一个实例),不是标准的 Spring 中的组件,因此在注入 Mapper 时不能用 @Autowired 直接注入,一般是将 @Autowired 写在一个 set() 方法上,Spring 会根据方法的参数类型从 IoC 容器中找到该类型的 Bean 对象注入到方法的行参中,并且自动反射调用该方法。

我们先假设前端传过来的是用户 ID 而不是 JWT 令牌:

package com.kob.backend.consumer;import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;@Component
@ServerEndpoint("/websocket/{token}")  // 注意不要以'/'结尾
public class WebSocketServer {// ConcurrentHashMap是一个线程安全的哈希表,用于将用户ID映射到WS实例private static final ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();private User user;private Session session = null;private static UserMapper userMapper;@Autowiredpublic void setUserMapper(UserMapper userMapper) {WebSocketServer.userMapper = userMapper;}@OnOpenpublic void onOpen(Session session, @PathParam("token") String token) {this.session = session;System.out.println("Connected!");Integer userId = Integer.parseInt(token);this.user = userMapper.selectById(userId);users.put(userId, this);}@OnClosepublic void onClose() {System.out.println("Disconnected!");if (this.user != null) {users.remove(this.user.getId());}}@OnMessagepublic void onMessage(String message, Session session) {System.out.println("Receive message!");}@OnErrorpublic void onError(Session session, Throwable error) {error.printStackTrace();}public void sendMessage(String message) {  // 从后端向当前链接发送消息synchronized (this.session) {  // 由于是异步通信,需要加一个锁try {this.session.getBasicRemote().sendText(message);} catch (IOException e) {e.printStackTrace();}}}
}

然后我们先在前端的 PKIndexView 组件中调试,当组件被挂载完成后发出请求建立 WS 链接,当被卸载后关闭 WS 链接:

<template><PlayGround />
</template><script>
import PlayGround from "@/components/PlayGround.vue";
import { onMounted, onUnmounted } from "vue";
import { useStore } from "vuex";export default {components: {PlayGround,},setup() {const store = useStore();let socket = null;let socket_url = `ws://localhost:3000/websocket/${store.state.user.id}/`;onMounted(() => {socket = new WebSocket(socket_url);store.commit("updateOpponent", {username: "我的对手",photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",});socket.onopen = () => {  // 链接成功建立后会执行console.log("Connected!");store.commit("updateSocket", socket);};socket.onmessage = (msg) => {  // 接收到后端消息时会执行const data = JSON.parse(msg.data);  // Spring传过来的数据是放在消息的data中console.log(data);};socket.onclose = () => {  // 关闭链接后会执行console.log("Disconnected!");};});onUnmounted(() => {socket.close();  // 如果不断开链接每次切换页面都会创建新链接,就会导致有很多冗余链接});},
};
</script><style scoped></style>

现在我们在对战页面每次刷新后都可以在浏览器控制台或后端控制台中看到 WS 的输出信息。

接下来我们要将 WebSocket 存到前端的 store 中,在 store 目录下创建 pk.js 用来存储和对战页面相关的全局变量:

export default {state: {status: "matching",  // 当前状态,matching表示正在匹配,playing表示正在对战socket: null,  // 前端和后端建立的链接opponent_username: "",  // 对手的用户名opponent_photo: "",  // 对手的头像},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: {},
};

同时要在 store/index.js 中引入进来:

import { createStore } from "vuex";
import ModuleUser from "./user";
import ModulePk from "./pk";export default createStore({state: {},getters: {},mutations: {},actions: {},modules: {user: ModuleUser,pk: ModulePk,},
});

2.2 加入JWT验证

现在我们直接使用用户的 ID 建立 WS 链接,这是不安全的,因为前端可以自行修改这个 ID,因此就需要加入 JWT 验证。

WebSocket 中没有 Session 的概念,因此我们在验证的时候前端就不用将信息放到表头里了,直接放到链接中就行:

...<script>
...export default {...setup() {...let socket_url = `ws://localhost:3000/websocket/${store.state.user.jwt_token}/`;...},
};
</script>...

验证的逻辑可以参考之前的 JwtAuthenticationTokenFilter,我们可以把这个验证的模块单独写到一个文件中,在 consumer 包下创建 utils 包,然后创建一个 JwtAuthentication 类:

package com.kob.backend.consumer.utils;import com.kob.backend.utils.JwtUtil;
import io.jsonwebtoken.Claims;public class JwtAuthentication {public static Integer getUserId(String token) {int userId = -1;try {Claims claims = JwtUtil.parseJWT(token);userId = Integer.parseInt(claims.getSubject());} catch (Exception e) {throw new RuntimeException(e);}return userId;}
}

然后就可以在 WebSocketServer 中解析 JWT 令牌:

package com.kob.backend.consumer;import com.kob.backend.consumer.utils.JwtAuthentication;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;@Component
@ServerEndpoint("/websocket/{token}")  // 注意不要以'/'结尾
public class WebSocketServer {...@OnOpenpublic void onOpen(Session session, @PathParam("token") String token) throws IOException {this.session = session;System.out.println("Connected!");Integer userId = JwtAuthentication.getUserId(token);this.user = userMapper.selectById(userId);if (user != null) {users.put(userId, this);} else {this.session.close();}}...
}

3. 前后端匹配业务

3.1 实现前端页面

我们需要实现一个前端的匹配页面,并能够切换匹配和对战页面,可以根据之前在 store 中存储的 status 状态来动态展示页面。首先在 components 目录下创建 MatchGround.vue 组件,其中需要展示玩家自己的头像和用户名以及对手的头像和用户名,当点击开始匹配按钮时向 WS 链接发送开始匹配的消息,点击取消按钮时发送取消匹配的消息:

<template><div class="matchground"><div class="row"><div class="col-md-6" style="text-align: center;"><div class="photo"><img class="img-fluid" :src="$store.state.user.photo"></div><div class="username">{{ $store.state.user.username }}</div></div><div class="col-md-6" style="text-align: center;"><div class="photo"><img class="img-fluid" :src="$store.state.pk.opponent_photo"></div><div class="username">{{ $store.state.pk.opponent_username }}</div></div><div class="col-md-12 text-center" style="margin-top: 14vh;"><button @click="click_match_btn" type="button" class="btn btn-info btn-lg">{{ match_btn_info }}</button></div></div></div>
</template><script>
import { ref } from "vue";
import { useStore } from "vuex";export default {setup() {const store = useStore();let match_btn_info = ref("开始匹配");const click_match_btn = () => {if (match_btn_info.value === "开始匹配") {match_btn_info.value = "取消";store.state.pk.socket.send(JSON.stringify({  // 将json封装成字符串发送给后端,后端会在onMessage()中接到请求event: "start_match",  // 表示开始匹配}));} else {match_btn_info.value = "开始匹配";store.state.pk.socket.send(JSON.stringify({event: "stop_match",  // 表示停止匹配}));}};return {match_btn_info,click_match_btn,};},
};
</script><style scoped>
div.matchground {width: 60vw;height: 70vh;margin: 40px auto;border-radius: 10px;background-color: rgba(50, 50, 50, 0.5);
}img {width: 35%;border-radius: 50%;margin: 14vh 0 1vh 0;
}.username {font-size: 24px;font-weight: bold;color: white;
}
</style>

3.2 实现前后端交互逻辑

当用户点击开始匹配按钮后,前端要向服务器发出一个请求,后端接收到请求后应该将该用户放入匹配池中,由于目前还没有实现微服务,因此我们先在 WebSocketServer 后端用一个 Set 维护正在匹配的玩家,当匹配池中满两名玩家就将其匹配在一起,然后将匹配结果返回给两名玩家的前端:

package com.kob.backend.consumer;import com.alibaba.fastjson2.JSONObject;
import com.kob.backend.consumer.utils.JwtAuthentication;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;import java.io.IOException;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;@Component
@ServerEndpoint("/websocket/{token}")  // 注意不要以'/'结尾
public class WebSocketServer {// ConcurrentHashMap是一个线程安全的哈希表,用于将用户ID映射到WS实例private static final ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();// CopyOnWriteArraySet也是线程安全的private static final CopyOnWriteArraySet<User> matchPool = new CopyOnWriteArraySet<>();  // 匹配池private User user;private Session session = null;private static UserMapper userMapper;@Autowiredpublic void setUserMapper(UserMapper userMapper) {WebSocketServer.userMapper = userMapper;}@OnOpenpublic void onOpen(Session session, @PathParam("token") String token) throws IOException {this.session = session;System.out.println("Connected!");Integer userId = JwtAuthentication.getUserId(token);this.user = userMapper.selectById(userId);if (user != null) {users.put(userId, this);} else {this.session.close();}}@OnClosepublic void onClose() {System.out.println("Disconnected!");if (this.user != null) {users.remove(this.user.getId());matchPool.remove(this.user);}}@OnMessagepublic void onMessage(String message, Session session) {  // 一般会把onMessage()当作路由System.out.println("Receive message!");JSONObject data = JSONObject.parseObject(message);String event = data.getString("event");  // 取出event的内容if ("start_match".equals(event)) {this.startMatching();} else if ("stop_match".equals(event)) {this.stopMatching();}}@OnErrorpublic void onError(Session session, Throwable error) {error.printStackTrace();}public void sendMessage(String message) {  // 从后端向当前链接发送消息synchronized (this.session) {  // 由于是异步通信,需要加一个锁try {this.session.getBasicRemote().sendText(message);} catch (IOException e) {e.printStackTrace();}}}private void startMatching() {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);JSONObject respA = new JSONObject();  // 发送给A的信息respA.put("event", "match_success");respA.put("opponent_username", b.getUsername());respA.put("opponent_photo", b.getPhoto());users.get(a.getId()).sendMessage(respA.toJSONString());  // A不一定是当前链接,因此要在users中获取JSONObject respB = new JSONObject();  // 发送给B的信息respB.put("event", "match_success");respB.put("opponent_username", a.getUsername());respB.put("opponent_photo", a.getPhoto());users.get(b.getId()).sendMessage(respB.toJSONString());}}private void stopMatching() {System.out.println("Stop matching!");matchPool.remove(this.user);}
}

接着修改一下 PKIndexView,当接收到 WS 链接从后端发送过来的匹配成功消息后需要更新对手的头像和用户名:

...<script>
...export default {...setup() {...onMounted(() => {...socket.onmessage = (msg) => {  // 接收到后端消息时会执行const data = JSON.parse(msg.data);  // Spring传过来的数据是放在消息的data中console.log(data);if (data.event === "match_success") {  // 匹配成功store.commit("updateOpponent", {username: data.opponent_username,photo: data.opponent_photo,});setTimeout(() => {  // 3秒后再进入游戏地图界面store.commit("updateStatus", "playing");}, 3000);}};socket.onclose = () => {  // 关闭链接后会执行console.log("Disconnected!");store.commit("updateStatus", "matching");  // 进入游戏地图后玩家点击其他页面应该是默认退出游戏};...});...},
};
</script>...

测试的时候需要用两个浏览器,如果没有两个浏览器可以在 Edge 浏览器的右上角设置菜单中新建 InPrivate 窗口,这样就可以自己登录两个不同的账号进行匹配测试。

3.3 同步游戏地图

现在匹配成功后两名玩家进入游戏时看到的地图是不一样的,因为目前地图还都是在每名玩家本地的浏览器生成的,那么我们就需要将生成地图的逻辑放到服务器端。

先在后端的 consumer.utils 包下创建 Game 类,用来管理整个游戏流程。


http://www.ppmy.cn/news/1233846.html

相关文章

利用Python进行数据分析【送书第六期:文末送书】

&#x1f468;‍&#x1f393;博主简介 &#x1f3c5;云计算领域优质创作者   &#x1f3c5;华为云开发者社区专家博主   &#x1f3c5;阿里云开发者社区专家博主 &#x1f48a;交流社区&#xff1a;运维交流社区 欢迎大家的加入&#xff01; &#x1f40b; 希望大家多多支…

【01】ES6:ECMAScript 介绍

ECMAScript 6.0 &#xff08;以下简称 ES6&#xff09;是 JavaScript 语言的下一代标准&#xff0c;已经在 2015 年 6 月正式发布。它的目标&#xff0c;是使得 JavaScript 语言可以用来编写复杂的大型应用程序&#xff0c;成为企业级开发语言。 参考&#xff1a;《ECMAScript…

2、基础入门——web应用架构搭建漏洞HTTP数据包代理服务器

Web应用环境架构类 开发语言&#xff1a;php、java、python、ASP、ASPX等程序源码&#xff1a;用的人多了&#xff0c;就成CMS了。中间件容器&#xff1a;IIS、Apache、Nginx、Tomcat、Weblogic、Jboos、glasshfish等数据库类型&#xff1a;Access、Mysql、Mssql、Oracle、Red…

uniapp相关记录

一、自定义我的物品组件 my_goods.vue <template><view class"goods-item"><!-- 左侧 --><view class"goods-item-left"><radio :checked"goods.goods_state" color"#c00000" v-if"showRadio" …

NEJM一篇新文为例,聊聊孟德尔随机化研究mr 连锁不平衡(linkage disequilibrium)

2019年3月14日&#xff0c;新英格兰医学杂志发表了一篇论著&#xff0c;Mendelian Randomization Study of ACLY and Cardiovascular disease, 即《ACLY和心血管疾病的孟德尔随机化研究》。与小咖在2017年1月9日报道的一篇发表在新英格兰医学的孟德尔随机化研究——精读NEJM&am…

Axios使用方式

ajax是JQUERY封装的XMLHttprequest用来发送http请求 Axios简单点说它就是一个js库,支持ajax请求,发送axios请求功能更加丰富,丰富在哪不知道 1.npm使用方式 vue项目中 npm install axios 2.cdn方式 <script src"https://unpkg.com/axios/dist/axios.min.js">…

蓝桥等考C++组别八级001

第一部分:选择题 1、C++ L8 (15分) 整数12,18的最大公约数(公因数)是( )。 A. 3 B. 4 C. 6 D. 36 正确答案:C

golang 断点调试

1.碰见如下报错,调试器没有打印变量信息 Delve is too old for Go version 1.21.2 (maximum supported version 1.19) 2. 解决办法 升级delve delve是go语言的debug工具。 go install github.com/go-delve/delve/cmd/dlvlatest报错 Get “https://proxy.golang.org/github…