目录
项目介绍
开发环境
核心技术
项目前置知识点介绍
Websocketpp
1. WebSocket基本认识
2. WebSocket协议切换原理解析
3. WebSocket报文格式
4. Websocketpp介绍
5. 搭建一个简单WebSocket服务器
JsonCpp
1. Json格式的基本认识
2. JsonCpp介绍
3. 序列化与反序列化接口调用demo
MySQL API
1. MySQL数据库的访问操作流程
2. API介绍
3. API接口调用demo
项目结构设计
项目模块划分
业务处理模块的子模块划分
项目流程图
用户流程图
服务器流程图
项目类实现
工具类
日志宏封装
MySQL_API封装
Json格式数据的序列化和反序列封装
字符串分割封装
文件读取封装
数据管理类
数据库设计
实现user_table类
在线用户管理类实现
游戏房间管理类
房间类实现
房间管理类实现
| 房间类和房间管理类整合 |
session管理类
session的基本认识
session类实现
session管理类实现
| session类和session管理类整合 |
玩家匹配管理类
匹配队列类实现
匹配管理类实现
| 匹配队列类和匹配管理类整合 |
服务器类
Restful风格的网络通信接口设计
静态资源请求与响应格式
动态资源请求与响应格式
客户端对服务器的请求
服务器类实现
搭建基本的服务器框架
HTTP请求处理函数
静态资源请求处理函数
用户注册请求处理函数
用户登录请求处理函数
获取用户信息请求处理函数
WebSocket长连接建立成功后的处理函数
用户登录验证函数(登录成功则返回用户session)
游戏大厅长连接建立成功的处理函数
游戏房间长连接建立成功的处理函数
WebSocket长连接断开前的处理函数
游戏大厅长连接断开的处理函数
游戏房间长连接断开的处理函数
WebSocket长连接通信处理函数
游戏大厅请求处理函数(游戏匹配请求/停止匹配请求)
游戏房间请求处理函数(下棋请求/聊天请求)
| 服务器类所有函数整合 |
守护进程化
项目源码
项目介绍
本项目主要实现一个网页版的五子棋对战游戏,其当前版本支持以下核心功能:
- 用户管理:实现用户注册、用户登录、获取用户信息、用户游戏分数记录、用户比赛场次记录等。
- 匹配对战:实现玩家在浏览器网页端根据玩家的游戏分数进行匹配游戏对手,并进行五子棋游戏对战的功能。
- 实时聊天:实现在游戏房间内对战的两个玩家可以进行实时的聊天功能。
后续还可追加以下功能:
- 落子计时
- 棋局房间内观战
- 人机对战
开发环境
- Linux(CentOS-7.6)
- Visual Studio Code/Vim
- g++/gdb
- Makefile
核心技术
- HTTP/WebSocket
- Websocketpp
- JsonCpp
- MySQL
- C++11
- BlockQueue
- HTML/CSS/JS/AJAX
项目前置知识点介绍
Websocketpp
1. WebSocket基本认识
WebSocket是从HTML5开始支持的一种网页端和服务端保持长连接的消息推送机制。
WebSocket协议相较于HTTP协议的最大不同点在于,WebSocket协议支持服务端主动向客户端发送消息,这是HTTP协议所不具备的!
HTTP本质上就是一个“请求-响应”协议,客户端和服务器的通信属于“一问一答”的形式。在HTTP协议下服务器是属于被动的一方,如果客户端不给服务器发送请求,服务器是无法主动给客户端发送响应的。
HTTP协议切换到WebSocket协议 >> 短连接切换到长连接
本项目中的在线聊天功能以及实时显示落子功能,都需要支持服务器主动给客户端发送响应信息,所以引入Websocketpp这个库。
2. WebSocket协议切换原理解析
WebSocket协议本质上是一个基于TCP的协议。为了建立一个WebSocket连接,客户端浏览器首先要向服务器发起一个HTTP请求,这个请求和通常的HTTP请求不同,其中包含了些附加头信息,通过这些附加头信息完成握手过程并升级协议的过程。
3. WebSocket报文格式
重点关注以下字段:
- FIN:WebSocket传输数据以消息为概念单位,一个消息有可能由一个或多个帧组成,FIN字段为1表示末尾帧。
- RSV1~3:保留字段,只在扩展时使用,若未启用扩展则应置1,若收到不全为0的数据帧,且未协商扩展则立即终止连接。
- opcode:标志当前数据帧的类型。
- mask:表示Payload数据是否被编码,若为1则必有Mask-Key,用于解码Payload数据。仅客户端发送给服务端的消息需要设置。
- Payload length:数据载荷的长度,单位是字节,有可能为7位、7+16位、7+64位。假设Payload length = x。
- Mask-Key:当mask为1时存在,长度为4字节,解码规则:DECODED[i] = ENCODED[i] ^ MASK[i % 4]。
- Payload data:报文携带的载荷数据。
4. Websocketpp介绍
WebSocketpp是一个跨平台的开源(BSD许可证)头部专用C++库,它实现了RFC6455(WebSocket 协议)和RFC7692(WebSocketCompression Extensions)。它允许将WebSocket客户端和服务器功能集成到C++程序中。在最常见的配置中,全功能网络I/O由Asio网络库提供。
项目内常用Websocketpp常用接口:
日志相关接口:
void set_access_channels(log::level channels); //设置⽇志打印等级
回调函数相关接口:
针对不同的事件设置不同的处理函数。
搭建完WebSocket服务器后,给不同的事件设置不同的处理函数指针,这些指针指向指定的函数。当服务器收到了指定数据,触发了指定事件后,就会通过函数指针去调用对应的事件处理函数。
此时程序员只需要编写对应的业务处理函数,并设置好对应的函数指针的指向,即可做到当对应事件触发时,执行对应的业务函数。
void set_open_handler(open_handler h); //websocket握⼿成功回调处理函数
void set_close_handler(close_handler h); //websocket连接关闭回调处理函数
void set_message_handler(message_handler h); //websocket消息回调处理函数
void set_http_handler(http_handler h); //http请求回调处理函数
通信连接相关接口:
// 给客户端发送信息
void send(connection_hdl hdl, std::string& payload, frame::opcode::value op);
void send(connection_hdl hdl, void* payload, size_t len, frame::opcode::value op);// 关闭连接
void close(connection_hdl hdl, close::status::value code, std::string& reason);// 通过connection_hdl获取对应的connection_ptr
connection_ptr get_con_from_hdl(connection_hdl hdl);
其他服务器搭建的接口:
// 初始化asio框架
void init_asio();// 是否启用地址
void set_reuse_addr(bool value);// 开始获取新连接
void start_accept();// 设置endpoint的绑定监听端⼝
void listen(uint16_t port);// 启动服务器
std::size_t run();// 设置定时任务
timer_ptr set_timer(long duration, timer_handler callback);
5. 搭建一个简单WebSocket服务器
step1:实例化一个WebSocket的server对象。
step2:设置日志输出等级。
step3:初始化asio框架中的调度器
step4:设置业务处理回调函数。
step5:设置监听端口。
step6:开始获取tcp连接。
step7:启动服务器。
#include <iostream>
#include <string>
#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>typedef websocketpp::server<websocketpp::config::asio> websocketsvr_t;void wsopen_callback(websocketsvr_t* wssvr, websocketpp::connection_hdl hdl)
{std::cout << "websocket建立连接" << std::endl;
}void wsclose_callback(websocketsvr_t* wssvr, websocketpp::connection_hdl hdl)
{std::cout << "websocket连接断开" << std::endl;
}void wsmsg_callback(websocketsvr_t* wssvr, websocketpp::connection_hdl hdl, websocketsvr_t::message_ptr msg)
{// 获取通信连接websocketsvr_t::connection_ptr conn = wssvr->get_con_from_hdl(hdl);std::cout << "msg: " << msg->get_payload() << std::endl;// 将客户端发送的信息作为响应std::string resp = "client say: " + msg->get_payload();// 将响应信息发送给客户端conn->send(resp);
}// 给客户端返回一个hello world页面
void http_callback(websocketsvr_t* wssvr, websocketpp::connection_hdl hdl)
{// 获取通信连接websocketsvr_t::connection_ptr conn = wssvr->get_con_from_hdl(hdl);// 打印请求正文std::cout << "body: " << conn->get_request_body() << std::endl;// 获取http请求websocketpp::http::parser::request req = conn->get_request();// 打印请求方法和urlstd::cout << "method: " << req.get_method() << std::endl;std::cout << "uri: " << req.get_uri() << std::endl;// 设置响应正文std::string body = "<html><body><h1>Hello World</h1></body></html>";conn->set_body(body);conn->append_header("Content-Type", "text/html");conn->set_status(websocketpp::http::status_code::ok);
}int main()
{// 1. 实例化server对象// 2. 设置日志等级// 3. 初始化asio调度器,设置地址重用// 4. 设置回调函数// 5. 设置监听端口// 6. 开始获取新连接// 7. 启动服务器// 1.websocketsvr_t wssvr;// 2.wssvr.set_access_channels(websocketpp::log::alevel::none); // 禁止打印所有日志// 3.wssvr.init_asio();wssvr.set_reuse_addr(true);// 4.wssvr.set_open_handler(std::bind(wsopen_callback, &wssvr, std::placeholders::_1));wssvr.set_close_handler(std::bind(wsclose_callback, &wssvr, std::placeholders::_1));wssvr.set_message_handler(std::bind(wsmsg_callback, &wssvr, std::placeholders::_1, std::placeholders::_2));wssvr.set_http_handler(std::bind(http_callback, &wssvr, std::placeholders::_1));// 5.wssvr.listen(8080);// 6.wssvr.start_accept();// 7.wssvr.run();return 0;
}
使用浏览器直接访问主机ip和port:
下面写一个简单的前端客户端界面,用于连接刚刚搭建的WebSocket服务器。
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Test Websocket</title>
</head><body><input type="text" id="message"><button id="submit">提交</button><script>// 创建 websocket 实例// ws://111.230.200.206:8080// 类⽐http// ws表示websocket协议// 111.230.200.206 表示服务器地址// 8080表示服务器绑定的端⼝let websocket = new WebSocket("ws://111.230.200.206:8080");// 处理连接打开的回调函数websocket.onopen = function () {alert("连接建⽴");}// 处理收到消息的回调函数// 控制台打印消息websocket.onmessage = function (e) {alert("收到消息: " + e.data);}// 处理连接异常的回调函数websocket.onerror = function () {alert("连接异常");}// 处理连接关闭的回调函数websocket.onclose = function () {alert("连接关闭");}// 实现点击按钮后, 通过websocket实例向服务器发送请求let input = document.querySelector('#message');let button = document.querySelector('#submit');button.onclick = function () {alert("发送消息: " + input.value);websocket.send(input.value);}</script>
</body></html>
建立连接:
发送信息:
关闭浏览器:
实际上该项目五子棋对战游戏后续的实现就是围绕以上这四个函数来实现的!
JsonCpp
1. Json格式的基本认识
Json是⼀种数据交换格式,它采用完全独立于编程语言的文本格式来存储和表示数据。
例如: 我们想表示一个同学的学生信息
| C++代码 |
string name = "nK";
int age = 21;
float score[3] = {88.5, 99, 58};
| Json |
{"姓名": "nK","年龄": 21,"成绩": [88.5, 99, 58]
}
Json的数据类型包括对象,数组,字符串,数字等。
- 对象:使用花括号 {} 括起来的表示一个对象。
- 数组:使用中括号 [] 括起来的表示一个数组。
- 字符串:使用常规双引号 "" 括起来的表示一个字符串。
- 数字:包括整形和浮点型,直接使用。
2. JsonCpp介绍
Jsoncpp库主要是用于实现Json格式数据的序列化和反序列化,它实现了将Json数据对象组织成为Json格式字符串,以及将Json格式字符串解析得到Json数据对象的功能。
| Json数据对象类的表示 |
class Json::Value
{Value& operator=(const Value& other); // Value重载了[]和=,因此所有的赋值和获取数据都可以通过Value& operator[](const std::string& key); // 简单的⽅式完成 val["name"] = "xx";Value& operator[](const char* key);Value removeMember(const char* key); // 移除元素const Value& operator[](ArrayIndex index) const; // val["score"][0]Value& append(const Value& value); // 添加数组元素 val["score"].append(88);ArrayIndex size() const; // 获取数组元素个数 val["score"].size();bool isNull(); // ⽤于判断是否存在某个字段std::string asString() const; // 转string string name = val["name"].asString();const char* asCString() const; // 转char* char* name = val["name"].asCString();int asInt() const; // 转int int age = val["age"].asInt();float asFloat() const; // 转float float weight = val["weight"].asFloat();bool asBool() const; // 转bool bool ok = val["ok"].asBool();
};
| Json::Value对象特性 |
Json::Value root; root["v1"] = 1;std::cout << root["v2"] << std::endl;
访问Json::Value对象中一个不存在的字段,该字段以null返回。
JsonCpp库主要借助三个类以及其对应的少量成员函数完成序列化和反序列化的工作。
| 序列化接口 |
class JSON_API StreamWriter
{virtual int write(Value const& root, std::ostream* sout) = 0;
}class JSON_API StreamWriterBuilder : public StreamWriter::Factory
{virtual StreamWriter* newStreamWriter() const;
}
| 反序列化接口 |
class JSON_API CharReader
{virtual bool parse(char const* beginDoc, char const* endDoc, Value* root, std::string* errs) = 0;
}class JSON_API CharReaderBuilder : public CharReader::Factory
{virtual CharReader* newCharReader() const;
}
3. 序列化与反序列化接口调用demo
下面是一个简单的demo,调用JsonCpp库中的序列化和反序列化接口对数据进行序列化和反序列化操作。
#include <iostream>
#include <sstream>
#include <vector>
#include <string>
#include <jsoncpp/json/json.h>// 序列化
std::string serialize(const Json::Value& root)
{// 1. 实例化一个StreamWriterBuilder工厂类对象Json::StreamWriterBuilder swb;// 2. 通过StreamWriterBuilder工厂类对象实例化一个StreamWriter对象Json::StreamWriter* sw = swb.newStreamWriter();// 3. 使用StreamWriter对象,对Json::Value对象中存储的数据进行序列化std::stringstream ss;sw->write(root, &ss);delete sw; // sw是new出来的记得释放return ss.str();
}// 反序列化
Json::Value unserialize(const std::string& str)
{// 1. 实例化一个CharReaderBuilder工厂类对象Json::CharReaderBuilder crb;// 2. 通过CharReaderBuilder工厂类对象实例化一个CharReader对象Json::CharReader* cr = crb.newCharReader();// 3. 创建一个Json::Value对象存储解析后的数据Json::Value root;// 4. 使用CharReader对象,对str字符串进行Json格式的反序列化std::string err;cr->parse(str.c_str(), str.c_str() + str.size(), &root, &err);delete cr;return root;
}// 使用JosnCpp库进行多个数据对象的序列化与反序列化
int main()
{// 将需要进行序列化的数据存储到Json::Value对象中Json::Value student;student["name"] = "nK";student["age"] = 21;student["score"].append(98);student["score"].append(100);student["score"].append(80);std::string str = serialize(student);std::cout << "序列化结果:\n" << str << std::endl;Json::Value studentTmp = unserialize(str);// 输出Json格式的数据std::cout << "反序列化结果:" << std::endl;std::cout << "name: " << studentTmp["name"].asString() << std::endl;std::cout << "age: " << studentTmp["age"].asInt() << std::endl;for (int i = 0; i < studentTmp["score"].size(); ++i){std::cout << "score" << studentTmp["score"][i].asFloat() << std::endl;} return 0;
}
MySQL API
1. MySQL数据库的访问操作流程
① 客户端初始化过程
- 初始化MySQL操作句柄。
- 连接MySQL服务器。
- 设置客户端字符集
- 选择想要操作的数据库
② 客户端对数据库中数据的操作
- 执行SQL语句
- 若SQL语句是查询语句,则将查询结果保存到本地
- 获取查询结果集中的结果条数
- 遍历获取结果集中的每一条数据进行处理
- 释放结果集
- 释放MySQL操作句柄
2. API介绍
| MySQL操作句柄初始化 |
MYSQL* mysql_init(MYSQL* mysql);
参数说明:
mysql为空则动态申请句柄空间进行初始化
返回值:
成功返回句柄指针,失败返回NULL
| 连接MySQL服务器 |
MYSQL* mysql_real_connect(MYSQL* mysql, const char* host, const char* user,const char* passwd, const char* db, unsigned int port,const char* unix_socket, unsigned long client_flag);
参数说明:
mysql ---- 初始化完成的句柄
host ---- 连接的MySQL服务器的地址
user ---- 连接的服务器的用户名
passwd ---- 连接的服务器的密码
db ---- 默认选择的数据库名称
port ---- 连接的服务器的端口:默认是3306端口
unix_socket ---- 通信管道文件或者socket文件,通常设置为NULL
client_flag ---- 客户端标志位,通常置为0
返回值:
成功返回句柄指针,失败返回NULL
| 设置当前客户端的字符集 |
int mysql_set_character_set(MYSQL* mysql, const char* csname);
参数说明:
mysql ---- 初始化完成的句柄
csname ---- 字符集名称,通常为 "utf8"
返回值:
成功返回0, 失败返回非0
| 选择操作的数据库 |
int mysql_select_db(MYSQL* mysql, const char* db);
参数说明:
mysql ---- 初始化完成的句柄
db ---- 要切换选择的数据库名称
返回值:
成功返回0, 失败返回非0
| 执行SQL语句 |
int mysql_query(MYSQL* mysql, const char* stmt_str);
参数说明:
mysql ---- 初始化完成的句柄
stmt_str ---- 要执⾏的SQL语句
返回值:
成功返回0, 失败返回非0
| 保存查询结果到本地 |
MYSQL_RES* mysql_store_result(MYSQL* mysql);
参数说明:
mysql ---- 初始化完成的句柄
返回值:
成功返回结果集的指针,失败返回NULL
| 获取结果集中的行数 |
uint64_t mysql_num_rows(MYSQL_RES* result);
参数说明:
result ---- 保存到本地的结果集地址
返回值:
结果集中数据的条数
| 获取结果集中的列数 |
unsigned int mysql_num_fields(MYSQL_RES* result);
参数说明:
result ---- 保存到本地的结果集地址
返回值:
结果集中每一条数据的列数
| 遍历结果集 |
MYSQL_ROW mysql_fetch_row(MYSQL_RES* result);
这个接口会保存当前读取结果位置,每次获取的都是下⼀条数据。
参数说明:
result ---- 保存到本地的结果集地址
返回值:
实际上是一个char**的指针,将每一条数据做成了字符串指针数组
row[0] ---- 第0列
row[1] ---- 第1列......
| 释放结果集 |
void mysql_free_result(MYSQL_RES* result);
参数说明:
result ---- 保存到本地的结果集地址
| 关闭数据库客户端连接,销毁句柄 |
void mysql_close(MYSQL* mysql);
参数说明:
mysql ---- 初始化完成的句柄
| 获取mysql接口执行错误原因 |
const char* mysql_error(MYSQL* mysql);
参数说明:
mysql ---- 初始化完成的句柄
返回值:
返回出错原因
3. API接口调用demo
#include <iostream>
#include <string>
#include <mysql/mysql.h>#define HOST "127.0.0.1"
#define PORT 3306
#define USER "root"
#define PASSWD ""
#define DBNAME "MySQL_API_study"int main()
{// 1. 初始化MySQL句柄MYSQL* mysql = mysql_init(NULL);if (mysql == NULL){std::cout << "MySQL init failed!" << std::endl;return -1;}// 2. 连接服务器if (mysql_real_connect(mysql, HOST, USER, PASSWD, DBNAME, PORT, NULL, 0) == NULL){std::cout << "connect MySQL server failed! " << mysql_error(mysql) << std::endl;mysql_close(mysql); // 退出前断开连接,释放mysql操作句柄return -1;}// 3. 设置客户端字符集if (mysql_set_character_set(mysql, "utf8") != 0){std::cout << "set client character failed! " << mysql_error(mysql) << std::endl;mysql_close(mysql); // 退出前断开连接,释放mysql操作句柄return -1;}// 4. 选择要操作的数据库(这一步在连接MySQL服务器时,函数参数中已经设置过了)// 5. 执行SQL语句// const char* sql = "insert into student values(null, 'nK', 21, 99.3, 100, 89.5);";// const char* sql = "update student set chinese=chinese + 30 where sn=1;";// const char* sql = "delete from student where sn=1;";const char* sql = "select * from student";int ret = mysql_query(mysql, sql);if (ret != 0){std::cout << "mysql query failed! " << mysql_error(mysql) << std::endl;mysql_close(mysql); // 退出前断开连接,释放mysql操作句柄return -1;}// 6. 若SQL语句是查询语句,则将查询结果保存到本地MYSQL_RES* res = mysql_store_result(mysql);if (res == NULL){mysql_close(mysql);return -1;}// 7. 获取结果集中的结果条数int row = mysql_num_rows(res);int col = mysql_num_fields(res);// 8. 遍历保存到本地的结果集for (int i = 0; i < row; ++i){MYSQL_ROW line = mysql_fetch_row(res);for (int j = 0; j < col; ++j){std::cout << line[j] << "\t";}std::cout << std::endl;}// 9. 释放结果集mysql_free_result(res);// 10. 关闭连接,释放句柄mysql_close(mysql);return 0;
}
项目结构设计
项目模块划分
该项目要实现一个网络版的在线五子棋匹配对战,由以下三个大模块构成。
- 数据管理模块:基于MySQL数据库进行用户数据的管理。
- 前端界面模块:基于JS实现前端页面(注册,登录,游戏大厅,游戏房间)的动态控制以及与服务器的通信。
- 业务处理模块:搭建WebSocket服务器与客户端进行通信,接收请求并进行业务处理。
业务处理模块的子模块划分
- 网络通信模块:基于Websocketpp库实现HTTP&WebSocket服务器的搭建,提供网络通信功能。
- 会话管理模块:对客户端的连接进行Cookie&Session管理,实现HTTP短连接时客户端身份识别功能。
- 在线管理模块:对进⼊游戏⼤厅与游戏房间中用户进行管理,提供判断用户是否在线以及获取用户连接的功能。
- 房间管理模块:为匹配成功的用户创建对战房间,提供实时的五子棋对战与聊天业务功能。
- 用户匹配模块:根据天梯分数不同进行不同层次的玩家匹配,为匹配成功的玩家创建房间并加⼊房间。
项目流程图
用户流程图
服务器流程图
项目类实现
工具类
工具类主要是一些项目中会用到的边缘功能代码,提前实现好了就可以在项目中用到的时候直接使用了。
(工具类中的成员函数都用static修饰,目的是为了不实例化具体对象也能调用到类中的成员函数)
日志宏封装
主要是为了输出程序运行时的一些关键的日志信息,方便程序运行出错时调试代码。
#pragma once#include <iostream>
#include <ctime>#define INF 0
#define DBG 1
#define ERR 2
#define DEFAULT_LOG_LEVEL INF#define LOG(level, format, ...) \do \{ \if (DEFAULT_LOG_LEVEL > level) \break; \time_t t = time(NULL); \struct tm *lt = localtime(&t); \char buf[32] = {0}; \strftime(buf, 31, "%H:%M:%S", lt); \fprintf(stdout, "[%s %s:%d] " format "\n", buf, __FILE__, __LINE__, ##__VA_ARGS__); \} while (false)#define ILOG(format, ...) LOG(INF, format, ##__VA_ARGS__)
#define DLOG(format, ...) LOG(DBG, format, ##__VA_ARGS__)
#define ELOG(format, ...) LOG(ERR, format, ##__VA_ARGS__)
MySQL_API封装
该模块封装了对数据库的三个操作:
- 数据库的初始化&连接
- SQL语句的执行
- MySQL操作句柄的销毁
class mysql_util
{
public:static MYSQL* mysql_create(const std::string& host, const std::string& user, const std::string& passwd, const std::string& dbname, uint32_t port = 3306){// 1. 初始化MySQL句柄MYSQL *mysql = mysql_init(NULL);if (mysql == NULL){ELOG("MySQL init failed!");return nullptr;}// 2. 连接服务器if (mysql_real_connect(mysql, host.c_str(), user.c_str(), passwd.c_str(), dbname.c_str(), port, NULL, 0) == NULL){ELOG("connect MySQL server failed! %s", mysql_error(mysql));mysql_close(mysql); // 退出前断开连接,释放mysql操作句柄return nullptr;}// 3. 设置客户端字符集if (mysql_set_character_set(mysql, "utf8") != 0){ELOG("set client character failed! %s", mysql_error(mysql));mysql_close(mysql); // 退出前断开连接,释放mysql操作句柄return nullptr;}return mysql;}static bool mysql_exec(MYSQL* mysql, const std::string& sql){int ret = mysql_query(mysql, sql.c_str());if (ret != 0){ELOG("%s", sql.c_str());ELOG("mysql query failed! %s", mysql_error(mysql));mysql_close(mysql); // 退出前断开连接,释放mysql操作句柄return false;}return true;}static void mysql_destroy(MYSQL* mysql){if (mysql != nullptr) mysql_close(mysql);}
};
Json格式数据的序列化和反序列封装
- Json序列化:将Json::Value对象进行序列化得到一个Json格式的字符串。
- Json反序列化:将Json格式的字符串反序列化得到一个Json::Value对象。
class json_util
{
public:static bool serialize(const Json::Value& root, std::string& str){// 1. 实例化一个StreamWriterBuilder工厂类对象Json::StreamWriterBuilder swb;// 2. 通过StreamWriterBuilder工厂类对象实例化一个StreamWriter对象std::unique_ptr<Json::StreamWriter> sw(swb.newStreamWriter());// 3. 使用StreamWriter对象,对Json::Value对象中存储的数据进行序列化std::stringstream ss;int ret = sw->write(root, &ss);if (ret != 0){ELOG("serialize failed!");return false;}str = ss.str();return true;}static bool unserialize(const std::string& str, Json::Value& root){// 1. 实例化一个CharReaderBuilder工厂类对象Json::CharReaderBuilder crb;// 2. 通过CharReaderBuilder工厂类对象实例化一个CharReader对象std::unique_ptr<Json::CharReader> cr(crb.newCharReader());// 3. 使用CharReader对象,对str字符串进行Json格式的反序列化std::string err;bool ret = cr->parse(str.c_str(), str.c_str() + str.size(), &root, &err);if (ret == false){ELOG("unserialize failed! %s", err.c_str());return false;}return true;}
};
字符串分割封装
该模块封装字符串分割的功能,通过传入的字符串和分割字符将字符串分割为若干份放入一个输出数组中。
class string_util
{
public:static int split(const std::string& src, const std::string& sep, std::vector<std::string>& res){size_t start = 0, pos = 0;while (start < src.size()){pos = src.find(sep, start);if (pos == std::string::npos){res.push_back(src.substr(start));break;}if (pos == start){start += sep.size();continue;}res.push_back(src.substr(start, pos - start));start = pos + sep.size();}return res.size();}
};
循环中的这个if语句就是为了处理右侧这种字符串中存在连续多个分割字符的情况。
文件读取封装
该模块对读取文件数据的操作进行封装,主要对于HTML文件数据进行读取。
读取一个文件数据分为以下步骤:
- 打开文件
- 获取文件大小
- 读取文件中所有数据
- 关闭文件
class file_util
{
public:static bool read(const std::string& filename, std::string& body){// 1. 打开文件std::ifstream ifs(filename, std::ios::binary);if (ifs.is_open() == false){ELOG("open %s failed!", filename.c_str());return false;}// 2. 获取文件大小size_t fsize = 0;ifs.seekg(0, std::ios::end); // 将文件指针移动到文件末尾处fsize = ifs.tellg(); // 返回当前文件指针相较于文件开头的偏移量(即当前文件指针的位置相对于文件开头的字节数)ifs.seekg(0, std::ios::beg); // 将文件指针恢复到文件开头处body.resize(fsize); // !将body扩容至文件中数据的大小,否则后续的read(),无法将文件数据存放进body中// 3. 读取文件中所有数据ifs.read(&body[0], fsize);if (ifs.good() == false){ELOG("read %s content failed!", filename.c_str());ifs.close();return false;}// 4. 关闭文件ifs.close();return true;}
};
注意!不要忘记body.resize()这个操作,若没有给body扩容,则后续read(),则无法将文件中的数据放入body中!
数据管理类
数据管理类主要负责对于数据库中数据进行统一的增删查改操作,其他模块要对数据操作都必须通过数据管理类完成。
数据库设计
设计一个用户信息表,表中包括以下几个数据:
- 用户id
- 用户名
- 用户登录密码
- 游戏积分
- 游戏总场次
- 游戏胜场次
创建一个名为online_gobang的数据库。
在online_gobang中创建一个user表,表中包含以下6个数据。
实现user_table类
#pragma once#include <mutex>
#include <cassert>#include "util.hpp"class user_table
{
public:user_table(const std::string& host, const std::string& user, const std::string& password, const std::string& dbname, uint32_t port = 3306){_mysql = mysql_util::mysql_create(host, user, password, dbname, port);assert(_mysql != nullptr);}~user_table(){mysql_util::mysql_destroy(_mysql);_mysql = nullptr;}// 注册用户bool signup(Json::Value& user){// 缺少用户名或密码,注册失败if (user["username"].isNull() || user["password"].isNull()){ELOG("missing username or password!");return false;}#define ADD_USER "insert into user values(null, '%s', password('%s'), 1000, 0, 0);"char sql[4096] = { 0 };sprintf(sql, ADD_USER, user["username"].asCString(), user["password"].asCString());bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){DLOG("add user failed!");return false;}return true;}// 登录验证,并返回详细的用户信息bool login(Json::Value& user){// 缺少用户名或密码,登录失败if (user["username"].isNull() || user["password"].isNull()){ELOG("missing username or password!");return false;}#define VERIFY_USER "select id, score, total_count, win_count from user where username='%s' and password=password('%s');"char sql[4096] = { 0 };sprintf(sql, VERIFY_USER, user["username"].asCString(), user["password"].asCString());MYSQL_RES* res = nullptr;{std::unique_lock<std::mutex> lock(_mtx);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){DLOG("select user information failed!");return false;}// 保存查询结果res = mysql_store_result(_mysql);int row = mysql_num_rows(res);if (res == nullptr || row == 0){DLOG("haven't user's information!");return false;}}MYSQL_ROW line = mysql_fetch_row(res); // 获取一行查询结果// 将用户的详细信息保存到形参user中user["id"] = (Json::UInt64)std::stol(line[0]);user["score"] = (Json::UInt64)std::stol(line[1]);user["total_count"] = std::stoi(line[2]);user["win_count"] = std::stoi(line[3]);mysql_free_result(res);return true;}// 通过用户名获取详细的用户详细bool select_by_username(const std::string& username, Json::Value& user){
#define SELECT_BY_NAME "select id, score, total_count, win_count from user where username='%s';"char sql[4096] = { 0 };sprintf(sql, SELECT_BY_NAME, username.c_str());MYSQL_RES* res = nullptr;{std::unique_lock<std::mutex> lock(_mtx);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){DLOG("select user information by username failed!");return false;}// 保存查询结果res = mysql_store_result(_mysql);int row = mysql_num_rows(res);if (res == nullptr || row == 0){DLOG("haven't user's information!");return false;}}MYSQL_ROW line = mysql_fetch_row(res); // 获取一行查询结果// 将用户的详细信息保存到形参user中user["id"] = (Json::UInt64)std::stol(line[0]);user["username"] = username;user["score"] = (Json::UInt64)std::stol(line[1]);user["total_count"] = std::stoi(line[2]);user["win_count"] = std::stoi(line[3]);mysql_free_result(res);return true;}// 通过用户id获取详细的用户详细bool select_by_id(uint64_t id, Json::Value& user){
#define SELECT_BY_ID "select username, score, total_count, win_count from user where id=%d;"char sql[4096] = { 0 };sprintf(sql, SELECT_BY_ID, id);MYSQL_RES* res = nullptr;{std::unique_lock<std::mutex> lock(_mtx);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){DLOG("select user information by id failed!");return false;}// 保存查询结果res = mysql_store_result(_mysql);int row = mysql_num_rows(res);if (res == nullptr || row == 0){DLOG("haven't user's information!");return false;}}MYSQL_ROW line = mysql_fetch_row(res); // 获取一行查询结果// 将用户的详细信息保存到形参user中user["id"] = (Json::UInt64)id;user["username"] = line[0];user["score"] = (Json::UInt64)std::stol(line[1]);user["total_count"] = std::stoi(line[2]);user["win_count"] = std::stoi(line[3]);mysql_free_result(res);return true;}// 玩家获胜,分数+30,总场+1,胜场+1bool victory(uint64_t id){// 根据id查询是否有该玩家Json::Value val;if (select_by_id(id, val) == false){return false;}#define WIN_GAME "update user set score=score+30, total_count=total_count+1, win_count=win_count+1 where id=%d;"char sql[4096] = { 0 };sprintf(sql, WIN_GAME, id);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){DLOG("update winner's info failed!");return false;}return true;}// 玩家失败,分数-30,总场+1,其他不变bool defeat(uint64_t id){// 根据id查询是否有该玩家Json::Value val;if (select_by_id(id, val) == false){return false;}#define LOSE_GAME "update user set score=score-30, total_count=total_count+1 where id=%d;"char sql[4096] = { 0 };sprintf(sql, LOSE_GAME, id);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){DLOG("update loser's info failed!");return false;}return true;}private:MYSQL* _mysql; // mysql操作句柄std::mutex _mtx; // 互斥锁,保证数据库的访问操作的安全性
};
该类可能在多线程中运行,在数据库中执行查询语句可能出现线程安全问题。
MySQL提供的两个API接口,mysql_query()和mysql_store_result(),这两个接口单独使用都是线程安全的,但是两个组合在一起使用就可能会出现线程安全问题!
线程A在对user表进行查询操作,调用完mysql_query()后,还没来得及调用my_store_result()将查询结果保存到本地,就被挂起,切换执行线程B,线程B对user表进行了其他操作(增、删、改),就会导致线程A的查询结果遗失,再切换回线程A时,继续往下执行,调用mysql_store_result(),就会失败。
为了解决上述可能出现的线程安全问题,要给类中执行查询操作的区域加上互斥锁进行保护,将上面代码改为👇。
在线用户管理类实现
在线用户管理类,是对于当前游戏⼤厅和游戏房间中的用户进行管理,主要是建立起用户与Socket连接的映射关系,该类具有以下两个功能:
- 能够让程序,根据用户信息进而找到能够与用户客户端进行通信的Socket连接,进而实现与客户端的通信。
- 判断一个用户是否在线,或判断用户是否已经掉线。
在线用户管理模块管理的是这两类用户:a.进入游戏大厅的用户 b.进入游戏房间的用户。
当客户端建立WebSocket长连接时,才能将用户添加到游戏大厅或游戏房间中。
该类管理在线用户的方法是:将用户id和对应的客户端WebSocket长连接关联起来。
实现在线用户管理类的作用是:
- 当用户A执行了一个业务操作(发送实时聊天信息/下棋操作),可以在在线用户管理类中找到用户A对应的WebSocket长连接,将业务处理后的响应发送给游戏房间内的用户B。
- 通过用户id找到用户的WebSocket长连接,从而实现给指定用户的客户端推送信息。
- 用户A的WebSocket长连接关闭时,会自动将用户A的信息从在线用户管理类中移除,即可以通过查找一个用户是否还在在线用户管理类中来判断该用户是否在线。
#pragma once#include <unordered_map>
#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>
#include <mutex>#include "logger.hpp"typedef websocketpp::server<websocketpp::config::asio> websocketsvr_t;class online_manager
{
public:// websocket连接建立的时候,才会加入游戏大厅的在线用户管理void enter_game_hall(uint64_t userId, websocketsvr_t::connection_ptr& conn){std::unique_lock<std::mutex> lock(_mtx);_hall_users.insert({userId, conn});}// websocket连接建立的时候,才会加入游戏房间的在线用户管理bool enter_game_room(uint64_t userId, websocketsvr_t::connection_ptr& conn){std::unique_lock<std::mutex> lock(_mtx);_room_users.insert({userId, conn});}// websocket连接断开的时候,才会移除游戏大厅的在线用户管理bool exit_game_hall(uint64_t userId){std::unique_lock<std::mutex> lock(_mtx);_hall_users.erase(userId);}// websocket连接断开的时候,才会移除游戏房间的在线用户管理bool exit_game_room(uint64_t userId){std::unique_lock<std::mutex> lock(_mtx);_room_users.erase(userId);}// 判断指定用户当前是否在游戏大厅中bool is_in_game_hall(uint64_t userId){std::unique_lock<std::mutex> lock(_mtx);if (_hall_users.find(userId) == _hall_users.end()) return false;return true;}// 判断指定用户当前是否在游戏房间中bool is_in_game_room(uint64_t userId){std::unique_lock<std::mutex> lock(_mtx);if (_room_users.find(userId) == _room_users.end()) return false;return true;}// 通过用户id在游戏大厅用户管理中获取对应用户的通信连接websocketsvr_t::connection_ptr get_conn_from_hall(uint64_t userId){std::unique_lock<std::mutex> lock(_mtx);auto it = _hall_users.find(userId);if (it == _hall_users.end()) return websocketsvr_t::connection_ptr();return it->second;}// 通过用户id在游戏房间用户管理中获取对应用户的通信连接websocketsvr_t::connection_ptr get_conn_from_room(uint64_t userId){std::unique_lock<std::mutex> lock(_mtx);auto it = _room_users.find(userId);if (it == _room_users.end()) return websocketsvr_t::connection_ptr();return it->second;}private:std::unordered_map<uint64_t, websocketsvr_t::connection_ptr> _hall_users; // 用于建立在游戏大厅的用户的用户id与通信连接的关系std::unordered_map<uint64_t, websocketsvr_t::connection_ptr> _room_users; // 用于建立在游戏房间的用户的用户id与通信连接的关系std::mutex _mtx;
};
在线用户管理类中的所有成员函数都要加锁保护,因为该类可能在多线程中运行,加锁保护以防止多个线程同时对成员变量_hall_users和_room_users进行操作,导致线程安全问题。
游戏房间管理类
游戏房间管理类由以下两个类构成,房间类和房间管理类。
房间类实现
首先,要设计一个房间类,该类能够实例化一个游戏房间对象,游戏房间主要是对匹配成功的两个玩家建立一个小范围的关联关系。房间中任意一个用户做出的动作都会被广播给房间中的所有用户。
房间中用户可执行的动作包含以下两种:
- 下棋
- 聊天
#define BOARD_ROWS 15
#define BOARD_COLS 15
#define WHITE_CHESS 1
#define BLACK_CHESS 2enum room_statu
{GAME_START,GAME_OVER
};class room
{
public:room(uint64_t room_id, user_table* user_table, online_manager* user_online):_room_id(room_id), _statu(GAME_START), _player_count(0), _user_table(user_table), _user_online(user_online), _board(BOARD_ROWS, std::vector<int>(BOARD_COLS, 0)){DLOG("%lu 房间创建成功", _room_id);}~room() { DLOG("%lu 房间销毁成功", _room_id); }// 获取游戏房间iduint64_t id() { return _room_id; }// 获取游戏房间状态room_statu statu() { return _statu; }// 获取游戏房间的玩家数量int player_count() { return _player_count; }// 添加白棋玩家void add_white_player(uint64_t user_id){_white_id = user_id;++_player_count;}// 添加黑棋玩家void add_black_player(uint64_t user_id){_black_id = user_id;++_player_count;}// 获取白棋玩家iduint64_t get_white_player() { return _white_id; }// 获取黑棋玩家iduint64_t get_black_player() { return _black_id; }// 处理下棋动作Json::Value handle_chess(const Json::Value& req){Json::Value resp;uint64_t cur_user_id = req["uid"].asUInt64();int chess_row = req["row"].asInt();int chess_col = req["col"].asInt();// 1. 判断走棋位置是否合理(是否越界,是否被占用)if (chess_row >= BOARD_ROWS || chess_col >= BOARD_COLS){resp["optype"] = "put_chess";resp["result"] = false;resp["reason"] = "下棋位置越界";return resp;}else if (_board[chess_row][chess_col] != 0){resp["optype"] = "put_chess";resp["result"] = false;resp["reason"] = "下棋位置被占用";return resp;}resp = req;// 2. 判断房间中两个玩家是否在线,若有一个退出,则判另一个获胜// 判断白棋玩家是否在线if (_user_online->is_in_game_room(_white_id) == false) // 白棋玩家掉线{resp["result"] = true;resp["reason"] = "白棋玩家掉线,黑棋玩家获胜";resp["winner"] = (Json::UInt64)_black_id;return resp;}// 判断黑棋玩家是否在线if (_user_online->is_in_game_room(_black_id) == false) // 黑棋玩家掉线{resp["result"] = true;resp["reason"] = "黑棋玩家掉线,白棋玩家获胜";resp["winner"] = (Json::UInt64)_white_id;return resp;}// 3. 下棋int cur_chess_color = cur_user_id == _white_id ? WHITE_CHESS : BLACK_CHESS;_board[chess_row][chess_col] = cur_chess_color;// 4. 判断是否有玩家胜利(从当前走棋位置开始判断,是否存在五星连珠)uint64_t winner_id = check_win(chess_row, chess_col, cur_chess_color);if (winner_id != 0) // winner_id 等于0表示没有玩家获胜{std::string reason = winner_id == _white_id ? "白棋五星连珠,白棋获胜,游戏结束" : "黑棋五星连珠,黑棋获胜,游戏结束";resp["result"] = true;resp["reason"] = reason;resp["winner"] = (Json::UInt64)winner_id;return resp;}// 没有玩家获胜,正常走棋resp["result"] = true;resp["reason"] = "正常走棋,游戏继续";resp["winner"] = (Json::UInt64)winner_id;return resp;}// 处理聊天动作Json::Value handle_chat(const Json::Value& req){Json::Value resp;// 检测消息中是否包含敏感词std::string msg = req["message"].asString();if (have_sensitive_word(msg)){resp["optype"] = "chat";resp["result"] = false;resp["reason"] = "消息中包含敏感词";return resp;}resp = req;resp["result"] = true;return resp;}// 处理玩家退出房间动作void handle_exit(uint64_t user_id){Json::Value resp;// 判断玩家退出时,房间状态是否处于GAME_STARTif (_statu == GAME_START) // 游戏进行中,玩家A退出,则判断玩家B胜利{uint64_t winner_id = user_id == _white_id ? _black_id : _white_id;std::string reason = user_id == _white_id ? "白棋玩家退出游戏房间,黑棋玩家获胜" : "黑棋玩家退出游戏房间,白棋玩家获胜";resp["optype"] = "put_chess";resp["result"] = true;resp["reason"] = reason;resp["room_id"] = (Json::UInt64)_room_id;resp["uid"] = (Json::UInt64)user_id;resp["row"] = -1; // -1 表示玩家掉线,没有走棋resp["col"] = -1; // -1 表示玩家掉线,没有走棋resp["winner"] = (Json::UInt64)winner_id;// 更新数据库中用户信息表的相关信息uint64_t loser_id = winner_id == _white_id ? _black_id : _white_id;_user_table->victory(winner_id);_user_table->defeat(loser_id);_statu = GAME_OVER; // 更新游戏房间的状态broadcast(resp); // 将处理信息广播给房间的所有用户}--_player_count; // 游戏房间中的玩家数量减一}// 总的请求处理函数,在函数内部区分请求类型,根据不同的请求调用不同的处理函数,将得到的响应进行广播void handle_request(const Json::Value& req){Json::Value resp;// 判断req请求中的房间id与当前房间id是否匹配uint64_t room_id = req["room_id"].asUInt64();if (room_id != _room_id){resp["optype"] = req["optype"];resp["result"] = false;resp["reason"] = "游戏房间id不匹配";}else{// 根据req["optype"]来调用不同的处理函数if (req["optype"].asString() == "put_chess"){resp = handle_chess(req);if (resp["winner"].asUInt64() != 0) // 说明有玩家获胜{// 更新数据库中用户信息表的相关信息uint64_t winner_id = resp["winner"].asUInt64();uint64_t loser_id = winner_id == _white_id ? _black_id : _white_id;_user_table->victory(winner_id);_user_table->defeat(loser_id);// 更新游戏房间的状态_statu = GAME_OVER;}}else if (req["optype"].asString() == "chat"){resp = handle_chat(req);}else{resp["optype"] = req["optype"];resp["result"] = false;resp["reason"] = "未知类型的请求";}}// 将处理信息广播给房间的所有用户broadcast(resp);}// 将指定的信息广播给房间中所有玩家void broadcast(const Json::Value& resp){// 1. 对resp进行序列化,将序列化结果保存到一个string中std::string resp_str;json_util::serialize(resp, resp_str);// 2. 获取房间中白棋玩家和黑棋玩家的通信连接,并通过通信连接给玩家发送响应信息websocketsvr_t::connection_ptr white_conn = _user_online->get_conn_from_room(_white_id);if (white_conn.get() != nullptr) white_conn->send(resp_str);websocketsvr_t::connection_ptr black_conn = _user_online->get_conn_from_room(_black_id);if (black_conn.get() != nullptr) black_conn->send(resp_str);}private:bool five_chess(int row, int col, int row_offset, int col_offset, int chess_color){int count = 1; // 将刚刚下的棋也包括在内// 判断方向1int serch_row = row + row_offset;int serch_col = col + col_offset;while (serch_row >= 0 && serch_row < BOARD_ROWS && serch_col >= 0 && serch_col <= BOARD_COLS && _board[serch_row][serch_col] == chess_color){++count; // 同色棋子数量++// 检索位置继续偏移serch_row += row_offset;serch_col += col_offset;}// 判断方向2serch_row = row - row_offset;serch_col = col - col_offset;while (serch_row >= 0 && serch_row < BOARD_ROWS && serch_col >= 0 && serch_col <= BOARD_COLS && _board[serch_row][serch_col] == chess_color){++count; // 同色棋子数量++// 检索位置继续偏移serch_row -= row_offset;serch_col -= col_offset;}return count >= 5;}// 返回胜利玩家的id,没有则返回0uint64_t check_win(int row, int col, int chess_color){// 在下棋的位置检查四个方向是是否有五星连珠的情况(横行,纵列,正斜,反斜)if ((five_chess(row, col, 0, 1, chess_color)) || (five_chess(row, col, 1, 0, chess_color)) || (five_chess(row, col, -1, -1, chess_color)) || (five_chess(row, col, -1, 1, chess_color))){return chess_color == WHITE_CHESS ? _white_id : _black_id;}return 0;}// 敏感词检测bool have_sensitive_word(const std::string& msg){for (const auto& word : _sensitive_words){// 聊天消息中包含敏感词if (msg.find(word) != std::string::npos) return true;}return false;}private:uint64_t _room_id; // 游戏房间idroom_statu _statu; // 游戏房间的状态int _player_count; // 游戏房间中玩家的数量uint64_t _white_id; // 白棋玩家的iduint64_t _black_id; // 黑棋玩家的iduser_table* _user_table; // 数据库用户信息表的操作句柄online_manager* _user_online; // 在线用户管理句柄std::vector<std::vector<int>> _board; // 棋盘static std::vector<std::string> _sensitive_words; // 聊天敏感词(后期可补充)
};
std::vector<std::string> room::_sensitive_words = {"色情", "裸体", "性爱", "性交", "色情片","色情服务", "色情网站", "色情图片", "色情小说","操", "滚", "傻逼", "蠢货", "贱人", "混蛋","畜生", "白痴", "废物", "黑鬼", "黄种人", "白猪","异教徒", "邪教", "基佬", "拉拉", "同性恋", "暴力","杀人", "打架", "战斗", "殴打", "刺杀", "爆炸","恐怖袭击", "毒品", "赌博", "贩卖", "贿赂", "偷窃","抢劫"};
房间类中的成员函数handle_chess()和handle_chat以及handle_request()的参数都是Json::Value对象,以下列举下棋和聊天的请求格式。
| 下棋 |
{"optype": "put_chess", // put_chess表示当前请求是下棋操作"room_id": 222, // room_id 表示当前动作属于哪个房间"uid": 1, // 当前的下棋操作是哪个用户发起的"row": 3, // 当前下棋位置的行号"col": 2 // 当前下棋位置的列号 }
{"optype": "put_chess","result": false,"reason": "走棋失败具体原因...." }
{"optype": "put_chess","result": true,"reason": "对放掉线,不战而胜!" / "对方/己方五星连珠,战无敌/虽败犹荣!","room_id": 222,"uid": 1,"row": 3,"col": 2,"winner": 0 // 0 -- 未分胜负, !0 -- 已分胜负(uid是谁,谁就赢了) }
| 聊天 |
{"optype": "chat","room_id": 222,"uid": 1,"message": "快点!" }
{"optype": "chat","result": false,"reason": "发送消息失败的原因" }
{"optype": "chat","result": true,"room_id": 222,"uid": 1,"message": "快点!" }
房间管理类实现
该类负责对所有的游戏房间进行管理,类中包括以下几个对游戏房间的操作:
- 创建游戏房间
- 查找游戏房间(通过房间id查找,通过用户id查找)
- 移除房间中的玩家
- 销毁游戏房间
typedef std::shared_ptr<room> room_ptr;
由于项目中使用的room实例化对象是通过new出来的,所以不希望直接对指针进行操作。为了避免对一个已经释放的room对象进行操作,所以使用room_ptr来管理room对象的指针。只要shared_ptr计数器还没有减到0,就不存在对空指针进行访问,从而避免了内存访问错误的问题。
typedef std::shared_ptr<room> room_ptr;
class room_manager
{
public:room_manager(user_table* user_table, online_manager* user_online):_next_room_id(1), _user_table(user_table), _user_online(user_online){srand(time(nullptr)); // 用于将玩家1和玩家2随机分配给白棋和黑棋DLOG("房间管理类初始化完毕");}~room_manager() { DLOG("房间管理类即将销毁"); }room_ptr create_room(uint64_t user_id1, uint64_t user_id2){// 两个玩家都在游戏大厅中匹配成功后才能创建房间// 1. 判断玩家1和玩家2是否都在游戏大厅中if (_user_online->is_in_game_hall(user_id1) == false) // 玩家1不在游戏大厅中{DLOG("创建游戏房间失败,玩家:%lu 不在游戏大厅中", user_id1);return room_ptr();}if (_user_online->is_in_game_hall(user_id2) == false) // 玩家2不在游戏大厅中{DLOG("创建游戏房间失败,玩家:%lu 不在游戏大厅中", user_id2);return room_ptr();}std::unique_lock<std::mutex> lock(_mtx); // 对下面的操作进行加锁保护// 2. 创建房间,将用户信息添加到房间中room_ptr proom(new room(_next_room_id++, _user_table, _user_online));// 玩家1和玩家2随机匹配白棋和黑棋uint64_t white_id = rand() % 2 == 0 ? user_id1 : user_id2;uint64_t black_id = white_id == user_id1 ? user_id2 : user_id1;proom->add_white_player(white_id);proom->add_black_player(black_id);//-----------------------存疑?这里要不要调用_user_online->enter_game_room()?-----------------------// 3. 将房间信息管理起来_room_id_and_room.insert({proom->id(), proom});_user_id_and_room_id.insert({user_id1, proom->id()});_user_id_and_room_id.insert({user_id2, proom->id()});return proom;}room_ptr get_room_by_room_id(uint64_t room_id){std::unique_lock<std::mutex> lock(_mtx); // 对下面的操作进行加锁保护auto it = _room_id_and_room.find(room_id);if (it == _room_id_and_room.end()) // 没找到房间号为id的房间{DLOG("不存在房间id为:%d 的房间", room_id);return room_ptr();}return it->second;}room_ptr get_room_by_user_id(uint64_t user_id){std::unique_lock<std::mutex> lock(_mtx); // 对下面的操作进行加锁保护auto it1 = _user_id_and_room_id.find(user_id);if (it1 == _user_id_and_room_id.end()){DLOG("不存在与id为:%d 的玩家匹配的房间");return room_ptr();}uint64_t room_id = it1->second;auto it2 = _room_id_and_room.find(room_id);if (it2 == _room_id_and_room.end()){DLOG("不存在房间id为:%d 的房间", room_id);return room_ptr();}return it2->second;}void remove_player_in_room(uint64_t user_id){// 1. 通过玩家id获取游戏房间信息room_ptr proom = get_room_by_user_id(user_id);if (proom.get() == nullptr){DLOG("通过玩家id获取游戏房间信息失败");return;}// 2. 处理玩家退出房间动作proom->handle_exit(user_id);// 3. 判断游戏房间中是否还有玩家,没有则销毁游戏房间if (proom->player_count() == 0) destroy_room(proom->id());}void destroy_room(uint64_t room_id){// 1. 通过房间id获取游戏房间信息room_ptr proom = get_room_by_room_id(room_id);if (proom.get() == nullptr){DLOG("通过房间id获取游戏房间信息失败");return;}// 2. 通过游戏房间获取白棋玩家id和黑棋玩家iduint64_t white_id = proom->get_white_player();uint64_t black_id = proom->get_black_player();{std::unique_lock<std::mutex> lock(_mtx); // 加锁保护该作用域中的操作// 3. 将白棋玩家和黑棋玩家在“玩家id和游戏房间id的关联关系”中移除_user_id_and_room_id.erase(white_id);_user_id_and_room_id.erase(black_id);// 4. 将游戏房间信息从房间管理中移除_room_id_and_room.erase(proom->id());}}private:uint64_t _next_room_id; // 房间id分配器std::mutex _mtx; // 互斥锁user_table* _user_table; // 数据库用户信息表的操作句柄online_manager* _user_online; // 在线用户管理句柄std::unordered_map<uint64_t, room_ptr> _room_id_and_room; // 游戏房间id和游戏房间的关联关系std::unordered_map<uint64_t, uint64_t> _user_id_and_room_id; // 玩家id和游戏房间id的关联关系
};
| get_room_by_user_id()中的死锁问题 |
在实现房间管理类中的get_room_by_user_id()时,想着复用代码,结果写出来bug。
以上是存在bug的代码。
由于_mtx是类的成员变量,在get_room_by_user_id()中获取_mtx,在get_room_by_user_id()中调用了get_room_by_room_id(),而在get_room_by_room_id()中也要获取_mtx,但问题是get_room_by_user_id()在调用get_room_by_room_id()时已经持有了_mtx,因此在get_room_by_room_id()中再次尝试获取_mtx就会导致死锁。
(上面代码块中的代码已经是解决死锁bug之后的正确代码)
| 房间类和房间管理类整合 |
#pragma once#include <vector>
#include <jsoncpp/json/json.h>
#include <string>
#include <memory>
#include <mutex>
#include <unordered_map>
#include <cstdlib>
#include <ctime>#include "logger.hpp"
#include "db.hpp"
#include "online.hpp"
#include "util.hpp"#define BOARD_ROWS 15
#define BOARD_COLS 15
#define WHITE_CHESS 1
#define BLACK_CHESS 2enum room_statu
{GAME_START,GAME_OVER
};class room
{
public:room(uint64_t room_id, user_table* user_table, online_manager* user_online):_room_id(room_id), _statu(GAME_START), _player_count(0), _user_table(user_table), _user_online(user_online), _board(BOARD_ROWS, std::vector<int>(BOARD_COLS, 0)){DLOG("%lu 房间创建成功", _room_id);}~room() { DLOG("%lu 房间销毁成功", _room_id); }// 获取游戏房间iduint64_t id() { return _room_id; }// 获取游戏房间状态room_statu statu() { return _statu; }// 获取游戏房间的玩家数量int player_count() { return _player_count; }// 添加白棋玩家void add_white_player(uint64_t user_id){_white_id = user_id;++_player_count;}// 添加黑棋玩家void add_black_player(uint64_t user_id){_black_id = user_id;++_player_count;}// 获取白棋玩家iduint64_t get_white_player() { return _white_id; }// 获取黑棋玩家iduint64_t get_black_player() { return _black_id; }// 处理下棋动作Json::Value handle_chess(const Json::Value& req){Json::Value resp;uint64_t cur_user_id = req["uid"].asUInt64();int chess_row = req["row"].asInt();int chess_col = req["col"].asInt();// 1. 判断走棋位置是否合理(是否越界,是否被占用)if (chess_row >= BOARD_ROWS || chess_col >= BOARD_COLS){resp["optype"] = "put_chess";resp["result"] = false;resp["reason"] = "下棋位置越界";return resp;}else if (_board[chess_row][chess_col] != 0){resp["optype"] = "put_chess";resp["result"] = false;resp["reason"] = "下棋位置被占用";return resp;}resp = req;// 2. 判断房间中两个玩家是否在线,若有一个退出,则判另一个获胜// 判断白棋玩家是否在线if (_user_online->is_in_game_room(_white_id) == false) // 白棋玩家掉线{resp["result"] = true;resp["reason"] = "白棋玩家掉线,黑棋玩家获胜";resp["winner"] = (Json::UInt64)_black_id;return resp;}// 判断黑棋玩家是否在线if (_user_online->is_in_game_room(_black_id) == false) // 黑棋玩家掉线{resp["result"] = true;resp["reason"] = "黑棋玩家掉线,白棋玩家获胜";resp["winner"] = (Json::UInt64)_white_id;return resp;}// 3. 下棋int cur_chess_color = cur_user_id == _white_id ? WHITE_CHESS : BLACK_CHESS;_board[chess_row][chess_col] = cur_chess_color;// 4. 判断是否有玩家胜利(从当前走棋位置开始判断,是否存在五星连珠)uint64_t winner_id = check_win(chess_row, chess_col, cur_chess_color);if (winner_id != 0) // winner_id 等于0表示没有玩家获胜{std::string reason = winner_id == _white_id ? "白棋五星连珠,白棋获胜,游戏结束" : "黑棋五星连珠,黑棋获胜,游戏结束";resp["result"] = true;resp["reason"] = reason;resp["winner"] = (Json::UInt64)winner_id;return resp;}// 没有玩家获胜,正常走棋resp["result"] = true;resp["reason"] = "正常走棋,游戏继续";resp["winner"] = (Json::UInt64)winner_id;return resp;}// 处理聊天动作Json::Value handle_chat(const Json::Value& req){Json::Value resp;// 检测消息中是否包含敏感词std::string msg = req["message"].asString();if (have_sensitive_word(msg)){resp["optype"] = "chat";resp["result"] = false;resp["reason"] = "消息中包含敏感词";return resp;}resp = req;resp["result"] = true;return resp;}// 处理玩家退出房间动作void handle_exit(uint64_t user_id){Json::Value resp;// 判断玩家退出时,房间状态是否处于GAME_STARTif (_statu == GAME_START) // 游戏进行中,玩家A退出,则判断玩家B胜利{uint64_t winner_id = user_id == _white_id ? _black_id : _white_id;std::string reason = user_id == _white_id ? "白棋玩家退出游戏房间,黑棋玩家获胜" : "黑棋玩家退出游戏房间,白棋玩家获胜";resp["optype"] = "put_chess";resp["result"] = true;resp["reason"] = reason;resp["room_id"] = (Json::UInt64)_room_id;resp["uid"] = (Json::UInt64)user_id;resp["row"] = -1; // -1 表示玩家掉线,没有走棋resp["col"] = -1; // -1 表示玩家掉线,没有走棋resp["winner"] = (Json::UInt64)winner_id;// 更新数据库中用户信息表的相关信息uint64_t loser_id = winner_id == _white_id ? _black_id : _white_id;_user_table->victory(winner_id);_user_table->defeat(loser_id);_statu = GAME_OVER; // 更新游戏房间的状态broadcast(resp); // 将处理信息广播给房间的所有用户}--_player_count; // 游戏房间中的玩家数量减一}// 总的请求处理函数,在函数内部区分请求类型,根据不同的请求调用不同的处理函数,将得到的响应进行广播void handle_request(const Json::Value& req){Json::Value resp;// 判断req请求中的房间id与当前房间id是否匹配uint64_t room_id = req["room_id"].asUInt64();if (room_id != _room_id){resp["optype"] = req["optype"];resp["result"] = false;resp["reason"] = "游戏房间id不匹配";}else{// 根据req["optype"]来调用不同的处理函数if (req["optype"].asString() == "put_chess"){resp = handle_chess(req);if (resp["winner"].asUInt64() != 0) // 说明有玩家获胜{// 更新数据库中用户信息表的相关信息uint64_t winner_id = resp["winner"].asUInt64();uint64_t loser_id = winner_id == _white_id ? _black_id : _white_id;_user_table->victory(winner_id);_user_table->defeat(loser_id);// 更新游戏房间的状态_statu = GAME_OVER;}}else if (req["optype"].asString() == "chat"){resp = handle_chat(req);}else{resp["optype"] = req["optype"];resp["result"] = false;resp["reason"] = "未知类型的请求";}}// 将处理信息广播给房间的所有用户broadcast(resp);}// 将指定的信息广播给房间中所有玩家void broadcast(const Json::Value& resp){// 1. 对resp进行序列化,将序列化结果保存到一个string中std::string resp_str;json_util::serialize(resp, resp_str);// 2. 获取房间中白棋玩家和黑棋玩家的通信连接,并通过通信连接给玩家发送响应信息websocketsvr_t::connection_ptr white_conn = _user_online->get_conn_from_room(_white_id);if (white_conn.get() != nullptr) white_conn->send(resp_str);websocketsvr_t::connection_ptr black_conn = _user_online->get_conn_from_room(_black_id);if (black_conn.get() != nullptr) black_conn->send(resp_str);}private:bool five_chess(int row, int col, int row_offset, int col_offset, int chess_color){int count = 1; // 将刚刚下的棋也包括在内// 判断方向1int serch_row = row + row_offset;int serch_col = col + col_offset;while (serch_row >= 0 && serch_row < BOARD_ROWS && serch_col >= 0 && serch_col <= BOARD_COLS && _board[serch_row][serch_col] == chess_color){++count; // 同色棋子数量++// 检索位置继续偏移serch_row += row_offset;serch_col += col_offset;}// 判断方向2serch_row = row - row_offset;serch_col = col - col_offset;while (serch_row >= 0 && serch_row < BOARD_ROWS && serch_col >= 0 && serch_col <= BOARD_COLS && _board[serch_row][serch_col] == chess_color){++count; // 同色棋子数量++// 检索位置继续偏移serch_row -= row_offset;serch_col -= col_offset;}return count >= 5;}// 返回胜利玩家的id,没有则返回0uint64_t check_win(int row, int col, int chess_color){// 在下棋的位置检查四个方向是是否有五星连珠的情况(横行,纵列,正斜,反斜)if ((five_chess(row, col, 0, 1, chess_color)) || (five_chess(row, col, 1, 0, chess_color)) || (five_chess(row, col, -1, -1, chess_color)) || (five_chess(row, col, -1, 1, chess_color))){return chess_color == WHITE_CHESS ? _white_id : _black_id;}return 0;}// 敏感词检测bool have_sensitive_word(const std::string& msg){for (const auto& word : _sensitive_words){// 聊天消息中包含敏感词if (msg.find(word) != std::string::npos) return true;}return false;}private:uint64_t _room_id; // 游戏房间idroom_statu _statu; // 游戏房间的状态int _player_count; // 游戏房间中玩家的数量uint64_t _white_id; // 白棋玩家的iduint64_t _black_id; // 黑棋玩家的iduser_table* _user_table; // 数据库用户信息表的操作句柄online_manager* _user_online; // 在线用户管理句柄std::vector<std::vector<int>> _board; // 棋盘static std::vector<std::string> _sensitive_words; // 聊天敏感词(后期可补充)
};
std::vector<std::string> room::_sensitive_words = {"色情", "裸体", "性爱", "性交", "色情片","色情服务", "色情网站", "色情图片", "色情小说","操", "滚", "傻逼", "蠢货", "贱人", "混蛋","畜生", "白痴", "废物", "黑鬼", "黄种人", "白猪","异教徒", "邪教", "基佬", "拉拉", "同性恋", "暴力","杀人", "打架", "战斗", "殴打", "刺杀", "爆炸","恐怖袭击", "毒品", "赌博", "贩卖", "贿赂", "偷窃","抢劫"};typedef std::shared_ptr<room> room_ptr;
class room_manager
{
public:room_manager(user_table* user_table, online_manager* user_online):_next_room_id(1), _user_table(user_table), _user_online(user_online){srand(time(nullptr)); // 用于将玩家1和玩家2随机分配给白棋和黑棋DLOG("房间管理类初始化完毕");}~room_manager() { DLOG("房间管理类即将销毁"); }room_ptr create_room(uint64_t user_id1, uint64_t user_id2){// 两个玩家都在游戏大厅中匹配成功后才能创建房间// 1. 判断玩家1和玩家2是否都在游戏大厅中if (_user_online->is_in_game_hall(user_id1) == false) // 玩家1不在游戏大厅中{DLOG("创建游戏房间失败,玩家:%lu 不在游戏大厅中", user_id1);return room_ptr();}if (_user_online->is_in_game_hall(user_id2) == false) // 玩家2不在游戏大厅中{DLOG("创建游戏房间失败,玩家:%lu 不在游戏大厅中", user_id2);return room_ptr();}std::unique_lock<std::mutex> lock(_mtx); // 对下面的操作进行加锁保护// 2. 创建房间,将用户信息添加到房间中room_ptr proom(new room(_next_room_id++, _user_table, _user_online));// 玩家1和玩家2随机匹配白棋和黑棋uint64_t white_id = rand() % 2 == 0 ? user_id1 : user_id2;uint64_t black_id = white_id == user_id1 ? user_id2 : user_id1;proom->add_white_player(white_id);proom->add_black_player(black_id);//-----------------------存疑?这里要不要调用_user_online->enter_game_room()?-----------------------// 3. 将房间信息管理起来_room_id_and_room.insert({proom->id(), proom});_user_id_and_room_id.insert({user_id1, proom->id()});_user_id_and_room_id.insert({user_id2, proom->id()});return proom;}room_ptr get_room_by_room_id(uint64_t room_id){std::unique_lock<std::mutex> lock(_mtx); // 对下面的操作进行加锁保护auto it = _room_id_and_room.find(room_id);if (it == _room_id_and_room.end()) // 没找到房间号为id的房间{DLOG("不存在房间id为:%d 的房间", room_id);return room_ptr();}return it->second;}room_ptr get_room_by_user_id(uint64_t user_id){std::unique_lock<std::mutex> lock(_mtx); // 对下面的操作进行加锁保护auto it1 = _user_id_and_room_id.find(user_id);if (it1 == _user_id_and_room_id.end()){DLOG("不存在与id为:%d 的玩家匹配的房间");return room_ptr();}uint64_t room_id = it1->second;auto it2 = _room_id_and_room.find(room_id);if (it2 == _room_id_and_room.end()){DLOG("不存在房间id为:%d 的房间", room_id);return room_ptr();}return it2->second;}void remove_player_in_room(uint64_t user_id){// 1. 通过玩家id获取游戏房间信息room_ptr proom = get_room_by_user_id(user_id);if (proom.get() == nullptr){DLOG("通过玩家id获取游戏房间信息失败");return;}// 2. 处理玩家退出房间动作proom->handle_exit(user_id);// 3. 判断游戏房间中是否还有玩家,没有则销毁游戏房间if (proom->player_count() == 0) destroy_room(proom->id());}void destroy_room(uint64_t room_id){// 1. 通过房间id获取游戏房间信息room_ptr proom = get_room_by_room_id(room_id);if (proom.get() == nullptr){DLOG("通过房间id获取游戏房间信息失败");return;}// 2. 通过游戏房间获取白棋玩家id和黑棋玩家iduint64_t white_id = proom->get_white_player();uint64_t black_id = proom->get_black_player();{std::unique_lock<std::mutex> lock(_mtx); // 加锁保护该作用域中的操作// 3. 将白棋玩家和黑棋玩家在“玩家id和游戏房间id的关联关系”中移除_user_id_and_room_id.erase(white_id);_user_id_and_room_id.erase(black_id);// 4. 将游戏房间信息从房间管理中移除_room_id_and_room.erase(proom->id());}}private:uint64_t _next_room_id; // 房间id分配器std::mutex _mtx; // 互斥锁user_table* _user_table; // 数据库用户信息表的操作句柄online_manager* _user_online; // 在线用户管理句柄std::unordered_map<uint64_t, room_ptr> _room_id_and_room; // 游戏房间id和游戏房间的关联关系std::unordered_map<uint64_t, uint64_t> _user_id_and_room_id; // 玩家id和游戏房间id的关联关系
};
session管理类
session管理类由以下两个类构成,session类和session管理类。
session的基本认识
在Web开发中,HTTP协议是一种无状态短链接协议,这就导致一个客户端连接到服务器上之后,服务器不知道当前的连接对应的是哪个用户,也不知道客户端是否登录成功,这时候为客户端提供服务是不合理的。因此,服务器为每个用户浏览器创建⼀个会话对象(session对象),注意!一个浏览器独占一个session对象(默认情况下)。因此,在需要保存用户数据时,服务器程序可以把用户数据写到用户浏览器独占的session中,当用户使用浏览器访问其它程序时,其它程序可以从用户的session中取出该用户的数据,识别该连接对应的用户,并为用户提供服务。
| session工作原理 |
session类实现
session类用于保存用户的状态信息。
服务器管理的每一个session都会有过期的时间,超过了过期时间就会将对应的session销毁。每次客户端与服务器通信后都要刷新session的过期时间。
为了实现销毁服务器中的超时session,引入Websocketpp中的定时器👇。
| Websocketpp中的定时器 |
Websocketpp中提供的定时器是基于boost::asio::steady_timer封装实现的。
timer_ptr set_timer(long duration, timer_handler callback);
参数说明:
duration ---- 延迟多少毫秒后执行callback
callback ---- 可调用对象,定时器倒计时结束后调用该函数
返回值:
返回一个句柄,如果不再需要定时器,可用该句柄取消定时器
void func(const std::string& str) { std::cout << str << std::endl; }websocketpp::server<websocketpp::config::asio> wssvr; websocketpp::server<websocketpp::config::asio>::timer_ptr tp = wssvr->set_timer(5000, std::bind(func, "hello nK!")); tp->cancel();
接收set_timer()的返回值来取消定时任务,会导致定时任务被立即执行。
#pragma once#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>#include "logger.hpp"enum session_statu
{LOGIN,NOTLOGIN
};typedef websocketpp::server<websocketpp::config::asio> websocketsvr_t;
class session
{
public:session(uint64_t session_id):_session_id(session_id){DLOG("session:%p 已创建", this);}~session() { DLOG("session:%p 已销毁", this); }uint64_t get_session_id() { return _session_id; }void set_statu(session_statu statu) { _statu = statu; }void set_user(uint64_t user_id) { _user_id = user_id; }uint64_t get_user_id() { return _user_id; }void set_timer(const websocketsvr_t::timer_ptr& tp) { _tp = tp; }bool is_login() { return _statu == LOGIN; }private:uint64_t _session_id; // session idsession_statu _statu; // 用户当前的状态信息(登录/未登录)uint64_t _user_id; // 与session对应的用户idwebsocketsvr_t::timer_ptr _tp; // 与session关联的定时器
};
session管理类实现
该类负责对所有的session进行管理,类中包括以下几个对session的操作:
- 创建session
- 重新添加一个已存在的session
- 通过sessin id获取session
- 移除session
-
设置session过期时间
typedef std::shared_ptr<session> session_ptr;
由于项目中使用的session实例化对象是通过new出来的,所以不希望直接对指针进行操作。为了避免对一个已经释放的session对象进行操作,所以使用session_ptr来管理session对象的指针。只要shared_ptr计数器还没有减到0,就不存在对空指针进行访问,从而避免了内存访问错误的问题。
对于session的生命周期需要注意以下三点👇:
- 用户登录后,创建session,此时的session是临时的,在指定时间内无通信就被删除。
- 进入游戏大厅或游戏房间,此时session的生命周期是永久的。
- 退出游戏大厅或游戏房间,该session又被重新设置为临时的,在指定时间内无通信就被删除。
#define SESSION_TEMOPRARY 30000 // session的生命周期为30s
#define SESSION_PERMANENT -1 // session的生命周期为永久typedef std::shared_ptr<session> session_ptr;
class session_manager
{
public:session_manager(websocketsvr_t* wssvr):_next_session_id(1), _wssvr(wssvr){DLOG("session管理类初始化完毕");}~session_manager() { DLOG("session管理类即将销毁"); }// 创建sessionsession_ptr create_session(uint64_t user_id, session_statu statu){std::unique_lock<std::mutex> lock(_mtx);session_ptr psession(new session(_next_session_id++));psession->set_user(user_id);psession->set_statu(statu);_session_id_and_session.insert({psession->get_session_id(), psession});return psession;}// 向_session_id_and_session中重新添加一个已存在的sessionvoid append_session(const session_ptr& psession){std::unique_lock<std::mutex> lock(_mtx);_session_id_and_session.insert({psession->get_session_id(), psession});}// 通过sessin id获取sessionsession_ptr get_session_by_session_id(uint64_t session_id){std::unique_lock<std::mutex> lock(_mtx);auto it = _session_id_and_session.find(session_id);if (it == _session_id_and_session.end()){DLOG("不存在session id为:%d 的session", session_id);return session_ptr();}return it->second;}// 移除sessionvoid remove_session(uint64_t session_id){std::unique_lock<std::mutex> lock(_mtx);_session_id_and_session.erase(session_id);}// 设置session过期时间void set_session_expiration_time(uint64_t session_id, int ms){// 依赖于websocketpp的定时器来实现对session生命周期的管理// 用户登录后,创建session,此时的session是临时的,在指定时间内无通信就被删除// 进入游戏大厅或游戏房间,这个session的生命周期是永久的// 退出游戏大厅或游戏房间,该session又被重新设置为临时的,在指定时间内无通信就被删除// 获取sessionsession_ptr psession = get_session_by_session_id(session_id);if (psession.get() == nullptr){DLOG("通过session id获取session失败");return;}websocketsvr_t::timer_ptr tp = psession->get_timer();// 1. 在session的生命周期为永久的情况下,将session的生命周期设置为永久if (tp.get() == nullptr && ms == SESSION_PERMANENT){// 无需处理return;}// 2. 在session的生命周期为永久的情况下,设置定时任务:超过指定时间移除该sessionelse if (tp.get() == nullptr && ms != SESSION_PERMANENT){websocketsvr_t::timer_ptr tmp_tp = _wssvr->set_timer(ms, std::bind(&session_manager::remove_session, this, session_id));psession->set_timer(tmp_tp);}// 3. 在session设置了定时移除任务的情况下,将session的生命周期设置为永久else if (tp.get() != nullptr && ms == SESSION_PERMANENT){// 使用cancel()提前结束当前的定时任务,向session中添加一个空的定时器tp->cancel();psession->set_timer(websocketsvr_t::timer_ptr());// 重新向_session_id_and_session添加该session_wssvr->set_timer(0, std::bind(&session_manager::append_session, this, psession));}// 4. 在session设置了定时移除任务的情况下,重置session的生命周期else if (tp.get() != nullptr && ms != SESSION_PERMANENT){// 使用cancel()提前结束当前的定时任务,向session中添加一个空的定时器tp->cancel();psession->set_timer(websocketsvr_t::timer_ptr());// 重新向_session_id_and_session添加该session_wssvr->set_timer(0, std::bind(&session_manager::append_session, this, psession));// 给session设置新的定时任务,并将定时器更新到session中websocketsvr_t::timer_ptr tmp_tp = _wssvr->set_timer(ms, std::bind(&session_manager::remove_session, this, session_id));psession->set_timer(tmp_tp);}}private:uint64_t _next_session_id; // session id分配器std::mutex _mtx; // 互斥锁std::unordered_map<uint64_t, session_ptr> _session_id_and_session; // session id和session的关联关系websocketsvr_t* _wssvr; // Websocket服务器对象
};
下面讲解一下set_session_expiration_time()中的几个细节问题👇。
1. 一个session中与之关联的定时器如果是nullptr,则说明没有给该session设置定时任务,既该session的生命周期为永久。
2. 修改一个session的生命周期需要先调用cancel()取消该session原先的定时任务。
但是cancel()并不是立即执行的,cancel()何时执行由操作系统决定,这就导致了可能会出现,先调用了_session_id_and_session.insert()然后操作系统才执行了cancel(),就会出现意料之外的情况。
正确写法👇。
将重新添加session这个操作也设置一个定时器来执行,延迟时间为0秒。
cancel()由操作系统执行,定时器也由操作系统执行,且cancel()在新设置的定时器前面,所以可以保证canel()执行完毕后,再向_session_id_and_session中重新添加session。
| session类和session管理类整合 |
#pragma once#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>
#include <mutex>
#include <unordered_map>
#include <memory>#include "logger.hpp"enum session_statu
{LOGIN,NOTLOGIN
};typedef websocketpp::server<websocketpp::config::asio> websocketsvr_t;
class session
{
public:session(uint64_t session_id):_session_id(session_id){DLOG("session:%p 已创建", this);}~session() { DLOG("session:%p 已销毁", this); }uint64_t get_session_id() { return _session_id; }void set_statu(session_statu statu) { _statu = statu; }void set_user(uint64_t user_id) { _user_id = user_id; }uint64_t get_user_id() { return _user_id; }void set_timer(const websocketsvr_t::timer_ptr& tp) { _tp = tp; }websocketsvr_t::timer_ptr get_timer() { return _tp; }bool is_login() { return _statu == LOGIN; }private:uint64_t _session_id; // session idsession_statu _statu; // 用户当前的状态信息(登录/未登录)uint64_t _user_id; // 与session对应的用户idwebsocketsvr_t::timer_ptr _tp; // 与session关联的定时器
};#define SESSION_TEMOPRARY 30000 // session的生命周期为30s
#define SESSION_PERMANENT -1 // session的生命周期为永久typedef std::shared_ptr<session> session_ptr;
class session_manager
{
public:session_manager(websocketsvr_t* wssvr):_next_session_id(1), _wssvr(wssvr){DLOG("session管理类初始化完毕");}~session_manager() { DLOG("session管理类即将销毁"); }// 创建sessionsession_ptr create_session(uint64_t user_id, session_statu statu){std::unique_lock<std::mutex> lock(_mtx);session_ptr psession(new session(_next_session_id++));psession->set_user(user_id);psession->set_statu(statu);_session_id_and_session.insert({psession->get_session_id(), psession});return psession;}// 向_session_id_and_session中重新添加一个已存在的sessionvoid append_session(const session_ptr& psession){std::unique_lock<std::mutex> lock(_mtx);_session_id_and_session.insert({psession->get_session_id(), psession});}// 通过sessin id获取sessionsession_ptr get_session_by_session_id(uint64_t session_id){std::unique_lock<std::mutex> lock(_mtx);auto it = _session_id_and_session.find(session_id);if (it == _session_id_and_session.end()){DLOG("不存在session id为:%d 的session", session_id);return session_ptr();}return it->second;}// 移除sessionvoid remove_session(uint64_t session_id){std::unique_lock<std::mutex> lock(_mtx);_session_id_and_session.erase(session_id);}// 设置session过期时间void set_session_expiration_time(uint64_t session_id, int ms){// 依赖于websocketpp的定时器来实现对session生命周期的管理// 用户登录后,创建session,此时的session是临时的,在指定时间内无通信就被删除// 进入游戏大厅或游戏房间,这个session的生命周期是永久的// 退出游戏大厅或游戏房间,该session又被重新设置为临时的,在指定时间内无通信就被删除// 获取sessionsession_ptr psession = get_session_by_session_id(session_id);if (psession.get() == nullptr){DLOG("通过session id获取session失败");return;}websocketsvr_t::timer_ptr tp = psession->get_timer();// 1. 在session的生命周期为永久的情况下,将session的生命周期设置为永久if (tp.get() == nullptr && ms == SESSION_PERMANENT){// 无需处理return;}// 2. 在session的生命周期为永久的情况下,设置定时任务:超过指定时间移除该sessionelse if (tp.get() == nullptr && ms != SESSION_PERMANENT){websocketsvr_t::timer_ptr tmp_tp = _wssvr->set_timer(ms, std::bind(&session_manager::remove_session, this, session_id));psession->set_timer(tmp_tp);}// 3. 在session设置了定时移除任务的情况下,将session的生命周期设置为永久else if (tp.get() != nullptr && ms == SESSION_PERMANENT){// 使用cancel()提前结束当前的定时任务,向session中添加一个空的定时器tp->cancel();psession->set_timer(websocketsvr_t::timer_ptr());// 重新向_session_id_and_session添加该session_wssvr->set_timer(0, std::bind(&session_manager::append_session, this, psession));}// 4. 在session设置了定时移除任务的情况下,重置session的生命周期else if (tp.get() != nullptr && ms != SESSION_PERMANENT){// 使用cancel()提前结束当前的定时任务,向session中添加一个空的定时器tp->cancel();psession->set_timer(websocketsvr_t::timer_ptr());// 重新向_session_id_and_session添加该session_wssvr->set_timer(0, std::bind(&session_manager::append_session, this, psession));// 给session设置新的定时任务,并将定时器更新到session中websocketsvr_t::timer_ptr tmp_tp = _wssvr->set_timer(ms, std::bind(&session_manager::remove_session, this, session_id));psession->set_timer(tmp_tp);}}private:uint64_t _next_session_id; // session id分配器std::mutex _mtx; // 互斥锁std::unordered_map<uint64_t, session_ptr> _session_id_and_session; // session id和session的关联关系websocketsvr_t* _wssvr; // Websocket服务器对象
};
玩家匹配管理类
玩家匹配管理类由以下两个类构成,匹配队列类和匹配管理类。
根据玩家的分数将所有的玩家分三个等级:
- 青铜:score < 2000
- 白银:2000 <= score < 3000
- 黄金:score >= 3000
分别为三个不同等级创建对应的匹配队列。
有玩家要进行匹配时,根据玩家的分数,将玩家的id加入到相应等级的匹配队列中。当匹配队列中的元素大于等于2时,则说明此时该等级有两个及以上的玩家正在进行游戏匹配。从该匹配队列中出队两个玩家id,为这两个玩家创建一个游戏房间,将玩家加入该游戏房间后,向匹配成功的玩家发送游戏匹配成功的响应,至此玩家游戏匹配结束。
匹配队列类实现
使用list来模拟queue,而不直接使用queue的原因是,后续操作中需要删除匹配队列中的指定元素,queue不支持遍历,所以不使用原生的queue。
#pragma once#include <list>
#include <mutex>
#include <condition_variable>template<class T>
class match_queue
{
public:// 获取队列中元素个数int size(){std::unique_lock<std::mutex> lock(_mtx);return _list.size();}// 判断队列是否为空bool empty(){std::unique_lock<std::mutex> lock(_mtx);return _list.empty();}// 阻塞队列所在的线程void wait(){std::unique_lock<std::mutex> lock(_mtx);_cond.wait(lock);}// 将数据入队,并唤醒线程void push(const T& data){std::unique_lock<std::mutex> lock(_mtx);_list.push_back(data);_cond.notify_all();}// 出队并获取数据bool pop(T& data){std::unique_lock<std::mutex> lock(_mtx);if (_list.empty() == true){return false;}data = _list.front();_list.pop_front();return true;}// 移除指定数据void remove(const T& data){std::unique_lock<std::mutex> lock(_mtx);_list.remove(data);}private:std::list<T> _list; // 使用双链表来模拟队列(因为有删除指定元素的需求)std::mutex _mtx; // 互斥锁,实现线程安全std::condition_variable _cond; // 条件变量,主要为了阻塞消费者,消费者是从队列中拿数据的,当队列中元素 < 2时阻塞
};
在编写匹配队列类的时候我自己遇到了一个坑,就是下面左边的这种写法👇:
错误写法中会导致死锁问题。两个成员函数pop()和empty()都同时申请同一个锁资源,即成员变量_mtx,就会出现争夺锁资源,导致死锁。
匹配管理类实现
玩家进入游戏大厅后向服务器发起Json::Value对象格式的游戏匹配请求。下面列举了开始游戏匹配和停止游戏匹配的Json::Value格式👇。
| 开始游戏匹配 |
{"optype": "match_start" }
// 服务器后台正确处理后回复 {"optype": "match_start", // 表示成功加入匹配队列"result": true }// 服务器后台处理出错后回复 {"optype": "match_start","result": false,"reason": "具体原因...." }
// 匹配成功后给客户端的响应 {"optype": "match_success", // 表示匹配成功"result": true }
| 停止游戏匹配 |
{"optype": "match_stop" }
// 服务器后台正确处理后回复 {"optype": "match_stop","result": true }// 服务器后台处理出错后回复 {"optype": "match_stop","result": false,"reason": "具体原因...." }
class match_manager
{
public:match_manager(room_manager* room_manager, user_table* user_table, online_manager* user_online):_room_manager(room_manager), _user_table(user_table), _user_online(user_online), _bronze_thread(std::thread(&match_manager::bronze_thread_entrance, this)), _silver_thread(std::thread(&match_manager::silver_thread_entrance, this)), _gold_thread(std::thread(&match_manager::gold_thread_entrance, this)){DLOG("游戏匹配管理类初始化完毕");}~match_manager() { DLOG("游戏匹配管理类即将销毁"); }// 将玩家添加到匹配队列中bool add(uint64_t user_id){// 根据玩家的分数,将玩家添加到不同的匹配队列中// 根据玩家id获取玩家信息,读取玩家的分数Json::Value user;bool ret = _user_table->select_by_id(user_id, user);if (ret == false){DLOG("获取玩家:%d 的信息失败", user_id);return false;}// 根据分数将玩家加入到对应的匹配队列中int score = user["score"].asInt();if (score < 2000){_bronze_queue.push(user_id);}else if (score >= 2000 && score < 3000){_silver_queue.push(user_id);}else if (score >= 3000){_gold_queue.push(user_id);}return true;}// 将玩家从匹配队列中移除bool del(uint64_t user_id){Json::Value user;bool ret = _user_table->select_by_id(user_id, user);if (ret == false){DLOG("获取玩家:%d 的信息失败", user_id);return false;}int score = user["score"].asInt();if (score < 2000){_bronze_queue.remove(user_id);}else if (score >= 2000 && score < 3000){_silver_queue.remove(user_id);}else if (score >= 3000){_gold_queue.remove(user_id);}return true;}private:void match_handler(match_queue<uint64_t>& q){while (true) // 匹配线程需要一直处于运行状态{while (q.size() < 2) // 匹配队列中玩家不足两人,阻塞线程{q.wait();}// 从匹配队列中出队两个玩家uint64_t user_id1 = 0, user_id2 = 0;bool ret = q.pop(user_id1);if (ret == false) continue; // 第一个玩家出队失败,跳过后续代码重新执行上述代码ret = q.pop(user_id2);if (ret == false) // 代码执行至此,说明第一个玩家已出队,第二个玩家出队失败,要将出队的第一个玩家重新添加到匹配队列中{this->add(user_id1);continue;}// 两个玩家都出队成功,则检验两个玩家是否都在游戏大厅,若玩家A掉线,则将玩家B重新添加到匹配队列中websocketsvr_t::connection_ptr conn1 = _user_online->get_conn_from_hall(user_id1);if (conn1.get() == nullptr){this->add(user_id2);continue;}websocketsvr_t::connection_ptr conn2 = _user_online->get_conn_from_hall(user_id2);if (conn2.get() == nullptr){this->add(user_id1);continue;}// 判断完两个玩家都在线后,给两个玩家创建游戏房间room_ptr proom = _room_manager->create_room(user_id1, user_id2);if (proom.get() == nullptr) // 创建游戏房间失败{// 将两个玩家重新放回匹配队列中this->add(user_id1);this->add(user_id2);continue;}// 给游戏房间内的两个玩家返回响应Json::Value resp;resp["optype"] = "match_success";resp["result"] = true;std::string resp_str;json_util::serialize(resp, resp_str);conn1->send(resp_str);conn2->send(resp_str);}}// 青铜匹配队列处理线程void bronze_thread_entrance() { match_handler(_bronze_queue); }// 白银匹配队列处理线程void silver_thread_entrance() { match_handler(_silver_queue); }// 黄金匹配队列处理线程void gold_thread_entrance() { match_handler(_gold_queue); }private:match_queue<uint64_t> _bronze_queue; // 青铜匹配队列match_queue<uint64_t> _silver_queue; // 白银匹配队列match_queue<uint64_t> _gold_queue; // 黄金匹配队列std::thread _bronze_thread; // 青铜线程,用来管理青铜匹配队列的操作std::thread _silver_thread; // 白银线程,用来管理白银匹配队列的操作std::thread _gold_thread; // 黄金线程,用来管理黄金匹配队列的操作room_manager* _room_manager; // 游戏房间管理句柄user_table* _user_table; // 用户数据管理句柄online_manager* _user_online; // 在线用户管理句柄
};
匹配管理类中的私有成员函数match_handler(),有以下几点值得留意:
①:match_handler()是三个匹配线程执行的函数,该函数要处于一个死循环的状态,持续不断的检测是否有玩家进入匹配队列继续游戏匹配。
②:匹配队列中元素个数小于2,则说明匹配多列中的玩家不够两人,则需要将线程阻塞在当前位置。
③:判断匹配队列中的元素使用了一个while循环,这是因为每次向匹配队列中添加一个玩家(调用push())时都会唤醒线程,即线程从③的位置继续执行,回到while循环判断,若此时匹配队列元素大于等于2则向下执行代码,若匹配队列中小于2则继续阻塞线程,等待下次向匹配队列添加玩家时才再次唤醒线程。
| 匹配队列类和匹配管理类整合 |
#pragma once#include <list>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <string>#include "logger.hpp"
#include "room.hpp"
#include "db.hpp"
#include "online.hpp"
#include "util.hpp"template<class T>
class match_queue
{
public:// 获取队列中元素个数int size(){std::unique_lock<std::mutex> lock(_mtx);return _list.size();}// 判断队列是否为空bool empty(){std::unique_lock<std::mutex> lock(_mtx);return _list.empty();}// 阻塞队列所在的线程void wait(){std::unique_lock<std::mutex> lock(_mtx);_cond.wait(lock);}// 将数据入队,并唤醒线程void push(const T& data){std::unique_lock<std::mutex> lock(_mtx);_list.push_back(data);_cond.notify_all();}// 出队并获取数据bool pop(T& data){std::unique_lock<std::mutex> lock(_mtx);if (_list.empty() == true){return false;}data = _list.front();_list.pop_front();return true;}// 移除指定数据void remove(const T& data){std::unique_lock<std::mutex> lock(_mtx);_list.remove(data);}private:std::list<T> _list; // 使用双链表来模拟队列(因为有删除指定元素的需求)std::mutex _mtx; // 互斥锁,实现线程安全std::condition_variable _cond; // 条件变量,主要为了阻塞消费者,消费者是从队列中拿数据的,当队列中元素 < 2时阻塞
};class match_manager
{
public:match_manager(room_manager* room_manager, user_table* user_table, online_manager* user_online):_room_manager(room_manager), _user_table(user_table), _user_online(user_online), _bronze_thread(std::thread(&match_manager::bronze_thread_entrance, this)), _silver_thread(std::thread(&match_manager::silver_thread_entrance, this)), _gold_thread(std::thread(&match_manager::gold_thread_entrance, this)){DLOG("游戏匹配管理类初始化完毕");}~match_manager() { DLOG("游戏匹配管理类即将销毁"); }// 将玩家添加到匹配队列中bool add(uint64_t user_id){// 根据玩家的分数,将玩家添加到不同的匹配队列中// 根据玩家id获取玩家信息,读取玩家的分数Json::Value user;bool ret = _user_table->select_by_id(user_id, user);if (ret == false){DLOG("获取玩家:%d 的信息失败", user_id);return false;}// 根据分数将玩家加入到对应的匹配队列中int score = user["score"].asInt();if (score < 2000){_bronze_queue.push(user_id);}else if (score >= 2000 && score < 3000){_silver_queue.push(user_id);}else if (score >= 3000){_gold_queue.push(user_id);}return true;}// 将玩家从匹配队列中移除bool del(uint64_t user_id){Json::Value user;bool ret = _user_table->select_by_id(user_id, user);if (ret == false){DLOG("获取玩家:%d 的信息失败", user_id);return false;}int score = user["score"].asInt();if (score < 2000){_bronze_queue.remove(user_id);}else if (score >= 2000 && score < 3000){_silver_queue.remove(user_id);}else if (score >= 3000){_gold_queue.remove(user_id);}return true;}private:void match_handler(match_queue<uint64_t>& q){while (true) // 匹配线程需要一直处于运行状态{while (q.size() < 2) // 匹配队列中玩家不足两人,阻塞线程{q.wait();}// 从匹配队列中出队两个玩家uint64_t user_id1 = 0, user_id2 = 0;bool ret = q.pop(user_id1);if (ret == false) continue; // 第一个玩家出队失败,跳过后续代码重新执行上述代码ret = q.pop(user_id2);if (ret == false) // 代码执行至此,说明第一个玩家已出队,第二个玩家出队失败,要将出队的第一个玩家重新添加到匹配队列中{this->add(user_id1);continue;}// 两个玩家都出队成功,则检验两个玩家是否都在游戏大厅,若玩家A掉线,则将玩家B重新添加到匹配队列中websocketsvr_t::connection_ptr conn1 = _user_online->get_conn_from_hall(user_id1);if (conn1.get() == nullptr){this->add(user_id2);continue;}websocketsvr_t::connection_ptr conn2 = _user_online->get_conn_from_hall(user_id2);if (conn2.get() == nullptr){this->add(user_id1);continue;}// 判断完两个玩家都在线后,给两个玩家创建游戏房间room_ptr proom = _room_manager->create_room(user_id1, user_id2);if (proom.get() == nullptr) // 创建游戏房间失败{// 将两个玩家重新放回匹配队列中this->add(user_id1);this->add(user_id2);continue;}// 给游戏房间内的两个玩家返回响应Json::Value resp;resp["optype"] = "match_success";resp["result"] = true;std::string resp_str;json_util::serialize(resp, resp_str);conn1->send(resp_str);conn2->send(resp_str);}}// 青铜匹配队列处理线程void bronze_thread_entrance() { match_handler(_bronze_queue); }// 白银匹配队列处理线程void silver_thread_entrance() { match_handler(_silver_queue); }// 黄金匹配队列处理线程void gold_thread_entrance() { match_handler(_gold_queue); }private:match_queue<uint64_t> _bronze_queue; // 青铜匹配队列match_queue<uint64_t> _silver_queue; // 白银匹配队列match_queue<uint64_t> _gold_queue; // 黄金匹配队列std::thread _bronze_thread; // 青铜线程,用来管理青铜匹配队列的操作std::thread _silver_thread; // 白银线程,用来管理白银匹配队列的操作std::thread _gold_thread; // 黄金线程,用来管理黄金匹配队列的操作room_manager* _room_manager; // 游戏房间管理句柄user_table* _user_table; // 用户数据管理句柄online_manager* _user_online; // 在线用户管理句柄
};
服务器类
服务器类是对先前所实现的所有类的一个整合封装,对外提供搭建五子棋对战游戏服务器的接口的类。通过实例化出一个服务器对象,即可简便的完成一个五子棋游戏服务器的搭建。
该项目只对后端C++代码进行讲解和梳理,项目中涉及到的前端资源及代码在这里获取👉前端资源及代码
👉在与服务器类文件同级目录下创建一个名为wwwroot的文件夹,将前端资源及代码放到wwwroot中即可👈
Restful风格的网络通信接口设计
客户端和服务器之间存在多种请求和响应,客户端发送的请求需要统一一种格式,同理服务器给客户端发送的响应也需要统一格式。
下面就来设计该项目中统一的网络通信数据格式👇。
Restful风格依托于HTTP协议来实现。
正文部分采用XML或Json格式进行正文数据的格式组织。
GET - 获取资源
POST - 新增资源
PUT - 更新资源
DELETE - 删除资源
静态资源请求与响应格式
静态资源页面在服务器上就是一个html/css/js文件。服务器对于静态资源请求的处理,其实就是读取文件中的内容再发送给客户端。
| 注册页面请求与响应 |
请求: GET /register.html HTTP/1.1响应: HTTP/1.1 200 OK Content-Length: xxx Content-Type: text/html (空行) (响应正文)register.html文件的内容数据
| 登录页面请求与响应 |
请求: GET /login.html HTTP/1.1响应: HTTP/1.1 200 OK Content-Length: xxx Content-Type: text/html (空行) (响应正文)login.html文件的内容数据
| 游戏大厅页面请求与响应 |
请求: GET /game_hall.html HTTP/1.1响应: HTTP/1.1 200 OK Content-Length: xxx Content-Type: text/html (空行) (响应正文)game_hall.html文件的内容数据
| 游戏房间页面请求与响应 |
请求: GET /game_room.html HTTP/1.1响应: HTTP/1.1 200 OK Content-Length: xxx Content-Type: text/html (空行) (响应正文)game_room.html文件的内容数据
动态资源请求与响应格式
| 用户注册请求与响应 |
POST /signup HTTP/1.1 Content-Type: application/json Content-Length: 32{"username":"xiaobai", "password":"123123"}
// 成功时的响应 HTTP/1.1 200 OK Content-Type: application/json Content-Length: 15{"result":true}
// 失败时的响应 HTTP/1.1 400 Bad Request Content-Type: application/json Content-Length: 43{"result":false, "reason":"用户名已经被占用"}
| 用户登录请求与响应 |
POST /login HTTP/1.1 Content-Type: application/json Content-Length: 32{"username":"xiaobai", "password":"123123"}
// 成功时的响应 HTTP/1.1 200 OK Content-Type: application/json Content-Length: 15{"result":true}
// 失败时的响应 HTTP/1.1 401 Unauthorized Content-Type: application/json Content-Length: 43{"result":false, "reason":"用户还未登录"}
| 获取客户端信息请求与响应 |
GET /userinfo HTTP/1.1 Content-Type: application/json Content-Length: 0
// 成功时的响应 HTTP/1.1 200 OK Content-Type: application/json Content-Length: 58{"id":1, "username":"xiaobai", "score":1000, "total_count":4, "win_count":2}
// 失败时的响应 HTTP/1.1 401 Unauthorized Content-Type: application/json Content-Length: 43{"result":false, "reason":"用户还未登录"}
| WebSocket长连接协议切换请求与响应(进入游戏大厅)|
GET /match HTTP/1.1 Connection: Upgrade Upgrade: WebSocket ......
// 响应 HTTP/1.1 101 Switching ......
| WebSocket长连接协议切换请求与响应(进入游戏房间)|
GET /game HTTP/1.1 Connection: Upgrade Upgrade: WebSocket ......
// 响应 HTTP/1.1 101 Switching ......
👆上面的请求与响应的格式是HTTP协议的格式。
WebSocket协议切换成功后,后续的请求与响应格式改为Json格式👇。
| WebSocket连接建立成功后的响应 |
表示已经成功进入游戏大厅
{"optype": "hall_ready","result": true,"uid": 1 }
| WebSocket连接建立成功后的响应 |
表示已经成功进入游戏房间
{"optype": "room_ready","result": true,"room_id": 222, // 游戏房间id"self_id": 1, // 当前玩家id"white_id": 1, // 白棋玩家id"black_id": 2 // 黑棋玩家id }
| 游戏匹配请求与响应 |
{"optype": "match_start" }
// 服务器后台正确处理后回复 {"optype": "match_start", // 表示成功加入匹配队列"result": true }// 服务器后台处理出错后回复 {"optype": "match_start","result": false,"reason": "具体原因...." }
// 匹配成功后给客户端的回复 {"optype": "match_success", // 表示匹配成功"result": true }
| 停止匹配请求与响应 |
{"optype": "match_stop" }
// 服务器后台正确处理后回复 {"optype": "match_stop","result": true }// 服务器后台处理出错后回复 {"optype": "match_stop","result": false,"reason": "具体原因...." }
| 下棋请求与响应 |
{"optype": "put_chess", // put_chess表示当前请求是下棋操作"room_id": 222, // room_id 表示当前动作属于哪个房间"uid": 1, // 当前的下棋操作是哪个用户发起的"row": 3, // 当前下棋位置的行号"col": 2 // 当前下棋位置的列号 }
{"optype": "put_chess","result": false,"reason": "走棋失败具体原因...." }
{"optype": "put_chess","result": true,"reason": "对放掉线,不战而胜!" / "对方/己方五星连珠,战无敌/虽败犹荣!","room_id": 222,"uid": 1,"row": 3,"col": 2,"winner": 0 // 0 -- 未分胜负, !0 -- 已分胜负(uid是谁,谁就赢了) }
| 聊天请求与响应 |
{"optype": "chat","room_id": 222,"uid": 1,"message": "快点!" }
{"optype": "chat","result": false,"reason": "发送消息失败的原因" }
{"optype": "chat","result": true,"room_id": 222,"uid": 1,"message": "快点!" }
客户端对服务器的请求
在搭建服务器之前,首先要清楚客户端对服务器有哪些请求,以下列举了用户从开始注册到游戏结束对服务器的所有请求👇。
HTTP请求:
- 客户端从服务器获取一个注册页面
- 客户端给服务器发送一个用户注册请求(提交用户名和密码)
- 客户端从服务器获取一个登录页面
- 客户端给服务器发送一个用户登录请求(提交用户名和密码)
- 客户端从服务器获取一个游戏大厅页面
- 客户端给服务器发送一个获取个人信息的请求(展示个人信息)
WebSocket请求:
- 客户端给服务器发送一个切换WebSocket协议通信的请求(建立游戏大厅长连接)
- 客户端给服务器发送一个游戏匹配请求
- 客户端给服务器发送一个停止匹配请求
- 对战匹配成功,客户端从服务器获取一个游戏房间页面
- 客户端给服务器发送一个切换WebSocket协议通信的请求(建立游戏房间长连接)
- 客户端给服务器发送一个下棋请求
- 客户端给服务器发送一个聊天请求
- 游戏结果,返回游戏大厅(客户端给服务器发送一个获取游戏大厅页面的请求)
服务器类实现
服务器类是对前面实现的所有类进行整合并使用的类,是该项目中的重中之重!
通过服务器类可以实例化出一个服务器对象,调用服务器的接口函数即可将游戏服务器运行起来。
由于服务器类中的成员函数繁多,下面将会一一实现服务器类中的成员函数。
搭建基本的服务器框架
#pragma once#include <string>
#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>#include "db.hpp"
#include "online.hpp"
#include "room.hpp"
#include "matcher.hpp"
#include "session.hpp"#define WWWROOT "./wwwroot/"class gobang_server
{
public:// 进行成员变量初始化,以及设置服务器回调函数gobang_server(const std::string& host,const std::string& user,const std::string& password,const std::string& dbname,uint32_t port = 3306,const std::string& wwwroot = WWWROOT):_web_root(wwwroot), _user_table(host, user, password, dbname, port), _room_manager(&_user_table, &_user_online), _match_manager(&_room_manager, &_user_table, &_user_online), _session_manager(&_wssvr){_wssvr.set_access_channels(websocketpp::log::alevel::none);_wssvr.init_asio();_wssvr.set_reuse_addr(true);_wssvr.set_open_handler(std::bind(&gobang_server::wsopen_callback, this, std::placeholders::_1));_wssvr.set_close_handler(std::bind(&gobang_server::wsclose_callback, this, std::placeholders::_1));_wssvr.set_message_handler(std::bind(&gobang_server::wsmsg_callback, this, std::placeholders::_1, std::placeholders::_2));_wssvr.set_http_handler(std::bind(&gobang_server::http_callback, this, std::placeholders::_1));}// 启动服务器void start(uint16_t port){_wssvr.listen(port);_wssvr.start_accept();_wssvr.run();}private:void wsopen_callback(websocketpp::connection_hdl hdl);void wsclose_callback(websocketpp::connection_hdl hdl);void wsmsg_callback(websocketpp::connection_hdl hdl, websocketsvr_t::message_ptr msg);void http_callback(websocketpp::connection_hdl hdl);private:std::string _web_root; // 静态资源根目录(./wwwroot/) 请求的url为 /register.html,会自动将url拼接到_web_root后,即./wwwroot/register.htmlwebsocketsvr_t _wssvr; // WebSocket服务器user_table _user_table; // 用户数据管理模块online_manager _user_online; // 在线用户管理模块room_manager _room_manager; // 游戏房间管理模块match_manager _match_manager; // 游戏匹配管理模块session_manager _session_manager; // session管理模块
};
博客的开头也有讲解如何搭建一个简单的WebSocket服务器。在服务器类中只是把搭建WebSocket服务器的步骤拆分到了构造函数和start()中,搭建WebSocket服务器的本质过程还是相同的。
后面主要围绕wsopen_callback(),wsclose_callback(),wsmsg_callback(),http_callback()这四个函数来做文章。
HTTP请求处理函数
void http_callback(websocketpp::connection_hdl hdl)
{websocketsvr_t::connection_ptr conn = _wssvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string method = req.get_method();std::string uri = req.get_uri();if (method == "POST" && uri == "/signup"){signup_handler(conn);}else if (method == "POST" && uri == "/login"){login_handler(conn);}else if (method == "GET" && uri == "/userinfo"){info_handler(conn);}else{file_handler(conn);}
}
首先调用Websocketpp中的接口get_conn_from_hdl(),获取连接(conn),通过conn获取用户发送的HTTP请求,解析HTTP请求,获取HTTP首行中的请求方法和uri,再根据请求方法和uri的组合,调用不同的处理函数。
实现以下函数接口以方便组织HTTP响应返回给用户端👇:
void http_resp(websocketsvr_t::connection_ptr& conn, bool result, const std::string& reason, websocketpp::http::status_code::value code)
{Json::Value resp;resp["result"] = result;resp["reason"] = reason;std::string resp_str;json_util::serialize(resp, resp_str);conn->set_status(code);conn->set_body(resp_str);conn->append_header("Content-Type", "application/json");
}
实现以下函数接口以方便组织WebSocket响应返回给用户端👇:
void websocket_resp(websocketsvr_t::connection_ptr& conn, Json::Value& resp)
{std::string resp_str;json_util::serialize(resp, resp_str);conn->send(resp_str);
}
根据请求方法和uri的组合要实现以下几个4个处理函数👇:
- 静态资源请求处理函数(file_handler())
- 用户注册请求处理函数(signup_handler())
- 用户登录请求处理函数(login_handler())
- 获取用户信息请求处理函数(info_handler())
静态资源请求处理函数
void file_handler(websocketsvr_t::connection_ptr& conn)
{// 1.获取http请求的uri -- 资源路径,了解客户端请求的页面文件名称websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();// 2.组合出文件实际的路径(相对根目录 + uri)std::string real_path = _web_root + uri;// 3.如果请求的uri是个目录,则增加一个后缀 -- login.htmlif (real_path.back() == '/') // 表示请求资源路径是一个目录{real_path += "login.html";}// 4.读取文件内容;若文件不存在,则返回404std::string body;bool ret = file_util::read(real_path, body);if (ret == false){body += "<html>";body += "<head>";body += "<meta charset='UTF-8'/>";body += "</head>";body += "<body>";body += "<h1> Not Found </h1>";body += "</body>";conn->set_status(websocketpp::http::status_code::value::not_found);conn->set_body(body);return;}// 5.设置响应正文conn->set_status(websocketpp::http::status_code::value::ok);conn->set_body(body);return;
}
用户注册请求处理函数
void signup_handler(websocketsvr_t::connection_ptr& conn)
{// 1. 获取HTTP请求正文std::string req_body = conn->get_request_body();// 2. 对HTTP请求正文进行反序列化得到用户名和密码Json::Value signup_info;bool ret = json_util::unserialize(req_body, signup_info);if (ret == false){DLOG("反序列化注册信息失败");return http_resp(conn, false, "用户注册失败", websocketpp::http::status_code::value::bad_request);}// 3. 进行数据库的用户新增操作(成功返回200,失败返回400)if (signup_info["username"].isNull() || signup_info["password"].isNull()){DLOG("缺少用户名或密码");return http_resp(conn, false, "缺少用户名或密码", websocketpp::http::status_code::value::bad_request);}ret = _user_table.signup(signup_info); // 在数据库中新增用户if (ret == false){DLOG("向数据库中添加用户失败");return http_resp(conn, false, "用户注册失败", websocketpp::http::status_code::value::bad_request);}// 用户注册成功,返回成功响应return http_resp(conn, true, "用户注册成功", websocketpp::http::status_code::value::ok);
}
用户登录请求处理函数
void login_handler(websocketsvr_t::connection_ptr& conn)
{// 1. 获取HTTP请求正文std::string req_body = conn->get_request_body();// 2. 对HTTP请求正文进行反序列化得到用户名和密码Json::Value login_info;bool ret = json_util::unserialize(req_body, login_info);if (ret == false){DLOG("反序列化登录信息失败");return http_resp(conn, false, "用户登录失败", websocketpp::http::status_code::value::bad_request);}// 3. 校验正文完整性,进行数据库的用户信息验证(失败返回400)if (login_info["username"].isNull() || login_info["password"].isNull()){DLOG("缺少用户名或密码");return http_resp(conn, false, "缺少用户名或密码", websocketpp::http::status_code::value::bad_request);}ret = _user_table.login(login_info); // 进行登录验证if (ret == false){DLOG("用户登录失败");return http_resp(conn, false, "用户登录失败", websocketpp::http::status_code::value::bad_request);}// 4. 用户信息验证成功,则给客户端创建sessionuint64_t user_id = login_info["id"].asUInt64();session_ptr psession = _session_manager.create_session(user_id, LOGIN);if (psession.get() == nullptr){DLOG("创建session失败");return http_resp(conn, false, "创建session失败", websocketpp::http::status_code::value::internal_server_error);}_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_TEMOPRARY);// 5. 设置响应头部,Set-Cookie,将session id通过cookie返回std::string cookie_ssid = "SSID=" + std::to_string(psession->get_session_id());conn->append_header("Set-Cookie", cookie_ssid);return http_resp(conn, true, "用户登录成功", websocketpp::http::status_code::value::ok);
}
获取用户信息请求处理函数
// 通过HTTP请求头部字段中的Cookie信息获取session id
bool get_cookie_value(const std::string& cookie, const std::string& key, std::string& value)
{// Cookie: SSID=xxx; key=value; key=value; // 1. 以‘; ’作为间隔,对字符串进行分割,得到各个单个的Cookie信息std::vector<std::string> cookie_arr;string_util::split(cookie, "; ", cookie_arr);// 2. 对单个的cookie字符串以‘=’为间隔进行分割,得到key和valfor (const auto& str : cookie_arr){std::vector<std::string> tmp_arr;string_util::split(str, "=", tmp_arr);if (tmp_arr.size() != 2) continue;if (tmp_arr[0] == key){value = tmp_arr[1];return true;}}return false;
}void info_handler(websocketsvr_t::connection_ptr& conn)
{// 1. 获取HTTP请求中的Cookie字段std::string cookie_str = conn->get_request_header("Cookie");if (cookie_str.empty()){return http_resp(conn, false, "没有Cookie信息", websocketpp::http::status_code::value::bad_request);}// 从Cookie中获取session idstd::string session_id_str;bool ret = get_cookie_value(cookie_str, "SSID", session_id_str);if (ret == false){return http_resp(conn, false, "Cookie中没有session id", websocketpp::http::status_code::value::bad_request);}// 2. 根据session id在session管理中获取对应的sessionsession_ptr psession = _session_manager.get_session_by_session_id(std::stoul(session_id_str));if (psession.get() == nullptr){return http_resp(conn, false, "session已过期", websocketpp::http::status_code::value::bad_request);}// 3. 通过session获取对应的user id,再从数据库中获取用户信息,并序列化返回给客户端uint64_t user_id = psession->get_user_id();Json::Value user_info;ret = _user_table.select_by_id(user_id, user_info);if (ret == false){return http_resp(conn, false, "获取用户信息失败", websocketpp::http::status_code::value::bad_request);}std::string user_info_str;json_util::serialize(user_info, user_info_str);conn->set_status(websocketpp::http::status_code::value::ok);conn->set_body(user_info_str);conn->append_header("Content-Type", "application/json");// 4. 上述操作访问了session,所以要刷新session的过期时间_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_TEMOPRARY);
}
WebSocket长连接建立成功后的处理函数
void wsopen_callback(websocketpp::connection_hdl hdl)
{websocketsvr_t::connection_ptr conn = _wssvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();if (uri == "/hall") // 建立了游戏大厅的长连接{wsopen_game_hall(conn);}else if (uri == "/room") // 建立了游戏房间的长连接{wsopen_game_room(conn);}
}
根据HTTP请求首行中的uri来判断,客户端和服务器建立的是游戏大厅的长连接还是游戏房间的长连接。
用户登录验证函数(登录成功则返回用户session)
session_ptr get_session_by_cookie(websocketsvr_t::connection_ptr conn)
{Json::Value resp;// 1. 获取HTTP请求中的Cookie字段std::string cookie_str = conn->get_request_header("Cookie");if (cookie_str.empty()){resp["optype"] = "hall_ready";resp["result"] = false;resp["reason"] = "没有Cookie信息";websocket_resp(conn, resp);return session_ptr();}// 从Cookie中获取session idstd::string session_id_str;bool ret = get_cookie_value(cookie_str, "SSID", session_id_str);if (ret == false){resp["optype"] = "hall_ready";resp["result"] = false;resp["reason"] = "Cookie中没有session";websocket_resp(conn, resp);return session_ptr();}// 2. 根据session id在session管理中获取对应的sessionsession_ptr psession = _session_manager.get_session_by_session_id(std::stoul(session_id_str));if (psession.get() == nullptr){resp["optype"] = "hall_ready";resp["result"] = false;resp["reason"] = "session已过期";websocket_resp(conn, resp);return session_ptr();}return psession;
}
游戏大厅长连接建立成功的处理函数
void wsopen_game_hall(websocketsvr_t::connection_ptr conn)
{Json::Value resp;// 1. 登录验证(判断当前用户是否登录成功)session_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 判断当前用户是否重复登录if (_user_online.is_in_game_hall(psession->get_user_id()) || _user_online.is_in_game_room(psession->get_user_id())){resp["optype"] = "hall_ready";resp["result"] = false;resp["reason"] = "用户已登录";return websocket_resp(conn, resp);}// 3. 将当前用户和对应的连接加入到游戏大厅_user_online.enter_game_hall(psession->get_user_id(), conn);// 4. 给用户响应游戏大厅建立成功resp["optype"] = "hall_ready";resp["result"] = true;resp["uid"] = (Json::UInt64)psession->get_user_id();websocket_resp(conn, resp);// 5. 将session的生命周期设置为永久_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_PERMANENT);
}
游戏房间长连接建立成功的处理函数
void wsopen_game_room(websocketsvr_t::connection_ptr conn)
{Json::Value resp;// 1. 登录验证(判断当前用户是否登录成功)session_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 判断当前用户是否重复登录if (_user_online.is_in_game_hall(psession->get_user_id()) || _user_online.is_in_game_room(psession->get_user_id())){resp["optype"] = "room_ready";resp["result"] = false;resp["reason"] = "用户已登录";return websocket_resp(conn, resp);}// 3. 判断当前用户是否已经创建好了房间room_ptr proom = _room_manager.get_room_by_user_id(psession->get_user_id());if (proom.get() == nullptr){resp["optype"] = "room_ready";resp["result"] = false;resp["reason"] = "通过用户id获取游戏房间失败";return websocket_resp(conn, resp);}// 4. 将当前用户添加到在线用户管理的游戏房间中的用户管理中_user_online.enter_game_room(psession->get_user_id(), conn);// 5. 给用户响应房间创建完成resp["optype"] = "room_ready";resp["result"] = true;resp["room_id"] = (Json::UInt64)proom->id();resp["uid"] = (Json::UInt64)psession->get_user_id();resp["white_id"] = (Json::UInt64)proom->get_white_player();resp["black_id"] = (Json::UInt64)proom->get_black_player();websocket_resp(conn, resp);// 6. 将session的生命周期设置为永久_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_PERMANENT);
}
WebSocket长连接断开前的处理函数
void wsclose_callback(websocketpp::connection_hdl hdl)
{websocketsvr_t::connection_ptr conn = _wssvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();if (uri == "/hall") // 游戏大厅长连接断开{wscloes_game_hall(conn);}else if (uri == "/room") // 游戏房间长连接断开{wscloes_game_room(conn);}
}
根据HTTP请求首行中的uri来判断,客户端断开的是游戏大厅的长连接还是游戏房间的长连接。
游戏大厅长连接断开的处理函数
void wscloes_game_hall(websocketsvr_t::connection_ptr conn)
{// 1. 获取用户的sessionsession_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 将用户从游戏大厅中移除_user_online.exit_game_hall(psession->get_user_id());// 3. 将session的生命周期设为定时的,超时自动删除_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_TEMOPRARY);
}
游戏房间长连接断开的处理函数
void wscloes_game_room(websocketsvr_t::connection_ptr conn)
{// 1. 获取用户的sessionsession_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 将玩家从在线用户管理中的游戏房间中的玩家中移除_user_online.exit_game_room(psession->get_user_id());// 3. 将玩家从游戏房间中移除(房间中所有玩家都退出了就会销毁房间)_room_manager.remove_player_in_room(psession->get_user_id());// 4. 将session的生命周期设置为定时的,超时自动销毁_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_TEMOPRARY);
}
WebSocket长连接通信处理函数
void wsmsg_callback(websocketpp::connection_hdl hdl, websocketsvr_t::message_ptr msg)
{websocketsvr_t::connection_ptr conn = _wssvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();if (uri == "/hall") // 游戏大厅请求{wsmsg_game_hall(conn, msg); // 游戏大厅请求处理函数}else if (uri == "/room") // 游戏房间请求{wsmsg_game_room(conn, msg); // 游戏房间请求处理函数}
}
根据HTTP请求首行中的uri来判断,当前请求是游戏大厅中的请求还是游戏房间中的请求。
游戏大厅请求处理函数(游戏匹配请求/停止匹配请求)
void wsmsg_game_hall(websocketsvr_t::connection_ptr conn, websocketsvr_t::message_ptr msg)
{Json::Value resp;// 1. 获取用户的sessionsession_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 获取WebSocket请求信息std::string req_str = msg->get_payload();Json::Value req;bool ret = json_util::unserialize(req_str, req);if (ret == false){resp["result"] = false;resp["reason"] = "解析请求失败";return websocket_resp(conn, resp);}// 3. 对于请求分别进行处理if (!req["optype"].isNull() && req["optype"].asString() == "match_start"){// 开始游戏匹配:通过匹配模块,将玩家添加到匹配队列中_match_manager.add(psession->get_user_id());resp["optype"] = "match_start";resp["result"] = true;return websocket_resp(conn, resp);}else if (!req["optype"].isNull() && req["optype"].asString() == "match_stop"){// 停止游戏匹配:通过匹配模块,将玩家从匹配队列中移除_match_manager.del(psession->get_user_id());resp["optype"] = "match_stop";resp["result"] = true;return websocket_resp(conn, resp);}resp["optype"] = "unknown";resp["result"] = false;resp["reason"] = "未知请求类型";return websocket_resp(conn, resp);
}
游戏房间请求处理函数(下棋请求/聊天请求)
void wsmsg_game_room(websocketsvr_t::connection_ptr conn, websocketsvr_t::message_ptr msg){Json::Value resp;// 1. 获取用户的sessionsession_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 获取用户所在的游戏房间信息room_ptr proom = _room_manager.get_room_by_user_id(psession->get_user_id());if (proom.get() == nullptr){resp["optype"] = "unknown";resp["result"] = false;resp["reason"] = "通过用户id获取游戏房间失败";return websocket_resp(conn, resp);}// 3. 对请求进行反序列化std::string req_str = msg->get_payload();Json::Value req;bool ret = json_util::unserialize(req_str, req);if (ret == false){resp["optype"] = "unknown";resp["result"] = false;resp["reason"] = "解析请求失败";return websocket_resp(conn, resp);}// 4. 通过游戏房间进行游戏房间请求的处理return proom->handle_request(req);}
| 服务器类所有函数整合 |
#pragma once#include <string>
#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>
#include <vector>#include "db.hpp"
#include "online.hpp"
#include "room.hpp"
#include "matcher.hpp"
#include "session.hpp"#define WWWROOT "./wwwroot/"class gobang_server
{
public:// 进行成员变量初始化,以及设置服务器回调函数gobang_server(const std::string& host,const std::string& user,const std::string& password,const std::string& dbname,uint32_t port = 3306,const std::string& wwwroot = WWWROOT):_web_root(wwwroot), _user_table(host, user, password, dbname, port), _room_manager(&_user_table, &_user_online), _match_manager(&_room_manager, &_user_table, &_user_online), _session_manager(&_wssvr){_wssvr.set_access_channels(websocketpp::log::alevel::none);_wssvr.init_asio();_wssvr.set_reuse_addr(true);_wssvr.set_open_handler(std::bind(&gobang_server::wsopen_callback, this, std::placeholders::_1));_wssvr.set_close_handler(std::bind(&gobang_server::wsclose_callback, this, std::placeholders::_1));_wssvr.set_message_handler(std::bind(&gobang_server::wsmsg_callback, this, std::placeholders::_1, std::placeholders::_2));_wssvr.set_http_handler(std::bind(&gobang_server::http_callback, this, std::placeholders::_1));}// 启动服务器void start(uint16_t port){_wssvr.listen(port);_wssvr.start_accept();_wssvr.run();}private:// WebSocket长连接建立成功后的处理函数void wsopen_callback(websocketpp::connection_hdl hdl){websocketsvr_t::connection_ptr conn = _wssvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();if (uri == "/hall") // 建立了游戏大厅的长连接{wsopen_game_hall(conn);}else if (uri == "/room") // 建立了游戏房间的长连接{wsopen_game_room(conn);}}// WebSocket长连接断开前的处理函数void wsclose_callback(websocketpp::connection_hdl hdl){websocketsvr_t::connection_ptr conn = _wssvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();if (uri == "/hall") // 游戏大厅长连接断开{wscloes_game_hall(conn);}else if (uri == "/room") // 游戏房间长连接断开{wscloes_game_room(conn);}}// WebSocket长连接通信处理void wsmsg_callback(websocketpp::connection_hdl hdl, websocketsvr_t::message_ptr msg){websocketsvr_t::connection_ptr conn = _wssvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();if (uri == "/hall") // 游戏大厅请求{wsmsg_game_hall(conn, msg); // 游戏大厅请求处理函数}else if (uri == "/room") // 游戏房间请求{wsmsg_game_room(conn, msg); // 游戏房间请求处理函数}}void http_callback(websocketpp::connection_hdl hdl){websocketsvr_t::connection_ptr conn = _wssvr.get_con_from_hdl(hdl);websocketpp::http::parser::request req = conn->get_request();std::string method = req.get_method();std::string uri = req.get_uri();if (method == "POST" && uri == "/signup"){signup_handler(conn);}else if (method == "POST" && uri == "/login"){login_handler(conn);}else if (method == "GET" && uri == "/userinfo"){info_handler(conn);}else{file_handler(conn);}}private:// 组织HTTP响应void http_resp(websocketsvr_t::connection_ptr& conn, bool result, const std::string& reason, websocketpp::http::status_code::value code){Json::Value resp;resp["result"] = result;resp["reason"] = reason;std::string resp_str;json_util::serialize(resp, resp_str);conn->set_status(code);conn->set_body(resp_str);conn->append_header("Content-Type", "application/json");}// 组织WebSocket响应void websocket_resp(websocketsvr_t::connection_ptr& conn, Json::Value& resp){std::string resp_str;json_util::serialize(resp, resp_str);conn->send(resp_str);}// 静态资源请求的处理void file_handler(websocketsvr_t::connection_ptr& conn){// 1.获取http请求的uri -- 资源路径,了解客户端请求的页面文件名称websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();// 2.组合出文件实际的路径(相对根目录 + uri)std::string real_path = _web_root + uri;// 3.如果请求的uri是个目录,则增加一个后缀 -- login.htmlif (real_path.back() == '/') // 表示请求资源路径是一个目录{real_path += "login.html";}// 4.读取文件内容;若文件不存在,则返回404std::string body;bool ret = file_util::read(real_path, body);if (ret == false){body += "<html>";body += "<head>";body += "<meta charset='UTF-8'/>";body += "</head>";body += "<body>";body += "<h1> Not Found </h1>";body += "</body>";conn->set_status(websocketpp::http::status_code::value::not_found);conn->set_body(body);return;}// 5.设置响应正文conn->set_status(websocketpp::http::status_code::value::ok);conn->set_body(body);return;}// 用户注册请求的处理void signup_handler(websocketsvr_t::connection_ptr& conn){// 1. 获取HTTP请求正文std::string req_body = conn->get_request_body();// 2. 对HTTP请求正文进行反序列化得到用户名和密码Json::Value signup_info;bool ret = json_util::unserialize(req_body, signup_info);if (ret == false){DLOG("反序列化注册信息失败");return http_resp(conn, false, "用户注册失败", websocketpp::http::status_code::value::bad_request);}// 3. 进行数据库的用户新增操作(成功返回200,失败返回400)if (signup_info["username"].isNull() || signup_info["password"].isNull()){DLOG("缺少用户名或密码");return http_resp(conn, false, "缺少用户名或密码", websocketpp::http::status_code::value::bad_request);}ret = _user_table.signup(signup_info); // 在数据库中新增用户if (ret == false){DLOG("向数据库中添加用户失败");return http_resp(conn, false, "用户注册失败", websocketpp::http::status_code::value::bad_request);}// 用户注册成功,返回成功响应return http_resp(conn, true, "用户注册成功", websocketpp::http::status_code::value::ok);}// 用户登录请求的处理void login_handler(websocketsvr_t::connection_ptr& conn){// 1. 获取HTTP请求正文std::string req_body = conn->get_request_body();// 2. 对HTTP请求正文进行反序列化得到用户名和密码Json::Value login_info;bool ret = json_util::unserialize(req_body, login_info);if (ret == false){DLOG("反序列化登录信息失败");return http_resp(conn, false, "用户登录失败", websocketpp::http::status_code::value::bad_request);}// 3. 校验正文完整性,进行数据库的用户信息验证(失败返回400)if (login_info["username"].isNull() || login_info["password"].isNull()){DLOG("缺少用户名或密码");return http_resp(conn, false, "缺少用户名或密码", websocketpp::http::status_code::value::bad_request);}ret = _user_table.login(login_info); // 进行登录验证if (ret == false){DLOG("用户登录失败");return http_resp(conn, false, "用户登录失败", websocketpp::http::status_code::value::bad_request);}// 4. 用户信息验证成功,则给客户端创建sessionuint64_t user_id = login_info["id"].asUInt64();session_ptr psession = _session_manager.create_session(user_id, LOGIN);if (psession.get() == nullptr){DLOG("创建session失败");return http_resp(conn, false, "创建session失败", websocketpp::http::status_code::value::internal_server_error);}_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_TEMOPRARY);// 5. 设置响应头部,Set-Cookie,将session id通过cookie返回std::string cookie_ssid = "SSID=" + std::to_string(psession->get_session_id());conn->append_header("Set-Cookie", cookie_ssid);return http_resp(conn, true, "用户登录成功", websocketpp::http::status_code::value::ok);}// 通过HTTP请求头部字段中的Cookie信息获取session idbool get_cookie_value(const std::string& cookie, const std::string& key, std::string& value){// Cookie: SSID=xxx; key=value; key=value; // 1. 以‘; ’作为间隔,对字符串进行分割,得到各个单个的Cookie信息std::vector<std::string> cookie_arr;string_util::split(cookie, "; ", cookie_arr);// 2. 对单个的cookie字符串以‘=’为间隔进行分割,得到key和valfor (const auto& str : cookie_arr){std::vector<std::string> tmp_arr;string_util::split(str, "=", tmp_arr);if (tmp_arr.size() != 2) continue;if (tmp_arr[0] == key){value = tmp_arr[1];return true;}}return false;}// 获取用户信息请求的处理void info_handler(websocketsvr_t::connection_ptr& conn){// 1. 获取HTTP请求中的Cookie字段std::string cookie_str = conn->get_request_header("Cookie");if (cookie_str.empty()){return http_resp(conn, false, "没有Cookie信息", websocketpp::http::status_code::value::bad_request);}// 从Cookie中获取session idstd::string session_id_str;bool ret = get_cookie_value(cookie_str, "SSID", session_id_str);if (ret == false){return http_resp(conn, false, "Cookie中没有session id", websocketpp::http::status_code::value::bad_request);}// 2. 根据session id在session管理中获取对应的sessionsession_ptr psession = _session_manager.get_session_by_session_id(std::stoul(session_id_str));if (psession.get() == nullptr){return http_resp(conn, false, "session已过期", websocketpp::http::status_code::value::bad_request);}// 3. 通过session获取对应的user id,再从数据库中获取用户信息,并序列化返回给客户端uint64_t user_id = psession->get_user_id();Json::Value user_info;ret = _user_table.select_by_id(user_id, user_info);if (ret == false){return http_resp(conn, false, "获取用户信息失败", websocketpp::http::status_code::value::bad_request);}std::string user_info_str;json_util::serialize(user_info, user_info_str);conn->set_status(websocketpp::http::status_code::value::ok);conn->set_body(user_info_str);conn->append_header("Content-Type", "application/json");// 4. 上述操作访问了session,所以要刷新session的过期时间_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_TEMOPRARY);}// 用于验证用户是否登录成功,登录成功则返回用户sessionsession_ptr get_session_by_cookie(websocketsvr_t::connection_ptr conn){Json::Value resp;// 1. 获取HTTP请求中的Cookie字段std::string cookie_str = conn->get_request_header("Cookie");if (cookie_str.empty()){resp["optype"] = "hall_ready";resp["result"] = false;resp["reason"] = "没有Cookie信息";websocket_resp(conn, resp);return session_ptr();}// 从Cookie中获取session idstd::string session_id_str;bool ret = get_cookie_value(cookie_str, "SSID", session_id_str);if (ret == false){resp["optype"] = "hall_ready";resp["result"] = false;resp["reason"] = "Cookie中没有session";websocket_resp(conn, resp);return session_ptr();}// 2. 根据session id在session管理中获取对应的sessionsession_ptr psession = _session_manager.get_session_by_session_id(std::stoul(session_id_str));if (psession.get() == nullptr){resp["optype"] = "hall_ready";resp["result"] = false;resp["reason"] = "session已过期";websocket_resp(conn, resp);return session_ptr();}return psession;}// 游戏大厅长连接建立成功的处理函数void wsopen_game_hall(websocketsvr_t::connection_ptr conn){Json::Value resp;// 1. 登录验证(判断当前用户是否登录成功)session_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 判断当前用户是否重复登录if (_user_online.is_in_game_hall(psession->get_user_id()) || _user_online.is_in_game_room(psession->get_user_id())){resp["optype"] = "hall_ready";resp["result"] = false;resp["reason"] = "用户已登录";return websocket_resp(conn, resp);}// 3. 将当前用户和对应的连接加入到游戏大厅_user_online.enter_game_hall(psession->get_user_id(), conn);// 4. 给用户响应游戏大厅建立成功resp["optype"] = "hall_ready";resp["result"] = true;resp["uid"] = (Json::UInt64)psession->get_user_id();websocket_resp(conn, resp);// 5. 将session的生命周期设置为永久_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_PERMANENT);}// 游戏房间长连接建立成功的处理函数void wsopen_game_room(websocketsvr_t::connection_ptr conn){Json::Value resp;// 1. 登录验证(判断当前用户是否登录成功)session_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 判断当前用户是否重复登录if (_user_online.is_in_game_hall(psession->get_user_id()) || _user_online.is_in_game_room(psession->get_user_id())){resp["optype"] = "room_ready";resp["result"] = false;resp["reason"] = "用户已登录";return websocket_resp(conn, resp);}// 3. 判断当前用户是否已经创建好了房间room_ptr proom = _room_manager.get_room_by_user_id(psession->get_user_id());if (proom.get() == nullptr){resp["optype"] = "room_ready";resp["result"] = false;resp["reason"] = "通过用户id获取游戏房间失败";return websocket_resp(conn, resp);}// 4. 将当前用户添加到在线用户管理的游戏房间中的用户管理中_user_online.enter_game_room(psession->get_user_id(), conn);// 5. 给用户响应房间创建完成resp["optype"] = "room_ready";resp["result"] = true;resp["room_id"] = (Json::UInt64)proom->id();resp["uid"] = (Json::UInt64)psession->get_user_id();resp["white_id"] = (Json::UInt64)proom->get_white_player();resp["black_id"] = (Json::UInt64)proom->get_black_player();websocket_resp(conn, resp);// 6. 将session的生命周期设置为永久_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_PERMANENT);}// 游戏大厅长连接断开的处理函数void wscloes_game_hall(websocketsvr_t::connection_ptr conn){// 1. 获取用户的sessionsession_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 将用户从游戏大厅中移除_user_online.exit_game_hall(psession->get_user_id());// 3. 将session的生命周期设为定时的,超时自动删除_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_TEMOPRARY);}// 游戏房间长连接断开的处理函数void wscloes_game_room(websocketsvr_t::connection_ptr conn){// 1. 获取用户的sessionsession_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 将玩家从在线用户管理中的游戏房间中的玩家中移除_user_online.exit_game_room(psession->get_user_id());// 3. 将玩家从游戏房间中移除(房间中所有玩家都退出了就会销毁房间)_room_manager.remove_player_in_room(psession->get_user_id());// 4. 将session的生命周期设置为定时的,超时自动销毁_session_manager.set_session_expiration_time(psession->get_session_id(), SESSION_TEMOPRARY);}// 游戏大厅请求处理函数(游戏匹配请求/停止匹配请求)void wsmsg_game_hall(websocketsvr_t::connection_ptr conn, websocketsvr_t::message_ptr msg){Json::Value resp;// 1. 获取用户的sessionsession_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 获取WebSocket请求信息std::string req_str = msg->get_payload();Json::Value req;bool ret = json_util::unserialize(req_str, req);if (ret == false){resp["result"] = false;resp["reason"] = "解析请求失败";return websocket_resp(conn, resp);}// 3. 对于请求分别进行处理if (!req["optype"].isNull() && req["optype"].asString() == "match_start"){// 开始游戏匹配:通过匹配模块,将玩家添加到匹配队列中_match_manager.add(psession->get_user_id());resp["optype"] = "match_start";resp["result"] = true;return websocket_resp(conn, resp);}else if (!req["optype"].isNull() && req["optype"].asString() == "match_stop"){// 停止游戏匹配:通过匹配模块,将玩家从匹配队列中移除_match_manager.del(psession->get_user_id());resp["optype"] = "match_stop";resp["result"] = true;return websocket_resp(conn, resp);}resp["optype"] = "unknown";resp["result"] = false;resp["reason"] = "未知请求类型";return websocket_resp(conn, resp);}// 游戏房间请求处理函数(下棋请求/聊天请求)void wsmsg_game_room(websocketsvr_t::connection_ptr conn, websocketsvr_t::message_ptr msg){Json::Value resp;// 1. 获取用户的sessionsession_ptr psession = get_session_by_cookie(conn);if (psession.get() == nullptr) return;// 2. 获取用户所在的游戏房间信息room_ptr proom = _room_manager.get_room_by_user_id(psession->get_user_id());if (proom.get() == nullptr){resp["optype"] = "unknown";resp["result"] = false;resp["reason"] = "通过用户id获取游戏房间失败";return websocket_resp(conn, resp);}// 3. 对请求进行反序列化std::string req_str = msg->get_payload();Json::Value req;bool ret = json_util::unserialize(req_str, req);if (ret == false){resp["optype"] = "unknown";resp["result"] = false;resp["reason"] = "解析请求失败";return websocket_resp(conn, resp);}// 4. 通过游戏房间进行游戏房间请求的处理return proom->handle_request(req);}private:std::string _web_root; // 静态资源根目录(./wwwroot/) 请求的url为 /register.html,会自动将url拼接到_web_root后,即./wwwroot/register.htmlwebsocketsvr_t _wssvr; // WebSocket服务器user_table _user_table; // 用户数据管理模块online_manager _user_online; // 在线用户管理模块room_manager _room_manager; // 游戏房间管理模块match_manager _match_manager; // 游戏匹配管理模块session_manager _session_manager; // session管理模块
};
项目做到这里已经实现了实时聊天的在线匹配五子棋对战游戏的基本功能了。但是每次启动游戏服务器都需要连接Linux服务器主机,在终端上运行该程序,才能运行该游戏服务器,不算真正意义上的网络游戏。
为了实现将程序脱离主机连接和手动启动服务器,我们要将该程序进行守护进程化。
守护进程化
守护进程是一种在后台运行的服务进程,没有控制终端,因此它们独立于任何用户会话。
在Linux中,daemon()用于将一个进程变成守护进程。
int daemon(int nochdir, int noclose);
参数说明:
nochdir ---- 如果这个参数为0,daemon()会将当前工作目录更改为根目录,这是因为守护进程不应该与某个具体的文件系统挂载点关联。如果你不希望改变当前工作目录,可以将这个参数设置为非零值
noclose ---- 如果这个参数为0,daemon()会将标准输入、标准输出和标准错误重定向到 /dev/null,这意味着守护进程不会在终端上产生输出。如果你希望保持这些文件描述符不变,可以将这个参数设置为非零值。
返回值:
返回0表示成功,返回-1表示失败,并设置errno以指示错误原因
由于要将程序设置为守护进程,则先前输出在终端上的日志信息需要写入一个文件中!
在src目录下创建一个log.txt文件,将日志信息写入到log.txt中。
修改日志宏以达到上述要求👇:
#pragma once#include <iostream>
#include <ctime>
#include <cstdio>#define INF 0
#define DBG 1
#define ERR 2
#define DEFAULT_LOG_LEVEL INF#define LOG(level, format, ...) \do \{ \if (DEFAULT_LOG_LEVEL > level) \break; \time_t t = time(NULL); \struct tm *lt = localtime(&t); \char buf[32] = {0}; \strftime(buf, 31, "%H:%M:%S", lt); \FILE* pf_log = fopen("./log.txt", "a"); \if (pf_log) \{ \fprintf(pf_log, "[%s %s:%d] " format "\n", buf, __FILE__, __LINE__, ##__VA_ARGS__); \fclose(pf_log); \} \else \{ \fprintf(stderr, "Failed to open log file\n"); \} \} while (false)#define ILOG(format, ...) LOG(INF, format, ##__VA_ARGS__)
#define DLOG(format, ...) LOG(DBG, format, ##__VA_ARGS__)
#define ELOG(format, ...) LOG(ERR, format, ##__VA_ARGS__)
使用fopen()打开log.txt,再调用fprintf(),将日志信息格式化写入log.txt中。
创建一个online_gobang.cpp文件,文件中实例化一个服务器类(gobang_server)对象,并运行服务器。
#include <unistd.h>#include "server.hpp"int main()
{// 将游戏服务器设置为守护进程if (daemon(1, 0) == -1) {perror("daemon");exit(EXIT_FAILURE);}gobang_server server("127.0.0.1", "root", "", "online_gobang", 3306);server.start(8080);return 0;
}
在main()中的开头调用daemon(),将该程序设置为守护进程。
注意不要改变程序的工作目录,因为程序中调用的前端资源和log.txt都在同级目录下。
在终端下运行编译生成的可执行程序即是将游戏服务器运行起来了!
在浏览器中访问 → 主机id:目标端口号,即可体验在线五子棋对战游戏了!
项目源码
项目完整源代码👉https://github.com/NICK03nK/Project/tree/main/Gobang_online_AG