【项目设计】网络版五子棋游戏

2023-11-19 08:28:48 浏览数 (1)

一、项目介绍

1. 项目简介

本项目主要是实现一个网页版的在线五子棋对战游戏,它主要支持以下核心功能:

  • 用户数据管理:实现用户注册与登录、用户session信息管理、用户比赛信息 (天梯分数、比赛场次、获胜场次) 管理等。
  • 匹配对战功能:实现两个在线玩家在网页端根据天梯分数进行对战匹配,匹配成功后在游戏房间中进行五子棋对战的功能。
  • 实时聊天功能:实现两个玩家在游戏过程中能够进行实时聊天的功能。

2. 开发环境

本项目的开发环境如下:

  • Linux:在 Centos7.6 环境下进行数据库部署与开发环境搭建。
  • VSCode/Vim:通过 VSCode 远程连接服务器或直接使用 Vim 进行代码编写与功能测试。
  • g /gdb:通过 g /gdb 进行代码编译与调试。
  • Makefile:通过 Makefile 进行项目构建。

3. 核心技术

本项目所使用到的核心技术如下:

  • HTTP/WebSocket:使用 HTTP/WebSocket 完成客户端与服务器的短连接/长连接通信。
  • WebSocketpp:使用 WebSocketpp 实现 WebSocket 协议的通信功能。
  • JsonCpp:封装 JsonCpp 完成网络数据的序列与反序列功能。
  • MySQL C API:封装 MySQL C API 完成在 C 程序中访问和操作 MySQL 数据库的功能。
  • C 11:使用 C 11 中的某些新特性完成代码的编写,例如 bind/shared_ptr/thread/mutex。
  • BlockQueue:为不同段位的玩家设计不同的阻塞式匹配队列来完成游戏的匹配功能。
  • HTML/CSS/JS/AJAX:通过 HTML/CSS/JS 来构建与渲染游戏前端页面,以及通过 AJAX来向服务器发送 HTTP 客户端请求。

4. 开发阶段

本项目一共分为四个开发阶段:

  1. 环境搭建:在 Centos7.6 环境下安装本项目会使用到的各种工具以及第三方库。
  2. 前置知识了解:对项目中需要用到的一些知识进行了解,学会它们的基本使用,比如 bind/WebSocketpp/HTML/JS/AJAX 等。
  3. 框架设计:进行项目模块划分,确定每一个模块需要实现的功能。
  4. 模块开发 && 功能测试:对各个子模块进行开发与功能测试,最后再将这些子模块进行整合并进行整体功能测试。

二、环境搭建

1. 安装 wget 工具

代码语言:javascript复制
sudo yum install wget

2. 更换 yum 源

备份之前的 yum 源:

代码语言:javascript复制
sudo mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak

更换 yum 源为国内阿里的镜像 yum 源:

代码语言:javascript复制
sudo wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo
代码语言:javascript复制
sudo yum clean all
代码语言:javascript复制
sudo yum makecache

安装 scl 软件源:

代码语言:javascript复制
sudo yum install centos-release-scl-rh centos-release-scl

安装 epel 软件源:

代码语言:javascript复制
sudo yum install epel-release

3. 安装 lrzsz 传输工具

代码语言:javascript复制
sudo yum install lrzsz

4. 安装⾼版本 gcc/g 编译器

安装 devtoolset 高版本 gcc/g 编译器:

代码语言:javascript复制
sudo yum install devtoolset-7-gcc devtoolset-7-gcc-c  

将 devtoolset 加载配置指令添加到终端初始化配置文件中,使其在以后的所有新打开终端中有效:

代码语言:javascript复制
echo "source /opt/rh/devtoolset-7/enable" >> ~/.bashrc

重新加载终端配置文件:

代码语言:javascript复制
source ~/.bashrc

5. 安装 gdb 调试器

代码语言:javascript复制
sudo yum install gdb

6. 安装分布式版本控制工具 git

代码语言:javascript复制
sudo yum install git

7. 安装 cmake

代码语言:javascript复制
sudo yum install cmake

8. 安装 boost 库

代码语言:javascript复制
sudo yum install boost-devel.x86_64 

9. 安装 Jsoncpp 库

代码语言:javascript复制
sudo yum install jsoncpp-devel

10. 安装 MySQL 数据库服务及开发包

安装 MySQL 环境:【MySQL】Linux 中 MySQL 环境的安装与卸载

设置 MySQL 用户与密码:【MySQL】用户与权限管理

11. 安装 WebSocketpp 库

从 github 官方仓库克隆 WebSocketpp 库:

代码语言:javascript复制
git clone https://github.com/zaphoyd/websocketpp.git

由于 github 服务器在国外,所以可能会出现 clone 失败的情况,此时可以从 gitee 仓库克隆 WebSocketpp 库:

代码语言:javascript复制
git clone https://gitee.com/freeasm/websocketpp.git

clone 成功后执行如下指令来安装 WebSocketpp 库 (执行 git clone 语句的目录下):

代码语言:javascript复制
cd websocketpp/
mkdir build
cd build
cmake -DCMAKE_INSTALL_PREFIX=/usr ..
sudo make install

验证 websocketpp 是否安装成功 (build 目录下):

代码语言:javascript复制
cd ../examples/echo_server

当前目录下 ls 显示:

代码语言:javascript复制
CMakeLists.txt echo_handler.hpp echo_server.cpp SConscript

g 编译 echo_server.cpp,如果编译成功则说明安装成功:

代码语言:javascript复制
g   -std=c  11 echo_server.cpp -o echo_server -lpthread -lboost_system

三、前置知识了解

1. WebSocketpp

1.1 WebSocket 协议

WebSocket 介绍

WebSocket 是从 HTML5 开始支持的⼀种网页端和服务端保持长连接的消息推送机制:

  • 传统的 web 程序都是属于 “⼀问⼀答” 的形式,即客户端给服务器发送⼀个 HTTP 请求,然后服务器给客⼾端返回⼀个 HTTP 响应。这种情况下服务器是属于被动的一方,即如果客户端不主动发起请求,那么服务器也就⽆法主动给客户端响应。
  • 但是像网页即时聊天或者五子棋游戏这样的程序都是非常依赖 “消息推送” 的,即需要服务器主动推动消息到客户端 (将一个客户端发送的消息或下棋的动作主动发送给另一个客户端)。那么如果只是使⽤原⽣的 HTTP 协议,要想实现消息推送⼀般就需要通过 “Ajax 轮询” 的方式来实现,而轮询的成本是比较高的,并且客户端也不能及时的获取到消息的响应。

为了解决上述两个问题,有大佬就设计了一种新的应用层协议 – WebSocket 协议。WebSocket 更接近于 TCP 这种级别的通信⽅式,⼀旦连接建立完成客户端或者服务器都可以主动的向对方发送数据

原理解析

WebSocket 协议本质上是⼀个基于 TCP 的协议。为了建⽴⼀个 WebSocket 连接,客户端浏览器会通过 JavaScript 向服务器发出建立 WebSocket 连接的请求,这个连接请求本质上仍然是一个 HTTP 请求,但它包含了⼀些附加头部信息,比如协议升级"Upgrade: WebSocket",服务器端解析这些附加的头信息然后产生应答信息返回给客户端,客户端和服务器端的 WebSocket 连接就建立起来了,双方就可以通过这个连接通道自由的传递信息,并且这个连接会持续存在直到客户端或者服务器端的某一方主动的关闭连接。。

同时,当客户端浏览器获取到 Web Socket 连接后,之后的通信就不再通过 Ajax 构建客户端请求发送给服务器了,而是直接使用 WebSocket 的 send() 方法方法来向服务器发送数据,并通过 onmessage 事件来接收服务器返回的数据。

报文格式

WebSocket 报文格式如下,大家了解即可:

WebSocket 相关接口

创建 WebSocket 对象:

代码语言:javascript复制
var Socket = new WebSocket(url, [protocol]);

WebSocket 对象的相关事件:

WebSocket 对象的相关方法:

参考资料:

https://www.runoob.com/html/html5-websocket.html https://www.bilibili.com/video/BV1684y1k7VP/?buvid=ZC4691C539D91BA74044…&vd_source=cbc46a2fc528c4362ce79ac44dd49e2c

1.2 WebSocketpp

WebSocketpp 介绍

WebSocketpp 是⼀个跨平台的开源 (BSD许可证) 头部专⽤C 库,它实现了RFC6455 (WebSocket协议) 和 RFC7692 (WebSocketCompression?Extensions)。它允许将 WebSocket 客户端和服务器功能集成到 C 程序中。在最常见的配置中,全功能网络 I/O 由 Asio 网络库提供。

WebSocketpp 如要有以下特性:

  • 事件驱动的接口。
  • ⽀持HTTP/HTTPS、WS/WSS、IPv6。
  • 灵活的依赖管理 – Boost库/C 11标准库。
  • 可移植性 – Posix/Windows、32/64bit、Intel/ARM。
  • 线程安全。

WebSocketpp 同时支持 HTTP 和 Websocket 两种网络协议,比较适用于我们本次的项目,所以我们选用该库作为项目的依赖库,用来搭建 HTTP 和 WebSocket 服务器。

以下是 WebSocketpp 的一些相关网站:

github:https://github.com/zaphoyd/websocketpp

用户手册:http://docs.websocketpp.org/

官网:http://www.zaphoyd.com/websocketpp

WebSocketpp 的使用

WebSocketpp 常用接口及其功能介绍如下:

代码语言:javascript复制
namespace websocketpp
{
    typedef lib::weak_ptr<void> connection_hdl;
    template <typename config>
    class endpoint : public config::socket_type
    {
        typedef lib::shared_ptr<lib::asio::steady_timer> timer_ptr;
        typedef typename connection_type::ptr connection_ptr;
        typedef typename connection_type::message_ptr message_ptr;
        typedef lib::function<void(connection_hdl)> open_handler;
        typedef lib::function<void(connection_hdl)> close_handler;
        typedef lib::function<void(connection_hdl)> http_handler;
        typedef lib::function<void(connection_hdl, message_ptr)>
            message_handler;
        /* websocketpp::log::alevel::none 禁⽌打印所有⽇志*/
        void set_access_channels(log::level channels);   /*设置⽇志打印等级*/
        void clear_access_channels(log::level channels); /*清除指定等级的⽇志*/
        /*设置指定事件的回调函数*/
        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);
        /*websocketpp基于asio框架实现,init_asio⽤于初始化asio框架中的io_service调度
        器*/
        void init_asio();
        /*设置是否启⽤地址重⽤*/
        void set_reuse_addr(bool value);
        /*设置endpoint的绑定监听端⼝*/
        void listen(uint16_t port);
        /*对io_service对象的run接⼝封装,⽤于启动服务器*/
        std::size_t run();
        /*websocketpp提供的定时器,以毫秒为单位*/
        timer_ptr set_timer(long duration, timer_handler callback);
    };
    template <typename config>
    class server : public endpoint<connection<config>, config>
    {
        /*初始化并启动服务端监听连接的accept事件处理*/
        void start_accept();
    };
    template <typename config>
    class connection
        : public config::transport_type::transport_con_type,
          public config::connection_base
    {
        /*发送数据接⼝*/
        error_code send(std::string &payload, frame::opcode::value
                                                  op = frame::opcode::text);
        /*获取http请求头部*/
        std::string const &get_request_header(std::string const &key)
            /*获取请求正⽂*/
            std::string const &get_request_body();
        /*设置响应状态码*/
        void set_status(http::status_code::value code);
        /*设置http响应正⽂*/
        void set_body(std::string const &value);
        /*添加http响应头部字段*/
        void append_header(std::string const &key, std::string const &val);
        /*获取http请求对象*/
        request_type const &get_request();
        /*获取connection_ptr 对应的 connection_hdl */
        connection_hdl get_handle();
    };
    namespace http
    {
        namespace parser
        {
            class parser
            {
                std::string const &get_header(std::string const &key);
            }; 
            class request : public parser
            {
                /*获取请求⽅法*/
                std::string const &get_method();
                /*获取请求uri接⼝*/
                std::string const &get_uri();
            };
        }
    };
    namespace message_buffer
    {
        /*获取websocket请求中的payload数据类型*/
        frame::opcode::value get_opcode();
        /*获取websocket中payload数据*/
        std::string const &get_payload();
    };
    namespace log
    {
        struct alevel
        {
            static level const none = 0x0;
            static level const connect = 0x1;
            static level const disconnect = 0x2;
            static level const control = 0x4;
            static level const frame_header = 0x8;
            static level const frame_payload = 0x10;
            static level const message_header = 0x20;
            static level const message_payload = 0x40;
            static level const endpoint = 0x80;
            static level const debug_handshake = 0x100;
            static level const debug_close = 0x200;
            static level const devel = 0x400;
            static level const app = 0x800;
            static level const http = 0x1000;
            static level const fail = 0x2000;
            static level const access_core = 0x00003003;
            static level const all = 0xffffffff;
        };
    }
    namespace http
    {
        namespace status_code
        {
            enum value
            {
                uninitialized = 0,
                continue_code = 100,
                switching_protocols = 101,
                ok = 200,
                created = 201,
                accepted = 202,
                non_authoritative_information = 203,
                no_content = 204,
                reset_content = 205,
                partial_content = 206,
                multiple_choices = 300,
                moved_permanently = 301,
                found = 302,
                see_other = 303,
                not_modified = 304,
                use_proxy = 305,
                temporary_redirect = 307,
                bad_request = 400,
                unauthorized = 401,
                payment_required = 402,
                forbidden = 403,
                not_found = 404,
                method_not_allowed = 405,
                not_acceptable = 406,
                proxy_authentication_required = 407,
                request_timeout = 408,
                conflict = 409,
                gone = 410,
                length_required = 411,
                precondition_failed = 412,
                request_entity_too_large = 413,
                request_uri_too_long = 414,
                unsupported_media_type = 415,
                request_range_not_satisfiable = 416,
                expectation_failed = 417,
                im_a_teapot = 418,
                upgrade_required = 426,
                precondition_required = 428,
                too_many_requests = 429,
                request_header_fields_too_large = 431,
                internal_server_error = 500,
                not_implemented = 501,
                bad_gateway = 502,
                service_unavailable = 503,
                gateway_timeout = 504,
                http_version_not_supported = 505,
                not_extended = 510,
                network_authentication_required = 511
            };
        }
    }
    namespace frame
    {
        namespace opcode
        {
            enum value
            {
                continuation = 0x0,
                text = 0x1,
                binary = 0x2,
                rsv3 = 0x3,
                rsv4 = 0x4,
                rsv5 = 0x5,
                rsv6 = 0x6,
                rsv7 = 0x7,
                close = 0x8,
                ping = 0x9,
                pong = 0xA,
                control_rsvb = 0xB,
                control_rsvc = 0xC,
                control_rsvd = 0xD,
                control_rsve = 0xE,
                control_rsvf = 0xF,
            };
        }
    }
}

使用 WebSocketpp 搭建一个简单服务器的流程如下:

  1. 实例化一个 websocketpp::server 对象。
  2. 设置日志等级。(本项目中我们使用自己封装的日志函数,所以这里设置日志等级为 none)
  3. 初始化 asio 调度器。
  4. 设置处理 http 请求、websocket 握手成功、websocket 连接关闭以及收到 websocket 消息的回调函数。
  5. 设置监听端口。
  6. 开始获取 tcp 连接。
  7. 启动服务器。

示例代码如下:

代码语言:javascript复制
#include <iostream>
#include <string>
#include <functional>
#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>
using std::cout;
using std::endl;

typedef websocketpp::server<websocketpp::config::asio> wsserver_t;

void http_callback(wsserver_t *srv, websocketpp::connection_hdl hdl) {
    wsserver_t::connection_ptr conn = srv->get_con_from_hdl(hdl);
    std::cout << "body: " << conn->get_request_body() << std::endl;
    websocketpp::http::parser::request req = conn->get_request();
    std::cout << "method: " << req.get_method() << std::endl;
    std::cout << "uri: " << req.get_uri() << std::endl;

    // 响应一个hello world页面
    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);
}
void wsopen_callback(wsserver_t *srv, websocketpp::connection_hdl hdl) {
    cout << "websocket握手成功" << std::endl;
}
void wsclose_callback(wsserver_t *srv, websocketpp::connection_hdl hdl) {
    cout << "websocket连接关闭" << endl;
}
void wsmessage_callback(wsserver_t *srv, websocketpp::connection_hdl hdl, wsserver_t::message_ptr msg) {
    wsserver_t::connection_ptr conn = srv->get_con_from_hdl(hdl);
    cout << "wsmsg: " << msg->get_payload() << endl;
    std::string rsp = "[server]# "   msg->get_payload();
    conn->send(rsp, websocketpp::frame::opcode::text);
}

int main()
{
    // 1. 实例化server对象
    wsserver_t wssrv;
    // 2. 设置日志等级
    wssrv.set_access_channels(websocketpp::log::alevel::none);
    // 3. 初始化asio调度器
    wssrv.init_asio();
    // 4. 设置回调函数
    wssrv.set_http_handler(std::bind(http_callback, &wssrv, std::placeholders::_1));
    wssrv.set_open_handler(std::bind(wsopen_callback, &wssrv, std::placeholders::_1));
    wssrv.set_close_handler(std::bind(wsclose_callback, &wssrv, std::placeholders::_1));
    wssrv.set_message_handler(std::bind(wsmessage_callback, &wssrv, std::placeholders::_1, std::placeholders::_2));
    // 5. 设置监听端口
    wssrv.listen(8080);
    wssrv.set_reuse_addr(true);
    // 6. 开始获取tcp连接
    wssrv.start_accept();
    // 7. 启动服务器
    wssrv.run();
    return 0; 
}

2. JsonCpp

Json 数据格式

Json 是⼀种数据交换格式,它采⽤完全独立于编程语⾔的⽂本格式来存储和表示数据。

比如,们想表示⼀个同学的学⽣信息。在 C/C 中我们可能使用结构体/类来表示:

