0. 项目介绍
- 本项目实现的是一个HTTP服务器,项目中将会通过基本的网络套接字读取客户端发来的HTTP请求并进行分析,最终构建HTTP响应并返回给客户端
- 完成了HTTP服务器后端的处理逻辑,主要完成的是GET和POST请求方法,以及CGI机制的搭建
-
该项目将会把HTTP中最核心的模块抽取出来,采用CS模型实现一个小型的HTTP服务器,目的在于理解HTTP协议的处理过程。
-
该项目主要涉及C/C++、HTTP协议、网络套接字编程、CGI、单例模式、多线程、线程池等方面的技术。
- 而本项目要做的就是,在接收到客户端发来的HTTP请求后,将HTTP的报头信息提取出来,然后对数据进行分析处理,最终将处理结果添加上HTTP报头再发送给客户端
- 我们发送的HTTP响应实际也只是交给了传输层,数据真正的发送还得靠网络协议栈中的下三层来完成
1.CGI机制
CGI(Common Gateway Interface,通用网关接口)是一种重要的互联网技术,可以让一个客户端,从网页浏览器向执行在网络服务器上的程序请求数据。它描述了服务器和请求处理程序之间传输数据的一种标准
-
实际对数据的处理与HTTP的关系并不大,而是取决于上层具体的业务场景的,因此HTTP不对这些数据做处理。但HTTP提供了CGI机制,上层可以在服务器中部署若干个CGI程序,
-
这些CGI程序可以用任何程序设计语言编写,当HTTP获取到数据后会将其提交给对应CGI程序进行处理,然后再用CGI程序的处理结果构建HTTP响应返回给浏览器
何时需要使用CGI模式
- 用户请求服务器时上传了数据
- 用户请求的是服务器上的一个可执行程序
- 单纯的想请求服务器上的某个资源文件则不需要使用CGI模式
1.1 设计CGI机制
1. 创建子进程进行程序替换
- 服务器获取到新连接后一般会创建一个新线程为其提供服务,而要执行CGI程序一定需要调用exec系列函数进行进程程序替换
-
但服务器创建的新线程与服务器进程使用的是同一个进程地址空间,如果直接让新线程调用exec系列函数进行进程程序替换,此时服务器进程的代码和数据就会直接被替换掉,但进程的进程控制块、页表、打开的文件等内核数据结构是不做任何替换的
-
所以新线程需要先调用fork函数创建子进程,然后让子进程调用exec系列函数进行进程程序替换。
2. 完成管道通信信道的建立
- 调用CGI程序的目的是为了让其进行数据处理
- 由于父进程不仅需要将数据交给子进程,还需要从子进程那里获取数据处理的结果,服务器进程和CGI进程是父子进程,所以优先选择使用匿名管道
- 而管道是半双工通信的,为了实现双向通信于是需要借助两个匿名管道,所以我在创建调用fork子进程之前需要先创建两个匿名管道,在创建子进程后还需要父子进程分别关闭两个管道对应的读写端。
3.重定向设置
- 进程程序替换只替换对应进程的代码和数据,而对于进程的进程控制块、页表、打开的文件等内核数据结构是不做任何替换的。
- 因此子进程进行进程程序替换后,虽然底层创建的两个匿名管道仍然存在,但是被替换后的CGI程序不知道这两个管道对应的文件描述符罢了。
- 所以我规定:被替换后的CGI程序,从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据
- 具体操作为:0号文件描述符重定向到对应管道的读端,将1号文件描述符重定向到对应管道的写端
4. 父子进程如何交付数据
- 当请求方法为GET方法,说明用户是通过URL传递参数的,此时就需要在子进程进行进程程序替换之前,通过putenv函数将参数导入环境变量 + 请求方法,父进程直接把数据写入写入环境变量,则CGI程序就直接从环境变量中获取父进程传递过来的数据
- 如果请求方法为POST方法,那么用户是通过请求正文传参的,此时就需要在子进程进行进程程序替换之前,通过putenv函数将请求正文的长度导入环境变量(为了知道参数个数) + 请求方法,父进程直接把数据写入写入管道,则CGI程序就直接从管道中获取父进程传递过来的数据
CGI程序读取到父进程传递过来的数据后,再进行对应的数据处理了,最后将数据处理结果写入到管道中,此时父进程就可以从管道中读取CGI程序的处理结果了。
由于环境变量也不受进程程序替换的影响,因此被替换后的CGI程序就可以通过getenv函数来获取对应的参数,或数据
至于为什么通过导入环境变量的方式来传递,是因为:请求正文长度、URL传递的参数以及请求方法都比较短,通过写入管道来传递会导致效率降低,
1.2 设计CGI机制的意义
- 服务器逻辑和业务逻辑进行了解耦,让服务器和业务程序可以各司其职
- 也可以让CGI程序的开发者,可以完全忽略中间服务器的处理逻辑,
- CGI程序从标准输入就能读取到浏览器输入的内容,CGI程序写入标准输出的数据最终就能输出到浏览器
2.Log.hpp
#pragma once
#include <iostream>
#include <string>
#include <ctime>
#define INFO 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)
void Log(std::string level, std::string message, std::string file_name, int line)
{
std::cout<<"["<<level<<"]["<<time(nullptr)<<"]["<<message<<"]["<<file_name<<"]["<<line<<"]"<<std::endl;
}
3. TcpServer.hpp
只要使用TCP编程,必然会用到的头文件
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#pragma once
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "Log.hpp"
#define BACKLOG 5
class TcpServer
{
public:
static TcpServer *GetInstance(int port)
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
if (_svr == nullptr)
{
pthread_mutex_lock(&lock);
if (_svr == nullptr)
{
_svr = new TcpServer(port);
_svr->InitServer();
}
pthread_mutex_unlock(&lock);
}
return _svr;
}
void InitServer()
{
Socket();
Bind();
Listen();
LOG(INFO, "tcp_server init ... success");
}
void Socket()
{
_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_listen_sock < 0)
{
LOG(FATAL, "socket error!");
exit(1);
}
// 设置端口复用
int opt = 1;
setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
LOG(INFO, "create socket ... success");
}
void Bind()
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY; // 云服务器不能直接绑定公网IP
if (bind(_listen_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
LOG(FATAL, "bind error!");
exit(2);
}
LOG(INFO, "bind socket ... success");
}
void Listen()
{
if (listen(_listen_sock, BACKLOG) < 0)
{
LOG(FATAL, "listen error!");
exit(3);
}
LOG(INFO, "listen socket ... success");
}
int Sock()
{
return _listen_sock;
}
~TcpServer()
{
if (_listen_sock >= 0)
{
close(_listen_sock);
}
}
private:
TcpServer(int port)
: _port(port), _listen_sock(-1)
{
}
TcpServer(const TcpServer &)
{
}
private:
int _port; // 端口号
int _listen_sock; // 监听套接字
static TcpServer *_svr;
};
TcpServer *TcpServer::_svr = nullptr;
-
如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显式绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序列的转换。
-
在第一次调用GetInstance获取单例对象时需要创建单例对象,这时需要定义一个锁来保证线程安全,代码中以PTHREAD_MUTEX_INITIALIZER的方式定义的静态的锁是不需要释放的,同时为了保证后续调用GetInstance获取单例对象时不会频繁的加锁解锁,因此代码中以双检查的方式进行加锁。
4. HTTP服务器主体设计
4.1 主要逻辑
#define PORT 8081
//HTTP服务器
class HttpServer{
private:
int _port; //端口号
public:
HttpServer(int port)
:_port(port)
{}
//启动服务器
void Loop()
{
LOG(INFO, "loop begin");
TcpServer* tsvr = TcpServer::GetInstance(_port); //获取TCP服务器单例对象
int listen_sock = tsvr->Sock(); //获取监听套接字
while(true){
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //获取新连接
if(sock < 0){
continue; //获取失败,继续获取
}
//打印客户端相关信息
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
LOG(INFO, "get a new link: ["+client_ip+":"+std::to_string(client_port)+"]");
//创建新线程处理新连接发起的HTTP请求
int* p = new int(sock);
pthread_t tid;
pthread_create(&tid, nullptr, CallBack::HandlerRequest, (void*)p);
pthread_detach(tid); //线程分离
}
}
~HttpServer()
{}
};
- 服务器需要将新连接对应的套接字作为参数传递给新线程,为了避免该套接字在新线程读取之前被下一次获取到的套接字覆盖,因此在传递套接字时最好重新new一块空间来存储套接字的值。
- 新线程创建后可以将新线程分离,分离后主线程继续获取新连接,而新线程则处理新连接发来的HTTP请求,代码中的HandlerRequest函数就是新线程处理新连接时需要执行的回调函数。
主函数逻辑
static void Usage(std::string proc)
{
std::cout<<"Usage:\n\t"<<proc<<" port"<<std::endl;
}
int main(int argc, char* argv[])
{
if(argc != 2){
Usage(argv[0]);
exit(4);
}
int port = atoi(argv[1]); //端口号
std::shared_ptr<HttpServer> svr(new HttpServer(port)); //创建HTTP服务器对象
svr->Loop(); //启动服务器
return 0;
}
-
参数:可执行程序 端口号
4.2 请求服务逻辑
将HTTP请求封装成一个类,这个类当中包括HTTP请求的内容、HTTP请求的解析结果以及是否需要使用CGI模式的标志位。后续处理请求时就可以定义一个HTTP请求类,读取到的HTTP请求的数据就存储在这个类当中,解析HTTP请求后得到的数据也存储在这个类当中
//HTTP请求
class HttpRequest{
public:
//HTTP请求内容
std::string _request_line; //请求行
std::vector<std::string> _request_header; //请求报头
std::string _blank; //空行
std::string _request_body; //请求正文
//解析结果
std::string _method; //请求方法
std::string _uri; //URI
std::string _version; //版本号
std::unordered_map<std::string, std::string> _header_kv; //请求报头中的键值对
int _content_length; //正文长度
std::string _path; //请求资源的路径
std::string _query_string; //uri中携带的参数
//CGI相关
bool _cgi; //是否需要使用CGI模式
public:
HttpRequest()
:_content_length(0) //默认请求正文长度为0
,_cgi(false) //默认不使用CGI模式
{}
~HttpRequest()
{}
};
4.3 响应服务逻辑
HTTP响应也可以封装成一个类,这个类当中包括HTTP响应的内容以及构建HTTP响应所需要的数据。后续构建响应时就可以定义一个HTTP响应类,构建响应需要使用的数据就存储在这个类当中,构建后得到的响应内容也存储在这个类当中
//HTTP响应
class HttpResponse{
public:
//HTTP响应内容
std::string _status_line; //状态行
std::vector<std::string> _response_header; //响应报头
std::string _blank; //空行
std::string _response_body; //响应正文(CGI相关)
//所需数据
int _status_code; //状态码
int _fd; //响应文件的fd (非CGI相关)
int _size; //响应文件的大小(非CGI相关)
std::string _suffix; //响应文件的后缀(非CGI相关)
public:
HttpResponse()
:_blank(LINE_END) //设置空行
,_status_code(OK) //状态码默认为200
,_fd(-1) //响应文件的fd初始化为-1
,_size(0) //响应文件的大小默认为0
{}
~HttpResponse()
{}
};
5. EndPoint类编写
5.1 EndPoint结构设计
EndPoint这个词经常用来描述进程间通信,比如在客户端和服务器通信时,客户端是一个EndPoint,服务器则是另一个EndPoint,因此这里将处理请求的类取名为EndPoint
EndPoint类中包含三个成员变量:
- sock:表示与客户端进行通信的套接字。
- http_request:表示客户端发来的HTTP请求。
- http_response:表示将会发送给客户端的HTTP响应
EndPoint类中主要包含四个成员函数
- RecvHttpRequest:读取客户端发来的HTTP请求。
- HandlerHttpRequest:处理客户端发来的HTTP请求。
- BuildHttpResponse:构建将要发送给客户端的HTTP响应。
- SendHttpResponse:发送HTTP响应给客户端
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
public:
EndPoint(int sock)
:_sock(sock)
{}
//读取请求
void RecvHttpRequest();
//处理请求
void HandlerHttpRequest();
//构建响应
void BuildHttpResponse();
//发送响应
void SendHttpResponse();
~EndPoint()
{}
};
5.2 设计线程回调
服务器每获取到一个新连接就会创建一个新线程来进行处理,而这个线程要做的实际就是定义一个EndPoint对象,然后依次进行读取请求、处理请求、构建响应、发送响应,处理完毕后将与客户端建立的套接字关闭即可
class CallBack{
public:
static void* HandlerRequest(void* arg)
{
LOG(INFO, "handler request begin");
int sock = *(int*)arg;
EndPoint* ep = new EndPoint(sock);
ep->RecvHttpRequest(); //读取请求
ep->HandlerHttpRequest(); //处理请求
ep->BuildHttpResponse(); //构建响应
ep->SendHttpResponse(); //发送响应
close(sock); //关闭与该客户端建立的套接字
delete ep;
LOG(INFO, "handler request end");
return nullptr;
}
};
5.3 读取HTTP请求
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
public:
//读取请求
void RecvHttpRequest()
{
RecvHttpRequestLine(); //读取请求行
RecvHttpRequestHeader(); //读取请求报头和空行
ParseHttpRequestLine(); //解析请求行
ParseHttpRequestHeader(); //解析请求报头
RecvHttpRequestBody(); //读取请求正文
}
};
- 读取请求行
- 读取请求报头和空行
- 解析请求行
- 解析请求报头
- 读取请求正文
5.3.1 读取请求行
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
private:
//读取请求行
void RecvHttpRequestLine()
{
auto& line = _http_request._request_line;
if(Util::ReadLine(_sock, line) > 0){
line.resize(line.size() - 1); //去掉读取上来的\n
}
}
};
由于需要考虑实际情况,存在3种分割符,\r,\r\n,\n,所以ReadLine函数需要我们自己实现
//工具类
class Util{
public:
//读取一行
static int ReadLine(int sock, std::string& out)
{
char ch = 'X'; //ch只要不初始化为\n即可(保证能够进入while循环)
while(ch != '\n'){
ssize_t size = recv(sock, &ch, 1, 0);
if(size > 0){
if(ch == '\r'){
//窥探下一个字符是否为\n
recv(sock, &ch, 1, MSG_PEEK);
if(ch == '\n'){ //下一个字符是\n
//\r\n->\n
recv(sock, &ch, 1, 0); //将这个\n读走
}
else{ //下一个字符不是\n
//\r->\n
ch = '\n'; //将ch设置为\n
}
}
//普通字符或\n
out.push_back(ch);
}
else if(size == 0){ //对方关闭连接
return 0;
}
else{ //读取失败
return -1;
}
}
return out.size(); //返回读取到的字符个数
}
};
- 最终读取完一行后我们都把
\n
push到了用户提供的缓冲区当中,相当于将这三种行分隔符
统一转换成了以\n
为行分隔符 - recv函数的最后一个参数如果设置为
MSG_PEEK
,那么recv函数将返回TCP接收缓冲区头部指定字节个数的数据,但是并不把这些数据从TCP接收缓冲区中取走,这个叫做数据的窥探功能
5.3.2 读取请求报头和空行
由于HTTP的请求报头和空行都是按行陈列的,因此可以循环调用ReadLine函数进行读取,并将读取到的每行数据都存储到HTTP请求类的request_header中,直到读取到空行为止
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
private:
//读取请求报头和空行
void RecvHttpRequestHeader()
{
std::string line;
while(true){
line.clear(); //每次读取之前清空line
Util::ReadLine(_sock, line);
if(line == "\n"){ //读取到了空行
_http_request._blank = line;
break;
}
//读取到一行请求报头
line.resize(line.size() - 1); //去掉读取上来的\n
_http_request._request_header.push_back(line);
}
}
};
- 由于ReadLine函数是将读取到的数据直接push_back到用户提供的缓冲区中的,因此每次调用ReadLine函数进行读取之前需要将缓冲区清空。
- ReadLine函数会将行分隔符\n一同读取上来,但对于我们来说\n并不是有效数据,因此在将读取到的行存储到HTTP请求类的request_header中之前,需要先将\n去掉
5.3.3 解析请求行
解析请求行要做的就是将请求行中的请求方法、URI和HTTP版本号拆分出来,依次存储到HTTP请求类的method、uri和version中,由于请求行中的这些数据都是以空格作为分隔符的,因此可以借助一个stringstream对象来进行拆分。此外,为了后续能够正确判断用户的请求方法,这里需要通过transform函数统一将请求方法转换为全大写
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
private:
//解析请求行
void ParseHttpRequestLine()
{
auto& line = _http_request._request_line;
//通过stringstream拆分请求行
std::stringstream ss(line);
ss>>_http_request._method>>_http_request._uri>>_http_request._version;
//将请求方法统一转换为全大写
auto& method = _http_request._method;
std::transform(method.begin(), method.end(), method.begin(), toupper);
}
};
5.3.4 解析请求报头
解析请求报头要做的就是将读取到的一行一行的请求报头,以:
为分隔符拆分成一个个的键值对存储到HTTP请求的header_kv中,后续就可以直接通过属性名获取到对应的值了
#define SEP ": "
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
private:
//解析请求报头
void ParseHttpRequestHeader()
{
std::string key;
std::string value;
for(auto& iter : _http_request._request_header){
//将每行请求报头打散成kv键值对,插入到unordered_map中
if(Util::CutString(iter, key, value, SEP)){
_http_request._header_kv.insert({key, value});
}
}
}
};
此处用于切割字符串的CutString函数也可以写到工具类中,切割字符串时先通过find方法找到指定的分隔符,然后通过substr提取切割后的子字符串即可。
//工具类
class Util{
public:
//切割字符串
static bool CutString(std::string& target, std::string& sub1_out, std::string& sub2_out, std::string sep)
{
size_t pos = target.find(sep, 0);
if(pos != std::string::npos){
sub1_out = target.substr(0, pos);
sub2_out = target.substr(pos + sep.size());
return true;
}
return false;
}
};
5.3.5 读取请求正文
在读取请求正文之前,首先需要通过本次的请求方法来判断是否需要读取请求正文,因为只有请求方法是POST方法才可能会有请求正文,此外,如果请求方法为POST,我们还需要通过请求报头中的Content-Length属性来得知请求正文的长度。
在得知需要读取请求正文以及请求正文的长度后,就可以将请求正文读取到HTTP请求类的request_body中了
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
private:
//判断是否需要读取请求正文
bool IsNeedRecvHttpRequestBody()
{
auto& method = _http_request._method;
if(method == "POST"){ //请求方法为POST则需要读取正文
auto& header_kv = _http_request._header_kv;
//通过Content-Length获取请求正文长度
auto iter = header_kv.find("Content-Length");
if(iter != header_kv.end()){
_http_request._content_length = atoi(iter->second.c_str());
return true;
}
}
return false;
}
//读取请求正文
void RecvHttpRequestBody()
{
if(IsNeedRecvHttpRequestBody()){ //先判断是否需要读取正文
int content_length = _http_request._content_length;
auto& body = _http_request._request_body;
//读取请求正文
char ch = 0;
while(content_length){
ssize_t size = recv(_sock, &ch, 1, 0);
if(size > 0){
body.push_back(ch);
content_length--;
}
else{
break;
}
}
}
}
};
- 由于后续还会用到请求正文的长度,因此代码中将其存储到了HTTP请求类的content_length中。
- 在通过Content-Length获取到请求正文的长度后,需要将请求正文长度从字符串类型转换为整型
5.4 处理HTTP请求
5.4.1 定义状态码
在处理请求的过程中可能会因为某些原因而直接停止处理,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。为了告知客户端本次HTTP请求的处理情况,服务器需要定义不同的状态码,当处理请求被终止时就可以设置对应的状态码,后续构建HTTP响应的时候就可以根据状态码返回对应的错误页面。
#define OK 200
#define BAD_REQUEST 400
#define NOT_FOUND 404
#define INTERNAL_SERVER_ERROR 500
5.4.2 处理HTTP请求
处理HTTP请求的步骤如下:
- 判断请求方法是否是正确,如果不正确则设置状态码为
BAD_REQUEST
后停止处理。 - 如果请求方法为GET方法,则需要判断URI中是否带参。
如果URI不带参,则说明URI即为客户端请求的资源路径;
如果URI带参,则需要以?为分隔符对URI进行字符串切分,
切分后?左边的内容就是客户端请求的资源路径,而?右边的内容则是GET方法携带的参数,由于此时GET方法携带了参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true - 如果请求方法为POST方法,则说明URI即为客户端请求的资源路径,由于POST方法会通过请求正文上传参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
-
接下来需要对客户端请求的资源路径进行处理,首先需要在请求的资源路径前拼接上web根目录,然后需要判断请求资源路径的最后一个字符是否是/,如果是则说明客户端请求的是一个目录,这时服务器不会将该目录下全部的资源都返回给客户端,而是默认将该目录下的index.html返回给客户端,因此这时还需要在请求资源路径的后面拼接上index.html
-
对请求资源的路径进行处理后,需要通过stat函数获取客户端请求资源文件的属性信息。如果客户端请求的是一个目录,则需要在请求资源路径的后面拼接上/index.html并重新获取资源文件的属性信息;如果客户端请求的是一个可执行程序,则说明后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true
- 根据HTTP请求类中的cgi分别进行CGI或非CGI处理。
#define WEB_ROOT "wwwroot"
#define HOME_PAGE "index.html"
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
public:
//处理请求
void HandlerHttpRequest()
{
auto& code = _http_response._status_code;
if(_http_request._method != "GET"&&_http_request._method != "POST"){ //非法请求
LOG(WARNING, "method is not right");
code = BAD_REQUEST; //设置对应的状态码,并直接返回
return;
}
if(_http_request._method == "GET"){
size_t pos = _http_request._uri.find('?');
if(pos != std::string::npos){ //uri中携带参数
//切割uri,得到客户端请求资源的路径和uri中携带的参数
Util::CutString(_http_request._uri, _http_request._path, _http_request._query_string, "?");
_http_request._cgi = true; //上传了参数,需要使用CGI模式
}
else{ //uri中没有携带参数
_http_request._path = _http_request._uri; //uri即是客户端请求资源的路径
}
}
else if(_http_request._method == "POST"){
_http_request._path = _http_request._uri; //uri即是客户端请求资源的路径
_http_request._cgi = true; //上传了参数,需要使用CGI模式
}
else{
//Do Nothing
}
//给请求资源路径拼接web根目录
std::string path = _http_request._path;
_http_request._path = WEB_ROOT;
_http_request._path += path;
//请求资源路径以/结尾,说明请求的是一个目录
if(_http_request._path[_http_request._path.size() - 1] == '/'){
//拼接上该目录下的index.html
_http_request._path += HOME_PAGE;
}
//获取请求资源文件的属性信息
struct stat st;
if(stat(_http_request._path.c_str(), &st) == 0){ //属性信息获取成功,说明该资源存在
if(S_ISDIR(st.st_mode)){ //该资源是一个目录
_http_request._path += "/"; //需要拼接/,以/结尾的目录前面已经处理过了
_http_request._path += HOME_PAGE; //拼接上该目录下的index.html
stat(_http_request._path.c_str(), &st); //需要重新资源文件的属性信息
}
else if(st.st_mode&S_IXUSR||st.st_mode&S_IXGRP||st.st_mode&S_IXOTH){ //该资源是一个可执行程序
_http_request._cgi = true; //需要使用CGI模式
}
_http_response._size = st.st_size; //设置请求资源文件的大小
}
else{ //属性信息获取失败,可以认为该资源不存在
LOG(WARNING, _http_request._path + " NOT_FOUND");
code = NOT_FOUND; //设置对应的状态码,并直接返回
return;
}
//获取请求资源文件的后缀
size_t pos = _http_request._path.rfind('.');
if(pos == std::string::npos){
_http_response._suffix = ".html"; //默认设置
}
else{
_http_response._suffix = _http_request._path.substr(pos);
}
//进行CGI或非CGI处理
if(_http_request._cgi == true){
code = ProcessCgi(); //以CGI的方式进行处理
}
else{
code = ProcessNonCgi(); //简单的网页返回,返回静态网页
}
}
};
-
本项目实现的HTTP服务器只支持GET方法和POST方法,因此如果客户端发来的HTTP请求中不是这两种方法则认为请求方法错误,如果想让服务器支持其他的请求方法则直接增加对应的逻辑即可
- 服务器向外提供的资源都会放在web根目录下,比如网页、图片、视频等资源,本项目中的web根目录取名为
wwwroot
。web根目录下的所有子目录下都会有一个首页文件,当用户请求的资源是一个目录时,就会默认返回该目录下的首页文件,本项目中的首页文件取名为index.html
。 - stat是一个系统调用函数,它可以获取指定文件的属性信息,包括文件的inode编号、文件的权限、文件的大小等。如果调用stat函数获取文件的属性信息失败,则可以认为客户端请求的这个资源文件不存在,此时直接设置状态码为
NOT_FOUND
后停止处理即 - 当获取文件的属性信息后发现该文件是一个目录,此时请求资源路径一定不是以
/
结尾的,因为在此之前已经对/
结尾的请求资源路径进行过处理了,因此这时需要给请求资源路径拼接上/index.html
- 只要一个文件的拥有者、所属组、other其中一个具有可执行权限,则说明这是一个可执行文件,此时就需要将HTTP请求类中的cgi设置为true。
- 由于后续构建HTTP响应时需要用到请求资源文件的后缀,因此代码中对请求资源路径通过从后往前找
.
的方式,来获取请求资源文件的后缀,如果没有找到.
则默认请求资源的后缀为.html
- 由于请求资源文件的大小后续可能会用到,因此在获取到请求资源文件的属性后,可以将请求资源文件的大小保存到HTTP响应类的size中。
5.4.3 CGI机制代码
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
private:
//CGI处理
int ProcessCgi()
{
int code = OK; //要返回的状态码,默认设置为200
auto& bin = _http_request._path; //需要执行的CGI程序
auto& method = _http_request._method; //请求方法
//需要传递给CGI程序的参数
auto& query_string = _http_request._query_string; //GET
auto& request_body = _http_request._request_body; //POST
int content_length = _http_request._content_length; //请求正文的长度
auto& response_body = _http_response._response_body; //CGI程序的处理结果放到响应正文当中
//1、创建两个匿名管道(管道命名站在父进程角度)
//创建从子进程到父进程的通信信道
int input[2];
if(pipe(input) < 0){ //管道创建失败,则返回对应的状态码
LOG(ERROR, "pipe input error!");
code = INTERNAL_SERVER_ERROR;
return code;
}
//创建从父进程到子进程的通信信道
int output[2];
if(pipe(output) < 0){ //管道创建失败,则返回对应的状态码
LOG(ERROR, "pipe output error!");
code = INTERNAL_SERVER_ERROR;
return code;
}
//2、创建子进程
pid_t pid = fork();
if(pid == 0){ //child
//子进程关闭两个管道对应的读写端
close(input[0]);
close(output[1]);
//将请求方法通过环境变量传参
std::string method_env = "METHOD=";
method_env += method;
putenv((char*)method_env.c_str());
if(method == "GET"){ //将query_string通过环境变量传参
std::string query_env = "QUERY_STRING=";
query_env += query_string;
putenv((char*)query_env.c_str());
LOG(INFO, "GET Method, Add Query_String env");
}
else if(method == "POST"){ //将正文长度通过环境变量传参
std::string content_length_env = "CONTENT_LENGTH=";
content_length_env += std::to_string(content_length);
putenv((char*)content_length_env.c_str());
LOG(INFO, "POST Method, Add Content_Length env");
}
else{
//Do Nothing
}
//3、将子进程的标准输入输出进行重定向
dup2(output[0], 0); //标准输入重定向到管道的输入
dup2(input[1], 1); //标准输出重定向到管道的输出
//4、将子进程替换为对应的CGI程序
execl(bin.c_str(), bin.c_str(), nullptr);
exit(1); //替换失败
}
else if(pid < 0){ //创建子进程失败,则返回对应的错误码
LOG(ERROR, "fork error!");
code = INTERNAL_SERVER_ERROR;
return code;
}
else{ //father
//父进程关闭两个管道对应的读写端
close(input[1]);
close(output[0]);
if(method == "POST"){ //将正文中的参数通过管道传递给CGI程序
const char* start = request_body.c_str();
int total = 0;
int size = 0;
while(total < content_length && (size = write(output[1], start + total, request_body.size() - total)) > 0){
total += size;
}
}
//读取CGI程序的处理结果
char ch = 0;
while(read(input[0], &ch, 1) > 0){
response_body.push_back(ch);
} //不会一直读,当另一端关闭后会继续执行下面的代码
//等待子进程(CGI程序)退出
int status = 0;
pid_t ret = waitpid(pid, &status, 0);
if(ret == pid){
if(WIFEXITED(status)){ //正常退出
if(WEXITSTATUS(status) == 0){ //结果正确
LOG(INFO, "CGI program exits normally with correct results");
code = OK;
}
else{
LOG(INFO, "CGI program exits normally with incorrect results");
code = BAD_REQUEST;
}
}
else{
LOG(INFO, "CGI program exits abnormally");
code = INTERNAL_SERVER_ERROR;
}
}
//关闭两个管道对应的文件描述符
close(input[0]);
close(output[1]);
}
return code; //返回状态码
}
};
-
在CGI处理过程中,如果管道创建失败或者子进程创建失败,则属于服务器端处理请求时出错,此时返回
INTERNAL_SERVER_ERROR
状态码后停止处理即可 -
环境变量是
key=value
形式的,因此在调用putenv函数导入环境变量前需要先正确构建环境变量,此后被替换的CGI程序在调用getenv函数时,就可以通过key
获取到对应的value
- 子进程传递参数的代码最好放在重定向之前,否则服务器运行后无法看到传递参数对应的日志信息,因为日志是以cout的方式打印到标准输出的,而
dup2
函数调用后标准输出已经被重定向到了管道,此时打印的日志信息将会被写入管道 - 父进程循环调用read函数从管道中读取CGI程序的处理结果,当CGI程序执行结束时相当于写端进程将写端关闭了(文件描述符的生命周期随进程),此时读端进程将管道当中的数据读完后,就会继续执行后续代码,而不会被阻塞。
-
父进程在等待子进程退出后,可以通过WIFEXITED判断子进程是否是正常退出,如果是正常退出再通过WEXITSTATUS判断处理结果是否正确,然后根据不同情况设置对应的状态码(此时就算子进程异常退出或处理结果不正确也不能立即返回,需要让父进程继续向后执行,关闭两个管道对应的文件描述符,防止文件描述符泄露)
5.4.4 非CGI处理
磁盘当中的目标文件内容读取到内核,再由内核将其发送给对应的网卡进行发送
要达到上述效果就需要使用sendfile函数,该函数的功能就是将数据从一个文件描述符拷贝到另一个文件描述符,并且这个拷贝操作是在内核中完成的,因此sendfile比单纯的调用read和write更加高效。
但是需要注意的是,这里还不能直接调用sendfile函数,因为sendfile函数调用后文件内容就发送出去了,而我们应该构建HTTP响应后再进行发送,因此我们这里要做的仅仅是将要发送的目标文件打开即可,将打开文件对应的文件描述符保存到HTTP响应的fd当中
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
private:
//非CGI处理
int ProcessNonCgi()
{
//打开客户端请求的资源文件,以供后续发送
_http_response._fd = open(_http_request._path.c_str(), O_RDONLY);
if(_http_response._fd >= 0){ //打开文件成功
return OK;
}
return INTERNAL_SERVER_ERROR; //打开文件失败
}
};
- 果打开文件失败,则返回
INTERNAL_SERVER_ERROR
状态码表示服务器处理请求时出错,而不能返回NOT_FOUND
,因为之前调用stat获取过客户端请求资源的属性信息,说明该资源文件是一定存在的
5.5 构建HTTP响应
5.5.1 构建状态行
构建HTTP响应首先需要构建的就是状态行,状态行由状态码、状态码描述、HTTP版本构成,并以空格作为分隔符,将状态行构建好后保存到HTTP响应的status_line当中即可,而响应报头需要根据请求是否正常处理完毕分别进行构建
#define HTTP_VERSION "HTTP/1.0"
#define LINE_END "\r\n"
#define PAGE_400 "400.html"
#define PAGE_404 "404.html"
#define PAGE_500 "500.html"
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
public:
//构建响应
void BuildHttpResponse()
{
int code = _http_response._status_code;
//构建状态行
auto& status_line = _http_response._status_line;
status_line += HTTP_VERSION;
status_line += " ";
status_line += std::to_string(code);
status_line += " ";
status_line += CodeToDesc(code);
status_line += LINE_END;
//构建响应报头
std::string path = WEB_ROOT;
path += "/";
switch(code){
case OK:
BuildOkResponse();
break;
case NOT_FOUND:
path += PAGE_404;
HandlerError(path);
break;
case BAD_REQUEST:
path += PAGE_400;
HandlerError(path);
break;
case INTERNAL_SERVER_ERROR:
path += PAGE_500;
HandlerError(path);
break;
default:
break;
}
}
};
- 本项目中将服务器的行分隔符设置为\r\n,在构建完状态行以及每行响应报头之后都需要加上对应的行分隔符,而在HTTP响应类的构造函数中已经将空行初始化为了LINE_END,因此在构建HTTP响应时不用处理空行。
对于状态行中的状态码描述,我们可以编写一个函数,该函数能够根据状态码返回对应的状态码描述
//根据状态码获取状态码描述
static std::string CodeToDesc(int code)
{
std::string desc;
switch(code){
case 200:
desc = "OK";
break;
case 400:
desc = "Bad Request";
break;
case 404:
desc = "Not Found";
break;
case 500:
desc = "Internal Server Error";
break;
default:
break;
}
return desc;
}
5.5.2 构建响应报头(请求正常处理完毕)
构建HTTP的响应报头时,我们至少需要构建Content-Type和Content-Length这两个响应报头,分别用于告知对方响应资源的类型和响应资源的长度。
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
private:
void BuildOkResponse()
{
//构建响应报头
std::string content_type = "Content-Type: ";
content_type += SuffixToDesc(_http_response._suffix);
content_type += LINE_END;
_http_response._response_header.push_back(content_type);
std::string content_length = "Content-Length: ";
if(_http_request._cgi){ //以CGI方式请求
content_length += std::to_string(_http_response._response_body.size());
}
else{ //以非CGI方式请求
content_length += std::to_string(_http_response._size);
}
content_length += LINE_END;
_http_response._response_header.push_back(content_length);
}
};
对于返回资源的类型,我们可以编写一个函数,该函数能够根据文件后缀返回对应的文件类型。查看Content-Type转化表可以得知后缀与文件类型的对应关系,将这个对应关系存储一个unordered_map容器中,当需要根据后缀得知文件类型时直接在这个unordered_map容器中进行查找,如果找到了则返回对应的文件类型,如果没有找到则默认该文件类型为text/html
//根据后缀获取资源类型
static std::string SuffixToDesc(const std::string& suffix)
{
static std::unordered_map<std::string, std::string> suffix_to_desc = {
{".html", "text/html"},
{".css", "text/css"},
{".js", "application/x-javascript"},
{".jpg", "application/x-jpg"},
{".xml", "text/xml"}
};
auto iter = suffix_to_desc.find(suffix);
if(iter != suffix_to_desc.end()){
return iter->second;
}
return "text/html"; //所给后缀未找到则默认该资源为html文件
}
5.5.3 构建响应报头(请求处理出现错误)
对于请求处理过程中出现错误的HTTP请求,服务器将会为其返回对应的错误页面,因此返回的资源类型就是text/html,而返回资源的大小可以通过获取错误页面对应的文件属性信息来得知。此外,为了后续发送响应时可以直接调用sendfile进行发送,这里需要将错误页面对应的文件打开,并将对应的文件描述符保存在HTTP响应类的fd当中
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
private:
void HandlerError(std::string page)
{
_http_request._cgi = false; //需要返回对应的错误页面(非CGI返回)
//打开对应的错误页面文件,以供后续发送
_http_response._fd = open(page.c_str(), O_RDONLY);
if(_http_response._fd > 0){ //打开文件成功
//构建响应报头
struct stat st;
stat(page.c_str(), &st); //获取错误页面文件的属性信息
std::string content_type = "Content-Type: text/html";
content_type += LINE_END;
_http_response._response_header.push_back(content_type);
std::string content_length = "Content-Length: ";
content_length += std::to_string(st.st_size);
content_length += LINE_END;
_http_response._response_header.push_back(content_length);
_http_response._size = st.st_size; //重新设置响应文件的大小
}
}
};
- 对于处理请求时出错的HTTP请求,需要将其HTTP请求类中的cgi重新设置为false,因为后续发送HTTP响应时,需要根据HTTP请求类中的cgi来进行响应正文的发送,当请求处理出错后要返回给客户端的本质就是一个错误页面文件,相当于是以非CGI方式进行处理的。
5.5.4 发送HTTP响应
发送HTTP响应的步骤如下:
- 调用send函数,依次发送状态行、响应报头和空行。
- 发送响应正文时需要判断本次请求的处理方式,如果本次请求是以CGI方式成功处理的,那么待发送的响应正文是保存在HTTP响应类的response_body中的,此时调用send函数进行发送即可
- 如果本次请求是以非CGI方式处理或在处理过程中出错的,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP响应类的fd中的,此时调用sendfile进行发送即可,发送后关闭对应的文件描述符
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
public:
//发送响应
void SendHttpResponse()
{
//发送状态行
send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0);
//发送响应报头
for(auto& iter : _http_response._response_header){
send(_sock, iter.c_str(), iter.size(), 0);
}
//发送空行
send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0);
//发送响应正文
if(_http_request._cgi){
auto& response_body = _http_response._response_body;
const char* start = response_body.c_str();
size_t size = 0;
size_t total = 0;
while(total < response_body.size()&&(size = send(_sock, start + total, response_body.size() - total, 0)) > 0){
total += size;
}
}
else{
sendfile(_sock, _http_response._fd, nullptr, _http_response._size);
//关闭请求的资源文件
close(_http_response._fd);
}
}
};
6. 项目问题
6.1 读取错误
读取请求的过程中出现的错误就叫做读取错误,比如调用recv读取请求时出错或读取请求时对方连接关闭等
出现读取错误时,意味着服务器都没有成功读取完客户端发来的HTTP请求,因此服务器也没有必要进行后续的处理请求、构建响应以及发送响应的相关操作了。
可以在EndPoint类中新增一个bool类型的stop成员,表示是否停止本次处理,stop的值默认设置为false,当读取请求出错时就直接设置stop为true并不再进行后续的读取操作,因此读取HTTP请求的代码需要稍作修改
-
可以将读取请求行、读取请求报头和空行、读取请求正文对应函数的返回值改为bool类型,当读取请求行成功后再读取请求报头和空行,
-
EndPoint类当中提供了IsStop函数,用于让外部处理线程得知是否应该停止本次处理。
6.2 写入错误
除了读取请求时可能出现读取错误,处理请求时可能出现逻辑错误,在响应构建完毕发送响应时同样可能会出现写入错误,比如调用send发送响应时出错或发送响应时对方连接关闭等。
出现写入错误时,服务器也没有必要继续进行发送了,这时需要直接设置stop为true并不再进行后续的发送操作,因此发送HTTP响应的代码也需要进行修改。
-
当服务器发送响应出错时会收到SIGPIPE信号,而该信号的默认处理动作是终止当前进程,为了防止服务器因为写入出错而被终止,需要在初始化HTTP服务器时调用signal函数忽略SIGPIPE信号
7. 接入线程池
7.1 当前多线程版服务器存在的问题
- 每当获取到新连接时,服务器主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁,这样做不仅麻烦,而且效率低下。
-
如果同时有大量的客户端连接请求,此时服务器就要为每一个客户端创建对应的服务线程,而计算机中的线程越多,CPU压力就越大,因为CPU要不断在这些线程之间来回切换。
7.2 服务器端引入线程池
- 在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中
- 线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理
7.3 设计任务
当服务器获取到一个新连接后,需要将其封装成一个任务对象放到任务队列当中。任务类中首先需要有一个套接字,也就是与客户端进行通信的套接字,此外还需要有一个回调函数,当线程池中的线程获取到任务后就可以调用这个回调函数进行任务处理
//任务类
class Task{
private:
int _sock; //通信的套接字
CallBack _handler; //回调函数
public:
Task()
{}
Task(int sock)
:_sock(sock)
{}
//处理任务
void ProcessOn()
{
_handler(_sock); //调用回调
}
~Task()
{}
};
7.4 编写任务回调
任务类中处理任务时需要调用的回调函数,实际就是之前创建新线程时传入的执行例程CallBack::HandlerRequest,我们可以将CallBack类的()运算符重载为调用HandlerRequest函数,这时CallBack对象就变成了一个仿函数对象,这个仿函数对象被调用时实际就是在调用HandlerRequest函数。
class CallBack{
public:
CallBack()
{}
void operator()(int sock)
{
HandlerRequest(sock);
}
void HandlerRequest(int sock)
{
LOG(INFO, "handler request begin");
EndPoint* ep = new EndPoint(sock);
ep->RecvHttpRequest(); //读取请求
if(!ep->IsStop()){
LOG(INFO, "Recv No Error, Begin Handler Request");
ep->HandlerHttpRequest(); //处理请求
ep->BuildHttpResponse(); //构建响应
ep->SendHttpResponse(); //发送响应
if(ep->IsStop()){
LOG(WARNING, "Send Error, Stop Send Response");
}
}
else{
LOG(WARNING, "Recv Error, Stop Handler Request");
}
close(sock); //关闭与该客户端建立的套接字
delete ep;
LOG(INFO, "handler request end");
}
~CallBack()
{}
};
7.5 编写线程池
#define NUM 6
//线程池
class ThreadPool{
private:
std::queue<Task> _task_queue; //任务队列
int _num; //线程池中线程的个数
pthread_mutex_t _mutex; //互斥锁
pthread_cond_t _cond; //条件变量
static ThreadPool* _inst; //指向单例对象的static指针
private:
//构造函数私有
ThreadPool(int num = NUM)
:_num(num)
{
//初始化互斥锁和条件变量
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_cond, nullptr);
}
//将拷贝构造函数和拷贝赋值函数私有或删除(防拷贝)
ThreadPool(const ThreadPool&)=delete;
ThreadPool* operator=(const ThreadPool&)=delete;
//判断任务队列是否为空
bool IsEmpty()
{
return _task_queue.empty();
}
//任务队列加锁
void LockQueue()
{
pthread_mutex_lock(&_mutex);
}
//任务队列解锁
void UnLockQueue()
{
pthread_mutex_unlock(&_mutex);
}
//让线程在条件变量下进行等待
void ThreadWait()
{
pthread_cond_wait(&_cond, &_mutex);
}
//唤醒在条件变量下等待的一个线程
void ThreadWakeUp()
{
pthread_cond_signal(&_cond);
}
public:
//获取单例对象
static ThreadPool* GetInstance()
{
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定义静态的互斥锁
//双检查加锁
if(_inst == nullptr){
pthread_mutex_lock(&mtx); //加锁
if(_inst == nullptr){
//创建单例线程池对象并初始化
_inst = new ThreadPool();
_inst->InitThreadPool();
}
pthread_mutex_unlock(&mtx); //解锁
}
return _inst; //返回单例对象
}
//线程的执行例程
static void* ThreadRoutine(void* arg)
{
pthread_detach(pthread_self()); //线程分离
ThreadPool* tp = (ThreadPool*)arg;
while(true){
tp->LockQueue(); //加锁
while(tp->IsEmpty()){
//任务队列为空,线程进行wait
tp->ThreadWait();
}
Task task;
tp->PopTask(task); //获取任务
tp->UnLockQueue(); //解锁
task.ProcessOn(); //处理任务
}
}
//初始化线程池
bool InitThreadPool()
{
//创建线程池中的若干线程
pthread_t tid;
for(int i = 0;i < _num;i++){
if(pthread_create(&tid, nullptr, ThreadRoutine, this) != 0){
LOG(FATAL, "create thread pool error!");
return false;
}
}
LOG(INFO, "create thread pool success");
return true;
}
//将任务放入任务队列
void PushTask(const Task& task)
{
LockQueue(); //加锁
_task_queue.push(task); //将任务推入任务队列
UnLockQueue(); //解锁
ThreadWakeUp(); //唤醒一个线程进行任务处理
}
//从任务队列中拿任务
void PopTask(Task& task)
{
//获取任务
task = _task_queue.front();
_task_queue.pop();
}
~ThreadPool()
{
//释放互斥锁和条件变量
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}
};
//单例对象指针初始化为nullptr
ThreadPool* ThreadPool::_inst = nullptr;
- 由于线程的执行例程的参数只能有一个
void*
类型的参数,因此线程的执行例程必须定义成静态成员函数,而线程执行例程中又需要访问任务队列,因此需要将this指针作为参数传递给线程的执行例程,这样线程才能够通过this指针访问任务队列 - 在向任务队列中放任务以及从任务队列中获取任务时,都需要通过加锁的方式来保证线程安全,而线程在调用PopTask之前已经进行过加锁了,因此在PopTask函数中不必再加锁
- 当任务队列中有任务时会唤醒线程进行任务处理,为了防止被伪唤醒的线程调用PopTask时无法获取到任务,因此需要以while的方式判断任务队列是否为空。
引入线程池后服务器要做的就是,每当获取到一个新连接时就构建一个任务,然后调用PushTask将其放入任务队列即可。
//HTTP服务器
class HttpServer{
private:
int _port; //端口号
public:
//启动服务器
void Loop()
{
LOG(INFO, "loop begin");
TcpServer* tsvr = TcpServer::GetInstance(_port); //获取TCP服务器单例对象
int listen_sock = tsvr->Sock(); //获取监听套接字
while(true){
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //获取新连接
if(sock < 0){
continue; //获取失败,继续获取
}
//打印客户端相关信息
std::string client_ip = inet_ntoa(peer.sin_addr);
int client_port = ntohs(peer.sin_port);
LOG(INFO, "get a new link: ["+client_ip+":"+std::to_string(client_port)+"]");
//构建任务并放入任务队列中
Task task(sock);
ThreadPool::GetInstance()->PushTask(task);
}
}
};
8. 测试
8.1 GET方法上传数据测试
如果用户请求服务器时上传了数据,那么服务器就需要将该数据后交给对应的CGI程序进行处理,因此在测试GET方法上传数据之前,我们需要先编写一个简单的CGI程序。
首先,CGI程序启动后需要先获取父进程传递过来的数据:
- 先通过getenv函数获取环境变量中的请求方法。
- 如果请求方法为GET方法,则继续通过getenv函数获取父进程传递过来的数据。
- 如果请求方法为POST方法,则先通过getenv函数获取父进程传递过来的数据的长度,然后再从0号文件描述符中读取指定长度的数据即可。
//获取参数
bool GetQueryString(std::string& query_string)
{
bool result = false;
std::string method = getenv("METHOD"); //获取请求方法
if(method == "GET"){ //GET方法通过环境变量获取参数
query_string = getenv("QUERY_STRING");
result = true;
}
else if(method == "POST"){ //POST方法通过管道获取参数
int content_length = atoi(getenv("CONTENT_LENGTH"));
//从管道中读取content_length个参数
char ch = 0;
while(content_length){
read(0, &ch, 1);
query_string += ch;
content_length--;
}
result = true;
}
else{
//Do Nothing
result = false;
}
return result;
}
CGI程序在获取到父进程传递过来的数据后,就可以根据具体的业务场景进行数据处理了,比如用户上传的如果是一个关键字则需要CGI程序做搜索处理。我们这里以演示为目的,认为用户上传的是形如a=10&b=20的两个参数,需要CGI程序进行加减乘除运算。
因此我们的CGI程序要做的就是,先以&为分隔符切割数据将两个操作数分开,再以=为分隔符切割数据分别获取到两个操作数的值,最后对两个操作数进行加减乘除运算,并将计算结果打印到标准输出即可(标准输出已经被重定向到了管道)
//切割字符串
bool CutString(std::string& in, const std::string& sep, std::string& out1, std::string& out2)
{
size_t pos = in.find(sep);
if(pos != std::string::npos){
out1 = in.substr(0, pos);
out2 = in.substr(pos + sep.size());
return true;
}
return false;
}
int main()
{
std::string query_string;
GetQueryString(query_string); //获取参数
//以&为分隔符将两个操作数分开
std::string str1;
std::string str2;
CutString(query_string, "&", str1, str2);
//以=为分隔符分别获取两个操作数的值
std::string name1;
std::string value1;
CutString(str1, "=", name1, value1);
std::string name2;
std::string value2;
CutString(str2, "=", name2, value2);
//处理数据
int x = atoi(value1.c_str());
int y = atoi(value2.c_str());
std::cout<<"<html>";
std::cout<<"<head><meta charset=\"UTF-8\"></head>";
std::cout<<"<body>";
std::cout<<"<h3>"<<x<<" + "<<y<<" = "<<x+y<<"</h3>";
std::cout<<"<h3>"<<x<<" - "<<y<<" = "<<x-y<<"</h3>";
std::cout<<"<h3>"<<x<<" * "<<y<<" = "<<x*y<<"</h3>";
std::cout<<"<h3>"<<x<<" / "<<y<<" = "<<x/y<<"</h3>"; //除0后cgi程序崩溃,属于异常退出
std::cout<<"</body>";
std::cout<<"</html>";
return 0;
}
-
CGI程序输出的结果最终会交给浏览器,因此CGI程序输出的最好是一个HTML文件,这样浏览器收到后就可以其渲染到页面上,让用户看起来更美观。
-
可以看到,使用C/C++以HTML的格式进行输出是很费劲的,因此这部分操作一般是由Python等语言来完成的,而在此之前对数据进行业务处理的动作一般才用C/C++等语言来完成。
-
在编写CGI程序时如果要进行调试,debug内容应该通过标准错误流进行输出,因为子进程在被替换成CGI程序之前,已经将标准输出重定向到管道了。
8.2 表单上传数据测试
HTML中的表单用于搜集用户的输入,我们可以通过设置表单的method属性来指定表单提交的方法,通过设置表单的action属性来指定表单需要提交给服务器上的哪一个CGI程序
<!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>
</head>
<body>
<form action="/test_cgi" method="get" align="center">
操作数1:<br>
<input type="text" name="x"><br>
操作数2:<br>
<input type="text" name="y"><br><br>
<input type="submit" value="计算">
</form>
</body>
</html>
8.3 POST方法上传数据测试
测试表单通过POST方法上传数据时,只需要将表单中的method属性改为“post”即可,此时点击“计算”提交表单时,浏览器检测到表单的提交方法为POST后,就会将表单中的数据添加到请求正文中,并将请求资源路径替换成表单action指定的路径,然后再次向服务器发起HTTP请求
9. 项目扩展
9.1 技术层面的扩展
-
当前项目编写的是HTTP1.0版本的服务器,每次连接都只会对一个请求进行处理,当服务器对客户端的请求处理完毕并收到客户端的应答后,就会直接断开连接。
可以将其扩展为HTTP1.1版本,让服务器支持长连接,即通过一条连接可以对多个请求进行处理,避免重复建立连接(涉及连接管理)。 -
当前项目虽然在后端接入了线程池,但也只能满足中小型应用,可以考虑将服务器改写成epoll版本,让服务器的IO变得更高效。
-
可以给当前的HTTP服务器新增代理功能,也就是可以替代客户端去访问某种服务,然后将访问结果再返回给客户
9.2 应用层面的扩展
- 基于当前HTTP服务器,搭建在线博客。
- 基于当前HTTP服务器,编写在线画图板。
- 基于当前HTTP服务器,编写一个搜索引擎。
======================================================================
原文链接:【项目设计】自主HTTP服务器-CSDN博客