1. 项目介绍

这个项目是一个网页版的在线五子棋对战游戏。

玩家完成注册登录并点击“开始匹配”之后,系统会根据玩家的积分,自动为玩家匹配同一分段的玩家进行实时对战与聊天

对局结束之后,根据对局结果修改玩家的积分。

该项目的核心是后端开发,前端主要是编写与玩家及服务器进行交互的逻辑,涉及到的知识并不复杂,即使对前端一窍不通也可尝试跟着这篇文章进行必要知识的学习(博主其实也不太会前端)。

项目演示当中的前端页面是由deepseek对页面样式进行了优化后的效果。

如果你是在是懒得做前端,也可以把博主源码中的前端页面拿来用,能结合下文给出的协议看懂就行。

1.1 项目演示

 目前你就可以试试访问:http://110.41.32.137:8888/

  1. 首页:用户在浏览器搜索框内输入服务器的ip地址与端口号之后,会跳转到游戏的首页。
  2. 登录页面:点击“开始游戏”,自动跳转到登录页面。
  3. 注册页面:如果还没有账号,可以跳转到注册页面进行注册。
  4. 游戏大厅页面:这里会显示玩家的信息,玩家可以在这个页面进行匹配。两位玩家匹配成功会自动跳转到游戏房间页面。
  5. 游戏房间页面:两位玩家可以在这里对弈并聊天,若有玩家中途退出,则另一方胜利。

注:这里的棋盘背景图是《大爱仙尊》中的商心慈,来自b站up主:Osot-酒保。

1.2 项目架构

1.3 项目源码

https://gitee.com/da-guan-mu-lao-sheng/linux-c/tree/master/online_Gomoku/source

2. 项目的前期工作

2.1 依赖的库

2.1.1 WebSocket++

WebSocket++ 是一个轻量级、跨平台的 C++ 库,核心用途是帮助开发者在应用中快速实现WebSocket 协议的客户端与服务器端通信,无需手动处理协议细节。

安装方式:

  1. 更新软件源:
    sudo apt update
  2. 安装 WebSocket++ 核心包(含头文件):
    sudo apt install libwebsocketpp-dev
  3. (可选)安装依赖(Boost/OpenSSL):
    # 安装 Boost(异步 IO 必需)
    sudo apt install libboost-system-dev libboost-thread-dev
    # 安装 OpenSSL(TLS 加密必需)
    sudo apt install libssl-dev

该项目要用到的相关头文件有两个:

#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>

在编译时可不带额外的链接选项。

2.1.1.1 引入该库的原因

WebSocket 协议和 HTTP 协议都是应用层的协议,也都是用于网页服务。那么,我们为什么需要 WebSocket 协议呢?

  • Http 协议:是 “请求 - 响应” 的短连接 / 半双工协议。即,服务端不能主动给客户端发送消息
  • WebSocket 协议:WebSocket 是 “双向实时” 的长连接 / 全双工协议。即,服务端也可以主动给客户端发送消息

在该项目中,有两处地方需要用到 WebSocket 协议的全双工特特性:

  1. 游戏大厅,客户端发起开始匹配的请求之后,服务端需要在匹配成功时主动通知客户端。
  2. 游戏房间当中,用户做出的任何成功的行为(落子,聊天,中途退出等)都需要同步到另一个用户的网页上,此时服务器就需要主动通知另一个用户的客户端。
2.1.1.2 如何建立 WebSocket 连接

显然,要建立连接,首先就要绕开连接进行通信。所以,在建立 WebSocket 连接之前,我们需要先建立 HTTP 连接,然后再将 HTTP 连接切换为WebSocket连接