代码语言:javascript复制
typedef struct {
    char *name = "XXX";
    int age = 18;
    float score[3] = { 88.5, 99, 58 };
} stu;

而用 Json 数据格式表示如下:

代码语言:javascript复制
{
    "姓名" : "xxX",
    "年龄" : 18,
    "成绩" : [88.5, 99, 58]
}

Json 的数据类型包括对象,数组,字符串,数字等:

  • 对象:使用花括号 {} 括起来的表示⼀个对象。
  • 数组:使用中括号 [] 括起来的表示⼀个数组。
  • 字符串:使用常规双引号 “” 括起来的表示⼀个字符串。
  • 数字:包括整形和浮点型,直接使用。

JsonCpp 介绍

Jsoncpp 库主要是⽤于实现 Json 格式数据的序列化和反序列化,它实现了将多个数据对象组织成为 json 格式字符串,以及将 Json 格式字符串解析得到多个数据对象的功能。

Json 数据对象类的部分表示如下:

代码语言:javascript复制
class Json::Value{
    /*Value重载了[]和=,因此所有的赋值和获取数据都可以通过简单的⽅式完成 val["name"] ="xx"*/
    Value &operator=(const Value &other); 
    Value& operator[](const std::string& key); 
    Value& operator[](const char* key);
    /*移除元素*/
    Value removeMember(const char* key); 
    /*val["score"][0]*/
    const Value& operator[](ArrayIndex index) const; 
    /*添加数组元素 -- val["score"].append(88)*/
    Value& append(const Value& value);
    /*获取数组元素个数 -- val["score"].size()*/
    ArrayIndex size() const; 
    /*⽤于判断是否存在某个字段*/
    bool isNull(); 
    /*json格式数据转string类型 -- string name = val["name"].asString()*/
    std::string asString() const; 
    /*json格式数据转C语言格式的字符串即char*类型 -- char *name = val["name"].asCString()*/
    const char* asCString() const;
    /*转int -- int age = val["age"].asInt()*/
    Int asInt() const; 
    /*转无符号长整型uint64_t -- uint64_t id = val["id"].asUInt64()*/
    Uint64 asUint64() const;
    /*转浮点数 -- float weight = val["weight"].asFloat()*/
    float asFloat() const; 
    /*转bool类型 -- bool ok = val["ok"].asBool()*/
    bool asBool() const;
};

Jsoncpp 库主要借助三个类以及其对应的少量成员函数完成序列化及反序列化。

序列化接口:

代码语言:javascript复制
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;
}

反序列化接口:

代码语言:javascript复制
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;
}

使用 jsonCpp 将数据序列化的步骤如下:

  1. 将需要序列化的数据存储在Json::Value对象中。
  2. 实例化StreamWriterBuilder工厂类对象。
  3. 使用StreamWriterBuilder工厂类对象实例化StreamWriter对象。
  4. 使用StreamWriter对象完成Json::Value中数据的序列化工作,并将序列化结果存放到ss中。

使用 JsonCpp 将数据反序列化的步骤如下:

  1. 实例化一个 CharReaderBuilder 工厂类对象。
  2. 使用CharReaderBuilder对象实例化一个CharReader对象。
  3. 创建一个Json::Value对象,用于保存json格式字符串反序列化后的结果。
  4. 使用CharReader对象完成json格式字符串的反序列化工作。

示例代码如下:

代码语言:javascript复制
#include <iostream>
#include <string>
#include <sstream>
#include <jsoncpp/json/json.h>
using std::cout;
using std::endl;

/*使用jsonCpp完成数据的序列化工作*/
std::string serialize()
{
    // 1. 将需要序列化的数据存储在Json::Value对象中
    Json::Value root;
    root["姓名"] = "小明";
    root["年龄"] = 18;
    root["成绩"].append(80);  //成绩是数组类型
    root["成绩"].append(90);
    root["成绩"].append(100);
    // 2. 实例化StreamWriterBuilder工厂类对象
    Json::StreamWriterBuilder swb;
    // 3. 使用StreamWriterBuilder工厂类对象实例化StreamWriter对象
    Json::StreamWriter *sw = swb.newStreamWriter();
    // 4. 使用StreamWriter对象完成Json::Value中数据的序列化工作,并将序列化结果存放到ss中
    std::stringstream ss;
    int n = sw->write(root, &ss);
    if(n != 0)
    {
        cout << "json serialize fail" << endl;
        delete sw;
        return "";  
    }
    
    delete sw;
    return ss.str();
}

/*使用JsonCpp完成序列化数据的反序列化工作*/
void deserialize(const std::string &str)
{
    // 1. 实例化一个CharReaderBuilder工厂类对象
    Json::CharReaderBuilder crb;
    // 2. 使用CharReaderBuilder对象实例化一个CharReader对象
    Json::CharReader *cr = crb.newCharReader();
    // 3. 创建一个Json::Value对象,用于保存json格式字符串反序列化后的结果
    Json::Value root;
    // 4. 使用CharReader对象完成json格式字符串的反序列化工作
    std::string errmsg;
    bool ret = cr->parse(str.c_str(), str.c_str()   str.size(), &root, &errmsg);
    if(ret == false)
    {
        cout << "json deserialize fail: " << errmsg << endl;
        delete cr;
        return;
    }
    // 5. 依次打印Json::Value中的数据
    cout << "姓名: " << root["姓名"].asString() << endl;
    cout << "年龄: " << root["年龄"].asInt() << endl; 
    int size = root["成绩"].size();
    for(int i = 0; i < size; i  )
    {
        cout << "成绩: " << root["成绩"][i].asFloat() << endl;
    }
}

int main()
{
    std::string str = serialize();
    cout << str << endl;
    deserialize(str);
    
    return 0;
}

3. C 11

C 11 bind 参考文章:

std::bind(一):包装普通函数

std::bind(二):包装成员函数

C 11 智能指针参考文章:

【C 】智能指针

C 11 线程库/互斥锁/条件变量参考文章:

【C 】C 11 线程库

4. GDB

GDB 是一个强大的命令行式的源代码级调试工具,可以用于分析和调试 C/C 等程序,在程序运行时检查变量的值、跟踪函数调用、设置断点以及其他调试操作。GDB 在服务器开发中使用非常广泛,一个合格的后台开发/服务器开发程序员应该能够使用 GDB 来调试程序。

由于 GDB 是纯命令行的,所以我们需要学习 GDB 相关的一些基本指令,下面是陈皓大佬编写的关于 GDB 调试技巧的博客,供大家参考:

https://so.csdn.net/so/search?q=gdb&t=blog&u=haoel

https://coolshell.cn/articles/3643.html

5. MySQL C API

参考文章:

【MySQL】C语言连接数据库

6. HTML/CSS/JS/AJAX

本项目中与前端有关的技术分别是HTML、CSS、JavaScript 和 AJAX:

  • HTML:标签语言,用于渲染前端网页。
  • CSS:层叠样式表,对 HTML 标签进行样式修饰,使其更加好看。
  • JavaScript:脚本语言,在 web 前端中主要用于控制页面的渲染,使得前端静态页面能够根据数据的变化而变化。
  • AJAX:一个异步的 HTTP 客户端,它可以异步的向服务器发送 HTTP 请求,获取响应并进行处理。

注意:本项目中只是对上述这些前端技术进行一个最基本的使用,目的是能够通过它们做出一个简单的前端页面。

6.1 HTML 简单了解

HTML 标签:HTML 代码是由 “标签” 构成的

  • 标签名 (body) 放到 < > 中。
  • 大部分标签成对出现, 为开始标签, 为结束标签。
  • 少数标签只有开始标签, 称为 “单标签”。
  • 开始标签和结束标签之间, 写的是标签的内容。(hello)
  • 开始标签中可能会带有 “属性”, id 属性相当于给这个标签设置了一个唯一的标识符。
代码语言:javascript复制
body>hello</body>
<body id="myId">hello</body>

HTML 文件基本结构:

  • html 标签是整个 html 文件的根标签(最顶层标签)。
  • head 标签中写页面的属性。
  • body 标签中写的是页面上显示的内容。
  • title 标签中写的是页面的标题。
代码语言:javascript复制
<html>
  <head>
    <title>第一个页面</title>
  </head>
  <body>
   hello world
  </body>
</html>

HTML 常见标签:

  • 注释标签:注释不会显示在界面上. 目的是提高代码的可读性。
代码语言:javascript复制
<!-- 我是注释 -->
  • 标题标签:标题标签一共有六个 – h1-h6,数字越大, 则字体越小。
代码语言:javascript复制
<h1>hello</h1>
<h2>hello</h2>
<!-- ... -->
  • 段落标签:p 标签表示一个段落。
代码语言:javascript复制
<p>这是一个段落</p>
  • 换行标签:br 是 break 的缩写,表示换行。br 是一个单标签(不需要结束标签)。
代码语言:javascript复制
<br/>
  • 图片标签 img:img 标签必须带有 src 属性表示图片的路径。
代码语言:javascript复制
<img src="./tmp.jpg">
代码语言:javascript复制
<img src="rose.jpg" alt="鲜花" title="这是一朵鲜花" width="500px" height="800px" border="5px">
  • 超链接标签 a:a 标签必须具备 href,表示点击后会跳转到哪个页面。同时可以指定 target 打开方式。(默认是 _self,如果是 _blank 则用新的标签页打开)
代码语言:javascript复制
<!-- 外部链接 -->
<a href="http://www.github.com">github</a>
<!-- 内部链接: 网站内部页面之间的链接 -->
<a href="2.html">点我跳转到 2.html</a>
<!-- 下载链接: href 对应的路径是一个文件 -->
<a href="test.zip">下载文件</a>
  • 列表标签:ul li 表示无序列表,ol li 表示有序列表,dl (总标签) dt (小标题) dd (围绕标题来说明) 表示自定义列表。
代码语言:javascript复制
<h3>无序列表</h3>
<ul>
  <li>HTML</li>
  <li>CSS</li>
  <li>JS</li>
</ul>
<h3>有序列表</h3>
<ol>
  <li>HTML</li>
  <li>CSS</li>
  <li>JS</li>
</ol>
<h3>自定义列表</h3>
<dl>
  <dt>前端相关:</dt>
  <dd>HTML</dd>
  <dd>CSS</dd>
  <dd>JS</dd>
</dl>

表单标签 (重要):表单是让用户输入信息的重要途径,分成两个部分 – 表单域和表单控件,其中表单域是包含表单元素的区域,重点是 form 标签;表单控件是输入框、提交按钮等,重点是 input 标签。

form 标签:描述了要把数据按照什么方式, 提交到哪个页面中。

代码语言:javascript复制
<form action="test.html">
 ... [form 的内容]
</form>

input 标签:各种输入控件, 单行文本框, 按钮, 单选框, 复选框等。

代码语言:javascript复制
<!-- 文本框 -->
<input type="text">
<!-- 密码框 -->
<input type="password">
<!-- 单选框 -->
<input type="radio" name="sex">男
<input type="radio" name="sex" checked="checked">女
<!-- 普通按钮 -->
<input type="button" value="我是个按钮">
<!-- 提交按钮 -->
<form action="test.html">
  <input type="text" name="username">
  <input type="submit" value="提交">
</form>

无语义标签 div & span:div 标签, division 的缩写, 含义是分割;span 标签, 含义是跨度。它们是两个盒子,一般搭配 CSS 用于网页布局。(div 是独占一行的, 是一个大盒子;而span 不独占一行, 是一个小盒子。)

代码语言:javascript复制
<div>
  <span>HTML</span>
  <span>CSS</span>
  <span>JS</span>
</div>

参考资料:

HTML 教程 – 菜鸟教程

6.2 CSS 简单了解

CSS (层叠样式表) 能够对网页中元素位置的排版进行像素级精确控制, 实现美化页面的效果, 能够做到页面的样式和结构分离。

CSS 基本语法规范是 选择器 {一条/N条声明}:

  • 选择器决定针对谁修改。
  • 声明决定修改什么内容。
  • 声明的属性是键值对,使用 “;” 区分键值对, 使用 “:” 区分键和值。
代码语言:javascript复制
/*对段落标签进行样式修饰*/
<style>
  p {
    /* 设置字体颜色 */
    color: red;
    /* 设置字体大小 */
    font-size: 30px;
 }
</style>
<p>hello</p>

选择器的功能是选中页面中指定的标签元素,然后对其进行修饰。选择器有很多种类,这里我们主要介绍基础选择器:

标签选择器:标签选择器的优点是能快速为同一类型的标签都选择出来,缺点是不能差异化选择。

代码语言:javascript复制
<!-- 对段落标签p进行样式修饰 -->
<style>
p {
  color: red;
}
</style>

<p>demo</p>

类选择器:类选择器的优点是可以差异化表示不同的标签,同时一个类可以被多个标签使用。类选择器就类似于我们给标签取了一个名字,然后对这个名字的所有标签统一进行样式修饰。

代码语言:javascript复制
<style>
  .blue {
    color: blue;
 }
</style>

<div class="blue">demo1</div>
<p class="blue">demo2</p>

id 选择器:和类选择器类似,不同的是 id 是唯一的, 不能被多个标签使用。

代码语言:javascript复制
<style>
  #ha {
    color: red;
 }
</style>

<div id="ha">demo</div>

通配符选择器:使用 * 的定义, 对所有的标签都有效。

CSS 的引入方式一般有三种:

内部样式表:直接写在 style 标签中,嵌入到 html 内部。(style 一般都是放到 head 标签中)

这样做的优点是能够让样式和页面结构分离,缺点是分离的不够彻底,在实际开发中并不常用。

代码语言:javascript复制
<style>
  div {
    color: red;
 }
</style>

行内样式表:通过 style 属性, 来指定某个标签的样式。

这种方法只适合于写简单样式,并且只针对某个标签生效,在实际开发中也不常用。

代码语言:javascript复制
<div style="color:green">想要生活过的去, 头上总得带点绿</div>

外部样式表 (重要):先 创建一个 css 文件,然后使用 link 标签引入 css。

这样做能够让让样式和页面结构彻底分离,即使是在 css 内容很多的时候,这也是实际开发中最常用的方式。

代码语言:javascript复制
<link rel="stylesheet" href="[CSS文件路径]">

参考资料:

css 教程 – 菜鸟教程

css 选择器参考手册 – W3school

6.3 JS 简单了解

JavaScript 的基本语法和 java 类似,所以我们不再单独学习。这里我们主要学习如何使用 JavaScript 去渲染前端页面,具体内容如下:

  • 如何使用 js 给按钮添加点击事件。
  • 如何使用 js 去获取以及设置一个页面控件的内容。
代码语言:javascript复制
<body>
    <input type="text" id="user_name">
    <input type="password" id="password">
    <!--为button按钮添加点击事件,调用登录函数-->
    <button id="submit" onclick="login()">提交</button>
    
    <div>
      <span>hello world</span>
      <span>hello world</span>
	</div>
</body>
代码语言:javascript复制
<javascript>
    function login() {
    	//获取输入框中的内容
        var username = document.getElementById("user_name").value;
        var password = document.getElementById("password").value;
        //服务器用户信息验证成功后提示登录成功
    	alert("登录成功");
    	//服务器用户信息验证失败后提示登录失败并清空输入框内容
    	alert("登录失败");
    	document.getElementById("user_name").value = "";
        document.getElementById("password").value = "";
	};
	//js相关的一些其他WebAPI
	function demo() {
        var div = getElementById("div");
        //读取页面内容
        var msg = div.innerHTML;
        //向控制台打印日志信息
        console.log(msg);
        //修改页面内容
        div.innerHTML = "<span>hello js</span>";
    }
</javascript>

参考资料:

JavaScript 教程 – 菜鸟教程

6.4 AJAX 简单了解

为了降低学习成本,这里我们并不使用 js 中原生的 AJAX,而是使用 jQuery 中的 AJAX:

  • jQuery 是一种基于JavaScript的开源库。它简化了HTML文档遍历、事件处理、动画效果等操作。通过使用jQuery,开发者可以更轻松地操作DOM元素、处理事件、发送AJAX请求以及创建动态效果,从而使网页开发变得更加便捷和灵活。
  • jQuery AJAX 是指使用 jQuery 库中提供的 AJAX 相关方法来进行异步数据交互。通过使用 jQuery 提供的 AJAX 方法,开发者可以轻松地执行诸如发送 GET 或 POST 请求、处理服务器响应、以及执行其他与异步数据交互相关的操作,简化了原生 JavaScript 中使用 XMLHttpRequest 对象进行 AJAX 操作的复杂性。
代码语言:javascript复制
<body>
    <input type="text" id="user_name">
    <input type="password" id="password">
    <!--为button按钮添加点击事件,调用登录函数-->
    <button id="submit" onclick="login()">提交</button>
</body>
// 引用jQuery库
<script src="jquery-1.10.2.min.js"></script>
<javascript>
    function login() {
    	//获取输入框中的内容
        var username = document.getElementById("user_name").value;
        var password = document.getElementById("password").value;
        // 通过ajax向服务器发送登录请求
        $.ajax({
            // 请求类型 -- get/post
            type: "post",
            // 请求资源路径
            url: "http://106.52.90.67/login",
            // 请求的数据
            data: JSON.stringify(log_info),
            // 请求成功处理函数
            success: function(res) {
                alert("登录成功");
            },
            // 请求失败处理函数
            error: function(xhr) {
                document.getElementById("user_name").value = "";
                document.getElementById("password").value = "";
                alert(JSON.stringify(xhr));
            }
        })
	};
</javascript>

参考资料:

jQuery 安装 – 菜鸟教程

jQuery Ajax 参考手册 – 菜鸟教程


四、框架设计

1. 项目模块划分

1.1 总体模块划分

