本项目主要是实现一个网页版的在线五子棋对战游戏,它主要支持以下核心功能:
本项目的开发环境如下:
本项目所使用到的核心技术如下:
本项目一共分为四个开发阶段:
sudo yum install wget
备份之前的 yum 源:
sudo mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak
更换 yum 源为国内阿里的镜像 yum 源:
sudo wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo
sudo yum clean all
sudo yum makecache
安装 scl 软件源:
sudo yum install centos-release-scl-rh centos-release-scl
安装 epel 软件源:
sudo yum install epel-release
sudo yum install lrzsz
安装 devtoolset 高版本 gcc/g++ 编译器:
sudo yum install devtoolset-7-gcc devtoolset-7-gcc-c++
将 devtoolset 加载配置指令添加到终端初始化配置文件中,使其在以后的所有新打开终端中有效:
echo "source /opt/rh/devtoolset-7/enable" >> ~/.bashrc
重新加载终端配置文件:
source ~/.bashrc
sudo yum install gdb
sudo yum install git
sudo yum install cmake
sudo yum install boost-devel.x86_64
sudo yum install jsoncpp-devel
安装 MySQL 环境:【MySQL】Linux 中 MySQL 环境的安装与卸载
设置 MySQL 用户与密码:【MySQL】用户与权限管理
从 github 官方仓库克隆 WebSocketpp 库:
git clone https://github.com/zaphoyd/websocketpp.git
由于 github 服务器在国外,所以可能会出现 clone 失败的情况,此时可以从 gitee 仓库克隆 WebSocketpp 库:
git clone https://gitee.com/freeasm/websocketpp.git
clone 成功后执行如下指令来安装 WebSocketpp 库 (执行 git clone 语句的目录下):
cd websocketpp/
mkdir build
cd build
cmake -DCMAKE_INSTALL_PREFIX=/usr ..
sudo make install
验证 websocketpp 是否安装成功 (build 目录下):
cd ../examples/echo_server
当前目录下 ls 显示:
CMakeLists.txt echo_handler.hpp echo_server.cpp SConscript
g++ 编译 echo_server.cpp,如果编译成功则说明安装成功:
g++ -std=c++11 echo_server.cpp -o echo_server -lpthread -lboost_system
WebSocket 介绍
WebSocket 是从 HTML5 开始支持的⼀种网页端和服务端保持长连接的消息推送机制:
为了解决上述两个问题,有大佬就设计了一种新的应用层协议 – WebSocket 协议。WebSocket 更接近于 TCP 这种级别的通信⽅式,⼀旦连接建立完成客户端或者服务器都可以主动的向对方发送数据。
原理解析
WebSocket 协议本质上是⼀个基于 TCP 的协议。为了建⽴⼀个 WebSocket 连接,客户端浏览器会通过 JavaScript 向服务器发出建立 WebSocket 连接的请求,这个连接请求本质上仍然是一个 HTTP 请求,但它包含了⼀些附加头部信息,比如协议升级"Upgrade: WebSocket",服务器端解析这些附加的头信息然后产生应答信息返回给客户端,客户端和服务器端的 WebSocket 连接就建立起来了,双方就可以通过这个连接通道自由的传递信息,并且这个连接会持续存在直到客户端或者服务器端的某一方主动的关闭连接。。
同时,当客户端浏览器获取到 Web Socket 连接后,之后的通信就不再通过 Ajax 构建客户端请求发送给服务器了,而是直接使用 WebSocket 的 send() 方法方法来向服务器发送数据,并通过 onmessage 事件来接收服务器返回的数据。
报文格式
WebSocket 报文格式如下,大家了解即可:
WebSocket 相关接口
创建 WebSocket 对象:
var Socket = new WebSocket(url, [protocol]);
WebSocket 对象的相关事件:
WebSocket 对象的相关方法:
参考资料:
https://www.runoob.com/html/html5-websocket.html https://www.bilibili.com/video/BV1684y1k7VP/?buvid=ZC4691C539D91BA74044%E2%80%A6&vd_source=cbc46a2fc528c4362ce79ac44dd49e2c
WebSocketpp 介绍
WebSocketpp 是⼀个跨平台的开源 (BSD许可证) 头部专⽤C++库,它实现了RFC6455 (WebSocket协议) 和 RFC7692 (WebSocketCompression?Extensions)。它允许将 WebSocket 客户端和服务器功能集成到 C++ 程序中。在最常见的配置中,全功能网络 I/O 由 Asio 网络库提供。
WebSocketpp 如要有以下特性:
WebSocketpp 同时支持 HTTP 和 Websocket 两种网络协议,比较适用于我们本次的项目,所以我们选用该库作为项目的依赖库,用来搭建 HTTP 和 WebSocket 服务器。
以下是 WebSocketpp 的一些相关网站:
github:https://github.com/zaphoyd/websocketpp
用户手册:http://docs.websocketpp.org/
官网:http://www.zaphoyd.com/websocketpp
WebSocketpp 的使用
WebSocketpp 常用接口及其功能介绍如下:
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 搭建一个简单服务器的流程如下:
示例代码如下:
#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;
}
Json 数据格式
Json 是⼀种数据交换格式,它采⽤完全独立于编程语⾔的⽂本格式来存储和表示数据。
比如,们想表示⼀个同学的学⽣信息。在 C/C++ 中我们可能使用结构体/类来表示:
typedef struct {
char *name = "XXX";
int age = 18;
float score[3] = { 88.5, 99, 58 };
} stu;
而用 Json 数据格式表示如下:
{
"姓名" : "xxX",
"年龄" : 18,
"成绩" : [88.5, 99, 58]
}
Json 的数据类型包括对象,数组,字符串,数字等:
JsonCpp 介绍
Jsoncpp 库主要是⽤于实现 Json 格式数据的序列化和反序列化,它实现了将多个数据对象组织成为 json 格式字符串,以及将 Json 格式字符串解析得到多个数据对象的功能。
Json 数据对象类的部分表示如下:
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 库主要借助三个类以及其对应的少量成员函数完成序列化及反序列化。
序列化接口:
class JSON_API StreamWriter {
virtual int write(Value const& root, std::ostream* sout) = 0;
}
class JSON_API StreamWriterBuilder : public StreamWriter::Factory {
virtual StreamWriter* newStreamWriter() const;
}
反序列化接口:
class JSON_API CharReader {
virtual bool parse(char const* beginDoc, char const* endDoc,
Value* root, std::string* errs) = 0;
}
class JSON_API CharReaderBuilder : public CharReader::Factory {
virtual CharReader* newCharReader() const;
}
使用 jsonCpp 将数据序列化的步骤如下:
使用 JsonCpp 将数据反序列化的步骤如下:
示例代码如下:
#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;
}
C++11 bind 参考文章:
C++11 智能指针参考文章:
C++11 线程库/互斥锁/条件变量参考文章:
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
参考文章:
本项目中与前端有关的技术分别是HTML、CSS、JavaScript 和 AJAX:
注意:本项目中只是对上述这些前端技术进行一个最基本的使用,目的是能够通过它们做出一个简单的前端页面。
HTML 标签:HTML 代码是由 “标签” 构成的
body>hello</body>
<body id="myId">hello</body>
HTML 文件基本结构:
<html>
<head>
<title>第一个页面</title>
</head>
<body>
hello world
</body>
</html>
HTML 常见标签:
<!-- 我是注释 -->
<h1>hello</h1>
<h2>hello</h2>
<!-- ... -->
<p>这是一个段落</p>
<br/>
<img src="./tmp.jpg">
<img src="rose.jpg" alt="鲜花" title="这是一朵鲜花" width="500px" height="800px" border="5px">
<!-- 外部链接 -->
<a href="http://www.github.com">github</a>
<!-- 内部链接: 网站内部页面之间的链接 -->
<a href="2.html">点我跳转到 2.html</a>
<!-- 下载链接: href 对应的路径是一个文件 -->
<a href="test.zip">下载文件</a>
<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 标签:描述了要把数据按照什么方式, 提交到哪个页面中。
<form action="test.html">
... [form 的内容]
</form>
input 标签:各种输入控件, 单行文本框, 按钮, 单选框, 复选框等。
<!-- 文本框 -->
<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 不独占一行, 是一个小盒子。)
<div>
<span>HTML</span>
<span>CSS</span>
<span>JS</span>
</div>
参考资料:
CSS (层叠样式表) 能够对网页中元素位置的排版进行像素级精确控制, 实现美化页面的效果, 能够做到页面的样式和结构分离。
CSS 基本语法规范是 选择器 + {一条/N条声明}:
/*对段落标签进行样式修饰*/
<style>
p {
/* 设置字体颜色 */
color: red;
/* 设置字体大小 */
font-size: 30px;
}
</style>
<p>hello</p>
选择器的功能是选中页面中指定的标签元素,然后对其进行修饰。选择器有很多种类,这里我们主要介绍基础选择器:
标签选择器:标签选择器的优点是能快速为同一类型的标签都选择出来,缺点是不能差异化选择。
<!-- 对段落标签p进行样式修饰 -->
<style>
p {
color: red;
}
</style>
<p>demo</p>
类选择器:类选择器的优点是可以差异化表示不同的标签,同时一个类可以被多个标签使用。类选择器就类似于我们给标签取了一个名字,然后对这个名字的所有标签统一进行样式修饰。
<style>
.blue {
color: blue;
}
</style>
<div class="blue">demo1</div>
<p class="blue">demo2</p>
id 选择器:和类选择器类似,不同的是 id 是唯一的, 不能被多个标签使用。
<style>
#ha {
color: red;
}
</style>
<div id="ha">demo</div>
通配符选择器:使用 * 的定义, 对所有的标签都有效。
CSS 的引入方式一般有三种:
内部样式表:直接写在 style 标签中,嵌入到 html 内部。(style 一般都是放到 head 标签中)
这样做的优点是能够让样式和页面结构分离,缺点是分离的不够彻底,在实际开发中并不常用。
<style>
div {
color: red;
}
</style>
行内样式表:通过 style 属性, 来指定某个标签的样式。
这种方法只适合于写简单样式,并且只针对某个标签生效,在实际开发中也不常用。
<div style="color:green">想要生活过的去, 头上总得带点绿</div>
外部样式表 (重要):先 创建一个 css 文件,然后使用 link 标签引入 css。
这样做能够让让样式和页面结构彻底分离,即使是在 css 内容很多的时候,这也是实际开发中最常用的方式。
<link rel="stylesheet" href="[CSS文件路径]">
参考资料:
JavaScript 的基本语法和 java 类似,所以我们不再单独学习。这里我们主要学习如何使用 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>
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>
参考资料:
为了降低学习成本,这里我们并不使用 js 中原生的 AJAX,而是使用 jQuery 中的 AJAX:
<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>
参考资料:
本项目一共会划分为三个大的模块:
由于项目需要实现用户注册、用户登录、用户匹配对战以及游戏内实时聊天等不同的功能,所以需要对业务处理模块进行子模块划分,让不同的子模块负责不同的业务处理。
业务处理模块具体的子模块划分如下:
从用户/玩家的角度出发,本项目的流程是 注册 -> 登录 -> 对战匹配 -> 游戏对战&实时聊天 -> 游戏结束返回游戏大厅。
从服务器角度出发,本项目的流程如下:
在进行具体的业务模块开发之前,我们可以提前封装实现⼀些项⽬中会用到的边缘功能代码,这样以后在项目中有相应需求时就可以直接使用了。
日志宏功能主要负责程序日志的打印,方便我们在程序出错时能够快速定位错误,以及在程序运行过程中打印一些关键的提示信息。
logger.hpp:
#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
MySQL C API 工具类主要是封装部分C语言连接数据库的接口,包括 MySQL 句柄的创建和销毁,以及 sql 语句的执行。
需要注意的是,我们并没有封装获取 sql 查询结果的相关接口,因为是否要获取查询结果、要获取哪部分查询结果以及以何种形式获取查询结果,这些都是与业务需求强相关的。
mysql_util:
/*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);
}
}
};
jsoncpp 工具类主要是完成数据的序列化与反序列化工作。
json_util:
/*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;
}
};
string split 主要是按照特定分隔符对字符串进行分割,并将分割后的结果进行返回。在本项目中,它的使用场景是分割请求头部中的 cookie 信息,获取 session id。
string_util:
/*字符串处理工具类*/
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();
}
};
file read 的作用是读取指定文件中的内容。
file_util:
/*读取文件数据工具类*/
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;
}
};
用户数据管理模块主要负责对数据库中数据进行统⼀的增删查改管理,其他模块对数据的操作都必须通过用户数据管理模块来完成。
在本项目中,用户数据主要包括用户名、用户密码、用户天梯分数、用户对战场次以及用户获胜场次,我们可以在数据库中创建一个 user 表来保存用户数据。其中,user 表中需要有一个自增主键 id 来唯一标识一个用户。
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
);
对于一般的数据库来说,数据库中有可能存在很多张表,而每张表中管理的数据以及要进行的数据操作都各不相同,因此我们可以为每⼀张表中的数据操作都设计⼀个类,通过类实例化的对象来访问这张数据库表中的数据。这样当我们要访问哪张表的时候,只需要使用对应类实例化的对象即可。
对于本项目而言,目前数据库中只有一张 user 表,所以我们需要为其设计一个类,它的主要功能如下:
db.hpp:
#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
在线用户管理模块主要管理两类用户 – 进入游戏大厅的用户与进入游戏房间的用户,因为用户只有进入了游戏大厅或者游戏房间,其对应的客户端才会与服务器建立 WebSocket 长连接。
此时我们需要将用户 id 与用户所对应的 WebSocket 长连接关联起来,这样我们就能够通过用户 id 找到用户所对应的连接,进而实现服务器主动向客户端推送消息的功能:
需要注意的是,用户在游戏大厅的长连接与游戏房间的长连接是不同的,所以我们需要分别建立游戏大厅用户 id 与 WebSocket 长连接的关联关系以及游戏房间用户 id 与 WebSocket 长连接的关联关系。
在线用户管理类的主要功能如下:
online.hpp:
#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
游戏房间管理模块就是设计一个房间类,能够实现房间的实例化;房间类主要是对匹配成功的玩家建立一个小范围的关联关系,当一个房间中的玩家发生下棋或者聊天动作时,服务器能够将其广播给房间中的其他玩家。
游戏房间类的具体功能如下:
同时,由于同一时间段内进行匹配或者正在对战的玩家有很多,所以游戏房间可能会有多个;那么我们就需要设计一个游戏房间管理类来对多个房间进行管理。
游戏房间管理类的具体功能如下:
最后,需要注意的是,在游戏房间管理模块中,由于我们需要根据不同的消息类型来调用不同的函数,进而得到不同的响应,所以我们需要提前规定好 WebSocket (游戏房间中 WebSocket 长连接已建立) 网络通信中不同类型的消息的格式是怎样的。这部分代码会在服务器模块的通信接口设计处给出,但为了便于理解,这里我们也放一份。
玩家下棋的消息:
// 玩家下棋消息
{
"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
}
玩家聊天的消息:
// 玩家聊天消息
{
"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": "你好"
}
未知类型的消息:
{
"optype": 消息的类型,
"result": false,
"reason": "未知类型的消息"
}
room.hpp:
#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
什么是 cookie&session:
此后,服务器与客户端就通过 cookie 和 session 相结合的方式完成用户身份与状态的验证:
基于上面的原理,在本项目中,我们也需要设计一个 session 类以及一个 session 管理类,用来完成客户端身份与状态的验证以及 session 对象的管理。需要注意的是,session 对象不能一直存在,即当用户长时间无操作后我们需要删除服务器中该用户对应的 session 对象,因此我们需要使用 WebSocketpp 的定时器功能对每个创建的 session 对象进行定时销毁,否则也算是一种资源泄露。
session 类的具体功能如下:
session 管理类的具体功能如下:
session.hpp:
#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
匹配对战管理模块主要负责游戏大厅内玩家开始匹配与取消匹配的功能,本模块将玩家按照天梯分数分为三个段位 (玩家的初始天梯分数为1000分):
本模块的设计思想是为不同段位的玩家分别设计一个匹配阻塞队列:
最后,和游戏房间管理模块一样,这里我们也给出 WebSocket 通信的消息格式。
游戏匹配成功的消息:
{
"optype": "match_success", //表⽰成匹配成功
"result": true
}
matcher.hpp:
#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
服务器模块是对当前所实现的所有模块进行整合,并进行服务器搭建的⼀个模块。目的是封装实现出⼀个 gobang_server 的服务器模块类,向外提供搭建五子棋对战服务器的接口。程序员通过实例化服务器模块类对象可以简便的完成服务器的搭建。
在实现具体的服务器类之前,我们需要对 HTTP 网络通信的通信接口格式进行设计,确保服务器能够根据客户端请求的格式判断出这是一个什么类型请求,并在完成业务处理后给客户端以特定格式的响应。
本项目采用 RESTful 风格通信接口:
本项目中客户端的 HTTP 请求分为静态资源请求与动态功能请求,静态资源请求指获取游戏注册页面、登录页面等,动态功能请求指用户登录/注册请求、协议切换请求等。
静态资源页面,在后台服务器上就是一个个 HTML/CSS/JS 文件;而静态资源请求,其实就是让服务器把对应的文件发送给客户端。
获取注册界面:
// 客户端请求
GET /register.html HTTP/1.1
报头其他字段
// 服务器响应
// 响应报头
HTTP/1.1 200 OK
Content-Length: XXX
Content-Type: text/html
报头其他字段
// 响应正文
register.html文件中的数据
获取登录界面、游戏大厅页面与游戏房间页面类似:
// 客户端请求
GET /login.html HTTP/1.1 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文件中的数据
用户注册请求:
// 客户端请求
// 请求报头
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": "错误信息,比如该用户名已被占用"}
用户登录请求:
// 客户端请求
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": "错误信息,比如用户名或密码错误"}
获取玩家详细信息请求:
// 客户端请求
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 长连接协议切换请求
// 客户端请求
/* ws://localhost:9000/match */
GET /hall HTTP/1.1
Connection: Upgrade
Upgrade: WebSocket
......
// 服务器成功的响应
HTTP/1.1 101 Switching
...
游戏房间 WebSocket 长连接协议切换请求
// 客户端请求
/* ws://localhost:9000/match */
GET /room HTTP/1.1
Connection: Upgrade
Upgrade: WebSocket
......
// 服务器成功的响应
HTTP/1.1 101 Switching
...
上面我们提到的不管是静态资源请求,还是动态功能请求,它们本质上都是 HTTP 请求,所以我们使用 RESTful 风格的通信接口;但是当玩家进入游戏大厅或者游戏房间后,客户端就会向服务器发送协议切换请求 (协议切换请求本身是 HTTP 请求),将 HTTP 短连接通信协议升级为 WebSocket 长连接通信协议。
由于 WebSocket 协议是一种全双工的持久连接协议,它允许在客户端和服务器之间进行双向实时通信,所以我们每次通信时直接使用 WebSocketpp::server 中的 send 接口向对方发送消息即可,而不再需要重新建立连接。
但是我们仍然需要事先规定好发送消息中不同字段代表的含义,这样才能正确区分收到的消息类型,从而根据消息不同的类型执行不同的处理函数并返回不同的消息。
游戏大厅 WebSocket 握手成功后的回复:
// 游戏大厅进入成功
{
"optype": "hall_ready",
"result": true
}
// 游戏大厅进入失败
{
"optype": "hall_ready",
"result": false,
"reason": "失败原因"
}
玩家开始匹配消息:
// 开始匹配消息
{
"optype": "match_start"
}
// 后台正确处理后回复的消息
{
"optype": "match_start"
"result": true,
}
玩家停止匹配消息:
// 停止匹配消息
{
"optype": "match_stop"
}
// 后台正确处理后回复的消息
{
"optype": "match_stop"
"result": true
}
游戏匹配成功后后台回复的消息:
{
"optype": "match_success",
"result": true
}
游戏房间 WebSocket 握手成功后的回复:
// 游戏房间创建成功
{
"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": "失败原因"
}
玩家下棋的消息:
// 玩家下棋消息
{
"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
}
玩家聊天的消息:
// 玩家聊天消息
{
"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": "你好"
}
未知类型的消息:
{
"optype": 消息的类型,
"result": false,
"reason": "未知类型的消息"
}
关于如何使用 WebSocketpp 来搭建一个服务器,我们在上面前置知识了解那里已经说过了,大体流程如下:
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 消息这四个回调函数。具体实现如下:
/*
服务器模块
通过对之前所有模块进行整合以及进行服务器搭建,最终封装实现出⼀个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
register.html:
<!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>
login.html:
<!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>
game_hall.html:
<!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>
game_room.html:
<!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
#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 信息冲突)
此时,一方的聊天信息以及走棋信息都能被另一方知道。在游戏结束途中,如果一方退出,另一方直接获胜;游戏结束后,用户可以点击 “返回大厅” 按钮回到游戏大厅。
回到游戏大厅后,大厅界面显示的玩家的比赛信息以及数据库中玩家的比赛信息都会被更新。
我们上面实现的网络五子棋其实只是一个最基础的版本,或者说是一个重度删减版,其实还可以对它进行许多的扩展,比如添加如下的一些功能:
本项目是一个业务型的项目,也是本人的第一个项目,在编程方面的难度其实并不是太大,主要是学习一个具体业务的整体工作逻辑是怎样的 (从请求到业务处理再到响应),以及前后端是如何配合进行工作的 (HTML/CSS/JS/AJAX)。
在项目编写过程中,相较于 C++、系统编程、网络编程这些已经学过的东西,其实前端以及 WebSocketpp 这方面的知识花费的时间精力会要更多一些,因为这些技术都是第一次接触,需要一边查阅文档一边使用,很多地方出了 bug 也需要花很多时间才能修复。
下面是项目中一些需要特别注意的地方,也可以说是我自己踩过的坑:
源码地址:
https://gitee.com/tian-hongjin/project-design/tree/master/gobang