因此,建立 WebSocket 连接的核心是 “客户端发起 HTTP 协议升级请求 + 服务器验证响应”。无论哪种场景,连接建立的核心步骤一致,可概括为 3 步:

  1. 客户端初始化连接:指定 WebSocket 服务地址(ws:// 未加密 / wss:// 加密),发起 HTTP 升级请求。核心的请求头部有:
    Upgrade: websocket	        核心:告知服务器 “请求升级到 WebSocket 协议”
    Connection: Upgrade	        辅助:明确表示 “这是一个协议升级请求”(HTTP/1.1 要求必须配合 Upgrade)
    Sec-WebSocket-Key	        随机字符串(客户端生成),用于服务器验证(避免误升级,非加密用途)
    Sec-WebSocket-Version: 13	声明 WebSocket 版本(主流是 13,对应 RFC 6455,服务器需兼容该版本)
  2. 服务器验证响应:校验请求合法性(方法、版本、升级头),返回 101 状态码确认协议切换。核心的响应头部有:
    HTTP/1.1 101 Switching Protocols	状态码:告知客户端 “协议切换成功”
    Upgrade: websocket	                确认升级到 WebSocket 协议(与请求头一致)
    Connection: Upgrade	                确认是协议升级(与请求头一致)
    Sec-WebSocket-Accept	            服务器对 Sec-WebSocket-Key 的编码结果(客户端需校验该值,防止伪造)
  3. 连接成功:TCP 连接转为 WebSocket 连接,双方可双向发送消息(触发 onopen 回调)。

切换的关键是:复用初始 TCP 连接,通过 HTTP 头告知双方 “要切换到 WebSocket 协议”,验证通过后即切换完成。

整个过程不建立新的 TCP 连接,仅改变连接的 “协议规则”,从而避免 HTTP 反复握手的开销,实现低延迟双向通信。

2.1.1.3 使用方式

我们在客户端使用的是JavaScript,我们可以使用浏览器原生 WebSocket API,其用法与WebSocketpp相似。

2.1.1.3.1 JavaScript
  1. 实例化客户端对象:
    var ws_hdl = new WebSocket("ws://" + 服务端ip地址 + 要请求的资源);
  2. 设置客户端的回调函数:
    // 建立WebSocket连接时的回调函数(略)
    function ws_onopen();
    // 关闭WebSocket连接时的回调函数(略)
    function ws_onclose();
    // 连接出现错误时的回调函数(略)
    function ws_onerror();
    // 收到消息时的回调函数(核心)
    function ws_onmessage(evt);
    
    // 注: evt是服务器发来的消息,也叫做事件
    
    ws_hdl.onopen = ws_onopen;
    ws_hdl.onclose = ws_onclose;
    ws_hdl.onerror = ws_onerror;
    ws_hdl.onmessage = ws_onmessage;
2.1.1.3.2 C++
  1. 实例化服务器对象并初始化:
    websocketpp::server<websocketpp::config::asio_tls_server> _wssrv;
    // 设置日志等级
    _wssrv.set_access_channels(websocketpp::log::alevel::none);
    // 初始化asio调度器
    _wssrv.init_asio();
    _wssrv.set_reuse_addr(true);
  2. 设置服务器的回调函数:
    // http请求回调处理函数
    void HttpCallback(websocketpp::connection_hdl hdl);
    // websocket握手成功回调处理函数,客户端发起连接后,该函数就会被调用
    void OpenCallback(websocketpp::connection_hdl hdl);
    // websocket连接关闭回调处理函数
    void CloseCallback(websocketpp::connection_hdl hdl);
    // websocket消息回调处理函数
    void MessageCallback(websocketpp::connection_hdl hdl, ws_server_t::message_ptr msg)
    
    // 注: hdl是客户端句柄,msg是客户端发来的消息
    
    _wssrv.set_http_handler(HttpCallback);
    _wssrv.set_open_handler(OpenCallback);
    _wssrv.set_close_handler(CloseCallback);
    _wssrv.set_message_handler(MessageCallback);
  3. 启动服务器:

    // 开始监听
    _wssrv.listen(port);
    // 开始接受新连接
    _wssrv.start_accept();
    // 启动服务器
    _wssrv.run();

2.1.2 jsoncpp

JsonCpp 是一款用于C++ 语言的开源 JSON 数据解析与生成库,能帮助开发者在 C++ 项目中便捷地处理 JSON 格式数据。

安装方式:

# 更新软件源
sudo apt update
# 安装 JsonCpp 开发包(包含头文件和库文件)
sudo apt install libjsoncpp-dev

该项目所需头文件:

#include <jsoncpp/json/json.h>

编译时需要带上链接选项:

-ljsoncpp
序列化与反序列化

客户端与服务器之间使用Json格式的字符串交换结构化数据。

jsoncpp的具体使用方法这里就不多介绍了,我们要使用该库实现两个接口。在解析数据时需要反序列化,在发送数据时需要序列化

class Util_Json
{
public:
    // 序列化
    static bool Serialize(const Json::Value &root, std::string &json_str)
    {
        Json::StreamWriterBuilder swb;
        // 启用UTF-8直接输出(不转义非ASCII字符)
        swb.settings_["emitUTF8"] = true;
        std::unique_ptr<Json::StreamWriter> sw(swb.newStreamWriter());

        std::stringstream ss;
        int ret = sw->write(root, &ss);
        if (ret != 0)
        {
            LOG(LogLevel::ERROR) << "序列化失败!";
            return false;
        }
        json_str = ss.str();
        return true;
    }
    // 反序列化
    static bool Deserialize(const std::string &json_str, Json::Value &root)
    {
        Json::CharReaderBuilder crb;
        std::unique_ptr<Json::CharReader> cr(crb.newCharReader());

        std::string err;
        bool ret = cr->parse(json_str.c_str(), json_str.c_str() + json_str.size(), &root, &err);
        if (!ret)
        {
            LOG(LogLevel::ERROR) << "反序列化失败: " << err;
            return false;
        }
        return true;
    }
};

2.1.3 mysqlclient

详见:https://the-old.blog.csdn.net/article/details/152927880?spm=1011.2415.3001.5331

封装MySqlClient
#ifndef __MY_MYSQL_H__
#define __MY_MYSQL_H__
#include <mysql/mysql.h>
#include <string>
#include <stdexcept>
#include <memory>
#include <mutex>
#include "Log.hpp"
using namespace LogModule;

// 自定义异常类
class MySqlClientException : public std::runtime_error
{
public:
    explicit MySqlClientException(const std::string &message)
        : std::runtime_error("MySqlClientException: " + message) {}
};

class MySqlResult
{
public:
    MySqlResult(MYSQL_RES *res = nullptr)
        : _res(res)
    {
    }

    size_t RowNum() const
    {
        if (!_res)
            throw MySqlClientException("MySqlResult::RowNum: 非法的结果集!");
        return mysql_num_rows(_res);
    }

    size_t FieldNum() const
    {
        if (!_res)
            throw MySqlClientException("MySqlResult::FieldNum: 非法的结果集!");
        return mysql_num_fields(_res);
    }

    MYSQL_ROW NextRow()
    {
        if (!_res)
            return nullptr; // 避免无效访问
        return mysql_fetch_row(_res);
    }

    MYSQL_FIELD *NextField()
    {
        if (!_res)
            return nullptr; // 避免无效访问
        return mysql_fetch_field(_res);
    }

    ~MySqlResult()
    {
        if (_res != nullptr)
        {
            mysql_free_result(_res);
            _res = nullptr;
        }
    }

    // 禁用拷贝构造和赋值运算符
    MySqlResult(const MySqlResult &) = delete;
    MySqlResult &operator=(const MySqlResult &) = delete;

    // 支持移动语义
    MySqlResult(MySqlResult &&other) noexcept : _res(other._res)
    {
        other._res = nullptr;
    }
    MySqlResult &operator=(MySqlResult &&other) noexcept
    {
        if (this != &other)
        {
            _res = other._res;
            other._res = nullptr;
        }
        return *this;
    }

private:
    MYSQL_RES *_res = nullptr;
};

class MySqlClient
{
public:
    MySqlClient(const std::string &host,
                const std::string &user,
                const std::string &passwd,
                const std::string &db,
                unsigned int port = 3306)
        : _mysql(mysql_init(nullptr))
    {
        if (!_mysql)
        {
            throw MySqlClientException("mysql句柄初始化失败!");
        }

        // 处理空字符串参数
        const char *host_ptr = host.empty() ? nullptr : host.c_str();
        const char *db_ptr = db.empty() ? nullptr : db.c_str();

        if (!mysql_real_connect(_mysql, host_ptr, user.c_str(), passwd.c_str(),
                                db_ptr, port, nullptr, 0))
        {
            std::string err = mysql_error(_mysql);
            mysql_close(_mysql); // 连接失败时释放已初始化的_mysql
            _mysql = nullptr;
            throw MySqlClientException("连接到mysql服务器失败: " + err);
        }

        // 使用utf8mb4支持完整UTF-8
        if (mysql_set_character_set(_mysql, "utf8mb4") != 0)
        {
            std::string err = mysql_error(_mysql);
            mysql_close(_mysql);
            _mysql = nullptr;
            throw MySqlClientException("设置字符集失败: " + err);
        }
    }

    // 执行SQL,返回:<影响行数(或结果集行数), 结果集(若有)>
    std::pair<size_t, std::shared_ptr<MySqlResult>> Query(const std::string &sql)
    {
        if (!_mysql)
            throw MySqlClientException("Query: 非法的mysql客户端句柄!");

        // 查询的过程上锁,避免结果被其他线程的查询结果覆盖
        std::unique_lock<std::mutex> lockguard(_mtx);
        // 执行SQL,检查是否失败
        int query_ret = mysql_query(_mysql, sql.c_str());
        if (query_ret != 0)
        {
            LOG(LogLevel::ERROR) << "Query: [" << sql << "]查询失败! " << mysql_error(_mysql);
            return std::make_pair<size_t, std::shared_ptr<MySqlResult>>(0, nullptr);
        }

        std::pair<size_t, std::shared_ptr<MySqlResult>> ret;

        // 判断是否有结果集(如SELECT、SHOW等)
        if (mysql_field_count(_mysql) != 0)
        {
            MYSQL_RES *res = mysql_store_result(_mysql);
            if (!res)
            {
                // 若预期有结果集但获取失败(如内存不足)
                throw MySqlClientException("获取结果集失败: " + std::string(mysql_error(_mysql)));
            }
            ret.second = std::make_shared<MySqlResult>(res);
            ret.first = ret.second->RowNum(); // 结果集行数
        }
        else
        {
            // 无结果集,返回影响行数
            ret.first = static_cast<size_t>(mysql_affected_rows(_mysql));
            ret.second = nullptr;
        }
        return ret;
    }

    ~MySqlClient()
    {
        if (_mysql != nullptr) // 仅在有效时关闭
        {
            mysql_close(_mysql);
            _mysql = nullptr;
        }
    }

    // 禁用拷贝构造和赋值运算符
    MySqlClient(const MySqlClient &) = delete;
    MySqlClient &operator=(const MySqlClient &) = delete;

    // 支持移动语义(可选)
    MySqlClient(MySqlClient &&other) noexcept : _mysql(other._mysql)
    {
        other._mysql = nullptr;
    }
    MySqlClient &operator=(MySqlClient &&other) noexcept
    {
        if (this != &other)
        {
            _mysql = other._mysql;
            other._mysql = nullptr;
        }
        return *this;
    }

private:
    MYSQL *_mysql = nullptr;
    std::mutex _mtx;
};

#endif

2.1.4 sodium

Sodium 是一款 现代、安全、易用的加密库,源于著名的 NaCl(Networking and Cryptography library),核心目标是让开发者无需深入密码学细节,就能快速实现安全可靠的加密功能。

它封装了一系列经过密码学验证的强算法,避免了传统加密库(如 OpenSSL)API 复杂、易误用(如密钥长度错误、IV 生成不当)的问题,被誉为 “加密领域的瑞士军刀”。

安装方式:

# 更新软件源
sudo apt update
# 安装 Sodium 开发包(含头文件、静态库、动态库)
sudo apt install libsodium-dev

所需头文件:

#include <sodium.h>

编译时需要带上链接选项:

-lsodium

由于现在MySQL已经不支持PASSWD了,所以我们使用该库对用户的密码进行哈希与验证。

封装Util_Hash
class Util_Hash
{
public:
    static bool Init()
    {
        if (sodium_init() < 0)
        {
            LOG(LogLevel::ERROR) << "libsodium初始化失败!";
            return false;
        }
        return true;
    }

    // 生成密码哈希(存储到数据库)
    static std::string Password(const std::string &password)
    {
        // 哈希结果缓冲区(包含盐值和哈希值,可直接存储)
        std::vector<char> hash(crypto_pwhash_STRBYTES);

        // 加密参数(libsodium推荐的安全值,可根据性能调整)
        const unsigned long long opslimit = crypto_pwhash_OPSLIMIT_MODERATE; // 计算次数
        const size_t memlimit = crypto_pwhash_MEMLIMIT_MODERATE;             // 内存限制

        // 生成哈希(自动生成随机盐值,并存入hash中)
        if (crypto_pwhash_str(
                hash.data(),
                password.c_str(),
                password.size(),
                opslimit,
                memlimit) != 0)
        {
            throw std::runtime_error("密码哈希失败(可能内存不足)");
        }

        return std::string(hash.data());
    }

    // 验证密码(对比用户输入与存储的哈希)
    static bool Verify(const std::string &password, const std::string &stored_hash)
    {
        // 验证哈希是否匹配
        return crypto_pwhash_str_verify(
                   stored_hash.c_str(),
                   password.c_str(),
                   password.size()) == 0;
    }
};

2.2 其他工具类

除了Util_Json和Util_Hash以外,我们还需要如下两个工具类:

  • Util_String:包含一个方法Split,用于将字符串按照指定的分隔符进行切分,在处理Cookie字段时用到。
    class Util_String
    {
    public:
        // 字符串分割
        static size_t Split(const std::string &str, const std::string &sep, std::vector<std::string> &res)
        {
            res.clear(); // 清空原有内容,确保结果仅包含本次分割的元素
            if (sep.empty())
            {
                return 0;
            }
    
            const size_t sep_len = sep.size();
            const size_t str_len = str.size();
            size_t index = 0;
            while (index < str_len)
            {
                size_t pos = str.find(sep, index);
                // 未找到分隔符,截取剩余部分
                if (pos == std::string::npos)
                {
                    res.emplace_back(str.substr(index));
                    break;
                }
                // 当前位置就是分隔符,跳过
                if (pos == index)
                {
                    index += sep_len;
                    continue;
                }
                // 截取[index, pos)区间的子串
                res.emplace_back(str.substr(index, pos - index));
                index = pos + sep_len;
            }
            return res.size();
        }
    };

2.3 前端的简单介绍

2.3.1 前端开发的核心技术(3 大基础)

这是前端开发的 “基石”,所有复杂界面和交互都基于这三者:

  • HTML(结构):相当于网页的 “骨架”,用来搭建页面的基础结构,比如标题、按钮、图片、文字段落等,决定页面有哪些内容。
  • CSS(样式):相当于网页的 “皮肤”,负责美化页面,比如设置颜色、字体、布局(比如左右分栏)、动画效果(比如按钮 hover 时变色),决定页面好不好看。
  • JavaScript(交互):相当于网页的 “大脑”,负责实现页面的动态交互,比如点击按钮弹出弹窗、下拉加载更多内容、表单提交验证,决定页面能不能 “动起来”。
<!-- HTML:定义按钮结构 -->
<button id="myBtn">点击我</button>

<!-- CSS:美化按钮样式, 在style标签中可嵌入css代码 -->
<style>
<!-- #ByBtn表示对id为myBtn的元素进行修饰 -->
#myBtn {
  background: blue;
  color: white;
  padding: 8px 16px;
  border: none;
}
</style>