本项目一共会划分为三个大的模块:

  • 用户数据管理模块:基于 MySQL 数据库进行用户数据的管理,包括用户名、密码、天梯分数、比赛场次、获胜场次等。
  • 前端界面模块:基于 HTTP/CSS/JS/AJAX 实现用户注册、登录、游戏大厅和游戏房间前端界面的动态控制以及与服务器的通信。
  • 业务处理模块:通过 WebSocketpp 相关 API 搭建 WebSocket 服务器与客户端浏览器进行通信,接受客户端请求并进行业务处理。
1.2 业务处理子模块划分

由于项目需要实现用户注册、用户登录、用户匹配对战以及游戏内实时聊天等不同的功能,所以需要对业务处理模块进行子模块划分,让不同的子模块负责不同的业务处理。

业务处理模块具体的子模块划分如下:

  • 网络通信模块:基于 websocketpp 库实现 Http&WebSocket 服务器的搭建,提供客户端与服务器的网络通信功能。
  • 会话管理模块:对客户端的连接进行 cookie&session 管理,实现 HTTP 短连接下客户端身份识别的功能。
  • 在线用户管理模块:对进行游戏大厅与游戏房间的用户进行在线管理,提供用户在线判断与用户 WebSocket 长连接获取等功能。
  • 游戏房间管理模块:为匹配成功的用户创建游戏房间,提供实时的五子棋对战与聊天业务功能。
  • 匹配对战管理:根据天梯分数为不同段位的玩家创建不同的匹配队列,为匹配成功的用户创建游戏房间并加入游戏房间。

2. 项目流程图

2.1 用户角度流程图

从用户/玩家的角度出发,本项目的流程是 注册 -> 登录 -> 对战匹配 -> 游戏对战&实时聊天 -> 游戏结束返回游戏大厅。

2.2 服务器角度流程图

从服务器角度出发,本项目的流程如下:

  • 服务器收到客户端获取注册页面请求,服务器响应注册页面 register.html。
  • 服务器收到客户端用户注册请求,服务器根据用户提交上来的注册信息向数据库中新增用户,并返回注册成功或失败的响应。
  • 服务器收到客户端获取登录页面请求,服务器响应登录页面 login.html。
  • 服务器收到客户端用户登录请求,服务器使用用户提交上来的登录信息与数据库中的信息进行比对,并返回登录成功或失败的响应。(注:用户登录成功后服务器会为用户创建会话信息,并将用户会话 id 添加到 http 头部中进行返回)
  • 服务器收到客户端获取游戏大厅页面请求,服务器响应游戏大厅页面 game_hall.html。
  • 服务器收到客户端获取用户详细信息请求,服务器会取出请求头部中的 cookie 信息获取用户 session 信息,cookie/session 不存在则返回失败响应 (会话验证),存在则通过用户 session 信息获取用户 id,再通过用户 id 从数据库中获取用户详细信息并返回。
  • 服务器收到客户端建立游戏大厅 WebSocket 长连接请求, 会话验证成功后,返回长连接建立成功或失败的响应。(游戏大厅长连接建立后,用户会被加入到游戏大厅在线用户管理中)
  • 服务器收到客户端开始/停止对战匹配的请求,会话验证成功后,会根据用户天梯分数将用户加入对应的匹配队列或从对应的匹配队列中移除并返回响应。(游戏匹配成功后,服务器会为用户创建游戏房间,并主动给客户端发送 match_success 响应)
  • 游戏匹配成功后,服务器收到客户端建立游戏房间长连接请求,会话验证成功后,返回长连接建立成功或失败的响应。(游戏房间长连接建立后,用户会被加入到游戏房间在线用户管理中)
  • 之后,开始游戏对战与实时聊天,服务器会收到或主动向另一个客户端推送下棋/聊天信息。
  • 最后,当游戏结束后,用户会返回游戏大厅并重新建立游戏大厅长连接。

五、模块开发

1. 实用工具类模块

在进行具体的业务模块开发之前,我们可以提前封装实现⼀些项⽬中会用到的边缘功能代码,这样以后在项目中有相应需求时就可以直接使用了。

1.1 日志宏封装

日志宏功能主要负责程序日志的打印,方便我们在程序出错时能够快速定位错误,以及在程序运行过程中打印一些关键的提示信息。

logger.hpp:

代码语言:javascript复制
#ifndef __LOGGER_HPP__
#define __LOGGER_HPP__
#include <cstdio>
#include <time.h>

/*日志等级*/    
enum  {
    NORMAL, 
    DEBUG, 
    ERROR,
    FATAL
};

/*将日志等级转化为字符串*/
const char* level_to_stirng(int level) {
    switch (level)
    {
    case NORMAL:
        return "NORMAL";
    case DEBUG:
        return "DEBUG";
    case ERROR:
        return "ERROR";
    case FATAL:
        return "FATAL";
    default:
        return nullptr;
    }
}

#define LOG(level, format, ...) do {
    const char* levelstr = level_to_stirng(level); /*日志等级*/
    time_t ts = time(NULL);  /*时间戳*/  
    struct tm *lt = localtime(&ts);  /*格式化时间*/ 
    char buffer[32] = { 0 };
    strftime(buffer, sizeof(buffer) - 1, "%y-%m-%d %H:%M:%S", lt);  /*格式化时间到字符串*/
    fprintf(stdout, "[%s][%s][%s:%d] " format "n", levelstr, buffer, __FILE__, __LINE__, ##__VA_ARGS__); /*##解除必须传递可变参数的限制*/
} while(0)
#endif
1.2 MySQL C API 封装

MySQL C API 工具类主要是封装部分C语言连接数据库的接口,包括 MySQL 句柄的创建和销毁,以及 sql 语句的执行。

需要注意的是,我们并没有封装获取 sql 查询结果的相关接口,因为是否要获取查询结果、要获取哪部分查询结果以及以何种形式获取查询结果,这些都是与业务需求强相关的。

mysql_util:

代码语言:javascript复制
/*MySQL C API工具类*/
class mysql_util {
public:
    /*创建MySQL句柄*/
    static MYSQL *mysql_create(const std::string &host, const std::string &user, const std::string                                      &passwd, const std::string db = "gobang", uint16_t port = 4106) {
        /*初始化MYSQL句柄*/
        MYSQL *mysql = mysql_init(nullptr);
        if(mysql == nullptr) {
            LOG(FATAL, "mysql init failed");
            return nullptr;
        }
        /*连接MySQL数据库*/
        mysql = mysql_real_connect(mysql, host.c_str(), user.c_str(), passwd.c_str(), db.c_str(), port, nullptr, 0);
        if(mysql == nullptr) {
            LOG(FATAL, "mysql connect failed: %s", mysql_error(mysql));
            mysql_close(mysql);
            return nullptr;
        }
        /*设置客户端字符集*/
        if(mysql_set_character_set(mysql, "utf8") != 0) {
            LOG(ERROR, "client character set failed: %s", mysql_error(mysql));
        }
        return mysql;
    }

    /*执行sql语句*/
    static bool mysql_execute(MYSQL *mysql, const std::string &sql) {
        if(mysql_query(mysql, sql.c_str()) != 0) {
            LOG(ERROR, "sql query failed: %s", mysql_error(mysql));
            return false;
        }
        return true;
    }

    /*销毁MySQL句柄*/
    static void mysql_destroy(MYSQL *mysql) {
        if(mysql != nullptr) {
            mysql_close(mysql);
        }
    }
};
1.3 JsonCpp 封装

jsoncpp 工具类主要是完成数据的序列化与反序列化工作。

json_util:

代码语言:javascript复制
/*jsoncpp工具类*/
class json_util {
public:
    /*序列化接口*/
    static bool serialize(Json::Value &root, std::string &str) {
        Json::StreamWriterBuilder swb;
        std::unique_ptr<Json::StreamWriter> sw(swb.newStreamWriter());
        std::stringstream ss;
        if(sw->write(root, &ss) != 0) {
            LOG(ERROR, "json serialize failed");
            return false;
        }
        str = ss.str();
        return true;
    }

    /*反序列化接口*/
    static bool deserialize(const std::string &str, Json::Value &root) {
        Json::CharReaderBuilder crb;
        std::unique_ptr<Json::CharReader> cr(crb.newCharReader());
        std::string err;
        if(cr->parse(str.c_str(), str.c_str()   str.size(), &root, &err) == false) {
            LOG(ERROR, "json deserialize failed: %s", err);
            return false;
        }
        return true;
    }
};
1.4 String Split 封装

string split 主要是按照特定分隔符对字符串进行分割,并将分割后的结果进行返回。在本项目中,它的使用场景是分割请求头部中的 cookie 信息,获取 session id。

string_util:

代码语言:javascript复制
/*字符串处理工具类*/
class string_util {
public:
    /*将源字符串按照特定分隔符分割为若干个子字符串*/
    static int split(const std::string &src, const std::string &sep, std::vector<std::string> &res) {
        // ..abc..de..ef
        int index = 0, pos = 0;
        while(index < src.size()) {
            pos = src.find(sep, index);
            if(pos == std::string::npos) {
                res.push_back(src.substr(index));
                break;
            }
            if(index == pos) {
                index  = sep.size();
                continue;
            }
            else {
                res.push_back(src.substr(index, pos - index));
                index = pos   sep.size();
            }
        }
        return res.size();
    }
};
1.5 File Read 封装

file read 的作用是读取指定文件中的内容。

file_util:

代码语言:javascript复制
/*读取文件数据工具类*/
class file_util {
public:
    static bool read(const char* filename, std::string &data) {
        /*以二进制形式打开文件*/
        std::ifstream ifs(filename, std::ios::binary);
        if(ifs.is_open() == false) {
            LOG(ERROR, "open %s file failed", filename);
            return false;
        }
        /*获取文件大小*/
        size_t size;
        ifs.seekg(0, std::ios::end);
        size = ifs.tellg();
        ifs.seekg(0, std::ios::beg);
        /*读取文件内容*/
        data.resize(size);
        ifs.read(&data[0], size);
        if(ifs.good() == false) {
            LOG(ERROR, "read %s file content failed", filename);
            ifs.close();
            return false;
        }
        /*关闭文件*/
        ifs.close();
        return true;
    }
};

2. 用户数据管理模块

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

2.1 用户信息表

在本项目中,用户数据主要包括用户名、用户密码、用户天梯分数、用户对战场次以及用户获胜场次,我们可以在数据库中创建一个 user 表来保存用户数据。其中,user 表中需要有一个自增主键 id 来唯一标识一个用户。

代码语言:javascript复制
create database if not exists gobang;
use gobang;
create table if not exists user (
    id bigint unsigned primary key auto_increment key,
    username varchar(32) unique key not null,
    password varchar(64) not null,
    score int default 1000,
    total_count int default 0,
    win_count int default 0
);
2.2 用户数据管理类

对于一般的数据库来说,数据库中有可能存在很多张表,而每张表中管理的数据以及要进行的数据操作都各不相同,因此我们可以为每⼀张表中的数据操作都设计⼀个类,通过类实例化的对象来访问这张数据库表中的数据。这样当我们要访问哪张表的时候,只需要使用对应类实例化的对象即可。

对于本项目而言,目前数据库中只有一张 user 表,所以我们需要为其设计一个类,它的主要功能如下:

  • registers:完成新用户注册,返回是否注册成功。
  • login:完成用户登录验证,如果登录成功返回 true 并且填充用户详细信息。
  • select_by_name:通过用户名查找用户详细信息。
  • select_by_id:通过用户 id 查找用户详细信息。
  • win:当用户对战胜利后修改用户数据库数据 – 天梯分数、对战场次、获胜场次。
  • lose:当用户对战失败后修改用户数据库数据 – 天梯分数、对战场次。

db.hpp:

代码语言:javascript复制
#ifndef __DB_HPP__
#define __DB_HPP__
#include "util.hpp"
#include <mutex>
#include <cassert>

/*用户数据管理模块 -- 用于管理数据库数据,为数据库中的每张表都设计一个类,然后通过类对象来操作数据库表中的数据*/
/*用户信息表*/
class user_table {
public:
    user_table(const std::string &host, const std::string &user, const std::string &passwd, 
                               const std::string db = "gobang", uint16_t port = 4106) 
    {
        _mysql = mysql_util::mysql_create(host, user, passwd, db, port);
        assert(_mysql != nullptr);
        LOG(DEBUG, "用户数据管理模块初识化完毕");
    }

    ~user_table() {
        if(_mysql != nullptr) { 
            mysql_util::mysql_destroy(_mysql);
            _mysql = nullptr;
        }
        LOG(DEBUG, "用户数据管理模块已被销毁");
    }

    /*新用户注册*/
    bool registers(Json::Value &user) {
        if(user["username"].isNull() || user["password"].isNull()) {
            LOG(NORMAL, "please input username and password");
            return false;   
        }
        // 由于用户名有唯一键约束,所以不需要担心用户已被注册的情况
        char sql[1024];
#define INSERT_USER "insert into user values(null, '%s', password('%s'), 1000, 0, 0)"
        sprintf(sql, INSERT_USER, user["username"].asCString(), user["password"].asCString());
        // LOG(DEBUG, "%s", sql);
        if(mysql_util::mysql_execute(_mysql, sql) == false) {
            LOG(NORMAL, "user register failed");
            return false;
        }
        LOG(NORMAL, "%s register success", user["username"].asCString());
        return true;
    }

