SpringBoot + WebSocket 實現答題對戰匹配機制

低吟不作語發表於2021-05-28

概要設計

類似競技問答遊戲:使用者隨機匹配一名對手,雙方同時開始答題,直到雙方都完成答題,對局結束。基本的邏輯就是這樣,如果有其他需求,可以在其基礎上進行擴充套件

明確了這一點,下面介紹開發思路。為每個使用者擬定四種線上狀態,分別是:待匹配、匹配中、遊戲中、遊戲結束。下面是流程圖,使用者的流程是被規則約束的,狀態也隨流程而變化

對流程再補充如下:

  • 使用者進入匹配大廳(具體效果如何由客戶端體現),將使用者的狀態設定為待匹配
  • 使用者開始匹配,將使用者的狀態設定為匹配中,系統搜尋其他同樣處於匹配中的使用者,在這個過程中,使用者可以取消匹配,返回匹配大廳,此時使用者狀態重新設定為待匹配。匹配成功,儲存匹配資訊,將使用者狀態設定為遊戲中
  • 根據已儲存的匹配資訊,使用者可以獲得對手的資訊。答題是時,每次使用者分數更新,也會向對手推送更新後的分數
  • 使用者完成答題,則等待對手也完成答題。雙方都完成答題,使用者狀態設定為遊戲結束,展示對局結果

詳細設計

針對概要設計提出的思路,我們需要思考以下幾個問題:

  • 如何保持客戶端與伺服器的連線?
  • 如何設計客戶端與服務端的訊息互動?
  • 如何儲存以及改變使用者狀態?
  • 如何匹配使用者?

下面我們一個一個來解決

1. 如何保持使用者與伺服器的連線?

以往我們使用 Http 請求伺服器,並獲取響應資訊。然而 Http 有個缺陷,就是通訊只能由客戶端發起,無法做到服務端主動向客戶端推送資訊。根據概要設計我們知道,服務端需要向客戶端推送對手的實時分數,因此這裡不適合使用 Http,而選擇了 WebSocket。WebSocket 最大的特點就是服務端可以主動向客戶端推送資訊,客戶端也可以主動向服務端傳送資訊,是真正的雙向平等對話

有關 SpringBoot 整合 WebSocket 可參考這篇部落格:https://blog.csdn.net/qq_35387940/article/details/93483678

2. 如何設計客戶端與服務端的訊息互動?

按照匹配機制要求,把訊息劃分為 ADD_USER(使用者加入)、MATCH_USER(匹配對手)、CANCEL_MATCH(取消匹配)、PLAY_GAME(遊戲開始)、GAME_OVER(遊戲結束)

public enum MessageTypeEnum {

    /**
     * 使用者加入
     */
    ADD_USER,
    /**
     * 匹配對手
     */
    MATCH_USER,
    /**
     * 取消匹配
     */
    CANCEL_MATCH,
    /**
     * 遊戲開始
     */
    PLAY_GAME,
    /**
     * 遊戲結束
     */
    GAME_OVER,
}

使用 WebSocket 客戶端可以向服務端傳送訊息,服務端也能向客戶端傳送訊息。把訊息按照需求劃分成不同的型別,客戶端傳送某一型別的訊息,服務端接收後判斷,並按照型別分別處理,最後返回向客戶端推送處理結果。區別客戶端 WebSocket 連線的是從客戶端傳來的 userId,用 HashMap 儲存

@Component
@Slf4j
@ServerEndpoint(value = "/game/match/{userId}")
public class ChatWebsocket {

    private Session session;

    private String userId;

    static QuestionSev questionSev;
    static MatchCacheUtil matchCacheUtil;

    static Lock lock = new ReentrantLock();

    static Condition matchCond = lock.newCondition();

    @Autowired
    public void setMatchCacheUtil(MatchCacheUtil matchCacheUtil) {
        ChatWebsocket.matchCacheUtil = matchCacheUtil;
    }

    @Autowired
    public void setQuestionSev(QuestionSev questionSev) {
        ChatWebsocket.questionSev = questionSev;
    }