<!-- JavaScript:实现按钮点击交互, 在script标签中可嵌入JavaScript代码 -->
<script>
// 设置点击事件, 等号后面是一个lambda表达式, 也可用已定义的函数
document.getElementById("myBtn").onclick = function() {
  alert("按钮被点击了!");
};
</script>

能看懂,在不符合需求时知道怎么改就行。

2.3.2 前端如何发起HTTP请求

在该项目当中,我们主要使用AJAX来帮助我们发起HTTP请求并处理响应。

AJAX(Asynchronous JavaScript and XML,异步 JavaScript 和 XML)是一种前端技术方案,核心作用是:在不刷新整个网页的前提下,与服务器异步交换数据并更新页面部分内容

简单说:传统网页要更新数据必须刷新整个页面(比如提交表单后跳转),而 AJAX 能实现 “局部刷新”(比如点击 “加载更多” 只加载新内容、搜索框实时联想提示),大幅提升用户体验。

使用方式也很简单,在某个按钮的点击事件当中内嵌上如下结构即可:

function onclick() {
    // ...
    // 使用ajax进行HTTP请求
    $.ajax({
        url: 要请求的资源,
        type: HTTP方法,
        data: 正文部分,
        success: function (res) {
            // 请求成功时的处理(响应状态码==ok)
            // ...
        },
        error: function (xhr) {
            // 请求失败时的处理(响应状态码!=ok)
            // ...
        }
    })
    // ...
}

