AcWing:SpringBoot框架课 - 实现微服务:匹配系统(上)
匹配逻辑
- 匹配逻辑
当打开游戏界面,进入匹配界面开始匹配,客户端会向服务器发送请求。并且匹配服务是不固定的时间,因此它是个异步请求,因此它是一个独立的服务,我们可以使用thrift、spring cloud来实现。当后端接收到客户端的请求,会将请求发送给匹配系统。匹配系统接收到请求,会将用户存储到用户集合中,匹配系统会不断的匹配,将匹配分相近的几名用户匹配到一起,将信息返回给后端,后端会将匹配结果分别返回给前端,前端负责展示。若使用http请求是无法达到匹配服务的要求的,因此需要使用web socket协议。
- http协议
简单来说,只有客户端向后端请求,后端才会返回相应的结果给前端,并且后端处理的时间是不太长的。
- web socket协议
客户端向后端发送请求,经过不确定的时间,会返回一次或多次结果给客户端。
微服务使用技术:spring cloud
存在问题
-
地图不能留给客户端生成,要不然两名玩家生成的地图不同
-
游戏裁判逻辑需要移到服务器,要不然客户端会容易作弊,但若裁判逻辑过多,太多用户的请求会使服务器负担过大。因此需要权衡用户体验和游戏的作弊。
实现步骤
-
后端生成地图
-
将地图传给两个客户端
-
wating,等待 用户输入 或 Bot代码 的输入
-
judging
实现大概流程:死循环,每一秒判断是否有了两名用户的输入,若有,则下一步,若没有则继续等待。当下一步输入在五秒内未得到下一步操作,则判定没有输入的蛇输。若得到输入,则通过裁判程序判断两个蛇的操作是否合法或撞墙,不合法方输。之后回到waiting开始状态
代码
0.文件结构
backend
config
WebSocketConfig.java
consumer
utils
Game.java
JwtAuthentication.java
WebSocketServer.java
1.添加依赖
pom.xml
...
<dependencies>
...
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-websocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.7.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.11</version>
</dependency>
...
</dependencies>
...
2.websocket配置类
backend/config/WebSocketConfig.java
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 {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3.建立ws连接类
每个WebSocketServer实例就是一个客户端连接,也就是用户的连接,每个实例的私有属性就是每个连接都各具有的信息,比如说当前连接的用户信息;每个实例的静态属性,就是所有连接的都共享的信息,比如存储所有连接的用户信息List、或者连接数量。
backend/consumer/WebSocketServer.java
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
// url链接:ws://127.0.0.1:3000/websocket/**
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
// 每个链接用Session来维护
private Session session = null;
// 当前链接请求的用户
private User user;
// 目前正在链接的所有用户链接
// ConcurrentHashMap: 线程安全的哈希表
private static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
private static UserMapper userMapper;
// 因为WebSocketServer不是单例的,因此需要用此方式注入UserMapper
@Autowired
public void setUserMapper(UserMapper userMapper) {
WebSocketServer.userMapper = userMapper;
}
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接
this.session = session;
System.out.println("connected!");
// 目前token所代表的含义是userId, 后面会改成token
Integer userId = Integer.parseInt(token);
this.user = userMapper.selectById(userId);
users.put(userId, this);
}
@OnClose
public void onClose() {
// 关闭链接
System.out.println("disconnected!");
if(this.user != null) {
users.remove(this.user.getId());
}
}
@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
System.out.println("receive message: " + message);
}
@OnError
public 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();
}
}
}
}
4.放行websocket连接
SecurityConfig.java
package com.kob.backend.config;
...
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/websocket/**");
}
}
5.先建立pk相关的信息存储
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: {
}
}
将pk引入store中
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,
}
})
6.pk页面创建websocket连接
PkIndexView.vue
<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();
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.id}/`;
let socket = null;
onMounted(() => {
socket = new WebSocket(socketUrl);
// 回调函数:连接时调用
socket.onopen = () => {
console.log("connected!");
};
// 回调函数:接收到后端信息调用
socket.onmessage = msg => {
// 返回的信息格式由后端框架定义,django与spring定义的不一样
const data = JSON.parse(msg.data);
console.log(data);
}
// 回调函数:断开连接时调用
socket.onclose = () => {
console.log("disconnected!");
}
});
onUnmounted(() => {
socket.close();
});
}
}
</script>
<style scoped>
</style>
7.给websocket建立token验证
若使用userId建立ws连接,用户可伪装成任意用户,因此这是不安全的
PkIndexView.vue
<template>
...
</template>
<script>
...
export default {
...
setup() {
...
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;
...
}
}
</script>
<style scoped>
</style>
后端加上判断ws连接:根据token判断用户是否存在
backend/consumer/utils/JwtAuthentication.java
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;
}
}
在建立连接前判断用户是否存在,若不存在,则不能进行建立连接
backend/consumer/WebSocketServer.java
import com.kob.backend.consumer.utils.JwtAuthentication;
...
public class WebSocketServer {
...
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
// 建立连接
this.session = session;
System.out.println("connected!");
// 目前token所代表的含义是userId, 后面会改成token
Integer userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);
if(this.user != null) {
users.put(userId, this);
} else {
this.session.close();
}
}
...
}
8.匹配界面的实现
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';
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({
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;
background-color: rgba(50, 50, 50, 0.5);
margin: 40px auto;
}
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>
根据status判断游戏界面和匹配界面的显示
PkIndexView.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';
export default {
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 = new WebSocket(socketUrl);
// 回调函数:连接时调用
socket.onopen = () => {
console.log("connected!");
store.commit("updateSocket", socket);
};
// 回调函数:接收到后端信息调用
socket.onmessage = msg => {
// 返回的信息格式由后端框架定义,django与spring定义的不一样
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);
}
}
// 回调函数:断开连接时调用
socket.onclose = () => {
console.log("disconnected!");
}
});
onUnmounted(() => {
socket.close();
store.commit("updateStatus", "matching");
});
}
}
</script>
<style scoped>
</style>
后端匹配逻辑的实现
backend/consumer/WebSocketServer.java
package com.kob.backend.consumer;
...
import com.alibaba.fastjson.JSONObject;
import java.util.concurrent.CopyOnWriteArraySet;
@Component
// url链接:ws://127.0.0.1:3000/websocket/**
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
...
// 线程安全的set
private static final CopyOnWriteArraySet<User> matchPool = new CopyOnWriteArraySet<>();
// 因为WebSocketServer不是单例的,因此需要用此方式注入UserMapper
@Autowired
public void setUserMapper(UserMapper userMapper) {
...
}
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
...
}
@OnClose
public void onClose() {
// 关闭链接
System.out.println("disconnected!");
if(this.user != null) {
users.remove(this.user.getId());
matchPool.remove(this.user);
}
}
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);
Game game = new Game(13, 14, 20);
game.createMap();
// 发送给A的信息
JSONObject respA = new JSONObject();
respA.put("event", "start-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
// 通过userId取出a的连接,给A发送respA
users.get(a.getId()).sendMessage(respA.toJSONString());
// 发送给B的信息
JSONObject respB = new JSONObject();
respB.put("event", "start-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
// 通过userId取出b的连接,给B发送respB
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}
private void stopMatching() {
System.out.println("stop matching");
matchPool.remove(this.user);
}
@OnMessage
public void onMessage(String message, Session session) { // 当做路由
// 从Client接收消息
System.out.println("receive message: " + message);
JSONObject data = JSONObject.parseObject(message);
String event = data.getString("event");
if("start-matching".equals(event)) {
startMatching();
} else if("stop-matching".equals(event)) {
stopMatching();
}
}
@OnError
public void onError(Session session, Throwable error) {
...
}
// 后端向前端发送信息
public void sendMessage(String message) {
...
}
}
9.后端生成地图
backend/consumer/utils/Game.java
package com.kob.backend.consumer.utils;
import java.util.Random;
public class Game {
private final Integer rows;
private final Integer cols;
private final Integer inner_walls_count;
private final int[][] g;
private final static int[] dx = {-1, 0, 1, 0};
private final static int[] dy = {0, 1, 0, -1};
public Game(Integer rows, Integer cols, Integer inner_walls_count) {
this.rows = rows;
this.cols = cols;
this.inner_walls_count = inner_walls_count;
this.g = new int[rows][cols];
}
public int[][] getG() {
return g;
}
private boolean check_connectivity(int sx, int sy, int tx, int ty) {
if(sx == tx && sy == ty) return true;
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;
return true;
}
}
}
g[sx][sy] = 0;
return false;
}
// 画地图
private boolean draw() {
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 = new Random();
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 - c][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;
}
}
return check_connectivity(this.rows - 2, 1, 1, this.cols - 2);
}
public void createMap() {
for(int i = 0; i < 1000; i++) {
if(draw()) {
break;
}
}
}
}
在匹配成功后生成地图。GameMap先定义成局部变量,不能定义成WebSocketServer的属性,因为地图是a和b的地图。
backend/consumer/WebSocketServer.java
...
import com.kob.backend.consumer.utils.Game;
public class WebSocketServer {
...
private Game gmae = null;
private void startMatching() {
...
while(matchPool.size() >= 2) {
Iterator<User> it = matchPool.iterator();
// 匹配成功
User a = it.next(), b = it.next();
matchPool.remove(a);
matchPool.remove(b);
Game game = new Game(13, 14, 20);
game.createMap();
// 发送给A的信息
JSONObject respA = new JSONObject();
respA.put("event", "start-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("gamemap", game.getG());
// 通过userId取出a的连接,给A发送respA
users.get(a.getId()).sendMessage(respA.toJSONString());
// 发送给B的信息
JSONObject respB = new JSONObject();
respB.put("event", "start-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("gamemap", game.getG());
// 通过userId取出b的连接,给B发送respB
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}
...
}
10.在store.pk中定义gamemap属性与函数
store/pk.js
export default {
state: {
...
gamemap: null,
},
getters: {
},
mutations: {
...
updateGamemap(state, gamemap) {
state.gamemap = gamemap;
},
},
actions: {
},
modules: {
}
}
匹配成功接收后端生成的地图
PkIndexView.vue
<template>
...
</template>
<script>
...
export default {
components: {
...
},
setup() {
...
onMounted(() => {
...
// 回调函数:接收到后端信息调用
socket.onmessage = msg => {
// 返回的信息格式由后端框架定义,django与spring定义的不一样
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!");
}
});
...
}
}
</script>
<style scoped>
</style>
在GameMap.vue中将store传给GameMap.js,以便取出地图
GameMap.vue
<template>
...
</template>
<script>
...
import { useStore } from "vuex";
export default {
setup() {
const store = useStore();
...
onMounted(() => {
new GameMap(canvas.value.getContext('2d'), parent.value, store);
});
...
}
}
</script>
<style scoped>
...
</style>
删除生成地图函数,直接获取地图
GameMap.js
import { AcGameObject } from "./AcGameObject";
import { Wall } from "./Wall";
import { Snake } from './Snake';
export class GameMap extends AcGameObject {
constructor(ctx, parent, store) {
super();
this.ctx = ctx;
this.parent = parent;
this.store = store;
this.L = 0;
...
}
// 删除check_connectivity()函数
create_walls() {
const g = this.store.state.pk.gamemap;
for(let r = 0; r < this.rows; r++) {
for(let c = 0; c < this.cols; c++) {
if(g[r][c]) {
this.walls.push(new Wall(r, c, this));
}
}
}
}
start() {
this.create_walls();
this.add_listening_events();
}
...
}