匹配系统
匹配逻辑
开个多线程无限循环,每次循环等待一秒钟,sleep(1000),给当前正在匹配的每个玩家等待时间+1,然后执行匹配函数看看有没有合适的匹配人选,有能配对上的就给服务端发送请求.(等待时间每秒+1)
匹配过程
当用户在客户端点击开始匹配后,客户端会向服务端发送一个websocket协议的请求,服务端接收到后会把它发送给匹配端。匹配端会新建一个当前用户,等到这个用户和其他的用户匹配成功后,会发一个请求给服务端,在由服务端把具体游戏信息以及匹配到的用户信息通过websocket协议发送给客户端。
匹配端就是另一个springboot服务,匹配端用一个多线程来维护玩家间的匹配。
tips:其实也可以不用新增一个springboot服务,直接在原有的springboot服务上用一个多线程来维护,但新弄一个服务的好处就在于分布式处理,一个服务只用做它该做的就好,不需要在考虑其他的服务,使得更好维护。
匹配原则
等待时间越长的玩家优先匹配
下面是匹配的源代码:
package com.kob.matchingsystem.service.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.client.RestTemplate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
@Component
//匹配池
public class MatchingPool extends Thread{
private static List<Player> players = new ArrayList<>();
private final static ReentrantLock lock = new ReentrantLock(); //线程锁
public static RestTemplate restTemplate; //可以在两个springboot中通信
private static final String StartGameUrl = "http://127.0.0.1:3000/pk/start/game/";
@Autowired
public void setRestTemplate(RestTemplate restTemplate){
MatchingPool.restTemplate = restTemplate;
}
public void addPlayer(Integer userId,Integer rating,Integer BotId){
lock.lock();
try {
players.add(new Player(userId,rating,0,BotId));
}finally {
lock.unlock();
}
}
public void removePlayer(Integer userId){
lock.lock();
try{
List<Player> newPlayers = new ArrayList<>();
for(int i=0;i<players.size();i++){
if(!players.get(i).getUserId().equals(userId)){
newPlayers.add(players.get(i));
}
}
players = newPlayers;
}finally {
lock.unlock();
}
}
public void increaseWaitingTime(){ //将所有玩家的匹配时间+1
for(Player player:players){
player.setWaitingTime(player.getWaitingTime()+1);
}
}
public boolean checkMatched(Player a,Player b){ //两名玩家是否能被匹配到一起
Integer value = Math.abs(a.getRating()-b.getRating());
Integer waitingTimeMin = Math.min(a.getWaitingTime(),b.getWaitingTime());
return waitingTimeMin*10>=value;
}
public void sendReslt(Player a,Player b){ // 返回匹配结果
MultiValueMap<String,String> data = new LinkedMultiValueMap<>();
data.put("aId", Collections.singletonList(a.getUserId().toString()));
data.put("bId", Collections.singletonList(b.getUserId().toString()));
data.put("a_BotId", Collections.singletonList(a.getBotId().toString()));
data.put("b_BotId", Collections.singletonList(b.getBotId().toString()));
restTemplate.postForObject(StartGameUrl,data,String.class);
}
public void matchingPlayers(){ //尝试匹配所有玩家
boolean[] used = new boolean[players.size()]; //当前已经匹配到了的玩家
for(int i=0;i<players.size();i++){ //最先add的一定是等待时间最长的,先给他们分配
if(used[i]) continue;
for(int j=i+1;j<players.size();j++){
if(used[j]) continue;
Player a = players.get(i),b = players.get(j);
if(checkMatched(a,b)){
used[i] = used[j] = true;
sendReslt(a,b);
break;
}
}
}
List<Player> newPlayers = new ArrayList<>();
for(int i=0;i<players.size();i++){ //删掉已经匹配成功的玩家
if(!used[i]){
newPlayers.add(players.get(i));
}
}
players = newPlayers;
}
@Override
public void run() {
while (true){
try {
Thread.sleep(1000);
lock.lock();
try {
System.out.println(players);
increaseWaitingTime();
matchingPlayers();
System.out.println(players);
}finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
WebSocket协议
一问一答是http,单向服务
websocket协议是连接后不需要立即发送消息,而是等需要时再给我发消息,他是双向的,而http是单向的。
也就是说客户端和服务端都有对应的回调函数,用来接收消息和发送消息,触发某个机制后会自动发送消息给客户端或服务端。
维护游戏的流程
因为是小型游戏,我们选择在服务端来判断用户的当前操作合不合法。
如果是大型游戏,会在客户端判断操作合不合法,因为大型游戏所涉及的请求会很多,如果全在服务端判断,用户的体验就会不佳。
实现一局游戏
因为用户不仅要看到自己的移动,还要看到对手的移动,所以每次发请求给客户端的时候,通常要发用户的移动和对手的移动。
判断用户在规定的时间内是否移动了
可以用Thread.sleep()函数来判断,等待毫秒数后再判断是否有操作,在套个循环即可,循环次数=规定的时间(单位(s))*1000/设定的等待毫秒数
原理:因为如果直接用循环,而不用sleep的话,很快就能执行完,无法判断再规定的时间是否有移动,但如果先等待毫秒再去执行,就可以把时间变成可控性.
匹配以某个前缀后面的所有链接
比如:@ServerEndpoint(“/websocket/{token}”)
{}代表匹配所有链接,只要以/websocket/前缀的,后面的链接都匹配的到
WebSocketServer通信类的解析
为了维护多个用户之间的通信,我们需要把地址改成:@ServerEndpoint(“/websocket/{token}”)
这样每个用户传过来的链接都是不一样的,也就可以实现每个用户对应一个实例化对象,操作的也只是当前用户的请求,而不会混淆。
因此,可以把用户的信息定义成成员变量,而所有的用户信息、sql语句的操作等等可以直接定义成static,方便管理用户.
经过我的测试,上诉观点成立
当我们在某一个链接对应的对象中由死循环时,就会阻塞除了有新用户建立连接之外的所有通信,所以很多额外的操作要用多线程来完成,这样通信才是有效的
集成Websocket
-
在pom.xml文件中添加依赖:
-
spring-boot-starter-websocket
-
fastjson
-
-
添加config.WebSocketConfig配置类:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
- 添加consumer.WebSocketServer类
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接
}
@OnClose
public void onClose() {
// 关闭链接
}
@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
}
- 配置config.SecurityConfig
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/websocket/**");
}
用来放行websocket对应的所有链接,**表示匹配所有
通过JWTtoken获取用户ID
public static Integer getUserId(String token) {
int userId = -1;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = Integer.parseInt(claims.getSubject());
} catch (Exception e) {
throw new RuntimeException(e);
}
return userId;
}
一场游戏对应一个多线程
原因:如果用单线程来考虑,除非当前只有一场游戏,否则其他游戏是无法响应的。
无法响应是因为他是一步一步执行的,如果前面没执行完,后面就更不可能了。
为了能让多个游戏都有响应,我们将每局游戏对应一个多线程
线程锁
涉及到多线程之间公共资源之间的写写和读写冲突问题,通常用一个ReentrantLock线程锁类来解决,如下代码:
private ReentrantLock lock = new ReentrantLock();
void set(){
lock.lock;//上锁,其他线程要等待解锁才能访问
try{
//写上要读或要写的公共资源
}finally{ //写finally是因为可能会处异常,出异常也一定要解锁
lock.unlock(); //解锁
}
}
tips:只有多线程之间某些资源涉及到读写和写写冲突,才用得上线程锁,而且一般是写在冲突资源的获取和设置上
CopyOnWriteArraySet类
-
它最适合于具有以下特征的应用程序:Set 大小通常保持很小,只读操作远多于可变操作,需要在遍历期间防止线程间的冲突。
-
它是线程安全的。
-
因为通常需要复制整个基础数组(动态数组),所以可变操作(add()、set() 和 remove() 等等)的开销很大。
-
迭代器支持hasNext(), next()等不可变操作,但不支持可变 remove()等 操作。
-
使用迭代器进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。
RestTemplete类
它是实现微服务的重要一步,通过RestTemplete类的实例来实现服务之间的通信,跟客户端和服务端类似,发送一个http链接,对应的服务端接收请求并调用的对应的处理方法.
微服务:它是一种面向服务的架构风格,其中应用程序被构建为多个不同的小型服务的集合而不是单个应用程序.
下面是具体用法:
实现RestTemplate类的注入
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();
}
}
发送请求:
restTemplate.postForObject( "http://127.0.0.1:3001/player/remove/",data,String.class);
参数1:链接地址
参数2:数据,通常用MultiValueMap来存储
参数3:请求对应的函数返回值类型,通常是类型.class
WebSocket协议的不足
如果用户断网,服务端是无法接收到断开连接,断开连接建立在双方都有网络的前提下
设置网关
我们应该设置一定的访问权限,让自己的系统更加安全
代码如下:
package com.kob.backend.config;
import com.kob.backend.config.filter.JwtAuthenticationTokenFilter;
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.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.authentication.UsernamePasswordAuthenticationFilter;
//实现jwt验证和网关
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/account/token/", "/user/account/register/").permitAll()
.antMatchers("/pk/start/game/","/receive/bot/move/").hasIpAddress("127.0.0.1")
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/websocket/**");
}
}
.hasIpAddress(“127.0.0.1”) 这个函数表示这个链接只允许这个IP访问
优化
在匹配的过程中,等待的时间里,当用户选择取消匹配,但是没有向匹配池里发送命令直接断开连接。也就是说匹配池中的两名玩家有可能一名玩家已经不存在了。那么需要做到的是,如果一名玩家不存在,去通知他并且直接判为输。对于这样的优化需要加一些判断,如果不加判断会报异常。
报异常的原因在于因为WebSocektServer中的玩家已经不存在,去get一个空对象,如果空对象不存在Game属性,那么就一定会报错。
这样就需要加一下get判断,来判断get获取是否为空,如果获取不为空,则执行下一步操作
当已经有一名用户断开连接。暂且先绑定在一起然后在规定时间内直接判输,以达到相应的效果
所有的WebSocketServer、Game类里面都加上相应的判断以执行下一步操作。
另外可以在onclose函数里把当前用户从匹配池中删去,这样便不会遇到匹配自己的情况,也不会遇到切换页面缺还在匹配池里的情况.