2.4 数据库用户表的设计

2.4.1 用户表

-- 创建数据库
DROP DATABASE IF EXISTS online_Gomoku;
CREATE DATABASE IF NOT EXISTS online_Gomoku;
USE online_Gomoku;

-- 创建用户表
CREATE TABLE IF NOT EXISTS user(
    id INT PRIMARY KEY AUTO_INCREMENT,    // 用户id
    name VARCHAR(21) UNIQUE KEY NOT NULL, // 用户名
    passwd VARCHAR(128) NOT NULL,         // 密码
    points INT UNSIGNED,                  // 积分
    total_count INT UNSIGNED,             // 总场次计数
    win_count INT UNSIGNED                // 胜场计数
);

2.4.2 对用户表进行操作

我们设计一个Table_User类来帮助我们进行表查询:

#ifndef __MY_DATABASE_H__
#define __MY_DATABASE_H__
#include "Log.hpp"
#include "MySqlClient.hpp"
#include <jsoncpp/json/json.h>
#include <cassert>

using namespace LogModule;

class Table_User
{
private:
    template<class ...ARGS>
    std::string MakeSql(const std::string format, ARGS... args)
    {
        char sql[4096];
        sprintf(sql, format.c_str(), args...);
        return sql;
    }
public:
    Table_User(const std::string &host, const std::string &user, const std::string &passwd, const std::string &db)
        : _mysql(host, user, passwd, db)
    {
        // 初始化sodium库
        assert(Util_Hash::Init());
    }

    // 将用户信息注册到用户表
    bool Register(const std::string& name, const std::string& passwd);

    // 验证用户登录信息
    bool LoginCheck(Json::Value& login_info);

    // 根据用户名查找用户信息
    bool SelectById(const id_t id, Json::Value& user);

    // 根据用户id查找用户信息
    bool SelectByName(const std::string& name, Json::Value& user);

    // 计数胜利: 总场次++ 胜利场次++ 积分+30
    bool CountWin(const id_t id);

    // 计数失败
    bool CountLose(const id_t id);

    ~Table_User()
    {}
private:
    MySqlClient _mysql;
};

#endif

这些函数的内部逻辑都相似,就是定义一个查询语句的模板,然后使用MakeSql将参数带入并调用MySqlClient::Query,例如SelectById的实现:

// 根据用户名查找用户信息
bool SelectById(const id_t id, Json::Value &user)
{
    static const std::string select_by_id = "SELECT id, name, passwd, points, total_count, win_count FROM user WHERE id=%u;";
    std::string sql = MakeSql(select_by_id, id);
    auto ret = _mysql.Query(sql);
    if (ret.first == 0)
    {
        LOG(LogLevel::ERROR) << "Table_User::SelectById: 用户id[%u]不存在!", id;
        return false;
    }

    MYSQL_ROW row = ret.second->NextRow();
    user["id"] = std::stoi(row[0]);
    user["name"] = row[1];
    user["passwd"] = row[2];
    user["points"] = std::stoi(row[3]);
    user["total_count"] = std::stoi(row[4]);
    user["win_count"] = std::stoi(row[5]);
    return true;
}

2.5 协议的定义

2.5.1 HTTP

客户端使用AJAX发送请求,直接通过状态码判断是否成功,result存放执行结果的提示。

后端通过HTTP回调函数处理客户端发来的消息,处理客户端的请求并设置状态码等。

注意:为了好看,下面正文部分Json串中的字段名都没有带引号,实际的Json串中字段名是有引号的。

2.5.1.1 注册

(1)客户端请求

url: "/register"
type: "POST"
data: 
{
    name: 用户名,
    passwd: 密码
}