    /*用户登录验证*/
    bool login(Json::Value &user) {
        // 与数据库中的用户名 密码进行比对
        // 注意:数据库的password是经过mysql password函数转换后的,所以sql查询时也需要对user["password"].asString()进行转化
#define SELECT_USER "select id, score, total_count, win_count from user where username = '%s' and password = password('%s')"   
        char sql[1024];
        sprintf(sql, SELECT_USER, user["username"].asCString(), user["password"].asCString());
        MYSQL_RES *res = nullptr;
        {
            // mysql查询与查询结果的本地保存两步操作需要加锁,避免多线程使用同一句柄进行操作的情况下发送结果集的数据覆盖问题
            // 将锁交给RAII unique_lock进行管理
            std::unique_lock<std::mutex> lock(_mutex);
            if(mysql_util::mysql_execute(_mysql, sql) == false) return false;;
            // 获取查询到的结果--一行记录
            res = mysql_store_result(_mysql);
            // 注意:当mysql查询结果为空时,mysql_store_result也不会返回空,所以不能在这里判断用户名密码是否正确
            if(res == nullptr) {
                LOG(NORMAL, "mysql store failed: ", mysql_error(_mysql));
                return false;
            }
        }
        int row_count = mysql_num_rows(res);
        int col_count = mysql_num_fields(res);
        // row_count 为0说明查询不到与当前用户名 密码匹配的数据,即用户名或密码错误
        if(row_count == 0) {
            LOG(NORMAL, "the username or password error, please input again");
            return false;
        }
        // 用户名存在唯一键约束
        if(row_count > 1) {
            LOG(ERROR, "there are same user %s in the database", user["username"].asCString());
            return false;
        }        
        LOG(NORMAL, "%s login success", user["username"].asCString());
        // 填充该用户的其他详细信息
        MYSQL_ROW row = mysql_fetch_row(res);
        user["id"] = std::stoi(row[0]);
        user["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 &name, Json::Value &user) {
#define SELECT_BY_USERNAME "select id, score, total_count, win_count from user where username = '%s'"
        char sql[1024];
        sprintf(sql, SELECT_BY_USERNAME, name.c_str());
        MYSQL_RES *res = nullptr;
        {
            // 加锁
            std::unique_lock<std::mutex> lock(_mutex);
            if(mysql_util::mysql_execute(_mysql, sql) == false) return false;
            // 获取查询到的结果--一行记录
            res = mysql_store_result(_mysql);
            // 注意:当mysql查询结果为空时,mysql_store_result也不会返回空,所以不能在这里判断用户是否存在
            if(res == nullptr) {
                LOG(DEBUG, "mysql store failed: ", mysql_error(_mysql));
                return false;
            }
        }
        int row_count = mysql_num_rows(res);
        int col_count = mysql_num_fields(res);
        // row_count为0说明查询不到与当前用户名匹配的数据,即用户不存在
        if(row_count == 0) {
            LOG(DEBUG, "the user with name %s does not exist", name.c_str());
            return false;
        }
        // 用户名存在唯一键约束
        if(row_count > 1) {
            LOG(ERROR, "there are same user name %s in the database", name.c_str());
            return false;
        }  
        MYSQL_ROW row = mysql_fetch_row(res);
        // password是转换后的,获取无意义
        user["id"] = std::stoi(row[0]);
        user["username"] = name.c_str();
        user["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(uint64_t id, Json::Value &user) {
#define SELECT_BY_ID "select username, score, total_count, win_count from user where id = %d"
        char sql[1024];
        sprintf(sql, SELECT_BY_ID, id);
        MYSQL_RES *res = nullptr;
        {
            // 加锁
            std::unique_lock<std::mutex> lock(_mutex);
            if(mysql_util::mysql_execute(_mysql, sql) == false) return false;
            // 获取查询到的结果--一行记录
            res = mysql_store_result(_mysql);
            // 注意:当mysql查询结果为空时,mysql_store_result也不会返回空,所以不能在这里判断用户是否存在
            if(res == nullptr) {
                LOG(DEBUG, "mysql store failed: ", mysql_error(_mysql));
                return false;
            }
        }
        int row_count = mysql_num_rows(res);
        int col_count = mysql_num_fields(res);
        // row_count为0说明查询不到与当前用户名ID匹配的数据,即用户不存在
        if(row_count == 0) {
            LOG(DEBUG, "the user with ID %d does not exist", id);
            return false;
        }
        // 用户名存在唯一键约束
        if(row_count > 1) {
            LOG(ERROR, "there are same user with ID %d in the database", id);
            return false;
        }
        MYSQL_ROW row = mysql_fetch_row(res);
        // password是转换后的,获取无意义
        user["id"] = (Json::UInt64)id;
        user["username"] = row[0];
        user["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;        
    }

    /*用户对战胜利,修改分数以及比赛和胜利场次,胜利一场增加30分*/
    bool win(uint64_t id) {
#define UPDATE_WIN "update user set score=score 30, total_count=total_count 1, win_count=win_count 1 where id = %d"
        char sql[1024];
        sprintf(sql, UPDATE_WIN, id);
        if(mysql_util::mysql_execute(_mysql, sql) == false) {
            LOG(ERROR, "update the user info of win failed");
            return false;
        }
        return true;
    }

    /*用户对战失败,修改分数以及比赛场次*,失败一场减少30分*/
    bool lose(uint64_t id) {
#define UPDATE_LOSE "update user set score=score-30, total_count=total_count 1 where id = %d"
        char sql[1024];
        sprintf(sql, UPDATE_LOSE, id);
        if(mysql_util::mysql_execute(_mysql, sql) == false) {
            LOG(ERROR, "update the user info of lose failed");
            return false;
        }
        return true;
    }
private:
    MYSQL *_mysql;      // mysql操作句柄
    std::mutex _mutex;  // 解决多线程使用同一类对象(句柄)访问数据库时可能发生的线程安全问题
};
#endif

3. 在线用户管理模块

在线用户管理模块主要管理两类用户 – 进入游戏大厅的用户与进入游戏房间的用户,因为用户只有进入了游戏大厅或者游戏房间,其对应的客户端才会与服务器建立 WebSocket 长连接。

此时我们需要将用户 id 与用户所对应的 WebSocket 长连接关联起来,这样我们就能够通过用户 id 找到用户所对应的连接,进而实现服务器主动向客户端推送消息的功能:

  • 在游戏大厅中,当一个用户开始匹配后,如果匹配成功,服务器需要主动向客户端推送匹配成功的消息。
  • 在游戏房间中,当一个玩家有下棋或者聊天动作时,服务器也需要将这些动作主动推送给另一个玩家。

需要注意的是,用户在游戏大厅的长连接与游戏房间的长连接是不同的,所以我们需要分别建立游戏大厅用户 id 与 WebSocket 长连接的关联关系以及游戏房间用户 id 与 WebSocket 长连接的关联关系。

在线用户管理类的主要功能如下:

  • enter_game_hall:指定用户进入游戏大厅,此时需要建立用户 id 与游戏大厅 WebSocket 长连接的关联关系。
  • enter_game_hall:指定用户进入游戏房间,此时需要建立用户 id 与游戏房间 WebSocket 长连接的关联关系。
  • exit_game_hall:指定用户离开游戏大厅,此时需要断开用户 id 与游戏大厅 WebSocket 长连接的关联关系。
  • exit_game_room:指定用户离开游戏房间,此时需要断开用户 id 与游戏房间 WebSocket 长连接的关联关系。
  • is_in_game_hall:判断指定用户是否在游戏大厅中。
  • is_in_game_room:判断指定用户是否在游戏房间中。
  • get_conn_from_hall:获取指定用户的游戏大厅长连接。
  • get_conn_from_room:获取指定用户的游戏房间长连接。

online.hpp:

代码语言:javascript复制
#ifndef __ONLINE_HPP__
#define __ONLINE_HPP__
#include "util.hpp"
#include <mutex>
#include <unordered_map>
#include <websocketpp/server.hpp>
#include <websocketpp/config/asio_no_tls.hpp>

typedef websocketpp::server<websocketpp::config::asio> wsserver_t;

/*在线用户管理模块 -- 用于管理在游戏大厅以及游戏房间中的用户,建立用户id与websocket长连接的对应关系*/
class online_manager {
public:
    online_manager() { LOG(DEBUG, "在线用户管理模块初始化完毕"); }
    ~online_manager() { LOG(DEBUG, "在线用户管理模块已被销毁"); }

    /*用户进入游戏大厅(此时用户websocket长连接已建立好)*/
    void enter_game_hall(uint64_t uid, wsserver_t::connection_ptr conn) {
        std::unique_lock<std::mutex> lock(_mutex);
        _hall_user[uid] = conn;
    }
    /*用户进入游戏房间*/
    void enter_game_room(uint64_t uid, wsserver_t::connection_ptr conn) {
        std::unique_lock<std::mutex> lock(_mutex);
        _room_user[uid] = conn;
    }

    /*用户离开游戏大厅(websocket长连接断开时)*/
    void exit_game_hall(uint64_t uid) {
        std::unique_lock<std::mutex> lock(_mutex);
        _hall_user.erase(uid);
    }

    /*用户对战结束离开游戏房间回到游戏大厅*/
    void exit_game_room(uint64_t uid) {
        std::unique_lock<std::mutex> lock(_mutex);
        _room_user.erase(uid);
    }

    /*判断当前用户是否在游戏大厅*/
    bool is_in_game_hall(uint64_t uid) {
        std::unique_lock<std::mutex> lock(_mutex);
        auto it = _hall_user.find(uid);
        if(it == _hall_user.end()) return false;
        return true;
    }

    /*判断当前用户是否在游戏房间*/
    bool is_in_game_room(uint64_t uid) {
        std::unique_lock<std::mutex> lock(_mutex);
        auto it = _room_user.find(uid);
        if(it == _room_user.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_user.find(uid);
        if(it == _hall_user.end()) return nullptr;
        return _hall_user[uid];
    }

    /*通过用户id获取游戏房间用户的通信连接*/
    wsserver_t::connection_ptr get_conn_from_room(uint64_t uid) {
        std::unique_lock<std::mutex> lock(_mutex);
        auto it = _room_user.find(uid);
        if(it == _room_user.end()) return nullptr;
        return _room_user[uid];
    }
private:
    std::mutex _mutex;  // 解决多线程模式下的线程安全问题
    std::unordered_map<uint64_t, wsserver_t::connection_ptr> _hall_user;  // 建立游戏大厅用户id与通信连接之间的关联关系
    std::unordered_map<uint64_t, wsserver_t::connection_ptr> _room_user;  // 建立游戏房间用户id与通信连接之间的关联关系
};
#endif

4. 游戏房间管理模块

游戏房间管理模块就是设计一个房间类,能够实现房间的实例化;房间类主要是对匹配成功的玩家建立一个小范围的关联关系,当一个房间中的玩家发生下棋或者聊天动作时,服务器能够将其广播给房间中的其他玩家。

游戏房间类的具体功能如下:

  • add_white_user:为房间添加白棋玩家。
  • add_black_user:为白棋添加黑棋玩家。
  • handler:总的动作处理函数,函数内部会根据不同的动作类型 (下棋/聊天) 调用不同的子函数进行处理得到响应。
  • broadcast:将处理动作得到的响应广播给房间中的其他玩家。

同时,由于同一时间段内进行匹配或者正在对战的玩家有很多,所以游戏房间可能会有多个;那么我们就需要设计一个游戏房间管理类来对多个房间进行管理。

游戏房间管理类的具体功能如下:

  • create_room:为两个玩家创建一个游戏房间。
  • get_room_by_rid:通过房间 id 获取房间信息。
  • get_room_by_uid:通过玩家 id 获取玩家所在房间的房间信息。
  • remove_room:通过房间 id 销毁房间。
  • remove_room_user:移除房间中的指定玩家,若房间中没有玩家了则直接销毁房间。

最后,需要注意的是,在游戏房间管理模块中,由于我们需要根据不同的消息类型来调用不同的函数,进而得到不同的响应,所以我们需要提前规定好 WebSocket (游戏房间中 WebSocket 长连接已建立) 网络通信中不同类型的消息的格式是怎样的。这部分代码会在服务器模块的通信接口设计处给出,但为了便于理解,这里我们也放一份。

玩家下棋的消息:

代码语言:javascript复制
// 玩家下棋消息
{
    "optype": "put_chess", // put_chess表示当前请求是下棋操作
    "room_id": 222,        // room_id 表⽰当前动作属于哪个房间
    "uid": 1,              // 当前的下棋操作是哪个用户发起的
    "row": 3,              // 当前下棋位置的⾏号
    "col": 2               // 当前下棋位置的列号
}

// 下棋成功后后台回复的消息
{
    "optype": "put_chess",
    "result": true,
    "reason": "下棋成功或游戏胜利或游戏失败",
    "room_id": 222,
    "uid": 1,
    "row": 3,
    "col": 2,
    "winner": 0  // 游戏获胜者,0表示未分胜负,!0表示已分胜负
}

// 下棋失败后后台回复的消息
{
    "optype": "put_chess",
    "result": false,
    "reason": "下棋失败的原因",
    "room_id": 222,  
    "uid": 1,
    "row": 3,
    "col": 2,
    "winner": 0  
}

玩家聊天的消息:

代码语言:javascript复制
// 玩家聊天消息
{
    "optype": "chat",  // chat表示当前请求是下棋操作
    "room_id": 222,    // room_id 表⽰当前动作属于哪个房间
    "uid": 1,          // 当前的下棋操作是哪个用户发起的
    "message": "你好"   // 聊天消息的具体内容
}

// 聊天消息发送成功后台回复的消息
{
    "optype": "chat",
    "result": true,
    "room_id": 222,
    "uid": 1,
    "message": "你好"
}

// 聊天消息发送失败后台回复的消息
{
    "optype": "chat",
    "result": false,
    "reason": "错误原因,比如消息中包含敏感词",
    "room_id": 222,
    "uid": 1,
    "message": "你好"
}

未知类型的消息:

代码语言:javascript复制
{
    "optype": 消息的类型,
    "result": false,
    "reason": "未知类型的消息"
}

room.hpp:

代码语言:javascript复制
#ifndef __ROOM_HPP__
#define __ROOM_HPP__
#include "util.hpp"
#include "db.hpp"
#include "online.hpp"
#include <vector>

#define BOARD_ROW 15
#define BOARD_COL 15
#define CHESS_WHITE 1
#define CHESS_BLACK 2

typedef enum {
    GAME_START,
    GAME_OVER
} room_status;

/*游戏房间管理模块 -- 用于管理在游戏房间中产生的各种数据以及动作,同时也包括对多个游戏房间本身的管理*/
/*游戏房间类*/
class room {
private:
    /*check_win子函数,其中row/col表示下棋位置,row_off/col_off表示是否偏移*/
    bool five_piece(int row, int col, int row_off, int col_off, int color) {
        int count = 1; 
        // 处理正方向
        int search_row = row   row_off;
        int search_col = col   col_off;
        while((search_row >= 0 && search_row < BOARD_ROW) && (search_col >= 0 && search_col < BOARD_COL)
               && (_board[search_row][search_col] == color)) {
              count;
            search_row  = row_off;
            search_col  = col_off;
        }
        // 处理反方向
        search_row = row - row_off;
        search_col = col - col_off;
        while((search_row >= 0 && search_row < BOARD_ROW) && (search_col >= 0 && search_col < BOARD_COL)
               && (_board[search_row][search_col] == color)) {
              count;
            search_row -= row_off;
            search_col -= col_off;
        }
        return count >= 5;
    }

    /*判断是否有用户胜利并返回winner_id (0表示没有用户胜利,非0表示有)*/
    uint64_t check_win(int chess_row, int chess_col, int cur_color) {
        uint64_t winner_id = cur_color == CHESS_WHITE ? _white_user_id : _black_user_id;
        // 横行方向:当前位置开始,行不变,列  /--
        if(five_piece(chess_row, chess_col, 0, 1, cur_color)) return winner_id;
        // 纵列方向:当前位置开始,行  /--,列不变
        if(five_piece(chess_row, chess_col, 1, 0, cur_color)) return winner_id;
        // 正斜方向:当前位置开始,行  列-- 以及 行--列  
        if(five_piece(chess_row, chess_col, 1, -1, cur_color)) return winner_id;
        // 反斜方向:当前位置开始,行  列   以及 行--列--
        if(five_piece(chess_row, chess_col, 1, 1, cur_color)) return winner_id;
        // 没有人获胜返回0
        return 0;
    }

    /*用户胜利或失败后更新用户数据库信息*/
    void update_db_info(uint64_t winner_id, uint64_t loser_id) {
        _tb_user->win(winner_id);
        _tb_user->lose(loser_id);
    }
public:
    room(uint64_t room_id, user_table *tb_user, online_manager *online_user)
    : _room_id(room_id), _statu(GAME_START), _tb_user(tb_user), _online_user(online_user), _board(BOARD_ROW, std::vector<int>(BOARD_COL, 0))
    {
        LOG(DEBUG, "%d号房间创建成功", _room_id);
    }
    ~room() { LOG(DEBUG, "%d号房间已被销毁", _room_id); }

    /*添加白棋用户*/
    void add_white_user(uint64_t id) {
        _white_user_id = id;
          _player_count;
    }

    /*添加黑棋用户*/
    void add_black_user(uint64_t id) {
        _black_user_id = id;
          _player_count;
    }

    /*处理玩家下棋动作并返回响应*/
    Json::Value handler_chess(Json::Value &req) {
        Json::Value resp = req;
        // 判断白棋与黑棋用户是否在线,若一方不在线,另一方直接获胜
        if(_online_user->is_in_game_room(_white_user_id) == false) {
            resp["result"] = true;
            resp["reason"] = "对方已掉线,游戏获胜";  // 在黑棋的视角,白棋是"对方"  
            resp["winner"] = (Json::UInt64)_black_user_id;  // 白棋掉线,黑棋用户
        }
        if(_online_user->is_in_game_room(_black_user_id) == false) {
            resp["result"] = true;
            resp["reason"] = "对方已掉线,游戏胜利";    
            resp["winner"] = (Json::UInt64)_white_user_id;  
        }
        // 获取下棋位置,判断位置是否合理并下棋
        uint64_t cur_uid = req["uid"].asUInt64();
        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;            
        }
        int cur_color = (cur_uid == _white_user_id ? CHESS_WHITE : CHESS_BLACK);
        _board[chess_row][chess_col] = cur_color;
        // 判断是否有玩家获胜(存在五星连珠的情况) 其中0表示没有玩家胜利,非0表示胜利的玩家id
        uint64_t winner_id = check_win(chess_row, chess_col, cur_color);
        resp["result"] = true;
        resp["reason"] = "下棋成功";  
        resp["winner"] = (Json::UInt64)winner_id;
        if(winner_id != 0) { resp["reason"] = "五星连珠,游戏胜利"; }
        return resp;
    }

    /*处理玩家聊天动作并返回响应*/
    Json::Value handler_chat(Json::Value &req) {
        Json::Value resp = req;
        // 检查消息中是否包含敏感词
        std::string msg = req["message"].asString();
        size_t pos = msg.find("垃圾");
        if(pos != std::string::npos) {
            resp["result"] = false;
            resp["reason"] = "消息中包含敏感词";
            return resp;
        }
        resp["reslut"] = true;
        return resp;
    }

    /*处理玩家退出动作并返回响应*/
    void handler_exit(uint64_t uid) {
        // 如果玩家在下棋中,则对方直接获胜
        if(_statu == GAME_START) {
            Json::Value resp;
            resp["optype"] = "put_chess";
            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)(uid == _white_user_id ? _black_user_id : _white_user_id);
            // 更新用户数据库信息与游戏房间的状态
            uint64_t loser_id = uid;
            uint64_t winner_id = loser_id == _white_user_id ? _black_user_id : _white_user_id;
            update_db_info(winner_id, loser_id);
            _statu = GAME_OVER;
            // 将消息广播给房间其他玩家
            broadcast(resp);
        }
        // 游戏结束正常退出直接更新玩家数量
        --_player_count;
    }

    /*总的动作处理函数,负责判断动作类型并调用对应的处理函数,得到处理响应后将其广播给房间中其他用户*/
    /*注意:玩家退出动作属于玩家断开连接后调用的操作,不属于handler的一种*/
    void handler(Json::Value &req) {
        Json::Value resp;
        // 判断房间号是否匹配
        if(_room_id != req["room_id"].asUInt64()) {
            resp["optype"] = req["optype"].asString();
            resp["result"] = false;
            resp["reason"] = "房间号不匹配";
            broadcast(resp);
            return;
        }
        // 根据请求类型调用不同的处理函数
        std::string type = req["optype"].asString();
        if(type == "put_chess") {
            resp = handler_chess(req);
            // 判断是否有玩家获胜,如果有则需要更新用户数据库信息与游戏房间的状态
            if(resp["winner"].asUInt64() != 0) {
                uint64_t winner_id = resp["winner"].asUInt64();
                uint64_t loser_id = (winner_id == _white_user_id ? _black_user_id : _white_user_id);
                update_db_info(winner_id, loser_id);
                _statu = GAME_OVER;
            }
        } else if(type == "chat") {
            resp = handler_chat(req);
        } else {
            resp["optype"] = req["optype"].asString();
            resp["result"] = false;
            resp["reason"] = "未知类型的消息";
        }
        // 将消息广播给房间中的其他玩家
        broadcast(resp);
    }

    /*将动作响应广播给房间中的其他玩家*/
    void broadcast(Json::Value &resp) {
        // 将Json响应进行序列化
        std::string body;
        json_util::serialize(resp, body);
        // 获取房间中的所有玩家的通信连接
        wsserver_t::connection_ptr conn_white = _online_user->get_conn_from_room(_white_user_id);
        wsserver_t::connection_ptr conn_black = _online_user->get_conn_from_room(_black_user_id);
        // 如果玩家连接没有断开,则将消息广播给他
        if(conn_white.get() != nullptr) {
            conn_white->send(body);
        }
        if(conn_black.get() != nullptr) {
            conn_black->send(body);
        }
    }
public:
    // 将部分成员变量设为public,供外部类访问
    uint64_t _room_id;             // 房间ID
    room_status _statu;            // 房间状态
    int _player_count;             // 玩家数量
    uint64_t _white_user_id;       // 白棋玩家ID
    uint64_t _black_user_id;       // 黑棋玩家ID
private:
    user_table *_tb_user;          // 管理玩家数据的句柄
    online_manager *_online_user;  // 管理玩家在线状态的句柄 
    std::vector<std::vector<int>> _board;  // 二维棋盘
};

/*管理房间数据的智能指针*/
using room_ptr = std::shared_ptr<room>;  

/*游戏房间管理类*/
class room_manager {
public:
    room_manager(user_table *tb_user, online_manager *online_user)
    : _next_rid(1), _tb_user(tb_user), _online_user(online_user) {
        LOG(DEBUG, "游戏房间管理模块初始化成功");
    }
    ~room_manager() { LOG(NORMAL, "游戏房间管理模块已被销毁"); }

    /*为两个玩家创建房间,并返回房间信息*/
    room_ptr create_room(uint64_t uid1, uint64_t uid2) {
        // 判断两个玩家是否都处于游戏大厅中
        if(_online_user->is_in_game_hall(uid1) == false || _online_user->is_in_game_hall(uid2) == false) {
            LOG(DEBUG, "玩家不在游戏大厅中,匹配失败");
            return room_ptr();
        }
        // 创建游戏房间,将用户信息添加到房间中
        std::unique_lock<std::mutex> lock(_mutex);
        room_ptr rp(new room(_next_rid, _tb_user, _online_user));
        rp->add_white_user(uid1);
        rp->add_black_user(uid2);
        // 将游戏房间管理起来(建立房间id与房间信息以及玩家id与房间id的关联关系)
        _rooms[_next_rid] = rp;
        _users[uid1] = _next_rid;
        _users[uid2] = _next_rid;
        // 更新下一个房间的房间id
          _next_rid;
        // 返回房间信息
        return rp;
    }

    /*通过房间id获取房间信息*/
    room_ptr get_room_by_rid(uint64_t rid) {
        std::unique_lock<std::mutex> lock(_mutex);
        auto it = _rooms.find(rid);
        if(it == _rooms.end()) return room_ptr();
        return _rooms[rid];
    }

    /*通过用户id获取房间信息*/
    room_ptr get_room_by_uid(uint64_t uid) {
        std::unique_lock<std::mutex> lock(_mutex);
        // 获取房间id
        auto it1 = _users.find(uid);
        if(it1 == _users.end()) return room_ptr();
        uint64_t rid = _users[uid];
        // 获取房间信息(这里不能直接调用get_room_by_rid,会造成死锁)
        auto it2 = _rooms.find(rid);
        if(it2 == _rooms.end()) return room_ptr();
        return _rooms[rid];
    }

    /*通过房间id销毁房间*/
    void remove_room(uint64_t rid) {
        // 通过房间id获取房间信息
        room_ptr rp = get_room_by_rid(rid);
        if(rp.get() == nullptr) return;
        // 通过房间信息获取房间中的玩家
        uint64_t white_user_id = rp->_white_user_id;
        uint64_t black_user_id = rp->_black_user_id;
        // 移除房间管理中的玩家信息
        std::unique_lock<std::mutex> lock(_mutex);
        _users.erase(white_user_id);
        _users.erase(black_user_id);
        // 移除房间管理信息 -- 移除房间对应的shared_ptr(room_ptr)
        _rooms.erase(rid);
    }

    /*删除房间中的指定用户,若房间中没有用户则销毁房间(用户断开websocket连接时调用)*/
    void remove_room_user(uint64_t uid) {
        // 通过玩家id获取房间信息
        room_ptr rp = get_room_by_uid(uid);
        if(rp.get() == nullptr) return;
        // 玩家退出
        rp->handler_exit(uid);
        // 如果房间中没有玩家了,则移除房间
        if(rp->_player_count == 0) remove_room(rp->_room_id);
    }
private:
    uint64_t _next_rid;             //房间ID分配计数器
    std::mutex _mutex;              
    user_table *_tb_user;           // 管理玩家数据的句柄
    online_manager *_online_user;   // 管理玩家在线状态的句柄
    std::unordered_map<uint64_t, room_ptr> _rooms;  // 建立房间id与房间信息的关联关系
    std::unordered_map<uint64_t, uint64_t> _users;  // 建立用户id与房间id的关联关系
};
#endif

5. 用户 session 信息管理模块

什么是 cookie&session:

  • 在 web 开发中,由于 HTTP 是一种无状态短连接的协议,这就导致一个用户可能当前登录了,但过一会在进行其他操作时原来的连接已经断开了,而我们又不知道新连接对应的用户是谁。这就导致要么我们频繁让用户执行登录操作,完成身份认证,要么不给用户提供服务,又或者在不确定当前用户身份和状态的情况下为用户提供服务。显然这些方式都是不合理的。
  • 为了解决这个问题,有大佬就提出了 cookie 的方案 – 客户端在第一次登录成功后,服务器会为响应添加一个 “Set-Cookie” 头部字段,“Set-Cookie” 中包含了诸如 username&password 这类信息;客户端收到响应后会将 “Set-Cookie” 中的信息保存起来,并且之后发送新的请求时会自动将 cookie 信息发送给服务器进行身份与状态验证,从而避免了用户频繁登录的问题。
  • 但是这样简单的 cookie 机制会带来安全问题,因为客户端可能会自己伪造 “Set-Cookie” 信息,或者 HTTP 请求被中间人劫持导致 cookie 信息被篡改,所以大佬又提出了 session 机制。
  • session 机制是指客户端在第一次登录成功后服务器会为客户端实例化一个 session (会话) 对象,该对象中保存了诸如用户 id、用户名、用户密码、用户状态 (登录/未登录等) 这类信息,最重要的是服务器会为每一个 session 对象,即每一个用户分配一个唯一的 session id (ssid)。

此后,服务器与客户端就通过 cookie 和 session 相结合的方式完成用户身份与状态的验证

  • 用户首次登录时服务器会为其实例化一个 session 对象,然后将 ssid 添加到 “Set-Cookie” 头部字段中响应给客户端。
  • 客户端收到响应后会保存 cookie 信息,并且以后每次请求都自动带上 cookie 信息发送给服务器。
  • 服务器收到新的客户端请求后,会从请求头部中获取 cookie 信息,如果 cookie 信息中没有 ssid 或者该 ssid 与服务器中所有的 session id 都不匹配时,服务器会让客户端重新登录并为其实例化 session 对象。如果服务器中存在与该 ssid 匹配的 session 对象,则为客户端提供服务。

基于上面的原理,在本项目中,我们也需要设计一个 session 类以及一个 session 管理类,用来完成客户端身份与状态的验证以及 session 对象的管理。需要注意的是,session 对象不能一直存在,即当用户长时间无操作后我们需要删除服务器中该用户对应的 session 对象,因此我们需要使用 WebSocketpp 的定时器功能对每个创建的 session 对象进行定时销毁,否则也算是一种资源泄露。

session 类的具体功能如下:

  • add_user:为 session 对象关联具体的用户。
  • get_user:获取 session 对象关联的用户。
  • is_login:获取用户状态 (是否登录)。
  • get_ssid:获取 session id。
  • set_timer:设置 session 定时删除任务。
  • get_timer:获取 session 关联的定时器。

session 管理类的具体功能如下:

  • create_session:为指定用户创建 session 信息并返回 session 信息。
  • get_session_by_ssid:通过 sessionID 获取 session 信息。
  • remove_session:通过 sessionID 删除 session 信息。
  • set_session_expire_time:设置 session 过期时间。

session.hpp:

代码语言:javascript复制
#ifndef __SESSION_HPP__
#define __SESSION_HPP__
#include "online.hpp"
#include "logger.hpp"
#include <functional>

typedef enum {
    UNLOGIN, 
    LOGIN
} ss_statu;

/*用户session信息管理模块 -- 用于http短连接通信情况下用户状态的管理(登录/未登录)*/
/*session 类*/
class session {
public:
    session(uint64_t ssid) : _ssid(ssid), _statu(LOGIN) { LOG(DEBUG, "session %d:%p 被创建", _ssid, this); }
    ~session() { LOG(DEBUG, "session %d:%p 被删除", _ssid, this); }
    /*添加用户*/
    void add_user(uint64_t uid) { _uid = uid; }
    /*获取用户id*/
    uint64_t get_user() { return _uid; }
    /*获取用户状态(检查用户是否已登录)*/
    bool is_login() { return _statu == LOGIN; }
    /*获取session id*/
    uint64_t get_ssid() { return _ssid; }
    /*设置session定时删除任务*/
    void set_timer(const wsserver_t::timer_ptr &tp) { _tp = tp; }
    /*获取session关联的定时器*/
    wsserver_t::timer_ptr& get_timer() { return _tp; }
private:
    uint64_t _ssid;             // session id
    uint64_t _uid;              // session对应的用户id
    ss_statu _statu;            // 用户状态(登录/未登录)
    wsserver_t::timer_ptr _tp;  // session关联的定时器
};

#define SESSION_TIMEOUT 30000  //30s
#define SESSION_FOREVER -1

/*使用智能指针来管理session信息*/
using session_ptr = std::shared_ptr<session>;

/*session 管理类*/
class session_manager {
public:
    session_manager(wsserver_t *server)
    : _server(server), _next_ssid(1) {
        LOG(DEBUG, "用户session管理模块初始化成功");
    }
    ~session_manager() { LOG(DEBUG, "用户session管理模块已被销毁"); }

    /*为指定用户创建session信息并返回*/
    session_ptr create_session(uint64_t uid) {
        std::unique_lock<std::mutex> lock(_mutex);
        // 创建session信息
        session_ptr ssp(new session(_next_ssid));
        ssp->add_user(uid);
        // 建立sessionID与session信息的关联关系
        _sessions[_next_ssid] = ssp;
        // 更新下一个session的id计数
          _next_ssid;
        return ssp;
    }

    /*通过sessionID获取session信息*/
    session_ptr get_session_by_ssid(uint64_t ssid) {
        std::unique_lock<std::mutex> lock(_mutex);
        auto it = _sessions.find(ssid);
        if(it == _sessions.end()) return session_ptr();
        return _sessions[ssid];
    }

    /*删除session信息*/
    void remove_session(uint64_t ssid) {
        std::unique_lock<std::mutex> lock(_mutex);
        _sessions.erase(ssid);
    }

    /*重新添加因cancel函数被删除的_sessions成员*/
    void append_session(session_ptr ssp) {
        std::unique_lock<std::mutex> lock(_mutex);
        _sessions.insert(make_pair(ssp->get_ssid(), ssp));  // _sessions[ssp->get_ssid()] = ssp;
    }

    /*设置session过期时间(毫秒)*/
    /*基于websocketpp定时器(timer_ptr)来完成对session生命周期的管理*/
    void set_session_expire_time(uint64_t ssid, int ms) {
        //当客户端与服务器建立http短连接通信(登录/注册)时,session应该是临时的,需要设置定时删除任务
        //当客户端与服务器建立websocket长连接通信(游戏大厅/游戏房间)时,session应该是永久的,直到websocket长连接断开
        session_ptr ssp = get_session_by_ssid(ssid);
        if(ssp.get() == nullptr) return;
        // 获取session状态 -- session对象创建时默认没有关联time_ptr,此时session是永久存在的(timer_ptr==nullptr)
        wsserver_t::timer_ptr tp = ssp->get_timer();
        // 1. 在session永久的情况下设置永久
        if(tp.get() == nullptr && ms == SESSION_FOREVER) return;
        // 2. 在session永久的情况下设置定时删除任务
        else if(tp.get() == nullptr && ms != SESSION_FOREVER) {
            wsserver_t::timer_ptr tp_task = _server->set_timer(ms, std::bind(&session_manager::remove_session, this, ssid));
            ssp->set_timer(tp_task);  // 重新设置session关联的定时器
        }
        // 3. 在session定时删除的情况下设置永久(删除定时任务)
        else if(tp.get() != nullptr && ms == SESSION_FOREVER) {
            // 注意:websocketpp使用cancel函数删除定时任务会导致定时任务直接被执行,所以我们需要重新向_sessions中添加ssid与session_ptr
            // 同时,由于这个定时任务不是立即被执行的(服务器处理时才处理这个任务),所以我们不能在cancel函数后面直接重新添加session_ptr(这样可能出现先添加、再删除的情况)
            // 而是需要专门设置一个定时器来添加ssid与session_ptr
            tp->cancel();
            // 通过定时器来添加被删除的_sessions成员
            _server->set_timer(0, std::bind(&session_manager::append_session, this, ssp)); 
            ssp->set_timer(wsserver_t::timer_ptr());  // 将session关联的定时器设置为空(session永久有效)
        }
        // 4. 在session定时删除的情况下重置删除时间
        else {
            // 先删除定时任务
            tp->cancel();
            _server->set_timer(0, std::bind(&session_manager::append_session, this, ssp)); 
            ssp->set_timer(wsserver_t::timer_ptr());  // 将session关联的定时器设置为空(session永久有效)
            // 再重新添加定时任务
            wsserver_t::timer_ptr tp_task = _server->set_timer(ms, std::bind(&session_manager::remove_session, this, ssid));
            ssp->set_timer(tp_task);  // 重新设置session关联的定时器
        }
    }
private:
    uint64_t _next_ssid;     // sessionID计数器                             
    std::mutex _mutex;  
    std::unordered_map<uint64_t, session_ptr> _sessions;  // 建立ssid与session信息之间的关联关系
    wsserver_t *_server;  // 服务器指针对象,用于设置定时任务
};
#endif

6. 匹配对战管理模块

匹配对战管理模块主要负责游戏大厅内玩家开始匹配与取消匹配的功能,本模块将玩家按照天梯分数分为三个段位 (玩家的初始天梯分数为1000分):

  • 青铜:天梯分数小于2000分。
  • 黄金:天梯分数大于等于2000分但小于3000分。
  • 王者:天梯分数大于等于3000分。

本模块的设计思想是为不同段位的玩家分别设计一个匹配阻塞队列:

  • 当有玩家开始匹配时,服务器会将该玩家加入对应的匹配队列中,并唤醒该匹配队列的线程。
  • 当有玩家取消匹配时,会将该玩家从对应的匹配队列中移除.
  • 当某个匹配队列中的玩家人数不足两个时,服务器会将该匹配队列的线程阻塞,等待有新玩家加入匹配队列时被唤醒。
  • 当某个匹配队列中的玩家人数达到两个时,服务器会将队头的两个玩家出队列并给对应的玩家推送匹配成功的信息,同时为匹配成功的玩家创建游戏房间。

最后,和游戏房间管理模块一样,这里我们也给出 WebSocket 通信的消息格式。

游戏匹配成功的消息:

代码语言:javascript复制
{
    "optype": "match_success", //表⽰成匹配成功
    "result": true
}

matcher.hpp:

代码语言:javascript复制
#ifndef __MATCHER_HPP__
#define __MATCHER_HPP__
#include "db.hpp"
#include "room.hpp"
#include "util.hpp"
#include "online.hpp"
#include <list>
#include <thread>
#include <mutex>
#include <condition_variable>

/*用户对战匹配管理模块 -- 将用户按分数分为青铜、黄金、王者三档,并分别为它们设计一个匹配队列,队列元素>=2则匹配成功,否则阻塞*/
/*匹配队列类*/
template <class T>
class match_queue {
public:
    match_queue() {}
    ~match_queue() {}
    /*目标元素入队列,并唤醒线程*/
    void push(const T& data) {
        std::unique_lock<std::mutex> lock(_mutex);
        _list.push_back(data);
        LOG(DEBUG, "%d用户加入匹配队列", data);
        // 匹配队列每新增一个元素,就唤醒对应的匹配线程,判断是否满足匹配要求(队列人数>=2)
        _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();
        LOG(DEBUG, "%d用户从匹配队列中移除", data);
        return true;
    }

    /*移除队列中的目标元素*/
    void remove(const T& data) {
        std::unique_lock<std::mutex> lock(_mutex);
        _list.remove(data);
        LOG(DEBUG, "%d用户从匹配队列中移除", data);
    }
    
    /*阻塞线程*/
    void wait() {
        std::unique_lock<std::mutex> lock(_mutex);
        _cond.wait(lock);
    }

    /*获取队列元素个数*/
    int size() { 
        std::unique_lock<std::mutex> lock(_mutex);
        return _list.size(); 
    }

    /*判断队列是否为空*/
    bool empty() {
        std::unique_lock<std::mutex> lock(_mutex); 
        return _list.empty();
    }
private:
    std::list<T> _list;  // 使用双向链表而不是queue充当匹配队列,便于用户取消匹配时将该用户从匹配队列中移除
    std::mutex _mutex;   // 实现线程安全
    std::condition_variable _cond;  // 条件变量,当向队列中push元素时唤醒,用于阻塞消费者
};

/*匹配管理类*/
class matcher {
private:
    void handler_match(match_queue<uint64_t> &mq) {
        while(true) {
            // 检查匹配条件是否满足(人数>=2),不满足则继续阻塞
            while(mq.size() < 2) mq.wait();
            // 条件满足,从队列中取出两个玩家
            uint64_t uid1, uid2;
            if(mq.pop(uid1) == false) continue;
            if(mq.pop(uid2) == false) {
                // 如果第二个玩家出队列失败,则需要将第一个玩家重新添加到队列中
                this->add(uid1);
                continue;
            }
            // 检查两个玩家是否都处于大厅在线状态,若一方掉线,则需要将另一方重新添加到队列
            wsserver_t::connection_ptr conn1 = _om->get_conn_from_hall(uid1);
            wsserver_t::connection_ptr conn2 = _om->get_conn_from_hall(uid2);
            if(conn1.get() == nullptr) {
                this->add(uid2);
                continue;
            }
            if(conn2.get() == nullptr) {
                this->add(uid1);
                continue;
            }
            // 为两个玩家创建房间,失败则重新添加到队列
            room_ptr rp = _rm->create_room(uid1, uid2);
            if(rp.get() == nullptr) {
                this->add(uid1);
                this->add(uid2);
                continue;
            }
            // 给玩家返回匹配成功的响应
            Json::Value resp;
            resp["optype"] = "match_success";
            resp["result"] = true;
            std::string body;
            json_util::serialize(resp, body);
            conn1->send(body);
            conn2->send(body);
        }
    }

    /*三个匹配队列的线程入口*/
    void th_low_entry() { handler_match(_q_low); }
    void th_mid_entry() { handler_match(_q_mid); }
    void th_high_entry() { handler_match(_q_high); }
public:
    matcher(user_table *ut, online_manager *om, room_manager *rm)
    : _ut(ut), _om(om), _rm(rm), 
    _th_low(std::thread(&matcher::th_low_entry, this)),
    _th_mid(std::thread(&matcher::th_mid_entry, this)),
    _th_high(std::thread(&matcher::th_high_entry, this)) {
        LOG(DEBUG, "游戏对战匹配管理模块初始化完毕");
    }

    ~matcher() {
        LOG(DEBUG, "游戏对战匹配管理模块已被销毁");
    }

    /*添加用户到匹配队列*/
    bool add(uint64_t uid) {
        // 根据用户id获取用户数据库信息
        Json::Value user;
        if(_ut->select_by_id(uid, user) == false) {
            LOG(DEBUG, "查找玩家%d信息失败", uid);
            return false;
        }
        // 根据用户分数将用户添加到对应的匹配队列中去
        int score = user["score"].asInt();
        if(score < 2000) _q_low.push(uid);
        else if(score >= 2000 && score < 3000) _q_mid.push(uid);
        else _q_high.push(uid);
        return true;
    }

    /*将用户从匹配队列中移除*/
    bool remove(uint64_t uid) {
        // 根据用户id获取用户数据库信息
        Json::Value user;
        if(_ut->select_by_id(uid, user) == false) {
            LOG(DEBUG, "查找用户%d信息失败", uid);
            return false;
        }
        // 根据用户分数将用户从对应的匹配队列中移除
        int score = user["score"].asInt();
        if(score < 2000) _q_low.remove(uid);
        else if(score >= 2000 && score < 3000) _q_mid.remove(uid);
        else _q_high.remove(uid);
        return true;        
    }
private:
    // 三个匹配队列(青铜/黄金/王者 -> low/mid/high)
    match_queue<uint64_t> _q_low;
    match_queue<uint64_t> _q_mid;
    match_queue<uint64_t> _q_high;
    // 三个管理匹配队列的线程
    std::thread _th_low;
    std::thread _th_mid;
    std::thread _th_high;
    room_manager *_rm;    // 游戏房间管理句柄
    online_manager *_om;  // 在线用户管理句柄
    user_table *_ut;      // 用户数据管理句柄
};
#endif

7. 整合封装服务器模块

服务器模块是对当前所实现的所有模块进行整合,并进行服务器搭建的⼀个模块。目的是封装实现出⼀个 gobang_server 的服务器模块类,向外提供搭建五子棋对战服务器的接口。程序员通过实例化服务器模块类对象可以简便的完成服务器的搭建。

7.1 网络通信接口设计

在实现具体的服务器类之前,我们需要对 HTTP 网络通信的通信接口格式进行设计,确保服务器能够根据客户端请求的格式判断出这是一个什么类型请求,并在完成业务处理后给客户端以特定格式的响应。

本项目采用 RESTful 风格通信接口:

  • 资源定位:每个资源都有一个唯一的 URI 标识符,比如 /login.html 表示获取登录页面,/hall 表示进入游戏大厅请求。
  • 使用 HTTP 方法:使用 HTTP 的 GET 和 POST 方法来对资源进行获取与提交操作。
  • 无状态性:客户端状态信息由客户端保存 (cookie&session),服务器不保存,客户端的每个请求都是独立的。
  • 统一接口:使用统一的接口约束,包括使用标准的 HTTP 方法和状态码,使用标准的媒体类型 JSON 来传输数据。

本项目中客户端的 HTTP 请求分为静态资源请求与动态功能请求,静态资源请求指获取游戏注册页面、登录页面等,动态功能请求指用户登录/注册请求、协议切换请求等。

7.1.1 静态资源请求

静态资源页面,在后台服务器上就是一个个 HTML/CSS/JS 文件;而静态资源请求,其实就是让服务器把对应的文件发送给客户端。

获取注册界面:

代码语言:javascript复制
// 客户端请求
GET /register.html HTTP/1.1 
报头其他字段

// 服务器响应
// 响应报头
HTTP/1.1 200 OK
Content-Length: XXX
Content-Type: text/html
报头其他字段
// 响应正文
register.html文件中的数据

获取登录界面、游戏大厅页面与游戏房间页面类似:

代码语言:javascript复制
// 客户端请求
GET /login.html HTTP/1.1 or GET /game_hall.html HTTP/1.1 or GET /game_room.html HTTP/1.1
报头其他字段

// 服务器响应
// 响应报头
HTTP/1.1 200 OK
Content-Length: XXX
Content-Type: text/html
报头其他字段
// 响应正文
login.html/game_hall/game_room文件中的数据
7.1.2 动态功能请求

用户注册请求:

代码语言:javascript复制
// 客户端请求
// 请求报头
POST /reg HTTP/1.1
Content-Type: application/json
Content-Length: XXX
// 请求正文 -- 序列化的用户名和用户密码
{"username":"zhangsan", "password":"123456"}

// 服务器成功的响应
// 响应报头
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 15
// 响应正文
{"result":true, "reason": "用户注册成功"}

// 服务器失败的响应
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: XXX
{"result":false, "reason": "错误信息,比如该用户名已被占用"}

用户登录请求:

代码语言:javascript复制
// 客户端请求
POST /login HTTP/1.1
Content-Type: application/json
Content-Length: XXX
{"username":"zhangsan", "password":"123456"}

// 服务器成功的响应
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: XXX
{"result":true, "reason": "用户登录成功"}

// 服务器失败的响应
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: XXX
{"result":false, "reason": "错误信息,比如用户名或密码错误"}

获取玩家详细信息请求:

代码语言:javascript复制
// 客户端请求
GET /info HTTP/1.1
Content-Type: application/json
Content-Length: 0

// 服务器成功的响应
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: XXX
{"id":1, "username":"zhangsan", "score":1000, "total_count":4, "win_count":2}

// 服务器失败的响应
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: XXX
{"result":false, "reason": "错误信息,比如用户信息不存在"}

游戏大厅 WebSocket 长连接协议切换请求

代码语言:javascript复制
// 客户端请求
/* ws://localhost:9000/match */
GET /hall HTTP/1.1
Connection: Upgrade
Upgrade: WebSocket
......

// 服务器成功的响应
HTTP/1.1 101 Switching
...

游戏房间 WebSocket 长连接协议切换请求

代码语言:javascript复制
// 客户端请求
/* ws://localhost:9000/match */
GET /room HTTP/1.1
Connection: Upgrade
Upgrade: WebSocket
......

// 服务器成功的响应
HTTP/1.1 101 Switching
...
7.1.3 WebSocket 通信格式

上面我们提到的不管是静态资源请求,还是动态功能请求,它们本质上都是 HTTP 请求,所以我们使用 RESTful 风格的通信接口;但是当玩家进入游戏大厅或者游戏房间后,客户端就会向服务器发送协议切换请求 (协议切换请求本身是 HTTP 请求),将 HTTP 短连接通信协议升级为 WebSocket 长连接通信协议。

由于 WebSocket 协议是一种全双工的持久连接协议,它允许在客户端和服务器之间进行双向实时通信,所以我们每次通信时直接使用 WebSocketpp::server 中的 send 接口向对方发送消息即可,而不再需要重新建立连接。

但是我们仍然需要事先规定好发送消息中不同字段代表的含义,这样才能正确区分收到的消息类型,从而根据消息不同的类型执行不同的处理函数并返回不同的消息。

游戏大厅 WebSocket 握手成功后的回复:

代码语言:javascript复制
// 游戏大厅进入成功
{
    "optype": "hall_ready", 
    "result": true
}

// 游戏大厅进入失败
{
    "optype": "hall_ready", 
    "result": false,
    "reason": "失败原因"
}

玩家开始匹配消息:

代码语言:javascript复制
// 开始匹配消息
{
    "optype": "match_start"
}

// 后台正确处理后回复的消息
{
    "optype": "match_start"
    "result": true,
}

玩家停止匹配消息:

代码语言:javascript复制
// 停止匹配消息
{
	"optype": "match_stop"	
}

// 后台正确处理后回复的消息
{
    "optype": "match_stop"
	"result": true
}

游戏匹配成功后后台回复的消息:

代码语言:javascript复制
{
    "optype": "match_success", 
    "result": true
}

游戏房间 WebSocket 握手成功后的回复:

代码语言:javascript复制
// 游戏房间创建成功
{
    "optype": "room_ready",
    "result": true,
    "room_id": 222,    //房间ID
    "uid": 1,          //⾃⾝ID
    "white_id": 1,     //⽩棋ID
    "black_id": 2,     //⿊棋ID
}

// 游戏房间创建失败
{
    "optype": "room_ready",
    "result": false,
    "reason": "失败原因"
}

玩家下棋的消息:

代码语言:javascript复制
// 玩家下棋消息
{
    "optype": "put_chess", // put_chess表示当前请求是下棋操作
    "room_id": 222,        // room_id 表⽰当前动作属于哪个房间
    "uid": 1,              // 当前的下棋操作是哪个用户发起的
    "row": 3,              // 当前下棋位置的⾏号
    "col": 2               // 当前下棋位置的列号
}

// 下棋成功后后台回复的消息
{
    "optype": "put_chess",
    "result": true,
    "reason": "下棋成功或游戏胜利或游戏失败",
    "room_id": 222,
    "uid": 1,
    "row": 3,
    "col": 2,
    "winner": 0  // 游戏获胜者,0表示未分胜负,!0表示已分胜负
}

// 下棋失败后后台回复的消息
{
    "optype": "put_chess",
    "result": false,
    "reason": "下棋失败的原因",
    "room_id": 222,  
    "uid": 1,
    "row": 3,
    "col": 2,
    "winner": 0  
}

玩家聊天的消息:

代码语言:javascript复制
// 玩家聊天消息
{
    "optype": "chat",  // chat表示当前请求是下棋操作
    "room_id": 222,    // room_id 表⽰当前动作属于哪个房间
    "uid": 1,          // 当前的下棋操作是哪个用户发起的
    "message": "你好"   // 聊天消息的具体内容
}

// 聊天消息发送成功后台回复的消息
{
    "optype": "chat",
    "result": true,
    "room_id": 222,
    "uid": 1,
    "message": "你好"
}

// 聊天消息发送失败后台回复的消息
{
    "optype": "chat",
    "result": false,
    "reason": "错误原因,比如消息中包含敏感词",
    "room_id": 222,
    "uid": 1,
    "message": "你好"
}

未知类型的消息:

代码语言:javascript复制
{
    "optype": 消息的类型,
    "result": false,
    "reason": "未知类型的消息"
}
7.2 服务器模块实现

关于如何使用 WebSocketpp 来搭建一个服务器,我们在上面前置知识了解那里已经说过了,大体流程如下:

  1. 实例化一个 websocketpp::server 对象。
  2. 设置日志等级。(本项目中我们使用自己封装的日志函数,所以这里设置日志等级为 none)
  3. 初始化 asio 调度器。
  4. 设置处理 http 请求、websocket 握手成功、websocket 连接关闭以及收到 websocket 消息的回调函数。
  5. 设置监听端口。
  6. 开始获取 tcp 连接。
  7. 启动服务器。
代码语言:javascript复制
class gobang_server {
public:
    /*成员初始化与服务器回调函数设置*/
    gobang_server(const std::string &host, const std::string &user, const std::string &passwd, 
                               const std::string db = "gobang", uint16_t port = 4106)
        : _wwwroot(WWWROOT), _ut(host, user, passwd, db, port), _sm(&_wssrv), _rm(&_ut, &_om), _mm(&_ut, &_om, &_rm) {
        // 设置日志等级
        _wssrv.set_access_channels(websocketpp::log::alevel::none);
        // 初始化asio调度器
        _wssrv.init_asio();
        // 设置回调函数
        _wssrv.set_http_handler(std::bind(&gobang_server::http_callback, this, std::placeholders::_1));
        _wssrv.set_open_handler(std::bind(&gobang_server::wsopen_callback, this, std::placeholders::_1));
        _wssrv.set_close_handler(std::bind(&gobang_server::wsclose_callback, this, std::placeholders::_1));
        _wssrv.set_message_handler(std::bind(&gobang_server::wsmsg_callback, this, std::placeholders::_1, std::placeholders::_2));
    }

    /*启动服务器*/
    void start(uint16_t port) {
        // 设置监听端口
        _wssrv.listen(port);
        _wssrv.set_reuse_addr(true);
        // 开始获取新连接
        _wssrv.start_accept();
        // 启动服务器
        _wssrv.run();        
    }
private:
    std::string _wwwroot;    // 静态资源根目录
    user_table _ut;          // 用户数据管理模块句柄
    session_manager _sm;     // 用户session信息管理模块句柄
    online_manager _om;      // 用户在线信息管理模块句柄
    room_manager _rm;        // 游戏房间管理模块句柄
    matcher _mm;             // 用户对战匹配管理模块句柄
    wsserver_t _wssrv;       // websocketpp::server 句柄
};    

我们的重难点在于如何实现 http 请求、websocket 握手成功、websocket 连接关闭以及 websocket 消息这四个回调函数。具体实现如下:

代码语言:javascript复制
/* 
服务器模块 
通过对之前所有模块进行整合以及进行服务器搭建,最终封装实现出⼀个gobang_server的服务器模块类,向外提供搭建五⼦棋对战服务器的接⼝。
达到通过实例化的对象就可以简便的完成服务器搭建的目的
*/

#ifndef __SERVER_HPP__
#define __SERVER_HPP__
#include "util.hpp"
#include "db.hpp"
#include "online.hpp"
#include "room.hpp"
#include "matcher.hpp"
#include "session.hpp"

#define WWWROOT "./wwwroot"

typedef websocketpp::server<websocketpp::config::asio> wsserver_t;

class gobang_server {
private:
    /*http静态资源请求处理函数(注册界面、登录界面、游戏大厅界面)*/
    void file_handler(wsserver_t::connection_ptr conn) {
        // 获取http请求对象与请求uri
        websocketpp::http::parser::request req = conn->get_request();
        std::string uri = req.get_uri();
        // 根据uri组合出文件路径,如果文件路径是目录(/结尾)则追加login.html,否则返回相应界面
        std::string pathname = _wwwroot   uri;
        if(pathname.back() == '/') {
            pathname  = "login.html";
        }
        // 读取文件内容,如果文件不存在,则返回404
        std::string body;
        if(file_util::read(pathname.c_str(), body) == false) {
            body  = "<html><head><meta charset='UTF-8'/></head><body><h1> 404 Not Found </h1></body></html>";
            // 设置响应状态码
            conn->set_status(websocketpp::http::status_code::not_found);
        }
        else conn->set_status(websocketpp::http::status_code::ok);
        // 添加响应头部
        conn->append_header("Content-Length", std::to_string(body.size()));
        // 设置响应正文
        conn->set_body(body);        
    }

    /*处理http响应的子功能函数*/
    void http_resp(wsserver_t::connection_ptr conn, bool result, websocketpp::http::status_code::value code, const std::string &reason) {
            // 设置响应正文及其序列化
            Json::Value resp;
            std::string resp_body;
            resp["result"] = result;
            resp["reason"] = reason;
            json_util::serialize(resp, resp_body);
            // 设置响应状态码,添加响应正文以及正文类型
            conn->set_status(code);
            conn->append_header("Content-Type", "application/json");
            conn->set_body(resp_body);
    }

    /*http动态功能请求处理函数 -- 用户注册*/
    void reg(wsserver_t::connection_ptr conn) {
        // 获取json格式的请求正文
        std::string req_body = conn->get_request_body();
        // 将正文反序列化得到username和password
        Json::Value user_info;
        if(json_util::deserialize(req_body, user_info) == false) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "请求正文格式错误");
        }
        // 数据库新增用户
        if(user_info["username"].isNull() || user_info["password"].isNull()) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "请输入用户名/密码");
        }
        if(_ut.registers(user_info) == false) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "该用户名已被占用");
        }
        return http_resp(conn, true, websocketpp::http::status_code::ok, "用户注册成功");
    }

    /*http动态功能请求处理函数 -- 用户登录*/
    void login(wsserver_t::connection_ptr conn) {
        // 获取请求正文并反序列化
        std::string req_body = conn->get_request_body();
        Json::Value user_info;
        if(json_util::deserialize(req_body, user_info) == false) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "请求正文格式错误");
        }
        if(user_info["username"].isNull() || user_info["password"].isNull()) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "请输入用户名/密码");
        }
        // 用户登录 -- 登录失败返回404
        if(_ut.login(user_info) == false) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "用户名/密码错误");
        }
        // 登录成功则为用户创建session信息以及session生命周期
        session_ptr ssp = _sm.create_session(user_info["id"].asUInt64());
        if(ssp.get() == nullptr) {
            return http_resp(conn, false, websocketpp::http::status_code::internal_server_error, "用户会话创建失败");
        }
        _sm.set_session_expire_time(ssp->get_ssid(), SESSION_TIMEOUT);
        // 设置过响应头部 将cookie返回给客户端
        std::string cookie_ssid = "SSID="   std::to_string(ssp->get_ssid());
        conn->append_header("Set-Cookie", cookie_ssid);
        return http_resp(conn, true, websocketpp::http::status_code::ok, "用户登录成功");
    }

    /*从http请求头部Cookie中获取指定key对应的value*/
    bool get_cookie_val(const std::string &cookie_str, const std::string &key, std::string &val) {
        // cookie_str格式:SSID=XXX; path=/XXX
        // 先以逗号为分割将cookie_str中的各个cookie信息分割开
        std::vector<std::string> cookies;
        string_util::split(cookie_str, ";", cookies);
        // 再以等号为分割将单个cookie中的key与val分割开,比对查找目标key对应的val
        for(const auto cookie : cookies) {
            std::vector<std::string> kv;
            string_util::split(cookie, "=", kv);
            if(kv.size() != 2) continue;
            if(kv[0] == key) {
                val = kv[1];
                return true;
            }
        }
        return false;
    }

    /*http动态功能请求处理函数 -- 获取用户信息*/
    void info(wsserver_t::connection_ptr conn) {
        // 通过http请求头部中的cookie字段获取用户ssid
        std::string cookie_str = conn->get_request_header("Cookie");
        if(cookie_str.empty()) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "找不到Cookie信息,请重新登录");
        }
        std::string ssid_str;
        if(get_cookie_val(cookie_str, "SSID", ssid_str) == false) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "找不到Session信息,请重新登录");
        }
        // 根据ssid_str获取用户Session信息
        session_ptr ssp = _sm.get_session_by_ssid(std::stol(ssid_str));
        if(ssp.get() == nullptr) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "Session已过期,请重新登录");
        }
        // 通过用户session获取用户id,再根据用户id获取用户详细信息
        uint64_t uid = ssp->get_user(); 
        Json::Value user;
        if(_ut.select_by_id(uid, user) == false) {
            return http_resp(conn, false, websocketpp::http::status_code::bad_request, "用户信息不存在");
        }
        // 返回用户详细信息
        std::string body;
        json_util::serialize(user, body);
        std::string resp_cookie = "SSID="   ssid_str;
        conn->set_status(websocketpp::http::status_code::ok);
        conn->append_header("Content-Type", "application/json");
        conn->append_header("Set-Cookie", resp_cookie);
        conn->set_body(body);
        // 更新用户session过期时间
        _sm.set_session_expire_time(ssp->get_ssid(), SESSION_TIMEOUT);        
    }
