筆記內容轉載自 AcWing 的 SpringBoot 框架課講義,課程鏈接:AcWing SpringBoot 框架課。
CONTENTS
- 1. 配置WebSocket
- 2. 前后端WebSocket通信
- 2.1 WS通信的建立
- 2.2 加入JWT驗證
- 3. 前后端匹配業務
- 3.1 實現前端頁面
- 3.2 實現前后端交互邏輯
- 3.3 同步游戲地圖
我們的游戲之后是兩名玩家對戰,因此需要實現聯機功能,在這之前還需要實現一個匹配系統,能夠匹配分數相近的玩家進行對戰。
想要進行匹配就至少要有兩個客戶端,當兩個客戶端都向服務器發送匹配請求后并不會馬上得到返回結果,一般會等待一段時間,這個時間是未知的,因此這個匹配是一個異步的過程,對于這種異步的過程或者是計算量比較大的過程我們都會用一個額外的服務來操作。
那么這個額外的用于匹配的服務可以稱為 Matching System,這是另外一個程序(進程),當后端服務器接收到前端的請求后就會將請求發送給 Matching System,這個匹配系統維護了一堆用戶的集合,它會不斷地去匹配分數最接近的用戶,當匹配成功一組用戶后就會將結果返回給后端服務器,再由后端將匹配結果立即返回給對應的前端。這種服務就被稱為微服務,可以用 Spring Cloud 實現。
用以前的 HTTP 請求很難達到這種效果,之前我們是在客戶端向后端發送請求,且后端在短時間內就會返回結果,HTTP 請求只能滿足這種一問一答式的服務。而我們現在需要實現的效果是客戶端發送請求后不知道經過多長時間后端才會返回結果,對于這種情況需要使用 WebSocket 協議(WS),該協議不僅支持客戶端向服務器發送請求,也支持服務器向客戶端發送請求。
在前端向服務器發送請求后,服務器會維護好一個 WS 鏈接,這個鏈接其實就是一個 WebSocketServer
類的實例,所有和這個鏈接相關的信息都會存到這個類中。
1. 配置WebSocket
我們之前每次刷新網頁就會隨機生成游戲地圖,該過程是在瀏覽器本地執行的,當我們要實現匹配功能時,地圖就不能由兩名玩家各自的客戶端生成,否則就基本不可能完全一樣了。
當匹配成功后應該由服務器端創建一個 Game 任務,將游戲放到該任務下執行,統一生成地圖,且判斷移動或者輸贏等邏輯之后也應該移到后端來執行。
生成好地圖后服務器就將地圖傳給兩名玩家的前端,然后等待玩家的鍵盤輸入或者是 Bot 代碼的輸入,Bot 代碼的輸入也屬于一個微服務。
首先我們先在 pom.xml
文件中添加以下依賴:
spring-boot-starter-websocket
fastjson
接著在 config
包下創建 WebSocketConfig
配置類:
package com.kob.backend.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;@Configuration
public class WebSocketConfig {@Beanpublic ServerEndpointExporter serverEndpointExporter() {return new ServerEndpointExporter();}
}
然后我們創建一個 consumer
包,在其中創建 WebSocketServer
類:
package com.kob.backend.consumer;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 {@OnOpenpublic void onOpen(Session session, @PathParam("token") String token) {// 建立鏈接}@OnClosepublic void onClose() {// 關閉鏈接}@OnMessagepublic void onMessage(String message, Session session) {// 從Client接收消息}@OnErrorpublic void onError(Session session, Throwable error) {error.printStackTrace();}
}
之前我們配置的 Spring Security 設置了屏蔽除了授權之外的其他所有鏈接,因此我們需要在 SecurityConfig
類中放行一下 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;@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception { // AuthenticationManager用于處理身份驗證return super.authenticationManagerBean();}@Overrideprotected void configure(HttpSecurity http) throws Exception { // 配置HttpSecurityhttp.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests().antMatchers("/user/account/login/", "/user/account/register/").permitAll() // 需要公開的鏈接在這邊寫即可.antMatchers(HttpMethod.OPTIONS).permitAll().anyRequest().authenticated();http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);}@Overridepublic void configure(WebSecurity web) throws Exception {web.ignoring().antMatchers("/websocket/**");}
}
如果是使用新版的配置而不是使用 WebSecurityConfigurerAdapter
可以按以下方式配置:
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.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.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;@Configuration
@EnableWebSecurity
public class SecurityConfig {@Autowiredprivate JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Beanpublic AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests().antMatchers("/user/account/login/", "/user/account/register/").permitAll().antMatchers(HttpMethod.OPTIONS).permitAll().anyRequest().authenticated();http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);return http.build();}@Beanpublic WebSecurityCustomizer webSecurityCustomizer(){return (web) -> web.ignoring().antMatchers("/websocket/**");}
}
2. 前后端WebSocket通信
2.1 WS通信的建立
WebSocket 不屬于單例模式(同一個時間每個類只能有一個實例,我們每建一個 WS 鏈接都會新創建一個實例),不是標準的 Spring 中的組件,因此在注入 Mapper
時不能用 @Autowired
直接注入,一般是將 @Autowired
寫在一個 set()
方法上,Spring 會根據方法的參數類型從 IoC 容器中找到該類型的 Bean 對象注入到方法的行參中,并且自動反射調用該方法。
我們先假設前端傳過來的是用戶 ID 而不是 JWT 令牌:
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
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'結尾
public class WebSocketServer {// ConcurrentHashMap是一個線程安全的哈希表,用于將用戶ID映射到WS實例private static final ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();private User user;private Session session = null;private static UserMapper userMapper;@Autowiredpublic void setUserMapper(UserMapper userMapper) {WebSocketServer.userMapper = userMapper;}@OnOpenpublic void onOpen(Session session, @PathParam("token") String token) {this.session = session;System.out.println("Connected!");Integer userId = Integer.parseInt(token);this.user = userMapper.selectById(userId);users.put(userId, this);}@OnClosepublic void onClose() {System.out.println("Disconnected!");if (this.user != null) {users.remove(this.user.getId());}}@OnMessagepublic void onMessage(String message, Session session) {System.out.println("Receive message!");}@OnErrorpublic 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();}}}
}
然后我們先在前端的 PKIndexView
組件中調試,當組件被掛載完成后發出請求建立 WS 鏈接,當被卸載后關閉 WS 鏈接:
<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();let socket = null;let socket_url = `ws://localhost:3000/websocket/${store.state.user.id}/`;onMounted(() => {socket = new WebSocket(socket_url);store.commit("updateOpponent", {username: "我的對手",photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",});socket.onopen = () => { // 鏈接成功建立后會執行console.log("Connected!");store.commit("updateSocket", socket);};socket.onmessage = (msg) => { // 接收到后端消息時會執行const data = JSON.parse(msg.data); // Spring傳過來的數據是放在消息的data中console.log(data);};socket.onclose = () => { // 關閉鏈接后會執行console.log("Disconnected!");};});onUnmounted(() => {socket.close(); // 如果不斷開鏈接每次切換頁面都會創建新鏈接,就會導致有很多冗余鏈接});},
};
</script><style scoped></style>
現在我們在對戰頁面每次刷新后都可以在瀏覽器控制臺或后端控制臺中看到 WS 的輸出信息。
接下來我們要將 WebSocket 存到前端的 store
中,在 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: {},
};
同時要在 store/index.js
中引入進來:
import { createStore } from "vuex";
import ModuleUser from "./user";
import ModulePk from "./pk";export default createStore({state: {},getters: {},mutations: {},actions: {},modules: {user: ModuleUser,pk: ModulePk,},
});
2.2 加入JWT驗證
現在我們直接使用用戶的 ID 建立 WS 鏈接,這是不安全的,因為前端可以自行修改這個 ID,因此就需要加入 JWT 驗證。
WebSocket 中沒有 Session 的概念,因此我們在驗證的時候前端就不用將信息放到表頭里了,直接放到鏈接中就行:
...<script>
...export default {...setup() {...let socket_url = `ws://localhost:3000/websocket/${store.state.user.jwt_token}/`;...},
};
</script>...
驗證的邏輯可以參考之前的 JwtAuthenticationTokenFilter
,我們可以把這個驗證的模塊單獨寫到一個文件中,在 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;}
}
然后就可以在 WebSocketServer
中解析 JWT 令牌:
package com.kob.backend.consumer;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.concurrent.ConcurrentHashMap;@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'結尾
public class WebSocketServer {...@OnOpenpublic void onOpen(Session session, @PathParam("token") String token) throws IOException {this.session = session;System.out.println("Connected!");Integer userId = JwtAuthentication.getUserId(token);this.user = userMapper.selectById(userId);if (user != null) {users.put(userId, this);} else {this.session.close();}}...
}
3. 前后端匹配業務
3.1 實現前端頁面
我們需要實現一個前端的匹配頁面,并能夠切換匹配和對戰頁面,可以根據之前在 store
中存儲的 status
狀態來動態展示頁面。首先在 components
目錄下創建 MatchGround.vue
組件,其中需要展示玩家自己的頭像和用戶名以及對手的頭像和用戶名,當點擊開始匹配按鈕時向 WS 鏈接發送開始匹配的消息,點擊取消按鈕時發送取消匹配的消息:
<template><div class="matchground"><div class="row"><div class="col-md-6" style="text-align: center;"><div class="photo"><img class="img-fluid" :src="$store.state.user.photo"></div><div class="username">{{ $store.state.user.username }}</div></div><div class="col-md-6" style="text-align: center;"><div class="photo"><img class="img-fluid" :src="$store.state.pk.opponent_photo"></div><div class="username">{{ $store.state.pk.opponent_username }}</div></div><div class="col-md-12 text-center" style="margin-top: 14vh;"><button @click="click_match_btn" type="button" class="btn btn-info btn-lg">{{ match_btn_info }}</button></div></div></div>
</template><script>
import { ref } from "vue";
import { useStore } from "vuex";export default {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({ // 將json封裝成字符串發送給后端,后端會在onMessage()中接到請求event: "start_match", // 表示開始匹配}));} else {match_btn_info.value = "開始匹配";store.state.pk.socket.send(JSON.stringify({event: "stop_match", // 表示停止匹配}));}};return {match_btn_info,click_match_btn,};},
};
</script><style scoped>
div.matchground {width: 60vw;height: 70vh;margin: 40px auto;border-radius: 10px;background-color: rgba(50, 50, 50, 0.5);
}img {width: 35%;border-radius: 50%;margin: 14vh 0 1vh 0;
}.username {font-size: 24px;font-weight: bold;color: white;
}
</style>
3.2 實現前后端交互邏輯
當用戶點擊開始匹配按鈕后,前端要向服務器發出一個請求,后端接收到請求后應該將該用戶放入匹配池中,由于目前還沒有實現微服務,因此我們先在 WebSocketServer
后端用一個 Set
維護正在匹配的玩家,當匹配池中滿兩名玩家就將其匹配在一起,然后將匹配結果返回給兩名玩家的前端:
package com.kob.backend.consumer;import com.alibaba.fastjson2.JSONObject;
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 {// ConcurrentHashMap是一個線程安全的哈希表,用于將用戶ID映射到WS實例private static final ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();// CopyOnWriteArraySet也是線程安全的private static final CopyOnWriteArraySet<User> matchPool = new CopyOnWriteArraySet<>(); // 匹配池private User user;private Session session = null;private static UserMapper userMapper;@Autowiredpublic void setUserMapper(UserMapper userMapper) {WebSocketServer.userMapper = userMapper;}@OnOpenpublic void onOpen(Session session, @PathParam("token") String token) throws IOException {this.session = session;System.out.println("Connected!");Integer userId = JwtAuthentication.getUserId(token);this.user = userMapper.selectById(userId);if (user != null) {users.put(userId, this);} else {this.session.close();}}@OnClosepublic void onClose() {System.out.println("Disconnected!");if (this.user != null) {users.remove(this.user.getId());matchPool.remove(this.user);}}@OnMessagepublic void onMessage(String message, Session session) { // 一般會把onMessage()當作路由System.out.println("Receive message!");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();}}@OnErrorpublic 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();}}}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(); // 發送給A的信息respA.put("event", "match_success");respA.put("opponent_username", b.getUsername());respA.put("opponent_photo", b.getPhoto());users.get(a.getId()).sendMessage(respA.toJSONString()); // A不一定是當前鏈接,因此要在users中獲取JSONObject respB = new JSONObject(); // 發送給B的信息respB.put("event", "match_success");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(this.user);}
}
接著修改一下 PKIndexView
,當接收到 WS 鏈接從后端發送過來的匹配成功消息后需要更新對手的頭像和用戶名:
...<script>
...export default {...setup() {...onMounted(() => {...socket.onmessage = (msg) => { // 接收到后端消息時會執行const data = JSON.parse(msg.data); // Spring傳過來的數據是放在消息的data中console.log(data);if (data.event === "match_success") { // 匹配成功store.commit("updateOpponent", {username: data.opponent_username,photo: data.opponent_photo,});setTimeout(() => { // 3秒后再進入游戲地圖界面store.commit("updateStatus", "playing");}, 3000);}};socket.onclose = () => { // 關閉鏈接后會執行console.log("Disconnected!");store.commit("updateStatus", "matching"); // 進入游戲地圖后玩家點擊其他頁面應該是默認退出游戲};...});...},
};
</script>...
測試的時候需要用兩個瀏覽器,如果沒有兩個瀏覽器可以在 Edge 瀏覽器的右上角設置菜單中新建 InPrivate 窗口,這樣就可以自己登錄兩個不同的賬號進行匹配測試。
3.3 同步游戲地圖
現在匹配成功后兩名玩家進入游戲時看到的地圖是不一樣的,因為目前地圖還都是在每名玩家本地的瀏覽器生成的,那么我們就需要將生成地圖的邏輯放到服務器端。
先在后端的 consumer.utils
包下創建 Game
類,用來管理整個游戲流程。