(2)服务端响应

status: ok
Content-Type: "application/json"
data: 
{
    result: "注册成功"
}
2.5.1.2 登录

(1)客户端请求

url: "/login"
type: "POST"
data: 
{
    name: 用户名,
    passwd: 密码
}

(2)服务器响应

status: ok
Content-Type: "application/json"
Set-Cookie: "SSID=用户会话id"
data: 
{
    result: "登录成功"
}
2.5.1.3 获取用户信息

(1)客户端请求

url: "/info"
type: "GET"

这里不需要其他信息是因为用户已经登录成功,在发送到服务器的请求当中都会带上设置好的Cookie,而Cookie中的信息是用户的会话id。

根据这个id,我们就可以在会话管理模块找到用户的会话与用户id。

(2)服务器响应

status: ok
Content-Type: "application/json"
data: 
{
    // user表的全部字段
    // ...
}
2.5.1.4 失败的响应
status: 具体的状态码
Content-Type: "application/json"
data:
{
    result: 具体的错误原因
}

2.5.2 WebSocket

与 HTTP 协议不同,WebSocket在连接建立之后,报头采用二进制的极简报头。并且,我们无需关心或设置报头(API接口会自动设置),只需要设置正文部分即可

但是,如此一来会出现两个问题:

  1. 请求报头中不包含URI,需要在正文部分给出该请求的目的;
  2. 响应报头中不包含状态码,我们无法据此直接判断执行结果;
  3. 请求与响应不是一一对应的关系了,客户端不知道某个响应到底对应哪个/什么请求(所有响应的处理都集中在消息回调函数中处理)。

为了解决这一问题,我们在响应中新增一个字段"op_type",表示这是什么操作对应的请求/响应;当请求失败时,我们就将这个字段设置为"error",方便客户端统一处理。

除此之外,WebSocket还有一个特点,那就是当页面关闭或切换时当前页面的WebSocket连接就会关闭(因为WebSocket连接在代码层面上是一个页面内的对象)。

所以,我们每次进入游戏大厅或游戏房间时,都需要重新建立WebSocket连接。

2.5.2.1 建立游戏大厅WebSocket连接

(1)客户端请求

// 客户端实例化WebSocket对象时,自动向服务器发起切换协议的请求
ws_hdl = new WebSocket(ws_url);

(2)服务器响应

{
    op_type: "hall_ready",
    result: "游戏大厅长连接建立成功!"
}
2.5.2.2 建立游戏房间WebSocket连接

(1)客户端请求

ws_hdl = new WebSocket(ws_url);

(2)服务器响应

{
    op_type: "room_ready",
    room_id: 房间id,
    uid: 用户id,
    white_id: 白棋棋手id,
    black_id: 黑棋棋手id,
    white_name: 白棋棋手用户名,
    black_name: 黑棋棋手用户名,
    white_points: 白棋棋手积分,
    black_points: 黑棋棋手积分
}
2.5.2.3 游戏大厅
2.5.2.3.1 开始匹配

(1)客户端请求

{
    op_type: "match_start"
}

(2)服务器响应

{
    op_type: "match_start",
    result: "已开始匹配"
}
2.5.2.3.2 取消匹配

(1)客户端请求

{
    op_type: "match_stop"
}

(2)服务器响应

{
    op_type: "match_stop",
    result: "已取消匹配"
}
2.5.2.3.3 匹配成功

(1)客户端请求:无需请求。

(2)服务端响应

{
    op_type: "match_success"
}
2.5.2.4 游戏房间

游戏房间内的消息发送需要同步到两个客户端(两个玩家),所以请求与响应当中都包含用户id(uid),以确保客户端在收到消息时,能判断该操作是否是自己做的。

房间id(room_id)主要是一个验证作用,确保消息没有被错误转发。

2.5.2.4.1 聊天

(1)客户端请求

{
    op_type: "chat",
    room_id: 房间id
    uid: 用户id,
    message: 要发送的消息
}

(2)服务器响应:将请求正文原封不动地发送给两个客户端。

2.5.2.4.2 落子

(1)客户端请求

{
    op_type: "chess",
    room_id: 房间id,
    uid: 用户id,
    row: 行,
    col: 列
};

(2)服务器响应:将请求正文原封不动地发送给两个客户端。

2.5.2.4.3 游戏结束

(1)客户端请求:无需请求。

(2)服务器响应

{
    op_type: "game_over",
    winner: 赢家id,
    result: 胜利原因
}

2.5.3 Protocal.hpp

由于HTTP响应的很多信息都包含在响应头部,所以就不额外封装了,直接在HTTP回调函数中处理。

但是WebSocket响应的所有信息都是在Json串中定义的,因此我们可以进行一下简单封装:

class Response
{
public:
    // 落子请求的响应
    static Json::Value ChessSuccess(const Json::Value &req)
    {
        // 请求中的信息都保留,包括操作类型"chess",下棋位置,用户id等
        Json::Value resq = req;
        return resq;
    }

    // 游戏结束的响应
    static Json::Value GameOver(id_t winner, const std::string &result)
    {
        Json::Value resq;
        resq["op_type"] = "game_over";
        resq["winner"] = winner;
        resq["result"] = result;
        return resq;
    }

    // 聊天消息响应
    static Json::Value ChatSuccess(const Json::Value &req)
    {
        // 包含op_type,uid,message
        Json::Value resq = req;
        return resq;
    }
    
    // 一般WebSocket请求的响应消息
    static Json::Value WebSocket(const std::string &op_type, const std::string &result)
    {
        Json::Value resq;
        resq["op_type"] = op_type;
        resq["result"] = result;
        return resq;
    }
    
    // 房间准备好的消息
    static Json::Value RoomReady(id_t uid, id_t room_id, id_t white_id, id_t black_id, Table_User* tb)
    {
        Json::Value resq;
        resq["op_type"] = "room_ready";
        resq["room_id"] = room_id;
        resq["uid"] = uid;
        resq["white_id"] = white_id;
        resq["black_id"] = black_id;
        Json::Value tmp;
        tb->SelectById(white_id, tmp);
        resq["white_name"] = tmp["name"];
        resq["white_points"] = tmp["points"];
        tb->SelectById(black_id, tmp);
        resq["black_name"] = tmp["name"];
        resq["black_points"] = tmp["points"];
        return resq;
    }

