文章目录
- 项目介绍
- CGI技术
- 概念
- 原理
- 设计框架
- 日志文件
- TCPServer
- 任务类
- 初始化与启动HttpServer
- HTTP请求结构
- HTTP响应结构
- 线程回调
- EndPoint类
- EndPoint主体框架
- 读取HTTP请求
- 处理HTTP请求
- CGI处理
- 非CGI处理
- 构建HTTP响应
- 发送HTTP响应
- 接入线程池
- 简单测试
- 项目扩展
项目介绍
该项目是一个基于Http和Tcp协议自主实现的WebServer,用于实现服务器对客户端发送过来的GET和POST请求的接收、解析、处理,并返回处理结果给到客户端。该项目主要背景知识涉及C++、网络分层协议栈、HTTP协议、网络套接字编程、CGI技术、单例模式、多线程编程、线程池等。
项目源码:Click
CGI技术
CGI技术可能大家比较陌生,单拎出来提下。
概念
CGI(通用网关接口,Common Gateway Interface)是一种用于在Web服务器上执行程序并生成动态Web内容的技术。CGI程序可以是任何可执行程序,通常是脚本语言,例如Perl或Python。
CGI技术允许Web服务器通过将Web请求传递给CGI程序来执行任意可执行文件。CGI程序接收HTTP请求,并生成HTTP响应以返回给Web服务器,最终返回给Web浏览器。这使得Web服务器能够动态地生成网页内容,与静态HTML文件不同。CGI程序可以处理表单数据、数据库查询和其他任务,从而实现动态Web内容。一些常见的用途包括创建动态网页、在线购物车、用户注册、论坛、网上投票等。
原理
通过Web服务器将Web请求传递给CGI程序,CGI程序处理请求并生成响应,然后将响应传递回Web服务器,最终返回给客户端浏览器。这个过程可以概括为:
- 客户端发送HTTP请求到Web服务器。
- Web服务器检查请求类型,如果是CGI请求,Web服务器将环境变量和请求参数传递给CGI程序,并等待CGI程序的响应。
- CGI程序接收请求参数,并执行相应的操作,例如读取数据库或处理表单数据等。
- CGI程序生成HTTP响应,将响应返回给Web服务器。
- Web服务器将响应返回给客户端浏览器。
在这个过程中,Web服务器和CGI程序之间通过标准输入和标准输出(建立管道并重定向到标准输入输出)进行通信。Web服务器将请求参数通过环境变量传递给CGI程序,CGI程序将生成的响应通过标准输出返回给Web服务器。此外,CGI程序还可以通过其他方式与Web服务器进行通信,例如通过命令行参数或文件进行交互。
设计框架
日志文件
用于记录下服务器运行过程中产生的一些事件。日志格式如下:
日志级别说明:
- INFO: 表示正常的日志输出,一切按预期运行。
- WARNING: 表示警告,该事件不影响服务器运行,但存在风险。
- ERROR: 表示发生了某种错误,但该事件不影响服务器继续运行。
- FATAL: 表示发生了致命的错误,该事件将导致服务器停止运行。
文件名称和行数可以通过C语言中的预定义符号__FILE__
和__LINE__
,分别可以获取当前文件的名称和当前的行数。
#define INFO 1
#define WARNING 2
#define ERROR 3
#define FATAL 4
// #将宏参数level转为字符串格式
#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)
TCPServer
思路是:创建一个TCP服务器,并通过初始化、绑定和监听等步骤实现对外服务。
具体实现中,单例模式通过一个名为GetInstance
的静态方法实现,该方法首先使用pthread_mutex_t保证线程安全,然后使用静态变量 _svr指向单例对象,如果 _svr为空,则创建一个新的TcpServer对象并初始化,最后返回 _svr指针。由于 _svr是static类型的,因此可以确保整个程序中只有一个TcpServer实例。
Socket
方法用于创建一个监听套接字,Bind
方法用于将端口号与IP地址绑定,Listen
方法用于将监听套接字置于监听状态,等待客户端连接。Sock
方法用于返回监听套接字的文件描述符。
#define BACKLOG 5
class TcpServer{
private:
int _port; // 端口号
int _listen_sock; // 监听套接字
static TcpServer* _svr; // 指向单例对象的static指针
private:
TcpServer(int port)
:_port(port)
,_listen_sock(-1)
{}
TcpServer(const TcpServer&) = delete;
TcpServer* operator=(const TcpServer&) = delete;
public:
static TcpServer* GetInstance(int port)// 单例
{
static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
if (_svr == nullptr)
{
pthread_mutex_lock(&mtx);
if (_svr == nullptr)// 为什么要两个if? 原因:当首个拿锁者完成了对象创建,之后的线程都不会通过第一个if了,而这期间阻塞的线程开始唤醒,它们则需要靠第二个if语句来避免再次创建对象。
{
_svr = new TcpServer(port);
_svr -> InitServer();
}
pthread_mutex_unlock(&mtx);
}
return _svr;
}
void InitServer()
{
Socket(); // 创建
Bind(); // 绑定
Listen(); // 监听
LOG(INFO, "TcpServer 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;// 将 SO_REUSEADDR 设置为 1 将允许在端口上快速重启套接字
setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
LOG(INFO, "creat listen_sock 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;
if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
{
LOG(FATAL, "bind error");
exit(2);
}
LOG(INFO, "port bind listen_sock success");
}
void Listen() // 监听
{
if (listen(_listen_sock, BACKLOG) < 0) // 声明_listen_sock处于监听状态,并且最多允许有backlog个客户端处于连接等待状态,如果接收到更多的连接请求就忽略
{
LOG(FATAL, "listen error");
exit(3);
}
LOG(INFO, "listen listen_sock success");
}
int Sock() // 获取监听套接字fd
{
return _listen_sock;
}
~TcpServer()
{
if (_listen_sock >= 0)
{
close(_listen_sock);
}
}
};
// 单例对象指针初始化
TcpServer* TcpServer::_svr = nullptr;
任务类
// 任务类
class Task{
private:
int _sock; // 通信套接字
CallBack _handler; // 回调函数
public:
Task()
{}
~Task()
{}
Task(int sock) // accept建立连接成功产生的通信套接字sock
:_sock(sock)
{}
// 执行任务
void ProcessOn()
{
_handler(_sock); //_handler对象的运算符()已经重装,直接调用重载的()
}
};
初始化与启动HttpServer
这部分包含一个初始化服务器的方法InitServer()和一个启动服务器的方法Loop()。其中InitServer()函数注册了一个信号处理函数,忽略SIGPIPE信号(避免写入崩溃)。而Loop()函数则通过调用TcpServer类的单例对象获取监听套接字,然后通过accept()函数等待客户端连接,每当有客户端连接进来,就创建一个线程来处理该客户端的请求,并把任务放入线程池中。这里的Task是一个简单的封装,它包含一个处理客户端请求的成员函数,该成员函数读取客户端请求,解析请求,然后调用CGI程序来执行请求,最后将响应发送给客户端。
#define PORT 8081
class HttpServer
{
private:
int _port;// 端口号
public:
HttpServer(int port)
:_port(port)
{}
~HttpServer()
{}
// 初始化服务器
void InitServer()
{
signal(SIGPIPE, SIG_IGN); // 直接粗暴处理cgi程序写入管道时崩溃的情况,忽略SIGPIPE信号,避免因为一个被关闭的socket连接而使整个进程终止
}
// 启动服务器
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);
}
}
};
HTTP请求结构
将HTTP请求封装成一个类,这个类当中包括HTTP请求的内容、HTTP请求的解析结果以及是否需要使用CGI模式的标志位。后续处理请求时就可以定义一个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; // 请求报头的内容是以键值对的形式存在的,用hash保存
int _content_length; // 正文长度
std::string _path; // 请求资源的路径
std::string _query_string; // URI携带的参数
// 是否使用CGI
bool _cgi;
public:
HttpRequest()
:_content_length(0) // 默认请求正文长度为0
,_cgi(false) // 默认不适用CGI模式
{}
~HttpRequest()
{}
};
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为true(即Get带_query_string或者Post),响应正文才存在)
// 所需数据
int _status_code; // 状态码
int _fd; // 响应文件的fd
int _size; // 响应文件的大小
std::string _suffix; // 响应文件的后缀
public:
HttpResponse()
:_blank(LINE_END)
,_status_code(OK)
,_fd(-1)
,_size(0)
{}
~HttpResponse()
{}
};
线程回调
该回调函数实际上是一个函数对象,其重载了圆括号运算符“()”。当该函数对象被调用时,会传入一个int类型的套接字描述符作为参数,代表与客户端建立的连接套接字。该函数对象内部通过创建一个EndPoint对象来处理该客户端发来的HTTP请求,包括读取请求、处理请求、构建响应和发送响应。处理完毕后,该连接套接字将被关闭,EndPoint对象也会被释放。
class CallBack{
public:
CallBack()
{}
~CallBack()
{}
// 重载运算符 ()
void operator()(int sock)
{
HandlerRequest(sock);
}
void HandlerRequest(int sock)
{
LOG(INFO, "HandlerRequest begin");
EndPoint* ep = new EndPoint(sock);
ep->RecvHttpRequest(); //读取请求
if (!ep->IsStop())
{
LOG(INFO, "RecvHttpRequest Success");
ep->HandlerHttpRequest(); //处理请求
ep->BulidHttpResponse(); //构建响应
ep->SendHttpResponse(); //发送响应
if (ep->IsStop())
{
LOG(WARNING, "SendHttpResponse Error, Stop Send HttpResponse");
}
}
else
{
LOG(WARNING, "RecvHttpRequest Error, Stop handler Response");
}
close(sock); //响应完毕,关闭与该客户端建立的套接字
delete ep;
LOG(INFO, "handler request end");
}
};
EndPoint类
EndPoint主体框架
EndPoint类中包含三个成员变量:
- sock:表示与客户端进行通信的套接字。
- http_request:表示客户端发来的HTTP请求。
- http_response:表示将会发送给客户端的HTTP响应。
- _stop:是否异常停止本次处理
EndPoint类中主要包含四个成员函数:
- RecvHttpRequest:读取客户端发来的HTTP请求。
- HandlerHttpRequest:处理客户端发来的HTTP请求。
- BuildHttpResponse:构建将要发送给客户端的HTTP响应。
- SendHttpResponse:发送HTTP响应给客户端。
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
bool _stop; //是否停止本次处理
public:
EndPoint(int sock)
:_sock(sock)
{}
//读取请求
void RecvHttpRequest();
//处理请求
void HandlerHttpRequest();
//构建响应
void BuildHttpResponse();
//发送响应
void SendHttpResponse();
~EndPoint()
{}
};
读取HTTP请求
读取HTTP请求的同时可以对HTTP请求进行解析,这里我们分为五个步骤,分别是读取请求行、读取请求报头和空行、解析请求行、解析请求报头、读取请求正文。
// 读取请求:如果请求行和请求报头正常读取,那先解析请求行和请求报头,然后读取请求正文
void RecvHttpRequest()
{
if (!RecvHttpRequestLine() && !RecvHttpRequestHeader())// 请求行与请求报头读取均正常读取
{
ParseHttpRequestLine();
ParseHttpRequestHeader();
RecvHttpRequestBody();
}
}
处理HTTP请求
首先判断请求方法是否为GET或POST,如果不是则返回错误信息;然后判断请求是GET还是POST,设置对应的cgi、路径和查询字符串;接着拼接web根目录和请求资源路径,并判断路径是否以/结尾,如果是则拼接index.html;获取请求资源文件的属性信息,并根据属性信息判断是否需要使用CGI模式处理;获取请求资源文件的后缀,进行CGI或非CGI处理。
// 处理请求
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;
}
// 判断请求是get还是post,设置cgi,_path,_query_string
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, "?");
LOG(INFO, "GET方法分割路径和参数");
_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
{
// 只是为了代码完整性
}
// 为请求资源路径拼接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] == '/')
{
_http_request._path += HOME_PAGE; // 拼接上该目录下的index.html
}
LOG(INFO, _http_request._path);
//获取请求资源文件的属性信息
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处理
// CGI为true就三种情况,GET方法的uri带参(_query_string),或者POST方法,又或者请求的资源是一个可执行程序
if (_http_request._cgi == true)
{
code = ProcessCgi(); // 以CGI的方式进行处理
}
else
{
code = ProcessNonCgi(); // 简单的网页返回,返回静态网页
}
}
CGI处理
CGI处理时需要创建子进程进行进程程序替换,但是在创建子进程之前需要先创建两个匿名管道。这里站在父进程角度对这两个管道进行命名,父进程用于读取数据的管道叫做input,父进程用于写入数据的管道叫做output。
创建匿名管道并创建子进程后,需要父子进程各自关闭两个管道对应的读写端:
- 对于父进程来说,input管道是用来读数据的,因此父进程需要保留input[0]关闭input[1],而output管道是用来写数据的,因此父进程需要保留output[1]关闭output[0]。
- 对于子进程来说,input管道是用来写数据的,因此子进程需要保留input[1]关闭input[0],而output管道是用来读数据的,因此子进程需要保留output[0]关闭output[1]。
此时父子进程之间的通信信道已经建立好了,但为了让替换后的CGI程序从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据,因此在子进程进行进程程序替换之前,还需要对子进程进行重定向。
假设子进程保留的input[1]和output[0]对应的文件描述符分别是3和4,那么子进程对应的文件描述符表的指向大致如下:
现在我们要做的就是将子进程的标准输入重定向到output管道,将子进程的标准输出重定向到input管道,也就是让子进程的0号文件描述符指向output管道,让子进程的1号文件描述符指向input管道。
此外,在子进程进行进程程序替换之前,还需要进行各种参数的传递:
- 首先需要将请求方法通过putenv函数导入环境变量,以供CGI程序判断应该以哪种方式读取父进程传递过来的参数。
- 如果请求方法为GET方法,则需要将URL中携带的参数通过导入环境变量的方式传递给CGI程序。
- 如果请求方法为POST方法,则需要将请求正文的长度通过导入环境变量的方式传递给CGI程序,以供CGI程序判断应该从管道读取多少个参数。
此时子进程就可以进行进程程序替换了,而父进程需要做如下工作:
- 如果请求方法为POST方法,则父进程需要将请求正文中的参数写入管道中,以供被替换后的CGI程序进行读取。
- 然后父进程要做的就是不断调用read函数,从管道中读取CGI程序写入的处理结果,并将其保存到HTTP响应类的response_body当中。
- 管道中的数据读取完毕后,父进程需要调用waitpid函数等待CGI程序退出,并关闭两个管道对应的文件描述符,防止文件描述符泄露。
// CGI = true,处理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、创建两个匿名管道(管道命名站在父进程角度)
// 在调用 pipe 函数创建管道成功后,pipefd[0] 用于读取数据,pipefd[1] 用于写入数据。
// 1.1 创建从子进程到父进程的通信信道
int input[2];
if(pipe(input) < 0){ // 管道创建失败,pipe()返回-1
LOG(ERROR, "pipe input error!");
code = INTERNAL_SERVER_ERROR;
return code;
}
// 1.2 创建从父进程到子进程的通信信道
int output[2];
if(pipe(output) < 0){ // 管道创建失败,pipe()返回-1
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); // 替换失败则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程序)退出
// status 保存退出状态
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处理
非CGI处理时只需要将客户端请求的资源构建成HTTP响应发送给客户端即可,理论上这里要做的就是打开目标文件,将文件中的内容读取到HTTP响应类的response_body中,以供后续发送HTTP响应时进行发送即可,但这种做法还可以优化。
因为HTTP响应类的response_body属于用户层的缓冲区,而目标文件是存储在服务器的磁盘上的,按照这种方式需要先将文件内容读取到内核层缓冲区,再由操作系统将其拷贝到用户层缓冲区,发送响应正文的时候又需要先将其拷贝到内核层缓冲区,再由操作系统将其发送给对应的网卡进行发送。我们完全可以调用sendfile函数直接将磁盘当中的目标文件内容读取到内核,再由内核将其发送给对应的网卡进行发送。
sendfile函数是一个系统调用函数,用于将一个文件描述符指向的文件内容直接发送给另一个文件描述符指向的套接字,从而实现了零拷贝(Zero Copy)技术。这种技术避免了数据在用户态和内核态之间的多次拷贝,从而提高了数据传输效率。
sendfile函数的使用场景通常是在Web服务器中,用于将静态文件直接发送给客户端浏览器,从而避免了将文件内容复制到用户空间的过程。在Linux系统中,sendfile函数的原型为:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
其中,out_fd表示目标文件描述符,in_fd表示源文件描述符,offset表示源文件偏移量,count表示要发送的字节数。函数返回值表示成功发送的字节数,如果返回-1则表示出现了错误。
// CGI = false
int ProcessNonCgi()
{
// 打开客户端请求的资源文件,以供后续发送
_http_response._fd = open(_http_request._path.c_str(), O_RDONLY);
if(_http_response._fd >= 0){ // 打开文件成功
return OK;
}
return INTERNAL_SERVER_ERROR; // 打开文件失败
}
构建HTTP响应
构建 HTTP 响应报文,首先根据响应的状态码构建状态行(包含 HTTP 版本、状态码和状态码描述),然后根据状态码分别构建不同的响应报头和响应正文。如果状态码为 200 OK,则调用 BuildOkResponse() 函数构建成功的响应报头和响应正文;如果状态码为 404 NOT FOUND、400 BAD REQUEST 或 500 INTERNAL SERVER ERROR,则根据不同的状态码构建相应的错误响应报头和响应正文,并调用 HandlerError() 函数处理错误。
// 构建响应
void BulidHttpResponse()
{
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;
}
}
发送HTTP响应
发送HTTP响应的步骤如下:
- 调用send函数,依次发送状态行、响应报头和空行。
- 发送响应正文时需要判断本次请求的处理方式,如果本次请求是以CGI方式成功处理的,那么待发送的响应正文是保存在HTTP响应类的response_body中的,此时调用send函数进行发送即可。
- 如果本次请求是以非CGI方式处理或在处理过程中出错的,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP响应类的fd中的,此时调用sendfile进行发送即可,发送后关闭对应的文件描述符。
// 发送响应
bool SendHttpResponse()
{
//发送状态行
if(send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0) <= 0)
{
_stop = true; //发送失败,设置_stop
}
//发送响应报头
if(!_stop){
for(auto& iter : _http_response._response_header)
{
if(send(_sock, iter.c_str(), iter.size(), 0) <= 0)
{
_stop = true; //发送失败,设置_stop
break;
}
}
}
//发送空行
if(!_stop)
{
if(send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0) <= 0)
{
_stop = true; //发送失败,设置_stop
}
}
//发送响应正文
if(_http_request._cgi)
{
if(!_stop)
{
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
{
if(!_stop)
{
// sendfile:这是一个系统调用,用于高效地从文件传输数据到套接字中。它避免了在内核空间和用户空间之间复制数据的需求,从而实现更快的数据传输。
if(sendfile(_sock, _http_response._fd, nullptr, _http_response._size) <= 0)
{
_stop = true; //发送失败,设置_stop
}
}
//关闭请求的资源文件
close(_http_response._fd);
}
return _stop;
}
接入线程池
当前多线程版服务器存在的问题:
- 每当获取到新连接时,服务器主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁,这样做不仅麻烦,而且效率低下。
- 如果同时有大量的客户端连接请求,此时服务器就要为每一个客户端创建对应的服务线程,而计算机中的线程越多,CPU压力就越大,因为CPU要不断在这些线程之间来回切换。此外,一旦线程过多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也就迟迟得不到应答。
考虑接入线程池简单优化下(其实也可以直接上epoll)
- 在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中。
- 线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理。
#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;
//判断任务队列是否为空
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;
简单测试
默认页面测试:
带query_string,CGI传参测试:
项目扩展
当前项目的重点在于HTTP服务器后端的处理逻辑,主要完成的是GET和POST请求方法,以及CGI机制的搭建。还可以进行不少扩展,比如:
- 当前项目编写的是HTTP1.0版本的服务器,每次连接都只会对一个请求进行处理,当服务器对客户端的请求处理完毕并收到客户端的应答后,就会直接断开连接。可以将其扩展为HTTP1.1版本,让服务器支持长连接,即通过一条连接可以对多个请求进行处理,避免重复建立连接(涉及连接管理)。
- 当前项目虽然在后端接入了线程池,但是效果有限,可以将线程池换成epoll版本,让服务器的IO变得更高效。
- 可以给当前的HTTP服务器新增代理功能,也就是可以替代客户端去访问某种服务,然后将访问结果再返回给客户端(比如课题中的数据备份、数据计算等等)。