【项目实战】 —— 轻量级HTTP服务器设计与实现
该项目是实现一个HTTP服务器,该服务器能通过基本的网络套接字读取客户端发送来的HTTP请求报文并进行解析,最终构建合适的HTTP响应报文并返回给客户端项目会抽取HTTP自定义协议的核心模块,采用浏览器与服务器形式的CS模型实现一个小的HTTP通信渠道,目的是深入学习HTTP协议的处理与响应过程该项目涉及技术:C/C++,网络套接字编程,单例模式,线程池,CGI等技术。
目录
一,项目介绍
该项目是实现一个HTTP服务器,该服务器能通过基本的网络套接字读取客户端发送来的HTTP请求报文并进行解析,最终构建合适的HTTP响应报文并返回给客户端
- 项目会抽取HTTP自定义协议的核心模块,采用浏览器与服务器形式的CS模型实现一个小的HTTP通信渠道,目的是深入学习HTTP协议的处理与响应过程
- 该项目涉及技术:C/C++,网络套接字编程,单例模式,线程池,CGI等技术
二,背景知识补充
HTTP协议的内容我们在往期文章有过了解:计算机网络(六) —— http协议详解-CSDN博客
关于其它的比如套接字编程可以参考更多往期文章哦
2.1 http特点
主要有五个:
- 服务器客户端模式:一条通信线路上必定有一端是服务端,另一端是客户端,请求从客户端发出,服务器收到请求构建响应发回,使信息的传递具有针对性
- 简单快速:客户端只需将请求方法和请求资源路径传给服务器即可,这使得客户端不需要发送很多信息给服务器,并且http协议结构简单, 也使得http服务器通信规模较小,简单快速
- 灵活:http协议允许传输任意类型的数据对象,也就是可以传图片,音频,视频等非文本资源,通过报头的Content-Type属性进行标记
- 无连接:每次连接只对一个请求进行处理,发回响应报文给客户端并收到客户端的应答之后,直接断开连接,这样能大大节省传输时间,提高传输效率,比如Tcp就需要花费资源来维护连接才能进行传送
- 无状态:http协议不对请求和响应之间的通信状态进行保存,每个请求独立,可以让http更快速处理事务,确保协议的可伸缩性;但是随着http的普及,图片视频等非文本资源量大大增加,再继续执行每次请求都断开连接的方案,明显增加了通信的代价,因此 HTTP 1.1 支持了长连接Keey-Alive,就是任意一端只要没有明确提出断开连接,则保持连接状态
2.2 URI,URL,URN
三个东东的定义如下:
- URI(Uniform Resource Indentifier)统一资源标识符:用来唯一标识资源
- URL(Uniform Resource Locator)统一资源定位符:用来定位唯一的资源
- URN(Uniform Resource Name)统一资源名称:是通过名字来直接标识资源,访问直接下载
三者的关系如下:
2.3 http请求方法
方法 |
解释 | 支持的HTTP版本 |
---|---|---|
GET | 获取资源 | 1.0,1.1 |
POST |
传输实体 |
1.0,1.1 |
PUT | 传输文件 | 1.0,1.1 |
HEAD | 获得报文首部 | 1.0,1.1 |
DELETE | 删除文件 | 1.0,1.1 |
OPTIONS | 询问支持的方法 | 1.1 |
TRACE | 追踪路径 | 1.1 |
CONNECT | 使用隧道协议连接代理 | 1.1 |
LINK | 建立和资源之间的联系 | 1.1 |
UNLINK | 断开联系 | 1.1 |
目前市面上用到的95%以上的方法就是GET方法和POST方法,两种方法都可以带参,GET通过URL传参,POST通过请求报文的正文部分传参,所以GET传参一般用来上传简短的数据比如百度上传关键字时就是用的GET方法,POST方法一般用来传文件,因为URL一般不建议太长,而请求正文一般没有长度限制
三,前置功能实现
该项目要用到的文件如下:
后面的代码开头都会标记上该代码所在的文件名
3.1 日志编写
项目中的日志格式如下:
其中日志级别:
- INFO:表示一切正常运行
- WARNING:表示警告,意思是存在风险,但是不影响整体运行
- ERROR:发生错误,但不致命,风险大于警告
- FATAL:发生了致命的错误,直接导致整体运行失败
下面是日志头文件的编写:
//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;
}
- time函数作用是获取时间戳,所以调用Log函数时不必带时间
- __FILE__和__LINE__是C语言中的预定义符号,作用是获取当前文件名称和当前所在行,但由于我们的Log函数是定义在Log.hpp里的,那么每次调用时这两个参数都只会显示在Log.hpp里的位置
- 所以我们可以使用宏定义,因为宏在预处理阶段会将代码替换到目标地点,这样就可以和time函数一样,自动获取所在文件名和所在行了
3.2 封装相关套接字
套接字的介绍和使用往期文章已经详细介绍过了,这里不再赘述:
计算机网络(二) —— 网络编程套接字_计算机网络中套接字-CSDN博客
计算机网络(三) —— 简单Udp网络程序_udp初始化-CSDN博客
下面是Socket.hpp的封装套接字的代码:
//Socket.hpp
#pragma once
#include <iostream>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>
#include<sys/types.h>
#include "Log.hpp"
class Socket
{
private: //设置成单例模式
Socket(int port)
:_port(port)
,_listen_sock(-1)
{}
Socket(const Socket &s) = delete;
Socket* operator=(const Socket&) = delete;
public:
static Socket *getinstance(int port) //获取单例模式
{
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
if(nullptr == svr)
{
pthread_mutex_lock(&lock);
if(nullptr == svr)
{
svr = new Socket(port);
svr->InitServer();
}
pthread_mutex_unlock(&lock);
}
return svr;
}
void InitServer()
{
GetSocket();
Bind();
Listen();
LOG(INFO, "tcp_server init ... success");
}
void GetSocket()
{
_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;
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, 10) < 0)
{
LOG(FATAL, "listen socket error!");
exit(3);
}
LOG(INFO, "listen socket ... success");
}
int Sock() { return _listen_sock; }
~Socket()
{
if(_listen_sock >= 0)
close(_listen_sock);
}
private:
int _port;
int _listen_sock;
static Socket* svr;
};
Socket* Socket::svr = nullptr; //创建唯一对象,单例模式
- 我们仍然把Socket搞成单例模式,然后提供一个全局访问点来访问,这次我们搞成饿汉模式,一开始就把对象创建好
- 由于我们是云服务器,所以在填充sockaddr结构体的IP地址时,我们设置成INADDR_ANY即可,表示可以从本地任何一张网卡读取数据,并且由于INADDR_ANY本质就是0,所以也不需要inet_addr函数进行网络字节序转换
- 由于我们后面会搞成多线程,所以要用到锁,锁我们用PTHREAD_MUTEX_INITIALIZER来定义,这样就不需要手动释放了,同时为了保证后续获取单例对象时避免频繁加锁解锁,我们以双检查的方式进行加锁
3.3 http请求结构设计
//Protocol.hpp
class Request
{
public:
std::string request_line; //请求行
std::vector<std::string> request_header; //包括请求报头各字段
std::string blank; //表示空行
std::string request_body; //请求正文
//下面是保存解析完毕之后的结果,包括:http方法,URI,http版本等
std::string method; //请求方法
std::string uri; //URL也分成两部分,左边是URL,右边可能是带的参数
std::string version; //http版本
std::unordered_map<std::string, std::string> header_kv; //将报头中的字段以KV形式解析出来
int content_length; //报头字段中表示正文的长度,单位字节
std::string path; //表示URL的路径
std::string query_string; //表示URL右边带的参数
std::string suffix; //表示文件后缀,方便后面填写响应报文的Content-Tyoe
bool cgi; //表示是否要使用CGI模式
int size;
public:
Request()
: content_length(0) //请求长度初始化为0
, cgi(false) //默认不使用CGI
{}
~Request(){}
};
3.4 http响应结构设计
//Protocol.hpp
class Response
{
public:
std::string status_line; //状态行
std::vector<std::string> response_header; //响应报头的各个属性
std::string blank; //空行
std::string response_body; //响应正文
int status_code; //表示响应报文中第一行请求行的状态码
int fd; //表示用open打开的文件的文件描述符
int size; //表示响应文件的大小
std::string suffix; //表示响应文件的后缀
public:
Response()
: status_code(OK) //状态码默认200
, fd(-1)
, blank(LINE_END) //设置空行
, size(0)
{}
~Response(){}
};
3.5 http服务器主体逻辑
HttpServer.hpp主题逻辑:
服务器主体逻辑和我们之前实现的那个很相似:计算机网络(六) —— http协议详解-CSDN博客
- 将http服务器也搞成一个类,构造时传入端口,然后调用Loop就可以让服务器跑起来了:
- 运行起来后首先就是获取Socket单例对象然后获取监听套接字,然后不断从中获取新连接‘
- 当获取一个新连接后就创建一个线程来处理(前面我们先用线程,后期再搞成线程池)
//HttpServer.hpp
#include <iostream>
#include <pthread.h>
#include <signal.h>
#include "Log.hpp"
#include "Socket.hpp"
#include "Task.hpp"
#include "ThreadPool.hpp"
#include "Protocol.hpp"
static const int defaultport = 8081; //默认端口,可更改
class HttpServer
{
public:
HttpServer(int _port = defaultport): port(_port),stop(false)
{}
void InitServer()
{
//信号SIGPIPE需要进行忽略,如果不忽略,在写入时候,可能直接崩溃server
signal(SIGPIPE, SIG_IGN);
}
void Loop()
{
Socket *tsvr = Socket::getinstance(port); //获取单例对象
LOG(INFO, "Loop begin");
while(!stop)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(tsvr->Sock(), (struct sockaddr*)&peer, &len); //获取连接
if(sock < 0) continue;
int* _sock = new int(sock);
pthread_t tid;
pthread_create(&tid, nullptr, CallBack::HandlerRequest, (void*)_sock);
pthread_detach(tid);
//Task task(sock);
//ThreadPool::GetInstance()->PushTask(task);
}
}
private:
int port;
bool stop;
};
main.cc主函数逻辑:
- 我们可以在命令行指定我们的端口号,然后用这个端口号创建一个HttpServer对象,然后调用Loop函数运行服务器,之后服务器就会不断获取新连接并创建线程来处理主业务逻辑
//main.cc
#include "HttpServer.hpp"
#include <iostream>
#include <string>
#include <memory>
#include "Daemon.hpp"
static void Usage(std::string proc)
{
std::cout << "Usage:\n\t" << proc << " port" << std::endl;;
}
int main(int argc, char *argv[])
{
//Daemon(); //守护进程
if( argc != 2 ){
Usage(argv[0]);
exit(4);
}
int port = atoi(argv[1]);
std::shared_ptr<HttpServer> http_server(new HttpServer(port)); //使用智能指针自动初始化和释放
http_server->InitServer();
http_server->Loop();
return 0;
}
四,读取和解析请求
4.1 EndPoing类介绍
EndPoing这个单词的中文翻译是“终点,端点”,所以我们可以将其比作“终端”,经常用来描述进程间通信的双方,比如客户端和服务器通信时,客户端是一个EndPoint,服务器是另一个EndPoint,所以我们用这个单词来当我们服务器主逻辑的类的类名
下面是EndPoint类的总览
下面是EndPoint类的主结构:
//Protocol.hpp
class EndPoint
{
public:
void RecvRequest(); //读取并解析请求
void BuildReponse(); //构建http响应
void SendResponse(); //响应构建好,然后就是发送响应了
bool IsStop() { return stop; }
EndPoint(int sock)
: _sock(sock)
, stop(false)
{}
~EndPoint() { close(_sock); }
private:
int total;
int _sock; //通信套接字
Request http_request; //http请求
Response http_response; //http响应
bool stop; //表示读取是否出错
};
设计线程回调
前面说了我们的HttpServer.hpp里的服务器主逻辑是创建线程然后让线程处理,所以我们需要给线程传一个回调函数,也就是前面的CallBack
服务器每获取到一个新连接就会创建一个新线程来进行处理,而这个线程的任务就是先搞出一个EndPoint对象,然后依次进行“读取请求-->解析请求-->构建响应-->发回响应”四个步骤,处理完成后通过析构函数关闭套接字即可,下面是CallBack类的代码:
//Protocol.hpp
class CallBack
{
public:
CallBack()
{}
~CallBack()
{}
void operator()(int sock)
{
HandlerRequest((void*)sock); //仿函数,重载(),调用HandlerRequest
}
static void* HandlerRequest(void* arg)
{
LOG(INFO, "Hander Request Begin");
#ifdef DEBUG //测试打印http请求报头
char buffer [4096];
recv(sock, buffer, sizeof(buffer), 0);
std::cout << "-------------begin----------------" << std::endl;
std::cout << buffer << std::endl;
std::cout << "-------------end----------------" << std::endl;
#else
int sock = *(int*)arg;
EndPoint* ep = new EndPoint(sock);
ep->RecvRequest();//读取并解析请求
if(!ep->IsStop()) //当读取解析请求都成功时,才开始构建和发回相应
{
LOG(INFO, "Recv No Error, Begin Build And Send Reponse");
ep->BuildReponse(); //构建响应
ep->SendResponse(); //发回响应
}
else
{
LOG(WARNING, "Recv Error, Stop Build And Send Reponse");
}
delete ep;
#endif
LOG(INFO, "Hander Request End");
}
};
Protocol.hpp需要包含的头文件和需要定义的内容:
//Protocol.hpp
#pragma once
#include<iostream>
#include<sstream>
#include<unistd.h>
#include<string>
#include<vector>
#include <algorithm>
#include <unordered_map>
#include <sys/types.h>
#include <sys/stat.h>
#include<fcntl.h>
#include<sys/socket.h>
#include<sys/sendfile.h>
#include<sys/wait.h>
#include "Log.hpp"
#include "Util.hpp"
#define SEP ": "
#define WEB_ROOT "wwwroot"
#define HOME_PAGE "index.html"
#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"
//状态码
#define OK 200
#define BAD_REQUEST 400
#define NOT_FOUND 404
#define SERVER_ERROR 500
4.2 读取http请求
我们测试打印的http请求如下:
读取和请求我们可以放到一个函数RecvRequest函数里,如下:
void RecvRequest() //读取并解析请求
{
//只有当读取请求行和读取请求报头都成功的时候,才执行后续操作
if((!RecvRequestLine()) && (!RecvRequestHeader()))
{
ParseRequestLine(); //解析请求行
ParseRequestHeader(); //解析请求报头各字段
RecvRequestBoby(); //如果是POST请求方法就去读取正文,如果不是POST就什么也不做
}
}
①读取请求行
注意,我们这个只是把第一行的内容提取出来,提取出来后http版本等内容还是黏在一起的,这个步骤交给解析的时候处理,报头字段也是同理
下面是RecvRequestLine读取请求行函数代码:
//class EndPoint
bool RecvRequestLine() //读取请求报头的第一行请求行
{
std::string& line = http_request.request_line;
if(Util::ReadLine(_sock, line) > 0)
{
line.resize(line.size() - 1);
LOG(INFO, http_request.request_line);
}
else
{
stop = true; //如果读取出错,直接不玩了
}
return stop;
}
我们熟知的换行符是“\n”,但是不同平台下的行分隔符可能不一样的,可能是“ \r ”,“ \n ”或者“ \r\n ”,所以不能直接用C/C++的gets或者getline等函数进行读取,我们需要手动实现一个ReadLine函数,使这个函数能兼容这三种分隔符,定义在一个工具类Unit里,代码如下:
//Unit.hpp
class Util
{
public:
//分隔符有很多,'\r\n' '\n' '\r',我们统一按照'\n'的方式将各字段读取
static int ReadLine(int sock, std::string &out) //读取报头信息,能够处理各种行分隔符
{
char ch = 'a'; //初始化,可以随便设置,只要不是\n就行,目的是为了进入循环
while(ch != '\n') //如果行分隔符是'\n',则自动退出循环,返回请求行长度
{
ssize_t s = recv(sock, &ch, 1, 0); //从sock里面读,读到ch里面,每次循环读1个字符
if(s > 0) //读取成功
{
if(ch == '\r') //如果这个if条件成立,那么ch读取到的换行符有两种情况'\r\n' 和 '\r'
{
//查看一下'\r'后面的内容,不取走
recv(sock, &ch, 1, MSG_PEEK); //MSG_PEEK这个选项,会直接返回接收队列的头部,但不取走,这叫做“数据窥探”
if(ch == '\n') recv(sock, &ch, 1, 0); //如果是'\r\n',把'\r\n'转化成'\n'
else ch = '\n'; //如果就是一个'\r'则直接转化为'\n'
}
//走到这里后,只能有两种字符:普通字符和 '\n'
out.push_back(ch);
}
else if(s == 0) return 0;//表面对方已经关闭连接,所有读到0
else return -1;
}
return out.size();
}
}
解释下功能:
- 一开始进入循环,先雷打不动读取一个字符,然后对这个字符做判断
- 根据我们的循环条件,如果读取到普通字符,则再次循环读取,如果读取到' \n ',则自动退出循环
- 如果读取到\r,那么有‘ \r\n ’ 和 ‘ \r ’,两种情况,那么我们使用“数据窥探”先查看一下下一个字符,如果窥探成功说明行分隔符是' \r\n ',失败则是' \r '
- 然后不论是\r\n还是\r,都转化成\n,然后添加到最终提取结果中,这样就能兼容三种换行符了
②读取报头各字段
http的请求报头都是按行排列的,所以可以循环调用ReadLine进行读取,并存储读取结果到http请求类的request_header字段中方便后续操作
//class EndPoint
bool RecvRequestHeader() //读取报头各字段
{
std::string line;
while(true) //当请求报文中出现单个\n时,就说明请求报头读取完毕
{
line.clear(); //每次读取前刷新一下
//http请求报头的各字段都是以\n结尾的,所以可以用作分割符,将各字段分离出来
if(Util::ReadLine(_sock, line) <= 0)
{
stop = true; //如果读取报头出错,我也直接不玩了
break;
}
if(line == "\n") //这个就表示读取到的这一行只有一个单独的换行符,就表示读取到空行了
{
http_request.blank = line; //空行后面的就是请求正文
break;
}
line.resize(line.size() - 1); //ReadLine读取时是会把\n一起搞上来的,但是我们不需要\n,所以要去掉
http_request.request_header.push_back(line); //然后将各字段信息插入到请求类中,方便后续处理
LOG(INFO, line);
}
return stop;
}
4.3 解析http请求
解析步骤主要涉及三个函数:
①解析请求行
- ParseRequestLine函数作用就是将请求行中的http版本号,请求方法和URI都分开来,然后依次存储到请求类的对应字段里
- 我们这里用stringstream以流的形式进行拆分
- 并且由于平台不同,请求方法可能也会不同,比如有get,Get还有GET,所以我们存储请求方法时同一全部搞成大写再存储
//class EndPoint
void ParseRequestLine() //把请求行变成三个字符串后存起来
{
auto &line = http_request.request_line; //拿到请求行
std::stringstream ss(line); //stringstring可以将目标字符传以流的形式格式化输入到目标字符串里
ss >> http_request.method >> http_request.uri >> http_request.version;
//因为可能不是所有的协议都严格按照标准的,所以方法可能是 "Get",或者"get",就不是全是大写,所以我们需要做下处理
auto &method = http_request.method;
std::transform(method.begin(), method.end(), method.begin(), ::toupper); //toupper就是全转成大写,类似仿函数
}
②解析请求报头
- 我们前面将读取到的一行行的请求报头都存储在数组里,所以我们直接遍历这个数组,以: 为分隔符拆分成一个一个键值对,然后存储到请求类的对应字段里,方便后续操作,如下代码:
//class EndPoint
void ParseRequestHeader() //把请求报头各字段变成一个一个的键值对然后存起来
{
std::string key;
std::string value;
for (auto &iter : http_request.request_header)
{
// 以SEP为分隔符,把字符从iter迭代器拿出来,通过这个函数做分割放到key和value里面
if (Util::CutString(iter, key, value, SEP))
{
http_request.header_kv.insert(make_pair(key, value)); // 然后再以KV形式将key和value保存到map里
// std::cout << "debug: " << key << std::endl;
// std::cout << "debug: " << value << std::endl;
}
}
}
由于涉及到比较复杂的字符串切割工作,所以我们需要自己搞一个切割函数CutString,也定义在Unit工具类中:
//Unit.hpp
class Util
{
public:
static bool CutString(const std::string &target, std::string &sub1_out, std::string &sub2_out, std::string sep)
{
size_t pos = target.find(sep); //找到冒号的位置
if(pos != std::string::npos)
{
sub1_out = target.substr(0, pos); //截取到冒号位置
sub2_out = target.substr(pos+sep.size()); //分割符是"\n ",是一个\n加一个空格,所以截取后面位置要从pos+分隔符大小位置处开始
return true;
}
return false;
}
};
- 先通过find找到指定分隔符的位置,然后用substr进行截取
③读取请求正文(如果是POST方法)
- 当请求方法是GET时,就是单纯的获取资源,我们只需要返回指定资源即可
- 但是当请求方法是POST时,可能会有请求正文,我们就需要通过请求报头的Content-Length属性得知正文长度,然后进行读取并存储到请求类的对应字段
首先是判断是不是POST方法:
//Protocol.hpp
bool IsNeedRecvHttpRequestBody() // 判断是不是POST方法
{
auto &method = http_request.method;
if (method == "POST")
{
auto &header_kv = http_request.header_kv;
// header_kv["Content-Length"] //并不是所有的协议都按照规定的,所以不建议这样写
auto iter = header_kv.find("Content-Length"); // 找该属性
if (iter != header_kv.end()) // 找到了该属性
{
LOG(INFO, "Post Method, Content-Length: " + iter->second);
http_request.content_length = atoi(iter->second.c_str());
return true;
}
}
return false;
}
然后就是读取正文函数:
//Protocol.hpp
bool RecvRequestBoby() // 读取并保存请求正文
{
if (IsNeedRecvHttpRequestBody()) // 如果method是"POST",就要读取正文内容
{
int content_length = http_request.content_length; // 正文长度
auto &body = http_request.request_body; // 要存储正文的地方
char ch = 0;
while (content_length)
{
ssize_t s = recv(_sock, &ch, 1, 0);
if (s > 0)
{
body.push_back(ch);
content_length--;
}
else
{
stop = true; // 正文读取出错,我直接不玩了
break;
}
}
LOG(INFO, body);
}
return stop;
}
五,构建并发回应答
5.1 CGI机制介绍
①关于CGI机制
CGI(Common Gateway Interface,通用网关接口)是一种非常重要的互联网技术,可以让客户端从网页浏览器向一个执行在服务器上的进程请求数据
我们在使用网络时,无非就两种目的:
- 获取资源:客户端向服务器申请某种资源,比如打开网页,下载资源等
- 上传资源:客户端要将自己的资源上传给服务器,比如上传个人资料,发送聊天消息,登录注册等
-
一般从服务器上获取资源的请求方法是GET方法,将数据上传至服务器的请求方法是POST方法,通过请求正文上传,GET方法也可以用URL上传数据,
-
而用户将自己的数据上传至服务器并不仅仅是为了上传,用户上传数据的目的是为了让http或相关程序对该数据进行处理;比如用户提交搜索关键字,服务器就要在后端进行搜索,然后将搜索结果返回给浏览器,再由浏览器对HTML文件进行解析并构建页面最终展示给用户。
-
但实际对数据的处理我们并不交给HTTP来做,所以HTTP提供了CGI机制,上层可以在服务器中部署若干个CGI可执行程序,当HTTP获取到数据后会将其提交给对应CGI程序进行处理,然后再用CGI程序的处理结果构建HTTP响应返回给浏览器
-
当HTTP获取到数据后,如何调用目标CGI程序、如何传递数据给CGI程序、如何得到CGI程序的处理结果,就都属于CGI机制的通信细节,而我们这个项目就是要实现一个HTTP服务器,因此CGI的所有交互细节都需要由我们来完成
②CGI的工作步骤
一,创建子进程后进行程序替换:
- 服务器获取到连接后是创建一个线程来处理的,执行CGI程序需要调用exec系列函数进行进程程序替换
- 但是不能直接调用,因为服务器创建的线程和服务器进程是共用一个进程地址空间的,如果直接调用exec,那么我们整个服务器的代码和数据就直接被替换掉了,简单点说就是http服务器执行一次CGI程序后直接退出了
- 所以我们用fork搞个子进程,然后让exec替换子进程即可
二,建立管道通信信道:
- 我们把数据通过exec交给CGI程序后,还需要获得CGI处理数据后的结果,所以我们需要使用管道,并且服务器进程和CGI进程是父子关系,所以最好使用匿名管道
- 但是匿名管道是半双工通信,是单向的,所以我们需要搞两个管道,在创建子进程之前建立好,并且父子进程要分别关闭两个管道的读写端
三,完成重定向相关的设置
- 创建匿名管道时,父子进程都是用两个变量来记录管道的读写端文件描述符的,但是当子进程执行exec后,子进程的代码和数据就被替换成了CGI程序的代码和数据了,也就意味着被替换后的CGI程序丢失了管道的读写端了
- 但是进程程序替换只替换对应进程的代码和数据,而对于进程的PCB,页表等内核数据没有改变,所以匿名管道依然存在,只是CGI程序无法获取管道的读写端文件描述符了
- 所以我们在子进程被替换之前,将0号文件描述符也就是标准输入重定向到对应管道的读端,将1号文件描述符重定向到对应管道的写端
- 这样一来,CGI程序直接从标准输入里读数据,处理好的结果直接通过标准输出返回给父进程即可
四,父子进程交付结果
在需要启动CGI机制的情况下:
- 如果请求方法是GET方法,那么是通过URL传数据的,我们先通过putenv函数将参数导入环境变量,因为环境变量不受进程程序替换的影响,所以CGI可以通过访问环境变量得到参数(这样做是因为URL传递的参数较短,再搞管道的话会降低效率)
- 如果请求方法是POST方法,此时父进程直接将请求正文数据写入管道即可,同时也要通过环境变量告诉CGI写入管道的数据大小,这样CGI才能读取到正确数量的数据
- 但是CGI并不知道本次的请求方法是GET还是POST,所以也需要通过环境变量将本次的请求方法也传给CGI
③CGI机制的处理流程
如下图:
- 先判断请求方法,如果是POST或者带参GET方法就直接启动CGI,如果是不带参GET就以非CGI进行处理
- 非CGI处理就是直接根据用户请求的资源构建HTTO响应返回给用户,没有创建子进程程序替换等步骤
- CGI处理步骤就是我们上面说的,最终也就是把CGI程序的处理结果也放进应答里然后返回给浏览器
④CGI机制的意义
- 最大的意义就是实现了服务器逻辑和业务逻辑的功能解耦,明确分工,可以显著提高效率
- CGI机制让用户提交的数据最终交给了CGI,CGI的结果最终交给了用户,忽略了中间服务器的处理逻辑,能够减少用户和业务的沟通成本
5.2 处理HTTP请求
状态码我们一开始就定义好了,因为在处理请求的过程中可能因为很多原因导致处理失败,比如请求方法不合法,请求资源不存在等等,状态码就是负责标识处理情况,让后续构建http应答时返回对应的错误状态码对应页面
下面是处理请求的代码,由于处理请求可以和构建应答放到一起,所以直接使用BuildReponse作为函数名:
//class EndPoint
void BuildReponse() // 构建http响应
{
std::string Path;
auto &code = http_response.status_code; // 状态码
struct stat st; // 用户获取资源文件的属性信息
int size = 0;
std::size_t found = 0; // 表示文件后缀的点的位置
if (http_request.method != "GET" && http_request.method != "POST")
{
// 走到这里说明这是个非法请求(因为我们目前的服务器只支持GET和POST方法)
std::cout << "method: " << http_request.method << std::endl;
LOG(WARNING, "method is not right");
code = BAD_REQUEST;
return;
}
if (http_request.method == "GET")
{
// 如果是GET方法,需要知道http请求行的URL中是否携带了参数
ssize_t pos = http_request.uri.find('?'); // 一般以问号作为分隔符
if (pos != std::string::npos) // 有问号,说明带参,要启动CGI
{
Util::CutString(http_request.uri, http_request.path, http_request.query_string, "?");
http_request.cgi = true;
}
else
http_request.path = http_request.uri; // 没有问号,不启动CGI
}
else if (http_request.method == "POST")
{
http_request.cgi = true; // 如果是POST方法,就要启动CGI机制
http_request.path = http_request.uri;
}
else
{
} // 如果要想支持其它的请求方法都可以在这里往后面扩展
// 测试
// std::cout << "debug - uri: " << http_request.uri << std::endl;
// std::cout << "debug - path: " << http_request.path << std::endl;
// std::cout << "debug - quary_string: " << http_request.query_string << std::endl;
// 然后就是需要把客户端传过来的目录,变成我们的web根目录
Path = http_request.path;
http_request.path = WEB_ROOT;
http_request.path += Path;
// std::cout << "debug - path: " << http_request.path << std::endl;
// 如果请求路径以 / 结尾,说明请求的是一个目录
if (http_request.path[http_request.path.size() - 1] == '/')
{
http_request.path += HOME_PAGE;
}
// 当把web目录处理好后,接下来就是要确认要申请的资源是否存在了:
// 1,如果存在,就返回 2,如果不存在,返回首页
if (stat(http_request.path.c_str(), &st) == 0) // stat函数用于获取某个文件的属性
{
// 走到这里说明要获取的资源是存在的
// 问题:存在就是可以访问读取的呢?不一定,因为有可能要访问的资源是一个目录
if (S_ISDIR(st.st_mode)) // 这是个宏,man手册说是判断st里mode是否为目录
{
// 走到这里说明申请的资源是一个目录,需要做特殊处理
http_request.path += "/"; // 细节:虽然是一个目录,但是不会以"/"结尾,因为上面已经做了对"/"的处理
http_request.path += HOME_PAGE;
stat(http_request.path.c_str(), &st); // 细节:由于路径发生更改,所以再重新获取一下属性
}
// 请求的资源也有可能是一个可执行程序,需要特殊处理
// 当文件的拥有者,所属组,other有任何一个有可执行权限,那么这个文件就是可执行权限
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; // 获取目标文件大小,方便后面sendfile发送
}
else
{
// 走到这里说明获取的资源不存在
LOG(WARNING, http_request.path + " Not Found");
code = NOT_FOUND;
return;
}
// 走到了这里,说明一切正常
// 获得资源后缀
found = http_request.path.rfind(".");
if (found == std::string::npos) // 没找到后缀,添加默认后缀
{
http_request.suffix = ".html";
}
else // 成功找到后缀
{
http_request.suffix = http_request.path.substr(found); // 截取点后面的字符
}
if (http_request.cgi == true)
{
code = ProcessCgi(); // 以CGI方式处理,结果已经存储到:http_response.response_body里面了
}
else
{
code = ProcessNonCgi(); // 以非CGI方式处理:简单的返回静态网页
// 1,目标网页一定是存在的
// 2,返回的不只是网页,还要构建Http响应,将网页以响应正文形式返回
}
BuildHttpResponseHelper(); //开始构建http响应报文
}
- GET方法通过URL的方式带参,一般以?作为URL和参数的分隔符,左边是资源路径,右边是参数,所以当有问号时,要启动CGI机制
- 如果URL以 / 结尾,那么表示要请求的资源是一个目录,但是我们不可能真的把目录返回过去,所以我们默认将该目录下的index.html返回给用户,所以需要在实际的请求资源路径后面加上字符串“index.html”
- 关于啥是web根目录我们之前的http简单服务器已经介绍过了,只需要知道服务器对外提供的资源都会放在web根目录下,然后所有的子目录都会有一个index.html首页文件
5.3 CGI处理
CGI主要是这个函数:
在创建子进程之前要创建两个匿名管道,我们以父进程为主,input管道用于读取厨具,output用于写入数据:
- 对于父进程来说:input用于读数据,output用于写数据,所以需要关闭 input[1] 和 output[0],保留 input[0] 和 output[1]
- 对于子进程来说:input用于写数据,output用于读数据,所以需要关闭 input[0] 和 output[1],保留 input[1] 和 output[0]
具体步骤就不详细展开讲了,上面已经介绍过,先直接上代码再解释:
//class EndPoint
int ProcessCgi()
{
// 问题
// 1,父进程拿到的数据在哪里?body(POST), query_string(GET)
// 2,父进程如何把参数交给子进程?管道,环境变量(具有全局属性,可以被子进程继承下去,并不受exec的影响)
int code = OK; // 默认状态码是200
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; // 负责保存CGI程序的处理结果
// 用于导入环境变量
std::string query_string_env;
std::string method_env;
std::string content_length_env;
// 对于程序替换:
// 1,可以创建子进程,让子进程去执行exec进程替换,那么要替换的目标程序在哪里呢?是通过URL告诉我们的
// 2,子进程处理完通信要把数据发给父进程,所以要用到进程间通信,也就是管道;同时父子进程要相互通信,那就要两个管道
// 约定:管道的读写,完全站在父进程角度
int input[2];
int output[2];
if (pipe(input) < 0) // pipi创建管道,可能会失败
{
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 pid = fork();
if (pid == 0) // 子进程,需要关闭两个管道的读写端
{
close(input[0]); // 一般0为读,1为写,所以子进程通过input从父进程读数据
close(output[1]); // 通过output向父进程写数据
method_env = "METHOD=";
method_env += method;
putenv((char *)method_env.c_str()); // putenv导入环境变量,导入请求方法
// 根据不同的方法导入环境变量
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")
{
// 通过环境变量告诉cgi程序要从管道读取多少正文数据
// 由于正文可能很长,所以正文内容通过管道传递
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
{
} // 要想支持其它方法直接在这里扩展即可
// 替换成功之后,目标子进程如何得知对应的读写文件描述符是多少呢?
// 程序替换,只替换代码和数据,不会对内核进程的数据结构做改变,所以替换后,位于用户层的文件描述符没有了,但是曾经打开的文件的数据结构还在
// 约定:让目标进程被替换之后,读取管道等价于读取标准输入;写入管道等价与写到标准输出
dup2(output[0], 0); // 让本来从0标准输入读的,现在直接从管道里面读
dup2(input[1], 1); // 让本来往1标准输出写的,我现在直接让它写到管道里
execl(bin.c_str(), bin.c_str(), nullptr); // 子进程进行程序替换
exit(1);
}
else if (pid < 0) // 创建子进程失败
{
LOG(ERROR, "fork error! ");
return 404;
}
else // 父进程
{
close(input[1]); // 一般0为读,1为写,所以父进程通过output向子进程写数据
close(output[0]); // 通过input读数据
if (method == "POST") // 如果请求方法是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) // 读取结果
{
// CGI执行完之后,把结果放到响应的正文里,不能直接send发回,因为这部分内容只是响应的正文,不是响应全部
response_body.push_back(ch);
}
int status = 0; // 子进程退出码
pid_t ret = waitpid(pid, &status, 0);
if (ret == pid)
{
if (WIFEXITED(status)) // 这个宏用来检测进程退出码是否正常
{
if (WEXITSTATUS(status) == 0)
code = OK; // 检测进程退出码是否为0,如果是0说明一切正常
else
code = BAD_REQUEST;
}
else
{
code = SERVER_ERROR;
}
}
// 关闭不必要的文件描述符,最大程度节省资源
close(input[0]);
close(output[1]);
}
return code;
}
- 环境变量也是key,value的键值对形式,所以我们要先把我们要传递的参数也先搞成键值对的形式再传递,这样方便CGI程序获取
- 父进程是循环调用read函数从管道中读取CGI的处理结果的,当CGI程序执行结束时,也就是CGI这个进程没了,所以与其相关的包括写端在内的文件描述符也就没了,此时read循环就会结束继续执行后续代码,而不会阻塞
5.4 非CGI处理
非CGI处理比CGI处理简单很多,只需要根据URL中的路径找到对应的资源,放进应答即可:
直接上代码:
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 NOT_FOUND;
}
我们一般不推荐直接将文件的内容拷贝到http响应类的response_body中然后发送回去,因为我们的http响应类存在于用户缓冲区,而目标文件是存储在磁盘上的,如果是直接这样搞的话我们需要先将文件搞到内核层缓冲区,然后再拷贝到用户层缓冲区,然后发送时需要再次拷贝到内核缓冲区,然后再拷贝到网卡上,如下图:
这样来回拷贝的代价是非常高的,所以我们其实可以直接将磁盘中的目标文件内容搞到内核层缓冲区,然后直接在内核层缓冲区直接发给网卡,省去用户层的拷贝,如下图:
我们需要使用sendfile函数来达到上述效果,该函数的主要作用就是将一个文件描述符拷贝到另一个文件描述符,在内核层完成,所以效率会比用户层的文件IO更高
但是需要注意,我们的非CGI处理逻辑还不能直接调用sendfile函数,因为sendfile是即时拷贝,也就是一调用就会发送,我们需要先构建http响应后再发送,所以我们这里的工作仅仅是打开这个文件,保存文件描述符到http响应类的对应字段即可
六,构建并发回响应
6.1 构建状态行
http响应首先是状态行,由状态码,状态码描述,http版本构成,以空格作为分隔符,我们先将状态行拼接好后保存在http响应类的status_line里即可,而响应报头需要根据请求是否正常处理完毕按情况构建,代码如下:(HandlerError是差错处理要执行的逻辑,这里先摆出来)
void BuildHttpResponseHelper()
{
auto &code = http_response.status_code; // 获取前面执行完的状态码,有正确也有错误
// 构建状态行
auto &status_line = http_response.status_line; // 状态行的状态码,不一定被填充
status_line += HTTP_VERSION; // 添加http版本
status_line += " ";
status_line += std::to_string(code); // 添加状态码
status_line += " ";
status_line += Code2Desc(code); // 添加状态码描述,比如Not Found
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 SERVER_ERROR:
path += PAGE_500;
HandlerError(path);
break;
default:
break;
}
}
对于状态码描述,我们可以单独搞一个函数CodeToDesc,该函数作用是能根据状态码返回对应的状态码描述,代码如下:
static std::string Code2Desc(int code)
{
std::string desc;
switch(code){
case 200:
desc = "OK";
break;
case 404:
desc = "Not Found";
break;
case 500:
desc = "Internal Server Error";
break;
default:
break;
}
return desc;
}
6.2 构建响应报头
构建响应报头也有两种情况,一种是请求正常处理完成,一种是请求处理出错,前者我们返回正确的内容,后面我们发送的则是错误的内容
①构建正确的响应报头
- 对于响应报头,我们最少要包含两个对象:Content-Type和Content-Length这两个内容,用于告诉对方响应资源的类型和大小
- 对于正常处理完毕的http请求,我们需要根据用户请求资源的后缀来填充Content-Type以告诉用户我们给你的资源是什么类型,这样用户才能根据类型来对资源进行各种操作
- 而资源的大小Content-Length需要根据处理的方式来获取,如果没有启动CGI机制,那么返回资源的大小是保存在http响应类的size中,如果启动了CGI机制,返回资源的大小对应着http响应类中的response_body的大小
下面是构建响应报头的BuildOkResponse函数代码:
void BuildOkResponse()
{
std::string line = "Content-Length: ";
if (http_request.cgi) // POST方法和带参GET方法
{
line += std::to_string(http_response.response_body.size());
}
else // 无参GET方法
{
line += std::to_string(http_request.size);
}
line += LINE_END;
http_response.response_header.push_back(line);
line = "Content-Type: ";
line += SuffixToDesc(http_request.suffix); // 添加资源文件类型
line += LINE_END;
http_response.response_header.push_back(line);
}
对于Content-Type,我们可以编写一个函数SuffixToDesc,用户根据文件后缀返回对应的文件类型,原因我们以前也讲过:计算机网络(六) —— http协议详解-CSDN博客
//Protocol.hpp
static std::string SuffixToDesc(const std::string &suffix)
{
static std::unordered_map<std::string, std::string> suffix2desc = {
{ ".html", "text/html" },
{ ".css", "text/css" },
{ ".js", "application/javascript" },
{ ".jpg", "application/x-jpg" },
{ ".xml", "application/xml" },
{ ".png", "image/png" },
};
//https://www.runoob.com/http/http-content-type.html
auto iter = suffix2desc.find(suffix);
if(iter != suffix2desc.end()) return iter->second;
else return "text/html";
}
②构建错误的响应报头
- 对于请求处理过程中出现错误的http请求,服务器将会为返回对应的错误页面,因此返回的资源类型就是text/html,而返回资源的大小可以通过获取错误页面对应的文件属性信息来得知
- 此外,为了后续发送响应时可以直接调用sendfile进行发送,这里需要将错误页面对应的文件打开,并将对应的文件描述符保存在HTTP响应类的fd当中
- 如果是处理CGI时出错,需要将其HTTP请求类中的cgi重新设置为false,好让后续发送的错误页面也是以非CGI方式发送的
void HandlerError(std::string page)
{
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); // 获取错误页面的属性
std::string line = "Content-Type : text/html";
line += LINE_END;
http_response.response_header.push_back(line);
line = "Content-Length: ";
line += std::to_string(st.st_size);
line += LINE_END;
http_response.response_header.push_back(line);
http_request.size = st.st_size;
}
}
6.3 发送响应
- 我们要发三个东西,一个是响应状态行,响应报头和响应正文,响应报头和响应正文通过空行分开
- 如果是启动了CGI机制,直接用send函数把http响应类的response_body发过去即可
- 如果是非CGI机制,直接用sendfile把对应的资源文件或者错误文件的文件描述符搞过去即可
void SendResponse() // 响应构建好,然后就是发送响应了
{
// 1,先把状态行发过去
send(_sock, http_response.status_line.c_str(), http_response.status_line.size(), 0);
// 2,再把响应报头发过去
for (auto iter : http_response.response_header) // 是vector,有很多
{
send(_sock, iter.c_str(), iter.size(), 0);
}
send(_sock, http_response.blank.c_str(), http_response.blank.size(), 0); // 状态行和报头发出去后再发一个空行,表示接下来发的就是响应正文了
// send发送不是真的发,而是拷贝到Tcp的发送缓冲区
// 3,最后发送正文
if (http_request.cgi == true) // CGI和非CGI要分开发
{
// 如果是cgi模式,那么我们的正文是在http_response的body中的
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;
}
// 这个步骤和我们上面把cgi程序执行完之后把数据写回管道那里的步骤是一样的,只是write变成了send
}
else
{
// 正常页面和错误页面通过同一种方法返回
sendfile(_sock, http_response.fd, nullptr, http_request.size); // 发送效率比write和send高一些
close(http_response.fd);
}
}
七,差错处理
到这里服务器的逻辑差不多已经完善了,但我们的服务器在处理请求过程中还是有很大缺陷,这是因为当前服务器的错误处理还没有完全处理完毕:
7.1 逻辑错误
- 这个我们已经解决了,主要是服务器在处理请求的过程中出现的一些错误,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。当出现这类错误时服务器会将对应的错误页面返回给客户端
- 在BuildResponse也就是根据请求填充响应类各字段这个函数里,我们如果遇到了逻辑错误,是直接标记响应类的status_code状态码然后就直接return返回了,但是这里我们可以再优化下
- 当有任何一个步骤出错了,我们标记完状态码后,可以直接跳转到开始构建http响应报文那里,所以我们可以使用goto语句来完成跳转工作
void BuildReponse() // 构建http响应
{
std::string Path;
auto &code = http_response.status_code; // 状态码
struct stat st; // 用户获取资源文件的属性信息
int size = 0;
std::size_t found = 0; // 表示文件后缀的点的位置
if (http_request.method != "GET" && http_request.method != "POST")
{
// 走到这里说明这是个非法请求(因为我们目前的服务器只支持GET和POST方法)
std::cout << "method: " << http_request.method << std::endl;
LOG(WARNING, "method is not right");
code = BAD_REQUEST;
goto END;
}
if (http_request.method == "GET")
{
// 如果是GET方法,需要知道http请求行的URL中是否携带了参数
ssize_t pos = http_request.uri.find('?'); // 一般以问号作为分隔符
if (pos != std::string::npos) // 有问号,说明带参,要启动CGI
{
Util::CutString(http_request.uri, http_request.path, http_request.query_string, "?");
http_request.cgi = true;
}
else
http_request.path = http_request.uri; // 没有问号,不启动CGI
}
else if (http_request.method == "POST")
{
http_request.cgi = true; // 如果是POST方法,就要启动CGI机制
http_request.path = http_request.uri;
}
else
{
} // 如果要想支持其它的请求方法都可以在这里往后面扩展
// 测试
// std::cout << "debug - uri: " << http_request.uri << std::endl;
// std::cout << "debug - path: " << http_request.path << std::endl;
// std::cout << "debug - quary_string: " << http_request.query_string << std::endl;
// 然后就是需要把客户端传过来的目录,变成我们的web根目录
Path = http_request.path;
http_request.path = WEB_ROOT;
http_request.path += Path;
// std::cout << "debug - path: " << http_request.path << std::endl;
// 如果请求路径以 / 结尾,说明请求的是一个目录
if (http_request.path[http_request.path.size() - 1] == '/')
{
http_request.path += HOME_PAGE;
}
// 当把web目录处理好后,接下来就是要确认要申请的资源是否存在了:
// 1,如果存在,就返回 2,如果不存在,返回首页
if (stat(http_request.path.c_str(), &st) == 0) // stat函数用于获取某个文件的属性
{
// 走到这里说明要获取的资源是存在的
// 问题:存在就是可以访问读取的呢?不一定,因为有可能要访问的资源是一个目录
if (S_ISDIR(st.st_mode)) // 这是个宏,man手册说是判断st里mode是否为目录
{
// 走到这里说明申请的资源是一个目录,需要做特殊处理
http_request.path += "/"; // 细节:虽然是一个目录,但是不会以"/"结尾,因为上面已经做了对"/"的处理
http_request.path += HOME_PAGE;
stat(http_request.path.c_str(), &st); // 细节:由于路径发生更改,所以再重新获取一下属性
}
// 请求的资源也有可能是一个可执行程序,需要特殊处理
// 当文件的拥有者,所属组,other有任何一个有可执行权限,那么这个文件就是可执行权限
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; // 获取目标文件大小,方便后面sendfile发送
}
else
{
// 走到这里说明获取的资源不存在
LOG(WARNING, http_request.path + " Not Found");
code = NOT_FOUND;
goto END;
}
// 走到了这里,说明没有goto跳转到END,一切正常
// 获得资源后缀
found = http_request.path.rfind(".");
if (found == std::string::npos) // 没找到后缀,添加默认后缀
{
http_request.suffix = ".html";
}
else // 成功找到后缀
{
http_request.suffix = http_request.path.substr(found); // 截取点后面的字符
}
if (http_request.cgi == true) // 启动CGI机制
{
code = ProcessCgi(); // CGI处理完后的结果已经存储到:http_response.response_body里面了
}
else
{
code = ProcessNonCgi(); // 以非CGI方式处理:简单的返回静态网页
// 1,目标网页一定是存在的
// 2,返回的不只是网页,还要构建Http响应,将网页以响应正文形式返回
}
END:
BuildHttpResponseHelper(); // 开始构建http响应报文
}
7.2 读取资源文件错误
逻辑错误是服务器在处理请求时可能出现的错误,而在处理请求之前就是需要先读取请求,在这个过程中出现的错误就是读取错误,比如recv等读取函数读取出错
这时候我们EndPoint类中的stop成员就派上用场了,表示是否停止本次处理
读取请求行出错时:
读取报头出错时:
读取请求正文出错时:
最后我们的线程回调函数通过IsStop函数得知读取是否出错,如果出错,后续处理请求构建响应等步骤统统不再执行,直接打印错误日志:
7.3 发送出错
我们发送时是把响应状态行,响应报头和响应正文分三次发的,所以也可以针对这里做一些判断处理
bool SendResponse() // 响应构建好,然后就是发送响应了
{
// 1,先把状态行发过去
if (send(_sock, http_response.status_line.c_str(), http_response.status_line.size(), 0) <= 0)
stop = true; // 如果发送出错,也用stop标记一下
// 2,再把响应报头发过去
if (!stop)
{
for (auto &iter : http_response.response_header) // 是vector,有很多
{
if (!stop && send(_sock, iter.c_str(), iter.size(), 0) <= 0)
stop = true;
}
if (!stop && send(_sock, http_response.blank.c_str(), http_response.blank.size(), 0) <= 0) // 状态行和报头发出去后再发一个空行,表示接下来发的就是响应正文了
stop = true;
// send发送不是真的发,而是拷贝到Tcp的发送缓冲区
}
// 3,最后发送正文
if (!stop && http_request.cgi == true) // CGI和非CGI要分开发
{
// 如果是cgi模式,那么我们的正文是在http_response的body中的
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;
}
// 这个步骤和我们上面把cgi程序执行完之后把数据写回管道那里的步骤是一样的,只是write变成了send
}
else
{
// 正常页面和错误页面通过同一种方法返回
if (!stop)
{
if (sendfile(_sock, http_response.fd, nullptr, http_request.size) <= 0) // 发送效率比write和send高一些
stop = true;
}
close(http_response.fd);
}
return stop;
}
八,接入线程池
8.1 为什么需要线程池
我们目前的服务器对于效率上的问题:
- 每次获取新线程后,主线程都会创建新线程进行处理,处理完毕后又销毁线程,这样也就导致了效率低下,使得很多资源无法重复利用
- 而且如果同时有大量客户端连接进来,那么线程数也就越多,CPU压力也就越大,这样一个线程得处理时间就变长了,严重影响效率,严重些可能导致崩溃
所以我们可以接入线程池:
- 在服务器启动时创建一批线程,用一个任务队列组织起来,每当获取到一个新连接时,就封装成一个任务交给任务队列,然后任务队列自动分配任务到下面得线程里去
8.2 任务设计
- 任务类首先得有一个套接字,也就是与用户进行通信得套接字
- 然后还需要一个回调函数,给线程用的
//Task.hpp
#pragma once
#include <iostream>
#include "Protocol.hpp"
class Task
{
public:
Task()
{}
Task(int _sock):sock(_sock)
{}
//处理任务
void ProcessOn()
{
handler(sock);
}
~Task()
{}
private:
int sock;
CallBack handler; //设置回调
};
关于回调类CallBack我们前面也实现过了:
- 把CallBack类的 () 运算符重载为调用HandlerRequest函数时,CallBack对象就变成了一个仿函数对象,可以让这个类像函数一样直接被调用
- 需要改一下HandlerRequest的参数,因为我们是通过任务类调用的这个方法,不是线程直接调用了
//#define DEBUG 1
class CallBack
{
public:
CallBack()
{}
~CallBack()
{}
void operator()(int sock)
{
HandlerRequest(sock); //仿函数,重载(),调用HandlerRequest
}
static void* HandlerRequest(int sock)
{
LOG(INFO, "Hander Request Begin");
#ifdef DEBUG //测试打印http请求报头
char buffer [4096];
recv(sock, buffer, sizeof(buffer), 0);
std::cout << "-------------begin----------------" << std::endl;
std::cout << buffer << std::endl;
std::cout << "-------------end----------------" << std::endl;
#else
EndPoint* ep = new EndPoint(sock);
ep->RecvRequest();//读取并解析请求
if(!ep->IsStop()) //当读取解析请求都成功时,才开始构建和发回相应
{
LOG(INFO, "Recv No Error, Begin Build And Send Reponse");
ep->BuildReponse(); //构建响应
if(ep->SendResponse()) //发回响应
{
LOG(WARNING, "Send Error");
}
}
else
{
LOG(WARNING, "Recv Error, Stop Build And Send Reponse");
}
delete ep;
#endif
LOG(INFO, "Hander Request End");
}
};
8.3 线程池设计
关于线程池,我们以前也做过介绍:Linux系统编程——线程池_linux内核线程池-CSDN博客
所以,现在实现一个线程池也不是很困难,只要把代码搬过来做下修改即可:
- 将ThreadPool类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象
- 提供一个指向单例对象的static指针,并在类外将其初始化为nullptr
- 提供一个全局访问点获取单例对象,并且在单例对象第一次被获取时就创建这个单例对象并进行初始化
//ThreadPool.hpp
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>
#include "Log.hpp"
#include "Task.hpp"
#define NUM 6
class ThreadPool
{
public:
static ThreadPool* GetInstance() //获取对象,第一次获取对象时,初始化线程池,往后再次获取时不再初始化
{
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 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();
ThreadWakeup(); //唤醒在条件变量下等待的一个线程处理任务
}
void PopTask(Task &task) //任务执行完毕,干掉任务
{
task = task_queue.front();
task_queue.pop();
}
static void *ThreadRoutine(void *args) //线程池中每个线程的执行函数
{
//线程函数的参数只能有一个void*,当传的是this指针,就能通过this指针访问任务队列
ThreadPool *tp = (ThreadPool *)args;
while (true)
{
Task t;
tp->Lock();
while (tp->TaskQueueIsEmpty()) //这里用while判断任务队列是否为空,防止线程被伪唤醒
{
tp->ThreadWait(); // 当我醒来的时候,一定是占有互斥锁的!
}
tp->PopTask(t);
tp->Unlock();
t.ProcessOn();
}
}
public:
bool IsStop()
{
return stop;
}
bool TaskQueueIsEmpty()
{
return task_queue.size() == 0 ? true : false;
}
void Lock()
{
pthread_mutex_lock(&lock);
}
void Unlock()
{
pthread_mutex_unlock(&lock);
}
void ThreadWait()
{
pthread_cond_wait(&cond, &lock);
}
void ThreadWakeup()
{
pthread_cond_signal(&cond);
}
~ThreadPool()
{
pthread_mutex_destroy(&lock);
pthread_cond_destroy(&cond);
}
private: //搞成单例模式
ThreadPool(int _num = NUM)
:num(_num)
,stop(false)
{
pthread_mutex_init(&lock, nullptr);
pthread_cond_init(&cond, nullptr);
}
ThreadPool(const ThreadPool &){}
static ThreadPool *single_instance; //指向单例对象的指针
private:
int num; //表示线程池中线程的个数
bool stop;
std::queue<Task> task_queue; //用于暂时存储未被处理的任务对象
pthread_mutex_t lock; //互斥锁
pthread_cond_t cond; //条件变量,当任务队列没有任务时,让线程进行等待,当任务队列中有任务时,通过该条件变量唤醒线程
};
ThreadPool* ThreadPool::single_instance = nullptr;
最后更改下服务器主逻辑即可:
九,测试
makfile文件内容如下:
bin=httpserver
cgi=test_cgi
cc=g++
LD_FLAGS=-std=c++11 -lpthread
curr=$(shell pwd)
src=main.cc
ALL:$(bin) $(cgi)
.PHONY:ALL
$(bin):$(src)
$(cc) -o $@ $^ $(LD_FLAGS)
$(cgi):cgi/test_cgi.cc
$(cc) -o $@ $^
.PHONY:clean
clean:
rm -rf $(bin) $(cgi)
rm -rf output
.PHONY:output #发布
output:
mkdir -p output
cp $(bin) output
cp -rf wwwroot output
cp $(cgi) output/wwwroot
9.1 返回网页
我们这个服务器使用的wwwroot还是我们之前用的那个:计算机网络(六) —— http协议详解-CSDN博客
首先是启动服务器:
然后打开浏览器输入IP加端口即可获取html网页信息
获取主页:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
a {
color: blue;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
table {
width: 536px
}
.title .col-1 {
font-size: 20px;
font-weight: bolder;
}
.col-1 {
width: 80%;
text-align: left;
/*居左*/
}
.col-2 {
width: 20%;
text-align: center;
}
.icon {
background-image: url(./male.png);
width: 24px;
height: 24px;
background-size: 100% 100%;
display: inline-block;
/*加上后图片才能显示出来*/
vertical-align: bottom;
/*使垂直对齐*/
}
.content {
font-size: 18px;
line-height: 30px;
}
.content .col-1,
.content .col-2 {
border-bottom: 2px solid #f3f3f3;
}
.num {
font-size: 20px;
color: #fffff3;
}
.first {
background-color: #f54545;
padding-right: 8px;
}
.second {
background-color: #ff8547;
padding-right: 8px;
}
.third {
background-color: #ffac38;
padding-right: 8px;
}
.other {
background-color: #81b9f5;
padding-right: 8px;
}
</style>
</head>
<body>
<table cellspacint="0px">
<th class="title col-1">热搜</th>
<<th class="title col-2"><a href="./a/b/hello.html">登录<span class="icon"></span></a></th>
<tr class="content">
<td class="col-1"><span class="num first">1</span><a
href="https://github.com/"
target="blank">GitHub</a>
</td>
<td class="col-2">666万</td>
</tr>
<tr class="content">
<td class="col-1"><span class="num second">2</span><a href="https://www.csdn.net/"
target="blank">CSDN</a></td>
<td class="col-2">666万</td>
</tr>
<tr class="content">
<td class="col-1"><span class="num third">3</span><a href="https://gitee.com/"
target="blank">Gitee</a></td>
<td class="col-2">666万</td>
</tr>
<tr class="content">
<td class="col-1"><span class="num other">4</span><a href="https://leetcode.cn/"
target="blank">LeetCode</a></td>
<td class="col-2">666万</td>
</tr>
<tr>
<td>
<a href="./image.html" target="blank">你好</a>
</td>
</tr>
</table>
</body>
</html>
获取不存在的资源时返回404页面:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>404 Not Found</h1>
<h21>您好,您访问的页面不存在</h21>
</body>
</html>
获取图片等超文本数据:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<img src="/image/1.jpg" alt="你好" weigh="800px" width="800px">
<img src="/image/2.jpg" alt="你好" weigh="800px" width="800px">
<img src="/image/3.jpg" alt="你好" weigh="800px" width="800px">
</body>
</html>
图片文件可以自己上传:
9.2 测试CGI
①编写CGI程序
在测试CGI程序之前,我们要先编写一个简单的CGI程序
首先,CGI程序启动后需要先获取父进程传过来的数据:
- 先通过getenv获取环境变量中的请求方法
- 如果请求方法伪GET方法,就再通过getenv函数获取父进程传递过来的数据
- ru张请求方法是POST方法,则先通过getenv函数获取父进程传递过来的数据的长度,然后再从0号文件描述符中读取指定长度的数据即可,如下代码:
#include<iostream>
#include<cstdlib>
#include <unistd.h>
#include <string>
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")
{
int content_length = atoi(getenv("CONTENT_LENGTH"));
char c = 0;
while(content_length)
{
read(0, &c, 1);
query_string.push_back(c);
content_length--;
}
result = true;
}
else //如果要支持其它方法就可以在这里继续拓展
{
result = false;
}
return result;
}
CGI获取父进程传递过来的数据后,就是进行数据处理了:
- 我们这里假设用户上传的是形如a=100&b=200这样的字符串,需要CGI程序进行加减乘除运算并把运算结果返回过去
- 我们的CGI要先以&为分隔符分开两个操作数,再以=为分隔符分别获取两个具体的数字,最后进行运算,把结果通过标准输出输出到管道里即可,如下代码:
void CutString(std::string &in, const std::string &sep, std::string &out1, std::string &out2)
{
auto pos = in.find(sep);
if(std::string::npos != pos)
{
out1 = in.substr(0, pos);
out2 = in.substr(pos+sep.size());
}
}
int main()
{
std::string query_string;
GetQueryString(query_string); //a=100$b=200
std::string str1;
std::string str2;
CutString(query_string, "&", str1, str2); //"a=100", "b=200"
std::string name1;
std::string value1;
CutString(str1, "=", name1, value1); //"a", "100"
std::string name2;
std::string value2;
CutString(str2, "=", name2, value2); //"b", "200"
std::cerr << name1 << " : " << value1 << std::endl; //方便测试
std::cerr << name2 << " : " << value2 << std::endl;
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++等语言来完成
我们先需要把test_cgi.cc编译成可执行文件,然后再把可执行文件放到wwwroot下再进行访问:
②URL上传数据测试
如果第二个操作数是0,那么CGI在进行除法运算时会除0错误就会崩溃:
③表单上传数据测试
- 服务器一般会让用户通过表单来上传参数,HTML中的表单用于搜集用户的输入,我们可以通过设置表单的method属性来指定表单提交的方法,通过设置表单的action属性来指定表单需要提交给服务器上的哪一个CGI程序。
- 比如我们将/a/b/hello.html的内容改成以下HTML代码,指定将表单中的数据以GET方法提交给web根目录下的test_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">
操作数1:<br>
<input type="text" name="x"><br>
操作数2:<br>
<input type="text" name="y"><br><br>
<input type="submit" value="计算">
</form>
</body>
</html>
下面是测试结果:
9.3 实现成守护进程
守护进程的作用和实现这里不再赘述,之前也讲过:计算机网络(四) —— 简单Tcp网络程序-CSDN博客
下面是Daemon.hpp的代码:
#pragma once
#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <signal.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
const std::string nullfile = "/dev/null";
void Daemon(const std::string &cwd = "") // 不传参数的话就是默认把守护进程工作目录放到根目录去
{
// 1,守护进程需要忽略其它信信号
signal(SIGCLD, SIG_IGN); // 直接忽略17号信号,为了防止万一出现一些读端关掉了,写端还在写的情况,守护进程
signal(SIGPIPE, SIG_IGN); // 直接忽略13号信号
signal(SIGSTOP, SIG_IGN); // 忽略19号暂停信号
// 2,将自己变成独立的会话
if (fork() > 0)
exit(0); // 直接干掉父进程
setsid(); // 子进程自成会话
// 3,更改当前调用进程的工作目录
if (!cwd.empty())
chdir(cwd.c_str());
// 4,不能直接关闭三个输入流,打印时会出错,Linux中有一个/dev/null 字符文件,类似垃圾桶,所有往这个文件写的数据会被直接丢弃,读也读不到
// 所以可以把标准输入输出错误全部重定向到这个文件中
// 如果需要就往文件里写,反正不能再打印到屏幕上了
int fd = open(nullfile.c_str(), O_RDWR); // 以读写方式打开
if (fd > 0) // 打开成功
{
// 把三个默认流全部重定向到垃圾桶的null的套接字里去
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
}
}
然后在main.cc里添加守护进程函数即可:
十,源码
更多推荐
所有评论(0)