SpringBoot + WebSocket 实现答题对战匹配机制案例详解

2022-07-23,,,,

概要设计

类似竞技问答游戏:用户随机匹配一名对手,双方同时开始答题,直到双方都完成答题,对局结束。基本的逻辑就是这样,如果有其他需求,可以在其基础上进行扩展

明确了这一点,下面介绍开发思路。为每个用户拟定四种在线状态,分别是:待匹配、匹配中、游戏中、游戏结束。下面是流程图,用户的流程是被规则约束的,状态也随流程而变化

对流程再补充如下:

  • 用户进入匹配大厅(具体效果如何由客户端体现),将用户的状态设置为待匹配
  • 用户开始匹配,将用户的状态设置为匹配中,系统搜索其他同样处于匹配中的用户,在这个过程中,用户可以取消匹配,返回匹配大厅,此时用户状态重新设置为待匹配。匹配成功,保存匹配信息,将用户状态设置为游戏中
  • 根据已保存的匹配信息,用户可以获得对手的信息。答题是时,每次用户分数更新,也会向对手推送更新后的分数
  • 用户完成答题,则等待对手也完成答题。双方都完成答题,用户状态设置为游戏结束,展示对局结果

详细设计

针对概要设计提出的思路,我们需要思考以下几个问题:

  • 如何保持客户端与服务器的连接?
  • 如何设计客户端与服务端的消息交互?
  • 如何保存以及改变用户状态?
  • 如何匹配用户?

下面我们一个一个来解决

1. 如何保持用户与服务器的连接?

以往我们使用 http 请求服务器,并获取响应信息。然而 http 有个缺陷,就是通信只能由客户端发起,无法做到服务端主动向客户端推送信息。根据概要设计我们知道,服务端需要向客户端推送对手的实时分数,因此这里不适合使用 http,而选择了 websocket。websocket 最大的特点就是服务端可以主动向客户端推送信息,客户端也可以主动向服务端发送信息,是真正的双向平等对话

有关 springboot 集成 websocket 可参考这篇博客:

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,点击实时更新按钮,对手将受到最新的分数消息

当双方都点击游戏结束按钮,则游戏结束

以上就是springboot + websocket 实现答题对战匹配机制案例详解的详细内容,更多关于springboot websocket答题对战的资料请关注其它相关文章!

《SpringBoot + WebSocket 实现答题对战匹配机制案例详解.doc》

下载本文的Word格式文档,以方便收藏与打印。