SpringBoot学习笔记-实现微服务:匹配系统(下)

笔记内容转载自 AcWing 的 SpringBoot 框架课讲义,课程链接:AcWing SpringBoot 框架课。

CONTENTS

  • [1. 重构项目](#1. 重构项目)
    • [1.1 初始化Spring Cloud项目](#1.1 初始化Spring Cloud项目)
    • [1.2 创建匹配系统框架](#1.2 创建匹配系统框架)
  • [2. 实现匹配系统微服务](#2. 实现匹配系统微服务)
    • [2.1 数据库更新](#2.1 数据库更新)
    • [2.2 Web后端与匹配系统后端通信](#2.2 Web后端与匹配系统后端通信)
    • [2.3 实现匹配逻辑](#2.3 实现匹配逻辑)
    • [2.4 Web后端接收匹配结果](#2.4 Web后端接收匹配结果)

1. 重构项目

1.1 初始化Spring Cloud项目

现在需要把匹配系统设计成一个微服务,也就是一个独立的系统,可以认为是一个新的 SpringBoot 后端,当之前的服务器获取到两名玩家的匹配请求后会向后台的匹配系统服务器发送 HTTP 请求,匹配系统类似于之前的 Game,在接收到请求之后也会单独开一个新的线程来匹配,可以设计成每隔一秒扫一遍匹配池中已有的玩家,然后判断能否匹配出来,如果可以就将匹配结果通过 HTTP 请求返回。

匹配系统和网站后端是两个并列的后端项目,因此可以修改一下项目结构,将这两个后端改为子项目,然后新建一个新的父级项目。

我们新建一个 Spring 项目,项目名为 backendcloud,还是选用 Maven 管理项目,组名为 com.kob。注意 2023.11.24 之后 SpringBoot2.X 版本正式弃用,SpringBoot3.X 版本需要 Java17 及以上。我们现在选择 SpringBoot3.2.0 版本,依赖选上 Spring Web 即可。

父级项目是没有逻辑的,因此可以把 src 目录删掉,然后修改一下 pom.xml,首先在 <description>backendcloud</description> 后添加一行:<packaging>pom</packaging>,然后添加 Spring Cloud 的依赖,前往 Maven 仓库,搜索并安装以下依赖:

  • spring-cloud-dependencies

接着在 backendcloud 目录下创建匹配系统子项目,选择新建一个模块(Module),选择空项目,匹配系统的名称为 matchingsystem,在高级设置中将组 ID 设置为 com.kob.matchingsystem。

这个新建的子项目本质上也是一个 SpringBoot,我们将父级目录的 pom.xml 中的 Spring Web 依赖剪切到 matchingsystem 中的 pom.xml。

1.2 创建匹配系统框架

由于有两个 SpringBoot 服务,因此需要修改一下匹配系统的端口,在 resources 目录下创建 application.properties 文件:

json 复制代码
server.port=3001

在 com.kob.matchingsystem 包下创建 controller 和 service 包,在 service 包下创建 impl 包。先在 service 包下创建 MatchingService 接口:

java 复制代码
package com.kob.matchingsystem.service;

public interface MatchingService {
    String addPlayer(Integer userId, Integer rating);  // 将玩家添加到匹配池中
    String removePlayer(Integer userId);  // 从匹配池中删除玩家
}

然后简单实现一下 MatchingServiceImpl:

java 复制代码
package com.kob.matchingsystem.service.impl;

import com.kob.matchingsystem.service.MatchingService;
import org.springframework.stereotype.Service;

@Service
public class MatchingServiceImpl implements MatchingService {
    @Override
    public String addPlayer(Integer userId, Integer rating) {
        System.out.println("Add Player: " + userId + ", Rating: " + rating);
        return "success";
    }

    @Override
    public String removePlayer(Integer userId) {
        System.out.println("Remove Player: " + userId);
        return "success";
    }
}

最后在 controller 包下创建 MatchingController:

java 复制代码
package com.kob.matchingsystem.controller;

import com.kob.matchingsystem.service.MatchingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Objects;

@RestController
public class MatchingController {
    @Autowired
    private MatchingService matchingService;

    @PostMapping("/matching/add/")
    public String addPlayer(@RequestParam MultiValueMap<String, String> data) {  // 注意这边不能用Map
        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("/matching/remove/")
    public String removePlayer(@RequestParam MultiValueMap<String, String> data) {
        Integer userId = Integer.parseInt(Objects.requireNonNull(data.getFirst("user_id")));
        return matchingService.removePlayer(userId);
    }
}

现在需要将这个匹配系统子项目变为 Spring 项目,将 Main 改名为 MatchingSystemApplication,然后将其修改为 SpringBoot 的入口:

java 复制代码
package com.kob.matchingsystem.service;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MatchingSystemApplication {
    public static void main(String[] args) {
        SpringApplication.run(MatchingSystemApplication.class, args);
    }
}

2. 实现匹配系统微服务

2.1 数据库更新

我们将 rating 放到用户身上而不是 BOT 上,每个用户对应一个自己的天梯分。在 user 表中创建 rating,并将 bot 表中的 rating 删去,然后需要修改对应的 pojo,还有 service.impl.user.account 包下的 RegisterServiceImpl 类以及 service.impl.user.bot 包下的 AddServiceImpl 和 UpdateServiceImpl 类。

2.2 Web后端与匹配系统后端通信

先在 backend 项目的 config 包下创建 RestTemplateConfig 类,便于之后在其他地方注入 RestTemplate。RestTemplate 能够在应用中调用 REST 服务。它简化了与 HTTP 服务的通信方式,统一了 RESTful 的标准,封装了 HTTP 链接,我们只需要传入 URL 及返回值类型即可:

java 复制代码
package com.kob.backend.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {
    @Bean
    public RestTemplate getRestTemplate() {
        return new RestTemplate();
    }
}

我们将 WebSocketServer 的简易匹配代码删去,然后使用 HTTP 请求向 matchingsystem 后端发送匹配请求,注意我们将 startGame() 方法改为 public,因为之后需要在处理匹配成功的 Service 中调用该方法来启动游戏:

java 复制代码
package com.kob.backend.consumer;

import com.alibaba.fastjson2.JSONObject;
import com.kob.backend.consumer.utils.Game;
import com.kob.backend.consumer.utils.JwtAuthentication;
import com.kob.backend.mapper.RecordMapper;
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 jakarta.websocket.*;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

@Component
@ServerEndpoint("/websocket/{token}")  // 注意不要以'/'结尾
public class WebSocketServer {
    // ConcurrentHashMap是一个线程安全的哈希表,用于将用户ID映射到WS实例
    public static final ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
    private User user;
    private Session session = null;
    private Game game = null;

    private static UserMapper userMapper;
    public static RecordMapper recordMapper;  // 要在Game中调用
    private static RestTemplate restTemplate;  // 用于发送HTTP请求

    // 向匹配系统发送请求的URL
    private static final String matchingAddPlayerUrl = "http://127.0.0.1:3001/matching/add/";
    private static final String matchingRemovePlayerUrl = "http://127.0.0.1:3001/matching/remove/";

    @Autowired
    public void setUserMapper(UserMapper userMapper) {
        WebSocketServer.userMapper = userMapper;
    }

    @Autowired
    public void setRecordMapper(RecordMapper recordMapper) {
        WebSocketServer.recordMapper = recordMapper;
    }

    @Autowired
    public void setRestTemplate(RestTemplate restTemplate) {
        WebSocketServer.restTemplate = restTemplate;
    }

    @OnOpen
    public void onOpen(Session session, @PathParam("token") String token) throws IOException {
        this.session = session;

        Integer userId = JwtAuthentication.getUserId(token);
        user = userMapper.selectById(userId);

        if (user != null) {
            users.put(userId, this);
            System.out.println("Player " + user.getId() + " Connected!");
        } else {
            this.session.close();
        }
    }

    @OnClose
    public void onClose() {
        if (user != null) {
            users.remove(this.user.getId());
            System.out.println("Player " + user.getId() + " Disconnected!");
        }
        stopMatching();  // 断开连接时取消匹配
    }

    @OnMessage
    public void onMessage(String message, Session session) {  // 一般会把onMessage()当作路由
        JSONObject data = JSONObject.parseObject(message);
        String event = data.getString("event");  // 取出event的内容

        if ("start_match".equals(event)) {  // 开始匹配
            this.startMatching();
        } else if ("stop_match".equals(event)) {  // 取消匹配
            this.stopMatching();
        } else if ("move".equals(event)) {  // 移动
            move(data.getInteger("direction"));
        }
    }

    @OnError
    public void onError(Session session, Throwable error) {
        error.printStackTrace();
    }

    public void sendMessage(String message) {  // 从后端向当前链接发送消息
        synchronized (session) {  // 由于是异步通信,需要加一个锁
            try {
                session.getBasicRemote().sendText(message);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public void startGame(Integer aId, Integer bId) {
        User a = userMapper.selectById(aId), b = userMapper.selectById(bId);

        game = new Game(13, 14, 20, a.getId(), b.getId());
        game.createMap();
        users.get(a.getId()).game = game;
        users.get(b.getId()).game = game;

        game.start();  // 开一个新的线程

        JSONObject respGame = new JSONObject();
        respGame.put("a_id", game.getPlayerA().getId());
        respGame.put("a_sx", game.getPlayerA().getSx());
        respGame.put("a_sy", game.getPlayerA().getSy());
        respGame.put("b_id", game.getPlayerB().getId());
        respGame.put("b_sx", game.getPlayerB().getSx());
        respGame.put("b_sy", game.getPlayerB().getSy());
        respGame.put("map", game.getG());

        JSONObject respA = new JSONObject(), respB = new JSONObject();  // 发送给A/B的信息
        respA.put("event", "match_success");
        respA.put("opponent_username", b.getUsername());
        respA.put("opponent_photo", b.getPhoto());
        respA.put("game", respGame);
        users.get(a.getId()).sendMessage(respA.toJSONString());  // A不一定是当前链接,因此要在users中获取

        respB.put("event", "match_success");
        respB.put("opponent_username", a.getUsername());
        respB.put("opponent_photo", a.getPhoto());
        respB.put("game", respGame);
        users.get(b.getId()).sendMessage(respB.toJSONString());
    }

    private void startMatching() {  // 需要向MatchingSystem发送请求
        MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
        data.add("user_id", String.valueOf(user.getId()));
        data.add("rating", String.valueOf(user.getRating()));
        String resp = restTemplate.postForObject(matchingAddPlayerUrl, data, String.class);  // 参数为请求地址、数据、返回值的Class
        if ("success".equals(resp)) {
            System.out.println("Player " + user.getId() + " start matching!");
        }
    }

    private void stopMatching() {  // 需要向MatchingSystem发送请求
        MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
        data.add("user_id", String.valueOf(user.getId()));
        String resp = restTemplate.postForObject(matchingRemovePlayerUrl, data, String.class);
        if ("success".equals(resp)) {
            System.out.println("Player " + user.getId() + " stop matching!");
        }
    }

    private void move(Integer direction) {
        if (game.getPlayerA().getId().equals(user.getId())) {
            game.setNextStepA(direction);
        } else if (game.getPlayerB().getId().equals(user.getId())) {
            game.setNextStepB(direction);
        }
    }
}

现在将两个后端项目都启动起来,可以在 IDEA 下方的服务(Services)选项卡的 Add Service 中点击 Run Configuration Type,然后选中 Spring Boot,这样就能在下方窗口中看到两个 SpringBoot 后端的情况。

尝试在前端中开始匹配,可以看到 matchingsystem 后端控制台输出:Add Player: 1, Rating: 1500。

2.3 实现匹配逻辑

匹配系统需要将当前正在匹配的用户放到一个匹配池中,然后开一个新线程每隔一段时间去扫描一遍匹配池,将能够匹配的玩家匹配在一起,我们的匹配逻辑是匹配两名分值接近的玩家,且随着时间的推移,两名玩家的分差可以越来越大。

首先需要添加 Project Lombok 依赖,我们使用与之前 Web 后端相同的依赖版本:

xml 复制代码
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.30</version>
    <scope>provided</scope>
</dependency>

在 matchingsystem 项目的 service.impl 包下创建 utils 包,然后在其中创建 Player 类:

java 复制代码
package com.kob.matchingsystem.service.impl.utils;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Player {
    private Integer userId;
    private Integer rating;
    private Integer waitingTime;  // 等待时间
}

接着创建 MatchingPool 类用来维护我们的这个新线程:

java 复制代码
package com.kob.matchingsystem.service.impl.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.client.RestTemplate;

import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

@Component  // 为了在类中能够注入Bean
public class MatchingPool extends Thread {
    private static List<Player> players = new ArrayList<>();  // 我们之后会自己加锁,因此不需要用线程安全的集合
    private ReentrantLock lock = new ReentrantLock();

    private static RestTemplate restTemplate;
    private static final String startGameUrl = "http://127.0.0.1:3000/pk/startgame/";

    @Autowired
    public void setRestTemplate(RestTemplate restTemplate) {
        WebSocketServer.restTemplate = restTemplate;
    }

    public void addPlayer(Integer userId, Integer rating) {
        lock.lock();
        try {
            // TODO:创建一个新的Player添加到players中
        } finally {
            lock.unlock();
        }
    }

    public void removePlayer(Integer userId) {
        lock.lock();
        try {
            // TODO:将某个Player从players中删掉
        } finally {
            lock.unlock();
        }
    }

    private void increaseWaitingTime(Integer waitingTime) {  // 将当前所有等待匹配的玩家等待时间加waitingTime秒
        for (Player player: players) {
            player.setWaitingTime(player.getWaitingTime() + waitingTime);
        }
    }

    private boolean checkMatched(Player a, Player b) {  // 判断两名玩家是否能够匹配
        int ratingDelta = Math.abs(a.getRating() - b.getRating());  // 分差
        int minWatingTime = Math.min(a.getWaitingTime(), b.getWaitingTime());  // 等待时间较短的玩家符合匹配要求那么等待时间长的也一定符合要求
        return ratingDelta <= minWatingTime * 10;  // 每多匹配一秒则匹配的分值范围加10
    }

    private void sendResult(Player a, Player b) {  // 返回匹配结果给Web后端
        MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
        data.add("a_id", String.valueOf(a.getUserId()));
        data.add("b_id", String.valueOf(b.getUserId()));
        String resp = restTemplate.postForObject(startGameUrl, data, String.class);
    }

    private void matchPlayers() {  // 尝试匹配所有玩家
        Set<Player> used = new HashSet<>();  // 标记玩家是否已经被匹配
        for (int i = 0; i < players.size(); i++) {
            if (used.contains(players.get(i))) continue;
            for (int j = i + 1; j < players.size(); j++) {
                if (used.contains(players.get(j))) continue;
                Player a = players.get(i), b = players.get(j);
                if (checkMatched(a, b)) {
                    used.add(a);
                    used.add(b);
                    sendResult(a, b);
                    break;
                }
            }
        }
        // TODO:从players中移除used中的玩家
    }

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(1000);
                System.out.println(players);  // 输出当前匹配池中的玩家
                lock.lock();
                try {
                    increaseWaitingTime(1);
                    matchPlayers();
                } finally {
                    lock.unlock();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
                break;
            }
        }
    }
}

现在即可将这个线程在 MatchingServiceImpl 中定义出来:

java 复制代码
package com.kob.matchingsystem.service.impl;

import com.kob.matchingsystem.service.MatchingService;
import com.kob.matchingsystem.service.impl.utils.MatchingPool;
import org.springframework.stereotype.Service;

@Service
public class MatchingServiceImpl implements MatchingService {
    public static final MatchingPool matchingPool = new MatchingPool();  // 全局只有一个匹配线程

    @Override
    public String addPlayer(Integer userId, Integer rating) {
        System.out.println("Add Player: " + userId + ", Rating: " + rating);
        matchingPool.addPlayer(userId, rating);
        return "success";
    }

    @Override
    public String removePlayer(Integer userId) {
        System.out.println("Remove Player: " + userId);
        matchingPool.removePlayer(userId);
        return "success";
    }
}

可以在启动 matchingsystem 项目的时候就将该线程启动,即在 MatchingSystemApplication 这个主入口处启动:

java 复制代码
package com.kob.matchingsystem;

import com.kob.matchingsystem.service.impl.MatchingServiceImpl;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class MatchingSystemApplication {
    public static void main(String[] args) {
        MatchingServiceImpl.matchingPool.start();  // 启动匹配线程
        SpringApplication.run(MatchingSystemApplication.class, args);
    }
}

2.4 Web后端接收匹配结果

我们的 Web 后端还需要从 matchingsystem 接收请求,即接收匹配系统匹配成功的信息。在 backend 项目的 service 以及 service.impl 包下创建 pk 包,然后在 service.pk 包下创建 StartGameService 接口:

java 复制代码
package com.kob.backend.service.pk;

public interface StartGameService {
    String startGame(Integer aId, Integer bId);
}

然后在 service.impl.pk 包下创建接口的实现 StartGameServiceImpl

java 复制代码
package com.kob.backend.service.impl.pk;

import com.kob.backend.consumer.WebSocketServer;
import com.kob.backend.service.pk.StartGameService;
import org.springframework.stereotype.Service;

@Service
public class StartGameServiceImpl implements StartGameService {
    @Override
    public String startGame(Integer aId, Integer bId) {
        System.out.println("Start Game: Player " + aId + " and Player " + bId);
        WebSocketServer webSocketServer = WebSocketServer.users.get(aId);
        webSocketServer.startGame(aId, bId);
        return "success";
    }
}

接着在 controller.pk 包下创建 StartGameController:

java 复制代码
package com.kob.backend.controller.pk;

import com.kob.backend.service.pk.StartGameService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Objects;

@RestController
public class StartGameController {
    @Autowired
    private StartGameService startGameService;

    @PostMapping("/pk/startgame/")
    public String startGame(@RequestParam MultiValueMap<String, String> data) {
        Integer aId = Integer.parseInt(Objects.requireNonNull(data.getFirst("a_id")));
        Integer bId = Integer.parseInt(Objects.requireNonNull(data.getFirst("b_id")));
        return startGameService.startGame(aId, bId);
    }
}

实现完最后别忘了在 SecurityConfig 中放行这个 URL。

相关推荐
panxianren2 小时前
Bun 1.4 的 bun test --parallel 将 4 秒的测试套件缩短到约 1 秒
java·javascript
码事漫谈3 小时前
骂AI,会让它做得更好吗?
后端
sunshine22 girl4 小时前
Java学习七 Java 项目实战2-注册业务
java·学习
萧瑟余晖4 小时前
Spring Boot 核心理念与快速上手
spring boot
陈随易4 小时前
bm2,Node.js 与 Bun 项目部署新选择
前端·后端·程序员
省钱兄--zs4 小时前
北京24小时自助健身房系统软件开发实战:从架构设计到部署指南
java·数据仓库·spring boot·小程序·需求分析
youdexiang4 小时前
Android录音软件时间关键词检索功能分析
java·人工智能
架构技术专栏5 小时前
AI Agent 框架怎么选:从一次制度查询拆出技术边界
后端·面试
sunshine22 girl5 小时前
Java学习七 Java 项目实战1-项目创建和主页面搭建
java·开发语言·学习
code2cat5 小时前
Java进阶篇之AtomicStampedReference:引用回到原值,版本戳仍能记住变化
java·cas·并发编程·aba