    @OnOpen
    public void onOpen(@PathParam("userId") String userId, Session session) {

        log.info("ChatWebsocket open 有新連線加入 userId: {}", userId);

        this.userId = userId;
        this.session = session;
        matchCacheUtil.addClient(userId, this);

        log.info("ChatWebsocket open 連線建立完成 userId: {}", userId);
    }

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

        log.error("ChatWebsocket onError 發生了錯誤 userId: {}, errorMessage: {}", userId, error.getMessage());

        matchCacheUtil.removeClinet(userId);
        matchCacheUtil.removeUserOnlineStatus(userId);
        matchCacheUtil.removeUserFromRoom(userId);
        matchCacheUtil.removeUserMatchInfo(userId);

        log.info("ChatWebsocket onError 連線斷開完成 userId: {}", userId);
    }

    @OnClose
    public void onClose()
    {
        log.info("ChatWebsocket onClose 連線斷開 userId: {}", userId);

        matchCacheUtil.removeClinet(userId);
        matchCacheUtil.removeUserOnlineStatus(userId);
        matchCacheUtil.removeUserFromRoom(userId);
        matchCacheUtil.removeUserMatchInfo(userId);

        log.info("ChatWebsocket onClose 連線斷開完成 userId: {}", userId);
    }

    @OnMessage
    public void onMessage(String message, Session session) {

        log.info("ChatWebsocket onMessage userId: {}, 來自客戶端的訊息 message: {}", userId, message);

        JSONObject jsonObject = JSON.parseObject(message);
        MessageTypeEnum type = jsonObject.getObject("type", MessageTypeEnum.class);

        log.info("ChatWebsocket onMessage userId: {}, 來自客戶端的訊息型別 type: {}", userId, type);

        if (type == MessageTypeEnum.ADD_USER) {
            addUser(jsonObject);
        } else if (type == MessageTypeEnum.MATCH_USER) {
            matchUser(jsonObject);
        } else if (type == MessageTypeEnum.CANCEL_MATCH) {
            cancelMatch(jsonObject);
        } else if (type == MessageTypeEnum.PLAY_GAME) {
            toPlay(jsonObject);
        } else if (type == MessageTypeEnum.GAME_OVER) {
            gameover(jsonObject);
        } else {
            throw new GameServerException(GameServerError.WEBSOCKET_ADD_USER_FAILED);
        }

        log.info("ChatWebsocket onMessage userId: {} 訊息接收結束", userId);
    }

    /**
     * 群發訊息
     */
    private void sendMessageAll(MessageReply<?> messageReply) {

        log.info("ChatWebsocket sendMessageAll 訊息群發開始 userId: {}, messageReply: {}", userId, JSON.toJSONString(messageReply));

        Set<String> receivers = messageReply.getChatMessage().getReceivers();
        for (String receiver : receivers) {
            ChatWebsocket client = matchCacheUtil.getClient(receiver);
            client.session.getAsyncRemote().sendText(JSON.toJSONString(messageReply));
        }

        log.info("ChatWebsocket sendMessageAll 訊息群髮結束 userId: {}", userId);
    }

    // 出於減少篇幅的目的,業務處理方法暫不貼出...
}

3. 如何儲存以及改變使用者狀態?

建立一個列舉類,定義使用者的狀態

/**
 * 使用者狀態
 * @author yeeq
 */
public enum StatusEnum {

    /**
     * 待匹配
     */
    IDLE,
    /**
     * 匹配中
     */
    IN_MATCH,
    /**
     * 遊戲中
     */
    IN_GAME,
    /**
     * 遊戲結束
     */
    GAME_OVER,
    ;

    public static StatusEnum getStatusEnum(String status) {
        switch (status) {
            case "IDLE":
                return IDLE;
            case "IN_MATCH":
                return IN_MATCH;
            case "IN_GAME":
                return IN_GAME;
            case "GAME_OVER":
                return GAME_OVER;
            default:
                throw new GameServerException(GameServerError.MESSAGE_TYPE_ERROR);
        }
    }

    public String getValue() {
        return this.name();
    }
}