private:
    /*************************************************************************************************/
    /*http请求回调函数*/
    /*************************************************************************************************/
    void http_callback(websocketpp::connection_hdl hdl) {
        
        wsserver_t::connection_ptr conn = _wssrv.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") reg(conn);
        else if(method == "POST" && uri == "/login") login(conn);
        else if(method == "GET" && uri == "/info") info(conn);
        // 静态资源请求
        else file_handler(conn);
    }

    /*游戏大厅websocket长连接建立后的响应子函数*/
    void game_hall_resp(wsserver_t::connection_ptr conn, bool result, const std::string &reason = "") {
        Json::Value resp;
        resp["optype"] = "hall_ready";
        resp["result"] = result;
        // 只有错误才返回错误信息reason
        if(result == false) resp["reason"] = reason;
        std::string body;
        json_util::serialize(resp, body);
        conn->send(body);
    }

    /*wsopen_callback子函数 -- 游戏大厅websocket长连接建立后的处理函数*/
    void wsopen_game_hall(wsserver_t::connection_ptr conn) {
        // 检查用户是否登录 -- 检查cookie&session信息
        // 通过http请求头部中的cookie字段获取用户ssid
        std::string cookie_str = conn->get_request_header("Cookie");
        if(cookie_str.empty()) {
            return game_hall_resp(conn, false, "找不到Cookie信息,请重新登录");
        }
        std::string ssid_str;
        if(get_cookie_val(cookie_str, "SSID", ssid_str) == false) {
            return game_hall_resp(conn, false, "找不到Session信息,请重新登录");
        }
        // 根据ssid_str获取用户Session信息
        session_ptr ssp = _sm.get_session_by_ssid(std::stol(ssid_str));
        if(ssp.get() == nullptr) {
            return game_hall_resp(conn, false, "Session已过期,请重新登录");
        }
        // 通过用户session获取用户id
        uint64_t uid = ssp->get_user();
        // 检查用户是否重复登录 -- 用户游戏大厅长连接/游戏房间长连接是否已经存在
        if(_om.is_in_game_hall(uid) == true) {
            return game_hall_resp(conn, false, "玩家重复登录");
        }        
        // 将玩家及其连接加入到在线游戏大厅中
        _om.enter_game_hall(uid, conn);
        // 返回响应
        game_hall_resp(conn, true);
        // 将用户Session过期时间设置为永不过期
        _sm.set_session_expire_time(ssp->get_ssid(), SESSION_FOREVER);
    }

    /*游戏房间websocket长连接建立后的响应子函数*/
    void game_room_resp(wsserver_t::connection_ptr conn, bool result, const std::string &reason,  
                        uint64_t room_id = 0, uint64_t self_id = 0, uint64_t white_id = 0, uint64_t black_id = 0) {
        Json::Value resp;
        resp["optype"] = "room_ready";
        resp["result"] = result;
        // 如果成功返回room_id,self_id,white_id,black_id等信息,如果错误则返回错误信息
        if(result == true) {
            resp["room_id"] = (Json::UInt64)room_id;
            resp["uid"] = (Json::UInt64)self_id;
            resp["white_id"] = (Json::UInt64)white_id;
            resp["black_id"] = (Json::UInt64)black_id;
        }
        else resp["reason"] = reason;        
        std::string body;
        json_util::serialize(resp, body);
        conn->send(body);
    }    

    /*wsopen_callback子函数 -- 游戏房间websocket长连接建立后的处理函数*/
    void wsopen_game_room(wsserver_t::connection_ptr conn) {
        // 获取cookie&session信息
        std::string cookie_str = conn->get_request_header("Cookie");
        if(cookie_str.empty()) {
            return game_room_resp(conn, false, "找不到Cookie信息,请重新登录");
        }
        std::string ssid_str;
        if(get_cookie_val(cookie_str, "SSID", ssid_str) == false) {
            return game_room_resp(conn, false, "找不到Session信息,请重新登录");
        }
        // 根据ssid_str获取用户Session信息
        session_ptr ssp = _sm.get_session_by_ssid(std::stol(ssid_str));
        if(ssp.get() == nullptr) {
            return game_room_resp(conn, false, "Session已过期,请重新登录");
        }    
        // 判断用户是否已经处于游戏大厅/房间中了(在创建游戏房间长连接之前,游戏大厅的长连接已经断开了) -- 在线用户管理
        if(_om.is_in_game_hall(ssp->get_user()) || _om.is_in_game_room(ssp->get_user())) {
            return game_room_resp(conn, false, "玩家重复登录");
        } 
        // 判断游戏房间是否被创建 -- 游戏房间管理
        room_ptr rp = _rm.get_room_by_uid(ssp->get_user());
        if(rp.get() == nullptr) {
            return game_room_resp(conn, false, "找不到房间信息");
        }
        // 将玩家加入到在线游戏房间中
        _om.enter_game_room(ssp->get_user(), conn);
        // 返回响应信息
        game_room_resp(conn, true, "", rp->_room_id, ssp->get_user(), rp->_white_user_id, rp->_black_user_id);
        // 将玩家session设置为永不过期
        _sm.set_session_expire_time(ssp->get_ssid(), SESSION_FOREVER);
    }

    /*************************************************************************************************/
    /*websocket长连接建立之后的处理函数*/
    /*************************************************************************************************/
    void wsopen_callback(websocketpp::connection_hdl hdl) {
        // 获取通信连接、http请求对象和请求uri
        wsserver_t::connection_ptr conn = _wssrv.get_con_from_hdl(hdl);
        websocketpp::http::parser::request req = conn->get_request();
        std::string uri = req.get_uri();
        // 进入游戏大厅与进入游戏房间需要分别建立websocket长连接
        if(uri == "/hall") wsopen_game_hall(conn);
        else if(uri == "/room") wsopen_game_room(conn);
    }

    /*wsclose_callback子函数 -- 游戏大厅websocket长连接断开后的处理函数*/
    void wsclose_game_hall(wsserver_t::connection_ptr conn) {
        // 获取cookie&session,如果不存在则说明websocket长连接未建立(websocket长连接建立后Session永久存在),直接返回
       std::string cookie_str = conn->get_request_header("Cookie");
        if(cookie_str.empty()) return;
        std::string ssid_str;
        if(get_cookie_val(cookie_str, "SSID", ssid_str) == false) return;
        session_ptr ssp = _sm.get_session_by_ssid(std::stol(ssid_str));
        if(ssp.get() == nullptr) return;
        // 将玩家从游戏大厅移除
        _om.exit_game_hall(ssp->get_user());
        // 将玩家session设置为定时删除
        _sm.set_session_expire_time(ssp->get_ssid(), SESSION_TIMEOUT);           
    }    

    /*wsclose_callback子函数 -- 游戏房间websocket长连接断开后的处理函数*/
    void wsclose_game_room(wsserver_t::connection_ptr conn) {
        // 获取cookie&session,如果不存在直接返回
       std::string cookie_str = conn->get_request_header("Cookie");
        if(cookie_str.empty()) return;
        std::string ssid_str;
        if(get_cookie_val(cookie_str, "SSID", ssid_str) == false) return;
        session_ptr ssp = _sm.get_session_by_ssid(std::stol(ssid_str));
        if(ssp.get() == nullptr) return;
        // 将玩家从在线用户管理的游戏房间中移除
        _om.exit_game_room(ssp->get_user());
        // 将玩家从游戏房间管理的房间中移除
        _rm.remove_room_user(ssp->get_user());
        // 设置玩家session为定时删除
        _sm.set_session_expire_time(ssp->get_ssid(), SESSION_TIMEOUT);        
    }
	
    /*************************************************************************************************/
    /*websocket长连接断开之间的处理函数*/
    /*************************************************************************************************/
    void wsclose_callback(websocketpp::connection_hdl hdl) {
        // 获取通信连接、http请求对象和请求uri
        wsserver_t::connection_ptr conn = _wssrv.get_con_from_hdl(hdl);
        websocketpp::http::parser::request req = conn->get_request();
        std::string uri = req.get_uri();
        // 离开游戏大厅与离开游戏房间需要分别断开websocket长连接
        if(uri == "/hall") wsclose_game_hall(conn);
        else if(uri == "/room") wsclose_game_room(conn);  
    }

    /*wsmsg_callback子函数 -- 游戏大厅通信处理函数*/
    void wsmsg_game_hall(wsserver_t::connection_ptr conn, wsserver_t::message_ptr msg) {
        // 获取cookie&session,如果不存在则返回错误信息
        std::string cookie_str = conn->get_request_header("Cookie");
        if(cookie_str.empty()) {
            return game_hall_resp(conn, false, "找不到Cookie信息,请重新登录");
        }
        std::string ssid_str;
        if(get_cookie_val(cookie_str, "SSID", ssid_str) == false) {
            return game_hall_resp(conn, false, "找不到Session信息,请重新登录");
        }
        session_ptr ssp = _sm.get_session_by_ssid(std::stol(ssid_str));
        if(ssp.get() == nullptr) {
            return game_hall_resp(conn, false, "Session已过期,请重新登录");
        }
        // 获取请求信息 
        std::string req_msg_body = msg->get_payload(); 
        Json::Value req_msg;
        if(json_util::deserialize(req_msg_body, req_msg) == false)  {
            return game_hall_resp(conn, false, "请求信息解析失败"); 
        }
        // 处理请求信息 -- 开始对战匹配与停止对战匹配
        Json::Value resp = req_msg;
        std::string resp_body;
        // 开始对战匹配请求则将用户加入到匹配队列中,取消对战匹配请求则将用户从匹配队列中移除
        if(req_msg["optype"].isNull() == false && req_msg["optype"].asString() == "match_start") {
            _mm.add(ssp->get_user());
            resp["result"] = true;
            json_util::serialize(resp, resp_body);
            conn->send(resp_body);
        } else if(req_msg["optype"].isNull() == false && req_msg["optype"].asString() == "match_stop") {
            _mm.remove(ssp->get_user());
            resp["result"] = true;
            json_util::serialize(resp, resp_body);
            conn->send(resp_body);
        } else {
            resp["optype"] = req_msg["optype"].asString();
            resp["result"] = false;
            resp["reason"] = "未知类型的消息";
            json_util::serialize(resp, resp_body);
            conn->send(resp_body);
        }       
    }    

    /*wsmsg_callback子函数 -- 游戏房间通信处理函数*/
    void wsmsg_game_room(wsserver_t::connection_ptr conn, wsserver_t::message_ptr msg) {
        // 获取cookie&session,如果不存在则返回错误信息
        std::string cookie_str = conn->get_request_header("Cookie");
        if(cookie_str.empty()) {
            return game_room_resp(conn, false, "找不到Cookie信息,请重新登录");
        }
        std::string ssid_str;
        if(get_cookie_val(cookie_str, "SSID", ssid_str) == false) {
            return game_room_resp(conn, false, "找不到Session信息,请重新登录");
        }
        session_ptr ssp = _sm.get_session_by_ssid(std::stol(ssid_str));
        if(ssp.get() == nullptr) {
            return game_room_resp(conn, false, "Session已过期,请重新登录");
        }
        // 获取房间信息
        room_ptr rp = _rm.get_room_by_uid(ssp->get_user());
        if(rp.get() == nullptr) {
            return game_room_resp(conn, false, "找不到房间信息");
        }
        // 获取请求信息 
        std::string req_msg_body = msg->get_payload(); 
        Json::Value req_msg;
        if(json_util::deserialize(req_msg_body, req_msg) == false)  {
            return game_room_resp(conn, false, "请求信息解析失败"); 
        }
        // 处理请求信息 -- 下棋动作与聊天动作
        rp->handler(req_msg);
    }    

    /*************************************************************************************************/
    /*websocket长连接建立后通信的处理函数*/
    /*************************************************************************************************/
    void wsmsg_callback(websocketpp::connection_hdl hdl, wsserver_t::message_ptr msg) {
        // 获取通信连接、http请求对象和请求uri
        wsserver_t::connection_ptr conn = _wssrv.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);  
    }   