    // 匹配成功的消息
    static Json::Value MatchSuccess()
    {
        Json::Value resq;
        resq["op_type"] = "match_success";
        return resq;
    }
};

3. 项目核心模块

3.1 在线用户管理

该模块定义了两个映射关系,分别存放游戏大厅/房间中玩家的连接:

std::unordered_map<id_t, ws_server_t::connection_ptr> _game_hall; // 游戏大厅
std::unordered_map<id_t, ws_server_t::connection_ptr> _game_room; // 游戏房间

该模块的主要作用有两个:

  1. 判断玩家状态:检查玩家是否在游戏大厅/房间中
  2. 获取指定用户的连接:在房间管理模块,很多消息需要同步给房间内的所有人,这时就需要通过该模块来查找连接了。
class OnlineManager
{
public:
    // 进入游戏大厅/房间
    void EnterGameHall(id_t uid, ws_server_t::connection_ptr conn);
    void EnterGameRoom(id_t uid, ws_server_t::connection_ptr conn);
    
    // 退出游戏大厅/房间
    void ExitGameHall(id_t uid);
    void ExitGameRoom(id_t uid);
    
    // 判断是否在大厅/房间内
    bool IsInGameHall(id_t uid);
    bool IsInGameRoom(id_t uid);
    
    // 从游戏大厅/房间获取WebSocket连接
    ws_server_t::connection_ptr GetConFromHall(id_t uid);
    ws_server_t::connection_ptr GetConFromRoom(id_t uid);
    
private:
    std::unordered_map<id_t, ws_server_t::connection_ptr> _game_hall; // 游戏大厅
    std::unordered_map<id_t, ws_server_t::connection_ptr> _game_room; // 游戏房间
    std::mutex _mtx;
};

3.2 会话管理

为了识别用户的登录状态,我们需要设计一个会话管理模块。

当用户登录成功之后,我们需要为用户创建一个会话,并将会话id作为Cookie返回给客户端。

会话当中包含了用户的uid与一个websocketpp的计时器,用于定时销毁会话

class Session
{
public:
    // // Session状态
    // enum SessionStatus
    // {
    //     LoggedOut = 0,
    //     LoggedIn = 1
    // };
    // 网站的所有操作都是在登录的前提下完成的,所以决定把状态去掉
    Session(id_t ssid, id_t uid) : _ssid(ssid), _uid(uid)
    {
        LOG(LogLevel::INFO) << "用户[uid=" << _uid << "]的会话创建成功!";
    }
    void SetUser(id_t uid) { _uid = uid; }
    id_t GetUser() { return _uid; }
    id_t GetId() { return _ssid; }
    void SetTimer(const ws_server_t::timer_ptr &tp) { _tp = tp; }
    ws_server_t::timer_ptr &GetTimer() { return _tp; }
    ~Session()
    {
        LOG(LogLevel::INFO) << "用户[uid=" << _uid << "]的会话即将销毁!";
    }

private:
    id_t _ssid; // 会话id
    id_t _uid;  // 该会话所属用户的uid
    ws_server_t::timer_ptr _tp;
};

我们可以通过websocketpp::server<websocketpp::config::asio_tls_server>对象(也就是服务器句柄)的set_timer方法获取某个设置了定时任务的定时器。

当定时器的计时归零时,定时任务就会触发。我们在会话管理当中,可以设置定时删除某个会话的定时器,并交由对应的会话进行保管。会话在什么时候该删除呢?在未建立WebSocket连接的情况下长时间无操作,就需要删除会话。

也就是说,在用户离开游戏大厅/房间之后,到再次进入游戏大厅/房间之前,会话需要设置定时删除。

class SessionManager
{
public:
    SessionManager(ws_server_t *server)
        : _server(server)
    {
        LOG(LogLevel::INFO) << "会话管理器创建成功!";
    }
    // 新建会话
    SessionPtr CreateSession(id_t uid);

    // 添加会话
    void AppendSession(const SessionPtr &ssp);

    // 获取会话
    SessionPtr GetSession(id_t ssid);

    static const int TIMEOUT = 30000; // 默认删除时间
    static const int FOREVER = -1; // 永不删除
    // 为某个会话设置定时删除
    void SetSessionExpireTime(id_t ssid, int ms);

    // 删除某个会话
    void RemoveSession(id_t ssid);

    ~SessionManager()
    {
        LOG(LogLevel::INFO) << "会话管理器即将销毁!";
    }

private:
    id_t _next_ssid = 0;
    std::mutex _mtx;
    std::unordered_map<id_t, SessionPtr> _ssid_sp;
    ws_server_t *_server;
};

设置定时器使用的是服务器句柄的set_timer方法,但是这个接口不能实现设置永久不触发(因为这没有任何意义)

要实现永久不触发的办法就是不设置定时器,但是已经设置了的话该怎么办呢?

这种情况下,只能调用定时器的cancel方法来取消定时器。但是这个接口实在是太老实了,真的只取消了定时器,而任务没被取消。这就导致定时任务会被立即触发

所以,我们在取消掉原本的定时器之后,还需要将被删除的会话重新添加到会话集合(_ssid_sp)中。

这里,将会话添加回去的方法也有考究。定时任务和当前程序不是一个执行流的,这就意味着定时任务是立即触发,而不是立即被执行。

假如在调用cancel之后立即将会话添加回去的话,添加会话时其可能还未被删除。

所以,正确的做法就是在调用cancel之后,设置一个用于添加会话的立即执行的定时器

这样,在被触发任务队列当中,添加会话的任务就排在删除会话的任务之后,一定后执行了。