選擇 Redis 儲存使用者狀態,還是建立一個列舉類,Redis 中儲存資料都有唯一的 Key 做標識,因此在這裡定義 Redis 中的 Key,分別介紹如下:

  • USER_STATUS:儲存使用者狀態的 Key,儲存型別是 Map<String, String>,其中使用者 userId 為 key,使用者線上狀態 為 value
  • USER_MATCH_INFO:當使用者處於遊戲中時,我們需要記錄使用者的資訊,比如分數等。這些資訊不需要記錄到資料庫,而且隨時會更新,放入快取方便獲取
  • ROOM:可以理解為匹配的兩名使用者建立一個房間,具體實現是以鍵值對方式儲存,比如使用者 A 和使用者 B 匹配,使用者 A 的 userId 是 A,使用者 B 的 userId 是 B,則在 Redis 中記錄為 {A -- B},{B -- A}
public enum EnumRedisKey {

    /**
     * userOnline 線上狀態
     */
    USER_STATUS,
    /**
     * userOnline 對局資訊
     */
    USER_IN_PLAY,
    /**
     * userOnline 匹配資訊
     */
    USER_MATCH_INFO,
    /**
     * 房間
     */
    ROOM;

    public String getKey() {
        return this.name();
    }
}

建立一個工具類,用於操作 Redis 中的資料。

@Component
public class MatchCacheUtil {

    /**
     * 使用者 userId 為 key,ChatWebsocket 為 value
     */
    private static final Map<String, ChatWebsocket> CLIENTS = new HashMap<>();

    /**
     * key 是標識儲存使用者線上狀態的 EnumRedisKey,value 為 map 型別,其中使用者 userId 為 key,使用者線上狀態 為 value
     */
    @Resource
    private RedisTemplate<String, Map<String, String>> redisTemplate;

    /**
     * 新增客戶端
     */
    public void addClient(String userId, ChatWebsocket websocket) {
        CLIENTS.put(userId, websocket);
    }

    /**
     * 移除客戶端
     */
    public void removeClinet(String userId) {
        CLIENTS.remove(userId);
    }

    /**
     * 獲取客戶端
     */
    public ChatWebsocket getClient(String userId) {
        return CLIENTS.get(userId);
    }

    /**
     * 移除使用者線上狀態
     */
    public void removeUserOnlineStatus(String userId) {
        redisTemplate.opsForHash().delete(EnumRedisKey.USER_STATUS.getKey(), userId);
    }

    /**
     * 獲取使用者線上狀態
     */
    public StatusEnum getUserOnlineStatus(String userId) {
        Object status = redisTemplate.opsForHash().get(EnumRedisKey.USER_STATUS.getKey(), userId);
        if (status == null) {
            return null;
        }
        return StatusEnum.getStatusEnum(status.toString());
    }

