基于Epoll与多线程实现Http服务器,前端部分: 中国象棋
项目展示
1. 前端界面
- 登录注册
- 游戏开始
- 项目结构
2. 项目说明
- 项目类型主要是后端的http服务器。前端部分的中国的象棋来自于网上的源码,然后加上登录注册功能,以及简单的cookie机制实现认证功能。
- 开发平台:CentOS Linux release 7.8.2003。编译器:gcc version 8.3.1 20190311 (Red Hat 8.3.1-3)。编辑器:vscode。
3. 涉及到的技术点
- Epoll多路复用
- 多线程,线程池,互斥锁,条件变量
- 程序替换,管道,环境变量
- C语言连接MySQL数据库
4. 克服的难点
- 支持长连接,主线程Epoll服务器专注于读取链接,获取Http请求。将每一个链接封装成一个Connection对象,每一个链接可以对应有多个Http请求。线程池负责解析Http请求,构建响应,发送静态网页文件或者创建子进程并程序替换为cgi程序。
- 链接超时管理,利用链表以及哈希表对链接进行管理,链表的排列顺序对应于链接的活跃程度,每一次主线程epoll_wait获取就绪事件完毕之后,会对链表进行检测,链表头部对应的就是最不活跃,冷却时间最长的链接。
- 简单模拟Cookie技术。用户注册,以及登录时,将访问时间戳经过哈希得到哈希值作为用户的uid,构建响应时通过Set-Cookie字段返回给客户端浏览器。后续用户登录成功后开始游戏,对局结束更新结果时,前端通过Ajax获取当前Cookie,并向服务器发送post请求。cgi程序通过用户名与Cookie中的uid进行认证。认证成功则访问更新数据库,如果认证失败则用户异常登录,账号存在风险。
5. 存在的问题
- 目前Cookie不支持在一个浏览器客户端上同时有多个账号访问服务,因为服务器只设置了一个Cookie。
代码细节具体逻辑展示
1. EpollServer部分
- 初步的设计思路
- EpollServer高效就在于无需等待,一旦有时间就绪,立即获知,并做出响应。所以在设计的时候可以让EpollServer专注于获取链接,而后台的多线程处理请求,构建响应。
- 这里我将EpollServer获取到的每一个Tcp链接都封装成一个Connection对象,在这个对象身上有读取缓冲区(对于EpollServer来说因为只关心读取,所以此处只需要有读取缓冲区即可)。
- 因为使用的是HTTP/1.1,开启了长连接,所以一个Tcp链接下对应可能会有多个Http请求,所以Connection对象身上的读取缓冲区会存在有多个Http请求。
- 此时面临的一个问题就是在读取缓冲区中分离出一个完整的Http请求,然后封装成任务,交给线程池。Http请求中采用换行符结尾,并且包含一个空行,因此可以使用 “\n\n” 作为分隔符。
- 但是还有一个问题,Http请求当中也有可能会有请求体,比如使用post方法,所以势必需要对Http请求进行初步的解析,判断请求方法以及Content-Length字段。
-
说了这么多来两张图帮你理解一下我的思路,首先来看一张主逻辑图
-
再来看一张代码层次图
-
CentralProcess 类和 HttpRequest 类以及 HttpResponse 类之间是has-a组合关系。CentralProcess类包含请求和响应对象的指针,同时该类身上也包含着解析Http请求,以及构建Http响应的方法。这三个类属于Http协议的具体业务层,属于上层。下图左边的EpollServer类和Connection类属于Tcp链接层,属于下层,上下两层之间不存在组合,继承关系,只存在数据交付。
-
EpollServer会将每一个链接封装成Connection对象,Connection类如下:
class EpollServer;
class Connection;
using func_t = std::function<void(Connection *)>;
class Connection
{
public:
Connection(int sock = -1) : _sock(sock), _eps(nullptr)
{
}
void setCallBack(func_t _recv, func_t _send, func_t _exep)
{
_recv_cb = _recv;
_send_cb = _send;
_exep_cb = _exep;
}
public:
// 负责进行IO的文件描述符
int _sock;
// 三个回调方法,表征的就是对_sock进行特定读写对应的方法
func_t _recv_cb;
func_t _send_cb;
func_t _exep_cb;
// 设置对EpollServer的回值指针
EpollServer *_eps;
// 链表迭代器, 该迭代器指向该链接自身,方便插入删除操作
std::list<Connection *>::iterator _myself;
// 接收缓冲区&&发送缓冲区
std::string inBuffer;
std::string outBuffer;
// 时间戳
uint64_t _lasttimestamp; // time();
};
- 每一个Http请求对应任务队列中的一个任务,Task类如下:
class Task
{
private:
CentralProcess* _cp;
public:
Task()
{}
Task(CentralProcess* cp):_cp(cp)
{}
void operator()()
{
std::cout << "线程开始处理http请求, pthread_id" << pthread_self() << std::endl;
_cp->buildHttpResponse();
_cp->sendHttpResponse();
delete _cp; //当前http请求处理完毕,释放动态申请的资源
std::cout << "线程处理http请求完毕, pthread_id" << pthread_self() << std::endl;
}
~Task()
{}
};
- EpollServer类的属性
private:
int _listen_sock;
uint16_t _port;
Epoll _epoll;
struct epoll_event *_revs;
int _revs_num;
std::unordered_map<int, Connection *> _conn;//哈希表,每一个链接对应一个sock
std::list<Connection *> _conn_time_list; //超时时间管理链表
ThreadPool<Task> *_tp;
- EpollServer构造函数
EpollServer(uint16_t port = default_port) : _port(port), _revs_num(gnum)
{
// 创建监听套接字
_listen_sock = Sock::Socket();
Sock::Bind(_listen_sock, _port);
Sock::Listen(_listen_sock);
// 创建epoll模型
_epoll.CreateEpoll();
LOG(DEBUG) << msg("create epoll sucess...");
// 忽略SIGPIPE信号,防止在写入时,读端异常关闭,server直接崩溃
signal(SIGPIPE, SIG_IGN);
// 获取线程池单例,初始化线程池
_tp = ThreadPool<Task>::getThreadPool();
if (_tp->initThreadPool())
LOG(INFO) << "init ThreadPool success..." << std::endl;
else
LOG(FATAL) << "init ThreadPool fail !!!" << std::endl;
// 创建epoll就绪事件用户空间
_revs = new struct epoll_event[_revs_num];
// 将监听套接字封装成Connection对象,同时添加到epoll模型中
AddConnection(_listen_sock,
std::bind(&EpollServer::Accepter, this, std::placeholders::_1),
nullptr, nullptr);
LOG(DEBUG) << msg("add listen_sock success...");
}
- 添加链接,将每一个sock封装成Connection对象
void AddConnection(int sock, func_t _recv, func_t _send, func_t _exep)
{
// 除了_listensock,未来我们会存在大量的socket,每一个sock都必须被封装成为一个Connection
// 当服务器中存在大量的Connection的时候,EpollServer就需要将所有的Connection要进行管理:
// 1.设置文件描述符为非阻塞模式
assert(Sock::setNonBlock(sock));
// 2.根据sock创建链接对象,注册响应事件回调方法
Connection *con = new Connection(sock);
con->setCallBack(_recv, _send, _exep);
con->_eps = this;
if (sock != _listen_sock) //将非listen套接字对应的Connection添加到活跃时间管理链表中
{
_conn_time_list.push_back(con);
con->_myself = --(_conn_time_list.end());
con->_lasttimestamp = time(nullptr);
}
// 3.将sock以及相应的事件添加到epoll模型, 默认关心读事件, ET模式边缘触发
_epoll.AddSockToEpoll(sock, EPOLLIN | EPOLLET);
// 4.文件描述符与Connection*的映射关系添加到哈希表中
_conn.insert(std::make_pair(sock, con));
}
- 每一个链接都有读取方法,下面是listen套接字的读取方法
void Accepter(Connection *conn)
{
while (true)
{
int err_num = 0;
std::string client_ip;
uint16_t client_port;
int sock = Sock::Accept(_listen_sock, &client_ip, &client_port, &err_num);
if (sock >= 0)
{
LOG(DEBUG) << msg("get a new link, sock:%d, client_ip:%s, client_port:%d", sock, client_ip.c_str(), client_port);
AddConnection(sock, std::bind(&EpollServer::Recver, this, std::placeholders::_1),
std::bind(&EpollServer::Sender, this, std::placeholders::_1),
std::bind(&EpollServer::Exceper, this, std::placeholders::_1));
}
else if (err_num == EAGAIN || err_num == EWOULDBLOCK)
{
LOG(DEBUG) << msg("没有链接了,待会再来吧");
break;
}
else if (err_num == EINTR)
{
LOG(DEBUG) << msg("被信号中断了,再来一次");
continue;
}
else
{
LOG(DEBUG) << msg("accept error");
break;
}
}
}
- service 套接字的读取方法
void Recver(Connection *conn)
{
// 链接上有事件就绪,说明当前链接是最新的活跃链接
//_conn_time_list 链表从头到尾的顺序,就代表着这个链接最近活跃的时间顺序
conn->_lasttimestamp = time(nullptr); // 更新最近活跃时间
_conn_time_list.erase(conn->_myself); // 根据自身的回指迭代器,快速在链表中找到自己,然后将自身从链表中删除
_conn_time_list.push_back(conn); // 重新将自身插入到链表尾部
conn->_myself = --(_conn_time_list.end()); // 更新自身内部的回指指针,end()返回最后一个元素的下一位置,因此end()--,指向自身
LOG(DEBUG) << msg("数据来了。。。。");
const int num = 512;
bool err = false;
while (true)
{
char buffer[num];
ssize_t n = recv(conn->_sock, buffer, sizeof(buffer) - 1, 0);
if (n < 0)
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
else if (errno == EINTR)
continue;
else
{
LOG(ERROR) << msg("recv error, %d:%s", errno, strerror(errno));
conn->_exep_cb(conn);
err = true;
break;
}
}
else if (n == 0)
{
LOG(DEBUG) << msg("client[%d] quit, server close [%d]", conn->_sock, conn->_sock);
conn->_exep_cb(conn);
err = true;
break;
}
else
{
// 读取成功
buffer[n] = 0;
conn->inBuffer += buffer;
}
} // end while
if (!err)
{
// LOG(INFO) << msg("inbuffer: %s", conn->inBuffer.c_str());
string message;
string message_sep;
int index = 0;
while (true)
{
int sep_no = SpliteHttpMessage(conn->inBuffer, &message, &index);//从inbuffer中提取出一个完整的http报文
if (sep_no == 3)
break;
// LOG(DEBUG) << "收到一个http请求:\n " << message << std::endl;
message_sep = sep1[sep_no];
CentralProcess* cp = new CentralProcess(conn->_sock);
//因为不确定该请求是否有请求体,因此需要进一步对http请求解析
//判断是否为POST方法,且含有Content-Length,是则需要继续从当前链接的inbuffer输入缓冲区上读取Content-Length个字节
if (recvHttpRequest(cp, message, message_sep) && parseHttpRequest(cp, &index))
{
Task t(cp);
_tp->pushTask(t); // 成功分离出一个完整的http请求,封装成任务,push到任务队列中,交给线程池解析请求,构建并发送http响应
}
else {
delete cp; // 到这里说明提取出的http报文是有问题的,直接丢弃
}
// LOG(DEBUG) << "分隔符是: " << sep[sep_no] <<w sep_no << std::endl;
}
//while循环结束后,说明这一次连接缓冲区inbuffer内数据处理完毕
//通过index的位置来判断是否刚好处理了整数个http报文,因为连接缓冲区中可能读上来了不完整的http报文,比如 1.5个http报文
if(index == conn->inBuffer.size()) //刚好处理玩,缓冲区清空
conn->inBuffer.clear();
else if(index < conn->inBuffer.size()) //缓冲区当中存在不完整的http报文,对inbuffer重新赋值
conn->inBuffer = conn->inBuffer.substr(index);
else
LOG(ERROR) << msg("从连接缓冲区中提取http报文出错....");
}
}
- listen套接字封装添加完毕,初始化工作完成后,EpollServer开始等待事件就绪,一旦检测到有链接上事件就绪,就会去执行之前注册好的相应链接上的读写方法。
// 根据就绪的事件,进行特定事件的派发
void Dispatcher()
{
while (true)
{
ConnectAliveCheck();
LoopOnce();
}
}
void LoopOnce()
{
int n = _epoll.WaitEpoll(_revs, _revs_num);
if (n > 0)
{
LOG(DEBUG) << msg("有%d个事件响应就绪了", n);
for (int i = 0; i < n; i++)
{
int sock = _revs[i].data.fd;
uint32_t revents = _revs[i].events;
// 将所有的异常,全部交给read或者write来统一处理
if (revents & EPOLLERR)
revents |= (EPOLLIN | EPOLLOUT);
if (revents & EPOLLHUP)
revents |= (EPOLLIN | EPOLLOUT);
// 读事件就绪
if (revents & EPOLLIN)
{
if (isExist(sock) && _conn[sock]->_recv_cb != nullptr)
_conn[sock]->_recv_cb(_conn[sock]);
}
// 写事件就绪
if (revents & EPOLLOUT)
{
if (isExist(sock) && _conn[sock]->_send_cb != nullptr)
_conn[sock]->_send_cb(_conn[sock]);
}
}
}
else if (n == 0)
{
LOG(DEBUG) << msg("timeout...");
}
else
{
LOG(ERROR) << msg("epoll_wait error %d:%s", errno, strerror(errno));
}
}
- 这里对超时时间管理链表解释一下
//对活跃时间链表进行检测,将所有冷却时间大于20s的连接关闭
void ConnectAliveCheck()
{
// 链表的的插入删除操作是按照连接的最近活跃时间来操作的,最活跃的连接总是插入链表尾部
// 也就是说,当前链表从头到尾的顺序就是冷却时间从大到小的顺序,链表头部就是冷却时间最长,最不活跃的连接
// 所以,只需要从链表头部开始判断其最近活跃时间,跟当前的时间戳比较
// first - cur > 10 , 说明超时了,调用自身注册的异常函数,关闭连接,回收资源,然后循环继续判断下一个连接
// first - cur < 10 , 说明没超时,最不活跃的都没超时,后面的都不会超时,循环退出,检测完毕。
uint64_t cur = time(nullptr);
auto conn = _conn_time_list.begin();
while (!_conn_time_list.empty() && (cur - (*conn)->_lasttimestamp) >= 20)
{
if ((*conn)->_exep_cb != nullptr)
{
LOG(INFO) << msg("client[%d] timeout... , close sock: %d", (*conn)->_sock, (*conn)->_sock);
auto cur = conn;
cur++;
(*conn)->_exep_cb((*conn));
conn = cur;
continue;
}
else
{
logMessage(ERROR, "Connextion 链表不为空,但是注册的异常回调函数为空,未知错误.....");
break;
}
}
}
2. Http协议主逻辑部分
- HttpRequest类
// 每一个http请求对应一个HttpRequest类的实体对象
class HttpRequest
{
public:
string request_line; // 请求行 GET / HTTP/1.1
vector<string> request_head; // 请求头
string request_body; // 请求体
// http请求解析后的结果 方法, 协议版本, 资源路径, 参数, 后缀等
string method;
string version;
string uri; // uri = path?args, 这里注意uri中可能包含有参数
string path;
string args;
string suffix; // 请求的资源后缀,代表着所请求资源的类型
bool cgi;
int file_size;
// 将http请求头拆分为键值对
unordered_map<string, string> head_map;
int content_length;
public:
HttpRequest() : cgi(false), content_length(0) {}
~HttpRequest(){};
};
- HttpResponse类
// 每一个http响应对应一个HttpResponse类的实体对象
class HttpResponse
{
public:
string response_line; // 响应行
vector<string> response_head; // 响应头
string blank; // 空行
string response_body; // 响应体
int status_code;
int fd;
string uid; // 用户ID
HttpResponse() : blank(BLANK_LINE), status_code(OK), fd(-1) {}
~HttpResponse() {}
};
- CentralProcess 类的属性,这个类上有解析Http请求和构建发送响应的方法
// 中央处理模块,http协议的主要业务逻辑
// 根据EpollServer获取上来的http请求,构建并发送响应。返回静态网页文件,或者执行CGI程序,由具体线程负责处理
class CentralProcess
{
public:
int _sock;
HttpRequest *_request;
HttpResponse *_response;
- 解析Http请求
void buildHttpResponse()
{
string path;
auto &status_code = _response->status_code;
size_t suffix_pos = 0;
if (_request->method != "GET" && _request->method != "POST")
{
LOG(WARNING) << "method is invalid..." << std::endl;
status_code = NOT_FOUND;
goto END;
}
if (_request->method == "GET")
{
size_t pos = _request->uri.find("?");
if (pos != std::string::npos)
{
Util::cutString(_request->uri, _request->path, _request->args, "?");
_request->cgi = true;
}
else
{
_request->path = _request->uri;
}
}
else
{ //_request->method == "POST"
_request->cgi = true;
_request->path = _request->uri;
}
path = _request->path;
_request->path = WEB_HOME;
_request->path += path;
if (_request->path[_request->path.size() - 1] == '/')
{
_request->path += HOME_PAGE;
}
struct stat st;
if (stat(_request->path.c_str(), &st) == 0) // 访问的资源存在
{
status_code = OK;
if (S_ISDIR(st.st_mode)) // 访问的是一个目录
{
_request->path += "/";
_request->path += HOME_PAGE;
if (stat(_request->path.c_str(), &st) != 0)
{
LOG(WARNING) << _request->path + " NOT FOUND" << std::endl;
status_code = NOT_FOUND;
goto END;
}
}
else if (_request->cgi == true) // 说明前面成功设置了cgi, 此时为post方法,或者get方法带参数,只需判断是否为可执行文件
{
if ((st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH))
; // 访问的是一个可执行文件
else
{
LOG(WARNING) << _request->path + " NOT FOUND" << std::endl;
status_code = NOT_FOUND;
goto END;
}
}
else if ((st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH))
{ // 走到这儿说明get方法无参访问cgi程序, 属于非法访问
status_code = BAD_REQUEST;
goto END;
}
_request->file_size = st.st_size;
}
else // 访问的资源不存在
{
LOG(WARNING) << _request->path + " Not Found" << std::endl;
status_code = NOT_FOUND;
goto END;
}
suffix_pos = _request->path.rfind(".");
if (suffix_pos != string::npos)
{
_request->suffix = _request->path.substr(suffix_pos);
}
else
{
_request->suffix = "text/html";
}
if (_request->cgi)
{
status_code = processWithCGI(); // 执行cgi程序
}
else
{
status_code = processNoneCGI(); // 返回一个静态网页
}
END:
buildHttpResponseHelper();
}
- 线程创建子进程并程序替换,执行cgi程序
int processWithCGI()
{
LOG(INFO) << "Process With CGI" << std::endl;
int code = OK;
auto &bin = _request->path;
auto &method = _request->method;
auto &body = _request->request_body;
auto &args = _request->args;
int input[2];
int output[2];
if (pipe(input) < 0)
{
// 创建管道失败,服务器发生内部错误
LOG(ERROR) << "create pipe fail!" << std::endl;
code = SERVER_ERROR;
return code;
}
if (pipe(output) < 0)
{
LOG(ERROR) << "create pipe fail!" << std::endl;
code = SERVER_ERROR;
return code;
}
//这里要在创建子进程之前更新用户ID信息,因为父子进程会有写时拷贝
if (_request->path == "www/wwwroot/login_cgi" || _request->path == "www/wwwroot/register_cgi")
{ // 用户执行的是登录或者注册操作, 获取当前时间戳,获取哈希值作为用户ID;
// 这里的用户ID会作为访问 updateRequest_cgi 更新用户数据时的认证机制
size_t time_stamp = time(nullptr);
std::hash<string> szHash;
size_t uid = szHash(std::to_string(time_stamp)) - 1;
_response->uid = std::to_string(uid);
}
pid_t id = fork();
if (id < 0)
{
LOG(ERROR) << "fork error" << std::endl;
code = SERVER_ERROR;
return code;
}
else if (id == 0) // child process
{
close(input[0]);
close(output[1]);
// string method_env = "METHOD="; //进程程序替换并不会替换环境变量,但是注意这里存放环境变量用static, 存放在子进程地址空间的全局数据区
// method_env += method; //如果是局部变量,则存放在栈区,但是栈是动态改变的,putenv()函数,是将环境变量字符串的地址放到环境变量表当中
// putenv((char *)method_env.c_str()); //此时,栈区的动态增长有可能导致,导入的环境变量丢失,从而在进程替换后,程序无法获取环境变量
// 使用setenv, 可以避免putenv()的浅拷贝问题
setenv("METHOD", method.c_str(), 1);
if (method == "GET")
{
// static string query_env = "QUERY_STRING=";
// query_env += args;
setenv("QUERY_STRING", args.c_str(), 1);
}
else if (method == "POST")
{
setenv("USER_ID", _response->uid.c_str(), 1); //将用户ID导入环境变量
// setenv("USER_ID", "15391271231361086382", 1); //模拟动态生成的用户ID 与 数据库中原有的用户ID相同的情况
// static string content_length_env = "CONTENT_LENGTH=";
// content_length_env += std::to_string(_request->content_length);
// putenv((char *)content_length_env.c_str());
setenv("CONTENT_LENGTH", std::to_string(_request->content_length).c_str(), 1);
}
dup2(output[0], 0);
dup2(input[1], 1);
execl(bin.c_str(), bin.c_str(), nullptr);
exit(1);
}
else
{ // parent process
close(input[1]);
close(output[0]);
if ("POST" == method)
{
const char *start = body.c_str();
LOG(DEBUG) << "body " << body << std::endl;
ssize_t has_write = 0; // 已经写入的字节数
ssize_t size = 0; // 每次成功写入的字节数
while ((size = write(output[1], start + has_write, body.size() - has_write)) > 0)
{
has_write += size;
}
char ch = '\n';
write(output[1], &ch, 1);
}
char ch = 'a';
while (read(input[0], &ch, 1) > 0)
{
_response->response_body.push_back(ch);
}
int status = 0;
int ret = waitpid(id, &status, 0);
if (ret == id)
{
if (WIFEXITED(status))
{
if (WEXITSTATUS(status) == 0)
{
code = OK;
}
else
{
code = BAD_REQUEST;
}
}
else
{
code = SERVER_ERROR;
}
}
close(input[0]);
close(output[1]);
}
return code;
}
- 构建响应行和响应头
void buildOKResponse()
{
auto &resp_head = _response->response_head;
string head = "Connection: keep-alive"; // 告诉浏览器客户端,支持长连接
head += BLANK_LINE;
resp_head.push_back(head);
head = "Content-Type: ";
if (_request->cgi)
{
if (_request->path == "www/wwwroot/updateResult_cgi")
head += suffix2Desc(".json");
else
head += "text/html";
head += BLANK_LINE;
}
else
{
head += suffix2Desc(_request->suffix);
head += BLANK_LINE;
}
resp_head.push_back(head);
head = "Content-Length: ";
if (_request->cgi)
head += std::to_string(_response->response_body.size());
else
head += std::to_string(_request->file_size);
head += BLANK_LINE;
resp_head.push_back(head);
if (_request->cgi)
{
if (_request->path == "www/wwwroot/login_cgi" || _request->path == "www/wwwroot/register_cgi")
{
head = "Set-Cookie: ";
head += " uid=";
head += _response->uid;
head += BLANK_LINE;
LOG(DEBUG) << "uid: " << _response->uid << std::endl;
resp_head.push_back(head);
LOG(DEBUG) << "cookie: " << head << std::endl;
}
}
}
- 发送响应,注意发送响应时要设置为阻塞模式发送,因为是让线程发送响应。在非阻塞模式下发送,如果文件比较大,一次把发送缓冲区打满之后,会返回-1,并且设置错误码为EAGAIN或者EWOULDBLOCK,此时需要重新发送,逻辑更复杂。因为在设计之初只考虑让EpollServer按照ET模式负责读取,因此多线程采用阻塞模式发送响应。
void sendHttpResponse()
{
LOG(DEBUG) << _response->response_line << std::endl;
Sock::setBlock(_sock); // 阻塞式发送
send(_sock, _response->response_line.c_str(), _response->response_line.size(), 0); // 发送响应行
for (auto it : _response->response_head) // 发送响应头
{
send(_sock, it.c_str(), it.size(), 0);
LOG(DEBUG) << it << std::endl;
}
send(_sock, _response->blank.c_str(), _response->blank.size(), 0); // 发送空行
LOG(DEBUG) << _response->blank << std::endl;
if (_request->cgi)
{
auto &resp_body = _response->response_body;
size_t size = 0;
size_t has_send = 0;
const char *start = _response->response_body.c_str();
while (has_send < resp_body.size() && (size = send(_sock, start + has_send, resp_body.size() - has_send, 0)) > 0)
{
has_send += size;
}
LOG(DEBUG) << _response->response_body << std::endl;
}
else
{
std::cout << "response_file_size: " << _request->file_size << std::endl;
ssize_t sz = sendfile(_sock, _response->fd, nullptr, _request->file_size);
if (sz < 0)
{
LOG(ERROR) << msg("errno: %d, strerr: %s", errno, strerror(errno));
}
else
{
LOG(DEBUG) << "sz: " << sz << std::endl;
}
}
Sock::setNonBlock(_sock);
close(_response->fd);
}
3. 用户登录注册,更新数据
- cgi程序主要有三个,login_cgi,register_cgi,updateResult_cgi。分别对应着用户登录,注册,以及用户对局结束更新结果。这三个cgi程序都需要通过post方法请求,子进程在程序替换之后,通过环境变量以及管道的方式获取用户数据。
- 首先是comm.hpp,这个类中包含了连接,访问,查询,更新数据库的方法,以及渲染网页的方法。其中渲染网页用到了ctemplate库。
#include <string>
#include <iostream>
#include <jsoncpp/json/json.h>
#include <mysql_connector/mysql.h>
#include <ctemplate/template.h>
#include <unistd.h>
#include <cstdlib>
#include "../../Log.hpp"
using std::string;
struct User
{
long long hash;
string name;
string password;
int win;
int lose;
string uid;
};
bool GetQueryString(string &query_string, string *uid)
{
string method = getenv("METHOD");
if (method != "POST")
{
std::cerr << "请求方法错误..." << std::endl;
return false;
}
if (uid != nullptr)
*uid = getenv("USER_ID");
int content_length = atoi(getenv("CONTENT_LENGTH"));
char c = 0;
while (content_length)
{
read(0, &c, 1);
query_string.push_back(c);
content_length--;
}
return true;
}
bool executeSql(const string &sql)
{
MYSQL *my = mysql_init(nullptr);
if (nullptr == mysql_real_connect(my, "127.0.0.1", "XX", "XXXXXXX", "chess", 3306, nullptr, 0))
{
std::cerr << "数据库连接失败" << std::endl;
return false;
}
// 一定要设置该链接的编码格式, 要不然会出现乱码问题
mysql_set_character_set(my, "utf8");
std::cerr << "数据库连接成功..." << std::endl;
// 执行sql语句
if (0 != mysql_query(my, sql.c_str()))
{
mysql_close(my);
return false;
}
else
{
std::cerr << "sql 执行成功" << std::endl;
}
mysql_close(my);
return true;
}
bool querySql(const string &sql, struct User &user, string *uid)
{
MYSQL *my = mysql_init(nullptr);
if (nullptr == mysql_real_connect(my, "127.0.0.1", "XXX", "XXXXX", "chess", 3306, nullptr, 0))
{
std::cerr << "数据库连接失败" << std::endl;
return false;
}
// 一定要设置该链接的编码格式, 要不然会出现乱码问题
mysql_set_character_set(my, "utf8");
std::cerr << "数据库连接成功..." << std::endl;
// 执行sql语句
if (0 != mysql_query(my, sql.c_str()))
{
mysql_close(my);
return false;
}
else
std::cerr << "sql 执行成功" << std::endl;
// 提取结果
MYSQL_RES *res = mysql_store_result(my);
// 分析结果
int rows = mysql_num_rows(res); // 获得行数量
int cols = mysql_num_fields(res); // 获得列数量
std::cerr << "rows: " << rows << " cols: " << cols << std::endl;
if (rows != 1 || cols != 6)
return false;
MYSQL_ROW row = mysql_fetch_row(res);
user.hash = atoll(row[0]); //这里有问题。。。。暂时没法解决。。。。。
user.name = row[1];
user.password = row[2];
user.win = atoi(row[3]);
user.lose = atoi(row[4]);
user.uid = row[5];
// 走到这儿,说明用户登录成功,需要更新用户ID
if (uid != nullptr)
{
if(user.uid == *uid) //判断成立则说明,数据库当中存储的uid和需要更新的uid相同
return false; //这种情况概率很低,但是如果发生,直接false返回,提示服务器内部错误,需要重新登录
string updateUID = "update user set uid = \'";
updateUID += *uid;
updateUID += "\'";
updateUID += " where name = \'";
updateUID += user.name;
updateUID += "\'";
std::cerr << updateUID << std::endl;
//std::cerr << "atoll 转换或的 user.hash: " << user.hash << std::endl; //转换失败
//std::cerr << "没有经过转换,直接从sql结果集读取hash: " << row[0] << std::endl;//读取正确
if (0 != mysql_query(my, updateUID.c_str()))
{
std::cerr << "更新用户ID失败。。。。。。" << std::endl;
mysql_close(my);
return false;
}
else
{
std::cerr << "更新用户ID成功" << std::endl;
}
}
// 释放结果空间
mysql_free_result(res);
// 关闭mysql连接
mysql_close(my);
return true;
}
const static string template_path = "./www/wwwroot/play.html";
// 渲染网页
void ExpandHtml(struct User &user, string *html)
{
// 形成数字典
ctemplate::TemplateDictionary root("play");
root.SetValue("userName", user.name);
root.SetValue("win", std::to_string(user.win));
root.SetValue("lose", std::to_string(user.lose));
// 获取被渲染的html
ctemplate::Template *tpl = ctemplate::Template::GetTemplate(template_path, ctemplate::DO_NOT_STRIP);
// 开始完成渲染功能
tpl->Expand(html, &root);
}
void CutString(std::string &in, const std::string &sep, std::string &out1, std::string &out2)
{
auto pos = in.find(sep);
if (std::string::npos != pos)
{
out1 = in.substr(0, pos);
out2 = in.substr(pos + sep.size());
}
}
void sendError()
{
std::cout << "<html>";
std::cout << "<head><meta charset=\"utf-8\"></head>";
std::cout << "<body><h1>用户名或密码格式错误<br>目前只支持字母和数字组合, 名字密码长度小于等于20, 大于等于5</h1></body></html>";
}
bool check(const string &str)
{
if (str.size() == 0 || str.size() > 20 || str.size() < 5)
{
sendError();
return false;
}
// 判断name password中是否含有非法字符, name以及password由英文字母与数字构成
for (auto &ch : str)
{
if (((ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'Z')) || (ch >= 'a' && ch <= 'z'))
continue;
else
{
std::cerr << "ch: " << ch << std::endl;
std::cerr << "str: " << str << std::endl;
std::cerr << "用户名或密码中含有非法字符" << std::endl;
sendError();
return false;
}
}
return true;
}
- login.cc
#include "comm.hpp"
int main()
{
string query_string;//存放用户的数据,包括用户名以及密码
string uid; //用户ID
// from http
if(!GetQueryString(query_string, &uid))//从管道以及环境变量中获取数据
return 1;
std::cerr << "cgi get the uid is " << uid << std::endl;
std::string name;
std::string passwd;
CutString(query_string, "&", name, passwd);
std::string _name;
std::string sql_name;
CutString(name, "=", _name, sql_name);
std::string _passwd;
std::string sql_passwd;
CutString(passwd, "=", _passwd, sql_passwd);
//检查用户名密码的合法性再查询数据库
if (!check(sql_name) || !check(sql_passwd))
return 0;
struct User user;
std::hash<std::string> szHash;
size_t hashVal = szHash(sql_name) - 1; //对用户名使用哈希算法,生成哈希值,作为主键
string sql = "select * from user where hash = ";
sql += std::to_string(hashVal);
std::cerr << sql << std::endl;
if (querySql(sql, user, &uid))
{
//核对用户密码是否正确
if(sql_passwd == user.password){ //密码正确,登录成功
string html;
ExpandHtml(user, &html); // 获取渲染或的网页
std::cout << html << std::endl;
}else{
std::cout << "<html>";
std::cout << "<head><meta charset=\"utf-8\"></head>";
std::cout << "<body><h1>密码错误!</h1></body></html>";
}
}
else
{
std::cerr << "用户登录失败,用户uid更新失败 " << sql << std::endl;
std::cout << "<html>";
std::cout << "<head><meta charset=\"utf-8\"></head>";
if(user.uid != "")
std::cout << "<body><h1>服务器内部错误,请重新登录</h1></body></html>"; //用户uid读取成功,说明用户查询成功,但是uid更新失败,属于服务器内部错误
else
std::cout << "<body><h1>用户不存在!</h1></body></html>"; //用户uid读取失败,说明用户查询失败,用户不存在,登录失败
}
return 0;
}
- register.cc,
#include "comm.hpp"
int main()
{
string query_string;
string uid;
if(!GetQueryString(query_string, &uid))
return 1;
std::string name;
std::string passwd;
CutString(query_string, "&", name, passwd);
std::string _name;
std::string sql_name;
CutString(name, "=", _name, sql_name);
std::string _passwd;
std::string sql_passwd;
CutString(passwd, "=", _passwd, sql_passwd);
std::cerr << "name: " << sql_name << " password: " << sql_passwd << std::endl;
if (!check(sql_name) || !check(sql_passwd))
return 0;
struct User user;
std::hash<std::string> szHash;
size_t hashVal = szHash(sql_name) - 1; //根据用户名生成哈希值,作为主键
string sql = "insert into user values(";
sql += std::to_string(hashVal);
sql += ", \'";
sql += sql_name;
sql += "\', \'";
sql += sql_passwd;
sql += "\', 0, 0, \'";
sql += uid;
sql += "\')";
if (executeSql(sql))
{
user.name = sql_name;
user.win = 0;
user.lose = 0;
string html;
ExpandHtml(user, &html); // 获取渲染或的网页
std::cout << html << std::endl;
return 0;
}
else
{
std::cerr << "sql语句执行失败: " << sql << std::endl;
std::cout << "<html>";
std::cout << "<head><meta charset=\"utf-8\"></head>";
std::cout << "<body><h1>注册失败! 用户名已存在</h1></body></html>";
}
return 0;
}
- updateResult.cc
#include "comm.hpp"
int main()
{
string query_string;
if(!GetQueryString(query_string, nullptr))
return 1;
Json::Value root;
Json::Reader reader;
reader.parse(query_string, root);
int status = root["status"].asInt(); // 获取用户对局状态,1 表示赢, 0 表示输
string name = root["userName"].asString();
string uid = root["uid"].asString();
// 连接数据库,查询并且更新用户数据
std::hash<string> szHash;
size_t hashVal = szHash(name) - 1;
string sql = "select * from user where hash = ";
sql += std::to_string(hashVal);
struct User user;
Json::Value result;
Json::StyledWriter writer;
string str;
if (!querySql(sql, user, nullptr)) //查询用户数据
{
goto END;
}
if(user.uid != uid) //根据用户ID, 判断用户登录是否过期, 是否存在风险
{ //如果前端传过来的uid与数据库的uid不一致, 说明用户账号可能在不同的地方被其他人登录, 存在风险
std::cerr << "uid 不相等, 用户账号存在风险, uid: " << uid << " user.uid: " << user.uid << std::endl;
goto END;
}
if (status == 1)
{
sql = "update user set win = ";
sql += std::to_string(user.win + 1);
sql += " where hash = ";
sql += std::to_string(hashVal);
}else
{
sql = "update user set lose = ";
sql += std::to_string(user.lose + 1);
sql += " where hash = ";
sql += std::to_string(hashVal);
}
if (!executeSql(sql)){ //更新用户数据
goto END;
}
result["code"] = status == 1 ? user.win + 1 : user.lose + 1;
str = writer.write(result);
std::cout << str << std::endl;
std::cerr << str << std::endl;
return 0;
END:
std::cerr << "sql 语句执行失败, sql: " << sql << std::endl;
result["code"] = -1;
str = writer.write(result);
std::cout << str << std::endl;
std::cerr << str << std::endl;
return 0;
}
说明
- 项目当中有很多细节,很多坑,没有全部展示出来,后续会继续更新。