P4. 微服务: 匹配系统 下
0 概述
- 本章是匹配系统的后篇,前篇见P4. 微服务: 匹配系统(上),前篇主要介绍了前后端之间的
websocket
通信,后篇主要介绍了整个游戏系统的实现,以及如何实现匹配系统独立成微服务,Spring
中微服务之间如何进行通信,如何进行url
的访问权限控制。 - 整个匹配系统相对来说还是比较复杂的,涉及到每个模块内部的逻辑,各个模块之间的通信(前后端的
websocket
,微服务之间的restTemplate
),如何新开一个线程,新开的线程如何加锁保证不出现冲突,等等。 - 检验是否完全理解整个模块,我认为需要自己能够自行编码实现匹配系统和游戏同步系统,知道每个工具怎么使用,包括
websocket, Thread, restTemplate, ReentrantLock
等等。
1 游戏同步系统
1.1 游戏同步的设计
在上一节P4. 微服务: 匹配系统(上)的末尾实现了蛇和棋盘的同步,也就是匹配在一起的两名玩家会收到相同的棋盘。现在还需要实现游戏具体逻辑的判断过程,接收用户输入过程。
每一局游戏一共包含三个棋盘,每位用户各一个棋盘,后端维护一个棋盘,基本想法是: (1) 两名用户分别输入下一步操作给后端,(2) 后端执行游戏逻辑过程,(3) 把每一轮的执行结果同步广播给两个前端。
较为复杂的点在于第(2)步的实现,第(2)步的具体整个过程: (1) 匹配成功之后,new
一个 game
对象维护游戏信息,(2) 创建地图并广播给两位用户,(3) 读取两名玩家的输入,(4) 根据输入执行每一轮的游戏逻辑。
会发现有个问题,如果有多名玩家匹配成功,例如有4名玩家匹配成功,分别有两个游戏 game1, game2
,然而一般的程序都是单线程的,也就是在 game1
执行完成后才会执行 game2
,因此 game
不能用单线程来处理,于是就涉及到线程的通信和加锁问题。
1.2 游戏同步的实现
-
首先将
Game
类继承Thread
类以实现多线程,需要在Game
中重写run
方法,run
方法是开启新线程的入口函数,在两名玩家匹配成功后通过game.start()
进入一个新线程(之后执行Game
中的run
),每个websocket
连接维护一个自己的game
棋盘。Game game = new Game(13, 14, 20, a.getId(), b.getId());
-
在
run
中要实现的是具体游戏逻辑,由于游戏一定会在1000轮以内结束,因此循环1000次代替死循环。每一轮执行的内容和设计图中的一样,首先判断双方是否都有输入,如果一方没有输入则直接判负,记录败者,广播给前端结果;如果都有输入,则进入裁判逻辑进行局面判断,判断有结果了则结束游戏,记录败者,广播给前端结果,否则把双方的下一步操作同步广播给游戏双方的前端,再在前端渲染。
@Override public void run() { for (int i = 0; i < 1000; i ++ ) { if (nextStep()) { // 双方都有输入 judge(); if ("playing".equals(status)) { sendMove(); } else { sendResult(); break; } } else { // 有一方没有输入操作,超时判输 status = "finished"; lock.lock(); try { if (nextStepA == null && nextStepB == null) { loser = "all"; } else if (nextStepA == null) { loser = "A"; } else if (nextStepB == null) { loser = "B"; } } finally { lock.unlock(); } sendResult(); // 向两名玩家广播结果 break; } } }
-
nextStep()
是判断获取双方的下一步操作,那就要在Game
中通过nextStepA, nextStepB
来记录每名玩家的下一步操作,在外部线程中要修改这两个变量,内部线程中要读取这两个变量,因此就出现了两个线程同时读写同一个变量的问题,于是要加锁解决。前端获取输入 → 发送消息给后端 → 后端维护的
websocket
连接调用setNextStepA()
→Game
中读取nextStepA
private ReentrantLock lock = new ReentrantLock(); public void setNextStepA(Integer nextStepA) { lock.lock(); try { this.nextStepA = nextStepA; } finally { lock.unlock(); } }
这边设定如果超过5s未获取用户输入,则判定该用户超时,直接判负。
/* 判断双方是否都有输入,如果有则记录下输入并返回 true,如果有一方没有输入则返回 false */ private boolean nextStep() { for (int i = 0; i < 5; i ++ ) { try { sleep(1000); lock.lock(); try { if (nextStepA != null && nextStepB != null) { playerA.getSteps().add(nextStepA); playerB.getSteps().add(nextStepB); return true; } } finally { lock.unlock(); } } catch (InterruptedException e) { e.printStackTrace(); } } return false; }
-
在双方都有输入后,需要向前端广播两者的输入,再让前端进行渲染;如果有没有输入,也需要向前端返回结果。于是又涉及到前后端之间通过
websocket
进行通信,在P4. 微服务: 匹配系统(上)有详细说明。private void sendAllMessage(String message) { WebSocketServer.users.get(playerA.getId()).sendMessage(message); WebSocketServer.users.get(playerB.getId()).sendMessage(message); } private void sendMove() { lock.lock(); try { JSONObject resp = new JSONObject(); resp.put("event", "move"); resp.put("a_direction", nextStepA); resp.put("b_direction", nextStepB); nextStepA = nextStepB = null; sendAllMessage(resp.toJSONString()); } finally { lock.unlock(); } } private void sendResult() { JSONObject resp = new JSONObject(); resp.put("event", "result"); resp.put("loser", loser); sendAllMessage(resp.toJSONString()); }
-
前端向后端通信,对应的是设置
game
线程中的nextStepA, nextStepB
。// 前端通过 socket.send 发送 JSON 格式消息给后端 add_listening_events() { this.ctx.canvas.focus(); const [snake0, snake1] = this.snakes; this.ctx.canvas.addEventListener("keydown", e => { let d = -1; if (e.key === 'w') d = 2; else if (e.key === 'd') d = 1; else if (e.key === 's') d = 0; else if (e.key === 'a') d = 3; if (d >= 0) { this.store.state.pk.socket.send(JSON.stringify({ event: "move", direction: d, })); } }); }
-
之后在前端进行调试,通过
onmessage
接收到后端传来的消息后,通过event
进行判断,如果是move
则渲染蛇移动的方向,如果是result
则渲染蛇已经死亡的情况。为了方便调试,可以自行在前端写一下
a, b
的位置信息。socket.onmessage = msg => { const data = JSON.parse(msg.data); if (data.event === "match_success") { /* ... */ } else if (data.event === "move") { console.log(data); const game = store.state.pk.gameObject; const [snake0, snake1] = game.snakes; snake0.set_direction(data.a_direction); snake1.set_direction(data.b_direction); } else if (data.event === "result") { console.log(data); const game = store.state.pk.gameObject; const [snake0, snake1] = game.snakes; if (data.loser === "all" || data.loser === "A") { snake0.status = "die"; } if (data.loser === "all" || data.loser === "B") { snake1.status = "die"; } } }
private void move(int direction) { if (game.getPlayerA().getId().equals(user.getId())) { game.setNextStepA(direction); } else if (game.getPlayerB().getId().equals(user.getId())) { game.setNextStepB(direction); } } @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(); } else if ("move".equals(event)) { move(data.getInteger("direction")); } }
-
最后在后端翻译一下P1.创建菜单与游戏界面中介绍的游戏裁判逻辑实现
judge
就完成了整个游戏同步系统。private boolean check_valid(List<Cell> cellsA, List<Cell> cellsB) { int n = cellsA.size(); Cell cell = cellsA.get(n - 1); if (g[cell.x][cell.y] == 1) return false; for (int i = 0; i < n - 1; i ++ ) { if (cellsA.get(i).x == cell.x && cellsA.get(i).y == cell.y) return false; } for (int i = 0; i < n - 1; i ++ ) { if (cellsB.get(i).x == cell.x && cellsB.get(i).y == cell.y) return false; } return true; } private void judge() { // 判断两名玩家下一步操作是否合法 List<Cell> cellsA = playerA.getCells(); List<Cell> cellsB = playerB.getCells(); boolean validA = check_valid(cellsA, cellsB); boolean validB = check_valid(cellsB, cellsA); if (!validA || !validB) { status = "finished"; if (!validA && !validB) { loser = "all"; } else if (!validA) { loser = "A"; } else { loser = "B"; } } }
-
前端中计分板,重新匹配按钮等小细节的实现就略过了,请自行实现。
2 匹配系统微服务的实现
2.1 微服务概述
微服务可以理解成为一个独立的程序,本质上是新开了一个 Springboot
。和原来实现的后端服务 backend
呈并列关系,也就是两个独立的 SpringBoot
,均可以接收和发送信息,在 Spring
中通过 url
进行 http
通信。
在 King of Bots 中选择把匹配系统单独拉出来做一个微服务,其实也可以开一个新线程直接实现,但是为了学习新技术就拉出来做一个微服务,学习一下怎么创建微服务,微服务如何和之前实现的后端
backend
进行通信。
微服务的创建就是新建一个父项目,包含 matchingsystem, backend
两个模块,父项目要添加 springcloud
依赖。
2.2 匹配系统接口url的实现
MatchingSystem
一共要实现两个接口 addPlayer, removePlayer
,都是通过 controller, service, service.impl
的步骤实现。
service
中的接口:
public interface MatchingService {
String addPlayer(Integer userId, Integer rating);
String removePlayer(Integer userId);
}
service.impl
先简单的调试一下:
@Service
public class MatchingServiceImpl implements MatchingService {
@Override
public String addPlayer(Integer userId, Integer rating) {
System.out.println("Player: " + userId + " rating: " + rating + "add!");
return "add player success";
}
@Override
public String removePlayer(Integer userId) {
System.out.println("Player: " + userId + " remove!");
return "remove player success";
}
}
controller
定义一下 url
:
@RestController
public class MatchingController {
@Autowired
private MatchingService matchingService;
@PostMapping("/player/add/")
public String addPlayer(@RequestParam MultiValueMap<String, String> data) {
Integer userId = Integer.parseInt(Objects.requireNonNull(data.getFirst("user_id")));
Integer rating = Integer.parseInt(Objects.requireNonNull(data.getFirst("rating")));
return matchingService.addPlayer(userId, rating);
}
@PostMapping("player/remove/")
public String removePlayer(@RequestParam MultiValueMap<String, String> data) {
Integer userId = Integer.parseInt(Objects.requireNonNull(data.getFirst("user_id")));
return matchingService.removePlayer(userId);
}
}
2.3 微服务之间的通信
之前在P4. 微服务: 匹配系统(上)中 WebsocketServer
后端本地实现了一个傻瓜式匹配,现在开始匹配 startMatching
之后,应该向微服务发送一个请求,表示传一个玩家过去;在取消匹配 stopMatching
之后,应该发送一个请求,表示取消当前玩家的匹配。
向后端发请求会用到 SpringBoot 中的一个工具 RestTemplate,需要先进行配置。
RestTemplate 用于在两个 SpringBoot 之间进行通信。
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate getRestTemplate() {
return new RestTemplate();
}
}
之后直接通过 restTemplate.postForObject
调用另一个微服务的 url
,一共有3个参数 url, data, 返回值的class
.
private static RestTemplate restTemplate;
private final String addPlayerUrl = "http://127.0.0.1:3001/player/add/";
@Autowired
private void setRestTemplate(RestTemplate restTemplate) {
WebSocketServer.restTemplate = restTemplate;
}
private void startMatching() {
System.out.println("Start Matching!");
MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
data.put("user_id", Collections.singletonList(this.user.getId().toString()));
data.put("rating", Collections.singletonList(this.user.getRating().toString()));
restTemplate.postForObject(addPlayerUrl, data, String.class);
}
2.4 匹配逻辑的实现
在匹配中通过匹配池 MatchingPool
进行匹配,创建 service.impl.utils.MatchingPool
继承 Thread
,和之前的一样,重写 run
方法实现多线程,这边用多线程是因为每秒都要看有没有玩家可以匹配,是一个死循环,如果单线程就会卡死在这个循环中。
线程的启动设置成在 SpringBoot
服务开启的时候启动:
@SpringBootApplication
public class MatchingSystemApplication {
public static void main(String[] args) {
MatchingServiceImpl.matchingPool.start();
SpringApplication.run(MatchingSystemApplication.class, args);
}
}
在接收到 backend
中 WebsocketServer
调用 /player/add/
的 url
后,找到 serviceImpl
中对应的方法,该方法调用辅助类 MatchingPool
的 addPlayer
方法,向匹配池中添加一位玩家,removePlayer
同理。
@Service
public class MatchingServiceImpl implements MatchingService {
public final static MatchingPool matchingPool = new MatchingPool();
@Override
public String addPlayer(Integer userId, Integer rating) {
System.out.println("Player: " + userId + " rating: " + rating + " add!");
matchingPool.addPlayer(userId, rating);
return "add player success";
}
@Override
public String removePlayer(Integer userId) {
System.out.println("Player: " + userId + " remove!");
matchingPool.removePlayer(userId);
return "remove player success";
}
}
// 辅助类 MatchingPool
public class MatchingPool extends Thread {
private static List<Player> players = new ArrayList<>();
private ReentrantLock lock = new ReentrantLock();
public void addPlayer(Integer userId, Integer rating) {
lock.lock();
try {
players.add(new Player(userId, rating, 0));
} finally {
lock.unlock();
}
}
public void removePlayer(Integer userId) {
lock.lock();
try {
List<Player> newPlayers = new ArrayList<>();
for (Player player : players)
if (!player.getUserId().equals(userId))
newPlayers.add(player);
players = newPlayers;
} finally {
lock.unlock();
}
}
@Override
public void run() {
/* 具体匹配逻辑,看个人喜好实现就行 */
}
}
具体的匹配逻辑在 run
中实现,通常是通过 while(true)
和 Thread.sleep(1000)
每秒对匹配池中所有玩家进行检查,符合条件的玩家对会进行匹配。
匹配完成之后 matchingsystem
需要 sendResult
给 WebsocketServer
,所以 backend
中需要写个 url
可以接收到这个消息,其中 service
的逻辑如下,startGame
为之前写的逻辑,具体是开始一场游戏,并传信息给前端。
要记得在
SecurityConfig
中对权限进行设置,只允许本地调用。
@Override
public String startGame(Integer aId, Integer bId) {
System.out.println("start game: " + aId + " " + bId);
WebSocketServer.startGame(aId, bId);
return "game start success!";
}
最后在 sendResult
中进行调用,一样使用 RestTemplate
实现,
private final static String startGameUrl = "http://127.0.0.1:3000/pk/game/start/";
private void sendResult(Player a, Player b) {
MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
data.put("a_id", Collections.singletonList(a.getUserId().toString()));
data.put("b_id", Collections.singletonList(b.getUserId().toString()));
restTemplate.postForObject(startGameUrl, data, String.class);
}
2.5 匹配系统的权限控制
匹配系统的 url
只能允许本地进行访问,不能让外部进行访问,避免恶意行为,因此使用 Spring Security
进行权限控制。
我们希望只能后端服务器 backend
访问 /player/add/, /player/remove/
,和之前的一样,在 matchingsystem
中添加依赖并写一个网关 SecurityConfig
配置。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/player/add/", "/player/remove/").hasIpAddress("127.0.0.1")
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();
}
}
3 bug的解决
3.1 自己匹配自己
先点击匹配,进入匹配池,再刷新页面(此时已经断开连接并建立新连接),再次点击匹配,会出现自己匹配自己的问题。
解决方法: 在断开连接的时候调用微服务的 removePlayer
@OnClose
public void onClose() {
System.out.println("disconnected!");
if (this.user != null) {
users.remove(this.user.getId());
MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
data.put("user_id", Collections.singletonList(user.getId().toString()));
restTemplate.postForObject(removePlayerUrl, data, String.class);
}
}
3.2 断开连接问题
会出现某位玩家突然断电导致断开连接,或者妈妈回来了,紧急按 alt + F4
结束进程,等等这种情况。
会导致该玩家仍然在匹配池里,能够匹配成功,但是后端和前端无法通过 websocket
进行通信,导致报错。
因此,在用到 users
的地方都要进行非空判断,解决这种情况,以下举个例子:
if (users.get(a.getId()) != null) users.get(a.getId()).game = game;
if (users.get(b.getId()) != null) users.get(b.getId()).game = game;