    /**
     * 設定使用者為 IDLE 狀態
     */
    public void setUserIDLE(String userId) {
        removeUserOnlineStatus(userId);
        redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IDLE.getValue());
    }

    /**
     * 設定使用者為 IN_MATCH 狀態
     */
    public void setUserInMatch(String userId) {
        removeUserOnlineStatus(userId);
        redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IN_MATCH.getValue());
    }

    /**
     * 隨機獲取處於匹配狀態的使用者(除了指定使用者外)
     */
    public String getUserInMatchRandom(String userId) {
        Optional<Map.Entry<Object, Object>> any = redisTemplate.opsForHash().entries(EnumRedisKey.USER_STATUS.getKey())
                .entrySet().stream().filter(entry -> entry.getValue().equals(StatusEnum.IN_MATCH.getValue()) && !entry.getKey().equals(userId))
                .findAny();
        return any.map(entry -> entry.getKey().toString()).orElse(null);
    }

    /**
     * 設定使用者為 IN_GAME 狀態
     */
    public void setUserInGame(String userId) {
        removeUserOnlineStatus(userId);
        redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IN_GAME.getValue());
    }

    /**
     * 設定處於遊戲中的使用者在同一房間
     */
    public void setUserInRoom(String userId1, String userId2) {
        redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(), userId1, userId2);
        redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(), userId2, userId1);
    }

    /**
     * 從房間中移除使用者
     */
    public void removeUserFromRoom(String userId) {
        redisTemplate.opsForHash().delete(EnumRedisKey.ROOM.getKey(), userId);
    }

    /**
     * 從房間中獲取使用者
     */
    public String getUserFromRoom(String userId) {
        return redisTemplate.opsForHash().get(EnumRedisKey.ROOM.getKey(), userId).toString();
    }

    /**
     * 設定處於遊戲中的使用者的對戰資訊
     */
    public void setUserMatchInfo(String userId, String userMatchInfo) {
        redisTemplate.opsForHash().put(EnumRedisKey.USER_MATCH_INFO.getKey(), userId, userMatchInfo);
    }

    /**
     * 移除處於遊戲中的使用者的對戰資訊
     */
    public void removeUserMatchInfo(String userId) {
        redisTemplate.opsForHash().delete(EnumRedisKey.USER_MATCH_INFO.getKey(), userId);
    }

    /**
     * 設定處於遊戲中的使用者的對戰資訊
     */
    public String getUserMatchInfo(String userId) {
        return redisTemplate.opsForHash().get(EnumRedisKey.USER_MATCH_INFO.getKey(), userId).toString();
    }

    /**
     * 設定使用者為遊戲結束狀態
     */
    public synchronized void setUserGameover(String userId) {
        removeUserOnlineStatus(userId);
        redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.GAME_OVER.getValue());
    }
}

4. 如何匹配使用者?

匹配使用者的思路之前已經提到過,為了不阻塞客戶端與服務端的 WebSocket 連線,建立一個執行緒專門用來匹配使用者,如果匹配成功就向客戶端推送訊息

使用者匹配對手時遵循這麼一個原則:使用者 A 找到使用者 B,由使用者 A 負責一切工作,既由使用者 A 完成建立匹配資料並儲存到快取的全部操作。值得注意的一點是,在匹配時要注意保證狀態的變化:

  • 當前使用者在匹配對手的同時,被其他使用者匹配,那麼當前使用者應當停止匹配操作
  • 當前使用者匹配到對手,但對手被其他使用者匹配了,那麼當前使用者應該重新尋找新的對手

使用者匹配對手的過程應該保證原子性,使用 Java 鎖來保證

/**
 * 使用者隨機匹配對手
 */
