P4. 微服务: 匹配系统 上
- Tips
- 0 概述
- 1 匹配系统流程
- 2 游戏系统流程
- 3 websocket 前后端通信的基础配置
- 3.1 websocket 的需要的配置
- 3.2 websocket 连接的建立
- 3.3 为 websocket 连接添加 jwt 验证
- 4 实现匹配界面和对战界面的切换
- 5 匹配系统的客户端和 websocket 后端交互部分
- 5.1 明确业务逻辑过程
- 5.2 前端通过 socket 向后端发送消息
- 5.3 后端通过 socket 向前端返回结果
- 6 解决匹配系统其他问题
- 6.1 页面切换判断问题
- 6.2 地图同步问题
- 7 拓展
- 7.1 聊天功能
Tips
- 做任何一个业务,先分析整体的流程,再想怎么用代码实现各部分。
- 对于类似匹配系统这种通信复杂的,最好把系统画出来明确一下。
0 概述
- 观前须知: 整个匹配系统比较复杂,因此分上下章阐述,本章尚未涉及到微服务,只是简单的设计并实现了匹配系统,未考虑到多并发,线程等问题,在下章中会进行改进,开一个微服务进行实现。
- 本章首先介绍了匹配系统和游戏系统的整个流程,需要明确为什么匹配系统要用微服务。
- 另外,本章的关键点在于理解为什么匹配系统要用
websocket
协议,websocket
协议的原理是什么,如何使用websocket
实现通信,前后端分别如何建立websocket
连接,前端如何向后端发送消息,后端如何向前端发送消息。 - 在学习完成后思考一下该怎么通过
websocket
来实现一个聊天对话功能。
1 匹配系统流程
整个匹配流程如上图所示,匹配系统实际上就是用户的集合,是类似于 MySQL
的单独的程序(微服务)。
(1) 客户端先发送匹配请求给后端
(2) 后端把每个用户信息发送给匹配系统 (把用户扔到匹配池)
(3) 匹配系统根据匹配规则将用户进行匹配,有匹配结果 {user1, user2}
之后立刻返回给后端
(4) 后端根据匹配结果中的 {user1, user2}
根据每个用户对应的 socket
连接向客户端返回匹配成功结果
在介绍完匹配系统的流程后,分析一下以下几个问题:
Q1. 什么时候用微服务?
微服务可以理解成一个额外的程序,实现某个逻辑比较独立的功能。
可以发现,整个匹配流程是异步的,也就是在用户发送匹配请求之后,不知道要过多久才有结果,等待时间未知。
当面对异步或计算量大的操作时,需要维护额外的服务进行操作。
Q2. 为什么用后端用
websocket
协议?传统的
http
协议的特点是一问一答,中间返回过程的时间很短,像上一节中bot
的CRUD
操作就是传统的http
,而匹配系统的特点是发送请求后不知道过多长时间才有结果,同时也可能返回多次结果,因此不能用
http
协议,
websocket
协议的特点是客户端和服务端都可以主动发送请求(全双工,两边对称),因此后端采用websocket
协议。介绍一下
websocket
的基本原理:每一个前端建立的连接都会在后端进行维护,维护的实际上是一个
WebSocketServer
类的实例,每一个连接都开一个线程维护(多线程并发)。每一个连接的独有信息,比如匹配的用户可以用private
存下来,而对于所有连接共有的信息,比如匹配池的用户,可以用static
静态变量存起来。简单来说就是每来一个连接就开一个线程,每一个线程
new
一个WebSocketServer
实例来维护这个连接。
2 游戏系统流程
在P1.创建菜单与游戏界面中介绍的游戏都是在本地端实现的,然而对于匹配到的对局需要相同的地图,并且不能把裁判逻辑等放在前端,方便外挂出现。因此需要在后端实现一个 Game
维护整个游戏地图生成和裁判逻辑等。
对于回合制游戏大多把裁判逻辑放在后端,但对于
fps
游戏等需要大量实时返回的游戏会把部分逻辑放在前端,否则延迟太高。
(1) 创建游戏地图,并且返回给对局的两个用户 client1, client2
(本章6.2节实现的部分)
(2) 等待两个玩家都输入下一步操作(可以客户端手动输入,也可以通过执行 Bot
代码的微服务发送结果),如果长时间未获得输入,则判定未输入操作的玩家超时直接判输,否则传给裁判函数进行判断
(3) 判断新局面的情况是否合法,如果有不合法的直接判输赢,合法则继续下一回合直到分出胜负
3 websocket 前后端通信的基础配置
3.1 websocket 的需要的配置
-
首先要安装2个依赖
spring-boot-starter-websocket
,fastjson
(前后端以json
格式通信)。 -
再创建
config.WebSocketConfig
配置类,启用WebSocket
支持。
@Configuration
public class WebSocketConfig {@Beanpublic ServerEndpointExporter serverEndpointExporter() {return new ServerEndpointExporter();}
}
- 在
config.SecurityConfig
配置中添加如下函数,放行websocket
连接。
@Override
public void configure(WebSecurity web) throws Exception {web.ignoring().antMatchers("/websocket/**");
}
3.2 websocket 连接的建立
- 添加
consumer.WebSocketConfig
类,实现后端websocket
连接相关功能。
首先说明一下几个函数的作用:
onOpen: 在创建 websocket
连接时触发,获取当前连接对应的 user
并且放到 users
中,users
是用于通过 userId
找到对应的连接,这样在匹配成功时可以找到用户对应的连接。
onClose: 在关闭连接时触发,把 user
从 users
中移除。
onMessage: 后端接收到前端消息时触发。
sendMessage: 后端向当前连接发送消息。
在
websocket
连接中,每个连接通过一个Session
对象来维护。sendMessage
是一个异步通信过程,需要加一个锁维护。
ConCurrentHashMap
是一个线程安全的哈希表,把userId
映射到WebSocketServer
实例。
WebSocketServer
中注入userMapper
需要setUserMapper
特殊注入,和之前的不同。
@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {private Session session = null;private User user;private static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();private static UserMapper userMapper;@Autowiredprivate 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);if (this.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());}}@OnMessagepublic void onMessage(String message, Session session) {System.out.println("received!");}@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();}}}
}
-
在前端进行调试,实现前端
websocket
连接建立。前端建立
websocket
是通过socketUrl
和js
内置的WebSocket
类来实例化WebSocket
对象实现,该对象包含的函数和后端websocket
包含的类似。onMounted
是指组件挂载时触发的函数,可以理解成页面加载完成后触发,简单来说就是在pk
页面加载完成后建立一个websocket
连接,通过socketUrl
和后端连接起来。
export default {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!");store.commit("updateSocket", socket); // 存到全局变量里}socket.onmessage = msg => {const data = JSON.parse(msg.data);console.log(data);}socket.onclose = () => {console.log("disconnected!");}});onUnmounted(() => {socket.close();});}
}
3.3 为 websocket 连接添加 jwt 验证
之前实现的 socketUrl
是直接传用户的 id
,显然这样很不安全,前端只要更改 socketUrl
就可以用别人的身份进行对局,因此需要把 id
改成 token
进行 jwt
验证。
前端只需要修改 socketUrl
,后端需要从 token
中解析出 userId
。
consumer.utils.JwtAuthentication
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;}
}
consumer.WebSocketServer
@OnOpen
public 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 (this.user != null) {users.put(userId, this);} else {this.session.close(); // 断开连接}System.out.println(users);
}
4 实现匹配界面和对战界面的切换
-
首先模仿
user.js
创建pk.js
包含所有pk
页面所需的全局变量status, opponent_username, opponent_photo, socket
,其中status
表示当前是匹配界面还是对战界面。 -
在
pk
页面通过v-if="$store.state.pk.status === 'xxx'"
来实现界面切换。<template><PlayGround v-if="$store.state.pk.status === 'playing'" /><MatchGround v-if="$store.state.pk.status === 'matching'" /> </template>
-
自行设计
MatchGround
页面内容,需要提供匹配按钮,让用户进行匹配。
5 匹配系统的客户端和 websocket 后端交互部分
5.1 明确业务逻辑过程
用户在点击匹配按钮之后,(1)向 websocket
后端发送一个请求,(2)后端接收到请求之后把用户放到匹配池之中,(3)在匹配池匹配到两个用户之后将结果给后端,(4)最后返回结果给用户。在用户点击取消匹配之后,应该移出匹配池。
可以发现以上的过程涉及以下几个问题:
- 前端如何通过
websocket
连接发送消息给后端,发送消息的格式是什么,后端又如何返回结果给前端 - 如何区分匹配操作和取消操作
5.2 前端通过 socket 向后端发送消息
前端点击按钮之后通过 socket.send()
向后端发送消息,格式为 JSON
格式,通过设置 event
域来区分匹配和取消操作。
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",}));}
}
后端在 onMessage()
函数中接收到消息,将前端发送回来的 JSON
格式信息进行解析,根据 event
判断接下来的操作,可以发现通常是把 onMessage
当做路由来使用。
先用内存存储匹配池,后面用到微服务再改,这边用的是线程安全的容器。
这边有个常用的小细节,在判断字符串相等的时候通常是
"str".equals(var)
的格式,避免出错。
private static CopyOnWriteArrayList<User> matchpool = new CopyOnWriteArrayList<>();@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);
}private void stopMatching() {System.out.println("Stop Matching!");matchpool.remove(this.user);
}@OnMessage
public void onMessage(String message, Session session) {System.out.println("received!");JSONObject data = JSONObject.parseObject(message);String event = data.getString("event");if ("start-matching".equals(event)) {startMatching();} else if ("stop-matching".equals(event)) {stopMatching();}
}
5.3 后端通过 socket 向前端返回结果
先写一个傻瓜式匹配规则,也不考虑并发等问题,因为后面改成微服务还会改,这边只是调试一下用的。
每当匹配池有两个用户可以匹配则进行匹配,结果返回给前端是先通过之前定义的 users
找到匹配用户的 socket
连接,再通过连接调用 sendMessage
向前端发送消息。
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();respA.put("event", "match_success");respA.put("opponent_username", b.getUsername());respA.put("opponent_photo", b.getPhoto());users.get(a.getId()).sendMessage(respA.toJSONString());JSONObject respB = new JSONObject();respB.put("event", "match_success");respB.put("opponent_username", a.getUsername());respB.put("opponent_photo", a.getPhoto());users.get(b.getId()).sendMessage(respB.toJSONString());}
}
前端同样地,在 onmessage
中接收后端返回过来的结果。
PkIndexView.vue
socket.onmessage = msg => {const data = JSON.parse(msg.data);console.log(data);if (data.event === "match_success") {store.commit("UpdateOpponent", {username: data.opponent_username,photo: data.opponent_photo,});setTimeout(() => {store.commit("UpdateStatus", "playing");}, 2000);}
}
6 解决匹配系统其他问题
6.1 页面切换判断问题
在用户匹配成功后,切换到其他页面应该判定为自动放弃,再回到匹配页面。
onUnmounted(() => {socket.close();store.commit("UpdateStatus", "matching");
});
6.2 地图同步问题
当两个用户匹配成功之后,由于地图生成逻辑是放在前端生成的,因此两名玩家的地图是不同的,需要解决这个问题。
解决方法是将地图生成的逻辑放到后端统一生成,在 consumer.utils.Game
实现 Game
类统一管理游戏流程。
地图生成的逻辑在P1.创建菜单与游戏界面中介绍,这边只要翻译成 Java
的就行。
在匹配成功之后,将地图生成并返回给前端:
private void startMatching() {/* ... */while (matchpool.size() >= 2) {Game game = new Game(13, 14, 20);game.createMap();JSONObject respA = new JSONObject();respA.put("gamemap", game.getG());users.get(a.getId()).sendMessage(respA.toJSONString());}
}
之后在前端将 gamemap
存到全局变量中,并且使用该变量在 gamemap.js
中渲染出来地图。
7 拓展
7.1 聊天功能
思考一下如果希望添加对话框的聊天功能该如何实现?
聊天功能就是用户A发送消息 content
,用户B接收到 content
。
在匹配过程中我们已经学习过 websocket
的具体使用方法: (1) 客户端向后端发送消息,(2) 后端向客户端发送消息。
因此可以用户A首先向后端发送消息,event
可以设置为 send_message
,再添加 content
域记录发送的消息,后端接收到 message
后根据对手用户B的 id
找到对应的 socket
之后发送给用户B的客户端 message
即可。