本节内容为实现完整的匹配系统微服务。
1. 重构项目
1.1 初始化Spring Cloud项目
现在需要把匹配系统设计成一个微服务,也就是一个独立的系统,可以认为是一个新的 SpringBoot 后端,当之前的服务器获取到两名玩家的匹配请求后会向后台的匹配系统服务器发送 HTTP 请求,匹配系统类似于之前的 Game
,在接收到请求之后也会单独开一个新的线程来匹配,可以设计成每隔一秒扫一遍匹配池中已有的玩家,然后判断能否匹配出来,如果可以就将匹配结果通过 HTTP 请求返回。
匹配系统和网站后端是两个并列的后端项目,因此可以修改一下项目结构,将这两个后端改为子项目,然后新建一个新的父级项目。
我们新建一个 Spring 项目,项目名为 backendcloud
,还是选用 Maven 管理项目,组名为 com.kob
。注意 2023.11.24 之后 SpringBoot 2.x 版本正式弃用,SpringBoot 3.x 版本需要 Java 17 及以上。我们现在选择 SpringBoot 3.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 3 4 5 6 7 8 9 10 11 12 <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-test</artifactId > <scope > test</scope > </dependency > </dependencies >
1.2 创建匹配系统框架
由于有两个 SpringBoot 服务,因此需要修改一下匹配系统的端口,在 resources
目录下创建 application.properties
文件:
在 com.kob.matchingsystem
包下创建 controller
和 service
包,在 service
包下创建 impl
包。先在 service
包下创建 MatchingService
接口:
1 2 3 4 5 6 package com.kob.matchingsystem.service;public interface MatchingService { String addPlayer (Integer userId, Integer rating) ; String removePlayer (Integer userId) ; }
然后简单实现一下 MatchingServiceImpl
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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
,在 Spring 的 WebClient
中,如果你想要发送一个 Map
类型的数据,你需要将它转换为 MultiValueMap
。这是因为 HTTP 的表单数据通常可以包含多个值,而 Map
只能包含一个值。因此,WebClient
使用 MultiValueMap
来表示表单数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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) { 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); } }
注意,匹配系统应该只能接收来自 Web 后端服务器发来的请求,而不能接收其他外网的请求,因此需要使用 Spring Security 实现用户授权,在 matchingsystem
子项目中添加上 spring-boot-starter-security
依赖后创建一个 config
包,然后创建和之前类似的 SecurityConfig
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package com.kob.matchingsystem.config;import jakarta.servlet.http.HttpServletRequest;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.http.HttpMethod;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;import org.springframework.security.config.http.SessionCreationPolicy;import org.springframework.security.web.SecurityFilterChain;import org.springframework.security.web.util.matcher.RequestMatcher;@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { http.csrf(CsrfConfigurer::disable) .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> { auth.requestMatchers(new IpAddressMatcher ("127.0.0.1" , "/error" )).permitAll(); auth.requestMatchers(new IpAddressMatcher ("127.0.0.1" , "/matching/add/" )).permitAll(); auth.requestMatchers(new IpAddressMatcher ("127.0.0.1" , "/matching/remove/" )).permitAll(); auth.requestMatchers(HttpMethod.OPTIONS).permitAll(); auth.anyRequest().authenticated(); }); return http.build(); } private record IpAddressMatcher (String remoteAddr, String servletPath) implements RequestMatcher { @Override public boolean matches (HttpServletRequest request) { return remoteAddr.equals(request.getRemoteAddr()) && servletPath.equals(request.getServletPath()); } } }
我们将自定义的 IpAddressMatcher
类定义为 record
,这是 Java 14 中引入的一个新特性。它是一种类似于类的结构,但用于表示不可变数据。相比于传统的 Java 类,record
在定义数据类时更为简洁、易读和易用,record
申明的类,具备以下特点:
它是一个 final
类。
自动实现 equals
、hashCode
、toString
方法。
成员变量均为 final
,且有对应的 public
访问器方法。
相当于以下的类定义(省略了自动实现的方法):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 private static final class IpAddressMatcher implements RequestMatcher { private final String remoteAddr; private final String servletPath; public IpAddressMatcher (String remoteAddr, String servletPath) { this .remoteAddr = remoteAddr; this .servletPath = servletPath; } @Override public boolean matches (HttpServletRequest request) { ... } }
现在需要将这个匹配系统子项目变为 Spring 项目,将 Main
改名为 MatchingSystemApplication
,然后将其修改为 SpringBoot 的入口:
1 2 3 4 5 6 7 8 9 10 11 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); } }
1.3 迁移Web后端
在 backendcloud
目录下创建一个新的模块名为 backend
,然后在高级设置中将组 ID 改为 com.kob.backend
,创建好后先将 src
目录删掉,然后将之前的 Web 后端项目中的 src
复制过来,还需要将之前的 pom.xml
配置信息中的依赖配置 <dependencies>
也复制过来,可以将 spring-boot-starter-thymeleaf
依赖删掉。
由于我们新建的 backendcloud
项目升级到了 SpringBoot 3.2.0,因此迁移过来的 backend
需要重构一部分代码,首先是 javax.servlet
和 javax.websocket
已经被弃用,需要用 Jakarta 提供的依赖包,先在 backend
的 pom.xml
中添加以下依赖:
jakarta.websocket-api
jakarta.websocket-client-api
然后修改 JwtAuthenticationTokenFilter
的包导入代码:
1 2 3 4 5 6 7 8 9 10 package com.kob.backend.config.filter;... import jakarta.servlet.FilterChain;import jakarta.servlet.ServletException;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;...
修改 WebSocketServer
的包导入代码:
1 2 3 4 5 6 7 8 9 package com.kob.backend.consumer;... import jakarta.websocket.*;import jakarta.websocket.server.PathParam;import jakarta.websocket.server.ServerEndpoint;...
然后修改 Spring Security 的配置文件 SecurityConfig
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 package com.kob.backend.config;import com.kob.backend.config.filter.JwtAuthenticationTokenFilter;import jakarta.servlet.http.HttpServletRequest;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.http.HttpMethod;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;import org.springframework.security.config.http.SessionCreationPolicy;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.web.SecurityFilterChain;import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;import org.springframework.security.web.util.matcher.RequestMatcher;@Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter; @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } @Bean public AuthenticationManager authenticationManagerBean (AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { http.csrf(CsrfConfigurer::disable) .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> { auth.requestMatchers(new IpAddressMatcher ("0:0:0:0:0:0:0:1" , "/error" )).permitAll(); auth.requestMatchers(new IpAddressMatcher ("0:0:0:0:0:0:0:1" , "/user/account/login/" )).permitAll(); auth.requestMatchers(new IpAddressMatcher ("0:0:0:0:0:0:0:1" , "/user/account/register/" )).permitAll(); auth.requestMatchers(HttpMethod.OPTIONS).permitAll(); auth.anyRequest().authenticated(); }); http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public WebSecurityCustomizer webSecurityCustomizer () { return (web) -> web.ignoring().requestMatchers("/websocket/**" ); } private record IpAddressMatcher (String remoteAddr, String servletPath) implements RequestMatcher { @Override public boolean matches (HttpServletRequest request) { return remoteAddr.equals(request.getRemoteAddr()) && servletPath.equals(request.getServletPath()); } } }
可以注意到我们在放行链接时 IP 地址为 0:0:0:0:0:0:0:1
,代码中的 request.getRemoteAddr()
方法用于获取发起 HTTP 请求的客户端的 IP 地址,Vue 前端使用 Ajax 向后端发送 HTTP 请求时所用的 localhost
通过该方法会返回 IPv6 的回环地址 0:0:0:0:0:0:0:1
,而如果是 SpringBoot 后端之间发送 HTTP 请求则 localhost
会返回 IPv4 的回环地址 127.0.0.1
。
这时候在 IDEA 中连接上数据库后启动一下 backend
项目会看到报错:Invalid value type for attribute 'factoryBeanObjectType': java.lang.String
,这是由于 MyBatis-Plus 中的 MyBatis-Spring 版本较低,以后更新成新版本应该能解决问题,目前我们先在 mybatis-plus-boot-starter
依赖中排除掉 mybatis-spring
,然后自己添加一个新的 mybatis-spring
依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <dependency > <groupId > com.baomidou</groupId > <artifactId > mybatis-plus-boot-starter</artifactId > <version > 3.5.4.1</version > <exclusions > <exclusion > <groupId > org.mybatis</groupId > <artifactId > mybatis-spring</artifactId > </exclusion > </exclusions > </dependency > <dependency > <groupId > org.mybatis</groupId > <artifactId > mybatis-spring</artifactId > <version > 3.0.3</version > </dependency >
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 及返回值类型即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 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 中调用该方法来启动游戏:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 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 { 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; private static RestTemplate restTemplate; private static final String matchingAddPlayerUrl = "http://localhost:3001/matching/add/" ; private static final String matchingRemovePlayerUrl = "http://localhost: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(user.getId()); System.out.println("Player " + user.getId() + " Disconnected!" ); } stopMatching(); } @OnMessage public void onMessage (String message, Session session) { JSONObject data = JSONObject.parseObject(message); String event = data.getString("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 (); 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()); 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 () { 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); if ("success" .equals(resp)) { System.out.println("Player " + user.getId() + " start matching!" ); } } private void stopMatching () { 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); } } }
需要注意的是,从 Spring 5 开始,Spring 引入了一个新的 HTTP 客户端叫做 WebClient
。WebClient
是 RestTemplate
的一个现代化的替代品,它不仅提供了传统的同步 API,还支持高效的非阻塞和异步方法。
WebClient
在 WebFlux 模块中,WebFlux 是 Spring 5.0 之后引入的一种基于响应式编程的 Web 框架。它是完全非阻塞式的,与传统的 Spring MVC 相比,WebFlux 是基于 NIO(新 IO,也叫非阻塞 IO),所以在 IO 性能上会比传统的 MVC 性能要好一些。
如果需要替换成 WebClient
,需要先安装依赖:Spring Boot Starter WebFlux
,然后在 config
包下创建 WebClientConfig
:
1 2 3 4 5 6 7 8 9 10 11 12 13 package com.kob.backend.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.reactive.function.client.WebClient;@Configuration public class WebClientConfig { @Bean public WebClient getWebClient () { return WebClient.builder().build(); } }
WebClient
接口提供了三个不同的静态方法来创建 WebClient
实例:
利用 create()
创建。
利用 create(String baseUrl)
创建。
利用 builder()
创建(推荐)。
使用 builder()
返回一个 WebClient.Builder
,然后再调用 build()
就可以返回 WebClient
对象,我们可以用 WebClient.Builder
实例配置 WebClient
:
baseUrl(String baseUrl)
:这个方法用于设置 WebClient
的基础 URL,所有的请求都会基于这个 URL。例如,如果你设置了 baseUrl("http://base.com")
,那么后续的 .uri("/user/1")
实际上是在请求 http://base.com/user/1
这个 URL。
defaultHeader(String headerName, String... headerValues)
:这个方法用于设置默认的 HTTP 头,这些头会被添加到所有的请求中。例如,你可以使用 defaultHeader(HttpHeaders.USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko)")
来设置 User-Agent 头。
defaultCookie(String cookieName, String... cookieValues)
:这个方法用于设置默认的 HTTP Cookie。这些 Cookie 会被添加到所有的请求中。例如,你可以使用 defaultCookie("ACCESS_TOKEN", "test_token")
来设置一个名为 ACCESS_TOKEN
的 Cookie。
例如:
1 2 3 4 5 WebClient webClient = WebClient.builder() .baseUrl("http://base.com" ) .defaultHeader(HttpHeaders.USER_AGENT,"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko)" ) .defaultCookie("ACCESS_TOKEN" , "test_token" ) .build();
在 WebSocketServer
中按如下方式发送请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 ... import org.springframework.web.reactive.function.BodyInserters;import org.springframework.web.reactive.function.client.WebClient;@Component @ServerEndpoint("/websocket/{token}") public class WebSocketServer { ... private static WebClient webClient; private static final String matchingAddPlayerUrl = "http://localhost:3001/matching/add/" ; private static final String matchingRemovePlayerUrl = "http://localhost:3001/matching/remove/" ; ... @Autowired public void setWebClient (WebClient webClient) { WebSocketServer.webClient = webClient; } ... private void startMatching () { MultiValueMap<String, String> data = new LinkedMultiValueMap <>(); data.add("user_id" , String.valueOf(user.getId())); data.add("rating" , String.valueOf(user.getRating())); webClient.post() .uri(matchingAddPlayerUrl) .body(BodyInserters.fromFormData(data)) .retrieve() .bodyToMono(String.class) .subscribe( resp -> { if ("success" .equals(resp)) { System.out.println("Player " + user.getId() + " start matching!" ); } }, error -> { System.out.println("Start Matching WebClient Error: " + error.getMessage()); }); } private void stopMatching () { MultiValueMap<String, String> data = new LinkedMultiValueMap <>(); data.add("user_id" , String.valueOf(user.getId())); webClient.post() .uri(matchingRemovePlayerUrl) .body(BodyInserters.fromFormData(data)) .retrieve() .bodyToMono(String.class) .subscribe( resp -> { if ("success" .equals(resp)) { System.out.println("Player " + user.getId() + " stop matching!" ); } }, error -> { System.out.println("Stop Matching WebClient Error: " + error.getMessage()); }); } 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 后端相同的依赖版本:
1 2 3 4 5 6 7 <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > <version > 1.18.30</version > <scope > provided</scope > </dependency >
在 matchingsystem
项目的 service.impl
包下创建 utils
包,然后在其中创建 Player
类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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; }
由于我们需要在 matchingsystem
项目中向 backend
发请求,因此也需要用 WebClient
,将 backend
的 config
包中的 WebClientConfig
复制到 matchingsystem
的 config
包中(别忘了也需要在 matchingsystem
项目的 pom.xml
中添加 Spring Boot Starter WebFlux
依赖)。
接着创建 MatchingPool
类用来维护我们的这个新线程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 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.reactive.function.client.WebClient;import java.util.*;import java.util.concurrent.locks.ReentrantLock;@Component public class MatchingPool extends Thread { private static List<Player> players = new ArrayList <>(); private ReentrantLock lock = new ReentrantLock (); private static WebClient webClient; private static final String startGameUrl = "http://localhost:3000/pk/startgame/" ; @Autowired public void setWebClient (WebClient webClient) { MatchingPool.webClient = webClient; } 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 { players.removeIf(player -> player.getUserId().equals(userId)); } finally { lock.unlock(); } } private void increaseWaitingTime (Integer 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 ; } private void sendResult (Player a, Player b) { MultiValueMap<String, String> data = new LinkedMultiValueMap <>(); data.add("a_id" , String.valueOf(a.getUserId())); data.add("b_id" , String.valueOf(b.getUserId())); webClient.post() .uri(startGameUrl) .body(BodyInserters.fromFormData(data)) .retrieve() .bodyToMono(String.class) .subscribe( resp -> { if ("success" .equals(resp)) { System.out.println("Match Success: Player " + a.getUserId() + " and Player " + b.getUserId()); } }, error -> { System.out.println("Matching WebClient Error: " + error.getMessage()); }); } 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 ; } } } players.removeIf(used::contains); } @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 ; } } } }
从匹配池中删除玩家需要遍历找出 userId
所对应的玩家,players.removeIf(player -> player.getUserId().equals(userId));
就等价于以下代码:
1 2 3 4 5 6 for (Iterator<Player> it = players.iterator(); it.hasNext();) { Player player = it.next(); if (player.getUserId().equals(userId)) { it.remove(); } }
现在即可将这个线程在 MatchingServiceImpl
中定义出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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
这个主入口处启动:
1 2 3 4 5 6 7 8 9 10 11 12 13 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
接口:
1 2 3 4 5 package com.kob.backend.service.pk;public interface StartGameService { String startGame (Integer aId, Integer bId) ; }
然后在 service.impl.pk
包下创建接口的实现 StartGameServiceImpl
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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); } }
实现完最后需要放行这个 URL,修改 SecurityConfig
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package com.kob.backend.config;... @Configuration @EnableWebSecurity public class SecurityConfig { ... @Bean public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { http.csrf(CsrfConfigurer::disable) .sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> { ... auth.requestMatchers(new IpAddressMatcher ("127.0.0.1" , "/pk/startgame/" )).permitAll(); ... }); ... } ... }