public:
    /*成员初始化与服务器回调函数设置*/
    gobang_server(const std::string &host, const std::string &user, const std::string &passwd, 
                               const std::string db = "gobang", uint16_t port = 4106)
        : _wwwroot(WWWROOT), _ut(host, user, passwd, db, port), _sm(&_wssrv), _rm(&_ut, &_om), _mm(&_ut, &_om, &_rm) {
        // 设置日志等级
        _wssrv.set_access_channels(websocketpp::log::alevel::none);
        // 初始化asio调度器
        _wssrv.init_asio();
        // 设置回调函数
        _wssrv.set_http_handler(std::bind(&gobang_server::http_callback, this, std::placeholders::_1));
        _wssrv.set_open_handler(std::bind(&gobang_server::wsopen_callback, this, std::placeholders::_1));
        _wssrv.set_close_handler(std::bind(&gobang_server::wsclose_callback, this, std::placeholders::_1));
        _wssrv.set_message_handler(std::bind(&gobang_server::wsmsg_callback, this, std::placeholders::_1, std::placeholders::_2));
    }

    /*启动服务器*/
    void start(uint16_t port) {
        // 设置监听端口
        _wssrv.listen(port);
        _wssrv.set_reuse_addr(true);
        // 开始获取新连接
        _wssrv.start_accept();
        // 启动服务器
        _wssrv.run();        
    }
