笔记内容转载自 AcWing 的 SpringBoot 框架课讲义,课程链接:AcWing SpringBoot 框架课。
CONTENTS
- 1. 重构项目
- 1.1 初始化Spring Cloud项目
- 1.2 创建匹配系统框架
- 2. 实现匹配系统微服务
- 2.1 数据库更新
- 2.2 Web后端与匹配系统后端通信
- 2.3 实现匹配逻辑
- 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
文件:
server.port=3001
在 com.kob.matchingsystem
包下创建 controller
和 service
包,在 service
包下创建 impl
包。先在 service
包下创建 MatchingService
接口:
package com.kob.matchingsystem.service;public interface MatchingService {String addPlayer(Integer userId, Integer rating); // 将玩家添加到匹配池中String removePlayer(Integer userId); // 从匹配池中删除玩家
}
然后简单实现一下 MatchingServiceImpl
:
package com.kob.matchingsystem.service.impl;import com.kob.matchingsystem.service.MatchingService;
import org.springframework.stereotype.Service;@Service
public class MatchingServiceImpl implements MatchingService {@Overridepublic String addPlayer(Integer userId, Integer rating) {System.out.println("Add Player: " + userId + ", Rating: " + rating);return "success";}@Overridepublic String removePlayer(Integer userId) {System.out.println("Remove Player: " + userId);return "success";}
}
最后在 controller
包下创建 MatchingController
:
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 {@Autowiredprivate MatchingService matchingService;@PostMapping("/matching/add/")public String addPlayer(@RequestParam MultiValueMap<String, String> data) { // 注意这边不能用MapInteger 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 的入口:
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 及返回值类型即可:
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 {@Beanpublic RestTemplate getRestTemplate() {return new RestTemplate();}
}
我们将 WebSocketServer
的简易匹配代码删去,然后使用 HTTP 请求向 matchingsystem
后端发送匹配请求,注意我们将 startGame()
方法改为 public
,因为之后需要在处理匹配成功的 Service 中调用该方法来启动游戏:
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请求// 向匹配系统发送请求的URLprivate 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/";@Autowiredpublic void setUserMapper(UserMapper userMapper) {WebSocketServer.userMapper = userMapper;}@Autowiredpublic void setRecordMapper(RecordMapper recordMapper) {WebSocketServer.recordMapper = recordMapper;}@Autowiredpublic void setRestTemplate(RestTemplate restTemplate) {WebSocketServer.restTemplate = restTemplate;}@OnOpenpublic 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();}}@OnClosepublic void onClose() {if (user != null) {users.remove(this.user.getId());System.out.println("Player " + user.getId() + " Disconnected!");}stopMatching(); // 断开连接时取消匹配}@OnMessagepublic 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"));}}@OnErrorpublic 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); // 参数为请求地址、数据、返回值的Classif ("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 后端相同的依赖版本:
<!-- 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
类:
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
类用来维护我们的这个新线程:
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/";@Autowiredpublic 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中的玩家}@Overridepublic 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
中定义出来:
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(); // 全局只有一个匹配线程@Overridepublic String addPlayer(Integer userId, Integer rating) {System.out.println("Add Player: " + userId + ", Rating: " + rating);matchingPool.addPlayer(userId, rating);return "success";}@Overridepublic String removePlayer(Integer userId) {System.out.println("Remove Player: " + userId);matchingPool.removePlayer(userId);return "success";}
}
可以在启动 matchingsystem
项目的时候就将该线程启动,即在 MatchingSystemApplication
这个主入口处启动:
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
接口:
package com.kob.backend.service.pk;public interface StartGameService {String startGame(Integer aId, Integer bId);
}
然后在 service.impl.pk
包下创建接口的实现 StartGameServiceImpl
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 {@Overridepublic 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
:
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 {@Autowiredprivate 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。