static const int TIMEOUT = 30000;
    static const int FOREVER = -1;
    void SetSessionExpireTime(id_t ssid, int ms)
    {
        // 依赖于websocketpp的定时器来管理会话的生命周期
        // 登录之后,在SESSION_TIMEOUT时间内无通信则删除会话
        // 进入游戏大厅或房间之后,会话永久不过期
        // 退出游戏大厅或房间之后,会话会话需要设置过期时间
        SessionPtr ssp = GetSession(ssid);
        if (ssp.get() == nullptr)
        {
            return;
        }

        ws_server_t::timer_ptr tp = ssp->GetTimer();
        if (tp.get() == nullptr && ms == FOREVER)
        {
            // 1.为永久会话设置永久,无意义,直接返回
            return;
        }
        else if (tp.get() == nullptr && ms != FOREVER)
        {
            // 2.为永久会话设置指定时间后删除
            ws_server_t::timer_ptr tp = _server->set_timer(ms, std::bind(&SessionManager::RemoveSession, this, ssid));
            ssp->SetTimer(tp);
        }
        else if (tp.get() != nullptr && ms == FOREVER)
        {
            // 3.为临时会话设置永久
            // 该函数实际上是立即触发回调
            tp->cancel();
            // 将目标会话的定时器指针置空
            ssp->SetTimer(ws_server_t::timer_ptr());
            // tp->cancel()立即触发但不立即执行,直接将ssp添加回去可能被后执行的回调函数删除
            // 所以应该为添加ssp的任务设置一个立即触发的定时器,使其加入到回调函数的执行队列中,确保在删除后再添加
            _server->set_timer(0, std::bind(&SessionManager::AppendSession, this, ssp));
        }
        else
        {
            // 4.为临时会话重新设置定时器
            // 清理旧定时器(同3)
            tp->cancel();
            _server->set_timer(0, std::bind(&SessionManager::AppendSession, this, ssp));

            // 设置新定时器(同2)
            ws_server_t::timer_ptr tp = _server->set_timer(ms, std::bind(&SessionManager::RemoveSession, this, ssid));
            ssp->SetTimer(tp);
        }
    }

 3.3 匹配管理

两个玩家如何完成匹配呢?很简单,设计一个匹配队列:

  1. 当玩家发起开始匹配的请求时,就将玩家加入到匹配队列当中;
  2. 当队列当中的玩家数量>=2时,就取出两个玩家,为他们创建房间并告诉他们匹配成功。
// 匹配队列
template <typename T>
class MatchQueue
{
public:
    size_t Size()
    {
        std::unique_lock<std::mutex> lockguard(_mtx);
        return _list.size();
    }
    bool Empty()
    {
        std::unique_lock<std::mutex> lockguard(_mtx);
        return _list.empty();
    }
    void Push(const T &data)
    {
        std::unique_lock<std::mutex> lockguard(_mtx);
        _list.emplace_back(data);
        if (_list.size() >= 2)
        {
            _cond.notify_all();
        }
    }
    void Pop(T &data1, T &data2)
    {
        std::unique_lock<std::mutex> lockguard(_mtx);
        while (_list.size() < 2)
        {
            _cond.wait(lockguard);
        }
        data1 = _list.front();
        _list.pop_front();
        data2 = _list.front();
        _list.pop_front();
    }
    void Remove(const T &data)
    {
        std::unique_lock<std::mutex> lockguard(_mtx);
        _list.remove(data);
    }

private:
    std::mutex _mtx;
    std::list<T> _list; // 不直接使用queue,因为涉及到对队列中间元素的删除
    // 一个线程阻塞等待
    // 另一个线程向MatchQueue中放入数据,当元素个数>=2时唤醒第一个线程取出数据完成匹配
    std::condition_variable _cond; // 用于阻塞等待队列中的元素个数>=2
};

我们可以额外开一个线程来专门完成匹配这件事,以减小WebSocket服务器的压力。

当然,我们也可以设计多个匹配队列来处理不同分段的玩家匹配,并为每一个匹配队列专门设置一个线程

template <size_t N> // 匹配队列的个数
class Matcher
{
private:
    // 线程入口函数
    void ThreadEntry(uint32_t i)
    {
        while (true)
        {
            // 从匹配队列取出两个玩家
            id_t player1, player2;
            _queues[i].Pop(player1, player2);

            // 检查玩家是否掉线
            ws_server_t::connection_ptr conn1 = _om->GetConFromHall(player1);
            if (conn1.get() == nullptr)
            {
                Add(player2);
                continue;
            }
            ws_server_t::connection_ptr conn2 = _om->GetConFromHall(player2);
            if (conn2.get() == nullptr)
            {
                Add(player1);
                continue;
            }

            // 为两个玩家创建游戏房间
            RoomPtr room = _rm->CreateRoom(player1, player2);
            // LOG(LogLevel::DEBUG) << "为两位玩家创建房间: " << player1 << " " << player2;
            if (room.get() == nullptr)
            {
                Add(player1);
                Add(player2);
                continue;
            }

            // 匹配成功,回复玩家
            Json::Value resp = Response::MatchSuccess();
            std::string body;
            Util_Json::Serialize(resp, body);
            conn1->send(body);
            conn2->send(body);
        }
    }

public:
    Matcher(RoomManager *rm, OnlineManager *om, Table_User *tb)
        : _queues(N), _rm(rm), _om(om), _tb(tb)
    {
        _threads.reserve(N);
        for (int i = 0; i < N; i++)
        {
            _threads.push_back(std::thread(&Matcher::ThreadEntry, this, i));
        }
        LOG(LogLevel::INFO) << "匹配管理模块创建成功";
    }
    // 将用户添加到匹配队列
    bool Add(id_t uid)
    {
        Json::Value user;
        bool ret = _tb->SelectById(uid, user);
        if (!ret)
        {
            LOG(LogLevel::ERROR) << "玩家[id=" << uid << "]信息查询失败";
            return false;
        }

        // 计算玩家属于哪一个匹配队列
        uint32_t points = std::max(base_points, user["points"].asUInt());
        uint32_t pos = std::min(N - 1, (size_t)(points - base_points) / gap_points);
        _queues[pos].Push(uid);
        return true;
    }
    // 将用户移出匹配队列
    bool Delete(id_t uid)
    {
        Json::Value user;
        bool ret = _tb->SelectById(uid, user);
        if (!ret)
        {
            LOG(LogLevel::ERROR) << "玩家[id=" << uid << "]信息查询失败";
            return false;
        }

        uint32_t points = std::max(base_points, user["points"].asUInt());
        uint32_t pos = std::min(N - 1, (size_t)(points - base_points) / gap_points);
        _queues[pos].Remove(uid);
        return true;
    }
    ~Matcher()
    {
        for (auto &thread : _threads)
        {
            thread.join();
        }
        LOG(LogLevel::INFO) << "匹配管理模块即将销毁";
    }

private:
    // 不同段位的匹配队列
    std::vector<MatchQueue<id_t>> _queues;
    // 处理不同段位匹配的线程
    std::vector<std::thread> _threads;
    // 基准分数
    static const uint32_t base_points = 1200;
    // 段位间隔分数
    static const uint32_t gap_points = 600;
    RoomManager *_rm;
    OnlineManager *_om;
    Table_User *_tb;
};

