当前位置: 首页 > news >正文

项目--五子棋(模块实现)

项目结构设计

主模块划分说明

本项目实现主要划分为三个大模块来进行:

  • 数据管理模块: 基于 MySQL 数据库对用户的数据进行管理。包括用户名/密码、天梯分数、获胜场次、对战场次等信息。
  • 前端页面模块: 基于 JS 实现前端页面的动态控制以及与服务器的通信。主要有注册、登录、游戏大厅、游戏房间等页面。详情查看
  • 业务处理页面: 搭建 WebSocket 服务器与客户端(浏览器)进行通信,接收请求并进行相对应的业务处理,最后返回结果。

大致过程如下:

  1. 客户端浏览器向服务器发送一个用户注册页面请求
  2. 服务器接收到请求之后,从前端模块中获取注册页面(静态)并响应给客户端。
  3. 客户端向服务器发送一个用户注册请求。将注册的用户名和密码发送给服务器。
  4. 服务器收到注册请求后,将请求中的数据交给 数据管理 模块进行存储管理在MySQL数据库中。然后服务器响应200状态码。
  5. 客户端向服务器发送一个用户登录页面请求
  6. 服务器接收到登录页面请求后,从前端模块中获取登录页面并响应给客户端。
  7. 客户端接收到登录页面后,输入用户名和密码进行登录。然后向服务器发送一个用户登录请求,发送用户名和密码发送给服务器。
  8. 服务器接收到登录请求后,将数据交给 数据管理 模块进行验证数据是否与数据库存储的相一致:
    • 验证成功,服务器响应验证成功信息,用户可以执行后续操作。
    • 验证失败,服务器响应验证失败信息。
  9. 客户端向服务器发送一个游戏大厅页面请求
  10. 服务器收到后,从前端模块获取游戏大厅的页面并响应给客户端。 在该页面中,会展示用户的一些信息,这些信息是从 数据管理 模块获取的。
  11. 客户端会发起一个对战请求,然后服务器会根据积分从多个对战请求的客户端中选择合适的用户进行匹配对战。

在这里插入图片描述

业务处理模块的子模块划分

服务器是用来完成客户端想要实现的业务的,而业务是多种多样的,如用户的注册、用户的登录、静态页面的获取、实时对战的匹配、对战的过程、聊天的过程……所以还需要对业务模块进行细分,从而应对不同的业务。

  • 网络通信模块: 基于 WebSocketpp 库实现 HTTP&WebSocket 服务器的搭建,提供网络通信的功能,以此接收来自客户端的请求。
  • 会话管理模块: 对客户端的连接进行 cookie&session 管理,实现客户端的身份认证功能。(因为HTTP 无状态特性,会造成服务器无法识别 用户登陆时使用的连接进入游戏大厅时使用的连接是否属于同一个用户。如果该用户曾经没有登陆过,而是直接发送一个进入游戏大厅的请求连接,服务器是不允许该用户进入游戏大厅的,只有曾经登录过的用户发起进入游戏大厅的请求,才允许进入游戏大厅。所以,服务器需要给每一个登陆成功的用户连接创建一个会话,这个会话中包含了用户的信息,并且将session id通过cookie 交给客户端,客户端在下次发送请求时,需要携带 session id,这样服务器在收到登录请求之后的请求连接时,都需要先获取 session id并通过会话管理模块查找到该session id对应哪个用户,进而判断该连接的用户曾经是否登录成功过)。
  • 在线用户管理模块: 对进入游戏大厅与游戏房间中的用户进行管理,提供用户是否在线以及获取用户连接的功能。(进入游戏大厅的用户,会使用 WebSocket 协议与服务器进行长连接通信,用户就有了一个固定的连接,后续的请求(下棋请求、聊天请求)使用的都是同一个连接来完成的。将这些用户的长连接进行管理后,当服务器接收到一个下棋请求/聊天请求后,需要验证当前连接的用户是否是在线用户,只有在线用户服务器才会处理请求。)
  • 房间管理模块: 为匹配成功的用户创建对战房间,提供实时的五子棋对战与聊天业务功能。(这是一个小范围的用户管理,当一个用户发起下棋请求时,服务器会将该用户的走起位置广播给该房间内所用的用户,这样所有的用户就能知道落棋的位置。聊天也是同样的道理~)
  • 用户匹配模块: 根据天梯分数不同,将玩家划分为不同层次,相同层次的玩家可以匹配对战。同时为匹配成功的玩家创建房间并加入房间。

项目流程图

玩家用户角度的流程图

在这里插入图片描述

服务器角度的流程图

以下列出的是动态页面的请求,对于客户端静态页面的请求没有列出:
在这里插入图片描述
会话中主要保存了用户的账户信息,在进入游戏大厅之后的请求都需要进行验证,判断服务器是否存在该登录用户,之后登录后的用户才能提供服务。

实用工具类模块代码实现

要实现的工具模块有以下几个:

  1. 日志宏: 用于程序调试日志的打印。
  2. mysql_uitl: 用户数据库的连接和初始化、语句的执行、句柄的销毁等操作。
  3. json_util: 用于对JSON的序列化和反序列化。
  4. string_util: 实现对字符串的分割功能。
  5. file_util: 封装了对文件数据的读取功能(网页在后台保存的就是 HTML 文件,将读取到文件的数据发送给浏览器,进行渲染)。

日志宏模块

前置知识,可以参考这篇博客:日志模块的简单封装

这这篇博客中,主要借助函数LogMessage()完成对日志信息的拼接。我们还可以使用宏函数来实现:

#define INF 0
#define DBG 1
#define ERR 2
#define LOG_LEVEL DBG#define LOG(format, ...)                                                                        \do                                                                                          \{                                                                                           \if (level < LOG_LEVEL)                                                                  \break;                                                                              \time_t t = time(NULL);                                                                  \struct tm *lt = localtime(&t);                                                          \char buffer[128] = {0};                                                                 \strftime(buffer, sizeof(buffer) - 1, "%H:%M:%S", lt);                                   \fprintf(stdout, "[%s %s : %d]" format "\n", buffer, __FILE__, __LINE__, ##__VA_ARGS__); \} while (0)#define INF_LOG(format, ...) LOG(INF, format, ##__VAR_ARGS__)
#define DBG_LOG(format, ...) LOG(DBG, format, ##__VAR_ARGS__)
#define ERR_LOG(format, ...) LOG(ERR, format, ##__VAR_ARGS__)

MySQL API模块

在本项目中,需要使用MySQL对用户数据进行持久化存储,比如用户的账号和密码、用户的对战场景……

为了方便我们操作MySQL,可以对MySQL官方提供的API进一步封装。

前置知识中,提到过MySQL操作一般有如下步骤:

  1. 初始化MySQL句柄
  2. 连接MySQL服务器
  3. 设置客户端字符集
  4. 选择你要操作的数据库
  5. 执行SQL语句
  6. 将结果集保存到本地
  7. 获取结果集的行列数,并遍历处理结果
  8. 释放结果集
  9. 释放MySQL句柄
  • 这里的封装将①②③④步骤封装为一个函数MYSQL* mysql_create(),参数为 主机号、端口号、用户名、密码、数据库名。
  • 将⑤封装为一个函数bool mysql_exec(MYSQL* mysql, const std::string& sql)
  • 其中⑥⑦依赖于数据的处理操作,不能进行封装。
  • ⑨封装为一个函数void mysql_destroy(MYSQL* mysql)
class mysql_util
{
public:// 创建MySQL:进行初始化、连接服务器、设置字符集、选择数据库操作static MYSQL *mysql_create(const std::string& host, const std::string& user, const std::string& passwd, const std::string& dbname, uint16_t port = 3306){MYSQL *mysql = mysql_init(nullptr);if (mysql == nullptr){LOG(ERROR, "mysql_init failed!: %s", mysql_error(mysql));return nullptr;}MYSQL *ret = mysql_real_connect(mysql, host.c_str(), user.c_str(), passwd.c_str(), dbname.c_str(), port, NULL, 0);if (ret == nullptr){LOG(ERROR, "mysql connect failed!: %s", mysql_error(mysql));mysql_close(mysql);return nullptr;}if (mysql_set_character_set(mysql, "utf8") != 0){LOG(ERROR, "mysql set character_set failed!: %s", mysql_error(mysql));mysql_close(mysql);return nullptr;}return mysql;}// 执行SQL语句static bool mysql_exec(MYSQL *mysql, const std::string &sql){if (mysql_query(mysql, sql.c_str()) != 0){LOG(ERROR, "%s, mysql exec failed: %s", sql.c_str(), mysql_error(mysql));mysql_close(mysql);return false;}return true;}// 释放MySQL句柄static void mysql_destroy(MYSQL *mysql){if(mysql != nullptr){mysql_close(mysql);}}
};

Json 模块

这一模块是用来辅助完成数据的序列化和反序列化的,本项目是使用 Jsoncpp 库来完成序列化和反序列化,因此这一模块就是对 Jsoncpp 接口的进一步封装。

class json_util
{
public:static bool serialize(const Json::Value& root, std::string& str){Json::StreamWriterBuilder sw;std::unique_ptr<Json::StreamWriter> writer(sw.newStreamWriter());std::stringstream ss;int ret = writer->write(root, &ss);if(ret != 0){LOG(ERROR, "json serialize failed!");return false;}str = ss.str();return true;}static bool unserialize(Json::Value* root, const std::string& str){Json::CharReaderBuilder crb;std::unique_ptr<Json::CharReader> reader(crb.newCharReader());std::string err;bool ret = reader->parse(str.c_str(), str.c_str() + str.size(), root, &err);if (!ret){LOG(ERROR, "json unserialize failed!");return false;}return true;}
};

