第六章--- 实现微服务:匹配系统(上)

匹配过程

匹配系统就是一个单独的程序就类似于MySQL

生成地图的过程在用户本地、两名玩家在本地实现地图

地图就大概率不一样、需要将生成地图过程放在服务器中

Game任务需要生成统一的地图、Game第一步先生成一个地图CreateMap

有很多逻辑都需要在服务器端完成、判断蛇输赢的逻辑

当我们撞的时候死亡、如果在客户端用户本地就可以作弊

所以说我们整个游戏的过程应该都放到服务器端、不止生成地图这个过程

同时蛇的移动、蛇的判定、都要在服务器端统一完成

服务器端判断完之后再把结果返还给前端、前端只是用来花动画的

前端不做任何判定逻辑、并不是所有的游戏判定逻辑都在云端

回合制通信量比较少、比如吃鸡各种fps游戏在本地操作非常频繁

如果都在云端的话延迟会非常高、判断是否击中对方的逻辑判断就是在本地

所以要在游戏体验在用户作弊之间做一个权衡、锁头挂之类的

炉石所有逻辑判断都在云端、很难作弊

整个我们在云端维护游戏过程它的整个流程

1、实现匹配系统的原理剖析

都点击匹配向服务器发出请求匹配系统不会立即返回结果,一般会匹配个几秒

整个游戏是异步的过程,计算量比较大的过程,所以我们就另外写一个进程

后端接收的请求,会将用户的请求,发送给我们的匹配系统,匹配系统维护了一堆用户的集合

匹配系统里有很多很多的用户,将当前用户中战斗力最相近的几个人匹配到一起

然后将我们的匹配结果返回给网站后端返回给serverSpringBoot

返回之后我们的后端就会把结果返回给前端、我们在前端就可以看到匹配的对手是谁

整个匹配的过程其实是一个异步的过程、匹配的过程会经过一段比较长的时间

什么时候有匹配结果我们是未知的

2、WebSocket协议原理剖析

一问一答式http

问一次返回多次中间还有间隔时间用websocket协议

这种情况下我们的https就不能满足要求了、websocket协议

不仅客户端可以主动像服务器发送请求、服务器端也可以主动向客户端发送请求

是两遍对称的一个通信方式

3、我们在云端维护游戏的整个流程

先生成一个地图,将两个地图传给两个客户端,传完之后等待用户输入

waiting、我们可以从代码端获取下一步操作也可以客户端返还

代码端要用微服务了,waiting可以写一个死循环每次循环前先sleep一秒钟

然后判断一下是否两条蛇的下一步操作都有了、如果有的话进行下一步

如果没有的话继续等待、当然我们可以设定一个最大时间最大5s

如果5s之内没有得到下一步操作的话、我们就判断没有输入操作的蛇输

如果超时就判断输赢、如果获得输入写一个judging程序、判断是否合法和撞墙、这个游戏的逻辑

4、WebSocket协议原理剖析

基本原理就是、每一个连接我们都会在后端维护起来

我们会把前端建立的每一个websocket连接在后端维护起来,

比如我们的Clint1连接到我们的服务器、其实一个连接就是一个类

其实就是一个websocketserver类,每来一个连接,其实就是new一个这个类的实例

先创建这个类,我们每次来一个连接的时候本质上就是new一个这个类的实例

每一个连接都是这个类的一个实列来维护的、所有和这个连接相关的信息

都会存到这个类里面、如果是每一个连接自己独有的信息、比如说维护这个连接对应的用户是谁

那可以存成私有变量、如果是维护所有连接的公共信息

比如我们想去维护一下当前哪些用户建立的连接、那么可以存成一个静态变量

WebSocket就是一个多线程、每来一个连接就会开一个新的线程来维护它这个websocket就是一个类

每来一个连接就会开一个线程创建一个类,去维护这个连接流程

用户开始匹配的时候向后端发送一个请求、就会在后端websocket里new一个新的类开一个线程

来维护这个链接那么接收到这个请求之后、我们会把我们的信息发送给我们的匹配系统

匹配系统是一个单独的额外的程序、匹配系统当接收到很多的用户之后随着时间的推移

出现两名玩家的战斗力比较接近匹配出来一局、匹配系统就会将信息返回给我们的后端服务器

