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

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

本节内容为实现完整的匹配系统微服务。

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 文件:

1
server.port=3001

com.kob.matchingsystem 包下创建 controllerservice 包,在 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) { // 注意这边不能用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);
}
}

注意,匹配系统应该只能接收来自 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) {
// 放行来自http://remoteAddr/servletPath的请求
return remoteAddr.equals(request.getRemoteAddr()) && servletPath.equals(request.getServletPath());
}
}
}

我们将自定义的 IpAddressMatcher 类定义为 record,这是 Java 14 中引入的一个新特性。它是一种类似于类的结构,但用于表示不可变数据。相比于传统的 Java 类,record 在定义数据类时更为简洁、易读和易用,record 申明的类,具备以下特点:

  • 它是一个 final 类。
  • 自动实现 equalshashCodetoString 方法。
  • 成员变量均为 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.servletjavax.websocket 已经被弃用,需要用 Jakarta 提供的依赖包,先在 backendpom.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("/user/account/login/", "/user/account/register/").permitAll(); // 使用这种也行,但是无法通过浏览器地址访问,只能通过前端发出HTTP请求
auth.requestMatchers(new IpAddressMatcher("0:0:0:0:0:0:0:1", "/error")).permitAll(); // 0:0:0:0:0:0:0:1是localhost的IPv6版本
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) {
// 放行来自http://remoteAddr/servletPath的请求
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
<!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter -->
<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>

<!-- 解决SpringBoot版本升级到3.2.0导致的Invalid value type for attribute 'factoryBeanObjectType': java.lang.String -->
<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 包下的 AddServiceImplUpdateServiceImpl 类。

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

先在 backend 项目的 config 包下创建 RestTemplateConfig 类,便于之后在其他地方注入 RestTemplateRestTemplate 能够在应用中调用 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 {
// 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://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) { // 一般会把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);
}
}
}

需要注意的是,从 Spring 5 开始,Spring 引入了一个新的 HTTP 客户端叫做 WebClientWebClientRestTemplate 的一个现代化的替代品,它不仅提供了传统的同步 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;

// 向匹配系统发送请求的URL
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() { // 需要向MatchingSystem发送请求
MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
data.add("user_id", String.valueOf(user.getId()));
data.add("rating", String.valueOf(user.getRating()));
webClient.post() // POST请求
.uri(matchingAddPlayerUrl) // 请求路径
.body(BodyInserters.fromFormData(data)) // 请求体,MultiValueMap对象默认发起的是Form提交,使用BodyInserters.fromFormData()将其添加到请求体中
.retrieve() // 获取响应体
.bodyToMono(String.class) // 响应数据类型转换,返回Mono<String>类型的数据
.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() { // 需要向MatchingSystem发送请求
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
<!-- 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 类:

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,将 backendconfig 包中的 WebClientConfig 复制到 matchingsystemconfig 包中(别忘了也需要在 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 // 为了在类中能够注入Bean
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) { // 将当前所有等待匹配的玩家等待时间加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()));
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); // 从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;
}
}
}
}

从匹配池中删除玩家需要遍历找出 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();
...
});

...
}

...
}