目录
引言
目前主流的服务器协议是Http/1.1,而我们这次要实现的是1.0,其主要的特点就是短链接,所谓短链接,就是请求,响应,客户端关闭连接,这样就完成了一次http请求,使用其主要的原因是因为其简单。
Http/1.0版本的特征:
1、简单快速,HTTP服务器的程序规模小,因而通信速度很快。
2、灵活,HTTP允许传输任意类型的数据对象,正在传输的类型由Content-Type加以标记。
3、无连接,每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。
4.无状态,本身是不会记录对方任何状态。 http/1.1 虽然也是无状态的协议,但是为了保持状态的功能,引入了 cookie 技术。
项目介绍
本项目实现的是一个HTTP服务器,项目中将会通过基本的网络套接字读取客户端发来的HTTP请求并进行分析,最终构建HTTP响应并返回给客户端。
HTTP在网络应用层中的地位是不可撼动的,无论是移动端还是PC端浏览器,HTTP无疑是打开互联网应用窗口的重要协议。
该项目采用C/S(BS)模型,实现支持较为小型应用的http服务。做完项目后可以明显的对打开浏览器上网、关闭浏览器中,很多技术上细节的实现这样的概况有一定的了解。
该项目主要涉及C/C++、HTTP协议、网络套接字编程、CGI、单例模式、多线程、线程池等方面的技术。
网络协议栈介绍
协议分层
网络协议栈的分层情况:
网络协议栈中各层的功能如下:
1、应用层:根据特定的通信目的,对数据进行分析处理,以达到某种业务性的目的。
2、传输层:处理传输时遇到的问题,主要是保证数据传输的可靠性。
3、网络层:完成数据的转发,解决数据去哪里的问题。
4、链路层:负责数据真正的发生过程。
数据的封装和分用
数据的封装和分用的过程如下:
HTTP协议介绍
HTTP(Hyper Text Transfer Protocol)协议又叫做超文本传输协议,是一个简单的请求-响应协议,HTTP通常运行在TCP之上。
URL/URI
URI:是uniform resource identifier,统一资源标识符,用来唯一的标识一个资源
URL:是uniform resource locator,统一资源定位符,它是一种具体的URI,即URL可以用来标识一个资源,而且还指明了如何locate这个资源。
URL(Uniform Resource Lacator)叫做统一资源定位符,也就是我们通常所说的网址,是因特网的万维网服务程序上用于指定信息位置的表示方法。
一个URL大概由以下几部分组成:
http://:表示的是协议名称,表示请求时需要使用的协议,通常使用的是HTTP协议或安全协议HTTPS。HTTPS是以安全为目标的HTTP通道,在HTTP的基础上通过传输加密和身份认证保证了传输过程的安全性。
usr:pass:表示的是登录认证信息,包括登录用户的用户名和密码。虽然登录认证信息可以在URL中体现出来,但绝大多数URL的这个字段都是被省略的,因为登录信息可以通过其他方案交付给服务器。
www.example.jp:表示的是服务器地址,也叫做域名,比如www.alibaba.com,www.qq.com,www.baidu.com。
80:表示的是服务器端口号。HTTP协议和套接字编程一样都是位于应用层的,在进行套接字编程时我们需要给服务器绑定对应的IP和端口,而这里的应用层协议也同样需要有明确的端口号。
/dir/index.htm:表示的是要访问的资源所在的路径。访问服务器的目的是获取服务器上的某种资源,通过前面的域名和端口已经能够找到对应的服务器进程了,此时要做的就是指明该资源所在的路径。
uid=1:表示的是请求时提供的额外的参数,这些参数是以键值对的形式,通过&符号分隔开的。
ch1:表示的是片段标识符,是对资源的部分补充。
TcpServer结构设计
我们可以将套接字相关的代码封装到TcpServer类中,在初始化TcpServer对象时完成套接字的创建、绑定和监听动作,并向外提供一个Sock接口用于获取监听套接字。同时,将TcpServer设置成单例模式。
并且设计成单例模式,保障此类程序中只有一个,使用的是懒汉模式
#pragma once
#include"Common.hpp"
#include "Log.hpp"
#define BACKLOG 5
//单例模式==饿汉模式
class TcpServer
{
private:
int port; //端口号
int listen_sock; //监听套接字
static TcpServer* svr; //指向单例对象指针
private:
TcpServer(int _port):port(_port),listen_sock(-1)
{}
TcpServer(const TcpServer& s)=delete;
public:
//获取实例
static TcpServer* getinstance(int port)
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//出作用域自动销毁
if(svr==nullptr)
{
pthread_mutex_lock(&lock);
if(svr==nullptr)// 为什么要两个if?避免重复创建对象
{
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,"create 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; //IPv4
local.sin_port=htons(port); //转换网络字节序
local.sin_addr.s_addr=INADDR_ANY;//云服务器不能直接绑定公网ip
if(bind(listen_sock,(struct sockaddr*)&local,sizeof(local))<0)
{
LOG(FLTAL,"bind socket error!");
exit(2);
}
LOG(INFO,"bind socket ... success");
}
//监听
void Listen()
{
if(listen(listen_sock,BACKLOG)<0)
{
LOG(FLTAL,"listen socket error!");
exit(3);
}
LOG(INFO,"listen socket ... success");
}
//获取监听套接字
int Sock()
{
return listen_sock;
}
~TcpServer()
{
if(listen_sock>=0) close(listen_sock);
}
};
//单例初始化
TcpServer* TcpServer::svr=nullptr;
如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显式绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序列的转换。
在第一次调用GetInstance获取单例对象时需要创建单例对象,这时需要定义一个锁来保证线程安全,代码中以PTHREAD_MUTEX_INITIALIZER方式定义的静态锁是不需要释放的,同时为了保证后续调用GetInstance获取单例对象时不会频繁的加锁解锁,因此代码中以双检查的方式进行加锁。
Log日志
服务器在运作时会产生一些日志,这些日志会记录下服务器运行过程中产生的一些事件。
日志级别:INFO(成功)WARNING(警告)ERROR(错误)FATAL(崩溃)
时间戳: 事件产生的时间。
日志信息: 事件产生的日志信息。
错误文件名称: 事件在哪一个文件产生。
行数: 事件在对应文件的哪一行产生。
为避免手动传入参数,我们使用的LOG宏函数,LOG的参数level在预处理后变成数字,为了保证其成为子串在前面加"#"即可。
#pragma once
#include"Common.hpp"
#define INFO 1 //成功
#define WARNING 2 //警告
#define ERROR 3 //错误
#define FATAL 4 //崩溃
//编译器的内置宏:__FILE__ 包含当前程序文件名的字符串__LINE__ 表示当前行号的整数
#define LOG(level,message) Log(#level,message,__FILE__,__LINE__)
//打印日志
void Log(string level,string message,string file_name,int line)
{
cout<<"【"<<level<<"】"<<" "<<"【"<<time(nullptr)<<"】"<<" "<<"【"<<message<<"】"<<" "<<"【"<<file_name<<"】"<<" "<<"【"<<line<<"】"<<endl;
}
HttpServer
我们可以将HTTP服务器封装成一个HttpServer类,在构造HttpServer对象时传入一个端口号,之后就可以调用Loop让服务器运行起来了。服务器运行起来后要做的就是,先获取单例对象TcpServer中的监听套接字,然后不断从监听套接字中获取新连接,每当获取到一个新连接后就创建一个新线程为该连接提供服务。
#pragma once
#include"Common.hpp"
#include"TcpServer.hpp"
#include"Log.hpp"
#include"Task.hpp"
#include"ThreadPool.hpp"
#define PORT 8081
class HttpServer
{
private:
int port;
bool stop;
public:
HttpServer(int _port=PORT):port(PORT),stop(false)
{}
void InitHttpServer()
{
//信号SIGPIPE需要进行忽略,如果不忽略,再写入的时候可能会崩溃;
signal(SIGPIPE,SIG_IGN);
}
//循环
void Loop()
{
TcpServer* tcvr=TcpServer::getinstance(port);
LOG(INFO,"Loop Begin");
while(!stop)
{
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
int sock=accept(tcvr->Sock(),(struct sockaddr*)&peer,&len);//等待请求
if(sock<0)
{
continue;
}
LOG(INFO,"Get a new link");
Task task(sock);
ThreadPool::getinstance()->PushTask(task);
}
}
~HttpServer()
{}
};
main.cc
主函数代码:运行服务器时要求指定服务器的端口号,我们用这个端口号创建一HttpServer对象,然后调用Loop函数运行服务器,此时服务器就会不断获取新连接并创建新线程来处理连接。
#include"Common.hpp"
#include"HttpServer.hpp"
static void Usage(string proc)
{
cout<<"Usage:\n\t" <<proc<<" port"<<endl;
}
int main(int argc,char* argv[])
{
if(argc!=2)
{
Usage(argv[0]);
exit(4);
}
int port=atoi(argv[1]); //提取端口号
std::shared_ptr<HttpServer> http_server(new HttpServer(port)); //智能指针方便析构释放
http_server->InitHttpServer();
http_server->Loop();
return 0;
}
HTT层结构
左边的是请求报文:请求行 请求报头 空行 请求正文
请求行:[请求方法] + [URI] + [HTTP版本]
请求报头:记录了报文的状况,比如请求正文的长度,类型等
空行:请求报头和正文分界线
请求正文:GET方法没有正文,POST可能会有,取决于报头是否有正文长度信息
右边的是响应报文:状态行 响应报头 空行 响应正文
状态行:[HTTP版本] + [状态码] + [状态码描述]
响应报头:记录了报文的状况,比如请求正文的长度,类型等
空行:区分报头和正文
响应正文:要返回给浏览器的资源信息
C/S模型框架
Protocol协议
上面的流程步骤都在Protocol里完成
HttpRequest(请求报文)
定义一个HTTP请求类,读取到的HTTP请求的数据就存储在这个类当中,解析HTTP请求后得到的数据也存储在这个类当中。
//请求
class HttpRequest
{
public:
string request_line; //读取行
vector<string> request_header; //读取报头
string blank; //空行
string request_body; //读取正文
//解析完毕之后的结果
//解析行
string method; //方法
string uri; //资源 path ? args
string version;//版本
//解析报头
unordered_map<string,string> header_kv;
int content_length=0; //正文长度--POST
string path; //路径(资源)
string suffix; //正文类型(后缀)
string query_string; //可能存在参数
bool cgi=false; //是否执行cgi
int size; //资源的大小
};
HttpResponse(响应报文)
HTTP响应也可以封装成一个类,这个类当中包括HTTP响应的内容以及构建HTTP响应所需要的数据。后续构建响应时就可以定义一个HTTP响应类,构建响应需要使用的数据就存储在这个类当中,构建后得到的响应内容也存储在这个类当中。
//响应
class HttpResponse
{
public:
string status_line; //状态行
vector<string> response_header; //分析报头
string blank=LIND_END; //空行
string response_body; //分析正文
int status_code=OK; //状态码
int fd=-1; //文件
};
EndPoint(处理程序)
EndPoint这个词经常用来描述进程间通信,比如在客户端和服务器通信时,客户端是一个EndPoint,服务器则是另一个EndPoint,因此这里将处理请求的类取名为EndPoint。
//终端
//读取请求,分析请求,构建响应
//IO通信
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()
{}
};
CallBack(回调函数)
线程回调设计:
服务器每获取到一个新连接就会创建一个新线程来进行处理,而这个线程要做的实际就是定义一个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;
}
};
读取分析HTTP请求
读取HTTP请求的同时可以对HTTP请求进行解析,这里我们分为五个步骤,分别是读取请求行、读取请求报头和空行、解析请求行、解析请求报头、读取请求正文。
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
public:
//读取请求
void RecvHttpRequest()
{
RecvHttpRequestLine(); //读取请求行
RecvHttpRequestHeader(); //读取请求报头和空行
ParseHttpRequestLine(); //解析请求行
ParseHttpRequestHeader(); //解析请求报头
RecvHttpRequestBody(); //读取请求正文
}
};
RecvHttpRequestLine
读取请求行就是从套接字中读取一行内容存储到HTTP请求类中的request_line中。
//服务端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
}
}
};
需要注意的是,这里在按行读取HTTP请求时,不能直接使用C/C++提供的gets或getline函数进行读取,因为不同平台下的行分隔符可能是不一样的,可能是\r、\n或者\r\n。
因此我们这里需要自己写一个ReadLine函数,以确保能够兼容这三种行分隔符。我们可以把这个函数写到一个工具类当中,后续编写的处理字符串的函数也都写到这个类当中。
ReadLine函数的处理逻辑如下:
从指定套接字中读取一个个字符。
如果读取到的字符既不是 \n也不是 \r,则将读取到的字符push到用户提供的缓冲区后继续读取下一个字符。
如果读取到的字符是\n,则说明行分隔符是 \n,此时将\n push到用户提供的缓冲区后停止读取。
如果读取到的字符是\r,则需要继续窥探下一个字符是否是\n,如果窥探成功则说明行分隔符为\r\n,此时将未读取的\n读取上来后,将\n push到用户提供的缓冲区后停止读取;如果窥探失败则说明行分隔符是\r,此时也将\n push到用户提供的缓冲区后停止读取。
也就是说,无论是哪一种行分隔符,最终读取完一行后我们都把 \n push到了用户提供的缓冲区当中,相当于将这三种行分隔符统一转换成了以 \n 为行分隔符,只不过最终我们把\n一同读取到了用户提供的缓冲区中罢了,因此如果调用者不需要读取上来的 \n,需要后续自行将其去掉。
Util(工具类)
ReadLine
//工具类
class Util {
public:
static int ReadLine(int sock, std::string& out)
{
char ch = 'X';
while (ch != '\n') {
ssize_t s = recv(sock, &ch, 1, 0);
if (s > 0) {
if (ch == '\r') {
recv(sock, &ch, 1, MSG_PEEK);
if (ch == '\n') {
//把\r\n->\n
//窥探成功,这个字符一定存在
recv(sock, &ch, 1, 0);
}
else {
ch = '\n';
}
}
//1. 普通字符
//2. \n
out.push_back(ch);
}
else if (s == 0) {
return 0;
}
else {
return -1;
}
}
return out.size();
}
}
recv函数的最后一个参数如果设置为MSG_PEEK
,那么recv函数将返回TCP接收缓冲区头部指定字节个数的数据,但是并不把这些数据从TCP接收缓冲区中取走,这个叫做数据的窥探功能。
RecvHttpRequestHeader
由于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);
}
}
};
ParseHttpRequestLine
解析请求行要做的就是将请求行中的请求方法、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);
}
};
ParseHttpRequestHeader
解析请求报头要做的就是将读取到的一行一行的请求报头,以":"为分隔符拆分成一个个的键值对存储到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提取切割后的子字符串即可。
CutString(切割字符串)
//工具类
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;
}
};
在读取请求正文之前,首先需要通过本次的请求方法来判断是否需要读取请求正文,因为只有请求方法是POST方法才可能会有请求正文,此外,如果请求方法为POST,我们还需要通过请求报头中的Content-Length属性来得知请求正文的长度。
在得知需要读取请求正文以及请求正文的长度后,就可以将请求正文读取到HTTP请求类的request_body中了。
RecvHttpRequestBody
//服务端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响应
定义状态码
在处理请求的过程中可能会因为某些原因而直接停止处理,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。为了告知客户端本次HTTP请求的处理情况,服务器需要定义不同的状态码,当处理请求被终止时就可以设置对应的状态码,后续构建HTTP响应的时候就可以根据状态码返回对应的错误页面。
#define OK 200 //状态码
#define NOT_FOUND 404 //找不到页面
#define BAD_REQUEST 400 //方法错误
#define SERVER_ERROR 500 //服务器错误
处理HTTP请求的步骤如下:
1、判断请求方法是否是正确,如果不正确则设置状态码为BAD_REQUEST后停止处理。
2、如果请求方法为GET方法,则需要判断URI中是否带参。如果URI不带参,则说明URI即为客户端请求的资源路径;如果URI带参,则需要以?为分隔符对URI进行字符串切分,切分后?左边的内容就是客户端请求的资源路径,而?右边的内容则是GET方法携带的参数,由于此时GET方法携带了参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
3、如果请求方法为POST方法,则说明URI即为客户端请求的资源路径,由于POST方法会通过请求正文上传参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
4、接下来需要对客户端请求的资源路径进行处理,首先需要在请求的资源路径前拼接上web根目录,然后需要判断请求资源路径的最后一个字符是否是/,默认将该目录下的index.html返回给客户端,因此这时还需要在请求资源路径的后面拼接上index.html。
5、对请求资源的路径进行处理后,需要通过stat函数获取客户端请求资源文件的属性信息。如果客户端请求的是一个目录,这时服务器不会将该目录下全部的资源都返回给客户端,而是默认将该目录下的index.html返回给客户端,则需要在请求资源路径的后面拼接上/index.html并重新获取资源文件的属性信息;如果客户端请求的是一个可执行程序,则说明后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。
BuildHttpResponse
根据HTTP请求类中的cgi分别进行CGI或非CGI处理
void BuildHttpResponse() //构建http响应
{
auto& code=http_response.status_code;
struct stat st;
size_t found=0;
if(http_request.method!="GET" && http_request.method!="POST")
{
LOG(WARNING,"method is not right");
code=BAD_REQUEST;
goto END;
}
if(http_request.method=="GET")
{
size_t pos=http_request.uri.find('?');
if(pos!=string::npos) //有参
{
Util::CutString(http_request.uri,http_request.path,http_request.query_string,"?"); //分割字串
http_request.cgi=true;
}
else
{
http_request.path=http_request.uri; //没有参数,路径等于资源
}
}
else if(http_request.method=="POST")
{
http_request.cgi=true;
http_request.path=http_request.uri;
}
else
{
//其他
}
//重构路径
http_request.path=WEB_ROOT+http_request.path; //将 ‘/‘ 转换成web根目录
if(http_request.path[http_request.path.size()-1]=='/') //判断是否为 / ,转跳到页面
{
http_request.path+=HOME_PAGE;
}
if(stat(http_request.path.c_str(),&st)==0) //获取文件属性
{
//说明资源是存在的
if(S_ISDIR(st.st_mode)) //判断是否目录
{
//说明请求的资源是一个目录,里面可能有几十个网页,所以是不被允许的
//目录不会以’/‘结尾
http_request.path+="/";
http_request.path+=HOME_PAGE; //首页
stat(http_request.path.c_str(),&st); //重新获取一下属性
}
if((st.st_mode&S_IXUSR) || (st.st_mode&S_IXGRP) || (st.st_mode&S_IXOTH)) //判断是否可执行程序
{
http_request.cgi=true;
}
http_request.size=st.st_size;
}
else
{
//资源不存在
LOG(WARNING,http_request.path+" Not Found!");
code=NOT_FOUND;
goto END;
}
found=http_request.path.rfind("."); //获取后缀
if(found==string::npos)
{
http_request.suffix=".html";
}
else http_request.suffix=http_request.path.substr(found);
if(http_request.cgi)
{
code=ProcessCgi(); //执行目标程序,拿到结果: http_response.response_body
}
else
{
//目标网页一定是存在的
//并不是单单返回网页,而是要构建HTTP响应
code=ProcessNonCgi(); //简单的网页返回,返回静态网页
}
END:
BuildHttpResponseHelper(); //构建响应
}
1、本项目实现的HTTP服务器只支持GET方法和POST方法,因此如果客户端发来的HTTP请求中不是这两种方法则认为请求方法错误,如果想让服务器支持其他的请求方法则直接增加对应的逻辑即可。
2、服务器向外提供的资源都会放在web根目录下,比如网页、图片、视频等资源,本项目中的web根目录取名为wwwroot。web根目录下的所有子目录下都会有一个首页文件,当用户请求的资源是一个目录时,就会默认返回该目录下的首页文件,本项目中的首页文件取名为index.html。
3、stat是一个系统调用函数,它可以获取指定文件的属性信息,包括文件的inode编号、文件的权限、文件的大小等。如果调用stat函数获取文件的属性信息失败,则可以认为客户端请求的这个资源文件不存在,此时直接设置状态码为NOT_FOUND后停止处理即可。
4、当获取文件的属性信息后发现该文件是一个目录,此时请求资源路径一定不是以/结尾的,因为在此之前已经对/结尾的请求资源路径进行过处理了,因此这时需要给请求资源路径拼接上/index.html。
5、只要一个文件的拥有者、所属组、other其中一个具有可执行权限,则说明这是一个可执行文件,此时就需要将HTTP请求类中的cgi设置为true。
6、由于后续构建HTTP响应时需要用到请求资源文件的后缀,因此代码中对请求资源路径通过从后往前找.的方式,来获取请求资源文件的后缀,如果没有找到.则默认请求资源的后缀为.html。
7、由于请求资源文件的大小后续可能会用到,因此在获取到请求资源文件的属性后,可以将请求资源文件的大小保存到HTTP请求类的size中。
CGI机制
什么是CGI机制?
我们在HTTP的角度来看,实际上HTTP对于可执行文件是不会直接处理的,他会把客户端传来的参数传递给其可执行程序,可执行程序进行程序的执行,当结束的时候,再把处理的结果返回给我们的HTTP,HTTP拿到处理结果再返回给我们的客户端,这套机制就是CGI机制
但是这个时候又有一个问题了,就是说我们CGI这个机制是一个程序,当被加载到内存的时候是一个进程,我们怎么通过进程来去执行另一部分的程序呢?——这就用到了程序替换,但是直接替换的话,我们之前的代码就都被覆盖了,我们肯定不能直接替换,那么应该怎么做呢,我们可以来创建子进程帮助我们完成程序替换,然后再把结果返回给我们。
这个时候问题又来了,我们需要执行的资源在哪里?简而言之,经过了前面的分析之后,请求资源可执行程序其实就是我们的路径。
对于GET方法而言:就是路径path+query_string。
对于POST的方法而言:就是路径path+body(传参)。
CGI处理时需要创建子进程进行进程程序替换,但是在创建子进程之前需要先创建两个匿名管道。这里站在父进程角度对这两个管道进行命名,父进程用于读取数据的管道叫做input,父进程用于写入数据的管道叫做output。
创建匿名管道并创建子进程后,需要父子进程各自关闭两个管道对应的读写端:
1、对于父进程来说,input管道是用来读数据的,因此父进程需要保留input[0]关闭input[1],而output管道是用来写数据的,因此父进程需要保留output[1]关闭output[0]。
2、对于子进程来说,input管道是用来写数据的,因此子进程需要保留input[1]关闭input[0],而output管道是用来读数据的,因此子进程需要保留output[0]关闭output[1]。
此时父子进程之间的通信信道已经建立好了,但为了让替换后的CGI程序从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据,因此在子进程进行进程程序替换之前,还需要对子进程进行重定向。
现在我们要做的就是将子进程的标准输入重定向到output管道,将子进程的标准输出重定向到input管道,也就是让子进程的0号文件描述符指向output管道,让子进程的1号文件描述符指向input管道
此外,在子进程进行进程程序替换之前,还需要进行各种参数的传递:
首先需要将请求方法通过putenv函数导入环境变量,以供CGI程序判断应该以哪种方式读取父进程传递过来的参数。
如果请求方法为GET方法,则需要将URL中携带的参数通过导入环境变量的方式传递给CGI程序。
如果请求方法为POST方法,则需要将请求正文的长度通过导入环境变量的方式传递给CGI程序,以供CGI程序判断应该从管道读取多少个参数。
如果请求方法为POST方法,则父进程需要将请求正文中的参数写入管道中,以供被替换后的CGI程序进行读取。
然后父进程要做的就是不断调用read函数,从管道中读取CGI程序写入的处理结果,并将其保存到HTTP响应类的response_body当中。
管道中的数据读取完毕后,父进程需要调用waitpid函数等待CGI程序退出,并关闭两个管道对应的文件描述符,防止文件描述符泄露。
ProcessCgi
int ProcessCgi()
{
LOG(INFO, "process cgi mthod!");
int code=OK;
//父进程数据
auto& method=http_request.method;
auto& query_string=http_request.query_string; //GET
auto& body_text=http_request.request_body; //POST
auto& bin=http_request.path; //让子进程执行的目标程序一定存在
int content_length= http_request.content_length; //正文长度
auto& response_body= http_response.response_body; //正文
string query_string_env; //环境变量
string method_env;
string content_length_env;
//站在父进程角度
int input[2]; //输入
int output[2]; //输出
if(pipe(input)<0)
{
LOG(ERROR,"pipe input error!");
code=SERVER_ERROR;
return code;
}
if(pipe(output)<0)
{
LOG(ERROR,"pipe output error!");
code=SERVER_ERROR;
return code;
}
//新线程,但是从头到尾都只有一个进程httpserver
pid_t pid=fork();
if(pid==0)//child
{
close(input[0]);
close(output[1]);
method_env="METHOD="; //方法也要加入环境变量,环境变量是全局的并且不受程序替换影响
method_env+=method;
putenv((char*)method_env.c_str()); //c_str()是const char*
if(method=="GET")
{
query_string_env="QUERY_STRING=";
query_string_env+=query_string;
putenv((char*)query_string_env.c_str()); //加入环境变量
LOG(INFO,"GET method add query_string_env");
}
else if(method=="POST")
{
content_length_env="CONTENT_LENGTH=";
content_length_env+=to_string(content_length);
putenv((char*)content_length_env.c_str());
LOG(INFO,"post method add content_length_env");
}
else
{
//not
}
cout<<"bin :"<<bin<<endl;
//站在子进程角度
//input[1]:写出-》1
//output[0]:读入-》0
//重定向文件描述符
dup2(input[1],1); //标准输入重定向到管道的输入
dup2(output[0],0); //标准输出重定向到管道的输出
execl(bin.c_str(),bin.c_str(),nullptr); //程序替换
exit(1); //如果替换成功到不了这一步
}
else if(pid<0)
{
LOG(ERROR,"pid fork error!");
code=404;
return code;
}
else
{
close(input[1]);
close(output[0]);
if(method=="POST")//将正文中的参数通过管道传递给CGI程序
{
const char* start=body_text.c_str();//请求正文
int total=0; //总共写入了多少
int size=0; //本次写入了多少
while(total<content_length && (size = write(output[1] , start+total , body_text.size()-total) >0) )
{
total+=size;
}
}
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(WIFSIGNALED(status)==0) //结果是否正确
code=OK;
else code=BAD_REQUEST;
}
else code=SERVER_ERROR;
}
//关闭两个管道对应的文件描述符
close(input[0]);
close(output[1]);
}
return code;
}
1、在CGI处理过程中,如果管道创建失败或者子进程创建失败,则属于服务器端处理请求时出错,此时返回INTERNAL_SERVER_ERROR状态码后停止处理即可。
2、环境变量是key=value形式的,因此在调用putenv函数导入环境变量前需要先正确构建环境变量,此后被替换的CGI程序在调用getenv函数时,就可以通过key获取到对应的value。
3、子进程传递参数的代码最好放在重定向之前,否则服务器运行后无法看到传递参数对应的日志信息,因为日志是以cout的方式打印到标准输出的,而dup2函数调用后标准输出已经被重定向到了管道,此时打印的日志信息将会被写入管道。
4、父进程循环调用read函数从管道中读取CGI程序的处理结果,当CGI程序执行结束时相当于写端进程将写端关闭了(文件描述符的生命周期随进程),此时读端进程将管道当中的数据读完后,就会继续执行后续代码,而不会被阻塞。
5、父进程在等待子进程退出后,可以通过WIFEXITED判断子进程是否是正常退出,如果是正常退出再通过WEXITSTATUS判断处理结果是否正确,然后根据不同情况设置对应的状态码(此时就算子进程异常退出或处理结果不正确也不能立即返回,需要让父进程继续向后执行,关闭两个管道对应的文件描述符,防止文件描述符泄露)。
非CGI处理
非CGI处理时只需要将客户端请求的资源构建成HTTP响应发送给客户端即可,理论上这里要做的就是打开目标文件,将文件中的内容读取到HTTP响应类的response_body中,以供后续发送HTTP响应时进行发送即可,但我们并不推荐这种做法。
因为HTTP响应类的response_body属于用户层的缓冲区,而目标文件是存储在服务器的磁盘上的,按照这种方式需要先将文件内容读取到内核层缓冲区,再由操作系统将其拷贝到用户层缓冲区,发送响应正文的时候又需要先将其拷贝到内核层缓冲区,再由操作系统将其发送给对应的网卡进行发送。
可以看到上述过程涉及数据在用户层和内核层的来回拷贝,但实际这个拷贝操作是不需要的,我们完全可以直接将磁盘当中的目标文件内容读取到内核,再由内核将其发送给对应的网卡进行发送。
要达到上述效果就需要使用sendfile函数,该函数的功能就是将数据从一个文件描述符拷贝到另一个文件描述符,并且这个拷贝操作是在内核中完成的,因此sendfile比单纯的调用read和write更加高效。
但是需要注意的是,这里还不能直接调用sendfile函数,因为sendfile函数调用后文件内容就发送出去了,而我们应该构建HTTP响应后再进行发送,因此我们这里要做的仅仅是将要发送的目标文件打开即可,将打开文件对应的文件描述符保存到HTTP响应的fd当中。
int ProcessNonCgi() //简单的网页返回,返回静态网页
{
http_response.fd=open(http_request.path.c_str(),O_RDONLY); //打开只读文件
if(http_response.fd>=0)
{
LOG(INFO, http_request.path + " open success!");
return OK;
}
return 404;
}
BuildHttpResponseHelper(构建响应)
构建HTTP响应首先需要构建的就是状态行,状态行由状态码、状态码描述、HTTP版本构成,并以空格作为分隔符,将状态行构建好后保存到HTTP响应的status_line当中即可,而响应报头需要根据请求是否正常处理完毕分别进行构建。
void BuildHttpResponseHelper() //构建响应
{
auto& code=http_response.status_code;
//构建状态行
auto& status_line=http_response.status_line;
status_line+=HTTP_VERSION;
status_line+=" ";
status_line+=to_string(code);
status_line+=" ";
status_line+=Code2Desc(code);
status_line+=LIND_END;
//构建响应报头
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_404;
HandlerError(path);
break;
case SERVER_ERROR:
path+=PAGE_404;
HandlerError(path);
break;
default:
break;
}
}
本项目中将服务器的行分隔符设置为\r\n,在构建完状态行以及每行响应报头之后都需要加上对应的行分隔符,而在HTTP响应类的构造函数中已经将空行初始化为了LINE_END,因此在构建HTTP响应时不用处理空行。
对于状态行中的状态码描述,我们可以编写一个函数,该函数能够根据状态码返回对应的状态码描述。
//构建响应报头
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_404;
HandlerError(path);
break;
case SERVER_ERROR:
path+=PAGE_404;
HandlerError(path);
break;
default:
break;
}
构建HTTP的响应报头时,我们至少需要构建Content-Type和Content-Length这两个响应报头,分别用于告知对方响应资源的类型和响应资源的长度。
对于请求正常处理完毕的HTTP请求,需要根据客户端请求资源的后缀来得知返回资源的类型。而返回资源的大小需要根据该请求被处理的方式来得知,如果该请求是以非CGI方式进行处理的,那么返回资源的大小早已在获取请求资源属性时被保存到了HTTP响应类中的size当中,如果该请求是以CGI方式进行处理的,那么返回资源的大小应该是HTTP请求类中的response_body的大小。
//构建响应报文
void BuildOkResponse()
{
string line="Content-Type: ";
line+=suffix2Desc(http_request.suffix);
line+=LIND_END;
http_response.response_header.push_back(line);
line="Content-Length: ";
if(http_request.cgi)
{
line+=to_string(http_response.response_body.size());
}
else line+=to_string(http_request.size);//GET
line+=LIND_END;
http_response.response_header.push_back(line);
}
对于返回资源的类型,我们可以编写一个函数,该函数能够根据文件后缀返回对应的文件类型。查看Content-Type转化表可以得知后缀与文件类型的对应关系,将这个对应关系存储一个unordered_map容器中,当需要根据后缀得知文件类型时直接在这个unordered_map容器中进行查找,如果找到了则返回对应的文件类型,如果没有找到则默认该文件类型为text/html。
static string suffix2Desc(const string& suffix) //返回后缀类型
{
static unordered_map<string,string> suffix2desc={
{".html","text/html"},
{".css","text/css"},
{".js","application/javascript"},
{".jpg","application/x-jpg"},
{".xml","application/xml"},
};
auto itea=suffix2desc.find(suffix);
if(itea!=suffix2desc.end())
{
return itea->second;
}
return "text/html";
}
对于请求处理过程中出现错误的HTTP请求,服务器将会为其返回对应的错误页面,因此返回的资源类型就是text/html,而返回资源的大小可以通过获取错误页面对应的文件属性信息来得知。此外,为了后续发送响应时可以直接调用sendfile进行发送,这里需要将错误页面对应的文件打开,并将对应的文件描述符保存在HTTP响应类的fd当中。
void HandlerError(string page) //返回404页面
{
std::cerr<<"debug : "<<page<<endl;
http_request.cgi=false;
http_response.fd=open(page.c_str(),O_RDONLY);
if(http_response.fd>0)
{
struct stat st;
stat(page.c_str(),&st);
http_request.size=st.st_size;
string line="Content-Type: text/html";
line+=LIND_END;
http_response.response_header.push_back(line);
line="Content-Length: ";
line+=to_string(st.st_size);
line+=LIND_END;
http_response.response_header.push_back(line);
}
}
对于处理请求时出错的HTTP请求,需要将其HTTP请求类中的cgi重新设置为false,因为后续发送HTTP响应时,需要根据HTTP请求类中的cgi来进行响应正文的发送,当请求处理出错后要返回给客户端的本质就是一个错误页面文件,相当于是以非CGI方式进行处理的。
发送HTTP响应
发送HTTP响应的步骤如下:
1、调用send函数,依次发送状态行、响应报头和空行。
2、发送响应正文时需要判断本次请求的处理方式,如果本次请求是以CGI方式成功处理的,那么待发送的响应正文是保存在HTTP响应类的response_body中的,此时调用send函数进行发送即可。
3、如果本次请求是以非CGI方式处理或在处理过程中出错的,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP响应类的fd中的,此时调用sendfile进行发送即可,可以使用sendfile将数据从文件传递到套接字上,发送后关闭对应的文件描述符。
void SendHttpResponse() //发送http响应
{
send(sock,http_response.status_line.c_str(),http_response.status_line.size(),0); //发送状态行
for(auto itea:http_response.response_header) //发送报头
{
send(sock,itea.c_str(),itea.size(),0);
}
send(sock,http_response.blank.c_str(),http_response.blank.size(),0); //发送空行
//fd,body
if(http_request.cgi)
{
auto& response_body=http_response.response_body;
size_t size=0;
size_t total=0;
const char* start=response_body.c_str();
while(total<response_body.size() && (size=send(sock,start+total,response_body.size()-total,0))>0)
{
total+=size;
}
}
else
{
cout << ".............."<< http_request.size << endl;
sendfile(sock,http_response.fd,nullptr,http_request.size); //发送正文
//关闭请求的资源
close(http_response.fd);
}
}
差错处理
至此服务器逻辑其实已经已经走通了,但你会发现服务器在处理请求的过程中有时会莫名其妙的崩溃,根本原因就是当前服务器的错误处理还没有完全处理完毕。
逻辑错误
逻辑错误主要是服务器在处理请求的过程中出现的一些错误,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。逻辑错误其实我们已经处理过了,当出现这类错误时服务器会将对应的错误页面返回给客户端。
读取错误
逻辑错误是在服务器处理请求时可能出现的错误,而在服务器处理请求之前首先要做的是读取请求,在读取请求的过程中出现的错误就叫做读取错误,比如调用recv读取请求时出错或读取请求时对方连接关闭等。
出现读取错误时,意味着服务器都没有成功读取完客户端发来的HTTP请求,因此服务器也没有必要进行后续的处理请求、构建响应以及发送响应的相关操作了。
可以在EndPoint类中新增一个bool类型的stop成员,表示是否停止本次处理,stop的值默认设置为false,当读取请求出错时就直接设置stop为true并不再进行后续的读取操作,因此读取HTTP请求的代码需要稍作修改。
//服务端EndPoint
class EndPoint{
private:
int _sock; //通信的套接字
HttpRequest _http_request; //HTTP请求
HttpResponse _http_response; //HTTP响应
bool _stop; //是否停止本次处理
private:
//读取请求行
bool RecvHttpRequestLine()
{
auto& line = _http_request._request_line;
if(Util::ReadLine(_sock, line) > 0){
line.resize(line.size() - 1); //去掉读取上来的\n
}
else{ //读取出错,则停止本次处理
_stop = true;
}
return _stop;
}
//读取请求报头和空行
bool RecvHttpRequestHeader()
{
std::string line;
while(true){
line.clear(); //每次读取之前清空line
if(Util::ReadLine(_sock, line) <= 0){ //读取出错,则停止本次处理
_stop = true;
break;
}
if(line == "\n"){ //读取到了空行
_http_request._blank = line;
break;
}
//读取到一行请求报头
line.resize(line.size() - 1); //去掉读取上来的\n
_http_request._request_header.push_back(line);
}
return _stop;
}
//读取请求正文
bool 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{ //读取出错或对端关闭,则停止本次处理
_stop = true;
break;
}
}
}
return _stop;
}
public:
EndPoint(int sock)
:_sock(sock)
,_stop(false)
{}
//本次处理是否停止
bool IsStop()
{
return _stop;
}
//读取请求
void RecvHttpRequest()
{
if(!RecvHttpRequestLine()&&!RecvHttpRequestHeader()){ //短路求值
ParseHttpRequestLine();
ParseHttpRequestHeader();
RecvHttpRequestBody();
}
}
};
1、可以将读取请求行、读取请求报头和空行、读取请求正文对应函数的返回值改为bool类型,当读取请求行成功后再读取请求报头和空行,而当读取请求报头和空行成功后才需要进行后续的解析请求行、解析请求报头以及读取请求正文操作,这里利用到了逻辑运算符的短路求值策略。
2、EndPoint类当中提供了IsStop函数,用于让外部处理线程得知是否应该停止本次处理。
此时服务器创建的新线程在读取请求后,就需要判断是否应该停止本次处理,如果需要则不再进行处理请求、构建响应以及发送响应操作,而直接关闭于客户端建立的套接字即可。
class CallBack//回调函数
{
public:
CallBack()
{}
void operator()(int sock)
{
HandlerRequest(sock);
}
//处理程序请求
void HandlerRequest(int sock)
{
LOG(INFO,"Handler Request Begin");
//只会在debug模式下测试
#ifdef DEBUG
//查看请求报文
char buffer[4096];
recv(sock,buffer,sizeof(buffer),0);
cout<<"-----------------begin--------------------"<<endl;
cout<<buffer<<endl;
cout<<"------------------end---------------------"<<endl;
#else
EndPoint* ep=new EndPoint(sock);
ep->RecvHttpRequest(); //读取并分析http请求
if(!ep->IsStop())
{
LOG(INFO,"Recv Not Error ,Begin Build And Send ");
ep->BuildHttpResponse(); //构建http响应
ep->SendHttpResponse(); //发送http响应
}
else LOG(WARNING,"Recv Error,Stop Begin Build And Send ");
delete ep;
#endif
LOG(INFO,"Handler Request End");
}
~CallBack()
{}
此外,当服务器发送响应出错时会收到SIGPIPE信号,而该信号的默认处理动作是终止当前进程,为了防止服务器因为写入出错而被终止,需要在初始化HTTP服务器时调用signal函数忽略SIGPIPE信号。
//HTTP服务器
class HttpServer{
private:
int _port; //端口号
public:
//初始化服务器
void InitServer()
{
signal(SIGPIPE, SIG_IGN); //忽略SIGPIPE信号,防止写入时崩溃
}
};
接入线程池
当前多线程版服务器存在的问题:
每当获取到新连接时,服务器主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁,这样做不仅麻烦,而且效率低下。
如果同时有大量的客户端连接请求,此时服务器就要为每一个客户端创建对应的服务线程,而计算机中的线程越多,CPU压力就越大,因为CPU要不断在这些线程之间来回切换。此外,一旦线程过多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也就迟迟得不到应答。
这时可以在服务器端引入线程池:
在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中。
线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理。
Task(任务类)
当服务器获取到一个新连接后,需要将其封装成一个任务对象放到任务队列当中。任务类中首先需要有一个套接字,也就是与客户端进行通信的套接字,此外还需要有一个回调函数,当线程池中的线程获取到任务后就可以调用这个回调函数进行任务处理。
#pragma once
#include"Common.hpp"
#include"Protocol.hpp"
class Task
{
private:
int sock;
CallBack handler;
public:
Task()
{}
Task(int _sock):sock(_sock)
{}
void ProcessOn() //处理任务
{
handler(sock);
}
~Task()
{}
};
CallBack(任务回调)
class CallBack//回调函数
{
public:
CallBack()
{}
void operator()(int sock)
{
HandlerRequest(sock);
}
//处理程序请求
void HandlerRequest(int sock)
{
LOG(INFO,"Handler Request Begin");
//只会在debug模式下测试
#ifdef DEBUG
//查看请求报文
char buffer[4096];
recv(sock,buffer,sizeof(buffer),0);
cout<<"-----------------begin--------------------"<<endl;
cout<<buffer<<endl;
cout<<"------------------end---------------------"<<endl;
#else
EndPoint* ep=new EndPoint(sock);
ep->RecvHttpRequest(); //读取并分析http请求
if(!ep->IsStop())
{
LOG(INFO,"Recv Not Error ,Begin Build And Send ");
ep->BuildHttpResponse(); //构建http响应
ep->SendHttpResponse(); //发送http响应
}
else LOG(WARNING,"Recv Error,Stop Begin Build And Send ");
delete ep;
#endif
LOG(INFO,"Handler Request End");
}
~CallBack()
{}
};
编写线程池
设计线程池结构
可以将线程池设计成单例模式:
将ThreadPool类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。
提供一个指向单例对象的static指针,并在类外将其初始化为nullptr。
提供一个全局访问点获取单例对象,在单例对象第一次被获取时就创建这个单例对象并进行初始化。
ThreadPool类中的成员变量包括:
任务队列:用于暂时存储未被处理的任务对象。
num:表示线程池中线程的个数。
互斥锁:用于保证任务队列在多线程环境下的线程安全。
条件变量:当任务队列中没有任务时,让线程在该条件变量下进行等等,当任务队列中新增时,唤醒在该条件变量下进行等待的线程。
指向单例对象的指针:用于指向唯一的单例线程池对象。
ThreadPool类中的成员函数主要包括:
构造函数:完成互斥锁和条件变量的初始化操作。
析构函数:完成互斥锁和条件变量的释放操作。
nitThreadPool:初始化线程池时调用,完成线程池中若干线程的创建。
PushTask:生产任务时调用,将任务对象放入任务队列,并唤醒在条件变量下等待的一个线程进行处理。
PopTask:消费任务时调用,从任务队列中获取一个任务对象。
ThreadRoutine:线程池中每个线程的执行例程,完成线程分离后不断检测任务队列中是否有任务,如果有则调用PopTask获取任务进行处理,如果没有则进行休眠直到被唤醒。
GetInstance:获取单例线程池对象时调用,如果单例对象未创建则创建并初始化后返回,如果单例对象已经创建则直接返回单例对象。
#include"Common.hpp"
#include"Task.hpp"
#include"Log.hpp"
#define NUM 6
class ThreadPool
{
private:
int num; //线程数量
queue<Task> task_queue; //任务队列
pthread_mutex_t lock; //锁
pthread_cond_t cond; //条件变量
bool stop;
ThreadPool(int _num=NUM):num(_num),stop(false)
{
pthread_mutex_init(&lock,nullptr);
pthread_cond_init(&cond,nullptr);
}
ThreadPool(const ThreadPool& tmp)
{}
static ThreadPool* single_instance;
public:
static ThreadPool* getinstance() //获得对象指针
{
// static ThreadPool* single_instance=new ThreadPool();
// single_instance->InitThreadPool();
// return single_instance;
static pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;
if(single_instance == nullptr)
{
pthread_mutex_lock(&_mutex);
if(single_instance==nullptr)
{
single_instance=new ThreadPool();
single_instance->InitThreadPool();
}
pthread_mutex_unlock(&_mutex);
}
return single_instance;
}
bool IsStop() //线程是否退出
{
return stop;
}
void Lock() //加锁
{
pthread_mutex_lock(&lock);
}
void Unlock() //解锁
{
pthread_mutex_unlock(&lock);
}
bool TaskQueueIsEmpty()
{
return task_queue.size()==0 ? true : false;
}
void ThreadWait() //线程等待
{
pthread_cond_wait(&cond,&lock);
}
void ThreadWekeup() //线程唤醒
{
pthread_cond_signal(&cond);
}
static void* ThreadRoutine(void* arge) //线程实例
{
ThreadPool* tp=(ThreadPool*)arge;
while(true)
{
Task t;
tp->Lock();
if(tp->TaskQueueIsEmpty())
{
tp->ThreadWait();
}
tp->PopTack(t);//获取任务
tp->Unlock();
t.ProcessOn();
}
}
bool InitThreadPool() //初始化线程池
{
for(int i=0;i<num;i++)
{
pthread_t tid;
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) //插入任务
{
Lock();
task_queue.push(task);
Unlock();
ThreadWekeup();
}
void PopTack(Task& task) //删除任务
{
task=task_queue.front();
task_queue.pop();
}
~ThreadPool()
{
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
}
};
ThreadPool* ThreadPool::single_instance=nullptr;
由于线程的执行例程的参数只能有一个void*类型的参数,因此线程的执行例程必须定义成静态成员函数,而线程执行例程中又需要访问任务队列,因此需要将this指针作为参数传递给线程的执行例程,这样线程才能够通过this指针访问任务队列。
在向任务队列中放任务以及从任务队列中获取任务时,都需要通过加锁的方式来保证线程安全,而线程在调用PopTask之前已经进行过加锁了,因此在PopTask函数中不必再加锁。
当任务队列中有任务时会唤醒线程进行任务处理,为了防止被伪唤醒的线程调用PopTask时无法获取到任务,因此需要以while的方式判断任务队列是否为空。
引入线程池后服务器要做的就是,每当获取到一个新连接时就构建一个任务,然后调用PushTask将其放入任务队列即可。
#pragma once
#include"Common.hpp"
#include"TcpServer.hpp"
#include"Log.hpp"
#include"Task.hpp"
#include"ThreadPool.hpp"
#define PORT 8081
class HttpServer
{
private:
int port;
bool stop;
public:
HttpServer(int _port=PORT):port(PORT),stop(false)
{}
void InitHttpServer()
{
//信号SIGPIPE需要进行忽略,如果不忽略,再写入的时候可能会崩溃;
signal(SIGPIPE,SIG_IGN);
}
//循环
void Loop()
{
TcpServer* tcvr=TcpServer::getinstance(port);
LOG(INFO,"Loop Begin");
while(!stop)
{
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
int sock=accept(tcvr->Sock(),(struct sockaddr*)&peer,&len);//等待请求
if(sock<0)
{
continue;
}
LOG(INFO,"Get a new link");
Task task(sock);
ThreadPool::getinstance()->PushTask(task);
}
}
~HttpServer()
{}
};
技术层面的扩展
技术层面可以选择进行如下扩展:
1,当前项目编写的是HTTP1.0版本的服务器,每次连接都只会对一个请求进行处理,当服务器对客户端的请求处理完毕并收到客户端的应答后,就会直接断开连接。可以将其扩展为HTTP1.1版本,让服务器支持长连接,即通过一条连接可以对多个请求进行处理,避免重复建立连接(涉及连接管理)。
2,当前项目虽然在后端接入了线程池,但也只能满足中小型应用,可以考虑将服务器改写成epoll版本,让服务器的IO变得更高效。
3,可以给当前的HTTP服务器新增代理功能,也就是可以替代客户端去访问某种服务,然后将访问结果再返回给客户端。
应用层面可以选择进行如下扩展:
1,基于当前HTTP服务器,搭建在线博客。
2,基于当前HTTP服务器,编写在线画图板。
3,基于当前HTTP服务器,编写一个搜索引擎。