@SneakyThrows
private void matchUser(JSONObject jsonObject) {

    log.info("ChatWebsocket matchUser 使用者隨機匹配對手開始 message: {}, userId: {}", jsonObject.toJSONString(), userId);

    MessageReply<GameMatchInfo> messageReply = new MessageReply<>();
    ChatMessage<GameMatchInfo> result = new ChatMessage<>();
    result.setSender(userId);
    result.setType(MessageTypeEnum.MATCH_USER);

    lock.lock();
    try {
        // 設定使用者狀態為匹配中
        matchCacheUtil.setUserInMatch(userId);
        matchCond.signal();
    } finally {
        lock.unlock();
    }

    // 建立一個非同步執行緒任務,負責匹配其他同樣處於匹配狀態的其他使用者
    Thread matchThread = new Thread(() -> {
        boolean flag = true;
        String receiver = null;
        while (flag) {
            // 獲取除自己以外的其他待匹配使用者
            lock.lock();
            try {
                // 當前使用者不處於待匹配狀態
                if (matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.IN_GAME) == 0
                    || matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.GAME_OVER) == 0) {
                    log.info("ChatWebsocket matchUser 當前使用者 {} 已退出匹配", userId);
                    return;
                }
                // 當前使用者取消匹配狀態
                if (matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.IDLE) == 0) {
                    // 當前使用者取消匹配
                    messageReply.setCode(MessageCode.CANCEL_MATCH_ERROR.getCode());
                    messageReply.setDesc(MessageCode.CANCEL_MATCH_ERROR.getDesc());
                    Set<String> set = new HashSet<>();
                    set.add(userId);
                    result.setReceivers(set);
                    result.setType(MessageTypeEnum.CANCEL_MATCH);
                    messageReply.setChatMessage(result);
                    log.info("ChatWebsocket matchUser 當前使用者 {} 已退出匹配", userId);
                    sendMessageAll(messageReply);
                    return;
                }
                receiver = matchCacheUtil.getUserInMatchRandom(userId);
                if (receiver != null) {
                    // 對手不處於待匹配狀態
                    if (matchCacheUtil.getUserOnlineStatus(receiver).compareTo(StatusEnum.IN_MATCH) != 0) {
                        log.info("ChatWebsocket matchUser 當前使用者 {}, 匹配對手 {} 已退出匹配狀態", userId, receiver);
                    } else {
                        matchCacheUtil.setUserInGame(userId);
                        matchCacheUtil.setUserInGame(receiver);
                        matchCacheUtil.setUserInRoom(userId, receiver);
                        flag = false;
                    }
                } else {
                    // 如果當前沒有待匹配使用者,進入等待佇列
                    try {
                        log.info("ChatWebsocket matchUser 當前使用者 {} 無對手可匹配", userId);
                        matchCond.await();
                    } catch (InterruptedException e) {
                        log.error("ChatWebsocket matchUser 匹配執行緒 {} 發生異常: {}",
                                  Thread.currentThread().getName(), e.getMessage());
                    }
                }
            } finally {
                lock.unlock();
            }
        }

        UserMatchInfo senderInfo = new UserMatchInfo();
        UserMatchInfo receiverInfo = new UserMatchInfo();
        senderInfo.setUserId(userId);
        senderInfo.setScore(0);
        receiverInfo.setUserId(receiver);
        receiverInfo.setScore(0);

        matchCacheUtil.setUserMatchInfo(userId, JSON.toJSONString(senderInfo));
        matchCacheUtil.setUserMatchInfo(receiver, JSON.toJSONString(receiverInfo));

        GameMatchInfo gameMatchInfo = new GameMatchInfo();
        List<Question> questions = questionSev.getAllQuestion();
        gameMatchInfo.setQuestions(questions);
        gameMatchInfo.setSelfInfo(senderInfo);
        gameMatchInfo.setOpponentInfo(receiverInfo);

        messageReply.setCode(MessageCode.SUCCESS.getCode());
        messageReply.setDesc(MessageCode.SUCCESS.getDesc());

        result.setData(gameMatchInfo);
        Set<String> set = new HashSet<>();
        set.add(userId);
        result.setReceivers(set);
        result.setType(MessageTypeEnum.MATCH_USER);
        messageReply.setChatMessage(result);
        sendMessageAll(messageReply);

        gameMatchInfo.setSelfInfo(receiverInfo);
        gameMatchInfo.setOpponentInfo(senderInfo);

        result.setData(gameMatchInfo);
        set.clear();
        set.add(receiver);
        result.setReceivers(set);
        messageReply.setChatMessage(result);

        sendMessageAll(messageReply);

        log.info("ChatWebsocket matchUser 使用者隨機匹配對手結束 messageReply: {}", JSON.toJSONString(messageReply));

    }, CommonField.MATCH_TASK_NAME_PREFIX + userId);
    matchThread.start();
}

專案展示

專案程式碼如下:https://github.com/Yee-Q/match-project

跑起來後,使用 websocket-client 可以進行測試。在瀏覽器開啟,在控制檯檢視訊息。

在連線輸入框隨便輸入一個數字作為 userId,點選連線,此時客戶端就和服務端建立 WebSocket 連線了

點選加入使用者按鈕,使用者“進入匹配大廳”

點選隨機匹配按鈕,開始匹配,再取消匹配

按照之前的步驟再建立一個使用者連線,都點選隨機匹配按鈕,匹配成功,服務端返回響應資訊

使用者分數更新時,在輸入框輸入新的分數,比如 6,點選實時更新按鈕,對手將受到最新的分數訊息

當雙方都點選遊戲結束按鈕,則遊戲結束


相關文章