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;