private:
    std::string _wwwroot;    // 静态资源根目录
    user_table _ut;          // 用户数据管理模块句柄
    session_manager _sm;     // 用户session信息管理模块句柄
    online_manager _om;      // 用户在线信息管理模块句柄
    room_manager _rm;        // 游戏房间管理模块句柄
    matcher _mm;             // 用户对战匹配管理模块句柄
    wsserver_t _wssrv;       // websocketpp::server 句柄
};
#endif

8. 前端界面模块

8.1 用户注册界面

register.html:

代码语言:javascript复制
<!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>注册</title>
    <link rel="stylesheet" href="./css/common.css">
    <link rel="stylesheet" href="./css/login.css">
</head>
<body>
    <div class="nav">
        网络五子棋对战游戏 
    </div>
    <div class="login-container">
        <!-- 登录界面的对话框 -->
        <div class="login-dialog">
            <!-- 提示信息 -->
            <h3>注册</h3>
            <!-- 这个表示一行 -->
            <div class="row">
                <span>用户名</span>
                <input type="text" id="user_name" name="username">
            </div>
            <!-- 这是另一行 -->
            <div class="row">
                <span>密码</span>
                <input type="password" id="password" name="password">
            </div>
            <!-- 提交按钮 -->
            <div class="row">
                <!--给提交按钮添加点击事件 -- 调用注册函数reg-->
                <button id="submit" onclick="reg()">提交</button>
            </div>
        </div>
    </div> 

    <script src="js/jquery.min.js"></script>
    <script>
        // 封装实现注册函数
        function reg() {
            // 获取输入框中的username和password,并将它们组织成json格式字符串
            var reg_info = {
                username: document.getElementById("user_name").value,
                password: document.getElementById("password").value
            };
            // 通过ajax向服务器发送注册请求
            $.ajax({
                url: "/reg",
                type: "post",
                data: JSON.stringify(reg_info),
                // 请求失败,清空输入框中的内容并提示错误信息;请求成功,则返回用户登录页面
                success: function(res) {
                    if(res.result == false) {
                        document.getElementById("user_name").value = "";
                        document.getElementById("password").value = "";
                        alert(res.reason);
                    } else {
                        alert(res.reason);
                        window.location.assign("/login.html");
                    }
                },
                error: function(xhr) {
                    document.getElementById("user_name").value = "";
                    document.getElementById("password").value = "";
                    alert(JSON.stringify(xhr));
                }
            })
        }
    </script>
</body>
</html>
8.2 用户登录界面

login.html:

代码语言:javascript复制
<!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>登录</title>

    <link rel="stylesheet" href="./css/common.css">
    <link rel="stylesheet" href="./css/login.css">