template <size_t N>
const uint32_t Matcher<N>::base_points;

template <size_t N>
const uint32_t Matcher<N>::gap_points;

3.4 房间管理

3.4.1 棋盘状态与玩家id

显然,我们会用二维数组表示棋盘,但是棋盘的内容呢?我们可以定一个单格状态枚举量:

// 棋盘单格状态
enum BoardPattern
{
    SPACE = 0,
    WHITE = 1,
    BLACK = 2
};

我希望能将白棋与白棋棋手绑定,黑棋与黑棋棋手绑定,于是我们可以使用一个包含三个元素的数组来存储两位玩家:

std::vector<id_t> _player(3); // 1:白棋用户id - 2:黑棋用户id

为什么不把WHITEBLACK设置为0和1呢?这样数组不就可以少一个元素了吗?

这是因为我觉得0和!0是有质变的区别的(false/true),将SPACE设置为0而另外两个设置为!0也许会有什么好处,于是我就这样做了。

3.4.2 请求处理

针对于房间的请求,我们全部放到房间内来处理。首先需要开放给调用者一个同一的函数接口,然后再进行内部路由:

// 处理请求
void RequestHandler(const Json::Value &req)
{
    // 检查请求房间号与当前房间号是否匹配
    if (req["room_id"].asUInt() != _id)
    {
        return Broadcast(Response::WebSocket("error", "房间号错误"));
    }

    std::string op_type = req["op_type"].asString();
    std::string body;
    if (op_type == "chess")
    {
        Json::Value resq = Chess(req);
        // 判断落子之后是否有玩家胜出
        if (resq["op_type"].asString() == "chess")
        {
            Broadcast(resq);
            BoardPattern winner;
            // 检查是否有玩家胜出
            if (winner = CheckWin(req["row"].asUInt(), req["col"].asUInt()))
            {
                // 有玩家胜出,进行结算并追加一条游戏结束的消息
                GameSettlement(winner);
                Broadcast(Response::GameOver(_player[winner], "五子连珠"));
            }
        }
    }
    else if (op_type == "chat")
    {
        Broadcast(Chat(req));
    }
    else if (op_type == "exit")
    {
        id_t uid = req["uid"].asUInt();
        Broadcast(Exit(uid));
    }
    else
    {
        // 未知操作类型,什么也不做
    }
}

3.4.3 Room的查找

在实现RoomManager,我们定义了如下两个映射关系:

std::unordered_map<id_t, RoomPtr> _rid_room; // 房间id - 房间指针
std::unordered_map<id_t, id_t> _uid_rid;     // 用户id - 房间id

一个是根据房间id查找房间指针,一个是根据用户id查找房间id。这样一来,我们既可以通过房间id查找房间,也可以通过用户id查找房间。

那么,为什么不直接设计一个用户id到房间指针的映射呢?

这是因为,我们这里的房间指针本质上是智能指针

using RoomPtr = std::shared_ptr<Room>;

这样设计,就使得每个房间只有一个引用,我们要销毁掉一个房间就变得很方便。

3.5 服务器

前面各种模块都是为了这一阶段服务的。这一阶段的工作实际上并不复杂,总结起来就两点:

  1. 初始化WebSocket服务器;
  2. 填充WebSocket服务器句柄的四个回调函数。

为了简化代码,我么定义了如下两个类型:

// WebSocket服务器句柄
using ws_server_t = websocketpp::server<websocketpp::config::asio>;
// HTTP状态码枚举类型
using http_status_t = websocketpp::http::status_code::value;

3.5.1 hdl的使用方式

当然,这里指的是服务器回调函数的参数。WebSocket服务器的前四个回调函数的参数都包含hdl,这个实际上就是客户端的句柄。

那么我们如何用它获取请求并进行响应呢?

3.5.1.1 请求处理
// 获取连接
ws_server_t::connection_ptr conn = _wssrv.get_con_from_hdl(hdl);
// 获取请求
websocketpp::http::parser::request req = conn->get_request();
// 获取HTTP请求方法(只有HTTP回调函数应该调用)
std::string method = req.get_method();
// 获取uri
std::string uri = req.get_uri();
3.5.1.2 回复报文

(1)HTTP

// 设置响应状态码
// 成功设置ok,客户端请求有问题设置bad_request,服务器出问题设置internal_server_error
conn->set_status(http_status_t::ok);
// 添加头部字段
conn->append_header(字段名字符串, 字段值);
// 设置正文
conn->set_body(正文字符串);

(2)WebSocket

conn->send(body);

3.5.2 msg的使用方式

当然,这个msg指的是消息回调函数中的第二个参数。

我们如何使用msg获得消息(请求正文)呢?

std::string req_body = msg->get_payload();

其他的还请参考源码,这个模块的编写逻辑很清晰,也很好看懂,源码中注释也很清晰。

但是介绍起来的话就又没完没了了。实在是写不下去了,前端之后有机会再补充吧。

4. 后续拓展方向

  1. 引入线程池:服务器回调函数被触发之后,根据uri将对应的任务添加到线程池;
  2. 实现局时/步时:限制玩家一局/一步落子所能用于思考的时间;
  3. 保存棋谱&录像回放服务器可以把每一局对局、玩家轮流落子的位置都记录下来。玩家可以在游戏大厅页面选定某个曾经的比赛,在页面上回放出对局的过程(感觉应该比较难);
  4. 观战功能:在游戏大厅显示当前所有的对局房间。玩家可以选中某个房间以观众的形式加入到房间中,实时的看到选手的对局情况。
  5. 虚拟对战&人机如果当前长时间匹配不到对手,则自动分配一个 AI 对手, 实现人机对战
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