文章目录
- 一、项目介绍
- 二、前置知识
- 2.1 URI、URL、URN
- 2.2 CGI
- 2.2.1 CGI的概念
- 2.2.2 CGI模式的实现
- 2.2.3 CGI的意义
- 三、项目设计
- 3.1 日志的编写
- 3.2 套接字编写
- 3.3 HTTP服务器实现
- 3.4 HTTP请求与响应结构
- 3.5 EndPoint类的实现
- 3.5.1 EndPoint的基本逻辑
- 3.5.2 读取请求
- 3.5.3 构建响应
- 3.5.3.1 CGI处理
- 3.5.3.2 非CGI处理
- 3.5.4 发送响应
- 3.6 错误处理
- 3.6.1 处理逻辑错误
- 3.6.2 处理读取错误
- 3.6.3 处理写入错误
- 3.7 引入线程池
- 四、项目测试
- 4.1 GET方法上传数据测试
- 4.2 POST方法上传数据测试
- 五、项目源码
一、项目介绍
本项目实现的是一个HTTP服务器,项目主要功能是通过基本的网络套接字读取客户端发来的HTTP请求并进行分析,最终构建HTTP响应并返回给客户端。
该项目采用CS模型实现一个轻量级的HTTP服务器,目的在于理解HTTP协议的处理过程。
涉及技术:
C/C++、HTTP协议、Socket编程、CGI、单例模式、互斥锁、条件变量、多线程、线程池等方面的技术。
二、前置知识
关于HTTP协议在博主之前的文章有过详细介绍:【网络编程】应用层协议——HTTP协议
2.1 URI、URL、URN
这里补充一些知识点:
- URI、URL、URN的定义
URI
统一资源标识符:用来标识唯一资源。
URL
统一资源定位符:用来定位唯一的资源。
URN
统一资源名称:通过名字来标识资源。
URI就是保证资源的唯一性即可,而URL不仅要保证唯一性还要让我们能找到这个资源。
2.2 CGI
当我们进行网络请求的时候,也就两种情况
1️⃣ 从服务器获得资源
2️⃣ 提交数据给服务器
通常从服务器上获取资源对应的请求方法就是GET(通过URL传参)方法,而将数据上传至服务器对应的请求方法就是POST(通过正文传参)方法。
拿到数据只是第一步,还要对数据进行处理。如何处理数据呢?
用CGI模式处理数据。
CGI(通用网关接口)是一种重要的互联网技术,可以让一个客户端,从网页浏览器向执行在网络服务器上的程序请求数据。CGI描述了服务器和请求处理程序之间传输数据的一种标准。
2.2.1 CGI的概念
实际对数据的处理与HTTP的关系并不大,而是取决于上层具体的业务场景的,因此HTTP不对这些数据做处理。但HTTP提供了CGI机制,上层可以在服务器中部署若干个CGI程序,这些CGI程序可以用任何程语言编写,当HTTP获取到数据后会将其提交给对应CGI程序进行处理,然后再用CGI程序的处理结果构建HTTP响应返回给浏览器。
CGI也是一个运行程序,逻辑图如下:
细节问题:
如何调用目标CGI程序、如何传递数据给CGI程序、如何拿到CGI程序的处理结果
- 什么时候需要使用CGI模式?
只要用户请求服务器时上传了数据,那么服务器就需要使用CGI模式对用户上传的数据进行处理。
而如果用户只是单纯的想请求服务器上的某个资源文件则不需要使用CGI模式,此时直接将用户请求的资源文件返回给用户即可。
此外,如果用户请求的是服务器上的一个可执行程序,说明用户想让服务器运行这个可执行程序,此时也需要使用CGI模式。
2.2.2 CGI模式的实现
想让一个程序执行另一个程序,显而易见要用到程序替换,但是如果直接进行程序替换的话,那么服务器的代码和数据就会被替换掉,所以第一步:
1️⃣ 创建子进程进行程序替换
接下来要思考的问题是CGI程序是为了帮我们处理数据的,那么CGI程序如何拿到数据呢?因为这里的服务器进程和CGI进程是父子进程,因此优先选择使用匿名管道。
但匿名管道只能单向通信,这里是要相互传输数据,所以我们可以用两个匿名管道。
2️⃣ 创建两个匿名管道完成数据传输
父子进程都是各自用两个变量来记录管道对应读写端的文件描述符的,但是对于子进程来说,当子进程执行exec程序替换后,记录的文件描述符就会丢失(程序替换会替换代码和数据)。
子进程进行进程程序替换后,底层创建的两个匿名管道仍然存在,只不过被替换后的CGI程序不知道这两个管道对应的文件描述符罢了。
解决办法:
被替换后的CGI程序,从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据。
要实现这种操作就要在子进程被替换之前进行重定向。
3️⃣ 重定向操作
现在把通信信道建立好后,就要进行数据交付了。
首先要知道父进程的数据在哪?
因为有两种请求方法,GET和POST,所以参数可能会在url中,也可能会在正文部分。所以要分两种情况讨论。
如果请求方法为GET方法,那么用户是通过URL传递参数的,一般参数长度比较短,通过管道读取效率较低,此时可以在子进程进行进程程序替换之前,通过putenv函数将参数导入环境变量,由于环境变量也不受进程程序替换的影响,因此被替换后的CGI程序就可以通过getenv函数来获取对应的参数。
如果请求方法为POST方法,那么用户是通过请求正文传参的,此时父进程直接将请求正文中的数据写入管道传递给CGI程序即可,但是为了让CGI程序知道应该从管道读取多少个参数,父进程还需要通过putenv函数将请求正文的长度导入环境变量。
但是子进程如何知道自己是从管道读取数据还是从环境变量中读取呢?
子进程也得知道请求方法。方法就是通过环境变量。
总结以下:
使用CGI模式时如果请求方法为POST方法,那么CGI程序需要从管道读取父进程传递过来的数据,如果请求方法为GET方法,那么CGI程序需要从环境变量中获取父进程传递过来的数据。
在子进程在进行进程程序替换之前,还需要通过putenv函数将本次HTTP请求所对应的请求方法也导入环境变量
这整个流程就是第四步:
4️⃣ 父子进程交付数据
2.2.3 CGI的意义
CGI机制就是让服务器将获取到的数据交给对应的CGI程序进行处理,然后将CGI程序的处理结果返回给客户端。
CGI机制使得浏览器输入的数据最终交给了CGI程序,而CGI程序输出的结果最终交给了浏览器。这也就意味着CGI程序的开发者,可以完全忽略中间服务器的处理逻辑。
相当于CGI程序从标准输入就能读取到浏览器输入的内容,CGI程序写入标准输出的数据最终就能输出到浏览器。中间的通信细节全部是由HTTP完成的,CGI并不关注。
三、项目设计
3.1 日志的编写
在服务器的运行过程中我们想要看到服务器产生的一些事件,就可以使用日志。
期望看到的结果如下:
[日志等级][时间][信息][错误文件][行数]
说明:
日志等级分成四个:
#define NORMAL 0// 正常
#define WARNING 1// 警告
#define ERROR 2// 错误
#define FATAL 3// 致命错误
时间:打印当前时间。
信息: 事件产生的日志信息。
错误文件:事件在哪一个文件产生。
行数: 事件在对应文件的哪一行产生。
源码:
#define NORMAL 0// 正常
#define WARNING 1// 警告
#define ERROR 2// 错误
#define FATAL 3// 致命错误
#define LOG_NOR "log.txt"
#define LOG_ERR "log.error"
const char* to_string_level(int level)
{
switch(level)
{
case NORMAL: return "NORMAL";
case WARNING: return "WARNING";
case ERROR: return "ERROR";
case FATAL: return "FATAL";
default : return nullptr;
}
}
#define LOG(level, format) logMessage(level, __FILE__, __LINE__, format)
void logMessage(int level, std::string file_name, int line, std::string format)
{
// [日志等级][时间][信息][错误文件][行数]
char logprefix[1024];
time_t now;
time(&now);
struct tm *ptm = localtime(&now);
char timebuf[1024];
snprintf(timebuf, sizeof timebuf, "%d年%d月%d日 %d:%d:%d", ptm->tm_year + 1900, ptm->tm_mon + 1, ptm->tm_mday, ptm->tm_hour, ptm->tm_min, ptm->tm_sec);
snprintf(logprefix, sizeof logprefix, "[%s][%s]", to_string_level(level), timebuf);
std::cout << logprefix << "[" << format << "]" << "[" << file_name << "]" << "[" << line << "]" << std::endl;
}
3.2 套接字编写
服务器绑定端口就够了,不需要显示绑定IP,直接将IP地址设置为INADDR_ANY
即可,它的意思就是随机绑定本主机的ip地址,这里不能绑定公网IP和私网IP,如果在云服务器上绑定了公网IP,因为这是云服务器厂商虚拟出来的IP,无法通信。
因为服务器全局唯一,所以可以设置为单例模式。
#define BACKLOG 5 // 等待队列的最大长度
class TCPServer
{
public:
// 获取单例对象
static TCPServer* GetSingle(int port)
{
// 静态锁,不用调用init初始化和销毁锁
static pthread_mutex_t Lock = PTHREAD_MUTEX_INITIALIZER;
if(_TCPSingle == nullptr)
{
pthread_mutex_lock(&Lock);
if(_TCPSingle == nullptr)
{
_TCPSingle = new TCPServer(port);
_TCPSingle->InitServer();// 初始化服务器
}
pthread_mutex_unlock(&Lock);
}
return _TCPSingle;
}
void InitServer()
{
// 1.创建套接字
// 2.绑定
// 3.设置监听状态
Socket();
Bind();
Listen();
LOG(NORMAL, "Init Server Success");
}
// 创建套接字
void Socket()
{
_listensock = socket(AF_INET/*网络通信*/, SOCK_STREAM/*流式套接字*/, 0/*协议*/);
if(_listensock < 0)
{
LOG(FATAL, "Socket Error!");
exit(1);
}
// 设置端口复用
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof opt);
LOG(NORMAL, "Creat Socket Success");
}
// 方便上层获得监听套接字
int sock()
{
return _listensock;
}
// 绑定
void Bind()
{
struct sockaddr_in local;
// 初始化结构体
bzero(&local, sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = INADDR_ANY;// 随机绑定IP
if(bind(_listensock, (struct sockaddr*)&local, sizeof local) < 0)
{
// 绑定失败
LOG(FATAL, "Bind Socket Error!");
exit(2);
}
LOG(NORMAL, "Bind Socket Success");
}
// 监听
void Listen()
{
if(listen(_listensock, BACKLOG) < 0)
{
// 监听失败
LOG(FATAL, "Listen Socket Error!");
exit(3);
}
LOG(NORMAL, "Listen Socket Success");
}
~TCPServer()
{
if(_listensock >= 0)
close(_listensock);
}
private:
// 私有构造+防拷贝
TCPServer(int port)
: _port(port)
, _listensock(-1)
{}
TCPServer(const TCPServer&)=delete;
TCPServer* operator=(const TCPServer&)=delete;
private:
int _port;// 端口号
int _listensock;// 监听套接字
static TCPServer* _TCPSingle;// 单例对象
};
TCPServer* TCPServer::_TCPSingle = nullptr;
3.3 HTTP服务器实现
在套接字编写中,完成了创建套接字、绑定端口、设置监听状态的工作,接下来就是要获取新连接。
#define PORT 8080
class HTTPServer
{
public:
HTTPServer(int port = PORT)
: _port(port)
{}
void InitServer()
{
tcp_server = TCPServer::GetSingle(_port);
}
// 启动服务器
void Start()
{
LOG(NORMAL, "HTTP Start");
// 监听套接字
int listen_sock = tcp_server->sock();
while(true)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 获取新连接
int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
if(sock < 0)
{
continue; //获取失败,继续获取
}
LOG(NORMAL, "Accept Link Success");
int* _sock = new int(sock);
pthread_t tid;
pthread_create(&tid, nullptr,Enter::Handler, _sock);
pthread_detach(tid);// 线程分离
}
}
~HTTPServer()
{}
private:
int _port;
TCPServer* tcp_server = nullptr;
};
说一下为什么要int* _sock = new int(sock);
因为传递给了线程后可能会发生修改。
- 线程函数
class Enter
{
public:
static void *Handler(void* sock)
{
LOG(NORMAL, "Handler Request Begin");
int _sock = *(int*)sock;
delete (int*)sock;
EndPoint *ep = new EndPoint(_sock);
ep->RecvHTTPRequest();
ep->BuildHTTPResponse();
ep->SendHTTPResponse();
delete ep;
LOG(NORMAL, "Handler Request End");
return nullptr;
}
};
这里的EndPoint类下面讲解。
3.4 HTTP请求与响应结构
- HTTP请求类
//HTTP请求
class Request
{
public:
//HTTP请求内容
std::string req_line;// 请求行
std::vector<std::string> req_header;// 请求报头
std::string req_blank;// 空行
std::string req_body;// 请求正文
// 请求行解析完之后的数据
std::string method;// 请求方法
std::string uri;// 请求资源
std::string version;// 版本号
std::unordered_map<std::string, std::string> header_kv;
int content_length = 0;
// uri: path?args
std::string path;// 路径
std::string args;// 参数
std::string suffix;// 文件后缀
int fd_size = 0;// 资源文件的大小
bool cgi = false;// 是否使用CGI模式
};
收到了请求后把数据放入成员变量中(请求行、请求报头、空行、请求正文),因为请求行包含了三个字段,所以还要添加三个成员变量来存请求方法和请求资源和版本号。为了后边分析请求报头中的数据,所以用了
header_kv
结构存储。下面几个成员变量主要是为了获取请求资源的相关信息以及是否使用cgi模式。
- HTTP响应类
//HTTP响应
class Response
{
public:
//HTTP响应内容
std::string status_line;// 状态行
std::vector<std::string> resp_header;// 响应报头
std::string resp_blank = LINE_SEP;// 空行
std::string resp_body;// 响应正文
int status_code = OK;// 状态码
int fd = -1;// 响应文件的fd
};
这里讲一下fd:非CGI正文在fd中保存、CGI在_resp.body中保存。
3.5 EndPoint类的实现
3.5.1 EndPoint的基本逻辑
EndPoint这个词经常用来描述进程间通信,比如在客户端和服务器通信时,客户端是一个EndPoint,服务器则是另一个EndPoint,因此这里将处理请求的类取名为EndPoint,主要作用:读取分析请求、构建响应、IO通信。
基本结构:
//读取分析请求、构建响应、IO通信
class EndPoint
{
public:
EndPoint(int sock)
: _sock(sock)
{}
// 读取请求
void RecvHTTPRequest()
{}
// 构建响应
void BuildHTTPResponse()
{}
// 发送响应
void SendHTTPResponse()
{}
~EndPoint()
{
close(_sock);
}
private:
int _sock;
Request _req;
Response _resp;
};
可以看到处理的流程是:
1️⃣ 读取请求
2️⃣ 构建响应
3️⃣ 发送响应
3.5.2 读取请求
读取HTTP请求的同时可以对HTTP请求进行解析,这里分为五个步骤,分别是读取请求行、读取请求报头和空行、解析请求行、解析请求报头、读取请求正文。
// 读取请求
void RecvHTTPRequest()
{
// 读取请求行 + 请求报头
RecvHTTpRequestLine();
RecvHTTpRequestHeader();
// 解析请求行 + 请求报头
PraseHTTPRequestLine();
PraseHTTPRequestHeader();
// 读取请求正文
RecvHTTPRequsetBody();
}
- 读取请求行
这里首先要知道请求行的结尾方式有三种:
\r
、\n
、\r\n
。
由此得知不能直接按行读,需要自定义方法读取:
我们可以写一个Util
工具类进行方法ReadLine
的编写
这个函数采取的方法:
按照一个字符一个字符的读取。
如果读取到的字符是\n
,则说明行分隔符是\n
,此时将\n
push到自定义的缓冲区后停止读取。
如果读取到的字符是\r,则需要继续窥探下一个字符是否是\n
,不管后边是不是\n
,都将\n
push到自定义的缓冲区后停止读取。
// 按行读取
static int ReadLine(int sock, std::string& out)
{
char ch;
do{
// 一次读一个字符
ssize_t n = recv(sock, &ch, 1, 0);
if(n > 0)
{
// 读取成功
if(ch == '\r')
{
// '\r' || "\r\n"
// 窥探,只看不取
recv(sock, &ch, 1, MSG_PEEK);
if(ch == '\n')
{
// 把"\r\n"中的'\r'覆盖掉
recv(sock, &ch, 1, 0);
}
else
{
// '\r' -> '\n'
ch = '\n';
}
}
out.push_back(ch);
}
else if(n == 0)
{
// 对端关闭
return 0;
}
else
{
// 读取错误
return -1;
}
}while(ch != '\n');
return out.size();
}
说明一下: recv函数的最后一个参数MSG_PEEK
:recv函数将返回TCP接收缓冲区头部指定字节个数的数据,但是并不把这些数据从TCP接收缓冲区中取走,这个叫做数据的窥探功能。
- 读取请求报头和空行
这里也是按照一行行的读取,刚好使用ReadLine
函数:
//读取请求报头和空行
bool RecvHTTpRequestHeader()
{
std::string line;
// 读取报头
while(true)
{
line.clear();
Util::ReadLine(_sock, line);
if(line == "\n")
{
// 读取空行
_req.req_blank= line;
break;
}
line.resize(line.size() - 1);
_req.req_header.push_back(line);
LOG(NORMAL, line);
}
}
- 解析请求行
这个步骤要做的是将请求行中的请求方法、URI和HTTP版本号拆分出来,并且为了兼容大小写,我们把请求方法全部转换成大写:
// 解析请求行
void PraseHTTPRequestLine()
{
std::string& line = _req.req_line;
std::stringstream ss(line);
ss >> _req.method >> _req.uri >> _req.version;
// 请求方法全部转大写字母
std::transform(_req.method.begin(), _req.method.end(), _req.method.begin(), ::toupper);
}
- 解析请求报头
因为请求报头是name: val
的结构,所以要把每一行的kv键值对放入header_kv
中,以便后续通过属性名获取到对应的值。
至于这里的切分也可以在Util
工具类中封装切割的方法:
// 分割字符串
static bool CutString(const std::string& body, std::string& sub1, std::string& sub2, const std::string& sep)
{
size_t pos = body.find(sep);
if(pos == std::string::npos)
{
return false;
}
sub1 = body.substr(0, pos);
sub2 = body.substr(pos + sep.size());
return true;
}
// 解析请求报头
void PraseHTTPRequestHeader()
{
std::string key;
std::string val;
for(auto& e : _req.req_header)
{
if(Util::CutString(e, key, val, ": "))
{
_req.header_kv.insert({key, val});
}
}
}
- 读取请求正文
这里要分析是否有正文,因为只有POST方法可能会有请求正文。
如果请求方法为POST,我们还需要通过请求报头中的Content-Length
属性来得知请求正文的长度。 以便读取正文。
// 是否需要读取正文
bool IsNeedRecvHTTPRequsetBody()
{
// 通过method判断是否有正文
if(_req.method == "POST")
{
// 有正文
auto it = _req.header_kv.find("Content-Length");
if(it != _req.header_kv.end())
{
LOG(NORMAL, "POST Method, Content-Length: " + it->second);
_req.content_length = atoi(it->second.c_str());
return true;
}
}
return false;
}
// 读取正文
void RecvHTTPRequsetBody()
{
if(IsNeedRecvHTTPRequsetBody())
{
int len = _req.content_length;
char ch;
while(len--)
{
ssize_t n = recv(_sock, &ch, 1, 0);
if(n > 0)
{
_req.req_body += ch;
}
else break;
}
}
LOG(NORMAL, _req.req_body);
}
3.5.3 构建响应
上面把请求全部收到后,首先要处理请求,但是处理请求的过程可能会出错,不管是什么类型的错误客户端都希望能够收到反馈,根据不同的状态码构建响应,所以定义了如下的状态码:
// 状态码
#define OK 200
#define BAD_REQUEST 400// 请求方法不正确
#define NOT_FOUND 404// 请求资源不存在
#define SERVER_ERROR 500// 服务器错误
首先要做的就是解析收到的请求:
1️⃣ 先判断方法,方法出错就把状态码置为
BAD_REQUEST
然后直接构建响应。
如果是GET方法,有两种情况,一种是URI携带参数,一种是不携带参数,用非CGI处理请求。携带了参数就要把请求路径和参数都提取出来,并用CGI模式处理参数。没有携带参数就只用把请求路径提取出来即可。
如果是POST方法,先要看是否有正文,如果没有正文,说明没有参数,用非CGI处理请求。如果有正文说明有参数就使用CGI模式处理。
2️⃣ 接下来分析客户端的请求路径,首先要在请求的路径前拼接WEB根目录,也就是我们自己定义的根目录,判断路径的结尾是不是/
,如果是/
说明是个目录,不可能把整个目录都返回,所以每个目录离都要添加一个默认资源index.html
。
而客户端请求的资源有可能是一个可执行程序,怎么判断呢?
通过stat函数获取客户端请求资源文件的属性信息,如果资源类型是一个可执行程序,则说明后续处理需要使用CGI模式。
当然我们也需要知道请求的资源是什么类型,以便构建响应返回。
我们可以通过请求资源的后缀来判断是什么类型。
然后再写一个通过后缀获得资源类型的函数,后边填写Content-Type
的时候会用到。
// 根据后缀提取资源类型
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 it = suffix_to_desc.find(suffix);
if(it != suffix_to_desc.end())
{
return it->second;
}
return "text/html"; //所给后缀未找到则默认该资源为html文件
}
构建响应流程:
// 构建响应
void BuildHTTPResponse()
{
// 验证合法性
struct stat st;
int size = 0;// 资源大小
size_t suf_pos = 0;// 找后缀
if(_req.method != "GET" && _req.method != "POST")
{
LOG(WARNING, "Method Error!");
_resp.status_code = BAD_REQUEST;
goto END;
}
if(_req.method == "GET")
{
auto pos = _req.uri.find("?");
if(pos != std::string::npos)
{
// uri携带了参数
Util::CutString(_req.uri, _req.path, _req.args, "?");
_req.cgi = true;
}
else
{
// uri没有携带参数
_req.path = _req.uri;
}
}
else if(_req.method == "POST")
{
// CGI处理数据
_req.cgi = true;
_req.path = _req.uri;
// 无参数就不走CGI
if(_req.content_length == 0)
{
_req.cgi = false;
}
}
else
{
// do nothing
}
// 添加根目录
_req.path = WEB_ROOT + _req.path;
if(_req.path[_req.path.size() - 1] == '/')
{
// 添加首页信息
_req.path += HOME_PAGE;
}
// 判断路径是否存在
if(stat(_req.path.c_str(), &st) == 0)
{
// 资源存在
if(S_ISDIR(st.st_mode))
{
// 请求的是一个目录
_req.path += "/";
_req.path += HOME_PAGE;
// 重新获取属性
stat(_req.path.c_str(), &st);
}
// 拥有者、所属组、其他 是否有可执行权限
if( (st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH) )
{
// 可执行程序
_req.cgi = true;
}
_req.fd_size = st.st_size;
}
else
{
std::string msg = _req.path;
msg += " Not Find";
LOG(WARNING, msg);
_resp.status_code = NOT_FOUND;
goto END;
}
// 提取后缀以便确认资源类型
suf_pos = _req.path.rfind(".");
if(suf_pos == std::string::npos)
{
// 没找到到,设置默认
_req.suffix = ".html";
}
else
{
_req.suffix = _req.path.substr(suf_pos);
}
if(_req.cgi == true)
{
// 要用CGI处理请求
_resp.status_code = ProcessCGI();
}
else
{
// 非CGI方式处理请求
// 返回静态网页 + HTTP响应
_resp.status_code = ProcessNoCGI();
}
END:
// 根据状态码构建响应
BuildHTTPResponseHelper();
}
说明:
stat是一个系统调用函数,它可以获取指定文件的属性信息,包括文件的inode编号、文件的权限、文件的大小等。如果调用stat函数获取文件的属性信息失败,则可以认为客户端请求的这个资源文件不存在,此时直接设置状态码为NOT_FOUND后停止处理即可。
判断是否是可执行文件:只要一个文件的拥有者、所属组、other其中一个具有可执行权限,则说明这是一个可执行文件。
3.5.3.1 CGI处理
在上面讲过CGI处理的流程,这里讲一下细节问题。
因为用CGI模式就说明有参数,参数就需要传递,结果就需要返回,这样就需要两个管道。
站在父进程角度对这两个管道进行命名,父进程用于读取数据的管道叫做input,父进程用于写入数据的管道叫做output。
但是后边要执行程序替换,我们保存的指向input
和output
文件描述符会丢失,因为程序替换会替换所有的代码和数据,当时管道还是存在的,为了让子进程也获得这两个文件描述符,我们可以把这两个文件描述符重定向到标准输入和标准输出。
那么子进程往标准输出写就是相当于写给了input
管道。
此外,在子进程进行程序替换之前,还需要进行各种参数的传递:
首先需要将请求方法通过putenv函数导入环境变量,以供CGI程序判断应该以哪种方式读取父进程传递过来的参数。
如果请求方法为GET方法,则需要将URL中携带的参数通过导入环境变量的方式传递给CGI程序。
如果请求方法为POST方法,则需要将请求正文的长度通过导入环境变量的方式传递给CGI程序,以供CGI程序判断应该从管道读取多少个参数。
至此,如果是GET方法时参数就已经传递过去了,POST方法只知道了要传递参数的字节数。
1️⃣ 所以父进程就负责往管道中写入POST方法传递的参数。
2️⃣ 接下来就是获得子进程程序替换成CGI处理的结果:不断调用read函数,从管道中读取CGI程序写入的处理结果。
int ProcessCGI()
{
LOG(NORMAL, "Process cgi method");
int code = OK;
// 可执行程序在path中
auto& bin = _req.path;
// 把方法导入环境变量
std::string method_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;
}
pid_t id = fork();
if(id == 0)
{
// 子进程
close(input[0]);
close(output[1]);
// 通过环境变量传递请求方法
method_env = "METHOD=";
method_env += _req.method;
std::cout << "Method_env: " << method_env << std::endl;
putenv((char*)method_env.c_str());
if(_req.method == "GET")
{
// kv模型
// 把参数导入环境变量
std::string query_string_env = "QUERY_STRING=";
query_string_env += _req.args;
// 导入环境变量
putenv((char*)query_string_env.c_str());
LOG(NORMAL, "Get Method, Add QUERY_STRING");
}
else if(_req.method == "POST")
{
// 把数据大小导入环境变量
std::string content_length_env = "CONTENT_LENGTH=";
content_length_env += std::to_string(_req.content_length);
putenv((char*)content_length_env.c_str());
LOG(NORMAL, "POST Method, Add CONTENT_LENGTH");
}
else
{
// Do Nothing
}
std::cout << "bin: " << bin << std::endl;
// 重定向
dup2(output[0], 0);
dup2(input[1], 1);
// 进行程序替换
execl(bin.c_str(), bin.c_str(), nullptr);
exit(1);
}
else if(id < 0)
{
LOG(ERROR, "Fork Error!");
return NOT_FOUND;
}
else
{
// 父进程
close(input[1]);
close(output[0]);
if(_req.method == "POST")
{
// 可能管道不够大,多次写入
const char* start = _req.req_body.c_str();
int total = 0;
int size = 0;
while(total < _req.content_length && (size = write(output[1], start + total, _req.req_body.size() - total)) > 0)
{
total += size;
}
}
char ch;
while(read(input[0], &ch, 1) > 0)
{
_resp.resp_body.push_back(ch);
}
int status;// 获取退出码
pid_t 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;
}
说明一下:
WIFEXITED
可以获取子进程是否是正常退出,根据status
的值填写不同的退出码,以便后续构建响应。
3.5.3.2 非CGI处理
其实非CGI处理的过程非常简单,因为没有参数,所以请求的一定是一个静态网页,所以我们只需要返回资源+构建响应即可。构建响应是最后通过状态码来构建,这里只需要考虑如何返回我们的静态网页。
如果按照正常的方法,就是打开我们的文件,然后读取内容拷贝到_resp.body
中,然后构建响应发送出去,但是有一个方法可以不用将数据拷贝到_resp.body
(不用进入用户级缓冲区),而是直接在内核区完成拷贝,由内核直接发送到对端。
使用
sendfile
函数该函数的功能就是将数据从一个文件描述符拷贝到另一个文件描述符,并且这个拷贝操作是在内核中完成的。
但是一旦sendfile
之后文件就发出去了,所以我们应该在构建响应之后再调用sendfile
。那么我们现在的工作就只用将要发送的目标文件打开即可,将打开文件对应的文件描述符保存到HTTP响应的fd当中。
// 返回静态网页 + 响应
int ProcessNoCGI()
{
_resp.fd = open(_req.path.c_str(), O_RDONLY);// 只读
if(_resp.fd >= 0)
{
return OK;
}
return NOT_FOUND;
}
上面已经把数据处理好并且得到了结果,接下来就是根据状态码来构建响应。
不管状态码是正确都要先填写状态行,至于响应报头要分情况:
1️⃣ 如果状态码出错,那么一定返回的是一个静态网页,那么所有的错误状态码都可以用一个函数封装来填写报头。
2️⃣ 对于正确的情况则还需要分析正文的类型,因为可能是CGI处理的或者非CGI,而非CGI正文在fd中保存、CGI在_resp.body中保存、所以要分情况填写报头。
// 对于错误直接返回的是页面
void HandlerError(const std::string& page)
{
_req.cgi = false;// 保证最后发送的是网页
_resp.fd = open(page.c_str(), O_RDONLY);
if(_resp.fd > 0)
{
// 填写报头
// 获取属性
struct stat st;
stat(page.c_str(), &st);
_req.fd_size = st.st_size;
std::string line = "Content-Type: text/html";
line += LINE_SEP;
_resp.resp_header.push_back(line);
line = "Content-Length: ";
line += std::to_string(st.st_size);
line += LINE_SEP;
_resp.resp_header.push_back(line);
}
}
// 构建OK的响应
void BuildOkResponse()
{
std::string line = "Content-Type: ";
line += SuffixToDesc(_req.suffix);
line += LINE_SEP;
_resp.resp_header.push_back(line);
// 正文大小
line = "Content-Length: ";
if(_req.cgi)
{
line += std::to_string(_resp.resp_body.size());
}
else
{
line += std::to_string(_req.fd_size);
}
line += LINE_SEP;
_resp.resp_header.push_back(line);
}
// 根据状态码构建响应
void BuildHTTPResponseHelper()
{
// 状态行
_resp.status_line += HTTP_VERSION;
_resp.status_line += " ";
_resp.status_line += std::to_string(_resp.status_code);
_resp.status_line += " ";
_resp.status_line += CodeToDesc(_resp.status_code);
_resp.status_line += LINE_SEP;
// 响应报头
std::string path = WEB_ROOT;// 路径
path += "/";
switch(_resp.status_code)
{
case OK:
BuildOkResponse();
break;
case NOT_FOUND:
path += PAGE_400;
HandlerError(path);// 返回400页面
break;
case BAD_REQUEST:
path += PAGE_404;
HandlerError(path);// 返回404页面
break;
case SERVER_ERROR:
path += PAGE_500;
HandlerError(path);// 返回500页面
break;
default:
break;
}
}
至于正文就在发送响应的时候进行发送,因为对于非CGI要用sendfile
函数。
3.5.4 发送响应
发送流程:
1️⃣ 发送状态行、响应报头和空行。
2️⃣ 关于正文部分就需要看是什么处理方式了。因为非CGI正文在fd中保存、CGI在_resp.body中保存。
如果是CGI方式,直接把数据发送给对端即可。
如果是非CGI方式处理或在处理过程中出错的,它们返回的都是静态网页+响应,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP响应类的fd中的,此时调用sendfile进行发送即可。
// 发送响应
void SendHTTPResponse()
{
// 发送状态行
send(_sock, _resp.status_line.c_str(), _resp.status_line.size(), 0);
// 发送响应报头
for(auto& it : _resp.resp_header)
{
send(_sock, it.c_str(), it.size(), 0);
}
// 发送空行
send(_sock, _resp.resp_blank.c_str(), _resp.resp_blank.size(), 0);
// 非CGI正文在fd中保存、CGI在_resp.body中保存
// 发送正文
if(_req.cgi)
{
size_t size = 0;
size_t total = 0;
const char* start = _resp.resp_body.c_str();// 起始
while( total < _resp.resp_body.size() && (size = send(_sock, start + total, _resp.resp_body.size() - total, 0)) > 0 )
{
total += size;
}
}
else
{
sendfile(_sock, _resp.fd, nullptr, _req.fd_size);
close(_resp.fd);
}
}
3.6 错误处理
3.6.1 处理逻辑错误
逻辑错误指的是请求已经被读完成了,但是发现有一些逻辑错误比如说请求的方法不对。对于这种类型的错误我们是要返回给客户端响应的。
3.6.2 处理读取错误
在读取请求的过程中出现的错误就叫做读取错误,比如调用recv读取请求时出错或读取请求时对方连接关闭等。
这就意味这服务器根本就没有读取一个完整的请求,那么就不需要返回响应了,更不需要对数据进行分析,直接停止处理即可。
处理方式:
在EndPoint类中新增
_stop
成员变量,表示是否停止本次处理。
在所有读取请求的地方判断是否读取成功:
//本次处理是否停止
bool Stop()
{
return _stop;
}
// 读取请求
void RecvHTTPRequest()
{
// 读取请求行 + 请求报头
if(!RecvHTTpRequestLine() && !RecvHTTpRequestHeader())// 都没出错
{
// 解析请求行 + 请求报头
PraseHTTPRequestLine();
PraseHTTPRequestHeader();
// 读取请求正文
RecvHTTPRequsetBody();
}
}
//读取请求行
bool RecvHTTpRequestLine()
{
if(Util::ReadLine(_sock, _req.req_line) > 0)
{
_req.req_line.resize(_req.req_line.size() - 1);
LOG(NORMAL, _req.req_line);
}
else
{
_stop = true;
}
return _stop;
}
// 读取请求报头和空行
bool RecvHTTpRequestHeader()
{
std::string line;
// 读取报头
while(true)
{
line.clear();
if(Util::ReadLine(_sock, line) <= 0)
{
_stop = true;
break;
}
if(line == "\n")
{
// 读取空行
_req.req_blank= line;
break;
}
line.resize(line.size() - 1);
_req.req_header.push_back(line);
LOG(NORMAL, line);
}
return _stop;
}
3.6.3 处理写入错误
当构建好响应要返回给客户端的时候,当数据正在发送的时候客户端把连接断开了,就出现了写入错误。
当对方把读文件描述符关闭时,我们还在写入的话就会收到一个信号SIGNALPIPE,那么服务端就会直接退出。
我们就可以在初始化服务器的时候忽略掉这个信号。
//HTTP服务器
class HTTPServer
{
public:
//初始化服务器
void InitServer()
{
signal(SIGPIPE, SIG_IGN); // 忽略掉SIGNAL信号
}
private:
int _port; //端口号
};
3.7 引入线程池
目前我们的服务器是当获取一个新连接就创建一个套接字,然后把套接字传递给线程让线程来处理,处理完后就把连接断开,把线程销毁,总的来说就是短连接的方式。
为了提高效率。可以引入线程池:
关于线程池在博主之前的文章就有过介绍:【linux】基于单例模式实现线程池
在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中。
线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理。
那么首先第一步就是封装任务类:
当服务器获取到一个新连接后,需要将其封装成一个任务对象放到任务队列当中。任务类中首先需要有一个套接字,此外还需要有一个回调函数,当线程池中的线程获取到任务后就可以调用这个回调函数进行任务处理。
// 任务类
class Task
{
public:
Task()
{}
Task(int sock)
:_sock(sock)
{}
//处理任务
void ProcessOn()
{
_handler(_sock); //调用回调
}
~Task()
{}
private:
int _sock;// 套接字
CallBack _handler;// 回调函数
};
接下来就是处理这里的回调函数,其实回调函数我们之前就写好了,就是之前的线程执行的函数,我们提供一个仿函数来调用即可。
class CallBack
{
public:
CallBack()
{}
~CallBack()
{}
void operator()(int sock)
{
Handler(sock);
}
void Handler(int sock)
{
LOG(NORMAL, "Handler Request Begin");
EndPoint *ep = new EndPoint(sock);
ep->RecvHTTPRequest();
if(!ep->Stop())
{
LOG(NORMAL, "Recv Success");
ep->BuildHTTPResponse();
ep->SendHTTPResponse();
}
else
{
LOG(WARNING, "Recv Error");
}
delete ep;
}
};
- 线程池编写
#define NUM 6
class ThreadPool
{
public:
// 获取单例
static ThreadPool* GetSingle()
{
static pthread_mutex_t _mtx = PTHREAD_MUTEX_INITIALIZER;
if(_single == nullptr)
{
pthread_mutex_lock(&_mtx);
if(_single == nullptr)
{
_single = new ThreadPool();
_single->InitThreadPool();
}
pthread_mutex_unlock(&_mtx);
}
return _single;
}
~ThreadPool()
{
pthread_mutex_destroy(&_lock);
pthread_cond_destroy(&_cond);
}
// 让线程在条件变量下进行等待
void ThreadWait()
{
pthread_cond_wait(&_cond, &_lock);
}
// 让线程在条件变量下进行唤醒
void ThreadWakeUp()
{
pthread_cond_signal(&_cond);
}
// 加锁
void Lock()
{
pthread_mutex_lock(&_lock);
}
// 解锁
void unLock()
{
pthread_mutex_unlock(&_lock);
}
bool TaskQueueIsEmpty()
{
return _task_q.empty();
}
// 线程执行函数
static void* ThreadRoutine(void* args)
{
ThreadPool* tp = (ThreadPool*)args;
while(true)
{
Task t;
tp->Lock();
while(tp->TaskQueueIsEmpty())
{
// 任务队列为空,线程休眠
tp->ThreadWait();
}
tp->PopTask(t);// 获取任务
tp->unLock();
t.ProcessOn();// 处理任务
}
}
// 初始化线程池
bool InitThreadPool()
{
// 创建一批线程
for(int i = 0; i < _num; i++)
{
pthread_t id;
if(0 != pthread_create(&id, nullptr, ThreadRoutine, this))
{
// 创建失败
LOG(FATAL, "Create ThreadPool Error!");
return false;
}
}
LOG(NORMAL, "Create ThreadPool Success");
return true;
}
// 推送任务
void PushTask(const Task& task)
{
Lock();
_task_q.push(task);
unLock();
// 一旦有了任务就可以唤醒线程进行处理了
ThreadWakeUp();
}
// 获取任务
void PopTask(Task& task)
{
task = _task_q.front();
_task_q.pop();
}
private:
// 构造私有+防拷贝
ThreadPool(int num = NUM)
: _num(num)
{
// 初始化锁和条件变量
pthread_mutex_init(&_lock, nullptr);
pthread_cond_init(&_cond, nullptr);
}
ThreadPool(const ThreadPool&)=delete;
ThreadPool& operator=(const ThreadPool&)=delete;
private:
std::queue<Task> _task_q;// 任务队列
int _num;// 线程数
pthread_mutex_t _lock;// 锁
pthread_cond_t _cond;// 条件变量
static ThreadPool* _single;// 单例
};
ThreadPool* ThreadPool::_single = nullptr;
当第一次获得单例对象时,线程池会创建出一批线程,但此时任务队列为空,所以会在条件变量下等待,一但服务器推送任务进入任务队列,就会随机唤醒一个线程。
四、项目测试
首先CGI要获得我们请求的参数:
// 获得参数
bool GetQueryString(std::string& query_string)
{
std::string method = getenv("METHOD");
if(method == "GET")
{
query_string = getenv("QUERY_STRING");
return true;
}
else if(method == "POST")
{
// 通过环境变量得知该从标准输入读取多少字节
std::cerr << "Content-Length: " << getenv("CONTENT_LENGTH") << std::endl;
int content_length = atoi(getenv("CONTENT_LENGTH"));
char ch;
while(content_length--)
{
read(0, &ch, 1);
query_string.push_back(ch);
}
return true;
}
else
{
return false;
}
}
先通过环境变量获取请求方法。
如果请求方法为GET方法,则继续通过环境变量获取父进程传递过来的数据。
如果请求方法为POST方法,则先通过环境变量获取父进程传递过来的数据的长度,然后再标准输入中读取指定长度的数据即可。
CGI获取到了数据后就可以对数据进行处理,这里我们可以进行加减乘除操作:
// 分割字符串
static void CutString(const std::string& body, std::string& sub1, std::string& sub2, const std::string& sep)
{
size_t pos = body.find(sep);
if(pos != std::string::npos)
{
sub1 = body.substr(0, pos);
sub2 = body.substr(pos + sep.size());
}
}
int main()
{
std::string query_string;
GetQueryString(query_string);
std::cerr << "query_string: " << query_string << std::endl;
// x=10&y=20
//切分
std::string left;
std::string right;
CutString(query_string, left, right, "&");
std::cerr << "left: " << left << std::endl;
std::cerr << "right: " << right << std::endl;
std::string name1, val1;
std::string name2, val2;
CutString(left, name1, val1, "=");
CutString(right, name2, val2, "=");
//处理数据
int x = atoi(val1.c_str());
int y = atoi(val2.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后子进程返回错误状态码
std::cout << "</body>";
std::cout << "</html>";
return 0;
}
4.1 GET方法上传数据测试
我们可以在w3School
网站复制一个表单:
HTML中的表单用于搜集用户的输入,我们可以通过设置表单的method属性来指定表单提交的方法,通过设置表单的action属性来指定表单需要提交给服务器上的哪一个CGI程序。
<html>
<body>
<head>
<meta charset="UTF-8" />
</head>
<form action="/test_cgi" method="GET">
x: <input type="text" name="data_x" value="0">
<br>
y: <input type="text" name="data_y" value="0">
<br><br>
<input type="submit" value="提交">
</form>
<p>点击提交,表单数据发送给CGI</p>
</body>
</html>
这样我们在请求的时候点击提交就可以把参数提交给我们自己写的CGI程序中。
- 为什么GET方法提交参数会有大小限制?
因为GET方法传递参数给子进程是通过环境变量传递的,所以注定了参数不能过长。
4.2 POST方法上传数据测试
测试表单通过POST方法上传数据时,只需要将表单中的method属性改为“post”即可。
可以看到参数被放到了正文中。
当然如果出现了除0错误:
子进程会异常退出,退出码被设置,父进程分析退出码得出服务器处理出错,返回500.html
的静态网页。
五、项目源码
gitee:https://gitee.com/yyh1161/http-server