P4. 微服务: 匹配系统(下)

发布于:2024-06-30 ⋅ 阅读:(21) ⋅ 点赞:(0)

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);
    }
}

在接收到 backendWebsocketServer 调用 /player/add/url 后,找到 serviceImpl 中对应的方法,该方法调用辅助类 MatchingPooladdPlayer 方法,向匹配池中添加一位玩家,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 需要 sendResultWebsocketServer,所以 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;