</head>
<body>
    <div class="nav">
        网络五子棋对战游戏
    </div>
    <div class="login-container">
        <!-- 登录界面的对话框 -->
        <div class="login-dialog">
            <!-- 提示信息 -->
            <h3>登录</h3>
            <!-- 这个表示一行 -->
            <div class="row">
                <span>用户名</span>
                <input type="text" id="user_name">
            </div>
            <!-- 这是另一行 -->
            <div class="row">
                <span>密码</span>
                <input type="password" id="password">
            </div>
            <!-- 提交按钮 -->
            <div class="row">
                <!--为按钮添加点击事件,调用登录函数-->
                <button id="submit" onclick="login()">提交</button>
            </div>
        </div>

    </div>

    <script src="./js/jquery.min.js"></script>
    <script>
        function login() {
            // 获取输入框中的username和password
            var log_info = {
                username: document.getElementById("user_name").value,
                password: document.getElementById("password").value
            };
            // 通过ajax向服务器发送登录请求
            $.ajax({
                url: "/login",
                type: "post",
                data: JSON.stringify(log_info),
                // 请求成功返回游戏大厅页面,请求失败则清空输入框中的内容并提示错误信息
                success: function(res) {
                    alert("登录成功");
                    window.location.assign("/game_hall.html");
                },
                error: function(xhr) {
                    document.getElementById("user_name").value = "";
                    document.getElementById("password").value = "";
                    alert(JSON.stringify(xhr));
                }
            })
        }
    </script>
</body>
</html>
8.3 游戏大厅界面

game_hall.html:

代码语言:javascript复制
<!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>游戏大厅</title>
    <link rel="stylesheet" href="./css/common.css">
    <link rel="stylesheet" href="./css/game_hall.css">
</head>
<body>
    <div class="nav">网络五子棋对战游戏</div>
    <!-- 整个页面的容器元素 -->
    <div class="container">
        <!-- 这个 div 在 container 中是处于垂直水平居中这样的位置的 -->
        <div>
            <!-- 展示用户信息 -->
            <div id="screen"></div>
            <!-- 匹配按钮 -->
            <div id="match-button">开始匹配</div>
        </div>
    </div>

    <script src="./js/jquery.min.js"></script>
    <script>
        ws_hdl = null;
        //设置离开当前页面后立即断开websocket链接
        window.onbeforeunload = function () {
            ws_hdl.close();
        }

        // 获取玩家信息展示在游戏大厅与websocket长连接切换
        function get_user_info() {
            // 通过ajax向服务器发送获取用户信息请求
            $.ajax({
                url: "/info",
                type: "get",
                success: function(res) {
                    var info_html = "<p>"   "姓名: "   res.username   "  积分:"   res.score   "</br>"   
                        "  战斗场次: "   res.total_count   "  胜利场次: "   res.win_count   "</p>";
                    var screen_div = document.getElementById("screen");
                    screen_div.innerHTML = info_html;

                    // 获取玩家信息成功之后将http短连接协议切换为websocket长连接切换
                    ws_url = "ws://"   location.host   "/hall";
                    ws_hdl = new WebSocket(ws_url);
                    // 为websocket各种触发事件设置回调函数
                    ws_hdl.onopen = ws_onopen;
                    ws_hdl.onclose = ws_onclose;
                    ws_hdl.onerror = ws_onerror;
                    ws_hdl.onmessage = ws_onmessage;
                },
                // 获取失败则返回登录页面并提示错误信息
                error: function(xhr) {
                    alert(JSON.stringify(xhr));
                    location.replace("/login.html");
                }
            })
        }

        // 匹配按钮一共有两种状态 -- 未开始匹配(unmatched)和匹配中(matching)
        var button_statu = "unmatched";
        // 为匹配按钮添加点击事件
        var button_ele = document.getElementById("match-button");
        button_ele.onclick = function() {
            // 在没有匹配状态下点击按钮,则发送开始匹配请求
            if(button_statu == "unmatched") {
                var req = { optype: "match_start" };
                ws_hdl.send(JSON.stringify(req));
            }
            // 在匹配状态下点击按钮,则范式停止匹配请求
            else if(button_statu == "matching") {
                var req = { optype: "match_stop" };
                ws_hdl.send(JSON.stringify(req));
            }
        }

        function ws_onopen() {
            console.log("游戏大厅长连接建立成功");
        }
        function ws_onclose() {
            console.log("游戏大厅长连接断开");
        }
        function ws_onerror() {
            console.log("游戏大厅长连接建立出错");
        }

        // 服务器响应处理函数
        function ws_onmessage(evt) {
            // 判断请求是否被成功处理,如果处理失败,则提示错误信息并跳转登录页面
            var resp = JSON.parse(evt.data);
            if(resp.result == false) {
                alert(evt.data)
                location.replace("/login.html");
                return;
            }
            // 根据不同的响应类型进行不同的操作(成功建立大厅长连接、开始匹配、停止匹配、匹配成功以及未知响应类型)
            if(resp.optype == "hall_ready") {} 
            else if(resp.optype == "match_start") {
                console.log("玩家已成功加入匹配队列");
                button_statu = "matching";
                button_ele.innerHTML = "匹配中... (点击停止匹配)";
            } 
            else if(resp.optype == "match_stop") {
                console.log("玩家已从匹配队列中移除");
                button_statu = "unmatched";
                button_ele.innerHTML = "开始匹配";
            } 
            else if(resp.optype == "match_success") {
                alert("匹配成功");
                location.replace("/game_room.html");
            }
            else {
                alert(evt.data);
                location.replace("/login.html");
            }
        }
        // 调用获取玩家信息函数
        get_user_info();
    </script>
</body>
</html>
8.4 游戏房间界面

game_room.html:

代码语言:javascript复制
<!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>游戏房间</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/game_room.css">
</head>
<body>
    <div class="nav">网络五子棋对战游戏</div>
    <div class="container">
        <div id="chess_area">
            <!-- 棋盘区域, 需要基于 canvas 进行实现 -->
            <canvas id="chess" width="450px" height="450px"></canvas>
            <!-- 显示区域 -->
            <div id="screen"> 等待玩家连接中... </div>
        </div>
        <div id="chat_area" width="400px" height="300px">
            <div id="chat_show">
                <p id="self_msg">你好!</p></br>
                <p id="peer_msg">你好!</p></br>
            </div>
            <div id="msg_show">
                <input type="text" id="chat_input">
                <button id="chat_button">发送</button>
            </div>
        </div>
    </div>
    <script>
        let chessBoard = [];
        let BOARD_ROW_AND_COL = 15;
        let chess = document.getElementById('chess');
        //获取chess控件区域2d画布
        let context = chess.getContext('2d');

        // 将http协议切换为游戏房间的websocket长连接协议
        var ws_url = "ws://"   location.host   "/room";
        var ws_hdl = new WebSocket(ws_url);

        // 设置离开当前页面立即断开websocket连接
        window.onbeforeunload = function () {
            ws_hdl.close();
        }

        // 保存房间信息与是否轮到己方走棋
        var room_info;
        var is_me;

        function initGame() {
            initBoard();
            // 背景图片
            let logo = new Image();
            logo.src = "image/sky.jpeg";
            logo.onload = function () {
                // 绘制图片
                context.drawImage(logo, 0, 0, 450, 450);
                // 绘制棋盘
                drawChessBoard();
            }
        }
        function initBoard() {
            for (let i = 0; i < BOARD_ROW_AND_COL; i  ) {
                chessBoard[i] = [];
                for (let j = 0; j < BOARD_ROW_AND_COL; j  ) {
                    chessBoard[i][j] = 0;
                }
            }
        }
        // 绘制棋盘网格线
        function drawChessBoard() {
            context.strokeStyle = "#BFBFBF";
            for (let i = 0; i < BOARD_ROW_AND_COL; i  ) {
                //横向的线条
                context.moveTo(15   i * 30, 15);
                context.lineTo(15   i * 30, 430); 
                context.stroke();
                //纵向的线条
                context.moveTo(15, 15   i * 30);
                context.lineTo(435, 15   i * 30); 
                context.stroke();
            }
        }
        //绘制棋子
        function oneStep(i, j, isWhite) {
            if (i < 0 || j < 0) return;
            context.beginPath();
            context.arc(15   i * 30, 15   j * 30, 13, 0, 2 * Math.PI);
            context.closePath();
            //createLinearGradient() 方法创建放射状/圆形渐变对象
            var gradient = context.createRadialGradient(15   i * 30   2, 15   j * 30 - 2, 13, 15   i * 30   2, 15   j * 30 - 2, 0);
            // 区分黑白子
            if (!isWhite) {
                gradient.addColorStop(0, "#0A0A0A");
                gradient.addColorStop(1, "#636766");
            } else {
                gradient.addColorStop(0, "#D1D1D1");
                gradient.addColorStop(1, "#F9F9F9");
            }
            context.fillStyle = gradient;
            context.fill();
        }
        //棋盘区域的点击事件
        chess.onclick = function (e) {
            // 如果当前轮到对方走棋,则直接返回
            if(is_me == false) {
                return;
            }
            let x = e.offsetX;
            let y = e.offsetY;
            // 注意, 横坐标是列, 纵坐标是行
            // 这里是为了让点击操作能够对应到网格线上
            let col = Math.floor(x / 30);
            let row = Math.floor(y / 30);
            if (chessBoard[row][col] != 0) {
                alert("当前位置已有棋子");
                return;
            }
            // 发送走棋请求
            send_chess(row, col);
        }
        // 发送走棋请求(websocket长连接通信,直接使用ws_hdl.send,而不是通过ajax)
        function send_chess(r, c) {
            var chess_info = {
                optype: "put_chess",
                room_id: room_info.room_id,
                uid: room_info.uid,
                row: r,
                col: c
            };
            ws_hdl.send(JSON.stringify(chess_info));
            console.log("click:"   JSON.stringify(chess_info));
        }
        // 聊天动作
        // 给消息发送按钮添加点击事件
        var chat_button_div = document.getElementById("chat_button");
        chat_button_div.onclick = function() {
            // 获取聊天输入框中的消息
            var chat_msg = {
                optype: "chat",
                room_id: room_info.room_id,
                uid: room_info.uid,
                message: document.getElementById("chat_input").value
            };
            // 将消息发送给服务器
            ws_hdl.send(JSON.stringify(chat_msg)); 
        }        
        // websocket各种事件的执行函数
        ws_hdl.onopen = function() {
            console.log("游戏房间长连接建立成功");
        }
        ws_hdl.onclose = function() {
            console.log("游戏房间长连接断开");
        }
        ws_hdl.onerror = function() {
            console.log("游戏房间长连接建立出错");
        }
        // 更新screen显示的内容
        function set_screen(me) {
            var screen_div = document.getElementById("screen");
            if(me) screen_div.innerHTML = "轮到己方走棋...";
            else screen_div.innerHTML = "轮到对方走棋...";
        }
        ws_hdl.onmessage = function(evt) {
            console.log("message:"   evt.data);
            var resp = JSON.parse(evt.data);
            // 收到room_ready响应消息
            if(resp.optype == "room_ready") {
                // 保存房间信息与执棋用户
                room_info = resp; 
                // 规定白棋先走
                is_me = (room_info.uid == room_info.white_id ? true : false);
                if(resp.result == false) {
                    alert(resp.reason);
                    location.replace("/login.html");
                } 
                else {
                    // 更新screen显示的内容
                    set_screen(is_me);
                    // 初始化游戏
                    initGame();
                }
            }
            // 收到put_chess响应消息
            else if(resp.optype == "put_chess") {
                // 判断走棋是否成功
                if(resp.result == false) {
                    alert(resp.reason);
                    return;
                }
                // 下棋坐标为-1表示对方掉线
                if(resp.row != -1 && resp.col != -1) {
                    // 绘制棋子
                    isWhite = (resp.uid == room_info.white_id ? true : false);
                    oneStep(resp.col, resp.row, isWhite);
                    // 更新棋盘
                    chessBoard[resp.row][resp.col] = 1;                
                }
                // 更新执棋玩家
                is_me = !is_me;
                // 更新screen显示的内容
                set_screen(is_me);
                // 判断是否有胜利者
                winner = resp.winner;
                if(winner == 0) return;
                // 更新screen信息
                var screen_div = document.getElementById("screen");
                if(winner == room_info.uid) screen_div.innerHTML = resp.reason;
                else screen_div.innerHTML = "游戏失败,再接再厉";
                // 在chess_area区域下方添加返回大厅按钮
                var chess_area_div = document.getElementById("chess_area");
                var button_div = document.createElement("div");
                button_div.innerHTML = "返回大厅";
                button_div.onclick = function() {
                    ws_hdl.close();
                    location.replace("/game_hall.html");
                }
                chess_area_div.appendChild(button_div);
            }
            // 收到chat响应消息
            else if(resp.optype == "chat") {
                if(resp.result == false) {
                    alert(resp.reason);
                    document.getElementById("chat_input").value = "";
                    return;
                }
                // 创建一个子控件,将消息内嵌到其中
                var msg_div = document.createElement("p");
                msg_div.innerHTML = resp.message;
                // 添加属性
                if(resp.uid == room_info.uid) msg_div.setAttribute("id", "self_msg");
                else msg_div.setAttribute("id", "peer_msg");
                // 添加换行
                var br_div = document.createElement("br");
                // 将消息与换行子控件渲染到聊天显示框中
                var msg_show_div = document.getElementById("chat_show");
                msg_show_div.appendChild(msg_div);
                msg_show_div.appendChild(br_div);
                // 清空输入框内容
                document.getElementById("chat_input").value = "";
            }
        }
    </script>
</body>
</html>

六、项目演示

编译 main.cc 得到可执行程序 gobang 并运行:

main.cc

代码语言:javascript复制
#include "server.hpp"

#define HOST "127.0.0.1"
#define USER "thj"
#define PASSWD "Abcd1234@"

int main() 
{
    gobang_server server(HOST, USER, PASSWD);
    server.start(8081);
    return 0;
}

打开浏览器,访问 106.52.90.67:8081/register.html 进行新用户注册,注册成功后浏览器弹出 “用户注册成功” 提示框,点击确定会自动跳转到登录页面。

此时,打开 mysql 客户端,可以看到 xiaowang 的用户信息记录被成功创建。

输入用户名密码,点击登录,浏览器弹出 “登录成功” 提示框,点击自动跳转游戏大厅页面,并且该用户的详细信息成功从数据库获取并展示在游戏大厅页面;同时,该用户与服务器的通信协议由 HTTP 变为 WebSocket,控制台打印 “游戏大厅长连接建立成功” 日志;该用户的 session 信息也被创建并且由于建立了 WebSocket 长连接所以 session 被设置为永久有效。

然后,点击开始匹配,该用户会根据其天梯分数被添加到对应的匹配队列中;点击停止匹配,该用户会从对应的匹配队列中移除。控制台提示相关信息。

此时,我们再用另外一个浏览器注册一个用户,登录并开始匹配,由于新用户天梯分数默认都是 1000,所以两个玩家匹配成功,浏览器弹出 “匹配成功” 提示框,点击确定自动跳转到游戏房间界面,此时原来游戏大厅的长连接会断开,游戏房间的长连接会被创建。(使用不同的浏览器,防止 cookie 信息冲突)

此时,一方的聊天信息以及走棋信息都能被另一方知道。在游戏结束途中,如果一方退出,另一方直接获胜;游戏结束后,用户可以点击 “返回大厅” 按钮回到游戏大厅。

回到游戏大厅后,大厅界面显示的玩家的比赛信息以及数据库中玩家的比赛信息都会被更新。

七、项目扩展

我们上面实现的网络五子棋其实只是一个最基础的版本,或者说是一个重度删减版,其实还可以对它进行许多的扩展,比如添加如下的一些功能:

  • 实现局时与步时功能:我们可以设置一个玩家一局游戏能够思考的总时间以及一步棋能够思考的最长时间;如果步时到了玩家仍未下棋,那么系统可以随机落下一枚棋子。
  • 实现棋谱保存与录像回放功能:我们可以在数据库中创建一个对战表,用来存储玩家的对战数据,即己方与对方下棋的步骤。 这样玩家在对局结束后可以生成对局录像回放 (将数据库中该局对战双方的下棋步骤获取出来,然后间隔一定时间依次显示到前端页面中),同时,如果玩家游戏中途刷新界面或掉线重连后,我们也可以通过数据库中的对战数据让其可以继续对战。
  • 实现观战功能:我们可以在游戏大厅中显示当前正在对战的所有游戏房间,然后家可以选中某个房间以观众的形式加入到房间中,实时的看到选手的对局情况。
  • 实现人机对战的功能:当玩家长时间匹配不到对手时,我们可以为该玩家分配一个 AI 对手与其进行对战;同时,在玩家游戏过程中,我们也可以提供类似 “托管” 的功能,由人机代替玩家来进行对战。

八、项目总结

本项目是一个业务型的项目,也是本人的第一个项目,在编程方面的难度其实并不是太大,主要是学习一个具体业务的整体工作逻辑是怎样的 (从请求到业务处理再到响应),以及前后端是如何配合进行工作的 (HTML/CSS/JS/AJAX)。

在项目编写过程中,相较于 C 、系统编程、网络编程这些已经学过的东西,其实前端以及 WebSocketpp 这方面的知识花费的时间精力会要更多一些,因为这些技术都是第一次接触,需要一边查阅文档一边使用,很多地方出了 bug 也需要花很多时间才能修复。

下面是项目中一些需要特别注意的地方,也可以说是我自己踩过的坑:

  • C语言可变参数与宏函数:本项目日志宏封装模块中使用了一些C语言的知识,包括可变参数、宏函数、预处理符号 ## 以及格式化输出函数 fprintf 等,要注意正确使用它们。
  • C 11 相关:本项目中用到了一些 C 11 相关的知识,包括函数绑定、智能指针、互斥锁、条件变量等,其中要特别注意 bind 如何使用,包括如何使用 bind 固定参数、调整参数顺序等。
  • 动静态库相关:由于本项目中使用了一些第三方库,包括 JsonCpp、WebSocketpp、MySQL C API 等,所以在 Makefile 中进行编译链接时需要使用 -l、-L、-I 选项来指定动态库名称、动态库路径以及库头文件路径。
  • WebSocketpp 相关:由于本项目是使用 WebSocketpp 来进行服务器搭建,所以要对其相关的接口及其使用有一定的了解,特别是其中的 cancel 函数,需要充分了解它的特性才能够正确的使用它。

源码地址:

https://gitee.com/tian-hongjin/project-design/tree/master/gobang

0 人点赞