也就是我们的websocket服务器、websocket服务器接受到这个信息之后就会将这个信息返回

给这局对战的两名玩家、根据两名玩家建立的链接返还到他们的前端的浏览器里面

同时在我们的服务器端创建一个游戏的过程、因为整个游戏的判断地图的生成都是在云端进行的

这就是websocket的基本原理

5、集成WebSocket

pom.xml文件中添加依赖:

  • spring-boot-starter-websocket
  • fastjson
java 复制代码
  <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-websocket -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
            <version>3.2.0</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>2.0.43</version>
        </dependency>


6.添加WebSocketConfig配置类

java 复制代码
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();
    }
}

7.放行WebSocket协议

java 复制代码
config/SecurityConfig

    // 默认是不接受Websocket请求,需要放行
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/websocket/**");
    }
}

8.创建ws

在此之前先将判断用户是否存在的代码封装起来

代码的业务范围在哪就创建在哪里

java 复制代码
backend/consumer/utils/JwtAuthentication

package com.kob.backend.consumer.utils;

import com.kob.backend.utils.JwtUtil;
import io.jsonwebtoken.Claims;

public class JwtAuthentication {
    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;
    }
}
java 复制代码
backend/consumer/WebSocketServer.java

package com.kob.backend.consumer;

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 javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

@Component
// url链接:ws://127.0.0.1:3000/websocket/**
@ServerEndpoint("/websocket/{token}")  // 注意不要以'/'结尾
public class WebSocketServer {
   // ConcurrentHashMap是线程安全的哈希表
    // 让每一个实例访问同一个users(存储目前所有的连接)
    final private static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();

    private User user; // 当前连接的用户
    private Session session = null; // 每个连接用session来维护
    private static UserMapper userMapper;

    // 因为WebSocketServer不是单例的,因此需要用此方式注入UserMapper
    @Autowired
    public void setUserMapper(UserMapper userMapper) {
        WebSocketServer.userMapper = userMapper;
    }

    @OnOpen
    public void onOpen(Session session, @PathParam("token") String token) {
        // 建立连接
        this.session = session;
        System.out.println("connected!");
        // JwtAuthentication是再consumer/utils中封装的类,用token判断用户是否存在
        Integer userId = JwtAuthentication.getUserId(token); 
        this.user = userMapper.selectById(userId);

        if(this.user != null) {
            users.put(userId, this);
        } else {
            this.session.close();
        }

        System.out.println(users);
    }

    @OnClose
    public void onClose() {
        // 关闭连接
        System.out.println("disconnected!");
        if(this.user != null) {
            users.remove(this.user.getId());
        }
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        // 从Client接收消息
        System.out.println("receive message: " + message);
    }

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

    public void sendMessage(String message) {
        synchronized (this.session) {
            try {
                this.session.getBasicRemote().sendText(message);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

9.在store中存储pk信息

javascript 复制代码
store/pk.js


export default{
  state: {
    status: "matching", // matching表示匹配界面,playing表示对战界面
    socket: null, // 前端和后端建立的连接
    opponent_username: "",
    opponent_photo: "",

  },
  getters: {
  },
  mutations: {
    updateSocket(state, socket) {
        state.socket = socket;
    },
    updateOpponent(state, opponent) {
        state.opponent_username = opponent.username;
        state.opponent_photo = opponent.photo;
    },
    updateStatus(state, status) {
        state.status = status;
    },
  },
  actions: {
  },
  modules: {
  }
}

引入pk模块

javascript 复制代码
store/index.js

...
import ModulePk from './pk'
...

export default createStore({
  ...
  modules: {
    ...
    pk: ModulePk,
  }
})

10在游戏界面中创建ws连接

javascript 复制代码
PkIndexView.vue

<template>
    <PlayGround />
</template>

<script>
import PlayGround from '../../components/PlayGround.vue';
import { onMounted, onUnmounted } from 'vue';
import { useStore } from 'vuex';

export default {
    components: {
        PlayGround,
    },
    setup() {
        const store = useStore();
        const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;

        let socket = null;
        onMounted(() => {
            socket = new WebSocket(socketUrl);

            socket.onopen = () => {
                console.log("connected!");
            };

            socket.onmessage = msg => {
                const data = JSON.parse(msg.data);
                console.log(data);
            }

            socket.onclose = () => {
                console.log("disconnected!");
            }
        });
        onUnmounted(() => {
            socket.close(); 
        });
    }
}
</script>

<style scoped>

</style>

11.实现匹配界面

html 复制代码
components/MatchGround.vue

<template>
  <div class="matchground">
    <div class="row">
      <div class="col-6">
        <div class="user-photo">
          <img :src="$store.state.user.photo" alt="" />
        </div>
        <div class="user-username">
          {{ $store.state.user.username }}
        </div>
      </div>

      <div class="col-6">
        <div class="user-photo">
          <img :src="$store.state.pk.opponent_photo" alt="" />
        </div>
        <div class="user-username">
          {{ $store.state.pk.opponent_username }}
        </div>
      </div>
    </div>
    <div class="col-12" style="text-align: center; padding-top: 15vh">
      <button
        type="button"
        class="btn btn-warning btn-lg"
        @click="click_match_btn"
      >
        {{ match_btn_info }}
      </button>
    </div>
  </div>
</template>

<script>
import { ref } from "vue";
import { useStore } from "vuex";
export default {
  name: "MatchGround",
  setup() {
    const store = useStore();
    let match_btn_info = ref("开始匹配");

    const click_match_btn = () => {
      if (match_btn_info.value === "开始匹配") {
        match_btn_info.value = "取消";
        store.state.pk.socket.send(
          JSON.stringify({
            event: "start-matching",
          })
        );
      } else {
        match_btn_info.value = "开始匹配";
        store.state.pk.socket.send(
          JSON.stringify({
            event: "stop-matching",
          })
        );
      }
    };

    return {
      match_btn_info,
      click_match_btn,
    };
  },
};
</script>

<style scoped>
div.matchground {
  width: 60vw;
  height: 70vh;
  margin: 40px auto;
  background-color: rgba(50, 50, 50, 0.5);
}

div.user-photo {
  text-align: center;
  padding-top: 10vh;
}
div.user-photo > img {
  border-radius: 50%;
  width: 20vh;
}
div.user-username {
  text-align: center;
  font-size: 24px;
  font-weight: 600;
  color: white;
  padding-top: 2vh;
}
</style>
  • 按钮点击后向后端发起相应的请求。

放入游戏界面,根据玩家status来判断显示匹配界面还是游戏界面

html 复制代码
PkIndexView.vue

<template>
  <PlayGround v-if="$store.state.pk.status === 'playing'" />
  <MatchGround v-if="$store.state.pk.status === 'matching'" />
</template>

<script>
import PlayGround from "@/components/PlayGround.vue";
import MatchGround from "@/components/MatchGround.vue";
import { onMounted, onUnmounted } from "vue";
import { useStore } from "vuex";

export default {
  components: {
    PlayGround,
    MatchGround,
  },
  setup() {
    const store = useStore();
    const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;

    let socket = null;
    onMounted(() => {
      store.commit("updateOpponent", {
        username: "我的对手",
        photo:
          "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",
      });
      socket = new WebSocket(socketUrl);

      socket.onopen = () => {
        console.log("connected!");
        store.commit("updateSocket", socket);
      };

      socket.onmessage = (msg) => {
        const data = JSON.parse(msg.data);
        if (data.event === "success-matching") {
          store.commit("updateOpponent", {
            username: data.opponent_username,
            photo: data.opponent_photo,
          });
          // 秒换地图看不见对手
          setTimeout(() => {
            store.commit("updateStatus", "playing");
          }, 2000);
          store.commit("updateGamemap", data.gamemap);
        }
      };

      socket.onclose = () => {
        console.log("disconnected!");
      };
    });

    // 卸载时
    onUnmounted(() => {
      socket.close();
      store.commit("updateStatus", "matching");
    });
  },
};
</script>

<style scoped></style>

代码逻辑:

  1. 根据pk.js中的status来判断生成的界面。
  2. 挂载后,建立连接。
  3. 接收后端信息,更新对手的usernamephoto.
  4. 卸载时,断开连接。

12.完成后端匹配逻辑

java 复制代码
WebSocketServer.java

package com.kob.backend.consumer;

import com.alibaba.fastjson.JSONObject;
import com.kob.backend.consumer.utils.Game;
import com.kob.backend.consumer.utils.JwtAuthentication;
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 javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

@Component
@ServerEndpoint("/websocket/{token}")  // 注意不要以'/'结尾
public class WebSocketServer {

    ...
    private static UserMapper userMapper;

    // 由于WebSocketServer不是单例的,需要用先定义static静态变量,再用类名接收
    @Autowired
    public void setUserMapper(UserMapper userMapper) {
        WebSocketServer.userMapper = userMapper;
    }

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

    @OnClose
    public void onClose() {
        // 关闭连接
        System.out.println("disconnected!");
        if(this.user != null) {
            users.remove(this.user.getId());
            matchpool.remove(this.user);
        }
    }

    private void startMatching() {
        System.out.println("start matching!");
        matchpool.add(this.user);

        while(matchpool.size() >= 2) {
            Iterator<User> it = matchpool.iterator();
            User a = it.next(), b = it.next();
            matchpool.remove(a);
            matchpool.remove(b);


            JSONObject respA = new JSONObject();
            respA.put("event", "success-matching");
            respA.put("opponent_username", b.getUsername());
            respA.put("opponent_photo", b.getPhoto());
            users.get(a.getId()).sendMessage(respA.toJSONString());

            JSONObject respB = new JSONObject();
            respB.put("event", "success-matching");
            respB.put("opponent_username", a.getUsername());
            respB.put("opponent_photo", a.getPhoto());
            users.get(b.getId()).sendMessage(respB.toJSONString());
        }
    }

    private void stopMatching() {
        System.out.println("stop matching!");
        matchpool.remove(user);
    }

    @OnMessage
    public void onMessage(String message, Session session) { // 一般当做路由,判断把任务交给谁处理
        // 从Client接收消息
        System.out.println("receive message!");
        JSONObject data = JSONObject.parseObject(message);
        String event = data.getString("event");
        // 反过来调用equals(),可减少避免event为空时抛出异常
        if("start-matching".equals(event)) {
            startMatching();
        } else if("stop-matching".equals(event)) {
            stopMatching();
        }
    }

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

    public void sendMessage(String message) {
        ...
    }
}

逻辑:

  1. 建立连接后,会将当前连接实例存入users(注意存的时类的实例)中。
  2. 后端接收到请求后,onMessage根据请求类型调用不同的函数。
  3. 开始匹配时,将玩家user存入匹配池。简单的匹配匹池中相邻的玩家,将对手的信息发送给前端(通过userId获取玩家的连接实例,从而调用sendMessage),对手将当前玩家信息发送给前端。
  4. 取消匹配时,将玩家从匹配池退出来。
  5. 断开连接后,将玩家从匹配池退出来,将连接实例从users中移除。

13.实现后端生成地图

由于之前是前端生成地图,会导致两名玩家生成的地图不一样,影响游戏进行。因此,我们需要在后端将地图生成,返回给两名玩家。

和之前在前端写的逻辑一样

在后端实现生成地图代码

java 复制代码
backend/consumer/utils/Game.java

package com.kob.backend.consumer.utils;

import java.util.Random;

public class Game {
    final private Integer rows;
    final private Integer cols;
    final private Integer inner_walls_count;
    final private int [][]g;
    final private static int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};

    public Game(Integer rows, Integer cols, Integer inner_walls_count) {
        this.rows = rows;
        this.cols = cols;
        this.inner_walls_count = inner_walls_count;
        this.g = new int[rows][cols];
    }

    public int[][] getG() {
        return g;
    }

    private boolean check_connectivity(int sx, int sy, int tx, int ty) {
        if(sx == tx && sy == ty) return true;
        g[sx][sy] = 1;

        for(int i = 0; i < 4; i ++) {
            int x = sx + dx[i], y = sy + dy[i];
            if(x >= 0 && x < this.rows && y >= 0 && y < this.cols && g[x][y] == 0) {
                if(check_connectivity(x, y, tx, ty)) {
                    g[sx][sy] = 0;
                    return true;
                }
            }
        }
        g[sx][sy] = 0;
        return false;
    }

    private boolean draw() { // 画地图
        for(int i = 0; i < this.rows; i ++) {
            for(int j = 0; j < this.cols; j ++) {
                g[i][j] = 0;
            }
        }

        for(int r = 0; r < this.rows; r ++) {
            g[r][0] = g[r][this.cols - 1] = 1;
        }
        for(int c = 0; c < this.cols; c ++) {
            g[0][c] = g[this.rows - 1][c] = 1;
        }

        Random random = new Random();
        for(int i = 0; i < this.inner_walls_count / 2; i ++) {
            for(int j = 0; j < 1000; j ++) {
                int r = random.nextInt(this.rows); // 返回0~this.rows - 1的随机值
                int c = random.nextInt(this.cols);
                if(g[r][c] == 1 || g[this.rows - 1 - r][this.cols - 1 - c] == 1) continue;
                if(r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) continue;

                g[r][c] = g[this.rows - 1 - r][this.cols - 1 - c] = 1;
                break;
            }
        }
        return check_connectivity(this.rows - 2, 1, 1, this.cols - 2);
    }

    public void createMap() {
        for(int i = 0; i < 1000; i ++) {
            if(draw()) break;
        }
    }

}

后端向前端发送地图信息

java 复制代码
WebSocketServer.java

private void startMatching() {
        ...
        while(matchpool.size() >= 2) {
            ...

            Game game = new Game(13, 14, 20);
            game.createMap();

            JSONObject respA = new JSONObject();
            ...
            respA.put("gamemap", game.getG());
            users.get(a.getId()).sendMessage(respA.toJSONString());

            JSONObject respB = new JSONObject();
            ...
            respB.put("gamemap", game.getG());
            users.get(b.getId()).sendMessage(respB.toJSONString());
        }
    }

将地图存储在store

javascript 复制代码
export default{
  state: {
    ...
    gamemap: null,

  },
  ...
  mutations: {
    ...
    updateGamemap(state, gamemap) {
        state.gamemap = gamemap;
    }
  },
  ...

前端接收地图信息

javascript 复制代码
PkIndexView.vue

....

    socket.onmessage = (msg) => {
        const data = JSON.parse(msg.data);
        if (data.event === "success-matching") {
          ...
          store.commit("updateGamemap", data.gamemap);
        }
      };
...

画出地图

在创建GameMap实例时,将store也传进去

javascript 复制代码
GameMap.vue

...
    onMounted(() => {
      new GameMap(canvas.value.getContext("2d"), parent.value, store);
    });
...

注意将之前生成地图的代码删干净

javascript 复制代码
GameMap.js

...
 // 创建所有的墙
    create_walls() {
        const g = this.store.state.pk.gamemap;

        for(let r = 0; r < this.rows; r ++) {
            for(let c = 0; c < this.cols; c ++) {
                if(g[r][c]) {
                    this.walls.push(new Wall(r, c, this));
                }
            }
        }

        return true;
    }
...
    start() {
        this.create_walls();
        this.add_listening_events();
    }
...
相关推荐
问道飞鱼11 小时前
【微服务知识】开源RPC框架Dubbo入门介绍
微服务·rpc·开源·dubbo
白总Server12 小时前
JVM解说
网络·jvm·物联网·安全·web安全·架构·数据库架构
CodingBrother13 小时前
软考之面向服务架构SOA
微服务·架构
随遇而安622&50820 小时前
分布式微服务项目,同一个controller方法间的转发导致cookie丢失,报错null pointer异常
分布式·微服务·架构·bug
未命名冀21 小时前
微服务day07
微服务·架构·jenkins
蜜桃小阿雯21 小时前
JAVA开源项目 微服务在线教育系统 计算机毕业设计
java·开发语言·spring boot·微服务·java-ee·开源·maven
车载诊断技术21 小时前
电子电气架构--- 实施基于以太网的安全车载网络
网络·人工智能·安全·架构·汽车·电子电器架构
向上的车轮21 小时前
ODOO学习笔记(8):模块化架构的优势
笔记·python·学习·架构
Kika写代码1 天前
【基于轻量型架构的WEB开发】课程 13.2.4 拦截器 Java EE企业级应用开发教程 Spring+SpringMVC+MyBatis
spring·架构·java-ee
喵叔哟1 天前
【.NET 8 实战--孢子记账--从单体到微服务】--简易权限--访问权限中间件
微服务·中间件·.net