C++标准项目---在线五子棋对战
1. 项目介绍
这个项目是一个网页版的在线五子棋对战游戏。
玩家完成注册、登录并点击“开始匹配”之后,系统会根据玩家的积分,自动为玩家匹配同一分段的玩家进行实时对战与聊天。
对局结束之后,根据对局结果修改玩家的积分。
该项目的核心是后端开发,前端主要是编写与玩家及服务器进行交互的逻辑,涉及到的知识并不复杂,即使对前端一窍不通也可尝试跟着这篇文章进行必要知识的学习(博主其实也不太会前端)。
项目演示当中的前端页面是由deepseek对页面样式进行了优化后的效果。
如果你是在是懒得做前端,也可以把博主源码中的前端页面拿来用,能结合下文给出的协议看懂就行。
1.1 项目演示
目前你就可以试试访问:http://110.41.32.137:8888/
- 首页:用户在浏览器搜索框内输入服务器的ip地址与端口号之后,会跳转到游戏的首页。

- 登录页面:点击“开始游戏”,自动跳转到登录页面。

- 注册页面:如果还没有账号,可以跳转到注册页面进行注册。

- 游戏大厅页面:这里会显示玩家的信息,玩家可以在这个页面进行匹配。两位玩家匹配成功会自动跳转到游戏房间页面。

- 游戏房间页面:两位玩家可以在这里对弈并聊天,若有玩家中途退出,则另一方胜利。

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

1.3 项目源码
https://gitee.com/da-guan-mu-lao-sheng/linux-c/tree/master/%E5%9C%A8%E7%BA%BF%E4%BA%94%E5%AD%90%E6%A3%8B%E5%AF%B9%E6%88%98/source_2
2. 项目的前期工作
2.1 依赖的库
2.1.1 WebSocket++
WebSocket++ 是一个轻量级、跨平台的 C++ 库,核心用途是帮助开发者在应用中快速实现WebSocket 协议的客户端与服务器端通信,无需手动处理协议细节。
安装方式:
- 更新软件源:
sudo apt update - 安装 WebSocket++ 核心包(含头文件):
sudo apt install libwebsocketpp-dev - (可选)安装依赖(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 协议的全双工特特性:
- 在游戏大厅,客户端发起开始匹配的请求之后,服务端需要在匹配成功时主动通知客户端。
- 在游戏房间当中,用户做出的任何成功的行为(落子,聊天,中途退出等)都需要同步到另一个用户的网页上,此时服务器就需要主动通知另一个用户的客户端。
2.1.1.2 如何建立 WebSocket 连接
显然,要建立连接,首先就要绕开连接进行通信。所以,在建立 WebSocket 连接之前,我们需要先建立 HTTP 连接,然后再将 HTTP 连接切换为WebSocket连接。
因此,建立 WebSocket 连接的核心是 “客户端发起 HTTP 协议升级请求 + 服务器验证响应”。无论哪种场景,连接建立的核心步骤一致,可概括为 3 步:
- 客户端初始化连接:指定 WebSocket 服务地址(ws:// 未加密 / wss:// 加密),发起 HTTP 升级请求。核心的请求头部有:
Upgrade: websocket 核心:告知服务器 “请求升级到 WebSocket 协议” Connection: Upgrade 辅助:明确表示 “这是一个协议升级请求”(HTTP/1.1 要求必须配合 Upgrade) Sec-WebSocket-Key 随机字符串(客户端生成),用于服务器验证(避免误升级,非加密用途) Sec-WebSocket-Version: 13 声明 WebSocket 版本(主流是 13,对应 RFC 6455,服务器需兼容该版本) - 服务器验证响应:校验请求合法性(方法、版本、升级头),返回 101 状态码确认协议切换。核心的响应头部有:
HTTP/1.1 101 Switching Protocols 状态码:告知客户端 “协议切换成功” Upgrade: websocket 确认升级到 WebSocket 协议(与请求头一致) Connection: Upgrade 确认是协议升级(与请求头一致) Sec-WebSocket-Accept 服务器对 Sec-WebSocket-Key 的编码结果(客户端需校验该值,防止伪造) - 连接成功:TCP 连接转为 WebSocket 连接,双方可双向发送消息(触发 onopen 回调)。
切换的关键是:复用初始 TCP 连接,通过 HTTP 头告知双方 “要切换到 WebSocket 协议”,验证通过后即切换完成。
整个过程不建立新的 TCP 连接,仅改变连接的 “协议规则”,从而避免 HTTP 反复握手的开销,实现低延迟双向通信。
2.1.1.3 使用方式
我们在客户端使用的是JavaScript,我们可以使用浏览器原生 WebSocket API,其用法与WebSocketpp相似。
2.1.1.3.1 JavaScript
- 实例化客户端对象:
var ws_hdl = new WebSocket("ws://" + 服务端ip地址 + 要请求的资源); - 设置客户端的回调函数:
// 建立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++
- 实例化服务器对象并初始化:
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); - 设置服务器的回调函数:
// 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); -
启动服务器:
// 开始监听 _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-8if (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, // 用户idname 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);// 计数胜利: 总场次++ 胜利场次++ 积分+30bool 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接口会自动设置),只需要设置正文部分即可。
但是,如此一来会出现两个问题:
- 请求报头中不包含URI,需要在正文部分给出该请求的目的;
- 响应报头中不包含状态码,我们无法据此直接判断执行结果;
- 请求与响应不是一一对应的关系了,客户端不知道某个响应到底对应哪个/什么请求(所有响应的处理都集中在消息回调函数中处理)。
为了解决这一问题,我们在响应中新增一个字段"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: 房间iduid: 用户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,messageJson::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; // 游戏房间
该模块的主要作用有两个:
- 判断玩家状态:检查玩家是否在游戏大厅/房间中
- 获取指定用户的连接:在房间管理模块,很多消息需要同步给房间内的所有人,这时就需要通过该模块来查找连接了。
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; // 会话idid_t _uid; // 该会话所属用户的uidws_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 匹配管理
两个玩家如何完成匹配呢?很简单,设计一个匹配队列:
- 当玩家发起开始匹配的请求时,就将玩家加入到匹配队列当中;
- 当队列当中的玩家数量>=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
为什么不把WHITE和BLACK设置为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 服务器
前面各种模块都是为了这一阶段服务的。这一阶段的工作实际上并不复杂,总结起来就两点:
- 初始化WebSocket服务器;
- 填充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. 后续拓展方向
- 引入线程池:服务器回调函数被触发之后,根据uri将对应的任务添加到线程池;
- 实现局时/步时:限制玩家一局/一步落子所能用于思考的时间;
- 保存棋谱&录像回放:服务器可以把每一局对局、玩家轮流落子的位置都记录下来。玩家可以在游戏大厅页面选定某个曾经的比赛,在页面上回放出对局的过程(感觉应该比较难);
- 观战功能:在游戏大厅显示当前所有的对局房间。玩家可以选中某个房间以观众的形式加入到房间中,实时的看到选手的对局情况。
- 虚拟对战&人机:如果当前长时间匹配不到对手,则自动分配一个 AI 对手, 实现人机对战