字符串分割

class string_util
{
public:static int split(const std::string& src, const std::string& sep, std::vector<std::string>& ret){size_t pos, idx = 0;while(idx < src.size()){pos = src.find(sep, idx);if (pos == std::string::npos){// 此时剩余字符串中已经没有分隔符了ret.push_back(src.substr(idx));break;}else{// 为了避免idx == pos时,插入空串(连续的分隔符时)if(idx != pos)ret.push_back(src.substr(idx, pos - idx)); // 将分隔符之前的字符串添加到结果中idx = pos + sep.size();}}return ret.size();}
};

文件

在本项目中,涉及到文件的读写操作,如服务器需要读取 .html 文件,将其中的html语法返回给浏览器,这样在浏览器才可以渲染出页面。

class file_util
{
public:static bool read(const std::string& filename, std::string& body){// 打开文件(以二进制形式打开)std::ifstream ifs(filename, std::ios::binary);if(ifs.is_open() == false){LOG(ERROR, "open file(%s) failed!", filename.c_str());return false;}// 获取文件大小(指针偏移量)ifs.seekg(0, std::ios::end);size_t fsize = ifs.tellg();ifs.seekg(0, std::ios::beg);// 读取文件内容到body中body.resize(fsize);ifs.read(&body[0], fsize);if(ifs.good() == false){LOG(ERROR, "read file(%s) failed!", filename.c_str());ifs.close();return false;}// 关闭文件ifs.close();return true;}
};

数据管理模块

数据管理模块主要负责对数据库中的数据进行统一的增删查改管理,其他模块要对数据操作都必须通过数据管理模块完成。

本质就是实现一个MySQL客户端来访问服务器对数据进行操作。

数据库/表的设计

由于本项目中,只要存储用户的相关信息,在一个数据库和一张表中就可以满足需求。

表中需要记录用户的一些信息:

  • id方便管理所有用户的信息,并且将该字段设置为主键和自增。(也是为了快速查找)
  • 用户的登录信息: 用户名和密码,将其设置为 vachar(32)类型并且为非空数据,用户名还需要设置为uinque key
  • 用户的游戏数据: 天梯分数、总对战场次、获胜对战场次,将其设置为int类型。
# db.sql
create database if not exists gobang;use gobang;create tableif not exists user (id int primary key auto_increment,username varchar(32) unique key not null,password varchar(32) not null,ladder_score int,total_count int,win_count int);

使用mysql -uroot -p < db.sql即可创建数据库和数据表。

模块类的设计

这里为了后续扩展项目,我们使用一个类管理一张表,这样在后续我们在数据库中添加新表时,只需要实现一个新类即可完成对这张表的操作。

如果我们想对某个表的数据进行操作时,需要实例化该类的对象,通过该对象的成员方法进而操作数据。

类的成员变量

  • 因为要对数据库进行操作,要有一个MySQL句柄
  • 对数据库表的操作可能涉及多线程,在调用多个MySQL API时,需要保证线程安全,因此需要一个互斥锁

对于使用互斥锁的详细原因:

一、数据库表操作与多线程

在游戏开发或其他涉及数据库操作的应用程序中,经常需要对数据库表进行操作。为了提高性能和并发处理能力,会使用多线程技术,让多个操作可以同时进行。例如,一个游戏服务器可能会同时处理多个玩家的请求,如查询玩家的信息、更新玩家的分数、保存玩家的游戏记录等。

二、MySQL API 与线程安全

MySQL 提供了一系列的 API 来操作数据库,如执行 SQL 语句、获取结果集、更新数据等。通常,一个 MySQL API 自身是线程安全的,这意味着在多线程环境下,一个线程调用该 API 不会影响其他线程对该 API 的使用。但是,当涉及到多个 API 的组合使用时,可能会出现线程安全问题。

三、互斥锁的需求

  • 假设有两个线程都创建了一个类对象,并且它们都使用同一个 MySQL 句柄(可以理解为连接到同一个数据库)来操作数据。
  • 查询线程:这个线程使用对象执行 SQL 语句来查询数据库中的数据。它首先会执行一个 SQL 查询API,然后会使用另一个 API 来获取结果集。在执行 SQL 查询语句和获取结果集之间,存在一个时间窗口,在此期间,查询操作并没有完成。
  • 插入线程:同时,另一个线程使用同一个类对象执行 SQL 语句向数据库中插入数据。

四、可能出现的问题

  • 当查询线程执行完 SQL 语句查询数据库后,还未获取结果集时,插入线程开始执行 SQL 语句插入数据。此时,由于数据库的操作是共享资源,插入操作可能会影响查询操作的结果。例如,插入操作可能会改变查询操作所期望的结果集,或者导致查询操作获取到的数据不准确。
  • 更严重的是,由于查询线程还未完成整个查询操作,插入线程的操作可能会导致查询线程的操作出现异常,例如,查询线程可能会被阻塞,因为数据库引擎可能需要等待插入操作完成后才能继续进行查询操作的后续步骤,或者数据库引擎可能会对资源进行重新分配和调度,导致查询线程无法继续执行。

五、互斥锁的作用

  • 为了避免上述问题,需要引入互斥锁(Mutex)。互斥锁的作用是保证在同一时间内,只有一个线程可以访问共享的数据库操作资源。
  • 当查询线程开始执行查询操作时,它会先获取互斥锁,这意味着在查询线程完成整个查询操作(包括执行 SQL 语句和获取结果集)之前,其他线程无法获取该锁,从而无法进行插入或其他修改数据库的操作。只有当查询线程释放了互斥锁后,插入线程才能获取锁并进行插入操作。这样可以确保数据库操作的原子性,避免不同线程之间的操作相互干扰,保证了数据的一致性和操作的正确性。

类的成员方法

  • 构造函数和析构函数:构造函数内部调用前面封装好的mysql_create函数,来连接数据库;析构函数就是来释放我们使用的资源,避免资源泄露。
  • insert():完成注册时新增用户。需要提供用户的用户名和密码,这里可以使用Json::Value来接收参数。这样传递参数时,只需要先将参数打包成一个Json::Value对象,然后传递这一个对象,使得函数参数列表简洁明了。在函数内部检查要注册的用户是否存在(查表操作),如果存在直接返回,不存在就向表中插入一条记录。
  • login():完成登录验证,并返回详细的用户信息。Json::Value作为输出型参数,保存返回的用户信息。
  • select_by_name():通过用户名获取用户信息。
  • select_by_id():通过 id 获取用户信息。
  • win():当用户对战胜利时,根据 id 更改表中的数据,增加天梯分数、增加战斗场次、增加胜利场次。
  • lose():当用户对战失败时,根据 id 更改表中的数据,增加战斗场次。

具体实现

#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 &passwd, const std::string &dbname, uint16_t port = 3306){_mysql = mysql_util::mysql_create(host, user, passwd, dbname, port);assert(_mysql != NULL);}~user_table(){mysql_util::mysql_destroy(_mysql);_mysql = NULL;}// 注册时新增用户bool insert(const Json::Value &user){
#define INSERT_USR "insert into user values(null, '%s', MD5('%s'), 1000, 0, 0);"// 如果注册时用户输入的用户名和密码有一个为空,则不能注册 if (user["username"].isNull() || user["password"].isNull()){LOG(DEBUG, "please input username or password");return false;}// 检测当前用户名是否已经被注册过了Json::Value val;if (select_by_name(user["username"].asString(), val) == true){LOG(DEBUG, "%s username is already exists", user["username"].asCString());return false;}char sql[4096] = {0};sprintf(sql, INSERT_USR, user["username"].asCString(), user["password"].asCString());return mysql_util::mysql_exec(_mysql, sql);}// 登录验证,并返回详细的用户信息bool login(Json::Value &user){if (user["username"].isNull() || user["password"].isNull()){LOG(DEBUG, "please input username or password");return false;}// 需要保证用户名和密码都匹配
#define LOGIN_USR "select id, ladder_score, total_count, win_count from user where username='%s' and password=MD5('%s');"char sql[4096] = {0};sprintf(sql, LOGIN_USR, user["username"].asCString(), user["password"].asCString());MYSQL_RES *res = NULL;{std::unique_lock<std::mutex> lock(_mutex);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){LOG(DEBUG, "User login failed!!");return false;}// 保存结果集res = mysql_store_result(_mysql);if (res == NULL){LOG(DEBUG, "Sorry, system can not find this user.");return false;}}int rows = mysql_num_rows(res);if (rows != 1)return false;MYSQL_ROW row = mysql_fetch_row(res);user["id"] = (Json::UInt64)std::stol(row[0]);user["ladder_score"] = std::stoi(row[1]);user["total_count"] = std::stoi(row[2]);user["win_count"] = std::stoi(row[3]);mysql_free_result(res);return true;}// 通过用户名获取用户信息bool select_by_name(const std::string &username, Json::Value &user){
#define USER_BY_NAME "select id, ladder_score, total_count, win_count from user where username='%s';"char sql[4096] = {0};sprintf(sql, USER_BY_NAME, username.c_str());MYSQL_RES *res = NULL;{std::unique_lock<std::mutex> lock(_mutex);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){LOG(DEBUG, "get user by name failed");return false;}// 保存结果集res = mysql_store_result(_mysql);if (res == NULL){LOG(DEBUG, "Sorry, system can not find this user.");return false;}}int rows = mysql_num_rows(res);if (rows != 1) // 没有结果,返回falsereturn false;MYSQL_ROW row = mysql_fetch_row(res);user["id"] = (Json::UInt64)std::stol(row[0]);user["username"] = username;user["ladder_score"] = std::stoi(row[1]);user["total_count"] = std::stoi(row[2]);user["win_count"] = std::stoi(row[3]);mysql_free_result(res);return true;}// 通过 id 获取用户信息bool select_by_id(int64_t id, Json::Value &user){
#define USER_BY_ID "select username, ladder_score, total_count, win_count from user where id=%ld;"char sql[4096] = {0};sprintf(sql, USER_BY_ID, id);MYSQL_RES *res = NULL;{std::unique_lock<std::mutex> lock(_mutex);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){LOG(DEBUG, "get user by name failed");return false;}// 保存结果集res = mysql_store_result(_mysql);if (res == NULL){LOG(DEBUG, "Sorry, system can not find this user.");return false;}}int rows = mysql_num_rows(res);if (rows != 1)return false;MYSQL_ROW row = mysql_fetch_row(res);user["id"] = (Json::UInt64)id;user["username"] = row[0];user["ladder_score"] = std::stoi(row[1]);user["total_count"] = std::stoi(row[2]);user["win_count"] = std::stoi(row[3]);mysql_free_result(res);return true;}// 胜利时天梯分数增加、战斗场次增加,胜利场次增加bool win(int64_t id){
#define USER_WIN "update user set ladder_score=ladder_score+30, total_count=total_count+1, win_count=win_count+1 where id=%ld;"char sql[4096] = {0};sprintf(sql, USER_WIN, id);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){LOG(DEBUG, "update win user info failed");return false;}return true;}// 失败时天梯分数减少、战斗场次增加,其他不变bool lose(uint64_t id){
#define USER_LOSE "update user set ladder_score=ladder_score-30, total_count=total_count+1 where id=%ld;"char sql[4096] = {0};sprintf(sql, USER_LOSE, id);bool ret = mysql_util::mysql_exec(_mysql, sql);if (ret == false){LOG(DEBUG, "update lose user info failed");return false;}return true;}private:MYSQL *_mysql = NULL;std::mutex _mutex;
};

session模块

对客户端的连接进行 cookie&session 管理,实现 HTTP 无状态时客户端的身份认证功能。

session类

用于表示一个用户会话,包含会话的基本信息,如会话 ID、用户 ID、登录状态和定时器指针等。

成员变量:

  • _session_id:会话的唯一标识符。
  • _uid:与该会话关联的用户 ID。
  • _status:会话的登录状态。(有未登录(UNLOGIN)和已登录(LOGIN)两种状态)
  • _timer_ptr:指向定时器的指针,用于设置会话的过期时间。

区分会话状态的原因如下:

  • 用户通过 HTTP 进行登录时,会话的生命周期应该是一段时间,因为用户登录后没有进入游戏大厅,掉线了,那么就不需要保存用户的登录状态了,会话就应该被删除。
  • 如果登录过后,进入游戏大厅/游戏房间,session就应该永久存在,不然用户在下棋时,登录信息过期了,用户需要重新登录,这样用户体验感很差。
  • 如果下棋完成后,也就是退出游戏时,session应该被删除,因为用户已经退出了,不需要再保存用户的登录状态了。

成员函数: 提供了获取和设置成员变量的方法,以及判断用户是否已登录的方法。

typedef enum
{UNLOGIN,LOGIN
} session_status;class session
{
private:uint64_t _session_id;             // 标识符uint64_t _uid;                    // session对应的用户idsession_status _status;           // 用户的登录状态wsserver_t::timer_ptr _timer_ptr; // 定时器的指针
public:session(uint64_t session_id): _session_id(session_id){LOG(INFO, "session %p 创建成功", this);}~session() { LOG(INFO, "session %p 被销毁了", this); }uint64_t get_uid() { return _uid; }void set_uid(uint64_t uid) { _uid = uid; }void set_status(session_status status) { _status = status; }uint64_t get_session_id() { return _session_id; }bool is_login() { return (_status == LOGIN); }void set_timer(const wsserver_t::timer_ptr timer_ptr) { _timer_ptr = timer_ptr; }wsserver_t::timer_ptr &get_timer() { return _timer_ptr; }
};

session 管理类

主要功能

  1. 创建session:会为每一个session分配一个session_id,因此管理模块需要维护一个计数器。
  2. 为session设置一个过期时间
  3. 获取session
  4. 销毁session(过期后被自动销毁)

需要管理的数据:

  1. 计数器
  2. 互斥锁
  3. 对多个session进行管理,将session_id与session对象的映射关系存放在map中
  4. 定时器是由服务器进行设置的,因此还需要有一个服务器的句柄

这里使用 websocketpp 的定时器来完成对session生命周期的管理。

关于session的生命周期,有以下四种情况可以进行设置:

  1. 在session永久存在的情况下,设置永久存在。(session一被创建就是永久的)
  2. 在session永久存在的情况下,设置一个定时任务,在指定时间后将session删除。
  3. 在session设置了定时任务后,将session设置为永久存在。
  4. 在session设置了定时任务后,重置session删除时间。

为什么要将session的生命周期分为这几个情况:

在 HTTP 通信的时候(登录,注册)session应该具备生命周期,指定时间无通信后需要删除session。在客户端建立 WebSocket 长连接之后,session应该是永久存在的,即使长时间不通信也不能删除session。

  • 登录之后,创建session,此时使用的是HTTP短连接,session需要在指定时间无通信后删除。
  • 但是进入游戏大厅,或者游戏房间,使用的是 WebSocket 长连接,这个session就应该永久存在
  • 等到退出游戏大厅,或者游戏房间,这个session应该被重新设置为临时,在长时间无通信后被删除

程序编写时的一些细节:

  1. 判断一个session是否是永久存在的。当一个session对象的定时器指针为 nullptr 时,就代表该session没有定时任务,是永久存在的。
  2. 设置定时任务后,需要更新会话定时器的状态,以便下一次判断该会话,我们得知该会话处于什么状态。
  3. 定时任务的取消会导致任务会被执行
  4. 第三种情况删除会话并重新创建会话时。 任务被删除后,不是立即执行,需要由服务器感知到该动作时才会执行,也就是说会话不是被立马删除的,所以可能会在我们重新insert会话后才被删除,所以insert操作不能立即执行,否则会导致我们刚添加session就被删除了。需要保证先删除会话,再重新添加新会话。 所以我们可以将重新添加会话的动作交给服务器的定时器来做,这样就能保证先删除会话,再重新添加会话(因为都是定时器做的,定时器内部有先后顺序)。
  5. 重置会话的定时任务: 将会话原先的定时任务取消(删除会话),然后重新添加该会话,最后设置定时器任务,并将定时器状态设置给该会话。

注:这里删除会话定时器的任务,可以理解成——为服务器设置了0秒后执行的任务。

#define SESSION_TIMEOUT 30000
#define SESSION_FORVER -1using session_ptr = std::shared_ptr<session>;class session_manager
{
private:uint64_t _next_id;std::mutex _mutex;std::unordered_map<uint64_t, session_ptr> _sessions;wsserver_t *_server;private:void append_session(const session_ptr &sp){// 删除session后,在将该会话添加std::unique_lock<std::mutex> lock(_mutex);_sessions.insert(std::make_pair(sp->get_session_id(), sp));}public:session_manager(wsserver_t *server) : _next_id(1), _server(server) { LOG(DEBUG, "session管理模块初始化完毕..."); }session_ptr create_session(uint64_t uid, session_status status){std::unique_lock<std::mutex> lock(_mutex);session_ptr sp(new session(_next_id));sp->set_uid(uid);sp->set_status(session_status::UNLOGIN);_sessions.insert(std::make_pair(_next_id, sp));_next_id++;return sp;}session_ptr get_session_byId(uint64_t sid){std::unique_lock<std::mutex> lock(_mutex);auto it = _sessions.find(sid);if (it == _sessions.end())return session_ptr();return it->second;}void remove_session(uint64_t sid){std::unique_lock<std::mutex> lock(_mutex);_sessions.erase(sid);}void set_session_expir_time(uint64_t sid, int ms){// 判断sid对应的session对象是否存在session_ptr sp = get_session_byId(sid); // 获取会话状态if (sp.get() == nullptr)return;// 如果session对象存在,就需要判断以下四个对定时器的设置wsserver_t::timer_ptr tp = sp->get_timer(); // 会话定时器状态// 1. 当session的生存周期是永久的,用户设置为永久的if (tp.get() == nullptr && ms == SESSION_FORVER)return;else if (tp.get() == nullptr && ms != SESSION_FORVER){// 2. 当session的生存周期是永久的,用户设置ms时间后执行删除session任务wsserver_t::timer_ptr tmp = _server->set_timer(ms, std::bind(&session_manager::remove_session, this, sid));// 需要重新设置该会话定时器的状态,以便下一次判断该会话,我们得知该会话处于什么状态sp->set_timer(tmp);}else if (tp.get() != nullptr && ms == SESSION_FORVER){// 3. 当session被设置了定时删除任务,用户将该会话的生命周期设置为永久// 删除定时任务——steady_timer在被取消,会直接执行定时任务,也就是会把会话删除,我们需要重新添加该会话/*因为这个定时任务被取消,不是立即执行,也就是说会话不是被立马删除的,可能会在我们重新insert会话后才被删除,所以insert操作不能立即执行,否则会导致我们刚添加session就被删除了*/tp->cancel();// 重新设置定时器的状态,以便下一次进行判断sp->set_timer(wsserver_t::timer_ptr());                                       _server->set_timer(0, std::bind(&session_manager::append_session, this, sp)); // 将重新添加会话的动作交给定时器来做}else if (tp.get() != nullptr && ms != SESSION_FORVER){// 4. 当session被设置了定时删除任务,用户重置了该会话被删除的时间tp->cancel();_server->set_timer(0, std::bind(&session_manager::append_session, this, sp)); // 将重新添加会话的动作交给定时器来做// 这里重新设置session关联定时器的时间wsserver_t::timer_ptr tmp = _server->set_timer(ms, std::bind(&session_manager::remove_session, this, sid)); sp->set_timer(tmp);    // 重置session关联的定时器,以便下一次进行判断}}
};

在线用户管理模块

在线用户管理模块,主要是对进入游戏大厅和进入游戏房间中的用户进行管理,建立起用户与 Socket 连接的映射关系,具体功能如下:

  1. 能够让服务器根据用户信息,找到与该用户客户端进行通信的 Socket 连接,进而实现与客户端的通信。
  2. 判断一个用户是否在线,或者判断用户是否掉线。

例如:

  • 当一个房间里某个用户发送信息(实时聊天信息、落子信息)时,服务器要将该信息广播给房间内其他在线的所有用户,这样就需要先找到其他的所有用户与服务器建立的 Socket 连接,之后才能广播消息。
  • 当房间内或者游戏大厅里有一个用户客户端与服务器建立的 Socket 连接断开了,保存的映射关系就会被删除,这样我们可以通过用户管理模块判断用户是否在线。

管理对象: 进入游戏大厅的用户、进入游戏房间的用户。
管理原因: 上述这两个场景的用户在与服务器进行通信时使用的是 WebSocket 协议,是一个长连接,一个用户对应一个连接,其映射关系是“稳定的”,很方便进行管理。(在这之前的连接,使用的是 HTTP 协议的短连接,一个用户可能会使用多个短连接与服务器通信)
管理内容: 将用户 id 和其客户端 WebSocket 长连接的映射关系。

成员变量

  • 因为维护游戏大厅/房间里 用户id 与 WebSocket连接的映射关系,所以需要两个哈希表来保存。
  • 对两个哈希表进行CURD操作时,可能会涉及多线程,要保证线程安全,就还需要一个互斥锁

成员方法

主要就是添加和删除要维护的信息。

  • 客户端与服务器之间的WebSocket连接建立成功时,向游戏大厅/游戏房间中增加用户信息:enter_game_hall()enter_game_room
  • 客户端与服务器之间的WebSocket连接断开时,移除游戏大厅/游戏房间中的用户信息:exit_game_hall()exit_game_room()
  • 根据用户ID,判断当前用户是否在游戏大厅/游戏房间中:is_in_game_hall()is_in_game_room()
  • 通过用户ID获取对应游戏大厅/游戏房间中管理的通信连接:get_conn_from_hall()get_conn_from_room()

具体实现

#pragma once#include <mutex>
#include <unordered_map>
#include "util.hpp"class online_manager
{
private:std::mutex _mutex;// 用于维护游戏大厅中用户id和通信连接的映射关系std::unordered_map<uint64_t, wsserver_t::connection_ptr> _hall_users;// 用于维护游戏房间中用户id和通信连接的映射关系std::unordered_map<uint64_t, wsserver_t::connection_ptr> _room_users;
public:// 客户端与服务器之间的WebSocket连接建立成功时,增加游戏大厅/游戏房间的用户信息void enter_game_hall(uint64_t uid, wsserver_t::connection_ptr& conn){std::unique_lock<std::mutex> lock(_mutex);_hall_users.insert(std::make_pair(uid, conn));}void enter_game_room(uint64_t uid, wsserver_t::connection_ptr &conn){std::unique_lock<std::mutex> lock(_mutex);_room_users.insert(std::make_pair(uid, conn));}// 客户端与服务器之间的WebSocket连接断开时,移除游戏大厅/游戏房间中的用户信息void exit_game_hall(uint64_t uid){std::unique_lock<std::mutex> lock(_mutex);_hall_users.erase(uid);}void exit_game_room(uint64_t uid){std::unique_lock<std::mutex> lock(_mutex);_room_users.erase(uid);}// 判断当前用户是否在游戏大厅/游戏房间中bool is_in_game_hall(uint64_t uid){std::unique_lock<std::mutex> lock(_mutex);auto it = _hall_users.find(uid);if(it == _hall_users.end())return false;return true;}bool is_in_game_room(uint64_t uid){std::unique_lock<std::mutex> lock(_mutex);auto it = _room_users.find(uid);if (it == _room_users.end())return false;return true;}// 通过用户ID获取对应游戏大厅/游戏房间中管理的通信连接wsserver_t::connection_ptr get_conn_from_hall(uint64_t uid){std::unique_lock<std::mutex> lock(_mutex);auto it = _hall_users.find(uid);if (it == _hall_users.end())return wsserver_t::connection_ptr();return it->second;}wsserver_t::connection_ptr get_conn_from_room(uint64_t uid){std::unique_lock<std::mutex> lock(_mutex);auto it = _room_users.find(uid);if (it == _room_users.end())return wsserver_t::connection_ptr();return it->second;}
};

游戏房间管理模块

当用户匹配对战成功后,就要为匹配成功的这两位玩家建立一个小范围的关联关系。目的是,在房间内的任意一个用户发生的任何动作(下棋/实时聊天),需要将该动作广播给其他用户。

同时,会有多个玩家匹配成功,就需要创建多个房间,因此还需要对多个房间进行管理,以便对这些房间的生命周期进行控制(创建、销毁)。

因此 游戏房间管理模块 又可以分为两个小模块:

  • 游戏房间的设计(对房间的抽象)
  • 多个游戏房间管理的设计(对 房间对象 的组织管理)

游戏房间的设计

在游戏房间中,需要维护一些信息和进行的操作,如执黑白棋的用户对应房间里的哪位用户,因为特定用户只能下特定的棋子。当房间内所有玩家退出后,需要销毁房间……

成员变量

房间管理的所要维护的数据如下:

  1. 房间ID: 唯一标识一个房间。
  2. 房间状态: 决定了一个玩家退出房间时,需要进行的动作。 房间有两个状态:房间处于正在被使用的状态(玩家对战进行中)、房间被使用完毕的状态(玩家对战结束)。
    • 如果房间处于被使用的状态,两个玩家正在对战,如果有一个玩家退出了房间,则另一个玩家直接获胜。
    • 如果房间处于被使用完毕的状态,对战结束(已经分出胜负了),如果玩家退出房间就不需要判定另一个玩家的胜负了,而是等待所有玩家退出房间后,销毁房间。
  3. 房间内玩家的数量: 决定了房间什么时候被销毁(当所有用户退出房间时)。
  4. 白棋玩家的用户ID
  5. 黑棋玩家的用户ID
  6. 玩家信息表的句柄: 对战结束后,通过 数据管理模块的句柄 更新数据库中玩家的相关数据。
  7. 玩家通信句柄: 用来发送消息。(在线用户管理模块的句柄)
  8. 棋盘信息: 用来判断玩家是否对战胜利,使用二维数组来记录。

所要维护的信息,体现在类中就是类的成员变量。

成员函数

房间管理主要需要处理的动作有 下棋实时聊天 两个动作。

  • 在处理动作之前,需要判定请求是否正确,即判断请求房间的房间ID与当前房间ID是否相等。
  • 在处理完动作之后,需要将响应广播给房间的每一个用户。

下棋合法性的验证:

  • 玩家不能一次落多个子,落子动作需要交替进行
  • 玩家落子的位置应该是没有棋子的
  • 玩家落子成功后,需要判断该玩家是否满足胜利条件

聊天合法性的验证:

  • 聊天信息中不能出现特定的敏感词。

  1. 构造函数: 对需要进行初始化的成员变量进行初始化。

    • 房间ID是由房间管理模块统一分配,所以由外部传参进行初始化。
    • 房间状态:一旦房间被创建了,对战就开始了,房间的状态为GAMING
    • 玩家数量:初始化为0,当有玩家进入房间时,增加(收到进入房间的请求)。
    • 黑白棋用户ID:当用户进入房间时,黑白棋用户就确定了,此时再进行初始化。
    • 数据库表操作句柄:由外部传入。
    • 在线用户通信句柄:由外部传入。
    • 棋盘:与前端网页对应。
  2. handle_request():总的处理请求函数,根据不同的请求调用不同的处理函数。需要将不同处理函数返回的响应,广播给房间内所有用户。

  3. handle_chess():下棋动作。

    • 判断双方玩家是否在线列表中(避免处理无效请求),如果玩家不在线,则另一方获胜
    • 判断当前请求玩家落子位置是否合法
    • 落子成功后,需要判断当前玩家是否获胜
    • 最后返回对应的响应Json::Value对象
  4. handle_chat()

    • 判断聊天信息中是否包含敏感词,如果有敏感词则不能发送
    • 最后返回对应的响应Json::Value对象
  5. handle_exit():处理玩家退出房间的动作。

    • 如果是对战时调用该接口退出房间,则对方玩家获胜。
    • 如果是对战结束时,退出房间则不影响输赢,房间人数减一。
  6. broadcast():将指定信息(响应)广播给指定房间内的玩家。

    • 对要响应的数据进行序列化后,才能发送给其他玩家。
    • 目前需要发送给白棋玩家和黑棋玩家。
    • 发送消息前,需要获取对应玩家的通信连接。

具体实现

// 棋盘大小
#define BOARD_ROW 15
#define BOARD_COL 15// 棋子颜色
#define CHESS_WHITE 1
#define CHESS_BLACK 2typedef enum
{GAMING = 0,GAME_OVER
} room_status;class room
{
private:uint64_t _room_id;room_status _status;int _player_conut;int _white_id;int _black_id;user_table *_tb_user;online_manager *_online_user;std::vector<std::vector<int>> _board;public:room(uint64_t room_id, user_table *tb_user, online_manager *online_user): _room_id(room_id), _tb_user(tb_user), _online_user(online_user),_status(room_status::GAMING), _player_conut(0),_board(BOARD_ROW, std::vector<int>(BOARD_COL, 0)){LOG(INFO, "房间创建成功");}~room(){LOG(INFO, "房间销毁成功");}// 总的请求处理函数,在函数外部区分请求类型,根据不同的请求调用不同的处理函数,并将响应广播void handle_request(Json::Value &req){Json::Value resp = req;// 1. 判断玩家请求房间ID与当前房间ID是否匹配uint64_t req_room_id = req["id"].asUInt64();if (req_room_id != _room_id){resp["result"] = false;resp["reason"] = "房间号不匹配!!";return broadcast(resp);}// 2. 根据不同请求调用不同的处理函数std::string req_oper = req["optype"].asString();if (req_oper == "put_chess") // 下棋请求{resp = handle_chess(req);// 当前落子满足获胜条件后(winner_id),需要更改数据库中的数据uint64_t winner_id = resp["winner_id"].asUInt64();if (winner_id != 0){uint64_t loser_id = winner_id == _black_id ? _white_id : _black_id;_tb_user->win(winner_id);_tb_user->lose(loser_id);_status = room_status::GAME_OVER;}}else if (req_oper == "chat") // 实时聊天请求{resp = handle_chat(req);}else{resp["result"] = false;resp["reason"] = "请求处理未知!!";}return broadcast(resp);}// 处理玩家退出房间的动作void handle_exit(uint64_t uid){// 如果是对战时退出房间,则对方玩家获胜(对战结束时,退出房间是正常退出)Json::Value resp;if (_status == room_status::GAMING){uint64_t winner_id = (uid == _black_id ? _white_id : _black_id);resp["result"] = true;resp["reason"] = "对方已掉线,你直接获胜...";resp["room_id"] = (Json::UInt64)_room_id;resp["uid"] = (Json::UInt64)uid;resp["row"] = -1;resp["col"] = -1;resp["winner"] = (Json::UInt64)winner_id;// 更改数据库中的数据uint64_t loser_id = uid;_tb_user->win(winner_id);_tb_user->lose(loser_id);_status = room_status::GAME_OVER;broadcast(resp);}// 正常退出,房间人数--即可_player_conut--;}uint64_t room_id() { return _room_id; }room_status status() { return _status; }int player_conut() { return _player_conut; }uint64_t white_id() { return _white_id; }uint64_t black_id() { return _black_id; }void add_white_user(uint64_t uid){_white_id = uid;_player_conut++;}void add_black_user(uint64_t uid){_black_id = uid;_player_conut++;}private:// 判断单个方向是否满足获胜条件bool is_five(int row, int row_off, int col, int col_off, int color){int count = 1; // 当前棋子的个数int row_ptr = row + row_off;int col_ptr = col + col_off;while ((row_ptr >= 0 && row_ptr < BOARD_ROW) &&(col_ptr >= 0 && col_ptr < BOARD_COL) &&_board[row_ptr][col_ptr] == color){count++;row_ptr += row_off;col_ptr += col_off;}row_ptr = row - row_off;col_ptr = col - col_off;while ((row_ptr >= 0 && row_ptr < BOARD_ROW) &&(col_ptr >= 0 && col_ptr < BOARD_COL) &&_board[row_ptr][col_ptr] == color){count++;row_ptr -= row_off;col_ptr -= col_off;}return (count >= 5);}// 判断当前color棋子的棋局是否获胜:从四个方向进行判断uint64_t check_win(int row, int col, int color){if (is_five(row, 0, col, 1, color) || is_five(row, 1, col, 0, color) ||is_five(row, 1, col, 1, color) || is_five(row, -1, col, -1, color)){return color == CHESS_BLACK ? _white_id : _black_id;}return 0;}// 下棋动作Json::Value handle_chess(const Json::Value &req){Json::Value resp = req;// 1. 判断双方玩家是否在线列表中(避免处理无效请求),如果玩家不在线,则另一方获胜uint64_t req_user_id = req["uid"].asUInt64();if (_online_user->is_in_game_room(_white_id) == false){resp["result"] = true; // 下棋动作完成resp["reason"] = "对方已掉线,你直接获胜...";resp["winner"] = _black_id;return resp; // 广播动作由总函数完成}if (_online_user->is_in_game_room(_black_id) == false){resp["result"] = true; // 下棋动作完成resp["reason"] = "对方已掉线,你直接获胜...";resp["winner"] = _white_id;return resp; // 广播动作由总函数完成}// 2. 判断当前玩家落子位置是否合法int chess_row = req["row"].asInt();int chess_col = req["col"].asInt();if (_board[chess_row][chess_col] == 0){resp["result"] = false;resp["reason"] = "落子位置非法!!";return resp;}// 3. 落子成功,需要判断当前玩家是否获胜int chess_color = req["color"].asInt();_board[chess_row][chess_col] = req_user_id == _black_id ? CHESS_BLACK : CHESS_WHITE;uint64_t winner_id = check_win(chess_row, chess_col, chess_color);if (winner_id != 0){resp["reason"] = "五子连星,你已获胜!!";}resp["result"] = true;                    // 下棋动作完成resp["winner"] = (Json::UInt64)winner_id; // winner_id == 0 则没有玩家获胜return resp;                              // 广播动作由总函数完成}// 聊天动作Json::Value handle_chat(const Json::Value &req){Json::Value resp = req;// 判断聊天信息中是否包含敏感词std::string message = req["message"].asString();int pos = message.find("垃圾");if (pos != std::string::npos){resp["result"] = false; // 发送聊天信息失败resp["reason"] = "消息中包含不明用语,无法发送!!";return resp;}resp["result"] = true;return resp;}// 将指定信息(响应)广播给指定房间内的玩家void broadcast(const Json::Value &rsp){// 对要响应的数据进行序列化,发送给其他玩家std::string body;json_util::serialize(rsp, body);// 发送给白棋玩家// 1. 获取通信连接wsserver_t::connection_ptr wconn = _online_user->get_conn_from_room(_white_id);// 2. 发送消息if (wconn.get() != nullptr){wconn->send(body);}// 发送给黑棋玩家// 1. 获取通信连接wsserver_t::connection_ptr bconn = _online_user->get_conn_from_room(_black_id);// 2. 发送消息if (bconn.get() != nullptr){bconn->send(body);}}
};

房间管理

成员变量

需要管理的数据:

  1. 数据管理模块的句柄: 房间模块所需要的句柄。
  2. 在线用户管理模块的句柄: 房间模块所需要的句柄。
  3. 房间ID分配计数器。
  4. 互斥锁: 可能有多个线程同时创建房间/销毁房间,同时还需要使用 房间ID分配计数器,所以需要对 计数器 进行保护、对房间容器的保护。
  5. 房间信息管理: 建立起房间ID与房间信息/对象的映射关系。
  6. 房间ID与用户信息的关联: 维护用户ID与房间ID的关联关系,先通过用户ID找到其所在的房间ID,再去查找房间信息。

成员函数

房间管理对房间的操作有:

  1. 创建房间: 当两个玩家对战匹配完成了,为他们创建一个房间,需要传入两个玩家的用户ID。
  2. 查找房间:
    • 通过房间ID找到房间内部维护的信息,即通过房间ID找到房间对象。
    • 通过房间中用户ID找到所在房间的信息。
  3. 销毁房间:
    • 根据房间ID销毁房间。
    • 当房间内所有用户都退出了,销毁房间。
  4. 删除指定房间内的用户: 删除完一个用户后,需要判断是否是所有用户都退出了,进行销毁房间。(用户连接断开时被调用)

  • create_room():创建房间,将两个在线用户加入房间中。

    1. 校验两个用户是否还在游戏大厅中,都在时才创建;
    2. 创建房间后,需要使用 房间类 提供的black_id()white_id()方法将用户信息添加到房间中;
    3. 需要将新创建房间的房间信息管理起来:维护房间ID和房间对象之间的映射关系。
    4. 还需要将该房间内的用户信息管理起来:维护用户ID和房间ID之间的映射关系。
  • findRoom_byRoomId():根据房间ID查找房间对象,已得到房间的信息。直接在map中查找即可。(需要加锁)

  • findRoom_byUserId():根据房间内用户ID查找所在房间的对象。先根据用户ID找到房间ID,再根据房间ID找到房间信息。(需要加锁)(需要注意的是,这里使用房间ID找到房间信息这一步,不能复用findRoom_byRoomId()函数,不然重复加锁会造成死锁。)

  • removeRoom_byRoomId():根据房间ID删除房间。这一步直接在map表中删除即可,同时还需要将房间内的用户信息删除。

  • removeUser():从游戏房间中删除用户,如果房间中没有用户,就销毁该房间。

具体实现

using room_ptr = std::shared_ptr<room>;class room_manager
{
private:uint64_t _next_rid;std::mutex _mutex;online_manager *_online_users;user_table *_tb_users;std::unordered_map<uint64_t, room_ptr> _rooms;std::unordered_map<uint64_t, uint64_t> _users;public:room_manager(user_table *tb_users, online_manager *online_users): _next_rid(1), _online_users(online_users), _tb_users(tb_users){LOG(INFO, "房间管理模块初始化完毕...");}~room_manager(){LOG(INFO, "房间管理模块销毁完毕...");}// 创建房间:将两个在线用户加入房间中room_ptr create_room(uint64_t uid1, uint64_t uid2){// 两个用户在游戏大厅中进行游戏匹配,匹配成功后才创建房间// 1. 校验两个用户是否还在游戏大厅中,都在时才创建if (_online_users->is_in_game_hall(uid1) == false){LOG(DEBUG, "%lu 用户不在游戏大厅中,创建房间失败!!", uid1);return room_ptr();}if (_online_users->is_in_game_hall(uid2) == false){LOG(DEBUG, "%lu 用户不在游戏大厅中,创建房间失败!!", uid2);return room_ptr();}// 2. 创建房间,将用户信息添加到房间中std::unique_lock<std::mutex> lock(_mutex);room_ptr rp(new room(_next_rid, _tb_users, _online_users));rp->add_black_user(uid1);rp->add_white_user(uid2);// 3. 将房间信息管理起来_rooms.insert(std::make_pair(_next_rid, rp));_users.insert(std::make_pair(uid1, _next_rid));_users.insert(std::make_pair(uid2, _next_rid));_next_rid++;// 4. 返回房间信息return rp;}// 查找房间room_ptr findRoom_byRoomId(uint64_t rid) // 根据房间ID查找{std::unique_lock<std::mutex> lock(_mutex);auto it = _rooms.find(rid);if (it == _rooms.end()){LOG(DEBUG, "未找到房间ID为 %lu 对应的房间", rid);return room_ptr();}return it->second;}room_ptr findRoom_byUserId(uint64_t uid) // 根据房间内用户ID查找:先根据用户ID找到房间ID,再根据房间ID找到房间信息{std::unique_lock<std::mutex> lock(_mutex);// 1. 获取房间IDauto uit = _users.find(uid);if (uit == _users.end()){LOG(DEBUG, "uid为 %lu 的用户没有在任何房间中", uid);return room_ptr();}// 2. 根据房间ID找到房间信息(这里不能直接调用函数完成,会造成重复申请锁,造成死锁)auto rit = _rooms.find(uit->second);if (rit == _rooms.end()){LOG(DEBUG, "未找到房间ID为 %lu 对应的房间", uit->second);return room_ptr();}return rit->second;}// 删除房间void removeRoom_byRoomId(uint64_t rid) // 根据房间ID删除{// 删除房间后,还需要将维护房间内的用户信息删除room_ptr rp = findRoom_byRoomId(rid); // 这里之前没有加锁,可以调用函数完成if(rp.get() == nullptr)return;// 获取房间内用户ID,以便删除用户信息uint64_t black_id = rp->black_id();uint64_t white_id = rp->white_id();std::unique_lock<std::mutex> lock(_mutex);// 删除房间信息和用户信息_rooms.erase(rid);_users.erase(black_id);_users.erase(white_id);}void removeUser(uint64_t uid) // 从游戏房间中删除用户,如果房间中没有用户,就销毁该房间{room_ptr rp = findRoom_byUserId(uid);if (rp.get() == nullptr){LOG(INFO, "无法移除uid: %lu 用户", uid);return;}// 玩家从房间中退出rp->handle_exit(uid);// 判断是否要删除房间if(rp->player_conut() == 0){removeRoom_byRoomId(rp->room_id());}}
};

匹配对战模块

当有玩家进行匹配时,我们需要将这个玩家放在特定队列中,等待满足游戏条件,条件满足时才会开始游戏,因此这一过程可能需要等待一段时间,这就涉及到阻塞队列,下面我们先来看一下如何设计阻塞队列。

设计阻塞队列

该队列要完成以下主要功能:

  1. 入队数据
  2. 出队数据
  3. 移除指定数据(玩家不想等待匹配了)
  4. 线程安全
  5. 获取队列元素个数
  6. 阻塞
  7. 判断队列是否为空
  • 因为涉及到频繁的插入和删除操作,所以底层数据结构使用双向链表来模拟实现该阻塞队列。并且会有多个线程向这个队列中插入/删除数据,所以需要保证线程安全。
  • 如果队列中没有数据/没有满足特定条件,就需要阻塞访问队列的线程,所以需要提供阻塞接口wait()
  • 其余的就是,队列相关的操作:入队push()、出队pop()、队列元素的个数size()、队列是否为空empty()
  • 还需要提供一个重要的接口remove(),普通队列操作是不会提供这个接口的,但是在匹配过程中可能会遇到匹配玩家掉线的场景,这时我们就需要将这个玩家从匹配队列中移除,而这个玩家对象很可能不在队头,而我们又需要将其删除,所以就需要提供这个 remove 接口。
  • 其中阻塞线程wait接口出队pop接口不能合并成为一个接口:因为如果队列中有两个元素,当出队一个元素时不会阻塞,但是当出队剩余一个元素时,它检测到队列中只有一个元素就被阻塞了,这就不合理了。所以出队元素时,需要判断队列是否为空,如果为空,就不能出队,等待接口和出队接口两个操作不是原子的,可能有多个线程在等待接口等待,当等待接口被唤醒后,资源已经被取走了。(先wait对立中有两个玩家,条件成立后再出队)
  • 其中入队接口唤醒线程接口合并为一个接口,这样队列中每增加一个元素,就唤醒线程判断是否满足条件,这样就不需要入队接口知道队列元素个数。这样就需要防止伪唤醒。

具体实现

template <typename T>
class match_queue
{
private:std::list<T> _list; // 使用双向链表实现队列std::mutex _mutex;std::condition_variable _cond; // 给消费者使用,判断是否有玩家匹配:队列元素个数小于2时阻塞
public:// 入队数据,唤醒线程void push(const T& data){std::unique_lock<std::mutex> lock(_mutex);_list.push_back(data);_cond.notify_all();}// 出队数据bool pop(T& data){// 阻塞std::unique_lock<std::mutex> lock(_mutex);if(_list.empty())return false;data = _list.front();_list.pop_front();return true;}// 移除队列中指定用户void remove(const T& data){std::unique_lock<std::mutex> lock(_mutex);_list.remove(data);}// 获取元素个数int size() {std::unique_lock<std::mutex> lock(_mutex);return _list.size(); }// 判断队列是否为空bool empty() {std::unique_lock<std::mutex> lock(_mutex);return _list.empty();}// 阻塞线程void wait() {std::unique_lock<std::mutex> lock(_mutex);_cond.wait(lock); // 这里直接等待push唤醒即可}
};

匹配管理

需要管理的数据

  • _match_queues:因为游戏中会存在多个“段位”,每一个段位就需要一个队列来存放正在匹配的玩家。同时为了快速查找到某一个段位对应的队列,所以将<段位名称,队列>的映射关系存储起来,这里使用std::unordered_map容器。
  • _tb_user:当一个玩家需要匹配时,我们需要获得该用户的段位信息,进而将其加入到特定队列中进行匹配。而用户的信息是存放在数据库中的,因此我们还需要一个数据库操作句柄

对于每一个匹配队列我们需要在满足游戏开始条件时,立即出队玩家,并告诉玩家游戏匹配完成,这样用户体验才会比较好。但是因为存在多个队列,如果仅仅使用一个线程来判断所有队列是否满足条件,会出现较大的延迟:当这一个线程检查第一个队列是否满足条件时,发现不满足条件,就会阻塞在这个队列中,并且如果此时有其他队列满足条件,也不会进行任何操作,因为线程被阻塞了,这样就算其他队列满足条件了也不会被处理,需要等到前面的队列都处理完了才可以。

较好的办法就是每一个队列使用一个线程来检查,这样就不会出现上面这种场景了。

  • _threads:每一个队列需要有一个线程来判断队列是否满足条件。
  • _online_users:在匹配时,如果将玩家从队列取出后,还需要判断玩家是否在线,如果有一个玩家掉线了,开始游戏的条件就不满足了,因此还需要将取出玩家中在线的玩家放入队列中继续匹配。
  • _rooms:当匹配成功后,就需要将匹配的玩家加入房间中
std::unordered_map<std::string, queue_t<uint64_t>> _match_queues;
std::unordered_map<std::string, thread_t> _threads;
user_table* _tb_user;
online_manager *_online_users;
room_manager * _rooms;

提供的方法

  • 构造函数/析构函数:完成对成员变量的初始化和销毁。构造函数中创建队列队列的线程(这里采用默认先创建三个队列和线程,后续如果有段位较高的玩家需要加入队列再创建);析构函数中需要join创建的线程。
  • 线程函数threadFunction:每一个队列的线程要完成的工作为:
    • 判断指定队列的人数是否大于2
    • 出队两个玩家
    • 判断两个玩家是否在线,如果有一方不在线,则将另一方重新添加入队列进行重新匹配
    • 创建房间,将两个玩家添加到房间中
    • 向两个玩家发送对战匹配成功的消息
  • addUser:获取用户信息后,向特定队列中添加玩家,如果当前还没有与玩家匹配的队列和线程,就创建。
  • removeUser:获取用户信息后,从特定队列中将该玩家删除。

总体实现

const static std::vector<std::string> rank = {"Bronze", "Silver", "Gold", "Platinum", "Diamond", "Starlight", "Starlight", "Glory King"};template <typename T>
using queue_t = std::shared_ptr<match_queue<T>>;
using thread_t = std::unique_ptr<std::thread>;class mathcer
{
private:std::unordered_map<std::string, queue_t<uint64_t>> _match_queues;std::unordered_map<std::string, thread_t> _threads;user_table* _tb_user;online_manager *_online_users;room_manager * _rooms;
private:// 线程函数void threadFunction(std::string key){// 循环等待匹配while(1){// 1. 判断指定队列的人数是否大于2while (_match_queues[key]->size() < 2)_match_queues[key]->wait();// 2. 出队两个玩家uint64_t uid1 = 0, uid2 = 0;bool ret = _match_queues[key]->pop(uid1);if (ret == false){continue; // 刚匹配成功,玩家退出匹配}ret = _match_queues[key]->pop(uid2);if(ret == false){_match_queues[key]->push(uid1);continue;}// 3. 判断两个玩家是否在线,如果有一方不在线,则将另一方重新添加入队列进行重新匹配wsserver_t::connection_ptr conn1 = _online_users->get_conn_from_hall(uid1);if(conn1.get() == nullptr){_match_queues[key]->push(uid2);continue;}wsserver_t::connection_ptr conn2 = _online_users->get_conn_from_hall(uid2);if(conn2.get() == nullptr){_match_queues[key]->push(uid1);continue;}// 4. 创建房间,将两个玩家添加到房间中room_ptr rp = _rooms->create_room(uid1, uid2);if(rp.get() == nullptr){_match_queues[key]->push(uid2);_match_queues[key]->push(uid1);continue;}// 5. 向两个玩家发送对战匹配成功的消息Json::Value resp;resp["optype"] = "match_success";resp["result"] = true;std::string body;json_util::serialize(resp, body);conn1->send(body);conn2->send(body);}}
public:mathcer(user_table *tb_user, online_manager *online_users, room_manager *rooms): _tb_user(tb_user), _online_users(online_users), _rooms(rooms){// 先创建三个段位for(int i = 0; i < 3; i++){_match_queues.emplace(rank[i], std::make_shared<match_queue<uint64_t>>());// 创建线程,使用 lambda 函数作为线程函数_threads.emplace(rank[i], std::make_unique<std::thread>([=]() { threadFunction(rank[i]); }));}LOG(DEBUG, "用户匹配模块初始化完毕...");}~mathcer(){for (int i = 0; i < _threads.size(); i++){_threads[rank[i]]->join(); // 等待所有线程}}bool addUser(uint64_t uid){// 1. 先判断该用户是否在线if(_online_users->is_in_game_hall(uid) == false){LOG(DEBUG, "匹配-该用户不在游戏大厅中, 无法添加");return false;}// 2. 得到该用户的段位信息Json::Value val;bool ret = _tb_user->select_by_id(uid, val);if(ret == false){LOG(DEBUG, "匹配时获取玩家信息失败:%lu", uid);return false;}uint64_t ladder_score = val["ladder_score"].asUInt64();// 3. 添加到对应队列中std::string key = rank[ladder_score / 1000 ];auto it = _match_queues.find(key);if (it == _match_queues.end()){_match_queues.emplace(key, std::make_shared<match_queue<uint64_t>>()); // 开辟一个新的段位_threads.emplace(key, std::make_unique<std::thread>([=]() { threadFunction(key); }));}_match_queues[key]->push(uid);LOG(DEBUG, "%s 队列中新增一个用户: %lu", key.c_str(), uid);return true;}// 移除指定用户bool removeUser(u_int64_t uid){// 1. 先判断该用户是否在线if (_online_users->is_in_game_hall(uid) == false){LOG(DEBUG, "匹配-该用户不在游戏大厅中,无法删除");return false;}// 2. 得到该用户的段位信息Json::Value val;bool ret = _tb_user->select_by_id(uid, val);if (ret == false){LOG(DEBUG, "匹配时获取玩家信息失败:%lu", uid);return false;}uint64_t ladder_score = val["ladder_score"].asUInt64();// 3. 到对应队列中删除该用户std::string key = rank[ladder_score / 1000];_match_queues[key]->remove(uid);LOG(DEBUG, "%s 队列中移除一个用户: %lu", key.c_str(), uid);return true;}
};

服务器模块

整合前边实现的所有类,并进行服务器搭建的一个模块,最终实现一个 gobang_server 的服务器模块类,向外提供搭建五子棋服务器的接口。通过实例化对象可以简便完成服务器的搭建。

服务器业务请求流程

这里的请求流程,在上面已经介绍过了,就是服务器视角的流程。

需要注意的是:因为 WebSocket 长连接在前端页面发送变化后,连接可能会断开,所以进入游戏大厅和进入游戏房间使用的长连接是不同的连接。

网络通信接口设计

这里通信接口,相当客户端和服务端约定:

  • 某一个请求需要携带哪些信息,以便客户端以此来组织请求
  • 服务器完成业务处理后,需要携带什么结果(如响应码和reason),以便服务端以此来组织响应

Restful风格的请求,主要是动态的请求,如注册、登录请求,提交了数据,服务器再给出响应。

静态资源请求/响应

静态资源页面,在后台服务器上就是个html/css/js文件。

静态资源请求的处理,其实就是将文件中的内容发送给客户端。这里主要通过工具类中的文件读取函数完成文件的读取。

// 1. 注册页面请求
请求:GET /register.html HTTP/1.1// 2. 登录页面请求
请求:GET /login.html HTTP/1.1// 3. 大厅页面请求
请求:GET /game_hall.html HTTP/1.1// 4. 房间页面请求
请求:GET /game_room.html HTTP/1.1

静态页面响应格式如下:

响应:
HTTP/1.1 200 OK
Content-Length: xxx
Content-Type: text/html
register.html文件的内容数据

注册请求/响应

请求格式:

POST /reg 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 400 Bad Request
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 /hall HTTP/1.1
Connection: Upgrade
Upgrade: WebSocket

响应格式:

HTTP/1.1 101 Switching

游戏大厅长连接握手成功后,服务器向客户端发送响应:

{
"optype": "hall_ready",
"uid": 1
}

游戏房间长连接握手成功后,服务器向客户端发送响应:

/*协议切换成功, 房间已经建立*/
{
"optype": "room_ready",
"room_id": 222, //房间ID
"self_id": 1, //自身ID
"white_id": 1, //白棋ID
"black_id": 2, //黑棋ID
}

开始匹配请求/响应

因为这之后使用的是 WebSocket 协议进行通信,就不需要携带 HTTP 协议的请求头了

请求格式:

{
"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",
"room_id": 222,
"uid": 1,
"row": 3,
"col": 2,
"result": true,
"reason": "对方掉线,不战而胜!" / "对方/己方五星连珠,战无敌/虽败犹荣!",
"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请求

对于 HTTP 请求,可以细分为几个类型的请求:

  • 静态资源的请求:注册页面和登录页面的获取。
  • 用户注册功能的请求:该请求是用户提交注册的信息。
  • 用户登录功能的请求:该请求是用户提高登录信息的请求。
  • 用户信息获取的请求:该请求是用户进入游戏大厅,在游戏大厅显示用户段位信息的请求。

对于以上请求,都是通过 HTTP 组织的消息,服务端在收到该请求时,通过通过注册的回调函数来做不同的处理:

_wssvr.set_http_handler(std::bind(&gobang_server::http_callback, this, std::placeholders::_1));

http_callback 函数逻辑如下:

void http_callback(websocketpp::connection_hdl hdl)
{// HTTP通信wsserver_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 == "/reg"){return register_(conn);}else if (method == "POST" && uri == "/login"){return login(conn);}else if (method == "GET" && uri == "/info"){return info(conn);}elsereturn file_handler(conn);
}
静态资源获取

对于静态资源,要有一个 记录静态资源根目录 的字符串,用来找到各种静态页面所使用的路径。如果静态资源根目录为./wwwroot/,则当收到一个注册页面的请求时register,要转为./wwwroot/register

读取静态文件内容,并返回。该工作由file_handler函数完成:

// 静态资源请求的处理
void file_handler(wsserver_t::connection_ptr &conn)
{// 1. 获取请求连接的uri,了解客户端请求的页面文件名称websocketpp::http::parser::request req = conn->get_request();std::string uri = req.get_uri();// 2. 根据相对路径 + uri 组合出请求文件的实际路径std::string pathname = _web_root + uri;// 3. 如果请求的是一个目录,增加一个后缀if (pathname.back() == '/')pathname += "login.html";// 4. 读取文件内容:如果文件不存在,则读取内容失败,返回404std::string body;bool ret = file_util::read(pathname, body);if (ret == false){std::string not_found = "./wwwroot/404.html";file_util::read(not_found, body); // 读取404页面conn->set_status(websocketpp::http::status_code::not_found);conn->set_body(body);return;}// 5. 设置响应正文conn->set_body(body);conn->set_status(websocketpp::http::status_code::ok);
}
注册请求

需要完成以下工作(每一步成功后,才能执行下一个操作,如果失败就组织错误响应):

  • 获取连接中的请求,并将其反序列化
  • 校验用户注册的信息是否合理、完整
  • 通过数据管理模块类对象,将用户信息存放在数据库中
  • 组织响应,并发送
// 用户注册功能请求的处理
void register_(wsserver_t::connection_ptr &conn)
{websocketpp::http::parser::request req = conn->get_request();// 1. 获取请求正文(获得用户提交的注册数据)std::string request_body = conn->get_request_body();// 2. 对正文进行Json反序列化,得到用户名和密码Json::Value register_info; // 用户注册信息bool ret = json_util::unserialize(register_info, request_body);if (ret == false){return http_response(false, "请求正文格式错误 >︿< ", websocketpp::http::status_code::bad_request, conn);}// 3. 进行数据库的用户新增操作:如果成功了,返回200;如果失败了,返回400if (register_info["username"].isNull() || register_info["password"].isNull()){return http_response(false, "请输入用户名/密码 >︿< ", websocketpp::http::status_code::bad_request, conn);}ret = _tb_user.insert(register_info);if (ret == false){return http_response(false, "用户名已经被占用 >︿< ", websocketpp::http::status_code::bad_request, conn);}// 成功了return http_response(true, "用户注册成功  φ(* ̄0 ̄) ", websocketpp::http::status_code::ok, conn);
}
登录请求

需要完成以下工作(每一步成功后,才能执行下一个操作,如果失败就组织错误响应):

  • 获取连接中的请求,并将其反序列化
  • 校验用户注册的信息是否合理、完整
  • 通过数据管理模块类对象,查询数据库中是否存在该用户信息
  • 创建session,并为会话设置一个过期时间
  • 组织响应,将session_id以cookie字段返回给客户端
// 用户登录功能请求的处理
void login(wsserver_t::connection_ptr &conn)
{websocketpp::http::parser::request req = conn->get_request();// 1. 获取请求正文(获得用户提交的注册数据)std::string request_body = conn->get_request_body();// 2. 对正文进行Json反序列化,得到用户名和密码Json::Value login_info; // 用户注册信息bool ret = json_util::unserialize(login_info, request_body);if (ret == false){LOG(DEBUG, "请求正文格式错误");return http_response(false, "请求正文格式错误 >︿< ", websocketpp::http::status_code::bad_request, conn);}// 3. 进行数据的完整性验证,如果验证失败,返回400if (login_info["username"].isNull() || login_info["password"].isNull()){LOG(DEBUG, "请输入用户名/密码");return http_response(false, "请输入用户名/密码 >︿< ", websocketpp::http::status_code::bad_request, conn);}ret = _tb_user.login(login_info);if (ret == false){LOG(DEBUG, "用户名/密码错误");return http_response(false, "用户名/密码错误 >︿< ", websocketpp::http::status_code::bad_request, conn);}// 4. 如果验证成功,创建sessionuint64_t uid = login_info["id"].asUInt64();session_ptr sp = _session_manager.create_session(uid, session_status::LOGIN);if (sp.get() == nullptr){LOG(DEBUG, "创建会话失败");return http_response(false, "创建会话失败 >︿< ", websocketpp::http::status_code::internal_server_error, conn);}// 设置 session 的过期时间_session_manager.set_session_expir_time(sp->get_session_id(), SESSION_TIMEOUT);// 5. 设置响应头部,Set-Cookie,通过Cookie返回sessionidstd::string cookie = "SSID=" + std::to_string(sp->get_session_id());conn->append_header("Set-Cookie", cookie);http_response(true, "登录成功  φ(* ̄0 ̄) ", websocketpp::http::status_code::ok, conn);return;
}
用户信息获取

需要完成以下工作(每一步成功后,才能执行下一个操作,如果失败就组织错误响应):

  • 获取连接中的请求,判断请求中是否携带cookie字段,并且session_id是否存在
  • 并将其反序列化
  • 通过sessionID获取uid,校验用户是否存在
  • 通过数据管理模块类对象,查询数据库获取该用户的对战信息
  • 组织响应,返回给客户端
// 用户信息获取功能请求的处理
void info(wsserver_t::connection_ptr &conn)
{// 1. 先获取请求连接中的 Cookie字段,如果没有该字段就不能处理std::string cookie_str = conn->get_request_header("Cookie");if (cookie_str.empty()){// 没有cookie的用户,需要先登录return http_response(false, "找不到cookie信息,请重新登录 >︿< ", websocketpp::http::status_code::bad_request, conn);}// 2. 从 Cookie 字段中获取 sessionIDstd::string sessionID_str;bool ret = get_cookie_val(cookie_str, "SSID", sessionID_str);if (ret == false){// Cookie 中没有sessionID值,需要先登录return http_response(false, "找不到sessionID信息,请重新登录 >︿< ", websocketpp::http::status_code::bad_request, conn);}// 3. 查找后台是否有sessionID所对应的会话uint64_t sessionID = std::stol(sessionID_str);session_ptr sp = _session_manager.get_session_byId(sessionID);if (sp.get() == nullptr){// 登录过期,需要重新登录return http_response(false, "登录过期,需要重新登录 >︿< ", websocketpp::http::status_code::bad_request, conn);}// 4. 根据sessionID获取uiduint16_t uid = sp->get_uid();Json::Value user_info;ret = _tb_user.select_by_id(uid, user_info);if (ret == false){// 找不到用户信息,需要重新登录return http_response(false, "找不到用户信息,需要重新登录 >︿< ", websocketpp::http::status_code::bad_request, conn);}// 5. 获取用户信息,进行序列化后发送给前端std::string user_info_str;json_util::serialize(user_info, user_info_str);conn->set_body(user_info_str);conn->append_header("Content-Type", "application/json");conn->set_status(websocketpp::http::status_code::ok);// 6. 设置该session的过期时间_session_manager.set_session_expir_time(sessionID, SESSION_TIMEOUT);
}

WebSocket请求

对于 WebSocket 请求,可以细分为几个类型的请求:

  • 静态资源的请求:注册页面和登录页面的获取。
  • 用户注册功能的请求:该请求是用户提交注册的信息。
  • 用户登录功能的请求:该请求是用户提高登录信息的请求。
  • 用户信息获取的请求:该请求是用户进入游戏大厅,在游戏大厅显示用户段位信息的请求。

对于以上请求,都是通过 HTTP 组织的消息,服务端在收到该请求时,通过通过注册的回调函数来做不同的处理:

_wssvr.set_open_handler(std::bind(&gobang_server::open_callback, this, std::placeholders::_1));
_wssvr.set_close_handler(std::bind(&gobang_server::close_callback, this, std::placeholders::_1));
_wssvr.set_message_handler(std::bind(&gobang_server::message_callback, this, std::placeholders::_1, std::placeholders::_2));

注意: 在默认状态下,WebSocketpp 服务器返回的响应的状态码 为500

http://www.dtcms.com/a/284906.html

相关文章:

  • MATLAB电力系统暂态稳定分析
  • 掌握Git核心技巧:深入理解.gitignore文件的使用
  • 【Bluedroid】btif_a2dp_sink_init 全流程源码解析
  • 25.7.16 25.7.17 每日一题——找出有效子序列的最大长度 I/II
  • NumPy 数组存储字符串的方法
  • 「Linux命令基础」Shell常见命令
  • Qwen3-8B Dify RAG环境搭建
  • 从C#6天学会Python:速通基础语法(第一天)
  • 【面板数据】企业劳动收入份额数据集-含代码及原始数据(2007-2022年)
  • 模板方法设计模式
  • JUnit5 实操
  • 杭州卓健信息科技有限公司 Java 面经
  • CPP学习之list使用及模拟实现
  • 【39】MFC入门到精通——C++ /MFC操作文件行(读取,删除,修改指定行)
  • 闲庭信步使用图像验证平台加速FPGA的开发:第二十一课——高斯下采样后图像还原的FPGA实现
  • 在VsCode上使用开发容器devcontainer
  • 基于MATLAB的极限学习机ELM的数据分类预测方法应用
  • VSCode 配置 C# 开发环境完整教程(附效果截图)
  • 【后端】.NET Core API框架搭建(7) --配置使用Redis
  • java-字符串
  • 东芝2822AM复印机请求维修C449简单操作修复步骤
  • vue3 自定义vant-calendar header/footer/maincontent
  • 【实时Linux实战系列】利用容器化实现实时应用部署
  • 量化环节剖析
  • 鸿蒙Navigation跳转页面白屏
  • 【agent实战】基于 LangGraph 实现 Agentic RAG:原理、实战与创新全解
  • SII9022ACNU-富利威-HDMI芯片
  • stack,queue,priority_queue的模拟实现及常用接口
  • Qt6-学习Cmakelist(翻译官方文档)
  • Pytorch深度学习框架实战教程02:开发环境部署